资讯动态

STM32F407简易示波器制作教程:ADC采样与DMA波形显示实践

发布时间:2026/10/14 1:21:00 来源:尧图企业网站定制
简介基于STM32F407的简易示波器完整源码适合嵌入式初学者或关注ADC采集、LCD显示的开发者可帮助理解从信号调理到波形绘制的完整链路。项目用C语言编写Visual Studio Code中编辑、Keil5.3编译架构为主循环加采集中断加定时界面更新。实现一路ADC定时采集通过继电器控制放大电路增益独立按键调整采样周期与电压范围在3.2寸TFTLCD上绘制采集曲线并计算周期、频率、峰峰值等参数。资源共171个文件以73个C源码、65个头文件为主体辅以少量PNG图片、工程配置与Hex文件压缩包仅1.18MB可直接导入Keil工程研读。已有3346人学习下载覆盖ADC采集、LCD波形显示、继电器增益控制与按键交互逻辑适合作STM32F407入门与嵌入式综合练习参考。1. 简易示波器怎么从零搭为什么选STM32F407而不是普通单片机调试一块电机驱动板时万用表只能告诉你“有没有电压”却看不出PWM边沿的振铃和通信线上的毛刺。手头如果有一台基于STM32F407的简易示波器——哪怕分辨率只有12位、带宽只有几百kHz——也能把这些波形看清楚。F407有独立的ADC、DMA通道和168MHz主频配合源代码里那套“定时器触发、DMA搬运、波形绘制”的框架就是一台能连续采样的示波器和用声卡看音频的玩具玩法不是一个量级。这篇笔记写给想做低频模拟信号调试工具的嵌入式开发者以及想把F4外设串起来练手的人你会知道硬件怎么搭、采样参数怎么设、跑起来会遇到哪些坑。2. 模拟前端与硬件链路探针到ADC之间的电路设计2.1 阻抗匹配与输入保护探头不是一根导线很多第一次做简易示波器的人直接把被测信号用一根杜邦线连到STM32F407的ADC引脚上测出来的波形要么幅度不对要么在某个频率上突然塌下去。这不是ADC的锅而是信号源阻抗和ADC输入结构不匹配。F407的ADC是开关电容结构采样瞬间会有电荷注入等效输入阻抗并不高尤其在采样周期很短的时候。如果信号源阻抗达到几十kΩ采样保持电容还没来得及充满转换结果就会偏小高阻抗信号源配短采样周期时偏差更明显。常见做法是在ADC前面加一级运放跟随器用轨到轨运放把信号源的阻抗隔离掉让ADC看到一个低阻抗源。输入保护也不能省。被测板子上的毛刺、反电动势或者误接高压都可能直接打坏ADC引脚。最简保护电路是两个二极管钳位加一个串联电阻电阻串在输入端限流二极管把电压钳在GND和VDD之间。我一般用双二极管把信号钳位到3.3V供电轨再串一个1kΩ电阻进ADC牺牲一点带宽换安全。这个成本只有几毛钱却能避免“采集卡突然烧了”这种血泪事故。2.2 信号调理分压、偏置与增益切换F407的ADC只能测0到VREF通常等于3.3V之间的电压所以被测信号范围必须映射到这个区间。测0~3.3V的数字信号最简单想测正负电压或者超过3.3V的模拟信号就要做分压和偏置。常见做法是电阻分压加隔直电容。比如要测-5V~5V的信号先用两只等值电阻分压把信号缩到一半再叠加一个1.65V直流偏置把-5V映射到约-0.85V这里要算清楚。更稳妥的是用运放搭加法器Vout Vin * Rf/Rin Vref通过选电阻同时完成缩放和偏置。增益切换是另一个容易被忽略的点。直接测3.3V信号和测10mV的小信号ADC满量程都是4096但10mV信号在12位下只占大约12个LSB波形几乎是一条噪声带。简易方案用固定增益不够灵活我一般会在模拟开关加两挡运放增益之间切换比如1倍和10倍用两个GPIO控制。不用高档仪表放大器的原因是F407自带12位ADC前端做太高的增益只会把噪声一起放大够用就行。2.3 主控外设规划ADC、DMA、TIM、FSMC各负责什么STM32F407做示波器的硬件基础在于外设齐全3个12位ADC、2个DMA控制器、十几个定时器、FSMC并行总线接口甚至还有一个DAC和一个DMA2D图形加速器。单片机上常见的8位MCU要么ADC采样率不够要么没有DMA导致CPU被采样中断占满要么LCD接口慢得波形刷新像幻灯片。F407把这些短板都补上了。整个系统里每个外设的职责可以这样分外设职责关键点ADC1波形采样12位外部触发模式采样周期按需配置DMA2 Stream0搬运采样数据到内存循环模式半传输/全传输中断配合双缓冲TIM3产生精确的采样触发脉冲通过TRGO事件触发ADC采样率由预分频和自动重装值决定FSMC驱动并行接口LCD像操作SRAM一样写LCD刷新率远高于GPIO模拟DAC1自检信号源输出方波或正弦波接回ADC校验整条链路USART1与上位机通信把原始采样点传回电脑便于分析和存储引脚规划时要注意冲突。ADC1的通道1在PA1这是比较干净的一个引脚DAC1在PA4FSMC的数据总线占用PD14~PD15、PE7~PE15等USART1是PA9/PA10。先画一张引脚分配表再画PCB能少走很多弯路。这块我走过一次“回头路”先选了LCD的FSMC引脚结果把ADC引脚挤到和DAC复用最后只能改板。3. 定时器触发ADC采样与DMA搬运核心代码一步步写3.1 采样不依赖CPU触发源为什么选定时器两种常见采样方案摆在面前一是ADC连续转换加DMA让ADC自己不停地采二是定时器周期触发ADC每个上升沿采一次。连续转换模式看似简单但采样间隔完全由ADC转换时间决定改分辨率或采样周期就会改变等效采样率而且ADC转换结束事件触发DMA搬运的节奏并不均匀。示波器最忌讳的就是采样间隔不均匀——波形会变形FFT频谱会出现杂散。定时器触发则完全不同TIM3的计数时钟是固定的预分频和自动重装值一设触发脉冲间隔就精确稳定所以我把TIM3作为采样时钟源。F407的定时器触发是通过TRGO主模式输出实现的把TIM3的更新事件映射到TRGO再把ADC1的外部触发源选成TIM3_TRGO。ADC每次收到上升沿就启动一次转换转换完由DMA把结果搬走CPU全程不参与采样过程。3.2 ADC初始化外部触发与DMA请求配置先初始化ADC1的引脚和基本参数。以PA1作为采样输入为例GPIO_InitTypeDef gpio {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_ADC1_CLK_ENABLE(); gpio.Pin GPIO_PIN_1; gpio.Mode GPIO_MODE_ANALOG; HAL_GPIO_Init(GPIOA, gpio); ADC_HandleTypeDef hadc1 {0}; hadc1.Instance ADC1; hadc1.Init.ClockPrescaler ADC_CLOCK_SYNC_PCLK_DIV4; // ADCCLK 84MHz / 4 21MHz hadc1.Init.Resolution ADC_RESOLUTION_12B; hadc1.Init.ScanConvMode DISABLE; // 单通道不扫描 hadc1.Init.ContinuousConvMode DISABLE; // 不用连续转换等定时器触发 hadc1.Init.DiscontinuousConvMode DISABLE; hadc1.Init.ExternalTrigConvEdge ADC_EXTERNALTRIGCONVEDGE_RISING; hadc1.Init.ExternalTrigConv ADC_EXTERNALTRIGCONV_T3_TRGO; HAL_ADC_Init(hadc1);这段配置的核心是最后两行ExternalTrigConvEdge指明上升沿触发ExternalTrigConv选定触发源为TIM3的TRGO。ClockPrescaler选4分频得到21MHz的ADC时钟这是F407比较稳妥的配置。ADC时钟频率直接决定转换速度。对于12位分辨率一次转换最少需要“采样周期12”个ADCCLK周期采样周期最小为3。在21MHz下最大单通道采样率是21MHz/(312)1.4Msps。如果你看到网上有人开到2Msps以上还没问题多半用的是双ADC交叉采样或者降低了分辨率。3.3 DMA循环模式与半缓冲切换等效双缓冲ADC转换结果需要DMA搬进内存否则每采一个点都要进一次中断168MHz的CPU也会被频繁打断。DMA配置的关键是循环模式加半传输中断DMA_HandleTypeDef hdma_adc {0}; __HAL_RCC_DMA2_CLK_ENABLE(); hdma_adc.Instance DMA2_Stream0; // ADC1的DMA请求映射到DMA2 Stream0 hdma_adc.Init.Channel DMA_CHANNEL_0; hdma_adc.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_adc.Init.PeriphInc DMA_PINC_DISABLE; hdma_adc.Init.MemInc DMA_MINC_ENABLE; hdma_adc.Init.PeriphDataAlignment DMA_PDATAALIGN_HALFWORD; // ADC12位用16位搬运 hdma_adc.Init.MemDataAlignment DMA_MDATAALIGN_HALFWORD; hdma_adc.Init.Mode DMA_CIRCULAR; // 循环模式DMA永远不停 hdma_adc.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_adc); __HAL_LINKDMA(hadc1, DMA_Handle, hdma_adc);循环模式意味着DMA把一块内存填满后自动回到起始地址继续写。传统做法是在DMA传输完成中断里把整块数据复制出去处理但这样有半个缓冲周期的时间在等数据浪费了采集时间。更高效的做法是直接利用“半传输完成中断”和“传输完成中断”当DMA写满前半块时后半块还在继续采主逻辑处理前半块。当写满整块时前半块又开始被覆盖此时后半块的数据是完整的。这种交替使用方式我一般叫它软件双缓冲不需要开启F407的硬件双缓冲模式也能做到不丢点。注意两个回调函数里的处理时间必须小于半个缓冲区的采集时间否则缓冲区会被覆盖出问题。3.4 定时器参数采样率怎么算才准TIM3的时钟在F407上来自APB1定时器时钟系统主频168MHz时是84MHz。采样率就是TIM3的更新频率TIM_HandleTypeDef htim3 {0}; __HAL_RCC_TIM3_CLK_ENABLE(); htim3.Instance TIM3; htim3.Init.Prescaler 83; // 84MHz / 84 1MHz计数频率 htim3.Init.Period 999; // 计1000个数1kHz触发一次 htim3.Init.CounterMode TIM_COUNTERMODE_UP; HAL_TIM_Base_Init(htim3); TIM_MasterConfigTypeDef masterCfg {0}; masterCfg.MasterOutputTrigger TIM_TRGO_UPDATE; // 更新事件作为TRGO输出 masterCfg.MasterSlaveMode TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(htim3, masterCfg);采样率公式是采样率 TIM3时钟 / (PSC1) / (ARR1)。所以上面这个配置就是 84MHz / 84 / 1000 1kHz适合观察低频信号。常用挡位可以直接套这组参数目标采样率PrescalerPeriod说明1kHz83999适合看传感器慢信号100kHz839适合看PWM和音频信号1MHz830接近单ADC上限注意ADC转换时间把Period设0不代表禁止更新ARR0时计数器每计一个数就溢出TIM3计数频率是多少触发频率就是多少。最终采样率受到ADC转换时间的硬限制1MHz以上建议把分辨率降到8位或者安排ADC1和ADC2交叉采样。3.5 启动采样与源码结构启动顺序有讲究。先启动ADC的DMA传输再启动TIM3否则定时器先转起来时DMA还没准备好第一批触发事件会被丢掉。正确的顺序uint16_t adcBuf[4096]; HAL_ADC_Start_DMA(hadc1, (uint32_t *)adcBuf, 4096); HAL_TIM_Base_Start(htim3);然后DMA的循环搬运就自己跑了。在中断回调里只需要做缓冲区的“交接”动作void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { uint32_t *p (uint32_t *)hadc-DMA_Handle-Instance-M0AR; HAL_GPIO_WritePin(DRAM_CS_GPIO_Port, DRAM_CS_Pin, GPIO_PIN_SET); (void)p; } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { uint32_t *p (uint32_t *)hadc-DMA_Handle-Instance-M0AR; HAL_GPIO_WritePin(DRAM_CS_GPIO_Port, DRAM_CS_Pin, GPIO_PIN_RESET); (void)p; }实际工程里我不会在中断里做处理而是置一个标志位主循环看到标志后在前后半缓冲区之间切换显示指针。源码组织上我习惯分三层Driver存放ADC、TIM、DMA、LCD、串口的驱动App存放触发检测、波形绘制和帧协议Core放主循环和中断入口。分清楚之后想换LCD或者改采样率都只动一个文件不用翻整个工程。4. 波形显示与触发同步把数字点变成屏幕上稳定的波形4.1 电平触发软件算法为什么波形会横向乱跑没有触发机制的示波器屏幕上看到的波形是不断左右滚动的因为每次缓冲区起始点不固定波形画出来的位置自然就在漂。要稳住波形必须让每次显示的起点对准信号上同一类位置。最常见的做法是上升沿触发在缓冲区内找到第一个从低于触发电平跳变到高于触发电平的点然后从这个点开始向后取固定长度的数据画波形。这样无论数据从哪儿开始波形在屏幕上的位置是确定的。uint32_t Scope_FindTriggerIndex(const uint16_t *buf, uint32_t len, uint16_t level, uint32_t startIdx) { for (uint32_t i startIdx 1; i len; i) { if (buf[i - 1] level buf[i] level) { return i; } } return 0; // 没找到返回0表示保持上一帧 }这个算法看起来简单但要注意触发电平必须落在信号幅度范围之内。电平设成0或者4096都永远找不到上升沿表现为波形偶尔冻结不刷新。另一个问题是噪声信号上叠加噪声时会在触发电平附近反复穿越触发点抖动波形看起来有毛边。噪声大时先做一点软件滤波或者把触发电平抬高一些就不会那么敏感。4.2 LCD绘制波形坐标映射和刷新效率找到触发点后就可以把采样数据映射到LCD坐标。假设屏幕宽240、高320ADC是12位#define LCD_WIDTH 240 #define LCD_HEIGHT 320 #define ADC_FULL 4096 void Scope_DrawWaveform(const uint16_t *data, uint32_t count, uint32_t trigIdx) { uint32_t xPrev 0, yPrev 0; LCD_ClearScreen(LCD_COLOR_BLACK); for (uint32_t i 0; i count; i) { uint32_t x (LCD_WIDTH - 1) * i / (count - 1); uint32_t y (LCD_HEIGHT - 1) * (data[(trigIdx i) % count]) / ADC_FULL; y (LCD_HEIGHT - 1) - y; // LCD的Y轴向下波形要反一下 if (i 0) { LCD_DrawPixel(x, y, LCD_COLOR_GREEN); } else { LCD_DrawLine(xPrev, yPrev, x, y, LCD_COLOR_GREEN); } xPrev x; yPrev y; } }代码里data[(trigIdx i) % count]是环形取数的写法从触发点开始环绕整个缓冲区避免拷贝数据。这个细节能省掉每次触发都要memcpy的开销。绘制效率是简易示波器最容易卡住的地方。逐点写屏幕会被LCD总线速度限制如果LCD画线函数是逐像素设置坐标再写数据240个点也要几毫秒。我一般会做两件事第一先用LCD的区域填充命令清背景而不是整屏整屏擦第二把画线函数改成尽量按行扫描减少坐标跳变很多LCD控制器连续写同一行比跳变写快很多。F407有FSMC并行总线配合DMA2D还能进一步提速但DMA2D主要适合整块区域填充画波形这种稀疏图形还是靠优化画线逻辑最实际。4.3 串口上位机方案把采样数据搬到电脑屏幕小的LCD看波形细节有限存储深度也就几千点。很多时候我们需要把ADC原始数据传回电脑用软件画波形、算频率、存文件。这时要设计一个简单的串口帧协议。我一般用这样的帧格式帧头固定为0xAA 0x55接着是2字节数据长度然后是原始采样数据最后加1字节累加和校验。单片机端发送逻辑不复杂uint8_t uartBuf[2 2 4096 * 2 1]; void Scope_SendFrame(const uint16_t *data, uint32_t len) { uint32_t idx 0; uartBuf[idx] 0xAA; uartBuf[idx] 0x55; uartBuf[idx] (len 8) 0xFF; uartBuf[idx] len 0xFF; uint8_t sum 0; for (uint32_t i 0; i len; i) { uartBuf[idx] data[i] 0xFF; uartBuf[idx] (data[i] 8) 0xFF; sum data[i] 0xFF; sum (data[i] 8) 0xFF; } uartBuf[idx] sum; HAL_UART_Transmit(huart1, uartBuf, idx, 100); }上位机端我习惯用Python快速验证几行代码就能画出来import serial, struct, matplotlib.pyplot as plt ser serial.Serial(COM10, 921600, timeout1) data ser.read(4) assert data[:2] b\xaa\x55 length struct.unpack(H, data[2:4])[0] raw ser.read(length * 2 1) values struct.unpack( H * length, raw[:length * 2]) plt.plot(values) plt.show()串口波特率决定了数据吞吐上限。921600bps每秒约100KB4096点一帧数据约8KB刷新率撑死十几帧基本够看波形形态。要更高刷新率就降低传输长度或者提高波特率F407的USART通常能稳定跑1.5Mbps前提是上位机和线材都靠谱。5. 简易示波器最常见的5个坑从采样撕裂到电压偏差的排查手册5.1 DMA半缓冲里做数据处理波形偶尔撕裂现象采集到的正弦波在屏幕中间某处突然水平平移了一段左右两半波形接不上。最初我以为是LCD绘制问题后来发现数据本身就不连续。原因虽然用了半传输中断但在回调里做的是数据拷贝拷贝期间DMA仍在往后半段写。如果拷贝时间超过了半段缓冲的采集时间DMA完成一轮循环回到前半段时内存里已经是新旧数据混在一起了。半缓冲时间在高采样率下会非常短1MHz采样、缓冲4096点半段只有2ms如果回调里还做了滤波、画图很容易超时。解决把“拷贝”改成“指针切换”。维护两个显示缓冲区A和BDMA写A时显示BDMA写B时显示A。DMA缓冲区的地址随半传输和全传输中断切换主循环只拿当前可用的缓冲区指针。这样中断回调里只置一个标志处理数据的大头全在主循环做没有拷贝也就没有竞争条件。5.2 采样率设到2Msps以上就丢点现象把TIM3触发频率调到2MHz屏幕上波形周期完全不对频率读数也乱跳。用串口把原始数据发到电脑看发现数据点之间的时间间隔不均匀有的地方点特别稀。原因F407单ADC在12位模式下ADCCLK为21MHz时最大转换率只有1.4Msps。触发频率超过转换能力后ADC还没来得及完成上一次转换下一次触发就到了规则通道一次只能处理一个转换请求后面的触发就被丢弃。这是硬件上限不是代码能调好的。解决先算上限再选方案。如果2Msps只是理想值实际可以把采样周期调成3个周期ADCCLK保持21MHz得到1.4Msps或者牺牲分辨率8位模式转换周期变成“38”个周期上限提高到约1.9Msps。真正的2Msps以上要用双ADC交叉采样ADC1和ADC2各采各的DMA把两路结果合并等效采样率翻倍。交叉采样的代码复杂度上升不少我一般只在确实需要时才上。5.3 测电压偏大而且数值随USB供电变化现象测量一颗标称2.7V的锂电池示波器显示2.88V换一个USB口供电显示又变成2.81V。用万用表量F407的3.3V供电发现是3.38V而不是3.30V。原因F407的ADC参考电压VREF在绝大多数开发板上直接连着VDD而VDD来自板载LDO或DC-DC精度和负载调整率都有限。ADC的满量程4096对应的是实际VREF电压不是理论3.3V。供电电压偏高ADC读数自然偏大。解决用芯片内部的基准电压VREFINT做校准。F407内部有一个约1.21V的参考电压它会随供电一起飘但飘的规律和ADC通道相同。先测量VREFINT通道的ADC值反算出实际参考电压float vrefint_value; // 在初始化时读ADC16通道获得 float Scope_GetActualVref(void) { // VREFINT典型值1.21V实际值因批次而异 return 1.21f * 4096.0f / vrefint_value; }以后每次测电压就用这个实际参考电压替代3.3V做换算。这样的校准不能替代精密基准源但能把误差从百分之几压到百分之一以内。要更准就外接一个精密基准芯片把VREF接到独立基准上整个ADC的地也单独处理。5.4 按一次按键后波形冻结几十毫秒现象菜单按键每按一下LCD上的波形就停一下像卡顿一样。在波形刷新过程中按按键卡顿更明显。原因按键处理写在主循环里还用了while(!HAL_GPIO_ReadPin(...))这种等待释放的死循环。按下到释放的几百毫秒里主循环始终卡在按键扫描上LCD刷新和缓冲区切换全停了。虽然DMA还在采样但显示逻辑没机会运行波形看起来就是冻结的。解决按键扫描改成状态机检测到按下立即返回不等待释放。或者利用定时器中断每5ms扫一次按键主循环只读取按键事件。不要把任何阻塞操作放进采集刷新路径上。我自己的习惯是波形刷新永远放在主循环最靠前的位置按键、串口、菜单全部放到主循环后面这样即便某个任务出问题波形显示优先级最高。5.5 显示的频率比真实值低几十Hz现象信号发生器输出1.000kHz正弦波示波器显示980Hz。低频时差得不多频率越高误差越大。原因频率测量用的是计数法统计一屏波形里有几个完整周期再用采样率除以周期点数。如果缓冲区只有4096点采样率1kHz能捕获的周期数很少计数误差一个点就会让频率显示差了很多。触发点的离散化也是一个采样周期正负误差叠加后读数就偏了。解决改用更长的测量窗口。在DMA缓冲区中找相邻两个上升触发点数出两者之间的采样点数N频率就是采样率除以N。采样率越高、N越大分辨率越高。测量时建议让一屏至少包含10个周期这样误差能摊薄到可接受范围。还有一种误差来自晶振本身开发板上的8MHz或者25MHz晶振精度一般在几十ppm对1kHz信号影响很小但如果用内部RC振荡器跑系统时钟误差可能达到1%那就得考虑外接晶振了。6. 让示波器更好用校准、自检与FFT扩展的落地经验硬件和驱动跑稳之后可以给这个简易示波器补三个实用的能力。第一个是自检信号源。F407自带DAC用定时器中断按查表方式更新DAC输出就能生成已知幅度和频率的方波或正弦波。把DAC输出用一根跳线接回ADC输入整个采样链路的正确性一目了然幅度对不对、触发稳不稳、采样率准不准全都能在几千点数据里验证出来。这个习惯帮我绕开了不少“玄学”问题。第二个是校准。测量误差来源不只是VREFINT校准能解决的分压电阻的精度、运放的失调电压、LCD上显示的坐标舍入误差都会叠加。我会记录一组已知电压和ADC实测值做两点线性校正把offset和gain两个系数存进Flash。这样每次开机自动校准省得每次换板子都要拿万用表对一遍。第三个是FFT频谱显示。F407带FPU和DSP指令算4096点复数FFT只需要几毫秒完全能在实时性要求不高的场景里边采边算。用CMSIS-DSP库的arm_cfft_f32先对采样数据做FFT再用arm_cmplx_mag_f32取模就能画出幅度谱。配合前面提到的触发功能可以在固定信号下观察频谱泄漏和噪声底对小信号调试很有帮助。我自己的习惯是每次改完采样参数先让DAC输出一个1kHz正弦波做自检确认幅度、触发和频率都正常后再去测目标板。这个小动作看起来多花两分钟实际上把采样时钟配置错误、DMA缓冲越界这类问题挡在了测量现场之外。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑