操作系统对定时器的应用

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. 参考资料

  1. Linux内核时钟系统和定时器实现,by Walker
  2. 浅析 Linux 中的时间编程和实现原理,by kyle
  3. Linux下定时器的设计与实现,by Baixiangcpp
  4. 试谈Linux下的线程调度,by Gunjianpan
  5. 时间系统、进程的调度与切换,by s1mba
  6. 《Linux内核完全注释》,by 赵炯
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值