每隔一段時間就會看到有人對機器語言、組合語言感興趣,特別是剛翻了幾本書的初學者,但很多人只對書上幾句描述有點含糊的理解而已。這也未必不好,個人的能力是有限的,應該把精力放在對自己最有價值的事物上。只是有時候看到有初學者指明要學「機器語言」,不免讓人覺得好笑。

如果你真的對機器語言長什麼樣子很感興趣,而且很碰巧使用 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

novus 發表在 痞客邦 PIXNET 留言(0) 人氣()