简介:面向需要将LabVIEW程序封装为DLL并供其他语言调用的开发者,资源内含可运行LabVIEW工程与配套教程,完整演示了从新建VI、启用DLL选项、设计输入输出接口,到编译链接、生成头文件并在外部程序中调用的流程,适合在跨语言集成、仪器控制或算法复用场景中工作的工程师快速上手。资源包共18个文件、约492KB,包含多个VI源程序和LabVIEW项目文件用于修改逻辑,生成的DLL、导入库(lib)和头文件(h)可直接复用,另有INI配置与Word教程文档帮助梳理步骤。已有627人学习/下载。通过该范例,读者能重点掌握数据类型映射、错误处理、线程安全等关键环节,有效降低LabVIEW与其他语言交互时的调试成本,快速产出稳定可用的DLL模块。
1. LabVIEW 生成 dll 的范例和教程:先解决“跨语言调用”这个真实需求
在自动化测试上位机里,LabVIEW 负责采集和信号处理,主程序却是 C# 写的。你不可能为了两个算法就把界面重写一遍,也不该用 TCP 把数组传来传去,那既慢又难维护。最直接的做法是:把那个 VI 封装成 dll,丢给 C# 或 Python 调用,调用方连 LabVIEW 都不用装。这份笔记就是一套能照做的 LabVIEW 生成 dll 的范例和教程:从新建共享库、配置参数格式,到跨语言调用与排错,全程按可复现的步骤写。适合正在用 LabVIEW 交付算法、又必须对外提供接口的工程师。你即将踩到的 dll 冲突、函数入口找不到、内存翻车,我也都踩过。
2. LabVIEW 共享库的三个底层选择:位数、调用约定与导出函数名
LabVIEW 生成 dll,本质上是把图形化的 VI 编译成一个共享库。生成后的二进制不依赖 LabVIEW 开发环境,但依赖对应位数的 LabVIEW Runtime;这个 Runtime 在目标机器上可以免费分发。很多人第一次做这件事,第一反应就是新建一个“共享库”然后点构建,结果拿到别的语言里调用,莫名其妙崩溃。做这个方案之前,先把三个底层问题定下来,后面才不会反复返工。
2.1 32位还是64位:一次选择决定所有调用方的进程位数
位数是第一个要拍板的事。Windows 下 32 位进程只能加载 32 位 DLL,64 位进程只能加载 64 位 DLL,两边没有任何兼容性可讲。一个很典型的翻车现场:开发机装的是 64 位 Python,调用 32 位 LabVIEW 生成的 dll,加载时报错,或者加载通过但一调函数就崩溃,原因根本不是代码,而是位数错配。
在 LabVIEW 里生成 dll 时会让你选目标平台。常见做法是:右键项目树里的“程序生成规范”,选择“新建 -> 共享库”,在弹出的共享库属性对话框里找到“目标”页签,里面会列出平台选项。这里有个关键限制——只有 64 位版本的 LabVIEW 才能生成 64 位 DLL;如果你装的是 32 位 LabVIEW,生成出来的一定是 32 位,跟调用方没关系。所以,如果客户那边整体是 64 位环境,而你手里只有 32 位 LabVIEW,最省事的做法是先统一开发工具版本,而不是在部署现场硬扛。
拿到一个陌生 DLL 想快速判断位数,可以在 Visual Studio 开发命令行里用 dumpbin 查看:
# 在 Visual Studio 开发命令行里执行;查看 PE 头的机器类型 dumpbin /headers SignalProc.dll | findstr /i machine输出里出现 x86 就是 32 位 DLL,出现 x64 就是 64 位 DLL。这个命令同样可以用来验证你生成出来的产物,而不是只看文件大小或猜。要记住:每次重新生成 dll 后,调用方的项目平台目标也要跟着检查一遍。C# 项目里是“平台目标”选 AnyCPU、x64 还是 x86;Python 则是解释器位数。这个选择一旦定下来,整个接口契约都得围绕它走,中途换位数不是改一个选项就能补齐的。
2.2 调用约定:C还是StdCall,为什么跨语言调用时总在这里翻车
调用约定决定了函数参数怎么入栈、由谁清理栈。最常见的是 C 约定(cdecl)和 StdCall 约定。LabVIEW 的共享库配置里有一个“调用约定”下拉框,很多新手直接忽略,结果外部调用方怎么调都不对,还以为是 DLL 坏了。
先说结论:如果主要给 Python 和 C/C++ 调用,选 C 约定;如果给 VB6 或老式 C/C++ 代码调用,选 StdCall。C# 两种都能支持,但DllImport里写什么CallingConvention,必须和 LabVIEW 生成时选的一致,不能想当然。
| 调用方 | 默认约定 | LabVIEW 侧建议 |
|---|---|---|
Pythonctypes.CDLL | C(cdecl) | C |
Pythonctypes.WinDLL | StdCall | StdCall |
C#DllImport | StdCall | 与 DllImport 保持一致 |
| C/C++ 源码 | 由函数声明决定 | C 约定最通用 |
调用约定不匹配的诡异之处在于,它往往不直接报“参数错误”,而是函数能执行,但返回结果不对,或者调用几次后程序栈损坏崩溃。这种问题排查起来很费劲,我一般会在生成 dll 后第一时间把调用约定写进一个接口记录文件,避免过两个月自己都忘了当时选了什么。
2.3 导出函数名不是VI名:@符号和实例后缀
第三个坑是函数名。LabVIEW 生成的 DLL,导出函数名并不是单纯的 VI 名,而是“VI 名 + @ + 实例后缀”,比如AddValues@或Filter@1。@ 后面的内容跟 VI 在项目树里的路径有关,带有一定的随机性,但它是实际导出符号的一部分,外部调用必须用完整名字。
要确认导出的真实函数名,最靠谱的办法是看项目生成的头文件,或者用 dumpbin 查看导出表:
# 列出 DLL 里所有导出的函数符号 dumpbin /exports SignalProc.dll输出里“Export Name”那一列就是外部代码要用的名字,比如AddValues@。在 Python 里访问带 @ 的函数名不是直接写lib.AddValues@,那样是语法错误,要写成lib["AddValues@"]或者getattr(lib, "AddValues@")。C# 的EntryPoint参数同样要把完整符号名写成字符串。
我见过不少人在这三个点上来回折腾:动态加载失败就怀疑 Dll 路径,路径对了又怀疑依赖缺失,最后才发现是位数、调用约定和带 @ 的导出名这三件事没对齐。所以第 2 章的这些底层选择,值得在动手写 VI 之前就确定。
3. 最小生成流程:从新建共享库到 Python/C# 双端调用
网上 LabVIEW 实例很多,但大多是串口通信、仪表盘或数组处理,真正把“生成 dll”从头到尾讲完整的并不常见。这一章直接给出一份可抄作业的最小工程:一个输入两个 double 返回一个 double 的 VI,生成 DLL 后用 Python 和 C# 分别调用。按这个流程跑通一次,后面的复杂接口就只是参数扩展。
3.1 在LabVIEW里建立共享库工程并配置导出 VI
首先新建项目:文件 -> 新建 -> 项目。在“我的电脑”下新建一个虚拟文件夹,比如叫dll_src,把要导出的 VI 放进去。VI 的接口尽量简单,用数值、数值数组和字符串,不要用深层嵌套的簇,否则跨语言调用时封送数据会让你怀疑人生。
右键“我的电脑”或项目文件夹,选择“新建 -> 程序生成规范 -> 共享库”,打开共享库属性对话框。这个对话框里需要关心的页签有“源文件”“目标”“调用约定”“错误处理”。在“源文件”页里,把目标 VI 添加到导出列表,然后点“配置”,逐参数设置方向和数据结构。
我一般这样配置参数:
| 配置项 | 我的推荐 | 说明 |
|---|---|---|
| 目标平台 | 与调用方一致(如 x64) | 32/64 位错配是崩溃头号原因 |
| 调用约定 | C(cdecl) | Python CDLL 与 C# Cdecl 都能对应 |
| 错误簇 | 映射为 int32 返回码 | 0 表示成功,外部判 0 即可 |
| 字符串参数 | C String 指针或 U8 字节数组 | 外部预分配缓冲区 |
| 数组参数 | 数据指针 + 长度 | 避免传 LabVIEW 内部句柄 |
| 生成头文件/导入库 | 勾选 | 接口签名有据可查 |
配置完成后,在“目标”页里设置输出文件名,比如SignalProc.dll,然后点击构建。构建信息会显示在输出窗口,报错时最常见的三个原因是:VI 没保存、项目路径里有中文或空格、某个用于导出的 VI 没连错误簇导致配置失败。先把 VI 存盘,再用纯英文路径重建一次,大多数问题能解决。
3.2 第一次生成DLL后,检查哪些文件
构建成功后,进入输出目录,常见的产物通常包括 .dll、.h 头文件和 .lib 导入库。不同 LabVIEW 版本略有差异,但头文件和导入库不是摆设:
| 文件 | 作用 | 谁需要 |
|---|---|---|
SignalProc.dll | 运行时实际加载的库 | 所有调用方 |
SignalProc.h | 函数声明、数据类型定义 | C/C++ 调用方 |
SignalProc.lib | VS 链接时需要的导入库 | 静态链接场景 |
Python 和 C# 只需要 DLL,但头文件对你依然有价值,因为它最准确地反映了函数签名和导出名。生成后我会先用 dumpbin 确认导出表,再做调用,而不是直接写代码,这样能绕开一多半的排错时间。
# 确认导出函数名和 DLL 位数(在 Visual Studio 命令行执行) dumpbin /exports ".\Build\SignalProc.dll"3.3 Python调用LabVIEW生成的DLL:最小可运行代码
假设导出的 VI 是AddValues,输入a和b,输出result,错误簇映射为 int32 返回码。生成后的实际导出名是AddValues@。Python 端代码如下:
import ctypes # DLL 路径请替换为你自己的实际构建目录 dll_path = r"C:\work\SignalProc\Build\SignalProc.dll" lib = ctypes.CDLL(dll_path) # 导出名带 @ 符号,必须用这种方式访问 func = lib["AddValues@"] # 前两个参数是 double 输入,第三个是输出指针 func.argtypes = [ ctypes.c_double, # 对应 VI 的输入 a ctypes.c_double, # 对应 VI 的输入 b ctypes.POINTER(ctypes.c_double) # 输出 result,外部预分配 ] func.restype = ctypes.c_int32 # 错误簇映射成返回码 out = ctypes.c_double(0.0) # 提前分配一块内存给结果 err = func(1.2, 3.4, ctypes.byref(out)) if err != 0: raise RuntimeError(f"LabVIEW 返回错误码: {err}") print("a + b =", out.value)这段代码的关键在最后三个参数的处理:argtypes里的第三个类型必须是POINTER(c_double),因为 LabVIEW 侧的输出参数本质上是往一个地址里写数据;调用时用ctypes.byref(out)传引用,out.value才能拿到结果。restype设成ctypes.c_int32是因为我们把错误簇映射成了返回码。如果你在 LabVIEW 侧选的是 StdCall,这里要改用ctypes.WinDLL,否则栈清理方式对不上,函数能跑但结果不对。
3.4 C#调用LabVIEW生成的DLL:平台配置与入口点匹配
C# 调用同样不复杂,但要比 Python 多注意两件事:平台目标必须和 DLL 位数一致,EntryPoint必须带 @ 后缀。
using System; using System.Runtime.InteropServices; class Program { // 入口点必须写完整符号名,约定与生成时保持一致 [DllImport(@"C:\work\SignalProc\Build\SignalProc.dll", EntryPoint = "AddValues@", CallingConvention = CallingConvention.Cdecl)] private static extern int AddValues( double a, double b, out double result); static void Main() { int err = AddValues(1.2, 3.4, out double result); if (err != 0) throw new InvalidOperationException($"LabVIEW 返回错误码 {err}"); Console.WriteLine($"a + b = {result}"); } }注意EntryPoint我特意写成了字符串,而不是把方法名AddValues直接当成入口名。因为在 LabVIEW 生成的 DLL 里,真正的导出符号是AddValues@,让 CLR 按原方法名去 PE 导出表里找是找不到的。out double result是 C# 封送器帮你分配了一块 8 字节内存,并把地址传给 DLL 写入,这正好对上了 LabVIEW 侧输出参数为指针的设计。
C# 项目还有个容易忽略的环节:项目属性 -> 生成 -> 平台目标。DLL 是 64 位就选 x64,是 32 位就选 x86。如果你选了 AnyCPU,在 64 位系统上运行时进程是 64 位的,加载 32 位 DLL 会直接抛 BadImageFormatException,这种报错一看就是位数不匹配,别去翻代码了。
4. 参数边界设计:内存管理、字符串与数组
生成 DLL 只是第一步,真正决定这个接口能不能稳定用下去的,是参数的设计方式。LabVIEW 自己管理内存,C# 和 Python 也管理自己的内存,跨语言边界一旦涉及谁分配、谁释放的问题,最容易出事故。搞清楚下面的三个边界,基本能避开 90% 的内存崩溃。
4.1 字符串参数:让LabVIEW分配还是外部传缓冲区
字符串本质上也是一块内存。如果把 LabVIEW 内部生成的字符串句柄直接导出,外部语言根本不知道它的结构布局,也没办法安全释放。所以对外接口里最稳妥的做法是:字符串输入由外部传缓冲区进来,输出也由外部预分配缓冲区,LabVIEW 只负责写,不负责分配和释放。
比如导出一个GetVersion函数,输入参数是char* buffer和int32 length,Python 端这样调用:
func = lib["GetVersion@"] func.argtypes = [ctypes.c_char_p, ctypes.c_int32] func.restype = ctypes.c_int32 buf = ctypes.create_string_buffer(64) # 外部预分配 64 字节 err = func(buf, len(buf)) if err == 0: print(buf.value.decode("utf-8", errors="replace"))create_string_buffer的作用是在 Python 内存里开出一块连续空间,DLL 可以往里写字节;传入len(buf)是告诉 LabVIEW 这块空间的上限,避免它写入越界。返回后buf.value拿到的是字节串,再按约定的编码解码成文本。
中文乱码是字符串边界里最常见的问题。LabVIEW 的 C String 默认编码和操作系统的区域设置相关,跟 Python 的 UTF-8 经常对不上。我的习惯是:只要接口是给 Python 或 C# 用的,字符串一律按字节数组传,LabVIEW 侧用“字符串转字节数组”的 VI 处理,外部按 UTF-8 解码,基本不会再出现乱码。
4.2 数组参数:数据指针+长度是最稳的跨界方式
LabVIEW 内部数组用的是带句柄的结构,直接暴露句柄给外部调用方是灾难。我们不知道那个结构里存了什么,更不知道内存谁释放。所以共享库配置数组参数时,一定要选“数据指针 + 长度”的模式,即一个数组参数在 C 接口里变成两个参数:指向连续内存的指针和数组长度。
C# 里调用一个计算统计值的数组函数,签名可以这样写:
[DllImport(@"C:\work\SignalProc\Build\SignalProc.dll", EntryPoint = "CalcStatistics@", CallingConvention = CallingConvention.Cdecl)] private static extern int CalcStatistics( [In] double[] samples, int length, [Out] double[] meanAndStd); // 调用示例 double[] samples = { 1.0, 2.0, 3.0 }; double[] result = new double[2]; // 外部预分配输出缓冲区 int err = CalcStatistics(samples, samples.Length, result);[In]和[Out]这两个特性不要省。[In]让 C# 封送器把托管数组内容复制给 DLL 使用,[Out]告诉封送器调用结束后把 DLL 写入的数据复制回托管数组并让代码看到修改。数组长度必须显式传进去,DLL 端才能知道处理多少个元素,否则它拿到的只是一个裸指针,越界读 100 个字节是家常便饭。
有人喜欢让 LabVIEW 输出一个全新的大数组,觉得省事。但这要求在外部语言里处理一个不知内存归属的句柄,最终大概率演变成内存泄漏或堆损坏。提前分配好输出缓冲,把内存所有权留在调用方,才是长期好维护的方案。
4.3 重入还是不重入:多线程调用DLL时结果相互污染
DLL 被多线程调用时还有一个隐蔽问题:LabVIEW 的 VI 默认不重入,也就是说同一个 VI 在 DLL 里是单实例的,内部的数据空间会被并发调用共享。如果 VI 内部用了未初始化的移位寄存器或功能全局变量保存状态,两个线程同时调同一个函数,数据会互相覆盖。
解决办法有两个层面。第一,在 VI 属性 -> 执行 里勾选“重入执行”,让每次调用都有独立数据空间。第二,更推荐的做法是让导出 VI 保持无状态,所有中间数据都通过参数流动,不依赖任何内部存储。无状态函数天然抗并发,也更容易在外部做单元测试。
提示:重入解决的是数据空间复用问题,不能解决你自己的多线程资源竞争。如果 VI 内部访问了硬件或文件,即使 VI 重入,硬件通道和文件句柄依然是共享资源,仍然需要调用方串行控制。
5. 避坑记录:LabVIEW 生成 DLL 后最容易踩的 6 个坑
接口能跑通只是起点,生产环境里踩过的坑,每个都值得写下来。下面按“现象 -> 原因 -> 解决”的方式记录六条,基本都是我或是同行在交付时真正吃过亏的地方。
5.1 函数入口名带@符号,调用方找不到导出函数
现象:Python 加载 DLL 时报找不到函数,C# 抛 EntryPointNotFoundException,可 LabVIEW 里明明有这个名字的 VI。
原因:LabVIEW 生成的导出符号不是 VI 名,而是VI名@实例后缀,比如AddValues@。按普通函数名找,自然找不到。
解决:不要靠猜,直接用 dumpbin 查看导出表,把完整符号名复制进代码。Python 用lib["AddValues@"],C# 在EntryPoint里写全名。还要留意一点:把 VI 挪到项目不同路径,@ 后面的后缀可能变化,重新生成后要重新核对。
5.2 32位DLL被64位进程调用,加载即崩溃
现象:C# 抛 BadImageFormatException,Python 加载时报“%1 不是有效的 Win32 应用程序”,或者进程直接崩。
原因:位数不匹配。32 位 DLL 只能进 32 位进程,64 位 DLL 只能进 64 位进程,这是 PE 格式的硬规则。
解决:先确定你的 LabVIEW 是 32 位还是 64 位,再确认调用方进程位数。C# 项目平台目标要和 DLL 一致,Python 则要看解释器本身。最容易犯的错是在 64 位操作系统上默认装有 32 位 Python,代码看着没问题,实际上解释器是 32 位的。
5.3 字符串乱码与内存泄漏
现象:英文正常,中文乱码;长时间多次调用后进程内存持续上涨,最后崩溃。
原因:编码不一致,或者内存所有权混乱。LabVIEW 分配的字符内存被外部释放,堆直接被破坏;又或者外部缓冲区开小了,LabVIEW 写入越界。
解决:这类问题与其修,不如从设计上规避。字符串入参用字节数组或 C String 指针,出参用外部预分配缓冲区,长度必须作为参数传进去。编码统一用 UTF-8,不再依赖系统区域设置。内存谁分配谁释放,LabVIEW 内部创建的内存绝不交给外部释放。
5.4 换一台电脑调用,提示找不到DLL模块
现象:开发机一切正常,拷贝到客户机器后,DLL 加载失败,报“找不到指定的模块”或“无法加载 DLL”。
原因:DLL 本身在,但它的依赖缺失。LabVIEW 生成的 DLL 依赖对应位数和版本的 LabVIEW Runtime,如果你在 VI 里还调用其他第三方 DLL,那个 DLL 也得一并分发。
解决:用 dumpbin 查看依赖列表,把需要的运行库一起部署到目标机器。
# 查看 DLL 的依赖列表 dumpbin /dependents SignalProc.dllLabVIEW Runtime 可以在客户机器上免费安装,但要保证版本和位数与生成 DLL 时一致。另一个常见坑是目标机器缺 VC++ 运行库,这类依赖在 dumpbin 输出里也能看到,按需补装即可。
5.5 错误码返回非0,但结果没有写入外部变量
现象:函数正常调用,返回的错误码不是 0,但外部传入的结果变量没有被赋值。
原因:参数方向配置错误。VI 里明明把result连到了输出接线端,但在共享库配置里可能被设成了输入;或者外部调用侧没有提供有效的内存地址,LabVIEW 往空指针写数据,自然失败。
解决:回到共享库源文件配置页,逐个参数确认“输入/输出”方向,把结果参数设置为输出。C# 侧输出参数改用out double,Python 侧确保用byref传了对象。还有一个小技巧:在 LabVIEW 里给错误簇连线一个错误提示 VI,生成 DLL 后出错时能拿到更详细的信息,而不是只看到一个错误码。
5.6 更新DLL后老程序崩溃
现象:DLL 重新生成后,调用方没有重新编译,程序运行到旧接口调用时,内存越界崩溃。
原因:接口变了,参数数量、类型或顺序变化,但调用方仍按旧签名封送数据,栈和内存全乱。
解决:把 DLL 对外接口当成契约来维护。不要删除旧函数,新增逻辑就用新函数名;文件名带版本号,比如SignalProc_v1.dll,调用方锁定版本。重新生成后要做回归测试,确认导出表没有非预期变化,再替换部署。
6. 进阶技巧:每次生成 DLL 后花 5 分钟做接口回归检查
生成 DLL 不是终点,真正让人放心的是:每次改完代码重新构建,都知道接口没有无声无息地变化。我现在的固定习惯是维护一个接口契约表,内容包括函数名、完整导出符号、返回类型、参数列表、调用约定和位数。每次构建后,用 dumpbin 把当前导出表导出来,和上一次对比,出现差异就意味着接口有变,必须确认是有意改动还是误操作。
# smoke_test.py:构建后快速确认 DLL 能被加载、入口都存在 import sys import ctypes funcs = ["AddValues@", "CalcStatistics@", "GetVersion@"] lib = ctypes.CDLL(sys.argv[1]) for name in funcs: getattr(lib, name) # 能取到入口就算基础通过 print("OK")这个冒烟测试不验证业务正确性,只验证两件事:DLL 能加载,导出入口都在。业务逻辑正确性交给接口测试用例。把脚本放在项目目录里,每次构建后跑一次,能省掉大量“到了现场才发现入口名变了”的血泪时间。
版本命名也有讲究。文件名带 v1、v2 后缀,调用方锁定具体版本;如果确实需要扩展接口,只新增函数,不在旧函数上改参数。这样即使导出表对比时有差异,也能一眼看出是新增还是破坏性变更。我以前图省事,改完接口直接覆盖旧 DLL,结果部署到现场的版本和开发版本不一致,排查了大半天才定位到是函数签名对不上。后来养成这套最小回归习惯,再没翻过车。
另外,头文件是接口契约里最好的纸上文档。生成 DLL 后顺手把 .h 文件归档一份,连同导出表一起保存,后续接手的人不用打开 LabVIEW 就能知道这个 DLL 暴露了什么。希望这些 LabVIEW 生成 dll 的范例和踩坑记录能帮你少走一趟弯路。
本文还有配套的精品资源,点击获取