一、整理下到目前為止的流程圖
寫到這,終於才把一些苦力活都干完了,也終於到了我們的內核代碼部分,也終於開始第一次用 c 語言寫代碼了!為了這個階段性的勝利,以及更好地進入內核部分,下圖貼一張到目前為止的流程圖。(其中黃色部分是今天准備做的事情)
二、先上代碼
loader.asm
...
;加載kernel
mov eax,0x9 ;kernel.bin所在的扇區號 0x9
mov ebx,0x70000 ;寫入的內存地址 0x70000
mov ecx,200 ;讀入的扇區數
call rd_disk_m_32
...
;進入內核
call kernel_init
mov byte [gs:0x280],'i'
mov byte [gs:0x282],'n'
mov byte [gs:0x284],'i'
mov byte [gs:0x286],'t'
mov byte [gs:0x28a],'k'
mov byte [gs:0x28c],'e'
mov byte [gs:0x28e],'r'
mov byte [gs:0x290],'n'
mov byte [gs:0x292],'e'
mov byte [gs:0x294],'l'
mov esp,0xc009f000
jmp 0xc0001500
; 將kernel.bin中的segment拷貝到編譯的地址
kernel_init:
xor eax,eax
xor ebx,ebx ;記錄程序頭表地址(內核地址+程序頭表偏移地址)
xor ecx,ecx ;記錄程序頭中的數量
xor edx,edx ;記錄程序頭表中每個條目的字節大小
mov dx,[0x70000+42] ;偏移文件42字節處是e_phentsize
mov ebx,[0x70000+28] ;偏移文件28字節處是e_phoff
add ebx,0x70000
mov cx,[0x70000+44] ;偏移文件44字節處是e_phnum
.each_segment:
cmp byte [ebx+0],0 ;p_type=0,說明此頭未使用
je .PTNULL
push dword [ebx+16] ;p_filesz壓入棧(mem_cpy第三個參數)
mov eax,[ebx+4]
add eax,0x70000
push eax ;p_offset+內核地址=段地址(mem_cpy第二個參數)
push dword [ebx+8] ;p_vaddr(mem_cpy第一個參數)
call mem_cpy
add esp,12
.PTNULL:
add ebx,edx ;ebx指向下一個程序頭
loop .each_segment
ret
;主子拷貝函數(dst,src,size)
mem_cpy:
cld
push ebp
mov ebp,esp
push ecx
mov edi,[ebp+8] ;dst
mov esi,[ebp+12] ;src
mov ecx,[ebp+16] ;size
rep movsb
pop ecx
pop ebp
ret
; 以下是兩個函數的具體實現,不看不影響理解主流程
; 保護模式的硬盤讀取函數
rd_disk_m_32:
mov esi, eax
mov di, cx
mov dx, 0x1f2
mov al, cl
out dx, al
mov eax, esi
; 保存LBA地址
mov dx, 0x1f3
out dx, al
mov cl, 8
shr eax, cl
mov dx, 0x1f4
out dx, al
shr eax, cl
mov dx, 0x1f5
out dx, al
shr eax, cl
and al, 0x0f
or al, 0xe0
mov dx, 0x1f6
out dx, al
mov dx, 0x1f7
mov al, 0x20
out dx, al
.not_ready:
nop
in al, dx
and al, 0x88
cmp al, 0x08
jnz .not_ready
mov ax, di
mov dx, 256
mul dx
mov cx, ax
mov dx, 0x1f0
.go_on_read:
in ax, dx
mov [ds:ebx], ax
add ebx, 2
loop .go_on_read
ret
main.c
#include "print.h"
int main(void){
put_str("put_str finish\n");
while(1);
return 0;
}
print.h
#ifndef __LIB_KERNEL_PRINT_H
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"
void put_char(uint8_t char_asci);
void put_str(char* message);
#endif
print.asm
TI_GDT equ 0
RPL0 equ 0
SELECTOR_VIDEO equ (0x0003<<3)+TI_GDT+RPL0
[bits 32]
section .text
global put_str
put_str:
push ebx
push ecx
xor ecx,ecx
mov ebx,[esp+12]
.goon:
mov cl,[ebx]
cmp cl,0
jz .str_over
push ecx
call put_char
add esp,4
inc ebx
jmp .goon
.str_over:
pop ecx
pop ebx
ret
global put_char
put_char:
pushad
;保證gs中為正確到視頻段選擇子
mov ax,SELECTOR_VIDEO
mov gs,ax
;獲取當前光標位置
;獲得高8位
mov dx,0x03d4 ;索引寄存器
mov al,0x0e
out dx,al
mov dx,0x03d5
in al,dx
mov ah,al
;獲得低8位
mov dx,0x03d4
mov al,0x0f
out dx,al
mov dx,0x03d5
in al,dx
;將光標存入bx
mov bx,ax
mov ecx,[esp+36]
cmp cl,0xd
jz .is_carriage_return
cmp cl,0xa
jz .is_line_feed
cmp cl,0x8
jz .is_backspace
jmp .put_other
.is_backspace:
dec bx
shl bx,1
mov byte [gs:bx],0x20
inc bx
mov byte [gs:bx],0x07
shr bx,1
jmp .set_cursor
.put_other:
shl bx,1
mov [gs:bx],cl
inc bx
mov byte [gs:bx],0x07
shr bx,1
inc bx
cmp bx,2000
jl .set_cursor
.is_line_feed:
.is_carriage_return:
;cr(\r),只要把光標移到首行就行了
xor dx,dx
mov ax,bx
mov si,80
div si
sub bx,dx
.is_carriage_return_end:
add bx,80
cmp bx,2000
.is_line_feed_end:
jl .set_cursor
.roll_screen:
cld
mov ecx,960
mov esi,0xc00b80a0 ;第1行行首
mov edi,0xc00b8000 ;第0行行首
rep movsd
;最后一行填充為空白
mov ebx,3840
mov ecx,80
.cls:
mov word [gs:ebx],0x0720
add ebx,2
loop .cls
mov bx,1920 ;最后一行行首
.set_cursor:
;將光標設為bx值
;設置高8位
mov dx,0x03d4
mov al,0x0e
out dx,al
mov dx,0x03d5
mov al,bh
out dx,al
;再設置低8位
mov dx,0x03d4
mov al,0x0f
out dx,al
mov dx,0x03d5
mov al,bl
out dx,al
.put_char_done:
popad
ret
Makefile
mbr.bin: mbr.asm
nasm -I include/ -o out/mbr.bin mbr.asm -l out/mbr.lst
loader.bin: loader.asm
nasm -I include/ -o out/loader.bin loader.asm -l out/loader.lst
kernel.bin: kernel/main.c
nasm -f elf -o out/print.o lib/kernel/print.asm
gcc -I lib/kernel/ -c -o out/main.o kernel/main.c
ld -Ttext 0xc0001500 -e main -o out/kernel.bin out/main.o out/print.o
os.raw: mbr.bin loader.bin kernel.bin
../bochs/bin/bximage -hd -mode="flat" -size=60 -q target/os.raw
dd if=out/mbr.bin of=target/os.raw bs=512 count=1
dd if=out/loader.bin of=target/os.raw bs=512 count=4 seek=2
dd if=out/kernel.bin of=target/os.raw bs=512 count=200 seek=9
brun:
make install
make only-bochs-run
only-bochs-run:
../bochs/bin/bochs -f ../bochs/bochsrc.disk -q
install:
make clean
make -r os.raw
三、鳥瞰代碼
;加載kernel
mov eax,0x9 ;kernel.bin所在的扇區號 0x9
mov ebx,0x70000 ;寫入的內存地址 0x70000
mov ecx,200 ;讀入的扇區數
call rd_disk_m_32
;進入內核
call kernel_init
mov esp,0xc009f000
jmp 0xc0001500
我將關鍵部分提取出來,有助於你鳥瞰本講的全部代碼要做的事。本段代碼實際上就做了這么幾個事:
- 將硬盤第 9 扇區開始后的 200 個扇區的內容(包括 kernel.bin),復制到內存 0x70000 開始的地方
- call kernel_init 調用了一下這個方法,這個方法干嘛之后再說,也是重點
- 棧指針賦值為 0xc009f000,並跳轉到 0xc0001500 開始執行
有一點有些不符合我們的直覺,既然 kernel.bin 被寫入內存第 0x70000 位置了,按照我們之前一跳二跳三跳的寫法,應該直接跳轉到 0x70000,可為什么是 0xc0001500 呢?
下面直接解答這個問題,
kernel.bin 是用 c 語言 寫好之后編譯出來的產物,不像之前我們都是直接匯編語言 .asm 編譯成 .bin。c 語言在 linux 的 gcc 工具編譯后的二進制文件,是一個格式為 ELF 的文件,並不完全是從頭到尾都是可執行的機器指令。
這個格式里肯定有某個地方指出,指令代碼在什么位置(相對文件開始的偏移量),並且要求加載這種格式文件的程序(kernel_init),將指令代碼放在內存中的什么位置(0xc0001500)。
如果是這樣的話,整個流程就說通了,kernel_init 只是將 kernel.bin 這個 ELF 格式的文件里的關鍵信息提取出來,最重要的就是加載到內存中的什么位置這個信息,然后執行相應的處理操作。
那接下來,我們就該詳細看看,ELF 格式究竟是什么?
四、詳解 ELF 格式
ELF:1999 年,被 86open 項目選為 x86 架構上的類 Unix 操作系統的二進制文件標准格式,用來取代 COFF,也是 Linux 的主要可執行文件格式
為什么要有這種格式呢?其實沒有這種格式也是完全可以的,但我們用戶寫的應用程序,是獨立與操作系統之外的。換句話說,就是需要操作系統這個 主應用程序,去調用那些用戶寫出來的 應用程序。如果沒有一種特定的格式當然也可以,那就讓操作系統約定俗成一個內存地址來存放用戶的應用程序,這樣應用程序也不能將自己的程序分成一段一段的。所以有個格式,至少是只有好處沒有壞處。
剛剛只提到了可執行文件,生成可執行文件之前還要經歷一個重定位文件的過程,鏈接之后才是可執行文件。重定位文件和可執行文件都可以用 ELF 格式來表示,該格式有一個統一的頭,下面分成好多個段和好多個節,多個節通過鏈接變成一個段,具體格式如下圖。
ELF 格式鳥瞰
ELF 格式具體定義
先定義下數據類型方便后續描述
數據類型 | 字節大小 |
---|---|
Elf32_Half | 無符號整數(2) |
Elf32_Word | 無符號整數(4) |
Elf32_Addr | 程序運行地址(4) |
Elf32_Off | 文件偏移量(4) |
ELF 頭
數據類型 | 名稱 | 字節 | 含義 | 例子 |
---|---|---|---|---|
unsigned char | e_ident[16] | 16 | 0-3魔數 4類型 5大小端 6版本 7-15保留零 | |
Elf32_Half | e_type | 2 | 文件類型:0未知 1可重定位 2可執行 3動態共享目標 4core | 0x0002 |
Elf32_Half | e_machine | 2 | 處理器結構:0未知 3Intel80386 8MIPSRS3000 | 0x0003 |
Elf32_Word | e_version | 4 | 版本 | 0x00000001 |
Elf32_Addr | e_entry | 4 | 用來指明操作系統運行該程序時,將控制權轉交到的虛擬地址 | 0xc0001500 |
Elf32_Off | e_phoff | 4 | 程序頭表(program header table)在文件內的字節偏移量。沒有為0 | 0x00000034 |
Elf32_Off | e_shoff | 4 | 節頭表(section header table)在文件內的字節偏移量。沒有為0 | 0x0000055c |
Elf32_Word | e_flags | 4 | 與處理器相關標志 | 0x00000000 |
Elf32_Half | e_enhsize | 2 | elf header的字節大小 | 0x0034 |
Elf32_Half | e_phentsize | 2 | 程序頭表(program header table)中每個條目(entry)的字節大小 | 0x0020 |
Elf32_Half | e_phnum | 2 | 程序頭表中條目的數量。實際上就是段的個數 | 0x0002 |
Elf32_Half | e_shentsize | 2 | 節頭表(section header table)中每個條目(entry)的字節大小 | 0x0028 |
Elf32_Half | e_shnum | 2 | 程序頭表中條目的數量。實際上就是節的個數 | 0x0006 |
Elf32_Half | e_shstmdx | 2 | 用來指明string name table在節頭表中的索引index | 0x0003 |
程序頭表
數據類型 | 名稱 | 字節 | 含義 | 例子 |
---|---|---|---|---|
Elf32_Word | p_type | 4 | 段的類型:1可加載的程序段 2動態連接信息 3動態加載器名稱 | 0x00000001 |
Elf32_Off | p_offset | 4 | 本段在文件內的起始偏移字節 | 0x00000000 |
Elf32_Addr | p_vaddr | 4 | 本段在內存中的起始虛擬地址 | 0xc0001000 |
Elf32_Addr | p_paddr | 4 | 物理地址相關,保留,未設定 | 0xc0001000 |
Elf32_Word | p_filesz | 4 | 本段在文件中的大小 | 0x0000060b |
Elf32_Word | p_memsz | 4 | 本段在內存中的大小 | 0x0000060b |
Elf32_Word | p_flags | 4 | 標志 1可執行 2可寫 4可讀 | 0x00000005 |
Elf32_Word | p_align | 4 | 對其方式 0不對齊 2的冪次對齊 | 0x00001000 |
其實不用想得多復雜,就是一個格式而已,程序中需要哪個數據,就根據偏移量把它取出來用就可以了,實際上我們的程序就是這么做的。
來看一下 kernel.bin 的具體內容
7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
02 00 03 00 01 00 00 00 [00 15 00 c0] [34 00 00 00]
64 06 00 00 00 00 00 00 34 00 [20 00] [02 00] 28 00
06 00 03 00 01 00 00 00 [00 00 00 00] [00 10 00 c0]
00 10 00 c0 [0b 06 00 00] 0b 06 00 00 05 00 00 00
00 10 00 00 51 e5 74 64 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 07 00 00 00
04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
...
按照上述的 ELF 格式表一一對應看,便能知道全部信息,其中我們本次代碼中用到的,都用加粗了。我們拿 ELF 文件查看器工具看一下(不是必須的)
代碼中的 kernel_init 就是將 ELF 格式文件中的 程序頭表地址、程序頭中的數量、程序頭表中每個條目的字節大小、加載到的內存地址 取出,然后執行相應的拷貝操作。
kernel_init:
xor eax,eax
xor ebx,ebx ;記錄程序頭表地址(內核地址+程序頭表偏移地址)
xor ecx,ecx ;記錄程序頭中的數量
xor edx,edx ;記錄程序頭表中每個條目的字節大小
mov dx,[0x70000+42] ;偏移文件42字節處是e_phentsize
mov ebx,[0x70000+28] ;偏移文件28字節處是e_phoff
add ebx,0x70000
mov cx,[0x70000+44] ;偏移文件44字節處是e_phnum
.each_segment:
cmp byte [ebx+0],0 ;p_type=0,說明此頭未使用
je .PTNULL
push dword [ebx+16] ;p_filesz壓入棧(mem_cpy第三個參數)
mov eax,[ebx+4]
add eax,0x70000
push eax ;p_offset+內核地址=段地址(mem_cpy第二個參數)
push dword [ebx+8] ;p_vaddr(mem_cpy第一個參數)
call mem_cpy
add esp,12
.PTNULL:
add ebx,edx ;ebx指向下一個程序頭
loop .each_segment
ret
五、c 語言和匯編語言相互調用
本章講述了 ELF 格式的可執行文件,還講述了如何加載一個 ELF 可執行文件,並跳轉到相應的地址去執行。
本章還隱含講述了匯編語言如何調用 c 語言(約定好跳轉地址,以及傳參方式),以及 C 語言如何調用匯編語言。
c 語言調用匯編
print.asm
global put_str
put_str:
...
ret
main.c
#include "print.h"
int main(void){
put_str();
return 0;
}
print.h
#ifndef __LIB_KERNEL_PRINT_H
#define __LIB_KERNEL_PRINT_H
void put_str();
#endif
寫在最后:開源項目和課程規划
如果你對自制一個操作系統感興趣,不妨跟隨這個系列課程看下去,甚至加入我們(下方有公眾號和小助手微信),一起來開發。
參考書籍
《操作系統真相還原》這本書真的贊!強烈推薦
項目開源
當你看到該文章時,代碼可能已經比文章中的又多寫了一些部分了。你可以通過提交記錄歷史來查看歷史的代碼,我會慢慢梳理提交歷史以及項目說明文檔,爭取給每一課都准備一個可執行的代碼。當然文章中的代碼也是全的,采用復制粘貼的方式也是完全可以的。
如果你有興趣加入這個自制操作系統的大軍,也可以在留言區留下您的聯系方式,或者在 gitee 私信我您的聯系方式。
課程規划
本課程打算出系列課程,我寫到哪覺得可以寫成一篇文章了就寫出來分享給大家,最終會完成一個功能全面的操作系統,我覺得這是最好的學習操作系統的方式了。所以中間遇到的各種坎也會寫進去,如果你能持續跟進,跟着我一塊寫,必然會有很好的收貨。即使沒有,交個朋友也是好的哈哈。
目前的系列包括