Bom和字节顺序的讲解

282 阅读3分钟

字节顺序

我们知道,1 个字节是 8 个比特,刚好表示 2 个十六进制数。因此,字符 /x00 转换成比特是 0000 0000,表示一个空字节;0x0f 表示一个比特序列是 0000 1111 的字节。计算机可以使用两种主要的字节顺序:大端序(Big-Endian)和小端序(Little-Endian)。

  • 大端序(Big-Endian): 在大端序中,多字节数据的高位字节存储在低地址,低位字节存储在高地址。例如,整数值 0x12345678 在大端序中的存储方式是:0x12 0x34 0x56 0x78。
  • 小端序(Little-Endian): 在小端序中,多字节数据的低位字节存储在低地址,高位字节存储在高地址。例如,整数值 0x12345678 在小端序中的存储方式是:0x78 0x56 0x34 0x12。

那么问题来了:若一个字符占用了 2 个字节或者更多,它的比特序列是怎样排列的呢?比如字符 0x13ef 的比特序列是多少?

你可能会想,当然是 0001 0011 1110 1111 啦。但实际上未必。这种符合人类直觉的、从高位到低位的顺序,被称为大端序列(big-endian),在大部分的网络序列中是这样的,在一些处理器上是这样的,但在另一些情况下却是反过来的,采用低位优先的顺序,也就是 1110 1111 0001 0011,这样的字节序被称作小端序列(little-endian)。PDP-11、x86 处理器就采用了小端序列,ARM 则是可配置的字节序列。

为什么会有这么反直觉的序列呢?

据说是因为早期的工程师考虑数字的处理从低位开始计算比较快,因此把低位存储在前面。但如今这个理由早已不成立了,它更多是一种偏好问题,就像格列佛游记小人国争论鸡蛋应该是从大端剥开还是从小端剥开一样,一直没有一个统一的定论。

字节顺序标记 BOM

在使用 ASCII 编码的时候,因为每个字符都可以用一个字节表示,所以不存在先传高位或先传低位的问题。但对于 UTF-8、UTF-16 等多字节编码来说,有时需要一种机制来标识出一段数据采用的是哪种字节序列,以便不同程序之间能够正确的传输、解码信息。

BOM(byte order mark,字节顺序标记)就是一种这样的机制,它是 Unicode 字符集中的一个专用字符,码位 U+FEFF。BOM 一般出现在一个字节流的开头,用来标识该字节流的字节序,是高位在前还是低位在前。在 W3C 指定的 HTML5 标准中,出于兼容性考虑,BOM 是最高优先级的。

但 BOM 的使用是可选的,因为它是一个 Unicode 字符,可能会影响一些使用 ASCII 编码读取第一个字符的程序。

实例:使用 Go 读取一个 UTF-16LE 文件

在 macOS 的终端使用 file -I 可以查看一个文件的编码:

$ file -I foo.txt
foo.txt: text/plain; charset=utf-16le

以上输出表示 foo.txt 是一个采用 UTF-16 小端序列编码的文件。使用 Golang 读取该文件,按行打印:

file, err := os.Open("foo.txt")
if err != nil {
    log.Fatal(err)
}
defer file.Close()

// 手动生成一个解码器,指定编码为 UTF-16,字节序列为小端序列
utf16le := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM)
utf16leOverrided := unicode.BOMOverride(utf16le.NewDecoder())

// 使用该解码器解码文件
r := transform.NewReader(file, utf16leOverrided)))

scanner := bufio.NewScanner(r)
for scanner.Scan() {
    fmt.Println(scanner.Text())
}
if err := scanner.Err(); err != nil {
    log.Fatal(err)
}