
简介一份完整的ADC采集与快速傅里叶变换频谱分析工程面向嵌入式开发者和数字信号处理学习者解决从模拟信号采样到频域可视化的实战问题。工程基于单片机平台包含ADC驱动、FFT运算、液晶屏显示等模块能对实时采集的数据做频谱变换并显示便于观察不同输入信号对频谱的影响。包内共261个文件以C语言源码、头文件及编译生成的目标文件、可执行文件、烧录文件为主还有集成开发环境配置文件、批量清理脚本和说明文档结构与用途相对清晰。资源压缩包整体约10.12MB已有386人下载学习。对想掌握单片机平台ADC与FFT落地方法、学习频谱分析界面实现思路的读者这份工程提供了完整的参考流程与可直接复用和二次开发的基础代码。1. 从ADC原始码到频谱图中间差的不只是一个FFT做嵌入式采集的人大概率遇到过这种情况用示波器看ADC引脚上的信号很干净但进单片机之后数据在串口里跳得像噪声或者DMA搬运了8K个采样点Matlab里一画波形图确实是对的可一提到“频域分析”就卡住了——不是不会傅里叶变换而是不知道采样率怎么填、数据要不要截断、结果里的直流分量算不算bug。FFT.rar里装的往往就是这种“能用但说不清”的ADCFFT实现代码跑得通换一个采样率就出鬼影。这篇文章把ADC采集到FFT频谱分析的整条链路拆开讲先从理论把采样周期、点数与频率分辨率的约束算清楚再给出可直接抄的C语言FFT实现和DMA接入方式接着用MATLAB验证算法正确性与排错顺序最后落到嵌入式FFT的进阶用法——用频谱数据算ADC的信噪比和动态参数。适合正在做嵌入式数据采集、振动监测、音频分析或者电源纹波测量的工程师看完能自己写一套不依赖特定厂商库的FFT工具链。2. 采样参数先算清楚ADC采样周期、数据点数与频率分辨率的关系2.1 ADC采样周期怎么定从奈奎斯特到实际工程余量FFT分析的对象是离散序列而这个序列的物理意义完全由ADC采样周期决定——同一个FFT结果,采样率标错了所有频率点的坐标就全错了。理论上的下限是奈奎斯特频率采样率必须大于信号最高频率的两倍但实际嵌入式项目里至少留出4到10倍余量。常见做法是先确认信号带宽再反推采样率。比如做一个20kHz以内的音频频谱分析仪采样率选到96kHz甚至192kHz不是因为人耳需要而是为了给抗混叠滤波器留过渡带余量。若用STM32的ADC连续采样典型配置是ADC时钟14MHz时采样周期选7.5个周期总周期数约14个得到约1MSPS的采样率代码里对应的定时器触发周期就是1μs。// 假设目标采样率 Fs 51200 Hz,使用 TIM3 触发 ADC 注入组采样 // APB1 时钟 84MHz,预分频 84,自动重载 19,即可得到 84M/84/20 50kHz TIM_TimeBaseInitTypeDef.TIM_Prescaler 84 - 1; TIM_TimeBaseInitTypeDef.TIM_Period 20 - 1; // 实际 Fs 84MHz / 84 / 20 50kHz参数说明:预分频器把84MHz降到1MHz,自动重载值20决定了触发脉冲周期20μs,对应50kHz采样率。这里把采样率取50kHz而不是精确的51200,是为了让定时器重载值是整数,避免采样间隔抖动——采样时钟抖动会直接变成FFT输出的相位噪声,尤其在测量ADC动态参数时,哪怕1ns的时钟抖动都会让高次谐波的信噪比恶化几个dB。还有一点容易被忽略:如果用ADC的规则组配合DMA做连续采样,那采样周期不是定时器单独决定的,而是ADC时钟分频×采样周期寄存器固定转换周期。更改ADC采样周期寄存器时,实际采样间隔会跟着变,必须重新计算频率分辨率。2.2 FFT点数N的选择:分辨率、栈开销与频谱密度的三角权衡FFT输出分辨率由Fs/N决定。N取1024时,50kHz采样率下的分辨率约为48.8Hz;N取4096时分辨率提升到12.2Hz。但N增大带来的不只是计算量线性增长,还有内存的几何级消耗——复数数组要2×N×4字节,加上旋转因子表和窗函数表,4096点大约需要32KB以上RAM,这对未外扩存储的单片机是个压力。这里给出常用对应关系表:采样率FsFFT点数N频率分辨率Δf单次采集时间内存占用(复数float32)50kHz102448.8 Hz20.48 ms8 KB50kHz409612.2 Hz81.92 ms32 KB10kHz10249.77 Hz102.4 ms8 KB10kHz40962.44 Hz409.6 ms32 KB内存紧张时还可以用16位定点FFT替代浮点FFT,或者分块存储旋转因子只保留四分之一象限——sin/cos的对称性决定其他象限可以镜像得到。2.3 采集时间窗与窗函数:泄漏不是bug,是截断的数学必然只要采集的不是信号周期的整数倍,FFT就会发生频谱泄漏——能量从真实频率点扩散到相邻频点。ADC采集时长N×Ts,如果你的信号是50Hz工频干扰,Fs1kHz,N256时Δf≈3.9Hz,50Hz不是3.9的整数倍(50/3.9≈12.8),那么频谱图上50Hz的峰会明显变胖。处理泄漏的标准手段是加窗。矩形窗对应原始数据直接截断,频域主瓣最窄但旁瓣只衰减13dB,适合测量频率接近的两个等幅信号;汉宁窗旁瓣衰减31dB,适合绝大多数振动信号和音频分析;平顶窗幅度误差最小,适合需要精确读幅值的场景但主瓣宽,频率分辨率损失大。// 汉宁窗系数生成,C语言实现 float hanning[4096]; // 按实际N分配 for (uint16_t i 0; i N; i) { hanning[i] 0.5f * (1.0f - cosf(2.0f * M_PI * i / (N - 1))); } // 加窗操作:对每个采样点执行 sample[i] * hanning[i]加窗之后,信号总能量被窗函数削掉了一部分(汉宁窗的能量恢复系数是0.5),算幅度谱时要除以窗函数的相干增益。对于汉宁窗,幅度恢复系数约1.63,即读取到的幅度要乘以1.63才是接近真实的正弦波幅度。这个细节在很多教程里不写,导致FFT结果幅度整体偏低,误以为算法有问题。3. 嵌入式FFT代码实现:从DMA缓冲区到蝶形运算3.1 先手写一个基2时间抽取FFT,再接工程代码很多人直接上STM32官方DSP库的arm_cfft_f32,这没问题,但如果出了问题(比如输出顺序是自然序还是位反转序弄混),你要能看懂库内部发生了什么。所以我建议做ADC FFT项目时先自己实现一遍基2 FFT,Debug通过之后再决定是否换DSP库加速。基2时间抽取FFT的核心步骤是位反转排序和蝶形运算。位反转的时间复杂度是O(N),蝶形运算共log2(N)级,每级N/2个蝶形。以N1024为例,蝶形总数为5120个,每个蝶形含1次复数乘法和2次复数加法。Cortex-M4F内核带FPU,1024点浮点FFT大约在200μs内可完成,不影响50kHz连续采样下的实时显示。// 位反转排序 - 以N8为例,索引0,4,2,6,1,5,3,7 void bit_reverse(float* real, float* imag, uint16_t N) { uint16_t j 0; for (uint16_t i 0; i N; i) { if (j i) { // 交换只做一次 SWAP(real[i], real[j]); SWAP(imag[i], imag[j]); } uint16_t mask N 1; while (j mask) { j ^ mask; mask 1; } j ^ mask; } } // SWAP宏定义为常规交换操作,可自行实现这段代码的逻辑:用二进制位反转的方式生成逆序索引j,当ji时才交换,保证每对只需换一次。掩码mask从N/2开始逐位右移,遇到j的某一位为0就停止并置1,这是经典的Rader位反转算法,不需要查表。3.2 蝶形运算的旋转因子表与就地计算蝶形运算中最容易错的是旋转因子W_N^k e^(-j2πk/N)的符号和初始k值。C语言里用预计算旋转因子表代替每次cosf/sinf调用,能省去60%以上的三角函数计算时间。旋转因子的实部存在数组twiddle_re[],虚部存twiddle_im[],长度N/2。void fft_butterfly(float* re, float* im, uint16_t N, float* tw_re, float* tw_im) { uint16_t levels (uint16_t)log2f(N); // log2(1024)10 for (uint16_t len 2; len N; len 1) { uint16_t half len 1; uint16_t step N / len; // 当前级旋转因子索引步长 for (uint16_t i 0; i N; i len) { for (uint16_t j 0; j half; j) { uint16_t tw_idx j * step; float wr tw_re[tw_idx]; float wi tw_im[tw_idx]; // 蝶形运算:re[ijhalf] re[ij] * wr float tr re[i j half] * wr - im[i j half] * wi; float ti re[i j half] * wi im[i j half] * wr; re[i j half] re[i j] - tr; im[i j half] im[i j] - ti; re[i j] tr; im[i j] ti; } } } } // 旋转因子表可提前用 tw_re[k] cosf(-2*M_PI*k/N) 生成旋转因子索引tw_idx j * step这个设计是为了适配DIT架构每级步长的变化。第一级len2时stepN/2,第二级len4时stepN/4,这样用同一张表就能覆盖所有级,省去每级重建索引表的开销。数据按时间抽取后输出是自然序,如果你需要和MATLAB的fft()输出对比,注意MATLAB默认输出顺序就是自然序,但幅值多了归一化系数1/N。3.3 DMA搬运ADC数据到FFT输入数组的工程衔接ADC配置建议用定时器硬件触发DMA循环模式,不做CPU中断搬运。以STM32F4为例,ADC1的DMA数据流映射到DMA2_Stream0,外设地址是ADC1-DR,内存地址是你的uint16_t类型缓冲区,长度为FFT点数N。DMA传输完成一半时进中断(半传输中断),此时前半段数据可以处理,同时DMA继续填充后半段——乒乓结构。// 伪代码:DMA半传输/全传输中断里的数据取出逻辑 volatile uint16_t adc_buf[2][N]; // 双缓冲 volatile uint8_t active_buf 0; void DMA_IRQHandler(void) { if (DMA_GetITStatus(DMA2_Stream0, DMA_IT_HT)) { // 半传输完成 process_buffer(adc_buf[active_buf], N/2, FFT_FORWARD); } if (DMA_GetITStatus(DMA2_Stream0, DMA_IT_TC)) { // 全传输完成 active_buf ^ 1; // 切换缓冲区 process_buffer(adc_buf[active_buf], N/2, FFT_FORWARD); DMA_ClearITPendingBit(DMA2_Stream0, DMA_IT_TC); } }注意这里DMA的半传输中断拿到的数据是从缓冲区起始地址算的,在循环模式下,TC中断后的下一次传输又会覆盖同一块内存,所以必须用双缓冲或者三缓冲来保证处理期间数据不被覆盖。最简单的做法是分配两个完整的ADC缓冲区,一个被DMA写入时另一个做FFT计算,交替使用。提示:ADC的采样数据是12位或16位无符号整型,直接送入FFT会有直流偏置(大约采样值的一半)。做FFT前先减掉平均值,即信号零均值化,否则直流分量会占据第0根谱线,并且由于窗函数的频谱泄露,直流分量会污染附近30~100Hz的低频区域。3.4 幅度谱计算与归一化:读对每一根谱线的物理含义经过蝶形运算后,re[],im[]里存的是复数频谱系数,频率点k对应的物理频率是f k × Fs / N。幅度计算为sqrt(re² im²),但要注意归一化:直流分量(k0)幅度应除以N其他频率点(k0)幅度应除以N/2加窗后需要额外除以窗函数的相干增益(汉宁窗为0.5,幅度恢复系数是2)下面这个函数完成从复数频谱到实际幅度谱的转换:void compute_amplitude_spectrum(float* re, float* im, float* amp, uint16_t N, float Fs, float win_gain) { for (uint16_t k 0; k N/2; k) { float mag sqrtf(re[k]*re[k] im[k]*im[k]); if (k 0) { amp[k] mag / N; // 直流分量 } else { amp[k] mag * 2.0f / (N * win_gain); // 交流分量 } } // 输出频率轴对应:amp[k] 的频率是 k * Fs / N }从工程测试角度看,把一组已知频率和幅值的正弦波输入到ADC引脚,比如1kHz、1Vpp的正弦波,经过ADCFFT后读amp[k]在k对应1kHz处的值,回读的结果应当与信号源设定值误差在±1%以内。如果偏差大于5%,优先检查ADC参考电压是否准确,其次检查窗函数增益是否乘了两次。4. 用MATLAB导入CSV验证FFT结果正确性的完整流程4.1 先把CSV里的一维ADC码变成能跑FFT的double数组嵌入式打印到串口的ADC值大概率是16进制或十进制文本,导入MATLAB时最常见的坑是表头、时间戳和逗号混杂。用readmatrix或readtable时,直接指定从第N行开始读、只读数据列,避免表头干扰。更稳妥的是用textread低层函数,它能把所有数字按列读入,不会因为某一行少个逗号而报错。% 导入串口助手保存的CSV:一列ADC原始值 % 第一行可能是A0\r\n之类的表头, 从第2行开始读 data readmatrix(adc_raw.csv, NumHeaderLines, 1); % 如果数据是uint16格式, 先转double再操作 adc double(data); % 基本信息确认 - 打印条数和采样率 Fs 50000; % 与单片机定时器配置严格一致 N length(adc); fprintf(共%d点, 采样率%.0fHz, 时长%.3fs\n, N, Fs, N/Fs);参数说明:这里Fs50000必须与嵌入式端定时器配置一致,否則fft结果的频率轴全部错位。NumHeaderLines参数解决表头问题。如果CSV里混入了字符型数据,可以改用readtable配合其中一个数值列vpa转为double。4.2 MATLAB里完整的FFT频谱分析代码模板导入后先可视化原始波形确定有无饱和削顶,再减直流、加窗、FFT、归一化,一次性做完所有步骤,避免在命令行里反复敲。以下代码段可以直接保存为analyze_adc_fft.m复用。% 载入数据后执行 adc adc - mean(adc); % 去直流, 消除ADC偏置 N length(adc); win hanning(N); % 汉宁窗, 与嵌入式端保持一致 adc_win adc .* win; % FFT变换 Y fft(adc_win); P2 abs(Y / N); % MATLAB基本归一化 P1 P2(1:N/21); P1(2:end-1) 2 * P1(2:end-1); % 交流分量乘2 % 恢复汉宁窗能量 coherent_gain mean(win); % 汉宁窗约0.5 P1 P1 / coherent_gain; % 画对数幅度谱 f Fs * (0:(N/2)) / N; figure; plot(f, 20*log10(P1 1e-12)); % 加1e-12防止log(0) xlabel(频率 (Hz)); ylabel(幅度 (dBFS)); grid on; xlim([0 Fs/2]);代码说明:去直流后再加窗,避免窗函数把直流分量泄漏到低频段。hanning默认是列向量,N点长度要在乘法前转置为行向量,否则报维度错误。20*log10(P11e-12)是为了让零幅度的谱线也能画在-240dBFS的位置而不是NaN,不影响数据本身的正确性。4.3 用MATLAB结果反推嵌入式FFT的实现正确性如果你在单片机上算出的幅度谱和MATLAB对同一份CSV数据算出的结果不一致,按以下顺序排查:先比对数坐标,看每根谱线的高度差是不是固定的倍数。固定倍数通常是归一化因子错,嵌入式端漏乘了2/N或者多乘了1/N,整体平移一个常数dB。如果只有部分谱线不对,检查位反转排序是否正确。一个典型症状是频点错位,输入1kHz正弦,结果在2kHz或者500Hz处出现峰值。如果频率位置对但幅度抖动,优先怀疑加窗不一致——MATLAB用的hanning是周期窗(N),嵌入式实现可能用了对称窗(N1),两者主瓣增益略有差别。若是谱线附近的底噪明显抬高,检查ADC数据是否在存储过程中被截断成8位,或者在DMA搬运时高低字节交换了。提示:最容易忽略的是FS的精度。定时器算出来的50kHz实际可能偏差2%,MCU晶振为RC振荡器时偏差更大。做精确FFT测量时应使用外部晶振并用定时器输入捕获校准实际采样率,否则频谱峰值位置会整体偏移。5. 进阶实战:把FFT当工具,评测ADC性能与嵌入式频域技巧5.1 用FFT计算ADC的无杂散动态范围SFDR与信噪比SNRSFDR表示最大信号分量与最大杂散分量的差值,是衡量ADC线性度和动态性能的核心指标之一。做法:输入一个接近满量程但不超过满量程的单音正弦(建议-1dBFS),做FFT后排除直流和谐波频点,找出最大杂散峰。杂散通常是谐波(2次、3次)或采样时钟与信号的交调产物。% MATLAB计算SFDR [mag_max, idx_max] max(P1(2:end)); % 排除直流 idx_max idx_max 1; % 修正索引 signal_power mag_max^2 / 2; % 正弦波信号功率 % 排除信号所在频点附近的窄带范围(信号可能泄漏) excl_band 5; % 排除±5根谱线 mask true(size(P1)); mask(max(1,idx_max-excl_band):min(length(P1),idx_maxexcl_band)) false; mask(1) false; % 排除直流 [spur_max, idx_spur] max(P1(mask)); sfdr_db 20*log10(mag_max / spur_max); fprintf(SFDR %.2f dB\n, sfdr_db);SFDR的实际意义:当你在频域里看到某些不明的周期干扰时,SFDR帮你分辨是ADC动态性能不足、电源纹波耦合还是信号源本身带杂散。若SFDR低于ADC数据手册典型值8dB以上,先量参考电压噪声,再检查PCB布局——ADC的VREF引脚旁路电容离引脚超过5mm,SFDR基本都会掉。5.2 嵌入式FFT实时性与定时器触发ADC采样的并发设计实时FFT系统的经典死锁:定时器以50kHz触发ADC,DMA不断搬运数据,FFT计算耗时300μs,更新频谱显示时发现波形卡顿——原因可能是FFT在主循环里阻塞太久,导致串口/显示任务饿死。常见做法是将FFT计算放入DMA的中断回调里,但要控制中断内耗时,超过下一次采样间隔就会丢失数据。我一般怎么做:把FFT拆成两级。DMA半满/全满中断只负责拷贝数据并置一个标志位,主循环检测标志位后做FFT和显示。当FFT耗时接近采样帧间隔时,把采样率分频,让FFT每次只处理上一帧的数据,保证流水线不被阻塞。// 主循环结构示意 while (1) { if (fft_ready_flag) { fft_ready_flag 0; memcpy(fft_in_re, adc_buf[last_full], N*sizeof(float)); // 拷贝避免DMA覆盖 fft_butterfly(fft_in_re, fft_in_im, N, tw_re, tw_im); compute_amplitude_spectrum(fft_in_re, fft_in_im, amp, N, Fs, 0.5f); find_peak_frequency(amp, N, Fs); } // 其他任务:LCD刷新、串口打印、按键扫描 }参数说明:memcpy从DMA缓冲区拷贝到FFT专用数组是必要的——如果不拷贝直接对DMA缓冲区做FFT,半满中断触发后已经处理了前N/2点,后N/2点的数据还在被DMA写入,蝶形运算会读到一半新一半旧的数据。时间开销上,N1024时拷贝4KBfloat大约20μs,Cortex-M4F完全能接受。5.3 一个验证FFT代码正确性的闭环技巧:片内DAC生成正弦波反测最后分享一个自己常用的验证技巧,不依赖外部信号源:用STM32片内DAC输出正弦波,将DAC输出引脚直接连到ADC输入引脚(注意共地),DAC查表输出频率已知的正弦波,这样ADC采集到的信号频率、幅度都是代码精确可控的。然后跑FFT,检查峰值频率是否与DAC设定的频率一致,幅度误差是否在3%以内。DAC生成的频率由定时器触发DAC的更新速率决定,DAC更新速率必须比ADC采样率高两倍以上,否则ADC看到的波形会因DAC的零阶保持效应产生高频镜像。实际操作时,DAC更新率设为ADC采样率的8倍(比如ADC 50kHz,DAC 400kHz),这样在ADC输入处看到的是阶梯状正弦,阶梯频率落在高频段不会干扰目标信号。验证通过后再把DAC断开,接真实传感器信号,这套代码链路就算彻底可信了——以后调算法参数,分得清是传感器的问题还是FFT链路的问题。本文还有配套的精品资源点击获取