引言
我們很多人是開發者,每天寫大量的程式碼,有時也不是糟糕的程式碼。每個人都能很輕鬆寫下這樣的程式碼:
1 2 3 4 5 6 7 8 |
#include <stdio.h> int main() { int x = 10; int y = 100; printf("x + y = %d", x + y); return 0; } |
大家都能理解上面這段 C 語言程式碼完成的功能,但是…這段程式碼底層是如何工作的呢?我想我們中間不是所有人都能回答這個問題,我也不能。我認為我可以用高階程式語言寫程式碼,例如 Haskell、Erlang、Go 等等,但是我完全不知道在編譯之後它在底層是如何工作的。所以,我決定往下再深入一步,到彙編這個層次,並且記錄下我的學習彙編之路。希望這是有趣的過程,而不是僅僅對我一個人。大約五、六年前我已經使用過彙編來寫簡單的程式,那時我還在上大學,用的是 Turbo 彙編和 DOS 作業系統。現在我使用 Linux-x86_64 作業系統,是的,64 位 Linux 和 16 位 DOS 肯定有很大的不同。那我們就開始吧。
準備階段
在開始之前,我們需要準備一些我接下來要提到的東西。我使用的是 Ubuntu(Ubuntu 14.04.1 LTS 64 位) 系統,因此我的文章都是基於該作業系統和體系結構的。不同的 CPU 支援不同的指令集,我使用的是 Intel Core i7 870 處理器,所有程式碼都在這上面執行。另外我將用 nasm 彙編,你可以用下面命令來安裝:
1 |
sudo apt-get install nasm |
I它的版本應該是 2.0.0 或者更高了。我是用的是 2013年12月29日編譯的 NASM version 2.10.09 版本。最後一部分,你需要一款寫彙編程式碼的文字編輯器,我使用配有 nasm-mode.el 的 Emacs 編輯器。當然這不是強制性的,你可以選擇任何你喜歡的文字編輯器。如果你像我一樣使用的是 Emacs,你可以下載 nasm-mode.el,將你的 Emacs 配置成這樣:
1 2 3 |
(load "~/.emacs.d/lisp/nasm.el") (require 'nasm-mode) (add-to-list 'auto-mode-alist '(".(asm|s)$" . nasm-mode)) |
這就是目前我們需要準備的所有東西,其它工作在接下來的文章中會提到。
x64 語法
這裡我就不全面介紹彙編的語法了,我們僅提一下這篇文章中用到的語法。通常 NASM 程式會被劃分為不同的段(section),這篇文章中我們會涉及到兩個段:
- 資料段(data section)
- 程式碼段(text section)
資料段用來定義常量(constant),常量是在執行時不會改變的資料。你可以定義數字或其他常量等等,宣告一個資料段的語法如下:
1 |
section .data |
程式碼段是存放程式碼(code)的,該段必須以 global_start 開始,告訴核心這裡是程式開始執行的地方。
1 2 3 |
section .text global _start _start: |
註釋是以 ; 開始。每個 NASM 程式碼行包含下面四個欄位的組合:
1 |
[label:] instruction [operands] [; comment] |
中括號括起來的欄位表示是可選的。基本 NASM 指令由兩部分組成,第一部分是需要執行指令的名字,第二部分是該指令的運算元。例如:
1 |
MOV COUNT, 48 ;將數值 48 存放到 COUNT 變數中 |
Hello world
讓我們用 NASM 彙編來寫第一個程式吧,當然是傳統的列印 “Hello world” 的程式。這是程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
section .data msg db "hello, world!" section .text global _start _start: mov rax, 1 mov rdi, 1 mov rsi, msg mov rdx, 13 syscall mov rax, 60 mov rdi, 0 syscall |
是的,看起來不像 printf(“Hello world”),我們試著去理解它是什麼、怎麼工作的。先看 1-2 行,我們定義了一個資料段,並且有一個 msg 常量,值為 Hello world,那麼我們就可以在程式碼中使用這個常量了。下一步是定義了一個程式碼段,以及程式的入口,程式碼從第 7 行開始執行。現在到了程式最有意思的部分了。我們已經瞭解了 mov 指令的功能,它帶有兩個運算元,將第二個運算元的值放到第一個運算元中。但是,rax、rdi 等等這些是什麼呢?我們找到維基百科的解釋:
中央處理單元(CPU)是計算機中的硬體,它讀取計算機程式中的指令,完成系統中基本的算術、邏輯、輸入/輸出操作。
好了,CPU 完成一些操作,例如算術操作等,但是它從哪獲得操作的資料呢?第一個答案是記憶體。然而從記憶體中讀取和存入資料的速度遠遠低於處理器的速度,它涉及到複雜的通過控制匯流排來傳送資料請求的過程。因此,CPU 有其內部的儲存位置,稱為暫存器(register)。
那麼我們寫 mov rax, 1,意思是將 1 放到 rax 暫存器中。現在我們知道什麼是 rax、rdi、rbx 等等了吧,但是還需要知道什麼時候使用 rax,什麼時候使用 rsi 等等。
- rax —— 臨時暫存器,當我們呼叫系統呼叫時,rax 儲存系統呼叫號
- rdx —— 用來向函式傳遞第三個引數
- rdi —— 用來向函式傳遞第一個引數
- rsi —— 用來向函式傳遞第二個引數的指標
換句話說,我們就是呼叫了 sys_write 系統呼叫,該函式原型是:
1 |
ssize_t sys_write(unsigned int fd, const char *buf, size_t count) |
它有三個引數:
- fd —— 檔案描述符,0、1、2 分別代表標準輸入、標準輸出和標準錯誤
- buf —— 字元陣列的指標,用來儲存從 fd 指向的檔案中獲取的內容
- count —— 表示要從檔案中讀入到字元陣列的位元組數
我們知道 sys_write 系統呼叫帶有三個引數,它在系統呼叫表中有一個系統呼叫號。我們再看看程式的實現,將 1 放到 rax 暫存器中,它意思是我們使用 sys_write 系統呼叫;下一行將 1 存到 rdi 暫存器,它是 sys_write 的第一個引數,1 代表標準輸出;然後我們將 msg 的指標存到 rsi 暫存器中,這是 sys_write 的第二個引數 buf;接著我們傳遞 sys_write 最後一個引數(字串的長度)到 rdx 暫存器中。現在,我們有了 sys_write 的所有引數,就可以在 11 行使用 syscall 來呼叫它了。好了,我們列印出 “Hello world” 字串,現在需要從程式中正確退出。我們傳遞 60 到 rax 暫存器,60 是 exit 的系統呼叫號;以及將 0 傳遞給 rdi 暫存器,這是錯誤碼,0 表示我們的程式正確地退出。這就是 “Hello world” 的所有分析,相當簡單吧:)現在我們編譯程式,假設我們的程式放在 hello.asm 檔案中,那麼我們需要執行下面的命令來執行:
1 2 |
nasm -f elf64 -o hello.o hello.asm ld -o hello hello.o |
編譯連結完成之後,我們得到可執行檔案 hello,可以使用 ./hello 來執行,可以在終端看到輸出 “Hello world”。
總結
本文用一個簡單不能再簡單的程式開始第一部分,接下來我們會看到一些算術運算。如果你有任何問題或者建議可以給我評論。
譯註:所有的原始碼,都可以在作者的 GitHub 找到。