简介:这是基于mini STM32(STM32F103RCT6)的简易示波器完整工程,面向嵌入式初学者、电子爱好者和课程设计学生,可用于学习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最高约1Msps,12位分辨率,内置SRAM几十KB。用512点做一次显示缓冲,单通道连续采样时,理论最高显示刷新率约1Msps/512≈1953帧/秒,但OLED绘制一帧往往要几毫秒,所以实际瓶颈在显示端。
存储深度直接限制单次捕获的时间长度。采样率10kHz、512点只能记录51.2ms,想记录更长的信号要么降低采样率,要么扩大缓冲。扩大缓冲会影响绘制延迟,这也是简易示波器和商用示波器差距最大的地方。
垂直分辨率在F103上是满12位,但实际有效位数会被电源噪声、参考电压波动拉低。短路输入端看波形底噪,通常有2~4个LSB跳动。这个底噪值记下来,后续判断信号是真有毛刺还是板子本身噪声,非常有用。
2.2 为什么连续采样不能写在主循环里
如果ADC在主循环里按顺序轮询转换,采样时刻取决于循环里其他代码的执行时间,中断一多波形就会横向拉伸、压缩,频率读出来全错。
定时器触发ADC是标准做法。选择定时器的PWM输出作为ADC的外部触发源,ADC不在循环里启动,而是等定时器的上升沿到来。这样采样间隔严格等于(PSC+1)×(ARR+1)/TIM_CLK,波形在时间轴上是均匀的。
触发频率与采样率换算(以72MHz定时器时钟为例):
| 目标采样率 | PSC | ARR | 512点记录时长 |
|---|---|---|---|
| 500kHz | 1 | 71 | 1.024ms |
| 100kHz | 8 | 79 | 5.12ms |
| 10kHz | 71 | 99 | 51.2ms |
2.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 / ((PSC+1) * (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 复用推挽输出 (CNF=10, MODE=11) */ 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,记录满量程码值,用两个点做线性校准,之后所有电压读数都过这个函数。这样读数才真实,后续调试信号链时有据可查。
本文还有配套的精品资源,点击获取