说一句话,继电器就动作。ZK-VR01 每识别一次都会同时报两遍 —— 一遍是串口焊盘 T 上发出的字节,另一遍是左边那一列八个命令焊盘中某一个的电平变化 —— 所以 Uno 只需要盯住其中一条通道,把看到的结果锁存下来,再去驱动继电器模块。哪一个焊盘对应哪一条命令,是你手上这块板子自己的属性,所以下面第一个 sketch 先把它读出来。
ZK-VR01 板子上到底有些什么?
ZK-VR01 把所有你要接线的地方都放在这块深蓝色板子的两条边上。上边一排是四个焊孔,丝印 GND、T、R 和 5V —— 供电,再加一对 TTL 电平的串口线:T 是模块的发送脚,R 是它的接收脚。左边一列是八个命令焊盘,丝印分别是 A25、A26、A27、B2、B3、B1、B0 和 B7,每一个都各自串了一颗电阻。
这两排出厂时都是裸露的镀铜通孔,所以在焊上一条 2.54 mm 排针之前,什么都插不上去。除此之外,板上其他元件都已经装好:银色圈里那颗黑面驻极体麦克风、右边那颗银色的 OFF/ON 拨动开关,还有一个白色的 SPK 座子,用来接包装里附的那只喇叭 —— 所以这块板子除了麦克风,也有音频输出。
材料清单 —— 语音模块、继电器模块,以及把它们连起来的排针和杜邦线
Parts list
| Item | Price | Qty | |
|---|---|---|---|
Soldering Service for Circuit Boards & Electronics - 10 Pins Soldering ServiceSOLSE10 | RM1.40 |
ZK-VR01 出货时焊盘是裸的:上面一排四个,加上你实际会用到的每条命令各一个,大约七支。加购 10 针焊接服务,我们帮你把排针焊上去。
这个作品为什么用继电器模块,而不是一颗裸继电器?
继电器本身是同一颗。1 路继电器模块上那颗天蓝色方块,丝印写着 SRD-05VDC-SL-C —— 跟我们单卖的裸继电器 SRD5VRL 是同一颗松乐(Songle)继电器。红色的模块板加上去的,是这颗继电器和单片机引脚之间本来就该有的那些东西。
先从线圈说起。松乐 SRD 数据手册给 5 V 线圈定的是 70 Ω、约 0.36 W,所以让它吸合要 5 V / 70 Ω ≈ 71 mA。ATmega328P 数据手册只保证 I/O 脚在 20 mA 以内的输出电平,而把 40 mA 列为绝对最大值。71 mA 不是引脚在闹脾气,是引脚被超出额定地使唤。做好之后的成品里,这股电流是从 Uno 的 5 V 电源轨、经过模块上那颗三极管走的 —— 电源轨供得起,引脚供不起。这个作品就用 USB 供电来做:圆孔电源座要经过一颗线性稳压器才接到同一条电源轨,而稳压器会把 (Vin − 5 V) × 电流 全部变成热。
第二个问题出在断开的那一瞬间。电感会反抗流过它的电流发生任何变化,所以一切断线圈的供电,正在塌缩的磁场就会顶出足以维持这股电流的电压 —— 方向反过来,直接打回刚才做开关的那个器件。在线圈两端并一颗二极管,等于给这股电流一条无害的回路走。裸继电器没有这颗二极管;模块上有,而且在你的引脚和线圈之间还有一颗驱动三极管和一颗 PC817 类型的光耦。要把它理解成缓冲,而不是电气隔离:这块板子只有一组电源端子,所以由 Uno 供电时两边共用同一个地,线圈用的也是 Uno 的电。光耦真正帮你省下的是:你的引脚只驱动它内部那颗 LED,几个毫安而已,而不必去驱动线圈。这块板子上真正的隔离,是继电器自己触点与线圈之间的那道空气间隙。
那什么时候该买 SRD5VRL?当你打算在洞洞板上自己搭那套驱动电路,或者要换掉一颗坏掉的继电器的时候。它那五支脚是按松乐自己的封装排的,不是 0.1 in 的标准格点,所以它同样插不进面包板。
ZK-VR01、Uno 和继电器要怎么接线?
决定用哪种杜邦线的是两件事,而且都不是引脚编号。Uno 上的排针座是母的,所以每一条线接到它那一头都得是公针。继电器模块的控制侧是一组三位螺丝端子,标着 IN、DC− 和 DC+,所以这几条线在那一头也得是裸露的公针,压在螺丝底下 —— 也就是公对公。ZK-VR01 那边则是从一条 40 针排针上掰一段下来,焊进它的裸焊盘(做法见我们的排针焊接教程),所以接它的线是公对母。
只有 T 接到 Uno。R 是故意不接的 —— 这个作品只负责听,这样就避开了唯一需要动脑筋的那个方向。模块的输出进 Uno 的输入,不管模块用什么电平驱动都是安全的那一边:Uno 判 HIGH 的门槛是 0.6 × Vcc = 3.0 V,3.3 V 一级的输出刚好过得去,5 V 的更是绰绰有余。反过来就不对称了 —— Uno 用 5 V 发送进模块的 R 脚,才是那个需要加分压电阻的方向,而这个作品从头到尾都不用它。
| 从 | 到 | 杜邦线 |
|---|---|---|
| ZK-VR01 5V | Uno 5V | 公–母 |
| ZK-VR01 GND | Uno GND | 公–母 |
| ZK-VR01 T | Uno D10 | 公–母 |
| ZK-VR01 R | 不接 | — |
| ZK-VR01 焊盘 A25(这一列最上面那个) | Uno D2 | 公–母 |
| ZK-VR01 焊盘 A26 | Uno D3 | 公–母 |
| ZK-VR01 焊盘 A27 | Uno D4 | 公–母 |
| Uno 5V | 继电器 DC+ | 公–公,压在螺丝下 |
| Uno GND | 继电器 DC− | 公–公,压在螺丝下 |
| Uno D7 | 继电器 IN | 公–公,压在螺丝下 |

写代码之前,怎么先把这块模块读明白?
这个问题 ZK-VR01 自己会回答。下面这个 sketch 什么都不控制:它把 D10 上的一个软件串口在五个常见波特率之间轮流切换,同时给 T 上收到的字节和 D2、D3、D4 上的每一次跳变打时间戳。每个窗口里说同一条命令,说三遍。五十多条命令共用八个焊盘,三条线只能采样其中三个:如果 T 上有字节出来,焊盘却一动不动,那就说明你说的这些词落在你还没接线的焊盘上 —— 把线沿这一列往下挪几格,再来一遍。
这样会掉出两个答案。正确的波特率,就是同一个词三遍都给出同一串字节的那一档;错的波特率只会给你乱码或者一片安静。而时间戳会告诉你:一次识别到底是把焊盘脉冲几十毫秒就放回去,还是锁存在那里直到下一条命令 —— 这决定了你接下来该怎么写代码。
一开始读焊盘请用普通的 INPUT,不要用 INPUT_PULLUP:输入脚什么都不驱动,面对一支方向还没弄清楚的引脚,这是唯一安全的接法。一个不停冒出随机跳变的焊盘并不是坏了 —— 只是根本没有东西在驱动它;这时加上上拉,它就会安静地停在稳定的 HIGH,而真正被驱动的焊盘照样会跳变。ATmega328P 的内部上拉是 20–50 kΩ,所以就算那个焊盘正被反方向驱动,电流最多也只有 5 V / 20 kΩ = 0.25 mA。
// ZK-VR01 探测 sketch —— 它什么都不控制,只负责看。
// 接线:模块 5V -> Uno 5V,模块 GND -> Uno GND,模块 T -> Uno D10。
// 模块的 R 脚是故意不接的。
// 三个命令焊盘 -> Uno D2、D3、D4。
// 用 115200 打开串口监视器,每个监听窗口里说一条命令,
// 把你自己这块板子的反应记下来。那张表才是真正的输出。
#include <SoftwareSerial.h>
const uint8_t PIN_VR_T = 10; // 模块 T(它的发送焊盘)-> Uno D10
const uint8_t PIN_VR_R = 11; // SoftwareSerial 需要一支 TX 脚;这一支不接线
const uint8_t PADS[] = { 2, 3, 4 };
const uint8_t N_PADS = sizeof(PADS) / sizeof(PADS[0]);
SoftwareSerial vr(PIN_VR_T, PIN_VR_R);
// 模块的波特率不是拿来猜的 —— 扫一遍,然后看结果。
const long RATES[] = { 9600, 19200, 38400, 57600, 115200 };
const uint8_t N_RATES = sizeof(RATES) / sizeof(RATES[0]);
const unsigned long WINDOW_MS = 8000; // 足够把同一条命令说三遍
uint8_t lastPad[N_PADS];
uint8_t rateIndex = 0;
unsigned long windowStart = 0;
void openWindow() {
vr.end();
vr.begin(RATES[rateIndex]);
vr.listen();
windowStart = millis();
Serial.println();
Serial.print(F("--- listening at "));
Serial.print(RATES[rateIndex]);
Serial.println(F(" baud - say one command now ---"));
rateIndex = (rateIndex + 1) % N_RATES;
}
void setup() {
// 用普通的 INPUT,不要用 INPUT_PULLUP:高阻输入不会往一支我们还不知道
// 方向的引脚里灌任何东西。等这个焊盘证明了自己确实是输出,再考虑
// 加上拉(见下文)。
for (uint8_t i = 0; i < N_PADS; i++) {
pinMode(PADS[i], INPUT);
lastPad[i] = digitalRead(PADS[i]);
}
Serial.begin(115200);
Serial.println(F("ZK-VR01 discovery. Bytes and pad edges are timestamped in ms."));
openWindow();
}
void loop() {
if (millis() - windowStart >= WINDOW_MS) {
openWindow();
}
// T 上来的任何东西都原样打印出来。同一个词说三遍:
// 波特率对了,三遍会给你完全一样的字节。
while (vr.available()) {
uint8_t b = (uint8_t)vr.read();
Serial.print(F("byte 0x"));
if (b < 0x10) Serial.print('0');
Serial.print(b, HEX);
Serial.print(F(" '"));
Serial.print((char)((b >= 32 && b < 127) ? b : '.'));
Serial.print(F("' @"));
Serial.println(millis());
}
// 每一次焊盘跳变都带时间戳。两次跳变挨得很近,说明这个焊盘是
// 脉冲式的;只有一次跳变、之后再也不回来,说明它是锁存式的。
// 写继电器那段代码之前,必须先知道是哪一种。
for (uint8_t i = 0; i < N_PADS; i++) {
uint8_t now = digitalRead(PADS[i]);
if (now != lastPad[i]) {
lastPad[i] = now;
Serial.print(F("pad D"));
Serial.print(PADS[i]);
Serial.print(now ? F(" -> HIGH") : F(" -> LOW"));
Serial.print(F(" @"));
Serial.println(millis());
}
}
}
这个 sketch 编译出来占 4,418 字节 flash、335 字节全局变量。每个波特率都是乱码,并不代表模块坏了(每个波特率都一片安静,那是另一种故障)—— 那是 SoftwareSerial 已经到顶了:它自己的源码里,接收时序只标定到 16 MHz AVR 上的 115200 波特为止。我们的 HC-05 蓝牙教程用的是同一个库,跑 9600,稳稳落在这个范围里面。这个库是用软件模拟时序的:先由引脚电平变化中断抓住起始位,然后靠一段标定过的忙等,一位一位走完八个数据位。在 16 MHz、115200 波特下,一位只有 16,000,000 / 115,200 ≈ 139 个时钟周期,而这段中断执行时会把其他中断关掉,所以一个字节收到一半不会被人插队。真正伤人的是它开始之前的那一刻:起始位到达时已经在跑的中断 —— 维持 millis() 的 Timer0、正在清空缓冲区的硬件 UART —— 会推迟进入中断处理程序的时间,而之后的每一次采样都会实实在在地晚掉一位里的一部分。到了 9600,同样一位有 1,667 个周期,同样的推迟就什么都不算了。这也正是命令焊盘值得同等重视的原因:焊盘根本没有波特率可以搞错。

继电器为什么一吸合就马上跳回去?
因为一次识别是事件,而继电器是状态,两者之间不会自己完成转换。焊盘只是闪一下:有效几十毫秒,然后就回去了。写成 digitalWrite(relay, digitalRead(pad)),继电器就把这一下闪烁原样照抄。可是一盏灯得在声音停了之后继续亮着。
所以正确的做法是:焊盘的跳变去改一个变量,再由这个变量去驱动引脚。下面这段里,两个焊盘分别把同一个布尔量置位和清零,而引脚只在这个布尔量真的发生变化时才写一次。
// 语音锁存继电器:ZK-VR01 命令焊盘 -> Arduino Uno -> 1 路继电器模块。
// 接线:模块 5V/GND -> Uno 5V/GND,两个命令焊盘 -> Uno D2 和 D3。
// Uno 5V -> 继电器 DC+,Uno GND -> 继电器 DC-,Uno D7 -> 继电器 IN。
// 下面四个常量,请照你自己那一轮探测的结果来填。
const uint8_t PIN_PAD_ON = 2; // 你那条“开”命令触发的焊盘
const uint8_t PIN_PAD_OFF = 3; // 你那条“关”命令触发的焊盘
const uint8_t PIN_RELAY = 7; // Uno D7 -> 继电器模块 IN
const uint8_t PAD_ACTIVE = LOW; // 一次识别时焊盘跳到的电平
const uint8_t RELAY_ON = LOW; // 出货时短路块插在 L = 低电平触发
const uint8_t RELAY_OFF = HIGH;
const unsigned long DEBOUNCE_MS = 30;
struct Pad {
uint8_t pin;
uint8_t last;
unsigned long changedAt;
};
Pad padOn = { PIN_PAD_ON, HIGH, 0 };
Pad padOff = { PIN_PAD_OFF, HIGH, 0 };
bool lampOn = false;
// 一次识别是一个“事件”:焊盘只动一小下。这个函数在每一次稳定地跳进
// 有效电平时返回一次 true,而焊盘只是静静停在那里时永远不会返回 true
// —— 正是这一步,把一闪变成你可以拿来做事的东西。
bool firedOnce(Pad &p) {
uint8_t now = digitalRead(p.pin);
if (now == p.last) return false;
if (millis() - p.changedAt < DEBOUNCE_MS) return false; // 还在封锁期内
p.last = now;
p.changedAt = millis();
return (now == PAD_ACTIVE);
}
void setup() {
// 顺序很重要。引脚还是输入的时候,digitalWrite() 设的是它的内部上拉,
// 而不是驱动电平,所以在 pinMode() 把它变成输出之前,这条线就已经被
// 拉高了。反过来写的话,引脚会以复位值 LOW 变成输出 —— 在低电平触发
// 的板子上,这意味着每次上传继电器都会啪地吸合一次。
digitalWrite(PIN_RELAY, RELAY_OFF);
pinMode(PIN_RELAY, OUTPUT);
pinMode(PIN_PAD_ON, INPUT);
pinMode(PIN_PAD_OFF, INPUT);
padOn.last = digitalRead(PIN_PAD_ON);
padOff.last = digitalRead(PIN_PAD_OFF);
Serial.begin(115200);
Serial.println(F("ready - relay OFF"));
}
void loop() {
bool sawOn = firedOnce(padOn);
bool sawOff = firedOnce(padOff);
// 继电器是一个“状态”。事件负责置位和清零同一个布尔量;只有这个布尔量
// 真的变了才去写引脚,所以重复的识别不会有任何副作用。
bool wanted = lampOn;
if (sawOn) wanted = true;
if (sawOff) wanted = false;
// 想用一条命令来回切换,而不是两条命令分别开和关?把上面两行换成
// if (sawOn) wanted = !wanted; 再加一秒左右的封锁时间,因为一句话
// 可能让焊盘触发不止一次,而多出来的每一次都会把状态立刻翻回去。
if (wanted != lampOn) {
lampOn = wanted;
digitalWrite(PIN_RELAY, lampOn ? RELAY_ON : RELAY_OFF);
Serial.println(lampOn ? F("relay ON") : F("relay OFF"));
}
}
这一个编译出来是 2,270 字节 flash、201 字节全局变量。如果你那一轮探测跑出来的是每条命令一个干净、可重复的字节,那就把 firedOnce() 以下的部分全部留着,只把读焊盘那几句换成比较字节即可。
继电器模块上的 PWR 和 LED1 指示灯,加上那一声清脆的吸合,已经足够证明「语音 → Uno → 继电器」这条链路是通的 —— 这也是材料清单里没有任何负载的原因。NO、COM 和 NC 上到底能接什么,以及为什么 240 V 市电不是业余玩家该碰的活,是我们继电器模块安全指南的主题。这个作品请全程停留在低压直流 —— 另外要提醒一句,12 V 的信号灯、风扇或 2 A 电源适配器,各自都还会带来自己那一套压接端子和插座。

我们常见到的错误
把模块接到 D0 和 D1 上。这两支脚是 Uno 的硬件 UART,跟 USB bootloader 和串口监视器共用,所以结果不是上传失败,就是调试输出变成一堆乱码。换成其他任意一对引脚。
杜邦线只是插在没焊的焊盘里。那些孔是裸的,杜邦公针插进去顶多算时通时断。先焊:一个称不上焊点的焊点,能把这一页上的每一种故障都模仿一遍。
Uno 一复位,继电器就吸合。那是 pinMode(OUTPUT) 跑在第一次 digitalWrite() 前面了。把这两行对调过来。
把裸继电器直接接到引脚上。它要么吸得有气无力,要么根本不动,而且常常把引脚一起拖垮:71 mA 走一支 20 mA 的引脚,还没有续流二极管,这是两个故障同时发生。
不管哪个波特率,T 上都什么都收不到。先看看板子自己那颗 OFF/ON 拨动开关,然后改走命令焊盘 —— 那条路没有波特率可以拖后腿。
常见问题
ZK-VR01 一定要自己焊吗?
要。两排都是裸露的镀铜通孔,所以得先焊上 2.54 mm 公排针。材料清单里已经放了一条 40 针排针供你掰段使用,也可以加购 10 针焊接服务,由我们帮你焊好。
不写任何串口代码,只用命令焊盘可以吗?
可以,而且这条路更好走。焊盘就是一条普通的数字线:每条命令一根线接进一支数字输入脚,用 digitalRead() 读。整个过程没有波特率,所以上面讲的那些 SoftwareSerial 时序问题咬不到你。
命令词可以自己定吗?
ZK-VR01 的命令集是固化在它固件里的。如果自定义唤醒词正是你要的重点,那该拿的是 ASR-PRO 模块:它的命令集可以自己编程,价位也更高一些。
包装里那只喇叭是干什么用的?
白色的两针 SPK 座子就是用来接包装里那只喇叭的。把它当成你的第一项台面检查:只给模块接 5 V 和 GND,把开关拨到 ON 然后说话 —— 它这时候有什么反应,都是在完全没有 Uno 的情况下做到的。
这一套可以用来开关市电灯泡吗?
这篇教程里不行 —— 这里的一切请全程停留在低压直流。继电器触点的额定值确实更高,但在马来西亚,接 240 V 市电是需要执照的工作,我们的继电器模块安全指南会正经地把这件事讲清楚。
作品会做完,习惯却会留下来。任何一块带串口焊盘、或者带一排没标注 IO 的模块,都可以用同样的方式对付:先看,把它到底怎么动记下来,然后照着这个写代码。
最后更新:2026年8月。有问题?WhatsApp 联系我们。



ZK-VR01 Voice Recognition Module – Voice Commands for Smart Home and DIY Projects
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(1WAY)
Arduino Uno Compatible SMD UNO R3 with Type B Cable - ATMEGA328P with CH340G-Microcontroller Project
2.54mm Pin Header Female Male Single Row Header 40P Connector Black White Red Yellow Green Blue - 40P MALE HEADER
40pcs Dupont Wire 10cm 20cm 30cm for Breadboard DIY Experiment Jumper Wire Breadboard wire - DUPONT WIRE M-F 20CM
40pcs Dupont Wire 10cm 20cm 30cm for Breadboard DIY Experiment Jumper Wire Breadboard wire - DUPONT WIRE M-M 20CM
Soldering Service for Circuit Boards & Electronics - 10 Pins Soldering Service