引言
本文主要参考赵炅老师的linux内核完全注释。下面的代码使用汇编语言,本文更加详细地分析了其中的执行过程,重新注释每句代码。
运行结果:任务0执行10ms打印A字符,任务1执行10ms打印B字符。两个任务轮流占用CPU。
Makefile
# Makefile for the simple example kernel.
# 文件格式:
# target... : prerequisites ...
# command
# as86:汇编器--boot.s-->boot.o。ld86:链接器--boot.o-->boot.exe。
AS86 =as86 -0 -a # -0 :生成8086目标程序;-a :生成与gas和gld部分兼容的代码;
LD86 =ld86 -0 # -0:产生具有16bit魔数的头结构;
AS =gas
LD =gld
LDFLAGS =-s -x -M
# all 需要依赖 Image
all: Image
# boot的大小是544B,包含一个32B的文件头,所以需要把这个头去掉。
# dd bs=32 if=boot of=Image skip=1 boot的大小是544B,包含一个32B的文件头,所以需要把这个头去掉。
Image: boot system
dd bs=32 if=boot of=Image skip=1
dd bs=512 if=system of=Image skip=2 seek=1
sync
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 *.o system
boot.s
BOOTSEG = 0x07c0 ; 当打开电源,BIOS开始自检,然后出发19号中断 ,在处理19号中断时,BIOS检测电脑是否具有软盘、硬盘或是固定磁盘,如果有任何可以使用的磁盘,BIOS就把磁盘的第一个扇区(512B)加载到内存的0x7C00地址处。总的来讲就是BIOS会从硬盘或其他设备把操作系统引导程序加载到内存0x7c00,然后跳转到这里继续执行程序。
SYSSEG = 0x1000 ; 首先将代码加载到0x10000位置,然后移动到0x00的位置。使代码移动到0x00的位置是为了方便设置GDT表,方便编辑代码(减少偏移地址的计算)。
; 之所以先将代码加载到0x10000的位置是因为内存0位置保存着BIOS中断向量表,我们在后面的代码中要使用里面的中断。
; 所以将直接将head.s移动到内存地址0处会覆盖BIOS中断向量表。
SYSLEN = 17 ; 内核占用最大磁盘扇区数
entry start
start:
jmpi go, #BOOTSEG ; 相当于设置cs:ip的值为BOOTSEG:go,即0x7c00:go
go:
mov ax,cs ; 将cs,ds,ss的值均设置为0x7c00
mov ds,ax
mov ss,ax
mov sp,#0x400 ; 设置栈顶指针位置。目前的值是临时的,只是为了分配栈空间。使之大于栈底指针即可。
; 从外设加载内核代码到磁盘
; 参数描述
; ah: int13功能号,2表示读扇区
; al: 读取的扇区数
; ch: 磁道号
; cl: 扇区号
; dh: 磁头号,对于软驱为面号,因为一个面用一个磁头来读写。
; dl: 驱动器号。软驱从0开始,0:软驱A,1:代表软驱B;硬盘从80h开始,80h:硬盘C,81h:硬盘D。具体读取哪个盘是提前设置的参数。
; es:bx: 指向接收扇区读取数据的内存区。
load_system:
mov dx,#0x0000 ; 为寄存器dx赋值
mov cx,#0x0002 ; 为寄存器cx赋值
mov ax,#SYSSEG ; 准备为ex赋值
mov es,ax ; 为es赋值
xor bx,bx ; 初始化bx的值
mov ax,#0x200+SYSLEN ; 初始化寄存器ax,ah为2表示读扇区,al为17表示读取的扇区数
int 0x13 ; 调用13号中断对应的例程读取磁盘
jnc ok_load ; 如果读取发生错误,则返回的错误码将会存在寄存器ah中。读取成功ah=0,al等于读取的扇区数。正常情况下进位标记为0,跳转到ok_load地址
die:
jmp: die ; 读取失败后,jnc没有跳转,所以执行这里的die地址处的代码。重复执行die地址代码,发生死循环
ok_load:
cli ; 关闭中断
mov ax,#SYSSEG ; 准备为段寄存器赋值
mov ds,ax
xor ax,ax
mov es,ax
mov cx,#0x1000 ; 设置cx为4096
sub si,si ; 源地址ds:si = 0×07C0:0×0000
sub di,di ; 目的地址es:di = 0×1000:0×0000
rep movw ; rep: 重复执行该语句直至寄存器cx为0。movw: 将DS:SI的内容送至ES:DI,note! 是复制过去,原来的代码还在。
mov ax,#BOOTSEG
mov ds,ax ; 重置ds
; descriptor table的相关概念可以查看 https://www.techbulo.com/708.html,最好在了解了段页式查询以后再行阅读。
lidt idt_48 ; 加载idt表,idt_48指向的位置有六个字节,前两个字节表示字节表长度,后四个字节表示该表的线性基地址。
lgdt gdt_48 ; 加载gdt表,gdt_48指向的位置有六个字节,前两个字节表示字节表长度,后四个字节表示该表的线性基地址。
; 使用寄存器ax设置CR0寄存器
mov ax,#0x0001 ; 控制寄存器CR0为32位寄存器,其中第0位PE含义是启用保护标志。当该位设置为1时启用保护模式。
lmsw ax ; 置处理器状态字。但是只有操作数的低4位被存入CR0,只有PE,MP,EM和TS被改写,CR0其他位不受影响。
jmpi 0,8 ; 8被储存到cs寄存器中,cs对应的二进制为00000000 00001000,1~2位表示代码特权级,3位表示采用gdt或ldt,cs第三位为0所以采用gdt。4~16位的值为00000000 0001,对应十进制1,意为使用gdt表中的第一个描述符(注意gdt描述符是从0开始占位)。详情参考:https://blog.csdn.net/suppercoder/article/details/9422093
; 设置gdt全局描述表的内容,每个描述符占8个字节。.word表示申请一个字的空间储存值
gdt:
.word 0,0,0,0 ; 第0个描述符为0,不用。占用8个字节
.word 0x07FF ; 设置段限长,0x07FF=2047d,2048*4kb = 8mb
.word 0x0000 ; 设置段基地址为0
.word 0x9A00 ; 表示当前段描述符对应的是代码段,可读/可执行
.word 0x00C0 ; 段颗粒度为4kb
.word 0x07FF ; 设置段限长,0x07FF=2047d,2048*4kb = 8mb
.word 0x0000 ; 设置段基地址为0
.word 0x9200 ; 表示当前段描述符对应的是数据段,可读/可写
.word 0x00C0 ; 段颗粒度为4kb
idt_48:
.word 0 ; IDT表长度是0
.word 0,0 ; IDT表线性基址是0
gdt_48:
.word 0x7ff ; GDT长度为2048字节,可以容纳256个8字节段描述符
.word 0x7c00+gdt,0 ; GDT表的线性基址为0x7c00段的偏移gdt处
.org 510 ; 在汇编语言源程序的开始通常都用一条ORG伪指令来实现规定程序的起始地址。如果不用ORG规定则汇编得到的目标程序将从0000H开始。本句意为从510字节处执行下面的代码。
.word 0xAA55 ; 磁盘的一个扇区是512字节,标志0xaa55存于这个扇区的最后一个字(两字节,偏移地址为:1FEH),其余空间用于存储指令代码和一些参数、提示信息等。磁盘引导记录由ROM BIOS的INT 19H(引导加载程序,相当于热启动系统,对应的快捷键为:Ctrl+Alt+Del),固定装入内存的0000:7C00H,然后将控制权交给磁盘引导程序。
head.s
SCRN_SEL = 0x18 ; 定时器初始数值,每隔10毫秒发送一次中断请求
TSS0_SEL = 0x20 ; 任务00的TSS段选择符
LDT0_SEL = 0x28 ; 任务0的ldt段选择符
TSS1_SEL = 0X30 ; 任务1的TSS段选择符
LDT1_SEL = 0x38 ; 任务1的LDT段选择符
.text
startup_32:
; 0x10是什么,是段选择符:0x10000,右移三位后正好选定gdt中的第2项。内核数据段。
movl $0x10,%eax ; 0x10是GDT表中的数据段选择符的位置。0001 0000 对应的ds段索引为2。
mov %ax,%ds ; 为数据段寄存器ds赋值
lss init_stack,%esp ; lss含义是给ss寄存器和一个通用的寄存器同时赋值。将init_stack的低16位传入esp,高16位传入ss。此时传入的是init_stack的地址。
call setup_idt ; 初始化idt表格
call setup_gdt ; 初始化gdt表格
; 初始化段寄存器
movl $0x10,%eax ; 重新加载GDT数据段选择符
mov %ax,%ds
mov %ax,%es
mov %ax,%fs
mov %ax,%gs
lss init_stack,%esp ; init_stack是一个远指针,前4个字节是偏移,后2个字节是段选择子,这句代码表示用偏移加载esp,用数据段选择子0x10加载ss。最终的结果就是ss:esp指向init_stack
; 接下来设置8253芯片不用深挖,想要了解原理的小朋友可以参考:https://baike.baidu.com/item/8253%E8%8A%AF%E7%89%87
; 达到的效果是:把计数器通道0设置为每个10ms向中断控制器发送一个中断信号
movb $0x36, %al
movl $0x43, %edx
outb %al, %dx
movl $11930, %eax
movl $0x40, %edx
outb %al, %dx
movb %ah, %al
outb %al, %dx
; 接下来设置idt描述符,设置以后,我们就可以通过int指令调用该中断例程
; 0x0008和0x0010分别是代码段和数据段的段选择符,可是代码段和数据段分别在哪里?去gdt看看,需要查看gdt描述符
movl $0x00080000, %eax ; EAX是32位的寄存器,而AX是EAX的低16位。将EAX高16位设置为0x0008。
movw $timer_interrupt, %ax ; 将EAX高16位设置为timer_interrupt地址。
movw $0x8E00, %dx ; 将dx置为0x8E00。
movl $0x08, %ecx ; 8号中断向量。开机时BIOS设置的时钟中断向量号8.
lea idt(,%ecx,8), %esi ; 将%ecx*8+idt的地址放入%esi中去
movl %eax,(%esi) ; 再把%eax的值放入%esi所指的内存区域
movl %edx,4(%esi) ; 把%edx放在这个中断描述符的高八位
; 接下来设置系统中断,程序可以通过int指令调用。设置过程和设置时钟中断相同
movw $system_interrupt, %ax
movw $0xef00, %dx
movl $0x80, %ecx ; 中断向量号为0x80
lea idt(,%ecx,8), %esi
movl %eax,(%esi)
movl %edx,4(%esi)
; 准备跳转到用户态去执行
pushfl ; pushfl是push flags long的简写,将标志寄存器压栈,双字四字节
andl $0xffffbfff, (%esp) ; 1111111111111111 1011 1111 1111 1111 & esp 达到清零特定位的效果
popfl ; pop标志位
movl $TSS0_SEL, %eax ; TSS0_SEL是在一开始就设定好的,它的值是0x20。
ltr %ax ; 装载任务寄存器,设置tr寄存器的值。那么现在是装载了任务0的,段选择符0x20,右移三位变成二进制的100,表示gdt表的第4项
movl $LDT0_SEL, %eax ; 想执行任务0,除了要设置tr以外还要设置ldt。
lldt %ax ; 设置来ldt寄存器。
movl $0, current ; 把一个叫做current的变量设置成0
sti ; 不明白这里的开中断是开的哪个中断,难道是boot.s中的cli中断?
; 至此,栈为空。
pushl $0x17 ; 把任务0当前局部数据段选择符入栈。
pushl $init_stack ; 入栈ESP
pushfl ; 入栈标志寄存器
pushl $0x0f ; 入栈当前局部代码段选择符。
pushl $task0 ; 入栈代码位置。
iret ; 这句命令很重要。
; 弹出IP指令指针和CS代码段选择符,以及EFLAGS的值到EIP,CS和EFLAGS寄存器中,然后继续执行中断的程序。
; 如果返回到另一个特权级,那么这个指令再继续执行前还要弹出栈指针和SS寄存器。
; 这里要转换特权级,就要弹出五项,一一对应
; EIP -> $task0的地址
; CS -> 0x0f (00001111),特权级3的ldt的第一项,任务0
; EFLAGS -> EFLAGS 这个不用变
; ESP -> $init_stack的地址
; SS -> 0x17 (00010111),特权级3的ldt的第二项,数据段,也做堆栈段的选择符
; 到这里,就跳到任务0去执行了……我们直接去任务0!
setup_gdt:
lgdt lgdt_opcode ; 使用6位GDTR寄存器记录gdt表的基地址和段限长。在lgdt_opcode处记录着一个已初始化的gdt表格。
ret ; 返回到调用程序。
setup_idt:
lea ignore_int,%edx ; 装入有效地址。将ignore_int的值存在寄存器edx中。
movl $0x00080000,%eax ; 选择符为0x0008,选择第8个idt描述符。
movw %dx, %ax ; 设置ax,此时eax寄存器将会保存选择符合偏移地址。高16位为选择符,低16位保存偏移地址。
movw $0x8E00,%dx ; 设置中断门描述符,其特权级为0。0x8E00 = 1000111000000000b
lea idt,%edi ; 将idt描述符表的基址存在寄存器edi中。此时idt表未初始化,均为0。
mov $256,%ecx ; 将ecx设置为256,稍后用来填充256次idt表。ecx寄存器记录循环变量。
rp_sidt:
movl %eax,(%edi) ; 将寄存器eax的内容移动到edi寄存器记录的地址处。
movl %edx,4(%edi) ; 将寄存器edx的内容移动到edi记录的地址偏移32位处。这两句代码实现了idt表中每个描述符的初始化。
addl $8,%edi ; 将edi偏移64位后使用edi记录当前位置。
dec %ecx ; ecx的值减少1
jne rp_sidt ; 如果ecx的值大于0,继续跳转到rp_sidt处执行。
lidt lidt_opcode ; 使用idtr记录idt的入口地址和限长。
ret ; 返回调用程序。
; 向屏幕打印字符,具体功能不再深入探究。想要了解具体功能的小朋友参考本文:https://juejin.cn/post/7028744851805978638
write_char:
push %gs ; 这里的gs指向内存段0xb8000。
pushl %ebx
mov $SCRN_SEL, %ebx ; 根据屏幕显示内存段选择符来向屏幕打印字符。其原理可以参考本文。
mov %bx, %gs
movl scr_loc, %bx ; 从变量src_loc中取目前字符的显示位置。
; 在屏幕上每个字符还有一个属性字节,因此字符实际显示位置对应的显示内存偏移地址要乘2.
; 把字符放到显示内存后把位置除以2加1,得到下个字符的显示位置。如果过该位置大于2000,归零。
shl $1, %ebx
movb %al, %gs:(%ebx)
shr $1, %ebx
incl %ebx
cmpl $2000, %ebx
jb 1f
movl $0, %ebx
1:
movl %ebx, scr_loc
popl %ebx
pop %gs
ret ; 普通例程,通过call调用,通过ret返回。
.align 2
; 默认中断处理程序,中断向量表初始化时所有向量号对应的都是这个处理程序。
ignore_int:
push %ds
pushl %eax
movl $0x10, %eax
mov %ax, %ds
movl $67, %eax ; 'C'的ASCII为0x67
call write_char
popl %eax
pop %ds
iret
.align 2
; 定时中断处理程序,主要是切换任务。
timer_interrupt:
push %ds
pushl %eax
movl $0x10, %eax
mov %ax, %ds ; 初始化ds指向内核数据段
movb $0x20, %al ; 向8259A发送EOI命令
outb %al, $0x20
movl $1, %eax
cmpl %eax, current ; 根据current的值判断当前执行的任务,如果是任务1,切换到任务0,如果是任务0,切换到任务1.
je 1f
movl %eax, current
ljmp $TSS1_SEL, $0
jmp 2f
1:
movl $0, current
ljmp $TSS0_SEL, $0
2:
popl %eax
pop %ds
iret
.align 2
; 系统中断做的事情就是把当前任务对应的字符打印出来, task0打印“A”,task1打印“B”
system_interrupt:
push %ds
pushl %edx
pushl %ecx
pushl %ebx
pushl %eax
movl $0x10, %edx
mov %dx, %ds
call write_char
popl %eax
popl %ebx
popl %ecx
popl %edx
pop %ds
iret
current:.long 0 ; 变量current,代表当前任务号
scr_loc:.long 0 ; 变量scr_loc,代表当前屏幕显示位置
.align 2
; 使用6字节记录表长度和基址。
lidt_opcode:
.word 256*8-1
.long idt
; 使用6字节记录表长度和基址。
lgdt_opcode:
.word (end_gdt-gdt)-1
.long gdt
.align 3
; 系统将一些具有一定功能的子程序,以中断处理程序的方式提供给应用程序调用。
idt:
.fill 256,8,0 ; 未初始化的idt表格,此时表格所有内容使用0占位。.fill repeat,size,value 本命令生成size个字节的repeat个副本。
; gdt 表中的每一行代表一个描述符,主要定义了段限长,段基址,段权限,段颗粒
; 从gdt到ldt中的的过程逻辑参考:https://blog.csdn.net/u013982161/article/details/52138155
gdt:
.quad 0x0000000000000000 ; .quad定义64位值,第一个描述符,默认不使用。
.quad 0x00c09a00000007ff ; 内核代码段描述符,对应的选择符是0x08
.quad 0x00c09200000007ff ; 内核数据段描述符,对应的选择符是0x10
.quad 0x00c0920b80000002 ; 显示内存段描述符,对应的选择符是0x18
.word 0x0068, tss0, 0xe900, 0x0 ; 定义四个16位的值,其中tss0是段基址
.word 0x0040, ldt0, 0xe200, 0x0 ; 定义四个16位的值,其中ldt0是段基址
.word 0x0068, tss1, 0xe900, 0x0 ; 定义四个16位的值,其中tss1是段基址
.word 0x0040, ldt1, 0xe200, 0x0 ; 定义四个16位的值,其中ldt1是段基址
end_gdt:
.fill 128,4,0 ; 初始化内核堆栈,使用0占位。
init_stack:
.long init_stack ; 申请64bit空间,表示了init_stack这个标号的地址,上面调用lss时用以给sp赋值为init_stack
.word 0x10 ; 申请16bit空间,上面调用lss时用以给ss赋值为0x10,指向内核数据段
.align 3
ldt0:
.quad 0x0000000000000000 ; 第一个选择符默认不使用
.quad 0x00c0fa00000003ff ; 对应的选择符是0x0f, base = 0x00000
.quad 0x00c0f200000003ff ; 对应的选择符是0x17, base = 0x00000
tss0: ; 任务段:用于恢复一个任务执行的处理器状态信息
.long 0 ; 上一个任务的链接
.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
; ldt字段用于给出每个用户自己的LDT,通过把与任务相关的所有段描述符都放在ldt中,可以隔绝任务空间。
.long LDT0_SEL, 0x8000000 ; ldt段选择符,I/O位图基地址。
.fill 128,4,0 ; 任务0的内核空间,这部分空间可以用来存放代码段或者数据段的内容。
krn_stk0:
.align 3
; 任务1的ldt表段内容
ldt1:
.quad 0x0000000000000000 ; 第一个描述符,默认不用。
.quad 0x00c0fa00000003ff ; 选择符是0x0f,基地址是0x00000。
.quad 0x00c0f200000003ff ; 选择符是0x17,基地址是0x00000。
; 任务1的tss表段内容
tss1:
.long 0 ; 前一个任务的链接
.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, fs, gs
.long LDT1_SEL, 0x8000000 ; ldt, trace bitmap
.fill 128,4,0 ; 任务1的内核空间,这部分空间可以用来存放代码段或者数据段的内容。
krn_stk1:
; 下面是任务0和任务1的程序,它们分别循环显示字符'A'和'B'
task0:
movl $0x17, %eax
movw %ax, %ds ; ds设置为0x17
movl $65, %al ; 'A'的ASCII码为0x65
int $0x80 ; 调用int $0x80,这个中断在之前就已经写好了,对应中断例程为system_interrupt
movl $0xfff, %ecx ; 将ecx改为0xfff,意思是一直执行这个task0,因为loop每执行一次会减ecx的值
1:
loop 1b ; 这里task0会一直执行,什么时候切换到task1呢?使用中断。时钟中断在每10ms时8253芯片会发送一个时钟中断,进行任务切换。
jmp task0
; 指令含义同task0
task1:
movl $0x17, %eax
movw %ax, %ds
movl $66, %al
int $0x80
movl $0xfff, %ecx
1:
loop 1b
jmp task1
.fill 128,4,0 ; 任务1的用户栈空间
usr_stk1: