简介:这份基于STM32的LCD实时波形绘制工程,面向学习嵌入式硬件与单片机显示技术的开发者,尤其适合已掌握GPIO、ADC基础、希望打通采集—处理—显示链路的进阶练习者。项目以3.5寸LCD为输出端,围绕LCD初始化、像素格式转换、波形坐标缩放与动态刷新展开,并给出缓冲区分行或逐点写入、中断与轮询刷新、DMA减轻CPU负担等实现思路,代码采用较清晰的模块化与面向对象组织,便于更换屏幕尺寸或调整分辨率、刷新率与定时器参数。
压缩包共136个文件,约3.08MB,包含35个.h头文件、33个.c源文件及Keil工程配置、HEX与AXF编译产物,另附STM32标准外设库中ADC、TIM、RCC、FLASH等驱动与LCD驱动源码,便于直接打开工程对照学习。目前已有4531人学习下载。读者可从中获得一套可运行的LCD波形显示参考实现,理解颜色数据组织、采样值归一化与实时刷屏的取舍,也能借鉴其工程结构与性能优化切入点,作为课程设计、数据采集可视化原型或二次开发的起点。
1. 用一块几十块的屏替代示波器:STM32 实时绘制波形图能解决什么
调电源纹波、看传感器输出、抓串口时序,很多人第一次想的是买台示波器,可一台入门设备的价格够买几十套 STM32 最小系统板加一块 2.4 寸 LCD。于是“基于 STM32 的 LCD 实时绘制波形图”这个需求在嵌入式圈子里反复被问起:单片机自己采、自己算、自己画,到底能不能顶上一台简易示波器?答案是可以,但边界很清楚——它适合低频、慢变、需要长时间盯着看的信号,比如温度曲线、称重传感器抖动、电机霍尔波形、音频包络。不适合高频、需要精确幅度校准的场合。**适合哪些人?**手上有 STM32F103/F407 一类开发板、会点 C、想给自制设备加一个“能看到信号”的窗口的从业者。坑主要集中在数据链路和刷屏策略上,而不是画线算法本身,下面按链路一层层拆。
2. 数据链路:从 ADC 采样到屏幕像素的四个环节
波形图看起来是“画”出来的,实际上是“搬”出来的。一条完整的链路是:定时器触发 ADC → DMA 把结果搬进内存 → CPU 在缓冲区里做映射和抽点 → LCD 按列刷新。任何一环拖后腿,屏幕上的波形就会抖、会撕裂、会“跑”。这一章把前三个环节拆开,把参数怎么定讲透。
2.1 定时器触发 + DMA 搬运,把采样和画图解耦
最常见的错误写法是在 ADC 转换完成中断里画点。采样率一上到 10kHz,中断里跑一次 LCD 打点(哪怕是并口)就是几微秒起步,中断频率直接吃掉大半 CPU,主循环连串口都收不完整。正确做法是让硬件自己跑:定时器的更新事件作为 TRGO 输出,触发 ADC 规则组转换,ADC 每次转换完成发 DMA 请求,DMA 把ADC1->DR搬进内存数组,全程 CPU 不参与。CPU 只在 DMA 半传输和传输完成两个中断里被叫醒一次,一次处理半个缓冲区。
/* 采样前端:TIM3 以 20kHz 更新事件输出 TRGO,硬件触发 ADC1 规则组 */ #define ADC_BUF_LEN 512 /* 必须是 2 的幂,方便用位与取模 */ static uint16_t g_adc_buf[ADC_BUF_LEN] __attribute__((aligned(4))); static void SampleChain_Init(void) { /* 1. 时钟先全开:ADC 走 APB2,DMA 走 AHB,TIM3 走 APB1 */ RCC->APB2ENR |= RCC_APB2ENR_ADC1EN; RCC->AHBENR |= RCC_AHBENR_DMA1EN; RCC->APB1ENR |= RCC_APB1ENR_TIM3EN; /* 2. 采样时基:72MHz / (PSC+1) / (ARR+1) = 20kHz */ TIM3->PSC = 0; /* 不分频,计数时钟 72MHz */ TIM3->ARR = 3599; /* 3600 个计数一个周期 -> 20kHz */ TIM3->CR2 = TIM_CR2_MMS_1; /* MMS=010:更新事件作为 TRGO 输出 */ /* 3. ADC1:外部触发选 TIM3_TRGO,右对齐,12 位,单通道 */ ADC1->SQR1 = 0; /* 规则组长度 = 1 */ ADC1->SQR3 = 0; /* 通道 0,按实际接线改 */ ADC1->SMPR2 = ADC_SMPR2_SMP0_2; /* 采样时间 41.5 周期 */ ADC1->CR2 = ADC_CR2_EXTSEL_2 | ADC_CR2_EXTSEL_1 /* TIM3_TRGO */ | ADC_CR2_EXTTRIG /* 允许外部触发 */ | ADC_CR2_DMA /* 转换完成发 DMA 请求 */ | ADC_CR2_ADON; ADC1->CR2 |= ADC_CR2_ADON; /* 老型号要写两次才唤醒 */ /* 4. DMA1 通道1:循环模式,半字宽度,指向 ADC1->DR */ DMA1_Channel1->CPAR = (uint32_t)&ADC1->DR; /* 外设地址固定 */ DMA1_Channel1->CMAR = (uint32_t)g_adc_buf; /* 内存地址 = 缓冲区首地址 */ DMA1_Channel1->CNDTR = ADC_BUF_LEN; /* 循环模式下自动重装 */ DMA1_Channel1->CCR = DMA_CCR_MINC /* 内存地址自增 */ | DMA_CCR_CIRC /* 循环,永不停止 */ | DMA_CCR_HTIE /* 半传输中断 */ | DMA_CCR_TCIE /* 传输完成中断 */ | DMA_CCR_PSIZE_0 /* 外设 16 位 */ | DMA_CCR_MSIZE_0 /* 内存 16 位 */ | DMA_CCR_PL_0; /* 优先级:中 */ DMA1_Channel1->CCR |= DMA_CCR_EN; TIM3->CR1 |= TIM_CR1_CEN; /* 启动后整条链路自动跑 */ }这段代码里有四个参数值得逐个抠。ARR=3599决定了采样率,改采样率只改这一个数,别去动 PSC,PSC 保持 0 能让时基误差最小。SMPR 采样时间要根据信号源阻抗选,信号源内阻大(比如接了个 100k 分压)就必须拉长到 239.5 周期,否则采出来全是漂的,这种问题在温度采集上特别常见,很多人以为是 ADC 精度不够,其实是采样电容没充够。CNDTR是 DMA 一轮搬多少个数,取 512 而非 500,是为了后面用idx & (LEN-1)代替取模运算,中断里少几十个周期。CIRC 循环位必须开,关了以后 DMA 搬完一轮就停,波形只画一屏就定格,这是新手最常踩的一个坑。
链路跑起来之后,CPU 端的入口就只剩两个中断回调:半传输(前 256 点满)和传输完成(后 256 点满)。回调里不要画图,只置一个标志或者把索引递交给主循环,把耗时的工作全部挪到while(1)里做。
2.2 双缓冲为什么必须:波形撕裂的成因与帧率预算
只用一个缓冲区,就会遇到经典的撕裂:DMA 正在往 buf 后半段写,CPU 同时在读前半段做映射,读到一半 DMA 指针绕回开头把前面的数据覆盖了,屏幕上就出现“新波形左半边接着旧波形右半边”的断层。解决方式是乒乓结构——把 512 点的缓冲区看作前后两个 256 点的半区,DMA 写后半区时 CPU 只读前半区,DMA 写前半区时 CPU 只读后半区,靠半传输和传输完成两个中断切换。
/* 乒乓读:中断只递索引,主循环处理“刚写满”的那一半 */ static volatile uint8_t s_half_ready = 0; /* bit0=前半满, bit1=后半满 */ void DMA1_Channel1_IRQHandler(void) { if (DMA1->ISR & DMA_ISR_HTIF1) { /* 前半区写满 */ DMA1->IFCR = DMA_IFCR_CHTIF1; s_half_ready |= 0x01; } if (DMA1->ISR & DMA_ISR_TCIF1) { /* 后半区写满 */ DMA1->IFCR = DMA_IFCR_CTCIF1; s_half_ready |= 0x02; } } static void Wave_Task(void) { if (s_half_ready & 0x01) { s_half_ready &= ~0x01; Render_Half(&g_adc_buf[0], ADC_BUF_LEN / 2); /* 读前半区 */ } if (s_half_ready & 0x02) { s_half_ready &= ~0x02; Render_Half(&g_adc_buf[ADC_BUF_LEN / 2], ADC_BUF_LEN / 2); } }注意s_half_ready是volatile,中断里做的是“或”而不是赋值,否则两个中断挨得近时会丢标志。Render_Half里做的事就是把 256 个采样点映射成屏幕列并刷出去。
接下来算一笔帧率预算,这决定了缓冲区该开多大。假设采样率 20kHz、屏幕宽度 240 列,一屏能装 240 个点,那么理论上填满一屏需要 12ms,也就是最高 83 帧。但实际瓶颈在刷屏:如果用的是 SPI 接口的 ILI9341 一类屏,单列刷新(清旧点、画新点、连线)大约要 15~20 微秒,240 列就是 4ms 左右,加上映射计算,一帧 8~10ms 是可以做到的。若采样率只有 1kHz,一屏装 240 点要 240ms,那波形是“一帧一帧跳”的,这时候反而应该做数据抽点,把采样率降到刚好匹配屏幕刷新率,或者把时基拉开显示更长的窗口。一句话原则:采样率 × 屏幕列数 得到的每帧时间,要落在刷屏时间的 1~3 倍之间,低于这个范围是浪费,高于这个范围是卡顿。
2.3 坐标映射:定点化把 12 位码值压进屏幕高度
采样值是 0~4095,屏幕可用高度比如是 220 行(上下各留 10 行边距)。每一帧都要做 256 次映射,如果每次写浮点除法,F103 没有 FPU,一次除法几百个周期,光映射就吃掉几毫秒。做法是把斜率提前算成 Q16 定点数,运行时只做一次乘法和一次右移。
/* 12 位采样值 -> 屏幕行号:y 越大越靠下,所以底部对应码值 0 */ #define Y_TOP 10 #define Y_BOTTOM 230 static int32_t s_slope_q16; /* 每 1 个 ADC 码对应多少像素,Q16 格式 */ void Map_Init(uint16_t vref_mv, uint16_t vmax_mv) { /* 只显示 0 ~ vmax_mv 这一段量程,超出部分被裁剪掉 */ uint32_t span_code = (uint32_t)vmax_mv * 4095u / vref_mv; if (span_code == 0) span_code = 1; s_slope_q16 = ((int32_t)(Y_BOTTOM - Y_TOP) << 16) / (int32_t)span_code; } static inline uint16_t Map_ToY(uint16_t code) { int32_t y = Y_BOTTOM - (((int32_t)code * s_slope_q16) >> 16); if (y < Y_TOP) y = Y_TOP; /* 上限裁剪,防止越界写显存 */ if (y > Y_BOTTOM) y = Y_BOTTOM; return (uint16_t)y; }Map_Init里的vmax_mv就是“满量程显示多少毫伏”,它是整个波形显示的唯一标尺。比如参考电压 3300mV、想看 0~3.3V 全量程,span_code就是 4095;想看 0~1V 的细节,span_code变成 1240,同样的码值波动就会在屏幕上放大三倍多,这是把 LCD 当“放大镜”用的关键。裁剪那两行不能省——信号一旦过冲,y会算到屏幕外,直接写显存就是花屏,这个 bug 一旦出现很难查,因为花屏位置会随信号漂移。inline加在Map_ToY上是必须的,256 次函数调用在 F103 上也是几百个周期的开销。
3. 驱动层落地:打点、画线与窗口刷新
数据准备好了,下一步是把它变成屏幕上的像素。这一层的差别直接决定波形是“流畅”还是“一顿一顿”。先算带宽,再选封装,最后做局部刷新。
3.1 SPI 还是并口:先算一笔带宽账
一块 320×240 的 16 位色屏,一帧全屏是 320×240×2 = 153600 字节。SPI 接口在 36MHz 时钟下理论 4.5MB/s,全屏刷一遍要 34ms,也就是满打满算 29 帧;实际扣掉命令开销和片选间隙,能跑到 20 帧就不错了。并口(FSMC 驱动)一次写 16 位,一个读-改-写周期几十纳秒,全屏刷一遍 3~5ms,差了一个数量级。
| 接口方式 | 典型时钟/时序 | 全屏理论耗时 | 适用场景 |
|---|---|---|---|
| SPI 软件模拟 | 几 MHz | 200ms 以上 | 只做静态显示,不做波形 |
| SPI 硬件 DMA | 18~36MHz | 34~68ms | 单列局部刷新,240 列约 5~10ms |
| 8080 并口(GPIO 推) | 数十 ns 级 | 10ms 左右 | 中等,占用引脚多 |
| FSMC / 外部总线 | 总线时序 | 3~5ms | 多通道、高刷新率首选 |
结论很直白:**做实时波形,全屏刷新是绝路,必须走局部刷新。**SPI 屏也能做波形,前提是只刷发生变化的那几列,而不是每帧清屏重画。如果手头是 SPI 屏又要画多条通道,那就把列宽降下来——320 列里只用 200 列显示波形,剩下的放文字。
3.2 从打点到画线:三级封装的取舍
驱动封装一般分三级:打点、画线段、画波形。打点是最底层,只有一个“设窗口 + 写一像素”的动作;画线是 Bresenham;画波形不用通用画线,而是用“竖线连接法”——因为波形的 x 坐标是单调递增的,每列只需要从上一列的 y 到这一列的 y 画一条竖线,比通用 Bresenham 快很多,而且不会出现斜线的锯齿断裂。
/* 一级:打点。所有绘图最终都落到这里 */ void LCD_DrawPoint(uint16_t x, uint16_t y, uint16_t color) { LCD_SetWindow(x, y, x, y); LCD_WriteData16(color); } /* 二级:竖直线段,波形绘制的主力,一条命令窗口内连续写 */ void LCD_DrawVLine(uint16_t x, uint16_t y0, uint16_t y1, uint16_t color) { uint16_t n; if (y0 > y1) { uint16_t t = y0; y0 = y1; y1 = t; } n = y1 - y0 + 1; LCD_SetWindow(x, y0, x, y1); while (n--) LCD_WriteData16(color); /* 并口可用 DMA 批量推 */ } /* 三级:波形单列。擦旧 + 画新 + 连线,三件事一次窗口搞定 */ void Wave_DrawColumn(uint16_t x, uint16_t y_new, uint16_t y_prev, uint16_t fg, uint16_t bg) { LCD_DrawVLine(x, Y_TOP, Y_BOTTOM, bg); /* 先整列清背景 */ if (y_prev == 0xFFFF) { /* 首列没有前一点 */ LCD_DrawVLine(x, y_new, y_new, fg); return; } /* 用竖线把前后两点连起来,信号跳变时也不会断 */ LCD_DrawVLine(x, y_prev < y_new ? y_prev : y_new, y_prev < y_new ? y_new : y_prev, fg); }Wave_DrawColumn里“先整列清背景”这一步是整篇文章里最容易被优化掉、也最容易被优化错的。有人为了省时间只擦旧点附近三个像素,结果信号幅度突变时屏幕上留下残影拖尾,越跑越脏。整列清一次是 221 行的写入量,在并口下不到 20 微秒,完全值得。y_prev == 0xFFFF这个哨兵值用来标记“这一列是窗口的第一列”,避免画出从屏幕顶部到当前点的竖线——这个 bug 我第一次做的时候调了整整一个下午。
3.3 局部刷新:让每一列只被写一次
真正的实时波形不是每帧重画整屏,而是像老式示波器的余辉一样,一列一列往前推。做法是维护一个写指针x_cursor,每来一个新采样点就画一列,然后x_cursor++,走到最右边就绕回 0,同时把 0 列整列清掉,形成“滚动 + 覆盖”的效果。
/* 滚动刷新:新点永远画在游标处,游标绕回时那一列自然被覆盖 */ static uint16_t s_x_cursor = 0; static uint16_t s_y_prev = 0xFFFF; void Wave_Push(uint16_t code) { uint16_t y = Map_ToY(code); if (s_x_cursor == 0) s_y_prev = 0xFFFF; /* 绕回,断掉连线 */ Wave_DrawColumn(s_x_cursor, y, s_y_prev, COLOR_FG, COLOR_BG); s_y_prev = y; s_x_cursor++; if (s_x_cursor >= WAVE_W) s_x_cursor = 0; /* WAVE_W 通常取 240 或 300 */ }这段逻辑有个隐藏的取舍:滚动模式下,屏幕左边是“最新的数据”,还是“最旧的数据”?上面的写法里,游标从左到右推进,所以左边是旧数据、右边是新数据,绕回时新数据会从左边覆盖旧数据——视觉上像是波形在“原地刷新”,而不是“向左滚动”。想要示波器那种向左滚的效果,就把游标反向走,或者干脆用memmove的方式:每来一列,整屏数据左移一列,新数据画在最右边。后者每次要重画整屏,代价大,只有在并口屏上才划算。SPI 屏老老实实用原地覆盖,并口屏可以上滚动。
还有一个常见做法是“脏列标记”——只在数据变化超过阈值(比如 2 个像素)时才刷新那一列,静止信号下 CPU 占用能降到接近 0。这个优化在只看缓变信号时非常好用,但要注意:一旦启用,屏幕上的旧波形不会被擦掉,看起来像“余辉”,需要配合定期的整屏清除。
4. 实时性调优:让波形不抖的四个参数
波形画出来了,但抖、闪、跳,是下一个要解决的问题。抖动基本来自三处:采样时基不准、中断优先级打架、像素数少于采样点。这一章逐个拆。
4.1 采样率、时基与触发方式:先决定“一屏看多久”
时基是波形显示里最像“玄学”的参数。同样一个 50Hz 的正弦,采样率设 20kHz、一屏 240 点,窗口长度只有 12ms,连一个完整周期都看不到;采样率设 2kHz、一屏 240 点,窗口 120ms,能看到 6 个周期,但每个周期只有 40 个点,波形会有明显的折角。正确的算法是反着来:先确定“一屏要显示多长时间”(时基),再倒推采样率。
比如想看 100ms 的窗口、屏幕 240 列,那么每列代表 416 微秒,采样率取 1/416us ≈ 2.4kHz 就够了;如果信号里有 1kHz 以上的成分,按奈奎斯特至少要 2 倍,工程上取 5~10 倍,那就得把采样率提到 10kHz,然后靠抽点把 1000 个点压成 240 列。抽点在 4.3 节展开。
触发方式决定了波形“站不站得住”。自由运行模式下,每帧的起始相位随机,屏幕上看到的就是一条左右乱窜的波形。解决办法是加一个边沿触发:在一段缓冲区里找第一个上升沿(前一点小于阈值、当前点大于阈值),从那个位置开始往屏幕上画。这样每一帧都从同一个相位开始,波形就是“定住”的。触发电平取信号峰峰值的中点附近,用代码自动算:
/* 上升沿触发:返回触发点在缓冲区中的下标,找不到返回 0 */ static uint16_t Trigger_FindRising(const uint16_t *buf, uint16_t len, uint16_t level) { for (uint16_t i = 1; i < len; i++) { if (buf[i - 1] < level && buf[i] >= level) return i; /* 找到了,从这一点开始画 */ } return 0; /* 没触发,退化成自由运行 */ } /* 自动电平:取缓冲区最大最小值的中间,避免手调 */ static uint16_t Trigger_AutoLevel(const uint16_t *buf, uint16_t len) { uint16_t vmin = 0xFFFF, vmax = 0; for (uint16_t i = 0; i < len; i++) { if (buf[i] < vmin) vmin = buf[i]; if (buf[i] > vmax) vmax = buf[i]; } return (uint16_t)(((uint32_t)vmin + vmax) / 2); }Trigger_FindRising的循环上限是len而不是len-1,是为了防止i+1越界;level用自动电平能省掉一个电位器,但要注意信号本身有直流偏置时,自动电平会把触发点定在偏置上,波形看起来还是会轻微左右晃——这时候要么加迟滞(要求上升沿超过 level + 2% 量程),要么在映射阶段先减去直流均值。迟滞的实现只需把条件改成buf[i-1] < level - hyst && buf[i] >= level + hyst。
4.2 中断优先级:谁可以打断谁,必须提前定死
采样链路一旦用上 DMA + 定时器,中断就不再只有 LCD 一个了。典型的冲突是:DMA 半传输中断正在处理数据,串口接收中断插进来跑了 200 微秒,等回来的时候 DMA 已经把半个缓冲区覆盖了。表现出来就是波形上随机出现一小段“错位”的锯齿。
规则很简单:**采样链路的 DMA 中断优先级必须高于所有通信类中断,低于任何硬件故障类中断。**在 Cortex-M 的 NVIC 里给 DMA1_Channel1 设抢占优先级 0 或 1,串口设 3,SysTick 设 15。同时把 DMA 中断里的工作量压到最小——只置标志,不做映射。映射和刷屏全部放到主循环,主循环里再关一次全局中断把数据memcpy到工作缓冲区,拷贝 512 个半字只要几微秒,代价可以接受。
/* 主循环里取一份快照,避免渲染期间 DMA 又写回同一块内存 */ static uint16_t s_snapshot[ADC_BUF_LEN / 2]; static void Take_Snapshot(const uint16_t *src) { __disable_irq(); /* 关中断,拷贝,开中断 */ for (uint32_t i = 0; i < ADC_BUF_LEN / 2; i++) s_snapshot[i] = src[i]; __enable_irq(); }关中断的时间要控制在 10 微秒以内,超过就会影响串口收字节。512 个半字的拷贝在 72MHz 的 M3 上大约 3~5 微秒,勉强合格;缓冲区开到 2048 就危险了,这时候应该改用双缓冲指针交换,而不是拷贝。
4.3 抽点与峰值保持:屏幕像素比采样点少的时候
当一屏的采样点数远大于屏幕列数(比如 1000 个点画 240 列),直接隔 N 个取一个会丢掉窄脉冲——一个只持续 2 个采样点的毛刺,抽点后完全消失,你以为信号很干净,实际上有故障。正确做法是峰值保持:把落在同一列里的所有采样点取最大值和最小值,在这一列上画一条从 min 到 max 的竖线。这样任何毛刺都会以竖线的形式显示出来,宁可“看起来脏”,也不能漏掉。
/* 峰值保持抽点:把 src 的 len 个点压成 dst_w 列,每列保留 min/max */ void Decimate_PeakHold(const uint16_t *src, uint16_t len, uint16_t *out_min, uint16_t *out_max, uint16_t dst_w) { uint16_t step_q8 = (uint16_t)(((uint32_t)len << 8) / dst_w); /* 每列步长,Q8 */ for (uint16_t x = 0; x < dst_w; x++) { uint16_t start = (uint16_t)(((uint32_t)x * step_q8) >> 8); uint16_t end = (uint16_t)(((uint32_t)(x + 1) * step_q8) >> 8); uint16_t vmin = 0xFFFF, vmax = 0; if (end > len) end = len; for (uint16_t i = start; i < end; i++) { if (src[i] < vmin) vmin = src[i]; if (src[i] > vmax) vmax = src[i]; } out_min[x] = vmin; out_max[x] = vmax; } }step_q8用 Q8 定点表示每列跨多少个采样点,避免浮点。注意end的边界裁剪——当len不是dst_w的整数倍时,最后一列会越界,这个越界不会立刻崩溃,但会读到数组外的随机值,表现为屏幕最右边一列总是乱跳。渲染时把out_min[x]和out_max[x]都映射成 y,然后调用LCD_DrawVLine画一条竖线,毛刺就保住了。代价是波形看起来比实际“粗”,静态信号下会显示成一团 2~3 像素厚的带状——这是正常的,不是 bug。
5. 避坑与排查:五个现场翻车记录
下面这几条都是我在实际项目里踩过的,按“现象 → 原因 → 解决”写,遇到类似症状可以直接对号入座。
5.1 显示层:花屏、残影、刷新慢
**现象一:屏幕上偶尔出现一条斜着贯穿全屏的亮线,位置每次都不一样。**原因几乎总是坐标越界:Map_ToY返回了负值或者超过屏幕高度的值,被强转成uint16_t后变成一个巨大的数,窗口设置命令收到这个值后把显存地址算飞了。解决是把映射函数的上下裁剪写死,并且在LCD_SetWindow里再兜一层if (x >= LCD_W || y >= LCD_H) return;。这层防御看着冗余,但它把“偶发花屏”变成了“偶发丢点”,后者肉眼根本看不出来。
**现象二:波形跑一段时间后,屏幕上留下越来越厚的拖影。**原因是刷新时只擦了旧点附近,没有清整列。信号幅度大范围跳变时,旧点和新点之间的区域没被覆盖,累积成残影。解决就是回到 3.2 节的Wave_DrawColumn,每列先整列刷背景再画前景。如果嫌整列清屏慢,可以退一步:清min(y_prev,y_new)-2到max(y_prev,y_new)+2这一段,比整列省一半时间,又不会留残影。
**现象三:SPI 屏刷到 240 列时明显卡顿,CPU 占用率打到 90%。**原因是每列刷新都重新发了一遍“设置窗口”命令,一列四条命令加数据,命令开销比数据还大。解决办法是合并窗口:波形是连续列的,可以一次性设置一个 16 列宽的窗口,把 16 列的数据打包进一个 DMA 缓冲区推出去。代价是要多做一次内存组包,在 F103 上大约 30 微秒,但省下的 SPI 命令时间是这个的好几倍。
5.2 数据层:抖动、丢点、量程不对
**现象四:波形整体在左右方向上轻微晃动,像喝了酒。**如果已经加了触发还是晃,八成是触发点落在了缓冲区末尾附近,导致后面补的是下一轮的数据,相位对不上。解决办法是把触发搜索范围限制在缓冲区的前 3/4,并且要求触发点之后至少还有dst_w个点可用,不够就丢弃这一帧。另一个隐蔽原因是采样时基不是整数分频——ARR算出来带小数被截断,实际采样率和设定值差千分之几,长时间看会缓慢漂移。这种漂移很难查,建议在初始化时把实际分频值打印出来核对一遍。
**现象五:接 3.3V 信号时波形顶到屏幕上边就削平了。**先查参考电压:如果Map_Init里传的vref_mv是 3300,但实际 VDDA 只有 3250,满量程就会差一点。再查采样时间:信号源内阻大、采样时间短,ADC 采到的值会系统性偏低,表现为波形整体压低且顶部变圆。还有一种情况是输入超过 VDDA,被内部保护二极管钳位,波形顶部削平的同时 ADC 输入引脚会有微弱漏电流,长时间这么接会损伤引脚。量程标定的正确做法是接一个已知电压(比如用基准源或电池)跑一遍,把vref_mv反算出来,而不是照抄手册。
6. 进阶:从“看得见”到“读得出”,加一层参数测量
波形画出来只解决了一半问题,真要用起来还得能读数。在渲染链路后面挂一个轻量的测量层,对每一帧的快照算几个特征值,成本很低但价值很高。
| 测量项 | 算法 | 注意点 |
|---|---|---|
| 峰峰值 | max - min | 要跳过触发点之前的无效段 |
| 平均值 | 累加后除以点数 | 用uint32_t累加,512 点不会溢出 |
| 有效值 | 平方和累加后开方 | 开方用整数牛顿迭代,别引入 libm |
| 频率 | 找相邻两个上升沿的下标差 | 下标差 × 采样周期,噪声大时要加迟滞 |
| 占空比 | 高电平点数 / 总点数 | 阈值取自动电平 |
频率测量的实现要点是“下标差”而不是“数周期”:f = fs / (idx2 - idx1),其中fs是采样率。这个方法在低频下误差大(下标差可能有 ±1 的量化误差),信号周期越长越准。想要更准就用线性插值:触发点落在两个采样点之间,按斜率插出小数部分,能把误差降到 1/10 个采样周期。
/* 用插值提高触发点精度:在 last<level<=cur 的区间里线性插值 */ static float Trigger_SubSample(const uint16_t *buf, uint16_t idx, uint16_t level) { float a = (float)buf[idx - 1]; float b = (float)buf[idx]; if (b == a) return (float)idx; return (float)(idx - 1) + (level - a) / (b - a); /* 返回小数下标 */ }有了小数下标,两个触发点相减得到的就是带小数的周期数,频率精度直接上一个台阶。这个技巧在测 50Hz 工频、测电机转速时特别有用,因为整数下标的量化误差在低频段占比很大。
最后说一个我自己的习惯。早期做这类项目,我总喜欢先把画图调通,看着波形出来就觉得成了,结果一到现场就发现数据是错的、量程是错的、触发是晃的,只能从头返工。从那以后我每次做采样显示类的东西,都强制走一遍“先存后画”的流程:先把原始数据通过串口整块 dump 到上位机,确认数值、量程、时基全对,再打开 LCD 渲染。这一步多花半小时,能省掉后面几天的排查。希望帮到你。
本文还有配套的精品资源,点击获取