字節序
字節序就是多字節數據類型 (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
