在绝大多数工业自动化场景中上位机 (Host Computer)会作为Modbus 主机 (Master)或客户端 (Client)来与 PLC (可编程逻辑控制器) 进行通信。 典型的通信架构------------------- Modbus TCP/RTU ---------------- | | | | | 上位机 (PC) | | PLC (从机) | | - SCADA系统 | | - 控制设备 | | - HMI界面 | | - 采集数据 | | - 监控软件 | | - 执行命令 | | - 数据库 | | | | - 用户应用程序 | | | | | | | ------------------- ---------------- (Master/Client) (Slave/Server) 为什么上位机通常是 Master集中控制与监控上位机通常运行着 SCADA (监控与数据采集) 系统或 HMI (人机界面)。它的核心任务是收集PLC的状态信息如传感器读数、设备运行状态并下发控制命令如启动/停止电机、设置参数。这种“主动查询”和“主动下发”的行为决定了它必须是 Master。数据聚合一个上位机可能需要同时与多个PLC、仪表、传感器通信。由上位机统一发起请求可以更好地协调和管理这些通信会话。协议设计Modbus 的主从模式本身就适用于这种“中心化”的控制系统。Master 负责轮询各个 Slave确保数据的及时获取和指令的有效传达。 上位机上的典型应用SCADA 软件 (如 WinCC, Wonderware, Ignition)用于工厂级的监控和数据历史记录。HMI 软件 (如 Vijeo Designer, Pro-face)提供直观的操作界面供操作员使用。定制开发的应用程序 (用 C, C#, Python, Java 等编写)实现特定的数据处理、分析或与企业级系统的集成。你写的 C Modbus 客户端就属于这一类。数据库用于存储从 PLC 收集来的历史数据。 通信过程上位机 (Master)向特定地址的PLC (Slave)发送请求例如读取保持寄存器 40001-40010 的值。PLC (Slave)接收到请求后根据请求内容功能码读取内部数据或执行相应动作。PLC (Slave)将结果或确认信息发送回上位机 (Master)。上位机 (Master)接收并处理来自 PLC 的数据更新 HMI 界面或存储到数据库。总结在 Modbus 通信中上位机如 PC 上的软件几乎总是扮演 Master/Client 的角色而 PLC、仪表等现场设备则扮演 Slave/Server 的角色。这是工业自动化领域最常见和最基础的通信模式。好的我来详细解释一下 Modbus 中的这些核心概念——数据区类型。这是理解 Modbus 协议的关键。 Modbus 数据模型Modbus 协议定义了四种主要的数据区每种数据区代表不同类型的数据具有不同的读写特性。你可以把它们想象成 PLC 内部的四个不同类型的“仓库”或“表格”。数据区类型 (Type)功能码 (Function Codes)访问方式数据单位描述典型应用线圈 (Coils)读: 0x01, 写: 0x05, 0x0F读/写1 位 (Bit)可以被读取和写入的开关量只有 ON (1) 或 OFF (0) 两种状态。控制继电器、启动/停止电机、控制指示灯、读取数字输入开关状态。离散输入 (Discrete Inputs)读: 0x02只读1 位 (Bit)只能被读取的开关量通常对应外部不可改变的输入信号。读取按钮状态、门开关、传感器干接点输出。保持寄存器 (Holding Registers)读: 0x03, 写: 0x06, 0x10读/写16 位 (Word)可以被读取和写入的数值。一个寄存器可以存储一个 16 位的整数 (0 到 65535 或 -32768 到 32767)。存储和修改设定值、PID 参数、计数器值、温度目标值、读取或设置模拟量输出。输入寄存器 (Input Registers)读: 0x04只读16 位 (Word)只能被读取的数值通常对应外部模拟量输入或传感器的测量值。读取模拟量传感器数据如温度、压力、流量、液位。 详细讲解1. 线圈 (Coils)本质一个可以被控制的开关。数据只有两种状态 ——1 (ON/TRUE)或0 (OFF/FALSE)。地址范围通常表示为00001到09999(注意这是传统表示法实际地址从 0 开始)。作用写 (Output)上位机可以向 PLC 的某个线圈地址发送指令将其置为 ON 或 OFF从而控制外部设备如打开/关闭阀门、启动/停止马达。读 (Input)上位机可以读取 PLC 内部某个线圈的状态了解之前发出的指令是否被执行或者查看 PLC 内部逻辑运算的结果。C 代码示例关联readCoils()和writeSingleRegister()(虽然名字叫寄存器但功能码 0x05 用于写单个线圈)。2. 离散输入 (Discrete Inputs)本质一个只能被读取的开关。数据只有两种状态 ——1 (ON/TRUE)或0 (OFF/FALSE)。地址范围通常表示为10001到19999。作用只读 (Input)用于读取外部连接到 PLC 的输入设备的状态。这些设备的状态不由 PLC 控制而是由外部物理条件决定例如按下按钮、门打开。C 代码示例关联readDiscreteInputs()(虽然上面的代码没有直接体现但modbus_read_input_bits对应此功能)。3. 保持寄存器 (Holding Registers)本质一个可以被读取和修改的变量。数据一个 16 位的数值。可以是无符号整数 (0 到 65535) 或有符号整数 (-32768 到 32767)也可以用来存储两个 ASCII 字符。地址范围通常表示为40001到49999。作用读/写 (Input/Output)这是最灵活的数据区。上位机可以读取其中的数值如当前温度、设定的压力值也可以写入新的数值如设定新的目标温度、修改 PID 参数。C 代码示例关联readHoldingRegisters()和writeSingleRegister()/writeMultipleRegisters()。4. 输入寄存器 (Input Registers)本质一个只能被读取的变量。数据一个 16 位的数值。地址范围通常表示为30001到39999。作用只读 (Input)主要用于读取连接到 PLC 的模拟量输入模块的数据。例如一个温度传感器连接到 PLC 的 AI (Analog Input) 模块其测量到的温度值会被转换成一个数字量存储在某个输入寄存器中供上位机读取。C 代码示例关联readInputRegisters()(虽然上面的代码没有直接体现但modbus_read_input_registers对应此功能)。 重要提示地址编号虽然协议规范中提到0xxxx,1xxxx,3xxxx,4xxxx的编号方式但在实际编程时例如使用libmodbus传递给函数的地址通常是从 0 开始的偏移量。例如如果你想读取40001号保持寄存器你传入的地址参数是0读取40002传入1以此类推。请务必查阅你所使用的库的文档。数据长度线圈和离散输入是位 (Bit)而寄存器是字 (Word, 16位)。如果需要传输更大的数据如 32 位浮点数通常需要占用两个连续的保持寄存器。这是个非常好的观察你的疑惑在于协议规定的数据单位bit vs word与代码中使用的数据类型uint8_t vs uint16_t之间的关系。让我来解释一下 实际编程中的数据打包方式虽然 Modbus 协议将线圈 (Coils) 和离散输入 (Discrete Inputs) 定义为位 (Bit)但在实际的网络传输和 API 接口设计中为了效率和便利性它们通常被打包成字节 (Byte)进行处理。1. 读操作 (readCoils,readDiscreteInputs)协议层面你想读取 10 个线圈的状态例如[1, 0, 1, 1, 0, 0, 0, 1, 1, 0]。传输层面Modbus 协议会将这 10 个位打包。前 8 个位10110001会被打包成一个字节0xB1剩下的 2 个位10会被打包成另一个字节的最低两位例如0x02。API 接口libmodbus的modbus_read_bits函数就是这样设计的。它接收一个uint8_t*数组作为输出缓冲区。为什么用uint8_t效率CPU 通常按字节处理数据而不是按位。将 8 个位打包成一个字节可以提高传输和处理效率。标准化这是一种行业标准做法。即使原始数据是位API 也倾向于返回字节数组。数组大小如果你要读取 N 个位实际需要的字节数是(N 7) / 8向上取整。std::vectoruint8_t很好地适应了这种动态大小的需求。// 示例读取 10 个线圈std::vectoruint8_tresult;// 假设调用 readCoils(0, 10, result)// result.size() 将是 ceil(10/8) 2// result[0] 可能是 0xB1 (对应前8个位)// result[1] 可能是 0x02 (对应后2个位其余位为0)2. 读寄存器操作 (readHoldingRegisters,readInputRegisters)协议层面每个寄存器是 16 位 (2 字节)。传输层面数据按 16 位为单位打包传输。API 接口libmodbus的modbus_read_registers函数直接接收一个uint16_t*数组作为输出缓冲区。为什么用uint16_t匹配数据单位完全匹配。每个元素正好代表一个 16 位的寄存器值。方便可以直接使用这些uint16_t值进行计算、比较等操作无需额外的解包步骤。// 示例读取 3 个保持寄存器std::vectoruint16_tresult;// 假设调用 readHoldingRegisters(0, 3, result)// result.size() 将是 3// result[0], result[1], result[2] 分别对应三个寄存器的值3. 写操作 (writeSingleCoil,writeMultipleCoils,writeSingleRegister,writeMultipleRegisters)写单个线圈 (writeSingleCoil)由于只写一个位libmodbus的modbus_write_bit函数期望一个int类型的值0表示 OFF非0表示 ON。在你的封装中value ? 1 : 0就是将bool转换为int。写多个线圈 (writeMultipleCoils)你将std::vectorbool转换为std::vectoruint8_t这与读操作的打包逻辑类似。libmodbus的modbus_write_bits期望一个const uint8_t*作为输入源。虽然std::vectorbool在 C 中是特化的内部可能也是按位存储但转换为uint8_t保证了数据的正确传递给底层库。写寄存器 (writeSingleRegister,writeMultipleRegisters)这些操作非常直观因为uint16_t完全符合寄存器的 16 位宽度要求。 总结协议定义Modbus 规定了线圈/离散输入是位寄存器是16 位。API 实现为了高效处理libmodbus等库将位打包成字节 (uint8_t)进行传输和接收将寄存器作为16 位整数 (uint16_t)进行传输和接收。你的代码你选择uint8_t作为线圈和离散输入的容器类型uint16_t作为寄存器的容器类型这是完全正确且符合标准的做法它准确反映了底层 API 的要求和数据的实际组织形式。简而言之协议是抽象的逻辑模型而代码实现要考虑具体的传输效率和编程接口的设计。