简介:这份资源面向嵌入式开发工程师与信号处理学习者,围绕AT32F413单片机给出正弦波频率测量的完整工程实现,解决从模拟采样到频域分析的链路搭建问题。方案串联ADC采样、TMR定时控制采样率、DMA搬运数据与DSP库FFT频谱分析,可用于工业数据采集、仪器仪表等需要实时频率检测的场景,适合具备一定外设编程基础的中级开发者参考。压缩包共560个文件、约6.33MB,以C源码与头文件为主体,含外设驱动、DSP库函数与FFT算法实现,另附工程配置、链接脚本、编译中间文件及说明文档,便于直接编译与二次移植。已有429人学习下载。读者可从中获得ADC+TMR+DMA协同采样的配置思路、FFT频谱峰值识别频率的完整代码路径,以及DSP库在AT32平台上的调用范例,为同类频率测量项目提供可复用的工程骨架与调试参照。
1. 从一块板子测频率说起:AT32F413 上 ADC+TMR+DMA+FFT 的完整链路
手头有块 AT32F413 的板子,信号发生器输出一路正弦波,想测出它的频率,精度还要能看。很多人第一反应是拿定时器输入捕获测周期,方波好使,正弦波就得先过比较器整形,信号幅度一变过零点就飘。这个项目走的是另一条路:ADC 采样、定时器触发、DMA 搬运、DSP 库做 FFT,从频谱里找峰值频率。整条链路不依赖外部整形电路,对正弦波、三角波这类连续信号更友好,适合做音频、振动、电源纹波这类中低频测量。下面按「能干什么 → 怎么跑 → 怎么改」把这份工程拆开讲,重点落在 ADC+TMR+DMA 的触发时序和 FFT 的频率换算上,这两处是最容易翻车的地方。
2. 技术选型与工程结构:为什么是 ADC+TMR+DMA+FFT 这套组合
2.1 四种测频方案对比,为什么选 FFT
测正弦波频率,常见做法有四种,各有各的适用边界。把对比摆出来,选型逻辑就清楚了。
| 方案 | 原理 | 优点 | 局限 |
|---|---|---|---|
| 定时器输入捕获 | 测两个上升沿间隔 | 实现简单、单周期精度高 | 需整形为方波,幅度变化影响过零点 |
| 外部比较器 + 捕获 | 硬件过零检测 | 响应快 | 多一颗器件,阈值需调 |
| ADC + 软件过零 | 采样后找符号翻转 | 无需外部器件 | 噪声敏感,低频段误差大 |
| ADC + FFT | 频域找峰值 | 抗噪好、可看谐波、多频分量可分 | 需 DSP 库、有频率分辨率限制 |
这个项目选 FFT,核心原因是它同时给出幅频信息,能看出信号里有没有谐波和干扰,对调试阶段判断信号质量很有用。代价是要接受频率分辨率 = 采样率 / FFT 点数的硬约束,后面会专门讲怎么用插值把这个分辨率再往上抬。
2.2 工程目录与模块职责
工程按 AT32 官方固件库的组织方式来,目录结构大致如下,读代码前先建立这张地图,找文件会快很多。
at32f413_fft_freq/ ├── libraries/ │ ├── cmsis/ # 内核与外设寄存器定义 │ ├── drivers/ # AT32 标准外设库(adc/tmr/dma/usart) │ └── dsp/ # CMSIS-DSP 库,提供 arm_rfft_fast_f32 等 ├── project/ │ ├── inc/ │ │ ├── adc_sample.h # 采样配置接口 │ │ ├── fft_process.h # FFT 与频率计算接口 │ │ └── bsp_usart.h # 串口打印 │ └── src/ │ ├── main.c # 初始化与主循环 │ ├── adc_sample.c # ADC+TMR+DMA 触发采样 │ ├── fft_process.c # 加窗、FFT、峰值搜索、插值 │ └── bsp_usart.c # 结果输出 └── mdk/ 或 cmake/ # 工程文件模块划分很干净:adc_sample.c只负责把数据搬进缓冲区,fft_process.c只负责算,main.c负责串起来。二次开发时想换采样率只动前者,想换算法只动后者,互不牵连。这种「采集与计算分离」的写法值得保留,后面改代码时别把两件事揉一起。
2.3 关键参数先定下来
动手前先把几个数定死,它们决定了整个系统的量程和精度。假设目标测 100 Hz ~ 10 kHz 的正弦波:
- 采样率 fs:按奈奎斯特,至少 2 倍最高频率,工程上取 4~10 倍更稳,这里取 20 kHz。
- FFT 点数 N:取 1024,则频率分辨率 Δf = fs / N = 20000 / 1024 ≈ 19.5 Hz。
- 定时器触发频率:等于 fs,即每 50 µs 触发一次 ADC。
- ADC 采样时间:要小于触发间隔,留足转换余量。
这几个数在代码里是联动的,改一个要回头核对另外几个,血泪经验是先把这张表算清楚再写代码,比写完再调省事得多。
3. 采集链路落地:TMR 触发 ADC、DMA 搬运的完整配置
3.1 定时器产生精确采样节拍
采样率准不准,直接决定频率算得准不准。用定时器溢出事件触发 ADC,比软件延时靠谱得多。以 TMR3 为例,系统时钟 120 MHz,要 20 kHz 触发,则计数周期 = 120000000 / 20000 = 6000。
void tmr3_sample_init(void) { tmr_base_init_type base; /* 120MHz / 6000 = 20kHz 触发频率 */ tmr_base_struct_init(&base); base.period = 6000 - 1; /* 计数到 5999 溢出 */ base.clock_division = TMR_CLOCK_DIV1; base.count_mode = TMR_COUNT_UP; tmr_base_init(TMR3, &base); /* 溢出事件作为 ADC 外部触发源 */ tmr_trigger_adc_set(TMR3, TMR_SELECT_ADC_TRIGGER_OVERFLOW); tmr_counter_enable(TMR3, TRUE); }period决定触发间隔,改采样率就改这个值,同时记得回头改 FFT 里的fs常量,两处必须一致,这是最常见的对不上号来源。TMR_SELECT_ADC_TRIGGER_OVERFLOW把溢出事件路由到 ADC 触发线,具体触发源编号要对照芯片手册的触发映射表,不同定时器通道对应的宏不一样,抄代码时别照搬。
3.2 ADC 配置为外部触发 + DMA 循环
ADC 要设成外部触发启动、连续转换交给 DMA,CPU 全程不参与搬运。
#define FFT_N 1024 uint16_t adc_buf[FFT_N]; /* DMA 目标缓冲区 */ void adc_dma_init(void) { adc_base_config_type adc_cfg; dma_init_type dma_cfg; /* DMA1 通道1 搬运 ADC1 数据,循环模式 */ dma_reset(DMA1_CHANNEL1); dma_default_para_init(&dma_cfg); dma_cfg.direction = DMA_DIR_PERIPHERAL_TO_MEMORY; dma_cfg.buffer_size = FFT_N; dma_cfg.peripheral_inc_enable = FALSE; dma_cfg.memory_inc_enable = TRUE; dma_cfg.peripheral_data_width = DMA_PERIPHERAL_DATA_WIDTH_HALFWORD; dma_cfg.memory_data_width = DMA_MEMORY_DATA_WIDTH_HALFWORD; dma_cfg.loop_mode_enable = TRUE; /* 循环,采满自动回卷 */ dma_init(DMA1_CHANNEL1, &dma_cfg); dma_channel_enable(DMA1_CHANNEL1, TRUE); /* ADC1 外部触发,由 TMR3 溢出事件启动 */ adc_base_default_para_init(&adc_cfg); adc_cfg.sequence_mode = FALSE; adc_cfg.external_trigger = ADC_EXTERNAL_TRIGGER_TMR3_OVERFLOW; adc_cfg.data_align = ADC_DATA_ALIGN_RIGHT; adc_base_init(ADC1, &adc_cfg); adc_dma_mode_enable(ADC1, TRUE); adc_interrupt_enable(ADC1, ADC_DMA_CCE_INT, TRUE); /* 采满一轮中断 */ adc_enable(ADC1, TRUE); }loop_mode_enable = TRUE让 DMA 采满 1024 点后自动从头覆盖,配合 ADC 的 DMA 完成中断,每采满一轮就置一个标志位,主循环看到标志再去做 FFT。buffer_size必须等于 FFT 点数,否则算出来的频谱和实际采样率对不上。数据宽度用半字,因为 AT32F413 的 ADC 是 12 位,右对齐存在 16 位里。
3.3 主循环的采集与计算节奏
采集和计算要解耦,否则 FFT 算的时候采样就断了。常见做法是双缓冲或者「采满一轮 → 停 → 算 → 再开」,这个项目点数不多,用后者更简单。
volatile uint8_t fft_ready = 0; void adc_dma_cplt_isr(void) /* ADC DMA 完成中断 */ { fft_ready = 1; } int main(void) { system_clock_config(); tmr3_sample_init(); adc_dma_init(); usart_init(115200); while (1) { if (fft_ready) { fft_ready = 0; float freq = fft_calc_freq(adc_buf, FFT_N, 20000.0f); printf("freq = %.2f Hz\r\n", freq); } } }fft_calc_freq的第三个参数就是采样率,必须和定时器配置一致。中断里只置标志,不做重活,这是嵌入式里保命的习惯。如果发现频率读数跳动大,先查这里是不是在中断里干了太多事导致采样节拍被拖慢。
4. FFT 计算与频率换算:从 1024 点频谱到 Hz
4.1 加窗与实数 FFT
直接对矩形截断的数据做 FFT,频谱泄漏会让峰值变胖,找峰不准。工程上一般加汉宁窗。CMSIS-DSP 提供arm_rfft_fast_f32,专做实数序列,比复数 FFT 省一半算力。
#include "arm_math.h" #include "arm_const_structs.h" static float fft_in[FFT_N]; static float fft_out[FFT_N]; static arm_rfft_fast_instance_f32 fft_inst; void fft_init(void) { arm_rfft_fast_init_f32(&fft_inst, FFT_N); } /* 汉宁窗,预先生成表更省事,这里现场算 */ static void apply_hanning(float *buf, uint32_t n) { for (uint32_t i = 0; i < n; i++) { float w = 0.5f - 0.5f * arm_cos_f32(2.0f * PI * i / (n - 1)); buf[i] *= w; } }arm_rfft_fast_f32的输出是打包格式:fft_out[0]是直流,fft_out[1]是奈奎斯特分量,之后实部虚部交替存放。算幅值时要从索引 2 开始按对取,这一点新手经常搞错,直接把fft_out当连续幅值用,结果全乱。
4.2 幅值谱与峰值搜索
float fft_calc_freq(uint16_t *raw, uint32_t n, float fs) { /* 1. 转浮点并去直流 */ float mean = 0; for (uint32_t i = 0; i < n; i++) mean += raw[i]; mean /= n; for (uint32_t i = 0; i < n; i++) fft_in[i] = (float)raw[i] - mean; /* 2. 加窗后做实数 FFT */ apply_hanning(fft_in, n); arm_rfft_fast_f32(&fft_inst, fft_in, fft_out, 0); /* 3. 计算幅值,跳过直流,只搜前一半(奈奎斯特内) */ uint32_t half = n / 2; float max_mag = 0; uint32_t max_idx = 0; for (uint32_t k = 1; k < half; k++) { float re = fft_out[2 * k]; float im = fft_out[2 * k + 1]; float mag = sqrtf(re * re + im * im); if (mag > max_mag) { max_mag = mag; max_idx = k; } } /* 4. 频率 = 峰值索引 * 分辨率 */ return (float)max_idx * fs / (float)n; }去直流这一步不能省,否则直流分量会盖过信号峰。搜索范围限定在1 ~ n/2,因为实数 FFT 的后半段是前半段的镜像,搜了也白搜。max_idx * fs / n就是最朴素的频率换算,分辨率 19.5 Hz,意味着 1000 Hz 的信号可能读成 996 或 1015,误差肉眼可见。
4.3 用抛物线插值把精度抬上去
要突破分辨率限制,最省事的办法是对峰值附近三点做抛物线插值,估计真实峰位置。
/* 在 max_idx 附近做三点抛物线插值,返回亚 bin 偏移 */ static float parabolic_interp(float m0, float m1, float m2) { float denom = (m0 - 2.0f * m1 + m2); if (fabsf(denom) < 1e-9f) return 0.0f; return 0.5f * (m0 - m2) / denom; /* 偏移量,范围约 -0.5~0.5 */ }把max_idx-1、max_idx、max_idx+1三点的幅值传进去,得到偏移delta,最终频率 =(max_idx + delta) * fs / n。这一步能把误差从半个 bin 压到十分之一个 bin 量级,实测 1 kHz 信号能稳到几 Hz 以内。注意插值只在峰值明显高于旁瓣时有效,信号太弱或两峰靠太近时反而会引入偏差,加个幅值门限判断更稳。
5. 避坑与排查:这套链路最容易翻车的五个地方
5.1 频率读数整体偏一个固定比例
现象:测 1 kHz 读出来 980 或 1020,比例固定。原因:定时器实际触发频率和代码里传的fs不一致,多半是系统时钟配置改了但period没跟着改,或者fs常量写死没同步。解决:用示波器量一个 GPIO 在采样中断里翻转的波形,实测触发频率,和fs对齐后再看读数。
5.2 频谱峰值出现在错误位置甚至满屏噪声
现象:峰值索引乱跳,幅值谱一片糊。原因:DMA 缓冲区和 FFT 计算缓冲区共用,FFT 算的时候 DMA 还在往里写,数据被撕裂。解决:计算前先关 DMA 或停定时器,算完再开;或者上双缓冲,一个采一个算。这个坑在循环 DMA 模式下特别隐蔽。
5.3 小信号测不出来,大信号反而正常
现象:幅度小的正弦波峰值淹没在噪声里。原因:ADC 参考电压固定,小信号只用到少量量化台阶,信噪比低。解决:前端加一级可调增益放大,或者软件上做多点平均降噪;同时确认加窗后幅值门限别设太高。
5.4 频率接近奈奎斯特时读数翻倍或减半
现象:高频段读数出现镜像。原因:采样率不够或信号含高次谐波混叠。解决:确认fs至少是最高目标频率的 4 倍,前端加抗混叠低通滤波,别指望软件补救。
5.5 串口打印拖慢采样节奏
现象:加了 printf 后频率读数开始漂。原因:printf阻塞时间长,主循环被拖住,DMA 完成标志来不及处理导致丢帧。解决:结果先存变量,用 DMA 串口发送或降低打印频率,别在采集窗口里打印。
6. 二次开发改造:换点数、换窗函数、上双缓冲的实操思路
拿到这份工程,最常改的三处是 FFT 点数、窗函数和采集架构。改点数最直接:把FFT_N从 1024 改成 2048,同步改 DMA 的buffer_size、adc_buf大小和arm_rfft_fast_init_f32的入参,三处必须一起动,漏一处就是内存越界或者频谱错位。分辨率会从 19.5 Hz 降到 9.8 Hz,代价是 RAM 占用翻倍、单次计算时间变长,AT32F413 的 RAM 够不够要提前算。
换窗函数看场景:汉宁窗适合连续正弦、旁瓣低;如果关心幅值绝对精度而不是频率,平顶窗更合适,它的幅值平坦度好但主瓣宽。改的时候只动apply_hanning里的系数,注意窗函数会带来幅值衰减,做幅值标定时要除以窗的相干增益。
采集架构上,如果目标信号频率低、要求连续监测,建议上双缓冲:两个FFT_N大小的缓冲区,DMA 采 A 时 CPU 算 B,用半传输和全传输两个中断切换。改造的关键是把「采满标志」从单缓冲的完成中断,换成半满/全满两个中断,主循环里判断当前该算哪块。
/* 双缓冲切换的核心逻辑示意 */ volatile uint8_t calc_buf_idx = 0; void adc_dma_half_isr(void) { calc_buf_idx = 0; } /* 前半满,算前半 */ void adc_dma_full_isr(void) { calc_buf_idx = 1; } /* 后半满,算后半 */验证改造是否成功,我一般用信号发生器扫几个固定点:100 Hz、1 kHz、5 kHz、9 kHz,每个点读十次看离散度,再看谐波位置对不对。有一次改双缓冲时忘了同步改中断里的缓冲区索引,结果算的永远是上一轮数据,读数滞后一拍,查了半天才发现是索引没跟着切。从那以后我每次动采集架构,都先用一个已知频率的信号把「采到的原始数据」直接打到串口画波形,确认数据流对了再谈算法。希望这套拆解能帮你把板子上的频率先测准,再谈改得漂亮。
本文还有配套的精品资源,点击获取