《操作系统真像还原》操作系统实现——引导内核

Posted on May 18, 2021

到现在为止,我们已经进入了保护模式并做好了虚拟地址映射、开启了分页模式,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 下常用为 ELFExecutable and Linkable Format,可执行与可链接格式),Windows 下则为 PEPortable 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_WordELF32_AddrElf32_Off 三个类型都是 4 字节,偏移可以自己计算。这里面对我们有用的是 e_phoffe_phentsizee_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 为主进行开发了。

另外说一下,这里的引导应该说是不太完整的,相应的段属性都没有设置,之后应该会逐渐完善。