https://mp.weixin.qq.com/s/ykYXDQBNqhCYF0MDFx19gQ
程序員必備:10分鍾搞懂各種編碼丨另附實戰案例
背景
HTTP 協議基於文本傳輸,字符編碼將文本變為二進制,二進制編碼將二進制變為文本。TCP 協議基於二進制傳輸,數據讀取時需要處理字節序。本文將介紹常見的字符編碼、二進制編碼及字節序,並一探 Golang 中的實現。
字符編碼
引言:如何把“Hello world”變成字節?
-
Step1:得到要表示的全量字符(字符表)
-
Step2:為每個字符指定一個整數編號(編碼字符集)
-
Step3:將編號映射成有限長度比特值(字符編碼表)
字符是各種文字和符號的總稱,包括各國家文字、標點符號、圖形符號、數字等。全世界共使用 5651 種語言,其中使用人數超過 5000 萬的語言有 13 種,每種語言有自己的字符。漢語中,一個漢字就是一個字符。英語中,一個字母就是一個字符。甚至看不見的也可以是字符(如控制字符)。字符的集合即為字符表,如英文字母表,阿拉伯數字表。ASCII 碼表中一共有 128 個字符。
編碼字符集(CCS:Coded Character Set)
為字符表中的每個字符指定一個編號(碼點,Code Point),即得到編碼字符集。常見有 ASCII 字符集、Unicode 字符集、GB2312 字符集、BIG5 字符集、 GB18030 字符集等。ASCII 字符集中一共有 128 個字符,包括了 94 個可打印字符(英文大小寫字母 52 個、阿拉伯數字 10 個、西文符號 32 個)和 34 個控制符或通信專用字符,碼點值范圍為[0, 128)
,如下圖所示。Unicode 字符集是一個很大的集合,現有容量將近 2^21 個字符,碼點值范圍為[0, 2^20+2^16)
。

字符編碼表(CEF:Character Encoding Form)
編碼字符集只定義了字符與碼點的映射,並沒有規定碼點的字節表示方式。由於 1 個字節可以表示 256 個編號,足以容納 ASCII 字符集,因此ASCII 編碼的規則很簡單:直接將碼點值用 uint8 表示即可。對於 Unicode 字符集,容納 2^21 至少需要 3 字節。可以采用類似 ASCII 的編碼規則:直接將編碼點值用 uint32 表示即可,這正是 UTF-32 編碼。
這種一刀切的定長編碼方式雖然簡單粗暴,弊端也很明顯:對於純英文文本,UTF-32 編碼空間占用將是 ACSII 編碼的 4 倍,造成極大的空間浪費,幾乎沒什么人用。有沒有更優雅的解決方案?當然,這就是 UTF-8 和 UTF-16,兩種當前比較流行的 Unicode 編碼方式。
UTF-8
歷史告訴我們,成功的設計往往具有包容性。UTF-8 是一個典型,漂亮的實現了對 ASCII 碼的向后兼容,以保證可以被大眾接受。UTF-8 是目前互聯網上使用最廣泛的一種 Unicode 編碼方式,它的最大特點就是可變長,隨碼點變換長度(從 1 字節到 4 字節)。text
大道至簡,優雅的設計一定是簡單的,UTF-8 的編碼規則也詮釋了這一點。編碼規則如下:
-
<=127(U+7F)的碼點采用單字節編碼,與 ASCII 保持一致;
-
>127(U+7F)的碼點采用 N 字節(N 屬於 2,3,4)編碼,首字節的前 N 位為 1,第 N+1 位為 0,剩余 N-1 個字節的前兩位都為 10,剩下的二進制位使用字符的碼點來填充。
其中(U+7F)表示 Unicode 的十六進制碼點值,即 127。如果覺得編碼規則抽象,結合下表更加清晰:
Unicode 碼點范圍 | 碼點數量 | UTF-8 編碼格式 |
---|---|---|
0000 0000 ~ 0000 007F | 2^7 | 0xxxxxxx |
0000 0080 ~ 0000 07FF | 2^11 - 2^7 | 110xxxxx 10xxxxxx |
0000 0800 ~ 0000 FFFF | 2^16 - 2^11 | 1110xxxx 10xxxxxx 10xxxxxx |
0001 0000 ~ 0010 FFFF | 2^20 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
舉個例子,如“漢”的 Unicode 碼點是 U+6C49(110 1100 0100 1001),根據上表可得需要 3 字節編碼,填充碼點值后得到 0xE6 0xB7 0x89(11100110 10110001 10001001)。
根據編碼規則,解碼也很簡單,關鍵是如何判斷連續的字節數:首字節連續 1 的個數即為字節數。
需要一提的是,在 MySQL 中,utf8 是“虛假的 utf8”,最大只支持 3 個字節,如果建表時選擇 CHARSET=utf8,會導致很多特殊字符和 emoji 表情都無法插入。utf8mb4 才是“真正的 utf8”,mb4 即most bytes 4
。為什么 MySQL 中 utf8 最大只支持 3 字節?歷史原因,在 MySQL 剛開發那會兒,Unicode 空間只有 2^16,Unicode 委員會還在做 “65535 個字符足夠全世界用了”的美夢呢。
UTF-16
在 C/C++ 中遇到的wchar_t
類型或 Java 中的char
類型,這些類型占內存兩個字節,因為 Unicode 中常用的字符都處於[U+0, U+FFFF]
(基本平面)的范圍之內,因此兩個字節幾乎可以覆蓋大部分的常用字符,這正是 UTF-16 編碼的一個前提。
相比 UTF-32 與 UTF-8,UTF-16 編碼是一個折中:小於(U+FFFF)2^16 的碼點(基本平面)使用 2 字節編碼,大於(U+FFFF)2^16 的碼點(輔助碼點)使用 4 字節編碼。由於基礎平面空間會占用 2 字節的所有比特位,無法像 UTF-8 那樣留有“10”前綴。那么問題來了:當我們遇到兩個節時,如何判斷是 2 字節編碼還是 4 字節編碼?
UTF-16 的編碼的另一個前提:在基本平面內,[U+D800, U+DFFF]
是一個空段(空間大小為 2^11),這些碼點不對應任何字符。因此,這個空段可以用來映射輔助平面的字符。
輔助平面容量為 2^20,至少需要 20 個二進制位,UTF-16 將這 20 個二進制位分成兩半,前 10 位映射在 U+D800 到 U+DBFF(空間大小 2^10),稱為高位(H),后 10 位映射在 U+DC00 到 U+DFFF(空間大小 2^10),稱為低位(L)。
映射方式采用線性映射。Unicode3.0 中給出了輔助平面字符的轉換公式:
H = Math.floor((c-0x10000) / 0x400) + 0xD800
L = (c - 0x10000) % 0x400 + 0xDC00
也就是說,一個輔助平面的碼點,被拆成兩個基本平面的空段碼點表示。如果雙字節的值在[U+D800, U+DBFF]
中,則要和后續相鄰的雙字節一同解碼。具體編碼規則為:
-
<= (U+FFFF)的碼點采用雙字節編碼,直接將碼點使用 uint16 表示;
-
> (U+FFFF)的碼點采用 4 字節編碼,作差計算碼點溢出值,將溢出值用 uint20 表示后,前 10 位映射到
[U+D800, U+DBFF]
,后 10 位映射到[U+DC00, U+DFFF]
;
小結: 定長編碼的優點是轉換規則簡單直觀,查找效率高,缺點是空間浪費,以及不可擴展。如果 Unicode 字符集進一步擴充,UTF-16 和 UTF-32 都將不可用,而 UTF-8 具有更強的可擴展性。
Golang 中字符編碼
不像 C++、Java 等語言支持五花八門的字符編碼,Golang 遵從“大道至簡”的原則:全給老子用 UTF-8。所以 go 程序員再也不用擔心亂碼問題,甚至可以用漢字和表情包寫代碼,string 與字節數組轉換也是直接轉換,十分酸爽。
func TestTemp(t *testing.T) {
來自打工人的問候()
}
func 來自打工人的問候() {
問候語 := "早安,打工人😁"
fmt.Println(問候語)
bytes := []byte(問候語)
fmt.Println(hex.EncodeToString(bytes))
}
// 執行結果-->
早安,打工人😁
e697a9e5ae89efbc8ce68993e5b7a5e4babaf09f9881
值得一提的是,Golang 中 string 的底層模型就是字節數組,所以類型轉換過程中無需編解碼。也因此,Golang 中 string 的底層模型是字節數組,其長度並非字符數,而是對應字節數。如果要取字符數,需要先將字符串轉換為字符數組。字符類型(rune)實際上是 int32 的別名,即用 UTF-32 編碼表示字符。
func TestTemp(t *testing.T) {
fmt.Println(len("早")) // 3
fmt.Println(len([]byte("早"))) // 3
fmt.Println(len([]rune("早")) // 1
}
// rune is an alias for int32 and is equivalent to int32 in all ways. It is
// used, by convention, to distinguish character values from integer values.
type rune = int32
再看一下 go 中 utf-8 編碼的具體實現。首先獲取字符的碼點值,然后根據范圍判斷字節數,根據對應格式生成編碼值。如果是無效的碼點值,或碼點值位於空段,則返回U+FFFD
(即 �)。解碼過程不再贅述。
// EncodeRune writes into p (which must be large enough) the UTF-8 encoding of the rune.
// It returns the number of bytes written.
func EncodeRune(p []byte, r rune) int {
// Negative values are erroneous. Making it unsigned addresses the problem.
switch i := uint32(r); {
case i <= rune1Max:
p[0] = byte(r)
return 1
case i <= rune2Max:
_ = p[1] // eliminate bounds checks
p[0] = t2 | byte(r>>6)
p[1] = tx | byte(r)&maskx
return 2
case i > MaxRune, surrogateMin <= i && i <= surrogateMax:
r = RuneError
fallthrough
case i <= rune3Max:
_ = p[2] // eliminate bounds checks
p[0] = t3 | byte(r>>12)
p[1] = tx | byte(r>>6)&maskx
p[2] = tx | byte(r)&maskx
return 3
default:
_ = p[3] // eliminate bounds checks
p[0] = t4 | byte(r>>18)
p[1] = tx | byte(r>>12)&maskx
p[2] = tx | byte(r>>6)&maskx
p[3] = tx | byte(r)&maskx
return 4
}
}
const(
t1 = 0b00000000
tx = 0b10000000
t2 = 0b11000000
t3 = 0b11100000
t4 = 0b11110000
t5 = 0b11111000
maskx = 0b00111111
mask2 = 0b00011111
mask3 = 0b00001111
mask4 = 0b00000111
rune1Max = 1<<7 - 1
rune2Max = 1<<11 - 1
rune3Max = 1<<16 - 1
RuneError = '\uFFFD' // the "error" Rune or "Unicode replacement character"
)
// Code points in the surrogate range are not valid for UTF-8.
const (
surrogateMin = 0xD800
surrogateMax = 0xDFFF
)
二進制編碼
引言:HTTP 是怎么傳輸二進制數據的?
-
Step1:定義字符集;
-
Step2:將二進制數據分組;
-
Step3:將每組映射為字符;
字符編碼是「文本」變為「二進制」的過程,那如何將任意「二進制」變為「文本」?答案是進行二進制編碼,常見有 Hex 編碼與 Base64 編碼。
顯然不能按字符編碼直接解碼,因為字符編碼的結果二進制是滿足編碼規律的,而非「任意」的,非法格式進行字符解碼會出現亂碼(比如對0b11xxxxxx
進行 UTF-8 解碼)。
Hex 編碼
Hex 編碼是最直觀的二進制編碼方式,所見即所得。上文中的十六進制表示就是用的 Hex 編碼。規則如下:
- Hex 字符集為
0123456789abcdef
; - 每 4bit 為 1 組(2^4=16);
- 每組映射為一個 Hex 字符;
計算機中二進制數據都是以字節為單位存儲的,1 個字節 8bit,不會出現無法被 4 整除的情況。
每個字節編碼為 2 個 Hex 字符,即編碼后的字符數是原始數據字節數的 2 倍。在 ASCII 或 UTF-8 編碼下,存儲 Hex 結果字符串需要的空間是原始數據的 2 倍,存儲效率為 50%。
Base64 編碼
Base64 編碼,顧名思義,是基於 64 個字符進行編碼。規則如下:
- Base64 字符集(以標准 Base64 為例, 26 大寫, 26 小寫, 10 數字, 以及
+
、/
)為ABC...YZabc...yz012...89+/
; - 每 6bit 為一組(2^6=64),即每 3 個字節為 4 組;
- 每組映射為一個 Base64 字符;
如果要編碼的二進制數據不是 3 的倍數,最后會剩下 1 個或 2 個字節怎么辦?標准編碼(StdEncoding) 會先在末尾用 0x00 補齊再分組,並將最后 2 個或 1 個 6bit 分組(全為 0 填充)映射為'=',表示補齊的 0 字節數量。
0x12 34 ab cd
編碼為標准 base64 為例:
- 不足 3 的倍數,先用兩個 0 字節補齊 -->
0x12 34 ab cd 00 00
0x12 34 ab
編碼為EjSr
0xcd 00 00
二進制為0b1100 1101 0000 0000 0000 0000
,分為 4 組后為110011 010000 000000 000000
,編碼結果為zQ==
- 最終編碼結果為
EjSrzQ==
解碼過程注意末尾字節的處理即可,此處不再贅述。
EjSrzQ==
-->0x12 34 ab cd 00 00
-->0x12 34 ab cd
標准編碼中編碼結果字符長度一定是 4 的倍數,且是原始數據字節數的 4/3 倍,因為會將字節數據補齊至 3 的倍數,每 3 個字節編碼為 4 個字符。在 ASCII 或 UTF-8 編碼下,存儲結果字符串需要的空間是原始數據的 4/3 倍,存儲效率為 75%。
根據字符集的不同,Base64 編碼有幾個變種,除了標准編碼(StdEncoding),常見的還有 URL 編碼(URLEncoding)、原始標准編碼(RawStdEncoding)以及原始 URL 編碼(RawUrlEncoded)。
簡單來說,Raw 指的是無 Padding,URL 指的是用-
和_
取代編碼結果中包含的 url 關鍵字+
和/
。不妨參考 Golang 中encoding/base64
包中的描述:
// StdEncoding is the standard base64 encoding, as defined in
// RFC 4648.
var StdEncoding = NewEncoding(*encodeStd*)
// URLEncoding is the alternate base64 encoding defined in RFC 4648.
// It is typically used in URLs and file names.
var URLEncoding = NewEncoding(*encodeURL*)
// RawStdEncoding is the standard raw, unpadded base64 encoding,
// as defined in RFC 4648 section 3.2.
// This is the same as StdEncoding but omits padding characters.
var RawStdEncoding = StdEncoding.WithPadding(*NoPadding*)
// RawURLEncoding is the unpadded alternate base64 encoding defined in RFC 4648.
// It is typically used in URLs and file names.
// This is the same as URLEncoding but omits padding characters.
var RawURLEncoding = URLEncoding.WithPadding(*NoPadding*)
與標准編碼不同的是,原始編碼中,字節數不足 3 的倍數時不會補齊字節數,采用如下方案:
- 如果剩余 1 字節,則左移 4bit 后轉換為 2 字符;
- 如果剩余 2 字節,則左移 2bit 后轉化為 3 字符;
即原始編碼方案中,結果字符串長度可以不是 4 的倍數。
最后,聰明的你一定已經發現了,Hex 編碼可以看成“Base16 編碼”。隨着字符數量的增加,存儲效率也隨之增加。如果有“Base256”編碼,存儲效率豈不就 100%了?很遺憾,主流字符編碼中,單字節能表示的可打印字符只有 92 個。通過擴充多字節字符,或用組合字符實現 base256 意義不大。
Golang 中的二進制編碼
看一下 Golang 中 Base64 編碼的實現。首先通過EncodedLen
方法確定結果長度,生成輸出buf
,然后通過Encode
方法將編碼結果填充到buf
並返回結果字符串。
// EncodeToString returns the base64 encoding of src.
func (enc *Encoding) EncodeToString(src []byte) string {
buf := make([]byte, enc.EncodedLen(len(src)))
enc.Encode(buf, src)
return string(buf)
}
如前述,標准編碼和原始編碼(無 Padding)的結果長度不同:如果需要 Padding,直接根據字節數計算即可,反之則需要根據 bit 數計算。
// EncodedLen returns the length in bytes of the base64 encoding
// of an input buffer of length n.
func (enc *Encoding) EncodedLen(n int) int {
if enc.padChar == *NoPadding* {
return (n*8 + 5) / 6 // minimum # chars at 6 bits per char
}
return (n + 2) / 3 * 4 // minimum # 4-char quanta, 3 bytes each
}
Encode
方法實現了編碼細節。首先遍歷字節數組,將每 3 個字節編碼為 4 個字符。最后處理剩余的 1 或 2 個字節(如有):首先使用移位運算進行 0bit 填充,然后進行字符轉換。如前述,無 Padding 時,剩下 1 字節對應 2 字符,剩下 2 字節對應 3 字符,即至少會有 2 字符。最后在switch
代碼段中,根據剩余字節數填充第 3 個字符和 Padding 字符(如有)即可。
func (enc *Encoding) Encode(dst, src []byte) {
if len(src) == 0 {
return
}
// enc is a pointer receiver, so the use of enc.encode within the hot
// loop below means a nil check at every operation. Lift that nil check
// outside of the loop to speed up the encoder.
_ = enc.encode
di, si := 0, 0
n := (len(src) / 3) * 3
for si < n {
// Convert 3x 8bit source bytes into 4 bytes
val := uint(src[si+0])<<16 | uint(src[si+1])<<8 | uint(src[si+2])
dst[di+0] = enc.encode[val>>18&0x3F]
dst[di+1] = enc.encode[val>>12&0x3F]
dst[di+2] = enc.encode[val>>6&0x3F]
dst[di+3] = enc.encode[val&0x3F]
si += 3
di += 4
}
remain := len(src) - si
if remain == 0 {
return
}
// Add the remaining small block
val := uint(src[si+0]) << 16
if remain == 2 {
val |= uint(src[si+1]) << 8
}
dst[di+0] = enc.encode[val>>18&0x3F]
dst[di+1] = enc.encode[val>>12&0x3F]
switch remain {
case 2:
dst[di+2] = enc.encode[val>>6&0x3F]
if enc.padChar != *NoPadding* {
dst[di+3] = byte(enc.padChar)
}
case 1:
if enc.padChar != *NoPadding* {
dst[di+2] = byte(enc.padChar)
dst[di+3] = byte(enc.padChar)
}
}
}
字節序
引言:拿到兩個字節,如何解析為整形?
-
Step1:明確字節高低位順序
-
Step2:按高低位權重計算結果
上述二進制編碼主要用於文本傳輸,能不能不進行編碼,直接傳輸二進制?當然可以,基於二進制傳輸協議,如 TCP 協議。那么什么是文本傳輸,什么是二進制傳輸?簡單來說,文本傳輸,內容為文本,自帶描述信息(參數名),如 HTTP 中的字段都以 KV 形式存在。二進制傳輸,內容為二進制,以預先定義好的格式拼在一起,如 TCP 協議報文格式。
大端與小端
聊到二進制傳輸,一個避不開的話題是字節序。什么是字節序?假設讀取到一個兩字節的 uint16 0x04 0x00,如果從左往右(從高位往低位)解碼,得到的是 1024,反過來(從低位往高位)解碼則是 4,這就是字節序。符合人類閱讀習慣的(從高位往低位)是大端(BigEndian),反之為小端(LittleEndian)。
另一種大小端的定義:LittleEndian 將低序字節存儲在低地址,BigEndian 將高序字節存儲在低地址。理解起來有些抽象,本質上是一致的。
為什么會有小端字節序,統一都用大端不好么?
計算機不這么想,因為計算機中計算都是從低位開始的,電路先處理低位字節效率比較高。但是,人類還是習慣讀寫大端字節序。所以, 除了計算機的內部處理,其他的場合幾乎都是大端字節序,比如網絡傳輸和文件儲存。那什么時候程序員需要進行字節序處理呢?當多字節整形(uint16,uint32,uint64)需要和字節數組互相轉換時。字節數組是無字節序的,客戶端寫入啥,服務端就讀取啥,不會出現逆序,寫入和讀取無需考慮字節序,這點大可放心。只有當多字節整形和字節數組互轉時必須指明字節序。
Golang 中的字節序
以 uint16 與字節數組互轉為例,看一下 Golang 中 encoding/binary 包中的字節序處理與實現。可見實現並不復雜,注意字節順序即可。
func TestEndian(t *testing.T) {
bytes := make([]byte, 2)
binary.LittleEndian.PutUint16(bytes, 1024) // 小端寫 --> 0x0004
binary.BigEndian.PutUint16(bytes, 1024) // 大端寫 --> 0x0400
binary.LittleEndian.Uint16(bytes) // 小端讀 --> 4
binary.BigEndian.Uint16(bytes) // 大端讀 --> 1024
}
func (littleEndian) PutUint16(b []byte, v uint16) {
_ = b[1] // early bounds check to guarantee safety of writes below
b[0] = byte(v)
b[1] = byte(v >> 8)
}
func (bigEndian) PutUint16(b []byte, v uint16) {
_ = b[1] // early bounds check to guarantee safety of writes below
b[0] = byte(v >> 8)
b[1] = byte(v)
}
func (littleEndian) Uint16(b []byte) uint16 {
_ = b[1] // bounds check hint to compiler; see golang.org/issue/14808
return uint16(b[0]) | uint16(b[1])<<8
}
func (bigEndian) Uint16(b []byte) uint16 {
_ = b[1] // bounds check hint to compiler; see golang.org/issue/14808
return uint16(b[1]) | uint16(b[0])<<8
}
實戰:加解密中的編碼與字節序
在加解密場景中,通常我們會對明文加密得到密文,對密文解密得到明文。比如對密碼"123456"
(明文)進行對稱加密(如 SM4)得到"G7EeTPnuvSU41T68qsuc_g"
(密文)。明文和密文都是由可打印字符構成的文本,通常明文人類可直接閱讀其含義(不考慮二次加密),密文需要解密后才能理解含義。
那么上述明文變成密文,期間經歷了哪些編碼過程呢?以加密為例:
- 將明文
"123456"
進行字符解碼(如 UTF-8),得到明文字節序列0x31 32 33 34 35 36
; - 將明文字節序列輸入 SM4 加密算法,輸出密文字節序列
0x1b b1 1e 4c f9 ee bd 25 38 d5 3e bc aa cb 9c fe
; - 將密文字節序列進行二進制編碼(如 RawURLBase64),得到密文
"G7EeTPnuvSU41T68qsuc_g"
;
同理,將"G7EeTPnuvSU41T68qsuc_g"
解密成"123456"
過程中,應與加密過程的編碼方式對應:先進行 RawRULBase64 解碼,再解密,最后再進行 UTF-8 編碼。
加解密算法的輸入輸出都是字節序列,所以要將明文、密文與字節序列進行轉換。有兩點需要注意:
- 明文解碼為明文字節序列,解碼方式因場景而定。對於多次加密場景(如對“G7EeTPnuvSU41T68qsuc_g”再次加密),明文是 Base64 編碼得到的,建議采用一致的方式解碼。雖然也可以直接進行 UTF-8 解碼,但會使加解密流程設計變得復雜。
- 密文字節序列編碼為密文,必須用二進制編碼,不能用字符編碼。使用字符編碼會產生亂碼(意味着數據丟失,無法逆向解碼出原始數據)。上述密文序列密文序列進行 UTF-8 編碼的結果是 �L���%8�>��˜�。
合規要求,加解密場景中應使用硬件加密機。通常硬件加密機提供基於 TCP 的字節流通信方式,比如約定每次通信數據中的前 2 字節為數據長度,后面的為真實數據。發送時,需要將真實數據長度轉為 2 字節拼在前面,接收時,需要先讀取前兩字節得到真實數據長度 N,再讀取 N 字節得到真實數據。其中長度與字節序列的轉換需要關注字節序:發送方和接收方的字節序處理保持一致即可,比如全用大端。下面給出了數據發送的示例代碼:
func (m *EncryptMachine) sendData(conn net.Conn, data []byte) error {
// add length
newData := m.addLength(data)
// send new data
return util.SocketWriteData(conn, newData)
}
func (m *EncryptMachine) addLength(data []byte) []byte {
lengthBytes := make([]byte, 2)
binary.BigEndian.PutUint16(lengthBytes, uint16(len(data)))
return append(lengthBytes, data...)
}
總結
編碼雖然基礎,但卻容易出錯,切莫眼高手低。希望本文能幫助大家進一步了解字符編碼、二進制編碼與字節序,避免踩坑。真誠點贊,手留余香。
參考資料
[1] 十分鍾搞清字符集和字符編碼: http://cenalulu.github.io/linux/character-encoding/
[2] 字符編碼筆記:ASCII,Unicode 和 UTF-8: http://www.ruanyifeng.com/blog/2007/10/ascii_unicode_and_utf-8.html
[3] 徹底弄懂 Unicode: https://liyucang-git.github.io/2019/06/17/%E5%BD%BB%E5%BA%95%E5%BC%84%E6%87%82Unicode%E7%BC%96%E7%A0%81/
[4] 理解字節序: https://www.ruanyifeng.com/blog/2016/11/byte-order.html