跳到主要内容

设计思想

这一页不讲 API 清单,而是讲 LibXR 现在仍然坚持的几条底层判断:数据流怎样组织、回调和中断该承担什么职责、I/O 完成行为怎样表达、平台边界怎样划分。很多接口看起来有些克制,正是这些判断的结果。

无锁数据结构与 ISR 驱动的数据流

在设备驱动里,真正棘手的不是“有没有多核”,而是数据在 ISRDMA、线程之间如何交接得既及时又可控。只要这条交接链路依赖长临界区、阻塞等待或调度顺序,问题很快就会表现成时延抖动、状态错乱、甚至直接丢数据。

因此 LibXR 在串口、USB、DMA 这类敏感路径上,更倾向于使用预先准备好的缓冲区、环形队列、双缓冲和明确的状态流转,把高频路径压成由硬件事件推动的数据流:中断负责交接与状态推进,线程负责展开后续处理。重点不在“形式上一定无锁”,而在尽量不让敏感路径依赖 mutex、阻塞式 OS 队列或长时间关中断

常见疑问

  • 大部分 MCU 都是单核,还需要无锁吗? 需要。这里要解决的不是“多核竞争”本身,而是 ISR 抢占线程、DMA/外设按自己的节奏推进、线程与中断共享状态时的数据交接问题。单核照样会有竞态,差别只是竞争发生在“不同上下文切换之间”,不是多个核心同时执行。

  • 为什么不用 mutex 和 OS 队列,这不是给自己找麻烦吗? 不是完全不用,而是不把它们放在真正敏感的 I/O 热路径上。很多时候如果一条路径用 SPSC 环形队列就能解决,那当然是更好的选择;CAS 也只是手段之一,不是目的本身。真正要避免的是:把高频交接路径绑到锁竞争、线程唤醒顺序或长临界区上。尤其在很多 RTOS 里,mutex 本身就不能在 ISR 中使用,临界区往往最后还是会退化成关中断。

  • 为什么不把所有数据都在线程里处理? 因为有些动作不能等线程醒来再做。缓冲切换、端点 rearm、DMA 状态推进、接收窗口确认这类事情,本身就和硬件节奏绑在一起,必须尽快完成交接;真正适合丢给线程的,是后续业务处理、协议解析和上层逻辑。另外,也不是所有驱动都只靠“一条队列”就能解决,很多地方还有 busy/pending 状态、缓冲生命周期和状态机切换要在临界点上处理。

  • 这种设计到底好在哪? 它的价值不只是快,而是更可控:高频路径的最坏时延更容易估计;回调、中断、线程的职责边界更清楚;更少依赖调度器唤醒顺序;适配新硬件时,也更容易判断问题到底出在状态交接、缓冲管理,还是上层逻辑。

嵌入式系统中,运行时的内存分配是设计缺陷

这句话真正想强调的,不是口号式地禁用 mallocnew,而是:高频运行期路径不应该依赖临时资源分配来维持正确性。

LibXR 里,构造阶段、初始化阶段可以完成缓冲区、队列和必要对象的准备;但一旦进入主循环、热路径、ISR 或高频回调,再临时申请和释放资源,就很容易把时延、失败路径和内存边界一起拖进不可控状态。

而且作为一个稳定的嵌入式系统,所有需要堆分配的对象都应该只在初始化时构造一次,永不析构。这种设计允许使用非常简单的数据结构实现内存分配,例如只出不进的栈,或者其他只需要在初始化阶段单向增长的 allocator。

常见疑问

  • 完全动态分配能够节省内存吗? 动态分配只是看起来能够节省而已。真实系统里的内存占用更接近一个持续波动、而且很难提前封顶的分布函数。你无法知道所有任务在运行过程中可能申请的总量,也就无法知道系统真正需要的上限。换句话说,这个上限会在某个时刻悄悄超过你的心理预期,然后惊艳所有人;而它往往又非常接近静态分配时最终需要准备的总和。

  • 为什么不完全使用全静态分配? 因为“全静态”也不是目标本身。它确实最容易分析,但会把初始化复杂度、配置负担和对象组织方式推到很高。LibXR 当前真正坚持的是:把资源准备尽量放在可控时机,把热点路径里的临时补救式分配降到最低;而不是要求所有对象都必须用最死板的方式一次性摊平。

  • 遇到必须使用动态分配的场景怎么办? 设计是为业务服务的,必要时可以打破。关键不在“有没有动态分配”,而在于能不能给出清晰可控的上界。如果不是 web/ui 这类天生开放、上界极难事先封顶的场景,大多数设计模式都可以在“存在有限内存上界”的约束下完成。因此,这里更推荐的策略不是“运行中到处零散申请、零散释放”,而是按阶段分配、按生命周期回收:启动阶段准备长期对象和缓冲;某个模块或某个流程的临时对象集中放在一段受控区域里;当这个阶段结束、模块卸载或系统重启时再整体回收。

  • 为什么不按照内存池分配不同区域的内存? LibXR 只会根据速度、总线访问能力这类硬件属性去区分内存区域,例如 STM32H7 上的 DTC RAM。而驱动层本身就带有天然的隔离语义,不需要再靠额外的“按模块分池”策略去制造第二层复杂度。真正需要区分的,通常是“这块内存够不够快、能不能被某条总线访问”,而不是“这块内存属于哪个设计模式”。

一切回调/中断都必须是无阻塞的

这条原则真正要限制的,不是“回调里一行代码都不能有”,而是不要在回调和中断里做会把时延、调度和资源边界一起拖进不可控状态的事情。因此,LibXR 反对的是阻塞等待、复杂业务展开、额外资源申请,以及任何依赖调度器和唤醒顺序才能完成的处理;至于和硬件交接直接相关、时长可估计、边界明确的工作,本来就应该在这里完成。

常见疑问

  • LibXR 在很多回调里做的事情并不少,尤其是 ISR 驱动的通信接口里经常会有大量拷贝,这是不是违背了这条语义? 不违背。这里的“无阻塞”不等于“什么都不做”,而是“不做不可控的事”。像缓冲切换、数据搬运、端点 rearm、状态推进、把一段数据从硬件交到软件队列里,这些都是交接的一部分,只要它们的边界明确、成本可估计,就属于回调和 ISR 里应该完成的工作。真正不该放进来的,是等待、睡眠、锁竞争、堆分配和大段高层业务逻辑。换句话说,允许的是有限、可界定的交接;禁止的是不可控的展开。

  • 如何保证这个规则的遵守?只靠开发者的个人能力吗? 不能只靠自觉。LibXR 里,很多接口形状本身就在替这条规则兜底:上下文信息通过 in_isr 显式传递;普通接口与 callback-safe 接口分开;Operation 会在发起时就把完成行为绑定进去;高频 I/O 路径通过端口、缓冲和队列做交接,而不是鼓励你在回调里自己发明收尾逻辑。另外,很多明显错误的做法本身在语义上就走不通,例如把需要线程语义的同步原语直接塞进 ISR。也就是说,这条规则当然仍然需要开发者理解,但它不是赤裸裸地只靠“个人修养”,而是通过接口边界、上下文显式化和数据流分层去持续约束。

  • 回调的嵌套与堆栈如何管理? 这是这条原则成立的另一半。如果回调之间可以无限递归地相互触发,那么就算每一层都“不阻塞”,系统照样会因为栈深失控而出问题。LibXR 在回调实现里对这种情况做了明确约束:同一回调在执行期间再次被触发时,不继续递归加深调用栈,而是把请求压回当前调用点,等当前执行结束后再补跑。这样做的目标,就是把“嵌套调用”压平,避免回调链越滚越深。再配合前面那条“ISR 负责交接,线程负责展开”的分层,堆栈和响应时序才都能保持在可分析范围内。

上下文(thread/isr)必须在回调中显式传递

回调究竟发生在线程里,还是发生在 ISR / callback 上下文里,会直接影响哪些 API 能调、哪些行为安全。LibXR 在这件事上的选择很明确:让上下文成为接口语义的一部分,而不是让调用者自己猜。

因此你会在很多接口里看到 in_isr 一类信息,或者看到普通接口与 callback-safe 接口成对出现。这样做会比“全部隐藏在内部”更啰嗦一点,但更安全,也更容易在不同平台下保持一致行为。

常见疑问

  • 为什么不把上下文判断藏在内部,让框架自己识别? 因为不同平台并不一定都能用统一、低成本而且可靠的方式判断当前是不是处在 ISR / callback 上下文里。更重要的是,这件事本来就会影响哪些 API 可以调、哪些行为安全,所以它不该被伪装成一个“调用者不需要知道”的实现细节。显式传递上下文,本质上是在把约束写进接口语义里。

  • 显式传 in_isr 会不会让接口变得很丑? 会更啰嗦一点,但这点啰嗦远小于把上下文藏起来之后带来的误用成本。这里暴露的不是平台私货,而是调用边界。如果这条边界不在接口里写清楚,调用方迟早还是要以更隐蔽、也更危险的方式把它补回来。

  • 为什么普通接口和 callback-safe 接口要分开,不能只保留一套“自动兼容”的接口? 因为线程上下文能做的事和 ISR 上下文能做的事本来就不一样。强行统一,通常只会把所有接口都退化到最保守子集,或者把失败条件埋进运行时。分开反而更清楚:哪些地方允许阻塞,哪些地方只能做投递、唤醒和有限交接,一眼就知道。

任何 I/O 操作都必须绑定确定的完成行为

I/O 的重点不是“请求已经发出去了”,而是完成之后由谁接、怎么接、什么时候算结束

这也是 Operation 模型存在的原因:发起操作时,就把完成行为一起绑定进去,例如回调完成、阻塞等待、轮询状态或忽略结果。这样驱动层、端口层和上层调用方之间的职责边界会更清楚,也不需要靠约定俗成去猜接口的收尾方式。

常见疑问

  • 为什么不能只提供一个“发起 I/O”的接口,完成以后再由上层自己想办法收尾? 因为 I/O 最大的不确定性恰恰就在收尾阶段。谁来接完成信号、何时释放 busy 状态、超时算谁的责任、出错后怎么传播,如果这些都不在发起时说明白,驱动层和调用方就只能靠默契拼接,最后谁都以为自己负责了一半,实际上没人真正把结束条件钉死。

  • 阻塞、回调、轮询、忽略结果,看起来只是调用风格不同,为什么要在一套语义里统一表达? 因为它们不只是“风格不同”,而是完成责任的归属不同。驱动在发起那一刻就应该知道:这次操作完成后是唤醒等待者、触发回调、等待轮询方来取状态,还是明确允许直接忽略。把这些行为统一进一套模型,才能让端口层、驱动层和上层在同一套结束语义上对齐。

  • NONE 这种“忽略结果”的模式真的有意义吗? 有,但前提是它必须是一个显式选择,而不是默认漏掉。它的意义不在于鼓励“发出去就不管”,而在于把“这次我确实不关心完成通知”也纳入可表达的语义里。真正危险的不是 NONE 本身,而是接口没有把这种选择表达出来,结果把本来应该明确绑定的完成行为默默丢掉。

接口中不应出现任何平台相关类型

公共接口应该表达能力和语义,而不是直接暴露某个平台的底层句柄类型。换句话说,接口更应该告诉你“这里是一条 UART”“这里是一种完成行为”,而不是要求上层代码直接依赖某个 MCU SDK 的结构体或某个系统的私有类型。

一旦平台细节进入公共接口,上层就会很快和具体实现绑死,后续的可移植性与维护性都会迅速变差。

常见疑问

  • 为什么不能直接把 HAL 句柄、termiosTaskHandle_t 这类类型暴露给上层?这样不是更直接吗? 因为这种“直接”只是把平台耦合提前了而已。一旦公共接口带上这些类型,上层逻辑就会立即和某个 SDK、某个 OS、某个平台的生命周期与约束绑死,后面再谈替换实现、跨平台复用、做模拟测试,代价都会急剧上升。

  • 把平台类型都藏起来,会不会把接口抽象得太空,最后什么都表达不了? 不会。公共接口本来就应该表达能力和语义,例如“这是一条串口”“这里需要一个完成行为”“这里要传一个缓冲区”,而不是表达某个平台内部到底拿什么结构体承载这些能力。抽象真正该做的是保留行为信息、隔离实现细节,而不是把所有细节都硬抹平。

  • 如果某个平台真有独占能力怎么办,难道也不能暴露? 可以有,但不应该污染公共主接口。跨平台共性就留在公共接口里;平台独占能力应该放在平台实现、扩展层或明确带平台语义的专用入口里。这样做的关键不是“绝不允许特殊能力存在”,而是避免为了照顾一个平台的特例,把整套公共接口一起拖偏。