我自己的手机常年静音,验证码、快递短信、网站通知经常错过,等发现的时候已经过了时效。后来搞了个开源UART短信转发器,把一张旧SIM卡插在带串口的GSM模块上,通过串口读到短信,再自动推送到我常用的IM工具。整个方案不需要焊接一块板子,四根杜邦线一插就能跑,而且短信走的是运营商网络通道,不占用家里宽带的流量,也不消耗SIM卡的数据流量,很适合放一台当“永不关机的短信收件箱”。
这方案从原理到落地其实没多少玄学:核心就是让单片机或小主机通过UART串口和GSM模块对话,用AT指令让模块把收到的短信吐出来,再用软件解析、转发到目标通道。适合的人群很明确——想给旧手机卡找点事干的人、有短信验证码接收需求的独立开发者、以及想入门嵌入式串口通信但不敢碰烙铁的新手。下面我按自己的实操过程把整个项目拆开来讲,从硬件怎么选、线怎么接、代码怎么写,到踩过的坑和排查方法,尽量写得让你照着能复现。
1. 项目思路拆解:短信转发这件事,为什么值得自己造
1.1 核心需求:短信只“收”不“发”,却要能实时产生动作
很多人第一反应是手机上装个短信转发App不就得了。问题在于,手机必须保持开机、联网、App不被系统杀掉,而且很多国产ROM的后台管控让这类应用极其不稳定。我测试过多个方案,最多稳定跑三天就静默被杀。换一块专门的GSM模块做收件端,模块不跑操作系统,没有后台清理,通电就待机,收到短信立刻通过串口吐数据,天然适合7×24小时运行。
另一个需求点是“收”和“发”的分离。GSM模块只负责接收短信,转发动作交给主控(开发板或小主机)通过网络发起。这样一来,即使SIM卡本身没有开通数据套餐也没关系,因为模块物理上只走短信通道,网络请求完全由主控承担。这也是“不耗流量”的真正含义——不是魔法,而是架构上就把两条通道切开了。
1.2 方案选型:为什么选UART串口,而不是USB、WiFi或蓝牙
给GSM模块和主控之间做数据通路,理论上有好几种选择,我逐个说下为什么最终选了UART。
USB接口看似方便,插上就能识别成虚拟串口,但USB需要主控上有对应的驱动和USB Host能力,很多便宜的MCU不具备。而且USB线缆焊接转接的环节多,距离稍长还会出现供电不足的问题。WiFi和蓝牙需要模块本身支持AT over TCP或SPP,这类模块(比如ESP32外挂GSM)不是不行,而是把问题复杂化了,调试时难以区分是网络问题还是串口问题——多一个中间层,就多一层排查成本。
UART串口是最通用、最底层的通信方式。GSM模块几乎百分之百会引出UART引脚,主控侧只要有任意一组串口就能对接。它的表现就像两个人面对面对话,不需要协议栈,不需要驱动,裸线就能传数据。而且UART的调试手段非常直接:串口监视器里看到什么就是什么,错误几乎都能定位到具体哪一行数据异常,对新手特别友好。
1.3 “无需焊接”是怎么做到的:选对模块形态,全程不碰烙铁
市面上的GSM模块形态大概分三种:邮票孔贴片模块、插针式开发板、带排针母座的扩展板。要做免焊接方案,只能选后两种,关键是模块板子上必须预先焊好“母座”或“排针”,这样你只需要买杜邦线(公对母或公对公)插上去即可。
我用的组合是“带排针的GSM扩展板 + USB转TTL串口板 + 杜邦线若干”。GSM扩展板出厂自带排针焊好的母座,USB转TTL板是常见的那种CH340小板,VCC、GND、TX、RX四个引脚通过排针露在外面。你要做的只是把线插在对应的孔位上,等效于传统方案的“飞线”,但完全不用动电烙铁。
注意:这里有个概念容易混淆——模块板上的“排针”如果已经焊接好了,那叫免焊;但两种板子之间仍然需要用杜邦线连接。我第一次买模块时忽略了是否带排针,收到的是邮票孔半成品,结果必须自己焊引线,白白多了半小时烙铁时间。下单前务必看清“已焊排针”或“带底板”字样。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心原理:UART通信与AT指令,读懂这两个东西就成功了一半
2.1 UART通信的本质:一对交叉连接的话筒和听筒
UART全称是Universal Asynchronous Receiver/Transmitter,通用异步收发传输器。它只有两根数据线,一根发送(TX),一根接收(RX),通信时一方发、另一方收。接线时最容易被坑的就是“TX接RX,RX接TX”的交叉规则。
可以这样理解:甲的话筒(TX)必须对着乙的耳朵(RX),乙的耳朵才能听到甲说话。如果甲的话筒对着甲的耳朵,那就成了自言自语,乙什么都收不到。这个交叉连接的规则在几乎所有串口通信里都适用。
UART还有一个“异步”特性,意思是通信双方不需要共享时钟信号,而是通过事先约定一个波特率(每秒传输的比特数)来同步节奏。只要双方波特率一致,数据就能正确解析;不一致时就会出现花屏乱码。本项目的GSM模块默认波特率大多是115200或9600,需要在配置里指定正确值。
我画个简单的接线示意:
- 主控的 TX 接到 GSM 模块的 RX
- 主控的 RX 接到 GSM 模块的 TX
- 主控的 GND 接到 GSM 模块的 GND(必须共地,否则电平电压参考点不一致,接收端读到的信号是乱的)
- 主控的 VCC 接到 GSM 模块的 VIN(供电)
有人会问为什么还要共地。打个比方:两个人一个站在山顶一个站在山谷,喊话时音量大小不同,但对“大声”的标准理解不一致,就无法判断对方是在喊还是在说。共地就是给双方一个统一的“参考零点”,这样信号电压的高低才能被正确识别。
2.2 AT指令:人和模块之间的对话语言
GSM模块出厂时内置了完整的AT指令解析器,你通过串口向它发送文本指令,它执行后返回结果。整个过程是典型的“请求—响应”模式,和HTTP协议有点神似——你发一个请求,它回一个响应,只不过AT指令的格式固定为“AT”开头加命令。
常用的几条指令:
AT:测试模块是否在线,返回OK表示正常AT+CMGF=1:把短信模式切换为文本模式(Text Mode),这样收到的短信直接以可读文字呈现AT+CNMI=2,1,0,0,0:设置新短信主动上报,当模块收到短信时,会主动通过串口给你推一条+CMTI: "SM", 序号的提示AT+CMGR=序号:读取指定序号的短信内容AT+CMGD=序号:删除指定序号的短信,防止存储区满
项目里最重要的就是AT+CNMI这条指令,它让模块从“被动查询模式”变成“主动推送模式”。模块收到短信后会先通知主控,主控再发指令读取,而不是傻乎乎地每隔几秒轮询一遍。这样既省电又能做到秒级响应。
2.3 不耗流量的技术解释:短信通道与数据通道是两回事
GSM模块注册到运营商网络后,分为短信通道、语音通道、数据通道。短信走的是SS7信令通道,正常情况下短信接收本身不产生数据流量费用;主控网络转发走的是WiFi或以太网。因此在“模块收短信、主控发网络请求”的架构下,你唯一需要确认的是SIM卡没有欠费,并不依赖数据套餐。
这个设计的另外一个隐藏好处是,短信收发不受宽带断网影响。就算家里的路由器挂了、光猫红灯了,短信依然能通过GSM模块收到并存着,等主控恢复网络后再补推。对于做告警通知的场景,这等于多了一路独立于互联网的“保底链路”。
3. 软件架构与Go语言实现:监听、解析、转发、去重四件事
3.1 程序模块划分:一个常驻监控进程该有的样子
软件部分我选择用Go语言实现,原因是交叉编译方便、部署时只需要一个二进制文件、并发模型简单。整个程序逻辑上拆成四块:串口监听、短信解析、转发通道、状态管理。
串口监听模块负责收拢GSM模块上报的原始数据,按+CMTI这类事件做触发。短信解析模块把+CMGR返回的多行文本拆成发送号码、时间、正文。转发通道把解析结果通过HTTP推送到目标平台,支持重试。状态管理记录已处理短信的序号或指纹,保证重启后不重复推送。
四块模块之间用Go的channel解耦:串口监听读到的原始数据丢进一个channel,解析模块消费后产出“格式化短信”结构体,再丢给转发队列。这样做的好处是任意一块出错都不会拖垮整条链路,转发超时不会导致串口读取阻塞。
3.2 串口打开与参数配置:波特率、数据位、停止位不要随便改
在Go里操作串口,目前比较省心的库是github.com/tarm/serial,不过它已经不怎么维护了;新项目更推荐go.bug.st/serial/enumerator或github.com/creack/gpt风格的库。我用的是go.bug.st/serial,支持跨平台和更完整的超时控制。
打开串口时,波特率选115200还是9600必须和GSM模块实际固件匹配。大多数模块默认115200,但也有出厂9600的型号。判断方式很简单:连上后发一条AT,如果返回OK就说明波特率正确;如果返回乱码或没有响应,就切换波特率重试。
Go代码打开串口的骨架:
go复制package main
import (
"fmt"
"log"
"go.bug.st/serial"
)
func openUART(device string, baud int) (serial.Port, error) {
mode := &serial.Mode{
BaudRate: baud,
DataBits: 8,
Parity: serial.NoParity,
StopBits: serial.OneStopBit,
}
port, err := serial.Open(device, mode)
if err != nil {
return nil, fmt.Errorf("open serial %s failed: %w", device, err)
}
return port, nil
}
func main() {
port, err := openUART("/dev/ttyUSB0", 115200)
if err != nil {
log.Fatal(err)
}
defer port.Close()
// 后续向port写入AT指令、读取响应
}
参数里的8位数据位、无校验、1位停止位是串口通信最通用的组合,也叫8N1。除非模块文档明确要求,否则不要改动这三项。很多新手习惯性把校验改成偶校验,结果模块怎么都不回数据,其实就是参数不匹配。
3.3 读取短信内容:先开启主动上报,再按序号拉正文
模块上电后需要先做一套初始化流程。我个人习惯依次发送:
bash复制AT # 等到 OK
AT+CMGF=1 # 文本模式
AT+CNMI=2,1,0,0,0 # 短信主动上报
AT+CPMS="SM" # 短信存储区指向SIM卡
前三条好理解,第四条很多人会漏。默认的短信存储区可能是模块内部Flash,如果开机后SIM卡插拔过,存储区可能切换为SM。显式指定一遍,可以避免后面AT+CMGR报错找不到短信。
当模块收到一条新短信时,串口会主动推送一行:
code复制+CMTI: "SM", 8
这行字的意思是:SIM卡存储区收到一条新短信,序号为8。接下来主控发送:
bash复制AT+CMGR=8
模块返回类似这样的多行文本:
code复制+CMGR: "REC UNREAD","+8613800138000","24/09/14,10:30:00+32"
这是一条测试短信内容
OK
解析时注意几点:第一行以+CMGR:开头,用逗号分隔,第二项是发送号码(带引号),第三项是时间戳,后面可能还跟有+32这种时区偏移。正文是回车换行后的内容,一直到OK之前的所有行都算正文。如果正文中间出现连续换行,不要误当作短信结束。
短信内容里极有可能混入中文GB2312编码。文本模式下模块返回的字节流是GBK编码的,如果你的程序用UTF-8解析,中文全文乱码。我处理的方法是先把原始字节GBK转成UTF-8,再进业务解析逻辑。
3.4 转发通道设计:HTTP推送、重试机制与消息去重
短信解析完成后,下一步是推到目标端。最简单的做法是构造一个HTTP POST请求,把号码和正文打在JSON里发出去。目标端我用的是通用Webhook,接收后能自动发到群聊。
代码片段如下:
go复制func pushMessage(num, text string) error {
payload := fmt.Sprintf(`{"number":%q,"content":%q}`, num, text)
req, _ := http.NewRequest(http.MethodPost, webhookURL, strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("webhook returned %d", resp.StatusCode)
}
return nil
}
这里的细节在于重试策略。网络抖动是常态,Webhook偶发超时也是常态,但不能因此丢短信。我设置了三档重试:第一次失败后等5秒重试,第二次失败后等30秒,第三次失败后进入后台慢队列,每10分钟重试一次,直到成功或手动干预。重试用Go的定时器实现,不能用for { time.Sleep }阻塞主流程,否则串口监听会断。
消息去重是必须考虑的。GSM模块掉电重启后,存储区里可能还有没删除的旧短信;或者转发时网络一直失败,模块存储区满了之后新短信进不来。我的做法是在本地SQLite里记录已经转发成功的短信指纹,指纹用“号码+正文前20个字符+时间戳前12位”算SHA256。每次解析完成后查一下库,命中就直接删短信不转发。
3.5 异常恢复:串口断线重连、模块无响应自愈
GSM模块挂了、串口被系统释放、USB转串口板被重新枚举,这些都是实际会遇到的故障。程序不能指望着人手动重启,需要做自愈。
我在主循环里加了一个看门狗:每60秒发一条AT指令,如果连续3次没有响应,就关闭当前串口句柄,等待5秒后重新打开设备。对于USB转串口设备,重新打开前最好先等待设备节点重新出现,因为驱动重新枚举需要时间。
go复制func watchdog(port serial.Port, reopen func() (serial.Port, error)) {
failCount := 0
for {
time.Sleep(60 * time.Second)
_, err := port.Write([]byte("AT\r\n"))
if err != nil {
failCount++
} else {
failCount = 0
}
if failCount >= 3 {
port.Close()
time.Sleep(5 * time.Second)
port, _ = reopen()
setSmsMode(port)
failCount = 0
}
}
}
注意重新打开串口后,必须重跑一遍AT+CMGF和AT+CNMI初始化指令,因为模块断电或复位后配置会恢复默认值。少这一步的后果是模块连上了,但短信上报事件死活不触发,看起来一切正常就是收不到短信。
4. 实操过程:从零搭建一个可用的短信转发器
4.1 硬件清单与接线表:照着买、照着插
以下是我实际用到且验证过的物料清单,全部不需要焊接:
| 组成 | 推荐型号/规格 | 作用 |
|---|---|---|
| GSM扩展板 | 带排针母座的4频GSM/GRPS模块板 | 插SIM卡、收发短信 |
| USB转TTL板 | CH340或CP2102芯片的小板 | 把USB信号转成UART电平 |
| 杜邦线 | 母对母、公对母各备几条,线长10cm内 | 连接两块板 |
| 主控主机 | Linux小主机或树莓派、香橙派等开发板 | 运行转发程序 |
| SIM卡 | 任意能收短信的旧卡即可 | 接收短信 |
接线表的重点我再强调一遍,这是最容易出错的地方:
- USB转TTL板的 TX 接 GSM模块的 RX
- USB转TTL板的 RX 接 GSM模块的 TX
- USB转TTL板的 GND 接 GSM模块的 GND
- USB转TTL板的 VCC(常见是5V)接 GSM模块的 VIN(如果模块要求5V);部分模块要求4.2V直供,那就不要接VIN,改接独立的5V电源
这里有个安全提示:GSM模块在打电话或收发短信的瞬间,射频功耗会突然拉升,导致电流需求短时间冲到2A级别。USB转TTL板的供电能力通常只有几百毫安,如果直接从USB取电,模块可能在上电注册网络时反复重启。我一开始图省事,结果SIM卡注册成功率非常低,后来改用独立5V/2A电源给模块供电才稳定。如果你的模块带电池接口,插一块18650电池并联供电会更稳。
物理接线完成后的检查步骤:先看GSM模块的指示灯,正常上电搜索网络时LED会以约1Hz频率慢闪,注册成功后变成每3秒闪一下。如果LED常亮或完全不亮,检查供电和GND。
4.2 交叉编译与部署:一个二进制文件跑遍Linux开发板
Go最让人省心的地方是交叉编译一次,目标平台上直接跑,不用装运行时。我平时在电脑上编译ARM64版,然后scp到开发板上执行。
编译命令:
bash复制GOOS=linux GOARCH=arm64 go build -o sms-forwarder .
如果是树莓派或其他ARMv7设备,把GOARCH改成arm,必要时加GOARM=7。X86小主机则用GOARCH=amd64。
程序第一次运行前,先创建一个配置文件config.json,内容如下:
json复制{
"serialDevice": "/dev/ttyUSB0",
"baudRate": 115200,
"webhookUrl": "https://your-webhook.example.com/sms",
"retry": {
"initialWaitMs": 5000,
"maxRetries": 3
},
"storage": "sms-cache.db"
}
我把所有运行参数都外置到配置,这样改Webhook地址或串口设备时不用重新编译。注意serialDevice这一项,插上USB转TTL后Linux下的设备名通常是/dev/ttyUSB0,也可能因插入顺序变成ttyUSB1。如果程序报找不到设备,先执行ls /dev/ttyUSB*确认实际名称。
部署时建议注册为systemd服务,让程序开机自启、崩溃自动拉起。写一个最小的service文件:
ini复制[Unit]
Description=SMS Forwarder Service
After=network-online.target
[Service]
ExecStart=/opt/sms-forwarder/sms-forwarder
Restart=always
RestartSec=10
WorkingDirectory=/opt/sms-forwarder
[Install]
WantedBy=multi-user.target
试验阶段也可以直接前台运行,看到日志输出更直观。日志里能看到模块初始化过程、发送的AT指令、收到的响应以及每个转发动作的结果。
4.3 功能验证流程:用另一台手机发短信,逐步确认各环节
系统启动后,从另一台手机给SIM卡发一条测试短信,然后按下面顺序验证。
第一层验证看模块是否上报。串口日志里应该出现+CMTI: "SM", 1。如果没有这行,大概率是AT+CNMI没设成功,或模块还在开机搜网阶段,等LED进入3秒周期后再试一次。
第二层验证看解析是否正确。程序日志会打印号码、时间和正文,重点检查中文是否乱码。如果乱码,检查GBK转UTF-8的编码转换有没有生效。
第三层验证看Webhook是否收到。去目标端查看有没有新消息,正文和号码是否和发件信息一致。如果没收到,在配置里把webhookUrl临时改成本地测试服务器的地址,确认出网和推送代码都没问题。
整个验证流程走通后,就进入“丢角落不用管”的状态了。我自己的机器放在弱电箱旁边,运行了快两个月,只出现过一次USB转串口板被系统重新枚举导致断连,看门狗自动恢复了,没有丢短信。
5. 避坑手册:串口不通、乱码、被拦截等常见问题
5.1 模块上电后没有任何反应,AT指令发过去也没有OK返回
先从供电排查。用万用表量一下模块供电引脚的对地电压,必须在模块要求的范围内。我遇到过USB转TTL板输出5V,但模块要求4.2V,结果模块虽然上电但不稳定的问题,后来换独立电源解决。
再从接线排查。重点检查USB转TTL的TX是不是接到了模块的RX、RX接到了TX,GND有没有连通。这两类错误占了串口通信故障的九成比例。另外杜邦线外观看着插进去了,但内部金属端子可能接触不良,按压一下或用万用表通断档测一下两端是否导通。
还有一个被很多人忽略的地方:USB转TTL板上如果同时焊了TXD、RXD两个跳线帽,或者有“TXD/RXD使能”跳线,默认可能是断开状态。这类板子出厂时会跳好,但也有到手需要自己插跳线帽的型号,确认一下。
5.2 串口能收到数据但全是乱码,AT返回特征字符都不对
乱码只有一个原因:波特率不匹配。模块实际波特率是115200,程序里设成了9600,收到的比特就会“错位”,显示成乱码。把配置改成模块实际波特率即可。
少数情况是模块出厂用的DCE波特率自动协商模式,上电时主控必须以某个特定波特率发一个字符让模块判定。遇到这种模块,先在115200下发几个回车,再发AT,基本能唤醒。还有的模块支持AT+IPR=115200指令固定波特率,这个指令本身要在当前波特率下发。
5.3 短信能收到但正文乱码,或者号码显示不完整
正文乱码的根源是编码转换。文本模式下模块输出GBK编码,主控按UTF-8解释就乱码。解决方法是把串口读取的原始字节按GBK解码,再转成UTF-8字符串。Go标准库没有内置GBK解码器,需要用到golang.org/x/text/encoding/simplifiedchinese,示例代码如下:
go复制import (
"golang.org/x/text/encoding/simplifiedchinese"
)
func toUTF8(raw []byte) string {
decodeBytes, _ := simplifiedchinese.GBK.NewDecoder().Bytes(raw)
return string(decodeBytes)
}
号码显示不完整的情况,多半是号码带了+86前缀,解析时按逗号切分后没去除引号和前缀。我处理号码时会统一做一次清洗:去掉引号、去掉空格,保留+和数字;如果号码以+86开头且长度大于11位,去掉+86再展示。
5.4 模块上报了+CMTI,但根据序号读不到短信
这种问题出在存储区切换。模块默认可能把短信存在“MT”(模块终端存储区),而AT+CMTI上报的存储区名称是“SM”。如果读取时AT+CMGR=序号不带存储区参数,它会按默认存储区找,发现找不到就返回ERROR。
解决办法是在初始化时显式执行AT+CPMS="SM","SM","SM",三个参数分别指定“读取存储区”“写入存储区”“接收存储区”都为SIM卡。这样上报和读取的存储区就一致了。
另外刻意提一个关于PDU模式的坑。有些教程建议用AT+CMGF=0切到PDU模式,PDU模式下短信内容是以十六进制编码的,解析极其麻烦,而且编码格式和字符集切换容易出错。对不需要发送长短信、也不需要状态报告的场景,文本模式足够了。坚持用CMGF=1,能省掉一半调试时间。
5.5 Webhook一直失败,但短信没有丢,最后竟然是通过重试成功的
HTTP推送依赖主控能访问互联网,这本身又是一个潜在故障点。我的实践是把重试上限设置得尽量宽松:前10次重试间隔从5秒逐步加到600秒,之后进入每小时一次的慢轮询,直到成功。这个策略既避免了短时间抖动导致放弃,又不会在目标端永久宕机时频繁轰炸对方接口。
还要考虑到Webhook接口本身可能有幂等限制。如果目标端因为重复推送产生了重复数据,说明你需要在推送时带上一个唯一键,目标端基于这个键做去重。我的做法是在JSON里加一个msg_id字段,值为短信指纹值。
5.6 一个小细节:模块长时间运行后收不到短信的排查顺序
我在第3周时遇到过模块“沉默”的现象。排查顺序是这样的:先看模块指示灯是否正常,再看程序日志有没有周期性AT心跳记录,最后手动往串口发一条AT确认模块是否还活着。发现模块对AT没响应,而指示灯正常,基本可以判定是模块内部的AT命令解析器死锁了。很多GSM模块的固件做不到完全稳定,偶发死机是常态。
对付死锁,除了换固件版本,更实际的办法是加硬件看门狗。如果模块的PWRKEY引脚接在主控的一个GPIO上,主控可以在发现模块不响应时,通过控制PWRKEY拉低一段时间强制模块断电重启。不过这个接法会引入额外的引脚连接,免焊接方案里要多接一根杜邦线,看自己权衡。我的做法是省掉硬件看门狗,改成程序里每5小时定时让串口关闭重开一次,实测能把死锁概率降低很多。
6. 扩展方向:从“只收短信”到“短信网关”
把基础版跑通后,稍微动一动软件就能延展出不少玩法。
增加短信转发到多个通道:比如同时推送到Webhook、邮件和本地日志。实现上不要在推送代码里硬编码多个通道,而是定义一组Notifier接口,每个通道实现一个Push方法。这样以后加新通道只需要写一个结构体和几行代码。
增加“短信命令回执”功能:收到特定号码发来的指令短信后,主控可以把需要回复的内容通过AT+CMGS指令发回去。比如从远程给模块发一条“status”,它就回一条“running since xxx”。这个功能对没有外网条件的环境很实用。
增加本地数据展示:把收到的短信、转发状态、重试次数全部落到SQLite里,再通过一个轻量HTTP接口暴露出来,用浏览器就能查看历史记录。相当于自己做一个私有“短信网关后台”。
我个人的体会是,这个项目的价值并不在于转发短信本身,而在于它完美演示了“UART串口通信 + 状态机解析 + 可靠推送”这套嵌入式开发里最通用的链路。就算以后不玩GSM模块,把这套结构换成传感器数据上报、GPS定位数据回传,逻辑是完全可以平移的。最后分享一个小技巧:程序跑通后,记得把GSM模块的SIM卡PIN码关掉,不然模块重启后会卡在输入PIN的状态,AT指令全部失效。我就被这个问题坑过一次,查了半天才发现是PIN码锁住了SIM卡。
