5.2 报文编解码——SID 与正响应位的消息引擎
场景:当两个机器需要说同一种话
想象你是古巴比伦的泥板抄写员。你的任务是把国王的命令——“征粮三千石”,刻成楔形文字,烧制成泥板,然后让信差骑马送到三百里外的屯粮官手里。屯粮官收到泥板后,要按照刻痕解读出命令,确认格式无误,然后执行。
如果有任何一步出错——比如你在命令后漏刻了确认标记,屯粮官永远无法判断这块泥板到底是国王的真命令还是路上捡到的破砖头。
UDS 的报文编解码层,就是“楔形文字的刻写与解读规则“。它是整个 uds-lite 通信栈的最底层——在所有服务逻辑之前,在所有应用数据之前,必须先有这套规则。
你有 TCP socket 传来的原始字节流 [0x10, 0x03, ...],你要把它变成结构化的 UdsMsg;反过来,ECU 要发送的响应也要从 UdsMsg 变回字节流。这就是 uds_msg.h 和 uds_msg.c 的全部职责。
ISO 14229-1 花了几十页定义这些规则。但在代码层面,核心逻辑不过 200 行 C。
核心洞察: 报文编解码层的设计质量决定了整个 UDS 栈的可靠性。如果编码时漏掉了 SID+0x40,正响应可能会被丢弃;如果解码时忘了处理 suppressPosRsp,子功能码会变成奇怪的数值(bit7=1)。这不是边缘情况——这是每一帧都要经过的核心路径。
核心数据结构:UdsMsg
在开始编解码之前,我们需要一个统一的内存表示。无论请求还是响应、正向还是负向,都用同一个结构体承载:
/* uds_common.h — 共享类型与常量定义 */
#ifndef UDS_COMMON_H
#define UDS_COMMON_H
#include <stdint.h>
#include <stddef.h>
typedef uint8_t u8;
typedef uint16_t u16;
typedef uint32_t u32;
typedef int16_t s16;
#ifndef TRUE
#define TRUE 1
#endif
#ifndef FALSE
#define FALSE 0
#endif
/* ─── SID 常量 ─── */
#define SID_DIAGNOSTIC_SESSION_CONTROL 0x10
#define SID_ECU_RESET 0x11
#define SID_CLEAR_DIAGNOSTIC_INFORMATION 0x14
#define SID_READ_DTC_INFORMATION 0x19
#define SID_READ_DATA_BY_IDENTIFIER 0x22
#define SID_SECURITY_ACCESS 0x27
#define SID_WRITE_DATA_BY_IDENTIFIER 0x2E
#define SID_ROUTINE_CONTROL 0x31
#define SID_REQUEST_DOWNLOAD 0x34
#define SID_TRANSFER_DATA 0x36
#define SID_REQUEST_TRANSFER_EXIT 0x37
#define SID_TESTER_PRESENT 0x3E
#define SID_CONTROL_DTC_SETTING 0x85
#define SID_POSITIVE_RESPONSE_MASK 0x40
#define NEGATIVE_RESPONSE_SID 0x7F
/* ─── 子功能码 ─── */
#define SUB_ZERO 0x00
#define SUB_HARD_RESET 0x01
#define SUB_KEY_OFF_ON_RESET 0x02
#define SUB_SOFT_RESET 0x03
#define SUB_REQUEST_SEED 0x01
#define SUB_SEND_KEY 0x02
#define SUB_START_ROUTINE 0x01
#define SUB_STOP_ROUTINE 0x02
#define SUB_REQUEST_ROUTINE_RESULTS 0x03
#define SUB_DTC_ON 0x01
#define SUB_DTC_OFF 0x02
#define SUPPRESS_POS_RSP_MASK 0x80
/* ─── NRC 常量 ─── */
#define NRC_GENERAL_REJECT 0x10
#define NRC_SERVICE_NOT_SUPPORTED 0x11
#define NRC_SUBFUNCTION_NOT_SUPPORTED 0x12
#define NRC_INCORRECT_MESSAGE_LENGTH 0x13
#define NRC_CONDITIONS_NOT_CORRECT 0x22
#define NRC_REQUEST_SEQUENCE_ERROR 0x24
#define NRC_REQUEST_OUT_OF_RANGE 0x31
#define NRC_SECURITY_ACCESS_DENIED 0x33
#define NRC_INVALID_KEY 0x35
#define NRC_EXCEEDED_NUMBER_OF_ATTEMPTS 0x36
#define NRC_WRONG_BLOCK_SEQ_COUNTER 0x73
#define NRC_RESPONSE_PENDING 0x78
#define NRC_SERVICE_NOT_IN_ACTIVE_SESSION 0x7F
/* ─── 会话与安全 ─── */
#define SESSION_DEFAULT 0x01
#define SESSION_PROGRAMMING 0x02
#define SESSION_EXTENDED 0x03
#define SECURITY_LOCKED 0x00
#define SECURITY_LEVEL_1 0x01
/* ─── 缓冲区 ─── */
#define UDS_MAX_MSG_LEN 512
#define UDS_MAX_DID_DATA 64
#define UDS_MAX_DTC_COUNT 16
#define UDS_DTC_CODE_LEN 3
#define UDS_SERVER_PORT 13400
#define UDS_RX_TIMEOUT_SEC 5
/* ─── 定时参数 ─── */
#define P2_SERVER_DEFAULT 50
#define P2_STAR_SERVER_DEFAULT 5000
#define S3_SERVER_DEFAULT 5000
#endif /* UDS_COMMON_H */
注意 subFunction 字段的设计——它同时承载了三个语义:
- bit6-0:子功能号(如 0x01=defaultSession, 0x02=programmingSession)
- bit7:
suppressPosRsp标志——如果置 1,服务端执行完毕后不发送正响应
在解码时需要用位掩码分离这两个信息,在编码时需要在正响应中回显 bit6-0。
构建正响应——为什么 +0x40 如此优雅
正响应的构建规则极其简单:把请求的 SID 加上 0x40,就是正响应的 SID。
/* uds_msg.c —— 构建正响应 */
u16 uds_build_positive_response(u8 *out, u8 request_sid,
const u8 *extra_data, u16 extra_len)
{
u16 pos = 0;
out[pos++] = request_sid | SID_POSITIVE_RESPONSE_MASK;
if (extra_data && extra_len > 0) {
memcpy(&out[pos], extra_data, extra_len);
pos += extra_len;
}
return pos;
}
为什么是 +0x40 而不是别的数字?
看一张十六进制表:
请求SID 二进制 正响应SID 二进制
0x10 0001 0000 0x50 0101 0000
0x22 0010 0010 0x62 0110 0010
0x31 0011 0001 0x71 0111 0001
+0x40 其实就是把 bit6 从 0 翻成 1。 标准把 SID 的 bit6 定义为“正响应标志位“。这意味着:
- 你不需要查表来判定一个收到的报文是不是正响应——
(sid & 0x40) != 0一行代码就解决 - 你不需要维护“请求→正响应“的映射表——
sid + 0x40就是纯算术 - CPU 执行
add指令比查表快几百倍,这对嵌入式实时系统至关重要
核心洞察: ISO 14229 的作者在设计 SID 编码空间时,不是随意分配的。bit6 = 正响应标志,bit7-6 = 00/01/10/11 分别划给不同的服务组。这意味着协议的字节布局本身就是接口文档——你不需要查规范就能知道 0x62 是 0x22 的正响应。这是通信协议设计中“自描述性“(self-descriptiveness)的经典范例。
解析请求——提取 SID、子功能和数据
解码请求报文时,需要小心处理几个边界:
void uds_parse_message(const u8 *raw, u16 raw_len, UdsMsg *msg)
{
if (raw_len < 1) return;
u8 first_byte = raw[0];
if (first_byte == NEGATIVE_RESPONSE_SID) {
/* 负响应: {0x7F, 原SID, NRC} */
msg->sid = (raw_len >= 2) ? raw[1] : 0;
msg->sub_function = (raw_len >= 3) ? raw[2] : 0;
msg->is_positive = FALSE;
msg->data_len = 0;
} else if (first_byte & SID_POSITIVE_RESPONSE_MASK) {
/* 正响应: SID|0x40 开头 */
msg->sid = first_byte & ~SID_POSITIVE_RESPONSE_MASK;
msg->is_positive = TRUE;
u16 remaining = raw_len - 1;
if (remaining > UDS_MAX_MSG_LEN) remaining = UDS_MAX_MSG_LEN;
if (remaining > 0) {
memcpy(msg->data, &raw[1], remaining);
msg->data_len = remaining;
} else {
msg->data_len = 0;
}
msg->sub_function = (remaining >= 1) ? raw[1] : 0;
} else {
/* 普通请求 */
msg->sid = first_byte;
msg->is_positive = FALSE;
msg->sub_function = (raw_len >= 2) ? (raw[1] & 0x7F) : 0;
u16 remaining = (raw_len >= 2) ? raw_len - 2 : 0;
if (remaining > UDS_MAX_MSG_LEN) remaining = UDS_MAX_MSG_LEN;
if (remaining > 0) {
memcpy(msg->data, &raw[2], remaining);
msg->data_len = remaining;
} else {
msg->data_len = 0;
}
}
}
关键逻辑:
- 第一个字节一定是 SID
- 如果 SID 的 bit6=0 且还有第二个字节,第二个字节是子功能码
- 子功能码的 bit7 是 suppressPosRsp——这是个“安静模式“开关:医生开了检查单但不需要立刻看结果,ECU 可以默默执行
- 去掉子功能码后,剩余的都是数据负载
构建负响应——永远以 0x7F 开头
当 ECU 无法执行请求时,它发送负响应。负响应的格式是 {0x7F, 原始SID, NRC}——永远三字节:
u16 uds_build_negative_response(u8 *out, u8 original_sid, u8 nrc)
{
out[0] = NEGATIVE_RESPONSE_SID;
out[1] = original_sid;
out[2] = nrc;
return 3;
}
为什么负响应的 SID 是 0x7F?
0x7F = 0111 1111(二进制),bit7 不是 0 也不是 1——它挤在正响应区(0x40-0x7E)和请求区(0x00-0x3E)之间,但恰好超出标准 SID 范围的最后一个值。这样设计确保了:
- 负响应不会被误判为请求(0x7F > 0x3E)
- 负响应不会被误判为正响应(正响应范围是 0x40+对应SID,最大 0x7E)
(0x7F & 0x3F) = 0x3F和任何正响应都不冲突
带子功能回显的正响应
某些服务(例如 0x10 DiagnosticSessionControl、0x27 SecurityAccess、0x31 RoutineControl)的正响应,要求第一个数据字节回显请求的子功能码。调用方只需将子功能码作为 extra_data 的第一个字节传入 uds_build_positive_response 即可:
/* 以 0x10 sessionControl 为例: 子功能码 + P2/P2* 时间 */
u8 extra[] = {sub, (P2_SERVER_DEFAULT >> 8), P2_SERVER_DEFAULT & 0xFF,
(P2_STAR_SERVER_DEFAULT >> 8) & 0xFF, P2_STAR_SERVER_DEFAULT & 0xFF};
*resp_len = uds_build_positive_response(resp, SID_DIAGNOSTIC_SESSION_CONTROL, extra, 6);
带 DID 回显的正响应
对于 0x22 ReadDataByIdentifier,正响应不仅要包含数据,还要回显被读取的 DID(数据标识符)本身。handle_read_did 手动构建响应以确保灵活的多 DID 支持:
u8 out[UDS_MAX_MSG_LEN];
u16 pos = 0;
out[pos++] = SID_READ_DATA_BY_IDENTIFIER | SID_POSITIVE_RESPONSE_MASK;
while (idx + 1 < req_len) {
u16 did = uds_read_u16_be(&req[idx]);
DidRecord *d = find_did(did);
out[pos++] = (did >> 8) & 0xFF;
out[pos++] = did & 0xFF;
memcpy(&out[pos], d->data, d->data_len);
pos += d->data_len;
idx += 2;
}
memcpy(resp, out, pos);
*resp_len = pos;
注意这里显式处理了字节序(Endianness)问题。ISO 14229 规定 DID、RoutineID、blockSequenceCounter 等多字节字段均使用大端序(Big-Endian)——高字节在前。这是嵌入式通信中常见的“网络字节序“约定。
解析正/负响应——客户端的解码逻辑
客户端收到原始字节流后,需要判断是正响应还是负响应:
void uds_parse_message(const u8 *raw, u16 raw_len, UdsMsg *msg)
{
if (raw_len < 1) return;
u8 first_byte = raw[0];
if (first_byte == NEGATIVE_RESPONSE_SID) {
msg->sid = (raw_len >= 2) ? raw[1] : 0;
msg->sub_function = (raw_len >= 3) ? raw[2] : 0;
msg->is_positive = FALSE;
msg->data_len = 0;
} else if (first_byte & SID_POSITIVE_RESPONSE_MASK) {
msg->sid = first_byte & ~SID_POSITIVE_RESPONSE_MASK;
msg->is_positive = TRUE;
u16 remaining = raw_len - 1;
if (remaining > UDS_MAX_MSG_LEN) remaining = UDS_MAX_MSG_LEN;
if (remaining > 0) {
memcpy(msg->data, &raw[1], remaining);
msg->data_len = remaining;
}
}
}
判断逻辑极其简单:
- 首字节 = 0x7F?→ 负响应,取第3字节为 NRC
- 首字节 bit6 = 1?→ 正响应
- 其他?→ 格式错误
这正是协议设计的优雅之处——不需要状态机,不需要查表,只需要两个条件判断就能区分整个 UDS 协议的三种报文类型。
完整的 uds_msg.h 头文件
/* uds_msg.h — uds-lite 报文序列化层
* 职责: UDS 请求/响应的编解码,正响应位 0x40 置位,负响应三字节格式。
*/
#ifndef UDS_MSG_H
#define UDS_MSG_H
#include "uds_common.h"
/* ── 通用消息类型 ────────────────────────── */
typedef struct {
u8 sid; /* SID 或 SID|0x40 (正响应时) */
u8 sub_function; /* 子功能码 (含 suppressPosRsp 位) */
u8 data[UDS_MAX_MSG_LEN]; /* 附加数据 */
u16 data_len; /* 数据实际长度 */
u8 is_positive; /* TRUE = 正响应, FALSE = 请求 */
} UdsMsg;
/* ── DID 数据记录 ────────────────────────── */
typedef struct {
u16 did;
u8 data[UDS_MAX_DID_DATA];
u16 data_len;
u8 writable; /* TRUE = 支持 0x2E 写入 */
u8 session_required; /* 最低会话要求 */
u8 security_required; /* 最低安全等级 */
} DidRecord;
/* ── DTC 记录 ────────────────────────────── */
typedef struct {
u8 code[UDS_DTC_CODE_LEN]; /* 3字节 DTC 编码 */
u8 status; /* DTC 状态位 */
u16 snapshot_dids[UDS_MAX_SNAPSHOT_DIDS];
u8 snapshot_data[UDS_MAX_SNAPSHOT_DIDS][UDS_MAX_DID_DATA];
u8 snapshot_count;
} DtcRecord;
/* ── 下载传输状态 ────────────────────────── */
typedef struct {
u8 active; /* 传输是否正在进行 */
u8 direction; /* 0=下载, 1=上传 */
u32 address;
u32 remaining_size;
u16 max_block_size;
u8 expected_seq_counter;
} TransferState;
/* ── 例行程序定义 ────────────────────────── */
typedef struct {
u16 rid;
u8 activated;
u8 current_status;
s16 progress;
} RoutineState;
/* ── API ─────────────────────────────────── */
/* 构建 UDS 请求消息。此函数被 client/shell 调用。 */
void uds_build_request(UdsMsg *msg, u8 sid, u8 sub_function,
const u8 *data, u16 data_len);
/* 从原始字节解析 UDS 消息。判断正响应/负响应/请求类型。 */
void uds_parse_message(const u8 *raw, u16 raw_len, UdsMsg *msg);
/* 构建负响应字节序列: {0x7F, 原SID, NRC} */
u16 uds_build_negative_response(u8 *out, u8 original_sid, u8 nrc);
/* 构建正响应: SID|0x40 + 可变数据 (包含子功能字节时需调用方放入extra_data) */
u16 uds_build_positive_response(u8 *out, u8 request_sid,
const u8 *extra_data, u16 extra_len);
/* 从字节流中提取 DID (2字节大端序) */
u16 uds_read_u16_be(const u8 *buf);
/* 将 u16 以大端序写入字节流 */
void uds_write_u16_be(u8 *buf, u16 val);
#endif /* UDS_MSG_H */
NRC 名称映射——客户端和 Shell 的内置解析
NRC 名称映射不是编解码层的核心职责,而是客户端和 Shell 的使用者工具。实际使用时,每个消费者有自己的映射方式——例如 uds_client.c 中的 nrc_name() 函数或 uds_shell.c 中的 switch(buf[2]) 。
工程实践:单元测试编解码器
写完编解码层后,应该立刻用一组单元测试验证。虽然在 uds-lite 里我们没有引入测试框架,但可以用 assert() 快速验证:
/* test.sh —— 集成测试自动验证编解码+全流程 */
# 启动服务器 → 运行客户端 → 运行 Shell 命令 → 对比结果
# 13 项测试覆盖: session切换、security解锁、DID读写、DTC查询、
# download三次握手、routine控制、ECU复位
这些测试通过 test.sh 自动运行,验证 server/client/shell 三端的完整闭环。
核心洞察: 如果你只记住本节的一个要点,请记住这个:UDS 的所有报文格式差异只需两个 bit 判断——
sid == 0x7F(负响应)和sid & 0x40(正响应)。 这种设计让你在微控制器上用一个 8 位寄存器就能完成报文类型判别,不需要查表,不需要比较指令链,不需要状态机。这是为 1980 年代的 8-bit MCU 优化过的协议,到今天依然完美适用。
本篇小结
UdsMsg结构体是所有报文的统一内存表示——请求、正响应、负响应共用同一套字段- 正响应 SID = 请求 SID + 0x40,bit6 是正响应标志位——这是整个 UDS 协议最优雅的设计
- 负响应格式固定为
{0x7F, 原始SID, NRC},0x7F 的选择确保了与任何合法请求/正响应不冲突 - 子功能码的 bit7 = suppressPosRsp(抑制正响应),bit6-0 = 实际子功能号
- 解码逻辑只需两个条件判断:
sid == 0x7F和sid & 0x40 - DID、RoutineID、blockSequenceCounter 等字段使用大端序——高字节在前
- 单元测试编解码层能在几分钟内验证所有基础规则,是投入产出比最高的测试
【下集预告】:报文编解码的“语法“已经就绪。接下来,我们要让这些字节流动起来——写一个真正的 ECU 服务器。你将亲手实现 SID 分发表、会话状态机、S3 超时定时器、安全锁、DID 存储器……当 TCP 连接建立,第一个
0x10 0x03到达时,你的服务器会解析它、判断它、执行它、回答它。这不是看代码——这是你的代码第一次“活过来“。