简介:这份资源面向单片机初学者与嵌入式爱好者,聚焦51单片机汇编语言实现矩阵键盘扫描这一经典入门项目,帮助读者理解行线列线交叉检测按键的硬件原理与汇编编程方法。压缩包内共1个doc文件,约283KB,完整收录了从变量定义、主函数、键盘扫描子程序到数码管显示、移位操作、延时子程序及共阴极段码表的源代码与说明,代码结构清晰,便于对照学习。目前已有569人学习下载,适合作为汇编语言与单片机输入输出操作的实践参考。通过这份资料,读者可以掌握矩阵键盘扫描的完整实现流程,理解键值计算、去抖动处理、数码管动态显示与移位控制等关键环节,并借助源码注释快速上手调试,为后续嵌入式开发打下底层硬件操作基础。
1. 矩阵键盘扫描为什么在汇编里反而更稳
很多人第一次接触矩阵键盘,是在某款入门开发板上:4 行 4 列,8 根 IO 口,能读出 16 个按键。用 C 语言写,一个双重循环加 10 毫秒消抖就完事。可一旦换成汇编,尤其是资源只有 1K 到 4K 字节的单片机,事情就变得微妙起来——代码空间要抠着用,寄存器要反复复用,扫描周期还得卡准,否则按键要么连击、要么迟钝。
我真正被矩阵键盘“教育”是在一个模拟项目里:某工业面板需要 12 个按键,主控是一颗 8 位单片机,程序空间只剩不到 600 字节。C 编译器生成的扫描代码体积超标,中断响应也偶尔超时。最后用汇编写了一版行列扫描,代码压到 200 字节出头,扫描周期稳定在 2 毫秒以内。从那以后我认定:矩阵键盘扫描这件事,汇编不是炫技,而是在小资源场景下把确定性握在自己手里。
这篇文章面向的是已经能看懂单片机数据手册、知道 IO 口方向寄存器怎么配的从业者。如果你正在用汇编做按键扫描,或者被 C 代码的体积和时序逼到墙角,下面的内容可以直接抄作业。我会从行列扫描原理讲起,一步步给出可复现的汇编代码框架、参数设置和排错方法,最后落到一个能稳定跑的完整扫描流程。
2. 行列扫描的电气本质与汇编实现选型
2.1 为什么是“行输出、列输入”而不是反过来
矩阵键盘的物理结构就是行线和列线的交叉点放按键。扫描的本质是:让行线逐行输出低电平,其余行保持高电平,然后读列线。如果某列读到低电平,说明该列与当前低电平行交叉处的按键被按下。
反过来行输入、列输出行不行?电气上可以,但工程上不划算。原因有两个:第一,行线作为输出时,同一时刻只有一行被拉低,驱动电流集中在一根 IO 上,对端口的灌电流能力要求明确;第二,列线作为输入时,通常要开内部上拉,读到的低电平直接对应按键,逻辑清晰。如果反过来,列线输出低电平,行线输入,那么同一时刻多行可能同时被按下,读回来的行状态会互相干扰,消抖和键值映射都更麻烦。
常见做法是行线接单片机输出口,列线接输入口并开启内部上拉。以 4×4 键盘为例,8 根 IO 分两组:低 4 位做行输出,高 4 位做列输入。这个分配不是固定的,但一旦确定,键值表就要跟着走。
2.2 汇编里做扫描的三种结构:轮询、定时中断、状态机
在汇编里实现扫描,结构选择直接决定代码复杂度和实时性。
轮询方式最简单:主循环里调用扫描子程序,扫完一轮延时消抖,再扫下一轮。优点是代码直白,缺点是主循环被拉长,如果还有其他任务,响应会变慢。适合功能单一的小项目。
定时中断方式把扫描放在定时器中断里,比如每 2 毫秒进一次中断,扫一行,4 行轮完是 8 毫秒,再配合状态计数做消抖。优点是扫描周期固定,主循环不受影响;缺点是中断服务程序要保存和恢复现场,寄存器占用要精打细算。
状态机方式是把按键过程拆成“空闲、消抖、按下、连发、释放”几个状态,每个状态只做一件事。代码量比前两种大,但逻辑最清晰,适合按键数量多、需要长按连发的场景。
我一般会这样选:按键少于 8 个、主循环不忙,用轮询;按键多、有显示刷新或通信任务,用定时中断;需要长按和双击,上状态机。下面给出的代码框架以定时中断扫描为主,因为它最能体现汇编对时序的控制力,也最容易暴露资源冲突的坑。
2.3 扫描周期与消抖时间的参数计算
消抖时间不是随便定的。机械按键的抖动时间通常在 5 到 20 毫秒之间,所以软件消抖的采样间隔要大于抖动时间。常见做法是每 10 毫秒采样一次,连续两次读到相同键值才确认。
如果放在定时中断里,扫描一行的时间 = 中断周期 × 行数。比如中断周期 2 毫秒,4 行扫完是 8 毫秒。要凑够 10 毫秒消抖,可以每扫完一轮计数一次,计到 2 次就是 16 毫秒,计到 1 次是 8 毫秒。我通常取 2 次,牺牲一点响应速度换稳定。
这里有个容易翻车的地方:中断周期不能太短。如果设成 100 微秒,4 行扫完 400 微秒,中断频率太高,主程序几乎跑不动。一般 1 到 5 毫秒是合理区间。具体值要看单片机主频和中断服务程序的指令数。假设主频 8MHz,一条指令 1 微秒,中断服务程序 50 条指令,那中断本身就要 50 微秒,周期设 1 毫秒时 CPU 占用 5%,可以接受。
2.4 键值映射表在汇编里的存放方式
键值映射就是把“第几行第几列”翻译成具体的键码。在汇编里,最省空间的做法是用查表指令,比如MOVC A, @A+DPTR。把 16 个键码按行优先顺序放在程序存储器里,扫描到行列后算出偏移量,一次查表就得到键值。
偏移量的算法:行号 × 4 + 列号。行号和列号都是从 0 开始。比如第 2 行第 3 列,偏移量是 2×4+3=11,查表第 11 个字节就是键码。
如果键盘不是 4×4,而是 4×3 或 3×4,表的大小和偏移算法要跟着改。常见错误是行列顺序搞反,导致键值错位。我的习惯是:在纸上画出键盘布局,标好行列编号,再按同样顺序写表,最后用万用表逐个按键验证。
3. 用汇编写一个可复现的 4×4 扫描程序
3.1 端口定义与寄存器分配
先确定硬件连接。假设使用某款 8 位单片机,P1 口低 4 位接行线,高 4 位接列线。行线输出低电平有效,列线输入带上拉。定义如下:
; 端口定义 PORT_KEY EQU P1 ; 键盘连接口 ROW_MASK EQU 0x0F ; 低4位为行 COL_MASK EQU 0xF0 ; 高4位为列 ; 寄存器分配 ROW_IDX EQU R0 ; 当前扫描行号 0-3 COL_VAL EQU R1 ; 读回的列值 KEY_CODE EQU R2 ; 最终键值 TMP_REG EQU R3 ; 临时寄存器 SCAN_CNT EQU R4 ; 消抖计数 LAST_KEY EQU R5 ; 上一次键值这段定义里,ROW_MASK和COL_MASK用来分离行和列。ROW_IDX在扫描过程中从 0 到 3 循环,COL_VAL保存读回的列状态,KEY_CODE是查表后的结果。SCAN_CNT和LAST_KEY用于消抖比较。
寄存器分配的原则是:中断服务程序里用到的寄存器,要么在主程序里保护,要么用专用的寄存器组。如果单片机支持多寄存器组,把中断用的寄存器切到另一组,省去压栈弹栈的时间。这一点在 2 毫秒中断周期下很关键。
3.2 行扫描与列读取的核心代码
下面是扫描一行的核心代码。思路是:先把当前行对应的位拉低,其余行拉高,然后读列线,判断是否有低电平。
SCAN_ONE_ROW: ; 输入:ROW_IDX 当前行号 0-3 ; 输出:COL_VAL 列状态,低4位有效,0表示有按键 MOV A, ROW_IDX MOV DPTR, #ROW_TABLE ; 行输出表 MOVC A, @A+DPTR ; 取行输出值 MOV PORT_KEY, A ; 输出到端口 NOP ; 等待电平稳定 NOP MOV A, PORT_KEY ; 读回端口 ANL A, #COL_MASK ; 只保留列线 SWAP A ; 高4位移到低4位 MOV COL_VAL, A ; 保存列状态 RET ROW_TABLE: DB 0xFE, 0xFD, 0xFB, 0xF7 ; 第0行拉低,第1行拉低...ROW_TABLE里存放的是行输出值。0xFE表示最低位为 0,其余为 1,对应第 0 行拉低。0xFD对应第 1 行,依此类推。MOVC A, @A+DPTR用ROW_IDX做偏移,取出对应的行输出值。
读回端口后,用ANL A, #COL_MASK屏蔽掉行线,只留列线。SWAP A把高 4 位移到低 4 位,方便后续判断。如果某列被按下,对应的位会是 0。比如读回0xE0,交换后是0x0E,低 4 位是1110,说明第 0 列被按下。
这里有两个NOP,是为了等待端口电平稳定。如果单片机主频较高,或者端口驱动能力弱,可以适当增加 NOP 数量。但也不能太多,否则扫描周期被拉长。我一般会在示波器上看行线波形,确认电平建立时间后再定。
3.3 消抖计数与键值查表
扫描到列状态后,不能立刻认定按键有效,要经过消抖。消抖的逻辑是:如果本次读到的列状态与上一次相同,计数器加一;如果不同,计数器清零,并更新上一次状态。当计数器达到阈值,才确认按键。
DEBOUNCE: ; 输入:COL_VAL 当前列状态 ; 输出:KEY_CODE 有效键值,0xFF表示无键 MOV A, COL_VAL JZ NO_KEY ; 列状态为0,说明无按键 CJNE A, LAST_KEY, NEW_KEY INC SCAN_CNT MOV A, SCAN_CNT CJNE A, #2, NO_KEY ; 连续2次相同才确认 ; 确认按键,查表 MOV A, ROW_IDX RL A ; 行号×2 RL A ; 行号×4 ADD A, COL_VAL ; 加上列号 MOV DPTR, #KEY_TABLE MOVC A, @A+DPTR MOV KEY_CODE, A RET NEW_KEY: MOV LAST_KEY, COL_VAL MOV SCAN_CNT, #0 NO_KEY: MOV KEY_CODE, #0xFF RET KEY_TABLE: DB '1','2','3','A' DB '4','5','6','B' DB '7','8','9','C' DB '*','0','#','D'CJNE A, LAST_KEY, NEW_KEY比较当前列状态和上一次状态。如果不同,跳到NEW_KEY更新状态并清零计数。如果相同,计数器加一。CJNE A, #2, NO_KEY判断是否达到 2 次。达到后,用行号和列号算偏移,查KEY_TABLE得到键码。
KEY_TABLE按行优先排列,和键盘布局一一对应。这里用的是 ASCII 码,实际项目里可以换成自定义键码。注意COL_VAL是交换后的低 4 位,某位为 0 表示按下。但查表时用的是列号,不是列状态。所以COL_VAL需要转换成列号:如果COL_VAL是1110,列号是 0;1101列号是 1;1011列号是 2;0111列号是 3。这个转换可以用一个小的查表或者位扫描实现。上面的代码为了简洁,假设COL_VAL已经转换成列号,实际使用时需要加一段转换。
3.4 主循环与中断服务程序的配合
如果扫描放在定时中断里,主循环只负责处理键值。中断服务程序每 2 毫秒执行一次,每次扫一行,4 次扫完一轮。主循环检查KEY_CODE,如果有有效键值就执行对应动作。
TIMER_ISR: PUSH ACC PUSH PSW ; 重装定时器 MOV TH0, #HIGH(65536-2000) MOV TL0, #LOW(65536-2000) ; 扫描一行 LCALL SCAN_ONE_ROW LCALL DEBOUNCE ; 行号递增 INC ROW_IDX CJNE ROW_IDX, #4, ISR_END MOV ROW_IDX, #0 ISR_END: POP PSW POP ACC RETI中断服务程序里先保存 ACC 和 PSW,因为扫描和消抖会用到累加器和标志位。定时器重装值根据主频计算,假设 12MHz 晶振,机器周期 1 微秒,2 毫秒需要 2000 个机器周期,所以重装值是 65536-2000。
ROW_IDX在中断里递增,从 0 到 3 循环。每进一次中断扫一行,4 次中断扫完一轮。消抖计数在DEBOUNCE里处理,连续两次读到相同列状态才确认。这样一轮扫描 8 毫秒,两次确认 16 毫秒,满足消抖要求。
主循环里读KEY_CODE,如果非 0xFF 就处理。处理完后要把KEY_CODE清回 0xFF,避免重复执行。如果按键需要释放检测,还要在中断里判断列状态是否恢复为全 1。
4. 扫描程序里最容易翻车的五个地方
4.1 现象:按键反应迟钝,要按很久才识别
原因:消抖计数阈值设得太大,或者扫描周期太长。比如中断周期 10 毫秒,4 行扫完 40 毫秒,再计 2 次就是 80 毫秒,人感觉明显延迟。
解决:把中断周期降到 2 到 5 毫秒,消抖计数保持 2 次。这样一轮扫描 8 到 20 毫秒,确认时间 16 到 40 毫秒,手感正常。如果还慢,检查是不是在中断里做了太多事情,比如查表、运算、串口发送,把中断服务程序精简到只做扫描和计数。
4.2 现象:多个按键同时按下时,键值乱跳
原因:行列扫描时,如果两个按键在同一列不同行,或者同一行不同列,读回的列状态可能同时有多位为 0。代码里如果只取最低位或者不做多键判断,就会随机返回其中一个键值。
解决:在DEBOUNCE里判断COL_VAL是否只有一位为 0。如果有多位为 0,说明多键按下,可以忽略或者返回一个特殊键值。判断方法是:COL_VAL & (COL_VAL-1),如果结果非零,说明有多位为 1(注意列状态是低有效,取反后判断)。实际汇编里可以用循环移位统计 0 的个数。
4.3 现象:按键松开后,键值一直不消失
原因:消抖逻辑只处理了按下,没处理释放。LAST_KEY和SCAN_CNT在松开后没有及时清零,导致KEY_CODE保持上一次的值。
解决:在DEBOUNCE里增加释放判断。如果COL_VAL为 0(无按键),把LAST_KEY和SCAN_CNT清零,KEY_CODE设为 0xFF。这样松开后键值立即失效。如果主循环处理键值后没有清KEY_CODE,也会导致重复执行,记得在处理完后手动清零。
4.4 现象:中断里读端口,偶尔读到错误值
原因:端口电平在切换行线后没有稳定,读回的是过渡态。或者中断优先级被其他中断打断,导致读端口时行线状态已经变了。
解决:在输出行线后加 2 到 4 个 NOP,等电平稳定再读。如果还有其他中断,把键盘扫描中断设为高优先级,或者在读端口前关中断,读完再开。关中断的时间要短,几个指令周期内完成,不影响其他中断响应。
4.5 现象:代码烧进去后,按键完全没反应
原因:端口方向寄存器没配置。行线要设为输出,列线要设为输入并开上拉。很多单片机复位后端口默认是输入,行线没有输出低电平,列线读回全是高,自然读不到按键。
解决:在初始化代码里明确设置端口方向。比如行线对应的位设为 0(输出),列线对应的位设为 1(输入)。如果单片机有上拉寄存器,把列线对应的位上拉使能。初始化完成后,先读一次端口确认列线为高,再启动定时中断。
5. 从能跑到好用:扫描程序的验证与优化技巧
5.1 用 IO 口翻转做扫描周期的“示波器”
没有示波器的时候,可以用一个空闲 IO 口做指示。在中断服务程序开头把 IO 拉高,结尾拉低,然后用万用表测电压,或者接一个 LED 看亮度。如果 LED 明显闪烁,说明中断周期太长;如果亮度稳定,说明周期合适。更精确的做法是用逻辑分析仪抓这个 IO 的波形,直接读出中断周期和扫描一轮的时间。
我习惯在扫描程序里留一个调试 IO,正式发布时再关掉。这个 IO 翻转的代码只有两条指令,对时序影响可以忽略,但排查问题时能省很多时间。
5.2 键值查表法的边界检查
查表之前一定要确认偏移量在表范围内。行号 0 到 3,列号 0 到 3,偏移量 0 到 15。如果列状态转换出错,列号可能变成 4 到 7,查表就会越界,读到程序存储器里的其他数据,表现为随机键值。
解决办法是在查表前加边界判断:如果行号大于 3 或列号大于 3,直接返回 0xFF。汇编里可以用CJNE比较,也可以用掩码操作。比如列号只保留低 2 位,ANL A, #0x03,这样即使出错也不会越界。行号在中断里递增,本身不会超过 3,但保险起见还是加一道判断。
5.3 长按连发与双击的实现思路
长按连发需要记录按键按下的持续时间。可以在消抖确认后,启动一个计数器,每 100 毫秒加一。当计数器达到阈值(比如 5,即 500 毫秒),触发连发,之后每 100 毫秒重复一次键值。双击则需要记录两次按下的时间间隔,如果小于 300 毫秒,判定为双击。
在汇编里实现这些,状态机会更清晰。定义几个状态:空闲、消抖、按下、连发、等待释放。每个状态只做一件事,用CJNE跳转。代码量会增加,但逻辑不会乱。如果资源实在紧张,可以只做长按,双击交给上位机处理。
5.4 用表格对比不同扫描周期的实际手感
下面这张表是我在 12MHz 单片机上实测的扫描周期与手感关系。测试条件:4×4 键盘,消抖计数 2 次,主循环空转。
| 中断周期 | 一轮扫描 | 确认时间 | 手感评价 |
|---|---|---|---|
| 1 ms | 4 ms | 8 ms | 反应快,偶有连击 |
| 2 ms | 8 ms | 16 ms | 稳定,推荐 |
| 5 ms | 20 ms | 40 ms | 稍慢,可接受 |
| 10 ms | 40 ms | 80 ms | 明显延迟 |
从表里可以看出,2 毫秒是一个平衡点。1 毫秒虽然更快,但中断频率高,CPU 占用大,而且机械按键的抖动还没结束就采样,容易误判。5 毫秒以上手感变差,除非主频很低或者中断服务程序很长,否则不建议。
5.5 一个我常犯的错误:忘了在中断里保护寄存器
早期写汇编时,我在中断服务程序里直接用了 R0 到 R7,没有保存。主程序里也在用这些寄存器,结果中断返回后主程序的数据被改得面目全非,表现是按键偶尔失灵、显示乱码。后来养成习惯:中断里用到的寄存器,要么用另一组寄存器,要么在入口 PUSH、出口 POP。多花几条指令,换稳定。
还有一个教训:中断服务程序里不要调用太深的子程序。LCALL 会压栈,如果中断频繁,栈空间可能溢出。我一般把扫描和消抖写成宏或者内联代码,减少调用层次。如果必须调用,确保栈深度不超过预留空间。
希望帮到你。
本文还有配套的精品资源,点击获取