☰
ADC+TMR+DMA+FFT测频方案:嵌入式高精度频率测量实战
2026/11/4 10:14:38 网站建设 项目流程

简介:面向嵌入式开发者的AT32F413正弦波频率测量实战工程,聚焦ADC采样、TMR定时控制、DMA搬运与FFT频谱分析的技术链路,适合已具备单片机外设编程基础、希望深入信号处理应用的中高级学习者。资源包共560个文件,约6.33MB,以C源码与头文件为主体,辅以工程配置、链接脚本、编译中间文件及说明文档,完整呈现从外设驱动到DSP库调用的工程组织方式。已有429人学习下载。读者可从中获得ADC+TMR+DMA协同采样的配置思路、基于DSP库FFT函数完成频域分析的实现参考,以及定时器触发采样率设定、数据传输链路搭建等关键环节的代码范例,便于对照移植到自身项目并排查采样精度与频谱分辨率相关问题。

1. 用 ADC+TMR+DMA+FFT 测正弦波频率:这套方案到底值不值得拆

手里有一块 AT32F413,想测一路正弦波的频率,最容易想到的是过零比较加输入捕获,硬件简单、代码也短。但真把信号源接上去,稍微带点噪声或者谐波,捕获到的边沿就开始抖,读数跳得让人怀疑人生。这套资源走的是另一条路:ADC 采样、TMR 定时触发、DMA 搬运、FFT 变换,四件事串成一条流水线,把时域波形直接换成频域峰值。它解决的不是“能不能测”,而是“在噪声和干扰下还能不能稳”。适合已经能点灯、能跑串口,想往信号处理方向迈一步的嵌入式从业者,也适合做课程设计、要把测量精度写进报告的人。下面按我拆包的顺序讲,先立住原理,再落到能抄的配置。

2. 采样链路怎么搭:TMR 触发 ADC、DMA 搬运的时序账

2.1 为什么不用软件触发采样

软件触发采样看着简单,循环里调一次启动转换、等一次标志位,但采样间隔完全取决于 CPU 当时在干什么。中断一进来,间隔就歪了,FFT 最怕的就是这个——它默认你送进去的 N 个点是在均匀时间间隔上采的,间隔一抖,频谱就糊,峰值展宽,频率读数跟着漂。这套方案用 TMR 的更新事件去触发 ADC,采样节拍由硬件定时器给,CPU 只负责在 DMA 搬满一帧之后处理数据,中间不插手。这是整条链路能稳的第一块基石。

具体到 AT32F413,常见做法是把 TMR 配成 PWM 输出模式但不使能输出引脚,只借用它的更新事件作为触发源,通过内部触发线连到 ADC 的外部触发输入。这样采样率就等于定时器的更新频率,改采样率就是改 ARR 和 PSC 两个寄存器,不用动 ADC 配置。我一般会先算清楚目标采样率,再倒推这两个值,而不是先随便填一个再拿示波器去凑。

2.2 定时器参数怎么倒推

假设要测的是 50Hz 到 2kHz 的正弦波,按 FFT 的常识,采样率至少要是最高频率的 2 倍,实际工程里取 10 倍以上才舒服。这里取采样率 20kHz,采样点数 1024,那么一帧数据覆盖的时间是 1024/20000 = 51.2ms,频率分辨率是 20000/1024 ≈ 19.5Hz。这个分辨率意味着相邻两个频点的间隔接近 20Hz,测 1kHz 附近的信号,量化误差就有十几赫兹。想把分辨率压下去,要么加点数,要么降采样率,两者互相牵制,这是后面避坑章要重点说的账。

定时器这边,如果系统时钟 120MHz,预分频 PSC 取 0,那么 ARR = 120000000/20000 - 1 = 5999。配置成向上计数、更新事件触发,代码大致是这样:

// TMR3 作为 ADC 采样触发源,更新频率 = 20kHz tmr_base_config_t tmr_init; tmr_init.clock_div = TMR_CLOCK_DIV1; tmr_init.count_mode = TMR_COUNT_TWO_WAY_1; // 向上计数 tmr_init.period = 5999; // ARR,决定采样率 tmr_init.division = 0; // PSC tmr_base_init(TMR3, &tmr_init); // 只产生更新事件,不驱动引脚 tmr_output_config_t out_cfg; out_cfg.oc_mode = TMR_OUTPUT_CONTROL_PWM_MODE_A; out_cfg.oc_polarity = TMR_OUTPUT_ACTIVE_HIGH; out_cfg.oc_idle_state = FALSE; out_cfg.oc_output_state= FALSE; // 关键:不使能输出 tmr_output_channel_config(TMR3, TMR_SELECT_CHANNEL_1, &out_cfg); tmr_counter_enable(TMR3, TRUE);

这段的关键在oc_output_state = FALSE,很多人抄例程时把这句漏了,结果定时器通道真的往引脚上打方波,虽然不影响触发,但白白多一路干扰源。ARR 和 PSC 的算法是更新频率 = 时钟 / ((PSC+1)*(ARR+1)),改采样率只动这两个数,别去改 ADC 的分频,那是另一套时钟树。

2.3 ADC 与 DMA 的配对

ADC 这边要配成外部触发、连续转换关闭、DMA 循环模式。触发源选 TMR3 的更新事件,转换顺序只留一个通道,采样时间给足——正弦波源阻抗如果不低,采样时间太短会导致采样保持电容充不满,波形顶部被削平,FFT 出来的谐波分量会骗你。我一般先把采样时间设到中等偏长,比如 41.5 个 ADC 时钟周期,再根据实际波形微调。

DMA 配置成从 ADC 数据寄存器搬到内存数组,循环模式,传输宽度半字,传输数量等于 FFT 点数。这里有个细节:DMA 的循环模式和 FFT 的帧处理要配合好,常见做法是开 DMA 传输完成中断,在中断里把这一帧标记为就绪,主循环里做 FFT,做完再清标志。不要在中断里直接跑 FFT,1024 点的浮点 FFT 在 120MHz 的 M4 上也要几毫秒,中断里干这个,别的中断全被拖住。

// ADC1 通道0,TMR3 更新事件触发,DMA 循环搬运 1024 点 adc_base_config_t adc_cfg; adc_cfg.sequence_mode = FALSE; adc_cfg.repeat_mode = FALSE; adc_cfg.data_align = ADC_DATA_ALIGN_RIGHT; adc_cfg.external_trig = TRUE; adc_cfg.ext_trigger_sel= ADC_EXT_TRIG_TMR3_CC1; adc_cfg.ext_trigger_edge = ADC_EXT_TRIG_EDGE_RISING; adc_base_config(ADC1, &adc_cfg); adc_ordinary_conversion_config_t ch_cfg; ch_cfg.channel = ADC_CHANNEL_0; ch_cfg.sample_time = ADC_SAMPLETIME_41_5; adc_ordinary_channel_set(ADC1, &ch_cfg, 1); dma_init_struct.direction = DMA_DIR_MEMORY_TO_PERIPHERAL; // 注意方向按外设到内存写 dma_init_struct.memory_base_addr = (uint32_t)adc_buf; dma_init_struct.buffer_size = 1024; dma_init_struct.peripheral_base_addr = (uint32_t)&ADC1->odt; dma_init_struct.loop_mode_enable = TRUE; dma_init_struct.memory_data_width = DMA_MEMORY_DATA_WIDTH_HALFWORD; dma_init_struct.peripheral_data_width = DMA_PERIPHERAL_DATA_WIDTH_HALFWORD; dma_init(ADC_DMA_CHANNEL, &dma_init_struct);

方向那一行我故意留了个注释,因为不同库的枚举命名容易让人写反,写反了现象是数组里全是 0 或者固定值,排查时先看这里。DMA 配好之后,ADC 的普通通道转换使能、DMA 使能、定时器启动,顺序别乱:先开 DMA,再开 ADC,最后启动定时器,否则前几个触发事件会丢,第一帧数据头部是脏的。

3. FFT 这一环:DSP 库怎么接、窗函数和点数怎么定

3.1 定点还是浮点,先看芯片

AT32F413 是 Cortex-M4F 内核,带单精度浮点单元,所以直接用 DSP 库里的浮点 FFT 函数是划算的。如果换成没有 FPU 的 M0/M3,就得走定点 Q15 那套,还得自己管缩放,否则中间结果溢出,频谱全是假的。这套资源用浮点,代码里arm_rfft_fast_f32这类接口是常见选择,输入实数序列,输出复数频谱,长度 1024 时只算一半,省一半时间。

接库之前先确认工程里链接了对应的 DSP 库文件,头文件路径加上,编译选项里把 FPU 打开(-mfpu=fpv4-spd之类,具体看工具链)。我见过有人库加了、头文件也包含了,就是链接报一堆未定义,最后发现是 FPU 没开,浮点指令被软模拟,函数签名对不上。

3.2 点数、窗函数和分辨率的三方拉扯

点数不是越多越好。1024 点在 20kHz 采样率下分辨率约 19.5Hz,2048 点降到约 9.8Hz,但一帧时间翻倍到 102ms,频率变化的信号在这段时间里已经变了,测出来的是平均值。测稳态正弦波可以往大里取,测扫频或者频率抖动的信号,点数要克制。

窗函数的选择直接影响峰值定位。矩形窗主瓣窄、旁瓣高,信号干净时频率读数最准,但有一点噪声或者非整周期截断,旁瓣就把邻近频点抬起来。汉宁窗主瓣宽一倍,旁瓣压得低,抗泄漏好,代价是峰值展宽,找峰时要多做一个插值。我的习惯是默认上汉宁窗,只有在确认信号非常干净、且要求分辨率极限时才换矩形窗。

#define FFT_LEN 1024 float adc_buf[FFT_LEN]; float fft_in[FFT_LEN]; float fft_out[FFT_LEN]; arm_rfft_fast_instance_f32 fft_inst; void fft_prepare(void) { arm_rfft_fast_init_f32(&fft_inst, FFT_LEN); } void fft_process(void) { // 1. 去直流:正弦波叠加的偏置会在 0 频点形成大峰,干扰找峰 float mean = 0.0f; for (int i = 0; i < FFT_LEN; i++) mean += adc_buf[i]; mean /= FFT_LEN; // 2. 加汉宁窗并归一化到 [-1,1] for (int i = 0; i < FFT_LEN; i++) { float w = 0.5f - 0.5f * arm_cos_f32(2.0f * PI * i / (FFT_LEN - 1)); fft_in[i] = (adc_buf[i] - mean) / 2048.0f * w; } // 3. 实数 FFT,输出交错存放的复数 arm_rfft_fast_f32(&fft_inst, fft_in, fft_out, 0); // 4. 求幅度谱,只取前一半 float mag[FFT_LEN / 2]; arm_cmplx_mag_f32(fft_out, mag, FFT_LEN / 2); // 后续找峰... }

去直流那一步别省。ADC 的偏置、运放的单电源抬升,都会在 0 频点堆一个巨大的峰,找峰算法如果从索引 0 开始扫,第一个超过阈值的点就是它,频率直接读成 0。归一化除以 2048 是按 12 位 ADC 满量程折半来的,如果你的参考电压或者前端增益不同,这个系数要跟着改,否则幅度阈值不好定。

3.3 找峰和频率换算

幅度谱出来之后,找最大值所在的索引 k,频率就是k * 采样率 / FFT_LEN。但直接取整数索引,误差最大能到半个频点,也就是接近 10Hz。想再准一点,在峰值附近做抛物线插值,用 k-1、k、k+1 三个点的幅度拟合一个二次曲线,顶点位置就是修正后的索引。这一步不增加多少计算量,精度能提升一个档次,报告里写出来也好看。

// 抛物线插值修正峰值索引 float y0 = mag[k - 1], y1 = mag[k], y2 = mag[k + 1]; float delta = 0.5f * (y0 - y2) / (y0 - 2.0f * y1 + y2); float freq = (k + delta) * SAMPLE_RATE / FFT_LEN;

分母接近 0 的时候要加保护,说明三个点几乎等高,插值没有意义,直接退回整数索引。这个保护不做,偶尔会算出离谱的频率值,现场调试时很难复现,属于典型的玄学 bug。

4. 避坑与排查:这套链路最容易翻车的五个地方

4.1 频率读数总是偏一点,换个信号源又好了

现象是测某个固定频率时读数稳定地偏几赫兹,换一台信号源就正常。原因多半是信号源和采样时钟不同源,两者各自的晶振有偏差,FFT 测的是相对采样率的频率,采样率本身不准,读数就系统性偏移。解决方法是先用一个已知准确频率做单点校准,算出修正系数,或者干脆用同一个时钟源给信号发生器和板子。别一上来就怀疑 FFT 算法,先排除时钟。

4.2 频谱上出现不该有的峰,频率是信号频率的整数倍

现象是主峰之外还有 2 倍、3 倍频率的峰,幅度还不低。原因通常是 ADC 采样时间不够,采样保持电容没充到位,波形被削顶,削顶就是非线性,非线性就产生谐波。也可能是前端运放已经进入非线性区。解决方法是加长采样时间、检查前端偏置和增益,用示波器看 ADC 引脚上的实际波形,别只看信号源输出。

4.3 DMA 搬完一帧,数组里前半段是旧数据

现象是 FFT 结果时好时坏,像是数据没更新全。原因是 DMA 循环模式和主循环处理之间存在竞争:主循环还在读数组做 FFT,DMA 已经把下一帧的前半段写进去了。解决方法是双缓冲,DMA 配成半传输和全传输两个中断,一个缓冲在处理时另一个在接收,或者处理前先关 DMA、处理完再开,代价是丢一帧。测稳态信号丢一帧无所谓,测瞬态就得老老实实双缓冲。

4.4 加了窗函数之后,幅度阈值怎么调都不对

现象是找峰阈值要么把真峰漏掉,要么把噪声当峰。原因是加窗之后信号能量被摊到主瓣上,峰值幅度下降,原来按矩形窗定的阈值全部失效。解决方法是阈值不要用绝对值,改用“最大值的一个比例”,比如取全谱最大值的 30%,再配合一个最小索引跳过直流附近。这样换窗函数、换增益都不用重调阈值。

4.5 中断里跑 FFT,串口开始丢数据

现象是 FFT 一开,别的外设就不正常。原因是 FFT 计算时间长,放在高优先级中断里把整个系统拖住。解决方法是中断只做标记,FFT 放主循环,主循环里用状态机判断“有新帧且上一帧已处理完”才启动计算。如果主循环还有其他耗时任务,考虑把 FFT 放到低优先级任务里,别和通信抢时间。

5. 把精度再压一压:校准、验证和几个能立刻用上的技巧

前面搭起来的是能跑的链路,但“能跑”和“测得准”之间还差一层校准。我一般会做两件事:一是用已知频率的信号源扫几个点,记录每个点的读数误差,看误差是常数偏移还是随频率变化。常数偏移多半是采样时钟偏差,乘个修正系数就回来了;随频率变化的误差,往往是窗函数泄漏或者插值没做好,得回头查算法。二是把 FFT 读数和输入捕获的读数并排对比,两者在信号干净时应该很接近,差得多说明其中一路有问题,这种交叉验证比单看一个数字靠谱。

采样率和点数的组合值得单独做一张表放在手边,测不同频段时直接查,不用每次现算:

目标频段采样率点数分辨率单帧时间
10–100Hz1kHz20480.49Hz2.05s
50–500Hz5kHz20482.44Hz0.41s
200–2kHz20kHz102419.5Hz51ms
1–10kHz50kHz204824.4Hz41ms

这张表里的单帧时间是硬约束,测低频时帧时间长,信号如果有漂移,读数就是这段时间的平均,别指望它能跟上快速变化。另一个常被忽略的点是 ADC 的有效位数,12 位是标称,实际有效位数可能只有 10 位出头,噪声底决定了你能分辨多小的峰,找峰阈值不要设得比噪声底还低。

还有一个立刻能用上的技巧:如果只关心单一频率附近,不必每次做全谱 FFT,可以对这一小段做 Zoom FFT 或者直接做 Goertzel,计算量小一个量级,在资源紧张的场合很实用。不过这是另一条路,先把全谱这套跑通、验证过,再考虑替换。

从那以后我每次接这类测量任务,都强制先用信号源扫三个频点、把误差表记下来再谈精度,不校准直接看读数的亏我吃过不止一次。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询