《操作系统真像还原》操作系统实现——引导内核
到现在为止,我们已经进入了保护模式并做好了虚拟地址映射、开启了分页模式,loader 的历史使命也差不多该完成了,现在它需要来引导我们的内核并移交控制权了。
内核较为复杂,全部用汇编实现显然是不现实的,类似于大多数的操作系统,我们使用 C 来完成开发。
关于编译方式:
高版本的 GCC 在编译代码的时候开启了许多优化和保护,我的虚拟机为 Ubuntu 20.04,gcc 版本为 9.0,难以生成我们希望的汇编代码,解决方法为降级为 gcc 4.8,使用
gcc -c -o main.o main.c -m32 -fno-asynchronous-unwind-tables
进行编译,可以获得希望最低程度改动的代码(指汇编代码和预期的基本一致)。
关于文件格式:
现代操作系统基本都有对该操作系统的可执行文件的格式进行约定,Linux 下常用为 ELF(Executable and Linkable Format,可执行与可链接格式),Windows 下则为 PE(Portable Executable,可移植的可执行的文件)。我们的大象操作系统当然也可以约定一个格式,比如大象格式。
但是大可不必这样做,说到底来,格式不过是一种约定,浪费时间在约定格式上对我们的学习并无多少帮助,另一方面,使用 ELF 也代表我们可以直接用 Linux + gcc 进行开发,节省许多格式处理上的麻烦。最后 ELF 也是一个成熟的、标准化的格式,广为接受,直接拿来用完全没毛病。
内核代码生成方式
我们的内核代码的入口地址需要我们自己指定,由于内核未来会比较小,所以可以直接放到 1M 空间以下,和书上相同,我也放在虚拟地址 0xC0001500 上,既然这样,就不能让 gcc 直接给我们链接掉,而是需要我们自己用 ld 链接。以 main.c 做例子,就是先用 gcc 生成目标文件 main.o
gcc -c -o main.o main.c -m32 -fno-asynchronous-unwind-tables
然后用 ld 指定入口点和代码段基地址
ld main.o -Ttext 0xc0001500 -e _start -o kernel.bin -m elf_i386
注意命令中的 -e _start
,这是指定入口点符号为 _start,其实默认就是使用这个函数做入口点的。如果习惯用 main 函数做入口点函数的话(其实事实上一般来说 ELF 文件都不是真的以 main 函数作为入口点的),只要把 -e _start
改为 -e main
就可以正常链接了。得到的 kernel.bin 就是我们未来要引导的内核文件了。
然后需要写入磁盘,和书中的选择一样,我也是从 0x9 扇区(第十个扇区)开始写 200 个扇区的,也就是
dd if=./kernel.bin of=/path/to/hd60M.img bs=512 count=200 seek=9 conv=notrunc
读取 ELF 文件
之前的几步做好了准备工作,之后就是要 loader 来做引导了,首先先把 kernel.bin 的内容都读到内存里面来,避免频繁的磁盘 I/O 操作造成性能过低。和书上一样,我也在分页模式开启前读取,虽然其实开启前后读关系都不大。
这里的读取方式可以几乎直接沿用 mbr 中对 loader 的引用方式,只要改一下进行写入操作的寄存器为 32 位寄存器就可以了,看后面的代码就可以很容易理解。主要是读到内存的什么位置比较重要,其实也不是很重要,只要不会覆盖后面的页表,且在内核展开后不会被内核覆盖就可以。多次提到,底端 1M 的内存在未来会映射到自己身上,这 1M 我们准备防止内核代码,提一下其中 0x500 ~ 0x9FBFF 是没有被其他设备映射的,我们可以随便用。顺便提一下,其中 GDT 表处在 0x610 ~ 0x810 中,后面又跟了一些重要的变量。
内核代码放在虚拟地址 0xC00001500,也就是物理地址 0x1500 处。我们沿用 Linux 的习惯,代码从低地址开始向上增长,栈从高地址开始向下增长,中间余留一定空间保证不会交汇。我们可以把 kernel.bin 放在这中间的地方,和书上一样,我也放在了 0x60000 上。
导出 ELF 文件中各段
导出的过程涉及 ELF 的结构,这个结构里面东西挺多的,我觉得没必要死记硬背,这里只要知道我们需要的一些东西就可以了,由于是 32 位系统,所以只考虑 ELF32 的格式。
#define EI_NIDENT 16
typedef struct {
unsigned char e_ident[EI_NIDENT];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry;
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
} Elf32_Ehdr;
这里 Elf32_Half
类型占 2 字节,Elf32_Word
、ELF32_Addr
和 Elf32_Off
三个类型都是 4 字节,偏移可以自己计算。这里面对我们有用的是 e_phoff
、e_phentsize
和 e_phnum
三个成员变量 ,分别代表段表的偏移,段表的大小,段表的总数。
每一个段表项的结构如下
typedef struct {
Elf32_Word p_type; \\ 段的类型
Elf32_Off p_offset; \\ 段距文件头的偏移
Elf32_Addr p_vaddr; \\ 该段应该处于的虚拟地址
Elf32_Addr p_paddr;
Elf32_Word p_filesz; \\ 该段的文件长度(即在文件中的长度,下面哪个是段在内存中占的长度)
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
} Elf32_Phdr;
这里有用到四个变量,已经注释出来了。
那么我们如何导出呢?其实比较容易,首先获得段表基地址和段表项总数,然后遍历段表,通过内存拷贝把对应的数据拷到对应的地址就可以了。
总结
好吧我承认这里我没有说的很清楚,一方面是对 ELF 格式我虽然尝试学了很多次,但是一直没法记下来,所以也不是特别了解,另一方面我觉得说实话也不是很重要;-)
实现代码
之前虽然说的很简略,但是看着代码应该就可以理解了
boot.inc 中新增
KERNEL_START_SECTOR equ 0x9
KERNEL_SUM_SECTOR equ 200
KERNEL_BIN_BASE_ADDR equ 0x60000 ; where we put the kernel.bin
KERNEL_ENTER_POINT equ 0xC0001500 ; the kernel enter point addr
;--------- elf related ----------
PT_NULL equ 0 ; segment type
loader.S
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
jmp LoaderStart ; 3 bytes
db 0
dd 0,0,0 ; addr align
; offset 0x10
; set up GOT and descriptor
GDT_BASE: dd 0x00000000
dd 0x00000000
CODE_DESC: dd 0x0000FFFF ; low 32 bits
dd DESC_CODE_HIGH4 ; high 32 bits
DATA_STACK_DESC: dd 0x0000FFFF ; used by stack and data seg
dd DESC_DATA_HIGH4
; text-mode display
; limit = (0xBFFFF - 0xB8000) / 4K = 0x7
VIDEO_DESC: dd 0x80000007
dd DESC_VIDEO_HIGH4
GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
times 60 dq 0 ; reserve 60 GDTs
TOTAL_MEM_BYTES dd 0 ; memory of the machine
; addr: LOADER_BASE_ADDR + 0x10 + 0x200 = 0x800
SELECTOR_CODE equ ((CODE_DESC - GDT_BASE) / 8) << 3 + TI_GDT + RPL0
SELECTOR_DATA equ ((DATA_STACK_DESC - GDT_BASE) / 8) << 3 + TI_GDT + RPL0
SELECTOR_VIDEO equ ((VIDEO_DESC - GDT_BASE) / 8) << 3 + TI_GDT + RPL0
; pointer point to GDT
gdt_ptr: dw GDT_LIMIT ; low 16 bits of GDT reg
dd GDT_BASE ; high 32 bits of GDT reg
; end of GDT setup
LoaderStart:
; ---------- first, get the total memory of the machine ----------
; ---------- we must do it before enter the PE mode as we need the BIOS int ----------
; use bios int 0x15 sub 0xE801
.LoaderStart_E801FailedRetry:
mov ax,0xE801
int 0x15
jc .LoaderStart_E801FailedRetry
; calculate low 15MB memory
mov cx,0x400
mul cx
shl edx,16
and eax,0x0000FFFF
or edx,eax
add edx,0x100000 ; add 1MB, this is caused by the memory hole
mov esi,edx
xor eax,eax
mov ax,bx
mov ecx,0x10000 ; 64 * 1024
mul ecx
add esi,eax ; esi store the
mov [TOTAL_MEM_BYTES],esi ; now TOTAL_MEM_BYTES stores the total memory
; ---------- ready to enter Proctection mode ----------
; 1 open A20 address line
; 2 load GDT reg
; 3 set pe of cr0 to 1
; open A20
in al,0x92
or al,0000_0010B ; save existed status
out 0x92,al
; load GDT reg
lgdt [gdt_ptr]
; set cr0, let's roll!
mov eax,cr0
or eax,0x00000001 ; save existed status
mov cr0,eax ; enter Protection mode
jmp dword SELECTOR_CODE:ProctectionModeStart ; reflesh assembly line
; ---------- end of function LoaderStart ----------
; ---------- now we are in 32-bits PE mode ----------
[bits 32]
ProctectionModeStart:
; set selectors
mov ax,SELECTOR_DATA
mov ds,ax
mov es,ax
mov ss,ax
mov esp,LOADER_STACK_TOP
mov ax,SELECTOR_VIDEO
mov gs,ax
mov byte [gs:2],'P'
; first thing we do is load the kernel.bin to the RAM
mov esi,KERNEL_START_SECTOR
mov edi,KERNEL_BIN_BASE_ADDR
mov edx,KERNEL_SUM_SECTOR ; read this much sectors
call ReadDiskSector_32
; second thing we do is start the page mode
; 1 setup PDE and related PTE
call SetupPage
; 2 modify the GDT to make it work in paging mode
sgdt [gdt_ptr]
mov ebx,[gdt_ptr + 2]
or dword [ebx + 0x18 + 4],0xC0000000 ; modify the VIDEO_DESC
add dword [gdt_ptr + 2],0xC0000000 ; pre modify the GDTR value
add esp,0xC0000000 ; also modify the stack
mov eax,PAGE_DIR_TABLE_POS
mov cr3,eax
mov eax,cr0
or eax,0x80000000 ; save existed status
mov cr0,eax ; enable paging mode
lgdt [gdt_ptr] ; change GDTR
mov byte [gs:4],'V'
; last thing we do is extract the Ttext to where it belongs
jmp SELECTOR_CODE:EnterKernel
EnterKernel:
call KernelInit
mov esp,0xC009F000 ; set kernel stack
jmp KERNEL_ENTER_POINT ; enter kernel
; end of ProctectionModeStart
; end of loader, thank you and goodbye!
SetupPage:
; ---------- this function setup the Page Directory Entry and Page Table Entry ----------
; clear PTE
mov ecx,0x1000 ; 4K PDE
mov esi,0 ; use this reg the clear
.SetupPage_ClearPDE:
mov byte [PAGE_DIR_TABLE_POS + esi],0
inc esi
loop .SetupPage_ClearPDE
; setup PDE
.SetupPage_CreatePDE:
mov eax,PAGE_DIR_TABLE_POS
add eax,0x1000 ; addr of the first PTE
mov ebx,eax ; ebx is the base addr of PTEs
; make the PDE[0] and PDE[0xC00] point to the first PTE
or eax,PG_US_U | PG_RW_RW | PG_P ; set user page status
mov [PAGE_DIR_TABLE_POS + 0x0],eax ; the first PTE's place, mapping loader's addr to itself
mov [PAGE_DIR_TABLE_POS + 0xC00],eax ; the first PTE used by kernel, mapping to low 1M
; 0xC0000000 ~ 0xFFFFFFFF belongs to kernel
sub eax,0x1000
mov [PAGE_DIR_TABLE_POS + 0xFFC],eax ; make the last Entry point to PDE itself
; creat PTE for kernel
mov ecx,256 ; 1M / 4K = 256
mov esi,0
mov edx,PG_US_U | PG_RW_RW | PG_P ; User, RW, P
.SetupPage_CreatePTE:
mov [ebx + esi * 4],edx
add edx,0x1000
inc esi
loop .SetupPage_CreatePTE
mov eax,PAGE_DIR_TABLE_POS
add eax,0x2000 ; second PTE
or eax,PG_US_U | PG_RW_RW | PG_P
mov ebx,PAGE_DIR_TABLE_POS
mov ecx,254 ; 1022 - 769 + 1
mov esi,769 ; start from 769,the second PTE of kernel
.SetupPage_CreateKernelPDE:
mov [ebx + esi * 4],eax
inc esi
add eax,0x1000
loop .SetupPage_CreateKernelPDE
ret
; ---------- end of function SetupPage ----------
; ---------- start of function ReadDiskSector_32
; function MBR_ReadDiskSector_32(LBA_addr, writing_addr, n), read n sectors from hard-disk in 32 bit mode
; esi: LBA addr of start sector
; edi: writing addr
; edx: n
ReadDiskSector_32:
; read sectors
mov ebx,edx ; bx keeps the n
mov ax,bx ; n sectors
mov dx,0x1F2 ; set reg Sector count
out dx,al ; read n sectors
; set LBA addr
mov eax,esi
mov dx,0x1F3 ; set reg LBA low
out dx,al ; write low 8 bits
mov cl,8
shr eax,cl
mov dx,0x1F4 ; set reg LBA mid
out dx,al ; write LBA mid
shr eax,cl
mov dx,0x1F5 ; set reg LBA high
out dx,al ; write LBA high
shr eax,cl
and al,0xF ; only 4 bits
or al,0xE0 ; 1110b: LBA mode, disk: master
mov dx,0x1F6 ; set reg device
out dx,al ; set mode and LBA addr
; ready to read
mov dx,0x1F7 ; set reg command
mov al,0x20 ; mode: read
out dx,al ; do read
; check disk status
.ReadDiskSector_32_DiskNotReady:
in al,dx ; get disk status
and al,0x88 ; result 0x8 => disk is read
; result 0x80 => disk is busy
cmp al,0x08
jnz .ReadDiskSector_32_DiskNotReady
; read data
mov ax,bx ; get n
mov dx,256 ; read by word, so dx = 512 / 2
mul dx ; assum this mul won't overflow
mov cx,ax ; sum of words need to read
mov dx,0x1F0 ; set reg data
.ReadDiskSector_32_ReadingLoop:
in ax,dx ; read a word
mov [edi],ax ; write a word
add edi,2
loop .ReadDiskSector_32_ReadingLoop
ret
; end of function ReadDiskSector_32
FatalKernelBroken:
mov byte [gs:0],'F'
mov byte [gs:1],0xA4
mov byte [gs:2],'A'
mov byte [gs:3],0xA4
mov byte [gs:4],'T'
mov byte [gs:5],0xA4
mov byte [gs:6],'A'
mov byte [gs:7],0xA4
mov byte [gs:8],'L'
mov byte [gs:9],0xA4
mov byte [gs:10],':'
mov byte [gs:11],0xA4
mov byte [gs:12],' '
mov byte [gs:14],'K'
mov byte [gs:16],'E'
mov byte [gs:18],'R'
mov byte [gs:20],'N'
mov byte [gs:22],'E'
mov byte [gs:24],'L'
mov byte [gs:26],' '
mov byte [gs:28],'B'
mov byte [gs:30],'R'
mov byte [gs:32],'O'
mov byte [gs:34],'K'
mov byte [gs:36],'E'
mov byte [gs:38],'N'
jmp $
KernelInit:
mov eax,[KERNEL_BIN_BASE_ADDR] ; check the magic number
cmp eax,0x464c457f
jne FatalKernelBroken
mov al,[KERNEL_BIN_BASE_ADDR + 4] ; make sure it is a 32 bits elf
cmp al,1
jne FatalKernelBroken
mov al,[KERNEL_BIN_BASE_ADDR + 5] ; make sure it is a LSB elf
cmp al,1
jne FatalKernelBroken
; check done
mov ebx,[KERNEL_BIN_BASE_ADDR + 28] ; offset of program header table
add ebx,KERNEL_BIN_BASE_ADDR ; address of program header table
xor edx,edx
mov dx,[KERNEL_BIN_BASE_ADDR + 42] ; program header size
mov cx,[KERNEL_BIN_BASE_ADDR + 44] ; sum of segments
.LoadKernelEachSegment:
cmp byte [ebx],PT_NULL ; skip th null segment
je .LoadKernelEachSegment_PT_NULL
push dword [ebx + 16] ; nbytes, p_filesz
mov eax,[ebx + 4]
add eax,KERNEL_BIN_BASE_ADDR ; src
push eax
push dword [ebx + 8] ; dst
call mem_cpy
add esp,12 ; unpush 3
.LoadKernelEachSegment_PT_NULL:
add ebx,edx ; skip the header
loop .LoadKernelEachSegment
ret
; ---------- function mem_cpy(dst,src,nbytes) ----------
mem_cpy:
push ebp
mov ebp,esp
push edi
push esi
push ecx
mov edi,[ebp + 8] ; dst
mov esi,[ebp + 12] ; src
mov ecx,[ebp + 16] ; nbytes
cld
rep movsb
pop ecx
pop esi
pop edi
leave
ret
kernel/main.c
int _start()
{
int i = 0;
while(1)
{
i++;
asm volatile(
"movb $\'K\',%gs:6"
);
};
return 0;
}
到现在为止,我们向屏幕输出了四个字符:“MPVK”,分别在 mbr,保护模式,分页模式,内核中输出,代表四模式的成功进入。

之后我们就可以以 C 为主进行开发了。
另外说一下,这里的引导应该说是不太完整的,相应的段属性都没有设置,之后应该会逐渐完善。