coact:让嵌入式事件处理更可控
GitHub:https://github.com/DeguiLiu/coact
1. 概述
嵌入式设备经常需要同时处理按键、通信、传感器、定时器和故障通知。传统做法通常是为每类功能创建任务,再用队列、回调和锁连接这些任务。功能增加后,系统还要处理几个问题:事件按什么顺序执行,队列最多能积压多少,提交失败后如何处理,以及系统过载后能否恢复。
coact 是一个面向单核微控制器的 C++17 事件框架。它让多个功能模块共享一个事件分发机制,并用固定容量的队列、事件池和状态机组织执行过程。框架支持 RT-Thread 和 FreeRTOS,也可以在 Linux 和 Windows 主机上运行测试。
coact 的主要价值在于统一这些规则:资源有明确上限,失败有可检查的结果,过载有受控的退化和恢复过程。选择框架时,需要同时考虑这些机制带来的收益,以及共享分发线程和静态配置带来的限制。
2. 它解决什么问题
事件传递只是事件驱动系统的一部分。多个模块协作时,还需要明确执行方式、资源归属和异常处理。例如,传感器持续上报数据时,普通采样事件能否占满故障通知需要的队列?通信模块处理变慢时,是否会拖延其他模块?这些问题需要在系统设计时给出答案。
| 需要明确的问题 | coact 的处理方式 |
|---|---|
| 事件由谁执行 | 排队事件由分发线程处理,符合条件的任务提交也可以直接执行 |
| 队列能容纳多少事件 | 队列和事件池的容量在编译期确定 |
| 提交失败怎么办 | 提交接口返回明确结果,调用方可以区分拒绝、已满和其他失败 |
| 过载后如何处理 | 通过监控和熔断状态限制继续提交,并按条件恢复 |
| 同一模块能否被同时执行 | 通过执行租约保证同一主动对象不被两个执行路径同时推进 |
使用任务、队列和回调也能实现这些功能。随着提交点和状态机增加,各模块分别实现容量控制、错误处理和恢复逻辑,会增加检查与维护的工作量。coact 将这些公共机制集中在框架中,业务代码仍负责事件含义、状态变化和失败后的具体动作。
3. coact 如何组织一次处理
3.1 事件如何到达目标模块
任务或中断产生事件后,先由协调器检查目标、提交规则和过载状态。下面展示事件需要排队时的处理路径。
#mermaid-svg-uHgkekq8YDiPDb3n{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uHgkekq8YDiPDb3n .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uHgkekq8YDiPDb3n .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uHgkekq8YDiPDb3n .error-icon{fill:#552222;}#mermaid-svg-uHgkekq8YDiPDb3n .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uHgkekq8YDiPDb3n .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uHgkekq8YDiPDb3n .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uHgkekq8YDiPDb3n .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uHgkekq8YDiPDb3n .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uHgkekq8YDiPDb3n .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uHgkekq8YDiPDb3n .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uHgkekq8YDiPDb3n .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uHgkekq8YDiPDb3n .marker.cross{stroke:#333333;}#mermaid-svg-uHgkekq8YDiPDb3n svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uHgkekq8YDiPDb3n p{margin:0;}#mermaid-svg-uHgkekq8YDiPDb3n .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-uHgkekq8YDiPDb3n .cluster-label text{fill:#333;}#mermaid-svg-uHgkekq8YDiPDb3n .cluster-label span{color:#333;}#mermaid-svg-uHgkekq8YDiPDb3n .cluster-label span p{background-color:transparent;}#mermaid-svg-uHgkekq8YDiPDb3n .label text,#mermaid-svg-uHgkekq8YDiPDb3n span{fill:#333;color:#333;}#mermaid-svg-uHgkekq8YDiPDb3n .node rect,#mermaid-svg-uHgkekq8YDiPDb3n .node circle,#mermaid-svg-uHgkekq8YDiPDb3n .node ellipse,#mermaid-svg-uHgkekq8YDiPDb3n .node polygon,#mermaid-svg-uHgkekq8YDiPDb3n .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-uHgkekq8YDiPDb3n .rough-node .label text,#mermaid-svg-uHgkekq8YDiPDb3n .node .label text,#mermaid-svg-uHgkekq8YDiPDb3n .image-shape .label,#mermaid-svg-uHgkekq8YDiPDb3n .icon-shape .label{text-anchor:middle;}#mermaid-svg-uHgkekq8YDiPDb3n .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-uHgkekq8YDiPDb3n .rough-node .label,#mermaid-svg-uHgkekq8YDiPDb3n .node .label,#mermaid-svg-uHgkekq8YDiPDb3n .image-shape .label,#mermaid-svg-uHgkekq8YDiPDb3n .icon-shape .label{text-align:center;}#mermaid-svg-uHgkekq8YDiPDb3n .node.clickable{cursor:pointer;}#mermaid-svg-uHgkekq8YDiPDb3n .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-uHgkekq8YDiPDb3n .arrowheadPath{fill:#333333;}#mermaid-svg-uHgkekq8YDiPDb3n .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-uHgkekq8YDiPDb3n .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-uHgkekq8YDiPDb3n .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uHgkekq8YDiPDb3n .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-uHgkekq8YDiPDb3n .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uHgkekq8YDiPDb3n .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-uHgkekq8YDiPDb3n .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-uHgkekq8YDiPDb3n .cluster text{fill:#333;}#mermaid-svg-uHgkekq8YDiPDb3n .cluster span{color:#333;}#mermaid-svg-uHgkekq8YDiPDb3n div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-uHgkekq8YDiPDb3n .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-uHgkekq8YDiPDb3n rect.text{fill:none;stroke-width:0;}#mermaid-svg-uHgkekq8YDiPDb3n .icon-shape,#mermaid-svg-uHgkekq8YDiPDb3n .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uHgkekq8YDiPDb3n .icon-shape p,#mermaid-svg-uHgkekq8YDiPDb3n .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-uHgkekq8YDiPDb3n .icon-shape .label rect,#mermaid-svg-uHgkekq8YDiPDb3n .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uHgkekq8YDiPDb3n .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-uHgkekq8YDiPDb3n .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-uHgkekq8YDiPDb3n :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 任务或中断
产生事件
协调器
检查并提交
固定容量队列
等待处理
分发线程
按顺序取出
主动对象
更新状态
事件回收
释放资源
排队事件进入 Staging,也就是固定容量的事件暂存区。它按 High、Normal、Low 划分优先级,分发线程再按策略取出事件,交给目标模块。配置关键事件预留容量后,普通流量不能占用相应预留槽位;预留容量默认关闭,需要按业务需求配置。低优先级事件等待达到配置阈值后,会被优先取出,避免持续被高优先级流量推迟。
框架还支持直发:当提交来自任务、目标允许直发且当前可以执行时,在提交任务的调用过程中完成处理,省去一次排队和唤醒。当前中断提交路径不执行直发。
直发和排队派发共用目标的执行租约。执行租约相当于该模块的独占执行权,只有取得它的路径才能推进模块状态。因此,同一个模块不会被两个路径同时执行,但不同模块可能在不同任务中执行。跨模块共享的可变数据仍需要单独设计同步方式。
3.2 状态机如何描述业务
主动对象(AO)是拥有自身业务数据和状态机的功能模块,例如通信模块或传感器模块。它通过事件改变状态,无须为每个模块单独创建线程。
coact 使用层次状态机(HSM)描述行为。"层次"表示状态之间可以存在父子关系。例如,通信模块的"连接中"和"已连接"可以共同属于"工作中",两个子状态都无法处理的停止事件,可以交给父状态统一处理。
状态和转移使用 C++ 的 constexpr 常量表定义。一条转移写明源状态、事件、目标状态、触发条件和执行动作,构建时不需要额外的建模工具或代码生成器。状态机引用这些表,不在运行时复制或扩展它们。评审和调试都可以直接对应源码中的定义。
共享分发线程也带来限制:某个处理函数长时间不返回,会延迟其他排队事件。状态机每一步都应在有限时间内完成;耗时计算和阻塞式设备操作应拆分,或交给独立任务,完成后再用事件通知状态机。
4. 如何控制资源和过载
4.1 容量在运行前确定
队列、事件池和模块数量都有配置上限。事件池预先保留一组存储块,事件使用完毕后归还;多个接收方共享池中事件时,通过引用计数决定何时回收。框架不在运行过程中申请堆内存,也不依靠动态注册模块扩展系统。
静态容量便于估算内存,但容量耗尽仍可能发生。事件池没有空闲块、队列已满时,调用方仍需根据业务选择重试、丢弃可替代数据,或执行故障处置。关键事件需要怎样的容量保障,也应在配置时明确。
coact 使用 C++17 的编译期检查约束部分配置和资源用法。例如,关键事件预留容量不能超过所在分区的总容量,事件池存储块必须满足事件类型的大小与对齐要求。协调器禁止复制,避免复制出两份独立的准入状态;部分关键返回值通过 [[nodiscard]] 提示调用方检查。这些机制让一部分错误在构建阶段暴露,业务层的失败处理仍需检查。
4.2 在接收事件前检查负载
队列总容量限制整个暂存区的占用,准入检查则可以进一步限制单个模块接收的负载。准入指事件正式接受前的检查,当前可配置两项约束:
| 约束 | 含义 | 作用 |
|---|---|---|
| 最小到达间隔 | 同一目标两次成功准入之间至少间隔指定时间 | 限制过密提交 |
| 每个 AO 的积压上限 | 限制已接受但尚未完成的排队事件数量,包含已出队但暂缓处理的事件 | 避免单个目标积压过多事件 |
最小到达间隔在提交处检查,不按事件出队的时间计算。事件积压后可能被连续取出,这不代表生产者提交过密。只有成功直发或成功入队才更新成功准入时间,提交失败会回滚预约。多个提交交错时,预约状态采用保守判定,可能额外拒绝请求,但不会因此放宽间隔限制。
任务和中断可能同时提交,因此检查和容量预约需要在单核有界临界区中同步,业务状态机不在该临界区中执行。准入策略在编译期选择,默认 NoAdmission 不启用这些附加检查,也不支持运行中切换策略。
4.3 拒绝和异常留下可检查的结果
提交接口返回 SubmitResult,用于区分接受、限流、过载拒绝等结果。监控模块记录队列水位、溢出、拒绝和处理耗时等信息,状态机未处理的事件也有单独计数。测试或上层监控可以据此判断事件是否按预期处理。
需要定位准入问题时,可以额外配置故障快照,记录目标、原因、时间等现场信息。快照默认不分配记录槽位,并且在并发竞争时允许丢失诊断记录,不能把它当作保证完整的事件日志。
4.4 过载后逐步限制并恢复
coact 为每个 AO 保存一份熔断状态机。熔断指负载或异常达到条件后,暂时限制继续执行的工作,避免问题持续扩大。
| 状态 | 主要行为 |
|---|---|
| Normal | 按正常规则提交和执行 |
| BrokenL1 | 撤销该 AO 的直发能力 |
| BrokenL2 | 限制非关键事件,减少继续积压 |
| Safe | 仅允许规定的安全事件,需要外部恢复请求 |
| Recovering | 进行受控探测,观察是否具备恢复条件 |
从降级状态恢复,需要满足冷却、水位持续降低、探测成功和连续健康窗口等条件。一次处理成功不足以恢复到 Normal。恢复所需的水位采样、健康信息和外部恢复请求,需要产品侧按接口提供。
这里的 Safe 是事件策略状态,不代表设备已经完成硬件安全动作。熔断器不能终止正在执行的函数,也不能直接切断电机输出或关闭危险外设,这些动作需要由产品定义并接入。
5. 故障处理能做到什么
coact 通过准入检查限制提交负载,通过耗时监测识别处理过慢的模块,再由熔断机制限制后续事件。这些机制用于控制过载,不提供内存隔离,也不保证内存损坏后的局部恢复。
处理耗时在函数调用前后测量,包含期间的中断和任务抢占时间。因此,它是经过时间监测,无法精确代表 CPU 执行时间,也不能在超时瞬间强制终止函数。函数陷入死循环时,需要独立硬件看门狗或经验证的平台机制执行安全动作或复位。
多个 AO 共用分发线程栈,发生栈损坏后不能默认只重启一个 AO。若无法证明栈和框架数据完整,应交由平台执行安全动作或复位。
6. 使用 coact 需要多少资源
固定容量使资源用量便于核算,但框架本身仍有代码和存储开销。仓库在 Cortex-M4、FreeRTOS、-Os 优化和零堆配置下,以裸 FreeRTOS 为基线运行一个 AO,得到以下增量:
| 配置 | Flash 增量 | 静态 RAM 增量 |
|---|---|---|
| 默认配置 | 约 8.8 KiB | 约 9.3 KiB |
| 小配置并启用链接时优化(LTO) | 约 7.9 KiB | 约 6.3 KiB |
默认配置分发线程栈为 4 KiB,三个优先级队列分别有 32、64、128 个槽位,每个槽位 12 字节。栈和队列占据主要 RAM 成本。缩小配置能够降低开销,同时也会降低可容纳的模块数或积压事件数。
这些数字来自仓库测试配置,不能直接当作产品的最低需求。实际项目还要计入业务数据、事件载荷和其他任务栈,并验证长调用链、抢占和故障路径下的栈用量。静态容量给出了存储上限,单个业务函数的最坏执行时间仍需另行分析。
7. 什么情况下适合使用
coact 更适合以下系统:
- 有多个相互协作的功能模块;
- 事件来源同时包括任务和中断;
- 模块内部存在较多状态转换;
- 需要说明队列上限、失败路径和恢复条件;
- 目标平台希望避免运行时堆分配。
以下情况通常不需要引入 coact:
- 系统只有一个生产者和一条线性处理流程;
- 业务规模很小,队列和状态机可以直接写清楚;
- Flash 或 RAM 预算无法承担固定的分发线程和队列开销;
- 系统需要多核调度、完整用户态或现成的认证平台。
框架的价值取决于它减少了多少重复的约定和检查。如果系统本身很简单,直接使用任务、队列和状态机可能更合适。
8. 进一步了解
coact 的源码、示例、测试和完整接口说明见 GitHub:
https://github.com/DeguiLiu/coact
资源测量过程见固件与 RAM 占用实测,准入检查和故障快照的实现说明见可靠性增强设计第 4 节和第 8.1 节。
项目采用 MIT License。coact 不是经过功能安全认证的产品,使用者仍需根据目标设备完成风险分析、故障注入、覆盖率验证和硬件平台测试。