4. GPIO与SPI外设初始化:使用HAL库或ESP-IDF配置SPI时钟、极性、相位,初始化控制引脚
好,咱们进入第四讲。这一节,我打算把屏幕驱动里最核心的硬件初始化讲透。说白了,就是让主控芯片和屏幕之间能「对上话」。你想想看,如果GPIO脚位没配好,SPI的时钟极性搞反了,那屏幕要么不亮,要么花屏,甚至直接烧掉——嗯,我当年就吃过这个亏。
4.1 控制引脚的本质:不是所有GPIO都一样
屏幕驱动里,除了SPI数据线,还有几个控制引脚:CS(片选)、DC(数据/命令选择)、RST(复位)、BL(背光)。这些引脚,说白了就是几个高低电平的开关。但我在项目中遇到过,有人把CS接到了一个只支持输入模式的引脚上,结果死活拉不低——嗯,这就是硬件选型的坑。
我个人习惯,先把原理图摊开,确认每个控制引脚对应的MCU GPIO编号。然后,在代码里用宏定义把它们写死。比如:
/* 以STM32 HAL库为例 */
#define LCD_CS_PIN GPIO_PIN_4
#define LCD_CS_PORT GPIOA
#define LCD_DC_PIN GPIO_PIN_5
#define LCD_DC_PORT GPIOA
#define LCD_RST_PIN GPIO_PIN_6
#define LCD_RST_PORT GPIOA
#define LCD_BL_PIN GPIO_PIN_7
#define LCD_BL_PORT GPIOA
为什么要用宏?因为后期换屏幕或者改板子,你只需要改这一处。我曾经见过一个项目,所有引脚硬编码在十几个文件里,改一次引脚要翻遍整个工程——那滋味,别提了。
4.2 GPIO初始化:推挽输出,速度别设太低
控制引脚,全部配置为推挽输出模式。为什么?因为我们需要主动拉高或拉低电平,去控制屏幕的状态。开漏输出?那是给I2C用的,别搞混。
速度方面,我建议至少设为High(高速)。为什么?因为屏幕的RST复位信号,需要纳秒级的脉冲宽度。如果你把速度设成Low,脉冲可能被拉长,导致复位不彻底。嗯,这里要注意:不是所有MCU的GPIO速度选项都一样,但原则是——控制引脚的速度不低于SPI时钟速度的一半。
来看一段HAL库的初始化代码:
void LCD_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
/* 使能GPIO时钟 */
__HAL_RCC_GPIOA_CLK_ENABLE();
/* 配置CS、DC、RST、BL为推挽输出 */
GPIO_InitStruct.Pin = LCD_CS_PIN | LCD_DC_PIN | LCD_RST_PIN | LCD_BL_PIN;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(LCD_CS_PORT, &GPIO_InitStruct);
/* 初始状态:CS高电平(未选中),RST高电平(不复位) */
HAL_GPIO_WritePin(LCD_CS_PORT, LCD_CS_PIN, GPIO_PIN_SET);
HAL_GPIO_WritePin(LCD_RST_PORT, LCD_RST_PIN, GPIO_PIN_SET);
HAL_GPIO_WritePin(LCD_DC_PORT, LCD_DC_PIN, GPIO_PIN_SET);
HAL_GPIO_WritePin(LCD_BL_PORT, LCD_BL_PIN, GPIO_PIN_RESET);
}
注意看最后几行——初始状态很重要。CS默认拉高,表示不选中屏幕;RST默认拉高,表示不复位。如果你一上电就把CS拉低,屏幕可能会误判为有数据要传输,导致初始化时序混乱。
4.3 SPI外设初始化:时钟、极性、相位,一个都不能错
SPI的配置,是屏幕驱动的重中之重。我见过太多人,屏幕不亮就怀疑硬件坏了,结果一查,SPI的极性(CPOL)和相位(CPHA)配反了。
先解释一下这两个概念:
- CPOL(时钟极性):空闲时时钟线的电平。CPOL=0,空闲低电平;CPOL=1,空闲高电平。
- CPHA(时钟相位):数据在哪个时钟沿采样。CPHA=0,第一个跳变沿采样;CPHA=1,第二个跳变沿采样。
大多数屏幕驱动IC,比如ILI9341、ST7789,都支持SPI模式0(CPOL=0, CPHA=0)或模式3(CPOL=1, CPHA=1)。我个人习惯,优先用模式0,因为兼容性最好。但注意,一定要看屏幕数据手册的时序图!
来看ESP-IDF的SPI配置代码:
#include "driver/spi_master.h"
spi_device_handle_t spi_handle;
void LCD_SPI_Init(void)
{
esp_err_t ret;
spi_bus_config_t bus_cfg = {
.mosi_io_num = GPIO_NUM_11,
.miso_io_num = GPIO_NUM_12, /* 有些屏幕不需要MISO,但也要定义 */
.sclk_io_num = GPIO_NUM_13,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = 4096,
};
spi_device_interface_config_t dev_cfg = {
.clock_speed_hz = 40 * 1000 * 1000, /* 40MHz,别超过屏幕上限 */
.mode = 0, /* CPOL=0, CPHA=0 */
.spics_io_num = GPIO_NUM_10, /* CS引脚,由SPI硬件控制 */
.queue_size = 7,
.flags = SPI_DEVICE_HALFDUPLEX, /* 屏幕通常是半双工 */
};
/* 初始化SPI总线 */
ret = spi_bus_initialize(SPI2_HOST, &bus_cfg, SPI_DMA_CH_AUTO);
ESP_ERROR_CHECK(ret);
/* 添加设备 */
ret = spi_bus_add_device(SPI2_HOST, &dev_cfg, &spi_handle);
ESP_ERROR_CHECK(ret);
}
这里有个细节:clock_speed_hz我设了40MHz。但实际能不能跑到40MHz,取决于你的PCB走线长度和屏幕的驱动能力。我建议先从10MHz开始试,稳定后再往上提。如果出现花屏,大概率是时钟太快了。
4.4 控制引脚的软件模拟:当硬件SPI不够用时
有时候,MCU的硬件SPI引脚被其他外设占用了,或者你需要更灵活的时序控制。这时候,可以用GPIO模拟SPI。说白了,就是手动拉高拉低时钟线和数据线。
我个人不推荐全程用软件模拟,因为效率低。但如果你只是初始化阶段用一下,或者屏幕速率很低(比如1MHz以下),那完全没问题。
来看一段模拟SPI发送一个字节的代码:
void LCD_SPI_WriteByte(uint8_t data)
{
for (int i = 0; i < 8; i++)
{
/* 时钟拉低 */
HAL_GPIO_WritePin(SPI_SCK_PORT, SPI_SCK_PIN, GPIO_PIN_RESET);
/* 设置数据线 */
if (data & 0x80)
HAL_GPIO_WritePin(SPI_MOSI_PORT, SPI_MOSI_PIN, GPIO_PIN_SET);
else
HAL_GPIO_WritePin(SPI_MOSI_PORT, SPI_MOSI_PIN, GPIO_PIN_RESET);
/* 时钟拉高,数据被采样 */
HAL_GPIO_WritePin(SPI_SCK_PORT, SPI_SCK_PIN, GPIO_PIN_SET);
data <<= 1; /* 准备下一位 */
}
}
注意看顺序:先拉低时钟,再设置数据,最后拉高时钟。这就是SPI模式0的典型时序。如果你把顺序搞反了,数据就会在错误的边沿被采样。
delay_us(1)。这样能保证时序稳定,尤其是当MCU主频很高时。我见过有人用200MHz的MCU模拟SPI,不加延时,结果时钟脉冲宽度只有5ns,屏幕根本识别不了。
4.5 初始化顺序:先复位,再配置,最后点亮
屏幕的初始化,有一个固定的顺序。我把它总结为三步:
- 硬件复位:拉低RST引脚10ms以上,再拉高。这能让屏幕内部状态机回到初始状态。
- 发送初始化命令序列:通过SPI发送屏幕厂商提供的初始化指令,比如设置显示方向、颜色格式、扫描方式等。
- 点亮背光:拉高BL引脚,屏幕开始显示。
来看一个完整的初始化函数:
void LCD_Init(void)
{
/* 1. 初始化GPIO和SPI */
LCD_GPIO_Init();
LCD_SPI_Init();
/* 2. 硬件复位 */
HAL_GPIO_WritePin(LCD_RST_PORT, LCD_RST_PIN, GPIO_PIN_RESET);
HAL_Delay(20); /* 至少10ms */
HAL_GPIO_WritePin(LCD_RST_PORT, LCD_RST_PIN, GPIO_PIN_SET);
HAL_Delay(150); /* 等待内部初始化完成 */
/* 3. 发送初始化命令(以ILI9341为例) */
LCD_WriteCmd(0x01); /* 软件复位 */
HAL_Delay(10);
LCD_WriteCmd(0x11); /* 退出睡眠模式 */
HAL_Delay(120);
LCD_WriteCmd(0x36); /* 内存访问控制 */
LCD_WriteData(0x48); /* 根据你的屏幕方向调整 */
LCD_WriteCmd(0x3A); /* 像素格式 */
LCD_WriteData(0x55); /* 16位色 */
LCD_WriteCmd(0x29); /* 开启显示 */
/* 4. 点亮背光 */
HAL_GPIO_WritePin(LCD_BL_PORT, LCD_BL_PIN, GPIO_PIN_SET);
}
注意,LCD_WriteCmd和LCD_WriteData的区别在于DC引脚的电平。写命令时DC拉低,写数据时DC拉高。这个细节,我在第一节课就强调过——千万别搞反。
4.6 避坑指南:我踩过的三个雷
最后,分享几个我实际项目中遇到的坑,希望能帮你省点时间:
- 雷区一:CS引脚被SPI硬件自动控制,但初始化时忘了拉高。 结果屏幕一直处于选中状态,其他SPI设备无法通信。解决办法:在SPI初始化前,手动把CS拉高。
- 雷区二:SPI时钟频率设得太高,导致数据采样错误。 我遇到过一块屏幕,标称支持80MHz,但实际跑到40MHz就花屏。后来发现是PCB走线太长,信号反射严重。降频到20MHz就稳定了。
- 雷区三:复位时序太短。 有些屏幕的RST引脚需要至少20ms的低电平才能可靠复位。我一开始只给了5ms,结果10块板子里有3块初始化失败。后来统一改成50ms,再也没出过问题。
好了,这一节的内容就到这里。下一节,我们会正式开始写屏幕的驱动函数——从画一个像素点开始。嗯,那才是真正有意思的部分。