Linux核心完全註釋 閱讀筆記:3.4、C與彙編程式的相互呼叫
1、C函式呼叫機制
函式呼叫操作包括從一塊程式碼到另一塊程式碼之間的雙向資料傳遞和執行控制轉移。資料傳遞通過函式引數和返回值來進行。另外,我們還需要在進入函式時為函式的區域性變數分配儲存空間,並且在退出函式時收回這部分空間。Intel 80x86 CPU為控制傳遞提供了簡單的指令,而資料的傳遞和區域性變數儲存空間的分配與回收則通過棧操作來實現。
(1)、棧幀結構和控制轉移權方式
大多數CPU上的程式實現使用棧來支援函式呼叫操作。棧被用來傳遞函式引數、儲存返回資訊、臨時儲存暫存器原有值以備恢復以及用來儲存區域性資料。單個函式呼叫操作所使用的棧部分被稱為棧幀(
對於函式A呼叫函式B的情況,傳遞給B的引數包含在A的棧幀中。當A呼叫B時,函式A的返回地址(呼叫返回後繼續執行的指令地址)被壓入棧中,棧中該位置也明確指明瞭A棧幀的結束處。而B的棧幀則從隨後的棧部分開始,即圖中儲存棧指標(ebp)的地方開始。再隨後則用於存放任何儲存的暫存器值以及函式的臨時值。
棧是往低(小)地址方向擴充套件的,而esp指向當前棧頂處的元素。通過使用push和pop指令我們可以把資料壓入棧中或者從棧中彈出。對於沒有指定初始值的資料所需要的儲存空間,我們可以通過把棧指標遞減適當的值來做到。類似的,通過增加棧指標值我們可以回收棧中已分配的空間。
指令CALL和RET用於處理函式呼叫和返回操作。呼叫指令CALL的作用是把返回地址壓入棧中並且跳轉到被呼叫函式開始處執行。返回地址是程式中緊隨呼叫指令CALL後面一條指令的地址。因此當被調函式返回時就會從該位置繼續執行。返回指令RET用於彈出棧頂處的地址並跳轉到該地址處。在使用該指令之前,應該先正確處理棧中內容,使得當前棧指標所指位置內容正是先前CALL指令儲存的返回地址。另外,若返回值是一個整數或者一個指標,那麼暫存器
儘管某一時刻只有一個函式在執行,但我們還是需要確定在一個函式(呼叫者)呼叫其他函式(被呼叫者)時,被呼叫者不會修改或覆蓋掉呼叫者今後要用到的暫存器內容。因此Intel CPU採用了所有函式必須遵守的暫存器用法統一慣例。該慣例指明,暫存器eax、edx和ecx的內容必須由呼叫者自己負責儲存。當函式B被A呼叫時,函式B可以在不用儲存這些暫存器內容的情況下任意使用它們而不會毀壞函式A所需要的任何資料。另外,暫存器ebx、esi和edi的內容則必須由被呼叫者B來保護。當被呼叫者需要使用這些暫存器中的任何一個時,必須首先在棧中儲存其內容,並在退出時恢復這些暫存器的內容。因為呼叫者A(或者一些更高層的函式)並不負責儲存這些暫存器的內容,但可能在以後的操作中還需要用到原先的值。還有暫存器ebp和esp也必須遵守第二個慣例用法,即由被呼叫者來保護。
(2)、函式呼叫舉例
我們觀察下面C程式exch.c中函式呼叫的處理過程。該程式交換兩個變數中的值,並返回它們的差值:
void swap(int *a, int *b)
{
int c;
c = *a; *a = *b; *b = c;
}
int main()
{
int a, b;
a = 16; b = 32;
swap(&a, &b);
return (a-b);
}
其中函式swap()用於交換兩個變數的值。C程式中的主程式main()也是一個函式(將在本章的後面進行說明),它在呼叫了swap()之後返回交換的結果。這兩個函式的棧幀結構如圖3-5所示。可以看出,函式swap()從呼叫者(main())的棧幀中獲取其引數。圖中的位置資訊相對於暫存器ebp中的幀指標。棧幀左邊的數字指出了相對於幀指標的地址偏移值。在象gdb這樣的偵錯程式中,這些數值都用2的補碼錶示,例如,’-4’被表示成’0xfffffffc’,’-12’被表示成’0xfffffff4’。
呼叫者main()的棧幀結構中,包括區域性變數a和b的儲存空間,相對於幀指標位於-4和-8偏移處。由於我們需要為這兩個區域性變數生成地址,因此它們必須儲存在棧中而非簡單的存放在暫存器中。
使用命令”gcc -Wall -S -o exch.s exch.c”可以生成該C語言程式的彙編程式exch.s程式碼,見如下所示(刪除了一些無關的程式碼):
.text
_swap:
pushl %ebp #儲存原ebp值,設定當前函式的幀指標
movl %esp, %ebp
subl $4, %esp #為區域性變數c在棧內分配空間
movl 8(%ebp), %eax #取函式第1個引數,該引數是一個整數型別值的指標
movl (%eax),%ecx #取該指標所指位置的內容,並儲存到區域性變數c中
movl %ecx, -4(%ebp)
movl 8(%ebp), %eax #再次取第1個引數,然後取第2個引數
movl 12(%ebp), %edx
movl (%edx), %ecx #把第2個引數所指內容放到第1個引數所指的位置
movl %ecx, (%eax)
movl 12(%ebp), %eax #再次取第2個引數
movl -4(%ebp), %ecx #然後把區域性變數c中的內容放到這個指標所指位置處
movl %ecx, (%eax)
leave #恢復原ebp、esp值(即movl %ebp,%esp; popl %ebp)
ret
_main:
pushl %ebp #儲存原ebp值,設定當前函式的幀指標
movl %esp, %ebp
subl $8, %esp #為整型區域性變數a和b在棧中分配空間
movl $16, -4(%ebp) #為區域性變數賦初值(a=16,b=32)
movl $32, -8(%ebp)
leal -8(%ebp), %eax #為呼叫swap()函式作準備,取區域性變數b的地址
pushl %eax #作為呼叫的引數並壓入棧中,即先壓入第2個引數
leal -4(%ebp), %eax #再取區域性變數a的地址,作為第1個引數入棧
pushl %eax
call _swap #再呼叫函式swap()
movl -4(%ebp), %eax #取第一個區域性變數a的值,減去第二個變數b的值
subl -8(%ebp), %eax
leave #恢復原ebp、esp值(即movl %ebp,%esp; popl %ebp)
ret
這兩個函式均可以劃分為3個部分:”設定”,初始化棧幀結構;”主體”,執行函式的實際計算操作;”結束”,恢復棧狀態並從函式中返回。
leave指令用於處理棧內容以準備返回,它的作用等價於下面兩個指令:
movl %ebp, %esp #恢復原esp的值(指向棧幀開始處)
popl %ebp #恢復原ebp的值(通常是呼叫者的幀指標)
變數地址壓入棧中的順序正好與函式宣告的引數順序相反。即函式最後一個引數首先壓入棧中,而函式的第1個引數則是最後一個在呼叫函式指令call之前壓入棧中的。
(3)、main也是一個函式
上面提到C程式的主程式main()也是一個函式,這是因為在編譯連結時它將會作為crt0.s彙編程式的函式被呼叫。crt0.s是一個樁(stub)程式,名稱中的”crt”是”C run-time”的縮寫。該程式的目標檔案將被連結在每個使用者執行程式的開始部分,主要用於設定一些初始化全域性變數等。Linux 0.11中crt0.s彙編程式如下所示,其中建立並初始化全域性變數_environ供程式中其他模組使用。
.text
.globl _environ #宣告全域性變數_environ(對應C程式中的environ變數)
__entry: #程式碼入口標號
movl 8(%esp), %eax #取程式的環境變數指標envp並儲存在_environ中
movl %eax, _environ #envp是execve()函式在載入執行檔案時設定的
call _main #呼叫我們的主程式,其返回狀態值在eax暫存器中
pushl %eax #壓入返回值作為exit()函式的引數並呼叫該函式
1: call _exit
jmp 1b #控制應該不會到達這裡,若到達這裡則繼續執行exit()
.data
_environ: #定義變數_environ,為其分配一個長字空間
.long 0
在通常的編譯過程中,我們無需特別指定stub模組crt0.o,但是若想從上面給出的彙編程式手工使用ld從exch.o模組連結產生可執行檔案exch,那麼我們就需要在命令列上特別指明crt0.o這個模組,並且連結的順序應該是”crt0.o、所有程式模組、庫檔案”。
為了使用ELF格式的目標檔案以及建立共享庫模組檔案,現在的gcc編譯器(2.x)已經把這個crt0擴充套件成幾個模組:crt1.0、crti.o、crtbegin.o、crtend.o和crtn.o。這些模組的連結順序為“crt1.0、crti.o、crtbegin.o(crtbeginS.o)、所有程式模組、crtend.o(crtendS.o)、crtn.o、庫模組檔案”。gcc的配置檔案specfile指定了這種連結順序。其中crt1.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程式中。
2、在彙編程式中呼叫C函式
在彙編程式呼叫一個C函式時,程式需要首先按照逆向順序把函式引數壓入棧中,即函式最後(最右邊的)一個引數先入棧,而最左邊的第1個引數在最後呼叫指令之前入棧,如圖3-6所示。然後執行CALL指令去執行被呼叫的函式。在呼叫函式返回後,程式需要再把先前壓入棧中的函式引數清除掉。
在執行CALL指令時,CPU會把CALL指令下一條指令的地址壓入棧中(見圖中EIP)。如果呼叫還涉及到程式碼特權級變化,那麼CPU還會進行堆疊切換,並且把當前堆疊指標、段描述符和呼叫引數壓入新堆疊中。由於Linux核心中只使用中斷門和陷阱門方式處理特權級變化時的呼叫情況,並沒有使用CALL指令來處理特權級變化的情況,因此這裡對特權級變化時的CALL指令使用方式不再進行說明。
彙編中呼叫C函式比較“自由”。只要是在棧中適當位置的內容就都可以作為引數供C函式使用。這裡以圖3-6中具有3個引數的函式呼叫為例,如果我們沒有專門為呼叫函式func()壓入引數就直接呼叫它的話,那麼func()函式仍然會把存放EIP位置以上的棧中其他內容作為自己的引數呼叫。如果我們為呼叫func()而僅僅明確的壓入了第1、第2個引數,那麼func()函式的第3個引數p3就會直接使用p2前的棧中內容。
另外,我們說彙編程式呼叫C函式比較自由的另一個原因是我們可以根本不用CALL指令而採用JMP指令來同樣達到呼叫函式的目的。方法是在引數入棧後人工把下一條要執行的指令地址壓入棧中,然後直接使用JMP指令跳轉到被呼叫函式開始地址處去執行函式。此後,當函式執行完成時就會執行RET指令把我們人工壓入棧中的下一條指令地址彈出,作為函式返回的地址。Linux核心中也有多處用到了這種函式呼叫方法,例如kernel/asm.s程式第62行呼叫執行traps.c中的do_int3()函式的情況。
3、在C程式中調用匯編函式
從C程式中調用匯程式設計序函式的方法與彙編程式中呼叫C函式的原理相同,但Linux核心程式中不常使用。呼叫方法的著重點仍然是對函式引數在棧中位置的確定上。當然,如果呼叫的組合語言程式比較短,那麼可以直接在C程式中使用前面介紹的內聯彙編語句來實現。下面是一個示例,包含兩個函式的彙編程式callee.s如下所示:
#/*
# *By:Ailson Jack
# *Date:2018.08.28
# *Blog:www.only2fire.com
# *Des:本彙編程式利用系統呼叫sys_write()實現顯示函式int mywrite(int fd, char *buf, int count).
# * 函式int myadd(int a, int b, int *res)用於執行a+b=res運算.若函式返回0,則說明溢位.
# *注意:如果在現在的Linux系統(例如RedHat9)下編譯,則請去掉函式名前的下劃線'_'.
# *編譯:as -o callee.o callee.s
#*/
#.code32 #讓as彙編器切換為32位程式碼彙編方式,否則在64位系統下編譯時,編譯的時候可能會出現"invalid instruction suffix for push"的問題
SYSWRITE = 4 #sys_write()系統呼叫號
.globl _mywrite, _myadd
.text
_mywrite:
pushl %ebp
movl %esp, %ebp
pushl %ebx
movl 8(%ebp), %ebx #取呼叫者的第1個引數:檔案描述符 fd
movl 12(%ebp), %ecx #取第2個引數:緩衝區指標
movl 16(%ebp), %edx #取第3個引數:顯示字元數
movl $SYSWRITE, %eax # %eax中放入系統呼叫號4
int $0x80 #執行系統呼叫
popl %ebx
movl %ebp, %esp
popl %ebp
ret
_myadd:
pushl %ebp
movl %esp, %ebp
movl 8(%ebp), %eax #取第1個引數:a
movl 12(%ebp), %edx #取第2個引數:b
xorl %ecx, %ecx # %ecx為0表示計算溢位
addl %eax, %edx #執行加法運算
jo 1f #若溢位 則跳轉
movl 16(%ebp), %eax #取出第3個引數的指標
movl %edx, (%eax) #把計算結果放入指標所指位置處
incl %ecx #沒有發生溢位,於是設定無溢位返回值
1: movl %ecx, %eax # %eax中是函式返回值
movl %ebp, %esp
popl %ebp
ret
該彙編檔案中的第1個函式mywrite()利用系統中斷0x80呼叫系統呼叫sys_write(int fd, char *buf, int count)實現在螢幕上顯示資訊。對應的系統呼叫功能號是4(參見include/unistd.h中的__NR_write),三個引數分別為檔案描述符、顯示緩衝區指標和顯示字元數。在執行int 0x80之前,暫存器%eax中需要放入呼叫功能號(4),暫存器%ebx、%ecx和%edx要按呼叫規定分別存放fd、buf和count。函式mywrite()的呼叫引數個數和用途與sys_write()完全一樣。
第2個函式myadd(int a, int b, int *res)執行加法運算,其中引數res是運算的結果。函式返回值用於判斷是否發生溢位。如果返回值為0表示計算已經發生溢位,結果不可用。否則計算結果將通過res返回給呼叫者。
注意:如果在現在的Linux系統(例如RedHat 9)下編譯callee.s程式,則請去掉函式名前的下劃線’_’。呼叫這兩個函式的C程式caller.c如下所示:
/*
*By:Ailson Jack
*Date:2018.08.28
*Blog:www.only2fire.com
*Des:調用匯編函式mywrite(fd, buf, count)顯示資訊;呼叫myadd(a, b, result)執行加運算.
*如果myadd()返回0,則表示加函式發生溢位.首先顯示開始計算資訊,然後顯示運算結果.
*/
#include <stdio.h>
int main(void)
{
char buf[1024];
int a, b, res;
char *mystr = "Calculating...\r\n";
char *emsg = "Error in adding...\r\n";
a = 5;
b = 10;
mywrite(1, mystr, strlen(mystr));
if(myadd(a, b, &res))
{
sprintf(buf, "The result is %d\r\n", res);
mywrite(1, buf, strlen(buf));
}
else
mywrite(1, emsg, strlen(emsg));
return 0;
}
該函式首先利用匯編函式mywrite()在螢幕上顯示開始計算的資訊“Calculating…”,然後呼叫加法計算彙編函式myadd()對a和b兩個數進行運算,並在第3個引數res中返回計算結果。最後再利用mywrite()函式把格式化過的結果資訊字串顯示在螢幕上。如果函式myadd()返回0,則表示加函式發生溢位,計算結果無效。這兩個檔案的編譯和執行結果如下所示:
# as -o callee.o callee.s
# gcc -o caller caller.c callee.o
# ./caller
注意:上述彙編程式和C程式在我提供的Linux 0.11系統和Ubuntu 14.04的32位系統均可以編譯和執行成功;在Ubuntu 14.04的64位系統中可以編譯,但是執行不成功。在其他的32位版本Linux系統中應該也可以正常的編譯和執行,64位系統可能不行。
上述程式碼例子我已經上傳到雲盤,大家可以自行下載研究,下載地址: 金鑰:。