“生产者-消费者”架构的LabVIEW连续数据采集程序设计 [原创www.cnblogs.com/helesheng]

LabVIEW 是 NI 公司数据采集设备的标准开发平台,配合 NI-DAQmx 驱动和接口函数,可以开发出高效的数据采集程序。在各类数据采集任务中,连续数据采集与存储的难度最高——程序需要在操作系统负载随时波动的条件下,保证流式数据不丢包、完整写入硬盘。

为了防止系统繁忙时采样数据从缓冲区溢出丢失,NI 官方推荐使用基于 LabVIEW 队列实现的"生产者-消费者"架构来开发这类程序(https://www.ni.com/zh-cn/support/documentation/supplemental/21/producer-consumer-architecture-in-labview0.html)。但架构思路好理解,真正落到代码层面——如何用 NI-DAQmx 接口函数配合队列实现高速连续数据的可靠存储——可参考的完整实例却不多。

最近我在研究不同物联网业务场景下,NB-IoT 模块配合 MQTT 协议的低功耗优化策略,需要长时间记录物联网节点的功耗变化。做过 NB-IoT 的朋友都有体会:这类模块"激活-休眠"整个周期的电流电流变化的动态范围极宽,且变化速度快。只有 PC 端的高动态范围、高速数据采集卡加硬盘记录数据才能胜任。花了一周时间学习了 NI-DAQmx 底层驱动的工作逻辑,也尝试了"生产者-消费者"架构在 LabVIEW 中的实现方法。整理成文章分享出来,希望对做同类开发的朋友有帮助。

(以下内容欢迎转载,请注明出处: https://www.cnblogs.com/helesheng

一、整体框架:围绕两个队列的连续数据采集系统

连续数据采集程序之所以难写,本质上难在速度不匹配:ADC 采样间隔是客观硬性指标,匀速且不可等待,与之矛盾的是:数据处理、磁盘写入、界面刷新这些操作的耗时是波动的。如果把二者塞进同一个循环,采样的节拍就有可能被后端的数据处理和磁盘写入这些环节打乱,从而导致采集卡缓冲区溢出、数据丢失。

NI 官方推荐的"生产者-消费者"架构的思路是用队列来解耦,把整个数据采集系统的功能拆成让多个线程来处理,每个线程只干一件事,线程之间靠队列传递数据

图1 “生产者-消费者”架构的连续数据采集程序功能框图
数据从采集卡到你的程序,中间要经过三层缓冲。最前面一层是采集卡上的硬件 FIFO,ADC 转换完的数据先存在这里,然后通过总线(PCI 或 USB 等)批量搬到 PC。这一层容量小、但完全由硬件控制,是对抗总线传输延迟的第一道防线。后面两层,也就是PC 内存中的两个队列才是本文的重点:**一个由驱动维护**,**一个由LabVIEW程序开发者维护**。PC内部在缓冲间连续传递数据的包括两个队列。

第一个队列是 DAQmx 驱动层的循环缓冲区。它由 DAQmx 驱动在内核态维护,对应的执行线程也由驱动自动创建,开发者感知不到它的存在。当你调用 DAQmx 定时函数(DAQmx Timing VI)做初始化时,驱动就确定了这个循环队列缓冲区的大小。任务启动后,驱动在分配循环缓冲区的后,启动后台传输通道(PCIe 设备走 DMA,USB 设备走 Bulk Transfer),持续不断地把采集卡传上来的采样数据批量写入循环队列。写入的节奏完全由硬件采样时钟决定,不受应用层调度影响。第二个队列是应用侧的队列,需要开发者用 队列 函数(Queue)手动创建。它的"生产者"是DAQmx 读取函数(DAQmx Read VI),该函数从第一个循环缓冲区队列里批量取出数据,放入第二个队列。第二个队列的"消费者"是开发者自己写的循环,负责从该队列里读取数据,随后进行数据分析、磁盘写入、波形显示等操作。

第一个队列(驱动层循环缓冲队列)的大小是固定的,由你在 DAQmx 定时 VI 中设置的"每通道采样数"决定,任务运行中不能动态扩容。而第二个队列(应用侧队列)是从进程堆中动态分配的,理论上大小只受系统可用内存限制。消费者处理慢了,数据就在队列里攒着,只要内存够大,就不会因为消费者执行程序延时的波动而丢数据。

使用队列的根本原因是为了解决"硬件节拍"和"软件波动"之间的根本矛盾。
第一个队列(驱动层循环缓冲队列)的存在,是为了对抗USB 和 PCIe 总线定时且成批的传输数据。循环缓冲区把这些批量到达的数据"摊平"成连续的数据流,让上层应用可以按自己的节奏去读,其所需的容量也较小。

第二个队列(应用侧队列)的存在,是为了对抗软件处理的波动性:消费者循环里的写文件、画图、算法处理,耗时是不稳定的。如果直接在 DAQmx 读取函数后面跟着做这些事,慢的时候读指针就会被拖住,导致第一个队列发生数据覆盖。这个队列给后端复杂的处理"松绑",DAQmx 读取函数只管以最快速度把数据搬出来放到应用侧队列中,再由后续线程慢慢处理。

另外,队列的增加也使数据从获取、传输到存储的步骤分成多个线程,而Windows会给为新增的线程分配多核CPU中新的内核。从另一个角度看,这种架构通过有效的利用现代CPU中空闲的大量内核资源,提升了连续采集程序运行的效率。

最后补充一句:如果你的应用不是连续采样,只是单次触发后采集有限点数据并存储,那就不需要第二个队列的"生产者-消费者"架构,DAQmx驱动完成触发后的一轮采样后,后端程序再从第一个队列里一次性读取全部这一轮数据即可。只有在连续采集、数据源源不断产生,且后端存储、处理和显示任务较复杂的场景下,第二级的"生产者-消费者"架构才有意义。

二、由DAQmx 定时函数(DAQmx Timing VI)配置的第一个队列

用 DAQmx 进行数据采集的一般步骤这里不再赘述,需要的读者可以参考本人其他博文。本文重点介绍连续采集模式下,DAQmx 定时函数与第一个队列(驱动层循环缓冲区)之间的关系(https://www.cnblogs.com/helesheng/p/9833691.htmlhttps://www.cnblogs.com/helesheng/p/15795427.html )。
DAQmx 定时函数的核心作用有两个:一是设置采样率,二是配置缓冲区。其中"每通道采样"这个输入参数,直接决定了驱动层循环缓冲区的大小,如下图所示。

图2 DAQmx 定时函数(DAQmx Timing VI)及其参数

要理解这个参数,得先搞清楚"采样模式"。DAQmx 定时函数的"采样模式"有三种:有限采样、连续采样、硬件定时单点。不同模式下,"每通道采样"这个参数的含义是不同的。在有限采样模式下,缓冲区只用一次——采集完指定数量的采样就停止,不存在"循环"的概念。此时"每通道采样"就是你要采集的总点数,采集到这个数就结束。在连续采样模式下,采集会一直持续,直到你主动停止任务。这时候缓冲区需要循环使用,写指针走到末尾后回到开头继续写。因此"每通道采样"不再是总采集量,而是循环缓冲区的容量——也就是第一个队列的大小
硬件定时单点模式不使用缓冲区,这里不展开。

在连续采样模式下,建议将"每通道采样数"设为采样率的 1~10 倍。也就是说,缓冲区至少能存放 1 秒的采样数据,多则 10 秒。这样即使应用层暂时来不及读取(比如系统突然繁忙、硬盘卡顿),数据在驱动层还有缓冲的余地,不至于发生数据覆盖。

这里有一个容易被误解的点:增大循环缓冲区的大小,只会降低数据覆盖的风险,不会增加数据延迟。 原因很简单——循环缓冲区是"有多少读多少"的,DAQmx 读取函数不会等缓冲区满了才读,而是有数据就可以读。缓冲区大,只是给"万一读慢了"留的余量,正常情况下数据进来多少就被读走多少,不会因为缓冲区大就多等一会儿。

最后,如果不连线"每通道采样数"这个输入端,或者将其设为 -1,DAQmx 不会报错,而是进入自动分配模式——驱动根据采样率自己计算一个默认的缓冲区大小。计算规则大致是:

  • 采样率 ≤ 1000 S/s:默认分配 1000 个采样
  • 采样率 > 1000 S/s:默认分配 10 秒的数据量(缓冲区大小 = 采样率 × 10)
  • 同时驱动会保证缓冲区不低于一个最小阈值(通常为 256 或 512 个采样,因设备而异)

三、在两个队列间搬运数据的DAQmx 读取函数(DAQmx Read VI)

DAQmx 读取函数负责从驱动层循环缓冲区(第一个队列)读取数据。在有限采样、采样率较低或后续处理/存储任务较简单的场景下,不需要第二个队列和"生产者-消费者"架构,该函数读取的数据可以直接用于显示和存储。但在连续采集且后端任务较重的场景下,它的角色就是两个队列之间的搬运工,把数据从第一个队列搬入第二个队列。

图3 DAQmx读取函数(DAQmx Read VI)及其参数

DAQmx 读取函数也有一个"每通道采样"输入参数,和 DAQmx 定时函数的参数同名,但含义完全不同。定时函数的"每通道采样数"决定的是循环缓冲区的容量上限;读取函数的"每通道采样数"决定的是每次从循环缓冲区中读取多少个采样

如果后续还使用了“生产者-消费者”架构,从DAQmx读取函数中读取数据的,应该是另一个线程。

在 Windows 环境下,线程调度的时间片在 10-30ms 量级,理论上读取频率可以很高。但每次调用 DAQmx 读取函数都有用户态/内核态切换的开销,读得太频繁反而浪费 CPU。实践中每秒读取 3-10 次就够了,对应的参数值大约是采样率的 1/10 到 1/3。如果不对这个输入端连线,或者将其设为 -1,DAQmx 采取"有多少读多少"的方式:只要生产者线程获得 CPU 时间、且循环缓冲区中有数据,函数就把当前可用的数据全部读出来。这种模式的好处是简单,不用估算;缺点是每次读取的数据量不固定,后端处理起来需要适配可变长度的数据块。

请注意:DAQmx定时函数的"每通道采样"参数只是缓冲区的上限,绝大多数情况下不影响数据延迟。然而DAQmx读取函数的"每通道采样数"是真的会影响延迟的。原因很简单:读取函数必须等到循环缓冲区中有指定数量的采样数据,才会一次性返回。参数设得越大,每次等的时间就越长,数据从采集到显示/存储的延迟也就越大。参数设得小 → 延迟低,显示更"丝滑",但循环更频繁,系统开销略大;参数设得大 → 单次处理效率高,写文件、做 数据处理都更方便,但延迟也更大。这是一个典型的"延迟 vs 效率"的权衡,需要根据实际应用场景来定。

四、用生产者-消费者架构协调数据采集和显示/存储/处理任务

如第一部分所述,为了解决显示/存储/处理等软件操作的波动性与数据采集的实时性之间的矛盾,需要为二者各自构建独立线程,并用手动创建的应用侧队列来实现数据读取线程和显示、存储线程之间的数据通信与同步。

在 LabVIEW 里,两个互不相连的 While 循环就会形成两个独立的执行线程,各自按自己的节奏运行。如果用"获取队列引用"函数创建一个队列,再把队列引用分别连进两个循环,这两个循环就从"各自为政"变成了"协同工作"的生产者和消费者。生产者循环用"元素入队列"函数把数据放进去,消费者循环用"元素出队列"函数取出数据。

队列函数天然带同步机制:如果队列为空,消费者循环的"出队列"函数会自动等待,不占用 CPU;如果队列满了(设置了最大长度时),"入队列"函数也会等待。这种"等待-唤醒"机制在队列函数内部已经处理好了,开发者不需要自己写信号量或锁。

值得注意的是,用 LabVIEW 队列函数手动构建的队列是基于链表和动态内存申请的非循环队列,由 LabVIEW 运行时在运行过程中根据实际需要,从进程堆中动态申请和释放内存。因此它的大小只受 PC 内存限制,可以从容应对显示/存储/处理线程因复杂操作而产生的时间波动。

使用队列函数构建和使用应用侧队列的程序框图如下图所示。

图4 连续数据采集和存储程序框图

图中标注 1 的红色圆圈是"获取队列引用"函数,负责在程序开始运行时创建队列数据结构;标注 2 的红色圆圈是"元素入队列"函数,负责在生产者线程中将 DAQmx 读取函数从循环缓冲区中获取的数据放入队列;标注 3 的红色圆圈是"元素出队列"函数,负责在消费者线程中将队列中的数据读取出来,用于显示和存储;标注 4 的红色圆圈是"释放队列引用"函数,负责在程序结束时删除队列数据结构。

程序框图中从上到下三个 While 循环,分别对应三个线程:最上面的是 UI 界面线程(事件结构),中间的是 DAQmx 数据采集线程(生产者),最下面的是数据处理/存储/显示线程(消费者)。

从图中可以看到,本例使用 1 MSPS 的采样率对单个通道 DEV2/ai0 进行采样。DAQmx 定时函数的"每通道采样数"参数被设置为 2M,也就是说驱动层循环缓冲区的容量为 2M 个采样,最多可以缓存 2 秒的实时数据。DAQmx 读取函数的"每通道采样数"参数被设置为 400K,即每次读取 400K 个采样并放入应用侧队列,后续数据显示和存储的单笔数据量也就是 0.4 秒的采样数据。

五、连续数据采集和存储程序的实现和测试

我编写的连续数据采集 VI 结构并不复杂,程序框图如图 4 所示。前面板上有"开始采样""停止采样""退出"三个按钮控件,以及一个用于显示实时波形的波形图表控件。按钮控件由事件结构管理,放在最上面的 While 循环中。DAQmx 连续数据采集的代码作为一个独立线程,放在中间的 While 循环中。数据存储、显示和分析的线程则放在最下面的 While 循环中。

不同线程间的通信和同步依赖两个机制:一是前面介绍的应用侧队列(传递采样数据),二是前面板上的"运行中…"和"采样中…"两个 LED 显示控件及其对应的局部变量(传递控制状态)。

VI 启动后,三个 While 循环外的初始化代码负责创建应用侧队列,并打开用于数据存储的 CSV 文件(data.csv)。随后三个循环各自进入等待状态:

  • 第一个循环(UI 线程):事件结构持续检测"开始采样""停止采样""退出"三个按钮是否被按下
  • 第二个循环(采集线程):由于"采样中…"LED 尚未置位,处于空转状态。空转循环中加入了 10ms 的延迟,防止空转线程占用过多 CPU
  • 第三个循环(处理线程):调用"元素出队列"函数,处于等待数据的状态,不占用 CPU

当用户单击"开始采样"按钮,事件结构的对应分支会将"采样中…"LED 的局部变量置位,点亮前面板上的指示灯。与此同时,中间 While 循环中的条件结构被使能,DAQmx 连续采集代码开始运行。采集线程的运行逻辑如前几节所述:DAQmx 读取函数每读到 400K 个采样,就将数据打包放入应用侧队列,然后等待下一笔数据。只有当"停止采样"按钮被单击后,"采样中…"LED 被清零,连续采集循环才会退出。最下方的处理线程则在应用侧队列收到数据后被唤醒,将数据写入 CSV 文件并更新波形显示。连续采集停止后,用户可以随时再次单击"开始采样"按钮重新启动采集。

单击"退出"按钮后,事件结构的对应分支会释放应用侧队列引用,将"运行中…"LED 清零,并通过其局部变量通知采集线程结束运行。处理线程在队列引用被释放后也会自动退出。最后关闭数据文件,程序结束。

注:这里我使用了局部变量来进行线程间按键消息的通信,也可以尝试使用官方推荐的通知器(Noticer)来实现线程间的消息通信。

图5 连续数据采集和存储程序前面板

为了测试程序的性能上限,我使用了手头采样率最高的 USB-6363 采集卡,将其配置为 1 MSPS 的单通道采样率。数据存储采用 CSV 文本格式——这种格式写入速度较慢,但好处是数据文件可以直接用任意文本编辑器或 Excel 打开,方便后续分析。

在 1 MSPS 采样率下进行实时采集、显示和存储,CPU 占用率和硬盘读写时间均约为 5%。

图6 1MSPS连续采样模式下进行数据实时存储和显示所耗费的CPU和硬盘读写时间

这个结果说明,对于 1 MSPS 单通道的连续采集场景,"生产者-消费者"架构LabVIEW程序有充足的性能余量。即使后续增加更复杂的数据处理,或者提高采样率、增加通道数,系统仍然有足够的性能冗余来应对。理论上,说限制采样率和数据处理复杂度的条件,只有读取采集卡的线程和数据处理的线程所需的处理能力,不能超过运行这些线程的CPU内核的处理能力。

posted @ 2026-08-11 17:45  helesheng  阅读(3)  评论(0)    收藏  举报