Skip to content

综合例程:采集第三方设备(三菱 FX / Modbus RTU / Modbus TCP) ​

让设备自己周期性去读第三方 PLC 或仪表,值自动落到本机的 D / M 或 data 对象里, 梯形图、上位机、云端直接取用 —— 这是第七章四个「自动轮询」模块的通用做法。 本页把三条最常见的链路放在一起,并单独讲清一个高频误解:通道到底落在 D 还是 M (见第四节)。


一、先分清两件事 ​

自动轮询(本页)按需读写(Pipe)
谁发起固件后台线程按周期采您的脚本每次调 read_xxx / write_xxx
值放在哪通道绑定好的 D / M / data 对象函数返回值
适合连续监控、要上云、要和梯形图联动偶尔查一次、要拿每次通信的错误码

三条链路的分工是固定的:协议子设备(com_fx / com_modbus / com_dlt645)描述"读哪些点、放到哪", 串口父设备(com)负责"什么时候去读"。modbus(TCP)自己就是完整的主站,不需要 com 包装。


二、链路一:串口读三菱 FX 编程口 ​

场景:COM1 挂一台 FX 系列 PLC(9600 / 7 数据位 / 偶校验 / 1 停止位,这是编程口的常规设置), 读它的 D0 到本机 D100,D1、D2 读进 data 对象并回写。

完整代码(可直接下载运行):

lua
-- ============================================================
-- 串口读三菱 FX 编程口
-- 子设备描述"读什么",父设备决定"多久读一次"
-- ============================================================

-- ---------- 1. 子设备:三菱编程口 ----------
local fx_state = data.creat(0)                          -- 通信状态对象(建议建)
local fx = com_fx.creat(fx_state)                       -- 第 1 参:状态回写位置
fx:add_channel("D0", com_fx.S16, 100,       com_fx.READ_WRITE)  -- 对方 D0 <-> 本机 D100
local d1 = data.creat(0)
local d2 = data.creat(0)
fx:add_channel("D1", com_fx.S16, d1,        com_fx.READ_WRITE)  -- 对方 D1 <-> d1 对象
fx:add_channel("D2", com_fx.S16, d2,        com_fx.READ_WRITE)  -- 对方 D2 <-> d2 对象

-- ---------- 2. 父设备:串口 ----------
local port = com.setup(1, 9600, com.EVEN, 7, 1)        -- 编程口常用 7 位偶校验
if not port then
    print("串口打开失败,检查端口号与波特率")
    return
end
port:add_device(fx)                                     -- 必须 add_device 之后再 start
port:set_time_lunxun(1000)                              -- 1 秒一轮
port:start()                                            -- 后台线程开始自动采集

-- ---------- 3. 本机侧使用与回写 ----------
timer.start(10000, function()
    print("D0=" .. plc.d(100) .. "  D1=" .. d1:get_num() ..
          "  D2=" .. d2:get_num() .. "  状态=" .. fx_state:get_num())
    if d2:get_num() > 100 then
        d2:set(88)                                      -- 对象值一变,下轮自动写到对方 D2
    end
    d1:set(1 + d1:get_num())                             -- 累加后写回对方 D1
end)

逐段要点:

  1. add_device 只认三种子设备对象:com_modbus / com_fx / com_dlt645 的返回值。 传错类型会被静默忽略,不报错也不采集 —— 采不到数据时先回头查这一步。
  2. 通道必须在 start 之前加完,启动后再 add_channel 不保证生效。
  3. READ_WRITE 的含义是「本机值发生变化就写下去,否则读上来」,不是"一直写"。 所以同一个通道既能采集又能下发,不用分两条。
  4. 状态对象不能省:0 通常表示成功,非 0 表示异常或超时(具体值随固件版本略有差异)。 没有它就分不清"对端确实是 0"和"根本没读到"。
  5. 波特率只接受 300/600/1200/2400/4800/9600/19200/38400/57600/76800/115200, 填别的值 com.setup 返回 nil,现象和"端口不存在"一模一样。

三、链路二:串口读 Modbus RTU 仪表 ​

场景:COM2 上一台 Modbus RTU 从站(地址 1),保持寄存器 400001 双向绑定到本机 D1, 400002 绑定到 data 对象。

完整代码(可直接下载运行):

lua
-- ============================================================
-- 串口读 Modbus RTU 仪表
-- ============================================================

-- ---------- 1. 子设备 ----------
local stata = data.creat(0)                                  -- 通讯状态:0=成功,非 0=异常/超时
local modbus1 = com_modbus.creat(com_modbus.RTU, 1, stata)   -- RTU 模式,从站地址 1
modbus1:add_channel(400001, com_modbus.S16, 1,    com_modbus.READ_WRITE)  -- -> 本机 D1
local v = data.creat(0)
modbus1:add_channel(400002, com_modbus.S16, v,    com_modbus.READ_WRITE)  -- -> 对象 v
modbus1:set_time_error(1000)                                 -- 读写出错后的恢复时间

-- ---------- 2. 父设备 ----------
local port = com.setup(2, 9600, com.NONE, 8, 1)              -- 要与从站通讯格式一致
if not port then
    print("串口打开失败")
    return
end
port:add_device(modbus1)
port:start()

-- ---------- 3. 取用与下发 ----------
timer.start(1000, function()
    print("400001=" .. plc.d(1) .. "  400002=" .. v:get_num() .. "  状态=" .. stata:get_num())
    if v:get_num() > 100 then
        v:set(88)                                            -- 值一变,下轮自动写到从站
    end
end)

逐段要点:

  1. 组合地址 xyyyyy 从 1 开始数:400001 是偏移 0;线圈第 1 点直接写 1。
  2. ASCII 模式要改串口格式:com_modbus.ASCII 的从站是 7 位数据, com.setup 第 4 参要填 7,填 8 会通不了。
  3. 浮点、32 位数据读出来是乱码时,先查字序:国产设备多数是 CDAB, 用 set_float_abcd(com_modbus.CDAB)。字序函数要和通道类型配对, 给 S16 通道调 set_32_abcd 不会有任何反应。
  4. 不要在同一个串口上再手写 send / recv(裸报文例程), 轮询线程和您的报文会互相插队。
  5. 上电瞬间串口/总线可能还没就绪,不要写 system.sleep(5000) 去等 —— 脚本顶层的 sleep 是真阻塞,那 5 秒里所有回调都是死的(见并发模型概述)。 要延后就用一个单次定时器,或者直接不管:轮询线程自己会按 set_time_error 重试。

四、通道落点:位类型到 M,字类型到 D ​

add_channel 的 target 传数字时,落在 D 区还是 M 区由数据类型决定,不是固定 D 区:

通道数据类型数字 target N 的含义读写的是
BITE本机 M 元件号MN,一位
S16 / U16本机 D 寄存器号DN,一个
S32 / U32 / FLOAT本机 D 寄存器号DN 和 DN+1,两个
DOUBLE本机 D 寄存器号DN 到 DN+3,四个
传 data 对象与 D / M 无关写到该对象

所以 add_channel(1, 100002, modbus.BITE, 21, modbus.READ) 是「读从站线圈 → M21」, 本机侧要用 plc.m(21) 取,而不是 plc.d(21)。

位和字用同一段编号会打架

把位通道写成 200、又把字通道写成 200,看起来不冲突(一个是 D200、一个是 M200), 但很容易被自己或接手的人误读。建议给位通道和字通道划开两段编号, 例如位统一用 M500 起、字统一用 D1000 起,一眼能看出类型。


五、链路三:网口读 Modbus TCP 从站 ​

场景:设备通过网口连一台 Modbus TCP 从站(192.168.0.238:503), 读线圈 100001 / 100002 到本机 M20 / M21,另读保持寄存器 400001 到 D100。

完整代码(可直接下载运行):

lua
-- ============================================================
-- Modbus TCP 主站:一个对象一条 TCP 连接,连接上挂多个从站单元号也行
-- ============================================================

local dev = modbus.creat("192.168.0.238", 503)         -- 只有 IP + 端口两个参数
if not dev then
    print("建立对象失败")
    return
end

-- 位通道:数字 target 落 M 区
dev:add_channel(1, 100001, modbus.BITE, 20, modbus.READ)         -- 线圈 -> M20
dev:add_channel(1, 100002, modbus.BITE, 21, modbus.READ)         -- 线圈 -> M21
-- 字通道:数字 target 落 D 区
dev:add_channel(1, 400001, modbus.U16,  100, modbus.READ)        -- 保持寄存器 -> D100
-- 双向:本机改 D101 就会写下去
dev:add_channel(1, 400002, modbus.U16,  101, modbus.READ_WRITE)

dev:set_time_lunxun(1000)                                -- 轮询 1 秒
dev:set_time_recv(500)                                   -- 单次应答等 500ms
dev:set_time_error(1000)                                 -- 出错后 1 秒恢复
dev:start()

timer.start(2000, function()
    print("M20=", plc.m(20), "M21=", plc.m(21),
          "D100=", plc.d(100), "D101=", plc.d(101))
end)

逐段要点:

  1. modbus.creat 只有两个参数:IP 和端口。它没有"模式"参数, 本身就是 TCP 主站(RTU 走串口,用 com_modbus)。
  2. 对象必须长期持有:用全局变量或本页这种脚本顶层的 local。 若写成函数里的局部变量,函数一返回对象就被回收,连接会莫名断掉。
  3. 通道要在 start 之前全部加完。
  4. close() 是停止,停止后同一个对象不能再次 start,要恢复通信必须重新 creat。
  5. 需要"读一次拿错误码"或按需单点读,用 pipe,不是本模块。

六、错误写法 vs 正确写法对照 ​

下面这段在客户手上出现频率极高 —— 它是照着别的设备的协议手册写出来的, 一上来就报错,程序一行都不执行:

lua
-- 错误:凭空捏造的常量和函数名
modbusTcp1 = modbus.creat(modbus.TCP, "192.168.0.238", 503)   -- 建立 MODBUS RTU 串口设备
modbusTcp1:set_channel(1, 100002, modbus.BITE, 21, modbus.READ)
modbusTcp1:set_channel(1, 100001, modbus.BITE, 20, modbus.READ)
modbusTcp1:start()
timer.start(500, function()
    print(plc.m(20), plc.m(21))
end)
问题后果正确做法
modbus.TCP 这个常量不存在表达式求值为 nil,creat 第 1 参要的是字符串,直接 bad argument #1modbus.creat("192.168.0.238", 503),没有模式参数
传了 3 个参数,IP 被挤到第 2 位端口当成 IP 用、真正的端口丢失参数个数固定是两个:IP、端口
set_channel 这个函数不存在attempt to call field 'set_channel' (a nil value)叫 add_channel
注释写"RTU 串口设备"、代码用 modbus后面有人照着注释改,改成串口参数就通不了RTU 用 com_modbus + com,TCP 用 modbus
把 100002 读到 M21,注释也这么写这一步其实是对的:位通道的数字 target 确实落 M 区保持 add_channel(1, 100002, modbus.BITE, 21, modbus.READ) + plc.m(21)

修正后的完整可运行版就是第五节那段。 值得留意:原作者对"落到 M21"的理解是正确的,错的是函数名和参数 —— 这类"业务想法对、API 名字凭印象编"的写法是脚本失败的第一大来源, 写完务必对着 API 速查表 逐个核一遍函数名。


七、注意事项汇总 ​

  • add_device 的参数类型不对时静默忽略,既不报错也不采:采不到数据先查这条。
  • 通道加在 start 之后不保证生效,一律先加完通道再启动。
  • 字序设置和通道类型不配对不会报错,只会"设置了没反应"。
  • 32 位以上数据类型要占多个 D,相邻通道地址重叠时后者会被覆盖。
  • 同一从站加重复通道会被拒绝:同一个从站、同一区、同一地址只能有一条。
  • 不要拿 status 当"数据是不是 0"用:它只说明通信成不成功,成功时数据完全可能就是 0。
  • 不要为了等待硬件就绪在脚本顶层写 while ... do system.sleep(...) end: 这是阻塞主循环,会冻死所有回调。真要等,用定时器反复检查,见 并发模型概述。
  • 从站是别人的 PLC 时,改 READ_WRITE 通道等于直接改对方寄存器, 上线前务必确认地址表,避免误写运行中的设备。

八、相关文档 ​

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