/* * 模块名称:采样环缓冲(全周傅氏相量计算的输入) * 模块功能:按语义通道保存最近 2 个周波(40 点)采样值,供 全周傅氏相量计算按窗取数。 * 适用平台:STM32F407ZGTx(纯算法,无寄存器/无 RTOS 依赖) * 作者:王建锋 * 创建日期:2026-10-10 * 修改记录: * v1.0 2026-10-10 王建锋 创建:全周傅氏相量输入环缓冲(20 点/周波 × 2 周波) */ #include "sample_ring.h" #include /* 采样环:[语义通道][点]。按语义通道索引(0..ADC_SEM_COUNT-1), * 故消费侧"按语义取数",不依赖任何物理通道号或转换器归属。 */ static float s_ring[ADC_SEM_COUNT][SAMPLE_RING_DEPTH]; /* 写指针:下一个待写入的点下标(0..SAMPLE_RING_DEPTH-1) */ static uint16_t s_head; /* 已累积点数(饱和于 SAMPLE_RING_DEPTH) */ static uint16_t s_filled; /* 累计入环帧数(不饱和,供心跳/丢帧观测) */ static uint32_t s_frames; /* * 函数功能:采样环缓冲初始化 * 入口参数:无 * 返 回 值:无 * 限定条件:无 * 函数说明:清空全部通道历史与计数;可重复调用(幂等)。 */ void sample_ring_init(void) { uint8_t c; uint16_t i; for (c = 0u; c < (uint8_t)ADC_SEM_COUNT; c++) { for (i = 0u; i < SAMPLE_RING_DEPTH; i++) { s_ring[c][i] = 0.0f; } } s_head = 0u; s_filled = 0u; s_frames = 0u; } /* * 函数功能:压入一拍(一帧)全部语义通道的采样值 * 入口参数:values - 按语义通道顺序排列的一帧采样值(长度须 = ADC_SEM_COUNT) * n - 实际长度(必须等于 ADC_SEM_COUNT) * 返 回 值:0 - 成功;-1 - 参数错 * 限定条件:同一帧内各通道须同刻(同族量同刻性由上游 语义取数与有效性负责) * 函数说明:整帧写入完毕后**统一**推进写指针——单帧内不存在"半新半旧", * 消费侧取到的窗永远落在整帧边界上(无帧内撕裂)。 */ int sample_ring_push_frame(const float *values, uint8_t n) { uint8_t c; if ((values == NULL) || (n != (uint8_t)ADC_SEM_COUNT)) { return -1; } for (c = 0u; c < (uint8_t)ADC_SEM_COUNT; c++) { s_ring[c][s_head] = values[c]; } s_head = (uint16_t)((s_head + 1u) % SAMPLE_RING_DEPTH); if (s_filled < SAMPLE_RING_DEPTH) { s_filled++; } s_frames++; return 0; } /* * 函数功能:取指定语义通道最近 n 点的窗(按时间序,旧 → 新) * 入口参数:ch - 语义通道(0 .. ADC_SEM_COUNT-1) * 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() 的傅氏求和方向一致,调用方不必再反转。 * 环内布局与窗首下标的推导见函数体内的分步注释。 */ 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)]; } return 0; } /* * 函数功能:读已累积点数(饱和于 SAMPLE_RING_DEPTH) * 入口参数:无 * 返 回 值:已累积点数(0..SAMPLE_RING_DEPTH) * 限定条件:无 * 函数说明:供上层判断"数据是否够一周波"。 */ uint16_t sample_ring_filled(void) { return s_filled; } /* * 函数功能:读累计入环帧数(不饱和) * 入口参数:无 * 返 回 值:累计入环帧数 * 限定条件:无 * 函数说明:供心跳日志 / 丢帧观测(采样有效性校验 判据之一)。 *(保护功能清单见 docs/算法/继电保护功能复现规划.md) */ uint32_t sample_ring_frames(void) { return s_frames; }