简介:cat021报文解析的C++实现资源,面向需要处理cat021协议数据的开发人员,适合作为协议解析入门与项目参考。压缩包共44个文件,以C++源代码、头文件、Visual Studio工程文件、编译中间文件及可执行程序为主,涵盖编译日志、调试符号、工程配置与测试样例,便于追踪构建过程和排查问题;整体约17.27MB,已有1806人学习下载。实现采用面向对象设计,将报文拆分为报文头、数据段与校验部分,通过Cat021Message类封装,从字节流读取、大小端转换、字段抽取到校验和验证均有完整代码。目前常规数据段解析已实现,特殊数据段留有扩展接口,便于按协议文档继续完善;配套的cat021_test_1测试用例可帮助验证解析逻辑,是学习C++网络协议处理的实用示例,可快速上手cat021报文的解析流程。该资源具备良好的教学与工程参考价值。 搞过空管数据监听、ADS-B数据处理的朋友应该都绕不过一个东西——CAT021报文。无论你是接地面站的原始数据,还是做二次雷达数据融合,CAT021报文解析都是最基础也最关键的一环。我记得第一次接触CAT021时,对着二进制流一头雾水,网上资料又少得可怜,硬是靠着一份PDF规范和一堆16进制数据一点点啃出来的。这篇文章就结合我实际踩过的坑,用C++把CAT021报文解析这件事彻底说清楚。
1. 报文解析第一课:CAT021到底是什么
CAT021报文属于ASTERIX协议族,由欧洲航行安全组织(EUROCONTROL)定义,全称是Category 021 Transmission of ADS-B Messages。它解决的核心问题只有一个:如何把ADS-B广播出来的飞机状态信息,封装成二进制格式,在雷达站、数据处理中心、ATC系统之间高效传输。简单说,雷达接收机收到飞机的广播信号之后,需要通过某种"语言"向后端系统描述"某架飞机此刻在哪个位置、飞多快、爬升还是下降",CAT021就是这个"语言"。
1.1 CAT021和ASTERIX协议族的关系
ASTERIX协议族就像一套"集装箱运输标准",CAT001、CAT002、CAT004、CAT021等是不同尺寸、不同用途的集装箱。CAT001是雷达视频数据,CAT004是SDPS数据,CAT021则专门负责ADS-B报文。每个CAT都遵循同样的框架:Cat辨识符(8bit) + 报文长度(16bit,大端序) + 记录块。记录块内部由FSPEC(字段说明)决定具体包含哪些信息。
这套设计的妙处在于通用性。你只要理解了CAT021的解析框架,再去看CAT001、CAT048、CAT062等报文,完全可以"抄作业"。它们最大的区别只是数据项字段定义不同,框架和解析逻辑是一模一样的。
1.2 我为什么选择C++来实现
Go、Python、Java都能做同样的事,但如果你有性能要求——比如要处理每秒上千条报文、要接入实时系统,C++几乎是最平衡的选择。我个人的理由是这几个:
- 内存布局可控。报文本来就是字节流,直接用结构体映射比反复创建对象、拷贝容器高效得多
- 位操作灵活。C++的位运算、位域、
std::bitset能够直接用代码表达协议里的bit级定义,读代码如同读规范 - 零拷贝解析容易实现。只需要对原始缓冲区做内存映射、指针偏移,不需要把数据拷来拷去
当然,C++的初始开发成本比Python高,所以如果只是做离线分析,用Python快速验证也没问题。不过生产环境里我依然推荐C++,特别是你要做实时数据处理的时候。
2. 动手之前,把CAT021格式彻底吃透
开始写代码前,最忌讳的就是上来就写结构体。先把手里的16进制数据打开,对着规范一行一行看,弄清楚"哪些字节恒定不变、哪些字节是可选信息、哪些字节需要倒过来读",否则后面查bug会查得你想摔键盘。
2.1 从一条真实的CAT021报文数据开始
假设我从网络端口抓到一个数据包(Wireshark里筛选UDP端口某个目标端口就能看到),内容是:
15 00 19 f0 02 09 89 71 43 00 00 00 00 00 00 00 10 00 00 78 51 90 45 00 00 03 e8 00逐字节拆解:
15是CAT标识符,十六进制15等于十进制21,即CAT02100 19是报文长度,大端序,十六进制0x0019等于25,表示从CAT标识符开始的整个报文一共25字节,实际上等于1(CAT)+2(长度)+1(FSPEC)+数据项部分f0是FSPEC的第一个字节。FSPEC是变长的,每个字节的低7位最有价值,最高位是扩展标志位。f0二进制是1111 0000,最高位为1表示FSPEC还有后续字节;低7位从bit7到bit1分别是FSPEC-next、I021/010、I021/020、I021/040、I021/050、I021/070、I021/080的标记位
看到这里思路就清晰了:FSPEC的职责是用一种紧凑的位图,告诉解析器"这一条报文里实际包含哪些字段"。有了这个设计,传输的时候空字段就不用占用字节,节省带宽,哪怕车载式的低带宽链路也能跑得动。
2.2 数据项列表的分类与定长/变长判断
CAT021目前规范里定义了数十个数据项,比如I021/010数据源识别、I021/015服务识别、I021/020目标报告描述符、I021/040目标航迹号、I021/071航迹质量、I021/130位置坐标、I021/165航迹速度、I021/170航迹状态、I021/200目标识别(24位ICAO地址)、I021/220目标高度,等等。
这些数据项有个重要分类标准:定长数据项和变长数据项。
- 定长数据项:长度固定,比如I021/010是2字节,I021/020是2字节,I021/040是2字节,I021/220是2字节。解析时直接按固定偏移读取
- 变长数据项:长度由自身内容决定或由重复计数字段决定。比如I021/130(位置坐标)在规范里是6字节,但有些扩展版本可能带4字节的CGS坐标或额外信息;I021/500(天气数据)这类甚至可能带多位数组
对变长数据项的处理,是解析器最容易出问题的地方。我的经验是:先把规范里每个数据项的长度记录下来,整理成一张基础字典表,再去对照实际报文验证。纯靠记忆很快就会忘,特别是隔几个月回来维护代码时,这张表能救你的命。
3. C++实现报文解析的完整思路
明确了报文格式后,接下来才是真正的编码实现。这一步的核心目标是:让代码和规范一一对应,结构清晰、容易扩展。我最终的实现,没有依赖任何第三方的报文解析库,纯标准C++,足以应对生产需求。
3.1 整体设计:解析上下文与数据项注册表
先定义好程序里最基础的几个数据结构。我只用一个流式字节读取器,一个CAT21格式描述符,外加一个数据项基类,就完成了整个框架。
// 数据项基类:每个数据项对应一个16位的ID(如0x010),以及它的取值类型 struct IDataItem { virtual ~IDataItem() = default; virtual bool parse(const uint8_t* p, size_t len, std::ostream& log) = 0; uint16_t itemId = 0; // 例如 0x010 表示 I021/010 }; // 字节流读取器,负责大端序读取、偏移管理,并且记录解析到哪个位置了 class ByteReader { public: ByteReader(const uint8_t* data, size_t sz) : cur_(data), end_(data + sz) {} bool readU8(uint8_t& v) { if (cur_ + 1 > end_) return false; v = *cur_++; return true; } bool readU16(uint16_t& v) { if (cur_ + 2 > end_) return false; v = (static_cast<uint16_t>(cur_[0]) << 8) | cur_[1]; cur_ += 2; return true; } bool readU32(uint32_t& v) { if (cur_ + 4 > end_) return false; v = (static_cast<uint32_t>(cur_[0]) << 24) | (static_cast<uint32_t>(cur_[1]) << 16) | (static_cast<uint32_t>(cur_[2]) << 8) | static_cast<uint32_t>(cur_[3]); cur_ += 4; return true; } bool readBytes(uint8_t* out, size_t n) { if (cur_ + n > end_) return false; memcpy(out, cur_, n); cur_ += n; return true; } size_t remaining() const { return end_ - cur_; } const uint8_t* position() const { return cur_; } private: const uint8_t* cur_; const uint8_t* end_; };这个ByteReader是整个解析器的地基。报文里所有多字节数值都是大端序(网络字节序),所以readU16和readU32必须先移位再合并,不能直接用memcpy去读,否则在小端机器上会得到颠倒的值。
3.2 解析器主循环:FSPEC识别与数据项分发
核心流程就三件事:读FSPEC,按位序确定要解析的数据项ID,逐个调用对应解析函数。FSPEC的解析有递归味道,因为FSPEC是变长的,第一位如果是1还要继续读下一字节。
bool parseCat021Frame(const uint8_t* data, size_t sz) { ByteReader reader(data, sz); uint8_t cat = 0; uint16_t len = 0; if (!reader.readU8(cat) || !reader.readU16(len)) return false; if (cat != 0x15) return false; // 不是CAT021 // 这一帧剩余的字节(len字段已经包含了CAT标识符、长度字段和记录块) size_t bodyLen = len - 3; if (bodyLen > reader.remaining()) return false; // 解析FSPEC,直到没有扩展位为止 std::vector<uint8_t> fspecBytes; while (true) { uint8_t fs = 0; if (!reader.readU8(fs)) return false; fspecBytes.push_back(fs); if ((fs & 0x80) == 0) break; // 最高位为0表示FSPEC结束 } // FSPEC的每个bit对应UAP(用户应用配置文件)中的一个数据项 // bit8~bit1:表示I021/010 ~ I021/080等,依具体规范版本而定 for (size_t byteIdx = 0; byteIdx < fspecBytes.size(); ++byteIdx) { uint8_t byte = fspecBytes[byteIdx]; // 对本字节的7个数据位依次处理 for (int bit = 7; bit >= 1; --bit) { if ((byte & (1 << (bit - 1))) == 0) continue; // bit为0,字段不存在 uint16_t itemType = fspecByteItemMapping(byteIdx + 1, bit); // 根据byteIdx(第几个FSPEC字节)和bit位置,查UAP表得到数据项编号 if (!parseDataItem(reader, itemType)) return false; } } return true; }这段代码看起来很直白,但有个关键设计点值得展开:fspecByteItemMapping函数必须依据具体使用的CAT021版本去实现。
早期版本(如V1.x)只用一个FSPEC字节就定义了30来个数据项。到V2.x版本以后,字段多了很多,FSPEC可能扩展到2个、3个甚至更多字节。每个字节的bit7~bit1对应哪一项,在规范文档里都有一张UAP表(User Application Profile,用户应用配置文件)。我的做法是直接把UAP表做成一个静态数组:
// 假设是某版本CAT021 UAP的部分映射:第一字节的bit7对应I021/010,bit6对应I021/020...bit1对应I021/070 // 第二字节bit7对应I021/080等 uint16_t fspecByteItemMapping(size_t byteSeq, uint8_t bitPos) { // 实际使用时,替换为你的规范UAP映射表 static const uint16_t UapMap1[] = {0x010, 0x015, 0x020, 0x030, 0x040, 0x050, 0x070}; static const uint16_t UapMap2[] = {0x080, 0x090, 0x100, 0x110, 0x120, 0x130, 0x140}; if (byteSeq == 1 && bitPos >= 1 && bitPos <= 7) return UapMap1[7 - bitPos]; if (byteSeq == 2 && bitPos >= 1 && bitPos <= 7) return UapMap2[7 - bitPos]; return 0xFFFF; // 未定义字段 }用数组做映射的好处是:规范升级时,你只需要改这一张表,而不需要改主循环的代码。我第一次实现CAT021解析器时,就是靠这样一张UAP表才避免把主循环改成一坨if-else的烂摊子。
3.3 核心点位解析:经纬度、高度、速度的编码与单位转换
ADS-B报文里大家最关心的三个量是:经纬度、高度、速度。这三个量在CAT021里都用了缩放因子,解析时不能只读原始二进制,还要做单位换算。
经纬度(I021/130)
CAT021规范里,纬度用3字节、经度用3字节,均为2的补码,但单位不是直接的度数,而是用了一个特殊的比例因子:1 LSB = 180/2^23 度。也就是说,把解析出的int24再乘以180,然后除以8388608,才能得到真正的度数。
struct Position { double lon; // 度 double lat; // 度 }; bool parsePosition(ByteReader& reader, Position& pos) { int32_t latRaw = 0, lonRaw = 0; uint8_t b[3]; if (!reader.readBytes(b, 3)) return false; // 把3字节转为有符号24位整数 latRaw = (static_cast<int32_t>(b[0]) << 16) | (static_cast<int32_t>(b[1]) << 8) | static_cast<int32_t>(b[2]); if (latRaw & 0x800000) latRaw |= ~0xFFFFFF; // 符号扩展 if (!reader.readBytes(b, 3)) return false; lonRaw = (static_cast<int32_t>(b[0]) << 16) | (static_cast<int32_t>(b[1]) << 8) | static_cast<int32_t>(b[2]); if (lonRaw & 0x800000) lonRaw |= ~0xFFFFFF; pos.lat = latRaw * (180.0 / 8388608.0); pos.lon = lonRaw * (180.0 / 8388608.0); return true; }注意这里必须自己处理int24的符号扩展。C++的int32_t是从3个字节扩展而来,不能直接把b[0]<<16 | b[1]<<8 | b[2]当作有符号数,因为第23位是符号位。如果不做这一步,南半球和西半球的坐标会解析成超大正数,画在图上全跑到非洲去。
高度(I021/220)
高度字段在CAT021中有两种可能,取决于目标报告描述符里的位置测量类型。最常见的是用12位二进制表示的气压高度,单位是25英尺;还有一种是用12位二进制表示的气压高度,单位是25英尺,但值和ICAO定义的Mode C高度有对应关系。我实现时的做法是:
// I021/220目标高度:2字节,低12位有效 double parseAltitudeFt(const uint8_t* p) { uint16_t raw = (static_cast<uint16_t>(p[0]) << 8) | p[1]; uint16_t altCode = raw & 0x0FFF; // 取低12位 if (altCode == 0) return 0; // 无效值 return altCode * 25.0; // 单位换算为英尺 }在实际项目里,高度最终往往会转成米,那么再乘一个0.3048即可。但解析层我先保持英尺,把单位换算交给上层逻辑,这样解析器更中立,避免被业务需求绑架。
速度(I021/165)
速度字段的情况略微复杂,它是2字节,多少位参与计算完全取决于字段内部的"运动状态"标志位。简单版本里直接取整段,如果包含了地速和空速标志,则需要条件判断再换算。
// I021/165航迹速度:2字节,具体含义依赖运动状态位 double parseSpeedKnots(const uint8_t* p) { uint16_t raw = (static_cast<uint16_t>(p[0]) << 8) | p[1]; // 如果指定使用地速(GS),则数据位为低12位或低10位 // 这里以最常见的模式为例:低12位为地速,单位0.25节 uint16_t gsRaw = raw & 0x0FFF; return gsRaw * 0.25; // 单位转换为节 }速度字段的“单位”在不同版本规范里有差异,有的版本是0.22节,有的是1节。务必以你所用的那版规范为准,不要盲目照抄我这里的0.25。我在两个不同项目里就踩过这个坑,一个项目用的是0.25节,另一个项目用的却是0.22节,解析结果差了几个百分点。
4. 完整代码骨架与关键步骤注释
下面给出我实际用来做报文解析的一整套骨架代码。它不是完整到能直接编译,但它的流程、函数边界、注释风格都来自真实项目,你完全可以按这个架子去填你自己的业务逻辑。
4.1 主解析框架:从网络流到格式化数据
#include <iostream> #include <vector> #include <cstdint> #include <cstring> // 一个解析结果的结构体(实际工程中会拆成更细的类) struct ADSBReport { bool valid = false; uint8_t sourceSys = 0; uint16_t trackNo = 0; double lat = 0.0, lon = 0.0; double altitudeFt = 0.0; double speedKt = 0.0; uint32_t icaoAddr = 0; // 飞机24位地址 uint32_t timestamp = 0; // UNIX时间戳 }; // 为了方便业务层使用,可以把每个数据项的解析结果回调注册进来 using DataItemHandler = std::function<bool(const uint8_t* data, size_t len, ADSBReport& report)>; class Cat021Parser { public: // 外部调用统一入口:传入完整报文数据,输出业务对象 bool parse(const uint8_t* frame, size_t sz, ADSBReport& report); };接口层的设计建议:解析器不要直接把所有数据项都塞给业务层,而是应该提供一个ADSBReport统一对象,上层只关心"当前这个航迹的最终状态是什么"。这样就算规范后续新增了某个数据项,只要解析器内部做兼容映射,上层代码几乎不用改动。
4.2 字节序陷阱和高扩展FSPEC的处理
字节序这个问题,大多数第一次解析CAT021的人都会栽跟头。你可能会问:CAT021规范是大端序还是小端序?答案是:网络传输一律用大端序(big-endian),这是从UDP抓包里直接确认的。如果你在本地小端机器上直接memcpy读多字节字段,就会得到数字颠倒的结果。
我在代码里所有的readU16、readU32都刻意手动实现,就是为了彻底避免字节序坑。就算你换到某台嵌入式平台,如果平台恰好是big-endian,这个代码依然能稳定工作。
再来说FSPEC扩展。对于较新版本的CAT021,FSPEC会超过一个字节。扩展机制简单直接:每字节最高位是"继续位"。最高位=1,继续读下一个字节;最高位=0,FSPEC结束。所以while(true)循环里判断(fs & 0x80)是核心逻辑。
4.3 数据项注册表的实现方式
我在项目里做了一个小而美的注册表,用std::unordered_map<uint16_t, DataItemHandler>存放所有数据项解析函数。每读到FSPEC里的一个有效位,就查表得到处理函数:
bool Cat021Parser::parseDataItem(ByteReader& reader, uint16_t itemType) { auto it = handlers_.find(itemType); if (it == handlers_.end()) { // 碰到未知数据项,不能直接报错,因为有些新版本数据项老规范没有 // 但必须知道它到底占了几个字节,否则后续解析会错位 std::cerr << "Unknown data item I021/" << std::hex << itemType << std::endl; return false; // 可以记录严重但继续尝试,也可以直接丢弃当前帧 } // 保存当前位置,让handler读取。 return it->second(*this, reader); }注册表的优势是方便扩展。以后规范更新,只需注册一个新handler,不需要改主解析循环。我做过的几个项目里,CAT021版本有的用V2.1、有的用V2.4,数据项定义有一些细微差别,靠注册表在构造时选择不同配置,就能让一个解析器兼容多个规范版本。
4.4 一小段可运行的示例,助你快速打通流程
如果你只是想验证自己的UAP映射对不对,我建议你做一个最简版本,先把一条报文解析到能打印经纬度即可:
int main() { // 假设这是从端口捕获的原始报文 std::vector<uint8_t> raw = { 0x15, 0x00, 0x19, 0xf0, 0x02, 0x09, 0x89, 0x71, 0x43, // 后面的字节依据实际报文补齐 }; Cat021Parser parser; ADSBReport report; if (parser.parse(raw.data(), raw.size(), report)) { std::cout << "track=" << report.trackNo << " lat=" << report.lat << " lon=" << report.lon << " alt=" << report.altitudeFt << " spd=" << report.speedKt << std::endl; } return 0; }跑通这段最简单的程序后,你就能逐步把自己要用的数据项注册进来。没必要一次性实现全部数据项,按业务需求来,用哪个注册哪个,反而更容易维护。
5. 常见问题与排查技巧实录
无论看多少遍代码,在实际解析真实报文时,总会遇到一些奇奇怪怪的问题。我把这几年亲手踩过的坑整理出来,做个速查表,省得你再来回折腾。
5.1 频率最高的5个"为什么"排查
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 经纬度解析出来是乱码,出现几千上万度的数值 | 没有做24位符号扩展,或比例因子写错 | 检查符号扩展代码,确认单位是否为180/2^23度 |
| FSPEC识别出的字段顺序和报文内容对不上 | UAP表映射错误,用了错误规范的UAP表 | 去官网下载对应版本的CAT021规范,逐一比对UAP表 |
| 高度总是显示0或很大 | 高度字段的有效位判断错误,把保留位也读进去了 | 检查I021/220定义,确认低12位是高度编码,并处理好无效值 |
| 有时候能解析成功,有时候数据错位 | 某些数据项是变长的,你没处理;或者变长数据项长度由内容决定 | 对所有数据项做定长/变长梳理,对变长数据项写明长度规则 |
| 报文长度正确但某些帧解析失败 | 帧里有未知数据项,解析器跳过了错误长度 | 遇到未知数据项时,也要正确计算其长度,不能盲目跳过 |
5.2 两个经典疑难问题的深度解疑
问题一:FSPEC中出现未定义的数据位,如何处理?
真实报文里偶尔会有一些位是1,却对应UAP表中未定义字段。这种情况多发生在不同厂商的设备上。处理策略是:如果解析器不认识这个字段,但又想继续解析后面的数据,你必须知道这个字段的长度,否则整体都会错位。
我的做法是维护一张"未知字段长度表"。如果表中也没有这个未知字段,就放弃整帧并记录错误,而不是硬着头皮接着读。因为一旦读错了长度,后面所有字段的解释都是错的,这种"伪成功"比直接丢弃危害更大。
问题二:经纬度在边界值处出现异常跳动,如何处理?
ADS-B报文的经纬度本应该是平滑变化的,但偶尔会出现一个新值跳到几万度的情况。这通常不是解析错误,而是接收链路质量问题。我做了个简单的滤波:如果上一帧和当前帧的经纬度差超过比如0.5度,就认为是野值,交给上层逻辑做丢弃或平滑处理。这种过滤最好放在解析器之后,而不是解析器内部,因为有些机器学习或雷达融合模型可能需要原始野值作为特征。
5.3 高效调试的两大利器
排查报文解析问题时,光靠printf打印太慢了。我强烈建议你准备这两个工具:
第一是Wireshark。它能直接过滤目标UDP端口,还能看到十六进制原始数据。我会先抓几段真实的CAT021报文,存成hex文本,再用自己写的解析器逐字节核对。Wireshark里有个很实用的功能,是能对ASTERIX协议做初步解析,虽然它不一定完全正确,但可以对照判断自己的UAP映射有没有大方向错误。
第二是自写的dump工具。我在解析器里加了一个dumpFrameInfo的调试函数,每一帧解析完都会打印FSPEC、数据项个数、偏移位置等。这样一旦某帧解析失败,我能立刻从日志里看出是在哪个字节、哪个字段上崩的。经验是:把日志打全,能省去你一半的排错时间。
6. 工程化落地的几个关键建议
解析器写出来只是第一步,要让它稳定跑在生产环境,还需要在工程层面多花心思。
关于多版本兼容,我建议在你的解析器构造函数里传入一个版本参数(如kCat021VersionV24),然后在注册表初始化时按版本注册不同handler。这样同一个解析器实例能应对不同数据源,切换时不用改代码,只是配置不同。
关于位操作可读性,CAT021里有大量bit级别的标志位,比如目标报告描述符里按位表示"是否为转发目标""是否为军事目标""是否载荷有效性"等。直接用位运算是最好的,但一定要写成有名函数,别让代码里全都是data[1] & 0x07这种魔法数字。我自己的习惯是在解析器类里增加类似bool hasMilitaryTarget() const { return (desc_ & 0x04) != 0; }的访问方法。
关于单元测试,有人觉得报文解析这种纯IO逻辑不需要写测试,但恰恰是这种协议解析,回归测试价值最高。我会拿一批真实抓包数据作为测试样本,把已知的经纬度、高度、速度存成测试基线,每次改完代码跑一遍,确保没有把正常解析改坏。这套测试后来救了我好几次,都是改UAP映射表后引发的隐性回归。
最后再分享一个小技巧
实际做CAT021解析时,我最后悔的一件事就是早期没有马上把报文归档成原始二进制文件。千万别只存解析后的CSV,因为一旦解析逻辑改了一行代码,所有的历史数据都没法重新验证。正确的做法是:把原始UDP报文完整存成pcap或二进制文件,解析完成后,还要保留一个由原始报文生成的文件。这样你每次改完代码,都能拿历史数据做回放对比,排查问题效率和准确性完全不在一个量级。
CAT021报文解析,本质上就是一场和二进制格式的博弈。理解了它的框架结构、数据项编码方式和C++里的字节操作,你就能从容应对各类ASTERIX协议族的报文。希望这篇基于实际项目的拆解,能帮你少走些弯路。
本文还有配套的精品资源,点击获取