在本系列的前一篇文章《操作系统内核Hack:(三)BootLoader制作》中,我们制作出了一个两阶段引导BootLoader,并进入了一个内核的空壳main函数。本文我们继续完善引导程序和内核,让内核的内容一点点充实起来。本文的代码可以参考GitHub上的MiniOS分支kernel_prototype。
像各个模块的内存这种常量,会经常被引导,所以就提取出一个单独的文件var.inc。同理,保护模式相关的常量和宏都提取到了pm.inc,这里主要是拷贝了Orange’s的代码和注释。
; var.inc
; ############################ ; Constants ; ############################
SETUPLEN equ 4
BOOTSEG equ 0x07c0
INITSEG equ 0x9000
SETUPSEG equ 0x9020
SYSSEG equ 0x1000
NEWSYSSEG equ 0x0000
MEMSIZE equ 0 ; INITSEG:MEMSIZE
; pm.inc
; ############################ ; Macros ; ############################
; 描述符类型
DA_32 equ 4000h ; 32 位段
DA_LIMIT_4K equ 8000h ; 段界限粒度为 4K 字节
; 存储段描述符类型
DA_DR equ 90h ; 存在的只读数据段类型值
DA_DRW equ 92h ; 存在的可读写数据段属性值
DA_C equ 98h ; 存在的只执行代码段属性值
DA_CR equ 9Ah ; 存在的可执行可读代码段属性值
; Descriptor macro
%macro Descriptor 3
dw %2 & 0FFFFh ; Limit 1
dw %1 & 0FFFFh ; Base addr 1
db (%1 >> 16) & 0FFh ; Base addr 2
dw ((%2 >> 8) & 0F00h) | (%3 & 0F0FFh) ; Attr 1 + Limit 2 + Attr 2
db (%1 >> 24) & 0FFh ; Base addr 3
%endmacro
因为include/的提取和后面第二阶段引导过程拆分的影响,所以Makefile也要做相应的修改:
ASINC = -I include/
ASFLAGS = -f elf
LD = ld
# -Ttext org -e entry -s(omit all symbol info)
# -x(discard all local symbols) -M(print memory map)
LDFLAGS = -Ttext 0 -e startup_32 --oformat binary -s -x -M
...
# SYSSIZE = system file size
boot1/bootsect: boot1/bootsect.asm include/var.inc system/system
(echo -n "SYSSIZE equ ";ls -l system/system | grep system \
| cut -d " " -f 5 | tr '\012' ' ') > tmp.asm
cat $< >> tmp.asm
$(AS) $(ASINC) -o $@ tmp.asm
rm -f tmp.asm
boot2/setup: boot2/setup.asm include/var.inc include/pm.inc
$(AS) $(ASINC) -o $@ $<
system/system: system/init/head.o system/init/main.o
$(LD) $(LDFLAGS) \
system/init/head.o \
system/init/main.o \
-o $@ > System.map
system/init/head.o: system/init/head.asm include/var.inc include/pm.inc
$(AS) $(ASFLAGS) $(ASINC) -o $@ $<
...
之前为了简化代码,避免被细枝末节干扰,所以从软盘加载system到内存的代码写的非常简单,只读取了一个扇区。随着我们的system模块越来越大,这样是肯定不行的,于是就想参考Linux源码做一下改进。
结果不看不知道,一细研究还真吓一跳每个磁道有18个扇区,每个磁头有80个磁道,根据要加载的数据大小,自己负责切换扇区、磁道、磁头号。就这样还不行,要加载到的内存位置是用[es:bx]表示的,也就是说bx偏移逐渐增加最后要溢出的时候,我们还得修改es重置bx,避免它溢出。原来想写一段通用的从软盘加载数据的代码这么费劲啊!先看看我写的,因为之前已经加载过bootsect和setup了,所以第一个磁道还需读取13个扇区,之后的每个磁道读取18个扇区,最后一个磁道根据还剩余多少扇区没读决定要读取多少,先不考虑磁头和bx溢出问题。结果已经来回调试改进了好几遍了,还是有问题。
; 4) Load system module at 0x10000
; Assume SYSSIZE < 1 head
; 1 track = 18 sectors * 512b = 9216(b)
; 1 head = 80 tracks * 9216 = 720(kb)
_Sector: db 0
_Track: db 0
Sector equ _Sector-$$
Track equ _Track-$$
SECT_PER_TRACK equ 18
LEFT_IN_TRACK1 equ SECT_PER_TRACK - 1 - SETUPLEN
load_system:
mov ax, SYSSEG
mov es, ax
mov bx, 0000h ; es:bx = target(es=1000h,bx=0)
mov dx, 0000h ; dx = driver(dh)/head(dl)
mov cx, 0006h ; cx = track(ch)/sector(cl)
mov ax, SYSSIZE
add ax, 511
shr ax, 9 ; al = (SYSSIZE + 511) / 512, sectors to read
mov byte [Sector], al
cmp al, LEFT_IN_TRACK1
jbe .loop
mov al, LEFT_IN_TRACK1 ; al = (al <= 13) ? al : 13
.loop
mov ah, 02h ; ah = service id(ah=02 means read)
int 13h ; ignore any error
sub byte [Sector], al ; remainingSector -= al
cmp byte [Sector], 0
je ok_load_system
xor ah, ah
shl ax, 9
add bx, ax ; offset += (al * 512)
add byte [Track], 1
mov ch, byte [Track] ; track++
mov cl, 1 ; start at first sector
xor ax, ax
mov al, byte [Sector]
cmp al, SECT_PER_TRACK
jbe .loop
mov al, SECT_PER_TRACK ; al = (al <= 18) ? al : 18
jmp .loop
最后发现一次读取跨磁道的扇区也没关系,Bochs的BIOS支持一次最多读取72个扇区。于是就放弃了,先读取最多72个吧,对于现阶段的system的规模是暂时够用了,到时再改吧。此外,要注意的是对要加载的扇区数的计算:这里SYSSIZE是system的实际大小,而不是Linux中所谓的click数(实际size加15后左移了4位)。并且为了避免丢失余数的差一问题,我们要先加上511。
; 4) Load system module at 0x10000
; Assume SYSSIZE < 72 sectors (36864)
; 1 track = 18 sectors * 512b = 9216(b)
; 1 head = 80 tracks * 9216 = 720(kb)
MAX_ONE_READ equ 72
load_system:
mov ax, SYSSEG
mov es, ax
mov bx, 0000h ; es:bx = target(es=1000h,bx=0)
mov dx, 0000h ; dx = driver(dh)/head(dl)
mov cx, 0006h ; cx = track(ch)/sector(cl)
mov ax, SYSSIZE
add ax, 511
shr ax, 9 ; al = (SYSSIZE + 511) / 512 sectors to read
cmp al, MAX_ONE_READ
jbe .loop
mov al, MAX_ONE_READ ; al = (al <= 72) ? al : 72
.loop
mov ah, 02h ; ah = service id(ah=02 means read)
int 13h ; ignore any error
至此所有要加载的数据就都加载完了,所以我们可以关掉软驱的马达了。这样可以关闭软盘控制器FDC、禁止DMA和中断请求。具体细节有待深入研究。
; 5) Kill motor
ok_load_system:
mov dx, 0x3f2 ; floppy controller port
mov al, 0 ; floppy A
outb ; output al to dx port
在上一篇文章中,在setup.asm中进入了保护模式,并执行了一段32位的代码。我们其实可以将进入保护模式之后的内核初始化工作继续填到setup.asm这段32位代码中,但这样做不如Linux 0.11的方式优雅,即将这部分工作放到system模块的头部去完成。缺点是可能引导过程有些零散,但优点就是因为system会被加载到0x0处,所以后续初始化的页目录表和页表、重放置后的GDT都会在低地址,安全、集中且易于管理,这在我们的上一篇文章中也提到了。
setup.asm首先读取BIOS中的有用信息保存到0x9000,即覆盖了bootsect的内存位置,因为它已经没有用了。然后将system拷贝到0x0低地址,进入保护模式后就跳转到system。
%include "var.inc"
%include "pm.inc"
; ############################
; Booting Process
; ############################
[SECTION .s16]
[BITS 16]
; 1) Read memory info from BIOS
mov ax, INITSEG
mov ds, ax ; save to bootsect space
mov ah, 0x88
int 0x15
mov [MEMSIZE], ax ; ax=3c00h (15360kb=15mb)
; 2) Move system to 0x0000
; round-1: 10000~1ffff => 0000~ffff
; ...
; round-5: 80000~8ffff => 70000~7ffff
;
; NOTE: 8000h word = 10000h byte
WordPerMove equ 8000h
move_system:
mov ax, 0h
.loop
mov es, ax
add ax, 1000h
mov ds, ax
mov cx, WordPerMove ; cx = counter
xor si, si ; ds:si = source
xor di, di ; es:di = target
rep movsw ; move
cmp ax, INITSEG
jne .loop
; 3) Enter protection mode
mov ax, cs
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0100h
; 3.1) Load gdt to gdtr
xor eax, eax
mov ax, ds
shl eax, 4
add eax, LABEL_GDT ; eax <- gdt base addr
mov dword [GdtPtr + 2], eax ; [GdtPtr + 2] <- gdt base addr
lgdt [GdtPtr]
; 3.2) Disable interrupt
cli
; 3.3) Enable A20 addr line
in al, 92h
or al, 00000010b
out 92h, al
; 3.4) Set PE in cr0
mov eax, cr0
or eax, 1
mov cr0, eax
; 3.5) Jump to protective mode!
jmp dword SelectorSystem:0 ; 0x0000:0x0
[SECTION .gdt]
; Base Addr, Limit, Attribute
LABEL_GDT: Descriptor 0h, 0h, 0h
LABEL_DESC_SYSTEM: Descriptor 0h, 0ffffh, DA_CR | DA_32 | DA_LIMIT_4K
LABEL_DESC_DATA: Descriptor 0h, 0ffffh, DA_DRW | DA_32 | DA_LIMIT_4K
LABEL_DESC_VIDEO: Descriptor 0B8000h, 0ffffh, DA_DRW
GdtLen equ $ - LABEL_GDT
GdtPtr dw GdtLen - 1 ; GDT limit
dd 0 ; GDT base addr
SelectorSystem equ LABEL_DESC_SYSTEM - LABEL_GDT
SelectorData equ LABEL_DESC_DATA - LABEL_GDT
SelectorVideo equ LABEL_DESC_VIDEO - LABEL_GDT
读取BIOS中内存信息的方式有几种,目前setup.s中用的是最简单的一种,也是Linux 0.11中用的方式。代码只有三行,极其简单。获取结果保存在ax中,单位是KB,不是Byte:
mov ah, 0x88 int 0x15 mov [MEMSIZE], ax ; ax=3c00h (15360kb=15mb)
但这种方式的缺点也同样明显,就是最大只支持64MB。因为ax只有16位,最大能表示65536。所以在Orange’s中作者用了另一种更为强大的方式,ax=0xe820的int 0x15中断,除了能获得内存大小外,还能获得到内存分布。当然缺点就是代码比前面这种方式要麻烦多了,所以这里就不细说了,知道有多种获取BIOS中内存信息的方式就可以了。
这就是我们新拆分出来的head.asm,它将各个段寄存器置为内核的代码段Selector后,就开始内核初始化的工作了:
%include "var.inc"
%include "pm.inc"
extern main
global startup_32
pdt:
[SECTION .text]
ALIGN 32
[BITS 32]
startup_32:
mov ax, 16 ; SelectorData
mov ds, ax
mov es, ax
mov ss, ax
mov esp, TopOfStack
; 1) Print welcome message
mov ax, 24 ; SelectorVideo
mov gs, ax
mov ah, 0Ch
mov ebx, 0
mov ecx, len
mov edx, Message
.loop:
mov edi, ebx
add edi, (80 * 20) ; (80 * row + col) * 2
imul edi, 2
mov al, byte [edx]
mov [gs:edi], ax
inc ebx
dec ecx
inc edx
cmp ecx, 0h
jne .loop
; 2) Reset GDTR
lgdt [GdtPtr]
; 3) Prepare return address
push main
jmp setup_paging
; Temporary data and stack, will be overriden later
Message:
db "Welcome to MiniOS"
len equ $ - Message
; LinearAddr[31~22] = 10 bits = 1024 entry (* 4B = 4096B)
; So PDT has 1024 entries (1024 page tables, occupy 4096b totally)
; LinearAddr[21~12] = 10 bits = 1024 entry (* 4B = 4096B)
; So PD has 1024 entries (1024 pages, occupy 4096b totally)
; LinearAddr[11~0] = 12 bits = 4096 byte
; So offset (page size) is 4096b
PdtSize equ 1024
PtSize equ 1024
EntrySize equ 4
PageSize equ 4096
times PdtSize*EntrySize-($-$$) db 0
pg0:
times PtSize*EntrySize db 0
pg1:
times PtSize*EntrySize db 0
pg2:
times PtSize*EntrySize db 0
pg3:
times PtSize*EntrySize db 0
; 4) Setup paging
PgRw equ 111h
ALIGN 32
setup_paging:
; 4.1) Clear page space
mov ecx, PdtSize + PtSize ; counter = 5*1024
xor eax, eax
xor edi, edi
cld ; DF=0: edi move forward
rep stosd ; move eax => [es:edi] by dword
; 4.2) Fill page dir
mov dword [pdt], pg0 + PgRw ; 111h(7): read/write page
mov dword [pdt+04h], pg1 + PgRw
mov dword [pdt+08h], pg2 + PgRw
mov dword [pdt+0ch], pg3 + PgRw
; 4.3) Fill page table
; pg0~3 can represent 0h ~ fff000h (16MB) memory space
; 0x3ffc: start addr of last entry of last PT
mov edi, (pg3 + PtSize * EntrySize) - EntrySize
; 0xfff000: start addr of last page represented by last entry
mov eax, ((4 * PtSize * PageSize) - PageSize) + PgRw
std ; DF=1: edi move backward
.loop:
stosd ; move eax => [es:edi] by dword
sub eax, PageSize
jge .loop
; 4.4) Set cr3 (PDBR, Page-Dir Base address Register)
xor eax, eax
mov cr3, eax
; 4.5) Set PG bit of cr0 to enable paging
mov eax, cr0
or eax, 80000000h
mov cr0, eax
; 4.6) Transfer control to main()
ret
; Temporary stack space
times 100h db 0
TopOfStack equ $
;[SECTION .gdt]
; Base Addr, Limit, Attribute
LABEL_GDT: Descriptor 0h, 0h, 0h
LABEL_DESC_SYSTEM: Descriptor 0h, 0ffffh, DA_CR | DA_32 | DA_LIMIT_4K
LABEL_DESC_DATA: Descriptor 0h, 0ffffh, DA_DRW | DA_32 | DA_LIMIT_4K
times 253 dd 0x0, 0x0 ; space for LDT and TSS
GdtLen equ $ - LABEL_GDT
GdtPtr dw GdtLen - 1 ; GDT limit
dd LABEL_GDT ; GDT base addr
SelectorSystem equ LABEL_DESC_SYSTEM - LABEL_GDT
SelectorData equ LABEL_DESC_DATA - LABEL_GDT
代码有些长,但比较清晰,真正的难点在于我们之前没有接触到的分页管理机制。其实我们此刻不是必须开启分页管理,但为了避免麻烦,我们这次就多做一点,把分页给弄好,这样以后就没有后顾之忧了。
可能大家刚才看上面代码时没有注意标签,现在后看一下就会发现精心放置好的标签,包括pdt、pg0~3、以及后面的栈空间和GDT。这些标签和代码在运行时对应的内存空间非常重要,都是内核最重要的数据,所以它们的位置绝对不是随意放置的:
--------pdt-----------
| 0x0000 | 0000 0000 |
| ... | ... |
| 0x0FFC | 0000 0000 |
|-------pg0----------|
| 0x1000 | 0000 0000 |
| ... | ... |
| 0x1FFC | 0000 0000 |
|-------pg1----------|
| 0x2000 | 0000 0000 |
| ... | ... |
| 0x2FFC | 0000 0000 |
|-------pg2----------|
| 0x3000 | 0000 0000 |
| ... | ... |
| 0x3FFC | 0000 0000 |
|-------pg3----------|
| 0x4000 | 0000 0000 |
| ... | ... |
| 0x4FFC | 0000 0000 |
| ... | ... |
| ... | ... |
|------stack---------|
| ... | ... |
|-------gdt----------|
| ... | ... |
《Linux 0.11中的页目录表及页表内容分析》作者对Linux 0.11中setup_paging处的代码进行了详细分析,清晰易懂,非常棒!
首先解释一下cld和std两个命令的用处,其实很简单:“在字符串的比较、赋值、读取等一系列和rep连用的操作中,di或si是可以自动增减的而不需要人来加减它的值,cld即告诉程序si,di向前移动,std指令为设置方向,告诉程序si,di向后移动”。
下面就重点说一下PDT和PT的初始化过程,为了使代码尽可能的清晰,很多“魔数”都提取成了常量。
; 4.1) Clear page space
mov ecx, PdtSize + PtSize ; counter = 5*1024
xor eax, eax
xor edi, edi
cld ; DF=0: edi move forward
rep stosd ; move eax => [es:edi] by dword
; 4.2) Fill page dir
mov dword [pdt], pg0 + PgRw ; 111h(7): read/write page
mov dword [pdt+04h], pg1 + PgRw
mov dword [pdt+08h], pg2 + PgRw
mov dword [pdt+0ch], pg3 + PgRw
; 4.3) Fill page table
; pg0~3 can represent 0h ~ fff000h (16MB) memory space
; 0x3ffc: start addr of last entry of last PT
mov edi, (pg3 + PtSize * EntrySize) - EntrySize
; 0xfff000: start addr of last page represented by last entry
mov eax, ((4 * PtSize * PageSize) - PageSize) + PgRw
std ; DF=1: edi move backward
.loop:
stosd ; move eax => [es:edi] by dword
sub eax, PageSize
jge .loop
PDT和PT到底是什么样子呢?要是看完前面的代码解释还是觉得很抽象的话,我们就直观的看看初始化成功后,内存从低到高的模样!
--------pdt-----------
| 0x0000 | 0000 1111 | => pg0
| 0x0004 | 0000 2111 | => pg1
| 0x0008 | 0000 3111 | => pg2
| 0x000C | 0000 4111 | => pg3
| 0x0010 | 0000 0000 |
| ... | ... |
| 0x0FFC | 0000 0000 |
|-------pg0----------| Physical Address
| 0x1000 | 0000 0111 | => [00000000~00000FFF]
| 0x1004 | 0000 1111 | => [00001000~00001FFF]
| 0x1008 | 0000 2111 | => [00002000~00002FFF]
| ... | ... |
| 0x1FFC | 003F F111 | => [003FF000~003FFFFF]
|-------pg1----------|
| 0x2000 | 0040 0111 | => [00400000~00400FFF]
| 0x2004 | 0040 1111 | => [00401000~00401FFF]
| 0x2008 | 0040 2111 | => [00402000~00402FFF]
| ... | ... |
| 0x2FFC | 007F F111 | => [007FF000~007FFFFF]
|-------pg2----------|
| 0x3000 | 0080 0111 | => [00800000~00800FFF]
| 0x3004 | 0080 1111 | => [00801000~00801FFF]
| 0x3008 | 0080 2111 | => [00802000~00802FFF]
| ... | ... |
| 0x3FFC | 00BF F111 | => [00BFF000~00BFFFFF]
|-------pg3----------|
| 0x4000 | 00C0 0111 | => [00C00000~00C00FFF]
| 0x4004 | 00C0 1111 | => [00C01000~00C01FFF]
| 0x4008 | 00C0 2111 | => [00C02000~00C02FFF]
| ... | ... |
| 0x4FFC | 00FF F111 | => [00CFF000~00CFFFFF]
下面就运行起来Bochs,验证一下页表是否初始化成功了。我们查看几个关键位置就可以了,比如页目录表(0x0000),四个页表的开头部分(0x1000, 0x2000, 0x3000, 0x4000),以及pg4的最末尾部分(0x4ffc)。
(0) Breakpoint 1, 0x00005047 in ?? ()
Next at t=15473080
(0) [0x0000000000005047] 0008:00005047 (unk. ctxt): xor eax, eax ; 31c0
<bochs:3> xp /32bx 0x00000
[bochs]: 0x00000000 <bogus+ 0>: 0x11 0x11 0x00 0x00 0x11 0x21 0x00 0x00 0x00000008 <bogus+ 8>: 0x11 0x31 0x00 0x00 0x11 0x41 0x00 0x00 0x00000010 <bogus+ 16>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00000018 <bogus+ 24>: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 <bochs:4> xp /32bx 0x01000 [bochs]: 0x00001000 <bogus+ 0>: 0x11 0x01 0x00 0x00 0x11 0x11 0x00 0x00 0x00001008 <bogus+ 8>: 0x11 0x21 0x00 0x00 0x11 0x31 0x00 0x00 0x00001010 <bogus+ 16>: 0x11 0x41 0x00 0x00 0x11 0x51 0x00 0x00 0x00001018 <bogus+ 24>: 0x11 0x61 0x00 0x00 0x11 0x71 0x00 0x00 <bochs:5> xp /32bx 0x02000 [bochs]: 0x00002000 <bogus+ 0>: 0x11 0x01 0x40 0x00 0x11 0x11 0x40 0x00 0x00002008 <bogus+ 8>: 0x11 0x21 0x40 0x00 0x11 0x31 0x40 0x00 0x00002010 <bogus+ 16>: 0x11 0x41 0x40 0x00 0x11 0x51 0x40 0x00 0x00002018 <bogus+ 24>: 0x11 0x61 0x40 0x00 0x11 0x71 0x40 0x00 <bochs:6> xp /32bx 0x03000 [bochs]: 0x00003000 <bogus+ 0>: 0x11 0x01 0x80 0x00 0x11 0x11 0x80 0x00 0x00003008 <bogus+ 8>: 0x11 0x21 0x80 0x00 0x11 0x31 0x80 0x00 0x00003010 <bogus+ 16>: 0x11 0x41 0x80 0x00 0x11 0x51 0x80 0x00 0x00003018 <bogus+ 24>: 0x11 0x61 0x80 0x00 0x11 0x71 0x80 0x00 <bochs:8> xp /4bx 0x04ffc [bochs]: 0x00004ffc <bogus+ 0>: 0x11 0xf1 0xff 0x00
个人感觉底层编程的学习曲线非常陡峭,要积累好多知识,爬过好多的“坑”,才能走到这一步,所以好的学习资料是非常重要的。以下就是我学习过程中常用的资料,它们的用法是:以《Linux内核完全剖析》为主,如果看不懂就去找《Orange’s:一个操作系统实现》中对应的章节对比学习。渐渐熟悉Linux的代码后,就参照Linux实现我们的操作系统。如果碰到NASM语法的相关问题,就去看一下博古以通今的博客,作者用NASM重写的代码还是靠谱的,只不过下不到全部代码了。
《完全剖析》中给出的代码包不是很方便,有热心的网友已经做了优化,“一键”就可直接编译运行起来Linux 0.11。
《用nasm语言重新实现linux-0.11 bootsect.s(博古以通今)》
《用nasm语言重新实现linux-0.11 setup.s (博古以通今)》
《nasm重写linux-0.11 head.s (博古以通今)》
官方文档提供了一份NASM汇编指令列表,令我惊讶的是这份列表貌似并不全,有些查不到的指令如jge也是可用的。
此外,汇编语言没有高级语言那些控制结构,所以jmp对于实现逻辑就非常重要了,这是一份总结的不错的各种跳转指令的列表。