Logo
Section 1 / 6

编写函数指针

学习代码如何被编译和加载到内存中,以及如何编写函数指针。

导入

编写函数指针

学习代码如何被编译和加载到内存中,以及如何编写函数指针。

加载和执行代码

当我们把代码写在源文件中时,必须使用编译器来编译该源文件,从而生成一个二进制文件。

探索将源文件转换为可执行文件的步骤非常有趣:

  1. 预处理器阶段会对源文件进行文本替换(移除注释、处理 #include 语句、展开宏)。我们不深入讨论这一步,因为它对当前分析并非关键。需要注意的是,这一步是在编译器内部完成的。
  2. 编译器接收预处理后的源文件,并将其转换为汇编语言,这是一种低级语言。
  3. 汇编器将汇编语言文件转换为机器码(CPU 可以理解的指令),得到一个称为目标文件的二进制文件。
  4. 我们的代码通常依赖系统库或其他第三方库或目标文件来实现功能。为了将这些文件与我们的目标文件(第3步获得)链接在一起,我们需要链接器。 链接器通过将我们的目标文件与所需的库进行链接,解决所有依赖关系。
    • 例如,要在屏幕上输出文字,我们可以使用 printf 函数。
    • 这个函数并未在我们的代码中实现,而是在系统库中。
    • 第3步生成的目标文件只包含对该函数的引用,而不是其实际代码。此时,printf 的代码尚不可知。
    • 链接器会解析这个对 printf 的引用。
  5. 最后,经过上述步骤后,我们得到了一个完整的可执行文件,可以在 CPU 上运行。

回想一下,可执行文件包含多个段,如 .data、.bss、.rodata 和 .text。其中 .text 段(也称为 .code)是我们在生成可执行文件后,所写的代码被放置的位置。

...
从源代码到可执行文件

操作系统加载器将可执行文件加载到进程的内存中,然后程序正式开始运行。

我们习惯于谈论变量或数组的内存地址。然而,由于 .text 段被加载到内存中,代码本身也有内存位置。每个函数都有一个起始地址,我们可以为其分配一个指针。

指向函数或代码块的指针被称为 函数指针。

探索 .text 段

在之前的课程中,我们检查了 .rodata 段的内容。现在我们也同样查看 .text 段。

在代码中,我们调用一个什么都不做的 dummy 函数,然后转储 .text 段的内容。输出结果是汇编语言形式,即使你不熟悉汇编也不用担心。

1#include <stdio.h> 2#include <stdlib.h> 3 4void dummy() 5{ 6 7} 8 9int main() 10{ 11 dummy(); 12 13 system("objdump -S -M intel -j .text main"); 14 return 0; 15}

<> 之间的内容是标签。我们可以将标签视为函数的起始位置。

我们选取一条随机指令来理解输出:

1 4004f6: e8 eb ff ff ff call 4004e6 <dummy>
  • 4004f6 是该指令在内存中的起始地址。
  • e8 eb ff ff ff 是这条指令的机器码编码。编译器将我们的 dummy() 调用转换为机器码 e8 eb ff ff ff。这些字节编码了 CPU 指令,称为助记符。
  • call 4004e6 <dummy> 是机器码的汇编翻译,表示调用位于地址 4004e6 处的 <dummy> 函数。
  • 该指令占用了 5 个字节。一个字节包含 2 个 nibble(1 个 nibble 是 4 位),因此 5 个字节等于 40 位。
  • 如果我们查看下一条指令的地址,可以看到 4004fb。用任意十六进制计算器计算 4004fb 减去 4004f6,结果正好是 5,这正是前一条 call 指令的长度。
  • 关键点在于,代码也被像向量一样加载到内存中。我们有一系列指令,每条指令都有起始位置和结束位置,它们具有字节或位级别的大小。
  • 我们可以为这些指令中的任意一条创建指针。可以通过指针读取内容,但不能写入。在现代系统中,代码被放置在只读内存区域。这有助于防止恶意攻击,如代码注入。一旦代码加载完成,就无法被覆盖,黑客也无法篡改它来劫持应用程序。

每个可执行文件都有一个名为 _start 的标签。可执行文件加载后,程序从 _start 开始执行,它完成一些初始化工作后调用 main。我们可以在 _start 的输出中看到如下一行,地址为 400414:

1call 4003d0 <__libc_start_main@plt>

call 助记符用于调用函数,大致相当于在 C 语言中编写 main()。

_start 标签从地址 4003f0 开始,到 40041a 结束。

我们还可以注意到 dummy 函数的标签。代码在返回前设置了堆栈并清理了它。由于 dummy 函数为空,不再做其他事情。该标签从地址 4004e6 开始,到 4004ec 结束。结尾的 ret 助记符类似于 C 中的 return。

注意: 一个标签从起始地址开始执行,直到结束地址。结束地址包含 ret 助记符,用于通知 CPU 恢复调用者。

最后,我们可以查看 main 函数。 我们注意到对 dummy 函数的调用:

1 4004f6: e8 eb ff ff ff call 4004e6 <dummy>

稍后,我们看到对 system 函数的调用,这是我们用来查看 .text 段的:

1 400500: e8 bb fe ff ff call 4003c0 <system@plt>

编写函数指针

考虑以下代码,我们定义了一个名为 print 的函数,它显示其参数并返回该值。我们的目标是为它编写一个函数指针。

1#include <stdio.h> 2 3int print(float x) 4{ 5 printf("x = %f\n", x); 6 return x; 7} 8 9int main() 10{ 11 print(5.5f); 12 return 0; 13}

编写函数指针的一般规则是:返回类型(*名称)(参数列表)。

函数指针的写法如下:

1int(*fptr)(float);
  • int 是函数的返回类型。
  • (*fptr) 是指针的名称。
  • (float) 是函数的参数列表。

我们必须使用 (*fptr),这是由运算符优先级规则决定的。我们尚未讨论这些规则,但这种情况与数组指针类似。

  • int(*fptr)(float); 声明 fptr 是一个指向接受一个 float 参数并返回 int 的函数的指针。
  • int* fptr(float); 声明一个名为 fptr 的函数,它接受一个 float 参数并返回一个指向 int 的指针。

我们可以像操作普通指针一样操作函数指针。

1fptr = &function; // 使 fptr 指向 function 2(*fptr)(args); // 调用 fptr 所指向的函数

让我们更新代码,添加函数指针,然后通过指针调用函数。

1#include <stdio.h> 2 3int print(float x) 4{ 5 printf("x = %f\n", x); 6 return x; 7} 8 9int main() 10{ 11 int(*fptr)(float) = &print; // fptr 指向 print 12 13 (*fptr)(5.5); // 使用 fptr 调用 print 14 return 0; 15}

我们可以看到代码运行效果相同。print 函数被调用,并显示了 x 的值。

函数指针别名

声明函数指针的语法相当繁琐,容易引起混淆。我们可以使用别名来让代码更易读。

对于前面的例子,语法如下:

1typedef int (*printType)(float);

我们通常将这种声明写在全局作用域。该声明在整个文件中可见。

注意关键字 typedef。这里我们并不是声明一个名为 printType 的变量,而是创建一个名为 printType 的类型。它是 int(*)(float) 的快捷方式。

然后,我们使用这个别名来创建指向返回 int 且接受 float 参数的函数的指针。

1printType fptr = &func;

我们在上面的代码中做出这些修改:

1#include <stdio.h> 2 3typedef int (*printType)(float); // 全局类型别名 4 5int print(float x) 6{ 7 printf("x = %f\n", x); 8 return x; 9} 10 11int main() 12{ 13 printType fptr = &print; // 创建类型为 printType 的指针 14 15 (*fptr)(5.5); // 使用 fptr 调用 print 16 return 0; 17}

功能上没有任何变化,但代码现在更具可读性。我们不再使用奇怪的 int(*)(float) 类型,而是使用一个可读的名称(printType)。

我们可以将 printType 作为任意数量指针的类型。考虑以下示例,我们定义了多个 print 函数。它们都具有相同的函数头,这一点至关重要。函数的类型必须与指针类型匹配。

1#include <stdio.h> 2 3typedef int (*printType)(float); // 全局类型别名 4 5int print(float x) 6{ 7 printf("x = %f\n", x); 8 return x; 9} 10 11int printPretty(float x) 12{ 13 printf("Variable x has the value of %f\n", x); 14 return x; 15} 16 17int main() 18{ 19 printType fptr = &print; 20 printType fprtPretty = &printPretty; 21 22 (*fptr)(5.5); // 使用 fptr 调用 print 23 (*fprtPretty)(5.5); // 使用 fprtPretty 调用 print 24 25 return 0; 26}

两个指针具有相同的类型,但指向不同的函数。使用类型别名使代码更加清晰。对比以下两个代码片段。

片段 1:

1printType fptr = &print; 2printType fprtPretty = &printPretty;

片段 2:

1int (*fptr)(float) = &print; 2int (*fprtPretty)(float) = &printPretty;

使用空指针实现泛型

函数指针与普通指针