如何在STM32上用SPI驱动TFT屏跑通LVGL?一个字:稳
最近做项目,客户要个小巧的HMI面板——成本压得死死的,还想要动画流畅、界面好看。主控选了STM32F407,屏幕是常见的1.3寸ST7789 SPI屏,分辨率240×240。看起来不难?但真动手才发现,从点亮屏幕到跑起LVGL,中间全是坑。
别看网上一堆“三步搞定LVGL”的教程,等你一上电,不是黑屏就是花屏,刷新卡得像幻灯片。这背后其实是SPI通信、LCD初始化、帧缓冲管理、LVGL对接这一整套系统的协同问题。今天我就把这套完整链路拆开讲透,不玩虚的,只讲实战中踩过的雷和绕过的弯。
为什么非得用SPI接TFT?便宜是真的,限制也不少
现在主流MCU引脚越来越紧张,尤其是要做小型化产品时。如果用RGB并口接TFT,动辄16根数据线+控制线,光PCB布线就能劝退一波人。而SPI呢?只需要SCK、MOSI、CS、DC四根线,有些还能省掉CS(固定拉低),简直是资源紧张项目的救星。
但代价也很明显:带宽有限。
我们来算一笔账:
- 屏幕分辨率:240×240
- 每像素格式:RGB565 → 占2字节
- 一帧总数据量 = 240 × 240 × 2 = 115,200 字节 ≈ 112.5KB
- 若目标帧率30fps → 总吞吐需求 = 3.375 MB/s
再看SPI的实际能力。假设你主频80MHz,SPI分频为4,得到SCLK=20MHz。由于SPI每周期传1位,理论最大速率是2.5MB/s(20Mbps ÷ 8)。再加上命令开销、DMA启动延迟、协议间隔,实际有效传输往往不到2MB/s。
结论很现实:20MHz SPI带240×240的LVGL界面,已经接近极限。
所以,想让GUI“顺滑”,不能靠蛮力堆速度,得靠机制优化——比如DMA、双缓冲、局部刷新。这些才是关键。
先让屏幕亮起来:SPI时序对不对,决定成败
很多开发者以为,只要代码烧进去,屏幕就该亮。结果通电后一片漆黑,查半天发现是SPI模式错了。
SPI Mode 到底怎么选?
TFT控制器对SPI极性(CPOL)和相位(CPHA)有严格要求。比如ST7789常用的是Mode 3:空闲时钟高电平(CPOL=1),第二个边沿采样(CPHA=1)。
如果你配置成Mode 0,虽然也能传数据,但可能在第一个上升沿就读错了,导致命令乱套。
所以第一步,打开你的LCD规格书,找到Timing Diagram那一节。重点看SCLK在发送命令/数据时的状态。
STM32 HAL库配置如下:
hspi2.Instance = SPI2; hspi2.Init.Mode = SPI_MODE_MASTER; hspi2.Init.Direction = SPI_DIRECTION_1LINE; // 只发不收 hspi2.Init.DataSize = SPI_DATASIZE_8BIT; hspi2.Init.CLKPolarity = SPI_POLARITY_HIGH; // CPOL = 1 hspi2.Init.CLKPhase = SPI_PHASE_2EDGE; // CPHA = 1 hspi2.Init.NSS = SPI_NSS_SOFT; hspi2.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4; // PCLK=80MHz → SCLK=20MHz hspi2.Init.FirstBit = SPI_FIRSTBIT_MSB;注意Direction设为1LINE,因为我们不需要读取屏幕状态(MISO可悬空或不用连接),这样能减少干扰风险。
LCD控制器初始化:顺序不能乱,延时不能少
你以为发个0x11(退出睡眠)就能唤醒屏幕?Too young.
我曾经试过简化初始化流程,删掉几个“看起来无关紧要”的伽马校正设置,结果屏幕偏色严重,像是蒙了一层红膜。后来才知道,某些寄存器会影响内部电源模块的稳定,跳步会导致驱动电压异常。
以ST7789为例,典型的初始化序列必须包含以下几个阶段:
| 阶段 | 关键操作 |
|---|---|
| 上电等待 | 延时至少120ms,确保内部LDO稳定 |
| 退出睡眠 | 发送0x11,然后延时120ms |
| 设置色彩格式 | 0x3A+0x55表示16位RGB565 |
| 内存访问控制 | 0x36设置旋转方向和BGR顺序 |
| 设置显示窗口 | 0x2A,0x2B定义X/Y范围 |
| 开启显示 | 0x29 |
其中最容易忽略的是DC引脚控制。这个引脚决定了当前传的是命令还是数据:
void lcd_write_command(uint8_t cmd) { HAL_GPIO_WritePin(LCD_DC_GPIO_Port, LCD_DC_Pin, GPIO_PIN_RESET); // DC=0: command HAL_SPI_Transmit(&hspi2, &cmd, 1, HAL_MAX_DELAY); } void lcd_write_data(uint8_t *data, size_t len) { HAL_GPIO_WritePin(LCD_DC_GPIO_Port, LCD_DC_Pin, GPIO_PIN_SET); // DC=1: data HAL_SPI_Transmit(&hspi2, data, len, HAL_MAX_DELAY); }千万别图省事把所有数据都当命令发!否则控制器根本不会进入GRAM写入模式。
还有一个隐藏陷阱:部分型号需要特定的“厂商指令”才能激活高级功能。例如GC9A01需要先发0xFF解锁寄存器权限,否则改不了旋转角度。这类细节只能靠翻数据手册,没有捷径。
LVGL怎么接上来?核心在于flush_cb
LVGL本身不管硬件,它只负责计算哪些区域变了,然后告诉你:“喂,这块矩形该重绘了。” 至于你怎么把像素刷到屏幕上,它不管你。
这就是flush_cb的作用——你注册一个回调函数,LVGL每次需要刷新时就会调它。
最简单的实现方式是直接轮询SPI发送:
void lcd_flush(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_map) { int32_t w = area->x2 - area->x1 + 1; int32_t h = area->y2 - area->y1 + 1; lcd_set_window(area->x1, area->y1, area->x2, area->y2); // 设置GRAM窗口 lcd_write_command(0x2C); // 写GRAM for (int i = 0; i < w * h; i++) { uint16_t color = color_map[i].full; uint8_t data[2] = {color >> 8, color & 0xFF}; lcd_write_data(data, 2); } lv_disp_flush_ready(drv); // 必须调!否则LVGL卡住 }但这玩意儿会阻塞CPU几毫秒,期间啥也干不了。按钮按下去半天没反应,用户体验直接崩盘。
怎么办?上DMA!
真正提升体验的关键:DMA + 中断 + 双缓冲
为什么必须用DMA?
SPI传输大量像素数据时,如果用CPU轮询,相当于让你一边跑步一边数脚印。而DMA就像雇了个搬运工,你只管下单,剩下的他全包了。
改造后的刷新函数:
void lcd_flush(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_map) { int32_t w = area->x2 - area->x1 + 1; int32_t h = area->y2 - area->y1 + 1; lcd_set_window(area->x1, area->y1, area->x2, area->y2); lcd_write_command(0x2C); // 启动DMA异步传输 HAL_SPI_Transmit_DMA(&hspi2, (uint8_t *)color_map, w * h * 2); }同时,在stm32xx_it.c中实现中断回调:
void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi == &hspi2) { lv_disp_flush_ready(&disp_drv); // 通知LVGL:这次刷新完了 } }这一招让CPU得以解放,可以继续处理触摸事件、运行动画逻辑,整体响应速度立马上来。
双缓冲防撕裂
单缓冲有个致命问题:前半屏还没刷完,后半屏的内容已经被LVGL改写了,结果画面“上下错位”。
解决方案:准备两块内存,一块用于显示(前台),一块用于绘制(后台)。等DMA完成后再交换角色。
LVGL原生支持这个机制:
static lv_disp_draw_buf_t draw_buf; static lv_color_t buf_1[240 * 10]; // 每行缓冲,降低内存占用 static lv_color_t buf_2[240 * 10]; lv_disp_draw_buf_init(&draw_buf, buf_1, buf_2, 240 * 10); disp_drv.draw_buf = &draw_buf;注意:如果你的RAM不够(比如只有128KB SRAM),不要傻乎乎分配整屏双缓冲(240×240×2×2 ≈ 225KB),肯定爆。可以用部分行缓冲策略,LVGL会自动分批刷新。
实战中那些“离谱”的Bug是怎么解决的?
问题1:屏幕一闪一闪,像是接触不良
排查了半天硬件,最后发现是忘了调lv_disp_flush_ready()。因为DMA完成后没通知LVGL,系统以为还在刷新,下一帧又来了,造成重复刷新冲突。
✅ 解决方案:确保每个DMA传输完成后都调一次lv_disp_flush_ready()。
问题2:颜色发红,像是滤镜没关
查了很久,原来是字节顺序反了。ARM小端架构下,lv_color_t存储是低位在前,但SPI发送时要求高位先行(MSB first)。如果不处理,RGB565会被拆成[G|R][B|G]这种错位组合。
✅ 解决方案:启用硬件字节交换,或者预处理颜色数组:
// 方法一:使用HAL自带的16位DMA(需SPI支持) hspi2.Init.DataSize = SPI_DATASIZE_16BIT; HAL_SPI_Transmit_DMA(&hspi2, (uint16_t *)color_map, w * h); // 方法二:软件翻转字节(兼容性更好) for (int i = 0; i < w * h; i++) { uint16_t c = color_map[i].full; tx_buffer[i*2] = c >> 8; tx_buffer[i*2 + 1] = c; }推荐方法二,更可控。
问题3:初始化成功,但显示内容不动
原来是忘了在主循环里调lv_timer_handler()。这个函数是LVGL的“心跳”,负责处理动画、输入扫描、刷新调度。不调它,界面就跟静态图片一样。
✅ 正确做法:
while (1) { lv_timer_handler(); // 必须定期调用 HAL_Delay(5); // 控制调用频率,约20ms一次 → 50fps上限 }资源不够怎么办?裁剪与妥协的艺术
不是所有项目都能上外部SDRAM。面对片上RAM捉襟见肘的情况,我们必须学会“精打细算”。
几个有效的节省手段:
| 技术 | 效果 | 注意事项 |
|---|---|---|
| 关闭抗锯齿 | 节省~15%渲染时间 | 文字边缘变锯齿 |
| 使用单缓冲 | 内存减半 | 可能出现撕裂 |
| 缩小缓冲区尺寸 | 如每行10行像素 | 刷新次数增加,CPU负载略升 |
| 禁用阴影/渐变 | 显著降低GPU压力 | UI视觉降级 |
| 使用更低分辨率字体 | 减少内存占用 | 小字号可能模糊 |
我的经验是:优先保证交互流畅性,其次才是美观。用户宁愿看到简洁但响应快的界面,也不愿等3秒才弹出的“精美”菜单。
最终效果:在STM32F407上跑出接近30fps的LVGL界面
经过上述优化,最终在我的开发板上实现了:
- 平均刷新延迟:<16ms(目标60fps)
- CPU占用率:峰值约45%,空闲时<10%
- 内存使用:双缓冲共约48KB(分块式)
- 触摸响应:按下即反馈,无卡顿
虽然比不上高端平台,但对于一个成本不过百元的嵌入式设备来说,已经足够胜任大多数工业控制、智能家居面板的需求。
结语:别指望一键起飞,真正的流畅来自细节打磨
很多人搜“LVGL教程”,希望找到一个复制粘贴就能跑的例程。但现实是,每一个稳定运行的GUI背后,都是对SPI时序的理解、对初始化流程的敬畏、对内存模型的权衡。
技术没有银弹。你能走多远,取决于你愿不愿意去读那本枯燥的数据手册,愿不愿意为几毫秒的延迟反复调试。
下次当你面对一块黑屏时,不妨问问自己:
是我真的配齐了所有初始化命令?
DMA真的传完了才通知LVGL吗?
缓冲区有没有被并发访问?
把这些细节抠明白了,屏幕自然就亮了。
如果你也在做类似项目,欢迎留言交流踩过的坑。毕竟,嵌入式这条路,从来都不是一个人在战斗。