AT&T x86_32 匯編_001_一個示例程序.md
這一節先寫一個簡單的匯編程序. 輸出cpu的出產廠商. 不對語法, 寄存器等內容進行深入討論, 只是整體上先有個認知印象.
1. 一些基礎知識
簡單來說, Linux下的可執行程序文件中, 最重要的三個部分是: 數據段
, 代碼段
, bss段
. 關於可執行文件, 以及目標文件的內容構成, 其實這是一個十分復雜的話題, 這裏不進行深入討論, 你可以簡單的理解為:
- 可執行文件由
段(section)
組成. 每個可執行文件中存在多個段. 段是一種劃分可執行二進制程序內容的手段 - 其中最重要的三個段:
數據段
存儲了程序運行期間的所有數據. 典型的有: 在C代碼中定義且初始化了的全局變量, 函數內的靜態變量.代碼段
中存儲了程序運行期間的所有指令. 可以理解為你在C代碼中所寫的所有邏輯語句, 循環語句, 函數調用, 函數定義等, 都在這裏bss段
比較特殊. 對於一些未初始化的全局變量和函數內的靜態變量, 這部分數據登記在bss段. 這裏之所以用登記
這個說法, 是因為bss段
在可執行程序的文件中, 是不占用長度的. 即這個段占用的字節數其實是0
. 你可以這樣理解: 這些根據語言標準, 初始值為0的變量, 不需要將它們存儲在數據段中, 因為它們的值均是0, 所以簡單的登記一下就行了, 程序運行起來後, 凡是登記在這裏的變量, 統一給賦值為0即可.
上面的簡單理解, 用於應付學習匯編其實並不夠, 上面的理解, 其實也存在很多錯誤的地方. 如果有興趣深究的話, 建議閱讀 <程序員的自我修養: 鏈接, 裝載與庫>
目標文件裏面有什麽
. 其中詳細講解了ELF文件的組成, 這裏就不展開篇幅講解了.
既然現在我們簡單的認為, 可執行二進制文件就由三部分構成, 那麽反過來理解, 程序的編譯鏈接其實就是把高級語言的代碼, 轉換成一個二進制文件的過程. 也就是說通過代碼
, 來構造數據段
, 代碼段
, 與bss段
的過程. 同樣的道理其實也適用於匯編語言, 不同的, 高級語言對使用者完全封裝了段
的概念, 但在匯編語言中, 所謂的編譯器最主要的工作, 只是把匯編代碼, 轉換為機器指令
, 而關於如何分配, 定義段, 則是需要程序員自己手動負責.
所以從邏輯上來講, 我們寫的第一個程序需要做以下幾件事:
- 定義必要的段. 鑒於程序比較簡單, 我們應該不需要聲明類似於C語言中的
未初始化的全局及靜態變量
bss段
, 只需要聲明且定義代碼段
與數據段
即可 - 在數據段中寫上程序運行所需要的數據. 我們需要輸出cpu的制造廠商, 最起碼需要一個字符串常量, 類似於
這臺電腦的CPU生產廠商其實是xxxx
這種常量. - 在代碼段中寫上程序運行的邏輯, 這些邏輯通過匯編語言書寫, 最終會被轉換成cpu執行指令. 這裏的邏輯包括兩部分
- 通過某種手段, 詢問CPU它的生產廠商是什麽, 並得到一個字符串的回答
- 把生產廠商(字符串, 比如
因特爾
三個字), 通過程序邏輯, 拼接到我們的字符串常量後面去 - 最終把拼接完成的字符串, 通過某種手段, 輸出到屏幕上去
具體到實際實施上, 就需要了解匯編語言的一點基礎用法, 包括:
- 定義段的語法: GNU匯編使用
.section
命令語句聲明一個段. - 定義全局符號:
.globl
命令用心定義一個全局符號 - 定義程序運行的起始點: GNU匯編中, 默認以
_start
標簽所標示的代碼點, 為程序入口點
2. 一個簡單的程序
上面的講解肯定有很多你聽不明白, 理解不了的東西, 不要緊, 我們先直接來看這個程序的全文
# cpuid.s: 查看CPU廠商信息
.section .data # 數據段
output:
.ascii "The processor Vendor ID is ‘xxxxxxxxxxxx‘\n" # 單引號內是12個x, 廠商信息也共12個字節
# ---|---|---|---|---|---|---|---|---|---|--
# 0 3 7 11 15 19 23 27 31
# 所以第一個x的位置, 其實是 output[28]
.section .text # 代碼段
.globl _start # 定義程序入口點符號
_start:
movl $0, %eax # 為eax寄存器賦值為0
cpuid # 調用cpuid指令
movl $output, %edi # 將數據段中的字符串起始地址, 放在寄存器edi中
movl %ebx, 28(%edi) # edi寄存器中存儲的地址取出來, 再偏移28字節, 之後把ebx四個字節放在後面
movl %edx, 32(%edi) # 同上, 只不過偏移是32
movl %ecx, 36(%edi) # 同上, 只不過偏移是36
# 以下為調用顯示函數的代碼
# 0x80軟中斷是調用內核預置函數的方法, 具體調用哪個預置函數, 由 eax 寄存器在中斷時的值確定
movl $4, %eax # 為eax寄存器賦值為4, 表示調用的是名為 write 的內核預置函數
movl $1, %ebx # write 系統調用要求, 在ebx寄存器中存放要寫入的文件描述符. 這裏寫入1, 代表標準輸出
movl $output, %ecx # write 系統調用要求, 在ecx中存放要寫入的字符串地址, 這裏寫入 $output, 即為符號 output 的值, 即為字符串的起始地址
movl $42, %edx # write 系統調用要求, 在edx中存放字符串的長度. 這個字符串的長度為42個字符
int $0x80 # 軟中斷, 調用write
movl $1, %eax
movl $0, %ebx
int $0x80
2.1 程序中的數據段
我們通過.section .data
, 聲明了一個數據段. 這裏需要註意的是, 在匯編程序中, 段
是沒有所謂的類型的. 我們之所以把這個段叫數據段
, 並不是因為這個段的名字叫.data
, 也並不是因為它是程序中第一個段, 而是因為: 這個段中存儲了我們需要使用的字符串常量, 即程序運行中所需要的數據.
也就是說, 所謂段的類型
, 其實是程序員出於對程序結構功能的劃分, 人為制造出來的概念. 你可以聲明一個叫.text
的段, 但裏面存儲的是數據, 也可以聲明一個名為.data
的段, 裏面寫著指令. 更可以聲明一個叫.fuckyou
的段, 裏面你愛存什麽存什麽.
而我們一般情況下, 約定俗成的把數據段命名為.data
, 把代碼段命名為.text
等等, 其實是因為: 這些約定俗成的段名, 是GNU編譯器編譯高級語言, 特別是C代碼時, 對各個功能段的命名.
在.section .data
下的兩行, 做了兩件事:
- 定義了一個
符號
, 叫output
. - 通過
.ascii
聲明了一個字符串, 這個字符串有42個字符, 其中從索引28開始, 至索引40結束, 即字符串中的12個x
, 是預留的空位, 用心在取到CPU廠商名字後, 把CPU廠商的名字寫在其中.
通過單詞+冒號
的方式, 可以在匯編程序中聲明一個符號
. 這種寫法有點類似於C語言中的label, 標簽
. 你現在可以這樣簡單的理解符號
: 它就是一個變量, 或函數名!
所以, 用C的思維去看待數據段, 其實就做了一件事:
static char output[] = "The processor Vendor ID is ‘xxxxxxxxxxxx‘\n";
2.2 程序中的代碼段
我們通過.section .text
, 聲明了一個段, 名為.text
, 根據約定俗成的規則, 這是一個代碼段.
我們還通過_start:
, 聲明了一個符號, 名為_start
, 目前我們可以簡單的理解為, 這是一個函數, 名為_start
.
在_start:
以下, 都是匯編語句, 可以簡單的理解為, 這就是函數的內部實現.
一個陌生的命令, 是.globl _start
. 即是.globl
命令. 目前你可以簡單的理解為, 符號經過.globl
修飾後, 就是一個全局符號. 類比於C語言中的函數或變量, 所謂的全局符號就是: 全局變量, 以及非static函數
2.2.1 向CPU發送請求: "你是哪家工廠生產的?"
cpuid
是一個特殊的指令, 當你在匯編代碼中使用cpuid
時, 其實是在向cpu詢問: 你從哪來? 到哪去? 家裏幾頭牛? 地裏幾畝地?
寄存器eax
中的值, 決定了你問的具體是哪一個問題. 所以我們在詢問之前, 先把eax
寄存器的值設置為0
: 這其實是在問: 你是哪家廠商生產的?
將eax
設置為不同的值, 其實是在向cpu
提出不同的問題. 並且對於不同的問題, cpu回應問題的方式也不同. 但就詢問廠商這個請求來說, cpu會將廠商的名字, 分別放在三個寄存器中去. 分別是:
ebx
寄存器, 32位, 4字節, 裏面放著廠商名字的前四個字符edx
寄存器, 裏面放著廠商名字的中間的四個字符ecx
寄存器, 裏面放著廠商名字的後四個字符
即是, 在cpuid
這條指令執行時: cpu會去讀取寄存器eax
的值, 以確認你的提問到底是什麽內容. 我們在cpuid
這條指令之前, 賦值eax
寄存器的值為0
, 其實這是詢問廠商名稱的命令.
在cpuid
這條指令執行之後, cpu會將廠商的名稱, 共12個字符, 切成三塊, 分別放在三個寄存器中. 即下一步, 我們需要把三個寄存器中的內容, 挪到我們在數據段聲明的字符串中, 並把其中的12個x
給替換掉.
cpuid
指令還可以詢問很多其它內容, 有關這個指令的詳情, 請參考x86指令參考文檔 CPUID
2.2.2 把CPU的回答, 挪到字符串中去:
以下四個語句, 就是在執行這個操作
movl $output, %edi # 將數據段中的字符串起始地址, 放在寄存器edi中
movl %ebx, 28(%edi) # edi寄存器中存儲的地址取出來, 再偏移28字節, 之後把ebx四個字節放在後面
movl %edx, 32(%edi) # 同上, 只不過偏移是32
movl %ecx, 36(%edi) # 同上, 只不過偏移是36
這裏比較奇怪的是, 引入了一個名為edi
的寄存器. 這裏先感性認識一下. 至於為什麽要引入edi
, 以及這四條語句為什麽要這樣寫, 後續章節再介紹
總之, 忽略掉細節, 這四條語句執行完畢之後, 我們定義在數據段中的字符串, 內容就會變成類似於下面這樣:
static char output[] = "The processor Vendor ID is ‘因特爾‘\n"; # 實際情況下, cpu的名字是12個字節的ASCII碼英文字符, 而不是中文字符
2.2.3 將字符串輸出到屏幕上
在學習高級語言時, 我們向屏幕輸出內容, 一般都是調用語言的類庫接口, 比如C中的printf
, C++中的std::cout <<
等. 這些類似背後做了什麽工作, 高級語言的使用者是不關心的.
所以這裏, 我們需要先回想一下, 從硬件到軟件的抽象層次, 我們以"在屏幕上顯示內容"為例子, 在這個過程中, 如果你全C中的printf
來輸出一串字符串, 其實要經過這麽幾個封裝層級:
libc
層級.libc
向你提供了printf
函數. 程序員在這一層, 將要輸出的內容, 交給printf
函數.- 操作系統層級.
printf
函數在各個操作系統上都可用. 但在其背後, 對於不同的操作系統,printf
其實調用的是操作系統中輸出內容的接口. 對於Linux系統來說, 在這一層,printf
函數, 調用的是write
系統調用, 即syscall
- 硬件驅動程序層級. 操作系統背後是千差萬別的硬件, 對於輸出字符來說, 可能是老式的CRT大屁股顯示器, 也可能是VGA接口上的投影儀, 也可能是DELL的24寸液晶顯示器, 甚至有可能是一個繪圖儀, 打印機什麽的. 顯示設備可能是彩色的, 也可能是黑白的, 對於彩色顯示器, 可能最高支持16位色, 32位色等等亂七八糟的. 但是操作系統不可能內部囊括所有顯示設備的信息. 對於操作系統來說, 要輸出一些內容並顯示出來, 操作系統只是把這部分內容扔在一個中轉站中. 可以簡單的理解這個中轉站就是所謂的
顯存
. 操作系統將層層傳遞下來的字符串, 扔到內存空間中一塊特定的區域, 也就是顯存
中, 然後就不管了, 至於這個東西怎麽顯示, 那就是顯示設備驅動程序的責任了. 驅動程序是連接硬件與軟件的橋梁, 顯示設備的驅動程序在讀取顯存的內容後, 將內容進行翻譯, 翻譯成電壓, 電流, 扔給硬件. - 最終, 硬件接受到最簡樸的電壓, 電流的變換, 控制著設備上的像素變化, 這個字符串才顯示到你面前.
以上的描述中, 有很多錯誤的地方, 對於程序員來說, 特別是上層程序員來說, 這樣的認知和理解是無傷大雅的, 因為在上面的第三層, 所謂的我稱為其為硬件驅動層級
上, 其實有很多復雜的事情. 但這對於上層程序員來說, 並不是必須要了解的細節.
現在回頭來想, 當我們用C語言輸出中時, 我們位於最高的層級, 我們直接調用printf
. 而當我們使用匯編語言時, 我們要輸出一個字符串, 我們位於哪一層? 我們調用的是哪一層的接口呢?
嚴格的來說, 當使用匯編語言時, 並沒有限定我們非得在某一層, 我們既可以調用libc
中的printf
函數: 是的, C語言中可以內聯匯編, 當然匯編代碼是可以調用C庫的. 也可以位於操作系統層級, 我們可以通過特殊的方法調用write
系統調用. 我們更可以直接寫顯存(可能繞過操作系統的屏障要做一些額外的工作), 甚至於, 我們可以在硬件驅動程序中去完成這個任務: 硬件驅動程序也是由C和匯編編寫的. 總之, 用匯編要完成"輸出字符串"這項任務, 其實只要位於軟件層面上, 都可以, 無非就是每一層的實現難度不一樣而已.
而我們學習匯編的目的, 不是進行驅動開發, 也不是為了研究操作系統的實現, 而是通過學習匯編
- 理解代碼的本質
- 通過匯編語言的內聯, 來優化高級語言編寫的代碼
換句話說, 我們學習匯編, 腳下踏著的還是操作系統. 我們並不是要用匯編日天日地, 而是使用匯編, 在操作系統的肩膀上, 做高級語言很難做到的細致活. 再換個說法, 其實就是用匯編去寫應用程序, 我們應用匯編的層次, 和C語言的層次是一樣的. 所以, 回到輸出串的話題上, 最適合我們的方法是: 調用操作系統的接口, 即write
系統調用.
所以在示例程序中, 我們這樣寫:
# 以下為調用顯示函數的代碼
# 0x80軟中斷是調用內核預置函數的方法, 具體調用哪個預置函數, 由 eax 寄存器在中斷時的值確定
movl $4, %eax # 為eax寄存器賦值為4, 表示調用的是名為 write 的內核預置函數
movl $1, %ebx # write 系統調用要求, 在ebx寄存器中存放要寫入的文件描述符. 這裏寫入1, 代表標準輸出
movl $output, %ecx # write 系統調用要求, 在ecx中存放要寫入的字符串地址, 這裏寫入 $output, 即為符號 output 的值, 即為字符串的起始地址
movl $42, %edx # write 系統調用要求, 在edx中存放字符串的長度. 這個字符串的長度為42個字符
int $0x80 # 軟中斷, 調用write
在這裏你可以這樣簡單的理解:
- Linux操作系統本身提供了很多系統調用.
- 系統調用類似於函數調用. 在系統調用之前, 需要將要調用的命令號, 以及調用所需要的參數, 填寫在各個寄存器中
- 類似於
cpuid
指令, 系統調用觸發使用的是軟中斷int $0x80
. 不同的是,cpuid
翻譯成cpu指令後, 是一條切實的cpu指令, 是寫給cpu看的. 而int $0x80
軟中斷, 不是直接寫給cpu
看的, 而是寫給Linux操作系統
看的. 操作系統被軟中斷後, 會查看相應的寄存器, 以確認用戶到底想幹嘛, 然後給出回應. 在這個過程中, 當操作系統被int $0x80
中斷後, 操作系統會跳轉執行內核中的一些代碼去完成用戶的請求. 在整個過程中,cpu
並不知道中斷前後發生了什麽, 它只是機械的執行指令, 而這個指令是用戶的匯編代碼裏寫的, 還是受軟中斷而執行的操作系統內核代碼, cpu是不知情的.
所以上面的五行語句, 其實就做了兩件事:
- 把
write
系統調用所需要的所有參數, 寫在各個寄存器中 - 調用
int $0x80
, 觸發軟中斷, 將控制權交接給操作系統, 由操作系統內核代碼接管cpu. 完成內容輸出.
怎麽樣? 是不是像極了一次高級語言中的函數調用? 是的, 就是這樣, 匯編語言也是這樣. 沒有什麽復雜的.
而至於write
系統調用背後發生了哪些故事, 如何寫顯存, 顯示設備驅動程序如何工作, 顯示器如何點亮像素, 和我們就沒什麽關系了, 我們也不關心.
Linux提供了數量眾多的系統調用, 截止目前, 已經有300多個, 關於Linux系統調用的參考文檔, 可以參考這裏, 在這個頁面, 可以查詢到一個系統調用, 名為sys_write
, 即是我們上面說的write
系統調用.
2.2.4 優雅的退出程序
在C語言中, 有一個很有意思的函數, 叫exit
, 而我們寫的匯編程序, 要優雅的退出, 也需要做類似的事情. 這個系統調用在這裏, 名稱叫sys_exit
, 即是我們這個示例程序最後兩行做的事情:
movl $1, %eax
movl $0, %ebx
int $0x80
3. 編譯, 鏈接, 與運行
如下gif所示:
4. 總結
"匯編語言"本身, 是一個範疇很大的概念, 多數科班出身的程序員, 大多都讀過這樣的一本書: 匯編語言, 有很多高校甚至在本科學習階段, 將本書列為匯編語言的教學教材, 這本書講的匯編的目的是什麽呢? 其實和我們的目的是完全不同的. 這本書的教學目的在我看來, 主要是:
- 向學生展示匯編語言
- 讓學生了解寄存器, cpu, 內存等硬件, 與軟件的聯系
- 讓學生深刻理解中斷
這樣的教學目的, 有一個很大的盲點就是: 學完這本書之後, 你幾乎還是什麽有用的東西都做不出來! 它對於科班學生計算機思維的培養很有用, 但對於實際工作應用, 基本作用為0
而我們學習匯編的目的是什麽呢? 我們學習匯編的目的很功利:
- 我們希望了解一些被編譯鏈接隱藏起來的細節實現
- 在高級語言表面上得不到的功能, 我們希望用匯編去實現.
- 使用內聯匯編去優化我們的業務二進制包, 使在一些特殊應用場景下的代碼跑的更快.
典型的匯編在工程上的應用, 就是C/C++中的協程庫, 而我廠的libco更是一個標桿. 這樣的匯編才是有用的. libco
庫中, 最核心的協程切換, 寥寥不到100行匯編, 就是這100行匯編, 撐起了微信後臺開發的核心. 這樣的匯編, 才是有用的.
註意, 我不是在批王爽這本書沒卵用, 並不是. 王爽的這本書寫的非常好, 十分好, 只是王爽老師寫的這本書, 不適用於我們這種"功利的目的".
所以, 我們學習匯編有以下幾個點需要註意:
- 我們在Linux平臺下, 以AT&T語法去學習匯編. 因為這是GNU編譯器在編譯階段生成匯編的格式. 也是
binutils
工具鏈的服務對象. - 學習x86 32位匯編是一個過渡. 因為x86_64位匯編的學習, 基本上沒有成體系的書籍去介紹引領. 我們需要先通過學習32位匯編, 把匯編中普適性的概念, 思想, 最佳實踐學習到手, 之後再從32位匯編轉向64位匯編.
- 我們腳踏操作系統平臺, 不向下深挖. 確切的說, 是腳踏Linux系統調用
這樣學習匯編, 需要先行了解編譯, 鏈接的一些基礎知識, 所以, 建議大家有空看看這一本書: 程序員的自我修養, 特別是書中的第三章.
AT&T x86_32 匯編_001_一個示例程序.md