简介:一份基于STM32F103的串行ADC数据采集与FFT频谱分析项目工程,面向嵌入式开发者和信号处理学习者,旨在解决常规MCU在20kHz采样率下实现高分辨率频谱显示与失真度测量的需求。工程完整集成了模拟信号采集、TI公司FFT库调用以及OLED动态显示链路,可对最高10kHz频率成分进行20Hz频率分辨率的实时分析,适用于教学实验、仪器仪表原型验证等场景。资源包整体约80MB,已有487人学习,读者可从中掌握STM32内置ADC的采样时间与转换速率配置、FFT谐波与相位解析方法,以及OLED实时绘制频谱图的驱动思路;并能结合通过串口发送数据到OLED显示的具体实现,理解示波器类设备从模拟前端到数字处理的完整流程。以此项目为模板,开发者可进一步扩展多通道采集或提高采样率,快速定制满足特定需求的信号分析与测量工具。
1. 认识这个压缩包:串口、ADC、FFT、OLED 四件事是怎么拧在一起的
拿到一个名为 Keil_Proj_V1_Serial_ADC_TI_FFT_OLED.zip 的工程包,第一反应应该是:这不是一个单点 demo,而是一条完整的数据采集链路。ADC 负责把模拟信号变成数字码值,FFT 负责把这串时域数据变成频谱信息,串口负责把计算结果送出去,OLED 负责在本地把结果画出来。标题里的 TI 通常指某厂商提供的 FFT 运算库,理解成"用 Keil 开发环境,在基于某系列 Cortex-M 内核的 MCU 上,把 ADC 采样、串口通信、FFT 频谱分析和 OLED 显示串成一套方案"就对了。
这套组合最常见的落地场景是音频可视化、振动监测、电源谐波分析这类需求:信号进入 MCU,采样后既要显示在屏幕上,又要通过串口传给上位机存档。适合的读者是手里已经有这块板子、想快速跑通全链路的人,而不是只想看某个外设单独怎么点的初学者。前者的痛点很明确:每个外设的单独例程都能跑,但凑到一起就互相打架——串口丢数据、OLED 闪烁、FFT 结果对不上。这篇就把这条链路的每个环节拆开讲,重点放在参数匹配和踩坑上。
2. 打开工程前先做三件事:Keil 版本、器件包与工程路径
2.1 用哪一版 Keil:版本不对,打开工程就是满屏报错
工程包大概率是用 MDK 5.x 或者更高版本建的,因为 4.x 时代的工程文件后缀是 .uvproj,而 5.x 之后是 .uvprojx。拿到 zip 先别急着双击,看一眼后缀就知道该用什么环境。如果你的机器上只有 MDK 4.x,打开 .uvprojx 会直接提示版本不支持;反过来,用 MDK 5.x 打开老工程虽然能识别,但会要求先安装对应的器件支持包,否则打开以后 Device 栏是空的,编译直接报错。
我的建议是装 MDK 5.x 最新稳定版,不要追 beta。装完之后第一件事不是解压工程,而是进 Pack Installer 确认目标芯片的器件包已经装上。你可以从 Project → Manage → Pack Installer 里搜芯片型号,看到绿色勾就说明齐了。这一步没做,后面所有"打开报错、找不到设备、无法下载程序"的问题都会来纠缠你。
# 解压工程包,并检查顶层目录结构是否完整 unzip Keil_Proj_V1_Serial_ADC_TI_FFT_OLED.zip -d proj_v1 tree -L 2 proj_v1正常解压后,顶层应该能看到工程文件(.uvprojx 或 .uvproj)、至少一个启动文件(startup_xxx.s)、一堆 .c/.h 源文件,以及可选的 .sct 链接脚本。用 tree 看目录结构是最快的体检方式:如果只有 .c 文件和打包者的截图,没有工程文件,说明这个 zip 本来就缺东西,趁早找对方要完整的。如果目录结构完整,就可以进入下一步。
2.2 解压后的工程文件清单:每个文件到底是干嘛的
拿到一份陌生的 Keil 工程,先花两分钟读文件,不要急着点编译。我一般按下面的清单快速过一遍,确认内容是不是和标题声称的功能对得上:
| 文件类型 | 典型文件名 | 职责说明 |
|---|---|---|
| 工程文件 | xxx.uvprojx | 记录源文件列表、编译选项、调试器配置,双击它来打开工程 |
| 用户选项 | xxx.uvoptx | 记录断点、窗口布局、仿真器设置,不影响编译结果 |
| 启动文件 | startup_xxx.s | 初始化堆栈、向量表、系统时钟,缺了它程序跑不起来 |
| 链接脚本 | xxx.sct | 定义代码段、数据段的存放地址,RAM 不够时多半要改它 |
| 主程序 | main.c | 外设初始化与主循环调度 |
| 外设驱动 | uart.c / adc.c / oled.c | 各外设的底层寄存器配置 |
| 计算模块 | fft.c + fft 相关头文件 | 封装 FFT 运算库的调用接口 |
一个值得注意的细节:如果压缩包里带的是已经编译过的 .o 或 .lib 二进制库,而不是 .c 源码,那么 FFT 库对应的头文件必须在工程路径里,否则你能调用函数却看不到实现,排错时基本等于对着黑匣子猜。遇到这种情况,我建议先把库函数能查到的参数说明抄一遍,再动手改代码。
2.3 把绝对路径改成相对路径:告别"头文件找不到"的玄学
很多人解压完工程直接编译,报了一堆 fatal error:"某个头文件 No such file or directory"。究其原因,九成是工程里记录了打包者电脑上的绝对路径,换到你电脑上就失效了。解决方式很简单:打开工程后,进入 Options for Target → C/C++ → Include Paths,把里面每个路径都改成基于工程目录的相对路径。
/* 假设工程解压在 D:\work\proj_v1,源文件在 .\User 和 .\Lib 下 */ /* Include Paths 里应配置为: */ /* .\User */ /* .\Lib */ /* .\Device */改完 Include Paths 后还有一个容易忽略的地方:工程里如果还引用了分散加载文件(.sct),它的路径也是绝对路径。到 Linker 选项卡里把 Scatter File 也改成相对路径。做完这两步,把工程文件夹整个挪到别的盘符下再编译一次,如果能过,说明路径已经彻底干净了。
3. 串口与 ADC:让模拟信号变成有序的串口字节流
3.1 为什么用"定时器触发 + DMA"而不是在 while 循环里轮询采样
ADC 采集最怕两件事:采样间隔不均匀、CPU 在采样期间被其他事情打断。在 while 循环里直接调用 ADC 转换,表面看逻辑简单,但只要代码里插入一条 OLED 刷新指令,那段时间的采样点间隔就会被拉长,FFT 出来的频谱图上会出现莫名的杂散分量。这就是为什么工程里真正可用的方案几乎都是硬件定时器触发 ADC,然后用 DMA 把结果搬进内存,全程不占用 CPU。
定时器触发的方式是:定时器计数到设定值溢出时,自动向 ADC 发出触发脉冲,ADC 在每个脉冲到来时启动一次转换,转换完成的标志信号再去拉起 DMA 搬运。这样采样间隔完全由定时器决定,和主循环跑多快没有关系。标题里既然同时出现了 ADC 和 FFT,你就应该默认采样要按 FFT 的需求来设计——稳定、等间隔、可计算频率。
| 参数 | 常规选值范围 | 影响 |
|---|---|---|
| 定时器频率 | 1 kHz ~ 50 kHz | 直接决定 ADC 采样率,也就是 FFT 的横轴范围 |
| ADC 采样周期 | 5 ~ 30 个 ADC 时钟周期 | 太短会导致采样电容没充满,结果偏小 |
| DMA 数据宽度 | 半字(16 位) | 匹配 12 位 ADC 的右对齐输出 |
3.2 定时器触发采样与 DMA 双缓冲的落盘代码
下面是这套方案里我认为最值得抄的初始化代码,它完成了定时器、ADC、DMA 三者的接线。代码里用了"某系列 Cortex-M 内核 MCU"的寄存器风格,你在移植时只需替换为自己的外设基地址和中断号即可。
/* ADC 采样率 = 定时器频率 / (预分频 + 1) / (自动重载 + 1) */ void ADC_Init_With_TimerTrigger(void) { /* 1. 使能外设时钟:ADC、定时器、DMA 都要开 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); /* 2. 配置定时器:1 kHz 触发频率,即每秒采样 1000 个点 */ TIM_TimeBaseInitTypeDef tim = {0}; tim.TIM_Prescaler = 84 - 1; /* 84 MHz / 84 = 1 MHz */ tim.TIM_Period = 1000 - 1; /* 1 MHz / 1000 = 1 kHz */ tim.TIM_ClockDivision = TIM_CKD_DIV1; tim.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &tim); TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update); /* 3. 配置 ADC:定时器触发,右对齐,采样周期拉长到 20 个周期 */ ADC_InitTypeDef adc = {0}; adc.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_TRGO; adc.ADC_ContinuousConvMode = DISABLE; /* 由定时器逐个触发 */ adc.ADC_DataAlign = ADC_DataAlign_Right; adc.ADC_SampleTime = ADC_SampleTime_20Cycles; /* 宁长勿短 */ ADC_Init(ADC1, &adc); ADC_ExternalTrigConvCmd(ADC1, ENABLE); /* 4. 配置 DMA:ADC 数据寄存器 -> 内存数组,循环模式 */ DMA_InitTypeDef dma = {0}; dma.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; dma.DMA_MemoryBaseAddr = (uint32_t)adc_buf; dma.DMA_DIR = DMA_DIR_PeripheralSRC; dma.DMA_BufferSize = FFT_POINTS; /* 采集完一整段就自动停止 */ dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma.DMA_MemoryInc = DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; dma.DMA_Mode = DMA_Mode_Circular; /* 循环采集,配合双缓冲 */ DMA_Init(DMA1_Channel1, &dma); }逻辑说明:第一步开时钟没有先后顺序问题,但漏掉任何一个,后面外设都是死的。第二步定时器的预分频要结合系统时钟计算,不是照着抄就完事,84 MHz 总线时钟下,先分频到 1 MHz,再计数 1000 次,才是 1 kHz。第三步关闭连续转换是必须的,否则定时器触发就被忽略了。第四步 DMA 的循环模式意味着采集完一段后自动从头开始,你不需要手动重新启动,这是保证持续采样的关键。
参数说明:FFT_POINTS建议写成宏,不要写成裸数字。adc_buf 数组类型应该是uint16_t数组,长度等于 FFT_POINTS。DMA 数据宽度选半字,是因为 12 位 ADC 的结果只占低 12 位,用半字既能省一半带宽,又不丢数据。如果你把采样率设到 50 kHz,DMA 中断频率会很高,这时候要注意中断服务函数里只做标志位置位,不要做任何计算。
3.3 数据帧设计:帧头、长度与校验,少一个都会让上位机抓狂
ADC 数据进了内存之后,接下来有两个出口:一是直接送 FFT,二是通过串口发给上位机。串口这边的常见翻车点在于:上位机只负责按字节流读数据,它并不知道哪一包是完整的。如果没有帧头,接收端拿到一半数据就开解,解出来的全是错值。所以串口协议至少要包含帧头、数据长度、数据区和校验字节。
/* 把一段 ADC 原始数据打包成一帧,通过串口发送 */ void UART_SendFrame(uint16_t *data, uint8_t len) { uint8_t frame[2 + 1 + len * 2 + 1]; /* 帧头2字节+长度1字节+数据+校验 */ uint8_t idx = 0, checksum = 0; frame[idx++] = 0xAA; /* 帧头1 */ frame[idx++] = 0x55; /* 帧头2 */ frame[idx++] = len; /* 数据个数,单位是 uint16_t */ for (uint8_t i = 0; i < len; i++) { frame[idx++] = data[i] & 0xFF; /* 低字节在前 */ frame[idx++] = (data[i] >> 8) & 0xFF; checksum += frame[idx-2] + frame[idx-1]; /* 累加校验 */ } frame[idx] = checksum; for (uint16_t i = 0; i < sizeof(frame); i++) { UART_SendByte(frame[i]); /* 阻塞发送,简单可靠 */ } }逻辑说明:帧头用两个字节 0xAA 0x55,是为了减少和正常数据撞车的概率;长度字段只放数据个数,上位机据此计算剩余字节数;校验用累加和,写完数据区之后把校验字节一并发出。发送部分用了阻塞方式,因为串口波特率一般不高,比如 115200 波特率下每秒只能发约 11.5 KB,而采集速度远快于此。如果采样率是 1 kHz 且每点 2 字节,每秒只有 2 KB 数据,完全来得及。
参数说明:len一定不要超过 255,因为帧里用单字节表示长度。如果一次要发 1024 个点的 FFT 结果,就要拆成多帧。串口波特率的选择要看你的采样率和数据量:波特率 bps / 10 大约是每秒能发的字节数,例如 115200 / 10 ≈ 11520 字节/秒。数据量超过这个值,要么降采样率,要么只发 FFT 计算后的几十个有效频点。
4. FFT 计算:从 ADC 原始码值到频谱图的参数匹配
4.1 直接用厂商 FFT 库还是自己写:分界线在哪
标题里写明了 TI_FFT,说明这是一个"开箱即用"的库方案,而不是要求你从零写蝶形运算。我的建议是:只要不是在做教学演示,优先用库。理由很简单,FFT 的边界条件太多——输入序列长度若不是 2 的整数次幂、位反转表没建对、循环变量越界,这些问题在你盯着频谱图上乱跳的噪声时,几乎没法靠肉眼定位。
自己写 FFT 唯一的优势是能精确控制每一步的行为,比如你想做固定点运算来省去浮点开销。但代价是你必须自己处理溢出、缩放和查表,调试周期至少多出一倍。工程里既然已经集成了厂商的 FFT 库,尊重现状,把精力花在"怎么正确地喂数据"和"怎么解释输出"上,这才是性价比最高的做法。
4.2 采样率与 FFT 点数:决定频率分辨率的两个旋钮
FFT 的输出质量,在调用库之前就已经由采样参数决定了。两条硬规则必须记牢:FFT 点数必须是 2 的整数次幂,常见是 256、512、1024、2048;频率分辨率等于采样率除以点数,例如 10 kHz 采样配 1024 点,分辨率约为 9.76 Hz。这意味着两个频率相差小于 10 Hz 的信号,在频谱上会糊成一个峰,你没法分辨。
/* FFT 库调用前的准备:从 ADC 数组拷贝数据,并做去直流处理 */ #define FFT_N 1024 #define F_S 10000.0f float fft_input[2 * FFT_N]; /* 实部+虚部交叉存放 */ float fft_output[2 * FFT_N]; void Prepare_And_Compute_FFT(uint16_t *adc_buf) { uint32_t sum = 0; /* 1. 先求平均值,然后每个点减掉均值,消去直流分量 */ for (int i = 0; i < FFT_N; i++) { sum += adc_buf[i]; } float dc = (float)sum / FFT_N; /* 2. 填入 FFT 输入数组:实部放码值,虚部清零 */ for (int i = 0; i < FFT_N; i++) { fft_input[2 * i] = (float)adc_buf[i] - dc; fft_input[2 * i + 1] = 0.0f; } /* 3. 调用库函数,库内部会做位反转和蝶形运算 */ Vendor_FFT_Compute(fft_input, fft_output, FFT_N); }逻辑说明:第一步去直流不是可选项。ADC 采集到的信号是叠加在 Vref/2 左右的直流偏置上的,如果不减掉,FFT 输出的 0 Hz 分量会占满整幅画面,其他频点的幅度被压缩得几乎看不见。第二步的实部虚部交替存放,是多家 FFT 库统一要求的输入格式,填错位置的结果是频谱完全错误,而且很难排查。第三步库函数内部会处理位反转,你不需要管。
参数说明:F_S和FFT_N两个宏必须和定时器配置严格对应。如果你改了定时器预分频导致采样率变成 8 kHz,但这里还写 10000,那么频谱图的横轴全部偏了 20%,你看多少遍都找不出问题。另外,FFT 库的输入可以是 float 或 q15 定点格式。定点格式跑得快,但动态范围小,输入满偏时容易溢出;浮点格式慢一些,在 72 MHz 主频下算 1024 点大约需要几十毫秒,用来做屏幕刷新足够,用来做实时流处理就要掂量了。
4.3 幅值换算:把 FFT 输出变成真实电压值
FFT 输出的频点幅度,和你在屏幕上看到的"电压"不是一回事。库函数给的幅度值经过了蝶形运算的累加,通常会比真实幅度大 N/2 倍。还原成原始信号幅度要经过两步:先求模再缩放。直流分量不需要乘 2,非直流分量要乘 2,因为单边频谱把负频率的能量并到了正频率上。
/* 第 k 个频点的幅度换算成电压有效值 */ #define ADC_VREF 3.3f #define ADC_FULL_SCALE 4096.0f float bin_to_voltage(float *fft_output, int k) { float re = fft_output[2 * k]; /* 实部 */ float im = fft_output[2 * k + 1]; /* 虚部 */ float mag = sqrtf(re * re + im * im); if (k == 0) { mag = mag / FFT_N; /* 直流分量不乘 2 */ } else { mag = mag * 2.0f / FFT_N; /* 单边谱换算 */ } /* 把归一化幅度换算成实际电压 */ return mag * ADC_VREF / ADC_FULL_SCALE; }逻辑说明:这个换算分三步:第一步从输出数组里取出实部和虚部,求模得到频点幅度;第二步按单边谱规则缩放,直流不算 2 倍;第三步把无量纲的数字幅度换算成电压。如果只关心相对大小而不关心绝对电压,第三步可以省略,但显示频谱图时建议保留,因为这样你能判断信号幅度到底是不是 0.5 Vrms,而不是只看一个神秘数字。
参数说明:ADC_FULL_SCALE对应 ADC 满量程码值。12 位 ADC 是 4096,如果你用的是 16 位 ADC 就要改成 65536。还有一个常见问题是窗函数:直接做 FFT 相当于加了矩形窗,频谱泄漏明显。库通常提供加窗 API,比如汉宁窗。做音频可视化时,加汉宁窗后主瓣会变宽但旁瓣抑制更好,画面更干净;做精确频率测量时矩形窗更合适,因为频点更尖锐。
5. 常见问题排查:串口、ADC、FFT、OLED 这五个坑我几乎每次都会遇到
5.1 打开工程报错"target device not found",或者 Device 下拉框是空的
现象:双击 .uvprojx 打开工程,编译前提示找不到目标芯片,或者下拉列表里没有可选型号。原因:MDK 装好了,但对应芯片的器件支持包(Pack)没装。这不是工程坏了,是你的环境缺组件。解决:进入 Project → Manage → Pack Installer,在线搜索芯片型号,安装对应版本 Pack。装完关掉重开工程,Device 栏就能识别了。如果公司网络不允许在线下载,可以去某渠道下载离线 Pack 再双击安装,注意要和 Keil 版本位数一致。
5.2 ADC 采集值永远停在 0 或者满量程 4095
现象:程序跑起来了,但 OLED 上和串口里看到的 ADC 码值不是 0 就是 4095,中间值永远不出现。原因有两种。第一,ADC 采样周期设得太短,比如设成了 1.5 个 ADC 时钟周期,内部采样电容来不及充电,转换出来就是 0。第二,输入信号超过了参考电压范围,负压或者高于 Vref 的信号会把结果钳在边界。解决:把 ADC_SampleTime 加大到 20 个周期以上,同时确认信号调理电路已经把输入电压控制在 0~Vref 之间。如果你信号本来就带负压,就必须加偏置电路,别指望 ADC 能测量负电压。
5.3 串口收到的帧错位,校验永远不过
现象:第一次发数据是好的,第二次开始全部乱掉,重启又能好一阵。原因:串口接收端或发送端的字节丢失了。常见来源是串口中断优先级设得太低,被 ADC-DMA 中断和 FFT 计算打断太久,接收寄存器里的字节被新数据覆盖。解决:把串口全局中断优先级提到最高,或者改用 DMA 发送。另外检查波特率计算:如果定时器时钟源是 APB1 而不是 APB2,分频系数要对应,否则波特率会偏差 5% 以上,长时间传输必乱。
5.4 FFT 频谱只有一个巨大的 0 Hz 尖峰,其他频点全都看不清
现象:频谱图上一根柱子顶天立地,别的频点就算有信号也看不见。原因:没有做去直流处理,或者做了去直流但数据还被平均值滤波过了。很多新手把库函数调用前的输入数组直接用原始 ADC 码值填进去,结果就是 0 Hz 分量占了 99% 的动态范围。解决:在准备 FFT 输入时按 4.2 节的做法,先算整段平均值,每个点减去平均值。这一步做完,0 Hz 尖峰会明显下降,真正的信号频率才会显现出来。还有一种隐蔽情况:如果你在 ADC 到 FFT 之间加了滑动平均滤波,高频成分会被压低,频谱图上原本存在的频点会消失,排查时注意区分。
5.5 OLED 一刷新,ADC 采样频率就明显变慢
现象:不显示 OLED 时数据正常,OLED 开启后波形频率往下掉,FFT 主峰位置飘了。原因:OLED 刷新是阻塞操作,如果用的是 4 线 SPI,一次全屏刷新要写 1024 字节,按 1 MHz SPI 算就是 8 毫秒以上。如果采样率是 1 kHz,意味着每采 8 个点就要停一次,频谱自然不对。解决:采集和显示分离。ADC 用 DMA 循环采,不占用 CPU;OLED 只在一个"FFT 已算完"的标志位置位后才刷新一屏。如果还不够,把 OLED 刷新率降到 10 帧,或者在刷新期间开启定时器中断把采样任务抢回来。优先级上,采样任务永远高于显示任务。
| 模块 | 优先级建议 | 说明 |
|---|---|---|
| 串口接收 | 最高 | 字节随时可能来,丢了没法补 |
| ADC-DMA 中断 | 高 | 只做标志位置位,不做事 |
| FFT 计算 | 中 | 可以在主循环里做,允许被打断 |
| OLED 刷新 | 低 | 丢一帧没人看得见 |
6. 验证与进阶:如何确定频谱图不是玄学,以及让它更实用
验证 FFT 结果是否正确,最靠谱的土办法是:用信号发生器输出一个已知频率和幅度的正弦波,比如 1 kHz、0.5 V 峰峰值。接好硬件后,OLED 上应看到明显的一个主峰,串口把幅度换算后的值打出来,对照表显值看误差。没有信号发生器也可以直接用某手机 App 播放 1 kHz 正弦波,配合耳机输出。这一步能同时验证 ADC 采样率配置、FFT 点数和幅值换算三个环节。如果主峰频率偏离 1 kHz,优先查定时器的时钟来源和预分频;如果幅度差一倍,多半是单边谱乘 2 的处理写错了。
标定通过之后,再谈进阶优化。第一个值得做的是频谱平滑:因为 FFT 输出每个频点的跳动很大,尤其在没有窗函数时,屏幕上会看到一根根抖动的柱子。常见做法是对每个频点做一次一阶低通滤波,公式是y[k] = 0.8 * y[k] + 0.2 * new_value[k]。平滑系数决定了响应速度,0.8 适合频谱柱状图,如果做语音实时显示,系数调小到 0.6 会更跟手。第二个值得做的是局部放大:1024 条频谱柱不可能全部画到 128 像素宽的 OLED 上,常见做法是把频段线性或对数压缩。对数压缩对音频更友好,因为听觉本身就是对数感知,代码上就是取20 * log10(mag)再映射到显示范围。
我的一个使用习惯是:每次改完采样率或 FFT 点数,先重跑一遍已知正弦波标定,确认主峰位置没偏再上实际信号。否则你分不清是代码写错了还是信号本来就长这样,这在现场调试时是最让人抓狂的。另外提醒一点:OLED 驱动里如果用了 I2C 接口,刷新速度天生受限,做动态频谱时建议改用 SPI 接口,或者降低刷新帧率。这套链路并不复杂,参数匹配对了,串口、FFT 和 OLED 是可以和谐共处的,希望帮到你。
本文还有配套的精品资源,点击获取