寫這篇文章的起因是這么一個問題:我們在使用和安裝Windows程序時,有時會看到以“2052”、“1033”這些數字為名的文件夾,這些數字似乎和字符集有關,但它們究竟是什么意思呢?
研究這個問題的同時,又會遇到其它問題。我們會談到Windows的內部架構、Win32 API的A/W函數、Locale、ANSI代碼頁、與字符編碼有關的編譯參數、MBCS和Unicode程序、資源和亂碼等,一起經歷這段瑣碎細節為主,間或樂趣點綴的旅程。
0 Where is Win32 API
Windows程序有用戶態和核心態的說法。在32位地址空間中,0x80000000以下屬于用戶態,0x80000000以上屬于核心態。所有硬件管理都在核心態。用戶態程序的不能直接使用核心態的任何代碼。所謂核心態其實只是CPU的一種保護模式。在x86 CPU上,用戶態處于ring 3,核心態處于ring 0。
從用戶態進入核心態的最常用的方法是在寄存器eax填一個功能碼,然后執行int 2e。這有點像DOS時代的DOS和BIOS系統調用。在NT架構中這種機制被稱作system service。
在核心態提供system service的有兩個家伙:ntoskrnl.exe和win32k.sys。ntoskrnl.exe是Windows的大腦,它的上層被稱為Executive,下層被稱作Kernel。Win32k.sys提供與顯示有關的system service。
在用戶態一側,有一個重要的角色叫作ntdll.dll,大多數system service都是它調用的。它封裝這些system service,然后提供一個API接口。這個接口被稱作native API。 native API的用戶是各個子系統(subsystem),包括Win32子系統、OS/2子系統、POSIX子系統。各個子系統為Win32、OS2、POSIX程序提供了運行平臺。
ntdll.dll由于提供了平臺無關的API接口,所以被看作是NT系統的原生接口,由之得到了“native API”的匪號。其實它的主要工作是將調用傳遞到核心態。
Win32、OS/2、POSIX,聽起來很龐大。其實真正做好的只有Win32子系統。OS2、POSIX都是Console UI,即只有字符界面。提供OS/2子系統,只因為在1988年,NT的主要設計目標就是與OS/2兼容,后來由于Windows 3.0賣得很好,所以設計目標被變更為與Windows兼容。提供POSIX子系統,是為了應付美國政府的一個編號為FIPS 151-2的標準。
Win32子系統的管理員是一個叫作csrss.exe的弟兄,它的全名是:Client/Server Run-Time Subsystem。它剛上任時,本來要分管所有的子系統,但后來POSIX和OS/2都被分別處理了,所以只管了一個Win32。即使這樣也很了不起,所有的Win32程序的進程、線程們都要向它登記。
不過Win32程序用得最多的還是Win32子系統的DLL們,最核心的DLL包括:kernel32.dll、User32.dll、Gdi32.dll、Advapi32.dll。這些DLL包裝了ntdll.dll的native API。其中Gdi32.dll比較特殊,它與核心態的win32k.sys直接保持聯系,以提高NT系統的圖形處理能力。Win32子系統的DLL們提供的接口函數在MSDN文檔中被詳細介紹,它們就是Win32 API。
附錄0 Windows的啟動
計算機上電后,從BIOS的ROM開始運行。BIOS在做一些初始化后會將硬盤的第一個扇區的數據讀入內存,然后將控制權交給它,這段數據被稱作Master Boot Record(MBR)。
MBR包含一段啟動代碼和硬盤的主分區表。這段啟動代碼掃描主分區表,找到第一個可以啟動的分區,然后將這個分區的第一個扇區讀入內存并運行。這個扇區被稱作引導扇區(boot sector)。
引導扇區的代碼具備讀文件系統根目錄的能力,顯然不同的文件系統需要不同的代碼。引導扇區會從根目錄中讀出一個叫作ntldr的文件。顧名思義,這個文件是load NT的主要角色。它的業績主要包括將CPU從實模式轉入保護模式,啟動分頁機制,處理boot.ini等。
如果boot.ini中有一句:
C:\bootsect.rh="Red Hat Linux"
bootsect.rh的內容是Linux引導扇區,用戶又選擇了“Red Hat Linux”,ntldr就會將執行Linux的引導扇區,開始Linux的引導。如果用戶選擇繼續使用Windows,ntldr會裝載并運行我們前面提到的ntoskrnl.exe。
ntoskrnl.exe會啟動會話管理器smss.exe。smss.exe啟動csrss.exe和winlogon.exe。smss.exe會永遠等待csrss.exe和winlogon.exe返回。如果兩者之一異常中止,就會導致系統崩潰。所以病毒們經常以打擊csrss.exe為樂。
winlogon.exe負責用戶登錄,在完成登錄后,它會啟動注冊表HKLM\SOFTWARE\Microsoft\Windows NT\Current Version\Winlogon項下Userinit值指定的程序。該值的缺省數據是userinit.exe。userinit.exe會裝載個人設置,讓硬盤響個不停,并考驗我們的耐性,最后啟動注冊表同一項下Shell值指定的程序。該值的缺省數據是Explorer.exe。Explorer.exe運行后,我們就會看到熟悉的開始菜單和桌面。
要了解Win32子系統的DLL們提供了哪些API,最直接的方法就是用Win32dsm直接查看DLL們的導出表。這時我們會發現Win32 API中帶字符串的API一般都有兩個版本,例如CreateFileA和CreateFileW。當然也有例外,例如GetProcAddress函數。
A代表ANSI代碼頁,W是寬字符,即Unicode字符。Windows中的Unicode字符一般指UCS2的UTF16-LE編碼。讓我們通過幾個實例觀察A/W版本間的關系。
例1:用WIn32dsm查看gdi32.dll的匯編代碼,可以看到TextOutA調用GdiGetCodePage獲取當前代碼頁,再調用MultiByteToWideChar轉換輸入的字符串,然后調用一個內部函數。而TextOutW直接調用這個內部函數。
例2:用調試器跟蹤一個使用了CreateFileA的程序,可以看到:CreateFileA在將輸入字符串轉換為Unicode后,會調用CreateFileW。假設輸入文件名是“測試.txt”,對應的數據就是:“B2 E2 CA D4 2E 74 78 74 00”。在調試器中可以看到傳給CreateFileW的文件名數據是:“4B 6D D5 8B 2E 00 74 00 78 00 74 00 00 00”。 這是"測試.txt"對應的Unicdoe字符串。CreateFileW會接著調用ntdll.dll中的NtCreateFile。順便看看NtCreateFile的代碼:
mov eax, 00000020
lea edx, dword ptr [esp+04]
int 2E
ret 002C
可見這個native API只是簡單地調用了核心態提供的0x20號system service。
例3:gdi32.dll中的GetGlyphOutline函數可以獲取指定字符的字模。GetGlyphOutlineA和GetGlyphOutlineW函數都會調用同一個內部函數(記作F)。函數F在返回前將通過int 2E調用0x10B1號system service。
GetGlyphOutlineW直接調用函數F。GetGlyphOutlineA在調用函數F前,要依次調用GdiGetCodePage、IsDBCSLeadByteEx和MultiByteToWideChar,將當前代碼頁的字符編碼轉換成Unicode編碼。
如果我們調用GetGlyphOutlineA時傳入“baba”,這是“漢”字的GBK編碼,用調試器可以看到傳給函數F的字符編碼是“6c49”,這是“漢”字的Unicode編碼。
從以上例子可見,A版本總會在某處將輸入的字符串轉換為Unicode字符串,然后和W版本執行相同的代碼。在由A/W版本API引出MBCS程序和Unicode程序前,讓我們先解釋一下Locale和ANSI代碼頁。
2 Locale和ANSI代碼頁
2.1 Locale和LCID
Locale是指特定于某個國家或地區的一組設定,包括字符集,數字、貨幣、時間和日期的格式等。在Windows中,每個Locale可以用一個32位數字表示,記作LCID。在winnt.h中可以看到LCID的組成。它的高16位表示字符的排序方法,一般為0。在它的低16位中,低10位是primary language的ID,高4位指定sublanguage。sublanguage被用來區分同一種語言的不同編碼。下面是部分primary language和sublanguage的常數定義:
#define LANG_CHINESE 0x04
#define LANG_ENGLISH 0x09
#define LANG_FRENCH 0x0c
#define LANG_GERMAN 0x07
#define SUBLANG_CHINESE_TRADITIONAL 0x01 // Chinese (Taiwan Region)
#define SUBLANG_CHINESE_SIMPLIFIED 0x02 // Chinese (PR China)
#define SUBLANG_ENGLISH_US 0x01 // English (USA)
#define SUBLANG_ENGLISH_UK 0x02 // English (UK)
好,現在我們可以計算簡體中文的LCID了,將sublanguage的常數左移10位,即乘上1024,再加上primary language的常數:2*1024+4=2052,16進制是0804。美國英語是:1*1024+9=1033,16進制是0409。。繁體中文是1*1024+4=1028,16進制是0404。
2.2 代碼頁
每個Locale都聯系著很多信息,可以通過GetLocalInfo函數讀取。其中最重要的信息就是字符集了,即Locale對應的語言文字的編碼。Windows將字符集稱作代碼頁。
每個Locale可以對應一個ANSI代碼頁和一個OEM代碼頁。Win32 API使用ANSI代碼頁,底層設備使用OEM代碼頁,兩者可以相互映射。
例如English (US)的ANSI和OEM代碼頁分別為“1252 (ANSI - Latin I)”和“437 (OEM - United States)”。 Chinese (PRC)的ANSI和OEM代碼頁都是“950 (ANSI/OEM - Traditional Chinese Big5)”。 Chinese (TW)的ANSI和OEM代碼頁都是“936 (ANSI/OEM - Simplified Chinese GBK)”。
附錄1中有一張很長的表。列出了我正在使用的Windows所支持的135個Locale的部分信息,包括 LCID、國家/地區名稱、語言名稱、語言縮寫和對應的ANSI代碼頁。
2.3 系統Locale、用戶Locale,再談ANSI代碼頁
在Windows中,通過控制面板可以為系統和用戶分別設置Locale。系統Locale決定代碼頁,用戶Locale決定數字、貨幣、時間和日期的格式。這不是一個好的設計,后面會談到它帶來的問題。
使用GetSystemDefaultLCID函數和GetUserDefaultLCID函數分別得到系統和用戶的LCID。有很多材料將這兩個函數和另外兩個函數混淆:GetSystemDefaultUILanguage和GetUserDefaultUILanguage。
GetSystemDefaultUILanguage和GetUserDefaultUILanguage得到的是您當前使用的Windows版本所帶的UI資源的語言。
用戶程序缺省使用的代碼頁是當前系統Locale的ANSI代碼頁,可以稱作ANSI編碼,也就是A版本的Win32 API默認的字符編碼。對于一個未指定編碼方式的文本文件,Windows會按照ANSI編碼解釋。
2.4 AppLocale
如果一個文本文件采用BIG5編碼,系統當前的ANSI代碼頁是GBK。打開這個文件,就會顯示亂碼。例如“中文”在BIG5中的編碼是A4A4、A4E5,這兩個編碼在GBK中對應的字符是“いゅ”。這是日文的兩個平假名。
在Windows XP平臺有一個AppLocale程序,可以以指定的語言運行非Unicode程序。用Win32dsm打開看一看,其實它只是在運行程序前設置了兩個環境變量。我們可以用個批處理文件模仿一下:
@ECHO OFF
SET __COMPAT_LAYER=#ApplicationLocale
SET ApplocaleID=0404
start notepad.exe
在簡體中文平臺,用這個批處理文件啟動的記事本可以正確顯示BIG5編碼的文本文件。用它打開GBK編碼的文本文件會怎么樣?“中文”會被顯示為“笢恅”。設置這兩個環境變量會作用于當前進程和其子進程。Windows 2000平臺不支持這個方法。
3 MBCS程序和Unicode程序
3.1 與字符編碼有關的編譯參數
讓我們回到Win32 API。我們在程序中使用的Win32 API沒有A/W后綴,Windows的頭文件會根據編譯參數UNICODE將沒有后綴的函數名替換為A版本或W版本,例如:
#ifdef UNICODE
#define CreateFile CreateFileW
#else
#define CreateFile CreateFileA
#endif
C RunTime庫(CRT)使用_UNICODE和_MBCS來區分三套字符串處理函數,分別用于SBCS、MBCS和Unicdoe字符串。SBCS和MBCS分別指單字節字符串和多字節字符串。例如_tcsclen的3個版本分別為strlen、_mbslen和wcslen ,猜猜以下函數返回幾?
strlen("VOIP網關");
_mbslen((unsigned char *)"VOIP網關");
wcslen(L"VOIP網關");
答案是8、6、6。L"ANSI字符串"通知編譯器將ANSI字符串轉換為Unicode字符串,這是VC++編譯器提供的一個小甜點。不過我們應該用宏:_T("ANSI字符串")。_T宏只在我們定義了_UNICODE時才轉換。這樣同一套代碼既可以編譯MBCS版本,也可以編譯Unicode版本。
MFC用_UNICODE參數區分Unicode版本特有的代碼,決定使用什么版本的導入庫或靜態庫。
3.2 Unicode程序、MBCS程序和多語言支持
Unicode程序直接使用Unicode版本的CRT和Win32 API。Unicode程序的運行與當前的ANSI代碼頁沒有關系。MBCS程序的運行依賴于ANSI代碼頁。如果設計者和使用者使用不同的代碼頁,就可能出現亂碼。微軟開發的程序大都是Unicode程序,不管我們怎樣變換系統Locale,它們總能正常運行。
使用VCL類庫的Delphi程序都是MBCS程序。VCL框架在程序啟動會調用GetThreadLocale獲取當前用戶的LCID,然后在當前目錄查找對應的資源文件,命名規則是:程序名+'.'+語言縮寫,語言縮寫可以參見附錄1。在找不到時才會使用EXE文件中的資源。不過如果系統LCID是English(United States),用戶LCID是Chinese(PRC),由VCL產生的程序就會出現亂碼。讀者可以自己分析原因。
為VCL程序做多語言版本。只要用Delphi自帶的Resource DLL Wizard再做一個特定語言的資源DLL,原來的程序都不用改。不過很多程序員用其它組件做多語言版本,例如TsiLang 。
MBCS程序雖然也可以做成多語言版本,但它無法在同時顯示不同代碼頁特有的字符,這時就必須使用Unicode程序了。
VS.NET文檔中有個多語言資源的例子:SatDLL。它只用Win32 API的例子,卻用了VC7項目。我在學習時將它改成了VC6項目,并糾正了它的兩個問題:
1、用GetUserDefaultUILanguage讀到的是Windows資源版本,不是當前用戶設置的代碼頁。
2、啟動時沒有使用資源DLL里的菜單。
在我的個人主頁(http://fmddlmyy.home4u.china.com)上可以下載修改過的SatDLL。這個程序說明了支持多語言資源的基本思路:將不同語言資源放到不同的DLL中,在程序啟動時根據當前Locale裝載對應的資源DLL。必要時動態切換資源。為了標記不同語言的資源,可以將它們放到不同的目錄中,以LCID作為目錄名,例如“2052”、“1033”。當然我們也可以用其它方法聯系LCID和資源DLL。
MFC程序可以在App類的InitInstance函數中用AfxSetResourceHandle函數設置資源DLL。在Delphi中動態切換資源可以參考Delphi Demo目錄RichEdit項目的ReInit.pas。在讀取當前設定時,建議用GetSystemDefaultLCID函數,因為系統Locale決定ANSI代碼頁。
3.4 資源和亂碼
通過檢查可執行文件,我們可以確定VC和Delphi的資源編譯器都以Unicode保存字符資源。在VC環境編輯資源時,我們會指定資源的代碼頁。編譯器根據資源的代碼頁,將其轉換到Unicode。
Unicode程序直接使用以Unicode編碼保存的資源。MBCS程序需要將Unicode資源先轉換回當前ANSI代碼頁,然后再使用。如果資源中的Unicode字符串不能映射到當前代碼頁中的字符,就會出現??。
例如Windows的標準對話框也會出現亂碼。假設我們使用簡體中文Windows,當前Locale是Chinese (TW),我們的程序是MBCS的,使用標準的打開文件對話框。因為在BIG5中沒有“開”這個字,所以“打開”會被顯示成“打?”。將程序編譯成Unicode版本,就可以避免這個問題。
如果字符不是保存在資源中,而是硬編碼在程序中。然后開發者和用戶使用不同的代碼頁,就會導致亂碼。假設開發者的Locale是Chinese (PRC),用戶的Locale是English (US),程序中硬編碼了字符串“文件”。 Chinese (PRC)的ANSI代碼頁是GBK,“文件”的編碼“CE C4 BC FE”。English (US)的ANSI代碼頁是Latin I,用戶按照Latin I編碼去解釋“CE C4 BC FE”,就會看到“???t”。
回答我前面提過的一個問題:Delphi程序根據用戶LCID轉換資源中的字符串。如果用戶LCID是Chinese (PRC),系統LCID是English (US)。那么資源中的Unicode字符串會被轉換為GBK編碼,然后按照Latin I顯示,這時我們看到的就是類似“???t”的東東,不是??。
既然資源是以Unicode保存的,MBCS程序如果不將其轉換到ANSI代碼頁,而用W版本的函數直接顯示,就不會產生亂碼。例如MFC程序菜單里的中文,在English (US)的Locale也可以正常顯示。不過這取決于各部分代碼的具體實現,menu bar控件里的中文在English (US)的Locale會全部顯示成??。
進一步的參考資料
本文的第0節和附錄0主要參考了《Inside Windows 2000 Third Edition》,國內出過該書的影印版。DDK文檔中有大量Windows內核的信息。用Win32dsm和各種調試器查看Windows系統文件可以獲得更直接的信息。
關于Window程序的字符編碼,最好的參考資料是winnt.h等SDK的包含文件、VCL、MFC、CRT的源文件。我們不需要閱讀它們,只要找到自己感興趣的信息就可以了,用Source Insight可能方便一些。
本文所談的不是什么萬古不遷的道理,只是別的程序員的一些設定,我們因為需要使用他們的程序,所以有必要了解一些細節。研究問題的方法和興趣永遠比問題本身重要,如一句拉丁俗語所說:res, non verba,實質勝于文字。