探本溯源——深入領略Linux內核絕美風光之系統啓動篇(二)

在前文結尾處我們提到內核映像的加載是由專用的bootloader比如LILO或是GRUB來實現的,而在x86架構下Linux內核通常使用其中之一的GRUB,它通過執行initrd文件來識別內核映像所在的文件系統進而執行加載,然而有一個需要注意的問題是,並非所有的物理地址空間對內核而言都是可用的,比如其中的某個物理地址範圍可能被映射爲I/O設備的共享內存,也可能其中的一個物理頁框存放着BIOS數據,綜合上述原因,GRUB必須建立一個物理地址映射來將內核加載至可用的物理內存中,而建立映射的這一過程則是根據協議完成的,不僅如此,實模式下的內核代碼能夠佔用的內存空間,以及初始化過程中用來建立堆棧的物理內存大小都需要遵守相關的啓動協議,有關LINUX/x86啓動協議的詳細信息可參考Documentation\x86目錄下的boot.txt文件,該文件還詳細解釋了header.S中所定義的全局變量hdr的各個字段的含義,而其重要性單從源文件中各個字段後的註釋規模便可見一斑。

物理內存佈局
在深入剖析源代碼之前,我們有必要首先明確啓動階段物理內存的分佈情況,因爲只有明白了執行過程背後所依據的一些基本事實才能對源代碼有更爲深刻的認識,而其中的一些事實源於軟硬件的高速發展,另外一些則是爲了滿足兼容性的要求。

總的來說,內核映像由三部分構成,它們分別是:

  • 一個512字節的啓動扇區——Kernel boot sector,也即在前文中所剖析的Linux內核自帶的bootloader,但由於現在使用的bootloader爲GRUB,爲了避免產生歧義,所以冠以另外一個名稱。該boot sector佔用操作系統映像所在邏輯分區的第一個扇區,注意從bootsect的偏移0x1f1處開始存放hdr變量。
  • 實模式下的內核安裝部分——Kernel setup,連續佔用若干個扇區大小的內存空間,主要用於檢測硬件環境並執行一系列的初始化,爲保護模式下內核代碼的運行完成一些前期的準備工作。
  • 保護模式下的內核代碼,GRUB將其加載至從地址0x10 0000開始的物理內存中。

這裏要注意的是實模式下的Kernel boot sector和Kernel setup雖然從邏輯上被分成兩部分,但它們的分佈是連續的,並且這兩部分都處於第一個1MB的物理內存空間中,而其上所提到的保護模式下的內核代碼的初始地址爲0x10 0000,表示這部分代碼從物理內存的第二個1MB開始安裝,因此內核映像的實模式及保護模式這兩個部分的分界線即物理地址0x10 0000。這裏要特別注意的一點是,GRUB不可能單獨在實模式下完成上述加載任務,因爲在實模式下CPU只能尋址第一個1MB範圍內的物理內存空間(原因請參見處理器體系結構及尋址模式一文)。有意思的是,在GRUB的次引導過程執行時將會臨時性地切換到保護模式,完成內核映像的加載後再次回到實模式。因此雖然引導加載過程有兩種模式的來回切換操作,但對於內核映像來說卻是透明的,當控制權轉交給內核後,它仍然首先從實模式下開始運行,但此時並不意味着超出第一個1MB範圍的物理內存中沒有任何內容,只是這些內容佔用的內存空間無法被內核當前執行的指令所尋址,這使得保護模式下的內核代碼不會被隨意修改,也算是對保護模式中“保護”一詞的另類詮釋。

以下是GRUB將內核映像加載至內存空間後的分佈圖:


圖1

上圖同樣位於Documentation\x86目錄下的boot.txt中。在該文件中還展示了由zImage內核映像所使用的傳統內存映射模型,但我們並不過多關注這類歷史遺留問題,在後續的代碼剖析過程中均以現階段所使用的一些模型作爲基準。在前文結尾處曾提到過BIOS例程將內核放入低地址0x0001 0000(小內核映像zImage)或者從高地址0x0010 0000(大內核映像bzImage)開始的RAM中,而現階段所使用的Linux內核均編譯爲bzImage,因此保護模式下的內核代碼被安裝在從高地址0x0010 0000開始的RAM中。正如上圖所示,我們也可以看到在完成POST以及一系列的初始化工作後,BIOS將bootloader加載至物理地址0000 7c00處。而令人困惑的是,內核的啓動扇區bootsect的起始地址並未被嚴格限制,這其實是由於Linux內核允許使用多種bootloader所導致的,比如前文所提到的LILO以及GRUB,在現實情況中存在更多不同類型的bootloader,不同的bootloader可能將實模式的起始地址加載至不同的位置,然而在x86架構下的GRUB設置的起始地址正是0x9 0000

另外我們也可以看到實模式下的內核代碼以及該代碼所創建的堆棧的大小至多爲8KB,之所以需要創建堆棧是爲了提供C語言的運行環境,因爲代碼的執行過程需要使用棧來保存局部變量,函數調用則需要藉助棧來傳遞參數以及保存返回地址,其中還有可能涉及到動態內存的分配,以及將內存分配給內核命令行。這樣一來,實模式下的內核代碼需要使用的內存空間將會達到8KB*2=16KB,而該段的起始地址爲0x9 0000,因此這段內存空間的結尾處的地址至多將會達到0xA 0000。而這是不允許,因爲現代機器中的許多BIOS例程需要使用從起始地址0x9 A000開始的額外內存空間,該內存空間即擴展的BIOS數據區(Extended BIOS Data Area,EBDA)。若內核被GRUB安裝至較高的內存空間,那麼執行代碼將被BIOS修改。事實上實模式下的Linux內核正是基於這一原則所設計的,我們將在後文的源代碼剖析過程中看到其具體的實現細節。

我們在前文提到過,代碼背後所依據的某些事實是爲了滿足歷史遺留問題所提出的兼容性要求,這一點可從上圖中物理地址範圍0xA 0000~0x10 0000的內存佔用情況得知。追溯至上世紀80年代,當時IBM所推出的第一臺PC機可供尋址的物理內存總共爲1MB。而這1MB中的低640KB供DOS以及應用程序使用,而高端的384KB則被留作它用,其中低端的640KB被稱爲常規內存,高端的384KB則被稱爲保留內存,這兩種不同的內存類型通過物理地址0xA 0000得以分隔,此後這個分界線便被確定下來並沿用至今。

簡單總結一下,在x86體系結構中,RAM的第一個1MB內存空間包含如下兩個“獨特”的地方:

  • 物理地址0x0000~0x1000以及0x9 A000~0xA 0000所佔內存由BIOS使用,存放加電自檢(Power-On Self-Test,POST)期間檢查到的系統硬件配置。有些類型的BIOS甚至在系統初始化之後依然將數據寫入該內存。
  • 從0xA 0000~0x10 0000範圍內的物理內存通常保存BIOS例程,並且映射ISA圖形卡上的內部內存。這個區域就是IBM兼容PC上從640KB到1MB之間的著名的洞——圖1中的I/O memory hole:物理地址存在但被保留,不能由操作系統使用。

內核將上述地址範圍的物理內存所佔頁框標記爲保留,它們連同內核代碼以及已初始化或未初始化的內核數據一起,在整個機器的執行週期中常駐內存,而絕不能被動態分配或由內核調度程序交換到磁盤上。


圖2

上圖形象地顯示了常駐物理內存中各個區域的使用情況,需要注意的一點是保護模式下的內核代碼所佔頁框的總數依賴於對應的配置方案,因此上圖中與之對應的區域只是用符號簡單的加以表示。而實模式下的內核代碼僅在啓動期間執行硬件檢測及一系列初始化操作之後便不再被使用,因此當實模式下的內核代碼跳轉到保護模式之後,這部分內存空間即可由內核代碼用於其他用途。

實模式下內核的初始化變量——安裝頭(setup header)
該安裝頭爲從Kernel boot sector中偏移0x1f1處開始的hdr變量,主要存放初始化期間將會使用到的一些數據。我將把該變量中各個字段的含義集中羅列在這裏,在後文中講到內核執行初始化過程中使用到這些數據時不再單獨詳細描述。另外要注意的是有些字段的存在同樣屬於歷史遺留性問題,對於這些內容我們直接一帶而過。下表是這些字段的概要說明:

偏移量/大小 字段名 含義
0x1F1/1 setup_sects 表示Kernel setup中的代碼所佔用的物理存儲空間
0x1F2/2 root_flags
如果被設置,則根以可讀形式掛載
0x1F4/4 syssize
32位代碼的大小,該值以16字節的段爲單位
0x1F8/2 ram_size
不再使用——僅由bootsect.S文件使用(已被廢棄
0x1FA/2 vid_mode
視頻模式控制
0x1FC/2 root_dev
默認根設備號
0x1FE/2 boot_flag
魔數,其值爲常量"0xAA55",標識有效引導記錄結尾的標籤
0x200/2 jump
跳轉指令
0x202/4 header
魔數標籤,其值爲"HdrS"
0x206/2 version
啓動協議版本支持
0x208/4 realmode_swtch
引導加載程序鉤子
0x20C/2 start_sys_seg
加載低地址段(已被廢棄
0x20E/2 kernel_version
指向內核版本字符串的指針
0x210/1 type_of_loader
引導加載程序標識符
0x211/1 loadflags
引導協議選項標誌
0x212/2 setup_move_size
移動至高位內存所需的大小(已被廢棄
0x214/4 code32_start
引導加載程序鉤子
0x218/4 ramdisk_image
initrd加載地址
0x21C/4 ramdisk_size
initrd的尺寸
0x220/4 bootsect_kludge
不再使用——僅由bootsect.S使用(已被廢棄
0x224/2 heap_end_ptr
在setup尾部之後的空閒內存
0x226/1 ext_loader_ver
擴展的引導加載程序版本
0x227/1 ext_loader_type
擴展的引導加載的ID
0x228/4 cmd_line_ptr
指向內核命令行的32位指針
0x22C/4 ramdisk_max
initrd可用的最高地址
0x230/4 kernel_alignment
對內核要求的物理地址對齊
0x234/1 relocatable_kernel
內核是否可重定位
0x235/1 min_alignment
最小對齊,其值要求爲2的平方
0x236/2 pad3
不再使用(已被廢棄
0x238/4 cmdline_size
內核命令行的最大尺寸
0x23C/4 hardware_subarch
硬件子架構
0x240/8 hardware_subarch_data
特定子架構的數據
0x248/4 payload_offset
內核負載偏移
0x24C/4 payload_length
內核負載的長度
0x250/8 setup_data
指向存放setup_data結構體的鏈表的64位物理指針
0x258/8 pref_address
首選的加載地址
0x260/4 init_size
初始化期間的線性地址要求
注:上表中由紅色標識的字段已被廢棄,由綠色標識的字段不再建議使用,而是統一在命令行中設置

下面單獨列出其中某些重要字段更詳細的解釋,注意所有字段都以小端法存放,並且其中一些字段存放由引導加載程序從內核中讀出的信息(即類型爲可讀),另外一些字段則由引導加載程序填充(類型爲可寫),其他的字段則由引導加載程序做適當的修改(類型爲可修改)。

  • setup_sects——佔用1個字節,類型爲可讀,表示Kernel setup所佔用的物理內存大小,且以一個512字節的扇區爲單位。爲了保持後向兼容,如果該字段被賦值爲0,那麼實際的值是4。實模式代碼由boot sector(總是佔用一個512字節的扇區大小)加上Kernel setup代碼組成。
  • syssize——佔用4個字節,類型爲可讀,該字段表示保護模式代碼的尺寸,大小以16字節的小段爲單元。但對於現在的Linux內核來說,在進行引導配置時僅使用其中的兩個字節——兩個高位字節不再可用,因此如果LOAD_HIGH標誌被置位那麼該字段不能被認爲是內核的大小。
  • jump——佔用2個字節,類型爲可讀。該字段包含x86架構下的跳轉指令,0xEB(跳轉指令的字節碼)後跟一個相對於地址0x202的有符號偏移,這個字段能被用來決定安裝頭的大小。
  • header——佔用4個字節,類型爲可讀,包含魔數"Hdrs"(0x53726448)。如果該魔數沒有在偏移0x202處設置,那麼啓動協議的版本被認爲是舊的,因此裝載一個老的內核。但我們在源文件header.S中清晰地看到了該標籤,因此總是加載bzImage內核映像。並且header字段之後的version字段包含協議的版本,例如若version字段被設置爲0x0204則代表使用2.04版本的協議,在源文件中該字段被設置爲0x020a,因此表示使用最新的2.10版本的協議。
  • kernel_version——佔用2個字節,類型爲可讀,如果該字段被設置爲非零值,則表示一個指向以NULL結尾的含有內核版本號的字符串的指針。這能夠被用來向用戶展示內核版本。字段值應小於0x200*setup_sects。
  • type_of_loader——佔用1個字節,類型爲可寫,該字段與ext_loader_type以及ext_loader_ver字段聯合起來表示所使用的bootloader的類型及其版本號,由於x86架構下始終使用的是GRUB,因此這裏我們不再繼續深究,具體細節可參考Documentation\x86目錄下的boot.txt文件。
  • loadflags——佔用1個字節,類型爲可修改,這個字段是一個位掩碼(bitmask)。第0位(只讀):LOADED_HIGH,如果該位置0,那麼保護模式下的代碼被加載至0x1 0000處,若復位則加載至0x10 0000。第5位(可寫):QUIET_FLAG,如果置0則打印早期信息,如果復位則禁止早期信息。第6位(可寫):KEEP_SEGMENTS,如果該位置0那麼在32位入口點處重新加載段寄存器,如果復位那麼不會重新加載。第7位(可寫):CAN_USE_HEAP,將該位置1指示字段heap_end_ptr中的值有效,如果這個位被清除,那麼一些Kernel setup代碼將無法執行。其中最重要的是第0位與第7位。
  • code32_start——佔用4個字節,類型爲可修改。表示在保護模式中的跳轉地址,其值默認爲內核的加載地址,同時能夠用來被bootloader決定合適的加載地址。修改這個字段是出於以下兩個目的:①作爲引導加載程序的鉤子(a boot loader hook),②如果沒有安裝鉤子的bootloader將一個可重定位的內核加載至非標準的地址,那麼bootloader將會修改這個字段以指向加載地址。
  • ramdisk_image/ramdisk_size——均佔用4個字節且類型爲可寫。這兩個字段主要指示initrd的32位線性地址及其尺寸,若不使用initrd則這兩位均爲0。此外還有一個名爲ramdisk_max的字段,同樣佔用4個字節但類型爲可讀,表示initrd可用物理內存的最大地址。我們在前文中提到過,initrd主要是由GRUB的次引導程序載入內存,實現一些模塊的加載及文件系統的安裝。
  • heap_end_ptr——佔用2個字節,類型爲可寫。將這個字段設置爲Kernel setup中堆棧結尾處距離實模式代碼起始部分的偏移減去0x200後的值。
  • cmd_line_ptr——佔用4個字節,類型爲可寫。將這個字段設置爲內核命令行的線性地址。內核命令行能夠被定位至Kernel setup中堆的結尾處至物理地址0xA 0000之間的任何位置,正如實模式代碼自身一樣,內核命令行同樣不一定需要被放置在同一個64KB段中。即使引導加載程序不支持命令行,也需要填充這個字段,在這種情形下可以將其指向一個空字符串。但如果這個字段被置爲0,那麼內核將假設引導加載程序不支持2.02以上版本的協議(當前所使用的是2.10版本的協議)。
  • kernel_alignment——佔用4個字節,類型爲可讀可修改。若relocatable_kernel字段被設置爲真,那麼這個字段是由內核要求的對齊單元。一個可重定位內核當被加載至對齊方式與當前字段不兼容的地址處,那麼在內核的初始化過程中將會被重新對齊。在允許更小對齊的情況下,這個字段可以由引導加載程序修改。
  • relocatable_kernel——佔用1個字節,類型爲可讀。如果這個字段非零,內核的保護模式部分能夠被加載至滿足kernel_alignment字段的任意地址處。完成加載之後bootloader將會設置code32_start字段以指向被加載的代碼,或是bootloader鉤子。
  • min_alignment——佔用1個字節,類型爲可讀。這個字段如果非零那麼作爲2的冪指示最小對齊要求。如果引導加載程序使用了這個字段,它也應該更新kernel_alignment字段,更新方式爲:kernel_alignment=1<<min_alignment。
  • cmdline_size——佔用4個字節,類型爲可讀。這個字段表示不考慮結束符0在內的命令行的最大尺寸。這移位這命令行最多能夠包含cmdline_size個字符。
  • hardware_subarch/hardware_subarch_data——分別佔用4個及8個字節,且類型均爲可寫,這個字段允許bootloader通知內核現在所處的硬件環境。
  • payload_offset——佔用4個字節,類型爲可寫。如果非零那麼這個字段包含從保護模式代碼的起始地址到負載(payload)的偏移量。負載應該被壓縮,壓縮和非壓縮的數據都應該使用標準的魔數來決定。當前所支持的壓縮格式分別是:gzip(魔數爲1F 88或1F 9E),bzip2(魔數爲42 5A),LZMA(魔數爲5D 00)以及XZ(魔數爲FD 37)。非壓縮負載的格式至今總是ELF(魔數爲7F 45 4C 46)。下一個字段payload_length指示負載的長度。
  • setup_data——佔用8個字節,類型爲可寫。這個字段是一個指向節點爲setup_data結構體且以NULL結尾的鏈表的64位物理指針。它被用來定義可擴展的啓動參數傳遞機制。
  • pref_address——佔用8個字節,類型爲可讀。這個字段如果非零,則其值爲內核首選的加載地址。一個可重定位的bootloader應該儘可能試圖將內核加載至此處。一個不可重定位的內核則無條件移動其自身並從該地址處開始運行。
  • init_size——佔用4個字節,類型爲可讀。這個字段指示了在內核能夠檢測內存映射之前它所需要的線性連續內存的總量,這段連續內存起始於內核運行時的開始地址。它能夠被用來幫助可重定位的引導加載程序爲內核選擇一個安全的加載地址。

建立堆棧——準備C語言的運行環境
在前文中提到過,GRUB在完成一系列工作之後通過執行一個長跳轉指令進入內核的入口點,該入口點位於從實模式內核起始的偏移量0x200處。這意味着如果實模式內核代碼在地址0x9 0000處,內核的入口點則爲0000:9020。在起始處,ds/es/ss寄存器應該指向實模式內核代碼的開始處,即如果代碼被加載至0x9 0000處時這些寄存器的值被置爲0x9000(注意這一點很重要!),棧指針寄存器sp一般指向堆的頂部,並且中斷被禁用。此外爲了防範錯誤,在有些引導加載程序中將把fs/gs/ds/es/ss寄存器均設爲相同的值。通常引導加載程序的典型設置方式如下所示:

  1. /*段基址由特定的引導加載程序而定,在x86架構下始終使用GRUB*/  
  2. /*因此seg被設置爲0x9000*/  
  3. seg = base_ptr >> 4;    
  4.   
  5. /*禁用中斷*/  
  6. cli();  
  7.   
  8. /*設置實模式內核棧 */  
  9. _SS = seg;  
  10. _SP = heap_end;  
  11.   
  12. /*將DS/ES/FS/GS寄存器設爲段基址值*/  
  13. _DS = _ES = _FS = _GS = seg;  
  14.   
  15. /*執行長跳轉將控制權轉交內核*/  
  16. /*從header.S的_start全局標號處開始執行*/  
  17. jmp_far(seg+0x20, 0);  
/*段基址由特定的引導加載程序而定,在x86架構下始終使用GRUB*/
/*因此seg被設置爲0x9000*/
seg = base_ptr >> 4;  

/*禁用中斷*/
cli();

/*設置實模式內核棧 */
_SS = seg;
_SP = heap_end;

/*將DS/ES/FS/GS寄存器設爲段基址值*/
_DS = _ES = _FS = _GS = seg;

/*執行長跳轉將控制權轉交內核*/
/*從header.S的_start全局標號處開始執行*/
jmp_far(seg+0x20, 0);

以下是緊跟全局標號_start的頭兩個字節的內容:

  1.     .globl    _start  
  2. _start:  
  3.         # Explicitly enter this as bytes, or the assembler  
  4.         # tries to generate a 3-byte jump here, which causes  
  5.         # everything else to push off to the wrong offset.  
  6.   
  7.         /*跳轉指令,對應於安裝頭變量中的jump字段*/  
  8.         .byte    0xeb        # short (2-byte) jump  
  9.         .byte    start_of_setup-1f  
  10.   
  11. 1:  /*標號1*/  
  12.   
  13.     # Part 2 of the header, from the old setup.S  
    .globl    _start
_start:
        # Explicitly enter this as bytes, or the assembler
        # tries to generate a 3-byte jump here, which causes
        # everything else to push off to the wrong offset.

        /*跳轉指令,對應於安裝頭變量中的jump字段*/
        .byte    0xeb        # short (2-byte) jump
        .byte    start_of_setup-1f

1:  /*標號1*/

    # Part 2 of the header, from the old setup.S

這裏.byte 0xeb與.byte start_of_setup-1f是彙編指令jmp start_of_setup-1f的硬編碼形式,其中的跳轉爲短轉移,因此start_of_setup-1f所在的字節表示偏移量。因爲彙編指令經過彙編器的“翻譯”之後所形成的均爲如上形式的字節碼,而CPU本質上並不對數據和指令嚴格區分,因此可以通過對數據進行精心構造,使其表面上看起來是被處理的數據,但本質上卻是可以被用來執行的指令。說到這裏還想扯句題外話,我們經常說一個可執行文件被感染了,通常就是指該文件的內部構造被修改了,因爲對任何文件都可以進行寫操作,所以該可執行文件自然可以由某個不懷好意的進程增加一些新的數據,而這些數據正是被精心構造好的指令,在該文件此後的執行過程中發生的原本不可能存在的一系列操作都是這些數據的“功勞”,而這種方式正是通常所說的“區段注入”,這些被感染的文件自然也就成了所謂的病毒或是木馬,另外在某些形式的緩衝區溢出攻擊中也利用了這一點。其實這一特點還會催生很多有意思的話題,比如可執行文件對自身進行修改,在執行過程中進行自我進化——有些《黑客帝國》的味道 :-)。

繼續回到內核的剖析上來,我們在之前說過上面這兩個字節的硬編碼執行的是jmp跳轉指令的功能,而如果直接寫jmp start_of_setup-1f形式的彙編指令,彙編器最終也能生成執行相同功能的字節碼,那爲什麼不直接寫彙編指令而偏要費那麼大勁,去精心構造這兩個字節的數據呢?其實上面的註釋已經給出了詳細的解答,因爲彙編器生成的字節碼最終將會佔用3個字節,這使得後續的字段都被“推移”到錯誤的偏移處,所以我們只能通過硬編碼的形式來實現跳轉。另外在GAS彙編中還有一個重要的知識點,那就是在start_of_setup-1f中的1f並不表示十六進制的數據0x1f,其中的1表示一個標號,緊跟着的f表示向前的(forward),而如果要向後面的標號1跳轉,則應該寫成1b(b—backward)。.byte start_of_setup-1f這個字節的值表示兩個標號——即start_of_setup與1之間的偏移量,由彙編器在彙編過程中自動填充。因爲在彙編中的分支及循環語句只能通過jmp及其各種不同的變體來實現,因而跳轉指令所跳轉到的位置必須賦予一個標號,如果每個標號都取一個具有特定意義的名稱將會very painful,因而GNU assembler中的這一特性對於彙編愛好者來說無疑very absorbing。我們在源文件中可以看到其中的大部分標號都僅僅只是一些沒有含義的數字,足以說明維護Linux內核代碼的這些hackers是十足的懶人 :-)。

接着跳轉到start_of_setup標號處:

  1.     .section ".entrytext""ax"  
  2. start_of_setup:  
  3. #ifdef SAFE_RESET_DISK_CONTROLLER   
  4. # Reset the disk controller.   
  5.     movw    $0x0000, %ax        # Reset disk controller  
  6.     movb    $0x80, %dl      # All disks  
  7.     int $0x13  
  8. #endif   
  9.   
  10. # Force %es = %ds  /*強制將ds寄存器的內容賦值給es寄存器*/   
  11.     movw    %ds, %ax  
  12.     movw    %ax, %es  /*注意此時ax寄存器的內容與ds寄存器相同*/  
  13.     cld  /*清除方向標誌,使用在串傳送指令中,表示在完成傳送後將di寄存器自動增加*/  
	.section ".entrytext", "ax"
start_of_setup:
#ifdef SAFE_RESET_DISK_CONTROLLER
# Reset the disk controller.
	movw	$0x0000, %ax		# Reset disk controller
	movb	$0x80, %dl		# All disks
	int	$0x13
#endif

# Force %es = %ds  /*強制將ds寄存器的內容賦值給es寄存器*/
	movw	%ds, %ax
	movw	%ax, %es  /*注意此時ax寄存器的內容與ds寄存器相同*/
	cld  /*清除方向標誌,使用在串傳送指令中,表示在完成傳送後將di寄存器自動增加*/

在start_of_setup標號之後緊跟着的又是一個歷史遺留的產物。在上述代碼中我們看到如果預定義了宏SAFE_RESET_DISK_CONTROLLER,那麼將調用BIOS中斷例程0x13重置磁盤控制器。而這僅僅只是針對老式硬盤的代碼,目前的硬盤並不需要執行這些指令,留着它僅僅是爲了兼容老式硬盤,因此內核文件中並未預定義這個宏。

  1. # Apparently some ancient versions of LILO invoked the kernel with %ss != %ds,   
  2. # which happened to work by accident for the old code.  Recalculate the stack   
  3. # pointer if %ss is invalid.  Otherwise leave it alone, LOADLIN sets up the   
  4. # stack behind its own code, so we can't blindly put it directly past the heap.   
  5.         /*注意之前已將ds寄存器中的值賦予ax寄存器*/  
  6.     movw    %ss, %dx  /*將ss寄存器賦予dx寄存器*/  
  7.     cmpw    %ax, %dx    # %ds == %ss?  /*比較ds寄存器與ss寄存器是否相等*/  
  8.     movw    %sp, %dx  /*將棧指針寄存器sp的值賦給dx寄存器*/  
  9.           
  10.         /*若ds寄存器的值與ss相等則跳轉至標號2處,即說明sp寄存器已被合理設置*/  
  11.         je  2f      # -> assume %sp is reasonably set  
  12.   
  13.         /*反之則說明ss寄存器無效,建立一個新的棧*/  
  14.     # Invalid %ss, make up a new stack   
  15.     movw    $_end, %dx  /*將Kernel setup的結束地址裝入dx寄存器*/  
  16.     testb   $CAN_USE_HEAP, loadflags  /*位測試操作的結果爲真,不發生跳轉*/  
  17.     jz  1f  
  18.     movw    heap_end_ptr, %dx  /*heap_end_ptr = _end+STACK_SIZE-512*/  
  19. 1:  addw    $STACK_SIZE, %dx  /*將heap_end_ptr的值加上STACK_SIZE,即棧的大小*/  
  20.     jnc 2f  
  21.     xorw    %dx, %dx    # Prevent wraparound  
# Apparently some ancient versions of LILO invoked the kernel with %ss != %ds,
# which happened to work by accident for the old code.  Recalculate the stack
# pointer if %ss is invalid.  Otherwise leave it alone, LOADLIN sets up the
# stack behind its own code, so we can't blindly put it directly past the heap.
        /*注意之前已將ds寄存器中的值賦予ax寄存器*/
	movw	%ss, %dx  /*將ss寄存器賦予dx寄存器*/
	cmpw	%ax, %dx	# %ds == %ss?  /*比較ds寄存器與ss寄存器是否相等*/
	movw	%sp, %dx  /*將棧指針寄存器sp的值賦給dx寄存器*/
        
        /*若ds寄存器的值與ss相等則跳轉至標號2處,即說明sp寄存器已被合理設置*/
        je	2f		# -> assume %sp is reasonably set

        /*反之則說明ss寄存器無效,建立一個新的棧*/
	# Invalid %ss, make up a new stack
	movw	$_end, %dx  /*將Kernel setup的結束地址裝入dx寄存器*/
	testb	$CAN_USE_HEAP, loadflags  /*位測試操作的結果爲真,不發生跳轉*/
	jz	1f
	movw	heap_end_ptr, %dx  /*heap_end_ptr = _end+STACK_SIZE-512*/
1:	addw	$STACK_SIZE, %dx  /*將heap_end_ptr的值加上STACK_SIZE,即棧的大小*/
	jnc	2f
	xorw	%dx, %dx	# Prevent wraparound

首先解釋一下注釋——有些舊版本的引導加載程序LILO在裝載完內核並將控制權轉交給內核後,寄存器ss與ds並不相等。並且此時ss寄存器無效,因而需要重新計算棧指針,建立新棧的過程是由上述代碼中跳轉指令je  2f與標號2之間所執行的指令完成的。建立新棧時首先執行movw  $_end, %dx指令將_end的值賦給dx寄存器,這裏_end是在彙編時由彙編器自動填充的,它的值正是Kernel setup與實模式內核代碼起始地址的偏移量,由圖1中我們可以看出其值最大爲0x8000,在arch\x86\boot目錄中的鏈接腳本setup.ld也驗證了這一點:

  1. . = ASSERT(_end <= 0x8000, "Setup too big!");  /*第59行*/  
	. = ASSERT(_end <= 0x8000, "Setup too big!");  /*第59行*/

上述語句斷言當_end的值大於0x8000時,執行鏈接時將會報錯,提示“Kernel setup太大”。接着執行testb $CAN_USE_HEAP, loadflags指令,測試在loadflags字段中是否已經置位CAN_USE_HEAP所指示的位,這兩個操作數在源文件中的定義如下:

  1.       
  2. loadflags:  
  3. LOADED_HIGH = 1         # If set, the kernel is loaded high  
  4. CAN_USE_HEAP    = 0x80      # If set, the loader also has set  
  5.                     # heap_end_ptr to tell how much   
  6.                     # space behind setup.S can be used for   
  7.                     # heap purposes.   
  8.                     # Only the loader knows what is free   
  9.         .byte   LOADED_HIGH  /*被設置爲LOADED_HIGH*/  
	
loadflags:
LOADED_HIGH	= 1			# If set, the kernel is loaded high
CAN_USE_HEAP	= 0x80		# If set, the loader also has set
					# heap_end_ptr to tell how much
					# space behind setup.S can be used for
					# heap purposes.
					# Only the loader knows what is free
		.byte	LOADED_HIGH  /*被設置爲LOADED_HIGH*/

我們發現loadflags字段的值被設置爲LOADED_HIGH,因此由註釋看出保護模式下的內核將被加載至起始地址0x10 0000處。然而根據1=0000 0001b可知這個字段的第7位並未被置1,但是其註釋同樣指出只有在第7位置1時才表示內核會使用堆,那麼loadflags字段中的這一位究竟是否會被置1?答案是肯定的,因爲Linux內核從啓動協議版本號2.01開始,至今一直都支持實模式下的堆,所以根據註釋可以猜測正是bootloader在加載內核時將這一位設置成1。於是testb $CAN_USE_HEAP, loadflags指令最後的運算結果爲1,從而不發生跳轉,隨後緊接着執行movw heap_end_ptr, %dx指令,這條指令將heap_end_ptr的值填充dx寄存器,其中heap_end_ptr的值爲:

  1. heap_end_ptr:   .word   _end+STACK_SIZE-512  
  2.                     # (Header version 0x0201 or later)   
  3.                     # space from here (exclusive) down to   
  4.                     # end of setup code can be used by setup   
  5.                     # for local heap purposes.  
heap_end_ptr:	.word	_end+STACK_SIZE-512
					# (Header version 0x0201 or later)
					# space from here (exclusive) down to
					# end of setup code can be used by setup
					# for local heap purposes.

heap_end_ptr被設置爲_end+STACK_SIZE+512,其中STACK_SIZE的值在arch\x86\boot\Boot.h文件中被定義如下:

  1. #define STACK_SIZE  512 /* Minimum number of bytes for stack */  
#define STACK_SIZE	512	/* Minimum number of bytes for stack */

可以發現heap_end_ptr的值其實就等價於_end,而_end的值爲Kernel setup的結束地址。接着執行標號1後的指令addw $STACK_SIZE, %dx,其中STACK_SIZE表示整個棧的大小,在實模式下,512字節的內存被分配給堆和棧同時使用完全足夠。接着執行jnc  2f指令,由於標誌位沒有發生進位,因此直接跳轉至標號2處,代碼如下:

  1. 2:  # Now %dx should point to the end of our stack space  
  2.     andw    $~3, %dx    # dword align (might as well...)  
  3.     jnz 3f  /*測試條件爲真,執行跳轉*/  
  4.     movw    $0xfffc, %dx    # Make sure we're not zero  
  5.   
  6. 3:  movw    %ax, %ss  /* 實際執行 movw %ds, %ss */  
  7.     movzwl  %dx, %esp   # Clear upper half of %esp  
  8.           
  9.         /*此時允許中斷,該指令與GRUB中的cli對應*/  
  10.         sti            # Now we should have a working stack  
2:	# Now %dx should point to the end of our stack space
	andw	$~3, %dx	# dword align (might as well...)
	jnz	3f  /*測試條件爲真,執行跳轉*/
	movw	$0xfffc, %dx	# Make sure we're not zero

3:	movw	%ax, %ss  /* 實際執行 movw %ds, %ss */
	movzwl	%dx, %esp	# Clear upper half of %esp
        
        /*此時允許中斷,該指令與GRUB中的cli對應*/
        sti			# Now we should have a working stack

首先執行andw $~3, %dx指令將dx寄存器中的最低兩位清零,即將棧底地址執行雙字對齊操作,使得加載數據的效率更高。之後執行jnz  3f指令,由於上一條指令執行後的結果非零,因此跳轉。在標號3後首先執行movw %ax, %ss指令,將ax寄存器賦值給ss,這裏注意我們在一開始跳轉至start_of_setup標號處執行時,曾將ax寄存器用來暫存ds寄存器的值,而此後的執行過程中該寄存器的值一直都未發生改變,因此這條指令實際是將ds寄存器的值賦值給了ss寄存器。緊接着執行movzwl  %dx, %esp將棧底地址賦值給esp寄存器從而完成堆棧的建立,將棧指針寄存器esp賦值爲棧底地址表明初始時刻棧爲空。其後的sti指令則打開中斷,執行該指令是因爲在將控制權轉交給實模式下的內核代碼之前,GRUB執行了cli()禁用中斷的操作,因此完成堆棧的建立之後需要再次打開中斷。上圖所執行的一系列指令最終可由下圖形象的顯示:


圖3

完成堆棧的建立操作後,還需對cs:eip執行相關的修正操作,具體指令如下所示:

  1. # We will have entered with %cs = %ds+0x20, normalize %cs so   
  2. # it is on par with the other segments.   
  3.     pushw   %ds  
  4.     pushw   $6f  
  5.     lretw  
  6. 6:  
# We will have entered with %cs = %ds+0x20, normalize %cs so
# it is on par with the other segments.
	pushw	%ds
	pushw	$6f
	lretw
6:

前兩條pushw指令分別將ds寄存器的值以及標號6處的偏移量進行壓棧,因爲我們已經正確建立了堆棧,因此上述兩條指令可以正常工作。此後執行lretw長跳轉指令,它將先前壓入堆棧的操作數——即ds寄存器以及標號6對應的偏移量分別彈出至cs寄存器和eip寄存器,此後從標號6處繼續開始執行。之所以要執行這三條指令,是因爲GRUB將內核裝載至從地址0x9 0000開始的物理內存後,執行一條長跳轉指令jmp_far(seg+0x20, 0)跳過了512字節大小的bootsect,該指令將cs寄存器的值設置爲0x9020,而其他的一系列段寄存器ds/es/ss/fs/gs的值均指向起始地址0x9 0000處,因此在執行上述三條指令後將所有的段寄存器的值均設置爲0x9000——即執行了前文所說的修正操作。

接着執行標號6之後的指令:

  1. # Check signature at end of setup   
  2.     cmpl    $0x5a5aaa55, setup_sig  
  3.     jne setup_bad  /*測試條件爲假,不執行跳轉*/  
  4.   
  5. # Zero the bss   
  6.     movw    $__bss_start, %di  /*將bss段的起始地址加載至di寄存器中*/  
  7.     movw    $_end+3, %cx  /*將地址_end+3加載至cx寄存器中*/  
  8.     xorl    %eax, %eax  /*將eax寄存器清零*/  
  9.     subw    %di, %cx  /*將cx寄存器的值減去di寄存器的值,並將結果放入cx寄存器*/  
  10.     shrw    $2, %cx  /*將cx寄存器中的值右移兩位,即將cx的值除以4,並將結果存入cx寄存器*/  
  11.     rep; stosl  /*執行串指令操作stosl,將eax中的值保存到es:edi指向的內存中,並且將edi自增4*/  
  12.   
  13. # Jump to C code (should not return)   
  14.     calll   main  /*跳轉到main函數中*/  
# Check signature at end of setup
	cmpl	$0x5a5aaa55, setup_sig
	jne	setup_bad  /*測試條件爲假,不執行跳轉*/

# Zero the bss
	movw	$__bss_start, %di  /*將bss段的起始地址加載至di寄存器中*/
	movw	$_end+3, %cx  /*將地址_end+3加載至cx寄存器中*/
	xorl	%eax, %eax  /*將eax寄存器清零*/
	subw	%di, %cx  /*將cx寄存器的值減去di寄存器的值,並將結果放入cx寄存器*/
	shrw	$2, %cx  /*將cx寄存器中的值右移兩位,即將cx的值除以4,並將結果存入cx寄存器*/
	rep; stosl  /*執行串指令操作stosl,將eax中的值保存到es:edi指向的內存中,並且將edi自增4*/

# Jump to C code (should not return)
	calll	main  /*跳轉到main函數中*/

首先比較setup_sig的值是否與0x5a5aaa55相等,若不等則跳轉至setup_bad標號處,同樣setup_sig的值是在鏈接的時候由鏈接器填充的,該值同樣定義在arch\x86\boot\setup.ld鏈接腳本文件中:

  1. .signature  : {  
  2.     setup_sig = .;  
  3.     LONG(0x5a5aaa55)  
  4. }  
	.signature	: {
		setup_sig = .;
		LONG(0x5a5aaa55)
	}

可以發現該值確實被定義爲0x5a5aaa55,因此jne  setup_bad指令將不會發生跳轉。接下來就是清空實模式下內核代碼的bss段——該段是Kernel setup的最後一段內存空間,需要注意bss段與數據段的區別:bss段存放的是未初始化的全局變量和靜態變量,而數據段存放的則是已初始化的全局變量和靜態變量。首先將bss段的起始地址裝載至di寄存器中並將eax清零,隨後設置帶前綴的串指令rep; stosl的循環次數,該循環次數存放在cx寄存器中,這裏需要注意的是由於執行一次串指令將清除4個字節的內存空間,因此cx寄存器存放的應該是整個bss段佔用的內存空間大小除以4之後的值,由於bss段的大小可能並非4的倍數,而除4之後會將餘數捨去,因此爲了保證將整個bss段所佔內存全部清零,需要將Kernel setup的結束地址_end加3之後再存入寄存器cx中,即執行movw  $_end+3, %cx指令才能正確清空整個bss段,而非movw  $end, %cx。在將整個bss段全部清零之後,即執行call main指令跳轉至C語言的main函數中,該函數主要執行一系列硬件檢測及初始化操作,至於詳細內容放在後文剖析。

發佈了6 篇原創文章 · 獲贊 3 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章