引言
日志里突然冒出一串 aGVsbG8=,二进制协议抓包回来全是 \x00\xff,xxd 的转储看不懂——字节级调试是每个工程师迟早要跨的门槛。本文把这块"工具箱"一次补齐:先建立数制直觉(为什么 0xFF 是 255、位运算长什么样),再拆透两种最常见的编码——Hex(十六进制)与 Base64(各自解决什么问题、表格怎么来的、URL-safe 变体),接着讲文本与字节的关系(ASCII/UTF-8 在字节层面到底什么关系),最后给 xxd/od/hexdump 的实用姿势、magic bytes 识文件、以及调试二进制协议/文件时的实战套路与踩坑。
前置:/unicode-encoding-guide/(字符编码的完整理论)、/serialization-formats-compare/(二进制序列化格式)。网络与文件的字节处理见 [[network]]、[[python]]。
目录
- 1. 数制直觉:为什么 0xFF = 255
- 2. Hex 编码:字节的直接字面
- 3. Base64:把二进制塞进文本
- 4. URL-safe 与 Base64 的变体
- 5. 文本与字节:ASCII、UTF-8 与乱码
- 6. xxd、od 与 hexdump:字节查看器
- 7. magic bytes 与文件类型识别
- 8. 位运算与打包解包
- 9. 实战:调试二进制协议与文件
- 10. 速查表与一句话记忆
- 延伸阅读
1. 数制直觉:为什么 0xFF = 255
字节是 8 个 bit,能表示 0–255。不同数制只是"换个记法":
十进制 255
十六进制 0xFF (FF = 15×16 + 15)
二进制 0b11111111 (8 个 1)
八进制 0377 (3×64 + 7×8 + 7)
为什么工程里常用十六进制:1 个 hex 位 = 4 个 bit——一个字节刚好 2 个 hex 位(FF),把字节串直接可读化,还能对齐内存布局。
十六进制对应表(前 16):
0x0=0 0x1=1 0x2=2 0x3=3 0x4=4 0x5=5 0x6=6 0x7=7
0x8=8 0x9=9 0xA=10 0xB=11 0xC=12 0xD=13 0xE=14 0xF=15
常用魔法数:
0xFF = 255 掩码取低 8 位
0xFFFF = 65535 IPv4 端口上限(2^16)
0x7F = 127 ASCII 上限
0x80 = 128 UTF-8 多字节首字节标志
0xFF 0xD8 JPEG magic
0x50 0x4B 'PK' = ZIP
记忆:一个 hex 位 = 4 bit,一个字节 = 2 hex 位——hex 是"人可读的二进制"。
2. Hex 编码:字节的直接字面
Hex 编码把每个字节写成 2 个 hex 字符——1 字节 → 2 字符,无损、一一对应、不改变数据含义(只是表示法)。
# Python 里
import binascii
data = b"Hi!"
print(data.hex()) # '486921'
print(bytes.fromhex("486921")) # b'Hi!'
# 'H'=0x48, 'i'=0x69, '!'=0x21
Hex 的用武之地:
- 协议调试:看原始字节流(抓包、日志中的 hex dump)
- 哈希/签名展示:sha256 就是 64 个 hex 字符
- 内存/文件布局:对齐查看结构体、二进制格式
- MAC 地址、UUID 的标准文本形态
典型展示:
echo -n "Hi!" | xxd # 00000000: 4869 210a Hi!.
# 偏移 hex 内容 ASCII
注意:hex 字符串是原始数据的 2 倍长度,没有压缩也不适合放进 HTTP 头(太长);要"体积更小地塞进文本",用 Base64。
记忆:Hex = 字节的直译(1:2),调试与哈希展示首选;要"紧凑地塞进文本"再上 Base64。
3. Base64:把二进制塞进文本
Base64 解决的问题:很多通道只接受可打印 ASCII(JSON、URL、邮件、HTML)。二进制字节(含 \x00、控制符、非 ASCII)直接放会乱、会被截断、会触发转义——于是用 64 个安全字符重新表示。
原理:把每 3 个字节(24 bit)切成 4 组 6 bit,每组查表(64 个字符):
64 字符表(标准):
A-Z a-z 0-9 + / → 共 64 个
填充:= (补足到 4 的倍数)
"Hi!" (3 bytes)
0x48 0x69 0x21 = 010010 000110 100100 100001
→ 18 6 36 33 → 查表 S G k h → "SGkh"
import base64
base64.b64encode(b"Hi!") # b'SGkh'
base64.b64decode(b"SGkh") # b'Hi!'
体积账:3 字节 → 4 字符(3/4 比例,含填充约 +33%)——比 hex(1:2)紧凑,但仍比原始大 33%。
典型应用:
- JWT:三段 Base64url 编码(header.payload.signature)
- 邮件附件(MIME):任意二进制转文本
- JSON 里嵌二进制/图片(data URI:data:image/png;base64,...)
- API 鉴权:Basic Auth(base64("user:pass"))
# data URI:小图片直接嵌进 HTML/JSON
import base64
png = open("icon.png", "rb").read()
uri = f"data:image/png;base64,{base64.b64encode(png).decode()}"
记忆:Base64 是"二进制的文本皮",3→4 膨胀 33%;它不加密也不压缩,只是换可打印字符。
4. URL-safe 与 Base64 的变体
标准 Base64 的 + 和 / 在 URL/文件名里不友好(+ 是空格、/ 是路径分隔),于是有 Base64url:
import base64
b64 = base64.urlsafe_b64encode(b"\xff\xee") # 用 - 和 _ 代替 + 和 /
# b'_-4='
Base64 家族速查:
| 变体 | 字符集 | 填充 | 用途 |
|---|---|---|---|
| 标准 Base64 | A-Za-z0-9+/ | = | 邮件、data URI |
| Base64url | A-Za-z0-9-_ | =(可去) | JWT、URL 参数 |
| Base32 | A-Z2-7 | = | 可读性/人工输入(TOTP) |
| Base16(Hex) | 0-9A-F | 无 | 哈希、调试 |
| Base58 | 去 0OIl | 无 | 比特币地址(去歧义) |
Base32 的独特价值:去掉易混淆字符(0/O、1/I)后人类可读、可手输——这就是 TOTP(Google Authenticator)用 Base32 的原因。
去填充注意:JWT 常省略 =(无填充 Base64url)。解码时要能容忍"无填充":
import base64
def b64url_decode(s):
s += '=' * (-len(s) % 4) # 补回填充
return base64.urlsafe_b64decode(s)
一个坑:base64.b64encode 的默认 validate=False 会静默忽略非法字符——调试"解码报错"时先看是不是填充/换行问题。
记忆:Base64url 换掉 +/ 为 -_,是 URL 与 JWT 的标准;Base32 求可读性,Base58 求无歧义——按通道选变体。
5. 文本与字节:ASCII、UTF-8 与乱码
字节≠字符——同一个字节序列在不同编码下读出不同文本,这就是乱码的根源。
ASCII(0x00–0x7F):128 个字符,1 字节 1 字符,高位恒为 0。
UTF-8(今天的事实标准):可变长,ASCII 与单字节兼容,中文 3 字节:
U+0000–007F 1 字节 0xxxxxxx ASCII 原样
U+0080–07FF 2 字节 110xxxxx 10xxxxxx
U+0800–FFFF 3 字节 1110xxxx 10xxxxxx 10xxxxxx ← 中文在此
U+10000+ 4 字节 11110xxx ...
"中".encode("utf-8") # b'\xe4\xb8\xad'(3 字节)
乱码的典型来源:
- 源文件是 GBK/GB2312,读取/解析按 UTF-8 → 全是锟斤拷/��
- 网络请求头 Content-Type 缺 charset → 浏览器猜编码
- 数据库连接未指定 utf8mb4 → 中文截断
- 终端 locale 错 → 显示乱码
字节层调试:看"到底是什么字节",用 xxd/od/Python:
printf '中文' | xxd # e4 b8 ad e6 96 87 → 3+3 字节 UTF-8
printf '中文' | iconv -f UTF-8 -t GBK | xxd # 转 GBK 后字节不同
s = "中文"
print([hex(b) for b in s.encode("utf-8")]) # ['0xe4','0xb8','0xad',...]
记忆:乱码是"同一字节不同解码"的误会——用 xxd 看字节、用 iconv 换编码、用 charset 定契约。理论完整版见 /unicode-encoding-guide/。
6. xxd、od 与 hexdump:字节查看器
三大命令行查看器的定位:
| 工具 | 特点 | 何时用 |
|---|---|---|
xxd | Vim 出品,支持 -r 反向 | 日常查看、快速 hex |
od | 最老牌,数制任意(octal/hex/char) | 可移植脚本、八进制 |
hexdump(hd) | 多格式布局、-C 经典 | 文件结构分析 |
xxd 常用姿势:
xxd file.bin # hex + ASCII 对照
xxd -l 64 file.bin # 只看前 64 字节
xxd -g 1 file.bin # 每字节分组显示
xxd -r hex.txt > out.bin # hex → 二进制(反向)
od 常用姿势:
od -A d -t x1 file.bin # 十进制偏移 + 单字节 hex
od -c file.bin # 按字符显示(含转义 \n \0)
od -t u2 file.bin # 按 2 字节无符号整数读
hexdump -C 经典输出:
00000000 48 69 21 0a 00 ff |Hi!...|
读转储的方法:左列是字节偏移(16 进制),中间是 hex 字节,右侧是 ASCII 可读区(不可打印显示 .)。
记忆:xxd 日常、od 可移植、hexdump -C 结构化——三个都会看偏移列与 ASCII 区,二进制就"读"得动了。
7. magic bytes 与文件类型识别
文件系统/浏览器靠扩展名,但真实识别靠文件头(magic bytes):
| 文件类型 | 文件头(hex) | ASCII |
|---|---|---|
| JPEG | FF D8 FF | ÿØÿ |
| PNG | 89 50 4E 47 | ‰PNG |
| GIF | 47 49 46 38 | GIF8 |
| ZIP | 50 4B 03 04 | PK.. |
25 50 44 46 | %PDF | |
| gzip | 1F 8B | .‹ |
| ELF(可执行) | 7F 45 4C 46 | \x7fELF |
xxd -l 8 photo.jpg # 看文件头
file photo.jpg # 用 libmagic 识别(读 magic 库)
Python 侧快速识别:
with open("unknown", "rb") as f:
head = f.read(8)
if head[:2] == b"\xff\xd8": print("JPEG")
elif head[:4] == b"\x89PNG": print("PNG")
elif head[:4] == b"PK\x03\x04": print("ZIP")
工程场景:文件上传校验"是否真是图片"不能只信扩展名,要读 magic;恶意文件常改扩展名骗过过滤器——识别走 magic、上传白名单走 content-type + magic 双校验(详见 [[security]] 的文件上传安全)。
记忆:文件类型看 magic bytes 不看扩展名;上传校验要 magic + content-type 双保险。
8. 位运算与打包解包
二进制格式里频繁出现位打包(一个字节存多个标志位)、掩码、位移:
flags = 0b1101_0000 # 一个字节存 8 个布尔
BIT_SET = 0b0000_0010
flags & BIT_SET # 检查第 2 位 → 0(未设)
flags | BIT_SET # 置位
flags & ~BIT_SET # 清零
flags ^ BIT_SET # 翻转
(flags >> 4) & 0x0F # 取高 4 位 → 0b1101 = 13
网络协议常见:大端(big-endian)还是小端?TCP/IP 用大端,x86 内存是小端:
import struct
# struct 是打包/解包的瑞士军刀
struct.pack(">I", 0xDEADBEEF) # 大端 4 字节
struct.pack("<I", 0xDEADBEEF) # 小端 4 字节
struct.unpack(">HH", b"\x00\x01\x00\x02") # (1, 2)
printf '\xde\xad\xbe\xef' | xxd # 大端写入
# Python: int.from_bytes(b'\xde\xad\xbe\xef', 'big') == 0xDEADBEEF
位域/对齐:C 结构体有字节对齐(padding),读二进制结构体时要注意字段偏移——用 struct 的格式串显式控制,别靠"数着读"。
记忆:位运算管"一个字节塞多个标志",struct 管"跨端序打包解包"——协议调试先确认端序与对齐。
9. 实战:调试二进制协议与文件
场景:抓到的 TCP 报文全是十六进制
1. 确认端序(大端/小端)与字段宽度
2. 按协议文档切字段:用 xxd/od 或 Python struct
3. 标记 magic/长度前缀 → 检查长度字段对不对
4. 把可读部分(ASCII)单独提出来看 → 定位具体内容
# 示例:自定义协议 [4B 长度][2B 类型][payload]
import struct
buf = b"\x00\x00\x00\x04\x00\x01Hi\x00\x00"
length, kind = struct.unpack(">IH", buf[:6]) # 长度 4、类型 1
payload = buf[6:6+length] # b'Hi\x00\x00'
场景:日志里的一串 Base64 看不懂
echo 'eyJhbGciOiJIUzI1NiJ9' | base64 -d # {"alg":"HS256"}
# JWT 三段:header.payload.signature,每段 base64url
场景:文件头对不上导致解析失败
先 xxd -l 16 看头部 → 对比 magic 表
常见:BOM(EF BB BF)没处理、行尾 \r\n 混入、压缩包被二次 gzip
调试铁律:
□ 永远从"原始字节"出发,别相信打印出来的"看起来像"
□ 一次只看一个字段:拆开、标注、再合起来
□ 用确定性样例(构造最小复现)而不是大海捞针
记忆:二进制调试 = 端序 + 字段切分 + magic 对照 + 最小复现——从字节出发,不信’打印’。
10. 速查表与一句话记忆
全篇速查:
| 主题 | 结论 |
|---|---|
| 数制 | 1 hex 位 = 4 bit,一字节 = 2 hex 位 |
| Hex | 字节直译 1:2,调试/哈希展示 |
| Base64 | 3→4,+33%,塞进文本通道 |
| Base64url | -_ 代替 +/,JWT/URL |
| Base32/58 | 可读性/无歧义 |
| 乱码 | 同一字节不同解码,用 iconv/xxd |
| xxd/od | 看偏移 + hex + ASCII 区 |
| magic | 文件头识类型,上传双校验 |
| 位运算 | 一个字节塞多标志 |
| struct | 端序与打包解包 |
| 调试 | 字节出发 + 字段切分 + 最小复现 |
一句话记忆:字节是一切的本源——hex 是直译、Base64 是"塞进文本的皮"、Base64url 服务 URL/JWT;乱码是解码契约的误会,用 xxd/iconv 看清字节;文件识别看 magic 不看扩展名;协议调试先定端序再切字段,用 struct 打包解包、位运算管标志——从字节出发,二进制就不再有玄学。
延伸阅读
- /unicode-encoding-guide/ — 字符编码完整理论
- /serialization-formats-compare/ — Protobuf/JSON 等序列化的字节布局
- /regex-deep-dive/ — 字节流上的文本模式
- [[security]] — 文件上传校验与 magic bytes
- [[network]] — 网络协议与抓包分析
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。