拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于STM32的示波器设计:ADC+DMA+触发+时基的完整实现

简介一份基于STM32F103ZET6微控制器的完整示波器设计源码项目面向嵌入式系统学习者和开发者可帮助理解从模拟信号采集、硬件电路设计到上位机显示交互的完整链路。资源压缩包大小约19.27MB整合了硬件原理图与PCB图、STM32端核心算法源码包含采样、滤波、存储、触发等模块、上位机测试程序以及设计文档和解释文档便于对照硬件与代码逐层分析。目前已有1388人学习下载适合需要实践STM32外设应用、数字信号处理及串口通信技术的人群。通过这套资源读者既能借助图纸掌握电路布局与信号路径又能从设计文档中获取项目目标、选型依据、功能实现与调试过程同时参考解释文档详解硬件原理与软件架构从而快速复现项目并在此基础上扩展新功能是兼顾入门与进阶的实用性资料。1. 为什么折腾一台“用STM32做的示波器”如果只给我一块 STM32、一块 LCD 和几个按键能不能搓出一台能看波形、触发起停、调时基的示波器在“STM32示波器的设计源码程序”这个标题背后主力器件的起点往往是 STM32F103 或 F407它内带 12 位 ADC、定时器触发 DMA 传输还有足够的中断资源去干“采数据、绘波形、响按键”三件并行的事。对于 5 年以上经验的人来说这么做的意义不是替代一台几千块的台式示波器而是吃透 ADCDMA定时器这条链路以及把“采样率、时基、触发、缓冲区”这几个示波器概念写进固件里。工作中有四种人适合照着这个方案动手一是要给学生讲“示波器原理”的实验课老师二是想把产线带宽测试板做成低成本测量装置的工程师三是做音频、电源纹波预处理观测的嵌入式开发者四是想在求职作品里拿出点完整系统设计源码的应届生。下面这一整套代码和参数建议我按“一个能跑、可调时基、可触发、能显示”的最小系统来讲先立住原理再给可复现的命令和源码。2. 搭建采样链路模拟前端、ADC 与 DMA 双缓冲2.1 输入调理衰减、直流偏置和钳位示波器第一级通常不是直接把信号怼进芯片引脚否则 12 位 ADC 的 03.3V 输入范围会很快把你限制死。常见做法是用电阻分压把 ±10V 信号缩到 03.3V 以内再用运放做跟随和直流偏置。我一般会留两个量程一个直接通道适配 03.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 时钟 72MHzADC 预分频取 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 触发源PSC1-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主循环读head到tail之间的数据做触发检测和绘图读取完再把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/div3011ms/div3001010ms/div30001004. 显示渲染与交互菜单、网格和波形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一般只在用户调整时基时才重绘文本区域波形刷新时不翻页——这就是“局部刷新”的思路。在 F10372MHz、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, timeout1) 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 的刷屏带宽和写入方式。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门