最近在做一块 STM32F767ZI 开发板的外设扩展,外接 Waveshare 4.0 寸 LCD Shield(SKU13587),主控是 ILI9486,跑 SPI 接口。刚开始一切正常,初始化能刷背景色,点个像素、画条直线都没毛病。等我开始调用 fillRect 填充一个大矩形的时候,屏幕上突然出现零星的随机像素点,位置每次刷新都不一样,颜色也不固定,就像屏幕上撒了一把细盐。第一反应是排线松了、屏坏了,但重新压紧排线、换了一块屏幕后问题照旧。后来把整个显示链路从硬件到驱动代码重新过了一遍,才确认这根本不是屏的问题,而是 SPI 传输细节里几个非常容易忽略的坑叠在了一起。这篇记录的就是完整的排查过程和最终修法,遇到类似“大区域填充出现随机像素”的朋友可以直接照着排。
1. 故障现象还原:填充矩形时出现的“雪花点”到底长什么样
1.1 最开始的复现步骤
我的环境是这样的:STM32CubeMX 生成工程,HAL 库,SPI1 做主模式,查询方式发送,F767 主频 216MHz,SPI 时钟最初配置在 27MHz。屏幕初始化序列用的从 Waveshare 官方例程改过来的 ILI9486 初始化代码,颜色格式选 RGB565。
复现步骤如下:
- 全屏填充黑色(0x0000);
- 调用 fillRect(50, 50, 250, 250) 画一个红色矩形;
- 期望结果是边缘清晰、内部纯色的矩形;
- 实际结果是矩形内部出现大约三四十个随机像素点。
这些像素点有一个共同特点:位置不固定。同一段代码重复执行,每次出现的位置都不一样,但横坐标和纵坐标也不会偏到矩形外面去。矩形面积越小,出现概率越低;全屏填充时数量最多,肉眼很明显。小矩形比如 16x16 的填充,有时候完全看不出问题,这可能也是很多人最初没当回事的原因。
1.2 为什么偏偏是“填充矩形”暴露出问题
这个问题最迷惑人的地方在于:画点、画线都正常,凭什么填充矩形就出乱子?
关键在于数据量。画一个点,SPI 上只需要发送几条命令加两三个字节数据,整段时间不到几十微秒,传输间隙极短,就算 SPI 配置有轻微偏差,也很难积累出肉眼可见的错误。画线稍微长一点,但每条线最多也就是几百个点,数据量依然有限。
填充矩形是完全不同的场景。一个 200x200 的矩形,RGB565 格式下就是 200x200x2 = 80000 字节;如果是 320x480 全屏,一次要往 SPI 灌 307200 字节。这么长的连续数据流,任何一字节的解释错误、任何一次时钟采样点偏差、任何一次片选信号异常,都会被放大成可见像素错误。SPI 协议本身是同步串行传输,发送端和接收端只要有一个 bit 错位,后续所有数据全部错位,直到下一次命令序列重新同步。
所以当时我就意识到:屏幕和初始化大概率没坏,问题出在“长数据流”的传输机制上,也就是 SPI 外设配置、片选控制、以及底层填充函数的实现方式。
2. 从硬件开始排除:确认 SPI 接口与片选控制
2.1 ILI9486 的接口模式比你想的更复杂
很多人把 ILI9486 当成一块“SPI 屏”,直接用 SPI 外设去驱动。实际上 ILI9486 内部支持多种接口模式,包括 I8080 并行接口、3 线 SPI、4 线 SPI,型号引脚 IM[3:0] 决定当前用哪种。Waveshare 这种 Shield 板卡,硬件上通常有模式选择电阻,出厂可能是 I8080 并口模式,也可能是 SPI 模式,必须看这块板子的原理图或者丝印说明确认。
我当时最初犯过一个粗心错误:以为 Shield 插上就能用,结果白屏了半小时,后来发现是 DC 引脚的复用没配好。ILI9486 在 4 线 SPI 模式下,DCX 引脚必须由主机 GPIO 控制,用来区分当前传输的是命令还是数据。如果 DCX 没接或者被固定拉高/拉低,芯片会把所有字节都当成同一种类型,命令和数据全混在一起。画点和画线时由于命令较短,偶然能“碰对”,但填充矩形时命令和数据字节数量不对等,必然错乱。
另外,SKU13587 这类 Shield 上还带 SD 卡槽,SD 卡也是走 SPI 的。如果 SD 卡和 LCD 共用一条 SPI 总线,必须各自独立 CS。很多代码在初始化阶段会先探测 SD 卡,占住 SPI 总线一段时间,如果 CS 控制没做好,SD 卡的通信垃圾数据会被 LCD 误收,屏幕上也会出现随机点。
2.2 每根线都值得重查一遍
在排查这个问题时,我把接线重新整理了一遍,这里给出一份参考接线表,不一定适用于所有开发板,但作为 STM32F767ZI SPI1 的典型接法没问题:
| 信号 | STM32 引脚 | 说明 |
|---|---|---|
| CLK | PA5 / SPI1_SCK | 接到屏幕 SCL,线尽量短 |
| MOSI | PA7 / SPI1_MOSI | 接到屏幕 SDI/SDA |
| MISO | 可不接 | ILI9486 的 SDO 只用于读显存,不用可以不连 |
| CS | 任意 GPIO,例如 PB0 | 软件控制,后续会重点讲 |
| DC | 任意 GPIO,例如 PB1 | 命令/数据选择 |
| RST | 任意 GPIO,例如 PB2 | 复位引脚 |
这里特别提醒一点:MISO 如果没用到,不要在主机的 SPI 配置里强制开启读功能。STM32F767 的 SPI 外设如果是全双工模式,发送每个字节的同时都会在 MISO 上采样。MISO 悬空时采到的电平是随机的,虽然大多不会影响输出数据,但如果你开启了 SPI 接收中断或者 DMA 双缓冲,这些随机垃圾数据会被 CPU/DMA 处理,反而干扰主流程。对只写屏的场景,建议直接用 SPI 发送专用配置,或者把 MISO 引脚在 GPIO 初始化时配置为普通输入并下拉。
还有一个硬件层面的检查点:供电。4 寸 TFT 背光电流很容易到几十毫安,如果从开发板的 3.3V 排针直接取电,当背光占空比变化或屏幕刷新大块面积时,电源纹波会增大,进而干扰 SPI 电平阈值。排障时最好用稳压源或单独的 LDO 给屏幕单独供电,排除电源因素。
2.3 硬件片选和软件片选:随机像素能否消失的分水岭
说到 SPI 片选,很多人会直接想到 STM32 的 NSS 引脚。NSS 可以作为硬件片选自动控制,但这个“自动控制”在主机模式下并不总符合预期。特别是当你使用 HAL 的HAL_SPI_Transmit时,如果你把 NSS 配成了硬件模式,外设会在某些帧边界翻转 NSS,或在多字节传输过程中产生额外的电平变化。对于 ILI9486 这种对命令序列完整性有要求的芯片,CS 在命令中间被拉高是灾难性的。
记住一个结论:驱动 ILI9486 这类需要连续多字节传输的 SPI 屏时,推荐使用 GPIO 软件片选,不要使用硬件 NSS。
软件片选的核心原则是:一个完整事务内,CS 保持低电平不动;事务结束后才拉高。比如发送“设置窗口命令 0x2A + 四个参数”,CS 必须在这五个字节全部发送完毕后再释放。下面给一个参考实现:
void ili9486_write_command(uint8_t cmd) { CS_LOW(); DC_LOW(); // 命令 spi_write_byte(cmd); CS_HIGH(); } void ili9486_write_data(uint8_t data) { CS_LOW(); DC_HIGH(); // 数据 spi_write_byte(data); CS_HIGH(); }看起来更细的拆分就是每个字节一个事务。这样在画点时没有问题,但大填充时一定要演变成行缓冲模式,不要让 CS 在像素之间反复翻转。后面第 4 章会专门讲 fillRect 的实现方式。
3. SPI 参数与 ILI9486 时序的匹配:时钟频率和采样边沿
3.1 检查 CPOL/CPHA,别让采样边沿卡在悬崖上
SPI 协议有四种模式,由时钟极性 CPOL 和时钟相位 CPHA 决定。ILI9486 的 4 线 SPI 通常工作在 Mode 0,也就是 CPOL=0、CPHA=0,SCK 空闲低电平,数据在上升沿采样。如果你的初始化配置成 Mode 1、Mode 2 甚至 Mode 3,屏幕可能也能亮,因为芯片时序容忍度有一定余量,低速时尤其不明显。
但问题恰恰出在“低速时正常,高速时随机错”上。当 SPI 时钟拉到 20MHz 以上,SCK 的建立时间和保持时间余量都很小,如果 CPOL/CPHA 和芯片要求不一致,采样点可能落在数据线翻转的附近。这时候数据线上的毛刺、走线串扰、电平上升沿不陡,都会导致某一 bit 被采错。一个 bit 采错,反映到屏幕上就是一个颜色错误的随机像素点。
在 CubeMX 里检查 SPI 配置时,重点确认这几个参数:
- Clock Polarity (CPOL):Low
- Clock Phase (CPHA):1 Edge
- Data Size:8 bit
- First Bit:MSB First
如果排查过程中不确定,可以先按 Mode 0 跑最低频,确认现象有没有变化。如果 Mode 0 低频稳定,再逐步升频。
3.2 分频:降频是百试百灵的定位手段
SPI 时钟频率不是越高越好,尤其当你用杜邦线连接屏幕时。F767 的 SPI1 挂在 APB2 上,PCLK2 理论最高 108MHz,SPI 外设分频最小 2 就是 54MHz。但实际上 ILI9486 的 SPI 时钟上限通常在 20MHz 左右,Waveshare 官方 Arduino 例程一般也用 15~20MHz。超过这个范围,就算协议没错,信号完整性也会出问题。
信号完整性出问题时,具体表现就是长线传输下出现偶发错位。我最初用 27MHz,随机像素很多;降到 13.5MHz,随机像素明显变少但仍有;再配合其他修复后,回到 16MHz 才彻底稳定。这个过程说明两点:时钟频率是诱发因素,但不是根因。
给你一个实用的分频对照表,方便你在 CubeMX 里做估算。假设 PCLK2 = 108MHz:
| Prescaler | SPI 时钟 |
|---|---|
| 2 | 54 MHz |
| 4 | 27 MHz |
| 8 | 13.5 MHz |
| 16 | 6.75 MHz |
| 32 | 3.375 MHz |
排障时建议从最低频率开始,比如 6.75MHz,确认屏幕显示一切正常后,再往上升频。如果 6.75MHz 也有随机像素,那就基本可以排除频率因素。
3.3 数据位序和像素格式的隐性影响
SPI 字节传输还有一个容易被忽略的选项:MSB first 还是 LSB first。STM32 的 SPI 外设默认 MSB first,ILI9486 的数据手册也是按 MSB 先传来定义的。如果不小心把 SPI 配置成了 LSB first,整个数据流按位翻转,图像会左右镜像或颜色通道错乱,表现上也可能是满屏噪点。所以要在 CubeMX 里把First Bit设为 MSB First。
更关键的还是像素格式。ILI9486 本身是 18 位色深,也就是 RGB666,但 SPI 模式下可以通过 0x3A 寄存器设置输入数据格式:
- 0x55:16 位/像素,RGB565
- 0x66:18 位/像素,RGB666
- 0x11:3 位/像素,RGB111(一般不用于正常显示)
驱动库和初始化寄存器必须匹配。比如初始化时把 0x3A 设成了 0x66,但 fillRect 按 RGB565 每像素 2 字节发送,那么从第二个像素开始数据就错位了,芯片会把 RGB 分量拆错,屏幕上的表现就是密密麻麻的彩色噪点,有时会被误认为“随机像素”。
我在最后检查初始化序列时,确认 0x3A = 0x55,才排除这个因素。
4. ILI9486 初始化序列与显存窗口:故障最可能的藏身之处
4.1 关键初始化参数怎么调
ILI9486 的初始化序列可以从 Waveshare 官方代码或任何成熟驱动库抄,但别从 ILI9341 的代码直接改芯片型号就完事。这两颗芯片寄存器差异很大,比如 ILI9486 的显示分辨率是 320x480,而 ILI9341 是 240x320,窗口边界和像素格式的设置完全不同。我见过不少“初始化后花屏”的案例,查到最后都是因为用了别的芯片的初始化序列。
这里列出几个关键寄存器和它们的作用,方便排查时对照:
| 寄存器 | 作用 | 常用值 |
|---|---|---|
| 0x3A | 像素格式设置 | 0x55 = RGB565,0x66 = RGB666 |
| 0x36 | 显存访问控制(MADCTL) | 控制扫描方向、RGB/BGR 顺序 |
| 0xC0 | 电源控制 1 | 跟随官方初始化 |
| 0xB0 | 显示模式/帧率等 | 跟随官方初始化 |
| 0x11 | 退出睡眠模式 | 初始化最后需要 |
| 0x29 | 打开显示 | 初始化完成打开显示 |
特别注意 0x36 MADCTL。如果你的窗口设置函数和 MADCTL 的扫描方向不匹配,填充矩形时内容方向会偏,但不至于出现随机点。不过 RGB/BGR 位如果搞反,颜色通道会错乱,在某些颜色过渡区域出现类似噪点的纹理。排障时可以先固定 MADCTL 为 0x00 或 0x48,测试纯色矩形是否正常。
4.2 窗口设置命令被截断:地址指针错乱的源头
ILI9486 的显存写入流程是:先用 0x2A 设置列地址范围(CASET),再用 0x2B 设置行地址范围(PASET),最后用 0x2C 连续写入像素数据。任何一条命令后面的参数如果没收到完整,芯片内部的窗口地址就会指向一个错误位置。错误窗口范围内的数据会被写入显存,但不一定是当前你要填充的矩形区域。这些写到错误区域的数据,最终会在屏幕上表现为显示内容混乱,如果只是一小部分越界,看起来就是零星的异常像素。
窗口命令的格式如下:
0x2A: [x0_high, x0_low, x1_high, x1_low] // CASET 0x2B: [y0_high, y0_low, y1_high, y1_low] // PASET 0x2C: [pixel data...] // RAMWR所有参数都必须在一个 CS 低电平区间内连续发送。如果发送中间 CS 被拉高,或者 SCK 意外停摆,ILI9486 只能收到部分参数,后续数据就会落到错误地址。这也解释了为什么“CS 拆包”会导致随机像素:窗口地址设错了,数据还在往显存里写,但位置不对。
自查方法:用逻辑分析仪抓 CS 和 MOSI,放大 0x2A 后面一段,确认 4 个地址字节是否连续、CS 是否一直维持低电平。一旦发现高脉冲插在参数中间,直接定位。
4.3 fillRect 实现方式对比:逐像素发送是原罪
接下来看 fillRect 本身。很多从画点函数扩展来的矩形填充代码,长这样:
void fillRect(uint16_t x0, uint16_t y0, uint16_t w, uint16_t h, uint16_t color) { set_window(x0, y0, x0 + w - 1, y0 + h - 1); for (uint32_t i = 0; i < w * h; i++) { CS_LOW(); DC_HIGH(); spi_write_byte(color >> 8); spi_write_byte(color & 0xFF); CS_HIGH(); } }这代码从功能上没错,画小矩形时看起来也正常。但每次只写一个像素就把 CS 拉高,意味着每两个像素之间 CS 都会产生一个高脉冲。对于 ILI9486 来说,每个高脉冲都意味着当前 SPI 事务结束,芯片状态机回到空闲或命令接收状态。当这个操作发生在 RAMWR 数据流中间时,芯片内部可能会认为数据写入结束,或者显存地址指针被重置。最终写进去的数据不连续,显存里就出现了空洞。
正确做法是:把窗口设置和像素数据分成两个大事务,窗口设置一次性发完,像素数据按行缓冲连续发送。推荐这样实现:
static uint8_t line_buffer[480 * 2]; // 行缓冲,按屏幕宽度算 void fillRect(uint16_t x0, uint16_t y0, uint16_t w, uint16_t h, uint16_t color) { uint32_t row_bytes = w * 2; // 填满行缓冲 for (uint16_t i = 0; i < w; i++) { line_buffer[i * 2] = color >> 8; line_buffer[i * 2 + 1] = color & 0xFF; } set_window(x0, y0, x0 + w - 1, y0 + h - 1); CS_LOW(); DC_HIGH(); // 数据模式 for (uint16_t row = 0; row < h; row++) { spi_write_buffer(line_buffer, row_bytes); } CS_HIGH(); }这个版本里,