每隔一段時間就會看到有人對機器語言、組合語言感興趣,特別是剛翻了幾本書的初學者,但很多人只對書上幾句描述有點含糊的理解而已。這也未必不好,個人的能力是有限的,應該把精力放在對自己最有價值的事物上。只是有時候看到有初學者指明要學「機器語言」,不免讓人覺得好笑。
如果你真的對機器語言長什麼樣子很感興趣,而且很碰巧使用 x86-32 處理器就繼續往下看吧。以下假設讀者已經對 x86 組合語言有基本的認識。
在 C 語言當中,整數連加函數可以像這樣寫:
// Calculate lbound + .. + ubound
int Sum(int lbound, int ubound)
{
int n = ubound - lbound + 1;
return ((ubound + lbound) * n) / 2;
}
這裡假設函數呼叫方式為 cdecl,上面 C 程式翻譯成 x86-32 組合語言的其中一種翻譯法為:
push ebp
mov ebp, esp
mov edx, DWORD PTR [ebp+0x8]
mov ecx, DWORD PTR [ebp+0xc]
mov eax, ecx
sub eax, edx
inc eax
lea edx, [ecx+edx*1]
imul eax, edx
mov edx, eax
shr edx, 0x1f
lea eax, [edx+eax*1]
sar eax, 1
leave
ret
有不少人以為一種機器指令集只有一種組合語言寫法,嚴格來說是不正確的。不同廠牌的組譯器有不同的語法,像 MASM 還提供了相當高階的陳述。如果把上面的程式用 GNU 組譯器的 AT&T 語法來表示,不僅暫存器表示法不同,運算欄位的順序也相反:
pushl %ebp
movl %esp,%ebp
movl 0x8(%ebp),%edx
movl 0xc(%ebp),%ecx
movl %ecx,%eax
subl %edx,%eax
incl %eax
leal (%ecx,%edx,1),%edx
imull %edx,%eax
movl %eax,%edx
shrl $0x1f,%edx
leal (%edx,%eax,1),%eax
sarl %eax
leave
ret
接下來看機器語言吧。假如你使用 x86-32 相容的 CPU,下面這段程式碼「有可能」是可以執行的。
unsigned char code[] = {
0x55, // push ebp
0x89, 0xE5, // mov ebp, esp
0x8B, 0x55, 0x08, // mov edx, DWORD PTR [ebp+0x8]
0x8B, 0x4D, 0x0C, // mov ecx, DWORD PTR [ebp+0xc]
0x89, 0xC8, // mov eax, ecx
0x29, 0xD0, // sub eax, edx
0x40, // inc eax
0x8D, 0x14, 0x11, // lea edx, [ecx+edx*1]
0x0F, 0xAF, 0xC2, // imul eax, edx
0x89, 0xC2, // mov edx, eax
0xC1, 0xEA, 0x1F, // shr edx, 0x1f
0x8D, 0x04, 0x02, // lea eax, [edx+eax*1]
0xD1, 0xF8, // sar eax, 1
0xC9, // leave
0xC3 // ret
};
typedef int (__cdecl *Func)(int, int);
int main()
{
union U
{
unsigned char* bytes;
Func f;
} u;
u.bytes = code;
printf("1 + .. + 10 = %d \n", u.f(1, 10));
printf("1 + .. + 100 = %d \n", u.f(1, 100));
return 0;
}
可以看到機器碼基本上只是記憶體中的一串 byte,用函數指標指過去就可以執行了,請注意一下這個函數是 __cdecl。在一些比較舊的書如《The Practice of Programming》當中的範例會直接把資料指標 cast 成函數指標,據我所知有些編譯器禁止這樣的轉換,所以我採用 union 的方式。要注意並不是所有的平台都可以這樣玩,有些平台用不同的方法對待資料指標和函數指標,直接強制轉換並不安全,有時候要在底層作一些小修飾。
類似的作法有一個很主要的應用,就是可以在執行時動態產生機器碼。像 Java 之類依賴虛擬機器的語言或是 Script 語言,產生原生機器碼再執行通常會比直接解釋還快很多。不過反過來說,程式可以從任何位址開始執行也是很危險的事,有心人也有可能透過緩衝區溢位之類的漏洞把惡意的機器碼寫到自己想要的位址然後執行。這種事在資安漏洞當中一直排得上前幾名,有一版 Netscape 瀏覽器讀入過長的 HTML 屬性後會發生緩衝區溢位,於是駭客就能插入想要執行的指令。前幾年的 GDI+ 也有類似的問題,發生在 jpeg comment 過長時。
以上是題外話,回過頭來看看機器碼,首先會觀察到各個指令的機器碼看起來非常不規律,這是因為 x86 指令很多,各指令的功能也很複雜,因此這樣的編碼方式比較有效率。現在以 x86 上多才多藝的 mov 指令為例:
0x89, 0xC8, // mov eax, ecx
這是在兩個 32 bit 暫存器之間搬移資料,指令的 op code 為 10001001,所以第一個 byte 為 0x89。第一個 byte 決定了第二個 byte 的格式為 oo rrr mmm,其中 oo=11 表示 mmm 所代表的是暫存器編號。現在 rrr 和 mmm 各自代表兩個暫存器編號,參考下表:
- 000 : EAX
- 001 : ECX
- 010 : EDX
- 011 : EBX
- 101 : EBP
- 110 : ESI
- 111 : EDI
rrr=001(ecx) mmm=000(eax),所以第二個 byte 為 11 001 000,即 0xC8。
同樣的 mov 還有另一個功能,把 32 bit 資料從記憶體搬到暫存器。
0x8B, 0x55, 0x08, // mov edx, DWORD PTR [ebp+0x8]
這個功能的 op code 為 10001011,所以第一個 byte 為 0x8B。第二個 byte 同樣是 oo rrr mmm 的格式,其中 oo=01 表示在指令後面還有 8 位元的偏移量,這個偏移量會和 mmm 暫存器的內容相加得到記憶體位址,在這裡的 mmm=101(ebp),rrr=001(ecx),所以第二個 byte 值為 01 001 101,以 16 進位表示成 0x55,第三個 byte 的 0x08 即前述的偏移量。
看起來很繁瑣但其實並不複雜,上面的工作都可查表來進行,不論是組譯或反組譯。這篇文章大概對絕大多數讀者都沒有意義,不過希望可以滿足一些好奇寶寶的求知慾。
參考資料
http://developer.intel.com/products/processor/manuals/index.htm
留言列表