把 4×4 键盘的八条线接到 Arduino Uno 的 D2–D9,用 Keypad library 读出按键,再把输入的密码和存在 EEPROM 里的那一组比对——对上了,Uno 就让继电器模块吸合几秒钟。这个项目最关键的部分不是密码对的时候,而是密码连错三次之后发生什么。
为什么选键盘密码锁,而不是 RFID 或指纹?
4×4 薄膜键盘给你一样东西,是 RC522 RFID 门禁和 AS608 指纹锁给不了的:一种你可以在三十秒内更换并分享的凭证,不必交出任何实物,也不必替谁重新录入。
| 凭证 | 能改吗? | 能远程分享吗? | 丢失或泄露时 |
|---|---|---|---|
| 键盘密码 | 能,几秒钟的事 | 能——说一声就行 | 改掉密码,完事 |
| RFID 卡 | 能,但要发一张新卡 | 不能——卡得亲手交出去 | 删掉旧 UID,再发一张新卡 |
| 指纹 | 不能 | 不能 | 收不回来 |
对 RBT 专题来说还有一个额外的理由:键盘一次带出两个你在口试时守得住的概念——矩阵扫描和错误锁定(也可以看看我们的RBT 电子专题项目点子)。
零件清单
Parts list
| Item | Price | Qty | |
|---|---|---|---|
Power Supply Adapter DC Universal AC to DC Converter PSU 5V2A 5V3A 9V2A 12V2A - P.S. ADAPTOR (12V2A)PSA1202 | RM15.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,跟着掉下去的那一行就是被按下的键”。

想看这块键盘真的接起来、按键在 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 mA(SRD-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 上也只显示星号。

完整的 Arduino sketch 长什么样?
这个 sketch 需要在 Arduino IDE 的 Library Manager 里装两样东西:Mark Stanley 与 Alexander Brevig 的 Keypad(library 参考文档),以及 Adafruit SSD1306 连同它的 Adafruit GFX 和 Adafruit 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 专题与作品展示 | 合适 | 矩阵与锁定这两个概念在口试时守得住 |
| 住家大门 | 只能当作附加的一层 | 底下必须是仍有实体钥匙的机械锁 |
最后那一条是实务,不是理论:单片机会死机,电会断,而你还是得进得去。只要下面还有一把机械锁,最坏的结果也不过是麻烦一点。

我们从真实客户身上看到的常见错误
最常送到我们这里的情况,是行和列的引脚顺序接反了。键盘并没有坏——它只是回传错误的字符,按 2 出来 4、按 3 出来 7,可是 1、5、9 和 D 却是对的,因为它们坐在对角线上。原因在于:矩阵本来就是对称的,开关只是把两根线接通,而哪边叫行、哪边叫列,来自你的 sketch,不是来自键盘本身。所以什么都没坏——把 rowPins 和 colPins 两组顺序对调就好。
第二是用数字引脚直接驱动裸继电器,然后判定这颗继电器是坏的。第三是 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 原样就能跑——把 rowPins 和 colPins 里的引脚号换掉,再把 OLED 移到 GPIO21(SDA)和 GPIO22(SCL)。有一点不同:ESP32 没有真正的 EEPROM,那边的 EEPROM library 是用 flash 模拟出来的,需要 EEPROM.begin() 和 EEPROM.commit()。
最后更新:2026年8月。有问题?WhatsApp 联系我们。



Arduino Uno Compatible SMD UNO R3 with Type B Cable - ATMEGA328P with CH340G-Microcontroller Project
Matrix Array 16 Key Membrane Keypad 4 x 4 Keypad Ultra Slim Keyboard For Arduino Application
Relay Module 3.3V 5V 12V 1/2/4/8 Ways Optocoupler Trigger Relay Module 1 2 4 8 Channel Relay Module - 5V RELAY MODULE(2WAY)
0.96‘ OLED Display Module Blue White Screen I2C IIC Serial 128X64 LCD Display Board - 0.96' OLED WHITE
40pcs Dupont Wire 10cm 20cm 30cm for Breadboard DIY Experiment Jumper Wire Breadboard wire - DUPONT WIRE M-M 30CM
Power Supply Adapter DC Universal AC to DC Converter PSU 5V2A 5V3A 9V2A 12V2A - P.S. ADAPTOR (12V2A)