ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ASN1C v0.9.29 实战:ASN.1 到 C 代码生成、编解码与工程集成

ASN1C v0.9.29 实战:ASN.1 到 C 代码生成、编解码与工程集成 简介asn1c 工具 v0.9.29 是一款将 ASN.1 规范自动编译为 C 代码的编译器面向通信协议开发、嵌入式系统及需要处理 BER/DER/PER 编码的程序员。压缩包内共 17257 个文件总大小约 189.42MB主要包含 C 源码文件c/h、编译产物o/bin、ASN.1 示例文件asn1、makefile 构建脚本、测试用例及编译期辅助脚本等目录结构完整适合直接学习或集成使用。目前已有 1219 人学习下载。通过它可快速掌握 asn1c 的命令行用法与常见编译选项如 -fcompound-names、-pduall理解 BER、DER、PER、XER 等编码规则的选择与切换熟悉类型别名、扩展标记、约束条件等 ASN.1 高级特性同时借助大量示例与测试代码可以观察自动生成的头文件、编解码 API 及内存管理方式并结合自带的转换工具试验多种输入输出格式。整体而言该工具包对希望高效处理 ASN.1 数据的 C 语言开发者很有价值能显著降低跨系统数据交换时的开发与调试成本。1. 先看 ASN1C 和 ASN.1 是什么关系一条命令把定义文件变成能编的 C 代码ASN.1 在通信协议里无处不在从 LTE RRC 到 V2X 消息规范里那一堆大写字母和 { } 定义看着像数学题可交付到工程上必须变成 C 结构体、编解码函数和内存释放函数。手写 BER/DER 编码器的人十有八九最后都被 tag 长度和嵌套结构折磨到怀疑人生。ASN1C v0.9.29 就是干这个的它以 ASN.1 模块定义文件为输入生成一整套 C 源码包含类型结构体、BER/DER/PER 编码解码入口、free 和 print 辅助函数直接丢进工程就能编译。这篇就把我从装工具到集成进 CMake 工程、再到排查编码输出不对的完整过程拆给你看每一步都带参数和坑点。2. 环境准备与第一次编译把 .asn 定义变成 C 代码的两条路径2.1 版本选择和安装方式v0.9.29 为什么值得单独装很多 Linux 发行版仓库里的 asn1c 版本比较旧有的还停留在 0.9.27甚至某些嵌入式 buildroot 里自带的是改过的分支。v0.9.29 这个版本值得单独装是因为它对 clang 的兼容性比之前好生成代码里不再依赖 GNU 扩展的零长数组在严格告警的编译环境下更容易过。另外它修复了一批 PER 编码的边界问题尤其是 OCTET STRING 不定长时的长度编码。获取源码直接走官方 GitHub 仓库我一般固定 checkout v0.9.29 这个 tag避免最新主线行为变化影响工程稳定性。编译安装的依赖很少autoconf、automake、libtool 这三件套加上标准 build-essential 就行。在 Ubuntu 系和 CentOS 系上我都装过没有遇到隐藏依赖。git clone https://github.com/vlm/asn1c.git cd asn1c git checkout v0.9.29 autoreconf -iv ./configure --prefix/usr/local make -j$(nproc) sudo make install asn1c -h | head -5这里的 autoreconf 是为了在源码目录里生成 configure 脚本因为直接从 git clone 下来的仓库是不带 configure 的这是一个很常见的忽略点。configure 阶段可以加 --prefix 指定安装位置默认装在 /usr/local/bin 下如果你系统里已经有旧版 asn1c最好确认一下 PATH 优先级否则后面命令行调用的可能还是旧版本。安装完成后执行 asn1c -h 能看到版本号v0.9.29 会直接打印出来。这一步虽然简单但是用来验证环境的第一道关口。很多人后面代码生成行为诡异查下来其实是调用了别的 python 包装脚本或旧二进制这一步就成了排查的关键。2.2 用最小 ASN.1 模块跑通第一个生成流程先写一个最小的 ASN.1 定义文件这里故意把模块名和类型名区分开方便后面验证 -fcompound-names 参数的用处。我们定义一个简单的消息结构包含一个 ID 字段和一个 OCTET STRING 数据载荷。DEFINITIONS AUTOMATIC TAGS :: BEGIN SimplePacket :: SEQUENCE { msgId INTEGER (0..255), payload OCTET STRING (SIZE (1..1024)) } END把这个文件存成 simple.asn然后执行生成命令。注意这里我用了两个关键参数一个是 -fcompound-names另一个是 -fincludes-q前者让生成的结构体名字带上模块名前缀后者控制生成代码里的 include 格式。mkdir -p gen asn1c -fcompound-names -fincludes-quoted -D gen simple.asn ls -la gen/生成的目录里会出现 SimplePacket.c、SimplePacket.h以及一堆以 asn_ 开头的支撑文件。核心原理是 asn1c 读入 ASN.1 定义后先通过内部的 grammar 解析器把模块转换成 AST然后遍历 AST 生成每个类型的结构体、编解码表项和类型描述符。这些 asn_ 开头文件是骨架代码提供通用的 BER/DER 编解码框架、内存分配器和管理函数。生成的 SimplePacket.h 里最关键的是两个东西一个是结构体定义另一个是 asn_TYPE_descriptor_t 类型的描述符它叫 asn_DEF_SimplePacket。结构体里每个成员除了本身数据外还可能带一个 ATS 结构来标记可选字段这就是 AUTOMATIC TAGS 的体现。这个描述符是后续一切编解码操作的入口指针类似对象模型里的元类。2.3 生成文件里哪些需要提交到版本库哪些不用管生成的文件分成两类一是你这个 ASN.1 模块直接对应的类型文件比如 SimplePacket.c/h这些需要进版本库并且每次 ASN.1 规范变更后重新生成覆盖二是 asn_ 前缀的骨架文件它们属于公共支撑层不同 ASN.1 定义生成出来的骨架文件基本一致但 v0.9.29 生成的骨架和版本配套不建议混用旧库里的同名文件。实际工程里我一般只把类型描述文件和 .asn 定义提交 git骨架文件用脚本统一生成或者放独立目录。这样当 ASN.1 版本升级带来类型变化时diff 主要集中在业务类型上。骨架文件版本混用的典型症状是编译报 asn_application.h 里缺少某个函数声明或者运行时编解码直接段错误。生成文件里还有 .merg 文件这是 asn1c 自己用的临时拼接信息不需要提交也不需要编译。另外如果你的定义里有 IMPORTS 引用别的模块还需要用 -I 参数指定依赖模块所在目录asn1c 才能解析完全。我现在维护的工程里就有两个 .asn 文件互相引用这种多模块场景下建议先单独编译公共模块再编译引用方。3. 生成选项与类型映射-f 系列参数怎么选才不翻车3.1 一组常用参数及其真实影响asn1c 的编译选项很多但工程上真正每天用到的不超过十个。我把它们分成三类命名控制、类型控制、输出控制。命名控制里 -fcompound-names 最常用它把模块名拼进生成的结构体名里避免多模块下类型重名代价是代码里函数名和结构体名变长阅读时稍显啰嗦。类型控制里有几个参数对嵌入式影响很大。-fnative-types 会把 INTEGER 映射成 long 而不是 intmax_t这在 32 位平台上能减少 ROM 占用但要注意如果 ASN.1 里有超出 32 位范围的整数值原生类型可能放不下。-fwide-types 则反过来显式用宽类型。默认生成代码里整数类型用的是 intmax_t保证 64 位可表达性代价是结构体体积变大。输出控制里 -fskeleton-only 是个容易被误用的参数。从字面看像是只生成骨架代码实际含义是只生成支撑框架代码不生成你的类型代码。这个参数通常和 -gen-PER 搭配使用用于手动搭建独立工程。正常的业务代码生成不需要加它加了反而看不到自己的类型文件。asn1c -fcompound-names -fnative-types -D gen -I . simple.asn grep -E typedef.*msgId gen/SimplePacket.h上面命令在 gen 目录里查找 msgId 字段的类型定义。加了 -fnative-types 之后会出现 INTEGER 映射成 long 的代码。这里需要根据目标平台决定如果你的报文里数值范围严格在 32 位内用 native-types 没问题如果涉及 64 位大整数我会去掉这个参数让工具用 intmax_t。3.2 PER 与 BER/DER 的生成差异据说 PER 性能更好但生成方式完全不同很多协议从 BER 迁移到 PER 是为了省流量。asn1c 里 PER 支持通过 -gen-PER 打开但有一个细节PER 编码器生成的函数名和 BER 一样都是 asn_encode 系列区别在于编译进工程的骨架代码里是否包含 PER 实现。注意 -gen-PER 必须在第一次生成时就用上骨架文件会多出 per_encoder.c、per_decoder.c 等一堆文件。asn1c -gen-PER -fcompound-names -D gen_per simple.asn ls gen_per | grep per_生成目录里出现了 per_encoder.c、per_encoder.h、per_decoder.c、per_opentype.c 这些文件这就是 PER 模式的标志。如果漏了 -gen-PERasn1c 默认只带 BER/DER 实现后续你想用 PER 还得重新生成整个工程重新生成带来的类型文件覆盖问题很容易引发 git 冲突。PER 编码还有一个 uPER 和 aligned PER 的分支asn1c 实现的 PER 编码遵循 X.691 基本对齐方式。在 5G RRC 这类协议里很多字段定义了扩展标记...生成代码里会多出扩展位图处理逻辑。这部分逻辑属于生成代码自动管理的你不需要手动干预但要意识到加了扩展标记的定义编解码性能会略降因为每次都要处理位图。3.3 参数选错时的典型症状与补救方式有一种情况我踩过用 -fno-include-deps 让生成的类型文件不包含互相依赖的 .h而是统一包含一个大头文件。这个参数在类型数量非常多时能减少 include 爆炸但副作用是局部编译依赖变弱改一个小类型定义会导致全部文件重编译增量构建优势消失。另外用这个参数后你必须保证最终包含关系里所有类型头文件按正确顺序出现。补救方式很简单重新生成一次把 -fno-include-deps 去掉。asn1c 的生成过程是确定性的同样的 .asn 文件加上同样参数生成结果完全一致。这一点值得记住因为很多人担心重新生成是不是会引入随机差异其实不会。所以遇到参数导致的问题最可靠的做法是清理输出目录重新跑一次命令而不是手工修改生成出来的 .h 文件。手工改生成代码的后果是下轮重新生成时改动全部丢失这是白费功夫。我的习惯是把 asn1c 的生成命令写成一个 shell 脚本脚本里固定好参数和输出目录任何新成员拿到脚本就能复现生成过程。这样避免口头传递参数时漏掉某个 -f。脚本里还加上了一个校验步骤检查生成目录里是否包含预期的类型文件防止 ASN.1 定义里类型名拼错导致根本没有生成文件。4. 与 C/CMake 工程对接生成代码如何编进你的模块4.1 需要编译哪些源文件别漏掉骨架里那些 asn_*.c把生成目录直接丢进 CMake 大概率会编译失败因为 asn1c 生成了不少骨架文件但不是每个都需要。你需要的是以你类型命名的 .c以及所有 asn_ 开头的支撑源文件但测试程序文件比如 converter-example.c要排除。v0.9.29 默认不会生成 converter-example但如果是旧版本或手动添加过这个测试入口文件会和你的 main 冲突。我通常用 file(GLOB) 匹配 *.c 再排除掉不需要的文件。这种方法有个缺点如果生成目录里有你不认识的新文件会在 CMake 重新配置时才被发现。更稳的做法是维护一个显式源文件列表因为 asn1c 生成的源文件名相对稳定骨架文件就那二十几个显式写清楚有利于后续审计。cmake_minimum_required(VERSION 3.10) project(asn_packet_demo C) set(ASN_SRC_DIR ${CMAKE_SOURCE_DIR}/gen) file(GLOB ASN_SRCS ${ASN_SRC_DIR}/*.c ) list(FILTER ASN_SRCS EXCLUDE REGEX converter-example|test|\.merg) add_library(asn_codec STATIC ${ASN_SRCS}) target_include_directories(asn_codec PUBLIC ${ASN_SRC_DIR}) add_executable(packet_demo main.c) target_link_libraries(packet_demo PRIVATE asn_codec)这段 CMake 里 filter 正则排除了测试入口文件。另外生成代码用了较多标准库函数不需要额外链接第三方库。但如果你在 -fgen-deterministic 之外还加了某些 XML 支持选项可能需要链接 libxml2这种情况少见一般不需要。4.2 运行时编解码调用方式从结构体到报文再回到结构体代码生成和编译只是第一步实际使用时的核心函数就那几个。编码入口是 asn_encode_to_buffer解码入口是 asn_decode释放内存入口是 asn_DEF_xxx_free。这三个函数都是骨架层提供的通用接口通过类型描述符统一分发到具体类型的编解码实现。只要持有类型描述符指针用同一套 API 就能处理所有生成的类型。#include stdio.h #include stdlib.h #include string.h #include SimplePacket.h int main(void) { SimplePacket_t pkt; uint8_t buf[2048]; memset(pkt, 0, sizeof(pkt)); pkt.msgId 42; uint8_t data[] {0xAA, 0xBB, 0xCC}; OCTET_STRING_fromBuf(pkt.payload, (const char *)data, sizeof(data)); asn_enc_rval_t ec asn_encode_to_buffer( NULL, ATS_DER, asn_DEF_SimplePacket, pkt, buf, sizeof(buf)); if (ec.encoded 0) { fprintf(stderr, encode failed: %s\n, strerror(errno)); return 1; } printf(encoded %zd bytes\n, ec.encoded); SimplePacket_t *dec NULL; asn_dec_rval_t dc asn_decode(NULL, ATS_DER, asn_DEF_SimplePacket, (void **)dec, buf, ec.encoded); if (dc.code ! RC_OK) { fprintf(stderr, decode failed\n); return 1; } printf(decoded msgId: %ld\n, dec-msgId); ASN_STRUCT_FREE(asn_DEF_SimplePacket, dec); return 0; }这里 asn_encode_to_buffer 的 ATS_DER 参数指确定性编码输出字节流不带不确定长度适合签名和校验场景。如果想要流式输出可以用 asn_encode_to_new_buffer 拿到 malloc 出来的新缓冲用完必须 free否则泄漏。解码时传的是指针的指针RC_OK 表示完全解析成功如果数据被截断或字段缺失会返回 RC_WMORE 或 RC_FAIL实际工程里这几种返回码都要处理。OCTET_STRING_fromBuf 是 OCTET STRING 类型的便捷构造函数它内部做了 memcpy。如果直接对 payload.buf 赋值需要自己维护 buf 和 size容易出错。生成的结构体整体是静态分配的但里面的 OCTET STRING 成员是动态指针所以释放时要用 ASN_STRUCT_FREE 或对应类型的 free 函数不能简单 free 整个结构体指针。4.3 动态缓冲与内存归属编码结果到底谁负责释放用 asn_encode_to_new_buffer 拿到的缓冲文档上说的是调用者负责释放。这个规则容易被忽略尤其是函数内部封装了一层编解码工具函数编码结果传出来后没人释放时间长了就是内存泄漏。我的做法是在封装层统一规定解码出的结构体由调用者释放编码出的缓冲也由调用者释放中间层不做转移。另一个容易踩的点是嵌套结构体的内存管理。如果你的 ASN.1 类型里包含 SEQUENCE OF 或 OPTIONAL 字段释放时必须调用对应深度的 free 函数。asn1c 生成的每个类型描述符都有配套的 free 实现它会递归释放所有动态成员。所以 ASN_STRUCT_FREE 不能换成 free后者只会释放顶层内存内部指针全部泄漏。为了把内存管理风险降下来我在调试阶段会给编译开启 AddressSanitizer专门抓这类泄漏。ASAN 对 asn1c 生成的代码很有效因为它对越界和释放后使用非常敏感。等工程跑到稳定期再考虑关闭但至少回归测试时都会开。这和业务代码的测试策略不同编解码库对内存错误的容忍度很低。5. 避坑与排查BER/DER 编码输出与内存管理的典型问题5.1 编码后的字节和协议文档对不上现象自己用工具编出来的 DER 字节流拿 Wireshark 或协议分析仪一对比Tag 或者长度字段多了几个字节跟文档示例不一致。原因多数情况是用了 BER 而协议要求 DER或者定义里字段顺序与文档不同。BER 允许不定长编码DER 强制定长同样的 SEQUENCE 编码结果可能不同。另外 ASN.1 里字段顺序在定义时已经排好asn1c 不会重新排序如果文档里写的是另一套字段顺序编码结果当然对不上。解决确认编解码时用的 ATS_BER 还是 ATS_DER默认 asn_encode_to_buffer 如果传 ATS_BERSEQUENCE 里的 OCTET STRING 长度字段在某些情况下会用不定长形式。改成 ATS_DER 即可。同时逐字段比对 .asn 定义与文档的顺序和约束特别是 OPTIONAL DEFAULT 字段它们会影响编码结果。5.2 解码返回 RC_WMORE 但数据明明完整现象接收端拿到报文后走 asn_decode返回 RC_WMORE代码里判断不完整直接丢弃但包分析工具显示报文完整。原因RC_WMORE 并不一定表示数据不够。当解码器遇到一个字段的类型与预期不符或者扩展标记与实例值不匹配时也可能返回 RC_WMORE它真正的含义是“这轮没解完”。很多工程师把这个返回码当成普通错误处理丢了有效报文。解决把 RC_WMORE 当作“需要更多数据”来重试的话要注意 asn_dec_rval_t 里还有一个 consumed 字段它表示本轮消耗了多少字节。正确处理是累计偏移把剩余数据追加到缓冲继续喂给解码器而不是清空重新开始。实际工程里我见到不少实现是靠追包解决但最省事的是保证一次收到完整包在传输层做好分片重组。5.3 结构体 malloc 未初始化导致 free 崩溃现象解码失败后调 ASN_STRUCT_FREE 释放部分解析的结果直接段错误。或者结构体静态分配后没清空就调用编码编码器读出随机指针。原因asn1c 生成的编解码器会读取结构体里的指针字段来判断 optional 字段是否存在。如果你的结构体是 calloc 分配的还好如果是 malloc 或者栈上声明后没 memset指针字段值是随机的解码器在 free 阶段就会访问这个随机地址。解决所有结构体分配后立即 ASN_STRUCT_RESET 或者 memset。静态分配的场景更要小心我一般写一个初始化宏在实例化时统一清零。这一点在嵌入式裸机环境尤其关键因为栈上内存本来就被反复使用过残留值更不可测。5.4 -fcompound-names 对既有代码的破坏性改名现象升级 asn1c 版本后重新生成原来代码里引用的 MyType 变成了 ModuleA_MyType编译直接报未定义标识符。原因asn1c 的 -fcompound-names 在不同版本之间对嵌套类型命名的处理有一些变化特别是 SEQUENCE 里的内联 SEQUENCE 类型新版本生成的嵌套结构体名可能会多拼接一层父类型名。解决如果不想改业务代码可以在生成命令里去掉 -fcompound-names但前提是模块之间类型没有重名。多模块强约束场景我建议还是在业务代码里预留一个类型别名层比如统一用 typedef 把生成的类型名映射成业务缩写这样 asn1c 升级引起的改名只影响别名层。从那以后我每次升级 asn1c 后都强制走一遍编译 全量测试。5.5 生成代码在严格编译器下报 warning 为 error现象CMake 开启 -Werror 后生成代码里出现 unused variable 或 signed compare 告警编译失败。原因asn1c v0.9.29 生成的代码本身不是在任何告警级别下都干净尤其是指针相关代码在 clang 下容易触发 sign-conversion 告警。这不是工具坏了是对告警级别要求过高。解决对 asn_codec 这个 target 关闭部分告警即可不要把 -Werror 加到生成代码上。在 target_compile_options 里用 -Wno-sign-conversion 和 -Wno-unused-variable业务代码保持原有告警级别。这样生成代码和手写代码各自按不同标准管理工程整体告警仍然可控。6. 进阶与验证用 -asn1p 参数约束别名映射让结构体名字更贴近业务除了基础的 -f 系列asn1c 还支持 -asn1p 开头的若干参数它们直接传给内嵌的 ASN.1 解析器。其中一个实用场景是处理单模块多文件引用配合 -I 参数能控制搜索路径顺序。另一个值得研究的是用 -fknown-extern-type 把自己手写的类型标记为已知类型让生成代码直接 include 你自己的头文件。asn1c -fknown-extern-type MyCustomType \ -I /path/to/my_types \ -fcompound-names -D gen custom.asn这个参数在对接既有 C 数据结构的场景里很有用。比如先实现了自己的链表结构不想让 ASN.1 重新生成一套就可以在 .asn 定义里引用 MyCustomType然后用 -fknown-extern-type 让 asn1c 认为它是外部已有类型只生成引用头文件的代码不生成重复结构体定义。代价是你必须保证这个类型的布局和编解码行为跟你手写实现一致否则混合编解码会出问题。验证生成结果是否满足工程要求除了编译跑通测试用例之外我还会额外看两样东西。第一个是生成的头文件里 include 关系是否合理有没有过多的循环包含隐患第二个是结构体内存布局大小打开编译器的 -fdump-record-layouts 可以打印每个结构体的偏移和总大小对嵌入式内存敏感的模块尤其有用。还有一个适合日常自检的做法是用 ASN.1 自带的上层约束去验证编解码结果的语义正确性。比如 INTEGER 定义成 (0..255)编码前插入 300 会怎样asn1c 生成代码会调用约束检查函数但是默认约束检查是从骨架层开启的如果编译时定义了 -DASN_DISABLE_CONSTRAINTS检查会被禁用编码器会照常输出超范围值。我把这个宏当作测试阶段的开关正式构建里强制不定义它。最后说回调试工具asn1c 生成的类型描述符可以通过 asn_print 之类的打印函数输出调试字符流在 GDB 里也能方便地观察结构体内容。我现在每次改动 .asn 定义后都会先跑一遍编码、打印十六进制字节、再用独立解码器解回来三个步骤走完才提交生成代码。从那以后我再也没遇到过把规范改了却忘了重新生成导致的线上问题这算是一种代价最低的回归手段。希望这篇 ASN1C v0.9.29 的落地笔记能帮到你少走我踩过的几个弯。本文还有配套的精品资源点击获取
返回列表