字节序及 Go encoding/binary 库

最近工作之余在研究比特币 Go 实现源码,当看到 wire 部分时,发现测试代码里面初始化区块头整型字段十六进制的字节序都反转存储。

nonce := uint32(123123) // 0x1e0f3
pver := uint32(70001)

// baseBlockHdr is used in the various tests as a baseline BlockHeader.
bits := uint32(0x1d00ffff)
baseBlockHdr := &BlockHeader{
  Version:    1,
  PrevBlock:  mainNetGenesisHash,
  MerkleRoot: mainNetGenesisMerkleRoot,
  Timestamp:  time.Unix(0x495fab29, 0), // 2009-01-03 12:15:05 -0600 CST
  Bits:       bits,
  Nonce:      nonce,
}

// baseBlockHdrEncoded is the wire encoded bytes of baseBlockHdr.
baseBlockHdrEncoded := []byte{
  0x01, 0x00, 0x00, 0x00, // Version 1
  0x6f, 0xe2, 0x8c, 0x0a, 0xb6, 0xf1, 0xb3, 0x72,
  0xc1, 0xa6, 0xa2, 0x46, 0xae, 0x63, 0xf7, 0x4f,
  0x93, 0x1e, 0x83, 0x65, 0xe1, 0x5a, 0x08, 0x9c,
  0x68, 0xd6, 0x19, 0x00, 0x00, 0x00, 0x00, 0x00, // PrevBlock
  0x3b, 0xa3, 0xed, 0xfd, 0x7a, 0x7b, 0x12, 0xb2,
  0x7a, 0xc7, 0x2c, 0x3e, 0x67, 0x76, 0x8f, 0x61,
  0x7f, 0xc8, 0x1b, 0xc3, 0x88, 0x8a, 0x51, 0x32,
  0x3a, 0x9f, 0xb8, 0xaa, 0x4b, 0x1e, 0x5e, 0x4a, // MerkleRoot
  0x29, 0xab, 0x5f, 0x49, // Timestamp
  0xff, 0xff, 0x00, 0x1d, // Bits
  0xf3, 0xe0, 0x01, 0x00, // Nonce
}

直接对字节底层操作显然是最高效的做法。Go 语言的 encoding/binary 包构建二进制协议非常有效。

二进制协议

基于文本类型的协议(比如 JSON)和二进制协议都是字节通信,他们不同点在于他们使用哪种类型的字节和如何组织这些字节。

文本协议只适用于 ASCII 或 Unicode 编码可打印的字符通信。例如 "26" 使用 "2" 和 "6" 的 utf 编码的字符串表示,这种方式方便我们读,但对于计算机效率较低。

在二进制协议中,同样数字 "26" 可使用一个字节 0x1A 十六进制表示,减少了一半的存储空间且原始的字节格式能够被计算机直接识别而不需解析。当一个数字足够大的时候,性能优势就会明显体现。

计算机字节序和网络字节序

字节序 就是多字节数据类型 (int, float 等)在内存中的存储顺序。可分为大端序,低地址端存放高位字节;小端序与之相反,低地址端存放低位字节。

go_binary.png

在计算机内部,小端序被广泛应用于现代性 CPU 内部存储数据;而在其他场景譬如网络传输和文件存储使用大端序。

使用小端序时不移动字节就能改变 number 占内存的大小而不需内存地址起始位。比如我想把四字节的 int32 类型的整型转变为八字节的 int64 整型,只需在小端序末端加零即可。

44 33 22 11
44 33 22 11 00 00 00 00

上述扩展或缩小整型变量操作在编译器层面非常有用,但在网络协议层非也。

在网络协议层操作二进制数字时约定使用大端序,大端序是网络字节传输采用的方式。因为大端序最高有效字节排在首位(低地址端存放高位字节),能够按照字典排序,所以我们能够比较二进制编码后数字的每个字节。

fmt.Println(bytes.Equal([]byte("Go"), []byte("Go")))  // true

固定长度编码 Fixed-length encoding

Go 中有多种类型的整型, int8, int16, int32 和 int64 ,分别使用 1, 3, 4, 8 个字节表示,我们称之为固定长度类型 (fixed-length types)。

处理字节流和内存中的字节切片方式不一样,编码 (Encoding) 和解码 (decoding) 二进制数据过程也不一样。

编码字节切片 (Encoding to byte slices)

使用 ByteOrder 从字节切片中读写二进制数据。

type ByteOrder interface {
        Uint16([]byte) uint16
        Uint32([]byte) uint32
        Uint64([]byte) uint64
        PutUint16([]byte, uint16)
        PutUint32([]byte, uint32)
        PutUint64([]byte, uint64)
        String() string
}

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 编码处理过的字节。

流处理 (stream processing)

binary package 提供了内置的读写固定长度值的流 (stream):

  • Read() func Read(r io.Reader, order ByteOrder, data interface{}) error
  • Write() 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

编码到字节切片 Encoding to byte slices

函数 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,
    }
}

解码字节流数据 Decoding from a byte stream

binary 包提供了两个函数从字节流中读取到可变长度值。

func ReadVarint(r io.ByteReader) (int64, error)
func ReadUvarint(r io.ByteReader) (uint64, error)

总结

二进制协议 (Binary protocol) 高效地在底层处理数据通信,字节序决定字节输出的顺序、通过可变长度编码压缩数据存储空间。理解了 Encoding/binary 库之后,我们可以继续深入理解当前一些主流的二进制协议。

推荐阅读

0 条评论
您想说点什么吗?