☰
hexedit 十六进制编辑控件 MFC 集成实战:装载、查找、保存与避坑指南
2026/10/18 12:20:39 网站建设 项目流程

简介:HexEdit 是一套基于 VC++ 环境开发的十六进制编辑控件,面向需要直接查看和修改二进制文件的开发者,广泛适用于软件调试、系统维护、数据恢复、逆向工程等场景,可帮助用户摆脱依赖通用编辑器的局限,快速定位并修改任意二进制数据。控件封装了文件读写 API、数据渲染、查找替换、插入删除等核心操作,同时支持 ASCII 与二进制双视图模式,界面简洁直观,并提供充足的扩展接口以便按项目需求定制。压缩包共 53 个文件,其中以 9 个 h 头文件、8 个 cpp 源文件为主要代码主体,搭配 vcxproj/sln 工程文件与 exe 演示程序,另有 htm 升级说明及调试过程文件,整体大小仅 2.02MB,结构组织清晰,适合直接导入工程学习或二次开发。演示项目与升级日志完整展示了从控件集成、界面布局到接口调用的全过程,可帮助开发者快速理解控件的初始化、打开文件、数据编辑及保存回写等关键步骤,缩短同类功能的开发周期。目前已有 97 人学习下载,对于需要在 Windows 平台构建十六进制编辑器或类似二进制数据工具的程序员来说,是一份小而实用的参考代码。

1. 十六进制编辑控件不是玩具:先想清楚要它做什么

做固件解析、协议调试、PE 文件修补的同行,应该都有过这种时刻:拿 Hex 工具打开一个 bin 文件,找到偏移 0x1A2C 处,改掉 4 个字节,再烧回设备。一次两次能忍,等要批量处理几十个文件、或者把这种能力塞进自研的测试工具时,就发现独立 EXE 帮不上忙了。这时候你会想,自己的程序里要是能嵌一个 16 进制编辑器就好了。我最近拆了一份在 VC 环境下流传很广的 hexedit 十六进制编辑控件源码,它解决的正是这个问题——把二进制浏览、编辑、查找、跳转能力直接变成控件,拖到对话框里就能用。适合做上位机、文件分析器、调试辅助工具的人。但接入它不像往界面上放一个 Button 那么简单,源码里有不少隐性的坑,今天一次性说清楚。

2. 把 hexedit 控件接进对话框:从导入源码到绑定控件变量

2.1 源码导入与编译配置

拿到 hexedit 控件的源码包,第一件事不是急着建工程,而是先看清目录结构。常见做法是把源码直接编进你的项目,而不是编译成 DLL 再引用。因为控件本身依赖很多内部消息循环,静态编译反而少了很多导出接口上的麻烦。

把HexEdit.h、HexEdit.cpp这两个核心文件拷贝到工程目录,然后在 VS 工程的“现有项”里添加它们。如果工程用了 MFC 共享库,确保项目属性里选择了“在共享 DLL 中使用 MFC”。字符集要记得统一,我一般建议直接使用 Unicode 字符集,因为 hexedit 内部对文本的处理是基于 TCHAR 的,你如果项目是 Unicode,控件里那些宽窄字符转换就不用额外改。

以下是集成到对话框的基本步骤。

// HexEditDlg.h #include "HexEdit.h" class CHexEditDlg : public CDialogEx { // 在对话框类中声明一个控件对象 CHexEdit m_wndHex; };

这里声明了一个成员对象m_wndHex,它对应的是CHexEdit类,也就是 hexedit 控件的封装类。注意,不需要像普通控件在资源编辑器里拖一个控件进去,这个控件是动态创建的。

接着在对话框的OnInitDialog里创建它并设置初始位置:

BOOL CHexEditDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 创建一个位置矩形,这里是对应对话框中预留的 IDC_STATIC 控件 CRect rc; CWnd* pWnd = GetDlgItem(IDC_STATIC_PLACEHOLDER); pWnd->GetWindowRect(&rc); ScreenToClient(&rc); // 创建 hexedit 控件 m_wndHex.Create(WS_CHILD | WS_VISIBLE | WS_TABSTOP | WS_BORDER, rc, this, 0x1001); // 初始化控件属性 m_wndHex.SetFont(&m_font); m_wndHex.SetBytesPerRow(16); return TRUE; }

注意Create的最后一个参数是控件 ID,这里用了0x1001,建议用IDC_HEXEDIT这样的宏定义,避免魔法数字。SetFont是设置显示字体,建议用等宽字体如 Consolas,否则十六进制字节对不齐。SetBytesPerRow是每行显示多少字节,16 是常用设置,如果你要查看的是离散寄存器值,也可以设为 8 或 32。

这一阶段最常见的翻车点是忘记在OnInitDialog里调用Create,结果界面上一片空白。另外,WS_TABSTOP风格一定要加,否则 Tab 键焦点进不了控件,键盘方向键无法操作。

2.2 控件变量绑定与消息映射

如果你不想用Create动态创建,也可以把 hexedit 控件静态嵌入到对话框模板里。前提是你要么自己实现一个自定义控件注册,要么直接在资源编辑器里放置一个自定义控件,指定类名为 hexedit 的 WNDCLASS 名称。

不过大多数 hexedit 控件源码是不带注册类的,它靠的是 MFC 的窗口子类化机制。你可以在对话框模板里放一个普通的自定义控件窗口,然后在DoDataExchange里把它绑定到CHexEdit对象:

void CHexEditDlg::DoDataExchange(CDataExchange* pDX) { CDialogEx::DoDataExchange(pDX); DDX_Control(pDX, IDC_HEXEDIT_PLACEHOLDER, m_wndHex); }

这个做法要求 hexedit 控件类实现了Create和PreSubclassWindow,否则 DDX_Control 无法接管窗口。很多早期版本的 hexedit 源码在PreSubclassWindow里做了一些初始化,如果你发现绑定后控件不可编辑,多半是PreSubclassWindow没被调用,或者窗口类名不匹配。

绑定之后,还需要给控件添加通知消息,比如当用户修改了某个字节时,通知主对话框更新状态栏。在消息映射中手动添加一个自定义消息:

#define WM_HEXEDIT_UPDATE (WM_USER + 100) BEGIN_MESSAGE_MAP(CHexEditDlg, CDialogEx) ON_MESSAGE(WM_HEXEDIT_UPDATE, OnHexEditUpdate) END_MESSAGE_MAP()

然后在控件代码内部合适位置发送这个消息,比如在OnChar处理完成之后调用::SendMessage(GetParent(), WM_HEXEDIT_UPDATE, 0, 0)。

如果你遇到控件没有响应键盘输入,先检查对话框是否能接收到焦点。我遇到过类似情况,最后发现是因为对话框的WS_EX_CONTROLPARENT样式没设置,导致 Tab 链失效。

3. 数据装载与保存:从 CFile 到内存缓冲区的几种做法

3.1 文件读入与初始化显示

hexedit 控件的核心是内存缓冲区,它内部维护了一块字节数组。装载一个文件,本质上就是把这个字节数组填充好,然后刷新显示。

常见做法是先用CFile把整个文件读入到一个CByteArray或std::vector<BYTE>里,然后调用控件的SetData方法。有些版本的 hexedit 类没有SetData,而是直接暴露m_pData指针,你需要把缓冲区的指针传进去。

下面是一个稳妥的装载流程:

// 读取整个文件到内存 CFile file; if (!file.Open(strFilePath, CFile::modeRead)) { AfxMessageBox(_T("打开失败")); return; } ULONGLONG ullLen = file.GetLength(); if (ullLen == 0) { file.Close(); return; } // 限制最大文件大小,避免内存耗尽,例如 256MB const ULONGLONG MAX_FILE_SIZE = 256 * 1024 * 1024; if (ullLen > MAX_FILE_SIZE) { AfxMessageBox(_T("文件过大,无法装载")); file.Close(); return; } std::vector<BYTE> buffer((size_t)ullLen); file.Read(buffer.data(), (UINT)ullLen); file.Close(); // 将数据设置到 hexedit 控件 m_wndHex.SetData(buffer.data(), (int)buffer.size()); m_wndHex.Invalidate();

这里有几个参数值得说明。SetData的第二个参数是数据长度,必须与缓冲区实际长度一致。如果你传入的缓冲区来自 local 变量,在SetData之后不能立刻释放,因为很多 hexedit 实现内部只是保存了指针,而不是拷贝数据。你会看到界面能显示,但一旦源缓冲区释放,控件就是一堆随机数。我一般会用成员变量std::vector<BYTE> m_fileBuffer来持有这份数据,确保生命周期与控件共存。

如果你拿到的 hexedit 版本支持SetData内部拷贝,那就无所谓。但为了保险,请先用小文件测试:加载一个 100 字节的文件,然后关闭对话框,看是否崩溃。如果不崩溃,说明内部有拷贝;如果崩溃,就用成员缓冲区。

3.2 修改后写回与大小变化

用户编辑完字节后,需要把数据写回文件。大部分 hexedit 控件提供GetData或GetModified接口。写回前要判断控件状态,是否被修改过。

典型的写回代码:

// 检查是否有未保存的修改 if (m_wndHex.IsModified()) { // 获取当前数据长度(可能因插入/删除而变化) int nDataLen = m_wndHex.GetDataLength(); std::vector<BYTE> newBuffer(nDataLen); m_wndHex.GetData(newBuffer.data(), nDataLen); // 写回文件 CFile file; if (file.Open(strFilePath, CFile::modeWrite | CFile::modeCreate)) { file.Write(newBuffer.data(), nDataLen); file.Close(); m_wndHex.SetModified(FALSE); } }

注意,GetDataLength是获取控件内部缓冲区当前长度,与原始文件长度不一定相同。因为 hexedit 支持插入和删除字节。如果控件在设计上只允许修改现有字节而不允许增减长度,那GetDataLength恒等于初始长度。你要根据自己的需求来决定是否开启插入删除功能。

有些版本会在GetData时返回一个指向内部缓冲区的只读指针,直接memcpy也行。但为了避免越界,我还是建议用GetData加目标缓冲区的模式。

3.3 参数:地址位数、列宽、字体设置等

hexedit 控件比较在意的参数有这么几个:

参数常见值作用
BytesPerRow16每行显示的字节数,影响横向滚动范围
AddressBytes4 / 8地址显示的字节宽度,4 位适合小文件,8 位适合大文件
ColumnSpacing8~12 像素调整十六进制和 ASCII 区域的间隔
Font等宽字体保证字符对齐,Consolas 或 Courier New 都可以
ReadOnlyTRUE/FALSE是否允许编辑

设置这些参数的代码通常在OnInitDialog里:

m_wndHex.SetBytesPerRow(16); m_wndHex.SetAddressBytes(8); m_wndHex.SetColumnSpacing(10); m_wndHex.SetReadOnly(FALSE);

这里特别提醒:SetAddressBytes(8)只适合显示超过 4GB 地址空间的文件,如果你只是看一个几十 KB 的固件,用 4 位更清爽。不过两者只影响显示,不影响内部逻辑。

字体设置也很关键。如果你不设置等宽字体,默认的宋体(MFC 对话框字体)会导致每个字节占据宽度不同,界面看起来非常乱。我通常在对话框初始化时创建一个CFont:

m_font.CreatePointFont(90, _T("Consolas")); // 9pt m_wndHex.SetFont(&m_font);

注意CFont对象也要是成员变量,否则OnInitDialog返回后字体对象销毁,控件字体又变回默认。

4. 与编辑需求配套:查找、跳转与选中高亮

4.1 查找二进制序列

十六进制编辑器的查找功能不能简单用strstr,因为数据里可能有\0。控件内部一般提供Find函数,传入字节数组和模式。

常见实现是封装一个FindPattern方法:

int CHexEdit::FindPattern(const BYTE* pPattern, int nPatternLen, int nStartAddr) { for (int i = nStartAddr; i <= m_nDataLen - nPatternLen; i++) { if (memcmp(m_pData + i, pPattern, nPatternLen) == 0) return i; } return -1; }

这个函数从指定地址开始逐字节比对。虽然效率不高,但对于一般几十 MB 的文件,都能接受。如果你要查找几 GB 文件,建议用 BM 算法,但 hexedit 控件大多没有内置,需要自己扩展。

调用查找时,通常会弹出一个对话框让用户输入十六进制字符串,比如输入A1 B2 C3,然后解析成字节数组,再调用FindPattern。注意解析时别忽略空格,也不要幻想用户会规范输入。

4.2 跳转到偏移地址

跳转功能往往是配合查找使用的。找到某个字节位置后,需要让控件滚动到对应行,并高亮显示。控件一般提供SetSel或GoTo方法。

实现跳转:

int nOffset = m_wndHex.FindPattern(pPattern, nLen, 0); if (nOffset >= 0) { m_wndHex.SetSel(nOffset, nOffset + nLen); m_wndHex.ShowAddr(nOffset); }

这里SetSel选中一段范围,ShowAddr让指定地址出现在可见区域。不同版本的 hexedit 可能方法名不同,但基本逻辑一致:先移动光标,再滚动视口。

有一个细节:ShowAddr有些实现是设置当前显示页的起始地址,而不是精确滚动到目标行。如果你发现跳转后目标行在视口底部,看不全,可以用EnsureVisible之类的额外步骤。如果没有这个方法,就自己算一下行号,调用SetScrollPos加ScrollWindow。这种“假滚动”问题我在好几个版本里都遇到过。

4.3 高亮与标记

除了查找,另一个高频需求是把某些特征字节高亮,比如协议头、魔数。hexedit 控件不一定支持多区域高亮,需要你查看源码里是否有SetHighlightRgn或SetMark接口。

如果没有,你可以通过重绘控件来实现。但更省事的方法是:把高亮区域的信息存储在一个数组里,然后修改控件的OnPaint,在绘制完原有内容后,再绘制一个半透明矩形覆盖在目标行上。这个做法需要你对 MFC 绘图有一定了解,否则容易造成重影。

常见做法是利用控件的OnDraw中已有的字节坐标计算函数。你可以在源码里找到类似GetRectByOffset的私有方法,然后在OnPaint末尾调用它,画一个FillRect。注意要处理滚动偏移,否则高亮位置会错。

我一般不会在主线程里频繁重绘高亮,而是用一个标记结构体:

struct HEX_HIGHLIGHT { int nStart; int nEnd; COLORREF clr; }; std::vector<HEX_HIGHLIGHT> m_vecHighlights;

然后通知控件刷新。这样你可以一次高亮多个区域,比如把固件里的所有关键配置块都标出来。

5. 避坑/常见问题:内存、刷新、Unicode、光标闪烁

我在把 hexedit 控件集成到自己的工具中时,踩过不少坑,这里列出 5 个最具代表性的,每条都是“现象 → 原因 → 解决”。

坑 1:关闭对话框时崩溃

现象:加载文件、编辑几处、关闭对话框,程序直接报内存访问越界。

原因:控件内部的m_pData指针引用了临时缓冲区,而这个缓冲区在OnInitDialog里用局部变量创建,函数返回后就被释放。控件在析构时企图访问它,就崩溃了。

解决:将缓冲区声明为对话框类的成员变量,并在装载时保持其生命周期。或者在SetData之后,让控件自己复制一份数据。最好在控件类的SetData实现里加一个memcpy,而不是单纯保存指针。

坑 2:界面显示乱码,字节不对齐

现象:十六进制列看起来歪歪扭扭,ASCII 列对不上。

原因:没有使用等宽字体,或者某个版本的控件内部使用DrawText没有固定字符宽度。

解决:统一设置Consolas或Courier New字体,同时确保SetBytesPerRow在设置字体之后调用,否则行宽计算用旧字体,会出现错位。

坑 3:控件不接收键盘输入

现象:点击控件后,光标不闪烁,按方向键没反应。

原因:控件没有获得焦点,或者对话框没有把焦点转移给控件。有时候是因为控件创建时没有WS_TABSTOP风格。

解决:在OnInitDialog里调用m_wndHex.SetFocus()。同时检查控件创建参数里是否包含WS_TABSTOP,这样 Tab 键才能真正进入控件。

坑 4:修改状态保存无效,重新打开文件后修改丢失

现象:调用SetModified(FALSE)后,再编辑,关闭程序时提示未保存,或者写回文件后发现没有变化。

原因:SetModified只是清除了界面上的修改标记,但控件内部的状态计数器可能没有正确重置。或者你的写回逻辑用了旧的缓冲区指针,没有同步更新。

解决:写回完成后,明确调用m_wndHex.Invalidate(),并确保GetData拿到的数据是最新的。这里我自己的习惯是每次写回后主动重建内部缓冲区,而不是依赖控件内部的“懒更新”。

坑 5:Unicode 编译环境下部分功能异常

现象:项目从多字节字符集切换到 Unicode 后,控件显示地址区时出现乱码,或者编辑中文注释时崩溃。

原因:早期 hexedit 源码里大量使用char*和CString混用,没有统一用_tcs函数,导致宽窄字符转换出错。

解决:在工程属性中强制使用 Unicode 字符集,并检查控件源码中所有与字符串相关的 API,把char*改为TCHAR*,或者使用CString自动转换。更简单的方法是找最新版的 hexedit 源码,或者自己对涉及文本绘制的部分做一次替换。

6. 让控件更好用的两个进阶技巧:拖拽打开与实时校验

6.1 拖拽文件到控件

很多用户喜欢直接把文件拖到窗口上打开,这比点按钮选择文件快得多。实现拖拽打开需要让对话框接受WM_DROPFILES消息。

在ON_WM_DROPFILES消息处理函数里解析文件路径,然后调用已有的LoadFile函数:

void CHexEditDlg::OnDropFiles(HDROP hDropInfo) { // 只处理第一个拖进来的文件 TCHAR szPath[MAX_PATH] = {0}; DragQueryFile(hDropInfo, 0, szPath, MAX_PATH); if (PathFileExists(szPath)) { LoadFile(szPath); } DragFinish(hDropInfo); CDialogEx::OnDropFiles(hDropInfo); }

记得在OnInitDialog里调用DragAcceptFiles(TRUE),否则窗口不会接收拖拽消息。

这里有个细节:如果你希望拖进来一个文件后,控件自动跳到文件末尾或某个特定位置,可以在LoadFile里增加一个可选参数,比如nStartOffset,这样后续扩展能更灵活。

6.2 修改后自动更新 CRC/校验和

很多十六进制文件在尾部附带 CRC 或校验和。你用 hexedit 修改了中间某个字节,但忘了更新 CRC,整个文件就不合法了。所以我做了一个自动校验功能:当控件发出修改消息时,实时计算整个缓冲区的 CRC32,并显示在状态栏。

下面是计算 CRC32 的简版实现:

DWORD CHexEditDlg::CalcCRC32(const BYTE* pData, size_t nLen) { DWORD crc = 0xFFFFFFFF; for (size_t i = 0; i < nLen; i++) { crc ^= pData[i]; for (int bit = 0; bit < 8; bit++) { if (crc & 1) crc = (crc >> 1) ^ 0xEDB88320; else crc >>= 1; } } return ~crc; }

然后在OnHexEditUpdate消息处理里调用它:

LRESULT CHexEditDlg::OnHexEditUpdate(WPARAM wParam, LPARAM lParam) { int nLen = m_wndHex.GetDataLength(); std::vector<BYTE> tmp(nLen); m_wndHex.GetData(tmp.data(), nLen); DWORD crc = CalcCRC32(tmp.data(), nLen); CString strInfo; strInfo.Format(_T("数据长度: %d, CRC32: %08X"), nLen, crc); m_statusBar.SetWindowText(strInfo); return 0; }

注意不要在每次按键时都计算整个文件,否则文件一大就很卡。我通常的做法是加一个 300 毫秒的定时器,修改消息发出后重置定时器,定时时间到了才算一次 CRC。这样既能保持实时性,又不会影响编辑体验。

这个技巧很实用,但也容易把校验值算错。有一次我改完了配置区,软件依然报校验失败,排查了半天才发现自己用的是 CRC16,而固件实际要求 CRC32。从那以后,我每次集成 hexedit 控件,都会先确认目标文件用的校验算法是 CRC8、CRC16 还是 CRC32,再写对应的函数。再后来我干脆在控件上做了一个右键菜单,直接读取文件尾部两个字节作为期望值,和当前计算值对比,这样一眼就能看出修改有没有同步。希望这个习惯能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询