ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

利用C语言编写一个十六进制日志转换工具

利用C语言编写一个十六进制日志转换工具

前言


二进制日志(网络包、传感器采样、游戏存档、崩溃转储)直接用文本编辑器打开往往是一片乱码。十六进制转储(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 ...

设计要点:



  1. 偏移列用十六进制,至少 8 位,文件超过 4 GiB 后自然扩展。用uint64_t保存偏移,配合<inttypes.h>的PRIx64打印,避免平台间long宽度差异(Windows 的long是 32 位)。

  2. 每行 16 字节,第 8 字节后加一个额外空格,便于肉眼分组。

  3. 不足一行时补空格,保证右侧 ASCII 列对齐;补位不能用00,否则会和真实的零字节混淆。

  4. ASCII 列只显示可打印字符,其余(包括\0、换行、制表符)统一显示为.。isprint的参数必须是unsigned char提升后的值或EOF,传负数会有 UB。

  5. 字节顺序: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失败也要报告;对只读文件来说少见,但写工具时应养成检查习惯。


四、错误处理与可扩展方向


上面的实现适合教学和临时排障,工程化还需要补几块:



  1. 返回值传播。让dump_stream返回int(0 成功、非 0 失败),main据此设置退出码。脚本调用时才能判断是否成功。

  2. 偏移与长度限制。支持--offset和--length时,用fseek定位,再按剩余长度循环读取。注意fseek的偏移类型是long,大文件要用平台提供的 64 位定位接口(如 POSIX 的fseeko),标准 C 没有统一的 64 位版本。

  3. 从标准输入读取。argc == 1或参数为-时用stdin,此时不能fseek,只能顺序读。

  4. 多文件。循环处理argv[1..argc-1],每个文件输出一段标题。

  5. 输出到文件。把printf换成fprintf(out, ...),并检查写入错误。

  6. 避免system()拼接命令。如果工具要调用外部命令,不要把文件名直接拼进 shell 字符串,否则文件名里的分号、反引号会造成命令注入。能用标准库完成就不要调用外部命令。




扩展关键函数注意点



指定偏移fseeklong宽度平台相关,大文件需 64 位接口

限制长度计数器 +fread不要读超过剩余长度

标准输入stdin不可 seek,只能顺序读

多文件argv循环每个文件单独打开/关闭

输出到文件fopen(..., "wb")检查fwrite/fclose返回值



常见坑点



  1. ❌while (!feof(fp)) { fread(...); ... }。


✅ 以fread返回值控制循环,结束后用ferror区分错误。



  1. ❌ 用"r"打开二进制日志,Windows 上\r\n被改写。


✅ 用"rb",跨平台都显式指定二进制模式。



  1. ❌ 最后一行不足 16 字节时,把缓冲区剩余旧数据也打印出来。


✅ 只处理fread返回的n个字节,其余补空格。



  1. ❌isprint(buf[i])直接传char,遇到负值(非 ASCII 字节)是 UB。


✅ 先存为unsigned char,再传给isprint。



  1. ❌ 用int保存文件偏移,超过 2 GiB 后溢出。


✅ 用uint64_t+PRIx64;定位大文件用平台的 64 位接口。



  1. ❌ 用%x打印uint64_t,在部分平台上截断或行为未定义。


✅ 用PRIx64宏,格式串与类型严格匹配。



  1. ❌ 只检查fopen,不检查fread的错误状态和fclose的返回值。


✅ 读取后用ferror,关闭后检查fclose,并把失败反映到退出码。



  1. ❌ 把文件名拼进system("xxd ..."),文件名含空格或分号时出错甚至被注入。


✅ 用标准库自己读文件;必须调用外部程序时使用参数数组形式的接口并校验输入。


总结




环节正确做法



打开fopen(path, "rb"),失败用perror

读取fread(buf, 1, sizeof buf, fp),按返回值循环

结束判断ferror判错,feof仅作确认

输出偏移 + 十六进制 + 补位 + ASCII 列

偏移类型uint64_t+PRIx64

关闭检查fclose返回值



一个十六进制日志工具的核心就是「按块读、按块打、正确处理最后一块」。把feof的误用、二进制模式、无符号字符和 64 位偏移这四个点处理对,代码就具备了实用价值;再补上参数解析和错误码,就可以作为日常排障工具使用。编译时用 C 编译器并开启警告(如-Wall -Wextra),能提前发现格式串与类型不匹配的问题。




RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进