0. 起因
上週末下着大雨,自己在家吃着火鍋聽着歌,聽到周杰倫的《七里香》,裏面有句歌詞:
雨下整夜,我的愛溢出就像雨水
聽到“溢出”這個詞我腦子裏就想到的棧溢出,我就將歌詞篡改成“雨下整天,我的佔溢出就像雨水”發了朋友圈,並配了下圖1。
圖中包含的是一個簡單C的Hello World
程序:
#include <stdio.h>
void main() {
char *str = "hello world.";
*str = 'A';
}
但編譯後執行遇到了段錯誤。
原本這張圖是我網上隨便檔下來的,當時也沒太在意,但卻在朋友圈引發了討論。焦點主要存在於:1)這是不是棧溢出;2)這麼簡單的代碼爲什麼會引發段錯誤。
討論是不是棧溢出感覺沒什麼意義,因爲本來就是隨便配圖。所以令人好奇的是爲什麼會出現段錯誤。通過先驗知識,我們知道段錯誤(segmentation fault)一般是因爲程序非法訪問了某一段地址。代碼中hello world
是個字符串,在Java等語言中字符串是不可變對象,因此我也猜測C中的字符串是不是也有類似性質,即字符串是隻讀的。因此我的觀點是操作系統將字符串所在的內存區域標記成只讀,現在程序進行寫操作,顯然它沒有權限,因此非法。
在我想着怎麼去驗證我的想法的時候,有前輩直接通過導出的彙編代碼看到了真相。
.file "test.c"
.section .rodata
.LC0:
.string "hello world."
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movq $.LC0, -8(%rbp)
movq -8(%rbp), %rax
movb $65, (%rax)
nop
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.12) 5.4.0 20160609"
.section .note.GNU-stack,"",@progbits
從彙編代碼中看到,gcc通過.section
這個彙編指令將hello world.
這個字符串放到一個稱之爲.rodata
的數據段。.section
後面可以跟上一些標誌位如r,w,x
等表示只讀、可寫、可執行等,如果沒有跟上標誌位彙編器則根據名字來確定這段數據的屬性,如果這個名字是彙編器無法識別的,則默認爲可寫。
上面的的C語言源代碼生成的彙編代碼中,.section
後面只跟了個.rodata
的名字並未加標誌位,但是由於這些彙編代碼本身就是gcc生成給彙編器as使用的,as肯定是可以識別這個名字的。從名字看,.rodata
表示read only data
,因此hello world
就被放入了一個只讀數據段,進而在運行的時候寫操作引發了非法訪問的段錯誤。這麼看來,雖然我們通常不會用到彙編寫程序,但是如果對彙編中的一個有所瞭解,有時候也是很有幫助的。
1. 簡介
如果描述C語言的量詞是一門,那麼描述彙編的量詞應該是一屋子。雖然都叫彙編,但爲不同架構的芯片所寫的彙編代碼可能區別非常大,因爲每個平臺的操作碼、寄存器數量等可作用不盡相同,因此同一個功能的彙編代碼之間的差異可能會很大,這就使得雖然統一稱爲彙編,但是其實彙編不是一個,而是一羣。由於上述原因,GCC中的彙編器as(assembler,彙編器)其實也是由一些列的彙編器組成的,每一個彙編器對應一種彙編格式,用戶通過命令行選項可以指定到底使用哪個彙編器去生成目標文件。雖然如此,畢竟彙編代碼不是直接機器可識別的機器碼,最終還是需要一個彙編器來翻譯,有什麼樣的編譯器就會有什麼樣的語言,因此彙編中有些部分還是可以抽離出來,形成一些通用的部分。
可以通用的主要有以下三個部分:
- 目標文件格式;
- 機器獨立的語法;
- 大部分僞操作碼(也叫彙編指令),也就是實際上不生成機器碼,知識一些指導彙編器怎麼工作的指令。如前面提到的
.section
就是一個彙編指令。
如果繼續抽象,就成了一種類似LLVM的中間表示(IR),這就距離機器碼就更遠了。
關心目標文件格式的主要是連接器,這裏我們只關注下語法和一些通用匯編指令。
2. 語法
2.1. 預處理
GCC as的預處理主要做三件事:
- 多餘空格去除:as會將每一行上多個連續空格變成一個空格或者製表符;
- 使用空格或者換行符替換所有註釋;
- 將字符常量變成數值類型。
當然,你可以使用#APP
和#NO_APP
這對標記去指定只移除文件中某部分的多餘空格和註釋,如下所示。如果#NO_APP
出現在一個文件的開頭則as 不會對註釋和空格進行處理。
some assemble code here
#APP
as will not remove the whitespace a and comments
#NO_APP
2.2. 註釋
註釋分兩種:一種是塊註釋,另一種是行註釋。
塊註釋使用/*
和/*
包起來,可跨越多行,例如/* comments here */
。
行註釋根據不同的芯片系列可能各不相同,例如在AMD 29K系列上使用分號;
表示,在SPARC上使用感嘆(!
)號表示。
opcode operand1 operand2 ; here is the comment for AMD 29K family
opcode operand1 operand2 ! here is the comment for SPARC
2.3. 符號
符號可以由A-Za-z0-9_.$
等字符組成,但是第一個字符不能是數字並且大小寫敏感。符號的長度沒有限制。除了如果在符號中使用不在前面所述的字符中的字符,則這個符號就被該字符分成了兩個符號,例如helloworld
是一個符號,而hello world
表示兩個符號。
2.4. 表達式
表達式一般以換行符(\n
)或者艾特符@
表示結尾,不同的架構可能不同,例如H8/300平臺還可以以美元符($
)結尾。如果反斜槓(\
)後面緊跟着換行符,下一行仍舊是本表達式的一部分。
2.5. 常量
常量的表示通過一些彙編指令來表示,這些指令包括.byte, .ascii, .float
等,例如:
.byte 74, 0112, 092, 0x4A, 0X4a, 'J, '\J # All the same value.
.ascii "Ring the bell\7" # A string constant.
.octa 0x123456789abcdef0123456789ABCDEF0 # A bignum.
.float 0f-314159265358979323846264338327\
95028841971.693993751E-40 # - pi, a flonum.
2.6. 字符串
字符串使用雙引號表示,並且可以包含空字符。一些特殊字符可以使用反斜槓(\
)加上一些轉義字符表示,例如:
\b
:退格鍵;\f
:換頁符;\n
:換行符;\r
:回車符;\t
:製表符;\ digit digit digit
:例如\073
,八進制數;\x hex-digits...
:例如\xabc
,十六進制數;\\
:反斜杆;\"
:雙引號。
3. 段和重定位
彙編器會把彙編代碼編譯成目標文件,每個目標文件由多個段組成。段,簡單的說,就是一段連續的地址空間,同一個段的內容會在某一方面具有相同的屬性,例如,可能每個目標文件中都有一個.rodata
段,表示它裏面的內容都是隻讀的。
GCC彙編器as所生成的目標文件至少包含三個段:text、data以及bss,其中某些段中可以沒有內容。每一段中還可以分爲一些小段,稱爲子段(subsection)。text段包含指令以及常量或者是相當於常量的數據;data段包含運行數據;bss段包含未初始化的變量或者通用存儲數據。
彙編器生成的一個個目標文件是基本不能獨立運行的,需要鏈接器來將他們鏈接成一個可執行文件。鏈接器所讀取彙編器生成的一個個目標文件是一個可執行文件的一部分,這些文件都是以地址0爲開始,因此鏈接器要將這些目標文件中的地址做更改,這樣所有目標文件的段的地址都不會重疊。
方法也很簡單,鏈接器以段文單位,爲每個段滑動分配地址。例如目標文件1中的數據段佔用了整個可執行文件地址的0~0xFF,那麼目標文件2的數據段就可以從0x100開始,兩個目標文件中數據的內容和內容順序是不會改變的,以此類推。爲段重新分配地址的過程就稱爲重定位(relocation)。
打個比方,目標文件就像是一個個火車車廂,車廂基本不可能獨自上路,需要將他們編組成列車。可能每個車廂在在編組之前的作爲都有一號、二號、三號…編組後原都是一號的作爲現在有了新的定位:一號車廂一號座、二號車廂一號座…
4. 符號和標號
所謂符號(symbol)就是程序中的一些內容的名字,例如變量名、函數名等。鏈接器需要靠這些符號去進行鏈接操作,而調試器需要靠符號去進行調試。符號有三個屬性,分別是名字(name)、類型(type)以及值(value)。符號的命名規則參看2.3節。
除了符號,彙編中還有一個概念,稱爲標號(label)。標號就是一個類似與符號的字符串,並以冒號(:
)結尾。標號表示當前的地址,並且可以用作一些指令的操作數,例如上面例子中的main:
就是一個標號。
5. 彙編指令
彙編指令又稱僞操作碼,他們不會被翻譯成機器碼,而是用於指導彙編器進行彙編工作。彙編指令總是以英文狀態的句號(.
)開頭,後面的字符一般是小寫,例如前面已經提到過的.section
,.float
等。下面提到的彙編指令都是通用的彙編指令。
我們先看看上面hello world.
導出的彙編代碼中的彙編指令,然後再看看其他的彙編指令。
5.1. Hello World中彙編指令
.file string(.app-file string)
:用於告訴彙編其接下來的代碼、指令,屬於所跟的字符串表示的邏輯文件(也就是邏輯上它們是屬於這個文件的),字符串可以用雙引號包圍,也可以沒有,但是如果是空字符串,必須用雙引號;.text subsection
:告訴彙編其將接下來的指令放入text
段中subsection
指定的子段中,如果subsection
省略,則默認使用0號段;.global symbol, .globl symbol
:彙編器將symbol暴露給鏈接器;.type int
:用於指定符號表中符號的類型;.section name
:將接下來的代碼放入由name
指定的段中;.ascii "string"...
:定義字符串數據;.size
:用於添加調試信息,並且這些個指令只在生成COFF格式的文件有效;.ident
:用於向目標文件中添加標籤;
Hello World中用到的一些彙編指令基本就以上這幾個,接下來我們看一看其他彙編指令。
5.2. 數據指令
.align abs-expr, abs-expr, abs-expr
:將當前位置的存儲區域填充到一個特定的大小,指令後面跟的三個表達式分別表示填充的字節數、填充的內容、需要跳過的最大字節數。後面兩個表達式可以省略。.asciz "string"...
:指定字符串數據,與.ascii
不同的是.asciz
會在字符串末尾自動填充一個空字符;.float flonums
:定義浮點型數據;.int expressions
:定義整型數據;.byte expressions
:定義字節數據;.hword expressions
:定義存放與兩個字節的數據;.octa bignums
:定義十六個字節的數據,octa
的意思是8個字,因爲定義一個字爲兩個字節,因此八個字是十六字節;.quad bignums
:定義八個字節的數據;.string "str"
:拷貝str
到目標文件中,也就是定義字符串;.word expressions
:定義一個字長度的數據,具體的字節數以及大小端格式視具體架構而定;.long expressions
:與.int
一樣;.def name
:爲符號name
定義調試信息,直到遇到.endef
指令爲止;.desc symbol, abs-expression
:爲符號定義描述;.double flonums
:定義雙精度浮點數;
5.3. 行爲指令
.abort
:立即結束彙編操作;.data subsection
:將接下來的指令放入指定的數據子段,如果子段沒有指定,則默認放入0號子段;.eject
:強制換頁;.if absolute expression
:條件性的彙編接下來的指令,和一般的語言中的if分支類似,可以有.else, .elseif
等指令與之匹配,並以.endif
表示條件彙編結束;.include "file"
:將其他彙編文件添加進來,可以通過命令行-I
選項指定搜索路徑;.line line-number
:修改邏輯行號;.linkonce [type]
:指導鏈接器如何操作接下來的指令;.macro [macname [macargs ...]]
定義以段宏,並以.endm
結束,也可以使用.exitm
提前推出宏,定義後的宏可以與一般操作碼一樣使用;.rept count
:重複執行接下來的代碼指定次,直到遇到.endr
指令;.set symbol, expression
:爲符號賦值;.skip size , fill
:條過size
指定數量的字節後獲取fill
指定數量的字節空間;.space size , fill
與.skip
一樣;.stabd, .stabn, .stabs
:定義調試信息。
以上是絕大部分通用匯編指令的簡介,對於特定架構相關的彙編指令以及對指令更詳細的介紹,請參閱參考文檔的內容。
首發於個人微信公衆號TensorBoy。微信掃描上方二維碼或者微信搜索TensorBoy並關注,及時獲取更多最新文章!
C++ | Python | 推理引擎 | AI框架源碼,有一起玩耍的麼?
References
[1] https://ftp.gnu.org/old-gnu/Manuals/gas-2.9.1/html_chapter/as_toc.html