【原創】X86_64/X86 GNU彙編、暫存器、內嵌彙編
整理的X86_64/X86彙編、暫存器、C內嵌彙編筆記,主要用於查閱使用。
一、彙編語言
電腦的處理器有很多不同的架構,比如 x86-64、ARM、Power 等,每種處理器的指令集都不相同,那也就意味著彙編語言不同。目前的電腦,CPU 一般是 x86-64 架構,是 64 位機。
C語言程式碼:
#include <stdio.h>
int main(int argc, char* argv[])
{
printf("Hello %s!\n", "WSG");
return 0;
}
編譯為彙編:
gcc -S -O2 hello.c -o hello.s
或
clang -S -O2 hello.c -o hello.s
對應的彙編程式碼如下:
.file "hello.c"
.section .rodata.str1.1,"aMS",@progbits,1
.LC0:
.string "WSG"
.LC1:
.string "Hello %s!\n"
.section .text.unlikely,"ax",@progbits
.LCOLDB2:
.section .text.startup,"ax",@progbits
.LHOTB2:
.p2align 4,,15
.globl main
.type main, @function
main:
.LFB23:
.cfi_startproc
subq $8, %rsp
.cfi_def_cfa_offset 16
movl $.LC0, %edx
movl $.LC1, %esi
movl $1, %edi
xorl %eax, %eax
call __printf_chk
xorl %eax, %eax
addq $8, %rsp
.cfi_def_cfa_offset 8
ret
.cfi_endproc
.LFE23:
.size main, .-main
.section .text.unlikely
.LCOLDE2:
.section .text.startup
.LHOTE2:
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.12) 5.4.0 20160609"
.section .note.GNU-stack,"",@progbits
彙編語言的組成元素:指令、偽指令、標籤和注釋,每種元素獨佔一行
指令:
助記符 操作數(源,目的)
偽指令以”.”開頭,末尾沒有冒號”:”。偽指令是是輔助性的,彙編器在生成目標文件時會用到這些資訊,但偽指令不是真正的 CPU 指令,就是寫給彙編器的。每種彙編器的偽指令也不同,要查閱相應的手冊。常見的彙編器偽指令如下。
.file "hello.c"
.section .rodata.str1.1,"aMS",@progbits,1
標籤以冒號「:」結尾,用於對偽指令生成的數據或指令做標記。標籤很有用,它可以代表一段程式碼或者常量的地址(也就是在程式碼區或靜態數據區中的位置)。可一開始,我們沒法知道這個地址的具體值,必須生成目標文件後,才能算出來。所以,標籤會簡化彙編程式碼的編寫。
.LC1:
.string "Hello %s!\n"
注釋以「#」號開頭,與C語言中//表示注釋是一樣的。
二、指令
在程式碼中,助記符movq
,xorl
中的mov
和xor
是指令,而q
和l
叫做後綴,表示操作數的位數。後綴一共有 b, w, l, q 四種,分別代表 8 位、16 位、32 位和 64 位。
比如,movq
中的 q 代表操作數是 8 個位元組,也就是 64 位的。movq
就是把 8 位元組從一個地方拷貝到另一個地方,而 movl
則是拷貝 4 個位元組。
而在指令中使用操作數,可以使用四種格式,它們分別是:立即數、暫存器、直接記憶體訪問和間接記憶體訪問。
操作數可以表示立即數(常數)值、暫存器值或是來自記憶體的值。比例因子\(s\)必須是1、2、4或者8.
立即數以 $ 開頭, 比如 $40。(下面這行程式碼是把 40 這個數字拷貝到 %eax 暫存器)。
movl $40, %eax
除此之外,在指令中最常見到的就是對暫存器的訪問,GNU 的彙編器規定暫存器一定要以 % 開頭。
直接記憶體訪問:當我們在程式碼中看到操作數是一個數字時,它其實指的是記憶體地址。不要誤以為它是一個數字,因為數字立即數必須以 $ 開頭。另外,彙編程式碼里的標籤,也會被翻譯成直接記憶體訪問的地址。比如callq _printf
中的_printf
是一個函數入口的地址。彙編器幫我們計算出程式裝載在記憶體時,每個字面量和過程的地址。
間接記憶體訪問:帶有括弧,比如(%rbp),它是指 %rbp 暫存器的值所指向的地址。
間接記憶體訪問的完整形式是:
偏移量(基址,索引值,位元組數)這樣的格式。
其地址是:
基址 + 索引值 * 位元組數 + 偏移量
舉例來說:
8(%rbp),是比 %rbp 暫存器的值加 8。
-8(%rbp),是比 %rbp 暫存器的值減 8。
(%rbp, %eax, 4)的值,等於 %rbp + %eax*4。這個地址格式相當於訪問 C 語言中的數組中的元素,數組元素是 32 位的整數,其索引值是 %eax,而數組的起始位置是 %rbp。其中位元組數只能取 1,2,4,8 四個值。
幾個常用的指令:
數據傳輸指令
mov
mov 暫存器|記憶體|立即數, 暫存器|記憶體
這個指令最常用到,用於在暫存器或記憶體之間傳遞數據,或者把立即數載入到記憶體或暫存器。mov 指令的第一個參數是源,可以是暫存器、記憶體或立即數。第二個參數是目的地,可以是暫存器或記憶體。
lea:lea 是「load effective address」的意思,裝載有效地址,實際是mov指令的變形。其操作不影響任何條件碼
lea 源,目的
參數為標準格式中給定的記憶體位置,但並不載入記憶體位置的內容,而是載入計算得出的地址。例如:如果暫存器%rdx的值為x,那麼指令leaq 7(%rdx,%rdx,4),%eax
將設置暫存器%rax
的值為5x+7
。
cld
該指令清除了標誌暫存器中的DF位。 清除方向標誌後,所有字元串操作(如stos,scas和其他操作)都會使索引暫存器esi或edi遞增。
std
與cld相反,該指令置位了標誌暫存器中的DF位。 置位方向標誌後,所有字元串操作(如stos,scas和其他操作)都會使索引暫存器esi或edi遞減。
stosl
stosl指令將eax複製到es:di中,若設置了EFLAGS中的方向位置位(即在STOSL指令前使用STD
指令)則EDI自減4,否則(使用CLD
指令)EDI自增4;
rep
重複執行%ecx次,如rep; stosl
表示重複執行stosl
,直到cx為0,例:
cld;rep;stosl
cld設置edi或同esi為遞增方向,rep做(%ecx)次重複操作,stosl表示edi每次增加4。
棧操作指令
指令 | 描述 |
---|---|
push 源 | 把源壓入棧 |
pop 目的 | 把棧頂的元素放入目的 |
push
pushl %eax
相當於:
subl $4, %esp
mvol %eax,(%esp)
pushfl #表示將%eflage暫存器當前的數據入棧
pop
popl %eax
相當於:
movl (%esp), %eax
addl $4, %esp
運算指令
指令 | 描述 |
---|---|
sub 源, 目的 | 把目的中值減去源的值 |
imul 源, 目的 | 把目的乘上源 |
clto | 轉換為8字(%rax符號擴展 →%rdx:%rax) |
xor 源, 目的 | 做異或運算 |
or 源, 目的 | 或運算 |
and 源, 目的 | 與運算 |
inc 目的 | 加一 |
dec 目的 | 減一 |
neg 目的 | 取負值 |
not 目的 | 按位取反 |
add 指令是做加法運算,它可以採取下面的格式:
add 立即數, 暫存器
add 暫存器, 暫存器
add 記憶體, 暫存器
add 立即數, 記憶體
add 暫存器, 記憶體
比如,典型的 c=a+b 這樣一個算術運算可能是這樣的:
movl -4(%rbp), %eax #把%rbp-4的值拷貝到%eax
addl -8(%rbp), %eax #把%rbp-8地址的值加到%eax上
movl %eax, -12(%rbp) #把%eax的值寫到記憶體地址%rbp-12
and 對兩個操作數的內容進行邏輯與運算,並將結果存儲到第二個操作數,將溢出標誌位及進位標誌設置為FALSE。
not 對操作數的每一位邏輯取反,也稱為一個數的補數
or 對兩個操作數進行邏輯或,並將結果存儲到第二個操作數,將溢出標誌位設置為FLASE
adc 帶進位加法。將進位位與第一個操作數與第二個操作數相加,如果存在溢出,就將溢出及進位標誌設置為真。
cdq將%eax中的字帶符號擴展為%eax:%eax組成的雙字。q表示這是一個雙字(64位元組).這條指令通常在發出idivl指令之前。
cmp 比較兩個整數,將第二個操作數減去第一個操作數,捨棄結果,設置標誌位。
dec將暫存器或記憶體位置的數據減一。
div執行無符號除法。將%edx:%eax所含的雙字除以指定暫存器或記憶體位置的值。運算後%eax包含商,%edx包含餘數,如果商對於%eax來說過大,導致溢出,將觸發中斷0.
idiv執行有符號除法。
imul執行有符號乘法,將結果保存到第二個操作數。如果第二個操作數空缺,就默認為%eax,且完好的結果將存在%eax:%eax中
inc遞增給定暫存器或地址。
mul執行無符號乘法,運算規則與imull相同
neg將給定暫存器或記憶體位置的內容補齊(二進位求補)
sbb錯位減法,與adc用法相同。通常使用sub
sub將兩個操作數相減,用第二個操作數減去第一個操作數,將結果保存的到第二個操作數,本指令可用於有符號整數及無符號整數
位操作
rcl將第一個操作數,向左循環移位給定次數,第一個操作數可以是立即數或暫存器%cl。循環移位包含進位標誌,因此此指令實際上對33位而非32位進行操作。本指令將設置溢出標誌
rcr向右循環移位,其他與上一條指令相同
rol向左循環移位,本指令設置溢出標誌和進位標誌,但不會將進位位作為循環移位的一部分。向左循環移位的次數可以通過立即定址方式或暫存器%cl的值指定
ror向右循環移位,其他與上一條指令相同
sal算術左移,符號位移出至進位標誌,最低有效位填充0,其他位左移。與一般左移相同,移動位數通過立即定址方式或是暫存器%cl指定。
sar算術右移(填上符號位),最低有效位移出至進位標誌,符號位被向右移入,並保留原符號位。其他位只是向右移。移動位數通過立即定址方式或是暫存器%cl指定。
shl邏輯左移,將所有位左移(對符號位不做特殊處理).將最左一位推入進位標誌,移動位數通過立即定址方式或是暫存器%cl指定。
shr邏輯右移,將所有位右移(對符號位不做特殊處理).將最右一位推入進位標誌,移動位數通過立即定址方式或是暫存器%cl指定。
比較操作指令
指令 | 描述 |
---|---|
cmp 源1, 源2 | 根據源1-源2設置狀態碼 |
test 源1, 源2 | 根據源1& 源2設置狀態碼 |
標誌暫存器
OF: 溢出標誌.最近的操作導致一個補碼溢出—正溢出或負溢出。
SF : 符號標誌.最近的操作得到的結果為負數。
ZF:零標誌,最近的操作得出的結果為0。
A 輔助進位標誌。
P 奇偶標誌,如果最後一個結果的低位元組由偶數個1,此標誌為真。
CF 進位標誌,最近的操作使最高位產生了進位。可用來檢查無符號操作的溢出。
指令 | 描述 |
---|---|
cli、sti | 清除IF標誌位(CLear Interrupt flag)、置位IF標誌(SeT Interrupt flag) |
pushfq、popfq | 將RFLAGS的值壓棧和出棧 |
例如,用一條ADD指令完成等價於t=a+b的功能,這裡a、b和t都是整型。然後根據結果來設置條件碼:
CF
(unsigned) t < (unsigned) a
無符號溢出ZF
(t = 0)
零SF
(t < 0)
負數OF
(a < 0==b < 0) && (t < 0 !=a < 0)
有符號溢出
流控制指令
指令 | 描述 |
---|---|
jmp 標籤或地址 | 跳轉到某個位置的程式碼 |
call 標籤或地址 | 把返回地址壓入棧,並跳轉到指定位置的程式碼 |
ret | 從棧里彈出返回地址,並跳轉過去 |
call 將%eip所指的下一個值入棧,並跳轉到目的地址。這用於函數調用。目的地址也可以是星號後跟暫存器的形式,這種方式為間接函數調用。例如 call *%eax
將調用%eax中所含地址所指的函數
int 引起給定數字的中斷。
jxx條件分支。xx為條件(由前一條指令設置)為TRUE,就跳轉到給定的地址;否則,執行下一條指令。條件程式碼如下:
指令 | 含義 | 狀態碼 |
---|---|---|
je或jz | 跳轉,如果相等(等於零) | ZF |
jne或jnz | 跳轉,如果不相等(等於零) | ~ZF |
js | 跳轉,如果為負值 | SF |
jns | 跳轉,如果不為負值 | ~SF |
jg或jnle | 跳轉,如果大於,有符號數 | ~(SF^OF) & ~ZF |
jge或jnl | 跳轉,如果大於等於,有符號數 | ~(SF^OF) |
jl或jnge | 跳轉,如果小於,有符號數 | SF^OF |
jle或jne | 跳轉,如果小於等於,有符號數 | SF^OF | ZF |
…… |
- [n]a[e]—大於(無符號大於)、不大於、大於等於
- [n]b[e]—小於(無符號小於)
- [n]e—等於
- [n]z—0
- [n]g[e]—大於(帶符號比較)
- [n]l[e]—小於(帶符號比較)
- [n]c——進位標誌集
- [n]o ——溢出標誌集
- [p]p ——相等標誌集
- [n]s ——符號標誌集
- ecxz——%ecx為0
jmp無條件跳轉。僅僅是將%eip設置為目的地址,目的地址也可以是星號後跟暫存器的形式,這種方式為間接函數調用。jmp *%eax
用暫存器中的值作為跳轉目標,jmp *(%rax)
以記憶體地址%rax中的值作為跳轉目標。
ret從棧種彈出值,並將%eip設置為該值,用於從函數調用返回。
三、偽指令
.equ
.equ
允許你為數字分配名稱。例如
.equ LINUX_SYSCALL,0x80
此時LINUX_SYSCALL
就是一個常量,使用如下
int $LINUX_SYSCALL
計算一段數據的長度
.section .data
helloworld:
.ascii "hello world\n"
helloworld_end;
.equ helloworld_len, helloworld_end - helloworld
.rept
.rept
用於填充每一項,.rept
告訴彙編程式將.rept
和.endr
之間的斷重複指定次數.
.rept 30 #填充30位元組0
.byte 0
.endr
.endr
結束以.rept定義的重複節(section)
.lcomm
.locmm
指令將創建一個符號,代指一個存儲位置。使用.lcomm
創建一個符號my_buffer,代指.bss
段中用作緩衝區的500位元組存儲位置。
.section .bss
.locmm my_buffer, 500
movl $my_buffer, %ecx #將緩衝區地址載入到%ecx中
.globl
.globl
聲明一個全局符號。
.globl _start
_start:
.type
.type
指定一個符號作為某種類型。例如告訴鏈接器 符號power作為函數處理:
.type power, @function
power:
……
如果其他程式中沒有使用該函數,則這條指令可以不需要。power:
將下一條指令的存儲位置賦給符號power,這就是為什麼調用該函數時需要如下執行:
call power
.ascii
將給定帶引號字元串轉換為位元組數據
.byte
將逗號分隔符的值列表作為數據插入程式
.section
切換正在使用的節。通用節包括.text、.data、.bss
變數
定義一個long型變數begin
如下:
begin:
.long 0
四、X86_64暫存器
x86-64 架構的 CPU 里有很多暫存器,我們在程式碼里最常用的是 16 個 64 位的通用暫存器,分別是:
%rax,%rbx,%rcx,%rdx,%rsi,%rdi,%rbp,%rsp, %r8,%r9,%r10,%r11,%r12,%r13,%r14,%r15。
這些暫存器在歷史上有各自的用途,比如,rax 中的「a」,是 Accumulator(累加器) 的意思,這個暫存器是累加暫存器。
但隨著技術的發展,這些暫存器基本上都成為了通用的暫存器,不限於某種特定的用途。但是,為了方便軟體的編寫,我們還是做了一些約定,給這些暫存器劃分了用途。針對 x86-64 架構有多個調用約定(Calling Convention),包括微軟的 x64 調用約定(Windows 系統)、System V AMD64 ABI(Unix 和 Linux 系統)等,下面的內容屬於後者:
-
%rax 除了其他用途外,通常在函數返回的時候,把返回值放在這裡。
-
%rsp 作為棧指針暫存器,指向棧頂。
-
%rdi,%rsi,%rdx,%rcx,%r8,%r9 給函數傳整型參數,依次對應第 1 參數到第 6 參數。超過 6 個參數使用。
如果程式要使用 %rbx,%rbp,%r12,%r13,%r14,%r15 這幾個暫存器,是由被調用者(Callee)負責保護的,也就是寫到棧里,在返回的時候要恢復這些暫存器中原來的內容。其他暫存器的內容,則是由調用者(Caller)負責保護,如果不想這些暫存器中的內容被破壞,那麼要自己保護起來。
上面這些暫存器的名字都是 64 位的名字,對於每個暫存器,我們還可以只使用它的一部分,並且另起一個名字。比如對於 %rax,如果使用它的前 32 位,就叫做 %eax,前 16 位叫 %ax,前 8 位(0 到 7 位)叫 %al,8 到 15 位叫 %ah。
原本含義 | 64位 | 32位 | 16位 | 高8位 | 低8位 | |
---|---|---|---|---|---|---|
Accumulator | 累加器 | rax | eax | ax | ah | al |
Base | 基地址 | rbx | ebx | bx | bh | bl |
Counter | 計數器 | rcx | ecx | cx | ch | cl |
Data | 數據 | rdx | edx | dx | dh | dl |
Source | 源 | rsi | esi | si | sil | |
Destination | 目的 | rdi | edi | di | dil | |
Stack Base Pointer | 棧基址 | rbp | ebp | bp | bpl | |
Stack Pointer | 棧指針 | rsp | esp | sp | spi | |
後增加的8個通用暫存器 | r8-r15 | r8d-r15d | r8w-r15w | r8b-r15b |
除了通用暫存器以外,有可能的話,還要了解下面的暫存器和它們的用途,我們寫彙編程式碼時經常跟它們發生關聯:
-
8 個 80 位的 x87 暫存器,用於做浮點計算;
-
8 個 64 位的 MMX 暫存器,用於 MMX 指令(即多媒體指令),這 8 個跟 x87 暫存器在物理上是相同的暫存器。在傳遞浮點數參數的時候,要用 mmx 暫存器。
-
16 個 128 位的 SSE 暫存器,用於 SSE 指令。 (SIMD )。
-
指令暫存器,rip,保存指令地址。CPU 總是根據這個暫存器來讀取指令。
-
flags(64 位:rflags, 32 位:eflags)暫存器:每個位用來標識一個狀態。比如,它們會用於比較和跳轉的指令,比如 if 語句翻譯成的彙編程式碼,就會用它們來保存 if 條件的計算結果。
五、常見彙編結構
1. 函數調用傳參
使用暫存器傳參
在 X86-64 架構下,有很多的暫存器,所以程式調用約定中規定盡量通過暫存器來傳遞參數,而且,只要參數不超過 6 個,都可以通過暫存器來傳參,使用的暫存器如下:
32位名稱 | 64位名稱 | 所傳參數 |
---|---|---|
%eax | %rax | 參數1 |
%esi | %esi | 參數2 |
%edx | %rdx | 參數3 |
%ecx | %rcx | 參數4 |
%r8d | %r8 | 參數5 |
%r9d | %r9 | 參數6 |
使用棧傳參
超過 6 個的參數的話,要再加上棧來傳參:
根據程式調用約定的規定,參數 1~6 是放在暫存器里的,參數 7 和 8 是放到棧里的,函數參數以逆序的方向入棧,先放參數 8,再放參數 7。
int fun1(int x1, int x2, int x3, int x4, int x5, int x6, int x7, int x8){
int c = 10;
return x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8 + c;
}
println("fun1:" + fun1(1,2,3,4,5,6,7,8));
# function-call2-craft.s 函數調用和參數傳遞
# 文本段,純程式碼
.section __TEXT,__text,regular,pure_instructions
_fun1:
# 函數調用的序曲,設置棧指針
pushq %rbp # 把調用者的棧幀底部地址保存起來
movq %rsp, %rbp # 把調用者的棧幀頂部地址,設置為本棧幀的底部
movl $10, -4(%rbp) # 變數c賦值為10,也可以寫成 movl $10, (%rsp)
# 做加法
movl %edi, %eax # 第一個參數放進%eax
addl %esi, %eax # 加參數2
addl %edx, %eax # 加參數3
addl %ecx, %eax # 加參數4
addl %r8d, %eax # 加參數5
addl %r9d, %eax # 加參數6
addl 16(%rbp), %eax # 加參數7
addl 24(%rbp), %eax # 加參數8
addl -4(%rbp), %eax # 加上c的值
# 函數調用的尾聲,恢復棧指針為原來的值
popq %rbp # 恢復調用者棧幀的底部數值
retq # 返回
.globl _main # .global偽指令讓_main函數外部可見
_main: ## @main
# 函數調用的序曲,設置棧指針
pushq %rbp # 把調用者的棧幀底部地址保存起來
movq %rsp, %rbp # 把調用者的棧幀頂部地址,設置為本棧幀的底部
subq $16, %rsp # 這裡是為了讓棧幀16位元組對齊,實際使用可以更少
# 設置參數
movl $1, %edi # 參數1
movl $2, %esi # 參數2
movl $3, %edx # 參數3
movl $4, %ecx # 參數4
movl $5, %r8d # 參數5
movl $6, %r9d # 參數6
movl $7, (%rsp) # 參數7
movl $8, 8(%rsp) # 參數8
callq _fun1 # 調用函數
# 為pritf設置參數
leaq L_.str(%rip), %rdi # 第一個參數是字元串的地址
movl %eax, %esi # 第二個參數是前一個參數的返回值
callq _printf # 調用函數
# 設置返回值。這句也常用 xorl %esi, %esi 這樣的指令,都是置為零
movl $0, %eax
addq $16, %rsp # 縮小棧
# 函數調用的尾聲,恢復棧指針為原來的值
popq %rbp # 恢復調用者棧幀的底部數值
retq # 返回
# 文本段,保存字元串字面量
.section __TEXT,__cstring,cstring_literals
L_.str: ## @.str
.asciz "fun1 :%d \n"
其棧幀的變化過程,如下:
使用棧來傳遞參數時,需要將函數參數以逆序的方向入棧,並發出call
指令。調用後在將參數出棧:
printf("The numer is %d",88);
.section .data
test_string:
.ascii "The numer is %d\0"
.section .text
pushl $88
pushl $test_string
call printf
popl %eax
popl %eax
2. 變數賦值
彙編語言中全局變數訪問方式與局部變數不同。全局變數通過直接定址訪問,而局部變數使用基址定址方式,例如
int my_global_var;
int foo()
{
int my_local_var;
my_local_var = 1;
my_glocal_var = 2;
return 0
}
用彙編表示以上為:
.section .data
.lcomm my_globl_var, 4
.type foo, @function
foo:
pushl %ebp #保存原棧基址
movl %esp, %ebp #令棧指針指向新基址指針
subl $4, %esp #為變數my_local_var保留空間
.equ my_local_var, -4 #用my_local_var尋找局部變數
movl $1, my_local_var(%ebp)
movl $2, my_global_var
movl %ebp, %esp #清除函數變數並返回
popl %ebp
ret
3. 指針
指針,它只是保存某個值的地址。全局變數:
int global_data = 30;
其對應的彙編為:
.section .data
global_data:
.long 30
C語言中取地址如下:
p = &global_data;
對應的彙編為:
movl $global_data, %eax
可以看到彙編語言中總是通說指針訪問記憶體,也就是直接定址方式,為了取得指針本身,必須採用立即定址方式。
局部變數略為複雜,C語言程式碼如下:
void foo()
{
int a;
int *b;
a = 30;
b = &a;
*b = 44;
}
對應彙編如下:
foo:
#標準函數開頭
pushl %ebp
movl %ebp,%esp
#保留兩個字的記憶體
subl -8, %ebp
.equ A_VAR, -4
.equ B_VAR, -8
#a = 30
movl $30, A_VAL(%ebp)
#b = &a
movl $A_VAR, B_VAR(%ebp)
addl %ebp, B_VAR(%ebp)
#*b = 30
movl B_VAR(%ebp), %eax #B
movl $30, (%eax)
#標準結束函數
movl %ebp ,%esp
popl %ebp
ret
要獲取局部變數的地址,必須按基址定址方式計算該地址。還有更簡單的方式就是lea指令,該指令載入有效地址,會讓電腦計算地址,然後在需要的時候加上地址:
#b = &a
leal A_VAR(%ebp), %eax
movl %eax, B_VAR(%ebp)
4. 結構
結構時對記憶體塊的簡單描述,例如,在C語言中可以使用如下程式碼:
struct person{
char pristname[40];
char lastname[40];
int age
};
彙編中只是給予你一種使用84位元組數據的方式。
.equ PERSON_SIZE, 84
.equ PERSON_FIRSTNAME_OFFSET, 0
.equ PERSON_LASTNAME_OFFSET, 40
.equ PERSON_AGE_OFFSET, 80
當聲明此類型的一個變數時,保留84位元組空間就行,C程式碼如下:
void foo()
{
struct person p;
/**/
……
}
對應的彙編程式碼如下:
foo:
#標準開頭
pushl %ebp
movl %esp, %ebp
#為局部變數分配空間
subl $PERSON_SIZE, %esp
#這是變數相對於%ebp的偏移量
.equ P_VAR, 0-PERSON_SIZE
……
#標準結束
movl %ebp, %esp
pop %ebp
ret
訪問結構體成員,必須使用基址定址方式,偏移量為上面定義的值。如C語言設置年齡如下:
p.age = 30;
對應的彙編入下:
movl $30, P_VAR + PERSON_AGE_OFFSET(%ebp)
5. 循環
C語言語句如下:
while (a < b){
/*某些操作*/
}
/*結束循環*/
這些對應的彙編如下所示:
loop_begin:
movl a, %eax
movl b, %ebx
cmpl %eax, %ebx
jge loop_end
loop_body:
#某些操作
jmp loop_begin
loop_end:
#結束循環
上面說到暫存器%ecx可用作計數器,終止條件為0,loop 指令會遞減%ecx,並在%ecx不為0 的條件下跳轉到指定地址,例如,需執行某個語句100次C 語言如下:
for (i = 0; i < 100; i++){
/*某些操作*/
}
彙編實現如下:
loop_initalize:
movl 100,%ecx
loop_begin:
#某些操作
#遞減%ecx,若%ecx不為0則繼續循環
loop loop_begin
rest_of_program:
6. if語句
if(a == b){
/*真分支操作*/
}else{
/*假分支操作*/
}
/*真假匯合*/
在彙編中表示如下:
#將a.b移入暫存器用於比較
movl a, %eax
movl b, %ebx
#比較
cmpl %eax, %ebx
#跳轉到真分支
je true_branch
fale_branch: #非必要標籤,只是為了說明這是假分支
#假分支程式碼
#跳到真假匯合
jmp reconverge
true_branch:
#真分支程式碼
reconverge:
7. 浮點數使用
之前我們用的例子都是採用整數,現在使用浮點數來做運算。下面這段程式碼:
float fun1(float a, float b){
float c = 2.0;
return a + b + c;
}
使用 -O2 參數,把 C 語言的程式編譯成彙編程式碼如下:
.file "float.c"
.section .text.unlikely,"ax",@progbits
.LCOLDB1:
.text
.LHOTB1:
.p2align 4,,15
.globl fun1
.type fun1, @function
fun1:
.LFB0:
.cfi_startproc
addss %xmm0, %xmm1 #浮點數傳參用XMM暫存器,加法用addss指令
addss .LC0(%rip), %xmm1 #把常量2.0加到xmm0上,xmm0保存返回值
movaps %xmm1, %xmm0
ret
.cfi_endproc
.LFE0:
.size fun1, .-fun1
.section .text.unlikely
.LCOLDE1:
.text
.LHOTE1:
.section .rodata.cst4,"aM",@progbits,4
.align 4
.LC0:
.long 1073741824 ## float 2 常量
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.12) 5.4.0 20160609"
.section .note.GNU-stack,"",@progbits
這個程式碼的結構你應該熟悉了,棧幀的管理方式都是一樣的,都要維護 %rbp 和 %rsp。不一樣的地方,有幾個地方:
-
傳參。給函數傳遞浮點型參數,是要使用 XMM 暫存器。
-
指令。浮點數的加法運算,使用的是
addss
指令,它用於對單精度的標量浮點數做加法計算,這是一個 SSE1 指令。SSE1 是一組指令,主要是對單精度浮點數 (比如 C 或 Java 語言中的 float) 進行運算的,而 SSE2 則包含了一些雙精度浮點數(比如 C 或 Java 語言中的 double)的運算指令。 -
返回值。整型返回值是放在
%eax
暫存器中,而浮點數返回值是放在xmm0
暫存器中的。調用者可以從這裡取出來使用。
六、C嵌入彙編
為什麼要使用彙編語言有兩個可能的原因。首先是,當我們接近硬體時,C受到限制。例如。沒有C語句可直接修改處理器狀態暫存器。第二個原因是創建高度優化的程式碼。(以下內容大多來源於GCC-Inline-Assembly-HOWTO.)
1.基本內嵌
基本內聯彙編的格式非常簡單。它的基本形式是
asm("assembly code");
例如:
asm("movl %ecx %eax"); /* moves the contents of ecx to eax */
__asm__("movb %bh (%eax)"); /*moves the byte from bh to the memory pointed by eax */
在這裡使用過asm
和__asm__
。兩者都有效。如果關鍵字asm
與我們程式中的某些內容衝突,我們可以使用__asm__
。如果我們有多個指令,我們用雙引號每行寫一個,並在指令後面加上’\ n’和’\ t’。這是因為gcc將每個指令作為一個字元串發送至彙編器。如下:
__asm__ ("movl %eax, %ebx\n\t"
"movl $56, %esi\n\t"
"movl %ecx, $label(%edx,%ebx,$4)\n\t"
"movb %ah, (%ebx)");
如果在我們的程式碼中,我們更改一些暫存器中的內容並從asm返回而不恢復為修改前的值,則會發生一些不好的事情。這是因為GCC不知道暫存器內容的變化,這導致我們遇到麻煩,特別是當編譯器進行一些優化時。它會假設某些暫存器包含某些變數的值,我們可能在沒有通知GCC的情況下對其進行了更改,並且它仍然沒有發生任何事情。我們可以做的是使用那些沒有副作用的指令或在我們退出或等待某些事情崩潰時解決問題。這是我們想要一些擴展功能的地方。擴展的asm為我們提供了該功能。
2.擴展內嵌彙編
在基本的內聯彙編中,只有指令。在擴展彙編中,我們還可以指定操作數。它允許我們指定輸入暫存器,輸出暫存器和破壞暫存器列表。指定要使用的暫存器並不是強制性的,我們可以將這個問題留給GCC,這可能更適合GCC的優化方案。無論如何,基本格式是:
asm ( assembler template
: output operands /*optional*/
: input operands /*ooptional*/
: list of clobbered registers /*ooptional*/
);
- assembler template由彙編指令組成.
- 每個操作數由操作數約束字元串描述,後跟括弧中的C表達式。
- 冒號將彙編程式模板與第一個輸出操作數分隔開,另一個冒號將最後一個輸出操作數與第一個輸入分開,如果有的話。
- 逗號分隔每個組中的操作數。操作數的總數限制為10或機器描述中任何指令模式中的最大操作數數量,以較大者為準。
asm_(
彙編語句模版:
輸出部分:
輸入部分:
破壞描述部分:);
例如:
int a=10, b;
asm ("movl %1, %%eax;
movl %%eax, %0;"
:"=r"(b) /* output */
:"r"(a) /* input */
:"%eax" /* clobbered register */
);
這裡我們做的是使用彙編指令使’b’的值等於’a’的值。一些興趣點是:
- 「b」是輸出操作數,由%0引用,「a」是輸入操作數,由%1引用。
- 「r」是對操作數的約束。我們稍後會詳細介紹約束。目前,「r」向GCC表示使用任何暫存器來存儲操作數。輸出操作數約束應該有一個約束修飾符「=」。而這個修飾符表示它是輸出操作數並且是只寫的。
- 暫存器名稱前面有兩個%的前綴。這有助於GCC區分操作數和暫存器。操作數具有單個%作為前綴。
- 第三個冒號後的修改後的暫存器%eax告訴GCC%eax的值將在「asm」內修改,因此GCC不會使用該暫存器來存儲任何其他值。
當「asm」的執行完成時,「b」將反映更新的值,因為它被指定為輸出操作數。換句話說,「asm」中對「b」的改變應該反映在「asm」之外。
現在我們可以詳細查看每個欄位。
2.1彙編模板
彙編程式模板包含插入C程式內的彙編指令集。格式如下:要麼每條指令都用雙引號括起來,要麼整個指令組都在雙引號內。每條指令也應以分隔符結束。有效分隔符是換行符(\ n)和分號(;)。’\ n’後面可能跟一個標籤(\ t)。對應於C表達式的操作數由%0,%1 ……等表示。
2.2操作數
C表達式用作「asm」中的彙編指令的操作數。每個操作數都被寫為雙引號中的第一個操作數約束。對於輸出操作數,在引號內也會有一個約束修飾符,然後是C表達式,它代表操作數。即
「約束」(C表達式)是一般形式。對於輸出操作數,將有一個額外的修飾符。約束主要用於決定操作數的定址模式。它們還用於指定要使用的暫存器。
如果我們使用多個操作數,則用逗號分隔。
在彙編程式模板中,每個操作數都由數字引用。編號如下進行。如果總共有n個操作數(包括輸入和輸出),則第一個輸出操作數編號為0,按遞增順序繼續,最後一個輸入操作數編號為n-1。最大操作數是我們在上一節中看到的。
輸出操作數表達式必須是左值。輸入操作數不受此限制。他們可能是表達式。擴展的asm功能最常用於編譯器本身不知道存在的機器指令;-)。如果無法直接定址輸出表達式(例如,它是位欄位),則我們的約束必須允許暫存器。在這種情況下,GCC將使用暫存器作為asm的輸出,然後將該暫存器內容存儲到輸出中。
如上所述,普通輸出操作數必須是只寫的; GCC將假設在指令之前這些操作數中的值已經死亡且無需生成。擴展的asm還支援輸入輸出或讀寫操作數。
所以現在我們專註於一些例子。我們想要將數字乘以5。為此,我們使用指令
lea
。asm ("leal (%1,%1,4), %0" : "=r" (five_times_x) : "r" (x) );
這裡我們的輸入是’x’。我們沒有指定要使用的暫存器。GCC將選擇一些輸入暫存器,一個用於輸出,並按我們的意願行事。如果我們希望輸入和輸出駐留在同一個暫存器中,我們可以指示GCC這樣做。這裡我們使用那些類型的讀寫操作數。通過指定適當的約束,例如。
sm ("leal (%0,%0,4), %0" : "=r" (five_times_x) : "0" (x) );
現在輸入和輸出操作數在同一個暫存器中。但是我們不知道哪個暫存器。現在,如果我們也要指定它,那麼有一種方法。
asm ("leal (%%ecx,%%ecx,4), %%ecx" : "=c" (x) : "c" (x) );
從GCC 3.1版開始,GCC編譯器就支援符號名稱,在程式碼部分,操作數由百分號引用,後跟方括弧內的相關符號名。它引用包含相同符號名的操作數列表之一中的條目。如xenomai執行緒切換程式碼示例:
static inline void do_switch_threads(struct xnarchtcb *out_tcb,
struct xnarchtcb *in_tcb,
struct task_struct *outproc,
struct task_struct *inproc)
{
long ebx_out, ecx_out, edi_out, esi_out;
__asm__ __volatile__("pushfl\n\t"
"pushl %%ebp\n\t"
"movl %[spp_out_ptr],%%ecx\n\t"
"movl %%esp,(%%ecx)\n\t"
"movl %[ipp_out_ptr],%%ecx\n\t"
"movl $1f,(%%ecx)\n\t"
"movl %[spp_in_ptr],%%ecx\n\t"
"movl %[ipp_in_ptr],%%edi\n\t"
"movl (%%ecx),%%esp\n\t"
"pushl (%%edi)\n\t"
__CANARY_SWITCH
"jmp __switch_to\n\t"
"1: popl %%ebp\n\t"
"popfl\n\t"
: "=b"(ebx_out),
"=&c"(ecx_out),
"=S"(esi_out),
"=D"(edi_out),
"+a"(outproc),
"+d"(inproc)
__CANARY_OUTPUT
: [spp_out_ptr] "m"(out_tcb->spp),
[ipp_out_ptr] "m"(out_tcb->ipp),
[spp_in_ptr] "m"(in_tcb->spp),
[ipp_in_ptr] "m"(in_tcb->ipp)
__CANARY_INPUT
: "memory");
}
其中的asm符號操作符使用單獨的名稱空間。也就是說,與C程式碼中的符號無關。但是必須在每個asm語句的符號必須唯一。
2.3 Clobber列表
一些指令破壞了一些硬體暫存器。我們必須在clobber-list中列出這些暫存器,即asm函數中第三個’ : ‘ 之後的欄位。這是為了告知gcc我們將自己使用和修改它們。所以gcc不會假設它載入到這些暫存器中的值是有效的。我們不應該在此列表中列出輸入和輸出暫存器。因為,gcc知道「asm」使用它們(因為它們被明確指定為約束)。如果指令隱式或顯式地使用任何其他暫存器(並且輸入或輸出約束列表中不存在暫存器),則必須在破壞列表中指定這些暫存器。
如果我們的指令可以改變條件程式碼暫存器,我們必須將「cc」添加到破壞暫存器列表中。
如果我們的指令以不可預測的方式修改記憶體,請將「memory」添加到修飾暫存器列表中。這將導致GCC不在彙編器指令的暫存器中保持快取的記憶體值。如果受影響的記憶體未在asm的輸入或輸出中列出,我們還必須添加volatile關鍵字。
我們可以根據需要多次讀取和編寫被破壞的暫存器。考慮模板中多個指令的示例; 它假定子程式_foo接收在暫存器eax
和ecx
參數的參數。
asm ("movl %0,%%eax;
movl %1,%%ecx;
call _foo"
: /* no outputs */
: "g" (from), "g" (to)
: "eax", "ecx"
);
2.4 Volatile
如果您熟悉內核源程式碼或類似的一些漂亮的程式碼,您必須已經看到許多函數聲明為volatile
或 __volatile__
。我之前提到過關鍵字asm
和__asm__
。那這 volatile
是什麼?
如果我們的彙編語句必須在我們放置的地方執行,(即不能作為優化移出循環),請將關鍵字volatile
放在asm之後和()之前。我們將其聲明為
asm volatile ( ... : ... : ... : ...);
使用__volatile__
的時候,我們必須非常小心。
C編譯器的程式碼優化器也會優化內嵌asm程式碼。如果我們的程式集只是用於進行一些計算並且沒有任何副作用,那麼最好不要使用關鍵字volatile
。避免gcc無法優化程式碼。
在 一些有用的方法 中,我提供了許多內聯asm函數的示例。在那裡我們可以看到詳細的clobber列表。
2.5 常用約束
存在許多約束,其中僅頻繁使用少數約束。我們將看看這些約束。
- 註冊操作數約束(r)
使用此約束指定操作數時,它們將存儲在通用暫存器(GPR)中。採用以下示例:
asm ("movl %%eax, %0\n" :"=r"(myval));
這裡變數myval保存在暫存器中,暫存器中的值 eax
被複制到該暫存器中,並且值myval
從該暫存器更新到存儲器中。當指定「r」約束時,gcc可以將變數保存在任何可用的GPR中。要指定暫存器,必須使用特定的暫存器約束直接指定暫存器名稱。他們是:
+---+--------------------+
| r | Register(s) |
+---+--------------------+
| a | %eax, %ax, %al |
| b | %ebx, %bx, %bl |
| c | %ecx, %cx, %cl |
| d | %edx, %dx, %dl |
| S | %esi, %si |
| D | %edi, %di |
+---+--------------------+
- 記憶體操作數約束(m)
當操作數在存儲器中時,對它們執行的任何操作將直接發生在存儲器位置,而不是暫存器約束,暫存器約束首先將值存儲在要修改的暫存器中,然後將其寫回存儲器位置。但是暫存器約束通常僅在它們對於指令絕對必要時才使用,或者它們顯著加速了該過程。在需要在「asm」內更新C變數並且您真的不想使用暫存器來保存其值時,可以最有效地使用記憶體約束。例如,idtr的值存儲在記憶體位置loc中:
asm("sidt %0\n" : :"m"(loc));
- 匹配(數字)約束
在某些情況下,單個變數可以作為輸入和輸出操作數。可以通過使用匹配約束在「asm」中指定這種情況。
asm ("incl %0" :"=a"(var):"0"(var));
我們在操作數小節中也看到了類似的例子。在此示例中,匹配約束,暫存器%eax用作輸入和輸出變數。var輸入讀取到%eax,更新後%eax在增量後再次存儲在var中。這裡的「0」指定與第0個輸出變數相同的約束。也就是說,它指定var的輸出實例應僅存儲在%eax中。可以使用此約束:
- 在從變數讀取輸入或修改變數並將修改寫回同一變數的情況下。
- 如果不需要輸入和輸出操作數的單獨實例。
使用匹配約束的最重要的影響是它們導致有效使用可用暫存器。
- memory
volatile
將volatile屬性添加到asm語句中,以指示編譯器不要優化彙編部分程式碼。memory**
它告訴編譯器彙編程式指令可能會更改記憶體位置。將強制編譯器在執行彙編程式指令之前先保存快取里的值到記憶體,然後在載入它們。並且保留執行順序,因為在使用記憶體破壞者執行asm語句後,所有變數的內容都是不可預測的。
使所有快取的值無效可能不是最佳的。除此之外,可以添加一個虛擬操作數來創建人工依賴項:
2.6約束修飾符
在使用約束時,為了更精確地控制約束的影響,GCC為我們提供了約束修飾符。最常用的約束修飾符是
-
「=」:表示該操作數對該指令是只寫的; 先前的值被丟棄並由輸出數據替換。
-
「&」:表示此操作數是一個earlyclobber操作數,在使用輸入操作數完成指令之前修改該操作數。因此,該操作數可能不在於用作輸入操作數的暫存器或任何存儲器地址的一部分。如果輸入操作數僅用作輸入,則在寫入早期結果之前,它可以綁定到earlyclobber操作數。
更多約束描述
2.7 一些有用的方法
現在我們已經介紹了關於GCC內聯彙編的基本理論,現在我們將集中討論一些簡單的例子。將內聯asm函數編寫為MACRO總是很方便。我們可以在內核程式碼中看到許多asm函數。(/usr/src/linux/include/asm/*.h)。
- 首先,我們從一個簡單的例子開始。我們將編寫一個程式來相加兩個數字。
int main(void)
{
int foo = 10, bar = 15;
__asm__ __volatile__("addl %%ebx,%%eax"
:"=a"(foo)
:"a"(foo), "b"(bar)
);
printf("foo+bar=%d\n", foo);
return 0;
}
在這裡,我們堅持要求GCC在%eax中存儲foo,bar存儲在%ebx中,我們也希望結果保存在%eax中。’=’符號表示它是輸出暫存器。現在我們可以用其他方式實現變數和整數相加。
__asm__ __volatile__(
" lock ;\n"
" addl %1,%0 ;\n"
: "=m" (my_var)
: "ir" (my_int), "m" (my_var)
: /* no clobber-list */
);
這是一個原子加法。我們可以刪除指令’lock’來刪除原子性。在輸出欄位中,「= m」表示my_var是輸出,它在記憶體中。類似地,「ir」表示,my_int是一個整數,應該駐留在某個暫存器中(回想一下我們上面看到的表)。clobber列表中沒有暫存器。
- 現在我們將對一些暫存器/變數執行一些操作並比較該值。
__asm__ __volatile__( "decl %0; sete %1"
: "=m" (my_var), "=q" (cond)
: "m" (my_var)
: "memory"
);
這裡,my_var的值減1,如果結果值是0
,則設置變數cond。我們可以通過添加指令「lock; \ n \ t」作為彙編程式模板中的第一條指令來添加原子性。
以類似的方式,我們可以使用「incl%0」而不是「decl%0」,以便增加my_var。
這裡要注意的是(i)my_var是駐留在記憶體中的變數。(ii)約束「= q」保證了cond在eax,ebx,ecx和edx暫存器其中之一中。(iii)我們可以看到記憶體在clobber列表中。即,程式碼正在改變記憶體的內容。
- 如何設置/清除暫存器中的位?
__asm__ __volatile__( "btsl %1,%0"
: "=m" (ADDR)
: "Ir" (pos)
: "cc"
);
這裡,ADDR變數位置’pos’處的位(存儲器變數)設置為1,
我們可以使用’btrl’代替’btsl’來清除該位。pos的約束「Ir」表示pos位於暫存器中,其值的範圍為0-31(x86依賴約束)。也就是說,我們可以在ADDR設置/清除變數的第0到第31位。由於條件程式碼將被更改,我們將「cc」添加到clobberlist。
- 現在我們來看一些更複雜但有用的功能。字元串副本。
static inline char * strcpy(char * dest,const char *src)
{
int d0, d1, d2;
__asm__ __volatile__( "1:\tlodsb\n\t"
"stosb\n\t"
"testb %%al,%%al\n\t"
"jne 1b"
: "=&S" (d0), "=&D" (d1), "=&a" (d2)
: "0" (src),"1" (dest)
: "memory");
return dest;
}
源地址存儲在esi中,目標位於edi中,然後啟動複製,當我們達到0時,複製完成。約束「&S」,「&D」,「&a」表示暫存器esi,edi和eax是early clobber暫存器,即它們的內容將在函數完成之前改變。這裡也很清楚為什麼memory在clobberlist中。
我們可以看到一個類似的函數移動一個double words。請注意,該函數聲明為宏。
#define mov_blk(src, dest, numwords) \
__asm__ __volatile__ ( \
"cld\n\t" \
"rep\n\t" \
"movsl" \
: \
: "S" (src), "D" (dest), "c" (numwords) \
: "%ecx", "%esi", "%edi" \
)
這裡我們沒有輸出,因此暫存器ecx,esi和edi的內容發生的變化是塊移動的副作用。所以我們必須將它們添加到clobber列表中.
- 在Linux中,使用GCC內聯彙編實現系統調用。讓我們看看如何實現系統調用。所有系統調用都寫成宏(linux / unistd.h)。例如,具有三個參數的系統調用被定義為宏,如下所示。
#define _syscall3(type,name,type1,arg1,type2,arg2,type3,arg3) \
type name(type1 arg1,type2 arg2,type3 arg3) \
{ \
long __res; \
__asm__ volatile ( "int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2)), \
"d" ((long)(arg3))); \
__syscall_return(type,__res); \
}
每當進行具有三個參數的系統調用時,上面顯示的宏用於進行調用。系統調用號放在eax中,然後是ebx,ecx,edx中的每個參數。最後,「int 0x80」是使系統調用工作的指令。可以從eax收集返回值。
每個系統調用都以類似的方式實現。退出是一個單個參數系統調用,讓我們看看它的程式碼是什麼樣的。它如下所示。
{
asm("movl $1,%%eax; /* SYS_exit is 1 */
xorl %%ebx,%%ebx; /* Argument is in ebx, it is 0 */
int $0x80" /* Enter kernel mode */
);
}
退出的數量是「1」,這裡,它的參數是0。所以我們安排eax包含1和ebx包含0和by int $0x80
,exit(0)
執行。這就是退出的方式。
七、編譯
彙編與鏈接
彙編:
as -o xxxx xxxx.s
as -32 -o xxxx xxxx.s #彙編32位
將彙編程式碼彙編成動態鏈接庫(-shared
):
ld -shared xxx.o yyy.o -o libzzz.so
使用動態鏈接。
ld -dynamic-linker /lib/ld-linux.so.2 -o xxxx xxxx.o -lc -lzzz
-lc
表示鏈接到庫c,也就是libc.so
,-dynamic-linker
鏈接動態庫。
版權聲明:本文為本文為部落客原創文章,轉載請註明出處。如有問題,歡迎指正。部落格地址://www.cnblogs.com/wsg1100/