3. 驱动函数封装:写命令、写数据、读数据、延迟函数

好,咱们接着往下走。上一章我们把底层 GPIO 的初始化搞定了,现在芯片的引脚已经能听我们使唤了。但说实话,你总不能在 main 函数里直接对着寄存器一顿操作吧?那代码没法看,也没法维护。

所以这一章,我们要做一件很重要的事——封装驱动函数。说白了,就是把那些底层的、重复的、容易出错的寄存器操作,包成一个个好用的函数。以后上层代码只管调用,不用管底层怎么折腾。

我个人习惯,先把最基础的四个函数写好:Write_Cmd()Write_Data()Read_Data()Delay()。这四个函数,就是整个 LCD 驱动的基石。

3.1 写命令函数:告诉 LCD 你要干什么

LCD 的控制器,比如常见的 ST7565、SSD1306,它们都有一套指令集。你想清屏、设置光标、开显示,都得通过写命令来实现。

写命令的时序,我画个简图你感受一下:

RS  = 0   // 命令模式
RW  = 0   // 写模式
E   = 1   // 使能拉高
// 数据总线上放命令字节
E   = 0   // 使能拉低,锁存数据

嗯,这里要注意:RS 引脚必须拉低,告诉 LCD “我发的是命令,不是数据”。我刚开始做的时候,有一次 RS 忘了拉低,结果发了一堆数据过去,屏幕乱码了。排查了半天才发现是 RS 电平不对。

代码实现如下:

void Write_Cmd(uint8_t cmd)
{
    // 先拉低 RS,进入命令模式
    LCD_RS_LOW();
    
    // 拉低 RW,选择写操作
    LCD_RW_LOW();
    
    // 拉高使能
    LCD_E_HIGH();
    
    // 把命令字节放到数据总线上
    // 这里假设数据总线是 8 位并行
    LCD_DATA_PORT = cmd;
    
    // 延时一小会儿,让 LCD 采样
    Delay_us(1);
    
    // 拉低使能,锁存数据
    LCD_E_LOW();
    
    // 保持一小段时间,等待 LCD 处理
    Delay_us(1);
}
我的小经验: 使能引脚 E 的脉冲宽度,不同 LCD 模块要求不一样。我建议你查一下数据手册,一般要求至少 450ns。如果单片机跑得慢,可以不加延时;如果跑得快,比如 72MHz 的 STM32,不加延时可能就翻车了。

3.2 写数据函数:往显存里填内容

写数据和写命令,流程几乎一样。唯一的区别就是:RS 要拉高

为什么?因为 RS=1 告诉 LCD:“我发的是数据,不是命令”。数据会被写入当前光标位置的显存单元,然后光标自动加一。

void Write_Data(uint8_t data)
{
    // RS 拉高,进入数据模式
    LCD_RS_HIGH();
    
    // RW 拉低,写操作
    LCD_RW_LOW();
    
    // 使能拉高
    LCD_E_HIGH();
    
    // 数据总线放数据
    LCD_DATA_PORT = data;
    
    // 延时
    Delay_us(1);
    
    // 使能拉低,锁存
    LCD_E_LOW();
    
    // 保持
    Delay_us(1);
}

你看,和 Write_Cmd() 长得几乎一样,就是 RS 的电平不同。我见过有些工程师把这两个函数合并成一个,加个参数区分。我个人不建议这么做——分开写更清晰,调用时一眼就能看出意图

3.3 读数据函数:从 LCD 读回状态或数据

读操作,说实话,在 LCD 驱动里用得不多。但有些场景你必须用,比如:

  • 读取 LCD 的忙标志(Busy Flag),判断是否忙
  • 读取显存内容,实现局部刷新

读时序和写时序的区别在于:RW 要拉高,而且数据总线要改成输入模式。

uint8_t Read_Data(void)
{
    uint8_t data;
    
    // RS 拉高,数据模式
    LCD_RS_HIGH();
    
    // RW 拉高,读操作
    LCD_RW_HIGH();
    
    // 数据总线改为输入
    // 注意:不同 MCU 的 GPIO 配置方式不同
    LCD_DATA_DDR = 0x00;  // 假设是 AVR 风格
    
    // 使能拉高
    LCD_E_HIGH();
    
    // 延时等待数据稳定
    Delay_us(1);
    
    // 读取数据
    data = LCD_DATA_PIN;
    
    // 使能拉低
    LCD_E_LOW();
    
    // 数据总线改回输出
    LCD_DATA_DDR = 0xFF;
    
    return data;
}
曾经踩过的坑: 读操作完成后,一定要把数据总线改回输出模式!我曾经有一次忘了改回来,结果后面写数据的时候,总线还在输入状态,写出去的信号全被外部拉低了,屏幕直接黑掉。排查了整整一个下午...

3.4 延迟函数:让时序“慢下来”

LCD 是个慢速设备。你想想看,单片机跑几十兆赫兹,LCD 控制器可能只能处理几兆赫兹的信号。所以,延迟函数是必须的

延迟函数有两种实现方式:

方式 优点 缺点 适用场景
软件延时(空循环) 简单、不占硬件资源 不精确、阻塞 CPU 简单项目、延时要求不高
硬件定时器 精确、不阻塞 CPU 占用定时器资源 复杂项目、需要精确时序

我个人习惯,在 LCD 驱动里用软件延时就够了。因为 LCD 的时序要求并不苛刻,微秒级的误差完全没问题。

下面是一个简单的微秒级软件延时:

// 假设 MCU 主频为 8MHz
// 一个空循环大约 4 个时钟周期 = 0.5us
void Delay_us(uint16_t us)
{
    uint16_t i, j;
    for(i = 0; i < us; i++)
    {
        for(j = 0; j < 2; j++)  // 这个系数需要根据实际主频调整
        {
            __NOP();  // 空操作
        }
    }
}

// 毫秒级延时,方便上层调用
void Delay_ms(uint16_t ms)
{
    uint16_t i;
    for(i = 0; i < ms; i++)
    {
        Delay_us(1000);
    }
}
避坑指南: 软件延时的系数,一定要用示波器或者逻辑分析仪实际校准。我遇到过有人直接复制网上的代码,结果主频不一样,延时差了十倍,屏幕死活不亮。后来用示波器一看,使能脉冲才 50ns,LCD 根本采样不到。

3.5 四个函数的完整封装

好了,我们把四个函数放在一起,形成一个完整的驱动层头文件。以后写上层代码,只需要包含这个头文件,调用这四个函数就行了。

// lcd_driver.h
#ifndef __LCD_DRIVER_H
#define __LCD_DRIVER_H

#include <stdint.h>

// 函数声明
void Write_Cmd(uint8_t cmd);
void Write_Data(uint8_t data);
uint8_t Read_Data(void);
void Delay_us(uint16_t us);
void Delay_ms(uint16_t ms);

#endif

对应的实现文件:

// lcd_driver.c
#include "lcd_driver.h"
#include "lcd_hw.h"  // 硬件相关的宏定义,比如 LCD_RS_HIGH 等

void Write_Cmd(uint8_t cmd)
{
    LCD_RS_LOW();
    LCD_RW_LOW();
    LCD_E_HIGH();
    LCD_DATA_PORT = cmd;
    Delay_us(1);
    LCD_E_LOW();
    Delay_us(1);
}

void Write_Data(uint8_t data)
{
    LCD_RS_HIGH();
    LCD_RW_LOW();
    LCD_E_HIGH();
    LCD_DATA_PORT = data;
    Delay_us(1);
    LCD_E_LOW();
    Delay_us(1);
}

uint8_t Read_Data(void)
{
    uint8_t data;
    LCD_RS_HIGH();
    LCD_RW_HIGH();
    LCD_DATA_DDR = 0x00;
    LCD_E_HIGH();
    Delay_us(1);
    data = LCD_DATA_PIN;
    LCD_E_LOW();
    LCD_DATA_DDR = 0xFF;
    return data;
}

void Delay_us(uint16_t us)
{
    // 根据实际主频调整循环系数
    while(us--)
    {
        // 大约 1us 的延时
        __NOP();
        __NOP();
        __NOP();
        __NOP();
    }
}

void Delay_ms(uint16_t ms)
{
    while(ms--)
    {
        Delay_us(1000);
    }
}

嗯,到这里,驱动层的四个核心函数就封装好了。你可能会问:“就这么简单?” 对,就这么简单。但越是简单的东西,越容易在细节上出错。我建议你写完代码后,用逻辑分析仪抓一下时序,看看 RS、RW、E 和数据总线的波形对不对。

下一章,我们会基于这四个函数,开始写 LCD 的初始化序列。那才是真正让屏幕亮起来的关键一步。