简介:基于STM32的正点原子示波器项目压缩包,定位为嵌入式信号采集与波形显示学习资源,适合正在学习STM32微控制器、准备电子设计竞赛或需要快速搭建调试工具的开发者。压缩包共254个文件,大小仅6.88MB,其中主要文件类型包括C语言和H头文件源码、Keil工程配置文件、编译生成的Hex与Axf固件、链接Map文件及一批调试备份文件,源码与固件同时提供,方便阅读底层驱动逻辑或直接上机验证。目前已有1278人学习浏览。工程围绕ADC多通道采样展开,覆盖采样率与精度配置、DMA缓存传输、LCD波形实时绘制等关键环节;保留的调试备份文件还能帮助对比排查常见配置问题。通过阅读源码和实际运行,读者可以深入理解示波器中的信号调理、触发显示、波形缩放等基础设计思路,并在此基础上扩展FFT频谱、频率周期测量等高级功能,是STM32进阶实践的一份实用参考。
1. 用 STM32 复刻正点原子示波器:A1 采集链路才是核心
很多人在正点原子例程里看到“示波器”三个字,下意识以为是上位机绘图工程,真正把代码下到板子上才发现,A1 是 ADC 的模拟输入通道,整个工程的价值不在屏幕上的正弦波,而在“采集—缓存—触发—显示”这条链路的组织方式。示波器类项目最容易踩的坑也在这里:DMA 搬运速度够快但触发时机不对,屏幕刷新率上去了波形却在抖动,FFT 窗口没对齐导致频率刻度全是噪声。这篇文章以 STM32 平台为基础,把 A1 通道采集信号、显示波形的完整方案拆开讲清楚。
先给结论:做这种项目,不要一上来就调 LCD 驱动或者纠结 UI 控件,先把“ADC 采样率 vs 显示刷新率”这对矛盾解决掉。示波器不是越快越好,而是每一帧都要让用户看到“稳定的、可触发的、带时间基准的”波形,这比单纯把采样率拉到最高难得多。下面的内容围绕存储型示波器的最小系统展开,覆盖从模拟前端到波形显示的完整闭环。
2. 显示波形前先想清楚:A1 通道的数据要经过哪几级处理
2.1 信号链路的五级拆分
一个实用的 STM32 示波器,信号从探头到屏幕,至少要经过五级处理:模拟前端衰减与阻抗匹配、ADC 采样、DMA 搬运与缓存管理、触发与时基控制、显示渲染。很多人把注意力放在最后一级,前面四级全凭感觉配置,结果就是波形“能出来但没法看”。
模拟前端决定了能测什么量级的信号。正点原子探索者或战舰板的 A1 通道通常直连 STM32 的 PA1,板上没有复杂的衰减网络,所以输入范围天然被限制在 0~3.3V。如果想测负电压或者超过 3.3V 的信号,必须在外部加电阻分压和偏置电路。不过在复刻阶段,直接测板载 DAC 输出或者 PWM 滤波后的信号就够用了。ADC 部分选用 STM32F103 的 12 位 ADC,采样率最高 1Msps 左右,这个指标决定了它只能算“低速示波器”,测音频和直流信号没问题,测高频开关波形就会失真。
DMA 搬运在这条链路里承担的角色容易被低估。如果不用 DMA,每采一个点就要进一次中断,在 1Msps 采样率下 CPU 根本腾不出手做显示和触发判断。用 DMA 循环模式加双缓冲,采集和显示才能真正并行起来。触发逻辑放在 DMA 回调里做简单比较,复杂的边沿触发和预触发则放在缓存管理阶段处理。显示渲染是最后一步,把 ADC 原始值映射到 LCD 坐标,同时叠加网格、电压刻度和时间刻度。
2.2 显示波形为什么需要“预触发”而不是“边采边画”
边采边画是第一版最容易犯的错误:ADC 中断里拿到数据,立刻把点画到屏幕上,看起来响应很快,但波形永远在屏幕左边往外冒,根本停不住。示波器应该像相机一样,有一个“快门”概念——触发时刻决定了波形从哪个位置开始绘制,触发时刻之前的数据也要保留,这就是预触发。
预触发实现的核心是环形缓冲区。设一个深度为 N 的数组,DMA 不断往里面写,同时记录当前写指针。触发条件满足时,把写指针往前回退 M 个点作为显示起始位置,然后连续读 N 个点作为一帧波形。这样看到的就是触发点居中、前后都有数据的完整波形。正点原子例程里也保留了类似的思路:ADC 采集长度固定,触发后一次性绘制整帧,而不是逐点刷新。
/* 环形缓冲区参数 */ #define BUF_SIZE 4096 #define PRE_TRIG 512 uint16_t adc_buf[BUF_SIZE]; volatile uint32_t write_idx = 0; volatile uint8_t trig_flag = 0; /* DMA 半满中断里更新写指针 */ void DMA1_Channel1_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_HT1)) { write_idx = BUF_SIZE / 2; DMA_ClearITPendingBits(DMA1_IT_HT1); } if (DMA_GetITStatus(DMA1_IT_TC1)) { write_idx = BUF_SIZE; DMA_ClearITPendingBits(DMA1_IT_TC1); } }这段代码里 DMA 被配置成循环模式,半传输中断和传输完成中断分别更新写指针。显示任务要读数据时,先关中断拿一份 write_idx 快照,再决定从哪个偏移开始绘制。PRE_TRIG 这里设 512,意思是触发点大约出现在一帧数据的 1/8 处,这样能看到比较长的触发前波形。参数可以按需调整,但注意 PRE_TRIG 必须小于 BUF_SIZE,否则取模运算会出错。使用环形缓冲的好处是写指针永远递增,不会因为 DMA 和 CPU 的速度差异产生数据覆盖错乱。需要留意的是,在 DMA 中断里不应做任何绘图操作,否则中断服务时间过长会拖垮采样节奏。
2.3 采样率与时基的换算关系
显示波形之前必须先确定横轴每一格代表多少毫秒。时基(Time/Div)和采样率是一组强关联的变量:屏幕上交给一个波形帧的采样点数是固定的,时基设得越大,意味着这一帧覆盖的时间越长,ADC 实际采样率就可以越低;反过来,时基越小,采样率必须拉满才能保证波形细节完整。
float timebase = 10; /* 单位 ms/div */ int divs = 10; /* 屏幕横向 10 格 */ int points = 4096; float frame_time_ms = timebase * divs; float sample_rate = points / (frame_time_ms / 1000.0f);这里 sample_rate 的单位是 Sa/s。如果显示 10 格需要 100ms,而一帧固定 4096 点,采样率就是 40960 Sa/s,完全在 F103 的 ADC 能力范围内。但如果时基设为 1ms/div,采样率变成 409600 Sa/s,就需要把 ADC 采样周期调到最小,且开启 DMA 连续模式,否则中间一旦有 CPU 干预,采集到的点数就不够一帧,显示会出现明显的断层。时基换算不能只在计算器里做,要落到代码里的定时器配置和 ADC 采样周期设置上。建议把时基参数做成一个变量,用户按键切换时联动修改 ADC 采样时间和 DMA 缓冲区长度。
3. STM32 ADC + DMA 配置:手动配寄存器比 CubeMX 更可控
3.1 ADC 采样频率计算与注入通道选择
ADC 的采样频率由时钟分频、采样周期和转换周期共同决定。以 STM32F103 为例,ADC 时钟最高 14MHz(APB2 为 72MHz 时选择 6 分频),12 位分辨率下每个通道的转换时间 = 采样周期 + 12.5 个 ADC 时钟周期。采样周期寄存器可设 1.5、7.5、13.5、28.5、41.5、55.5、71.5、239.5 周期,数值越大,内部采样电容充电越充分,对高阻抗信号源越友好。
一个反直觉的结论是:示波器应用里采样周期不能一味调最小。如果将采样周期设为 1.5,理论上单通道采样率能达到约 1Msps,但如果信号源内阻较高,采样电容没充满,采集到的电压值会整体偏低,波形幅度看起来“缩水”。做 A1 信号采集时,通常会设置 ADC 工作在扫描模式或单通道连续模式,优先保证通道稳定,而不是把采样率顶到极限。常规做法是采样周期设 13.5 或 28.5,配合外部运放缓冲电路来降低信号源阻抗,实测稳定性和精度要好很多。
CubeMX 配置 ADC 固然方便,但涉及 DMA 半满/全满中断和时基联动时,生成的代码分散在多个文件里,调试时反而要来回翻。更可控的做法是直接操作寄存器,把整个初始化收敛到一个函数里。
void ADC1_DMA_Init(uint16_t *buf, uint32_t len) { /* 开启时钟 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); /* ADC 时钟 = 72MHz/6 = 12MHz */ /* PA1 模拟输入 */ GPIO_InitTypeDef gpio; gpio.GPIO_Pin = GPIO_Pin_1; gpio.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &gpio); /* DMA1 通道 1 映射到 ADC1 */ DMA_InitTypeDef dma; dma.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; dma.DMA_MemoryBaseAddr = (uint32_t)buf; dma.DMA_DIR = DMA_DIR_PeripheralSRC; dma.DMA_BufferSize = len; dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma.DMA_MemoryInc = DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; dma.DMA_Mode = DMA_Mode_Circular; dma.DMA_Priority = DMA_Priority_High; dma.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel1, &dma); /* ADC1 配置 */ ADC_InitTypeDef adc; adc.ADC_Mode = ADC_Mode_Independent; adc.ADC_ScanConvMode = DISABLE; /* 单通道,无需扫描 */ adc.ADC_ContinuousConvMode = ENABLE; /* 连续转换,配合 DMA */ adc.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; /* 软件触发 */ adc.ADC_DataAlign = ADC_DataAlign_Right; adc.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &adc); /* 规则序列第 1 个通道是 A1,采样周期为 13.5 */ ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_13Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); ADC_SoftwareStartConvCmd(ADC1, ENABLE); DMA_ITConfig(DMA1_IT_HT | DMA1_IT_TC, ENABLE); }参数说明:ADC_Mode 独立模式表示只用 ADC1,不搞双 ADC 同步;ContinuousConvMode 必须使能,因为 DMA 循环模式要求 ADC 一直转换,否则采完一轮就停;ADC_NbrOfChannel 虽然是 1,但在扫描模式关闭时意义不大,保留它是因为 ADC_Init 的结构体要求初始化。采样周期 13.5 是折中取值,适合 1kHz~100kHz 的信号测量,如果需要更高采样率可以降到 7.5,但不建议用 1.5。DMA 的 PeripheralBaseAddr 直接取&ADC1->DR,固定地址,不递增,这和内存地址递增是两回事,别搞混。
3.2 双缓冲替代单缓冲,避免画面撕裂
单缓冲区最常见的现象是:刷新波形时上半部分是上一帧的数据,下半部分是新一帧,中间有一条明显的接缝。原因是 CPU 正在读取缓冲区绘制波形的同时,DMA 已经把新数据写进来了,绘制的数据源不稳定。双缓冲的思路是准备两个大小相等的数组,DMA 交替写入,收到半满/全满中断时,CPU 只读“当前不写入”的那一半。
#define HALF_LEN 2048 uint16_t buf[2][HALF_LEN]; volatile uint8_t active_buf = 0; /* 当前 DMA 正在写入的缓冲区编号 */在 DMA 中断里切换 active_buf,而显示线程读取buf[1 - active_buf],数据源就是稳定的。这个方案比加锁和临界区保护简单,也不存在并发写同一块内存的问题。有一点要注意:切换 active_buf 必须放在中断里完成,如果放在主循环判断,CPU 可能读到一半,DMA 又切换了缓冲区,等于没保护。双缓冲会让 ADC 的 DMA 配置略微复杂一点,因为 DMA 的 MemoryBaseAddr 需要指向当前内存的起始地址,每次 DMA 传输完成中断时重新设置DMA_SetCurrDataCounter和内存地址。但 F103 的 DMA 在半满中断时其实已经自动把地址切到后半段了,物理地址是连续的,所以在双缓冲场景下,半满中断代表前半段完成,全满中断代表后半段完成,CPU 只需要知道哪个半段可以读。
3.3 采样率不够时怎么办:定时器触发 ADC 的模式
连续转换模式下 ADC 的采样间隔完全由时钟决定,如果想让采样率精确等于某个非整数值(比如 500kSa/s),连续模式反而不方便。换用定时器触发 ADC,可以精确控制采样时刻。TIM2 的更新事件作为 ADC 外部触发源,通过修改预分频器和自动重装值来设定频率,ADC 每次触发转换一次,和 DMA 配合也能跑满速。
/* TIM2 产生 500kHz 触发信号 */ TIM_TimeBaseInitTypeDef tim; tim.TIM_Period = 144 - 1; /* 72MHz / 500kHz = 144 */ tim.TIM_Prescaler = 0; tim.TIM_ClockDivision = 0; tim.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &tim); TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update); TIM_Cmd(TIM2, ENABLE);用定时器触发后,ADC 的 ExternalTrigConv 不能再设 None,要改成ADC_ExternalTrigConv_T2_TRGO。这类细节经常是波形“时有时无”的根源——ADC 确实配置了外部触发,但定时器没使能 TRGO,结果就是不转换。定时器触发的好处是采样间隔均匀且可预测,坏处是采样率和时基联调时需要先算好定时器参数。这个模式更适合做等效时间采样,对周期性信号用随机等效时间采样,可以在低速 ADC 上还原高频重复信号。不过在入门阶段,连续模式加 DMA 已经能满足大部分需求,定时器触发作为进阶选项可以后面再加。
4. 波形显示与触发逻辑:LCD 上画出的每一帧都要“稳”
4.1 边沿触发实现与阈值归一化
触发是示波器区别于普通电压记录仪的核心功能。最简单的边沿触发实现不复杂:在 DMA 中断服务函数里扫描缓冲区,找到连续两个采样点跨过阈值且方向符合设定(上升沿或下降沿)的位置,以此作为波形绘制的起始索引。但阈值设多少,和 ADC 的量程直接相关。STM32 的 ADC 是 12 位,输入 0~3.3V 映射到 0~4095,如果用户输入的是 1.65V 的直流偏置正弦波,阈值应该设在 2048 附近,也就是 1.65V 对应代码值。
#define ADC_VREF 3.3f #define ADC_MAX 4095.0f int threshold_code = (int)(threshold_voltage * ADC_MAX / ADC_VREF);阈值归一化这一步看似简单,但很多人在代码里写死阈值 2048,换一块基准电压不同的板子或者输入信号带直流偏置,触发就失灵了。更稳妥的做法是把阈值电压做成可调参数,通过按键步进调整,同时屏幕上显示当前阈值对应的电压值。触发源选择上,单通道示波器一般只能触发在用通道,A1 通道的触发逻辑只用一路数据就够了,不需要交叉触发。
触发点查找不要在主循环里做,否则一边找一边 DMA 还在写新数据,触发位置会漂移。正确做法是在 DMA 传输完成中断里做一轮快速扫描,找到触发点后置位标志位,把触发点索引存在全局变量里,主循环只负责消费这个索引。
void ADC_DMA_IRQHandler(void) { uint16_t *base = (active_buf == 0) ? buf[0] : buf[1]; int trig_idx = -1; if (trig_mode == TRIG_RISING) { for (int i = 1; i < HALF_LEN; i++) { if (base[i-1] < threshold_code && base[i] >= threshold_code) { trig_idx = i; break; } } } if (trig_idx >= 0) { trig_point = trig_idx; trig_flag = 1; } }提示:触发扫描只在 DMA 中断里做一次,不要用 while 循环等待,否则高采样率下中断会持续被打断,显示任务彻底卡死。触发找不到时,trig_flag 保持复位状态,显示还是要继续刷新,只是没有参考点,这时波形会像自由滚动模式一样移动。这种“找不到触发就滚动显示”的行为是真实示波器的标准回退逻辑,建议你的实现也保留这一分支。
4.2 网格、刻度与波形的三层绘制
LCD 显示波形不建议直接在底图上画波形,那样擦除时会把网格也抹掉。规划三块内存:底色层画黑色背景,网格层画淡灰色网格和电压时间刻度,波形层画黄色或绿色波形。每次刷新只重绘波形层,网格层和数据层分离后,闪烁感会明显降低。
网格间隔的电压值由垂直档位决定。假设当前 V/div 为 1V,屏幕纵向 240 像素分成 8 格,每格 30 像素,ADC 量程 3.3V 映射过去就要做分段线性变换。
int y = LCD_HEIGHT - 1 - (adc_val * LCD_HEIGHT) / 4095;这个映射在数学上没问题,但视觉效果偏平,因为整个量程被拉伸到了整个屏幕高度,信号一来就削顶或削底。参考真实示波器的缩放逻辑,应该把 ADC 数值映射到屏幕中间的一部分区域,比如只占用 60% 的高度,留出顶部和底部的余量,这样过载时还能看到信号被削顶的形状。显示代码里用voltage_offset和voltage_gain两个参数控制映射比例,类比示波器的垂直位移和垂直灵敏度,调起来会顺手很多。
4.3 波形刷新率与屏幕控制器带宽的取舍
用 MCU 直接驱动 LCD 时,瓶颈往往不在采样的 ADC 端,而在屏幕控制器的写数据速度。以常见 ILI9341 为例,SPI 模式写一个像素需要 16 个时钟周期,假设 SPI 时钟 40MHz,理论最大像素率约 2.5M 像素/秒。显示 320×240 分辨率的全屏波形需要 76800 像素,这意味着理论最快全屏刷新也就 30fps 左右。加上读写寄存器指令开销和清屏操作,实际能达到 15~20fps 就不错了。
既然刷新率上不去,就要在显示策略上做优化。不要在每帧画完之后做全屏清屏操作,只更新波形点所在的像素区域,或者直接往 LCD 的 GRAM 对应位置写点。开启 LCD 的窗口写模式(SetWindow),只对波形区域的矩形范围写数据,能省掉不必要的全屏传输。如果追求更高帧率,可以换用 FSMC 接口的 LCD,并配合 STM32F407 等带 FSMC 的型号,把像素写到 GRAM 的时间压缩到纳秒级,波形刷新率能到 60fps 以上。
5. 从 A1 到真实信号的调理问题:增益、偏置与阻抗匹配
5.1 无源衰减探头和输入保护电路
直接拿杜邦线接到 PA1 测信号,不是不可以,但信号源阻抗一旦超过几 kΩ,ADC 采样电容充电不足,波形幅值就会明显偏低。示波器探头的本质是 10× 无源衰减器:探头内部有个 9MΩ 电阻,示波器输入端有一个 1MΩ 电阻,两者串联分压,把输入信号衰减为原来的 1/10,同时把输入阻抗提高到 10MΩ。对 STM32 而言,10MΩ 输入阻抗是好事,因为负载效应变小;但衰减后的信号幅度也变小了,需要后级放大。这个矛盾在低频测量时可以通过运放跟随器解决,高频测量得靠专用示波器前端芯片,不是 MCU 项目能覆盖的范围。
复刻阶段建议处理两类信号:一是板载信号源(如 DAC 输出滞回比较器方波信号或正弦波),直接接 A1 即可,不需要额外衰减;二是外部信号,务必先串一个 1kΩ 保护电阻,防止误接高压烧掉 PA1 引脚。保护电路不用多复杂,一个电阻加两个反向并联二极管(钳位到 VCC 和 GND)就能挡住绝大多数误操作。
/* A1 前端经典保护结构 */ R1: 1kΩ 串接在信号源与 PA1 之间 D1: 1N4148 阳极接 GND,阴极接 PA1 D2: 1N4148 阳极接 PA1,阴极接 VCC提示:如果你测的信号本身带有直流偏置,比如音频功放输出端,建议先并联一个 10μF 电容做隔直,再把交流分量送入 MCU。STM32 的 ADC 不支持负压输入,交流信号如果没有直流偏置,负半周会被砍掉,显示波形会变成只有上半边的畸变信号,这不是程序 bug,是前端电路没做对。
5.2 偏置调节与档位切换的软件联动
真实示波器面板上 V/div 旋钮不是纯模拟电路控制,而是通过改变 ADC 前端 PGA 增益实现的。MCU 示波器没法直接用 PGA,但可以把 V/div 映射成两组参数:一组是软件增益,另一组是显示缩放比例。
typedef struct { float v_per_div; float gain; /* 软件放大倍数 */ int y_offset; /* 屏幕纵向偏移 */ } vol_scale_t; vol_scale_t vol_table[] = { {0.1f, 32.0f, 100}, {0.2f, 16.0f, 100}, {0.5f, 6.4f, 100}, {1.0f, 3.2f, 100}, {2.0f, 1.6f, 100}, };如果 12 位 ADC 的满量程是 3.3V,设置 V/div 为 1V 时,8 格高度对应 8V,远超量程,波形必然削顶。此时软件增益应调到 0.4 左右,让 3.3V 信号被缩到 3 格高度,留下裕量。这个缩放流程不需要重新配置 ADC,只需在显示映射时乘上 gain 即可。要注意的是,软件放大会让噪声同时放大,所以增益调高后建议在 ADC 转换结果上多采几轮求平均,或者加一个滑动平均滤波,单独靠显示层的平滑滤镜治标不治本。
5.3 输入耦合模式:DC/AC 切换在 MCU 端怎么做
示波器的 AC 耦合是硬件实现的,在信号路径上串联一个电容,滤掉直流分量。MCU 端没有硬件 AC 耦合开关,但可以通过软件处理等效实现:采样一组完整波形后,先计算整帧的平均值,然后每个采样点减去这个直流分量,等价于 AC 耦合效果。这个方法的前提是信号帧要大于基波周期的整数倍,否则平均值里包含的直流分量估算不准,通常采集 10 个周期以上的数据再做均值,效果才稳定。
uint32_t sum = 0; for (int i = 0; i < HALF_LEN; i++) { sum += base[i]; } int dc_component = sum / HALF_LEN; for (int i = 0; i < HALF_LEN; i++) { int ac_val = (int)base[i] - dc_component; if (ac_val < 0) ac_val += 4095; /* 取模还原到 ADC 量程范围 */ display_buf[i] = (uint16_t)ac_val; }这段代码的适用边界要知道:它只能处理直流分量比较稳定的信号。如果输入信号的直流偏置本身在缓慢漂移,单帧均值没法跟踪变化,波形会持续上下移动。这种情况在真实示波器里靠硬件 AC 耦合电容时间常数解决,MCU 方案里可以用高通滤波代替均值消除,用一阶 IIR 高通y[n] = α * (y[n-1] + x[n] - x[n-1]),α 取 0.9 左右。两种方式各有适用场景,均值法计算简单显示稳定,IIR 法实时性更好。实现时建议做成切换项,按键在 DC/AC 耦合之间切换。
6. 把 A1 通道的波形同时送到串口和 LCD,做一个带数据回放的最小系统
6.1 串口波形输出的帧格式与上位机对接
LCD 是给人看的,串口是给机器用的。把 ADC 原始数据通过串口发到 PC 端,再用通用串口绘图工具或 Python 脚本画出来,很多调试场景比盯 LCD 更高效。串口输出的核心问题是格式:不要一次发 4096 个二进制字节,这样在串口助手里根本没法读,也不要发带太多文本说明的字符串,解析效率太低。推荐用 CSV 帧格式,一行 128 个点,带帧头标识。
void send_frame(uint16_t *data, uint16_t len) { printf("FRAME:%d:", len); for (uint16_t i = 0; i < len; i++) { printf("%d%s", data[i], (i % 16 == 15) ? "\n" : ","); } }帧头FRAME:方便上位机做识别,len让解析器知道每帧要读多少个数。逗号分隔和换行分隔混用是为了兼顾人工可读性和解析效率,16 个数据一行,在串口终端和 Python 的split()两套解析流程中都能流畅工作。波特率推荐 921600,普通 115200 在 100kSa/s 采样率下会严重丢包,串口会成为整个系统的速度瓶颈。
Python 端解析也不复杂,用pyserial读串口,遇到FRAME:头开始累积数据,读到指定长度后调用 matplotlib 绘制。关键技巧是 matplotlib 的刷新方式不要用plt.plot每次重新创建对象,应该先创建line对象,然后set_ydata更新数据,配合plt.pause(0.001)做交互模式刷新,否则窗口卡死,还要保留plt.cla()才会更新。这个链路里没用到任何网络服务,纯粹是串口直连,在任何操作系统上都能复现,通用性和可调试性都不错。
6.2 双通道扩展:把 A1 和 A2 做交替采样同时显示两路波形
单通道示波器做完之后,扩展双通道是很自然的下一步。STM32 的 ADC 规则组是多通道的,可以在扫描模式里配置两个通道,A1 和 A2,DMA 直接循环搬运两个通道的交替数据。这样做不增加硬件成本,显示层也只是把同一套绘制函数调用两次。
双通道模式需要注意内存组织方式:DMA 搬运的结果是[CH1, CH2, CH1, CH2, ...]交替排列,解析时要按步长 2 抽取,不能当作连续单通道数据去显示。
for (int i = 0; i < HALF_LEN; i++) { ch1_disp[i] = dma_buf[i * 2]; ch2_disp[i] = dma_buf[i * 2 + 1]; }交替采样模式下,两个通道的实际采样率各减半。假设 ADC 定时器触发频率仍为 1MHz,每个通道只能分到 500kSa/s,显示带宽相应下降,这个要在界面上明确提示,不然用户看到两个通道波形幅值都对就是频率不对会感到困惑。通道间要设置一定的采样间隔,防止两个通道复用同一个采样电容时出现串扰,一般至少间隔 2 个 ADC 时钟周期,扫描模式下硬件会自动处理,但在时序要求苛刻的场合还是要在初始化里显式设置。
探索者开发板也可以用 ADC1 的注入组分别配置两个不同的采样周期,来实现不同带宽需求的两路采集。注入组通道单独配置采样时间,规则组负责显示波形,注入组可以用来做触发通道或峰值检测,不必参与主显示。
6.3 如何验证显示和采样是否同步:一个 1kHz 方波自测流程
项目完成后,如何确认采样显示链路没有隐藏 bug?最好的自测信号是方波,因为方波的边沿陡峭,时间轴上的偏差一眼就能看出来。把 PA1 接到正点原子板载 DAC 或定时器输出的 1kHz 方波,设置时基为 0.5ms/div,触发方式为上升沿,观察波形显示是否稳定不动。
验证分三步。第一步:调节触发阈值到方波幅值一半左右,波形应该锁定,水平方向不滚动,如果滚动说明触发没找到或阈值超出范围。第二步:测试触发电平在波形的顶部和底部之间移动,显示波形会左右平移,这是正常的,预触发深度越小,平移越明显。第三步:缓慢改变输入信号的频率,每改一次等 2 秒,看波形是否稳定在屏幕上。如果频率改变了波形仍然完全重合,说明触发逻辑或等效采样有问题;如果波形横向伸缩范围同实际频率变化相符,链路就通了。
最后可以测试一个极端情况:把信号频率提升到 100kHz,ADC 采样率设到 1Msps,一个周期只能采 10 个点,波形看起来会明显“多边形化”,此时关注的不再是显示平滑度,而是触发点的位置是否始终保持一致。能稳定触发在高频欠采样条件下,说明整条信号链路的时序没有大的漂移。后续需要提升显示质量,可以叠加正弦插值或线性插值,不过这已经超出了采集显示的基础范畴,属于显示算法优化层面。你可以先在现有骨架上把这条链路跑通,再决定要不要加入 FFT 频谱显示或滚屏模式。
本文还有配套的精品资源,点击获取