PS:要轉載請注明出處,本人版權所有。
PS: 這個只是基於《我自己》的理解,
如果和你的原則及想法相沖突,請諒解,勿噴。
前置說明
本文作為本人csdn blog的主站的備份。(BlogID=102)
環境說明
- Ubuntu 18.04
- gcc version 7.5.0 (Ubuntu 7.5.0-3ubuntu1~18.04)
- Bochs 2.6
- As86 version: 0.16.17
前言
自從我近段時間開始溫習一些基礎知識以來,其中覺得以前學的很淺的就是OS原理。為啥這樣說呢?因為就是淺,知道一些瑣碎的知識。以前我自負的認為OS就是硬件的抽象,然后把這些硬件資源合理的分配給用戶使用就完了,因為我覺得合理的整合這些硬件資源是非常‘簡單’的。
由於我本身對底層是非常着迷的。帶着覺得OS很簡單的想法,想着去看看LinuxKernel的源碼。在以前,我對LinuxKernel的認知很膚淺,就知道一些驅動移植的事情。如果硬要說一件我在LinuxKernel中玩的很深的事情,那就是自己理解並實現了一個類似Anonymous Shared Memory的Linux驅動,詳見以下兩篇文章。
- 《Android匿名共享內存(Anonymous Shared Memory) --- 瞎折騰記錄 (驅動程序篇)》 https://blog.csdn.net/u011728480/article/details/88420467
- 《linux kernel 中進程間描述符的傳遞方法及原理》 https://blog.csdn.net/u011728480/article/details/88553602
帶着這樣的想法其實已經很久了,由於現在的LinuxKernel太大了,對新手不友好。我就想着去找一個老一點的版本內核看看。結果去網上一找,就發現了前人已經做了許多許多了,比如這個之前就有了解的《linux 0.11內核完全注釋》,還比如其他許許多多前人種的‘樹’,看到了許多,最終我決定跟着國內現在比較好和新的資料從‘遠古’開始學習它。它就是《Linux內核完全注釋(PDF) v5.0 by 趙炯.pdf》。它是基於LinuxKernel0.12 講述的,它是我在ubuntu1804上編譯通過LinuxKernel0.12的主要參考和學習資料,同時也是我在Bochs上運行成功的主要參考和學習資料。
好的多說無益,直接看運行效果。

說來也慚愧,利用斷斷續續的時間,我花了約2月,把LinuxKernel0.12在Ubuntu1804上編譯通過,並在1804上通過Bochs運行成功。而且要命的事情是我其實只加了一些打印調試函數,和根據實際的調試情況修改了一些代碼,卻花了那么久的時間,搞得我很不自信了QAQ。
我修改好的源碼已經開源,立即想要源碼的請直接去文末兩個rep clone即可。
本文主要還是簡單介紹LinuxKernel從上電到進入sh的中間的簡要流程。這些流程網上已經有很多了,可能我會挑選一些我覺得比較重要的來說。
本文適用於:
- 會編譯和使用bochs的人。不會可以去網上找找,很多這方面的資料。
- 對Intel AT&T 匯編有點了解的人。
- 會GDB調試的人。
- 知道C語言常識的人。
- 對LinuxKernel感興趣的人。
搭環境
工欲善其事必先利其器。本文主要是在Ubuntu1804上編譯生成LinuxKernel,然后用Bochs運行我們的內核。
Ubuntu18.04環境安裝
我們應該首先安裝make,gcc,gcc-multilib,bin86。
- sudo apt install build-essential cmake make gcc-multilib g++-multilib module-assistant bin86
然后進入源碼目錄。
- cd my_src
- make disk
更多的詳情信息查看開源的rep。
編譯兩個bochs版本備用
我們首先就得把Linux0.12的運行環境搭建起來,方便我們調試。我們使用的是Bochs2.6 和 GDB遠程調試。並編譯出兩個bochs版本,一個是帶本身調試功能(命名為:bochs),一個是和gdb聯調(命名為:bochsdbg)。bochs 主要是調試在init/main()函數之前的內容以及查看更多的x86寄存器。 bochsdbg主要是調試進入init/main()函數之后到sh成功執行的事情。
- 通過 ./configure --enable-debugger 生成bochs。
- 通過 ./configure --enable-gdb-stub 生成bochsdbg。
運行我們編譯的內核
通過本文介紹生成的文件是Linux內核鏡像,稍微懂點行的人都知道還差一個RootFS。這個文件系統我們在網上下載的例如: http://oldlinux.org/Linux.old/bochs/linux-0.12-080324.zip 。本文生成的Linux內核鏡像使用的是rootimage-0.12-hd這個文件系統。
我建議這里自己配置兩個.bxrc文件,一個對應bochs,一個對應bochsdbg遠程調試。這樣在遇到問題的時候我們可以很方便的調試。
LinuxKernel啟動簡介
本節簡述LinuxKernel的啟動流程。根據我近段時間的學習來看,這里包含了許多的歷史性的東西,大家不要去細究為啥是這樣,很多都是為了兼容。
此外在整個學習期間,由於涉及到許多的x86 硬件體系知識,除了參考上文我說的文檔以外,還必須參考以下Intel官方文檔:
- Intel® 64 and IA-32 architectures software developer's manual combined volumes 2A, 2B, 2C, and 2D:Instruction set reference, A-Z
- Intel® 64 and IA-32 architectures software developer's manual combined volumes 3A,
3B, 3C, and 3D: System programming guide - 《Linux內核完全注釋(PDF) v5.0 by 趙炯.pdf》 第4章,全篇精華。
boot/bootsect.S 階段
當我們的計算機上電以后,IntelCPU進入實模式,並且PC指向了0xfff0整個地址,如下圖。什么意思呢?就是開機的時候執行的第一句指令放在0xffff0這個地方,通常這里有一個很重要的東西叫做BIOS。我們可以看到下圖,cs=0xf000,base=0xffff0000,在實模式下面,cs:pc 就是真實的指向地址0xffff0。到了這里不知道大家發現沒有,這里還差一個東西,那就是bios本來是放在rom里面的,怎么被指向了內存地址0xffff0的地方呢?是誰在之前自動搬運的嗎?經過查詢后發現,大部分人說開機的時候,對特殊地址的訪問會被仲裁器件指向BIOS-ROM器件。仲裁器還可以把地址翻譯並指向我們熟悉的MEM和IO。所以這里我理解對0xffff0的訪問就是對BIOS-ROM器件的直接訪問和執行。

BIOS主要是做自檢,並且在物理地址0x0開始初始化BIOS的中斷向量,同時通過BIOS訪問存儲設備的中斷,將可啟動設備的第一個扇區512字節給搬運到絕對地址0x7c00(31k)處。然后跳轉到0x7c00繼續執行,這里被搬運的512字節就是bootsect.S生成的指令。這一段沒啥營養,都是一些約定好的,到了CPU執行到絕對地址0x7c00的時候,才是真正的我們能控制的地方。其實這里也能夠看到,我們的bootsect.S生成的指令最大只能夠512字節,超過了就會出問題。下圖為我們的0x7c00處的開始幾句指令和bootsect.S的幾句指令,同時也能夠看到BIOS初始化和自檢打印的一些內容:

entry start
start:
! start at 0x07c0:0
! add by sky
mov ax,#BOOTSEG
mov es,ax
mov bp,#msg2 ! sky-notes: src-str is es:bp
mov si,#15 ! sky-notes: src-str-len is cx
call pirnt_str
! add by sky
mov ax,#BOOTSEG
mov ds,ax
mov ax,#INITSEG
mov es,ax
mov cx,#256
sub si,si
sub di,di
rep
movw
jmpi go,INITSEG
從0x7c00開始,就是我們自己的可以編程的領域了,也開始有了一些我自己特有的內容。主要是各種方法實現的print語句。這種調試方法簡直不要太好。
下面簡要說明一下bootsect.S的功能:
- 首先用rep movw把自己從0x7c00搬運到0x90000,並跳轉cs=0x9000, pc=go 標號的地址。繼續執行剩下的內容。
- 通過讀取0x1E號中斷向量位置的軟驅參數(由BIOS初始化時候通過BIOS中斷讀取的)到內存,然后修改其中的最大扇區數,並重新寫回到0x1E中斷向量位置絕對地址0x78去。最后重置軟驅,使其加載最新的參數。
- 使用BIOS INT 0x13的2號功能,將第一個軟盤第2,3,4,5扇區讀取到0x90200開始的位置。這里讀取的就是setup.S的指令內容,最大共2k(4*512)。0x90000-0x90200存放的是bootsect.S, 0x90200-0x90A00 為setup.S。
- 使用BIOS INT 0x13的8號功能,讀取磁盤參數:每磁道扇區數。並保存到變量sectors中。
- 使用BIOS INT 0x13的2號功能,使用剛剛的參數,讀取system模塊到0x10000,我們的bootsect.S放在0x90000,所以我們system模塊最大只能夠占用0x10000~0x8ffff。這里的system模塊就是除了bootsect和setup模塊之外的所有內核代碼。
- 判斷bootsect模塊第508,509字節是否為0,來判斷我們是否指定根文件系統的設備號。我們的內核定義為0x0301,代表第一個磁盤第一個分區為我們的根文件系統。
- 然后通過jmpi 0:9020跳轉到cs=0x9020,pc=0的地方去執行setup.S的代碼。
在我的bootsect模塊,我定義了一個打印字符串的函數,主要是通過使用BIOS INT 0x10的0x13號功能實現。主要還是為了調試,注意,這里不能夠隨意添加代碼,因為生成的代碼超過512byte后,鏈接器會報錯。只能夠少量的添加我們的調試代碼。
至此,我們就執行完了bootsect模塊。本模塊的主要內容還是加載setup和system到指定位置。bootsect執行的一些調試日志如下圖(在0x90200下斷點):

boot/setup.S 階段
首先我們還是來看一下0x90200的位置是否是setup.S,換句話來說是否加載好了setup模塊。

剛剛我們提到,setup是從0x90200開始存放的。那么0x90000~0x901ff中的bootsect已經無用了,於是我們setup中,用這里的內存存放一些參數。下面簡要說明一下setup.S的功能:
- 用BIOS INT 0x15功能號0x88取系統所含擴展內存大小並保存在內存0x90002~0x90003處。共兩個字節。
- 用BIOS INT 0x10功能號0x12讀取顯卡參數,0x9000A 顯存大小,0x9000B 顯卡類型(單色/彩色),0x9000C顯卡特性參數。
- 用BIOS中斷讀取屏幕的行列存放到0x9000E 0x9000F
- 用BIOS INT 0x10功能號0x03讀取當前光標位置存放到0x90000 0x90001
- 用BIOS INT 0x10功能號0x0f讀取當前顯示頁,顯示模式,字符列數。 0x90004~0x90005 存放當前顯示頁。 0x90006 顯示模式, 0x90007 字符列數。
- 讀取第一個硬盤參數表和第二個硬盤參數表,並放到0x90080 0x90090。每個表共16byte。注意,這里和之前的軟盤參數一樣,在BIOS自檢過程中,就被放到了中斷向量0x41 0x46 的位置。
- 用BIOS INT 0x13功能號0x15讀取當前硬盤設備情況,如果硬盤2不存在,則把0x90090之后的16byte清零。
下面我們將使CPU從實模式變更為保護模式,下面繼續說明一下setup.S的功能:
- 禁用中斷。
- 然后我們把system模塊0x10000~0x8ffff整體下移到0x0開始的位置。就是把最大0x80000(512k)的system模塊向下移動0x10000(64k)。
- 首先加載LDT和GDT。
- 開啟A20地址線,支持1M以上的內存。
- 初始化兩個8259A中斷控制器。
- 通過lmsw 設置cr0最低位位1,進入保護模式。
- 通過jmpi 0:0x8跳轉到絕對地址0x0開始執行system的代碼。system是從boot/head.s開始的。
這里需要說明幾個事情:
- 我們在下移system模塊的時候,覆蓋了BIOS中斷向量表。所以通過BIOS中斷打印字符串是行不通的。
- 在實模式中,cs:pc就是真實執行的地址。但是在保護模式中,cs是一個選擇符號,根據選擇符號值不同,分表在GDT或者LDT中查找對應的CS段描述符,其中最重要的就是base地址,當未開啟分頁的時候,這里的base+pc就是我們真實的執行地址。上面我們加載了LDT和GDT。這里的LDT是空,GDT有3項,第零項是空,第一項是代碼段描述符,第二項是數據段描述符,他們的基地址都是0x0。當cs=0x08,ds=0x10時,分別指向這里的第一項和第二項。
剛剛說了,system下移導致BIOS中斷向量表被沖掉了,於是我們不能夠通過BIOS打印字符串,於是這里我們使用的是直接操作顯存內存地址顯示字符,這個原理和LinuxKernel tty顯示原理差別不是很大。
這里我們設計了print_str函數,通過直接操控顯存然后寫入字符進行顯示,這里還使用到了剛剛我們保存的當前光標位置(0x90000 0x90001)。寫這個主要還是為了調試。
到此,我們已經開始去執行system的內容,其中head.s是入口。下圖是在0x0下斷點得到的setup模塊的一些打印日志。

boot/head.s 階段
首先我們還是來看一下0x0的位置是否是head.s,換句話來說是否加載好了system模塊。並且,從這里開始,我們就是進入了真正的LinuxKernel的世界,前面都是做一些環境初始化,都是一些固定的內容。

這里我們需要說明的是,bootsect.S和setup.S用的是intel匯編,而從head.s開始,我們用的都是AT&T匯編。同理,這里我也弄了一個safe_mode_print_str_no_page,打印字符串,為了調試,還是用的直接操作顯存的方式。
從這里開始,CPU開始工作於保護模式,下面簡要介紹一下工作流程:
- 剛剛我們通過jmpi切換到0x0開始執行,這時cs=0x8,根據setup設置好的GDT,base為0x0,同理我們設置其他段寄存器。
- 設置堆棧為stack_start,這個就是內核堆棧。此符號定義於kernel/sched.c中,如下文。
long user_stack [ PAGE_SIZE>>2 ] ;
struct {
long * a;
short b;
// } stack_start = { & user_stack [PAGE_SIZE>>2] , 0x10 };
} stack_start = { & user_stack , 0x10 };
- 設置IDT,所有的中斷向量指向ignore_int,一個預定義的中斷服務程序。共256項,每項8byte。
- 重新設置GDT。共256項,每項8byte。重新設置GDT的原因是setup的GDT可能會被沖掉,於是把GDT設置到合理的內存位置。這里設置好的GDT有4個。和setup中類似。第0,3個為0.第1,2項為cs和ds的段描述符。
- 檢查A20是否開通,主要是通過判斷0x100000 和 0x0值是否相等。
- 檢查數學協處理器是否存在。
到這里,我們就開始准備正式進入到init/main.c中的main函數了,但是還差最后一個重要的事情,那就是啟用分頁機制,下面繼續介紹其工作流程:
after_page_tables:
# sky print
push %ebp
lea msg5, %ebp
call safe_mode_print_str_no_page
pop %ebp
#
pushl $0 # These are the parameters to main :-)
pushl $0
pushl $0
pushl $L6 # return address for main, if it decides to.
pushl $main
jmp setup_paging
- 從上面的代碼我們可只,我們在啟用分頁前,把init/main.c中的main函數地址設置到了堆棧中。
- 首先我們把從0x0開始的5頁內存清零。每頁4096字節。其中第一頁為頁表目錄,第2-5頁為頁表。
- 設置頁表目錄的前4項為第2-5頁頁表地址。注意頁表目錄為1024項,每項4字節。
- 倒序設置每一個頁表的每一項內容,第5頁最后一項為0xfff000。映射之后,2-5頁分別映射好了16MB內存的空間。
- 操作cr0,開啟分頁
- 通過ret指令,從堆棧中把main地址彈出去執行。
到這里,我們正式進入到init/main.c中的main函數中,進入c語言相關代碼的地界。下面是進入main之前的一些日志輸出。

init/main.c 到進入shell
這里我們進入了init/main.c中的main函數,可從下圖看到。從這里開始,也是我們大家都熟知的Linux內核部分。

void main(void) /* This really IS void, no error here. */
{ /* The startup routine assumes (well, ...) this */
/*
* Interrupts are still disabled. Do necessary setups, then
* enable them
*/
char _my_msg_buf[100];
sprintf(_my_msg_buf, "kernel main() start, root_dev=%x, swap_dev=%x ... ...\0", ORIG_ROOT_DEV, ORIG_SWAP_DEV);
__asm__ (
"push %%ebp\n\t"
"mov %0, %%ebp\n\t"
"call safe_mode_print_str_after_page\n\t"
"pop %%ebp\n\t"
:
:"p"((char *)&_my_msg_buf)
:);
ROOT_DEV = ORIG_ROOT_DEV;
SWAP_DEV = ORIG_SWAP_DEV;
sprintf(term, "TERM=con%dx%d", CON_COLS, CON_ROWS);
envp[1] = term;
envp_rc[1] = term;
drive_info = DRIVE_INFO;
memory_end = (1<<20) + (EXT_MEM_K<<10);
memory_end &= 0xfffff000;//align 4k
if (memory_end > 16*1024*1024)//if memory_end > 16MB, set it to be 16 MB
memory_end = 16*1024*1024;
if (memory_end > 12*1024*1024)
buffer_memory_end = 4*1024*1024;
else if (memory_end > 6*1024*1024)
buffer_memory_end = 2*1024*1024;
else
buffer_memory_end = 1*1024*1024;
main_memory_start = buffer_memory_end;
sprintf(_my_msg_buf, "Mem size is %x, buf-mem size is %x, main-mem start %x ... ...\0", memory_end, main_memory_start, buffer_memory_end);
__asm__ (
"push %%ebp\n\t"
"mov %0, %%ebp\n\t"
"call safe_mode_print_str_after_page\n\t"
"pop %%ebp\n\t"
:
:"p"((char *)&_my_msg_buf)
:);
#ifdef RAMDISK
sprintf(_my_msg_buf, "ramdisk init ... ...\0");
__asm__ (
"push %%ebp\n\t"
"mov %0, %%ebp\n\t"
"call safe_mode_print_str_after_page\n\t"
"pop %%ebp\n\t"
:
:"p"((char *)&_my_msg_buf)
:);
main_memory_start += rd_init(main_memory_start, RAMDISK*1024);
#endif
sprintf(_my_msg_buf, "memory init ... ...\0");
__asm__ (
"push %%ebp\n\t"
"mov %0, %%ebp\n\t"
"call safe_mode_print_str_after_page\n\t"
"pop %%ebp\n\t"
:
:"p"((char *)&_my_msg_buf)
:);
mem_init(main_memory_start,memory_end);
sprintf(_my_msg_buf, "trap init ... ...\0");
__asm__ (
"push %%ebp\n\t"
"mov %0, %%ebp\n\t"
"call safe_mode_print_str_after_page\n\t"
"pop %%ebp\n\t"
:
:"p"((char *)&_my_msg_buf)
:);
trap_init();
sprintf(_my_msg_buf, "blk init ... ...\0");
__asm__ (
"push %%ebp\n\t"
"mov %0, %%ebp\n\t"
"call safe_mode_print_str_after_page\n\t"
"pop %%ebp\n\t"
:
:"p"((char *)&_my_msg_buf)
:);
blk_dev_init();
sprintf(_my_msg_buf, "chr init ... ...\0");
__asm__ (
"push %%ebp\n\t"
"mov %0, %%ebp\n\t"
"call safe_mode_print_str_after_page\n\t"
"pop %%ebp\n\t"
:
:"p"((char *)&_my_msg_buf)
:);
chr_dev_init();
sprintf(_my_msg_buf, "tty init ... ...\0");
__asm__ (
"push %%ebp\n\t"
"mov %0, %%ebp\n\t"
"call safe_mode_print_str_after_page\n\t"
"pop %%ebp\n\t"
:
:"p"((char *)&_my_msg_buf)
:);
tty_init();
printk("time init ... ...\n\r");
time_init();
printk("sched init ... ...\n\r");
sched_init();
/*
After sched_init()
gdt[0] = NULL
gdt[1] = kernel cs
gdt[2] = kernel ds
gdt[3] = NULL
gdt[4] = task0.tss
gdt[5] = task0.ldt
tr=task0.tss
ldtr=task0.ldt
*/
printk("buffer init ... ...\n\r");
buffer_init(buffer_memory_end);
printk("hd init ... ...\n\r");
hd_init();
printk("floppy init ... ...\n\r");
floppy_init();
printk("enable interrupts ... ...\n\r");
sti();
printk("go to user mode ... ...\n\r");
/*
movl %%esp,%%eax
pushl $0x17
pushl %%eax
pushfl
pushl $0x0f
pushl $1f
iret
1:
movl $0x17,%%eax
mov %%ax,%%ds
mov %%ax,%%es
mov %%ax,%%fs
mov %%ax,%%gs
iret instruction will do follow op:
popl eip
popl cs
popl eflag
popl esp
popl ss
*/
move_to_user_mode();
printf("user_mode: fork() task0 ... ...");
if (!fork()) { /* we count on this going ok */
printf("user_mode: task1 call init ... ...");
init();
}
/*
* NOTE!! For any other task 'pause()' would mean we have to get a
* signal to awaken, but task0 is the sole exception (see 'schedule()')
* as task 0 gets activated at every idle moment (when no other tasks
* can run). For task0 'pause()' just means we go check if some other
* task can run, and if not we return here.
*/
printf("user_mode: task0 call sys_pause() in while ... ...");
for(;;)
__asm__("int $0x80"::"a" (__NR_pause):);
}
注意,這里我們仍然設計了一個函數為safe_mode_print_str_after_page,通過直接操作顯存進行顯示字符串,知道tty_init之后,我們才能夠調用printk類似的函數進行打印。
下面簡要介紹一下main函數主要做的事情:
- 根據我們在setup中保存到內存中的內存參數初始化高速緩沖區和主存的位置。
- 然后就是我們常見的初始化mm模塊。
- 初始化中斷向量。
- 初始化塊設備。
- 初始化字符串設備。
- 初始化tty設備。
- 初始化時間。
- 初始化調度模塊。
- 初始化緩沖區。
- 初始化硬盤。
- 初始化軟盤。
- 開啟中斷。
- 把當前任務切換到用戶態。
當我們切換到用戶態之后,並且當前我們的進程是0號進程,我們內核的一些重要初始化基本設置完畢。然后就像我們常見的linux編程那樣,通過fork,創建我們的1號進程。然后我們繼續進行下面的事情:
- task0在fork出task1之后,就循環調用sys_pause, 這里主要還是執行schedule()開始執行進程調度。
- task1成功創建后,調用setup,開始加載根文件系統。然后task1 通過fork創建了task2。
- task2通過execve開始運行/bin/sh,進入shell。后續就是一些其他的事情。
到這里,我們已經把kernel跑起來了。在我調試的過程中,主要還是mm模塊和schedule模塊有些問題,可能和編譯器版本有關系,反正我生成的代碼,總會報錯。哪怕到現在,我開源出來的我修改的內核,也非常的不穩定,經常崩潰。但是好在正常工作了。
下面給出兩種不同打印的日志:


tool/build.c
此工具是生成LinuxKernel鏡像的手段。但是我們在Ubuntu上生成的內核,由於gcc版本變更的原因,需要做一些變更。主要還是把生成的elf格式system模塊通過objcopy 生成二進制內存鏡像。主要原因就是elf格式需要一個elf加載器進行各個段的重定位,但是由於我們是內核,所以沒有。詳情,請查看tool/build.c 及 Makefile。
開源
https://github.com/flyinskyin2013/LinuxKernel-src0.12
https://gitee.com/sky-X/LinuxKernel-src0.12 (鏡像)
后記
為啥想要在ubuntu1804環境下弄這個東西呢?一方面是想學習一下,通過踩坑的方式加深自己的理解。另一方面還是太懶了,我只想在我的ubuntu1804上編譯內核,不想安裝其他虛擬機了,我的電腦太卡了(畢竟8年的電腦了QAQ)。
經過了這一波調試,我對LinuxKernel有了更深的認知,我覺得很不錯,如果以后有必要,我還可以分別對這些模塊進行詳細的查看,在這里,我只是簡單的說明了init/main中的內容,其實,還有許多其他的內容是運行在背后的。比如system_call,sys_table等等內容。還有do_fork do_execve等等內容都是我在調試過程中踩過的坑。
這里還是要說明,深入調試學習這個的原因還是想看看OS是怎么運行起來,雖然不能說已經100%的熟知,但是也可管中窺豹。
注意,這個版本的內核和現代的2.0,4.0,5.0還缺了一些主要的知識,比如網絡棧,VFS等。但是其他的一些內容,在現在的最新內核中,多多少少都能夠看到這個版本的一些影子。這也是學習這個內核的原因之一。

PS: 請尊重原創,不喜勿噴。
PS: 要轉載請注明出處,本人版權所有。
PS: 有問題請留言,看到后我會第一時間回復。