转载:http://www.fenesky.com/blog/2014/07/03/pointers-to-pointers.html
本章中使用的程序是使用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 void increase(int value) 5 { 6 value = value + 1; 7 } 8 9 int main(int argc, char** argv) 10 { 11 int count = 7; 12 increase(count); 13 printf("count = %d\n", count); 14 15 return 0; 16 }
这段代码对应的汇编代码如下:
1 080483e4 <increase>: 2 80483e4: 55 push %ebp 3 80483e5: 89 e5 mov %esp,%ebp 4 80483e7: 83 45 08 01 addl $0x1,0x8(%ebp) 5 80483eb: 5d pop %ebp 6 80483ec: c3 ret 7 8 080483ed <main>: 9 80483ed: 55 push %ebp 10 80483ee: 89 e5 mov %esp,%ebp 11 80483f0: 83 e4 f0 and $0xfffffff0,%esp 12 80483f3: 83 ec 20 sub $0x20,%esp 13 80483f6: c7 44 24 1c 07 00 00 movl $0x7,0x1c(%esp) 14 80483fd: 00 15 80483fe: 8b 44 24 1c mov 0x1c(%esp),%eax 16 8048402: 89 04 24 mov %eax,(%esp) 17 8048405: e8 da ff ff ff call 80483e4 <increase> 18 //[...]
这段代码执行的结果 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语言中的&取地址操作
- 解引用:在C语言中,对应于->操作。
对于一个普通变量,引用操作,得到的是一级指针。一级指针传递到函数内部,虽然这个一级指针的值会copy一份到临时变量,但是这个临时变量的内容是一个指针,通过->解引用一个地址可以修改该地址所指向的内存单元的值。
对于一个一级指针,引用操作,得到一个二级指针。相反,对于一个二级指针解引用得到一级指针,对于一个一级指针解引用得到原始变量。一级指针和二级指针的值都是指向一个内存单元,一级指针指向的内存单元存放的是源变量的值,二级指针指向的内存单元存放的是一级指针的地址。
二级指针一般用在需要修改函数外部指针的情况。因为函数外部的指针变量,只有通过二级指针解引用得到外部指针变量在内存单元的地址,修改这个地址所指向的内容即可。
我们针对上面的代码继续做修改
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;
总结
首先,指针变量,它也是一个变量,在内存单元中也要占用内存空间。一级指针变量指向的内容是普通变量的值,二级指针变量指向的内容是一级指针变量的地址。