多任务程序结构和工作原理
本文给出的内核文件由两个文件构成。一个使用as86语言编制而成的引导启动程序boot.s,用于在计算机上电时从启动盘上把内核代码加载到内存中;另一个是使用GNU as 汇编语言编制的内核程序head.s,其中实现两个运行在特权等级 3 上的任务在时钟中断控制下相互切换运行,并且还实现了在屏幕上显示字符的一个系统调用。
两个任务分别为任务 A 和任务 B,它们会调用这个显示系统调用在屏幕上显示字符 A 和 B,直到每 10 毫秒切换到另外一个任务。任务 A 则显示字符 A, 任务 B 则显示字符 B。若要终止这个内核程序,则需要重新启动机器,或关闭模拟 pc 运行的软件。
boot.s 编译出来的程序工 512 个字节,将被存放到软盘映像文件的第一个扇区中,如下图所示。pc 机在上电启动时,ROM BIOS中的程序会将启动盘上第一个扇区的数据加载到内存 0x7c00(31KB)位置开始处,并把执行权限转移到0x7c00处开始执行此 boot 程序。
boot 程序的主要功能是把软盘或映像文件中的head内核代码加载到内存的某个指定位置处,并设置好临时 GDT 表等信息后,把处理器到运行保护模式下,然后跳转到 head 代码处去运行内核代码。实际上,boot 程序首先利用 ROM BIOS 中断 int 0x13 把软盘中的 head 代码读入到内存 0x10000(64KB)位置开始处,然后再把这段 head代码移动到内存 0 开始处(此处为何不直接移动到内存 0 开始处? 因为 ROM BIOS 的中断描述表 IDT 在内存 0 开始处,且内存 1KB 开始处是 BIOS 程序使用的数据区,若直接拷贝到内存 0 开始处,则会覆盖 BIOS 的中断描述表,导致 BIOS 中断 int 0x13 无法调用,从而无法完成 head 代码从软盘到内存的拷贝。
)。最后设置控制寄存器 CR0 中的开启保护模式标志位,并跳转到内存 0 处开始执行 head 代码。boot 程序在内存中移动 head 代码的示意图如下:
把 head 内核代码移动到内存 0 开始处的主要原因是为了设置 GDT 表时可以简单一些,因而也能让 head 程序尽量简短一些(因为 GDT 表的设置是在 head 内完成。
)。
head 程序是运行在 32 位保护模式下,其中主要包括初始设置的代码、时钟中断 int 0x08 的过程代码、系统调用中断 int 0x80 的过程代码以及任务 A 和 任务 B 等的代码和数据。其中初始设置工作包括:1、重新设置 GDT 表;2、设置系统定时器芯片;3、设置 IDT 表并设置时钟和系统调用中断门;4、移动到任务 A 中执行。
在虚拟地址空间中 head 程序的内核代码和任务代码分布如下图所示。实际上,本内核示例中所有程序的代码段和数据段都对应到物理内存同一区域上,即从物理内存 0 开始的区域。GDT 中全局代码段和数据段描述符的内容都设置位:基地址 0x0000;段限长值为 0x07FF。因为颗粒度位 1,所以实际长度位 8MB。而全局显示数据段被设置成:基地址为0xb8000;段限长值为 0x0002,所以实际段长度为 8KB,对应到显示内存区域上。
两个任务在 LDT 中代码段和数据段描述符的内容也都设置为:基地址为 0x0000;段限长值为 0x03ff,实际长度为 4MB。因此在线性地址空间中这个“内核”的代码和数据段都从线性地址 0 开始并由于没有采用分页机制,所以它们都直接对应物理地址 0 开始处。在 head 程序编译出的目标文件中以及最终得到的软盘映像文件中,代码和数据的组织形式见下图所示。
由于处于特权级 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 代码的控制转移。
为了每隔 10ms 切换运行任务,head 程序中把定时芯片 8235 的通道 0 设置成每经过 10ms 就向中断控制芯片 82589A 发送一个时钟中断请求信号。PC 机的 ROM BIOS 开机时已经在8259A中把时钟中断请求信号设置为中断向量 8,因此我们需要在中断 8 处理过程中执行任务切换操作。实现任务切换。
引导程序 boot.s
其代码如下:
//首先利用bios中断把内核代码(head代码)加载到0x10000处,然后移动
//到内存0处。!最后进入保护模式,并跳转到内存0开始处继续执行
BOOTSEG = 0x07c0 !引导扇区被BIOS加载到内存的0x7c00处
SYSSEG = 0x1000 !内核(head)先加载到0x10000,然后移动到0x0处
SYSLEN = 17 !内核占用的最大磁盘扇区数
entry start
start:
jmpi go, #BOOTSEG !段间跳转至0x7c0:go处,当本程序刚刚运行时,所有的段寄存器值均为0
go1: mov ax, cs
mov ds, ax
mov es, ax
mov [msg + 17], ah
mov cx, #20
mov dx, #0x1004
mov bx, #0x000c
mov bp, #msg
mov ax, #0x1301
int 0x10
loop1: jmp loop1
msg: .ascii "loading system ..."
.byte 13, 10
`
go: mov ax, cs !该跳转语句会把cs寄存器加载为0x7c0(原来为1),让ds与ss都指向0x7co段
mov ds, ax
mov ss, ax
mov sp, #0x400 !设置临时栈指针,其值大于程序末端有一定空间即可
!加载内核代码到内存0x10000开始处
!利用bios中断int 0x13功能2从启动盘中读取head代码。 DH--磁头号, DL-驱动器号,
!CH-10位磁道号低8位, CL-位6,7是磁道号高两位, 位5-0起始扇区号(从1计)
!ES:BX-读入缓冲区位置(0x1000:0x0000),
!AH-读扇区功能号, AL-需都的扇区数(17)
load_system:
mov dx,#0x0000 !磁头号 与 驱动器号 均为0
mov cx,#0x0002 !磁道号为0 扇区号为2(1号扇区是该程序)
mov ax,#SYSSEG
mov es,ax
xor bx,bx
mov ax,#0x200 + SYSLEN
int 0x13
jnc ok_load !若没有发生错误,则跳转到ok_load处继续执行, 否则死循环
die: jmp go1
!把内核代码移动到内存0开始处, 共移动8KB字节(内核长度不超过8KB)
ok_load:
cli !关闭中断
mov ax, #SYSSEG
mov ds, ax !移动开始位置DS:SI = 0x1000:0 目前位置ES:DI=0:0
xor ax, ax
mov es, ax
mov cx, #0x2000 !设置共移动4K,每次移动一个字(word)
sub si, si
sub di, di
rep
movw !执行重复移动指令
!加载IDT 和 GDT基地址寄存器 IDTR和GDTR
mov ax, #BOOTSEG
mov ds, ax !让DS重新指向0x7c0d段
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 !然后跳转至选择符值指定的段中, 偏移0处
jmpi 0, 8 !注意此时段值已是段选择符, 该段的线性基地址是0
!下面是全局描述符表GDT的内容,其中包括3个段描述符, 第一个不可用,另外2个代码和数据描述符
gdt: .word 0, 0, 0, 0
.word 0x07FF !段描述符1, 8Mb--段限长值2047()
.word 0x0000
.word 0x9A00
.word 0x00C0
.word 0x07FF
.word 0x0000
.word 0x9200
.word 0x00C0
!下面分别是LIDT与LGDT指令的6字节操作数
idt_48: .word 0
.word 0,0 !此处复用bios的中断处理程序
gdt_48: .word 0x7ff
.word 0x7c00 + gdt, 0
.org 510
.word 0xAA55 !引导扇区有效标志,必须处于引导扇区的最后2个字节处
内核程序 head.s
#head.s 包含32位保护模式初始化设置代码,时钟中断代码,系统调用中断代码和两个任务的代码
#在初始化完成后程序移动到任务0开始执行,并在时钟中断控制下进行任务0和任务1之间切换操作
LATCH = 11930 #定时器初始计数值,即每个10ms发送一次中断请求
SCRN_SEL = 0x18 #屏幕显示内存段选择符
TSS0_SEL = 0x20 #任务0的TSS段选择符
LDT0_SEL = 0x28 #任务0的LDT段选择符
TSS1_SEL = 0x30 #任务1的TSS段选择符
LDT1_SEL = 0x38 #任务1的LDT段选择符
.text
.globl idt,gdt
startup_32:
#首先加载数据段寄存器DS、堆栈段寄存器SS和堆栈指针ESP。所有段的线性基地址都是0
movl $0x10, %eax #0x10是GDT中数据段选择符
mov %ax, %ds
lss init_stack, %esp
# setup base fields of descriptors.
call setup_idt
call setup_gdt
movl $0x10,%eax # reload all the segment registers
mov %ax,%ds # after changing gdt.
mov %ax,%es
mov %ax,%fs
mov %ax,%gs
lss init_stack,%esp
# setup up timer 8253 chip.
movb $0x36, %al
movl $0x43, %edx
outb %al, %dx
movl $11930, %eax # timer frequency 100 HZ
movl $0x40, %edx
outb %al, %dx
movb %ah, %al
outb %al, %dx
# setup timer & system call interrupt descriptors.
movl $0x00080000, %eax
movw $timer_interrupt, %ax
movw $0x8E00, %dx
movl $0x08, %ecx # The PC default timer int.
lea idt(,%ecx,8), %esi
movl %eax,(%esi)
movl %edx,4(%esi)
movw $system_interrupt, %ax
movw $0xef00, %dx
movl $0x80, %ecx
lea idt(,%ecx,8), %esi
movl %eax,(%esi)
movl %edx,4(%esi)
# unmask the timer interrupt.
# movl $0x21, %edx
# inb %dx, %al
# andb $0xfe, %al
# outb %al, %dx
# Move to user mode (task 0)
pushfl
andl $0xffffbfff, (%esp)
popfl
movl $TSS0_SEL, %eax
ltr %ax
movl $LDT0_SEL, %eax
lldt %ax
movl $0, current
sti
pushl $0x17
pushl $init_stack
pushfl
pushl $0x0f
pushl $task0
iret
/****************************************/
setup_gdt:
lgdt lgdt_opcode #使用6字节操作数lgdt_opcode设置GDT表位置和长度
ret
#这段代码暂时设置IDT表中所有256个中断门描述符都为同一个默认值,均使用默认中断处理程序
#ignore_int。 设置的具体方法是:首先eax和edx寄存器对中分别设置好默认中断门描述符的0-3字节
#和4-7字节的内容,然后利用该寄存器循环往IDT表中填入默认中断门描述符内容
setup_idt: #把所有256个中断描述符设置为默认处理过程
lea ignore_int, %edx #设置方法与设置定时中断门描述符的方法一样
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
ret
#显示字符子程序,取当前光标位置并把 AL 中的字符显示在屏幕上,整屏可显示80 X 25 个字符
write_char:
push %gs #首先保存要用到的寄存器, EAX 由调用者保存
pushl %ebx
mov $SCRN_SEL, %ebx #然后让GS指向显示内存段(0xb8000)
mov %bx, %gs
movl scr_loc, %ebx #再从变量 scr_loc中去目前字符位置
shl $1, %ebx #因为在屏幕上每个字符还有一个属性字节,因此字符
movb %al, %gs:(%ebx) #实际显示位置对应的显示内存偏移地址要乘以2
shr $1, %ebx #把字符放到显示内存后把位置除以 2 加 1,此时位置值对应
incl %ebx #下一个显示位置,如果该位置大于2000 则复位成0
cmpl $2000, %ebx
jb 1f
movl $0, %ebx
1: movl %ebx, scr_loc #最后把这个值保存起来
popl %ebx
pop %gs
ret
#以下是3个中断处理程序, 默认中断, 定时中断和系统调用中断
#ignore_int是默认中断处理程序, 托系统产生其他中断,则会在屏幕上显示一个字符C
.align 4
ignore_int:
push %ds
pushl %eax
movl $0x10, %eax #首先让DS指向内核数据段,因为中断程序属于内核
mov %ax, %ds
movl $67, %eax #在AL中放入字符c的代码, 调用显示程序显示在屏幕
call write_char
popl %eax
pop %ds
iret
#这是定时中断处理程序, 其中主要执行任务切换操作
.align 4
timer_interrupt:
push %ds
pushl %eax
movl $0x10, %eax #首先让Ds指向内核数据段
mov %ax, %ds
movb $0x20, %al #然后立刻允许其他硬件中断,即向8259A发送E01命令
outb %al, $0x20
movl $1, %eax #接着去判断当前任务,若是任务1则去执行任务0,或反之
cmpl %eax, current
je 1f
movl %eax, current #若当前任务是0,则把 1 存入current,并跳转到任务1
ljmp $TSS1_SEL, $0 #去执行,注意跳转的偏移值无用,但需要写上
jmp 2f
1: movl $0, current #若当前任务是1,则把0存入current,并跳转到任务0
ljmp $TSS0_SEL, $0 #去执行
2: popl %eax
pop %ds
iret
#系统调用中断int 0x80 处理程序,该示例只有一个显示字符功能
.align 4
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
popl %ebx
popl %ecx
popl %edx
pop %ds
iret
//--------------------------------------------//
current:.long 0 #当前任务号(0或1)
scr_loc:.long 0 #屏幕当前显示位置, 按从左上角到右下角顺序显示
.align 4
lidt_opcode:
.word 256*8 - 1 #加载IDTR寄存器的6字节操作数, 表长度(0x7FF)和基地址
.long idt
lgdt_opcode:
.word (end_gdt - gdt) - 1 #加载GDTR寄存器的6字节操作数, 表长度和基地址
.long gdt
.align 8
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, 0x00 #第5个是TSS0段的描述符, 其选择符是0x20
.word 0x40, ldt0, 0xe200, 0x00 #第6个是LDT0段的描述符, 其选择符是0x28
.word 0x68, tss1, 0xe900, 0x00 #第7个是TSS1段的描述符, 其选择符是0x30
.word 0x40, ldt1, 0xe200, 0x00 #第8个是LDT1段的描述符, 其选择符是0x38
end_gdt:
.fill 128, 4, 0 #初始化内核栈空间
init_stack: #刚进入保护模式时用于加载SS:ESP堆栈指针值
.long init_stack #堆栈偏移地址
.word 0x10 #堆栈段同内核数据段
#下面是任务0的LDT表段中的局部段描述符
.align 8
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 8
ldt1: .quad 0x0000000000000000 #第1个描述符,不用
.quad 0x00c0fa00000003ff #选择符0x0f 基地址=0x00000
.quad 0x00c0f200000003ff #选择符是0x17,基地址为0x00000
tss1: .long 0 #back link
.long krn_stk1, 0x10 # esp0, ss0
.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, gs
.long LDT1_SEL, 0x8000000 #ldt, trace bitmap
.fill 128, 4, 0 #这是任务1的内核栈空间,其用户栈直接用初始栈空间
krn_stk1:
#下面是任务0和任务1的程序, 他们分别循环显示A和B
task0:
movl $0x17, %eax #首先让DS指向任务的局部数据段
movw %ax, %ds #因为任务没有使用局部数据, 所以这两句可以省略
mov $65, %al #把需要显示的字符A放入AL寄存器中
int $0x80 #执行系统调用,显示字符
movl $0xfff, %ecx #执行循环起延时作用
1: loop 1b
jmp task0 #跳转到任务代码开始处继续显示字符
.fill 128, 4, 0
usr_stk0:
task1:
mov $66, %al #把需要显示的字符B放入AL寄存器
int $0x80 #执行系统调用,显示字符
movl $0xfff, %ecx #延时一段时间, 并跳转到开始处继续循环显示
1: loop 1b
jmp task1
.fill 128, 4, 0 #这是任务1的用户栈空间
usr_stk1:
编译过程
makefile 文件:
# Makefile for the simple example kernel.
AS86 =as86 -0 -a
LD86 =ld86 -0
AS =as
LD =ld
LDFLAGS =-m elf_i386 -Ttext 0 -e startup_32 -s -x -M
all: Image
Image: boot system
dd bs=32 if=boot of=Image skip=1
objcopy -O binary system head
cat head >> Image
disk: Image
dd bs=8192 if=Image of=/dev/fd0
sync;sync;sync
head.o: head.s
system: head.o
$(LD) $(LDFLAGS) head.o -o system > System.map
boot: boot.s
$(AS86) -o boot.o boot.s
$(LD86) -s -o boot boot.o
clean:
rm -f Image System.map core boot head *.o system