乐于分享
好东西不私藏

嵌入式软件里,模块之间接口调来调去,怎么治?

嵌入式软件里,模块之间接口调来调去,怎么治?

聊一个老生常谈但很多团队始终没解决好的问题:模块依赖方向。

从一次代码评审说起

前阵子评审一个同事的代码,他在 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 强太多。

今天讲的这些东西,总结起来其实就一句话:

模块之间可以有依赖,但依赖必须有方向。上层调下层天经地义,下层想通知上层就用回调或事件,绝不直接反向调用。

这不是什么高深的架构理论,就是一条简单的纪律。难的不是理解它,而是在赶工期的压力下还能守住它。

共勉。


如果你也在做嵌入式开发,欢迎关注我,后续会继续分享更多实战经验。

【往期推荐】

”单片机“ 也能玩 设计模式 ?

给你的设备做一套”砖不死”的 OTA 升级方案

嵌入式软件模块解耦进阶:构建高内聚、低耦合的系统架构

 往期

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » 嵌入式软件里,模块之间接口调来调去,怎么治?

评论 抢沙发

2 + 8 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
×
订阅图标按钮