羽夏看Linux內核——引導啟動(上)
- 2022 年 8 月 11 日
- 筆記
- 羽夏看Linux內核
寫在前面
此系列是本人一個字一個字碼出來的,包括示例和實驗截圖。如有好的建議,歡迎反饋。碼字不易,如果本篇文章有幫助你的,如有閑錢,可以打賞支持我的創作。如想轉載,請把我的轉載信息附在文章後面,並聲明我的個人信息和本人博客地址即可,但必須事先通知我。
你如果是從中間插過來看的,請仔細閱讀 羽夏看Linux系統內核——簡述 ,方便學習本教程。
Linux 0.11 介紹
Linux 0.11 寫於 1991 年年底,那時很多計算機都是通過軟盤啟動的,故該代碼是從軟盤啟動的。目前操作系統啟動都是通過硬盤,下面我們介紹它的啟動流程,仿製的時候改為從硬盤啟動。
在 16 位模式下,內存的使用是十分有限的,我再拿之前的表格:
從圖中可以看出,我們可以在實模式下可以隨便動的內存區域是0x00500-0x9FBFF
。注意,這裡可以隨便動是指我不影響實模式所用的東西的前提下可以動的區域。0x07C00-0x07DFF
是BIOS
把我們的第一個扇區加載到內存的代碼,如果我們需要第一扇區的代碼,這塊也不能亂動。
也就是說,在 16 位實模式下,我們拉起內核需要精打細算的利用好我們能夠用的內存,也不能在執行代碼過程中覆蓋到我們所需的數據。說完這些,我們來看內核代碼。
註:在之後的教程,我說內核源碼所在目錄,我會用
linuxsrc
表示,請悉知。
bootsect
學了之前的內容,我們知道BIOS
會加載第一扇區的代碼,而這個代碼對應了linuxsrc/boot
文件夾下的bootsect.s
文件,我們打開看一下,首先看到了注釋:
! SYS_SIZE is the number of clicks (16 bytes) to be loaded.
! 0x3000 is 0x30000 bytes = 196kB, more than enough for current
! versions of linux
!
SYSSIZE = 0x3000
!
! bootsect.s (C) 1991 Linus Torvalds
!
! bootsect.s is loaded at 0x7c00 by the bios-startup routines, and moves
! iself out of the way to address 0x90000, and jumps there.
!
! It then loads 'setup' directly after itself (0x90200), and the system
! at 0x10000, using BIOS interrupts.
對於as86
彙編語法來說,在!
或者;
之後的表示注釋。這段注釋告訴我們,bootsect.s
會被加載到0x7c00
這個地址,那這個代碼必定在第一扇區。之後這塊代碼會移動到0x90000
地址並跳轉到那裡,然後通過BIOS
中斷拉起setup
到0x10000
。
與此同時,為了更加直觀的學習,我們可以使用已有的鏡像看看情況。現在 Linux 0.11 版本很難直接編譯通過,我調了半天雖然編譯成功了,但Bochs
加載不了。不過我們網上已經有對應的鏡像,首先給個鏈接:
//www.oldlinux.org/Linux.old/bochs/
其實這些包個包是給在 Win 上學習準備的,這對於我們在 Deepin 等 Linux 發行版上進行學習就不太方便,這個配置文件沒法直接使用,會報錯。不過沒關係,我給製作了一個完整的包,並放到我的代碼倉庫:
你只需要clone
一下到你的學習文件夾下即可。都是學內核的同志了,git clone
應該都會,這裡就不贅述了。如果要啟動虛擬機,只需執行startLinux.sh
腳本即可。下面我們來看看開頭的代碼:
BOOTSEG = 0x07c0 ! original address of boot-sector
INITSEG = 0x9000 ! we move boot here - out of the way
SETUPSEG = 0x9020 ! setup starts here
start:
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
BOOTSEG
是0x07c0
,也就是被BIOS
初始加載的地址。你可能會有疑問。 被BIOS
初始加載的地址不是0x7c00
嗎? 是的,但是這個是被加載到段寄存器的,如果偏移是0
,且被16
整除,只需要把地址地板除以16
就是我們想要得到的結果。
這段就是拷貝bootsect.s
程序到0x90000
這個地址。加載程序一共有512個位元組,由於一次移動一個字,所以給cx
賦值256即可。我們可以看看Bochs
的內容:
雖然有了 GUI ,但不能完全被代替,你還得需要知道一些基本的調試命令,這個不是本教程的重點,請自行補充。
拷貝完後,並執行跨段跳轉後的狀態:
我們繼續:
go: mov ax,cs
mov ds,ax
mov es,ax
! put stack at 0x9ff00.
mov ss,ax
mov sp,#0xFF00 ! arbitrary value >>512
這塊是使用cs
初始化段寄存器和棧空間,由於十分簡單就不贅述了。
! load the setup-sectors directly after the bootblock.
! Note that 'es' is already set up.
load_setup:
mov dx,#0x0000 ! drive 0, head 0
mov cx,#0x0002 ! sector 2, track 0
mov bx,#0x0200 ! address = 512, in INITSEG
mov ax,#0x0200+SETUPLEN ! service 2, nr of sectors
int 0x13 ! read it
jnc ok_load_setup ! ok - continue
mov dx,#0x0000
mov ax,#0x0000 ! reset the diskette
int 0x13
j load_setup
0x13
中斷是一個服務,用來對磁盤進行操作。我們簡單介紹一下各寄存器的功能。這塊代碼是讀取扇區,如果使用該功能,需要AH=0x02
,這也是為什麼mov ax,#0x0200+SETUPLEN
要加個0x0200
。
寄存器 | 作用 |
---|---|
AL | 扇區數 |
CH | 柱面 |
CL | 扇區 |
DH | 磁頭 |
DL | 驅動器(00H – 7FH 為軟盤;80H – 0FFH 為硬盤) |
ES:BX | 緩衝區的地址 |
既然是讀取操作,必然需要知道讀取結果。如果CF = 0
,則表示成功,此時AH = 00H
,AL = 傳輸的扇區數
。如果失敗,AH
是狀態碼。有關狀態碼這事情我就不贅述了,自己可以從網絡進行查閱。
此時ES = 9000H
,而讀取時BX = 0x0200
,所以讀取後被放到0x90200
這個地址。如果讀取成功,就會跳轉到ok_load_setup
這個標籤,失敗重置磁盤狀態(AH = 0
調用),重試直到成功。執行完int 0x13
指令後,我們看看結果:
然後我們看看setup.s
開頭幾行彙編:
mov ax,#INITSEG ! this is done in bootsect already, but...
mov ds,ax
mov ah,#0x03 ! read cursor pos
xor bh,bh
int 0x10 ! save it in known place, con_init fetches
mov [0],dx ! it from 0x90000.
可以看到完全一致。我們接着繼續:
ok_load_setup:
! Get disk drive parameters, specifically nr of sectors/track
mov dl,#0x00
mov ax,#0x0800 ! AH=8 is get drive parameters
int 0x13
mov ch,#0x00
seg cs
mov sectors,cx
mov ax,#INITSEG
mov es,ax
! Print some inane message
mov ah,#0x03 ! read cursor pos
xor bh,bh
int 0x10
mov cx,#24
mov bx,#0x0007 ! page 0, attribute 7 (normal)
mov bp,#msg1
mov ax,#0x1301 ! write string, move cursor
int 0x10
當AH = 0x08
時,調用int 0x13
是獲取磁盤大小信息。其中DL
為驅動器,如果成功CF = 0
,BL
會獲得1-4
的數值,為磁盤大小,含義如下:
BL 值 | 含義 |
---|---|
1 | 360 KB |
2 | 1.2 MB |
3 | 720 KB |
4 | 1.44 MB |
與此同時,CH
代表柱面數的低八位;CL
的高兩位代表柱面數的高兩位,CL
剩餘的位代表扇區數;DH
代表柱頭數;DL
代表驅動器數;ES:DI
指向的是磁盤驅動器參數表地址。
在調用完int 0x13
之後,將區塊的扇區數目放到了sectors
中。緊接着後面我們又遇到了一個中斷int 0x10
,這個是用於顯示的服務,可以往屏幕上寫字符串操作。將msg1
寫到屏幕上,我們來看看這是什麼:
msg1:
.byte 13,10
.ascii "Loading system ..."
.byte 13,10,13,10
回車鍵的ASCII
是13,換行鍵的ASCII
是10,如果組合起來就是回車換行,就是C/C++
的\n
。
接下來我們繼續下一部分代碼:
! ok, we've written the message, now
! we want to load the system (at 0x10000)
mov ax,#SYSSEG
mov es,ax ! segment of 0x010000
call read_it
call kill_motor
這部分就是加載system
模塊了,system
模塊就是內核模塊,包含庫模塊lib
、內存管理模塊mm
、內核模塊kernel
、main.c
和head.s
程序,後面將會詳細介紹。read_it
就是讀取函數,將模塊讀取到0x010000
這個地址。kill_motor
函數是關閉驅動器馬達,以知道驅動器狀態。為什麼可以看注釋:
/*
* This procedure turns off the floppy drive motor, so
* that we enter the kernel in a known state, and
* don't have to worry about it later.
*/
kill_motor:
我們來粗略簡單看看read_it
函數:
read_it:
mov ax,es
test ax,#0x0fff
die:· jne die ! es must be at 64kB boundary
xor bx,bx ! bx is starting address within segment
rp_read:
mov ax,es
cmp ax,#ENDSEG ! have we loaded all yet?
jb ok1_read
ret
ok1_read:
seg cs
mov ax,sectors
sub ax,sread
mov cx,ax
shl cx,#9
add cx,bx
jnc ok2_read
je ok2_read
xor ax,ax
sub ax,bx
shr ax,#9
ok2_read:
call read_track
mov cx,ax
add ax,sread
seg cs
cmp ax,sectors
jne ok3_read
mov ax,#1
sub ax,head
jne ok4_read
inc track
ok4_read:
mov head,ax
xor ax,ax
ok3_read:
mov sread,ax
shl cx,#9
add bx,cx
jnc rp_read
mov ax,es
add ax,#0x1000
mov es,ax
xor bx,bx
jmp rp_read
read_track:
push ax
push bx
push cx
push dx
mov dx,track
mov cx,sread
inc cx
mov ch,dl
mov dx,head
mov dh,dl
mov dl,#0
and dx,#0x0100
mov ah,#2
int 0x13
jc bad_rt
pop dx
pop cx
pop bx
pop ax
ret
這些都是對磁盤進行大量讀寫的操作,以0x100位元組作為塊一次一次讀取。我們來看一下執行結果:
剩下的最後一塊bootsect.s
程序:
! After that we check which root-device to use. If the device is
! defined (!= 0), nothing is done and the given device is used.
! Otherwise, either /dev/PS0 (2,28) or /dev/at0 (2,8), depending
! on the number of sectors that the BIOS reports currently.
seg cs
mov ax,root_dev
cmp ax,#0
jne root_defined
seg cs
mov bx,sectors
mov ax,#0x0208 ! /dev/ps0 - 1.2Mb
cmp bx,#15
je root_defined
mov ax,#0x021c ! /dev/PS0 - 1.44Mb
cmp bx,#18
je root_defined
undef_root:
jmp undef_root
root_defined:
seg cs
mov root_dev,ax
! after that (everyting loaded), we jump to
! the setup-routine loaded directly after
! the bootblock:
jmpi 0,SETUPSEG
root_dev
是一個變量,它指向一個字大小的宏ROOT_DEV
,它的值為0x306
。
root_dev:
.word ROOT_DEV
為什麼是這個值呢?如果該值為0
,根文件系統設備與引導使用同樣的軟驅設備;如果是0x301
,則為第一個硬盤的第一個分區上,這個被稱為設備號。設備號 = 主設備號 * 256 + 次設備號,舉個例子:
- 0x300 – /dev/hd0 代表第一個硬盤
- 0x301 – /dev/hd1 代表第一個硬盤的第一個分區
- ……
- 0x304 – /dev/hd4 代表第一個硬盤的第四個分區
- 0x305 – /dev/hd5 代表第二個硬盤
- ……
於是該內核使用的是第二個硬盤的第一個分區,作為根文件系統設備。
接下來兩個cmp
可能看不懂,咱們給個解釋:sectors
是我們之前保存的每磁道扇區數目,如果是 15 ,那麼就是 1.2 MB 的驅動器;如果是 18 ,那麼就是 1.44 MB 的,也就是引導驅動器的設備號。如果正常找到,將會執行jmpi 0,SETUPSEG
,該部分程序結束;否則,直接死循環。
setup
下面開始setup.s
代碼的講解,這個程序十分重要,它是操作系統加載程序。先看開頭:
! ok, the read went well so we get current cursor position and save it for
! posterity.
mov ax,#INITSEG ! this is done in bootsect already, but...
mov ds,ax
mov ah,#0x03 ! read cursor pos
xor bh,bh
int 0x10 ! save it in known place, con_init fetches
mov [0],dx ! it from 0x90000.
! Get memory size (extended mem, kB)
mov ah,#0x88
int 0x15
mov [2],ax
! Get video-card data:
mov ah,#0x0f
int 0x10
mov [4],bx ! bh = display page
mov [6],ax ! al = video mode, ah = window width
! check for EGA/VGA and some config parameters
mov ah,#0x12
mov bl,#0x10
int 0x10
mov [8],ax
mov [10],bx
mov [12],cx
! Get hd0 data
mov ax,#0x0000
mov ds,ax
lds si,[4*0x41]
mov ax,#INITSEG
mov es,ax
mov di,#0x0080
mov cx,#0x10
rep
movsb
! Get hd1 data
mov ax,#0x0000
mov ds,ax
lds si,[4*0x46]
mov ax,#INITSEG
mov es,ax
mov di,#0x0090
mov cx,#0x10
rep
movsb
! Check that there IS a hd1 :-)
mov ax,#0x01500
mov dl,#0x81
int 0x13
jc no_disk1
cmp ah,#3
je is_disk1
no_disk1:
mov ax,#INITSEG
mov es,ax
mov di,#0x0090
mov cx,#0x10
mov ax,#0x00
rep
stosb
這塊代碼首先獲取了光標位置,然後作為一個字存到了0x90000
。同理,獲取了擴展內存的大小、一些顯示類的信息和硬盤參數列表。
硬盤參數表是什麼?在PC
機中BIOS
設定的中斷向量表中int 0x41
的中斷向量位置存放的並不是中斷程序的地址,而是第一個硬盤的基本參數表。對於BIOS
來說,這裡存放着硬盤參數表陣列的首地址0xFE401
。第二個硬盤的基本參數表入口地址存於int 0x46
中斷向量位置處。每個硬盤參數表有16個位元組大小。這些是硬件的相關知識,了解明白即可。
接下來就是讓你激動的時刻,開始進入保護模式。先看第一部分代碼:
! now we want to move to protected mode ...
cli ! no interrupts allowed !
! first we move the system to it's rightful place
mov ax,#0x0000
cld ! 'direction'=0, movs moves forward
do_move:
mov es,ax ! destination segment
add ax,#0x1000
cmp ax,#0x9000
jz end_move
mov ds,ax ! source segment
sub di,di
sub si,si
mov cx,#0x8000
rep
movsw
jmp do_move
end_move:
首先使用cli
指令屏蔽中斷,準備開始乾坤大挪移,將system
模塊移動到想要的位置(內存0地址處)。
但是0
地址附近正是BIOS
相關數據區,我們再把上面的表格拿下來:
也就是說,原來的BIOS
的中斷和數據被覆蓋了,也就是被捨棄掉了。由於當時system
假設模塊的最大長度不會超過0x80000
,也就是512 KB
,即末尾不會超過0x90000
這個地址。
移動完後,就開始進入保護模式的準備工作了。
在Intel
的保護模式下,段描述符存在於GDT
和IDT
表中(LDT
不使用)。段寄存器需要GDT
表,而調用中斷需要IDT
表,所以我們需要設置這兩張表:
! then we load the segment descriptors
end_move:
mov ax,#SETUPSEG ! right, forgot this at first. didn't work :-)
mov ds,ax
lidt idt_48 ! load idt with 0,0
lgdt gdt_48 ! load gdt with whatever appropriate
我們來看看這所謂的IDT
和GDT
表:
idt_48:
.word 0 ! idt limit=0
.word 0,0 ! idt base=0L
gdt_48:
.word 0x800 ! gdt limit=2048, 256 GDT entries
.word 512+gdt,0x9 ! gdt base = 0X9xxxx
下一步開啟A20
地址線,開始蛻變:
! that was painless, now we enable A20
call empty_8042
mov al,#0xD1 ! command write
out #0x64,al
call empty_8042
mov al,#0xDF ! A20 on
out #0x60,al
call empty_8042
這裡得注意一下:A20
地址線並不是打開保護模式的關鍵,只是在保護模式下,不打開A20
地址線,你將無法訪問到所有的內存。 這個又是為了保持兼容性出的幺蛾子。empty_8042
這個函數的作用是測試8042
狀態寄存器。這塊代碼涉及硬件的相關東西太多,這裡就簡單介紹,感興趣可自行科普。
! well, that went ok, I hope. Now we have to reprogram the interrupts :-(
! we put them right after the intel-reserved hardware interrupts, at
! int 0x20-0x2F. There they won't mess up anything. Sadly IBM really
! messed this up with the original PC, and they haven't been able to
! rectify it afterwards. Thus the bios puts interrupts at 0x08-0x0f,
! which is used for the internal hardware interrupts as well. We just
! have to reprogram the 8259's, and it isn't fun.
mov al,#0x11 ! initialization sequence
out #0x20,al ! send it to 8259A-1
.word 0x00eb,0x00eb ! jmp $+2, jmp $+2
out #0xA0,al ! and to 8259A-2
.word 0x00eb,0x00eb
mov al,#0x20 ! start of hardware int's (0x20)
out #0x21,al
.word 0x00eb,0x00eb
mov al,#0x28 ! start of hardware int's 2 (0x28)
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0x04 ! 8259-1 is master
out #0x21,al
.word 0x00eb,0x00eb
mov al,#0x02 ! 8259-2 is slave
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0x01 ! 8086 mode for both
out #0x21,al
.word 0x00eb,0x00eb
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0xFF ! mask off all interrupts for now
out #0x21,al
.word 0x00eb,0x00eb
out #0xA1,al
這塊代碼相當奇奇怪怪。這個是對中斷重新編程,放到Intel
保留中斷之後。這個又涉及硬件層面的東西,感興趣自行科普825A
芯片的相關知識。
這些代碼看起來真沒勁,生澀而且難看。不過所幸的是,我們終於可以真正的踏入保護模式了:
! Well, now's the time to actually move into protected mode. To make
! things as simple as possible, we do no register set-up or anything,
! we let the gnu-compiled 32-bit programs do that. We just jump to
! absolute address 0x00000, in 32-bit protected mode.
mov ax,#0x0001 ! protected mode (PE) bit
lmsw ax ! This is it!
jmpi 0,8 ! jmp offset 0 of segment 8 (cs)
我們先看一張圖:
CR0
的最後一位PE
,控制着是否開啟保護模式,如果置1
,則么表示開啟,此時CPU
將開始進入全新的模式。但為什麼用lmsw ax
加載程序狀態字的形式進行而不直接用mov cr0,ax
呢?這又是該死的歷史的包袱,僅僅是為了兼容罷了。
有關引導啟動還剩最後一塊
練習與思考
本節的答案將會在下一節進行講解,務必把本節練習做完後看下一個講解內容。不要偷懶,實驗是學習本教程的捷徑。
俗話說得好,光說不練假把式,如下是本節相關的練習。如果練習沒做成功,就不要看下一節教程了。
- 繪製執行進入保護模式的時候的內存布局狀態。
- 用表格的形式展示
setup.s
程序在內存中保存的數據。 .word 0x00eb,0x00eb
的作用是啥?- 介紹到最後的
jmpi 0,8
代碼最終跳到了哪個地址?為什麼?
下一篇
羽夏看Linux內核——引導啟動(下)