前一陣子去看 java 虛擬機原理, 突然痛悟到虛擬機也是機器啊, 呵呵也就是個軟件而已. 看到 java 方法調用太複雜. 字節碼那一套又不太熟悉, 還不如直接去看 C 編譯後的彙編代碼.
目的: 搞明白 X86 架構下函數究竟是怎麼調用執行的.
本文采用該風格.html
swap(int, int): pushq %rbp movq %rsp, %rbp movl %edi, -20(%rbp) movl %esi, -24(%rbp) movl -20(%rbp), %eax movl %eax, -4(%rbp) movl -24(%rbp), %eax movl %eax, -20(%rbp) movl -4(%rbp), %eax movl %eax, -24(%rbp) nop popq %rbp ret
swap(int, int): push rbp mov rbp, rsp mov DWORD PTR [rbp-20], edi mov DWORD PTR [rbp-24], esi mov eax, DWORD PTR [rbp-20] mov DWORD PTR [rbp-4], eax mov eax, DWORD PTR [rbp-24] mov DWORD PTR [rbp-20], eax mov eax, DWORD PTR [rbp-4] mov DWORD PTR [rbp-24], eax nop pop rbp ret
縮寫 | 全稱 | 位數 |
---|---|---|
b | byte | 8bit |
w | word | 16bit |
l | long | 32bit |
q | quad | 64bit |
CPU 尋址方式, 也就是拿到數據的方式.
movb $0x05,%al
表示爲:R[al] = 0x05;
將當即數 0x05(1 byte) 複製到寄存器 al
間接尋址也就是到內存裏去找
movl %eax, -4(%ebp)
表示爲: mem[R[ebp]-4] = R[eax];
將寄存器 eax 裏面的值複製到寄存器 ebp 的值減去 4 指向的內存地址處(也就是 R[ebp] -4 的值是一個內存地址).
經過寄存器指向了內存地址, 是否是很熟悉的指針啊, 對, 就是指針. C 語言的指針就是這麼玩的啊!
movl -4(%ebp)
%eax 表示爲: R[eax] = mem[R[ebp] -4];
將寄存器 esp 的值減去 4 的值指向的內存地址處存放的值, 複製到寄存器 eax
PC = PC + (instruction size in bytes)
(instruction) (src1) (src2) (dst)
In most processors, the PC is incremented after fetching an instruction,
and holds the memory address of ("points to") the next instruction that would be executed.
這裏就用到了指令週期(instruction cycle)這個概念了, fetch, decode, execute.
注意到 PC 這個寄存器, 在 CPU fetch 了一條指令後就自動增長了.
(In a processor where the incrementation precedes the fetch, the PC points to the current instruction being executed.)
一樣的在 CPU fetch 一條指令以前, PC 指向當前正在執行的指令.
注意: 不容許直接操做 ip(instruction pointer) 也叫 pc(program counter) 這個寄存器, 若是這個能被編譯器操做的話, 就徹底想跳到哪執行就跳到哪執行了. 實際上 call 和 ret 指令就是在間接操做這兩個寄存器. call 帶來的效果之一就是 push %rip, ret 帶來的效果之一就是 pop %rip. 二者具備對稱做用啊!
When a jump instruction executes (in the last step of the machine cycle), it puts a new address into the PC. Now the fetch at the top of the next machine cycle fetches the instruction at that new address. Instead of executing the instruction that follows the jump instruction in memory, the processor "jumps" to an instruction somewhere else in memory.
jmp 指令把 label 所在的地址, 複製給 pc 寄存器. 這就改變了程序的控制流. 而後程序流程就脫離了原來的執行流. 和 call label 很類似, 對, call指令做用之一就包括了一個隱式的 jmp label. 函數調用也就是把控制權交給了被調用者. 可是控制權要回到調用函數那裏. 只不過 call 指令在函數交出控制權以前還多幹了一件事, 就是把此時的 pc 值 push 到了棧裏.
A stack register is a computer central processor register whose purpose is to keep track of a call stack.
push pop 指令操做的是 sp(stack pointer) 這個寄存器.
棧底地址: 由bp(base pointer) 保存
棧分配空間: sp 減去須要的地址空間大小(所謂的棧向下生長);
棧回收空間: sp 加上須要的地址空間大小(所謂的棧向上收縮);(PS: 至關無聊的話)
push value of %eax onto stack
The push instruction places its operand onto the top of the hardware supported stack in memory. Specifically, push first decrements ESP by 4, then places its operand into the contents of the 32-bit location at address [ESP]. ESP (the stack pointer) is decremented by push since the x86 stack grows down - i.e. the stack grows from high addresses to lower addresses.
這裏能夠看到 push 的是多字節的數據, 那就涉及到怎樣排列多字節數據的問題了. 也就是所謂的字節序的問題. X86 採用所謂的小端, 也就是把數字按照順序放到棧裏, 數字的高位放在了比較大的內存地址那裏.(這裏不作討論)
等價於
subl $4, %esp //分配4個字節的空間, 所謂的棧向下生長 movl %eax, (%esp) //將 eax 的值複製到 esp 指到的內存地址處
pop %eax off stack
The pop instruction removes the 4-byte data element from the top of the hardware-supported stack into the specified operand (i.e. register or memory location). It first moves the 4 bytes located at memory location [ESP] into the specified register or memory location, and then increments SP by 4.
等價於
movl (%esp),%eax //將 esp 指向的內存地址裏面的值複製到 eax addl $4,%esp //回收空間
The call instruction first pushes the current code location onto the hardware supported stack in memory(see the push instruction for details), and then performs an unconditional jump to the code location indicated by the label operand. Unlike the simple jump instructions, the call instruction saves the location to return to when the subroutine completes.
注意到 CPU 在 fetch 到 call 指令後, PC 就已經自動加 1 了. 此時的 PC 值也就是所謂的函數返回地址. call 指令作了兩件事, 第一件事: 將此時的 ip 保存到棧中, 第二件事: jump 到 label 位置, 此時已經改變了 PC 的值.
call label 做用等價於:
pushq %rip
jmp label
The ret instruction implements a subroutine return mechanism. This instruction first pops a code location off the hardware supported in-memory stack (也就是 call 指令壓入棧中的 PC, 將這個值複製到 PC 寄存器)(see the pop instruction for details). It then performs an unconditional jump to the retrieved code location.
因此啊, call(含有一個 push 操做) 和 ret(含有一個 pop 操做) 指令, 這是實現控制流跳轉和恢復的關鍵. 也間接操做了 sp 這個寄存器. 硬件實現的功能, 不須要過多的計較.
ret 做用等價於:
popq %rip
In computer science, a call stack is a stack data structure that stores information about the active subroutines of a computer program. This kind of stack is also known as an execution stack, program stack, control stack, run-time stack, or machine stack, and is often shortened to just "the stack".
A call stack is used for several related purposes, but the main reason for having one is to keep track of the point to which each active subroutine should return control when it finishes executing.
An active subroutine is one that has been called but is yet to complete execution after which control should be handed back to the point of call. Such activations of subroutines may be nested to any level (recursive as a special case), hence the stack structure.
for example, a subroutine DrawSquare calls a subroutine DrawLine from four different places, DrawLine must know where to return when its execution completes. To accomplish this, the address following the instruction that jumps to DrawLine, the return address, is pushed onto the call stack with each call.
void swap(int a, int b){ int tmp = a; a = b; b = tmp; }
// 64 bit 機器 , AT&T 風格的彙編 swap(int, int): pushq %rbp // 上一個棧幀(main)的基地址壓棧 等價於 subq $8, %rsp; movq %rbp,(%rsp) movq %rsp, %rbp // 開闢新的函數棧幀, 也就是造成一個新的棧的基地址 movl %edi, -20(%rbp) // 參數 a movl %esi, -24(%rbp) // 參數 b movl -20(%rbp), %eax // 把 a 賦值給 %eax movl %eax, -4(%rbp) // 把 %eax (a)賦值給 %rbp - 4(a) 的地址處 movl -24(%rbp), %eax // 把 b 賦值給 % eax(b) movl %eax, -20(%rbp) // 把 %eax (b) 賦值給 %rbp - 20(b) 的地址處,完成 b 的交換 movl -4(%rbp), %eax // 把 %rbp - 4 地址處的值(a) 賦值給 %eax (a) movl %eax, -24(%rbp) // 把 %eax (a) 賦值給 %rbp - 24 的地址處, 完成 a 的交換 nop // 延時 popq %rbp // 等價於 movq (%rsp), %rbp ; 上一個函數棧幀(main)的基地址恢復; addq $8, %rsp ; 上一個函數的 %rsp 恢復 ret // 1. popq %rip. (恢復 main 的 pc, call swap 這條指令壓入的 pc ) 2. jmp % rip 處繼續執行.(也就是 movl $0, %eax 這條指令的地址)
int main() { swap(1, 2); return 0; }
main: pushq %rbp movq %rsp, %rbp movl $2, %esi // 由 caller 準備函數參數 2 movl $1, %edi // 由 caller 準備函數參數 1 call swap // 在 CPU fetch 了 call 指令後, pc 已經指向了下一條指令, 也就是 movl $0, %eax 這條指令. 此時的 call 指令完成了兩件事, 第一件事: 將 pc(old) 壓入到棧中(swap 函數 ret 指令(函數返回)就是把這個 pc(old) pop 到 pc 這個寄存器, CPU 就能接着執行 movl $0, %eax 這條指令了), 第二件事: jump 到swap的地址, 開始執行swap的代碼. movl $0, %eax // 返回值 0 popq %rbp ret
注意: 示意圖裏面的是 64 bit 的彙編代碼.
注意: 全部的 push 和 pop 指令都會改變 sp 寄存器的值.
圖1 main 函數執行完 pushq %rbp 和 movq %rsp, %rbp, 開闢 main 函數的棧幀.
圖2 main 函數執行 call swap. call 指令兩個做用: 1. 將 movl $0, %eax 這條指令的地址(X)壓入棧中. 2. jump 到 swap 的地址.
圖3 是 swap 函數的棧幀, 此時新函數的棧幀 rsp 和 rbp 指向的是相同的內存地址.
圖4 全部的 mov 使用的內存地址, 都是經過 rbp 來偏移獲得, rbp 的值並無發生改變.
圖5 執行完 popq %rsp, 恢復 main 函數的棧基址(rbp), 也就是和圖1 同樣.
圖6 執行完 ret 恢復爲 main 函數的棧幀(這裏主要是 rsp, rbp, pc, 我的理解把 pc 視爲棧幀的一部分, 由於函數調用控制權發生轉移, 幕後也離不開 pc 這個寄存器的變化). ret 的做用等價於 popq %rip. 可是沒法直接操做 ip(pc) 這個寄存器. 也就至關於間接改變 ip. 此時 pc 已被 ret 指令恢復成了 X. (此時實際上控制權已經回到 main 函數了), 接下來就是繼續執行 main 函數的代碼. 其實 swap 函數的棧幀已經被銷燬了. 也就是再也訪問不到 swap 函數裏的變量了. 這就是 C 語言裏的所謂的本地變量的本質.
注意: 圖1 和 圖6 , 圖2 和 圖5 徹底同樣, 這不是有意爲之, 按照 X86 的函數調用機制就是這樣的. 在被調用函數(swap)執行 popq % rbp, 這條指令就是要恢復調用函數(main)的rbp, 執行 ret 這條指令就是要恢復調用函數(main)的下一條指令的地址. 也就是將 pc 的值恢復爲 X, 這樣就能夠接着執行了嘛. 也就是所謂的恢復調用者(main)的棧幀. 也就是 main 函數調用 swap 函數(call 指令)保留 main 的狀態(也就是 main 函數的 rbp 和 pc), swap 執行到最後(popq, ret)負責恢復現場(也就是恢復 main 函數的 rbp 和 pc). call 和 ret 指令的也分別有 push %rip 和 pop %rip 的做用. 很對稱的操做!
pushq %rbp // 保留上一個函數(也就是調用者)的棧基址 movq %rsp, %rbp // 新函數的棧基址. 一個新的棧幀 sp 和 bp 指向的是同一個地址
一個所謂的棧幀(stack frame)就是由 sp(stack pointer) 和 bp(base pointer) 這兩個寄存器來維護的.
這兩句會出如今每個函數的開始, 那麼問題來了 main 函數裏面保留的是哪個調用函數的棧基址呢? 我的推測, 不必定正確, 咱們知道建立進程(線程)是 OS 內核的功能, 固然進程銷燬也是內核的功能. 內核一樣維護着屬於內核空間的棧幀, 當進程建立完畢後, 咱們寫的 C 代碼應該是被內核裏的函數調用的, 這樣的話 main 裏面 pushq %rbp 應該是保留的內核函數的棧基址. 這樣 main 的 ret 返回後就能接着執行內核函數裏面的邏輯了. (估計也就是銷燬進程一系列操做了, 這樣才能把分配的資源收回來啊!)