macOS HexEdit 源码解析与二进制文件查看实战
简介:HexEdit 是托管在 GitHub 的开源十六进制编辑器,面向程序员、逆向工程师、安全研究人员以及需要直接查看和修改二进制数据的用户。它提供十六进制视图,可用于字节级查看、搜索、编辑、修复损坏文件以及分析可执行文件内容,也适合了解 Windows 桌面工具的界面组织与数据读写流程。压缩包共 220 个文件,约 528KB,以 51 个 C 源文件、48 个 C++ 源文件、47 个头文件为主体,配合 bmp、ico、png 等界面素材,vcxproj、sln 等 Visual Studio 工程配置,以及 bat 构建脚本、rc 资源脚本和 txt、md 说明文档,构成较完整的编辑器源码工程。已有 206 人学习下载。源码与工程文件齐全,便于编译运行后对照界面、资源与编辑逻辑,也可作为 C/C++ 桌面开发、二进制文件分析和逆向安全入门的参考案例,读者可据此观察十六进制编辑器的模块划分、资源脚本与窗口消息处理。
1. 二进制文件查看为什么绕不开 HexEdit:Mac 上的十六进制编辑器选型
做嵌入式、逆向分析或协议解析的人都有过这种经历:一份设备导出的二进制日志,用文本编辑器打开全是乱码;想定位某个魔数在偏移 0x1A2C 处对不对,却只能靠xxd命令行反复倒腾。二进制文件查看的核心诉求其实就三条:能按字节定位、能改、改完不破坏结构。HexEdit 就是在这个场景下被反复提到的一个 macOS 原生十六进制编辑器,源码托管在 GitHub 上,采用 Objective-C 与 AppKit 编写,体积小、启动快,支持大文件映射、查找替换、校验和查看等基础能力。它适合需要频繁打开固件、二进制配置、磁盘镜像、资源包做比对的人,也适合想研究一个精简十六进制编辑器怎么实现的开发者。
2. HexEdit 的源码结构与关键机制:从工程骨架看它怎么读文件
拿到一份编辑器源码,先别急着编译运行,先弄清它把“文件读取”这件事放在哪一层。十六进制编辑器和普通文本编辑器的根本差异在于:文本编辑器可以假设字符编码,十六进制编辑器必须假设什么都没有,一切以字节为单位。这个假设决定了它的数据模型、渲染模型和保存策略。HexEdit 的工程结构并不复杂,但几个类之间的职责划分很清晰,看懂这一层,后面改功能、加插件、排查渲染错位才有依据。
2.1 工程目录与核心类的职责划分
按常见做法,HexEdit 的源码目录大致会分成应用入口、文档模型、视图渲染、辅助工具几块。核心类通常包括:一个文档类负责文件的内存映射与字节缓冲区管理,一个十六进制视图类负责把字节流画成“偏移 + 十六进制 + ASCII”三栏,一个查找替换控制器负责在字节流上做模式匹配,还有若干分类(Category)用于给 NSData、NSString 之类的系统类加便捷方法。
理解这套结构的关键是抓住“谁持有数据”。如果文档类持有一份NSData或NSMutableData,视图只读不写,那所有修改都必须回到文档层做,撤销、脏标记、保存才有统一入口。反过来,如果视图直接持有可变缓冲区,改起来快,但撤销栈和并发访问会变得很难收拾。读源码时先确认这一点,比逐个方法读下去效率高得多。
// 典型的文档层数据持有方式(示意,参照 HexEdit 的文档类设计思路) @interface HexDocument : NSDocument @property (nonatomic, strong) NSMutableData *buffer; // 全量字节缓冲 @property (nonatomic, assign) unsigned long long baseOffset; // 大文件分段时的基准偏移 @property (nonatomic, assign) BOOL isDirty; // 脏标记,决定保存按钮状态 - (BOOL)loadFromURL:(NSURL *)url error:(NSError **)error; - (void)patchByte:(uint8_t)value atOffset:(unsigned long long)offset; @end这段声明的意义在于划边界:buffer是全篇唯一的数据源,baseOffset是为大文件分段加载准备的,isDirty则让界面层的按钮状态有单一判断依据。patchByte:atOffset:是唯一允许写字节的入口,所有编辑操作都收敛到这里,撤销逻辑就可以在这一层做快照,而不是散落在各个视图回调里。
参数上要注意baseOffset的类型。用unsigned long long而不是NSUInteger,是为了在 32 位和 64 位环境下都能表达超过 4GB 的偏移。如果你的目标文件普遍在几 MB 到几十 MB,用NSUInteger也能跑,但一旦要打开磁盘镜像或大固件,类型截断会导致偏移回绕,表现为“跳到某地址后内容对不上”,这类问题排查起来相当费时间。
2.2 大文件的内存映射与分页加载策略
十六进制编辑器最容易踩的性能坑就是把整个文件一次性读进内存。几百 MB 的固件还好,几个 GB 的磁盘镜像直接就把内存吃满了。合格的做法是用NSData的映射选项,让系统按页把文件映射进虚拟地址空间,访问到哪一页才真正从磁盘读哪一页。
// 用内存映射方式打开文件,避免一次性读入全部内容 NSError *error = nil; NSData *mapped = [NSData dataWithContentsOfURL:fileURL options:NSDataReadingMappedIfSafe error:&error]; if (!mapped) { // 映射失败通常意味着文件被占用、权限不足或位于不支持的卷上 NSLog(@"map failed: %@", error.localizedDescription); return; } // 映射成功后可直接按偏移取字节,无需自行管理分页 const uint8_t *bytes = mapped.bytes; uint8_t magic = bytes[0x1A2C];NSDataReadingMappedIfSafe的语义是“安全时使用映射”,系统会自行判断文件所在卷是否支持。这个选项的好处是启动几乎瞬时完成,滚动到哪读到哪;代价是文件在打开期间如果被外部程序改写,映射内容可能与磁盘不一致,保存时必须以重新读取的磁盘内容为准,否则会覆盖掉别人的修改。
我一般会在打开时记录文件的修改时间和大小,保存前再 stat 一次,不一致就提示用户“文件已被外部修改”。这个检查不复杂,但能省掉很多“我明明改的是 A,存下去变成 B”的糊涂账。对只读查看场景,可以直接用NSDataReadingMappedAlways,牺牲一点安全性换确定的行为。
2.3 十六进制视图的渲染流程与偏移计算
视图层的核心是一道坐标换算:给定屏幕上的行号和列号,算出对应的文件偏移;反过来,给定偏移,算出它应该画在第几行第几列。这一步算错,表现就是“光标点在第 3 个字节,改的却是第 4 个”,属于典型的手感玄学问题,但根因完全是算术。
常见的布局是每行 16 字节,左侧 8 位十六进制偏移,中间 16 组两位十六进制,右侧 16 个 ASCII 字符。偏移公式是offset = row * bytesPerRow + column,其中column只对十六进制区生效,ASCII 区点击时需要用column / 3之类的换算反推,因为十六进制区每个字节占两个字符加一个空格。
// 视图坐标到文件偏移的换算(示意) static const NSUInteger kBytesPerRow = 16; - (unsigned long long)offsetForRow:(NSUInteger)row column:(NSUInteger)col { // 十六进制区:每字节占 3 个字符宽度(2 位数字 + 1 空格) NSUInteger byteIndex = col / 3; if (byteIndex >= kBytesPerRow) { byteIndex = kBytesPerRow - 1; // 越界时收敛到行末,避免算出跨行偏移 } return self.baseOffset + row * kBytesPerRow + byteIndex; }这里的kBytesPerRow是硬编码常量,但实际产品里往往允许用户在 8、16、32 之间切换每行字节数。切换时要同时重算滚动位置和光标位置,否则会出现“切完每行宽度,光标跳到别处”的现象。byteIndex的越界收敛也是必须的,用户点在最右侧空白区时,col / 3可能超出 16,不收敛就会算到下一行去,改错字节。
渲染层还有一个容易忽略的点:只画可见区域。文档有 100 万行时,不能为每一行创建文本对象,正确做法是根据NSClipView的可见矩形反推需要绘制的行区间,只处理这一段。这个优化做不做,直接决定打开大文件时是流畅还是卡成幻灯片。
3. 从源码到可运行:编译、签名与首次打开二进制文件
源码看明白之后,下一步是把它跑起来。macOS 应用的构建链比很多人想的要琐碎,尤其是这类带界面、需要读写任意路径文件的老项目,编译、签名、沙盒权限三关任何一关没过,都会表现为“能启动但打不开文件”或者“打开后保存失败”。这一章按实际操作顺序走一遍。
3.1 环境准备与工程编译
先确认工具链。打开终端执行xcode-select -p,能输出 Xcode 路径说明命令行工具已就位;再执行xcodebuild -version看版本号。HexEdit 这类项目年代较早,用较新的 Xcode 打开时通常会有若干警告,属于正常现象,先以能编译通过为准。
# 检查工具链 xcode-select -p xcodebuild -version # 克隆源码并进入目录 git clone https://github.com/strobejb/HexEdit.git cd HexEdit # 列出工程文件,确认打开的是哪个 ls -la克隆之后先ls看一眼,确认是.xcodeproj还是.xcworkspace。有 workspace 就开 workspace,没有就开项目文件。命令行编译可以用:
# 以 Release 配置编译,指定输出目录便于查找产物 xcodebuild -project HexEdit.xcodeproj \ -scheme HexEdit \ -configuration Release \ -derivedDataPath ./build \ build-derivedDataPath把中间产物和最终 app 都收拢到工程目录下的build里,避免去~/Library/Developer/Xcode/DerivedData那一堆哈希目录里翻。-configuration Release关掉大部分调试断言,跑起来更接近真实使用。如果命令报 “scheme not found”,说明工程的 scheme 没有共享,需要先在 Xcode 里打开一次,进 Product → Scheme → Manage Schemes,勾选 Shared 再提交。
编译报错里最常见的是 SDK 路径和部署目标。老项目可能把MACOSX_DEPLOYMENT_TARGET设得很低,新 Xcode 已经不再支持,改成当前系统能接受的最低版本即可。另一类是已废弃 API 的警告被当成错误,检查GCC_TREAT_WARNINGS_AS_ERRORS是否被打开,调试阶段可以先关掉。
3.2 代码签名与本地运行
编译产物在./build/Build/Products/Release/下。直接双击可能被系统拦下,因为没签名或者签名不完整。本地自用最省事的做法是 ad-hoc 签名:
# 对编译产物做 ad-hoc 签名,仅用于本机运行 codesign --force --deep --sign - \ ./build/Build/Products/Release/HexEdit.app # 验证签名结果 codesign -dv ./build/Build/Products/Release/HexEdit.app--sign -表示使用 ad-hoc 身份,不依赖开发者证书,适合本地调试。--deep会递归签内嵌的框架和插件,这类编辑器一般没有内嵌组件,加上也无妨。签名完成后再双击启动,如果仍被拦,去“系统设置 → 隐私与安全性”里看是否有放行提示。
需要注意的是,ad-hoc 签名每次重新编译都会失效,需要重新执行一遍。如果嫌麻烦,可以在 Xcode 的 Build Settings 里把 Signing 配成本地开发团队,让 Xcode 自动处理。但对只是读源码、改点小功能的场景,一条命令的事,不必折腾证书。
3.3 打开第一份二进制文件并做只读核对
启动之后,先别急着改东西。找一份已知结构的文件做只读核对,比如一张 PNG 图片,它的前 8 个字节固定是89 50 4E 47 0D 0A 1A 0A。用它来验证三件事:偏移显示是否正确、十六进制区与 ASCII 区是否同步、滚动到文件末尾是否正常。
| 核对项 | 预期结果 | 不符时的排查方向 |
|---|---|---|
| 文件头魔数 | 偏移 0x00 起为 89 50 4E 47 | 映射偏移错位,检查 baseOffset |
| ASCII 栏 | 显示\x89PNG与IHDR | 渲染层字符过滤规则过严 |
| 末尾滚动 | 最后一行不足 16 字节补齐显示 | 行数计算用了整除而非向上取整 |
| 大文件打开 | 秒开、内存占用平稳 | 是否误用了全量读取 |
表格里最后一项最能说明实现质量。打开一个几百 MB 的文件,如果内存曲线瞬间拉满,说明映射选项没生效,退回了全量读入。这时回到 2.2 节检查NSDataReadingMappedIfSafe是否被改成了NSDataReadingUncached之类。只读核对通过之后,再拿一份可丢弃的副本做写入测试,确认保存行为符合预期,再上真实文件。
4. 实战:用 HexEdit 定位并修复一份二进制配置的坏字节
光会打开文件还不算能用。二进制文件查看的真正价值在“定位问题字节并安全地改掉它”。这一章拿一份虚构的模拟项目配置做例子,完整走一遍从现象到修复的流程,把偏移定位、模式查找、校验和核对这几个动作串起来。整个过程在 HexEdit 里完成,不依赖外部脚本,但思路同样适用于命令行工具。
4.1 从现象倒推偏移:用魔数和长度字段缩小范围
假设某模拟设备导出的配置文件在加载时报“段长度非法”。这类文件通常有固定结构:文件头含魔数和版本,随后是若干段,每段带 4 字节长度字段和 4 字节校验和。现象是加载失败,说明某处的长度字段或校验和对不上。
第一步是确认文件头。用 HexEdit 打开后看偏移 0x00 起的几个字节,对照协议文档确认魔数是否正确。如果魔数都不对,说明文件本身拿错了,后面不用查。魔数正确,就找段表起始位置,通常在文件头固定长度之后,比如 0x40。
第二步是用查找功能定位可疑段。如果错误信息里带了段序号,可以直接按段表结构算出该段长度字段的偏移;如果没有,就按段表逐段读长度,累加偏移,看哪一段的“长度字段值”与“下一段起始偏移减去本段数据起始偏移”对不上。这个比对是纯算术,但在十六进制视图里做很直观:长度字段就在段头,紧跟着的字节数一眼能数出来。
偏移 0x0040: 00 00 01 2C 段 0 长度 = 300 字节 偏移 0x0044: 5A 3C 11 09 段 0 校验和 偏移 0x0048: ... 段 0 数据 偏移 0x0040 + 8 + 300 = 0x0174 段 1 头应在此处如果实际在 0x0174 处看到的不是合理的段头(比如长度字段大得离谱),说明段 0 的长度字段被改过,或者数据区被截断。这时就把问题收敛到了一个 4 字节字段上。
4.2 字节级修改与校验和重算
定位到具体字节后,修改本身很简单:光标点到对应位置,输入新的两位十六进制值。但改完之后必须重算校验和,否则加载器仍然报错。校验和算法要看协议文档,常见的是段内字节累加取低 16 位,或者 CRC32。
# 修改后重算段校验和(示例算法:字节累加取低 16 位) import struct def fix_segment_checksum(data: bytearray, seg_offset: int, seg_len: int) -> None: payload = data[seg_offset + 8 : seg_offset + 8 + seg_len] checksum = sum(payload) & 0xFFFF # 校验和字段位于段头偏移 +4 处,大端写入 struct.pack_into(">H", data, seg_offset + 4, checksum) with open("config.bin", "rb") as f: buf = bytearray(f.read()) # 假设已确认段 0 从 0x40 开始,长度 300 fix_segment_checksum(buf, 0x40, 300) with open("config_fixed.bin", "wb") as f: f.write(buf)这段脚本的用途是“改完长度字段后补校验和”,不是替代 HexEdit。实际流程里,我一般先在 HexEdit 里把长度字段改对,保存出副本,再用脚本重算校验和,最后用 HexEdit 打开修好的文件核对一遍段表是否自洽。这样分工的原因是:单字节改动用图形界面肉眼确认更可靠,而批量算校验和用脚本更快也不易错。
struct.pack_into(">H", ...)里的>H表示大端无符号 16 位。如果协议是小端,改成<H。这个字节序如果搞反,校验和写进去看着“有值”,但加载器算出来永远对不上,属于典型的血泪经验——别问我是怎么知道的。
4.3 保存策略:原地覆盖还是另存副本
十六进制编辑器的保存按钮往往有两个隐含风险:一是直接覆盖原文件,改错了没有后悔药;二是覆盖时文件正被其他程序占用,写入部分失败导致文件损坏。
稳妥的做法是默认另存副本,确认无误后再替换。HexEdit 这类工具一般支持“保存”和“另存为”两个动作,日常操作里我强制自己走另存为,文件名加_fixed后缀,核对通过再改名替换。对于必须原地保存的场景,保存前先复制一份.bak,这个动作用 Finder 或命令行都行,成本极低。
还有一点是保存时的写入方式。理想实现是先写临时文件再原子替换,避免写到一半断电留下半个文件。如果源码里是直接覆盖写,遇到大文件时窗口期会很长。这一点在读源码时可以留意文档类的保存方法,看有没有用NSDataWritingAtomic选项。没有的话,自己改一行加上,收益很直接。
5. 避坑与排查:HexEdit 使用和二次开发中的五类高频问题
工具能不能用,往往不取决于主流程顺不顺,而取决于出问题时能不能快速定位。下面这五条是在二进制查看与编辑场景里反复出现的,前三条偏使用,后两条偏二次开发,每条按现象、原因、解决来写。
5.1 打开文件显示空白或内容全零
现象:双击打开一个非空文件,视图区一片空白或者全是00,但文件大小显示正常。
原因:最常见的是内存映射失败后没有回退路径。文件位于网络卷、加密卷或权限受限的目录时,NSDataReadingMappedIfSafe会返回 nil 或映射到不可读区域;如果代码没有检查返回值就继续用缓冲区,渲染层读到的就是零页。另一种可能是文件确实以大量零字节开头,被误判为空白。
解决:先在终端用ls -l和xxd -l 64 文件名确认真实内容,排除文件本身问题。然后检查源码中加载方法的错误分支,补上映射失败时回退到普通读取的逻辑,并在界面上给出明确提示,而不是静默显示空白。
5.2 中文或非 ASCII 字节在 ASCII 栏显示异常
现象:右侧 ASCII 栏对某些字节显示为乱码、方块或直接省略,导致按内容定位时对不上。
原因:ASCII 栏的职责是把 0x20 到 0x7E 的可打印字符显示出来,其余字节应显示为点号。如果渲染时直接按某种编码解码整段数据,遇到非 ASCII 字节就会出问题;另一种情况是用了系统默认编码而不是显式指定,导致行为随环境变化。
解决:渲染逻辑里对每个字节单独判断,0x20 <= b <= 0x7E才转字符,否则一律输出.。不要对整行做字符串解码。这个规则写死在视图层,与文件内容无关,才能保证任何二进制文件打开后 ASCII 栏的显示都稳定可预期。
5.3 查找功能在跨行边界时漏匹配
现象:明明文件里有目标字节序列,用查找却提示未找到;把查找范围缩小到某一行内又能找到。
原因:查找实现按行缓冲做匹配,没有处理跨行的情况。每行 16 字节,目标序列如果从第 14 字节开始、延续到下一行,按行独立匹配就永远命中不了。这类 bug 在小文件上不容易发现,因为人眼扫一遍就找到了。
解决:查找应在整个字节缓冲上做滑动匹配,与行的概念完全解耦。实现上可以简单地对缓冲区做逐字节比较,数据量不大时性能足够;大文件则用 Boyer-Moore 之类的算法。改完之后用一个跨行构造的测试文件验证,比如把目标序列故意放在偏移 14 的位置。
5.4 编译通过但运行时崩溃在视图绘制
现象:应用启动正常,一打开文件就崩溃,堆栈停在绘制相关方法。
原因:老项目常见的是部署目标与新 SDK 的兼容问题,比如某些绘制 API 在新系统上行为变化,或者NSView的初始化流程调整后,代码里假设的调用顺序不再成立。另一个高发点是数组越界:行数计算用整除,最后一行不足 16 字节时,某些循环仍然按满行访问。
解决:先在崩溃堆栈定位到具体方法,检查所有按bytesPerRow做下标的循环,把上界改成min(rowStart + bytesPerRow, totalLength)。行数计算用(length + bytesPerRow - 1) / bytesPerRow向上取整。改完用 0 字节、1 字节、15 字节、16 字节、17 字节这几个边界长度的文件各测一遍,基本能覆盖干净。
5.5 修改后保存,文件大小变了
现象:只改了几个字节,保存后文件比原来长了或短了,加载器直接拒绝。
原因:保存路径走了文本编码转换。比如内部把缓冲区转成字符串再写回,非 ASCII 字节被重新编码,长度自然变化。另一种是用了带 BOM 或行尾转换的写入接口。
解决:保存必须走二进制写入,直接写NSData或字节缓冲区,全程不经过字符串编解码。检查保存方法里有没有NSString参与的中间步骤,有就删掉。改完后对同一文件做“打开 → 不改 → 保存”的操作,比对前后哈希,必须完全一致,这是最基本的自检。
6. 进阶:把 HexEdit 当作二进制比对的日常工具
用熟之后,HexEdit 的价值不只是“看字节”,而是成为一套轻量的二进制核对流程里的固定环节。下面几个习惯是我长期用下来觉得收益最高的,配合少量脚本能把很多重复劳动压掉。
第一个习惯是先算哈希再动手。任何要修改的文件,打开前先记一份哈希,改完再算一次,两次一致说明你根本没改到东西,不一致才继续往下核对。命令很简单:
# 修改前后各执行一次,确认确实发生了变化 shasum -a 256 config.bin第二个习惯是用固定长度的切片做比对。两份文件差异很多时,肉眼在十六进制视图里翻效率很低,先用cmp定位第一个不同字节的偏移,再跳到那个位置看上下文:
# 输出第一个差异字节的偏移和值,-l 会列出所有差异 cmp -l a.bin b.bin | head -20cmp -l的输出是“偏移(十进制) 八进制值1 八进制值2”三列,第一列减一就是十六进制视图里的偏移。注意它是十进制,跳到 HexEdit 里要换算,这一步换算错位是高频翻车点,建议直接用printf '%x\n' 偏移值转一下再跳。
第三个习惯是把常用结构的偏移做成一张表。协议文档里的字段偏移是给人看的,实际调试时换算成一份固定清单贴在旁边,能省掉大量心算。下面这张表是模拟项目里的示例,字段名、偏移、长度、字节序四列就够用:
| 字段 | 偏移 | 长度 | 字节序 |
|---|---|---|---|
| 魔数 | 0x00 | 4 | 大端 |
| 版本号 | 0x04 | 2 | 大端 |
| 段表数量 | 0x06 | 2 | 大端 |
| 段 0 长度 | 0x40 | 4 | 大端 |
| 段 0 校验和 | 0x44 | 2 | 大端 |
第四个习惯是改完必回读。改一个字节、保存、关闭、重新打开、跳到同一偏移确认值已变,这四步看着啰嗦,但能挡住“改了没保存”“保存到了另一个副本”“改的是缓存不是文件”这三类问题。我早期做固件调试时吃过一次亏,界面上显示改好了,实际写盘失败,烧进去的设备直接不启动,从那以后每次改完二进制我都强制走一遍回读。
最后一个技巧是善用只读模式做探索。不确定结构时,把文件权限改成只读再打开,或者在 HexEdit 里直接以查看方式浏览,避免手滑误触键盘改掉字节。二进制编辑没有撤销的后悔药,一个误敲的字符可能让整个文件失效,先用只读模式把结构摸清,再切到可写状态做精确修改,比事后靠备份恢复省事得多。希望这些习惯能帮到你。
本文还有配套的精品资源,点击获取