简介:面向STM32嵌入式开发者与单片机初学者,这是一套基于STM32驱动3.5寸LCD实时绘制波形图的完整工程资源。资源围绕GPIO引脚控制、LCD初始化时序、ADC模拟信号采集、数据处理与动态刷新等关键环节,提供可直接移植的源码实现与工程配置,覆盖从底层驱动到上层绘制的完整链路。压缩包内含136个文件,以H/C源码和工程配置文件为主,同时包含编译链接生成的O、D、CRF中间文件及HEX、AXF固件输出,整体大小3.08MB,便于对照分析开发和调试过程。波形绘制采用缓冲区逐点或逐行传输,并引入DMA减轻CPU负担,适合需要落地实时波形显示、学习屏幕驱动或数据可视化方法的开发者。该资源已有4522人学习,代码结构清晰、可拓展性强,稍作调整即可适配不同尺寸LCD,也可在此基础上加入触控或更复杂的图形界面。 前阵子调一块传感器板子,输出信号时好时坏,我想用示波器看一眼波形,结果示波器被隔壁组借走了。手头只剩一块带LCD屏的STM32开发板,我当时就想:板子上明明有屏,为什么不能让它直接把ADC采样到的波形画出来?于是花了两个晚上把“STM32 + LCD实时绘制波形图”这套方案跑通了,中间踩了不少坑,有几个问题在教程和例程里几乎没人提。这篇文章就把完整的实现思路和排障过程复盘一遍,给想在LCD上做波形显示、又不打算一直插着上位机的朋友一条能直接“抄作业”的路线。
这篇内容适合这几类人:一是做传感器采集、需要快速验证信号趋势的,二是做手持式仪表或离线监测设备的,三是学习STM32的ADC、DMA、SPI和简单图形算法,想找个综合项目练手的。看完你至少能搞清楚三件事:采样链路怎么搭才不会卡死、波形怎么映射到屏幕坐标、以及那些让人想砸电脑的显示问题到底出在哪。
1. 先决策:波形放在LCD上是刚需,但别把它当示波器用
1.1 什么场景真正需要LCD波形图
我见过不少项目,数据采集部分做得挺完善,结果调试的时候只能对着串口助手看一堆十六进制数,或者用OLED屏刷新几个数字。看数值不是不行,但信号是缓变、抖动还是周期性异常,靠肉眼看数字效率非常低。
LCD实时绘制波形图的核心价值,是把“数据到底好不好”变成“一眼就能看出来”的事。最典型的场景有三个:
- 手持式检测设备,现场没有电脑,必须本地直接看波形形状;
- 产线快速检验,操作员不需要懂协议,只看波形包络是否正常就能判断;
- 嵌入式系统自检,开机后跑一段测试序列,把关键节点信号画在屏上,方便定位故障。
我在这个项目里遇到的也是同样的问题:传感器输出的是几十赫兹的缓变信号,我需要确认它的包络、有没有异常的尖峰,而不是精确计算频率和幅值。这种情况下,LCD波形图比任何数值列表都好用得多。
1.2 方案边界:哪些波形不建议硬塞给LCD
在动手之前必须先把预期拉到一个合理水平。LCD波形图不是示波器,它有几个硬边界:
第一是刷新率限制。普通的SPI接口TFT屏,整屏刷新也就每秒二十帧左右,如果还要做清屏、画网格、显示文字,实际有效刷新率会更低。这意味着输入信号的频率不能太高,否则你看到的不是波形,是一团乱麻。
第二是存储空间限制。以STM32F103为例,SRAM也就20KB到48KB,ADC采样缓存不可能开得很大,采样点数受限,分辨率自然受限。
第三是波形特征问题。如果你想抓毛刺、抓瞬态跳变、抓高频干扰,LCD方案不但无能为力,还会给你错误的信心。展示出来的平滑波形,很可能是因为采样率不够而丢失了细节。
用我自己的标准来划分:10kHz以上的连续信号,或者需要ns级分辨率的瞬态信号,老老实实上真正的示波器。LCD波形图最适合的是观察“轮廓”和“趋势”的信号,比如温湿度变化、电池放电曲线、低频传感器输出、PWM占空比调整结果这些场景。想清楚这个边界,后面做方案选型时就不会走弯路。
2. 下板子前的硬件规划:SPI屏、ADC和DMA怎么搭不返工
2.1 屏幕选型:为什么SPI接口TFT成了默认答案
ST7789、ILI9341这类SPI接口TFT屏,几乎成了这类项目的默认选择。原因很直接:引脚少、布线简单、驱动资料多、价格便宜。四线SPI加上CS、DC、RST、背光,总共七八根信号线,接在一个20Pin左右的排针上刚刚好,不管是飞线还是画PCB都非常舒服。
相比之下,并口屏虽然写入速度更快,但8位数据线加上控制线就要占用十几个GPIO,在引脚资源紧张的板子上很难安排。如果你的需求是刷视频级别的全屏动画,并口确实有优势;但只是画波形的话,SPI的带宽完全够用。
我在这个项目中用的是240x320分辨率的ILI9341屏,RGB565格式,一个像素占16bit。实际测试中,SPI时钟跑到18MHz,显示一屏波形并刷新网格,耗时可控制在十几毫秒级别,肉眼看起来就是“实时”的。要提醒一句,SPI时钟能不能跑高,和你的接线长度以及杜邦线质量关系很大,线太长或者太乱,高速下很容易出现花屏。
2.2 引脚分配里最容易忽视的三个细节
很多人在原理图阶段只关心功能能不能通,不太在意引脚分配,结果后面调试时处处被动。这里说三个我交过学费的细节:
第一个是DC引脚,也就是数据/命令选择脚。SPI本身没有这个信号,必须用GPIO控制。我建议把DC引脚单独分配到某个普通GPIO,并且把它的输出速度配置为高速。因为每传输一个字节都要先拉高或拉低DC,如果GPIO翻转速度太慢,会拖低整条SPI链路的吞吐量,尤其在高SPI时钟频率下很明显。
第二个是背光控制。很多人直接用一个高电平把背光点亮就完事了,这样也行,但很不灵活。更好的做法是把背光接到支持PWM输出的引脚上,通过调节占空比控制亮度。白天强光下可以把亮度拉高,夜间调低,同时还能省电。如果你的面板对亮度有要求,这一步在原理图阶段就要想好,别等软件写完了才发现引脚不支持定时器复用。
第三个容易翻车的地方是共用SPI总线。如果LCD和其他设备共用同一条SPI,务必保证各自的CS片选逻辑正确,DC信号不能和CS混在一起。我刚开始图省事,把DC脚和另一个外设的CS脚共用了,结果屏幕一刷新就干扰那个外设,查了半天才发现是片选竞争。
2.3 ADC、DMA和时钟:数据从哪来、怎么搬、走多快
STM32的ADC采集配置有一定套路,但参数细节非常影响效果。首先,ADC时钟不能直接拿系统时钟用,必须经过分频,保证ADC时钟在允许范围内。以F103为例,ADC时钟最高约14MHz,我配置在12MHz左右,单次12位转换大概需要14个ADC时钟周期,换算下来差不多1us完成一次转换,这个速度对低频信号绰绰有余。
其次,启用扫描模式还是单通道模式?如果只是采集一个通道的波形,最简单的做法是配置单通道连续转换模式,然后靠DMA把结果持续搬进内存。DMA选择循环模式,传输方向设置为外设到内存,外设地址固定为ADC数据寄存器地址,内存地址指向一个缓冲区数组。这样配置之后,ADC会自动不停地转,DMA会自动不停地把结果搬到数组里,CPU完全不用管采样这件事,只负责在需要的时候取数据来画图。
外部晶振这一块也顺便说一下。F103通常用8MHz晶振,它的负载电容选择是有讲究的:晶振规格书会给出一个负载电容CL,实际配置的两个谐振电容C1、C2需要满足CL约等于C1与C2串联后再加上引脚寄生电容。经验值上,CL为18pF到20pF左右的8MHz晶振,配18到22pF的谐振电容通常能正常起振。如果你发现系统时钟不稳定、串口乱码、外设行为诡异,先查晶振电容和起振波形,别一上来就怀疑程序。
3. 采样链路的核心:环形缓冲让ADC不拖累显示
3.1 为什么裸奔轮询ADC容易翻车
新手写波形显示最容易犯的错误,就是在主循环里直接读ADC:启动转换、等转换完成、读结果、存数组、然后画屏。这个过程看起来顺理成章,但在高采样率下会有一个严重问题——CPU被迫在ADC转换期间一直空等,什么也不能干。
打个比方,这就像你一边数入场人数,一边还要负责维护现场秩序,每来一个人你都要停下来等他登记完才继续干活。如果人流小,问题不大;一旦人流上来,办事效率立刻崩盘。显示波形时,如果采样加绘制全塞在主循环里,屏幕刷新就会一卡一卡,波形跟着一顿一顿,根本谈不上“实时”。
更合理的做法是“分工”:ADC负责采集数据,DMA负责搬运数据,CPU只负责把数据画到屏幕上。三者并行工作,采样过程不消耗CPU,CPU可以专心处理画图逻辑。这也是为什么这一节标题说“环形缓冲才是这套系统的心脏”——没有它,后面画图写得再漂亮也白搭。
3.2 环形缓冲与DMA中断配合的完整思路
要实现“边采集边显示”,最实用的方式是DMA缓冲区加中断标志配合。我使用的方案是:设置一个512字节(即256个16位ADC样本)的DMA缓冲区,启用DMA的半传输完成中断和全传输完成中断。DMA在传输完前半段时触发一次中断,CPU去处理前半段256个样本;与此同时DMA继续向后半段写入新数据;当后半段写完触发第二次中断时,CPU又能处理后半段数据。如此循环,就相当于一个“双段缓冲”,CPU和DMA交替工作,互不干扰。
关键代码逻辑可以覆盖为这样:
#define BUF_SIZE 512 uint16_t adc_buf[BUF_SIZE]; volatile uint8_t buf_half = 0; void DMA1_Channel1_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_HT1)) { // 半传输完成 DMA_ClearITPendingBit(DMA1_IT_HT1); buf_half = 0; // 前半段就绪 } if (DMA_GetITStatus(DMA1_IT_TC1)) { // 全传输完成 DMA_ClearITPendingBit(DMA1_IT_TC1); buf_half = 1; // 后半段就绪 } }主循环里判断buf_half的值,就知道该处理哪个半区。需要注意的是,主循环处理数据和DMA写入数据之间存在竞争关系:可能出现主循环还在处理前半段的最后一两个点,DMA已经开始往那个区域写新数据了。工程上通常容忍这种轻微竞态,因为ADC是连续数据,偶尔丢一两个点不影响波形显示。但如果你对数据完整性要求极高,就得用调整指针的方式精确切分,或者把缓冲区做成真正的环形队列,用读写索引来管理。
对于大多数显示场景,上面这种“半区切换”已经足够了。它比真正的环形队列省内存,逻辑也直观很多。
3.3 采样率、缓存深度与波形周期的关系
采样率不是越高越好,过高会让CPU在中断里疲于搬运数据,过低则波形会严重失真。这里有一个比较实用的计算思路。
先算采样率:ADC单次转换时间约14个ADC时钟周期,在12MHz ADC时钟下,理论采样率约为857kHz。但实际上DMA搬运、系统中断、绘制任务都会消耗资源,工程上把这个ADC用在几百kHz以内是稳妥的。对于LCD波形显示,我们根本不需要这么高的采样率。
再看显示需求。屏幕一行有240个像素,也就是说一条完整的居中波形至少需要240个采样点。假设屏幕每秒刷新20帧,那就需要240乘以20等于4800个点每秒,这个采样率跟ADC的能力相比微不足道。真正限制我们的是:如果你用4096点缓存去存储一段波形,那这一段波形能覆盖的时间就是4096除以采样率。采样率越高,单屏显示的时间窗越短,看到的波形细节越多但整体趋势越少;采样率越低,时间窗越长,但细节丢失。
在调试时我的经验是:先把采样率设得较低,比如5kHz,看整体包络;确认信号大致形状后,再逐步提高采样率去看细节。另外在显示前加一道简单的一阶低通或滑动平均,能有效减少LCD上看到的高频毛刺,对判断信号趋势很有帮助。这一点在参数调试图里尤为好用——你不会希望屏幕上全是噪声峰,而把真实的信号轮廓淹没了。
4. 画波形的算法细节:坐标换算、自动缩放与不闪屏刷新
4.1 从ADC原始值到屏幕坐标的换算
ADC采回来的数据是数值,屏幕显示需要坐标。这个换算看似简单,但有两个容易出错的地方。
第一个是Y轴方向。大多数人习惯数学坐标系里Y轴向上,但LCD屏幕的Y坐标原点在左上角,向下递增。也就是说,电压越高,显示时Y值应该越小,否则波形是上下颠倒的。转换公式可以这样写:
int16_t y = LCD_HEIGHT - (int16_t)((long)(value - vmin) * LCD_HEIGHT / (vmax - vmin));这里vmin和vmax是你期望显示的电压范围,如果你的信号在0到3.3V之间波动,可以设置vmin=0,vmax=4095(12位ADC满量程)。这样value是0时y等于LCD_HEIGHT(屏幕底部),value是4095时y等于0(屏幕顶部),符合直觉:电压高在上面。
第二个容易忽略的是溢出问题。公式里有乘法,value减去vmin后乘以LCD_HEIGHT,如果直接用uint16_t做乘法,很容易在临界处溢出,得到的结果会莫名其妙地跳到屏幕另一头。我习惯把所有中间量提升到int32或者long再计算,稳妥很多。
X坐标的处理相对简单,一般用采样点序号作为X,然后等比例映射到屏幕宽度。比如缓存里有240个点,那就正好一个点对应一个像素,极其直观。如果点数超过屏幕宽度,可以抽点显示;如果点数不足,可以插值或只显示原有位置。
4.2 垂直方向的自动缩放与抗噪
固定Y轴范围写起来简单,但实际使用时非常别扭。信号幅值只有几百mV时,你在0到3.3V的满量程里看,它几乎就是屏幕中间一条不怎么动的直线。用上位机软件看波形时,很多人习惯手动拖Y轴,但是LCD上没鼠标,所以必须做自动缩放。
我的实现方案是:每次从缓存里找出这一段数据的最大值和最小值,然后根据这两个值动态计算vmin和vmax,再留出10%左右的边距,让波形不会贴在屏幕边缘。这样一来,不管信号是大幅摆动还是微小波动,屏幕上都能看到明显起伏。
自动缩放有一个必须处理好的坑:异常毛刺。如果原始数据里有一个特别离谱的尖峰,最大值会被拉得极高,其他正常波形被压成一条扁线,画面反而没法看。解决思路是在求最大值和最小值之前,先对数据做一次简单的去极值处理,或者使用百分位截断——把排序后的1%最大值和1%最小值去掉,再映射Y轴。这个方法在实测中效果好得多。
还有一种折中方案:固定基础量程,然后用自动缩放只调整一个微调系数,让波形在量程内保持可见但不过度缩放。这对大多数传感器调试场景够用,而且实现简单、不容易出bug。
4.3 滚动刷新与局部更新:让画面不闪
波形显示如果采用“整屏清屏再重画”的办法,你会看到屏幕闪烁非常严重,尤其SPI屏写全屏整体较慢。我记得第一次跑通波形时,整个屏幕像老式电影一样狂闪,眼睛没看几分钟就受不了。
不闪屏的做法是局部更新。具体来说,上一帧每个X坐标有一个Y值,这一帧也有一个Y值,对比两者差异,只在变化的位置重画:先清掉旧的像素点,再画新的像素点。因为波形在时间轴上连续,相邻两帧之间的差异其实很小,局部更新的数据量比全屏刷新少一个数量级。
如果内存允许,可以开辟一块与屏幕波形区域大小一致的显存缓冲区,先把要显示的内容全部画进缓冲区,然后用一次DMA把整块缓冲区推送到LCD。这种“离屏渲染”的方式能彻底解决闪烁,因为屏幕内容不会分两次显示。F103的SRAM有限,全屏240x320的RGB565缓冲区要150KB,放不下;但只给波形区域开一个240x200的缓冲区也差不多96KB,依然偏大。所以实际项目里更多用局部重画,只在文字或网格等静态元素上用“脏矩形”策略做局部刷新。实测下来,配合适当的延时,屏幕上波形平滑滚动,肉眼看不出闪烁。
5. 踩坑实录:五个差点劝退项目的问题和对应解法
5.1 波形全是毛刺:先别怀疑代码,去查电源和地
我第一版程序跑起来后,屏幕上全是密密麻麻的毛刺,完全看不出信号轮廓。那时候我第一反应是采样代码写错了,反复查了很久的ADC配置,结果毫无进展。直到我用万用表量了一下板子供电,才发现电源纹波大得离谱,传感器信号在这种电源下早就被污染了。
这类问题在嵌入式开发里太常见了:模拟信号对电源噪声非常敏感,而数字电路的开关噪声又会通过地线耦合进ADC。基本对策有几个:模拟地和数字地在PCB上单点连接,不要在传感器信号线上走长线,ADC的参考电压如果可配置的话尽量用独立的基准源。对于飞线调试的场景,至少要做到传感器地和板子地短而粗地连接,电源端加足够的去耦电容。
我后来养成了一个习惯:遇到波形毛刺先做两个实验。一是把ADC输入直接接到地的参考电压上,如果采集出来的数据依然有大量跳变,那问题不在外部信号,而在电源或ADC配置。二是把传感器断开,短接输入,波形理论上应该是平的。哪个环节不平,就从哪个环节往下查,效率比东猜西猜高很多。
5.2 波形断断续续:DMA中断标志的细节
另一个让我头疼的问题是波形显示不是连续的一条线,而是一段一段地跳。这个问题卡了我大半个晚上,现象非常诡异:波形大部分时间正常,但每隔一段时间会有一个明显的断口,像被什么东西切了一刀。
排查后确认问题出在DMA中断处理上。半传输完成标志和全传输完成标志在中断服务函数里清除后,CPU还没来得及处理某个半区的数据,DMA已经再次写入了该半区,导致数据被覆盖。虽然理论上这种竞态很难出现,但在SPI刷新屏幕占用了较长CPU时间的情况下,确实会触发。
解决办法有几条路:一是缩短DMA半区操作的时间,比如把画屏操作拆分成更小的批次,避免长时间关中断;二是增大缓冲区,让CPU有更多时间处理数据;三是为每个半区增加一个“已处理”标志,主循环只在标志有效时才去读取对应半区的数据。如果用的是带Cache的M系列内核,比如某些M7芯片,还要在读取DMA写入的数据前做Cache一致性失效操作,否则会读到陈旧数据,这个细节在标准库里不会教,但踩一次就记住了。
5.3 调试器连不上:从接线到调试认证的排查
做这个项目过程中,我还遇到过一个更“劝退”的问题:程序写完后,下一板重新上电,ST-Link突然报错“No STM32 Target Found!”,一度以为芯片坏了。
排查顺序很重要,不要一上来就怀疑芯片。先检查3.3V供电是否正常,再测量RESET引脚电平是否被外部电容异常拉低,然后检查SWDIO和SWCLK的接线是否正确。很多时候所谓“连不上”,其实就是杜邦线松了,或者供电不够导致芯片没有正常工作。
还有一种情况是芯片内部开启了调试认证保护。较新的STM32系列支持Debug Authentication功能,如果代码里意外使能了调试保护,调试器就无法正常连接到内核。遇到这种情况,普通ST-Link会一直报错,解决办法只能是通过专用方式解除保护,或使用带相应授权的调试工具。从操作角度讲,我建议在项目初期就把调试接口的硬件复位和接线检查步骤固化下来,不要等烧完代码才发现连不上——那种感觉真的很绝望。
5.4 中文和字符显示乱码:取模与编码双双把关
画波形通常要在屏上显示“波形”、“电压”、“时间”这类中文标签,这一步也坑了不少人。最常见的问题有两种:一是取模方向不对,字模数据是按行扫描还是按列扫描没搞清楚,显示出来的汉字就是镜像的或者东倒西歪;二是编码不对,代码文件保存的是GBK字模,而编译器按UTF-8解析字符串,导致内存里查表索引对不上,显示出乱码。
我的建议是:把字模取模方式固定为“行列式扫描,高位在前”,并在取模软件里统一导出。显示函数按字节顺序一行一行地推送到LCD,不要在中途改变扫描方向。对于编码问题,最简单的方法是把字模索引和字符串编码全部统一成GB2312或者UTF-8中的一种,并且在工程配置里固定下来。不要用“看起来差不多”的方式拼字符串,容易在改版时不知不觉引入混编码。
调试时有一个技巧很有用:单独写一个测试函数,一个字符一个字符地打印,逐步缩小乱码范围,能快速定位是取模方向错,还是索引错。这个技巧帮我节省了大量时间。
5.5 画面出现残影:LCD极化现象的规避
如果你长时间让LCD显示同一个画面,尤其是那些基本不动的静态文字和网格线,时间长了会发现屏幕上有隐隐的残影,像“印”上去了一样。这就是所谓的LCD极化现象:液晶分子长时间处于同一偏置状态,导致响应变慢,换画面时残影需要很久才能消退。
规避方法有两个思路。一个是定期反转驱动极性,比如每隔一段时间把某一小块区域的数据整体取反,再取反回来,让液晶分子经历一次完整的翻转过程。另一个思路是避免长时间静止画面,对波形图来说,因为波形本身在不断变化,极化现象还好;但屏幕上的标题、时间戳这些元素容易成为“重灾区”。所以在设计显示界面时,尽量让静止元素和动态区域分开,并对标题栏这些区域做周期性的刷新或小范围滚动。
网上还有一种说法是“用波浪线滚屏来防残影”,实际对波形显示来说,只要波形在移动,本身就能起到防止极化的作用。真正要做的是设置一个软件看门狗,如果设备长时间处于静止状态,就定期自动刷新整个屏幕或者做一次反色闪烁,预防残影产生。
最后分享一点个人体会
这套“STM32+LCD波形图”做完之后,我最大的感觉是:它把嵌入式系统里几块核心能力全都串了起来——ADC采样、DMA搬运、中断管理、SPI通信、图形坐标算法、显示刷新策略。相比单独点个灯、读个传感器,这个过程能学到的东西多得多。
如果让我给正在做类似项目的人一个最实用的建议,那就是:调显示的时候,先在代码里生成一个已知的标准正弦波用于测试,用这个虚拟信号去验证显示链路,不要一上来就接真实传感器。等屏幕上能画出干净平滑的正弦波,再接入真实信号做缩放和抗噪处理,两者分开排查会轻松非常多。
后续如果还想继续扩展,可以在这个骨架上加按键切换通道、触控十字光标、波形峰值自动打点等功能,甚至可以引入FFT做频谱显示。基础架构稳定了,往上加东西都是水到渠成的事。
本文还有配套的精品资源,点击获取