Unicode 的誕生背景
最早電腦使用的字元編碼都是 ASCII. ASCII 作為美國的計算機編碼標準, 理所當然的只包含了英語的 26 字母大小寫, 再加上一些常用符號. 後來因應一些非英語系國家/地區需要, IBM 將 ASCII 編碼擴充加入他們各自所需要的一些特殊字元, 是為 code page. 例如: code page 437 是原始的英文頁碼; code page 858 是帶有歐元符號的多語言頁碼 (我們用的正體中文是 code page 950). 所以 DOS, Windows 作業系統上有 chcp 指令可以查訽/切換不同的 code page.
但是這只解決了區域性的交流問題. 國際交流上的問題依然存在(註一). 例如: 亞洲語系的問題, CJK 字元編碼統一的問題... 於是有了 Unicode 聯盟, 致力於制定標準, 並努力的將全球各種文字符號的編碼統一, 以利於資訊的交流.
註一: 主要是因為可使用的編碼數只有 256 個, 而不同 code page 之間會對應不同的符號, 進而無法得知資訊的原始樣貌.
關於 Unicode 的 BOM (Byte Order Mark)
BOM (Byte Order Mark) 字面意思是位元組順序記號(註二, 註三). 那為什麼 Unicode 會有 BOM 的定義? 又為什麼需要 BOM 呢?
這是因為外部存儲體 (RAM, ROM, flash...) 的一直是以 8 位元為單位來定址, 但現在的 CPU 早就已經不再只是 8 位元的, 而是 16 位元, 32 位元甚至是 64 位元 CPU 了. 這些大於 8 位元的 CPU 對於大於 8 位元的資料如何擺放在外部儲存體中卻有二種不同的主要流派:
- 小端序 (Little-Endian): 將資料依其位元組的數量級由小至大擺放在外部儲存體地址也是由小至大.
- 大端序 (Big-Endian): 將資料依其位元組的數量級由小至大擺放在外部儲存體地址卻是反過來由大至小.
這二大 Endian 各自都有支持的理由. 不過幾十年下來, 目前除了 TCP/IP 是 Big-Endian 之外, 許多 (軟體/硬體) 協定都徧向採用 Little-Endian(註四). Unicode 不像 TCP/IP 或者其他協定只能用規定的 endian 進行資料交換, 而是設計了 BOM, 讓各個系統可以個自以最方便的方法使用 Unicode, 但又可以解決不同 endian 系統之間的資訊交換.
Unicode 基本編碼為 16 bits 大小, 後來擴充為 32 bits (目前尚未流行)Unicode 的編碼空間從U+0000到U+10FFFF, 共有 1,112,064 個碼位 (code point)(註五). 為了讓資料可以順利在不同 Endian 取向的 CPU 之間交換, Unicode 編碼的檔案最前頭先寫入一個數值為 65,279 (16 bits 格式: 0xFEFF 或 32 bits 格式: 0x0000FEFF) 的 BOM (Byte Order Mark).
要注意的是: 端序問題只是資料儲存 (storage) 及進出系統 (I/O) 時的問題. 在 CPU 內部不論是你是大端序機器或是小端序機器, Unicode BOM 的數值固定為 65,279: 16 bits 格式 0xFEFF; 32 bits 格式 0x0000FEFF.
所以, 以 16 bits 格式為例: 0xFEFF 的 MSB (最高數量級位元組) 為 0xFE, LSB (最低數量級位元組) 為 0xFF. 想當然的小端序的機器將 UTF-16 BOM 寫到檔案時, 第一個位元組是 LSB 0xFF, 此即為 UTF-16-LE (小端序) 編碼. 反之大端序的機器將 UTF-16 BOM 寫到檔案時, 第一個位元組是 MSB 0xFE, 所以就變成 UTF-16-BE (大端序) 編碼. 這樣子資料檔案要在各種電腦之間交換時, 就可以判定資料是不是和本機的 Endian 相同. 如果不同就需要將資料的高低位元組交換一下, 才能正確解碼.
編碼 | 十六進位表示 |
---|---|
UTF-8 | EF BB BF |
UTF-16-LE | FF FE |
UTF-16-BE | FE FF |
UTF-32-LE | FF FE 00 00 |
UTF-32-BE | 00 00 FE FF |
註二: 位元組順序記號這個字面翻譯有點小拗口, 個人認為改稱之為端序記號似乎好些. 你認為呢?
註三: 你可能會看到有些簡体中文的網站把它翻譯料表, 物料清單或材料清單, 這應該是機器翻譯把它誤認為另一個 BOM (Bill Of Material). 不要說我黑白講, 這幾個例子就是: https://tech-wiki.online/cn/javascript-unicode.html (誤值為物料清单字符), https://cloud.tencent.com/developer/section/1125377 (誤值為物料清单和材料清单)

utf-8 BOM 翻譯錯誤
註四: 參看 維基百科位元組順序.
註五: U+0000到U+10FFFF, 實際上有 1,114,112 個碼位. 但其中的 0xD800~0xDBFF 及 0xDC00~0xDFFF 被保留作為代理對 (Surrogate Pair) 編碼之用. 是故可以直正用於字元編碼的碼位 1,114,112 - 2,048 = 1,112,064.
UTF-8 的編碼規則和 BOM
至於 UTF-8 編碼: 是將 Unicode 編碼的字串資料轉成 8 位元序列 (轉換規則如下表: UTF-8 編碼規則), 所有機器都必需一個位元組接著一個位元組的讀 (地址由小到大), 所以不存在大小端的問題. 還有為何 UTF-8 的 BOM 會變成 EF BB BF. 其實它依然是數值 65,279 (0xFEFF) 編碼成 UTF-8 的結果. 並不是什麼特別的設定.
UTF-16 | 十六進位值 | FE FF |
---|---|---|
二進位值 | 1111 1110 1111 1111 | |
UTF-8 | 二進位值 | 1110 1111 1011 1011 1011 1111 |
十六進位值 | EF BB BF |
Code Point Range | bits | Byte 1 | Byte 2 | Byte 3 | Byte 4 | Byte 5 | Byte 6 |
---|---|---|---|---|---|---|---|
U+0000 ~ U+007F | 7 | 0xxx xxxx 00~7F |
|||||
U+0080 ~ U+07FF | 11 | 110x xxxx C0~DF |
10xx xxxx 80~BF | ||||
U+0800 ~ U+FFFF | 16 | 1110 xxxx E0~EF |
10xx xxxx 80~BF |
10xx xxxx 80~BF | |||
U+10000 ~ U+1FFFFF | 21 | 1111 0xxx F0~F7 |
10xx xxxx 80~BF |
10xx xxxx 80~BF |
10xx xxxx 80~BF | ||
U+200000 ~ U+3FFFFFF | 26 | 1111 10xx F8~FB |
10xx xxxx 80~BF |
10xx xxxx 80~BF |
10xx xxxx 80~BF |
10xx xxxx 80~BF | |
U+4000000 ~ U+7FFFFFFF | 31 | 1111 110x FC~FD |
10xx xxxx 80~BF |
10xx xxxx 80~BF |
10xx xxxx 80~BF |
10xx xxxx 80~BF |
10xx xxxx 80~BF |
|
UTF-16 的編碼規則
由於 Unicode 的編碼空間從U+0000到U+10FFFF, 共有 1,112,064 個碼位 (code point), 超出 16 位元可以表示的範圍. 因此 UTF-16 將輔助平面字符 (Supplementary Planes), 即超出 0xFFFF 的部份 0x10000~0x10FFFF, 以一對 (二組) 16 位元資料來表示: 第一組 16 位元編碼範圍 0xD800~0xDBFF, 加上第二組 16 位元編碼範圍 0xDC00~0xDFFF 此即所謂代理對 (Surrogate Pair).
為此 Unicode 也將區段 0xD800~0xDFFF 空下來不編碼.
代理對 (Surrogate Pair) 的編碼方法如下:
- 將 Unicode 輔助平面字符的碼位 (code point) 減去 0x10000, 得到的數值範圍為 20 位元, 並將之分為 2 組 10 位元.
(0x10000 ~ 0x10FFFF) - 0x10000 = 0x0 ~ 0xFFFFF - 高位的 10 位元的值 (範圍為 0x0 ~ 0x3FF) 加上 0xD800 得到第一個碼元, 稱作前導代理 (lead surrogate).
(0x0 ~ 0x3FF) + 0xD800 = 0xD800 ~ 0xDBFF - 低位的 10 位元的值 (範圍為 0x0 ~ 0x3FF) 加上 0xDC00 得到第二個碼元, 稱作後尾代理 (trail surrogate)
(0x0 ~ 0x3FF) + 0xDC00 = 0xDC00 ~ 0xDFFF
lead \ trail | 0xDC00 | 0xDC01 | ... | 0xDFFF |
---|---|---|---|---|
0xD800 | 0x10000 | 0x10001 | … | 0x103FF |
0xD801 | 0x10400 | 0x10401 | … | 0x107FF |
0xD802 | 0x10800 | 0x10801 | … | 0x10BFF |
⋮ | ⋮ | ⋮ | ⋱ | ⋮ |
0xDBFF | 0x10FC00 | 0x10FC01 | … | 0x10FFFF |
Python 如何處理 BOM
Python3 在開啟檔案時 (open()) 指定 encoding 參數可以讓我們選擇保留 BOM (自己處理 BOM 的讀寫); 或者也可以輕鬆點, 讓系統自動處理 BOM.
下列是我目前試過的 Unicode 相關的合法 encoding 參數值: (註六)
- 'utf-8': 系統不自動處理 BOM. (Python3 預設值(註七))
- 讀檔時 BOM 被當作資料讀入.
- 寫檔時, 要依據需求自己先寫入一個 BOM (write('\ufeff')). 如果寫檔時沒有寫 BOM, 檔案的開頭自然也就不會有 BOM (即所謂的 UTF-8 no BOM 編碼).
- 'utf-8-sig': 系統自動處理 BOM.
- 讀檔時, 自動跳過 BOM (有沒有 BOM 都 OK)
- 寫檔時, 自動加 BOM (一定有)
- 'utf-16': 系統自動處理 BOM.
- 讀檔時, 自動跳過 BOM (有沒有 BOM 都 OK)
- 寫檔時, 自動加 BOM (一定有, 大小端序應該是依系統而定)
- 'utf-16-le': 系統不自動處理 BOM.
- 讀檔時 BOM 被當作資料讀入.
- 寫檔時, 依據需求自己先寫入一個 BOM (write('\ufeff')).
- 讀/寫時, Python 字串以 Little-Endian 解/編碼.
- 'utf-16-be': 系統不自動處理 BOM.
- 讀檔時 BOM 被當作資料讀入.
- 寫檔時, 依據需求自己先寫入一個 BOM (write('\ufeff')).
- 讀/寫時, Python 字串以 Big-Endian 解/編碼.
注意: 有系統自動處理 BOM 功能的是: 'utf-8-sig' 和 'utf-16', 不要弄混了.
註六: 當然, 還有 utf-32 相關的三個 'utf-32', 'utf-32-le', 'utf-32-be' 合法設定值, 但是我自己沒試過, 可能要大家自行腦補一下.
註七: 這個預設值在 Windows 平台, 有點不同. 它預設是使用的頁碼 950 (Windows 繁體中文版系統預設值). 我們需要自行在環境變數 (系統或者使用者均可) 新增一個環境變數 PYTHONUTF8=1; 或者使用 python -x utf8 來啟動 python (即加上參數 -x utf8). 如此 Python3 才會將 encoding 參數預設改為 'UTF-8' (即 encoding='UTF-8').
另外, 使用 chcp 65001 指令並無法更動 Python3 預設的 encoding 設定.
不過有一個設定可以將你的命令提示字元, 預設為使用 65001 頁碼; 同時也會將 Python3 的 encoding 參數預設改為 'UTF-8'. 操作如下: 打開 控制台 (如果沒有它的捷徑, 在左下方的 '搜尋欄' 中輸入 '控制台' 也可以找到它), 打開其中的 '地區' 設定. 在 '系統管理' 頁籤中, 點選下方的 '變更系統地區設定(C)...' 按鈕, 開啟 '地區設定' 子視窗, 並勾選 'Beta: 使用 Unicode UTF-8 提供全語言支援 (U)'. 按下 '確定' 按鈕, 然後重新開機即可.

控制台 (請選擇 '地區')
Python 3 實例
廢話不多說, 來看幾個實例吧. 下面這個很直觀吧!?
xf3 = open('./test-utf8.txt', 'w', encoding='utf-8')
xf3.write(u'\uFEFF')
xf3.write('UTF-8 寫檔測試, 自行加入 utf-8 BOM.\n')
xf3.close()
輸出檔案的內容如下:

python 寫檔附加 utf-8 BOM
再來一個讀 csv 檔的例子. csv 檔一般並沒有要求使用 Unicode BOM, 但是 MS Excel 輸出的 csv 檔有 BOM, 讀檔時也要有 BOM. 下面這個例子用 'utf-8-sig' 來省略 utf-8 BOM 的檢查 (第 12~13 行). 程式如下:
import csv
import numpy as np
def dict_csvloader(path):
'''
Load lines in text file as a dict().
'''
keyDict = dict()
with open(path, newline='', encoding='utf-8-sig') as csvfile:
reader = csv.DictReader(csvfile)
headers = list(next(reader, None).keys())
# if headers[0][0] == u'\ufeff':
# headers[0] = headers[0][1:]
for row in reader:
data = tuple(row.values())
if data[2]=='':
break
vstr = np.array(data[3:])
vstr[vstr==''] = '0'
keyDict[data[2]] = vstr.astype(np.float32)
return keyDict, headers
nutriTbl, headers = dict_csvloader('./nutrition.csv')
資料檔 nutrition.csv 範例如下:
樣品編號,食品分類,樣品名稱,修正熱量(kcal),水分(g),粗蛋白(g),粗脂肪(g),總碳水化合物(g),膳食纖維(g),鈉(mg),鉀(mg),鈣(mg),鎂(mg),鐵(mg),鋅(mg),維生素A總量(IU),維生素E總量(mg),維生素B1(mg),維生素B2(mg),菸鹼素(mg),維生素B6(mg),維生素C(mg)
A0100101,穀物類,大麥仁,343,12.3,8.9,1.6,76.1,8.9,14,229,26,49,1.6,1.2,0,1.11,0.16,0.04,3.31,0.22,0.2
A0110101,穀物類,大麥片,352,12.1,8.6,1.8,76.7,6.0,7,246,13,55,2.2,0.8,0,0.26,0.15,0.04,3.23,0.18,9.8
A0120101,穀物類,大麥仁粉,374,5.7,7.1,1.3,85.0,7.2,10,307,25,50,1.1,1.3,0,0.12,0.09,0.03,2.85,0.23,0.0
追加註記
要是你不嫌麻煩, 或者有點 OO 的潔癖: 覺得直接用 '\uFEFF' 代表 Unicode BOM 不對或者不夠道地. 那可以考慮一下使用 import codecs 引入 codecs 模組來使用下列常數:
- codecs.BOM
- codecs.BOM_BE
- codecs.BOM_LE
- codecs.BOM_UTF8
- codecs.BOM_UTF16
- codecs.BOM_UTF16_BE
- codecs.BOM_UTF16_LE
- codecs.BOM_UTF32
- codecs.BOM_UTF32_BE
- codecs.BOM_UTF32_LE
參考連結
- 我的貼文: "Python: .py 檔的編碼問題"