Shell 处理二进制数据

系统讲解用 xxd、hexdump 与 od 阅读十六进制转储、用 dd 按偏移与长度精确截取数据、用校验和与清单验证文件完整性的做法,并给出定长头加变长体的协议帧手工解析实例、字节回写技巧与常见踩坑排错清单,说明 Shell 处理二进制的边界与替代方案。

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 处理二进制数据的正确姿势可以总结成四句话:

  1. 二进制不进变量——留在文件与管道里,用临时文件传递。
  2. 看用 xxd/od,切用 dd——od 保底可移植,xxd 读写双向都好用。
  3. 校验靠清单不靠单值——两端用同一份 SHA256SUMS 各算一次再比对。
  4. 解析超过 50 行就换语言——Shell 的优势在组合,不在循环与位运算。

这些手段和文本侧的工具是互补的:文本处理:awk 与 sed 处理行式数据,本文的工具处理字节流,两者在 文件 IO 与重定向 的层面上统一起来。若需要把二进制产物走网络传输,curl 与网络诊断 里的 --data-binary 与流式下载技巧会派上用场。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 日志轮转与归档
  2. 监控采集与告警脚本
  3. Shell 脚本的 POSIX 可移植性