写在前面
这可能是我的 C 语言系列最后一篇博客了,当然后面也可能继续补充。从最开始的 Hello World,到指针、结构体,不知不觉已经把 C 语言基础全部学完了。
但之前我一直有个疑问:我们写的.c文件,到底是怎么变成电脑能运行的.exe程序的?那些#include、#define开头的指令,编译器到底是怎么处理的?
直到学了编译和链接、预处理这两章,我才终于搞懂了代码背后的底层逻辑。其实我学习数据结构也有一段时间了,这篇博客就把我学到的内容整理出来,既是给自己的 C 语言学习画一个圆满的句号,也希望能帮到和我一样的新手同学。
一、程序的翻译与运行:从.c 到.exe 的旅程
我们写的 C 语言代码,电脑是根本看不懂的 ------ 电脑只认识 0 和 1 的二进制指令。所以必须经过一个 "翻译" 的过程,把我们写的 C 语言代码转换成机器能执行的二进制指令,这个过程就是编译和链接。
1.1 两个核心环境
在 C 语言的世界里,存在两个完全不同的环境:
- 翻译环境:把源代码转换成可执行的机器指令
- 运行环境:实际执行代码的环境
一个完整的流程是:多个.c源文件 → 各自编译生成目标文件 → 所有目标文件 + 链接库 → 链接生成可执行程序 → 运行程序输出结果。
1.2 翻译过程拆解:四大步骤
翻译环境其实是由预处理、编译、汇编、链接四个步骤组成的,我们一步步来看:
第一步:预处理(预编译)
预处理是代码的 "整容手术",处理所有以#开头的预编译指令,生成.i后缀的文件。主要做这几件事:
- 展开所有
#define宏定义,删除#define语句 - 处理
#include,把包含的头文件内容原封不动地插入到当前位置 - 处理条件编译指令(
#if、#ifdef、#endif等) - 删除所有注释
- 添加行号和文件名标识,方便编译器生成调试信息
我之前写通讯录的时候,把所有函数声明都放在了
contact.h里,预处理的时候,这些声明就会被全部插入到contact.c和main.c中,这样编译器才能知道这些函数是存在的。可以跳转到:数据结构 # 数据结构 | 学习与刷题笔记_DreamLuminous的博客-CSDN博客
第二步:编译
编译是把预处理后的.i文件,经过词法分析、语法分析、语义分析、优化 ,生成对应的汇编代码文件.s。
- 词法分析:把代码拆成一个个 "记号",比如关键字、标识符、数字、运算符
- 语法分析:把记号组成语法树,检查有没有语法错误
- 语义分析:检查类型是否匹配、变量是否声明等语义错误
- 优化:生成更高效的汇编代码
比如我们写
array[index] = (index+4)*(2+6);,编译的时候会先把它拆成一个个记号,然后生成语法树,最后优化成array[index] = (index+4)*8;,因为编译器知道2+6是个常量,可以直接计算。
第三步:汇编
汇编是把汇编代码.s转换成机器可执行的二进制指令,生成目标文件.obj(Windows)或.o(Linux)。每一条汇编语句几乎都对应一条机器指令,这个过程就是简单的 "翻译",不做任何优化。
第四步:链接
链接是最复杂的一步,把多个目标文件和链接库拼在一起,生成最终的可执行程序。主要做三件事:
- 地址和空间分配:给每个变量和函数分配内存地址
- 符号决议:找到每个符号(变量、函数)对应的实际地址
- 重定位:修正所有引用符号的地址
举个例子:我在
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.c和contact.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 语言系列第一阶段完结撒花!