基于STM32 Cortex-M4的ADC+DMA+FFT失真度测量与THD实现

发布时间:2026/10/30 3:08:16
基于STM32 Cortex-M4的ADC+DMA+FFT失真度测量与THD实现

简介:这份代码包对应2021年全国大学生电子设计竞赛A题“信号失真度测量”,面向备赛学生、嵌入式初学者及需要MSP430F5529工程参考的开发者。方案围绕信号采集、失真度分析展开,并用NRF24L01无线模块把结果发送到手机端显示,工程组织较贴近实际赛题实现,便于理解基础功能如何落地。

包内共990个文件,约31.58MB,以C/H源码、Keil工程配置、编译中间文件及备份文件为主,另含NRF24L01模块工程、HEX输出和少量说明文档,目录中保留了源码、工程配置到编译产物的完整痕迹,适合对照学习项目构建过程。

已有4031人学习下载。读者可据此梳理赛题方案的功能边界,参考采样、失真度计算、无线收发与手机显示之间的代码衔接,也可借助工程文件和HEX了解编译配置及模块集成方式,适合方案复盘、课程设计或二次开发对照。

1. 从一块“数字不动”的板子说起

去年帮人看一块测量板子,信号源输出 1kHz 正弦,示波器上波形干净得像教科书,可板子上的屏幕稳定显示 3.8% 的失真度。换板子、换固件、换信号源,数字一动不动,最后发现锅在采样点数和窗函数上——这就是照着 2021 年电赛 A 题(信号失真度测量装置)写的一版代码,也是这份资源里最值得拆的部分。它把从定时器触发 ADC、DMA 搬数、FFT 算谐波到屏幕出数的整条链路都摊开了,主控是常见的 Cortex-M4 平台,算法侧直接吃 CMSIS-DSP 的实数 FFT。适合两类人:一类要复现失真度测量方案、照着跑通出数;另一类手里有块板子,想把这套采集加频谱的链路改成自己的测量平台。

2. 工程结构拆解:从 main.c 到 FFT 计算的目录分层

拿到一份比赛代码,最怕的是所有东西塞在main.c里,一千多行,改一个采样率要把整个文件读一遍。这套工程的分层做得还算克制,至少能把“采集”“算法”“显示”三条线分开看。下面是我按实际文件整理出来的目录骨架,命名上做了点归一化,结构跟原工程一致。

Project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── adc.h tim.h dma.h usart.h │ │ └── stm32f4xx_it.h │ └── Src/ │ ├── main.c # 初始化 + 主循环调度 │ ├── adc.c # ADC1 单通道 + 外部触发 │ ├── tim.c # TIM2 作为采样节拍源 │ ├── dma.c # DMA2 Stream0 搬运 ADC 结果 │ ├── usart.c # 上位机调试输出 │ └── stm32f4xx_it.c # DMA 半满/全满中断 ├── App/ │ ├── thd.c / thd.h # 失真度主算法 │ ├── window.c / window.h # 汉宁窗表生成 │ ├── display.c # OLED/屏幕刷新 │ └── calib.c # 幅值校准系数 ├── Drivers/ │ ├── CMSIS/ # 含 DSP 库与 arm_rfft_fast_f32 │ └── STM32F4xx_HAL_Driver/ └── MDK-ARM/ # 工程文件与下载配置

2.1 三个层次各自的职责边界

Core目录里全是 CubeMX 生成的初始化代码,原则上只做“把外设配起来”这一件事,不放任何业务逻辑。App目录才是自己写的部分,thd.c负责把一整块原始采样变成失真度百分比,window.c只在开机时生成一次窗表,calib.c存的是出厂校准系数。Drivers不用动,但要注意 CMSIS-DSP 是以静态库形式挂进去的,工程属性里的头文件路径必须包含CMSIS/DSP/Include,否则arm_math.h找不到。

这么分的直接好处是,改算法只碰App,改采样率只碰tim.c里的 ARR 值,两边互不干扰。我见过不少比赛代码把 FFT 和 ADC 初始化写在一个函数里,调一次采样率要连带重算窗表长度,那种结构调试起来非常难受。

2.2 主循环的数据流与时序

main.c的主循环非常短,短到第一次看会以为漏了东西:

/* main.c 主循环节选 */ int main(void) { HAL_Init(); SystemClock_Config(); /* 168MHz, APB1 定时器时钟 84MHz */ MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); MX_TIM2_Init(); MX_USART1_UART_Init(); Window_Init(); /* 生成 4096 点汉宁窗表 */ Calib_Load(); /* 载入幅值/相位校准系数 */ HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, FFT_SIZE); HAL_TIM_Base_Start(&htim2); /* 只做触发源,不开中断 */ while (1) { if (fft_ready_flag) { /* 由 DMA 全满中断置位 */ fft_ready_flag = 0; THD_Process(adc_buf, &result); Display_Update(&result); Debug_Print(&result); } } }

这段代码的关键在于“中断只置标志,计算全在主循环”。HAL_ADC_Start_DMA一次挂上 4096 个半字的目标缓冲区,DMA 每搬满一次就触发一次传输完成中断,中断服务函数里只做一件事:把fft_ready_flag置 1。真正耗时的 FFT 和开方运算放在主循环,避免在中断里跑几毫秒导致后续采样被覆盖。

FFT_SIZE在这里是 4096,对应adc_buf是uint16_t[4096],占用 8KB RAM。M4 的 SRAM 一般够用,但如果同时开双缓冲就要 16KB,这时候得留意链接脚本里的 RAM 分配,别把栈挤爆——这个坑在第 5 章还会展开。

提示:HAL_TIM_Base_Start不带_IT后缀,表示只启动计数器、不开更新中断。采样节拍完全由硬件 TRGO 走,软件不参与,抖动会小很多。

3. 采样链路配置:定时器触发 ADC + DMA 的完整时序

失真度测量对采样链路的要求只有两条:采样率必须准,采样点必须连续不能丢。软件轮询 ADC 或者用HAL_ADC_Start反复重启转换,都会引入不可控的间隔抖动,频谱上直接反映成噪声底抬高。这套代码用的是“定时器 TRGO 硬件触发 + DMA 循环搬运”的经典组合。

3.1 采样率怎么定:200kHz 与 4096 点的取舍

先算一笔账。FFT 的频率分辨率是fs / N,如果采样率 200kHz、点数 4096,分辨率就是 48.8Hz。对 1kHz 基波来说,这个粒度只能定位到第 20 根谱线附近,直接取最大值会有接近 2.5% 的频率误差,谐波能量也会散到相邻 bin 上。

那为什么不把采样率降到 20kHz、点数拉到 16384?分辨率确实能到 1.2Hz,但代价是采集时间变成 800ms,屏幕刷新肉眼可见地卡,而且 16384 点实数 FFT 在 168MHz 的 M4 上要跑十几毫秒,主循环直接被拖慢。这套代码选的是中间路线:200kHz 采样、4096 点、20.48ms 一帧,再用双谱线插值把基波频率估到 1Hz 级,兼顾速度和精度。

参数取值说明
采样率 fs200 kHz满足高次谐波不混叠
FFT 点数 N4096频率分辨率 48.8 Hz
单帧时长20.48 ms屏幕刷新约 20 帧/秒
分析谐波次数2~5 次覆盖题目要求的失真范围
ADC 分辨率12 bit理论 SNR 约 74 dB

3.2 定时器 TRGO 与 ADC 外部触发

TIM2 挂在 APB1 上,定时器时钟 84MHz。要得到 200kHz 的更新事件,分频和重装值这样配:

/* tim.c:TIM2 产生 200kHz 更新事件,作为 ADC 触发源 */ void MX_TIM2_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 0; /* 不分频,计数时钟 84MHz */ htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 419; /* 84MHz / (419+1) = 200kHz */ htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; HAL_TIM_Base_Init(&htim2); sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; HAL_TIM_ConfigClockSource(&htim2, &sClockSourceConfig); sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE; /* 更新事件输出到 TRGO */ sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig); }

Period = 419是重装载值,实际计数是 0 到 419 共 420 个节拍,84000000 / 420 = 200000,正好 200kHz。MasterOutputTrigger选TIM_TRGO_UPDATE,意思是每次计数器溢出产生更新事件时,往 TRGO 线上发一个脉冲,ADC 就靠这个脉冲启动一次转换。

ADC 侧的配置要跟定时器对上:

/* adc.c:ADC1 由 TIM2_TRGO 上升沿触发,DMA 循环模式 */ void MX_ADC1_Init(void) { ADC_ChannelConfTypeDef sConfig = {0}; hadc1.Instance = ADC1; hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4; hadc1.Init.Resolution = ADC_RESOLUTION_12B; hadc1.Init.ScanConvMode = DISABLE; hadc1.Init.ContinuousConvMode = DISABLE; /* 单次转换,靠外部触发 */ hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_RISING; hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T2_TRGO; hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion = 1; hadc1.Init.DMAContinuousRequests = ENABLE; /* DMA 循环,采满自动绕回 */ hadc1.Init.EOCSelection = ADC_EOC_SINGLE_CONV; HAL_ADC_Init(&hadc1); sConfig.Channel = ADC_CHANNEL_0; sConfig.Rank = 1; sConfig.SamplingTime = ADC_SAMPLETIME_28CYCLES; /* 约 0.4us,够 200kHz 用 */ HAL_ADC_ConfigChannel(&hadc1, &sConfig); }

SamplingTime这一项容易被忽略。ADC 时钟是 PCLK2/4,168MHz 下 PCLK2 是 84MHz,除以 4 得 21MHz,一个转换周期约 47.6ns。28 个采样周期加 12 个转换周期共 40 个周期,约 1.9us,远小于 5us 的采样间隔,不会阻塞下一次触发。如果前端信号源内阻偏高,这个值要往上调到 56 或 84 周期,否则采样电容充不满,读数会偏低。

3.3 DMA 双缓冲与半满中断

HAL_ADC_Start_DMA启动之后,DMA 会在后台把每次 ADC 转换结果写进adc_buf,写满 4096 个自动绕回开头继续写。问题是:主循环在算 FFT 的那几毫秒里,DMA 还在往同一个缓冲区写,算到一半数据就被新采样覆盖了,结果就是“偶尔准、偶尔乱跳”。

解决办法是开半满中断,把缓冲区切成前后两半轮换:

/* stm32f4xx_it.c:DMA 传输完成回调,双缓冲切换 */ void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { /* 前半区采满,可以开始算,后半区继续由 DMA 填充 */ active_buf = (uint16_t *)&adc_buf[0]; fft_ready_flag = 1; } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { /* 后半区采满,切换过去 */ active_buf = (uint16_t *)&adc_buf[FFT_SIZE / 2]; fft_ready_flag = 1; }

这里adc_buf要按FFT_SIZE * 2开,两个半区各 2048 点。不过 2048 点 FFT 的分辨率只有 97.6Hz,精度会掉一截。更稳的做法是保留 4096 点整帧,用两块独立缓冲区做乒乓:一块在采,一块在算,采满一块再切。代价是多占 8KB RAM,换来的是分辨率不降。工程里实际用的是后者,中断回调里只做指针交换和标志置位,两行代码,没有其他动作。

提示:DMA 缓冲区的内存地址要保证 4 字节对齐,否则在某些 M4 型号上会触发总线错误。声明时加__attribute__((aligned(4))),或者用__ALIGN_BEGIN宏包一层。

4. 失真度算法实现:加窗、插值找基波与 THD 计算

采集链路只保证“拿到干净的波形”,真正决定失真度读数准不准的是算法这一段。整套流程是:去直流归一化 → 加窗 → 实数 FFT → 找基波峰并插值 → 取 2~5 次谐波能量 → 求比值。每一步都有坑,尤其前两步。

4.1 去直流与汉宁窗:为什么必须加窗

ADC 是单极性输入,12 位数据在 0~4095 之间,中心点大约 2048。如果直接把这串数丢进 FFT,直流分量会形成一根极高的 0 号谱线,虽然不影响非零频 bin,但会占掉大量动态范围,小信号谐波直接被淹掉。所以第一步必须先减偏置再归一化:

/* thd.c:去直流归一化 + 加窗 */ static void Preprocess(const uint16_t *raw, float32_t *out) { const float32_t offset = 2048.0f; /* 12bit 中点 */ const float32_t scale = 1.0f / 2048.0f; for (uint32_t i = 0; i < FFT_SIZE; i++) { float32_t x = ((float32_t)raw[i] - offset) * scale; /* 归一到 -1~1 */ out[i] = x * hann_win[i]; /* 逐点乘窗 */ } }

窗表在开机时算一次,公式是w[n] = 0.5 - 0.5 * cos(2πn/(N-1)),汉宁窗的相干增益是 0.5,这个值后面算幅值时要补回去。为什么一定要加窗?因为被测信号频率很少刚好落在 bin 中心,比如 1kHz 基波在 48.8Hz 分辨率下落在第 20.5 根谱线,能量会泄漏到左右各三四根谱线上。不加窗时泄漏衰减慢,旁瓣会污染谐波位置;加了汉宁窗,旁瓣衰减快得多,谐波能量能干净地收在有限几根谱线里。

代价是主瓣变宽,基波会占掉 ±2 根谱线。所以找基波峰的时候不能只看最大值那一根,得用插值把真实频率估出来,否则后面按整数倍算谐波位置会整体偏移。

4.2 双谱线插值:把基波频率估到 1Hz 级

CMSIS-DSP 提供的是打包格式的实数 FFT,输出数组里[0]是直流,[1]是奈奎斯特,之后[2k]是第 k 根谱线的实部、[2k+1]是虚部。所以取模要自己写,不能直接调arm_cmplx_mag_f32。

/* thd.c:实数 FFT 后转幅度谱 */ static void MagSpectrum(const float32_t *fft_out, float32_t *mag) { mag[0] = fabsf(fft_out[0]); /* 直流 */ for (uint32_t k = 1; k < FFT_SIZE / 2; k++) { float32_t re = fft_out[2 * k]; float32_t im = fft_out[2 * k + 1]; mag[k] = sqrtf(re * re + im * im); /* 第 k 根谱线幅值 */ } }

然后在 20Hz~20kHz 对应 bin 范围内找最大值位置k0,用左右两根谱线做比值插值:

/* thd.c:双谱线插值求基波精确频率 */ static float32_t InterpFreq(const float32_t *mag, uint32_t k0, float32_t bin_hz) { float32_t a = mag[k0 - 1]; float32_t b = mag[k0]; float32_t c = mag[k0 + 1]; float32_t denom = a - 2.0f * b + c; float32_t delta = 0.0f; if (fabsf(denom) > 1e-12f) { delta = 0.5f * (a - c) / denom; /* 落在 -0.5 ~ 0.5 */ if (delta > 0.5f) delta = 0.5f; /* 限幅,防止噪声导致跳变 */ if (delta < -0.5f) delta = -0.5f; } return (k0 + delta) * bin_hz; /* 修正后的基波频率 */ }

delta的物理含义是真实频率相对k0这根谱线偏了多少个 bin。直接取最大值那根谱线,误差最大能到半个 bin,也就是 24.4Hz;插值之后通常能压到 1Hz 以内。这个精度对算谐波位置很关键,因为第 5 次谐波的误差会被放大 5 倍。

4.3 谐波能量求和与 THD 输出

基波频率f0确定之后,2 到 5 次谐波的理想位置就是n * f0。但谐波能量同样受泄漏影响,不能只取一根谱线,稳妥做法是取中心 bin 左右各一根,三点平方和开根号:

/* thd.c:THD 主计算流程 */ void THD_Process(const uint16_t *raw, thd_result_t *out) { float32_t mag[FFT_SIZE / 2]; float32_t f0, v1, vh_sum = 0.0f; Preprocess(raw, fft_in); arm_rfft_fast_f32(&rfft, fft_in, fft_out, 0); /* 4096 点实数 FFT */ MagSpectrum(fft_out, mag); uint32_t k0 = FindPeak(mag, BIN_MIN, BIN_MAX); if (k0 < 2 || k0 > FFT_SIZE / 2 - 3) { /* 峰太靠边,判为无效 */ out->valid = 0; return; } f0 = InterpFreq(mag, k0, BIN_HZ); /* 幅值修正:实数 FFT 峰值 = A * N * CG / 2,汉宁窗 CG = 0.5 */ v1 = 2.0f * mag[k0] / (FFT_SIZE * 0.5f) * calib_gain; for (uint32_t n = 2; n <= 5; n++) { float32_t fn = f0 * n; if (fn >= SAMPLE_RATE * 0.5f) break; /* 超过奈奎斯特,跳过 */ uint32_t kc = (uint32_t)(fn / BIN_HZ + 0.5f); if (kc < 1 || kc >= FFT_SIZE / 2 - 1) break; float32_t h = sqrtf(mag[kc - 1] * mag[kc - 1] + mag[kc] * mag[kc] + mag[kc + 1] * mag[kc + 1]); vh_sum += h * h; } out->thd = (v1 > 1e-6f) ? (sqrtf(vh_sum) / v1 * 100.0f) : 0.0f; out->freq = f0; out->vpp = v1 * 2.0f * calib_vpp; out->valid = 1; }

三个细节值得说清楚。calib_gain是幅值校准系数,出厂时用标准源打一遍,把读数对齐到真实值;calib_vpp单独一套,因为屏幕显示的峰峰值和 FFT 算出来的幅值走的是不同校准路径。vh_sum累加的是平方,最后统一开根号,不要每算一次谐波就开一次根,多出来的sqrtf在高频中断场景下会拖慢主循环。out->valid这个标志位别省,信号源没接或者幅度太小的时候,k0会落在噪声上,算出来的 THD 可能是几百个百分点,有有效位判断就能直接把这种脏数据显示成“--”。

提示:FindPeak的搜索范围不要从 bin 1 开始,低频段的 1/f 噪声和前端运放的失调很容易造出一个假峰。建议从 20Hz 对应的 bin 起搜,上界卡到 20kHz 或奈奎斯特的一半,取小值。

5. 避坑排查:调这套代码最容易翻车的五个地方

下面这几条都是我在类似链路上真实踩过的,现象和解决方式按“看到什么 → 为什么 → 怎么办”组织,顺序基本对应从算法到硬件的排查路径。

5.1 读数随信号频率漂移,换频点就变一个数

现象:1kHz 时显示 2.1%,换成 3kHz 显示 4.7%,示波器上看波形失真度并没有明显变化。

原因:没加窗或者窗函数用错,频谱泄漏随频率与 bin 的偏移量变化,偏移越大泄漏越严重,谐波能量就被算多了。另一个常见原因是幅值修正里的相干增益写错,汉宁窗是 0.5,有人按矩形窗的 1.0 去补,所有读数系统性偏大。

解决:确认Preprocess里逐点乘了窗表,确认v1的计算分母是FFT_SIZE * CG / 2,汉宁窗CG = 0.5,即分母为FFT_SIZE * 0.25。改完拿标准源在 500Hz、1kHz、5kHz 三个点各测一遍,读数差应该小于 0.3 个百分点。

5.2 低频段失真度虚高,1kHz 以下尤其明显

现象:信号源输出 200Hz 纯正弦,理论上 THD 接近 0,代码显示 6% 以上。

原因:两个可能。一是 FFT 点数不够,200Hz 基波在 48.8Hz 分辨率下只占 4 根谱线,主瓣和二次谐波的位置挤在一起,能量互相污染;二是前端运放的带宽或者偏置电路没处理好,低频段运放失调漂移被当成谐波。

解决:低频测量时把采样率降下来,比如切到 50kHz、8192 点,分辨率变成 6.1Hz,主瓣和谐波彻底分开。硬件侧在 ADC 输入前加一级跟随器,把偏置电压用低温漂基准分压得到,别直接用电源分压。

5.3 结果时好时坏,屏幕数字每隔几秒跳一次

现象:同一个信号,读数大部分时间稳定,偶尔跳变十几个百分点,抓不到规律。

原因:在 DMA 中断里直接跑 FFT,或者主循环算 FFT 时缓冲区被 DMA 覆盖。前者是中断超时导致后续采样丢点,后者是两个任务抢同一块内存。还有一种隐蔽情况:FFT 的输入输出数组开在栈上,4096 点浮点数组占 16KB,默认栈只有 1KB,直接栈溢出,表现就是随机跑飞。

解决:中断只置标志,计算放主循环;缓冲区用乒乓或者半满切换;所有大数组改成全局静态或者丢到 CCM RAM,同时把启动文件里的Stack_Size调到 0x1000 以上。改完连续跑十分钟,读数不应该有超过 1 个百分点的跳变。

5.4 串口打印正常,一开屏幕就卡死

现象:只跑串口输出时一切正常,接上 OLED 之后程序卡在HAL_Delay或者直接 HardFault。

原因:屏幕刷新用了软件 I2C 加阻塞延时,一次刷屏几十毫秒,正好卡在主循环里,DMA 采满两轮都没人处理,标志位被反复覆盖。如果屏幕驱动里还开了中断,跟 DMA 中断抢优先级,情况会更乱。

解决:屏幕刷新降频,比如每 200ms 刷一次,中间用状态机分步写;或者把屏幕挂到硬件 I2C 上并开 DMA 传输。中断优先级上,DMA 采集中断设为最高,屏幕和串口一律低于它。

5.5 小信号测不准,噪声底把谐波淹了

现象:输入 100mV 左右的小信号,THD 读数从 1% 涨到 8%,示波器上看波形依然干净。

原因:ADC 的有效位数在低幅度下掉得厉害,加上地线处理不好,数字部分的开关噪声串到模拟输入端。12 位 ADC 理论 SNR 74dB,实际布板不好可能只剩 50dB,谐波比噪声还低的时候自然测不出来。

解决:模拟地和数字地单点连接,ADC 输入走差分或者加一级 RC 低通(截止频率设在最高分析频率的 1.5 倍);采样点数从 4096 提到 8192,用相干平均把噪声压下去 3dB。信号特别小的时候,前端加一级可编程增益,别指望 ADC 硬扛。

6. 二次开发改造:把固定题目代码变成通用测量平台

这套代码是为一个固定题目写的,参数基本写死:单通道、固定采样率、固定 2~5 次谐波。想拿去做别的测量,动刀的地方其实就三处,按改动成本从低到高排。

第一处是采样参数。tim.c里的Period和thd.h里的FFT_SIZE是一对,改一个必须改另一个,否则BIN_HZ算错,所有频率读数整体偏移。我的做法是把采样率和点数做成运行时可配的结构体,初始化时根据目标频段自动选档:测工频段用 20kHz/8192,测音频段用 200kHz/4096。切换时重新生成窗表,因为汉宁窗表长度跟点数绑定,这点很容易忘。

第二处是谐波次数和分析带宽。THD_Process里那个n <= 5改成读配置项,同时把FindPeak的搜索上下界一起参数化。更进一步可以把 THD 计算换成 Goertzel 算法,只算关心的那几根谱线,4096 点只需算 5 次,耗时从毫秒级掉到几十微秒,代价是不能再看全频谱。

第三处是输出接口。现在的Debug_Print是纯文本,接上位机要重新解析。改成定长二进制帧更省事:

/* debug.c:定长帧,帧头 0xAA55 + 载荷 + 校验 */ typedef struct __attribute__((packed)) { uint16_t head; /* 0xAA55 */ float freq; /* 基波频率 Hz */ float thd; /* 失真度 % */ float vpp; /* 峰峰值 V */ uint8_t valid; uint16_t crc; /* 前面所有字节的 CRC16 */ } thd_frame_t; void Debug_Send(const thd_result_t *r) { thd_frame_t f = { .head = 0xAA55, .freq = r->freq, .thd = r->thd, .vpp = r->vpp, .valid = r->valid }; f.crc = CRC16_Calc((uint8_t *)&f, sizeof(f) - 2); HAL_UART_Transmit_DMA(&huart1, (uint8_t *)&f, sizeof(f)); }

上位机按帧头对齐、按长度切片,比解析文本稳得多,也不怕打印格式改来改去。__attribute__((packed))不能省,否则结构体会被编译器按 4 字节对齐填充,帧长对不上。

验证改造是否成功,我一般走三步:先用标准源打一个已知失真度的信号,看读数偏差是否在 ±0.5% 内;再把信号源关掉,看程序会不会输出一堆垃圾数,valid应该稳定为 0;最后连续跑半小时,看有没有累积漂移。这三步走完,才敢把板子接到真实信号上。

说个教训。早些年我改这类采集代码,习惯先动算法再调硬件,觉得软件能补一切。有一次为了压噪声在算法里加了三层滤波,结果把真实的五次谐波一起滤掉了,读数漂亮得不像话,接上标准源立刻原形毕露。从那以后我每次改采样链路,都强制先用标准源打一遍原始数据存下来,算法改完拿同一批数据回放对比,绝不在没有基准的情况下凭读数好看就收工。希望这套拆解能帮你少绕两圈,把板子先跑出数,再谈改得多漂亮。

本文还有配套的精品资源,点击获取