根據所用編譯器和CPU的不同,以及返回值數據類型的不同,C語言中的函數返回值可能通過寄存器傳遞,也可能通過棧傳遞。對大多數CPU和編譯器來說,出于性能考慮,能使用寄存器傳遞的,盡量使用寄存器傳遞,只有當寄存器不夠用的時候,才會通過棧傳遞。
針對這兩種情況,我分別舉個x64 + GCC環境下的例子來說明。
通過寄存器傳遞返回值
如下圖中的一段簡單的代碼,返回值是一個有符號整數類型
我們看下x64/GCC下面對應的匯編代碼:
test函數中的
1129: mov $0x2,%eax
便是把返回值2存放到eax寄存器中。而main函數中的
113d: callq 1125 <test>
1142:mov %eax,-0x4(%rbp)
則先調用test函數,然后把返回值從eax中取出,并存放到rbp - 4的地址處,也就是賦值給局部變量a。
通過棧傳遞返回值
下面這個例子中,test()函數返回一個結構體struct result。(注:這里只是為了演示用棧傳遞返回值,實際項目中不建議函數直接返回結構體,可以用結構體指針代替)
(這個例子第一眼看上去會有些許復雜,千萬不要懵逼,匯編代碼不是洪水猛獸,掌握一些基本的匯編代碼對修煉內功、調試問題都是大有裨益的:)
在x64/GCC環境下的匯編代碼如下:
先看main()函數:
我們先看main()函數中調用test()的幾條指令:
ret = test();
11dd: lea -0x50(%rbp), %rax
11e1: mov %rax, %rdi
11e4: mov $0x0, %eax
11e9: callq 1135 <test>
11dd和11e1兩條指令的作用是把棧地址rbp - 0x50存放到rdi寄存器中,我們暫且不去管這個地址是用來做什么的,等看了test()函數之后自然就會明白。后面兩條指令是把eax清零,然后調用test()函數。
test()函數的匯編代碼如下:
test()的匯編看起來是不是有點復雜呢?不要緊張,其實做的事情很簡單,就是給局部變量r分配棧空間,然后對它進行初始化,然后把r的值存放到一個內存地址當中,最后把這個內存地址放到rax寄存器中,并返回出去。我們仔細分析一下:
1139: mov %rdi, -0x28(%rbp)
這條指令是把rdi寄存器的值存放到棧空間rbp - 0x28的地址處。還記得rdi寄存器中存放的是什么嗎?回想一下,在main()函數調用test()函數之前,是不是把一個地址存放到rdi寄存器中了呢?忘了的話,再去看一下。我們先不管這個值用來做什么,只要記得,test()函數把main()函數傳遞過來的一個值存放到了一個棧地址當中。
接下來的這幾條指令,就是對局部變量r進行初始化:
struct result r = {1, 2, 3, 4};
113d: movq $0x1, -0x20(%rbp)
1145: movq $0x2, -0x18(%rbp)
114d: movq $0x3, -0x10(%rbp)
1155: movq $0x4, -0x8(%rbp)
下面就要把r的值返回出去了,我們來看看編譯器是怎么做的,先看這幾條指令:
return r;
115d: mov -0x28(%rbp), %rcx
1161: mov -0x20(%rbp), %rax
1165: mov -0x18(%rbp), %rdx
1169: mov %rax, (%rcx)
116c: mov %rdx, 0x8(%rcx)
115d這條指令,是把棧中rbp-0x28處的值放到rcx寄存器中,還記得這個地址存放的值是什么嗎?對了,就是test()入口處從rdi中取出來的那個值,也就是main()函數通過rdi寄存器傳遞給test()的一個值。然后,1161和1169兩條指令把r.a值存放到rcx寄存器指向的地址處,1165和116c兩條指令把r.b的值存放到rcx寄存器指向的地址再偏移8的位置處。
現在我們再來回過頭想一下,main()函數通過rdi寄存器傳遞給test()函數的那個值是用來做什么的呢?對了,那個值其實就是存放test()函數返回值的那塊內存的地址。
那么記下來的幾條指令就比較容易理解了:
1170: mov -0x10(%rbp), %rax
1174: mov -0x8(%rbp), %rdx
1178: mov %rax, 0x10(%rcx)
117c: mov %rdx, 0x18(%rcx)
1170和1178把r.c存放到rcx + 0x10地址處,1174和117c把r.d存放到rcx + 0x18地址處。
到這里為止,test()函數已經把局部變量struct result r的所有字段的值全部存放到main()函數通過rdi寄存器傳遞給test()的那個內存地址中。
最后,看一下剩下的幾條指令:
1180: mov -0x28(%rbp), %rax
1184: pop %rbp
1185: retq
1180指令把rbp - 0x28處的值rax中,也就是把存放返回值的那塊內存的地址,存放到rax寄存器中,最后返回出去。
到這里,是不是清晰多了呢?我們再來總結一下這個過程:
- main()函數把一個棧空間中的地址rbp - 0x30通過rdi寄存器傳遞給test()函數
- test()函數從rdi寄存器中取得這個地址,然后把要返回的值存放到這個地址指向的內存中
- test()把這個地址存放到rax寄存器中,并返回給main()函數
掌握一定匯編知識的重要性
可能對于很多童鞋來說,匯編語言比較晦澀難懂,難以掌握。確實,作為一個最為接近機器語言的編程語言來說,匯編確實比較晦澀,除了一些做底層系統軟件的童鞋外,日常工作中直接用匯編寫代碼的機會確實不多,但是,這并不意味著掌握匯編語言就毫無用處。
掌握一定的匯編知識,會對整個計算機的原理和體系結構有更深入的理解,很多東西都能夠知其然并知其所以然。尤其那些對底層系統軟件感興趣的童鞋,如BIOS/bootloader、OS內核、設備驅動、編譯器、虛擬機等,匯編語言更是必須要掌握的。有些做上層應用的童鞋,如前端開發等,平時用到匯編的機會不多,但是在調試一些問題的時候,如果能夠了解一些匯編知識,就會如虎添翼,事半功倍。
總之,不管所用的開發語言是C/C++還是Java、Python、PHP、Javascript,不管是做系統軟件開發,還是做前端開發,只要是有志于干程序員這一行當的,掌握一定的匯編,對完善自己的技術知識體系,增強自己調試問題的能力,和對計算機體系結構的理解都大有裨益。
思考題
能堅持讀到這里,我想你已經基本清楚C語言的函數返回值是怎么傳遞的了。
那么,不妨思考一下,C語言的函數參數又是怎么傳遞的呢?