C 语言系列终章|编译与链接 + 预处理

写在前面

这可能是我的 C 语言系列最后一篇博客了,当然后面也可能继续补充。从最开始的 Hello World,到指针、结构体,不知不觉已经把 C 语言基础全部学完了。

但之前我一直有个疑问:我们写的.c文件,到底是怎么变成电脑能运行的.exe程序的?那些#include#define开头的指令,编译器到底是怎么处理的?

直到学了编译和链接、预处理这两章,我才终于搞懂了代码背后的底层逻辑。其实我学习数据结构也有一段时间了,这篇博客就把我学到的内容整理出来,既是给自己的 C 语言学习画一个圆满的句号,也希望能帮到和我一样的新手同学。


一、程序的翻译与运行:从.c 到.exe 的旅程

我们写的 C 语言代码,电脑是根本看不懂的 ------ 电脑只认识 0 和 1 的二进制指令。所以必须经过一个 "翻译" 的过程,把我们写的 C 语言代码转换成机器能执行的二进制指令,这个过程就是编译和链接

1.1 两个核心环境

在 C 语言的世界里,存在两个完全不同的环境:

  • 翻译环境:把源代码转换成可执行的机器指令
  • 运行环境:实际执行代码的环境

一个完整的流程是:多个.c源文件 → 各自编译生成目标文件 → 所有目标文件 + 链接库 → 链接生成可执行程序 → 运行程序输出结果。

1.2 翻译过程拆解:四大步骤

翻译环境其实是由预处理、编译、汇编、链接四个步骤组成的,我们一步步来看:

第一步:预处理(预编译)

预处理是代码的 "整容手术",处理所有以#开头的预编译指令,生成.i后缀的文件。主要做这几件事:

  1. 展开所有#define宏定义,删除#define语句
  2. 处理#include,把包含的头文件内容原封不动地插入到当前位置
  3. 处理条件编译指令(#if#ifdef#endif等)
  4. 删除所有注释
  5. 添加行号和文件名标识,方便编译器生成调试信息

我之前写通讯录的时候,把所有函数声明都放在了contact.h里,预处理的时候,这些声明就会被全部插入到contact.cmain.c中,这样编译器才能知道这些函数是存在的。可以跳转到:数据结构 # 数据结构 | 学习与刷题笔记_DreamLuminous的博客-CSDN博客

第二步:编译

编译是把预处理后的.i文件,经过词法分析、语法分析、语义分析、优化 ,生成对应的汇编代码文件.s

  • 词法分析:把代码拆成一个个 "记号",比如关键字、标识符、数字、运算符
  • 语法分析:把记号组成语法树,检查有没有语法错误
  • 语义分析:检查类型是否匹配、变量是否声明等语义错误
  • 优化:生成更高效的汇编代码

比如我们写array[index] = (index+4)*(2+6);,编译的时候会先把它拆成一个个记号,然后生成语法树,最后优化成array[index] = (index+4)*8;,因为编译器知道2+6是个常量,可以直接计算。

第三步:汇编

汇编是把汇编代码.s转换成机器可执行的二进制指令,生成目标文件.obj(Windows)或.o(Linux)。每一条汇编语句几乎都对应一条机器指令,这个过程就是简单的 "翻译",不做任何优化。

第四步:链接

链接是最复杂的一步,把多个目标文件和链接库拼在一起,生成最终的可执行程序。主要做三件事:

  1. 地址和空间分配:给每个变量和函数分配内存地址
  2. 符号决议:找到每个符号(变量、函数)对应的实际地址
  3. 重定位:修正所有引用符号的地址

举个例子:我在main.c里调用了add.c里的Add函数,编译main.c的时候,编译器不知道Add函数的地址,就先把这个地址空着。链接的时候,链接器会去add.obj里找到Add函数的地址,然后把main.obj里所有引用Add的地方都改成正确的地址,这个过程就是重定位。


二、预处理详解:那些 #开头的秘密

预处理是编译的第一步,所有以#开头的指令都是预处理指令,它们在预处理阶段就被处理了,不会进入编译阶段。

2.1 预定义符号:编译器自带的小工具

C 语言内置了一些预定义符号,可以直接使用,它们在预处理阶段就被替换成对应的值:

cpp 复制代码
__FILE__    // 当前编译的源文件名
__LINE__    // 当前行号
__DATE__    // 编译日期
__TIME__    // 编译时间
__STDC__    // 如果编译器遵循ANSI C,值为1,否则未定义

我经常用它们来打印调试信息:

cpp 复制代码
printf("文件:%s 行号:%d 编译时间:%s %s\n", 
       __FILE__, __LINE__, __DATE__, __TIME__);

2.2 #define 定义常量:不是变量,是文本替换

#define最基本的用法是定义常量:

cpp 复制代码
#define MAX 1000
#define PI 3.14159

注意 :不要在#define最后加分号!比如:

cpp 复制代码
#define MAX 1000;  // 错误写法
int a = MAX;
// 预处理后会变成:int a = 1000;; 多了一个分号,在if-else里会导致语法错误

2.3 #define 定义宏:小心运算符优先级的坑

#define还可以定义带参数的宏,看起来像函数,但本质还是文本替换:

cpp 复制代码
// 求平方的宏
#define SQUARE(x) x * x

注意:宏只是简单的文本替换,不会考虑运算符优先级!比如:

cpp 复制代码
int a = 5;
printf("%d\n", SQUARE(a + 1));
// 你以为是(5+1)*(5+1)=36,实际上预处理后是a + 1 * a + 1 = 5+1*5+1=11

正确的写法是给每个参数和整个表达式都加上括号:

cpp 复制代码
#define SQUARE(x) ((x) * (x))  // 正确写法

2.4 宏和函数的对比

宏和函数看起来很像,但有本质的区别:

特性 #define 宏 函数
代码长度 每次使用都会插入代码,程序会变长 代码只存在一份,每次调用都跳转到同一个地方
执行速度 更快,没有函数调用和返回的开销 稍慢,有函数调用的额外开销
参数类型 与类型无关,只要操作合法就能用 必须指定参数类型,不同类型需要写不同的函数
调试 无法调试,因为预处理后就被替换了 可以逐行调试
递归 不支持递归 支持递归

使用建议:简单的、频繁调用的小运算用宏,复杂的逻辑用函数。

2.5 #和 ##:神奇的字符串化和符号粘合

  • #运算符:把宏参数转换成字符串字面量

    cpp 复制代码
    #define PRINT(n) printf("the value of "#n " is %d\n", n);
    int a = 10;
    PRINT(a); // 预处理后变成:printf("the value of ""a"" is %d\n", a);
    // 输出:the value of a is 10
  • ##运算符:把两个符号粘合成一个新的符号

    cpp 复制代码
    #define GENERIC_MAX(type) \
    type type##_max(type x, type y) \
    { \
        return x > y ? x : y; \
    }
    
    GENERIC_MAX(int)   // 生成int_max函数
    GENERIC_MAX(float) // 生成float_max函数
    
    int main()
    {
        printf("%d\n", int_max(2, 3));    // 输出3
        printf("%f\n", float_max(3.5, 4.5)); // 输出4.5
        return 0;
    }

2.6 条件编译:选择性编译代码

条件编译可以让我们选择性地编译某段代码,最常用的场景是调试代码:

cpp 复制代码
#define LUMINOUS_DEBUG 1

int main()
{
    int arr[10] = {0};
    for (int i = 0; i < 10; i++)
    {
        arr[i] = i;
#if LUMINOUS_DEBUG
        printf("%d ", arr[i]); // 只有定义了LUMINOUS_DEBUG,这段代码才会被编译
#endif
    }
    return 0;
}

这样我们不用删除调试代码,只需要把LUMINOUS_DEBUG改成 0,调试代码就不会被编译了。

2.7 头文件包含:为什么不能重复包含?

#include的本质是把头文件的内容插入到当前位置,如果一个头文件被包含多次,它的内容就会被插入多次,导致重定义错误

比如我在contact.h里定义了一个结构体:

cpp 复制代码
struct UserData
{
    char name[20];
    int age;
};

如果main.ccontact.c都包含了contact.h,那么这个结构体就会被定义两次,编译就会报错。

解决方案:在每个头文件开头加上条件编译,防止重复包含:

cpp 复制代码
#ifndef __CONTACT_H__
#define __CONTACT_H__

// 头文件内容

#endif // __CONTACT_H__

或者更简单的:

cpp 复制代码
#pragma once

三、我的 C 语言学习总结

从去年9月开始学 C 语言并在10月初写博客,到现也有 9个月了。这段时间我写了很多代码:

  • 基础语法阶段:写了各种循环、分支、数组、函数的练习题
  • 指针阶段:搞懂了指针和数组的关系,写了字符串操作的各种函数
  • 结构体阶段:用结构体实现了学生管理系统
  • 数据结构阶段:用 C 语言实现了顺序表、链表、栈、队列
  • 项目阶段:写了基于顺序表的通讯录,踩了无数坑

现在回头看,C 语言最难的不是语法,而是思维方式------ 要学会用计算机的角度思考问题,理解内存、地址、指针这些底层概念。

很多人说 C 语言难,但我觉得 C 语言是最 "诚实" 的语言 ------ 你写的每一行代码,都能清楚地知道它在内存里是怎么运行的。这种对底层的理解,是学习其他任何语言都无法替代的。


最后:从 C 到 C++,新的开始

C 语言的基础已经打牢了,但这不是结束,而是新的开始。这学期学校已经开了 C++ 课程,我也开始了 C++ 的学习。

其实 C++ 就是 C 语言的升级版,很多 C 语言的知识都可以直接用到 C++ 里。比如我之前写的顺序表,用 C++ 的类和引用重写之后,代码变得更简洁、更优雅了。

接下来我会继续更新数据结构 和 C++ 系列的博客,当然后面继续更新系统内容,把我的学习过程记录下来。如果你也刚学完 C 语言,准备学 C++,欢迎一起交流学习!

C 语言系列第一阶段完结撒花!

相关推荐
.千余1 小时前
【C++】 String 常用操作:增删查改 | 查找 | 截取 | IO
java·服务器·开发语言·c++·笔记·学习
xian_wwq1 小时前
【学习笔记】「大模型安全:攻击面演化史」第 04 篇-模型窃取与供应链安全
笔记·学习·ai安全
十月的皮皮1 小时前
C语言学习笔记20260607-判断一个数是否为2的n次方(三种方法)
c语言·笔记·学习
caimouse1 小时前
Reactos 第 3 章 内存管理 — 【下篇】换出、Section、池
c语言·开发语言·windows·架构
San813_LDD1 小时前
[量化]《多线程数据同步精讲:std::mutex 的底层原理与最佳实践》
c语言·数据结构
sheeta19981 小时前
LeetCode 补拙笔记 日期:2026.06.07 题目:49. 字母异位词分组
笔记·算法·leetcode
secret_to_me1 小时前
buildRoot编译rootfs实战
linux·c语言·c++·ubuntu·电脑·buildroot
问心无愧05131 小时前
ctf show web入门101
android·前端·笔记
AOwhisky1 小时前
MySQL 学习笔记(第五期):用户管理与权限控制
linux·运维·数据库·笔记·学习·mysql