重新認識二級指針(Pointers to Pointers)

重新認識二級指針(Pointers to Pointers)

https://blog.csdn.net/zero93run/article/details/48321109

四年前(2010年),我寫了一篇關於我自己對於二級指針(Pointers to Pointers)的理解:《深入理解雙指針》。這篇文章在網上一直存在着很大的爭議,後面的評論也有很多質疑的聲音。通過這幾年我對C/C++更加深入的理解,我覺得有必要重新寫一篇對於二級指針(雙指針)的理解。

另外,本章中使用的程序是使用Linux的GCC編譯出來的,所以彙編代碼使用的是AT&T彙編指令,跟windows下使用Intel指令有所不同,詳見AT&T與Intel彙編比較。同時,由於我是用的是64位機器,爲了方便講解32位的程序以及防止編譯器對代碼的優化影響我們對問題的分析,本章所講解的所有代碼編譯選項爲:gcc -m32 -O0。

概述

Pointers to Pointers:二級指針,我之前把它叫做雙指針,比較專業的叫法是二級指針。二級指針是相對一級指針而言的。
二級指針一般用於函數參數傳遞:

addNode(Type** list);   

C語言參數值傳遞

很多C語言書上,對於參數的值傳遞都講解的不是很清楚。對於值傳遞的理解有助於理解我們理解二級指針。

普通變量的值傳遞

先看看一段代碼:


 
  1. 1 #include <unistd.h>

  2. 2 #include <stdio.h>

  3. 3 #include <stdlib.h>

  4. 4

  5. 5 void increase(int value)

  6. 6 {

  7. 7 value = value + 1;

  8. 8 }

  9. 9

  10. 10 int main(int argc, char** argv)

  11. 11 {

  12. 12 int count = 7;

  13. 13 increase(count);

  14. 14 printf("count = %d\n", count);

  15. 15

  16. 16 return 0;

  17. 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. 1 #include <unistd.h>

  2. 2 #include <stdio.h>

  3. 3 #include <stdlib.h>

  4. 4

  5. 5 void increase(int* ptr)

  6. 6 {

  7. 7 *ptr = *ptr + 1;

  8. 8 }

  9. 9

  10. 10 int main(int argc, char** argv)

  11. 11 {

  12. 12 int count = 7;

  13. 13 increase(&count);

  14. 14 printf("count = %d\n", count);

  15. 15 return 0;

  16. 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. 1 #include <unistd.h>

  2. 2 #include <stdio.h>

  3. 3 #include <stdlib.h>

  4. 4 void increase(int* ptr)

  5. 5 {

  6. 6 *ptr = *ptr + 1;

  7. 7 ptr = NULL;

  8. 8 }

  9. 9

  10. 10 int main(int argc, char** argv)

  11. 11 {

  12. 12 int count = 7;

  13. 13 int* countPtr = &count;

  14. 14 increase(countPtr);

  15. 15 printf("count = %d\n", count);

  16. 16 printf("countPtr = %p\n", countPtr);

  17. 17 return 0;

  18. 18 }

運行結果,count = 8,而countPtr則不是NULL。

運用前面的理論,其實很容易分析出問題。一級指針變量,也是一個普通變量,只不過這變量的值是一個內存單元的地址而已。countPtr在傳遞給increase之前,被copy到一個臨時變量中,這個臨時變量的值是一個地址,可以改變這個地址所在內存單元的值,但是無法改變外部的countPtr。

從這個結果可以得出一個結論:一級指針作爲參數傳遞,可以改變外部變量的值,即一級指針所指向的內容,但是卻無法改變指針本身(如countPtr)。

有了上面的理解基礎,其實對於理解二級指針已經很容易了。

對於指針操作,有兩個概念:

  • 引用:對應於C語言中的&取地址操作

Reference

  • 解引用:在C語言中,對應於->操作。

Dereference operator

對於一個普通變量,引用操作,得到的是一級指針。一級指針傳遞到函數內部,雖然這個一級指針的值會copy一份到臨時變量,但是這個臨時變量的內容是一個指針,通過->解引用一個地址可以修改該地址所指向的內存單元的值。

 

Alt Text

 

對於一個一級指針,引用操作,得到一個二級指針。相反,對於一個二級指針解引用得到一級指針,對於一個一級指針解引用得到原始變量。一級指針和二級指針的值都是指向一個內存單元,一級指針指向的內存單元存放的是源變量的值,二級指針指向的內存單元存放的是一級指針的地址。

二級指針一般用在需要修改函數外部指針的情況。因爲函數外部的指針變量,只有通過二級指針解引用得到外部指針變量在內存單元的地址,修改這個地址所指向的內容即可。

我們針對上面的代碼繼續做修改


 
  1. 1 #include <unistd.h>

  2. 2 #include <stdio.h>

  3. 3 #include <stdlib.h>

  4. 4 void increase(int** ptr)

  5. 5 {

  6. 6 **ptr = **ptr + 1;

  7. 7 *ptr = NULL;

  8. 8 }

  9. 9

  10. 10 int main(int argc, char** argv)

  11. 11 {

  12. 12 int count = 7;

  13. 13 int* countPtr = &count;

  14. 14 increase(&countPtr);

  15. 15

  16. 16 printf("count = %d\n", count);

  17. 17 printf("countPtr = %p\n", countPtr);

  18. 18 return 0;

  19. 19 }

這段代碼,運行結果count = 8, countPtr = NULL;

總結

首先,指針變量,它也是一個變量,在內存單元中也要佔用內存空間。一級指針變量指向的內容是普通變量的值,二級指針變量指向的內容是一級指針變量的地址。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章