段码 LCD 键盘显示板(Cortex-M0,小容量 Flash/RAM)
这块板子只干三件事:把主板下发的几个字节显示到段码屏上、把按下的键上报给主板、 在链路不正常的时候明确地告诉人“我不正常”。主控是 STM32F030K6(Cortex-M0,32 KB Flash/4 KB SRAM), 不跑 RTOS,主循环就是“扫键盘 + 数 10 ms + 喂狗”。做下来我的结论是: 段码屏真正的难点不在驱动,而在那张必须自己维护的段码映射表; 矩阵键盘真正的难点不在扫描,而在组合键误判;而通信部分最值得写的不是协议本身, 是那条“用有没有收到显示帧来判断链路健康”的零成本心跳。
一、这块板要做什么
这台设备的界面上有二十来个按键和一块段码屏。最初的想法是都挂在主板上, 但那样会带来两个后果:主板到面板之间要拉一整排线,面板结构一改,主板就得跟着改版; 而且主板上已经挤着计量、阀门、支付、通信这些事,再塞进扫描和刷新,资源和注意力都不够分。
所以我把“显示 + 按键 + 在线状态”这三件事切出来,做成一块独立的小板,中间只用一条串口线相连。 我给自己划的职责边界很硬:
- 收到显示帧就刷新显存,屏幕显示什么内容由主板决定,板子自己不理解这一屏是什么;
- 扫到按键就主动上报,板子不判断这个键该不该被响应;
- 链路不正常就显示固定的故障提示,而不是继续显示最后一屏;
- 其余一律不做——不存参数、不碰计量、不做业务判断。
这样切分的直接好处是:面板可以独立更换,主板的业务逻辑完全不用知道段码屏有几段。 代价是这条串口线变成了面板的“生命线”——它断了,面板就会显示一个漂亮的错误画面(如果我没做心跳的话), 这一点我在第六节专门写。
我一开始觉得“显示板”是个很简单的活:会点 I²C、会扫键盘就完了。真正花时间的恰恰是两件事: 段码屏没有字符发生器,字符长什么样得我自己算;矩阵键盘没有二极管,同时按键时会凭空多出一个键。 这两件事都不是写驱动能解决的,是硬件结构决定的。
硬件上主控选的是小容量档的 Cortex-M0:段码屏的刷新、按键扫描、一条串口, 对算力的要求几乎为零,选大芯片只是浪费。真正的约束在 RAM——只有 4 KB, 这意味着任何“先把整屏点阵缓存下来再处理”的思路都不能有,显存必须是能省就省的那种。
二、段码 LCD 驱动芯片:静态显示模式与“显存就是几个字节”
屏的驱动没有做在 MCU 里,而是用了一颗 I²C 接口的段码 LCD 驱动器(PCF8566 这一类器件)。 它替我干掉了三件事:段的高压驱动、偏压网络、以及“哪个段亮”的锁存。 MCU 要做的只剩下一件事:把几个字节的显存写进去。
接口用的是软件位翻转 I²C(SCL 与 SDA 各占一个 GPIO),没有用硬件 I²C 外设。 理由很实际:这块屏的刷新频率低到可以忽略,软件时序我想改就改; 而且硬件 I²C 一旦被从机拉住总线,排查起来比软件时序麻烦得多。
为什么是“静态显示模式”
这类驱动器通常支持静态、1/2 偏压复用、1/3 偏压复用等几种驱动方式, 复用度越高,同样多的引脚能驱动的段数越多——但每个段的占空比越低、对比度越差, 而且要配一套更讲究的偏压电阻网络。我的面板上字符数量不多,引脚够用, 所以直接选了静态显示模式:一段一个驱动输出,占空比拉满,对比度最好, 软件上也不需要维护“第几个复用周期该刷新哪一组”这种状态。
初始化时我给两组输出各写了一次模式配置:一组驱动主屏,一组驱动副屏, 模式字里都包含“静态显示模式”和“不闪烁”两个设定(模式字的具体取值按器件数据手册来, 本文不列出)。这两组输出挂在同一个器件的不同子地址上,所以初始化是两次写, 刷新也是两次写。
| 配置项 | 我选的取值 | 我的理解 |
|---|---|---|
| 显示模式 | 静态显示 | 面板段数不多,用不着复用;换来最好的对比度和最简单的刷新逻辑 |
| 闪烁控制 | 关闭 | 闪烁交给主板用显示内容去表达,驱动层不掺和,否则“闪”和“内容”两套语义会打架 |
| 总线方式 | 软件位翻转 I²C | 速率要求极低,时序可控、便于用逻辑分析仪抓 |
| 刷新粒度 | 整块显存重写 | 显存只有几个字节,差量更新的比较逻辑比省下的时间更贵 |
| 显存载体 | 一块全局字节数组 | 主屏、副屏各占连续一段;刷新就是把数组按子地址分两次发出去 |
“显存就是几个字节”意味着什么
这块屏的显存只有几个字节(具体长度由屏的段数决定,本文不列出)。 这个事实决定了刷新策略:整块重写比差量更新划算得多。 差量更新要先维护一份“上一帧的镜像”,再逐字节比较、算出变了哪几位, 在只有几个字节的情况下,比较逻辑本身的代码量和出错概率都超过了它省下来的那点总线时间。
整块重写还有一个隐性好处:刷新的耗时是恒定的。 我在刷新函数的注释里留了当时量到的耗时量级——不到 1 ms(软件 I²C 逐位翻转的账), 而主循环一轮扫描是几十毫秒,所以刷新永远不会成为节拍的瓶颈。 这种“先算清最坏耗时再决定要不要优化”的习惯,是我在小容量 MCU 上被逼出来的。
三、段码映射:为什么需要一张段码表
接着上一节:既然驱动器不管字符形状,那“把一个数字变成亮哪几段”就必须由固件来做。 做法是一张字符到段位掩码的查表:数组下标是字符,数组元素是“哪几段要亮”的位掩码。
这里有一个容易被忽略的关键点:这张表不是通用的,它是这块板专用的。 数字由 a~g 七段加小数点组成,但“a 段接在驱动输出的哪一位”完全取决于 PCB 走线和屏的引脚定义。 换一块板、换一种走线,同一个字符的位掩码就完全不同。 换句话说,这张表是把硬件连线关系固化进固件的一种方式。
表里除了 0~9,还必须有一个“空白”项。空白不是 0——0 是点亮六段、灭掉中间那段, 空白是整段全灭。我一开始把两者当成一回事,结果关机前的那一屏会留下一个“0”的残影, 查了半天才发现是查表时用了默认下标 0。
/* 通用写法示意:字符 -> 段位掩码 的查表,以及跨字节的位域拼装 */
static const unsigned char seg_map[] = { /* 每个字符对应哪几段亮:由屏的连线决定 */ };
unsigned char seg = seg_map[ch]; /* 取到的是“哪些段”,不是“哪个数字” */
/* 空白项与数字 0 是两项不同的掩码,不能共用下标 */
/* 这段掩码落在显存的哪几个字节、偏移多少位,同样由硬件连线决定 */
mem[byte_idx] |= (unsigned char)(seg << bit_off);
mem[byte_idx + 1] |= (unsigned char)(seg >> (8 - bit_off));掩码还可能横跨字节边界:一个字符的段有可能一部分落在显存的前一个字节、一部分落在后一个字节, 所以拼装时要同时处理“字节下标、位偏移、掩码”三件事,少一个都会点错段。 上面这段是我按个人理解重写的最小片段,只保留控制流形状,不含任何真实码值。
数字之外,还有一组“图标位”
面板上除了数字,还有一批状态图标(加油、停止、读卡、成功、失败、投币、漏油、挂枪之类的标识)。 它们和数字段码是两套东西:数字走查表,图标走置位与清位—— 每个图标在显存里对应固定的某一位,主板要显示哪个图标,就是把对应位置 1 或清 0。 因为图标是“有/无”的二值状态,用位掩码比查表更直接。
真实痕迹:这套映射改过版
我在同一个头文件里翻到了两套图标位掩码定义:旧的一套被注释掉,新的那套在位值上明显不同 (有个图标的位从低位挪到了高位)。这说明段码映射改过版,而且改的时候没有把旧定义删掉。
为什么这种“改版”特别容易出事?因为它不会产生任何编译错误。 位掩码是两个整数,改错了照样编译通过,只是点亮的段换了地方——现场表现是“某个图标跑到了别的位置上”或者干脆不亮, 而代码里一切正常。我的处理办法是把段码与图标位集中到唯一一处定义, 并且另外维护一张“字符/图标 → 位”的对照表,改版时两边一起改、上板逐项点一遍。
四、矩阵键盘扫描:行输出列输入、组合键要排除
按键用的是 4 行 × 5 列矩阵,一共 20 个键位。行线接在 4 个 GPIO 上做输出, 列线接在 5 个 GPIO 上做下拉输入。扫描逻辑就是最朴素的那一种: 逐行拉高、读回列电平、按位取反,1 表示这一列上有键按下; 把命中的行号和列号合成一个键位编号,就得到了“按了哪个键”。
为什么不用独立按键直连?20 个键直连要 20 根线加 20 个上拉电阻, 面板到主控的排线会宽得离谱;矩阵只要 9 根。这是矩阵键盘存在的唯一理由, 它换来的代价就是下面要说的“幽灵键”。
/* 矩阵扫描骨架:逐行输出、读列、把命中的行列合成一个编号 */
for (row = 0; row < ROW_NUM; row++) {
drive_row(row); /* 只把当前行拉高,其余行保持低电平 */
settle(); /* 等电平建立:时长按线上的 RC 与线长定 */
cols = read_columns(); /* 读回列电平,1 表示该列有键按下 */
for (col = 0; col < COL_NUM; col++) {
if (cols & (1u << col)) {
key = (row << ROW_SHIFT) | col; /* 行列合成键位编号 */
}
}
}
/* 同列多键的组合必须被显式排除,否则会扫出并不存在的键 */扫描节奏:主循环里数 10 ms
这块板子上没有为键盘单独做定时器节拍:主循环每次循环延时 10 ms,数到第 3 次扫一轮键盘, 也就是大约 30 ms 扫一次。为什么不更快?因为机械按键的抖动本身就在毫秒量级, 30 ms 已经能把抖动挡在外面;而扫描是要占主循环时间的,扫得更快只会挤掉别的活。
这个 10 ms 计数还兼了一份差事:它是整块板子的“软时钟”。 指示灯亮灭的时间、加密校验的时间推进,都由这个计数推着走。 在小容量 MCU 上,主循环延时计数当软时钟是很常见的省事做法, 代价是它的精度取决于主循环一轮的实际耗时——只要有一处代码变慢,所有基于它的时间都会跟着漂。
组合键与“幽灵键”
键位编号是打包进一个 32 位位域里的,这样一个上报帧就能把多个键一起带走。 但位域能表达“同时按了多个键”,不等于硬件能可靠地识别多个键: 矩阵键盘没有二极管隔离,当同一列上有两个键同时按下、而另一列也有键按下时, 电流会走出一条意料之外的通路,扫出一个谁也没按的“幽灵键”。
代码里对这个情形做了显式排除,注释写得很直白:是为了防止同列组合被误判。 具体做法是判定时先看这一行有没有已经置位的键,如果有,剩下的命中直接丢弃, 也就是“同列多键时只认一个”。这不是最聪明的做法(更讲究的方案是加二极管或者做多键真值校验), 但它一行判断就能把幽灵键挡掉,对这块只有 20 个键的面板来说足够了。
从注释里能看出,组合键与单键的取舍被反复调整过——有的注释写着“释放快速查询”。 我后来想明白了:组合键是产品语义,幽灵键是电气事实, 在电气事实没有解决之前,产品语义怎么调都是在薄冰上走。
并存的另一套输入方案
工程里其实还有一套电容触摸芯片的驱动:I²C 接口,带灵敏度配置寄存器, 灵敏度还分了两段档位。它和矩阵键盘并存,但主流程走的是矩阵键盘。
我翻了下引脚,发现触摸芯片的 I²C 两根线与矩阵键盘的行线落在了同一组端口上—— 从代码上看,这两套输入没法同时工作。所以我把它如实记成一次“没有走完的尝试”: 驱动写了,没接到主流程,也没做过手感对比。
五、与主板的协议:定长帧、校验范围与长度自校验
对外只有一条串口(两个引脚复用串口功能)。协议是私有的定长帧, 结构不复杂,但有三处取舍我觉得值得写下来:校验覆盖到哪、长度字段怎么自校验、帧头帧尾为什么要两个字节。
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2 字节 | 两个固定字节。作用不是“标识协议”,而是让接收状态机能尽快丢掉噪声、降低误同步概率 |
| 有效数据长度 | 1 字节 | 只统计“指令码 + 数据”,不含帧头、CRC 与帧尾 |
| 指令码 + 数据 | 1 + N 字节 | 第一个字节说明这条帧要做什么,其后是这条指令的数据 |
| 校验 | 4 字节 | CRC32,小端序,紧跟在有效数据之后 |
| 帧尾 | 2 字节 | 两个固定字节,和帧头一起把一帧“夹住” |
校验的覆盖范围是一个明确的取舍:CRC32 只覆盖“有效数据”,也就是长度字节加上指令码与数据, 不覆盖帧头与帧尾。这样做的好处是发送端可以“先算校验、再套壳”, 组帧和数据校验互不干扰;代价是帧头帧尾的完整性只能靠“两处都要对上”来间接保证。 如果让我重做一版,我会把校验范围扩到整帧——多算几个固定字节的成本可以忽略, 换来的是“帧头被噪声打坏”这类问题也能被校验直接盖住。
长度自校验:不要只数字节
接收侧最容易写错的地方是“收满多少字节就算一帧”。定长帧看似可以让接收端数固定字节数, 但一旦线路中途丢字节、或者两帧粘在一起,数出来的边界就全是错的。 我的做法是用长度字段反算总长:收到长度字段之后,算出“本帧总长应该等于长度字段加上固定开销”, 收满之后把实际收到的字节数与这个期望值比对,同时再校验帧尾的两个字节, 两个条件同时满足才认这一帧。
/* 长度自校验骨架:用“总长 = 长度字段 + 固定开销”反算,别只数字节 */
if (rx_len >= FRAME_MIN) {
expect = rx_buf[LEN_IDX] + FRAME_OVERHEAD; /* 开销 = 帧头 + 校验 + 帧尾 */
if (rx_len == expect && tail_match(rx_buf, rx_len)) {
frame_ready = 1; /* 两个条件同时满足才认帧 */
} else {
rx_state = ST_HEAD; /* 不满足就丢掉,重新找帧头 */
}
}为什么两个条件缺一不可?只校验帧尾的话,一帧噪声恰好以这两个字节结尾也会被认下来; 只校验长度的话,长度字段本身被噪声改了,会把后面的字节一起吞进来。 这两种失败模式我在别的串口链路上都遇到过, 关于“帧边界到底该怎么判”我把四种方案放在 《串口帧同步的四种方案》的长度字段主动闭合一节里对比过。
指令集与收发流程
指令码是单字节的 ASCII 字符,属于板级约定,本文不列出具体字符。 按用途分成四类:下发显示数据(把数据段整块搬进显存并立即刷新)、 读按键(回一帧带上最近的键值)、命令键盘重启、 以及键盘上报校验失败用的短帧。
接收流程(中断里只搬字节,判断全在主循环):
- 串口中断里逐字节进缓冲区,不做任何解析;
- 主循环按状态机推进:第一个字节不是帧头首字节就复位状态,第二个字节不符合同样复位;
- 拿到长度字段后,算出本帧的总长期望值;
- 收满期望值后先校验帧尾两个字节,再比对长度是否自洽;
- 通过则解析指令码、执行动作;不通过则整帧丢弃并回到“找帧头”状态;
- 如果校验失败原因是 CRC,就回一帧短帧告诉主板“这一帧我没收下”,让主板决定要不要重发。
发送流程(三步,先算校验再套壳):
- 先把“长度字节 + 指令码 + 数据”拼成有效数据;
- 对整个有效数据算一次 CRC32,按小端序追加 4 个字节;
- 补上帧头与帧尾,一次性交给发送,不要拆成多次发送——拆开发送会在总线上留出空隙, 给对方的判帧添麻烦。
接收缓冲区按可能出现的最大帧长留(本文不列出具体长度)。 这里有一个我没做但知道该做的事:缓冲区应该有硬上限, 长度字段再离谱也不能让下标跑出数组——现在的写法是靠“长度字段比对”间接挡住, 但如果长度字段本身很大而后续字节一直不来,缓冲区就会一直等。
六、一条低成本的心跳设计
这块板子是“被动”的:它不主动问主板还在不在。这带来一个很危险的失效模式—— 如果主板重启了、或者线掉了,显示板会一直显示最后一屏内容,看上去一切正常。 面板比实际状态“更好看”,这在设备上是最不该出现的一种错。
做法:开机主动上报 + 用“有没有收到显示帧”判断健康
- 开机时键盘板主动上报一帧“我复位了”,同时把一个“链路未确认”标志置 1;
- 此后只有收到主板下发的显示帧,才把这个标志清零;
- 标志为 1 期间,屏幕显示一个固定的故障提示(代码里对应 Err02 这个提示), 而不是继续显示正常业务画面;
- 主板收到“我复位了”之后再刷一次显示,两边状态就对齐了。
为什么不做一条专门的心跳命令?因为主板刷新显示本来就是周期性必然发生的事件, 直接复用它当心跳,等于零协议开销、零额外状态机。 再加一条心跳命令,只会多出一种“两边对心跳的理解不一致”的可能。 我后来把这条经验总结成一句话:如果链路上已经有一条周期性必然发生的报文,就不要再发明心跳。
| 触发条件 | 屏幕表现 | 我这样设计的理由 |
|---|---|---|
| 按键侧复位(上电或看门狗),且尚未收到主板的显示帧 | 显示固定的链路故障提示(Err02) | 提示“我和主板之间还没对上”,现场看到就知道该查线或查主板 |
| 加密校验未通过 | 显示固定的校验故障提示(Err00) | “我自己有问题”和“链路有问题”必须是两种不同的显示,现场才能一眼分辨 |
| 收到显示帧 | 按帧内容正常刷新,故障提示清除 | 用“收到了真实业务数据”作为恢复条件,比用时间做恢复条件可靠 |
七、问题与解决
下面这几条都是我在这个工程里真实撞到的,按“现象 → 原因 → 办法”记下来。
| 现象 | 原因 | 我的办法 |
|---|---|---|
| 改了段码映射之后,某个图标不亮或者亮在别的位置上,代码没有任何报错 | 同一头文件里存在两套图标位掩码定义(旧的一套被注释掉),位值不同,改版时没有收敛到一处 | 把段码与图标位集中到唯一一份定义,另维护一张“字符/图标 → 位”的对照表;改版时上板逐项点亮核对 |
| 同时按两个键时,上报了一个谁也没按的键值 | 矩阵键盘没有二极管隔离,同列多键时会通过另一列形成通路,扫出“幽灵键” | 扫描判定里显式排除同列组合,一次只认一个键;并把这条写进注释说明原因 |
| 键盘板复位之后,主板仍在等旧状态,两边显示与按键对不上 | 复位是单向事件,主板没有被通知;键盘自己也不知道对面还在不在 | 开机主动上报一帧“我复位了”,并置“链路未确认”标志;只有收到显示帧才清零,期间显示固定故障提示 |
| 翻源码时分不清哪个驱动文件才是真在用的 | 同一族的两颗驱动器头文件并存(一颗是替换掉的旧型号),旧文件旁边还留着大量编译器临时备份文件(*.h~RF*.TMP) |
先看工程文件里实际参与编译的文件列表,再读代码;确认不用的文件移出源码目录,不要留在原处 |
| 以为工程里有 IAP 升级能力,实际上没有 | 代码里确实有一个“把应用入口拷到 RAM、再把存储映射切到 RAM”的函数,宏也指向了引导区之后的应用区,说明规划过“引导区 + 应用区”的方案;但整个工程里找不到它的调用点,函数体也是空的 | 如实记为“规划过、未启用”;判断一个功能在不在,必须找调用链,不能只看函数名 |
| 想给某个模块加一个 .c 文件,结果到处重复定义 | 大量实现写在头文件里,源码目录下只有三个 .c 文件,其余全是“头文件里写实现” | 把实现挪回 .c,头文件只留声明;这件事我是当成技术债记下来的,没有全面返工 |
这一路排查下来我最大的体会是:在这类工程里,“找到真正在跑的那份代码”本身就是一项技能。 同一族驱动器的两个版本并存、大量临时文件混在源码目录里, 这些痕迹都不会报错,但会让你读错代码。关于这件事我另外写了一篇 《拿到一份陌生固件工程的前 30 分钟》, 里面把“先看编译列表、再找调用链”的顺序整理了一遍。
八、局限与还没做完的部分
这块板子的功能是跑通了,但下面每一条都是我知道有问题、只是还没动手改的。
| 问题 | 现状 | 我打算怎么改 |
|---|---|---|
| 按键事件只有“按下”一种 | 键值一变就上报,没有区分长按、连击、释放,长按在上位机看来就是一串重复的单键 | 在本地维护按下时长,把“短按 / 长按 / 释放”做成三类事件再上报,让产品语义和电气事实分开 |
| 触摸方案只到驱动层 | 驱动写好了,引脚与矩阵键盘的行线重叠,没接进主流程,也没做过手感对比 | 要么给触摸单独排一组引脚并做一轮对比试验,要么把这部分代码从源码目录里清出去 |
| 段码定义仍是两处遗留 | 旧的一套图标位掩码还以注释形式留在头文件里 | 删除旧定义,并把段码表与对照表放在同一个文件、同一处维护 |
| 没有做上电全段自检 | 上电直接进正常显示,屏上有没有坏段、虚焊的段,软件不检查 | 上电先点亮全部段一小段时间(在故障提示之前),既是自检也让现场能看出屏本身的好坏 |
| 校验范围偏窄 | CRC32 只覆盖有效数据,帧头与帧尾靠“两处都对上”间接保证 | 下一版把校验范围扩到整帧;多算几个字节的成本可以忽略 |
| 接收缓冲区没有硬上限 | 按可能的最大帧长留缓冲,但没有“越界即丢弃”的强制判断 | 加一个长度上限判断,超限直接复位接收状态机 |
| 没有版本记录 | 工程里没有 readme,只能靠头文件模板的版本号和注释里的日期戳推断年代 | 补一份改动记录;至少把“这一版改了什么、为什么改”写清楚 |
还有一条不属于“没做完”,而是设计上的取舍我想说明白:这块板子不理解屏幕上的内容。 它只负责把主板发来的字节放进显存。好处是面板完全受控于主板,改显示不用动键盘板的固件; 坏处是主板的显示逻辑一旦有 bug,会原封不动地显示在面板上—— 这块板子不会、也没能力替主板兜底。
参考资料与说明
- 段码 LCD 驱动芯片数据手册(静态/复用驱动模式、闪烁控制、显存与子地址说明),用于确认显示模式与刷新方式。
- 电容触摸芯片数据手册,用于确认灵敏度配置与 I²C 读写时序。
- I²C 总线规范(NXP UM10204)公开版本,用于确认起始/停止条件与时序要求。
- CRC-32(IEEE 802.3 多项式)公开说明,用于确认校验算法的参数与字节序约定。
- Arm Cortex-M0 技术参考手册与 STM32F0 系列参考手册、标准外设库文档,用于确认 GPIO、串口与看门狗配置。
- Modbus 应用协议规范(Modbus Application Protocol Specification),作为“从站/功能码/异常码”概念的参照;本文这条链路是私有协议,与 Modbus 无关。
- 文中涉及的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。
- 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码; 协议只保留字段顺序与长度,段码表、指令码、时钟配置等板级参数均不在本文范围内。
- 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。