简介:一套基于C++编写的NI数字万用表DMM驱动源码包,面向测试开发、自动化测量及实验室数据采集场景。压缩包内只有1个cpp源文件,整体仅2KB,但完整覆盖了设备初始化、测量参数配置、数据读取、错误处理和连接释放等驱动核心环节。借助该驱动,开发者可直接调用NI API控制万用表完成电压、电流、电阻等参数测量,并支持调整量程、分辨率与测量速度等关键参数。驱动通过API封装底层硬件通信,C++直接操作的方式保证了执行效率与可扩展性,代码精简,适合需要快速上手的初学者,也可作为正式项目中底层驱动模块的参考模板;目前已有283人学习下载,资源量小而精,借助该驱动可以理解C++与NI硬件交互的典型流程,并在此基础上扩展校准、多通道扫描等高级功能,无论是实验室研究、产线测试还是工程验证,都能帮助开发者快速搭建基于NI数字万用表的自动测量环境。
1. 从 dmm.cpp 说起:这个压缩包里到底有什么
做产测的人大概都有这种经历:一块板子十几个直流电压点,手上只有一台手持万用表,一笔一笔戳,半小时下不来。如果工位上恰好有一块NI数字万用表板卡,又拿到一份能看懂的C++驱动源码,这个流程就能压缩成一个for循环。dmm.zip里装的就是这么个东西——一个dmm.cpp文件,它没有安装包、没有依赖库,是把NI数字万用表封装成一组C++函数调用的驱动层代码,管初始化、管配置量程、管读数、管错误恢复。它不是让你双击安装的驱动,而是让你在VC++工程里直接引用、能改、能编过的那一层。适合三拨人:被产测自动化任务砸到、得跟NI板卡打交道的;想在C++工程里集成一台可编程万用表但不想啃SCPI手册的;还有纯粹想看硬件通信代码怎么组织的新手。接下来我把这份源码的用法、环境和值得注意的边界一次说清。
2. NI 数字万用表驱动拆解:五个基本动作与 VISA 选型逻辑
2.1 DMM 驱动到底做了什么事:五个动作对应一段测量生命周期
不管手里的NI数字万用表是PXI板卡还是台式仪器,驱动层的职责通常绕不开五个动作:建立会话、配置测量、触发采集、读取数据、关闭会话。dmm.cpp 把这五个动作包装成 C++ 函数,外部调用的时候不用管底层是 GPIB、USB 还是以太网。
我一般会这样组织测量生命周期:程序启动时先打开设备句柄,接着根据测量需求写量程和分辨率,然后发起一次测量,同步等待结果,最后在退出或者切换通道时关闭句柄。这个顺序不能乱,尤其不能跳过配置直接读——硬件会按上一次的配置返回数据,初学阶段很容易在这里拿到莫名其妙的数值。
需要特别说明的是,「驱动」这两个字在 dmm.cpp 这个语境里不是指 NI 官方的 .inf 驱动文件。官方驱动负责操作系统识别硬件,而 dmm.cpp 是站在官方驱动之上的应用层封装。你可以把它理解成一个翻译层:应用程序说「我要测直流电压,量程 10V」,它负责翻译成 NI-VISA 能识别的指令串。
2.2 为什么绕不开 NI-VISA:SCPI 命令与设备句柄的统一入口
接触 NI 板卡的人第一课基本都会遇到 VISA 这个概念。VISA(Virtual Instrument Software Architecture)是仪器通信的中间层,NI 自家的板卡用它,是德科技的仪器也用它。好处是代码写一遍,换硬件的时候只需要改设备描述符,不需要重写通信逻辑。
dmm.cpp 走的就是 VISA 这条路。关键函数是viOpenDefaultRM建立资源管理器会话,viOpen按地址打开具体设备,viWrite下发指令,viRead读取返回值。这套函数在 Windows 下对应 visa32.lib,在 Linux 下对应 libvisa.so,接口名一致。
选 VISA 而不是直接用 NI-DMM 的专属 API,是我认为这份源码最有价值的地方。NI-DMM 的 API 封装程度更高,但它绑定厂商生态;VISA 加 SCPI 指令是仪器行业通用的「普通话」。你拿 dmm.cpp 的框架去接是德科技的万用表,只要改设备描述符和量程关键字,逻辑完全能复用。SCPI 指令长这样:
CONFigure:VOLT:DC 10,0.001这条指令的意思是配置直流电压测量,量程 10V,分辨率 1mV。可见 SCPI 并没有那么神秘,就是一组带参数的命令行文本。驱动层的本质工作之一,就是把 C++ 函数的参数组合成这样一行文本再发出去。
2.3 dmm.cpp 文件的代码组织方式:一个类还是一组函数
拿到 dmm.cpp 的时候,先不要急着编译。我习惯先看它的组织结构:是用 class 封装,还是一组全局函数。两种风格各有用处,class 封装适合一个程序里同时控制多台仪器的情况,全局函数更适合快速原型。
dmm.cpp 里最常见的组织方式是围绕会话句柄展开,所有操作都依赖ViSession这个句柄,所以核心函数默认都有一个vi参数来标注当前操作哪台设备。这样设计的直接好处是:如果产线上同时挂了四台万用表,四组句柄互不干扰。如果你的应用只需要一台设备,可以再包一层单例,把句柄藏起来。
提示:判断驱动封装水平有一个简单标准——外部调用方是否能看到底层字节流。如果调用方需要自己拼 SCPI 字符串,封装就不完整;如果调用方只需要传「电压、10V、1mV」这种业务参数,封装才算到家。
3. 让 dmm.cpp 编译通过:NI-VISA 环境与工程配置清单
3.1 装齐三样东西:NI-VISA、NI-DMM 驱动与 VC++ 运行时
很多人在编译阶段就卡住,不是因为代码写错,而是环境少东西。要跑 dmm.cpp,系统里至少要有三样:NI-VISA 运行时库、NI 数字万用表的官方驱动、以及 VC++ 运行时库。
NI-VISA 是通信的基础,它提供 visa32.lib 和 visa32.dll。NI-DMM 官方驱动负责让系统在 NI MAX(Measurement & Automation Explorer)里认出板卡。这两者是配合关系:NI MAX 负责硬件枚举,VISA 负责指令通道。安装顺序上,先装官方驱动再装 VISA 是常规做法,装完之后可以在 NI MAX 左侧看到设备树。
VC++ 运行时的坑比较隐蔽。如果你用 Visual Studio 2022 编译工程,而目标机器是 Windows 7,那么必须确保目标机器装了对应版本的 Visual C++ Redistributable。这个包在微软官网上能找到,它解决的是「编译通过但运行时弹窗丢失 MSVCP140.dll」这一类问题。我的做法是把它写进部署清单,和测量程序一起分发,而不是等现场报错再到处找。
如果你在 Ubuntu 环境做同样的事情,NI 也有对应版本的 VISA 库,安装思路一致,只是设备描述符里需要填 IP 地址而不是 GPIB 卡号。跨平台时最常翻车的不是代码,而是 VISA 库的位数——32 位程序必须配 32 位 VISA,这一点在 64 位系统上尤其容易被忽略。
3.2 工程配置三条:包含目录、库目录、附加依赖项
拿到 dmm.cpp 之后,在 Visual Studio 里新建一个空工程,把 dmm.cpp 加到源文件列表,然后手动配三处:包含目录、库目录和附加依赖项。
项目配置顺序如下:
1. 打开项目属性 -> VC++ 目录 -> 包含目录 添加 C:\Program Files\IVI Foundation\VISA\Win64\Include 2. VC++ 目录 -> 库目录 添加 C:\Program Files\IVI Foundation\VISA\Win64\Lib_x64 3. 链接器 -> 输入 -> 附加依赖项 添加 visa32.lib 4. C/C++ -> 预处理器 -> 预处理器定义 添加 _CRT_SECURE_NO_WARNINGS第一项让编译器找到 visa.h 头文件,里面声明了viOpenDefaultRM这些函数的原型。第二项让链接器找到 visa32.lib 这个导入库。第三项指明具体链接哪个库。第四项是为了压掉 C 运行时函数的警告——dmm.cpp 这类源自硬件工程的代码通常直接用sprintf,新版 Visual Studio 会报 C4996,加上这个宏定义是最省事的处理方式,或者用_CRT_SECURE_NO_DEPRECATE也行。
注意:如果你的编译目标是 32 位程序,库目录要指向 Lib_x86,两个位数混用会在链接阶段报无法解析的外部符号,而且错误信息不会直接说你配错目录。
这里有个容易混淆的地方:VISA 的导入库名为 visa32.lib,但实际加载的动态库在 64 位系统上叫 visa64.dll。这是一个历史遗留命名,Windows 下 32 位和 64 位的 VISA 库文件名不同,但链接名都叫 visa32.lib。也就是说,你链接 visa32.lib 不代表你编出来的是 32 位程序,程序的位数取决于你选的平台工具集。
3.3 编译通过的判断标准:不报错不等于能跑
工程配置完之后,按下编译。编译通过这件事本身只说明了语法和链接没有问题,距离真正读到电压值还差两步:第一,NI MAX 里能看到设备;第二,一个最小测试程序能把设备句柄打开。
我每次拿到新环境都会写一个三行的最小验证,不做测量,只发*IDN?查询仪器身份。这一步能过滤掉八成环境问题。常见局面是:编译通过、程序运行、viOpen返回错误,错误码显示设备不存在。这时候问题几乎都出在设备描述符文本上——GPIB 板卡写成了 USB 的地址,或者 PXI 设备没有上电。
设备描述符的格式有固定套路:USB0::0x3923::0x72A8::MY57203084::INSTR这是 USB 设备的写法,GPIB0::1::INSTR是 GPIB 写法。不确定设备描述符的时候,可以直接在 NI MAX 里右键设备查看属性,那里显示的 VISA 地址复制过来就能用。记住一个原则:设备描述符是驱动与硬件之间的暗号,写错一个字符它都不会理你。
4. 逐段读懂 dmm.cpp:初始化、配置、采集与错误恢复
4.1 设备初始化:viOpenDefaultRM 与 viOpen 的层层参数
初始化是 dmm.cpp 里最值得读的一段代码,它示范了如何从资源管理器拿到会话句柄,再通过会话句柄打开设备。典型的写法长这样:
#include <visa.h> #include <stdio.h> ViSession rmSession = VI_NULL; // 资源管理器会话 ViSession devSession = VI_NULL; // 设备会话 ViStatus status = VI_SUCCESS; status = viOpenDefaultRM(&rmSession); if (status != VI_SUCCESS) { printf("打开资源管理器失败,错误码: 0x%x\n", status); return -1; } char desc[] = "USB0::0x3923::0x72A8::MY57203084::INSTR"; status = viOpen(rmSession, desc, VI_NULL, VI_NULL, &devSession); if (status != VI_SUCCESS) { printf("打开设备失败,请检查描述符: %s\n", desc); viClose(rmSession); return -2; }viOpenDefaultRM是整个通信链路的起点,它创建一个资源管理器会话,负责跟踪当前进程打开的所有仪器资源。desc是设备描述符,刚才说过,这个字符串的每一个字段都有含义:USB0 代表传输层,0x3923 是厂商 ID,0x72A8 是产品 ID,后面是序列号。viOpen的第三和第四个参数分别是访问模式和超时时间,这里都传VI_NULL,意思是用默认设置。
这段代码需要注意的是错误处理路径:viOpen失败时不只要打印错误,还必须把已经打开的rmSession关掉。这是典型的资源泄漏点,初学的人经常只关设备不关资源管理器,程序跑一个晚上之后,再打开设备就会报资源不足。
编译这段代码之前,确认你的项目已经配置好了 visa32.lib。如果没配置,链接器会报LNK2019: 无法解析的外部符号 viOpenDefaultRM,这是第 3 章讲过的问题。
4.2 配置测量量程:CONFigure 与 MEASure 的取舍
测量配置这部分代码决定了读回来的数值是否可信。dmm.cpp 里常见的配置写法是拼接 SCPI 命令字符串,再用viWrite发出去。下面是一种实用的封装方式:
int dmm_config_voltage_dc(ViSession dev, double range, double resolution) { char cmd[128]; // 量程传 0 表示让仪表自动选择;分辨率越小读数越慢但越精确 snprintf(cmd, sizeof(cmd), "CONFigure:VOLT:DC %.6g,%.6g", range, resolution); ViStatus status = viWrite(dev, (ViBuf)cmd, (ViUInt32)strlen(cmd), VI_NULL); if (status != VI_SUCCESS) { printf("配置命令发送失败: %s (错误码 0x%x)\n", cmd, status); return -1; } return 0; }CONFigure:VOLT:DC是配置但不算测,它只设置量程和分辨率,之后还需要单独触发。如果直接用MEASure:VOLT:DC,则是一条命令完成配置、触发、读取三个动作,但不方便在连续采样时复用配置。dmm.cpp 的价值就在这里:它把选择权留给调用方。做单次测量用MEASure,做批量连续测量就用CONFigure加后续的READ。
%.6g这个格式化符有讲究:它让浮点数按有效位输出,避免出现0.001000000000这种冗长的字符串。仪器指令解析器对超长参数的处理并不一致,保持指令短是降低兼容性风险的有效手段。
range 参数传 0 的时候,仪表会自动切换量程,这在信号幅值未知时很省事。但自动量程在高速连续采样场景下会暴露问题——量程切换本身耗时,如果信号在临界点附近波动,万用表会反复切换量程,读数的稳定性会变得很难看。所以做产测时我一般会先跑一次手动测量确认量级,再把量程固定下来。
4.3 读取与单位解析:把仪器返回的字符串变成 double
发送完测量指令之后,驱动需要把仪表返回的文本读数解析成数值。这个步骤看似简单,实际上坑不少——返回值可能是+1.23456789E-02,可能是9.91e+00,紧急情况下还可能返回OVERFLOW。下面是 dmm.cpp 里典型的读取和解析逻辑:
#include <stdlib.h> int dmm_read_dc_voltage(ViSession dev, double *value) { char buf[256]; ViUInt32 retCount = 0, ret = 0; // 先发 READ? 触发读数,再等待返回 strcpy(buf, "READ?\n"); viWrite(dev, (ViBuf)buf, (ViUInt32)strlen(buf), VI_NULL); viRead(dev, (ViBuf)buf, sizeof(buf) - 1, &retCount); buf[retCount] = '\0'; // 返回数据形如 "+1.23456789E-02",单位为伏特 if (strstr(buf, "OVERFLOW") != NULL) { printf("警告: 输入超出量程范围\n"); return -1; } *value = atof(buf); return 0; }viRead是阻塞操作,它会一直等到仪器返回数据或者超时。程序中sizeof(buf) - 1是为了给末尾字符串结束符留位置,防止数据恰好填满缓冲区导致越界。strstr判断是否出现过载标志,这比检查数值是否是INF或NAN更稳妥,因为不同型号的仪表过载返回格式不同。
把字符串转成数字用的是atof,它适合读数格式已知的场景。用strtod更稳妥——它比atof多一个能力:会告诉转换在哪里停下来。如果仪表未来改了返回格式,你的解析函数会返回转换进度供定位,而不是静默返回 0。如果你要解析的是一个包含多点位的批量返回串(有些型号支持一次读回多个通道),就可以用strtod配合指针逐步扫描。
这里再说一个解析相关的细节:dmm.cpp 返回的单位是伏特,这是 SCPI 协议里 AC/DC 电压测量的默认单位。如果你的程序处理的是毫伏信号,不要直接在驱动层除以 1000,那会污染原始数据。我的习惯是驱动层只负责把字符串转成标准单位双精度浮点数,单位换算留给上层业务函数。这样将来换台返回单位不同的仪表,只需要改驱动层。
4.4 错误恢复:错误队列与状态码的两种处理习惯
仪器通信中只要一段链路里有一个环节不稳定,程序就可能卡在某一次viRead上。dmm.cpp 的错误处理部分通常分两个层次:C API 的返回值检查和仪器自身的错误队列查询。
先看第一层,C API 层。所有 VISA 函数都返回一个ViStatus,成功时等于VI_SUCCESS。这层处理的关键是异常路径不要忽略viClose。网络超时、USB 拔出、板卡掉电都会让句柄失效,这时程序应对的策略不是重试一百次,而是记录当时的操作指令和错误码,关闭会话,重新打开设备。硬件设备的状态恢复能力远超你的想象,重新初始化通常比重试更可靠。
第二层是仪器错误队列。SCPI 设备内部维护一个错误队列,你发给它的每一条非法指令都会被记录在案。查询方式很简单:
char cmd[] = "SYSTem:ERRor?\n"; viWrite(dev, (ViBuf)cmd, strlen(cmd), VI_NULL); char errBuf[256]; ViUInt32 retCount = 0; viRead(dev, (ViBuf)errBuf, sizeof(errBuf) - 1, &retCount); errBuf[retCount] = '\0'; printf("仪器错误队列: %s\n", errBuf);这段代码发SYSTem:ERRor?指令,仪器会返回一条错误记录,格式大致是-113,"Undefined header"。负数代表错误,正数代表通知。习惯上把这个查询放在程序退出前,用来兜底检查整个测量过程是否有被吞掉的指令错误。
5. 避坑记录:从编译到采集的五条实操问题
5.1 编译报 LNK2019:viOpen 未解析的外部符号
现象:工程编译报错,提示LNK2019: 无法解析的外部符号 viOpenDefaultRM,但源码头文件明明已经包含了 visa.h。
原因:头文件只是声明,链接器需要找到 visa32.lib 才能把符号解析出来。缺少附加依赖项是最常见的原因。另外就是位数不匹配——工程配置成 x64,但库目录指向 Lib_x86。
解决:打开项目属性,确认「链接器-输入-附加依赖项」里有visa32.lib;然后检查「VC++ 目录-库目录」的具体路径,x64 工程必须指向Lib_x64。改完配置重新生成,这个错误会在 30 秒内消失。
5.2 设备描述符写错导致打开失败
现象:程序运行后卡在viOpen,返回错误码VI_ERROR_RSRC_NFOUND (0xBFFF8005),意思是没有找到资源。
原因:设备描述符字符串写错。最常见的错误是直接抄了网上的示例,把 USB 设备描述符套在 GPIB 设备上,或者漏掉了序列号字段。另一个原因是设备没上电或者没被 NI MAX 识别。
解决:打开 NI MAX,在「我的系统-设备和接口」里找到你的万用表,右键属性,复制 VISA 资源名称。把这个名称原样贴到代码里,不要手工拼接。从 NI MAX 复制出来的描述符一定是对的,手工敲容易出现隐性字符错误。
5.3 读数恒定为零且无报错
现象:程序正常编译运行,设备也打开了,指令发送也成功,但读回来的值永远是0.000000E+00,而且没有任何错误。
原因:测量指令发送成功但还没有触发,或者当前仪表处于远程控制模式但没有被正确初始化。用CONFigure配置之后忘了发READ?,或者把READ?发成了单引号版本——SCPI 指令只认双引号。
解决:确认指令顺序是「配置 → READ? → 读回」。把原始收发字符串打印出来,逐个字符核对。SCPI 命令不区分大小写,但引号、问号、空格是严格匹配的。不要用MEASure和CONFigure混着用,同一台仪表上两条路径的缓存状态不互通。
5.4 sprintf 安全报错 C4996:老代码在 VS 新版本下的兼容
现象:编译时报C4996: 'sprintf': This function may be unsafe,一大堆警告刷屏。
原因:微软在 VC++ 里默认把sprintf、strcpy这些 CRT 函数标为不安全,建议用安全版本。硬件工程代码里大量使用这些函数,逐个替换工作量很大。
解决:在工程预处理器定义里加一行_CRT_SECURE_NO_WARNINGS。这是最省事且不会影响行为的做法,适合本地工具类程序。如果你有志于长期维护这份代码,顺手把sprintf换成snprintf是更负责任的选择,它多一个缓冲区长度参数,能有效预防溢出。
5.5 读取卡死:IO 超时参数不是摆设
现象:程序运行到viRead的时候整个界面假死,等了十几秒没有反应,最终也不报错。
原因:VISA 会话打开时用的超时参数是VI_NULL,这表示用驱动默认值,通常是 2000 毫秒,但某些板卡驱动对网络延迟较大的链路会设置无限超时。viRead是同步阻塞调用,超时设置不合理就会永久等下去。
解决:在viOpen之后,用viSetAttribute显式设置超时,代码很好记:
viSetAttribute(devSession, VI_ATTR_TMO_VALUE, 5000); // 5 秒超时这里 5000 是毫秒。时间设短了,慢速仪表启动自校准时会误报超时;设长了,程序出错时反应迟钝。我的经验值:本地 USB 链路 3 秒足够,以太网链路给 10 秒。如果一个操作正常需要 20 秒,那就应该换异步模式,而不是调大超时时间。
6. 验证驱动的三个习惯:最小测试、日志与连续采样
6.1 最小测试:用一行 *IDN? 打通整条链路
每次拿到新硬件,不管是换了台设备还是换了根线,我都会先跑一个最小测试,只做一件事:发*IDN?并打印返回。这行指令是 SCPI 标准中的身份查询,任何合规仪器都会回应它的制造商、型号、序列号和固件版本。
这个测试的意义在于把问题边界划清楚。如果*IDN?能正确返回,说明从上位机到仪器的通信链路全通、驱动安装正确、会话管理正常;之后的任何问题都可以安心地在测量配置层面排查。如果连*IDN?都返回不了,就别浪费时间查测量代码了,直接回到 NI MAX 检查设备状态。习惯养成之后,每趟现场调试我能省下半小时的排错时间。
验证完成后,顺手把返回的设备型号和固件版本记到测试日志里。产测项目里经常遇到「昨天好好的今天读数不对」的情况,而实际上某台设备被固件更新过、量程行为发生了变化。有日志在手,这种玄学问题五分钟就能定位。
6.2 连续采样:同步读写的适用边界与多线程改造
单向测量场景下,同步viWrite+viRead够用。但产测程序一旦涉及连续采样,比如每 100 毫秒采一个点采 10 分钟,或者需要同时控制两台设备交替测量,同步模型就撑不住了。
我一般会做这样的改造:把测量循环放进一个独立线程,主线程只负责拿结果和显示状态。线程内部维持自己的会话句柄,不做跨线程传递。设备句柄不是线程安全的,两个线程同时往同一个句柄写指令,轻则指令交错,重则会话崩溃。每台设备的使用权要收敛到一个线程,这是硬件编程里最重要的一条纪律。
如果采样频率要求更高,就需要考虑事件回调。VISA 的viInstallHandler可以把仪器事件注册成回调函数,会话有数据到达时通知你,而不是阻塞等你来读。回调函数体要尽量短,把数据推给消费者线程处理,不要在回调里做字符串解析和文件写入,这些动作会拖慢 VISA 的事件循环,反而让采样出现抖动。
6.3 一句话收尾
这套 DMM 驱动我前后在三个项目里用过,中间踩过不少坑,最后留下的习惯是:换任何硬件之前,先跑一次*IDN?,再查一次量程,最后才动测量代码。这份 dmm.zip 的资源值得下载,希望这篇笔记能让你拿到手就直接跑起来,少走我走过的弯路,希望帮到你。
本文还有配套的精品资源,点击获取