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位。
我给大家画个时序图,这样更直观:
看这张图,写操作的流程是这样的:
- 先把CS拉低,选中LCD。
- 根据要发的是命令还是数据,设置RS的电平。
- 在数据总线上放好数据。
- 拉低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来加速数据传输。但那是后话了,先把今天的内容消化掉,动手写写代码,跑通再说。