01-写了include之后到底发生了什么

写了include之后到底发生了什么

1. 平时大家怎么用

写 C 写久了之后,#include 这东西很容易被敲得越来越熟,熟到最后几乎不看它。

比如你写串口,就先来一句:

c 复制代码
#include "uart.h"

写标准输出,就来一句:

c 复制代码
#include <stdio.h>

写久了之后,#include 很容易被理解成一种"把某个东西接进来"的动作。

好像写完这一句,编译器就知道这个头文件里的函数、变量、宏都归你用了。

所以很多人脑子里会有一个很自然的印象:

我写了 #include,后面那些东西就归我用了。

这个印象拿来入门勉强够用,但只要你开始自己拆工程、写头文件、整理模块,很快就会发现这句话其实糊得很厉害。

2. 大家容易怎么误解

很多人会把 #include 误解成下面几种东西:

  • 它像 import
  • 它像"链接某个库"
  • 它像"告诉编译器去别的文件里找实现"
  • 它只是一个"声明依赖关系"的标记

这些理解不算离谱,因为从使用体验上看,确实很像。

你写了 #include "a.h",后面就能调用 a.h 里声明的函数;

不写,编译器往往就开始报错。

于是很多人自然会以为:

#include 的本质,是把另一个模块"引进来"。

但问题也就在这里:

像,不等于就是。

如果这里一直只停留在"好像是导入",后面很多错你会越看越像玄学。

3. 这种误解会导致什么问题

如果你把 #include 理解成"导入模块",后面最常见的不是一句"原来理解错了",而是工程开始出现一些你一眼看不懂的报错。

最典型的一类就是:

  • 明明 include 了
  • 明明对应头文件里也写了
  • 但编译器还是说类型不完整、结构体未定义,或者链接阶段重复定义

很多人第一次遇到这种问题,会先怀疑路径、怀疑顺序、怀疑编译器,最后把自己绕进去。

再往下拆,常见坑大概有这几类。

第一类坑是头文件里乱放定义。

很多新手会在 .h 里直接写全局变量定义,甚至直接写函数实现,因为他觉得"既然别人 include 了这个文件,那这些东西就应该自动共享"。结果一旦被多个 .c 同时包含,链接阶段就开始多重定义。

第二类坑是以为 #include 有"去重"能力。

于是一个头文件里又 include 另一个头文件,另一个头文件又绕回来 include 它,最后不是重复定义,就是声明被反复展开,搞得预处理结果膨胀得很难看。

第三类坑是搞不清错误到底发生在哪一层。

有的人看到"重复定义"就以为是 #include 写错了;看到"找不到符号"就以为是头文件没 include;看到"找不到头文件"又以为是链接器问题。其实这三种报错,经常分别属于预处理、编译、链接三个不同阶段。

第四类坑是过度 include。

一个小头文件里连着 include 十几个别的头,最后任何一个细节变动都让整个工程重新编译,依赖关系越来越脏。

第五类坑是搞一个统一的 headfile.h

很多小项目喜欢这么干:把常用头文件全塞进 headfile.h,然后别的 .c.h 只需要 include 这一个文件,看起来省事很多。

但这种写法一旦开始嵌套包含,很容易把"谁依赖谁"这件事搅乱,最后出现一种非常典型的现象:

  • 明明结构体写过了
  • 明明 headfile.h 也 include 了对应头文件
  • 但编译器还是说这个结构体未定义

这类问题看起来像编译器抽风,往下拆通常还是预处理展开顺序、声明位置、依赖关系这几件事。

4. 它实际上是什么

#include 本质上不是导入模块,不是链接库,也不是运行时行为。

它本质上是:

预处理阶段的一次文本插入。

更准确一点说,预处理器看到:

c 复制代码
#include "a.h"

会去找到 a.h 的内容,然后把那份内容原地塞进来,再继续往下处理。

所以从预处理器眼里看,根本没有"头文件"和"源文件"的神圣区别,只有:

  • 当前文件里的文本
  • 被插进来的文本
  • 宏展开后的文本
  • 条件编译筛完之后剩下的文本

也就是说,编译器真正开始编译的时候,看到的通常已经不是你原来分散在多个文件里的样子了,而是一份展开后的"大文件"。

顺手补一个常被忽略的小点:

  • #include "a.h" 往往先找当前工程或当前目录
  • #include <a.h> 往往更偏向系统头文件搜索路径

这影响的是"去哪里找文件",不改变 #include 的文本插入本质。

5. 底层到底发生了什么

可以把一份 C 代码的大致流程先记成:

源文件 -> 预处理 -> 编译 -> 汇编 -> 链接

#include 发生在最前面的预处理阶段。

比如你有两个文件:

main.c

c 复制代码
#include "add.h"

int main(void) {
    return add(1, 2);
}

add.h

c 复制代码
int add(int a, int b);

如果你真的跑一遍 gcc -E main.c,会看到类似这样的预处理输出:

c 复制代码
# 0 "main.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "main.c"
# 1 "add.h" 1
int add(int a, int b);
# 2 "main.c" 2

int main(void) {
    return add(1, 2);
}

这里最值得盯住的不是那些行号标记,而是中间这一段:

  • add.h 的内容真的被插进来了
  • 插进来的位置就是 #include "add.h" 原来所在的位置
  • add.c 还是没出现

注意这里发生的事情非常朴素:

  • 没有把 add.c 自动拿进来
  • 没有把函数实现自动补进来
  • 没有自动链接任何目标文件

它只是把声明文字贴进了当前位置。

那为什么后面还能调用 add

因为编译阶段只需要先知道这个函数"长什么样",也就是它的声明;

至于它真正的实现在哪,由后面的链接阶段再去把别的目标文件拼起来。

这就是为什么:

  • #include 解决的是"让当前编译单元看到哪些文本"
  • 声明解决的是"编译器能不能先通过语义检查"
  • 链接解决的是"最终实现能不能对上号"

这三件事经常一起出现,所以新手很容易把它们混成一件事。

6. 实验

实验 1:include 之后不是"引用",而是真展开

先看这两个文件。

config.h

c 复制代码
#define SIZE 128
int buffer_size = SIZE;

main.c

c 复制代码
#include "config.h"

int main(void) {
    return buffer_size;
}

如果你只看 main.c,可能会觉得自己"用了一个头文件里的变量"。

这次不只口头说"更接近于",直接看 gcc -E main.c 的结果节选:

c 复制代码
# 0 "main.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "main.c"
# 1 "config.h" 1
int buffer_size = 128;
# 2 "main.c" 2

int main(void) {
    return buffer_size;
}

这里已经很直接了:

  • #define SIZE 128 被先展开了
  • config.h 里的定义被直接插进了 main.c

也就是说,对当前这个 .c 文件来说,config.h 里的内容已经直接变成了它自己代码的一部分。

这就是为什么头文件里一旦写了定义,它不是"共享一次",而是"谁 include 谁就拷一份进来"。

实验 2:多个源文件 include 同一个定义,会在链接时出事

现在把问题再往前推一步。

如果 a.cb.c 都写:

c 复制代码
#include "config.h"

config.h 里又有:

c 复制代码
int buffer_size = 128;

那先别急着说"链接报错",先看预处理之后会发生什么。

a.c 展开后,核心部分会变成:

c 复制代码
int buffer_size = 128;

b.c 展开后,核心部分也会变成:

c 复制代码
int buffer_size = 128;

也就是说,到编译阶段之前,这两个编译单元里已经各自带了一份同名全局变量定义。

各自编译成目标文件都不一定报错,因为每个编译单元单独看都"合法"。

真正爆炸通常发生在链接阶段:链接器发现你定义了多个同名全局符号,不知道该用哪一个。

如果把这个场景真的编过去,常见报错会长得像这样:

text 复制代码
multiple definition of `buffer_size'

如果你再往下想一步,这个报错其实已经把问题说得很直白了:

  • 不是声明重复了
  • 不是 include 失效了
  • 而是两个目标文件里真的各自有一份定义

所以这一类错,表面上看是"头文件用了两次",本质上还是:

你把定义写进了会被重复展开的地方。

这能直接说明一个事实:

#include 不会帮你共享定义,它只会复制文本。

实验 3:重复 include 为什么要头文件保护

假设有:

a.h

c 复制代码
struct Device {
    int id;
};

main.c

c 复制代码
#include "a.h"
#include "a.h"

int main(void) {
    return 0;
}

预处理器并不会天然觉得"你已经 include 过了,所以第二次算了"。

它通常还是照样再插一遍。

于是展开后更接近:

c 复制代码
struct Device {
    int id;
};

struct Device {
    int id;
};

int main(void) {
    return 0;
}

这时编译器就会抱怨同一个结构体被重复定义。

所以头文件保护:

c 复制代码
#ifndef A_H
#define A_H

struct Device {
    int id;
};

#endif

本质上不是某种"高级模块机制",而是非常朴素地防止同一段文本被重复插入。

实验 4:统一 headfile.h 为什么容易把依赖绕死

直接看代码。

a.h

c 复制代码
#ifndef A_H
#define A_H

struct Device {
    int id;
};

#endif

b.h

c 复制代码
#ifndef B_H
#define B_H

#include "headfile.h"

struct Wrapper {
    struct Device dev;
};

#endif

headfile.h

c 复制代码
#ifndef HEADFILE_H
#define HEADFILE_H

#include "b.h"
#include "a.h"

#endif

再假设 main.c 里只有一句:

c 复制代码
#include "headfile.h"

如果只看表面,会觉得:

  • main.c include 了 headfile.h
  • headfile.h 里又 include 了 b.ha.h
  • b.h 里用 struct Device 应该没问题

但麻烦就在于,预处理不是"按你脑子里的依赖图理解",它只会老老实实地按展开顺序插文本。

先看第一层展开。

第一层展开:main.c 展开 headfile.h

main.c

c 复制代码
#include "headfile.h"

先展开一层之后,接近:

c 复制代码
#ifndef HEADFILE_H
#define HEADFILE_H

#include "b.h"
#include "a.h"

#endif

这时候问题还没暴露出来,因为 b.ha.h 还没继续展开。

第二层展开:headfile.h 里的 b.h 先被展开

由于 headfile.h 里先写的是:

c 复制代码
#include "b.h"

所以预处理器会先去展开 b.h,结果接近:

c 复制代码
#ifndef B_H
#define B_H

#include "headfile.h"

struct Wrapper {
    struct Device dev;
};

#endif

这里最坑的地方在于:

  • b.h 里又 include 了 headfile.h
  • 但这时 HEADFILE_H 已经定义过了
  • 所以第二次碰到 headfile.h 时,内容会被头文件保护直接跳过

也就是说,b.h 这里并不会重新把 a.h 展开出来救场。

第三层展开:回到 headfile.h,再继续展开 a.h

b.h 这一支走完,预处理器才会回到:

c 复制代码
#include "a.h"

然后才把 a.h 里的内容补进来。

所以一路走下来,最终预处理结果的核心部分就会变成:

c 复制代码
struct Wrapper {
    struct Device dev;
};

struct Device {
    int id;
};

一眼就能看出问题:

  • struct Wrapper 先用了 struct Device
  • struct Device 的定义后出现

所以编译器报结构体未定义,或者类型不完整。

根本原因不是"没 include 到",而是你把真实依赖藏进了 headfile.h,再加上头文件保护,最后让定义出现得太晚。

这里最直接的修法,不是继续猜 include 顺序,而是把真实依赖写回去。

如果 b.h 要直接用 struct Device 的完整定义,那就老老实实先 include a.h

c 复制代码
#ifndef B_H
#define B_H

#include "a.h"
#include "headfile.h"

struct Wrapper {
    struct Device dev;
};

#endif

如果你这里只需要一个指针,而不是完整实体,那就别硬 include 完整定义,直接做前置声明:

c 复制代码
#ifndef B_H
#define B_H

struct Device;

struct Wrapper {
    struct Device *dev;
};

#endif

这里真正该记住的不是"某次顺序怎么调通了",而是:

谁需要谁,就直接依赖谁;不要指望一个总头文件替你偷偷把依赖带进来。

7. 工程里更推荐怎么写

理解了 #include 的本质之后,头文件该怎么写就会清楚很多。

第一,头文件主要放声明,不要轻易放定义。

通常适合放进 .h 的有:

  • 类型声明
  • 函数声明
  • extern 声明
  • static inline 的短小实现

通常不适合直接放进 .h 的有:

  • 普通全局变量定义
  • static 的函数实现

第二,每个头文件都应该做保护。

常见写法是 #ifndef / #define / #endif,有些编译器也支持 #pragma once。它们在解决的问题都很直接:避免同一份文本被重复展开。

第三,能前置声明的时候,不要无脑 include 全家桶。

头文件一多,编译依赖会很快发胖。很多时候你只是需要"知道这个类型存在",并不需要立刻看到它的完整定义。

第四,统一 headfile.h 这种聚合头,只适合很小的项目。

它的好处很直接:

  • 新手写起来方便
  • 小 demo 改起来快
  • 某些公共配置集中管理看起来省心

它的问题也很直接:

  • 依赖关系被隐藏
  • 包含顺序很难推理
  • 循环包含更隐蔽
  • 一改一个公共头,整个工程一起重编
  • 很容易演变成"依赖地狱"

所以更稳的建议是:

  • 每个 .c 只 include 自己真正需要的头文件
  • 每个 .h 只 include 它自己声明所必需的头文件
  • 能用前置声明就别硬 include 完整定义
  • 不要指望某个总头文件"顺便"把依赖带进来

第五,分清三个阶段的问题。

  • 找不到头文件,先看 include 路径和文件位置
  • 编译时报类型或声明错误,先看预处理之后当前编译单元到底看到了什么
  • 链接时报未定义或重复定义,先看声明和定义是不是分布错了

8. 一句话记住

#include 不是导入模块,而是预处理阶段把另一个文件的文本原地插进来。

相关推荐
文祐5 小时前
C语言用双向链表实现单调递减(递增)队列
c语言·开发语言·链表
gugucoding7 小时前
28. 【C语言】通用数据操作:`void *` 与类型无关编程
c语言·开发语言
凉、介7 小时前
ARMv8 架构下的刷 Cache 操作
c语言·笔记·学习·架构·嵌入式·arm
灯厂码农1 天前
C语言动态内存分配完全指南(malloc、calloc、realloc、free)
java·c语言·算法
wuyk5551 天前
24. C 语言模块化:不是拆几个.c 文件那么简单
c语言·开发语言·stm32·单片机
qq_241585611 天前
可用在中断中浮点数打印类似printf
c语言
C语言小火车1 天前
C++ 快速排序(Quick Sort)深度精讲:分治思想、Lomuto 分区法及三数取中优化,面试手撕必会
c语言·开发语言·c++·面试·排序算法·快速排序
aaaameliaaa1 天前
进制练习题【找出只出现一次的数字、交换两个变量(不创建临时变量)、统计二进制中1的个数、打印整数二进制的奇数位和偶数位、求两个数二进制中不同位的个数】
c语言·数据结构·笔记·算法
灯厂码农1 天前
C语言内存管理——内存对齐与共用体union
linux·服务器·c语言