字節序
字節序就是多字節數據類型 (int, float 等)在內存中的存儲順序。在網絡傳輸中基於文本類型的協議(比如 JSON)和二進制協議都是字節通信,是采用字節序進行數據包的處理。
字節序可分為大端序,低地址端存放高位字節;小端序與之相反,低地址端存放低位字節。

在計算機內部,小端序被廣泛應用於現代性 CPU 內部存儲數據;而在其他場景譬如網絡傳輸和文件存儲使用大端序。
在網絡協議層操作二進制數字時約定使用大端序,大端序是網絡字節傳輸采用的方式。因為大端序最高有效字節排在首位(低地址端存放高位字節),能夠按照字典排序,所以我們能夠比較二進制編碼后數字的每個字節。
固定長度編碼 Fixed-length encoding
Go 中有多種類型的整型, int8, int16, int32 和 int64 ,分別使用 1, 3, 4, 8 個字節表示,我們稱之為固定長度類型 (fixed-length types)。
Go 處理固定長度字節序
Go中處理大小端序的代碼位於 encoding/binary ,包中的全局變量BigEndian用於操作大端序數據,LittleEndian用於操作小端序數據,這兩個變量所對應的數據類型都實行了ByteOrder接口:
type ByteOrder interface { Uint16([]byte) uint16 Uint32([]byte) uint32 Uint64([]byte) uint64 PutUint16([]byte, uint16) PutUint32([]byte, uint32) PutUint64([]byte, uint64) String() string }
其中,前三個方法用於讀取數據,后三個方法用於寫入數據。
上面的方法操作的都是無符號整型,如果我們要操作有符號整型的時候怎么辦呢?很簡單,強制轉換就可以了,比如這樣:
func PutInt32(b []byte, v int32) { binary.BigEndian.PutUint32(b, uint32(v)) }
BigEndian 和 LittleEndian 實現了 ByteOrder 接口
//BigEndian is the big-endian implementation of ByteOrder. var BigEndian bigEndian //LittleEndian is the little-endian implementation of ByteOrder. var LittleEndian littleEndian
舉個例子,把固定長度的數字寫入字節切片 (byte slice),然后從字節切片中讀取到並賦值給一個變量:
// write v := uint32(500) buf := make([]byte, 4) binary.BigEndian.PutUint32(buf, v) // read x := binary.BigEndian.Uint32(buf)
在這里,需要注意的是使用 put 寫時要保證足夠的切片長度,另外如果從流 (stream) 讀取時要使用 io.ReadFull 確保讀取的是原始字節,而不是使用特定的 read Buffer 編碼處理過的字節。
go處理大端序和小端序的方式:
package main import ( "encoding/binary" "fmt" "unsafe" ) const INT_SIZE int = int(unsafe.Sizeof(0)) //判斷我們系統中的字節序類型 func systemEdian() { var i int = 0x1 bs := (*[INT_SIZE]byte)(unsafe.Pointer(&i)) if bs[0] == 0 { fmt.Println("system edian is little endian") } else { fmt.Println("system edian is big endian") } } func testBigEndian() { // 0000 0000 0000 0000 0000 0001 1111 1111 var testInt int32 = 256 fmt.Printf("%d use big endian: \n", testInt) var testBytes []byte = make([]byte, 4) binary.BigEndian.PutUint32(testBytes, uint32(testInt)) fmt.Println("int32 to bytes:", testBytes) convInt := binary.BigEndian.Uint32(testBytes) fmt.Printf("bytes to int32: %d\n\n", convInt) } func testLittleEndian() { // 0000 0000 0000 0000 0000 0001 1111 1111 var testInt int32 = 256 fmt.Printf("%d use little endian: \n", testInt) var testBytes []byte = make([]byte, 4) binary.LittleEndian.PutUint32(testBytes, uint32(testInt)) fmt.Println("int32 to bytes:", testBytes) convInt := binary.LittleEndian.Uint32(testBytes) fmt.Printf("bytes to int32: %d\n\n", convInt) } func main() { systemEdian() fmt.Println("") testBigEndian() testLittleEndian() }
Go 處理固定長度流 (stream processing)
binary package 提供了內置的讀寫固定長度值的流 (stream):
func Read(r io.Reader, order ByteOrder, data interface{}) error func Write(w io.Writer, order ByteOrder, data interface{}) error
Read 通過指定類型的字節序把字節解碼 (decode) 到 data 變量中。解碼布爾類型時,0 字節 (也就是 []byte{0x00}) 為 false, 其他都為 true
package main import ( "bytes" "encoding/binary" "fmt" ) func main() { var( piVar float64 boolVar bool ) piByte := []byte{0x18, 0x2d, 0x44, 0x54, 0xfb, 0x21, 0x09, 0x40} boolByte := []byte{0x00} piBuffer := bytes.NewReader(piByte) boolBuffer := bytes.NewReader(boolByte) binary.Read(piBuffer, binary.LittleEndian, &piVar) binary.Read(boolBuffer, binary.LittleEndian, & boolByte) fmt.Println("pi", piVar) // pi 3.141592653589793 fmt.Println("bool", boolVar) // bool false }
Write 是 Read 的逆過程,直接看例子比較直觀:
package main import ( "bytes" "encoding/binary" "fmt" "math" ) func main() { buf := new(bytes.Buffer) var pi float64 = math.Pi err := binary.Write(buf, binary.LittleEndian, pi) if err != nil { fmt.Println("binary.Write failed:", err) } fmt.Printf("% x", buf.Bytes()) // 18 2d 44 54 fb 21 09 40 }
在實際編碼中,面對復雜的數據結構,可考慮使用更標准化高效的協議,比如 Protocol Buffer。
可變長度編碼 Variable-length encoding
固定長度編碼對存儲空間的占用不靈活,比如一個 int64 類型范圍內的值,當值較小時就會產生比較多的 0 字節無效位,直至達到 64 位。使用可變長度編碼可限制這種空間浪費。
原理
可變長度編碼理想情況下值小的數字占用的空間比值大的數字少,有多種實現方案,Go Binary 實現方式和 protocol buffer encoding 一致,具體原理如下:
每個字節的首位存放一個標識位,用以表明是否還有跟多字節要讀取及剩下的七位是否真正存儲數據。標識位分別為 0 和 1
1 表示還要繼續讀取該字節后面的字節
0 表示停止讀取該字節后面的字節
一旦所有讀取完所有的字節,每個字節串聯的結果就是最后的值。舉例說明:數字 53 用二進制表示為 110101 ,需要六位存儲,除了標識位還剩余七位,所以在標識位后補 0 湊夠七位,最終結果為 00110101。標識位 0 表明所在字節后面沒有字節可讀了,標識位后面的 0110101 保存了值。
再來一個大點的數字舉例,1732 二進制使用 11011000100 表示,實際上只需使用 11 位的空間存儲,除了標識位每個字節只能保存 7 位,所以數字 1732 需要兩個字節存儲。第一個字節使用 1 表示所在字節后面還有字節,第二個字節使用 0 表示所在字節后面沒有字節,最終結果為:10001101 01000100
go處理可變長度的字節序
函數 putVarint() 和 putUvarint() 把可變長值寫到內存字節切片中
func PutVarint(buf []byte, x int64) int func PutUvarint(buf []byte, x uint64) int
這兩個函數把 x 編碼到 buf 中並返回寫入 buf 中字節的長度,如果 buf 初始化長度過小(比 x 還要小)函數就會 panic , 建議使用 binary.MaxVarintLen64 常量確保出現 panic 的情況。
package main import ( "encoding/binary" "fmt" ) func main() { buf := make([]byte, binary.MaxVarintLen64) for _, x := range []int64{-65, 1, 2, 127, 128, 255, 256} { n := binary.PutVarint(buf, x) fmt.Print(x, "輸出的可變長度為:", n, ",十六進制為:") fmt.Printf("%x\n", buf[:n]) } }
-65輸出的可變長度為:2,十六進制為:8101 1輸出的可變長度為:1,十六進制為:02 2輸出的可變長度為:1,十六進制為:04 127輸出的可變長度為:2,十六進制為:fe01 128輸出的可變長度為:2,十六進制為:8002 255輸出的可變長度為:2,十六進制為:fe03 256輸出的可變長度為:2,十六進制為:8004
函數 Varint() 和 Uvarint() 把字節碼轉為十進制。
func Varint(buf []byte) (int64, int) func Uvarint(buf []byte) (uint64, int) package main import ( "encoding/binary" "fmt" ) func main() { inputs := [][]byte{ []byte{0x81, 0x01}, []byte{0x7f}, []byte{0x03}, []byte{0x01}, []byte{0x00}, []byte{0x02}, []byte{0x04}, []byte{0x7e}, []byte{0x80, 0x01}, } for _, b := range inputs { x, n := binary.Varint(b) if n != len(b) { fmt.Println("Varint did not consume all of in") } fmt.Println(x) // -65,-64,-2,-1,0,1,2,63,64, } }
go處理可變長度字節流數據 Decoding from a byte stream
binary 包提供了兩個函數從字節流中讀取到可變長度值。
func ReadVarint(r io.ByteReader) (int64, error) func ReadUvarint(r io.ByteReader) (uint64, error)
總結
二進制協議 (Binary protocol) 高效地在底層處理數據通信,字節序決定字節輸出的順序、通過可變長度編碼壓縮數據存儲空間。理解了 Encoding/binary 庫之后,我們可以繼續深入理解當前一些主流的二進制協議。
全文整理於:
字節序及 Go encoding/binary 庫:https://zhuanlan.zhihu.com/p/35326716、https://www.jianshu.com/p/1deed9012440