跳到主要内容

BLOCK 超时与完成交接

本文说明异步完成路径中 BLOCK 超时的含义:调用者停止等待之后,底层是否还会完成;如果会,这次完成是否还能唤醒原来的等待者、访问原来的缓冲区。

1. BLOCK timeout 的真实含义​

BLOCK timeout 限制的是调用者的同步等待窗口,不等于所有底层工作都会被撤销。

对 ReadPort,尚未完成的软件读可以取消;但返回前必须保证完成方不会再访问调用者的接收缓冲。如果完成已经先 claim 了这次交接,等待者会把这笔完成收完再返回。

对 WritePort,请求一旦接纳,字节已经复制进端口队列。timeout 不撤回这些字节,后端仍可能继续发送。也就是说,“调用返回 TIMEOUT”和“这条命令没有发生”不是同一件事。

事务型 SPI / I2C 则由具体驱动决定能不能停 DMA、复位外设或安全回收中间缓冲。统一的 waiter 状态机不能代替这些硬件动作。

2. 为什么要有 detach 语义​

如果超时后只把等待状态清零,会出现以下问题:

  1. 调用者已经返回 TIMEOUT;
  2. 老的底层完成稍后到来;
  3. 这次完成又错误地 post 旧 semaphore,或者碰到下一次调用已经复用的通知对象。

所以等待路径要先把“这次 waiter 还属不属于当前调用者”说清楚。AsyncBlockWait 用 DETACHED 表达 timeout 已经分离 waiter;WritePort 也有自己的 BLOCK_DETACHED / BLOCK_RETIRE_WAITING 来处理“调用已返回,但请求还在队列里”的情况。

detach 让迟到完成知道旧 waiter 已经不能再被唤醒,底层工作本身仍会继续。

3. AsyncBlockWait 解决什么问题​

AsyncBlockWait 给驱动内部“同步等待一个异步完成”的路径提供标准 handoff。

它的状态是:

状态含义
IDLE当前没有等待
PENDINGwaiter 已经挂起
CLAIMED完成方已经认领通知
DETACHEDtimeout 已经把 waiter 分离

Start(sem) 先把 waiter 挂成 PENDING。完成方(TryPost(...))只有成功做出 PENDING -> CLAIMED,才写入结果并 post;timeout 方则在仍是 PENDING 时切到 DETACHED。如果完成先 claim,Wait() 即使最初那次有限等待已经超时,也要继续等这次已经归属当前调用的 post,再返回最终结果。迟到完成看到 DETACHED 时只把状态清回 IDLE,不再 post。

Cancel() 在硬件启动失败时把状态清回 IDLE,不发送通知。AsyncBlockWait 不检查上一次传输是否结束:Start() 总是进入 PENDING,若旧传输的迟到完成在此之后到达,会认领新的等待者。因此驱动在调用 Start() 前须确认硬件空闲,例如 STM32SPI 在 HAL 状态不是 HAL_SPI_STATE_READY 时返回 BUSY。

这个模型只处理 waiter 的归属;停止 DMA、保证 caller buffer 的状态由具体驱动负责。


4. 最常见的几个 bug​

4.1 硬件先启动,waiter 后挂起​

这是最典型的一类。

错误顺序:

  1. 先 arm 硬件 / 启动 DMA / 打开中断;
  2. 再 block_wait_.Start(...)。

风险是底层完成太快,ISR 比 waiter setup 更早到,完成通知没有合法的等待者可以 claim。

正确顺序应该是先把 waiter 状态挂好,再把硬件暴露给可能立即到来的 DMA / IRQ 完成路径。

4.2 把旧 semaphore token 当成本次完成​

如果一个 semaphore 被重复用于多次 BLOCK 调用,而等待路径只把 sem->Wait(timeout) == OK 当成成功,上一次残留的 token 就可能被误认成当前完成。

正确做法是由请求状态先确认“这次 wake 到底属于谁”:等待状态须已由本次操作的完成方切换为 CLAIMED(AsyncBlockWait 的 CLAIMED,或端口的 BLOCK_CLAIMED)。semaphore 是唤醒通道,不是请求身份本身。

4.3 timeout 后只返回,不处理所有权​

超时返回之后,旧的完成路径仍可能修改共享状态、再次 post 信号量,甚至覆盖新等待者的归属。

因此 timeout 返回前至少要完成一件事:要么安全取消这次软件请求,要么把 waiter detach,让迟到完成静默退出;如果完成已经 claim,则等这次交接结束。

4.4 完成成功了,但 caller buffer 没更新​

这类问题多出现在“异步完成 + 同步外观”的读事务。完成路径报告成功,但 DMA 收到的数据还在中间 buffer,或者 timeout/abort 后又迟到写进 caller 地址;上层看到的状态和手里的数据不一致。

所以驱动不能只检查“有没有 post”。它还要定义成功返回时 caller-visible buffer 已经处于什么状态,以及 timeout 返回后谁还可能访问它。


5. timeout 和最终结果为什么可能不一致​

如果 timeout 和 completion 竞争,大致有两种情况。

timeout 先赢​

  • waiter 成功从 PENDING detach;
  • 当前调用返回 TIMEOUT;
  • 迟到完成不再唤醒旧 waiter,只处理自己的收尾。

completion 先 claim​

  • completion 已经把 waiter 标成 CLAIMED;
  • 有限 Wait(timeout) 可能正好返回超时;
  • 但这笔完成的所有权已经属于当前调用;
  • 等待路径继续等对应 post,最后返回真实完成结果。

因此 timeout 是等待窗口,不是严格的函数墙钟上限;竞争点上谁先拿到 completion ownership,决定最终结果。

6. ReadPort 和 WritePort 为什么表现不同​

读端口借用的是调用者接收缓冲区。timeout 后最重要的事情,是返回之前结束对这块缓冲区的访问,所以它可以取消尚未完成的软件读,并在必要时等待已经 claim 的处理方退出。

写端在接纳时已经复制源数据,caller 的源 buffer 可以在调用返回后复用。因此 timeout 处理的是另一件事:队列里的旧请求怎样继续退出,同时不再触碰旧 waiter 的 semaphore。BLOCK_DETACHED / BLOCK_RETIRE_WAITING 用于这种情况。

读端超时保证返回后接收缓冲区不再被访问;写端超时不撤销已经接纳的传输。

7. 清队列与硬件 abort​

ReadPort::ClearQueuedData() 只清已经排队的字节,有活动请求时返回 BUSY。它不取消挂起请求,也不停止 UART/DMA。

具体驱动如果提供重新配置、abort 或复位路径,需要自己保证:旧 DMA 已经停稳,旧 completion 不会再访问已经释放的外部缓冲,端口/等待器状态也已经完成交接。软件队列清空只是其中一个动作。

这与 timeout 的问题相同:关键在于返回之后还有谁可能继续修改旧状态。

8. 一个实用判断标准​

检查一个 BLOCK 驱动路径时确认以下几点:

  • waiter 是不是在硬件可能完成之前就挂好;
  • timeout 后,完成所有权有没有明确 detach 或安全取消;
  • 迟到完成会不会再次唤醒已经返回的调用;
  • 成功返回时 caller-visible buffer 是否已经更新;
  • timeout 返回后还有没有 DMA/ISR 可能访问 caller-owned storage;
  • 对写事务,调用者是否知道 timeout 后请求仍可能执行。