从零点亮一块LCD12864:并行写操作与自定义图形实战
你有没有遇到过这样的场景?设备已经能采集数据、处理信号,却卡在了“怎么让人看得懂”这一步。用串口打印太原始,上TFT彩屏成本又压不住——这时候,一块LCD12864往控制板上一贴,问题迎刃而解。
它不是最炫的,但足够稳;不算最快,但实时性够用;不带字库,反而更灵活。尤其当你想在开机时显示一个公司Logo,或者把传感器数据画成趋势图而不是只列数字时,这块老派却实用的图形液晶模块,就成了嵌入式系统里那颗“刚刚好”的螺丝钉。
今天我们就来动手,彻底搞清楚如何通过并行接口精准控制 LCD12864,实现自定义图形显示。不讲虚的,直接从硬件结构讲到代码落地,带你绕开那些藏在数据手册里的坑。
为什么是 LCD12864?它到底特别在哪?
先说清楚我们说的是哪款屏:本文聚焦的是基于KS0108B 控制器的 128×64 点阵液晶模块(不是ST7920那种带汉字库的版本)。它的分辨率是 128 列 × 64 行,每个点都能独立控制亮灭,属于典型的“图形型”而非“字符型”LCD。
相比常见的1602或128x64 OLED,它的优势很明确:
| 维度 | LCD12864(KS0108) | 字符LCD(如1602) | OLED/TFT |
|---|---|---|---|
| 显示自由度 | 高(全像素可寻址) | 极低(固定字符块) | 极高 |
| 成本 | 低(批量<¥15) | 极低 | 中高 |
| 功耗 | 低(无自发光) | 极低 | 较高(尤其白色背景) |
| 接口速度 | 快(8位并行,μs级响应) | 简单 | 取决于SPI/FSMC |
| 开发难度 | 中(需手动管理显存) | 低 | 高(驱动复杂) |
所以,如果你的项目有这些需求:
- 要显示波形、图标、Logo;
- MCU资源有限(比如只有普通IO口,没有SPI DMA);
- 对功耗敏感,又不想牺牲可视角度;
- 成本敏感,但还想有点“设计感”;
那么,LCD12864 就是一个非常值得考虑的选择。
内部结构揭秘:两片芯片拼出一张图
别看它是一整块屏幕,其实内部是由两个独立的64×64 GDRAM控制器(KS0108B)协同工作的。左边一半归 CS1 管,右边一半归 CS2 管。这种“双片分治”的架构决定了我们必须学会“左右开弓”。
显存是怎么组织的?
想象一下这张图被切成了8层蛋糕,每层高8行,总共64行 —— 这就是所谓的“页”(Page),共8页(Page 0 ~ Page 7)。
每一“页”中,横向有128列,但因为左右各由一片芯片控制,所以每片只管64列。也就是说:
- 左半屏:X ∈ [0,63] → 使用 CS1
- 右半屏:X ∈ [64,127] → 使用 CS2
而每一个字节写进去,并不是代表横着的8个像素,而是竖着的8个像素!即一个字节对应当前列上的连续8行(bit7 是第7行,bit0 是第0行),这就是所谓的“垂直字节排列”。
📌关键理解:
你要画一个点 (x, y),就得先算:
page = y / 8; // 找到第几页 y_addr = x % 64; // 在该芯片内的列地址 chip = (x < 64) ? 1 : 2;然后告诉对应的控制器:“我要往第page页、第y_addr列写一个字节”,再把那个包含目标像素的 byte 发过去。
并行写操作:让MCU和LCD真正“对话”
LCD12864 支持8位和4位并行模式,这里我们采用8位高速模式,直接将 DB0~DB7 接到MCU的一个完整GPIO端口上(例如STM32的PA0~PA7)。
控制信号一览
| 引脚 | 名称 | 作用说明 |
|---|---|---|
| DB0~7 | 数据总线 | 输入/输出8位数据 |
| RS | 寄存器选择 | 0=命令,1=数据 |
| R/W | 读写控制 | 0=写,1=读(通常我们只写) |
| E | 使能信号 | 下降沿锁存数据 |
| CS1 | 片选1 | 选通左半屏控制器 |
| CS2 | 片选2 | 选通右半屏控制器 |
| RES | 复位 | 低电平有效,启动前拉低再拉高 |
实际应用中,R/W 通常接地(固定为写模式),因为我们很少需要读取状态(读操作还要切换IO方向,麻烦且易出错)。E 脚必须严格按照时序触发。
时序要求不能马虎
KS0108B 的典型写周期要求如下:
| 参数 | 最小值 | 单位 | 含义 |
|---|---|---|---|
| t_cycl(e) | 1000 | ns | E信号完整周期 |
| t_pw(e)h / t_pw(e)l | 450 | ns | E高低电平脉宽 |
| t_ds(data) | 230 | ns | 数据建立时间 |
| t_h(data) | 10 | ns | 数据保持时间 |
这意味着你在拉高 E 之后至少要等450ns才能拉低,整个E脉冲宽度也不能小于这个值。虽然现代MCU跑几十MHz,一个NOP都不到几十ns,但我们仍需加入适当的延时。
幸运的是,哪怕延时1微秒也完全满足要求,因此可以用简单的软件延时替代复杂的硬件等待。
核心驱动函数:写出稳定可靠的写字节操作
下面这段代码直接操作寄存器,适用于STM32系列(如F1/F4),避免HAL库带来的额外开销。
// 引脚定义(以GPIOA为数据端口,GPIOB为控制端口) #define LCD_DATA_PORT GPIOA #define LCD_CTRL_PORT GPIOB #define RS_PIN GPIO_PIN_0 #define RW_PIN GPIO_PIN_1 #define E_PIN GPIO_PIN_2 #define CS1_PIN GPIO_PIN_3 #define CS2_PIN GPIO_PIN_4 // 微秒级延时(根据主频调整,假设SystemCoreClock = 72MHz) void lcd_delay_us(uint16_t us) { uint32_t delay = us * (72000000 / 1000000 / 3); // ≈每us循环24次 while (delay--) __NOP(); } /** * @brief 向LCD写入一个字节 * @param data 要写的数据 * @param is_data 0=命令,1=数据 */ void lcd_write_byte(uint8_t data, uint8_t is_data) { // 设置数据端口为输出模式(MODER = 0b01 for each pin) LCD_DATA_PORT->MODER = 0x5555; // PA0~PA7 输出模式 // 设置RS:0=命令,1=数据 if (is_data) { LCD_CTRL_PORT->BSRR = RS_PIN; // RS = 1 } else { LCD_CTRL_PORT->BRR = RS_PIN; // RS = 0 } // R/W = 0(写操作) LCD_CTRL_PORT->BRR = RW_PIN; // 将数据放到总线上 LCD_DATA_PORT->ODR = (LCD_DATA_PORT->ODR & 0xFF00) | data; // E = High -> 延时 -> E = Low(下降沿锁存) LCD_CTRL_PORT->BSRR = E_PIN; lcd_delay_us(1); // >450ns即可 LCD_CTRL_PORT->BRR = E_PIN; lcd_delay_us(1); // 可选:恢复数据端口为输入或其他用途 }💡技巧提示:
- 使用BSRR和BRR寄存器可以原子地置位和清零引脚,避免读-修改-写风险。
- 如果你的MCU主频不同,请重新计算lcd_delay_us()的循环次数,确保至少延迟450ns以上。
- 不必追求极致效率,多延时一点不影响功能,反而提高稳定性。
如何画一张图?一步步教你绘制自定义图形
现在我们有了“写字”的能力,下一步就是“画画”。假设你想在屏幕上显示一个心形 Logo。
第一步:准备图形数据
使用工具(如PCtoLCD2002、Image2Lcd)将图片转为C数组。注意设置参数:
- 宽高:8×8
- 扫描方式:纵向扫描,高位在上
- 输出格式:C数组,十六进制
得到如下数据:
const uint8_t heart_8x8[] = { 0x3C, 0x42, 0xA5, 0x81, 0x81, 0xA5, 0x42, 0x3C };第二步:编写绘图函数
我们要实现一个通用的lcd_draw_8x8函数,在任意位置(x, y)绘制这个8×8图像。
/** * @brief 在指定坐标绘制8x8位图 * @param x 起始X坐标(0~127) * @param y 起始Y坐标(0~63) * @param bitmap 指向8字节数组的指针 */ void lcd_draw_8x8(uint8_t x, uint8_t y, const uint8_t *bitmap) { for (int i = 0; i < 8; i++) { uint8_t col_x = x + i; // 当前列X坐标 uint8_t page = y / 8; // 所属页 uint8_t y_addr = col_x % 64; // 列地址(0~63) uint8_t chip_sel = (col_x < 64) ? 1 : 2; // 设置页地址和列地址 lcd_write_byte(0xB8 | page, 0); // B8h ~ BFh 为页地址命令 lcd_write_byte(0x40 | y_addr, 0); // 40h ~ 7Fh 为Y地址命令 // 片选控制 if (chip_sel == 1) { LCD_CTRL_PORT->BSRR = CS1_PIN; LCD_CTRL_PORT->BRR = CS2_PIN; } else { LCD_CTRL_PORT->BRR = CS1_PIN; LCD_CTRL_PORT->BSRR = CS2_PIN; } // 写入数据 lcd_write_byte(bitmap[i], 1); } }🎯重点说明:
- 每次写入一个垂直列(8行),共8列完成整个图案;
- 地址命令必须每次重新发送,因为KS0108不会自动递增X地址(不像某些OLED);
- 片选要在每次写之前正确配置,否则可能写错区域!
调用示例:
lcd_draw_8x8(60, 24, heart_8x8); // 屏幕中央画个小心心 ❤️实战应用场景:不只是显示Logo
你以为这只是为了秀个图标?远远不止。结合缓冲区管理和定时刷新,你可以实现很多实用功能:
✅ 波形图显示(趋势曲线)
将历史采样值映射为Y坐标,每隔一段时间描一个点,形成折线图。例如温度变化趋势:
uint8_t graph_buffer[128]; // 缓存最近128个采样点 // 更新逻辑:滑动窗口 + 归一化到0~63范围 // 绘图:逐列写入,每列一个byte,构成波形轮廓✅ 图标状态提示
- 电池电量:用4段竖条表示剩余电量;
- 报警标志:异常时闪烁三角感叹号;
- 运行指示灯:小圆点动态移动模拟心跳。
✅ 混合界面布局
左半边显示实时数值(ASCII文本),右半边画图表或Logo,打造专业仪表风格。
常见坑点与调试秘籍
别急着通电,先看看前辈们踩过的坑:
🔧问题1:屏幕全黑或部分不亮
→ 检查负压是否建立。有些模块需要外接-5V,或调节VLCD对比度引脚(VO)电压(通常接可调电阻)。
🔧问题2:显示错位、左右偏移
→ 片选逻辑错误!确认x >= 64时是否正确切换了 CS2。
🔧问题3:图形上下颠倒或反色
→ 查看图像生成工具的扫描顺序是否匹配。应选择“纵向扫描,高位在上”。
🔧问题4:写入无效,像是没反应
→ 检查E脉冲宽度是否足够。建议初始调试时延时lcd_delay_us(2)更稳妥。
🔧问题5:频繁复位后才正常
→ 初始化流程不对。标准初始化序列应包括:
lcd_write_byte(0x3E, 0); // 关闭显示 lcd_write_byte(0x40, 0); // 设置Y地址=0 lcd_write_byte(0xB8, 0); // 设置页=0 // ... 清屏操作 ... lcd_write_byte(0x3F, 0); // 开启显示总结:一块经典屏幕的现代价值
LCD12864 或许不再是最先进的显示技术,但在许多工业、医疗、仪器类设备中,它依然是不可替代的存在。它的价值不仅在于低成本,更在于其确定性高、抗干扰强、寿命长的特点。
掌握它的并行写操作,本质上是在训练一种底层思维:
- 如何与硬件精确同步?
- 如何管理显存映射?
- 如何在资源受限下实现最大表达力?
这些能力,远比学会调用某个GUI库更有长期价值。
下次当你面对一个“要不要上彩屏”的抉择时,不妨想想:也许一块黑白的 LCD12864,配上精心设计的图形逻辑,就已经足够讲清你想表达的一切。
如果你正在做类似的项目,欢迎留言交流经验,我们可以一起优化驱动框架,甚至封装成轻量级库供后续复用。