前言
二进制日志(网络包、传感器采样、游戏存档、崩溃转储)直接用文本编辑器打开往往是一片乱码。十六进制转储(hex dump)工具把每个字节显示成两位十六进制,并在右侧附上可打印字符,既能看结构,又能定位偏移。xxd、hexdump、od都能做这件事,但自己用 C 写一个,是练习文件 I/O、缓冲区处理和边界判断的好题目。
需要先说明:这个工具是C 语言程序,不是 C++。它只用到 C 标准库,编译时应使用 C 编译器(如gcc -std=c17),代码里也没有std::、类、模板这些东西。用 C++ 编译器按 C++ 模式编译通常也能通过,但语义上应按 C 来理解。
另一个常见误解是「读文件就该用while (!feof(fp))」。feof只有在一次读取已经失败之后才返回真,用它当循环条件会多处理一次旧数据。正确做法是以fread的返回值为准,循环结束后再用feof/ferror区分「正常结束」和「读取出错」。
本文实现一个按 16 字节一行输出的十六进制查看器,支持命令行指定文件、处理最后一行不足 16 字节的情况、区分 EOF 与错误,并讨论大文件偏移和可扩展方向。
一、输出格式设计
目标格式如下(每行 16 字节):
00000000 48 65 6c 6c 6f 2c 20 57 6f 72 6c 64 21 0a 00 00 |Hello, World!...|
00000010 ...设计要点:
- 偏移列用十六进制,至少 8 位,文件超过 4 GiB 后自然扩展。用
uint64_t保存偏移,配合<inttypes.h>的PRIx64打印,避免平台间long宽度差异(Windows 的long是 32 位)。 - 每行 16 字节,第 8 字节后加一个额外空格,便于肉眼分组。
- 不足一行时补空格,保证右侧 ASCII 列对齐;补位不能用
00,否则会和真实的零字节混淆。 - ASCII 列只显示可打印字符,其余(包括
\0、换行、制表符)统一显示为.。isprint的参数必须是unsigned char提升后的值或EOF,传负数会有 UB。 - 字节顺序:hex dump 按文件中的原始顺序显示,不做大小端解释。大小端是「多字节值如何解释」的问题,不是转储工具该做的事。
这些规则和xxd的默认格式接近,但不必逐字符模仿;关键是偏移、字节、ASCII 三列对齐且无歧义。
二、按块读取与 EOF 判断
核心循环只有几行,但边界条件不少:
// 适用于 C17
unsigned char buf[16];
size_t n;
while ((n = fread(buf, 1, sizeof buf, fp)) > 0) {
/* 处理 n 个字节 */
}
if (ferror(fp)) {
/* 读取出错 */
}为什么这样写是对的:
fread返回本次实际读到的元素个数。这里元素大小是 1 字节,所以返回值就是字节数。- 返回 0 有两种可能:读到文件末尾,或者发生错误。循环退出后再用
ferror判断,feof只用于确认「是正常 EOF」。 - 短读(返回值小于 16 但大于 0)是合法的,可能发生在管道、终端或文件末尾;循环会继续,下一次读到 0 才结束。
- 最后一行不足 16 字节时,只处理
n个字节,其余位置打印空格。
不要写:
// 适用于 C17(反面示例,不要这样写)
while (!feof(fp)) {
n = fread(buf, 1, sizeof buf, fp);
/* 如果上次已到 EOF,这里 n 为 0,buf 里是旧数据 */
}feof在「读操作尝试越过末尾」之后才置位,所以这种写法会多循环一次,把上一次的缓冲区内容重复输出。这是 C 文件 I/O 最经典的错误之一。
三、完整实现
// 适用于 C17
#include <ctype.h>
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#define BYTES_PER_LINE 16
static void dump_stream(FILE *fp) {
unsigned char buf[BYTES_PER_LINE];
uint64_t offset = 0;
size_t n;
size_t i;
while ((n = fread(buf, 1, sizeof buf, fp)) > 0) {
/* 偏移列 */
printf("%08" PRIx64 " ", offset);
/* 十六进制列,每 8 字节多一个空格,不足补位 */
for (i = 0; i < BYTES_PER_LINE; ++i) {
if (i < n) {
printf("%02x ", (unsigned)buf[i]);
} else {
printf(" ");
}
if (i == 7) {
putchar(' ');
}
}
/* ASCII 列,不足 16 字节时补空格以保持右侧竖线对齐 */
putchar('|');
for (i = 0; i < BYTES_PER_LINE; ++i) {
if (i < n) {
unsigned char c = buf[i];
putchar(isprint(c) ? (int)c : '.');
} else {
putchar(' ');
}
}
putchar('|');
putchar('\n');
offset += (uint64_t)n;
}
if (ferror(fp)) {
perror("read");
}
}
int main(int argc, char **argv) {
FILE *fp;
int rc = EXIT_SUCCESS;
if (argc != 2) {
fprintf(stderr, "usage: %s FILE\n", argv[0]);
return EXIT_FAILURE;
}
fp = fopen(argv[1], "rb");
if (fp == NULL) {
perror(argv[1]);
return EXIT_FAILURE;
}
dump_stream(fp);
if (fclose(fp) != 0) {
perror("fclose");
rc = EXIT_FAILURE;
}
return rc;
}逐点说明:
fopen用"rb"。在 Windows 上文本模式会把\r\n转换成\n,破坏二进制内容;b必须显式加。fread(buf, 1, sizeof buf, fp)中元素大小固定为 1,避免「元素个数」和「字节数」混淆。printf("%02x ", (unsigned)buf[i]):%x要求unsigned int,unsigned char会提升为int,显式转unsigned更清楚。printf("%08" PRIx64 " ", offset):PRIx64展开为对应uint64_t的格式串,宽度 8 是最小宽度,超过 8 位会自然变长,不会截断。- 最后一个不足 16 字节的块,十六进制列和 ASCII 列都补空格,右侧的
|仍然对齐。 - 读取错误时
perror("read")打印系统错误信息,但函数仍返回,main的退出码没有反映读取失败。实际工具应让dump_stream返回状态,下面讨论。 fclose失败也要报告;对只读文件来说少见,但写工具时应养成检查习惯。
四、错误处理与可扩展方向
上面的实现适合教学和临时排障,工程化还需要补几块:
- 返回值传播。让
dump_stream返回int(0 成功、非 0 失败),main据此设置退出码。脚本调用时才能判断是否成功。 - 偏移与长度限制。支持
--offset和--length时,用fseek定位,再按剩余长度循环读取。注意fseek的偏移类型是long,大文件要用平台提供的 64 位定位接口(如 POSIX 的fseeko),标准 C 没有统一的 64 位版本。 - 从标准输入读取。
argc == 1或参数为-时用stdin,此时不能fseek,只能顺序读。 - 多文件。循环处理
argv[1..argc-1],每个文件输出一段标题。 - 输出到文件。把
printf换成fprintf(out, ...),并检查写入错误。 - 避免
system()拼接命令。如果工具要调用外部命令,不要把文件名直接拼进 shell 字符串,否则文件名里的分号、反引号会造成命令注入。能用标准库完成就不要调用外部命令。
| 扩展 | 关键函数 | 注意点 |
|---|
| 指定偏移 | fseek | long宽度平台相关,大文件需 64 位接口 |
| 限制长度 | 计数器 +fread | 不要读超过剩余长度 |
| 标准输入 | stdin | 不可 seek,只能顺序读 |
| 多文件 | argv循环 | 每个文件单独打开/关闭 |
| 输出到文件 | fopen(..., "wb") | 检查fwrite/fclose返回值 |
常见坑点
- ❌
while (!feof(fp)) { fread(...); ... }。
✅ 以fread返回值控制循环,结束后用ferror区分错误。
- ❌ 用
"r"打开二进制日志,Windows 上\r\n被改写。
✅ 用"rb",跨平台都显式指定二进制模式。
- ❌ 最后一行不足 16 字节时,把缓冲区剩余旧数据也打印出来。
✅ 只处理fread返回的n个字节,其余补空格。
- ❌
isprint(buf[i])直接传char,遇到负值(非 ASCII 字节)是 UB。
✅ 先存为unsigned char,再传给isprint。
- ❌ 用
int保存文件偏移,超过 2 GiB 后溢出。
✅ 用uint64_t+PRIx64;定位大文件用平台的 64 位接口。
- ❌ 用
%x打印uint64_t,在部分平台上截断或行为未定义。
✅ 用PRIx64宏,格式串与类型严格匹配。
- ❌ 只检查
fopen,不检查fread的错误状态和fclose的返回值。
✅ 读取后用ferror,关闭后检查fclose,并把失败反映到退出码。
- ❌ 把文件名拼进
system("xxd ..."),文件名含空格或分号时出错甚至被注入。
✅ 用标准库自己读文件;必须调用外部程序时使用参数数组形式的接口并校验输入。
总结
| 环节 | 正确做法 |
|---|
| 打开 | fopen(path, "rb"),失败用perror |
| 读取 | fread(buf, 1, sizeof buf, fp),按返回值循环 |
| 结束判断 | ferror判错,feof仅作确认 |
| 输出 | 偏移 + 十六进制 + 补位 + ASCII 列 |
| 偏移类型 | uint64_t+PRIx64 |
| 关闭 | 检查fclose返回值 |
一个十六进制日志工具的核心就是「按块读、按块打、正确处理最后一块」。把feof的误用、二进制模式、无符号字符和 64 位偏移这四个点处理对,代码就具备了实用价值;再补上参数解析和错误码,就可以作为日常排障工具使用。编译时用 C 编译器并开启警告(如-Wall -Wextra),能提前发现格式串与类型不匹配的问题。