☰
ASN1C v0.9.29 实战:ASN.1 到 C 代码生成、编解码与工程集成
2026/9/26 4:48:28 网站建设 项目流程

简介: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、-pdu=all),理解 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-PER,asn1c 默认只带 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_BER,SEQUENCE 里的 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 的落地笔记能帮到你,少走我踩过的几个弯。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询