以下是对您提供的博文内容进行深度润色与结构优化后的技术文章。整体风格已全面转向真实工程师口吻的实战分享体,去除了所有AI生成痕迹、模板化表达和冗余术语堆砌;强化了逻辑递进、经验沉淀与可复用性,并严格遵循您提出的全部格式与表达规范(如禁用“引言/总结”类标题、不设模块化小节、自然过渡、口语化专业表达、关键点加粗提示等)。
用4根线点亮128×64中文屏:我在STM32上踩过的LCD12864 SPI驱动坑,以及怎么填平它
去年做一款便携式电能质量监测仪时,客户一句话让我改了三版PCB:“能不能把LCD接口从并口换成SPI?我们板子IO太紧了。”
当时我第一反应是——这不就是换种接法吗?结果真动手才发现:PSB没接地,SPI发包全静默;RS和NSS共用一个IO,一帧数据刚发一半,屏幕就卡死;GB2312区位码算错两位,“温度”显示成“湿度”……
后来翻烂ST7920手册、对比五家国产模组原理图、在示波器上盯了三天SCLK边沿,才真正搞懂:LCD12864的SPI模式不是“能通就行”,而是“每一步都要踩准时序节拍”的精密协作。
今天就把这套已在量产设备中稳定运行超2年的驱动方案,毫无保留地拆给你看——不讲概念,只说怎么让屏幕稳稳亮起来。
为什么非得用SPI?先看清并口的硬伤
你可能也遇到过这样的场景:
- STM32F103C8T6只剩6个空闲GPIO,但并口LCD要占13个(DB0–DB7 + RS/RW/EN/CS/RESET);
- 调试时屏幕突然乱码,查了半天发现是PCB上DB4走线刚好跨过DC-DC电感,干扰了建立时间;
- 换了个批次的LCD模组,同样代码跑出来字库偏移两列,厂商标称“兼容ST7920”,实际内部ROM映射有微小差异……
这些都不是玄学,全是并口时序在作祟。
KS0108/ST7920这类控制器对tsu(数据建立时间)、th(保持时间)、tPW(脉冲宽度)的要求非常苛刻——比如RW=0写指令时,E下降沿后数据必须维持≥10ns,而MCU GPIO翻转+PCB走线延时+信号反射,稍不注意就踩线边缘。
SPI的优势就在这里:
✅仅需4根线(SCLK/MOSI/NSS/RESET,甚至RESET都能省);
✅硬件时钟同步,边沿精度达纳秒级,不受MCU负载波动影响;
✅单向通信,不用操心MISO读忙标志(BUSY Flag),简化流程;
✅天然抗干扰——差分虽不支持,但SCLK+MOSI走线短、速率可控(我们实测400kHz最稳),比8根并行线好控得多。
当然,前提是——你得让SPI真的“听懂”LCD在说什么。
真正卡住人的,从来不是代码,而是那几个不起眼的硬件开关
很多新手烧录完程序,屏幕黑着不动,第一反应是“驱动写错了”。其实90%的问题出在上电前的物理配置上:
🔹PSB引脚必须接地
这是SPI模式的总闸门。不少国产模组默认PSB悬空或接VCC,并口模式才能工作。你SPI发再多次,只要PSB=1,芯片压根不进串行状态机。
→ 解决办法:用万用表蜂鸣档测PSB对GND是否导通;若无跳线,直接飞线接地。
🔹V₀对比度电位器不能省
ST7920内部升压电路输出约-10V给COM驱动,但V₀电压决定液晶偏压阈值。如果只接固定电阻,低温下对比度骤降,字符发虚;高温又易出现鬼影。
→ 我们量产板统一用10kΩ多圈精密电位器,调好后点胶固定,并在固件中预留V₀校准指令(0x81 + value),出厂时自动适配。
🔹RESET引脚建议保留
虽然ST7920支持软件复位(0xE2),但实测某些批次模组冷启动时,内部振荡器起振慢,0xE2发早了无效。加一颗10kΩ上拉+100nF电容到GND,确保上电后≥10ms可靠复位。
这些细节,手册里往往藏在第17页角落,但却是你调试两小时找不到原因的根源。
SPI配置的关键:别信默认值,每个bit都要亲手拧紧
STM32的SPI外设很强大,但默认配置≠LCD能认。我见过太多人直接复制HAL库初始化,结果BUSY标志永远不退,屏幕定格在开机画面。
核心三点,必须手写寄存器配置(不用HAL,也不用标准外设库):
1. 时钟极性与相位:CPOL=0, CPHA=0 是铁律
ST7920 datasheet明确要求:
- SCLK空闲为低电平(CPOL=0);
- 数据在SCLK第一个上升沿采样(CPHA=0)。
⚠️ 注意:有些国产模组文档写反了,实测以示波器为准——用逻辑分析仪抓一帧,看MOSI数据是不是在SCLK上升沿锁存。
2. NSS必须软件控制,禁用硬件NSS
SPI硬件NSS(SSOEN)会自动拉低片选,但LCD需要先置RS电平,再拉NSS。如果让硬件NSS抢跑,RS还没切过去,数据就发出去了——轻则显示错位,重则控制器进入未知状态。
→ 正确做法:SPI_CR1 |= SPI_CR1_SSI;(强制SS输出高),NSS完全由GPIO控制。
3. 波特率别贪快,400kHz是甜点
理论最高可到1.125MHz(PCLK2/8),但ST7920内部移位寄存器响应有延迟。我们实测:
- >500kHz:偶发丢字节,尤其在连续写GDRAM时;
- 400kHz(BR=011):全温域稳定,全屏刷新120ms,人眼无感知延迟;
- <200kHz:刷新慢,按键响应拖沓。
所以最终CR1配置是:
SPI1->CR1 = SPI_CR1_MSTR | SPI_CR1_SSI | SPI_CR1_SPE | SPI_CR1_BR_1 | SPI_CR1_BR_0 | // BR=011 → PCLK2/16 = 4.5MHz → 实际SCLK≈400kHz SPI_CR1_CPOL | SPI_CR1_CPHA;驱动函数的灵魂:RS和NSS,到底谁先动?
这是最容易写错的一环。网上很多例程把RS当普通GPIO,NSS当SPI片选,混着用——结果就是:
- 发指令时RS=0,但NSS拉低瞬间RS还没稳定,LCD收到的是“半条指令”;
- 写数据时RS=1,但NSS抬高太快,最后几个bit被截断。
我们的做法是:PA4同时承担RS与NSS功能,靠电平组合定义操作类型:
| PA4电平 | 含义 | 对应LCD动作 |
|----------|----------------|------------------------|
|低电平| RS=0, NSS=0 | 发送指令(如0xAF开显示) |
|高电平| RS=1, NSS=1 | 空闲/结束传输 |
⚠️ 关键细节:
-RS切换必须在NSS变化之前完成,且留足建立时间(≥100ns);
- 使用BSRR寄存器原子置位/清零,杜绝RMW冲突;
-SPI_SR_BSY检测比delay_us(72)更可靠——不同主频MCU下延时不准,而BSY是硬件真实状态。
精简后的写入函数长这样:
void LCD_Write(uint8_t byte, uint8_t is_data) { // Step 1: 设置RS —— 必须最先执行! if (is_data) { GPIOA->BSRR = GPIO_BSRR_BS4; // PA4=1 → RS=1 } else { GPIOA->BSRR = GPIO_BSRR_BR4; // PA4=0 → RS=0 } // Step 2: 拉低NSS(此时RS已稳定) GPIOA->BSRR = GPIO_BSRR_BR4; // PA4=0 → NSS=0 // Step 3: 等TXE就绪,发数据 while (!(SPI1->SR & SPI_SR_TXE)); SPI1->DR = byte; // Step 4: 等BSY清零,确保发送完成 while (SPI1->SR & SPI_SR_BSY); // Step 5: 抬高NSS,结束本次事务 GPIOA->BSRR = GPIO_BSRR_BS4; // PA4=1 → NSS=1 }这个函数看似简单,但每一行都对应着时序图里的一个关键时间节点。少一行,就可能让LCD进入busy lock状态,再也收不到下一条指令。
中文显示不靠猜,GB2312区位码要亲手算一遍
LCD12864内置字库是最大优势,但也是最大陷阱——因为“啊”字的编码不是0x00,而是按GB2312编码规则映射的。
举个真实例子:
你要显示“电流”,两个字的GB2312码分别是:
- “电” →0xB5E7
- “流” →0xC1F7
但LCD不认这个。它要的是区位码:
- 区号 = 高字节 - 0xA0 =0xB5 - 0xA0 = 0x15
- 位号 = 低字节 - 0xA0 =0xE7 - 0xA0 = 0x47
- 字库存储地址 = 区号 × 94 + 位号 =0x15 × 94 + 0x47 = 0x8B9
然后你得把0x08和0xB9两个字节依次写入——注意顺序!高位字节在前,否则显示成乱码。
我们封装了一个宏来防错:
#define GB2312_TO_ADDR(h, l) (((h)-0xA0)*94 + ((l)-0xA0)) #define LCD_PUT_CH(h, l) do { \ uint16_t addr = GB2312_TO_ADDR(h, l); \ LCD_Write(addr >> 8, 0); /* 高字节,作为指令 */ \ LCD_Write(addr & 0xFF, 1); /* 低字节,作为数据 */ \ } while(0) // 调用:LCD_PUT_CH(0xB5, 0xE7); // 显示“电”别嫌麻烦。量产中曾因一个字节顺序颠倒,导致某批次仪表菜单全显示为方块,返工2000台。
工程落地的最后一公里:那些手册不会告诉你的事
▶ 局部刷新比全屏快3倍,但必须手动管理页指针
LCD12864的GDRAM分8页(0~7),每页128×8像素。想只刷新右下角时间,不要清全屏——
- 先发0xB0 + page_num定位页;
- 再发0x40 + y设Y地址;
- 最后发0xB8 + x设X地址;
- 然后连续写入该页内需要更新的字节。
⚠️ 注意:页指针不会自动递增!每次写完一行,必须重新发0xB0+x,否则数据会写到错误页。
▶ 背光PWM别和LCD帧频打架
我们用PB0输出PWM控制LED背光,初始设为1kHz。结果用户反馈:在暗环境下看屏幕,有轻微闪烁感。
示波器一看:LCD刷新帧频约64Hz,1kHz PWM的16次谐波(1024Hz)与之耦合,产生拍频干扰。
→ 改为2.5kHz(避开所有常见谐波),问题消失。
▶ 低温启动失败?先查V₀温漂
-20℃环境下,10kΩ碳膜电位器阻值漂移可达±20%,导致V₀电压偏离最佳点。
→ 量产版改用金属膜多圈电位器(温漂≤100ppm/℃),并在固件中加入低温自适应算法:
- 上电后读取内部温度传感器;
- 若<-10℃,自动微调0x81指令参数,补偿V₀偏移。
现在回看那台电力谐波分析仪,它已经稳定运行在变电站、光伏逆变器、充电桩里——没有花屏,没有乱码,没有重启。
而这一切,起点只是把PA4、PA5、PA7这三根线,正确地连到了LCD背面的四个焊盘上。
如果你正在为IO资源发愁,或者被LCD时序折磨得睡不着觉,请记住:
嵌入式里最可靠的优化,从来不是加芯片,而是把已有的硬件,用得足够准、足够深。
如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。