基于STM32与HAL库的简易示波器设计与实现
1. 项目缘起:为什么用STM32做示波器?
几年前,我在调试一个电机驱动板时,需要观察PWM信号的死区时间和电流采样波形。手头的台式示波器笨重不便,而市面上的手持示波器要么精度不够,要么价格不菲。当时就想,能不能用手里富余的STM32开发板自己搭一个简易的、够用的波形显示工具?这个想法催生了这个项目。它不是一个能替代专业仪器的产品,而是一个极佳的学习载体和应急工具。通过它,你能把STM32的ADC、DMA、定时器、通信接口等核心外设串起来,理解数据采集的完整链路,尤其是HAL库框架下如何高效、可靠地组织这些资源。
这个基于STM32和HAL库的简易示波器,核心功能是捕获模拟信号,并通过屏幕(如OLED、TFT串口屏)或串口上传到PC进行波形显示。它瞄准的是低频信号(比如音频范围、传感器输出、数字通信信号)的定性观察。对于嵌入式开发者、电子爱好者或学生来说,自己动手实现一遍,远比读十遍数据手册来得深刻。你会发现,数据手册上冷冰冰的寄存器描述,在项目里变成了活生生的采样率、触发条件和波形刷新率。
2. 核心架构与硬件选型分析
一个示波器,无论简易与否,其核心流程都离不开信号输入、采集、处理与显示。在这个STM32项目中,我们需要为每个环节选择合适的硬件和软件方案。
2.1 信号调理前端:安全与精度的第一道关卡
STM32的ADC输入范围通常是0-3.3V(取决于VDDA)。直接接入外部信号是危险的,过压会永久损坏芯片。因此,信号调理电路必不可少。一个最基本的调理电路包含三个部分:
过压保护:通常在输入端并联两个反向串联的肖特基二极管(如BAT54S)到电源和地,利用其低压降(约0.3V)特性,将输入电压钳位在(GND-0.3V)到(VCC+0.3V)之间。有人问能否用BAV99?BAV99是高速开关二极管,其正向压降约0.7-0.9V,反应速度可能不如肖特基二极管快,且钳位电压更高,保护裕量变小,在要求较高的前端保护中并非最优选,但在要求不高的场合可以应急使用。
衰减与偏置:为了测量高于3.3V的信号,需要电阻分压网络。例如,测量0-10V信号,可以用一个20kΩ和10kΩ电阻串联,从10kΩ电阻上取分压。更关键的是偏置电路。很多信号是交流的,有正负电压,而ADC只能测量正电压。因此需要一个直流偏置,将信号“抬高”到ADC量程中间。通常使用一个电压跟随器(运放构成)来产生一个1.65V(Vref/2)的虚地,信号通过电容耦合到此虚地上。这部分电路的设计直接决定了示波器的输入阻抗和带宽。
低通滤波:为了抗混叠,需要在ADC输入端加入一个简单的RC低通滤波器,其截止频率应略高于你关心的最高信号频率。例如,目标采样率是100kHz,根据奈奎斯特采样定理,能无失真还原的最高信号频率是50kHz,那么滤波器截止频率可以设在50-60kHz。
注意:对于简易示波器,如果只测量0-3.3V的板内信号(如单片机产生的PWM),可以省略复杂的调理电路,但必须在信号源和ADC引脚之间串联一个至少100Ω的电阻作为限流,这是最后的保险丝。
2.2 MCU与关键外设选型
主控选择:STM32F1、F4、H7系列都可以。F1(如F103C8T6)成本低,但ADC性能和DMA能力较弱;F4(如F407)有更快的ADC和更强大的DMA,适合更高采样率;H7性能最强,但开发复杂度也高。对于入门和多数应用,STM32F103或F407是性价比之选。本项目以STM32F407为例,因其资源丰富,网上资料也多。
核心外设角色分配:
- ADC:负责将模拟电压转换为数字值。我们需要用到它的规则组(Regular Group)连续采样模式。
- DMA:这是实现高速、不间断采集的关键。ADC每完成一次转换,DMA就自动把数据搬运到指定的内存数组(缓冲区)中,完全无需CPU干预,解放CPU去处理显示和通信。
- 定时器:用于精确控制ADC的采样间隔,从而得到稳定的采样率。通常使用一个通用定时器(如TIM2)的TRGO事件来触发ADC开始转换。
- 通信接口:
- 显示:可选SPI/I2C驱动的OLED屏,或UART驱动的串口屏。串口屏开发简单,但刷新速率受波特率限制;直接驱动SPI屏需要更多CPU时间,但刷新快。
- 上传PC:使用USART,将采集到的数据打包(可借鉴YMODEM协议格式,但简易项目通常自定义帧头帧尾)发送到上位机软件显示。
3. 软件驱动层:HAL库下的ADC与DMA协同
这是项目的核心代码部分。HAL库封装了底层寄存器操作,让我们能更关注逻辑,但也要理解其配置流程。
3.1 ADC与DMA的初始化和配置流程
首先,使用STM32CubeMX进行图形化配置是最快的方式,它能生成正确的初始化代码。我们需要配置:
ADC配置:
- 模式:选择“独立模式”。
- 扫描转换模式:禁用(如果只用一个通道)。启用(如果用多个通道交替采样)。
- 连续转换模式:启用。这样在被触发后,ADC会连续进行转换。
- 不连续采样模式:禁用。
- 外部触发源:选择“定时器触发输出事件”。例如,ADC1选择“Timer 2 Trigger Out event”。
- 数据对齐:右对齐。
- 采样时间:根据信号源阻抗选择。阻抗高则需要更长的采样时间以保证电容充电完成。对于一般情况,选择“239.5 Cycles”能保证精度,但会降低最大采样率。需要在速度和精度间权衡。
DMA配置:
- 为ADC添加一个DMA请求。
- 模式:循环模式(Circular)。这是关键!在此模式下,DMA在填满缓冲区后会自动回到开头重新开始填充,形成一个连续不断的环形缓冲区,完美契合持续采集的需求。
- 数据宽度:外设和内存都选择“半字”(Half Word),因为ADC是12位,用16位变量存储。
定时器配置:
- 配置一个定时器(如TIM2)为内部时钟源,向上计数模式。
- 预分频器(PSC)和自动重载值(ARR):这两个值决定了定时器的更新频率,也就是ADC的采样率。计算公式:
定时器时钟 / ((PSC+1)*(ARR+1)) = 定时器更新频率。这个更新频率就是采样率。 - 例如,STM32F407的APB1定时器时钟是84MHz。若需要10kHz采样率,则定时器更新频率应为10kHz。可以设PSC=8399,ARR=9。计算:84,000,000 / (8400 * 10) = 1000Hz?这里错了。正确计算:
Update Freq = 84MHz / ((8399+1)*(9+1)) = 84MHz / (8400*10) = 1000Hz。要达到10kHz,需要调整:设PSC=839,ARR=9,则84MHz / (840*10) = 10,000Hz。 - 开启定时器的“触发输出”(TRGO)事件,选择“更新事件”作为触发源。
CubeMX生成代码后,我们在main.c的用户代码区启动它们:
// 启动DMA(指定存储缓冲区) HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, BUFFER_SIZE); // 启动定时器,开始产生触发脉冲 HAL_TIM_Base_Start(&htim2);一旦定时器开始运行,ADC就会以固定的周期被触发、转换,DMA则默默地将结果搬运到adc_buffer数组中。
3.2 双缓冲区技术与触发策略
双缓冲区(Ping-Pong Buffer):这是防止波形显示撕裂的高级技巧。我们分配两个一样大的缓冲区BufferA和BufferB。DMA始终在其中一个(比如A)填充。当A填满时,触发DMA半满/全满传输完成中断。
- 在半满中断(即填满一半时),意味着BufferA的前半部分已经是可以处理的“稳定”数据,此时我们可以让显示逻辑去处理这前半部分数据,同时DMA继续向BufferA的后半部分填充。
- 在全满中断时,意味着BufferA已满,我们可以切换DMA的目标到BufferB,然后处理BufferA的全部数据。 这样,数据处理和采集始终在不同的缓冲区上交替进行,互不干扰。
软件触发:简易示波器还需要一个关键功能——波形稳定。这通过触发实现。我们可以在内存中设置一个触发电平。DMA填充缓冲区后,我们在缓冲区中寻找第一个超过(或低于)触发电平的点,然后从这个点开始将数据送去显示。这样每次显示的波形都从同一个电压点开始,视觉上就稳定了。代码逻辑如下:
// 在 adc_buffer 中寻找上升沿触发点 uint16_t trigger_level = 2048; // 对应1.65V uint16_t trigger_index = 0; for(int i=0; i<BUFFER_SIZE-1; i++) { if( (adc_buffer[i] < trigger_level) && (adc_buffer[i+1] >= trigger_level) ) { trigger_index = i; break; } } // 从 trigger_index 开始,将后续数据送去显示4. 数据处理与波形显示实现
采集到的原始ADC值只是数字,需要经过处理和渲染才能变成屏幕上的波形。
4.1 数据校准与换算
ADC值需要转换为电压值。公式很简单:Voltage = (ADC_Value / 4095) * Vref。其中Vref是ADC的参考电压,通常是VDDA(3.3V)。但需要注意,电源可能有纹波,VDDA不一定精确等于3.3V。如果对精度要求高,可以测量实际的VDDA电压,或者使用MCU内部自带的标准电压参考(如STM32的Vrefint通道)进行校准。
对于通过分压衰减的信号,还需要在代码中乘以衰减系数进行还原。例如,10V量程采用10k/20k分压,衰减系数为 (10k/(10k+20k)) = 1/3,那么实际电压V_real = Voltage / (1/3) = Voltage * 3。
4.2 显示模块驱动与优化
OLED (SSD1306) 显示: 使用HAL库驱动OLED,通常是通过SPI或I2C发送命令和数据。难点在于高效地绘制波形线。直接逐点打点(OLED_DrawPoint)速度极慢。优化方法是使用画线函数(OLED_DrawLine),将相邻的采样点用直线连接起来。由于采样率相对屏幕水平分辨率通常更高,我们还需要对数据进行抽点或平均,以匹配屏幕的像素宽度(比如128点)。
一个高效的绘制流程是:
- 确定显示窗口的Y轴零点(对应0V)和缩放比例(每伏特多少像素)。
- 将处理后的电压数组转换为屏幕Y坐标数组。
- 使用画线函数,从点
(x, y[i])到(x+1, y[i+1])进行绘制。
串口屏显示: 串口屏(如淘晶驰、大彩等)指令简单。你需要将电压数据按照屏厂协议封装成指令帧发送。例如,画线指令可能是“line x1,y1,x2,y2,color”。瓶颈在于串口波特率。以115200波特率为例,一帧画线指令可能包含20字节,发送一帧需时约1.7ms。画一条由100个点连成的波形线就需要170ms,刷新率低于6帧/秒,会有明显卡顿。优化方法:
- 使用更高的波特率(如921600)。
- 使用DMA发送串口数据,避免CPU阻塞等待。
- 精简协议,或让屏幕端固件支持直接接收并绘制数组数据。
4.3 上位机通信协议设计
如果不方便接屏幕,将数据发送到PC,用Python(Matplotlib)或LabVIEW等工具显示是更灵活的选择。串口通信协议需要精心设计,确保数据完整、可解析。
帧结构设计: 一个简单的自定义帧结构如下:帧头(2字节,如0xAA 0x55)+数据长度(2字节)+命令字(1字节)+ADC数据(N字节)+校验和(1字节,累加和或CRC8)。
使用DMA发送与中断判断: HAL库提供了HAL_UART_Transmit_DMA函数。但有一个常见问题:如何判断一帧数据已发送完成?不能单纯依赖DMA传输完成中断,因为DMA传输完成只代表数据从内存搬到了串口外设的数据寄存器(或FIFO),不代表数据已全部从TX引脚发出。
可靠的方法是:
- 启用串口的发送完成中断(TC, Transmission Complete)。当串口移位寄存器中的最后一位数据(包括停止位)发送完毕时,会触发TC中断。
- 在DMA发送函数后,等待TC中断。在TC中断回调函数中,进行发送完成后的处理(如释放缓冲区、置位标志位)。
// 启动DMA发送 HAL_UART_Transmit_DMA(&huart1, tx_buffer, length); // ... 等待TC中断 // 在中断回调函数中 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if(huart->Instance == USART1) { // 数据已真正发送完毕 tx_complete_flag = 1; } }5. 项目实战:从搭建到调试的完整链路
5.1 工程搭建与CubeMX配置要点
- 时钟树配置:确保系统时钟、ADC时钟、定时器时钟正确。STM32F407的ADC最大时钟为36MHz,不要超频。定时器时钟来源于APB总线,注意APB预分频器。
- ADC通道与引脚:指定一个模拟输入引脚(如PA0)作为信号输入。
- DMA流/通道选择:对于F4,ADC1通常对应DMA2 Stream0或Stream4,通道0。CubeMX会提示可用选项,按提示选择即可。
- 中断优先级:如果使用了DMA半满/全满中断、定时器中断、串口中断,需要在NVIC设置中合理分配优先级。通常采集相关的中断(DMA、定时器)优先级高于显示和通信中断。
5.2 调试技巧与常见问题排查
问题1:波形失真,出现锯齿或台阶
- 可能原因:采样时间不足。ADC对输入电容充电需要时间,如果信号源阻抗较大(如用了大阻值分压电阻),而采样时间设置太短,会导致转换结果不准确。
- 排查:增加ADC采样周期数(Sampling Time)。或者,在ADC输入端并联一个小电容(如10nF)到地,作为电荷池,但注意这会降低输入带宽。
问题2:采样率不稳定或达不到预期
- 可能原因:定时器配置计算错误;或者系统中有其他高优先级中断长时间阻塞,影响了定时器的精确触发。
- 排查:用另一个定时器或IO口翻转来测量实际采样间隔。检查定时器PSC和ARR的计算公式。确保采集相关中断优先级最高。
问题3:串口上传数据,上位机显示乱码或错位
- 可能原因:波特率不匹配;帧结构不同步;未处理粘包问题。
- 排查:
- 确认上下位机波特率、数据位、停止位、校验位完全一致。
- 在上位机程序中加入“帧同步”逻辑:只有连续收到正确的帧头0xAA 0x55后,才开始按长度解析后续数据。
- 对于粘包(两帧数据连在一起),依靠“长度”字段来正确分割。
问题4:使用DMA+双缓冲区时,显示波形有断裂
- 可能原因:数据处理速度跟不上采集速度。当DMA填满一个缓冲区并切换到另一个时,如果CPU还没来得及处理完前一个缓冲区的数据,新数据就会覆盖旧数据。
- 排查:优化显示算法,减少处理时间。或者增大缓冲区大小,为数据处理争取更多时间。检查是否在中断服务程序(如DMA完成中断)中做了太多耗时的操作(如浮点运算、屏幕刷新),应尽快置标志位,在主循环中处理。
5.3 性能优化与扩展思路
- 提高采样率:使用ADC的过采样(Oversampling)或交替模式(ADC1和ADC2交替触发),理论上可将采样率翻倍。对于F4,还可以使用ADC的“多通道扫描+DMA”模式,但需要处理好每个通道数据的对应关系。
- 增加测量功能:在获得波形数组后,可以计算其频率(通过寻找两个上升沿的时间差)、峰峰值(数组最大值减最小值)、平均值、有效值(RMS)等。
- 实现自动量程:通过软件判断当前波形幅值,动态调整信号调理前端的衰减倍数(需要配合模拟开关或继电器),或调整代码中的电压换算系数。
- 数据存储:集成SD卡,使用FATFS文件系统,将采集到的波形以CSV格式存储,方便后期在电脑上用Excel或专业软件分析。
- 移植RT-Thread:对于功能复杂的项目,可以考虑将程序移植到RT-Thread实时操作系统上。将采集任务、显示任务、通信任务分别放在不同线程中,通过消息队列、信号量同步数据,可以使程序结构更清晰,更易于维护和扩展。例如,采集线程负责填充缓冲区,显示线程定时从缓冲区取数据刷新屏幕。
这个项目就像一把钥匙,打开了通往嵌入式系统实时信号处理的大门。从最初的电压采集,到稳定的波形显示,再到各种高级功能的添加,每一步都会遇到具体的问题。解决问题的过程,正是能力提升的阶梯。当你看到第一个正确的正弦波出现在自己制作的设备屏幕上时,那种成就感是无可替代的。
