一次充电桩 Modbus TCP 采集模块的踩坑实录


背景

最近在做一个储能+充电管理系统,需要通过 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 有些新的认识,记录一下。

  1. Modbus TCP 是串行协议,不是并发协议

Modbus 的设计是主从轮询式:主站发一个请求,等设备回应,再发下一个。TCP 只是它的传输层,并不能让 Modbus 并发。同一个TCP 连接上,同时有读请求和写请求,设备端可能会收到混合的请求序列,响应框架也可能乱。这就是为什么 Sample读取和 Control写入必须严格互斥。

  1. 寄存器分段读取要遵守125个上限

FC03 和 FC04(读保持/输入寄存器)每次最多读 125 个寄存器,FC01/FC02(读线圈/离散输入)最多2000个位。不是因为 TCP 有限制,是Modbus 协议 PDU 大小决定的(ADU最大256字节,减掉头部剩255,每个寄存器2字节,125个寄存器=250字节,加上功能码等头部正好不超)。

Amber 协议的寄存器地址跨度很大(0x0001 到 0xE007),不能一次读完,必须按地址段切分。每段内部也可能有空洞(比如 PM 数据每组用0x10个地址槽,但只有9个有效寄存器),读取时要用地址跨度而不是有效寄存器数量来设置quantity,否则中间的空洞会导致偏移错位。

  1. 设备的响应超时要根据实际情况调整

默认的响应超时可能需要根据网络和设备情况调整。嵌入式 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);
  1. 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 更合理(表示无效/空闲),但你要看协议文档确认。错误的数据类型会导致采集值完全失真。

  1. FC06 和 FC10 的适用场景

Amber 协议里,控制类寄存器:

  • FC06(Write Single Register):单个寄存器写,适合功率限制、开关控制这种一次写一个值的场景
  • FC10(Write Multiple Registers):批量写,适合 idTag(32字节ASCII字符串)这种需要写多个连续寄存器的场景

modbus_write_register() 对应 FC06modbus_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

总结

做完这个模块,几个主要教训:

  1. 数据表索引和枚举值不一定对齐。做通用逻辑时不要假设数组位置等于枚举值,要么按地址搜索,要么显式排序保证对齐。
  2. TCP 长连接 + Modbus 一定要注意并发。同一个 socket 的读写必须互斥,不能想当然地认为框架帮你处理了。
  3. 连接管理要区分"建立失败"和"通信失败"。启动时设备没上线是正常状态,不应该拦住初始化。通信中断是运行态问题,断了重连就行。
  4. MODBUS_ERROR_RECOVERY_LINK 少用。它的内部实现会修改socket的阻塞模式,在多线程环境下很容易出问题。如果自己管连接生命周期,这个选项不需要开。
  5. 设备固件版本决定寄存器支持范围。拿到协议文档不代表设备全支持。超出范围的地址,设备不回应,你的超时就在那等着。读之前最好确认固件版本,或者做好非关键段的容错处理。

总的来说这个协议本身不难,坑基本都在工程实现上:并发、连接状态管理、协议边界条件。花时间最多的反而不是协议解析,而是这些"基础设施"层面的问题。

相关推荐
用户7713970207061 小时前
深夜食堂:那个让我加班到凌晨的 ! 符号
后端
swipe1 小时前
02|从 `pnpm dev` 到 Spring Boot 启动:后端服务到底怎么跑起来?
前端·后端·全栈
网易云信1 小时前
网易智企Data Agent实践入选信通院《智能体创新实践案例汇编》
人工智能·后端·线下活动
swipe1 小时前
01|前端人第一次打开 Spring Boot 项目,应该先看哪里?
前端·后端·全栈
jvmind_dev2 小时前
1c1g 容器莫名 OOM Killed?排查 Metaspace 持续增长与脚本引擎的坑
java·后端
geovindu2 小时前
go:Backtracking Algorithm
开发语言·后端·算法·golang·回溯算法
AskHarries2 小时前
图片存储怎么选
后端
ZDQNFU2 小时前
ORM之SQLAlchemy教程
后端·python
Assby2 小时前
为什么我不建议你在 MySQL 里写 `IN (超过1000个ID)`?从 AST 解析到存储引擎的深度拆解
后端·面试