CODESYS 用 ModbusFB 库实现 Modbus TCP Server(双向读写)

CODESYS 用 ModbusFB 库实现 Modbus TCP Server(双向读写)

LinJiefeng Lv3

问题:设备映射的 Modbus TCP Server 是”单向”的

最开始我是在 CODESYS 的设备连接里直接添加了一个 Modbus TCP Server 设备,然后在里面做寄存器映射。跑起来之后发现一个问题:

  • 映射通道会把数据单向覆盖:这些寄存器只能由上位机发起读写;
  • PLC 侧的程序对这些映射变量只能读,就算你在程序里给它赋值,下一轮还是会被映射关系覆盖掉。

也就是说,这是一条”上位机 → 映射区”的单行道,PLC 自己没有真正的写权限。

而在西门子那边做 Modbus TCP 服务器(比如 S7-1200/1500 的 MB_SERVER)时,我的习惯是:上位机和 PLC 程序都能对同一块 DB 读写,PLC 写的状态量上位机能看到,上位机下的设定值 PLC 也能拿到。CODESYS 设备映射这种”单向覆盖”的行为完全达不到我的预期。

问了一圈免费的 AI,回答都大而化之,结论基本一致:设备映射这条路走不通,要用第三方库(ModbusFB)自己编程搭一个 Server。后来用智谱的 GLM 拿到了一套完整可行的方案,我这边在 CODESYS 上实操验证通过,整理出来给大家参考。

思路一句话概括:用 ModbusFB 库的 ServerTCP 功能块,把普通的全局变量数组”声明”成 Modbus 四张表的数据区。数据区就是普普通通的 GVL 变量,PLC 程序随便读写,上位机通过 Modbus TCP 也可以随便读写——天然双向。

📦 添加 ModbusFB 库

  1. 在应用的项目树双击打开「库管理器」
  2. 点「添加库」
  3. 搜索库 → ModbusFB
  4. 选中 ModbusFB → 确定

ModbusFB 是 CODESYS 自带库仓库(CODESYS Store / 随安装包的库)里就能搜到的库,不需要额外下载。

🔧 新建 POU 程序

  1. 右键应用(Application)→ 添加对象 → POU
  2. 名称填 ModbusTCP,类型选「程序(Program)」
  3. 添加

📬 新建任务

不建议直接挂在 MainTask 里跑——Modbus 响应最好有自己固定周期的任务,不受主程序扫描时间波动影响。

  1. 右键「任务配置器」→ 添加对象 → 任务
  2. 命名 Task_ModbusTCP,周期建议 10~50ms
  3. 添加

💽 任务里调用程序

  1. 右键任务 → 添加程序调用
  2. 点输入助手的「…」按钮
  3. 选中 ModbusTCP 程序
  4. 确认

📝 添加数据类型和全局变量

Modbus 里数据就分四张表,先把概念对齐,后面配置才不糊:

表功能码方向典型用途
保持寄存器 Holding RegistersFC03 读 / FC06、FC16 写双向设定值、参数(上位机写,PLC 读)
输入寄存器 Input RegistersFC04 只读PLC → 上位机温度、压力等运行数据
线圈 CoilsFC01 读 / FC05、FC15 写双向启停、开关量下发
离散输入 Discrete InputsFC02 只读PLC → 上位机故障、运行状态信号

对应地建一个数据类型 + 一组全局变量:

1. 新建数据类型(DUT)

右键「应用」→ 添加对象 → DUT,命名 test_modbus:

1
2
3
4
5
6
7
8
TYPE test_modbus :
STRUCT
// 保持寄存器原始区:WORD 直接对应一个 Modbus 寄存器
awHoldingRegs : ARRAY[0..9] OF WORD;
// 浮点区:Real 占 2 个寄存器,PLC 写这里,按字拆开给上位机
awHoldingFloat : ARRAY[0..9] OF REAL;
END_STRUCT
END_TYPE

浮点数据没法直接走一个寄存器,常规做法是 PLC 写 REAL 变量,再用 WORD_TO_... 拆成高低字放进 WORD 区,或者像上面这样把 REAL 数组直接指给一段寄存器区(一个 REAL = 2 个寄存器)。拆字时注意和上位机约定好字节序(ABCD / CDAB / BADC / DCBA),这是联调时最容易对不上的地方。

2. 新建全局变量列表(GVL)

命名 ModbusDB(也叫”数据区”,它是 PLC 程序和 Modbus 客户端共享的同一块内存):

1
2
3
4
5
6
7
8
9
10
VAR_GLOBAL
// 保持寄存器 (FC03/06/16):Modbus 地址 0..19
awHoldingRegs : test_modbus;
// 输入寄存器 (FC04):Modbus 地址 0..9
awInputRegs : ARRAY[0..9] OF WORD;
// 线圈 (FC01/05/15):Modbus 地址 0..9
abCoils : ARRAY[0..9] OF BOOL;
// 离散输入 (FC02):Modbus 地址 0..9
abDiscInputs : ARRAY[0..9] OF BOOL;
END_VAR

test_modbus 里两个数组加起来是 10 + 20 = 30 个寄存器(REAL 占 2 个),这决定了后面保持寄存器分区的 uiNumDataItems 要填 30。分区数量必须 ≤ 数据区实际大小,否则客户端越界访问会触发异常应答。

✍ 编写 ModbusTCP 调用程序

1. 声明变量

1
2
3
4
5
6
7
8
9
10
11
PROGRAM ModbusTCP
VAR
fbServerTCP : ModbusFB.ServerTCP;
// ---- 数据模型定义(xEnable 上升沿时被库读取)----
aSecHR : ARRAY[0..1] OF ModbusFB.TableSection; // 保持寄存器分区
aSecIR : ARRAY[0..0] OF ModbusFB.TableSection; // 输入寄存器分区
aSecCoil : ARRAY[0..0] OF ModbusFB.TableSection; // 线圈分区
aSecDI : ARRAY[0..0] OF ModbusFB.TableSection; // 离散输入分区
stTables : ModbusFB.TableDefinitions; // 四张"主表"
xModelInit : BOOL := FALSE;
END_VAR

分区(TableSection)的意义是允许把一段连续的 Modbus 地址拆成几块内存来放,比如 0..19 放设定值、20..99 放参数,各指到不同数组。这里我每种表只用一块分区。

2. 主程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// ---------- 1. 构建数据模型(只需做一次,必须在使能之前)----------
IF NOT xModelInit THEN
// 保持寄存器:整个 test_modbus 结构体,WORD 10 个 + REAL 10 个(=20 个寄存器)
aSecHR[0].uiStart := 0; // 起始 Modbus 地址
aSecHR[0].uiNumDataItems := 30; // 10 + 10*2 = 30 个寄存器
aSecHR[0].pStartAddr := ADR(ModbusDB.awHoldingRegs); // 指到数据区
aSecHR[0].uiDataItemSize := 16; // 寄存器类 = 16 bit

aSecIR[0].uiStart := 0;
aSecIR[0].uiNumDataItems := 10;
aSecIR[0].pStartAddr := ADR(ModbusDB.awInputRegs);
aSecIR[0].uiDataItemSize := 16;

// BOOL 数组方式时 uiDataItemSize 必须填 8(填 1 表示按 bit 打包进 BYTE)
aSecCoil[0].uiStart := 0;
aSecCoil[0].uiNumDataItems := 10;
aSecCoil[0].pStartAddr := ADR(ModbusDB.abCoils);
aSecCoil[0].uiDataItemSize := 8;

aSecDI[0].uiStart := 0;
aSecDI[0].uiNumDataItems := 10;
aSecDI[0].pStartAddr := ADR(ModbusDB.abDiscInputs);
aSecDI[0].uiDataItemSize := 8;

stTables.tableHoldingRegisters.uiNumSections := 1;
stTables.tableHoldingRegisters.pSections := ADR(aSecHR[0]);
stTables.tableInputRegisters.uiNumSections := 1;
stTables.tableInputRegisters.pSections := ADR(aSecIR[0]);
stTables.tableCoils.uiNumSections := 1;
stTables.tableCoils.pSections := ADR(aSecDI[0]); // 注意别指错
stTables.tableDiscreteInputs.uiNumSections := 1;
stTables.tableDiscreteInputs.pSections := ADR(aSecDI[0]);

xModelInit := TRUE;
END_IF

// ---------- 2. 通讯配置(仅 xEnable 上升沿被读取)----------
fbServerTCP.wsInterfaceName := ''; // WSTRING:'' = 绑定所有网卡
fbServerTCP.uiPort := 502; // 标准 Modbus TCP 端口,被占用就改(如 1502)
fbServerTCP.dataModel := stTables;
// fcsSupported 不赋值时默认 Constants.SUPPORTED_FCS_SIMPLE_SERVER,
// 即 FC01/02/03/04/05/06/15/16(+22/23),一般够用

// ---------- 3. 使能 + 周期调用 ----------
fbServerTCP.xEnable := TRUE;
fbServerTCP();

// ---------- 4. 状态与诊断(可选)----------
// fbServerTCP.xRunning : 监听正常
// fbServerTCP.xError : 出错,看 eErrorID
// fbServerTCP.xWriteRequest : 本周期收到过写请求(数据已由库写入 GVL 数组)
// fbServerTCP.udiNumMsgRecv / udiNumMsgReply / udiNumMsgExcReply : 收发统计
// udiNumMsgExcReplyIllFct / IllDataAdr / IllDataValue : 三类异常应答计数
// IllFct = 非法功能码;IllDataAdr = 地址越界;IllDataValue = 数值非法

几个关键点解释一下:

  • 数据模型只在 xEnable 上升沿被库读取一次。所以第 1 步建模型必须发生在使能之前,而且运行中途改 pStartAddr、改数量都是不生效的——要改就得先 xEnable := FALSE 再重新使能。这也是用 xModelInit 一次性初始化的原因。
  • ADR() 指过去的就是普通变量。PLC 程序里对 ModbusDB.xxx 的读写和上位机的 Modbus 读写操作的是同一块内存,谁写都行,这正是设备映射做不到的双向。
  • uiDataItemSize 是最坑的一个参数:WORD/寄存器类填 16,BOOL 数组填 8(按”每元素 8 bit”算,不是 1)。填错了表现为客户端读到的数据错位。
  • pSections 指的是分区数组第一个元素的地址,多分区时连续元素依次排开即可。

💡联调建议

  1. 先用 Modbus Poll / QModMaster 之类的客户端工具连 PLC的IP:502,扫一遍 0~29 保持寄存器,确认能读到数;
  2. 客户端写一个寄存器,在 PLC 里监控 ModbusDB,确认值变了——双向通了,剩下的就是正常的 PLC 编程了;
  3. 如果读回来全是异常应答,优先查三件事:分区数量 vs 数组实际大小、uiDataItemSize 填没填对、字节序约定。

🎉大功告成

  • 标题: CODESYS 用 ModbusFB 库实现 Modbus TCP Server(双向读写)
  • 作者: LinJiefeng
  • 创建于 : 2026-11-05 21:30:44
  • 更新于 : 2026-11-06 09:22:24
  • 链接: https://blog.icsteam.cn/2026/11/05/208_codesysModbusTcpServer/
  • 版权声明: 本文章采用 CC BY 4.0 进行许可。
评论