基于STM32的示波器设计:ADC+DMA+触发+时基的完整实现
2026/9/16 16:20:38 网站建设 项目流程

简介:一份基于STM32F103ZET6微控制器的完整示波器设计源码项目,面向嵌入式系统学习者和开发者,可帮助理解从模拟信号采集、硬件电路设计到上位机显示交互的完整链路。资源压缩包大小约19.27MB,整合了硬件原理图与PCB图、STM32端核心算法源码(包含采样、滤波、存储、触发等模块)、上位机测试程序,以及设计文档和解释文档,便于对照硬件与代码逐层分析。目前已有1388人学习下载,适合需要实践STM32外设应用、数字信号处理及串口通信技术的人群。通过这套资源,读者既能借助图纸掌握电路布局与信号路径,又能从设计文档中获取项目目标、选型依据、功能实现与调试过程,同时参考解释文档详解硬件原理与软件架构,从而快速复现项目并在此基础上扩展新功能,是兼顾入门与进阶的实用性资料。

1. 为什么折腾一台“用STM32做的示波器”

如果只给我一块 STM32、一块 LCD 和几个按键,能不能搓出一台能看波形、触发起停、调时基的示波器?在“STM32示波器的设计源码程序”这个标题背后,主力器件的起点往往是 STM32F103 或 F407:它内带 12 位 ADC、定时器触发 DMA 传输,还有足够的中断资源去干“采数据、绘波形、响按键”三件并行的事。对于 5 年以上经验的人来说,这么做的意义不是替代一台几千块的台式示波器,而是吃透 ADC+DMA+定时器这条链路,以及把“采样率、时基、触发、缓冲区”这几个示波器概念写进固件里。

工作中有四种人适合照着这个方案动手:一是要给学生讲“示波器原理”的实验课老师;二是想把产线带宽测试板做成低成本测量装置的工程师;三是做音频、电源纹波预处理观测的嵌入式开发者;四是想在求职作品里拿出点完整系统设计源码的应届生。下面这一整套代码和参数建议,我按“一个能跑、可调时基、可触发、能显示”的最小系统来讲,先立住原理再给可复现的命令和源码。

2. 搭建采样链路:模拟前端、ADC 与 DMA 双缓冲

2.1 输入调理:衰减、直流偏置和钳位

示波器第一级通常不是直接把信号怼进芯片引脚,否则 12 位 ADC 的 0~3.3V 输入范围会很快把你限制死。常见做法是用电阻分压把 ±10V 信号缩到 0~3.3V 以内,再用运放做跟随和直流偏置。我一般会留两个量程:一个直接通道,适配 0~3.3V;一个 10:1 衰减,再经运放加法器把信号中心搬到 1.65V,这样交流信号的负半周才不会在 ADC 入口被削掉。

参数项参考值选型/作用说明
ADC 参考电压3.3V(外部基准更稳)决定垂直分辨率,1 LSB ≈ 0.806mV
输入保护双向 TVS + 串电阻 1kΩ防浪涌和反接,采样保持电容前限流
偏置中心1.65V让交流信号对称进入 ADC 动态范围
运放SGM8532 / OPA357 + 跟随器高带宽、低输出阻抗,驱动片内 ADC

注意:ADC 采样电容对前一瞬间的电流很敏感,驱动器输出阻抗大了,采样保持时间不够就会带来非线性。这也是有人用几十千欧电阻分压后直接进引脚,低频没事、高频波形却出现固定台阶的原因。真实示波器里还有“探头×1/×10”补偿电容网络,在自制方案里先用纯电阻布局即可,但 PCB 走线要尽量短,避免采样点旁边有高速数字线。

2.2 采样率怎么定:定时器作为精准节拍器

STM32 的 ADC 有两种常规采样来源:软件启动和外部触发。软件启动可以把连续转换 Open 开到极限,但时间戳不稳定,而且 CPU 要参与填缓冲区。示波器对采样间隔的一致性要求很高,否则在屏幕上就是一个可变“跨度”的曲线。常见选择是用定时器更新事件去触发 ADC 转换,每个更新事件采一个点。

采样频率计算:

Fs = Fpclk / (ADC_PRESCALER * TIM_PRESCALER * TIM_PERIOD)

例如 APB2 时钟 72MHz,ADC 预分频取 6、定时器预分频 1-1,则:

Fs = 72000000 / (6 * 1 * 40) ≈ 300kHz

每个触发点间隔约 3.3μs,对 100kHz 以内的信号完全够用;想看更高频率就需要换 F407 的 ADC 双交错采样或直接用 DMA 突发模式。参数里最容易写错的是定时器分频系数——如果取“预分频值”而不减 1,采样率会直接差出若干倍。别问我是怎么知道的,代码注释里也容易忽略,建议在初始化里统一写成宏并留一个自测断言。

到定时器边沿时,硬件会自动触发一次 ADC 转换,转换结果通过 DMA 自动搬运到内存。这里的关键点是:CPU 只负责看“缓冲区满了一半没有”,而不是去读寄存器。DMA 的两个空闲缓存区轮流使用,就可以边采集边绘图,不丢点。

2.3 ADC + DMA 初始化最小代码

下面这段用 HAL 库写,结构上比寄存器版本直观,适合作为源码程序的基础骨架:

/* 输入参数:timer_trigger_freq 为目标采样率,例如 300000u */ static ADC_HandleTypeDef hadc1; static TIM_HandleTypeDef htim2; void scope_adc_init(uint32_t sample_rate_hz) { __HAL_RCC_ADC1_CLK_ENABLE(); __HAL_RCC_TIM2_CLK_ENABLE(); /* ADC 配置:单通道、12 位、右对齐、定时器触发 */ hadc1.Instance = ADC1; hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV6; hadc1.Init.Resolution = ADC_RESOLUTION_12B; hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.ScanConvMode = ADC_SCAN_DISABLE; hadc1.Init.ContinuousConvMode = DISABLE; hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.EOCSelection = ADC_EOC_SINGLE_CONV; hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_RISING; hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T2_TRGO; hadc1.Init.DMAContinuousRequests = ENABLE; HAL_ADC_Init(&hadc1); HAL_ADCEx_Calibration_Start(&hadc1); /* 通道 0:采样时间取 13.5 周期,兼顾内阻和速度 */ ADC_ChannelConfTypeDef sConfig = {0}; sConfig.Channel = ADC_CHANNEL_0; sConfig.Rank = ADC_REGULAR_RANK_1; sConfig.SamplingTime = ADC_SAMPLETIME_13CYCLES_5; HAL_ADC_ConfigChannel(&hadc1, &sConfig); /* TIM2 更新事件作为 ADC 触发源,PSC=1-1 相当于不分频 */ __HAL_RCC_TIM2_CLK_ENABLE(); htim2.Instance = TIM2; htim2.Init.Prescaler = 1u - 1u; htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = (72000000u / 6u / sample_rate_hz) - 1u; htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(&htim2); TIM_MasterConfigTypeDef master = {0}; master.MasterOutputTrigger = TIM_TRGO_UPDATE; master.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim2, &master); }

DMA 部分:

static uint16_t adc_buf[SCOPE_BUF_LEN]; // 环状采集缓冲 static DMA_HandleTypeDef hdma_adc1; void scope_dma_start(void) { __HAL_RCC_DMA1_CLK_ENABLE(); hdma_adc1.Instance = DMA1_Channel1; hdma_adc1.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_adc1.Init.PeriphInc = DMA_PINC_DISABLE; hdma_adc1.Init.MemInc = DMA_MINC_ENABLE; hdma_adc1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; hdma_adc1.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_adc1.Init.Mode = DMA_CIRCULAR; // 环形,自动回卷 hdma_adc1.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_adc1); __HAL_LINKDMA(&hadc1, DMA_Handle, hdma_adc1); HAL_DMA_Start_IT(&hdma_adc1, (uint32_t)&hadc1.Instance->DR, (uint32_t)adc_buf, SCOPE_BUF_LEN); HAL_ADC_Start(&hadc1); }

参数说明:DMA_CIRCULAR是关键,它保证 DMA 在缓冲区末尾自动回卷、继续搬运,采集线程不必时刻干预。HAL_DMA_Start_IT启动后会触发“半传输”和“传输完成”两个中断:半传输中断时 CPU 可以整理前半段数据,传输完成中断时处理后半段,两者错开,正好实现双缓冲效果。

失败时优先检查三件事:__HAL_LINKDMA是否在HAL_ADC_Start之前执行;定时器 TRGO 更新事件是否真的输出;DMA 通道对应映射对不对——F103 的 ADC1 只走 DMA1_Channel1,而 F407 是 channel0,移植时千万不要照抄。

3. 源码核心:环形缓冲、触发条件与时基换算

3.1 为什么用环形缓冲区而不是数组

最简单思路是把采样点直接存进数组,采集完一整段再去绘图,缺点是触发前波形(预触发)没法保存,而且采样和显示不能同时工作。环形缓冲的作用是让 DMA 永远只往固定内存里写,读方按照标尺同步追赶,本质上是一个“生产者-消费者”模型。

环形缓冲的核心代码可以简化成下面这样:

#define SCOPE_RB_SIZE 4096u typedef struct { uint16_t buf[SCOPE_RB_SIZE]; // 存放 ADC 原始值 uint16_t head; // DMA 上次写完的位置 uint16_t tail; // 显示线程当前读取位置 } scope_ring_buffer_t; uint16_t scope_rb_write_pos(uint16_t dma_counter) { return (dma_counter + SCOPE_RB_SIZE - 1u) & (SCOPE_RB_SIZE - 1u); }

这里SCOPE_RB_SIZE必须是 2 的幂,这样取模操作可以用一个与运算替代,省掉的除法开销在 4096 长度上微不足道,但思路值得保留。DMA 在中断里更新head,主循环读headtail之间的数据做触发检测和绘图,读取完再把tail推上去。

实际工程常见坑是头部和尾部越界追尾:当head追上tail时会出现新数据覆盖未消费数据。示波器场景里,解决办法是让绘图消费速度永远快于采集速度,一旦慢就被打败,通常需要按比例缩小时间刻度或降低刷新率,不要在环形缓冲区里做复杂 IPCC。

3.2 触发怎么写:边沿检测和“预触发”

没有触发功能,波形在屏幕上会左右乱跑。触发是指定一个电压阈值和方向,当 ADC 序列里出现“从阈值下方跨到上方”(上升沿触发)时,从那个点往前数一段样本作为屏幕起点,之后整帧波形就能保持稳定。

简单实现如下:

int32_t scope_find_trigger(const uint16_t *data, uint32_t len, uint16_t threshold, uint8_t edge, uint32_t pre_trig) { uint8_t in_prev = (data[0] >= threshold) ? 1u : 0u; for (uint32_t i = 1u; i < len; i++) { uint8_t in_cur = (data[i] >= threshold) ? 1u : 0u; if (in_prev == 0u && in_cur == 1u) { // 上升沿穿过阈值 if (edge == TRIG_EDGE_RISING) { return (pre_trig <= i) ? (int32_t)(i - pre_trig) : 0; } } else if (in_prev == 1u && in_cur == 0u && edge == TRIG_EDGE_FALLING) { return (pre_trig <= i) ? (int32_t)(i - pre_trig) : 0; } in_prev = in_cur; } return -1; // 当前窗口内没触发 }

参数说明:pre_trig是预触发样本数,表示波形起点比触发点早多少个样本。触发点应该落在屏幕约 10%~20% 的位置,这样用户能看到触发前的信号。没有返回触发就再等下一段 DMA 数据,直到满足条件再刷新屏幕——这对应示波器的“正常触发模式”。

值得留意的性能点:在 STM32F103 上做逐点for循环比较 4096 个点,72MHz 主频下也就百微秒级别,完全可接受。但如果涉及浮点运算,记得把软件浮点库换成-O2优化编译,这对性能提升很大。

3.3 时基换算:把“每毫秒多少点”变成“每格多少点”

时基(time/div)水平方向核心概念。一块 240×320 分辨率的 LCD,横轴 8 格,每格 30 像素。给定时基 1ms/div,采样率 300kHz,那么每格波形需要 300 个样本,整个屏幕约 2400 个样本。

uint32_t samples_per_div(uint32_t time_base_ms, uint32_t sample_rate_hz) { return (uint32_t)((uint64_t)(time_base_ms * sample_rate_hz) / 1000u); }

对应每像素样本数:

uint32_t samples_per_pixel = samples_per_div(1, 300000) / 30; // 约 10 点/像素

在绘制阶段,如果直接将每一列样本取最后一个,噪声峰值会丢,观感差。我要做的简单而有效的方案是“峰值包络”抽样:对每个像素块,取该块集内最大值和最小值,一条竖直的画线连接 min 到 max,这样高频细节和毛刺不会被压缩掉。这个方法在时基压缩到每像素十几个样本时尤其重要。

时基每格点数(300kHz 采样率)每像素点数
100μs/div301
1ms/div30010
10ms/div3000100

4. 显示渲染与交互:菜单、网格和波形

4.1 波形绘制:不要用描点函数

在 240×320 的 TFT 屏上,如果我直接调用 GPU 的draw_point多次,刷新一帧波形就会因为 SPI/I8080 接口带宽被占满而卡顿。通常做法是把整个波形绘制看成“竖线集合”:波形上任一列像素延 y 方向画一条直线。

波形映射代码:

int16_t scope_adc_to_y(uint16_t adc_val, uint16_t adc_center, uint16_t adc_per_div, int16_t y_center) { int16_t offset = (int16_t)((int32_t)adc_val - adc_center) * LCD_Y_DIV; return y_center - (int16_t)((int32_t)offset / adc_per_div); }

参数说明:adc_center是波形垂直中心对应的 ADC 值,通常为 2048(±10V 输入时)或 0(单极性直通)。adc_per_div是垂直方向每格所代表的 ADC 码值,比如 1V/div 时约为 1241 码。LCD_Y_DIV是一格所占的像素数,常见取 30 左右。

绘制整帧的写法:

void scope_draw_wave(void) { uint32_t spp = samples_per_pixel_current; uint16_t *p = scope_window_start; // 已经由触发机制定位 for (uint16_t x = 0; x < LCD_WIDTH; x++) { uint16_t vmin = 0xFFFF, vmax = 0; for (uint32_t k = 0; k < spp; k++) { uint16_t v = p[x * spp + k]; if (v < vmin) vmin = v; if (v > vmax) vmax = v; } int16_t y1 = scope_adc_to_y(vmax, center_adc, adc_per_div, Y_DIV_CENTER); int16_t y2 = scope_adc_to_y(vmin, center_adc, adc_per_div, Y_DIV_CENTER); lcd_draw_vline(x, y1, y2, LCD_COLOR_WAVE); } }

细节说明:外层循环 x 从 0 到LCD_WIDTH-1,每次处理一个像素列对应的一组样本。内层当前只取遍历 min/max,没有做平均和累加,这保证突发毛刺能被完整绘制。spp在时基变化时只需重算一次,不用每帧更新。

4.2 网格、基准线和坐标标注

示波器屏幕上必须有网格,否则没法读数。网格绘制有两种方案:预先烧录一张静态位图;或者初始化时用draw_line把 10×8 的交点描一遍。网格线在示波器实际效果差异不大,但要确保网格不会覆盖波形刷新——通常采用独立图层,即先画网格再画波形。垂直中线和水平中线用短划线表示,避免与方形网格混淆。

横坐标每格下方标时基值,比如“1ms”。因字符显示占用 CPU,一般只在用户调整时基时才重绘文本区域,波形刷新时不翻页——这就是“局部刷新”的思路。在 F103@72MHz、ILI9341 SPI 模式下,全局刷新一屏从几百毫秒降到约 30ms,肉眼感知是流畅的。

4.3 按键状态机与菜单参数调整

四个按键通常承担“上、下、左、右”或“菜单切换、确认”功能。我会把这些按键扫描放到 1ms 定时器中断里,用状态机消除抖动和连击效应。

typedef enum { MENU_TIME_DIV, MENU_VOLT_DIV, MENU_TRIG_EDGE, MENU_TRIG_LEVEL } scope_menu_t; void scope_menu_process(uint8_t key) { static scope_menu_t menu = MENU_TIME_DIV; switch (menu) { case MENU_TIME_DIV: if (key == KEY_UP) { time_base_ms = next_larger(time_base_ms); } if (key == KEY_DOWN) { time_base_ms = next_smaller(time_base_ms); } break; case MENU_VOLT_DIV: if (key == KEY_UP) { adc_per_div = next_smaller(adc_per_div); } if (key == KEY_DOWN) { adc_per_div = next_larger(adc_per_div); } break; case MENU_TRIG_EDGE: if (key == KEY_LEFT || key == KEY_RIGHT) { trigger_edge ^= 1u; // 上升沿/下降沿切换 } break; case MENU_TRIG_LEVEL: if (key == KEY_UP) { trigger_level += 32u; } if (key == KEY_DOWN) { trigger_level -= 32u; } break; } }

这里的时间步进表next_larger/next_smaller是阶梯数组,比如 0.1ms、0.2ms、0.5ms、1ms、2ms、5ms、10ms,每档之间十进制步进,和真实示波器旋钮的“1-2-5 步进”规则一致。因为按键回调里改的不是一个不断累加的计数器,而是映射到固定档位,参数不会出现未定义中间值,也不容易漏算采样率和显示比例。

触发电平键让用户选择阈值,在源码里对应trigger_level变量,并在触发判断函数中替换掉传入的threshold。屏幕上最好画一条虚线标出阈值位置,否则用户不知道现在电平设在哪,但绘图刷新时要注意这条线不能和网格线颜色一致,否则看不出触发动作。

5. 进阶:在滚动模式和自动测量上榨干这套系统

5.1 滚动模式:低速信号下的连续更新

把时基调到 1s/div 时,上面那套“整帧刷新”方案会显得割裂,因为波形的细节流动感完全丢失。更好的方案是滚动模式:屏幕右边缘作为“当前时刻”,历史数据向左移动。实现时不必把整个缓冲区往左挪,只需要把触发逻辑关掉,每获取固定数量的新样本就在屏幕上向右画一列点,同时启动一次横向偏移。为了不拖慢绘图,也可以把一部分工作丢给 DMA:绘制历史波形列时,直接把整块显存用HAL_SPI_Transmit_DMA搬到 LCD 控制器的 GRAM 写入地址,让 SPI 外设忙,CPU 继续读 ADC。

5.2 自动测量:频率、幅值和占空比

示波器如果没有自动测量,只能靠眼睛读刻度。常见做法是拿采集缓冲里的完整帧做统计:

void scope_measure(uint16_t *data, uint32_t n, int32_t *freq_hz, uint16_t *vpp, uint16_t *vavg) { uint32_t min = 0xFFFF, max = 0, sum = 0; for (uint32_t i = 0; i < n; i++) { uint16_t v = data[i]; if (v < min) min = v; if (v > max) max = v; sum += v; } *vpp = (uint16_t)(max - min); *vavg = (uint16_t)(sum / n); // 用中值作为过零阈值,简单但有防偏置效果 uint16_t mid = (uint16_t)(((uint32_t)min + max) / 2u); uint32_t crossings = 0; uint8_t old_above = (data[0] > mid) ? 1u : 0u; for (uint32_t i = 1; i < n; i++) { uint8_t above = (data[i] > mid) ? 1u : 0u; if (above != old_above) crossings++; old_above = above; } *freq_hz = (int32_t)((crossings / 2u) * sample_rate_hz / n); }

这个方法在低噪声环境下足够准确,但对面敏感,容易因为抖晃多算过零次数。如果被测信号中心线偏离 1.65V,中值会比平均值更稳,因为平均值会把直流成算进阈值,造成高低电平不对称时过零判断错位。

5.3 通过 UART/VCP 把采样导出到上位机

一旦有异常波形现场抓不到,能打印到命令行的小工具极其有用。测试版固件可以加一段 UART 导出逻辑:

void scope_export_raw(void) { extern uint16_t adc_buf[SCOPE_BUF_LEN]; for (uint32_t i = 0; i < SCOPE_BUF_LEN; i++) { printf("%u,%u\r\n", i, adc_buf[i]); } }

配套 Python 脚本一拉到底:

import serial ser = serial.Serial('COM10', 115200, timeout=1) samples = [] for line in ser: line = line.strip() if b',' not in line: continue idx, val = line.split(b',') samples.append(int(val)) if len(samples) >= 4096: break ser.close()

串口导出的波特率限制了实时性,115200 下导出 4096 点约耗时 0.4 秒,适合做单次捕获和离线分析。想真看图,就把这份 raw 数据丢进 Jupyter 用 matplotlib 画出来,比直接在屏幕上数刻度精准得多。

真正要把它做成产品级源码,我建议再把垂直光标和水平光标测量加进去:两个光标分别对应两个 ADC 样本索引,屏幕上显示 ΔV/Δt,实现思路仍然是复用scope_adc_to_y,只是把坐标换算成表笔电压。至于速度,F103 跑到 300kHz 采样率、每帧 2400 点波形重绘还有余量,够了——ST 家的芯片跑示波器,瓶颈从来不在 CPU,而在你给 LCD 的刷屏带宽和写入方式。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询