该方案讨论的是电子血压计里最容易被低估的一段路,主控 MCU 已经把收缩压、舒张压、脉率算出来了,这三个数字怎么穿过一颗蓝牙语音芯片,最后落在手机 App 的界面上。很多项目在这一段翻车,翻车的位置几乎不在蓝牙本身,而在串口组帧和 App 解帧这两头。
下面按真实调试顺序拆,从串口拿到数据开始,一路讲到 App 解析代码,帧结构和校验值都是算过的,可以直接抄。
一、先说三个最容易被误判的现场症状
做血压计同步功能,工程师反馈最多的是下面三类现象,它们的根因和第一直觉往往对不上。
症状一,App 收到的数据偶尔多一段、偶尔少一段。 直觉判断是蓝牙丢包,实际九成是串口解帧用了起始码 7E 做同步。收缩压 126 的十六进制正好是 0x7E,跟起始码一模一样。只要数据域里出现 0x7E,靠起始码切帧的解析程序就会在中间重新开一帧,后面全部错位。
症状二,芯片回了 00,App 一帧都收不到。 透传指令的返回码 00 只代表芯片把数据塞进了发送缓冲,不代表手机收到了。主机没使能 notify 的时候,芯片会单独返回 04,这个码常被忽略。
症状三,播报播到一半就哑了。 主控发完数据立刻发休眠指令,语音被腰斩。休眠指令不等播报结束,要靠 BUSY 脚或者查询播放状态来收尾。
二、整条链路其实是四段,先分清楚出问题时在哪一段
把链路拆开看,故障定位会快很多。
| 链路段 | 物理承载 | 关键参数 | 常见故障点 |
| 第一段 主控到芯片 | UART,默认 9600,3.3V TTL 电平,8 位数据位无校验 1 位停止位 | 帧长度字段、累加和校验 | 数据域里出现 7E 导致解析错位 |
| 第二段 芯片内部 | 协议栈把串口帧转成透传通道载荷 | 透传模式裸流或不裸流 | 模式选错,App 端收到带协议头的数据 |
| 第三段 芯片到手机 | BLE 5.4 射频,透传服务 notify | 单次参数最大 331 字节,实际受 MTU 约束 | MTU 未协商,长包被截断 |
| 第四段 手机侧 | App 收到 notify 字节流 | 自定义业务帧格式 | CRC 校验没做,偶发错值直接显示 |
该芯片用的是 WT2801Ax,QFN32 封装 4 毫米见方,32 位处理器最高 240MHz,工作电压 2.7 到 5.0V,串口波特率有 11 档可选,最高 921600。血压计这种电池供电又要求小体积的整机,这颗芯片把语音播报和蓝牙透传合成一颗,比 MCU 外挂蓝牙模块再加一颗语音芯片省一块板面积。
三、串口这一段,把帧结构钉死再往下走
芯片的串口协议是定长头加变长参数的结构,格式如下。
| 起始码 | 长度 | 命令码 | 参数 | 累加和校验 | 结束码 |
| 7E | 2 字节 | 3 字节 | N 字节 | 1 字节 | EF |
两个必须自己算对的字段。
长度字段。 官方的写法是长度加命令码加参数加校验和的字节数,换算出来是参数字节数加 6。我用说明书里的 7 组示例逐条验过,包括播放、音量、组合播放、透传发送、广播间隔、发射功率,全部吻合。
写成公式就是
长度 = 参数字节数 + 6
累加和校验。 从长度高字节开始,一直加到最后一个参数字节,取累加结果的低 8 位。校验范围不包含起始码 7E 和结束码 EF。给一段 C 代码。
uint8_t wt_checksum(uint8_t len_h, uint8_t len_l,
uint8_t c1, uint8_t c2, uint8_t c3,
uint8_t *param, uint8_t n)
{
uint16_t sum = len_h + len_l + c1 + c2 + c3;
for (uint8_t i = 0; i < n; i++) sum += param[i];
return (uint8_t)(sum & 0xFF);
}
解帧必须用长度驱动,不能用起始码驱动。 前面说的 0x7E 冲突就是这里。正确做法是先找 7E,接着读两个字节长度,再用长度把整帧圈出来,最后校验结束码和累加和。任何一步不对就丢掉这个字节,从下一个 7E 重新开始。
// 串口接收状态机,逐字节喂进来
typedef enum { WAIT_HEAD, WAIT_LEN_H, WAIT_LEN_L, WAIT_BODY } RxState;
void wt_rx_byte(uint8_t b)
{
static RxState st = WAIT_HEAD;
static uint8_t buf[356];
static uint16_t idx = 0, need = 0;
switch (st) {
case WAIT_HEAD:
if (b == 0x7E) { buf[0] = b; idx = 1; st = WAIT_LEN_H; }
break;
case WAIT_LEN_H:
buf[idx++] = b; need = (uint16_t)b << 8; st = WAIT_LEN_L;
break;
case WAIT_LEN_L:
buf[idx++] = b;
need = (need | b) + 2; // 长度值只算到校验字节,加 2 补上 7E 和 EF
if (need > sizeof(buf)) { st = WAIT_HEAD; idx = 0; break; }
st = WAIT_BODY;
break;
case WAIT_BODY:
buf[idx++] = b;
if (idx >= need) { // 收满整帧再判结束码
if (buf[idx - 1] == 0xEF) wt_on_frame(buf, idx);
st = WAIT_HEAD; idx = 0; // 结束码不对就整帧丢弃
}
break;
}
}
波特率这块给个建议。9600 默认档跑 20Hz 的结果上报完全够,但要回放袖带放气过程的压力曲线,采样率上到 50Hz 就吃紧了。50Hz 乘每帧 14 字节是 700 字节每秒,波特率 9600 的理论上限是 960 字节每秒,余量不到三成。这种项目建议上电直接切到 115200,代号是 0x06。切换指令有掉电记忆,主控要在发完指令后 100 毫秒内同步改自己的波特率,不然返码会收乱。
四、血压数据怎么塞进透传通道
透传模式由 FF 05 01 指令决定,参数 00 是裸流,01 是不裸流,默认 00。两种模式的差别直接决定 App 端收到什么。
| 对比项 | 裸流模式(参数 00) | 不裸流模式(参数 01) |
| 从机发送指令 | FF 05 02 | FF 05 02 |
| 手机收到内容 | 只有参数部分 | 完整串口帧,含 7E 头和 EF 尾 |
| 手机下发到串口 | 原样透传 | 串口收到 FF 05 03 帧格式 |
| 业务协议自由度 | 高,自己定帧头帧尾校验 | 低,得跟着芯片的帧走 |
| 自带校验 | 无,业务帧要自建 CRC | 有,芯片的累加和 |
血压计这套我建议用裸流。原因是血压数据要上报给 App,业务帧格式得自己定,加 CRC8 和帧尾,App 端拿到的是干净字节流,不用在手机侧再实现一遍芯片的串口协议。裸流的代价是丢了芯片那层累加和,所以业务帧里的 CRC8 不能省。
下面是自定义的业务帧,它整段作为 FF 05 02 的参数,裸流之后手机收到的就是这 10 个字节。
| 偏移 | 字段 | 字节 | 说明 |
| 0 | 帧头 | 1 | 固定 AA |
| 1 | 载荷长度 | 1 | 从命令字到标志位的字节数 |
| 2 | 命令字 | 1 | 01 测量结果,02 实时压力,03 设备状态 |
| 3 | 序号 | 1 | 每次测量自增,App 用来去重 |
| 4 | 收缩压 | 1 | 单位 mmHg,范围 0 到 255 |
| 5 | 舒张压 | 1 | 单位 mmHg |
| 6 | 脉率 | 1 | 每分钟次数 |
| 7 | 标志位 | 1 | bit0 心律不齐,bit1 体位异常,bit2 电量低 |
| 8 | CRC8 | 1 | 多项式 0x07,初值 0x00,校验前面 8 字节 |
| 9 | 帧尾 | 1 | 固定 55 |
举一个真实例子,第 5 次测量,收缩压 126、舒张压 82、脉率 75,无异常标志。
业务帧(手机端裸流后收到的内容)
AA 06 01 05 7E 52 4B 00 AF 55
注意第 5 个字节 7E,就是那个会撞上起始码的收缩压值。
带串口头的完整帧
7E 00 10 FF 05 02 AA 06 01 05 7E 52 4B 00 AF 55 EB EF
长度 0x0010 等于 16,来自参数 10 字节加 6。校验 EB 来自 00 加 10 加 FF 加 05 加 02 加参数全部字节后的低 8 位。CRC8 算出来是 AF。
实时压力帧做成短帧,每包 8 字节,袖带压力 168mmHg 的例子
7E 00 0E FF 05 02 AA 05 02 1F 00 A8 BF 55 A0 EF
设备状态帧,电量 92%、错误码 01
7E 00 0D FF 05 02 AA 04 03 5C 01 3C 55 B2 EF
主控侧的组帧函数,直接抄过去改参数就行。
uint8_t bp_frame[64];
// cmd 命令字,seq 序号,p1 收缩压,p2 舒张压,p3 脉率,flag 标志位
uint16_t bp_build_result(uint8_t cmd, uint8_t seq,
uint8_t p1, uint8_t p2, uint8_t p3, uint8_t flag)
{
uint8_t payload[] = {0xAA, 0x06, cmd, seq, p1, p2, p3, flag};
uint8_t crc = crc8(payload, 8); // 多项式 0x07,初值 0x00
uint8_t param[10];
memcpy(param, payload, 8);
param[8] = crc;
param[9] = 0x55;
uint16_t len = 10 + 6;
bp_frame[0] = 0x7E;
bp_frame[1] = 0x00;
bp_frame[2] = (uint8_t)len;
bp_frame[3] = 0xFF;
bp_frame[4] = 0x05;
bp_frame[5] = 0x02;
memcpy(&bp_frame[6], param, 10);
bp_frame[16] = wt_checksum(0x00, (uint8_t)len, 0xFF, 0x05, 0x02, param, 10);
bp_frame[17] = 0xEF;
return 18;
}
五、BLE 这一段,五个返码要看得懂
透传相关的指令就三条,先记住命令码。
| 命令码 | 作用 | 参数说明 |
| FF 05 01 | 设置透传模式 | 00 裸流,01 不裸流,默认 00 |
| FF 05 02 | 从机发送 | 最大 331 字节,通过透传服务 notify 出去 |
| FF 05 03 | 从机接收 | 不是指令,是不裸流时串口收到数据的输出格式 |
FF 05 02 的返回码一共五个,每一个都对应一个具体的现场问题。
| 返回码 | 含义 | 现场处理 |
| 00 | 发送成功 | 继续下一包 |
| 01 | 发送缓冲满,当前包被丢弃 | 延迟 10 毫秒重发,别连续猛灌 |
| 02 | 操作错误 | 检查参数长度是否超 331 字节 |
| 03 | 链路已断开 | 重新开广播等连接,或提示用户靠近手机 |
| 04 | 主机没有使能 notify | App 侧必须对 CCC 描述符写 01 00 |
返回码 04 是最常见的坑。Android 和 iOS 都要求在订阅 characteristic 之后,再往对应的客户端特性配置描述符写一次使能值,只调用订阅接口在很多手机上并不会真的打开 notify。
初始化阶段的几条指令,按血压计的整机需求给一组常用值。
7E 00 07 FF 05 01 00 0C EF // 设为裸流模式
7E 00 08 FF 05 15 00 06 27 EF // 发射功率用默认值 06
7E 00 08 FF 05 09 00 A0 B5 EF // 广播间隔设为 100 毫秒
7E 00 08 FF 05 19 00 01 26 EF // 打开广播
广播间隔的单位是 0.625 毫秒,默认值 03 20 换算出来是 500 毫秒,对血压计来说偏慢,用户按完测量键要等手机连上会有明显延迟。设成 00 A0 也就是 100 毫秒,连接响应快很多,待机功耗的增加在血压计这种低频使用场景下可以接受。可调范围是 0020 到 4000,对应 20 毫秒到 10240 毫秒。
说明书里有两处表述不一致,用到的时候要实测确认,别照字面猜。第一处是 FF 05 04 开关蓝牙的参数极性,正文写 00 打开、01 关闭,命令码汇总表里写 00 关闭、01 打开,两处相反,而且说明书没有写明上电后蓝牙的默认开关状态,建议上电直接发一帧看返回值。第二处是蓝牙名长度,正文写最大 29 字节,汇总表写 28 字节,取名按 28 字节留余量更稳。
331 字节不是手机侧的单包上限。 331 是串口指令的参数上限,BLE 这一头还要过 MTU。默认 MTU 是 23,去掉 3 字节开销后有效载荷只有 20 字节。我们这套业务帧 10 字节,裸流之后正好一包发完,不用分包。如果后面要把历史记录批量上传,一次塞 100 字节进去,就必须在 App 侧主动申请更大的 MTU。Android 上调用 requestMtu(247),iOS 由系统自动协商,实际能到多少以 maximumWriteValueLength 的返回值为准,别在代码里写死数字。
六、App 端怎么解析
Android 侧的核心是把 notify 回调里的字节流喂给状态机,攒够一帧再校验。下面这段代码可以直接用。
class BpFrameParser {
companion object {
const val HEAD = 0xAA.toByte()
const val TAIL = 0x55.toByte()
}
private val buf = ByteArray(512)
private var len = 0
// notify 回调里每次收到数据就调这个
fun feed(chunk: ByteArray): List<BpResult> {
val out = mutableListOf<BpResult>()
for (b in chunk) {
buf[len++] = b
if (len >= 2 && buf[0] == HEAD) {
val payloadLen = buf[1].toInt() and 0xFF // 命令字到标志位
val total = payloadLen + 4 // 头1 长1 CRC1 尾1
if (len == total) {
parseOne(buf, total)?.let { out.add(it) }
len = 0
} else if (len > total) {
len = 0 // 长度对不上直接丢弃
}
} else if (buf[0] != HEAD) {
len = 0
}
}
return out
}
private fun parseOne(f: ByteArray, n: Int): BpResult? {
if (f[n - 1] != TAIL) return null
val crcCalc = crc8(f, n - 2) // 从头算到 CRC 前一个字节
if (crcCalc != f[n - 2]) return null
val cmd = f[2].toInt() and 0xFF
if (cmd != 0x01) return null // 只处理测量结果
return BpResult(
seq = f[3].toInt() and 0xFF,
sys = f[4].toInt() and 0xFF,
dia = f[5].toInt() and 0xFF,
pulse = f[6].toInt() and 0xFF,
arrhyth = (f[7].toInt() and 0x01) == 1
)
}
private fun crc8(d: ByteArray, n: Int): Byte {
var c = 0
for (i in 0 until n) {
c = c xor (d[i].toInt() and 0xFF)
repeat(8) {
c = if (c and 0x80 != 0) ((c shl 1) xor 0x07) and 0xFF
else (c shl 1) and 0xFF
}
}
return c.toByte()
}
}
data class BpResult(
val seq: Int, val sys: Int, val dia: Int,
val pulse: Int, val arrhyth: Boolean
)
订阅的时候记得补上描述符写入,不然芯片会一直回 04。
// 先订阅,再写 CCC 描述符,两步都要做
gatt.setCharacteristicNotification(txChar, true)
val ccc = txChar.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"))
gatt.writeDescriptor(ccc, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
血压数据属于健康数据,App 侧建议做两件事。一是用序号字段去重,防止重连后同一条记录写两遍。二是把原始字节和解析结果一起落日志,后期出现用户投诉数值异常的时候,能直接看出是传输错还是测量错。
七、语音播报和低功耗怎么收尾
血压计的用户群里有大量老年人,屏幕读数不够,还得把数值念出来。播报这件事用 FF 00 B0 组合播放指令做,最大支持 35 首组合。
语音素材的切法有讲究。把数字拆成 0 到 10、十、百、二十 这类基础片段,再录「收缩压」「舒张压」「脉搏」「毫米汞柱」「您的血压偏高」这些固定词,一次播报用组合播放串起来。比如播收缩压 126、舒张压 82、脉率 75,六段素材依次是收缩压、一百二十六、舒张压、八十二、脉搏、七十五,对应的组合播放帧是
7E 00 13 FF 00 B0 06 00 0B 00 7F 00 52 00 4B 00 0C 00 4D 48 EF
参数第一个字节 06 是曲目数,后面跟 6 个两字节曲目地址,长度字段 0x0013 来自参数 13 字节加 6,校验 48。按这种拆法算,一套带心律异常提示的完整播报通常不超过 12 段,35 首的上限留足了余量。
休眠不能发早了。 主控很容易犯的错是数据发完就发休眠指令,结果语音播一半断掉。正确做法是监测 BUSY 脚,它在 P12 也就是 22 号引脚上,上电默认播放时为高电平、空闲低电平,也可以用 FF 00 BF 切换极性。等 BUSY 回落再发休眠。
7E 00 07 FF 00 B8 01 BF EF // 原地休眠,功耗小于 30 微安
7E 00 07 FF 00 B8 00 BE EF // 深度休眠,功耗小于 5 微安
两个参数的功耗差了一个数量级。原地休眠 30 微安,适合测量间隙这种短时间内还要继续干活的场合。深度休眠 5 微安,适合整机长期待机。有一点必须提醒,说明书里只写了这两种模式的功耗值,没有写具体怎么唤醒,量产前要跟原厂确认清楚唤醒条件,别按猜的做。
八、上电调试自检清单
照着这条清单走一遍,能省掉大半的来回沟通。
★ 用串口助手单独发 FF 05 02,确认返回码是 00 而不是 04,04 说明 App 没写 CCC 描述符
★ 用逻辑分析仪抓串口波形,确认波特率实测值和标称值偏差在 2% 以内,偏差过大换个频点稳的晶振
★ 构造收缩压等于 126 也就是 0x7E 的测试数据,确认 App 解帧不错位
★ 构造参数长度超过 331 字节的帧,确认芯片返回 02 而不是死机
★ 连续快速发 20 包,观察是否出现返回码 01,出现就在主控侧加发送间隔
★ 量休眠电流,确认原地休眠低于 30 微安、深度休眠低于 5 微安
★ 用两部不同品牌的手机各测 20 次连接,统计首次连接耗时,超过 1 秒就把广播间隔从 500 毫秒调下来
★ 播报结束到休眠之间留 200 毫秒余量,防止最后一帧语音被切
九、选型速查表
| 项目 | WT2801Ax 参数 | 对血压计的意义 |
| 芯片型号 | WT2801AX-32N,QFN32,4 毫米乘 4 毫米 | 臂式血压计主板空间紧张也能放下 |
| 蓝牙规格 | BLE 5.4 加 2.4GHz | 双模,2.4G 通道可一对多更新语音 |
| 工作电压 | 2.7 到 5.0V | 直接吃锂电池电压,省一颗 LDO |
| 串口波特率 | 11 档,最高 921600 | 波形回放场景可以拉满 |
| 透传单包 | 参数最大 331 字节 | 批量传历史记录不用拆太多包 |
| 广播间隔 | 20 毫秒到 10240 毫秒可调,默认 500 毫秒 | 连接速度和待机功耗可现场权衡 |
| 发射功率 | 01 到 09 对应 0 到 6dB,默认 06 | 穿臂带衰减后仍可稳定连接 |
| 组合播放 | 最大 35 首 | 数字加单位拼接播报够用 |
| 休眠功耗 | 深度休眠低于 5 微安,原地休眠低于 30 微安 | 整板待机电流能压到微安级,唤醒条件需向原厂确认 |
| 音频输出 | 内置 16bit D 类功放,320mW 接 8 欧喇叭 | 直接推腔体喇叭,省外部功放 |
| 存储扩展 | 外挂 Flash 最大 128Mbit,TF 卡和 U 盘各 32G | 多语言语音包放得下 |
| 语音更新 | 2.4G 广播,10 米范围,单文件小于 100K | 产线不改板就能换语音内容 |
除了透传,这颗芯片本身还是一颗完整的语音芯片,32 级音量可调,支持 MP3 和 WAV,码率 8kbps 到 320kbps,内置 16bit ADC 和 DAC。血压计项目里常见的「播报加蓝牙上传」两件事,一颗芯片就都办了,主控只需要留一个串口。
165
