简介:这份资源是面向嵌入式初学者与电子爱好者的STM32F103ZET6简易示波器完整工程,基于Cortex-M3内核芯片,利用其72MHz主频与多通道ADC实现正弦波、方波等信号的采集与LCD波形显示,适合作为单片机外设综合练习与课程设计参考。压缩包共272个文件,约8.9MB,以c与h源码、o与d编译中间文件、crf与lst链接列表、s启动文件及txt说明为主,另含uvproj、uvopt等Keil工程文件与hex、axf烧录产物,覆盖从源码到可执行固件的完整链路。目前已有4570人学习下载。读者可从中获得ADC采样、定时器控制采样率、SPI/I²C驱动LCD、CMSIS标准库配置等模块化实现思路,并借助工程文件直接编译烧录,对照波形调试过程理解微控制器资源调度与信号可视化方法,对掌握嵌入式开发流程具有实际参考价值。
1. 拆开一个 STM32F103ZET6 示波器程序包:它到底能跑出什么波形
前阵子帮一个做电源测试的朋友找便携采集方案,他丢过来一个压缩包,名字就叫「基于STM32F103ZET6示波器程序.rar」。我第一反应是:这年头用 F103 做简易示波器的资料满天飞,但真正能跑、能看、能改的没几个。拆开之后发现,这套东西的定位很清晰——它不是要替代台式示波器,而是给嵌入式工程师一个能塞进设备里的波形观察窗口。核心是 STM32F103ZET6 这颗 Cortex-M3 芯片,主频 72MHz,自带 12 位 ADC,配合 TFT 屏和按键交互,能采能显能调。适合谁?做电机驱动、电源环路、传感器信号调理的从业者,手头需要一台能长期挂在板子上的低成本监视器;也适合刚学完单片机外设、想找个综合项目练手的同学。它解决的不是「高精度测量」,而是「现场快速看波形趋势」这个具体诉求。
2. 信号链拆解:从模拟前端到屏幕像素的完整路径
2.1 模拟前端与 ADC 采样时序
这套程序最核心的部分不是显示,而是采样。STM32F103ZET6 的 ADC1 挂在 APB2 总线上,最高时钟 14MHz,12 位分辨率下最快转换时间 1μs。但程序里不会跑这么快,因为要留时间给 DMA 搬运和屏幕刷新。常见做法是配置 ADC 为连续转换模式,由 TIM2 或 TIM3 的更新事件触发,采样率通过定时器分频算出来。比如要采 10kHz 的正弦波,按奈奎斯特至少 20kHz 采样率,实际工程里取 10 倍以上,也就是 100kHz 以上才看得清形状。定时器触发的好处是采样间隔严格等距,不会因为中断响应抖动导致波形畸变。
模拟前端部分,程序包里通常配一个运放做阻抗变换和偏置。F103 的 ADC 输入范围是 0 到 3.3V,如果测双极性信号,得把信号抬到 1.65V 中点。常见电路是用电阻分压加跟随器,或者直接用轨到轨运放搭一个加法器。这里有个血泪经验:偏置电压不稳,整个波形会上下漂,看起来像信号在呼吸。所以基准源最好用 TL431 或者 REF3033,别直接用 3.3V 分压,那玩意随负载变。
// ADC1 初始化关键片段:TIM3 触发 + DMA 循环搬运 void ADC_Config(void) { ADC_InitTypeDef ADC_InitStructure; DMA_InitTypeDef DMA_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); // PA0 作为模拟输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure); // DMA1 通道1 搬运 ADC1 数据到缓冲区 DMA_DeInit(DMA1_Channel1); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)adc_buffer; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = ADC_BUFFER_SIZE; // 比如 1024 DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; // 循环模式,永不停止 DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_Init(DMA1_Channel1, &DMA_InitStructure); DMA_Cmd(DMA1_Channel1, ENABLE); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; // 由外部触发 ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T3_TRGO; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_ExternalTrigConvCmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); // 校准 ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); // TIM3 产生 100kHz 触发信号:72MHz / (72 * 10) = 100kHz TIM_TimeBaseStructure.TIM_Period = 10 - 1; TIM_TimeBaseStructure.TIM_Prescaler = 72 - 1; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); TIM_SelectOutputTrigger(TIM3, TIM_TRGOSource_Update); TIM_Cmd(TIM3, ENABLE); }这段代码里几个参数值得盯住:ADC_SampleTime_55Cycles5是采样保持时间,信号源内阻大就加长,否则采不准;DMA_Mode_Circular让缓冲区循环覆盖,屏幕读的时候直接读最新一段,不用等中断;TIM3 的Period和Prescaler决定了采样率,改这两个值就能切档位。我一般会把采样率做成可调,从 10kHz 到 500kHz 分几档,用按键切换,这样既能看工频干扰,也能看开关电源的纹波。
2.2 波形重建与 TFT 刷新策略
采到数据只是第一步,怎么把 1024 个点映射到 240x320 的 TFT 屏上才是显示部分的关键。程序里通常用 ILI9341 或 ST7789 驱动,SPI 接口,刷一屏大概几十毫秒。如果每采一次就全屏刷新,波形会闪得没法看。常见做法是双缓冲:一个缓冲区给 DMA 写,另一个给屏幕读,读完了交换。但 F103 的 RAM 只有 64KB,双缓冲 1024 个半字就是 2KB,加上屏幕显存,勉强够用。更省的做法是只刷新变化区域,但实现复杂,这套程序里一般不做。
波形映射的算法很简单:找到缓冲区里的最大值和最小值,算出峰峰值,然后按屏幕高度做线性缩放。但这里有个玄学问题——如果信号一直在变,峰峰值跟着变,波形会上下跳。解决办法是加一个「自动量程」的滞回区间,或者干脆固定垂直档位,让用户手动调。我倾向于后者,因为自动量程在测瞬态信号时会把小信号放大成噪声,看起来全是毛刺。
// 将 ADC 缓冲区映射到屏幕坐标并绘制 void Draw_Waveform(uint16_t *buf, uint16_t len) { uint16_t i; uint16_t max = 0, min = 4095; uint16_t y, y_prev; uint16_t x_prev = 0; // 找峰峰值 for(i = 0; i < len; i++) { if(buf[i] > max) max = buf[i]; if(buf[i] < min) min = buf[i]; } // 防止除零,至少留 10 个 ADC 码的余量 if(max - min < 10) { max = min + 10; } // 清屏或只清波形区,这里用局部清屏 LCD_Fill(0, WAVE_Y_START, 240, WAVE_Y_END, BLACK); // 逐点画线,x 方向压缩到 240 像素 for(i = 0; i < 240; i++) { // 从缓冲区里等间隔取点,len 可能大于 240 uint16_t idx = i * len / 240; // 映射到 Y 坐标:ADC 值越大,屏幕 Y 越小(因为屏幕原点在左上) y = WAVE_Y_END - (buf[idx] - min) * (WAVE_Y_END - WAVE_Y_START) / (max - min); if(i > 0) { LCD_DrawLine(x_prev, y_prev, i, y, GREEN); } x_prev = i; y_prev = y; } }这段代码里WAVE_Y_START和WAVE_Y_END是屏幕上的波形显示区域,一般留出顶部状态栏和底部菜单栏。LCD_DrawLine是画线函数,比画点快,但要注意坐标边界,别画到区域外面去。如果屏幕驱动带硬件加速,比如 ILI9341 的 Memory Write 连续写,可以先把整行像素拼好再一次性发送,速度能快一倍。另外,buf[idx]这种等间隔抽取在信号频率高的时候会混叠,看起来像低频波,这是采样定理的锅,不是程序 bug。要避免就得提高采样率或者加抗混叠滤波器。
3. 交互与参数配置:按键、菜单和那些反直觉的默认值
3.1 按键状态机与菜单结构
这套程序一般配三到四个按键:一个菜单键、一个加键、一个减键、一个确认键。按键处理用状态机比用延时消抖靠谱,因为示波器刷新占着 CPU,延时消抖会把主循环卡死。常见做法是定时器每 10ms 扫一次 IO,记录按下和释放事件,主循环里根据当前菜单层级做响应。菜单结构通常是两级:一级选功能(垂直档位、水平时基、触发模式),二级调参数。这里有个坑:如果按键扫描放在中断里,而菜单刷新在主循环,两个上下文同时改同一个变量,会出玄学问题。我一般把按键事件放进一个环形队列,主循环慢慢消费。
触发模式是示波器的灵魂。这套程序里通常支持三种:自动、正常、单次。自动模式下,没触发信号也刷新,屏幕上全是滚动的噪声;正常模式下,等触发条件满足才更新一屏,波形稳定;单次模式抓一个瞬态就停。触发条件一般是边沿触发,上升沿或下降沿,触发电平用 ADC 码值表示。调触发电平的时候,屏幕上会有一条水平虚线,告诉你当前触发点在哪。如果触发电平设得比信号峰峰值还高,那就永远触发不了,屏幕卡住不动——这不是死机,是触发条件没满足。
3.2 时基与垂直档位的换算关系
时基决定了屏幕上显示多长时间的信号。假设采样率是 100kHz,缓冲区 1024 个点,那么一屏就是 10.24ms。如果屏幕横向 240 像素,每个像素代表 42.7μs。用户看到的「时基」参数通常用「ms/div」表示,比如 5ms/div,一共 10 格就是 50ms。但程序内部是按采样点算的,所以改时基实际上是改采样率或者改抽取比例。垂直档位同理,ADC 满量程 3.3V 对应 4095,如果垂直档位设成 1V/div,屏幕 8 格就是 8V,但 ADC 只能测 3.3V,所以前面必须有衰减器或者放大器。程序里的垂直档位只是改显示比例,不改变硬件增益,这点要分清。
| 参数 | 典型值 | 影响 | 调整建议 |
|---|---|---|---|
| 采样率 | 10kHz~500kHz | 决定能看多快的信号 | 按信号频率的 10 倍以上选 |
| 缓冲区长度 | 512~2048 | 决定一屏点数 | 越大越占 RAM,F103 建议 1024 |
| 触发电平 | 0~4095 | 决定波形从哪开始画 | 设在信号幅值中间偏上 |
| 垂直档位 | 0.1V/div~2V/div | 决定波形高度 | 先选大档位,再往下调 |
| 时基 | 0.1ms/div~50ms/div | 决定波形疏密 | 先选大时基找信号,再缩小 |
这张表里的参数不是孤立的。比如你把采样率降到 10kHz,时基设成 0.1ms/div,那一个像素对应的时间就太长了,波形会挤成一团。反过来,采样率 500kHz,时基 50ms/div,缓冲区根本装不下那么多点,程序会做抽取,波形看起来是好的,但细节丢了。我一般会先让用户选一个「自动设置」,程序根据输入信号的频率和幅值算一组参数,然后再手动微调。自动设置的核心就是测频和测幅,测频用定时器捕获或者过零检测,测幅用峰峰值。
4. 避坑与排查:那些让你怀疑芯片坏了的瞬间
4.1 波形显示为一条直线或满屏噪声
现象:上电后屏幕有背光,菜单能操作,但波形区要么一条水平线,要么全是跳动的点。原因通常有三个:一是模拟前端没供电,运放没输出,ADC 读到的是零或满量程;二是 DMA 没启动,缓冲区里全是初始值;三是触发条件设错了,自动模式下应该滚动,但程序卡在正常模式。排查顺序:先用万用表量 ADC 输入引脚的电压,如果是 0V 或 3.3V 固定,查前端;如果电压随信号变,查 DMA 配置里的DMA_BufferSize和DMA_Mode;如果都正常,把触发模式切到自动,看有没有滚动。我遇到过最坑的一次是 DMA 通道选错了,ADC1 对应 DMA1 通道1,结果代码里写了通道2,编译不报错,运行就是没数据。
4.2 采样率上不去,波形混叠
现象:想测 50kHz 的开关纹波,结果屏幕上显示的是一个低频正弦,频率对不上。原因:采样率不够,或者定时器触发频率算错了。F103 的 ADC 最快 1MHz 左右,但加上 DMA 和屏幕刷新,实际能稳定跑 200kHz 就不错了。如果信号频率 50kHz,采样率至少 500kHz,这时候得把屏幕刷新降下来,或者用抽取模式,只显示一部分点。另一个常见错误是定时器分频算错,比如Prescaler设成 72,以为得到 1MHz,实际上 APB1 定时器时钟是 36MHz(如果系统时钟 72MHz,APB1 分频系数为 2),所以 36MHz / 72 = 500kHz,不是 1MHz。这个细节在参考手册的时钟树里,不看就翻车。
4.3 屏幕刷新撕裂或闪烁
现象:波形在屏幕上滚动时,中间有一条横向撕裂线,或者整体闪烁。原因:SPI 刷屏和 DMA 采样抢总线,或者刷屏没有做局部更新。F103 的 SPI 最高 18MHz,刷 240x320 全屏要 2403202*8/18M ≈ 68ms,这期间如果采样缓冲区被覆盖,显示的就是新旧混合的数据。解决办法:一是用双缓冲,DMA 写 buf0 的时候,屏幕读 buf1;二是降低刷新率,比如每 100ms 刷一次,中间只更新变化区域;三是把 SPI 时钟提到 36MHz(F103 的 SPI1 在 APB2 上,最高 72MHz 二分频)。但 SPI 太快会导致屏幕花屏,得看屏幕驱动芯片的手册,ILI9341 一般能跑 20MHz 以上。
4.4 按键反应迟钝或连击
现象:按一下加键,参数跳好几格;或者按了没反应,得使劲按。原因:按键扫描周期太长,或者没有做释放检测。如果扫描周期 50ms,人按一下至少 100ms,程序会认为按了两次。解决办法:扫描周期降到 10ms,并且记录「按下沿」和「释放沿」,只在按下沿触发一次动作。另外,机械按键抖动时间一般 5~10ms,10ms 扫描刚好能滤掉,不用再加延时。如果按键接在中断引脚上,记得在中断里关掉该引脚的中断,处理完再开,否则会反复进中断。
4.5 程序烧录后不运行,或运行一段时间死机
现象:烧录成功,但屏幕不亮,或者跑几分钟后卡死。原因:一是堆栈溢出,F103 默认堆栈 1KB,如果局部数组太大,比如uint16_t buf[2048]放在函数里,直接爆栈;二是中断优先级冲突,ADC 中断和 DMA 中断抢同一个优先级,导致死锁;三是看门狗没喂。排查:把大数组改成全局变量或静态变量;检查NVIC_PriorityGroupConfig和各个中断的抢占优先级,DMA 和 ADC 别设成一样;如果开了独立看门狗,主循环里记得喂狗,但刷屏函数如果耗时太长,喂狗间隔要算好。我一般会在主循环里翻转一个 IO,用示波器看周期,如果周期突然变长,就是哪里卡住了。
5. 进阶玩法:把 F103 示波器变成能存数据的简易分析仪
这套程序最值钱的地方不是显示,而是它能改。F103ZET6 有 512KB Flash 和 64KB RAM,除了跑示波器,还能塞进去一个简单的数据记录功能。我一般会加一个 SD 卡或者串口上传,把 ADC 缓冲区里的原始数据按二进制存下来,然后在电脑上用 Python 画图分析。这样比盯着小屏幕看靠谱得多,尤其是抓瞬态信号的时候。串口上传的代码很简单,把 DMA 缓冲区里的半字转成字节,加个帧头,用 115200 波特率发出去,一秒钟能传 10K 个点,对于 100kHz 采样率来说,传 1024 个点只要 0.1 秒,完全跟得上。
# 上位机解析串口传来的 ADC 数据并绘图 import serial import struct import matplotlib.pyplot as plt ser = serial.Serial('COM3', 115200, timeout=1) raw = ser.read(2048) # 读 1024 个半字 data = struct.unpack('<' + 'H' * 1024, raw) # 小端解析 # 转成电压:3.3V / 4095 volts = [v * 3.3 / 4095 for v in data] plt.plot(volts) plt.xlabel('Sample') plt.ylabel('Voltage (V)') plt.grid(True) plt.show()这段 Python 脚本里,struct.unpack的格式字符串'<'+'H'*1024表示小端、1024 个无符号半字。如果 STM32 那边发的是大端,改成'>'。串口波特率 115200 下,一帧 2048 字节大概 0.18 秒,加上解析和绘图,整体延迟不到一秒。如果想连续采集,可以在 STM32 那边加一个简单的协议:每帧开头两个字节 0xAA 0x55,然后跟 1024 个半字,上位机按帧同步。这样即使丢几个字节,也能重新对齐。我习惯在 STM32 端用 DMA 串口发送,把缓冲区指针直接给 USART1 的 DMA 通道,CPU 完全不参与,采样和上传互不干扰。
另一个进阶方向是加 FFT。F103 没有硬件浮点,但可以用定点数做 256 点 FFT,大概几毫秒算完。把 ADC 数据加汉宁窗,然后跑基 2 的 FFT,把幅值谱画在屏幕上,就能看谐波了。不过屏幕分辨率有限,一般只显示前 64 个频点。这个功能对做电源的很有用,能直接看开关频率和它的谐波分布。代码网上有现成的定点 FFT 库,移植过来改改就行,注意 Q 格式和溢出。
从那以后我每次拿到这种「简易示波器」程序包,都先不急着烧录,而是把 ADC 配置和定时器时钟树对一遍,再用信号发生器从低频到高频扫一遍,确认采样率和显示都对得上。这套 F103 的方案虽然老,但胜在便宜、资料多、改起来方便,适合当个长期挂机的波形监视器。希望帮到你。
本文还有配套的精品资源,点击获取