1. 程式人生 > >C語言的本質(28)——C語言與彙編之用匯編寫一個Helloword

C語言的本質(28)——C語言與彙編之用匯編寫一個Helloword

為了更加深入理解C語言的本質,我們需要學習一些彙編相關的知識。作為最基本的程式語言之一,組合語言雖然應用的範圍不算很廣,但是非常重要。因為它能夠完成許多其它語言所無法完成的功能。就拿 Linux 核心來講,雖然絕大部分程式碼是用 C 語言編寫的,但仍然不可避免地在某些關鍵地方使用了彙編程式碼,其中主要是在 Linux 的啟動部分。由於這部分程式碼與硬體的關係非常密切,即使是 C 語言也會有些力不從心,而組合語言則能夠很好揚長避短,最大限度地發揮硬體的效能。

大多數情況下我們不需要使用匯編語言,因為即便是硬體驅動這樣的底層程式在 Linux 作業系統中也可以用完全用 C 語言來實現,再加上 GCC 這一優秀的編譯器目前已經能夠對最終生成的程式碼進行很好的優化。但實現情況是我們有時還是需要使用匯編,或者不得不使用匯編,理由很簡單:精簡、高效和 libc 無關性。假設要移植 Linux 到某一特定的嵌入式硬體環境下,首先必然面臨如何減少系統大小、提高執行效率等問題,此時或許只有組合語言能幫上忙了。

組合語言直接同計算機的底層軟體甚至硬體進行互動,它具有以下一些優點:

1、能夠直接訪問與硬體相關的儲存器或 I/O埠;

2、能夠不受編譯器的限制,對生成的二進位制程式碼進行完全的控制;

3、能夠對關鍵程式碼進行更準確的控制,避免因執行緒共同訪問或者硬體裝置共享引起的死鎖;

4、能夠根據特定的應用對程式碼做最佳的優化,提高執行速度;

5、能夠最大限度地發揮硬體的功能。

x86彙編的兩種語法:intel語法和AT&T語法。在intel的官方文件中使用intel語法,Windows也使用intel語法,而Linux/UNIX平臺的彙編器一直使用AT&T語法。如果AT&T語法的mov%edx,%eax這條指令如果用intel語法來寫,就是mov eax,edx,暫存器名不加%號,並且源運算元和目標運算元的位置互換。由於我們在Linux平臺下開發,所以使用AT&T語法。

Linux 平臺下的彙編工具雖然種類很多,但是最基本的仍然是彙編器、聯結器和偵錯程式。

彙編器(assembler)的作用是將用匯編語言編寫的源程式轉換成二進位制形式的目的碼。Linux 平臺的標準彙編器是 GAS,它是 GCC 所依賴的後臺彙編工具,它包含在 binutils 軟體包中。GAS 使用標準的 AT&T 彙編語法,可以用來彙編用 AT&T 格式編寫的程式。

另外一種經常用到的彙編器是 NASM,它提供了很好的巨集指令功能,並能夠支援相當多的目的碼格式。NASM使用的是 Intel 彙編語法,可以用來編譯用 Intel 語法格式編寫的彙編程式:

由彙編器產生的目的碼是不能直接在計算機上執行的,它必須經過連結器的處理才能生成可執行程式碼。連結器通常用來將多個目的碼連線成一個可執行程式碼,這樣可以先將整個程式分成幾個模組來單獨開發,然後才將它們組合(連結)成一個應用程式。 Linux 使用 ld 作為標準的連結程式,它同樣也包含在 binutils 軟體包中。彙編程式在成功通過 GAS 或 NASM 的編譯並生成目的碼後,就可以使用 ld 將其連結成可執行程式了。

Linux 下除錯彙編程式碼既可以用GDB、DDD 這類通用的偵錯程式,也可以使用專門用來除錯彙編程式碼的 ALD。執行 as 命令時帶上引數 --gstabs 可以告訴彙編器在生成的目的碼中加上符號表,同時需要注意的是,在用 ld 命令進行連結時不要加上 -s 引數,否則目的碼中的符號表在連結時將被刪去。

在 GDB 和 DDD 中除錯彙編程式碼和除錯 C 語言程式碼是一樣的,我們可以通過設定斷點來中斷程式的執行,檢視變數和暫存器的當前值,並可以對程式碼進行單步跟蹤。

下面我們來使用匯編語言編寫一個Hello world程式。

#hello.asm
.section .data                    # 資料段宣告
         msg: .string "Hello, world!\\n"  #要輸出的字串
         len= . - msg# 字串長度
.section .text# 程式碼段宣告
.global _start# 指定入口函式
 
_start:# 在螢幕上顯示一個字串
         movl$len, %edx# 引數三:字串長度
         movl$msg, %ecx # 引數二:要顯示的字串
         movl$1, %ebx   #引數一:檔案描述符(stdout)
         movl$4, %eax   # 系統呼叫號(sys_write)
         int  $0x80     # 呼叫核心功能                     
# 退出程式
         movl$0,%ebx    # 引數一:退出程式碼
         movl$1,%eax    # 系統呼叫號(sys_exit)
         int  $0x80     # 呼叫核心功能

 

把這個程式儲存成檔案hello.asm,然後用匯編器(Assembler)as把彙編程式中的助記符翻譯成機器指令,生成目標檔案hello.o,再用連結器(Linker,或Link Editor)ld把目標檔案hello.o連結成可執行檔案hello:

我們執行生產的hello程式:

程式中的#號表示單行註釋,類似於C語言的//註釋。

彙編程式中以.開頭的名稱並不是指令的助記符,不會被翻譯成機器指令,而是給彙編器一些特殊的指示,稱為彙編指示或偽操作,由於它不是真正的指令所以加個“偽”字。.section指示把程式碼劃分成若干個段(Section),程式被作業系統載入執行時,每個段被載入到不同的地址,具有不同的讀、寫、執行許可權。.data段儲存程式的資料,是可讀可寫的,C程式的全域性變數也屬於.data段。

.text段儲存程式碼,是隻讀和可執行的,後面那些指令都屬於這個.text段。

_start是一個符號,符號在彙編程式中代表一個地址,可以用在指令中,彙編程式經過彙編器的處理之後,所有的符號都被替換成它所代表的地址值。在C語言中我們通過變數名訪問一個變數,其實就是讀寫某個地址的記憶體單元,我們通過函式名呼叫一個函式,其實就是跳轉到該函式第一條指令所在的地址,所以變數名和函式名都是符號,本質上是代表記憶體地址的。

.globl指示告訴彙編器,_start這個符號要被連結器用到,所以要在目標檔案的符號表中給它特殊標記。_start就像C程式的main函式一樣特殊,是整個程式的入口,連結器在連結時會查詢目標檔案中的_start符號代表的地址,把它設定為整個程式的入口地址,所以每個彙編程式都要提供一個_start符號並且用.globl宣告。如果一個符號沒有用.globl指示宣告,就表示這個符號不會被連結器用到。

_start在這裡就像C語言的語句標號一樣。彙編器在處理彙編程式時會計算每個資料物件和每條指令的地址,當彙編器看到這樣一個標號時,就把它下面一條指令的地址作為_start這個符號所代表的地址。而_start這個符號又比較特殊,它所代表的地址是整個程式的入口地址,所以下一條指令movl $len, %edx就成了程式中第一條被執行的指令。 

int $0x80前四條指令都是為這條指令做準備的,執行這條指令時發生以下動作:

1、int指令稱為軟中斷指令,可以用這條指令故意產生一個異常,CPU從使用者模式切換到特權模式,然後跳轉到核心程式碼中執行異常處理程式。

2、int指令中的立即數0x80是一個引數,在異常處理程式中要根據這個引數決定如何處理,在Linux核心中,int $0x80這種異常稱為系統呼叫。核心提供了很多系統服務供使用者程式使用,但這些系統服務不能像庫函式(比如printf)那樣呼叫,因為在執行使用者程式時CPU處於使用者模式,不能直接呼叫核心函式,所以需要通過系統呼叫切換CPU模式,通過異常處理程式進入核心,使用者程式只能通過暫存器傳幾個引數,之後就要按核心設計好的程式碼路線走,而不能由使用者程式隨心所欲,想調哪個核心函式就調哪個核心函式,這樣保證了系統服務被安全地呼叫。在呼叫結束之後,CPU再切換回使用者模式,繼續執行int指令後面的指令,在使用者程式看來就像函式的呼叫和返回一樣。

3、eax、ebx、ecx和edx暫存器的值是傳遞給系統呼叫的兩個引數,eax的值是系統呼叫號,4表示sys_write系統呼叫,ebx、ecx和edx的值則是傳給sys_write系統呼叫的引數,也就是向標準輸出裝置輸出字串。大多數系統呼叫完成之後是會返回使用者程式繼續執行的,

最後一部分的程式碼:

         movl$0,%ebx     # 引數一:退出程式碼
         movl$1,%eax     # 系統呼叫號(sys_exit)
         int  $0x80      # 呼叫核心功能

 

eax和ebx暫存器的值是傳遞給系統呼叫的兩個引數,eax的值是系統呼叫號,1表示_exit系統呼叫,ebx的值則是傳給_exit系統呼叫的引數,也就是退出狀態。_exit這個系統呼叫會終止掉當前程序,而不會返回它繼續執行。