第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 hello | rustc 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 思考题
- 为什么C语言需要手动链接库文件,而Rust可以自动处理?
- 预处理阶段为什么要展开头文件?直接包含不行吗?
- 为什么需要汇编这一步?不能直接从高级语言生成机器码吗?
- 虚拟内存的作用是什么?为什么每个程序都需要?
- 编译错误和运行时错误的区别是什么?哪种更严重?