Bootstrap

大端,小端,go

什么是大端序和小端序?
如何快速区分大端序和小端序?
为什么会有大端序和小端序?
go如何处理二进制序列化?

让我们来聊聊大端序小端序,以及go语言是如何处理的。主要是回答上面4个问题。

大端序和小端序的概念大家都知道:

  • 大端序是数据的低字节放在高地址,高字节放在低地址。
  • 小端序是数据的低字节放在低地址,高字节放在高地址。

它其实代表了一种数据处理的顺序,在数据传输和存储中,我们先处理高字节,那就是大端序,先处理低字节,就是小端序。

为什么在我们熟知的定义中,会出现字节和地址的对应呢?它其实是对数据存储场景的一种说明。在数据存储中,数据一般从低地址向高地址存储。如果是大端序,我们先处理(存储)高字节,它被存储到了低地址;如果是小端序,我们先处理(存储)低字节,它被存储到了低地址。于是就有了我们熟知的对于大小端的定义,本质上其实还是数据处理顺序的问题,和高低地址对应起来反而让人疑惑,也不方便记忆。这个概念不会经常出现,很多时候是过段时间再有人问你什么是大小端序的时候,你会突然发现自己也分不清了。

所有该如何快速区分大端和小端呢?

关键是看首先处理的是高字节还是低字节,高字节=大,低字节=小。

一个更一般的理解是:

  • 大端序是首先处理高字节
  • 小端序是首先处理低字节

在网络传输中,如果首先传输高字节就是大端序,首先传输低字节就是小端序。用新的理解方式,任何场景都不会混淆了。


为什么会有大端序和小端序呢?

很多人说是历史原因,目前大多数CPU使用的是小端序,而网络传输和文件存储则使用了大端序。其实用什么序都可以,真正重要的是为什么用这个序,有什么好处。

小端序的好处是利于计算,比如做两个数的加法,从低位向高位加肯定是最方便无脑的。如果从高位开始加就要面临数据对齐的问题。再比如数据截断,把一个64位整数截断为32位,如果是小端序只要舍弃高位32位就行了,但是如果是大端序还需要移动低32位到高32位的位置。因此小端序对CPU计算是比较友好的。

大端序和人的阅读习惯一致,方便数据比较,相比于小端序能更快得知结果。另一个重要的原因是读取压缩编码的比特流时,大端序是非常有优势的。网络传输的字节常常是经过压缩的,比如00001011不会传输完整字节,可能是压缩后的1011(只是举例,实际不可能这样)。如果是大端序,比特按照1011的顺序到达,假设有个readBit的函数可以读取一个比特,那么要将1011读取出来,可以通过下面的算法。

func read1011() (r int) {
	for i:=0; i<4; i++ {
		r <<= 1
		r |= readBit()
	}
	return
}

如果是小端序,比特按照1101的顺序到达,想要正确读出11就要复杂很多了。

还有一个原因是和数据压缩有关系,压缩算法通常会选择去掉最高有效位前面的0,如果是低位在前,那不不管是0还是1,它都是有效位。具体可以看看指数哥伦布编码,会对此深有体会。


最后来看看go如何处理大端序和小端序。go语言关于大小端的处理都在encoding/binary包中,有两个变量BigEndianLittleEndian分别用来处理大端数据和小端数据。这两货其实是ByteOrder接口的实例,接口源代码如下:

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

基本操作一目了然,它将字节序列转化为无符号整数或者将无符号整数转化为字节序列。注意,String函数只是为了实现Stringer接口,和序列化无关。

让我们用一个例子来理解"首先处理"这个概念。

func main() {
	var (
		ua uint16 = 258
		ba        = make([]byte, 2)
		bb        = make([]byte, 2)
	)
	binary.LittleEndian.PutUint16(ba, ua)
	binary.BigEndian.PutUint16(bb, ua)
	fmt.Printf("%x\n", ba)
	fmt.Printf("%x\n", bb)
}

/*-------- output ------------
0201
0102
*/

258用十六进制表示为0x0102。在小端序下,先处理低字节,也就是0x02,而"处理"就是append到切片中,于是第一个结果我们看到了0201。在大端序下,先处理高字节,也就是0x01,并将它append到切片,于是我们看到了0102的结果。

让我们以uint16的实现为例来看看具体实现。

type littleEndian struct{}

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 (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)
}

type bigEndian struct{}

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
}

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)
}

可见实现并不复杂。唯一需要注意的是无论是用来存放结果的切片还是接收数据的切片都必须有足够的长度。

除了上面无符号整数类型的转换,binary包还提供了readwrite函数用来处理字节流。但是依然只能处理数字、bool、以及他们的切片。

func main() {
	var pi = math.Pi
	var buf bytes.Buffer
	binary.Write(&buf, binary.BigEndian, pi)
	fmt.Println(buf.Bytes())

	var a float64
	binary.Read(&buf, binary.BigEndian, &a)
	fmt.Println(a)
}

/* --------- output -----------
[64 9 33 251 84 68 45 24]
3.141592653589793
*/

LittileEndianBigEndianReadWrite能处理的类型很有限,如果你想用它来处理字符串,你将得到空。其实字符串就是字节数组,并不需要特殊的处理。可以处理类型如下:

  • bool []bool
  • int8 uint8 []int8 []uint8
  • int16 uint16 []int16 []uint16
  • int32 uint32 []int32 []uint32
  • int64 uint64 []int64 []uint64
  • float32 []float32
  • float64 []float64
  • 以上类型组成的struct

注意:> 以上带[]的都是数组类型,read write是不支持切片类型的。

以上都是固定长度编码。Go还支持变长编码,其原理是用每个字节的最高位标识其后是否还有字节需要读取。1标识需要继续读取后面的字节,0表示只读取到当前字节结束。反序列化就是将每个字节的后7位拼起来,低字节在前,高字节在后。

举个例子,259,二进制表示为‭0001 0000 0011‬,由于每个字节只能存储7位有效数据,按7位进行拆分为‭00010 0000011,首先处理低7位,由于后面还有数据,因此最高位为1,编码后为1000 0011,下个字节是最后一个字节,因此最高位为0,编码后为0000 0010

Go语言处理变成编码的函数如下:

  • PutUvarint Uvarint :编码/解码uint64类型
  • PutVarint Varint :编码/解码int64类型
  • ReadUvarint ReadVarint :从字节流读取uint/int64变量

变长编码也需要提前知道存放结果的字节数组的长度,go定义了以下常量可供使用。

/* varint.go */
const (
	MaxVarintLen16 = 3
	MaxVarintLen32 = 5
	MaxVarintLen64 = 10
)

示例:

func main() {
	var a uint64 = 259
	var buf = make([]byte, binary.MaxVarintLen16)
	binary.PutUvarint(buf, a)
	fmt.Printf("%b\n", buf)

	b, _ := binary.Uvarint(buf)
	fmt.Println(b)

	r := bytes.NewBuffer(buf)
	c, _ := binary.ReadUvarint(r)
	fmt.Println(c)
}

需要注意的是,在解码过程中,读取动作使用的是ReadFull函数,它的实现如下:

/* io.go */
func ReadFull(r Reader, buf []byte) (n int, err error) {
	return ReadAtLeast(r, buf, len(buf))
}

func ReadAtLeast(r Reader, buf []byte, min int) (n int, err error) {
	if len(buf) < min {
		return 0, ErrShortBuffer
	}
	for n < min && err == nil {
		var nn int
		nn, err = r.Read(buf[n:])
		n += nn
	}
	if n >= min {
		err = nil
	} else if n > 0 && err == EOF {
		err = ErrUnexpectedEOF
	}
	return
}
;