文章目錄
- 不帶正負號的整數的壓縮演算法
- 不帶正負號的整數的解壓縮演算法
- 帶正負號的整數的壓縮演算法
- 帶正負號的整數的解壓縮演算法
- ILASM/ILDASM
- Mono Cecil
- CCI Metadata
- 其他尚未研究的實現
- 《Expert .NET 2.0 IL Assembler》
- 《ECMA-335——Common Language Infrastructure (CLI) 4th Edition》
.NET/CLI中繼資料中使用的壓縮整數
本文地址:http://www.cnblogs.com/AndersLiu/archive/2010/02/09/compressed-integer-in-metadata.html
作者:Anders Liu
摘要:.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] |
1 |
80h |
0BBBBBBBB |
| [00000080h, 00003FFFh] |
2 |
C0h |
10BBBBBB BBBBBBBB |
| [00004000h, 1FFFFFFFh] |
4 |
E0h |
110BBBBB 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 > 8) | 0x80); bytes[1] = (byte)(data & 0x00FF); return bytes; } else if (data > 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) 帶正負號的整數的壓縮與解壓帶正負號的整數的壓縮演算法
帶正負號的整數的壓縮與解壓略微複雜一些,因為需要處理符號位。簡單來說,需要在確定好所需的儲存位元組數之後,將原整數整體向左移1位,然後將符號位放置在最低位上(0表示正數,1表示負數),最後按照同不帶正負號的整數一樣的方式為首位元組設定掩碼。
在為帶正負號的整數確定需要用多少個位元組來存放壓縮值時,需要首先取得原整數的“准絕對值”,即對負數進行按位取反(而不是數學求負),然後將這個“准絕對值”左移1位(為符號位空出最低位),再按照表1列出的區段取得最終佔用的位元組數。
或者,可以省略左移1位的操作,而是按照表2中列出的區段進行尋找。
表2 - 帶正負號的整數“准絕對值”的區段劃分
| 區段 |
位元組數 |
有效位元遮罩 |
| [00000000h, 0000003Fh] |
1 |
0000003Fh |
| [00000040h, 00001FFFh] |
2 |
00001FFFh |
| [00002000h, 0FFFFFFFh] |
4 |
0FFFFFFFh |
在表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 > 8) | 0x80); bytes[1] = (byte)(uv & 0x00FF); return bytes; } else if (u > 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)。並按照本文“帶正負號的整數的壓縮演算法”一節的描述,提供帶正負號的整數的壓縮演算法。
(完)