嵌入式软件里,模块之间接口调来调去,怎么治?
聊一个老生常谈但很多团队始终没解决好的问题:模块依赖方向。
从一次代码评审说起
前阵子评审一个同事的代码,他在 LED 驱动模块里加了一行:
#include "network_manager.h"
我问他:LED 驱动为什么要包含网络管理的头文件?
他说:因为产品要求联网成功后 LED 亮绿灯,断网亮红灯。
逻辑上没毛病。但我告诉他,这行 #include 一旦写下去,你就在驱动层和业务层之间埋了一颗雷。今天是 LED 依赖网络模块,明天可能传感器模块也要知道网络状态,后天电机控制也要看网络……最后你会发现,所有模块都在互相 include,改一个头文件,半个工程重新编译。
这不是假设,是我亲身经历过的事。
先看看”病症”长什么样
我画一张图,做过两三年嵌入式的人应该都不陌生:

看到没?箭头满天飞,双向的、交叉的,像一团乱麻。这就是典型的无方向依赖——谁都可以调谁,谁都 include 谁。
这种代码能跑吗?能跑。能维护吗?等着哭吧。
我见过最夸张的一个项目,一个 MCU 工程里二十来个 .c 文件,头文件的交叉引用关系用工具画出来,跟蜘蛛网一样。改一个传感器的采样频率,编译器报了 47 个 warning。不是代码有 bug,是头文件互相依赖导致的类型重定义。
这种”接口调来调去”的根源是什么?就是没有依赖方向的约束。
怎么一步步走到这个地步的
没有人一开始就想把代码写成蜘蛛网。项目初期,模块关系通常很清晰:驱动归驱动,应用归应用。但随着需求迭代,问题就冒出来了。
第一步:赶工期,先跑通再说。 产品经理说下周要演示,你直接在 LED 驱动里调了网络模块的函数,心想回头再重构。但你也知道,”回头”这两个字在嵌入式项目里约等于”永远不会”。
第二步:有样学样。 新来的同事看到你这么写了,他也有样学样。传感器模块要根据电机状态调整采样率?直接 include 电机控制的头文件,简单粗暴。
第三步:积重难返。 几轮迭代下来,模块之间你中有我、我中有你。想拆?牵一发动全身。不拆?每次改动如履薄冰。
说到底,根本原因就一个:项目缺少一条明确的规则——依赖只能朝一个方向流动。
一条铁律:依赖只准”往下”不准”往上”
解决方案其实不复杂,核心就一句话:给模块分层,上层可以调下层,下层绝对不能调上层。
这是我这些年在实际项目中反复验证过的原则。别管什么设计模式的花活,先把这一条守住,代码结构就不会太差。
┌─────────────────────────────────────────────┐│ 应用层 (Application) ││ 网络管理、业务逻辑、状态机... ││ ││ 可以调用 → 中间层、驱动层 ││ 不允许被下层直接调用 │├─────────────────────────────────────────────┤│ 中间层 (Service) ││ 日志服务、配置管理、消息分发... ││ ││ 可以调用 → 驱动层 ││ 不允许调用 → 应用层 │├─────────────────────────────────────────────┤│ 驱动层 (Driver) ││ LED、UART、SPI、传感器、电机... ││ ││ 可以调用 → HAL/硬件 ││ 不允许调用 → 中间层、应用层 │├─────────────────────────────────────────────┤│ 硬件抽象层 (HAL) ││ 寄存器操作、中断向量... │└─────────────────────────────────────────────┘ 依赖方向:只能从上往下 ↓ 绝不逆流 ↑
道理大家都懂,但一到实际写代码就会遇到一个问题:下层确实需要通知上层啊! 比如串口收到数据了,总得告诉上层吧?传感器数据就绪了,应用层得知道吧?
这就是很多人最终又打破分层的原因。下面我讲讲怎么在不破坏依赖方向的前提下,解决”下层通知上层”的问题。
实战:三种”下层通知上层”的正确姿势
方法一:回调函数(最常用)
回到开头那个例子——LED 需要根据网络状态变化来切换颜色。
错误做法:LED 驱动主动去查询网络状态(下层依赖上层)
/* led_driver.c —— 千万别这么干 */#include "network_manager.h" // 驱动层 include 了应用层!void LED_Update(void) { if (NetworkManager_IsConnected()) { // 驱动直接调用应用层接口 HAL_GPIO_WritePin(LED_GREEN, ON); } else { HAL_GPIO_WritePin(LED_RED, ON); }}
正确做法:LED 驱动提供回调注册接口,由应用层来注册
/* led_driver.h —— 驱动只暴露注册接口 */typedef void (*LED_EventCallback)(uint8_t led_id, uint8_t state);void LED_Init(void);void LED_SetState(uint8_t led_id, uint8_t state);
/* app_main.c —— 应用层负责"粘合"逻辑 */#include "led_driver.h" // 上层 include 下层,方向正确#include "network_manager.h" // 同层引用,没问题void OnNetworkStateChanged(bool connected) { if (connected) { LED_SetState(LED_GREEN, ON); LED_SetState(LED_RED, OFF); } else { LED_SetState(LED_GREEN, OFF); LED_SetState(LED_RED, ON); }}
看到区别了吗?LED 驱动根本不知道”网络”这个概念的存在。它只管亮灯灭灯。至于什么时候亮、为什么亮,那是应用层的事。
依赖关系的对比:
改之前: 改之后: 网络管理 ◄──── LED驱动 应用层(app_main) (上层被下层依赖,方向反了) │ │ ▼ ▼ 网络管理 LED驱动 (依赖只从上往下,清清爽爽)
方法二:事件/消息机制(适合多个模块关心同一事件)
如果不只是 LED,还有日志模块、报警模块都关心网络状态呢?一个一个写回调太啰嗦。这时候可以用简单的事件广播:
/* event_bus.h —— 一个极简的事件总线 */#define EVENT_MAX_SUBSCRIBERS 8typedef void (*EventHandler)(uint32_t event_id, void *data);void Event_Init(void);void Event_Subscribe(uint32_t event_id, EventHandler handler);void Event_Publish(uint32_t event_id, void *data);
使用起来很直观:
/* 网络模块:状态变化时发布事件 */Event_Publish(EVT_NETWORK_STATE, &is_connected);/* LED模块:订阅事件并响应 */Event_Subscribe(EVT_NETWORK_STATE, LED_OnNetworkEvent);/* 日志模块:也订阅同一个事件 */Event_Subscribe(EVT_NETWORK_STATE, Log_OnNetworkEvent);
事件总线放在中间层,所有模块只依赖事件总线,互相之间完全不知道对方的存在。
方法三:共享状态 + 轮询(最简单但有局限)
有时候项目特别简单,搞事件总线有点杀鸡用牛刀。那就用一个全局状态结构体:
/* system_status.h —— 放在中间层 */typedefstruct { bool network_connected; bool sensor_ready; uint8_t battery_level;} SystemStatus_t;extern SystemStatus_t g_sys_status;
各模块只依赖这个头文件,写入方写入自己负责的字段,读取方在主循环里轮询。简单粗暴,但对于裸机跑 while(1) 的小项目来说,够用了。
三种方法的选择建议:
┌──────────────┬──────────────────┬────────────────┐│ 方法 │ 适用场景 │ 复杂度 │├──────────────┼──────────────────┼────────────────┤│ 回调函数 │ 一对一通知 │ ★☆☆ ││ 事件总线 │ 一对多广播 │ ★★☆ ││ 共享状态 │ 裸机简单项目 │ ★☆☆ │└──────────────┴──────────────────┴────────────────┘
落地:几条能马上用的规矩
讲了这么多方法论,最后说几条我在团队里实际执行的规矩,直接抄走就能用。
1、头文件引用检查——新代码合入前必查
每次 code review,我必看的第一件事就是 #include 列表。一个驱动层的 .c 文件如果 include 了应用层的头文件,直接打回,没有商量余地。
简单记一条:驱动层的 .c 文件里,不应该出现任何带 app_、manager_、service_ 前缀的头文件。
2、目录结构要体现层次
别把所有 .c 和 .h 都扔一个文件夹里。目录结构本身就是依赖方向的”物理隔离”:
project/├── app/ ← 应用层:业务逻辑、状态机│ ├── app_main.c│ └── network_manager.c├── service/ ← 中间层:事件总线、日志、配置│ ├── event_bus.c│ └── sys_config.c├── driver/ ← 驱动层:LED、UART、传感器│ ├── led_driver.c│ └── uart_driver.c└── hal/ ← 硬件抽象层 └── stm32f4xx_hal.c
有了目录层次,谁依赖谁一目了然。我以前带的组甚至在 Makefile 里加了检查脚本——driver/ 下的文件如果引用了 app/ 下的头文件,编译直接报错。土办法,但有效。
3、”粘合层”的概念
很多依赖混乱的根源是缺少一个”粘合层”。各模块各管各的,最终得有人把它们串起来。这个活应该由应用层最顶层的代码来干(比如 app_main.c),而不是让底层模块自己去找其他模块。
谁负责”连线”,谁就在最上层。底层模块只管好自己那一亩三分地。
4、新模块加入时先画依赖图
不用画多复杂,在纸上或者白板上把新模块和已有模块的调用关系标出来,看看箭头是不是只朝一个方向。如果出现了回头箭头,先想想设计是不是有问题,再动手写代码。
五分钟的思考,能省五天的重构。
写在最后
嵌入式项目和互联网项目不一样。我们的代码一旦烧进去,跑在客户的设备上,想改就没那么容易了。上线前多花点功夫把模块依赖关系理清楚,比上线后通宵 debug 强太多。
今天讲的这些东西,总结起来其实就一句话:
模块之间可以有依赖,但依赖必须有方向。上层调下层天经地义,下层想通知上层就用回调或事件,绝不直接反向调用。
这不是什么高深的架构理论,就是一条简单的纪律。难的不是理解它,而是在赶工期的压力下还能守住它。
共勉。
如果你也在做嵌入式开发,欢迎关注我,后续会继续分享更多实战经验。
【往期推荐】
往期
夜雨聆风
