C 语言系列终章|编译与链接 + 预处理
写在前面这可能是我的 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] (index4)*(26);编译的时候会先把它拆成一个个记号然后生成语法树最后优化成array[index] (index4)*8;因为编译器知道26是个常量可以直接计算。第三步汇编汇编是把汇编代码.s转换成机器可执行的二进制指令生成目标文件.objWindows或.oLinux。每一条汇编语句几乎都对应一条机器指令这个过程就是简单的 翻译不做任何优化。第四步链接链接是最复杂的一步把多个目标文件和链接库拼在一起生成最终的可执行程序。主要做三件事地址和空间分配给每个变量和函数分配内存地址符号决议找到每个符号变量、函数对应的实际地址重定位修正所有引用符号的地址举个例子我在main.c里调用了add.c里的Add函数编译main.c的时候编译器不知道Add函数的地址就先把这个地址空着。链接的时候链接器会去add.obj里找到Add函数的地址然后把main.obj里所有引用Add的地方都改成正确的地址这个过程就是重定位。二、预处理详解那些 #开头的秘密预处理是编译的第一步所有以#开头的指令都是预处理指令它们在预处理阶段就被处理了不会进入编译阶段。2.1 预定义符号编译器自带的小工具C 语言内置了一些预定义符号可以直接使用它们在预处理阶段就被替换成对应的值__FILE__ // 当前编译的源文件名 __LINE__ // 当前行号 __DATE__ // 编译日期 __TIME__ // 编译时间 __STDC__ // 如果编译器遵循ANSI C值为1否则未定义我经常用它们来打印调试信息printf(文件%s 行号%d 编译时间%s %s\n, __FILE__, __LINE__, __DATE__, __TIME__);2.2 #define 定义常量不是变量是文本替换#define最基本的用法是定义常量#define MAX 1000 #define PI 3.14159注意不要在#define最后加分号比如#define MAX 1000; // 错误写法 int a MAX; // 预处理后会变成int a 1000;; 多了一个分号在if-else里会导致语法错误2.3 #define 定义宏小心运算符优先级的坑#define还可以定义带参数的宏看起来像函数但本质还是文本替换// 求平方的宏 #define SQUARE(x) x * x注意宏只是简单的文本替换不会考虑运算符优先级比如int a 5; printf(%d\n, SQUARE(a 1)); // 你以为是(51)*(51)36实际上预处理后是a 1 * a 1 51*5111正确的写法是给每个参数和整个表达式都加上括号#define SQUARE(x) ((x) * (x)) // 正确写法2.4 宏和函数的对比宏和函数看起来很像但有本质的区别特性#define 宏函数代码长度每次使用都会插入代码程序会变长代码只存在一份每次调用都跳转到同一个地方执行速度更快没有函数调用和返回的开销稍慢有函数调用的额外开销参数类型与类型无关只要操作合法就能用必须指定参数类型不同类型需要写不同的函数调试无法调试因为预处理后就被替换了可以逐行调试递归不支持递归支持递归使用建议简单的、频繁调用的小运算用宏复杂的逻辑用函数。2.5 #和 ##神奇的字符串化和符号粘合#运算符把宏参数转换成字符串字面量#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##运算符把两个符号粘合成一个新的符号#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 条件编译选择性编译代码条件编译可以让我们选择性地编译某段代码最常用的场景是调试代码#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里定义了一个结构体struct UserData { char name[20]; int age; };如果main.c和contact.c都包含了contact.h那么这个结构体就会被定义两次编译就会报错。解决方案在每个头文件开头加上条件编译防止重复包含#ifndef __CONTACT_H__ #define __CONTACT_H__ // 头文件内容 #endif // __CONTACT_H__或者更简单的#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 语言系列第一阶段完结撒花