diff --git a/App/algo/sample_ring.c b/App/algo/sample_ring.c index 02455d7b..ec52be45 100644 --- a/App/algo/sample_ring.c +++ b/App/algo/sample_ring.c @@ -77,102 +77,37 @@ int sample_ring_push_frame(const float *values, uint8_t n) { * n - 取点数(1 .. SAMPLE_RING_DEPTH) * out - 输出缓冲(调用方保证长度 >= n,本层不校验容量) * 返 回 值:0 - 成功;-1 - 参数错;-2 - 已累积点数不足 n - * 限定条件:**仅 adcTask 上下文**调用,且须在同拍 sample_ring_push_frame() 之后。 - * 全仓唯一写者 = adcTask 的压环语句,读者(phasor_run / meas_tick)与之 - * 同任务串行执行,写读之间不存在并发 —— 故本函数**不加锁、不用 volatile、 - * 不关中断**。若将来把读者搬到别的任务,必须先在本层补双区/序号快照, - * 否则会读到"半新半旧"的窗(旧点已被覆盖、新点尚未写入)。 - * 函数说明:语义 = "以当前最新一点为终点、向前回溯 n 点"的一周波窗。 - * out[0] 是最老一点、out[n-1] 是最新一点(时间序 旧 → 新), - * 与 phasor_run() 的傅氏求和方向一致,调用方不必再反转。 - * 环内布局与窗首下标的推导见函数体内的分步注释。 + * 限定条件:仅 adcTask 上下文(写者与读者同任务串行,故不加锁、不用 volatile); + * 须在同拍 sample_ring_push_frame() 之后调用。 + * 函数说明:以最新一点为终点、向前回溯 n 点;out[0] 最老、out[n-1] 最新, + * 与 phasor_run() 的求和方向一致。 + * 为什么需要这一层、环内布局与窗首下标的推导 → + * docs/算法/采集与算法层设计说明.md §5(本文件只留落地要点)。 */ 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 的空指针。) */ + /* 通道号先转 unsigned 再比:枚举底层可为有符号,负值会骗过 ch >= COUNT 的判据。 */ if ((out == NULL) || ((unsigned int)ch >= (unsigned int)ADC_SEM_COUNT)) { return -1; } - /* 参数校验(取点数): - * n == 0 → 空窗无物理意义,且此时 start 会等于 s_head,返回"最新 0 点", - * 容易被调用方当成一次成功取窗 → 显式拒收; - * n > DEPTH → 一个环装不下连续 n 点,逐点回绕会把同一点重复读出, - * 拼出来的窗不再是真实时序(会伪造出"周期性"假象)→ 显式拒收。 */ + /* n = 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 表示"再等几拍自然会好",调用方应跳过本拍, - * 不应把它当故障上报(首周波启动期必然出现)。 */ + /* -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 的使用点。 */ + /* 窗首 = s_head - n(s_head 是"下一待写"位置,故窗尾 = s_head - 1)。 + * 必须先加 DEPTH 再减:直接减在 s_head < n 时下溢或得负数,而 + * (x mod 65536) mod 40 不等于 x mod 40、C99 的负数取模也不是同余解。 + * 加 DEPTH 不改同余类,并把中间值锁在 [0, 2·DEPTH - 2]。详见设计说明 §5.3。 */ 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] 最新。 */ + /* 逐点取模拷贝:窗可能跨环尾,环在内存里首尾不相连,不能用一次 memcpy。 */ for (i = 0u; i < (uint16_t)n; i++) { out[i] = s_ring[ch][(uint16_t)((start + i) % SAMPLE_RING_DEPTH)]; } diff --git a/App/algo/sample_ring.h b/App/algo/sample_ring.h index df84572c..8513d9db 100644 --- a/App/algo/sample_ring.h +++ b/App/algo/sample_ring.h @@ -60,8 +60,8 @@ int sample_ring_push_frame(const float *values, uint8_t n); * 限定条件:无 * 函数说明:out[n-1] 为最新一点。相量层以本函数取"以当前时刻为终止的一周波"。 * ⚠ 仅 adcTask 上下文调用(写者与读者同任务串行,本层不加锁); - * 环内布局、窗首下标推导与"先加 DEPTH 再减 n"的理由见 - * `App/algo/sample_ring.c` 同名函数的注释。 + * 为什么需要这一层、环内布局与窗首下标的推导 → + * `docs/算法/采集与算法层设计说明.md` §5。 */ int sample_ring_window(adc_semantic_t ch, uint8_t n, float *out);