2、HAL库GPIO模拟8080时序:从理论到实战

各位同学,今天我们来啃一块硬骨头——8080并口时序。说实话,我刚入行那会儿,看到时序图就头大。但后来我发现,这东西说白了就是一组握手信号,你只要摸清楚每个引脚什么时候拉高、什么时候拉低,剩下的就是照着波形图写代码。

这一章,我会带着大家从时序图分析开始,一步步用HAL库的GPIO去模拟8080并口。嗯,咱们的目标很明确:让LCD乖乖听话,把它的ID读出来。

2.1 8080并口时序分析

先看看8080接口长什么样。它通常有这几根线:

  • CS (Chip Select):片选信号,低电平有效。说白了就是告诉LCD:“嘿,我要跟你说话了!”
  • RS (Register Select):寄存器选择。RS=0时,传输的是命令;RS=1时,传输的是数据。
  • WR (Write):写信号,低电平有效。当WR从高变低时,数据被写入LCD。
  • RD (Read):读信号,低电平有效。当RD从高变低时,从LCD读取数据。
  • DB[15:0]:数据总线,16位宽。当然,有些LCD只用8位。

我给大家画个时序图,这样更直观:

8080并口写时序图 CS RS WR DB[15:0] 数据有效 t1 t2 t1: 地址建立时间 t2: 数据建立时间

看这张图,写操作的流程是这样的:

  1. 先把CS拉低,选中LCD。
  2. 根据要发的是命令还是数据,设置RS的电平。
  3. 在数据总线上放好数据。
  4. 拉低WR,保持一段时间,再拉高。这个低脉冲就是“写入”的触发信号。

读操作类似,只是把WR换成RD。我刚开始做的时候,总搞混WR和RD的时序,后来记住一句话:写操作靠WR的下降沿,读操作靠RD的下降沿。这样就再也没错过。

关键点:8080时序的核心就是“先准备,再触发”。先准备好RS和DB,再给WR或RD一个低脉冲。顺序搞反了,数据就写不进去。

2.2 HAL库GPIO配置

接下来,咱们用STM32的HAL库来配置GPIO。我个人习惯用CubeMX生成初始化代码,然后手动调整。这样既快又灵活。

假设我们用的是STM32F103,LCD的引脚连接如下:

LCD引脚 STM32引脚 功能
CS PB0 片选
RS PB1 命令/数据选择
WR PB2 写使能
RD PB3 读使能
DB[15:0] PC0~PC15 数据总线

在CubeMX里,把这些引脚都配置成推挽输出,速度选High。注意,DB引脚在读取数据时需要切换方向,所以最好配置成开漏输出,或者用GPIO的输入输出模式切换。我这里图省事,直接用推挽输出加外部上拉电阻,读数据时把引脚切到输入模式。

代码生成后,我们写几个宏定义,方便操作:

/* 引脚定义 */
#define LCD_CS_PIN     GPIO_PIN_0
#define LCD_RS_PIN     GPIO_PIN_1
#define LCD_WR_PIN     GPIO_PIN_2
#define LCD_RD_PIN     GPIO_PIN_3
#define LCD_CS_PORT    GPIOB
#define LCD_RS_PORT    GPIOB
#define LCD_WR_PORT    GPIOB
#define LCD_RD_PORT    GPIOB
#define LCD_DATA_PORT  GPIOC

/* 控制引脚操作宏 */
#define LCD_CS_LOW()   HAL_GPIO_WritePin(LCD_CS_PORT, LCD_CS_PIN, GPIO_PIN_RESET)
#define LCD_CS_HIGH()  HAL_GPIO_WritePin(LCD_CS_PORT, LCD_CS_PIN, GPIO_PIN_SET)
#define LCD_RS_LOW()   HAL_GPIO_WritePin(LCD_RS_PORT, LCD_RS_PIN, GPIO_PIN_RESET)
#define LCD_RS_HIGH()  HAL_GPIO_WritePin(LCD_RS_PORT, LCD_RS_PIN, GPIO_PIN_SET)
#define LCD_WR_LOW()   HAL_GPIO_WritePin(LCD_WR_PORT, LCD_WR_PIN, GPIO_PIN_RESET)
#define LCD_WR_HIGH()  HAL_GPIO_WritePin(LCD_WR_PORT, LCD_WR_PIN, GPIO_PIN_SET)
#define LCD_RD_LOW()   HAL_GPIO_WritePin(LCD_RD_PORT, LCD_RD_PIN, GPIO_PIN_RESET)
#define LCD_RD_HIGH()  HAL_GPIO_WritePin(LCD_RD_PORT, LCD_RD_PIN, GPIO_PIN_SET)

小技巧:用宏定义代替函数调用,可以节省CPU开销。在时序要求严格的场景下,这点优化很关键。我曾经在一个项目中,因为用了函数调用导致时序偏差,折腾了两天才找到原因。

2.3 用延时函数模拟读写时序

好了,现在我们来写核心的读写函数。这里用简单的延时函数来模拟时序,虽然效率不高,但胜在直观易懂。

/* 简单的延时函数,单位:微秒(粗略) */
void delay_us(uint32_t us) {
    uint32_t i;
    for (i = 0; i < us * 8; i++) {
        __NOP();
    }
}

/* 写8位数据到LCD */
void LCD_Write8(uint8_t data, uint8_t isCommand) {
    /* 设置RS:命令还是数据 */
    if (isCommand) {
        LCD_RS_LOW();   /* 命令模式 */
    } else {
        LCD_RS_HIGH();  /* 数据模式 */
    }
    
    /* 拉低CS,选中LCD */
    LCD_CS_LOW();
    delay_us(1);
    
    /* 在数据总线上放数据 */
    HAL_GPIO_WritePin(LCD_DATA_PORT, 0xFF, data);
    delay_us(1);
    
    /* 产生WR低脉冲 */
    LCD_WR_LOW();
    delay_us(1);  /* 保持低电平 */
    LCD_WR_HIGH();
    delay_us(1);
    
    /* 释放CS */
    LCD_CS_HIGH();
}

/* 从LCD读8位数据 */
uint8_t LCD_Read8(void) {
    uint8_t data;
    
    /* 设置数据引脚为输入模式 */
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    GPIO_InitStruct.Pin = 0xFFFF;
    GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    HAL_GPIO_Init(LCD_DATA_PORT, &GPIO_InitStruct);
    
    /* 拉低CS */
    LCD_CS_LOW();
    delay_us(1);
    
    /* 产生RD低脉冲 */
    LCD_RD_LOW();
    delay_us(1);  /* 等待数据稳定 */
    
    /* 读取数据 */
    data = (uint8_t)HAL_GPIO_ReadPin(LCD_DATA_PORT, 0xFF);
    
    /* 拉高RD */
    LCD_RD_HIGH();
    delay_us(1);
    
    /* 释放CS */
    LCD_CS_HIGH();
    
    /* 恢复数据引脚为输出模式 */
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
    HAL_GPIO_Init(LCD_DATA_PORT, &GPIO_InitStruct);
    
    return data;
}

注意:上面的读函数中,我切换了GPIO的方向。这样做虽然可行,但每次切换都有开销。在实际项目中,我建议用开漏输出+外部上拉的方式,这样读数据时只需要把输出寄存器置1,然后读取引脚状态即可,省去了切换模式的麻烦。

2.4 实战:初始化LCD并读取ID

理论讲完了,咱们来点实战。以ILI9341这款常用的LCD驱动芯片为例,它的初始化流程大致是:先发一串初始化命令,然后读取芯片ID确认通信正常。

ILI9341的读ID命令是0xD3,返回4个字节的数据。我们来看看怎么实现:

/* ILI9341初始化序列(简化版) */
void ILI9341_Init(void) {
    /* 硬件复位 */
    LCD_CS_HIGH();
    HAL_Delay(10);
    /* 这里假设有RST引脚,拉低再拉高 */
    /* ... */
    
    /* 发送初始化命令 */
    LCD_Write8(0x01, 1);  /* 软件复位 */
    HAL_Delay(120);
    
    LCD_Write8(0x11, 1);  /* 退出睡眠模式 */
    HAL_Delay(120);
    
    LCD_Write8(0x36, 1);  /* 内存访问控制 */
    LCD_Write8(0x48, 0);  /* 参数:BGR顺序 */
    
    LCD_Write8(0x3A, 1);  /* 像素格式 */
    LCD_Write8(0x55, 0);  /* 16位色 */
    
    LCD_Write8(0x29, 1);  /* 开启显示 */
    HAL_Delay(10);
}

/* 读取LCD ID */
uint32_t ILI9341_ReadID(void) {
    uint32_t id = 0;
    
    /* 发送读ID命令 */
    LCD_Write8(0xD3, 1);
    
    /* 读取4个字节 */
    id |= (uint32_t)LCD_Read8() << 24;  /* 第一个字节:制造商ID */
    id |= (uint32_t)LCD_Read8() << 16;  /* 第二个字节:保留 */
    id |= (uint32_t)LCD_Read8() << 8;   /* 第三个字节:芯片ID高8位 */
    id |= (uint32_t)LCD_Read8();          /* 第四个字节:芯片ID低8位 */
    
    return id;
}

/* 主函数中调用 */
int main(void) {
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    
    ILI9341_Init();
    
    uint32_t lcd_id = ILI9341_ReadID();
    
    /* 如果ID为0x9341,说明通信正常 */
    if ((lcd_id & 0xFFFF) == 0x9341) {
        /* 初始化成功! */
        /* 这里可以点亮屏幕之类的 */
    } else {
        /* 初始化失败,检查硬件连接 */
        while(1);
    }
    
    while(1) {
        /* 主循环 */
    }
}

这段代码的核心逻辑很简单:先初始化LCD,再读ID验证。如果读到的ID是0x9341,说明我们的时序模拟成功了。

避坑指南:我曾经在一个项目里,读ID总是返回0xFFFF。查了半天,发现是WR和RD的时序太短,LCD没反应过来。后来把延时从1us加到5us,问题就解决了。所以,如果读ID失败,先检查时序延时是否足够

嗯,到这里,我们已经完成了8080并口的时序模拟,并且成功读取了LCD的ID。这个过程虽然看起来简单,但它是所有LCD驱动的基础。你想想看,只要掌握了这个读写函数,后面不管是画点、画线还是显示图片,都只是在这个基础上加功能而已。

下一章,我们会在这个基础上,实现更高级的功能——比如用DMA来加速数据传输。但那是后话了,先把今天的内容消化掉,动手写写代码,跑通再说。


专注资料整理