主题
综合例程:串口裸报文收发(同步 / 异步 / 十六进制)
对端不是 Modbus、三菱、电表这些标准协议,而是一台私有协议的板子(扫码枪、称重仪表、 自行开发的控制器、蓝牙模块)时,就不走第七章的自动轮询, 而是直接用
com自己发、自己收。 本页给三种写法,并重点解决一个高频翻车点:十六进制报文到底怎么转(见第五节)。
一、场景与选型
| 您要做的事 | 用哪种范式 | 理由 |
|---|---|---|
| 一问一答,发了就等回音 | 范式一 同步 | 代码最短,好对照调试 |
| 对端主动上报,随时来数据 | 范式二 异步 | 不占用时间,多台设备也不互相拖累 |
报文是二进制(带 0x00、0xFF) | 前两种都可以,但要按第四节处理 | 打印和拼串都不能直接用 |
先记住一条边界:一旦某个串口用 add_device + start 挂了协议子设备, 就不要再在脚本里对同一个串口手动 send —— 轮询线程和您的代码会抢同一条 485 总线。 反过来,本页这三种范式都是「不挂子设备」的裸串口用法。
二、范式一:同步(发送后立刻读)
场景:每秒向对端发一条查询帧 01 03 00 00 00 01 84 0A,读完把报文打印出来。
完整代码(可直接下载运行):
lua
-- ============================================================
-- 串口裸报文:同步范式
-- 端口 1,9600 无校验 8 数据位 1 停止位(按现场改)
-- ============================================================
local serial = com.setup(1, 9600, com.NONE, 8, 1)
if not serial then
print("串口打开失败,检查端口号与占用")
return
end
serial:set_timeout(100) -- 接收超时 100ms
-- 查询帧写成十六进制文本,发之前转成字节
local QUERY = "010300000001840A"
timer.start(1000, function()
local cmd = string.tohex(QUERY) -- 十六进制文本 -> 字节串
serial:send(cmd)
print("发送:", QUERY, "共", #cmd, "字节")
local data, len = serial:recv() -- 在 set_timeout 内等待
if len > 0 then
print("收到:", string.fromhex(data)) -- 字节串 -> 十六进制文本
else
print("本轮无应答")
end
end)逐段要点:
com.setup打开失败返回nil,必须先判空,否则后面每一次send都报错。set_timeout(100)是recv的等待上限;传小于 100 的值会被强制按 100 处理。recv有两个返回值:数据串和字节数。用第二个返回值判长度,不要用#data—— 报文里含\0时#data会在\0处截断,长度就错了。- 单次最多读 1024 字节,一帧更长时要循环读到空为止。
- 这段代码在定时器回调里执行,
recv等待的那 100ms 里整台设备的其它回调都不推进。 100ms 可以接受;需要等几百毫秒以上,改范式二。
验证方法:
- 下载运行,日志每 1 秒出现一对
发送: 010300000001840A 共 8 字节/收到: …。 - 拔掉对端(或接一个不响应的设备):应看到
本轮无应答,且程序不报错、继续跑。 - 把
set_timeout改成2000作为对照:等待期间另一个定时器明显停摆—— 这就是同步方式的代价,看到这种现象说明您该换范式二。
注意事项:
- 忘记
set_timeout,默认 100ms,慢设备会读成空。 - 把十六进制文本直接
send:发出去的是 ASCII 字符,对端收到的是一串可读字母而不是报文。 - 用
#data判长度:含\0的报文会短一截。
三、范式二:异步(注册回调接收)
场景:对端是一台会主动吐数据的设备(扫码枪、GPS 模块),数据随时来,不能靠轮询去等。
完整代码(可直接下载运行):
lua
-- ============================================================
-- 串口裸报文:异步范式
-- 收:注册回调,数据来了固件自动叫你
-- 发:仍然可以在定时器里主动发
-- ============================================================
local serial = com.setup(1, 9600, com.NONE, 8, 1)
if not serial then
print("串口打开失败")
return
end
-- 收:注册一个回调,参数就是收到的原始字节串
serial:reg(function(recv)
print("收到", #recv, "字节:", string.fromhex(recv))
-- 在这里按对端协议解析,例如判断帧头帧尾后拆分
end)
-- 发:主动查询照常写在定时器里
local QUERY = "010300000001840A"
timer.start(1000, function()
serial:send(string.tohex(QUERY))
end)逐段要点:
reg会另外起一条接收线程,收到数据就投递事件,主循环里调用您的回调, 所以不会占用等待时间,多台设备并存也互不拖累。reg的回调参数只有一个 string(收到的原始字节),不带长度, 长度用#recv取。异步路径不存在\0截断的歧义问题,但拼包逻辑仍要自己写。- 同一个串口上回调和
recv会抢同一份数据,混用会随机丢包。 选了reg就不要再调serial:recv()。 - 只保留最后一次注册的回调,重复
reg会覆盖前一个。 - 回调里写报错不会中断程序,只会输出一行以
ERR:开头的日志—— 排查时先找ERR:,别看没崩就以为对了。
验证方法:
- 下载运行,从对端(或串口调试助手)随便发几个字节,日志立刻出现
收到 N 字节: …。 - 同时保留那个 1 秒定时器:能看到「主动发」和「被动收」互不影响, 这是与范式一最直观的区别。
- 故意在回调里写一句
error("测试"):日志出现ERR:,程序仍在跑,据此确认错误是被吞掉的。
注意事项:
- 一边
reg一边recv:数据被抢走,看起来"随机收不到"。 - 在多处对同一个串口
reg,以为会叠加,实际只剩最后一个。 - 把回调当成"一次收到完整一帧":串口是按到达时机切块的, 一帧可能被拆成两次回调,也可能两帧并成一次,必须自己做粘包/拆包。
四、十六进制报文的正确转换
这是裸串口最容易翻车的地方。设备上同时存在两组转换函数, 名字相似但方向相反,抄错代码就得到一堆看不懂的字符。
| 您想要的方向 | 用这个 | 结果长什么样 |
|---|---|---|
| 十六进制文本 → 字节串(发之前) | string.tohex("0103…") | 8 个原始字节 |
| 字节串 → 十六进制文本(打印收到的) | string.fromhex(recv) | 大写,如 01030000 |
| 十六进制文本 → 字节串(发之前) | encoder.to16hex("01 03 …") | 忽略空格等非法字符 |
| 字节串 → 十六进制文本(打印收到的) | encoder.tohex(recv) | 小写,如 01030000 |
| 字节串 → 十六进制文本(大写) | encoder.from16hex(recv) | 大写 |
名字和方向是反的,别按字面猜
string.tohex / string.fromhex 与 encoder.tohex / encoder.fromhex方向完全相反:string.tohex 是「十六进制转成字节」, 而 encoder.tohex 是「字节转成十六进制」。 encoder 里的 from16hex / to16hex 同样是名字与直觉相反的那一对。 所以照搬别人代码时,先看它有没有 string. 前缀,不要只看函数名。
两种风格怎么选:
lua
-- 风格 A:string 版,可用冒号语法糖,写在报文常量旁很紧凑
local cmd = ("010300000001840A"):tohex()
print("收到:", recv:fromhex())
-- 风格 B:encoder 版,处理"带空格 / 带 0x 前缀"的人工输入更稳
local cmd = encoder.to16hex("01 03 00 00 00 01 84 0A")
print("收到:", encoder.tohex(recv)) -- 小写输出要点:
- 一份脚本里只用一种风格,混用是事故源头。
- 大小写有要求时:
string.fromhex和encoder.from16hex出大写,encoder.tohex出小写。 - 来源是人工输入、带空格或分隔符 → 用
encoder.to16hex(宽松,跳过非法字符); 要求严格校验 → 用encoder.fromhex(奇数长度或非法字符直接报错,外面要pcall)。 string.tohex会就地把它拿到的那段字符串转成大写。Lua 里内容相同的字符串是同一个对象, 所以转十六进制文本(本来就只关心 0-9/A-F)没问题,但不要把还要原样使用的普通文本变量递给它。
五、错误写法 vs 正确写法对照
下面这段是真实会跑通、但结果完全不对的写法,比报错更难查:
lua
-- 错误:把十六进制文本当字节串发出去了
local serial = com.setup(1, 9600, com.NONE, 8, 1)
serial:send("010300000001840A") -- 发出去是 16 个 ASCII 字符
local data = serial:recv()
print("收到:", data) -- 打印出一堆乱码,有时直接把终端搞乱| 问题 | 后果 | 正确做法 |
|---|---|---|
报文常量没转换就 send | 发的是 0、1、0、3 这些字符(0x30 0x31 …),长度翻倍,对端校验必然不过 | serial:send(string.tohex("010300000001840A")) |
收到的原始字节直接 print | 终端上出现乱码、响铃,甚至把后续日志挤乱 | print(string.fromhex(data)),先转成可打印文本 |
print("收到:", data) 用 #data 判断有没有收到 | 报文首字节是 0x00 时长度为 0,被误判成"没收到" | 用 recv 的第二个返回值 len |
把 encoder.tohex 和 string.tohex 记成同一个意思 | 转换方向反了,发出去的是十六进制文本的十六进制 | 见第四节对照表 |
还有一个报错型错误,出现频率最高:
lua
-- 错误:没判空
local serial = com.setup(99, 9600) -- 端口不存在,返回 nil
serial:send("xx") -- attempt to index a nil value (local 'serial')com.setup 的波特率只接受 300/600/1200/2400/4800/9600/19200/38400/57600/76800/115200, 填别的值同样返回 nil,和"端口不存在"看不出区别,所以判空之后要回头核对波特率是否在列表里。
六、注意事项汇总
- 停止位当前一律按 1 位处理,
com.setup第 5 参填2不会报错,但也不会生效。 - 数据位只有
7/8有效;某些 ASCII 码或私有协议要 7 位,填错表现是"收得到但对不上"。 - 同一个端口重复
setup之前应先close旧对象,否则第二次打开失败拿到nil。 - 同步
recv的等待时间是全设备共享的,把set_timeout设成几秒,MQTT 下发、 梯形图交互都会跟着卡住;需要长等待就改异步。 - 裸串口方式没有 CRC、没有重发,帧校验和超时重试都要自己在回调里写。 能套进标准协议的(Modbus / FX / DLT645)优先用第七章,省事得多。
- 要用远程透传给别的设备编程时,见 综合例程:串口透传(本地双串口 / 远程 / 网口),不要自己另写转发循环。
七、相关文档
- com — 串口基础:全部函数与常量
- encoder — 编码转换:
tohex/fromhex/to16hex/from16hex/ Base64 - string — 字符串库:
string.tohex/string.fromhex - com_modbus — Modbus RTU / ASCII:标准协议自动轮询
- 综合例程:串口透传(本地双串口 / 远程 / 网口)
- 综合例程:Modbus 采集 + MQTT 上报