.NET/CLI元数据中使用的压缩整数

.NET/CLI元数据中使用的压缩整数

摘要:.NET/CLI的PE文件中广泛采用了一种整数压缩算法,这种算法可以将一个32位整数根据其大小的不同放置在1、2或4个字节中。当整数的值比较小时,这种算法能够有效地减少PE文件的大小。本文介绍了这种压缩算法,并给出了压缩/解压缩的参考实现。

参考文献

  • 《ECMA-335——Common Language Infrastructure (CLI) 4th Edition》,June 2006
  • 《Expert .NET 2.0 IL Assembler》,Serge Lidin,Apress,2006
  • 《.NET探秘:MSIL权威指南》(《Expert .NET 2.0 IL Assembler》中文版),Serge Lidin著,包建强 译,人民邮电出版社,2009

简介

简单来说,整数压缩算法就是将一个32位整数(通常占用4个字节)放置到尽可能少的存储空间中(1、2或4个字节)的方法。

整数压缩算法广泛地应用在.NET/CLI PE文件中,如各种元数据签名、#Blob和#US流等。在这些地方,需要使用整数值来记录条目的数量或是数据块的大小等。如果单纯地采用32位整数,由于绝大多数数量值或大小值都不大,会造成大量字节都被置为无意义的0值。在这些场景中使用压缩算法,可以有效地节省PE文件占用的磁盘空间或网络带宽。

以下是PE文件中一些使用到压缩整数的场景:

  • Blob堆(#Blob流和#US流所采用的存储格式)中的每个条目开始处,使用压缩的无符号整数表示条目的大小;
  • 方法的元数据签名中,使用压缩的无符号整数存储参数的数量;
  • 元数据签名中的数组下标,采用压缩的带符号整数进行存储。

注意,本文所介绍的压缩与解压算法,都是针对32位整数的。此外,在本文的介绍中,如果没有特殊提及,则所出现的整数都按照大尾数法表示(最高权重字节放在左侧或上方)。

无符号整数的压缩与解压

无符号整数的压缩算法

无符号整数的压缩是比较简单的,即将无符号整数的整个取值范围划分为几个区段,而整数值根据其所在的区段不同,放置在1、2或4个字节中。表1列出了无符号整数的区段划分和压缩方式。

表1 - 无符号整数的区段划分
区段字节数掩码二进制形式
[00000000h, 0000007Fh]180h0BBBBBBBB
[00000080h, 00003FFFh]2C0h10BBBBBB BBBBBBBB
[00004000h, 1FFFFFFFh]4E0h110BBBBB BBBBBBBB BBBBBBBB BBBBBBBB

在表1中:

  • “区段”列出了每个区段的最小值(含)和最大值(含)。
  • “字节数”列出了压缩后的值占用的字节数。
  • “掩码”列出了在压缩后的值上施加的掩码,
    • 如果压缩后的整数值占用1字节,则与掩码80h进行&(按位与)操作后的结果为0h,
    • 如果压缩后的整数值占用2字节,则其首字节与掩码C0h进行&操作后的结果是80h,
    • 如果压缩后的整数值占用4字节,则其首字节与掩码E0h进行&操作后的结果是C0h。
  • “二进制形式”列出了压缩结果的二进制形式,其中的“1”和“0”都是固定值,而“B”则表示实际整数值的有效位。

从表1可以清晰地看出,无符号整数压缩算法的适用范围是[0h, 1FFFFFFFh]([0, 536870911])之内的无符号整数,大于1FFFFFFFh的无符号整数不能用这种方式进行压缩。

代码1给出了无符号整数压缩算法的参考实现。

代码1 - 无符号整数压缩算法的参考实现

public static byte[] CompressUInt(uint data)
{
  if (data <= 0x7F)
  {
    var bytes = new byte[1];
    bytes[0] = (byte)data;
    return bytes;
  }
  else if (data <= 0x3FFF)
  {
    var bytes = new byte[2];
    bytes[0] = (byte)(((data & 0xFF00) >> 8) | 0x80);
    bytes[1] = (byte)(data & 0x00FF);
    return bytes;
  }
  else if (data <= 0x1FFFFFFF)
  {
    var bytes = new byte[4];
    bytes[0] = (byte)(((data & 0xFF000000) >> 24) | 0xC0);
    bytes[1] = (byte)((data & 0x00FF0000) >> 16);
    bytes[2] = (byte)((data & 0x0000FF00) >> 8);
    bytes[3] = (byte)(data & 0x000000FF);
    return bytes;
  }
  else
    throw new NotSupportedException();
}

无符号整数的解压缩算法

无符号整数的解压缩算法也非常简单,如下所示:

  • 如果首字节的二进制形式型如0bbbbbbb(与80h进行按位与运算,结果为0h),则采用1个字节存放整数值(字节值为b0),原整数值=b0。
  • 如果首字节的二进制形式型如10bbbbbb(与C0h进行按位与运算,结果为80h),则采用2个字节存放整数值(字节值依次为b0,b1),原整数值=(b0 & 0x3F) << 8 | b1。
  •  如果首字节的二进制形式型如110bbbbb(与E0h进行按位与运算,结果为C0h),则采用4个字节存放整数值(字节值依次为b0,b1,b2,b3),原整数值=(b0 & 0x1F) << 24 | b1 << 16 | b2 << 8 | b3。.

代码2给出了无符号整数解压缩算法的参考实现。

代码2 – 无符号整数解压缩算法的参考实现

public static uint DecompressUInt(byte[] data)
{
  if (data == null)
    throw new ArgumentNullException("data");

  if ((data[0] & 0x80) == 0
    && data.Length == 1)
  {
    return (uint)data[0];
  }
  else if ((data[0] & 0xC0) == 0x80
    && data.Length == 2)
  {
    return (uint)((data[0] & 0x3F) << 8 | data[1]);
  }
  else if ((data[0] & 0xE0) == 0xC0
    && data.Length == 4)
  {
    return (uint)((data[0] & 0x1F) << 24
      | data[1] << 16 | data[2] << 8 | data[3]);
  }
  else
    throw new NotSupportedException();
}

带符号整数的压缩与解压

带符号整数的压缩算法

带符号整数的压缩与解压略微复杂一些,因为需要处理符号位。简单来说,需要在确定好所需的存储字节数之后,将原整数整体向左移1位,然后将符号位放置在最低位上(0表示正数,1表示负数),最后按照同无符号整数一样的方式为首字节设置掩码。

在为带符号整数确定需要用多少个字节来存放压缩值时,需要首先取得原整数的“准绝对值”,即对负数进行按位取反(而不是数学求负),然后将这个“准绝对值”左移1位(为符号位空出最低位),再按照表1列出的区段取得最终占用的字节数。

或者,可以省略左移1位的操作,而是按照表2中列出的区段进行查找。

表2 - 带符号整数“准绝对值”的区段划分
区段字节数有效位掩码
[00000000h, 0000003Fh]10000003Fh
[00000040h, 00001FFFh]200001FFFh
[00002000h, 0FFFFFFFh]40FFFFFFFh

在表2中:

  • “区段”列出的是根据原整数“准绝对值”划分出的每个区段的最小值(含)和最大值(含)。
  • “字节数”列出了压缩后的值占用的字节数。
  • “有效位掩码”列出的掩码在与原整数进行&操作之后,可以取得原整数中真正有意义的位数。这建立在这样一个事实上——对于正整数来说,其最左侧的一些位都是0,是没有意义的,可以省略;而对于负整数来说,其最左侧的一些位都是1,也是没有意义的,可以省略。

在与有效位掩码进行&操作取得有效位之后,需要将这些有效位整体左移1位。接下来,如果原整数是负数,则需要将最低位(符号位)置1。

最后,为压缩值的首字节设置掩码,规则与无符号整数一样。

带符号整数压缩算法的适用范围为——对于正数为[0h, 0FFFFFFFh]([0, 268435455]),对于负数为[F0000000h, FFFFFFFFh]([-268435456, -1]),在此范围之外的整数不能用这种方式进行压缩。

代码3给出了带符号整数压缩算法的参考实现。

代码3 -带符号整数压缩算法的参考实现

public static byte[] CompressInt(int data)
{
    var u = data >= 0 ? (uint)data : ~(uint)data;
    if (u <= 0x3F)
    {
        var uv = ((uint)data & 0x0000003F) << 1;
        if (data < 0)
            uv |= 0x01;

        var bytes = new byte[1];
        bytes[0] = (byte)uv;
        return bytes;
    }
    else if (u <= 0x1FFF)
    {
        var uv = ((uint)data & 0x00001FFF) << 1;
        if (data < 0)
            uv |= 0x01;

        var bytes = new byte[2];
        bytes[0] = (byte)(((uv & 0xFF00) >> 8) | 0x80);
        bytes[1] = (byte)(uv & 0x00FF);
        return bytes;
    }
    else if (u <= 0x0FFFFFFF)
    {
        var uv = ((uint)data & 0x0FFFFFFF) << 1;
        if (data < 0)
            uv |= 0x01;

        var bytes = new byte[4];
        bytes[0] = (byte)(((uv & 0xFF000000) >> 24) | 0xC0);
        bytes[1] = (byte)((uv & 0x00FF0000) >> 16);
        bytes[2] = (byte)((uv & 0x0000FF00) >> 8);
        bytes[3] = (byte)(uv & 0x000000FF);
        return bytes;
    }
    else
        throw new NotSupportedException();
}

注意,只有在确定压缩值占用的字节数时用到了原整数的“准绝对值”,一旦字节数确定之后,实际进行压缩时,使用的还是原整数,只不过将其当做无符号整数对待。

带符号整数的解压缩算法

由于带符号整数的压缩值与无符号整数的压缩值具有相同的结构,所以带符号整数的解压缩算法可以建立在无符号整数的解压缩算法基础之上。

首先,按照无符号整数的解压缩算法对压缩值进行解压缩,得到一个32位无符号整数,根据最低位(符号位)确定原整数的符号。

如果原整数为正数(最低位,即符号位为0),则将解压得到的无符号整数右移1位,再强制转换为带符号整数,即可得到原整数值。

如果原整数为负数(最低位,即符号位为1),则需要将解压得到的无符号整数右移1位,再将负数最左侧那些没有意义的“1”位恢复回来:

  • 如果压缩值占用了1字节,则与FFFFFFC0h进行|(按位或)操作;
  • 如果压缩值占用了2字节,则与FFFFE000h进行|操作;
  • 如果压缩值占用了4字节,则与F0000000h进行|操作。

最后,将这个无符号整数强制转换为带符号整数,即可得到原整数值。

代码4给出了带符号整数解压缩算法的参考实现。

代码4 - 带符号整数解压缩算法的参考实现

public static int DecompressInt(byte[] data)
{
    var u = DecompressUInt(data);

    if ((u & 0x00000001) == 0)
        return (int)(u >> 1);

    var nb = GetCompressedIntSize(data[0]);
    uint sm;
    switch (nb)
    {
        case 1: sm = 0xFFFFFFC0; break;
        case 2: sm = 0xFFFFE000; break;
        case 4: sm = 0xF0000000; break;
        default: throw new NotSupportedException();
    }

    return (int)((u >> 1) | sm);
}

这里调用了一个工具方法GetCompressedIntSize,用于根据压缩值的第一个字节判断采用几个字节存放该压缩值。该方法非常简单,如代码5所示。

代码5 – 根据压缩值的第一个字节判断所需字节数

public static uint GetCompressedIntSize(byte firstByte)
{
  if ((firstByte & 0x80) == 0)
    return 1;
  else if ((firstByte & 0xC0) == 0x80)
    return 2;
  else if ((firstByte & 0xE0) == 0xC0)
    return 4;
  else
      throw new NotSupportedException();
}

各种实现中的问题

压缩的带符号整数在.NET/CLI元数据中的使用场景非常少——据我所知,只有元数据签名中的数组下标值使用了压缩的带符号整数(这意味着原理上.NET/CLI的底层是支持下标为负数的数组的)。而在这方面,几乎所有现有的CLI实现都或多或少的出现了一些问题,同时,我所参考的文献中,关于带符号整数压缩算法的描述也都是含糊不清的。幸运的是,几乎所有高级语言都不允许开发者声明下标为负数的数组,CLS规范也要求数组的下标必须从0开始,所以这些问题并不会对实际项目造成重大影响。

下面列举几个我所研究过的实现中的问题,下一节将列出参考文献中的问题。

ILASM/ILDASM

很显然,微软自己对带符号整数的压缩算法也不是很清晰。ILASM是我所接触过的编译器中唯一能接受负数下标数组的,也是我在研究这个课题时使用最多的编译器。对于正数数组下标,ILASM完全没有问题;但对于负数下标,当下标值在-8192(含)到-8129(含)之间时,得到的压缩值是错误的。

另外,ILASM使用的带符号整数压缩算法实现,很明显与本文介绍的不同,因此并不能涵盖所有理论上支持的整数([-268435456, 268435455]),当下标值小于或等于-268427265时,得到的压缩值也是错误的。

由于ILASM存在错误,所以对ILDASM无法进行完全准确的测验。不过,即便是对ILASM产生的错误值进行解压缩,ILDASM得到的结果和本文中介绍的带符号整数解压缩算法得到的结果都是一致的,所有有理由相信ILDASM在解压缩算法上应该是正确的。但是,错误的压缩值会随机造成ILDASM的崩溃。

以上问题存在于ILASM的2.0、3.0和3.5版本中,但在4.0 Beta版中已经得到改正,.NET Framework SDK 4.0 Beta携带的ILASM能够对所有理论上可接受的负数数组下标进行正确的压缩,而ILDASM也能对其进行正确的解压缩。

Mono Cecil

通过对Mono Cecil源代码的研究发现,Mono Cecil的实现非常忠诚于ECMA-335标准,而ECMA-335对数组下标的描述恰恰是错误的(参见后面“参考文献之修正”一节)——称数组下标值是压缩的无符号整数(而不是带符号整数)。

因此,Mono Cecil只提供了针对无符号整数的压缩和解压缩实现(参见Mono.Cecil.dll中的Mono.Cecil.Metadata.Utilities.WriteCompressedInteger(BinaryWriter, Int32) : Int32方法和Mono.Cecil.Metadata.Utilities.ReadCompressedInteger(Byte[], Int32, Int32&) : Int32方法)。而在写入和读取元数据签名时,也是将数组下标作为无符号整数处理的(参见Mono.Cecil.Signatures.SignatureWriter.Write(SigType) : Void方法和Mono.Cecil.Signatures.SignatureReader.ReadType(Byte[], Int32, Int32&) : SigType方法)。

在使用Mono Cecil库进行反射时,如果数组的下标为正数,则得到的结果是实际下标的2倍(因为缺少了解压缩带符号整数时的右移操作);而如果数组的下表是负数,则得到的结果就是完全错误的了。

我只对Mono Cecil 0.6版本的源代码做了调查,其他版本不详,读者可自行检查、分析。

CCI Metadata

CCI Metadata则确实将数组下标当作带符号整数对待了,但是它使用的压缩算法非常简单——将原整数的绝对值左移1位,再将符号位放置在最低位(参见Microsoft.Cci.PeWriter.dll中的Microsoft.Cci.BinaryWriter.WriteCompressedInt(Int32) : Void方法),然后按照无符号整数进行压缩;而解压缩算法是对应的——先按照无符号整数的解压算法得到一个无符号整数,然后根据最低位确定结果的符号,最后将整个无符号数右移1位,再根据符号位设置正负号(参见Microsoft.Cci.PeReader.dll中的Microsoft.Cci.UtilityDataStructures.MemoryReader.ReadCompressedInt32() : Int32方法)。

CCI Metadata所采用的算法与《Expert .NET 2.0 IL Assembler》一书中提到的算法描述相符,但该书中的描述也是有误的(参见后面“参考文献之修正”一节)。

我所调研的CCI Metadata版本是2.0.49.23471。

其他尚未研究的实现

还有一些.NET/CLI的实现尚未研究,例如:

  • System.Reflection/System.Reflection.Emit
  • Shared Source CLI (Rotor)

参考文献之修正

《Expert .NET 2.0 IL Assembler》

本书在第8章表8-4之后的一个自然段(P150第一段)描述了带符号整数的压缩算法,此处的描述有误,正确的描述请参见本文中“带符号整数的压缩算法”一节。

不幸的是,本书的中文版《.NET探秘:MSIL权威指南》并没有对这个问题进行修正(同样是第8章表8-4之后的一个自然段,P132)。当初包建强在翻译这本书的时候,我也向他提到过这里的问题,不过那时候我还没有完全准确地推断出正确的压缩算法,因此他只好直译。

《ECMA-335——Common Language Infrastructure (CLI) 4th Edition》

在ECMA-335标准中,完全没有区分“压缩的无符号整数”和“压缩的带符号整数”这两个术语,统称之为“compressed integer”。

ECMA-335 Partition II: Metadata Definition and Semantics中的23.2 Blobs and signatures一节中给出了“compressed integer”的压缩算法(P153),这实际上是无符号整数的压缩算法,该算法是正确的。

ECMA-335 Partition II: Metadata Definition and Semantics中的23.2.13 ArrayShape一节中给出了元数据签名中的数组表示方法(P161),其中称Size和LoBound都是“compressed integer”,这是不准确的。

修正方法是,引入术语“compressed unsigned integer”,用于描述其他地方的“compressed integer”;引入术语“compressed signed integer”,用于描述数组下标值(LoBound)。并按照本文“带符号整数的压缩算法”一节的描述,提供带符号整数的压缩算法。

(完)

posted @ 2010-02-09 19:20  Anders Liu  阅读(...)  评论(... 编辑 收藏