以空间换时间:乒乓缓冲、速率匹配和异步隔离

上一回那 64 个格子,真正替我们挡住的是"等"。线上每 86.8µs 送来一个字节,主循环什么时候转回来却说不准;多开那 64 个字节的内存,等于给主循环买下了一段可以迟到的余量。

用内存换时间这件事在嵌入式里出现得比想象中频繁,串口只是第一个撞上它的地方。采样和控制之间、通信和解析之间、显示和刷新之间,都在做同一笔交易:多占一点 RAM,换来"不必马上处理"。

这笔交易有几种做法,每种都假设了不同的事情。把它们摆在一起看,比急着多开几个数组有用。

缓冲买到的是时间,不是速度

缓冲不改变两边的速度。生产方还是那么快,消费方还是那么慢,它改变的只是什么时候做。

拿上一回那块仓库来算:64 个格子可用 63 个,每个字节在 115200 下占 86.8µs,63 × 86.8µs 大约是 5.5ms。主循环只要在 5.5ms 以内回来取一次,这段数据就一个都不会丢。

这 5.5ms 花了多少内存?63 个字节。F103C8T6 有 20 KB SRAM,它占千分之三。

便宜得几乎不要钱------前提是这段时间要还。缓冲区满的那一刻就是该还的时候:主循环迟到超过 5.5ms,或者电脑一口气灌进来 200 个字节,仓库就没地方放了。剩下的路只有几条:丢掉新到的、丢掉旧的、或者让程序退回去等空间空出来。

缓冲从不消除"处理不过来"这个事实。它只是把这件事从"每个字节都撞一次"变成"攒到一块再撞一次"。

流、块,和状态

程序看待数据有两种眼光。

串口送来的是流 :一个字节接一个字节,没有边界。How are you? 在线上就是 12 个各自独立的字节,谁也不知道第几个字符才算一句话说完。

程序真正要处理的却常常是块 :一整条命令、一整帧数据、一整屏内容。半条命令没有意义------LED O 既不是开灯也不是关灯。还有第三种,状态:当前速度是多少、最新的传感器读数是多少。它不是消息,而是"此刻的数值",拿三秒前那一个去做控制本身就是错的。

同一块内存,面对这三种东西,排法完全不同:

形状 数据的单位 谁写、谁读 买到什么
环形缓冲(队列) 字节流 中断写、主循环读 取走的时间可以晚一点
乒乓(两块交替) 整块 一块在写、另一块在用 整块的搬运和等待
只留最新(一格覆盖) 状态 谁新谁写、要用就读 连队列都不必留

上一回用的是第一种。后两种摆在这里看。

乒乓:两块,交替

"乒乓"这个名字来自两块缓冲轮流当班:

text 复制代码
          正在写        正在用
第 1 轮     块 A         块 B
第 2 轮     块 B         块 A

写的一方把块 A 填满,就把 A 交出去,自己接着填 B;用的一方处理完 B,B 再回来当"下一个要填的块"。

交接的动作只是换一个下标,一个字节都不用搬。 这是乒乓最值钱的地方:块越大,省下的搬运越可观。用双份内存,买下"不必复制数据"。

拿上一回的一整条命令当最小的块。拼行时写"当前这一块",一行拼满就交给解析,自己换到另一块继续:

c 复制代码
static char    line_buffer[2][COMMAND_BUFFER_SIZE];
static uint8_t write_index   = 0U;   /* 正在拼的那一块 */
static uint8_t ready_index   = 0U;   /* 拼好的那一块,等着被用 */
static uint8_t line_length[2] = {0U, 0U};
static uint8_t line_ready    = 0U;

写入位置由 line_length 记着,上一回那个"长度不许超过 31"的判断仍然管着它。一行拼完时做四件事:

c 复制代码
line_buffer[write_index][line_length[write_index]] = '\0';

ready_index = write_index;
line_ready  = 1U;                    /* 先让数据完整,再说"这一块好了" */

write_index = (uint8_t)(1U - write_index);
line_length[write_index] = 0U;

顺序不能颠倒。如果把 line_ready = 1U 挪到写 '\0' 前面,用的一方可能拿到一块还差结尾的字符串。"交块"这个动作必须是完整的:数据先准备好,最后才亮出"可以取了"的标志。

用的一方拿 ready_index 和 line_length[ready_index] 去解析,用完把 line_ready 清成 0,那一块就还回来当下一轮要填的块。

1U - write_index 只适用于两块:0 变 1,1 变 0。块数再多,就得用取余,或者干脆回到队列。

再想一步:两块也只够"一块的余量"。如果解析一条命令要花 30ms,而这 30ms 里又拼好了两条新命令,第二条就会盖掉第一条。还想留住更多数据,就别再叫它乒乓缓冲了,那已经是队列该干的事。

而且在一个纯软件、单一主循环的工程里,这两块经常换不来什么。拼行和解析都在主循环里,一条命令执行完才会轮到拼下一条;中间那段时间真正替我们挡住字节的,还是上一回那块 64 字节的环形缓冲区。一块队列能做的事,两块缓冲做不了更多。

乒乓要真正划算,得同时满足两条:数据本来就是按块来的,而且搬运本身贵到值得省。这时候它的搭档不是 CPU,而是替 CPU 搬数据的硬件:把"这一块的地址和长度"告诉它,它自己把整块搬走,搬运的同时 CPU 已经在填另一块。那套硬件叫 DMA。要两块内存交替,STM32F103 上得用普通(或循环)模式加传输完成中断重新武装通道,由软件换块------软件换出来的两块内存,和这里的两块是同一件事。

显示侧也常这么干:一块在往屏上送,另一块在组下一屏。道理一样,贵的是"整块的搬运"。

另一种形状:只留最新

有些数据不需要历史,只需要"现在是多少"。当前速度、最新的传感器读数、摇杆最新的位置------存十条旧值没有用,拿旧值去做控制本身就是错的。

这类数据可以有最简单的缓冲:一格,谁采到新的就盖上去。

c 复制代码
static volatile uint16_t speed_latest = 0U;   /* 谁有新值就覆盖 */

/* 20ms 控制周期到了,直接读 */
control_input = speed_latest;

连"有没有新值"的标志都可以不要。控制周期到了就读,读到的是此刻最新的那个数;至于它是 3ms 前采的还是 18ms 前采的,控制器并不关心。省下的不是几个字节,而是整个队列和整套"取走"的规则。

队列在实时任务里帮的是倒忙

有一类任务的目标不是"一条都不漏",而是"跟上现在":让一个机构一直对准另一个正在移动的东西。

这类任务里,队列不是保险,而是负担。队列保证每条数据都被处理,代价是排在后面的数据要等前面的处理完:积压 20 条、每条处理要 1ms,控制器拿到的位置就比现实晚了 20ms。目标早就走到别处去了,控制器还在追它 20ms 前的位置,越追越偏;把处理速度提高一倍,也只是把这段延迟缩短一半,它不会消失。

缓冲买来的时间是借 来的:对"消息"是保险,对"状态"是负债,存下来的旧读数越多,延迟越大。所以要看清一件事------同一种做法,落在两种数据上会变成相反的东西:同样的队列,用来兜住串口字节是救命的,用来喂一个跟踪回路就成了纯粹的延迟。这类任务宁可把旧值直接扔掉,只要最新那一个。

有两件事不能这么贪省:

  • 要看趋势、要做平均:滑动平均、滤波、画曲线都需要一串历史,一格覆盖会把它们全部毁掉;
  • 指令不是状态:遥控连按两下"前进",如果只留最新,程序只会走一步,第二条把第一条顶掉了。指令的价值不随时间衰减,它必须一条一条被执行,那就得排队。

判据很短:旧值现在还有用吗?还有用,它是消息,得排队;已经没用了,它是状态,一格就够。

速率匹配:缓冲管什么,不管什么

缓冲吸收的是瞬时的差值:一阵突发、主循环多花的一圈时间、刷一次屏的抖动。

它管不了长期平均的差值。生产方平均每毫秒来 11.5 个字节,消费方平均每毫秒只能处理 5 个,那么无论缓冲多大,它迟早会满。64 格撑 5.5ms,640 格撑 55ms,6400 格撑半秒------差别只是你还有多久才看到问题。

这也是"加个更大的缓冲区"经常只是把失败推迟的原因。缓冲区长期贴着上限,该动的是消费方:把主循环里耗时的那一段拆开,而不是把数组加长。

两个方向都要看清楚:

  • 生产快、消费慢:缓冲防的是"还没来得及处理就被丢掉",满的时候必须决定丢谁;
  • 生产快、消费慢,而数据是状态:这时候要做的不是加缓冲,而是把旧的直接扔掉。积压本身就是误差------攒下来的每一条旧读数,都会变成控制回路里的一段延迟;
  • 生产慢、消费快:缓冲让消费方"取不到就先干别的",不必守在那儿空转。输入输出大部分时间没有动静,程序不该陪着它们等。

满了以后丢谁,有两种顺手的选择,代价不一样:

做法 适合的场景 代价
丢掉新到的,保留还没读走的 已经收了一半的东西不能被悄悄改写 最新的数据先牺牲,必须有计数,否则谁也不知道
退回去等,直到有空位 一个字节都不能少,例如往片内写数据 缓冲买来的时间当场还回去,主循环又被拖住

两种都不算错。错的是第三种:满了、丢了,而且没有任何地方能看出来。 上一回屏幕上那两个数字,就是把这件事从"偶尔抽风"变回"有个数字在涨"。

异步隔离:两边只通过缓冲打交道

有了缓冲,两边就不再直接调用对方。写的一方只管往里放,读的一方只管往外取,中间只靠缓冲和一个标志联系。这叫异步隔离。

隔离的好处是抖动不会立刻传过去。刷一整屏 OLED 要几十毫秒,这几十毫秒里串口该怎么收还怎么收------上一回那个 300ms 的空等就是被这条隔离挡下来的。

隔离的量就是缓冲的容量。额度用完,两边立刻重新耦合:要么丢数据,要么一边停下来等另一边。隔离不是取消依赖,而是把依赖从"每一个字节"变成"每一块"。

代价也要看清楚:多一份内存,多一层"什么时候取"的规则,出错时还不如直接调用那样一目了然。缓冲区里的数据是看不见的中间状态,它的健康全靠几个数字和几条判断维持。

四种形状摆在一起

形状 数据单位 它假设了什么 常见场合
不缓冲,直接等 无 两边速度差不多,谁都不会卡太久 点一颗灯、写一个引脚
环形缓冲 字节流 消费方的平均速度更快,且短时能等一等 串口接收、按键事件
乒乓 整块 两边都要整块,而且搬运本身很贵 DMA 搬整块 + 软件换块、显示刷新
只留最新 状态 旧的数值已经没有意义 传感器最新读数、控制量

选哪种,问四个问题就够了:

  1. 手里的东西是流、是块,还是状态?
  2. 少一个字节、少一块、晚一轮,要紧吗?
  3. 两边的平均速度谁更快?(长期跟不上,缓冲只能把失败推后)
  4. 真的要满的时候,丢谁最不疼?

最后橘猫说

缓冲不是"多开一块内存",它是一笔用空间换时间的交易:上一回那 63 个可用字节买下 5.5ms 的迟到余量,而缓冲区满的那一刻就是该还的时候。三种形状对应三种数据------字节流用环形缓冲,整块用乒乓,状态用一格覆盖。乒乓最值钱的地方是交接只换下标、不搬数据,代价是双份内存和"只有一块的余量",而且在纯软件的单主循环里它经常换不来什么,真正的舞台在搬运交给 DMA 之后。速率匹配要分开看"瞬时"和"长期":缓冲吸收突发,管不了平均,长期对不上,再大的数组也只是把失败推后;异步隔离的额度就是缓冲容量,额度用完,两边立刻重新耦合。最后那条判据最短:旧值现在还有用,它是消息,得排队;已经没用了,它是状态,一格就够。而这一条在实时任务上还会翻面------跟踪类的回路只认此刻,队列攒下的每一条旧数据都是延迟,你搭得越整齐,它偏得越远。

最后蜘蛛说

所以说了半天------你只想要最新那条消息,早说啊