第4章:程序的编译与执行流程

不要看helloworld程序很简单,但是即使要运行如此简单的程序,也需要极其复杂的软件和硬件配合才行。

我们在写下hello world代码之后,点击某个按钮运行,在底层是如何运作的呢?这一章我们来了解一下,从我们写下代码后点击运行,到程序给出一个结果,计算机到底经过了什么样的过程。我们在了解了底层原理之后,就能够更容易理解编译器给我们的报错,以及我们运行时遇到的Bug。

4.1 编译:从代码到程序

让我们通过具体的例子来了解编译流程,并解释每一步的原理。

4.1.1 C语言编译流程详解

编写源代码

这就是我们第一次学习计算机写的第一个helloworld程序:

// hello.c
#include <stdio.h>

int main() {
    printf("Hello, World!\n");
    return 0;
}

include指令是为了包含一些必要的库代码,例如上节课讲的打印的代码。我们再点击运行按钮的时候,计算机系统将运行编译指令,使用gcc或者clang来编译这个代码。

预处理(Preprocessing)

编译器第一步就是进行预处理,计算机运行如下的指令:

gcc -E hello.c -o hello.i

预处理后的文件 hello.i 内容:

# 1 "hello.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 1 "<command-line>" 2
# 1 "hello.c"
# 1 "/usr/include/stdio.h" 1 3 4
// ... 大量头文件内容 ...
extern int printf (const char *__restrict __format, ...);
// ... 更多内容 ...

int main() {
    printf("Hello, World!\n");
    return 0;
}

预处理阶段的主要工作是展开头文件。当我们写 #include <stdio.h> 时,编译器会把整个stdio.h文件的内容复制到我们的代码中。这个文件包含了printf函数的声明,以及其他很多标准库函数的定义。预处理还会处理宏定义,把 #define 定义的符号替换成实际的值,进行条件编译,根据 #ifdef 等条件决定哪些代码要编译,最后删除所有注释,因为计算机不需要看注释。

编译(Compilation)

gcc -S hello.i -o hello.s

编译阶段会把预处理后的代码转换成汇编代码。汇编代码非常接近机器代码,同时人也能看得懂,它可以直接操作硬件。生成的汇编文件包含了CPU能够理解的指令,比如把数据移动到寄存器、调用函数、返回结果等。

生成的汇编文件 hello.s

    .file   "hello.c"
    .text
    .section    .rodata
.LC0:
    .string "Hello, World!"
    .text
    .globl  main
    .type   main, @function
main:
    pushq   %rbp
    movq    %rsp, %rbp
    leaq    .LC0(%rip), %rdi
    call    printf@PLT
    movl    $0, %eax
    popq    %rbp
    ret
    .size   main, .-main

编译阶段会进行语法检查和类型检查,确保我们的代码符合C语言的语法规则,变量类型使用正确。同时编译器会优化代码,删除无用的代码,提高程序的执行效率。

汇编(Assembly)

gcc -c hello.s -o hello.o

汇编阶段把汇编代码转换成目标文件。目标文件是二进制文件,由0和1构成,人类无法直接阅读。这个文件包含了机器码,也就是CPU能够直接执行的指令。

汇编器的主要工作是转换指令格式。它把汇编语言中的指令转换成对应的机器码,比如把 mov 指令转换成特定的二进制序列。同时汇编器会生成符号表,记录函数名、变量名等符号信息,这些信息在后面的链接阶段会用到。

链接(Linking)

gcc hello.o -o hello

链接阶段把目标文件转换成可执行文件。链接器会合并多个目标文件,解析符号引用,找到printf等函数的实际地址,把标准库、第三方库的代码合并进来,最终生成操作系统能识别的可执行文件格式。

链接器的一个重要工作是解析符号。当我们的代码调用printf函数时,编译器并不知道printf函数的具体实现在哪里。链接器会在标准库中找到printf函数的实现,并建立正确的调用关系。这样程序运行时就能正确调用到printf函数了。

一步完成编译

gcc hello.c -o hello
./hello

实际上我们通常不需要手动执行每一步,编译器可以一步完成所有工作。当我们运行 gcc hello.c -o hello 时,编译器内部会自动执行预处理、编译、汇编、链接的所有步骤,最终生成可执行文件。

4.1.2 Rust编译流程详解

编写源代码

Rust的hello world程序看起来更简洁:

// main.rs
fn main() {
    println!("Hello, World!");
}

Rust代码不需要显式包含头文件,因为Rust的模块系统会自动处理依赖关系。println!是一个宏,比C语言的printf更安全,它会在编译时检查参数类型,防止格式化字符串错误。

编译

rustc main.rs

Rust编译器只需要一步就能完成编译。虽然命令简单,但Rust的编译过程比C更复杂。编译器不仅要检查语法和类型,还要检查所有权和借用规则、生命周期、并发安全等Rust特有的安全特性。

Rust编译器会进行更严格的类型检查。它会确保所有变量都有明确的类型,检查引用是否有效,验证所有权转移是否正确。这些检查在编译时就能发现很多潜在的内存安全问题。

运行

./main

生成的可执行文件可以直接运行,输出结果和C语言程序一样。

4.1.3 编译流程对比

阶段C语言命令Rust命令输出文件主要区别
预处理gcc -E hello.c -o hello.i自动处理.i 文件Rust的宏系统更强大,预处理更智能
编译gcc -S hello.i -o hello.s自动处理.s 文件Rust有更多安全检查,编译更复杂
汇编gcc -c hello.s -o hello.o自动处理.o 文件原理相同,但Rust生成更多元数据
链接gcc hello.o -o hello自动处理可执行文件Rust的链接器更智能,依赖管理更好
一步完成gcc hello.c -o hellorustc main.rs可执行文件Rust自动化程度更高

C语言需要手动管理编译步骤和文件依赖,开发者需要了解每个阶段的作用。Rust编译器自动处理所有细节,开发者只需要关注代码逻辑,编译器会确保代码的安全性。

4.1.4 编译过程中的文件类型

编译过程中会生成不同类型的文件,每种文件都有特定的用途。

源代码文件是我们编写的程序,通常以.c或.rs为扩展名。这些文件人类可以阅读和编辑,包含了程序的逻辑和算法。

预处理文件是展开宏和头文件后的代码,通常以.i为扩展名。这个文件包含了所有头文件的内容,可以用来调试预处理阶段的问题。

汇编文件是转换成汇编语言的代码,通常以.s为扩展名。汇编代码接近机器语言,但仍然可以阅读,可以用来学习汇编语言或进行底层优化。

目标文件是编译后的机器码,但还没有链接,通常以.o为扩展名。目标文件包含了机器指令,但函数调用还没有解析,不能直接运行。

可执行文件是最终的程序,可以直接运行。在Unix/Linux系统中,可执行文件通常没有扩展名,这是操作系统的传统。

4.1.5 实际编译示例

让我们看一个更复杂的例子,理解为什么需要多文件编译。

C语言多文件编译

在实际项目中,我们通常会把代码分成多个文件。比如一个简单的数学库:

// math.h - 头文件,声明函数
int add(int a, int b);
int multiply(int a, int b);

// math.c - 实现文件
#include "math.h"

int add(int a, int b) {
    return a + b;
}

int multiply(int a, int b) {
    return a * b;
}

// main.c - 主文件
#include <stdio.h>
#include "math.h"

int main() {
    int result1 = add(5, 3);
    int result2 = multiply(4, 6);
    printf("5 + 3 = %d\n", result1);
    printf("4 * 6 = %d\n", result2);
    return 0;
}

编译这样的多文件项目需要分步进行:

gcc -c math.c -o math.o    # 编译math.c
gcc -c main.c -o main.o    # 编译main.c
gcc math.o main.o -o program  # 链接所有目标文件

分步编译的好处是可以进行增量编译。当我们只修改了main.c文件时,只需要重新编译main.c,然后重新链接,而不需要重新编译math.c。这样可以节省编译时间,特别是在大型项目中。

Rust多文件编译

Rust的多文件编译更简单:

// math.rs - 模块文件
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

pub fn multiply(a: i32, b: i32) -> i32 {
    a * b
}

// main.rs - 主文件
mod math;  // 声明使用math模块

fn main() {
    let result1 = math::add(5, 3);
    let result2 = math::multiply(4, 6);
    println!("5 + 3 = {}", result1);
    println!("4 * 6 = {}", result2);
}

编译命令只需要一行:

rustc main.rs  # 自动处理所有依赖

Rust编译器会自动分析文件依赖关系,找到所有需要的模块文件,然后并行编译,最后链接成可执行文件。这种自动化的依赖管理大大简化了项目的构建过程。

4.2 程序运行时的底层原理

4.2.1 程序是如何被加载的?

当我们运行 ./hello 时,操作系统会执行一系列复杂的步骤来加载和启动程序。

首先,操作系统会解析可执行文件的头部信息。可执行文件包含了很多元数据,比如程序需要多少内存、需要哪些库文件、程序的入口点在哪里等。操作系统读取这些信息,为程序运行做准备。

然后,操作系统会为程序分配虚拟内存空间。现代操作系统使用虚拟内存技术,每个程序都以为自己独占了整个内存空间。操作系统负责把虚拟地址映射到物理地址,防止程序之间相互干扰。分配的内存空间包括代码段、数据段、栈、堆等不同的区域。

接下来,操作系统会把可执行文件的代码复制到内存中。代码段包含了程序的指令,数据段包含了全局变量和静态变量。操作系统会初始化这些变量,设置正确的初始值。

操作系统还会设置程序的栈和堆。栈用于存储局部变量和函数调用信息,它的分配和释放是自动的。堆用于动态分配内存,程序可以在运行时申请和释放堆内存。

最后,操作系统会跳转到程序的main函数开始执行。程序计数器被设置为main函数的地址,CPU开始执行程序的第一条指令。

4.2.2 为什么需要这些步骤?

虚拟内存是现代操作系统的重要特性。它让每个程序都以为自己独占了整个内存空间,但实际上多个程序可以同时运行在有限的物理内存中。操作系统通过内存管理单元(MMU)把虚拟地址转换成物理地址,实现了内存隔离和保护。

栈和堆有不同的用途。栈是后进先出的数据结构,适合存储局部变量和函数调用信息。当函数被调用时,参数和返回地址被压入栈中;当函数返回时,这些信息被弹出栈。堆是动态分配的内存区域,程序可以在运行时申请任意大小的内存,但需要手动管理内存的分配和释放。

程序运行时还需要加载库文件。很多程序都使用了标准库函数,比如printf、malloc等。这些函数的代码不在我们的程序中,而是在系统库文件中。操作系统会在程序启动时把这些库文件加载到内存中,建立正确的函数调用关系。

4.2.3 程序执行的过程

CPU执行程序的过程遵循冯·诺依曼体系结构的设计。程序和数据都存储在内存中,CPU按顺序执行指令。

CPU的执行过程包括五个步骤:取指令、解码、执行、写回、重复。首先,CPU从内存中读取下一条指令。指令通常包含操作码和操作数,操作码告诉CPU要执行什么操作,操作数告诉CPU操作的对象。

然后,CPU解码指令,理解这条指令要做什么。比如,如果指令是加法指令,CPU就知道要把两个数相加。接下来,CPU执行指令,进行实际的运算操作。运算的结果可能是一个数值,也可能是一个内存地址。

最后,CPU把结果写回到内存或寄存器中。寄存器是CPU内部的高速存储器,用于临时存储数据和地址。完成一条指令后,CPU继续执行下一条指令,这个过程不断重复,直到程序结束。

这种设计让CPU能够执行任何存储在内存中的程序,只要程序符合CPU的指令集架构。现代CPU还采用了流水线技术,可以同时处理多条指令的不同阶段,提高执行效率。

4.3 理解编译错误和运行时错误

4.3.1 编译错误的原理

编译错误发生在编译阶段,编译器会检查代码的语法和语义,发现问题时就会报错。

语法错误是最常见的编译错误。比如缺少分号、括号不匹配、关键字拼写错误等。编译器在语法分析阶段会检查代码是否符合编程语言的语法规则。如果发现语法错误,编译器会停止编译并显示错误信息。

int main() {
    printf("Hello, World!\n"  // 缺少分号
    return 0;
}

类型错误是另一种常见的编译错误。编译器会检查变量的类型使用是否正确,比如把字符串赋值给整数变量,或者调用函数时参数类型不匹配。

int main() {
    int a = "hello";  // 类型不匹配
    return 0;
}

编译器还会检查其他问题,比如未定义的变量、未声明的函数、重复定义等。这些检查帮助我们在程序运行前发现潜在的问题。

4.3.2 运行时错误的原理

运行时错误发生在程序执行过程中,通常更难发现和调试。

段错误(Segmentation Fault)是一种常见的运行时错误。当程序试图访问无效的内存地址时,操作系统会终止程序并报告段错误。常见的原因包括访问空指针、访问已释放的内存、数组越界等。

int main() {
    int* ptr = NULL;
    *ptr = 42;  // 访问空指针,导致段错误
    return 0;
}

栈溢出是另一种运行时错误。当函数调用层次过深,或者局部变量占用过多栈空间时,就会发生栈溢出。无限递归是导致栈溢出的常见原因。

void infinite_recursion() {
    infinite_recursion();  // 无限递归,导致栈溢出
}

内存泄漏也是一种运行时问题。当程序分配了内存但没有释放时,就会造成内存泄漏。虽然程序可能正常运行,但会逐渐消耗系统内存,最终可能导致系统性能下降。

编译错误通常比运行时错误更容易发现和修复,因为编译器会明确指出错误的位置和原因。运行时错误可能只在特定条件下出现,需要更多的调试技巧来定位问题。

4.4 思考题

  1. 为什么C语言需要手动链接库文件,而Rust可以自动处理?
  2. 预处理阶段为什么要展开头文件?直接包含不行吗?
  3. 为什么需要汇编这一步?不能直接从高级语言生成机器码吗?
  4. 虚拟内存的作用是什么?为什么每个程序都需要?
  5. 编译错误和运行时错误的区别是什么?哪种更严重?