简介:这套在VC++环境下完成的示波器与频谱分析工程,面向电子工程师及有C++、MFC基础的学习者,解决信号波形实时显示和频率成分分析的实践需求。资源包共35个文件,包含源代码、头文件、窗体资源配置、多格式工程文件,以及编译生成的可执行程序和调试符号,总大小约11.56MB,目录覆盖从界面、信号采样到算法实现的完整链路。目前已有225人学习浏览,适合结合运行效果理解波形绘制、快速傅里叶变换、多线程数据采集、绘制性能优化等关键技术点。工程保留了构建记录、编译日志和调试数据库,便于定位问题并二次扩展,例如增加触发方式或频谱细化功能。可以说,这份资料既是一份电子测量工具的开发实例,也是Windows环境下软硬结合编程的参考样本。
1. 为什么在 VC++ 里自己搭一套示波器加 FFT 的轮子
把 FFT 和波形显示同时塞进一个 VC++ 窗口,听起来像是课程设计,但这套工程里 scope.cpp、FFTransform.cpp、SampleIter.h 三个模块凑在一起,就是一台能真正跑数据的软件示波器。从 Release 目录里的 vc100.pdb 能推断它基于 Visual Studio 2010 的 VC100 工具集编译,而根目录下残留的 .dsw、.dsp 文件又暴露了它 VC6 时代的老底子——代码经历过多次环境迁移还能编得过,说明模块边界划分是干净的。软件示波器把硬件面板上的时基、触发电平、频谱分析全部搬进内存:信号从文件或采集卡读进来,一个线程负责喂数据,一个线程负责画波形,FFT 在中间把时域切到频域。适合想搞懂采样、触发、FFT 工程化细节的 C++ 开发者和嵌入式调试工程师。
2. 工程骨架与线程模型:从 scope.cpp 看窗口、采集与消息循环
2.1 从工程文件反推模块边界
拿到这份源码,先别急着打开 scope.cpp,把工程文件连同源码清单摊开看,模块职责很清晰:
| 文件 | 模块职责 | 在项目中的位置 |
|---|---|---|
| scope.cpp | 主窗口过程、消息映射、波形与频谱的绘制调度 | 核心入口 |
| SampleIter.h | 采样数据迭代接口,把输入缓冲切成可遍历的窗口 | 数据层 |
| FFTransform.h / .cpp | FFT 蝶形变换与幅度谱计算 | 算法层 |
| resource.rc / resource.h | 菜单、位图、图标与对话框资源 | 资源层 |
| oscilloscope.dsp / .dsw | VC6 工程文件,老版本兼容入口 | 工程层 |
这堆文件里真正参与编译的只有 scope.cpp、FFTransform.cpp 和两个头文件,其余 .sdf、ipch、.pdb 都是 VS 自动生成的中间产物。同目录下同时存在 .vcxproj 和 .dsw/.dsp,说明项目最初是在 Visual C++ 6.0 里维护的,后续迁移到 VC100 工具集。这类“VC6 起家、VS2010 收编”的代码库,最容易踩的坑不是语法,而是 MFC 版本差异和字符集切换。你在 2.2 节的代码里会看到消息映射宏,老工程迁移后第一件事就是检查这些宏是否还绑得到对应的父类。
2.2 消息映射:UI 线程怎么接住绘图请求
Windows 程序里所有界面操作都走消息循环,scope.cpp 最核心的就是消息映射表。常见写法如下:
BEGIN_MESSAGE_MAP(CScopeDlg, CDialogEx) ON_WM_PAINT() // 窗口重绘 ON_WM_TIMER() // 定时器驱动数据刷新 ON_WM_SIZE() // 窗口尺寸变化时重算绘图区域 ON_WM_LBUTTONDOWN() // 鼠标按下:设置触发电平 ON_COMMAND(IDC_BTN_RUN, &CScopeDlg::OnBtnRun) // 运行/停止 ON_COMMAND(IDC_BTN_FFT, &CScopeDlg::OnBtnFFT) // 频谱分析开关 END_MESSAGE_MAP()ON_WM_PAINT 负责刷新波形,但实际绘制不能直接在 OnPaint 里做耗时运算,否则拖动窗口时界面会卡死。我一般让 OnPaint 只做一件事:把上一帧已经画好的内存位图 BitBlt 到窗口上,真正的波形计算放到 OnTimer 或工作线程里提前完成。ON_COMMAND 宏把菜单命令和回调函数绑在一起,IDC_BTN_RUN 这类控件 ID 在 resource.h 里定义,手动改 ID 时记得两边同步,否则会出现“编译通过但按钮没反应”的怪问题。
2.3 采集线程与 UI 线程的分工
软件示波器如果只有一个 UI 线程,采样和绘制互相拖累,数据一来窗口就卡。这个项目里典型做法是拆两个线程:UI 线程处理消息循环并绘制图形,采集线程从数据源读采样点并维护共享缓冲。
| 线程 | 职责 | 关键注意点 |
|---|---|---|
| UI 线程 | 处理消息循环、绘制波形、响应菜单 | 禁止在这里做文件读盘或长循环 |
| 采集线程 | 读采样点、写共享缓冲、维护索引 | 循环里只做拷贝,不做分配 |
采集线程入口大致是这个样子:
UINT CScopeDlg::AcquireThreadProc(LPVOID pParam) { CScopeDlg* pDlg = (CScopeDlg*)pParam; while (pDlg->m_bRunning) // 线程退出标志 { // 从文件或采集卡接口一次性读出 4096 个采样点 int nRead = pDlg->ReadSamples(pDlg->m_tempBuf, 4096); if (nRead > 0) { // 写共享缓冲:临界区保护,避免和 UI 线程抢数据 EnterCriticalSection(&pDlg->m_csData); memcpy(pDlg->m_sharedBuf, pDlg->m_tempBuf, nRead * sizeof(float)); pDlg->m_nDataLen = nRead; LeaveCriticalSection(&pDlg->m_csData); } Sleep(2); // 让出 CPU,避免霸占核心 } return 0; }这段代码里 m_bRunning 是控制线程退出的标志位,m_csData 是临界区句柄。每次拷贝都要锁临界区,否则 UI 线程正在画图时数据被改了一半,波形上会出撕裂的毛刺。Sleep(2) 的语义不是“精确延时两毫秒”,而是把 CPU 时间让给 UI 线程,保证界面操作跟手。如果是 VC6 老工程迁移过来,还要注意 AfxBeginThread 和 CreateThread 混用的问题:MFC 对象不能直接跨线程访问,跨线程传递数据只能靠临界区加共享缓冲。
提示:调试时先跑 Debug 配置,确认逻辑正确;看实时性再切 Release。Debug 下的 MFC 断言和迭代器检查会把性能拖掉一个量级,这个问题留到第 5 章详细展开。
3. 波形绘制与 SampleIter:双缓冲绘图与时基控制
3.1 SampleIter:把采样缓冲区变成可遍历的信号窗口
SampleIter.h 是这个项目里容易被忽略但很关键的抽象。它的作用类似 C++ 标准库的迭代器:外部只关心“下一批采样点在哪、有多少个”,不关心数据来自声卡、串口还是文本文件。示波器要支持时基切换,本质上就是改变“每个屏幕像素对应多少个采样点”。
一个最小可用的迭代器接口大致如下:
class SampleIter { public: // 重置到缓冲区头部 void Reset(const float* pData, int nLen, int nPointsPerPixel); // 取出第 n 个屏幕像素覆盖的采样段,返回该段最小值和最大值 bool GetSegment(int nPixel, float& fMin, float& fMax); private: const float* m_pData; // 采集到的浮点采样缓冲 int m_nDataLen; // 采样点总数 int m_nStep; // 每像素对应的采样点个数,也就是时基档位 };把数据访问统一收敛到迭代器里,好处是画图代码不用关心数据是环形缓冲还是完整数组。想加缩放、平移、滚动显示,只要调整 m_nStep 和 Reset 时的起始偏移量,波形绘制代码一行都不用改。
3.2 双缓冲绘图:为什么你的波形会闪
很多人在 OnPaint 里直接画线,结果一刷新就闪烁。原因是背景擦除和前景绘制之间有空窗期:Windows 先用背景色把客户区刷白,再一条条画波形,人眼看到的就是连续明暗交替。解决方法是双缓冲,先在内存里把整帧波形画好,再一次 BitBlt 到屏幕:
void CScopeDlg::DrawWaveform(CDC* pDC, CRect rcClient) { // 创建与窗口兼容的内存 DC 和位图 CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rcClient.Width(), rcClient.Height()); CBitmap* pOld = memDC.SelectObject(&bmp); // 画深色网格背景 memDC.FillSolidRect(rcClient, RGB(8, 12, 20)); DrawGrid(&memDC, rcClient, 10, 8); // 用 SampleIter 遍历采样数据,逐像素连出波形 SampleIter iter; iter.Reset(m_pVisData, m_nVisLen, m_nPointsPerPixel); for (int x = 0; x < rcClient.Width(); x++) { float fMin, fMax; iter.GetSegment(x, fMin, fMax); int yTop = VoltageToY(fMax); // 电压值转屏幕 Y 坐标 int yBottom = VoltageToY(fMin); if (yTop > yBottom) { Swap(yTop, yBottom); } memDC.MoveTo(x, yTop); memDC.LineTo(x, yBottom); } // 一次性拷贝到屏幕,闪烁消失 pDC->BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }这里每像素画一根竖线而不是逐点连线,因为当每像素对应几十个采样点时,逐点画线会互相覆盖且效率低。VoltageToY 做电压刻度映射:y = centerY - (voltage / voltsPerDiv) * divHeight,divHeight 是屏幕上每格的像素高度。这种“每列取 min/max”的画法,视觉上保留了信号的峰值轨迹,适合观察带毛刺的脉冲波形。虚拟示波器里的曲线颜色也在这个函数里控制,把 memDC 的画笔换成不同 RGB 值就能区分多通道。
3.3 时基挡位、触发和占空比测量
时基设置直接决定 m_nPointsPerPixel 的取值。常见做法是按 1-2-5 步进提供挡位,比如 1ms/div、2ms/div、5ms/div、10ms/div,屏幕固定 10 格宽,每像素采样点用公式nStep = ceil(fs * totalTime / screenWidth)计算。以 16kHz 采样率为例:
| 时基挡位 | 10 格总时长 | 总采样点 | 每像素采样点(800px) |
|---|---|---|---|
| 1 ms/div | 10 ms | 160 | 1 |
| 10 ms/div | 100 ms | 1600 | 2 |
| 100 ms/div | 1 s | 16000 | 20 |
| 1 s/div | 10 s | 160000 | 200 |
触发逻辑是软件示波器的另一个关键点。边沿触发要在两个相邻采样点之间找“穿过触发电平”的位置:当前值大于触发电平且前一个值小于触发电平,就记下这个索引作为绘制起点。多周期波形叠加时,只锁定第一个上升沿,后续周期按固定采样数向后取。占空比测量也依赖同一个过零检测:统计一个周期内高于触发电平的时间占比,再乘 100%。这个功能在 Multisim 里是旋钮一拧的事,自研示波器里就得自己算计数和脉宽。
提示:接示波器探头时注意 x1 和 x10 的衰减档位。x10 探头在物理上把信号衰减了 10 倍,软件里不做补偿,电压读数会整整小 10 倍。程序里加一个探头衰减系数,切换探头时同步改系数。
4. FFT 频谱分析:FFTransform 的蝶形实现与参数选择
4.1 为什么不用教科书上的 DFT
频谱分析的核心是离散傅里叶变换(DFT),但按定义写两层循环,N=1024 时运算量是百万次复数乘加,刷新一帧频谱能把 UI 线程拖死。工程里用的是快速傅里叶变换(FFT),本质是把 DFT 矩阵按奇偶分解成蝶形结构,复杂度从 O(N²) 降到 O(N log₂N)。FFTransform.cpp 里实现的是基 2 时间抽取算法,要求输入长度必须是 2 的整数次幂,所以调用前要限制 FFT 点数只能是 1024、2048、4096 这种值。嵌入式场景里,基于 STM32F4 的嵌入式 FFT 频谱分析系统通常直接用 CMSIS-DSP 库,但在 PC 端 VC++ 环境自己写蝶形,反而更好控制内存布局和窗函数衔接。
4.2 位反转与三层蝶形循环
基 2 FFT 的第一步是位反转排序。输入序列按下标的二进制位倒序重排,之后才能按照蝶形结构逐级合并。核心代码如下:
void FFTransform::FFT(float* pReal, float* pImag, int nLen, bool bInverse) { // 第一步:位反转重排 for (int i = 1, j = 0; i < nLen; i++) { int bit = nLen >> 1; while (j & bit) { j ^= bit; bit >>= 1; } j ^= bit; if (i < j) { Swap(pReal[i], pReal[j]); Swap(pImag[i], pImag[j]); } } // 第二步:多级蝶形运算 for (int len = 2; len <= nLen; len <<= 1) { float fAngle = (bInverse ? 2.0f : -2.0f) * PI / len; float wR = cosf(fAngle); float wI = sinf(fAngle); for (int i = 0; i < nLen; i += len) { float curR = 1.0f, curI = 0.0f; for (int k = 0; k < len / 2; k++) { float ur = pReal[i + k]; float ui = pImag[i + k]; float vr = pReal[i + k + len / 2] * curR - pImag[i + k + len / 2] * curI; float vi = pReal[i + k + len / 2] * curI + pImag[i + k + len / 2] * curR; pReal[i + k] = ur + vr; pImag[i + k] = ui + vi; pReal[i + k + len / 2] = ur - vr; pImag[i + k + len / 2] = ui - vi; // 旋转因子递推,避免内层循环反复调用 sin/cos float tR = curR * wR - curI * wI; curI = curR * wI + curI * wR; curR = tR; } } } }位反转那段是标准写法:i 从 0 扫到 N-1,同时用 j 模拟二进制位翻转,只有 i<j 时才交换,保证每个点只换一次。三层循环分别是级数、蝶形组、组内蝶形。旋转因子 curR/curI 从 1.0 开始,每次乘上常数 wR/wI 递推得到,不用在里层循环反复调用三角函数。bInverse 控制正反变换方向,反变换时最后要记得整体除以 N。调用前把采样数据拷进 pReal,pImag 清零即可。
精度方面,float 和 double 的选择要看场景。采样率 8kHz 到 192kHz、点数 1024 到 8192 时,float 足够,而且内存占用减半、缓存命中更好;double 适合需要极高动态范围的振动或声学测量,但变换耗时接近翻倍。通用频谱分析软件里 float 加查表法已经能跑得很好。
4.3 窗函数、幅度谱和频率轴映射
FFT 隐含假设输入是无限周期延拓的,如果截断窗口里的信号不是整周期,频谱会出现泄漏,谱线拖尾盖住旁边的弱信号。加窗就是为了压低泄漏。汉宁窗是振动频谱分析和音频分析里最常用的折中方案:
// 对 4096 点数据施加汉宁窗后再做 FFT void FFTransform::ApplyHannWindow(float* pData, int nLen) { for (int i = 0; i < nLen; i++) { float w = 0.5f * (1.0f - cosf(2.0f * PI * i / (nLen - 1))); pData[i] *= w; } }窗函数的选择直接影响频谱质量,下面这张表总结了常见窗的差异:
| 窗函数 | 主瓣宽度 | 旁瓣衰减 | 适用场景 |
|---|---|---|---|
| 矩形窗 | 最窄 | -13.3 dB | 瞬态、冲击信号 |
| 汉宁窗 | 较宽 | -31.5 dB | 通用,振动、音频 |
| 汉明窗 | 较宽 | -43 dB | 语音窄带信号 |
| 布莱克曼窗 | 最宽 | -58 dB | 频率成分很接近的信号 |
加窗之后算幅度谱要用单边谱公式:amp[i] = 2 * sqrt(re[i]^2 + im[i]^2) / N,i=0 的直流分量不乘 2。显示成 dB 就取20 * log10(amp / ref),ref 是参考电平。频率轴第 k 条谱线对应频率fk = k * fs / N,fs=16000、N=4096 时频率分辨率只有 3.9 Hz,这就是能区分两个相邻频点的最小间隔。工程里看振动频谱图,关心的是基频处的峰值和它的谐波序列,比如 50Hz 工频干扰会在 50、100、150Hz 各出一根谱线;供水管网噪声记录仪做频带划分时,也是先把 20Hz 到 2kHz 的能量按三分之一倍频程累加,再判断管段泄漏特征。
5. Release 与 Debug 的差异:性能优化和实时性陷阱
5.1 vc100.pdb 与两套编译配置
工程里同时存在 Debug 和 Release 目录,Release 下躺着 vc100.pdb,这是 VS2010 生成的调试符号文件。老 VC6 项目最常见的问题是拿 Debug 版跑高速采集,结果界面卡死、数据丢帧。Debug 默认 /Od 关闭优化,STL 容器带迭代器调试检查,每条 assert 都拖慢速度;切到 Release 后 /O2 开启最大速度优化,性能能提升几倍甚至一个量级。所谓“VC++ 6 运行效率低”,多半不是编译器的问题,而是配置选错了。验证方法很简单:看任务管理器里进程 CPU 占用,Debug 版画 4096 点波形可能吃掉 30% 核心,Release 版通常能压到 5% 以下。
5.2 查表、缓存对齐与线程优先级
FFT 和波形绘制的优化可以从几个方向做,收益排序大致如下:
| 优化点 | 做法 | 典型收益 |
|---|---|---|
| 旋转因子查表 | 预计算 cos/sin 表,运行时查表替代三角函数 | 内层循环减少 70% 函数调用 |
| 数据对齐 | 采样缓冲按 16 字节对齐,配合 SIMD | 提高向量化命中率 |
| 线程优先级 | 采集线程设为 THREAD_PRIORITY_ABOVE_NORMAL | 减少采样丢帧 |
查表实现很直接,初始化一次,运行时按索引取数:
// 预计算 4096 点三角函数表 static float g_sinTable[4096], g_cosTable[4096]; void InitTwiddleTable(int nPoints) { for (int i = 0; i < nPoints; i++) { float angle = 2.0f * PI * i / nPoints; g_sinTable[i] = sinf(angle); g_cosTable[i] = cosf(angle); } }用的时候索引按idx & 4095取模,避免整型除法。注意表长度必须是 2 的幂,位与运算比取模快得多。线程优先级不要设成 REALTIME_PRIORITY_CLASS,那个级别会和系统关键线程抢时间片,导致鼠标键盘响应异常;ABOVE_NORMAL 足够保证采集不丢帧,又不会让系统失稳。
5.3 丢帧检测与时间戳
实时示波器最怕静默丢数据。UI 线程画图慢一点,屏幕只是刷新率下降,采集线程读数据时共享缓冲被覆盖,波形会在无提示的情况下失真。加一个采样计数器,用高精度时钟做丢帧检测:
// 采集线程里每次累加采样数,UI 线程定时比较进度 LONGLONG g_nTotalSamples = 0; // 采集线程写入 LONGLONG g_nLastSamples = 0; // UI 线程上一次读到的值 void CScopeDlg::OnTimerCheck() { LONGLONG nNow = g_nTotalSamples; LONGLONG nDelta = nNow - g_nLastSamples; // 实际采样速率 = 新增样本数 / 定时器间隔 float fActualRate = (float)nDelta * 1000.0f / (float)m_nTimerMs; // 低于期望值 90% 就报警 if (fActualRate < m_fSampleRate * 0.90f) { m_statusBar.SetWindowText( L"警告:采样跟不上,请检查线程优先级或缓冲大小"); } g_nLastSamples = nNow; }nDelta 是两次定时器触发之间新增的采样点数量,除以间隔毫秒数就得到实际采样速率。和期望采样率比较,偏差超过 10% 就报警告。调试 DLL 联动时也可以用同样的思路:在 DLL 内部导出采样统计接口,宿主程序定时查询,定位丢帧到底发生在采集驱动、DLL 处理还是 UI 绘制环节。缓冲区容量不够时,常见做法是加大共享缓冲到期望采样率的 3-5 倍,形成环形缓冲,用读写指针差值计算空满程度。
6. 扩展:把软件示波器变成小型信号分析平台
6.1 自动峰值搜索与频点标记
频谱图画出来只是第一步,实际振动和音频分析里,最常用的是自动找出峰值频点。做法是遍历幅度谱,要求某点既大于左右邻居,又超过绝对阈值和包络阈值,避免把噪声尖峰当成真实信号:
// 在幅度谱里搜索局部极大值,保存谱线索引 void FindPeaks(const float* pMag, int nLen, float fMinDb, float fThresholdDb, vector<int>& peaks) { for (int i = 1; i < nLen - 1; i++) { // 左右相邻都低于当前点,且幅度超过阈值 if (pMag[i] >= pMag[i - 1] && pMag[i] >= pMag[i + 1] && pMag[i] >= fThresholdDb && pMag[i] > fMinDb) { peaks.push_back(i); } } }找到的谱线索引换算成频率fk = k * fs / N,在频谱图上用不同颜色标记,频率值显示到状态栏或悬浮提示里。阈值建议设成相对参考电平的 dB 值,比如 -60dB,这样切换输入幅度时峰检逻辑不用跟着改。
6.2 波形导出 CSV 与离线分析
软件示波器的另一个价值是数据能带出去。把当前屏幕的采样点写成 CSV,直接交给 Python 或 Excel 做二次分析,比在示波器界面上反复截屏可靠得多:
FILE* fp = nullptr; fopen_s(&fp, "wave.csv", "w"); fprintf(fp, "index,time_ms,voltage_mV\n"); for (int i = 0; i < m_nVisLen; i++) { fprintf(fp, "%d,%.4f,%.4f\n", i, i * 1000.0f / m_fSampleRate, // 时间,单位 ms m_pVisData[i] * 1000.0f); // 电压,单位 mV } fclose(fp);导出后,示波器捕获的波形 CSV 可以在电脑上直接回放,离线跑更重的算法,比如谐波失真分析、包络提取或深度学习故障分类。多通道扩展时,每列数据加一个通道编号列,保持时间轴对齐,后续处理就不用担心串口和采样率对不上的问题。这套框架里,SampleIter 的抽象让换数据源变得很便宜,从文件读、从网络读、从真实采集卡读,只改 ReadSamples 的实现就够了。硬件探头 x1/x10 的衰减补偿在软件里要保留一个可调系数,多通道扩展时注意通道间串扰,相同幅值信号接入两个通道,在频谱上通常会出现微弱的二次谐波差值,这是正常的模拟前端特性,不是 FFT 算错了。
本文还有配套的精品资源,点击获取