把 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 联系我们。


PWM Generator Module Motor Control Frequency Adjustable Control Board 1.8V - 15V Power Control
ESP8266 Motor Driver Expansion V2 Board L293D LUA ESP8266 Wifi Motor Driver Shield
MH Soil Moisture Sensor Module Hygrometer Detection Moisture Sensor for Arduino Plant Watering System
Arduino Compactable Pro Mini ATMEGA328P 5V/16M 
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)