恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
STM32F407简易示波器:ADC+DMA+定时器实现波形采集与显示
首页
资讯中心
/
STM32F407简易示波器:ADC+DMA+定时器实现波形采集与显示
STM32F407简易示波器:ADC+DMA+定时器实现波形采集与显示
发布时间:2026/10/14 19:54:28
简介面向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 时全屏 2ms1024 个采样点相邻点间隔约 1.95μs对应 ARR1实际操作中这个值偏小所以我最低档从 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 位等效除 4096ADC 最大值 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约 250kHz500μs/div5ms约 4.88μs约 100kHz1ms/div10ms约 9.77μs约 50kHz5ms/div50ms约 48.8μs约 10kHz10ms/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, timeout2) 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 方波自检确认时基档位和触发位置符合预期再拿一个已知频率的外部信号做输入捕获最后用串口导出的数据对一次幅值。三道检查全过这个简易示波器才敢拿去帮别人看电路。希望帮到你。本文还有配套的精品资源点击获取