ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于MFC的乘法器开发:数值计算与信号波形可视化实践

基于MFC的乘法器开发:数值计算与信号波形可视化实践 简介基于C MFC的乘法器工程文件包面向初学MFC桌面开发的C学习者演示如何借助微软基础类库构建一个带图形界面的简易乘法计算工具。项目围绕对话框程序展开涉及编辑框输入、按钮触发、静态文本显示结果等关键环节并包含整数乘法函数实现与基础的异常处理思路。压缩包共28个文件其中以4个头文件和3个C源文件为代码主体另含工程配置文件dsp/dsw、资源脚本rc/ico以及已编译的exe整体约2.19MB目录结构简洁便于逐文件阅读和重新编译调试。已有285人学习浏览。通过该项目可掌握MFC对话框应用的基本搭建流程从创建对话框模板、添加控件到映射按钮消息、读取用户输入并调用乘法逻辑再到输出结果与容错处理同时还能熟悉VS工程文件组成和Debug目录下的中间产物对Windows GUI编程入门有一定参考价值。 把乘法器和MFC放在一起很多人第一反应是这不是杀鸡用牛刀吗写个乘法计算器控制台三行代码就完了非得用MFC搭个窗口出来。但如果你做过信号处理相关的课设或者给硬件写过调试上位机就会明白这里说的“乘法器”远不止两个数相乘那么简单。模拟乘法器是混频器、幅度调制器、倍频器的核心模块收音机里本振和接收信号混频靠的就是它。用C MFC给它包一层可视化外壳既能当普通计算工具用又能把两个正弦波相乘之后的波形直接画出来从运算内核到界面交互都自己写一遍这就有意思了。这篇文章我会按实际开发顺序来梳理先讲清楚“乘法器”这个项目到底要做什么再从算法层把它封装成C类然后是MFC界面怎么搭、波形怎么画最后把开发过程中踩过的坑和可以扩展的方向一并列出来。适合正在做MFC课程设计、想给硬件调试工具补一套上位机或者单纯想把C封装和界面开发串起来练手的朋友。1. 项目定位先想清楚你要做哪种“乘法器”1.1 软件层面不就是两个数相乘吗如果只看字面意思软件乘法器的核心就是一个乘号。用户在编辑框里输入两个数点击按钮弹出结果完事。很多MFC课设就是这样做的——把Edit Control、Button、Static Text拖到对话框上绑定一下变量加一段m_result m_a * m_b;编译运行交作业。功能没毛病但说实话做完之后对C和MFC的理解还停留在“会拖控件”的层面。乘法和加法减法不一样它有一个很容易被忽略的性质宽度翻倍。两个32位整数相乘结果可能需要64位才能放下两个double相乘精度和舍入行为也需要考虑。算法本身不难难的是把边界情况处理好。真正有价值的练习是把这一层抽出来做成一个独立的运算模块界面只负责输入输出运算逻辑用类封装这样后续扩展“信号乘法”模式时界面层不用大改。1.2 硬件视角模拟乘法器的真实场景再做深一点就进入了电子工程师熟悉的领域。硬件里的模拟乘法器输出并不是简单等于两个输入电压的数值乘积而是遵循一个近似的物理模型输出等于两个输入信号的乘积除以一个内部比例系数再加上一个偏置电压。典型产品比如AD633它的内部结构基于吉尔伯特单元四个象限都能工作所以叫“四象限模拟乘法器”。软件模拟这个模型并不难但它打开了一个非常实用的方向信号调制与混频的波形演示。把两个正弦波相乘用三角公式展开会得到两个新频率——和频与差频。射频收发、收音机混频、锁相环里的鉴相器底层全是这个数学关系。MFC里用CDC绘图就能把这个过程直观画出来一边是原始的两个输入波形另一边是它们的乘积肉眼直接看到波形幅度和频率的变化这比光看一堆数字直观太多了。1.3 为什么还选MFC很多人会问都什么年代了为什么不用Qt、C#或者Web技术如果你纯粹做新产品用现代框架没问题。但高校课设、老项目维护、部分工业上位机MFC依然是现实中的主力选项网上能搜到的大量Visual Studio版本相关的C MFC资料也证明了这点。MFC本身不复杂它复杂在消息映射和文档视图架构上但做一个小工具用CDialogEx就足够。学一遍MFC的消息机制再回头看Windows编程的很多概念都会通透很多。所以这个项目的定位我自己定义为一个带信号可视化能力的乘法器演示工具。既有普通乘法计算的实用功能又有模拟乘法器原理展示的教学价值。下面每个模块都是围绕这个定位展开的。2. 核心运算模块设计把硬件模型翻译成C类2.1 数值乘法类型、溢出、精度第一步先把纯粹的数值乘法写好。界面上的两个输入用户可能输入整数也可能输入小数所以运算核心用double类型最稳妥。int的问题在于溢出边界不好控制比如50000 * 50000在32位int下直接溢出成负数用户还以为程序算错了。double的范围足够大但要注意浮点比较和显示精度0.1加0.2在二进制浮点里是0.30000000000000004直接把double输出到界面上会很难看所以显示时要做格式化保留有效位数。封装一个简单的运算类界面和算法分离// MultiplierCore.h #pragma once #include string class CMultiplierCore { public: // 数值乘法模式 static double Multiply(double a, double b); // 带偏置的乘法模拟硬件乘法器 Vout (a * b) / scale offset static double HardwareMultiply(double a, double b, double scale 10.0, double offset 0.0); // 信号乘法生成采样序列result[i] A1 * sin(w1 * t) * A2 * sin(w2 * t) static bool GenerateSignalMultiply( double freq1, double freq2, double amplitude1, double amplitude2, double sampleRate, int sampleCount, std::vectordouble input1, std::vectordouble input2, std::vectordouble output); };2.2 硬件乘法器模型AD633风格的输出公式AD633这类模拟乘法器的传递函数是[ W \frac{(X_1 - X_2) \times (Y_1 - Y_2)}{10V} Z ]X和Y分别是一对差分输入Z是输出偏置。除法常数10V是内部基准电压决定的。软件模拟时如果不做差分输入简化为(a * b) / 10.0即可。界面里可以加一个“偏置电压”输入框让用户体会偏置项对输出的影响。为什么这个模型值得模拟因为硬件乘法器的输出不是无限线性的输入电压接近电源轨时会压缩、失真。软件版本如果引入一个非线性修正系数比如tanh压限就更接近真实硬件// 模拟乘法器输出限幅特性输入过大时输出不会无限增大 double CMultiplierCore::HardwareMultiply(double a, double b, double scale, double offset) { double product (a * b) / scale; // 近似模拟硬件输出级饱和特性限制在 -10V ~ 10V if (product 10.0) product 10.0; if (product -10.0) product -10.0; return product offset; }2.3 信号乘法与积化和差波形背后的数学信号乘法模式是核心功能。两个正弦波相乘三角恒等式给出[ A_1\sin(2\pi f_1 t) \times A_2\sin(2\pi f_2 t) \frac{A_1 A_2}{2}[\cos(2\pi (f_1 - f_2)t) - \cos(2\pi (f_1 f_2)t)] ]也就是说乘积信号里只有两个频率分量差频f1 - f2和和频f1 f2。原始频率f1和f2在输出里消失了。这个结论初次接触的人往往觉得反直觉但它是混频器能工作的根本原因。收音机把高频射频信号和本振信号相乘输出落在中频上然后直接滤波放大就是基于这个公式。代码实现的核心是采样。按固定的采样率生成离散时间点依次计算两个输入波形和输出波形。这里有个容易踩的坑如果两个输入频率相差特别大比如f1是1000Hz、f2是1Hz乘积信号里的和频分量有1001Hz差频分量只有999Hz两者非常接近而且都接近1000Hz。如果采样率不够高波形会严重失真甚至出现混叠。所以界面上要限制频率范围并且采样率至少要大于最高频率分量的5到10倍。我当时设了采样率10000Hz频率范围限制在1Hz到500Hz画出来效果就稳定很多。生成波形的代码核心部分bool CMultiplierCore::GenerateSignalMultiply( double freq1, double freq2, double amplitude1, double amplitude2, double sampleRate, int sampleCount, std::vectordouble input1, std::vectordouble input2, std::vectordouble output) { if (sampleRate 0 || sampleCount 0) return false; input1.resize(sampleCount); input2.resize(sampleCount); output.resize(sampleCount); for (int i 0; i sampleCount; i) { double t i / sampleRate; input1[i] amplitude1 * sin(2.0 * 3.14159265358979323846 * freq1 * t); input2[i] amplitude2 * sin(2.0 * 3.14159265358979323846 * freq2 * t); output[i] input1[i] * input2[i]; // 乘积就是模拟乘法器 } return true; }这个函数简短但它同时体现了几件事离散采样、C容器使用、三角函数计算、硬件乘法器数学模型的落地。界面模块只需要拿到这三个vector剩下的就是绘图的事。3. MFC界面与控件交互这层壳才是工作量所在3.1 界面布局方案MFC工程创建时选“基于对话框”一次到位。对话框资源里我按功能区做了分组输入区两个Edit Control分别绑定两个信号的频率和幅度一共四个输入框。为了演示方便再加两个下拉框一个选“普通乘法”一个选“信号乘法”。参数区一个Slider Control作为偏置电压调节旁边一个Static Text实时显示当前偏置值。输出区一个Edit Control显示数值乘法结果只读一个Static Text提示当前模式状态。波形显示区一个自定义绘制的静态控件CStatic子类化重写OnPaint画波形。关键设计原则所有控件都通过控件变量或DDX绑定不要在按钮事件里直接GetDlgItem到处取。每个控件都起有语义的ID比如IDC_EDIT_FREQ1、IDC_EDIT_FREQ2、IDC_EDIT_AMP1、IDC_EDIT_AMP2这样代码读起来不费劲。工程规模一大命名规范比压缩代码量重要得多。3.2 DDX数据交换与输入验证MFC里的DDXDialog Data Exchange机制负责在控件和成员变量之间同步数据。在对话框类里声明变量double m_freq1; double m_freq2; double m_amp1; double m_amp2; double m_offset; double m_result; CString m_statusText;然后在DoDataExchange里绑定void CMultiplierDlg::DoDataExchange(CDataExchange* pDX) { CDialogEx::DoDataExchange(pDX); DDX_Text(pDX, IDC_EDIT_FREQ1, m_freq1); DDV_MinMaxDouble(pDX, m_freq1, 1.0, 500.0); DDX_Text(pDX, IDC_EDIT_FREQ2, m_freq2); DDV_MinMaxDouble(pDX, m_freq2, 1.0, 500.0); DDX_Text(pDX, IDC_EDIT_AMP1, m_amp1); DDX_Text(pDX, IDC_EDIT_AMP2, m_amp2); DDX_Text(pDX, IDC_EDIT_OFFSET, m_offset); DDX_Text(pDX, IDC_EDIT_RESULT, m_result); DDX_Text(pDX, IDC_STATIC_STATUS, m_statusText); }DDV_MinMaxDouble是MFC自带的验证函数超出范围直接弹提示省去自己写if判断。这里有个注意点浮点变量在用户清空输入框时MFC的DDX_Text转换会失败默认弹窗提示“请输入一个数字”。对课设来说够用但如果想体验更好可以在OnKillfocus事件里做更友好的处理或者用UpdateData(FALSE)之前先手动判断。3.3 按钮消息映射与模式切换MFC的按钮点击事件靠消息映射处理。头文件里声明afx_msg void OnBnClickedBtnCalculate();源文件里用宏关联控件ID和函数BEGIN_MESSAGE_MAP(CMultiplierDlg, CDialogEx) ON_BN_CLICKED(IDC_BTN_CALCULATE, CMultiplierDlg::OnBnClickedBtnCalculate) ON_WM_PAINT() END_MESSAGE_MAP()按钮事件里的逻辑调度void CMultiplierDlg::OnBnClickedBtnCalculate() { UpdateData(TRUE); // 把控件值同步到成员变量 int mode m_comboMode.GetCurSel(); if (mode 0) { // 普通乘法模式 m_result CMultiplierCore::Multiply(m_amp1, m_amp2); m_statusText _T(普通乘法模式结果 输入1 × 输入2); } else { // 信号乘法模式更新波形并计算偏置后的平均幅度 m_result CMultiplierCore::HardwareMultiply(m_amp1, m_amp2, 10.0, m_offset); m_statusText _T(信号乘法模式已更新波形输出含偏置项); GenerateAndRefreshWave(); } UpdateData(FALSE); // 把成员变量值同步到控件 Invalidate(); // 触发重绘 }整个交互链路是控件 - DDX - 成员变量 - 运算类 - 回写控件 - 触发重绘。每个环节各干各的事逻辑清晰后面加功能的时候不会把按钮事件写得又臭又长。4. 波形可视化不依赖第三方库画出乘积效果4.1 自定义控件的绘制框架波形显示区我用一个自定义的CWaveView类继承CStatic在OnPaint里画图。这样封装的好处是波形控件可以被复用以后在其他项目里也能直接拖过去用。子类化的方式很简单在对话框头文件里声明一个CWaveView m_waveView;然后在OnInitDialog里动态创建或者用DDX_Control绑定。绘制逻辑分三步背景和网格、输入波形细虚线、乘积波形粗实线。坐标映射要处理好波形数据可能是-10到10窗口可能是几百像素高不做归一化直接画波形会顶出边界或者挤成一条线。void CWaveView::DrawWave(CDC* pDC, const std::vectordouble data, COLORREF color, int penWidth, bool isDashed) { CRect rcClient; GetClientRect(rcClient); // 预留边界防止波形贴边 const int margin 20; int drawWidth rcClient.Width() - 2 * margin; int drawHeight rcClient.Height() - 2 * margin; int midY rcClient.top margin drawHeight / 2; // 计算数据范围做归一化 double maxVal 1.0; for (double v : data) if (fabs(v) maxVal) maxVal fabs(v); CPen pen(isDashed ? PS_DASH : PS_SOLID, penWidth, color); CPen* pOldPen pDC-SelectObject(pen); int prevX 0, prevY 0; for (size_t i 0; i data.size(); i) { int x margin static_castint(i * drawWidth / (data.size() - 1)); int y midY - static_castint(data[i] / maxVal * drawHeight / 2); if (i 0) pDC-MoveTo(x, y); else pDC-LineTo(x, y); prevX x; prevY y; } pDC-SelectObject(pOldPen); pDC-DeleteObject(pen.Detach()); }maxVal动态计算是为了让波形在窗口里自适应显示不然频率或幅度一变波形可能会被截断。这里用fabs遍历一遍找最大值数据量只有几千个点性能完全没问题。4.2 双缓冲波形不闪烁的关键直接在OnPaint里用CPaintDC画波形窗口刷新时会看到明显的闪烁原因是整个客户区被反复擦除再重绘。数组大一点闪烁更严重。双缓冲的原理很简单先在内存里建一张和窗口一样大的位图把要画的元素全画在这张位图上最后一口气BitBlt到窗口上。用户看到的永远是一张完整画面闪烁自然消失。void CWaveView::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(rcClient); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap memBmp; memBmp.CreateCompatibleBitmap(dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(memBmp); // 在memDC上画背景、网格、波形 DrawBackground(memDC, rcClient); DrawGrid(memDC, rcClient); DrawWave(memDC, m_input1, RGB(160, 160, 160), 1, true); DrawWave(memDC, m_input2, RGB(160, 160, 160), 1, true); DrawWave(memDC, m_output, RGB(0, 102, 204), 2, false); // 一次性拷贝到窗口 dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); memBmp.DeleteObject(); }有个细节GDI资源是要手动释放的。SelectObject保存旧对象画完之后恢复避免句柄泄漏。CreateCompatibleBitmap创建的位图用完后必须DeleteObject否则长时间反复刷新程序GDI句柄会涨到几万最后绘图卡死。这是MFC老项目里最常被忽略的问题。4.3 网格绘制和坐标标注网格线的作用是让波形有参照。画水平和垂直的虚线水平线代表零基准和幅度刻度垂直线代表时间刻度。垂直网格的数量根据窗口宽度动态算间隔约50像素一条水平网格固定画5条中间一条居中并标为0。零基准线是关键——两个正弦波相乘后的输出正负半周都围绕零轴如果用户加了偏置整条波形会上移。画出零轴方便观察偏置的影响。坐标标注我不建议做太细标注最大值和零值就够了做得过于密集反而干扰波形观察。5. 踩坑记录这些细节文档里不会写5.1 输入框清空后程序崩溃第一次测试时我把频率输入框清空再点“计算”程序直接崩。原因很简单DDX_Text把空字符串转换成double失败MFC内部报异常而我没有做任何保护。后来在DoDataExchange里加了DDV_MinMaxDouble做范围限制同时还在OnBnClickedBtnCalculate开头加了数据有效性判断CString strFreq1; GetDlgItemText(IDC_EDIT_FREQ1, strFreq1); if (strFreq1.IsEmpty()) { AfxMessageBox(_T(请输入频率1)); return; }另外还有一个隐蔽情况用户输入“1e309”这个数超出double范围转换成无穷大inf画波形时坐标计算会出错。处理方式是在UpdateData(TRUE)之后用_finite()检查浮点值是否有限发现异常就重置为默认值。5.2 滑块控件消息响应偏置电压用CSliderCtrl实现范围设置成-100到100对应实际电压-10V到10V精度0.1V。滑块消息用ON_WM_HSCROLL处理但注意WM_HSCROLL不只是滑块控件发的水平滚动条也会发。处理时要先判断pScrollBar (CScrollBar*)m_sliderOffset否则其他控件的滚动消息可能会被误处理。另一个经验是滑块变化非常频繁OnHScroll里直接Invalidate()会导致波形实时刷新太过频繁CPU占用高。实际做法是滑块拖动时只更新旁边的数值文本松手后才触发波形重绘。实现方式是用TRBN_THUMBPOSCHANGING或简单一点在OnHScroll里判断通知码TB_ENDTRACK再刷新波形。滑块虚拖更新文本松手才重算波形体验和性能都兼顾。5.3 字符集选择Unicode是一道坎新建MFC工程时Visual Studio默认字符集是Unicode。这意味着所有字符串操作都涉及宽字符。常见问题用std::string和char*很顺手但MFC的CString在Unicode工程下是宽字符版本直接printf(%s)会乱码。解决办法字符串尽量用CString统一处理。需要传给标准库时用CT2A转换或者直接用CStringA。工程属性里“字符集”选“使用Unicode字符集”全局保持一致不要混着用。std::vectordouble这些纯数据容器不受字符集影响放心用。这个坑说大不大但很多刚接触MFC的人会被混淆搞到怀疑人生折腾半天以为是代码错了其实是字符集类型不一致。5.4 高DPI缩放下控件错位现在显示器分辨率都高如果不处理DPI感知程序在缩放超过100%的屏幕上会显示模糊控件布局错位波形绘制区也会变形。MFC默认按系统DPI缩放但如果对话框模板是固定像素布局字体放大会导致控件互相重叠。解决思路有两个一是从VS2015开始MFC在manifest里默认支持DPI感知但需要手动在InitInstance里设置进程DPI感知SetProcessDPIAware();二是布局动态化在OnSize里根据客户区大小重新计算每个控件的矩形位置。波形显示区做成可拉伸的对话框最大化时波形跟着变大。动态布局代码比固定布局麻烦但对用户体验提升明显。5.5 波形刷新与线程安全如果将来扩展成实时采集音频数据来驱动波形就涉及工作线程更新数据、UI线程刷新界面的问题。MFC里非UI线程不能直接操作控件必须通过PostMessage把更新请求投递到UI线程的消息队列。一个简单的做法是工作线程把数据填充到一个std::vector然后PostMessage(WM_UPDATE_WAVE)在OnUpdateWave里读取并Invalidate()。跨线程操作控件是最容易出隐性崩溃的地方一定要守规矩。6. 扩展方向把课设做成能写进简历的项目6.1 增加频谱分析乘积信号的和频差频光看波形还不够直观进一步可以做FFT频谱显示。把输出数据做一次快速傅里叶变换横轴频率、纵轴幅值两个频率分量直接以尖峰的形式显示出来。这样用户一眼就能看到100Hz和3Hz相乘后能量出现在97Hz和103Hz附近比从波形里硬读要直观得多。MFC里做FFT可以用现成的库也可以自己实现基2时间抽取算法难度适中但锻炼价值很高。频谱图一加这个项目的技术含量至少再上一个档次。6.2 叠加AM调制与混频演示在信号模式里如果两个输入频率差异很大比如500kHz的载波和1kHz的调制信号乘积结果就是调幅波。界面上加一个“调制模式”的下拉选项用户选“AM调制”输出波形直接显示包络变化的调幅波形。再做细致一点可以同时显示调制信号和已调信号把幅度调制“包络跟随调制信号”的特性展示出来。这样就从一个乘法器工具升级成一个信号与系统课程的多功能演示平台。6.3 历史记录与数据导出把每次的计算参数、结果、波形数据保存到CListBox或者CListCtrl里用户能回头查看之前的运算。更进一步用CDatabase或MySQL存储历史记录做一个小型查询系统这在某些课设里是加分项。MFC里访问MySQL可以用ODBC也可以用MySQL官方C API。波形数据导出成CSV文件也很实用用CStdioFile逐行写入用户可以在Excel里重现波形。6.4 打包发布开发完了要发给别人用不能要求对方装完整版Visual Studio。在VS里把配置从Debug改成Release然后在“项目属性 - C/C - 代码生成 - 运行库”里选“多线程(/MT)”这样C运行库会静态链接进exe目标机器不需要额外安装运行时。如果用了MFC发布时需要带上mfc140u.dll等动态库或者把MFC也改成静态链接。工程属性里“MFC的使用”选“在静态库中使用MFC”生成的exe就大了不少但省去一堆DLL的烦恼。除了运行时图标、版本信息这些也建议改一下细节拉满。6.5 面试相关MFC消息映射的底层理解做完这个项目很多C面试八股文里的知识点就有了落地的载体。MFC的消息映射机制到底是怎么回事为什么BEGIN_MESSAGE_MAP和END_MESSAGE_MAP之间能靠宏把控件事件和成员函数关联起来底层其实是一张静态消息表和虚函数表的配合。宏展开后在类里生成一个消息数组框架通过遍历数组把Windows消息分发到对应函数。这个机制和虚函数的区别、优劣是理解MFC架构的关键。项目做完之后把自己代码里ON_BN_CLICKED宏展开看看比背十道题都有用。还有一个值得深入的点C多态在MFC里的体现。CWaveView继承CStatic并重写OnPaint这本身就是多态的经典应用。面试官问你“面向对象三大特性”你能结合实际项目讲出“我在MFC里继承CStatic重写了绘制函数框架通过虚函数机制在窗口重绘时自动调用派生类的绘制逻辑”这个回答绝对加分。写在最后我在做这个项目的过程中最深的体会是一个看似简单的“乘法器”从普通乘法计算扩展到信号波形演示之后整个项目的深度和广度完全不一样了。C基础、MFC控件交互、GDI绘图、数据封装甚至信号与系统里的积化和差、混频原理全部串在了一条线上。如果你也在做同样类型的课设或者练手项目我建议别急着交作业把波形绘制和混频演示加上再抽空把FFT频谱做了这个项目写进简历完全拿得出手。最后分享一个实用小技巧调试波形绘制时不要每次重新编译再看效果可以在OnPaint里临时加一个static int s_frame 0; s_frame;在标题栏显示帧序号配合OutputDebugString输出关键变量值定位绘图问题会比打断点高效很多。Windows GDI绘图的坑大多不在逻辑而在资源管理和坐标计算多花点时间把这两个地方调稳剩下的就都是顺畅的。本文还有配套的精品资源点击获取
返回列表