文章目录
- [34 - Go 二进制处理(编码/解码)深度解析](#34 - Go 二进制处理(编码/解码)深度解析)
- 为什么需要二进制处理
- 核心概念
-
- 什么是二进制编码
- 二进制处理的核心问题
- [Go 为什么提供 encoding/binary](#Go 为什么提供 encoding/binary)
- [Go 二进制处理核心模块](#Go 二进制处理核心模块)
- 最简单可运行示例
- [int 转二进制](#int 转二进制)
- 大端序与小端序
-
- [大端序(Big Endian)](#大端序(Big Endian))
- [小端序(Little Endian)](#小端序(Little Endian))
- [Go 如何指定字节序](#Go 如何指定字节序)
- 思考点
- 进阶示例:结构体编码
- 进阶示例:自定义协议
- 进阶示例:文件二进制解析
- 常见错误与坑(重点)
- [坑一:struct 对齐问题](#坑一:struct 对齐问题)
- 坑二:字节序不一致
- [坑三:binary.Read 读不满](#坑三:binary.Read 读不满)
- 底层原理解析(核心)
-
- [binary.Write 到底做了什么](#binary.Write 到底做了什么)
- 为什么使用反射
- [为什么 binary 不支持 string](#为什么 binary 不支持 string)
- [binary.Size 原理](#binary.Size 原理)
- 对比与扩展
- 最佳实践
-
- 协议一定明确字节序
- [不要直接传 struct](#不要直接传 struct)
- 网络读取必须考虑半包
- 协议必须有边界
- 高频场景避免反射
- 思考与升华
- 点睛总结
- 总结
34 - Go 二进制处理(编码/解码)深度解析
在 Go 开发中,很多开发者第一次接触"二进制处理",往往是在:
- TCP 网络协议
- 文件格式解析
- 数据序列化
- 硬件通信
- MySQL / Redis 协议分析
- protobuf / msgpack / gob
- 性能优化
等场景。
但很多人对它的理解,还停留在:
"把 int 转成 []byte"
实际上,Go 的二进制处理,本质上是在解决:
"如何以最紧凑、最稳定、跨平台的方式表示数据"。
这篇文章,我们就聊聊 Go 中的二进制编码与解码。
为什么需要二进制处理
先看一个问题:
假设我们要通过 TCP 发送一个用户 ID。
go
userID := 10086
如果直接发送字符串:
text
"10086"
那么实际发送的是:
text
31 30 30 38 36
长度为 5 字节(ASCII 码表)。
但如果使用二进制:
text
00 00 27 66
只需要 4 字节。
而且:
- 更快
- 更省空间
- 更适合机器解析
- 不依赖字符编码
这也是所有底层协议都大量使用二进制的原因。
核心概念
什么是二进制编码
本质上:
二进制编码,就是把"内存中的数据"转换成"字节流"。
例如:
| 数据类型 | 内存值 | 二进制表示 |
|---|---|---|
| int32 | 10 | 00 00 00 0A |
| bool | true | 01 |
| float32 | 3.14 | 40 48 F5 C3 |
而解码:
就是反向恢复。
二进制处理的核心问题
真正难的不是"转字节"。
而是:
- 字节顺序(大端 / 小端)
- 类型长度
- 数据边界
- 对齐问题
- 跨平台一致性
- 协议兼容性
这也是 Go encoding/binary 存在的核心意义。
Go 为什么提供 encoding/binary
Go 标准库没有选择"直接暴露内存"。
而是:
go
encoding/binary
统一处理:
- 整数编码
- 浮点编码
- struct 编码
- 字节序转换
本质上:
Go 想保证不同机器上的数据一致性。
因为:
- x86 是小端
- 网络协议通常是大端
- ARM 可能不同
如果不统一,跨平台通信会直接混乱。
Go 二进制处理核心模块
Go 中常见的二进制相关包:
| 包 | 作用 |
|---|---|
| encoding/binary | 基础二进制编码 |
| bytes | 字节缓冲 |
| bufio | 缓冲 IO |
| encoding/hex | 十六进制 |
| encoding/base64 | Base64 |
| math/bits | 位运算 |
| unsafe | 底层内存操作 |
其中最核心的是:
go
encoding/binary
最简单可运行示例
int 转二进制
go
package main
import (
"bytes"
"encoding/binary"
"fmt"
)
func main() {
var number uint32 = 1024
buffer := new(bytes.Buffer) // 创建一个新的缓冲器
err := binary.Write(buffer, binary.BigEndian, number) // 编码
if err != nil {
panic(err)
}
fmt.Printf("编码结果: %v\n", buffer.Bytes()) // 输出编码结果
var decoded uint32 // 创建一个变量用于解码
err = binary.Read(buffer, binary.BigEndian, &decoded) // 解码
if err != nil {
panic(err)
}
fmt.Printf("解码结果: %v\n", decoded)
}
输出:
text
编码结果: [0 0 4 0]
解码结果: 1024
这里发生了什么
go
binary.Write()
实际上做了:
text
uint32(1024)
↓
0x00000400
↓
[]byte{0x00,0x00,0x04,0x00}
小结
这里最关键的是:
go
binary.BigEndian
它决定:
高位字节放前面还是后面。
这会直接影响:
- 网络协议
- 文件格式
- 数据兼容性
大端序与小端序
这是二进制处理中最容易踩坑的知识点。
大端序(Big Endian)
高位在前。
例如:
text
0x12345678
存储为:
text
12 34 56 78
网络协议基本都使用它。
因此:
go
binary.BigEndian
也叫:
text
Network Byte Order
小端序(Little Endian)
低位在前:
text
78 56 34 12
x86 CPU 默认就是小端。
Go 如何指定字节序
go
binary.BigEndian
binary.LittleEndian
例如:
go
binary.Write(buf, binary.LittleEndian, num)
思考点
为什么 CPU 喜欢小端?
text
因为小端序在"低位扩展"时更高效。
例如一个 uint8:
0x11
在小端内存中:
11
如果扩展成 uint16:
0x0011
小端布局会变成:
11 00
原来的低位数据位置完全不用动,
CPU 只需要在高位补 00 即可。
但如果是大端序:
00 11
原来的 11 需要移动位置。
这意味着:
- 更多数据搬运
- 更复杂的硬件逻辑
- 更高的处理成本
因此:
CPU 在做整数扩展、加法、位运算时,
小端序会更自然、更高效。
所以大多数 CPU(如 x86)都采用小端序。
进阶示例:结构体编码
真实开发里,最常见的是 struct 编码。
示例:协议包编码
go
package main
import (
"bytes"
"encoding/binary"
"fmt"
)
type Message struct {
ID uint32
Status uint16
}
func main() {
msg := Message{
ID: 1001,
Status: 200,
}
buf := new(bytes.Buffer)
err := binary.Write(buf, binary.BigEndian, msg)
if err != nil {
panic(err)
}
fmt.Printf("编码后: %x\n", buf.Bytes())
var decoded Message
err = binary.Read(buf, binary.BigEndian, &decoded)
if err != nil {
panic(err)
}
fmt.Printf("解码后: %+v\n", decoded)
}
输出:
text
编码后: 000003e900c8
解码后: {ID:1001 Status:200}
小结
这里已经非常接近:
- TCP 私有协议
- 游戏协议
- RPC 协议
底层实现。
进阶示例:自定义协议
真实场景:
TCP 粘包。
很多协议会这样设计:
text
| 长度 | 数据 |
示例:长度头协议
go
package main
import (
"bytes"
"encoding/binary"
"fmt"
)
// 编码和解码协议包
// 编码协议包
func encode(message string) []byte {
body := []byte(message)
buf := new(bytes.Buffer)
binary.Write(buf, binary.BigEndian, uint32(len(body))) // 写入长度
buf.Write(body) // 写入内容
return buf.Bytes()
}
// 解码协议包
func decode(data []byte) string {
buf := bytes.NewReader(data)
var length uint32
binary.Read(buf, binary.BigEndian, &length) // 读取长度
body := make([]byte, length) // 根据长度读取内容
buf.Read(body)
return string(body)
}
func main() {
packet := encode("hello")
fmt.Printf("协议包: %x\n", packet)
msg := decode(packet)
fmt.Println("解码:", msg)
}
输出:
text
协议包: 0000000568656c6c6f
解码: hello
为什么协议都喜欢这样设计
因为 TCP 是:
text
字节流
没有消息边界。
因此:
text
长度 + 内容
是最稳定的协议设计。
点睛总结
很多人以为:
TCP 传输的是"消息"。
实际上:
TCP 只负责传输"字节流"。
消息边界,需要应用层自己维护。
进阶示例:文件二进制解析
假设读取一个简单文件头:
text
| magic | version | data_length |
go
package main
import (
"bytes"
"encoding/binary"
"fmt"
)
type Header struct {
Magic [4]byte
Version uint16
Length uint32
}
func main() {
raw := []byte{
'G', 'O', 'F', 'S', // Magic
0x00, 0x01, // Version
0x00, 0x00, 0x00, 0x64, // Length
}
var header Header
err := binary.Read(
bytes.NewReader(raw), // Reader
binary.BigEndian,
&header,
)
if err != nil {
panic(err)
}
fmt.Printf("%+v\n", header)
}
输出:
text
{Magic:[71 79 70 83] Version:1 Length:100}
常见错误与坑(重点)
坑一:struct 对齐问题
这是很多人最容易忽略的高危问题。
错误代码
go
type Data struct {
A uint8
B uint32
}
很多人以为:
text
1 + 4 = 5 字节
实际上:
go
fmt.Println(binary.Size(Data{}))
结果:
text
8
为什么会这样
因为 CPU 需要:
text
内存对齐
Go 会自动填充 padding:
text
A -> 1 byte
padding-> 3 byte
B -> 4 byte
总共:
text
8 byte
正确写法
如果协议要求严格:
go
type Data struct {
B uint32
A uint8
}
变成:
text
4 + 1 + 3(padding)
或者:
手动编码字段。
小结
协议开发里:
不要迷信 struct 内存布局。
尤其:
- 网络协议
- 二进制文件
- 跨语言通信
必须严格控制字节结构。
坑二:字节序不一致
错误代码
发送端:
go
binary.Write(buf, binary.LittleEndian, num)
接收端:
go
binary.Read(buf, binary.BigEndian, &num)
后果
数据直接错乱。
例如:
text
0x12345678
可能变成:
text
0x78563412
底层原因
因为:
text
字节排列顺序不同
不是 Go 的问题。
而是:
二进制本身没有"方向感"。
正确做法
协议必须明确:
text
统一字节序
一般推荐:
go
binary.BigEndian
因为网络协议通用。
坑三:binary.Read 读不满
错误代码
go
conn.Read(buf)
binary.Read(...)
为什么危险
TCP 不保证:
text
一次读满
可能:
text
只收到半包
正确写法
使用:
go
io.ReadFull()
例如:
go
_, err := io.ReadFull(conn, buf)
小结
网络开发里:
"读到数据" ≠ "读完整数据"。
这是很多线上 bug 根源。
底层原理解析(核心)
binary.Write 到底做了什么
核心流程:
text
数据
↓
反射解析类型
↓
根据字节序拆分
↓
写入 io.Writer
源码核心思想:
go
func Write(w io.Writer, order ByteOrder, data interface{})
为什么使用:
go
io.Writer
?
因为 Go 希望:
二进制处理与 IO 解耦。
这样:
- 文件
- 网络
- 内存
- buffer
都能统一处理。
这是 Go 非常经典的设计。
为什么使用反射
因为:
go
binary.Write()
支持:
- int
- float
- array
- struct
需要动态解析类型。
因此:
go
reflect
不可避免。
为什么 binary 不支持 string
因为:
text
string 没有边界定义
例如:
text
hello
到底:
- 固定长度?
- UTF-8?
- null 结尾?
- 带长度头?
Go 无法猜测。
因此:
string 必须由协议自己定义。
这其实是非常合理的设计。
binary.Size 原理
go
binary.Size(v)
本质:
text
递归计算字段长度
但注意:
它会计算:
text
padding
因此:
不一定等于协议长度。
对比与扩展
binary vs json
| 对比 | binary | json |
|---|---|---|
| 可读性 | 差 | 好 |
| 性能 | 高 | 较低 |
| 空间占用 | 小 | 大 |
| 跨语言 | 好 | 好 |
| 调试 | 困难 | 简单 |
binary 适合
- 游戏协议
- RPC
- 高性能服务
- 文件格式
- IoT
json 适合
- Web API
- 配置文件
- 调试友好场景
binary vs protobuf
很多人会混淆。
binary
更底层:
text
你自己定义协议
protobuf
属于:
text
结构化序列化协议
自动:
- 编码
- 解码
- 版本兼容
protobuf 的本质
其实也是:
text
二进制编码
只是:
帮你自动管理协议结构。
最佳实践
协议一定明确字节序
推荐统一:
go
binary.BigEndian // 网络协议通用
避免跨平台问题。
不要直接传 struct
尤其:
go
unsafe.Pointer
直接转字节。
风险极高:
- padding
- 对齐
- Go 版本差异
- 架构差异
网络读取必须考虑半包
牢记:
go
io.ReadFull() // 确保读满
协议必须有边界
推荐:
text
长度 + 数据
不要依赖:
- 特殊字符
- EOF
- 分隔符
高频场景避免反射
binary.Write() 很方便。
但高性能场景:
建议:
go
手动编码
例如:
go
buf[0] = byte(num >> 24)
性能更高。
思考与升华
为什么所有协议最后都会回归"字节流"
因为:
计算机最终只认识 bit。
无论:
- JSON
- protobuf
- gRPC
- MySQL
- Redis
最后都会变成:
text
0101010101
区别只是:
如何组织这些 bit。
一个极简协议实现思路
例如:
text
| version | type | length | body |
编码:
go
write(version)
write(type)
write(length)
write(body)
解码:
go
read(version)
read(type)
read(length)
read(body)
你会发现:
所有协议,本质上都是"状态机 + 字节流解析"。
点睛总结
二进制处理看似只是:
text
数据 ↔ 字节
但真正核心的是:
"如何让不同机器、不同语言、不同系统,对同一份数据达成一致理解"。
而这:
才是所有协议设计、序列化框架、网络通信的真正基础。
总结
Go 的二进制处理,本质上是:
用稳定、紧凑、可预测的方式组织数据。
掌握它之后:
你会真正理解:
- TCP 协议
- RPC 框架
- protobuf
- 数据序列化
- 文件格式
- 网络通信
底层到底在做什么。
很多时候:
高级框架,只是在帮你"自动管理字节流"。