做 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 工具,或者对长期稳定性有严格要求,就应该用官方规范和成熟工具进行验证,而不是仅凭一段示例代码判断实现已经完整。