上一篇把 Modbus 一帧报文逐字节拆开讲了。这一篇往上走一层:与其每个项目都重写一遍 Modbus/西门子/三菱的收发,不如把它做成一套能长期用、能跑嵌入式、还能给上位机绑定的库。这是这个系列的第一篇,先把架构和取舍说清楚——协议实现的细节留给后面每一篇。

项目代号 talkplc。纯净室开发,只依据公开协议规范,个人时间与设备。

为什么又要造一个通讯库

工控现场的协议是一堆的:Modbus RTU/TCP、西门子 S7、三菱 MC、欧姆龙 FINS/Hostlink、CODESYS…… 每接一个新设备、每起一个新项目,都在重复写”打开串口 → 拼一帧 → 算校验 → 收一帧 → 解出寄存器”。

已有的轮子里,HslCommunication 很成熟,但主要面向 .NET。我想要的不太一样:

  • 以 C 为核心,能直接跑在嵌入式 Linux / 网关上,不背运行时;
  • 对外暴露干净的 C API,方便再包一层给 C# / Python 做上位机;
  • 模块解耦,用哪层链哪层,别一上来就拖一坨依赖。

对我个人,它同时是三样东西:一个开源作品集、这个博客的选题引擎(每实现一个协议写一篇),以及一个长期想做下去的方向。

一开始就定死的几条原则

  1. 纯 C11 内核 + 干净 C API——最大可移植性,绑定友好。
  2. 传输与协议分离——协议层不关心底下是串口、TCP 还是内存模拟。
  3. 每层单独成库、可单独链接——只想要 Modbus 帧逻辑?那就只链协议模块,不拖串口。
  4. 没有硬件也能测——内置一个内存里的从站模拟,帧逻辑不靠真设备就能验证。

架构总览

分层大概长这样,依赖方向自上而下、绝不反向:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  应用 / 上位机 / 绑定 (C#、Python…)

┌──────────▼──────────┐
│ gui Qt 监视界面 │ 可视化,可选
└──────────┬──────────┘
┌──────────▼──────────┐
│ poll 后台轮询线程 │ C11 线程,对外只给"线程安全快照"
└──────────┬──────────┘
┌──────────▼──────────┐
│ driver 点位(tag)模型 │ 地址+类型 → 值,连续寄存器批量读
└───┬──────────────┬───┘
┌───────▼──────┐ ┌─────▼──────────┐
│ modbus 协议 │ │ datatype 类型解码 │
└───────┬──────┘ └────────────────┘
┌───────▼──────┐
│ serial 传输后端 │ 串口(以后加 TCP)
└───────┬──────┘
┌───────▼───────────────────────────┐
│ core 状态码 + 传输接口 + 收发帧回调 │ 人人依赖的地基
└───────────────────────────────────┘

每层的职责:

模块 干什么 依赖
core 状态码、传输接口、收发帧回调(trace
serial 串口 I/O(Windows / POSIX),以后同法加 TCP core
modbus Modbus 帧的拼装 / 解析 / 校验,不关心串口 core
datatype 寄存器 ↔ 数据类型(u16/i16/u32/i32/f32/bool + 字序)
driver 点位模型 + 轮询 + 批量读 modbus, datatype
poll 把驱动跑在后台线程上,对外给线程安全快照 driver
gui 工业风监视界面(Qt) poll

下面挑三个最关键的设计点讲讲——只讲思路,代码点到为止。

设计一:传输层是一个”字节管道”

整套库的地基,是把”底层怎么收发字节”抽象成一个接口。协议层只认这个接口,永远不知道自己是在串口上、TCP 上,还是在一段内存里:

1
2
3
4
5
6
7
8
/* 一个"字节管道"。串口 / TCP / 内存模拟都实现它就行。 */
typedef struct tp_transport {
void *ctx;
int (*read )(void *ctx, uint8_t *buf, size_t len, int timeout_ms);
int (*write)(void *ctx, const uint8_t *buf, size_t len);
void (*flush)(void *ctx);
void (*close)(void *ctx);
} tp_transport_t;

就这么几行,换来两个大好处:

  • 以后加 Modbus TCP,协议帧逻辑一行不用改,只写一个新的传输后端;
  • 测试时塞一个内存里的假从站进去,不用真设备就能把收发全流程跑通。

设计二:协议层只管”一帧”

Modbus RTU 的一帧,结构很朴素(细节见上一篇拆解):

1
2
3
4
┌────────┬────────┬──────────────┬────────┬────────┐
│ 从站号 │ 功能码 │ 数据 │ CRC 低 │ CRC 高 │
│ 1 B │ 1 B │ N 字节 │ 1 B │ 1 B │
└────────┴────────┴──────────────┴────────┴────────┘

校验用的是 CRC-16/MODBUS,公开标准算法,多项式 0xA001

1
2
3
4
5
6
7
8
9
uint16_t crc16(const uint8_t *p, size_t n) {
uint16_t crc = 0xFFFF;
while (n--) {
crc ^= *p++;
for (int i = 0; i < 8; i++)
crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : crc >> 1;
}
return crc;
}

小验证:对 ASCII 串 "123456789" 算出来应是 0x4B37——这是 CRC-16/MODBUS 的标准校验值,拿它当单元测试的”已知答案”很省心。

协议层还做了一件对界面很有用的事:每收发一帧,就通过一个回调把原始字节抛给上层。上层想怎么显示(HEX / ASCII)、要不要记录,都随意——这就是监视界面里”收发帧”那一栏的数据来源。

设计三:点位模型 + 后台线程

再往上,”寄存器地址 + 数据类型”被抽象成点位(tag)。你只描述”我要读哪个地址、当成什么类型”,轮询之后值就自动填好:

1
2
3
4
5
6
7
8
9
typedef struct {
char name[32];
tp_area_t area; /* 线圈 / 保持寄存器 / 输入寄存器 … */
uint16_t address;
tp_datatype_t type; /* uint16 / int16 / float32 / bool … */
/* ↓ 轮询后自动填充 */
double value;
bool valid;
} tp_tag_t;

这一层还顺手做了两件事:

  • 批量读:把同一区域里地址连续的点位,合并成一次 Modbus 请求,而不是一个点位发一帧——少一半以上的往返;
  • 并发放在 C 库里:轮询跑在一个后台线程上(C11 <threads.h> 那套 API),界面只去读一份线程安全的快照。于是就算某个从站不应答、读超时卡住,界面照样丝滑,不会连”断开”都点不动。

值得一提的是并发没有塞进界面框架里——它在纯 C 层。这样哪天不用这个界面、换别的前端,甚至做成命令行守护进程,后台采集这套照样能用。

配套:一个工业风监视界面

光有库不够直观,所以配了个 Qt 写的监视器:左边选协议,右边配串口、列采集点位、看实时值(支持不同数据类型显示),底下是收发帧监视,可在 HEX / ASCII 之间切。

talkplc 监视界面:左侧选协议,右侧配串口 + 采集点位(支持多种数据类型的实时值),底部是收发帧监视,可在 HEX / ASCII 之间切换

界面只依赖最上面的 poll 那层,通过线程安全快照拿数据——它对底下协议一无所知,以后加了新协议,界面几乎不用动。

反过来,界面本身也是可换的。它只和纯 C 的 poll 层打交道,所以 Qt 只是”其中一种前端”:桌面 / 上位机拿 Qt 写省事;而同一套 C 内核,也能驱动一个 LVGL 写的界面,直接跑在嵌入式 HMI、触摸屏上。正因为核心是纯 C、不背运行时,这条”下沉到设备端”的路才走得通——这也是当初咬定纯 C 的原因之一。

接下来

这个系列会一个协议一篇往下写:

  • Modbus TCP(复用现成的帧逻辑,换个传输后端 + MBAP 头)
  • 西门子 S7、三菱 MC、欧姆龙 FINS/Hostlink……

界面也不只一种:桌面 / 上位机继续用 Qt,嵌入式 HMI 用 LVGL,两者共用同一套纯 C 内核——协议、驱动、后台采集一行都不用改。

每加一层,都会回来对照这张架构图看看——如果新协议逼着我改了地基,那多半是当初某个抽象没做对。这也是我留着这套分层、公开写出来的原因之一:架构是会被新需求反复拷问的

下一篇,Modbus TCP。