一、项目背景
在工业现场,温湿度传感器、压力传感器、智能电表、流量计等设备中,有不少采用 Modbus 进行数据通信。对于单纯的数据采集和协议转换,现成工业网关可以完成相应功能。但在一些场景中,需要的不只是固定的协议转换功能,而是自行定义数据采集、解析、处理以及网络通信逻辑。例如,现场设备的寄存器组织方式、数据换算方式以及后续的数据处理流程,都可能需要按照项目实际情况进行开发。
在这种情况下,可以选择用嵌入式ARM单板电脑来承接任务,本文用的是一块钡铼技术SBC2332嵌入式单板电脑,板载多路可选接口,SBC 本身不是一个封装好的网关软件产品,而是提供 CPU、RS485、以太网和操作系统等基础资源,能根据项目需求自行开发应用。
二、确认 SBC2332 的 RS485 接口
SBC2332 根据 X 板配置可以提供不同的接口。
本案例使用其中一路:RS485-2 → /dev/ttyAS3 → 9600-8E1 → 电能表
SBC2332 说明书给出了对应串口的使用方式,串口帧格式必须与设备完全一致,8E1 校验位必须显式配置。
三、配置流程
第一步:先把 Linux 串口跑通
Linux下串口配置的标准接口是termios。核心接口设计为:
/* 打开并配置串口,返回 fd,失败 -1 */
int rs485_open(const char *dev, int baud, char parity,
int data_bits, int stop_bits);void rs485_close(int fd);
int rs485_write(int fd, const uint8_t *buf, int len);
/* 带超时读取,返回实际字节数,超时/错误 0 或 -1 */
int rs485_read_timeout(int fd, uint8_t *buf, int len, int timeout_ms);/* 清空接收缓冲(发命令前调用,丢弃残帧) */
void rs485_flush_rx(int fd);
这样,上面的 Modbus 协议层就不需要关心 /dev/ttyAS3 还是 /dev/ttyAS4,只需要拿到一个文件描述符 fd 即可。
3.1 配置串口参数
实际串口初始化放在 rs485_open() 中,先打开设备节点:
int fd = open(dev, O_RDWR | O_NOCTTY | O_NONBLOCK);
打开串口以后,先调用 cfmakeraw(&tio),把 Linux 串口设置成原始模式,让串口尽可能按照收到的原始字节进行收发。需要注意的是,cfmakeraw(&tio) 会清除 PARENB、CSIZE 等标志,所以必须先进行 raw 配置,再设置具体串口参数。因此,初始化顺序是:
open(dev, O_RDWR | O_NOCTTY | O_NONBLOCK);
tcgetattr(fd, &tio);
cfmakeraw(&tio); // 先 raw
cfsetispeed / cfsetospeed; // 再波特率
c_cflag &= ~CSIZE; |= CS7/CS8; // 再数据位
c_cflag &= ~PARENB; E/O 分支; // 再校验位
c_cflag &= ~CSTOPB; 2 位分支; // 再停止位
tcsetattr(fd, TCSANOW, &tio);
tcflush(fd, TCIOFLUSH);
第二步:实现带超时的 RS485 读取
Modbus 是典型的”一问一答”通信,SBC2332 发送请求以后,需要等待从站返回数据。因此驱动层还需要提供带超时的读取接口。本案例没有直接依赖 VTIME,而是使用 select() 等待串口数据:
int r = select(fd + 1, &rfds, NULL, NULL, &tv);
如果r > 0 串口有数据到达;r == 0 等待超时。这样 Modbus 协议层就可以把”等待设备响应”作为一个明确的事务超时条件。驱动层只负责打开串口、配置串口、发送字节、按超时时间读取字节,至于这些字节是不是 Modbus 报文,由上面的协议层判断。
第三步:实现 Modbus RTU 协议层
串口能够正常工作以后,再开始处理 Modbus RTU。本案例使用的功能主要包括FC03:读取保持寄存器、FC04:读取输入寄存器、FC06:写单个寄存器。
协议层接口设计为:
/* 读保持/输入寄存器(fc=3 或 4),结果按大端 16bit 存入 out[cnt] */
int mbus_read_regs(int fd, uint8_t slave, uint8_t fc, uint16_t addr,
uint16_t cnt, uint16_t *out,
int timeout_ms, int retries);/* 写单个寄存器(fc=06) */
int mbus_write_reg(int fd, uint8_t slave, uint16_t addr, uint16_t val,
int timeout_ms, int retries);
协议层只处理 Modbus,不关心上层接的是什么设备。
第四步:组装 Modbus 请求帧
以 FC03/FC04 读取寄存器为例,请求帧固定为 8 字节:
| 地址 | 功能码 | 起始地址 | 寄存器数 | CRC16 | | --- | --- | --- | --- | --- | | 1B | 1B | 2B | 2B | 2B |
程序中:
tx[0] = slave;
tx[1] = fc;
tx[2] = (uint8_t)(addr >> 8);
tx[3] = (uint8_t)(addr & 0xFF);
tx[4] = (uint8_t)(cnt >> 8);
tx[5] = (uint8_t)(cnt & 0xFF);
然后计算 CRC16:
uint16_t crc = mbus_crc16(tx, 6);
tx[6] = (uint8_t)(crc & 0xFF);
tx[7] = (uint8_t)(crc >> 8);
这里有一个非常容易出错的地方:Modbus RTU 的 CRC 在线路上传输时是低字节在前,而普通的地址、数量等字段采用高字节在前。所以上面两行的写法不能写反。在发送之前,还需要先清一次接收缓冲:
rs485_flush_rx(fd); /* tcflush(TCIFLUSH) */
485 是半双工总线,上一轮的残帧、总线毛刺都可能留在接收缓冲里,不清掉会污染下一帧的解析。
第五步:发送请求并等待响应
报文组装完成以后,通过驱动层发送rs485_write(fd, tx, 8),然后开始等待从站响应。本文没有在用户态精确实现 3.5 字符时间定时,而是根据本次请求能够确定响应长度:
预期响应长度 = 5 + 2 × 寄存器数量
例如读取 2 个寄存器,预期长度为5 + 2 × 2 = 9 字节。所以程序持续读取,直到达到预期长度,或者等待超时。这样,在当前”一问一答、单从站”的采集场景下,可以完成响应帧接收。
第六步:校验 CRC 并解析响应
收到响应以后,首先重新计算 CRC:
uint16_t cc = mbus_crc16(rx, expect - 2);
然后和响应帧末尾的 CRC 对比,不一致则返回校验错误。CRC 正确后,再检查从站地址、功能码、响应长度,只有全部符合预期,才认为这次读取成功。对于寄存器数据,则按照 Modbus 的 16 位寄存器格式进行组合:
out[i] = (uint16_t)((rx[3 + i * 2] << 8) | rx[4 + i * 2]);
最终得到 uint16_t 寄存器数组,交给上层业务继续解析。
第七步:处理 Modbus 异常响应
正常情况下,设备返回“从站地址 + 功能码 + 数据 + CRC”。但如果从站拒绝请求,功能码最高位会被置 1。例如请求功能码03,异常响应会变成83。因此专门判断:
if (got == 5 && (rx[1] & 0x80))
return MBUS_ERR_EXC;
这种错误属于从站明确返回的异常,不需要重复等待,因此直接返回异常状态。而普通超时或者短帧,则作为通信异常,可以进入重试流程。
第八步:在业务层完成设备数据解析
到这里,底层 Modbus 已经能够返回寄存器数组。接下来进入和现场设备相关的部分,例如本案例中的电能表:
设备节点:/dev/ttyAS3
波特率:9600
数据格式:8E1
从站地址:1
功能码:03
起始地址:0x0000
数量:2
读取 2 个寄存器之后,两个 16 位寄存器共同组成一个 float32:
static float regs_to_float(const uint16_t *r)
{
uint32_t u = ((uint32_t)r[0] << 16) | r[1];
float f;
memcpy(&f, &u, sizeof(f));
return f;
}
也就是说寄存器 0 和寄存器 1 组合成 32 位数据,再按 IEEE754 解释成 float,最终得到电压值。实测验证过0x435D999A 解析后对应221.6V。
第九步:做成周期采集线程
单次读取成功以后,再把它放进周期任务中。本文使用一个独立线程完成采集,采集周期 1000 ms。这个线程只有三样东西:一个串口句柄、一份采集结果、一组统计计数。
/* acq.c —— 采集线程 */
static volatile int g_running = 1;
static volatile int g_online; /* 链路在线标志 */
static volatile float g_voltage; /* 采集结果 */
static uint32_t ok_cnt, err_cnt;static void *acq_thread(void *arg)
{
int fd = -1;while (g_running) {
/* 串口未打开:首次启动,或掉线后需要重连 */
if (fd < 0) {
fd = rs485_open("/dev/ttyAS3", 9600, 'E', 8, 1);
if (fd < 0) { g_online = 0; sleep(5); continue; }
}/* 一次 Modbus 读事务 */
uint16_t pm[2] = {0};
if (mbus_read_regs(fd, 1, 3, 0x0000, 2, pm, 400, 1) == MBUS_OK) {
g_voltage = regs_to_float(pm);
ok_cnt++;
g_online = 1;
} else {
err_cnt++;
g_online = 0;
}usleep(1000 * 1000); /* 1s 轮询周期 */
}if (fd >= 0) rs485_close(fd);
return NULL;
}
主程序只做两件事:启动采集线程、读取采集结果。
int main(void)
{
pthread_t th;
pthread_create(&th, NULL, acq_thread, NULL);while (1) {
printf("电压=%.1fV %s OK:%u ERR:%u\n",
g_voltage, g_online ? "在线" : "离线", ok_cnt, err_cnt);
sleep(1);
}
}
这里的 g_voltage / g_online / ok_cnt / err_cnt 就是”数据出口”:
采集线程 → 全局状态(电压 / 在线标志 / OK,ERR 计数) → 日志 / 数据库 / 上报云端
实际使用时,把出口换成共享内存、消息队列、socket 上报或者数据库写入都可以,采集线程本身不需要改动。
第十步:处理设备掉线
工业现场不能假设设备永远在线。本案例中,如果串口句柄失效 if (fd < 0),程序会重新打开串口;如果打开失败,等待 5 秒后重新尝试。这样在从站断电、线路恢复等情况下,不需要重新启动整个程序,就可以重新建立采集链路。
完整的软件结构
最终,整个 Modbus 采集程序形成三层结构:
这种结构的好处是,换设备时只改业务层,底层两个文件原样复用。例如现在采集的是电能表:
业务层:/dev/ttyAS3 · 9600-8E1 · 站1 · 1000ms 周期 · 读电压 float32
如果要在同一条底层上去接第二台设备(例如变频器),只需要再写一个采集线程:
业务层:/dev/ttyAS4 · 9600-8N1 · 站1 · 500ms 周期
读:运行频率、母线电压、输出电压/电流/功率、状态字
写:FC06 下发启动 / 停机 / 频率设定
底层 rs485.c(串口驱动)和 mbus.c(Modbus RTU 协议)在两台设备之间完全复用,一行都不用改。
最后再分享一个小技巧。现场调试时,先不要急着写程序,而是用PC机上的Modbus Poll软件直连传感器,确认地址、寄存器、波特率这些基本信息全对,再去碰嵌入式Linux侧的代码。这样可以快速区分问题是出在传感器配置上,还是出在代码逻辑上