站级 EMS 版本信息采集与上报全链路解析
涉及技术:多线程通信 · TCP/UDP 数据传输 · 数据库操作 · 自定义帧协议解析 · 跨层数据上报
背景与目标
在储能站控系统(站级 EMS)中,运维人员需要在站控运行界面上实时查看下级从控 EMS 的版本信息 ,以及每台 PCS(功率变换系统)的版本号与序列号。
这个需求听起来简单,但实现上涉及完整的数据采集链路:
bash
PCS 设备 → 从控EMS数据库(stat) → 从控EMS TCP上报 → 站控EMS TCP接收解析 → 北向传输 & 页面展示
整条链路横跨多线程并发 、自定义TCP帧协议 、数据库读写 和跨进程数据解析,下面逐层拆解。
一、下级 EMS:从数据库采集 PCS 版本信息
1.1 通过 pDAI->Get 读取数据库版本数据
从控 EMS 通过 pDAI->Get 接口从 stat 数据库中读取 PCS 的版本信息,这是整条链路的数据源头。
ini
int Handler_PCS_INFO(ROUGH_SLAVE_ROUGH* pSlave, BYTE* pData,
USHORT usLenID, PROTOCOL_TYPE bProtocol)
{
int iSizeHead = sizeof(PCS_INFO_DATA);
PCS_INFO_DATA* pHead = (PCS_INFO_DATA*)(&sendDataBuff[0]);
pHead->subCmd = 0x65; // 帧类型标识
pHead->idxClient = pSlave->lcCfg.idxClientLC; // 客户端索引
// 准备数据接收结构体
A_BLOB_SINGLE_RECT_INFO allsingleRectData;
ZERO_POBJS(&allsingleRectData, 1);
allsingleRectData.emBlobType = BLOB_TYPE_SINGLE_RECT_DATAINFO;
valGet.nGetSize = sizeof(A_BLOB_SINGLE_RECT_INFO);
valGet.val.blobValue = (BYTE*)&allsingleRectData;
// 从 stat 数据库读取参数值(DevId=200, ParamId=101)
pReporter->pDAI->Get((HANDLE)pReporter,
DAITYPE_STAT_PARAMVALUE_R,
200, 101,
&valGet, sizeof(PARAM_VAL_GET));
STU_SIGNLE_RECT_DATA singleRectData;
// ... 将读取结果整合进 pHead->data
}
关键点说明:
| 字段 | 含义 |
|---|---|
DAITYPE_STAT_PARAMVALUE_R |
从 stat 数据库读取参数值(只读) |
DevId=200, ParamId=101 |
对应 PCS 版本信息的设备ID与参数ID |
subCmd = 0x65 |
后续帧识别的命令字节,站控解析时依赖此标志 |
读取完成后,版本数据被整合进 PCS_INFO_DATA 结构体的 data 字段,等待打包上报。
1.2 TCP 通信线程:加锁接收站控下发的查询请求
从控侧维护一个专用 TCP 通信线程,持续监听来自站控的查询指令:
scss
static DWORD _ThreadEntry_SlaveTCPComm(void* pArg)
{
// ...
if (Mutex_Lock(pSlave->hMutex, 100) == ERR_MUTEX_OK)
{
Recv_Tcp_StationEms_Msg(pSlave); // 接收并处理站控消息
Mutex_Unlock(pSlave->hMutex);
}
// ...
}
为什么要加互斥锁? 从控 EMS 中多个线程可能同时访问同一个
pSlave结构体(例如数据采集线程、上报线程、TCP通信线程),互斥锁确保每次只有一个线程操作共享资源,避免数据竞争和内存错乱。
1.3 帧解析:识别有效命令字并路由到对应处理器
收到 TCP 数据后,进入 Recv_Tcp_StationEms_Msg 进行帧解析与路由:
ini
static void Recv_Tcp_StationEms_Msg(ROUGH_SLAVE_ROUGH* pSlave)
{
BYTE byBuff[TCP_BUFF_LEN];
memset(byBuff, 0, TCP_BUFF_LEN);
int nReadBytes = Slave_ReadData(pSlave, byBuff, sizeof(byBuff));
if (nReadBytes >= NLMSG_MIN_LEN)
{
BYTE* pFoundPos = byBuff;
do {
BYTE *pDataPos;
USHORT usDataLen;
NLMSG_FRAME_INFO* pFrm = Find_NLMsgFrame(
pSlave, pFoundPos, nReadBytes, &pDataPos, &usDataLen);
if (pFrm == NULL) { break; }
else if (pFrm->byCmd == pSlave->nlFrameInfo[NL_MSG_PCSINFO].byCmd)
{
// 匹配到 PCS 版本查询指令,触发处理
}
// ... 其他命令类型路由
} while (...);
}
}
帧定位函数 Find_NLMsgFrame 的核心逻辑是在原始字节流中搜索帧头标志 NLMSG_SOI:
ini
NLMSG_FRAME_INFO* Find_NLMsgFrame(IN ROUGH_SLAVE_ROUGH* pSlave,
IN BYTE* pRaw, IN int nReadLen,
OUT BYTE** ppDataPos, OUT USHORT* pusDataLen)
{
for (n = 0; n < nReadLen - NLMSG_MIN_LEN + 1; n++)
{
// 匹配三字节帧头 SOI
if ((byPos[n] == (BYTE)(NLMSG_SOI >> 16)) &&
(byPos[n+1] == (BYTE)(NLMSG_SOI >> 8)) &&
(byPos[n+2] == (BYTE)(NLMSG_SOI & 0xFF)))
{
// 匹配命令字节
for (m = 0; m < NL_MSG_Max; m++)
{
if (pSlave->nlFrameInfo[m].byCmd == byPos[n + 3])
{
pFoundMsg = &pSlave->nlFrameInfo[m];
break;
}
}
}
}
}
自定义帧格式结构如下:
objectivec
┌──────────────┬──────┬──────────┬──────────┬────────┬─────┐
│ SOI (3字节) │ CMD │ DataLen │ Data │ ChkSum │ EOI │
│ 帧起始标志 │ 命令 │ 数据长度 │ 有效载荷 │ 校验和 │ 帧尾│
└──────────────┴──────┴──────────┴──────────┴────────┴─────┘
解析时先找 SOI,再比对 CMD,能够在粘包或截断数据场景下依然稳健定位有效帧。
1.4 数据打包上报:构造标准帧并写入 TCP/UDP
从控收到站控的版本查询请求,采集好数据后,调用 Slave_WriteData 构造完整数据帧并发送:
scss
int Slave_WriteData(ROUGH_SLAVE_ROUGH* pSlave, BYTE *szDataBuff,
BYTE CMD, int nSendDataLen, PROTOCOL_TYPE bProtocol)
{
BYTE sendBuff[TCP_BUFF_LEN];
memset(sendBuff, 0, TCP_BUFF_LEN);
// 1. 填充帧头 SOI(3字节)
sendBuff[0] = (BYTE)(NLMSG_SOI >> 16);
sendBuff[1] = (BYTE)(NLMSG_SOI >> 8);
sendBuff[2] = (BYTE)(NLMSG_SOI & 0xFF);
// 2. 填充命令字
sendBuff[3] = CMD;
// 3. 填充数据长度(大端序,2字节)
sendBuff[4] = (BYTE)(nSendDataLen >> 8);
sendBuff[5] = (BYTE)(nSendDataLen & 0xFF);
// 4. 填充有效数据
memcpy(sendBuff + 6, szDataBuff, (size_t)nSendDataLen);
// 5. 计算并填充校验和
USHORT ulCalcSum = MakeChkSum(CMD, (USHORT)nSendDataLen, sendBuff + 6);
sendBuff[nSendDataLen + 6] = (BYTE)(ulCalcSum >> 8);
sendBuff[nSendDataLen + 7] = (BYTE)(ulCalcSum & 0xFF);
// 6. 填充帧尾 EOI
sendBuff[nSendDataLen + 8] = (BYTE)(NLMSG_EOI);
// 7. 根据协议类型选择发送方式
if (bProtocol == PROTOCOL_TCP)
nWrite = pReporter->pUsedCommPort->Write(
pReporter->hCommPort, (char*)sendBuff, nSendDataLen + 9);
else // UDP
nWrite = s_pUDPCommClient->Write(
s_hUDPPortClient, (char*)sendBuff, nSendDataLen + 9);
}
TCP vs UDP 选择策略:
- TCP:用于需要可靠传输的版本信息、配置数据等关键数据,保证数据完整性
- UDP:通常用于实时遥测数据(如电压、电流),允许少量丢包以换取低延迟
二、站级 EMS:主动查询 + 接收解析 PCS 版本信息
2.1 Sampler 线程:定时触发版本信息查询
站级 EMS 启动时,Sampler 线程向 control 表写入参数 253,触发周期性的版本查询:
ini
if (SYSTYPE_EMS_STATION == pSys_Rough->lcCfg.emSystemType)
{
varV.emValue = 0;
Push_VarValue(SETTYPE_INTERN_CTRL, (HANDLE)pSampler, 200, 253, varV);
}
Control 函数检测到参数 ID 为 253,遍历所有下级节点逐一发起查询:
arduino
DLLExport int Control(HANDLE hSamplerThis, PARAM_TYPE emParaType,
int nDevId, int nParamId,
VAR_VALUE *pVarVal, int nValSize, int nTimeOut)
{
// ...
else if (nParamId == 253)
{
int i;
for (i = 0; i < pCCU->emsMgmtData.nNodeNum; ++i)
{
Send_65Query_PCSInfo(i); // 向第 i 个从控节点查询
}
}
}
这里的设计思路:通过向内部 control 表写入特定参数 ID 来驱动业务逻辑,是 EMS 系统中常见的"内部信号触发"模式,可以统一由 Sampler 调度,避免业务逻辑散落在各处。
2.2 发起 TCP 查询:DoTCPSession_SEMS
Send_65Query_PCSInfo 构造查询数据并通过 DoTCPSession_SEMS 发出请求:
ini
BOOL Send_65Query_PCSInfo(int n)
{
BYTE Flag = 1;
USHORT usDataLen = (USHORT)sizeof(BYTE);
BOOL ret = DoTCPSession_SEMS(
n, // 目标从控节点编号
NL_MSG_PCSINFO, // 消息类型
(BYTE*)&Flag, usDataLen,
MAX_MS_DoTCPSession_SEMS_NoWaitREP // 超时时间(不等待应答)
);
return ret;
}
DoTCPSession_SEMS 根据超时参数决定是否等待应答:
ini
BOOL DoTCPSession_SEMS(int nWhichClient, NL_MSGTYPE emMsgTp,
BYTE *pData, USHORT usDataLen, int nTimeOut_mSec)
{
// ...
if (nTimeOut_mSec > MAX_MS_DoTCPSession_SEMS_NoWaitREP)
{
// 等待应答模式:阻塞直到收到回包或超时
bRet = Wait_ACCU_TCPReply(nWhichClient,
&s_SemsInfo.nlFrameInfoSEMS[emMsgTp],
nTimeOut_mSec);
Mutex_Unlock(s_MMClients[nWhichClient].hLock);
}
else
{
// 不等待应答,发完即返回(Fire and Forget)
bRet = TRUE;
}
return bRet;
}
两种模式的适用场景:
- 等待应答:配置下发、关键命令,必须确认对端已收到并执行
- 不等待应答:高频数据查询(如版本信息轮询),避免阻塞采样线程
2.3 接收与路由解析:Wait_ACCU_TCPReply
Wait_ACCU_TCPReply 持续读取 TCP 数据,识别帧类型并调用对应的解析函数:
scss
BOOL Wait_ACCU_TCPReply(int nWhichClient,
NLMSG_FRAME_INFO_SEMS *pFramInfo,
int nTimeOut_mSec)
{
do {
nReadBytes = pSampler->pUsedCommPort->Read(
s_MMClients[nWhichClient].hClientPort,
(char*)pReadPos,
CCU_TCP_BUFFLEN_SEMS - nTotalReadBytes);
nTotalReadBytes += nReadBytes;
// 获取通信端口最新错误码,检测连接状态
pSampler->pUsedCommPort->Control(
s_MMClients[nWhichClient].hClientPort,
COMM_GET_LAST_ERROR, &nLastErrorCode, sizeof(int));
// 帧类型路由
if (pFrm->msgTp == NL_MSG_CFGG)
{
pFrm->pfnParser(pCCU, pFrmData, usFrmDataLen, nWhichClient);
}
else if (pFrm->msgTp == NL_MSG_PCSINFO)
{
pFrm->pfnParser(pCCU, pFrmData, usFrmDataLen, nWhichClient);
}
// ...
} while (...);
}
解析函数指针在系统初始化时就已注册到帧信息表:
scss
BOOL Initialize_EMS_STATION(HANDLE hSamplerSelf)
{
NLMSG_FRAME_INFO_SEMS *pp = &s_SemsInfo.nlFrameInfoSEMS[0];
// ...
// 注册 PCS 版本信息帧的解析函数(CMD=0x65,最小数据长度=2)
INIT_NL_MSGINFO(pp, NL_MSG_PCSINFO, Parse_PCS_INFO, 0x65, 2);
// ...
}
函数指针表的设计优势 :通过在初始化阶段将帧类型与处理函数绑定,运行时只需通过
pFrm->pfnParser(...)直接调用,无需大量if-else或switch,具有良好的可扩展性------新增帧类型只需注册一行。
2.4 版本信息解析存储:Parse_PCS_INFO
收到从控上报的 PCS 版本数据帧后,最终由 Parse_PCS_INFO 完成数据解析与本地缓存:
ini
int Parse_PCS_INFO(ROUGH_CCU_DATA* pCCU, BYTE* pData,
USHORT usDataLen, int n)
{
// 1. 字节序转换(从控使用大端序发送,站控本地为小端序)
reverseBytes(pData, usDataLen);
// 2. 强制类型转换,将字节流映射为结构体
PCS_INFO_DATA* pPCS_INFO = (PCS_INFO_DATA*)pData;
// 3. 找到对应的客户端缓存区
A_BLOB_SEMS_CLIENTINFO *pClient = &s_MMClients[n];
pClient->stPcsInfoData.nPCSNum = pPCS_INFO->nPCSNum;
// 4. 遍历每台 PCS,存储序列号和版本号
int i;
for (i = 0; i < pClient->stPcsInfoData.nPCSNum; ++i)
{
if (i >= MAX_PSC_NUM) break;
memcpy(pClient->stPcsInfoData.szBar_code[i],
pPCS_INFO->szBar_code[i], MAX_BUFFER_LNE);
memcpy(pClient->stPcsInfoData.szVersion[i],
pPCS_INFO->szVersion[i], MAX_BUFFER_LNE);
// 强制字符串结尾,防止内存越界读取
pClient->stPcsInfoData.szBar_code[i][MAX_BUFFER_LNE - 1] = '\0';
pClient->stPcsInfoData.szVersion[i][MAX_BUFFER_LNE - 1] = '\0';
}
return 0;
}
几个细节值得注意:
reverseBytes字节序处理:下级设备通常以大端序(网络字节序)发送数据,而 x86 架构的站控是小端序,直接 cast 会导致数值错误,必须先转换。(PCS_INFO_DATA*)pData强制类型转换 :这是嵌入式系统中常见的"零拷贝"解析技巧,将原始字节流直接映射为结构体,避免逐字段手动解析,但要求结构体内存布局与协议完全对齐(注意编译器对齐填充问题,通常需要__attribute__((packed)))。MAX_BUFFER_LNE - 1处手动置\0:防止源数据中字符串未以\0结尾时,printf/strcmp等操作越界读取内存。
三、完整数据流总结
ini
┌─────────────────────────────────────────────────────────────────────┐
│ 完整版本信息采集链路 │
└─────────────────────────────────────────────────────────────────────┘
【站级 EMS - 主动查询侧】
Sampler线程
│ Push_VarValue(paramId=253)
▼
Control函数
│ for each 从控节点 → Send_65Query_PCSInfo(i)
▼
DoTCPSession_SEMS
│ 构造查询帧 → TCP Write → 等待/不等待应答
▼
Wait_ACCU_TCPReply
│ TCP Read → Find_NLMsgFrame → 路由到 pfnParser
▼
Parse_PCS_INFO
reverseBytes → 类型转换 → 存入 s_MMClients[n]
└─→ 供北向传输 & 页面展示使用
↑ TCP 上报(帧格式:SOI+CMD+Len+Data+ChkSum+EOI) ↑
【从控 EMS - 数据采集与上报侧】
_ThreadEntry_SlaveTCPComm(常驻线程)
│ Mutex_Lock → Recv_Tcp_StationEms_Msg → Mutex_Unlock
▼
Find_NLMsgFrame
│ 识别 SOI 帧头 + CMD=0x65
▼
Handler_PCS_INFO
│ pDAI->Get(stat数据库, DevId=200, ParamId=101)
│ 读取 PCS 版本号 & 序列号
▼
Slave_WriteData
构造完整帧 → TCP/UDP Write → 发送至站控
四、设计亮点与工程经验
4.1 自定义帧协议的健壮性设计
协议通过 SOI 三字节帧头 + CMD 命令字 + 长度字段 + EOI 帧尾 + 校验和 的组合,能有效应对 TCP 流式传输中的粘包和半包问题:
- SOI 三字节特征序列降低误同步概率
- 长度字段确保知道需要读多少字节
- 校验和提供数据完整性验证
- EOI 提供双重帧边界确认
4.2 函数指针表驱动的可扩展架构
scss
初始化阶段:INIT_NL_MSGINFO(NL_MSG_PCSINFO, Parse_PCS_INFO, 0x65, 2)
运行阶段: pFrm->pfnParser(pCCU, pFrmData, usFrmDataLen, n)
新增一种数据类型,只需注册一行,不修改路由逻辑,符合开闭原则。
4.3 互斥锁保护共享资源
从控侧的 TCP 通信线程通过 Mutex_Lock/Unlock 保护 pSlave 结构体,站控侧通过 s_MMClients[n].hLock 保护各节点的连接状态,两侧都严格遵循加锁-操作-解锁的原子模式。
4.4 TCP/UDP 双协议支持
Slave_WriteData 通过 bProtocol 参数在运行时切换通信方式,上层调用方无需关心底层传输细节,降低了模块间耦合度。
五、小结
本文梳理了站级 EMS 获取下级从控 PCS 版本信息的完整实现链路,核心环节包括:
- 数据源 :从控通过
pDAI->Get读取 stat 数据库中的版本参数 - 帧协议 :自定义
SOI+CMD+Len+Data+ChkSum+EOI格式保证传输可靠性 - 多线程:独立 TCP 通信线程 + 互斥锁保护共享数据结构
- 主动查询:站控通过内部 control 信号周期触发对所有从控的轮询
- 函数指针路由:初始化时注册解析函数,运行时 O(1) 路由,易于扩展
- 数据解析:字节序转换 + 零拷贝类型转换 + 字符串安全处理
整套机制在工业嵌入式场景中具有较强的实用性,同样的架构思路也适用于其他类型设备状态信息的持续采集与上报场景。