1. Shell 与二进制数据的边界
一句话总结: Shell 的管道、变量与大部分工具都以「行」和「NUL 结尾的字符串」为假设,处理二进制时会在这两处静默丢数据——先理解边界,再决定用哪些工具。
1.1 三处必然出问题的地方
# 1. 变量无法保存 NUL 字节
data=$(printf 'a\0b')
printf '%s' "$data" | xxd
# 输出只有 61(a),NUL 之后的内容被 shell 丢弃
# 2. 命令替换会剥掉所有末尾换行
x=$(printf 'a\n\n\n')
printf '%s' "$x" | wc -c # 1,三个换行被吞
# 3. 大多数文本工具按行读,遇 NUL 截断
printf 'a\0b\n' | grep b # 无输出
结论很直接:二进制数据必须留在文件或管道里,绝不能进变量。任何需要「取出来处理」的场景,都应该走临时文件。
1.2 仍然值得用 Shell 的场景
| 场景 | 是否适合 Shell |
|---|---|
| 快速看一眼文件头,确认格式 | 非常适合 |
| 按固定偏移切出几个字段 | 适合 |
| 校验下载文件的完整性 | 适合 |
| 批量拼接、截断、分割大文件 | 适合 |
| 解析复杂的二进制协议 | 勉强,超过 50 行就该换语言 |
| 做位运算密集的解码 | 不适合 |
一句话总结: Shell 在二进制场景里的定位是「切、看、校验」,一旦需要循环解析变长结构,用 Python 或 Go 重写会比继续加
dd参数更快。
1.3 语言环境的影响
# LC_ALL=C 让工具按字节而非字符处理,避免多字节字符干扰
LC_ALL=C grep -c . file.bin
# 某些工具在 UTF-8 locale 下会拒绝处理非法字节序列
LC_ALL=C sed -n '1p' file.bin
处理二进制时总是设置 LC_ALL=C,这是一个几乎零成本、收益确定的习惯。
2. 阅读十六进制转储
一句话总结:
xxd输出「偏移 + 十六进制 + ASCII」三列,是排查二进制问题的第一工具;hexdump更灵活但格式串需要学习;od是 POSIX 标准里唯一保证存在的。
2.1 xxd 常用模式
# 默认:偏移 8 位十六进制 + 16 字节十六进制 + ASCII
xxd header.bin
# 只要十六进制,去掉偏移与 ASCII
xxd -p header.bin
# 48656c6c6f
# 指定长度与偏移(-l 长度 -s 起始偏移)
xxd -s 0 -l 32 header.bin
# 每行字节数(-c)
xxd -c 8 header.bin
# 反向:把十六进制文本还原成二进制
xxd -r -p hex.txt > restored.bin
xxd -r 是往返验证的关键:任何「用十六进制文本改了内容再还原」的操作,都应该用 xxd -r -p 回写后做一次校验和比对。
2.2 hexdump 格式串
# -C:规范的 canonical 输出,最接近 xxd 默认
hexdump -C header.bin
# -n 长度 -s 偏移
hexdump -C -n 16 header.bin
# -e 自定义格式串:每 4 字节按十进制无符号输出
hexdump -e '4/1 "%02x " "\n"' header.bin
# 按大端 16 位读
hexdump -e '8/2 "%04x " "\n"' header.bin
格式串里 4/1 表示「重复 4 次、每次 1 字节」,是 hexdump 最核心的语法。解析协议头时它比 dd 加 od 的组合更紧凑:
# 一次读出 PNG 的前 24 字节:签名 8 字节 + 长度 4 + 类型 4 + 数据 8
hexdump -e '8/1 "%02x"' -e ' " | " 4/1 "%02x"' -e ' " | " 12/1 "%02x"' -e ' "\n"' image.png
2.3 od:POSIX 的保底选择
# od 在所有 POSIX 系统上都存在,Alpine 上也不例外
od -A x -t x1z header.bin
# -A 偏移基数(x 十六进制、d 十进制、o 八进制)
# -t 类型(x1 单字节十六进制、z 附带可打印字符)
od -A d -t x1 header.bin
# 只读前 16 字节
od -N 16 -t x1 header.bin
# 按大端 32 位无符号读
od -A d -t x4 header.bin
-t x1z 的组合几乎等价于 xxd 默认输出,且 od 是 POSIX 标准命令——写可移植脚本时优先用它。
2.4 大小端与字节序判断
# 读一个 32 位整数,判断字节序
first4=$(od -A n -t x1 -N 4 file.bin | tr -d ' \n')
echo "原始字节: $first4"
# 若文件规定是大端,用 dd 逐字节取值再拼
字节序示例:值 0x01020304
大端存储:01 02 03 04
小端存储:04 03 02 01
一句话总结: 十六进制转储只告诉你字节顺序,不告诉你字节序——字节序由协议规范决定,必须回到文档确认,不能靠「看起来像」猜。
3. dd 与偏移截取
一句话总结:
dd的核心是四个参数:if输入、of输出、bs块大小、skip/seek块偏移——把「字节偏移」换算成「块偏移」是唯一的计算负担。
3.1 参数模型
# 基本形式:从输入偏移 512 字节处取 1024 字节
dd if=disk.img of=part.bin bs=512 skip=1 count=2 status=none
# 四个关键参数
# if=FILE 输入文件(默认 stdin)
# of=FILE 输出文件(默认 stdout)
# bs=N 块大小(字节),skip/seek 以块为单位
# count=N 拷贝块数
# skip=N 输入起始块偏移
# seek=N 输出起始块偏移
# conv= 转换(notrunc 不截断、sync 补零、noerror 忽略读错)
关键换算:skip 的单位是「块」,不是字节。
# 想从字节偏移 1000 开始,取 100 字节
# 用 bs=1 让块等于字节,简单但慢
dd if=file.bin of=out.bin bs=1 skip=1000 count=100 status=none
# 用 bs=1000 先跳一块,再用 bs=1 取 100 字节
dd if=file.bin of=out.bin bs=1000 skip=1 count=1 status=none
3.2 性能:块大小决定一切
# 慢:bs=1 逐字节拷贝,1GB 文件可能要几分钟
time dd if=big.img of=/dev/null bs=1 count=1000000
# 快:bs=1M 让内核一次搬 1MB
time dd if=big.img of=/dev/null bs=1M count=1
一句话总结: 块大小是
dd性能的唯一旋钮——按字节偏移截取时,用大块跳过前缀、再用小块精确截取,能同时拿到精度与速度。
3.3 精确截取的辅助函数
#!/usr/bin/env bash
set -euo pipefail
# slice FILE OFFSET LENGTH OUTPUT
# 用「大块跳 + 小块取」避免 bs=1 逐字节
slice() {
local file=$1 offset=$2 length=$3 out=$4
local bs=1048576 # 1 MiB 对齐块
local blocks=$(( offset / bs ))
local rest=$(( offset % bs ))
{
dd if="$file" bs="$bs" skip="$blocks" count=1 status=none
dd if="$file" bs="$bs" skip="$(( blocks + 1 ))" status=none
} | dd bs=1 skip="$rest" count="$length" of="$out" status=none
}
3.4 seek 与拼接
# 把一块数据写到目标文件的指定偏移,且不截断后续内容
dd if=patch.bin of=target.bin bs=1 seek=1024 conv=notrunc status=none
# 拼接头 + 体
cat header.bin body.bin > full.bin
# 把两个文件按块交错(不常用但能说明 seek 语义)
dd if=a.bin of=out.bin bs=1 seek=0 conv=notrunc status=none
dd if=b.bin of=out.bin bs=1 seek=100 conv=notrunc status=none
conv=notrunc 必须显式写:默认情况下 dd 打开输出文件时会截断到 0,忘了它就会把目标文件后半段清空。
4. 校验和与完整性校验
一句话总结: 校验和的价值不在于「算出一个值」,而在于「用同一份清单在两端各算一次并比对」——所以清单文件本身也需要可信来源。
4.1 常用算法与工具
# GNU coreutils:sha256sum / md5sum / b2sum
sha256sum file.bin
md5sum file.bin
b2sum -l 256 file.bin # BLAKE2b,比 SHA-256 快
# BSD/macOS:shasum / md5
shasum -a 256 file.bin
md5 file.bin
# POSIX 保底:cksum(CRC32,弱但到处都有)
cksum file.bin
跨平台封装:
sha256() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | cut -d' ' -f1
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | cut -d' ' -f1
elif command -v openssl >/dev/null 2>&1; then
openssl dgst -sha256 "$1" | awk '{print $NF}'
else
echo "无可用 SHA-256 工具" >&2
return 1
fi
}
4.2 清单校验
# 生成清单(注意用相对路径,便于跨机器验证)
(cd dist && find . -type f -exec sha256sum {} + | sort -k2) > SHA256SUMS
# 验证清单:-c 逐行校验并汇总失败项
sha256sum -c SHA256SUMS
# 只看失败项
sha256sum -c SHA256SUMS 2>&1 | grep -v ': OK$' || echo "全部通过"
# 容器/CI 里对下载产物做校验的完整流程
set -euo pipefail
url=https://example.com/release.tar.gz
curl -fsSL "$url" -o release.tar.gz
curl -fsSL "$url.sha256" -o release.tar.gz.sha256
# 校验文件里通常带文件名,需要匹配本地名
expected=$(awk '{print $1}' release.tar.gz.sha256)
actual=$(sha256 "$PWD/release.tar.gz")
[ "$expected" = "$actual" ] || { echo "校验失败" >&2; exit 1; }
4.3 大文件与分块校验
# 边下载边算,避免存两份
curl -fsSL "$url" | tee >(sha256sum > /tmp/download.sha) | tar -xz -C /opt
# 只校验头部与尾部(快速判断是否为预期文件)
head -c 4096 big.iso | sha256sum
tail -c 4096 big.iso | sha256sum
一句话总结: 校验和不是安全机制——它只能发现传输错误,无法防御篡改;需要防篡改时用 GPG 签名或 TLS,而不是更长的哈希。
5. 协议帧的手工解析
一句话总结: 定长头 + 变长体是最常见的帧格式,解析只需三步:读头、按头里的长度字段算体长、按偏移切片。
5.1 一个自定义帧格式
偏移 长度 字段 说明
0 4 magic 固定 0x53 0x46 0x52 0x31("SFR1")
4 2 version 大端 uint16
6 4 body_len 大端 uint32
10 1 flags 位标志
11 N body 负载,长度 = body_len
5.2 逐步解析
#!/usr/bin/env bash
set -euo pipefail
export LC_ALL=C
frame=$1
# 步骤 1:校验魔数
magic=$(dd if="$frame" bs=1 count=4 status=none | xxd -p)
if [ "$magic" != "53465231" ]; then
echo "魔数不匹配: $magic" >&2
exit 1
fi
# 步骤 2:读版本(偏移 4,2 字节,大端)
ver_hex=$(dd if="$frame" bs=1 skip=4 count=2 status=none | xxd -p)
version=$(( 0x$ver_hex ))
echo "版本: $version"
# 步骤 3:读 body_len(偏移 6,4 字节,大端)
len_hex=$(dd if="$frame" bs=1 skip=6 count=4 status=none | xxd -p)
body_len=$(( 0x$len_hex ))
echo "负载长度: $body_len"
# 步骤 4:读 flags(偏移 10,1 字节)
flags=$(dd if="$frame" bs=1 skip=10 count=1 status=none | xxd -p)
echo "标志位: 0x$flags"
# 步骤 5:按长度截取负载
dd if="$frame" of=body.bin bs=1 skip=11 count="$body_len" status=none
echo "负载已提取: $(wc -c < body.bin) 字节"
5.3 用 od 一次读出多字段
dd 逐字段调用会产生大量子进程,对大文件很慢。用 od 一次读头更快:
# 一次读出前 11 字节的头部
header=$(od -A n -t x1 -N 11 "$frame" | tr -d ' \n')
echo "头部: $header"
# 用参数展开切字段(bash)
magic=${header:0:8} # 53465231
ver_hex=${header:8:4} # 版本
len_hex=${header:12:8} # 负载长度
flags=${header:20:2} # 标志位
printf 'magic=%s version=%d body_len=%d flags=0x%s\n' \
"$magic" "$((0x$ver_hex))" "$((0x$len_hex))" "$flags"
5.4 解析 PNG 头部实例
#!/usr/bin/env bash
set -euo pipefail
export LC_ALL=C
png=$1
# PNG 签名固定 8 字节:89 50 4E 47 0D 0A 1A 0A
sig=$(od -A n -t x1 -N 8 "$png" | tr -d ' \n')
[ "$sig" = "89504e470d0a1a0a" ] || { echo "不是 PNG" >&2; exit 1; }
# 紧接着是 IHDR 块:4 字节长度 + 4 字节类型 + 13 字节数据
chunk=$(od -A n -t x1 -N 25 "$png" | tr -d ' \n')
ctype=$(printf '%s' "${chunk:16:8}" | xxd -r -p)
[ "$ctype" = "IHDR" ] || { echo "首块不是 IHDR" >&2; exit 1; }
# 宽高各 4 字节大端,位于块内偏移 8 与 12
width=$(( 0x$(printf '%s' "${chunk:24:8}") ))
height=$(( 0x$(printf '%s' "${chunk:32:8}") ))
printf 'PNG %dx%d\n' "$width" "$height"
5.5 回写与 base64 中转
# 十六进制文本还原成二进制
printf '53465231' | xxd -r -p > magic.bin
# 修改一个字节后回写(用 xxd 转文本、编辑、再转回)
xxd -p config.bin > config.hex
sed -i.bak '1s/^00/01/' config.hex # 改首字节
xxd -r -p config.hex > config.new.bin
# base64 中转(适合在不支持二进制的通道里传数据)
base64 < frame.bin > frame.b64
base64 -d < frame.b64 > frame.restored.bin
cmp frame.bin frame.restored.bin && echo "往返一致"
cmp 比 diff 更适合二进制比对:它只报告第一个差异字节的位置,不会尝试逐行对齐。
6. 踩坑速查
| 症状 | 原因 | 处理 |
|---|---|---|
| 变量里数据被截断 | Shell 变量不能存 NUL | 用临时文件 |
| 末尾换行丢失 | 命令替换剥换行 | 用文件或 printf 拼接 |
| 偏移对不上 | 把字节偏移当成了块偏移 | 统一 bs=1 或手工换算 |
| 目标文件后半段被清空 | 忘了 conv=notrunc | 显式加上 |
dd 慢得离谱 | bs=1 逐字节 | 大块跳过 + 小块精确取 |
| 中文乱码或报错 | locale 干扰字节处理 | export LC_ALL=C |
| 校验清单全失败 | 路径不匹配 | 生成时用相对路径 |
| 大小端读反 | 未按规范确认字节序 | 回查协议文档 |
7. 总结
Shell 处理二进制数据的正确姿势可以总结成四句话:
- 二进制不进变量——留在文件与管道里,用临时文件传递。
- 看用
xxd/od,切用dd——od保底可移植,xxd读写双向都好用。 - 校验靠清单不靠单值——两端用同一份
SHA256SUMS各算一次再比对。 - 解析超过 50 行就换语言——Shell 的优势在组合,不在循环与位运算。
这些手段和文本侧的工具是互补的:文本处理:awk 与 sed
处理行式数据,本文的工具处理字节流,两者在 文件 IO 与重定向
的层面上统一起来。若需要把二进制产物走网络传输,curl 与网络诊断
里的 --data-binary 与流式下载技巧会派上用场。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。