《趣談Linux》總結二:系統初始化

4 x86架構

對於linux來說,如果下面的硬件環境千差萬別,就會很難集中精力做出讓用戶易用的產品;
畢竟天天適配不同的平臺,就已經夠辛苦了;
x86 架構就是這樣一個開放的平臺。

4.1 計算機的工作模式

在這裏插入圖片描述
對於一個計算機來講,最核心的就是CPU(Central Processing Unit,中央處理器)。這是這臺計算機的大腦,所有的設備都圍繞它展開。

CPU 和其他設備連接,要靠一種叫作總線(Bus)的東西,其實就是主板上密密麻麻的集成電路,這些東西組成了 CPU 和其他設備的高速通道。

在這些設備中,最重要的是內存(Memory)。
原因:因爲單靠 CPU 是沒辦法完成計算任務的,很多複雜的計算任務都需要將中間結果保存下來,然後基於中間結果進行進一步的計算;CPU 本身沒辦法保存這麼多中間結果,這就要依賴內存了。

還有一些其他設備,例如顯卡會連接顯示器、磁盤控制器會連接硬盤、USB 控制器會連接鍵盤和鼠標等等。

CPU 和內存是完成計算任務的核心組件,所以這裏重點介紹一下CPU 和內存是如何配合工作的:

  • CPU和內存的配合

CPU 其實也不是單純的一塊,它包括三個部分,運算單元(算)、數據單元(存)和控制單元(指揮);

運算單元只管算,例如做加法、做位移等等;
但是,它不知道應該算哪些數據,運算結果應該放在哪裏。

運算單元計算的數據如果每次都要經過總線,到內存裏面現拿,這樣就太慢了,所以就有了數據單元
數據單元包括 CPU 內部的緩存和寄存器組,空間很小,但是速度飛快,可以暫時存放數據和運算結果。

有了放數據的地方,也有了算的地方,還需要有個指揮到底做什麼運算的地方,這就是控制單元
控制單元是一個統一的指揮中心,它可以獲得下一條指令,然後執行這條指令;
這個指令會指導運算單元取出數據單元中的某幾個數據,計算出個結果,然後放在數據單元的某個地方。


每個進程都有一個程序放在硬盤上,是二進制的,再裏面就是一行行的指令,會操作一些數據。

進程一旦運行,比如圖中兩個進程 A 和 B,會有獨立的內存空間,互相隔離;
程序會分別加載到進程 A 和進程 B 的內存空間裏面,形成各自的代碼段。(真實情況更復雜)

程序運行的過程中要操作的數據和產生的計算結果,都會放在數據段裏面:

  • CPU 怎麼執行這些程序,操作這些數據,產生一些結果,並寫入回內存呢?

CPU 的控制單元裏面,有一個指令指針寄存器,執行的是下一條指令在內存中的地址;
控制單元會不停地將代碼段的指令拿進來,先放入指令寄存器

指令分兩部分:
第一部分是做什麼操作,例如是加法還是位移;
二一部分是操作哪些數據。

如何執行指令:把第一部分交給運算單元,第二部分交給數據單元。

數據單元根據數據的地址,從數據段裏讀到數據寄存器裏,就可以參與運算了;
運算單元做完運算,產生的結果會暫存在數據單元的數據寄存器裏;
最終,會有指令將數據寫回內存中的數據段。

CPU 裏有兩個寄存器,專門保存當前處理進程的代碼段的起始地址,以及數據段的起始地址;(指令起始地址寄存器數據起始地址寄存器
這裏面寫的都是進程 A,那當前執行的就是進程 A 的指令,等切換成進程 B,就會執行 B 的指令了,這個過程叫作進程切換(Process Switch),這是多任務的基礎
在這裏插入圖片描述

  • 總線

CPU 和內存來來回回傳數據,靠的都是總線。

總線上主要有兩類數據:
一個是地址數據,也就是我想拿內存中哪個位置的數據,這類總線叫地址總線(AddressBus);
另一類是真正的數據,這類總線叫數據總線(Data Bus)。

所以說,總線有點像連接 CPU 和內存這兩個設備的高速公路,說總線到底是多少位,就類似
說高速公路有幾個車道;

這兩種總線的位數意義是不同的:
地址總線的位數,決定了能訪問的地址範圍到底有多廣:例如只有兩位,那 CPU 就只能認 00,01,10,11 四個位置,超過四個位置,就區分不出來了。位數越多,能夠訪問的位置就越多,能管理的內存的範圍也就越廣。
數據總線的位數,決定了一次能拿多少個數據進來:例如只有兩位,那 CPU 一次只能從內存拿兩位數;要想拿八位,就要拿四次。位數越多,一次拿的數據就越多,訪問速度也就越快。

4.2 x86作用

CPU 數據總線和地址總線越來越寬,處理能力越來越強,但始終開放、統一、兼容:
在這裏插入圖片描述
“開放”,意味着有大量其他公司的軟硬件是基於這個架構來實現的,不能爲所欲爲,想怎麼改怎麼改,一定要和原來的架構兼容,而且要一直兼容,這樣大家才願意跟着你這個開放平臺一直玩下去。如果朝令夕改,那其他廠商就慘了。

4.2.1 8086處理器

CPU組件:
在這裏插入圖片描述

4.2.1.1 數據單元

爲了暫存數據,8086 處理器內部有 8 個 16 位的通用寄存器,也就是剛纔說的 CPU 內部的數據單元;
分別是AX、BX、CX、DX、SP、BP、SI、DI

這些寄存器主要用於在計算過程中暫存數據

這些寄存器比較靈活,其中 AX、BX、CX、DX 可以分成兩個 8 位的寄存器來使用,分別是 AH、
AL、BH、BL、CH、CL、DH、DL,其中 H 就是 High(高位),L 就是 Low(低位)的意思;
這樣,比較長的數據也能暫存,比較短的數據也能暫存;
在計算機剛剛起步的時代,16位就算很長了,所以要劃分

4.2.1.2 控制單元

  • IP 寄存器

即指令指針寄存器(Instruction Pointer Register),指向代碼段中下一條指令的位置;
CPU 會根據它來不斷地將指令從內存的代碼段中,加載到 CPU 的指令隊列中,然後交給運算單元去執行。

  • 切換進程所需的寄存器

每個進程都分代碼段和數據段,爲了指向不同進程的地址空間,有四個 16位的段寄存器,分別是 CS、DS、SS、ES。

CS 是代碼段寄存器(Code Segment Register):通過它可以找到代碼在內存中的位置;
DS 是數據段的寄存器(Data Register):通過它可以找到數據在內存中的位置;
SS 是棧寄存器(Stack Register):棧是程序運行中一個特殊的數據結構,數據的存取只能從一
端進行,秉承後進先出的原則,push 就是入棧,pop 就是出棧
ES 是附加段寄存器(Extra Segment) :其他幾個段寄存器不夠用的時候,可以考慮使用 ES 段寄存器,

DS作用如果運算中需要加載內存中的數據,需要通過 DS 找到內存中的數據,加載到通用寄存器中。(交互)
如何加載?
對於一個段,有一個起始的地址,而段內的具體位置稱之爲偏移量(Offset);
在 CS 和 DS 中都存放着一個段的起始地址:代碼段的偏移量在 IP 寄存器中,數據段的偏移量會放在通用寄存器中;
出現的問題:CS 和 DS 都是 16 位的,也就是說,起始地址都是 16 位的,IP 寄存器和通用寄存器都是 16 位的,偏移量也是 16 位的,但是 8086 的地址總線地址是 20 位。怎麼湊夠這 20 位呢?
解決方法:使用“起始地址 *16+ 偏移量”,也就是把 CS 和 DS 中的值左移 4 位,變成 20 位的,加上 16 位的偏移量,這樣就可以得到最終 20 位的數據地址。

SS作用:凡是與函數調用相關的操作,都與棧緊密相關;
例如,A 調用 B,B 調用 C。當 A 調用 B 的時候,要執行 B 函數的邏輯,因而 A 運行的相關信息就會被 push 到棧裏面;
當 B 調用 C 的時候,同樣,B 運行相關信息會被 push 到棧裏面,然後才運行 C 函數的邏輯;
當 C 運行完畢的時候,先 pop 出來的是 B,B 就接着調用 C 之後的指令運行下去;
B 運行完了,再 pop 出來的就是 A,A 接着運行,直到結束。
在這裏插入圖片描述
從DS例子可以算出,無論真正的內存多麼大,對於只有 20 位地址總線的 8086 來講,能夠區分出的地址也就 2^20=1M,超過這個空間就訪問不到了。爲什麼呢?如果你想訪問 1M+X的地方,這個位置已經超過 20 位了,由於地址總線只有 20 位,在總線上超過 20 位的部分根本是發不出去的,所以發出去的還是 X,最後還是會訪問 1M 內的 X 的位置。
那一個段最大能有多大呢?因爲偏移量只能是 16 位的,所以一個段最大的大小是 2^16=64k。

4.2.2 32位處理器

在 32 位處理器中,有 32根地址總線,即32位,可以訪問 2^32=4G 的內存。

在開放架構的基礎上,如何保持兼容呢?

1.首先,通用寄存器進行擴展,可以將 8 個 16 位的寄存器擴展到 8 個 32 位的,但是依然可以保留 16 位的和 8 位的使用方式。
爲什麼高 16 位不分成兩個 8 位使用呢?因爲這樣就不兼容了呀!

其中,指向下一條指令的指令指針寄存器 IP,就會擴展成 32 位的,同樣也兼容 16 位的。
在這裏插入圖片描述
而改動比較大,有點不兼容的就是段寄存器(Segment Register):CS、DS、SS、ES

因爲原來的模式沒有把 16 位當成一個段的起始地址,也沒有按 8 位或者 16 位擴展的形式,而是根據當時的硬件,弄了一個不上不下的 20 位的地址。

這樣每次都要左移四位,也就意味着段的起始地址不能是任何一個地方,只是能整除 16 的地方。

如果新的段寄存器都改成 32 位的,明明 4G 的內存全部都能訪問到(寄存器跟總線的位數相同),還左移不左移四位呢?

所以索性就重新定義:CS、SS、DS、ES 仍然是 16 位的,但是不再是段的起始地址;
段的起始地址放在內存的某個地方。這個地方是一個表格,表格中的一項一項是段描述符(Segment Descriptor);
這裏面纔是真正的段的起始地址。而段寄存器裏面保存的是在這個表格中的哪一項,稱爲選擇子(Selector)。

這樣,將一個從段寄存器直接拿到的段起始地址的操作變成了先間接地從段寄存器找到表格中的一項,再從表格中的一項中拿到段起始地址。

這樣段起始地址就會很靈活了;
當然爲了快速拿到段起始地址,段寄存器會從內存中拿到 CPU 的描述符高速緩存器中。

當然,改了設計就不兼容了,怎麼辦呢?
將前一種模式稱爲實模式(Real Partern),後一種模式稱爲保護模式(Protected Partern);
當系統剛剛啓動的時候,CPU 是處於實模式的,這個時候和原來的模式是兼容的;
也就是說,哪怕你買了 32 位的 CPU,也支持在原來的模式下運行,只不過快了一點而已。
當需要更多內存的時候,你可以遵循一定的規則,進行一系列的操作,然後切換到保護模式,就能夠用到 32 位 CPU 更強大的能力。
這也就是說,不能無縫兼容,但是通過切換模式兼容,也是可以接受的。

4.3 總結

在這裏插入圖片描述
接下來看一下,CPU 如何從啓動開始,逐漸從實模式變爲保護模式的。

5 從BIOS到bootloader

從實模式開始,講解操作系統的啓動過程

5.1 BIOS時期

計算機需要有一個指導來進行啓動,BIOS可以起這個作用

在主板上,有一個東西叫ROM(Read Only Memory,只讀存儲器)。

這和平常說的內存RAM(Read Access Memory,隨機存取存儲器)不同;
平時買的內存條是可讀可寫的,這樣才能保存計算結果;
而 ROM 是隻讀的,上面早就固化了一些初始化的程序,也就是BIOS(Basic Input and Output System,基本輸入輸出系統)。

安裝好操作系統時,剛啓動的時候,按某個組合鍵,顯示器會彈出一個藍色的界面,這能夠調整啓動順序的系統就是 BIOS,然後可以執行它:
在這裏插入圖片描述
剛啓動時,內存很小,要好好利用:(假設只有1M)
在這裏插入圖片描述
在 x86 系統中,將 1M 空間最上面的 0xF0000 到 0xFFFFF 這 64K 映射給 ROM;
也就是說,到這部分地址訪問的時候,會訪問 ROM:
當電腦剛加電的時候,會做一些重置的工作:將 CS 設置爲 0xFFFF,將 IP 設置爲 0x0000;
所以第一條指令就會指向 0xFFFF0,正是在 ROM 的範圍內;
在這裏,有一個 JMP 命令會跳到 ROM 中做初始化工作的代碼,於是,BIOS 開始進行初始化的工作。

5.1.1 BIOS流程

1.BIOS 要檢查一下系統的硬件是否沒問題

2.要有系統調用,只不過自己就是幹活的;
這個時期你提供的服務很簡單,但也會有零星需求。
這個時候,要建立一個中斷向量表中斷服務程序,因爲現在你還要用鍵盤和鼠標,這些都要通過中斷進行的。
這個時期要輸出一些結果,因爲需要自己來,所以還要充當輸入系統
做了什麼工作,做到了什麼程度,都要主動顯示出去,也就是在內存空間映射顯存的空間,在顯示器上顯示一些字符(輸出系統):

5.2 bootloader時期

BIOS只能保證系統成立,但不能保證系統做大做強,需要尋找操作系統,操作系統就很強了;
所以BIOS做完任務後,要開始從引導扇區開始找操作系統

那麼操作系統在哪兒呢?
一般都會在安裝在硬盤上;
在 BIOS 的界面上有一個啓動盤的選項
啓動盤有什麼特點呢?
它一般在第一個扇區,佔 512 字節,而且以 0xAA55 結束。這是一個約定,當滿足這個條件的時候,就說明這是一個啓動盤,在 512 字節以內會啓動相關的代碼。

這些代碼是誰放在這裏的呢?
在 Linux 裏面有一個工具,叫Grub2,全稱 Grand UnifiedBootloader Version 2。顧名思義,就是搞系統啓動的;
可以通過 grub2-mkconfig -o /boot/grub2/grub.cfg 來配置系統啓動的選項;
這裏面的選項會在系統啓動的時候,成爲一個列表,讓你選擇從哪個系統啓動;
使用 grub2-install /dev/sda,可以將啓動程序安裝到相應的位置。

grub2 第一個要安裝的就是 boot.img;
它由 boot.S 編譯而成,一共 512 字節,正式安裝到啓動盤的第一個扇區。這個扇區通常稱爲MBR(Master Boot Record,主引導記錄 / 扇區);

上文說的BIOS 完成任務後,會將 boot.img 從硬盤加載到內存中的 0x7c00 來運行。由於 512 個字節實在有限,boot.img 做不了太多的事情。它能做的最重要的一個事情就是加載grub2 的另一個鏡像 core.img。

引導扇區就是上文說的引導BIOS去找操作系統的“導遊”,它雖然不知道“寶典”在哪裏,但是它知道誰知道,即core.img。

core.img 就是尋找操作系統的真正入口,它們知道的和能做的事情就多了一些;
core.img 由lzma_decompress.img、diskboot.img、kernel.img 和一系列的模塊組成,功能比較豐富,能做
很多事情

boot.img和core.img組成的扇區:
在這裏插入圖片描述
boot.img 先加載的是 core.img 的第一個扇區;
如果從硬盤啓動的話,這個扇區裏面是diskboot.img,對應的代碼是 diskboot.S。

boot.img 將控制權交給 diskboot.img 後,diskboot.img 的任務就是將 core.img 的其他部分加載進來:
先是解壓縮程序 lzma_decompress.img,再往下是 kernel.img,最後是各個模塊 module對應的映像;
這裏需要注意,它不是 Linux 的內核,而是 grub 的內核。

lzma_decompress.img 對應的代碼是 startup_raw.S,本來 kernel.img 是壓縮過的,現在執行的時候,需要解壓縮。

在這之前,我們所有遇到過的程序都非常非常小,完全可以在實模式下運行;
但是隨着我們加載的東西越來越大,實模式這 1M 的地址空間實在放不下了,所以在真正的解壓縮之前,lzma_decompress.img 調用 real_to_prot,切換到保護模式
這樣就能在更大的尋址空間裏面,加載更多的東西。

5.2.1 從實模式切換到保護模式

切換到保護模式後,需要把哪些是操作系統的權限,哪些是可以授權給別人的,都分的清清楚楚;
這樣就可以分出多個子系統,同時進行多個進程

切換到保護模式要幹很多工作,大部分工作都與內存的訪問方式有關:
第一項是啓用分段,就是在內存裏面建立段描述符表,將寄存器裏面的段寄存器變成段選擇子,指向某個段描述符,這樣就能實現不同進程的切換了。
第二項是啓動分頁。能夠管理的內存變大了,就需要將內存分成相等大小的塊

保護模式需要做一項工作,那就是打開 Gate A20,也就是第 21根地址線的控制線。
在實模式 8086 下面,一共就 20 個地址線,可訪問 1M 的地址空間;
如果超過了這個限度怎麼辦呢?當然是繞回來了;
在保護模式下,第 21 根要起作用了,於是我們就需要打開 Gate A20。
切換保護模式的函數 DATA32 call real_to_prot 會打開 Gate A20,也就是第 21 根地址線的控制線。
這樣,我們就有大把空間了。

接下來要對壓縮過的 kernel.img 進行解壓縮,然後跳轉到kernel.img 開始運行;
這時就是真正進行操作系統的選擇
kernel.img 對應的代碼是 startup.S 以及一堆 c 文件,在 startup.S 中會調用 grub_main,這是grub kernel 的主函數。
在這個函數裏面,grub_load_config() 開始解析我們上面提到的 grub.cfg 文件裏的配置信息。
如果是正常啓動,grub_main 最後會調用 grub_command_execute (“normal”, 0, 0),最終會調用grub_normal_execute() 函數。在這個函數裏面,grub_show_menu() 會顯示出讓你選擇的那個操作系統的列表。

選擇啓動某個操作系統,就要開始調用 grub_menu_execute_entry() ,開始解析並執行你選擇的那一項,這時候操作系統就啓動完畢了

解析:
例如裏面的 linux16 命令,表示裝載指定的內核文件,並傳遞內核啓動參數;
於是grub_cmd_linux() 函數會被調用,它會首先讀取 Linux 內核鏡像頭部的一些數據結構,放到內存中的數據結構來,進行檢查;
如果檢查通過,則會讀取整個 Linux 內核鏡像到內存。
如果配置文件裏面還有 initrd 命令,用於爲即將啓動的內核傳遞 init ramdisk 路徑;於是grub_cmd_initrd() 函數會被調用,將 initramfs 加載到內存中來。

當這些事情做完之後,grub_command_execute (“boot”, 0, 0) 纔開始真正地啓動內核。

5.3 總結

BIOS->引導扇區boot.img->diskboot.img->lzma_decompress.img(實模式到保護模式、建立分段分頁、打開地址線)->kernel.img(選擇某個操作系統)->啓動內核

6 內核初始化

從實模式切換到了保護模式,有了更強的尋址能力後,就開始啓動內核

內核的啓動從入口函數 start_kernel() 開始;
在 init/main.c 文件中,start_kernel 相當於內核的main 函數;
打開這個函數,你會發現,裏面是各種各樣初始化函數 XXXX_init,用來初始化子系統:
在這裏插入圖片描述

6.1 初始化子系統

6.1.1 進程管理子系統:INIT_TASK(init_task)

首先是進程管理子系統。
將來肯定要運行各種各樣的進程,因此,進程管理體系和進程管理流程首先要建立起來。

在操作系統裏面,先要有個創始進程;
有一行指令 set_task_stack_end_magic(&init_task),這裏面有一個參數 init_task,它的定義是
struct task_struct init_task = INIT_TASK(init_task);
它是系統創建的第一個進程,我們稱爲0 號進程;
這是唯一一個沒有通過 fork 或者 kernel_thread 產生的進程,是進程列表的第一個。

進程列表(Procese List):進程管理工具,裏面列着我們所有運行的進程。

6.1.2 系統調用:trap_init()

第二個要初始化的就是系統調用。

有了系統調用,我們就可以響應進程的需求。

這裏面對應的函數是 trap_init(),裏面設置了很多中斷門(Interrupt Gate),用於處理各種中斷;
其中有一個 set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32),這是系統調用的中斷門;
系統調用也是通過發送中斷的方式進行的。當然,64 位的有另外的系統調用方法

6.1.3 內存管理系統:mm_init()、sched_init()

mm_init() 用來初始化內存管理模塊。

進程需要進程管理進行調度,需要執行一定的調度策略;
sched_init() 就是用於初始化調度模塊。

vfs_caches_init() 會用來初始化基於內存的文件系統 rootfs。在這個函數裏面,會調用 mnt_init()-init_rootfs();
這裏面有一行代碼:register_filesystem(&rootfs_fs_type);
在 VFS 虛擬文件系統裏面註冊了一種類型,定義爲 struct file_system_type rootfs_fs_type;
文件系統是我們的進程資料庫,爲了兼容各種各樣的文件系統,我們需要將文件的相關數據結構和操作抽象出來,形成一個抽象層對上提供統一的接口,這個抽象層就是 VFS(Virtual FileSystem),虛擬文件系統。

6.1.4 其他初始化:rest_init()

最後,start_kernel() 調用的是 rest_init(),用來做其他方面的初始化,這裏面做了好多的工作。

6.1.4.1 初始化1號進程

rest_init 的第一大工作是,用 kernel_thread(kernel_init, NULL, CLONE_FS) 創建第二個進程,這個是1 號進程。

1 號進程對於操作系統來講,有“劃時代”的意義。因爲它將運行一個用戶進程;
這意味着這個操作系統可以把程序交付他人完成;
比喻:這個 1 號進程就相當於老闆帶了一個大徒弟,有了第一個,就有第二個;後面大徒弟開枝散葉,也能帶很多徒弟,形成一棵進程樹。

一旦有了用戶進程,公司的運行模式就要發生一定的變化;
因爲原來只有操作系統,所有東西都是私有的,無論多麼關鍵的資源,第一,不會有人給你搶,第二,不會有人惡意破壞、惡意使用。
但是現在有了其他進程,就要開始做一定的區分,哪些是核心資源,哪些是非核心資源;
內存也要分開,哪些是普通的進程能夠訪問的,哪些是能夠訪問核心資源的

x86 提供了分層的權限機制,把區域分成了四個 Ring,越往裏權限越高,越往外權限越低:
在這裏插入圖片描述
操作系統很好地利用了這個機制:
將能夠訪問關鍵資源的代碼放在 Ring0,我們稱爲內核態(Kernel Mode);
將普通的程序代碼放在 Ring3,我們稱爲用戶態(User Mode)。

現在系統已經處於保護模式,保護模式除了可訪問空間大一些,還有另一個重要功能,就是“保護”;
也就是說,當處於用戶態的代碼想要執行更高權限的指令,這種行爲是被禁止的,要防止他們爲所欲爲。

那如果用戶態的代碼想要訪問核心資源,怎麼辦呢?系統調用是統一的入口,用戶態代碼在這裏請求就行;
系統調用後面就是內核態,用戶態代碼不用管後面發生了什麼,做完了返回結果就可以了。

當一個用戶態的程序運行到一半,要訪問一個核心資源,例如訪問網卡發一個網絡包,就需要暫停當前的運行,調用系統調用,接下來就輪到內核中的代碼運行了;
首先,內核將從系統調用傳過來的包,在網卡上排隊,輪到的時候就發送。發送完了,系統調用就結束了,返回用戶態,讓暫停運行的程序接着運行。

這個暫停怎麼實現呢?其實就是把程序運行到一半的情況保存下來。
例如,我們知道,內存是用來保存程序運行時候的中間結果的,現在要暫時停下來,這些中間結果不能丟,因爲再次運行的時候,還要基於這些中間結果接着來;
另外就是,當前運行到代碼的哪一行了,當前的棧在哪裏,這些都是在寄存器裏面的。
所以,暫停的那一刻,要把當時 CPU 的寄存器的值全部暫存到一個地方,這個地方可以放在進程管理系統很容易獲取的地方;
當系統調用完畢,返回的時候,再從這個地方將寄存器的值恢復回去,就能接着運行了:
在這裏插入圖片描述
這個過程就是這樣的:用戶態 - 系統調用 - 保存寄存器 - 內核態執行系統調用 - 恢復寄存器 - 返回用戶態,然後接着運行:
在這裏插入圖片描述

  • 從內核態到用戶態

再回到 1 號進程啓動的過程。

當前執行 kernel_thread 這個函數的時候,我們還在內核態。

那麼如何跨越這道屏障,到用戶態去運行一個程序呢?很少聽說“先內核態再用戶態”的,如何實現?

kernel_thread 的參數是一個函數 kernel_init,也就是這個進程會運行這個函數;
在 kernel_init裏面,會調用 kernel_init_freeable();
從 kernel_init可以看到,1 號進程運行的是一個文件,調用的是do_execve(它的作用是運行一個執行文件。加一個 do_ 的往往是內核系統調用的實現,即do_execve就是一個系統調用),它會嘗試運行 ramdisk 的“/init”,或者普通文件系統上的“/sbin/init”“/etc/init”“/bin/init”“/bin/sh”。不同版本的 Linux 會選擇不同的文件啓動,但是隻要有一個起來了就可以。

如何利用執行 init 文件的機會,從內核態回到用戶態呢?

從系統調用的過程可以得到啓發:“用戶態 - 系統調用 - 保存寄存器 - 內核態執行系統調用 -恢復寄存器 - 返回用戶態”,然後接着運行。

而剛纔運行 init 文件,會調用 do_execve,所以會從內核態執行系統調用開始。

然後會加載這個程序的二進制文件,它是有一定格式的。Linux 下一個常用的格式是ELF(Executable and Linkable Format,可執行與可鏈接格式);
加載過程中,保存用戶態寄存器信息:將用戶態的代碼段 CS 設置爲 __ USER_CS、用戶態的數據段 DS 設置爲 __USER_DS,以及指令指針寄存器 IP、棧指針寄存器SP。

最終從系統調用中返回:CS和指令指針寄存器 IP 恢復了,指向用戶態下一個要執行的語句;
DS 和函數棧指針 SP 也被恢復了,指向用戶態函數棧的棧頂。

所以,下一條指令,就從用戶態開始運行了。

  • ramdisk

系統調用時,init從內核到用戶態了;
一開始到用戶態的是 ramdisk 的 init,後來會啓動真正根文件系統上的 init,成爲所有用戶態進程的祖先。

ramdisk是一個基於內存的文件系統

出現的原因:

因爲 init 程序是在文件系統上的,文件系統一定是在一個存儲設備上的,例如硬盤;
Linux 訪問存儲設備,要有驅動才能訪問;
如果存儲系統數目很有限,那驅動可以直接放到內核裏面,因爲前面我們加載過內核到內存裏了(初始化子系統時),現在可以直接對存儲系統進行訪問。

但是存儲系統越來越多了,如果所有市面上的存儲系統的驅動都默認放進內核,內核就太大了。這該怎麼辦呢?
可以先弄一個基於內存的文件系統;
內存訪問是不需要驅動的,這個基於內存的文件系統就是 ramdisk。

這個時候,ramdisk 是根文件系統。

然後,我們開始運行 ramdisk 上的 /init,等它運行完了就已經在用戶態了;
/init 這個程序會先根據存儲系統的類型加載驅動,有了驅動就可以設置真正的根文件系統了;
有了真正的根文件系統,ramdisk 上的 /init 會啓動文件系統上的 init。

接下來就是各種系統的初始化。啓動系統的服務,啓動控制檯,用戶就可以登錄進來了。

此時rest_init 的第一個大事情完成,形成了用戶態所有進程的祖先

6.1.4.2 創建2號進程

用戶態的所有進程都有祖宗進程了,那內核態的進程有沒有一個人統一管起來呢?
有的,rest_init第二大事情就是創建第三個進程,就是 2 號進程。

kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES) :這裏又一次使用 kernel_thread 函數創建
進程。

這裏需要指出一點,函數名 thread 可以翻譯成“線程”,這也是操作系統很重要的一個概念。

它和進程有什麼區別呢?爲什麼這裏創建的是進程,函數名卻是線程呢?
從用戶態來看,創建進程其實就是立項,也就是啓動一個項目。這個項目包含很多資源,例如內存空間、磁盤文件等;
這些東西都屬於這個進程,但是這個進程需要人去執行;
有多個人並行執行不同的部分,這就叫多線程(Multithreading);
如果只有一個人,那它就是這個項目的主線程。

但是從內核態來看,無論是進程,還是線程,我們都可以統稱爲任務(Task),都使用相同的數據結構,平放在同一個鏈表中。

這裏的函數 kthreadd,負責所有內核態的線程的調度和管理,是內核態所有線程運行的祖先

這下用戶態和內核態都有人管了,可以開始運行程序了。

6.2 總結

內核的初始化過程,主要做了以下幾件事情:

1.各個子系統的創建

2.用戶態祖先進程的創建

3.內核態祖先進程的創建
在這裏插入圖片描述

7 系統調用

內核初始化完成後,系統進入了用戶態,可以開始運行程序了。

本節解析系統調用子系統的實現原理,因爲後面介紹的每一個模塊,都涉及系統調用。站在系統調用的角度,層層深入下去,就能從某個系統調用的場景出發,瞭解內核中各個模塊的實現機制。

如果覺得系統調用還是不夠方便,Linux 還提供了 glibc 這個中介;
它更熟悉系統調用的細節,並且可以封裝成更加友好的接口,可以直接使用。

7.1 glibc 對系統調用的封裝

以最常用的系統調用 open,打開一個文件爲線索來學習系統調用,看看從 glibc 如何調用到內核的 open。

在 glibc 的源代碼中,有個文件 syscalls.list,裏面列着所有 glibc 的函數對應的系統調用

另外,glibc 還有一個腳本 make-syscall.sh,可以根據 syscalls.list,對於每一個封裝好的系統調用,生成一個文件;
這個文件裏面定義了一些宏,例如 #define SYSCALL_NAME open;

glibc 還有一個文件 syscall-template.S,使用 上面的#define SYSCALL_NAME open 宏,定義了這個系統調用的調用方式

syscall-template.S裏的PSEUDO 也是一個宏;
裏面對於任何一個系統調用,會調用 DO_CALL。

DO_CALL這也是一個宏,這個宏 32 位和 64 位的定義是不一樣的。

7.2 32位系統的調用過程

  • 概述

32位的情況可看i386 目錄下的 sysdep.h 文件:將請求參數放在寄存器裏面,根據系統調用的名稱,得到系統調用號,放在寄存器eax 裏面,然後執行 ENTER_KERNEL。

ENTER_KERNEL:觸發一個軟中斷,通過它就可以陷入(trap)內核。

在內核啓動的時候初始化系統調用時,使用的是trap_init(),其中有一個方法是軟中斷的陷入門:

set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32);

當接收到一個系統調用的時候,entry_INT80_32 就被調用了;
entry_INT80_32會通過 push 和 SAVE_ALL 將當前用戶態的寄存器,保存在 pt_regs 結構裏面,即進入內核之前,保存所有的寄存器;
然後調用 do_syscall_32_irqs_on
將系統調用號從 eax 裏面取出來,然後根據系統調用號,在系統調用表中找到相應的函數進行調用,並將寄存器中保存的參數取出來,作爲函數參數。

當系統調用結束之後,即在 entry_INT80_32 之後,緊接着調用的是 INTERRUPT_RETURN;
它的定義,也就是 iret;
iret 指令將原來用戶態保存的現場恢復回來,包含代碼段、指令指針寄存器等。這時候用戶態進程恢復執行。

  • 總結
    在這裏插入圖片描述

7.3 64 位系統調用過程

  • 概述

64位的情況可看x86_64 下的 sysdep.h 文件:和之前一樣,還是將系統調用名稱轉換爲系統調用號,放到寄存器 rax;
這裏是真正進行調用,不是用中斷了,即改用syscall指令;
而且傳遞參數的寄存器也變了。

syscall 指令還使用了一種特殊的寄存器,我們叫特殊模塊寄存器(Model Specific Registers,簡稱 MSR);
這種寄存器是 CPU 爲了完成某些特殊控制功能爲目的的寄存器,其中就有系統調用

在系統初始化的時候,trap_init 除了初始化上面的中斷模式,這裏面還會調用 cpu_init->syscall_init;
cpu_init->syscall_init裏有這樣的代碼:

wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);

rdmsr 和 wrmsr 是用來讀寫特殊模塊寄存器的,MSR_LSTAR 就是一個特殊模塊寄存器;
當syscall 指令調用的時候,會從這個寄存器裏面拿出函數地址來調用,也就是調用entry_SYSCALL_64。

entry_SYSCALL_64先保存了很多寄存器到 pt_regs 結構裏面,例如用戶態的代碼段、數據段、保存參數的寄存
器,然後調用 entry_SYSCALL64_slow_pat->do_syscall_64。

在 do_syscall_64 裏面,從 rax 裏面拿出系統調用號,然後根據系統調用號,在系統調用表sys_call_table 中找到相應的函數進行調用,並將寄存器中保存的參數取出來,

所以,無論是 32 位,還是 64 位,都會到系統調用表 sys_call_table 這裏來。

  • 總結+與32對比(左64,右32)
    在這裏插入圖片描述

7.4 系統調用表

在研究系統調用表之前,我們看 64 位的系統調用返回的時候,執行的是 USERGS_SYSRET64。定
義如下:

#define USERGS_SYSRET64 \
	swapgs; \
	sysretq;

這裏,返回用戶態的指令變成了 sysretq。

接下來開始分析

不管是32位還是64位方式的系統調用,最終都是到了系統調用表;
但是到底調用內核的什麼函數呢??系統調用表 sys_call_table 是怎麼形成的呢?

32 位的系統調用表定義在 arch/x86/entry/syscalls/syscall_32.tbl 文件裏;(問題2)
64 位的系統調用表定義在arch/x86/entry/syscalls/syscall_64.tbl 裏:
第一列的數字是系統調用號,32 位和 64 位的系統調用號是不一樣的;
第三列是系統調用的名字;
第四列是系統調用在內核的實現函數,都是以 sys_ 開頭。

系統調用在內核中的實現函數要有一個聲明;
聲明往往在 include/linux/syscalls.h 文件中:32位爲syscalls_32.h,64位爲syscalls_64.h

真正的實現這個系統調用,一般在一個.c 文件裏面,例如 sys_open 的實現在 fs/open.c 裏面

此時,聲明和實現都完成了。

接下來,在編譯的過程中,需要根據 syscall_32.tbl 和 syscall_64.tbl 生成自己的 unistd_32.h 和 unistd_64.h;
生成方式在 arch/x86/entry/syscalls/Makefile 中;
這裏面會使用兩個腳本:
第一個腳本 arch/x86/entry/syscalls/syscallhdr.sh,會在文件中生成 #define __NR_open
第二個腳本 arch/x86/entry/syscalls/syscalltbl.sh,會在文件中生成__SYSCALL(__NR_open, sys_open)
這樣,unistd_32.h 和 unistd_64.h 就是對應的系統調用號和系統調用實現函數之間的對應關係(答問題1)

在文件 arch/x86/entry/syscall_32.c,定義了這樣一個表:裏面 include 了頭文件syscalls_32.h ,從而所有的32位 sys_ 系統調用都在這個表裏面了;
同理,在文件 arch/x86/entry/syscall_64.c,定義了這樣一個表,裏面 include 了頭文件syscalls_64.h,這樣所有的64位 sys_ 系統調用就都在這個表裏面了。

7.5 總結

重點分析 64 位的系統調用:
在這裏插入圖片描述
本質就是使用寄存器保存數據,調用到內核的函數,最後還是通過寄存器返回原先的位置,帶上調用的結果

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章