简介:一份面向Arduino学习者与电子爱好者的摩尔斯电码扩展库,封装了ASCII字符转摩尔斯电码的完整逻辑,可驱动LED、蜂鸣器等输出设备发送点划信号,解决手动控制引脚时序实现摩尔斯编码的繁琐问题。压缩包内共4个文件,以MorseCode.h头文件、MorseCode.cpp实现代码和两个txt说明文件为主,rar包仅2KB,结构精简便于快速导入项目。库内提供字符到电码序列的映射表,支持发送字符串或单独字符,也可自定义电码序列;示例可直接调用sendMorseCode("SOS")让LED闪烁发出SOS信号,还能通过参数调整长短音比例与间隔时间。两个txt文件分别说明库的用法与Arduino IDE关键字识别配置,配合开源的cpp/h源码,可帮助读者同时掌握摩尔斯电码规则和自定义库的封装思路,适合课程设计、创客通信演示等场景,也可作为实验教学素材。目前已有1008人学习下载,适合初学Arduino编程或对摩尔斯通信感兴趣的爱好者。
1. MorseCode摩尔斯电码库:给 Arduino 装上一个能“听”懂电键的解码大脑
MorseCode摩尔斯电码库(Arduino)解决的不是怎么让蜂鸣器响,而是怎么把手上的按键动作自动翻译成报文明文。很多做收报实验的人是这个状态:拍发端用delay()写好了点划,接收端却只能靠肉眼盯 LED 数亮灭,手稍微快一点就全乱。这个库把“按了多久、松了多久”变成点、划、字符间隔和词间隔,再用回调把解码结果交给你的业务代码。你不需要昂贵的专用接收机硬件,一块 Uno 或 Nano、一个按钮、一只蜂鸣器就能组成一套能收能发的练习台。它适合想做摩尔斯电码收发练习、给课程设计加“自动解码”功能的同学,也适合想搞懂非阻塞编程思路的创客。核心其实就一句话:摩尔斯码是时间码,解码就是测时间。
2. 解码原理与库架构:为什么说摩尔斯码是“时间码”
2.1 时长单位:点、划与三种间隔的判定基准
摩尔斯电码的信息全部落在时间轴上,而不是电平高低上。电码规范里常用的时长比例是:一个点等于 1 个时间单位,一个划等于 3 个时间单位;同一个字符内部,点和划之间间隔 1 个单位;字符与字符之间间隔 3 个单位;单词与单词之间间隔 7 个单位。解码器的任务,就是不断测量“信号持续了多久”和“信号消失了多久”,再拿这两组时长去套上面的比例。
难点在于,不同人手速不一样,不存在一个固定的“单位时长”。同一个-,新手按出来可能是 400ms,老手可能只有 120ms。如果库把点划判定阈值写死成 200ms,那新手按的划会被误判成多连点,老手按的点会被误判成划。所以常见做法是让库维护一个动态基准:每次确认一个点之后,把它的实测时长计入滚动均值,再用这个均值去推断当前单位时长。划的判定阈值一般取点均值的 2 到 3 倍,字符间隔取点均值的 3 倍左右,词间隔取 7 倍左右。
| 信号类型 | 标准时长比例 | 在库中对应的判定参数 |
|---|---|---|
| 点 | 1 单位 | 实测时长 ≤ 点最大值阈值 |
| 划 | 3 单位 | 实测时长 > 点最大值阈值 |
| 字符内间隔 | 1 单位 | 不触发任何边界 |
| 字符间隔 | 3 单位 | 超过字符间隔阈值,输出一个字符 |
| 词间隔 | 7 单位 | 超过词间隔阈值,输出一个词边界 |
使用这个库时最关键的一点是:三个间隔参数的比例比绝对值更重要。很多人只调点划阈值,字符间隔和词间隔还留在默认值,结果点划识别对了,整句输出却切得稀碎。我一般建议按 1:3:7 的比例锁着调,只动基准单位值,不单独乱改某一项。
2.2 引脚状态采样与状态机设计:轮询而非中断
刚接触这个场景的人通常会想:按键检测用attachInterrupt()不是更准吗?实测下来,引脚中断在摩尔斯码解码里反而不讨好。机械按键按下瞬间会有 5 到 20ms 的抖动,中断会反复触发,容易把一次按压缩放成多次点;而且摩尔斯码的最短信号也有几十毫秒,轮询的采样速度完全够用。
库内部一般维护一个三态状态机:空闲等待、正在测量按下时长、正在测量松开间隔。每个loop()周期调用一次update(),所有时间计算都用millis()差值完成,全程不阻塞。下面是一段核心状态机代码,我在自己的模拟项目里就是这么搭的:
// MorseCode 库内部状态机核心(节选) #include <Arduino.h> class MorseDecoder { enum DecoderState { IDLE, // 无信号,等待按下 MARK_ACTIVE, // 正在按下,测量持续时长 SPACE_ACTIVE // 已松开,测量间隔时长 }; DecoderState _state = IDLE; unsigned long _markStart = 0; // 按下时刻(ms) unsigned long _spaceStart = 0; // 松开时刻(ms) int _pin; // 输入引脚号 bool _activeHigh = false; // 电平极性,按钮/光敏可配置 public: void begin(int pin, bool activeHigh) { _pin = pin; _activeHigh = activeHigh; pinMode(pin, INPUT_PULLUP); } // 每轮 loop() 调用一次,根据当前电平推进状态 void update() { bool level = digitalRead(_pin); bool pressed = _activeHigh ? level == HIGH : level == LOW; switch (_state) { case IDLE: if (pressed) { _markStart = millis(); _state = MARK_ACTIVE; } break; case MARK_ACTIVE: if (!pressed) { unsigned long markMs = millis() - _markStart; classifyMark(markMs); // 判定点或划 _spaceStart = millis(); _state = SPACE_ACTIVE; } break; case SPACE_ACTIVE: if (pressed) { classifyGap(millis() - _spaceStart); // 判定字符/词边界 _markStart = millis(); _state = MARK_ACTIVE; } else if (millis() - _spaceStart > _wordGapMs) { onWordBoundary(); // 超时未收到新信号,强制产出词边界 _state = IDLE; } break; } } };逻辑说明:update()的每个分支都不调用delay(),状态推进全靠millis()与记录时刻的差值。classifyMark()负责把按下时长拆成点或划;classifyGap()负责把松开间隔拆成字符边界或忽略;onWordBoundary()在间隔超过词阈值时被调用,同时处理报文收尾时“最后一个字符没有后续信号”的难题。
参数说明:这里没有把时间阈值写死在代码里,而是留给外部通过 setter 设置。默认情况下,库会按 20WPM 的练习速度给出一组初始值:点最大 120ms、字符间隔 360ms、词间隔 840ms。手速慢就整体放大,手速快就整体缩小。
2.3 库的对外接口设计:回调、固定缓冲区与三个核心参数
库的接口风格常见做法是回调加参数配置,而不是让用户自己写循环解析。原因很简单:摩尔斯码解码过程充满时间边界,如果回调不及时处理,下一个字符的间隔判定就会被推迟,报文越收越乱。回调风格将“识别”和“处理”分开,业务代码只需关注拿到字符后干什么。
我习惯把接口设计成下面这样,使用者只需要在setup()里配好参数、注册回调:
// 库的对外接口(典型用法) MorseCodeDecoder decoder; decoder.begin(2, false); // D2 引脚,按下为低电平 decoder.setDotMaxMs(120); // 点最大时长 decoder.setCharGapMs(360); // 字符间隔阈值 decoder.setWordGapMs(840); // 词间隔阈值 decoder.onCharacter([](char c) { // 每识别出一个字符,回调一次,c 可能是 A~Z / 0~9 Serial.print(c); }); decoder.onWord([]() { // 每识别出一个词边界,回调一次 Serial.println(); });逻辑说明:onCharacter在状态机确认一个完整字符后触发,onWord在确认词边界后触发。回调里面不要做长时间操作,比如不要在字符回调里Serial.println一大段拼接字符串,否则会阻塞下一轮update()执行。解码得到的字符放进固定长度的 char 数组,等词边界回调时统一输出,这是在小内存板子上跑长报文最稳的做法。
参数说明:setDotMaxMs是唯一决定“点还是划”的参数,setCharGapMs和setWordGapMs决定“什么时候把已收字符提交出去”。这三个值必须一起按比例调,最忌讳只调其中一个。Arduino Uno 只有 2KB SRAM,库内部尽量不用String类,改用固定数组和环形缓冲,这是它能在 Uno 上稳定跑长报文的原因之一。
3. 用 MorseCode 库在 Arduino 上跑通最小收发系统
3.1 硬件接线清单与三个注意点
最小系统只需要四样东西:一块 Arduino 兼容板、一个自复位按钮、一个无源蜂鸣器、几根杜邦线。按钮一端接 GND,另一端接 D2;蜂鸣器正极通过驱动电路接 D9,负极接 GND;板载 LED 不接线,用 D13 自带的即可。
| 元件 | 引脚 | 说明 |
|---|---|---|
| 自复位按钮 | 一端 GND,一端 D2 | 按下为低电平,靠内部上拉 |
| 无源蜂鸣器 | 正极接 D9 驱动电路,负极 GND | 用 tone() 输出方波发声 |
| 板载 LED | D13 | 发送时同步闪烁,反馈用 |
接线时有三个注意点。第一,按钮必须用内部上拉,即代码里pinMode(KEY_PIN, INPUT_PULLUP),这样不需要外接上拉电阻,按下时读到 LOW。第二,蜂鸣器不要直接插在引脚上,Uno 引脚驱动能力有限,直接驱动蜂鸣器容易导致电压跌落。第三,如果用的是 Nano 或 ESP32,引脚编号不同,但逻辑完全一致,D2 换成任意数字引脚都行,因为这种解法不依赖中断。
3.2 发送端:把按键时长变成点划输出
发送端做的事情,是把操作者按下按钮的物理时长转成电键信号,并顺便在串口打印出点划序列,方便对拍。这里用tone()驱动蜂鸣器,按下发声、松开停声,时长完全由人手控制,符合真实电键的拍发习惯。代码如下:
// 发送端示例:按键时长 -> 声音 + 串口点划标记 #include <Arduino.h> const int KEY_PIN = 2; const int BUZZER_PIN = 9; const int LED_PIN = 13; const int DOT_MAX_MS = 120; // 120ms 以内算点,超过算划 unsigned long keyDownTime = 0; bool keyWasDown = false; void setup() { pinMode(KEY_PIN, INPUT_PULLUP); pinMode(BUZZER_PIN, OUTPUT); pinMode(LED_PIN, OUTPUT); Serial.begin(9600); } void loop() { bool pressed = (digitalRead(KEY_PIN) == LOW); // 按下为低 if (pressed && !keyWasDown) { keyDownTime = millis(); // 记录按下的起始时刻 tone(BUZZER_PIN, 800); // 800Hz 方波驱动蜂鸣器 digitalWrite(LED_PIN, HIGH); } if (!pressed && keyWasDown) { unsigned long holdMs = millis() - keyDownTime; noTone(BUZZER_PIN); digitalWrite(LED_PIN, LOW); // 按持续时间输出点或划,方便接收端核对 if (holdMs <= DOT_MAX_MS) Serial.print("."); else Serial.print("-"); Serial.print("|"); } keyWasDown = pressed; }逻辑说明:keyWasDown用来检测下降沿和上升沿,避免每一轮循环都重复计时。按下瞬间记录时刻,松开瞬间计算时长并分类。tone()和noTone()配对使用,保证蜂鸣器只会在按住期间响。
参数说明:DOT_MAX_MS是发送端自己的点划分类阈值,要和接收端的setDotMaxMs保持一致,否则发送端认为是划的信号,接收端可能还在按点处理。tone()的 800Hz 取决于蜂鸣器谐振频率,多数无源蜂鸣器在 2kHz 以下响应较好,可以试 800 到 1500Hz,选声音最响的那个值。
3.3 接收端:用库解码并在串口显示
接收端是整个系统的核心,直接调用库的update()即可。串口打印策略是字符回调里只收不打印,等词边界回调时再统一输出,这样报文不会被打碎。代码很短,但每个配置都有讲究:
// 接收端示例:D2 输入 -> 串口输出解码结果 #include "MorseCode.h" // 按实际安装的库头文件引入 MorseCodeDecoder decoder; char message[64]; // 固定缓冲区,不用 String int msgLen = 0; void setup() { Serial.begin(9600); decoder.begin(2, false); // D2,按下为低电平 decoder.setDotMaxMs(120); // 点最大 120ms decoder.setCharGapMs(360); // 字符间隔 360ms decoder.setWordGapMs(840); // 词间隔 840ms decoder.onCharacter([](char c) { if (msgLen < 63) message[msgLen++] = c; // 只写入数组 }); decoder.onWord([]() { message[msgLen] = '\0'; // 补结束符 Serial.println(message); // 词边界统一打印 msgLen = 0; }); } void loop() { decoder.update(); // 高频调用,非阻塞 }逻辑说明:字符回调里如果直接打印,会出现“报文单词被串口号乱”的问题;这里把字符写进固定数组,直到词边界才一次性输出。onWord回调在间隔超过词阈值时触发,同时会自动处理报文结束时的尾字符提交。
参数说明:这里三个时间参数对应 20WPM 的练习速度。如果操作者是新手,手速慢,可以把setDotMaxMs调到 180,字符间隔和词间隔分别按 3 倍和 7 倍跟着调到 540 和 1260。手速越慢,单位时长越大,这套参数是在实际练习中试出来的,不是拍脑袋定的。
3.4 回环验证:先让电脑自己拍给自己
最稳妥的验证方式,是先不接第二个按钮,让发送端和接收端跑在同一个程序里。做法是把串口收到的点划字符串解析成对应时长的高电平脉冲,直接送到解码引脚。调试时输入一串固定报文,例如“SOS”,用串口助手发给 Arduino,程序按... --- ...的时长驱动 D3 引脚,再接一根杜邦线把 D3 连到 D2,解码结果原样回传串口。这里的原则只有一个:任何新参数在真手键之前,先用软件回环打一遍,看切分对不对。
4. 输入源扩展:按键之外的光敏、音频与串口适配
4.1 为什么要扩展输入源:摩尔斯码不一定来自手指
库的输入判定只依赖“电平变化加时长测量”,所以信息来源换成光、声、串口数据都是可行的。光敏方案适合模拟光通信演示;音频方案适合两个板子隔着一段距离用声音传报文;串口方案适合自动化测试。三种输入源的电平特性差异很大,但接入库的思路是同一个:把外部的连续信号变成干净的高低电平,再交给状态机去测时长。
| 输入源 | 信号形态 | 接入方式 | 典型坑 |
|---|---|---|---|
| 机械按钮 | 高低电平 | 直接接数字引脚 | 抖动需要消抖 |
| 光敏电阻 | 缓慢变化的模拟量 | ADC 采样 + 动态阈值 | 环境光漂移 |
| 麦克风模块 | 声音存在与否 | 数字输出接引脚 | 噪声毛刺 |
| 串口数据 | 点划字符串 | 解析后模拟电平输出 | 阻塞导致丢信号 |
4.2 光敏输入:接一个光脉冲信号源
光敏电阻的玩法是用手或挡板遮挡光线,遮光时长对应点或划。电路上用光敏电阻和一个 10kΩ 电阻串联分压,中间点接 ADC 引脚。遮光时读数会变化,但环境光也在缓慢漂移,所以需要动态基线。下面这段代码是光敏输入的适配层:
// 光敏输入适配:ADC 采样 + 指数滑动基线 const int LDR_PIN = A0; int baseline = 0; void setup() { pinMode(LDR_PIN, INPUT); delay(500); // 让传感器稳定 baseline = analogRead(LDR_PIN); // 记录环境光基线 } bool readOpticalLevel() { int v = analogRead(LDR_PIN); baseline = baseline * 0.98 + v * 0.02; // 缓慢跟随基线 int delta = baseline - v; // 遮光时 v 下降 return delta > 40; // 超过阈值视为有信号 }逻辑说明:baseline用指数滑动平均维护,不会因为一次手挡光就剧烈跳动;当遮光发生时,当前读数与基线差迅速超过阈值,函数返回“按下”逻辑。把readOpticalLevel()的返回值替换掉原来digitalRead的位置,状态机不需要改任何代码。
参数说明:阈值 40 对应约 20mV 的电压差,在 5V 供电、10k 分压的电路下,遮光前后 ADC 读数差通常有 80 到 150,阈值留了一倍裕量。如果环境光很暗,读数差变小,就要把阈值降到 20 附近。荧光灯频闪会让 ADC 读数抖动,滑动系数 0.98 已经比较保守,还不够就把采样频率降到 200Hz 左右。
4.3 音频输入:让解码器直接听蜂鸣器声音
音频方案建议优先用带数字比较器输出的麦克风模块。这类模块上有一个电位器可调灵敏度,输出端直接输出高低电平,不用 ADC。接线时模块 VCC 接 5V,GND 共地,数字输出接 D2,把 D2 的activeHigh按模块输出极性配置。
麻烦的是模拟输出的麦克风模块。风噪、回声会让电平毛刺不断,信号边界不稳定。常见做法是在软件里加一个“有效按下确认窗口”:检测到边沿后,先等 10ms 再读一次,如果电平没变才算真实按下。这相当于给声音信号加了一个低通滤波。音频方案的另一个坑是蜂鸣器方波会串进麦克风,造成接收端自激。解决办法是发送端用tone()的 800Hz 和接收端麦克风保持至少半米距离,或者把发送和接收的板子供电分开。
4.4 串口输入:把电脑当自动拍发器
调试时最有用的输入源是串口。电脑把点划字符串发给 Arduino,Arduino 解析后按对应时长输出模拟电键信号,再接一根线送到解码引脚。这样你不需要真的按键,就能批量测试解码器的边界。参考实现如下:
// 串口输入:把点划字符串转换成引脚上的模拟电键信号 #include <Arduino.h> String packet; unsigned long signalUntil = 0; int signalState = LOW; void loop() { while (Serial.available()) packet += (char)Serial.read(); if (packet.length() > 0) { for (size_t i = 0; i < packet.length(); i++) { if (packet[i] == '.') { digitalWrite(3, HIGH); delay(60); // 点 = 60ms digitalWrite(3, LOW); delay(60); // 点后间隔 60ms } else if (packet[i] == '-') { digitalWrite(3, HIGH); delay(180); // 划 = 180ms digitalWrite(3, LOW); delay(60); // 划后间隔 60ms } else if (packet[i] == ' ') { delay(180); // 字符间隔补足到 3 单位 } } packet = ""; } }逻辑说明:这段把字符串里的.映射成 60ms 高电平,-映射成 180ms 高电平,空格补 180ms 间隔,整体就是一套自动拍发器。输出的引脚脉冲可以直接接解码引脚,也可以加一个三极管驱动蜂鸣器变成“有声自动拍发”。
参数说明:这里特意用了delay(),因为发送端可以阻塞;但如果同一块板子还要跑接收端,就必须把发送改成非阻塞定时器,否则发送的 180ms 期间解码器完全停摆,自己发的信号自己漏听。这是串口拍发和自主解码共存的经典冲突,防着点不会错。
5. 摩尔斯电码库的 5 个典型坑与排查记录
5.1 抖动误判:多出来的“点”到底哪来的
现象:按下按钮一次,解码器输出两三个点,报文明明只拍了一划却出来三个点。原因:机械按键内部触点闭合瞬间有 5 到 20ms 的抖动,状态机把一次按压识别成多次按下松开。解决:在库的MARK_ACTIVE分支里加一个确认窗口,边沿到来后先等 8ms 再确认,而不是立刻记录。8ms 远小于最短点时长,不会误伤真实信号。
case MARK_ACTIVE: if (!pressed) { // 等待 8ms 确认,滤掉机械抖动 delay(8); if (digitalRead(_pin) != pressed) break; unsigned long markMs = millis() - _markStart; classifyMark(markMs); _spaceStart = millis(); _state = SPACE_ACTIVE; } break;5.2 手速一变就脱码:阈值为什么不能写死
现象:练习时用同一只手拍发,前十分钟正常,手热了之后点越按越短,整个报文开始错乱,点变成划、字符间隔消失。原因:点划判定阈值和字符间隔阈值都是固定值,没有跟随操作者手速变化。解决:让库在每次确认一个点之后,用实测时长更新内部均值,再按均值动态调整阈值。我一般取当前点均值的 1.8 倍作为新的点最大时长,字符间隔取 3 倍,词间隔取 7 倍。
// 自适应阈值更新逻辑 _dotAvgMs = (_dotAvgMs * 7 + measuredDotMs) / 8; // 滚动均值 _dotMaxMs = _dotAvgMs * 1.8; // 点最大阈值 _charGapMs = _dotMaxMs * 3; // 字符间隔 _wordGapMs = _dotMaxMs * 7; // 词间隔5.3 蜂鸣器一响板子重启:驱动电路别省
现象:每次蜂鸣器发声的瞬间,板子按键失效,串口掉线,甚至整个板子重启。原因:无源蜂鸣器直接接引脚,启动瞬间电流过大,把板上电压拉低。解决:用小功率 NPN 三极管搭开关电路,蜂鸣器正极接电源,负极接三极管集电极,基极串 1kΩ 电阻接 D9,发射极接地。代码不用改,只是把引脚当开关控制三极管。这是硬件上最容易翻车的地方,别省一个三极管。
5.4 长报文乱码与卡死:回调里别打印别拼接
现象:短报文正常,超过二十个字符后串口输出乱码或程序卡死。原因:在onCharacter回调里直接Serial.print()拼接字符串,或者内部用了String类,小内存板子的动态内存被耗干。解决:回调里只做最轻量的操作,把字符写进固定长度数组,词边界时再统一输出;所有内部存储用 char 数组,不用String。我见过有人把message长度定义到 256,uno 直接内存溢出,缩到 64 就安静了。
5.5 最后一个字符总是丢:超时强制 flush
现象:报文收完了,但最后一个字符没出现在串口输出里,整个词最后少一位。原因:字符边界判定依赖“下一个信号到来”来触发,报文结束后没有后续信号,间隔判定永远等不到。解决:在SPACE_ACTIVE状态里加超时逻辑,间隔超过词间隔阈值仍未收到新信号,就强制提交当前缓冲并复位。这也解释了为什么库在每次词边界回调时就把msgLen清零,必须清,否则下一次报文会和上一次粘连。
6. 进阶验证:用“PARIS”标定速度与逻辑分析仪校准
训练圈常用“PARIS”这个词试手速,因为它包含的点划和间隔刚好是 50 个时间单位,按每分钟 50 个单位的定义,单位时长和 WPM 的关系可以简化成一个式子:unitMs = 1200 / WPM。20WPM 时单位时长 60ms,点最大阈值设 90ms 到 120ms 均合理;25WPM 时单位时长 48ms,点最大阈值就得缩到 72ms 左右。下面这张表是我常备的配置,直接照着填就行:
| 速度 (WPM) | 单位时长 (ms) | 点最大阈值 (ms) | 字符间隔 (ms) | 词间隔 (ms) |
|---|---|---|---|---|
| 15 | 80 | 120 | 360 | 840 |
| 20 | 60 | 90 | 270 | 630 |
| 25 | 48 | 72 | 216 | 504 |
| 30 | 40 | 60 | 180 | 420 |
验证时序别靠耳朵。把逻辑分析仪的通道夹在 D2 上,拍一段固定报文,放大看每个高电平宽度。正常情况下高电平应该集中在 1 倍和 3 倍单位时长附近,低电平间隔呈现 1 倍、3 倍、7 倍分层。如果发现点时长逐渐漂移,先检查发送端代码里是不是混了Serial.print()导致发送节奏被中断,再检查接收端update()调用频率是不是被别的事件拖慢。
最后说一个我自己的习惯:每次换输入源,从按钮换成光敏或麦克风,第一件事不是调解码参数,而是先跑一遍软件回环。把固定报文通过串口自动拍发到解码引脚,看能不能一字不差地收回来。我吃过一次亏,演示前把单位时长从 60 调成 80,没做校验,结果现场手一快整段报文脱码,翻车翻得很彻底。从那以后,时序校准永远排在新功能之前。这套思路从最简单的手键练习到光通信演示都适用,希望帮到你。
本文还有配套的精品资源,点击获取