简介:HexEdit 是一份来自 GitHub 的开源十六进制编辑器项目源码包,面向程序员、逆向工程与安全研究人员、游戏修改爱好者,以及需要查看和编辑二进制文件的开发者。它提供直观的十六进制界面,可用于搜索数据模式、修改字节内容、修复损坏文件,也适合调试可执行文件和分析恶意样本。压缩包共 220 个文件,约 528KB,包含 C/C++ 与头文件等核心实现、Visual Studio 工程与筛选器配置、批处理构建脚本,以及图标和位图等界面资源,目录结构便于梳理编译流程与界面实现。已有 204 人学习下载。读者可据此获得一套可编译、可二次开发的编辑器源码,结合构建脚本与工程文件快速还原开发环境,并借鉴其十六进制显示、文件操作和资源组织思路,用于工具开发、安全分析或课程实践。
1. 打开一个 2GB 的固件包,为什么它不卡
拿到一份固件、一段崩溃日志或者一个没文档的存档文件时,第一反应是拿文本编辑器打开,结果满屏乱码、光标乱跳;退回命令行敲xxd,看得清却改不了。HexEdit解决的就是这个位置上的问题:它是 macOS 上的图形化十六进制编辑器,仓库在strobejb/HexEdit,双击打开二进制文件就能按字节查看、定位、改写、保存,不用再在xxd、hexdump和脚本之间来回倒腾。做嵌入式固件比对、文件格式逆向前期摸底、崩溃日志里翻魔数、改存档改配置的人,基本都会需要这么一把手术刀。这类工具真正的门槛不在“显示十六进制”,而在二进制文件查看时对大文件的处理方式——整份读进内存还是按需映射,直接决定了打开一个 2GB 文件是秒开还是转菊花。下面按工程结构、核心渲染链路、编译验证、踩坑、二次开发这条线,把它拆开看。
2. 工程结构与技术选型:一个 AppKit 文档型应用的骨架
2.1 目录分层与 target 划分
这类 AppKit 工程我翻过不少,源码目录基本不会偏离职责分层这个形状——就算文件夹名字对不上,按职责去找也能在五分钟内定位。它不是一个把所有东西塞进ViewController的玩具工程,而是一个标准的 document-based 应用,右键菜单里的“打开最近使用”“另存为”“还原到上次版本”都是框架给的,不是自己写的。
| 层 | 典型文件/目录 | 职责 | 二次开发时的改动频率 |
|---|---|---|---|
| App 入口层 | main.m、AppDelegate、Info.plist | 启动、文档类型注册(UTI / CFBundleDocumentTypes)、菜单 | 低 |
| 文档层 | HexDocument(NSDocument 子类) | 文件读写、字节缓冲、撤销栈、脏标记 | 中 |
| 视图层 | HexView、HexWindowController | 行渲染、光标与选区、滚动与分页 | 高 |
| 模型/工具层 | 字节查找、编码转换、格式化 | 纯函数式逻辑,最好零依赖 | 高 |
| 资源 | Assets.xcassets、本地化字符串 | 图标、菜单文案 | 低 |
Info.plist里最值得看的是CFBundleDocumentTypes和UTExportedTypeDeclarations。前者决定了你在 Finder 里能不能“用 HexEdit 打开”,后者决定了系统怎么识别这个应用能处理什么文件。很多人改完功能发现拖拽打开还是不生效,八成是没在这里声明,跟代码一行关系都没有。
现代 Xcode 建出来的工程还会带*.entitlements。如果开了 App Sandbox,用户通过 NSOpenPanel 选中的文件系统会自动给一个 security-scoped bookmark,但如果你自己拼路径去读/tmp下的东西,会被静默拒绝——表现就是“文件明明存在却打不开”,这个坑在第五章会细说。
2.2 document-based:NSDocument 帮你兜住了什么
自己拿NSWindowController拼一个编辑器不是不行,但那意味着多窗口管理、最近打开列表、自动保存、版本浏览、撤销栈的生命周期,全得自己写一遍。NSDocument把这些都封好了,代价是你得按它的规矩实现几个方法。
// HexDocument.h —— 文档层只暴露"字节"这一件事,视图层不碰文件 IO @interface HexDocument : NSDocument @property (nonatomic, strong) NSMutableData *buffer; // 全量或分页的字节载体 @property (nonatomic, assign) BOOL dirty; // 脏标记,交给 updateChangeCount 也行 - (BOOL)replaceBytesInRange:(NSRange)range withBytes:(const uint8_t *)bytes; @end// HexDocument.m —— 读文件用 mappedIfSafe,写文件走临时文件 + 原子替换 - (BOOL)readFromURL:(NSURL *)url ofType:(NSString *)typeName error:(NSError **)outError { // NSDataReadingMappedIfSafe:小文件直接读进内存,大文件走 mmap,由系统决定 NSData *data = [NSData dataWithContentsOfURL:url options:NSDataReadingMappedIfSafe error:outError]; if (!data) return NO; self.buffer = [data mutableCopy]; // 要编辑就必须可写,mmap 的只读映射在这里被复制 self.dirty = NO; return YES; } - (BOOL)writeToURL:(NSURL *)url ofType:(NSString *)typeName error:(NSError **)outError { // 原子写入:先写临时文件再 rename,中途崩溃不会留下半截文件 return [self.buffer writeToURL:url options:NSDataWritingAtomic error:outError]; }NSDataReadingMappedIfSafe这个选项是整个工程的性能分水岭。它的语义是“系统认为安全就 mmap,不安全就老老实实读”。对只读查看来说这是最优解——页缓存负责换入换出,你不需要自己写 LRU。但注意下一行mutableCopy:一旦要编辑,映射出来的只读页会被整体复制成一块可写内存,2GB 的文件在这一步会实打实吃掉 2GB 物理内存,打开瞬间的卡顿就来自这里。
writeToURL:options:NSDataWritingAtomic同样值得说一句。原子写会先在目标目录下建临时文件,写完再rename覆盖。好处是断电不会毁文件,坏处是瞬时磁盘占用翻倍,改一个 8GB 的镜像时磁盘可能直接满——这个现象很迷惑,报错信息只会告诉你“无法写入”。
2.3 字节缓冲:整文件读进内存、mmap 还是分页
三种方案在这个项目场景下的取舍很明确,我做类似工具时基本按这张表决策:
| 方案 | 打开速度 | 内存占用 | 编辑支持 | 适用 |
|---|---|---|---|---|
NSData全量读入 | 与文件大小线性相关 | 等于文件大小 | 天然支持 | 小于几百 MB 的固件、日志 |
mmap只读映射 | 近乎瞬时 | 按页换入,峰值低 | 需要 COW 复制 | 纯查看、超大镜像 |
| 分页 + 窗口缓冲 | 瞬时 | 可控(如 4MB 窗口) | 需要脏页回写 | 真正的大文件编辑器 |
HexEdit 这类实现走的是前两条路的组合:默认映射打开保证“点开就有”,真正发生编辑时再决定是整块复制还是按页 COW。如果你要改造成能吃下几十 GB 的镜像,就得换成第三条路——自己维护一个NSMutableData窗口,记录窗口起始偏移和脏页位图,滚出窗口时把脏页刷回文件句柄。工作量不小,但这是唯一能撑住的方向。
3. 核心模块拆解:从文件打开到十六进制渲染的完整链路
3.1 建立缓冲与行索引
打开文件之后第一件事不是画界面,是算清楚“一共多少行”。十六进制编辑器默认一行 16 字节,行数 =ceil(length / 16),这个数字决定了滚动条的范围、也决定了能不能做虚拟滚动。我见过把行数算成length / 16的实现,结果文件末尾不足 16 字节的残行永远显示不出来,用户以为文件被截断了。
// HexDocument.m —— 打开后立刻算好布局,视图层只查表 - (void)rebuildLayoutWithBytesPerLine:(NSUInteger)bpl { self.bytesPerLine = bpl ?: 16; // 常见 16,也有工具用 8 或 32 self.bytes = self.buffer.bytes; // 缓存裸指针,避免循环里反复发消息 self.byteLength = self.buffer.length; self.lineCount = (self.byteLength + self.bytesPerLine - 1) / self.bytesPerLine; // 向上取整,别漏残行 self.lastLineBytes = self.byteLength % self.bytesPerLine; // 0 表示整除 }三个参数要盯住:bpl是每行字节数,改它要连带重算lineCount和光标位置映射;bytes是裸指针缓存,buffer一旦被replaceBytesInRange:触发重新分配就会失效,必须重新赋值,否则就是野指针;lastLineBytes为 0 时表示最后一行是满的,渲染层做补空格判断时要区分“0”和“没有残行”这两种语义。
裸指针缓存这招在渲染循环里收益很大——一行 16 个字节,每字节发一次NSData的getBytes:range:消息,一屏 50 行就是 800 次 Objective-C 消息发送,滚动起来立刻能感觉到滞涩。
3.2 十六进制渲染与 ASCII 栏对齐
渲染是整个项目里最见功力的地方。教科书式的 hex dump 长这样:左侧 8 位偏移,中间 16 组两位十六进制,右侧 ASCII 预览,不可打印字符用.占位。看着简单,但要保证等宽字体下三栏永远对齐,边缘情况一堆。
// HexView.m —— 单行格式化:偏移 + 16 字节 + ASCII gutter - (NSString *)dumpLineAtOffset:(NSUInteger)offset bytesPerLine:(NSUInteger)bpl { NSMutableString *line = [NSMutableString stringWithFormat:@"%08lx ", (unsigned long)offset]; NSMutableString *ascii = [NSMutableString stringWithCapacity:bpl]; for (NSUInteger i = 0; i < bpl; i++) { if (offset + i < self.byteLength) { uint8_t b = self.bytes[offset + i]; [line appendFormat:@"%02x ", b]; // 0x20~0x7e 才是可见 ASCII,其余一律打点,别让控制字符破坏布局 [ascii appendFormat:@"%c", (b >= 0x20 && b <= 0x7e) ? b : '.']; } else { [line appendString:@" "]; // 残行补三个空格,宽度和 "%02x " 严格一致 [ascii appendString:@" "]; } if (i == 7) [line appendString:@" "]; // 第 8 字节后加一列空隙,肉眼数位不容易错 } [line appendFormat:@" |%@|", ascii]; return line; }几个细节值得单独拎出来:
%08lx的 8 位是下限不是上限,超过 4GB 的文件偏移会自然变成 9 位,整块布局会横向抖一下。要么用%08llx加固定列宽裁剪,要么在byteLength > 0xFFFFFFFF时统一切到 16 位偏移显示。
i == 7那个分栏空格不是装饰。人眼数 16 个两位组很容易错位,中间断开成 8 + 8 之后,定位速度快一个量级——这是纯体感优化,但做过的人都会加。
ASCII 栏判断用的是0x20 <= b <= 0x7e。有人图省事写成isprint(b),在en_US.UTF-8locale 下它对 0x80 以上的字节会返回真,直接导致 ASCII 栏里冒出乱码字符、整行宽度失控。这类 locale 依赖的 C 函数在二进制工具里最好一个都别用。
真正要性能的话,这个函数不该返回NSString。滚动时每行建一个字符串对象,一屏 50 行、每秒 60 帧,ARC 的压力肉眼可见。成熟做法是直接往CGContext里画,或者用NSAttributedString做行缓存、只重绘脏行。
3.3 编辑、撤销与写回磁盘
编辑这一步的关键不是“改字节”,是“改完能后悔”。二进制文件改错一个字节,可能就是刷砖,所以撤销栈必须扎实。
// HexDocument.m —— 先存旧值注册撤销,再落新值,最后只通知脏区间重绘 - (BOOL)replaceBytesInRange:(NSRange)range withBytes:(const uint8_t *)newBytes { if (range.location > self.byteLength || NSMaxRange(range) > self.byteLength) { return NO; // 越界直接拒绝,别指望上层帮你兜 } NSData *old = [self.buffer subdataWithRange:range]; // 后悔药先备好 [[self undoManager] prepareWithInvocationTarget:self]; [self replaceBytesInRange:range withData:old]; // 递归注册反向操作 [self.buffer replaceBytesInRange:range withBytes:newBytes length:range.length]; self.bytes = self.buffer.bytes; // 可能已重新分配,裸指针必须刷新 [self updateChangeCount:NSChangeDone]; // 交给 NSDocument 打脏标记 [self.noteViewsChangedInRange:range]; // 只重绘受影响的行,别整屏 invalidate return YES; }prepareWithInvocationTarget:加递归调用是NSUndoManager的标准套路:注册的动作是“用旧数据再调一次自己”,于是撤销和重做自动互为反向。这里有个隐蔽的坑——递归调用会再次注册一次撤销,形成链式结构,这是正确的,但如果你在replaceBytesInRange:withData:里又调了updateChangeCount:,脏标记会被计两次,关闭窗口时的“是否保存”提示逻辑就会乱。所以反向方法要和正向方法分开,别图省事合成一个。
self.bytes = self.buffer.bytes;这行看着废,其实是保命的。NSMutableData在长度变化时会重新malloc,之前缓存的裸指针立刻变成野指针,崩溃现场通常在渲染层,堆栈跟编辑操作八竿子打不着,排查起来非常费劲。
写回磁盘还有一个容易忽略的点:只写脏区间还是整份重写。文件小无所谓,文件大了整份重写就是分钟级操作。想做增量保存,就得维护脏页位图并用NSFileHandle定位写:
// 增量保存:只回写真脏过的页,适合大文件小改动 NSFileHandle *fh = [NSFileHandle fileHandleForUpdatingURL:url error:&err]; for (NSUInteger page = 0; page < self.dirtyPageCount; page++) { if (![self isPageDirty:page]) continue; [fh seekToFileOffset:page * kPageSize]; [fh writeData:[self.buffer subdataWithRange:NSMakeRange(page * kPageSize, kPageSize)]]; } [fh synchronizeFile];代价是失去了原子性——写到一半崩了,文件就是半新半旧的状态。我的做法是增量写之前先复制一份.bak,用户明确知道备份在哪,比默默“保证原子性”却把磁盘塞满要实在。
3.4 搜索:从朴素匹配到跨块扫描
十六进制搜索和文本搜索最大的区别是模式本身就是字节,不存在编码问题,但存在“十六进制串怎么写”的问题。用户输入48 65 6C 6C 6F和输入Hello应该等价,解析层得把两种写法都吃下去。
// HexDocument.m —— 朴素匹配,先比首字节筛掉绝大多数位置 - (NSArray<NSNumber *> *)findAllOccurrencesOf:(NSData *)pattern { NSMutableArray<NSNumber *> *hits = [NSMutableArray array]; NSUInteger n = pattern.length; if (n == 0 || self.byteLength < n) return hits; const uint8_t *hay = self.bytes; const uint8_t *needle = pattern.bytes; for (NSUInteger i = 0; i + n <= self.byteLength; i++) { if (hay[i] != needle[0]) continue; // 首字节不等就跳,省掉 memcmp 调用开销 if (memcmp(hay + i, needle, n) == 0) { [hits addObject:@(i)]; i += n - 1; // 不允许重叠匹配;要重叠就删掉这行 } } return hits; }i += n - 1这行是个语义选择。搜AA AA在一个AA AA AA的序列里,不允许重叠只出 1 个结果(偏移 0),允许重叠出 2 个(偏移 0 和 1)。做固件特征码扫描时通常要重叠,做“查找下一个”时通常不要,把这个做成偏好设置比替用户拍板要好。
跨块扫描是分页方案绕不过去的坎。如果缓冲被切成 4MB 一块,一个匹配跨在两块边界上就会被漏掉,表现是“明明有却搜不到”,这种玄学问题极难复现。标准解法是每次多读patternLength - 1字节的 overlap 区域,代价是边界处的匹配要按绝对偏移去重。
4. 编译与运行:用 Xcode 把 HexEdit 跑起来
4.1 环境准备与依赖检查
这是一个纯 AppKit 工程,没有 CocoaPods、没有 Swift Package 依赖,理论上下载下来就能编。真正会卡住人的是 Xcode 版本和代码签名两件事。
先把工程拉下来,用-list看目标名,别照抄网上的命令:
git clone https://github.com/strobejb/HexEdit.git cd HexEdit # 先看清楚 scheme 和目标到底叫什么,不同年份的工程命名不一样 xcodebuild -list -project HexEdit.xcodeproj # 顺便看一眼最低部署版本,太低的话新 Xcode 会直接拒绝编译 /usr/libexec/PlistBuddy -c "Print :LSMinimumSystemVersion" HexEdit/Info.plist 2>/dev/null || truexcodebuild -list输出里会分Targets、Build Configurations、Schemes三块。老工程经常只有 Target 没有 Scheme,这时候用-target而不是-scheme,或者先在 Xcode 里打开一次让它自动生成 scheme。部署版本低于 10.13 的话,新版 SDK 会报一堆弃用错误——NSFileHandle的部分老 API、32 位时代的类型定义都会跳出来。
4.2 三条命令把工程编出来
第一遍编译一定要关签名。签名相关的报错会盖掉真正的编译错误,你先确认“代码本身能过”,再去折腾证书。
# 第一遍:关签名、指定 DerivedData,先确保编译通过 xcodebuild -project HexEdit.xcodeproj \ -scheme HexEdit \ -configuration Debug \ -derivedDataPath ./build \ CODE_SIGNING_ALLOWED=NO \ build # 编译成功后,产物在这里,直接双击就能跑 open ./build/Build/Products/Debug/HexEdit.app三个参数的作用:-configuration Debug保留符号和断言,排查阶段别用 Release;-derivedDataPath ./build把中间产物收到工程目录下,卸载时删一个文件夹就干净了,不会污染全局 DerivedData;CODE_SIGNING_ALLOWED=NO是最关键的一个,它让整个编译链跳过签名步骤。
跑起来之后如果要给别人分发,再回来做签名:
# 开发期用本地临时签名就够,不需要开发者账号 codesign --force --deep --sign - ./build/Build/Products/Debug/HexEdit.app # 验证签名是否生效,输出里应该有 "satisfies its Designated Requirement" codesign --verify --verbose=2 ./build/Build/Products/Debug/HexEdit.app--sign -里的横杠表示 ad-hoc 签名,特点是只能在本机运行,换台机器就会被 Gatekeeper 拦。要真正分发得用Developer ID Application证书加公证,那是另一套流程。自己用的话 ad-hoc 完全够。
4.3 用已知字节的 fixture 做回归验证
编译通过不等于功能正确。改过渲染或编辑逻辑之后,我习惯用一份每个字节都已知的测试文件跑一遍,肉眼看偏移、看 ASCII、看尾部残行,比点来点去靠谱得多。
# make_fixture.py —— 造一份字节全部可预测的测试文件 import zlib head = b"HEXEDIT-TEST" + b"\x00" * 4 # 前 16 字节:可见串 + 4 个 0x00 body = bytes(range(256)) * 4096 # 后面接 0x00..0xFF 循环,正好 1 MiB data = (head + body)[:1024 * 1024] # 截到整 1 MiB,方便算行数 with open("fixture.bin", "wb") as f: f.write(data) print("size :", len(data)) # 1048576,除以 16 正好 65536 行 print("crc32 :", hex(zlib.crc32(data))) print("offset 16 :", hex(data[16])) # 应该是 0x10,改它之前先记下来 print("last byte :", hex(data[-1]))这份 fixture 的每一处都有校验意义:偏移 0 处是48 65 78 ...,ASCII 栏应该显示HEXEDIT-TEST....;偏移 16 处的值是0x10,改成别的再撤销,来回切几次看撤销栈对不对;总长度 1048576 能被 16 整除,用来验证“没有残行”的情况,再砍掉 3 个字节就能测残行补位。
打开之后按这个清单走一遍,五分钟能覆盖大部分回归:
| 检查项 | 预期表现 | 不通过说明什么 |
|---|---|---|
| 偏移 0 行的 ASCII 栏 | HEXEDIT-TEST.... | ASCII 可打印判断写错了 |
| 滚动到文件末尾 | 最后一行完整,无残行 | lineCount向上取整漏了 |
| 拖到第 65535 行 | 偏移显示0000fff0 | 偏移格式化宽度不够 |
| 改偏移 16 处再撤销 | 值回到10 | 撤销栈注册或裸指针刷新有问题 |
| 另存为再比对 CRC | 与原文件一致 | 写入路径有隐式转换 |
最后一项最容易翻车。另存为之后用python3 -c "import zlib;print(hex(zlib.crc32(open('out.bin','rb').read())))"对一下 CRC,如果对不上,说明中间某处把字节当字符串处理了——这是二进制工具的头号杀手,第五章展开。
5. 避坑与排查:签名、编码、大文件这三类翻车最费时间
5.1 现象:双击 app 提示“已损坏,建议移到废纸篓”
原因:这条提示跟文件损坏没关系。ad-hoc 签名或者签名后又被改动(比如替换了Info.plist、往里塞了资源),Gatekeeper 的校验链断了,统一报这个文案。
解决:先看真实原因再动手,别急着xattr -d。
# 看真正的拒绝理由,输出里拒载原因写得比弹窗清楚得多 spctl --assess --type execute --verbose=4 ./build/Build/Products/Debug/HexEdit.app # 确认是签名问题就重新 ad-hoc 签一遍 codesign --force --deep --sign - ./build/Build/Products/Debug/HexEdit.app如果spctl报的是code object is not signed at all,那是压根没签;报invalid signature才是签了但被改动。两种情况都重新签一次即可。xattr -dr com.apple.quarantine只对“从网络下载”的场景有效,本地编译出来的产物根本不会有这个属性,套上去没用。
5.2 现象:打开几百 MB 的文件界面卡死十几秒,风扇狂转
原因:渲染走了“全量格式化”的路子——打开时把所有行都生成NSString塞进NSTextView。NSTextView的布局引擎是为文本设计的,几百万行文本它会认真算每一次换行和字形,卡死是必然的。
解决:改成按可视区域虚拟渲染。NSScrollView的documentView高度设为lineCount * lineHeight,但只对可见行 + 上下各一屏的缓冲行生成内容,滚动时重算可见区间。
// HexView.m —— 只渲染可视区间,滚动时重算 - (void)drawRect:(NSRect)dirtyRect { NSRange visible = [self visibleLineRange]; // 由 scrollView 的 bounds 反推行号 NSUInteger first = visible.location > 2 ? visible.location - 2 : 0; // 上缓冲两行 NSUInteger last = MIN(NSMaxRange(visible) + 2, self.lineCount); // 下缓冲两行 for (NSUInteger line = first; line < last; line++) { NSString *text = [self dumpLineAtOffset:line * self.bytesPerLine bytesPerLine:self.bytesPerLine]; [text drawAtPoint:NSMakePoint(0, line * self.lineHeight) withAttributes:self.textAttributes]; } }上下各留两行缓冲是为了滚动时不出白边。lineHeight一定要跟所用等宽字体的行高严格一致,差 0.5 点,滚到第 10 万行时偏移就肉眼可见了。
5.3 现象:改了 3 个字节,另存之后文件大了几十字节
原因:写入路径上把NSData转成了NSString又转回来。NSString→NSData这一步会按 UTF-8 编码,非 ASCII 字节被展开成 2~3 字节;如果中间还走了stringByAppendingString:@"\n"或者writeToFile:atomically:encoding:,行尾还会被转换。二进制文件全程只能碰NSData和uint8_t,一次都不能碰字符串。
解决:搜一遍工程里所有stringWithContentsOf、writeToFile:atomically:encoding:、stringByAppending,只要上下文跟文件读写在一条链路上就全换掉。判断标准很简单——任何带encoding:参数的方法,出现在二进制路径上就是错的。
5.4 现象:用 NSOpenPanel 选的文件能打开,程序里自己拼的路径打不开
原因:App Sandbox 开着,只有用户通过面板显式授权的路径才在沙盒白名单里。自己用NSSearchPathForDirectoriesInDomains或者硬编码/tmp/xxx拼出来的路径,NSData读取会返回 nil,NSError里是NSFileReadNoPermissionError。
解决:调试期先在 target 的 Signing & Capabilities 里把 Sandbox 关掉,确认逻辑没问题;要保留沙盒就得走 security-scoped bookmark,选完文件立刻存 bookmark,下次启动用startAccessingSecurityScopedResource恢复权限,用完必须配对调用stopAccessing...,漏了会句柄泄漏。
5.5 现象:搜十六进制串搜不到,搜 ASCII 字符反而能中
原因:输入解析层只处理了文本模式。用户输入DE AD BE EF时被当成字面 11 个字符去匹配,当然搜不到。另一种情况是输入框做了“智能转换”,把空格吃掉又没按两位一组切分。
解决:解析函数里做严格的分支判定——去掉空格后如果全部匹配^[0-9a-fA-F]+$且长度为偶数,按十六进制解析,否则按 UTF-8 文本解析。两个分支都失败的输入要明确报错,不要静默降级成文本搜索,那样用户会以为文件里真没有。
6. 二次开发:给 HexEdit 加一层结构体模板与校验面板
这个工具最实用的改造方向不是加功能,是把“看懂字节”这件事往前推一步。原始状态下你能看到DE AD BE EF,但看不出它是什么;加一层结构体模板,选中一段就能按magic / version / flags / length展开成字段。我的做法是在模型层加一个轻量描述结构,视图层只负责展示,解析逻辑跟 UI 完全解耦。
// FileHeader 是典型的大端格式,读出来必须先转字节序再展示 typedef struct { uint32_t magic; // 0x00 魔数 uint16_t version; // 0x04 版本 uint16_t flags; // 0x06 标志位 uint32_t payloadLen; // 0x08 负载长度 } FileHeader; - (NSDictionary *)parseHeaderAtOffset:(NSUInteger)offset { if (offset + sizeof(FileHeader) > self.byteLength) return nil; // 越界必须先判,别指望 memcpy 兜 FileHeader h; memcpy(&h, self.bytes + offset, sizeof(FileHeader)); // 先拷到栈上,避免非对齐访问 return @{ @"magic" : @(ntohl(h.magic)), // 大端转主机序 @"version" : @(ntohs(h.version)), @"flags" : @(ntohs(h.flags)), @"payloadLen" : @(ntohl(h.payloadLen)) }; }memcpy到栈上这一步不能省。直接(FileHeader *)ptr强转在 ARM64 上会因非对齐访问崩掉,而二进制文件里的结构体偏移几乎不可能恰好对齐。ntohl系列同理——小端机器上不转,读出来的payloadLen会是天文数字。
第二个改造点是 CRC 校验面板,配合上面的模板面板,改完负载立刻能看到校验值变没变:
// 对任意选区算 CRC32,用来判断改完的字节还"合不合法" - (uint32_t)crc32OfRange:(NSRange)range { static uint32_t table[256]; static dispatch_once_t once; dispatch_once(&once, ^{ for (uint32_t i = 0; i < 256; i++) { uint32_t c = i; for (int k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); table[i] = c; } }); uint32_t crc = 0xFFFFFFFFu; const uint8_t *p = self.bytes + range.location; for (NSUInteger i = 0; i < range.length; i++) crc = table[(crc ^ p[i]) & 0xFF] ^ (crc >> 8); return crc ^ 0xFFFFFFFFu; }查表用dispatch_once初始化只跑一次;0xEDB88320是 CRC32 反射多项式的标准值,跟 Python 的zlib.crc32结果一致,正好能拿第 4 章那份 fixture 对账。
改造优先级我按投入产出排了张表:
| 改造点 | 工作量 | 风险 | 值不值得先做 |
|---|---|---|---|
| 结构体模板面板 | 半天 | 低,只读不改文件 | 值得 |
| CRC / 校验和面板 | 半天 | 低 | 值得 |
| 虚拟滚动渲染 | 1~2 天 | 中,涉及光标坐标映射 | 大文件必做 |
| 增量保存 + 脏页位图 | 3 天以上 | 高,失去原子性 | 只在超大文件场景做 |
| 命令行 diff 子命令 | 1 天 | 低,可独立成 target | 有批量需求再做 |
验证方式很朴素:改完结构体面板,拿第 4 章那份 fixture 打开,偏移 0 的 magic 应该显示成0x48455845(HEXE的大端值),不对就是字节序转错了;改完 CRC 面板,选区算出来的值跟python3 -m zlib那行输出对一遍,差一位就是多项式或者初值写错了。
从那次把一个 4GB 镜像改崩、又没有备份之后,我每次动二进制文件之前都强制先跑一遍cp留一份原件,并且在工具里把“只读打开”设成默认——想编辑得再点一次,多这一下,救回来过好几个通宵。希望帮到你。
本文还有配套的精品资源,点击获取