夜雨聆风学习资料网

ARTICLE · 1256843

《从源码搭建嵌入式软件框架》03|模块互相依赖怎么办?先把依赖方向画清楚

《从源码搭建嵌入式软件框架》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. 接线代码是否混进了通用主体?

桥接代码可以集中处理注册、参数检查和转接,让两侧通过明确入口合作。

但它放在某个公共组件里,不代表这个组件就完全独立了:部分公共运行组件的依赖清单,仍引入了具体功能组件。

这说明,文件职责分开和组件依赖分开,是两个需要分别检查的问题。

已有注册、回调和桥接机制,也不能证明整个系统没有依赖环,或已经具备覆盖全部组件的自动检查。

六、画清方向,比急着“消灭依赖”更重要

依赖不是坏事。一个模块需要另一项能力,本来就会产生联系。

我们真正想减少的是:不必要的直接联系,以及说不清责任的联系。

记住三个检查动作就够了:

  1.    标箭头: 这是构建依赖、运行调用,还是消息流向?
  2.    找约定: 双方通过公开接口合作,还是直接摸进了内部?
  3.    看接线: 谁负责登记,谁负责调用,没准备好时返回什么?

方向画清楚,才知道该保留哪条线、把哪条线改成登记入口,以及把哪些细节留在内部。🔧

下一篇,我们把这些关系落到配置中:

《从源码搭建嵌入式软件框架》04|一个配置文件,怎样把组件装成完整工程

源码、头文件、条件开关和依赖清单,究竟怎样一起决定“这次编译出什么”?

相关学习资料