今天买个了电子血压计模块,今天用html实现了,效果如下:
本文以 HKB-08A 血压传感器模块为例,完整拆解一个典型的嵌入式二进制串口协议: 帧结构怎么设计的、指令怎么组包发送、接收端怎么可靠解包。 文中的分析方法和代码可以直接套用到绝大多数 "帧头 + 长度 + 校验 + 命令字" 类协议上。
1. 模块与链路参数
| 接口 | UART(TTL 电平) |
| 波特率 | 115200,8 数据位,1 停止位,无校验(8N1) |
| 供电 | 5V DC,350~500mA(气泵工作时电流较大,不能用弱供电口) |
| 量程 | 0~300 mmHg,分辨率 1 mmHg |
| 接线 | 模块 TX → 主机 RX,模块 RX → 主机 TX,共地 |
注意它是二进制协议,不是文本协议——用串口助手以字符模式看是一堆乱码,必须切到 HEX 模式观察。
2. 帧结构
所有帧(主机下发、模块应答)格式统一:
+------+------+------+--------+------+-----------+
| 0xFF | 0xC0 | LEN | CKSUM | CMD | DATA ... |
+------+------+------+--------+------+-----------+
帧头 帧头 长度 校验和 命令字 数据(可选)
字段含义:
帧头 FF C0:2 字节固定同步头,接收端靠它做帧同步(resync)。
LEN:从 LEN 字节自己开始、到帧尾的总字节数。即 LEN = 1(LEN) + 1(CKSUM) + 1(CMD) + N(DATA) = 3 + N。
无数据的命令帧:LEN = 3
实时压力帧:LEN = 5
测量结果帧:LEN = 8
CKSUM:校验和 = 从 LEN 开始(含 LEN)到帧尾所有字节累加取低 8 位(mod 256)。 注意 CKSUM 自己不参与求和——求和时跳过它。
CMD:命令字,决定 DATA 怎么解释。
DATA:0~N 字节载荷。
用厂商手册的样例帧验证
结果帧样例:FF C0 08 E0 AC 00 7B 00 4A 67
FF C0 帧头
08 LEN = 8,即 08 起到帧尾共 8 字节 → 全帧 10 字节 ✓
E0 CKSUM
AC CMD = 0xAC,测量结果
00 7B SSYH SSYL → 收缩压 = 0x007B = 123 mmHg
00 4A SZYH SZYL → 舒张压 = 0x004A = 74 mmHg
67 XL → 心率 = 0x67 = 103 bpm
校验:08 + AC + 00 + 7B + 00 + 4A + 67 = 0x1E0,取低 8 位 = 0xE0 ✓
这个"手工验算"的步骤在调试任何新协议时都值得做一遍——能快速确认自己对 LEN 含义、校验范围的理解是否正确。
3. 指令集(主机 → 模块)
| 唤醒 | 0xAA | FF C0 03 AD AA | 模块应答同样内容;上电/连接后建议连发 3 次(厂商例程做法) |
| 启动测量 | 0xA0 | FF C0 03 A3 A0 | 袖带开始充气,测量全程 30~60 秒 |
| 停止测量 | 0xA8 | FF C0 03 AB A8 | 中止测量、放气 |
校验口算验证:03 + AA = 0xAD,03 + A0 = 0xA3,03 + A8 = 0xAB ✓
模块应答(模块 → 主机):
| 0xAA | 3 | 无 | 唤醒应答 |
| 0xA8 | 3 | 无 | 停止应答 |
| 0xA0 | 5 | QYH QYL | 实时袖带压力,测量过程中持续上报 |
| 0xAC | 8 | SSYH SSYL SZYH SZYL XL | 测量结果 |
| 0xAD | 4 | X | 错误,X 为错误码 |
错误码(手册已确认部分):
X=1:充气异常,11 秒内压力未达到 50mmHg(检查袖带是否佩戴、气路是否漏气)
X=3:超压保护,压力超过 295mmHg
其余码值手册未列全,程序里应做兜底显示
高字节里的标志位
压力值和血压值都是 16 位(高字节在前),但高字节的高位是标志位,不是数值:
QYH.4:压力帧中的状态标志
SSYH.7:结果帧中的有效性标志
所以取数值时要把高字节掩码后再拼接,不能直接 (H << 8) | L:
压力 QY = ((QYH & 0x0F) << 8) | QYL // 实际量程 0~300,1 个数据位就够,多掩几位无害
收缩压 SSY = ((SSYH & 0x7F) << 8) | SSYL
舒张压 SZY = ((SZYH & 0x7F) << 8) | SZYL
心率 XL = XL // 单字节
厂商的 Arduino/STM32 例程偷懒直接只用低字节(血压极少超过 255),上位机里按掩码方式取更稳。
4. 发送端:组包
发送逻辑就一条规则:先算 LEN,再算 CKSUM,拼字节发出去。
JavaScript(Web Serial,上位机 host/index.html 实际代码):
function buildFrame(cmd, data = []) {
const len = 3 + data.length; // LEN + CKSUM + CMD + DATA
const cksum = (len + cmd + data.reduce((a, b) => a + b, 0)) & 0xFF;
return new Uint8Array([0xFF, 0xC0, len, cksum, cmd, ...data]);
}
// 通过 Web Serial 发送
async function sendFrame(frame) {
const writer = port.writable.getWriter();
try { await writer.write(frame); }
finally { writer.releaseLock(); } // 注意每次用完必须释放锁
}
await sendFrame(buildFrame(0xAA)); // 唤醒 → FF C0 03 AD AA
await sendFrame(buildFrame(0xA0)); // 启动 → FF C0 03 A3 A0
await sendFrame(buildFrame(0xA8)); // 停止 → FF C0 03 AB A8
C(单片机侧,对照厂商例程):
uint8_t build_frame(uint8_t *buf, uint8_t cmd, const uint8_t *data, uint8_t n)
{
uint8_t len = 3 + n, sum = 0, i;
buf[0] = 0xFF; buf[1] = 0xC0; buf[2] = len;
buf[4] = cmd;
for (i = 0; i < n; i++) buf[5 + i] = data[i];
for (i = 2; i < 5 + n; i++) sum += buf[i]; // LEN..帧尾,跳过 buf[3]
buf[3] = sum; // 自然溢出即 mod 256
return 5 + n;
}
工程实践要点:
连接建立后连发 3 次唤醒,间隔约 100ms——模块可能处于睡眠,单发一次不一定醒得来,这是厂商例程里的经验做法。
启动测量前把本地波形缓冲清空,避免上一次的压力曲线混进本次。
5. 接收端:解包(重点)
串口是字节流,没有天然的帧边界。一次 read() 可能拿到半帧、一帧半、或好几帧粘在一起。所以解包必须是一个带状态的字节流处理器,而不是"读一次解一帧"。
5.1 解包四步
拼接:新数据追加到接收缓冲区尾部。
同步(resync):从缓冲区头部扫描帧头 FF C0,找不到就丢弃扫过的字节。
等齐:按 LEN 算出整帧长度(2 + LEN),缓冲区不够就退出等下一批数据。
校验 + 取帧:校验和通过才交给命令分发;失败则只丢一个字节回到第 2 步(防止把下一帧的帧头误判掉)。
5.2 参考实现(JavaScript)
let rxBuf = []; // 接收字节缓冲(跨 read 调用保持)
// 每次串口读到数据就调用
function onSerialData(uint8arr) {
rxBuf.push(...uint8arr);
drainFrames();
}
function drainFrames() {
while (true) {
// 1) 同步:找 FF C0
let i = 0;
while (i + 1 < rxBuf.length && !(rxBuf[i] === 0xFF && rxBuf[i + 1] === 0xC0)) i++;
if (i > 0) rxBuf.splice(0, i);
if (rxBuf.length < 3) return;
// 2) 长度预检 + 等齐
const len = rxBuf[2];
if (len < 3 || len > 32) { rxBuf.splice(0, 1); continue; } // 非法 LEN:丢 1 字节重新同步
const total = 2 + len;
if (rxBuf.length < total) return; // 帧没收全,等下一批
// 3) 校验(CKSUM 不参与求和)
const frame = rxBuf.slice(0, total);
let sum = 0;
for (let k = 2; k < total; k++) if (k !== 3) sum = (sum + frame[k]) & 0xFF;
if (sum !== frame[3]) {
rxBuf.splice(0, 1); // 校验失败只丢 1 字节,而不是整帧
continue;
}
// 4) 取帧分发
rxBuf.splice(0, total);
handleFrame(frame[4], frame.slice(5));
}
}
5.3 命令分发
function handleFrame(cmd, data) {
switch (cmd) {
case 0xA0: { // 实时袖带压力
const qy = ((data[0] & 0x0F) << 8) | data[1];
updatePressure(qy); // 更新数值 + 推入波形缓冲
break;
}
case 0xAC: { // 测量结果
const ssy = ((data[0] & 0x7F) << 8) | data[1];
const szy = ((data[2] & 0x7F) << 8) | data[3];
const xl = data[4];
showResult(ssy, szy, xl);
break;
}
case 0xAD: // 错误帧
showError(ERR_TEXT[data[0]] || `未知错误码 ${data[0]}`);
break;
case 0xAA: // 唤醒应答
case 0xA8: // 停止应答
logAck(cmd);
break;
default:
logWarn(`未知命令字 0x${cmd.toString(16)}`); // 协议升级时保命的一行
}
}
5.4 状态机视角
上位机侧其实还隐含一层业务状态机:
发送 A0 收到 AC(结果)
空闲 ────────────► 测量中 ──────────────► 空闲(显示结果)
▲ │
│ ├─ 收到 AD(错误) ────► 空闲(显示错误原因)
│ └─ 发送 A8(停止) ────► 空闲
└─────── 收到 AA(唤醒应答) ───────────────┘
测量中持续收到 0xA0 压力帧,用来画袖带压力曲线——充气到高点再阶梯下降的过程本身就是一次完整的示波法测量记录,导出 CSV 后可以离线分析。
6. 调试与排错清单
实战中这类协议最常见的问题和排查顺序:
| 完全收不到数据 | TX/RX 没交叉;没共地;供电不足 | 换线、量 GND;气泵启动时电流大,5V 供电要够 |
| 收到乱码 | 波特率不对;当成文本显示了 | 确认 115200 8N1;串口工具切 HEX 模式 |
| 校验大量失败 | 对 LEN 含义或校验范围理解错了 | 拿手册样例帧手工验算(见第 2 节) |
| 解出的值离谱 | 没掩掉高字节标志位;大小端搞反 | 对照"标志位"一节检查取值逻辑 |
| 发命令没反应 | 模块在睡眠;帧格式错 | 先连发 3 次唤醒;把发送的 HEX 和手册逐字节对 |
| 测量立即报错 X=1 | 袖带没戴好/气路漏气 | 11 秒充不到 50mmHg 就会触发,先检查气路 |
工具建议:
HEX 日志必不可少——上位机里把每一帧的收发都以 HEX 形式带时间戳记下来,出问题先翻日志。
没有硬件时可以用"虚拟串口对"(如 com0com)+ 一个脚本模拟模块回帧,先把解包逻辑调通。
单元测试里最值得覆盖的就是解包器:半帧到达、多帧粘连、垃圾字节前缀、校验失败后的重新同步,各构造一个用例。
7. 小结
这个协议虽小,但把二进制串口协议的经典要素占全了:固定帧头同步、长度自描述、累加和校验、命令字分发、标志位与数值复用字节。掌握"组包算校验、解包做流式同步"这两条主线,再看 Modbus、自定义传感器协议,结构上都大同小异。

我要赚赏金
