stm32/input/keypad4x4-keyboard.md
矩阵键盘(4×4,行列扫描)
矩阵键盘(4×4,行列扫描)
4×4 阵列键盘用 8 根 IO 接 16 个按键(
2n根口线接n²个键),软件本质 = 扫描 + 去抖 + 映射表。本文含轮询/中断两种扫描写法、键位映射实测反推法,以及把它做成计算器的状态机设计(洋桃1号 2026-09-19 实测)。
#一、元信息
| 字段 | 值 |
|---|---|
| 芯片/平台 | STM32F103C8T6(洋桃1号,官方例程 demo35 轮询版 / demo36 中断版) |
| 接口类型 | GPIO 矩阵(4 行输出 + 4 列上拉输入) |
| 验证状态 | ✅ 已验证 |
| 验证环境 | 洋桃1号 / Keil MDK(AC5)/ 2026-09-19:两种扫描 + 计算器应用(四则/清除/续算)实测通过 |
| 驱动方式 | 行列扫描(两次反向扫描相或)+ EXTI 中断(中断版) |
#二、接线表
| 键盘 | MCU 引脚 | 方向 | 备注 |
|---|---|---|---|
| 行 1~4 | PA0 / PA1 / PA2 / PA3 | 输出(扫描时拉低) | ⚠️ PA0/PA1 与板载 KEY1/KEY2 微动开关、TTP224 触摸键 A/B 共用 |
| 列 1~4 | PA4 / PA5 / PA6 / PA7 | 上拉输入 | 接独立键盘模块时行列不能接反(接反按键有反应但键号乱) |
#三、扫描原理(轮询版)
官方 KEYPAD4x4_Read() 的核心流程:
- 行拉低、列读:4 行全部拉低,按键把某行某列连通,被拉低的列说明交叉处被按下;
- 20ms 去抖:机械按键抖动约 5~20ms;
- 两次反向扫描相或:先"行输出列输入"读一次,再
Init2反转(列输出行输入)读一次,两次读数a|b相或——即使接线有交叉或杂散电流也能稳定得到唯一键值,代价是读两次; - 查表得键号:
switch(编码)把0x77~0xEE的总线码翻译成键号 1~16,0 = 无键; - 等松手(阻塞):
while(任一行为低)+ 20ms——意味着快速连按会漏键(该实现方式的固有特性)。
cu8 KEYPAD4x4_Read(void){
u8 a = 0, b = 0;
KEYPAD4x4_Init(); /* 行=输出,列=上拉输入 */
GPIO_ResetBits(PORT, KEY1|KEY2|KEY3|KEY4); /* 4 行拉低 */
if(任一列为低){ delay_ms(20); if(仍为低) a = GPIO_ReadInputData(PORT) & 0xff; }
KEYPAD4x4_Init2(); /* 反转:列=输出,行=输入 */
GPIO_ResetBits(PORT, KEYa|KEYb|KEYc|KEYd); /* 4 列拉低 */
delay_ms(10);
b = GPIO_ReadInputData(PORT) & 0xff;
a = a | b; /* 两次读数相或 */
switch(a){ case 0xee: b=16; break; ... case 0x77: b=1; break; default: b=0; }
while(任一行为低); /* 等松手(阻塞) */
delay_ms(20);
return b;
}
#四、键位映射(实测反推,勿猜)
矩阵键盘的行列接法由 PCB 走线决定,键号不是自然顺序。实测 4 个点就能解出来:设键号 = a×行 + b×列 + c,代入 (行1,列4)→1、(行1,列2)→9、(行4,列4)→4、(行4,列1)→16,解得 键号 = (4-列)×4 + 行(列优先、列序反)。完整对照:
| 物理键 | 键号 | 物理键 | 键号 | 物理键 | 键号 | 物理键 | 键号 |
|---|---|---|---|---|---|---|---|
| * | 13 | 7 | 09 | 4 | 05 | 1 | 01 |
| 0 | 14 | 8 | 10 | 5 | 06 | 2 | 02 |
| # | 15 | 9 | 11 | 6 | 07 | 3 | 03 |
| D | 16 | C | 12 | B | 08 | A | 04 |
经验:不要假设键号顺序,实测几个点反推,或直接打印原始
0x77~0xEE编码表。
#五、应用:计算器状态机(2026-09-19 实测通过)
按键功能:0~9 数字、A/B/C/* = + − ÷ ×、# 清除"正在输入的这个数"(只删尾部连续数字,操作数和运算符保留)、D 求值。屏幕两行实时刷新:第 4 行表达式(超 16 字符左截断 + 居中)、第 6 行结果。
cstatic const char KEYMAP[17] = {
0,
'1','2','3','+', /* 1..4 : 1 2 3 A(+) */
'4','5','6','-', /* 5..8 : 4 5 6 B(-) */
'7','8','9','/', /* 9..12 : 7 8 9 C(/) */
'*','0','c','=' /* 13..16 : * 0 #(clear) D(=) */
};
while(1){
s = KEYPAD4x4_Read(); /* 0=无键,1..16=键号 */
if(s == 0 || s > 16) continue;
c = KEYMAP[s];
if(c >= '0' && c <= '9'){ /* ① 数字 */
if(hasResult){ len = 0; expr[0]=0; result[0]=0; hasResult=0; } /* 刚算完→新计算 */
if(len < EXPR_MAX){ expr[len++] = c; expr[len] = 0; }
}
else if(c == 'c'){ /* ② # 清当前输入的数 */
while(len > 0 && expr[len-1] >= '0' && expr[len-1] <= '9') len--;
expr[len] = 0;
}
else if(c == '='){ /* ③ D 求值 */
float v = eval_expr(expr, &ok);
if(ok) fmt_num(result, v); else strcpy(result, "Error");
hasResult = 1;
}
else{ /* ④ 运算符 */
if(len == 0 && result[0]){ strcpy(expr, result); len = strlen(expr); } /* 结果续算 */
if(len > 0 && !is_op(expr[len-1])){
if(count_op(expr) >= 1){ /* 已有 A op B → 先算中间结果(链式) */
float v = eval_expr(expr, &ok);
if(ok){ fmt_num(result, v); strcpy(expr, result); len = strlen(expr); }
}
if(len < EXPR_MAX - 1){ expr[len++] = c; expr[len] = 0; }
}else if(len > 0 && is_op(expr[len-1])){
expr[len-1] = c; /* 连按运算符 → 覆盖 */
}
hasResult = 0;
}
show_expr(); show_result(); /* 每次操作实时刷新 */
}
求值与格式化:eval_expr 找第一个运算符、解析左右操作数、除零返回 Error;fmt_num 能整除显整数、除不尽显 2 位小数。
实测用例(全部通过):
| 输入 | 预期 | 说明 |
|---|---|---|
556 * 789 D | =438684 | 基本乘法 |
556 * 787 # 789 D | =438684 | # 清当前数字后再输入 |
12 C 5 D | =2.4 | 除不尽 → 小数 |
5 C 0 D | Error | 除零保护 |
= 后再按 A 4 D | 结果续算 | 用上次结果继续 |
99999 * 99999 D | =9999800001 | 超 32 位整数不溢出(double) |
#六、中断版(demo36:EXTI + volatile)
与轮询版硬件相同、功能相同,唯一区别是触发方式:4 根列线(PA4~PA7)配成下降沿外部中断(按下拉低列线正好产生下降沿)。
c/* 中断服务函数只置标志,不做任何耗时的事 */
void EXTI4_IRQHandler(void){
if(EXTI_GetITStatus(EXTI_Line4) != RESET){
INT_MARK = 1; /* 只置标志,立刻返回 */
EXTI_ClearITPendingBit(EXTI_Line4);
}
}
/* 主循环干重活:平时完全空闲,有键才扫描+去抖+计算+显示 */
while(1){
if(INT_MARK == 0) continue;
INT_MARK = 0;
s = KEYPAD4x4_Read();
if(s != 0 && s <= 16){ /* 计算器逻辑 + 显示 */ }
}
⚠️ 中断和主循环共享的变量必须加 volatile:官方源码 u8 INT_MARK; 没加,编译器可能优化掉主循环对标志的重复读取。改造时已补 volatile u8 INT_MARK;。
| 维度 | 轮询版 | 中断版 |
|---|---|---|
| 检测方式 | 主循环不停调用扫描函数 | 列线下降沿触发中断 |
| 主循环占用 | 一直被占用(阻塞扫描 + 等松手) | 空闲,有键才处理 |
| 快速连按 | 容易漏键 | 响应及时、漏键少 |
| 代码复杂度 | 简单 | 要配中断线、优先级、volatile 标志 |
| 适用场景 | 功能单一、响应要求不高 | 多任务、要求实时响应 |
这就是嵌入式最经典的「中断置标志 + 主循环处理」结构:ISR 里绝不能放延时、扫描、显示这类耗时操作。
#七、踩坑清单
- 键号顺序不是自然顺序:行列接法由 PCB 决定,实测 4 点反推映射(见上),不要靠猜。
- 阻塞式扫描会漏键:
KEYPAD4x4_Read()内含"等松手",快速连按/长按期间的新按键被丢。要做长按连加需改非阻塞定时扫描 + 状态保持。 - 共用引脚干扰:PA0/PA1 同时挂板载 KEY1/KEY2 和 TTP224 触摸键 A/B;本板 TTP224 已故障(4 键全亮锁死),出现"乱键号/无响应"优先怀疑它,而不是键盘本身。
- 行列接反:按键仍有反应但键号全错——用实测反推法适配即可。
- 中断共享标志没加 volatile:编译器优化掉重复读取,主循环读不到中断的修改。
- float 精度不够:单精度 24 位尾数约 7 位有效数字,输入
49046869(8 位)存进去就被舍入、结果失真 → 计算器求值用double(53 位尾数约 15 位)。 - 解析器不认小数点:结果
3.33续算时表达式带.,逐字符解析a*10+(c-'0')把.当数字(ASCII 46−48 = −2)→ 算出 2832。修:解析函数区分整数/小数部分。 - UV4 是 GUI 程序:命令行编译必须
Start-Process -Wait,否则可能烧到旧 hex。 - pyocd 报 identical:用
--erase chip强制全片擦写。
#八、相关链接
- 触摸按键(TTP223 x4)——同功能域的单键输入方案,PA0~PA3 共用引脚的另一个主人
- 定时器与中断:1 秒心跳与五阶段状态机——「中断置标志 + 主循环处理」同款结构
- 温湿度传感器(DHT11,单总线)——同批实测的 OLED 显示模块
- 文章:STM32 低功耗三兄弟实测
#九、参考库/链接
- 洋桃1号官方例程:
demo00\35_KEYPAD4X4\(轮询版)、36_KEYPAD_INT\(中断版) - 4×4 薄膜键盘 datasheet(引脚定义各行列对应关系)
#十、更新日志
📝共 1 条记录
| 日期 | 类型 | 更新内容 |
|---|---|---|
| 2026-09-20 | 新增 | 首次建档:轮询/中断两种扫描 + 键位映射实测反推(键号=(4-列)×4+行)+ 计算器状态机(#/清除、结果续算、链式、除零保护)+ volatile/float/小数解析等 9 条踩坑 |