4.9一个简单的多任务内核实例
第四章第9节
本节描述了一个简单多任务内核的设计和实现方法,这个内核包括两个特权级3的用户任务和一个系统调用中断过程。
本节给出的内核实例由两个文件构成。一个是使用as86语言编制的引导启动程序boot.s,用于在计算机加电时从启动盘上把内核代码加载到内存中;另一个是使用GUN as汇编语言编制的内核程序head.s,其中实现了2个运行在特权级3上的任务在时钟中断控制下相互切换运行,并且还实现了在屏幕上显示字符的一个系统调用。我们把这两个任务分别称为任务A和任务B,它们会分别调用这个系统调用在屏幕上输出字符'A'和'B',直到每隔10毫秒切换至另一个任务,任务A连续循环的调用系统调用在屏幕上输出'A',而任务B一直显示'B'。如要终止这个内核实例程序,则要重新启动机器,或者关闭运行的模拟PC运行环境软件。
boot.s程序编绎出的代码共512字节,将被存放在软盘映像文件的第一个扇区中,PC在加电启动时,ROM BIOS中的程序会把启动盘第一个扇区加载到物理内存0X7C00(31kb)位置开始出,并把执行权限转移到0X7C00处开始执行boot程序代码。head.s程序运行在32位保护模式下,其功能主要包括:初始化设置代码、时钟中断0X08的过程代码、系统调用中断0X08的过程代码以及任务A和任务B等的代码和数据。初始化设置工作主要包括:1.重新设置GDT表 2.设置系统定时器芯片 3.重新设置IDT表并且设置时钟和系统调用中断门 4.移动到任务A中执行。
由于特权级0的代码不能直接把控制权转移到特权级3的代码中去,但是中断操作是可以的,因此当初始化GDT,IDT和定时芯片结束后,我们就利用中断返回指令IRET来启动运行第一个任务。具体实现方法是在初始堆栈init_stack中人工设置一个返回环境,即把任务0的TSS段选择符加载到任务寄存器LTR中、LDT段选择符加载到LDTR中以后,把任务0的用户栈指针(0x17:init_stack)和代码指针(0x0f:task0)以及标志寄存器的值压入栈中,然后执行中断返回指令IRET。该指令会弹出堆栈上的堆栈指针作为任务0的用户栈指针恢复假设的任务0的标志寄存器的内容,并且弹出堆栈中的代码指针放入CS:EIP寄存器中,从而开始执行任务0的代码,完成了从特权级0到特权级3代码的控制转移。
为了每隔10毫秒切换运行的任务,head.s程序中把定时器芯片8253的通道0设置成每隔10毫秒就向中断控制器8259A发送一个时钟中断请求信号,PC机的ROM BIOS开机时已经在8259A中把时钟中断请求信号设置成中断向量8,因此需要在中断8的处理过程中执行任务切换操作,任务切换的方法是查看current变量中当前运行的任务号,如果current是0,就利用任务1的TSS选择符作为操作数执行远跳转指令,从而切换到任务1中执行,否则反之。
每个任务在执行时,会首先把一个字符的ACII码放入寄存器AL中,然后调用系统中断调用int 0x80,该系统调用处理过程则会调用一个简单的字符写屏子程序,把AL中的字符显示在屏幕上,同时把字符显示的屏幕的下一个位置记录下来,用于下一次显示字符。在显示过一个字符后,任务代码会使用循环语句延迟一段时间,然后又跳转到任务代码开始处继续循环执行,知道运行了10毫秒而发生了定时中断,切换到另一个任务中去执行。对于任务A,寄存器AL中始终存放字符'A',而任务B运行时AL中始终存放字符'B',因此在程序运行时我们会看到一连串的字符'A'和一连串的字符'B'不断的显示在屏幕上。
下面给出boot.s和head.s程序的详细注释。有关这个简单内核实例的编译和运行方法参考最后一章“编译运行简单内核实例程序”一节的内容。
4.9.2 引导启动程序boot.s
为了让程序尽量简单,这个引导扇区启动程序仅能够加载长度不超过16个扇区的head代码,并且直接使用了ROM BIOS默认设置的中断向量号,即定时中断请求处理的中断号仍然是8,这与linux系统中使用的不同。linux系统会在内核初始化时重新设置8259A中断控制芯片,并把时钟中断请求信号对应到中断0x20上,详细说明见“内核引导启动程序”一章内容。
! boot.s程序 ! 首先利用BIOS中断把内核代码(head.s)加载到内存0x10000处,然后移动到内存0处 ! 最后进入保护模式,并跳转到内存0(head.s)开始出继续运行。 BOOTSEG = 0X07C0 !引导扇区(本程序)被BIOS加载到内存0X7C00处 SYSSEG = 0X1000 !内核(head)先加载到0X10000处,然后移动到0X0处 SYSLEN = 17 !内核占用的最大磁盘扇区数 entry start start: jmpi go,#BOOTSEG !段间跳转至0x7c0:go处。当本程序刚运行时所有段寄存器的值均为0.该 !跳转语句会把CS寄存器加载为0x7c0 go: mov ax,cs !让DS和SS都指向0X7C0段 mov ds,ax mov ss,ax mov sp,#0x400 !设置临时栈指针,其值需大于程序末端并有一定的空间即可 !加载内核代码到内存0x10000开始处 load_system: mov dx,#0x0000 !利用BIOS中断int 0x13功能2从启动盘读取head代码。 mov cx,#0x0002 ! DH - 磁头号;DL - 驱动器号; CH - 10位磁道号低8位; mov ax,#SYSSEG !CL - 位7,6是磁道号高2位,位5-0是起始扇区号(从1记). mov es,ax !ES:BX - 读入缓冲区位置(0x1000:0x0000)。 xor bx,bx mov ax,#0x200+SYSLEN !AH - 读扇区功能号;AL - 需读的扇区数(17) int 0x13 jnc ok_load !若没有发生错误则跳转继续运行,否则死循环 die: jmp die !把内核代码移动到内存0开始出,共移动8KB字节(内核长度不超过8KB) ok_load: cli ! 关中断 mov ax, #SYSSEG !移动开始位置 DS:SI = 0X1000:0 目的位置ES:DI=0:0. mov ds, ax xor ax, ax mov es, ax mov cx, #0X1000 sub si, si sub di, di rep movw ! 执行重复移动指令 ! 加载 IDT 和 GDT基地址寄存器 IDTR 和 GDTR mov ax, #BOOTSEG mov ds, ax ! 让DS重新指向 0x7c0段 lidt idt_48 ! 加载IDTR.6字节操作数,2字节表长度,4字节线性基地址 lgdt gdt_48 ! 加载GDTR.6字节操作数,2字节表长度,4字节线性基地址。 ! 设置控制寄存器CR0(即及其状态字),进入保护模式。段选择符8对应GDT表中第2个段描述符 mov ax, #0x0001 ! 在CR0中设置保护模式标志PE(位0) lmsw ax jmpi 0,8 ! 然后跳转至段选择符指定的段中,偏移0处。 ! 注意此时段值已是段选择符,该段的线性基地址是0 ! 下面是全局描述符表GDT的内容,其中包含3个段描述符。第一个不用,第二个是代码和数据段描述 ! 符 gdt: .word 0,0,0,0 ! 段描述符0,不用,每个描述符占8个字节 .word 0x07FF ! 段描述符1. 8MB 段限长=2047(2048*4096=8MB) .word 0X0000 ! 段基地址=0x00000 .word 0X9A00 ! 是代码段,可读/执行 .word 0X00C0 ! 段属性颗粒度=4KB, 80386 .word 0x07FF !段描述符2.8MB 段限长值=2047 (2048*4096=8MB) .word 0x0000 ! 段基地址=0x00000 .word 0x9200 ! 是数据段,可读写 .word 0x00c0 ! 段属性科类度=4KB,80386 ! 下面分别是LIDT和LGDT指令的6字节操作数 idt_48: .word 0 ! IDT表长度是0 .word 0,0 ! IDT表的线性基地址也是0 gdt_48: .word 0x7ff ! GDT 表长度是2048字节,可容纳256个描述符项 .word 0x7c00+gdt, 0 ! GDT 表的线性基地址在0x7c0段的偏移gdt处 .org 510 !.org命令的作用等同于给'.'赋值,即是使当前程序定位在510字节处 .word 0XAA55 ! 引导扇区有效标志,必须处于引导扇区最后2字节处
4.9.3 多任务内核程序 head.s
在进入保护模式后,head.s重新建立和设置IDT、GDT表的主要原因是为了让程序在结构上比较清晰,也为了与后面linux 0.11内核源代码中这两个表的设置方式保持一致。
#head.s 包含32位保护模式初始化设置代码、时钟中断代码、系统调用中断代码和两个任务的代码 #在初始化完成之后程序移动到任务0开始执行,并在时钟中断控制下进行任务0和任务1之间的切换操作 LATCH = 11930 #定时器出事计数值,即每隔10毫秒发送一次中断请求 SCRN_SEL = 0X18 #屏幕显示内存段选择符 TSS0_SEL = 0X20 #任务0的TSS段选择符 LDT0_SEL = 0X28 #任务0的LDT段选择符 TSS1_SEL = 0X30 #任务1的TSS段选择符 LDT1_SEL = 0X38 #任务1的LDT段选择符 .text startup_32: #首先加载数据段寄存器DS、堆栈寄存器SS和堆栈指针ESP。所有段的线性基地址都是0 movl $0x10, %eax #0x10是GDT中数据段选择符 mov %ax, %ds lss init_stack, %esp #lss命令同时给SS和ESP赋值,高16位赋给SS,低16位赋给ESP #在新的位置重新设置IDT和GDT表 call setup_idt #设置IDT,先把256个中断门都填默认处理过程的描述符 call setup_gdt #设置GDT movl $0x10, %eax #在改变了GDT之后重新加载所有段寄存器 mov %ax,%ds mov %ax,%es mov %ax,%fs mov %ax,%gs lss init_stack,%esp #设置8253定时芯片,把计数器通道0设置成每隔10户毫秒向中断控制器发送一个中断请信号 movb $0x36, %al #控制字:设置通道0工作在方式3,计数初值采用二进制 movl $0x43, %edx #8253芯片控制字寄存器写端口 outb %al, %dx movl $LATCH, %eax #初始计数值设置为LATCH(1193180/100),即频率100HZ movl $0x40, %edx #通道0的端口 outb %al, %dx #分两次把初始计数值写入通道0 movb %ah, %al outb %al, %dx #在IDT表第8和第128项处分别设置定时中断门描述符和系统调用陷阱门描述符 movl $0x00080000, %eax #中断程序属内核,即EAX高字是内核代码选择符0x0008 movw $timer_interrupt, %ax #设置定时中断们描述符,取定时中断处理程序地址 movw $0x8e00, %dx #中断门类型是14(屏蔽中断),特权级0或硬件使用 movl $0x08, %ecx #开机时BIOS设置的时钟中断向量号8,这里直接使用它 lea idt(,%ecx,8), %esi #把IDT描述符0x08地址放入ESI中,然后设置该描述符 movl %eax, (%esi) movl %edx, 4(%esi) movw $system_interrupt, %ax #设置系统调用先进门描述符,取系统调用处理程序地址 movw $0xef00, %dx #陷进门类型是15,特权级3的程序可执行 movl $0x80, %ecx #系统调用向量号的0x80 lea idt(,%ecx,8), %esi #把IDT描述符项0x80地址放入ESI中,然后设置该描述符 movl %eax,(%esi) movl $edx, 4(%esi) # 现在我们为移动到任务0(任务A)中执行来操作堆栈内容,在堆栈中人工建立中断返回时的场景 pushfl #复位标志寄存器EFLAGS中的嵌套任务标志 andl $0xffffbfff, (%esp) popf1 movl $TSS0_SEL, %eax #把任务0的TSS段选择符加载到任务寄存器TR ltr %ax movl $LDT0_SEL, %eax #把任务0的LDT段选择符加载到局部描述符表寄存器LDTR lldt %ax #TR和LDTR只需人工加载一次,以后CPU会自动处理 movl $0, current #把当前任务号0保存在current变量中 sti #现在开启中断,并在栈中营造中断返回时的场景 pushl $0x17 #把任务0当前局部空间数据段(堆栈段)选择符入栈 pushl $init_stack #把堆栈指针入栈(也可以直接把ESP入栈) pushfl #把标志寄存器入栈 pushl $0x0f #把当前局部空间代码选择符入栈 pushl $task0 #把代码指针入栈 iret #执行中断返回指令,从而切换到特权级3的任务0中执行 #以下是设置GDT和IDT中描述符项的子程序 setup_gdt: #使用6字节操作数lgdt_opcode设置GDT表位置和长度 lgdt lgdt_opcode ret #这段代码暂时设置IDT表中所有256个中断门描述符都为同一个默认值,均使用默认的中断处理过程ignore_int。 #设置的具体方法是:首先在EAX和EDX寄存器中分别设置好默认中断门描述符的0-3字节和4-7字节的内容,然后 #利用该寄存器对循环往IDT表中填充默认中断门描述符的内容 setup_idt: #把所有256个中断门描述符设置为使用默认处理过程 lea ignore_int , %eax #设置方法与设置定时中断门描述符的方法一样 movl $0x00080000, %eax #选择符为0x0008 movw %dx,%ax movw $0x8e00, %dx #中断门类型,特权级为0 lea idt, %edi mov $256, %ecx #循环设置所有256个门描述符项 rp_idt: movl %eax, (%edi) movl %edx, 4(%edi) addl $8, %edi dec %ecx jne rp_idt lidt lidt_opcode #最后用6字节操作数加载IDTR寄存器 ret #显示字符子程序。取当前光标位置并把AL中的字符显示在屏幕上,整屏可显示80x25个字符 write_char: push %gs #首先保存要用到的寄存器,EAX由调用者负责保存 pushl %ebx mov $SCRN_SEL, %ebx #然后让GS指向显示内存段(0xb8000) mov %bx, %gs movl scr_loc, %bx #再从变量scr_loc中取目前字符显示位置值 shl $1, %ebx #因为在屏幕上每个字符还有一个属性字节,因此字符 movb %al, %gs:(%ebx) #实际显示位置对应的显示内存偏移地址要乘2 shr $1, %ebx #把字符放到显示内存后把位置值除2加1,此时位置值对 incl %ebx #应下一个显示位置,如果该位置大于2000,则复位成0 cmpl $2000, %ebx jb lf movl $0, %ebx l: movl %ebx, scr_loc #最后把这个位置值保存起来(scr_loc) popl %ebx #并弹出保存的寄存器内容,返回 pop %gs ret #以下是3个中断处理程序:默认中断、定时中断和系统调用中断 #ignore_int是默认的中断处理程序,若系统产生了其它中断,则会在屏幕上显示一个字符“C” .align 2 ignore_int: push %ds pushl %eax movl $0x10, %eax #首先让DS指向内核数据段,因为中断程序属于内核 mov %ax, %ds movl $67, %eax #在AL中存放字符C的代码,调用显示程序显示在屏幕上 call write_char popl %eax popl %ds iret #这是定时中断处理程序。其中主要执行任务切换操作 .align 2 timer_interrupt: push %ds pushl %eax movl $0x10, %eax #首先让DS指向内核数据段 mov %ax, %ds movb $0x20, %al #然后立刻允许其他硬件中断,即向8259A发送EOI命令 outb %al, $0x20 movl $1, %eax #接着判断当前任务,若是任务1则去执行任务0,或反之 cmpl %eax, current je 1f movl %eax, current #若当前任务是1,则把0存入current,并跳转到任务0 ljmp $TSS0_SEL, $0 #去执行 popl %eax pop %ds iret #系统调用中断int 0x80处理程序。该示例只有一个显示字符功能 .align 2 system_interrupt: push %ds pushl %edx pushl %ecx pushl %ebx pushl %eax movl $0x10, %edx #首先让DS指向内核数据段 mov %dx, %ds call write_char #然后调用显示字符子程序write_char,显示AL中的字符。 popl %eax pop1 %ebx popl %ecx popl %edx pop %ds iret ##############****************************************############### current:.long 0 #当前任务号(0或1) scr_loc:.long 0 #屏幕当前显示位置。从左上角到右下角顺序显示 .align 2 lidt_opcode: .word 256 * 8 - 1 #加载IDTR寄存器的6字节操作数:表长度和基地址 .long idt lgdt_opcode: .word (end_gdt-gdt)-1 #加载GDTR寄存器的6字节操作数:表长度和基地址 .long gdt .align 3 idt: .fill 256,8,0 #IDT空间。共256个门描述符,每个8字节,共占用2KB gdt: .quad 0x0000000000000000 #GDT表,第1个描述符不用 .quad 0x00c09a00000007ff #第2个是内核代码段描述符,其选择符是0x08 .quad 0x00c09200000007ff #第3个是内核数据段描述符,其选择符是0x10 .quad 0x00c0920b80000002 #第4个是显示内存段描述符,其选择符是0x18 .word 0x68, tss0, 0xe900, 0x0 #第5个是TSS0段的描述符,其选择符是0x20 .word 0x40, ldt0, 0xe200, 0x0 #第6个是LDT0段的描述符。其选择符是0x28 .word 0x68, tss1, 0xe900, 0x0 #第7个是TSS1段的描述符。其选择符是0x30 .word 0x40, ldt1, 0xe200, 0x0 #第8个是LDT1段的描述符。其选择符是0x38 end_gdt: .fill 128,4,0 #初始内核堆栈空间 init_stack: #刚进入保护模式时用于加载SS:ESP堆栈指针值 .long init_stack #堆栈段偏移位置 .word 0x10 #堆栈段同内核数据段 #下面是任务0的LDT表段中的局部段描述符 .align 3 ldt0: .quad 0x0000000000000000 #第1个描述符,不用。 .quad 0x00c0fa00000003ff #第2个局部代码段描述符,对应选择符是0x0f .quad 0x00c0f200000003ff #第3个局部数据段描述符,对应选择符是0x17 #下面是任务0的TSS段的内容。注意其中标号等字段在任务切换时不会改变。 tss0: .long 0 /*back link*/ .long krn_stk0, 0x10 /*esp0,ss0*/ .long 0, 0, 0, 0, 0 /*esp1, ss1, esp2, ss2, cr3*/ .long 0, 0, 0, 0, 0 /*eip, eflags, eax, ecx, edx*/ .long 0, 0, 0, 0, 0 /*ebx, esp, ebp, esi, edi */ .long 0, 0, 0, 0, 0, 0 /*es, cs, ss, ds, fs, gs*/ .long LDT0_SEL, 0x8000000 /*ldt, trace bitmap*/ .fill 128,4,0 #这是任务0的内核栈空间 krn_stk0: #下面是任务1的LDT表段内容和TSS段内容 .align 3 ldt1: .quad 0x0000000000000000 #第1个描述符,不用。 .quad 0x00c0fa00000003ff #选择符是0x0f,基地址=0x00000 .quad 0x00c0f200000003ff #选择符是0x17, 基地址=0x00000 tss1: .long 0 /*back link */ .long krn_stk1, 0x10 /*esp0, sss0*/ .long 0,0,0,0,0 /*esp1, ss1,esp2,ss2,cr3*/ .long task1, 0x200 /*eip, eflags */ .long 0,0,0,0 /* eax, ecx , edx, ebx */ .long usr_stk1, 0, 0, 0 /* esp, ebp, esi, edi */ .long 0x17,0x0f,0x17,0x17,0x17,0x17 /* es,cs,ss,ds,fs,gs*/ .long LDT1_SEL, 0X8000000 /* ldt, tarce bitmap */ .fill 128,4,0 #这是任务1的内核空间。其用户栈直接使用初始栈空间 krn_stk1: #下面是任务0和任务1的程序,它们分别循环显示字符'A'和'B'。 task0: movl $0x17, %eax #首先让DS指向任务的局部数据,所以这两句可省略 movw %ax, %ds #因为任务没有使用局部数据,所以这两句可省略 movl $65, %al #把需要显示的字符'A'放入AL寄存器中 int $0x80 #执行系统调用,显示字符 movl $0xfff, %ecx #执行循环,起延时作用 1: loop 1b jmp task0 #跳转到任务代码开始处继续显示字符 task1: movl $66, %al #把需要显示的字符'B'放入AL寄存器中 int $0x80 #执行系统调用,显示字符 movl $0xfff, %ecx #延时一段时间,并跳转到开始处继续循环显示 1: loop 1b jmp task1 .fill 128,4,0 #这是任务1的用户栈空间 usr_stk1:
保护模式详解------http://baike.baidu.com/link?url=BwqoEM95JB15Q2Xl3-UEuEozXNToviyZ66qtEZFKSMU-XZDX-mNXO8L2mW4JwPqV