03 信息隐藏:static 与头文件边界

C 语言 OOP 系列导航

本系列面向会 C 和单片机基础、刚进入企业项目的同学,从 struct 封装一路讲到 ops、多态和类 Linux 驱动框架。

  1. C 语言面向对象设计路线
  2. 封装:struct 数据与 me 指针
  3. 信息隐藏:static 与头文件边界 ⇐ 当前位置
  4. 类的组织:前缀、init/deinit 与生命周期
  5. 数据归位:struct 成员与 static 变量
  6. HAL 映射:工业库的封装结构
  7. 继承:struct 嵌套复用公共字段
  8. 函数指针:把行为作为参数
  9. ops/vtable:行为表与对象绑定
  10. 多态与转型:base 指针与 dispatch
  11. 接口设计:ops 合同与默认实现
  12. 完整框架:抽象层与硬件解耦
  13. 终章:自动注册与 Linux 内核 OOP 结构

struct + me 解决了“一份函数操作多个对象”的问题,但还没解决“别人能不能绕过接口乱改状态”的问题。

例如 LED 对象里有一个 is_on

1
2
3
4
5
typedef struct {
uint8_t pin;
uint8_t brightness;
bool is_on;
} Led_t;

正常点亮应该调用接口:

1
led_on(&red_led);

如果外部直接改字段:

1
red_led.is_on = true;

软件状态变成“灯亮了”,但 GPIO 可能根本没写:

1
gpio_write(red_led.pin, true);

一句话先懂:信息隐藏不是故意藏代码,而是防止外部绕过接口,让软件状态和硬件状态脱节。

先看问题:字段被直接改,硬件不会自动跟着变

嵌入式里很多字段不是普通变量,它们背后对应真实硬件。

状态 结果
is_on == true 软件以为灯亮了
GPIO 没有同步写入 硬件实际没亮
后续逻辑依赖 is_on 判断全部偏掉

调试时最麻烦的就是这种问题:日志看起来是对的,现场硬件行为却不对。

头文件只说外部能做什么

C 模块通常由 .h.c 配合:

文件 职责
.h 对外承诺:别人能调用哪些接口
.c 内部实现:具体怎么操作硬件、怎么维护状态

LED 模块的头文件可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#ifndef LED_H
#define LED_H

#include <stdbool.h>
#include <stdint.h>

typedef struct {
uint8_t pin;
uint8_t brightness;
bool is_on;
} Led_t;

int led_init(Led_t *me, uint8_t pin);
int led_on(Led_t *me);
int led_off(Led_t *me);
int led_set_brightness(Led_t *me, uint8_t brightness);
int led_get_state(const Led_t *me, bool *is_on, uint8_t *brightness);

#endif

这里为了教学方便,结构体仍放在头文件里。但规则要讲清楚:外部可以声明 Led_t 对象,不能直接修改成员,状态变化必须走 led_xxx() 接口。

更严格的工程写法是不透明结构体,也就是头文件只写:

1
typedef struct Led Led_t;

然后把结构体定义藏在 .c 里。本系列先用可见结构体降低理解成本,后面再逐步接近工业写法。

static 函数相当于模块内部 private

如果 GPIO 操作分散在多个公共接口里:

1
2
3
4
5
6
7
8
9
10
11
12
13
int led_on(Led_t *me)
{
me->is_on = true;
gpio_write(me->pin, true);
return 0;
}

int led_off(Led_t *me)
{
me->is_on = false;
gpio_write(me->pin, false);
return 0;
}

硬件改成低电平有效时,所有地方都要改。更稳的做法是把硬件同步收敛到一个内部函数:

1
2
3
4
static void led_update_hardware(Led_t *me)
{
gpio_write(me->pin, me->is_on);
}

公共接口只负责改状态,再调用内部函数同步硬件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int led_on(Led_t *me)
{
if (me == NULL) {
return -1;
}

me->is_on = true;
led_update_hardware(me);

return 0;
}

int led_off(Led_t *me)
{
if (me == NULL) {
return -1;
}

me->is_on = false;
led_update_hardware(me);

return 0;
}

static 修饰函数后,这个函数只在当前 .c 文件可见。外部模块不能调用它,也就不会依赖你的内部细节。

getter:允许读,但要受控地读

外部确实可能需要知道 LED 状态,比如上报日志、显示 UI、做故障判断。此时不要直接读多个字段,而是提供查询接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int led_get_state(const Led_t *me, bool *is_on, uint8_t *brightness)
{
if (me == NULL) {
return -1;
}

if (is_on != NULL) {
*is_on = me->is_on;
}

if (brightness != NULL) {
*brightness = me->brightness;
}

return 0;
}

这个签名有三个细节:

写法 作用
const Led_t *me 表示只读对象,不修改状态
输出指针允许为 NULL 调用者可以只取关心的值
返回错误码 参数非法时能明确失败

调用方式:

1
2
3
4
5
6
bool is_on;
uint8_t brightness;

if (led_get_state(&red_led, &is_on, &brightness) == 0) {
/* 根据状态做业务判断 */
}

新人常见误区

误区 为什么危险
结构体成员公开了就随便改 可能绕过硬件同步和合法性检查
static 只是为了避免重名 更重要的是限制内部函数的可见范围
getter 没必要,直接读更快 直接读会把内部数据结构变成外部依赖
.h 放越多越方便 暴露越多,后续越难改实现

写模块时可以问自己一句:这个符号是“外部必须知道”,还是“内部自己用就行”?后者优先放进 .c,并加 static

反面写法和推荐写法

操作 不推荐 推荐
点亮 LED led.is_on = true led_on(&led)
熄灭 LED led.is_on = false led_off(&led)
查询状态 直接读多个字段 led_get_state()
更新硬件 到处写 gpio_write() 集中到 static helper
内部辅助 暴露在头文件 .c 并加 static

C/C++ 对照

C 语言 C++
.h 中声明的函数 public 方法
.c 中的 static 函数 private 方法
led_get_state() getter
不直接改成员 访问控制
统一内部 helper 私有工具函数

C 语言没有 private 关键字,但 static 能让函数和文件级变量仅在当前翻译单元可见。这是 C 模块化最重要的边界工具之一。

嵌入式项目意义

信息隐藏不是为了“防同事”,而是为了降低整个团队犯错的概率。

项目问题 信息隐藏带来的改善
软件状态和 GPIO 不一致 状态变化集中在接口里
多处直接操作寄存器 硬件改版只改内部 helper
内部函数被外部依赖 static 阻止依赖泄漏
查询和修改混在一起 getter 让读写边界清楚

总结

.h 只告诉外部能做什么,.c 决定内部怎么做;状态变化必须通过接口,硬件更新必须集中管理。