extreme-c-learning-notes ch2
C 源代码通过:预处理 --> 编译 --> 汇编 --> 链接 最终才能转换为产品(可执行文件)。这意味着,即使任何环节出现小错误都可能导致 编译(compilation) 或 链接(linker) 失败,同时也会产生相关的错误消息。
对于某些中间产品,如重定位目标文件(relocatable object files),只需要源文件成功的通过前三步。最后一个组件是链接器,通常通过合并一些已经准备好的可重定位目标文件来创建更大的产品,如可执行文件(executable object file)。因此,对一组 C 源文件进行构建可以创建一个或多个目标文件,其中包括可重定位、可执行和共享目标文件(shared object files)。
平台(platform): 是特定硬件(或架构)以及在其上运行的操作系统的组合,其 CPU 的指令集(instruction set)是其中最重要的部分。操作系统是平台的软件组件,而架构定义了硬件部分。例如,我们可以在 ARM powerd 芯片的主板上运行 Ubuntu 操作系统,或者我们可以在 AMD 64位 CPU 上运行微软 Windows 操作系统。
跨平台软件可以在不同的平台上运行,然而,跨平台(cross-platform) 与 可移植(portable) 是不同的。跨平台软件对于每个平台通常有不同的二进制文件(最终目标文件)和安装程序,而可移植软件在所有平台上使用相同的二进制文件和安装程序。
一些 C 编译器是跨平台的,例如 gcc 和 clang,它们可以为不同的平台生成代码,而 Java bytecode 就是可移植的。
每个 C 项目都有源代码或代码库,以及与项目描述和现有标准相关的其他文档。在 C 代码库中,通常有两种包含 C 代码的文件:
.h
扩展名.c
扩展名头文件通常包含枚举、宏和类型定义,以及函数、全局变量和结构的声明(declarations)。在 C 语言中,一些编程元素,如函数、变量和结构,它们的 定义(definition) 可以分别放在不同的文件中。
C++ 遵循相同的模式,但在其他编程语言(如 Java)中,这些程序元素是在声明的地方定义的。C 和 C++ 的模式能将声明与定义解耦,这是一个很好的特性,但它也使源代码更加复杂。
函数声明由 返回类型(return type) 和 函数签名(function signature) 组成。函数签名就是函数的名称和它的输入参数列表:
double average(int*, int);
声明引入了一个函数签名,其名称为 average
,它接收一个指向整型数组的指针和一个整型参数,该参数表示数组中元素的个数。声明还说明该函数返回一个双精度值。注意,一般认为返回类型是声明的一部分,但不是函数前面的一部分。
函数 average
的定义:
double average(int *array, int length) {
if (length <= 0) {
return 0;
}
double sum = 0.0;
for (int i = 0; i < length; i++) {
sum += array[i];
}
return sum / length;
}
在例 2.1 中有三个文件,其中一个是头文件,另外两个是源文件,它们都在同一个目录中。
头文件被用来连接两个独立的源文件,使我们可以在两个独立的文件中编写代码,并将它们构建在一起。如果没有头文件,就不可能在不违反规则(源文件不能包含源文件)的情况下,将代码分别写在两个源文件中。头文件包含一个源文件使用另一个源文件的功能所需的所有内容。
#ifndef EXTREMEC_EXAMPLES_CHAPTER_2_1_H
#define EXTREMEC_EXAMPLES_CHAPTER_2_1_H
typedef enum {
NONE,
NORMAL,
SQUARED
}average_type_t;
double avg(int*, int, average_type_t);
#endif
#include "ExtremeC_examples_chapter2_1.h"
double avg(int* array, int length, average_type_t type) {
if (length <= 0 || type == NONE) {
return 0;
}
double sum = 0.0;
for (int i = 0; i < length; i++) {
if (type == NORMAL) {
sum += array[i];
} else if (type == SQUARED) {
sum += array[i] * array[i];
}
}
return sum / length;
}
#include
#include "ExtremeC_examples_chapter2_1.h"
int main(int argc, char** argv) {
int array[5];
array[0] = 10;
array[1] = 3;
array[2] = 5;
array[3] = -8;
array[4] = 9;
double average = avg(array, 5, NORMAL);
printf("The average: %f\n", average);
average = avg(array, 5, SQUARED);
printf("The squared average: %f\n", average);
return 0;
}
规则:1:只编译源文件
因为编译头文件是没有意义的。头文件除了一些声明外不应该包含任何实际的 C 代码。因此,例 2.1 只需要编译两个源文件:ExtremeC_examples_chapter2_1.c
和 ExtremeC_examples_chapter2_1_main.c
规则:2:分别编译每个源文件
对于示例 2.1 这意味着必须运行编译器两次,每次传递一个源文件。
Tips: 可以一次性传递两个源文件,并要求编译器用一个命令编译它们,但我们不建议这样做。
C编译过程中的第 1 步是 预处理(preprocessing)。源文件包含许多头文件。但是在编译开始之前,这些文件的内容被预处理程序收集在一起成为单段 C 代码。换句话说,在预处理步骤之后,头文件的内容被复制到源文件中,从而得到一段代码。
其他 预处理指令(preprocessor directives) 也必须在此步骤中解析。这段预处理后的代码称为翻译单元。翻译单元是由预处理器生成的 C 代码的单个逻辑单元,它可用于编译。翻译单元有时也称为 编译单元(compilation unit)。
Tips: 在翻译单元中,是找不到预处理指令的。注意,C(和 C++)中的所有预处理指令都以
#
开头,例如#include
和#define
。
可以要求编译器转存储翻译单元而不进一步编译它。在 gcc
中,使用 -E
参数(区分大小写)即可。
下面是 ExtremeC_examples_chapter2_1.c
的翻译单元,使用 gcc 编译器:
$ gcc -E ExtremeC_examples_chapter2_1.c
# 0 "ExtremeC_examples_chapter2_1.c"
# 0 ""
# 0 ""
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "" 2
# 1 "ExtremeC_examples_chapter2_1.c"
# 1 "ExtremeC_examples_chapter2_1.h" 1
typedef enum {
NONE,
NORMAL,
SQUARED
}average_type_t;
double avg(int*, int, average_type_t);
# 2 "ExtremeC_examples_chapter2_1.c" 2
double avg(int* array, int length, average_type_t type) {
if (length <= 0 || type == NONE) {
return 0;
}
double sum = 0.0;
for (int i = 0; i < length; i++) {
if (type == NORMAL) {
sum += array[i];
} else if (type == SQUARED) {
sum += array[i] * array[i];
}
}
return sum / length;
}
如上所示,所有的声明都从头文件复制到翻译单元。注释也已被删除。
有了编译单元后,就可以进行第 2 步,即 编译(compilation) 。编译步骤的输入是由上一步处理所得的翻译单元,输出是相应的 汇编代码(assembly code) 。汇编代码仍然是人类可读的,但它依赖于机器,接近于硬件,仍然需要进一步的处理才能成为机器级别的指令。
可以通过使用 -S
参数(大写S)使得 gcc 在第 2 步后就停止,从而转存储生成的汇编代码。这时输出的是一个与给定源文件同名但扩展名为 .s
的文件。
ExtremeC_examples_chapter2_1.s 是 ExtremeC_examples_chapter2_1.c
源文件的汇编代码。
在编译步骤中,编译器解析翻译单元,将其转换为特 目标体系结构(target architecture) 下的汇编代码。目标体系结构,指的是为其编译程序并最终在其上运行的硬件或 CPU。目标体系结构有时称为 主机体系结构(host architecture)。
编译后的下一步是 汇编(assembly)。汇编的目标是基于上一步中编译器生成的汇编代码生成实际的 机器指令(或机器代码,machine code) 。每个体系结构都有自己的 汇编器(assembler) ,能将自己的汇编代码转换为自己的机器代码。
这个通过汇编得到的含有机器级指令的文件称为 目标文件(object file) 。C 项目有几个产品都是目标文件,但这里,我们主要对可重定位的目标文件感兴趣。这个文件是在构建过程中获得的重要的临时产品。
Tips: 可重定位的目标文件可以称为中间目标文件。
在大多数类 Unix 操作系统中都有一个汇编工具叫作 as
,可用于从汇编文件生成可重定位目标文件。这类目标文件是不可执行的,它们只包含为一个翻译单元生成的机器级指令。由于每个翻译单元都由各种函数和全局变量组成,一个可重定位的目标文件只包含相应函数的机器级指令以及全局变量的预分配入口。
使用 as
为 ExtremeC_examples_chapter2_1.s
生成可重定位的目标文件:
$ as ExtremeC_examples_chapter2_1.s -o ExtremeC_examples_chapter2_1.o
Tips:
-o
选项用于指定输出目标文件的名称。可重定位的目标文件通常使用.o
(在 Microsoft Windows 中是.obj
)扩展名。
如果给 C 编译器传递 -c
选项,它将直接为输入源文件生成相应的目标文件。换句话说,-c
选项相当于同时执行前面的三个步骤。几乎所有的 C 编译器都支持 -c
选项。
下面使用 -c
选项,对 ExtremeC_examples_chapter2_1.c
进行编译并生成相应的目标文件:
$ gcc -c ExtremeC_examples_chapter2_1.c
以上完成的所有步骤(预处理->编译->汇编)都是依据前面单个命令完成的。这意味着,在运行前面的命令之后,将生成一个可重定位目标文件。此可重定位目标文件与输入源文件具有相同的名称,不同之处在于它的扩展名为 .o
。
汇编是编译单个源文件的最后一步。也就是说,当我们得到与源文件对应的可重定位目标文件时,就完成了对它的编译。此时可以把已得到的可重定位目标文件放在一边,继续编译其他源文件。
下面分别编译 ExtremeC_examples_chapter2_1.c
和 ExtremeC_examples_chapter2_1_main.c
并生成相应的目标文件:
$ gcc -c ExtremeC_examples_chapter2_1.c -o impl.o
$ gcc -c ExtremeC_examples_chapter2_1_main.c -o main.o
现在我们有两个可重定位目标文件。因此,下一步是组合这些可重定位目标文件,以创建另一个可执行的目标文件。链接(linking) 就是进行这项工作的。
汇编器和链接器可以独立于编译器运行。在 Unix 系统中,ld
是默认链接器。下面展示了如何直接使用 ld
从可重定位目标文件创建可执行文件。
$ ld impl.o main.o
ld: warning: cannot find entry symbol _start; defaulting to 0000000000401000
ld: main.o: in function `main':
ExtremeC_examples_chapter2_1_main.c:(.text+0x7d): undefined reference to `printf'
ld: ExtremeC_examples_chapter2_1_main.c:(.text+0xb9): undefined reference to `printf'
ld: ExtremeC_examples_chapter2_1_main.c:(.text+0xd2): undefined reference to `__stack_chk_fail'
可以看到上面的命令失败了,并产生了一些错误消息。错误消息显示 ld
在文本段的三个地方遇到了三个 未定义(undefined) 的函数调用(或引用,references)。其中两个是在 main
函数中对 printf
函数的调用。然而,另一个函数 __stack_chk_fail
没有被开发人员调用过,它是由编译器放入可重定位目标文件的补充代码调用的,而且这个函数是 Linux 特有的,在其他平台上生成的相同目标文件中找不到它。默认链接器 ld
无法找到这些函数的定义而产生了这些错误。从逻辑上讲,这是有道理的,而且是正确的,因为在列 2.1 中我们没有自己定义 printf
和 __stack_chk_fail
函数。
这意味着应该给 ld
一些其他的目标文件,这些文件包含 printf
和 __stack_chk_fail
函数的定义。尽管这些文件不一定是可重定位目标文件。
在类 Unix 系统中,C 编译器通过传递适当的选项和指定额外所需的目标文件来使用 ld
。因此,我们不能直接使用 ld
。
$ gcc impl.o main.o
$ ./a.out
The average: 3.800000
The squared average: 55.800000
预处理器只执行简单的任务,例如如何包含(从文件中复制内容)或宏展开(文本替换)。但它对 C 语言一无所知;在执行任何进一步的任务之前,它需要一个解析器来解析输入文件。
预处理器的解析器与 C 编译器使用的解析器不同,因为它使用的语法几乎独立于 C 语法。这使我们能够在预处理 C 文件以外的环境中使用它。
在大多数类 Unix 操作系统中,有一个工具叫做 cpp,它代表 C 预处理器 – 而不是 C Plus Plus! cpp 是随各种 Unix 附带的 C 开发包的一部分,它可以用来处理 C 文件,在后台 C 编译器(如 gcc)使用该工具对 C 文件进行预处理。
$ cpp ExtremeC_examples_chapter2_1.c
# 0 "ExtremeC_examples_chapter2_1.c"
# 0 ""
# 0 ""
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "" 2
# 1 "ExtremeC_examples_chapter2_1.c"
# 1 "ExtremeC_examples_chapter2_1.h" 1
typedef enum {
NONE,
NORMAL,
SQUARED
}average_type_t;
double avg(int*, int, average_type_t);
# 2 "ExtremeC_examples_chapter2_1.c" 2
double avg(int* array, int length, average_type_t type) {
if (length <= 0 || type == NONE) {
return 0;
}
double sum = 0.0;
for (int i = 0; i < length; i++) {
if (type == NORMAL) {
sum += array[i];
} else if (type == SQUARED) {
sum += array[i] * array[i];
}
}
return sum / length;
}
如果给 C 编译器传递一个带有 .i
扩展名的文件,它将绕过预处理步骤。这是因为扩展名为 .i
的文件应该已经被处理过了。因此,它应该被直接发送到编译步骤。
编译器接受由预处理器准备的翻译单元,并生成相应的汇编指令。当多个 C 源文件被编译成等价的汇编代码后,平台中的工具(如汇编器和链接器),完成后面的工作:由汇编代码生成可重定位目标文件,最后将可重定位目标文件链接起来(也可能是与其它目标文件)形成一个库或者一个可执行文件。
一个平台必须有一个汇编器,以便产生含有正确的机器指令的目标文件。在类 Unix 操作系统中,可以使用 as 实用程序来调用汇编器。
如果在不同的体系结构上安装两个不同的类 Unix 操作系统,所安装的汇编器可能不一样。当在目标文件中存储机器指令时,每个操作系统都定义了自己特定的二进制格式或目标文件(object file format)。因此,有两个因素规定目标文件的内容:体系结构(或硬件)和操作系统。通常,我们将使用术语“平台”来描述二者的组合。
我们通常说目标文件(即由汇编生成的)是基于特定平台的。在 Linux 中,我们使用可执行文件和链接文件格式(Executable and Linking Format, ELF)。
构建 C 项目的第一个重要步骤是将所有源文件编译为相应的可重定位文件。可重定位目标文件是临时产品,不是最终产品,因为它们只参与链接步骤以生成最终产品,在此之后,我们就不再需要它们了。
一个 C/C++ 项目有以下最终产品:
.out
扩展名;在 Microsoft Windows 中,通常具有 .exe
扩展名。.a
扩展名;在 Microsoft Windows 中具有 .lib
扩展名。.so
扩展名;在 macOS 中有:.dylib
扩展名;在 Microsoft Windows 中具有 .dll
扩展名。静态库是封装一段逻辑的最简单、最容易的方法,可供重复使用。静态库通常链接到可执行文件,在**链接时(link time)**被用作最终可执行文件的一部分。在一个操作系统中存在大量的静态库,每个库都包含一段特定的逻辑,可以用来访问该操作系统中的特定功能。
共享目标文件由链接器直接创建,它们不仅仅是归档文件,而是具有更复杂的结构。它们的用法也不一样:要使用共享目标文件的话,需要在运行时将它们加载到正在运行的进程中。此外,一个共享目标文件可以被多个不同的进程同时加载和使用。
链接器是如何工作的?
“链接”到底是什么意思?
假设我们构建一个包含 5 个源文件的 C 项目,最终产品是一个可执行文件。在构建过程中,我们已经编译了所有的源文件,并拥有 5 个可重定位目标文件。现在我们需要一个链接器来完成最后一步并生成最终的可执行文件。
当我们组合目标文件以生成一个可执行目标文件时,需要审慎考虑目标文件的内容。只有知道了目标文件里有什么,才能知道链接器是如何使用重定位目标文件的。简单的说,目标文件包含于翻译单元等价的机器指令。然而,这些指令并不是随机放入文件的。相反,它们被分组在称为“符号”(symbols)的段中。
事实上,在一个目标文件中有很多东西,符号只是其中的一个组件,其功能是解释链接器如何工作以及如何将一些目标文件绑定在一起产生更大的文件。为了解释符号,让我们通过下例来讨论它们。
int average(int a, int b) {
return (a + b) / 2;
}
int sum(int* numbers, int count) {
int sum = 0;
for (int i = 0; i < count; i++) {
sum += numbers[i];
}
return sum;
}
首先,编译上面的代码以产生相应的目标文件 target.o
。
$ gcc -c ExtremeC_examples_chapter2_3.c -o target.o
使用 nm
命令查看 target.o
目标文件。 nm
命令允许我们查看目标文件中的符号:
$ nm target.o
0000000000000000 T average
0000000000000021 T sum
使用 readelf
命令来查看目标文件中的符号表(symbol table)
$ readelf -s target.o
Symbol table '.symtab' contains 5 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS ExtremeC_example[...]
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 33 FUNC GLOBAL DEFAULT 1 average
4: 0000000000000021 73 FUNC GLOBAL DEFAULT 1 sum
在 readelf
的输出中可以看到,在符号表中有两个函数符号。表中还有其它一些符号,它们指向目标文件中的不同段。
可以使用 objdump
查看每个函数符号下机器级指令的反汇编。
$ objdump -d target.o
target.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <average>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 89 75 f8 mov %esi,-0x8(%rbp)
e: 8b 55 fc mov -0x4(%rbp),%edx
11: 8b 45 f8 mov -0x8(%rbp),%eax
14: 01 d0 add %edx,%eax
16: 89 c2 mov %eax,%edx
18: c1 ea 1f shr $0x1f,%edx
1b: 01 d0 add %edx,%eax
1d: d1 f8 sar %eax
1f: 5d pop %rbp
20: c3 ret
0000000000000021 <sum>:
21: f3 0f 1e fa endbr64
25: 55 push %rbp
26: 48 89 e5 mov %rsp,%rbp
29: 48 89 7d e8 mov %rdi,-0x18(%rbp)
2d: 89 75 e4 mov %esi,-0x1c(%rbp)
30: c7 45 f8 00 00 00 00 movl $0x0,-0x8(%rbp)
37: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
3e: eb 1d jmp 5d <sum+0x3c>
40: 8b 45 fc mov -0x4(%rbp),%eax
43: 48 98 cltq
45: 48 8d 14 85 00 00 00 lea 0x0(,%rax,4),%rdx
4c: 00
4d: 48 8b 45 e8 mov -0x18(%rbp),%rax
51: 48 01 d0 add %rdx,%rax
54: 8b 00 mov (%rax),%eax
56: 01 45 f8 add %eax,-0x8(%rbp)
59: 83 45 fc 01 addl $0x1,-0x4(%rbp)
5d: 8b 45 fc mov -0x4(%rbp),%eax
60: 3b 45 e4 cmp -0x1c(%rbp),%eax
63: 7c db jl 40 <sum+0x1f>
65: 8b 45 f8 mov -0x8(%rbp),%eax
68: 5d pop %rbp
69: c3 ret
如上所述,每个函数符号都对应于一个源代码中定义的函数。这表明当需要链接几个可重定位目标文件,以产生一个可执行目标文件时,每个可重定位目标文件只包含构建一个完整的可执行程序所需的全部函数符号的一部分。
链接器从各个可重定位目标文件中收集所有符号,然后将它们放在一个更大的目标文件中,形成一个完整的可执行二进制文件。下面我们使用由 4 个 C 文件组成(3 个源文件和 1 个头文件)的例子来展示这一过程。
#ifndef EXTREMEC_EXAMPLES_CHAPTER_2_4_DECLS_H
#define EXTREMEC_EXAMPLES_CHAPTER_2_4_DECLS_H
int add(int, int);
int multiply(int, int);
#endif
int add(int a, int b) {
return a + b;
}
int multiply(int a, int b) {
return a * b;
}
#include "ExtremeC_examples_chapter2_4_decls.h"
int main(int argc, char** argv) {
int x = add(4, 5);
int y = multiply(9, x);
return 0;
}
从上述可以看到 ExtremeC_examples_chapter2_4_main.c
,必须包含头文件,一遍获得 add
和 multiply
这两函数的声明,否则,源文件将无法使用它们并可能导致编译失败。此外,main
函数不知道 add
和 multiply
函数的定义。在 ExtremeC_examples_chapter2_4_main.c
中只包含一个头文件,因此它与其他两个源文件没有关系。所以,这里有个重要的问题;在 main
函数不知道其他源文件的情况下,如何找到这些函数的定义?
上述问题可以通过链接器来解决。链接器将从各种目标文件中收集所需的定义,并将它们放在一起,通过这种方式,main
函数中的代码终于可以使用另一个函数中的代码。
Tips: 要编译使用函数的源文件,只要有函数的声明就够了,然而,要真正运行程序,要给链接器提供函数定义,以便将其放入最终的可执行文件中。
$ gcc -c ExtremeC_examples_chapter2_4_add.c -o add.o
$ gcc -c ExtremeC_examples_chapter2_4_multiply.c -o multiply.o
$ gcc -c ExtremeC_examples_chapter2_4_main.c -o main.o
查看每个可重定位目标文件中的符号表:
$ nm add.o
0000000000000000 T add
$ nm multiply.o
0000000000000000 T multiply
$ nm main.o
U add
0000000000000000 T main
U multiply
在 ExtremeC_examples_chapter2_4_main.c
中只有 main
函数,但在其对应的目标文件中看到了 add
和 multiply
两个符号。然而它们与 main
符号不同,main
符号是有地址的。而它们被标记为 U,或者 unresolved。这意味着,当编译器在翻译单元中看到这些符号时,却无法找到它们的实际定义。
如果链接器找不到未解析符号的定义,链接失败,则会给出**链接错误(linkage error)**信息。
接下来,我们使用命令将目标文件链接在一起:
gcc add.o multiply.o main.o
对几个目标文件直接运行 gcc,不使用任何选项,将执行链接步骤,看上去只使用给定的目标文件创建可执行文件。实际上,它在后台调用链接器,不仅使用给定的目标文件,还要使用平台需要的其他静态库和目标文件。
如果链接器无法正确定义,则会链接失败,如下之向链接器提供两个中间目标文件,main.o
和 add.o
:
$ gcc add.o main.o
/usr/bin/ld: main.o: in function `main':
ExtremeC_examples_chapter2_4_main.c:(.text+0x30): undefined reference to `multiply'
collect2: error: ld returned 1 exit status
$ gcc main.o multiply.o
/usr/bin/ld: main.o: in function `main':
ExtremeC_examples_chapter2_4_main.c:(.text+0x1e): undefined reference to `add'
collect2: error: ld returned 1 exit status
$ gcc add.o multiply.o
/usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu/Scrt1.o: in function `_start':
(.text+0x1b): undefined reference to `main'
collect2: error: ld returned 1 exit status
链接器会上当
本例中有两个源文件,其中一个文件包含一个函数的定义,该函数与文件同名,但是其函数签名与 main
函数中的声明不同;另一个文件包含 main
函数。
int add(int a, int b, int c, int d) {
return a + b + c + d;
}
#include
int add(int, int);
int main(int argc, char** argv) {
int x = add(5, 6);
printf("Result: %d\n", x);
return 0;
}
如上所示,ExtremeC_examples_chapter2_5_add.c 中定义了 add
函数接收四个整型数作为参数,但 ExtremeC_examples_chapter2_5_main.c 中 main
函数使用了另一个 add
函数,它只接受两个整型数作为参数。这些函数通常互称为对方的重载(overloads)函数。
编译和链接可重定位目标文件:
$ gcc -c ExtremeC_examples_chapter2_5_add.c -o add.o
$ gcc -c ExtremeC_examples_chapter2_5_main.c -o main.o
$ gcc add.o main.o -o ex2_5.out
$ ./ex2_5.out
Result: -1140472845
$ ./ex2_5.out
Result: 632793427
$ ./ex2_5.out
Result: -745364173
从上面输出可以发现,输出的 Result
值是错误的,而且每次输出的结果不一样。这个例子表明,当连接器使用了错误版本的符号时,会发生意外。函数符号只是名称,它们不携带任何有关对应函数签名的信息。函数参数只不过是一个 C 概念;事实上,它们并不真正存在于汇编代码或机器指令中。
为了研究的更加透彻,我们来看另一个例子
int add(int a, int b, int c, int d) {
return a + b + c + d;
}
int add(int a, int b) {
return a + b;
}
add
符号在两个目标文件中的反汇编$ objdump -d add_1.o
add_1.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <add>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 89 75 f8 mov %esi,-0x8(%rbp)
e: 89 55 f4 mov %edx,-0xc(%rbp)
11: 89 4d f0 mov %ecx,-0x10(%rbp)
14: 8b 55 fc mov -0x4(%rbp),%edx
17: 8b 45 f8 mov -0x8(%rbp),%eax
1a: 01 c2 add %eax,%edx
1c: 8b 45 f4 mov -0xc(%rbp),%eax
1f: 01 c2 add %eax,%edx
21: 8b 45 f0 mov -0x10(%rbp),%eax
24: 01 d0 add %edx,%eax
26: 5d pop %rbp
27: c3 ret
$ objdump -d add_2.o
add_2.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <add>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 89 75 f8 mov %esi,-0x8(%rbp)
e: 8b 55 fc mov -0x4(%rbp),%edx
11: 8b 45 f8 mov -0x8(%rbp),%eax
14: 01 d0 add %edx,%eax
16: 5d pop %rbp
17: c3 ret
当一个函数调用发生时,一个新的栈帧(stack frame)被创建在堆栈的顶部。这个栈帧包含传递给函数的参数和返回地址。在上述反汇编代码中可以看到如何从栈帧收集参数。
add_1.o
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 89 75 f8 mov %esi,-0x8(%rbp)
e: 89 55 f4 mov %edx,-0xc(%rbp)
11: 89 4d f0 mov %ecx,-0x10(%rbp)
这些指令从 %rbp
寄存器指向的内存地址复制四个值,并将它们放入本地寄存器中。
add_1.o
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 89 75 f8 mov %esi,-0x8(%rbp)
查看第二个目标文件的反汇编,值复制了两个值,因为函数只需要两个参数。这就是例 2.5 输出奇怪数值的原因。main
函数在调用 add
函数时只在栈帧中放入两个值,但实际上在 add
函数定义中需要四个参数。因此,基于错误的定义,可能会在栈帧之外继续读取未给出的参数,从而导致 sum
操作得到错误的值。
我们可以根据输入类型更改函数符号名来防止这种情况发生。这通常被称为名字改编(name mangling)。因为它有函数重载(function overloading)特性而主要在 C++
中使用。
C++名字改编
C 和 C++ 的编译过程非常相似,我们使用 g++ 来编译上述文件并生成目标文件,并使用 readelf 转存储每个目标文件的符号表。
$ g++ -c ExtremeC_examples_chapter2_6_add_1.c -o ExtremeC_examples_chapter2_6_add_1.o
$ g++ -c ExtremeC_examples_chapter2_6_add_2.c -o ExtremeC_examples_chapter2_6_add_2.o
$ readelf -s ExtremeC_examples_chapter2_6_add_1.o
Symbol table '.symtab' contains 4 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS ExtremeC_example[...]
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 40 FUNC GLOBAL DEFAULT 1 _Z3addiiii
$ readelf -s ExtremeC_examples_chapter2_6_add_2.o
Symbol table '.symtab' contains 4 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS ExtremeC_example[...]
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 24 FUNC GLOBAL DEFAULT 1 _Z3addii
如上输出所示,对于 add
函数的重载有两个不同的符号名。接收四个整数参数的重载函数具有符号名 _Z3addiiii
, 接受两个整数参数的函数具有符号名 _Z3addii
。由此可以看到符号名称是不同的,如果使用错误的符号,就会得到一个错误的链接,因为链接器无法找到错误符号的定义。名字改编(name mangling)是一种使 C++ 能够支持函数重载的技术。