4×4 键盘密码锁教程:Arduino Uno 接线、EEPROM 换码与错误锁定

Kartun keypad membran hitam dengan kekunci biru dan merah pada sebuah kotak, disambung ke Arduino Uno biru, modul relay merah dan paparan OLED biru

把 4×4 键盘的八条线接到 Arduino Uno 的 D2–D9,用 Keypad library 读出按键,再把输入的密码和存在 EEPROM 里的那一组比对——对上了,Uno 就让继电器模块吸合几秒钟。这个项目最关键的部分不是密码对的时候,而是密码连错三次之后发生什么。

为什么选键盘密码锁,而不是 RFID 或指纹?

4×4 薄膜键盘给你一样东西,是 RC522 RFID 门禁AS608 指纹锁给不了的:一种你可以在三十秒内更换分享的凭证,不必交出任何实物,也不必替谁重新录入。

凭证 能改吗? 能远程分享吗? 丢失或泄露时
键盘密码 能,几秒钟的事 能——说一声就行 改掉密码,完事
RFID 卡 能,但要发一张新卡 不能——卡得亲手交出去 删掉旧 UID,再发一张新卡
指纹 不能 不能 收不回来

对 RBT 专题来说还有一个额外的理由:键盘一次带出两个你在口试时守得住的概念——矩阵扫描和错误锁定(也可以看看我们的RBT 电子专题项目点子)。

Parts list

ItemPriceQty
Power Supply Adapter DC Universal AC to DC Converter PSU 5V2A 5V3A 9V2A 12V2A - P.S. ADAPTOR (12V2A)Power Supply Adapter DC Universal AC to DC Converter PSU 5V2A 5V3A 9V2A 12V2A - P.S. ADAPTOR (12V2A)PSA1202RM15.95

一把锁不该靠着一台笔记本电脑才能工作。12V 2A 适配器直接插进 Uno 的 barrel jack——12V 位于 Uno 建议输入 7–12V 的上限,所以板上稳压器摸起来会温温的,这是正常的。

4×4 矩阵键盘怎么用八条线读出十六个按键?

MEMKEYP 薄膜键盘的塑料面层底下有十六个开关,可是尾巴上的排线只引出八根导体——这正是它被叫作矩阵的原因。那些开关排成横向四行(row)和纵向四列(column),每一个都正好坐在某一行与某一列的交叉点上;按下一个键,做的事情只是把那一行的线和那一列的线接通。

Arduino 读它靠的是一种叫 scanning(扫描)的手法,这也正是 Keypad library 在幕后做的事。四个行引脚被设成 INPUT_PULLUP:只要没人按键,Uno 内部的上拉电阻就把每一行拉到 HIGH,所以某一行要掉到 LOW,唯一的可能就是有东西把它拽了下去。

接着 Uno 每次只把一列驱动到 LOW,其余三列留作高阻输入——电气上等同于没接,所以没有任何一行能透过它们被拉低。这时候如果某一行读到 LOW,这个 LOW 只有一条路能过来:那一行与当前被驱动的那一列交叉点上的开关。那就是被按下的键。四列轮一遍,十六个键每秒就被查过几十次。

算式很简单,力量却不小:4 + 4 = 8 条线覆盖 4 × 4 = 16 个键,而如果每个开关都单独走线,就要十六条信号线外加一条地线。这才是口试真正想听的答案——不是“我用了 Keypad library”,而是“把一列驱动到 LOW,跟着掉下去的那一行就是被按下的键”。

卡通概念图:四行横跨四列组成网格,一根手指按在其中一个交叉点上,八条线通向蓝色 Arduino Uno
四行乘四列等于十六个按键——可是键盘引出来的只有八条线。

想看这块键盘真的接起来、按键在 serial monitor 上跳出来的样子:

键盘、OLED 和继电器模块怎么接到 Uno?

MEMKEYP 的排线用的是 4×4 薄膜键盘的标准排列:从插座的一端数起,第 1–4 脚是四个行,第 5–8 脚是四个列。哪一端是第 1 脚,用万用表的通断(continuity)档确认——按住一个键不放,会响的那两个脚就是这个键的行和列。OLED 和 Uno 共用 A4、A5 这条 I2C 总线,继电器模块则要一个数字引脚。

连接 Uno 引脚 说明
键盘第 1 脚(第 1 行) D9 行——扫描时以 INPUT_PULLUP 读取
键盘第 2 脚(第 2 行) D8
键盘第 3 脚(第 3 行) D7
键盘第 4 脚(第 4 行) D6
键盘第 5 脚(第 1 列) D5 列——扫描时作为输出被驱动到 LOW
键盘第 6 脚(第 2 列) D4
键盘第 7 脚(第 3 列) D3
键盘第 8 脚(第 4 列) D2
OLED GND GND 这块模块的第一脚——认准丝印
OLED VCC 5V 第二脚,不是第一脚
OLED SCL A5 I2C 时钟
OLED SDA A4 I2C 数据
继电器 DC+ 5V 模块上的螺丝端子
继电器 DC− GND
继电器 IN1 D10 一个引脚控制一路

2WRELAY 模块的输入螺丝端子旁边有一组标着 Low – Com – High 的选择跳线(两路各有 S1 和 S2 一列)。跳线帽把 Com 和 Low 短接,就表示这块模块是低电平触发:Uno 的引脚给出 LOW,继电器才吸合。下面的 sketch 把这个选择写成了一个常量。

为什么要用继电器模块,而不是裸继电器?

2WRELAY 模块上那颗 Songle SRD-05VDC-SL-C 继电器的线圈是 70 欧姆,5V 加在 70 欧姆上,意味着它每次吸合都要流过大约 71 mASRD-05VDC-SL-C datasheet),而 Arduino Uno 的一个引脚设计上只给 20 mA。所以拿引脚直接驱动裸继电器,并不只是“不太安全”而已——它根本动不起来,而且那个引脚还有被烧坏的风险。

2WRELAY 模块在你的引脚和线圈之间放了光耦和驱动三极管:Uno 的引脚只负责点亮光耦里那颗 LED,送出几个毫安而已,线圈要的 71 mA 直接从 5V 线上取。裸继电器当然还有它的位置——留给那些本身就带驱动级的设计,可以看我们的继电器模块安全接线指南。本教程全程只走低压直流:继电器接通的那一侧是 12V 直流电磁锁或电插锁,而这颗继电器的触点额定值是 30V DC 10A。锁的电由 12V 电源经继电器触点直接供给——不是从 Uno 的 5V 引脚出,Uno 的稳压器本来就不是为电磁锁这种负载设计的。

MEMKEYP 的排线末端是一个 8 脚的母座,而 Uno 的排针座也是母的——所以公对公杜邦线放在主清单里,而不是可选清单;同样的公头也能压在继电器的螺丝端子底下拧紧。0.96″ OLED 模块则是带镀铜过孔的——把排针焊上去,或者干脆把线直接焊上去,因为靠摩擦接触的连接,一定会挑最麻烦的那一天断掉。

密码连错三次会发生什么?

四位密码意味着 10,000 种组合,这个数字听起来很大,直到你算一算它实际能撑多久。没有惩罚的话,每一次失败的尝试成本都是零:按每两秒试一次算,10,000 种组合不到六个小时就试完了。你这把锁的强度不是 10,000——是站在键盘前那个人的耐心。

锁定(lockout)把组合数换算成了时间。连错三次之后,下面的 sketch 会停止接收按键 30 秒,并在 OLED 上倒数;再错三次变成 60 秒,接着 120 秒,到这里就不再往上加,免得箱子的主人自己打错字就被关在外面好几个小时。惩罚计数器只有在输入一次正确密码之后才归零,所以一个人在键盘前能维持的速度是每两分钟三次尝试——平均 40 秒一次。原本一个下午就能穷尽的 10,000 空间,现在要四天多。

还有两个小习惯,而且都不花钱。默认密码用位而不是四位:一百万种组合,按 40 秒一次算就是一年多,因为密码长度和锁定时间是相乘的,不是相加的。另外,失败提示不告诉对方错在哪一位——半截反馈是白送给猜密码的人的礼物,出于同样的道理,OLED 上也只显示星号。

卡通图:黑色薄膜键盘旁边三个红色叉号,一把合上的挂锁挡在前面,蓝色 OLED 屏上显示沙漏
连错三次,键盘就停止接收按键 30 秒——接着是 60 秒,然后 120 秒。

完整的 Arduino sketch 长什么样?

这个 sketch 需要在 Arduino IDE 的 Library Manager 里装两样东西:Mark Stanley 与 Alexander Brevig 的 Keypadlibrary 参考文档),以及 Adafruit SSD1306 连同它的 Adafruit GFXAdafruit BusIO 依赖。UNOCH34 板用的是 CH340 USB 芯片,所以如果 COM 口没出现,先装 CH340 驱动。

密码存在 EEPROM 里,而不只是写死在 sketch 中——这正是本文开头那个承诺能兑现的原因:按 A,输入当前密码,再输入新密码,完成。这个密码在复位之后、甚至重新上传程序之后都还在,因为 Arduino 的 bootloader 只写 flash,完全不碰 EEPROM。

#include <Keypad.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <EEPROM.h>

#define SCREEN_WIDTH  128
#define SCREEN_HEIGHT 64
#define OLED_ADDR     0x3C

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);

const byte ROWS = 4;
const byte COLS = 4;
char keys[ROWS][COLS] = {
  {'1','2','3','A'},
  {'4','5','6','B'},
  {'7','8','9','C'},
  {'*','0','#','D'}
};
byte rowPins[ROWS] = {9, 8, 7, 6};   // 键盘排线第 1-4 脚
byte colPins[COLS] = {5, 4, 3, 2};   // 键盘排线第 5-8 脚
Keypad keypad = Keypad(makeKeymap(keys), rowPins, colPins, ROWS, COLS);

const byte    RELAY_PIN = 10;
const uint8_t RELAY_ON  = LOW;    // 跳线帽在 Com-Low;若是 Com-High 就改成 HIGH
const uint8_t RELAY_OFF = HIGH;

const byte CODE_LEN = 6;                          // 六位,不是四位
const char DEFAULT_CODE[CODE_LEN + 1] = "246810"; // 第一次上传后务必改掉这组密码
const byte EEPROM_MAGIC = 0xA5;
const byte MAX_TRIES = 3;
const unsigned long LOCKOUT_BASE_MS = 30000UL;    // 30 秒,接着 60 秒,然后 120 秒(上限)
const unsigned long UNLOCK_MS = 5000UL;           // 锁打开多久

enum Mode { MODE_ENTER, MODE_OLD, MODE_NEW };
Mode mode = MODE_ENTER;

char entry[CODE_LEN + 1];
char stored[CODE_LEN + 1];
byte filled = 0;
byte wrongTries = 0;
byte penalty = 0;
unsigned long lockoutUntil = 0;
unsigned long relayOffAt = 0;
unsigned int lastShown = 0;

void loadCode() {
  // 板子第一次上电时 EEPROM 还是空的:先写入默认密码
  if (EEPROM.read(0) != EEPROM_MAGIC) {
    for (byte i = 0; i < CODE_LEN; i++) EEPROM.update(1 + i, DEFAULT_CODE[i]);
    EEPROM.update(0, EEPROM_MAGIC);
  }
  for (byte i = 0; i < CODE_LEN; i++) stored[i] = EEPROM.read(1 + i);
  stored[CODE_LEN] = '\0';
}

void saveCode() {
  for (byte i = 0; i < CODE_LEN; i++) {
    stored[i] = entry[i];
    EEPROM.update(1 + i, entry[i]);   // update() 只写有变化的 byte - EEPROM 的写入寿命约 100,000 次
  }
  stored[CODE_LEN] = '\0';
}

bool matches() {
  for (byte i = 0; i < CODE_LEN; i++) {
    if (entry[i] != stored[i]) return false;
  }
  return true;
}

void resetEntry() {
  for (byte i = 0; i <= CODE_LEN; i++) entry[i] = '\0';
  filled = 0;
}

void drawPrompt() {
  display.clearDisplay();
  display.setTextSize(1);
  display.setCursor(0, 0);
  if (mode == MODE_ENTER)     display.print(F("Masukkan kod"));
  else if (mode == MODE_OLD)  display.print(F("Kod semasa"));
  else                        display.print(F("Kod baharu"));
  display.setTextSize(3);
  display.setCursor(0, 24);
  for (byte i = 0; i < filled; i++) display.print('*');   // 数字从不显示出来
  display.setTextSize(1);
  display.setCursor(0, 56);
  display.print(F("* padam   A tukar kod"));
  display.display();
}

void drawBanner(const __FlashStringHelper *top, const __FlashStringHelper *big) {
  display.clearDisplay();
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print(top);
  display.setTextSize(2);
  display.setCursor(0, 28);
  display.print(big);
  display.display();
}

void drawWrong(byte left) {
  display.clearDisplay();
  display.setTextSize(2);
  display.setCursor(0, 0);
  display.print(F("KOD SALAH"));
  display.setTextSize(1);
  display.setCursor(0, 34);
  display.print(F("Baki cubaan: "));
  display.print(left);          // 永远不告诉对方错在哪一位
  display.display();
}

void drawLockout(unsigned int secs) {
  display.clearDisplay();
  display.setTextSize(2);
  display.setCursor(0, 0);
  display.print(F("TERKUNCI"));
  display.setTextSize(3);
  display.setCursor(0, 28);
  display.print(secs);
  display.setTextSize(1);
  display.print(F(" s"));
  display.display();
}

void grantAccess() {
  wrongTries = 0;
  penalty = 0;                  // 惩罚计数器只有在密码正确后才归零
  resetEntry();
  digitalWrite(RELAY_PIN, RELAY_ON);
  relayOffAt = millis() + UNLOCK_MS;
  drawBanner(F("Akses"), F("DIBUKA"));
}

void wrongEntry() {
  resetEntry();
  wrongTries++;
  if (wrongTries >= MAX_TRIES) {
    if (penalty < 3) penalty++;
    lockoutUntil = millis() + (LOCKOUT_BASE_MS << (penalty - 1));
    lastShown = 0;
  } else {
    drawWrong(MAX_TRIES - wrongTries);
    delay(1200);                // 刻意加的一点延迟
    drawPrompt();
  }
}

void submit() {
  if (mode == MODE_ENTER) {
    if (matches()) grantAccess();
    else           wrongEntry();
  } else if (mode == MODE_OLD) {
    if (matches()) {
      mode = MODE_NEW;
      resetEntry();
      drawPrompt();
    } else {
      mode = MODE_ENTER;
      wrongEntry();             // 改密码失败也算进同一组尝试次数
    }
  } else {
    saveCode();
    mode = MODE_ENTER;
    resetEntry();
    drawBanner(F("Kod baharu"), F("DISIMPAN"));
    delay(1500);
    drawPrompt();
  }
}

void setup() {
  digitalWrite(RELAY_PIN, RELAY_OFF);   // 先把电平定好...
  pinMode(RELAY_PIN, OUTPUT);           // ...再设为输出,避免继电器抖一下

  Wire.begin();
  display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR);
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);

  loadCode();
  resetEntry();
  drawPrompt();
}

void loop() {
  unsigned long now = millis();

  // UNLOCK_MS 之后自动重新上锁
  if (relayOffAt != 0 && (long)(now - relayOffAt) >= 0) {
    digitalWrite(RELAY_PIN, RELAY_OFF);
    relayOffAt = 0;
    resetEntry();
    drawPrompt();
  }

  char key = keypad.getKey();   // 始终扫描,即使这个按键会被丢弃

  if (lockoutUntil != 0) {
    if ((long)(now - lockoutUntil) >= 0) {
      lockoutUntil = 0;
      wrongTries = 0;
      drawPrompt();
    } else {
      unsigned int left = (unsigned int)((lockoutUntil - now) / 1000UL) + 1;
      if (left != lastShown) {
        lastShown = left;
        drawLockout(left);
      }
      return;                   // 锁定期间所有按键一律忽略
    }
  }

  if (key == NO_KEY) return;

  if (key == '*') {             // 清除已经输入的内容
    resetEntry();
    drawPrompt();
    return;
  }

  if (key == '#') {             // 立即重新上锁
    digitalWrite(RELAY_PIN, RELAY_OFF);
    relayOffAt = 0;
    resetEntry();
    drawPrompt();
    return;
  }

  if (key == 'A' && mode == MODE_ENTER && filled == 0) {
    mode = MODE_OLD;            // 改密码:先输入当前密码
    resetEntry();
    drawPrompt();
    return;
  }

  if (key >= '0' && key <= '9') {
    if (filled < CODE_LEN) {
      entry[filled++] = key;
      drawPrompt();
    }
    if (filled == CODE_LEN) submit();
  }
}

第一次上传之后,先用默认密码开一次,然后按 A 把它换成你自己的密码。

这把键盘锁的安全边界在哪里?

这把键盘锁对自己很诚实。肩窥是第一个弱点:站在你身后的人看得见你的手指,而没有任何一行代码能补救这一点——用手挡住键盘,并且把它装在路过的人不容易看到的朝向。

第二是按键磨损。薄膜键盘上按得多的键会比旁边的亮:如果四位密码用了四个不同的键,磨损痕迹就把这组数字暴露了,剩下的只是顺序——24 种,不是 10,000 种。定期换密码,并且让你的密码至少重复一个数字。

第三,密码以明文形式待在 sketch 里。EEPROM 帮上了忙——交给老师的那份代码里的密码,已经不是能打开箱子的那一组——但任何能把编程器插上板子的人还是读得到。所以 Uno 这块板应该待在上锁的箱子里面

用途 合适吗? 原因
学校储物柜、桌子抽屉 合适 价值低、多人共用,密码每学期都可以换
元件柜、工具箱 合适 挡的是随手乱翻的人,不是认真的入侵者
RBT 专题与作品展示 合适 矩阵与锁定这两个概念在口试时守得住
住家大门 只能当作附加的一层 底下必须是仍有实体钥匙的机械锁

最后那一条是实务,不是理论:单片机会死机,电会断,而你还是得进得去。只要下面还有一把机械锁,最坏的结果也不过是麻烦一点。

卡通对比图:黑色薄膜键盘装在储物柜、抽屉和柜子上并打勾,对比住家大门配机械 deadbolt 与一个警告标志
储物柜、抽屉、柜子:可以。住家大门:只能加装在仍有实体钥匙的机械锁之上。

我们从真实客户身上看到的常见错误

最常送到我们这里的情况,是行和列的引脚顺序接反了。键盘并没有坏——它只是回传错误的字符,按 2 出来 4、按 3 出来 7,可是 1、5、9 和 D 却是对的,因为它们坐在对角线上。原因在于:矩阵本来就是对称的,开关只是把两根线接通,而哪边叫行、哪边叫列,来自你的 sketch,不是来自键盘本身。所以什么都没坏——把 rowPinscolPins 两组顺序对调就好。

第二是用数字引脚直接驱动裸继电器,然后判定这颗继电器是坏的。第三是 OLED 只插不焊:桌面测试时亮得好好的,箱子一挪就黑屏,然后一个小时耗在查 I2C 地址上,问题其实是机械性的。

第四,也是最安静的一个:默认密码从来没被改过。Sketch 跑得完美,专题也交上去了,而那个箱子的守护者,是一个印在全班共享文件里的数字。

FAQ

断电的时候,锁是开着还是保持锁上?

取决于继电器端子和锁的类型。常见的直流电磁锁属于 fail-secure(断电即上锁):它要通电才能打开,所以电一没就是锁着的——接 NO 或接 NC 都一样,因为锁那一路的 12V 电源也一起断了。把锁接在 NO(常开)端子上,停电期间箱子就保持锁着。确认你手上的锁确实是 fail-secure,因为像电磁吸盘那种 fail-safe 的锁,行为正好相反。

Uno 复位或者我重新上传 sketch,密码会丢吗?

不会。密码住在 ATmega328P 的 EEPROM 里,而 Arduino 上传只写 flash 存储器——EEPROM 完全不会被碰到。想把密码强制恢复成默认值,改一下 sketch 里 EEPROM_MAGIC 的值,再上传一次就行。

密码应该用几位?

六位是很好的平衡点:一百万种组合,又还记得住。四位只有一万。但光靠长度不够——一定要配上锁定,因为两者的效果是相乘的,不是相加的。

这个项目能跑在 ESP32 上吗?

能。键盘只需要八个普通 GPIO,所以 Keypad library 原样就能跑——把 rowPinscolPins 里的引脚号换掉,再把 OLED 移到 GPIO21(SDA)和 GPIO22(SCL)。有一点不同:ESP32 没有真正的 EEPROM,那边的 EEPROM library 是用 flash 模拟出来的,需要 EEPROM.begin()EEPROM.commit()

最后更新:2026年8月。有问题?WhatsApp 联系我们

Leave a Reply

Your email address will not be published. Required fields are marked *