简介这是基于mini STM32STM32F103RCT6的简易示波器完整工程面向嵌入式初学者、电子爱好者和课程设计学生可用于学习ADC采样、LCD波形显示、按键交互、C/C混合编程也适合作为项目原型快速起步。压缩包共145个文件、约2MB内容以C源码、头文件为主覆盖STM32标准外设库的定时器、ADC、USART、FSMC等驱动另有LCD显示驱动、启动汇编文件、Keil工程文件、hex烧录文件以及文本说明方便查看、编译、烧录和按模块继续学习。目前已有675人学习下载。资源清晰展示了基于正点原子STM32F103RCT6开发板的示波器实现思路包含从模拟信号采集、数据滤波到屏幕波形绘制的完整链路可以帮助理解ADC采样率配置、触发方式和实时处理等设计要点。在此基础上还可继续扩展触发、频率测量或将驱动与界面迁移到其他STM32平台适合做课程设计与项目预研。1. 一块Mini板做示波器难的不是采样而是把数据送出去多数人第一次在mini STM32上写示波器是把ADC读出来的数值直接画到OLED上结果看到的不是波形而是一团乱码。问题不在AD转换本身而在采样时刻是否均匀、数据有没有在绘制的过程中被新的采样覆盖。基于mini STM32的简易示波器本质是ADC、定时器、DMA和显示刷新的一条流水线定时器决定什么时候采DMA负责把结果搬走CPU只处理搬运完成之后的绘制和触发判断。这篇文章把这条链路上的每个环节拆开讲从采样率怎么设定到触发怎么对齐最后给两个能立刻验证板子好坏的实验。用的是C/C但核心是数据流的设计语言只是表现层。2. 采样链路设计定时器触发ADC与DMA双缓冲2.1 mini STM32做示波器先认清三个硬指标不管屏幕多大、按键多少示波器的价值由三个参数决定采样率、存储深度、垂直分辨率。STM32F103系列ADC最高约1Msps12位分辨率内置SRAM几十KB。用512点做一次显示缓冲单通道连续采样时理论最高显示刷新率约1Msps/512≈1953帧/秒但OLED绘制一帧往往要几毫秒所以实际瓶颈在显示端。存储深度直接限制单次捕获的时间长度。采样率10kHz、512点只能记录51.2ms想记录更长的信号要么降低采样率要么扩大缓冲。扩大缓冲会影响绘制延迟这也是简易示波器和商用示波器差距最大的地方。垂直分辨率在F103上是满12位但实际有效位数会被电源噪声、参考电压波动拉低。短路输入端看波形底噪通常有2~4个LSB跳动。这个底噪值记下来后续判断信号是真有毛刺还是板子本身噪声非常有用。2.2 为什么连续采样不能写在主循环里如果ADC在主循环里按顺序轮询转换采样时刻取决于循环里其他代码的执行时间中断一多波形就会横向拉伸、压缩频率读出来全错。定时器触发ADC是标准做法。选择定时器的PWM输出作为ADC的外部触发源ADC不在循环里启动而是等定时器的上升沿到来。这样采样间隔严格等于(PSC1)×(ARR1)/TIM_CLK波形在时间轴上是均匀的。触发频率与采样率换算以72MHz定时器时钟为例目标采样率PSCARR512点记录时长500kHz1711.024ms100kHz8795.12ms10kHz719951.2ms2.3 DMA双缓冲让采样与绘制互不等待采集过程中CPU一旦参与搬数采样率就会抖动。DMA的作用是把ADC转换结果自动写入内存数组传输完成再通知CPU。F103的DMA支持循环模式和半传输中断配置成双缓冲后前半段数据填充时CPU绘制后半段反之亦然。代码示意#define ADC_BUF_LEN 1024 static volatile uint16_t adc_buf[ADC_BUF_LEN]; void adc_dma_init(void) { /* ADC1 由 TIM2_CH1 触发使用 DMA1 通道1 */ DMA1_Channel1-CPAR (uint32_t)ADC1-DR; /* 源: ADC数据寄存器 */ DMA1_Channel1-CMAR (uint32_t)adc_buf; /* 目的地: 内存数组 */ DMA1_Channel1-CNDTR ADC_BUF_LEN; /* 传输点数 */ DMA1_Channel1-CCR DMA_CCR_MINC | /* 内存地址自增 */ DMA_CCR_CIRC | /* 循环模式 */ DMA_CCR_HTIE | /* 半传输中断 */ DMA_CCR_TCIE | /* 传输完成中断 */ DMA_CCR_EN; /* 使能DMA */ ADC1-CR2 | ADC_CR2_DMA | ADC_CR2_CONT; /* 使能DMA请求和连续转换 */ }循环模式下DMA装满1024点后自动从数组头重新开始半传输中断表示前512点已就绪完成中断表示后512点已就绪。CPU在中断里只设置标志位不在中断里做绘制主循环检测到标志后处理数据。数组用volatile修饰避免编译器把数据优化到寄存器里导致读到旧值。双缓冲的两个半区如果大小不同只要在中断回调里按实际长度处理即可但建议保持2的幂次便于取模运算定位写入位置。3. 在Mini STM32上跑通最小波形显示链路3.1 CubeMX里勾选的最小外设集合用STM32CubeMX生成工程时只需要四类外设ADC1、定时器、DMA、显示接口。显示用SPI OLED或并口LCD均可建议先跑OLED接线少排查问题快。关键配置点ADC1开启两个通道一个接被测信号另一个悬空做参考也可以只开一个通道减少DMA搬运压力ADC触发源选Timer 2 Trigger Out不要选软件触发DMA选择Circular模式数据宽度Half Word内存地址自增定时器PSC和ARR按第二章表格计算先跑100kHz采样率波形比较容易稳定OLED用硬件SPI时时钟分频调到4分频以上避免SPI速率过高导致花屏生成代码后把main函数里的HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_buf, ADC_BUF_LEN)放到初始化末尾采样链路就开始工作了。3.2 主循环里画波形先画网格再画曲线绘制代码要短因为每帧都要跑。128x64 OLED画网格用固定坐标常量避免每次重新计算画波形时把ADC码值映射到屏幕高度#define SCREEN_H 64 #define ADC_MAX 4095 int16_t value_to_screen_y(uint16_t adc_val) { /* 映射到屏幕中间20~52像素区域留出上下边距 */ return (int16_t)(20 (SCREEN_H - 40) * (1.0f - (float)adc_val / ADC_MAX)); } void draw_waveform(const uint16_t *buf, uint16_t count) { int16_t x0 0; int16_t y0 value_to_screen_y(buf[0]); for (uint16_t i 1; i count; i) { int16_t x1 i * (128 - 1) / (count - 1); int16_t y1 value_to_screen_y(buf[i]); oled_draw_line(x0, y0, x1, y1); x0 x1; y0 y1; } }电平映射用浮点运算实际工程里可以转成整数乘除法例如20 (24 * (4095 - val)) / 4096减少CPU周期占用。画线函数如果逐点设置显存字节1024次调用会明显拖慢刷新率多数OLED驱动库的draw_line是Bresenham实现开销可控。3.3 双缓冲处理中断标志中断里设置标志位主循环查询标志后切换绘制半区volatile uint8_t dma_half_flag 0; volatile uint8_t dma_done_flag 0; void DMA1_Channel1_IRQHandler(void) { if (DMA1-ISR DMA_ISR_HTIF1) { dma_half_flag 1; DMA1-IFCR | DMA_IFCR_CHTIF1; } if (DMA1-ISR DMA_ISR_TCIF1) { dma_done_flag 1; DMA1-IFCR | DMA_IFCR_CTCIF1; } } void main_loop(void) { while (1) { if (dma_half_flag) { dma_half_flag 0; draw_waveform((uint16_t*)adc_buf, ADC_BUF_LEN / 2); } if (dma_done_flag) { dma_done_flag 0; draw_waveform((uint16_t*)adc_buf ADC_BUF_LEN / 2, ADC_BUF_LEN / 2); } /* 按键扫描、菜单刷新放这里 */ } }这样采样始终连续绘制只消费DMA已经填好的半区数据。半传输中断触发时后半区正在被DMA写入前半区可以安全读取完成中断触发时前半区已经开始新一轮写入后半区则处于稳态。两个半区交替使用不存在同时读写同一块内存的问题。提示如果绘制耗时超过半区填充时间波形会出现撕裂。解决办法是降低采样率或者把绘制函数改成只在屏幕活动区域刷新。4. 触发对齐、频率测量与C/C封装4.1 软件上升沿触发的迟滞处理没有硬件比较器的简易示波器只能用软件在缓冲区里找触发点。常见的做法是设定触发电平扫描缓冲区分界点找到信号从低于电平变为高于电平的索引然后从该索引开始绘制固定长度窗口让波形在屏幕上稳定。直接比较前后两个点容易在噪声边缘反复触发给触发电平加迟滞uint16_t find_trigger(const uint16_t *buf, uint16_t len, uint16_t level, uint16_t hyst) { uint16_t i; for (i 1; i len; i) { if (buf[i - 1] level - hyst buf[i] level hyst) { return i; /* 上升沿越过迟滞带 */ } } return 0; /* 没找到触发点 */ }触发电平用ADC码值直接比较不换算成电压省去浮点运算。迟滞带hyst取level的2%~5%底噪大的板子取上限。找不到触发时返回0主循环可保持上一帧画面不动避免波形乱跳。4.2 频率测量输入捕获与FFT选哪个测量信号频率有两种路线定时器输入捕获测周期或者对采样数据做FFT找峰值谱线。输入捕获的优点是精度高、代码量小但对占空比过小的信号测不准。实现时让定时器在上升沿捕获计数值两次捕获差值换算频率volatile uint16_t cap_t1, cap_t2; volatile uint8_t cap_count 0; void TIM3_IRQHandler(void) { if (TIM3-SR TIM_SR_CC1IF) { uint16_t now TIM3-CCR1; if (cap_count 0) { cap_t1 now; cap_count 1; } else { cap_t2 now; cap_count 0; } TIM3-SR ~TIM_SR_CC1IF; } }两个捕获值做差信号频率为TIM_CLK / ((PSC1) * (cap_t2 - cap_t1))。注意cap_t2小于cap_t1的情况要加上计数器溢出值否则换算结果偏大。FFT路径适合显示频谱但1024点FFT在72MHz主频下耗时约1~3ms而且频率分辨率只有fs/N在100kHz采样率下分辨率约98Hz测50Hz市电工频信号根本不够看。结论是只读数用输入捕获看谐波结构才用FFT两者不冲突。4.3 用C封装示波器逻辑的边界工程里用C也能写完整个示波器但代码规模一大全局变量满天飞。C封装能把缓冲区、触发状态、绘制参数收敛成类模块边界更清晰。从C迁移到C注意三点第一中断回调里不要调用成员函数只置volatile标志位。成员函数可能访问非平凡对象中断上下文中执行构造、析构、虚函数开销不可控。第二不要在类构造里启动DMA初始化顺序在全局对象构造阶段不受控。我一般先声明全局OScope对象在main里显式调用init()和start()。第三volatile成员变量改为std::atomic要谨慎Cortex-M3不支持原子指令最后还是靠关中断保护。class OScope { public: enum Edge { RISING, FALLING }; void init(uint32_t sample_rate, uint16_t level); void start(); void stop(); bool getWaveform(uint16_t *out, uint16_t *out_len, Edge edge) { if (!ready_) return false; uint16_t trig_idx findTrigger(edge); if (trig_idx 0) return false; *out_len copyWindow(out, trig_idx); return true; } private: uint16_t buf_[ADC_BUF_LEN]; volatile bool ready_; uint16_t level_; uint16_t triggerIndex(Edge edge); uint16_t copyWindow(uint16_t *out, uint16_t start); };copyWindow内部用模运算把以触发点为中心的窗口平移到输出数组实现水平移动时不需要搬动大缓冲区只改起点索引绘制和数据处理都基于相对位置。5. 波形自检小实验用PWM当信号源验证采样链路板载定时器PWM输出就是现成的测试信号源。把TIM3_CH1配置为1kHz方波用杜邦线短接到ADC输入屏幕上应当出现稳定方波频率读数1kHz±2%。这段代码放在一个独立测试函数里平时注释掉void selftest_pwm_output(void) { RCC-APB1ENR | RCC_APB1ENR_TIM3EN; RCC-APB2ENR | RCC_APB2ENR_IOPAEN; /* PA6 复用推挽输出 (CNF10, MODE11) */ GPIOA-CRL ~(0xFUL 24); GPIOA-CRL | (0xBUL 24); TIM3-PSC 72 - 1; /* 计数频率 1MHz */ TIM3-ARR 1000 - 1; /* 周期 1ms - 1kHz */ TIM3-CCMR1 TIM_CCMR1_OC1M_2 | TIM_CCMR1_OC1M_1; /* PWM模式1 */ TIM3-CCR1 100; /* 占空比 10% */ TIM3-CCER | TIM_CCER_CC1E; TIM3-CR1 | TIM_CR1_CEN; }先看波形上沿是否出现毛刺。PWM输出与ADC共地如果波形前几个点有尖峰多半是PA6的IO翻转瞬间通过电源耦合到ADC输入给PA6串一个1k电阻再接ADC可以抑制。再把占空比从10%改到50%观察电平转换位置是否始终落在触发电平附近验证触发的水平稳定性。示波器根因排查的最后一步是软件校准。把ADC输入接地记录底噪均值再接到3.3V记录满量程码值用两个点做线性校准之后所有电压读数都过这个函数。这样读数才真实后续调试信号链时有据可查。本文还有配套的精品资源点击获取