简介:HexEdit十六进制编辑控件是一套基于VC++环境开发的二进制编辑组件,主要面向需要直接查看和修改配置、日志、数据库及图像等原始二进制数据的桌面程序开发者,常见于数据恢复、逆向工程、系统维护与软件调试等场景。资源包共53个文件、约2.02MB,既包含h/cpp源码头文件与实现代码,也带有sln/vcxproj/dsp等多套工程文件、可运行的exe演示程序、rc/ico界面资源及Debug编译中间产物,目录层次清晰,打开工程即可对照学习。目前已有97人学习浏览,属于实用型小型组件参考。该控件提供读写文件API、查找替换、插入删除、双视图显示等核心能力,演示项目完整覆盖从控件嵌入、界面初始化到具体调用的流程;同时附带高亮设置对话框、文档/视图结构等模块,有助于理解MFC框架下的组件封装思路。对于正在开发类似十六进制编辑器或需要集成二进制查看功能的VC++开发者,这套源码加示例能提供可直接借鉴的接口设计与交互逻辑,是快速上手和二次扩展的实用参考。
1. 为什么 VC 工程里需要 hexedit:一个能嵌进对话框的 16 进制编辑控件
第一次在 VC 工程里塞进一个 hexedit 十六进制编辑控件,我原以为跟放个文本框差不多,真正用起来才发现这套东西把内存缓冲区和屏幕视图之间那条链路做得很讲究。它本质上是一个自绘的 16 进制编辑器控件,能直接嵌入 MFC 对话框或视图窗口,把一段 BYTE 数组滚动渲染成偏移地址、十六进制字节、ASCII 三列视图,还支持光标移动、选区和直接改字节。我在解析自定义文件格式、调试通信协议封包、查看图片文件头时都靠它定位问题,比反复导出文件再用外部工具看省事得多。需要它的通常是两类人:一类是要在工具里内嵌二进制查看编辑能力的 VC 开发者,另一类是初学自绘控件、想读一套实现完整的代码来学绘制和滚动逻辑的人。
2. 吃透控件核心机制:数据模型、绘制循环与滚动偏移量换算
2.1 数据模型:控件管的是内存块,不是文件句柄
hexedit 控件内部维护一个动态字节缓冲区,数据来源是手动写入或从文件读入,它不直接持有文件句柄。这个设计很关键:文件 IO 和保存策略全部留在外部,控件只负责处理内存里的字节流。拆开之后的好处是,外部调用方可以自由选择用 CFile、CreateFile 还是直接把网络收包塞进来,控件不需要关心数据是从磁盘来还是从 socket 来。反之,如果控件内部直接包了文件操作,保存时机、失败重试、只读文件这些情况都得控件自己兜着,复杂度会上去不少。
结构上,控件把字节流组织成行。默认每行 16 字节,左侧显示逻辑偏移,中间按字节显示两位十六进制数,右侧是 ASCII 字符区,不可打印的字节统一显示成点。这种三区布局是十六进制编辑器的通用标准,好处是肉眼扫数据时能同时看到偏移、原始字节和可读文本。外部先把文件读进 BYTE 数组,再把指针和长度交给控件的初始化接口。更稳妥的做法是调用方自己管理那块内存,在文件关闭前把缓冲区拷进控件内部,避免文件句柄失效后数据被释放。
2.2 绘制循环与双缓冲:只画可见行,不整块重来
很多人在网上反馈这个控件“闪屏”,多半是绘制循环写得太粗暴,把整个缓冲区内容全部画了一遍。几 KB 的文件无所谓,一旦到了几 MB,每次滚动都全量重绘,刷新率就撑不住了。正确实现是:先拿到客户区高度,除以行高得到可见行数,再只在可见行区间内迭代。
绘制部分按三列拆开处理。第一列是地址区,画 8 位十六进制偏移,用%08X格式化;第二列是字节区,每个字节之间空一格,按每行字节数从左到右排开;第三列是 ASCII 区,逐字节判断是否可打印。这里有个强迫症级别的细节:三个分区必须用固定像素宽度分隔,不能用 Tab 或空格堆,因为等宽字体下空格是定宽的,但不同字号下宽度数值不一样。
绘制时还要处理好 DC 的选入字体。一般做法是先创建等宽字体,再在 WM_PAINT 里用 GetTextExtentPoint32 确认单字符宽度,最后才进入循环。滚动刷新时配合 ScrollWindowEx 和 InvalidateRect 做局部失效,系统会自动计算出需要无效化的矩形,这样滚动条拖动时不用整页重画。双缓冲属于锦上添花,处理方式是先画到内存 DC 再 BitBlt 到客户区,能消掉拖动滚动条时的撕裂感。
2.3 滚动条与偏移换算:滚动范围按行算,不按字节算
滚动条这块是翻车重灾区。许多人直接把缓冲区的字节数塞给 SetScrollInfo 的 nMax,结果滚动条滚到底还看不到文件最后几行,或者每滚动一格就跳过一大段。正确逻辑分三步:先算出总行数和可见行数,滚动条的 nRange 设成总行数减可见行数,nPage 设成可见行数。然后任何来自滚动条的 nPos 都要先换算成行号,再乘每行字节数,得到缓冲区起始偏移。最后鼠标点击某个字节时,还要把客户区坐标先换成行号再乘字节数,不能直接拿客户区 x 坐标当字节下标。
坐标换算有个易忽略的点:地址区宽度是固定的,中间区和 ASCII 区也各占固定像素,但不同字体下宽度数值不同。设计上应该把三个分区的像素偏移量算好存在成员变量里,点击时先判断落在哪个分区,再按各自规则换算。有些实现图省事,直接用客户区坐标除以字符宽度,结果就是点击第 15 字节却落到第 14 字节上,排查半天才发现是列宽没算对。
3. 在 MFC 工程里落地集成:Create 调用、常用接口与外观配置
3.1 两种集成路径:对话框放 Custom Control vs 动态创建
第一条路径是在资源编辑器里放一个 Custom Control,窗口类填控件注册的类名,再通过 DDX 或 GetDlgItem 拿到指针。这个方式操作简单,但有个隐藏雷点:类名必须和控件内部注册的类名完全一致,大小写都不能错,否则对话框创建时直接失败,你在界面上只会看到一个空矩形,系统也不给明确报错。
所以更多时候我走动态创建,把集成步骤拿到代码里控制。在 OnInitDialog 里先放一个占位用的 Static 或 Picture Control,读取它的 Rect 后转成相对对话框的坐标,再调用 Create。动态创建的优势是失败时能拿到返回值和错误码,而且 Create 之后立刻就能调 SetData,初始化链路短,出问题好定位。注意 Create 的样式不要沿用标准 EDIT 控件那套 ES_MULTILINE 之类的位,hexedit 是自绘控件,标准编辑框的样式位对它基本不生效,填了也是白填。
// 假设对话框资源里有一个占位控件 IDC_HEX_PLACEHOLDER CRect rc; GetDlgItem(IDC_HEX_PLACEHOLDER)->GetWindowRect(&rc); ScreenToClient(&rc); // 用自定义类创建 hexedit 控件 if (!m_hexEdit.Create(_T("hexedit"), WS_CHILD | WS_VISIBLE | WS_VSCROLL | WS_TABSTOP, rc, this, IDC_HEX_EDIT)) { // 创建失败直接返回,避免后续空指针调用 return FALSE; } // 初始化数据:文件缓冲区和长度交给控件 m_hexEdit.SetData(lpFileBuffer, dwFileSize); m_hexEdit.SetReadOnly(FALSE);这段代码的逻辑顺序是有讲究的。Create 的第一个参数只是给窗口命名的字符串,不是注册类名,真正类名由控件内部注册,调用者不用关心。第二个参数里 WS_VSCROLL 是给控件配纵向滚动条,WS_TABSTOP 保证按 Tab 切走焦点时方向键不会一起失效。ScreenToClient 容易漏,因为 GetWindowRect 拿到的是屏幕坐标,而 Create 要求相对父窗口客户端区域左上角。SetData 完成数据装载,控件内部会拷贝一份,外部释放缓冲区不影响控件显示。SetReadOnly 在数据装载之后调用,保证初始状态是编辑态。
3.2 常用接口的设计约定:数据读写、只读、选择与脏标记
集成之后真正会高频调用的接口不多,从使用角度我整理了一套通用约定。注意不同版本的头文件里接口名可能有差异,但功能面基本一致:
| 接口名(约定) | 作用 | 注意点 |
|---|---|---|
| SetData / GetData | 装载 / 导出字节缓冲区 | 内部深拷贝,GetData 需先查长度 |
| SetReadOnly / GetReadOnly | 切换只读 / 编辑态 | 只读态下点击不产生光标 |
| SetBytesPerLine / GetBytesPerLine | 调整每行字节数 | 改完必须联动重算滚动范围 |
| SetSelection / GetSelection | 设置 / 读取选区 | 返回的是缓冲区逻辑偏移 |
| IsModified / ClearModified | 判断数据是否被改过 | 任何字节修改都应该置位 |
| RegisterDataChangeCallback | 注册数据变更回调 | 外部状态栏刷新靠这个 |
SetData 的实现细节最值得看。一个合格的版本会做深拷贝,把外部传入的 BYTE 数组复制进私有缓冲区,而不是保存外部指针。这样做两个好处:外部文件改了,控件里的旧数据不受影响;控件里改了字节,外部文件也不会被意外改写。GetData 则是把当前缓冲区的有效内容回拷到外部传入的缓冲区,外部要保证缓冲区足够大。我的习惯是先调用一个 GetDataLength 查询方法,再分配空间,避免缓冲区溢出。RegisterDataChangeCallback 是编辑场景里必备的通知机制,原型一般是void (*)(DWORD dwOffset, DWORD dwDataLen),外部拿到偏移和改动长度后可以刷新状态栏或者更新校验值。
3.3 外观与字体配置:等宽字体和行高的配合
十六进制视图的对齐全靠等宽字体。如果用了系统默认字体,每列宽度不同,显示立刻错位,这个控件内部通常会固定选择等宽字体。常见做法是在 WM_CREATE 或初始化时用 CreateFont 创建一个等宽字体,再 SetFont 交给控件。字体的字符宽度会影响三列分区的像素宽度,所以字体变化之后必须重算列宽和行高。行高一般取字体的 tmHeight 加两到三像素内边距,太挤可读性差,太松又浪费纵向空间。
SetBytesPerLine 改成 32 或 8 时,地址区宽度、中间区宽度、滚动范围全部要联动重算,否则会出现字节画到地址区的情况。我一般把三个分区的绘制区域宽度计算封装成一个私有方法,在字体变化或每行字节数变化时统一调用,避免散落多处难以维护。这里还有一个显示细节:光标当前所在行的背景色通常会高亮,这个颜色建议和选中区背景色区分开,比如行高亮用浅灰、选中区用浅蓝,视觉上不会混淆。
4. 避坑手册:从创建到滚动的五处翻车现场
4.1 显示阶段的坑:创建时机和窗口类名不一致
现象:控件在对话框上显示为空白,或者只能看到一个固定矩形,无论怎么刷新都不出现十六进制内容。 原因:一种是资源编辑器里 Custom Control 填的窗口类名和控件内部注册的类名不一致,创建逻辑失败但不弹错误;另一种是控件创建成功了,但 SetData 调用时机太早,数据缓冲区还没准备好,绘制循环取到空指针,后续 WM_PAINT 也不再重新初始化。 解决:优先用代码动态创建,避免跨模块类名匹配问题;其次保证先初始化数据缓冲区再调 SetData;最后在 OnInitDialog 返回前主动调用一次Invalidate(FALSE),强制触发首帧绘制,别等系统调度。
4.2 滚动和绘制阶段的坑:滚动范围单位不一致导致滚不到尾
现象:缓冲区有几百 KB,滚动条拖到底仍停在文件中间位置,最后几页永远看不到。 原因:ScrollInfo 里的 nMin、nMax、nPage 直接用了字节数,而绘制循环按行号迭代。滚动条的单位是字节,页长是行数,两套单位混在一起,导致滚动比例完全错乱。 解决:统一按行数设置范围。总行数 = 缓冲区字节数 ÷ 每行字节数,向上取整;nMax = 总行数 - 可见行数;nPage = 可见行数。每次滚动后把行号乘以每行字节数,换算成绘制起始偏移。SetBytesPerLine 之后必须重新执行这套计算。
4.3 字符集相关的坑:Unicode 工程里出现乱码和列错位
现象:Unicode 字符集下编译,中间区十六进制显示正常,但右侧 ASCII 区偶尔出现乱码,或者整行列宽错位。 原因:内部格式化输出用了 TCHAR 缓冲区和_tcslen这类按字符计数的函数。中文字符串和双字节字符混入后,字符数不等于字节数,每列宽度计算全部偏移。 解决:控件内部统一使用单字节字符缓冲,格式化十六进制时用sprintf或StringCchPrintfA,宽度计算按字节数乘以单字符像素宽度,不要用 lstrlen。在 Unicode 工程里接入时,接口层做一次窄字节转换,内部控制逻辑保持纯 BYTE 操作。
4.4 数据回写阶段的坑:外部缓冲区被内部逻辑直接改写
现象:调用某个 Set 接口后,外部文件缓冲区被意外改写,甚至越界崩溃。 原因:部分简化版实现为了省一次内存拷贝,直接把外部传入的指针保存为内部缓冲区指针。用户编辑字节时写的是外部内存,一旦外部按只读方式管理这段内存,立即触发访问冲突。 解决:使用做深拷贝的版本。外部调用方也要遵守契约:GetData 之前先查询所需长度,分配等大缓冲区再拷贝。拿控件指针直接当文件缓冲区用是很危险的习惯,我在做过一个协议解析工具时吃过这个亏,从那以后统一改成读写都走拷贝。
4.5 鼠标点击定位不准的坑:坐标换算把列宽算偏了
现象:点击第 10 个字节位置,光标却落在第 9 个字节上,或者点了字节区却选中了 ASCII 区。 原因:地址区和中间区的分区宽度在初始化时按旧字体算了一次,后来 SetFont 或 SetBytesPerLine 之后没有重算。鼠标点击时用的还是旧宽度,换算出来的列号自然偏一位。 解决:任何影响列宽的操作之后,强制重新计算三个分区的像素宽度。计算客户点属于哪一列时,先算行号,再按分区宽度算列号,最后把行列换算成缓冲区偏移。可以在调试时加一个临时断点检查点击处换算出的偏移是否和数据行渲染处的偏移一致。
5. 站在控件之上做扩展:偏移定位、选区联动与撤销栈
5.1 按偏移量定位并滚动到可视区域
很多二进制分析场景需要跳到某个已知偏移,比如文件头里的魔数、协议报文里的固定字段。控件提供了基础滚动能力,但通常没有按偏移定位的接口,自己扩展一个 ScrollToOffset 并不难。核心逻辑是先把目标偏移换算成目标行,再钳位到滚动范围的有效区间,最后让滚动条把目标行滚动到可视区域中部。
void ScrollToOffset(DWORD dwTargetOffset, DWORD dwBytesPerLine) { DWORD dwTargetLine = dwTargetOffset / dwBytesPerLine; SCROLLINFO si = { sizeof(SCROLLINFO) }; si.fMask = SIF_ALL; GetScrollInfo(SB_VERT, &si); // 钳位到有效范围:nMax 已经是总行数减可见行数 int nMax = si.nMax; int nTarget = static_cast<int>(dwTargetLine) - static_cast<int>(si.nPage / 2); if (nTarget < si.nMin) nTarget = si.nMin; if (nTarget > nMax) nTarget = nMax; si.nPos = nTarget; SetScrollInfo(SB_VERT, &si, TRUE); // 把滚动起点换算回字节偏移,交给绘制循环 m_dwScrollOffset = nTarget * dwBytesPerLine; Invalidate(FALSE); }逻辑说明:先按偏移算出目标行,再把目标行减去半屏行数,得到让目标行居中的滚动起点。nMax 是总行数减可见行数,所以目标行超过 nMax 时直接钳位到尾部。最后把行号乘每行字节数,存成成员变量 m_dwScrollOffset,绘制循环从这里开始迭代。这样做比反复调用 SetScrollInfo 然后等 WM_PAINT 再定位更直接,滚动和绘制用同一个偏移量,不会出现两套值打架的情况。
参数说明:dwTargetOffset 必须小于缓冲区总长度,否则除法后的行号会超过总行数,钳位逻辑会让它停到文件末尾。dwBytesPerLine 要和控件的当前行字节数一致,如果调用者传错,滚动位置会偏。更稳妥的做法是从控件内部 GetBytesPerLine,而不是由外部传参。
5.2 选区与字节统计联动
定位到偏移之后,下一步通常是选中一段区域做统计分析,比如统计某个标记值的出现次数、检查连续 0xFF 填充区是否完整。控件有选区接口,但统计功能得自己做。注意 GetSelection 返回的是逻辑偏移,不是行内列号,遍历时直接用起始偏移加步长就可以。
DWORD CountByteInSelection(BYTE bTarget, DWORD dwStart, DWORD dwEnd) { DWORD dwCount = 0; for (DWORD i = dwStart; i < dwEnd; i++) { if (m_pBuffer[i] == bTarget) dwCount++; } return dwCount; }这段代码的思路很简单,但要提醒一点:dwStart 和 dwEnd 如果是从 GetSelection 拿到的,需要先检查大小关系,确保 dwStart <= dwEnd,否则循环根本进不去。数据量大的时候,逐字节遍历几 MB 区域还是很快的,不需要额外优化,但如果要做频率统计,建议改成一次遍历统计所有字节值,再用数组下标访问,比写 N 个 CountByteInSelection 高效得多。
5.3 撤销与重做的关键实现思路
很多 hexedit 控件的原始版本没有撤销栈,编辑之后按 Ctrl+Z 没反应。如果要在资源基础上做二次开发,建议用轻量替换栈而不是快照。快照方案在每次编辑时保存整个缓冲区副本,几 MB 文件会吃掉大量内存,实际没必要。替换栈每次只记录被改前的旧值、偏移量和改动长度,改动集中时先把旧数据缓存好再一起提交。
栈结构设计上有几点要注意:记录里必须有偏移和长度,因为一次可能改了多个连续字节;栈容量要限制,通常 128 步足够,超出就弹出最旧的记录,防止内存无限增长;撤销后要置灰标记,同时刷新绘制区域。连续输入时如果每次击键都压栈,撤销会变得很碎,相邻 64 步内的连续编辑可以合并成一条记录,这样用户按 Ctrl+Z 是一次撤掉一小段输入,而不是一个字一个字地回退。
这个栈要接在控件的 data change 回调里,不能自己遍历缓冲区对比新旧值来判断改动位置,那样性能太差。控件每次改动时已经把偏移和长度告诉你了,栈只是把这些信息按时间顺序攒起来,撤销时从尾部弹出,重做时再从撤销栈弹回更改栈。两部分各自维护一个栈结构即可。
6. 自检清单与滚动条滚得准的小技巧
6.1 拿固定样本做回归
换过字体、改过每行字节数、动过滚动逻辑之后,手上有几个固定样本做回归测试比什么都管用。我习惯准备一个 256 字节的递增序列,0x00 到 0xFF 重复两轮。验证时打开文件,第一行显示00 01 02 ... 0F,地址区从00000000开始,滚动到最后一页能看到最后一行还是完整的 16 字节,而不是只剩半行。另一个样本是全部 0xFF,用于观察滚动速度,如果滚动时出现整行空白,说明可见行数计算有误。
6.2 滚动条范围的精确设置技巧
滚动条滚得准不准,核心是 nPage 的语义要对齐。SCROLLINFO 里 nPage 代表一屏能显示的行数,nMax 设成总行数减 nPage,这样拖到最底下正好看到文件末尾。如果 nPage 给的是字节数,滚轮滚动时会一屏跳好几行,用户会觉得滚动太敏感。每次 SetBytesPerLine 之后都要重新计算这三个值,并且再次 SetScrollInfo。不要在 WM_PAINT 里反复 SetScrollInfo,否则每个 WM_PAINT 都触发一次滚动条重算,布局会抖动,性能也被拖垮。
6.3 键盘方向键与滚轮处理
一个能看不能完整操作的十六进制编辑控件,通常是因为只处理了 WM_MOUSEWHEEL,没处理方向键。在 WM_KEYDOWN 里要处理 VK_UP、VK_DOWN、VK_PRIOR、VK_NEXT、VK_HOME、VK_END 这六个键。其中 VK_PRIOR 和 VK_NEXT 按可见行数翻页,VK_HOME 跳到当前行第一个字节,VK_END 跳到当前行最后一个字节。处理完键后必须重新定位光标并刷新,否则键盘移动了光标位置但画面没跟上,用户就会觉得光标是"飘"的。
我在第一次把 hexedit 控件集成进一个协议分析工具时,就是栽在滚动范围按字节设置这个坑里,拖动滚动条怎么都看不到文件尾部,后来把所有单位统一成行才算解开。从那以后我每次改完控件逻辑都强制走一遍自检流程:先验证三个固定样本的显示,再拖滚动条看最后一行是否完整出现,最后用方向键走一遍光标移动。这套流程大概五分钟,但能拦下九成以上的回归问题。希望帮到你。
本文还有配套的精品资源,点击获取