ARTICLE · 1256843
《从源码搭建嵌入式软件框架》03|模块互相依赖怎么办?先把依赖方向画清楚
但目录整齐以后,新问题来了:
甲要用乙,乙又想通知甲。难道最后还是要互相依赖?🤔
先别急着删头文件。模块之间有来有往,不一定代表构建依赖形成了环。
关键是,先把箭头的含义说清楚。
一、三种箭头,别画成一回事
讨论“依赖”时,我们经常混用三种关系:
构建依赖: 编译、链接或构建时,需要谁提供的声明、实现或支持。 运行调用: 程序执行时,进入了谁的函数。 消息流向: 某个事件最终送给谁处理。 
图1:箭头有不同含义。不能看到“通知回去了”,就认定构建依赖也反过来了。
先看一条具体的构建依赖:发布订阅依赖任务执行器,也直接依赖基础接口;任务执行器同样使用基础接口。

图2:箭头表示“依赖”。这里只选取部分关系,未列出日志、工具和系统管理等支撑组件。
这里又有一个区别:
依赖基础接口,主要是取得公共约定;依赖任务执行器,则还需要它提供具体能力。
基础接口组件的依赖清单为空,但这不代表程序只靠这些声明就能运行。线程、锁等操作,仍要有对应实现。
二、公共流程调用上层,不一定要直接引用上层
以更新流程生成资源列表为例:
公共流程需要知道“接下来处理哪些资源”,但具体选择可以由上层策略决定。
我们用一个完整例子说明:
输入资源编号 101、102,经过已经登记的策略,得到包含这两个编号的待处理列表。
下面采用通用名称,只保留相关字段;策略登记表由框架提供,编号只是演示数据。
1. 公共接口:约定输入和输出
#include<stdint.h>#define RESOURCE_LIMIT 32#define DEMO_PACKAGE_ID 1u/* 输入:配置中的资源编号。 */typedef struct {uint32_t package_id;uint32_t count;uint32_t codes[RESOURCE_LIMIT];} update_config_t;/* 输出:准备处理的资源列表。 */typedef struct {uint32_t count;uint32_t codes[RESOURCE_LIMIT];} resource_list_t;/* 策略:某类配置应该交给哪个函数处理。 */typedef struct {uint32_t package_id;int (*build_list)(const update_config_t *, resource_list_t *);} update_policy_t;/* 公共服务提供的登记和查找入口。 */intpolicy_register(constupdate_policy_t *policy);constupdate_policy_t *policy_find(uint32_t package_id);
这里的能力很明确:策略接收配置,把需要处理的资源编号写入输出列表。
2. 上层实现:把全部资源加入列表
先采用一种简单策略:检查资源数量,再逐个复制编号。
#include<stddef.h>#include<string.h>staticintselect_all_resources(constupdate_config_t *config,resource_list_t *out){if (config == NULL || out == NULL ||config->count == 0 || config->count > RESOURCE_LIMIT) {return -1;}memset(out, 0, sizeof(*out));out->count = config->count;for (uint32_t i = 0; i < config->count; ++i) {out->codes[i] = config->codes[i];}return 0;}/* 初始化时,把策略交给公共服务。 */intregister_demo_policy(void){static const update_policy_t policy = {.package_id = DEMO_PACKAGE_ID,.build_list = select_all_resources};return policy_register(&policy);}
这次不只是声明一个回调,而是实现了具体动作:
检查数量 → 清空输出 → 复制编号 → 返回结果。
登记也可能失败,例如重复登记、登记表已满,调用方需要检查返回值。
3. 公共流程:查找策略,再执行
intmake_resource_list(constupdate_config_t *config,resource_list_t *out){if (config == NULL || out == NULL) {return -1;}const update_policy_t *policy =policy_find(config->package_id);if (policy == NULL || policy->build_list == NULL) {return -2; /* 没有可用策略。 */}int ret = policy->build_list(config, out);if (ret != 0) {return ret;}return out->count <= RESOURCE_LIMIT ? 0 : -1;}
注意,公共流程没有出现 select_all_resources 这个具体函数名。
它只知道:找到对应策略,调用约定的 build_list,检查结果。

图3:编写代码时依赖公共约定;运行时,通过登记的入口调用具体策略。
4. 给出输入,观察实际结果
初始化阶段先调用 register_demo_policy(),确认成功后,再处理配置:
update_config_t config = {.package_id = DEMO_PACKAGE_ID,.count = 2,.codes = {101, 102}};resource_list_t list = {0};int ret = make_resource_list(&config, &list);/* ret 为 0 时:* list.count == 2* list.codes[0] == 101* list.codes[1] == 102*/
现在,完整链路就清楚了:
上层登记策略 → 公共流程按标识查找 → 策略生成列表 → 公共流程拿到结果。 🧩
回调改变的是接线方式,不是让双方再无联系。策略仍要先登记,函数入口必须有效,错误也必须处理。
这里的结构经过简化,-1、-2 是教学错误码,不代表完整实现中的具体数值。
三、发布一个整数,看订阅者怎样收到它 📣
如果不是请求生成列表,而是告诉其他模块“有新数据了”,可以使用发布订阅。
这次完成一个具体动作:
发布整数 42,让订阅者在自己的执行环境中收到它。
1. 订阅者:校验消息,把整数复制出来
#include<stdint.h>#include<stddef.h>#include<string.h>#define EVENT_SAMPLE_READY 1001u/* 消息视图:通用化名称。 */typedef struct {uint32_t id;uint16_t length;uint8_t data[];} event_view_t;static uint32_t received_value;staticinton_sample_ready(void *raw, void *arg){const event_view_t *event = raw;(void)arg;if (event == NULL ||event->id != EVENT_SAMPLE_READY ||event->length != sizeof(received_value)) {return -1;}memcpy(&received_value,event->data,sizeof(received_value));return 0;}
这个回调实际完成了三件事:
确认是不是自己关心的事件。 检查消息长度是否正确。 把整数复制到自己管理的存储中。
不要把消息里的数据指针保存下来,等回调结束后继续使用。 需要保留的内容,应当复制出来。
2. 绑定接收环境,再发布数据
下面的 executor_handle_t 表示已经创建、可以接收任务的执行环境。订阅和发布函数采用通用名称,保留原有参数形式。
/* 订阅者:登记事件、执行环境和处理函数。 */intreceiver_start(executor_handle_t receiver){uint32_t ids[] = {EVENT_SAMPLE_READY};return event_subscribe(receiver,ids, 1,on_sample_ready,NULL);}/* 发布者:只知道事件和数据。 */intsend_sample(void){uint32_t value = 42;return event_publish(EVENT_SAMPLE_READY,&value,sizeof(value),NULL);}
先确认 receiver_start() 成功,再调用 send_sample()。
消息成功分发、回调执行完成后,received_value 就会变为 42。

图4:发布者使用事件标识发布消息,消息系统按订阅关系投递到接收环境。
关键区别在这里:
发布者没有调用 on_sample_ready(),甚至不需要知道这个函数名。
新增接收者时,可以登记另一个处理函数,不必修改发布者。
这个发布入口会复制消息体字节,因此示例可以传入局部整数的地址。但如果消息里装的是指针,复制指针不等于复制它指向的内容。
最后还要记住:
send_sample()返回成功,只表示发布流程已受理,不代表接收回调已经执行完成。
回调应尽快返回;如果其他线程也要读取接收结果,还需要另外处理同步。减少直接依赖,不等于省掉执行顺序和数据生命周期的管理。
四、依赖清单,还要看它服务于哪个构建目标
模块关系不只体现在头文件里,也写在组件的依赖描述中。
这里可以看到两类不同的组织方式:
主程序组件依赖: 组织参与主程序构建的组件。 独立模块根依赖: 组织独立模块的构建范围,不直接当作主程序依赖清单使用。 
图5:两类清单服务于不同构建范围,但不表示生成了互不关联的系统。
独立模块仍然需要调用约定和绑定关系,才能与其他部分合作。同一项能力的主程序适配部分,也可能单独参与主程序构建。
因此,“单独生成一个文件”不等于“没有依赖”。
还要分清:构建依赖不是启动顺序。
组件参与了编译链接,不代表它已经初始化。启动仍按登记阶段组织,不能仅凭依赖清单,就认定系统会自动算好全部初始化先后。
五、发现互相依赖,先查这三件事
1. 是真正的环,还是把调用和消息误画成了依赖?
“甲依赖乙的接口,乙通过已登记入口调用甲”,不等于乙必须在构建时引用甲的具体实现。
先标明每根箭头的含义,再判断是否形成环。
2. 双方是否把本该隐藏的细节公开了?
如果一个模块必须读取另一个模块的内部控制块才能工作,边界就值得重新检查。
可以优先考虑公开操作入口,或由需要这项能力的一侧定义回调约定。不要只是把几个内部头文件搬到公共目录,便宣布依赖已经解开。
3. 接线代码是否混进了通用主体?
桥接代码可以集中处理注册、参数检查和转接,让两侧通过明确入口合作。
但它放在某个公共组件里,不代表这个组件就完全独立了:部分公共运行组件的依赖清单,仍引入了具体功能组件。
这说明,文件职责分开和组件依赖分开,是两个需要分别检查的问题。
已有注册、回调和桥接机制,也不能证明整个系统没有依赖环,或已经具备覆盖全部组件的自动检查。
六、画清方向,比急着“消灭依赖”更重要
依赖不是坏事。一个模块需要另一项能力,本来就会产生联系。
我们真正想减少的是:不必要的直接联系,以及说不清责任的联系。
记住三个检查动作就够了:
标箭头: 这是构建依赖、运行调用,还是消息流向? 找约定: 双方通过公开接口合作,还是直接摸进了内部? 看接线: 谁负责登记,谁负责调用,没准备好时返回什么?
方向画清楚,才知道该保留哪条线、把哪条线改成登记入口,以及把哪些细节留在内部。🔧
下一篇,我们把这些关系落到配置中:
《从源码搭建嵌入式软件框架》04|一个配置文件,怎样把组件装成完整工程
源码、头文件、条件开关和依赖清单,究竟怎样一起决定“这次编译出什么”?















































































