简介:面向C51单片机开发者的T9输入法实现资料,专注解决小键盘设备高效文本输入问题。方案基于T9预测算法,通过数字键序列匹配预编译词库,可在资源受限的8位单片机上稳定运行。压缩包以RAR格式提供,共21个文件,总体积仅116KB,包含C语言源码、头文件、STARTUP.A51启动文件、hex烧录文件及Keil工程文件,其中源码与索引头文件便于二次开发,适用于嵌入式交互项目或学习预测输入原理。已有436人学习下载。该实现完整涵盖键值映射、动态预测、上下文关联及词库压缩与查找优化等关键环节,代码结构清晰,开发者可据此定制词库、调整记忆功能,或移植至其他单片机平台。对希望提升嵌入式文本输入体验、理解T9算法在有限资源下落地的读者而言,是一份轻量而完整的参考。
1. C51单片机T9输入法:存储布局比算法更值得较劲
接到一个「给某款小键盘设备加中文输入」的需求,最初的想法很简单:T9输入法九个键、一张映射表,把拼音查出来再上屏,能有多少工作量。真正动手才发现,C51单片机T9输入法最难的不是T9的匹配逻辑,而是存储布局——词库放哪、用多少字节、查询怎么不吃RAM。8位C51的Flash以KB计、RAM以字节计,一款常见的51芯片内部就8KB到几十KB,而一个16x16点阵的完整汉字字库超过100KB,光是这个数字就能让大多数方案现场翻车。这篇笔记写给想低成本键盘、仪表面板、课程设计里做中文输入的开发者。核心思路一句话:用查表法把词库压进code区,把复杂逻辑留给离线脚本。
2. T9查询为什么不能照搬PC方案:多候选、8位机与存储预算
T9的输入模型其实非常简单:九宫格键盘上,每个数字键对应一组字母,用户按一串数字,系统把这串数字还原成拼音,再映射到汉字。这个模型在PC上可以用哈希、字典树、动态内存轻松实现,但在C51上每一步都得重新算账。
2.1 多候选是T9的固有行为:426既是“高”也是“好”
数字键到字母的映射是T9的全部基础,标准映射如下表:
| 按键 | 对应字母 |
|---|---|
| 2 | abc |
| 3 | def |
| 4 | ghi |
| 5 | jkl |
| 6 | mno |
| 7 | pqrs |
| 8 | tuv |
| 9 | wxyz |
用户敲入序列“426”,这个序列在数学上对应哪些组合?数字4可能是g或h,数字2可能是a、b或c,数字6可能是m、n或o,全部组合数为2×3×3=18种。真正落在汉语拼音里的组合并不多,常见的有gao(高)、hao(好)、gan(干)、han(汗)等。T9的做法不是去穷举18种组合再逐一验证,而是维护一张“按键序列→合法拼音”的映射表,直接把426映射到这几个拼音。这里要强调一点:多个合法拼音对应同一个按键序列,这是T9的固有歧义,而不是代码写错了。输入法在用户敲到第2个数字时就应该开始猜——比如敲到42,序列还没完,但“gao”“hao”“gan”“han”这些拼音的前两位都是42,这时候就要先把候选刷出来,等第3个键敲下去再锁定。这种边敲边刷新候选的行为,直接决定了后面状态机的形状。
2.2 先算字库再谈算法:C51的存储预算倒算法
C51项目立项时有个坏习惯,先写代码再考虑容量。做T9输入法正好反过来,必须先算“字库有多大”,再倒推剩下的Flash能装多少代码。
汉字显示的常规方案是16x16点阵,每个字占16×16÷8=32字节。GB2312一级汉字有3755个,全量点阵是3755×32≈120KB。主流C51芯片内部Flash从8KB到64KB不等,也就是说全量字库在多数芯片里根本装不下,就算用64KB芯片硬塞进去,用户程序和词库表也没地方放了。想缩小字库可以用12x12点阵,每字18字节,3755字约67KB,仍然很挤,而且小号点阵在12864这类屏上显示效果明显变差。所以遇到这个项目,第一步就面临三个方向的取舍:
| 方案 | 点阵容量需求 | 优点 | 缺点 |
|---|---|---|---|
| 显示模块自带字库(ST7920方案) | 0 | 主控只存编码,ROM压力最小 | 屏幕型号受限 |
| 外挂SPI Flash存字库 | 0(字库在外部) | 任意点阵屏可用 | 增加一颗芯片和SPI驱动 |
| 内置常用几百字 | 约25KB(按800字算) | 电路最简单 | 输到生僻字显示不了 |
RAM侧反而乐观:T9输入过程只需要一个6字节的按键缓冲、一个候选序号、一个GB2312编码变量,加上堆栈和临时变量,128字节的data区够用。矛盾焦点始终在ROM。我一般先按ST7920带字库的12864屏来设计,把字库存放大问题外包给显示模块,主控里只存拼音表和索引表,整个词库表可以压到10KB以内。
2.3 为什么选查表法:链表和哈希在8位机上的代价
把PC上的习惯搬过来,很多人第一反应是链表:每个拼音节点挂一串汉字,查询时遍历。链表的代价在C51上非常明显:每个节点至少多出2字节指针,400多个节点就是接近1KB,这还算能忍;更要命的是动态分配。C51的RAM又小又没有MMU,malloc在小内存上很容易碎片化,跑一段时间后就分配不出连续块,设备当场死循环。哈希需要一个不小的桶数组,RAM预算直接爆掉。
查表法完全是另一条路:所有数据在编译期就排好序、放进code区(程序Flash),运行时只做“定位+比较+取数”。code区访问用的是CPU的查表指令,不需要额外RAM,查询时间也是可预测的。在几百个词条里做二分查找,最多十几次比较,每次数微秒,12MHz主频下完全无感。这也是为什么“C51 T9输入法”这类项目,正经的做法都是表驱动:把复杂逻辑放在离线脚本里,单片机上跑的是一个又小又快的查表函数,这正是这个标题最值得学的工程思路。
3. 词库结构设计:按键序列索引表和拼音表怎么压进code区
词库表是T9输入法的心脏。设计目标是三件事:容量小、查询快、生成方式可维护。这里的核心做法是把「按键序列到拼音」和「拼音到汉字」拆成两张表,由离线脚本生成,单片机上只写一个二分查找。
3.1 两张表的分工:索引表只回答“在哪个区间”
先看拼音表py_list。每个条目存三项内容:拼音字符串本身、该拼音下第一个汉字的GB2312编码、该拼音下汉字的个数。存拼音字符串不是浪费,候选栏要显示拼音助记,而且查询命中后还要拿它和按键序列比对。
struct PyItem { unsigned char code *pinyin; // 拼音字符串,如 "gao" unsigned int gb_start; // 该拼音首字的GB2312码 unsigned char count; // 该拼音下汉字个数 };注意这里用code修饰指针,表示指针指向code区(程序Flash),这样在C51编译器下指针只占2字节,访问效率也高于通用3字节指针。这条表按「拼音字符串的按键序列」分组排序,比如所有按键序列为426的拼音(gao、hao、gan、han)在表里连续存放。为什么要这样排?因为接下来索引表就能用“起始下标+数量”两个数字描述一段区间,查询时不必遍历整张拼音表。
索引表key_map的结构如下:
struct KeyItem { unsigned char code *seq; // 按键序列字符串,如 "426" unsigned char start; // 在py_list中的起始下标 unsigned char num; // 该序列对应的拼音个数 };key_map按seq升序排列,查询时用二分定位。找到seq后,取出start和num,再到py_list里从start开始连续取num个拼音。这样一次查询最多两次跳转,没有任何动态内存。
3.2 离线脚本生成C数组,别手写词库
表结构定了,接下来最忌讳的事就是手敲这400多个拼音条目。正确做法是写一个离线脚本,从一份“拼音→汉字”清单生成C数组。清单的来源是标准汉语拼音表和GB2312编码表,按拼音分组,每个拼音下列出该拼音对应的汉字及其GB码。脚本要做的处理有三步:把字母转成九宫格数字序列,按序列分组排序,最后输出C语言数组文件。这个脚本在PC上跑,生成的结果可以直接#include进工程。脚本代码放到第6章的进阶验证里,那里会给出一个能同时生成和验证的最小版本。
生成的C数组定义如下(示意数据,实际按脚本生成):
const struct PyItem code py_list[] = { {"ba", 0xB0D4, 1}, // 巴 {"ca", 0xB2E6, 1}, // 擦 // 其余条目由离线脚本生成,按按键序列分组排序 }; const struct KeyItem code key_map[] = { {"22", 0, 2}, // 22 对应 ba、ca // 其余条目由离线脚本生成 };手写这张表是自找苦吃,坐标、个数、GB码只要错一个字节,显示就是乱码,而且极难定位。脚本生成后要连带输出一份校验清单,列出每个序列对应的拼音数,方便人工抽样核对。
3.3 二分查询:定位序列、再取拼音
查询函数只做两件事:先用当前按键序列在key_map里二分,找到对应的拼音区间;再从py_list里取出这些拼音。当前按键序列存在一个6字节的data区数组里,每次按键追加一个数字,退格时减少一个数字,函数只靠这个数组定位。
unsigned char find_candidate(unsigned char *seq, unsigned char *out_start, unsigned char *out_num) { unsigned char lo = 0, hi = key_map_len - 1, mid; while (lo <= hi) { mid = (lo + hi) >> 1; if (seq_cmp(seq, key_map[mid].seq) < 0) hi = mid - 1; else if (seq_cmp(seq, key_map[mid].seq) > 0) lo = mid + 1; else { *out_start = key_map[mid].start; *out_num = key_map[mid].num; return 1; // 命中 } } *out_num = 0; return 0; // 未命中 }seq_cmp是逐字节比较函数,两个参数都是字符串,一个在data区、一个在code区。C51的编译器对跨存储空间的指针比较很挑,比较时统一用unsigned char code *访问code区的那一侧,避免使用默认的通用指针——通用指针占3字节且慢,在二分循环里开销会被放大。命中之后,外层代码拿着out_start和out_num到py_list里逐条取拼音,每个拼音再取其汉字段。
3.4 候选字生成与GB2312编码换算
找到拼音后,候选字直接从py_list条目的gb_start开始连续取。这个设计能成立,是因为离线脚本已经把同一拼音下的汉字整理成连续区间,并且把每个字的GB2312码记录进了数据源。要显示汉字时,主控只负责传两个字节的GB2312码。ST7920这类带汉字字库的12864屏,在扩展指令模式下直接写两个字节就能显示一个16x16汉字:
void lcd_put_hanzi(unsigned int code gb) { lcd_write_cmd(0x34); // 进入扩展指令模式 lcd_write_data((unsigned char)(gb >> 8)); // 高字节:区码+0xA0 lcd_write_data((unsigned char)(gb & 0xFF)); // 低字节:位码+0xA0 lcd_write_cmd(0x30); // 回到基本指令模式 }GB2312的编码规律是:区码和位码各加0xA0。比如“高”字在GB2312中算出0xB8DF,这里不需要背具体值,只要记住公式gb = (区 + 0xA0) << 8 | (位 + 0xA0)。离线脚本在生成py_list时已经算好了gb_start,单片机端不需要再做区位码换算,直接透传给液晶即可。
4. 跑起一个最小系统:矩阵键盘、状态机与ST7920显示
词库表设计好之后,剩下的工作是把键盘、逻辑、显示三部分接成一个闭环。这个章节给出一套可以照抄的最小系统,按键用4x4矩阵,显示用ST7920的12864,主循环只做三件事:查按键、刷新候选、上屏。
4.1 矩阵键盘扫描:翻转法和10ms去抖
4x4矩阵键盘占用8个IO,P1口就能搞定。扫描用翻转法:先把输出口置为低电平,读输入口得到其中一维;再把方向翻转,读另一维。代码骨架如下:
unsigned char scan_key(void) { unsigned char col, row; P1 = 0x0F; // 高4位输出0,低4位输入 col = P1 & 0x0F; // 读低4位得到列信息 if (col == 0x0F) return KEY_NONE; P1 = 0xF0; // 翻转方向 row = P1 & 0xF0; // 读高4位得到行信息 // 根据row、col换算键值,查矩阵表 return key_value[row | col]; }这段代码的关键是两次方向相反地读写P1,如果哪一行或哪一列连通,就能同时得到行和列。注意每次翻转前要确保P1方向一致,否则读到的是上一次的输出值。去抖用状态机而不是阻塞延时,否则按快了会丢键。去抖思路是:连续两次扫描值相同才认为按键有效,两次之间间隔10ms,由定时器标志控制,主循环不在扫描函数里死等。
4.2 输入状态机:等待、输入、选字、上屏
T9输入的交互流程是一个典型状态机,状态转移固定,适合用switch-case组织。状态定义如下:
| 状态 | 含义 | 触发条件 |
|---|---|---|
| IDLE | 等待首个数字键 | 收到数字键0-9 |
| INPUT | 已输入一串数字,正在刷新候选 | 每收到一个数字键 |
| SELECT | 候选已出现,等待切换或确认 | 候选数>1且用户按# |
| CONFIRM | 当前拼音的首字上屏 | 按确认键 |
状态转移规则要照顾两个T9特有的细节:一是INPUT状态下继续按数字键,序列追加一位,重新查询候选;二是按退格键*时序列缩短一位,回到INPUT;如果序列清空则回到IDLE。候选切换键#只在SELECT状态下有效。确认上屏后,如果用户继续按数字键,应当保留已上屏内容同时开启新的一轮输入,而不是清屏重来。这段逻辑在代码里不需要单独的枚举变量,seq_len是否为0就能区分IDLE与INPUT,num是不是大于0则隐含了SELECT状态。
4.3 主循环骨架:一轮完整的输入
把扫描、查询、显示拼起来,主循环大概长这样:
void main(void) { unsigned char seq[MAX_SEQ_LEN + 1]; unsigned char seq_len = 0; unsigned char start, num, idx = 0; unsigned char key; init_io(); init_lcd(); while (1) { key = get_key(); // 带去抖的按键读取 if (key >= '2' && key <= '9') { if (seq_len < MAX_SEQ_LEN) { seq[seq_len++] = key; // 追加一位 seq[seq_len] = 0; } if (find_candidate(seq, &start, &num)) { idx = 0; // 每次新序列从第一个候选开始 show_candidate(start, num, idx); } else { show_not_found(); } } else if (key == '#') { idx = (idx + 1) % num; // 切换候选字 show_candidate(start, num, idx); } else if (key == '*') { if (seq_len > 0) { // 退格 seq_len--; seq[seq_len] = 0; find_candidate(seq, &start, &num); show_candidate(start, num, idx); } } else if (key == KEY_OK) { if (num > 0) { send_to_display_line(seq, start, num, idx); seq_len = 0; // 上屏后清空序列,开启新一轮 seq[0] = 0; } } } }这段骨架的关键在候选刷新逻辑:每收到一位数字,都重新走一遍find_candidate;#键只做循环切换,不重新查表;*键删掉序列末尾再重新查询。show_candidate负责把当前拼音的汉字段显示出来,idx指向第几个汉字。num置0时表示当前序列没有匹配的拼音,这时候界面应该提示用户,而不是显示空白。
5. 避坑与排查:C51 T9输入法五个常见的翻车现场
这一章把最常见的坑集中写出来,每一条都是现场演示过的翻车经历。C51上做输入法,表的正确性、指针的存储空间、字库的选取,三个环节最容易出问题,下面按现象、原因、解决三步展开。
5.1 现象:候选字整体错位,第一个字对,后面的字全不对
首先想到的是拼音表里汉字个数count写错。离线脚本生成的表是按拼音分组、连续存放的,如果某个条目的count比实际汉字数多1,那么下一个拼音的第一个字就会被吞掉。可以从第一个拼音开始逐个复核count。要彻底避免这种问题,建议在离线脚本里加一个自检:生成C数组后,脚本重新读取数组,把每个拼音的汉字数量和GB2312区间核对一遍,任何不一致都直接报错。单片机端不要再人工校对数据。
5.2 现象:按键按快了丢数字,敲四个键只进三个
最常见的原因是去抖用了阻塞式延时,比如按下后delay(200ms)等按键释放。用户在快速输入时,后面的按键落在延时窗口里,被直接丢弃。解决思路是改成非阻塞去抖:主循环里周期性地扫描键盘,两次扫描值相同才确认按键,按键释放后再允许接收下一个键。还有一个容易被忽略的点:查询函数如果放在按键中断里执行,比较耗时,也会造成后续按键被丢弃。把查询放到主循环,中断里只置位“有按键”标志,这是最稳的写法。
5.3 现象:LCD上出现的是乱码点阵而不是汉字
乱码的根源多半是GB2312码被拆成了两个字节分别当作ASCII显示,或者屏幕根本没有汉字字库。普通1602液晶只有ASCII字库,送GB2312码给它,屏幕只会显示两个乱码字符。ST7920这类带字库的屏,要确定代码进入了扩展指令模式,而且先设好行内坐标,再连续送两个字节。还有个典型错误:有的驱动在每次lcd_write_data之间插入检查忙状态,字节之间间隔太长,也会显示异常。汉字是两个连续字节,中间不要插入其他命令。
5.4 现象:编译能过,一上电就复位;或者直接编译失败报code段溢出
先说代码段溢出:全量16x16字库120KB,塞进8KB的芯片当然失败。即使不带字库,拼音表和索引表如果设计不当也会到十几KB,必须检查map文件确认code段占用。再说上电复位:经典51的data区通常只有128字节左右,如果定义了大数组作为候选缓冲区,会把栈顶压爆。排查方法是看编译输出里data占用是否接近128字节,超过100字节就要警惕。候选显示不需要大的缓冲区,把字符串处理放到code区,RAM里只保留必要索引。
5.5 现象:换成不带字库的LCD之后,显示全废
这套方案把字库外包给了ST7920,一旦换回纯点阵屏就失效。换成普通LCD后,必须自己提供字库数据,这就回到第2章的存储预算问题。临时解决办法是只放当前拼音涉及汉字的点阵,做成一个小数组;正经做法是外挂SPI Flash字库芯片,主控通过SPI读取点阵再刷到LCD。要在项目早期就定这条路,别在最后关头换屏。如果换用OLED,注意OLED模块多数也不带汉字字库,需要同样的处理。
6. 进阶验证:用串口和上位机脚本把词库一次验透
T9词库最容易出错的位置是按键序列、拼音、汉字三者的对应关系。我的习惯是先用一个上位机脚本在PC上把整张表验证一遍,生成C数组的同时做一次交互查询,确认无误再烧进单片机。脚本核心逻辑只有几十行,但能省下大量现场调试时间:
KEYMAP = {'a':'2','b':'2','c':'2','d':'3','e':'3','f':'3', 'g':'4','h':'4','i':'4','j':'5','k':'5','l':'5', 'm':'6','n':'6','o':'6','p':'7','q':'7','r':'7', 's':'7','t':'8','u':'8','v':'8','w':'9','x':'9', 'y':'9','z':'9'} def seq_of(pinyin): return ''.join(KEYMAP[c] for c in pinyin) def build_table(word_file): table = {} for pinyin, hanzi in read_word_file(word_file): table.setdefault(seq_of(pinyin), []).append((pinyin, hanzi)) return table def query(table, seq): return table.get(seq, [])使用这个脚本时,输入426,输出应该是gao、hao、gan、han这一组拼音及对应汉字;输入22,输出ba、ca等。每修正一次词库来源,就重新跑一遍脚本生成C数组,保证单片机和脚本用的是同一份数据。烧片之后再用串口做回归:单片机收到PC发来的按键序列,回传候选字的GB2312码,PC侧解码成汉字比对。两边一致,这一版词库才算闭合。
联动第3章的离线生成,这套流程的完整闭环是:源数据→脚本验证→生成C数组→烧录→串口回读比对。我习惯在每次改词库后只做一件事——先跑脚本,后动硬件,脚本不通过就不烧片。这个小习惯帮我拦下了至少一半的“显示乱码”和“候选错位”问题,其他部分要靠第5章那些坑的经验慢慢磨。如果你也打算在C51上做T9输入法,把存储预算的前账算清楚、用脚本管好词库,这条路就有把握走下去。希望帮到你。
本文还有配套的精品资源,点击获取