深入系統底層--之--教你用0101寫程序

 

準備你的行囊----建立環境

爲了讓大家更爲輕鬆,除非迫不得已,我們儘量使用系統上已經安裝的工具,在這一章裏,下面兩個外部工具是必須的

  • nasm: 作爲彙編環境,官方網站http://www.nasm.us/
  • UltraEdit:作爲16進制文本編輯器

同時,讀者應該稍微具備的彙編知識,不用太多,知道下面這些指令的意義和用法即可

MOV 數據傳送指令
ADD 加法指令
PUSH,POP 堆棧指令
CMP 比較指令
LEA 取地址指令
XOR 異或指令
所有的轉移指令:JMP,JZ,JE

 

如果你還想進一步瞭解機器碼的規範,可以下載 http://download.csdn.net/source/1103630,裏面有Intel的文檔,以及本文用到的操作碼查詢表

用0和1寫程序

曾經有人發給我一張圖片,說世界上"最牛程序員"的鍵盤,鍵盤上一共兩個鍵,01,當時年少無知,崇拜到抓狂,今天就讓我們當回"頂尖高手",用01直接寫程序

請打開一個十六進制編輯器比如UltraEdit

把下面的二進制代碼化爲16進制輸入進去(主要無法直接輸入二進制代碼)

1011 1000 0000 0001 0000 0000 0000 0101 0000 0001 0000 0000

十六進制爲B8 01 00 05 01 00

將文件保存爲test.com文件,恭喜你,你剛剛完成了一個偉大"壯舉",你成功的讓CPU計算出了1+1等於幾,如果你興匆匆的運行它,什麼結果都看不到,那是因爲爲了保證代碼簡單,還沒有告訴CPU輸出結果的緣故,你願意的話,可以運行cmd,切換到保存test.com的目錄,通過執行debug test.com,來看看我們到底輸入了什麼

image

1011 1000 代表 MOV ax
0000 0001 0000 0000 代表1
0000 0101 代表 ADD ax
0000 0001 0000 0000 代表 0001h

全文加起來表示
MOV ax,01h
ADD ax,01h

可以看出,我們的代碼對應了兩條機器指令,每個指令分成兩個部分,比如MOV ax,1的二進制代碼,1011 1000 代表 MOV ax他指定了本條指令的操作,叫做指令操作碼(Opcode),0000 0001 0000 0000 代表1,指定了操作的操作數,可以看出機器碼是有自己固定的格式,只要掌握了這個格式,查詢對應的操作碼,應該就可以掌握機器語言了

當然,事情也有複雜的一面,同一條彙編指令其操作碼可能根據尋址方式或寄存器或操作數的位數的變化發生變化,比如同樣是MOV指令,MOV al,1 和MOV ax,1中Mov的操作碼分別爲B0(1011 0000)和B8(1011 1000),而MOV ax,[ES:100]操作碼會變成26 A1(前面26是段超越前綴,現在不用仔細追究),Intel8086規定的MOV指令就有12種之多,而且操作碼的長度還有可能不同,這些操作碼都可以在表<>中對應的查到,不需要記憶,下面我們就來了解機器語言指令的格式

自己設計機器語言指令格式

在閱讀Intel公司的實現前,爲了不讓您陷入一堆的解釋和說明中迷惘無助,我們先來熱熱身,做點有趣的事情---思考一下如果讓你自己來設計機器語言指令的格式,那麼你會做出怎樣的設計,下面是我的設計思路

首先彙編代碼和機器代碼是對應的,所以讓我們來看看一條典型x86彙編指令:

MOV ax,1

這條指令由三個部分組成:指令,目的操作數,源操作數

指令爲Mov,目的操作數ax,源操作數1,

ADD bx,2

指令爲Add,目的操作數bx,源操作數2

相對應的我們可以考慮把機器指令格式也分成三個部分:指令碼,目的操作數,原操作數

由於寄存器的數目是有限的,我們可以列個寄存器機器碼指令表,這樣代碼中的寄存器就可以被替換爲如下的機器代碼,比如

000   AX 001  CX 010  DX  011  BX  100  SP  101  BP  110  SI  111  DI

 

然後我們再列一個指令碼錶,比如

MOV=00000000
ADD=00000001
AND=00000010
.
.
.

則MOV ax,1就可以變成 00000000 00000000 00000001(ax是000)

但是這樣簡單清晰的三個部分會出現一些問題mov bx,0,和mov bx,ax就有可能混淆了,因爲ax的代碼是000,和立即數0相同

所以我們需要一個標誌位來確定是那種操作數,操作數有下面5種可能

目的操作數和原操作數的大小就比較難了,因爲操作數可能是

1)一個立即數 比如1

2)一個寄存器 ax,bx,cx,dx

3)一個內存地址 [StringLable]

4)一個由一個或多個寄存器組成的內存地址

[ebx],[ebx+esi],[es:ebx+esi]

5)一個由一個或多個寄存器再加上一個偏移量組成的內存地址

[ebx+esi]

顯然我們需要兩個標誌字段,每個5個值,(每個操作數一個)來標定自己是哪種操作數,每個標誌字段只要3位就夠了,我把這兩個標誌字段放到一個字節裏,放在兩個操作數前面

格式一:

  指令碼 保留2位|標誌1|標誌2| 操作數1 操作數2
Mov ax,1 00000000 00|001|000 00000000 00000001

 

標誌的意義

000:立即數
001:寄存器
010:內存地址
011:多個寄存器
100:多個寄存器加偏移量

問題又出來了,當標誌位爲100,這時,操作數應該是多個寄存器+偏移量,假設每個寄存器佔3位,兩個就是6位,留給我們的偏移量的空間只有兩位,也就是說偏移量最大隻有3,這顯然是不夠的,所以我們必須加上一個字節表示偏移量,而當不需要偏移量的時候,這兩個字段可以不存在,也就是說表格變成了

格式二:

  指令碼 00|標誌1|標誌2 操作數1
偏移量 00|操作數2
bbb|iii
偏移量
Mov ax,[bp+si+5] 00000000 00|001|100 00000000   00|101|110 00000110

怎麼樣,有點像樣子了吧,固定長度8位的指令碼可能有256種指令,我想最基本的操作,AND,OR,XOR,ADD,SHR,SHL等等不會太多,而其他的操作都可以由這些操作組合而成,比如減法是補碼的加法,乘法是重複相加等

似乎大部分問題都已經解決了,但是稍微熟悉x86彙編的朋友就會知道,不可能有任何指令的兩個操作數都是內存,也就是永遠不會出現
MOV [dx+di],[ex+si]這樣的語句,要想實現這樣的移動我們必須要把源操作數移動到一個寄存器裏,然後再從寄存器裏移動到目的地

反應在我們的設計上,我們就會發現兩個偏移量是多餘的,任何情況下最多會有一個被使用到,所以表格可以修改成這樣

格式三:

  指令碼 00|標誌1|標誌2 偏移量 操作數1
操作數2
00|bbb|iii
MOV ax,[bp+si+5] 00000000 00|001|100 00000110 00000000 00|101|110
MOV ax,bx 00000000 00|001|001 00000000 00000011

 

其實看看上表的第二條語句,我們就會發現一個很重大的問題,那就是空間浪費,第二行中所有黑體的部分都是被浪費掉的空間,浪費了12位,總共才32位的代碼,居然就浪費了12位,心疼啊,而且看看標誌字段,佔了三位,總共可以表示8個標誌,確只用了5個,我們能不能想辦法把這些空間利用起來呢?

我們重新仔細考慮第二個字節,也就是標誌字節,把最高位的兩位利用起來,稱作寄存器標誌,他的值如下表

00:操作數中沒有寄存器

01:操作數的後一個爲寄存器

10:操作數的前一個爲寄存器

11:兩個操作數都是寄存器

如果此位指明某操作數爲寄存器,則後面的標誌位直接爲寄存器值,如果爲00,則後面的操作數只可能爲 (內存,立即數) 形式,這樣MOV ax,bx的機器碼就變成了下面的樣子

格式四:

  指令碼 寄存器標誌|標誌1|標誌2 偏移量 操作數1
操作數2
00|bbb|iii
MOV ax,bx 00000000 11|000|011

 

好了,指令系統的雛形已經出來了,雖然和Intel的實現有很多不同,並且本身還有各種問題,比如依然有浪費空間的情況,功能也不太健全,不過基本體現了指令格式的特點:

  • 分成幾個字段表示不同意義
  • 儘量短小精幹
  • 不能浪費任何一位

下面讓我們來看看Intel公司的實現方法

 

讓書寫機器碼像填表一樣簡單

從上面的敘述,我們已經大概能看出點門道,每條指令分爲幾個部分,表示不同的含義.Intel規定,機器指令都可以被表示成六個部分,Prefix,Opcode,ModR/M,SIB,Displacement,Immediate,除了Opcode部分是必須的外,其他部分都有可能不存在

好像有點複雜不是?不要着急,我們稍作解釋就可以把書寫機器指令變得像填寫表格一樣簡單

下面我們把幾條命令按照六個部分進行分割,填寫到這張表裏,後面會解釋六個部分的含義

  Prefix
前綴
0-4個前綴,每個1字節
可選
Opcode
操作碼
1-2字節
一定存在
ModR/M
尋址與寄存器
1個字節
可選
SIB
內存尋址模式
一個字節
可選
Displayment
偏移量
1,2或4個字節
可選
Immeidate
立即數
1,2或4個字節
可選
      oo|rrr|mmm cc|iii|bbb    
MOV ax,1 1011 1000 0001 0000
ADD ax,1 0000 0101 0001 0000
MOV ax,[ES:0100h] 0010 0110(26h代表es的段超越前綴) 1010 0001 0000 0000
0001 0000
mov ax,[ebx+esi*2+1] 0110 0111
(67h,代表使用了32位
1000 1011 01 000 100 01 110 011 0000 0001
mov [ebx+esi*2+1],01h 67 1100 0111 01 000 100 01 110 011 0000 0001 0000 00001

 
只要會填這個表,我們就可以寫出所有的機器代碼.

可以看到,Intel的格式中並沒有明確的標出兩個操作數,而是把偏移量和立即數單獨拿了出來,而且同一條指令的操作碼會根據尋址方式的不同而變化,不像我們的設計,MOV就是MOV,所有的MOV指令都對應同樣的操作碼,Prefix部分也是我們的設計所沒有的

下面簡單的解釋下這六個部分,每個部分的具體含義和使用,後面的例子裏會逐步闡述

prefix:

指令前綴,爲了一些特殊的定義或者操作而存在,只有10個可能的值,可以在下表裏面查到,我們大致瞭解下就是了
• 鎖(Lock)和重複前綴:
鎖前綴用於多CPU環境中對共享存儲的排他訪問。重複前綴用於字符串的重複操作,他可以獲得比軟件循環方法更快的速度。
— F0H—LOCK 前綴.
— F2H—REPNE/REPNZ 前綴.
— F3H—REP 前綴
— F3H—REPE/REPZ prefix (used only with string instructions).
• Segment override:
根據指令的定義和程序的上下文,一條指令所使用的段寄存器名稱可以不出現在指令格式中,這稱爲段缺省規則。當要求一條指令不按缺省規則使用某個段寄存器時,必須以段取代前綴明確指明此段寄存器。
— 2EH—CS  段前綴
— 36H—SS 段前綴.
— 3EH—DS 段前綴.
— 26H—ES 段前綴.
— 64H—FS 段前綴.
— 65H—GS 段前綴.
• 操作大小前綴 66H 和 地址長度前綴 67H

Opcode:

操作碼,這個操作碼指定了具體的操作,他的值可以在下表查到,注意查表時候要根據操作類型,操作數類型和尋址方式來查詢,比如Mov指令有12種操作操作碼,我們需要根據操作數的類型,比如Mov bx,1,的兩個操作數一個是寄存器,一個是立即數,即Reg,Imm,查下表,應爲1011wrrr

    MemOfs,Acc     1010001w    
      Acc,MemOfs     1010000w    
      Reg,Imm     1011wrrr    
      Mem,Imm     1100011woo000mmm    
      Reg,Reg     1000101woorrrmmm    
      Reg,Mem     1000101woorrrmmm    
      Mem,Reg     1000100woorrrmmm    
      Reg16,Seg     10001100oosssmmm    
      Seg,Reg16     10001110oosssmmm    
      Mem16,Seg     10001100oosssmmm    
      Seg,Mem16     10001110oosssmmm    
      Reg32,CRn     000011110010000011sssrrr    
      CRn,Reg32     000011110010001011sssrrr    
      Reg32,DRn     000011110010000111sssrrr    
      DRn,Reg32     000011110010001111sssrrr    
      Reg32,TRn     000011110010010011sssrrr    
      TRn,Reg32     000011110010011011sssrrr

表中rrr,w,mmm,oo都可以看做幾個變量, 會根據寄存器,和尋址方式的變化而變化,如果使用4位寄存器,比如al,ah,bl,bh等,則其值爲0,否則爲1,表<>可以查到,注意所查的結果中已經包含了後面的ModR/M字節

ModR/M和SIB:

這兩個字節共同決定了尋址方式,ModR/M包含三個部分oo|rrr|mmm:這三個部分聯合表示了尋址方式,oo指示了尋址模式,rrr:指明所用寄存器,注意使用<>查詢得到的結果裏已經包含ModR/M字節,而SIB是輔助的尋址方式確定位,也包含三個部分

  • ss:放大倍數
  • iii:變址寄存器
  • bbb:基址寄存器

比如如果用到這樣的地址[ebp+5*esi],則ebp爲基址寄存器,esi爲變址寄存器,5爲放大倍數

Displayment偏移量位:尋址方式中的偏移量,如[ebp+5]中的5

Immediate:立即數,操作數中的立即數

 

一起練練手:人肉翻譯彙編代碼

一) mov bx,cx 

查詢其操作碼爲1000 100w,由於使用16位寄存器,則w=1 得到100010001即16進制的89H

ModR/M 包含三個部分oo|rrr|mmm:這三個部分聯合表示了尋址方式,這裏由於沒有內存尋址,查表得,oo=11,rrr和mmm各表示一個寄存器,那麼問題來了:哪個表示目的寄存器bx,哪個表示源寄存器cx呢?翻文檔太累了,不如用nasm彙編一下這條指令瞧瞧.得到的ModR/M字節爲對應寄存器代碼可以看出來,rrr表示的是源寄存器bx,則這一個字節爲:11 001 011,即16進制CBH

由於這條語句沒有內存尋址,SIB列爲空,也沒有偏移量列Displayment,這條語句也沒有立即數作爲操作數,所以Immediate列爲空

至於Prefix列,我們稍微看下Prefix的說明和他的值表就能知道,Prefix列只有少數的幾種情況才能出現,比如段超越啊,16位/32位切換啊,鎖定啊,像mov bx,cx這樣普通的語句自然也沒有Prefix列

所以我們可以得到mov bx,cx的最終代碼爲

  Prefix
Opcode
ModR/M
oo|rrr|mmm
SIB
ss|iii|bbb
Displayment
Immeidate
mov bx,cx   100010001 11 001 011      
mov cx,bx            
mov cl,bl            

既然已經掌握了mov bx,cx,那麼mov cx,bx呢? mov cl,bl呢?大家自己想想

如果覺得上面例子還是太簡單了,畢竟6列只用了2列,那麼我們就來挑戰一個有點難度的怎麼樣

二) mov [ebx+esi+1],dword 00h

word是nasm的關鍵字,表明存入內存的操作數是一個雙字,在內存中佔32位,即4個字節

查詢Opcode,得1100011w,w=1,即C7

現在來看ModR/M,這裏會有些變化了,我們要仔細分析我們的內存尋址方式ebx+esi+1,有一個8位的偏移量1,所以oo=01,後面的rrr和 mmm該指明用於尋址的兩個寄存器,ebp和esi,查詢rrr表,應該分別是011,110,則rrr=011,mmm=110,但是我偏偏不這樣作, 我設置rrr爲000(EAX),mmm爲100(ESP),於是代碼變爲了01000100,44h

奇怪?明明是ebx+esi,怎麼偏偏讓你給變成了eax+esp了?

其實在查詢mmm的時候,我們不應該查詢rrr表,應該查詢iii表,iii表是專門查詢變址寄存器號碼的,rrr表和iii表基本上完全相同,只是 rrr表中100代表ESP,而iii表中呢.....no index....,這不是表示沒有變址寄存器,而是表示設置兩個寄存器的工作交給後面的SIB來做,44h可以看做是個特殊的數字,這個數字就表明尋址方式所用的寄存器會讓SIB位來完成.

上面的做法不是我別出心裁,其實如果你用nasm編譯這句話,也會得到這個結果,讓SIB來設置內存尋址,我想至少有兩個好處,

一是可以更加靈活一些,畢竟人家SIB有整整一個字節專門來作這件事情,比如如果尋址模式位改爲ebx+esi*2+1,SIB裏專門有兩位ss,表示這個倍數,而ModR/M裏呢,對不起,沒地方放了

二是可以讓彙編編譯器簡單一些:統一成一種格式方便處理

ok,那麼如果我們嚴格按照寄存器查表的結果(ebx=011,esi=110)能不能運行呢,大家自己去試試吧

SIB
ss:沒有倍數,ss=00
iii:剛纔查過了esi=110
bbb:ebx=011
合起來是00110011即33

後面是8位的偏移量,01h,最後是立即數00h,注意這裏是個雙字,所以佔4個字節

填在表裏

  Prefix
Opcode
ModR/M
oo|rrr|mmm
SIB
ss|iii|bbb
Displayment
Immeidate
mov [ebx+esi+1],dword 00h 67,66 C7 44 33 01 0000
             
             

你可能用nasm彙編了一下這條語句,發現前面多了個67,66,恭喜你,67和66正是Prefix,由於你是在16位環境下彙編的,所以如果某條指令使用到32位的數據和地址,指令前面就會出現前綴,67表示使用了32位地址,66表示使用了32位數據.消除的方法是在文件頭上加上[BITS 32]

推薦一個好的機器碼入門<老羅的OPCODE教程:http://www.luocong.com/learningopcode.htm>,x86 OPCODE規範下載<>

讓人迷惑的倒置 -LittleEndian

參見上面的代碼,MOV到ax的操作數爲16位二進制的一,即0001h(h表示16進制)可是從這裏看上去,是0100h,這是爲什麼呢?

其實這是著名的Little Endian存儲格式搗的鬼,Little Endian的意思是高位在高地址,低位在低地址,比如0100 0011 0010 0001這個二進制數(十六進制爲4321h),在內存裏類似

位置 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15
1 0 0 0 0 1 0 0 1 1 0 0 0 0 1 0

 

顯示的時候,顯示程序一般都以一個字節爲整體顯示這個數,即先解析處0-7位,爲數字21h,顯示在前面,然後解析8-16位,爲數據43h,顯示在後面,則變爲了21h 43h,如果顯示程序能按照字爲整體解析並顯示,就能沒有這個倒裝了,但是顯示是不會知道你到底需要怎麼顯示的,比如你可以定義一個32位數據,也可能定義64位數據,即使是按照16位,也仍然會有倒裝發生,所以現在一般顯示程序都簡單按照字節顯示

除了LittleEndian反過來當然也有BigEndian,這種存儲格式就和咱平時的數字理解習慣沒有衝突了

LittleEndian 是Intel x86(8086/8088,80286,80x86,PentiumX)系列CPU所採用的格式,而BigEndian是Motorola的 PowerPC系列CPU所採用的標準,網絡傳輸也採用BigEndian,二者各有優缺點,有興趣的讀者可以參考1980年的著名論文<On Holy Wars and a Plea for Peace>

別看LittleEndian這個是個細節,卻絆倒了不少初學者的腿,比如你剛打開Windbg,想嘗試利用調試工具修改某個遊戲角色的體力值,從 157110修改爲100000000,157110的16進製爲265B6,而你在內存裏怎麼都找不到02 65 B6這個序列,那就是LittleEndian搞的鬼

據Jargon File記載,endian這個詞來源於Jonathan Swift在1726年寫的諷刺小說 "Gulliver's Travels"(《格利佛遊記》)。該小說在描述Gulliver暢遊小人國時碰到了如下的一個場景。在小人國裏的小人因爲非常小(身高6英寸)所以總是碰到一些意想不到的問題。有一次因爲對水煮蛋該從大的一端(Big-End)剝開還是小的一端(Little-End)剝開的爭論而引發了一場戰爭,並形成了兩支截然對立的隊伍:支持從Big- End剝開的人Swift就稱作Big-Endians而支持從Little-End剝開的人就稱作Little-Endians……(後綴ian表明的就是支持某種觀點的人:-)。Endian這個詞由此而來。
1980年,Danny Cohen在其著名的論文"On Holy Wars and a Plea for Peace"中爲了平息一場關於在消息中字節該以什麼樣的順序進行傳送的爭論而引用了該詞。該文中,Cohen非常形象貼切地把支持從一個消息序列的 MSB開始傳送的那夥人叫做Big-Endians,支持從LSB開始傳送的相對應地叫做Little-Endians。此後Endian這個詞便隨着這篇論文而被廣爲採用。

思考:指令的起止

既然每條指令都可能不一樣常,我們的CPU怎麼知道每條指令從哪裏開始,到哪裏結束?

要知道變長指令的起止,系統就必須自己知道各個指令的長度,可以說系統內部有個登記簿,登記了每個指令的長度.

程序執行的時候,系統會把eip指向的指令加載到cpu,cpu會嘗試翻譯指令,這樣系統會知道這條指令的長度,比如長度爲6,則將eip增加6,指向下一條語句.如何正確計算指令長度本身是採用CISC(複雜指令集)計算機特有的問題,因爲使用RISC(精簡指令集)的 cpu,他的指令長度是固定的,讓指令變長的優勢在於可以節省空間,也方便以後的擴展,缺點是cpu實現會比較複雜

輸出結果

也許你覺得雖然cpu已經執行了我們的工作,但是由於看不到結果,不能滿足我們小小的虛榮心,那麼下面我們就告訴系統,讓他把結果展示在屏幕上

打開剛纔建立的test.com,在剛纔的程序後面附加上下面這段

04 30 88 C2 B4 02 CD 21 E9 FD FF

程序變爲:

B8 01 00 05 01 00 04 30 88 C2 B4 02 CD 21 E9 FD FF

保存運行一下看看是不是輸出了結果

感覺好多了吧,至少看見了自己勞動的結晶,後面附加的那段機器碼是調用了Dos的int 21中斷輸出了一個字符,我們直接給出他對應的彙編代碼

mov ax,1
add ax,1
add al,'0'    ;數字到ascii的粗糙轉換
mov dl,al    ;-----|
mov ah,02h;-----|--調用中斷
int 21h      ;-----|
jmp $        ;保證程序不會立即退出,好讓我們看到結果

image

從上面的圖上我們可以清晰的看到機器碼和彙編指令的對應關係,不再贅述

add al,'0',是把結果轉化成ascii,'0'的值爲30h,2+30h=32h,是'2'這個字符的ascii值,當然這是個非常粗糙的轉換,一旦數字大過9,就會輸出奇怪的結果,這樣作是爲了機器碼儘量簡單,方便大家輸入

通過上面的二進制編碼與彙編代碼的對比,我們大概能知道彙編和機器指令是一一對應的,但是由於機器指令實在是太不方便人類記憶,寫起來也非常繁瑣,所以需要彙編語言,也就是說彙編語言實際上是機器語言的助記符號

 

總結

 

我們會算1+1了

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章