C 编译器制作入门

程序的运行映像与初始化式

程序的运行映像与初始化式

到目前为止,编译器已经支持函数、全局变量和局部变量这些核心元素;你也已经看到,多个源文件可以分别编译,再由链接器合成为一个可执行文件。

本章把视角从“如何生成汇编”推进到“可执行文件如何被操作系统启动”。

读完后,你应该能理解:文件里的代码和数据如何进入内存,程序入口在哪里,main 又是在什么准备工作之后被调用的。

有了这层背景,我们再处理变量初始化式,也就是下面这种代码应该怎样变成数据段内容或运行时赋值。

int x = 3;
int y[3] = { 1, 2, 3 };
char *msg1 = "foo";
char msg2[] = "bar";

初始化式之所以要放到这里讲,是因为它不只是语法问题,还牵涉到程序启动前内存里已经放好了什么。

为了让模型足够清楚,本章先只讨论静态链接:所有代码和数据都打包进一个可执行文件。动态链接会把部分代码和数据留到运行时再组合,机制更复杂,下一章再展开。

可执行文件的结构

可执行文件由文件头和一个以上称为“段”(segment)的区域构成。

一个可执行文件通常至少有两个段,分别存放可执行代码和数据。存放可执行代码的段称为“文本段”(text segment),除此之外的数据所在段称为“数据段”(data segment)。实际可执行文件还包含其他段,但这些对理解机制不是必要内容,这里省略。

注意这里的“文本”一词和文本文件中的“文本”同名但含义不同。在传统底层术语中,表示机器码的数据常被称为“文本”。另外,机器码本身也不过是字节列,因此文本也是数据的一种。本章中说“文本和数据”时,“数据”指的是文本以外的数据。

作为链接器输入的目标文件中,文本和数据也是分开放置的。链接器从多个目标文件中读取文本并连接起来,放入一个文本段;同样地,它也从多个目标文件中读取数据并连接起来,放入一个数据段。

可执行文件的文件头会记录每个段在运行时应放到内存中的哪个地址。执行可执行文件时,OS 中称为“程序加载器”(program loader)或简称“加载器”的程序,会根据这些信息把文本和数据从可执行文件复制到内存。

下面的图展示了可执行文件被加载器读入内存后的状态。

教程示意图
可执行文件和内存印象

在这个图里,假定可执行文件的文件头中写着:文本段加载到 0x41000,数据段加载到 0x50000

文件头中还包含从哪个地址开始执行的信息。例如如果写着从 0x41040 开始运行,加载器就会按上图把可执行文件加载到内存,设置栈指针为 0x7fff_ffff_ffff_ffff,随后跳转到 0x41040,开始执行用户程序。

数据段的内容

文本段的内容显然是机器码。那么数据段中放什么?答案是全局变量和字符串字面量等。

局部变量不会直接放在文本段或数据段中。局部变量是在程序运行时在栈区域上创建的,因此在可执行文件刚被加载到内存时并不存在。

C 的运行模型是:程序被读入内存,然后从 main 函数开始执行。因此,全局变量必须在可执行文件的数据段中事先准备好适当的初始值,并在加载时复制到内存。

受这个限制,C 中不能用下面这样的函数调用来初始化全局变量。

int foo = bar();

如果全局变量需要这种动态初始化,那么就必须在 main 运行之前由某个机制先执行上述表达式。但 C 没有在 main 之前启动并执行初始化的机制,因此不能进行这样的初始化。

也就是说,全局变量必须在链接时就能确定值,并且能作为字节列直接放入可执行文件。这类值限于下面几种表达式。

字面量数字和字符串这样的常量表达式,显然可以作为固定值设置到文本段或数据段中。

全局变量和函数的地址通常在编译时还不能确定,而是在链接器完成可执行文件时确定。

因此,像 int *x = &y; 这样,用另一个全局变量的地址初始化指针类型全局变量,是合法的。链接器会自行决定程序各段的布局,也当然知道函数和全局变量的加载地址,因此可以在链接时把 x 的内容填好。

另外,链接器也支持“地址加常量”这样的功能,因此 int *x = &y + 3; 这样的定义也是合法的。

除上述模式之外的表达式不能写作初始化式。例如不能用某个全局变量的值(而不是地址)来初始化另一个全局变量。像 ptrdiff_t x = &y - &z; 这种取两个全局变量地址差的表达式,原则上链接时可以求值,但链接器不支持这种两个标签相减的操作,因此这样的表达式不能作为初始化式。全局变量初始化式只允许上述有限模式。

把可写作全局变量初始化式的 C 表达式举例表示如下。

int a = 3;
char b[] = "foobar";
int *c = &a;
char *d = b + 3;

支持这些表达式的汇编如下。

a:
  .long 3
b:
  .byte 0x66 // 'f'
  .byte 0x6f // 'o'
  .byte 0x6f // 'o'
  .byte 0x62 // 'b'
  .byte 0x61 // 'a'
  .byte 0x72 // 'r'
  .byte 0    // '\0'
c:
  .quad a
d:
  .quad b + 3

也可以不用连续的 .byte,而使用 .ascii 记法,例如写成 .ascii "foobar\0"

专栏:全局变量的动态初始化

C 要求全局变量的内容静态确定,而 C++ 允许用任意表达式初始化全局变量。也就是说,在 C++ 中,全局变量的初始化式会在调用 main 之前执行。机制大致如下。

  • C++ 编译器把全局变量的初始化式编译成函数,并把这些函数指针输出到称为 .init_array 的特殊节中。
  • 链接器把多个输入文件中的 .init_array 节连接起来,输出为 .init_array 段(因此该段中保存的是函数指针数组)。
  • 加载器在把控制权交给 main 之前,先按顺序执行 .init_array 段中的函数指针。

通过编译器、链接器和程序加载器的协作,全局变量的动态初始化就能实现。

使用与 C++ 相同的机制,C 也可以支持全局变量的动态初始化;但这样的功能不属于 C 语言规范。

C 语言规范的这种设计,会给写程序的一侧带来较多限制;但对运行程序的一侧而言,即使在性能不足的加载器、或者没有加载器的环境(例如计算机启动时直接从 ROM 运行代码)中,也能完全满足语言规范。因此这是一种取舍,很难简单说哪边更优。

初始化式的语法

初始化式乍看像赋值表达式,但实际上初始化式和赋值表达式语法不同,初始化式允许一些特殊写法。

这里先掌握这些特殊写法。

首先,初始化式可以初始化数组。例如下面的表达式会把 x[0]x[1]x[2] 分别初始化为 0、1、2。

int x[3] = { 0, 1, 2 };

使用初始化式时,可以根据右边元素数量知道数组长度,因此数组长度可以省略。例如上面的表达式和下面的表达式含义相同。

int x[] = { 0, 1, 2 };

即使显式写出数组长度,如果初始化式只给出一部分元素,剩余元素也会被初始化为 0。因此下面两个表达式含义相同。

int x[5] = { 1, 2, 3, 0, 0 };
int x[5] = { 1, 2, 3 };

另外,作为 char 数组初始化式的特殊语法,可以使用字符串字面量作为初始化式,如下所示。

char msg[] = "foo";

上面的表达式与下面的表达式含义相同。

char msg[4] = { 'f', 'o', 'o', '\0' };

全局变量的初始化式

全局变量的初始化式必须能在编译时计算。计算结果要么是单纯的字节列,要么是函数或全局变量的指针;如果是指针,还可以带一个表示该指针偏移量的整数。

没有初始化式的全局变量必须被初始化为全 0。这是 C 语法规定的。

如果初始化式无法计算成上述形式,就作为编译错误处理。

局部变量的初始化式

局部变量的初始化式和全局变量的初始化式外观相同,但含义大不相同。局部变量的初始化式是在该位置运行的表达式,因此内容不必在编译时确定。

基本上,int x = 5; 可以编译成和 int x; x = 5; 这两条语句相同的形式。

int x[] = {1, 2, foo()}; 这样的语句,可以编译成下面这样的语句。

int x[3];
x[0] = 1;
x[1] = 2;
x[2] = foo();

没有初始化式的局部变量内容是不确定的。因此不需要初始化这样的变量。

专栏:字的大小

在 x86-64 中,“字”(word)这个术语既可能表示 16 位数据,也可能表示 64 位数据。这种状态有历史原因。

本来,“字”这个术语指计算机上自然且容易处理的最大整数或地址大小。在 64 位处理器 x86-64 中,1 个字为 64 位的说法就来自这里。

另一方面,16 位的 1 个字来自 16 位处理器 8086 的术语。Intel 工程师把 8086 扩展成 32 位的 386 处理器时,为了避免改变“字”的大小,把 32 位称为双字(double word,dword)。同样,在把 386 扩展到 x86-64 时,又把 64 位称为四字(quad word,qword)。这种面向兼容性的考虑,导致“字”有了两个不同含义。