0x300-從頭開始寫操作系統-內核

目錄

回顧

上一篇文章,我們討論了以下內容:

  • 讀取硬盤所需的參數設置,硬盤數據的地址由 CHS 提供,我們需要將柱面,磁頭,扇區信息寫入相應的寄存器
  • 讀取硬盤的測試數據並打印
  • 32 位模式提供虛擬內存,分頁等更加靈活高效的內存管理模式,同時增加了內存尋址的空間,寄存器也從 16 位擴展到了 32 位
  • 無論計算機的顯示設備多麼高級,在計算機啓動時,都處於 VGA 模式
  • VGA 文本模式的一種,是 80x25 的行列模式,每個字符的像素大小是 9x16
  • VGA 模式下,一個字符的在內存中的位置被稱爲字符單元
  • 顯示設備是內存映射設備,我們在顯示設備的內存地址寫入信息,就可以顯示在屏幕上
  • 全局描述符是 32 位模式下內存尋址重要信息
  • 在 32 位模式下,段寄存器指向的不是段內存的基址,而是 GDT 中的段描述符
  • 段描述符包含了段內存的基址,大小,權限等信息
  • 切換到 32 位之前,我們必須定義 GDT 和 GDTD(GDT Descriptor)
  • 我們還需要禁用中斷,將 GDTD 交給 CPU,設置 cr0 寄存器的第一個 bit,做一個 far jump 清空 CPU pipeline
  • 切換到 32 位之後,要在代碼中使用 bits 32 來讓 assembler 以 32 位模式編譯指令,另外,我們需要將其他段寄存器指向我們新的段內存,並更新棧的地址到空閒內存地址

在讀到原書關於內核這個章節的時候(第 5 章),我感覺到原書的內容開始有些不太清晰。有些地方講複雜了,有些地方又解釋不夠。所以,如果有讀者在讀原書,那麼可以結合我這個系列的 0x300 這一章節做解惑和補充。

今日目標

激動人心的時候到了,我們終於碰觸到了操作系統最核心的內容——內核。

今天開始,我們將用 C 語言,開始像搭樂高積木一樣,慢慢組裝一個簡易的操作系統。

文章很長,但是因爲每個概念都具有連續性,我覺得分開寫並不好,讀者會忘記之前的內容,又返回去看一遍之前的文章是一種時間上的浪費。因此,我將必要的內容組織在一起,需要大家有一點耐心。

我們先預覽一下本系列第 3 章會討論的內容:

  • 工具介紹,包括 gcc,ld,objdump,ndisasm 等
  • C 語言的編譯過程
  • C 語言與彙編
  • 加載內核
  • Makefile
  • 內核調試

在這裏我想提醒一下大家,最好的學習方式就是動手實踐。請大家務必動手實驗,才能挖掘出更多的潛在問題,從而學習到更多的知識點。

這是我在測試的時候發現的問題。這也是爲什麼到加載內核的階段,我們需要手動編譯安裝交叉編譯器的原因之一(編譯安裝過程見下文)。

我的測試環境是 64 位 Ubuntu 18.04,我寫了一個測試程序,只有一個函數定義。

test.c

int main()
{
	return 0;
}
# 系統自帶的 gcc 生成 obj
gcc -ffreestanding -c test.c -o test.o

然後使用:

# 系統自帶的 linker 鏈接生成 bin 文件
ld -o test.bin -Ttext 0x0 --oformat binary test.o

最後,使用:

# ndisasm 查看彙編
ndisasm test.bin > test.dis

結果生成了一個 200 多萬行的彙編指令文件。

在這裏插入圖片描述

文件中一直在循環出現 add [bx + si], al 這樣的指令。

在這裏插入圖片描述

每個人的操作環境不同,我不知道 32 位系統上或者 MacOS 自帶的工具是不是就能正常工作,可能還會碰到其他問題。總之,希望大家動手實踐。

必要工具的安裝及介紹

我們首先了解一下內核編譯和加載階段需要用到的一些工具。不是系統自帶的工具,我們必須進行編譯、安裝。

工具一覽

這些工具和庫包括:

編譯、鏈接工具

  • gcc
  • ld
  • nasm

反彙編工具

  • ndisasm

以及編譯測試代碼時需要用到的工具包

  • binutils

工具介紹

回憶之前,我們使用 nasm 編譯彙編代碼,生成 .bin 文件,然後使用 qemu 做測試。並使用 od 查看原始機器碼。我們能直接操作內存,這是彙編提供的最底層的能力。

現在,我們進入了可以使用 C 語言的階段。我們有必要了解 C 語言與彙編的關係。我們需要知道 C 語言編譯之後,會生成什麼樣的彙編代碼,同時要了解 C 代碼在執行的時候,內存中發生了什麼變化。

有了這些需求,我們先來了解一下將會用到的工具,之後我們將簡單討論一下 C 的編譯過程,讓大家理解這些工具的背後邏輯。

GCC

我們將用 C 語言編寫內核,那麼編譯 C 語言就會用到衆所周知的工具 gcc

我們即將使用以下命令來編譯我們的內核:

gcc -ffreestanding -c kernel.c -o kernel.o

我們看一下每個參數的作用:

  • -c - 告訴編譯器不要鏈接目標文件
  • -o - 輸出文件的文件名
  • -ffreestanding - 見下文

gcc 在編譯內核的時候,提供了一個 freestanding 的參數,用於編譯內核這樣的獨立模塊。爲了更好理解這個參數,我們需要看一下 ISO C 標準中的兩種 C 程序實現環境

Hosted Environment

Hosted Env,指的是能夠使用所有標準庫文件或者三方庫文件,並且程序啓動(startup)是從 main 函數開始的程序環境。

我們在操作系統上寫的任何依賴於現有操作系統資源的程序,都處於 Hosted Env(暫譯爲宿主環境)。

Freestanding Environment

相對於 Hosted Env,另一種環境是 Freestanding Env。在該環境下,只提供了一些標準庫文件的支持,如 <float.h>,<limits.h>,<stdarg.h>,<stddef.h>等。這種只有少許標準庫支持的,並且程序啓動(startup)和終結(termination)都必須手動控制的程序環境,就是 Freestanding Env(暫譯爲獨立環境)。

我們在後面的實踐中會遇到,內核的執行不是從 main 函數開始,而是從程序第一個定義的函數開始執行。我們需要手動定義內核程序的入口。

關於 GCC 按編程語言分類的參數,可以參考這篇文章

Linker

鏈接器的介入,其實已經到了編譯的最後階段。鏈接器的作用是找到函數的定義,然後將涉及到的函數調用全部拷貝到最後的可執行文件中,或者將函數的內存地址寫入到可執行文件中,讓程序在運行時調用。

下面,我們會講到 C 語言的編譯過程,會對 Linker 做更多說明。

C 語言編譯(gcc 的臨時文件)

我們所編譯 C 語言,如果大家指的是 Compiling 這個動作,那麼就已經到了編譯的第二步。我們從頭開始回顧一下編譯的全過程。

C 程序的編譯過程主要分爲四步:

  • 預編譯(Pre-processing)
  • 編譯(Compiling)
  • 彙編(Assembly)
  • 鏈接(Linking)

開始之前,我們寫一個很簡單的 C 程序,然後使用一些參數來編譯,保存每一步的臨時文件。

我們的 C 程序包含一行註釋;包含 stdio.h 頭文件;定義了一個宏 sum,用於兩個數字相加;又在條件編譯中定義了另一個宏 BANNER,是一個字符串;然後在 main 函數中使用兩個宏。

compile.c

/* I am comment and will be stripped by preprocessor */

#include <stdio.h>

// macro
#define sum(a, b) (a + b)
#ifndef BANNER
#define BANNER "Simple Addition!"
#endif

int main()
{
    int a = 5, b = 10;
    printf(BANNER);
    printf("The sum of a + b is: %d\n", sum(a, b));
    return 0;
}

使用 gcc 編譯,保存臨時文件:

# 使用 -save-temps 保存臨時文件
gcc -save-temps compile.c -o compile

結果如下。

在這裏插入圖片描述

compile.i 是預處理之後生成的文件,compile.s 是編譯之後生成的文件,compile.o 是彙編之後生成的文件,compile 是最終鏈接生成的可執行文件。

我們繼續。

預處理或預編譯(Pre-processing)

在這個階段,gcc 編譯工具中提供的 preprocessor,將會做 4 件事情:

  • 去除註釋
    CPU 可不需要看註釋。
  • 擴展宏定義
    諸如 #define CIRCLEAREA(r) (3.1415 * (r) * (r)),會被相應擴展成函數調用。
  • 擴展文件包含
    諸如 #include <stdio.h>,會被相應擴展成 stdio.h 的內容。
  • 擴展條件編譯
    諸如 ifdef MACRO ... endif,會被按照條件進行擴展編譯。

我們來看一下 compile.i 文件中都有些什麼內容。

首先,沒有任何的註釋信息,註釋已經被 preprocessor 去除。

然後,是一大堆的頭文件,從 stdio.h 擴展而來。

在這裏插入圖片描述

緊接着是大量的類型定義。

在這裏插入圖片描述

我們可以在文件的最後,找到我們的源代碼。但是可以看到,所有的宏的使用,都已經替換成了宏的定義。

在這裏插入圖片描述

這就是預處理階段,preprocessor 所進行的操作。我們進入編譯階段。

編譯(Compiling)

編譯階段,編譯器將代碼轉換爲彙編指令,生成 compile.s 臨時文件。

我們看一下文件中包含的內容。

可以看到這就是我們的程序的彙編形式。

在這裏插入圖片描述

我嘗試用 nasm 直接編譯 compile.s 文件,但是會報錯。這個文件的語法可能只有 gcc 工具集能認識 😂

繼續下一步。

彙編(Assembly)

彙編階段,編譯器將上一步的 compile.s 文件,轉換成機器碼,也稱 object code,生成 compile.o 文件。之前的 compile.icompile.s 都是可讀的 ASCII 文件,而彙編階段生成的 compile.o 文件已經是不可讀文件,我們可以看一下他的文件類型。

在這裏插入圖片描述

關於什麼是 ELF 文件,可以參考 這篇文章

不過,如果查看文件內容,還是會有一些字符串在臨時文件中(使用 strings)。

在這裏插入圖片描述

目標文件包含 object code,已經接近於一個可執行文件,但是缺最後重定位(relocation)的一步。目標文件中包含程序的元信息,即變量和函數在內存中的位置。這些變量和函數,被稱爲符號(symbol),被保存在一個被稱爲符號表(symbol table)的數據結構中。目標文件將告訴鏈接器如何定位函數在內存中的位置。

除此之外,目標文件還可以包含程序的調試信息,例如在使用 gcc 編譯的時候,指定 -g 參數。然後在用 ld 鏈接的時候,不要指定 --oformat 參數,即可在目標文件中包含調試信息(後面會講到)。

繼續下一步。

鏈接(Linking)

最後一步鏈接,會將函數的實際內存地址,鏈接到最終的執行文件。我們看到,函數的調用在運行時,CPU 就知道到內存的什麼位置,去找需要用到的函數。

我們可以看到,上一步生成的 compile.o 目標文件的文件類型是 relocatable。這之中包含了鏈接器可以執行的操作,relocate

目標文件中包含三種符號(symbol):

  • 已經定義的外部符號(defined external symbols),也被稱爲 public symbol 或者 entry symbol;可以被其他模塊(目標文件)調用;理解爲,定義在本目標文件之內的符號(函數名,變量名等),被其他目標文件調用
  • 未定義的外部符號(undefined external symbols),對於其他目標文件內符號的引用;理解爲,這些符號的定義在其他目標文件中,而本目標文件在調用他們
  • 本地符號(local symbols),在本目標文件中使用的符號;理解爲,在本目標文件定義,在本目標文件內調用

那麼,我們再看一下什麼是重定位(relocate)。

Relocate 是對內存位置極度依賴的代碼的內存地址重新分配。

鏈接器就像一個統籌人員,他讀取所有的目標文件,知道了程序需要使用的所有的符號(函數名,變量名等),然後爲每一個符號分配內存地址。那麼,最終所有目標文件被組裝再一起的時候,就知道該到哪個內存位置,調用哪個函數。

另外,鏈接器還會添加一些額外的代碼,幫助程序正常啓動和終止。我們可以查看 .o 目標文件和最終生成的可執行文件的大小。

可以看到最終的可執行文件大了很多。

在這裏插入圖片描述

關於鏈接,還有動態和靜態兩者之分。這裏不再展開了。大家想要了解,可以看這篇文章這篇文章還有這篇文章

我們即將使用如下命令來鏈接目標文件:

ld -o test.bin -Ttext 0x0 --oformat binary test.o

我們看一下每個參數的作用:

  • -o - 輸出文件的文件名
  • -Ttext - 如之前 Boot Sector 中的 org 指令,定義我們的內核代碼將被加載到內存的哪個位置
  • –oformat binary - 設置以二進制的形式輸出最終文件,支持的所有輸出格式可以使用 objdump -i 查看

下面我們繼續看一下程序編寫過程中其他工具的介紹。

Objdump

objdump 可以用於查看 .bin 文件的機器碼內容,同時也可以查看 .o 目標文件的彙編代碼,只需要使用 -d 參數即可。

objdump -d filename.o

Nasm

用於編譯加載內核的 Boot Sector。

nasm 又很多的輸出格式。在這篇文章之前,我們生成的都是 .bin 文件。

這裏,我們會在 指定內核入口 一節中,將源文件編譯成一個 .o 目標文件,之後跟內核的 .o 文件鏈接到一起。

Ndisasm

反彙編工具。可以將二進制文件反編譯成彙編指令。

# 以 32 位反彙編
ndisasm -b 32 filename.bin

# 或者
ndisasm -u filename.bin

Cross-compiler

我們看一下工具鏈的最後一個環節,交叉編譯器(Cross-compiler)。

爲什麼我們需要交叉編譯器,大家可以看這篇文章

疑問
我對於爲什麼要用交叉編譯目前還沒有太理解。因爲無論我使用系統自帶的 64位 gcc 和 ld 去編譯、鏈接,還是使用 32 位的交叉編譯去編譯和鏈接,生成的最終文件都可以在 qemu-system-i386qemu-system-x86_64 上運行。編譯出 32 位文件可以運行在 64 位環境我理解,有向上兼容。但是編譯出 64 位文件可以在 32 位環境運行,就有點無法理解了。這個問題拋出來,有待研究。

不過我們即將使用 32 位交叉編譯去測試所有的代碼,第一,爲了降低複雜度。我不想給自己增加負擔,參照原書先理解 32 位環境,64 位留作進階。第二,爲了解決文首所說的 ndisasm 反彙編 64 位的目標文件時,會出現大量重複彙編指令的問題。

Debian 系列 Linux 之外的系統,Cross-compiler 的編譯和安裝參考這篇文章

Debian 系列的 Linux 可以按照下面總結的步驟,進行 Cross-compiler 的編譯和安裝。

首先下載 Binutils 和 GCC 的源碼,下載地址:

  • binutils - http://ftp.gnu.org/gnu/binutils/
  • gcc - http://ftp.tsukuba.wide.ad.jp/software/gcc/releases/

安裝依賴:

sudo apt install build-essential bison flex libgmp3-dev libmpc-dev libmpfr-dev texinfo -y

設置環境變量:

export PREFIX="$HOME/opt/cross"
export TARGET=i686-elf
export PATH="$PREFIX/bin:$PATH"

編譯安裝 Binutils:

cd $HOME/src
 
mkdir build-binutils
cd build-binutils
../binutils-x.y.z/configure --target=$TARGET --prefix="$PREFIX" --with-sysroot --disable-nls --disable-werror
make
make install

編譯安裝 GCC:

cd $HOME/src
 
# The $PREFIX/bin dir _must_ be in the PATH. We did that above.
which -- $TARGET-as || echo $TARGET-as is not in the PATH
 
mkdir build-gcc
cd build-gcc
../gcc-x.y.z/configure --target=$TARGET --prefix="$PREFIX" --disable-nls --enable-languages=c,c++ --without-headers
make all-gcc
make all-target-libgcc
make install-gcc
make install-target-libgcc

現在,就可以使用 ~/opt/cross/bin 下的 i686-elf-gcc/ld 來編譯和鏈接目標文件了。

C 與彙編

之前的文章中,我們直接使用匯編編寫 Boot Sector。

現在,雖然我們有了更高級的 C 語言,但同時,我們也必須瞭解 C 語言與彙編的緊密聯繫。

其實細想這裏面的對應關係,沒有想象那麼複雜。

我們可以把一個 C 程序按功能大致分爲:

  • 變量定義(局部變量)
  • 條件判斷
  • 循環
  • 函數調用(函數參數,函數返回值)
  • 指針

而在理解 C 程序與彙編的轉換的時候,最重要的是,要理解棧是如何生成,運用並銷燬的。

接下來我們要做的就是寫 5 個簡單的 C 程序,包含這 5 個方面。然後,通過 ndisam 查看程序的彙編指令,來理解每一個功能是如何在彙編中實現的。

如果你還沒有交叉編譯器,參見前文 Cross-compiler 一節。

另外,建議大家先閱讀我逆向系列文章第三篇中的 爲什麼程序會有棧? 以及 爲什麼 EBP 和 EIP 會在 BoF 中被覆蓋? 這兩個小章節。那裏講了棧的作用以及 C 函數調用時棧的變化情況,提及了函數序言等必要概念。

局部變量

首先,我們看一下局部變量在彙編中的實現。

寫一個簡單的 local_var 程序,只有一個函數定義,函數中包含一個局部變量 var, 然後直接 return 這個變量。

在這裏插入圖片描述

交叉編譯一下,然後用 ndisasm 查看彙編。

在這裏插入圖片描述

00000000  55                push ebp
00000001  89E5              mov ebp,esp
00000003  83EC10            sub esp,byte +0x10
00000006  C745FCEFBE0000    mov dword [ebp-0x4],0xbeef
0000000D  8B45FC            mov eax,[ebp-0x4]
00000010  C9                leave
00000011  C3                ret

任何 C 函數定義在彙編中都以前三行函數序言(Function Prologue)開始,分配一個棧空間(棧幀)給指定函數。

32 位 C 程序使用 4 個字節存儲 int 類型的變量。因此可以看到第 4 行,0xbeef 被存儲到 ebp - 0x4 的內存地址上。

函數的返回值,會被存儲到 eax 中。因此,第 5 行,將 ebp - 0x4 位置上的數據,寫入到 eax 寄存器中。

最後,調用 leave ret 結束該函數調用。

如果大家看了 0x03-逆向-BoF基礎的基礎 一文,就能知道彙編指令 leaveret 的時候做了哪些額外的操作。

條件判斷

在這裏插入圖片描述

00000000  55                push ebp
00000001  89E5              mov ebp,esp
00000003  83EC10            sub esp,byte +0x10
00000006  C745FC05000000    mov dword [ebp-0x4],0x5
0000000D  C745F800000000    mov dword [ebp-0x8],0x0
00000014  837DFC0A          cmp dword [ebp-0x4],byte +0xa
00000018  7E09              jng 0x23
0000001A  C745F801000000    mov dword [ebp-0x8],0x1
00000021  EB07              jmp short 0x2a
00000023  C745F802000000    mov dword [ebp-0x8],0x2
0000002A  B800000000        mov eax,0x0
0000002F  C9                leave
00000030  C3                ret

可以看到,函數序言是一樣的,分配了一個棧空間。

這段代碼裏有兩個變量,a,和 b。因此,第 4 第 5 行,分別分配了 4 個字節的空間給兩個變量,並將初始值 50 寫入到相應的地址。

第 6 行,cmp 命令比較 ebp - 0x4 內存地址中的值(變量 a,初始值爲 5)與 0xa (十進制 10)的大小。

如果變量 a 的值小於 10,執行第 7 行的指令,跳轉到偏移量爲 0x23 的位置上,也就是第 10 行指令,將 2 寫入到 ebp - 0x8 的內存位置中(變量 b 存儲的內存地址),變量 b 被賦值爲 2

如果變量 a 的值大於等於 10,執行第 8 行指令,將 b 賦值爲 1

然後執行第 9 行指令,跳轉到偏移量爲 0x2a 的內存位置,將返回值 0 寫入到 eax 寄存器中。

最後調用 leave ret 結束函數調用。

關於彙編的條件指令,看 這篇文章

循環

在這裏插入圖片描述

00000000  55                push ebp
00000001  89E5              mov ebp,esp
00000003  83EC10            sub esp,byte +0x10
00000006  C745FC00000000    mov dword [ebp-0x4],0x0
0000000D  EB04              jmp short 0x13
0000000F  8345FC01          add dword [ebp-0x4],byte +0x1
00000013  837DFC04          cmp dword [ebp-0x4],byte +0x4
00000017  7EF6              jng 0xf
00000019  90                nop
0000001A  90                nop
0000001B  C9                leave
0000001C  C3                ret

第 4 行分配 4 個字節給變量 i,並寫入初始值 0ebp - 0x4 的內存位置上。

第 5 行跳轉到偏移量爲 0x13 的內存地址,也就是第 7 行。

第 7 行比較 ebp - 0x4 內存地址上的值(也就是變量 i)與 4 的大小。

如果小於 4,執行第 8 行指令,跳轉到偏移量爲 0xf 的內存位置上,也就是第 6 行。

第 6 行將 ebp - 0x4 內存地址上的值(也就是變量 i)加 1

然後又執行第 7 行的比較指令,這樣跳轉循環,直到 ebp - 0x4 內存位置上的值等於 4,調用 leave ret,結束函數調用。

函數調用

在這裏插入圖片描述

我們寫了兩個函數。第一個函數接受一個 int 類型參數,然後返回該參數。第二個參數調用第一個函數,傳遞一個變量 var 給第一個函數,並將返回值賦值給變量 b

00000000  55                push ebp
00000001  89E5              mov ebp,esp
00000003  8B4508            mov eax,[ebp+0x8]
00000006  5D                pop ebp
00000007  C3                ret
00000008  55                push ebp
00000009  89E5              mov ebp,esp
0000000B  83EC10            sub esp,byte +0x10
0000000E  C745FC05000000    mov dword [ebp-0x4],0x5
00000015  FF75FC            push dword [ebp-0x4]
00000018  E8E3FFFFFF        call 0x0
0000001D  83C404            add esp,byte +0x4
00000020  8945F8            mov [ebp-0x8],eax
00000023  8B45F8            mov eax,[ebp-0x8]
00000026  C9                leave
00000027  C3                ret

前 5 行,是第一個函數 my_func 的彙編形式。

第 3 行中,該函數將 ebp + 0x8 內存內存位置上的內容寫入 eax 寄存器,作爲返回值。

第 6 行開始,是第二個函數調用第一個函數的彙編實現。

第 9 行,將 5 寫入 ebp - 0x4 的內存地址(var 變量)。

第 10 行將 var 變量存入棧中,由於 push 指令先將 esp - 4,因此當前 esp 應該在 ebp - 0xc 的位置上(0x10 - 0x4)。

第 11 行,call 指令調用內存偏移量爲 0x0 位置上的函數,也就是 my_func 函數。

第 2 行,my_func 函數中,將 esp 的值寫入了 ebp,也就是說,當前 ebp 指向 ebp - 0xc

第 3 行,my_func 函數中,將 ebp + 0x8 內存位置上的值,寫入 eax 寄存器,作爲返回值。

因爲當前 ebp 指向 ebp - 0xc,那麼 ebp + 0x8 也就等於 ebp -0x4。在 ebp - 0x4 位置上的,是我們的變量 var,因此寫入 eax 寄存器作爲返回值的,就是我們傳遞給第一個函數的 var 變量。

指針

在這裏插入圖片描述

00000000  55                push ebp
00000001  89E5              mov ebp,esp
00000003  83EC10            sub esp,byte +0x10
00000006  C745F805000000    mov dword [ebp-0x8],0x5
0000000D  8D45F8            lea eax,[ebp-0x8]
00000010  8945FC            mov [ebp-0x4],eax
00000013  B800000000        mov eax,0x0
00000018  C9                leave
00000019  C3                ret

第 4 行,將 5 寫入 ebp - 0x8 的內存位置。

第 5 行,lea(load effective address) 指令將 ebp - 0x8 位置上的值的內存地址,寫入 eax 寄存器。

第 6 行,將 eax 寫入到 ebp - 0x4 的內存位置上。

第 7 行,將 0 寫入 eax 作爲返回值。

最後結束函數調用。

疑問
一個指針是 8 個字節。如果第 4 行變量 a 被寫入到 ebp - 0x8 的位置上,而第 6 行將 eax 寫入到 ebp - 0x4 的位置上,豈不是會覆蓋掉變量 a 的值。

猜想可能是編譯器內部的優化。因爲它知道在指針賦值之後,沒有任何代碼需要引用變量 a,因此覆蓋其值也沒事,節省內存空間。可以再寫一個 int b = a; *ptr = &b; 來觀察彙編的實現。

指針這一特性的彙編實現,我們需要了解的是第 5 行 lea 指令,會將我們變量的地址,寫入到寄存器。

加載內核

現在,我們將加載一個極其簡單的內核。這個內核往顯示設備內存中寫入一個 X 字符。

我們將用交叉編譯器 i686-elf-gcci686-elf-ld 完成 C 程序的編譯,用 nasm 完成 Boot Sector 的編譯,最後用 cat 命令將兩個二進制文件寫入到一個文件中(os-image),最後用 qemu 測試。

如果在測試當中報出 Disk load error,可以嘗試調整磁盤參數(第一個軟盤是 0x0,第一塊硬盤是 0x80),或者,在 qemu 上加上 -fda 參數。

另外,我們將分兩種模式來編譯,一種使用手動編譯,另一種使用 Makefile 來自動化編譯的過程。

手動編譯

代碼在這裏可以找到 Simple Kernel

首先,一個新的彙編指令:

  • equ

equ 用於定義常量,可以看到我們將常量名按慣例全部大寫:

KERNEL_OFFSET equ 0x1000

再來看一下我們的內核文件:

void main()
{
    char* video_memory = (char*)0xb8000;
    *video_memory = 'X';
}

我們之前講過,內核編譯處於 freestanding 模式,所以,這個函數名,不一定是 main,可以是任意合法的函數名。

這個簡單內核,將用 C 語言完成在屏幕左上角顯示字符 X 的功能。

回憶上一篇文章,32 位模式調用顯示設備(內存映射型設備)顯示字符,就是往顯示設備內存地址中寫入數據即可。

我們來看一下這個程序的彙編實現。

00000000  55                push ebp
00000001  89E5              mov ebp,esp
00000003  83EC10            sub esp,byte +0x10
00000006  C745FC00800B00    mov dword [ebp-0x4],0xb8000
0000000D  8B45FC            mov eax,[ebp-0x4]
00000010  C60058            mov byte [eax],0x58
00000013  90                nop
00000014  C9                leave
00000015  C3                ret

有了之前對指針彙編實現的理解,這個內核的彙編實現就非常簡單了。

第 4 行將顯示設備內存地址 0xb8000 寫入棧中。

第 5 行將這個內存地址再寫入 eax 寄存器中。

第 6 行,往 eax 所指向的內存地址中寫入 0x58

0x58,即 X 的十六進制 ASCII 碼。

在這裏插入圖片描述

現在,可以開始編譯了。

# 編譯內核
i686-elf-gcc -ffreestanding -o kernel.o -c kernel.c
# 鏈接生成二進制文件 kernel.bin
i686-elf-ld -o kernel.bin -Ttext 0x1000 --oformat binary kernel.o
# 編譯 boot sector
nasm -fbin boot_sector.asm -o boot_sector.bin
# 將兩個 bin 文件合併
cat boot_sector.bin kernel.bin > os-image
# 測試
qemu-system-i386 -fda os-image

如果在 qemu 屏幕的左上角看到如下字符,說明第一個內核加載並執行成功。

在這裏插入圖片描述

指定內核入口

我們的內核處於 freestanding 模式,意味着默認情況下,內核程序沒有 main 函數這樣的入口。如果我們嘗試在當前內核函數的上方再定義一個空函數,那麼字符將無法顯示。

void mess_up()
{

}

void main()
{
    char* video_memory = (char*)0xb8000;
    *video_memory = 'X';
}

並沒有字符 X 輸出。

在這裏插入圖片描述

由於我們在鏈接的時候指定了 -Ttext 參數,告訴編譯器跳轉到指定的地址去執行內核代碼。CPU 跳轉到指定地址之後,就會從內核的第一行指令開始執行。

而我們看過函數調用的彙編實現。每個函數調用結束的時候,都有一個 ret 指令,用於退出當前函數。

那麼,CPU 就會從 Boot Sector 跳轉到我們的內核,執行第一個空函數,然後執行到 ret 指令,返回到 Boot Sector 繼續執行。所以 main 函數永遠不會執行。

現在,我們必須告訴編譯器,我們內核程序的入口在哪裏。

extern 指令

這一小節的代碼可以在這裏找到 Kernel Entry

編譯過程中,我們提到了符號(symbol)。每一個函數名,變量名,都是一個 symbol。

鏈接的過程當中,鏈接器會確定這些符號實際的內存地址。

extern 命令,就可以告訴鏈接器,將指定符號替換成實際內存地址。

下面是 kernel_entry.asm 的代碼:

[bits 32]
[extern main]
call main
jmp $

拆解一下:

  1. 我們已經處於 32 位模式
  2. 內核入口函數,這裏爲 main(替換成對應的內核函數名)
  3. 調用 main 函數
  4. 從 main 函數返回之後,掛起(可以不需要這行代碼,自行測試)

現在,需要首先將 kernel_entry.asm 編譯成目標文件:

# 指定 elf 格式輸出可重定位目標文件,用於和 kernel.o 一起鏈接
nasm -felf kernel_entry.asm -o kernel_entry.o

在鏈接的時候,要加入 kernel_entry.o

i686-elf-ld -o kernel.bin -Ttext 0x1000 --oformat binary kernel_entry.o kernel.o

其他步驟照舊。這樣,我們就可以準確找到指定的內核代碼,顯示出字符 X

在這裏插入圖片描述

Makefile

每次都手動編譯每一個目標文件,再鏈接,再合併,非常麻煩。

我們將使用 make 來自動化編譯過程。

make 需要一個配置文件,稱爲 Makefile

下面我們就討論一下如何使用 Makefile 自動化整個編譯過程。

Makefile 可以讓我們指定一個源文件該如何編譯,需要哪些依賴。我們通過在 Makefile 中編寫一系列的規則,來讓 make 幫助我們自動化編譯的過程。同時,make 將只更新必要的文件,而不會去編譯沒有改動的文件。

關於 Makefile 的一切,看 這裏

基本規則

最簡單的規則:

[目標(taget)]: [源/依賴(source-file/dependency)]
	[編譯命令(compile command)]

# 例子
kernel.o: kernel.c
	gcc -ffreestanding -c kernel.c -o kernel.o

確保 kernel.cMakefile 在同一文件夾。那麼就可以使用

make kernel.o

來編譯內核的目標文件,再也不需要輸入冗長的命令了。

我們可以照樣加入其他文件的編譯代碼:

kernel.bin: kernel_entry.o kernel o
	i686-elf-ld -o kernel.bin - Ttext 0x1000 kernel_entry.o kernel o -- oformat binary

kernel.o: kernel.c
	i686-elf-gcc - ffreestanding -c kernel .c -o kernel.o

kernel_entry.o: kernel_entry.asm
	nasm kernel_entry.asm -f elf -o kernel_entry.o

特殊變量

Makfile 提供了一些特殊變量來簡化規則編寫。

這些特殊變量包括:

  • $@ - 目標的文件名;比如 kernel.o: kernel.c,那麼 $@ 可以用來代替 kernel.o
  • $< - 第一個源文件/依賴的文件名;比如 kernel.o: kernel.c,那麼 $< 可以代替 kernel.c
  • $^ - 所有的源文件/依賴的文件名;比如 kernel.bin: kernel_entry.o kernel.o,那麼 $^ 可以代替 kernel_entry.o kernel.o

更多關於特殊變量的信息,可以看 這篇文章

那麼,Makefile 現在可以寫成這樣:

kernel.bin: kernel_entry.o kernel o
	ld -o $@ - Ttext 0x1000 $^-- oformat binary

kernel.o: kernel.c
	gcc - ffreestanding -c $< -o $@

kernel_entry.o: kernel_entry.asm
	nasm $< -f elf -o $@

默認目標與臨時文件清理

代碼可以在這裏找到 Kernel Makefile

當使用 make 命令而不指定目標時,Makefile 中定義的第一個目標會被選中。這個目標,也叫默認目標。

另外,我們可以定義一個目標(通常爲 clean)來清理生成的臨時文件(.o,.bin 等)。

現在,我們的 Makefile 如下:

all: run

kernel.bin: kernel_entry.o kernel.o
	i686-elf-ld -o $@ -Ttext 0x1000 $^ --oformat binary

kernel_entry.o: kernel_entry.asm
	nasm $< -f elf -o $@

kernel.o: kernel.c
	i686-elf-gcc -ffreestanding -c $< -o $@

boot_sector.bin: boot_sector.asm
	nasm $< -f bin -o $@

os-image: boot_sector.bin kernel.bin
	cat $^ > $@

run: os-image
	qemu-system-i386 -fda $<

clean:
	rm *.bin *.o

我們只需要運行 make,就可以完成編譯和測試的所有流程。

在這裏插入圖片描述

宏、匹配規則與通配符

這一小節的代碼可以在這裏找到 New Code Struct

由於之後代碼會越來越多,那麼所有文件都放在一起顯然難以維護。所以,我們需要重新安排一下項目的代碼結構。

新的代碼結構

按照原書,我們將代碼分爲三個模塊:

  • boot - 啓動相關的所有代碼(所有的彙編代碼)
  • kernel - 內核相關的所有代碼(目前只有 kernel.c)
  • drivers - 硬件驅動相關的所有代碼(目前還沒有涉及)

有了新的代碼結構,那麼原來的 Makefile 就不可用了。我們需要一些新的概念來讓 Makefile 更可擴展,更加高效。

新的 Makefile

接下來,我們將使用宏(macro),通配符(wildcard)以及匹配規則(pattern rules)來擴展 Makefile。

關於 Makefile 的一切,看 這裏

可以根據文檔理解新的 Makefile 中的內容。我把 Makefile 和註釋列在下面。

# 通配符匹配文件夾中所有的 C 源文件;C_SOURCES 宏將擴展爲所有的 C 源文件
C_SOURCES = $(wildcard kernel/*.c drivers/*.c)

# 通配符匹配文件夾中所有的頭文件;HEADERS 宏將擴展爲所有的頭文件
HEADERS = $(wildcard kernel/*.h drivers/*.h)

# 通過這樣的形式,將所有 C 源文件的擴展名替換爲 .o,比如 kernel.c 變成 kernel.o;
# OBJ 宏將擴展爲所有的 .o 目標文件
OBJ = ${C_SOURCES:.c=.o}

# 修改路徑,指向你機器上編譯好的交叉編譯器
CC = /home/opr/opt/cross/bin/i686-elf-gcc
# 修改路徑,指向你機器上的 gdb
GDB = /usr/bin/gdb
# -g: 編譯時帶上默認 debug 信息;debug 是分級的,更多信息大家可以參考
# https://www.rapidtables.com/code/linux/gcc/gcc-g.html
CFLAGS = -g

# 默認目標
os-image: boot/boot_sector.bin kernel.bin
	cat $^ > $@

# 默認目標將運行這個 run
run: os-image
	qemu-system-i386 -fda $<

kernel.bin: boot/kernel_entry.o ${OBJ}
	i686-elf-ld -o $@ -Ttext 0x1000 $^ --oformat binary

# kernel.gdb 用於內核 debug,我們沒有指定 --oformat 因爲該參數會去除掉所有的
# debug 信息,包括符號表
kernel.gdb: boot/kernel_entry.o ${OBJ}
	i686-elf-ld -o $@ -Ttext 0x1000 $^

# 啓動 QEMU 並使用 -s 參數連接 gdb,-s 參數會讓 qemu 監聽 TCP 1234 端口,等待 gdb 連接
# 然後啓動 gdb,-ex 執行命令,首先連接 1234 端口,然後加載上一步編譯好的帶有符號表的 debug 文件,即可開始 debug
debug: os-image kernel.gdb
	qemu-system-i386 -s -fda os-image &
	${GDB} -ex "target remote localhost:1234" -ex "symbol-file kernel.gdb"

# % 在這裏的意思是,要編譯一個 .o 文件,一定用文件名相同的 .c 文件去編譯
# 比如要編譯 kernel.o,那麼一定要找到 kernel.c 去編譯
%.o: %.c ${HEADERS}
	${CC} ${CFLAGS} -ffreestanding -c $< -o $@

%.o: %.asm
	nasm $< -f elf -o $@

%.bin: %.asm
	nasm $< -f bin -o $@

clean:
	rm -rf *.bin *.o os-image *.elf *.gdb
	rm -rf kernel/*.o boot/*.bin drivers/*.o boot/*.o

關於 Makefile,還是參考 這篇教程

Debug 內核代碼

隨着內核代碼越來越多,能夠 Debug 是非常重要的。否則,出了問題卻無法定位。好在 QEMU 提供了連接 GDB 的功能,讓我們可以 debug 我們的內核。

接下來,我們討論如何將 QEMU 連接到 gdb 來 debug 我們的內核代碼。

QEMU 與 GDB

如果你用的是 MacOS,那麼 參考這篇文章,編譯 gdb

QEMU 如何與 GDB 連接,我們在新的 Makefile 中已經提到了。

啓動 QEMU 時使用 -s 參數,QEMU 會監聽 TCP 1234 端口,等待 GDB 連接。

GDB 連接之後,執行 target remote 命令和 symbol-file 命令,開始 debug。

這裏要注意,斷點之後的函數名,是你定義在 kernel.c 中的函數名,任意合法函數名皆可。

運行 make debug

在這裏插入圖片描述

更多關於 QEMU Debug 的信息,可以參考 官方文檔

現在,我們有一個可以運行的內核,並掌握了 debug 內核代碼的能力。

這就是本篇文章的全部內容。

總結

  • 加載內核階段需要用到的工具鏈,包括交叉編譯的 gcc 和 ld,以及用於查看彙編指令的 ndisasm
  • C 程序的編譯可以處於兩個不同的模式,一個是 Hosted Environment(宿主環境),另一個是 Freestanding Environment(獨立環境)
  • 獨立環境下,程序沒有入口,程序的啓動和終結可以由程序員手動指定
  • C 的編譯過程有預處理,編譯,彙編和鏈接 4 個步驟
  • 討論了 C 程序的不同功能在彙編中的實現(局部變量,條件判斷,循環,函數調用以及指針)
  • 通過手動編譯的方式加載一個簡單的內核
  • 使用 Makefile 自動化編譯過程
  • 爲了使項目更有維護性,代碼被分爲 bootkerneldrivers 三個模塊
  • 更新了 Makefile,使用宏、通配符以及匹配規則使 Makefile 可以用於新的代碼結構
  • QEMU 使用 -s 參數,可以和 GDB 連接,進行內核代碼的調試

下一章開始,我們將逐步添加硬件支持,讓我們的操作系統擁有更多功能。


推薦閱讀(參考鏈接):

  • https://wiki.osdev.org/Why_do_I_need_a_Cross_Compiler
  • https://wiki.osdev.org/GCC_Cross-Compiler#Installing_Dependencies
  • https://en.wikipedia.org/wiki/GNU_Compiler_Collection#Back_end
  • https://en.wikipedia.org/wiki/Linker_(computing)
  • https://www.geeksforgeeks.org/linker/#:~:text=Linker%20is%20a%20program%20in,data%20into%20a%20single%20file.
  • https://wiki.osdev.org/Linkers
  • https://wiki.osdev.org/Linker_Scripts
  • https://wiki.osdev.org/LD
  • https://wiki.osdev.org/GCC_Cross-Compiler
  • https://wiki.osdev.org/Why_do_I_need_a_Cross_Compiler%3F#Background_information
  • https://wiki.osdev.org/GCC
  • https://wiki.osdev.org/OS_Specific_Toolchain
  • https://wiki.osdev.org/Preparing_GCC_Build
  • https://wiki.osdev.org/Porting_GCC_to_your_OS
  • https://ftp.gnu.org/old-gnu/Manuals/ld-2.9.1/html_chapter/ld_3.html
  • https://www.eecs.umich.edu/courses/eecs373/readings/Linker.pdf
  • http://csapp.cs.cmu.edu/2e/ch7-preview.pdf
  • https://stackoverflow.com/questions/17692428/what-is-ffreestanding-option-in-gcc
  • https://gcc.gnu.org/onlinedocs/gcc/Standards.html
  • https://gcc.gnu.org/onlinedocs/gcc/Option-Summary.html
  • https://medium.com/datadriveninvestor/compilation-process-db17c3b58e62#:~:text=C%20is%20a%20compiled%20language,are%20by%20convention%20named%20with%20.
  • https://www.geeksforgeeks.org/compiling-a-c-program-behind-the-scenes/
  • https://www.geeksforgeeks.org/static-vs-dynamic-libraries/
  • https://www.geeksforgeeks.org/working-with-shared-libraries-set-1/
  • https://www.geeksforgeeks.org/working-with-shared-libraries-set-2/
  • https://en.wikibooks.org/wiki/C_Programming/Basics_of_compilation
  • https://en.wikipedia.org/wiki/Linker_(computing)
  • https://en.wikipedia.org/wiki/Relocation_(computing)
  • https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
  • https://www.cs.virginia.edu/~evans/cs216/guides/x86.html
  • https://www.tutorialspoint.com/assembly_programming/assembly_conditions.htm
  • https://ftp.gnu.org/old-gnu/Manuals/ld-2.9.1/html_node/ld_3.html
  • https://www.csie.ntu.edu.tw/~comp03/nasm/nasmdoc2.html
  • https://www.tutorialspoint.com/makefile/index.htm
  • https://www.gnu.org/software/make/manual/html_node/Automatic-Variables.html
  • https://www.qemu.org/docs/master/system/gdb.html#:~:text=In%20order%20to%20use%20gdb,tell%20it%20to%20from%20gdb.
  • https://www.rapidtables.com/code/linux/gcc/gcc-g.html
  • https://stackoverflow.com/questions/54854128/use-of-o-c-in-makefile/54892804
  • https://sourceware.org/gdb/wiki/How%20gdb%20loads%20symbol%20files#:~:text=symbol%2Dfile%20indicates%20that%20gdb,or%20vice%2Dversa%2C%20etc.
  • http://nickdesaulniers.github.io/blog/2016/08/13/object-files-and-symbols/
  • https://en.wikipedia.org/wiki/Object_file#:~:text=An%20object%20file%20is%20a,work%20like%20a%20shared%20library.
  • https://stackoverflow.com/questions/7718299/whats-an-object-file-in-c
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章