ZK-VR01 语音识别模块:用说话控制 Arduino 继电器

Cartoon of a deep-blue ZK-VR01 voice recognition board with a black microphone face and a round bare-silver speaker beside it, teal sound waves arriving at the microphone, and a red relay module with a sky-blue relay nearby

说一句话,继电器就动作。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

ItemPriceQty
Soldering Service for Circuit Boards & Electronics - 10 Pins Soldering ServiceSoldering Service for Circuit Boards & Electronics - 10 Pins Soldering ServiceSOLSE10RM1.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 个周期,同样的推迟就什么都不算了。这也正是命令焊盘值得同等重视的原因:焊盘根本没有波特率可以搞错。

卡通图:一只放大镜罩在深蓝色语音识别板上,一条黄线上带着深蓝色方波、另外三条彩色线各带一个青色脉冲,一起接到一块小小的蓝色 Arduino 板,下方是一条画着青色脉冲的深蓝色时间轴
探测 sketch 什么都不驱动。它同时盯着串口焊盘和命令焊盘,把每一次动静都打上时间戳。

继电器为什么一吸合就马上跳回去?

因为一次识别是事件,而继电器是状态,两者之间不会自己完成转换。焊盘只是闪一下:有效几十毫秒,然后就回去了。写成 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 电源适配器,各自都还会带来自己那一套压接端子和插座。

卡通对比图:上方是一个窄窄的青色脉冲直接驱动红色继电器模块,模块画成闪烁重影的样子;下方是同一个脉冲先经过一个深蓝色的锁存方块,变成一个又长又稳的脉冲,继电器模块只有一个、稳稳当当,绿色 LED 亮得很清楚
一次识别只是一闪;一盏灯却是一个状态。直接拿这一闪去驱动继电器,它就会抖个不停。让这一闪去置位一个锁存,继电器才会稳稳停在那里。

我们常见到的错误

把模块接到 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 联系我们

Leave a Reply

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