2、文件结构规范:头文件与源文件的职责划分

说到文件结构,我见过太多新手把代码写得像一锅粥。头文件里塞函数实现,源文件里又到处 extern 声明。嗯,这其实是个很要命的习惯。

今天我们就来聊聊,头文件和源文件到底该怎么分工。说白了,就是「声明放哪里,定义放哪里」的问题。

2.1 头文件的职责

头文件是接口,不是实现。它告诉别人:「我这个模块提供了什么功能,你只管调用就行,别管我怎么实现的。」

我个人习惯,头文件里只放这些东西:

  • 类型定义:结构体、枚举、typedef 别名
  • 宏定义:常量宏、功能宏
  • 函数声明:对外暴露的 API
  • extern 变量声明:全局变量(尽量少用)

举个例子,一个 GPIO 驱动模块的头文件:

/* gpio_driver.h */
#ifndef GPIO_DRIVER_H
#define GPIO_DRIVER_H

#include <stdint.h>

/* 引脚模式枚举 */
typedef enum {
    GPIO_MODE_INPUT,
    GPIO_MODE_OUTPUT,
    GPIO_MODE_ALT_FUNC
} gpio_mode_t;

/* 初始化 GPIO 引脚 */
void gpio_init(uint8_t pin, gpio_mode_t mode);

/* 设置引脚输出电平 */
void gpio_set(uint8_t pin, uint8_t level);

/* 读取引脚输入电平 */
uint8_t gpio_get(uint8_t pin);

#endif /* GPIO_DRIVER_H */
我的经验:头文件里不要放 #include 太多东西。我曾经接手过一个项目,一个头文件引了十几个其他头文件,编译一次要等两分钟。你想想看,这多耽误事。

2.2 源文件的职责

源文件是具体的实现。它负责把头文件里声明的函数一个个写出来。说白了,头文件是「菜单」,源文件是「后厨」。

源文件里可以放:

  • 函数定义:所有 API 的具体实现
  • 静态函数:模块内部使用的辅助函数
  • 静态全局变量:模块内部状态
  • 局部宏定义:仅本文件使用的宏

对应上面的头文件,源文件长这样:

/* gpio_driver.c */
#include "gpio_driver.h"

/* 内部寄存器地址(仅本文件可见) */
#define GPIO_BASE_ADDR   0x40020000
#define GPIO_OUT_REG     (*(volatile uint32_t *)(GPIO_BASE_ADDR + 0x00))
#define GPIO_IN_REG      (*(volatile uint32_t *)(GPIO_BASE_ADDR + 0x04))

/* 内部辅助函数:校验引脚号 */
static int is_valid_pin(uint8_t pin) {
    return (pin < 16);
}

void gpio_init(uint8_t pin, gpio_mode_t mode) {
    if (!is_valid_pin(pin)) return;
    /* 具体初始化代码... */
}

void gpio_set(uint8_t pin, uint8_t level) {
    if (level) {
        GPIO_OUT_REG |= (1 << pin);
    } else {
        GPIO_OUT_REG &= ~(1 << pin);
    }
}

uint8_t gpio_get(uint8_t pin) {
    return (GPIO_IN_REG >> pin) & 0x01;
}
注意:源文件里不要出现 extern 声明。如果你需要引用其他模块的变量,请通过头文件来引用。我曾经见过有人直接在 .c 文件里写 extern int flag;,结果后来变量改名了,编译报错找了一下午。

2.3 文件头注释模板

每个文件开头都应该有个注释块。这不是形式主义,是救命用的。你想想看,半年后你回来改代码,看到这个注释能省多少时间。

我常用的模板:

/**
 * @file    gpio_driver.c
 * @brief   GPIO 驱动模块实现
 * @author  张三
 * @date    2024-01-15
 * @version v1.0
 *
 * @note    本模块基于 STM32F4 系列实现
 * @warning 调用 gpio_init 前需确保时钟已使能
 */

关键字段说明:

字段 说明 是否必须
@file 文件名 必须
@brief 简要描述 必须
@author 作者 建议
@date 创建日期 建议
@version 版本号 建议
@note 注意事项 可选
@warning 警告信息 可选
核心原则:头文件和源文件的注释模板保持一致。我习惯把模板存成代码片段,新建文件时直接插入,省得每次手写。

2.4 包含卫士的正确写法

包含卫士(Include Guard)是为了防止头文件被重复包含。这个坑我踩过好几次。最典型的就是:两个头文件互相包含,编译直接死循环。

正确的写法有两种:

方式一:#ifndef 宏(推荐)

#ifndef GPIO_DRIVER_H
#define GPIO_DRIVER_H

/* 头文件内容 */

#endif /* GPIO_DRIVER_H */

命名规则:模块名_H,全部大写,下划线分隔。

方式二:#pragma once(编译器相关)

#pragma once

/* 头文件内容 */
我曾经踩过的坑:有次我用 #pragma once,结果换了个编译器就不认了。从那以后,我统一用 #ifndef 方式。虽然多写两行,但兼容性最好。你想想看,嵌入式项目经常要换平台,这种细节能省不少麻烦。

写包含卫士时要注意几点:

  • 宏名要唯一,不要用 _H 这种太短的
  • 宏名不要以下划线开头,避免和系统保留字冲突
  • 结尾一定要加注释,标明是哪个文件的卫士
  • 不要漏掉 #endif 后面的注释
小技巧:我习惯在 #endif 后面写上宏名,比如 #endif /* GPIO_DRIVER_H */。这样如果嵌套了很多条件编译,一眼就能看出每个 #endif 对应的是谁。

2.5 总结一下

文件结构这事,说白了就是「各司其职」。头文件管接口,源文件管实现。注释要写清楚,包含卫士要规范。这些看起来都是小事,但项目大了以后,每一点规范都能帮你省下大把调试时间。

嗯,下一章我们聊聊命名规范。变量名、函数名怎么起才不容易出 bug,到时候见。