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,因为兼容性最好。但注意,一定要看屏幕数据手册的时序图!

⚠️ 我曾经踩过的坑: 有一次用ESP32驱动一块国产屏幕,数据手册上写的是「SPI Mode 0」,但我用逻辑分析仪抓出来,发现屏幕实际工作在Mode 2。后来才发现,手册里那个时序图画错了。所以,我的建议是——拿到屏幕后,先用逻辑分析仪抓一下原厂驱动板的SPI波形,确认极性相位。

来看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的典型时序。如果你把顺序搞反了,数据就会在错误的边沿被采样。

💡 小技巧: 如果你用软件模拟SPI,建议在每次时钟跳变后加一个微秒级的延时。比如用 delay_us(1)。这样能保证时序稳定,尤其是当MCU主频很高时。我见过有人用200MHz的MCU模拟SPI,不加延时,结果时钟脉冲宽度只有5ns,屏幕根本识别不了。

4.5 初始化顺序:先复位,再配置,最后点亮

屏幕的初始化,有一个固定的顺序。我把它总结为三步:

  1. 硬件复位:拉低RST引脚10ms以上,再拉高。这能让屏幕内部状态机回到初始状态。
  2. 发送初始化命令序列:通过SPI发送屏幕厂商提供的初始化指令,比如设置显示方向、颜色格式、扫描方式等。
  3. 点亮背光:拉高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_WriteCmdLCD_WriteData的区别在于DC引脚的电平。写命令时DC拉低,写数据时DC拉高。这个细节,我在第一节课就强调过——千万别搞反。

4.6 避坑指南:我踩过的三个雷

最后,分享几个我实际项目中遇到的坑,希望能帮你省点时间:

  • 雷区一:CS引脚被SPI硬件自动控制,但初始化时忘了拉高。 结果屏幕一直处于选中状态,其他SPI设备无法通信。解决办法:在SPI初始化前,手动把CS拉高。
  • 雷区二:SPI时钟频率设得太高,导致数据采样错误。 我遇到过一块屏幕,标称支持80MHz,但实际跑到40MHz就花屏。后来发现是PCB走线太长,信号反射严重。降频到20MHz就稳定了。
  • 雷区三:复位时序太短。 有些屏幕的RST引脚需要至少20ms的低电平才能可靠复位。我一开始只给了5ms,结果10块板子里有3块初始化失败。后来统一改成50ms,再也没出过问题。

好了,这一节的内容就到这里。下一节,我们会正式开始写屏幕的驱动函数——从画一个像素点开始。嗯,那才是真正有意思的部分。