
简介基于 mini STM32 的简易示波器项目资料采用 STM32F103RCT6 微控制器面向嵌入式开发者和电子爱好者解决如何利用片上 ADC、LCD 显示与按键交互快速搭建低成本示波器的问题。资源包共 145 个文件大小约 2.09MB主要包含 C/C 源文件.c/.h、Keil 工程文件、编译中间文件、烧录文件.hex及少量说明文档便于直接在开发板上编译验证或二次修改。已有 675 人学习下载。资料围绕 ADC 信号采集、数字滤波与波形绘制、显示驱动、用户交互等关键模块展开并涉及定时器、FSMC 和 DMA 等 STM32 外设配置适合从 GPIO 操作进阶到综合外设应用的阶段学习者。项目源文件结构清晰包含 MDK-ARM 工程配置与编译产物可对照电路图理解各模块功能帮助快速上手基于正点原子开发板的示波器开发流程。1. 基于mini STM32的简易示波器到底能测什么一块几十块钱的STM32最小系统板最常见的STM32F103C8T6加上一块12864 OLED和一个旋转编码器就能搭出一台能抓串口波形、看PWM占空比是否稳定、查继电器吸合瞬间电源跌落幅度的简易示波器。这个标题指向的并不是一台仿真玩具而是一条完整的数字示波器信号链路定时器触发ADC采样、DMA把数据搬进内存、C代码做触发定位、C负责界面与交互。它的精度没法跟台式的比内置ADC的采样率、输入带宽和显示刷新率这三项决定了它的上限。但它能解决大量低频调试需求I2C/SPI时序对不对、电机PWM频率有没有漂、开关电源纹波大概几个毫伏、拨码开关按下瞬间的电平毛刺长什么样。对做嵌入式开发、做毕业设计、或者想把手头板子改造成产测工装的人来说这套方案的工程价值在于它把“采样、触发、显示、交互”四个环节全部串起来了比单独用逻辑分析仪更练基本功。下面先从采样链路的指标和参数讲起再给出一套C/C分层实现最后落到校准与验收。2. 先定指标再选硬件mini STM32的ADC采样、DMA与时基框架2.1 为什么用定时器触发ADC而不是连续采样ADC连续转换模式下每次转换启动的时机由软件决定而主循环里可能正在刷屏、读按键、处理串口启动时刻的抖动直接变成采样间隔抖动。反映到波形上就是水平方向出现几格宽的扭曲低频信号看不出来一旦测10kHz以上的方波边沿就会左右乱跳。所以简易示波器的第一条原则是让硬件自己产生采样时钟。常见做法是拿一个通用定时器做触发源让它以固定频率输出TRGO事件ADC配置为外部触发转换转换结果由DMA自动搬运。定时器溢出一次ADC转换一次DMA搬运一次CPU全程不参与采样间隔是严格的硬件周期。采样率的上限取决于ADC转换时间。以F103为例APB2给72MHz时ADC时钟最高取12MHz单次转换时间由采样时间加上固定的12.5个周期组成。不同采样时间对应的理论吞吐如下表ADC采样时间总转换周期12MHz ADC时钟下的最大采样率1.5周期14周期约857kSa/s7.5周期20周期600kSa/s28.5周期41周期约293kSa/s71.5周期84周期约143kSa/s239.5周期252周期约48kSa/s这张表说明F103的ADC理论吞吐上限不到1MSa/s所以时基档位设计要反过来扣着这个数来。设计时不要超过理论值的80%留出触发处理和DMA带宽的余量。提示直接在CubeMX里把ADC采样时间调大可以降低采样率但这是改变单次转换时间不是改变采样间隔。用定时器触发时采样间隔完全由定时器决定ADC采样时间只影响精度和等效阻抗两者不要混为一谈。2.2 时基档位、窗口时间和显示点数的换算时基档位的本质是一屏显示多长时间以及在这一屏里放多少个采样点。比如一屏10格每格1ms窗口就是10ms如果用512个点显示一整屏采样率就是512/10ms51.2kSa/s。反推定时器参数就是让定时器触发频率等于这个值。定时器计数公式是TIM_CLK / ((PSC1) * (ARR1)) 采样率。用一小段Python可以直接算出档位参数def timebase_params(points_per_win, window_s, tim_clk72e6): fs points_per_win / window_s # 目标采样率 ticks round(tim_clk / fs) # 定时器总共要数多少个脉冲 psc max(1, min(1 16, ticks // 65536 1)) arr max(1, min(65535, ticks // psc)) return int(fs), psc, arr # 10ms窗口512点 - 51.2kSa/s print(timebase_params(512, 10e-3)) # 2ms窗口512点 - 256kSa/s print(timebase_params(512, 2e-3))ticks是两个寄存器的总乘积受ARR的16位限制。PSC应尽量取小值分频粒度才细。如果算出来的采样率超过1MSa/s那就要么减少显示点数、缩短窗口要么换F4/F303这类ADC时钟更高的芯片。实际档位表建议按“每格时间”来组织因为示波器使用者的习惯是看每格多少毫秒每格时间窗口(10格)显示点数目标采样率50us500us128256kSa/s0.1ms1ms256256kSa/s0.2ms2ms512256kSa/s0.5ms5ms512102.4kSa/s1ms10ms51251.2kSa/s10ms100ms5125.12kSa/s显示点数不等于DMA缓冲区长度。常用做法是DMA缓冲开1024点循环慢时基档位下每两个原始点抽一个显示快时基档位下取完整缓冲直接映射。这样缓冲区长度固定改档位只需要改定时器参数。2.3 输入前端1:10分压、钳位保护与输入阻抗F103的ADC输入范围是0到3.3V超过就会烧引脚分压和钳位是必须做的。最简单的1:10衰减R11MΩ接输入端R2100kΩ接到GND两个电阻中点进ADC引脚。为了补偿PCB和引脚电容带来的频响衰减R2两端并联一个10到47pF的电容形成一个简单的低通补偿网络。这样输入阻抗大约1MΩ比普通万用表低但对TTL/CMOS电路足够。如果被测点本身阻抗很高比如音频电路里的高阻节点最好在分压之后加一级运放跟随MCP6002、LM358都行把ADC看到的源阻抗压到1kΩ以内否则读数会明显偏小。钳位用两个BAT54S把ADC引脚对GND和VDDA各钳一个二极管方向进ADC之前串一个100Ω电阻限流。注意这种简易示波器不要接到市电或超过3.3V太多的电源上测量。分压电阻一旦烧穿高压直接进MCU板子和调试电脑都可能遭殃。不带自动量程的话实用做法是把R2做成多个档位跳线或者模拟开关选1:1、1:10、1:100三档软件里按当前档位换算“每格多少伏”。这个量程概念在后面显示层会用到。3. 用C在CubeMX与Keil里跑通采集触发的最小实现3.1 CubeMX配置核对表与DMA启动代码开发环境上目前参考做法仍是Keil MDK加STM32CubeMX生成初始化代码。老版本的Keil装C51和STM32支持包经常打架稍微省心一点的做法是只装MDK用Pack Installer单独勾选STM32F1的Device Family Pack不要和C51装同一个安装目录。我一般直接建一个空MDK工程再让CubeMX生成HAL代码后覆盖进去这样能绕开keil5兼容c51和stm32安装的大多数历史问题。CubeMX里只需要核对采样链路的三个对象ADC1、TIM2、DMA。配置项取值含义ADC1 ChannelIN0 (PA0)信号输入引脚Continuous ConversionDisable必须关由定时器发触发External Trigger SourceTimer 2 Trigger Out eventTIM2的TRGO接到ADCDMA ModeCircular循环模式才能做乒乓缓冲DMA Data WidthHalf Word对应uint16_t缓冲区TIM2 Trigger OutputUpdate Event每次溢出产生一个触发NVICDMA1_Channel1_IRQn 开启优先级高于显示任务生成之后在main函数里先设置好当前档位的定时器参数再按顺序启动定时器和DMA__HAL_TIM_SET_PRESCALER(htim2, psc); // 采样间隔(PSC1)*(ARR1)/72MHz __HAL_TIM_SET_AUTORELOAD(htim2, arr); HAL_TIM_Base_Start(htim2); // 定时器开始产生TRGO事件 HAL_ADC_Start_DMA(hadc1, (uint32_t *)dma_buf, BUF_LEN); // DMA自动搬运注意HAL_ADC_Start_DMA的缓冲区类型必须和DMA数据宽度一致配成Half Word时传uint16_t数组的首地址。如果CubeMX里DMA数据宽度配错最常见的问题就是波形只有上半截或者数据整体错位这类“采样数据对不上”的故障排查优先级要放在触发和绘制之前。3.2 乒乓缓冲与半满中断回调DMA在这种场景下最舒服的用法是Circular加半满/全满中断。BUF_LEN设为1024时DMA采满512个点触发一次HalfCplt中断再采512个点触发一次Cplt中断。主循环只需要交替消费这两块数据等效于双缓冲边采边用。volatile uint8_t half_ready 0; volatile uint8_t full_ready 0; void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) half_ready 1; // 前半区dma_buf[0..511]填满 } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) full_ready 1; // 后半区dma_buf[512..1023]填满 }主循环里看到half_ready就处理前512点看到full_ready就处理后512点处理完清标志。不要在回调里调用HAL_ADC_StopDMA停止后再启动会丢失当前的触发相位下一次边沿对不齐。提示中断回调里只允许置标志位。触发定位、波形重绘这类耗时操作全部放在主循环否则会出现一种很隐蔽的问题DMA还在往这半区写数据主循环已经在读这半区波形上出现周期性毛刺。如果发现波形有一帧好一帧坏还有一个排查点在读取标志之后、处理数据之前检查DMA当前剩余数据计数值。剩余值突然跳过512的边界说明DMA正在写入这一半区这一轮先跳过下一轮再处理。3.3 软件边沿触发找穿越点、带滞回简易示波器的触发不需要硬件比较器软件找就足够。在缓冲里搜索某个采样点从阈值以下变为阈值以上上升沿把整个窗口按这个位置重新对齐。一段最基础的C实现长这样int find_trigger(uint16_t *buf, int n, int level, int dir) { int i; for (i 1; i n; i) { if (dir 0) { // 上升沿 if (buf[i - 1] level buf[i] level) return i; } else { // 下降沿 if (buf[i - 1] level buf[i] level) return i; } } return -1; }阈值level一般取自动先遍历一遍缓冲区取(min max) / 2比固定写3.3V的一半要靠谱。dir默认0需要抓下降沿时切到非0分支。只有这一版会有一个典型问题信号边沿附近有噪声时采样值会在阈值两边来回穿越触发点前后抖动。改进办法是加滞回上升沿要求当前点大于level 5个LSB同时左侧最近一个小于level - 5个LSB确认后才算有效触发。代价是触发点整体右移软件里按像素补偿回去。这个补偿值和当前档位的水平缩放比例有关做成常量表比每次计算更省事。拿到触发索引之后把采样点按索引重新映射到屏幕X坐标上让触发点落在第2格或第3格附近右边留出足够的波形观察空间。这个重映射逻辑属于显示层不要和DMA采集混在一起后面换C类封装时也不需要改动中断回调。4. 再用C做波形显示与交互让简易示波器像一个仪器4.1 采集层C、界面层C的分工与extern C桥接C和C的边界不应该是“哪个文件后缀是.cpp就在哪里用C”而应该是职责边界DMA回调、定时器配置、ADC初始化这些底层操作保持C波形视图、档位状态机、按键管理做成C类。CubeMX生成的工程默认按C编译不影响你用自己的C文件。两边需要互相引用函数和变量时用extern C包一层声明#ifdef __cplusplus extern C { #endif void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc); void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc); #ifdef __cplusplus } #endif这段声明放在C文件里链接时C文件里实现的回调符号就能和C侧的对象安全交互。编译器选ARM Compiler 6AC6C17的大部分语法都可以用。要注意的是std::vector这类容器会引入不小的运行时开销如果开microlib还容易链接报错。实际项目里能用数组就用数组C主要用来写类、枚举和重载而不是依赖标准库容器。用VSCode配好C/C环境做代码阅读和静态检查是舒服的能跳转、能看到类型推导但最终编译还是走Keil或命令行工具链避免两套工程配置不一致产生“编辑器能编译、IDE里报错”的问题。MCU上写C最重要的原则是控制全局对象的数量通常两个到三个就够并且全程禁止动态内存分配。4.2 OLED/TFT波形刷新的两种画法显示部分决定这台机器像不像一台仪器。128x64的OLED用SPI接口整帧写显存大约2到3ms如果每帧都整屏重画波形刷新率会掉到20fps以下旋转编码器切档位时画面像PPT。绘制方案实现成本刷新效果适用场景全屏重绘代码最少帧率低、观感卡顿只验证逻辑背景缓存脏列刷新多一块显存帧率明显提升OLED/TFT都适用局部差分绘制内存最多最快但容易花屏波形区域固定的场合我一般用第二种栅格、刻度和波形前景分开放在两个显存缓冲区背景只画一次每帧只擦除上一次波形占用的列再画新波形把变化的列通过SPI/DMA刷出去。class WaveView { public: void update(const uint16_t *samples, uint32_t n); private: uint8_t bg[64][16]; // 栅格与刻度背景画一次 uint8_t fg[64][16]; // 前景缓冲 uint8_t dirty[128]; // 需要刷新的列索引表 };这样每帧SPI写入量从全屏128×64个像素点降到几十列刷新率几乎能翻倍。4.2.1 画波形时最容易漏掉的降采样一屏只有128列采样点可能是512、1024甚至更多。常见错误是直接每隔若干点取一个值窄毛刺会刚好落在采样间隔里被丢掉。正确做法是把连续几个点按最大值和最小值画成一条竖线int yMax mapY(samples[i]); int yMin yMax; for (int j i; j i decimate; j) { int y mapY(samples[j]); if (y yMin) yMin y; if (y yMax) yMax y; } drawVLine(x, yMin, yMax); // 画竖线包含这段的峰谷decimate由“采样点数/128”得到mapY根据当前电压档位把ADC码值映射到像素Y坐标。这个细节决定了简易示波器能不能看到真正的毛刺而不是只看到一条平滑曲线。4.3 编码器与按键的档位切换状态机交互部分最少需要一个旋转编码器加两个按键编码器旋转切档位短按切换触发极性长按回到自动模式。裸机循环里用SysTick做5ms轮询扫描即可不需要进中断状态逻辑写起来更清晰。enum class UiState { TIME_BASE, VOLTS_DIV, TRIGGER_MODE }; void onEncoderDelta(int delta) { if (uiState UiState::TIME_BASE) { setTimebaseIndex(clamp(timebaseIndex delta, 0, kTimebaseCount - 1)); } else if (uiState UiState::VOLTS_DIV) { setVoltDivIndex(clamp(voltDivIndex delta, 0, kVoltDivCount - 1)); } } void setTimebaseIndex(int idx) { const TimebaseEntry e kTimebaseTable[idx]; __HAL_TIM_SET_PRESCALER(htim2, e.psc); __HAL_TIM_SET_AUTORELOAD(htim2, e.arr); }kTimebaseTable是一张const表每一档的PSC和ARR在编译期确定运行时只做寄存器赋值不跑浮点。切换时要注意如果DMA正在写入当前半区先等half_ready或full_ready出现再改定时器否则这一帧的时间轴会中间断开。提示旋转编码器在按压过程中会产生误触发的旋转计数我只在编码器空闲态累计delta按下期间忽略旋转菜单就不会乱跳。按键扫描要区分短按和长按最省事的办法是登记按下时刻超过800ms走长按分支抬起时小于800ms走短按分支。这个状态机用switch写在主循环里不要在SysTick中断里跑。采集、触发、重绘、交互四件事都在同一个while(1)里每轮最多处理一个事件帧率稳定在25到30fps就好。简易示波器的操纵手感核心不是快而是每次操作都有即时的波形反馈。5. 验收与校准用定时器输出和上位机把简易示波器的时基误差压到5%以内5.1 用片内PWM输出做X轴校准信号最简单也最可靠的校准信号来自板子自己。把TIM1的CH1配置成PWM输出1kHz、50%占空比杜邦线直接接到ADC输入。切到1ms/格的档位观察一个完整周期是1ms屏幕上应该正好占10格。确认PWM频率和占空比无误后数出波形跨过的格数换算成实际周期。校准项目预期实测结论1kHz方波周期1.000ms0.982ms误差1.8%合格上升沿位置触发点第2格第2格偏1像素触发正常这与实验室示波器的使用方法里最基础的“已知信号验证时基”是一致的。误差超过5%先查时钟树PLL有没有打开、SYSCLK到底是不是72MHz再看定时器预分频和重装值有没有写反。每个档位都过一遍把校准系数存进Flash结构体下次开机自动读。5.2 触发稳定性与底噪自检校准做完第二步看触发。连续截获10帧上升沿在屏幕上应该稳定在同一列左右抖动超过2个像素先怀疑输入前端的焊接和地线再看阈值是不是落在边沿缓慢变化的区域。底噪检查输入端接GNDADC读数波动应不超过5个LSB超过就查VDDA是否需要加LC滤波、PA0到地的100nF电容有没有漏贴。这两项过了这台简易示波器才真的能当工具用。5.3 一帧数据导出CSV给上位机交叉验证屏幕上的波形不能说明全部细节。把最近一帧原始数据通过串口打印成CSVfor (int i 0; i 256; i) { uint32_t mv dma_buf[i] * 3300UL / 4095UL; printf(%u,%lu\r\n, i * sample_interval_ns, mv); }导出的CSV在电脑上用free master这类带示波器插件的上位机或者直接用serial_digital_scope打开能和单板显示做逐一对比。经常有人问示波器报错的波形CSV在电脑上能不能看——能但CSV必须纯文本保存、字段不带单位、表头只留一行否则Excel打开后换行和分列全是乱的。Proteus里的仿真示波器只能验证触发逻辑真实ADC噪声还是要靠上板测。5.4 低成本往前再走三步想抓更长时间的协议时序把DMA缓冲扩到4096点配合单次触发模式抓开关机波形想测更小的信号在分压网络后面加一级可编程增益放大器想让界面更像台式示波器把WaveView的后端抽象成SPI设备接口换TFT甚至带触摸的屏改动都只局限于渲染层。每次校准完把档位系数写入片内Flash开机自动装载这样一台基于mini STM32的简易示波器才真正具备可复现的测量能力而不只是循环采集和画点。本文还有配套的精品资源点击获取