2008年6月2日 星期一

如何在 Linux 下使用 GNU AS 撰寫組合語言(1)

在 DOS 下寫組合語言有 MASM 組譯器,那麼在 Linux 下呢?我們有 GAS (GNU AS) 以及 NASM 等等。

NASM 一開始是為商用軟體為導向而開發的,但最近已經為開放原始碼釋出。NASM 支援 Windows 以及 UNIX 環境,能夠產生 UNIX , 16-bit MS-DOS , 32-bit Windows 格式的執行檔。

GAS 是由 GNU 所開發的自由軟體,在 UNIX 上是最受歡迎的跨平台組譯器。基本上組譯器都是針對特定處理器所設計的,但 GAS 特別之處在於他支援多種處理器,可產生不同種處理器的指令碼( instruction code ),通常 GAS 能夠自動的偵測硬體平台並產生相對應的指令碼。關於 GNU AS 所支援之處理器可參考 gas Manual ( Machine-Dependencies )

當然不同組譯器,語法也都不相同,NASM 和 GAS 語法是有差異的,稍後會說明。在此我們選擇 GAS 為組譯工具。

如何在 Linux 底下使用 gas 撰寫組合語言呢?首先,必須先安裝 binutils 這個套件,若你是在 Debian 或 Ubuntu 底下可透過 apt 安裝:

sudo apt-get install binutils

若沒有,可於 GNU 官方網站(http://ftp.gnu.org/gnu/binutils/)取得程式碼,解壓縮之後編譯,安裝他

./configure
make
make install


binutils 套件除了有 GNU as 之外,還有其他工具如:addr2line ar c++filt gprof nlmconv nm objcopy objdump 等等,可參考此說明(中華民國軟體自由協會),或者參考 GNU 官方文件 binutils 的說明

一支以 GAS 為組譯器之程式的基本架構如下:
.section .data
# 已初始化的資料

.section .bss
# 未初始化的資料

.section .text
.globl _start
_start:
# 程式碼由此開始
其中 .data 區塊,為放置初始化資料區塊,也就是變數已經有了初始值。.bss 則為未初始化資料區塊,此區塊為非必要。.text 則為程式碼區塊。 _start 為程式一開始的進入點,在 Linking 的時候會自動找到 _start 這個標籤為進入點,若是沒有 _start 標籤的話,Linking 時會出現以下訊息:

$ ld -o test test.o
ld: warning: cannot find entry symbol _start; defaulting to xxxxxxx


對於 GNU AS 所使用的語法在此針對幾點說明:

GAS 的原開發者選擇實做 AT&T opcode 語法作為此編譯器之語法,是因為 AT&T opcode 語法是由 AT&T Bell Labs 所發展,在當時是為那些實做 UNIX 系統的處理器而設計的。

也因此以 GNU AS 來撰寫 Intel 平台的組合語言程式是比較令人容易混淆。

有幾點差異如下:
  • 立即定址( immediate operands) 使用 $ 符號作為前綴,譬如說要使用數值 4 作為值,則寫作 $4。
  • 暫存器名稱一律使用 % 作為前綴,譬如說 EAX 則寫做 %EAX
  • 來源與目的之運算元位置與 Intel 語法不同之在於,AT&T 之來源運算元在前面,目的運算元在後。譬如將十進位數字 3 放入 EAX 暫存器,AT&T 寫作 movl $3, %eax ,Intel 寫法則寫為 mov eax, 3。
  • 為表示資料位址,AT&T 語法寫做 movl $test, %eax ,Intel 語法則為 mov eax, dword ptr test。
  • 跳躍或者呼叫使用不同的語法來定義區段(segment)以及偏移量(offset values),AT&T 使用 ljmp $section , $offset 而 Intel 使用 jmp section:offset。
如想知道的更清楚,這裡有一篇文章討論 NASM 與 GAS 之差異 (Linux assemblers: A comparison of GAS and NASM)

先寫一個最基本的小程式,此範例為一程式之最基本的架構,在此以 cpuid 為範例:
# cpuid.s Sample program to extract the processor Vendor ID

.section .data
output:
.ascii "The processor vendor id is 'xxxxxxxxxxxx''\n"
.section .text
.global _start
_start:
movl $0,%eax
cpuid

movl $output,%edi
movl %ebx, 28(%edi)
movl %edx, 32(%edi)
movl %ecx, 36(%edi)

movl $4, %eax
movl $1, %ebx
movl $output, %ecx
movl $42, %edx
int $0x80

movl $1,%eax
movl $0,%ebx
int $0x80
接下來做大略的解說,為示範整個編寫程式的流程,在此不闡述太多細節。第一段
.section .data
output:
.ascii "The processor vendor id is 'xxxxxxxxxxxx''\n"
在資料區塊裡頭宣告一個名為 output 的字串變數。
 movl $0,%eax
cpuid
movl $output,%edi
movl %ebx, 28(%edi)
movl %edx, 32(%edi)
movl %ecx, 36(%edi)
其中第一行, movl $0, %eax 將 0 移至 EAX 暫存器,執行 cpuid 時,會判斷 EAX 的內容取得相對資料。EAX 為 0 時,cpuid 是取得廠商之 ID,譬如說 Intel , Geniue 等等,cpuid 執行後,會將資料分別放入 EBX , EDX , ECX 。第 3 行,將 output 字串之起始位址存至 EDI 暫存器 ( EDI 暫存器是用來存放操作目的字串指標之暫存器 ),接著將 EBX,EDX,ECX 裡面存放之結果字串放入 EDI 暫存器指向之位址加上偏移量的位置內。
movl %ebx, 28(%edi)
此行代表將 %ebx 內四個 Byte 之內容搬移到 EDI 存放之位址 + 28 Bytes 之位址,其 28 剛好是 xxxxxxxxxxxx 之起始的位址。因此 28,32,36 這三行搬移指令剛好將 12 個 x 補滿。

現在我們有等待輸出的字串了,接下來要做的事情就是要將字串印出來。
 movl $4, %eax
movl $1, %ebx
movl $output, %ecx
movl $42, %edx
int $0x80
這五行為執行系統呼叫 ( 何謂系統呼叫?可參見此 ),其中 EAX 存放系統呼叫之值,EBX 存放要寫入之檔案敘述元 (STDOUT),ECX 存放字串起始位址,EDX 存放字串的長度。其等同於 write() 系統呼叫。關於系統呼叫,可以在以下定義的檔案找到:

/usr/include/asm/unistd.h

譬如說上頭使用的 write 可以在裡頭找到這麼一行:

#define __NR_write 4

4 就是這麼來的。(其他系統呼叫可參考 System Call Table)

最後以 0x80 之值執行軟體中斷。( Kernel System Call )
 movl $1,%eax
movl $0,%ebx
int $0x80
這三行等同於 exit(0) 系統呼叫。傳回 0 作為程式執行結果。

輸入以下命令,組譯

$ as -o cpuid.o cpuid.s

執行 Linking 的動作 (在此就不闡述何謂組譯以及連結,可參考 System Software )

$ ld -o cpuid cpuid.o

最後便可以執行了。

./cpuid

[1] gas 官方文件
[2] System Software / Beck