背景
最近在做一个储能+充电管理系统,需要通过 Modbus TCP 采集充电桩的实时运行数据。设备是 Amber协议的分体桩,12个枪头,每把枪都有 DC 电压、电流、SOC、充电状态等一堆寄存器。理论上用libmodbus几十行就能搞定,但实际下来坑一个接一个,前前后后搞了好几天。把这个过程记下来,供后来者参考。
模块架构
这个系统采用插件式采样器架构,charger_sampler 是一个独立的 .so 库,暴露 Initialize()、Sample()、Control()三个接口供主框架调用。大概流程是这样:
scss
主框架定时调用 Sample()
↓
锁 Mutex(hSamplerLock)
↓
ChargerModbusTCPSample()
→CtxIsReady()检查并重建连接
→ SegmentReadData() 分段读取寄存器
→ ParseModbusData() 按地址偏移解析原始值
→ ConvertData() 应用缩放系数转换成工程量↓
释放 Mutex
↓
Push_BlobValue() 推送数据到数据库
Control() 是单独路径,收到上层下发的控制命令(比如限功率)之后写Modbus 寄存器。
问题一:Too many data 从哪里来
连上设备,第一次跑起来就看到这个:
Reading registers segment0 failed: Too many data
Modbus TCP 单次读取上限是 125 个输入寄存器(FC03),超了就报这个错。问题在于代码里计算每个分段需要读多少个寄存器的逻辑出了问题。
原来的 CalcAddrSpan 函数长这样:
c
static int CalcAddrSpan(ROUGH_CHARGER_DATA *pModbusRough, int midStart, int midEnd, int segStartAddr)
{
int maxEnd = 0;
for (int i = midStart; i <= midEnd; ++i)
{
int end = pModbusRough->ModbusDataMap[i].nRegAddrStart + pModbusRough->ModbusDataMap[i].nRegNum;
if (end > maxEnd) maxEnd = end;
}
return (maxEnd > segStartAddr) ? (maxEnd - segStartAddr) : 0;
}
看起来没问题,但关键是 midStart 和 midEnd 是 MID 枚举值,被当作 数组下标在用。这个设计成立的前提是:数据表里每一项的位置,必须和它的 MID 枚举值严格对应。
Intel 一体桩的数据表(InitModbusDataMap)恰好满足这个条件,所以一直没出问题。但是 Amber 分体桩的数据表(InitModbusDataMap_split)不满足。举个例子,枚举里面的顺序是:
c
// MID 枚举定义顺序:
MID_SysAvaStu,// = 0
MID_Connector1AvaStu, // = 1
MID_Connector2AvaStu, // = 2
MID_Connector3AvaStu, // = 3
MID_Con1_L1_N_VolExport,// = 4
MID_Con1_L2_N_VolExport,// = 5 ← Intel有L2/L3,Amber没有
MID_Con1_L3_N_VolExport,// = 6
MID_Con2_L1_N_VolExport,// = 7
...
但Amber 数据表里,位置 4 放的是 MID_Con1_L1_N_VolExport (对应枚举值4,没问题),位置 5 放的是MID_Con2_L1_N_VolExport(枚举值7,不对了)。从第5项开始,数组位置和MID 枚举值就不再对齐。
当 CalcAddrSpan 用 MID_Con12ChargingStatus(枚举值约为120)去扫描 Amber 数据表时,位置120对应的是PowerNode区域(Modbus 地址 0x1210+),结果算出来的 span 是:
0x1211 - 0x0001 = 4624 个寄存器
远超 125 的上限,Too many data 就这么来了。
更离谱的是,MID_Cfg_TotalPMNumber 的枚举值大约是 475,InitModbusDataMap_split 只有 319 个条目,直接越界读内存,出现nReadRegNum=1608974166 这种垃圾值。
解决方案是彻底抛弃按MID 下标扫描的方式,改成按地址范围扫描:
c
static int CalcAddrSpanByRange(ROUGH_CHARGER_DATA *pModbusRough, int addrStart, int addrEnd)
{
int maxEnd = 0;
for (int i = 0; i < pModbusRough->nModbusDataMapNum; ++i)
{
int regAddr = pModbusRough->ModbusDataMap[i].nRegAddrStart;
if (regAddr >= addrStart && regAddr <= addrEnd)
{
int end = regAddr + pModbusRough->ModbusDataMap[i].nRegNum;
if (end > maxEnd) maxEnd = end;
}
}
return (maxEnd > addrStart) ? (maxEnd - addrStart) : 0;
}
扫全部数据表,只统计地址落在目标区间内的项。这样不管 MID 枚举值是什么,不管数据表怎么排序,都能算出正确的寄存器数量。
CountRegNum 里的调用也全部换成地址参数:
c
// Amber segment[0]:充电数据区0x0001-0x0061
pModbusRough->nReadRegNum[0] = CalcAddrSpanByRange(pModbusRough, 0x0001, 0x0061);
问题二:系统启动就重启
日志里出现这个就头大:
sql
Connection failed: Connection timed out [130.705215] reboot: Restarting system
130秒是 Linux 默认的 TCP SYN 重试超时(6次重试:1+2+4+8+16+32+64=127秒)。modbus_connect() 内部调用connect(),在设备不在线时会阻塞整整 130 秒。
而这个系统的初始化框架把 Initialize() 返回 FALSE 当成致命错误,触发了系统重启。重启之后还是连不上,然后又 130 秒,又重启------死循环。
两步修复:
第一步:初始化时不连接,把连接推迟到采样循环
c
BOOL ChargerModbusTCPInitialize(ROUGH_CHARGER_DATA *pModbusRough)
{
// 只存参数,不建连接
strcpy(pModbusRough->DevInfo.szIp, "192.168.100.100");
pModbusRough->DevInfo.nPort = 502;
pModbusRough->ctx = NULL; // 不调用 modbus_connect()
// 分配内存、初始化分段信息
...
CountRegNum(pModbusRough);
return TRUE; // 永远返回 TRUE,不因网络问题崩
}
第二步:连接操作加超时
CtxIsReady() 里的 modbus_connect() 调用也会阻塞130秒。用 alarm() + SIGALRM 给它加了一个5秒限制:
c
static int ModbusConnectWithTimeout(modbus_t *ctx, int timeout_sec)
{
struct sigaction sa, old_sa;
sa.sa_handler = ConnectTimeoutHandler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGALRM, &sa, &old_sa);
s_connect_timeout = 0;
alarm((unsigned int)timeout_sec);
int rc = modbus_connect(ctx);
alarm(0);
sigaction(SIGALRM, &old_sa, NULL);
if (s_connect_timeout) { errno = ETIMEDOUT; return -1; }
return rc;
}
这样采样时连不上设备,最多等5秒,不会卡死。
问题三:重启后连不上,只有重启系统才行
这个现象很奇怪:设备断线之后,程序一直连不上,但把嵌入式系统重启一次就好了。
原因是TCP 连接的关闭方式问题。正常的 TCP 四次挥手(FIN/FIN-ACK)完成后,本地还要等TIME_WAIT超时(最长2分钟)。在这期间,如果用相同的源 IP+端口再次连接,内核会拒绝。
充电桩设备同样有问题------它那边认为旧连接还在,看到新的 SYN 包就拒掉了。Connection timed out不是因为没到,而是因为设备不响应。
修复:建立连接成功后,立刻给socket 设置 SO_LINGER(1, 0)。这样关闭 socket 时,内核发送 RST 包而不是FIN,立即强制两端清除连接状态,不需要等待 TIME_WAIT:
c
static void SetSocketRST(modbus_t *ctx)
{
int fd = modbus_get_socket(ctx);
if (fd < 0) return;
struct linger lg;
lg.l_onoff = 1;
lg.l_linger = 0;
setsockopt(fd, SOL_SOCKET, SO_LINGER, &lg, sizeof(lg));
}
// 连接成功后调用
if (ModbusConnectWithTimeout(pModbusRough->ctx, 5) == 0)
{
SetSocketRST(pModbusRough->ctx); // 设好后,close() 时发RST
...
}
问题四:一个分段超时,整个连接就断了
这个问题隐藏得比较深。Amber V1.07 协议新增了 0x1016(PCU可用状态)和 0x1017(PCU自检状态)两个寄存器,代码把这两个也加进了读取范围。但实际设备固件版本比较老,不支持这两个地址,读取时不响应,超时后modbus_read_registers 返回 -1。
原来的 SegmentReadData 是这样的:
c
if (rc == -1)
{
printf("Reading registers segment[%d] failed: %s\n", i, modbus_strerror(errno));
modbus_close(pModbusRough->ctx);
modbus_free(pModbusRough->ctx);
pModbusRough->ctx = NULL;
return ERR_SAMPLE_FAILED;
}
任何一个分段读取失败,就关掉连接,整个采样周期结束。于是每次都因为seg2(扩展系统数据)超时,然后断连,然后重连,然后再超时......主要的充电数据、PM 数据这些有用的东西根本读不到。
改成分级处理:主数据段(seg0、seg1)失败才断连;其他段失败就跳过继续:
c
if (rc == -1)
{
printf("Reading registers segment[%d] failed: %s\n", i, modbus_strerror(errno));
if (i == 0 || i == 1)
{
// 关键数据,断连
modbus_close(pModbusRough->ctx);
modbus_free(pModbusRough->ctx);
pModbusRough->ctx = NULL;
return ERR_SAMPLE_FAILED;
}
else
{
printf("Segment[%d] is non-critical, skip and continue\n", i);
continue;
}
}
同时把 seg2 的地址范围从 0x1010, 0x1017 改为 0x1010, 0x1015,严格按协议规格,不读V1.07 扩展字段:
c
pModbusRough->nReadRegNum[2] = CalcAddrSpanByRange(pModbusRough, 0x1010, 0x1015);
问题五:Control 限功率命令发不出去,EAGAIN
这是最难定位的问题。日志:
ini
[Control] C001 PowerLimit nData[0005]
✗ C001 Write failed: Resource temporarily unavailable
rc[7] = -1 Reading registers segment[7] failed: Bad file descriptor
rc[8] = -1 Reading registers segment[8] failed: Invalid argument
Resource temporarily unavailable 就是 EAGAIN,Bad file descriptor 说明 socket 的 fd 已经失效。
看一眼时间序列就清楚了:
ini
时间 →
Sample 线程(无锁):读seg[5]... 读seg[6]... 读seg[7]...
Control 线程(有锁):写C001(EAGAIN)→关闭ctx→ctx=NULL
读seg[7](Bad fd)
Sample() 调用 ChargerModbusTCPSample() 时,根本没有加锁。Control() 是正确持锁的,但两个线程并发操作同一个 TCP socket,必然出问题。写操作打断了读操作,socket 状态乱了,EAGAIN 出现;然后 Control 把 ctx 强制关了,接下来 Sample 继续读时 socket fd 已经被释放,Bad file descriptor。
另外有一个隐藏问题:代码里设置了 MODBUS_ERROR_RECOVERY_LINK:
c
modbus_set_error_recovery(ctx, MODBUS_ERROR_RECOVERY_LINK | MODBUS_ERROR_RECOVERY_PROTOCOL);
MODBUS_ERROR_RECOVERY_LINK 这个标志在 libmodbus 内部做错误恢复时,会把 socket 临时切换为非阻塞模式(O_NONBLOCK)检测连接状态。一旦 socket 被切成非阻塞,后续的写操作如果缓冲区一时没腾出来,就会返回EAGAIN。这进一步加剧了问题。
修复分两步:
第一步:Sample() 加锁,与 Control() 互斥
c
DLLExport int Sample(HANDLE hSamplerThis)
{
SAMPLER* pSampler = (SAMPLER*)hSamplerThis;
ROUGH_CHARGER_DATA *pModbus = (ROUGH_CHARGER_DATA*)pSampler->pDataHeap;
// 关键:加锁,和 Control() 互斥访问 ctx
if (Mutex_Lock(pModbus->hSamplerLock,3000) != ERR_MUTEX_OK)
{
printf("[Sample] mutex lock timeout, skip this cycle\n");
return ERR_SAMPLE_FAILED;
}
int ret = ChargerModbusTCPSample(pSampler);Mutex_Unlock(pModbus->hSamplerLock);// 全部Modbus 操作完成后才释放锁
if (ret != ERR_SAMPLE_OK) return ERR_SAMPLE_FAILED;
// Push_BlobValue 不涉及 ctx,在锁外执行
...
}
第二步:去掉 MODBUS_ERROR_RECOVERY_LINK
// 只保留 PROTOCOL恢复(处理协议帧错误),去掉 LINK恢复modbus_set_error_recovery(ctx, MODBUS_ERROR_RECOVERY_PROTOCOL);
第三步:Control写失败后主动断连
之前的代码写失败了只打印一行错误,连接留着。但连接此时已经处于不确定状态,后续的采样读取照样会超时或出错。改成写失败就立即关闭:
c
int rc = modbus_write_register(pModbusRough->ctx, nAddress, nData);
if (rc == -1)
{
fprintf(stderr, "✗ C001 Write failed: %s --- force reconnect\n", modbus_strerror(errno));
modbus_close(pModbusRough->ctx);
modbus_free(pModbusRough->ctx);
pModbusRough->ctx = NULL;
nRet = ERR_SAMPLE_FAILED;
}
这样下次采样时 CtxIsReady() 检测到 ctx == NULL,重新建立干净的连接,再发限功率命令就能成功了。
关于 Modbus TCP 协议的一些体会
做这个模块过程中对 Modbus TCP 有些新的认识,记录一下。
- Modbus TCP 是串行协议,不是并发协议
Modbus 的设计是主从轮询式:主站发一个请求,等设备回应,再发下一个。TCP 只是它的传输层,并不能让 Modbus 并发。同一个TCP 连接上,同时有读请求和写请求,设备端可能会收到混合的请求序列,响应框架也可能乱。这就是为什么 Sample读取和 Control写入必须严格互斥。
- 寄存器分段读取要遵守125个上限
FC03 和 FC04(读保持/输入寄存器)每次最多读 125 个寄存器,FC01/FC02(读线圈/离散输入)最多2000个位。不是因为 TCP 有限制,是Modbus 协议 PDU 大小决定的(ADU最大256字节,减掉头部剩255,每个寄存器2字节,125个寄存器=250字节,加上功能码等头部正好不超)。
Amber 协议的寄存器地址跨度很大(0x0001 到 0xE007),不能一次读完,必须按地址段切分。每段内部也可能有空洞(比如 PM 数据每组用0x10个地址槽,但只有9个有效寄存器),读取时要用地址跨度而不是有效寄存器数量来设置quantity,否则中间的空洞会导致偏移错位。
- 设备的响应超时要根据实际情况调整
默认的响应超时可能需要根据网络和设备情况调整。嵌入式 Linux环境下,如果设备跨网段或者网络有延迟,默认几百毫秒可能不够。但设得太长,采样周期就被拉长了。这里设的是5秒响应超时 + 2秒字节超时:
c
pModbusRough->DevInfo.nRspTimeout = 5;
pModbusRough->DevInfo.nByteTimeout = 2;
modbus_set_response_timeout(ctx, 5, 0);
modbus_set_byte_timeout(ctx, 2, 0);
- INT16 和UINT16 的坑
Amber 协议的 DC 电流字段定义是 INT16,缩放因子 0.1A。当没有车辆接入时,寄存器值是 0xFFFF。如果用UINT16 解读,0xFFFF × 0.1 = 6553.5,显示成6553.5A;如果用 INT16 解读,0xFFFF = -1,× 0.1 = -0.1A。从业务语义上,-0.1A 更合理(表示无效/空闲),但你要看协议文档确认。错误的数据类型会导致采集值完全失真。
- FC06 和 FC10 的适用场景
Amber 协议里,控制类寄存器:
- FC06(Write Single Register):单个寄存器写,适合功率限制、开关控制这种一次写一个值的场景
- FC10(Write Multiple Registers):批量写,适合 idTag(32字节ASCII字符串)这种需要写多个连续寄存器的场景
modbus_write_register() 对应 FC06 ,modbus_write_registers() 对应 FC10。写限功率(C001,单个寄存器)用 FC06,写充电 idTag 用 FC10。用错了设备直接返回非法功能码。
最终效果
系统稳定运行后的日志大概是这样:
c
modbus reconnected OK
Seg[ 0] start=0x0001nReadRegNum=97
Seg[ 1] start=0x1001 nReadRegNum=21
Seg[ 2] start=0x1010 nReadRegNum=6
Seg[ 4] start=0x1200 nReadRegNum=36
Seg[ 5] start=0x1300 nReadRegNum=35
...
[System] SysAvaStu=[1] SerialNum=[L24B0003201040388A01]
[System] InputPwrLimit=[360.0]kW Temp: PowerCube=[29.0]C
[PMGrp1] DCVolt=499.8V DCCur=14.0A RatedPwr=30.0kW OnOff=1
[AC1Input] VA=236.0 VB=235.0 VC=237.0 P=7.8kW
[Control][ParamId=351][Amber] C001 PowerLimit nData[0064]
✓ C001 Write OK
总结
做完这个模块,几个主要教训:
- 数据表索引和枚举值不一定对齐。做通用逻辑时不要假设数组位置等于枚举值,要么按地址搜索,要么显式排序保证对齐。
- TCP 长连接 + Modbus 一定要注意并发。同一个 socket 的读写必须互斥,不能想当然地认为框架帮你处理了。
- 连接管理要区分"建立失败"和"通信失败"。启动时设备没上线是正常状态,不应该拦住初始化。通信中断是运行态问题,断了重连就行。
- MODBUS_ERROR_RECOVERY_LINK 少用。它的内部实现会修改socket的阻塞模式,在多线程环境下很容易出问题。如果自己管连接生命周期,这个选项不需要开。
- 设备固件版本决定寄存器支持范围。拿到协议文档不代表设备全支持。超出范围的地址,设备不回应,你的超时就在那等着。读之前最好确认固件版本,或者做好非关键段的容错处理。
总的来说这个协议本身不难,坑基本都在工程实现上:并发、连接状态管理、协议边界条件。花时间最多的反而不是协议解析,而是这些"基础设施"层面的问题。