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