简介:基于C++实现的Modbus TCP服务器工程,面向工业自动化与网络通信开发者,适用于PLC、传感器等设备之间通过以太网标准端口进行数据采集与控制,也可作为相关课程与毕业设计的实验素材。压缩包共14个文件,核心由5个C++源文件与5个头文件构成,配合Visual Studio工程文件(sln/vcproj)和说明文档(md/txt)即可编译运行,整体仅13KB,轻量易读,源码结构便于逐模块阅读。该项目已有680人学习下载,对工业通信入门与二次开发均具参考价值。源码覆盖Modbus协议核心流程,包括监听502端口、接受客户端连接、解析MBAP报文、执行读写寄存器操作、组装响应并返回错误代码,还提供调试与日志模块,便于观察请求响应细节和排查异常。工程采用C++实现,逻辑分层清晰,既适合作为学习Modbus TCP协议栈和C++网络编程的参考范例,也可在此基础上扩展寄存器映射、增加异常处理或集成到实际工业控制系统中,兼顾教学与工程应用。
1. 一个能扛并发请求的Modbus TCP服务器是怎么实现的
在产线上调一台老设备时我遇到过这样的场景:设备只提供Modbus TCP接口,但调试软件一连接,上位机那边就立刻掉线,因为设备本身只允许一个主站会话。当时手头没有现成的网关,只能自己写一个服务器端把设备协议包一层。这个用C++实现的modbus-tcp-server,就是在类似场景里能直接派上用场的东西——它基于标准socket完成Modbus TCP协议栈,不依赖第三方库,一个可执行文件跑起来就能当从站,支持多客户端并发访问,读保持寄存器、写单个寄存器、写多寄存器、读线圈这些常规功能码都覆盖了,还留了私有功能码扩展口。想用上位机联调、做设备模拟器、或者给老设备做协议转换的开发者,能在几分钟内把它跑起来,但真正值得沉下心看的,是它在线程模型、寄存器锁和帧解析上的组织方式。
2. 技术选型与整体架构:从socket监听到寄存器状态表的完整链路
2.1 为什么用C++而不是Python的pymodbus
网上Modbus TCP的Demo有很多,Python的pymodbus最简单,起一个ModbusServer就完事。但问题出在部署环境:很多工控机是Windows老系统或者嵌入式Linux,Python解释器和pip包安装都是额外负担,更别说性能瓶颈——当某个客户端把全部寄存器以100ms周期轮询时,Python的GIL会在多连接场景下明显放大延迟。
这个项目坚持用C++写,核心原因有三个:一是编译产物是单一二进制,拷到目标机器直接运行,不用装任何运行时;二是标准库自带的线程原语就能解决多客户端并发,不用引入Boost.Asio或者Qt网络模块;三是Modbus帧本身就是紧凑的二进制结构,C++的裸指针和字节操作能让解析代码和协议文档一一对应,调试时脑子里想的是TCP字节流,写出来的就是memcpy和移位。
当然这也带来一个代价:所有内存管理、字节序转换、锁粒度都要自己负责。项目里对这部分做了很克制的处理——不搞对象池,不搞异步回调,就用thread-per-connection配合读写锁,把复杂度压在能一眼看懂的程度。
2.2 代码目录结构与模块职责划分
整个工程的结构非常紧凑,我第一次打开的时候只花了十分钟就理清了每个文件干什么:
modbus-tcp-server/ ├── CMakeLists.txt # 构建脚本,无需第三方依赖 ├── src/ │ ├── main.cpp # 入口:解析命令行参数、启动服务 │ ├── modbus_server.h # ModbusTcpServer类声明 │ ├── modbus_server.cpp # 服务器核心:socket监听 + 客户端线程调度 │ ├── register_table.h # 寄存器/线圈状态表定义 │ ├── modbus_frame.cpp # MBAP头解析、PDU构造、CRC无关 │ ├── modbus_handlers.cpp # 各功能码的业务处理函数 │ └── logger.h # 简单的日志宏,支持调试帧输出 └── scripts/ └── test_modbus.py # Python脚本客户端,做自动化回归验证main.cpp只负责读配置和启动,modbus_server.cpp负责network生命周期,modbus_handlers.cpp关注协议语义。注意它的一个设计习惯:把网络接收和协议解析完全分开。recv回来的原始字节先进frame队列,由解析层去拆MBAP头,处理函数永远面对的是已经拆好的“请求结构体”,这样后面想加TLS、加串口RTU转发,都不需要碰业务逻辑。
2.3 协议核心:Modbus TCP报文格式与寄存器状态表设计
Modbus TCP的报文比RTU简单很多,没有CRC,只在TCP头后面加了一个MBAP头。MBAP头共7字节:事务ID(2字节)、协议ID(2字节,固定0x0000)、长度(2字节,值为后面字节总数)、单元标识符(1字节)。长度字段是这里最容易踩坑的地方,它统计的是“单元标识符 + PDU”的字节数,不包含自身。
这个项目把协议映射成了下面几张表,运行时的所有读写都在这几块内存上完成:
// register_table.h struct RegisterTable { // 使用vector而不是裸数组,附近线程安全由外部锁保证 std::vector<uint16_t> holding; // 保持寄存器区,支持03/06/10功能码 std::vector<uint16_t> input; // 输入寄存器区,只读,走04功能码 std::vector<uint8_t> coils; // 线圈区,支持01/05/0F功能码 std::vector<uint8_t> discrete; // 离散输入区,只读,走02功能码 // C++17的读写锁,读读共享、写写互斥 mutable std::shared_mutex rwLock; };这四块的默认大小都可以用命令行参数指定。选择vector是因为Modbus地址空间是线性连续的一段,vector配合下标就天然对应“地址+偏移量”的寻址方式。state table的key就一个起始地址和数量,不存在稀疏存储的命名空间问题。
值得注意的设计决定是:所有功能码处理函数都不直接访问vector,而是统一经过一个withReadLock或者withWriteLock的入口,把锁的获取集中在两个模板函数里。这样做的好处是后面一旦想换成数据库存储或者跨进程共享内存,只需要改这两个入口,上层业务代码一行不动。
3. 核心模块拆解:一个请求从进入到返回的完整链路
3.1 线程模型:主监听线程与工作线程的协作方式
服务器采用Windows/Linux通用的socket模型:主线程只做accept(),每来一个客户端就启动一个独立线程去处理,客户端断开则线程退出。这种模型没有epoll那么省资源,但对Modbus这种低吞吐、高可靠的工业通讯足够了,而且线程间天然隔离,一个客户端崩了不会拖垮其他会话。
// modbus_server.cpp void ModbusTcpServer::start(int port) { server_fd_ = socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(server_fd_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(port); addr.sin_addr.s_addr = htonl(INADDR_ANY); bind(server_fd_, (sockaddr*)&addr, sizeof(addr)); listen(server_fd_, 16); LOG_INFO("Modbus TCP server listening on port %d", port); while (running_) { sockaddr_in client_addr{}; socklen_t len = sizeof(client_addr); int client_fd = accept(server_fd_, (sockaddr*)&client_addr, &len); if (client_fd < 0) { if (errno == EINTR) continue; // 被信号打断,继续等待 LOG_ERROR("accept failed: %s", strerror(errno)); break; } // 每 accept 一个连接就创建一个线程 std::thread(&ModbusTcpServer::client_session, this, client_fd, client_addr) .detach(); } }代码逻辑并不复杂:setsockopt设置的SO_REUSEADDR很关键,否则频繁重启服务器时端口来不及释放,会报“Address already in use”。accept()返回的client_fd是每个会话独立的socket句柄,直接传给client_session线程处理。这里用的是detach线程,线程对象不joiner,必须在client_session内部确保用完即close。
有个隐患在while(running_)里埋着:如果accept()被某个信号(比如SIGPIPE)中断返回-1,线程会误以为服务要退出。上面代码里做了errno == EINTR判断来continue,这是从一个现场崩溃中捞回来的教训,后面会细说。
3.2 关键代码:报文解析与功能码分发
客户端线程的工作分为两步:先读取完整帧,再按键分发。这里的核心难点是TCP粘包/半包,read函数必须知道“该读多少字节才是一次完整的Modbus请求”。Modbus TCP的帧边界由MBAP头的length字段来定,因此读取顺序是:
// modbus_server.cpp bool ModbusTcpServer::recv_request(int fd, ModbusRequest& req) { uint8_t mbad[6]; // 先读6字节头(事务ID2 + 协议ID2 + 长度2) if (recv_n(fd, mbad, 6) <= 0) return false; req.transaction_id = (mbad[0] << 8) | mbad[1]; req.protocol_id = (mbad[2] << 8) | mbad[3]; uint16_t length = (mbad[4] << 8) | mbad[5]; if (req.protocol_id != 0) { LOG_WARN("drop frame with invalid protocol id %d", req.protocol_id); return false; } if (length < 2 || length > MAX_PDU_SIZE) { LOG_WARN("invalid frame length %d", length); return false; } // length 包含“单元标识符1字节 + PDU”,所以要再读 length 个字节 std::vector<uint8_t> body(length); if (recv_n(fd, body.data(), length) <= 0) return false; req.unit_id = body[0]; req.function_code = body[1]; req.data.assign(body.begin() + 2, body.end()); return true; }这段代码体现了frame层和逻辑层的边界:解析器认定length字段是“剩余字节数”,于是完整读后,才套进ModbusRequest。recv_n是一个内部小工具函数,循环调用recv()直到读满指定字节数,专门解决半包问题,只有它返回正确字节数,上层才能放心组包。如果是一次完整的帧,接下来就交给dispatcher:
// modbus_handlers.cpp void ModbusTcpServer::process_request(int fd, const ModbusRequest& req) { ModbusResponse resp; resp.function_code = req.function_code; int status = STATUS_OK; switch (req.function_code) { case 0x01: status = read_coils(req, resp); break; case 0x02: status = read_discrete_inputs(req, resp); break; case 0x03: status = read_holding_registers(req, resp); break; case 0x04: status = read_input_registers(req, resp); break; case 0x05: status = write_single_coil(req, resp); break; case 0x06: status = write_single_register(req, resp); break; case 0x0F: status = write_multiple_coils(req, resp); break; case 0x10: status = write_multiple_registers(req, resp); break; default: // 私有功能码入口,默认返回非法功能码 status = handle_private_function(req, resp); } // 出错时把异常码带回 send_response(fd, req, (status == STATUS_OK) ? resp : make_exception_response(req, status)); }功能码分发用switch就是最合适的可读性方案。所有处理函数返回int状态码,成功返回STATUS_OK,失败返回Modbus标准异常码(0x01非法功能、0x02非法地址、0x03非法值等)。最后统一组装响应帧,这样主循环里不需要散落异常处理逻辑。
私有功能码的默认处理函数只返回一个异常帧。这个设计意图很明确:二次开发时不需要修改dispatcher,直接在handle_private_function里按function_code再写一层switch,就能接入自定义逻辑。
3.3 关键代码:寄存器读写的锁设计与字节序处理
Modbus协议规定所有多字节字段都是大端序,这是老生常谈,但实现时很容易出错。比如读保持寄存器时,每个16位值必须拆成高字节在前、低字节在后:
// modbus_handlers.cpp int ModbusServer::read_holding_registers(const ModbusRequest& req, ModbusResponse& resp) { uint16_t start = (req.data[0] << 8) | req.data[1]; uint16_t count = (req.data[2] << 8) | req.data[3]; if (count == 0 || count > 125) { // Modbus标准规定一次最多125个寄存器 return STATUS_ILLEGAL_VALUE; } std::shared_lock lock(table_.rw_lock); // 读锁,多客户端并发读取不冲突 if ((size_t)start + count > table_.holding.size()) { return STATUS_ILLEGAL_ADDRESS; } // byteCount的值是 count * 2,每个寄存器占两字节 uint8_t byte_count = count * 2; resp.data.push_back(byte_count); for (uint16_t i = 0; i < count; ++i) { uint16_t value = table_.holding[start + i]; resp.data.push_back((value >> 8) & 0xFF); // 高字节 resp.data.push_back(value & 0xFF); // 低字节 } return STATUS_OK; }这里用std::shared_lock是本项目的精髓:Modbus轮询频繁,可能同时有好几个主站在周期读同一批寄存器。如果用std::mutex,一次只允许一个客户端读,其余全被阻塞在锁上,本来就刚够用的100ms轮询周期会直接翻倍。换std::shared_mutex后,读和读之间不互斥,只有写和读、写和写互斥,基本消除了读侧性能瓶颈。
写寄存器时的锁粒度对称使用了std::unique_lock。还有一个细节:先校验再上锁。如果把校验放到锁内,恶劣情况下恶意客户端发一个start = 0xFFFF, count = 0xFF的请求,先加锁再做start + count溢出,就会出现越界访问。项目里的顺序是“参数合法性检查 → 加锁 → 二次边界检查”,确保锁内不执行任何可能造成崩溃的运算。
4. 运行起来:编译参数、配置项与主站工具联调验证
4.1 编译与启动:CMake构建和命令行参数说明
项目构建非常简单,CMakeLists.txt没有链接任何第三方库,只开启了C++17标准。在项目根目录执行:
cmake -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j4编译产物中只有一个可执行文件modbus-server。运行参数全部走命令行:
./build/modbus-server \ --port 1502 \ --holding 200 \ --coils 64 \ --input 32 \ --log modbus.log \ --debug其中--port是Modbus TCP服务的端口,默认502,但非root用户通常没权限绑定1024以下端口,所以开发时用1502更顺手。--holding、--coils、--input分别指定各存储区的寄存器个数,IPC链接时是和从站配置对应的,如果上位机要读100个保持寄存器,这里至少要设置成100。--debug会在控制台打印收到的每一帧原始字节,联调阶段一定要开。
启动后控制台会输出监听端口和每个连接建立/断开的日志。如果跑在Linux上,建议用ulimit -n把文件描述符上限调整到65535,否则连接数超过系统默认值1024后,accept会开始失败。
4.2 用主站模拟器做真实联调验证
直接把Modbus Poll这类主站工具指向本机运行最直观:连接127.0.0.1:1502,从站地址1,功能码03,起始地址0,寄存器数量10。启动轮询后,你可以看到面板上的寄存器值正常刷新。
但更严谨的验证是用脚本自动比对请求和响应,项目scripts目录里带了一个测试脚本,模拟完整的主站行为:
# scripts/test_modbus.py import socket, struct server_addr = ("127.0.0.1", 1502) def read_holding_registers(start, count): trans_id = 0x1234 protocol_id = 0 length = struct.pack(">H", 5) # 单元标识符1 + 功能码1 + 地址2 + 数量2 unit_id = b"\x01" pdu = struct.pack(">BBHH", 0x03, start, count) frame = struct.pack(">HHH", trans_id, protocol_id, length) + unit_id + pdu s = socket.create_connection(server_addr, timeout=3) s.sendall(frame) resp = s.recv(256) s.close() # 解析响应:验证事务ID、单位ID、功能码按序 if len(resp) < 9: raise AssertionError(f"帧过长或过短: {len(resp)} bytes") h_resp = struct.unpack(">HHHBB", resp[:9]) assert h_resp[0] == trans_id, "事务ID不匹配" assert h_resp[2] == 5 + 2 * count, "长度字段错误" values = struct.unpack(">" + "H" * count, resp[9:9 + count * 2]) print("read ok:", values) if __name__ == "__main__": read_holding_registers(0, 4)这段脚本演示的事很有价值:不借助任何Modbus库,用标准库的socket和struct,按照协议文档手工拼帧。它能验证我们的服务器面向的标准帧是否正确。实际运行时我习惯先把脚本里的事务ID加1再发一次,确认服务器不会错误地缓存事务ID——Modbus TCP允许连接复用时事务ID递增,服务器必须在每个请求上原样回填事务ID。
4.3 日志与调试:打开debug模式看协议细节
--debug输出的日志是十六进制帧,格式如下:
[2025-01-10 11:32:05.127] <- Recv: 12 34 00 00 00 06 01 03 00 00 00 0A [2025-01-10 11:32:05.128] -> Send: 12 34 00 00 00 17 01 03 14 00 00 00 00 ...逐字节对照文档来看:前四个字节12 34 00 00是事务ID和协议ID,00 06表示后面还有6字节,01是单元标识符,03是功能码,00 00起始地址,00 0A数量10。如果看到异常长度、或者功能码变成83,说明对该帧返回了非法功能码异常。debug日志能直接暴露两个最常见的问题:指令发错了地址(比如把线圈地址写到保持寄存器区),或者读取数量超过存储区上限导致异常。
另外提一句日志里时间戳的格式,前面的[2025-01-10 11:32:05.127]是本地时钟,毫秒级别。多客户端并发调试时可以用时间戳区分请求和响应是否交错,如果疑心响应发错了客户端,把debug和连接日志一起开,每个线程在日志前缀里打印thread id,瞬间就能定位。
5. 常见问题与避坑记录:从卡死到丢帧的血泪经验
5.1 现象:连接也建立了,但主站收到的全是超时
原因:客户端发的请求帧很短,服务器端却还在傻等后面的字节。特别是用raw socket自己拼帧的客户端,容易漏掉MBAP头的length字段里的“单元标识符”这一字节数量,导致length写小,服务端按错误长度去read,客户端等不到响应。
解决:在recv_request的入口打印收到的字节长度和解析出的length字段,对照Modbus TCP报文文档逐字节人肉核对。其实最稳妥的做法是统一用两段式读取:先读6字节头,再根据length读body。这个项目就是这样处理的,任何一帧只要长度传错了,日志立刻会显示帧残留,下一次轮询时会因为多读了上次的残留字节而导致功能码错乱。
5.2 现象:客户端一多,多个并发读操作出现延迟甚至假死
原因:寄存器状态表用了std::mutex做全表锁,两个读请求之间也互斥。当上位机A在0-49地址做100ms周期轮询时,上位机B再发起读,B就要等A释放锁,每来一个客户端锁等待时间线性叠加。表面看起来是服务器卡死,其实是锁竞争。
解决:如上文所述换成std::shared_mutex,读与读并发执行。如果后续还想更激进一点,可以改成“每块存储区一把锁”,甚至“按地址段分桶”,不过对Modbus TCP的常规轮询场景,一级读写锁已经能把并发度提到上百个客户端。
5.3 现象:寄存器值被上位机显示成大数值,完全不对
原因:字节序翻车。Modbus规定寄存器值是大端(高字节在前),但很多设备内部存储是小端(x86),如果直接把uint16_t的内存字节复制到响应帧,就会高低字节颠倒。更隐蔽的情况是客户端也错用小端解析,两边错误互相抵消,看起来正常,换一个设备又错。
解决:所有发送出去的寄存器字节推入resp.data之前,统一经过(value >> 8) & 0xFF和value & 0xFF的分拆,禁止直接memcpy。同理接收时用(req.data[i] << 8) | req.data[i+1]拼回uint16_t。这个处理从不用偏移量的方式侥幸绕过。
5.4 现象:功能码对不上,08功能码莫名变成03,请求错位
原因:TCP粘包没有被处理。客户端两次write被TCP栈合并成一个包发送,服务端这时如果按“每读到6字节就是一个新请求”来解析,第二次请求的帧头会被当成第一次请求的body,功能码全部偏移。
解决:唯一的出路是严格按照length字段读完整个body再开始解析下一条。脚本里可以验证这个场景:把两个请求拼成一个TCP包发过去,服务器应能正确解出两个独立请求,只是响应之间会有先后。本项目在recv_request里就是按这个逻辑设计的,所以天然抗粘包,只要记得加测试用例。
5.5 现象:写多个寄存器后立刻读回,值还是旧的
原因:服务端把写请求存入寄存器后没有加读写锁的写屏障,另一线程并发读到的可能是旧值。虽然std::shared_mutex本身带内存屏障,但如果你用了自定义的lock-free变量存储寄存器值,就可能踩到乱序的坑。
解决:寄存器表的所有写操作必须通过std::unique_lock包裹,读通过std::shared_lock,让锁来保证可见性。要是不想用锁,至少把寄存器变量声明为std::atomic<uint16_t>,不过读多个连续寄存器时atomic无法保证整体一致性,场景允许时再考虑。
6. 二次开发:私有功能码与存储替换的改造路线
6.1 增加一个私有功能码,从数据到事件
Modbus标准功能码被0x01-0x10占满,但0x44-0x6F之间是非公开区,很多设备用它做扩展。比如我想增加一个0x64(100),用来返回设备固件版本与序列号字符串。改动点只有两个。
第一处在modbus_handlers.cpp,switch分支里加一行case 0x64: return read_device_info(req, resp);。第二处是新增handler,在加锁后把一段自定义结构体拼进resp.data:
int ModbusServer::read_device_info(const ModbusRequest& req, ModbusResponse& resp) { if (req.data.size() < 2) return STATUS_ILLEGAL_VALUE; std::shared_lock lock(table_.rw_lock); const char* info = "MODBUS-CPP-DEMO-V1.2"; resp.data.push_back(0); // 自定义返回码 resp.data.push_back(strlen(info)); // 字符串长度 resp.data.insert(resp.data.end(), info, info + strlen(info)); return STATUS_OK; }改造时保持dispatcher不动,只加case和实现。这样做的好处是,主站连上来依然是Modbus协议,但能通过私有功能码读到设备自身信息,插上Wireshark也看不出任何破坏标准帧的痕迹。
6.2 把寄存器存储从内存换成外部数据库
真正接实际设备或做云端同步时,寄存器表不能只存在内存里,项目可以替换RegisterTable的存储层。做法是新增一个StorageAdapter接口,把holding数组的读写委托给它,常见实现是sqlite:
// storage_adapter.h class StorageAdapter { public: virtual std::vector<uint16_t> read_holding(int start, int count) = 0; virtual void write_holding(int start, const std::vector<uint16_t>& values) = 0; };改造之后,read_holding_registers这个handler就变成调用storage->read_holding(start, count),锁的范围被压缩到每次数据库调用的前后,而不是覆盖整个响应构造过程。实际项目中我用过这个方案去做数据透传:寄存器写入的同时,通过SQLite触发器把变更同步到云端,相当于Modbus从站兼做数据采集网关。
6.3 验证回归:帧级测试是最后一道防线
每次改完功能码或存储层,不要只开着Modbus Poll手动点。把scripts/test_modbus.py扩展成通用的回归脚本,针对每个功能码写好固定输入的期望响应,用一个TestRunner依次发帧断言。我习惯把事务ID做成序列号递增,每轮测试结束后对比服务器日志里的帧计数,确保没有丢请求。
从那次改存储层顺手把锁搞成全局、导致轮询延迟暴涨之后,我如今每写一处模块替换,都会先把锁粒度和帧日志打印的预期结果写成测试用例,再动代码。如果你打算长期维护这套Modbus服务,建议把回归测试放进CI里,因为一个字段的字节序改动,影响的可能是整条产线上所有主站。希望这篇文章能帮你把Modbus TCP服务器跑起来,也少走我踩过的那几个坑。
本文还有配套的精品资源,点击获取