C函數調用機制(x86的linux環境下)

一 C與彙編程序的相互調用

爲了提高代碼執行效率,內核源代碼中有的地方直接使用了彙編語言編制。這就會涉及在兩種語言編制的程序之間的相互調用問題。本節首先說明C語言函數的調用機制,然後舉例說明兩者函數之間的調用方法。

(一)  C函數調用機制

在Linux內核程序boot/head.s執行完基本初始化操作之後,就會跳轉去執行init/main.c程序。那麼head.s程序是如何把執行控制轉交給init/main.c程序的呢?即彙編程序是如何調用執行C語言程序的?這裏我們首先描述一下C函數的調用機制、控制權傳遞方式,然後說明head.s程序跳轉到C程序的方法。

函數調用操作包括從一塊代碼到另一塊代碼之間的雙向數據傳遞和執行控制轉移。數據傳遞通過函數參數和返回值來進行。另外,我們還需要在進入函數時爲函數的局部變量分配存儲空間,並且在退出函數時收回這部分空間。Intel 80x86 CPU爲控制傳遞提供了簡單的指令,而數據的傳遞和局部變量存儲空間的分配與回收則通過棧操作來實現。

1.棧幀結構和控制轉移權方式

大多數CPU上的程序實現使用棧來支持函數調用操作。棧被用來傳遞函數參數、存儲返回信息、臨時保存寄存器原有值以備恢復以及用來存儲局部數據。單個函數調用操作所使用的棧部分被稱爲棧幀(stack frame)結構,其一般結構如圖3-4所示。棧幀結構的兩端由兩個指針來指定。寄存器ebp通常用做幀指針(frame pointer),而esp則用作棧指針(stack pointer)。在函數執行過程中,棧指針esp會隨着數據的入棧和出棧而移動,因此函數中對大部分數據的訪問都基於幀指針ebp進行。

 
圖3-4  棧中幀結構示意圖

 

對於函數A調用函數B的情況,傳遞給B的參數包含在A的棧幀中。當A調用B時,函數A的返回地址(調用返回後繼續執行的指令地址)被壓入棧中,棧中該位置也明確指明瞭A棧幀的結束處。而B的棧幀則從隨後的棧部分開始,即圖中保存幀指針(ebp)的地方開始。再隨後則用於存放任何保存的寄存器值以及函數的臨時值。

B函數同樣也使用棧來保存不能放在寄存器中的局部變量值。例如由於通常CPU的寄存器數量有限而不能夠存放函數的所有局部數據,或者有些局部變量是數組或結構,因此必須使用數組或結構引用來訪問。另外,C語言的地址操作符"&"被應用到一個局部變量上時,我們就需要爲該變量生成一個地址,即爲變量的地址指針分配一空間。最後,B函數會使用棧來保存調用任何其他函數的參數。

棧是往低(小)地址方向擴展的,而esp指向當前棧頂處的元素。通過使用push和pop指令我們可以把數據壓入棧中或從棧中彈出。對於沒有指定初始值的數據所需要的存儲空間,我們可以通過把棧指針遞減適當的值來做到。類似地,通過增加棧指針值我們可以回收棧中已分配的空間。

指令CALL和RET用於處理函數調用和返回操作。調用指令CALL的作用是把返回地址壓入棧中並且跳轉到被調用函數開始處執行。返回地址是程序中緊隨調用指令CALL後面一條指令的地址。因此當被調函數返回時就會從該位置繼續執行。返回指令RET用於彈出棧頂處的地址並跳轉到該地址處。在使用該指令之前,應該先正確處理棧中內容,使得當前棧指針所指位置內容正是先前CALL指令保存的返回地址。另外,若返回值是一個整數或一個指針,那麼寄存器eax將被默認用來傳遞返回值。

儘管某一時刻只有一個函數在執行,但我們還是需要確定在一個函數(調用者)調用其他函數(被調用者)時,被調用者不會修改或覆蓋調用者今後要用到的寄存器內容。因此Intel CPU 採用了所有函數必須遵守的寄存器用法統一慣例。該慣例指明,寄存器eax、edx和ecx的內容必須由調用者自己負責保存。當函數B被A調用時,函數B可以在不用保存這些寄存器內容的情況下任意使用它們而不會毀壞函數A所需要的任何數據。另外,寄存器ebx、esi和edi的內容則必須由被調用者B來保護。當被調用者需要使用這些寄存器中的任意一個時,必須首先在棧中保存其內容,並在退出時恢復這些寄存器的內容。因爲調用者A(或者一些更高層的函數)並不負責保存這些寄存器內容,但可能在以後的操作中還需要用到原先的值。還有寄存器ebp和esp也必須遵守第二個慣例用法。

2.函數調用舉例

作爲一個例子,我們來觀察下面C程序exch.c中函數調用的處理過程。該程序交換兩個變量中的值,並返回它們的差值。

1 void swap(int * a, int *b)
2 {
3int c;
4c = *a; *a = *b; *b = c;
5 }
6 
7 int main()
8 {
9int a, b;
10a = 16; b = 32;
11swap(&a, &b);
12return (a - b);
13 }

其中函數swap()用於交換兩個變量的值。C程序中的主程序main()也是一個函數(將在下面說明),它在調用了swap()之後返回交換後的結果。這兩個函數的棧幀結構如圖3-5所示。可以看出,函數swap()從調用者main()的棧幀中獲取其參數。圖中的位置信息相對於寄存器ebp中的幀指針。棧幀左邊的數字指出了相對於幀指針的地址偏移值。在像gdb這樣的調試器中,這些數值都用2的補碼錶示。例如,-4被表示成0xFFFFFFFC,-12會被表示成0xFFFFFFF4。

調用者main()的棧幀結構中包括局部變量a和b的存儲空間,相對於幀指針位於-4和-8偏移處。由於我們需要爲這兩個局部變量生成地址,因此它們必須保存在棧中而非簡單地存放在寄存器中。

 
圖3-5  調用函數main和swap的棧幀結構

使用命令"gcc -Wall -S -o exch.s exch.c"可以生成該C語言程序的彙編程序exch.s代碼,如下所示(刪除了幾行與討論無關的僞指令)。

1 .text
2 _swap:
3  pushl %ebp  # 保存原ebp值,設置當前函數的幀指針。
4  movl %esp,%ebp
5  subl $4,%esp  # 爲局部變量c在棧內分配空間。
6  movl 8(%ebp),%eax   # 取函數第1個參數,該參數是一個整數類型值的指針。
7  movl (%eax),%ecx # 取該指針所指位置的內容,並保存到局部變量c中。
8  movl %ecx,-4(%ebp)
9  movl 8(%ebp),%eax # 再次取第1個參數,然後取第2個參數。
10  movl 12(%ebp),%edx
11  movl (%edx),%ecx  # 把第2個參數所指內容放到第1個參數所指的位置。
12  movl %ecx,(%eax)
13  movl 12(%ebp),%eax   # 再次取第2個參數。
14  movl -4(%ebp),%ecx   # 然後把局部變量c中的內容放到這個指針所指位置處。
15  movl %ecx,(%eax)
16  leave   # 恢復原ebp、esp值(即movl %ebp,%esp; popl %ebp;)。
17  ret
18 _main:
19  pushl %ebp    # 保存原ebp值,設置當前函數的幀指針。
20  movl %esp,%ebp
21  subl $8,%esp # 爲整型局部變量a和b在棧中分配空間。
22  movl $16,-4(%ebp) # 爲局部變量賦初值(a=16,b=32)。
23  movl $32,-8(%ebp)
24  leal -8(%ebp),%eax # 爲調用swap()函數作準備,取局部變量b的地址,
25  pushl %eax # 作爲調用的參數並壓入棧中。即先壓入第2個參數。
26  leal -4(%ebp),%eax   # 再取局部變量a的地址,作爲第1個參數入棧。
27  pushl %eax
28  call _swap # 調用函數swap()。
29  movl -4(%ebp),%eax # 取第1個局部變量a的值,減去第2個變量b的值。
30  subl -8(%ebp),%eax
31  leave # 恢復原ebp、esp值(即movl %ebp,%esp; popl %ebp;)。
32  ret

這兩個函數均可以劃分成三個部分:"設置",初始化棧幀結構;"主體",執行函數的實際計算操作;"結束",恢復棧狀態並從函數中返回。對於swap()函數,其設置部分代碼是3~5行。前兩行用來設置保存調用者的幀指針和設置本函數的棧幀指針,第5行通過把棧指針esp下移4字節爲局部變量c分配空間。6~15行是swap函數的主體部分。第6~8行用於取調用者的第1個參數&a,並以該參數作爲地址取所存內容到ecx寄存器中,然後保存到爲局部變量分配的空間中(-4(%ebp))。第9~12行用於取第2個參數&b,並以該參數值作爲地址取其內容放到第1個參數指定的地址處。第13~15行把保存在臨時局部變量c中的值存放到第2個參數指定的地址處。第16~17行是函數結束部分。leave指令用於處理棧內容以準備返回,它的作用等價於下面兩個指令:

movl %ebp,%esp  # 恢復原esp的值(指向棧幀開始處)。
popl %ebp  # 恢復原ebp的值(通常是調用者的幀指針)。

這部分代碼恢復了在進入swap()函數時寄存器esp和ebp的原有值,並執行返回指令ret。

第19~21行是main()函數的設置部分,在保存和重新設置幀指針之後,main()爲局部變量a和b在棧中分配了空間。第22~23行爲這兩個局部變量賦值。從第24~28行可以看出,main()中是如何調用swap()函數的。其中首先使用leal指令(取有效地址)獲得變量b和a的地址並分別壓入棧中,然後調用swap()函數。變量地址壓入棧中的順序正好與函數申明的參數順序相反。即函數最後一個參數首先壓入棧中,而函數的第1個參數則是最後一個在調用函數指令call之前壓入棧中的。第29~30行將兩個已經交換過的數字相減,並放在eax寄存器中作爲返回值。

從以上分析可知,C語言在調用函數時是在堆棧上臨時存放被調函數參數的值,即C語言是傳值類語言,沒有直接的方法可用來在被調用函數中修改調用者變量的值。因此爲了達到修改的目的就需要向函數傳遞變量的指針(即變量的地址)。

3.main()也是一個函數

上面這段彙編程序是使用gcc 1.40編譯產生的,可以看出其中有幾行多餘的代碼。可見當時的gcc編譯器還不能產生最高效率的代碼,這也是爲什麼某些關鍵代碼需要直接使用彙編語言編制的原因之一。另外,上面提到C程序的主程序main()也是一個函數。這是因爲在編譯鏈接時它將會作爲crt0.s彙編程序的函數被調用。crt0.s是一個樁(stub)程序,名稱中的"crt"是"C run-time"的縮寫。該程序的目標文件將被鏈接在每個用戶執行程序的開始部分,主要用於設置一些初始化全局變量等。Linux 0.12中crt0.s彙編程序如下所示。其中已建立並初始化全局變量_environ供程序中的其他模塊使用。

1 .text
2 .globl _environ # 聲明全局變量 _environ(對應C程序中的environ變量)。
3 
4 __entry:   # 代碼入口標號。
5movl 8(%esp), %eax   # 取程序的環境變量指針envp並保存在_environ中。
6movl %eax, _environ   # envp是execve()函數在加載執行文件時設置的。
7call _main  # 調用我們的主程序。其返回狀態值在eax寄存器中。
8pushl %eax   # 壓入返回值作爲exit()函數的參數並調用該函數。
9 1:call _exit
10jmp 1b  # 控制應該不會到達這裏。若到達這裏則繼續執行exit()。
11 .data
12 _environ:   # 定義變量_environ,爲其分配一個長字空間。
13.long 0

通常使用gcc編譯鏈接生成執行文件時,gcc會自動把該文件的代碼作爲第一個模塊鏈接在可執行程序中。在編譯時使用顯示詳細信息選項"-v"就可以明顯地看出這個鏈接操作過程:

[/usr/root]# gcc -v -o exch exch.s
gcc version 1.40
/usr/local/lib/gcc-as -o exch.o exch.s
/usr/local/lib/gcc-ld -o exch /usr/local/
lib/crt0.o exch.o /usr/local/lib/gnulib -lc
/usr/local/lib/gnulib
[/usr/root]#

因此在通常的編譯過程中,我們無需特別指定stub模塊crt0.o,但是若想根據上面給出的彙編程序手工使用ld(gld)從exch.o模塊鏈接產生可執行文件exch,那麼就需要在命令行上特別指明crt0.o這個模塊,並且鏈接的順序應該是crt0.o、所有程序模塊、庫文件。

爲了使用ELF格式的目標文件以及建立共享庫模塊文件,現在的gcc編譯器(2.x)已經把這個crt0擴展成幾個模塊:crt1.o、crti.o、crtbegin.o、crtend.o和crtn.o。這些模塊的鏈接順序爲crt1.o、crti.o、crtbegin.o(crtbeginS.o)、所有程序模塊、crtend.o(crtendS.o)、crtn.o、庫模塊文件。gcc的配置文件specfile指定了這種鏈接順序。其中,ctr1.o、crti.o和crtn.o由C庫提供,是C程序的"啓動"模塊;crtbegin.o和crtend.o是C++語言的啓動模塊,由編譯器gcc提供;而crt1.o則與crt0.o的作用類似,主要用於在調用main()之前做一些初始化工作,全局符號_start就定義在這個模塊中。

crtbegin.o和crtend.o主要用於C++語言,在.ctors和.dtors區中執行全局構造(constructor)和析構(destructor)函數。crtbeginS.o和crtendS.o的作用與前兩者類似,但用於創建共享模塊中。crti.o用於在.init區中執行初始化函數init()。.init區中包含進程的初始化代碼,即當程序開始執行時,系統會在調用main()之前先執行.init中的代碼。crtn.o則用於在.fini區中執行進程終止退出處理函數fini()函數,即當程序正常退出時(main()返回之後),系統會安排執行.fini中的代碼。

boot/head.s程序中第136~140行就是用於爲跳轉到init/main.c中的main()函數做準備工作。第139行上的指令在棧中壓入了返回地址,而第140行則壓入了main()函數代碼的地址。當head.s最後在第218行上執行ret指令時就會彈出main()的地址,並把控制權轉移到init/main.c程序中。

 

(二)  在彙編程序中調用C函數

從彙編程序中調用C語言函數的方法實際上在上面已經給出。在上面C語言例子對應的彙編程序代碼中,我們可以看出彙編程序語句是如何調用swap()函數的。現在我們對調用方法作一總結。

在彙編程序調用一個C函數時,程序需要首先按照逆向順序把函數參數壓入棧中,即函數最後(最右邊的)一個參數先入棧,而最左邊的第1個參數在最後調用指令之前入棧,如圖3-6所示。然後執行CALL指令去執行被調用的函數。在調用函數返回後,程序需要再把先前壓入棧中的函數參數清除掉。

 
圖3-6  調用函數時壓入堆棧的參數

在執行CALL指令時,CPU會把CALL指令的下一條指令的地址壓入棧中(見圖3-6中的EIP)。如果調用還涉及代碼特權級變化,那麼CPU會進行堆棧切換,並且把當前堆棧指針、段描述符和調用參數壓入新堆棧中。由於Linux內核中只使用中斷門和陷阱門方式處理特權級變化時的調用情況,並沒有使用CALL指令來處理特權級變化的情況,因此這裏對特權級變化時的CALL指令使用方式不再進行說明。

彙編中調用C函數比較"自由",只要是在棧中適當位置的內容就都可以作爲參數供C函數使用。這裏仍然以圖3-6中具有3個參數的函數調用爲例,如果我們沒有專門爲調用函數func()壓入參數就直接調用它的話,那麼func()函數仍然會把存放EIP位置以上的棧中其他內容作爲自己的參數使用。如果我們爲調用func()而僅僅明確地壓入了第1、第2個參數,那麼func()函數的第3個參數p3就會直接使用p2前的棧中內容。在Linux 0.1x內核代碼中就有幾處使用了這種方式。例如在kernel/sys_call.s彙編程序中第231行上調用copy_process()函數(kernel/fork.c中第68行)的情況。在彙編程序函數_sys_fork中雖然只把5個參數壓入了棧中,但是copy_process()卻帶有多達17個參數(見下面的程序)。

// kernel/sys_call.s彙編程序_sys_fork部分。
226push %gs
227pushl %esi
228pushl %edi
229pushl %ebp
230pushl %eax
231call _copy_process # 調用C函數copy_process()(kernel/fork.c,68)。
232addl $20,%esp   # 丟棄這裏所有壓棧內容。
233 1:  ret
// kernel/fork.c程序。
68 int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
69  long ebx,long ecx,long edx, long orig_eax, 
70  long fs,long es,long ds,
71 long eip,long cs,long eflags,long esp,long ss)

我們知道,參數越是最後入棧,越是靠近C函數參數左側。因此實際上調用copy_process()函數之前入棧5個寄存器值就是copy_process()函數的最左面的5個參數。按順序它們分別對應爲入棧的eax(nr)、ebp、edi、esi和寄存器gs的值。而隨後的其餘參數實際上直接對應堆棧上已有的內容。這些內容是從進入系統調用中斷處理過程開始,直到調用本系統調用處理過程時逐步入棧的各寄存器的值。

參數none是sys_call.s程序第99行上利用地址跳轉表sys_call_table[](定義在include/linux/ sys.h,93行)調用_sys_fork時的下一條指令的返回地址值。隨後的參數是剛進入system_call時在85~91行壓入棧的寄存器ebx、ecx、edx、原eax和段寄存器fs、es、ds。最後5個參數是CPU執行中斷指令壓入返回地址eip和cs、標誌寄存器eflags、用戶棧地址esp和ss。因爲系統調用涉及程序特權級變化,所以CPU會把標誌寄存器值和用戶棧地址也壓入堆棧。在調用C函數copy_process()返回後,_sys_fork也只把自己壓入的5個參數丟棄掉,棧中其他值均保存着。其他採用上述用法的函數還有kernel/signal.c中的do_signal()、fs/exec.c中的do_execve()等,請讀者自行分析。

另外,我們說彙編程序調用C函數比較自由的另一個原因是我們可以根本不用CALL指令而採用JMP指令來同樣達到調用函數的目的。方法是在參數入棧後把下一條要執行的指令地址人工壓入棧中,然後直接使用JMP指令跳轉到被調用函數開始地址處去執行函數。此後當函數執行完成時就會執行RET指令,把人工壓入棧中的下一條指令地址彈出,作爲函數返回的地址。Linux內核中也有多處用到了這種函數調用方法,例如kernel/asm.s程序第62行調用執行traps.c中的do_int3()函數的情況。



http://blog.csdn.net/yyt7529/article/details/4257717

發佈了34 篇原創文章 · 獲贊 19 · 訪問量 31萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章