Qt 实时大数据量存储:自己动手实现 TDMS 读写

做 Qt/C++ 项目,涉及采集卡、振动分析或者多通道数据采集时,存储一直是一个绕不开的问题。

数据量小的时候,TXT、CSV 都能用。但如果数据持续产生,采样率高、通道多,还要求连续保存几个小时甚至更久,就不能只考虑"能不能写入文件"了。

写入速度、内存占用、数据完整性,以及后续读取和分析的效率,都需要一起考虑。

我之前做采集存储时,就需要考虑实时接收的数据如何持续落盘,以及后续如何按通道读取和分析。对于这类场景,TDMS 是一个值得研究的格式。

这篇不打算只介绍 TDMS 是什么,而是从 Qt/C++ 的实际开发出发,看看它为什么适合这类场景,以及能不能自己动手实现读写。

一、为什么考虑 TDMS?

TDMS 是 NI 定义的一种技术数据管理文件格式,常用于测试、测量和数据采集场景。

它和普通文本文件最大的区别之一,就是采用二进制方式存储数据,同时支持文件、组、通道三级结构,以及对应的属性信息。

例如,一个振动采集项目可以这样组织:

bash 复制代码
Measurement.tdms
├── File Properties
├── Raw Data
│   ├── Channel 1
│   ├── Channel 2
│   ├── Channel 3
│   └── Channel 4
└── Processed Data
    ├── RMS
    └── Spectrum

实际的文件、组、通道名称及属性可以根据业务需要设计。

这种组织方式比较适合多通道采集:每个通道可以保存自己的数据,文件和通道也可以附带采样率、单位、传感器编号等描述信息。

相比直接把数据写入 TXT,TDMS 不需要把每个浮点数转换成文本字符串,也不需要在读取时再逐个解析文本数字。

对于大批量数值数据,二进制存储通常更节省空间,也更适合高效读写。

当然,TDMS 并不意味着一定比所有其他格式快。实际性能还与磁盘、缓存策略、数据组织和写入方式有关。

二、实时存储,不能只盯着写文件的速度

假设采集设备不断通过 TCP 接收数据,收到一包就直接写一次磁盘,会有什么问题?

如果数据包很小,写入频率又高,就可能产生大量文件操作。即使平均写入速度足够,瞬间的磁盘延迟也可能让接收线程阻塞,最终造成数据积压。

所以,我更倾向于把接收、缓存和存储分开。

cpp 复制代码
采集设备
   ↓
TCP 接收线程
   ↓
数据缓存队列
   ↓
存储工作线程
   ↓
批量写入 TDMS

接收线程的任务是尽快接收和整理数据,不要因为磁盘写入而长时间阻塞。

存储线程从队列中取出数据,积累到一定数量后再写入文件。

例如,可以按照数据量或时间间隔触发写入:

  • 缓冲区达到指定大小,执行一次写入。
  • 到达定时刷新周期,即使数据量较少也尝试写入。
  • 停止采集时,把剩余数据写完并关闭文件。

这里有个容易忽略的问题:队列不能无限增长。

如果磁盘持续写不过来,队列就会不断积压,最后耗尽内存。因此还需要统计队列长度、写入耗时和丢弃数据量,并根据业务要求设计背压、告警或故障处理机制。

另外,批量写入不等于每次都调用 flush()。频繁强制刷新可能影响性能;操作系统缓存、文件刷新和真正落到稳定存储介质也不是一回事。

三、TDMS 文件到底是怎么组织的?

想自己实现读写,首先要理解文件结构。

一个 TDMS 文件由一个或多个数据段组成,每个数据段主要包括三部分:

cpp 复制代码
┌──────────────────────────┐
│ Lead In:数据段头部       │
├──────────────────────────┤
│ Metadata:通道与属性信息  │
├──────────────────────────┤
│ Raw Data:原始数值数据    │
└──────────────────────────┘

头部包含数据段标识、目录标志、版本号,以及元数据和原始数据的偏移信息。

元数据描述通道路径、数据类型、每个通道的数据数量和属性。

原始数据则存放真正采集到的数值。

TDMS 使用小端字节序时,double 类型的一个数据占 8 字节,float 类型占 4 字节。数据类型、元数据和实际数据必须对应起来,否则即使文件写入成功,其他程序也可能无法正确读取。

四、先动手:用 Qt 写出 TDMS 的核心二进制结构

自己实现 TDMS,不需要从头发明一套文件格式,但必须严格遵守现有格式规范。

下面以一个最简单的场景为例:

  • 一个 TDMS 文件。
  • 一个组:Raw Data。
  • 一个通道:Channel 1。
  • 通道数据类型为 double。
  • 只写入一个数据段,不包含自定义属性。

先看字符串的写入方式。TDMS 的字符串通常采用 UTF-8 编码,前面带一个 32 位无符号整数,表示字符串的字节长度。

cpp 复制代码
static void writeTdmsString(QDataStream &out,
                            const QString &value)
{
    const QByteArray bytes = value.toUtf8();

    out << quint32(bytes.size());

    if (!bytes.isEmpty()) {
        out.writeRawData(bytes.constData(),
                         bytes.size());
    }
}

这里使用 writeRawData() 写入实际字符串内容,而不是再通过 QDataStream 写一个 QByteArray。两者的序列化方式不同,不能随意替换。

接下来,写入一个 TDMS 对象的元数据。

cpp 复制代码
static void writeTdmsObject(QDataStream &out,
                            const QString &path,
                            bool hasRawData,
                            quint64 valueCount)
{
    writeTdmsString(out, path);

    if (!hasRawData) {
        out << quint32(0xFFFFFFFF);
    } else {
        // 原始数据索引长度:20 字节
        out << quint32(20);

        // TDMS 数据类型:Double
        out << quint32(10);

        // 数组维度:1
        out << quint32(1);

        // 当前数据段中的数值数量
        out << quint64(valueCount);
    }

    // 当前示例不写入对象属性
    out << quint32(0);
}

这里的 0xFFFFFFFF 表示该对象没有对应的原始数据索引。

对于通道对象,索引中需要写入数据类型、数组维度和数据数量。这里使用的是 TDMS 的 Double 数据类型标识。

接下来,构造一个包含三个对象的元数据块:

cpp 复制代码
static QByteArray makeMetadata(quint64 valueCount)
{
    QByteArray metadata;
    QDataStream out(&metadata, QIODevice::WriteOnly);

    out.setByteOrder(QDataStream::LittleEndian);
    out.setVersion(QDataStream::Qt_6_0);

    out << quint32(3);

    writeTdmsObject(out, "/", false, 0);

    writeTdmsObject(out,
                    "/'Raw Data'",
                    false, 0);

    writeTdmsObject(out,
                    "/'Raw Data'/'Channel 1'",
                    true,
                    valueCount);

    return metadata;
}

这段代码把文件对象、组对象和通道对象依次写入元数据。

需要注意,QDataStream 的版本设置并不是 TDMS 的文件版本;这里真正决定 TDMS 文件版本的是后面写入数据段头部的版本字段。设置 QDataStream 版本是为了明确 Qt 自身的序列化行为。

接下来写入数据段头部和原始数据:

cpp 复制代码
static bool writeTdmsSegment(const QString &filePath,
                             const QVector<double> &samples)
{
    QFile file(filePath);

    if (!file.open(QIODevice::WriteOnly)) {
        returnfalse;
    }

    const QByteArray metadata =
        makeMetadata(quint64(samples.size()));

    QByteArray rawData;
    QDataStream rawOut(&rawData, QIODevice::WriteOnly);

    rawOut.setByteOrder(QDataStream::LittleEndian);
    rawOut.setFloatingPointPrecision(
        QDataStream::DoublePrecision);

    for (double value : samples) {
        rawOut << value;
    }

    QDataStream out(&file);
    out.setByteOrder(QDataStream::LittleEndian);

    // TDMS 数据段标识
    out.writeRawData("TDSm", 4);

    // Metadata + New Object List + Raw Data
    out << quint32(0x0E);

    // TDMS 2.0 对应的文件格式版本
    out << quint32(4713);

    // 下一数据段偏移:元数据长度 + 原始数据长度
    out << quint64(metadata.size() + rawData.size());

    // 原始数据相对数据段起点的偏移
    out << quint64(metadata.size());

    if (!metadata.isEmpty()) {
        out.writeRawData(metadata.constData(),
                         metadata.size());
    }

    if (!rawData.isEmpty()) {
        out.writeRawData(rawData.constData(),
                         rawData.size());
    }

    return out.status() == QDataStream::Ok;
}

上面这段代码展示了一个单通道、单数据段 TDMS 文件的核心写入过程。

调用时,可以传入一批采样数据:

cpp 复制代码
QVector<double> samples;

samples.reserve(10000);

for (int i = 0; i < 10000; ++i) {
    samples.append(i * 0.01);
}

writeTdmsSegment("sample.tdms", samples);

这是一份用于讲解基础二进制结构的最小示例,不是经过完整互操作性验证的生产级 TDMS 库。 正式用于项目之前,还应检查所有写入操作的返回值和文件大小,使用 NI 软件或其他兼容读取器验证生成文件,并补充属性、追加写入、异常恢复等功能。

五、真正的实时存储,还需要解决什么?

前面的示例一次性把一批数据写入一个数据段,适合理解文件结构,但还不是完整的实时存储实现。

实际采集持续运行时,通常需要把数据拆成多个批次写入。

例如,每次缓存 1024 个样本,写入一个数据段。后续的数据段可以在通道列表和元数据没有变化时,利用 TDMS 的元数据复用机制减少重复信息。

但这里不能简单地每次都重新写一个头部,再把浮点数直接追加到文件末尾。每个数据段的偏移、元数据和通道数据数量必须一致;如果通道布局发生变化,还要正确处理对应的元数据更新。

此外,真正的生产级实现还需要考虑:

  • 多通道数据的排列方式:交错存储还是连续存储。
  • 通道名称、采样率、单位等属性的维护。
  • 文件关闭时的数据收尾,以及异常中断后的恢复。
  • 64 位偏移量和大文件处理。
  • 写入队列积压、磁盘空间不足以及写入失败。
  • 文件索引和读取接口,避免后续分析只能从头扫描。

如果需要兼容 LabVIEW、DIAdem 等现有工具,还要遵循完整的 TDMS 规范,而不只是让自己的写入代码和读取代码互相匹配。

六、读文件也可以自己实现

读取 TDMS 时,整体过程与写入相反。

首先读取每个数据段的 28 字节头部,解析数据段长度和原始数据偏移。

然后读取元数据,建立通道路径、数据类型、每个数据块中包含的数值数量等信息。

最后,根据数据段的布局和通道索引定位原始数据,再按正确的数据类型与字节序读取。

例如,已经确定某一段数据是小端 double 数组,而且对应的原始数据位置与长度均已知,就可以使用 QFile::seek() 定位,再按块读取。

对于大文件,不建议一次性把整个文件加载到内存中。更合理的方式是记录各通道数据的位置,按需读取指定通道或指定区间的数据。

还可以进一步把读取模块独立出来,与图表显示和数据分析模块分开。这样,后续无论是读取 TDMS,还是接入其他存储格式,都不必把整个分析流程重新实现一遍。

七、可视化工具

数据可以通过自己写的程序再把数据展示,NI也提供了对应的可视化工具(Scout),这里可以看到数据曲线以及所写的属性值

自带曲线绘制,这里他会自动按点的y值绘制

属性界面如下图,属性名可代码中自定义。

最后

Qt 实时大数据量存储,真正的难点不只是调用 QFile::write(),也不只是选择 TDMS 还是 TXT。

接收线程能否及时处理数据、缓存是否合理、写入是否稳定、文件格式是否正确,以及后续能否高效读取,这些问题需要一起考虑。

自己动手实现 TDMS 的好处,是能真正理解二进制文件格式、数据段、元数据和原始数据之间的关系。但如果项目要求完整兼容现有 TDMS 工具,或者对长期稳定性有严格要求,就应该用官方规范和成熟工具进行验证,而不是仅凭一段示例代码判断实现已经完整。

相关推荐
Leon-Ning Liu1 小时前
Oracle参数对比脚本分享:对比 SPFILE 参数与内存参数差异,支持 11g 到 19c
数据库·oracle
NiceBlueChai1 小时前
Qt 跨线程传递多容器嵌套结构:一次 Release 实测
开发语言·qt
扶尔魔ocy1 小时前
【QT android】模仿行车记录仪案例
android·qt
涛思数据(TDengine)1 小时前
TDengine TSDB 3.4.3.3 上线:流计算分层闭环、虚拟表跨层引用,查询关联一次写完
数据库·人工智能·数据分析·时序数据库·tdengine·工业
远方16091 小时前
118-五大进程LGWR DBWn CKPT SMON PMON 与存储故障-文件系统对照
大数据·数据库·sql·oracle·database
ss2731 小时前
AI时代首先数据库-PostgreSQL实战笔记 | 17 PostgreSQL SQL高级查询:窗口函数、CASE、COALESCE
数据库·笔记·postgresql
远方16091 小时前
119-Oracle CDB 下修改和重置 SYS SYSTEM密码记录
大数据·数据库·sql·oracle·database
橙光成长1 小时前
Context Mode 深度解析:用沙盒、SQLite 与钩子路由,把 AI 编码 Agent 的上下文消耗削减 98%
数据库·人工智能·sqlite
数据库小学妹2 小时前
MySQL时区问题排查:TIMESTAMP和DATETIME的区别与跨时区对账踩坑
数据库·mysql·datetime·时区·timestamp·跨时区对账