首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> Java 程式設計 >> Java 相關知識

程式設計師必知的字元編碼演進史:從 ASCII 到 Unicode

     編輯:Java 相關知識

想搞懂當前字元編碼的混亂局面,最捷徑的方法就是回顧歷史,看看我們是如何一步步走到今天這個地步的。雖然 ASCII 之前的編碼時代對現代開發影響有限,但那段歷史充滿了技術趣味。本文將從技術視角帶你梳理編碼演進的脈絡,重點聚焦於 Windows 與 Unix 這兩大主流作業系統體系(Linux、iOS、Android 等均基於 Unix)。我們將先探討 Unicode 誕生前基於 ASCII 編碼的諸多痛點,再總結跨平台開發的關鍵注意事項。

ASCII:一切的起點

ASCII 碼至關重要,因為它從根本上定義了標點符號的核心子集,這個子集後來被幾乎所有程式語言採用,作業系統也遵循此標準。有個關鍵概念必須釐清:ASCII 碼本質是 7 位元編碼,並不存在所謂「8 位元 ASCII 碼」。若聽聞有人提及 8 位元 ASCII,其實指的是利用 ASCII 可列印字元(碼位 32-126)加上少數控制字元(如 LF、CR、TAB)構成的 8 位元編碼方案。

早期 ASCII 實際上被設計為一個 7 位元編碼家族,部分碼位預留給「國家變體」。不同國家對這些碼位賦予不同含義:英國將井號(#)替換為英鎊符號(£),挪威則用 Æ、Å 取代方括號,用 æ、å 取代大括號。這對通訊無傷大雅,但對程式設計卻是災難——C 語言大量依賴方括號與花括號,結果被迫引入三字符組(如 `??(` 替代 `[`,`??)` 替代 `]`),極大降低了程式碼可讀性。鑑於此,國家變體 ASCII 很快被 8 位元編碼取代。如今提及 ASCII,實指美式 ASCII 變體,它是 C 語言及多數現代程式語言的基石。

基於 ASCII 的 8 位元編碼:擴展與新生

從 7 位元躍升至 8 位元,帶來了額外 128 個碼位,解決了兩大核心問題:

  • 徹底清除國家變體字元,建立 128 個通用字元集,供作業系統與語言設計者自由使用,無需擔心三字符組等替換方案。
  • 支援希臘語、俄語等需要更多字元的語言,突破了國家變體 ASCII 有限字元集的侷限。

Unix、終端與 C1 控制碼

Unix 是基於終端的系統。年輕開發者或許不熟悉「啞終端」:一台擁有字元式螢幕(通常 80×25 字元)與鍵盤的設備,透過序列埠連接 Unix 伺服器。終端決定螢幕顯示何字元,也決定鍵盤輸入發送何字元。通訊協定中混雜著控制序列(移動游標、清空螢幕等),因此無法將所有碼位分配給可列印字元,必須保留控制字元碼位且不得衝突。

控制字元 0-31(C0 區塊)及 ASCII 中的 127 (DEL) 已分配給控制用途;32-126 為可列印字元,承載命令名、目錄分隔符、Shell 轉義字元、程式語言符號等關鍵語義,不可更動。而 128-255 區間對所有人開放:終端可自行定義控制碼或可列印字元,伺服器完全不干涉。只要所有終端在可列印字元(32-126)達成共識,系統即可正常運作。

為了互操作性,慣例建議保留 128-159 作為第二控制區塊(C1 區塊),僅用 160-255 承載可列印字元,這奠定了 Unicode 前 Unix 編碼的基礎。但這非強制規範,Unix 核心對 C1 區塊與其他高位碼位一視同仁。

「編碼無關」的 Unix 哲學

這使得 Unix 與具體語言/編碼解耦。假設系統有兩個檔案,名稱含碼位 `68 196` 與 `68 228`:

  • 希臘終端顯示:「DΔ」與「Dδ」
  • 法文終端顯示:「DÄ」與「Dä」
  • 泰文終端顯示:「Dຜ」與「D໔」

同樣的檔案,三種終端呈現截然不同,但 Unix 照常運作。希臘用戶輸入 `vi DΔ`,泰國用戶輸入 `vi Dຜ`,皆能精準開啟對應檔案。Unix 不假設檔名大小寫規則,不參與內容渲染,僅透傳位元流給終端處理。甚至同一 Unix 系統可同時支援多種編碼:不同語言用戶各自建立子目錄,只要使用匹配編碼的終端訪問,一切井然有序。Unix 本身無需任何「編碼設定」,完全不關心編碼為何物。

唯一需要語言感知的是輸出人類可讀錯誤訊息的命令/服務,以及執行排序、文字處理的工具。這透過會話級環境變數 `LOCALE` 解決,無需系統級全域配置。

DOS/Windows:另一條路徑

DOS(及後續 Windows 95/98/Me)走向截然不同。螢幕由顯示適配器直接定位,無需控制字元。最初的 DOS 編碼(代碼頁 437)將所有 ASCII 控制碼與高 128 碼位全部分配給可列印字元,但保留了 ASCII 32-126,仍屬 ASCII 相容編碼。問題隨之而來:換行符(LF)對檔案換行有用,但直接定位螢幕時毫無作用;與支援 ASCII 控制碼的序列埠印表機通訊時,又需保留控制碼。為兼容性,Windows 95 放棄將 C0 區塊作為可列印字元,但既然不需要終端控制碼,便將 C1 區塊 (128-159) 釋出作為可列印字元(如 Windows-1252)。

不區分大小寫的檔名系統帶來的挑戰

Windows 採用不區分大小寫的檔名,強制要求系統級編碼配置。例如碼位 196 和 228 在 Windows-1253(希臘)中分別代表大寫 Δ 和小寫 δ(同一字母),但在 Windows-874(泰文)中卻是兩個完全無關的字母。這導致跨語言網路環境災難性後果:泰文機器上兩個不同檔名 `Dຜ`、`D໔`,複製到希臘機器上變成同名檔案,互相覆蓋;無法建立單一檔案伺服器服務多國辦公室;無法搭建多語言 Web 伺服器。這正是微軟成為 Unicode 鐵桿支持者、率先支援 Unicode 的根本動力。

8 位元編碼時代的程式設計實踐

在單一編碼端到端一致的前提下,程式設計相當輕鬆:

  • C 編譯器無需關心編碼。本地字元(≥128)僅出現在註釋或字串/字元常量中,註釋被忽略,常量原樣複製到資料段。
  • 正則表達式、`vi`/`sed`/`awk` 等工具無需知曉編碼,皆按位元組處理。挪威開發者匹配大寫字母寫 `/[A-ZÆÅ]/` 而非 `/[A-Z]/`,解析器零修改。
  • 核心假設:端到端編碼一致,工具無需轉換,「輸入什麼輸出什麼」。

若需語言相關處理(排序、大小寫轉換),C 標準庫 `` 提供極小介面:`setlocale()` 定義語言與編碼;`isupper()`/`islower()`/`isalpha()` 判斷字元類型;`toupper()`/`tolower()` 大小寫轉換;`strcoll()`/`strxfrm()` 處理排序。僅此而已。

多位元組編碼:CJK 的必由之路

中、日、韓(CJK)字元數遠超單位元組上限,必須採用多位元組編碼(MBCS)。為兼容 ASCII 系統,這些編碼均為變長編碼:ASCII 字元維持單位元組,非 ASCII 字元常安排在 94×94(雙位元組)或 94×94×94(三位元組)平面,兼顧 7 位元通訊(碼位 36-126)與 8 位元編碼(碼位 161-254)。

Unix 上的 EUC 機制

Unix 採用 EUC(Extended Unix Code):非 ASCII 字元採用多位元組編碼,且僅使用 ≥128 碼位。這意味著無需修改核心系統即可在檔名中使用中/日文。多數解析器、編譯器亦能無縫運作,因多位元組序列同樣僅出現在註釋或字串常量中。

現實中的痛點

  • 字元邊界識別困難:無法寫 `char c = '漢';`(編譯器視為兩字元),`substring`、正則表達式皆受影響。搜尋「詞」在「月月」(四位元組:`183 238 183 238`)中會誤匹配,除非「詞」編碼恰為 `238 183`。
  • 顯示寬度計算缺失:固定寬度終端/列印機中,CJK 字元佔兩個 ASCII 寬度。標準庫無函數計算「前 20 個顯示寬度對應多少位元組」,`mblen()` 僅回傳位元組數,無法得知顯示寬度。

這些問題在 Unicode 普及前長期困擾著 CJK 開發者,也倒逼了統一編碼標準的誕生。

 

如果想要知道當前字符編碼的情況,最簡單的方式就是回顧下歷史,看看我們是如何到達今天這種狀況的。在ASCII碼之前的時代對現今沒有多大的影響,但那是一段有趣的時光,所以如果你有興趣的話,我就從技術的角度帶你簡單回顧下各種編碼的那段歷史。Unicode的故事是從ASCII碼開始的。由於Windows和Unix是現在程序員們只能使用的兩種操作系統,所以我只會對這兩種系統進行講述。(Linux,iOS,Android等等都是基於Unix的)首先,我會介紹下在Unicode之前基於ASCII碼編碼的一些問題,然後說一下跨平台編程的一些編程注意點。如果你不感興趣的話,你可以跳過在產生Unicode編碼之前的內容

ASCII

ASCII碼非常重要,因為它從根本上定義了各種標點符號的一個子集,而這個子集將會被各種編程語言所使用,同時操作系統也會遵循這個標准。有一個問題需要講清楚,ASCII碼是一種7比特的編碼,沒有什麼所謂8比特編碼的ASCII碼。如果你曾經聽過有人講8比特編碼的ASCII碼,他真正的意思可能是使用了ASCII碼的可打印字符(32-126)加上肯能2個或3個控制字符(LF,CR,TAB)的8比特編碼。是個,你可能還不了解,ASCII碼當初實際上是被設計為一個7比特編碼的族,其中的一些字符用來表示這些字符在國別上的其他變體(“national variants”),它們在不同的國家有不同的含義。比如說,在英國的ASCII碼的變體會將井號(#)替換為英鎊標志(£),而在挪威的變體會將方括號替換為Æ與Å,同時會將大括號替換為æ與å。對於通信來說這不是什麼問題,但是對於編程來說很明顯這會有點問題。比如,對於C語言來說,大量使用了方括號與花括號。結果就是它們會被一些三字符組代替,方括號會被??(與??)所代替,花括號會被??<與??>所代替。(更多信息ISO/IEC 646以及三字符組)鑒於這些問題,國別變體的ASCII碼很快就被8比特編碼給替代了。現在,當人們再說起ASCII碼的時候,大家討論的實際上是美帝的ASCII碼變體,它是C語言以及其他大部分現代編程語言的基礎。

基於ASCII碼的8位編碼

從7位編碼轉換為8位編碼為我們提供了額外的128個碼位,這解決了很多問題。最重要的是,它意味著所有國家變體的字符從128個編碼中清除。現在,我們有了一個真正意義上的128個通用字符可以被操作系統以及語言創始人自由的使用而不用擔心三字符組或其他組合。其次,我們也可以支持比如希臘語以及俄語,相比較國家變體的ASCII碼有限的字符來說它們需要更多的字符,現在也能滿足了。

UNIX,終端以及C1

UNIX是一個基於終端的系統。對於年輕人來說可能還不知道什麼是啞終端,它是一台擁有基於字符的屏幕(通常約為80×25個字符)以及一個鍵盤的設備,並通過一根串口線連接到UNIX服務器。如果你通過終端向串行端口發送文字,最終什麼字符顯示在屏幕上是由終端決定的。同樣道理,當你按下鍵盤上的一個按鍵,哪個字符發送到服務器也是由終端決定的。在這之上,在同一個通信裡,我們還需要發送控制序列,比如移動鼠標,清空屏幕,這些序列都是與字符編碼混在一起發送的。這意味著我們不能把所有的碼位分配給字符。我們需要控制字符的編碼,並且這些控制字符的編碼不能與其他字符沖突。

控制字符編碼0-31(我們熟知的C0塊)以及ASCII中的127都已經分配給控制編碼。他們的意義可能與ASCII碼的定義不相符,但是沒有關系。他們不能用來編碼可打印字符並且很多已經有良好的功能。接下來我們是可打印字符從32到126,幾乎所有的字符都有特殊的意義(命令的名字,目錄分隔符,shell轉義字符,編程語言符號等)並且他們都是不可以改變的。然而,編碼128以及大於它的對於所有人來說都是可以自由使用的。終端可以用它來定義控制字符,可打印字符等等任何東西,對於服務器來說沒有任何關系。只要所有連接到服務器的不同終端在可打印字符上達成一致,任何工作都會順利進行。我們甚至可以使用不同的控制字符編碼,只要控制字符不會與可打印字符沖突就行。

為了保持一致性與可互操作性,大家建議128-159保留作為第二個控制字符塊(C1塊),只有160-255被用來做可打印字符。這樣就形成了在Unicode之前Unix編碼的基礎。然而,它並不是一個硬性的規定,Unix本身對待C1塊與其他超過128的編碼沒有任何不同。

這使得Unix與語言本身脫離了關系。比如說,我們有一個Unix操作系統,有兩個文件,他們的名字裡分別包含了字符編碼68 196與68 228。當我們通過一個希臘語的終端連接到系統並且查看文件的時候,我們將會看到“DΔ”與“Dδ”。當我們通過一個法語終端連接到系統時我們將會看到“DÄ”與“Dä”,當我們通過一個泰語終端連接到系統時我們將會看到 “Dฤ”與“Dไ”,同樣的文件三個不同的終端顯示卻不同,這沒有什麼。一個泰國的用戶仍然可以使用“vi Dฤ”打開文件,希臘的用戶可以使用“vi DΔ”打開文件。至於操作系統方面,只要它們底層的編碼是一致的,一切看起來都很順利。這裡我們可能注意到“δ”在希臘語裡是“Δ”的小寫形式,“ฤ”與“ไ”在泰語裡完全不相關。這沒有關系,對於Unix來說它不會在比如文件名大小寫這樣的問題上做任何假設,對於文件內容也是如此。如果你將文件的內容顯示到屏幕上,文件內容會直接發送到終端,由終端負責渲染。操作系統不會參與。

事實上,如果原因的話,我們可以在一個Unix操作系統上同時使用許多不同的編碼。我們可以使用不同的語言分別創建子目錄,只要用戶使用相同的編碼的終端訪問那些子目錄,一切都會很順利。這樣,Unix本身不需要任何“編碼”設置。它也不必關心。在泰國,我們使用泰語終端連接服務器。在希臘,我們使用希臘語終端連接。

然而,那些需要輸出我們可閱讀(human-readable)錯誤信息的命令或者系統服務需要知道使用什麼語言來輸出。同樣道理,對於那些附加的程序,特別是那些需要執行排序或者文字處理的,需要知道它們使用的是什麼語言。但是,這沒必要成為一個系統級別的設置,只需要對某一個用戶的會話采用相應的配置即可。操作系統使用了一個環境變量(LOCALE)來指定語言以及編碼方式。

DOS/Windows

換個角度,DOS(以及之後的Windows 95/98/Me)就是另一碼事了。首先,是控制編碼的問題。因為屏幕是通過視頻適配器直接定位的,所以我們不需要任何控制字符。最初的DOS編碼(code page 437)將所有的ASCII控制碼以及所有超過128的碼位都分配給了可打印字符,但是它仍然可以被認為是一個基於ASCII碼的,因為它包括了ASCII碼中(32-126)的所有可打印字符。現在事實證明,問題有點大啊。換行符對於文件的換行很有用,但是如果我們采用直接定位將內容顯示到屏幕上時,換行符就沒什麼作用了。我們也曾遇到這樣的問題,比如我們與一台支持ASCII碼的串行打印機通信,同時使用ASCII碼的控制碼作為打印機控制命令。考慮到兼容性的問題,我們不能將C0塊指定為可打印字符,因此Windows95沒有使用C0塊作為可打印字符。但是,Windows95也確實用不著終端控制碼,所以它就把C1塊(128-159)分配成可打印字符了。

另一個與Windows 95/98/Me極其相關的問題是它使用了不區分大小寫的文件名,因此操作系統需要知道它應該采用什麼編碼。例如,196與228在Windows-1253中是相同的字母-小寫delta(”δ”)與大寫delta(”Δ”),但是在Windows-874中就成了不同的字母 (”ฤ”)與(”ไ”)。因此Windows需要一個系統范圍的編碼配置。對於一個單用戶操作系統來說這不是什麼問題,但是當我們聯網工作的時候,問題就出現了。例如,在一個支持泰語的計算機上我們有兩個文件叫做 “Dฤ”以及“Dไ”。然而如果我們試著通過網絡將這兩個文件拷貝到一台支持希臘語的計算機上,我們就會遇到問題。就支持希臘語的計算機而言,這兩個文件有相同的文件名,一個文件將會破壞另一個文件。同時,我們也不能使用單獨一台文件服務器存儲我們不同國際辦事處的文件,這行不通。同樣道理,我們不能建立一個支持不同語言的Web服務器。到這裡,你應該毫不奇怪微軟是Unicode的鐵桿支持者之一,並且是首先支持Unicode的操作系統之一。

使用8位編碼編程

現在,編程應該是相當輕松了。在大部分情況下,你甚至不用理會那些與編碼有關的軟件。例如,一個C語言編譯器沒有必要關注編碼。所有本地字符(128或者更高的)只能出現在注釋或者字符串/字符常量中。注釋會被忽略掉,而常量會原樣拷貝到data區。正則表達式(以及vi/sed/awk/等等)都不需要知道采用什麼編碼方式。所有的字符都是基於字節的,只要使用這些程序的人使用他們的語言輸入正確的表達式,一切都會正確運行。(例如,一個挪威人想要匹配所有的大寫字母需要使用這樣的正則表達式/[A-ZÅÆ]/而不是/[A-Z]/,但是正則表達式解析器沒有不要改變。)最基本的假設就是,我們端到端有相同的編碼,所以工具沒有必要做任何的轉換,輸入什麼它就輸出什麼就可以了。

如果我們確實需要做一些與語言相關的工作,比如排序或者大小寫轉換,要完成這樣的功能我們只有很小的工作量。排序其實都不是一個編碼問題,因為排序在不同語言間會不同即使它們采用相同的編碼。在C語言中,使用很少的函數就能搞定。本地設置(在中)定義了語言和編碼,是我們需要的唯一編碼庫,它包括一些函數判斷一個字符的類型(isnum(),isupper(),islower(),isalpah()等等)以及大小寫轉換的函數(toupper()/tolower()).排序與語言以及編碼相關,中的strcoll()與strxfrm()提供支持。這就是所有我們需要的,下面是一個例子,你可以自己實踐下看看效果。

#include
#include
#include
int main() {
// West Europe: 198 = Æ, 230 = æ (upper case and lower case letters)
setlocale(LC_CTYPE, ".1252"); // On UNIX, use ".iso-8859-1"
printf("%d %d\n", (isupper(198) ? 1 : 0), (isupper(230) ? 1 : 0));
printf("%d %d\n", (islower(198) ? 1 : 0), (islower(230) ? 1 : 0));
printf("%d %d\n", (isalpha(198) ? 1 : 0), (isalpha(230) ? 1 : 0));

// Thai: 198 = ฦ, 230 = ๆ (letters, but neither upper or lower case)
setlocale(LC_CTYPE, ".874"); // On UNIX, use ".iso-8859-11"
printf("%d %d\n", (isupper(198) ? 1 : 0), (isupper(230) ? 1 : 0));
printf("%d %d\n", (islower(198) ? 1 : 0), (islower(230) ? 1 : 0));
printf("%d %d\n", (isalpha(198) ? 1 : 0), (isalpha(230) ? 1 : 0));
return 0;
};
 

多字節編碼

到目前為止我們討論的所有編碼都是單字節編碼,即一個字節代表一個字符。然而,對於像中國,日本,韓國(通常簡稱CJK)這些國家,單字節不能夠囊括他們所有的文字。唯一的選擇就是使用多字節代替單字節。因為所有的這些編碼仍然使用單字節表示ASCII碼,所以它們都是變長的編碼。為了兼容基於ASCII碼的系統,這些字符通常被安排在94*94(2字節)或者94*94*94(3字節)的頁中,這既可以覆蓋36到126的字符碼(通過7bit通信-實際中只在基於SMTP的email中使用)同時也能覆蓋161到254的字符編碼(8bit編碼).在UNIX上,這些字符集的編碼方式是一種叫做EUC(擴展unix碼)的系統。它的基本思想是所有的非ASCII碼字符使用多字節編碼,並且使用大於128的編碼范圍。這意味著我們可以隨時可以使用中文或者日文改變一個文件的名字而不必對底層操作系統進行任何改變。事實上,許多解析器與編譯器也能夠很好的工作而不用為了支持多字節編碼而進行單獨的修改。我們仍然可以運行與編碼無關的C編譯器,因為多自己序列只會出現在注釋或者文字常量中。

然而,還是有些問題的。我們不能直接這樣寫 char mychar = ‘字’; 因為對編譯器來說這是兩個字符。substring也會有問題,這對正則表達式也會產生影響。假設我們有一個字符串”月月”,它是由四個字節組成的-183 238 183 238。如果我們嘗試搜索字符“詞”,將沒有匹配的結果,除非”詞”的編碼是238 183。大部分情況下,這不是什麼問題,因為我們通過正則搜索的關鍵字都是ASCII碼,但這種情況確實存在。其實,我們卻是有一個問題,幾乎任何標准庫都沒有正式的提出過,那就是顯示寬度的問題。當輸出到固定寬度的輸出設備比如UNIX終端,Windows命令行或者固定寬度的打印機時,大部分CJK字符都會占用兩個ASCII碼寬度。例如,如果我們想要將沖數據庫中查詢的數據很好的打印出來,但是我們只給那些特別長的列分配了20個字符的長度,在C標准庫或者數據庫的字符串函數中都不能計算出前20個ASCII字符的數據的寬度。mblen()能夠告訴我們數據使用了4個字節,但是沒有相應的函數告訴我們那個字符使用了兩個ASCII碼還是一個1個。

 
  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved