GB/T 32960 里有一条容易被忽略的时序约定:终端每发出一帧,都在等平台回一个通用应答;等不到就判定链路不可用,断开、重连、重新登入。真车上的终端严格照做,于是"平台能不能立刻把回应吐回去"这件事,直接决定这条链路是稳稳挂着,还是每隔几分钟抖一次。
SagooIoT 原来的 TCP 入站路径没有"回应"这个动作。它的流程是:读 chunk → 注册包取 deviceKey → Protocol 插件 Decode → 组物模型信封 → 入库。从头到尾不回写一个字节------因为此前接入的协议都不需要。海湾 GST-TCP 是单向 JSON 上报,设备发完就完事;Modbus DTU 走的是"平台问、设备答"的半双工隧道,也不靠协议帧里的应答字段。
GB/T 32960 不一样。它一次把宿主侧的四个缺口全撞了出来。
一、一个只会"收"的入站路径
把缺口按"接哪个协议才会痛"排一下,是这样一张表:
| 缺口 | 具体表现 | 谁先撞上 |
|---|---|---|
| 粘拆包配置没生效 | network_server.stick 在库里、在 API 里、在模型里都齐了,但读循环按 4KB Read 之后直接投递,没人消费它 |
任何变长二进制协议 |
| 没有同连接应答 | TunnelBase.ReadData 在 Decode 成功后只做 json.Marshal,从不 Link.Write |
会话型协议 |
| 注册包偏文本 | RegisterPacket.Check 先跑 normalizeRegisterPayload(去换行、去首尾空白、剥掉末尾 )))),再走正则或定长 |
二进制首包 |
| 并发读无帧缓冲 | dispatchRead 对每帧 safe.Go 并发,上限 maxInflightReads = 64 |
会连发多帧的协议 |
顺带还有一个认知层面的缺口:服务级心跳配置,对入站 TCP 来说是死配置。这个后面单列一节。
四个缺口里,前三个是"能力不存在",第四个是"能力存在但不该用在这里"。它们共同说明一件事:协议插件不是万能的。插件跑在独立进程里、通过 RPC 收字节流,它能看到的东西只有"宿主喂给它的那段数据"。帧边界、注册身份、写回时机,全在插件够不着的地方。
二、第一处:Stick 配了很久,从来没人读过它
network_server 表上有个 stick 列,管理端的网络服务表单里能填,手册里也写了四种粘拆包方式(分隔符 / 固定长度 / 长度字段 / JS 自定义)。但在这轮改造之前,它只是一段存在数据库里的 JSON 字符串。
原因在 mapper:Register、Heartbeat、Protocol 三个字段都被 StrToPointInterfaceWithoutError 转成了运行时结构,唯独 Stick 只是把字符串赋进模型,没有解析。于是拆帧器根本没有东西可跑。
修法是把 Stick 也做成可解析结构,并在读循环里真正调用它:
go
// network/connection/stick/stick.go
//
// totalFrameLen = Offset + Len + lengthValue + Adjustment
//
// Example GB/T 32960: Offset=22, Len=2, big-endian data-unit length, Adjustment=1
// → total = 24 + dataLen + 1.
type LenField struct {
Len int `json:"len"`
Offset int `json:"offset"`
Endian string `json:"endian"` // big | little (also 大端/小端)
Adjustment int `json:"adjustment,omitempty"`
}
GB/T 32960 的帧长是 固定头 24 + 数据单元长度 + BCC 1。而长度字段本身在 offset 22、占 2 字节,所以:
adjustment = 24 - 22 - 2 + 1 = 1
也就是说,adjustment 这个字段的存在意义,仅仅是让运维不用手算"头尾那 25 个字节"。实现在 nextLength() 里只有一行:
go
total := lf.Offset + lf.Len + int(lengthValue) + lf.Adjustment
拆帧点定在"读循环拿到 chunk 之后、投递 DeliverInbound 之前",每连接一个 frameBuf 串行处理。定在这个位置的原因很直接:插件是通过 RPC 被调用的,可能在多个 goroutine 里并发进来,让插件自己拼包必然竞态。
拆不出来的时候,怎么恢复
半包和粘包是正常流量,不算错误;真正需要写死的是解出了非法长度怎么办:
| 情况 | 行为 |
|---|---|
| 缓冲不足一整帧 | 等待更多数据,不推进 |
长度字段算出的 totalFrameLen ≤ 已读头长,或 > maxFrame |
丢 1 字节滑窗,继续找下一个候选 |
| 再读一次长度字段仍非法 | 同样丢 1 字节,直到缓冲耗尽 |
单帧超过 maxFrame(默认 64 KiB) |
按非法长度处理,不断连 |
| 连接关闭 | 丢弃残余缓冲 |
"丢 1 字节"而不是"清空缓冲"是个刻意的选择:清空会把紧跟在坏数据后面的合法帧一起误杀,滑窗最多多扫几个字节。同理,长度字段非法或超上限都不断连------断连的代价是让设备重登一次,而重登会引发一整轮会话重建,比多扫几十字节贵得多。
另外两处细节:
readLength只认 1 / 2 / 4 字节的长度字段。出现别的宽度时返回ok=false,走滑窗分支,而不是按 0 处理。按 0 会解出"长度为 0 的帧"这种幻觉。- 字节序判定是白名单:
little/小端/le/LE算小端,其余一切(包括写错的Big、big-endian、留空)都按大端。这里没有报错,只有默认值------写错了不会有人告诉你。
分隔符模式还有一条不同的兜底:找不到分隔符且缓冲已超过 maxFrame 时,保留尾部 maxFrame/2 字节(至少保留一个分隔符长度),其余丢掉。留一半是因为分隔符可能正好骑在被截断的位置上。
Enabled() 的判定是"四个字段任一非空",所以空配置 = 关闭拆帧 = 老路径原样。这是这次改造能安全上线的前提:所有历史 TCP 服务(gst-tcp、protocol-binary、Modbus DTU)都保持原行为,只有显式配了 Stick 的服务才走新路径。
三、第二处:Decode 多了一个 reply 字段
协议插件的 Decode 返回值一直是 model.JsonRes{Code, Message, Data}。为了让插件能把应答帧交回来,Data 这个 map 里多了一个约定字段:
json
{
"model_func_name": "nop",
"reply": "<base64 of response frame>"
}
宿主侧的处理在 TunnelBase.ReadData 里:
go
// reply:base64 字符串;先写回再入库(会话型协议降低车端超时)
applyProtocolReply(ctx, deviceKey, dataInfo, l.Write)
modelFuncName, ok := dataInfo["model_func_name"].(string)
if !ok || modelFuncName == "" || modelFuncName == "nop" {
// 仅应答(如心跳 ACK):跳过物模型管线
return
}
三条语义写死在这里:
- 先写回,再入库。车端在等回应,把应答放到入库之后,等于给数据库的写入延迟加到了车端的等待时间里。
nop不入库。心跳帧只应答、不产生任何物模型数据。如果没有这个约定,每一帧心跳都会被当"空数据上报"塞进管线。reply解码失败不阻断入库 。applyProtocolReply里非法 base64 只打一条 Debug 日志,然后继续往下走。
为什么不直接传 []byte
这是设计阶段推翻过一次的地方。最初的方案是让 reply 为 []byte,理由是"走 gob 序列化能保住原始字节"。但这条路走不通,因为数据要过三次序列化:
- RPC 层对
Data做json.Marshal/Unmarshal------[]byte到这里就变成 base64 字符串了。 - 宿主
ReadData拿到pluginData.Data后又json.Marshal一次,再Unmarshal回map[string]any。 - 进 map 之后,任何字节切片都只会以字符串形式存在。
与其让"gob 曾经保住过、json 之后又丢了"这种语义分裂存在,不如直接约定 reply 就是标准 base64 字符串,只做一次编解码。契约简单,比"看起来更类型安全"重要。
开着 Stick 的连接,不再并发 Decode
把 reply 加进来之后,一个新问题浮出来:dispatchRead 原本对每帧 safe.Go 并发执行,最多 64 个在飞。多帧连发时,写回的顺序没有保证------车端可能先收到心跳应答,后收到登入应答。
处理办法是加一个开关:
go
// serialInbound:Stick 开启时串行 Decode+reply,避免应答乱序
serialInbound bool
newServerTcpTunnel 把 useStick 直接透传进来,所以:
- Stick 开启的连接:完整帧在该连接上串行执行"Decode → 写 reply → 投递入库",不再并发。会话型协议通常低频(GB/T 32960 的实时上报周期在秒级到十秒级),串行的吞吐损失可以接受。
- Stick 关闭的连接:保持原来的并发行为,gst-tcp 这类高吞吐单向协议不受影响。
同时给 Write 加了互斥锁,因为写回和平台下行 Encode 共用同一条 TCP 连接,两个 goroutine 各写一半会直接把帧切碎:
go
// writeMu:reply 与下行 Encode 共用,避免半包交织
writeMu sync.Mutex
顺序上,reply 和下行命令仍可能交错(协议本身允许),但每一次 Write 是原子的。
还有一个安静的坑值得记一笔:Write 在透传模式下会直接返回成功:
go
if l.pipe != nil {
return nil //透传模式下,直接抛弃
}
也就是说,如果一个隧道被接上了透传管道,插件的 reply 会被无声吞掉,车端等到超时。透传是独占语义,和"协议插件要应答"是互斥的两种用法,配的时候得知道自己选的是哪个。
四、第三处:VIN 得从二进制里抠出来
设备身份从哪来?RegisterPacket.Check 原本只有两条路:正则匹配,或固定长度。两条路都要先过 normalizeRegisterPayload:
go
func normalizeRegisterPayload(buf []byte) string {
s := string(buf)
s = strings.ReplaceAll(s, "\n", "")
s = strings.ReplaceAll(s, "\r", "")
s = strings.TrimSpace(s)
for strings.HasSuffix(s, ")))") {
s = strings.TrimSuffix(s, ")))")
s = strings.TrimSpace(s)
}
return s
}
这套规整是为海湾 GST-TCP 那类"JSON 尾巴带 ))) 分隔符"的文本协议准备的。但对二进制帧来说,它是破坏性的 :GB/T 32960 的 VIN 是 17 个定长字节,帧头是 0x23 0x23,数据单元里可能出现任意字节。先去空白再匹配,等于拿着错误的字节在找身份。
所以新增了一条完全独立的路径,用一个判据分流:
go
func (p *RegisterPacket) Check(buf []byte) (deviceKey string, checkOk bool) {
// binaryOffset:必须用原始字节,禁止 normalize(会破坏二进制帧)
if p.Size > 0 {
if p.HexPrefix != "" {
want, err := hex.DecodeString(p.HexPrefix)
if err != nil || len(buf) < len(want) || !bytes.Equal(buf[:len(want)], want) {
return "", false
}
}
if p.Offset < 0 || p.Offset+p.Size > len(buf) {
return "", false
}
keyBytes := buf[p.Offset : p.Offset+p.Size]
// ...
}
data := normalizeRegisterPayload(buf) // 老路径
// ...
}
Size > 0 就是分流开关:填了就从原始字节切片,一个字都不改;没填就走老的文本路径。GB/T 32960 的配置因此长这样:
| 字段 | 值 | 说明 |
|---|---|---|
offset |
4 | 跳过 ##(2) + 命令(1) + 应答(1) |
size |
17 | VIN 定长 17 字节 |
trimNull |
true | 去掉尾部 0x00 与空格 |
hexPrefix |
2323 |
魔数校验,不匹配直接拒绝 |
trimNull 只对 raw 编码生效 ------这是刻意的。hex 和 uint 都按定长字节解释,尾部补零是数值的一部分,裁掉会改变含义。切片怎么变成 deviceKey 由 keyEncoding 决定:raw 直接 string(b),hex 转小写十六进制,uint 按 1~8 字节无符号整数、字节序可选 big/little(默认 big)。
配置错误在服务启动时就被拦下,而不是表现为"所有设备注册失败且日志没线索":
go
switch registerKeyEncoding(p.KeyEncoding) {
case "raw", "hex":
return nil
case "uint":
if p.Size > 8 {
return fmt.Errorf("keyEncoding=uint 时 size 须为 1~8,当前 %d", p.Size)
}
// ...
default:
return fmt.Errorf("keyEncoding 无效: %q(可选 raw/hex/uint)", p.KeyEncoding)
}
最后一处配套改动:Stick 开启时,注册用的是第一个完整帧。因为二进制帧可能被 TCP 切成两段到达,拿半包去取 offset 4 开始的 17 个字节,取到的是残缺的 VIN。所以读循环变成"先拆帧、再注册、然后把拆出来的第一批帧一起投递":
go
if useStick {
frames := splitter.Feed(chunk)
if len(frames) == 0 {
continue // 半包,继续读
}
deviceKey, checkIsOk = server.server.Register.Check(frames[0])
// ...
initialFrames = frames
registered = true
}
顺带说一句"同 VIN 踢旧":新连接建立时,宿主会关闭同 deviceKey 的旧入站隧道------但只在 Stick 开启时做。空 Stick 的服务不踢,避免改变 gst-tcp 与 Modbus DTU 的既有行为。这条限制配的是下面第八节那个取舍。
五、第四处:服务级心跳其实是个死配置
网络服务配置里有一个"心跳"选项,看着像是用来判活的。但对入站 TCP 来说,HeartBeatPacket.Check 只在客户端出站隧道(tunnel-client)里被调用,ServerTcpTunnel 的投递路径从来不碰它。
所以过去的文档里那句"会话型协议请关闭服务级心跳过滤",前提根本不成立------没有一处代码在读它 。GB/T 32960 的 0x07 心跳只能走插件应答这条路:插件把它解成一帧应答,model_func_name 给 nop,宿主写回连接、跳过入库。
这轮改造没有"顺便把入站心跳也实现一下"。原因是判活语义要看协议:GB/T 32960 有自己的 0x07 周期心跳,设备端清楚自己在不在线;而通用层如果按"最后收帧时间"判活,会在设备批量补发历史数据时误判。要做就单独立项,别拿一个从来没生效过的配置项冒充"已经支持"。
六、帧结构:24 个字节的头,和 1 个字节的尾
宿主侧说完了,剩下是协议插件自身。GB/T 32960 一帧的结构是定长的头 + 变长的数据单元 + 校验尾:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 2 | 起始符 | ##(0x2323) = 2016;$$(0x2424) = 2025 |
| 2 | 1 | 命令标识 | 见下一节的命令字表 |
| 3 | 1 | 应答标志 | 0xFE = 车辆主动发送;0x01/0x02/0x03 = 平台应答 |
| 4 | 17 | VIN | 车辆识别代号,定长,尾部补 0x00 |
| 21 | 1 | 加密方式 | 0x01 明文 / 0x02 RSA / 0x03 AES128 |
| 22 | 2 | 数据单元长度 | 大端 uint16,只算数据单元,不含头尾 |
| 24 | N | 数据单元 | 加密时这里是密文 |
| 24+N | 1 | BCC | 从命令字开始到数据单元结束逐字节 XOR |
解析时先卡最小长度,再按起始符判版本:
go
if len(data) < MinPacketLen { // MinPacketLen = 25
return nil, errors.New("frame too short")
}
switch {
case data[0] == 0x23 && data[1] == 0x23:
ver = Version2016
case data[0] == 0x24 && data[1] == 0x24:
ver = Version2025
default:
return nil, fmt.Errorf("bad start marker %02X%02X", data[0], data[1])
}
dataLen := binary.BigEndian.Uint16(data[22:24])
total := HeaderLen + int(dataLen) + 1
BCC 的覆盖范围值得单独看一眼:
go
func verifyBCC(frame []byte) bool {
if len(frame) < MinPacketLen {
return false
}
return frame[len(frame)-1] == xorBCC(frame[2:len(frame)-1])
}
frame[2:len-1]------不含起始符,也不含 BCC 自己。起始符是帧同步用的,不参与校验;BCC 自己更不可能参与。RSA 场景下密文长度会变,但校验范围的定义不受影响。
另外两处边界:EncodePacket 里 VIN 被 copy 进一个固定 17 字节的切片,超长静默截断 ;数据单元超过 0xffff 也被截到 0xffff。解析侧则只从右侧裁掉 0x00 和空格:
go
vin := strings.TrimRight(string(data[4:21]), "\x00 ")
七、0xFE:平台只处理"车辆主动发"的那一半
帧头里第 3 个字节是应答标志。GB/T 32960 用它区分"命令"和"应答",而插件只认其中一半:
go
if pkt.Response != RespCommand { // RespCommand = 0xFE
return model.JsonRes{Code: 0, Data: map[string]any{"model_func_name": "nop"}}
}
0xFE 表示车辆主动发送;0x01/0x02/0x03 是平台应答。发往平台的应答帧一律按 nop 处理------不报错、不入库、也不回写应答。这是对的:对一条应答再发一条应答,会变成无休止的乒乓。
真正处理的命令只有六个:
| 命令 | 名称 | 插件行为 |
|---|---|---|
| 0x01 | 车辆登入 | 记录 VIN 会话,回事件 login + 成功应答 |
| 0x02 | 实时信息 | 解析信息体 → 属性上报 + 应答 |
| 0x03 | 补发信息 | 与 0x02 同路处理(补发的是历史帧,字段结构一致) |
| 0x04 | 车辆登出 | 清会话 + 回事件 logout + 应答 |
| 0x07 | 心跳 | 只有应答,nop |
| 0x08 | 终端校时 | 应答里带上平台当前时间 |
其余命令(含 0x05 平台登入)走 default 分支:回一个通用应答,不入库。应答帧的数据单元是回显请求的前 6 个字节:
go
func EchoTime6(dataUnit []byte) []byte {
out := make([]byte, 6)
if len(dataUnit) >= 6 {
copy(out, dataUnit[:6])
}
return out
}
这是国标里通用应答的写法:把请求里的时间原样带回去。唯一的例外是 0x08 校时------那个要用平台自己的当前时间,不能回显。
还有一处细节:应答帧的加密方式被固定写成 EncNone。即使请求是 AES 加密的帧,应答也走明文。这是因为通用应答的数据单元只有 6 个时间字节,不含任何敏感信息,而让应答也加密会给车端增加一次解密负担,还会引入"key 不对导致应答收不到"的新故障模式。
八、没有登入态,实时帧直接拒
ProtocolGbt32960 里挂了一个会话表,进程内、按 VIN 索引、sync.Mutex 保护:
go
type sessionState struct {
LoginUnix int64
Version ProtocolVersion
}
type sessionStore struct {
mu sync.Mutex
m map[string]*sessionState
}
三个方法:Get / Login / Logout。规则写得很硬:
- 收到 0x07 心跳或 0x02/0x03 数据帧时,如果 VIN 不在表里,回失败应答 (应答标志
0x02),数据不入库。 - 收到 0x01 登入时,覆盖已有会话,回成功应答。
第二条是这轮改造的取舍结果,值得展开一下。国标里有一个 0x03 应答码,含义是"VIN 重复登入"。字面看,同一台车第二次登入应该回 0x03 拒绝。但真实现场是这样的:4G 信号断一下,车端重连,TCP 是新连接、VIN 是老的,插件如果回 0x03,车端会认为登入失败,继续重试------重连反而被判成非法。
所以定下来的组合是:
| 层 | 行为 |
|---|---|
| 宿主 | Stick 开启时,新连接建立前关闭同 deviceKey 的旧隧道 |
| 插件 | 登入覆盖会话,一律回成功(0x01) |
RespVINDup = 0x03 这个常量在代码里保留着,但没有任何一处调用它。旧连接已经被宿主踢掉了,插件看到的"重复登入"其实是重连,回成功比回拒绝更接近真实语义。
会话表是进程内的,这是明确的边界而不是疏漏:
- 插件进程重启,会话全丢,车端必须重新登入一次。对车联网场景可以接受------车端本来就有重连逻辑。
- 如果插件被部署成多实例,每个实例各存一份会话,同一台车的不同帧可能落到不同实例,会话态不共享。要做跨连接、跨实例的会话中心,得引入外部存储,这属于另一个量级的改造。当前的做法是把会话看作"连接级状态"而不是"全局状态"。
九、0x07 和 0x08:同一个编号,两套含义
GB/T 32960 的信息体是"编号 + 长度"的自描述结构:一个字节的编号,后面跟着该编号对应的固定或变长数据。这是它设计得好的地方------可以只解自己关心的那几类,其他直接跳过。
但 2016 版和 2025 版共用了一套编号空间,却给 0x07 和 0x08 分配了完全不同的含义:
| 信息体编号 | 2016 版含义 | 2025 版含义 |
|---|---|---|
| 0x01 | 整车数据(20 字节定长) | 整车数据(同结构) |
| 0x02 | 驱动电机数据(1 + N×12) | 驱动电机数据 |
| 0x03 | 燃料电池数据 | 燃料电池数据 |
| 0x04 | 发动机数据(5 字节) | 发动机数据 |
| 0x05 | 车辆位置(9 字节) | 车辆位置 |
| 0x06 | 极值数据(14 字节) | 极值数据 |
| 0x07 | 报警数据 | 动力蓄电池电压 |
| 0x08 | 可充电储能装置电压 | 动力蓄电池温度 |
| 0x09 | 可充电储能装置温度 | --- |
| 0x30 | --- | 燃料电池电堆(摘要) |
这意味着同一个 0x07,按 2016 解出来是"最高报警等级 + 通用报警标志 + 故障码列表",按 2025 解出来是"电池包数量 + 单体数量 + 单体电压列表"。用错版本不会报错,只会静默产出垃圾数据。
所以 parseRealtime 的签名里带着版本,而两个分支的判定条件必须同时写上版本号:
go
case ver == Version2025 && infoType == 0x07:
n = parseBatteryVoltage2025(rest, props)
case ver == Version2025 && infoType == 0x08:
n = parseBatteryTemp2025(rest, props)
case ver == Version2016 && infoType == 0x07:
n = parseAlarm2016(rest, props)
case ver == Version2016 && infoType == 0x08:
n = parseStorageVoltage2016(rest, props)
0x02(驱动电机)和 0x03(燃料电池)这两个分支没有版本判断 ,因为两个版本的字段布局按国标是一致的。而 0x30 那一支也没有版本判断------注释指向 2025 的燃料电池电堆摘要,但 2016 的报文如果带上这个编号,同样会按 2025 语义解析。2016 版没有定义 0x30,所以这属于"宁可多解一次,也不多写一个判断"的取舍。
版本从哪来?就来自第一个字节:## 是 2016,$$ 是 2025。帧头自带版本标识,不需要额外配置。
十、一个函数吃掉四种变长列表
信息体里最麻烦的不是定长结构,是"带数量的列表"。GB/T 32960 里有四种:
| 结构 | 头长度 | 数量的位置 | 数量宽度 | 每项长度 |
|---|---|---|---|---|
| 2016 储能装置电压 | 10 字节 | 第 9 字节 | uint8 | 2 字节 |
| 2016 储能装置温度 | 3 字节 | 第 1 字节 | uint16 | 1 字节 |
| 2025 电池电压 | 7 字节 | 第 5 字节 | uint16 | 2 字节 |
| 2025 电池温度 | 3 字节 | 第 1 字节 | uint16 | 1 字节 |
四种结构看着不一样,抽出来其实是同一个模式:"数量的位置"和"数量宽度"和"每项长度"三个参数的不同组合。于是一个函数就够:
go
// countListLen walks a count-prefixed list: data[0] is the item count, each item
// is head bytes plus a variable part sized by a nested count at nestedAt within
// the head (nestedWidth 1 = uint8, 2 = uint16 BE) times scale.
func countListLen(data []byte, head, nestedAt, nestedWidth, scale int) int {
if len(data) < 1 {
return 0
}
count := int(data[0])
off := 1
for range count {
if len(data) < off+head {
return 0
}
var n int
if nestedWidth == 1 {
n = int(data[off+nestedAt])
} else {
n = int(binary.BigEndian.Uint16(data[off+nestedAt : off+nestedAt+2]))
}
off += head + n*scale
}
return off
}
四个调用点各自只差那一行:
go
func storageVoltageLen2016(data []byte) int { return countListLen(data, 10, 9, 1, 2) }
func storageTempLen2016(data []byte) int { return countListLen(data, 3, 1, 2, 1) }
func batteryVoltageLen2025(data []byte) int { return countListLen(data, 7, 5, 2, 2) }
func batteryTempLen2025(data []byte) int { return countListLen(data, 3, 1, 2, 1) }
注意这里的返回值是**"这一项占多少字节",不是"解出了什么数据"**。解析函数拿到长度后,只解第一项,其余原样跳过:
go
if len(rest) >= 8 {
props["battery_pack_count"] = rest[0]
props["pack1_voltage"] = float64(binary.BigEndian.Uint16(rest[2:4])) * 0.1
props["pack1_current"] = float64(binary.BigEndian.Uint16(rest[4:6]))*0.1 - 3000
props["pack1_cell_count"] = binary.BigEndian.Uint16(rest[6:8])
}
一个电池包最多几百个单体,一台车最多几个包,全量展开会是几百个属性。展平第一包的关键量、其余只用来推进游标------这样既拿到了最有诊断价值的量(包电压、包电流、单体数),又不会把物模型撑成一个几百字段的宽表。
代价也很明确:物模型里 pack1_voltage 这个名字只代表"第一个包"。多包车辆的第二个包去哪了,要等到有人真的需要时再谈。
storageVoltageLen2016 里还有个容易看漏的细节:电压取 rest[2:4] 而不是 rest[1:3]。因为头两个字节是"子系统数 + 子系统号",电压从第 3 个字节才开始。这类偏移错一位,读出来的就是完全错误的数值,而且不会报错。
十一、量纲与偏置:读对了字节,还得换算对
定长结构解出来的是原始整数,国标给了每一条的换算规则。整车数据那 20 个字节里挤了 13 个量:
| 属性 | 原始 | 换算 |
|---|---|---|
speed |
uint16 BE | × 0.1,km/h |
mileage |
uint32 BE | × 0.1,km |
total_voltage |
uint16 BE | × 0.1,V |
total_current |
uint16 BE | × 0.1 − 1000,A |
soc |
uint8 | 直读,% |
insulation_resistance |
uint16 BE | 直读,kΩ |
accel_pedal / brake_pedal |
uint8 | 直读,% |
vehicle_status / charge_status / run_mode / dc_dc_status / gear |
uint8 | 直读,枚举 |
最需要留神的是三处偏置:
go
props["speed"] = float64(binary.BigEndian.Uint16(b[3:5])) * 0.1
props["total_current"] = float64(binary.BigEndian.Uint16(b[11:13]))*0.1 - 1000
total_current 的 −1000 是为了让"充电/放电"两个方向都能落在同一个无符号字段里(0.1A 精度下 ±1000A 的范围)。同类偏置还有:电机转速 −20000 r/min(motor1_speed)、温度 −40 ℃(max_temp / min_temp / pack1_temp0)、2025 的电池包电流 −3000。
而且同一个物理量在不同信息体里的偏置量不一样 :2016 的 storage1_current 是 −1000,2025 的 pack1_current 是 −3000。这不是笔误------两版的数据单元字段宽度和量程都变了。
坐标的换算容易被忽略:
go
lon := int32(binary.BigEndian.Uint32(b[1:5]))
lat := int32(binary.BigEndian.Uint32(b[5:9]))
props["longitude"] = float64(lon) * 1e-6
props["latitude"] = float64(lat) * 1e-6
先转 int32 再乘 1e-6,因为经度有正负(西经为负),而原始字段是无符号的。直接 float64(uint32(...)) * 1e-6 在西经位置会得到一个 4000 多度的经度。
极值数据里的电压精度比整车数据高一个量级:整车电压是 ×0.1,极值电压是 ×0.001。同一个"电压"在同一个协议里有两种精度,混用会让单体电压看起来只有实际值的三分之一。
还有一个类型不一致的地方值得一提:vehicle_status 之类直接赋的是 uint8,而 speed 赋的是 float64,motor1_speed 赋的是 int。这些值最后都要经过物模型做类型校验,物模型里把属性定义成 double,而插件塞进去的是 uint8,就会在入库时被拒。写物模型的时候得照着插件的实际类型来,不能照着自己想象中的类型来。
十二、时间、加密,和几处静默
时间是"本地墙钟"
GB/T 32960 的时间字段是 6 个字节:年(减 2000)、月、日、时、分、秒。没有时区信息。插件里的处理是直白的:
go
// timeDateUnix builds unix seconds from calendar fields in local timezone.
func timeDateUnix(y, mo, d, h, mi, s int) int64 {
return time.Date(y, time.Month(mo), d, h, mi, s, 0, time.Local).Unix()
}
time.Local 意味着结果取决于插件进程所在机器的时区 。在国内部署、时区为 UTC+8 的机器上是对的;容器里如果 TZ 没设、跑在 UTC 上,每条数据的时间戳都会偏 8 小时。这不是 bug,是协议本身没带时区导致的天然歧义------但它是部署时必须确认的一件事。
登入时间的处理藏了一个细节:handleLogin 优先用报文里的时间 ,解析失败才退回 time.Now():
go
loginUnix := time.Now().Unix()
if u, ok := ParseTime6(pkt.DataUnit); ok {
loginUnix = u
}
ParseTime6 里有基本的合法性检查(月份 1~12、日期 1~31),越界就返回 ok=false。所以一台时钟没校准的车不会把"1970"这个时间戳写进会话表。
加密:AES-128-ECB,没有 IV
加密方式字段有三个取值,插件实际支持两个:
go
switch encrypt {
case EncNone, 0:
return dataUnit, nil
case EncRSA:
return nil, errors.New("RSA encryption not supported; use plaintext or AES128")
case EncAES128:
key := aesKeyFromEnv()
if key == nil {
return nil, errors.New("AES128 frame but GBT32960_AES_KEY unset")
}
return aesECBDecrypt(key, dataUnit)
default:
return nil, errors.New("unknown encryption mode")
}
EncAES128 是 AES-128-ECB + PKCS7 填充 。ECB 模式在现代密码学里是被诟病的(相同明文块产生相同密文块,泄露结构信息),但国标就是这么定的,插件不能自己改成 CBC------改了就跟车端对不上。
EncRSA 明确返回错误而不是"尽力而为"。一个不支持的加密方式如果静默降级成明文解析,会产出一堆看起来像数据的垃圾;明确报错至少能让运维看到真正的原因。
密钥从环境变量 GBT32960_AES_KEY 读,两种写法都认:32 个十六进制字符,或直接 16 字节原文。没配密钥时收到加密帧会被拒绝 ,但拒绝方式很讲究------nopReply(..., RespFail) 加一个 encrypt_error 字段:
go
du, err := maybeDecryptDataUnit(pkt.Encrypt, pkt.DataUnit)
if err != nil {
out := nopReply(pkt, pkt.Command, RespFail)
out.Data.(map[string]any)["encrypt_error"] = err.Error()
return out
}
车端会收到一个失败的应答(而不是石沉大海),运维可以从应答里读到原因。这一帧不入库。
三处静默
写协议解析最怕的不是解析失败,是解析成功但结果不对。这份实现里有三处属于这个类别:
第一,未知信息体编号直接截断后续。 parseRealtime 的 default 分支是这样写的:
go
default:
return tsUnix, props, ""
也就是说,只要碰到一个不认识的编号,这一帧后面所有信息体全部丢弃,而且不报错、不记日志。一帧 0x02 里通常有 3~6 个信息体,如果车端发了一个 2025 才定义、而平台按 2016 在解的编号,后面的位置、极值、电池数据会一起消失。物模型里表现为"这几条数据时有时无"。
第二,解析函数的错误信息被丢掉了。 handleRealtime 拿返回值时用了下划线:
go
ts, props, _ := parseRealtime(pkt.Version, pkt.DataUnit)
parseRealtime 的第三个返回值是 errMsg,里面写好了 "vehicle data short"、"location data short" 这类具体原因。但在调用点直接被丢弃------既不进日志,也不进应答。排障时只能看到"属性少了几个",看不到"为什么少"。
第三,parseFuelCellSimple 的失败也是静默的。 别的分支长度不足会返回一句具体的错误消息,这一支返回的是空串:
go
case infoType == 0x03: // 燃料电池:完整长度 = 7 + N探针 + 8
n = parseFuelCellSimple(rest, props)
if n == 0 {
return tsUnix, props, ""
}
这三处都不是崩溃,都是在"少一点数据"的边缘上悄悄退化。协议解析代码里,静默退化比崩溃更难查------崩溃至少有个栈。
十三、不做的,和没做的
| 项 | 状态 | 原因 |
|---|---|---|
| 平台登入 0x05 | 不做 | 那是平台与平台之间互联用的,车载终端不用这条 |
| RSA 加密(0x02) | 不做 | 只支持明文与 AES128,遇到 RSA 帧明确报错 |
| 2025 版全量信息体 | 只做主路径子集 | 整车、电机、位置、极值、电池电压/温度已覆盖;其余待有真实数据源再补 |
| 跨进程会话中心 | 不做 | 会话是连接级状态;进程重启后车端重新登入,可接受 |
| 真车端到端联调 | 未做 | 用自带模拟器顶上 |
| 入站心跳判活 | 不做 | 见第五节,通用层判活会在批量补发时误判 |
"用模拟器顶上"不是一句敷衍。tools/simulator/gbt32960 是一个自带的最小车端,登入 → 周期实时 → 心跳,二十来行的实时数据单元构造:
go
du = append(du, time6(t)...)
du = append(du, 0x01) // vehicle
body := make([]byte, 20)
body[0] = 1 // 启动
body[2] = 1 // 纯电
binary.BigEndian.PutUint16(body[3:5], 120) // 12.0 km/h
binary.BigEndian.PutUint32(body[5:9], 10000) // 1000.0 km
binary.BigEndian.PutUint16(body[9:11], 3600) // 360.0 V
body[13] = 75 // SOC
跑起来只差两条命令:
bash
cd tools/simulator
GOWORK=off go run ./gbt32960 \
-platform 127.0.0.1:32960 \
-vin LSVA12E40N2186903 \
-period 5s \
-heartbeat 30s
-v2025 一个开关就能切到 $$ 起始符,用来验双版本分支。配合平台侧"填入推荐配置"的网络服务(注册 offset=4 size=17 trimNull + Stick offset=22 len=2 big adjustment=1),整条链路的验证不需要一辆车。
最后
这次改造真正有意思的地方,不是协议本身------GB/T 32960 的编解码在国标文档里写得足够清楚,一个熟练的 Go 工程师照着抄一遍大概两三天。有意思的是平台为了承接它必须改什么。
"只写一个协议插件就够了"这个假设,在遇到第一个会话型二进制协议时就破了。插件跑在独立进程里,隔着 RPC 看字节流,它天然看不到三件事:帧从哪切、连接属于谁、回应写到哪。这三件事必须由宿主提供,插件才有条件把自己的协议逻辑写好。
反过来说,宿主提供的这三样能力必须是可选的、默认关闭的。这个方案能安全上线,靠的就是"空 Stick = 老路径"这一条------新增的能力全部挂在显式配置后面,没有一处改动会顺手改变既有协议的行为。加能力容易,加能力而不同时惊动存量难。
项目地址: