☰
STM32F407简易示波器:ADC+DMA+定时器实现波形采集与显示
2026/10/14 19:54:26 网站建设 项目流程

简介:面向STM32F407学习者的简易示波器完整源代码工程,源自作者疫情期间为学习F407而设计制作的实践项目,覆盖模拟量定时采集、继电器控制放大电路倍率、独立按键与状态灯交互、彩色液晶屏波形显示等主要模块,软件采用主循环加采集中断加定时界面更新的架构,可计算信号周期、频率与峰峰值,适合想通过具体项目掌握单片机外设驱动和示波器工作原理的开发者。源码包共171个文件,以73个C源文件和65个头文件为主体,其中C/H代码覆盖系统时钟、定时器、ADC采集、串口通信、LCD驱动及文件系统等底层模块;另有png图片作为界面显示素材,uvprojx为Keil工程配置,hex为编译生成的烧录文件。整体仅1.18MB,结构清晰,可直接导入Keil5.3编译学习。已有3346人学习参考,工程对按键设置采样周期与电压范围、继电器切换增益、LCD实时绘制采集曲线等均有完整实现,读者可顺着中断、定时器、ADC、LCD驱动和串口通信等主线拆解代码,作为嵌入式入门项目的参考。

1. 简易示波器为什么会出现在一块 F407 开发板上

调试电机驱动波形、串口电平翻转甚至无刷电调 PWM 占空比的时候,手边没有台式示波器,逻辑分析仪又被同事借走,是很多嵌入式工程师都遇到过的场景。当时我用某块 STM32F407 开发板临时拼了一个简易示波器,把片内 12 位 ADC 当采样前端,LCD 当屏幕,串口把原始采样数据倒给 PC 画波形,居然解决了八成问题。这份基于 STM32F407 的简易示波器源代码,就是用 C/C++ 实现 ADC 采样、触发、LCD 波形绘制和串口导出的完整工程。它不适合测射频或百兆高速信号,但用来观察几十 kHz 以内的模拟信号、排查传感器输出和串口电平,比靠万用表猜靠谱得多。

2. 采样链路:ADC1、DMA 和定时器触发怎么配合才不丢点

2.1 为什么不是轮询 ADC,而是定时器触发 + DMA

很多初学者写示波器第一个版本会这样:主循环里HAL_ADC_Start、等待转换完成、读数据、画点。这个做法的致命问题是采样间隔完全被主循环里的其他代码干扰。按键扫描、LCD 刷新、串口发送都会让某两个采样点之间的时间间隔拉长,波形在 LCD 上看起来就是忽快忽慢,频率计算也完全不可信。

正确做法是让 ADC 的采样节奏由硬件定时器决定。我用 TIM2 产生更新事件,通过 TRGO 触发 ADC1 的规则组转换,转换结果由 DMA 自动搬运到内存缓冲区,全程不需要 CPU 干预。这样采样间隔只取决于定时器的重装值和预分频值,不受主循环代码影响。

/* 时基档位表:每档对应一屏 10 格的全屏时间 */ static const uint16_t tim2_arr_lut[5] = { 199, /* 200us/div, 全屏 2ms, 采样点间隔约 1.95us */ 499, /* 500us/div, 全屏 5ms, 采样点间隔约 4.88us */ 999, /* 1ms/div, 全屏 10ms, 采样点间隔约 9.77us */ 4999, /* 5ms/div, 全屏 50ms, 采样点间隔约 48.8us */ 9999 /* 10ms/div, 全屏 100ms, 采样点间隔约 97.7us */ }; static void tim2_adc_trigger_init(uint8_t tb_index) { TIM_HandleTypeDef htim2 = {0}; __HAL_RCC_TIM2_CLK_ENABLE(); htim2.Instance = TIM2; htim2.Init.Prescaler = 83; /* 84MHz / 84 = 1MHz */ htim2.Init.Period = tim2_arr_lut[tb_index]; htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(&htim2); TIM_MasterConfigTypeDef sMasterConfig = {0}; sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE; sMasterConfig.MasterSlaveMode = TIM_MASTERSlaveMode_ENABLE; HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig); HAL_TIM_Base_Start(&htim2); }

这里关键是预分频系数 83,把 TIM2 的计数时钟变成 1MHz,这样 ARR 的值直接就是微秒数。200μs/div 时全屏 2ms,1024 个采样点,相邻点间隔约 1.95μs,对应 ARR=1,实际操作中这个值偏小,所以我最低档从 200μs/div 起步,不再往下压。需要更高采样率时,把预分频系数调小即可,但要注意 ADC 转换时间约 1μs 级,采样率再往上提,转换还没完成下一次触发就到了,DMA 搬到的还是旧数据。

2.2 ADC1 规则组与 DMA 循环模式配置

采样数据落地用 DMA 循环模式,缓冲区设为 2048 个 uint16_t,相当于双缓冲,每满 1024 点翻转一次。这样上半区填的时候下半区可以安全读取,画图线程和 DMA 写入互不踩踏。

static void adc1_dma_init(void) { ADC_HandleTypeDef hadc = {0}; __HAL_RCC_ADC1_CLK_ENABLE(); hadc.Instance = ADC1; hadc.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4; hadc.Init.Resolution = ADC_RESOLUTION_12B; hadc.Init.ScanConvMode = ADC_SCAN_DISABLE; hadc.Init.ContinuousConvMode = DISABLE; /* 由定时器触发 */ hadc.Init.DiscontinuousConvMode = DISABLE; hadc.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc.Init.EOCSelection = ADC_EOC_SINGLE; HAL_ADC_Init(&hadc); ADC_ChannelConfTypeDef sConfig = {0}; sConfig.Channel = ADC_CHANNEL_1; sConfig.Rank = ADC_REGULAR_RANK_1; sConfig.SamplingTime = ADC_SAMPLETIME_84CYCLES; HAL_ADC_ConfigChannel(&hadc, &sConfig); HAL_ADC_Start_DMA(&hadc, (uint32_t *)adc_buf, 2048); }

ContinuousConvMode必须设为DISABLE,否则 ADC 会自己连续转换,定时器触发就失去意义了。采样时间我用了 84 个时钟周期,ADC 时钟来自 APB2 分频,84 个周期大约 1μs,兼顾了输入阻抗和转换精度。如果信号源内阻较高,比如几十 kΩ 分压网络出来再直接进 ADC,采样时间太短会采不准,表现为直流信号读数偏低。

DMA 配置成循环模式后,我用HAL_ADC_ConvHalfCpltCallback和HAL_ADC_ConvCpltCallback两个回调来通知上层“下半区准备好了”和“上半区准备好了”,在回调里只置一个标志位,不做任何绘制动作。绘制放在主循环里检查标志位执行,避免在中断上下文里操作 LCD。

2.3 帧缓冲区切换与采样完成标志

volatile uint16_t *frame_ready = NULL; volatile uint8_t sampling_done = 0; void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { frame_ready = adc_buf; /* 前半段 */ sampling_done = 1; } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { frame_ready = adc_buf + 1024; /* 后半段 */ sampling_done = 1; } }

frame_ready指向当前可用帧,主循环里检测到sampling_done后,先备份指针,再清标志位,然后用这个指针做绘制。DMA 继续往另一块缓冲区写,两边互不干扰。这里有个细节:如果只在sampling_done置位后直接把全局数组地址传给绘制函数,不备份指针,那么 DMA 在绘制过程中写完了当前半区并翻转,绘制函数后半段拿到的其实是新数据,整个波形头部尾部会错位。必须先备份指针再清除标志,保证这一帧从第一个点到最后一个点都是同一批数据。

3. LCD 波形绘制:坐标映射、网格与滚动刷新怎么做到不撕裂

3.1 ADC 原始值到 LCD 像素的映射

LCD 显示波形不是简单把 ADC 采样值除以 16 画上去。12 位 ADC 取值为 0 到 4095,而 LCD 纵向像素一般只有 240 或 320 行,直接线性压缩会丢失信号细节。我做垂直方向映射时,顶部和底部各留出 8 像素作为坐标边框,实际波形区域高度是 LCD 高度减去 16。

static uint16_t adc_to_lcd_y(uint16_t adc_value, uint16_t lcd_height, uint8_t grid) { uint32_t y; /* 顶部留 grid 像素边框,底部留 grid 像素边框 */ y = (uint32_t)adc_value * (lcd_height - 2 * grid) / 4095 + grid; if (y >= lcd_height) { y = lcd_height - 1; } return (uint16_t)y; }

这里故意除以 4095 而不是用右移 12 位。右移 12 位等效除 4096,ADC 最大值 4095 算出来的结果会比除 4095 少 1 个像素,在屏上看起来是波形顶格差一格,虽然差别小,但配合网格线时一眼就能看出来,属于颜色像素级强迫症问题,但不难避免。

水平方向处理:采样点是 1024 个,LCD 横向像素是 320 或者 480,需要抽点或者平均。我用的是均匀映射:

uint16_t idx = (uint32_t)x * (frame_len - 1) / (lcd_width - 1);

注意是(frame_len - 1) / (lcd_width - 1),不是frame_len / lcd_width。原因很简单:屏幕最左边对应第 0 个采样点,屏幕最右边对应最后一个采样点,如果按frame_len / lcd_width映射,最后一个采样点永远不会画到屏幕右边缘,波形右侧会缺一段。

3.2 网格绘制与触发位置标记

波形显示区域我把背景刷成深色,网格用低亮度灰色实线画,水平方向每格 20 像素,垂直方向每格也是 20 像素,方便估算幅值。触发位置在屏幕中间偏左画一条垂直虚线,因为示波器事件触发后,我们关心触发点之前一小段窗口,触发位置放在 1/3 处比正中更能看到触发前波形。

网格绘制只在初始化时做一次,之后刷新波形只更新最左边的背景列和最右边的波形列,不整屏重刷。原因是整屏重刷会有明显的闪烁,而且刷屏期间 DMA 还在写下一帧,LCD 控制器带宽被大量占据,刷新率会掉下来。我实测过,整屏刷一帧大约耗 20ms,而只刷新改动区域耗 5ms 左右,这个差异直接影响 10ms/div 档位的观感。

3.3 时基档位与垂直灵敏度参数表

工程里预设了五档时基、四档垂直灵敏度。垂直灵敏度档位不是靠硬件程控增益实现的,而是软件把 ADC 值按比例缩放到显示区域,再在屏幕角落标注“每格对应多少 mV”。

时基档位全屏时间采样间隔可视频率上限
200μs/div2ms约 1.95μs约 250kHz
500μs/div5ms约 4.88μs约 100kHz
1ms/div10ms约 9.77μs约 50kHz
5ms/div50ms约 48.8μs约 10kHz
10ms/div100ms约 97.7μs约 5kHz

垂直灵敏度我做了 0.5V/div、1V/div、2V/div、5V/div 四档。0.5V/div 时满屏 5V,对应 240 像素纵向区域,每个像素代表约 20mV,这个精度在 3.3V 系统里可以分辨出几十毫伏的纹波。超过 5V/div 的档位没有加,因为 F407 的 ADC 输入范围就是 0 到 VREF,前端没有做衰减电路,直接测高压会把引脚打坏。

4. 串口导出:把采样原始数据倒给 PC 端做精准分析

4.1 串口帧格式设计:别直接发裸数据

LCD 上看波形轮廓没问题,但要精确测量一个脉冲宽度、查看触发点附近的细节,屏幕像素不够。我常用的做法是把 ADC 原始采样值通过串口发到 PC,用 Python 脚本解析、重绘波形并计算参数。关键是要定义一套稳定的帧格式,不然 PC 端解析容易错位。

工程里我用的帧结构大概是这样的:

typedef struct __attribute__((packed)) { uint8_t head[2]; /* 0xAA 0x55 帧头 */ uint8_t channel; /* 通道号 0/1 */ uint8_t reserved; /* 保留字节 */ uint16_t point_count; /* 本帧点数 1024 */ uint16_t vref_mv; /* 参考电压 mV */ uint16_t crc16; /* CRC16 校验值 */ } sample_frame_header_t;

帧头之后直接跟point_count个 uint16_t 原始采样值。PC 端先找帧头,再读长度,然后对整帧做 CRC 校验,确认无误后才开始画图。CRC16 计算我用了常见的多项式 0x8005,初始值 0xFFFF,代码直接从网上找的查表法实现就可以,不需要自己发明校验算法。

串口波特率视数据量而定。115200 波特率下,一帧 1024 点大约 2KB,发送耗时接近 180ms,如果边采样边发送,到第二帧还没发完,数据就会堆在环形缓冲里被新数据覆盖。我一般把波特率提到 460800,一帧发送耗时缩短到 45ms 左右,配合双缓冲,可以做到采样一帧发一帧,不再阻塞采样链路。

4.2 PC 端 Python 脚本快速验证

配套的 PC 解析脚本我用的是 pyserial 加 matplotlib,几十行就能把串口收到的帧重绘成波形。

import serial import struct import matplotlib.pyplot as plt ser = serial.Serial('COM3', 460800, timeout=2) ser.reset_input_buffer() while True: data = ser.read(2) if data != b'\xaa\x55': continue header = ser.read(6) if len(header) < 6: break ch, reserved, count, vref, crc = struct.unpack('<BBHHH', header) payload = ser.read(count * 2) if len(payload) < count * 2: break adc_vals = struct.unpack('<%dH' % count, payload) volt = [v * (vref / 4095.0) for v in adc_vals] time_us = [i * (1.0 / 512000) for i in range(count)] plt.clf() plt.plot(time_us, volt) plt.xlabel('time (us)') plt.ylabel('voltage (V)') plt.grid(True) plt.pause(0.1)

脚本里每个字段都用小端格式解包,vref表示当前 ADC 参考电压,换算成实际电压值时用v * (vref / 4095.0),而不是直接把采样值当毫伏看。如果拿到的电压值和万用表实测差很多,先检查这个换算系数。

关键点1.0 / 512000是采样间隔。F407 的 ADC 最大采样率约 1Msps,但我实际配置的采样点时间间隔由定时器决定,脚本里这个数字要跟单片机里定时器的实际配置对应。最省事的办法是把采样间隔也放进帧头,比如第 4.1 节的帧结构里再增加一个sample_interval_ns字段,这样 PC 端不需要知道单片机内部时基档位,直接读帧里带的时间戳参数,不容易对错。

4.3 串口发送的缓冲处理:不要让发送阻塞采样 DMA

发送原始数据时我一般用 DMA 加环形缓冲区,串口发送完毕回调只是把发送缓冲区索引往前推,不再占用 CPU 时间。如果片内 RAM 够用,可以直接分配两个 4KB 的发送缓冲区,当前帧拷进缓冲区后立即启动 DMA 发送,下一帧采完时上一帧大概率已经发完。

5. 避坑记录:ADC 校准、中断优先级、屏幕撕裂和按键阻塞

5.1 同一个信号两次读数差了快 50 个 LSB

现象:给 ADC 输入一个稳定的 1.2V 电压,第一帧采样均值 1020,过几分钟再读变成 1068,变化量将近 3%。

原因:F407 的 ADC 内部参考电压会随温度、电源电压波动,上电后如果不去读校准值或做自校准,增益误差会偏大。另一个因素是 VREF 引脚如果直接用 3.3V 供电而没有做滤波,电源纹波会直接耦合进 ADC 参考。

解决:每次上电执行一次 ADC 自校准,另外开机后做一次“零点校准”——把 ADC 输入接地,读 64 次取平均,把这个值写入内部 Flash 用户区作为零点偏移。之后每次换算电压前先减去零点值再乘增益系数。

uint32_t flash_read_user(void) { return *(volatile uint32_t *)0x08080000; /* 内部Flash用户区 */ } void adc_zero_calibrate(void) { uint32_t sum = 0; for (int i = 0; i < 64; i++) { HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 10); sum += HAL_ADC_GetValue(&hadc1); } sum /= 64; /* 擦除扇区后写入零点值 */ }

注意0x08080000这个地址要避开代码区和实际数据占用区,擦写内部 Flash 前务必确保没有在跑关键中断,否则擦写期间代码执行会卡死。

5.2 波形偶尔整段错位,时间轴跳了一格

现象:信号频率不变,波形大部分时间正常,但每隔几秒就会出现一次整个波形左移或右移一格,看起来像触发点漂移。

原因:DMA 中断和串口中断抢优先级。串口接收中断优先级比 DMA 中断高,串口数据到达时会延迟 DMA 半满中断的响应,导致主循环认为“当前帧已就绪”的时机比实际晚了,画出来触发位置就偏了。

解决:把 DMA 中断优先级提到最高,串口中断降一级。DMA 涉及数据搬运,中断延迟会直接影响帧完整性;串口收发偶尔延迟几百微秒不影响功能,优先级排后面反而更安全。

5.3 低频信号波形滚动时跳动明显

现象:10ms/div 档位下观察一个 100Hz 方波,波形滚动刷新时不是平滑移动,而是隔一段时间跳一大截。

原因:采样完一帧才刷新一次,帧间隔是固定的 100ms,人眼会把这种“整帧替换”感知成跳动。本质上这是示波器刷新率不够,不是程序 bug。

解决:低时基档位下减少单帧采样点数。比如 1024 点改成 256 点,帧间隔从 100ms 降到 25ms,刷新率提升 4 倍,肉眼滚动的平滑度明显变好。代价是触发点附近的波形细节减少,但低频信号本来就不需要太多点数,这是个合理的取舍。

5.4 按键消抖把采样主循环卡住了

现象:按下“单次触发”按键后,波形冻结了,但再按“连续触发”没反应,只能复位开发板。

原因:按键消抖用了HAL_Delay,按下瞬间主循环卡了 80ms,这期间采样 DMA 还在跑,但主循环没机会处理帧就绪标志,两个标志位都被置位,等到按键处理完,早期标志被覆盖,系统误以为没有新帧。

解决:按键消抖改用状态机,不用阻塞延时。状态机里记录上一次按键电平和时间戳,每 5ms 检查一次,连续 4 次读到稳定电平就算一次有效按键,期间主循环仍然可以读采样标志。这个改动看起来小,但对我调单次触发功能的体验影响非常大。

6. 进阶玩法:用 TIM4 输入捕获把频率测准

示波器显示波形之外,另一个高频需求是测量信号频率。用 LCD 像素去数周期不可靠,网格线对应的只是大概范围。我在工程里加了 TIM4 的输入捕获功能,用 PA3 输入待测信号,TIM4 工作在上升沿捕获模式。

static void tim4_input_capture_init(void) { TIM_IC_InitTypeDef sICConfig = {0}; __HAL_RCC_TIM4_CLK_ENABLE(); htim4.Instance = TIM4; htim4.Init.Prescaler = 83; /* 1MHz */ htim4.Init.Period = 0xFFFF; htim4.Init.CounterMode = TIM_COUNTERMODE_UP; HAL_TIM_IC_Init(&htim4); sICConfig.ICPolarity = TIM_INPUTCHANNELPOLARITY_RISING; sICConfig.ICSelection = TIM_ICSELECTION_DIRECTTI; sICConfig.ICPrescaler = TIM_ICPSC_DIV1; sICConfig.ICFilter = 0x05; HAL_TIM_IC_ConfigChannel(&htim4, &sICConfig, TIM_CHANNEL_1); HAL_TIM_IC_Start_IT(&htim4, TIM_CHANNEL_1); }

中断回调里记录两次上升沿之间的计数值,由 1MHz 计数时钟换算成周期,再取倒数得到频率。实测几百 Hz 到几十 kHz 的方波精度能到 1% 以内,前提是待测信号边沿干净、没有强烈抖动。ICFilter设成 0x05 是做了一次数字滤波,滤除窄毛刺。这个特性很实用,比如测 DC-DC 开关频率,探头靠近功率电感时会感应到振铃,不开滤波测出来的频率五花八门,开了之后稳定在预设开关频率附近。

串口导出的频率值和我用台式示波器测的结果做过对比,低频段误差小于 0.5%,主要误差来自信号源的占空比和边沿抖动,不是输入捕获本身的问题。需要测更高频率时把预分频调到 0,计数时钟变成 84MHz,频率上限能到几百 kHz,但要注意输入引脚要加简单限流,别直接怼过高电压。

从那以后我每次调采样链路,都会强制走一遍:先用内部 1kHz 方波自检,确认时基档位和触发位置符合预期,再拿一个已知频率的外部信号做输入捕获,最后用串口导出的数据对一次幅值。三道检查全过,这个简易示波器才敢拿去帮别人看电路。希望帮到你。

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

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

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

立即咨询