ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

asn1c v0.9.29实践:从ASN.1描述生成C代码与BER/DER编解码

asn1c v0.9.29实践:从ASN.1描述生成C代码与BER/DER编解码

简介:asn1c v0.9.29 是 ASN.1 源码到 C 语言代码的编译器,面向通信协议、网络设备管理与 M2M 等场景的 C 开发者,可将 .asn1 定义自动转换为头文件、编解码函数及构造函数,支持 BER/DER/PER 等编码规则和选择性编译、类型别名、约束处理等特性。这份资源为 zip 压缩包,共 17257 个文件,约 189.42MB,主体为编译产物与示例源码:7600 个 bin、2747 个 o 等可执行/目标文件,2637 个 c 与 2305 个 h 构成核心源码和接口,另有 asn1、makefile、test 环境与脚本等,适合直接编译、测试或对照学习。已有 1219 人学习下载。包内含大量 ASN.1 示例(如 real-life、resolver、enum、class 等主题)与编译检查脚本,覆盖从 ASN.1 模块编写、命令行编译(如 -fcompound-names、-pdu=all 等常用选项)到 C 项目集成、编码解码验证的完整流程,同时可借助其中的 test-set、test-environment 文件快速了解自动化测试与回归验证方式,适合作为学习和二次开发的模板参考。

1. asn1c v0.9.29 到底解决什么:把 ASN.1 描述变成可编译的 C 代码

做通信协议栈的人,几乎都让 ASN.1 的编解码折磨过:协议文档里一个 SEQUENCE 六七行,手写 BER 编码却要处理嵌套长度、可选字段缺省、tag 组合规则;报文一改字段类型,整个编解码模块就得推倒重来。asn1c 工具 v0.9.29 解决的就是这件重复劳动——输入一份 X.680 语法的 .asn 文件,输出一批 C 源码和配套运行时库,你只需要补齐业务逻辑。它适合刚接手协议项目想快速起量的开发者,也适合想搞懂 BER/DER 内部迭代过程的人。注意它生成的是骨架,不是替你理解协议:类型描述符、ber_decode、der_encode 这些入口需要你知道怎么接。

2. 编译原理与生成物:从 TLV 到 asn_TYPE_descriptor_t 的运行路径

2.1 为什么手写 BER 编解码是玄学:TLV 嵌套的边界成本

ASN.1 的二进制编码核心是 TLV(Tag-Length-Value)三元组。看着简单,真正手写编解码时容易在“长度”上翻车

字段字节示例说明
Tag0x30SEQUENCE 的 tag,由 class + constructed + 编号拼成
Length0x81 0x80短格式/长格式切换规则,128 字节以上必须长格式
Value0x02 0x01 0x2A嵌套子类型的完整 TLV

一套复杂协议里,SEQUENCE 套 SEQUENCE、CHOICE 里嵌 OCTET STRING 的情况很常见。手写时 Length 字段要在“先知道子内容长度”的前提下才能填,而子内容里又可能嵌套更深的 TLV,这就倒逼你写两遍逻辑:第一遍算长度,第二遍填内容。asn1c 生成的代码把这两遍封装在 der_encode 里,本质上是把这个递归过程机械化。

2.2 从词法分析到类型描述符:编译器的三个执行阶段

asn1c v0.9.29 拿到 .asn 文件后不是直接吐 C 代码,而是走三段流程。第一段是词法与语法解析,把文本拆成 AST;第二段做类型解析与约束推导,把 ASN.1 的 INTEGER、SEQUENCE、ENUMERATED 映射到内部类型系统;第三段才是代码生成,为每个类型输出一对 .c/.h 文件,并生成一个核心数据结构:asn_TYPE_descriptor_t。

#include <asn_application.h> #include "VehicleSpeed.h" asn_TYPE_descriptor_t asn_DEF_VehicleSpeed = { "VehicleSpeed", /* 类型名 */ "VehicleSpeed", /* 打印名 */ &asn_OP_SEQUENCE, /* 类型操作表:分配、释放、约束检查 */ SEQUENCE_constraint, /* 约束检查回调 */ SEQUENCE_decode_ber, /* BER 解码回调 */ SEQUENCE_encode_der, /* DER 编码回调 */ SEQUENCE_decode_xer, /* XER 解码回调 */ SEQUENCE_encode_xer /* XER 编码回调 */ };

这才是 asn1c 生成代码“活”的关键:所有编解码函数都通过这个描述符被调用,而不是直接操作结构体。你在业务代码里调用 der_encode_to_buffer 时,传入的第一个参数就是这个 asn_DEF_xxx 指针,编译期自动生成、链接期自动解析。理解这一点,后面遇到 undefined reference 就有排查方向了。

2.3 生成代码的核心对象与生命周期

asn1c 生成的业务结构体很朴素,比如一个 VehicleSpeed 类型会生成:

typedef struct VehicleSpeed { long value; VehicleSpeed__precision precision; OCTET_STRING_t timestamp; } VehicleSpeed_t;

结构体只负责“存数据”,真正做事的是描述符里挂的 decode/encode/free 回调。所以生命周期永远是:栈上初始化结构体 → 填字段 → der_encode 输出 → ber_decode 得到堆上的新结构体 → ASN_STRUCT_FREE 释放。

最容易被忽略的是释放。asn1c 生成的类型里有动态数组、动态字符串,直接 free 会漏掉深层内存,必须通过 ASN_STRUCT_FREE 宏走描述符里的 free 回调。我在某项目里就因为这个漏过内存,看起来是小事,跑 7x24 小时就变成内存爬坡。

3. 拉一个真实协议文件实战:编译参数、目录布局与最小链接示例

3.1 造一个最小协议文件:SEQUENCE 加 ENUMERATED

这里用一个虚构的车速消息来演示,协议结构足够小,但覆盖了 INTEGER、ENUMERATED、OCTET STRING 三类最常见的 ASN.1 类型。

VehicleSafetyMessage DEFINITIONS AUTOMATIC TAGS ::= BEGIN VehicleSpeed ::= SEQUENCE { value INTEGER (0..255), precision ENUMERATED { low(0), high(1) }, timestamp OCTET STRING (SIZE(4)) } END

AUTOMATIC TAGS 的作用是自动分配 tag 编号,省去手写显式 tag 的麻烦。如果你去掉这一行,生成的代码在 BER 编码时 tag 布局一般也不会冲突,但遇到跨模块引用时还是建议显式写清楚,避免排错时浪费大量时间。

3.2 编译命令逐段拆解:asn1c 的常用参数与版本边界

把上述内容存成 VehicleSafetyMessage.asn,执行编译:

asn1c -D ./gen \ -fcompound-names \ -fnative-types \ -fno-constraints \ -S /path/to/asn1c/skeletons \ VehicleSafetyMessage.asn
参数作用现场建议
-D ./gen输出目录,生成文件统一放进 gen 文件夹建议单独建目录,别和源码混在一起
-fcompound-names类型名带父级前缀,避免命名冲突多个 .asn 同时编译时必须加
-fnative-typesINTEGER 映射成 C 原生整型减少后续类型转换代码
-fno-constraints忽略 ASN.1 约束检查先跑通流程再加回来,不要在生产环境长期开
-S skeletons指定运行时骨架目录不指定通常也能找到,但显式指定最稳

跑完后 gen 目录下会出现一批 .c/.h 文件,规模小的协议大概二十到四十个文件。v0.9.29 的版本边界在这里要提一句:它的 PER(Packed Encoding Rules)支持并不是开箱即用的,需要额外补丁,如果你想编 PER 流,先确认手头这个版本是否满足,否则老老实实用 BER/DER。

注意:-fno-constraints 会让 SIZE(4) 这类约束失效。演示阶段可以开,正式协议栈里尽量不开,否则对端传一个 10 字节的 timestamp 过来,解码照样收,你后续处理就可能越界。

3.3 写一个最小可运行 demo:编码、发送、解码、校验闭环

现在写一个 main.c,完成“编码 → 拿到字节 → 解码 → 校验”的最简闭环。

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdint.h> #include "VehicleSpeed.h" #include <asn_application.h> int main(void) { VehicleSpeed_t msg; uint8_t buf[128]; size_t buf_size = sizeof(buf); asn_enc_rval_t ec; memset(&msg, 0, sizeof(msg)); msg.value = 80; msg.precision = 1; /* high */ msg.timestamp.buf = (uint8_t*)"\x01\x02\x03\x04"; msg.timestamp.size = 4; ec = der_encode_to_buffer(&asn_DEF_VehicleSpeed, &msg, buf, buf_size); if (ec.encoded == -1) { fprintf(stderr, "encode failed\n"); return 1; } printf("encoded %zd bytes\n", ec.encoded); VehicleSpeed_t *decoded = NULL; asn_dec_rval_t rc = ber_decode(NULL, &asn_DEF_VehicleSpeed, (void**)&decoded, buf, (size_t)ec.encoded); if (rc.code != RC_OK) { fprintf(stderr, "decode failed, code=%d\n", rc.code); return 1; } printf("decoded value=%ld precision=%ld\n", (long)decoded->value, (long)decoded->precision); ASN_STRUCT_FREE(asn_DEF_VehicleSpeed, decoded); return 0; }

这里要注意几个参数细节。ber_decode 第一个参数是 codec context,日常传 NULL 即可;解码结果必须判断 rc.code 是否等于 RC_OK,RC_WMORE 表示数据没传完,RC_FAIL 表示格式非法。解码失败后,asn1c 可能在结构体内部分配了部分内存,所以拿回来的 decoded 指针要用 ASN_STRUCT_FREE 走释放流程,不能直接 free。这个 demo 里 timestamp 用的是外部静态字符串指针,常规做法是先为 OCTET_STRING_t 分配独立 buffer,再 memcpy,否则结构体生命周期和字符串生命周期绑定在一起,容易出问题。

编译命令也不复杂:

cc -I ./gen -I /path/to/asn1c/skeletons \ main.c ./gen/*.c /path/to/asn1c/libasn1c.a \ -o demo -DASN_DEBUG

在 Windows 的 MinGW 环境里,./gen/*.c可能不会被 shell 自动展开,最常见的办法是写一个 .mk 文件把文件列表列出来,或者用find手动生成。先跑通这个 demo,后续再逐步接入真实收发逻辑。

4. 避坑与排查:5 个常见编译/编码翻车现场

4.1 编译期报错:unknown type,asn1c 一个文件都没生成

现象:asn1c 执行后,终端抛Unknown type: "SomeType",gen 目录是空的。

原因:ASN.1 文件里引用了另一个模块定义的类型,但你把两个文件分开编译;或者文件里用了 v0.9.29 不认识的新语法,比如新版开放类型 ANY 的某些变体。

解决:把依赖的类型定义文件放进同一次 asn1c 调用,例如asn1c fileA.asn fileB.asn,让它一次性完成交叉引用解析。如果确定是语法版本太新,退一步把无法识别的 ANY DEFINED BY 直接改成 OCTET STRING,生成代码后在业务层自己解析内部字节。这算是一种降级方案,但能保住整个协议栈的骨架。

4.2 链接期报错:undefined reference 到 asn_DEF_xxx

现象:main.c 编译通过,链接时报undefined reference to 'asn_DEF_VehicleSpeed'。

原因:绝大多数情况是 gen 目录下的类型实现 .c 文件没有全部参与链接,或者 skeleton 目录下的 asn_application.c / asn_codecs_prim.c 被漏掉了。asn_DEF_xxx 的定义存在于每个具体类型对应的 .c 文件里,不参与编译自然就找不着。

解决:编译时显式把 gen 下的所有 .c 文件和 skeleton 下的核心运行时文件都加进源文件列表。一个比较省事的做法是:

find ./gen -name '*.c' > sources.txt

然后把 sources.txt 的内容拼到 cc 命令里。注意别用asn1c -fcompound-names后又不加这个参数重新编译,类型名称对不上也会出现同样的报错前兆。

4.3 运行期错报:编码出来比预期长 2 字节,Length 字段变成 0x84

现象:der_encode_to_buffer 返回成功,但打印出来的十六进制里 SEQUENCE 的 Length 是0x84 0x00 0x00 0x01 0x08,比手算的 TLV 长出一截。

原因:一个可能是缓冲区给得太小,编码器内部回退到“初始长度未知”的长格式;另一个可能是字段本身缺少约束,asn1c 推断不出定长结构,DER 编码器只能按 BER 的思路处理长短格式切换。

解决:先确认 buf 给足,至少 128 字节以上;再检查 .asn 里是否加了 SIZE(4)、INTEGER (0..255) 这类边界约束,约束存在时编码器能确定内部字段长度,优先输出短格式。如果两边都查过还是长格式,就需要检查是不是把 ber_encode 和 der_encode 混用了——DER 对长度格式要求更严格,asn1c 的 der_encode 才会做短格式优化。

4.4 数据错报:对端解析后 OBJECT IDENTIFIER 变成全 0

现象:双方用同一份协议文件,发端编码正常,收端解析出来的 OID 节点全是 0。

原因:asn1c 的 OBJECT_IDENTIFIER_t 内部不是 char* 字符串,而是一组整型 arc 序列。直接把点分字符串 memcpy 进去,解析时就会把 ASCII 字符的字节值当成 arc,结果自然是乱码。

解决:查一下该字段对应的 .h 结构体,如果是 OBJECT_IDENTIFIER_t,赋值时拆 arc 或用辅助函数构造;如果你只是透传一段 OID 字节,干脆改成 OCTET STRING,避免解析语义的歧义。这个坑在证书、V2X 安全报文里尤其常见。

4.5 环境坑:源码包按 README 执行 make,机器上没有合适的工具链

现象:下载 asn1c v0.9.29 源码包后直接 make,报缺少依赖文件或编译中断。

原因:v0.9.29 的年代比较早,官方包自带的构建文件依赖 autotools 生成的一批 Makefile,新系统上缺预生成产物,或者编译器默认标准变了,老代码里一些隐式声明直接变成错误。

解决:先跑./configure重新生成 Makefile,再 make;如果连 configure 都过不去,就直接绕过构建系统,把预编译好的 asn1c 可执行文件放到 PATH 里,同时把 skeleton 目录单独保存。这样每次编译 .asn 时手动指定-S skeleton路径,不依赖整套构建链路,反而是最稳的用法。

5. 进阶用法:把生成代码封装成大系统可用的编解码库

项目里其他模块不关心 ASN.1 类型,只想要“给我车速和精度,吐出一段字节”。这种时候我会在 gen 代码之上包一层,把 asn1c 的细节全部藏进内部,对外只暴露普通 C API。

/* vsm_codec.h */ #ifndef VSM_CODEC_H #define VSM_CODEC_H #include <stdint.h> #include <stddef.h> int vsm_encode(uint8_t *out, size_t *cap, long speed, int precision, const uint8_t *ts4); int vsm_decode(const uint8_t *in, size_t len, long *speed, int *precision); #endif

实现里负责一件事:外界不拿 VehicleSpeed_t,也不拿 asn_DEF_VehicleSpeed,只需要处理内存和错误码。

/* vsm_codec.c */ #include "vsm_codec.h" #include "VehicleSpeed.h" #include <asn_application.h> #include <string.h> int vsm_encode(uint8_t *out, size_t *cap, long speed, int precision, const uint8_t *ts4) { VehicleSpeed_t msg; asn_enc_rval_t ec; int ret = 0; memset(&msg, 0, sizeof(msg)); msg.value = speed; msg.precision = precision; msg.timestamp.buf = (uint8_t*)ts4; msg.timestamp.size = 4; ec = der_encode_to_buffer(&asn_DEF_VehicleSpeed, &msg, out, *cap); if (ec.encoded < 0) { ret = -1; goto cleanup; } *cap = (size_t)ec.encoded; cleanup: /* 栈上结构体不涉及动态内存时无需释放 */ return ret; }

封装完成之后,验证方法是写一个只有几行的测试程序,先 encode,再立刻 decode,然后比较字段一致。编译时加-DASN_DEBUG,可以让 asn1c 运行时输出每一层的 TLV 解析日志,这时你能看到 BER Length 递归过程,也可以快速复现第 4.3 节的长格式问题。

cc -DASN_DEBUG -I gen -I skeletons main_test.c vsm_codec.c gen/*.c libasn1c.a -o codec_test ./codec_test | xxd

我在某跨平台项目的第二个迭代版本里,就因为省了这一步,直接拿生成代码去对接网关,结果 ASN.1 类型检查没过,对端静默丢包,排查了整整一个下午。从那以后我每次写完编码函数,都会强制用 asn_check_constraints 做一遍约束校验,再跑一次 encode/decode 回环测试,确认无误才会把代码合进去。希望这套操作让你在 asn1c v0.9.29 上少走弯路。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进