3、头文件编写规范:#ifndef/#define/#endif 防止重复包含、extern关键字声明、函数原型声明
头文件这东西,说白了就是C语言模块之间的「接口契约」。我见过太多新手把实现代码直接塞进头文件,或者忘记加防重复包含,结果编译报错时一脸懵。今天咱们就把头文件的三个核心规范讲透。
3.1 防止重复包含:头文件的「防盗门」
先问个问题:为什么需要防止重复包含?
你想想看,一个项目里可能有几十个源文件。A.h 被 B.c 和 C.c 都包含了,B.c 又包含了 C.h,C.h 里又引用了 A.h…… 编译器处理起来,同一个头文件的内容就被「展开」了多次。结构体重复定义、宏重复定义,编译直接报错。
解决方案就是经典的「三件套」:
#ifndef __MY_HEADER_H__
#define __MY_HEADER_H__
/* 头文件内容写在这里 */
#endif /* __MY_HEADER_H__ */
逻辑很简单:第一次包含时,__MY_HEADER_H__ 这个宏没定义,于是 #ifndef 成立,进入定义区,同时 #define 把这个宏标记上。第二次再包含时,宏已经存在,#ifndef 不成立,直接跳到 #endif,内容被跳过。
重要原则:每个头文件都必须有且仅有一套 #ifndef/#define/#endif 保护。这不是可选项,是强制规范。
我个人习惯用 __模块名_H__ 这种命名方式。比如 __UART_DRIVER_H__、__TIMER_H__。注意不要用下划线开头加两个大写字母——虽然常见,但严格来说属于保留标识符。不过在实际工程中,大家约定俗成这么用,也没出过问题。
小技巧:有些编译器支持 #pragma once,一行搞定。但跨平台项目我建议还是用标准的三件套,毕竟不是所有编译器都买 #pragma once 的账。
3.2 extern 关键字:跨文件的「全局变量通行证」
全局变量在嵌入式项目里很常见——系统时钟、设备状态、错误码等等。但全局变量定义只能在一个 .c 文件里出现一次,其他文件要用,就得靠 extern 声明。
举个例子:
/* system_clock.c */
uint32_t system_tick = 0; /* 定义 */
/* system_clock.h */
extern uint32_t system_tick; /* 声明 */
其他 .c 文件只要包含 system_clock.h,就能访问 system_tick。注意,extern 只是告诉编译器「这个变量在别处定义了,你先别报错」,链接器会负责找到真正的定义。
我在项目中遇到过一个问题:有人把 extern 写在了 .c 文件里,结果每个 .c 文件都自己声明一遍,变量名拼写不一致,链接时一堆未定义错误。嗯,这里要记住:extern 声明统一放在头文件里,不要分散在各 .c 文件中。
避坑指南:我曾经在一个多线程项目里,把全局变量不加 extern 直接写在头文件里,结果每个包含该头文件的 .c 文件都「定义」了一份同名变量。链接器没报错,但运行时各个模块访问的是不同的内存地址…… 查了三天才找到原因。从那以后,我严格遵循「定义在 .c,声明在 .h」的原则。
3.3 函数原型声明:让编译器认识你的函数
C语言是「先声明后使用」的语言。如果函数 A 调用函数 B,而 B 的定义在 A 的后面,编译器就会报错或产生隐式声明警告。解决办法就是在头文件里放函数原型声明。
/* uart_driver.h */
#ifndef __UART_DRIVER_H__
#define __UART_DRIVER_H__
#include <stdint.h>
/* 函数原型声明 */
void uart_init(uint32_t baudrate);
void uart_send_byte(uint8_t data);
uint8_t uart_receive_byte(void);
int uart_send_buffer(const uint8_t *data, uint32_t len);
#endif /* __UART_DRIVER_H__ */
函数原型声明要写完整:返回值类型、函数名、参数类型和参数名(参数名可以省略,但我建议写上,方便阅读)。
你可能会问:为什么不在 .c 文件里直接定义函数,然后让其他文件用 extern 引用?其实函数声明默认就是 extern 的,所以头文件里的函数原型本质上就是 extern 声明。但显式写出来,是为了让编译器做类型检查——参数个数、类型不匹配时,编译器能及时报错。
核心原则:头文件只放「声明」不放「定义」。函数体、全局变量定义、静态变量,统统留在 .c 文件里。头文件是接口,不是实现。
3.4 知识体系:头文件规范全景图
下面这张图把三个规范的关系和流程画清楚了:
3.5 综合示例:一个规范的头文件长什么样
把上面三个规范合在一起,一个标准的头文件应该是这样的:
/*
* file: timer_driver.h
* desc: 硬件定时器驱动模块接口
*/
#ifndef __TIMER_DRIVER_H__
#define __TIMER_DRIVER_H__
#include <stdint.h>
#include <stdbool.h>
/* ========== 宏定义 ========== */
#define TIMER_CHANNEL_MAX 4
#define TIMER_TICK_HZ 1000
/* ========== 类型定义 ========== */
typedef enum {
TIMER_MODE_ONESHOT,
TIMER_MODE_PERIODIC
} timer_mode_t;
typedef struct {
uint32_t channel;
timer_mode_t mode;
uint32_t period_ms;
void (*callback)(void);
} timer_config_t;
/* ========== 全局变量声明 ========== */
extern uint32_t timer_system_tick;
/* ========== 函数原型声明 ========== */
int timer_init(timer_config_t *cfg);
int timer_start(uint32_t channel);
int timer_stop(uint32_t channel);
bool timer_is_running(uint32_t channel);
void timer_irq_handler(void);
#endif /* __TIMER_DRIVER_H__ */
注意看:
- 文件头有注释说明模块用途
- 防重复包含三件套
- 只包含必要的系统头文件
- 宏定义、类型定义、extern 声明、函数原型,按顺序排列
- 没有函数体,没有全局变量定义
我的习惯:头文件里还会加一个「版本注释」块,记录修改日期、修改人和修改内容。团队协作时,这能省去很多沟通成本。
3.6 常见错误与避坑
| 错误做法 | 后果 | 正确做法 |
|---|---|---|
| 头文件里写函数体 | 多文件包含时重复定义 | 函数体放 .c 文件,头文件只放声明 |
| 忘记加 #ifndef 保护 | 重复包含导致编译错误 | 每个头文件必须加三件套 |
| extern 声明写在 .c 文件里 | 分散管理,容易拼写错误 | 统一放在对应的头文件中 |
| 函数原型参数类型不匹配 | 隐式声明或运行时错误 | 保持 .h 和 .c 中的声明一致 |
| 头文件互相包含形成循环 | 编译死循环或报错 | 用前置声明代替 #include |
嗯,最后说一句:头文件规范看起来是小事,但项目规模一大,不规范的头文件就是灾难的源头。我见过一个项目因为头文件互相包含形成循环,编译了整整 20 分钟才报错——那滋味,谁经历谁知道。
养成好习惯,从每个头文件的 #ifndef 开始。
公众号:蓝海资料掘金营,微信deep3321