简介:dmm.zip 是一份面向 NI(美国国家仪器)数字万用表的 C++ 驱动源码包,适合需要在自有应用中调用 DMM 板卡完成电压、电流、电阻等自动化测量的开发者。资源以单个 cpp 源文件呈现,压缩包仅 2KB,结构极简;代码覆盖设备初始化、测量参数配置、数据采集、错误处理与设备关闭等核心环节,可作为接入 NI 万用表硬件时的最小参考实现。已有 283 人学习下载,适合正在搭建 LabVIEW 之外的高性能测量控制程序、或希望快速理解 DMM 驱动调用流程的工程师。通过阅读这份驱动源码,能够直接掌握 NI DMM 常见 API 的用法与基本时序,并在此基础上扩展量程切换、连续采样等功能,满足实际项目需求。
1. 打开 dmm.zip 之前先想清楚:这个 dmm 驱动能帮你省掉什么
我拿到 dmm.zip 的第一反应是文件怎么这么小,解压出来就一个 dmm.cpp。但就是这个单文件驱动,把 NI 数字万用表从一块需要手动按量程的面板设备,变成了能在 C++ 程序里直接控制、能批量采集、能自动记录电压电流电阻的测量节点。做自动化测试的同学应该都有体会,LabVIEW 上手快但部署贵,用 C++ 写采集程序又绕不开驱动封装——这个 dmm.cpp 补的正是这一段:设备初始化、量程配置、触发测量、数据读取、资源释放,全部用 NI-DMM 底层的 API 串起来,让你在自己工程里只需关注测量逻辑。适合已经在 NI 板卡上做过点 LabVIEW 开发、想切到 C++ 或者被要求在测试机上用 C++ 重写采集模块的人,这篇就把它拆开讲清楚。
2. DMM 驱动怎么咬住硬件:初始化到读数的完整调用链
2.1 为什么 NI 的 C++ 驱动长得很像 C 代码
打开 nidmm.h 头文件你就会发现,NI-DMM 给 C/C++ 提供的接口几乎是清一色的 ViSession、ViStatus、ViReal64 这类 VISA 类型,函数名也统一是 niDMM_ 前缀加下划线命名。这其实是 NI 的兼容性设计:同一套 API 既能被 LabVIEW 的调用库函数节点引用,也能被 C++、C#、Python 调用,底层通信走 VISA 抽象层,把 USB、GPIB、PXI、以太网这些物理链路全部屏蔽掉。我拆过几个版本的 dmm.cpp,代码风格虽然各有差异,但头文件引用和函数签名的骨架基本一致,因为底层接口就长这样,想绕都绕不开。
以初始化函数为例,dmm.cpp 里最常见的一段是这个样子:
#include "nidmm.h" #include <iostream> int main() { ViSession vi = VI_NULL; ViStatus status = niDMM_InitWithOptions("", VI_FALSE, "", &vi); if (status != VI_SUCCESS) { std::cerr << "init failed with status " << status << std::endl; return -1; } std::cout << "device session: " << vi << std::endl; niDMM_close(vi); return 0; }写这段代码要留两个心眼。一是 InitWithOptions 的第一个参数传空字符串,会触发驱动自动枚举系统里所有 NI-DMM 设备,然后返回排在清单最前面那个;如果你机器上同时插了多块板卡,拿到的很可能不是你想控制的那块。二是 niDMM_close 必须放在所有读取操作之后,漏掉它会留下 ViSession 句柄,连续跑几百次采集进程会报句柄耗尽。
参数说明:第一个参数是设备描述符,传空串表示自动查找;第二个 VI_FALSE 表示不进入仿真模式,想先联调程序逻辑可以把开关打开,驱动会返回模拟数据;第三个参数是选项字符串,支持 "Simulate=1, DriverSetup=..." 这类内容,平时用不上可以传空;最后一个 &vi 回填的是会话句柄,后续所有配置、读取、关闭操作都靠它来标识设备和这一次的通信链路。
如果你不想让驱动自动选设备,建议把设备描述符写具体,比如 "PXI1Slot3" 或者 "VICP::192.168.1.100::INSTR",这样程序就不会乱认设备。常见做法是在程序入口加一个配置项,把设备描述符抽出来放在 ini 或 json 里,换设备时只改配置不动代码。
2.2 dmm.cpp 里必然会出现的四个函数块
不管 dmm.cpp 的代码风格怎么变,核心逃不开四块:设备初始化、配置测量参数、触发读取、关闭释放。初始化前面已经看过,重点说配置这一块,因为量程、分辨率、触发这三个参数直接决定测量结果的准确度和速度,也是后来排错最常盯的地方。
配置部分常见的调用顺序如下:
ViStatus config = niDMM_ConfigureMeasurement( vi, NIDMM_VAL_DC_VOLTS, // 测量直流电压 10.0, // 量程 10V 5.5 // 分辨率,NPLC 值 ); if (config != VI_SUCCESS) { ViChar desc[256] = {0}; niDMM_GetError(vi, nullptr, 256, desc); std::cerr << desc << std::endl; }ConfigureMeasurement 的参数含义要记清楚。第一个是测量类型,NIDMM_VAL_DC_VOLTS 是直流电压,NIDMM_VAL_AC_VOLTS 是交流电压,还有两线电阻 NIDMM_VAL_2_WIRE_RESISTANCE 和四线电阻 NIDMM_VAL_4_WIRE_RESISTANCE,测小电阻建议用四线,能消掉引线电阻的影响。第二个是量程,量程设得太大精度会下降,设得太小超量程直接报错,我的习惯是先自动量程跑一遍,看到信号幅值后再手动锁定量程,锁定的时候留出 20% 余量。第三个是分辨率,在 NI-DMM 里分辨率用 NPLC 表示。
NPLC 全称是 number of power line cycles,意思是你愿意让万用表用多少个工频周期去积一次分。NPLC 越大积分时间越长,工频噪声抑制越好,但测量速度越慢。10 NPLC 适合测电源纹波这类对噪声敏感的信号,1 NPLC 是日常基准,0.2 NPLC 以下适合观察动态变化。
提示:如果拿不准该用多少 NPLC,先用 1 跑一批数据,再用 10 跑同一批数据,两次结果差在噪声范围内就不用纠结,差太多就该查信号源或接地了。
2.3 同一块板卡,LabVIEW 和 C++ 的调用逻辑差在哪
做过 LabVIEW 上位机的朋友转到 C++ 后,最不适应的一点是 LabVIEW 把同步细节藏得太深。LabVIEW 里拖一个 Configure 节点接一个 Read 节点,前面板转起来很顺滑,但那是图形化编程把线程等待、触发判断都包起来了。C++ 没有这些隐式行为,你写完 ConfigureMeasurement 之后如果不显式处理触发,直接调 niDMM_Read,拿到的大概率是上一次采样留在缓冲里的旧数据,或者是驱动按默认触发条件采出来的不确定结果。
所以 dmm.cpp 里一般会在配置和读取之间插入一个触发设置:
// 设置内部触发,自动启动一次测量 niDMM_ConfigureTrigger(vi, NIDMM_VAL_INTERNAL, 1.0); // 需要外部硬件触发时改用 NIDMM_VAL_EXTERNAL,信号接到 PFI 端子第二个参数是触发延迟,单位秒。外部触发时这个值用来避开信号抖动,内部触发时设成 0 到 1 之间,目的是给驱动留出自动零点校准的时间。如果读数跳变很大,先检查这一行有没有设置足够的延迟,别急着怪硬件。
还有一个差异在错误处理上。LabVIEW 的节点自带错误簇,C++ 里只有返回值,而且 niDMM_Read 返回非零不代表程序崩了,只是驱动遇到超时或数据无效。想要拿到人能看懂的错误描述,必须额外调用一次 niDMM_GetError,把返回的错误码转成字符串再打日志。
注意:ConfigureTrigger 只决定测量何时开始,不决定测量何时结束。结束时机由读取函数控制,外部触发信号没来,niDMM_Read 会一直阻塞等待,后面避坑章会专门讲这个现象。
3. 把 dmm.cpp 接进工程:从 VSCode 配置 C/C++ 环境到第一个有效读数
3.1 只想拷贝一个源文件?先检查这三样东西
很多同学拿到 dmm.zip 后的第一个动作,是把 dmm.cpp 拷进自己的工程目录,然后看见满屏的“nidmm.h: No such file or directory”。这个错误和代码本身没关系,是你工程里缺了 NI 驱动开发包的三件套:头文件、导入库、运行时 DLL。
头文件在安装 NI-DMM 驱动后位于 C:\Program Files (x86)\IVI Foundation\VISA\WinNT\Include 这类目录,里面除了 nidmm.h,还有 visa.h 和 visatype.h,三个头文件必须一起引用。导入库在同目录的 Lib\msc 下面,文件名一般是 nidmm.lib 和 visa.lib,编译时要把这两个库链进去。运行时 DLL 是 nidmm.dll 和 visa32.dll,驱动装好后会被注册到 Windows 系统目录,不需要手工去拷,但你要确认程序运行时能找到它,常见手段是用 Dependency Walker 看一眼加载路径。
在 VSCode 里配 C/C++ 工程时,第一步不是写代码,而是把 includePath 指到驱动目录,否则代码补全和跳转全部失灵:
{ "configurations": [ { "name": "Win32", "includePath": [ "${workspaceFolder}/**", "C:/Program Files (x86)/IVI Foundation/VISA/WinNT/Include" ], "defines": ["_DEBUG", "UNICODE", "_UNICODE"], "cStandard": "c17", "cppStandard": "cpp17" } ], "version": 4 }这里有个很容易被忽略的细节:includePath 配了只是让编辑器不报红,真正编译时你要在构建任务里加 -I 参数,或者在 CMake 里用 target_include_directories 指过去。VSCode 的配置只管编辑器层面,编译器层面要单独配,这个坑我踩过,排查了半小时才发现编辑器不报错但编译器一直找不到头文件。
3.2 最小可运行的采集示例
我一般不建议直接拿别人的 dmm.cpp 拼进业务代码,而是先写一个最小程序验证驱动通路,跑通了再往你的界面或者采集流程上靠。下面这个示例完成的是:初始化设备、配置直流电压测量、读取一个点、打印结果、关闭设备,五步一步不多。
#include "nidmm.h" #include <cstdio> int main() { ViSession vi = VI_NULL; ViStatus st = niDMM_InitWithOptions("", VI_FALSE, "", &vi); if (st != VI_SUCCESS) return st; st = niDMM_ConfigureMeasurement(vi, NIDMM_VAL_DC_VOLTS, 10.0, 1.0); if (st != VI_SUCCESS) { niDMM_close(vi); return st; } st = niDMM_ConfigureTrigger(vi, NIDMM_VAL_INTERNAL, 0.5); ViReal64 reading = 0.0; st = niDMM_Read(vi, NIDMM_VAL_AUTO_DELAY, 10000, &reading); if (st == VI_SUCCESS) { std::printf("reading = %.6f V\n", reading); } else { ViChar desc[512] = {0}; niDMM_GetError(vi, nullptr, 512, desc); std::printf("error: %s\n", desc); } niDMM_close(vi); return 0; }这个程序里有三个参数容易被误写,逐个说。第一个是 niDMM_Read 的第二个参数 NIDMM_VAL_AUTO_DELAY,它的意思是读取前让驱动自动决定是否需要重新触发,如果你已经手动触发过了,把它改成 0 可以减小读取延迟。第二个是第三个参数 10000,单位毫秒,是读取超时时间,调试外部接线不确定是否正常时,先把它改成 30000,看程序是报错还是阻塞,一眼就能区分触发问题还是配置问题。第三个是 printf 里的 %.6f 格式符,这里保留 6 位小数是为了对齐显示,实际测量精度由前面配置的 NPLC 决定,不是打印格式能改变的。
编译链接时,我用的命令是:
cl /EHsc /I "C:\Program Files (x86)\IVI Foundation\VISA\WinNT\Include" \ demo_dmm.cpp \ "C:\Program Files (x86)\IVI Foundation\VISA\WinNT\Lib\msc\nidmm.lib" \ "C:\Program Files (x86)\IVI Foundation\VISA\WinNT\Lib\msc\visa.lib"如果是用 CMake 管理工程,那么 CMakeLists.txt 里至少要包含类似这样的两行,一个指头文件路径,一个指库文件路径。重点不是命令本身,而是让构建脚本可复用,不要每台机器都手敲一遍。命令里的 /EHsc 是启用 C++ 异常处理,NI 头文件里某些代码路径依赖这个开关,Release 模式不开它,可能会出现诡异的运行时错误。
3.3 四个参数的口语版理解
量程、NPLC、触发延迟、超时时间,这四个参数是 dmm.cpp 里最常被乱改的位置。量程可以粗暴理解为“你觉得信号最大能到多少伏”,NPLC 是“想滤掉多少工频噪声但不耐烦等多久”,触发延迟是“给硬件留多少时间醒过来”,超时时间是“等不到读数就报错拉倒”。这四个值是一组平衡关系,量程太小会让信号削顶,NPLC 太大让采样变慢,触发延迟太短让自动零点没跑完,超时太短让低速测量直接失败。每次调参至少只改一个变量,不要四个全改,否则出了问题根本定位不了是谁引起的。
3.4 从 dmm.cpp 到正式测量模块的收尾动作
跑通最小示例后,我在工程里一定还会做两件事。第一是把初始化逻辑和测量逻辑拆开,因为 dmm.cpp 里的代码顺序是固定的,但正式项目里设备可能要回退重连,不能每次都从头跑配置流程;第二是把错误字符串统一打成一个日志,NI 的 GetError 返回的字符串里带错误代码和上下文,存成日志比只在控制台打印返回值强得多。这两个动作没有技术难度,但能救命,尤其当你的采集程序被嵌进产线控制流程时,日志是排第一位的排错入口,没有之一。
4. 避坑与排查:NI 万用表驱动最常见的五个翻车现场
4.1 现象一:初始化返回错误,但设备管理器里明明有设备
现象:niDMM_InitWithOptions 返回负错误码,程序直接退出,但在 NI MAX 里能看到设备,指示灯也是亮的。
原因:最常见的是资源名冲突。NI MAX 里显示的设备名和驱动初始化时要的设备描述符对不上,比如你把设备命名成了 DMM1,但驱动器自动枚举时拿到的是 PXI1Slot2,两边认的不是同一个资源。还有一个隐蔽原因是 32 位程序和 64 位程序走了不同的驱动入口,某些老版本 NI-DMM 驱动对 32 位应用兼容性更好,64 位编译时容易踩到驱动版本问题。另外我遇到过机器上装了其他 NI 套件后,公共的 visa32.dll 被旧版本顶掉的情况,这类冲突排查起来最耗时间。
解决:不要依赖空字符串自动枚举,直接把设备描述符写具体,比如 "PXI1Slot2::INSTR";确认编译位数和驱动版本匹配,必要时卸载重装 NI-DMM 驱动,装完重启再跑一次,千万注意安装顺序,先装驱动再插硬件是标准做法。
4.2 现象二:配置和读取都返回成功,但读数全是一个固定值
现象:每次读取都是一个固定小数,比如 0.3267,接不接信号都一样,怎么动探头都不变。
原因:这不是硬件坏了,最可能是测量通道没切换或者量程设置不对。不少 dmm.cpp 的实现图省事,初始化后只走默认通道,信号接在另一个通道上,驱动当然只读到默认通道的底噪;另一种常见情况是量程设得和信号幅值不匹配,信号太小时万用表自动换挡没触发,读数被压在了低分辨率档位,看起来就是一个不变的小数。
解决:确认信号实际接在哪个通道,并在配置里显式指定,不要指望自动枚举帮你找到正确通道;量程先设成自动量程跑一次,看到真实幅值后再根据幅值锁定手动量程。锁定量程时留出 20% 余量,比如测得 8V,量程选 10V,不要贴着信号幅值选,否则信号波动稍大就超量程报错。
4.3 现象三:读取调用后程序卡死,不报错也不返回
现象:程序停在 niDMM_Read 这一行,状态一直是运行中,等多久都没反应,只能强杀进程。
原因:触发源配成了外部触发,但 PFI 线没接,或者外部信号压根没来,程序在等待一个永远不出现的触发脉冲。这个坑最容易在从 LabVIEW 移植到 C++ 时踩中,LabVIEW 节点对读超时的容忍度更高,C++ 的 Read 却是实打实的阻塞调用,一个触发没到就卡一整条线程。
解决:把触发源改回 NIDMM_VAL_INTERNAL,或者检查外部触发接线,用示波器确认 PFI 端子有脉冲。另外一个后悔药是给 niDMM_Read 设一个合理的超时时间,超时后驱动会返回错误而不是继续死等,但注意超时时间设太短,低速高 NPLC 的测量又会提前失败,超时值要和你配置的 NPLC 匹配起来。
提示:调试阶段先内部触发跑通数据流,再切外部触发验证同步,不要一上来就外部触发,然后怀疑驱动有问题。
4.4 现象四:连续采集几百次后内存缓慢上涨,程序越来越慢
现象:循环采集 500 次之后内存占用明显上升,最后报句柄不足,程序卡死。
原因:循环里反复调用了初始化,或者配置函数调用太频繁。niDMM_close 只调一次不够,如果每次循环都做 InitWithOptions,每次都生成一个会话对象,句柄只增不减。有些所谓“优化”代码会在循环里反复调 ConfigureMeasurement 切换测量类型,这个调用虽然比重复初始化轻量,但频繁调用同样会堆积驱动内部的缓冲状态,日积月累一样出问题。
解决:初始化只做一次,循环里只保留配置变更和读取工作;如果确实要频繁切换测量类型,就调用 ConfigureMeasurement 去切换测量函数,而不是走 Init/Close 的完整流程。我见过最夸张的一次是程序每次循环都 init 一次,最后把 VISA 会话句柄耗光,改完代码内存曲线立刻平了,这个坑完全可以通过代码审查提前拦下来。
4.5 现象五:换了一台同型号万用表,原来调好的程序读数偏了
现象:同样的代码、同样的信号,在 A 机器上读得很准,换到 B 机器上读数偏了一个固定百分比。
原因:不是代码问题,是校准参数存在每台硬件内部,出厂校准值不一样。原 dmm.cpp 如果写死了分辨率或者用了很高的 NPLC,在精度要求不高的场合看不出差异,对精度敏感的场景就暴露出来,这类问题最迷惑人,因为它没有报错,程序一切正常,只是读数对不上标准表。
解决:换设备后先跑一次设备自带的自校准,再验证测量;校准完成后把 NPLC 调低一档做交叉验证。千万不要手贱去改设备里的 calibration constants,那部分是厂商出厂标定的,改错要返厂恢复,这一点可以说是血泪教训。你可以理解为每块表都有自己的“脾气”,换表先校准,这是玄学背后唯一的科学解释。
5. 把测量吞吐拉上去:从单点 Read 换到 Initiate + Fetch 批量采集
单点 Read 在大部分场景够用,可一旦你要做的是电池放电曲线、继电器触点时序这一类需要连续采样的工作,单点读取的调度开销就成了瓶颈。NI-DMM 提供 Initiate 配合 Fetch 的批量模式,先把一轮测量缓冲起来,再开启采集循环一次一次取数,避免每次读数都被内部触发流程拖住。
niDMM_ConfigureMeasurement(vi, NIDMM_VAL_DC_VOLTS, 10.0, 0.2); // 低 NPLC 提高采样率 niDMM_Initiate(vi); // 启动一次批量采集会话 for (int i = 0; i < 100; i++) { ViReal64 val = 0.0; niDMM_Fetch(vi, NIDMM_VAL_AUTO_DELAY, 500, &val); buffer[i] = val; } niDMM_Abort(vi); // 收尾,释放会话内部缓冲Fetch 和 Read 的区别在于,Fetch 直接从已经 Initiate 的采集会话里取数,省掉了触发等待和终点判断的开销。用 0.2 NPLC 配内部触发,能做到每秒几百个点的采样率,前提是量程和信号幅值匹配好,否则 AutoZero 来回切换照样拖慢速度。如果一轮批量数据不够,可以在 Abort 之后重新 Initiate,循环套循环地做伪连续采样,大部分产线测试场景就是这么跑的。
我在自己写这个驱动的时候也碰到过一个问题——用 Read 跑单个点特别稳,一换 Fetch 就乱,后来发现是我缓冲数组只开了 50 个元素,Fetch 读取的采样数却设了 100,越界把后面数据全覆盖了,程序不报错但结果全是脏数据。从那以后我每次做连续采集都强制走一遍流程:先量采样点数开够数组,再 Initiate,循环 Fetch,最后 Abort,宁可多写一行代码也不把会话留给驱动去猜。希望帮到你。
本文还有配套的精品资源,点击获取