ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MFC上位机波形显示实战:用ChartCtrl快速实现实时曲线

MFC上位机波形显示实战:用ChartCtrl快速实现实时曲线

简介:这份源码资源面向具备一定C++基础的Windows开发者,尤其是正在学习MFC框架、需要在桌面应用中快速实现数据可视化的工程师与学生。它通过一个可运行的对话框工程,演示如何借助ChartCtrl控件完成波形数据的绘制与实时刷新,解决传统MFC绘图繁琐、上手慢的问题。压缩包共80个文件,约41.86MB,以h头文件与cpp源文件为主体,辅以inl、ico、rc资源脚本及vcxproj、sln工程配置,完整保留了Visual Studio 2015企业版下的编译结构。源码覆盖控件初始化、数据组织、坐标轴设定、定时器刷新与鼠标交互事件处理等关键环节,并包含界面美化与图例提示等细节,读者可据此理解折线波形从数据到呈现的完整链路。目前已有3852人学习下载,适合作为MFC图表开发的入门范例与二次开发起点。

1. 从一块“画不出波形”的工控屏说起

前阵子帮一个做数据采集的朋友看代码,他那个 MFC 上位机跑起来后,界面上本该实时刷新的波形区域一直是一片空白,数据明明已经进到缓冲区了,就是画不出来。他原本用的是 Picture Control 加一堆 GDI 手绘,坐标换算、双缓冲、刷新频率全得自己扛,改到后面连自己都绕晕了。后来我让他换成 ChartCtrl 这个 MFC 的图表控件,半天时间就把波形显示跑通了。这份源码实例干的就是这件事:在 MFC 对话框程序里,用 ChartCtrl 快速搭出一个能实时显示波形的界面,适合做工业采集、串口调试、传感器监控这类上位机的开发者。它不追求花哨,核心就是让你把“数据进来、波形出去”这条链路最短化,省掉手绘坐标轴和刷新逻辑的血泪经验。

2. 项目定位与技术选型:为什么是 ChartCtrl 而不是手绘

2.1 这个实例到底解决什么问题

做 MFC 上位机的人大多遇到过同一个困境:界面框架用 MFC 搭得挺快,一到波形显示就卡住。用 Picture Control 自己画,意味着你要处理坐标映射、刻度计算、网格线、双缓冲防闪烁、鼠标缩放,甚至还要考虑多通道曲线的颜色管理。这些活儿不是不能干,但每个项目都重写一遍,纯属浪费。

这份源码的思路很直接:把波形显示这件事交给一个成熟的图表控件 ChartCtrl,MFC 只负责数据采集和界面布局。ChartCtrl 本身是一个开源的 MFC 图表控件库,支持折线、散点、柱状、面积等多种图表类型,自带坐标轴、网格、图例和缩放。实例里重点演示的是折线波形,也就是最常见的实时数据展示场景。

它适合的人群很明确:正在做或准备做 MFC 上位机、需要显示实时曲线、又不想在绘图上耗太多时间的开发者。新手可以拿它当模板直接改数据源,熟手可以看它的控件封装方式和刷新机制,再决定要不要做二次封装。

2.2 技术栈与依赖关系

这个实例的技术栈不复杂,但有几个点需要提前说清楚,否则编译阶段就容易翻车。

项目说明
开发语言C++
界面框架MFC(对话框程序为主)
图表控件ChartCtrl(开源库,需集成到工程)
开发环境Visual Studio(建议 2015 及以上)
字符集建议统一用 Unicode,避免字符串转换问题
数据来源实例中用模拟数据,实际项目替换为采集线程或串口回调

ChartCtrl 不是 VS 自带的控件,需要把它的源码文件加入工程。常见做法是把 ChartCtrl 相关的 .h 和 .cpp 文件放到一个独立目录,然后在项目里添加现有项,或者编译成静态库再引用。实例源码里通常已经包含这些文件,你拿到后先确认目录结构是否完整。

提示:ChartCtrl 的源码文件在不同版本里命名可能略有差异,集成时以实例里实际包含的文件为准,不要凭记忆去网上找同名文件替换。

2.3 目录结构长什么样

拿到源码后,先别急着编译,花两分钟看一下目录结构,能省掉后面很多“找不到文件”的麻烦。一个典型的 ChartCtrl 波形显示实例,目录大致是这样的:

ChartWaveDemo/ ├── ChartCtrl/ # 图表控件源码目录 │ ├── ChartCtrl.h │ ├── ChartCtrl.cpp │ ├── ChartAxis.h │ ├── ChartAxis.cpp │ ├── ChartLineSerie.h │ ├── ChartLineSerie.cpp │ └── ... # 其他坐标轴、图例、网格相关文件 ├── res/ # 资源文件 │ ├── ChartWaveDemo.rc2 │ └── ChartWaveDemo.ico ├── ChartWaveDemo.h # 主对话框头文件 ├── ChartWaveDemo.cpp # 主对话框实现 ├── ChartWaveDemoDlg.h # 对话框类头文件 ├── ChartWaveDemoDlg.cpp # 对话框类实现(核心逻辑在这里) ├── stdafx.h / pch.h # 预编译头 └── ChartWaveDemo.sln # 解决方案文件

核心逻辑集中在对话框类的 .cpp 文件里,ChartCtrl 目录是控件本体,一般不需要改动。如果你要移植到自己的项目,把 ChartCtrl 目录整体拷过去,再把对话框里的初始化代码和刷新代码搬过去就行。

2.4 控件初始化的关键步骤

波形显示能不能跑起来,第一步就看控件有没有初始化对。实例里通常会在对话框的OnInitDialog里完成 ChartCtrl 的绑定和基本设置。下面这段代码是典型的初始化流程:

// 在对话框头文件中声明控件变量 // CChartCtrl m_ChartCtrl; BOOL CChartWaveDemoDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 1. 将 ChartCtrl 控件与对话框上的占位区域绑定 // 实例中通常用 Picture Control 作为占位,再子类化为 CChartCtrl m_ChartCtrl.SubclassDlgItem(IDC_CHART_PLACEHOLDER, this); // 2. 创建图表对象,一个 ChartCtrl 可以包含多个图表 m_ChartCtrl.CreateChart(); // 3. 获取默认图表的指针,后续所有曲线都加在这个图表上 CChartCtrl* pChart = m_ChartCtrl.GetChart(); // 4. 设置坐标轴范围,X 轴 0~100,Y 轴 -1.5~1.5 pChart->GetBottomAxis()->SetMinMax(0, 100); pChart->GetLeftAxis()->SetMinMax(-1.5, 1.5); // 5. 开启网格显示,方便观察波形幅度 pChart->GetBottomAxis()->SetGridVisible(true); pChart->GetLeftAxis()->SetGridVisible(true); // 6. 创建一条折线序列,并设置颜色和线宽 CChartLineSerie* pLineSerie = pChart->CreateLineSerie(); pLineSerie->SetColor(RGB(0, 200, 0)); pLineSerie->SetWidth(2); return TRUE; }

这段代码的逻辑说明一下。SubclassDlgItem是把对话框上的一个静态区域变成 ChartCtrl 控件,实例里一般会在资源编辑器里放一个 Picture Control,ID 对应IDC_CHART_PLACEHOLDER。CreateChart负责创建内部图表结构,GetChart拿到图表指针后,才能操作坐标轴和曲线。坐标轴范围要根据你的实际数据来设,设得太小波形会溢出,设得太大波形会挤成一条线。CreateLineSerie创建一条折线序列,返回的指针后面用来追加数据点。

参数方面,SetMinMax的两个参数分别是轴的最小值和最大值,SetGridVisible控制网格线是否显示,SetColor和SetWidth控制曲线颜色和线宽。这些都可以在运行时动态改,比如做缩放时重新设坐标轴范围。

3. 数据刷新与波形绘制:从缓冲区到屏幕的完整链路

3.1 数据是怎么进到曲线里的

初始化只是把画布准备好,真正让波形动起来的是数据刷新。实例里通常用一个定时器或者线程来模拟数据采集,然后调用 ChartCtrl 的接口把新数据点追加到曲线上。下面是一个典型的定时器刷新逻辑:

// 在对话框类中定义定时器 ID #define TIMER_REFRESH 1 // 启动定时器,间隔 50ms,即 20 帧每秒 SetTimer(TIMER_REFRESH, 50, nullptr); void CChartWaveDemoDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == TIMER_REFRESH) { // 1. 模拟采集一个数据点,实际项目中替换为真实数据源 static double x = 0; double y = sin(x * 0.1) * 1.0; // 模拟正弦波 // 2. 获取曲线序列指针(实际项目中应保存为成员变量) CChartLineSerie* pLineSerie = m_ChartCtrl.GetChart()->GetLineSerie(0); // 3. 追加数据点 pLineSerie->AddPoint(x, y); // 4. X 轴递增,控制波形滚动 x += 1.0; // 5. 如果数据点超过 100 个,移除最早的点,保持波形滚动效果 if (pLineSerie->GetPointsCount() > 100) { pLineSerie->RemovePoint(0); m_ChartCtrl.GetChart()->GetBottomAxis()->SetMinMax(x - 100, x); } // 6. 刷新图表显示 m_ChartCtrl.RefreshCtrl(); } CDialogEx::OnTimer(nIDEvent); }

这段代码的核心是AddPoint和RefreshCtrl。AddPoint往曲线里追加一个数据点,参数是 X 和 Y 坐标。RefreshCtrl触发重绘,把新数据画到屏幕上。如果不调用RefreshCtrl,数据虽然进去了,但界面不会更新,这就是很多人遇到的“数据有但波形不动”的原因。

滚动效果是通过移除最早的数据点并同步调整 X 轴范围实现的。GetPointsCount返回当前曲线上的点数,RemovePoint(0)移除第一个点,SetMinMax把 X 轴范围往后推。这样波形看起来就是从左往右滚动的。

注意:RefreshCtrl调用频率不要太高,50ms 一次比较稳妥。如果设成 10ms 一次,界面线程会被重绘拖慢,反而导致数据采集卡顿。

3.2 多通道波形怎么加

实际项目里经常要同时显示多条曲线,比如三轴加速度、多路温度。ChartCtrl 支持在一个图表里加多条折线序列,每条序列独立设置颜色和线宽。下面是在初始化阶段加三条曲线的写法:

// 创建三条曲线,分别对应 X、Y、Z 三轴数据 CChartLineSerie* pSerieX = pChart->CreateLineSerie(); pSerieX->SetColor(RGB(255, 0, 0)); // 红色 pSerieX->SetWidth(2); CChartLineSerie* pSerieY = pChart->CreateLineSerie(); pSerieY->SetColor(RGB(0, 255, 0)); // 绿色 pSerieY->SetWidth(2); CChartLineSerie* pSerieZ = pChart->CreateLineSerie(); pSerieZ->SetColor(RGB(0, 0, 255)); // 蓝色 pSerieZ->SetWidth(2);

刷新的时候,分别往三条曲线里追加数据:

// 假设从传感器读到了三轴数据 double ax = ReadSensorX(); double ay = ReadSensorY(); double az = ReadSensorZ(); pSerieX->AddPoint(x, ax); pSerieY->AddPoint(x, ay); pSerieZ->AddPoint(x, az); m_ChartCtrl.RefreshCtrl();

多通道的关键是每条曲线单独管理数据点,不要混在一起。如果通道数很多,建议把曲线指针存到数组或 vector 里,刷新时遍历追加,避免写一堆重复代码。

3.3 坐标轴和网格的细节调整

坐标轴设置直接影响波形的可读性。实例里默认的坐标轴可能不够用,实际项目中经常需要调整刻度间隔、标签格式和网格密度。下面是一些常用的调整代码:

// 设置 X 轴刻度间隔为 10 pChart->GetBottomAxis()->SetTickLength(10); // 设置 Y 轴刻度间隔为 0.5 pChart->GetLeftAxis()->SetTickLength(0.5); // 设置坐标轴标签的字体大小 pChart->GetBottomAxis()->SetFontSize(12); pChart->GetLeftAxis()->SetFontSize(12); // 隐藏右侧和顶部坐标轴,只保留左侧和底部 pChart->GetRightAxis()->SetVisible(false); pChart->GetTopAxis()->SetVisible(false);

SetTickLength控制刻度之间的间隔,设得太密标签会重叠,设得太疏波形细节看不清。SetFontSize调整标签字体,在高分屏上适当调大能避免文字太小。隐藏多余的坐标轴可以让界面更干净,尤其是小尺寸的显示区域。

3.4 性能优化的几个实用手段

波形刷新频率高了之后,性能问题会逐渐暴露。界面卡顿、数据延迟、CPU 占用飙升,这些都是常见现象。下面几个手段是实际项目中比较有效的:

第一,控制刷新频率。不要每来一个数据点就刷新一次,而是攒一批再刷。比如采集线程每 10ms 采一个点,但界面每 50ms 刷一次,一次追加 5 个点。这样重绘次数减少,界面流畅度反而更好。

第二,限制曲线上的点数。波形显示不需要保留全部历史数据,只保留当前屏幕能显示的点数就够了。比如 X 轴范围是 100,那就只保留最近 100 个点,超出的移除。这样RefreshCtrl的重绘量是恒定的,不会越跑越慢。

第三,避免在刷新线程里做耗时操作。数据采集和界面刷新最好分开,采集线程只管往缓冲区写数据,界面定时器只管从缓冲区读数据并刷新。不要在定时器里做文件读写、网络请求这类阻塞操作。

第四,合理使用双缓冲。ChartCtrl 内部一般已经做了双缓冲,但如果你在对话框上还有其他自绘元素,注意不要破坏双缓冲机制,否则会闪屏。

4. 避坑与常见问题排查

4.1 控件绑定失败,程序启动就崩

现象:对话框初始化时调用SubclassDlgItem直接崩溃,或者控件区域显示异常。

原因:占位控件的 ID 不对,或者占位控件不是 Picture Control 类型。ChartCtrl 需要绑定到一个有效的窗口句柄上,如果 ID 写错或者控件类型不匹配,子类化就会失败。

解决:检查资源编辑器里占位控件的 ID 是否和代码里一致,确认控件类型是 Picture Control 或 Static。如果还是不行,改用Create动态创建控件,不依赖资源编辑器里的占位。

4.2 数据进去了但波形不显示

现象:调试时看到AddPoint被调用了,数据也确实加进去了,但界面上就是一片空白。

原因:最常见的是忘了调用RefreshCtrl,或者坐标轴范围设错了,数据点全在可视区域外面。还有一种情况是曲线颜色和背景色一样,看起来像没画。

解决:先确认RefreshCtrl有没有调用,再检查坐标轴的SetMinMax范围是否覆盖了数据点的值。如果都正常,把曲线颜色改成红色试试,排除颜色问题。

4.3 波形刷新时闪屏严重

现象:波形每次刷新都闪一下,看起来很不舒服。

原因:重绘时没有做双缓冲,或者对话框背景擦除和图表重绘冲突了。

解决:在对话框的OnEraseBkgnd里直接返回 TRUE,阻止背景擦除。同时确认 ChartCtrl 内部的双缓冲是否开启。如果对话框上还有其他控件,尽量把图表区域独立出来,减少重绘范围。

4.4 长时间运行后界面越来越卡

现象:程序刚启动时波形很流畅,跑了几分钟后越来越卡,CPU 占用也越来越高。

原因:曲线上的数据点没有清理,越积越多,每次重绘都要画全部点。或者定时器里做了耗时操作,导致消息队列堆积。

解决:在追加数据点时判断点数上限,超过就移除最早的点。同时检查定时器回调里有没有阻塞操作,把耗时逻辑移到独立线程。刷新频率也不要设得太高,50ms 到 100ms 之间比较合适。

4.5 移植到新项目后编译报错

现象:把 ChartCtrl 目录和对话框代码拷到新项目后,编译一堆链接错误或头文件找不到。

原因:新项目的字符集、MFC 版本或预编译头设置和原实例不一致。ChartCtrl 源码里可能用了某些特定版本的 MFC 特性。

解决:先统一字符集,建议都用 Unicode。再检查预编译头设置,把 ChartCtrl 的 .cpp 文件也纳入预编译头管理。如果还有链接错误,看看是不是漏加了某些 .cpp 文件到项目里。

5. 二次开发改造:从实例到自己的波形工具

拿到这个实例后,最直接的改造就是把模拟数据换成真实数据源。如果你用的是串口采集,可以在串口回调里解析出数据,然后通过自定义消息发给界面线程,界面线程收到消息后追加数据点并刷新。如果用的是采集卡,通常在驱动回调里拿到数据,同样通过消息或线程安全队列传给界面。

一个比较实用的改造方向是封装一个波形管理类,把 ChartCtrl 的初始化、曲线创建、数据追加、坐标轴调整都包进去,对外只暴露AddData(channel, value)和SetRange(min, max)这样的接口。这样界面代码会干净很多,换控件或换项目时也容易迁移。

下面是一个简单的封装示例:

class CWaveformManager { public: void Init(CChartCtrl* pCtrl, int channelCount) { m_pCtrl = pCtrl; m_pChart = pCtrl->GetChart(); m_pChart->GetBottomAxis()->SetMinMax(0, 100); m_pChart->GetLeftAxis()->SetMinMax(-1.5, 1.5); for (int i = 0; i < channelCount; ++i) { CChartLineSerie* pSerie = m_pChart->CreateLineSerie(); pSerie->SetColor(GetChannelColor(i)); pSerie->SetWidth(2); m_series.push_back(pSerie); } } void AddData(int channel, double x, double y) { if (channel < 0 || channel >= (int)m_series.size()) return; m_series[channel]->AddPoint(x, y); // 保持点数上限,实现滚动效果 if (m_series[channel]->GetPointsCount() > 100) { m_series[channel]->RemovePoint(0); m_pChart->GetBottomAxis()->SetMinMax(x - 100, x); } } void Refresh() { m_pCtrl->RefreshCtrl(); } private: COLORREF GetChannelColor(int index) { static const COLORREF colors[] = { RGB(255, 0, 0), RGB(0, 255, 0), RGB(0, 0, 255), RGB(255, 255, 0), RGB(255, 0, 255), RGB(0, 255, 255) }; return colors[index % 6]; } CChartCtrl* m_pCtrl = nullptr; CChartCtrl* m_pChart = nullptr; std::vector<CChartLineSerie*> m_series; };

这个封装把通道管理和颜色分配都收进去了,界面代码只需要在初始化时调Init,在数据到达时调AddData,在定时器里调Refresh。改造时注意一点:AddData里的点数上限和坐标轴范围要匹配,否则滚动效果会不自然。

另一个改造方向是加交互功能,比如鼠标滚轮缩放、拖拽平移、点击显示数值。ChartCtrl 本身支持一些交互,但默认可能没开。你可以在对话框里处理鼠标消息,根据鼠标位置计算坐标轴的新范围,然后调SetMinMax并刷新。这个功能做出来之后,波形工具的可玩性会高很多。

验证改造是否成功,最直接的方法就是跑一段时间看波形是否稳定滚动、内存是否持续增长、CPU 占用是否平稳。我一般会挂一个性能监视器,观察 GDI 对象数和内存占用,如果这两个指标在几分钟内持续上升,说明有资源没释放,得回头检查曲线点数和控件刷新逻辑。

从那以后我每次集成新的图表控件,都会先写一个最小刷新循环跑十分钟,确认没有内存泄漏和句柄泄漏,再往正式项目里搬。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进