1. 项目概述:从点灯到点亮世界
玩STM32的朋友,估计都是从点亮一颗LED开始的。当GPIO的高低电平控制着那颗小灯珠明灭时,那种“我能控制硬件了”的成就感,是驱动我们继续深入的动力。但很快,你会发现,单色的LED和数码管已经无法满足表达的欲望。你想看到色彩,想显示图片,甚至想做一个带界面的小设备。这时,一块彩色的屏幕就成了进阶路上的“必修课”。
我手头正好有一块2.8寸的IPS彩屏,分辨率240x320,接口是SPI。IPS屏的好处是可视角度大,色彩表现也比普通的TFT屏要好一些,做个小仪表盘或者显示个状态信息,视觉效果会舒服很多。驱动它,本质上就是通过STM32的SPI接口,向屏幕的控制器(我这款是ILI9341)发送命令和数据,告诉它“在哪个位置,显示什么颜色”。
听起来和I2C驱动OLED有点像?确实,都是串行通信,但SPI的速度快得多,这对于刷新整块彩屏至关重要。这个项目,就是要把STM32的SPI用起来,让这块沉睡的屏幕亮起来,显示点我们想让它显示的东西。无论你是想做个温湿度显示终端,还是为你的机器人做个状态监视器,这都是非常扎实的第一步。
2. 核心思路与硬件连接解析
2.1 为什么是SPI?协议选型的背后考量
拿到一块彩屏,首先看接口。常见的有并口(8080/6800)和串口(SPI)。我这款是SPI接口,对于初学者和小型项目来说,这其实是个福音。
并口 vs 串口:并口需要占用大量的GPIO(通常是8位或16位数据线,加上几根控制线),速度快,适合大屏或高刷新率场景。而SPI通常只需要3-4根线(MOSI, MISO, SCK, CS),有些屏甚至把MISO也省了(只写不读),极大节省了宝贵的单片机引脚。对于240x320这种分辨率的屏,SPI的速率完全够用,尤其是在显示静态内容或更新不频繁的动态数据时。
SPI的四种模式:这是新手最容易懵的地方。SPI模式由时钟极性(CPOL)和时钟相位(CPHA)决定,共有0,1,2,3四种模式。不同的外设芯片,要求的模式可能不同。驱动LCD屏,模式0(CPOL=0, CPHA=0)或模式3(CPOL=1, CPHA=1)是最常见的。具体需要查你的屏幕驱动芯片手册。以ILI9341为例,它通常工作在SPI模式3。这意味着:
- CPOL=1:空闲时,时钟线SCK为高电平。
- CPHA=1:数据在时钟的第二个边沿(对于CPOL=1来说,就是下降沿)被采样。
如果模式设错,屏幕是绝对不会有任何反应的,就像对着一个聋子说话。所以,确认模式是调试的第一步。
硬件SPI vs 软件模拟SPI:STM32有硬件SPI外设,配置好后,你只需要往数据寄存器(DR)里写数据,硬件就会自动按照你设置的波特率、模式把数据一位位发出去,效率高,不占用CPU。软件模拟SPI则是用普通GPIO,通过代码控制电平变化来模拟时序,灵活但速度慢。对于驱动彩屏,强烈建议使用硬件SPI,否则刷新一帧图像会慢得让你怀疑人生。
2.2 硬件接线图与引脚功能详解
接线是实打实的第一步,线接错了,一切免谈。我们需要连接以下几类线:
1. 电源线(必须正确,否则烧屏!):
- VCC:接3.3V。绝大多数STM32开发板和3.3V供电的IPS屏都接3.3V,接5V很可能损坏屏幕或STM32的IO口。
- GND:接共地。
2. SPI通信线(核心):
- SCK (Serial Clock):时钟线。接STM32的SPI时钟引脚,例如SPI1的PA5。
- MOSI (Master Out Slave In):主设备输出,从设备输入。接STM32的SPI主输出引脚,例如SPI1的PA7。这条线负责向屏幕发送命令和数据。
- DC (Data/Command):数据/命令选择线。这是一根非常重要的控制线。它告诉屏幕控制器,接下来SPI线上传来的是“命令”(如设置地址窗口、开显示等)还是“数据”(如要显示的像素颜色值)。通常高电平为数据,低电平为命令。接一个普通的GPIO,如PB0。
- CS (Chip Select):片选线,低电平有效。当多个SPI设备挂在同一总线上时,用这根线来选择与哪个设备通信。即使只有一个设备,也需要控制它。接一个普通的GPIO,如PB1。
- RESET (Reset):复位线,低电平复位。用于硬复位屏幕控制器。接一个普通的GPIO,如PB2。上电后,最好给一个低电平脉冲(如拉低10ms再拉高)以确保屏幕控制器处于已知的初始状态。
3. 背光线:
- LED (Backlight):背光控制。有些屏直接接VCC常亮;有些可以通过PWM调节亮度,这时可以接一个STM32的PWM输出引脚。
下面是一个典型的接线表示例(以STM32F103C8T6核心板,使用SPI1为例):
| 屏幕引脚 | 标号 | STM32引脚 | 功能说明 |
|---|---|---|---|
| VCC | 3.3V | 3.3V | 电源,务必接对 |
| GND | GND | GND | 共地 |
| CS | CS | PB1 (GPIO_Output) | 片选,低电平有效 |
| RESET | RESET | PB2 (GPIO_Output) | 复位,低电平有效 |
| DC | DC/RS | PB0 (GPIO_Output) | 数据/命令选择 |
| MOSI | SDI(MOSI) | PA7 (SPI1_MOSI) | SPI主输出数据线 |
| SCK | SCK | PA5 (SPI1_SCK) | SPI时钟线 |
| LED | BL | 3.3V 或 PA8 (PWM) | 背光,接VCC常亮或PWM调光 |
注意:有些屏幕的引脚名称可能略有不同,比如DC可能叫RS或A0,MOSI可能叫SDA,但功能是一样的。务必以你屏幕的资料为准。
3. 软件驱动层设计与核心代码实现
硬件连接妥当后,我们来构建软件部分。一个好的驱动应该分层清晰:底层硬件抽象、中间驱动层、上层应用。这里我们聚焦在驱动层。
3.1 底层GPIO与SPI初始化配置
首先,我们需要初始化相关的GPIO和SPI外设。如果你使用CubeMX,这会非常方便;如果直接寄存器开发,则需要仔细配置。
关键配置点:
- SPI模式:全双工主模式(Master)。
- 数据大小:8位(通常LCD命令和数据都以8位传输)。
- 波特率预分频器:这是决定SPI速度的关键。SPI时钟 = APB总线时钟 / 预分频值。对于STM32F1,APB2时钟是72MHz。初期调试可以设得低一些(如分频128,约562.5KHz),确保通信稳定。后续优化可以提高(如分频4,达到18MHz)。注意,屏幕有最高SCK频率限制,请查阅数据手册(ILI9341通常支持到几十MHz)。
- CPOL和CPHA:根据屏幕要求设置,如前所述,设为3(CPOL=1, CPHA=1)。
- 片选(CS)管理:硬件SPI的NSS引脚通常不用于片选,因为一个SPI总线可能挂多个设备。我们使用软件控制普通的GPIO(PB1)来作为CS信号。
初始化后,我们需要编写几个最基础的底层函数:
// 软件片选控制 #define LCD_CS_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_RESET) #define LCD_CS_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_SET) // 数据/命令控制 #define LCD_DC_CMD() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET) // 写命令 #define LCD_DC_DATA() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET) // 写数据 // 复位控制 #define LCD_RST_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_2, GPIO_PIN_RESET) #define LCD_RST_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_2, GPIO_PIN_SET) // 毫秒级延迟(需要实现,如HAL_Delay) void LCD_Reset(void) { LCD_RST_LOW(); HAL_Delay(100); // 复位低电平保持100ms LCD_RST_HIGH(); HAL_Delay(100); // 复位后等待稳定 } // SPI发送一个字节(阻塞式) void LCD_SPI_SendByte(uint8_t data) { HAL_SPI_Transmit(&hspi1, &data, 1, HAL_MAX_DELAY); // hspi1是CubeMX生成的SPI句柄 }3.2 屏幕初始化序列与命令解析
屏幕在上电或复位后,需要一系列的命令进行初始化,设置伽马值、颜色模式、扫描方向、打开显示等。这个命令序列因驱动芯片而异,通常可以在屏幕供应商提供的例程或驱动芯片数据手册中找到。
初始化过程的核心是“写命令”和“写数据”两个操作:
- 写命令:拉低DC线,发送命令码(如0x36是存储访问控制命令)。
- 写数据:拉高DC线,发送该命令所需的参数(如0x36命令后跟的参数决定了显示方向)。
// 写命令函数 void LCD_Write_Cmd(uint8_t cmd) { LCD_DC_CMD(); // 设置为命令模式 LCD_SPI_SendByte(cmd); } // 写数据函数 void LCD_Write_Data(uint8_t data) { LCD_DC_DATA(); // 设置为数据模式 LCD_SPI_SendByte(data); } // 写多个数据(用于初始化序列或填充颜色) void LCD_Write_Data_Buffer(uint8_t *data, uint32_t length) { LCD_DC_DATA(); HAL_SPI_Transmit(&hspi1, data, length, HAL_MAX_DELAY); // 使用DMA会更快 }一个典型的初始化函数片段如下:
void LCD_Init(void) { // 1. 硬件复位 LCD_Reset(); // 2. 发送初始化命令序列 LCD_CS_LOW(); // 使能片选 LCD_Write_Cmd(0xCF); // 电源控制B LCD_Write_Data(0x00); LCD_Write_Data(0x83); LCD_Write_Data(0x30); // ... 省略更多初始化命令,可能长达十几条 ... LCD_Write_Cmd(0x11); // 退出睡眠模式 HAL_Delay(120); // 等待120ms,手册要求 LCD_Write_Cmd(0x29); // 打开显示 HAL_Delay(20); LCD_CS_HIGH(); // 关闭片选 // 3. 设置默认显示方向、颜色格式等 LCD_Set_Orientation(0); // 0度方向 }实操心得:初始化序列又长又枯燥,但绝对不能错一个字节。建议直接从可靠的例程中复制粘贴。如果屏幕点亮后花屏、颜色不对,首先检查初始化序列,尤其是颜色格式(RGB565还是RGB888)相关的命令。
3.3 设置显示窗口与像素填充
要在指定位置画点或填充区域,必须先告诉屏幕控制器“绘图区域”的坐标范围。这是通过设置“列地址”和“页地址”窗口实现的。
对于ILI9341(240x320):
- 设置列地址(X方向):发送命令0x2A,后跟4个字节数据:起始列高8位、低8位,结束列高8位、低8位。
- 设置页地址(Y方向):发送命令0x2B,后跟4个字节数据:起始页高8位、低8位,结束页高8位、低8位。
- 开始写入GRAM:发送命令0x2C。之后,所有通过SPI发送的数据都会被当作像素颜色值,依次填充到设定的窗口内,自动按行递增。
// 设置显示窗口 (x1, y1) 左上角, (x2, y2) 右下角 void LCD_Set_Window(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2) { LCD_Write_Cmd(0x2A); // 列地址设置 LCD_Write_Data(x1 >> 8); // 起始列高字节 LCD_Write_Data(x1 & 0xFF); // 起始列低字节 LCD_Write_Data(x2 >> 8); // 结束列高字节 LCD_Write_Data(x2 & 0xFF); // 结束列低字节 LCD_Write_Cmd(0x2B); // 页地址设置 LCD_Write_Data(y1 >> 8); LCD_Write_Data(y1 & 0xFF); LCD_Write_Data(y2 >> 8); LCD_Write_Data(y2 & 0xFF); LCD_Write_Cmd(0x2C); // 开始写入GRAM }设置好窗口后,就可以向这个矩形区域填充颜色了。颜色格式通常是RGB565,即一个像素用16位(2字节)表示:高5位是红色,中间6位是绿色,低5位是蓝色。
// 填充指定区域为单一颜色 void LCD_Fill(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t color) { uint32_t total_pixels = (uint32_t)(x2 - x1 + 1) * (y2 - y1 + 1); LCD_Set_Window(x1, y1, x2, y2); LCD_CS_LOW(); LCD_DC_DATA(); for(uint32_t i=0; i<total_pixels; i++) { // 连续发送颜色值的高字节和低字节 LCD_SPI_SendByte(color >> 8); // 发送高字节 LCD_SPI_SendByte(color & 0xFF); // 发送低字节 } LCD_CS_HIGH(); } // 在指定位置画一个点 void LCD_DrawPoint(uint16_t x, uint16_t y, uint16_t color) { // 设置窗口为单个像素点 LCD_Set_Window(x, y, x, y); LCD_CS_LOW(); LCD_Write_Data(color >> 8); LCD_Write_Data(color & 0xFF); LCD_CS_HIGH(); }3.4 显示方向与颜色格式的深入配置
显示方向(旋转):通过命令0x36(存储访问控制,MADCTL)来设置。通过修改其参数,可以实现0°、90°、180°、270°旋转以及镜像。参数是一个8位值,不同位控制不同功能:
- MY:行地址顺序(上下镜像)
- MX:列地址顺序(左右镜像)
- MV:行列交换(横竖屏切换)
- ML:垂直刷新顺序
- RGB:RGB/BGR顺序
- MH:水平刷新顺序
例如,0x48通常代表竖屏模式(MV=1),0x28代表横屏模式(MV=0)。你需要根据你的屏幕物理安装方向和期望的坐标系来调整这个值。
颜色格式:通过命令0x3A(像素格式设置)来配置。常见的是RGB565(0x55)和RGB888(0x66)。我们的驱动通常使用RGB565,因为16位颜色对于单片机处理和在SPI上传输更高效。如果你用LCD_Write_Data_Buffer发送一张图片数据,图片数组必须是RGB565格式的。
4. 性能优化与高级功能探索
基础驱动完成后,屏幕能亮了,也能画点画方块了。但你可能发现,刷全屏速度很慢,动画有卡顿。这时就需要进行优化。
4.1 使用DMA加速SPI数据传输
这是提升刷新率最有效的手段。SPI的阻塞式传输(HAL_SPI_Transmit)需要CPU等待每个字节发送完成,大量时间浪费在等待上。DMA(直接存储器访问)可以在不占用CPU的情况下,自动将内存中的数据搬运到SPI数据寄存器中。
配置步骤(以CubeMX为例):
- 在SPI配置中,使能DMA传输请求(TX方向)。
- 添加一个DMA流(Stream),方向为存储器到外设,模式为普通模式(非循环)。
- 在代码中,使用
HAL_SPI_Transmit_DMA函数替代HAL_SPI_Transmit。
// 使用DMA填充区域(示例片段) uint16_t color_buffer[BUFFER_SIZE]; // 定义一个颜色缓冲区 // ... 将颜色填充到buffer ... void LCD_Fill_DMA(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t color) { uint32_t total_pixels = (uint32_t)(x2 - x1 + 1) * (y2 - y1 + 1); // 1. 用目标颜色填充缓冲区 for(int i=0; i<BUFFER_SIZE; i++) { color_buffer[i] = color; } // 2. 设置窗口 LCD_Set_Window(x1, y1, x2, y2); LCD_CS_LOW(); LCD_DC_DATA(); // 3. 计算需要发送多少次(每次发送BUFFER_SIZE个像素,即BUFFER_SIZE*2字节) uint32_t remaining = total_pixels * 2; // 总字节数 uint8_t *p = (uint8_t*)color_buffer; // 转换为字节指针 while(remaining > 0) { uint32_t send_size = (remaining > BUFFER_SIZE*2) ? BUFFER_SIZE*2 : remaining; HAL_SPI_Transmit_DMA(&hspi1, p, send_size); // 等待本次DMA传输完成(可以通过回调函数或标志位非阻塞等待) while(hspi1.State == HAL_SPI_STATE_BUSY_TX); remaining -= send_size; p += send_size; } LCD_CS_HIGH(); }注意事项:DMA传输是异步的。你需要管理好传输完成的回调,确保在上一次DMA传输完成前,不要操作发送缓冲区,也不要开始下一次传输,否则会导致数据错乱。可以使用
HAL_SPI_TxCpltCallback回调函数,或者像上面例子一样简单使用阻塞等待(这失去了部分DMA的优势,但逻辑简单)。
4.2 双缓冲与局部刷新策略
双缓冲:在内存中开辟两块和屏幕大小一样的显示缓冲区(Frame Buffer)。所有的绘图操作都在“后台缓冲区”进行。当一帧画面绘制完成后,通过DMA快速将整个后台缓冲区的内容搬运到屏幕上,然后交换前后台缓冲区。这可以避免屏幕撕裂(即绘制过程中屏幕显示一半旧数据一半新数据)。但对于STM32F1这类内存较小的芯片,全屏双缓冲(2403202=150KB)可能不现实。
局部刷新:这是更实用的优化。不要动不动就LCD_Fill(0,0,239,319, BLACK)清全屏。只更新屏幕上发生变化的部分区域。例如,在显示一个不断变化的数字时,只清除和重绘这个数字所在的矩形区域,而不是整个屏幕。
4.3 显示图片与中英文字库
显示图片:
- 取模:使用工具(如Image2Lcd、LCDImageConverter)将图片转换为C语言数组。选择输出格式为RGB565,并可能需要进行宽度和高度的对齐。
- 存储:图片数组可以放在内部Flash(常量数组,会占用程序空间)或外部SPI Flash、SD卡中。
- 显示:设置图片对应的显示窗口,然后使用
LCD_Write_Data_Buffer(配合DMA最佳)将图片数组数据连续发送出去即可。
显示文字:
- 字库:需要点阵字库。ASCII字符可以用一个12x12、16x16的数组。中文字库庞大,通常需要外置Flash存储。
- 取模方式:有逐行式、逐列式、行列式等,需要和你的显示函数配合。例如,如果你的画点函数是水平扫描(X增加),那么字模数据也应该是水平优先的。
- 显示函数:根据字模数据,在屏幕上相应的位置画点(为1画前景色,为0画背景色)。可以预先写好显示单个字符和字符串的函数。
// 显示一个16x16 ASCII字符的简单示例 void LCD_ShowChar(uint16_t x, uint16_t y, char ch, uint16_t color, uint16_t bgcolor) { uint8_t i, j; uint8_t *p = (uint8_t*)&ascii_16x16[(ch - ' ') * 32]; // 假设字库数组 LCD_Set_Window(x, y, x+15, y+15); LCD_CS_LOW(); LCD_DC_DATA(); for(i=0; i<16; i++) { // 16行 uint8_t line_data = *p++; for(j=0; j<8; j++) { // 每行前8位 uint16_t pixel_color = (line_data & (0x80>>j)) ? color : bgcolor; LCD_SPI_SendByte(pixel_color >> 8); LCD_SPI_SendByte(pixel_color & 0xFF); } line_data = *p++; // 每行后8位 for(j=0; j<8; j++) { uint16_t pixel_color = (line_data & (0x80>>j)) ? color : bgcolor; LCD_SPI_SendByte(pixel_color >> 8); LCD_SPI_SendByte(pixel_color & 0xFF); } } LCD_CS_HIGH(); }5. 调试心法与常见问题实录
驱动SPI彩屏的过程,就是与各种奇怪现象斗争的过程。下面是我踩过的一些坑和解决方法。
5.1 屏幕完全无反应(白屏或黑屏)
这是最令人沮丧的情况。排查顺序如下:
- 电源和背光:万用表测量VCC和GND之间是否为3.3V?背光LED是否有电压?有些屏背光需要串联限流电阻。
- 复位信号:确保复位引脚上电后有一个正确的低电平脉冲(>10ms)。可以用逻辑分析仪或示波器抓一下波形。
- SPI通信:这是重点。
- 逻辑分析仪是神器:连接SCK,MOSI,DC,CS四根线。观察上电初始化阶段是否有波形。如果没有,检查STM32的SPI和GPIO初始化代码。
- 检查SPI模式:确认CPOL和CPHA是否与屏幕要求一致。模式错误是导致无反应的常见原因。
- 检查片选CS:发送数据前是否拉低?发送完成后是否拉高?逻辑分析仪上看CS信号是否正常。
- 检查DC线:发送命令时是否为低电平,发送数据时是否为高电平?
- 初始化序列:确认初始化命令序列完全正确,特别是退出睡眠(0x11)和打开显示(0x29)命令及其延迟是否满足。
5.2 屏幕花屏、错位、颜色异常
如果屏幕有显示,但内容乱七八糟,说明通信基本通了,但数据解读错了。
- 显示方向错误:画面旋转或镜像了。调整0x36命令的参数。
- 颜色格式错误:屏幕设置为RGB565,但你发送的是RGB888数据,或者BGR顺序错了。检查0x3A命令的设置。
- 窗口设置错误:画点或填充函数中的坐标计算或窗口设置命令(0x2A, 0x2B)发送有误。确认你的屏幕坐标系原点(通常是左上角)和方向。
- 数据传输位序:极少数情况下,需要关注SPI的MSB/LSB First设置。STM32 SPI通常默认为MSB First,这也是最常见的。
5.3 刷新速度慢,动画卡顿
- SPI波特率:首先检查SPI的波特率预分频是否设到了最高允许值(需参考屏幕和STM32主频的限制)。
- 是否使用硬件SPI:确保没有误用软件模拟SPI。
- 引入DMA:如前所述,这是最大的性能瓶颈突破口。
- 优化绘图算法:避免全屏刷新,采用局部刷新。避免在循环中频繁调用
LCD_Set_Window,一次性设置好大区域再填充。 - 编译器优化:尝试将编译器优化等级提高(如-O2),特别是纯数据处理的部分会快很多。
5.4 内存不足与显示缓冲区管理
在显示图片或复杂界面时,可能会遇到内存不足的问题。
- 使用
const存储大数组:将图片、字库等只读数据用const关键字定义,它们会被存储到Flash中,节省宝贵的RAM。 - 分段处理:显示大图片时,不要一次性将整个图片数组载入RAM。可以分块从外部存储器读取,分块发送到屏幕。
- 谨慎使用全屏缓冲区:在RAM紧张的MCU上,尽量避免开辟全屏的帧缓冲区。采用“计算一点,发送一点”的流式处理。
5.5 抗干扰与稳定性提升
当屏幕与电机、继电器等大功率器件一起工作时,可能出现显示乱码或复位。
- 电源去耦:在屏幕的VCC和GND引脚就近放置一个10uF的电解电容和一个0.1uF的瓷片电容。
- 信号线:如果SPI线较长(>10cm),可以考虑在信号线上串联一个几十欧姆的电阻,以减少振铃和过冲。
- 软件抗干扰:在关键操作(如初始化、清屏)前后加入短暂延时。对于重要的命令,可以考虑发送两遍。
- 接地:确保所有地线良好连接,形成单点接地或星型接地,避免地环路引入噪声。
驱动一块SPI彩屏,从硬件连接到软件调试,再到性能优化,是一个系统工程。它完美地串联了GPIO、SPI、中断、DMA等单片机核心知识。当你的代码最终让屏幕清晰地显示出预设的图形和文字时,那种掌控硬件的愉悦感,是点亮LED的十倍不止。这不仅仅是驱动了一个外设,更是为你打开了一扇通往更复杂嵌入式图形界面(如LVGL、emWin)的大门。接下来,你可以尝试在屏幕上画更复杂的图形,显示传感器实时数据曲线,甚至移植一个轻量级的GUI库,那将是另一个充满挑战和乐趣的世界。