
简介这是一份面向嵌入式开发者和单片机学习者的C51平台T9输入法完整工程源码解决数字小键盘设备中按键高效映射为文本输入的实现问题。压缩包共21个文件、约116KB以Keil C51工程形式组织涵盖t9.c主程序、PY_MB.h词库头文件、a51启动代码、uv2/uvproj工程配置以及hex固件、m51/lst编译清单和obj中间文件可直接打开编译并烧录验证。现有437人浏览学习作者针对C51资源受限环境对词库做了压缩与查找优化适合研究嵌入式输入法实现或在此基础上二次开发。源码内含键值映射表、动态预测逻辑与上下文关联处理通过数字序列匹配候选词对理解T9核心机制和单片机存储优化均具实用参考价值。1. 从矩阵键盘到九宫格C51单片机T9输入法到底能做什么拿到这块板子的时候我的想法挺朴素既然手头有 STC89C52、一块 4×4 矩阵键盘和一块 LCD1602能不能不做那种“按一下蹦一个数字”的玩具而是真的把手机上的 T9 九宫格输入法搬过来答案是可以。这张工程里没有触摸屏没有操作系统所有事都靠一颗 8 位单片机硬扛4 行 4 列矩阵键盘模拟九宫格键位把拼音字母映射成数字按键序列再用查表法匹配出候选汉字最后送到 LCD1602 上显示和翻页。整个项目跑起来以后你能在没有任何外部模块的情况下用物理按键完成“输入拼音串→选择候选字→上屏”的完整输入闭环。适合谁想搞懂按键状态机怎么设计的人、课程设计想做输入法主题的人、以及想看看 8 位机的 Flash 和 RAM 怎么在“码表体积”和“检索速度”之间做取舍的人。2. 整体设计与硬件选型C51 跑 T9 输入法的合理边界2.1 硬件接线与器件选型这个项目的硬件核心是三样主控、按键矩阵和 LCD。主控用的是 STC89C52因为它在 C51 教学板和实验箱上最常见内部 8KB Flash、512 字节 RAM对于存一个精简拼音码表来说刚刚够——码表放 code 段不占 RAM检索时只拿少量变量做索引。晶振选 11.0592MHz这个频率下串口波特率能除出整数方便后面调试时通过串口打印按键值和匹配结果。矩阵键盘是 4×4 排布P1.0~P1.3 做行线P1.4~P1.7 做列线。扫描方式是常见的行列反转先给行线输出低电平读列线再反过来给列线输出低电平读行线两次结果拼在一起就知道哪个键被按下。注意 C51 的 P1 口内部有上拉但为了抗干扰我一般会在行线上再外接 10k 上拉电阻否则在实验室里用手碰键盘很容易误触发。LCD1602 的数据线接 P0 口RS 接 P2.0RW 接 P2.1E 接 P2.2。P0 口是开漏输出必须外接 4.7k~10k 的上拉排阻不然数据总线拉不高屏幕显示出来全是乱码或者干脆不亮。这是新手做 1602 驱动最常见的坑之一后面避坑章节会展开说。整体接线表如下模块引脚接到单片机矩阵键盘行P1.0 ~ P1.3行扫描输出矩阵键盘列P1.4 ~ P1.7列扫描输入LCD 数据P0.0 ~ P0.78 位数据总线需上拉LCD RSP2.0命令/数据选择LCD RWP2.1读/写选择LCD EP2.2使能信号蜂鸣器P3.5按键提示音输出2.2 T9 输入法的运行流程T9 输入法的本质是把“拼音字母串”映射成“数字串”再用数字串做查表。比如字母 h、a、o 分别在九宫格的 4、2、6 三个键上所以 hao 这个拼音的数字串就是 426。在手机上你按 4、2、6 三个键输入法会从词库里把“好、浩、号、豪”这些候选字捞出来。这个项目完全复刻了这条链路只不过词库从云端字典变成了单片机 Flash 里的一张静态表。主循环的流程是这样每次扫描矩阵键盘得到键值做完消抖和松键检测后把键值交给按键状态机。状态机分数字输入态、拼音匹配态、候选选择态三个状态。数字输入态里按 2~9 会追加数字串按 1 或 0 切换特殊符号这个工程里没做留了接口拼音匹配态里每次数字串变化都会重新查表候选选择态里按左右键翻页、按确认键上屏。整个流程是同步轮询没用定时器中断做调度因为 8 位机跑这个逻辑已经足够快查表一次最多几十个周期。2.3 工程文件结构拿到这份工程后目录结构是这样的T9_C51/ ├── src/ │ ├── main.c // 主循环初始化和各状态调度 │ ├── key_matrix.c // 矩阵键盘扫描、消抖、键值映射 │ ├── key_matrix.h │ ├── t9_match.c // 拼音码表定义和数字串匹配算法 │ ├── t9_match.h │ ├── lcd1602.c // LCD 驱动、显示缓冲区、光标控制 │ ├── lcd1602.h │ └── codetable.c // GB2312 汉字内码索引表 ├── keil/ │ └── T9.uvproj // Keil 工程文件 ├── hardware/ │ ├── schematic.pdf // 接线原理图 │ └── bom.csv // 元器件清单 └── doc/ └── 设计说明.md // 状态机设计文档和流程图这个分层是故意拆开的。key_matrix 只管按键t9_match 只管查表lcd1602 只管显示main.c 里只做状态流转。后面你要改成五笔输入法只需要换掉 t9_match.c 和 codetable.c其他文件一行都不用动。这也是我在拆这个项目时觉得最舒服的地方——输入法核心算法和硬件驱动被隔离得比较干净。3. 核心模块拆解从按键事件到候选字上屏的完整链路3.1 矩阵键盘扫描与按键状态机键盘扫描是输入法最底层的部分如果这层按键值都不可靠后面的码表匹配再快也没用。先看扫描函数unsigned char KeyMatrix_Scan(void) { unsigned char row, col; unsigned char key_code 0xFF; // 0xFF 表示无按键 P1 0x0F; // 行线输出低列线输入 if ((P1 0x0F) ! 0x0F) // 有键按下 { DelayMs(10); // 软件消抖 if ((P1 0x0F) ! 0x0F) { col P1 0x0F; // 读列值 P1 0xF0; // 反转列线输出低行线输入 row P1 0xF0; // 读行值 key_code row | col; // 拼出完整键值 } } return key_code; }这个函数的核心是“两次扫描交叉验证”。第一次 P10x0F 时低四位输出低电平如果有按键按下对应列线会被拉低于是 P1 的低四位不再全部是 1延时 10ms 后再读一次是为了避开按键抖动的不稳定区间。第二次 P10xF0 把方向反转过来读取行线的状态。两次读到的 nibble 拼在一起就是一个 8 位的唯一编码比如第一行第一列是 0xEE第一行第二列是 0xDE。拿到键值以后不能直接拿去用因为物理按键按下和释放是两回事。我在这份工程里看到一个比较实用的处理方式只捕获“按下沿”而不是电平。也就是说只有按键从“没有按下”变成“按下”的瞬间才产生一个事件。这需要缓存上一次的键值unsigned char KeyMatrix_GetEvent(void) { static unsigned char last_key 0xFF; unsigned char cur_key KeyMatrix_Scan(); if (cur_key ! 0xFF last_key 0xFF) { last_key cur_key; return cur_key; // 只在按下瞬间返回有效键值 } if (cur_key 0xFF) { last_key 0xFF; } return 0xFF; // 按住不重复触发 }注意这里有个细节返回值 0xFF 被当作“无事件”处理而 0xEE 这类值才是有效按键。为什么不用 0 表示无效因为矩阵键盘扫描结果里本来就可能出现 0x0F、0xF0 这种中间状态用 0xFF 做哨兵值能避开这些歧义。这也是按键状态机设计里容易被忽略的一点。3.2 拼音码表的存储结构码表是 T9 输入法的灵魂。工程里的码表不是简单地存一个拼音字符串而是把拼音和它的数字编码、对应汉字内码放在同一个结构体里。我摘取了 core/codetable.c 里的核心定义typedef struct { unsigned char code[7]; // 数字编码如 426 对应 hao unsigned char pinyin[7]; // 拼音字母串如 hao unsigned char hanzi; // GB2312 内码低字节 unsigned char hanzi_hi; // GB2312 内码高字节 } T9_CodeItem; T9_CodeItem code t9_table[] { {426, hao, 0xBA, 0xC3}, // 好 {426, hao, 0xBA, 0xC5}, // 浩 {426, hao, 0xBA, 0xC6}, // 号 {542, lin, 0xC1, 0xD6}, // 林 {542, lin, 0xC1, 0xD9}, // 临 // ... };注意结构体变量前加了一个 code 关键字这在 Keil C51 里是扩展语法意思是不放在 RAM 里而是放在程序存储区 Flash 里。这么设计的原因很朴素C51 的 RAM 只有 512 字节一张稍微像样的拼音码表几百条记录每条记录光数字编码加拼音串就占十几个字节塞到 RAM 里直接爆放到 code 段以后访问时会通过 DPTR 寄存器间接寻址速度慢一点但容量能到 8KB。对于这个场景Flash 换 RAM 是绝对划算的。GB2312 内码为什么要拆成高低两个字节因为 LCD1602 显示汉字时字符发生器是直接读 GB2312 两字节内码的低字节在前高字节在后。如果用一个 int 存则还要做字节序处理在 8 位机上拆开存反而省事。这在后面 3.4 节写 LCD 显示函数时会看到用处。3.3 数字串匹配与候选字检索算法现在到了整个项目最核心的部分给定一串数字怎么在码表里把匹配的拼音捞出来工程用的方法是顺序扫描 前缀匹配代码在 t9_match.c 里unsigned char T9_Search(unsigned char *digit_str, T9_CodeItem *result, unsigned char max_count) { unsigned char i, j, match 0; unsigned char len 0; while (digit_str[len] ! \0) len; // 计算输入数字串长度 for (i 0; i TABLE_SIZE; i) { // 匹配前 len 个数字 for (j 0; j len; j) { if (t9_table[i].code[j] ! digit_str[j]) break; } if (j len) // 完全匹配前 len 位 { result[match] t9_table[i]; match; if (match max_count) break; } } return match; // 返回匹配条数 }这个函数的设计有几个关键点。第一它是“前缀匹配”而不是“全匹配”输入 426 会匹配所有以 426 开头的数字编码包括 426hao和 4263hang这样用户在输入过程中就能看到候选字不断收窄。第二max_count 参数限制了最多返回多少条候选结果避免一次把几十个同音字全部搬出来屏幕写不下。第三函数返回的是匹配条数外面拿到这个值以后再结合当前翻页偏移量计算应该显示哪些候选字。检索复杂度是 O(N×L)N 是码表长度L 是输入数字串长度。对于几百条记录的码表来说最多几千次比较C51 在 11.0592MHz 下跑完也就几毫秒完全感觉不到卡顿。如果你想优化可以给码表按数字编码排序然后用二分查找把复杂度降到 O(log N)。但这份工程没这么做原因我在代码注释里看到了排序会破坏同音字按 GB2312 内码排列的顺序而显示候选字时恰好需要这个顺序来保证“好、浩、号”这类同音字的稳定输出。3.4 候选字翻页与 LCD 显示驱动查表拿到了候选字列表接下来的问题是LCD1602 只有两行、每行 16 个字符一次最多显示 8 个汉字一个汉字占两个字符位置候选字多了就得翻页。工程里定义了一个简单的分页结构#define MAX_CANDIDATES 8 // 每页最多 8 个候选字 #define MAX_PAGES 6 // 最多 6 页 typedef struct { unsigned char current_page; unsigned char total_pages; unsigned char total_count; T9_CodeItem *page_items[MAX_CANDIDATES]; } CandidatePage;翻页逻辑在 main.c 里按 * 键向后翻按 # 键向前翻。每次翻页时重新计算当前页的起止索引然后把对应的汉字内码送到 LCD 上。LCD 显示函数里有个值得注意的写法它直接操作 P0 口发送数据void LCD_WriteByte(unsigned char is_data, unsigned char byte) { LCD_RS is_data; LCD_RW 0; P0 byte; LCD_E 1; DelayUs(5); LCD_E 0; }这里 LCD_E 从高到低的下降沿就是 1602 的锁存时机数据必须在 E 为高时保持稳定。显示候选字时把 GB2312 高字节和低字节分别当作两个“字符”发出去1602 的字符发生器会自动识别高位为 1 的编码并切换到汉字模式。所以你在 3.2 节看到的 hanzi_hi 和 hanzi 两个字节在这个函数里的作用就体现出来了。4. 编译烧录与调试让代码真正跑起来4.1 Keil 工程配置与编译工程文件里带了一个 Keil 的 .uvproj如果你用的是 Keil C51 V9.55 以上版本直接双击打开就能编译。需要注意的是Keil 默认会把 code 段限制在 64KB对于 8KB Flash 的 STC89C52 来说不是问题但如果你换了大容量芯片比如 STC12C5A60S2记得在 Target 选项卡里把 Code Rom Size 改成 Large否则超出 8KB 的部分会被截断。打开工程后建议先做三件事这是我在拿到这份源码后的固定动作。第一检查存储器模式Memory Model 改成 Small因为变量本来就少用 Small 模式能保证局部变量优先放内部 RAM访问速度最快。第二确认晶振频率在 Target 选项卡的 Xtal 里填 11.0592这不是摆设Keil 的模拟仿真和延时函数计算都依赖这个值。第三把优化级别从默认的 Level 0 改成 Level 2C51 的优化器在 Level 2 下会做变量覆盖分析能省出不少 data 空间。编译时如果报“CODE SIZE LIMIT EXCEEDED”说明码表超过了 8KB。这种时候不要急着换芯片先看 codetable.c 里有没有冗余的拼音条目。比如“zhi”“chi”“shi”这种高频拼音每个下面挂了几十个候选字占用非常大可以只保留前 12 个常用字作为精简版。编译通过后生成的 hex 文件在 Output 目录下。烧录用 STC-ISP 工具芯片型号选 STC89C52RC串口波特率 9600 或者 4800 都行关键是断电下载点“下载”按钮后再给板子上电STC 系芯片是这个流程。烧录失败时最常见的现象是“正在检测目标单片机... 握手失败”十有八九是串口号选错了或者板子上的外部晶振没起振——STC89C52 出厂默认用内部 RC 振荡器如果你把晶振电路接错了反而会干扰它。4.2 Proteus 仿真与硬件调试如果你手头暂时没有实物板子这份工程里的 Proteus 仿真文件也能撑起整个流程。Proteus 里已经放好了 STC89C52、4×4 键盘矩阵和 LCD1602逻辑连接和 2.1 节的接线表完全一致。直接加载 hex 文件运行点键盘上的数字键就能看到 LCD 上出现拼音和候选字。用仿真有个好处你能通过 Proteus 的数字探针看到 P1 口在行列反转扫描时的电平变化这对理解矩阵键盘工作原理非常有帮助。不过仿真和实物有区别我踩过的坑是 Proteus 里的虚拟按键没有机械抖动DelayMs(10) 消抖代码在仿真里根本看不出效果。你如果把按键输入改成快速连按Proteus 能准确捕获每一次按下但实物上铝合金按键的触点回弹会造成一次按下被识别成两三次。所以调完仿真逻辑以后强烈建议找块实物板子再过一遍真机的经验无法替代。上电后先验证三个基本现象第一LCD 背光亮、首次上电显示“T9 Input Ready”第二按 4 键屏幕第二行出现以 4 开头的拼音列表第三按 4、2、6 三个键后第一行出现“好 浩 号”候选字。如果前两步没反应先别急着查代码用万用表量一下 P0 口的电压——多半是上拉排阻没焊或者虚焊了。5. 避坑排查键盘扫描、码表越界、显示乱码的经验记录5.1 按键按一次触发两次响应现象在实物板上快速按数字键 5第二行的数字串一次跳了两个 5或者候选字跳过了某个拼音。原因按键消抖只做了按下延时没有处理释放瞬间的回跳。铝合金轻触开关在按下和释放两个边沿都有 5~10ms 的抖动区如果主循环轮询周期追上释放抖动会把“释放”误判成“按下”。更隐蔽的是有些开关内部触点回弹能持续 20ms单纯加长延时并不能根治反而会让快速输入变得迟钝。解决状态机里引入“等待释放”这一状态。检测到有效按下以后进入一个 while 循环直到扫描结果连续 30ms 为 0xFF无按键才退出回到待检测状态。这样一次完整的按下释放只产生一个事件。代码里在 KeyMatrix_GetEvent 函数中将 last_key 和 cur_key 的边沿判断结合已经覆盖了这个逻辑但如果你自己写键盘驱动一定要补上释放等待否则连击问题会反复出现。5.2 输入 3 位数字后候选字全部消失现象输入两位拼音数字时有候选字补到第三位以后 LCD 显示“NO MATCH”。原因码表里没有三个数字对应的拼音。比如你输入 937对应的字母组合里能拼出“yes”但拼不出任何声母韵母组合码表自然匹配不到。这个不是 bug是精简词库的局限。工程里的码表只覆盖了常用 400 多个拼音生僻拼音比如“zhuang”“chuang”这种三字母声母加后鼻韵母的长拼音在初期版本里根本没录入。解决检查 t9_table 数组里是不是漏了带“zh”“ch”“sh”的条目。这些翘舌音声母在九宫格上的编码是 z9, h4所以 zhuang 对应 948264——这个编码在不同精简版里有差异你得用 codetable.c 里的实际数据为准。另外注意输入顺序T9 匹配是严格的数字序列匹配如果用户先按了空格再按数字主循环会用空格清空输入串而不会当作“下一位”这也是一个需要跟用户明示交互规则的点。5.3 LCD 第一行正常第二行花屏现象LCD1602 上电后第一行显示“T9 Input Ready”没问题进入输入状态后第二行出现随机色块和残影。原因1602 是逐字符寻址的第二行首地址是 0xC0。如果写第二行前没调用 LCD_SetCursor(1, 0)光标还停在第一行的某个位置数据就会覆盖第一行后半段造成看起来像花屏。另一个常见原因是写第二行时发命令字节和发数据字节的间隔太短1602 内部处理命令需要 40us 以上。解决每次切行前先调 LCD_SetCursor(1, 0)确保光标定位到第二行首写完一行后用 0x01 清屏命令延时 2ms 再写下一笔。这个工程里 LCD 驱动已经封装好了行号参数只要你调用时传对行号就不会踩这个坑——但我见过不少人在 main.c 里直接往 P0 口塞数据绕过驱动结果就是花屏。绕驱动一时爽排障两小时。5.4 编译报错 DATA Segment Too Large现象码表加到 500 条以后Keil 报错 “DATA SEGMENT TOO LARGE”链接失败。原因你在 t9_table 数组前没加 code 关键字所有条目被放进了内部 RAM。C51 的 RAM 一共 256 字节其中高 128 字节还得看芯片是否支持每条 T9_CodeItem 数据结构占 77216 字节30 条就把 RAM 塞满了。这个错误是结构体设计最容易犯的——看到数组就默认放 RAM忘了 8 位机 RAM 的金贵程度。解决确认结构体变量前缀是 code 而非 data可以把鼠标悬停在数组名上Keil 的状态栏会显示它的存储区归属。如果已经加了 code 还报错检查是不是函数里定义了大型局部变量数组——局部数组默认放 data 段改成 static 或者直接放到 code 段就能解决。这种问题的排查思路就是先看存储区再看变量类型。5.5 输入反应快但 LCD 刷新有明显闪动现象按键按下后候选字能出来但 LCD 每次刷新都闪一下数字输入时闪的频率更高。原因LCD1602 的清屏命令和重写整行内容用了两次完整周期。清屏要 1.64ms写完 16 个字符要几百微秒交替进行时人眼能明显捕捉到“先灭再亮”的过程。这不是 T9 逻辑的问题是刷新策略的问题。解决改成“局部刷新”不要整行清屏。只更新数字串的最后一个字符位置以及候选字那一行中变化的下标区间。原理是 LCD 的显示 RAM 是按地址写的写单个字符不影响其他位置。这个工程里已经预留了 LCD_WriteChar 接口你需要做的是在 main.c 的显示刷新分支里改成按需写而不是每轮循环都调 ClearScreen。6. 二次开发词库扩充、联想输入与高阶改造思路6.1 从固定码表到自学习词库这份工程用的码表是纯 code 段静态数组编译时定死运行期不可改。它的优点是稳健缺点是词频没法适应个人使用习惯。第一次上手的人大概率会想能不能把自己经常打的字排到前面可以但有前提。STC89C52 没有外部存储器接口想在运行期改码表只能外挂 24C02 这类 I2C 串行 EEPROM把词频表存在 EEPROM 里。每次查表匹配时先按静态码表查出候选字再根据 EEPROM 里的词频做冒泡排序高频字排前。这样改动量不大加一个 I2C 驱动文件改 T9_Search 的排序部分EEPROM 按拼音编码做扇区索引。核心点是尽量少的 EEPROM 随机写次数——词频 1 的操作可以在 RAM 里缓存主循环空闲时再批量回写否则频繁写 I2C 会拖慢按键响应。6.2 从单字输入扩展到双字词组单字输入的逻辑已经完整但输入“中国”要打 zhong 和 guo 两个拼音、做两次选择和确认效率偏低。扩展词组思路不复杂在码表结构体里加一个“词组关联”字段指向同表中属于同一个双字词的另一个条目或者干脆建一张独立的词组表每条记录存“词组数字编码 第一字内码 第二字内码”。匹配时先走单字匹配在候选页按一下确定键后自动进入“预测下一字”模式——根据当前选中的字去词组表里找所有以它为首字的搭配比如选了“中”后面显示“国”“文”“间”。这个场景改动集中在 t9_match.c 里增加一次词组表扫描LCD 分页逻辑可以完全复用。我建议先做词组表再做联想因为词组的匹配范围是确定的不会出现像联想那样候选字爆炸的情况。6.3 改进按键布局上排数字键兼作选择键矩阵键盘只有 16 个键九宫格占掉 9 个上下翻页占 2 个确认和删除占 2 个剩余空间很紧张。一个直接可用的改造是把数字键 1 到 8 在候选模式下变成“直接选字键”候选字第 N 个正好对应数字键 N按 1 直接选第一个候选字按 2 选第二个。实现起来只需要在状态机里增加一个分支当前处于候选选择态时按键值 2~9 不再追加到数字串而是当作选择索引。这个改动在 main.c 的按键分发函数里加一个 if 判断就能完成但要注意和“数字输入态”的切换时机——进入候选选择态之前要先把输入数字串快照保存否则返回修改拼音时会丢失输入内容。6.4 显示层替换从 LCD1602 换到 OLED如果你手头有 SSD1306 驱动的 I2C OLED显示层可以整个换掉。OLED 的优势不只是省电128×64 的分辨率能在一屏里显示 4 行候选字每行 4 个翻页次数直接减半。替换路径很清晰保留 lcd1602.h 的接口名LCD_Init、LCD_SetCursor、LCD_WriteChar、LCD_Clear新建一个 ssd1306_i2c.c 实现同名函数核心是把 8 位并口时序换成 I2C 时序。SSD1306 是页寻址模式写字符时需要先把 GB2312 码转成点阵字模用取模软件生成 16×16 点阵数组然后按页写入显存。这个工作量的重头在字模转换逻辑层几乎不用动。做完这步你会发现这个工程的架构价值真正体现出来了——输入法逻辑和显示驱动完全解耦换屏只动一个文件。从那以后我每次拿到一个 C51 工程都会先画一张“数据流图”按键值怎么流转、查表结果的存储位置在哪里、显示刷新由谁触发。把这三条线理清楚再大的工程也能拆成几个独立模块逐个击破。这个项目虽然只是几十个文件的量级但麻雀虽小状态机的边界划分、RAM 与 Flash 的空间权衡、查询算法的复杂度取舍这些点都覆盖到了。希望帮到你期待看到你在这个基础上跑出带联想、带词频甚至带手写识别的版本。本文还有配套的精品资源点击获取