如何用STM32玩转LCD12864:从驱动到多页菜单的实战设计
你有没有遇到过这样的场景?手头有个温控器、数据采集仪或者智能仪表,想给它加个“脸”——一个能显示中文、操作直观的界面。但OLED太贵,TFT又太复杂,代码跑不起来还耗电严重。
这时候,LCD12864就该登场了。
这块黑乎乎的小屏,分辨率128×64,自带汉字库,5块钱搞定,接上STM32就能输出“系统正常”、“温度:25℃”这种接地气的信息。更关键的是——它支持多页面切换。你可以像翻菜单一样,在主页、设置页、关于页之间自由跳转。
今天我们就来干一件实在事:不用图形库、不依赖RTOS,用最基础的GPIO模拟SPI,让STM32F103驱动LCD12864实现稳定流畅的多页显示。
为什么是LCD12864?不是OLED也不是TFT?
先说清楚:我们不是在怀旧,而是在做工程权衡。
| 特性 | LCD12864 | OLED | TFT-LCD |
|---|---|---|---|
| 成本 | <¥10 | ¥30~80 | ¥50起 |
| 功耗(静态) | 极低(背光主导) | 自发光超省电 | 背光常亮,功耗高 |
| 中文支持 | ✅ 内置GB2312字库 | ❌ 需烧录字体 | ✅ 可加载任意字体 |
| 接口难度 | 3线SPI可驱动 | I2C/SPI简单 | FSMC/DMA较复杂 |
| 开发门槛 | 低,资料丰富 | 中等 | 高 |
如果你做的设备要批量生产几百台,成本敏感;或是学生项目预算有限;又或者只是想快速验证一个想法——那LCD12864依然是那个“够用就好”的黄金选择。
而且它的优势很明确:
-能显示中文,用户一看就懂;
-无需显存管理,ST7920控制器自带显存;
-支持串行模式,三根IO线就能点亮;
-工业级稳定性,阳光下也能看清。
搞懂LCD12864的核心机制:地址怎么映射?数据怎么写?
别急着写代码,先搞明白这块屏是怎么组织画面的。
它不是像素阵列,而是“页-列”结构
LCD12864的分辨率是128×64,但它内部并不是按“行×列”直接存储的。它被划分为8页(Page 0 ~ Page 7),每页对应8行(共64行),每一列是一个字节(8位),代表垂直方向上的8个点。
也就是说:
- 每一页有128列 × 8行 = 1024字节;
- 整个显存共 8 × 1024 = 1KB;
- 写入一个字节时,实际上是向某一列写入8个纵向排列的点。
这个结构决定了我们必须按页操作。比如你要画一条横线跨多行,就得拆成8次写入,分别写到不同的Page中。
支持两种工作模式:文本 vs 图形
这是ST7920控制器的一大特色:
- 文本模式(Basic Instruction Set, cmd=0x30)
- 直接发送汉字或ASCII字符,自动查内置字库存储位置;
- 光标定位使用
goto_xy(x, y),x为列(0~15),y为行(0~7); 最适合菜单、状态信息等静态文本。
图形模式(Extended Instruction Set, cmd=0x34 + 0x36)
- 手动控制每个像素点,可用于绘制图标、波形、进度条;
- 需手动设置Page和Column地址;
- 更灵活,但也更麻烦。
我们在多页系统中通常采用混合模式:标题用文本模式快速输出,图标或动态图用图形模式补充。
STM32怎么控制它?三根线搞定串行通信
虽然LCD12864支持并行8位接口,但对资源紧张的STM32F103C8T6来说,串行模式才是最优解。
只需要3个GPIO:
-SCL(时钟)→ PA5
-SID(数据)→ PA7
-CS(片选)→ PA6
注意:电源建议5V供电(模块逻辑电平兼容3.3V输入),若MCU为3.3V系统,可在信号线上串联1kΩ电阻防反灌。
串行协议怎么玩?
ST7920的串行通信其实是一种“伪SPI”,时序如下:
- CS拉低开始帧;
- 发送控制字节(0xF8表示指令,0xFA表示数据);
- 分两次发送高4位和低4位;
- CS拉高结束。
下面是核心函数实现:
static void lcd12864_write_byte(uint8_t data) { for (int i = 7; i >= 0; i--) { HAL_GPIO_WritePin(LCD_PORT, LCD_SCL_PIN, GPIO_PIN_RESET); if (data & (1 << i)) HAL_GPIO_WritePin(LCD_PORT, LCD_SID_PIN, GPIO_PIN_SET); else HAL_GPIO_WritePin(LCD_PORT, LCD_SID_PIN, GPIO_PIN_RESET); __NOP(); __NOP(); // 延时约1μs HAL_GPIO_WritePin(LCD_PORT, LCD_SCL_PIN, GPIO_PIN_SET); } } void lcd12864_send_cmd(uint8_t cmd) { HAL_GPIO_WritePin(LCD_PORT, LCD_CS_PIN, GPIO_PIN_RESET); lcd12864_write_byte(0xF8); // 指令标识 lcd12864_write_byte(cmd & 0xF0); // 高4位 lcd12864_write_byte((cmd << 4) & 0xF0); // 低4位 HAL_GPIO_WritePin(LCD_PORT, LCD_CS_PIN, GPIO_PIN_SET); }初始化时调用这些命令进入基本模式:
void lcd12864_init(void) { HAL_Delay(50); lcd12864_send_cmd(0x30); // 基本指令集 HAL_Delay(5); lcd12864_send_cmd(0x0C); // 开显示,关光标 HAL_Delay(5); lcd12864_send_cmd(0x01); // 清屏 HAL_Delay(5); }现在屏幕已经准备好了,接下来就是让它“会说话”。
多页面切换的本质:状态机 + 按键中断
很多人一开始都会犯一个错误:在主循环里轮询按键,发现按下就翻页。结果呢?卡顿、误触发、响应慢。
真正的做法是:用外部中断处理按键,用状态机管理页面,用标志位协调刷新。
硬件连接很简单
- KEY1 接 PA0,接地,上升沿触发EXTI;
- 上拉电阻确保默认高电平;
- 按下时产生下降沿,触发中断。
中断服务程序只做一件事:标记需要翻页
#define PAGE_COUNT 3 uint8_t current_page = 0; volatile uint8_t page_needs_update = 1; void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == GPIO_PIN_0) { HAL_Delay(20); // 简单消抖 if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET) return; // 不是有效按下 current_page = (current_page + 1) % PAGE_COUNT; page_needs_update = 1; // 标记刷新 } }注意这里用了软件延时消抖。实际项目中推荐用定时器+状态机实现更可靠的去抖算法,但初学者这样足够。
主循环负责渲染,不阻塞也不忙等
while (1) { if (page_needs_update) { switch (current_page) { case 0: render_page_home(); break; case 1: render_page_settings(); break; case 2: render_page_about(); break; } page_needs_update = 0; } HAL_Delay(50); // 给CPU喘口气 }这种前后台分离的设计,保证了按键响应快、显示更新及时,还能留出时间做其他任务(比如采集传感器数据)。
页面内容怎么画?封装你的render_xxx()函数
每个页面独立绘制,互不影响,后期扩展也方便。
举个例子,首页长这样:
【系统主页】 时间: 14:23 状态: 正常 IP: 192.168.1.100对应的绘制函数:
void render_page_home(void) { lcd12864_clear(); lcd12864_goto_xy(0, 0); lcd12864_puts("系统主页"); lcd12864_goto_xy(0, 2); lcd12864_puts("时间: 14:23"); lcd12864_goto_xy(0, 4); lcd12864_puts("状态: 正常"); lcd12864_goto_xy(0, 6); lcd12864_puts("IP: 192.168.1.100"); }其中lcd12864_goto_xy(x, y)是关键,它把“第y行、第x列”转换成正确的显存地址:
void lcd12864_goto_xy(uint8_t x, uint8_t y) { uint8_t addr = 0x80 + x; // 第x列 if (y < 4) { addr |= 0x00; // 左半屏 } else { addr |= 0x10; // 右半屏 y -= 4; } lcd12864_send_cmd(0x80 | (y << 6) | (addr & 0x0F)); }注:ST7920将屏幕分为左右两个64列区域,Y坐标需根据左/右半屏调整。
实战中的坑与避坑指南
1. 屏幕闪烁得像频闪灯?
原因:每次切换都lcd12864_clear()全屏清空再重绘。
解决方案:
- 改成局部擦除 + 局部刷新;
- 或者只在首次进入页面时清屏,后续增量更新。
例如:
if (current_page != previous_page) { lcd12864_clear(); previous_page = current_page; } // 然后只绘制变化的内容2. 按键一按连跳两页?
机械按键弹跳导致多次触发。
除了加HAL_Delay(20),更好的方式是记录上次触发时间,判断是否小于20ms则忽略:
static uint32_t last_press_time = 0; uint32_t now = HAL_GetTick(); if ((now - last_press_time) < 20) return; last_press_time = now;3. 中文变成“锟斤拷”?
编码不对!LCD12864只认GB2312编码的中文。
解决办法:
- 在Keil或VS Code中将源文件保存为GB2312编码;
- 或使用数组形式定义字符串:
const char* title = "\xB7\xC7\xD1\xF9"; // “系统”二字的GB2312编码工具网站可以帮你查汉字编码,搜索“GB2312编码查询”即可。
进阶技巧:如何让它更聪明?
掌握了基础之后,还可以加些实用功能:
✅ 自动轮播(无人操作时展示信息)
加个定时器,每10秒自动翻一页:
if (HAL_GetTick() - last_input_time > 10000) { current_page = (current_page + 1) % PAGE_COUNT; page_needs_update = 1; }用户一操作就重置计时器。
✅ 节能背光控制
长时间无操作关闭背光:
lcd12864_send_cmd(0x08); // 关显示 // 按键唤醒后再开 lcd12864_send_cmd(0x0C); // 开显示显存内容不会丢失,瞬间恢复。
✅ 加个返回键 or 旋钮编码器
两个按键:UP / DOWN,实现上下翻页:
if (up_pressed) current_page = (current_page - 1 + PAGE_COUNT) % PAGE_COUNT; if (down_pressed) current_page = (current_page + 1) % PAGE_COUNT;甚至可以用旋转编码器替代按键,手感更好。
写在最后:经典组合为何依然值得学?
也许你会问:都2025年了,为啥还要折腾这种“古董级”液晶?
因为技术的价值不在新旧,而在适用。
- 学生练手?它便宜、资料全、调试简单;
- 产品原型?它够稳、够省、够直观;
- 工业现场?它耐高温、抗干扰、寿命长。
更重要的是,通过LCD12864 + STM32这个组合,你能真正理解:
- GPIO如何模拟时序;
- 显存如何映射画面;
- 中断如何提升交互体验;
- 状态机如何管理UI流程。
这些底层能力,才是嵌入式开发的立身之本。
下次当你面对一块新屏、一个新的HMI框架时,你会发现:原来它们不过是把今天我们亲手实现的东西,封装得更漂亮而已。
所以,不妨拿起你的STM32最小系统板,接上那块吃灰已久的LCD12864,点亮第一个“Hello World”吧。
如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。