简介本资源面向STM32F4嵌入式开发者与信号处理学习者提供一套完整的时钟触发ADC双通道采样、DMA传输与FFT测频的可变采样率波形显示方案。项目以定时器输出作为ADC触发源实现精确同步采样双通道并行采集两路模拟信号借助DMA完成内存与外设间的高效数据搬运以降低CPU负载再通过FFT将时域信号转换为频域完成频率测量并支持采样频率切换与实时波形显示适用于实验室信号分析、工业设备故障诊断等场景。资源包共341个文件约40.78MB以116个h头文件与88个c源文件为核心配合uvprojx、uvoptx工程文件、hex固件、axf与map编译产物及bat脚本构成可直接编译运行的完整工程。目前已有631人学习下载适合希望掌握STM32F4高性能数据采集与频谱分析流程的中高级开发者参考借鉴。1. 用定时器触发 ADC 双通道采样为什么频域测量比时域过零靠谱如果你用 STM32F4 做过音频或者振动信号测频大概率经历过这样的场景时域里读 ADC 值靠过零点数周期信号稍微带点噪声或者谐波读数就开始跳低频段尤其明显。我早期做一台旋转机械的转速监测模块时就翻过车——电机匀速运转串口打印的频率值却在 ±3Hz 之间来回蹦现场调试时被质疑“这采样到底准不准”。后来换成定时器硬件触发 ADC、DMA 搬运、再做 FFT 求峰值 bin 的方案同样的信号源频率读数稳定在 ±0.1Hz 以内。核心思路是用 TIM 的更新事件或比较事件作为 ADC 的外部触发源让采样时刻由硬件严格等间隔产生ADC 双通道交替或同步采样DMA 把数据直接搬到内存缓冲区攒够一帧后做 FFT在频域找峰值对应的频率。采样频率通过改定时器 ARR/PSC 实现可变测完还能把波形推到显示屏上。这套方案适合谁做电机转速监测、音频音高检测、振动分析、超声波测距回波频率估计的嵌入式工程师手上有 STM32F4 系列F401/F407/F411/F429 都行需要几十 Hz 到几十 kHz 范围内的频率测量且对实时性和精度有要求。下面按“原理选型 → 硬件配置 → 代码实现 → 参数计算 → 避坑 → 进阶”的顺序拆开讲每一步都给可复现的配置和代码。2. 定时器触发 ADC DMA FFT 的链路怎么搭2.1 为什么不用软件触发和中断搬运软件触发 ADC 的问题是采样时刻受 CPU 执行流影响中断响应延迟、其他中断抢占都会让采样间隔抖动。假设你要采 10kHz 信号采样率设 100kHz周期 10μs一次中断延迟 1μs 就是 10% 的时序误差FFT 出来的频谱会散开峰值定位精度直接崩掉。定时器硬件触发的好处是触发信号由计数器比较/更新事件产生精度取决于定时器时钟源不受 CPU 状态影响。STM32F4 的 TIM1/TIM2/TIM3/TIM4/TIM5/TIM8 都能输出 TRGO 触发 ADC常见做法是用 TIM 的更新事件UEV作为 TRGO 源这样每个计数周期产生一次触发。DMA 搬运替代中断搬运的理由更直接ADC 每次转换完成产生 DMA 请求DMA 控制器直接把 DR 寄存器的值写到内存数组CPU 完全不参与。双通道时用 DMA 的循环模式缓冲区大小设为 2×NADC 按规则序列轮流转换 CH0、CH1DMA 依次填 buffer[0]、buffer[1]、buffer[2]……这样 buffer 偶数下标是通道 0奇数下标是通道 1天然分离。2.2 双通道的三种采样模式怎么选STM32F4 的 ADC 支持规则序列多通道扫描配合 DMA 可以实现双通道交替采样。三种常见模式模式配置方式特点适用场景规则序列交替规则序列长度 2CH0 在前 CH1 在后DMA 循环两通道非同时相差一个转换周期两路信号频率相近对相位差不敏感注入规则注入组触发规则组时序复杂一般不用特殊同步需求双重 ADC 同步ADC1ADC2 同时触发真正同时采样需双 ADC 模式需要精确相位差测量大多数测频场景用第一种就够了。注意交替采样时两通道之间有固定的时间差一个 ADC 转换时间如果两路信号要做相位比较这个差值必须补偿。我一般会在代码注释里标清楚这个偏移量避免后面做相位分析时踩坑。2.3 采样率、FFT 点数、频率分辨率的关系这是整个方案最核心的参数链搞错一个后面全乱。关系式采样率 fs 定时器触发频率 TIM_CLK / ((PSC1) × (ARR1))频率分辨率 Δf fs / NN 是 FFT 点数可测最高频率 fmax fs / 2奈奎斯特测频精度 ≈ Δf / 2峰值 bin 定位加插值可以到 Δf / 10 甚至更高举个例子TIM_CLK 84MHzPSC 0ARR 839则 fs 84MHz / 840 100kHz。FFT 点数 N 1024Δf 100000 / 1024 ≈ 97.66Hz。如果被测信号是 1kHz峰值在第 10 或 11 个 bin 附近直接读 bin 频率误差可能到 ±50Hz。这时候必须加插值后面第 6 章会讲。采样率可变的需求通过改 ARR 实现。比如要测 50Hz 工频信号fs 设 2kHz 就够ARR 84MHz / 2kHz - 1 41999。要测 20kHz 音频fs 至少 50kHzARR 1679。实际项目中我会把常用采样率做成一个表运行时根据信号频段切换。3. 从 CubeMX 配置到 DMA 缓冲区的完整实现3.1 定时器 TRGO 配置与 ADC 外部触发绑定先看定时器部分。以 TIM2 为例时钟挂载 APB1F4 系列 APB1 定时器时钟通常是 84MHz。配置成向上计数ARR 决定触发频率PSC 做粗调。// TIM2 初始化产生 TRGO 更新事件触发 ADC void TIM2_Init(uint32_t arr, uint32_t psc) { RCC-APB1ENR | RCC_APB1ENR_TIM2EN; // 使能 TIM2 时钟 TIM2-PSC psc; // 预分频 TIM2-ARR arr; // 自动重装载决定触发频率 TIM2-CR2 ~TIM_CR2_MMS; // 清 MMS 位 TIM2-CR2 | TIM_CR2_MMS_1; // MMS010更新事件作为 TRGO TIM2-EGR | TIM_EGR_UG; // 产生一次更新事件加载寄存器 TIM2-CR1 | TIM_CR1_CEN; // 启动定时器 }关键点是TIM_CR2_MMS_1这一位。MMS 字段三位值 010 表示更新事件作为 TRGO 输出。很多人配成 000复位或者 011比较脉冲导致 ADC 不触发查半天查不出来。配完后用示波器量 TRGO 对应的引脚如果有引出或者直接看 ADC 是否在转换能快速定位。ADC 侧要配置外部触发源为 TIM2_TRGO。F4 的 ADC1 外部触发在 ADC_CR2 的 EXTSEL 字段TIM2_TRGO 对应值 0b0110不同型号略有差异以参考手册为准。// ADC1 初始化双通道规则扫描外部触发DMA 使能 void ADC1_Init(void) { RCC-APB2ENR | RCC_APB2ENR_ADC1EN; RCC-AHB1ENR | RCC_AHB1ENR_DMA2EN; // ADC1 用 DMA2 Stream0 // 通道 0 和通道 1 的 GPIO 配置为模拟输入略按实际引脚配 ADC1-CR1 0; // 独立模式 ADC1-CR2 0; ADC1-CR2 | ADC_CR2_ADON; // 使能 ADC ADC1-CR2 | ADC_CR2_DMA; // 使能 DMA ADC1-CR2 | ADC_CR2_DDS; // DMA 持续请求 ADC1-CR2 | (0x06 24); // EXTSEL TIM2_TRGO ADC1-CR2 | ADC_CR2_EXTEN_0; // 上升沿触发 ADC1-SQR1 (1 20); // 规则序列长度 2 ADC1-SQR3 (0 0) | (1 5); // 第1个转换 CH0第2个 CH1 ADC1-SMPR2 (7 0) | (7 3); // 两通道采样时间 480 周期 }采样时间设 480 周期是为了让高阻抗信号源有足够建立时间。如果信号源阻抗低比如运放输出可以降到 84 或 144 周期提高转换速率。但注意总转换时间 采样时间 12 个 ADC 时钟周期双通道交替时两个转换之间就是这个总时间不能超过定时器触发周期否则会丢触发。3.2 DMA 循环模式配置与双通道数据分离DMA 配置成从 ADC1-DR 搬到内存数组循环模式数据宽度 16 位。#define FFT_N 1024 uint16_t adc_buf[FFT_N * 2]; // 双通道每个通道 FFT_N 个点 void DMA2_Stream0_Init(void) { DMA2_Stream0-CR ~DMA_SxCR_EN; // 先关闭流 while (DMA2_Stream0-CR DMA_SxCR_EN); // 等待关闭完成 DMA2_Stream0-PAR (uint32_t)ADC1-DR; // 外设地址 DMA2_Stream0-M0AR (uint32_t)adc_buf; // 内存地址 DMA2_Stream0-NDTR FFT_N * 2; // 传输数量 DMA2_Stream0-CR (0 25) // 通道 0 | (1 13) // 存储器增量 | (1 10) // 循环模式 | (1 8) // 外设到存储器 | (1 5); // 传输完成中断使能 DMA2_Stream0-CR | DMA_SxCR_EN; // 使能流 }循环模式的好处是 DMA 填满缓冲区后自动从头开始不需要软件重新配置。但这也带来一个问题CPU 什么时候去读缓冲区如果读的时候 DMA 正在写同一块区域数据会撕裂。常见做法是开双缓冲DMA 的 M1AR 寄存器或者用传输完成中断做半传输/全传输标志在中断里置标志位主循环检测标志后再处理。我一般用半传输中断DMA 填满前一半时触发 HT 中断此时后一半还在被 DMA 写前一半可以安全读取。处理完前一半等全传输中断触发再处理后一半。这样 CPU 和 DMA 交替访问不会冲突。3.3 FFT 前的数据预处理与 CMSIS-DSP 调用ADC 出来的是 12 位无符号数0~4095中心值 2048。做 FFT 前要去直流分量否则频谱在 0Hz 处有个巨大峰值可能淹没低频信号。去直流有两种做法硬件上在信号调理电路加隔直电容软件上减去均值。#include arm_math.h arm_rfft_fast_instance_f32 fft_inst; float32_t fft_in[FFT_N]; float32_t fft_out[FFT_N]; float32_t mag[FFT_N / 2]; void Process_FFT(void) { float32_t mean 0; for (int i 0; i FFT_N; i) { mean adc_buf[i * 2]; // 取通道 0 数据 } mean / FFT_N; for (int i 0; i FFT_N; i) { fft_in[i] (float32_t)adc_buf[i * 2] - mean; // 去直流 } arm_rfft_fast_init_f32(fft_inst, FFT_N); arm_rfft_fast_f32(fft_inst, fft_in, fft_out, 0); // 计算幅值fft_out 是打包格式实部在偶数索引虚部在奇数索引 for (int i 0; i FFT_N / 2; i) { float32_t re fft_out[2 * i]; float32_t im fft_out[2 * i 1]; mag[i] sqrtf(re * re im * im); } }CMSIS-DSP 的arm_rfft_fast_f32输出是打包的前 N/2 个复数按实部虚部交替排列。很多人第一次用会直接按顺序读结果频谱完全不对。记住这个打包格式或者用arm_cmplx_mag_f32函数处理。4. 采样频率可变时定时器参数怎么算才不翻车4.1 ARR 和 PSC 的分配策略采样率 fs 由 TIM_CLK / ((PSC1) × (ARR1)) 决定。给定 fsPSC 和 ARR 有无穷多组解。选择原则ARR 尽量大提高频率分辨率。ARR 是 16 位F4 的 TIM2/3/4/5或 32 位TIM1/816 位最大 65535。PSC 尽量小减少分频带来的抖动。实际计算时先定 PSC 0算 ARR TIM_CLK / fs - 1如果 ARR 超过 65535再增大 PSC。比如 fs 1kHzTIM_CLK 84MHzARR 84000 - 1 83999超过 16 位。这时 PSC 取 1ARR 42000 - 1 41999还在范围内。如果 fs 100HzARR 需要 840000PSC 至少取 12ARR 840000 / 13 - 1 ≈ 64614。// 根据目标采样率自动计算 PSC 和 ARR void Calc_Timer_Params(uint32_t tim_clk, uint32_t fs, uint32_t *psc, uint32_t *arr) { uint32_t p 0; uint32_t a; while (1) { a tim_clk / ((p 1) * fs); if (a 65536) break; // ARR 是 16 位最大 65535 p; } *psc p; *arr a - 1; }这个函数有个边界问题当 fs 不能整除 TIM_CLK 时实际采样率会有偏差。比如 TIM_CLK 84MHz目标 fs 44100Hz算出来 ARR 1904.76取整 1904实际 fs 84MHz / 1905 44094.5Hz偏差 0.01%。对大多数测频场景够用但如果要做精确的音频分析这个偏差会导致频率读数系统性偏移需要在软件里用实际 fs 做补偿。4.2 采样率切换时的缓冲区同步问题运行时改采样率不能直接写 ARR 寄存器。如果定时器正在运行写 ARR 后要等下一个更新事件才生效而且 DMA 可能正在搬运旧采样率下的数据缓冲区里新旧数据混在一起FFT 结果就是错的。正确做法是先关定时器CEN0关 DMA 流改 ARR/PSC重新使能 DMA再开定时器。中间加一个缓冲区清零操作。void Set_Sample_Rate(uint32_t fs) { TIM2-CR1 ~TIM_CR1_CEN; // 关定时器 DMA2_Stream0-CR ~DMA_SxCR_EN; // 关 DMA while (DMA2_Stream0-CR DMA_SxCR_EN); uint32_t psc, arr; Calc_Timer_Params(84000000, fs, psc, arr); TIM2-PSC psc; TIM2-ARR arr; TIM2-EGR | TIM_EGR_UG; // 立即加载新参数 memset(adc_buf, 0, sizeof(adc_buf)); // 清缓冲区 DMA2_Stream0-NDTR FFT_N * 2; // 重设传输数量 DMA2_Stream0-CR | DMA_SxCR_EN; // 开 DMA TIM2-CR1 | TIM_CR1_CEN; // 开定时器 }注意TIM_EGR_UG这一句它产生一次软件更新事件把 PSC 和 ARR 的值立即加载到影子寄存器。如果不加这句新参数要等到下一次自然更新事件才生效中间会有一段用旧参数采的数据。4.3 频率分辨率不够时的补零与插值选择FFT 点数固定时Δf fs / N。如果 fs 高、N 小Δf 就大。比如 fs 100kHzN 1024Δf ≈ 97.66Hz测 1kHz 信号误差可能到 ±50Hz。两种改善方式补零在 FFT_N 个数据后面补零到 2×FFT_N 或 4×FFT_N再做 FFT。补零不增加真实频率分辨率但会让频谱曲线更平滑峰值 bin 的位置更接近真实频率。代价是计算量增加。插值在峰值 bin 附近用抛物线插值或者 Jacobsen 插值估算真实频率。计算量小精度提升明显。我一般用抛物线插值公式简单效果够用。// 抛物线插值在峰值 bin 附近估算真实频率 float Estimate_Freq(float *mag, int peak_bin, float fs, int N) { if (peak_bin 0 || peak_bin N/2 - 1) return 0; float a mag[peak_bin - 1]; float b mag[peak_bin]; float c mag[peak_bin 1]; float delta 0.5f * (a - c) / (a - 2 * b c); // 偏移量 return (peak_bin delta) * fs / N; }这个公式假设峰值附近三个点的幅值符合抛物线分布实际信号加窗后基本满足。如果信号信噪比很低插值结果可能反而变差这时候要加窗汉宁窗或汉明窗抑制频谱泄漏。5. 这套链路里最容易翻车的五个地方5.1 ADC 触发不工作DMA 一个数都不搬现象程序跑起来adc_buf 里全是 0 或者初始值DMA 传输完成中断从来不触发。原因最常见的是 TIM 的 TRGO 没配对或者 ADC 的 EXTSEL 选错了触发源。F4 的 ADC 外部触发源有十几个TIM2_TRGO 只是其中一个不同 ADCADC1/2/3的 EXTSEL 编码还不一样。另一个常见原因是 ADC 的 EXTEN 位没使能触发沿选择错误。解决先确认 TIM 的 CR2 寄存器 MMS 字段是 010。再查参考手册的 ADC 外部触发表确认 EXTSEL 值。最后用调试器看 ADC 的 SR 寄存器如果 EOC 位一直不置位说明触发根本没进来。我一般会在 TIM 的 TRGO 输出引脚上挂示波器直接看有没有脉冲比查寄存器快。5.2 双通道数据错位通道 0 的数据跑到通道 1现象FFT 分析通道 0 的数据结果里混入了通道 1 的信号频率。原因DMA 缓冲区索引搞错了。双通道交替采样时buffer[0] 是 CH0buffer[1] 是 CH1buffer[2] 是 CH0……如果代码里按adc_buf[i]取通道 0 数据实际取到的是交替的 CH0 和 CH1。正确写法是adc_buf[i * 2]取 CH0adc_buf[i * 2 1]取 CH1。解决在代码里用宏定义明确通道偏移避免手写索引出错。#define CH0_OFFSET 0 #define CH1_OFFSET 1 #define GET_CH0(i) adc_buf[(i) * 2 CH0_OFFSET] #define GET_CH1(i) adc_buf[(i) * 2 CH1_OFFSET]5.3 采样率改了但 FFT 频率读数没变现象调用 Set_Sample_Rate 改了 fs但 FFT 算出来的频率还是旧值。原因FFT 计算时用的 fs 变量没有同步更新。很多代码里 fs 是个全局变量Set_Sample_Rate 只改了定时器寄存器忘了更新这个全局变量。或者更新了但 FFT 函数里用的是另一个局部变量。解决把 fs 和定时器参数绑在一起管理用一个结构体存当前采样率改的时候一起改。typedef struct { uint32_t psc; uint32_t arr; float fs; } SampleRate_t; SampleRate_t current_rate; void Set_Sample_Rate(uint32_t fs) { Calc_Timer_Params(84000000, fs, current_rate.psc, current_rate.arr); current_rate.fs 84000000.0f / ((current_rate.psc 1) * (current_rate.arr 1)); // ... 后续配置 }注意这里用实际算出来的 fs而不是目标 fs避免整除偏差导致频率读数系统性偏移。5.4 FFT 峰值找错谐波比基波还高现象测一个 1kHz 正弦波FFT 峰值出现在 2kHz 或 3kHz。原因信号本身谐波失真大或者 ADC 采样时发生了削顶。如果信号幅度超过 ADC 参考电压波形顶部被削平谐波分量会急剧增大。另一个可能是去直流没做干净0Hz 附近的泄漏抬高了低频段峰值搜索算法误判。解决先看时域波形确认没有削顶。如果有调整信号调理电路的增益。去直流用减去均值的方法如果信号有低频漂移加高通滤波。峰值搜索时跳过前几个 bin避开直流泄漏区。5.5 DMA 传输完成中断里做 FFT主循环卡死现象系统运行几秒后死机或者显示屏刷新极慢。原因在 DMA 中断服务函数里直接调用 FFT 计算。FFT 1024 点需要几毫秒中断里做这么长的计算会阻塞其他中断尤其是 systick 和显示屏刷新中断。如果 FFT 函数里还有浮点运算F4 的 FPU 在中断上下文里保存恢复现场也有开销。解决中断里只置标志位主循环检测标志后再做 FFT。volatile uint8_t fft_ready 0; void DMA2_Stream0_IRQHandler(void) { if (DMA2-LISR DMA_LISR_TCIF0) { DMA2-LIFCR DMA_LIFCR_CTCIF0; // 清标志 fft_ready 1; // 只置标志 } } int main(void) { // ... 初始化 while (1) { if (fft_ready) { fft_ready 0; Process_FFT(); Update_Display(); } } }6. 让频率读数再准一个数量级的三个技巧6.1 加窗抑制频谱泄漏FFT 默认假设信号是周期性的如果采样窗口不是信号周期的整数倍边界处会突变频谱泄漏到相邻 bin。加汉宁窗可以把泄漏压下去 30dB 以上。// 汉宁窗系数预计算 float window[FFT_N]; void Init_Window(void) { for (int i 0; i FFT_N; i) { window[i] 0.5f * (1.0f - cosf(2.0f * PI * i / (FFT_N - 1))); } } // 应用窗函数 for (int i 0; i FFT_N; i) { fft_in[i] ((float32_t)GET_CH0(i) - mean) * window[i]; }加窗后峰值幅值会衰减如果要做幅值精确测量需要乘补偿系数。汉宁窗的幅值补偿系数是 2.0。但测频只需要峰值位置幅值衰减不影响频率读数。6.2 用相位差法做精细频率估计如果两路信号是同一个信号源可以用两路之间的相位差来精算频率。原理是信号频率 f两路采样时间差 Δt相位差 Δφ 2πfΔt。测出 Δφ 后反推 f。这个方法在信噪比高的时候精度可以到 Δf 的 1/100。但前提是两路采样时间差 Δt 必须精确已知。前面说过规则序列交替采样时两通道之间有一个 ADC 转换时间的固定延迟这个延迟就是 Δt。如果 ADC 采样时间设 480 周期转换时间 12 周期总共 492 个 ADC 时钟周期。ADC 时钟如果是 21MHzΔt ≈ 23.4μs。这个值需要根据实际配置算准。6.3 显示屏波形刷新的降采样策略F4 的 SPI 或 FSMC 刷屏如果每帧 FFT 后都把 1024 个点推到屏幕刷新率上不去。实际显示波形不需要那么多点屏幕横向像素通常 240~800把 1024 点降采样到屏幕宽度即可。// 把 FFT_N 个时域点降采样到屏幕宽度 void Draw_Waveform(uint16_t *buf, int n, int screen_width) { int step n / screen_width; if (step 1) step 1; for (int x 0; x screen_width; x) { int idx x * step; if (idx n) idx n - 1; int y buf[idx] * SCREEN_HEIGHT / 4096; // 12 位 ADC 映射到屏幕高度 LCD_DrawPixel(x, y); } }降采样会丢失高频细节但显示波形主要是给人看趋势和大致形状不影响 FFT 测频精度。如果要做波形细节分析可以只显示缓冲区的一部分比如前 256 个点。最后说一个我自己的习惯每次改采样率或者 FFT 点数后先用信号发生器输入一个已知频率的正弦波比如 1kHz看读数对不对。确认无误再测实际信号。这个步骤花不了两分钟但能省掉后面大量排查时间。希望帮到你。本文还有配套的精品资源点击获取