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);
}
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);
}
}
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 的初始化序列。那才是真正让屏幕亮起来的关键一步。