STM32 SPI驱动TFT-LCD实战:从硬件配置到DMA刷屏优化
2026/7/29 6:29:50 网站建设 项目流程

1. 从“点不亮”到“跑得稳”:我的STM32 SPI驱动TFT-LCD踩坑全记录

搞嵌入式开发的朋友,估计都绕不开驱动一块屏幕。最近我在一个项目里,需要用STM32F103驱动一块1.8寸的TFT-LCD屏。这块屏接口是SPI的,听起来很简单对吧?HAL库一配,几个发送函数一调,画面就该出来了。但实际情况是,我花了整整两天时间,才从一片白屏或者花屏,折腾到能稳定显示图形和文字。这个过程里,硬件SPI和软件模拟SPI都试了个遍,踩的坑一个接一个。今天我就把这次从“点不亮”到“跑得稳”的完整过程,包括原理、配置、代码和那些手册上不会写的细节,全都梳理出来。无论你是刚开始接触STM32和屏幕,还是正在某个坑里挣扎,希望这篇记录都能帮你省下不少时间。

这块1.8寸TFT-LCD通常指的是ST7735S或ILI9163这类控制器的屏幕,它们色彩不错(通常是128x160或132x162分辨率),功耗低,而且SPI接口只需要3-4根线,非常适合资源有限的MCU。驱动它,本质上就是通过SPI总线,按照特定时序,向屏幕控制器发送命令和数据。听起来是标准的通信协议问题,但魔鬼全在细节里。

2. 知己知彼:1.8寸TFT-LCD屏幕与SPI通信协议深潜

在动手写代码之前,我们必须先搞清楚要驱动的对象是谁,以及它遵循的游戏规则。很多驱动失败,根源在于对屏幕本身和通信协议的理解有偏差。

2.1 屏幕控制器与初始化序列

市面上常见的1.8寸TFT屏,控制器芯片多半是ST7735S,也有部分是ILI9163或ILI9341的变种。首先,你必须找到你手上这块屏的数据手册。这听起来像废话,但很多人就是直接用网上找的例程,结果因为初始化序列或像素格式不匹配而失败。通过屏幕背面型号或购买链接找手册,确认控制器型号。

以ST7735S为例,它内部有一系列功能寄存器,用来设置扫描方向、颜色格式、电源模式等。我们上电后的第一件事,就是通过一串命令(Command)和数据(Data)来配置这些寄存器,这就是初始化序列。不同厂家、甚至同型号不同批次的屏幕,初始化序列都可能微调。一个典型的序列包括:退出睡眠模式、设置颜色格式(RGB565还是RGB666)、设置内存访问控制(横屏还是竖屏)、打开显示等。

注意:网上流传的初始化代码很多,但直接拷贝大概率不好使。最可靠的方法是,找到屏幕卖家提供的“Arduino示例代码”或“51单片机示例代码”,提取里面的初始化命令数组。这些数组是经过该批次屏幕验证的,比通用代码靠谱得多。

2.2 SPI通信模式与数据格式

这是核心中的核心。TFT屏的SPI通常是模式0或模式3。我强烈建议你直接用示波器或者逻辑分析仪抓一下通信波形,这是最权威的判定方法。如果没条件,可以尝试:大多数ST7735S屏使用SPI模式0,即CPOL=0(时钟空闲时为低电平),CPHA=0(数据在时钟的第一个边沿采样)。

数据格式通常是8位或9位。这里有个关键点:很多屏幕的SPI是“9位数据帧”,其中第一位是“数据/命令”选择位(DC或A0引脚控制),后面8位才是真正的命令或数据字节。但在STM32的SPI外设配置中,我们通常将其配置为8位数据帧。那第9位去哪了?实际上,它是由我们通过一个GPIO引脚(DC)来手动控制的。发送命令前,将DC拉低;发送数据前,将DC拉高。这样,结合8位SPI数据帧,就实现了9位通信的效果。

通信速率(BaudRate)不宜一开始就设到最高。屏幕控制器有最高时钟限制(比如15MHz或30MHz),但布线质量、屏幕本身性能都会影响稳定性。我的经验是,先从低速开始,比如1-2MHz,确保通信基本正确,再逐步提高,直到出现数据错误为止,然后留出至少20%的余量。这样能找到当前硬件环境下最稳定可靠的速率。

3. 硬件SPI驱动方案:配置、优化与致命陷阱

使用STM32的硬件SPI外设,效率高,CPU占用率低,是首选方案。但HAL库的封装和屏幕的特殊要求,带来了几个必须小心处理的坑。

3.1 CubeMX配置与引脚定义

首先在STM32CubeMX中进行配置。假设我们使用SPI1。

  1. 模式选择:选择“Full-Duplex Master”或“Transmit Only Master”。因为屏幕通常只接收,不发送数据回MCU,所以“只发送主模式”理论上更贴切,但全双工模式也能用。
  2. 基础参数
    • Data Size: 8 bits。
    • First Bit: MSB First(大多数SPI设备都是高位先行)。
    • CPOL & CPHA: 根据屏幕确定,假设为Low & 1 Edge (Mode 0)。
    • BaudRate: 先保守设置为2MHz(PCLK2分频后)。
  3. 引脚分配
    • SCK (PA5): 时钟线。
    • MOSI (PA7): 主设备输出,连接屏幕的SDASDI引脚。
    • CS (PA4): 片选,连接屏幕的CSCSX引脚。这里有个大坑:CubeMX可能会自动将硬件SPI的NSS(硬件片选)引脚使能。对于驱动屏幕,我强烈建议禁用硬件NSS(选择“Disable”),而是用一个普通的GPIO输出引脚(如PA4)来软件控制片选。原因有二:一是方便控制时序;二是很多屏幕初始化时需要CS保持有效(低电平)贯穿整个序列,硬件NSS模式可能不符合要求。
    • DC (PA3): 数据/命令选择,用一个普通GPIO输出引脚控制。
    • RST (PA2): 复位,用一个普通GPIO输出引脚控制。虽然有些代码不用复位,但规范的驱动必须包含硬件复位步骤,以确保屏幕处于确定状态。
    • BL (PA1): 背光控制,用一个普通GPIO输出引脚控制。

生成代码后,我们需要自己实现几个底层驱动函数。

3.2 关键驱动函数实现与HAL库调优

// 定义控制引脚 #define LCD_CS_Clr() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET) #define LCD_CS_Set() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET) #define LCD_DC_Clr() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_3, GPIO_PIN_RESET) // 命令 #define LCD_DC_Set() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_3, GPIO_PIN_SET) // 数据 #define LCD_RST_Clr() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_RESET) #define LCD_RST_Set() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_SET) // 发送一个字节(命令或数据) void LCD_WriteByte(uint8_t data) { HAL_SPI_Transmit(&hspi1, &data, 1, 100); // 超时时间100ms } // 发送命令 void LCD_WriteCmd(uint8_t cmd) { LCD_DC_Clr(); // DC拉低,表示命令 LCD_WriteByte(cmd); } // 发送数据 void LCD_WriteData(uint8_t data) { LCD_DC_Set(); // DC拉高,表示数据 LCD_WriteByte(data); } // 发送16位数据(用于RGB565颜色) void LCD_WriteData_16Bit(uint16_t data) { LCD_DC_Set(); LCD_WriteByte(data >> 8); // 发送高8位 LCD_WriteByte(data & 0xFF); // 发送低8位 }

看起来很简单,对吧?但这里隐藏着硬件SPI驱动屏幕的第一个性能瓶颈和潜在陷阱:单字节发送的效率问题HAL_SPI_Transmit函数每次调用都有一定的开销。如果刷屏时需要发送大量像素数据(比如128x160x2=40960字节),频繁调用此函数会导致速度极慢。

优化方案是使用DMA,或者至少使用批量发送。对于没有DMA或想简单处理的情况,可以优化为:

void LCD_WriteData_Buffer(uint8_t *buffer, uint32_t length) { LCD_DC_Set(); HAL_SPI_Transmit(&hspi1, buffer, length, 1000); }

在填充矩形或显示图片时,先将要发送的颜色数据存入一个数组缓冲区,然后一次性发送。这比循环发送单字节快一个数量级。

3.3 初始化流程的完整实现与避坑点

初始化函数LCD_Init()的编写,是成功与否的关键。下面是一个基于ST7735S的框架,请务必用你屏幕的实际初始化序列替换init_cmd数组

void LCD_Init(void) { // 1. 硬件复位 LCD_RST_Set(); HAL_Delay(5); // 保持高电平一段时间 LCD_RST_Clr(); HAL_Delay(20); // 复位低电平时间,典型值10-20ms LCD_RST_Set(); HAL_Delay(120); // 复位后等待屏幕内部稳定,手册要求至少120ms // 2. 片选使能 LCD_CS_Clr(); // 3. 发送初始化命令序列 // 这是一个示例序列,必须替换为你屏幕的真实序列! static const uint8_t init_cmd[] = { 0x01, 0x80, 0x96, // 软件复位,后跟延迟150ms (0x80|0x96) 0x11, 0x80, 0xFF, // 退出睡眠模式,延迟500ms 0x3A, 0x01, 0x05, // 设置颜色格式:0x05为16位/pixel (RGB565) 0x36, 0x01, 0x08, // 设置内存访问控制(MAC),这里影响横竖屏 // ... 更多命令 0x29, 0x00, // 打开显示 }; for(int i=0; i<sizeof(init_cmd); ) { uint8_t cmd = init_cmd[i++]; uint8_t is_delay = cmd & 0x80; // 假设我们约定最高位为1表示后跟延迟 uint8_t real_cmd = cmd & 0x7F; uint8_t num_args = init_cmd[i++]; // 下一个字节是参数个数 LCD_WriteCmd(real_cmd); for(int j=0; j<num_args; j++) { LCD_WriteData(init_cmd[i++]); } if(is_delay) { uint8_t delay_ms = init_cmd[i++]; HAL_Delay(delay_ms); } } // 4. 设置显示窗口和坐标计数器 LCD_SetWindow(0, 0, LCD_WIDTH-1, LCD_HEIGHT-1); // 5. 打开背光 LCD_BL_Set(); }

避坑指南

  1. 复位时序RST引脚的低电平脉冲宽度必须足够(通常>10ms),复位后必须等待足够长时间(>120ms)才能开始发送命令。时间不够会导致初始化失败。
  2. 命令参数:每个命令后面的参数个数必须严格正确。多送或少送一个参数,都可能使屏幕进入奇怪的状态。
  3. 延迟:初始化序列中的延迟(0x80标记)至关重要。特别是退出睡眠(0x11)和打开显示(0x29)之后的延迟,必须给足。用HAL_Delay是阻塞的,在实时性要求高的系统里可以考虑用非阻塞延时,但初始化阶段阻塞问题不大。
  4. 颜色格式:命令0x3A设置像素格式。常用的是0x05(16位,RGB565)和0x06(18位,RGB666)。必须和后续你发送颜色数据的方式匹配。如果设置成RGB565却发送了3个字节(RGB888),显示颜色会错乱。

4. 软件模拟SPI:何时用、怎么实现及性能权衡

当硬件SPI引脚被占用,或者需要极高的时序灵活性时,软件模拟SPI(Bit-Banging)就成了备选方案。它用普通的GPIO口,通过代码控制电平变化来模拟时钟和数据线。

4.1 软件SPI的实现代码

软件SPI的核心就是精确控制SCK和MOSI两个引脚的时序。下面是一个简单的实现:

// 定义软件SPI引脚 #define SOFT_SPI_SCK_PIN GPIO_PIN_5 #define SOFT_SPI_SCK_PORT GPIOA #define SOFT_SPI_MOSI_PIN GPIO_PIN_7 #define SOFT_SPI_MOSI_PORT GPIOA // 引脚电平操作宏(速度更快) #define SOFT_SPI_SCK_LOW() HAL_GPIO_WritePin(SOFT_SPI_SCK_PORT, SOFT_SPI_SCK_PIN, GPIO_PIN_RESET) #define SOFT_SPI_SCK_HIGH() HAL_GPIO_WritePin(SOFT_SPI_SCK_PORT, SOFT_SPI_SCK_PIN, GPIO_PIN_SET) #define SOFT_SPI_MOSI_LOW() HAL_GPIO_WritePin(SOFT_SPI_MOSI_PORT, SOFT_SPI_MOSI_PIN, GPIO_PIN_RESET) #define SOFT_SPI_MOSI_HIGH() HAL_GPIO_WritePin(SOFT_SPI_MOSI_PORT, SOFT_SPI_MOSI_PIN, GPIO_PIN_SET) // 模拟SPI发送一个字节(模式0) void Soft_SPI_WriteByte(uint8_t data) { for(uint8_t i=0; i<8; i++) { SOFT_SPI_SCK_LOW(); // 时钟拉低,准备数据 // 放置数据位(高位先行) if(data & 0x80) { SOFT_SPI_MOSI_HIGH(); } else { SOFT_SPI_MOSI_LOW(); } data <<= 1; // 左移,准备下一位 // 这里可以加一个微小的延时,用于调整速率 // __NOP(); __NOP(); // 空操作指令延时 SOFT_SPI_SCK_HIGH(); // 时钟拉高,屏幕采样数据 // 同样可以加延时 // __NOP(); } // 可选:最后将时钟保持在空闲状态(低电平) SOFT_SPI_SCK_LOW(); }

对应的LCD_WriteCmdLCD_WriteData函数只需要把调用HAL_SPI_Transmit的地方换成调用Soft_SPI_WriteByte即可。

4.2 软件SPI的致命缺点与适用场景

软件SPI最大的问题是速度慢。执行一个for循环、判断、移位、写GPIO,需要几十个甚至上百个时钟周期。对于STM32F103@72MHz,模拟1MHz的SPI时钟可能都吃力,更别说刷屏所需的高速了。这会导致刷屏速度极慢,动态刷新几乎不可用。

其次,时序精度受中断影响。如果系统中有高优先级中断发生,可能会打乱Soft_SPI_WriteByte函数中微妙的延时,导致通信错误。

那么,什么时候该用软件SPI呢?

  1. 调试与验证:当硬件SPI不通时,用软件SPI可以逐位控制,方便用逻辑分析仪观察波形,排查是配置问题还是硬件问题。
  2. 引脚冲突:硬件SPI引脚被其他更重要的外设占用,且屏幕刷新要求极低(比如只显示静态文本,几分钟更新一次)。
  3. 特殊时序需求:极少数屏幕可能需要非标准的SPI时序(比如数据在时钟下降沿变化),用软件模拟可以灵活调整。

性能优化技巧:如果不得不使用软件SPI,又想尽量快一点:

  • 使用寄存器直接操作(GPIOx->BSRR)代替HAL_GPIO_WritePin,减少函数调用开销。
  • SCKMOSI引脚配置为最大输出速度。
  • 精简循环内部代码,用查表法代替if判断数据位。
  • 考虑使用汇编语言编写核心发送函数。

但无论如何优化,软件SPI的性能天花板都远低于硬件SPI。对于需要流畅刷新的图形应用,硬件SPI+DMA是唯一可行的选择。

5. 核心功能实现:画点、填充与字符显示

驱动初始化成功后,我们需要构建上层应用函数。最基础的是画点函数,它是所有图形操作的基础。

5.1 设置窗口与写GRAM

屏幕控制器有一个显示RAM(GRAM)。我们通过设置“窗口”来告诉屏幕,接下来要写入的数据对应屏幕上的哪个矩形区域。设置好后,只需连续写入颜色数据,屏幕就会自动更新该区域。

// 设置窗口区域 (x0,y0) 到 (x1,y1) void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { // 发送设置列地址命令 LCD_WriteCmd(0x2A); // 列地址设置命令,ST7735S可能是0x2A LCD_WriteData(x0 >> 8); LCD_WriteData(x0 & 0xFF); LCD_WriteData(x1 >> 8); LCD_WriteData(x1 & 0xFF); // 发送设置行地址命令 LCD_WriteCmd(0x2B); // 行地址设置命令 LCD_WriteData(y0 >> 8); LCD_WriteData(y0 & 0xFF); LCD_WriteData(y1 >> 8); LCD_WriteData(y1 & 0xFF); // 发送写GRAM命令,准备接收像素数据 LCD_WriteCmd(0x2C); // 写内存命令 }

5.2 画点函数与坐标映射

有了设置窗口和连续写数据的功能,画点函数有两种实现思路:

  1. 每次画一个点都设置一次窗口:简单但极其低效,因为发送命令的开销远大于发送数据。
  2. 批量操作:更高效的方式是,在需要画多个点(比如一条线、一个矩形)时,先计算整个区域,设置一次窗口,然后连续发送所有像素的颜色数据。

这里给出一个通用但效率不高的单点绘制函数(用于理解原理):

void LCD_DrawPoint(uint16_t x, uint16_t y, uint16_t color) { // 检查坐标是否在屏幕范围内 if(x >= LCD_WIDTH || y >= LCD_HEIGHT) return; // 设置窗口为单个点 LCD_SetWindow(x, y, x, y); // 写入该点的颜色数据 LCD_WriteData_16Bit(color); }

在实际应用中,我们很少直接调用这个函数刷屏。而是会实现一个LCD_Fill填充函数,用于清屏或填充矩形。

5.3 填充矩形与清屏

填充矩形是使用最频繁的函数之一,优化它的性能至关重要。

void LCD_Fill(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color) { uint32_t pixels = (uint32_t)(x1 - x0 + 1) * (y1 - y0 + 1); // 总像素数 LCD_SetWindow(x0, y0, x1, y1); // 设置一次窗口 LCD_DC_Set(); // 切换到数据模式 // 关键优化:使用HAL_SPI_Transmit发送大量数据 // 我们需要构建一个缓冲区,或者直接连续发送相同颜色 // 方法A:使用DMA(最佳) // 方法B:构建一个行缓冲区并循环发送(次优) // 方法C:连续调用单次发送(最差) // 这里演示方法B的简化思路(假设一次发送一行) uint16_t buffer[LCD_WIDTH]; // 行缓冲区 for(int i=0; i< (x1-x0+1); i++) { buffer[i] = color; } for(int row = y0; row <= y1; row++) { // 注意:如果窗口设置正确,这里理论上可以连续发送所有行数据。 // 但更常见的做法是,将整个矩形区域的颜色数据打包成一个大的缓冲区,一次性发送。 // 受限于单片机RAM,可能需要对大区域进行分块发送。 HAL_SPI_Transmit(&hspi1, (uint8_t*)buffer, (x1-x0+1)*2, 1000); // 发送一行 } }

清屏就是填充整个屏幕:

void LCD_Clear(uint16_t color) { LCD_Fill(0, 0, LCD_WIDTH-1, LCD_HEIGHT-1, color); }

5.4 显示字符与中文字库

显示字符的本质,是在特定位置,根据字模(一个二维点阵数据)绘制一系列有颜色的点(前景色)和无颜色的点(背景色)。

西文字符:通常使用8x16或12x24等点阵字库。字模可以用数组保存在代码中(占用ROM),对于ASCII字符,128个字符的8x16字库大约需要2KB空间。

// 在指定位置显示一个ASCII字符 void LCD_ShowChar(uint16_t x, uint16_t y, char chr, uint16_t fc, uint16_t bc) { uint8_t i, j; uint8_t *pFont = (uint8_t *)&ascii_font[chr * 16]; // 假设字库数组名ascii_font LCD_SetWindow(x, y, x+7, y+15); // 8x16字符 for(j=0; j<16; j++) { // 16行 uint8_t lineData = pFont[j]; for(i=0; i<8; i++) { // 8列 if(lineData & (0x80 >> i)) { LCD_WriteData_16Bit(fc); // 前景色 } else { LCD_WriteData_16Bit(bc); // 背景色 } } } }

中文字符:汉字字模更大(通常16x16),字库也更庞大。全字库不适合放在单片机内部Flash。常用解决方案有:

  1. 外置SPI Flash存储字库:将字库文件(如GBK编码)烧录到外部Flash,需要显示时根据汉字机内码计算出地址,读取字模数据。
  2. 使用小字库:只包含项目用到的汉字,将字模数组编译进代码。
  3. 图形化上位机生成字模数组:用工具将需要的汉字生成C数组。

显示字符串就是循环调用显示字符函数,并计算下一个字符的位置(x坐标增加字符宽度)。

6. 高级话题:DMA刷屏、双缓冲与性能极限压榨

当你的应用需要显示动画、菜单或频繁更新的数据时,刷屏速度就成为瓶颈。硬件SPI的极限速度受限于时钟频率和CPU搬运数据的能力。

6.1 使用DMA解放CPU

DMA(直接存储器访问)可以在不占用CPU的情况下,将内存中的数据搬运到SPI数据寄存器。配置步骤:

  1. CubeMX配置:在SPI配置中使能DMA Tx通道(模式为NormalCircular,通常Normal即可)。
  2. 代码修改:将LCD_Fill或刷屏函数中调用HAL_SPI_Transmit的地方,改为调用HAL_SPI_Transmit_DMA
  3. 内存管理:确保你传递给DMA的数据缓冲区地址是有效的,并且在整个传输过程中保持不变(通常需要是全局数组或静态数组)。
  4. 传输完成回调:DMA传输是异步的。你需要实现SPI传输完成回调函数(HAL_SPI_TxCpltCallback),在传输完成后进行后续操作,比如更新帧缓冲区指针或发送同步信号。

使用DMA后,CPU在刷屏期间可以被释放出来处理其他任务,系统响应性大大提高。

6.2 双缓冲机制与撕裂效应

如果你在直接向屏幕GRAM写入数据的同时,屏幕正在从GRAM读取数据显示,可能会看到“撕裂”现象,即屏幕上半部分显示上一帧内容,下半部分显示当前帧正在写入的内容。

解决方案是双缓冲

  1. 在MCU的RAM中开辟两块和屏幕分辨率一样大的缓冲区(FrameBuffer1和FrameBuffer2)。
  2. 所有绘图操作(画点、画线、显示字符)都只在“后台缓冲区”上进行。
  3. 当一帧图像绘制完成后,启动DMA,将整个后台缓冲区的内容一次性搬运到屏幕GRAM。
  4. 搬运期间,交换前后台缓冲区指针,下一帧绘制到新的后台缓冲区中。

这样,屏幕始终显示完整的一帧,避免了撕裂。但代价是需要消耗大量RAM(对于128x160 RGB565,一帧需要1281602=40KB),这对于RAM紧张的STM32F103(仅20KB)来说是难以承受的。因此,双缓冲通常用于更高端的型号或分辨率更低的屏幕。

6.3 终极优化:降低通信开销

即使使用了DMA,SPI通信本身也有开销。每一帧数据前,都需要发送设置窗口的命令(0x2A,0x2B,0x2C)。对于全屏刷新,这个开销是固定的。但我们可以通过一些技巧减少开销:

  • 保持窗口不变:如果连续多帧都是全屏更新,可以在初始化设置一次窗口后,不再重复发送窗口命令,只连续发送像素数据。但这需要确保屏幕控制器支持这种“流式写入”模式,且中途没有其他命令打断。
  • 优化颜色格式:使用RGB565(16位)而不是RGB666(18位)可以减少33%的数据量。
  • 压缩与差分更新:如果帧间变化不大,可以只更新变化的区域(脏矩形),而不是整个屏幕。这需要上层应用逻辑的支持。

驱动一块SPI TFT屏,从点亮到流畅运行,是一个典型的嵌入式系统工程。它涉及硬件接口理解、通信协议把握、驱动代码编写和性能优化多个层面。硬件SPI配合DMA是追求性能的必由之路,而软件SPI则在灵活性和调试阶段有其价值。最重要的经验是:一定要以屏幕的数据手册和可靠的初始化序列为根本,用逻辑分析仪验证波形,从低速开始逐步测试稳定性。希望我踩过的这些坑,能为你铺平一点道路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询