stm32/input/keypad4x4-keyboard.md

矩阵键盘(4×4,行列扫描)

知识库 · STM32 📅 初版 2026-09-20

矩阵键盘(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~4PA0 / PA1 / PA2 / PA3输出(扫描时拉低)⚠️ PA0/PA1 与板载 KEY1/KEY2 微动开关、TTP224 触摸键 A/B 共用
列 1~4PA4 / PA5 / PA6 / PA7上拉输入接独立键盘模块时行列不能接反(接反按键有反应但键号乱)

#三、扫描原理(轮询版)

官方 KEYPAD4x4_Read() 的核心流程:

  1. 行拉低、列读:4 行全部拉低,按键把某行某列连通,被拉低的列说明交叉处被按下;
  2. 20ms 去抖:机械按键抖动约 5~20ms;
  3. 两次反向扫描相或:先"行输出列输入"读一次,再 Init2 反转(列输出行输入)读一次,两次读数 a|b 相或——即使接线有交叉或杂散电流也能稳定得到唯一键值,代价是读两次;
  4. 查表得键号:switch(编码) 把 0x77~0xEE 的总线码翻译成键号 1~16,0 = 无键;
  5. 等松手(阻塞):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 + 行(列优先、列序反)。完整对照:

物理键键号物理键键号物理键键号物理键键号
*13709405101
014810506202
#15911607303
D16C12B08A04

经验:不要假设键号顺序,实测几个点反推,或直接打印原始 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 DError除零保护
= 后再按 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 里绝不能放延时、扫描、显示这类耗时操作。

#七、踩坑清单

  1. 键号顺序不是自然顺序:行列接法由 PCB 决定,实测 4 点反推映射(见上),不要靠猜。
  2. 阻塞式扫描会漏键:KEYPAD4x4_Read() 内含"等松手",快速连按/长按期间的新按键被丢。要做长按连加需改非阻塞定时扫描 + 状态保持。
  3. 共用引脚干扰:PA0/PA1 同时挂板载 KEY1/KEY2 和 TTP224 触摸键 A/B;本板 TTP224 已故障(4 键全亮锁死),出现"乱键号/无响应"优先怀疑它,而不是键盘本身。
  4. 行列接反:按键仍有反应但键号全错——用实测反推法适配即可。
  5. 中断共享标志没加 volatile:编译器优化掉重复读取,主循环读不到中断的修改。
  6. float 精度不够:单精度 24 位尾数约 7 位有效数字,输入 49046869(8 位)存进去就被舍入、结果失真 → 计算器求值用 double(53 位尾数约 15 位)。
  7. 解析器不认小数点:结果 3.33 续算时表达式带 .,逐字符解析 a*10+(c-'0') 把 . 当数字(ASCII 46−48 = −2)→ 算出 2832。修:解析函数区分整数/小数部分。
  8. UV4 是 GUI 程序:命令行编译必须 Start-Process -Wait,否则可能烧到旧 hex。
  9. pyocd 报 identical:用 --erase chip 强制全片擦写。

#八、相关链接

#九、参考库/链接

  • 洋桃1号官方例程:demo00\35_KEYPAD4X4\(轮询版)、36_KEYPAD_INT\(中断版)
  • 4×4 薄膜键盘 datasheet(引脚定义各行列对应关系)

#十、更新日志

📝共 1 条记录
日期类型更新内容
2026-09-20新增首次建档:轮询/中断两种扫描 + 键位映射实测反推(键号=(4-列)×4+行)+ 计算器状态机(#/清除、结果续算、链式、除零保护)+ volatile/float/小数解析等 9 条踩坑