2023电赛H题代码包解析:FFT与陷波器的信号处理实战
简介面向电子设计竞赛参赛者的2023年电赛H题完整工程代码包基于STM32F4系列针对A、B双路叠加信号经加法器合成C后需分离并还原A、B的难点给出从硬件接线、信号采集、FFT频谱分析到DDS信号输出的完整解题思路重点处理同频稳定输出及相对漂移问题。包内共136个文件以69个h头文件与56个c源文件为主涵盖ADC采集、LCD显示、定时器、外设驱动等模块另含Keil工程配置、烧录脚本及说明文档压缩包整体约11.07MB目录结构清晰便于按模块查阅修改。目前已有685人学习下载适合备赛冲刺或赛后复盘。资源不仅提供可直接编译调试的工程还记录了将AD9833晶振由25M更换为1M以提升频率调整精度的关键排错思路对解决同频信号相对漂移问题有很强的实用参考价值。1. 为什么说2023年电赛H题代码包是个“黑匣子”但值得打开2023年电赛H题表面上看是两路正弦波混叠后让你测频率和幅度实际上考的是信号处理算法和硬件底噪较量的综合能力。我拆这份代码包之前以为就是一堆FFT代码外加几个滤波函数跑起来才发现里面藏着采样率设置、窗函数选择、陷波器稳定性这些真正的分水岭。不少队伍算法原理讲得头头是道一上板就翻车往往不是算法不懂而是工程细节没闭环。这份代码包是一个可完整运行的工程包含ADC采样、DMA缓存、CMSIS-DSP库的FFT调用、频率估计和DAC输出全套代码还附了调参记录和自检逻辑。新手能顺着工程把信号处理链路搭起来熟手也能直接抄走陷波器设计参数和双缓冲那套处理思路。2. 先把题目算清楚信号模型与算法选型拿到代码先别急着烧录H题这种信号分离场景第一件事是把输入信号模型理解透否则参数改来改去输出波形还是一团糟。这一章我们先不碰代码从为什么要这么设计讲起。2.1 混叠信号模型两个正弦波叠加后FFT为何看不到完整频率H题给出的原始信号可以写成这样x(t) A1·sin(2π·f1·t) A2·sin(2π·f2·t) n(t)其中n(t)是噪声f1、f2是要分离的两个频率A1、A2是对应幅度。难点在于两个频率可能比较接近也可能幅度差很大甚至有一路信号被另一路完全压住。数字处理的第一步是采样采样率必须满足奈奎斯特条件也就是fs 2·max(f1, f2)。比如题目要求最高频率10kHz那么fs至少20kHz。工程里我习惯取2.5倍以上裕量因为还要考虑ADC前级抗混叠滤波器的非理想性。FFT能把时域信号转换到频域但它的频率分辨率是固定的Δf fs / NN是FFT点数。举个例子fs20000HzN1024点那分辨率就是19.53Hz。如果f15000Hzf25020Hz这两根谱线差20Hz勉强能分开但如果差10Hz在频谱上就完全糊成一根线这叫做栅栏效应。所以代码包里第一层逻辑不是直接对原始信号做FFT而是先判断当前采样率和FFT点数是否能满足题目给的最小频率间隔。如果不能就要动态调整采样率或FFT点数。这里有一个关键参数最小频率间隔已知时N必须满足N fs / Δf_min。比如fs20000要求Δf_min10Hz那么N至少要2000点实际取2048点才保险。2.2 算法选型FFT、陷波器与自相关哪个适合片上跑信号分离的思路有很多种我盘点一下竞赛里最常见的四种以及各自的适用边界用表格看更直观方法优点缺点适合场景直接FFT找峰值实现简单CMSIS-DSP库直接支持分辨率受限小信号容易被旁瓣淹没两路频率差大于3~4个Δf幅度差小于10倍加窗FFT能压低旁瓣改善小信号检测主瓣变宽频率分辨率略降幅度差较大但频率差较宽陷波器第二轮FFT先滤掉强信号再检测弱信号陷波系数容易不稳定会引入相位偏差频率差中等幅度差大自相关/线性预测分辨率不受FFT点数限制计算量大噪声下容易偏差频率非常接近但信噪比高在单片机上我不推荐直接用自相关做实时系统它的矩阵求逆运算在M4上跑起来有点吃力而且对底噪敏感。常见做法是组合方案第一轮用FFT找到幅度最大的f1设计一个数字陷波器把f1及其附近频带滤掉对残差信号再做第二轮FFT找到f2。陷波器用的是二阶IIR滤波器传递函数是H(z) (1 - 2·cos(ω0)·z^-1 z^-2) / (1 - 2·r·cos(ω0)·z^-1 r^2·z^-2)其中ω0是归一化陷波频率r是极半径通常取0.95~0.99。r越接近1陷波带宽越窄但对系数精度要求越高。在float32下r0.99时极点非常接近单位圆滤波器做卷积时误差会被放大容易出现“滤完反而振荡”的现象。所以代码包默认把r控制在0.97~0.98之间这是我在调试中觉得稳定性和选择性平衡得比较好的范围。选型逻辑清楚了下一步就是打开工程看代码到底怎么落地的。3. 拿到代码包后先理清工程结构一套电赛代码工程往往比想象中复杂不是打开main.c就能跑。我拿到这份代码第一件事是画目录结构确认硬件资源映射关系然后再去走读核心函数。这一章我用实际工程里的文件组织方式来讲解。H2023/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── adc.h │ │ └── dac.h │ └── Src/ │ ├── main.c │ ├── adc.c │ └── dac.c ├── Drivers/ │ ├── CMSIS/ │ │ └── arm_math.h │ └── HAL_Driver/ │ └── hal_gpio.c ├── Middlewares/ │ └── DSP/ │ └── arm_cfft_f32.c ├── signal_process/ │ ├── spectrum.c │ ├── notch_filter.c │ └── estimate.c └── common/ ├── debug_uart.c └── timing.cCore下面的adc.c负责采集混叠信号DAC负责输出分离后的信号signal_process是整个代码包的核心里面有FFT封装、陷波器和最终估计逻辑。common下的debug_uart用来打印频率和幅度值timing用于计算FFT耗时。3.1 主控与传感器从工程文件看硬件资源分配H题一般是要求将分离出的信号重新输出所以ADC和DAC必须同时工作而且需要同步。工程里用的是ADC1的DMA双缓冲模式简单说就是两块缓冲区轮流采集一块在后台接收数据另一块被CPU拿来处理处理完再交换角色保证采样不丢点。初始化顺序很讲究先配置GPIO和时钟再初始化ADC然后启动DMA传输最后才允许全局中断。一旦顺序反了DMA可能在时钟稳定前就开始采样读出来的数据全是乱的。// main.c 初始化片段 void SystemInit(void) { HAL_Init(); SystemClock_Config(); // 设置PLL确保ADC时钟稳定 MX_GPIO_Init(); MX_ADC1_Init(); MX_DAC_Init(); MX_DMA_Init(); HAL_ADC_Start_DMA(hadc1, adc_buf, FFT_SIZE * 2); }这里的FFT_SIZE是FFT变换的点数实际缓冲区长度是FFT_SIZE * 2因为双缓冲要准备两份。DMA一旦启动每采样完一个点就累加直到填满一块缓冲区触发转换完成回调。3.2 核心模块采样、FFT、频率估计与输出DAC的代码走读在ADC转换完成回调里数据从DMA搬出来之后要做三步转成浮点电压、减去直流偏置、拷贝到FFT输入缓冲区。下面是工程里实际使用的代码void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ! ADC1) return; uint32_t *completed_buf is_using_first_half ? adc_buf : adc_buf[FFT_SIZE]; // 将12位ADC原始值转成电压浮点数并减掉当前均值 for (int i 0; i FFT_SIZE; i) { float vol (completed_buf[i] / 4096.0f * 3.3f - 1.65f); fft_input[i] vol - dc_offset; } arm_cfft_f32(arm_cfft_sR_f32_len1024, fft_input, 0, 1); process_spectrum(fft_input, result); is_using_first_half !is_using_first_half; }arm_cfft_f32是CMSIS-DSP库提供的浮点FFT函数最后一个参数0表示正变换1表示反变换。这里直接对fft_input在原地做变换所以后面再需要用原始数据时一定要提前备份这是容易踩坑的地方。频率估计的核心函数用两轮FFT加陷波void estimate_freq(float *input, float *f1, float *a1, float *f2, float *a2) { float buf[FFT_SIZE]; memcpy(buf, input, sizeof(buf)); // 备份原始信号 // 第一轮FFT arm_cfft_f32(arm_cfft_sR_f32_len1024, buf, 0, 1); int peak argmax_spectrum(buf, FFT_SIZE / 2); *f1 (float)peak * FS / FFT_SIZE; *a1 calc_amplitude(buf, peak); // 构造并应用陷波器滤掉f1 notch_state.reset(); notch_apply(notch_ctx, buf, FFT_SIZE, *f1, FS, 0.97f); // 注意notch_apply会把时域信号过滤后再交给FFT但前提是buf已经备份 memcpy(buf, input, sizeof(buf)); // 重新获取原始信号因为notch_apply会改变buf arm_cfft_f32(arm_cfft_sR_f32_len1024, buf, 0, 1); peak argmax_exclude_band(buf, FFT_SIZE / 2, *f1); *f2 (float)peak * FS / FFT_SIZE; *a2 calc_amplitude(buf, peak); }这里有一个重要细节notch_apply是时域滤波输入输出需要同时变换采样点。滤波后再做FFT理论上可以找到被强信号掩盖的弱者。但arm_cfft_f32会覆盖输入缓冲区所以memcpy不能省否则第二轮FFT用的是第一轮变换之后的数据。4. 参数怎么调从仿真到上板的调参流程代码能跑起来只是第一步H题的设计目标是要在指定频率和幅度范围内达到一定精度。这一章讲讲我在实际调测中是怎么确定采样率、FFT点数和陷波系数的这些参数在代码包里也有对应的宏定义。4.1 采样率与FFT点数分辨率和运算量的权衡采样率和FFT点数直接决定频率分辨率也决定单片机的计算负荷。以下是我在目标芯片上实测的一组参考数据使用的FFT库是CMSIS-DSP的浮点版本注意这里的耗时会因为芯片主频和编译器优化级别不同有所变化。fs (Hz)N (点)分辨率 Δf (Hz)FFT耗时(ms)最大可分离频差20000102419.532.8约60Hz2000020489.776.1约30Hz40000204819.536.2约60Hz4000040969.7713.5约30Hz如果题目给出的最小频率间隔是20Hz那用20k采样率1024点就够了如果最小间隔是10Hz就必须上2048点或者4096点。但点数增加后ADC采样缓冲区也要变长DMA传输时间变长而且两次FFT之间的时间间隔不能超过采样时间否则会丢数据。我一般会留出至少30%的CPU余量因为还要留给陷波器处理、串口打印和按键扫描。如果两轮FFT加陷波在2048点上耗时超过8ms而采样缓冲区采集2048点需要的时间是1024ms以20kHz采样率算那说明时间预算不够需要把FFT点数降下来或者减少陷波器的处理长度。4.2 阈值与陷波系数两路信号幅度差较大时怎么办实际信号里两路幅度可能差20倍以上。想象一下f1幅度3Vf2只有0.15VFFT的旁瓣和频谱泄漏很容易把弱信号淹没。这时候单靠FFT找峰值是找不到f2的必须先处理强信号。代码包里设定了一个幅度阈值逻辑第一轮找到的峰值peak_idx被认为是强信号陷波器不仅滤除中心频率f1还会把f1 ± 2·Δf范围内的谱线也一并衰减。这个操作能除掉主瓣的大部分能量让弱信号露出来。#define NOTCH_Q 0.97f #define EXCLUDE_BINS 2 int argmax_exclude_band(float *spectrum, int len, float f_notch) { int center (int)(f_notch / FS * FFT_SIZE); int start center - EXCLUDE_BINS; int end center EXCLUDE_BINS; // 搜索过程中跳过被排除的bin区间 for (int i 1; i len; i) { if (i start i end) continue; if (spectrum[i] max_val) { ... } } }注意陷波系数Q值越高陷波带宽越窄但滤波器的过渡带也更陡对浮点舍入误差更敏感。工程里的默认值0.97是一个折中我试过0.99在温度变化时滤波器容易产生自激振荡输出波形出现低频毛刺。如果你在调测时发现陷波后的信号幅度比预期大很多先看看是不是Q值设太高导致极点接近单位圆。另外FFT幅度计算一定要处理窗函数增益。如果用了汉宁窗它的增益是0.5那么实际幅度等于频谱峰值除以FFT_SIZE/2再乘以2。很多新手在这个地方漏乘系数导致串口打印的幅度值偏小一半。5. 避坑与常见问题电赛现场最容易翻车的5个细节电赛时间紧很多问题不会给时间让你慢慢查datasheet。以下是这个代码包在联调过程中最常遇到的五个坑每条我都踩过按“现象 → 原因 → 解决”写清楚。5.1 现象DAC输出波形带毛刺输出波形看起来像正弦波但上面叠加了一层高频噪声用示波器看不太干净。原因DAC输出缓冲区和ADC采样缓冲区在DMA传输时发生了内存重叠。ADC DMA往缓冲区写数据DAC DMA同时从同一块内存读数据导致写一半读一半输出波形在采样点边界出现毛刺。解决把ADC和DAC分别安排独立缓冲区并且用双缓冲。ADC填充完一块后处理完再赋值给DAC的发送缓冲区时务必使用memcpy完成数据搬移不要直接共享指针。memcpy(dac_buffer, processed_data, FFT_SIZE * sizeof(float)); HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_2, dac_buffer, FFT_SIZE, DAC_ALIGN_12B_R);5.2 现象频率跳变不稳定串口打印出来的频率值不是稳定在一个数而是每隔一两秒跳变几赫兹到几十赫兹。原因ADC输入信号带有直流偏置FFT结果中0Hz分量非常大峰值搜索函数把直流当成最高峰了。另一个可能是信号源本身就有漂移如果硬件上加了隔直电容更多的是数字处理问题。解决每次采集后先计算平均值然后从每个采样点里减去这个直流分量。另外在峰值搜索时直接从idx5开始搜索跳过最前面几个低频bin。dc_offset compute_dc(adc_buf, FFT_SIZE); for (int i 0; i FFT_SIZE; i) { adc_buf_float[i] - dc_offset; }5.3 现象串口打印的幅度值偏小明明信号源输出1V峰值的正弦波串口打印出来只有0.5V左右。原因FFT频谱计算时没有补偿窗函数增益或者采样值经过分压电阻衰减了。最常见的是前者汉宁窗使幅度感知增益变为0.5实际幅度等于频谱峰值除以窗口增益。解决计算幅度时除以窗增益系数。如果用了汉宁窗就除以0.5如果用的矩形窗增益是1.0。在工程里建议统一用汉宁窗因为频谱泄漏小代价是主瓣宽一点频率分辨率略有下降。5.4 现象一上电就死机连调试器也连不上按下复位键程序没跑起来仿真器报文提示无法连接内核。原因DMA在系统时钟稳定之前就被启动了或者中断优先级配置不当导致硬故障。还有一个典型原因是堆栈大小不足CMSIS-DSP的FFT计算会使用大量栈空间默认堆栈只有0x400字节很容易溢出。解决在启动文件里把Stack_Size改成0x1000并把DMA初始化的顺序放到SystemClock_Config()之后。同时给ADC中断分配比DMA更高的抢占优先级避免处理函数被其他中断打断导致数据错乱。5.5 现象两路信号频率相近时分离失效输入两个频率差小于10Hz的信号输出只有一路频率另一路完全测不出来。原因FFT分辨率不够两根谱线重叠在一起即使提高了点数陷波器带宽也会把第二路信号一起滤掉。如果陷波器的带宽太宽它在滤除f1的同时把f2也衰减掉导致第二轮FFT找不到f2。解决先用自检模式确认硬件链路没问题然后提高FFT点数到4096甚至8192。如果环境允许改用更窄的陷波器把NOTCH_Q提高到0.99但必须检查稳定性。我在竞赛中的折中方案是保持0.97的Q值但把陷波器的应用方式改成“只在频率差大于3Δf时使用陷波如果小于3Δf直接改用双峰插值估计”。6. 一个有用的进阶技巧用外加参考信号做算法自检竞赛现场最怕的是半夜发现测出来的数据不对但不知道是硬件坏了还是算法错了。我后来习惯在工程里加一个“自检模式”用拨码开关触发让代码自己产生一个标准信号走一遍从DAC到ADC再到FFT的全链路然后把误差打印出来。这个方法本质是把系统闭环起来DAC输出一个已知频率的正弦波经过一个简单的电阻分压网络直接接到ADC输入然后程序处理完会得到频率估计值和预先设定值做比较。如果误差超过0.5%说明链路有问题否则就可以放心进行正式测量。void run_self_test(void) { if (!is_self_test_mode) return; uint32_t ref_freq 1000; // 自检参考频率1kHz float ref_amp 1.0f; dac_output_sine(ref_freq, ref_amp); // 通过查表生成正弦波 HAL_Delay(50); // 等待信号稳定 capture_adc(buffer); float est; arm_cfft_f32(arm_cfft_sR_f32_len1024, buffer, 0, 1); est get_peak_frequency(buffer); float err fabsf(est - ref_freq) / ref_freq; if (err 0.005f) { inline_printf(SELF_TEST_FAIL: err%.2f%%\n, err * 100.0f); } else { inline_printf(SELF_TEST_PASS\n); } }自检函数里有两个细节值得注意一是DAC生成正弦波用查表法不要实时计算sin函数否则时序不稳二是ADC采集要在DAC输出后延时至少20ms让DAC输出端的RC滤波电容充放电完成。这个自检方法特别适合赛前准备每次现场环境变了上台前先跑一遍自检确认采样率没被干扰再上正式信号。从那以后我每次做信号处理类题目第一件事就是先写这个自检函数把ADC和DAC的链路验证完再谈算法。很多看似神秘的“频率不准”问题其实在自检阶段就暴露了。希望帮到你。本文还有配套的精品资源点击获取