这次我们来看一个非常硬核的嵌入式毕业设计项目:基于STM32的简易数字示波器。这不是一个商业产品,而是一个从零开始、包含硬件设计、固件开发和软件调试全过程的实践记录。对于电子、自动化、通信等相关专业的学生,或者想深入理解ADC采样、信号处理和嵌入式系统开发的工程师来说,这个项目极具参考价值。
项目的核心在于“手搓”——从绘制硬件原理图、焊接PCB,到编写STM32的ADC采样、数据处理和波形显示代码,再到通过串口或屏幕将波形可视化,整个过程完整地复现了一个简易示波器的核心功能。它不追求商业示波器的超高带宽和丰富功能,而是聚焦于理解示波器的工作原理和STM32的实际应用。
本文将带你完整复盘这个毕设项目。我们会重点拆解其硬件架构、核心代码逻辑、关键参数(如采样率、电压量程)的实现,并提供一套可复现的验证流程。无论你是想借鉴其设计思路完成自己的课程设计,还是希望深入学习STM32在数据采集领域的应用,这篇文章都能提供直接的帮助。
1. 核心能力速览
在深入细节之前,我们先通过一个表格快速了解这个自制示波器项目的关键规格和特点。这些参数是基于常见的STM32F1/F4系列和简易示波器设计推导的,实际性能需以具体硬件和代码为准。
| 能力项 | 说明与典型值 |
|---|---|
| 核心主控 | STM32F103C8T6 (蓝色战舰) / STM32F407VET6 等带ADC的型号 |
| 模拟前端 | 信号调理电路(衰减、放大、电平偏移),输入保护 |
| ADC采样 | 内置12位ADC,最高采样率约1MHz(依赖主频和配置) |
| 电压量程 | 通常设计为±5V、±10V等,通过硬件分压/放大切换 |
| 带宽 | 理论带宽受限于ADC采样率,实际有效带宽通常为几十到几百KHz |
| 波形显示 | 通过TFT液晶屏(如ILI9341)或PC上位机软件(串口绘图) |
| 触发功能 | 支持边沿触发(上升沿/下降沿),软件实现 |
| 测量功能 | 可计算并显示频率、周期、峰峰值、平均值等基本参数 |
| 代码架构 | 基于HAL库或标准库,模块化设计(ADC、DMA、定时器、显示) |
| 开发环境 | Keil MDK-ARM / STM32CubeIDE / VSCode + PlatformIO |
| 适合场景 | 教学演示、课程设计、毕业设计、低频信号观测、嵌入式学习 |
2. 适用场景与使用边界
这个自制STM32示波器项目最适合以下几类人群和场景:
- 高校学生:特别是电子信息、自动化、通信工程等专业的学生,用于完成课程设计、电子竞赛或毕业设计。它是一个将单片机原理、模拟电路、数字信号处理等多门课程知识融会贯通的绝佳实践。
- 嵌入式初学者/爱好者:希望超越点灯、按键等基础实验,挑战一个综合性项目的开发者。通过它,可以深入掌握STM32的ADC、DMA、定时器、中断等核心外设的协同工作。
- 低频信号观测需求:用于观测音频信号、传感器输出(如温度、压力)、PWM波形、简单的数字通信信号(如UART、I2C的波形)等低频或中低频信号。
然而,必须清楚它的局限性:
- 性能有限:受限于STM32内置ADC的采样率和精度,无法测量高频信号(通常超过几百KHz就会严重失真)。商业示波器轻松达到几十MHz甚至GHz级别。
- 功能简易:虽然实现了核心的波形显示和基本测量,但缺乏商业示波器的高级功能,如高级数学运算、深存储、协议解码、丰富的探头支持等。
- 精度与稳定性:受硬件设计(尤其是前端调理电路)和软件算法的影响,测量精度和稳定性无法与专业仪器相比,更适合定性观察和原理验证。
- 安全警告:切勿直接测量市电(220V AC)或其它高压危险信号!自制设备的输入保护电路通常比较薄弱,存在损坏设备甚至人身伤害的风险。只能用于测量安全的低压信号。
3. 环境准备与前置条件
要复现或借鉴这个项目,你需要准备以下软硬件环境。这是一个通用清单,具体型号可根据你的STM32开发板调整。
硬件清单:
- STM32核心板/开发板:推荐使用STM32F103(如“蓝色战舰”)或STM32F4系列。确保板载ADC引脚已引出。
- TFT液晶显示屏:用于本地显示波形,如1.44寸或2.4寸的SPI接口屏幕(驱动芯片ILI9341/ST7735等)。
- 信号调理板(可选但推荐):这是项目的关键硬件部分。你需要自己焊接或使用万用板搭建,包含:
- 衰减网络:用精密电阻分压,扩展电压测量范围(例如,将±20V输入衰减到MCU可接受的±3.3V)。
- 电压跟随器:使用运放(如TL084, LM358)提高输入阻抗,减少对被测电路的影响。
- 电平偏移电路:因为STM32的ADC只能测量0-3.3V的正电压,而信号常有正负。需要通过运放将信号整体抬升1.65V(Vref/2),使-1.65V~+1.65V的信号对应ADC的0~3.3V。
- 输入保护:至少包含钳位二极管和限流电阻,防止过高电压损坏MCU。
- 调试器:ST-Link V2或J-Link,用于程序下载和调试。
- 信号源:用于测试,例如函数信号发生器、另一块单片机产生的PWM、或者简单的RC振荡电路。
- 万用表、示波器(商业):用于校准和对比验证。
软件环境:
- 集成开发环境(IDE):
- Keil MDK-ARM:传统选择,需安装对应器件包。
- STM32CubeIDE:ST官方免费IDE,集成CubeMX配置工具,推荐新手使用。
- VSCode + PlatformIO:轻量级、高可定制化选择。
- STM32CubeMX:用于图形化配置引脚、时钟、外设(ADC、DMA、定时器、SPI等),并生成初始化代码。即使使用其他IDE,也强烈建议用其进行基础配置。
- 串口调试助手/上位机软件:如果你计划通过串口将数据发送到PC显示,需要如SecureCRT、Putty、或者自行编写的Python上位机。
- 电路设计软件:用于查看或修改原理图/PCB,如立创EDA、Altium Designer、KiCad等。
4. 硬件设计核心:信号调理电路
自制示波器的性能瓶颈和安全性很大程度上取决于模拟前端电路。这里拆解一个典型的双运放调理电路设计思路。
设计目标:将输入信号(例如±10V)安全地、线性地转换到STM32 ADC的输入范围(0~3.3V)。
电路结构(两级运放):
- 第一级:衰减与跟随
- 输入:通过一个高压电容(如104)耦合,隔离直流分量(如果测量交流信号)。
- 衰减网络:电阻分压器。例如,要将±10V衰减到±1V,分压比约为10:1。
- 电压跟随器:衰减后的信号接入一个运放构成的电压跟随器,利用其高输入阻抗、低输出阻抗的特性,缓冲信号,避免后续电路影响衰减精度。
- 第二级:电平偏移与放大
- 加法器电路:这是一个核心电路。利用运放的加法器功能,将第一级输出的信号(包含正负)与一个固定的参考电压(Vref/2 = 1.65V)相加。
- 计算:
V_out = -Rf/R1 * V_signal - Rf/R2 * V_ref。通过精心选择电阻比值,使得当V_signal = -1V时,V_out = 3.3V;当V_signal = +1V时,V_out = 0V;当V_signal = 0V时,V_out = 1.65V。这样就完成了双极性到单极性的转换,并且信号幅度被适配到ADC全量程。
- 保护电路:
- 在ADC输入引脚前,串联一个小的限流电阻(如100Ω)。
- 并联一对钳位二极管(如1N4148)到GND和3.3V,确保输入电压不会超过-0.7V~3.9V。
原理图片段示意(概念性):
输入 | [隔直电容] | [衰减电阻网络] ---> 运放1 (电压跟随器) ---> 运放2 (加法器: 信号 + 1.65V参考) ---> ADC引脚 | | [保护二极管] [限流电阻] | | GND/3.3V GND注意:实际电阻值需要根据所选量程和运放供电电压精确计算。运放应选择轨到轨输入输出的型号,并使用±5V或单5V供电(需产生虚地)。
5. 软件架构与核心代码实现
软件部分是STM32的“灵魂”。整个程序围绕实时采集、处理、显示三个环节展开,必须高效利用DMA和中断。
5.1 系统工作流程
- 初始化:配置系统时钟、ADC、DMA、定时器、SPI(用于屏幕)、USART(用于串口输出)。
- 触发等待:ADC持续采样,但数据不处理。软件检测采样值,等待满足触发条件(如上升沿超过某个阈值)。
- 数据采集:触发后,启动一次固定长度的连续采集(例如1024个点)。使用定时器触发ADC,ADC通过DMA将数据自动搬运到内存中的数组,全程无需CPU干预。
- 数据处理:采集完成后,DMA传输完成中断触发。在中断服务程序或主循环中,对原始ADC值进行软件校准、电压换算、触发点对齐等处理。
- 波形显示/上传:将处理后的数据点转换为屏幕坐标,通过SPI驱动TFT屏绘制波形;或通过串口按特定格式发送给PC上位机。
5.2 关键代码模块拆解
1. ADC与DMA配置(使用STM32CubeMX生成后修改)
// ADC1 初始化片段,使用DMA,定时器2触发 ADC_HandleTypeDef hadc1; DMA_HandleTypeDef hdma_adc1; void MX_ADC1_Init(void) { hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = ADC_SCAN_DISABLE; // 单通道 hadc1.Init.ContinuousConvMode = DISABLE; // 禁用连续转换,由定时器触发 hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T2_TRGO; // 定时器2触发 hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion = 1; HAL_ADC_Init(&hadc1); // 配置ADC通道 ADC_ChannelConfTypeDef sConfig = {0}; sConfig.Channel = ADC_CHANNEL_0; // 对应PA0 sConfig.Rank = 1; sConfig.SamplingTime = ADC_SAMPLETIME_3CYCLES; // 采样时间,影响转换速度 HAL_ADC_ConfigChannel(&hadc1, &sConfig); } // DMA配置(CubeMX通常可图形化配置) // 需要将DMA模式设为循环模式或正常模式,内存地址递增,外设地址固定。2. 定时器配置(用于精确控制采样率)采样率 = 定时器更新频率。例如,要实现100Ksps(每秒10万个点),定时器频率需配置为100kHz。
// 定时器2初始化,用于触发ADC TIM_HandleTypeDef htim2; void MX_TIM2_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 84 - 1; // 假设系统时钟84MHz,预分频后为1MHz htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 10 - 1; // 自动重装载值,决定更新频率:1MHz / 10 = 100kHz htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(&htim2); sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; HAL_TIM_ConfigClockSource(&htim2, &sClockSourceConfig); sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE; // 更新事件触发TRGO sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig); }3. 数据采集与触发逻辑
#define SAMPLE_BUFFER_SIZE 1024 uint16_t adc_buffer[SAMPLE_BUFFER_SIZE]; // DMA目标缓冲区 volatile uint8_t data_ready = 0; // 数据就绪标志 // 启动一次采集 void Start_Sampling(void) { data_ready = 0; // 启动DMA,ADC,定时器 HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, SAMPLE_BUFFER_SIZE); HAL_TIM_Base_Start(&htim2); // 定时器开始,ADC开始被触发转换 } // DMA传输完成中断回调函数 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { HAL_TIM_Base_Stop(&htim2); // 停止定时器,停止触发ADC HAL_ADC_Stop_DMA(hadc); // 停止ADC和DMA data_ready = 1; // 设置标志,通知主循环处理数据 } // 简单的软件边沿触发检测(在ADC中断或循环中调用) uint8_t Check_Trigger(uint16_t current_sample, uint16_t prev_sample, uint8_t edge) { // edge: 0-上升沿,1-下降沿 if(edge == 0) { return (prev_sample < TRIGGER_LEVEL) && (current_sample >= TRIGGER_LEVEL); } else { return (prev_sample > TRIGGER_LEVEL) && (current_sample <= TRIGGER_LEVEL); } } // 当检测到触发时,调用 Start_Sampling();4. 数据处理与电压换算
// 将ADC原始值转换为实际电压值(单位:伏特) // V_ref: ADC参考电压,通常是3.3V // offset: 零点偏移对应的ADC值(输入0V时的ADC读数) // scale: 电压缩放系数,由前端调理电路决定 float ADC_To_Voltage(uint16_t adc_value, float V_ref, uint16_t offset, float scale) { // 假设ADC是12位,满量程值4095对应V_ref float voltage = ((int32_t)adc_value - offset) * (V_ref / 4095.0f); return voltage * scale; // 乘以硬件衰减/放大系数 } // 寻找波形周期和频率(简易过零检测法) float Calculate_Frequency(float* voltage_buffer, uint32_t size, float sample_rate) { uint32_t zero_cross_count = 0; int8_t last_sign = (voltage_buffer[0] > 0) ? 1 : -1; for(uint32_t i=1; i<size; i++) { int8_t current_sign = (voltage_buffer[i] > 0) ? 1 : -1; if(current_sign != last_sign) { zero_cross_count++; last_sign = current_sign; } } // 每两个过零点为一个周期 float period_samples = (size * 2.0f) / zero_cross_count; return sample_rate / period_samples; }6. 功能测试与效果验证流程
完成硬件焊接和软件编写后,需要系统性地测试示波器的各项功能。以下是一个从简单到复杂的验证流程。
6.1 基础硬件与ADC测试
目的:确保信号调理电路和ADC基本工作正常。
- 输入接地(GND):将探头输入端短路到地。运行程序,观察屏幕或上位机显示的波形是否是一条在0V附近的直线。读取ADC原始值,这个值就是你的“零点偏移”(offset),记录下来用于软件校准。
- 输入固定直流电压:使用可调电源或稳压芯片,输入一个已知的稳定直流电压(例如1.0V)。测量实际输入电压,并记录示波器显示的电压值。计算误差,调整软件中的
scale系数。 - 输入方波信号(低频):使用另一块单片机或信号发生器,产生一个100Hz、0-3.3V的方波。观察波形是否显示为清晰的方波,上升/下降沿是否可见。调整显示的时间轴(通过改变采样率和显示点数实现),使屏幕上能显示1-2个完整周期。
6.2 触发功能测试
目的:验证边沿触发是否稳定,波形是否能够稳定显示。
- 输入一个频率为1KHz的正弦波。
- 在软件中设置触发模式为“上升沿”,触发电平设置为波形中间值(例如1.65V)。
- 观察波形。每次触发后,波形起点是否都在上升沿穿过触发电平的位置?多次触发,波形是否重叠良好(稳定)?
- 改为“下降沿”触发,重复测试。
- 调整触发电平:将触发电平设置到波形幅度之外(例如高于波峰或低于波谷),观察波形是否停止刷新(等待触发状态)。这证明触发逻辑有效。
6.3 测量功能测试
目的:验证软件算法计算的参数是否准确。
- 频率/周期测量:输入一个已知频率(如1.000KHz)的正弦波或方波。使用示波器的自动测量功能或读取代码计算出的频率值,与信号源标称值对比。计算相对误差。
- 峰峰值(Vpp)测量:输入一个已知幅度的信号(如2.0Vpp正弦波)。对比示波器测量的Vpp值与万用表测量值或信号源设定值。
- 平均值/有效值(RMS):对于正弦波,其RMS值应为Vpp/(2√2)。对比测量值。
6.4 带宽与采样率极限测试
目的:探索自制示波器的性能边界。
- 逐步增加信号频率:从1KHz开始,逐步增加正弦波信号的频率(10KHz, 50KHz, 100KHz, 200KHz...)。
- 观察波形失真:注意观察波形幅度是否开始下降(带宽不足),以及波形形状是否从正弦波变得“棱角分明”(采样率不足导致的混叠失真)。
- 使用奈奎斯特定律:理论上,要无失真还原信号,采样率至少是信号最高频率的2倍。如果你的采样率是100Ksps,那么能观测到的信号最高频率理论上不超过50KHz。实际由于前端电路限制,有效带宽会更低。
- 记录-3dB点:当信号幅度下降到低频时的0.707倍时,对应的频率即为系统的-3dB带宽。这是评估你硬件设计(尤其是运放和PCB布局)的关键指标。
7. 通过串口与PC通信(上位机方案)
如果屏幕显示受限,或者需要更复杂的分析,可以将数据发送到PC,用Python等工具绘制波形。
STM32端代码(发送一帧数据):
// 假设使用USART1 void Send_Waveform_Data(float* voltage_data, uint32_t size) { char tx_buffer[64]; // 可以发送简单的格式,如每行一个数据点 for(uint32_t i=0; i<size; i++) { int len = snprintf(tx_buffer, sizeof(tx_buffer), "%.3f\n", voltage_data[i]); HAL_UART_Transmit(&huart1, (uint8_t*)tx_buffer, len, 1000); } // 发送帧结束标识 HAL_UART_Transmit(&huart1, (uint8_t*)"END\n", 4, 1000); }PC端Python上位机示例(使用matplotlib):
import serial import matplotlib.pyplot as plt import matplotlib.animation as animation from collections import deque # 配置串口 ser = serial.Serial('COM3', 115200, timeout=1) # 端口和波特率根据实际情况修改 data_buffer = deque(maxlen=1024) # 环形缓冲区 def read_from_serial(): while ser.in_waiting: line = ser.readline().decode('utf-8', errors='ignore').strip() if line == 'END': # 一帧数据接收完毕,可以触发绘图更新 pass elif line: try: value = float(line) data_buffer.append(value) except ValueError: pass fig, ax = plt.subplots() line, = ax.plot([], []) def update(frame): read_from_serial() if data_buffer: line.set_data(range(len(data_buffer)), list(data_buffer)) ax.relim() ax.autoscale_view() return line, ani = animation.FuncAnimation(fig, update, interval=50, blit=True) plt.show()8. 常见问题与排查方法
在制作和调试过程中,你几乎一定会遇到以下问题。这里提供排查思路。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 屏幕无显示或花屏 | 1. SPI引脚连接错误或接触不良。 2. 屏幕初始化序列或驱动代码有误。 3. 电源电压不足。 | 1. 用逻辑分析仪或示波器检查SPI的CLK、MOSI信号。 2. 核对屏幕型号与驱动代码中的初始化命令。 3. 测量屏幕供电电压(通常是3.3V或5V)。 | 1. 重新检查接线,确保CS、DC、RESET等控制线正确。 2. 查找官方驱动示例,替换初始化代码。 3. 确保电源能提供足够电流。 |
| ADC采样值不变或全为0 | 1. ADC引脚配置错误(模拟输入模式)。 2. 信号调理电路无输出或运放未工作。 3. DMA配置错误,数据未搬运到内存。 4. 定时器未正确触发ADC。 | 1. 用CubeMX检查引脚模式。 2. 用万用表测量运放输出端电压。 3. 在DMA完成中断里设置断点,看是否进入。 4. 用示波器看定时器TRGO输出引脚是否有脉冲。 | 1. 将引脚重配置为模拟输入。 2. 检查运放供电、虚地电压(1.65V)是否正常。 3. 检查DMA通道、内存地址、数据宽度配置。 4. 检查定时器时钟、预分频、重装载值配置。 |
| 波形严重失真或噪声大 | 1. 采样率过低,发生混叠。 2. 信号调理电路带宽不足,高频衰减。 3. PCB布局不佳,引入数字噪声。 4. 电源纹波大。 | 1. 输入一个低频正弦波,看是否正常。 2. 测量调理电路在不同频率下的增益。 3. 用示波器直接测量ADC输入引脚上的信号。 4. 测量电源轨上的噪声。 | 1. 提高采样率(降低定时器周期值)。 2. 选择更高带宽的运放,优化反馈回路。 3. 模拟部分与数字部分(特别是MCU、晶振)分开布局,加粗地线。 4. 增加电源滤波电容,使用LDO稳压。 |
| 触发不稳定,波形抖动 | 1. 触发电平设置不当。 2. 信号本身有噪声,在触发电平附近抖动。 3. 触发判断逻辑有bug(如未做去抖)。 | 1. 观察信号和触发电平的位置。 2. 在触发判断函数中加入迟滞比较(Hysteresis)。 | 1. 将触发电平设置在信号变化陡峭的区域。 2. 修改触发判断: (prev < level-hyst) && (curr >= level-hyst)用于上升沿。 |
| 测量参数(频率、电压)误差大 | 1. 软件换算公式错误。 2. 硬件调理电路电阻精度不够。 3. ADC参考电压不准(内部Vref)。 4. 未进行软件校准(零点、增益)。 | 1. 用已知精度的信号源和万用表做基准对比。 2. 测量并记录输入多个已知电压时的ADC值,做线性拟合。 | 1. 核对电压换算代码。 2. 使用1%精度的电阻。 3. 使用外部精密基准电压源给ADC做参考。 4. 在代码中增加两点校准功能: V_real = k * ADC_raw + b,通过实测两点解出k和b。 |
| 串口上位机收不到数据或数据乱码 | 1. 串口波特率、数据位、停止位不匹配。 2. 发送数据过快,缓冲区溢出。 3. 文本格式解析错误。 | 1. 用串口调试助手先收发简单字符测试。 2. 在STM32端发送后加延时,或使用DMA发送。 3. 检查上位机代码的编码和解码方式。 | 1. 确保STM32与PC软件串口参数完全一致。 2. 优化发送逻辑,或使用流控。 3. 发送时加入帧头、帧尾、校验和,增强协议鲁棒性。 |
9. 项目优化与扩展方向
当基础功能实现后,可以考虑以下方向进行优化和扩展,这能让你的毕设更具深度和亮点。
- 多量程自动切换:通过模拟开关(如CD4051)切换前端衰减网络的不同电阻,配合软件实现电压量程(如1V/div, 2V/div, 5V/div)的自动或手动切换。
- 时基(扫描速度)调整:动态改变定时器的频率(采样率)和显示缓冲区对应的实际时间宽度,实现类似商业示波器的“秒/格”调整功能。
- 双通道显示:使用STM32的多个ADC单元或交替采样,配合两个独立的前端调理电路,实现双通道输入和显示,可以观察两个信号的相位关系。
- 波形存储与回放:利用STM32的内部Flash或外接SD卡,将采集到的波形数据保存下来,之后可以回放分析。
- FFT频谱分析:在STM32上实现简单的FFT算法(或利用DSP库),将时域波形转换为频域频谱并显示,用于分析信号的频率成分。
- 更好的显示界面:在TFT屏上绘制网格、刻度、测量参数文字,实现更专业的UI。甚至可以实现触摸屏控制。
- 使用更高性能的MCU:升级到STM32H7系列或带有高速ADC的型号,可以大幅提升采样率和有效带宽。
这个基于STM32的简易示波器项目,其价值远不止于得到一个能用的测量工具。它贯穿了硬件设计、信号处理、嵌入式编程和系统调试的完整流程,每一个问题的排查和解决都是宝贵的经验。从调通第一个ADC采样值,到稳定显示一个清晰的波形,再到实现触发和测量,这个过程会让你对“示波器”这个黑盒子的内部原理有透彻的理解。建议你在动手时,做好详细的实验记录,包括每一次电路修改、每一版代码的变更和对应的测试结果,这不仅是完成毕设的要求,更是未来解决更复杂工程问题的良好习惯。