emWin驱动SPI显示屏:一个嵌入式工程师的真实踩坑笔记

上周调试一块GC9A01小屏时,我第7次烧录固件后盯着那块忽明忽暗、边缘带紫边的320×240屏幕,突然意识到——这已经不是“能不能点亮”的问题了,而是 为什么每次改一行代码,花屏模式就换一种花样 。

这不是教程,是我在STM32H7 + emWin + SPI TFT这条路上,用示波器探头、逻辑分析仪和一沓打印出来的ST7789V数据手册撕下来的笔记。没有PPT式总结,只有真实发生过的错误、被忽略的细节,以及那些写在 // TODO: fix this 旁边却拖了三个月才真正解决的“小问题”。


从第一帧乱码开始:SPI时序从来不是“配对就行”

你肯定也试过:抄一份别人能跑的初始化代码,把DC引脚改成自己的GPIO, HAL_SPI_Transmit() 发个0x01复位命令……然后屏幕黑着,或者闪一下就灭。

别急着怀疑芯片坏了。先抓CS和SCK。

我在H7上第一次失败,是因为没注意 硬件NSS(即CS)和软件GPIO控制的时序语义完全不同 。
ST7789V手册里清清楚楚写着: tCSS ≥ 10 ns (CS建立时间), tCSH ≥ 10 ns (CS保持时间)。但当你用 HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET) 拉低CS,再调 HAL_SPI_Transmit() ——中间隔着函数调用开销、总线仲裁、甚至可能被SysTick打断。实测这段“软CS”操作在H7上最坏情况延迟达 320 ns ,远超芯片容忍极限。

结果?命令字节被吃掉一半,初始化序列错位,LCD进入未知状态。它不报错,只是沉默地拒绝响应。

✅ 真正在意时序的人,会这么干 :

// 关键:启用SPI外设的硬件NSS(不是GPIO模拟!)
hspi4.Init.NSS = SPI_NSS_HARD_OUTPUT; // 让SPI外设自己管CS
hspi4.Init.NSSP = SPI_NSS_PULSE_ENABLE; // 启用脉冲模式,自动收尾

这样, HAL_SPI_Transmit() 一触发,SPI硬件立刻拉低CS、发数据、自动抬高CS——整个过程在硬件状态机里完成, 抖动<2 ns ,完全满足tCSS/tCSH要求。

而DC引脚?别让它参与时序关键路径。ST7789V的DC只需要在每个字节发送前稳定即可。我们把它接到SPI的 TXE (发送寄存器空)中断里,在DMA搬完一个字节后立刻切DC——比轮询GPIO快一个数量级。

💡 小技巧:如果你用的是STM32G0/G4这类带“SPI增强模式”的MCU,甚至可以把DC复用为 NSS 的极性反向输出,实现零GPIO开销的命令/数据自动切换。


LCD_X_DrawBitmap() 不是搬运工,是时空协调员

emWin文档里说:“实现这个函数,你就完成了底层适配。”
听起来很简单。直到你发现:滚动列表时文字边缘发虚,动画帧率卡在12 FPS,触摸点总是偏右5像素。

问题不在算法,而在 坐标系统、内存布局与物理GRAM窗口三者之间的微妙错位 。

看这段看似标准的窗口设置代码:

LCD_IO_WriteReg(0x2A); // Column Address Set
LCD_IO_WriteData(pRect->x0 >> 8); LCD_IO_WriteData(pRect->x0 & 0xFF);
LCD_IO_WriteData(pRect->x1 >> 8); LCD_IO_WriteData(pRect->x1 & 0xFF);

你以为 pRect->x0 是emWin逻辑坐标系里的左上角X?没错。
但ST7789V的GRAM地址是从0开始连续编号的,而很多国产TFT模块(比如我手上的那块) PCB上把LCD的Y轴方向翻转了 ——也就是物理上第0行其实是屏幕最底下那一行。

结果?你告诉它画 (0,0)-(319,239) ,它真去GRAM地址0开始填,但填出来的图像被整个倒过来了。而emWin不知道这事,它还在按正向坐标渲染按钮、文字、图标……于是所有东西都“错位”了。

✅ 救回来的办法很土,但有效 :

// 在LCDConf.h里加这一行,让emWin内部坐标系翻转
#define LCD_MIRROR_Y 1
// 同时在LCD_X_Init()中配置MADCTL寄存器
LCD_IO_WriteReg(0x36); LCD_IO_WriteData(0xC0); // 0xC0 = MY=1, MX=0, MV=0 → Y镜像

更隐蔽的问题出在 像素格式交换 。
RGB565标准是: [R4 R3 R2 R1 R0 G5 G4 G3][G2 G1 G0 B4 B3 B2 B1 B0]
但ST7789V默认接收的是BGR排列(很多国产屏为了兼容旧设计),而emWin输出的 GUI_COLOR 经过 GUI__Color2Index_16() 转换后,是严格按RGB顺序组织的16位值。

所以你看到的“绿色变粉红、红色变青色”,其实是R和B字节被物理层互换了。

✅ 不要靠猜,用示波器看MOSI波形 :
发一个纯红像素(0xF800),如果MOSI上先出来 0x00 再出来 0xF8 ,说明字节序反了;如果先 0xF8 后 0x00 ,但颜色还是错的——那就是RGB/BGR问题。

最终解法只有一行:

#define LCD_SWAP_RB 1 // 在LCDConf.h中启用,emWin会自动交换R/B字节

⚠️ 注意:这个宏必须在 #include "LCDConf.h" 之前定义,否则无效。我为此浪费了两天,因为IDE缓存了旧的头文件依赖。


DMA双缓冲不是性能优化,是生存必需

320×240@16bpp = 每帧 153,600 字节 。
SPI跑25 MHz,理论吞吐≈3.125 MB/s,单帧传输耗时约 49 ms 。
如果用CPU轮询发送,每字节要进一次中断+寄存器操作,实测在H7上单帧要 210 ms ——意味着你连10 FPS都不到,GUI还怎么动?

但DMA双缓冲真正的价值,不在提速,而在 解除CPU与显示刷新的强耦合 。

没有双缓冲时, LCD_X_DrawBitmap() 必须等上一帧DMA彻底结束才能开始准备下一帧数据。一旦GUI主线程被某个USB中断卡住5ms,整条流水线就堵死,画面撕裂、触控延迟飙升。

启用双缓冲后,流程变成:
- Buffer A 正在DMA传输(屏幕显示中)
- CPU 在后台往 Buffer B 填充新数据(GUI仍在计算、响应触摸)
- Buffer A 传完瞬间,DMA自动切换到 Buffer B
- 下一帧绘制直接使用 Buffer A(已空闲)

这才是 GUI_MULTIBUF_Enable(2) 敢承诺“无撕裂”的底气。

✅ 实现要点:
- 缓冲区必须 16字节对齐 (H7的AXI总线要求);
- 使用 HAL_SPI_Transmit_DMA() 时,务必检查 HAL_SPI_STATE_BUSY_TX 状态,避免重入;
- 在 LCD_X_DrawBitmap() 末尾加一句 __DSB(); __ISB(); ,确保DMA描述符写入完成后再返回——否则某些编译器优化会把屏障指令移位,导致偶发丢帧。


稳定性陷阱:你以为是软件Bug,其实是电源在撒谎

项目交付前三天,客户现场测试反馈:“设备运行2小时后,屏幕右半边开始出现白色竖纹,重启后恢复。”

我们带着逻辑分析仪过去,抓了一整天SPI波形——一切正常。
又换示波器看VCC,发现3.3 V电源在持续负载下有 80 mV峰峰值噪声 ,集中在1–5 MHz频段。
查PCB:电源滤波电容离LCD接口太远,走线长达8 cm,成了天线。

ST7789V对电源噪声极其敏感。当VCC瞬时跌落超过5%,内部LVDS驱动器就会误判数据,表现为随机字节丢失——恰好对应白色竖纹(GRAM某列全被刷成0xFFFF)。

✅ 解决方案不是换更大电容,而是:
- 在LCD模块接口处就近放置 22 μF陶瓷电容 + 100 nF高频电容 (X7R,非Y5V);
- SPI信号线全程包地,两侧加地线屏蔽(guard traces),间距≤3W;
- 在 LCD_X_Init() 里加入冷热复位补偿:

// 工业级加固:根据温度调整延时系数
extern int32_t Get_Temperature_mC(void);
void LCD_X_Init(void) {
    int temp = Get_Temperature_mC();
    int delay_factor = (temp < 0) ? 2 : (temp > 6000) ? 15 : 1;

    LCD_IO_WriteReg(0x01); HAL_Delay(150 * delay_factor);
    LCD_IO_WriteReg(0x11); HAL_Delay(120 * delay_factor);
    ...
}

温度每变化10℃,tCSS参数漂移约8%,这点手册不会写,但量产批次测试会告诉你。


最后一点实在话

emWin + SPI TFT这条路,没有银弹。
你不会找到一份“开箱即用”的驱动,因为每一块屏的PCB布局、电源设计、背光驱动方式都不同;
你也不能指望 GUI_Init() 之后就万事大吉,因为GUI的内存分配策略、控件刷新粒度、双缓冲时机,都会反向影响SPI链路的稳定性。

真正有效的做法是:

  • 把示波器当日常工具,而不是“出问题才用”;
  • 在 LCD_X_DrawBitmap() 开头加 static uint32_t call_cnt++; if(call_cnt % 100 == 0) LED_Toggle(); ,用肉眼验证刷新频率是否稳定;
  • 写一个最小测试用例:只创建一个静态文本框,禁用所有动画、触摸、定时器,确认基础显示可靠后再叠加功能;
  • 永远相信数据手册,但更要相信你手里的那块实物——它可能和手册差了±15%的时序裕量。

如果你现在正对着一块不亮的SPI屏发愁,不妨先放下IDE,拿起万用表量一下VCC和RESET电压。
很多时候,答案不在代码里,而在那颗被焊歪了5度的0402电容下面。

欢迎在评论区分享你的“SPI屏玄学时刻”——那些让你凌晨三点删掉重写的行,往往藏着最硬核的真相。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐