✳相遇
导航

采样的流程

6158 字
31 分钟
采样的流程

5ms 的保护任务,会不会把 78us 的采样打断?#

拆解一台真实 FTU(馈线终端)装置的 AD7616 采样链路:12.8 kS/s、一周波 256 点、任务随时被抢占,却一个点都不丢。

关键词:嵌入式、ADC、DMA、输入捕获、环形缓冲、RTOS 优先级、保护算法、工程实践

适合谁:会用 HAL 点灯、会配定时器和 SPI,但没做过”高频采样 + 实时算法”这类真实项目的同学。


0. 引子:一个把我问住的问题#

上周同事问我:

保护算法任务每 5 毫秒跑一次,一次要算十几路信号的傅里叶变换,跑的时候采样会不会断?

我说:不会。

他又问:那采样是在哪个任务里跑?

我说:不在任何任务里。

他愣了一下。这个”不在任何任务里”,就是这篇文章想讲清楚的事——一块 AD7616,12.8 kHz 采样率,每 78.12 微秒出一个点,跑在一个任务随时会被抢占、随时会被更高优先级打断的 RTOS 上,一个点都不丢,是怎么做到的。

先给结论,后面逐层展开:

定时器 PWM 产生采样节拍 → ADC 转换
↓(BUSY 下降沿)
输入捕获中断启动 SPI+DMA → DMA 完成中断写环形缓冲
↓
高优先级任务"排空"缓冲 → 低优先级任务做算法(增量更新)

采样节拍和搬运动作全部由硬件 + 中断完成,任务只做”慢活”。 所以任务算多久,都影响不到采样。


1. 小白的实现:while(1) + delay(78us)#

大多数人第一次写采样都是这样的:

while (1) {
trigger_convert(); // 拉一下 CVT
while (BUSY == 1) { } // 忙等转换结束
spi_read(buf); // 读回 16 个通道
buf_index++;
delay_us(78); // 凑够采样周期
}

在”点亮一颗 LED”的层面上它是对的,但在工程上有三个致命伤:

坑 1:抖动#

delay_us(78) 只是”至少等 78us”。只要被更高优先级任务、中断、RTOS 临界区打断一次,这一拍就变成 100us、500us,甚至 2ms。

对保护算法来说,抖动不是”精度差一点”这么简单——傅里叶变换要求”等间隔采样”,采样间隔一乱,算出来的幅值和相位都错,相位错了还会把”正方向故障”判成”反方向”。

坑 2:CPU 被忙等吃干#

while (BUSY == 1) { } 是纯烧 CPU。采样率越高,烧得越狠。

坑 3:节拍无法跟踪电网频率#

电网频率在 49.5~50.5 Hz 之间漂。采样周期写死 78us,一周波就不是整数个点,“整周波傅氏”的前提直接崩了。

所以第一件事:把”节拍”从软件里扔出去,交给硬件。


2. 第一层:节拍交给定时器硬件#

真实代码里,采样节拍是定时器的一个 PWM 输出,直接接到 ADC 的 CVT(转换启动)引脚上。

/* bsp_spi_ad7616.c: TIMER31_CH3_PWM_Init() —— 简化版 */
timer_initpara.prescaler = 12 - 1; /* 300MHz / 12 = 25MHz,即 40ns 一个 tick */
timer_initpara.period = 1953 - 1; /* 数 1953 个 tick 输出一个脉冲 */
timer_init(TIMER31, &timer_initpara);
timer_channel_output_mode_config(TIMER31, TIMER_CH_3, TIMER_OC_MODE_PWM0);
timer_primary_output_config(TIMER31, ENABLE);

2.1 78.12us 是怎么算出来的#

定时器时钟 300 MHz
÷ 预分频 12 → 25 MHz(tick = 40 ns)
× 计数值 1953 → 78.12 us

也就是 12.8 kS/s。

这个节拍由定时器硬件产生,CPU 死了它还在跳。任务被抢占、进临界区、甚至跑飞,都不影响这个引脚按时翻转。

2.2 为什么是 256 点一周波(采样率由算法倒推)#

这是新手最容易忽略的一点:采样率不是”越高越好”,也不是”随便定”,而是被算法倒推出来的。

/* YC.H:一周波采样点数 = 傅氏变换的窗口长度 */
#define SAMPLES_PER_CYCLE_256 256 /* 一周波 256 个原始点 */
#define AD7616_PWM_CNT_256 1953 /* 对应 78.12us 的定时器周期 */
#define SAMPLES_PER_CYCLE SAMPLES_PER_CYCLE_256
#define AD7616_PWM_CNT AD7616_PWM_CNT_256
#define JD_WAVE_POINTS SAMPLES_PER_CYCLE /* 就是一个整数周波 */
#define WAVE_POINTS 32 /* DFT 只算 32 点 */
#define SAMPLE_INTERVAL_32POINT (JD_WAVE_POINTS / WAVE_POINTS) /* = 8,抽 8 取 1 */

算一下:256 × 78.12us = 20ms,正好是 50Hz 的一个周波。

为什么要”整周波”? 因为全周波傅氏变换才能消除直流分量和非整数次谐波的影响。窗口如果不是整数个周波,算出来的基波幅值会随相位周期性波动(频谱泄漏),保护装置就会”忽大忽小”地误动。

为什么原始 256 点,却只算 32 点? 抽 8 取 1 后仍然是均匀的整周波采样(32 点/周波),满足 DFT 要求,但计算量只剩 1/8。这是”精度够用就行”的典型工程取舍。

2.3 频率跟踪:让”周波”永远对齐#

电网频率一变,78.12us 就不是”整周波”了。所以要有跟踪:

/* YC.C: SampleFreqFollow_zt() —— 简化版 */
temp = 5000 * AD7616_PWM_CNT / freq_now - 1; /* 按实测频率重算周期 */
wavePeriod = temp;
if (abs(wavePeriod - timer_autoreload_value_read(TIMER31)) > 1)
FreqTIMAutoreload = wavePeriod; /* 请求改周期 */

改周期这件事放在捕获中断里执行(bsp_spi_ad7616.c 的 TIMER7_Channel_IRQHandler):

if (FreqTIMAutoreload > 0) {
timer_autoreload_value_config(TIMER31, FreqTIMAutoreload);
FreqTIMAutoreload = 0;
}

代码里还有一堆范围判断(1756 ~ 2198、2815 ~ 3515),那是防止频率测量异常时把采样周期改飞。真实工程里,“防呆”的代码往往比功能代码还长。


3. 第二层:搬运交给 DMA#

节拍解决了,接下来是”把数据搬进来”。

一次转换的结果不是一个数,而是 35 个 16 位字(16 通道 + 状态字 + CRC + 电源监视)。12.8 kS/s × 35 字 ≈ 448 KB/s,听起来不大,但中断频率才是杀手:如果每个字都中断一次,就是 448k 次中断/秒,CPU 什么也别干了。

所以 SPI 的收发两端都挂 DMA:

/* bsp_spi_ad7616.c: Bsp_Ad7616_Init() */
nvic_irq_enable(SPI_AD7616_DMA_TX_IRQn, 0, 0); /* 发送 DMA:优先级 0 */
nvic_irq_enable(SPI_AD7616_DMA_RX_IRQn, 0, 0); /* 接收 DMA:优先级 0 */

一句经验:高频、定长、不需要 CPU 判断的搬运,一律交给 DMA。 CPU 只在”搬完”这个边界上被打扰一次。


4. 第三层:什么时候搬——BUSY 下降沿 + 输入捕获中断#

ADC 转换完成会有个 BUSY 引脚拉低。我们用定时器的输入捕获功能盯住这个下降沿:这比外部中断更准(能同时读到”这一拍用了多少个定时器计数”,顺便做转换时间自检)。

/* bsp_spi_ad7616.c: TIMER7_CH2_InputCapture_Init() 关键三行 */
timer_icinitpara.icpolarity = TIMER_IC_POLARITY_FALLING; /* 下降沿 */
timer_icinitpara.icfilter = 0x0F; /* 硬件滤波,抗干扰 */
timer_input_capture_config(TIMER7, TIMER_CH_2, &timer_icinitpara);
nvic_irq_enable(TIMER7_Channel_IRQn, 1, 0); /* 优先级 1 */

捕获中断里做三件事(注意,没有一件是”运算”):

/* bsp_spi_ad7616.c: TIMER7_Channel_IRQHandler() —— 简化版 */
CvtTIM3Cnt = timer_counter_read(TIMER31);
if (CvtTIM3Cnt < 300) { /* 转换时间太短 = 数据不可信 */
CvtTIM3CntErrNum++;
} else {
ad7616_err_cnt++;
AD7616_CVT_CpltCallback(); /* 启动一次 SPI + DMA 读 */
}
/* 启动读:拉 CS,挂上 DMA,立刻返回 */
void AD7616_CVT_CpltCallback(void)
{
AD7616_SPI_LOCK(ADSpi_Lock); /* 防重入:上一次还没读完就跳过本次 */
SPI_AD7616_CS_LOW;
AD7616_SPI_RxTxDMA(SpiDummyBuff, ad7616_temp, AD_SEQNUM);
}

DMA 读完,进 DMA 中断,把 35 个字”解包”成 16 通道,写进环形缓冲:

/* bsp_spi_ad7616.c: AD7616_OriginValueDeal() —— 简化版 */
SCB_CleanInvalidateDCache_by_Addr((uint32_t *)ad7616_temp, AD_SEQNUM * 2);
for (i = 0; i < AD_CHANNEL_NUM - 1; i++) {
AD_Value = (ad7616_temp[ch_p] + ad7616_temp[ch_n]) >> 1; /* 正负端差分 */
struADRawBuf.s16ADChanBuf[i][struADRawBuf.rear] = AD_Value;
}
/* 状态字:CRC + 电源电压检查,0 = 好帧,1 = 坏帧 */
struADRawBuf.s16ADChanBuf[16][struADRawBuf.rear] = crc_ok ? 0 : 1;
struADRawBuf.u16Telesingal[0][struADRawBuf.rear] = u32YxTmpState & 0xFFFF;
struADRawBuf.rear = (struADRawBuf.rear + 1) % AD_SEQBUFMAX; /* ← 生产者指针 */

4.1 完整时序(一次采样事务)#

CVT 上升沿(每 78.12us) 转换 17us BUSY 下降沿 启动 SPI 读 35 字 FTF 传输完成 解包 16 通道 + 状态字,rear++ 生产者只做"放货",不通知任务 TIMER31 (PWM) AD7616 TIMER7 捕获中断(优先级1) SPI+DMA DMARX中断(优先级0) struADRawBuf
CVT 上升沿(每 78.12us) 转换 17us BUSY 下降沿 启动 SPI 读 35 字 FTF 传输完成 解包 16 通道 + 状态字,rear++ 生产者只做"放货",不通知任务 TIMER31 (PWM) AD7616 TIMER7 捕获中断(优先级1) SPI+DMA DMARX中断(优先级0) struADRawBuf

4.2 关键点:中断优先级决定了”任务永远打不断采样”#

这是全文最重要的一处细节。看 FreeRTOS 的配置:

FreeRTOSConfig.h
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 0x02

含义:数字优先级 < 2 的中断,不受 FreeRTOS 临界区(taskENTER_CRITICAL())屏蔽,它们永远能抢占任何任务,包括正在跑临界区的任务。

而采样链路用的正是 0 和 1:

中断优先级作用
DMA0_CH1 (SPI RX)0传输完成,写环形缓冲
TIMER7_CH2(BUSY 捕获)1启动 SPI+DMA 读
TIMER2(1ms 系统节拍)2发事件给任务(要调 FromISR API)
FreeRTOS 任务—DataSamp 26 / FA 25

结论:采样链路的延迟有一个硬上限,跟任务调度完全无关。 这就是”5ms 任务算多久都不会丢点”的物理基础。

反过来,如果谁把 ADC 的 DMA 中断优先级设成 5,那它就会被临界区挡住,采样就会出现随机延迟——这类 bug 极难查。


5. 第四层:数据放哪——环形缓冲(单写单读,天然免锁)#

中断里拿到数据后,不能直接扔给任务(一帧 35 个字、12.8kHz,队列开销比数据还大),标准做法是共享内存 + 环形缓冲。

结构非常简单,就是两个下标:

typedef struct {
int16_t s16ADChanBuf[17][512]; /* 17 通道 × 512 帧 */
uint16_t u16Telesingal[4][512]; /* 遥信状态 */
uint16_t front; /* 消费者读指针(任务写) */
uint16_t rear; /* 生产者写指针(中断写) */
} struADRAW_t;
struADRAW_t struADRawBuf @"SRAM"; /* 放在内部 AXI SRAM,见第 6 节 */

5.1 单生产者 + 单消费者 = 免锁#

rear(中断只在这里写)
↓
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ ← 环形,512 格
└───┴───┴───┴───┴───┴───┴───┴───┘
↑
front(任务只在这里写)
已消费区 │ 待消费区(中断已经写好的,任务随便读)

三个前提保证它不需要任何锁:

  1. 只有一个写者(DMA 完成中断),只有一个读者(DataSamp 任务);

  2. 读写的是对齐的 16 位下标,Cortex-M7 上单字访问天然原子;

  3. 任务永远不碰 rear 正在写的那一帧,只读 front ~ rear 之间”已完成”的区间。

用队列的话,12.8kHz × 每次拷贝 35 个字,光是入队出队的内存拷贝和链表操作就能吃掉大量 CPU。共享内存 + 环形指针是这个场景的标准答案。

5.2 缓冲要做多深?——一个可直接套用的公式#

最小深度 ≥ 最长可能阻塞时间 × 采样率

工程里留了多少余量?

#define SAMPLE_WAVE_NUM JD_WAVE_POINTS /* 256 */
#define AD_SEQBUFMAX (SAMPLE_WAVE_NUM*2) /* 512 帧 */

512 ÷ 12800 = 40 ms。

也就是说:只要 DataSamp 任务能在 40ms 内被调度到一次,数据就不会丢。 它实际是每 2ms 被唤醒一次,余量 20 倍。

反过来说,如果某次任务被卡住超过 40ms,rear 就会追上 front,数据被覆盖——这时程序不会崩,但数据会”跳变”。后面第 8 节会讲,工程上是怎么发现并自愈的。


6. 第五层:两个任务的分工,和它们的优先级#

系统里跟采样有关的任务只有两个(都在 system.cpp 里注册):

system.h
#define DATASAMPID 80
#define DATASAMPPRIORITY 26 /* 数据搬运任务 */
#define FAID 96
#define FAPRIORITY 25 /* 保护算法任务 */

6.1 DataSamp(优先级 26,2ms 一次):只负责”解包”#

/* DataSamp.c —— 简化版 */
AD7616_START_WORKING(); /* 启动 TIMER31,采样硬件开始自己跑,只需这一次 */
while (1) {
evReceive(DataSamp_thid, EV_TIMER1 | EV_TIMER4 | EV_MSG, &evFlag);
if ((evFlag & EV_TIMER1) && (app_update_flag == 0)) {
/* 自愈检查:rear 连续 5 次不动 → 采样死了,复位重来 */
if (cur_wp_old == struADRawBuf.rear) cur_wp_ErrNum++;
if (cur_wp_ErrNum >= 5) { AD7616SPI_Reset(); }
AD_HighDynamicCalibrate(); /* 排空环形缓冲 */
}
}

AD_HighDynamicCalibrate() 干的事:把 front ~ rear 之间所有帧”排空”,每帧 16 个通道写进 gAdBuf,顺带做极性处理、坏帧顶替(第 8 节),并按 8<1> 抽点写进 pro_three_buf:

while (struADRawBuf.front != cur_wp) { /* cur_wp 是本次开始时的 rear 快照 */
for (i = 0; i < AD_CHANNEL_NUM - 1; i++) {
adValue = struADRawBuf.s16ADChanBuf[i][struADRawBuf.front];
if (AD_ErrCunt) /* 坏帧用上一周波同相位点顶替 */
adValue = gAdBuf[gAdSwNo].value[i][delwp];
gAdBuf[gAdSwNo].value[i][gAdBuf[gAdSwNo].wp] = adValue;
if (pro_three_temp_num == 0) /* 抽 8 取 1 */
pro_three_buf[i][pro_three_num] = gAdBuf[gAdSwNo].value[i][gAdBuf[gAdSwNo].wp];
}
struADRawBuf.front = (struADRawBuf.front + 1) & (AD_SEQBUFMAX - 1);
gAdBuf[gAdSwNo].wp = (gAdBuf[gAdSwNo].wp + 1) & (AD_BUF_SIZE - 1);
}

注意这里又出现了一级生产/消费:pro_three_buf 是”32 点/周波”的抽点缓冲,64 格 = 2 周波,pro_three_num 是它的写指针。下一节会看到,保护任务就是靠这个写指针来对齐数据的。

6.2 fa(优先级 25,5ms 一次):只负责”算”#

/* FANEW.CPP: fa() —— 简化版 */
for (;;) {
evReceive(thid, EV_TIMER1 | EV_TIMER2 | EV_MSG, &evFlag);
if (evFlag & EV_TIMER1) { /* 1ms 节拍里 facount>=5 才发,所以是 5ms */
ProtYcCal(); /* 保护测量值计算 */
/* ... 保护判据 ... */
}
}

6.3 为什么”生产者”的任务优先级必须高于”消费者”?#

这是这张图的核心:

优先级 26 ─ DataSamp ── 每 2ms 抢占 ──► 把环形缓冲排空(≈25 帧)
│
优先级 25 ─ fa ─────── 算 DFT,随时被打断 ◄─┘

如果反过来(DataSamp 优先级更低)会怎样?

  • fa 正在算 3ms,这 3ms 里 DMA 中断写了 38 帧数据;

  • DataSamp 只能等 fa 算完才能排空,环形缓冲水位一路上涨;

  • 一旦 fa 某次算超时(比如参数变更后计算量突增),缓冲就溢出,数据跳变,保护可能误动。

优先级排序的正确依据不是”谁重要”,而是”谁晚一点就出事”。 采样缓冲会溢出,算法晚 5ms 只是保护动作慢 5ms——当然前者更急。

顺带说一个”不该有的现象”:fa 里收到 5ms 事件时,如果上一轮还没算完,事件标志会被 OR 合并(pEV->evFlag |= event),所以 FA 少算几次不会排队堆积,这是事件机制天然带来的”降级”。代码里还专门加了计数器兜底:

/* bsp_timer.c:1ms 中断里,每发一次 FA 事件就 +1 */
SpyOnFaTsak++;
if (SpyOnFaTsak > 1000) SpyOnFaTsak = 1000;
/* FANEW.CPP:FA 真正开始算的时候检查 */
if (SpyOnFaTsak > 1) CalcuProtMeasIni(); /* 说明被拖住了,测量累加器重新初始化 */
SpyOnFaTsak = 0;

6.4 内存放哪:一个容易被忽略的性能细节#

/* bsp_spi_ad7616.c —— 原样摘录 */
#pragma location=0x24070000
__attribute__((aligned(32))) uint16_t SpiDummyBuff[36] = {0};
#pragma location=0x24070100
__attribute__((aligned(32))) int16_t ad7616_temp[36];
struADRAW_t struADRawBuf @"SRAM"; /* 链接脚本把它放在内部 AXI SRAM */
  • DMA 目标缓冲放在内部 SRAM,并且 32 字节对齐(Cache Line 对齐);

  • 大缓冲(gAdBuf,一周波 256 点、深度 8192 点 ≈ 640ms 数据)放在外部 SDRAM(链接脚本里 RAM_region 起始 0xC0100000)。

Cortex-M7 有 D-Cache,DMA 直接写内存而 CPU 不知道,所以每次读 DMA 缓冲前必须手工失效 Cache:

SCB_CleanInvalidateDCache_by_Addr((uint32_t *)ad7616_temp, AD_SEQNUM * 2);

这是 M7 上做 DMA 最经典的坑:不写这一句,偶尔会读到上一次的旧数据,而且现象是”偶尔错一次”,极难复现。看到 aligned(32) + CleanInvalidateDCache 这两样东西同时出现,基本可以确定作者踩过这个坑。


7. 第六层:算的时候怎么”对齐”数据——用序号,不要用时间#

这里是新手和老手最大的分水岭。

7.1 错误的写法#

/* 错误示范:假设"我 5ms 跑一次,就有 5ms 的新数据" */
for (i = 0; i < 5ms对应的点数; i++) { ... }

错在哪?任务被抢占时,这 5ms 里可能来了 3ms 的数据,也可能来了 8ms 的数据。用时间假设数据量,相位就错位了。

7.2 正确写法:让生产者告诉你”新增了几个点”#

生产者(DataSamp)在写抽点缓冲时会维护一个序号 pro_three_num(0~63 循环,64 = 2 周波)。消费者(FA)只要快照这个序号,就能算出”这次新来了几个点”:

/* YC.C: ProtYcthree() —— 精简后的核心逻辑 */
AD_Position[AD_Position_Num] = pro_three_num; /* ① 快照生产者的写指针 */
if (AD_Position_First == 0) { /* 首次运行:直接算满一个周波 */
g_ucADNewlyCount = WAVE_POINTS;
g_ucADNewlyPosition = (AD_PROTBUFFSIZE + AD_Position[0] - WAVE_POINTS) & (AD_PROTBUFFSIZE - 1);
} else { /* ② 两次快照相减 = 新增点数 */
g_ucADNewlyCount = (AD_PROTBUFFSIZE + AD_Position[新] - AD_Position[旧]) & (AD_PROTBUFFSIZE - 1);
g_ucADNewlyPosition = (AD_PROTBUFFSIZE + AD_Position[新] - 1) & (AD_PROTBUFFSIZE - 1);
}
if (g_ucADNewlyCount > WAVE_POINTS) { /* ③ 落后超过一个周波:重对齐 */
AD_Position_First = 0;
AD_Position_Num = 0;
goto AD_PositionIni;
}

得到”新增 N 个点”之后,算法做的是增量 DFT:加进新点,减掉一周波之前对应的旧点。

/* YC.C: CalcuProtMeas() —— 滑动 DFT 的核心 3 行 */
m = pro_three_buf[chan][AddIndex]; /* 新进来的点 */
n = pro_three_buf[chan][RemoveIndex]; /* 一周波之前的点 */
g_gProcMeas_AC_R[chan] += gaDftFactorR_SMALL[TableIndex] * (m - n); /* 实部增量 */
g_gProcMeas_AC_I[chan] += gaDftFactorI_SMALL[TableIndex] * (m - n); /* 虚部增量 */
AddIndex = (AddIndex + 1) & (AD_PROTBUFFSIZE - 1);
RemoveIndex = (AddIndex - WAVE_POINTS) & (AD_PROTBUFFSIZE - 1);
TableIndex = AddIndex & (WAVE_POINTS - 1);

7.3 为什么这个设计很漂亮#

特性说明
不依赖时间只依赖”生产者写到哪了”,任务被抢占多久都不会错位
计算量最小每次只处理新增的点(5ms ≈ 8 个抽点),不是每次都重算 32 点
天然防竞态消费者只读”已经写好”的区域,环形 64 格覆盖 2 周波,写者追不上读者
可自愈落后超过一个周波就 goto 重新对齐,不会带着错位数据一直算下去

7.4 另一种更简单的写法(同一个工程里也有)#

/* YC.C: ProtYcCal() 的另一个版本 —— 每次重取"最近一个整周波"再算 */
cur_rp = (AD_BUF_SIZE + gAdBuf[cur_sw].wp - JD_WAVE_POINTS) & (AD_BUF_SIZE - 1);
for (j = 0; j < JD_WAVE_POINTS; j++) {
if (j % SAMPLE_INTERVAL_32POINT == 0) /* 256 点抽成 32 点 */
pro_final_buf[i][rp++] = gAdBuf[cur_sw].value[i][cur_rp];
cur_rp = (cur_rp + 1) & (AD_BUF_SIZE - 1);
}

这就是”滑动窗口 + 全窗重算”:每次取 写指针 - 256 到 写指针 这一段,抽点后做一次完整 DFT。

| | 增量 DFT | 全窗重算 | | --- | --- | --- | | CPU 开销 | 小(只算新点) | 大(每次 32×通道数) | | 状态 | 多(累加器、序号、对齐标志) | 少(无状态) | | 容错 | 需要专门的”落后重对齐”逻辑 | 天然自愈,每次都是新窗口 | | 适合 | 通道多、采样率高 | 通道少、算法要频繁改 |

两种写法都出现在同一个工程的不同配置里——这就是真实项目:同一件事往往有好几套实现,靠宏切换,因为不同型号、不同年份的需求不一样。


8. 真实工程的分界线:兜底#

前面所有内容,教科书都能讲。真正让装置在现场活十年的,是下面这些”丑”代码。

现场问题工程做法位置
ADC 数据 CRC 错 / 电源异常用上一周波同相位点顶替,窗口不断档DataSamp.c 坏帧顶替
采样彻底停摆rear 连续 5 次不前进 → 复位并重新初始化 ADDataSamp.c 自愈检查
SPI 通信连续出错错误计数 > 20 → AD7616SPI_Reset()DataSamp.c
AD 正在复位if (AD7616ResetCount > 0) return 0; 保护直接用上次结果YC.C ProtYcCal
FA 被拖住SpyOnFaTsak > 1 → 测量累加器重新初始化FANEW.CPP
事件来不及处理`evFlag= event` 天然合并,不会堆积

8.1 坏帧顶替:宁可”借”上一周波,也不能留窟窿#

/* DataSamp.c: AD_HighDynamicCalibrate() */
if (struADRawBuf.s16ADChanBuf[16][struADRawBuf.front] != 0x00) { /* 状态字非 0 = 坏帧 */
AD_ErrCunt++;
delwp = (gAdBuf[gAdSwNo].wp + AD_BUF_SIZE - JD_WAVE_POINTS) & (AD_BUF_SIZE - 1);
} else {
if (AD_ErrCunt < 20) AD_ErrCunt = 0;
}
/* 出错期间,每个新点都用"一周波之前的同相位点"替代 */
if (AD_ErrCunt) adValue = gAdBuf[gAdSwNo].value[i][delwp];

注意 delwp 是跟着 wp 一起前进的,所以顶替的是相位相同的点。这样 DFT 窗口永远是连续的整周波,只是某几个点”重复了一次历史”——对幅值影响很小,但不会因为缺一个点导致相位跳变。

这是保护装置里非常经典的思想:宁可数据”不新”,也不能数据”不连续”。

8.2 采样停摆检测:用”心跳”判断生死#

DataSamp.c
if (cur_wp_old == struADRawBuf.rear) cur_wp_ErrNum++; /* 这一轮没新数据 */
if (cur_wp_ErrNum >= 5) { /* 连续 5 轮(约 10ms)不动 */
cur_wp_ErrNum = 0;
AD7616SPI_Reset();
cur_wp_old = 1234;
taskDelay(2);
}
cur_wp_old = struADRawBuf.rear;

为什么需要这个?因为 SPI 是外部器件,可能被干扰、可能上电时序不对、可能电源跌落——“没有新数据”和”数据是 0”在数值上是看不出区别的,只能靠”指针有没有动”来判断。

8.3 复位流程:先停采样,再重配,最后重启#

DataSamp.c
void AD7616SPI_Reset(void)
{
AD7616ResetCount = 12; /* ① 置标志:保护算法这期间不算,沿用上次结果 */
AD7616_STOP_WORKING(); /* ② 关掉 TIMER31,停止 CVT 脉冲 */
printf("AD SPI RESET %d,%d\r\n", AD_ErrCunt, ad7616_err_cnt);
taskDelay(2); /* ③ 等一下,让 ADC 彻底停下来 */
Bsp_Ad7616_Init(); /* ④ 重新初始化 SPI / AD7616 寄存器 */
AD7616ResetCount = 12;
CalcuProtMeasIni(); /* ⑤ 测量累加器清零,重新对齐 */
ad7616_Init_Cnt++;
AD7616_START_WORKING(); /* ⑥ 重新开始采样 */
}

对应的保护侧:

/* YC.C: ProtYcCal() 第一行 */
if (AD7616ResetCount > 0) return 0; /* 采样没恢复,本次不更新测量值 */

“复位期间保护不动作”是一个必须明确的策略决定,而不是代码的副作用。因为复位期间数据是无效的,用它算出来的值会让保护误动。

8.4 写完这些,才算”能上现场”#

数一下:核心采样链路大约 200 行,而围绕它的容错、自愈、降级代码,也有 200 行。

能跑的是那 200 行,能量产的是另外 200 行。


9. 提炼成 10 条可以带走的方法论#

  1. 高频节拍交给定时器硬件,不要用 delay() 造节拍。软件延时只适合”人机交互”级别的时间。

  2. 定长、无脑的搬运交给 DMA,CPU 只处理”边界”(开始、完成、出错)。

  3. 中断里只做三件事:打时间戳、入队/写缓冲、置标志。不做运算,不做打印,不做查表浮点。

  4. 中断优先级按”能容忍多少延迟”排:采样 > 通信 > 人机。并且把采样类中断放在 configMAX_SYSCALL_INTERRUPT_PRIORITY 之上,让它连临界区都挡不住。

  5. 跨上下文传数据用环形缓冲,不要用队列/消息传高频采样帧。

  6. 单生产者 + 单消费者 + 2 的幂长度 + 对齐下标 = 免锁。位与(& (size-1))比取模快,也让编译器更容易优化。

  7. 缓冲深度 = 最长阻塞时间 × 采样率,再留 2 倍以上余量。这个公式能直接回答”缓冲要开多大”。

  8. 采样率由算法倒推(整周波、点数、抽点比),不是拍脑袋;把推导过程写在宏旁边。

  9. 消费者用”序号/时间戳”对齐数据,不要用”我多久跑一次”来假设数据量。

  10. 每条链路都要有”死了怎么办”:心跳检测、错误计数、降级策略、可观测性(复位次数、错误计数要能读出来)。


10. 三道思考题#

  1. 如果把采样率从 12.8 kS/s 提到 128 kS/s,这套结构里哪一层会先崩?(提示:算一下 512 帧缓冲还能撑多久)

  2. 如果把 DataSamp(26)和 fa(25)的优先级调换,会发生什么?什么情况下会暴露问题、什么情况下”看起来正常”?

  3. 如果用消息队列把每一帧(35 字)传给任务,在 12.8 kS/s 下每秒要做多少次内存拷贝?为什么这个方案在实践中不可行?

欢迎在评论区聊聊你的答案。


附录 A:关键参数速查#

参数值来源 / 推导
定时器时钟300 MHzAPB1 定时器时钟
采样周期78.12 us300MHz ÷ 12 ÷ 1953
采样率12.8 kS/s1 ÷ 78.12us
原始点/周波256SAMPLES_PER_CYCLE_256
一周波时间20 ms256 × 78.12us(50Hz)
DFT 点数32WAVE_POINTS
抽点比8<1>256 / 32
原始环形缓冲512 帧 ≈ 40 msAD_SEQBUFMAX = 2 × 256
算法缓冲8192 点 ≈ 640 msAD_BUF_SIZE = 4096 × 2(录波用)
抽点缓冲64 点 = 2 周波AD_PROTBUFFSIZE
数据搬运任务周期2 ms(优先级 26)TIMER2 1ms 计数 dscount>=2
保护算法周期5 ms(优先级 25)FA_CYCLE_TIMER = 5
采样链路中断优先级DMA=0,TIMER7=1高于 FreeRTOS 临界区屏蔽线 0x02

附录 B:本文涉及的源文件#

文件作用
User/bsp/bsp_spi_ad7616.cAD7616 驱动:PWM 节拍、BUSY 捕获、SPI+DMA、解包、写原始缓冲
User/bsp/bsp_spi_ad7616.h缓冲结构、宏(AD_SEQBUFMAX、AD_CHANNEL_NUM)
User/DataSamp.c数据搬运任务:排空缓冲、抽点、坏帧顶替、SPI 自愈
User/bsp/bsp_timer.c1ms 系统节拍:发 FA(5ms)与 DataSamp(2ms)事件
ARM9_ftu_net_pro/PRODUCT/FANEW.CPP保护算法主任务 fa()
ARM9_ftu_net_pro/PLATFORM/YC.C测量算法:ProtYcthree() 序号对齐、CalcuProtMeas() 增量 DFT
ARM9_ftu_net_pro/PLATFORM/YC.H采样点数与抽点比等参数
ARM9_ftu_net_pro/INCLUDE/system.h任务 ID 与优先级
User/FreeRTOSConfig.hconfigMAX_SYSCALL_INTERRUPT_PRIORITY
Project/EWARMv7/GD32H737xM.icf内部 AXI SRAM / 外部 SDRAM 的分布

一句话总结: 用定时器产生节拍,用捕获中断触发搬运,用 DMA 完成搬运,用环形缓冲隔离速率,用序号对齐数据,用优先级保证安全,用自愈逻辑保证十年不宕机。 任务算多久都无所谓——因为采样从来没等过任何人。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

采样的流程
https://blog.xuefei.art/posts/采样的流程-mvszxewr/
作者
雪飞
发布于
2026-10-29
许可协议
CC BY-NC-SA 4.0

文章目录