简介:《北斗三号协议(精简版)》面向卫星导航通信协议学习、北斗三号终端联调与二次开发人员,聚焦终端与外设之间数据交换的传输格式和接口规范,可用于快速了解系统通信流程、语句组织方式及调试要点。资源包共1个PDF文件,约405KB,篇幅精简,便于在项目现场或学习过程中随时查阅。内容围绕接口要求、数据传输和RDSS语句展开:硬件部分涉及互连线、连接器及收发器连接,数据传输涵盖串口规范、标准语句与语句样式,并附有修订记录;语句样式部分不仅包含EPQ定位申请、EPI定位信息、FKI反馈信息,还涉及ICR查询用户卡信息、ICP输出用户卡信息、TCQ通讯申请、TCI通讯信息与PWI功率查询等常用指令。已有2241人学习,适合作为协议速查与字段对照材料,帮助读者理解定位、通信、查询类语句的结构与交互逻辑,为终端调试、协议解析和二次开发提供参考。
1. 从一串$CCEPQ说起:这份精简版协议到底解决什么问题
调试北斗三号终端,最费时间的往往不是定位算法,而是串口那头回来的$BDEPI,...里哪一位是高程、哪一位是精度指示。这份《北斗三号协议(精简版)》把接口要求、标准语句格式和 RDSS 语句族压缩到十几页,核心就三件事:4800~115200 的串口怎么配、$...*hh的帧怎么校验、EPQ/EPI/FKI/TCQ/TCI/PWI 这些语句的字段怎么填。它适合已经拿到模块、需要把上位机或主控固件对接起来的开发者,也适合做北斗协议教学与仿真测试的团队当基线文档。整篇按“帧结构→校验→语句族→排错→自检”的顺序落地,每一步都能对着串口助手复现。
2. 串口与帧结构:115200 波特率下怎么把一条语句切干净
2.1 波特率与 8N1:默认值只是起点,不是答案
协议把串口参数定得很死:异步串行、起始位 1 位、数据位 8 bit、停止位 1 bit、无校验、最低有效位优先,波特率范围 4800~115200 bps,默认 115200。这套参数背后是典型的成本取舍——无校验位意味着串口本身不提供任何差错检测,所有可靠性都压在语句内部的异或校验和上。所以帧解析里校验和不是可选项,是唯一的兜底。
默认 115200 不等于你手上的模块就是 115200。同一批模块,出厂固件可能停在 9600 或 4800,尤其是老型号或者被测设备被上一任使用者改过配置。拿到模块先做一次波特率探测,比对着文档怀疑人生要快得多。探测思路很简单:逐个波特率打开串口,主动发一条查询语句,看有没有$开头的可打印 ASCII 回来。
import serial import time def probe_baud(port, bauds=(115200, 9600, 4800, 38400, 57600)): """逐个波特率试探,返回第一个能收到 $ 开头回帧的波特率""" for baud in bauds: try: with serial.Serial(port, baud, bytesize=8, parity="N", stopbits=1, timeout=0.5) as ser: time.sleep(0.2) ser.reset_input_buffer() # 查询本机 IC 卡信息,校验和按异或算出来是 0x68 ser.write(b"$CCICR,0,00*68\r\n") data = ser.read(256) if b"$" in data: print(f"hit {baud}: {data[:80]!r}") return baud except serial.SerialException as exc: print(f"{baud} open failed: {exc}") return None代码里bytesize=8, parity="N", stopbits=1就是协议要求的 8N1;timeout=0.5是读超时,探测阶段不要设太长,否则五个波特率轮一遍要好几秒。reset_input_buffer()的作用是丢掉打开串口瞬间的残留字节,避免把上一轮的脏数据当成命中。主动发 ICR 而不是被动等,是因为很多终端上电后不会主动吐语句,等下去只会超时。
硬件层还有一条容易被忽略:终端可以通过一根线缆并联多个接收器,但挂几个接收器取决于发送器驱动能力、终端输入驱动要求和有没有加终端电阻。互连线常见做法是屏蔽双绞线外加一根共地保护线,长距离或高波特率下不加终端电阻,波形振铃会把本来正确的帧边沿打成乱码。这类问题在串口助手里表现为“偶尔丢几个字节”,换线换电阻就好了,别一上来就怀疑协议解析写错。
2.2 五个定界符:一条语句的生死全在这几个字节上
标准语句的骨架是固定的:
$<5 位类型标识>,<数据字段>,<数据字段>,...,<数据字段>*<校验和><CR><LF>$是起始符,*是校验和定界符,CR(0x0D)和LF(0x0A)是终止符,逗号是字段分隔符。类型标识固定 5 位英文大写字符,这是硬约束,解析时不要用“读到逗号为止”去猜长度。
| 字符 | 十六进制 | 作用 |
|---|---|---|
$ | 0x24 | 语句开始标志 |
, | 0x2C | 字段分隔符 |
* | 0x2A | 数据与校验和的分界 |
CR | 0x0D | 回车,语句定界 |
LF | 0x0A | 换行 |
协议明确列了一组保留字符:0x0D、0x0A、0x24、0x2A、0x2C、0x5C、0x5E、0x7E、0x7F。数据字段里只允许出现除这些之外的可打印 ASCII。这条规则的价值在于:只要发送方守规矩,接收方按逗号切分就是安全的,不需要转义逻辑。反过来说,如果你在业务数据里塞了逗号或换行,帧边界立刻失去意义,后面的字段全部错位。
字段定位只能靠数逗号,不能靠数字符总数。协议写得很直白:数据字段的位置由分隔符计数确定,不应从语句开头数字符个数。原因是字段长度可变,比如 EPQ 里的高程值就是变长字段,正负号还占一位。用固定偏移去切,今天能跑,明天换个收信方 ID 长度就翻车。
类型标识的前两位有个不算秘密的规律:CC开头的多为输入语句(用户发给终端),BD开头的多为输出语句(终端上报给用户)。EPQ 是CCEPQ,EPI 是BDEPI,TCQ 是CCTCQ,TCI 是BDTCI,PWI 是BDPWI。按这个规律做分发,比写一长串 if-else 清晰得多。
2.3 收发骨架:先把字节流切成帧,再谈解析
串口读回来的是连续字节流,不是一条条消息。第一件事是按\r\n切帧,第二件事才是校验和字段解析。下面这段骨架可以直接放进工程里改:
import serial def iter_sentences(ser: serial.Serial): """从串口字节流中逐条产出以 $ 开头的语句(不含 CRLF)""" buf = b"" while True: chunk = ser.read(ser.in_waiting or 1) if not chunk: continue buf += chunk while b"\r\n" in buf: line, buf = buf.split(b"\r\n", 1) line = line.strip() if not line.startswith(b"$"): continue # 丢弃上电噪声、半截帧 if b"*" not in line: continue # 没有校验和字段,直接判废 yield line.decode("ascii", errors="replace")ser.in_waiting or 1的意思是:缓冲区里有数据就一次性读空,没有就阻塞读 1 字节,避免忙等把 CPU 打满。split(b"\r\n", 1)只切第一个换行,剩下的留在buf里等下一轮,这样半截帧不会被误判。line.strip()去掉可能粘在头尾的空白,但注意不要用strip()去删$或*,这两个字符有语义。
这段骨架只负责“切”,不负责“认”。真正落地时建议再加一层:按类型标识做白名单,不在CCEPQ/CCICR/CCTCQ/BDEPI/BDFKI/BDICP/BDTCI/BDPWI里的帧直接丢弃并计数。现场干扰大的时候,白名单能把大部分噪声挡在解析层外面,日志也干净。
3. 校验和与编解码:异或、BCD 和汉字内码的三个实现细节
3.1 异或校验:区间错一个字节,后面全白算
校验和的定义很明确:$和*之间(不含这两个字符)的全部字符按字节异或,结果的高 4 bit 和低 4 bit 分别转成十六进制 ASCII,高位在前,也就是两位大写十六进制字符。注意三个关键词:按字节、不含定界符、大写。
def checksum(body: str) -> str: """body 为 $ 与 * 之间的内容,例如 'CCICR,0,00'""" value = 0 for byte in body.encode("ascii"): value ^= byte return f"{value:02X}" # 两位大写十六进制,高位在前encode("ascii")保证按单字节异或,不会因为 Python 字符串是 Unicode 而算错。02X里的X输出大写,02保证个位数补零——校验和是0A而不是A,少一位接收方就切不准。
拿协议里的例子验一下:$CCICR,0,00*7B。按定义取CCICR,0,00逐字节异或,结果是 0x68,对应字符68,不是7B。也就是说文档给的这条示例校验和对不上,属于笔误。这种地方最坑人:照着示例抄,发出去没反应,还以为是串口接反了。实际做法是以设备回帧为准,自己算一遍再发;如果模块不响应,把68和7B各试一次,能回的那条就是它对校验的实现。
提示:校验和覆盖范围从类型标识的第一个字符开始,不是从逗号开始,也不是从
$开始。把$或*算进去是最常见的翻车方式,现象是模块完全不回 FKI。
3.2 BCD、汉字内码与编码类别:三种载荷三套处理
TCQ 和 TCI 里有个“编码类别”字段,取值 1~5,直接决定后面“通信数据”怎么编解码。这是整份协议里最容易写出兼容性 bug 的地方。
类别 1 是汉字,传输内容为计算机内码,每个汉字 16 bit,高位在前。协议规范性引用里挂了 GB 2312-80,所以常规做法是按 GB2312 编码后转十六进制字符串:
def hanzi_payload(text: str) -> str: """编码类别 1:汉字转 16 bit 内码,每字节两位十六进制""" raw = text.encode("gb2312") if len(raw) % 2 != 0: raise ValueError("GB2312 编码结果应为偶数字节") return raw.hex().upper()gb2312是协议引用的字符集,但现场模块不一定严格实现:有的按 GBK 走,有的直接按 Unicode 小端塞。差别在生僻字和繁体字上会暴露——同一个字,一边编出来两个字节,另一边编出来四个字节。稳妥做法是先发一条只含常用字的短报文,用 TCI 回读比对十六进制,确认字节序和字符集之后再上业务数据。
类别 2 是代码,传输内容为 ASCII 字符。协议给了一个容易看错的例子:代码 8 用字符8表示,也就是 0x38,不是数值 0x08。这类字段在组帧时按字符串拼接即可,别手贱做数值转换。
类别 3 是混编,传输内容为汉字与代码混合,输出的 BCD 码起始位为A4。这个A4是分段标记,解析时不能当成普通数据。精简版没有给出完整的分段语法,实际处理建议是先按A4切段,段内再按类别 1/2 的规则分别解,遇到不认识的分段标记就整条落日志、不猜。
BCD 码还有两个边界:搜救业务数据最长 117 个 BCD 码,状态数据最长 159 个 BCD 码,不足时低位补零。补零方向搞反,接收端解出来的长度字段会变成一个巨大的数,然后解析器越界。
3.3 组帧与回读:发出去还要能解回来
只写组帧不写解析,等于只做了一半。下面这组函数把“拼帧”和“验帧”凑成闭环,调试时可以直接对着串口助手做回归:
def build(sentence_type: str, *fields) -> bytes: """按标准语句格式组帧,None 输出为空字段""" if len(sentence_type) != 5 or not sentence_type.isupper(): raise ValueError("类型标识必须是 5 位大写字母") body = ",".join([sentence_type] + ["" if f is None else str(f) for f in fields]) return f"${body}*{checksum(body)}\r\n".encode("ascii") def parse(line: str) -> dict: """校验并拆字段,字段内不允许出现逗号""" line = line.strip() if not line.startswith("$") or "*" not in line: raise ValueError("帧边界不完整") body, _, cs = line[1:].partition("*") expect = checksum(body) if expect != cs.upper(): raise ValueError(f"checksum mismatch: expect {expect}, got {cs}") parts = body.split(",") return {"type": parts[0], "fields": parts[1:]}build里对类型标识做了长度和大写校验,这一条能挡住大部分手写拼帧的低级错误。空字段用空字符串占位而不是省略,因为逗号计数决定字段位置,少一个逗号后面全错。parse先校验再切分,校验不过直接抛异常,不要“尽力解析”——错误的帧解析出来的数据比没有数据更危险。
一个最小回归用例:build("CCICR", 0, "00")应该得到$CCICR,0,00*68\r\n。把它丢进parse,应该拿回类型CCICR和两个字段["0", "00"]。这条用例跑通,说明校验和、字段拼接、帧边界三件事都对上了。
4. RDSS 语句族拆解:EPQ、EPI、FKI 的字段怎么填
4.1 EPQ 定位申请:模式、频点、紧急标识的合法组合
EPQ 是输入语句,用户主动发定位报告或应急搜救申请。它一共 11 个字段,字段顺序和取值范围如下:
| 序号 | 含义 | 取值 | 关键备注 |
|---|---|---|---|
| 1 | 收信方 ID | 按卡分配 | 定位报告接收方 |
| 2 | 定位报告模式 | 1~5 | 1 无测高、2 有测高、3 广义 RDSS、4 普通位置报告、5 自适应 |
| 3 | 入站频点 | 0~5 | 0 自适应,1~5 对应 Lf0~Lf4 |
| 4 | 入站速率 | 1~4 | 四档速率 |
| 5 | 紧急标识 | 1~3 | 1 应急搜救、2 标准定位、3 精密定位 |
| 6 | 高程值 | 变长,可正可负 | 模式 1、4、5 下通常置空 |
| 7 | 搜救类型 | 1~3 | 1 呼救申请、2 呼救取消、3 前向身份鉴别 |
| 8 | 频度/秒 | 0 起 | 0 为单次并停止连续发送 |
| 9 | 入站功率 | 0~100 | 0 最小、100 最大,分辨率 0.5 dB |
| 10 | 业务数据长度 | BIT 数 | 与字段 11 配套 |
| 11 | 业务数据/状态数据 | BCD | 最长 117/159 个 BCD 码 |
最容易填错的是模式与紧急标识的联动。模式 4(普通位置报告)下,字段 6 和 7 要置空或填 0,终端会用 RNSS 定位结果自动补本地位置,你手工塞高程反而会被忽略。紧急标识为 2(标准定位报告)时,字段 7 置空或填 0;紧急标识为 3(精密定位报告)时,字段 6 和 7 都要置空或填 0。这几条在文档里分散在注 1 到注 3,抄字段表的时候很容易漏。
频点字段也有个隐含边界:1~5 映射 Lf0~Lf4,其中 Lf0 是北斗二号 RDSS 入站频点,Lf1、Lf2 是北斗三号民用入站频点,Lf4 是全球报文通信频点,Lf3 在精简版里没有明确业务定义。工程上能不碰 Lf3 就不碰,除非模块厂商明确告诉你它映射到什么。入站功率 0~100 对应 0.5 dB 步进,别按 dBm 直接填,填 100 就是最大功率,不是 100 dBm。
一条应急搜救定位申请的组帧长这样:
epq = build( "CCEPQ", "0000001", # 收信方 ID 1, # 模式 1:RDSS 定位报告(无测高) 1, # 入站频点 Lf0 2, # 入站速率第 2 档 1, # 紧急标识:应急搜救 None, # 高程:模式 1 不填 1, # 搜救类型:呼救申请 0, # 频度 0:单次 60, # 入站功率 0, # 业务数据长度 None, # 业务数据:无 )字段数与表一致,空字段保留逗号占位。发出去之后不要立刻发下一条,等 FKI 回来再决定重发还是继续,否则连续申请会把频度限制撞出来。
4.2 EPI 定位信息:输出语句的字段对齐与符号位
EPI 是终端上报的定位信息,字段比 EPQ 长得多,典型格式是$BDEPI,...,包含发信方 ID、收信方 ID、接收频点、紧急标识、定位报告模式、时间、经度、经度标志、纬度、纬度标志、高程、高程单位、高程异常、高程异常单位、精度指示、多值解、搜救业务子类型、数据长度、数据。
接收频点的取值和 EPQ 的入站频点不是一套:1 是 S1,2 是 S2C,3 预留,4 是 B2b。写解析器时如果复用同一张映射表,S1 会被当成 Lf0,数据全错。
经纬度被拆成“数值 + 标志”两个字段,经度标志 E/W,纬度标志 N/S。符号不要塞进数值里,也不要用正负号代替标志位。高程和高程异常各自带单位字段,值都是M,但两个字段必须分开读,混在一起会把高程异常当成高程。
注 1 规定:定位报告为标准定位报告时,字段 12、14、15 不为空。换句话说,这三个字段能不能信,取决于紧急标识是不是 2。解析时先读紧急标识,再决定要不要取高程单位、高程异常、精度指示,比无脑全取安全。
4.3 FKI 反馈与 ICR/ICP:发射成功没成功,看这几个字段
FKI 是终端对入站发射的反馈,格式为BDFKI,hhmmss,aaa,c,x,xxxx。注意这个格式串开头没有$,是文档排版漏了,实际帧一定以$开头,解析器不要按字面去匹配。
字段 1 是指令执行时间,文档写“UTC(东八区)”,这个表述本身有歧义:UTC 是零时区,东八区是 UTC+8,两者不可能同时成立。现场做法是以设备实测为准,接上串口对一次系统时间,把偏差记在配置里,别在代码里硬编码+8。
字段 2 是入站发射类型,取值是字符串:EPQ 应急搜救与定位报告、TCQ 报文通信申请、QRQ 查询、DTQ 定时申请、OFQ 开关机报告、MSQ 静默申请、GBQ 建组申请。字段 3 是发射情况,Y成功、N失败。字段 4 失败原因 1~6:
| 值 | 含义 | 现场通常怎么处理 |
|---|---|---|
| 1 | 频率未到 | 等频点就绪,检查频点配置 |
| 2 | 发射抑制 | 查是否被上层策略抑制 |
| 3 | 发射静默 | 查静默申请是否未解除 |
| 4 | 功率未锁定 | 查天线与功放链路 |
| 5 | 未检测到 IC 模块信息 | 查卡座与卡状态 |
| 6 | 权限不足 | 查卡权限与服务状态 |
发射情况不是Y时,失败原因可能是 0 或空,剩余时间可能是0000或空。解析器要把“空”和“0”都当合法值处理,不能直接int()。
ICR 是查询语句,格式$CCICR,x,xx,指令类型 0 查本机 IC 模块、1 查本机编组、2 查下属用户、3 查 IC 模块工作模式。类型 0 和 3 时帧号填00。ICP 是它对应的输出,22 个字段,其中通播地址在普通型和兼收型下填 0,下属用户数在普通用户机下填 0。这两个“填 0”不是可选,是区分设备类型的硬标志,解析时可以用来反推终端类型。
4.4 TCQ/TCI:报文通信的频点与长度上限对照
TCQ 是发报文,TCI 是收报文。TCQ 的字段依次是收信方 ID、频点、入站确认申请、编码类别、通信数据、报文通信频度。入站确认申请 1 表示不需确认、2 表示需确认;频度 0 为单次,连续发送要在 RMO 里关,而 RMO 在精简版里没有展开,这条得去查完整协议或厂商手册。
长度上限按频点分档,这是发长报文前必须查表的地方:
| 频点编号 | 频点 | 单条上限 | 说明 |
|---|---|---|---|
| 1 | Lf0 | 120 个汉字 | 北斗二号 RDSS 入站 |
| 2 | Lf1 | 1000 个汉字 | 北斗三号民用入站 |
| 3 | Lf2 | 1000 个汉字 | 北斗三号民用入站 |
| 4 | Lf3 | 文档未明确 | 不建议使用 |
| 5 | Lf4 | 40 个汉字 | 全球报文通信 |
接收侧对应关系是 S1 120 个汉字、S2C 1000 个汉字、B2b 40 个汉字。发送前按频点查上限,超长就分片,别指望终端帮你截断——截断后的报文长度字段对不上,接收端会整条丢弃。
MAX_HANZI = {1: 120, 2: 1000, 3: 1000, 5: 40} # 4 对应 Lf3,文档未定义 def check_len(freq: int, text: str) -> None: limit = MAX_HANZI.get(freq) if limit is None: raise ValueError(f"频点 {freq} 无明确长度定义,拒绝发送") if len(text) > limit: raise ValueError(f"频点 {freq} 上限 {limit} 字,当前 {len(text)} 字")len(text)按字符数算,不是按字节数。汉字在 GB2312 下占两字节,按字节算会把上限砍半,这是另一个常见误判。
5. 避坑与排查:现场最容易翻车的五件事
5.1 校验和算错区间,模块一声不吭
现象:帧发出去,串口没有任何回帧,也没有 FKI。原因:把$或*算进了异或区间,或者输出小写十六进制,或者个位数没补零。解决:固定用body = line[1:].partition("*")[0]取区间,输出格式固定f"{value:02X}",发之前先用一条已知能回的 ICR 做自检。文档里$CCICR,0,00*7B这条示例本身就算不出 7B,照抄必翻车,以自己算的值为准。
5.2 按字符总数切字段,换个小区号就错位
现象:解析出来的收信方 ID 跑到了模式字段上。原因:用固定偏移切字符串,而收信方 ID 和高程都是变长字段。解决:一律按逗号split,切完先校验字段数量是否符合语句定义,数量不对整条丢弃并打日志。别用“字段数不对就补空”的容错,补出来的数据比没有更害人。
5.3 时区理解偏差,FKI 时间永远差 8 小时
现象:FKI 里的时间和本地时间对不上,日志时间线错乱。原因:文档写“UTC(东八区)”,实现方按 UTC 解,设备按东八区输出,或者反过来。解决:拿一次已知时刻的发射做基准,记录设备输出与本地时间的差值,把这个偏移做成配置项,别写死在代码里。换一批模块要重新标定。
5.4 长报文不看频点,超长被静默丢弃
现象:短报文正常,长报文发出去没有 TCI 回来。原因:用了 Lf4 却按 1000 字的上限组包,实际上限只有 40 个汉字。解决:发送前按频点查长度表,超长主动分片,每片带序号;同时确认编码类别与长度单位一致,按字符数而不是字节数判断。
5.5 变长语句按固定结构解析,PWI 直接越界
现象:解析 PWI 时数组下标越界,或者载噪比读出一堆 0。原因:PWI 的波束数量字段决定后续字段个数,旧体制和新体制各有一段变长区,按固定 16 个波束去读必然错。解决:先读锁定数量,再循环读对应组数,读完旧体制再读新体制数量,最后检查剩余字段是否为空。任何数量字段超过协议上限(旧体制 16、新体制 63)都直接判废。
6. 进阶:用 PWI 判断波束锁定,再配一套上电自检
PWI 是判断“设备到底有没有锁上星”的最直接语句,格式为$BDPWI,hhmmss.ss,...。它的结构是两段变长:先给旧体制出站波束锁定数量(0~16),每个波束跟三字段(波束编号、S1I 载噪比、S1Q 载噪比);再给新体制锁定数量(0~63),每个波束跟三字段(波束编号、S2C_d 载噪比、S2C_p 载噪比)。载噪比单位 dBHz,0 表示未锁定。
| 观察项 | 未锁定特征 | 锁定特征 |
|---|---|---|
| 锁定数量 | 0 或反复跳变 | 稳定在 1 以上 |
| 载噪比 | 持续为 0 | 连续多帧大于链路门限 |
| 波动 | 相邻帧差异大 | 波动通常在几个 dB 以内 |
| 波束编号 | 频繁更换 | 基本固定在同一编号 |
判据不要写死一个绝对值,按你的天线增益和链路预算定门限,工程上常见做法是连续 5~10 帧载噪比都高于门限、且波动小于 2~3 dB,才认为“稳定锁定”。只看单帧,很容易被一次瞬时峰值骗过去。
def parse_pwi(fields: list[str]) -> dict: """按锁定数量动态解析,避免固定长度越界""" t = fields[0] idx = 1 old_cnt = int(fields[idx]); idx += 1 old = [] for _ in range(old_cnt): beam, s1i, s1q = fields[idx:idx + 3] old.append((int(beam), float(s1i), float(s1q))) idx += 3 new_cnt = int(fields[idx]); idx += 1 new = [] for _ in range(new_cnt): beam, s2d, s2p = fields[idx:idx + 3] new.append((int(beam), float(s2d), float(s2p))) idx += 3 return {"time": t, "old": old, "new": new, "rest": fields[idx:]}rest保留剩余字段,方便协议扩展时快速发现“多出来的东西”。int(fields[idx])之前建议加一层数量上限判断,旧体制超过 16、新体制超过 63 直接抛异常,不要让它进循环。
上电自检我一般固定跑四步:波特率探测、发 ICR 验校验和、发 EPQ 等 FKI 看失败原因、连续读 10 帧 PWI 看锁定稳定性。四步里任何一步不过,就先别碰业务逻辑。从那以后我每次拿到新模块,都强制先跑一遍这套自检,把波特率、时区偏移、频点长度上限三个参数写进配置文件再开工,省下的排查时间比写自检脚本多得多。希望帮到你。
本文还有配套的精品资源,点击获取