1. 项目缘起:当8位SPI遇上“指令+数据”的LCD
最近在做一个基于STM32的便携式设备,主控是STM32F103,显示部分选了一块小巧的1.8寸TFT LCD,控制器是常见的ILI9341。为了节省IO口,自然选择了SPI接口。前期用标准8位SPI驱动,初始化、清屏都挺顺利,但一到实际显示文字和图片,麻烦就来了。屏幕总是花屏,或者显示的位置完全不对。查了半天数据手册和代码,才发现根源在于ILI9341这类LCD控制器的一个关键通信特性:它的大部分操作,都需要先发送一个8位的“指令”(Command),再跟上一个或多个8位的“数据”(Data)。
在标准的8位SPI模式下,我发送的每一个字节都会被屏幕当成数据来处理。当我试图发送指令0x2C(写显存指令)时,屏幕会错误地将其解释为要写入显存的第一个数据字节。这直接导致了后续的数据流全部错位,显示自然就乱套了。这个问题在驱动ST7735、SSD1306(部分模式)等同样采用“指令/数据”架构的LCD时也会遇到。解决这个问题的核心,就是让MCU能明确地告诉LCD:“我接下来发的是命令”还是“我接下来发的是数据”。硬件上,这类屏幕通常会提供一个独立的“DC”(Data/Command)引脚,电平高低代表数据类型。而软件上,一种高效且优雅的解决方案,就是使用SPI的“9位数据帧”模式。
9位SPI,顾名思义,就是在每个时钟周期内传输9个比特位,而不是常见的8个。多出来的这第9位,通常用作标志位。在驱动LCD的场景下,我们可以约定:当第9位为0时,表示当前传输的8位数据是“指令”;当第9位为1时,表示是“数据”。这样,我们就能将指令和数据的标识信息,与数据本身打包在同一个SPI事务中发送出去,无需频繁操作额外的GPIO(DC引脚),通信效率更高,时序也更紧凑。STM32的SPI外设恰好支持这种模式,这便是我这次折腾“STM32使用9bit SPI驱动LCD”的全部起因。
2. 理解SPI的9位数据帧模式:硬件如何支持
在深入代码之前,我们必须先搞清楚STM32的SPI外设是如何实现9位传输的。这对于后续的配置和排错至关重要。很多朋友第一次接触时会疑惑:SPI不是通常说“数据寄存器是16位的吗”?这和9位模式有什么关系?
实际上,STM32的SPI数据寄存器(SPI_DR)确实是一个16位的寄存器。在常见的8位数据帧模式下,我们只使用其低8位([7:0])来存放要发送或接收的数据。当我们将SPI配置为9位数据帧时,情况发生了变化。此时,数据帧长度被设定为16位,但这16位被赋予了新的结构含义:
- 低8位(
[7:0]):这8位用来存放我们实际要发送的“有效数据”,也就是LCD的指令码(如0x2A)或者数据值(如颜色值0x00F8)。 - 高8位中的特定一位(通常是
[8]位):这1位被用作“帧格式位”或“标志位”。在LCD驱动语境下,我们将其定义为“指令/数据选择位”。例如,我们约定[8]=0代表指令,[8]=1代表数据。 - 高8位的其他位(
[15:9]):这些位在9位帧格式下是“无效位”或“保留位”,发送时通常被硬件置为0,接收时也应被忽略。
所以,从物理传输上看,STM32的SPI在9位模式下,每次通信实际上是在MOSI线上依次移出16个比特。但这16个比特被我们“解读”为:1个标志位 + 8个有效数据位 + 7个填充位。接收方(LCD)如果也是类似的9位SPI控制器,它可以只关注前9个比特。但更常见的情况是,LCD的SPI接口是标准的8位输入,它只关心8个数据位。这时,STM32发出的16位数据流中,只有中间的8个“有效数据位”会被LCD正确锁存,而最前面的那个“标志位”和后面的“填充位”在时钟边沿到来时,LCD可能已经错过了对它们的采样(取决于LCD控制器的设计),或者直接忽略。因此,“9位SPI”更像是一种主机(STM32)端的协议封装技巧,它利用16位传输的灵活性,将控制信息(标志位)和数据打包在一起发送,而从设备(LCD)可能依然按照标准的8位序列来接收数据部分。
理解这一点后,配置的关键就明确了:我们需要正确设置SPI的“数据帧格式”为16位,并确保在发送时,将“标志位”和“有效数据”正确组合成一个16位的值,写入SPI_DR寄存器。
3. STM32 CubeMX配置与关键代码实现
我使用的是STM32F103C8T6和HAL库,开发环境是STM32CubeIDE。以下配置过程具有通用性。
3.1 CubeMX图形化配置
- SPI外设选择:根据你的硬件连接,选择一个SPI外设(例如SPI1)。配置为“全双工主模式”(Full-Duplex Master)。
- 基础参数:
- 时钟分频:根据你的LCD控制器手册和MCU主频设置。ILI9341的SPI时钟最高通常支持几十MHz,初期调试可以设低一些,如
PCLK/8。 - 时钟极性与相位:这必须严格匹配你的LCD控制器。ILI9341通常使用
CPOL=0, CPHA=0(模式0)或CPOL=1, CPHA=1(模式3)。请查阅你的LCD数据手册“Serial Interface”部分。我使用的是模式0。
- 时钟分频:根据你的LCD控制器手册和MCU主频设置。ILI9341的SPI时钟最高通常支持几十MHz,初期调试可以设低一些,如
- 核心:数据帧格式:
- 在“Data Size”选项中,选择“16 Bits”。这是实现9位通信的关键一步。CubeMX和HAL库层面没有直接的“9 Bits”选项,我们通过选择16位,并在软件中构造包含标志位的16位数据来实现。
- 其他设置:
- 片选(NSS):设置为“软件控制”(Software NSS Management)。我们将用另一个GPIO(如PA4)手动控制片选信号。
- 硬件流控制:禁用。
- CRC计算:禁用。
- GPIO配置:
- 除了SPI的SCK、MOSI引脚,别忘了配置一个GPIO作为片选(CS),一个作为复位(RST),还有一个作为背光控制。那个关键的“DC”引脚在这里不需要了,因为其功能已被整合到9位数据帧中。
3.2 驱动代码编写:构造16位数据帧
配置完成后,生成代码。核心在于发送函数的编写。我们需要定义两个宏,用于快速构造包含指令/数据标志位的16位数据。
// 定义指令和数据的标志位在16位数据中的位置 // 假设我们使用 bit8 作为标志位:0=指令, 1=数据 #define LCD_CMD_FLAG (0x00 << 8) // 标志位为0, 指令 #define LCD_DATA_FLAG (0x01 << 8) // 标志位为1, 数据 // 发送一个字节的命令 void LCD_Write_Cmd(uint8_t cmd) { uint16_t data_to_send = LCD_CMD_FLAG | cmd; // 组合:标志位(0) + 8位命令 HAL_SPI_Transmit(&hspi1, (uint8_t*)&data_to_send, 1, HAL_MAX_DELAY); } // 发送一个字节的数据 void LCD_Write_Data(uint8_t dat) { uint16_t data_to_send = LCD_DATA_FLAG | dat; // 组合:标志位(1) + 8位数据 HAL_SPI_Transmit(&hspi1, (uint8_t*)&data_to_send, 1, HAL_MAX_DELAY); } // 发送多个字节的数据(常用于发送颜色数组) void LCD_Write_Data_Buffer(uint8_t *pData, uint32_t len) { // 注意:这里需要将每个字节数据都加上 DATA_FLAG for(uint32_t i=0; i<len; i++) { uint16_t data_to_send = LCD_DATA_FLAG | pData[i]; HAL_SPI_Transmit(&hspi1, (uint8_t*)&data_to_send, 1, HAL_MAX_DELAY); } }关键点解析:
LCD_CMD_FLAG被定义为(0x00 << 8),即0x0000。与8位命令cmd(例如0x2A)进行按位或操作后,得到16位数0x002A。其二进制为0000 0000 0010 1010。bit8为0,低8位是命令。LCD_DATA_FLAG被定义为(0x01 << 8),即0x0100。与8位数据dat(例如0xF8)进行按位或操作后,得到0x01F8,二进制0000 0001 1111 1000。bit8为1,低8位是数据。HAL_SPI_Transmit的第三个参数是DataSize,单位是“数据项”的数量。由于我们在CubeMX中配置了数据帧为16位(2字节),所以这里传入1,代表发送1个16位的数据项。函数内部会知道每个数据项是2字节。
3.3 初始化序列与基本绘图函数
有了基础的读写函数,LCD的初始化就是按照数据手册的指令序列,调用LCD_Write_Cmd和LCD_Write_Data。例如,设置显示区域(SET_COLUMN_ADDRESS, 0x2A)和(SET_PAGE_ADDRESS, 0x2B),然后发送写显存指令(MEMORY_WRITE, 0x2C),之后连续调用LCD_Write_Data发送颜色数据。
一个简单的画点函数示例如下:
void LCD_DrawPoint(uint16_t x, uint16_t y, uint16_t color) { // 1. 设置光标位置 LCD_Write_Cmd(0x2A); // 列地址设置 LCD_Write_Data(x >> 8); LCD_Write_Data(x & 0xFF); LCD_Write_Data((x+1) >> 8); // 结束列地址,单点所以是x+1 LCD_Write_Data((x+1) & 0xFF); LCD_Write_Cmd(0x2B); // 行地址设置 LCD_Write_Data(y >> 8); LCD_Write_Data(y & 0xFF); LCD_Write_Data((y+1) >> 8); LCD_Write_Data((y+1) & 0xFF); // 2. 发送写显存指令 LCD_Write_Cmd(0x2C); // 写显存 // 3. 发送颜色数据(16位色,如RGB565) LCD_Write_Data(color >> 8); // 高字节 LCD_Write_Data(color & 0xFF); // 低字节 }注意:这里
LCD_Write_Data发送的是颜色值的高8位和低8位,它们都带有DATA_FLAG。对于LCD控制器,它接收到写显存指令(0x2C)后,会预期接下来是所有要写入的数据流。我们发送的每个16位SPI帧中,bit8=1(数据标志),低8位是真实的颜色字节,LCD控制器会正确识别并存入显存。
4. 调试中的关键陷阱与波形分析
理论很美好,但实际调试时我遇到了几个典型的坑。用逻辑分析仪抓取SPI波形是必不可少的调试手段。
4.1 陷阱一:数据位序(MSB/LSB)不匹配
这是最隐蔽的问题之一。STM32的SPI可以配置数据帧从最高位(MSB)还是最低位(LSB)开始发送。而你的LCD控制器也有相应的要求。如果两者不匹配,你发送的指令0x2C(二进制0010 1100)可能被LCD解读为完全不同的值,例如0x34(如果LSB先发)。
排查与解决:
- 仔细核对STM32的SPI配置和LCD数据手册。在CubeMX中,SPI的“First Bit”选项,通常选择“MSB First”。
- 使用逻辑分析仪抓取初始化阶段的波形。发送一个简单的命令,如软复位命令
0x01。观察MOSI线上最先出现的是高位(0)还是低位(1)。0x01的MSB是0,LSB是1,很容易区分。 - 如果发现不匹配,修改CubeMX中的“First Bit”设置,或者查阅LCD手册看是否可以通过指令配置其位序。
4.2 陷阱二:16位数据帧的构造错误
逻辑分析仪显示,我发送的16位数据是0x012C(发送命令0x2C)。但我期望的波形是:先发标志位0(bit8),再发8位命令0x2C。然而,分析仪解码显示我发送的16位序列是0000 0001 0010 1100。这不对!0x012C的二进制是0000 0001 0010 1100,这意味着标志位(bit8)是1,而低8位是0x2C。LCD会把0x2C当成数据而不是命令!
根因:我错误地构造了16位数据。我的宏定义是#define LCD_CMD_FLAG (0 << 8),这没错。但在调用时,我写成了uint16_t data = cmd | (1 << 8),手误将命令标志写成了数据标志。或者是宏定义和函数实现中位域的理解出现了偏差。
解决:仔细检查LCD_Write_Cmd和LCD_Write_Data函数中data_to_send的构造逻辑。确保按位或运算的优先级和意图。使用逻辑分析仪验证发送出的16位数值是否符合预期:发送命令时,bit8应为0;发送数据时,bit8应为1。
4.3 陷阱三:SPI时钟极性与相位(CPOL/CPHA)
即使数据内容正确,如果时钟极性和相位不对,LCD也会在错误的时钟边沿采样数据,导致读取的值不稳定或完全错误。症状可能是屏幕初始化部分成功(能复位、背光亮),但无法正常显示内容,或者显示杂乱无章的条纹。
排查:
- 黄金法则:以LCD数据手册为准。找到“Serial Interface”章节的时序图。
- 看时序图关键点:
- CPOL(时钟极性):看SCK线在空闲状态(没有数据传输时)是高电平(CPOL=1)还是低电平(CPOL=0)。
- CPHA(时钟相位):看数据是在SCK的第一个边沿(上升沿或下降沿)被采样,还是在第二个边沿被采样。通常,数据在SCK的边沿保持稳定,在相反的边沿变化。
- 用逻辑分析仪抓取SPI的CS、SCK、MOSI三线波形,与LCD手册的时序图严格对比。调整CubeMX中的“Clock Polarity”和“Clock Phase”选项,直到波形匹配。
4.4 陷阱四:片选(CS)信号时序
片选信号CS(或NSS)的时序也容易被忽略。有些LCD要求CS在每笔SPI传输(一个命令或一个数据)之间都需要有一个从低到高再到低的跳变,而有些则允许在一次连续传输(如发送大量像素数据)期间保持低电平。
操作要点:
- 在
LCD_Write_Cmd和LCD_Write_Data函数中,在调用HAL_SPI_Transmit前后,手动控制CS引脚拉低和拉高。 - 对于连续发送大量数据的操作(如填充整屏颜色),可以在开始前拉低
CS,全部发送完毕后再拉高CS,以提高传输效率。 - 同样,参考LCD数据手册的时序图,确认
CS信号的有效电平和建立/保持时间要求。
5. 性能优化与DMA应用思考
当驱动分辨率较高的LCD(如320x240)进行全屏刷新或绘制复杂图形时,频繁调用HAL_SPI_Transmit会导致CPU被大量占用在等待SPI发送完成上。这时,使用DMA(直接存储器访问)来搬运显示数据到SPI外设是必然的选择。
然而,在9位SPI模式下使用DMA,会带来一个独特的挑战:数据预处理。DMA通常是从一个内存缓冲区(src)直接搬运数据到外设数据寄存器(dst)。我们的src缓冲区里存放的通常是纯像素数据(RGB565格式,每个像素2字节)。但SPI外设需要的是“带有数据标志位(bit8=1)的16位帧”。
因此,我们不能简单地把像素数据缓冲区直接丢给DMA。有两个主流解决方案:
方案一:CPU预处理缓冲区在启动DMA传输前,先用CPU遍历整个像素数据缓冲区,将每个像素的2个字节,分别与LCD_DATA_FLAG组合,生成一个新的、大小为两倍的16位缓冲区,然后将这个新缓冲区的地址交给DMA。这种方法简单可靠,但需要额外的内存(双倍缓冲区),并且CPU仍然需要执行预处理工作,只是省去了等待SPI发送的时间。
方案二:使用SPI的硬件CRC或TI模式“曲线救国”(高级技巧)这是一种更硬核的优化,但依赖特定型号SPI外设的支持和更底层的寄存器操作。有些STM32的SPI支持在16位模式下,通过配置“帧格式”寄存器,将特定的位(如bit8)固定为某个值。或者,可以利用SPI的“TI模式”(Motorola SPI的一种变体)的帧头特性来模拟标志位。这种方法需要对SPI外设寄存器有深入理解,且通用性较差,不推荐初学者尝试。
对于大多数应用,方案一(CPU预处理+DMA传输)是平衡性能和复杂度的最佳实践。它虽然消耗一些CPU时间和内存,但相比纯CPU轮询发送,全屏刷新的帧率提升是数量级的。
6. 与8位+DC引脚模式的对比与选型建议
最后,我们来对比一下“9位SPI”模式和传统的“8位SPI+独立DC引脚”模式。
| 特性 | 9位SPI模式 | 8位SPI + DC引脚模式 |
|---|---|---|
| GPIO占用 | 节省1个GPIO。无需独立的DC引脚。 | 多占用1个GPIO作为DC。 |
| 通信效率 | 理论上稍高。指令/数据标志与数据在同一SPI事务中发送,无GPIO操作延迟。 | 每次发送数据前需先设置DC引脚电平,引入微小延迟。 |
| 代码复杂度 | 稍高。需要理解9位数据帧构造,DMA使用更复杂。 | 简单直观。读写函数直接控制DC引脚电平。 |
| 兼容性 | 较低。依赖MCU的SPI支持16位/帧格式,且从设备(LCD)需能忽略或适应多出的位。 | 极高。任何支持SPI的MCU和LCD都适用,是行业最通用做法。 |
| 调试难度 | 较高。需用逻辑分析仪解码16位帧以验证标志位。 | 较低。DC引脚电平在逻辑分析仪上清晰可见。 |
选型建议:
- 首选8位SPI+DC引脚模式:如果你是初学者,或者项目对GPIO数量不敏感,或者未来可能更换不同型号的LCD屏幕,强烈建议使用这种最通用、最易调试的方案。它的库和示例代码也最丰富。
- 考虑9位SPI模式:仅当你的项目IO口真的非常紧张,寸土寸金,并且你使用的STM32型号SPI外设支持16位数据帧格式,同时你愿意为节省这一个IO口付出额外的学习和调试成本。在批量生产的低成本产品中,这1个IO口可能意味着可以选用更小封装的MCU,从而节省成本和PCB面积。
我这次选择9位SPI,更多是出于技术探索和挑战的目的。实际走通整个流程后,对SPI协议和STM32外设的理解确实加深了不少。但下次在新项目中,如果IO不是问题,我大概率会回归简单可靠的“8位SPI+DC引脚”方案。把时间花在实现更酷的功能上,而不是调试通信底层,往往是更明智的选择。