2 第二部分: 高级

第 2 章 1. 指针概要

简单罗列一下 C 的指针用法,便于复习。

1.1 指针常量

指针常量意指 "类型为指针的常量",初始化后不能被修改,固定指向某个内存地址。我们无法修改指针自身的值,但可以修改指针所指目标的内容。

  1. int x[] = { 1, 2, 3, 4 };
  2. int* const p = x;
  3.  
  4. for (int i = 0; i < 4; i++)
  5. {
  6. int v = *(p + i);
  7. *(p + i) = ++v;
  8.  
  9. printf("%d\n", v);
  10.  
  11. //p++; // Compile Error!
  12. }

上例中的指针 p 始终指向数组 x 的第一个元素,和数组名 x 作用相同。由于指针本身是常量,自然无法执行 p++、++p 之类的操作,否则会导致编译错误。

1.2 常量指针

常量指针是说 "指向常量数据的指针",指针目标被当做常量处理 (尽管原目标不一定是常量),不能用通过指针做赋值处理。指针自身并非常量,可以指向其他位置,但依然不能做赋值操作。

  1. int x = 1, y = 2;
  2.  
  3. int const* p = &x;
  4. //*p = 100; ! ! // Compile Error!
  5.  
  6. p = &y;
  7. printf("%d\n", *p);
  8.  
  9. //*p = 100; ! ! // Compile Error!

建议常量指针将 const 写在前面更易识别。

  1. const int* p = &x;

看几种特殊情况: (1) 下面的代码据说在 VC 下无法编译,但 GCC 是可以的。

  1. const int x = 1;
  2. int* p = &x;
  3.  
  4. printf("%d\n", *p);
  5.  
  6. *p = 1234;
  7. printf("%d\n", *p);

(2) const int* p 指向 const int 自然没有问题,但肯定也不能通过指针做出修改。

  1. const int x = 1;
  2. const int* p = &x;
  3.  
  4. printf("%d\n", *p);
  5.  
  6. *p = 1234; ! ! ! // Compile Error!

(3) 声明指向常量的指针常量,这很罕见,但也好理解。

  1. int x = 10;
  2. const int* const p = &i;
  3.  
  4. p++; ! ! ! ! // Compile Error!
  5. *p = 20; ! ! ! // Compile Error!

区别指针常量和常量指针方法很简单:看 const 修饰的是谁,也就是*在 const 的左边还是右边。 - int const p: const 修饰指针变量 p,指针是常量。 - int const p: const 修饰指针所指向的内容 *p,是常量的指针。或写成 const int p。 - const int\ const p: 指向常量的指针常量。右 const 修饰 p 常量,左 const 表明 *p 为常量。

1.3 指针的指针

指针本身也是内存区的一个数据变量,自然也可以用其他的指针来指向它。

  1. int x = 10;
  2. int* p = &x;
  3. int** p2 = &p;
  4.  
  5. printf("p = %p, *p = %d\n", p, *p);
  6. printf("p2 = %p, *p2 = %x\n", p2, *p2);
  7. printf("x = %d, %d\n",*p, **p2);

输出:

  1. p = 0xbfba3e5c, *p = 10
  2. p2 = 0xbfba3e58, *p2 = bfba3e5c
  3. x = 10, 10

我们可以发现 p2 存储的是指针 p 的地址。因此才有了指针的指针一说。

1.4 数组指针

默认情况下,数组名为指向该数组第一个元素的指针常量。

  1. int x[] = { 1, 2, 3, 4 };
  2. int* p = x;
  3.  
  4. for (int i = 0; i < 4; i++)
  5. {
  6. printf("%d, %d, %d\n", x[i], *(x + i), , *p++);
  7. }

尽管我们可以用 *(x + 1) 访问数组元素,但不能执行 x++ / ++x 操作。但 "数组的指针" 和数组名并不是一个类型,数组指针将整个数组当做一个对象,而不是其中的成员(元素)。

  1. int x[] = { 1, 2, 3, 4 };
  2.  
  3. int* p = x;
  4. int (*p2)[] = &x; ! ! // 数组指针
  5.  
  6. for(int i = 0; i < 4; i++)
  7. {
  8. printf("%d, %d\n", *p++, (*p2)[i]);
  9. }

更多详情参考《数组指针》。

1.5 指针数组

元素类型为指针的数组称之为指针数组。

  1. int x[] = { 1, 2, 3, 4 };
  2. int* ps[] = { x, x + 1, x + 2, x + 3 };
  3.  
  4. for(int i = 0; i < 4; i++)
  5. {
  6. printf("%d\n", *(ps[i]));
  7. }

x 默认就是指向第⼀一个元素的指针,那么 x + n 自然获取后续元素的指针。

指针数组通常⽤用来处理交错数组 (Jagged Array,又称数组的数组,不是二维数组),最常见的就是字符串数组了。

  1. void test(const char** x, int len)
  2. {
  3. for (int i = 0; i < len; i++)
  4. {
  5. printf("test: %d = %s\n", i, *(x + i));
  6. }
  7. }
  8.  
  9. int main(int argc, char* argv[])
  10. {
  11. char* a = "aaa";
  12. char* b = "bbb";
  13. char* ss[] = { a, b };
  14.  
  15. for (int i = 0; i < 2; i++)
  16. {
  17. printf("%d = %s\n", i, ss[i]);
  18. }
  19.  
  20. test(ss, 2);
  21.  
  22. return EXIT_SUCCESS;
  23. }

更多详情参考《指针数组》。

1.6 函数指针

默认情况下,函数名就是指向该函数的指针常量。

  1. void inc(int* x)
  2. {
  3. *x += 1;
  4. }
  5.  
  6. int main(void)
  7. {
  8. void (*f)(int*) = inc;
  9.  
  10. int i = 100;
  11. f(&i);
  12. printf("%d\n", i);
  13.  
  14. return 0;
  15. }

如果嫌函数指针的声明不好看,可以像 C# 委托那样定义一个函数指针类型。

  1. typedef void (*inc_t)(int*);
  2.  
  3. int main(void)
  4. {
  5. inc_t f = inc;
  6. ... ...
  7. }

很显然,有了 typedef,下面的代码更易阅读和理解。

  1. inc_t getFunc()
  2. {
  3. return inc;
  4. }
  5.  
  6. int main(void)
  7. {
  8. inc_t inc = getFunc();
  9. ... ...
  10. }

注意: - 定义函数指针类型: typedef void (inc_t)(int) - 定义函数类型: typedef void (inc_t)(int*)

  1. void test()
  2. {
  3. printf("test");
  4. }
  5.  
  6. typedef void(func_t)();
  7. typedef void(*func_ptr_t)();
  8.  
  9. int main(int argc, char* argv[])
  10. {
  11. func_t* f = test;
  12. func_ptr_t p = test;
  13.  
  14. f();
  15. p();
  16.  
  17. return EXIT_SUCCESS;
  18. }

第 2 章 2. 数组指针

注意下面代码中指针的区别。

  1. int x[] = {1,2,3,4,5,6};
  2.  
  3. int *p1 = x; ! // 指向整数的指针
  4. int (*p2)[] = &x; ! // 指向数组的指针

p1 的类型是 int,也就是说它指向一个整数类型。数组名默认指向数组中的第一个元素,因此 x 默认也是 int 类型。

p2 的含义是指向一个 "数组类型" 的指针,注意是 "数组类型" 而不是 "数组元素类型",这有本质上的区别。

数组指针把数组当做一个整体,因为从类型角度来说,数组类型和数组元素类型是两个概念。因此"p2 = &x" 当中 x 代表的是数组本身而不是数组的第一个元素地址,&x 取的是数组指针,而不是"第一个元素指针的指针"。

接下来,我们看看如何用数组指针操作一维数组。

  1. void array1()
  2. {
  3. int x[] = {1,2,3,4,5,6};
  4. int (*p)[] = &x; // 指针 p 指向数组
  5.  
  6. for(int i = 0; i < 6; i++)
  7. {
  8. printf("%d\n", (*p)[i]); // *p 返回该数组, (*p)[i] 相当于 x[i]
  9. }
  10. }

有了上面的说明,这个例子就很好理解了。

"p = &x" 使得指针 p 存储了该数组的指针,p 自然就是获取该数组。那么 (p)[i] 也就等于 x[i]。 注意: p 的目标类型是数组,因此 p++ 指向的不是数组下一个元素,而是 "整个数组之后" 位置 (EA + SizeOf(x)),这已经超出数组范围了。

数组指针对二维数组的操作。

  1. void array2()
  2. {
  3. int x[][4] = {{1, 2, 3, 4}, {11, 22, 33, 44}};
  4. int (*p)[4] = x; !! ! ! ! // 相当于 p = &x[0]
  5.  
  6. for(int i = 0; i < 2; i++)
  7. {
  8. for (int c = 0; c < 4; c++)
  9. {
  10. printf("[%d, %d] = %d\n", i, c, (*p)[c]);
  11. }
  12.  
  13. p++;
  14. }
  15. }

x 是一个二维数组,x 默认指向该数组的第一个元素,也就是 {1,2,3,4}。不过要注意,这第一个元素不是 int,而是一个 int[],x 实际上是 int()[] 指针。因此 "p = x" 而不是 "p = &x",否则 p 就指向 int ()[][] 了。

既然 p 指向第一个元素,那么 p 自然也就是第一行数组了,也就是 {1,2,3,4},(p)[2] 的含义就是第一行的第三个元素。p++ 的结果自然也就是指向下一行。我们还可以直接用 *(p + 1) 来访问 x[1]。

  1. void array2()
  2. {
  3. int x[][4] = {{1, 2, 3, 4}, {11, 22, 33, 44}};
  4. int (*p)[4] = x;
  5.  
  6. printf("[1, 3] = %d\n", (*(p + 1))[3]);
  7. }

我们继续看看 int (*)[][] 的例子。

  1. void array3()
  2. {
  3. int x[][4] = {{1, 2, 3, 4}, {11, 22, 33, 44}};
  4. int (*p)[][4] = &x;
  5.  
  6. for(int i = 0; i < 2; i++)
  7. {
  8. for (int c = 0; c < 4; c++)
  9. {
  10. printf("[%d, %d] = %d\n", i, c, (*p)[i][c]);
  11. }
  12. }
  13. }

这回 "p = &x",也就是说把整个二维数组当成一个整体,因此 *p 返回的是整个二维数组,因此 p++ 也就用不得了。

附: 在附有初始化的数组声明语句中,只有第一维度可以省略。

将数组指针当做函数参数传递。

  1. void test1(p,len)
  2. int(*p)[];
  3. int len;
  4. {
  5. for(int i = 0; i < len; i++)
  6. {
  7. printf("%d\n", (*p)[i]);
  8. }
  9. }
  10.  
  11. void test2(void* p, int len)
  12. {
  13. int(*pa)[] = p;
  14.  
  15. for(int i = 0; i < len; i++)
  16. {
  17. printf("%d\n", (*pa)[i]);
  18. }
  19. }
  20.  
  21. int main (int args, char* argv[])
  22. {
  23. int x[] = {1,2,3};
  24.  
  25. test1(&x, 3);
  26. test2(&x, 3);
  27.  
  28. return 0;
  29. }

由于数组指针类型中有括号,因此 test1 的参数定义看着有些古怪,不过习惯就好了。

第 2 章 3. 指针数组

指针数组是指元素为指针类型的数组,通常用来处理 "交错数组",又称之为数组的数组。和二维数组不同,指针数组的元素只是一个指针,因此在初始化的时候,每个元素只占用4字节内存空间,比二维数组节省。同时,每个元素数组的长度可以不同,这也是交错数组的说法。(在C# 中,二维数组用 [,] 表示,交错数组用 [][])

  1. int main(int argc, char* argv[])
  2. {
  3. int x[] = {1,2,3};
  4. int y[] = {4,5};
  5. int z[] = {6,7,8,9};
  6.  
  7. int* ints[] = { NULL, NULL, NULL };
  8. ints[0] = x;
  9. ints[1] = y;
  10. ints[2] = z;
  11.  
  12. printf("%d\n", ints[2][2]);
  13.  
  14. for(int i = 0; i < 4; i++)
  15. {
  16. printf("[2,%d] = %d\n", i, ints[2][i]);
  17. }
  18.  
  19. return 0;
  20. }

输出:

  1. 8
  2. [2,0] = 6
  3. [2,1] = 7
  4. [2,2] = 8
  5. [2,3] = 9

我们查看一下指针数组 ints 的内存数据。

  1. (gdb) x/3w ints
  2. 0xbf880fd0: 0xbf880fdc 0xbf880fe8 0xbf880fc0
  3.  
  4. (gdb) x/3w x
  5. 0xbf880fdc: 0x00000001 0x00000002 0x00000003
  6.  
  7. (gdb) x/2w y
  8. 0xbf880fe8: 0x00000004 0x00000005
  9.  
  10. (gdb) x/4w z
  11. 0xbf880fc0: 0x00000006 0x00000007 0x00000008 0x00000009

可以看出,指针数组存储的都是目标元素的指针。

那么默认情况下 ints 是哪种类型的指针呢?按规则来说,数组名默认是指向第一个元素的指针,那么第一个元素是什么呢?数组?当然不是,而是一个 int 的指针而已。注意 "ints[0] = x;" 这条语 句,实际上 x 返回的是 &x[0] 的指针 (int),而非 &a 这样的数组指针(int ()[])。继续,ints 取出第一个元素内容 (0xbf880fdc),这个内容又是一个指针,因此 ints 隐式成为一个指针的指针(int**),就交错数组而言,它默认指向 ints[0][0]。

  1. int main(int argc, char* argv[])
  2. {
  3. int x[] = {1,2,3};
  4. int y[] = {4,5};
  5. int z[] = {6,7,8,9};
  6.  
  7. int* ints[] = { NULL, NULL, NULL };
  8. ints[0] = x;
  9. ints[1] = y;
  10. ints[2] = z;
  11.  
  12. printf("%d\n", **ints);
  13. printf("%d\n", *(*ints + 1));
  14. printf("%d\n", **(ints + 1));
  15.  
  16. return 0;
  17. }

输出:

  1. 1
  2. 2
  3. 4

第一个 printf 语句验证了我们上面的说法。我们继续分析后面两个看上去有些复杂的 printf 语句。

(1) (ints + 1) 首先 ints 取出了第一个元素,也就是 ints[0][0] 的指针。那么 "ints + 1" 实际上就是向后移动一 次指针,因此指向 ints[0][1] 的指针。"(ints + 1)" 的结果也就是取出 ints[0][1] 的值了。 (2) (ints + 1) ints 指向第一个元素 (0xbf880fdc),"ints + 1" 指向第二个元素(0xbf880fe8)。"*(ints + 1)" 取 出 ints[1] 的内容,这个内容是另外一只指针,因此 "(ints + 1)" 就是取出 ints[1][0] 的内容。

下面这种写法,看上去更容易理解一些。

  1. int main(int argc, char* argv[])
  2. {
  3. int x[] = {1,2,3};
  4. int y[] = {4,5};
  5. int z[] = {6,7,8,9};
  6.  
  7. int* ints[] = { NULL, NULL, NULL };
  8. ints[0] = x;
  9. ints[1] = y;
  10. ints[2] = z;
  11.  
  12. int** p = ints;
  13.  
  14. // -----------------------------------------------
  15.  
  16. // *p 取出 ints[0] 存储的指针(&ints[0][0])
  17. // **p 取出 ints[0][0] 值
  18. printf("%d\n", **p);
  19.  
  20. // -----------------------------------------------
  21.  
  22. // p 指向 ints[1]
  23. p++;
  24.  
  25. // *p 取出 ints[1] 存储的指针(&ints[1][0])
  26. // **p 取出 ints[1][0] 的值(= 4)
  27. printf("%d\n", **p);
  28.  
  29. // -----------------------------------------------
  30.  
  31. // p 指向 ints[2]
  32. p++;
  33.  
  34. // *p 取出 ints[2] 存储的指针(&ints[2][0])
  35. // *p + 1 返回所取出指针的后一个位置
  36. // *(*p + 1) 取出 ints[2][0 + 1] 的值(= 7)
  37. printf("%d\n", *(*p + 1));
  38.  
  39. return 0;
  40. }

指针数组经常出现在操作字符串数组的场合。

  1. int main (int args, char* argv[])
  2. {
  3. char* strings[] = { "Ubuntu", "C", "C#", "NASM" };
  4.  
  5. for (int i = 0; i < 4; i++)
  6. {
  7. printf("%s\n", strings[i]);
  8. }
  9.  
  10. printf("------------------\n");
  11.  
  12. printf("[2,1] = '%c'\n", strings[2][1]);
  13.  
  14. strings[2] = "CSharp";
  15. printf("%s\n", strings[2]);
  16. printf("------------------\n");
  17.  
  18. char** p = strings;
  19. printf("%s\n", *(p + 2));
  20.  
  21. return 0;
  22. }

输出:

  1. Ubuntu
  2. C
  3. C#
  4. ## NASM[2,1] = '#'
  5. ## CSharpCSharp

main 参数的两种写法。

  1. int main(int argc, char* argv[])
  2. {
  3. for (int i = 0; i < argc; i++)
  4. {
  5. printf("%s\n", argv[i]);
  6. }
  7.  
  8. return 0;
  9. }
  10.  
  11. int main(int argc, char** argv)
  12. {
  13. for (int i = 0; i < argc; i++)
  14. {
  15. printf("%s\n", *(argv + i));
  16. }
  17.  
  18. return 0;
  19. }

当然,指针数组不仅仅用来处理数组。

  1. int main (int args, char* argv[])
  2. {
  3. int* ints[] = { NULL, NULL, NULL, NULL };
  4.  
  5. int a = 1;
  6. int b = 2;
  7.  
  8. ints[2] = &a;
  9. ints[3] = &b;
  10. for(int i = 0; i < 4; i++)
  11. {
  12. int* p = ints[i];
  13. printf("%d = %d\n", i, p == NULL ? 0 : *p);
  14. }
  15.  
  16. return 0;
  17. }

第 2 章 4. 函数调用

先准备一个简单的例子。

源代码

  1. #include <stdio.h>
  2.  
  3.  
  4. int test(int x, char* s)
  5. {
  6. s = "Ubuntu!";
  7. return ++x;
  8. }
  9.  
  10. int main(int args, char* argv[])
  11. {
  12. char* s = "Hello, World!";
  13. int x = 0x1234;
  14.  
  15. int c = test(x, s);
  16. printf(s);
  17.  
  18. return 0;
  19. }

编译 (注意没有使用优化参数):

  1. $ gcc -Wall -g -o hello hello.c

调试之初,我们先反编译代码,并做简单标注。

  1. $ gdb hello
  2.  
  3. (gdb) set disassembly-flavor intel ; 设置反汇编指令格式
  4. (gdb) disass main ; 反汇编 main
  5.  
  6. Dump of assembler code for function main:
  7. 0x080483d7 <main+0>: lea ecx,[esp+0x4]
  8. 0x080483db <main+4>: and esp,0xfffffff0
  9. 0x080483de <main+7>: push DWORD PTR [ecx-0x4]
  10.  
  11. 0x080483e1 <main+10>: push ebp ; main 堆栈帧开始
  12. 0x080483e2 <main+11>: mov ebp,esp ; 修正 ebp 基址
  13. 0x080483e4 <main+13>: push ecx ; 保护寄存器现场
  14. 0x080483e5 <main+14>: sub esp,0x24 ; 预留堆栈帧空间
  15.  
  16. 0x080483e8 <main+17>: mov DWORD PTR [ebp-0x8],0x80484f8 ; 设置变量 s,为字符串地址。
  17. 0x080483ef <main+24>: mov DWORD PTR [ebp-0xc],0x1234 ; 变量 x,内容为内联整数值。
  18.  
  19. 0x080483f6 <main+31>: mov eax,DWORD PTR [ebp-0x8] ; 复制变量 s
  20. 0x080483f9 <main+34>: mov DWORD PTR [esp+0x4],eax ; 将复制结果写入新堆栈位置
  21. 0x080483fd <main+38>: mov eax,DWORD PTR [ebp-0xc] ; 复制变量 x
  22. 0x08048400 <main+41>: mov DWORD PTR [esp],eax ; 将复制结果写入新堆栈位置
  23. 0x08048403 <main+44>: call 0x80483c4 <test> ; 调用 test
  24. 0x08048408 <main+49>: mov DWORD PTR [ebp-0x10],eax ; 保存 test 返回值
  25.  
  26. 0x0804840b <main+52>: mov eax,DWORD PTR [ebp-0x8] ; 复制变量 s 内容
  27. 0x0804840e <main+55>: mov DWORD PTR [esp],eax ; 保存复制结果到新位置
  28. 0x08048411 <main+58>: call 0x80482f8 <printf@plt> ; 调用 printf
  29. 0x08048416 <main+63>: mov eax,0x0 ; 丢弃 printf 返回值
  30.  
  31. 0x0804841b <main+68>: add esp,0x24 ; 恢复 esp 到堆栈空间预留前位置
  32. 0x0804841e <main+71>: pop ecx ; 恢复 ecx 保护现场
  33. 0x0804841f <main+72>: pop ebp ; 修正前一个堆栈帧基址
  34. 0x08048420 <main+73>: lea esp,[ecx-0x4] ; 修正 esp 指针
  35. 0x08048423 <main+76>: ret
  36. End of assembler dump.
  37.  
  38. (gdb) disass test ! ! ! ! ! ! ; 反汇编 test
  39.  
  40. Dump of assembler code for function test:
  41. 0x080483c4 <test+0>: push ebp ; 保存前一个堆栈帧的基址
  42. 0x080483c5 <test+1>: mov ebp,esp ; 修正 ebp 基址
  43. 0x080483c7 <test+3>: mov DWORD PTR [ebp+0xc],0x80484f0 ; 修改参数 s, 是前一堆栈帧地址
  44. 0x080483ce <test+10>: add DWORD PTR [ebp+0x8],0x1 ; 累加参数 x
  45. 0x080483d2 <test+14>: mov eax,DWORD PTR [ebp+0x8] ; 将返回值存入 eax
  46.  
  47. 0x080483d5 <test+17>: pop ebp ; 恢复 ebp
  48. 0x080483d6 <test+18>: ret
  49. End of assembler dump.

我们一步步分析,并用示意图说明堆栈状态。

(1) 在 0x080483f6 处设置断点,这时候 main 完成了基本的初始化和内部变量赋值。

  1. (gdb) b *0x080483f6
  2. Breakpoint 1 at 0x80483f6: file hello.c, line 14.
  3.  
  4. (gdb) r
  5. Starting program: /home/yuhen/Projects/Learn.C/hello
  6. Breakpoint 1, main () at hello.c:14
  7. 14 int c = test(x, s);

我们先记下 ebp 和 esp 的地址。

  1. (gdb) p $ebp
  2. $8 = (void *) 0xbfcb3c78
  3.  
  4. (gdb) p $esp
  5. $9 = (void *) 0xbfcb3c50! # $ebp - $esp = 0x28,不是 0x24?在预留空间前还 "push ecx" 了。
  6.  
  7. (gdb) p x ! ! ! # 整数值直接保存在堆栈
  8. $10 = 4660
  9. (gdb) p &x ! ! ! # 变量 x 地址 = ebp (0xbfcb3c78) - 0xc = 0xbfcb3c6c
  10. $11 = (int *) 0xbfcb3c6c
  11.  
  12. (gdb) p s ! ! ! # 变量 s 在堆栈保存了字符串在 .rodata 段的地址
  13. $12 = 0x80484f8 "Hello, World!"
  14.  
  15. (gdb) p &s ! ! ! # 变量 s 地址 = ebp (0xbfcb3c78) - 0x8 = 0xbfcb3c70
  16. $13 = (char **) 0xbfcb3c70

这时候的堆栈示意图如下:

2 第二部分: 高级 - 图1

(2) 接下来,我们将断点设在 call test 之前,看看调用前堆栈的准备情况。

  1. (gdb) b *0x08048403
  2. Breakpoint 2 at 0x8048403: file hello.c, line 14
  3.  
  4. (gdb) c
  5. Continuing.
  6. Breakpoint 2, 0x08048403 in main () at hello.c:14
  7. 14 int c = test(x, s);

0x08048403 之前的 4 条指令通过 eax 做中转,分别在 [esp+0x4] 和 [esp] 处复制了变量 s、x的内容。

  1. (gdb) x/12w $esp
  2. 0xbfcb3c50: 0x00001234 0x080484f8 0xbfcb3c68 0x080482c4
  3. 0xbfcb3c60: 0xb8081ff4 0x08049ff4 0xbfcb3c88 0x00001234
  4. 0xbfcb3c70: 0x080484f8 0xbfcb3c90 0xbfcb3ce8 0xb7f39775

第 1 行: 复制的变量 x,复制的变量 s,未使用,未使用 第 2 行: 未使用,未使用,未使用,变量 x 第 3 行: 变量 s,ecx 保护值,ebp 保护值,eip 保护值。

2 第二部分: 高级 - 图2

可以和 frame 信息对照着看。

  1. (gdb) info frame
  2.  
  3. Stack level 0, frame at 0xbfcb3c80:
  4. eip = 0x8048403 in main (hello.c:14); saved eip 0xb7f39775
  5. source language c.
  6. Arglist at 0xbfcb3c78, args:
  7. Locals at 0xbfcb3c78, Previous frame's sp at 0xbfcb3c74
  8. Saved registers:
  9. ebp at 0xbfcb3c78, eip at 0xbfcb3c7c

说明: 严格来说堆栈帧(frame)从函数被调用的 call 指令将 eip 入栈开始,而不是我们通常所指修正后的 ebp 位置。以 ebp 为基准纯粹是为了阅读代码方便,本文也以此做示意图。也就是说在 call test 之前,内存当中已经有了两份 s 和 x 。从中我们也看到了 C 函数参数是按照从右到左的方式入栈。

附:这种由调用方负责参数入栈和清理的方式是 C 默认的调用约定 cdecl,调用者除了参数入栈,还负责堆栈清理。相比 stdcall 的好处就是:cdecl 允许方法参数数量不固定。

(3) 在 test 中设置断点,我们看看 test 中的代码对堆栈的影响。

  1. (gdb) b test
  2. Breakpoint 3 at 0x80483c7: file hello.c, line 5.
  3.  
  4. (gdb) c
  5. Continuing.
  6. Breakpoint 3, test (x=4660, s=0x80484f8 "Hello, World!") at hello.c:5
  7. 5 s = "Ubuntu!";

2 第二部分: 高级 - 图3

main 中的 call 指令会先将 eip 的值入栈,以便函数完成时可以恢复调用位置。然后才是跳转到 test 函数地址入口。因此我们在 test 中设置的断点(0x080483c7)中断时,test 堆栈帧中就有了 p_eip 和 p_ebp 两个数据。

  1. (gdb) x/2w $esp
  2. 0xbfcb3c48: 0xbfcb3c78 0x08048408

分别保存了 main ebp 和 main call 后一条指令的 eip 地址。其后的指令直接修改 [ebp+0xc] 内容,使其指向新的字符串 "Ubuntu"。然后累加 [ebp+0x8] 的值,并用 eax 寄存器返回函数结果。

  1. 0x080483c7 <test+3>: mov DWORD PTR [ebp+0xc],0x80484f0
  2. 0x080483ce <test+10>: add DWORD PTR [ebp+0x8],0x1
  3. 0x080483d2 <test+14>: mov eax,DWORD PTR [ebp+0x8]

注意都是直接对 main 栈帧中的复制变量进⾏行操作,并没有在 test 栈帧中开辟存储区域。

  1. (gdb) x/s 0x80484f0
  2. 0x80484f0: "Ubuntu!"

2 第二部分: 高级 - 图4

执行到函数结束,然后再次输出 main 堆栈帧的内容看看。

  1. (gdb) finish ! ! ! # test 执⾏行结束,回到 main frame。
  2. Run till exit from #0 test (x=4660, s=0x80484f8 "Hello, World!") at hello.c:5
  3. 0x08048408 in main () at hello.c:14
  4. 14 int c = test(x, s);
  5. Value returned is $21 = 4661
  6.  
  7. (gdb) p $eip ! ! ! # eip 重新指向 main 中的指令
  8. $22 = (void (*)()) 0x8048408 <main+49>
  9.  
  10. (gdb) x/12xw $esp ! ! # 查看 main 堆栈帧内存
  11. 0xbfcb3c50: 0x00001235 0x080484f0 0xbfcb3c68 0x080482c4
  12. 0xbfcb3c60: 0xb8081ff4 0x08049ff4 0xbfcb3c88 0x00001234
  13. 0xbfcb3c70: 0x080484f8 0xbfcb3c90 0xbfcb3ce8 0xb7f39775

重新查看 main 堆栈帧信息,我们会发现栈顶两个复制变量的值被改变。

(3) 继续执行,查看修改后的变量对后续代码的影响。

当 call test 发生后,其返回值 eax 被保存到 [ebp-0x10] 处,也就是变量 c 的内容。

2 第二部分: 高级 - 图5

继续 "printf(s)",我们会发现和 call test 一样,再次复制了变量 s 到 [esp]。

  1. 0x0804840b <main+52>: mov eax,DWORD PTR [ebp-0x8]
  2. 0x0804840e <main+55>: mov DWORD PTR [esp],eax
  3. 0x08048411 <main+58>: call 0x80482f8 <printf@plt>

很显然,这会覆盖 test 修改的值。我们在 0x08048411 设置断点,查看堆栈帧的变化。

  1. (gdb) b *0x08048411
  2. Breakpoint 4 at 0x8048411: file hello.c, line 15.
  3.  
  4. (gdb) c
  5. Continuing.
  6. Breakpoint 4, 0x08048411 in main () at hello.c:15
  7. 15 printf(s);
  8.  
  9. (gdb) x/12w $esp
  10. 0xbfcb3c50: 0x080484f8 0x080484f0 0xbfcb3c68 0x080482c4
  11. 0xbfcb3c60: 0xb8081ff4 0x08049ff4 0x00001235 0x00001234
  12. 0xbfcb3c70: 0x080484f8 0xbfcb3c90 0xbfcb3ce8 0xb7f39775

从输出的栈内存可以看出,[esp] 和 [ebp-0x8] 值相同,都是指向 "Hello, World!" 的地址。

2 第二部分: 高级 - 图6

由此可见,test 的修改并没有对后续调用造成影响。这也是所谓 "指针本身也是按值传送" 的规则。剩余的工作就是恢复现场等等,在此就不多说废话了。