1. 程式人生 > >AT&T x86_32 匯編_001_一個示例程序.md

AT&T x86_32 匯編_001_一個示例程序.md

指令 本質 elf 編寫 操作系統 顯存 為什麽 ima 兩件

這一節先寫一個簡單的匯編程序. 輸出cpu的出產廠商. 不對語法, 寄存器等內容進行深入討論, 只是整體上先有個認知印象.

1. 一些基礎知識

簡單來說, Linux下的可執行程序文件中, 最重要的三個部分是: 數據段, 代碼段, bss段. 關於可執行文件, 以及目標文件的內容構成, 其實這是一個十分復雜的話題, 這裏不進行深入討論, 你可以簡單的理解為:

  1. 可執行文件由段(section)組成. 每個可執行文件中存在多個段. 段是一種劃分可執行二進制程序內容的手段
  2. 其中最重要的三個段:
    1. 數據段存儲了程序運行期間的所有數據. 典型的有: 在C代碼中定義且初始化了的全局變量, 函數內的靜態變量.
    2. 代碼段中存儲了程序運行期間的所有指令. 可以理解為你在C代碼中所寫的所有邏輯語句, 循環語句, 函數調用, 函數定義等, 都在這裏
    3. bss段比較特殊. 對於一些未初始化的全局變量和函數內的靜態變量, 這部分數據登記在bss段. 這裏之所以用登記這個說法, 是因為bss段在可執行程序的文件中, 是不占用長度的. 即這個段占用的字節數其實是0. 你可以這樣理解: 這些根據語言標準, 初始值為0的變量, 不需要將它們存儲在數據段中, 因為它們的值均是0, 所以簡單的登記一下就行了, 程序運行起來後, 凡是登記在這裏的變量, 統一給賦值為0即可.

上面的簡單理解, 用於應付學習匯編其實並不夠, 上面的理解, 其實也存在很多錯誤的地方. 如果有興趣深究的話, 建議閱讀 <程序員的自我修養: 鏈接, 裝載與庫>

這本書的的第三章: 目標文件裏面有什麽. 其中詳細講解了ELF文件的組成, 這裏就不展開篇幅講解了.

既然現在我們簡單的認為, 可執行二進制文件就由三部分構成, 那麽反過來理解, 程序的編譯鏈接其實就是把高級語言的代碼, 轉換成一個二進制文件的過程. 也就是說通過代碼, 來構造數據段, 代碼段, 與bss段的過程. 同樣的道理其實也適用於匯編語言, 不同的, 高級語言對使用者完全封裝了的概念, 但在匯編語言中, 所謂的編譯器最主要的工作, 只是把匯編代碼, 轉換為機器指令, 而關於如何分配, 定義段, 則是需要程序員自己手動負責.

所以從邏輯上來講, 我們寫的第一個程序需要做以下幾件事:

  1. 定義必要的段. 鑒於程序比較簡單, 我們應該不需要聲明類似於C語言中的未初始化的全局及靜態變量
    , 所以可以省略掉bss段, 只需要聲明且定義代碼段數據段即可
  2. 在數據段中寫上程序運行所需要的數據. 我們需要輸出cpu的制造廠商, 最起碼需要一個字符串常量, 類似於這臺電腦的CPU生產廠商其實是xxxx這種常量.
  3. 在代碼段中寫上程序運行的邏輯, 這些邏輯通過匯編語言書寫, 最終會被轉換成cpu執行指令. 這裏的邏輯包括兩部分
    1. 通過某種手段, 詢問CPU它的生產廠商是什麽, 並得到一個字符串的回答
    2. 把生產廠商(字符串, 比如因特爾三個字), 通過程序邏輯, 拼接到我們的字符串常量後面去
    3. 最終把拼接完成的字符串, 通過某種手段, 輸出到屏幕上去

具體到實際實施上, 就需要了解匯編語言的一點基礎用法, 包括:

  1. 定義段的語法: GNU匯編使用.section命令語句聲明一個段.
  2. 定義全局符號: .globl命令用心定義一個全局符號
  3. 定義程序運行的起始點: 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下的兩行, 做了兩件事:

  1. 定義了一個符號, 叫output.
  2. 通過.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會將廠商的名字, 分別放在三個寄存器中去. 分別是:

  1. ebx寄存器, 32位, 4字節, 裏面放著廠商名字的前四個字符
  2. edx寄存器, 裏面放著廠商名字的中間的四個字符
  3. 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來輸出一串字符串, 其實要經過這麽幾個封裝層級:

  1. libc層級. libc向你提供了printf函數. 程序員在這一層, 將要輸出的內容, 交給printf函數.
  2. 操作系統層級. printf函數在各個操作系統上都可用. 但在其背後, 對於不同的操作系統, printf其實調用的是操作系統中輸出內容的接口. 對於Linux系統來說, 在這一層, printf函數, 調用的是write系統調用, 即syscall
  3. 硬件驅動程序層級. 操作系統背後是千差萬別的硬件, 對於輸出字符來說, 可能是老式的CRT大屁股顯示器, 也可能是VGA接口上的投影儀, 也可能是DELL的24寸液晶顯示器, 甚至有可能是一個繪圖儀, 打印機什麽的. 顯示設備可能是彩色的, 也可能是黑白的, 對於彩色顯示器, 可能最高支持16位色, 32位色等等亂七八糟的. 但是操作系統不可能內部囊括所有顯示設備的信息. 對於操作系統來說, 要輸出一些內容並顯示出來, 操作系統只是把這部分內容扔在一個中轉站中. 可以簡單的理解這個中轉站就是所謂的顯存. 操作系統將層層傳遞下來的字符串, 扔到內存空間中一塊特定的區域, 也就是顯存中, 然後就不管了, 至於這個東西怎麽顯示, 那就是顯示設備驅動程序的責任了. 驅動程序是連接硬件與軟件的橋梁, 顯示設備的驅動程序在讀取顯存的內容後, 將內容進行翻譯, 翻譯成電壓, 電流, 扔給硬件.
  4. 最終, 硬件接受到最簡樸的電壓, 電流的變換, 控制著設備上的像素變化, 這個字符串才顯示到你面前.

以上的描述中, 有很多錯誤的地方, 對於程序員來說, 特別是上層程序員來說, 這樣的認知和理解是無傷大雅的, 因為在上面的第三層, 所謂的我稱為其為硬件驅動層級上, 其實有很多復雜的事情. 但這對於上層程序員來說, 並不是必須要了解的細節.

現在回頭來想, 當我們用C語言輸出中時, 我們位於最高的層級, 我們直接調用printf. 而當我們使用匯編語言時, 我們要輸出一個字符串, 我們位於哪一層? 我們調用的是哪一層的接口呢?

嚴格的來說, 當使用匯編語言時, 並沒有限定我們非得在某一層, 我們既可以調用libc中的printf函數: 是的, C語言中可以內聯匯編, 當然匯編代碼是可以調用C庫的. 也可以位於操作系統層級, 我們可以通過特殊的方法調用write系統調用. 我們更可以直接寫顯存(可能繞過操作系統的屏障要做一些額外的工作), 甚至於, 我們可以在硬件驅動程序中去完成這個任務: 硬件驅動程序也是由C和匯編編寫的. 總之, 用匯編要完成"輸出字符串"這項任務, 其實只要位於軟件層面上, 都可以, 無非就是每一層的實現難度不一樣而已.

而我們學習匯編的目的, 不是進行驅動開發, 也不是為了研究操作系統的實現, 而是通過學習匯編

  1. 理解代碼的本質
  2. 通過匯編語言的內聯, 來優化高級語言編寫的代碼

換句話說, 我們學習匯編, 腳下踏著的還是操作系統. 我們並不是要用匯編日天日地, 而是使用匯編, 在操作系統的肩膀上, 做高級語言很難做到的細致活. 再換個說法, 其實就是用匯編去寫應用程序, 我們應用匯編的層次, 和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

在這裏你可以這樣簡單的理解:

  1. Linux操作系統本身提供了很多系統調用.
  2. 系統調用類似於函數調用. 在系統調用之前, 需要將要調用的命令號, 以及調用所需要的參數, 填寫在各個寄存器中
  3. 類似於cpuid指令, 系統調用觸發使用的是軟中斷int $0x80. 不同的是, cpuid翻譯成cpu指令後, 是一條切實的cpu指令, 是寫給cpu看的. 而int $0x80軟中斷, 不是直接寫給cpu看的, 而是寫給Linux操作系統看的. 操作系統被軟中斷後, 會查看相應的寄存器, 以確認用戶到底想幹嘛, 然後給出回應. 在這個過程中, 當操作系統被int $0x80中斷後, 操作系統會跳轉執行內核中的一些代碼去完成用戶的請求. 在整個過程中, cpu並不知道中斷前後發生了什麽, 它只是機械的執行指令, 而這個指令是用戶的匯編代碼裏寫的, 還是受軟中斷而執行的操作系統內核代碼, cpu是不知情的.

所以上面的五行語句, 其實就做了兩件事:

  1. write系統調用所需要的所有參數, 寫在各個寄存器中
  2. 調用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. 總結

"匯編語言"本身, 是一個範疇很大的概念, 多數科班出身的程序員, 大多都讀過這樣的一本書: 匯編語言, 有很多高校甚至在本科學習階段, 將本書列為匯編語言的教學教材, 這本書講的匯編的目的是什麽呢? 其實和我們的目的是完全不同的. 這本書的教學目的在我看來, 主要是:

  1. 向學生展示匯編語言
  2. 讓學生了解寄存器, cpu, 內存等硬件, 與軟件的聯系
  3. 讓學生深刻理解中斷

這樣的教學目的, 有一個很大的盲點就是: 學完這本書之後, 你幾乎還是什麽有用的東西都做不出來! 它對於科班學生計算機思維的培養很有用, 但對於實際工作應用, 基本作用為0

而我們學習匯編的目的是什麽呢? 我們學習匯編的目的很功利:

  1. 我們希望了解一些被編譯鏈接隱藏起來的細節實現
  2. 在高級語言表面上得不到的功能, 我們希望用匯編去實現.
  3. 使用內聯匯編去優化我們的業務二進制包, 使在一些特殊應用場景下的代碼跑的更快.

典型的匯編在工程上的應用, 就是C/C++中的協程庫, 而我廠的libco更是一個標桿. 這樣的匯編才是有用的. libco庫中, 最核心的協程切換, 寥寥不到100行匯編, 就是這100行匯編, 撐起了微信後臺開發的核心. 這樣的匯編, 才是有用的.

註意, 我不是在批王爽這本書沒卵用, 並不是. 王爽的這本書寫的非常好, 十分好, 只是王爽老師寫的這本書, 不適用於我們這種"功利的目的".

所以, 我們學習匯編有以下幾個點需要註意:

  1. 我們在Linux平臺下, 以AT&T語法去學習匯編. 因為這是GNU編譯器在編譯階段生成匯編的格式. 也是binutils工具鏈的服務對象.
  2. 學習x86 32位匯編是一個過渡. 因為x86_64位匯編的學習, 基本上沒有成體系的書籍去介紹引領. 我們需要先通過學習32位匯編, 把匯編中普適性的概念, 思想, 最佳實踐學習到手, 之後再從32位匯編轉向64位匯編.
  3. 我們腳踏操作系統平臺, 不向下深挖. 確切的說, 是腳踏Linux系統調用

這樣學習匯編, 需要先行了解編譯, 鏈接的一些基礎知識, 所以, 建議大家有空看看這一本書: 程序員的自我修養, 特別是書中的第三章.

AT&T x86_32 匯編_001_一個示例程序.md