欢迎光临
我们一直在努力

C语言编译与链接介绍:从源文件到可执行程序的完整过程

文章目录

    • 先回答一个问题:编译器和链接器到底在忙什么?
    • 1. 翻译环境和运行环境
      • 1.1 翻译环境:把源代码变成可执行文件
      • 1.2 先区分两个环境
    • 2. 翻译环境的四步
      • 2.1 预处理(预编译):先把源文件整理好
      • 2.2 编译:把 C 语言翻译为汇编
        • 2.2.1 词法分析:字符组成一个个记号
        • 2.2.2 语法分析:记号是否符合 C 的句子结构
        • 2.2.3 语义分析:结构合法还要“有意义”
      • 2.3 汇编:把汇编文本变成目标文件
      • 2.4 链接:解决“这个名字和地址在哪里”
    • 3. 运行环境:把可执行文件变成一个进程
    • 4. 用命令亲眼观察每个中间产物
    • 5. 常见报错应该归到哪个阶段?
    • 6. 容易混淆的两件事
      • 6.1 头文件不是库
      • 6.2 .o 文件不是“半个可执行文件”
    • 总结

先回答一个问题:编译器和链接器到底在忙什么?

C 源代码是给人看的文本,处理器只能执行特定指令编码。工具链要完成几次“翻译和拼接”:先处理文本指令,再把 C 语句翻译成汇编和机器码,最后把多个文件及库中的代码地址接起来,生成操作系统可以加载的可执行文件。 在这里插入图片描述

预处理处理文本,编译理解 C,汇编生成目标文件,链接解决跨文件关系,运行环境负责加载和执行。

不同平台的文件扩展名可能不同(Windows 常见 .obj/.exe,Linux 常见 .o/无扩展名),但这条主线基本一致。本文用 GCC/Clang 风格命令演示;Visual Studio 的按钮名称不同,背后的阶段类似。

1. 翻译环境和运行环境


1.1 翻译环境:把源代码变成可执行文件

翻译环境包括预处理器、编译器、汇编器和链接器。它们可以由一个编译器驱动程序(如 gcc)按顺序调用,也可以手动拆开观察。

gcc hello.c -o hello
./hello

-o hello 只是指定输出文件名。命令成功只说明翻译阶段没有发现错误,不保证程序逻辑正确,更不保证运行时不会崩溃。

1.2 先区分两个环境

翻译环境解决“能不能生成程序”,运行环境解决“程序启动后怎样获得资源并执行”。

2. 翻译环境的四步


2.1 预处理(预编译):先把源文件整理好

预处理器主要处理以 # 开头的指令:

  • include:把头文件内容展开到当前位置;
  • define:进行宏替换;
  • if、ifdef:根据条件决定保留哪些文本;
  • 删除注释,并处理行连接等规则。

在这里插入图片描述

#include <stdio.h>
#define MESSAGE "hello"

int main()
{
puts(MESSAGE);
return 0;
}

用 -E 只做预处理:

gcc -E hello.c -o hello.i

打开 hello.i,会看到大量 stdio.h 声明以及 MESSAGE 已经变成字符串字面量。预处理器并不理解变量类型;宏只是文本替换,所以要给复杂宏的参数和整体表达式加括号:

#define SQUARE(x) ((x) * (x))

SQUARE(1 + 2) 如果定义成 x * x,会变成 1 + 2 * 1 + 2,结果不是 9。宏参数还可能被求值多次,SQUARE(i++) 就是带有副作用危险用法。

2.2 编译:把 C 语言翻译为汇编

编译器读取预处理结果,经过词法分析、语法分析和语义分析,再进行优化并生成汇编文本。可以用 -S 停在汇编阶段:

在这里插入图片描述

gcc -S hello.c -o hello.s

2.2.1 词法分析:字符组成一个个记号

词法分析器从左到右扫描字符,把字符归类为关键字、标识符、常量、运算符和分隔符。例如:

int total = price + 1;

会被看成 int(关键字)、total(标识符)、=、price、+、1 和 ;。无法识别的字符会在这一阶段附近报错:

int n = 3 @ 2; // @ 不是这里合法的 C 运算符

2.2.2 语法分析:记号是否符合 C 的句子结构

语法分析器根据 C 语言文法建立表达式和语句的结构。括号、分号和大括号缺失,往往属于语法错误:

int main(void) {
puts("hi") // 少了分号
return 0;
}

编译器可能把错误位置标在下一行,因为它直到继续读取后才确定前面的结构无法闭合。看到 expected ‘;’ 时,先检查上一行和配对括号。

2.2.3 语义分析:结构合法还要“有意义”

语义分析会检查类型、声明、作用域和表达式规则。下面代码的结构像赋值语句,但左边不是可修改对象:

3 = value; // 语义错误:常量不能被赋值

再看一个跨阶段的例子:

int add(int, int); // 只有声明
int main() {
return add(2, 3); // 调用形式正确
}

编译器可以据此生成目标代码;但如果工程没有提供 add 的定义,链接器才会报告未定义引用。这说明“编译通过”不等于“链接通过”。

2.3 汇编:把汇编文本变成目标文件

汇编器把 .s 中类似 mov、call 的指令翻译成机器码,并生成目标文件(.o 或 .obj):

gcc -c hello.c -o hello.o

-c 会完成预处理、编译和汇编,但不进行链接。目标文件已经包含机器码,却可能仍有无法确定的地址,例如调用了另一个源文件里的函数。它还包含符号表、重定位信息和调试信息(启用 -g 时)。

2.4 链接:解决“这个名字和地址在哪里”

链接器把多个目标文件和库合并,完成两件事:

  • 符号解析:把一个文件中对 add 的调用,匹配到另一个文件提供的 add 定义;
  • 重定位:目标文件生成时还不知道最终地址,链接器根据合并后的布局修补跳转和数据引用。
  • 在这里插入图片描述

    准备两个源文件:

    // math.c
    int add(int a, int b)
    {
    return a + b;
    }

    // main.c
    #include <stdio.h>
    int add(int, int); // 函数接口声明

    int main()
    {
    printf("%d\\n", add(2, 3));
    return 0;
    }

    分别编译再链接:

    gcc -c math.c -o math.o
    gcc -c main.c -o main.o
    gcc main.o math.o -o calc

    运行 calc 输出 5。如果漏掉 math.o,通常会出现 undefined reference to add(Windows 链接器有类似提示)。这不是语法错误,而是链接器找不到定义。标准库函数也需要链接;头文件只提供声明,不会把实现本身复制进程序。

    3. 运行环境:把可执行文件变成一个进程


    操作系统启动程序时,会创建进程、映射代码和数据、准备栈与堆,再调用 C 运行库初始化代码,最后进入 main。main 返回的整数会交给操作系统,通常 0 表示成功,非 0 表示失败。 在这里插入图片描述

    #include <stdio.h>

    int main()
    {
    puts("program is running");
    return 0; // 返回给操作系统的状态码
    }

    翻译环境解决“能不能生成程序”,运行环境解决“程序启动后怎样获得资源并执行”。数组越界、空指针和未初始化变量通常不会在链接阶段报错,而是在运行时造成未定义行为。

    4. 用命令亲眼观察每个中间产物

    以 hello.c 为例,可以依次执行:

    gcc -E hello.c -o hello.i # 预处理:文本变大
    gcc -S hello.i -o hello.s # 编译:得到汇编
    gcc -c hello.s -o hello.o # 汇编:得到目标文件
    gcc hello.o -o hello # 链接:得到可执行文件

    也可以让 gcc 自动完成前面步骤,只保留目标文件:

    gcc -c hello.c -o hello.o

    .i 和 .s 通常是文本,.o 和可执行文件是二进制,不能用普通文本编辑器判断它们是否正确。

    5. 常见报错应该归到哪个阶段?


    现象常见阶段先检查什么
    expected ';'、括号不匹配 语法分析 分号、大括号、上一行
    undeclared identifier 语义分析 声明、头文件、作用域
    undefined reference / unresolved external 链接 是否加入定义所在的 .o/库
    运行后崩溃、输出错误 运行环境/逻辑 越界、空指针、未初始化变量

    分阶段编译的好处,就是可以判断错误发生在翻译的哪一步。不要看到所有红字都统称为“编译错误”。

    6. 容易混淆的两件事


    6.1 头文件不是库

    头文件提供声明、宏和类型定义;库提供已经编译好的函数实现。include 让编译器知道函数怎么调用,链接器才负责把实现接入。

    6.2 .o 文件不是“半个可执行文件”

    它是一个可以重定位的目标文件,代码和数据已经有机器表示,但地址和跨文件符号可能未最终确定。多个 .o 能被链接成一个程序,也能被打包进静态库。

    总结

    在最初的学习过程中可以不必要一定了解编译与链接的底层实现,本文只浅谈编译与链接的一些简要大致内容,让人起码能够知晓一段C语言代码是如何一步步变成一个可执行程序,进而了解到从源代码到程序并不是神秘黑盒,而是一条可以拆开的流水线:预处理让文本就位,编译器确认 C 语言结构和含义,汇编器产出机器码目标文件,链接器把符号和地址接好,运行环境再创建进程并调用 main。。

    赞(0)
    未经允许不得转载:171主机测评 » C语言编译与链接介绍:从源文件到可执行程序的完整过程
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址