一个简单的多任务内核实例part2:head.s
0.head.s功能简介
-
初始化设置(时钟、GDT/LDT、寄存器等)
-
时钟中断、系统调用、字符显示
-
任务A和B的代码和数据
程序执行流程如下图:
1.head.s汇编代码(AT&T风格)及注解
# 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段选择符
/*备注:一个任务(进程)在GDT表中对应两项段选择符:LDT和TSS
这些段选择符怎么定的值?
[15~3]为段索引,[2]为0表示GDT,1表示LDT,[1~0]:RPL(请求特权级)
举例:0x18-->[00011 0 00] 表示GDT表的第3个选择符,RPL=0
8253时钟芯片的输入频率是1.193180Mhz,1193180/100=11930,即频率100Hz
*/
.text
startup_32: #告知链接器,以下是32位代码
# 首先加载数据段寄存器DS、堆栈段寄存器SS和堆栈指针ESP。所有段的线性基地址都是0
mov $0x10,%eax #0x10是GDT中数据段选择符
mov %ax,%ds
lss init_stack,%esp #给SS和ESP同时赋值,即SS:ESP执行init_stack地址
# 在新的位置重新设置IDT和GDT表(主要为了结构清晰,此外为与Linux0.12内核源码一致)
call setup_idt # 设置IDT,把256个中断门都填默认处理过程的描述符
call setup_gdt # 设置GDT
movl $0x10,%eax # setup_gdt时改变了gdt的位置,故这里需重新加载所有段寄存器
mov %ax,%ds
mov %ax,%es
mov %ax,%fs
mov %ax,%gs
lss init_stack,%esp
/*
LSS指令: 第一个操作数是一个有效地址,第二个是一个通用寄存器。该指令将有效地址的前32位
装入寄存器中,同时将后面16位的选择符装入SS堆栈段寄存器。
init_stack:
.long init_stack #四字节地址(32位)
.word 0x10 #段选择符(16位),同内核数据段选择符
最终效果:SS:ESP = 0x10:init_stack (注意,标号表示标号第一条指令的地址)
拓展:LDS、LFS、LGS、LES 指令用法一样,只是装入的段寄存器不同而已
*/
# 设置8253定时芯片。把计数器通道0设置成每隔10ms向中断控制器发送一个中断请求信号
movb $0x36,%al # 控制字:设置通道0工作在方式3、计数初值采用二进制
movl $0x43,%edx # 8253芯片控制字寄存器写端口
outb %al, %dx # 往0x43端口写0x36
movl $LATCH,%eax # 分两次往0x40端口写入LATCH,即计数器初值
movl $0x40, %edx
outb %al, %dx
mov %ah, %al
mov %al, %dx
# 在IDT表第8和第128(0x80)项处分别设置定时中断门描述符和系统调用陷阱门描述符
movl $0x00080000,%eax #%eax存放IDT表第8项的低4字节,timer_interrupt地址
movw $timer_interrupt,%ax
movw $0x8E00, %dx #%edx存放IDT表第8项的高4字节,中断门类型,特权级0
movl $0x08, %ecx #利用开机时BIOS设置的时钟中断向量号8(注:实际内核不用BIOS中断向量号)
lead idt(,%ecx,8),%esi
movl %eax,(%esi)
movl %edx,4(%esi)
movw $system_interrupt,%ax #同一的方法设置系统调用陷阱门描述符
movl $0xef00, %dx
movl $0x80, %ecx
led idt(,%ecx,8), %esi
movl %eax,(%esi)
movl %edx,4(%esi)
# 现在我们为移动到任务0(任务A)中执行来操作堆栈内容,在堆栈中人工建立中断返回时的场景
pushfl # 复位标志寄存器EFLAGS中的嵌套任务标志NT
andl $0xffffbfff,(%esp)
popfl
movl $TSS0_SEL, %eax # 把任务0的TSS段选择符加载到任务寄存器TR
ltr %ax # 注意,加载时段选择符时,TSS段描述符也会自动加载进TR
movl $LDT0_SEL, %eax # 把任务0的LDT段选择符加载到局部描述符表寄存器LDTR
lldt %ax # TR和LDTR只需人工加载一次,以后CPU会自动处理
sti # 现在开启中断,并在栈中营造中断返回时的场景
pushl $0x17 # 把任务0当前局部空间数据段(堆栈段)选择符入栈
pushl $init_stack # 把堆栈指针入栈(也可以把ESP入栈)
pushfl # 把标志寄存器入栈
pushl $0x0f # 把当前局部空间代码段选择符入栈
pushl $task0 # 把代码指针入栈
iret # 执行中断返回指令,从而切换到特权级3的任务0中执行
/* 备注:不能直接从特权级0跳转到特权级3执行。但中断返回操作是可以的,所以这里营造的中断返回时的场景以
切换到特权级3开始执行
问题1:上面为什么要复位NT标志位呢?
NT是嵌套任务标志,它控制被中断任务和调用任务之间的“链接”关系,使用CALL、中断或
异常执行任务调用时,处理器会设置该标志。在通过使用IRET指令从一个任务返回时,处理器会检查并
修改这个NT标志。使用POPF/POPFD指令也可以修改这个标志,但是在应用程序中改变这个标志的状态
会产生不可意料的异常
NT = 0,用堆栈中保存的值恢复EFLAGS、CS和EIP,执行常规的中断返回操作
NT = 1,通过任务转换实现中断返回
因此,复位NT即通过恢复堆栈中内容实现中断返回
问题2:为什么人工加载一次LDTR和TR就行了呢?(任务切换的问题)
一方面,正如上面讲到的NT标志位,使用CALL、中断或异常调用是通过“任务转换”
实现的,这个转换是CPU自动完成的。
另一方面,当CALL指令、中断或异常造成任务切换,处理器会自动把当前TSS段的
选择符复制到新任务TSS段的前一任务链接字段中(以能返回前一任务)。TSS0段的布局相当重要,在下文
会给出图示。
*/
setup_gdt:
lgdt lgdt_opcode
ret
setup_idt:
lea ignore_int,%edx #取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) #把ignore_int的地址送往idt地址处,一项的前4字节
movl %edx,4(%edi) #把0x8E00送往(idt+4)地址处,一项的后4字节
addl $8,%edi # edi+8
dec %ecx
jne rp_idt # ecx≠0则跳转到rp_idt,直到设置完256项描述符
lidt lidt_opcode # 最后用6字节操作数加载IDTR寄存器
ret
# 显示字符子程序。取当前光标位置并把AL中的字符显示在屏幕上。整屏可显示80x25个字符
write_char:
push %gs # 首先保存要用到的寄存器,EAX由调用者负责保存
pushl %ebx
mov $SCRN_SEL, %ebx # 然后让GS执行显示内存段(0xb800)
mov %bx,%gs
movl scr_loc,%bx # 在从变量scr_loc中取目前字符显示位置值
shl $1,%ebx # 因为屏幕上每个字符还有一个属性字符,因此字符实际
# 显示位置对应的显示内存偏移地址要乘2
movb %al,%gs:(%ebx) # 把字符放到显示内存后把位置值除2加1,此时位置对应
# 下一个显示位置。如果该位置>2000,则复位成0
shr $1,%ebx
incl %ebx
cmpl $2000,%ebx
jb 1f
movl $0,%ebx
1: 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执行内核数据段,因为中断程序属于内核
movl %ax, %ds
movl $67, %eax
call write_char
popl %eax
pop %ds
iret
# 备注:iret 和 ret的区别,以及何时使用iret,何时用ret?
# iret 和 ret 对寄存器的影响
# iret用于中断处理程序(内核程序)(特权级0)的返回,会根据特权级变换决定是否切换堆栈
# 而ret用于用户程序(特权级3)的返回
# 这是定时中断处理程序。其中主要执行任务切换操作
.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 # 1f是执行任务0
movl %eax, current # 若当前任务是0,则把1存入current,并跳转到任务1去执行
ljmp $TSS1_SEL, %0 # 注意,跳转的偏移值(0)无用,但需要写上
jmp 2f
1: movl $0, current
ljmp $TSS0_SEL, $0
2: 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执行内核数据段
movl %dx, %ds
call write_char
popl %eax
popl %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 # IDT表长度
.long idt # IDT表线性基地址
lgdt_opcode:
.word (end_gdt-gdt)-1 # GDT表长度
.long gdt # GDT表线性基地址
.align 3 # 2^3=8B,要求 addr mod 8 == 0 进行对齐
idt: .fill 256,8
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-gdt:
.fill 128,4,0
init_stack:
.long init_stack # 堆栈段偏移位置,即设置ESP
.word 0x10 # 堆栈段同内核数据段,即设置SS
# 下面是任务0的LDT表段中的局部段描述符
.align 3
ldt0: .quad 0x0000000000000000
.quad 0x00c0fa00000003ff
.quad 0x00c0f200000003ff
tss0: .long 0
.long krn_stk0, 0x10
.long 0, 0, 0, 0, 0, 0
.long 0, 0, 0, 0, 0, 0
.long 0, 0, 0, 0, 0, 0 #注意这里没设置任务0的用户栈,实际指向0x10(内核数据段):init_stack
.long 0, 0, 0, 0, 0, 0
.long LDT0_SEL, 0x8000000
.fill 128,4,0 #这是任务0的内核栈空间
krn_stk0:
# 下面是任务1的LDT表段内容和TSS段内容
.align 3
ldt1: .quad 0x0000000000000000
.quad 0x00c0fa00000003ff
.quad 0x00c0f200000003ff
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 /* ldt, trace bitmap */
.long LDT1_SEL, 0x8000000
.fill 128,4,0
krn_stk1:
# 下面是任务0和任务1的程序,它们分别循环显示字符‘A’ 和‘B’
task0:
mov $0x17, %eax #首先让DS指向任务的局部数据段
movw %ax, %ds
movl $65, %al
int $0x80
movl $0xfff,%ecx # 延迟一段时间,并跳转到开始处继续循环显示
1: loop 1b # loop 会检查ecx,若为0,则结束1循环
jmp task0
task1:
movl $66,%al
int $0x80
1: loop 1b
jmp task1
.fill 128,4,0
usr_stk1:
head.s在内存中分布如下:
2.补充知识:
#1.在16位CPU,只有4个段寄存器(CS DS SS ES),内存寻址为“段值:偏移量”
而在32位CPU,有6个段寄存器(+FS GS),此时装入段寄存器的不再是段值,
而是段选择符(或称为"选择子"),通过段选择符索引对应表(LDT/GDT/IDT)
项存储的“段描述符”,而段描述符中存储了对应段的“线性”基地址和长度等其它属性
#2.段描述符类型:
1.数据段描述符
2.代码段描述符
3.系统段描述符(包括TSS、LDT(表项为进程的代码段/数据段)\调用门/中断门/陷阱门/任务门描述符)
#3.三种地址空间:
1.逻辑地址空间(即可执行程序中看到的地址值)
2.线性地址空间(基于“段”进行划分,段长度不定)
3.物理地址空间(基于“页”划分,段长度一定(一般4K))
三者转换关系:开启分段、分页机制时:1->2->3
开启分段、没有分页时:1->2<=>3(线性地址直接线性映射到物理地址空间,两者是一 一对应的)
3 编译、链接、运行
环境:Ubuntu14.04-LTS
-
编译链接boot.s
as86 -0 -a -o boot.o boot.s
ld86 -0 -s -o boot boot.o -
编译链接head.s
as --32 -o head.o head.s //注意一定要–32选项,编译成32位代码,head.s修改如下
(.code32指定下面指令按32位编译,.global startup_32 声明一个全局标识符 startup_32供链接时指定入口地址)
ld -s -m elf_i386 -Ttext 0 -e startup_32 -o head head.o
注释:-m elf_i386:顾名思义,生成i386平台下的elf格式的可执行文件
-s:去掉符号信息 -Ttext 0:不加这个选项,ld默认会给代码内所有的偏移加上0x08048000,而使用之后则是+0
保留了符号引用的原值(即代码内偏移量——LC,更详细注解请参考:https://www.linuxidc.com/Linux/2014-11/108829.htm
-e: 即entry的缩写,指定入口地址(前面的.global声明目的在此) -
生成软盘映像文件
dd bs=32 if=boot of=Image skip=1
dd bs=512 if=head of=Image skip=8 seek=1
备注:生成的Image为5524字节(大小很重要,能判断是否正确生成!)
skip=xxx是在备份时对if=原文件跳过多少块再开始备份;
seek=xxx则是在备份时对of=目标文件跳过多少块再开始写。对于该实例更详细的编译和运行解释,推荐一篇博文:
https://blog.csdn.net/wangnanjun/article/details/7080318 -
bochsrc-0.00.bxrc配置文件
megs: 16
romimage: file=$BXSHARE/BIOS-bochs-latest, address=0xf0000
vgaromimage: file=$BXSHARE/VGABIOS-lgpl-latest
floppya: 1_44="Image", status=inserted
boot: a
log: bochsout.txt
5)运行效果
待完成:
该简单的多任务内核实例涉及任务切换、特权级转换、系统调用等相关知识。因内容比较多且复杂,会在后文展开时探讨。
参考资料:
《Linux内核完全剖析——基于0.12内核》第4章、第17章