在深入钻研 PE 文件解析库之后,我对 .NET 二进制结构有了非常深的理解。其中有一类元数据让我们既爱又恨——自定义属性。它们在 .NET 生态中几乎无处不在,但底层存储机制却让无数开发者和工具作者头疼。
本文将详细剖析自定义属性的工作原理、存储机制,以及为什么它们在底层如此复杂,让解析工作充满挑战。
示例源码:https://pan.baidu.com/s/1O0EMpm9y8PTqC0E534YkBQ?pwd=7uya
一、自定义属性是什么?
自定义属性是附加在类、方法、字段、参数等对象上的元数据。它们本质上是一种行为描述,告诉编译器或运行时在特定场景下需要执行额外逻辑。
你可以使用 CFF Explorer 等工具在 .NET 二进制文件中查看这些元数据表的原始内容:

单个自定义属性行的展示:

举个经典例子:
[Obsolete]
public class OldClass { }
使用 OldClass 时,编译器会提示:
CS0612: 'OldClass' is obsolete
这里,Attribute 并不执行任何逻辑,它只是提供了额外信息。
自定义属性不仅限于编译器控制,还广泛用于:
- 框架注解:如 ASP.NET Core 路由
[Route("api/users")]
- ORM 映射:如 Entity Framework 的
[Column("user_name")]
- JSON 序列化:如
[JsonPropertyName("user_name")]
- 依赖注入:如
[Singleton]
- 单元测试框架:如
[Test]
- 源代码生成:如
[AutoNotify] 自动生成 INotifyPropertyChanged 代码
可以说,现代 .NET 开发几乎离不开自定义属性。
二、自定义属性的底层存储
.NET 的文件格式采用元数据表(Metadata Tables)管理所有类型、方法、字段、参数等对象。每个对象都可以通过一个 metadata token(表 + 行号) 高效索引。
自定义属性也存储在专门的 CustomAttribute 表中,每行包括:
- 所附加对象的引用(Member)
- 属性构造函数的引用
- 参数数据在 Blob Stream 中的偏移量
Blob Stream 中的参数序列按照构造函数参数类型序列化。例如:
public class MyAttribute(int x, string y) : Attribute { }
[MyAttribute(0x1337, "Hello, world!")]
public class MyClass { }
Blob 中存储顺序为:
4 字节整数 + 长度前缀字符串
三、枚举参数的解析复杂性
大多数自定义属性使用 枚举参数。枚举值在存储时使用其底层类型(int、short 等)。
示例:
public enum MyIntEnum { Value1=1, Value2=2 }
public enum MyShortEnum : short { Value1=1, Value2=2 }
public class FooAttribute(MyIntEnum a, MyShortEnum b) : Attribute { }
[Foo(MyIntEnum.Value2, MyShortEnum.Value2)]
public class SomeClass { }
序列化后:
MyIntEnum.Value2 -> 4 字节
MyShortEnum.Value2 -> 2 字节
问题是:Blob 并不存储枚举底层类型。
解析时必须:
- 解析枚举类型所在的 Assembly
- 遍历 TypeDef 表找到枚举定义
- 查找隐藏字段
value__
- 确定底层类型长度(1、2、4 或 8 字节)
对于嵌套枚举或类型转发(Type Forwarder),解析逻辑可能涉及多个 DLL 和复杂的递归查找。即便加了缓存,仍然远比读取原始字节复杂得多。
四、类型参数(Type)解析的困境
自定义属性还可以使用 System.Type 作为参数:
[Foo(typeof(int))]
.NET 编译器并不使用 metadata token,而是存储完全限定名(FQN)字符串:
"System.Int32, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
问题在于:
- 空间效率低:单个整数变成 89 字符,泛型类型甚至可能超过 300 字符
- 解析复杂:需要拆解 FQN,解析类型名、程序集、版本、文化信息、PublicKeyToken
- 不一致:没有提供程序集信息时,解析行为依赖运行时猜测,导致某些类型无法解析
相比于元数据表索引机制,这种做法既慢又占用空间。
五、为什么会有这种设计?
我的猜测是历史原因和向 Java 借鉴的习惯:
- Attribute 在 .NET 中可跨 Assembly 使用,早期设计需要灵活序列化类型信息
- 字符串方式容易序列化和反序列化,也方便版本兼容
- 枚举和 Type 参数在大多数场景下是小众需求,兼顾通用性而牺牲了性能
无论如何,这为 PE 文件解析器 增加了大量复杂逻辑,也导致 AsmResolver 维护中出现了许多 bug。
六、总结
自定义属性是 .NET 元编程的核心工具,它极大丰富了语言和框架能力,但底层存储设计非常复杂:
- 枚举参数必须解析底层类型
- Type 参数使用 FQN,空间浪费且解析复杂
- 多层类型转发和 Assembly Resolution 增加了逻辑复杂度
作为开发者,我们可以:
- 理解它的底层原理,有助于调试、反射和工具开发
- 使用时关注性能和序列化成本
- 对 PE/IL 工具作者来说,这是“噩梦级”的挑战
归根结底,.NET 自定义属性是一个典型的高层设计优雅、底层实现复杂的案例。
即便如此,它依然是现代 .NET 框架不可或缺的基石。本文由云栈社区技术编辑发布。
引用链接
[1] https://blog.washi.dev/posts/custom-attributes-and-why-they-suck/