ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32 ADC采样与FFT频谱分析实战:从关键参数到工程优化

STM32 ADC采样与FFT频谱分析实战:从关键参数到工程优化 简介这是一套基于STM32F10x平台的ADC数据采集与FFT频谱分析完整工程面向嵌入式爱好者、数字信号处理初学者及毕业设计开发者。工程围绕“模拟信号采样—数字转换—FFT变换—液晶屏实时显示”的闭环链路展开可直接编译运行也可作为理解和调试信号处理流程的参考模板。资源共261个文件代码以C源文件与头文件为主包含标准外设库、DAC/ADC驱动及大量编译中间文件.o、.d、.crf同时提供Keil工程配置、汇编启动文件和地图/十六进制输出等压缩包整体10.12MB。目前已有386人学习下载。通过研读该工程读者可掌握STM32的ADC多通道采集、FFT算法调用与频域分析、LCD显示驱动以及工程规范化组织方法适合用于课程设计、电子竞赛备赛或嵌入式信号处理入门。1. ADC 采集和 FFT 放在同一个工程里先想清楚数据怎么走拿到一个 Keil 工程文件名是 FFT.rar但里面躺着 DAC.axf 和 HzLib_65k.c 时第一反应不是去看代码而是去看数据流ADC 把连续电压变成二进制序列FFT 把这段序列拆成频率分量最后搬到液晶屏上。很多人在 STM32F103 上做频谱分析第一步就卡在“怎么让采样率和 FFT 点数对上”第二步卡在“屏上那些毛刺是信号还是噪声”。这个工程的价值在于它把两条链路拼成了一条外设库文件 stm32f10x_adc.c 负责采样HzLib_65k.c 提供频域变换剩下的工作全是在做数据搬运。适合做音频谱分析、振动监测和电源纹波检测的人参考也适合想搞懂 ADC 动态参数和 FFT 分辨率关系的嵌入式开发者。2. STM32 ADC 采样链路先定采样率和转换时间2.1 采样率决定 FFT 能看到多高的频率频域分析的第一个约束不是 FFT 算法本身而是 ADC 采样率。按 Nyquist 定理采样率 fs 必须大于目标信号最高频率的两倍否则高频分量会混叠到低频段在频谱上形成假峰。STM32F103 的 ADC 时钟树上限是 14MHz标准外设库一般配置为 PCLK2 的分频工程里常用 Div6也就是 72MHz / 6 12MHz。每次转换的总周期数是“采样周期 12.5”等待时间和采样保持时间都在这一个参数里。下面这张表按 ADC 时钟 12MHz 计算列出不同采样周期配置对应的采样率和理论无混叠频率。实际设计时不要把信号频率推到 fs/2 边界要留出抗混叠滤波器的过渡带。采样周期配置总周期数单次转换时间采样率理论 fs/21.5141.17μs857kHz428kHz7.5201.67μs600kHz300kHz28.5413.42μs292kHz146kHz71.5847.00μs143kHz71kHz采样周期配置在标准外设库里的名称是ADC_SampleTime_28Cycles5后面的数字就是采样保持时间。ADC 是逐次逼近型SAR ADC内部采样电容通过模拟开关连接外部引脚开关闭合的时间就是采样周期。时间越短对信号源阻抗要求越高。如果前面没有运放缓冲换取的高采样率会栽在 ADC 建立时间上产生非线性误差。提示fs/2 只是奈奎斯特极限频谱分析工程上一般只用到 fs/4 以内的频率也要确认前级是否有足够的抗混叠能力。2.2 用标准外设库把 ADC 打开这个工程的文件列表里已经有 stm32f10x_adc.c所以初始化代码走的是标准外设库 API。下面是一段常用的单通道 ADC 初始化PA1 进 ADC1采样周期选 28.5 个时钟适合中低阻抗信号源。RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); /* ADC 时钟 72MHz / 6 12MHz */ GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; GPIO_Init(GPIOA, GPIO_InitStructure); ADC_InitTypeDef ADC_InitStructure; ADC_InitStructure.ADC_Mode ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode DISABLE; ADC_InitStructure.ADC_ContinuousConvMode ENABLE; /* 连续转换配合 DMA */ ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel 1; ADC_Init(ADC1, ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_28Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE);RCC_ADCCLKConfig决定了 ADC 的转换时钟这里 Div6 是 12MHz采样周期 28.5 对应总周期数 41单次转换约 3.42μs。ADC_ExternalTrigConv_None表示不使用外部触发启动后由内部自动连续转换。ADC_DataAlign_Right很重要12 位数据右对齐存放在寄存器低 16 位后面 DMA 搬运和 FFT 预处理都依赖这个布局。2.3 用 DMA 把连续采样搬进内存连续转换模式如果每次转换都进中断CPU 会一直被 ADC 打断FFT 根本没时间算。常见做法是开 DMA 周期传输把 ADC1 的数据寄存器直接搬进内存数组搬满一帧后再触发一次 FFT。这个工程要做的正是“采集 FFT 显示”所以 DMA 通道几乎绕不开。#define FFT_N 1024 uint16_t adc_buf[FFT_N]; void ADC1_DMA_Init(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel1); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)ADC1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)adc_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize FFT_N; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode DMA_Mode_Circular; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel1, DMA_InitStructure); DMA_ITConfig(DMA1_Channel1, DMA_IT_TC, ENABLE); DMA_Cmd(DMA1_Channel1, ENABLE); }DMA_BufferSize要和 FFT 点数一致这里设成 1024搬满后硬件自动产生传输完成中断。外设地址固定为ADC1-DR内存地址递增。关键在HalfWord数据宽度ADC1 数据寄存器高 16 位没有有效数据只有半字对齐才不会把数据错位。DMA_Mode_Circular让 DMA 在搬完一帧后自动回到起点继续搬ADC 永远在写FFT 处理的是上一帧。2.4 数据是 12 位右对齐进 FFT 前先做预处理DMA 搬进来的adc_buf是 04095 的无符号整数。信号如果是双极性系统比如音频或振动传感器需要先消掉直流偏置。很多新手直接把原始数组丢给 FFT结果 0 号 bin 出现一个巨大直流分量旁边还拖着一串泄漏低频信号被淹掉。int16_t adc_signed[FFT_N]; uint32_t sum 0; for (int i 0; i FFT_N; i) { sum adc_buf[i]; } int16_t dc (int16_t)(sum / FFT_N); for (int i 0; i FFT_N; i) { adc_signed[i] (int16_t)adc_buf[i] - dc; }先对整帧求平均值再把每个点减去这个平均值得到以零为中心的int16_t序列。这个操作相当于在时域做了高通滤波但只滤掉纯直流。手动去直流比在 FFT 结果里忽略第 0 个 bin 更稳妥因为直流分量在变换时会产生频谱泄漏影响相邻几个低频点。3. FFT 算法实现HzLib_65k.c 的底层逻辑和调用方式3.1 从 DFT 到 FFT这个库帮你省掉什么离散傅里叶变换的定义是把 N 个时域样本分别和 N 个频率点做复数相关计算量是 O(N^2)。一个 1024 点的 DFT 要做超过 100 万次复数乘法Cortex-M3 跑起来非常吃力。FFT 利用旋转因子的周期性和对称性把大 DFT 拆成多级蝶形运算计算量降到 O(N log N)。1024 点基 2 FFT 只需要大约 5120 次复数乘法差了接近两个数量级。HzLib_65k.c这类库一般会在初始化阶段把旋转因子预先算好存成一张查找表运行时只做加减法和查表乘法。名字里的 65k 我倾向于理解成和频率刻度有关具体对应多少 Hz 的基频要去看文件头部注释和采样率配合。不要被文件名里的数字迷惑核心还是点数、位宽和输出格式三个参数。3.2 在工程里怎么调用初始化、变换、取模一般这类库对外暴露的函数很简洁一个初始化一个执行变换。主循环里先等 DMA 搬完一帧然后把上一节的adc_signed填入实部数组虚部全部清零调用变换函数最后对前一半输出取模。static int16_t re[FFT_N]; static int16_t im[FFT_N]; static uint16_t mag[FFT_N / 2]; /* 以 HzLib_65k.c 头文件中的实际函数名为准 */ HzLib_Init(FFT_N); for (uint16_t i 0; i FFT_N; i) { re[i] adc_signed[i]; im[i] 0; } HzLib_FFT(re, im); for (uint16_t i 0; i FFT_N / 2; i) { mag[i] isqrt32((uint32_t)re[i] * re[i] (uint32_t)im[i] * im[i]); }im[i] 0是实数输入FFT 输出中后半部分只是前半部分的镜像所以只保留前N/2个点。isqrt32可以是整数平方根函数如果编译器自带sqrtf且不心疼浮点时间也可以直接转 float 求模。输出数组是uint16_t显示和比较都方便。注意定点乘法可能会溢出中间结果要转成uint32_t再平方。3.3 窗函数不是可选项如果采样点没有覆盖输入信号的整数个周期FFT 会把截断造成的跳变展开成额外频谱分量这就是频谱泄漏。最简单有效的方法是加窗。工程里最常见的是 Hanning 窗公式简单旁瓣衰减也够用。#define PI 3.14159265358979f float win; for (uint16_t i 0; i FFT_N; i) { win 0.5f - 0.5f * cosf(2.0f * PI * i / (FFT_N - 1)); re[i] (int16_t)(adc_signed[i] * win); im[i] 0; }加窗会把信号能量压缩峰值幅度会偏小。如果只看频率位置不影响判断如果要做幅值读取就需要在显示前乘回窗函数的幅度修正系数。Hanning 窗的峰值修正因子大约是 2也就是加了窗以后真正正弦分量在频域里的峰值大约是需要数值的 1/2具体还要看库是否已经做过归一化。3.4 频率分辨率N 和 fs 怎么搭配频谱上相邻两个 bin 之间的频率间隔是 Δf fs / N。分辨率越高越能把靠近的两个峰分开。下面以几个常见采样率为例fsN256N512N1024N20488kHz31.25Hz15.63Hz7.81Hz3.91Hz40kHz156.25Hz78.13Hz39.06Hz19.53Hz292kHz1.14kHz570Hz285Hz142Hz提高频率分辨率有两种办法降低采样率或者增加 N。但 N 翻倍FFT 运算时间只增一倍多而显示和 DMA 内存占用也会翻倍。STM32F103 内存不大FFT_N取 1024 在资源和实时性之间比较平衡。HzLib_65k.c 里很可能写了固定点数优化换点数之前要先确认库支持的序列长度是不是 2 的整数次幂。4. 频谱上屏LCD 显示与坐标映射4.1 从 FFT 输出到屏幕的坐标换算FFT 算出来的是离散频谱但液晶屏只有几百像素宽直接把FFT_N/2个点铺满不现实必须做合并。我一般会按屏幕宽度把频谱分成若干区间每个区间取峰值或平均值窄带信号取峰值不会丢宽带噪声取平均更平滑。#define SCREEN_W 240 #define SCREEN_H 320 uint16_t bar_height[SCREEN_W]; for (uint16_t x 0; x SCREEN_W; x) { int bin_start x * (FFT_N / 2) / SCREEN_W; int bin_end (x 1) * (FFT_N / 2) / SCREEN_W - 1; uint16_t max_mag 0; for (int b bin_start; b bin_end; b) { if (mag[b] max_mag) { max_mag mag[b]; } } bar_height[x] lcd_log_scale(max_mag); }bin_start到bin_end把 512 个 FFT bin 映射到 240 列每列大约合并两个 bin。实际工程里屏幕宽 240柱状图底部留 20 像素刻度顶部留一点余量。lcd_log_scale用对数压一下幅度否则大信号会把小信号全压扁uint16_t lcd_log_scale(uint16_t raw_mag) { if (raw_mag 0) return 0; float ratio log10f((float)raw_mag) / log10f(4095.0f); return (uint16_t)(ratio * (SCREEN_H - 20)); }对数映射能同时看到 -40dB 量级的微弱信号和满量程的大峰频谱分析仪习惯上也是这么显示。显示代码要注意raw_mag为 0 时的除零问题所以先判零返回。4.2 刷新策略整帧擦除和增量擦除一种最直接的画法是每帧先清屏再画所有柱状条。缺点是闪烁明显而且 LCD 写像素很慢。简单优化是维护上一帧每个柱子的高度只擦掉从当前高度到旧高度之间的区域然后画新的柱子。这样每次刷屏的写像素量减少很多。如果项目用的是带显存的 LCD 驱动芯片比如 ILI9341可以先把整帧频谱画在一块内存缓冲区里再用一条命令整块刷过去。STM32F103 内部 RAM 只有 20KB240×320×2 字节的 RGB565 缓冲放不下所以还是推荐用增量擦除法或者把屏幕分辨率降到 240×160。4.3 帧率预算数据从采样到显示花在哪里一帧完整的流程包括等待 DMA 采集满 FFT_N 个点、做加法去直流、乘窗函数、执行 FFT、取模、画柱状图。如果用 fs 292kHz、N 1024采集一帧只要 3.5ms但 FFT 浮点运算和 LCD 绘制会吃掉大部分时间。显示线段如果一条条写像 aff 普通 GPIO 模拟时序光是画 240 根柱子就可能超过 10ms。这时候优先优化 LCD 写入接口用 SPI DMA 发送像素数据而不是在循环里等while(SPI_I2S_GetFlagStatus)RESET。FFT 运算本身如果超过 20ms再考虑换定点库或者缩小点数。帧率能达到 10fps 以上肉眼观察频谱已经比较流畅。5. 实时性瓶颈与常见坑从采样周期到 DMA 双缓冲5.1 ADC 建立时间不够频谱里平白多出谐波SAR ADC 的内部采样电容在采样阶段从输入信号抽取电荷。如果信号源阻抗太高采样时间又短电容端电压还没稳定就被断开同一幅度的信号在不同采样点会被偏置产生非线性失真。具体表现是 FFT 里出现输入频率的 2 次、3 次谐波而且幅度随采样周期变短而变大。信号源阻抗建议采样周期 1kΩ1.57.51kΩ10kΩ28.510kΩ100kΩ71.5或加运放跟随器这组经验值适用于 STM32F103 这类 SAR ADC。如果板子上 ADC 引脚直接接电位器这种高压阻源最稳妥的是加一个低输出阻抗的运放跟随器。不要只盯着采样率高ADC 建立时间不够时信噪比和 THD 都会明显变差FFT 看到的数据已经失真了。5.2 DMA 循环模式的数据撕裂问题和双缓冲DMA_Mode_Circular很方便但有个隐患ADC 在持续写adc_buf而 FFT 正在读同一块数组可能读到半帧新数据、半帧旧数据。解决方法是双缓冲或者叫乒乓缓冲。两个数组交替使用DMA 写一块FFT 读另一块。uint16_t adc_buf[2][FFT_N]; volatile uint8_t cur_buf 0; volatile uint8_t frame_ready 0; void DMA1_Channel1_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC1)) { DMA_ClearITPendingBit(DMA1_IT_TC1); cur_buf ^ 1; frame_ready 1; } }主循环里检测frame_ready为 1 时处理adc_buf[cur_buf ^ 1]。需要保证 FFT 处理时间小余采集一帧的时间否则frame_ready还没被清零下一次 DMA 中断又置位数据仍然会互相追赶。cur_buf要声明成volatile防止编译器优化掉反复判断。5.3 电源噪声和 PCB 布局对噪底的影响FFT 分析对电源噪声特别敏感。ADC 的参考电压如果有 10mV 的开关噪声整个频谱底噪会被抬高弱小信号直接消失在底噪里。常见的三个 PCB 布局检查点模拟电源用 π 型 LC 滤波单独给 VDDA模拟地和数字地单点连接不要大面积连通ADC 采样引脚不要和高速数字信号线并行走长线。时钟抖动也会影响采样精度。对正弦波采样时抖动相当于在时间轴上叠加噪声转化为幅度上的随机误差最终看到的是噪底不平。STM32 内部 ADC 对时钟抖动不算苛刻但切忌用 I/O 翻转模拟时钟去触发 ADC。5.4 ADC 数据漂移下的软件姿态如果检测到 ADC 数值整体缓慢漂移比如温度变化或基准漂移可以在时域做中值滤波或滑动平均。但对频谱采集来说均值滤波会压低高频成分3 点中值滤波是性价比最高的选择既能去掉毛刺又不会明显破坏信号边缘。uint16_t adc_median3(uint16_t a, uint16_t b, uint16_t c) { if ((a - b) * (a - c) 0) return a; if ((b - a) * (b - c) 0) return b; return c; }这个函数用乘法的符号判断中间值注意uint16_t减法可能下溢但在无符号数运算里结果是正确模值不影响比较。中值滤波适合滤掉单点尖峰如果漂移是累积性的还是要检查基准源和 ADC 参考电压软件滤波解决不了系统性误差。6. 进阶用已知信号校准频率轴和幅值6.1 用 DAC 输出正弦波做自闭环验证这个工程的文件里出现了 DAC.axf说明原有的 ARM 目标里很可能已经启用了 DAC 外设。把 DAC 配置成输出一个已知频率的正弦波再用杜邦线或板载走线接到 ADC 输入是最快的验证方式。假设目标频率是 1kHz采样率 fs 292kHzFFT 点数 N 1024那么峰值应该出现在 k f_target × N / fs ≈ 3.5 这个 bin 附近。由于 k 不是整数理论上峰值会泄漏到相邻几个 bin加 Hanning 窗后峰值周围呈抛物线分布。这时可以先用峰值 bin 做粗定位再用相邻两个 bin 的幅值做抛物线插值频率估计精度可以远高于一个 bin 的宽度。这个步骤不复杂但对理解 FFT 参数很有帮助。6.2 用已知幅度校准显示增益校准频率轴后再校准幅度。给 DAC 一个满量程 10% 的正弦波比如 0.5Vpp记录加窗后峰值 bin 的幅值 A_measured。理想情况下加窗后峰值幅度和真实幅度之间存在固定比例这个比例受窗函数和库归一化影响。把实际输入幅度除以测量值得到一个增益系数。float gain 0.5f / (float)peak_mag; /* 实测 0.5Vpp 信号的峰值幅值 */ for (uint16_t i 0; i FFT_N / 2; i) { display_mag[i] (uint16_t)(mag[i] * gain); }把gain做成一维数组存到代码里作为校准表每次显示前直接乘上能做到比较准的幅值读数。校准表只在采样率、FFT 点数和窗函数不变时有效换一个参数重跑一遍校准函数。这样处理之后LCD 上的柱状图纵轴就变成了大致真实的电压幅度而不是一个只可看相对强弱的无量纲数字。本文还有配套的精品资源点击获取
返回列表