跳到主要内容

设计思想

本页说明 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 本身不是问题;问题是在热路径、ISR 或高频回调里实时申请和释放资源,容易把时延、失败路径和内存边界拖进不可控状态。

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

常见疑问

  • 完全动态分配能节省内存吗? 只是看起来能节省。真实系统里的内存占用更接近一个持续波动、难以提前封顶的分布。无法知道所有任务在运行过程中可能申请的总量,也就无法知道系统真正需要的上限,这个上限往往又非常接近静态分配时需要准备的总和。

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

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

  • 为什么不按内存池分配不同区域? 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”的接口,完成以后再由上层自己想办法收尾? 完成信号由谁接收、何时释放 busy 状态、超时和错误如何传播——这些如果不在发起时确定,驱动层和调用方就只能靠约定拼接,容易出现结束条件不明确的问题。

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

  • NONE 这种”忽略结果”的模式真的有意义吗? 有,但前提是它必须是一个显式选择。NONE 本身不危险;危险的是接口根本没把这个选项表达出来,结果完成行为被悄悄丢掉。

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

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

平台细节一旦进入公共接口,上层就会和具体实现绑死,替换实现、跨平台复用、做模拟测试的代价都会急剧上升。

常见疑问

  • 为什么不能直接把 HAL 句柄、termiosTaskHandle_t 这类类型暴露给上层?这样不是更直接吗? 这种”直接”只是把平台耦合提前了。公共接口带上这些类型,上层逻辑就会立即和某个 SDK、某个 OS、某个平台的生命周期与约束绑死。

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

  • 如果某个平台真有独占能力怎么办,难道也不能暴露? 可以有,但不应该污染公共主接口。跨平台共性留在公共接口里;平台独占能力放在平台实现、扩展层或明确带平台语义的专用入口里。目的是避免为了一个平台的特例,让整套公共接口一起偏移。