tips:这是一篇系列文章,总目录在这里哟~
到了在操作系统层面,可以依靠硬件产生的定时器中断做很多事情,同时,操作系统的定时器怎么实现呢?我们来分析一下。
1. 硬件定时器
现在的Linux对时间的管理是很复杂的,大体可以分为高精度时钟和低精度时钟。两者互不兼容。
在 Linux 2.6.16 之前,内核只支持低精度时钟。内核围绕着 tick 时钟来实现所有的时间相关功能。tick 是一个定期触发的中断,一般由 PIT 提供,大概 10ms 触发一次 (100HZ),精度比较低。如果频率设置的太高,就会严重影响系统性能。
(1) tick
以 x86 为例,系统初始化时会配置定时器中断。当硬件设备初始化完成后,便开始定期地产生中断,这便是 tick 了。需要强调的是 tick 中断是由硬件直接产生的真实中断。
那么,这个tick操作系统会拿它来做什么呢?
- Linux内核依赖 tick来进行分时,这是分时操作系统的硬件基础,也是多任务实现的基础。
- 维护系统时间。Linux 系统初始化时,读取 RTC,得到当前时间值。此后直到下次重新启动,Linux 不会再读取硬件 RTC 了。Linux通过tick来更新系统时间。
可以说tick和其他的中断就是Linux内核的驱动力。Linux内核并非是一堆持续运行的程序,内核的各个模块基本都是由中断驱动的。而中断可以包括硬件中断,可以是软件中断。
(2)进程调度
每个时钟中断(timer interrupt)发生时,需要由3个函数协同工作,共同完成进程的选择和切换:schedule()
、do_timer()
及ret_form_sys_call()
。
- schedule():进程调度函数,由它来完成进程的选择(调度)。
- do_timer():暂且称之为时钟函数,该函数在时钟中断服务程序中被调用,是时钟中断服务程序的主要组成部分,该函数被调用的频率就是时钟中断的频率即每秒钟100 次(简称100 赫兹或100Hz);由这个函数完成系统时间的更新、进程时间片的更新等工作,更新后的进程时间片counter 作为调度的主要依据。
- ret_from_sys_call():系统调用、异常及中断返回函数。当一个系统调用或中断完成时,该函数被调用,用于处理一些收尾工作,例如信号处理、核心任务等。函数检测need_resched 标志,如果此标志为非0,那么就调用调度程序schedule()进行进程的选择。调度程序schedule()会根据具体的标准在运行队列中选择下一个应该运行的进程。当从调度程序返回时,如果发现又有调度标志被设置,则又调用调度程序,直到调度标志为0,这时,从调度程序返回时由RESTORE_ALL恢复被选定进程的环境,返回到被选定进程的用户空间,使之得到运行。
(3)核心代码浅析
我们重点关注:硬件-->硬件控制-->进程控制
这部分内容。
Linux是如何控制8259芯片的?
在计算机的定时器章节中,我们知道了计算机有一个8259中断控制芯片,同时内存中会维护一个中断向量表。
硬盘的第一个扇区为主引导扇区,计算机加电后,执行BIOS程序初始化,然后从硬盘启动时,就会读取主引导扇区的程序执行。Linux系统会在主引导扇区写入一个bootsect.s
的程序,然后bootsect.s
通过调用到setup.s
的内容,进行一些系统初始化的设置。在setup.s
中会重新初始化8259A芯片,并且在header.s
中重新设置一张中断向量表。
bootsect.s
bootsect.s是引导程序,BIOS会把bootsect加载到0x7c00处开始执行。
bootsect首先会把自己搬移到0x90000处,然后继续执行,把setups加载到0x90200处。setups在硬盘中也有固定的位置,它位于第二个扇区开始的4个扇区内。然后利用BIOS中断0x13取磁盘参数表中当前引导盘的参数,并在屏幕上显示“Loading system…”。
bootsect继续执行,会把磁盘上挨着setups的system模块加载到0x10000的地方。随后是确定根文件系统的设备号。
最后长跳转到setup的开始处,开始执行setup程序。
汇编的程序大家读起来都比较费劲,就不全贴了。
! SYS_SIZE是要加载的系统模块长度,单位是节,16个字节为1节。0x3000就是196个字节
SYSSIZE = 0x3000
.globl begtext, begdata, begbss, endtext, enddata, endbss
.text
begtext:
.data
begdata:
.bss
begbss:
.text
SETUPLEN = 4 ! nr of setup-sectors
BOOTSEG = 0x07c0 ! 程序本来在这里
INITSEG = 0x9000 ! 我们把bootloader移到这里
SETUPSEG = 0x9020 ! setup程序从这里开始
SYSSEG = 0x1000 ! system loaded at 0x10000 (65536).
ENDSEG = SYSSEG + SYSSIZE ! where to stop loading
! .........省略
! 这句就是段间跳转指令,跳转执行setup程序
jmpi 0,SETUPSEG
! .........省略
sectors:
.word 0
msg1:
.byte 13,10
.ascii "Loading system ..."
.byte 13,10,13,10
.org 508
root_dev:
.word ROOT_DEV
boot_flag:
.word 0xAA55
.text
endtext:
.data
enddata:
.bss
endbss:
setup.s
setup顾名思义会做一些系统的初始设置。它会从BIOS的ROM中读取一些设置参数,并保存在对应的内存位置。
会重新设置两个中断控制芯片8259A,重新设置硬件中断号为0x20~0x2f。最后会转到system模块下开头部分的head.s继续执行。
! ...掐头
! well, that went ok, I hope. Now we have to reprogram the interrupts :-(
! we put them right after the intel-reserved hardware interrupts, at
! int 0x20-0x2F. There they won't mess up anything. Sadly IBM really
! messed this up with the original PC, and they haven't been able to
! rectify it afterwards. Thus the bios puts interrupts at 0x08-0x0f,
! which is used for the internal hardware interrupts as well. We just
! have to reprogram the 8259's, and it isn't fun.
mov al,#0x11 ! initialization sequence
out #0x20,al ! send it to 8259A-1
.word 0x00eb,0x00eb ! jmp $+2, jmp $+2
out #0xA0,al ! and to 8259A-2
.word 0x00eb,0x00eb
mov al,#0x20 ! start of hardware int's (0x20)
out #0x21,al
.word 0x00eb,0x00eb
mov al,#0x28 ! start of hardware int's 2 (0x28)
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0x04 ! 8259-1 is master
out #0x21,al
.word 0x00eb,0x00eb
mov al,#0x02 ! 8259-2 is slave
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0x01 ! 8086 mode for both
out #0x21,al
.word 0x00eb,0x00eb
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0xFF ! mask off all interrupts for now
out #0x21,al
.word 0x00eb,0x00eb
out #0xA1,al
! ...去尾
header.s
header会和其他程序一起链接成system模块,并位于system的头部。这段程序首先重新设置中段描述符表idt,使各项均指向一个只报错误的哑中断子程序ignore_int。中间还有一些处理,最后head.s程序利用返回指令将预先放置在堆栈中的/init/main.c程序的入口地址弹出,去运行main()程序。
main.c
main程序是整个内核初始化的过程。这里面做了很多工作,大部分都是调用其他的xxx.c
程序去处理。我们只关注定时器中断是怎么处理的。在上面headers中,所有的中断向量都是指向了ignore_int。在headers中,陆续的就会在需要的位置重新初始化中断向量。
在main.c的开头,我们可以看到这样一段逻辑:
// 内核初始化主程序。初始化结束后将以任务0(idle任务即空闲任务)的身份运行。
void main(void)
{
// ......省略一些代码
// 以下是内核进行所有方面的初始化工作
mem_init(main_memory_start,memory_end); // 主内存区初始化。mm/memory.c
trap_init(); // 陷阱门(硬件中断向量)初始化,kernel/traps.c
blk_dev_init(); // 块设备初始化,kernel/blk_drv/ll_rw_blk.c
chr_dev_init(); // 字符设备初始化, kernel/chr_drv/tty_io.c
tty_init(); // tty初始化, kernel/chr_drv/tty_io.c
time_init(); // 设置开机启动时间 startup_time
sched_init(); // 调度程序初始化(加载任务0的tr,ldtr)(kernel/sched.c)
// 缓冲管理初始化,建内存链表等。(fs/buffer.c)
buffer_init(buffer_memory_end);
hd_init(); // 硬盘初始化,kernel/blk_drv/hd.c
floppy_init(); // 软驱初始化,kernel/blk_drv/floppy.c
sti(); // 所有初始化工作都做完了,开启中断
// 下面过程通过在堆栈中设置的参数,利用中断返回指令启动任务0执行。
move_to_user_mode(); // 移到用户模式下执行
if (!fork()) { /* we count on this going ok */
init(); // 在新建的子进程(任务1)中执行。
}
// pause系统调用会把任务0转换成可中断等待状态,再执行调度函数。但是调度函数只要发现系统中
// 没有其他任务可以运行是就会切换到任务0,而不依赖于任务0的状态。
for(;;) pause();
}
就是各种初始化,其中有一个sched_init()
调度器的初始化。
sched.c
直接看调度器初始化的代码:
// 内核调度程序的初始化子程序
void sched_init(void)
{
// ......省略部分代码
// 下面代码用于初始化8253定时器。通道0,选择工作方式3,二进制计数方式。通道0的
// 输出引脚接在中断控制主芯片的IRQ0上,它每10毫秒发出一个IRQ0请求。LATCH是初始
// 定时计数值。
outb_p(0x36,0x43); /* binary, mode 3, LSB/MSB, ch 0 */
outb_p(LATCH & 0xff , 0x40); /* LSB */
outb(LATCH >> 8 , 0x40); /* MSB */
// 设置时钟中断处理程序句柄(设置时钟中断门)。修改中断控制器屏蔽码,允许时钟中断。
// 然后设置系统调用中断门。这两个设置中断描述符表IDT中描述符在宏定义在文件
// include/asm/system.h中。
set_intr_gate(0x20,&timer_interrupt);
outb(inb_p(0x21)&~0x01,0x21);
set_system_gate(0x80,&system_call);
}
set_intr_gate(0x20,&timer_interrupt);
这一句,就是我们要找的关键,现在是把timer_interrupt
作为了中断处理程序了。timer_interrupt是一段汇编代码,我们一起来看看:
### int32 - (int 0x20)时钟中断处理程序。中断频率被设置为100Hz。
# 定时芯片8253/8254是在kernel/sched.c中初始化的。因此这里jiffies每10 ms加1.
# 这段代码将jiffies增1,发送结束中断指令给8259控制器,然后用当前特权级作为
# 参数调用C函数do_timer(long CPL).当调用返回时转去检测并处理信号。
.align 2
timer_interrupt:
push %ds # save ds,es and put kernel data space
push %es # into them. %fs is used by _system_call
push %fs
pushl %edx # we save %eax,%ecx,%edx as gcc doesn't
pushl %ecx # save those across function calls. %ebx
pushl %ebx # is saved as we use that in ret_sys_call
pushl %eax
movl $0x10,%eax
mov %ax,%ds
mov %ax,%es
movl $0x17,%eax
mov %ax,%fs
incl jiffies
# 由于初始化中断控制芯片时没有采用自动EOI,所以这里需要发指令结束该硬件中断。
movb $0x20,%al # EOI to interrupt controller #1
outb %al,$0x20 # 操作命令字OCW2送0x20端口
# 下面从堆栈镇南关取出执行系统调用代码的选择符(CS段寄存器值)中的当前特权级别(0或3)
# 并压入堆栈,作为do_timer的参数。do_timer函数执行任务切换、计时等工作。
movl CS(%esp),%eax
andl $3,%eax # %eax is CPL (0 or 3, 0=supervisor)
pushl %eax
call do_timer # 'do_timer(long CPL)' does everything from
addl $4,%esp # task switching to accounting ...
jmp ret_from_sys_call
do_timer又做了什么呢:
/// 时钟中断C函数处理程序,在system_call.s中timer_interrupt被调用。
// 参数cpl是当前特权级0或3,是时钟中断发生时正在被执行的代码选择符中的特权级。
// cpl=0时表示中断发生时正在执行内核代码;cpl=3表示中断发生时正在执行用户代码。
// 对于一个进程由于执行时间片用完时,则进城任务切换。并执行一个计时更新工作。
void do_timer(long cpl)
{
extern int beepcount; // 扬声器发声滴答数
extern void sysbeepstop(void); // 关闭扬声器。
// 如果发声计数次数到,则关闭发声。(向0x61口发送命令,复位位0和1,位0
// 控制8253计数器2的工作,位1控制扬声器)
if (beepcount)
if (!--beepcount)
sysbeepstop();
// 如果当前特权级(cpl)为0,则将内核代码运行时间stime递增;
if (cpl)
current->utime++;
else
current->stime++;
// 如果有定时器存在,则将链表第1个定时器的值减1.如果已等于0,则调用相应的
// 处理程序,并将该处理程序指针置空。然后去掉该项定时器。next_timer是定时器
// 链表的头指针。
if (next_timer) {
next_timer->jiffies--;
while (next_timer && next_timer->jiffies <= 0) {
void (*fn)(void); // 这里插入了一个函数指针定义!!!! o(︶︿︶)o
fn = next_timer->fn;
next_timer->fn = NULL;
next_timer = next_timer->next;
(fn)(); // 调用处理函数
}
}
// 如果当前软盘控制器FDC的数字输出寄存器中马达启动位有置位的,则执行软盘定时程序
if (current_DOR & 0xf0)
do_floppy_timer();
// 如果进程运行时间还没完,则退出。否则置当前任务计数值为0.并且若发生时钟中断
// 正在内核代码中运行则返回,否则调用执行调度函数。
if ((--current->counter)>0) return;
current->counter=0;
if (!cpl) return; // 内核态程序不依赖counter值进行调度
schedule();
}
所以,所谓的时间片轮转法,知道是怎么回事了吧。所以,可以说操作系统的进程调度是时间驱动力的第三层体现。
2. 软件定时器
能够提供可编程定时中断的硬件电路都有一个缺点,即同时可以配置的定时器个数有限。但现代 Linux 系统中需要大量的定时器:内核自己需要使用 timer,比如内核驱动的某些操作需要等待一段给定的时间,或者 TCP 网络协议栈代码会需要大量 timer;内核还需要提供系统调用来支持 setitimer 和 POSIX timer。这意味着软件定时器的需求数量将大于硬件能够提供的 timer 个数,内核必须依靠软件 timer。
timer的软件实现:
- 通过timer链表实现,早起Linux就是这种方式。每次tick来临时,遍历链表,触发所有到期timer即可。但是遍历链表需要花费的时间不可控。
- 时间轮算法,Linux从2.4开始采用这种算法,时间负责度恒为O(1)。
3. 参考资料
- Linux内核时钟系统和定时器实现,by Walker
- 浅析 Linux 中的时间编程和实现原理,by kyle
- Linux下定时器的设计与实现,by Baixiangcpp
- 试谈Linux下的线程调度,by Gunjianpan
- 时间系统、进程的调度与切换,by s1mba
- 《Linux内核完全注释》,by 赵炯