自制操作系统12-定时器(1)

12 定时器(1)

2020.10.25

为了铺垫之后需要实现的多任务,今天先学习如何使用定时器。学会使用定时器可不简单,还要逐步优化定时器的中断处理。

1. 使用定时器

文档:harib09a

Timer(定时器):每隔一段时间就发送一个中断信号给CPU,有了Timer就不用CPU去辛苦计量时间了。

想知道如果操作系统没有定时器有多艰难的可以想象一下自己没有手表还想知道时间的话得怎么办,所以说定时器非常重要,管理定时器是操作系统的重要任务之一。

要使用定时器只需要对PIT(Programmable Interval Timer可编程的间隔型定时器)进行设定就可以了。

PIT(Programmable Interval Timer可编程的间隔型定时器):通过设定PIT就可以控制定时器每隔多少秒就产生一次中断。PIT连接着IRQ的0号,所以只要设定了PIT就可以设定IRQ0的中断间隔。在旧机种上,PIT是作为一个独立的芯片安装在主板上的,而现在已经和PIC(Programma Interrupt Controller)一样被集成到别的芯片上了。

IRQ0的中断周期变更:

  • ​ AL=0x34:OUT(0x43,AL);
  • ​ AL=中断周期的低8位; OUT(0x40,AL);
  • ​ AL=中断周期的高8位; OUT(0x40,AL);

到这里告一段落。
如果指定中断周期为0,会被看作是指定为65536。实际的中断产生的频率是单位时间时钟周期数(即主频)/设定的数值。比如设定值如果是1000,那么中断产生的频率就是1.19318KHz。设定值是10000的话,中断产生频率就是119.318Hz。再比如设定值是11932的话,中断产生的频率大约就是100Hz了,即每10ms发生一次中断。

这里我们将中断频率设为100Hz,也就是一秒会发送100次中断,把11932转换成16进制就是0x2e9c,

下面是PIT的初始化函数:

//本次的timer.c节选

#define PIT_CTRL 0x0043
#define PIT_CNT0 0x0040

void init_pit(void)
{
    io_out8(PIT_CTRL, 0x34);
    io_out8(PIT_CNT0, 0x9c);
    io_out8(PIT_CNT0, 0x2e);
    return;
}
//本次的bootback.c节选

void HariMain(void)
{
    //(中略)
        
    init_gdtidt();
    init_pic();
    io_sti(); /* IDT/PIC的初始化已经结束,所以解除CPU的中断禁止*/
    fifo8_init(&keyfifo, 32, keybuf);
    fifo8_init(&mousefifo, 128, mousebuf);
    init_pit(); /* 这里! */
    io_out8(PIC0_IMR, 0xf8); /* PIT和PIC1和键盘设置为许可(11111000) */ /* 这里! */
    io_out8(PIC1_IMR, 0xef); /* 鼠标设置为许可(11101111) */
    
    //(中略)
}

现在IRQ0就会在1s内产生100次中断了。

下面编写IRQ0发生时所调用的中断处理程序,几乎和键盘中断处理程序一样

//本次的timer.c节选

void inthandler20(int *esp)
{
    io_out8(PIC0_OCW2, 0x60); /* 把IRQ-00信号接收完了的信息通知给PIC */
    /* 暂时什么也不做 */
    return;
}
//本次的naskfunc.nas节选

_asm_inthandler20:
        PUSH ES
        PUSH DS
        PUSHAD
        MOV EAX,ESP
        PUSH EAX
        MOV AX,SS
        MOV DS,AX
        MOV ES,AX
        CALL _inthandler20
        POP EAX
        POPAD
        POP DS
        POP ES
        IRETD

还要把这个中断处理程序注册到IDT里,init_gdtidt函数也要加上几行

//本次的dsctbl.c节选

void init_gdtidt(void)
{
    //(中略)
    
    /* IDT的设定 */
    set_gatedesc(idt + 0x20, (int) asm_inthandler20, 2 * 8, AR_INTGATE32); /* 这里! */
    set_gatedesc(idt + 0x21, (int) asm_inthandler21, 2 * 8, AR_INTGATE32);
    set_gatedesc(idt + 0x27, (int) asm_inthandler27, 2 * 8, AR_INTGATE32);
    set_gatedesc(idt + 0x2c, (int) asm_inthandler2c, 2 * 8, AR_INTGATE32);
    
    return;
}

计量时间

文档:harib09b

下面往中断处理程序中加点内容来让它干点什么,就让它来计量时间吧。

//本次的bootback.h节选

struct TIMERCTL {
    unsigned int count;
};
//本次的timer.c节选

struct TIMERCTL timerctl;

void init_pit(void)
{
    io_out8(PIT_CTRL, 0x34);
    io_out8(PIT_CNT0, 0x9c);
    io_out8(PIT_CNT0, 0x2e);
    timerctl.count = 0; /* 这里! */
    return;
}

void inthandler20(int *esp)
{
    io_out8(PIC0_OCW2, 0x60); /* 把IRQ-00信号接收完了的信息通知给PIC */
    timerctl.count++; /* 这里! */
    return;
}

首先定义了struct TIMECTL结构体,在结构体内定义了一个计算变量,在初始化PIT时,就将这个计数变量设置为0,每次发送定时器中断就让count+1。也就是说这个变量在HariMain不进行加算每秒钟也会增加100。

把count显示出来

//本次的bootback.c节选

void HariMain(void)
{
    (中略)
        
    for (;;) {
        sprintf(s, "%010d", timerctl.count); /* 这里! */
        boxfill8(buf_win, 160, COL8_C6C6C6, 40, 28, 119, 43);
        putfonts8_asc(buf_win, 160, 40, 28, COL8_000000, s);
        sheet_refresh(sht_win, 40, 28, 120, 44);
        (中略)
    }
}

中断设置好了频率,就算你是天河1号来跑数字的增加速度都是一样的。

下面测试一下

image-20201028111039699

现在能知道从启动开始时间过去了多久了,操作系统开始有了实用价值,比如泡面时拿来计时,嘻嘻。


2. 超时功能

文档:harib09c

现在已经实现了显示窗口,能使用鼠标,能计量时间,还能进行内存管理。将这些功能组合起来能实现很多事情,已经有点操作系统的样子了。

回到定时器上,操作系统的定时器经常被用于这样一种情形:“喂,操作系统小兄弟,过了10秒钟以后通知我一声,我要干什么什么”。当然,不一定非要是10秒,也可以是1秒或30分钟。我们把这样的功能叫做“超时”(timeout)。下面就来实现这个功能吧。

首先往结构体struct TIMERCTL添加记录有关超时的变量

//本次的bootback.h节选

struct TIMERCTL {
    unsigned int count;
    unsigned int timeout;
    struct FIFO8 *fifo;
    unsigned char data;
};

以上结构体中的timeout用来记录离超时还有多长时间。一旦这个剩余时间达到0,程序就往FIFO缓冲区里发送数据。定时器就是通过这种方法通知HariMain时间到了。

修改相应的函数

//本次的timer.c节选

void init_pit(void)
{
    io_out8(PIT_CTRL, 0x34);
    io_out8(PIT_CNT0, 0x9c);
    io_out8(PIT_CNT0, 0x2e);
    timerctl.count = 0;
    timerctl.timeout = 0;
    return;
}

void inthandler20(int *esp)
{
    io_out8(PIC0_OCW2, 0x60); /* 把IRQ-00信号接收结束的信息通知给PIC */
    timerctl.count++;
    if (timerctl.timeout > 0) { /* 如果已经设定了超时 */
        timerctl.timeout--;
        if (timerctl.timeout == 0) {
        	fifo8_put(timerctl.fifo, timerctl.data);
        }
    }
    return;
}

void settimer(unsigned int timeout, struct FIFO8 *fifo, unsigned char data)
{
    int eflags;
    eflags = io_load_eflags();
    io_cli();
    timerctl.timeout = timeout;
    timerctl.fifo = fifo;
    timerctl.data = data;
    io_store_eflags(eflags);
    return;
}

在inthandler20函数里实现了超时功能,发送中断时timeout减1,减到0时就向fifo发送数据通知HariMain。

在settimer函数里如果设定还没有结束IRQ0就进来会发生混乱,所以要在设定前关中断,设定完再开中断。

修改HariMain函数

//本次的bootback.c节选

void HariMain(void)
{
    (中略)
        
    struct FIFO8 timerfifo;
    char s[40], keybuf[32], mousebuf[128], timerbuf[8];
    
    (中略)
        
    fifo8_init(&timerfifo, 8, timerbuf);
    settimer(1000, &timerfifo, 1);
    
    (中略)
        
    for (;;) {
        (中略)
        io_cli();
        if (fifo8_status(&keyfifo) + fifo8_status(&mousefifo) + fifo8_status(&timerfifo) == 0) io_sti();
        } else {
            if (fifo8_status(&keyfifo) != 0) {
            	(中略)
            } else if (fifo8_status(&mousefifo) != 0) {
            	(中略)
            } else if (fifo8_status(&timerfifo) != 0) {
                i = fifo8_get(&timerfifo); /* 首先读入(为了设定起始点) */
                io_sti();
                putfonts8_asc(buf_back, binfo->scrnx, 0, 64, COL8_FFFFFF, "10[sec]");
                sheet_refresh(sht_back, 0, 64, 56, 80);
            }
        }
    }
}

程序很简单,timerfifo接收到数据就显示"10[sec]"。

测试程序

image-20201028133256353

3. 设定多个定时器

文档:harib09d

超时功能我们还可以用来实现很多不同的功能。比如电子时钟每隔1s重新显示一次,演奏音乐计量音符的长短,设定为0.5的间隔实现的光标闪烁功能。

为了实现这么多不同的功能,我们需要设定很多的超时定时器。

首先修改struct TIMERCTL

//本次的bootback.h节选

#define MAX_TIMER 500

struct TIMER {
    unsigned int timeout, flags;
    struct FIFO8 *fifo;
    unsigned char data;
};

struct TIMERCTL {
    unsigned int count;
    struct TIMER timer[MAX_TIMER];
};

最多可以设定500个,flags用于记录各个定时器的状态。

修改相应函数

//本次的timer.c节选

#define TIMER_FLAGS_ALLOC 1 /* 已配置状态 */
#define TIMER_FLAGS_USING 2 /* 定时器运行中 */

void init_pit(void)
{
    int i;
    io_out8(PIT_CTRL, 0x34);
    io_out8(PIT_CNT0, 0x9c);
    io_out8(PIT_CNT0, 0x2e);
    timerctl.count = 0;
    for (i = 0; i < MAX_TIMER; i++) {
    	timerctl.timer[i].flags = 0; /* 未使用 */
    }
    return;
}

struct TIMER *timer_alloc(void)
{
    int i;
    for (i = 0; i < MAX_TIMER; i++) {
        if (timerctl.timer[i].flags == 0) {
        	timerctl.timer[i].flags = TIMER_FLAGS_ALLOC;
        	return &timerctl.timer[i];
        }
    }
    return 0; /* 没找到 */
}

void timer_free(struct TIMER *timer)
{
    timer->flags = 0; /* 未使用 */
    return;
}

void timer_init(struct TIMER *timer, struct FIFO8 *fifo, unsigned char data)
{
    timer->fifo = fifo;
    timer->data = data;
    return;
}

void timer_settime(struct TIMER *timer, unsigned int timeout)
{
    timer->timeout = timeout;
    timer->flags = TIMER_FLAGS_USING;
    return;
}

void inthandler20(int *esp)
{
    int i;
    io_out8(PIC0_OCW2, 0x60); /* 把IRQ-00信号接收结束的信息通知给PIC*/
    timerctl.count++;
    for (i = 0; i < MAX_TIMER; i++) {
        if (timerctl.timer[i].flags == TIMER_FLAGS_USING) {
            timerctl.timer[i].timeout--;
            if (timerctl.timer[i].timeout == 0) {
            	timerctl.timer[i].flags = TIMER_FLAGS_ALLOC;
            	fifo8_put(timerctl.timer[i].fifo, timerctl.timer[i].data);
            }
        }
    }
    return;
}

懂得都懂

修改HariMain

//本次的bootback.c节选

void HariMain(void)
{
    (中略)
        
    struct FIFO8 timerfifo, timerfifo2, timerfifo3;
    char s[40], keybuf[32], mousebuf[128], timerbuf[8], timerbuf2[8], timerbuf3[8];
    struct TIMER *timer, *timer2, *timer3;
    
    (中略)
        
    fifo8_init(&timerfifo, 8, timerbuf);
    timer = timer_alloc();
    timer_init(timer, &timerfifo, 1);
    timer_settime(timer, 1000);
    fifo8_init(&timerfifo2, 8, timerbuf2);
    timer2 = timer_alloc();
    timer_init(timer2, &timerfifo2, 1);
    timer_settime(timer2, 300);
    fifo8_init(&timerfifo3, 8, timerbuf3);
    timer3 = timer_alloc();
    timer_init(timer3, &timerfifo3, 1);
    timer_settime(timer3, 50);
    
    (中略)
    for (;;) {
        (中略)
            
        io_cli();
        if (fifo8_status(&keyfifo) + fifo8_status(&mousefifo) + fifo8_status(&timerfifo) + ifo8_status(&timerfifo2) + fifo8_status(&timerfifo3) == 0) {
            io_sti();
        } else {
            if (fifo8_status(&keyfifo) != 0) {
            	(中略)
            } else if (fifo8_status(&mousefifo) != 0) {
            	(中略)
            } else if (fifo8_status(&timerfifo) != 0) {
                i = fifo8_get(&timerfifo); /* 首先读入(为了设定起始点) */
                io_sti();
                putfonts8_asc(buf_back, binfo->scrnx, 0, 64, COL8_FFFFFF, "10[sec]");
                sheet_refresh(sht_back, 0, 64, 56, 80);
            } else if (fifo8_status(&timerfifo2) != 0) {
                i = fifo8_get(&timerfifo2); /* 首先读入(为了设定起始点) */
                io_sti();
                putfonts8_asc(buf_back, binfo->scrnx, 0, 80, COL8_FFFFFF, "3[sec]");
                sheet_refresh(sht_back, 0, 80, 48, 96);
            } else if (fifo8_status(&timerfifo3) != 0) { /* 模拟光标 */
                i = fifo8_get(&timerfifo3);
                io_sti();
                if (i != 0) {
                timer_init(timer3, &timerfifo3, 0); /* 然后设置0 */
                boxfill8(buf_back, binfo->scrnx, COL8_FFFFFF, 8, 96, 15, 111);
            	} else {
                	timer_init(timer3, &timerfifo3, 1); /* 然后设置1 */
                	boxfill8(buf_back, binfo->scrnx, COL8_008484, 8, 96, 15, 111);
            	}
            	timer_settime(timer3, 50);
            	sheet_refresh(sht_back, 8, 96, 16, 112);
            }
        }
    }
}

测试程序

image-20201028135423406

顺利执行

4. 加快中断处理(1)

文档:harib09e

现在的inthandler20函数每次进行定时器中断处理的时候都要对所有活动中定时器的count–,要完成从内存中读取变量值,减1,再写回内存的操作。这种细微之处,对于中断处理程序都需要多加注意。

改变了timer[i].timeout的含义,从“所剩时间”变为“予定时刻”,拿计数值count和timeout比较就行了。

//本次的timer.c节选

void inthandler20(int *esp)
{
    int i;
    io_out8(PIC0_OCW2, 0x60); /* 把IRQ-00信号接收结束的信息通知给PIC */
    timerctl.count++;
    for (i = 0; i < MAX_TIMER; i++) {
        if (timerctl.timer[i].flags == TIMER_FLAGS_USING) {
            if (timerctl.timer[i].timeout <= timerctl.count) { /* 这里! */
            	timerctl.timer[i].flags = TIMER_FLAGS_ALLOC;
            	fifo8_put(timerctl.timer[i].fifo, timerctl.timer[i].data);
            }
        }
    }
    return;
}

修改相应函数

//本次的timer.c节选

void timer_settime(struct TIMER *timer, unsigned int timeout)
{
    timer->timeout = timeout + timerctl.count; /* 这里! */
    timer->flags = TIMER_FLAGS_USING;
    return;
}

使用这种方式后,经过42949673s后count就达到上限0xffffffff,然后就不能再设定了。大约是497天。。。

为了让操作系统不需要重新启动,加一个时刻调整程序

    int t0 = timerctl.count; /* 所有时刻都要减去这个值 */
    io_cli(); /* 在时刻调整时禁止定时器中断 */
    timerctl.count -= t0;
    for (i = 0; i < MAX_TIMER; i++) {
        if (timerctl.timer[i].flags == TIMER_FLAGS_USING) {
        	timerctl.timer[i].timeout -= t0;
        }
    } 
	io_sti();

加快中断处理(2)

文档:harib09f

继续观察原来的中断处理程序

//改善前的timer.c节选

void inthandler20(int *esp)
{
    int i;
    io_out8(PIC0_OCW2, 0x60); /* 把IRQ-00信号接收结束的信息通知给PIC */
    timerctl.count++;
    for (i = 0; i < MAX_TIMER; i++) {
        if (timerctl.timer[i].flags == TIMER_FLAGS_USING) {
            if (timerctl.timer[i].timeout <= timerctl.count) {
            	timerctl.timer[i].flags = TIMER_FLAGS_ALLOC;
            	fifo8_put(timerctl.timer[i].fifo, timerctl.timer[i].data);
            }
        }
    }
    return;
}

每次中断都要执行500次if语句,每秒100次中断,没秒就要执行5w次if语句。每0.5秒一次if为真的话,其余的49998次if语句都在做无用功。

想想如果我们面对着一堆时间不一的定时炸弹的话,有2天爆炸的,有5天爆炸的,和100天爆炸的。我们肯定会优先关注2天就爆的炸弹,因为2天的没爆其他的肯定也轮不到。我们定时器也可以使用这种思路,只关心最快先超时的定时器,没必要把500个都看完。因此我们追加一个timerctl.next,让它记住下一个时刻。

//本次的bootpack.h节选

struct TIMERCTL {
    unsigned int count, next; /* 这里! */
    struct TIMER timer[MAX_TIMER];
};
//本次的timer.c节选

void inthandler20(int *esp)
{
    int i;
    io_out8(PIC0_OCW2, 0x60); /* 把IRQ-00信号接收结束的信息通知给PIC */
    timerctl.count++;
    if (timerctl.next > timerctl.count) {
    	return; /* 还不到下一个时刻,所以结束*/
    }
    timerctl.next = 0xffffffff;
    for (i = 0; i < MAX_TIMER; i++) {
        if (timerctl.timer[i].flags == TIMER_FLAGS_USING) {
            if (timerctl.timer[i].timeout <= timerctl.count) {
            	/* 超时 */
            	timerctl.timer[i].flags = TIMER_FLAGS_ALLOC;
            	fifo8_put(timerctl.timer[i].fifo, timerctl.timer[i].data);
            } else {
            	/* 还没有超时 */
            	if (timerctl.next > timerctl.timer[i].timeout) {
            		timerctl.next = timerctl.timer[i].timeout;
            	}
            }
        }
    }
    return;
}

虽然程序看起来变长了,但是要做的处理减少了。在大多数情况下,第一个if语句的return都会执行,中断处理就到此结束了。当到达下一个时刻时,使用之前那种方法检查是否超时。超时的话,就写入到FIFO中;还没超时的话就调查是否将其设定为下一个时刻(未超时时刻中,最小的时刻是下一个时刻)

使用了next,其他函数也要修改

//本次的timer.c节选

void init_pit(void)
{
    int i;
    io_out8(PIT_CTRL, 0x34);
    io_out8(PIT_CNT0, 0x9c);
    io_out8(PIT_CNT0, 0x2e);
    timerctl.count = 0;
    timerctl.next = 0xffffffff; /* 因为最初没有正在运行的定时器 */
    for (i = 0; i < MAX_TIMER; i++) {
    	timerctl.timer[i].flags = 0; /* 没有使用 */
    }
    return;
}

void timer_settime(struct TIMER *timer, unsigned int timeout)
{
    timer->timeout = timeout + timerctl.count;
    timer->flags = TIMER_FLAGS_USING;
    if (timerctl.next > timer->timeout) {
    	/* 更新下一次的时刻 */
    	timerctl.next = timer->timeout;
    }
    return;
}

加快中断处理(3)

文档:harib09g

到了harib09f的时候,中断处理程序的平均处理时间已经大大缩短了。这真是太好了。可是,现在有一个问题,那就是到达next时刻和没到next时刻的定时器中断,它们的处理时间差别很大。这样的程序结构不好。因为平常运行一直都很快的程序,会偶尔由于中断处理拖得太长,而搞得像是主程序要停了似的。更确切一点,这样有时会让人觉得“不知为什么,鼠标偶尔会反应迟钝,很卡。”

因此,我们要让到达next时刻的定时器中断的处理时间再缩短一些。嗯,怎么办呢?模仿sheet.c的做法怎么样呢?我们来试试看。

在sheet.c的结构体struct SHTCTL中,除了sheet0[ ]以外,我们还定义了*sheets[ ]。它里面存放的是按某种顺序排好的图层地址。有了这个变量,按顺序描绘图层就简单了。这次我们在Struct TIMERCTL中也定义一个变量,其中存放按某种顺序排好的定时器地址。

//本次的bootpack.h节选

struct TIMERCTL {
    unsigned int count, next, using;
    struct TIMER *timers[MAX_TIMER];
    struct TIMER timers0[MAX_TIMER];
};

变量using相当于struct SHTCTL中的top,它用于记录现在的定时器中有几个处于活动中。

//本次的timer.c节选

void inthandler20(int *esp)
{
    int i, j;
    io_out8(PIC0_OCW2, 0x60); /* 把IRQ-00信号接收结束的信息通知给PIC */
    timerctl.count++;
    if (timerctl.next > timerctl.count) {
    	return;
    }
    for (i = 0; i < timerctl.using; i++) {
        /* timers的定时器都处于动作中,所以不确认flags */
        if (timerctl.timers[i]->timeout > timerctl.count) {
        	break;
    	}
        /* 超时*/
        timerctl.timers[i]->flags = TIMER_FLAGS_ALLOC;
        fifo8_put(timerctl.timers[i]->fifo, timerctl.timers[i]->data);
    }
    /* 正好有i个定时器超时了。其余的进行移位。 */
    timerctl.using -= i;
    for (j = 0; j < timerctl.using; j++) {
        timerctl.timers[j] = timerctl.timers[i + j];
    }
    if (timerctl.using > 0) {
        timerctl.next = timerctl.timers[0]->timeout;
    } else {
        timerctl.next = 0xffffffff;
    }
    return;
}
void init_pit(void)
{
    int i;
    io_out8(PIT_CTRL, 0x34);
    io_out8(PIT_CNT0, 0x9c);
    io_out8(PIT_CNT0, 0x2e);
    timerctl.count = 0;
    timerctl.next = 0xffffffff; /* 因为最初没有正在运行的定时器 */
    timerctl.using = 0;
    for (i = 0; i < MAX_TIMER; i++) {
        timerctl.timers0[i].flags = 0; /* 未使用 */
    }
    return;
}

struct TIMER *timer_alloc(void)
{
    int i;
    for (i = 0; i < MAX_TIMER; i++) {
        if (timerctl.timers0[i].flags == 0) {
            timerctl.timers0[i].flags = TIMER_FLAGS_ALLOC;
            return &timerctl.timers0[i];
        }
    }
    return 0; /* 没找到 */
}

这两个函数比较简单,只是稍稍修改了一下变量名。

在timer_settime函数中,必须将timer注册到timers中去,而且要注册到正确的位置。如果在注册时发生中断的话可就麻烦了,所以我们要事先关闭中断。

void timer_settime(struct TIMER *timer, unsigned int timeout)
{
    int e, i, j;
    timer->timeout = timeout + timerctl.count;
    timer->flags = TIMER_FLAGS_USING;
    e = io_load_eflags();
    io_cli();
    /* 搜索注册位置 */
    for (i = 0; i < timerctl.using; i++) {
        if (timerctl.timers[i]->timeout >= timer->timeout) {
            break;
        }
    }
    /* i号之后全部后移一位 */
    for (j = timerctl.using; j > i; j--) {
        timerctl.timers[j] = timerctl.timers[j - 1];
    }
    timerctl.using++;
    /* 插入到空位上 */
    timerctl.timers[i] = timer;
    timerctl.next = timerctl.timers[0]->timeout;
    io_store_eflags(e);
    return;
}

这样做看来不错。虽然中断处理程序速度已经提高了,但在设定定时器期间,我们关闭了中断,这多少有些令人遗憾。不过就算对此不满意,也不要随便更改哦。

从某种程度上来讲,这也是无法避免的事。如果在设定时,多下点工夫整理一下,到达中断时刻时就能轻松一些了。反之,如果在设定时偷点懒,那么到达中断时刻时就要吃点苦头了。总之,要么提前做好准备,要么临时抱佛脚。究竟哪种做法好呢,要根据情况而定。(哲理起来了,泪目)

明天我们再继续修改定时器!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Lor :)

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值