docs(algo): 补 sample_ring_window 注释,详解窗首下标推导

- 补环内布局示意与 s_head 语义(下一待写位置,非最后写入位置),
  据此推出窗首 = s_head - n、窗尾 = s_head - 1。
- 重点解释 start 为何写成 (s_head + DEPTH - n) % DEPTH:
  加 DEPTH 不改变同余类;规避 16 位下溢后再取模不等价(65536 不是
  40 的倍数);规避有符号负取模(-17 % 40 == -17)。
- 补限定条件:仅 adcTask 上下文,写者与读者同任务串行,故不加锁。
- .h 只加一行指针指向 .c 的推导,避免同一事实两处详述。
- 纯注释改动:去注释后代码 token 与 HEAD 完全一致;@build 0 错 0 警。
This commit is contained in:
2026-10-29 17:06:28 +08:00
parent cdf88a7121
commit de512a0db8
2 changed files with 86 additions and 5 deletions
+83 -5
View File
@@ -73,28 +73,106 @@ int sample_ring_push_frame(const float *values, uint8_t n) {
/*
* 函数功能:取指定语义通道最近 n 点的窗(按时间序,旧 → 新)
* 入口参数:ch - 语义通道
* n - 取点数(1..SAMPLE_RING_DEPTH)
* out - 输出缓冲(长度须 >= n)
* 入口参数:ch - 语义通道(0 .. ADC_SEM_COUNT-1)
* n - 取点数(1 .. SAMPLE_RING_DEPTH)
* out - 输出缓冲(调用方保证长度 >= n,本层不校验容量)
* 返 回 值:0 - 成功;-1 - 参数错;-2 - 已累积点数不足 n
* 限定条件:无
* 函数说明:out[n-1] 为最新一点。最老点下标 = head - n(模 DEPTH)。
* 限定条件:**仅 adcTask 上下文**调用,且须在同拍 sample_ring_push_frame() 之后。
* 全仓唯一写者 = adcTask 的压环语句,读者(phasor_run / meas_tick)与之
* 同任务串行执行,写读之间不存在并发 —— 故本函数**不加锁、不用 volatile、
* 不关中断**。若将来把读者搬到别的任务,必须先在本层补双区/序号快照,
* 否则会读到"半新半旧"的窗(旧点已被覆盖、新点尚未写入)。
* 函数说明:语义 = "以当前最新一点为终点、向前回溯 n 点"的一周波窗。
* out[0] 是最老一点、out[n-1] 是最新一点(时间序 旧 → 新),
* 与 phasor_run() 的傅氏求和方向一致,调用方不必再反转。
* 环内布局与窗首下标的推导见函数体内的分步注释。
*/
int sample_ring_window(adc_semantic_t ch, uint8_t n, float *out) {
uint16_t start;
uint16_t i;
/* ── 环内布局(以 DEPTH = 40、n = 20、s_head = 30 为例,此时 start = 10)──
*
* 下标 0 .. 9 | 10 ........ 29 | 30 .. 39
* 内容 旧数据 | <-- 本窗 20 点 --> | 更旧(下一拍起被覆盖)
*
* 三个关键位置:start = 10(窗首/最老点)、s_head - 1 = 29(窗尾/最新点)、
* s_head = 30(下一拍写入处,也是环满时最先被覆盖的那一点)。
*
* ⚠ s_head 是**下一个待写入**的位置,不是最后写入的位置 —— 这一点是理解
* 下面 start 公式的全部关键:最后写入的一点 = s_head - 1,于是
* "以最新点为终点的 n 点窗" = 下标 [s_head-n, s_head-1],共 n 点;
* 窗首(最老点)就是 s_head - n。窗可能跨过环尾(start + i 越过
* DEPTH - 1 时回绕到 0),故取数必须逐点取模。 */
/* 参数校验(通道号):adc_semantic_t 是枚举,C 标准允许其底层类型为有符号整型;
* 若调用方传负值/越界值,直接写 ch >= ADC_SEM_COUNT 会因整数提升变成有符号比较,
* 负值能"骗过"上界判断 → 索引到 s_ring 之外。故先转 unsigned int 再比:
* 负值转成极大无符号数,必然被拦下。(同时覆盖 out == NULL 的空指针。) */
if ((out == NULL) || ((unsigned int)ch >= (unsigned int)ADC_SEM_COUNT)) {
return -1;
}
/* 参数校验(取点数):
* n == 0 → 空窗无物理意义,且此时 start 会等于 s_head,返回"最新 0 点",
* 容易被调用方当成一次成功取窗 → 显式拒收;
* n > DEPTH → 一个环装不下连续 n 点,逐点回绕会把同一点重复读出,
* 拼出来的窗不再是真实时序(会伪造出"周期性"假象)→ 显式拒收。 */
if ((n == 0u) || (n > SAMPLE_RING_DEPTH)) {
return -1;
}
/* 数据够不够:s_filled 饱和于 DEPTH(见 sample_ring_push_frame),故对
* n <= DEPTH 而言,本判据等价于"自 init 起累计入环帧数 >= n"。
* 返回 -2 而非 -1 是有意区分:-2 表示"再等几拍自然会好",调用方应跳过本拍,
* 不应把它当故障上报(首周波启动期必然出现)。 */
if (s_filled < (uint16_t)n) {
return -2;
}
/* ── 窗首(最老点)下标:start = (s_head - n) mod DEPTH 的安全写法 ──────
*
* 数学目标很直白:最老点 = 最新点(s_head-1) 往前退 n-1 步 = s_head - n,
* 因为环是循环的,结果要落在 [0, DEPTH - 1],所以取模。但 C 里**不能**直接
* 写成 (s_head - n) % DEPTH —— 有两个独立的坑,任一个都会让整窗错位:
*
* 坑 1(16 位无符号下溢后再取模,不等价):
* 若写成 (uint16_t)(s_head - n) % DEPTH,s_head < n 时减法先回绕成
* 65536 - (n - s_head)。而 (x mod 65536) mod 40 **不等于** x mod 40
* —— 65536 只含因子 2,不是 40(= 8 × 5)的整数倍,模运算不满足这种
* "分两步取模"的分解。反例:s_head = 3、n = 20,正确 start = 23,
* 而回绕后得 65519,65519 % 40 = 39 → 读到的不是窗首而是环尾附近,
* 整个窗错位(表面现象是相量相位乱跳,极易误判成"采样通道接反")。
*
* 坑 2(有符号负取模):
* 在 32 位 int 平台,两个 uint16_t 参与运算前先做整数提升 → s_head - n
* 得到 int 且可能为负;C99 规定 % 向零截断,于是 (-17) % 40 == -17
* (不是数学上的 23)。这个负值再强转 uint16_t 变成 65519,
* 拿去索引 s_ring[ch][65519] 就是**越界读**(可能读到相邻通道或别的全局量,
* 不报错、不 HardFault,数据看着"像那么回事")。
*
* 修法:**先加一个 DEPTH,再减 n,最后取模**。三件事同时被解决:
* ① 同余不变:DEPTH ≡ 0 (mod DEPTH),加它不改变结果所在的同余类,
* 数学值与 (s_head - n) mod DEPTH 完全一致,不是"凑数";
* ② 恒非负、不溢出:0 <= s_head <= DEPTH - 1、1 <= n <= DEPTH,
* 故 s_head + DEPTH - n ∈ [0, 2·DEPTH - 2] = [0, 78],恒非负且远小于 65535,
* 两种整数提升规则下都不会下溢/溢出,% 的结果就是唯一的 [0, DEPTH - 1] 解;
* ③ 顺带护住后面的 (start + i):start <= DEPTH - 1、i <= n-1,
* 加法最大 2·DEPTH - 2,同样不会在 16 位域回绕。
*
* 边界自检:n == DEPTH 时 start == s_head —— 环满时"下一拍将被覆盖的那一点"
* 恰好就是最老点,这是唯一自洽的解,不是特例。环尚未写满(s_filled < DEPTH)时
* 有 s_head == s_filled,故 start = s_filled - n >= 0,此时窗一定不跨环尾。
*
* 性能备注:SAMPLE_RING_DEPTH = 40 不是 2 的幂,**不能**用 & (DEPTH - 1) 代替 %;
* Cortex-M4 有硬件 UDIV/SDIV,编译器会生成一次除法指令,本函数每拍最多 n 次
* 取模(n <= 40),代价可接受。若将来把 DEPTH 改成 2 的幂,这里可换成位与,
* 但须同步核查全文件所有 % SAMPLE_RING_DEPTH 的使用点。 */
start = (uint16_t)((s_head + SAMPLE_RING_DEPTH - (uint16_t)n) % SAMPLE_RING_DEPTH);
/* 逐点顺序拷贝:从 start 起向前走 n 步,终点正好是 s_head-1(最新点)。
* 每步单独取模,是因为窗可能**跨过环尾**(start + i > DEPTH - 1 时回绕到 0);
* 环形缓冲在内存里首尾并不相连,故不能用一次 memcpy 拷完 —— 要么先判边界再
* 分两段拷,要么像这里逐点取模。DEPTH 只有 40,逐点拷贝比"两段 + 分支"更短更稳。
* 拷贝顺序即时间顺序:out[0] 最老、out[n-1] 最新。 */
for (i = 0u; i < (uint16_t)n; i++) {
out[i] = s_ring[ch][(uint16_t)((start + i) % SAMPLE_RING_DEPTH)];
}