简介:基于STM32F103ZET6的简易示波器程序,定位为单片机学习者与电子工程师的参考例程。它演示了如何利用芯片内置ADC对正弦波、方波等模拟信号进行实时采样,并经SPI/I2C或FSMC接口驱动LCD完成波形绘制,覆盖了定时器采样控制、DMA传输、中断与数据处理等关键知识点。压缩包共272个文件、8.9MB,主要包含C/.H源码、STM32启动文件(.s)、Keil工程文件(.uvproj)、编译中间文件(.o/.lst)、烧录文件(.hex/.axf)以及说明文档,结构完整,可直接编译或烧录到开发板运行。已有4559人学习下载。通过研读代码,既能理解STM32外设初始化与库函数调用方式,也可学习信号采集、缓存处理、LCD刷屏的实现思路;在此基础上可进一步扩展多通道采集、频率测量或FFT频谱分析,适合课程设计、毕设预研和项目原型验证。 玩过STM32F103ZET6的人都明白,示波器程序这个压缩包在网上一抓一大把,但十份里有八份是"能编译、上板子就是不出波形"的状态。我自己也踩过这个坑,从最初在裸机上用延时死等ADC采样,到后来用定时器触发起泵、DMA搬运、触发定位一条链路调通,前前后后折腾了一个多星期。作为过来人,我建议刚接触这个项目的朋友别急着双击.rar看keil工程,先把整个系统拆开看明白,否则你连波形抖动、削顶、混叠这些问题出在哪里都判断不了。
这篇文章的内容,我会从标题里的"STM32F103ZET6+示波器"这两个关键词展开,讲清楚一块Cortex-M3内核的单片机为什么能当示波器用、能到什么性能档次、硬件上哪些细节不能省、软件上哪几个逻辑是核心,以及我实测过程里遇到的坑。不管你拿到的压缩包是哪个版本,这篇文章都能帮你把它调出稳定可用的波形显示。
1. 这个示波器项目到底是什么:拆一下标题里的信息量
很多人看到"STM32F103ZET6示波器"会误以为这是个玩具级的东西,实际上这个组合是嵌入式学习里相当经典的"性能压榨型"项目。之所以选ZET6而不是C8T6或者G0系列,核心原因是这个芯片的几个硬指标刚好卡在入门示波器的门槛上。
1.1 STM32F103ZET6能做到什么程度
ZET6的关键资源是:72MHz主频、512KB Flash、64KB SRAM、3个12位ADC、2个DAC、5个串口、1个DMA控制器,LQFP144封装下有多达112个可用的GPIO。仪器底子主要是ADC部分,3个12位ADC理论上可以完成交叉采样,让等效采样率冲到数Msps级别。
但在实际工程里,我不会建议你一开始就上三重ADC交叉采样。交叉采样的相位误差、通道一致性,在没有高精度时钟源和前端调理电路的情况下很难控制好。单个ADC配合定时器触发,稳定跑出150kHz到1MHz的采样率,对于看PWM呼吸灯波形、电源纹波、串口波特率信号、音频信号这类日常调试完全够用。这也是绝大多数ST官方和开源社区的方案采用的路线。
另外一个容易被忽略的参数是SRAM。64KB的SRAM意味着你可以开很大的采样缓冲区,比如做10KB、20KB的深度采样,把一段长波形一次性抓到内存里再慢慢分析。相比之下,那些小容量芯片做示波器,缓冲区只有几百个点,看波形经常只能看到"一段一段"的画面,很难受。
1.2 示波器程序的核心模块划分
一个完整的STM32示波器固件,不管压缩包里代码写得再乱,最终跑起来一定是这几个模块在配合工作:
- 信号调理:如果不外接模拟前端,直接用ADC引脚采样,那就至少要有分压电阻和钳位保护电路。
- ADC采样控制:定时器产生触发事件,ADC在固定的时间点启动转换,保证采样时间间隔均匀。
- 数据搬运:ADC转换完成由DMA搬运进SRAM缓冲区,CPU不参与每次搬运。
- 触发定位:在一段采样数据中查找满足电平条件的位置,把波形"对齐"到你预设的触发电平上。
- 数据处理与显示:把原始ADC值映射成屏幕坐标,画网格、画波形、叠加刻度文字。
- 按键与交互:调整电压档位、时间档位、触发电平,这些参数必须能实时改。
把模块拆开之后,你就会发现调试的顺序也有了:先保证ADC能连续采到数,再验证DMA搬运数据完整,接着做触发定位,最后才折腾显示。很多人拿到程序一上来就炫屏,结果波形根本不稳定,问题往往出在前面两步,而且是最无聊的配置细节上。
2. 硬件要点:仪器级信号链路是软件救不了的
STM32F103ZET6的内部ADC再好,信号从探头进来的时候被弄坏了,后面软件再怎么滤波、插值都没有意义。所以在电路层面,这是一条"信号链路",不是你平时焊个最小系统板那么简单。
2.1 前端信号调理:探头的衰减与偏置
最简单的示波器前端至少要考虑两个问题:量程和保护。
如果你直接用3.3V供电的ADC引脚去测一个12V的电平信号,芯片很可能直接烧掉,这一点做过的人都懂。正规做法是用精密电阻分压,把被测信号等比衰减到ADC可接受的范围。比如测0~24V的直流电平,可以用一对100kΩ和10kΩ的电阻分压,衰减11倍,然后再用运放跟随器做阻抗匹配。给ADC提供一个高输入阻抗、低输出阻抗的信号源,这是保证采样精度的一个重要前提,我实测下来,没有跟随器和高阻分压时,ADC读数受采样保持电路的充放电影响,误差能超过十几毫伏,低频下还能接受,高频时波形幅度明显偏低。
交流耦合和偏置调整也不能漏。测音频、测纹波这种叠加在直流上的交流信号,如果直接把信号接到ADC,直流分量会占掉很大一部分量程,你关心的纹波细节反而看不到。所以前端需要一个隔直电容,把直流分量滤掉,再叠加一个可调的直流偏置,让交流信号中心处于ADC量程的中部。这个偏置通常用电位器加运放实现,软件里再做一个中值校准。
另外,两颗背靠背的钳位二极管或者齐纳二极管,把输入电压限制在0~3.3V以内,是保护ADC输入的最基本手段,成本几毛钱,但能省掉一整块板子。强烈建议无论多简单的版本都加上。
2.2 ADC采样电路与参考电压处理
STM32F103的ADC是12位逐次逼近型,它需要一个稳定的参考电压。板子上如果VREF+和VDDA只是简单接到3.3V电源,ADC低位的数据跳动会非常明显,特别是测小信号时。哪怕不做独立的基准源,也应该在VDDA引脚并联0.1μF和1μF以上的滤波电容,并且把模拟电源和数字电源用电感或者磁珠分割开。我自己第一次调的时候,ADC采集的噪声一直是4到6个LSB跳动,后来在VDDA和VSSA引脚上加强滤波、缩短地回路之后,跳动降到了1个LSB以内。
此外,ADC的时钟频率不能乱配。STM32F103的ADC工作在12MHz以上时(极限14MHz),采样非线性会变差。标准做法是PCLK2=72MHz,ADC分频系数选择6,让ADC时钟稳定在12MHz。在这个前提下,单个通道的最短转换时间可以做到1μs左右,也就是大概1Msps。如果你把分频配成4,ADC时钟到18MHz,采集速度看似快了不少,但很多芯片跑在这个配置下线性度明显下降,数据曲线在零点附近会出现台阶,这个我用可调电源实测验证过。
2.3 显示屏幕与按键接口选型
显示部分,大多数压缩包默认驱动的是3.2英寸或者4.3英寸的TFT-LCD,常见控制器有ILI9341、ILI9325、SSD1963等。选择屏幕时,除了分辨率,更要关注刷新接口。ILI9341用SPI接口时,240x320全屏刷新需要几百毫秒,画波形还好,但如果做菜单、波形缩放,体验会非常卡。SSD1963这类支持RGB并口直插的控制器,配合FSMC接口,刷新速度快一个量级。ZET6刚好有FSMC外设,可以像操作SRAM一样操作LCD,这是这个芯片做示波器显示的又一大优势。
按键至少需要5个:上、下、左、右、确认,如果愿意再加一个复位。触发电平调节和电压/时间档位切换都靠这组按键。用带ADC按键分压的方案可以省IO口,但多个按键同时按下时会互相干扰。更稳的做法是直接接GPIO,ZET6最不缺的就是引脚。
3. 软件逻辑拆解:采样、触发、画点三个循环
软件是这个项目的灵魂。很多人卡住的点在触发,但我得先提醒一句:如果采样节奏都没控制好,触发做出来也是白做。所以下面按数据流的顺序来讲。
3.1 定时器触发采样的节奏控制
要让ADC以固定间隔采样,最理想的方式是用定时器的TRGO事件作为ADC的触发源。以TIM3为例,配置TIM3的主频为某个分频后频率,设置自动重装载值(ARR)来控制周期。例如要得到100kHz的采样率,可以这样配置:72MHz时钟,预分频PSC设为9(即10分频),得到7.2MHz的计数频率,ARR设为71,那么触发频率是7200000/72=100kHz。
代码逻辑如下:
void ADC_TIM_Init(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); TIM_TimeBaseStructure.TIM_Period = 71; // ARR TIM_TimeBaseStructure.TIM_Prescaler = 9; // PSC TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); TIM_SelectOutputTrigger(TIM3, TIM_TRGOSource_Update); // TRGO TIM_Cmd(TIM3, ENABLE); // 使能前建议先关闭,确认配置完成再开启 }然后配置ADC的外部触发方式为TIM3的TRGO。这里有个细节:ADC的外部触发在STM32F103上需要等待一个稳定时间,官方库函数里有ADC_ExternalTrigConvCmd(ADC1, ENABLE)这个接口用于启动外部触发模式,不少老代码漏了这句导致ADC不转换。这个函数在标准外设库中是控制EXTSEL位的使能开关,如果漏掉,你会发现定时器跑了但ADC没有任何数据。
为什么不推荐直接在ADC中断里读数据然后软件发起下一次转换?因为ADC转换完成中断响应有延迟,中断里又要处理数值、启动下一次转换,时间一长采样间隔就会抖动。示波器最忌讳的就是横轴时间不均匀。定时器硬件触发+DMA搬运,是唯一能在入门级MCU上做到数据时序稳定的办法。
3.2 DMA双缓冲:不要让CPU搬数据
ADC采集的数据需要送到内存,如果ADC中断每次去读,CPU会被大量打断,导致波形显示刷新变慢。正确做法是DMA循环模式,把ADC1->DR地址作为源,指定数组作为目标,传输长度设为缓冲区的容量(比如2048或4096个点)。
DMA有一个很有用的功能是半传输中断(DMA_IT_HT)和传输完成中断(DMA_IT_TC)。利用这两个中断可以实现双缓冲:当DMA搬运完前半段数据时,CPU可以处理前半段(比如触发判定、波形绘制),同时DMA还在继续往后半段搬运;当后半段完成时,CPU再处理后半段。这种"乒乓"结构能极大提高数据处理的并行度。
配置要点:
DMA_InitTypeDef DMA_InitStructure; DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)SampleBuffer; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = BUFFER_SIZE; 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(ADC1_DMA_Channel, &DMA_InitStructure);这里有两个坑:一是数据宽度必须用HalfWord,也就是16位,因为ADC是12位的,放在寄存器低12位,如果用Byte宽度,你会得到交替错乱的字节,数据完全没法看;二是DMA要选择正确的通道,ADC1的DMA请求对应的是DMA1_Channel1,ADC2对应DMA1_Channel2,ADC3对应DMA2_Channel5,配错通道看起来程序正常,但数据永远不更新。
3.3 触发判断:从"乱跑"到"锁定"的关键
波形在屏幕上左右乱飞,是示波器项目里最常见的现象。原因是采样数据出来了,但没有做触发定位,每次开始画的点都不一样。触发的本质很简单:在数据缓冲区里,找到第一个满足"电压跨过触发电平并且方向是设定的边沿"的位置,从那个位置开始取一屏数据来显示。
以上升沿触发为例,算法可以这样写:
int16_t FindTriggerPoint(uint16_t *buf, uint32_t len, uint16_t threshold) { uint32_t i; for (i = 1; i < len; i++) { if (buf[i-1] < threshold && buf[i] >= threshold) { return i; // 找到跨过阈值的点 } } return -1; // 未找到 }这个逻辑不难,但要实用还需要处理几个细节:
触发电平需要一个滞回区间,否则信号在阈值附近上下抖动时,一次扫描可能触发多次,画面还是会晃。我常用的做法是:只有当前一个点低于threshold - HYSTERESIS且后一个点高于threshold + HYSTERESIS才认为触发。HYSTERESIS可以取信号幅度的3%到5%。
触发模式要有自动和正常两种。如果没有触发条件(比如信号幅度小于触发电平),自动模式下就周期性刷新最近的一段数据,保证屏幕不黑;正常模式则要等触发条件满足才刷新一帧。软件实现时,用一个标志位区分就行。
触发点的数据寻址是这样的:触发点不一定要落在缓冲区开头。如果找到触发点在位置N,那么显示起点是buf + N - DISPLAY_POINTS / 2,让触发点位于屏幕水平方向大约1/3到1/2的位置,这是很多示波器的默认习惯。如果N小于DISPLAY_POINTS/2,说明触发电平附近可用的数据不够,这时候可以等下一次DMA传输完成,或者做数据块移位处理。
3.4 波形绘制与坐标标定
波形绘制我建议采用"画线连接"而不是"打点"。打点模式在采样点稀疏、波形斜率大的地方会出现断点,看起来像虚线。画线的做法是在相邻两个显示坐标之间做线性插值。LCD如果是320像素宽,显示波形区域放在左边320x200的范围,右侧留一部分给菜单。每两个采样点之间映射到X轴可能只有零点几个像素到几个像素,具体映射要根据当前时间档位计算。
电压标度的核心公式是:
uint16_t y = (uint16_t)( (4095L - adc_value) / 4096.0f * DISPLAY_HEIGHT );为什么用4095L - adc_value?因为屏幕Y轴一般是上小下大,而ADC值越大电压越高,如果不反相,波形会上下颠倒。初学的人经常看到正弦波相位对不上才意识到这个问题。
Y轴标尺显示也要换算:已知ADC参考电压是3.3V,分压衰减倍数是比如10倍,那么一个LSB对应实际电压是3.3/4096*10≈8.06mV。显示"每格1V"还是"每格100mV"取决于你当前设置的电压档位。这些标定稀疏乘法计算量不大,可以用整数运算避免浮点,提高显示速度。
4. 实操落地:把程序烧进板子的完整步骤
这里我假定你手上的压缩包是标准的STM32库函数版工程,编译环境是Keil MDK。如果你的压缩包是寄存器版本也没有问题,核心步骤是一样的,只是底层写入方式不同。
4.1 引脚初始化的几个关键位置
时钟初始化是第一个大坑。ZET6的ADC挂在APB2总线上,你的启动代码里必须显式开启ADC1时钟、DMA1时钟、GPIOA时钟、TIM3时钟(如果用TIM3),缺一个ADC数据就为零。GPIO配置ADC输入引脚时,必须配置为模拟输入模式,即GPIO_Mode_AIN。配置成浮空输入或者上拉下拉输入都不对,会让ADC读数产生偏差。
LCD的FSMC时序要对照屏幕控制器手册查参数。常见错误是地址建立时间和数据建立时间配得太快,导致字节错位、花屏。ILI9341这类屏幕配合FSMC时,我用过一组比较稳的参数:地址建立时间16个HCLK、数据建立时间16个HCLK,写操作时序偏慢但是显示绝对稳定。如果你想追求刷新率,再逐步把这些参数调小然后观察是否有花屏。
4.2 采样率、电压档位与时间轴的对应关系
采样率决定了你能看到的最高信号频率。按照奈奎斯特定理,理论上采样率至少是信号频率的2倍才不会混叠,但实际显示时,上升沿、下降沿的细节需要更高的倍数,工程上至少5到10倍才能看出波形的形状。比如采样率100kHz时,显示10kHz方波就已经比较吃力,上升沿会变得很平缓;显示1kHz方波就非常清晰。
时间档位和采样率之间的关系由每屏点数决定。如果缓冲区有2048个点,每屏显示2048点,采样率100kHz时,一屏对应的实际时间是2048/100000=20.48ms,也就是屏上X轴左侧到右侧总时长约20ms。你切到200kHz采样率,一屏时间就变成10ms左右。按键"左/右"调整时间档位时,其实改的是定时器ARR值和显示点数映射参数,两者要联动修改,只改一个会导致时间轴显示不对。
4.3 性能优化:怎么把刷新率提上去
一块240x320的LCD,如果整屏全部重画,就算FSMC并口也可能要30到50ms,示波器刷新率就上不去,波形会闪。我的优化经验有三个:
第一个是波形区域采用局部刷新。波形只画在固定的窗口内,背景网格和坐标文字不随每次波形变化重刷,可以用一块显存区域保存固定的背景,刷新时只更新波形像素。这个方法操作量少,效果明显。
第二个是采用显存偏移的方式画波形。IL9325系列屏幕可以设置窗口坐标,然后连续写入像素数据,避免每画一个点切换一次RGB地址。STM32库的LCD驱动里如果你看到每次打点都有写地址的IO操作,那刷新速度肯定慢。正确做法是设置好窗口后,用连续的写数据操作把一帧波形刷完。
第三个是双缓冲到SRAM里的帧缓冲。ZET6有64KB SRAM,可以用大概320x200x2=128KB的1/4大小做双缓冲,或者用压缩格式。如果显存不够,可以用"幽灵缓冲"的方式,先在内存数组里画好波形,再一次DMA刷到LCD。实测下来,加了区域刷新和连续写窗口后,300x200波形区域刷新能在10ms以内跑完一帧,波形已经不太闪了。
5. 调试验收:拿信号源说话
程序写完不是看点灯式地"没崩溃"就行,示波器项目必须用标准信号源验证精度。手头没有函数发生器的话,用STM32的DAC生成正弦波或者方波,再用杜邦线接到ADC输入,也是一个自测方案。
5.1 用方波/正弦波校准的步骤
先把输入信号接到ADC引脚,设为自动触发模式,时间档位调到信号的几个周期内。此时你应该能看到波形大致成型。接下来逐项验证:
- 检查电压读数:用万用表测ADC引脚对地电压,和屏上显示的电压值对比。比如DAC输出1.65V,屏上显示应该在1.64V到1.66V之间,超过这个误差就要检查分压电阻精度和参考电压。
- 检查频率读数:用已知频率的信号源,比如1kHz正弦波,检查屏上每个完整周期占据的横轴格数。一屏20ms显示1kHz的信号应该有20个周期,如果只显示10个周期,说明采样率比设定值慢了一倍,多半是定时器分频配错或者主频不对。
- 检查上升沿:用方波信号看上升沿,如果看到明显倾斜甚至振铃,一是信号源本身的问题,二是前端带宽不足,三是采样率太低。把采样率提高再对比就能定位原因。
5.2 常见故障与排查速查表
我整理了这几个月里最常遇到的问题和对应的排查方案,你也可能在调这个项目时遇到完全一样的情况。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| ADC数据一直为0 | 模拟输入模式未配置;ADC未开启 | 检查GPIO_Mode_AIN配置和ADC_Cmd是否启用 |
| 波形上下颠倒 | Y坐标映射未反相 | 核对Y坐标计算是否用了4095 - adc_value |
| 波形左右乱跑 | 触发定位没做或触发电平超出信号范围 | 把触发阈值设在信号幅度中间再测试 |
| 波形显示缓慢且闪烁 | 全屏刷新、逐点写屏 | 改局部刷新、窗口连续写、减少菜单刷新 |
| 电压读数整体偏高/偏低 | 参考电压不准、分压电阻有误差 | 用万用表校准,再用电位器校准增益系数 |
| 高频信号变形 | 采样率不够或ADC时钟超频 | 检查定时器触发频率,把ADC时钟配到12MHz |
| 波形上有固定毛刺 | 数字地干扰模拟地 | 模拟地和数字地单点连接,加强VDDA滤波 |
遇到波形显示不正常,不要一上来就怀疑代码逻辑,先拿一个已知的稳定信号(比如电池电压分压得到的1.5V直流)去验证ADC采集数值是否准确。如果直流都测不准,波形显示问题大概率是硬件问题。
6. 写在最后:这个项目还能怎么玩
当我第一次在这个板子上看到稳定的1kHz正弦波流下来的时候,那种成就感真的无可替代。这个项目最迷人的地方在于,它把ADC、DMA、定时器、中断、显示驱动这些零散的知识点全部串成了一条完整的数据流。你只要完整走一遍,对STM32整个体系的理解能上一个台阶。
调完基础版本之后,我建议你往几个方向扩展一下:给信号前端加一个真正意义上的程控增益放大,用运放和模拟开关实现多档量程自动切换;把采样数据通过串口或者USB发给PC,在上位机做FFT频谱分析,这样就能把STM32示波器扩展成简易频谱仪;还可以把触发方式从简单的边沿触发升级成脉冲宽度触发,用来抓串口通信的毛刺。这些扩展每一个都不简单,但是每一个都能让你对示波器和嵌入式系统有更深的理解。
如果你身边有别的同学也在调这个压缩包,记得提醒他先把硬件前端处理好,再动软件,不然很容易出现"代码改到头了波形还是坏的"的尴尬局面。祝折腾愉快。
本文还有配套的精品资源,点击获取