夜雨聆风学习资料网

ARTICLE · 1218306

《从源码搭建嵌入式软件框架》01|先看全貌:一套嵌入式框架是怎样拼起来的

《从源码搭建嵌入式软件框架》01|先看全貌:一套嵌入式框架是怎样拼起来的

刚开始写嵌入式程序时,事情往往很简单:

初始化一下,读点数据,处理一下,再把结果交出去。
可随着功能增加,你会发现,真正麻烦的越来越不是“某个函数怎么写”,而是:

这件事该由谁做?什么时候做?做完通知谁?出错以后,谁来收尾?

一个模块还没准备好,另一个模块就来调用它;一个操作执行得太久,后面的任务全在等;一份数据同时被几个人使用,却没人说得清应该由谁释放……
这时,我们需要的就不只是更多函数,而是一套让函数、模块和资源有序配合的规则。
这就是软件框架要解决的问题。🧩
这一篇先不钻进复杂实现。我们先把几块主要的拼图摆出来,看看一套框架怎样从“能执行代码”,走向“能组织完整系统”。

阅读说明:文中的名称均采用通用说法。示例中的“模块甲”“任务乙”等只是帮助理解的角色,不代表特定设备功能;流程图省略了实现细节,涉及设计要求的地方会单独说明。

一、先分清:有哪些模块,和它们怎样合作,是两回事

看到一套软件,我们很容易先数功能:
有数据管理,有连接管理,有资源管理,还有显示和升级。
但把这些功能放进不同目录,并不等于有了清晰的架构。
就像一间办公室,分出了财务、人事和行政,还得继续回答:
谁负责安排工作?谁能申请资源?收到通知以后,谁来处理?
软件也是一样。
我们可以先把它分成下面几组:
图1:按职责归纳的架构总览。实线表示主要的使用关系,不是每个组件必须逐层经过的完整依赖图。
先记住三个问题就够了:
做什么?
由上层组织和具体服务决定。
怎样配合?
由任务、消息、调用和状态机等机制解决。
最终怎样执行?
通过统一接口连接到操作系统与硬件。
例如,资源管理负责找到并提供资源内容;任务执行器负责安排一段工作;线程接口负责创建实际运行工作的线程。
它们解决的不是同一个问题。
这也解释了一个容易混淆的地方:
服务是框架提供的能力,机制是把这些能力组织起来的方法。

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

假设几个模块都需要创建线程。
如果它们分别使用平台提供的接口,就都需要知道:线程怎样创建、优先级怎样填写、栈空间怎样设置、创建失败返回什么。
换一个平台,这些地方就可能一起受到影响。
统一接口的做法,是在中间增加一个明确的入口:
图2:上层表达需求,适配层处理平台差异。
举个简单的例子:

上层提出“创建一个线程,使用指定的名称、优先级和栈大小”;适配层负责把这些参数转换为底层能够接受的形式。

同样的思路还可以用在线程之外:
上层需要做的事
统一的操作入口
等待另一个任务完成某件事
信号量接口
保护一份共享数据
互斥锁接口
稍后触发一个动作
定时器接口
申请和释放内存
内存接口
查询时间或运行状态
时间与系统信息接口
这里并不是重新发明一个操作系统,而是在已有能力上加一层统一的使用方式。
不过,统一接口也不是“换个平台就完全不用改”。内存类型、线程限制、硬件访问方式仍可能不同。它的价值是:让差异尽量集中,而不是散落在各个模块里。

三、第二块拼图:这么多模块,应该怎样启动

接口准备好了,不代表所有模块已经可以使用。
任务队列还没创建,不能往里面投递工作;消息系统还没初始化,也不能指望它完成分发。
所以,启动不是把所有初始化函数随意调用一遍,而是有顺序的过程。
这里采用的组织方式,可以概括为:
图3:启动主路径的简化示意,未展开配置分支和失败路径。
关键不只是“分几步”,还有“怎样知道需要启动谁”。
一种做法是,让组件提供一份启动登记信息,其中包含初始化入口、退出入口和所属阶段。编译链接时收集这些信息,启动管理器再按阶段遍历执行。
于是,新组件可以通过登记加入启动过程,不必每次都去主入口增加一行调用。
可以把它理解成:

每个组件先交一张报到表,启动管理器按照安排逐个办理。

需要注意,这种方式并不等于系统已经能够自动推导全部依赖。阶段和优先级仍需要维护,失败结果也需要正确传递。
还有一个很有意思的现象:启动入口完成工作后,可以结束自身,而系统继续运行。
因为后续工作已经由其他线程、队列和定时任务接手。启动入口更像“开门的人”,不一定也是整天站在门口处理所有事情的人。

四、第三块拼图:把“现在就做”变成“交给合适的人做”

直接调用函数很方便:
调用方进入函数,函数处理完,再返回。
但如果操作比较耗时,调用方就得一直等着。
因此,我们还需要另一种方式:先把工作描述出来,交到队列里,再由执行器处理。
图4:任务的提交和执行,被分开了。
例如,任务甲提交了一项数据处理工作。提交后,它可以继续处理自己的其他事情;真正的数据处理,则由目标执行器在取到任务后进行。
一个任务不必很复杂,主要描述:
要执行什么操作。
操作需要什么参数。
是否需要在完成后处理结果。
但这里有一个必须马上记住的区别:

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

而且,异步并不等于无限快。
同一个执行器会顺序处理任务。如果前一个任务一直不结束,后面的任务仍然要等;队列也有容量,用完了就可能提交失败。
所以,任务执行框架不仅要负责“把工作送过去”,还要面对“工作积压了怎么办”。
做完以后,怎样把结果送回来?
如果发起方还关心执行结果,就需要回执。
图5:不同执行环境之间的一种回执路径;同一执行环境下,处理方式可以更直接。
这就是理解异步通知的一个入口:事情可以在别处完成,结果再交回约定的地方处理。
后面我们还会继续追问:参数保存在哪里?回执执行前,数据能不能释放?这些不是小细节,而是异步机制能否安全工作的基础。

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

模块一多,通信需求也会出现区别。
有时只是想告诉大家“某件事发生了”;有时是明确要求某个能力“帮我完成一个操作”。
这两种情况,最好不要混在一起。
1. 发生了一件事:使用发布订阅 📣
为了说明关系,假设模块甲、乙、丙都关心同一个事件。
它们先订阅这个事件。发布者之后只需要发布消息,不必逐个调用它们。
图6:一个发布动作,可以对应多个订阅者的处理任务。
这里的甲、乙、丙只是角色示例,不代表固定的功能组合。
发布订阅的好处,是发布者不需要认识每一个接收者。新增接收者时,可以通过增加订阅关系接入。
但它没有消除所有问题:消息仍需要占用内存,处理仍需要时间,接收者也可能处理失败。共享消息数据、结果确认和失败重试,都要有对应的管理方式。
2. 请求完成一项操作:使用服务调用
另一种情况更直接:

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

这时可以先查找服务,再交给对应的处理函数。
图7:调用方通过服务标识找到能力,而不是到处引用具体函数。
两者可以这样区分:
方式
更像在说什么
主要关系
发布订阅
“这件事发生了,关心的人来处理。”
一对多通知
服务调用
“请用这项能力帮我处理请求。”
面向处理者的请求
它们也不必各建一套线程体系。通知和服务请求中的异步工作,都可以借助前面的任务执行器。
框架的组合能力,正体现在这里:新机制利用已有基础,而不是每增加一种通信方式,就重新造一套运行环境。

六、第五块拼图:有先后顺序的流程,交给状态机

队列能安排工作,消息能传递事件,但它们还没有回答:

现在处在哪一步?收到这个事件以后,下一步应该做什么?

这正是状态机要解决的问题。
以更新过程为例,只看主路径,可以简化成:
图8:为方便理解而合并后的主路径,省略了连接、清单处理及错误分支等中间步骤。
这里有三个最基本的概念:
状态:
当前在哪一步。
事件:
刚刚发生了什么。
转移:
根据当前状态和事件,决定下一步。
例如,同样是一个“完成”事件,处在“准备”阶段和“安装”阶段,含义就不相同。
状态机负责把这种区别表达清楚。
它还可以在进入或离开状态时执行动作,并通过条件决定某条转移是否允许发生。超时也可以被转换为事件,交给状态机按当前阶段处理。
于是,任务、通知和状态机就连起来了:

进入某个状态 → 发起一项工作 → 收到完成或超时事件 → 决定下一步。

需要提醒的是,状态机引擎并不会自动帮我们完成异步工作。它管理的是“流程走到哪里”,任务执行器管理的是“工作在哪里执行”。

七、第六块拼图:公共服务不能各管各的一套底座

有了前面的机制,数据、资源、连接和窗口等服务,就可以在它们之上建立自己的能力。
以数据管理为例,同一个读写入口,可以根据配置把操作交给不同后端:
图9:使用者提出读写需求,具体存放方式由配置和后端处理。
例如,同样是读取一项数据,有些数据来自内存,有些来自非易失存储。使用者不需要在每次访问时,都重新处理底层操作。
不过,不能由此认为“所有数据都具有相同的保存方式和事务保证”。
对于需要以完整内容安装和提交的二进制对象,还存在另一套对象管理机制:
图10:对象写入和对象正式提交,是不同阶段。
这里的“对象”,可以先理解为“一份有身份、可查询、可读写的完整内容”,不必把它想得很复杂。
它与普通数据读写的区别在于:除了内容本身,还需要管理对象位置、提交状态和恢复信息。
这给我们一个很重要的认识:
统一框架,不等于所有东西都塞进同一个接口。职责不同,就应该保留清楚的边界。

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

前面讨论的模块,可以一起编译到主程序里。
但如果希望某些模块能够单独构建、独立更新,就需要处理新的问题:
双方不再只靠编译时直接连接,怎样保证调用方式仍然一致?
这时需要约定:
某个模块提供哪些能力。
调用时需要哪些参数。
结果放在哪里。
当前模块是否处在允许调用的状态。
跨独立模块的一次调用,可以先这样理解:
图11:跨模块调用,不只是拿到一个地址就直接跳过去。
这里会用到 RPC、ABI 等通用术语。第一篇先不用急着记缩写,只需要理解:

当双方分开构建时,调用也需要一份稳定的约定和对应的检查。

这种模块间互调,不必经过网络。
同时,独立更新也不等于可以随时替换正在执行的代码。在这里讨论的更新模型中,已绑定模块不会在正常运行期间被在线替换;更新需要协调退出运行环境,再通过重启建立新的运行关系。
还要注意:调用检查不是内存隔离。原生模块中的错误,仍可能影响整个设备,不能把它当作一个可以随意装入不可信代码的安全沙箱。

九、把“启动、运行、更新”连起来,才算看到了完整系统

如果只看某个函数,很容易觉得架构就是一次次调用。
但从系统角度看,还有三个不同时间段:
启动时,准备接口、创建执行环境、初始化组件。
运行时,接收任务、分发消息、处理服务请求、推动状态变化。
更新时,协调退出、安装内容,再重新启动。
尤其是更新,不能只盯着“把新内容写进去”。
如果还有任务在执行,或者还有调用使用旧内容,就不能假装它们不存在。
一个需要遵守的更新顺序,可以概括为:
图12:更新模型中的安全顺序。这是跨组件需要遵守的要求,不代表一张图就证明所有失败路径已经处理完善。
这里还有一条不能省略的责任边界:
运行环境能够关闭自身的新调用,不代表它能替整个系统停止所有线程和消息来源。
相关执行来源的停止,需要由外部组织者协调;运行环境再处理自己的在途调用、共享资源和映射释放。
谁负责停止,谁负责等待,谁负责释放,必须分别讲清楚。

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

运行时的协作只是一半。另一半发生在构建时。
组件需要说明自己有哪些源码、公开哪些接口、依赖哪些能力,以及哪些配置下参与构建。
上层装配配置再决定:这一次选择哪些组件,启用哪些后端,哪些内容进入主程序,哪些内容作为独立模块构建。
图13:构建时决定“装进来什么”,运行时决定“怎样一起工作”。
因此,一套框架至少需要看清两种关系:
构建关系:
谁依赖谁,哪些内容一起生成。
运行关系:
谁提交任务,谁接收事件,谁处理调用。
组件之间有构建依赖,不等于它们已经按正确顺序初始化;某个请求可以发出去,也不等于目标已经准备好。
这几条关系有关联,却不能混为一谈。

十一、先别急着追求“大而全”,记住这五个问题就够了

看到这里,你可能已经发现:完整架构不是一个特别庞大的模块,而是若干各负其责的机制组合起来。
以后再看一个模块,可以先问:
它负责什么?
是提供能力、安排执行,还是组织流程?
它依赖谁?
用的是公共接口,还是直接碰到了平台细节?
它在哪里执行?
当前线程、目标执行器,还是稍后的回调?
数据由谁保管?
谁创建,谁使用,什么时候释放?
失败以后怎么办?
返回失败、重试、退出,还是进入恢复处理?
第一篇不需要记住全部数据结构和调用细节。
只要先看懂这件事:

接口把平台差异收拢,任务把执行安排好,消息把变化传出去,调用把能力连接起来,状态机把流程组织清楚。公共服务再利用这些基础,形成完整系统。

而日志、内存管理、启动管理、存储和恢复机制,则让这套系统不只在顺利时能工作,也有机会在出问题时说明原因、收好资源、重新开始。🔧
下一篇,我们从最容易被忽略的一步开始:
代码怎么分层,才不会越写越乱?

相关学习资料