ARTICLE · 1218306
《从源码搭建嵌入式软件框架》01|先看全貌:一套嵌入式框架是怎样拼起来的
《从源码搭建嵌入式软件框架》01|先看全貌:一套嵌入式框架是怎样拼起来的初始化一下,读点数据,处理一下,再把结果交出去。 可随着功能增加,你会发现,真正麻烦的越来越不是“某个函数怎么写”,而是: 一个模块还没准备好,另一个模块就来调用它;一个操作执行得太久,后面的任务全在等;一份数据同时被几个人使用,却没人说得清应该由谁释放…… 这时,我们需要的就不只是更多函数,而是一套让函数、模块和资源有序配合的规则。 这就是软件框架要解决的问题。🧩 这一篇先不钻进复杂实现。我们先把几块主要的拼图摆出来,看看一套框架怎样从“能执行代码”,走向“能组织完整系统”。 看到一套软件,我们很容易先数功能: 有数据管理,有连接管理,有资源管理,还有显示和升级。 但把这些功能放进不同目录,并不等于有了清晰的架构。 就像一间办公室,分出了财务、人事和行政,还得继续回答: 谁负责安排工作?谁能申请资源?收到通知以后,谁来处理? 软件也是一样。 我们可以先把它分成下面几组: 
图1:按职责归纳的架构总览。实线表示主要的使用关系,不是每个组件必须逐层经过的完整依赖图。 先记住三个问题就够了: 做什么? 由上层组织和具体服务决定。 怎样配合? 由任务、消息、调用和状态机等机制解决。 最终怎样执行? 通过统一接口连接到操作系统与硬件。 例如,资源管理负责找到并提供资源内容;任务执行器负责安排一段工作;线程接口负责创建实际运行工作的线程。 它们解决的不是同一个问题。 这也解释了一个容易混淆的地方: 服务是框架提供的能力,机制是把这些能力组织起来的方法。 假设几个模块都需要创建线程。 如果它们分别使用平台提供的接口,就都需要知道:线程怎样创建、优先级怎样填写、栈空间怎样设置、创建失败返回什么。 换一个平台,这些地方就可能一起受到影响。 统一接口的做法,是在中间增加一个明确的入口: 
图2:上层表达需求,适配层处理平台差异。 举个简单的例子: 同样的思路还可以用在线程之外:
这里并不是重新发明一个操作系统,而是在已有能力上加一层统一的使用方式。 不过,统一接口也不是“换个平台就完全不用改”。内存类型、线程限制、硬件访问方式仍可能不同。它的价值是:让差异尽量集中,而不是散落在各个模块里。 接口准备好了,不代表所有模块已经可以使用。 任务队列还没创建,不能往里面投递工作;消息系统还没初始化,也不能指望它完成分发。 所以,启动不是把所有初始化函数随意调用一遍,而是有顺序的过程。 这里采用的组织方式,可以概括为: 
图3:启动主路径的简化示意,未展开配置分支和失败路径。 关键不只是“分几步”,还有“怎样知道需要启动谁”。 一种做法是,让组件提供一份启动登记信息,其中包含初始化入口、退出入口和所属阶段。编译链接时收集这些信息,启动管理器再按阶段遍历执行。 于是,新组件可以通过登记加入启动过程,不必每次都去主入口增加一行调用。 可以把它理解成: 需要注意,这种方式并不等于系统已经能够自动推导全部依赖。阶段和优先级仍需要维护,失败结果也需要正确传递。 还有一个很有意思的现象:启动入口完成工作后,可以结束自身,而系统继续运行。 因为后续工作已经由其他线程、队列和定时任务接手。启动入口更像“开门的人”,不一定也是整天站在门口处理所有事情的人。 直接调用函数很方便: 调用方进入函数,函数处理完,再返回。 但如果操作比较耗时,调用方就得一直等着。 因此,我们还需要另一种方式:先把工作描述出来,交到队列里,再由执行器处理。 
图4:任务的提交和执行,被分开了。 例如,任务甲提交了一项数据处理工作。提交后,它可以继续处理自己的其他事情;真正的数据处理,则由目标执行器在取到任务后进行。 一个任务不必很复杂,主要描述: 要执行什么操作。 操作需要什么参数。 是否需要在完成后处理结果。 但这里有一个必须马上记住的区别: 而且,异步并不等于无限快。 同一个执行器会顺序处理任务。如果前一个任务一直不结束,后面的任务仍然要等;队列也有容量,用完了就可能提交失败。 所以,任务执行框架不仅要负责“把工作送过去”,还要面对“工作积压了怎么办”。 做完以后,怎样把结果送回来? 如果发起方还关心执行结果,就需要回执。 
图5:不同执行环境之间的一种回执路径;同一执行环境下,处理方式可以更直接。 这就是理解异步通知的一个入口:事情可以在别处完成,结果再交回约定的地方处理。 后面我们还会继续追问:参数保存在哪里?回执执行前,数据能不能释放?这些不是小细节,而是异步机制能否安全工作的基础。 模块一多,通信需求也会出现区别。 有时只是想告诉大家“某件事发生了”;有时是明确要求某个能力“帮我完成一个操作”。 这两种情况,最好不要混在一起。 1. 发生了一件事:使用发布订阅 📣 为了说明关系,假设模块甲、乙、丙都关心同一个事件。 它们先订阅这个事件。发布者之后只需要发布消息,不必逐个调用它们。 
图6:一个发布动作,可以对应多个订阅者的处理任务。 这里的甲、乙、丙只是角色示例,不代表固定的功能组合。 发布订阅的好处,是发布者不需要认识每一个接收者。新增接收者时,可以通过增加订阅关系接入。 但它没有消除所有问题:消息仍需要占用内存,处理仍需要时间,接收者也可能处理失败。共享消息数据、结果确认和失败重试,都要有对应的管理方式。 2. 请求完成一项操作:使用服务调用 另一种情况更直接: 这时可以先查找服务,再交给对应的处理函数。 
图7:调用方通过服务标识找到能力,而不是到处引用具体函数。 两者可以这样区分:
它们也不必各建一套线程体系。通知和服务请求中的异步工作,都可以借助前面的任务执行器。 框架的组合能力,正体现在这里:新机制利用已有基础,而不是每增加一种通信方式,就重新造一套运行环境。 队列能安排工作,消息能传递事件,但它们还没有回答: 这正是状态机要解决的问题。 以更新过程为例,只看主路径,可以简化成: 
图8:为方便理解而合并后的主路径,省略了连接、清单处理及错误分支等中间步骤。 这里有三个最基本的概念: 状态: 当前在哪一步。 事件: 刚刚发生了什么。 转移: 根据当前状态和事件,决定下一步。 例如,同样是一个“完成”事件,处在“准备”阶段和“安装”阶段,含义就不相同。 状态机负责把这种区别表达清楚。 它还可以在进入或离开状态时执行动作,并通过条件决定某条转移是否允许发生。超时也可以被转换为事件,交给状态机按当前阶段处理。 于是,任务、通知和状态机就连起来了: 需要提醒的是,状态机引擎并不会自动帮我们完成异步工作。它管理的是“流程走到哪里”,任务执行器管理的是“工作在哪里执行”。 有了前面的机制,数据、资源、连接和窗口等服务,就可以在它们之上建立自己的能力。 以数据管理为例,同一个读写入口,可以根据配置把操作交给不同后端: 
图9:使用者提出读写需求,具体存放方式由配置和后端处理。 例如,同样是读取一项数据,有些数据来自内存,有些来自非易失存储。使用者不需要在每次访问时,都重新处理底层操作。 不过,不能由此认为“所有数据都具有相同的保存方式和事务保证”。 对于需要以完整内容安装和提交的二进制对象,还存在另一套对象管理机制: 
图10:对象写入和对象正式提交,是不同阶段。 这里的“对象”,可以先理解为“一份有身份、可查询、可读写的完整内容”,不必把它想得很复杂。 它与普通数据读写的区别在于:除了内容本身,还需要管理对象位置、提交状态和恢复信息。 这给我们一个很重要的认识: 统一框架,不等于所有东西都塞进同一个接口。职责不同,就应该保留清楚的边界。 前面讨论的模块,可以一起编译到主程序里。 但如果希望某些模块能够单独构建、独立更新,就需要处理新的问题: 双方不再只靠编译时直接连接,怎样保证调用方式仍然一致? 这时需要约定: 某个模块提供哪些能力。 调用时需要哪些参数。 结果放在哪里。 当前模块是否处在允许调用的状态。 跨独立模块的一次调用,可以先这样理解: 
图11:跨模块调用,不只是拿到一个地址就直接跳过去。 这里会用到 RPC、ABI 等通用术语。第一篇先不用急着记缩写,只需要理解: 这种模块间互调,不必经过网络。 同时,独立更新也不等于可以随时替换正在执行的代码。在这里讨论的更新模型中,已绑定模块不会在正常运行期间被在线替换;更新需要协调退出运行环境,再通过重启建立新的运行关系。 还要注意:调用检查不是内存隔离。原生模块中的错误,仍可能影响整个设备,不能把它当作一个可以随意装入不可信代码的安全沙箱。 如果只看某个函数,很容易觉得架构就是一次次调用。 但从系统角度看,还有三个不同时间段: 启动时,准备接口、创建执行环境、初始化组件。 运行时,接收任务、分发消息、处理服务请求、推动状态变化。 更新时,协调退出、安装内容,再重新启动。 尤其是更新,不能只盯着“把新内容写进去”。 如果还有任务在执行,或者还有调用使用旧内容,就不能假装它们不存在。 一个需要遵守的更新顺序,可以概括为: 
图12:更新模型中的安全顺序。这是跨组件需要遵守的要求,不代表一张图就证明所有失败路径已经处理完善。 这里还有一条不能省略的责任边界: 运行环境能够关闭自身的新调用,不代表它能替整个系统停止所有线程和消息来源。 相关执行来源的停止,需要由外部组织者协调;运行环境再处理自己的在途调用、共享资源和映射释放。 谁负责停止,谁负责等待,谁负责释放,必须分别讲清楚。 运行时的协作只是一半。另一半发生在构建时。 组件需要说明自己有哪些源码、公开哪些接口、依赖哪些能力,以及哪些配置下参与构建。 上层装配配置再决定:这一次选择哪些组件,启用哪些后端,哪些内容进入主程序,哪些内容作为独立模块构建。 
图13:构建时决定“装进来什么”,运行时决定“怎样一起工作”。 因此,一套框架至少需要看清两种关系: 构建关系: 谁依赖谁,哪些内容一起生成。 运行关系: 谁提交任务,谁接收事件,谁处理调用。 组件之间有构建依赖,不等于它们已经按正确顺序初始化;某个请求可以发出去,也不等于目标已经准备好。 这几条关系有关联,却不能混为一谈。 看到这里,你可能已经发现:完整架构不是一个特别庞大的模块,而是若干各负其责的机制组合起来。 以后再看一个模块,可以先问: 它负责什么? 是提供能力、安排执行,还是组织流程? 它依赖谁? 用的是公共接口,还是直接碰到了平台细节? 它在哪里执行? 当前线程、目标执行器,还是稍后的回调? 数据由谁保管? 谁创建,谁使用,什么时候释放? 失败以后怎么办? 返回失败、重试、退出,还是进入恢复处理? 第一篇不需要记住全部数据结构和调用细节。 只要先看懂这件事: 而日志、内存管理、启动管理、存储和恢复机制,则让这套系统不只在顺利时能工作,也有机会在出问题时说明原因、收好资源、重新开始。🔧 下一篇,我们从最容易被忽略的一步开始: 代码怎么分层,才不会越写越乱?
刚开始写嵌入式程序时,事情往往很简单:
这件事该由谁做?什么时候做?做完通知谁?出错以后,谁来收尾?
阅读说明:文中的名称均采用通用说法。示例中的“模块甲”“任务乙”等只是帮助理解的角色,不代表特定设备功能;流程图省略了实现细节,涉及设计要求的地方会单独说明。
一、先分清:有哪些模块,和它们怎样合作,是两回事

二、最底下的一块:别让每个模块都直接面对平台

上层提出“创建一个线程,使用指定的名称、优先级和栈大小”;适配层负责把这些参数转换为底层能够接受的形式。
三、第二块拼图:这么多模块,应该怎样启动

每个组件先交一张报到表,启动管理器按照安排逐个办理。
四、第三块拼图:把“现在就做”变成“交给合适的人做”

提交成功,只说明工作被接收,不说明工作已经完成。

五、第四块拼图:模块之间,不能只靠互相调用

我需要某项已经登记的能力来处理这个请求。

六、第五块拼图:有先后顺序的流程,交给状态机
现在处在哪一步?收到这个事件以后,下一步应该做什么?

进入某个状态 → 发起一项工作 → 收到完成或超时事件 → 决定下一步。
七、第六块拼图:公共服务不能各管各的一套底座


八、第七块拼图:代码分开发布以后,怎样继续合作

当双方分开构建时,调用也需要一份稳定的约定和对应的检查。
九、把“启动、运行、更新”连起来,才算看到了完整系统

十、这些拼图,最后怎样组装到一起

十一、先别急着追求“大而全”,记住这五个问题就够了
接口把平台差异收拢,任务把执行安排好,消息把变化传出去,调用把能力连接起来,状态机把流程组织清楚。公共服务再利用这些基础,形成完整系统。















































































