简介:这是一套基于VC++开发的心电图机监控与诊断系统完整源码,面向医疗电子方向的C++开发者、生物医学工程专业学生及嵌入式医疗设备二次开发人员,用于实现心电信号实时采集、波形动态显示、临床诊断辅助与电子病历管理。资源包共312个文件,涵盖58个头文件(h)与50个实现文件(cpp),构成核心业务逻辑;47个图标(ico)、9个位图(bmp)及8个资源脚本(rc)支撑界面交互;另有多个工程配置文件(dsp/dsw/sln)、本地化资源(APS)、数据库(mdb/biocaredb)及DICOM/BMP格式转换模块(ECGDICOMConv/ECGBMPConv),体现完整医疗软件架构。压缩包大小为4.82MB,结构清晰、模块解耦度高,便于学习MFC框架下串口通信、实时绘图、多语言支持与医疗数据标准化处理。目前已有196人学习下载,适合希望深入理解心电设备上位机开发全流程的中高级C++实践者。
1. 这不是通用绘图软件,而是一套面向心电图机设备的 VC++ 实时数据采集与临床可视化系统
当你在医院信息科或医疗设备厂商现场调试一台新接入的心电图机时,最常遇到的不是“怎么画曲线”,而是“怎么从这台机器的串口/USB/网络端口里,把原始毫伏级电压信号一帧不丢地抓出来,再按标准导联顺序实时渲染成可诊断的波形,并关联到患者病历”。这个标题里的“心电图机监控软件”不是演示程序,它必须满足:采样率稳定在 500Hz 以上(满足 AHA/IEC60601-2-27 要求)、支持多导联同步采集(I、II、III、aVR、aVL、aVF、V1–V6)、能处理设备特有的帧头校验与协议解析(如 GE Marquette、Philips Page、Siemens SC3000 的私有协议),还要在 MFC 界面中实现低延迟滚动显示(<100ms 端到端延迟)和病历字段绑定。它面向的是医疗器械软件开发工程师、医院信息科二次开发人员、以及需要对接国产心电设备的 OEM 厂商——这些人不关心“VC++ 怎么入门”,只关心“第 3 个字节是不是导联标识符”“滚动缓冲区怎么设才不卡顿”“如何让医生双击波形直接跳转到对应病历页”。源码.zip 里真正值钱的,从来不是CWnd::OnPaint(),而是CSerialPort::ReadRawData()后那一段按设备手册逐位解包的ParseMarquetteFrame()函数。
2. 用 VC++ MFC 构建心电数据采集层:从物理接口到结构化波形帧
心电图机的数据输出方式直接决定采集层架构。当前主流设备仍以 RS-232 串口为主(尤其基层医院存量设备),部分新型号支持 USB CDC 或 TCP/IP(如 Philips IntelliSpace Cardiovascular 的 DICOM-WS 接口)。MFC 工程中不能简单调用CreateFile("COM3")就完事——必须区分接口类型、配置硬件流控、设置精确超时,并预处理设备特有的握手协议。
2.1 串口采集的核心参数与容错设计
实际工程中,9600bps 是老旧设备底线,但现代设备普遍支持 115200bps。关键参数不是波特率本身,而是DCB结构体中三个易被忽略的字段:
DCB dcb = {0}; dcb.DCBlength = sizeof(DCB); GetCommState(hCom, &dcb); dcb.BaudRate = CBR_115200; dcb.ByteSize = 8; dcb.Parity = NOPARITY; dcb.StopBits = ONESTOPBIT; // 必须关闭 DTR/RTS 自动控制,否则设备可能拒绝发送 dcb.fDtrControl = DTR_CONTROL_DISABLE; dcb.fRtsControl = RTS_CONTROL_DISABLE; // 关键:接收超时设为 0,启用 ReadIntervalTimeout 控制单帧完整性 dcb.ReadIntervalTimeout = 10; // 字节间最大间隔 ms dcb.ReadTotalTimeoutConstant = 50; // 整帧最大等待 ms dcb.ReadTotalTimeoutMultiplier = 0; SetCommState(hCom, &dcb);提示:
ReadIntervalTimeout是救命参数。心电设备帧结构通常为“帧头 + 长度 + 数据 + 校验”,若设备在帧内因干扰导致字节间隔超过默认值(Windows 默认 1000ms),ReadFile()会提前返回部分数据,造成后续所有解析错位。设为 10ms 后,只有连续字节流才会被当作一帧读取。
2.2 解析 Marquette 协议的典型帧结构
以 GE Marquette 系列为例,其串口输出采用二进制帧格式(非 ASCII),典型结构如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| Sync Byte | 1 byte | 固定值 0x02 |
| Frame Type | 1 byte | 0x01=ECG Data, 0x02=Patient Info |
| Length | 2 bytes | 小端序,后续数据总长度 |
| Lead Data | N bytes | 每导联 16-bit 有符号整数,按 I, II, III, aVR... 顺序排列 |
| Checksum | 1 byte | 所有前 N 字节异或和 |
解析函数需严格按字节偏移提取,不可依赖sprintf或字符串分割:
bool CECGParser::ParseMarquetteFrame(const BYTE* pBuf, int nLen, ECG_FRAME& frame) { if (nLen < 6) return false; // 最小帧长:Sync+Type+Len(2)+至少1字节数据+Checksum if (pBuf[0] != 0x02) return false; WORD wLength = MAKEWORD(pBuf[3], pBuf[4]); // 小端序 if (nLen < 5 + wLength + 1) return false; // 不够长度 BYTE checksum = 0; for (int i = 0; i < 5 + wLength; i++) { checksum ^= pBuf[i]; } if (checksum != pBuf[5 + wLength]) return false; // 校验失败 // 提取导联数据:每导联2字节,共12导联 → 24字节 if (wLength >= 24) { for (int i = 0; i < 12; i++) { frame.leads[i] = MAKEWORD(pBuf[5 + i*2], pBuf[5 + i*2 + 1]); } frame.timestamp = GetTickCount64(); return true; } return false; }2.2.1 导联映射与单位换算
原始 ADC 值需转换为毫伏(mV):mV = raw_value × (2.5V / 65536) × gain_factor
其中gain_factor由设备当前增益档位决定(如 10mm/mV 对应 gain=1.0,5mm/mV 对应 gain=2.0)。该系数必须从设备协议中读取(Marquette 在 Patient Info 帧中携带),不能硬编码。
3. 在 MFC CScrollView 中实现亚像素级心电曲线实时渲染
心电波形显示不是“把数组画成折线”,而是要解决三个临床刚需:① 横轴时间精度达 2ms(500Hz 采样下每点 2ms),② 纵轴电压分辨率支持 ±5mV 全量程且无锯齿,③ 滚动时波形边缘不撕裂。CScrollView是 MFC 中唯一能天然支持平滑滚动和坐标映射的视图类,但默认 GDI 绘图在高刷新率下极易卡顿。
3.1 双缓冲机制与滚动缓冲区设计
直接在OnDraw()中调用MoveTo/LineTo会导致严重闪烁。必须使用内存 DC + 位图双缓冲:
void CECGView::OnDraw(CDC* pDC) { CRect rectClient; GetClientRect(&rectClient); // 创建兼容DC和位图 CDC memDC; CBitmap memBitmap; memDC.CreateCompatibleDC(pDC); memBitmap.CreateCompatibleBitmap(pDC, rectClient.Width(), rectClient.Height()); CBitmap* pOldBitmap = memDC.SelectObject(&memBitmap); // 清空背景(深灰底色符合医疗显示规范) memDC.FillSolidRect(&rectClient, RGB(30, 30, 30)); // 绘制网格线(5mm×5mm 标准心电网格) DrawGrid(&memDC, rectClient); // 关键:只重绘新增波形段,利用滚动缓冲区 DrawNewWaveform(&memDC, rectClient); // 一次性 BitBlt 到屏幕 pDC->BitBlt(0, 0, rectClient.Width(), rectClient.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBitmap); }3.2 时间轴压缩算法:解决 500Hz 下的内存爆炸问题
若每秒采集 500 点,10 秒即 5000 点。直接存储所有原始点会导致内存占用飙升且绘图变慢。实际工程采用“分段压缩”策略:
| 时间段 | 存储方式 | 说明 |
|---|---|---|
| 最近 2 秒 | 原始点(500Hz) | 支持放大查看 P 波细节 |
| 2–10 秒 | 每 5 点取 max/min(100Hz) | 保留 QRS 波群形态 |
| >10 秒 | 每 20 点取均值(25Hz) | 仅显示趋势,内存降低 20 倍 |
struct WAVE_COMPRESSED { float fMax; // 该段最大值 float fMin; // 该段最小值 float fAvg; // 该段均值 int nCount; // 原始点数 }; // 压缩逻辑在数据接收线程中完成,避免阻塞UI void CECGDataBuffer::CompressBuffer() { const int RAW_POINTS = 1000; // 最近2秒原始点 const int COMPRESSED_POINTS = 4000; // 10秒压缩点 // 2秒后开始压缩 if (m_rawBuffer.size() > RAW_POINTS) { std::vector<WAVE_COMPRESSED> compressed; for (int i = RAW_POINTS; i < m_rawBuffer.size(); i += 5) { WAVE_COMPRESSED wc = {0}; for (int j = 0; j < 5 && (i+j) < m_rawBuffer.size(); j++) { float v = m_rawBuffer[i+j]; wc.fMax = max(wc.fMax, v); wc.fMin = min(wc.fMin, v); wc.fAvg += v; wc.nCount++; } wc.fAvg /= wc.nCount; compressed.push_back(wc); } m_compressedBuffer = compressed; // 清理原始缓冲区,只保留最近2秒 m_rawBuffer.erase(m_rawBuffer.begin(), m_rawBuffer.begin() + RAW_POINTS - 200); } }3.2.1 亚像素抗锯齿绘制
心电波形要求线条平滑,GDI 默认开启SetGraphicsMode(GM_ADVANCED)并使用GraphicsPath:
void CECGView::DrawWaveform(CDC* pDC, const std::vector<float>& points, int startX, COLORREF color) { Graphics graphics(pDC->GetSafeHdc()); graphics.SetSmoothingMode(SmoothingModeAntiAlias); GraphicsPath path; for (size_t i = 0; i < points.size(); i++) { float x = startX + i * m_fXScale; // m_fXScale = 2.0f (2像素/ms) float y = GetYFromVoltage(points[i]); // 电压→像素Y if (i == 0) { path.StartFigure(); path.AddLine((REAL)x, (REAL)y, (REAL)x, (REAL)y); } else { path.AddLine((REAL)x, (REAL)y, (REAL)x, (REAL)y); } } Pen pen(color, 1.2f); // 1.2像素线宽增强可读性 graphics.DrawPath(&pen, &path); }4. 病历数据绑定与诊断辅助功能的工程落地
心电图监控软件的价值不在“能画线”,而在“画的线能驱动临床决策”。源码中CRecordManager类负责将波形数据与结构化病历字段双向绑定,这是通过 Windows 标准 COM 接口实现的,而非简单数据库写入。
4.1 使用 OLE DB 直连医院 HIS 数据库
多数三甲医院 HIS 采用 SQL Server,要求软件通过 Windows 身份验证连接,避免明文密码:
// 连接字符串示例(无需用户名密码) CString strConn = _T("Provider=SQLOLEDB.1;Integrated Security=SSPI;"); strConn += _T("Persist Security Info=False;"); strConn += _T("Initial Catalog=HIS_ECG;"); strConn += _T("Data Source=10.1.1.100;"); HRESULT hr = m_pConnection->Open(_bstr_t(strConn), L"", L"", adConnectUnspecified); if (FAILED(hr)) { // 检查 Windows 身份验证权限:运行软件的账户必须是 HIS 数据库的 db_datareader/db_datawriter 角色 AfxMessageBox(_T("HIS数据库连接失败,请检查域账户权限")); return FALSE; }4.2 双击波形跳转病历的底层机制
MFC 中OnLButtonDblClk()获取点击位置后,需反向计算时间戳并查询对应病历:
void CECGView::OnLButtonDblClk(UINT nFlags, CPoint point) { // 将屏幕坐标转为时间戳(毫秒) CRect rectClient; GetClientRect(&rectClient); float fMsPerPixel = 1000.0f / (rectClient.Width() * m_fXScale); // 例如 2px/ms → 0.5ms/px float fElapsedMs = (point.x - m_nLeftMargin) * fMsPerPixel; // 查询最近一次采集时间点 SYSTEMTIME st; GetLocalTime(&st); FILETIME ft; SystemTimeToFileTime(&st, &ft); ULARGE_INTEGER uli; uli.LowPart = ft.dwLowDateTime; uli.HighPart = ft.dwHighDateTime; __int64 llNow = uli.QuadPart; // 构造SQL:查找该时间点前后5秒内的病历 CString strSQL; strSQL.Format(_T("SELECT TOP 1 patient_id, ecg_id, report_time FROM ecg_records ") _T("WHERE ABS(DATEDIFF(ms, report_time, GETDATE())) < 5000 ") _T("ORDER BY ABS(DATEDIFF(ms, report_time, GETDATE()))")); _RecordsetPtr pRS; pRS = m_pConnection->Execute(_bstr_t(strSQL), NULL, adCmdText); if (!pRS->EndOfFile) { CString strPatientID = (LPCTSTR)(_bstr_t)pRS->Fields->GetItem("patient_id")->Value; // 启动病历查看器(调用医院标准病历系统exe) ShellExecute(NULL, _T("open"), _T("HISChartViewer.exe"), strPatientID, NULL, SW_SHOW); } }4.2.1 诊断标记的持久化存储
医生在波形上标注的“ST段抬高”“房颤”等标签,需存入独立表ecg_annotations:
| 字段 | 类型 | 说明 |
|---|---|---|
| annotation_id | int PK | 主键 |
| ecg_id | int FK | 关联心电记录 |
| start_time_ms | int | 标记起始时间(相对本条记录开头的毫秒) |
| end_time_ms | int | 标记结束时间 |
| diagnosis_code | varchar(10) | ICD-10 编码(如 I48.9) |
| annotator_id | int | 医生工号 |
插入语句必须带事务确保原子性:
m_pConnection->BeginTrans(); try { _CommandPtr pCmd; pCmd->ActiveConnection = m_pConnection; pCmd->CommandText = _T("INSERT INTO ecg_annotations (...) VALUES (?, ?, ?, ?, ?)"); pCmd->Parameters->Append(pCmd->CreateParameter(...)); pCmd->Execute(NULL, NULL, adCmdText); m_pConnection->CommitTrans(); } catch (...) { m_pConnection->RollbackTrans(); throw; }5. 调试与部署关键技巧:绕过 VC++ Runtime 依赖和串口权限陷阱
交付给医院信息科的软件,最大的上线障碍不是功能缺陷,而是环境兼容性。以下三点是现场工程师反复踩坑后总结的硬性操作清单。
5.1 静态链接 CRT 以消除 vc++ runtime repair tool 依赖
MFC 工程默认动态链接 CRT(MSVCR120.dll 等),而医院电脑往往缺失这些运行库。必须改为静态链接:
- 右键项目 → 属性 → 配置属性 → C/C++ → 代码生成 → 运行库
- 将
/MD(动态)改为/MT(静态) - 关键:MFC 库也必须同步改为静态(属性 → 常规 → 使用 MFC → 在静态库中使用 MFC)
- 重新编译后,用
dumpbin /dependents YourApp.exe验证输出中不再出现MSVCR*.dll
注意:静态链接后 EXE 体积增大约 2MB,但彻底规避了医院电脑缺少运行库的问题。这是医疗软件部署的强制要求。
5.2 Windows 10/11 下串口权限的注册表修复
Windows 10 1809 后,默认禁止普通用户访问 COM 端口。即使以管理员运行,仍需手动授权:
Windows Registry Editor Version 5.00 [HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\USBSTOR] "Start"=dword:00000003 [HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\serenum] "Start"=dword:00000003 ; 关键:授予 Users 组对 COM 端口的读写权限 [HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\USBSerial\Enum] "Security"=hex:01,00,14,00,02,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,0......实际部署时,应将此注册表项导出为.reg文件,与安装包一同交付,并在安装脚本中静默导入:
@echo off reg import "com_permission_fix.reg" >nul 2>&1 echo COM端口权限已修复5.3 心电数据采集的实时性验证方法
不能仅靠“看起来流畅”判断性能。必须用硬件级工具验证:
| 验证项 | 工具 | 合格标准 |
|---|---|---|
| 端到端延迟 | 示波器 + 信号发生器 | 输入 1Hz 方波,测量软件显示上升沿与输入相差 ≤120ms |
| 丢帧率 | 串口监视工具(如 SerialPortMonitor) | 连续采集 1 小时,校验失败帧数 = 0 |
| CPU 占用 | Windows 性能监视器 | Process\% Processor Time\YourApp.exe< 15%(i5-8250U) |
若延迟超标,优先检查:①ReadIntervalTimeout是否设为 0;②OnDraw()中是否调用了Sleep();③ 是否在 UI 线程中执行了数据库查询(必须移至工作线程)。
本文还有配套的精品资源,点击获取