疫情封在家里,不如抽时间读个基于8086的内核

296 阅读14分钟

引言

本文主要参考赵炅老师的linux内核完全注释。下面的代码使用汇编语言,本文更加详细地分析了其中的执行过程,重新注释每句代码。

运行结果:任务0执行10ms打印A字符,任务1执行10ms打印B字符。两个任务轮流占用CPU。

image.png

源码文件下载

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: