☰
基于STM32F103ZET6的简易示波器:从信号调理到波形显示全解析
2026/10/20 10:26:53 网站建设 项目流程

简介:基于STM32F103ZET6的简易示波器程序是一份可直接编译运行的完整工程,适合嵌入式初学者、电子爱好者和硬件调试人员理解单片机信号采集与显示的基本方法。项目借助芯片内置的12通道模数转换器采集外部模拟信号,经定时器控制采样节奏,再通过串行外设接口或I2C总线把波形绘制到液晶屏上,能够清晰呈现正弦波、方波等常见波形,覆盖了从模拟输入到图形输出的完整数据链路。压缩包共包含272个文件,大小约8.9MB,其中数量较多的为编译链接生成的中间文件和列表文件,同时包含完整的C语言源文件、头文件、Keil工程配置、AXF与HEX烧录文件,以及DOC和TXT说明文档,便于直接查阅、编译和烧录。目前已有4570人学习或下载。借助这套代码,读者可以重点研究模数转换采样率设置、定时器中断处理、显示缓冲管理和液晶屏驱动移植等关键实现;配合工程中的烧录文件与说明文档,可快速验证波形显示效果,也可在其基础上扩展多通道采集、触发电平调节或彩色波形显示,是STM32裸机开发与示波器原理学习的实用参考。

1. 基于STM32F103ZET6的简易示波器程序包:先看清它到底能干什么

如果你手头正好有一颗F103ZET6,又不想花几百块买台式示波器,那颗芯片完全能撑起一台简易示波器——但前提是你得知道它的边界。这个基于STM32F103ZET6的示波器程序包,核心就是让这颗主频72MHz的单片机承担ADC采样、数据处理和显示刷新全部流程,整机成本可以压到几十块钱。它适合这几类人:做电源、电机驱动、传感器调试的嵌入式工程师,想把采集到的波形实时画到TFT屏上的单片机学习爱好者,以及被实验室示波器数量不够逼着动手DIY的在校生。它能看1kHz到几十kHz的正弦波、方波、PWM信号,能调节触发电平和时基,也能让你理解“屏幕上的波形到底是怎么画出来的”——这跟你拿逻辑分析仪看数字信号是两种完全不同的思维。下面我按实际动手的顺序,拆解这套程序包从硬件前端到显示交互的每一个关键环节。

2. 信号调理电路:采样前不做这一步,ADC读到的全是玄学

2.1 为什么STM32不能直接采双极性信号

F103ZET6的ADC输入端不允许出现负压,参考电压接3.3V时,输入范围只有0~3.3V。可实际你要测的信号往往是双极性的,比如音频信号在0V上下摆动,电机驱动端的电流采样可能是-2.5V到+2.5V,直接怼到PA1上轻则读数削底,重则烧坏引脚内部的ESD保护二极管。

所以程序包对应的硬件方案里,一定有一级信号调理电路。常见做法是先经过电阻分压网络把大信号衰减到ADC量程内,再叠加一个直流偏置,把原本以0V为中心的信号“抬”到1.65V为中心,最后经过一级运放跟随器缓冲后送进ADC引脚。这套电路的等效模型可以拆成三步:输入电阻分压、偏置叠加、阻抗变换。

以最常见的±5V输入量程为例,分压比值选2:1,即输入信号先经过两只等值电阻对地分压,把±5V变成±2.5V;然后通过一只运放构成的加法器叠加+1.65V偏置,输出就变成了0.4V到2.9V的正电压区间,正好落在ADC安全范围内。偏置基准不建议直接从3.3V电源分压取得,因为MCU工作电流变化会让3.3V有几十毫伏的波动,反映到波形上就是基线抖动。我一般会用一只便宜的基准二极管或者LDO输出做偏置源,哪怕精度只有2%,也比直接拉电源干净。

2.2 衰减档位与输入阻抗的参数取舍

程序包里如果设计了多档量程,一般是通过模拟开关或继电器切换分压电阻来实现。下面是一组常用的档位参数,对应输入阻抗都设计在1MΩ左右,这是抄台式示波器的习惯,方便你接各种探头而不明显改变被测电路的负载。

档位量程范围分压比输入电阻并联补偿电容
1±20V10:11MΩ10pF
2±10V5:11MΩ15pF
3±5V2:11MΩ22pF
4±1V1:1100kΩ不补偿

补偿电容是关键。电阻分压网络里,电阻本身的寄生电容和下一级运放输入电容会形成一个低通滤波器,频率稍高波形就会变圆。并联一个小电容可以把这个低通效应抵消掉,让分压比在高频下依然保持稳定。具体的补偿电容值没有固定公式,我习惯用信号发生器输出1kHz方波去观察波形边沿,反复调整电容直到方波的上升沿既不过冲也不变缓为止。这个调校过程确实是“玄学”居多,但做一次之后记下数值,同一个PCB布局基本可以直接复用。

2.3 跟随器与钳位保护的实际接法

运放跟随器这一级,带宽至少要覆盖信号频率的10倍。便宜的单通道轨到轨运放,比如SGM8521、MCP6001这类,增益带宽积1MHz左右,测几十kHz的信号问题不大;如果你想看100kHz以上的信号,就得换增益带宽积更高的型号,否则运放自身就把高频衰减掉了。跟随器输出到ADC引脚之间,我习惯串一只100Ω电阻,再对地并联一只100pF电容,构成一个截止频率约16MHz的低通,主要目的是压制采样开关动作引起的毛刺回灌到信号源。

钳位保护也不能省。在ADC引脚对3.3V和GND各接一只开关速度快的肖特基二极管,比如BAT54系列,能把意外过压钳制在安全范围。别用1N4148,恢复速度太慢,高频信号来的时候它根本来不及导通。这一层的价值体现在你拿探头去戳一个没见过的节点时,它能给你一次“后悔药”的机会。

3. 采样引擎配置:定时器触发ADC与DMA搬运,把采样率榨干

3.1 为什么不用连续转换模式

很多人第一次写ADC程序,用HAL_ADC_Start()加while循环轮询读数据,这在示波器场景下是错的。连续转换模式下,ADC转换完一个通道马上开始下一次,采样间隔完全取决于转换时间,抖动很大,画出来的波形在时间轴上是飘的,触发位置对不齐。简易示波器需要用定时器触发ADC启动转换,这样每个采样点之间的时间间隔是定时器硬件决定的,抖动只有几十纳秒,波形才稳定。

具体触发链路是这样的:定时器TIM2产生更新事件(update event),通过TRGO输出连接到ADC的触发输入;ADC收到触发后开始一次转换,转换完成后DMA把结果从ADC数据寄存器搬到内存数组。这里定时器担当的是节拍器角色,ADC只负责按节拍转换,DMA负责搬运,CPU全程不参与单个采样点的读取,可以腾出手去处理显示和按键扫描。

3.2 DMA双缓冲与普通模式怎么选

先给一份能直接跑起来的初始化代码(HAL库风格),配合CubeMX生成的框架来看,最核心的是下面这几段配置:

// 定时器TIM2配置,输出触发信号给ADC TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; HAL_TIM_ConfigClockSource(&htim2, &sClockSourceConfig); sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE; // 更新事件作为触发输出 sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig); // 定时器分频和重载值决定采样率 htim2.Init.Prescaler = 71; // 72MHz / 72 = 1MHz计数频率 htim2.Init.Period = 9; // 100kHz溢出率,即100kSPS采样率 htim2.Init.CounterMode = TIM_COUNTERMODE_UP; HAL_TIM_Base_Init(&htim2); // ADC1配置,由外部触发启动转换 hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = ADC_SCAN_DISABLE; // 单通道模式 hadc1.Init.ContinuousConvMode = DISABLE; // 关闭连续转换,必须由定时器触发 hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T2_TRGO; // TIM2触发 hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_RISING; hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion = 1; hadc1.Init.Resolution = ADC_RESOLUTION_12B; HAL_ADC_Init(&hadc1); // DMA配置,普通模式单次搬运1024点 hdma_adc1.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_adc1.Init.PeriphInc = DMA_PINC_DISABLE; hdma_adc1.Init.MemInc = DMA_MINC_ENABLE; hdma_adc1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; hdma_adc1.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_adc1.Init.Mode = DMA_NORMAL; // 普通模式,采集一帧后停止 hdma_adc1.Init.Priority = DMA_PRIORITY_HIGH;

这段配置里最值得关注的是采样率计算公式:采样率 = 定时器时钟 / (Prescaler + 1) / (Period + 1)。上面参数算出的是100kHz,这个值对看音频、PWM、电源纹波足够用。要注意ADC本身的转换时间瓶颈:ADC时钟配置为12MHz时,最短转换时间是1.5采样周期加固定12.5周期,大约14个ADC时钟周期,折合1.17μs,也就是最高约850kSPS。所以当你把定时器触发频率调到超过这个值时,ADC根本转不过来,数据会变成错位的乱码。

至于DMA模式,程序包里给的是DMA_NORMAL单次采集1024点,传给CPU找触发点再画波形。如果你想提升波形刷新率,可以把模式改成DMA_CIRCULAR循环模式,配合半传输完成中断实现“上半帧数据在画、下半帧数据在采”的双缓冲效果。代价是代码复杂度上了一个台阶,中断里要做触发点搜索,稍不注意就会把数据传输到一半的数组覆盖掉。我建议新手先用普通模式跑通整条链路,确认波形稳定后,再动双缓冲这个进阶开关。

3.3 采样率与时基档位的换算关系

时基档位对应的是屏幕横轴每格代表多少时间。假设屏幕宽度240像素,要显示一个完整周期占屏幕一半即120像素,那么采样率、信号频率和屏幕宽度之间得满足一个约束关系:采样点数N = 采样率 / 信号频率,而N必须落在屏幕像素数的一个合理范围内。程序包里换挡的逻辑一般是改定时器的Period值,采样率随之变化,而数组长度固定1024不变。参数对照可以这样估算:显示10kHz信号且希望一个周期占120像素,采样率就需要到10k×120=1.2MSPS,这个值已经超出F103的ADC极限了,所以简易示波器对高频信号的观察能力天然受限。遇到这种情况,要么降低有效分辨率,只看波形包络,要么把这个活交给48MHz以上的ARM去做。

4. 波形显示与交互:FSMC总线刷屏与触发电平实现

4.1 显示方案怎么选

F103ZET6这个型号最大的优势之一是带FSMC控制器,可以并行接口直驱TFT液晶屏。数据总线一次写16位,刷一屏240×320的RGB565图像数据,理论带宽远高于SPI屏。程序包里如果用的是2.4寸或2.8寸并行屏,核心就是把屏当成外部存储器来访问,写一个像素只需要一条总线写指令。对应基地址在0x60000000,A0地址线用来区分命令和数据的写法很固定:

#define LCD_CMD (*(volatile uint16_t *)0x60000000) #define LCD_DATA (*(volatile uint16_t *)0x60020000) // 写寄存器命令 LCD_CMD = 0x002A; // 设置列地址 LCD_DATA = x_start; LCD_DATA = x_end; // 写像素数据 LCD_DATA = pixel; // 连续写入,地址自动递增

如果用SPI接口的OLED,那就完全告别并行总线的思路,靠GPIO模拟SPI或者硬件SPI刷波形。SPI屏的优势是接线少、程序包做出来体积小,劣势是波形刷新率腰斩。我实际对比过,同样的波形绘制循环,并屏F103能在1ms内写完一帧波形数据,SPI屏要3~5ms,肉眼能看出刷新变慢。如果你的程序包只适配了OLED,建议把SPI时钟调到18MHz以上,并开启DMA传输,否则波形拖动时会明显卡顿。

4.2 波形绘制与网格叠加

把ADC采集的原始数据映射到屏幕坐标,本质上是一个线性变换。垂直方向要把4096个量化级别映射到屏幕高度240像素上,水平方向要把1024个采样点映射到240个像素列。这里有个小技巧:不要每个像素点都只画一个点,而是先找触发点,然后从触发点开始每隔若干个采样点取一个点,相邻点在屏幕上用直线连接。这样画出来的波形是连续的,不会因为采样率与屏幕宽度的折算关系出现断线。

触发电平的实现有两种思路。程序包如果是纯软件触发,做法是:采集完一帧后,从数组第一个元素开始找第一个满足“前一采样点低于触发电平,后一个采样点高于触发电平”的位置,把这个位置作为波形绘制的起点。这种方式实现简单,代价是每次触发扫描都要把1024个点过一遍,CPU占用率上去了。另一种方案是边沿捕获触发,利用定时器输入捕获产生硬件中断,中断里记录当前DMA写入位置,并把该位置附近的数据标记为触发点。硬件触发实时性更好,但需要额外占用一个定时器通道。对简易示波器来说,软件触发帧率能做到每秒20帧以上,够用了。

触发电平的调节映射也要做细。屏幕上画了一条黄色的触发线,按键上下移动这条线时,要把它对应的像素坐标转换为ADC的量化值,然后与波形数组里的数据做比较。这个映射关系是线性的:触发AD值 = 触发电平百分比 × 4095,而百分比又来自像素位置与屏幕高度的比值。我见过程序包里这个转换写反的,按键按到头波形反而乱跳,最后查出来是像素坐标没有减去波形区域的起始偏移。

4.3 按键状态机与界面切换

简易示波器的交互无非是几个按键:运行/停止、触发电平加减、时基加减、量程切换。我建议按键扫描用20ms消抖加短按长按区分,短按负责步进调节,长按负责自动量程。界面上通常分成波形区、状态栏和触发电平指示三部分。状态栏显示当前的采样率、时基档位和ADC幅值范围,这排文字在每次参数变化后重绘,不要整屏刷新,否则波形区会被拖慢。波形区只画网格和当前波形,绘制顺序是先清屏为黑色,再画网格线,最后画波形曲线。注意网格线和波形的颜色对比要拉开,否则信号淹没在格子里看不清。

5. 排查与避坑:采样错位、屏闪撕裂和毛刺干扰的实战记录

5.1 波形头部出现不规则跳变

现象:每次触发采样后,屏幕上波形最左边十几个点明显乱跳,像被随机噪声打了一顿,右侧波形正常。

原因:ADC首次转换的数据不稳定。定时器刚触发ADC启动时,采样保持电容还在充放电过程中,前几次转换结果不可靠;另外ADC未做校准,内部电容失配会影响初始几个点的线性度。

解决:程序初始化时调用HAL_ADCEx_Calibration_Start()开始校准,并在正式采集前丢弃前半帧的前64个采样点。做法是把DMA回调里起始偏移设为64,波形绘制从数组下标64开始映射。这个偏移量是常量,不会影响触发判断,因为触发搜索同样从64开始即可。

5.2 正弦波看起来像阶梯波

现象:输入信号明明是光滑的正弦波,屏上却出现明显的阶梯状台阶,尤其在信号斜率高的时候更明显。

原因:采样率低于信号变化率。ADC是离散采样,正弦波在过零点附近的瞬时变化率最快,当采样间隔内信号变化超过两个LSB时,相邻两个采样点之间就有肉眼可见的台阶。这不是故障,是采样定理的物理限制。

解决:把时基档位往小调,提高采样率;如果采样率已经到顶,就只能接受这个失真。另一个容易踩的点是采样保持时间设置过短,源阻抗高的时候电荷还没来得及充满就转入转换阶段,会额外引入误差。把ADC采样周期从1.5周期改到7.5周期能明显改善低阻抗源下的小信号波形质量。

5.3 波形整体上下缓慢漂移

现象:输入一个直流电平,屏上波形稳定后仍以0.5Hz左右的频率缓慢上下浮动,幅度几十毫伏。

原因:偏置源或基准电压受温度影响,或者电源纹波直接串入了跟随器。F103的内部参考电压精度本身就不高,随温度变化约几十ppm/℃,用在示波器里做测量基准显然不合适。

解决:改用外部基准源,哪怕是TL431之类的简易基准,也比内部参考稳定得多。同时检查运放供电的去耦电容,靠近电源引脚放10μF和100nF并联,让信号地单点接地,别让采样电流回流路径与显示背光电流共用一段铜皮。

5.4 切换到双缓冲模式后画面撕裂

现象:开启DMA循环模式后,波形上半部分来自上一帧,下半部分来自下一帧,连接处有明显的水平分界线。

原因:DMA还在往当前帧数组写入数据时,CPU已经开始从同一个数组读取并显示波形,导致读写同一块内存,读到的是新旧混合的数据。

解决:双缓冲必须用两个数组交替工作。具体做法是把数组声明成buffer[2][1024],DMA循环写入buffer[0]时,CPU绘制buffer[1];DMA半传输完成切换到buffer[1]时,CPU绘制buffer[0]。切换标志放在DMA中断里,CPU绘制前先查询当前活跃缓冲区的索引。我最初在这个问题上翻车后,习惯在代码里加一个调试用计数器,每帧绘制完递增,如果计数器值与DMA中断标志对不上,就说明缓冲区切换时序出了问题。

5.5 波形绘制出现横线断层

现象:波形在某些位置突然缺了一段,变成一条横线,持续几帧后又恢复。

原因:DMA普通模式采集完一帧后,如果CPU处理显示耗时过长,下一次启动采集的间隔就不固定。这个间隔内信号变化过程完全丢失,看起来就是波形中间少了内容。

解决:优先保证采样连续性。一个有效做法是把采样启动放在DMA传输完成中断里,中断里只启动下一次采集,不处理显示;显示逻辑放在主循环里。这样采样间隔是“中断响应时间”量级的,远小于信号周期,就不会再出现断层。

6. 进阶:用输入捕获连动时基自动换挡,让简易示波器更好用

程序包玩熟之后,我最想推荐的一个进阶技巧是:用定时器输入捕获测出被测信号的频率,然后自动反推合适的采样率与时基档位。这个技巧能让你按一下“自动”键,波形就自动调整到占屏幕宽度合适的比例,而不是手动反复按时基按键。

实现的思路分两步。第一步配置一个空闲的定时器通道做输入捕获,上升沿触发,记录两次捕获的计数值之差Δ,那么信号频率就是定时器时钟 / Δ。比如TIM3计数频率为36MHz,测得一个周期计数值36000,信号频率就是1kHz。第二步根据信号频率选择采样率,规则是让每个信号周期覆盖120~180个采样点:目标采样率 = 频率 × 150,然后在定时器分频和重载值里找最接近的档位。例如1kHz信号需要150kSPS,就在已经算好的参数表里查表选取150kHz这一档。

代码实现可以这样写:

uint32_t freq_hz; uint16_t new_arr; // 假设已经通过输入捕获拿到频率freq_hz uint32_t target_sps = freq_hz * 150; // 每个周期采150个点 // 反向计算定时器重载值:72MHz / (PSC+1) = 计数频率 // 目标采样率 = 计数频率 / (ARR+1) // 固定Prescaler为0,即计数频率72MHz new_arr = 72000000UL / target_sps - 1; if (new_arr < 10) new_arr = 10; // 限制最高采样率 if (new_arr > 65535) new_arr = 65535; __HAL_TIM_SET_AUTORELOAD(&htim2, new_arr);

这段代码里最需要注意的是边界保护。F103的最高ADC采样率约850kSPS,所以new_arr算出来小于10的时候必须截断,否则定时器触发频率超过ADC能力,波形会乱。下限65535对应约1.1kSPS,信号频率低于几十Hz时这个档位也不够,这时要顺便调大Prescaler。我的经验是把Prescaler也纳入查表,做成一个二维档位表,每个档位对应一组PSC和ARR,用信号频率去匹配最接近的档位,比做套公式更稳。

加入这个自动时基功能之后,整套示波器程序就从“能看波形”变成了“方便地看波形”,特别适合快速排查电路板上有没有某个频率的干扰信号。从那以后,我每次给F103示波器换量程或者换被测信号,都会强制跑一遍这个频率测量流程,确认自动档位落在合理区间再开始观察波形,几乎再没出现过信号满屏乱跑的情况。希望这个思路对你手上的程序包有直接的帮助。

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

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

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

立即咨询