~/posts/binary/msvc-xfg-type-hash-encoding.md

MSVC XFG:从类型编码到调用点哈希

限定 MSVC 19.28 预览版的 x64 C 实现,拆解类型归一化、递归摘要与后端掩码。用 memcpy 和浮点函数原型验证序列化结果,区分调用点常量、入口标记和未覆盖的类型语义。

date[31:24]
read[23:16]
7 分钟
cat[15:8]
二进制
目录
  1. 0x00先区分调用点常量与入口标记
  2. 0x01前端与后端分别决定什么
  3. 0x02函数原型序列的字段顺序
  4. 0x03归一化先于递归类型哈希
  5. 0x04后端掩码不是摘要的透明搬运
  6. 0x05用两个原型验证编码链
  7. 0x06从最早的分歧定位错误
  8. 0x07参考资料

在 x64 间接调用附近看到一个 64 位常量,并不意味着它是地址或机器码摘要。XFG 为调用点和目标函数编码类型信息;要解释一次匹配,必须同时看类型归一化、前端序列化、后端掩码,以及运行时对标记位的处理。

分析范围固定为 Visual Studio 2019 16.8.0 Preview 2.1 的 C 编译流程,c1.dll 和 c2.dll 均为 19.28.29213.0。这些内部编码属于该预览实现,不作为后续工具链的稳定接口,也不直接推广到 c1xx.dll 处理的 C++ 类型。

先区分调用点常量与入口标记

CFG 的基本边界 是间接调用目标是否有效。此处的 XFG 路径进一步比较函数类型标识。用于观察的 C 原型如下:

example.cc
typedef float (*FPTR)(float, float);

float difference(float a, float b) {
    return b - a;
}

int main(void) {
    FPTR fn = difference;
    return fn(1.00001f, 2.00002f) > 0;
}

案例使用预览工具链的 x64 开发者命令行,命令为 cl /Zi /guard:xfg example.c。这是版本限定的编译输入,不是对任意当前 MSVC 的选项承诺;若优化把间接调用折叠为直接调用,还应先确认实际调用点是否保留。

float(float, float) 的两种编码形式3 行
位置 值 用途
调用点 R10 0x99743F3270D52870 预期函数类型
调用点 RAX 目标函数地址 间接调用目标
目标入口前 8 字节 0x99743F3270D52871 带最低位标记的类型值

调度经 __guard_xfg_dispatch_icall_fptr 进入。相关比较可摘成下面两条指令;它们不是完整、连续的调度器反汇编,中间还有目标地址相关检查。

XFG comparison excerptasm
or  r10, 1
cmp r10, [rax-8]

最低位相差 1 因而不是类型冲突。应该比较调度器实际使用的形式,而不是直接比较文件中两个十六进制常量。

前端与后端分别决定什么

从类型到程序常量5 行
阶段 组件或函数 产物
原型收集 c1.dll!XFGHelper__ComputeHash_1 参数、调用约定、返回类型
类型编码 XFGHasher、XFGTypeHasher 有序字节序列及嵌套类型摘要
前端摘要 XFGHasher::get_hash SHA-256 的前 8 字节
后端编码 c2.dll!XfgIlVisitor::visit_I_XFG_HASH 施加位掩码后的调用点常量
目标侧标记 函数入口前的数据 调用点形式再设置最低位

XFGHasher::add_function_type 追加参数数目、各参数类型摘要、可变参数标记和调用约定,随后 add_type 追加返回类型摘要。摘要以字节串参与下一层哈希,转换成 64 位整数时才需要明确小端解释。

XFGHelper__GetHashForType 可以复用 Type_t 的缓存结果。缓存改变计算成本,不应改变编码内容。这里研究的是输入给哈希函数的类型表示,不是函数体、变量名称或源文件的摘要。

函数原型序列的字段顺序

定义 H(x) = SHA256(x)[:8]。普通 C 原型按以下顺序构造前端输入:

函数原型序列化布局5 行
字段 宽度 编码
参数数目 4 字节 归一化后的 u32_le 计数
参数类型 每项 8 字节 按声明顺序连接类型摘要
可变参数标记 1 字节 普通非可变参数函数为 0
调用约定 4 字节 u32_le(convention & 0x0f)
返回类型 8 字节 返回类型摘要

在该实现中,默认 x64 调用约定内部值 0x201 留下低四位 1,__vectorcall 的 0x208 留下 8。这些数值是编译器内部编码;Microsoft 的 x64 调用约定 与 __vectorcall 文档用于理解 ABI 语义,并未承诺这里的内部枚举值。

参数计数来自 RealNumberOfParameters() 等内部表示。可变参数和某些特殊条目存在调整路径,因此通用编码器应从归一化后的原型出发,而不是机械地数源码逗号。与虚信息有关的分支没有在所述 C 测试中命中,其完整语义仍待独立验证。

归一化先于递归类型哈希

参数处理会执行数组或函数类型退化,并清理普通路径上的部分顶层修饰位,包括 const 和 volatile。这不代表所有层级的限定符都被删除。

const void * 的限定符位于被指向的 void 上;void * const 的限定符位于指针本身。递归编码前先确定限定符属于哪一层,是计算能否匹配的关键。

类型序列以“限定符字节、类型组字节、该组数据”开头。内部修饰位 0x800 与 0x40 分别表示 const、volatile,压缩后落在限定符字节的 bit 0、bit 1。无限定为 0,两者皆有为 3。

该预览实现中的类型分支5 行
类型分支 编码要点 边界
基础类型,组 1 限定符、01、基础类型码 float=0x0b、void=0x0e、本例 size_t=0x88
标签类型,组 2 名称参与编码 匿名名称为 <unnamed>;<local> 的完整触发条件未确定
普通指针 0x102、函数指针 0x106,组 3 限定符、03、被引用类型摘要、02 指针与被引用对象是两个层级
函数对象 0x101,组 3 原型字段、返回类型摘要,末尾标记 01 不应与指向它的指针合并
带计数的数组,组 3 限定符、03、u64_le(count)、元素摘要、06 另有省略计数的特殊分支;参数数组还可能先退化

组选择涉及内部类型值的 0x100、0x200、0x400 标志,并非直接使用 C 语法类别编号;泛型特殊路径也不应按普通基础类型继续编码。标签类型的观察则说明,同名、匿名、作用域和成员布局之间的等价关系,应由实际编码决定,单凭内存布局一致得不出哈希一定一致的结论。

基础类型的三个明确例子中,float 的内部类型值为 0x26,void 为 0x40,本例无符号 64 位 size_t 为 0x4019。它们分别映射到表中的单字节编码。因而 void 的输入为 00 01 0e,const void 为 01 01 0e;差异再向外传播到指针摘要。

后端掩码不是摘要的透明搬运

xfg_backend_encoding.pypython
encoded = (frontend & 0xFFFDBFFF7EDFFB70) | 0x8000060010500070
entry = encoded | 1

frontend 是截断摘要按小端解释的整数。AND 固定一部分位,OR 又强制设置一部分位,所以最后的 64 位存储宽度不等于保留了 64 位自由摘要信息。

对这两个特定掩码,仍可随输入变化的位集合为 AND_MASK & ~OR_MASK,即 0x7ffdb9ff6e8ffb00,置位数为 44。下面的模型会验证这一纯位运算结论。

用两个原型验证编码链

memcpy 原型同时覆盖普通指针、被指向类型的 const、无符号整数和指针返回值:

memcpy prototypec
void *memcpy(void *dest, const void *src, size_t count);

它的前端输入长 41 字节。下方字节来自随后脚本的计算;这是序列化模型的输出,不是可执行文件中的一段机器码。

memcpy 原型的 41 字节前端输入
0000000003000000F597783E5B4A60B01780B8C0
000000105B1BD0D82314B4BA91C7F66A00010000
0000002000F597783E5B4A60B0
  1. parameter_count0x00–0x03
  2. H(void*)0x04–0x0B
  3. H(const void*)0x0C–0x13
  4. H(size_t)0x14–0x1B
  5. is_variadic0x1C
  6. calling_convention0x1D–0x20
  7. H(return_type)0x21–0x28

脚本仅实现已明确覆盖的类型组合,不解析 C 源码,不处理全部特殊数组、标签作用域或 C++ 语义。它另用 float(float, float) 校验第二个独立原型,避免只凭一组最终数值下结论。

xfg_prototype_model.pypython
import hashlib, struct

def h8(data):
    return hashlib.sha256(data).digest()[:8]

def primitive(code, qualifiers=0):
    return h8(bytes([qualifiers, 1, code]))

def pointer(referenced_hash, qualifiers=0):
    return h8(bytes([qualifiers, 3]) + referenced_hash + b"\x02")

def function_bytes(params, result, variadic=0, convention=1):
    return (struct.pack("<I", len(params)) + b"".join(params)
            + bytes([variadic]) + struct.pack("<I", convention & 0x0f)
            + result)

def callsite_hash(payload):
    front = int.from_bytes(h8(payload), "little")
    return (front & 0xFFFDBFFF7EDFFB70) | 0x8000060010500070

void_ptr = pointer(primitive(0x0e))
const_void_ptr = pointer(primitive(0x0e, qualifiers=1))
size_t_hash = primitive(0x88)
payload = function_bytes([void_ptr, const_void_ptr, size_t_hash], void_ptr)
front = int.from_bytes(h8(payload), "little")
callsite = callsite_hash(payload)
entry = callsite | 1
assert len(payload) == 41
assert front == 0x1da7d393d6b63a72
assert callsite == 0x9da5979356d63a70
f32 = primitive(0x0b)
assert callsite_hash(function_bytes([f32, f32], f32)) == 0x99743f3270d52870
assert void_ptr != const_void_ptr
free_mask = 0xFFFDBFFF7EDFFB70 & ~0x8000060010500070
assert free_mask.bit_count() == 44
print(f"serialized bytes: {len(payload)}")
print(f"frontend: 0x{front:016x}")
print(f"callsite: 0x{callsite:016x}")
print(f"entry:    0x{entry:016x}")
print("PASS: memcpy; float(float,float); pointee const; 44 variable mask bits")
序列化模型的实际输出
$ python xfg_prototype_model.py
serialized bytes: 41
frontend: 0x1da7d393d6b63a72
callsite: 0x9da5979356d63a70
entry:    0x9da5979356d63a71
PASS: memcpy; float(float,float); pointee const; 44 variable mask bits

计算出的 memcpy 调用点值为 0x9da5979356d63a70,与案例中的 R10 常量一致;float 原型也得到 0x99743f3270d52870。这校验了所用模型的字节顺序、类型递归、掩码和标记位关系。执行的是上述离线计算,没有运行该历史 Visual Studio 预览工具链,因而不把结果描述为一次新的编译器回归测试。

从最早的分歧定位错误

不匹配时的检查顺序5 步
  1. 1

    锁定编译器与语言

    记录 c1.dll、c2.dll 版本及 x64 C 输入,先排除把其他版本或 C++ 行为混入模型。

  2. 2

    检查归一化后的原型

    确认数组、函数参数退化,区分顶层限定符和被指向类型的限定符。

  3. 3

    比较每个类型摘要

    单独打印参数和返回类型的 8 字节结果,再检查参数顺序、字段宽度与调用约定。

  4. 4

    比较两个编码阶段

    先比较前端摘要,再施加后端掩码,最后处理入口标记;不要用最终常量反向猜所有步骤。

  5. 5

    核对真实调用路径

    确认二进制保留间接调用并进入预期调度器,而不是被内联、去虚化或折叠为直接调用。

XFG 类型标识的关键不在 SHA-256 本身,而在哈希前保留了哪些类型信息。输入归一化定义了被比较的等价类;截断、掩码和运行时标记则决定这些信息怎样进入实际检查。

参考资料

编译器类型哈希相关研究:Francisco Falcon。下列官方资料用于 CFG 与调用约定背景,不作为预览版内部编码的稳定性保证。

NORMAL~/posts/binary/msvc-xfg-type-hash-encoding.md§--
0%zh-CN