C语言extern加不加有什么区别?一文讲透声明与定义
2026/9/23 13:57:34 网站建设 项目流程

C 语言群里,每隔几天就会冒出一个类似的问题:为什么头文件里写了extern int x;,另一个文件里写int x = 5;,有时候能编译过,有时候报重复定义?还有人问得更直接——加 exten 和不加 exten 到底有什么区别?

先把拼写纠正过来,C 语言里根本没有 exten 这个关键字,它少了个 r,完整拼写是 extern。但用 exten 去搜,照样能搜出一堆人在问同样的问题,可见这个少打一个字母的写法在初学者里相当普遍,干脆就拿它当引子。

这篇文章我打算把 extern 这件事彻底讲透。你会搞明白三件事:第一,extern 到底解决什么问题;第二,加了它和没加它,在编译器和链接器眼里分别是什么效果;第三,也是我写这篇文章的最终目的,在多文件工程和单片机项目里,header 里的 extern 到底应该怎么用才能不踩坑。适合刚学 C 语言的大学生、从 Arduino 或图形化编程转过来的爱好者,以及写过几个小项目但一直对全局变量声明摸不着头脑的开发者。

1. extern 不是"引入代码",它是"声明外部定义"

很多初学者第一次遇到 extern,是在看别人多文件项目时。看到某个 .c 文件里用了另一个文件里的变量,而这个变量前面加了 extern,第一反应就是"extern 是不是类似 import 那种引入外部代码的机制"。这个理解不能算全错,但非常危险,因为它的底层逻辑完全不同。

extern 的正式身份是存储类别说明符(storage-class specifier),C 语言里和它并列的还有 auto、register、static、typedef。存储类别说明符的作用,是告诉编译器一个标识符的存储方式和链接属性(linkage)。链接属性才是理解 extern 的关键,它分三种:外部链接(external linkage)、内部链接(internal linkage)、无链接(no linkage)。

文件作用域,也就是所有函数外面定义的变量,默认是外部链接;函数声明和函数定义默认也是外部链接。内部链接靠 static 实现,意思是只在当前翻译单元,也就是一个 .c 文件加它 include 的头文件里可见。无链接对应块作用域的局部变量,比如函数内部定义的变量,外面谁也看不见。

所以 extern 做的事情,往简单了说,就是两件:第一,把一个标识符的可见性提升到外部链接;第二,在另一个编译单元里重新声明一个已有外部链接的标识符。用大白话讲,extern 不是创建一个新变量,而是告诉编译器:这个变量的定义在别处,你先把名字记下来,等链接的时候去别的地方找它的地址。

注意:extern 声明不是"导入",更像打欠条。编译器记账,链接器最后统一结算。

为什么非要这么设计?这就要说到 C 语言支持分离编译的历史。C 允许把程序拆成多个文件独立编译,每个 .c 文件先编译成目标文件(.o 或 .obj),最后由链接器把多个目标文件合成可执行程序。如果 file1.c 定义了一个全局变量 shared,file2.c 想用它,编译器在编译 file2.c 时根本不知道 file1.c 里有什么,它只认本次编译看到的内容。这个时候,extern 声明就是唯一的桥梁——file2.c 的编译器知道存在一个 int 类型的变量叫 shared,至于它到底在哪,那是链接器的事。这种"编译期记名字、链接期找地址"的模型,和 import 那种把代码复制过来的思路有本质区别。

2. 声明和定义搞不清,extern 用起来全是坑

2.1 什么是定义,什么是声明

我在带新人的时候发现,很多人写了几百行 C 代码,却从没仔细想过"声明"和"定义"是两个不同概念。用标准术语说:

  • 定义(definition):除了说明标识符的类型和名字,还要分配存储空间。int x = 5;就是定义,它让编译器在数据段或 BSS 段给 x 留出内存。
  • 声明(declaration):只说明标识符的类型和名字,不分配存储空间。extern int x;典型的声明。

这个区分是所有链接规则的地基。若编译器在文件作用域看到int x;,它可不只是"声明了一个 int",按 C 标准,这是一个试探性定义(tentative definition)。后面我会专门解释试探性定义,你先记住结论:在文件作用域写int x;,基本等于定义了一个零初始化的变量;而写extern int x;,不管后面有没有初始化值,本质都是声明,不产生新的存储分配。

听起来很简单?但工程里出的幺蛾子,几乎全都出在这个"基本等于"和"本质就是"上面。

2.2 从编译器和链接器的视角看一遍

用最简单的两文件例子说明:

// file1.c int shared = 42; // file2.c #include <stdio.h> int shared; int main(void) { printf("%d\n", shared); return 0; }

一起编译再链接,很多工具链会直接报 duplicate symbol。为什么?因为int shared;是试探性定义,标准说如果整个翻译单元里没有显式定义,它就被当成定义处理,初始化为 0。如果程序里已经有int shared = 42;这个显式定义,两个定义撞在一起,行为就取决于环境和链接器,很危险。

解决办法是明确告诉编译器"这只是声明":

// file1.c int shared = 42; // file2.c #include <stdio.h> extern int shared; int main(void) { printf("%d\n", shared); return 0; }

加了 extern,file2.c 里的 shared 只是声明,不分配任何存储空间,链接器自然能找到 file1.c 里的定义。这一个细节,就是"加 extern 和不加 extern"最核心的区别——加与不加,不是风格问题,而是这个标识符到底是"定义"还是"声明"。

2.3 试探性定义:藏在 int x; 背后的规则

试探性定义这个概念,很多写了几年 C 的人都未必说得清。C11 标准 6.9.2 里有相关规定:文件作用域的一个对象声明,如果带初始化器,那它一定是外部定义;如果不带初始化器,也没有 extern 或其他存储类别说明符,那它就是试探性定义。试探性定义的规则是:翻译单元结束前如果没出现该对象的显式定义,则在 BSS 段分配零初始化的存储;如果出现了显式定义,试探性定义退化为普通声明。

听起来还行?问题在于,多个翻译单元同时出现相同试探性定义时,标准措辞留了余地,不同编译器和链接器处理方式不同,有的合并,有的直接报错。我实际见过 GNU 工具链下多个 .c 文件都写int shared;能正常链接的情况,也见过同样的代码换一个环境立刻 duplicate symbol。跨翻译单元的冲突定义,标准并没有强制要求统一处理,很多实现选择合并,但这绝对不是一个应该依赖的特性。

结论很简单:跨文件共享变量,应该一个文件里定义,其他文件 extern 声明。不要把程序行为交给具体平台决定,迟早出事。

3. 四种场景对比:加 extern 和不加 extern 到底差在哪

3.1 全局变量场景:int x 与 extern int x

这是最经典的对比。在同一个 .c 文件里,int x;extern int x;表面看都能访问 x,语义却完全不同:

// 不加 extern int count; // 试探性定义,通常当作定义,初始化为 0 count = 5; // 正常赋值 // 加 extern extern int count; // 纯声明,不分配存储 count = 5; // 能编译,但链接时找不到定义就报 undefined reference

如果在同一个文件里既写extern int count;又写int count;,它们指向同一个实体,因为 extern 声明不产生新实体。但如果你只写 extern 声明,却没有地方定义它,一旦取地址或赋值,链接器就会在原位报错——拿着借条找不到结算对象。

这里有个细节值得一提:单纯声明一个 extern 变量,但从来不碰它,链接器根本不会为它生成任何引用,那自然也不会去找定义。这种代码是定时炸弹,哪天你在函数里加一句printf("%d", x);,链接错误马上冒出来。

注意:extern int x;在多个文件里写一百遍都不算重复定义,因为每次都是声明。但真正带初始化器的定义只能出现一次。

3.2 函数声明场景:为什么没人写 extern void foo(void)

函数和变量在这里不一样。函数声明默认就是 extern,所以你写void foo(void);和写extern void foo(void);完全等价。C 标准里明确说过,函数声明如果没有存储类别说明符,链接属性按照带 extern 处理。

所以你会看到一个有趣的现象:正常工程里几乎不会有人写extern void foo(void);,因为那个 extern 是多余的。函数天生就是外部链接,声明它就已经告诉编译器"这个函数定义在别的地方"。

如果你给函数定义加了 static,情况就变了。static 让函数变成内部链接,别的文件里就算写了 extern 声明,链接时照样找不到。static 和 extern 在函数这里是对立关系,一个是"只在本文件可见",一个是"外部可见"。

3.3 头文件里应该怎么安排 extern 声明

头文件是多文件工程的信息枢纽。最常见的错误,是把变量定义直接写在头文件里:

// config.h —— 错 int timeout = 1000; // 这是定义,不是声明

如果整个项目只有一个 .c 文件包含 config.h,暂时没事。但只要两个 .c 文件 include,就会产生两份定义,链接时极大概率报重复定义。正确做法是让头文件只做声明:

// config.h —— 对 #ifndef CONFIG_H #define CONFIG_H extern int timeout; // 声明,不分配存储 #endif
// config.c —— 定义唯一的地方 int timeout = 1000;

"头文件放 extern 声明,.c 文件放定义",是 C 工程的标准范式。你去看 Linux 内核、看各种开源库,头文件里满屏都是 extern,而每个变量只在某个源文件里定义一次。遵循这个模式,工程再大,变量重复定义问题也能基本杜绝。

3.4 一张表汇总四种写法的语义

为了方便对比,我列了一张表:

写法语义典型风险
int x;试探性定义,无显式定义时分配零初始化存储多文件下可能重复定义
int x = 0;显式定义,分配存储并初始化出现在两个 .c 文件里必炸
extern int x;纯声明,不分配存储缺少对应定义时报 undefined reference
static int x;定义并限制为内部链接其他文件无法引用

函数也有对应关系,只不过void foo(void);默认就是 extern,所以平时不写也没关系。

4. 多文件工程报错排查:multiple definition 与 undefined reference

4.1 duplicate symbol(重复定义)是怎么来的

典型的报错长这样,Linux 下 GCC 工具链的输出一般是:

/usr/bin/ld: /tmp/ccXXXX.o:(.bss+0x0): multiple definition of `shared' /usr/bin/ld: /tmp/ccYYYY.o:(.bss+0x0): first defined here

这类错误八成和试探性定义或头文件里写定义有关。我之前接手过一个项目,六个 .c 文件,三个都写了int flag;这种全局变量。开发时用的是老版本 GCC,链接器把多个试探性定义合并了,一直没暴露。后来换到严格的链接参数或者换编译器,立刻满屏 duplicate symbol,连改都不知道从哪改起。

排查方法是反着来:从报错信息里找到目标文件,用nm命令看符号表。不带参数时,大写字母开头的基本是全局符号,B表示 BSS 段符号,D表示已初始化数据段符号。如果同一个符号在多个 .o 文件里都出现BD,就是有多个定义。然后定位到文件,只保留一个定义,其他都改成 extern 声明。

4.2 undefined reference:欠条找不到结算对象

另一种报错方向相反:

/usr/bin/ld: /tmp/ccZZZZ.o: undefined reference to `shared' collect2: error: ld returned 1 exit status

这个错误表示链接器找不到定义,常见原因有三个。

第一,你声明了 extern 变量,但全工程没有对应的定义。比如头文件里写了extern int timeout;,却忘了写int timeout = 1000;那个定义文件。

第二,定义存在,但目标文件没有被链接进来。IDE 工程里,某个 .c 文件没有被加入,或者 Makefile 漏写了源文件,就会出现这种奇怪情况。检查链接命令或者 IDE 里的源文件列表就行。

第三,定义和声明的类型或存储类别不一致。比如头文件声明extern int flag;,源文件里却写static int flag = 0;,flag 变成内部链接,外部自然找不到。这种错误最隐蔽,因为每个文件单独编译都没语法错误,只有链接阶段暴露。

4.3 static 与 extern 的冲突与协作

static 和 extern 用在一起,经常把人绕晕。在文件作用域,同一个变量声明里写static extern int x;是编译错误,因为两个存储类别说明符不能同时出现。但在设计层面,可以这样理解:static 在一处定义时使用,extern 在多处声明时使用。

实际工程里,若某个全局变量只想在本文件内用,就写static int x;,不要加 extern。如果你想通过头文件对外暴露一个变量,那么这个变量就不能定义成 static。有人用宏包装来绕开这个矛盾,阅读性很差,我不建议。在大型项目里,保持变量"要么 static 内部可见,要么 extern 全局可见",二选一,比发明各种技巧重要得多。

还有个容易误导人的写法:extern int x = 5;在文件作用域是合法,标准允许,效果和int x = 5;几乎一样。但这种写法非常容易让阅读者误判,我建议项目规范里直接禁止,统一成"定义不带 extern,声明才带 extern"。

5. 单片机项目里 extern 的真实用法:存储区与 volatile 的配合

5.1 为什么 51、STM32 项目里到处是 extern

搜索热词里有"单片机c语言"和"堆栈",说明不少写单片机的朋友也在纠结 extern。单片机项目与 PC 端 C 项目在 extern 使用上没有本质区别,但因为资源紧张、模块多,extern 的出现频率反而更高。

一个典型的 STM32 工程会拆成 main.c、timer.c、uart.c、sensor.c,每个模块配一个头文件。全局共享的状态变量,比如系统运行时间计数、传感器最新读数、开关标志位,往往定义在某个模块的 .c 文件里,头文件用 extern 声明,方便其他模块引用:

// timer.c volatile uint32_t system_ms = 0; void TIM_IRQHandler(void) { system_ms++; }
// timer.h #ifndef TIMER_H #define TIMER_H #include <stdint.h> extern volatile uint32_t system_ms; #endif

注意这里我加了 volatile,这是单片机场景里特别容易漏的关键字。system_ms 在中断里修改,主循环读取时如果没有 volatile,编译器可能优化成只读一次,导致逻辑错乱。extern 和 volatile 是两个独立维度:extern 解决跨文件可见性,volatile 告诉编译器别优化内存访问,缺一不可。

5.2 extern 变量到底存哪:和堆栈没有半点关系

一个高频疑问是:加了 extern 的变量是不是占堆栈?很多人觉得 extern 像"动态导入",运行时才去别处找东西,应该和堆栈有关系。其实完全不是。

extern 只影响编译和链接阶段的符号解析,不决定变量最终存哪。变量存哪里,由定义语句决定:

  • 函数内部定义的局部变量,存栈上或寄存器,不管前面有没有 extern。
  • 文件作用域带初始化器的定义,静态存储在数据段.data
  • 文件作用域未初始化或初始化为 0 的定义,静态存储在 BSS 段.bss
  • 文件作用域带 const 且初始化的定义,可能放在只读数据段.rodata

extern 声明本身不分配任何存储。所以"单片机 C 语言没有堆栈吗"这类问题,和 extern 没有直接关系。堆栈是给局部变量和函数调用用的,extern 修饰的全局变量存储在全局数据段或 BSS 段,不占堆栈。搞清楚这一点,就能理解为什么全局大数组不怕"栈溢出",真正怕的是把几百字节的大数组堆在栈上,或者定义了一堆全局变量把单片机的 RAM 吃光。

顺带提一个 C 和 C++ 的差异:C 语言里文件作用域的 const 变量默认也是外部链接,不像 C++ 默认内部链接,所以跨文件共享 const 全局变量,同样需要在头文件里 extern 声明。

5.3 属主文件模式:头文件只放声明的一个建议

单片机工程里模块众多,变量管理如果不当,最常见的连环坑是头文件互相 include,然后某个全局变量在多个地方重复定义。我的习惯是给每个模块设一个"属主文件":这个模块的全局变量只在模块自己的 .c 文件里定义,头文件只放 extern 声明,同时用防止重复包含的宏包住头文件。

举一个反面教材,我就见过有人直接在 main.h 里写:

uint8_t rx_buffer[256];

然后 main.c 和 uart.c 都 include main.h。结果两个 .c 文件各有一份 256 字节的缓冲区,整套逻辑看着没问题,实际上 uart 往它的缓冲区写,main 读的是另一个缓冲区,数据永远对不上。这种错误用调试器单步都难发现,因为两个数组地址不一样,代码逻辑却完全合理。一切问题的根源,还是那句话:头文件里放声明,不放定义。

6. 新手避坑速查:六种常见误区定位和解决

6.1 六种常见场景速查表

现象常见原因排查与解决方法
编译报 multiple definition头文件里写了变量定义,或多个 .c 写了同变量定义改为头文件 extern 声明 + 单一 .c 定义
链接报 undefined referenceextern 声明了但没定义;定义了但没参与链接;定义前加了 static确认定义存在且目标文件已加入链接;检查 static 冲突
extern 变量值被意外优化中断或多线程修改该变量却没加 volatile定义和声明处都加 volatile
声明与定义类型不一致头文件写extern int flag;,实现写float flag;保证类型完全一致,不一致可能链接报错或行为诡异
同时用 static 和 extern 修饰同一变量存储类别说明符冲突去掉其中一个,或重构访问方式
加 extern 后程序反而报错extern 声明了并不存在的变量检查定义是否存在、拼写是否一致、注意大小写

6.2 用 map 文件和 grep 快速定位全局符号

最后分享一个我实际用得很顺手的排查方法。当链接错误指向某个符号,又不太确定它定义在哪时,先用 grep 全工程搜这个标识符。先看有没有类型 名称 = 初值这种带初始化的写法,如果有且只在同一个文件出现一次,基本就定位了。如果没有带初始化的定义,再搜类型 名称;形式的试探性定义,然后用 nm 或 map 文件验证。

Windows 上的 IDE,比如 Keil、VS,打开链接后的 map 文件就可以查符号地址和所在目标文件,比在报错日志里瞎猜高效得多。GCC 工具链可以在链接参数里加-Wl,-Map=output.map生成 map 文件。map 文件里能看到每个全局符号落在哪个段、属于哪个目标文件,配合"extern 声明不占存储"这个认知,一眼就能看出哪些变量是有人声明了却没人定义。

注意:跨文件引用全局变量时,最安全的组合永远是"一个带初始化器的定义 + 若干 extern 声明"。这个组合在任何编译器、任何链接器下都标准兼容,也完全避开了试探性定义带来的平台差异。

写到最后,说点个人经验。我每次接手新项目,凡是要跨文件引用的变量,第一件事就是统一改成 extern 声明加单一属主定义,同时把只在一个文件里用的全局变量全部加 static 降级。这个习惯帮我省了大量调试时间,因为跨文件的重复定义错误在小工程里几乎看不出来,项目一旦变大就集中爆发。如果你正处于"代码能跑但说不清为什么稳"的阶段,不妨先试试这个思路——把所有共享变量的"属地"理清楚,你会立刻发现很多诡异的 bug 直接消失了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询