Skip to content

综合例程:串口裸报文收发(同步 / 异步 / 十六进制) ​

对端不是 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)

逐段要点:

  1. com.setup 打开失败返回 nil,必须先判空,否则后面每一次 send 都报错。
  2. set_timeout(100) 是 recv 的等待上限;传小于 100 的值会被强制按 100 处理。
  3. recv 有两个返回值:数据串和字节数。用第二个返回值判长度,不要用 #data—— 报文里含 \0 时 #data 会在 \0 处截断,长度就错了。
  4. 单次最多读 1024 字节,一帧更长时要循环读到空为止。
  5. 这段代码在定时器回调里执行,recv 等待的那 100ms 里整台设备的其它回调都不推进。 100ms 可以接受;需要等几百毫秒以上,改范式二。

验证方法:

  1. 下载运行,日志每 1 秒出现一对 发送: 010300000001840A 共 8 字节 / 收到: …。
  2. 拔掉对端(或接一个不响应的设备):应看到 本轮无应答,且程序不报错、继续跑。
  3. 把 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)

逐段要点:

  1. reg 会另外起一条接收线程,收到数据就投递事件,主循环里调用您的回调, 所以不会占用等待时间,多台设备并存也互不拖累。
  2. reg 的回调参数只有一个 string(收到的原始字节),不带长度, 长度用 #recv 取。异步路径不存在 \0 截断的歧义问题,但拼包逻辑仍要自己写。
  3. 同一个串口上回调和 recv 会抢同一份数据,混用会随机丢包。 选了 reg 就不要再调 serial:recv()。
  4. 只保留最后一次注册的回调,重复 reg 会覆盖前一个。
  5. 回调里写报错不会中断程序,只会输出一行以 ERR: 开头的日志—— 排查时先找 ERR:,别看没崩就以为对了。

验证方法:

  1. 下载运行,从对端(或串口调试助手)随便发几个字节,日志立刻出现 收到 N 字节: …。
  2. 同时保留那个 1 秒定时器:能看到「主动发」和「被动收」互不影响, 这是与范式一最直观的区别。
  3. 故意在回调里写一句 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)优先用第七章,省事得多。
  • 要用远程透传给别的设备编程时,见 综合例程:串口透传(本地双串口 / 远程 / 网口),不要自己另写转发循环。

七、相关文档 ​

LPPLC 工业物联网云平台 · 软硬件与云平台全栈自研