作 者:道哥,10+年的嵌入式開發老兵。
公眾號:【IOT物聯網小鎮】,專注於:C/C++、Linux作業系統、應用程式設計、物聯網、微控制器和嵌入式開發等領域。 公眾號回覆【書籍】,獲取 Linux、嵌入式領域經典書籍。
轉 載:歡迎轉載文章,轉載需註明出處。
【Linux 從頭學】是什麼
這兩年多以來,我的本職工作重心一直是在 x86 Linux 系統這一塊,從驅動到中間層,再到應用層的開發。
隨著內容的不斷擴充套件,越發覺得之前很多基礎的東西都差不多忘記了,比如下面這張表(《深入理解 LINUX 核心》第 47
頁):
這張表描述了 Linux
系統中幾個段描述符資訊。
資料段和程式碼段,仔細看一下相關書籍就知道這些描述符代表什麼意思,但是:
為什麼這幾個段的 Base 地址都是 0x00000000
?
為什麼 Limit 都是 0xfffff
?
為什麼它們的 Type 型別和優先順序 DPL 又各不相同?
如果沒有對 x86
平臺的一些基礎知識的理解,要啃完這本書真的是挺費力氣的!
更要命的是,隨著 Linux
核心程式碼的體積不斷膨脹,最新的 5.13 版本壓縮檔已經是一百多兆了:
這麼一個龐然大物,如何下手才能真正的學好 Linux
呢?!
即便是從 Linux 0.11 版本開始,其中的很多程式碼看起來也是非常費勁的!
週末在整理一些吃灰的書籍時,發現幾本以前看過的好書: 王爽的《組合語言》,李忠的《從真實模式到保護模式》,馬朝暉翻譯的《組合語言程式設計》等等。
都是非常-非常-老的書籍,再次翻了一下,真心覺得內容寫得真好!
對一些概念、原理、設計思路的描述,清晰而透徹。
Linux
系統中的很多關於分段、記憶體、暫存器相關的設計,都可以在這些書籍中找到基礎支撐。
於是乎,我就有了一個想法:是否可以把這些書籍中,與 Linux
系統相關的內容進行一次重讀和整理,但絕不是簡單的知識搬運。
考慮了一下,大概有下面幾個想法:
先確定最終目標的目標:學習 Linux 作業系統;
這幾本書寫的都是組合語言,以及比較基礎的底層知識。我們會淡化組合語言部分,把重點放在與 Linux 作業系統有關聯的原理部分;
不會嚴格按照書中的內容、順序來輸出文章,而是把幾本書中內容相關的部分放在一起學習、討論;
有些內容,可以與 Linux 2.6 版本中的相關部分進行對比分析,這樣的話在以後學習 Linux 核心部分時,可以找到底層的支撐;
最後,希望我自己能堅持這個系列,也算是給自己的一個梳理吧。
一句話:以基礎知識為主!
作為開篇第一章,本文將會描述下面這張圖的執行步驟:
現在就開始吧!
古老的 Intel8086 處理器
8086
是 Intel
公司的第一款 16
位處理器,誕生於 1978
年,應該比各位小夥伴的年齡都大一些。
在 Intel
公司的所有處理器中,它佔有很重要的地位,是整個 Intel 32
位架構處理器(IA-32)的開山鼻祖。
那麼,問題來了,什麼叫 16 位的處理器?
有些人會把處理器的位數與地址匯流排的位數搞混在一起!
我們知道,CPU
在訪問記憶體的時候,是通過地址匯流排來傳送實體地址的。
8086 CPU
有 20
位的地址線,可以傳送 20
位地址。
每一根地址線都表示一個 bit
,那麼 20
個 bit
可以表示的最大值就是 2 的 20 次方。
也就是說:最大可以定位到 1M
地址的記憶體,這稱作 CPU
的定址能力。
但是,8086
處理器卻是 16
位的,因為:
運算器一次最多可以處理 16 位的資料;
暫存器的最大寬度為 16 位;
暫存器和運算器之間的通路為 16 位;
也就是說:在 8086
處理器的內部,能夠一次性處理、傳輸、暫時儲存的最大長度是 16
位,因此,我們說它是 16 位結構的 CPU。
主儲存器是什麼?
計算機的本質就是對資料的儲存和處理,那麼參與計算的資料是從哪裡來的呢?那就是一個稱作 儲存器(Storage 或 Memory)的物理器件。
從廣義上來說,只要能儲存資料的器件都可以稱作儲存器,比如:硬碟、U盤等。
但是,在計算機內部,有一種專門與 CPU
相連線,用來儲存正在執行的程式和資料的儲存器,一般稱作記憶體儲器或者主儲存器,簡稱:記憶體或主存。
記憶體按照位元組來組織,單次訪問的最小單位是 1
個位元組,這是最基本的儲存單元。
每一個儲存單元,也就是一個位元組,都對應著一個地址,如下圖所示:
CPU
就通過地址匯流排來確定:對記憶體中的哪一個儲存單元中的資料進行訪問。
第 1 個位元組的地址是 0000H,第 2 個位元組的地址是 0001H,後面以此類推。
圖中的這個記憶體,最大儲存單元的地址是 FFFF
H,換算成十進位制就是 65535
,因此這個記憶體的容量是 65536
位元組,也就是 64 KB
。
這裡有一個原子操作的問題可以考慮一下。
在 Linux
核心程式碼中,很多地方使用了原子操作,比如:互斥鎖的實現程式碼。
為什麼原子操作需要對變數的型別限制為 int
型呢?這就涉及到對記憶體的讀寫操作了。
儘管記憶體的最小組成單位是位元組,但是,經過精心的設計和安排,不同位數的 CPU
,能夠按照位元組、字、雙字進行訪問。
換句話說,僅通過單次訪問,16
位處理器就能處理 16
位的二進位制數,32
位處理器就能處理 32
位的二進位制數。
暫存器是什麼?
在 CPU
內部,一些都是代表 0 或 1 的電訊號,這些二進位制數字的一組電訊號出現在處理器內部線路上,它們是一排高低電平的組合,代表著二進位制數中的每一位。
在處理器內部,必須用一個稱為暫存器的電路把這些資料鎖存起來。
因此,暫存器本質上也屬於儲存器的一種。只不過它們位於處理器的內部,CPU
訪問暫存器比訪問記憶體的速度更快。
處理器總是很忙的,在它操作的過程中,所有資料在暫存器裡面只能是臨時存在一小會,然後再被送往別處,這就是為什麼它被叫做“暫存器”。
8086
中的暫存器都是 16
位的,可以存放 2
個位元組,或者說 1
個字。高位元組在前(bit8 ~ bit15),低位元組在後(bit0 ~ bit7)。
8086
中有下面這些暫存器:
剛才說了,這些暫存器都是 16
位的。由於需要與以前更古老的處理器相容,其中的 4
個暫存器:AX、BX、CX、DX 還可以當成 2 個 8 位的暫存器來使用。
比如:AX
代表一個 16
位的暫存器,AH、AL
分別代表一個 8
位的暫存器。
mov AX, 5D 表示把 005D 送入 AX 暫存器(16 位)
mov AL, 5D 表示把 5D 送入 AL 暫存器(8 位)
三個匯流排
當我們啟動一個應用程式的時候,這個程式的程式碼和資料都被載入到實體記憶體中。
CPU
無論是讀取指令,還是運算元據,都需要與記憶體進行資訊的互動:
確定儲存單元的地址(地址資訊);
器件的選擇,讀或寫的命令(控制資訊);
讀或寫的資料(資料資訊);
在計算機中,有專門連線 CPU
和其他晶片的資料,稱為匯流排。
從邏輯上來分類,包括下面 3
種匯流排:
地址匯流排:用來確定儲存單元的地址;
控制匯流排: CPU 對外部期間進行控制;
資料匯流排: CPU 與記憶體或其他器件之間傳送資料;
8086 有 20
根地址線,稱作地址匯流排的寬度,它可以定址 2 的 20 次方個記憶體單元。
同樣的道理,8086 資料匯流排的寬度是 16
,也就是一次性可以傳送 16 bit
的資料。
控制匯流排決定了 CPU
可以對外進行多少種控制,決定了 CPU
對外部器件的控制能力。
CPU 如何對記憶體進行定址?
在 Linux 2.6
核心程式碼中,編譯器產生的地址叫做虛擬地址(也稱作:邏輯地址),這個邏輯地址經過段轉換之後,變成線性地址,線性地址再經過分頁轉換,就得到最終實體記憶體上的實體地址。
還記得文章開頭的那張段描述符的表格嗎?
其中的程式碼段和資料段描述符的起始地址都是 0x00000000
,也就是說: 在數值上虛擬地址和轉換後的線性地址是相等的(稍後就會明白為什麼是這樣)。
我們再來看看一下 8086
中更簡單的地址轉換。
剛才說到,記憶體是一個線性的儲存器件,CPU
依賴地址來定位每一個儲存單元。
對於 8086 CPU
來說,它有 20
根地址線,可以傳送 20
位地址,達到 1MB
的定址能力。
但是 8086
又是 16
位的結構,在內部一次性處理、傳輸、暫時儲存的地址只有 16 位。
從內部結構來看,如果將地址從內部簡單的發出到地址匯流排上,只能送出 16
位的地址,這樣的話,定址能力只有 64KB
。
那麼應該怎麼才能充分利用 20
根地址線呢?
8086 CPU
採用: 在內部使用兩個 16 位地址合成的方法,來形成一個 20
位的實體地址,如下所示:
第一個 16
位的地址稱為段地址,第二個 16
位的地址稱為偏移地址。
地址加法器採用下面的這個公式,來“合成”得到一個 20
位的實體地址:
實體地址 = 段地址 x 16 + 偏移地址
例如:我們編寫的程式,在載入到記憶體中之後,放在一個記憶體空間中。
CPU 在執行這些指令的時候,把 CS
暫存器當做段暫存器,把 IP
暫存器當做偏移暫存器,然後計算 CS x 16 + IP 的值,就得到了指令的實體地址。
從以上的描述中可以看出:8086 CPU 似乎是因為暫存器無法直接輸出 20
位的實體地址,不得已才使用這樣的地址合成方式。
其實更本質的原因是:8086 CPU 就是想通過 基地址 + 偏移量 的方式來對記憶體進行定址(這裡的基地址,就是段地址左移 4 位)。
也就是說,即使 CPU
有能力直接輸出一個 20
位的地址,它仍然可能會採用 基地址 + 偏移量的方式來進行記憶體定址。
想一下:我們在 Linux
系統中編譯一個庫檔案的時候,一般都會在編譯選項中新增 -fPIC
選項,表示編譯出來的動態庫是地址無關的,在被載入到記憶體時需要被重定位。
而基地址+偏移量的定址模式,就為重定位提供了底層支撐。
我們是如何控制 CPU 的?
CPU
其實是一個很純粹、很呆板的一個東西,它唯一做的事情就是:到 CS:IP 這兩個暫存器指定的記憶體單元中取出一條指令,然後執行這條指令:
當然了,還需要預先定義一套指令集,在記憶體中的指令區中,儲存的都必須是合法的指令,否則 CPU 就不認識了。
每一條指令都是用某些特定的數(指令碼)來指示 CPU
進行特定的操作。
CPU
認識這些指令,一看到這些指令碼,CPU
就知道這個指令碼後面還有幾個位元組的運算元、需要進行什麼樣的操作。
例如:指令碼 F4
H 表示讓處理器停機,當 CPU
執行這條指令的時候,就停止工作。
(其實這裡說 CPU
已經有點不準確了,因為 CPU 是囊括了很多器件的一個整體,也許這裡說 CPU
中的執行單元會更準確些。)
另外有一點可以提前說一下:記憶體中的一切都是資料,至於把其中的哪一部分資料當做指令來執行,哪一部分資料當做被指令操作的“變數”,這完全是由作業系統的設計者來規劃的。
在 8086 處理器的層面來說,只要是 CS:IP “指向”的記憶體區域,都被當做指令來執行。
從以上描述可以看出:在 CPU
中,程式設計師能夠用指令讀寫的器件只有暫存器,我們可以通過改變暫存器中的內容,來實現對 CPU 的控制。
更直白的說就是:我們可以通過改變 CS、IP 暫存器中的內容,來控制 CPU
執行目標指令。
作為一名合格的嵌入式開發者,大家估計都配置過一些微控制器裡的暫存器,以達到一些功能定義、埠複用的目的,其實這些操作,都可以看做是我們對 CPU 的控制。
如果把 CPU 比作木偶,那麼 暫存器就是控制木偶的繩索。
我們再把 CPU
與 工控領域的 PLC
程式設計進行類比一下。
我們在拿到一個新的 PLC
裝置之後,其中只有一個執行時(runtime),這個執行時執行的本職工作就是:
掃描所有的輸入埠,鎖存在輸入映象區;
執行一個運算、控制邏輯,得到一些列輸出訊號,鎖存到輸出映象區;
把輸出映象區的訊號,重新整理到輸出埠;
在一個全新的 PLC 中,其中第 2 個步驟中需要的運算、控制邏輯可能就不存在。
因此,單單一個 runtime
,PLC
是無法完成一件有意義的工作的。
為了讓 PLC
完成一個具體的控制目標,我們還需要利用 PLC
廠家提供的上位機程式設計軟體,開發一個運算、控制邏輯程式,程式語言一般都是梯形圖居多。
當這個程式被下載到 PLC
中之後,它就可以控制執行時來做一些有意義的工作了。
我們可以簡單的認為:梯形圖就是用來控制 PLC 的執行時。
對於 CPU
來說,想讓它執行某個記憶體單元的指令,只要修改暫存器 CS
和 IP
即可。
換句話說:只要對一個程式的記憶體佈局足夠的清楚,可以把 CPU 玩弄於股掌之間,讓它執行哪裡的程式碼都可以。
CPU 執行指令流程
現在我們已經明白了地址轉換、記憶體的定址,距離 CPU
執行一條指令需要的最小單元還剩下:指令緩衝區和控制電路。
簡單來說:指令緩衝區用來快取從記憶體中讀取的指令,控制電路用來協調各種器件對匯流排等資源的使用。
對於下面這張圖來說,它一共有 4
條指令:
以第一條指令來舉例,它一共經過 5
個步驟:
把 CS:IP 內容送入地址加法器,計算得到 20 位的實體地址 20000H;
控制電路把 20 位的地址,送入到地址匯流排;
記憶體中 20000H 單元處的指令 B8 23 01,經過資料匯流排被送到指令緩衝區;
指令偏移暫存器 IP 的值要加 3,指向下一條等待被執行的偏移地址(因為指令碼 B8 代表當前指令的長度是 3 個位元組);
執行指令緩衝區中的指令: 把數值 0123H 送入暫存器 AX 中;
以上就是一條指令的執行最基本步驟,當然,現代處理器的指令執行流程,比這裡的要複雜的多得多。
萬丈高樓平地起!
這篇文章,僅僅描述了 CPU
執行一條指令所需要的最小知識點。
下一篇文章,我們再繼續對記憶體的分段機制進行更進一步的窺探。
推薦閱讀