本章中使用的程序是使用Linux的GCC編譯出來的,所以匯編代碼使用的是AT&T匯編指令,跟windows下使用Intel指令有所不同,詳見AT&T與Intel匯編比較。同時,由於我是用的是64位機器,為了方便講解32位的程序以及防止編譯器對代碼的優化影響我們對問題的分析,本章所講解的所有代碼編譯選項為:gcc -m32 -O0。
概述
Pointers to Pointers:二級指針,我之前把它叫做雙指針,比較專業的叫法是二級指針。二級指針是相對一級指針而言的。
二級指針一般用於函數參數傳遞:
addNode(Type** list);
C語言參數值傳遞
很多C語言書上,對於參數的值傳遞都講解的不是很清楚。對於值傳遞的理解有助於理解我們理解二級指針。
普通變量的值傳遞
先看看一段代碼:
1 #include <unistd.h>
2 #include <stdio.h>
3 #include <stdlib.h>
4
5 void increase(int value)
6 {
7 value = value + 1;
8 }
9
10 int main(int argc, char** argv)
11 {
12 int count = 7;
13 increase(count);
14 printf("count = %d\n", count);
15
16 return 0;
17 }
這段代碼對應的匯編代碼如下:
080483e4 <increase>:
80483e4: 55 push %ebp
80483e5: 89 e5 mov %esp,%ebp
80483e7: 83 45 08 01 addl $0x1,0x8(%ebp)
80483eb: 5d pop %ebp
80483ec: c3 ret
080483ed <main>:
80483ed: 55 push %ebp
80483ee: 89 e5 mov %esp,%ebp
80483f0: 83 e4 f0 and $0xfffffff0,%esp
80483f3: 83 ec 20 sub $0x20,%esp
80483f6: c7 44 24 1c 07 00 00 movl $0x7,0x1c(%esp)
80483fd: 00
80483fe: 8b 44 24 1c mov 0x1c(%esp),%eax
8048402: 89 04 24 mov %eax,(%esp)
8048405: e8 da ff ff ff call 80483e4 <increase>
//[...]
這段代碼執行的結果 count = 7。 我是用gdb調試,打印ESP和count的地址如下:
(gdb) p $esp
$2 = (void *) 0xffffd2b0
(gdb) p &count
$3 = (int *) 0xffffd2cc
main函數內部的匯編如下:
sub $0x20,%esp #esp-0x20,棧向下生長0x20,用來存放局部變量
#在內存單元esp + 0x1c處存放7.
#即count,我上面打印的 $3 - #2 = 0x1c.
movl $0x7,0x1c(%esp)
mov 0x1c(%esp),%eax #將內存單元0x1c即count變量的值copy到EAX寄存器中
mov %eax,(%esp) #copy count變量的內容到當前的ESP寄存器所指向的內存單元
call 80483e4 <increase> #調用increase函數
在我的機器上當前運行的ESP指針指向的內存單元是0xffffd2b0,棧向下生長了0x20,則當前棧桢(Stack Frame)的起始地址是0xffffd2b0到0xffffd2d0。count是局部變量,占用的是棧空間,上面gdb打印出來count的地址0xffffd2cc,正好落在main函數的棧桢內。
有一點需要注意的是,在increase調用之前,count變量被copy了一份放在當前ESP所指向內存單元0xffffd2b0,這個count就是為了用來傳遞參數用的。
接下來看看increase的匯編代碼:
push %ebp #ebp壓棧,保護上一個棧桢
mov %esp,%ebp #保護ESP
addl $0x1,0x8(%ebp) #將copy出來的那個count變量+1
pop %ebp
ret
increase的匯編代碼比較簡單,這裡只需要解釋下addl $0x1,0x8(%ebp)。
由前面一句mov %esp,%ebp可以發現,此時EBP其實是指向棧頂。調用increase之前ESP是0xffffd2b0,由於調用increase需要將下一條IP指令壓棧,則ESP = ESP - 0x04 = 0xffffd2ac。在進入increase之後,又執行了一句push %ebp,ESP = 0xffffd2ac - 0x04 = 0xffffd2a8。那麼此時棧頂就是0xffffd2a8,EBP的內容就是0xffffd2a8。0x8(%ebp)表示的是EBP + 0x8處的內存單元:0xffffd2a8 + 8 = 0xffffd2b0出的內存單元。
addl $0x1,0x8(%ebp)這句匯編就是在內存單元0xffffd2b0處的內容加+1,最終將加一後的結果繼續存放在0xffffd2b0處 。再回顧下,前面0xffffd2b0存放的內容:沒錯,就是copy出來的count。
看到這裡,你會發現,在count傳遞到increase之後,一直都是在操作copy出來的那個count臨時變量,而沒有操作真正的count變量。可見,對於普通變量而言,參數的值傳遞就意味著只是簡單的將變量copy了一份傳遞給函數,普通變量是無法改變外部原始變量的值。
指針的值傳遞(一級指針)
還是先看代碼:
1 #include <unistd.h>
2 #include <stdio.h>
3 #include <stdlib.h>
4
5 void increase(int* ptr)
6 {
7 *ptr = *ptr + 1;
8 }
9
10 int main(int argc, char** argv)
11 {
12 int count = 7;
13 increase(&count);
14 printf("count = %d\n", count);
15 return 0;
16 }
這段代碼對應的匯編代碼如下:
080483e4 <increase>:
80483e4: 55 push %ebp
80483e5: 89 e5 mov %esp,%ebp
80483e7: 8b 45 08 mov 0x8(%ebp),%eax
80483ea: 8b 00 mov (%eax),%eax
80483ec: 8d 50 01 lea 0x1(%eax),%edx
80483ef: 8b 45 08 mov 0x8(%ebp),%eax
80483f2: 89 10 mov %edx,(%eax)
80483f4: 5d pop %ebp
80483f5: c3 ret
080483f6 <main>:
80483f6: 55 push %ebp
80483f7: 89 e5 mov %esp,%ebp
80483f9: 83 e4 f0 and $0xfffffff0,%esp
80483fc: 83 ec 20 sub $0x20,%esp
80483ff: c7 44 24 1c 07 00 00 movl $0x7,0x1c(%esp)
8048406: 00
8048407: 8d 44 24 1c lea 0x1c(%esp),%eax
804840b: 89 04 24 mov %eax,(%esp)
804840e: e8 d1 ff ff ff call 80483e4 <increase>
// [...]
這段代碼的執行結果是8。
這段代碼跟上一段代碼的唯一區別是將count的地址傳遞給increase函數了。
main函數的匯編代碼
push %ebp
mov %esp,%ebp
and $0xfffffff0,%esp
sub $0x20,%esp
movl $0x7,0x1c(%esp)
lea 0x1c(%esp),%eax #將count變量的地址賦值給EAX
mov %eax,(%esp)
call 80483e4 <increase>
跟前面的main函數的唯一區別是lea 0x1c(%esp),%eax
看懂這段代碼首先要補習下lea指令。lea指令跟mov指令很相似,區別在於lea類似於C語言中的&取地址。那麼lea操作也只是簡單的針對地址做加法而已,而不會針對這個地址單元取操作數。
那麼這代碼在調用increase函數之前,當前ESP所指向的內存單元的值是count變量的地址。而上一段代碼在調用increase之前,當前ESP所指向的內存單元的值是count臨時變量的值。
我們再來看看increase函數的匯編代碼
push %ebp
mov %esp,%ebp
mov 0x8(%ebp),%eax #前面已經講過了
# 取出EAX所指向的內存單元的值賦值給EAX
# 也就是說執行此句話之後,EAX的內容是
# count變量的值,而不是地址。
mov (%eax),%eax
lea 0x1(%eax),%edx #將EAX的內容加一,將加一後的結果存放到EDX
mov 0x8(%ebp),%eax #重新將count變量的地址賦值給EAX
#將EDX的內容存放到EAX所指向的內存單元
#就是將加一後的結果重新賦值給main函數裡的count變量
mov %edx,(%eax)
pop %ebp
ret
理解這段匯編代碼,需要記住一點,在調用increase之前,棧頂ESP所指向的內存單元的值是count變量的地址。之後,經過壓棧IP,進入increase函數,再壓棧EBP。則0x8(%ebp),EBP + 0x8表示的就是在調用increase前,棧頂所指向的內存單元,裡面存放的是count變量的地址。也就是說mov 0x8(%ebp),%eax之後,EAX的內容就是count變量的地址。緊接著mov (%eax),%eax是現將EAX指向的內存單元的內容取出來存放到EAX中,此時EAX寄存器的內容已經不是地址了,而直接是count變量的值。然後對其做加一操作,存放到EDX當中。
下面是最關鍵的兩句話:
mov 0x8(%ebp),%eax
mov %edx,(%eax)
由於EBP + 0x8裡面放的是count變量的地址,mov 0x8(%ebp),%eax之後,EAX中存放的就是count變量的地址。
EDX存放的是前面計算的結果,最後mov %edx,(%eax),將前面計算的結果重新存放到EAX所指向的內存單元,即重新給count變量賦值。
看到這裡,你會發現,函數參數值傳遞,對於指針變量來說,也只是僅僅傳遞了一個內存地址,然後對這個內存地址進行操作。由於內存地址是進程級別的,所以,在函數內部 ,對地址所指向內容的修改,是可以帶到函數外部的,是可以操作到函數外面的源變量的。
二級指針
我們改造下上面的代碼
1 #include <unistd.h>
2 #include <stdio.h>
3 #include <stdlib.h>
4 void increase(int* ptr)
5 {
6 *ptr = *ptr + 1;
7 ptr = NULL;
8 }
9
10 int main(int argc, char** argv)
11 {
12 int count = 7;
13 int* countPtr = &count;
14 increase(countPtr);
15 printf("count = %d\n", count);
16 printf("countPtr = %p\n", countPtr);
17 return 0;
18 }
運行結果,count = 8,而countPtr則不是NULL。
運用前面的理論,其實很容易分析出問題。一級指針變量,也是一個普通變量,只不過這變量的值是一個內存單元的地址而已。countPtr在傳遞給increase之前,被copy到一個臨時變量中,這個臨時變量的值是一個地址,可以改變這個地址所在內存單元的值,但是無法改變外部的countPtr。
從這個結果可以得出一個結論:一級指針作為參數傳遞,可以改變外部變量的值,即一級指針所指向的內容,但是卻無法改變指針本身(如countPtr)。
有了上面的理解基礎,其實對於理解二級指針已經很容易了。
對於指針操作,有兩個概念:
•引用:對應於C語言中的&取地址操作
Reference
•解引用:在C語言中,對應於->操作。
Dereference operator
對於一個普通變量,引用操作,得到的是一級指針。一級指針傳遞到函數內部,雖然這個一級指針的值會copy一份到臨時變量,但是這個臨時變量的內容是一個指針,通過->解引用一個地址可以修改該地址所指向的內存單元的值。
Alt Text
對於一個一級指針,引用操作,得到一個二級指針。相反,對於一個二級指針解引用得到一級指針,對於一個一級指針解引用得到原始變量。一級指針和二級指針的值都是指向一個內存單元,一級指針指向的內存單元存放的是源變量的值,二級指針指向的內存單元存放的是一級指針的地址。
二級指針一般用在需要修改函數外部指針的情況。因為函數外部的指針變量,只有通過二級指針解引用得到外部指針變量在內存單元的地址,修改這個地址所指向的內容即可。
我們針對上面的代碼繼續做修改
1 #include <unistd.h>
2 #include <stdio.h>
3 #include <stdlib.h>
4 void increase(int** ptr)
5 {
6 **ptr = **ptr + 1;
7 *ptr = NULL;
8 }
9
10 int main(int argc, char** argv)
11 {
12 int count = 7;
13 int* countPtr = &count;
14 increase(&countPtr);
15
16 printf("count = %d\n", count);
17 printf("countPtr = %p\n", countPtr);
18 return 0;
19 }
這段代碼,運行結果count = 8, countPtr = NULL;
總結
首先,指針變量,它也是一個變量,在內存單元中也要占用內存空間。一級指針變量指向的內容是普通變量的值,二級指針變量指向的內容是一級指針變量的地址。