☰
C++读写TDMS实战:DDC接口、性能避坑与选型
2026/10/28 4:13:42 网站建设 项目流程

简介:面向科学数据采集与自动化测试场景的 C++ TDMS 读写示例文档,适合已具备 C++ 基础、需要在工程中处理 NI 技术数据管理格式的开发者参考。文档围绕 TDMS 官方库提供的 API 展开,逐项说明写文件时创建文件、添加通道组与通道、设置通道数据值、保存文件的调用顺序,以及读文件时打开文件、统计通道组与通道数量、获取数据值的配合方式,并附有 WriteDDCFile 与 ReadDDCFile 两段可对照的完整实现。压缩包内共 1 个 doc 文件,约 20KB,篇幅精炼,便于随查随用,已有 996 人学习下载。作者还结合实测记录了 TDM 与 TDMS 在文件体积、写入耗时和读取耗时上的差异,给出万级通道数下的性能表现,并提醒写入时需预留足够的中间文件空间,这类踩坑经验能帮助读者少走弯路。

1. 一个导出按钮卡 3 秒,把 C++ 读写 TDMS 的账算清楚了

某实验室的上位机里有个“导出数据”按钮,128 个通道、每通道 102400 个 double,点下去界面卡一下,日志里有 800 毫秒的,也有 3 秒多的。同一份数据换个格式再导一次,时间又变了。问题最后落到 TDMS 上——TDMS(Technical Data Management Streaming)是面向测试测量场景的二进制流式数据格式,一个文件装多个通道组,一个组装多个通道,通道带名字、单位、描述,天然适合“一轮采集存一个文件”的组织方式。这份资源给的是 C++ 调用 DDC 接口读写 TDM/TDMS 的完整 Demo,写文件和读文件两段主流程可以直接搬进工程,还附带作者实测的选型结论和磁盘占用经验。做采集上位机、台架记录、要把原始波形交给下游分析的人,都能用上。

2. 工程结构:DDC 接口层怎么接进 C++ 项目,句柄树怎么生长

拿到这个 Demo 的第一件事不是看业务代码,而是看清楚它依赖什么。整个项目对 TDMS 的操作都压在DDC_这一套前缀的接口上,没有生成额外的工程,也没有中间层,就是一个对话框类里放了两个函数:一个WriteDDCFile,一个ReadDDCFile。这既是它好上手的地方——复制粘贴就能跑;也是它不好直接上生产的地方——所有句柄都靠裸指针手工管理,加第二个调用点就容易出问题。

2.1 头文件、导入库与字符集这三件事

DDC 这类第三方二进制接口,交付形态通常是三件套:一个声明导出函数的头文件、一个导入库(.lib)、一个运行时动态库(.dll)或静态库。项目里#include之后能看到DDC_CreateFile、DDC_AddChannelGroup、DDC_SetDataValues这些符号,说明头文件已经在包含路径里了。常见做法是把它收口到自己的一个头文件里,用#pragma comment(lib, ...)直接挂库,省得每台机器都要去改项目属性:

// tdms_bridge.h —— 把第三方 DDC 库收口到一个头文件里 #pragma once #include <string> #include <vector> #include <cstdint> #include "DDC.h" // 第三方库导出的 DDC_* 声明 #pragma comment(lib, "DDC.lib") // MSVC 下写在编译单元里,省去改附加依赖项 // 句柄树:File -> ChannelGroup -> Channel struct TdmsFile { DDCFileHandle file = nullptr; DDCChannelGroupHandle group = nullptr; std::vector<DDCChannelHandle> channels; ~TdmsFile() { if (file) DDC_CloseFile(file); } // 关掉 File,下层句柄一起释放 };

这里有两个参数层面的细节值得先记住。一是DDCFileHandle、DDCChannelGroupHandle、DDCChannelHandle都是不透明句柄,它们本身不需要单独销毁,DDC_CloseFile会释放整棵树;但你自己new出来的句柄数组必须自己delete[],Demo 里正是这两套东西混在一起,后面读文件那段就翻车了。二是字符集,Demo 用的是const char*路径和itoa,走的是多字节;如果你的项目开了Unicode,路径要转成char或者换宽字符接口,否则中文目录直接打不开。

2.2 句柄层级:File → ChannelGroup → Channel → DataValues

把 DDC 的对象模型画出来其实很简单,就四层,写的时候从上往下建,读的时候从上往下遍历:

层级句柄类型写文件时的动作读文件时的动作
文件DDCFileHandleDDC_CreateFile创建DDC_OpenFile打开
通道组DDCChannelGroupHandleDDC_AddChannelGroup新增DDC_GetChannelGroupByIndex取
通道DDCChannelHandleDDC_AddChannel新增DDC_GetChannelByIndex取
数据double*+ 长度DDC_SetDataValues写入DDC_GetDataValuesDouble读出

这个层级决定了文件内部的物理布局:TDMS 里每个通道组是一片独立的数据块,每个通道在块内有自己的索引项,通道名、单位、描述这些属性跟着索引走。所以“通道数”这个参数不是随便填的,它的量级直接影响索引区的规模,第 5 章里那条“通道数上万之后写入时间陡增”的实测记录,根子就在这张表里。

2.3 一个最小可编译骨架

动手之前建议先用下面这个骨架确认库链接成功,能创建再关掉一个空文件,就说明环境通了,再去填数据:

// 最小验证:能不能建出一个空的 TDM 文件 bool SmokeTest(const char* path) { DDCFileHandle file = nullptr; // 参数:路径、格式("TDM"/"TDMS")、后面几个是 name/description/title/author 一类元信息 // Demo 里全传空串,具体顺序以 DDC.h 的声明为准 if (DDC_CreateFile(path, "TDM", "", "", "", "", &file) != DDC_NoError) return false; if (!file) return false; DDC_SaveFile(file); // 不 Save 的话文件是空的 DDC_CloseFile(file); // 必须关闭,否则句柄泄漏、文件被占用 return true; }

DDC_CreateFile的后四个空串是元信息字段,Demo 的作者没填,因为采集场景里这些字段通常由下游工具去补,或者干脆不关心。注意DDC_SaveFile和DDC_CloseFile是两个不同动作:前者把内存中的数据刷到磁盘并生成最终结构,后者释放句柄和资源。顺序不能反,先 Close 再 Save 就是空文件。

提示:如果链接阶段报LNK2019 无法解析的外部符号,先查三件事——位数(32 位程序不能链 64 位 .lib)、调用约定是否匹配、Debug 版是否链了 Release 库。这三条是这类第三方二进制的固定坑位。

3. 写链路拆解:从 DDC_CreateFile 到 DDC_SaveFile 的五步

写文件这段逻辑,Demo 在一个函数里从头到尾走完了:建文件、加组、循环加通道并灌数据、保存、关闭。五步对应五个接口,中间没有分支判断,也没有错误码检查。这种写法用来验证库没问题,但放进业务代码之前必须补两样东西——错误处理和资源回收。

3.1 建文件和通道组:前两个参数决定一切

int CTDMSTestDlg::WriteDDCFile(const char* filePath, int channelNum, int dataLen, const char* fileType /* = "TDM" */) { DWORD begin = ::GetTickCount(); DDCFileHandle testFile = nullptr; // 第一个参数是完整路径,第二个是格式,传 "TDM" 或 "TDMS" DDC_CreateFile(filePath, fileType, "", "", "", "", &testFile); DDCChannelGroupHandle group01 = nullptr; // 组名在文件内要唯一,重复添加会得到一个空的同名组或者覆盖 DDC_AddChannelGroup(testFile, "group01", "", &group01); double* channelData = CreateRandomData(dataLen); // 造 dataLen 个 double // ... 通道和数据的部分见 3.2 }

filePath是带目录的完整路径,目录必须已经存在,库不会帮你递归建目录——这点容易忽略,写死一个D:\data\2024\xxx.tdm而2024文件夹还没建,DDC_CreateFile就是失败,但因为 Demo 没判返回值,后面一路空指针操作下去。fileType传"TDM"还是"TDMS"决定文件物理格式,两者接口完全一样,只是落盘方式不同,差异在第 5 章。

DDC_AddChannelGroup的第二个参数是组名,第三个参数是描述。组名建议按业务来,比如"AI"、"Counter",不要一律叫group01——下游用 Python 或别的工具读这个文件时,组名就是数据表的表名。

3.2 批量灌数据:为什么第三参数必须唯一

DDCChannelHandle* channels = new DDCChannelHandle[channelNum]; for (int i = 0; i < channelNum; i++) { char channelIdx[20]; itoa(i, channelIdx, 10); // 新版本 MSVC 建议换成 _itoa_s // 参数:所属组、数据类型、通道名、描述、单位、输出句柄 DDC_AddChannel(group01, DDC_Double, channelIdx, "", "", &channels[i]); // 注意:第三个参数是通道名,每次必须不一样,重名会覆盖前一个通道 DDC_SetDataValues(channels[i], channelData, dataLen); }

DDC_Double是数据类型枚举,说明这批数据按 8 字节浮点存。如果你的原始数据是int16或波形点,要么在采集端就转成double再喂进来,要么换成对应的类型枚举——支持的类型列在DDC.h里,别硬转,int转double再转回来精度丢一点点,在频谱分析里会被放大。

第三个参数是这段代码里最关键的一个约定:它是通道名,Demo 用itoa把下标转成字符串"0"、"1"、"2"。名字重复会怎样?不会报错,不会抛异常,DDC_AddChannel会认为你要写的是同一个通道,后写的把前面的覆盖掉。所以你最后得到的通道数少了一半,而且没有任何提示。这个行为在通道数上百、名字由算法拼出来的时候特别容易撞——比如两个来源的通道都叫"ch1"。

DDC_SetDataValues的第三个参数dataLen是数据点数,前两个是句柄和数组首地址。这里传的指针必须在函数返回前一直有效,库内部可能会延后到DDC_SaveFile才真正读这块内存,所以别把临时栈数组的地址喂进去——Demo 里用的是new出来的堆数组,这一点是对的。

3.3 保存与关闭:真正落盘的那一下

DDC_SaveFile(testFile); DWORD end = ::GetTickCount(); DDC_CloseFile(testFile); delete[] channels; delete[] channelData; return end - begin; // 返回值是整段写入耗时,单位毫秒

计时放在SaveFile之后、CloseFile之前,作者这么写是有讲究的。DDC_SaveFile是把内存里的通道数据和索引合并成最终文件,这是整个写入过程里最重的一步,12G 级别的数据在这一行上会停住相当久;DDC_CloseFile主要负责释放句柄。你如果只测SaveFile的耗时,得到的是“业务耗时”;把CloseFile也算进去,得到的才是“磁盘耗时”。两个数都要看,因为多层调用里异常路径可能绕过CloseFile。

注意:DDC_CloseFile必须写在异常安全的位置。Demo 里没有 try/catch,new失败或者SetDataValues出错都会直接跳过关闭动作,留下一个半成品文件和一个没释放的句柄。生产代码里我一般用第 2 章那个TdmsFile结构体的析构来兜底。

这四个接口拼下来,写文件的完整链路就是:CreateFile → AddChannelGroup → AddChannel → SetDataValues → SaveFile → CloseFile。看似简单,但每一环都有一个“不报错但结果不对”的坑——目录不存在、组名重复、通道名重复、指针提前失效,全都不抛异常,全靠返回值判断。

4. 读链路拆解:原 Demo 里三处会崩的地方和修正版

读文件这段代码,是这份资源里信息量最大也最需要动手改的地方。作者自己写的时候大概是脑中想着“打开、数组、数组、取数”四步,实际写下来漏了中间那步“根据索引取句柄”。

4.1 打开、数组、数组、取数:原流程走一遍

int CTDMSTestDlg::ReadDDCFile(const char* filePath, const char* fileType /* = "TDM" */) { DWORD begin = ::GetTickCount(); DDCFileHandle ddcFile = nullptr; DDC_OpenFile(filePath, fileType, &ddcFile); // 打开文件 unsigned int groupNum = 0; DDC_GetNumChannelGroups(ddcFile, &groupNum); // 先问有多少组 DDCChannelGroupHandle* groups = new DDCChannelGroupHandle[groupNum]; for (int i = 0; i < groupNum; i++) { unsigned int channelNum = 0; DDC_GetNumChannels(groups[i], &channelNum); // 这里开始有问题 DDCChannelHandle* channels = new DDCChannelHandle[channelNum]; for (int j = 0; j < channelNum; j++) { unsigned __int64 dataLen = 0; DDC_GetNumDataValues(channels[j], &dataLen); double* data = new double[dataLen]; DDC_GetDataValuesDouble(channels[j], 0, dataLen, data); // ... } delete[] groups; // 循环体内就删了 groups = NULL; } DDC_CloseFile(ddcFile); // 循环外又删一次 delete[] groups; }

这段代码的设计意图是对的:先拿组的数量,按数量开数组,再逐个组去拿通道数量,按数量开通道数组,最后按每个通道的长度开数据缓冲区。三层数量查询对应三种不同的计数接口,分别是DDC_GetNumChannelGroups、DDC_GetNumChannels、DDC_GetNumDataValues,命名很规整,看一眼就知道在查什么。问题在于,它少了“把索引变成句柄”的那一步。

4.2 三处硬伤:空指针、内存泄漏、重复释放

第一处,groups[i]从来没有被赋值过。new DDCChannelGroupHandle[groupNum]只分配了内存,数组元素是未初始化的随机值。要拿到第i个组,必须先调一次按索引取组的接口,常见命名是DDC_GetChannelGroupByIndex(ddcFile, i, &groups[i])。缺了这一步,DDC_GetNumChannels拿到的是一个野指针,运气好返回错误码,运气不好直接访问违例。通道那一层同理,channels[j]也要用DDC_GetChannelByIndex(group, j, &channels[j])取。

第二处,取出来的数据没有释放。内层new double[dataLen]之后,data在循环里被读了一遍(Demo 里那个data[k];是个空操作语句,只是占位),然后就没有下文了。通道数乘以每个通道的长度,一百个通道各十万点,一轮读下来泄漏的就是几百兆。channels数组也是只new不delete。

第三处,delete[] groups写的位置错得离谱。它被放在了组循环的内部,也就是说读第一个组的时候就把组句柄数组释放了;等i变成 1,groups[1]指向的是已经释放的内存。循环外还又来了一次delete[] groups,这是标准意义上的重复释放,堆直接损坏。groups = NULL;写在循环里也毫无意义,下一轮循环立刻又被访问。

注意:这三处不是风格问题,是必然会崩的问题。只要文件里有两个以上的通道组,这段代码就运行不到正常结束。

4.3 换成 vector 之后的重写版

修法不止一种,最简单的是把裸数组全换成std::vector,再把缺失的“按索引取句柄”补上。改完之后所有释放动作由容器负责,异常路径也不会漏:

bool ReadTdms(const char* filePath, const char* fileType, std::vector<std::vector<double>>& out) { DDCFileHandle file = nullptr; if (DDC_OpenFile(filePath, fileType, &file) != DDC_NoError || !file) return false; unsigned int groupNum = 0; DDC_GetNumChannelGroups(file, &groupNum); out.clear(); out.reserve(groupNum); for (unsigned int g = 0; g < groupNum; ++g) { DDCChannelGroupHandle group = nullptr; // 关键的一步:索引 -> 句柄,Demo 里缺的就是这一行 if (DDC_GetChannelGroupByIndex(file, g, &group) != DDC_NoError) continue; unsigned int channelNum = 0; DDC_GetNumChannels(group, &channelNum); std::vector<double> groupData; groupData.reserve(channelNum); for (unsigned int c = 0; c < channelNum; ++c) { DDCChannelHandle ch = nullptr; if (DDC_GetChannelByIndex(group, c, &ch) != DDC_NoError) continue; unsigned __int64 dataLen = 0; DDC_GetNumDataValues(ch, &dataLen); std::vector<double> buf(static_cast<size_t>(dataLen)); // 参数:通道句柄、起始下标、取多少个点、输出缓冲区 DDC_GetDataValuesDouble(ch, 0, dataLen, buf.data()); groupData.push_back(std::move(buf)); } out.push_back(std::move(groupData)); } DDC_CloseFile(file); return true; }

DDC_GetDataValuesDouble这四个参数是这套接口里最值得注意的:句柄、起始下标、点数、缓冲区。中间两个参数让它可以分段读——十二个 G 的文件不需要一次全塞进内存,按块读、边读边算统计量或者直接落成别的格式,是常规操作。参数写反了会静默读出零值,不报错。

unsigned __int64这个类型说明数据长度可能超 32 位,std::vector<double>::size_type在 64 位下正好对得上,所以static_cast<size_t>(dataLen)是安全的;32 位进程就没这么幸运,size_t只有 4 字节,超过 4G 的文件直接截断,这也顺带解释了为什么这类工程后来都得转 64 位。

5. 避坑记录:TDM/TDMS 选型、磁盘空间和 10000 通道的坎

Demo 作者在代码后面附了几条实测结论,这几条比代码本身更有价值,因为它们是踩出来的。下面把这些结论按“现象 → 原因 → 解决”整理成可以直接对照的记录。

5.1 五条实测记录

第一条:换成 TDMS 之后,写入时间翻倍,文件大小没变。

现象:通道数和每通道数据长度完全一致的情况下,写 TDM 花的时间和写 TDMS 花的时间差将近一倍,读的时候两者差不多。

原因:TDM 的结构是“XML 头 + 二进制数据”,头部信息一次性写成文本;TDMS 的索引也是二进制,边写数据边追加索引段。数据量越大,索引追加的次数越多,这部分开销在通道数多的时候会被放大。

解决:离线归档、数据量大且只读一次的场合用 TDM;如果是边采集边落盘、中途可能异常退出需要能恢复的场合,TDMS 的流式结构更合适。选型不是看哪个“先进”,是看你的使用模式。

第二条:12G 的数据,磁盘剩余 20G,写到一半失败。

现象:目标文件最终会到 12G 左右,磁盘明明还剩 20G,写到中途报错,留下一个半截文件。

原因:这类库在落盘时会先写中间文件再做合并,中间文件的体积可能比最终文件还大。作者实测 12G 的最终文件需要至少 32G 以上的空闲空间,也就是接近三倍。

解决:写之前先用GetDiskFreeSpaceEx查一下目标盘可用空间和最终大小的比值,留足 2.5 到 3 倍余量。小数据量不用太担心——128 通道、102400 点、最终约 100M 的规模,中间文件占用也不大。

第三条:通道数从几百加到一万,写入时间不止线性增长。

现象:数据总长度不变,把通道数从 128 提到 10000,写入耗时陡增,不是按比例涨。但单纯增加每通道的数据长度,耗时基本是线性的。

原因:每个通道在文件里都有独立的元数据和索引项,通道数过万时,索引区的开销超过了数据本身。

解决:通道数上千就考虑合并——把同一批物理量的多个时间段存成一个通道的多段数据,或者按组拆成多个文件。上万个通道的场景,基本可以断定数据组织方式需要重新设计。

第四条:同一个文件读第二次明显变快,但 TDMS 始终比 TDM 慢一点。

现象:第一次读要几百毫秒,连续读第二次、第三次降到几十毫秒,但 TDMS 无论读多少次都略慢于 TDM。

原因:两者都有索引,第一次读要真正从磁盘取数据,之后操作系统文件缓存命中,所以变快。TDMS 的索引层级更多,解索引的固定开销省不掉。

解决:读取循环里不要反复开关文件,一次打开把该拿的都拿走。如果业务允许,读进内存后做二次缓存。

第五条:循环写完,通道数少了一半。

现象:channelNum传 128,打开文件只看到 64 个通道。

原因:DDC_AddChannel的第三个参数是通道名,名字重复就等于覆盖。Demo 用itoa(i)生成名字,大概率没问题,但如果名字来自业务字段(设备编号、信号名),重名很常见。

解决:通道名加唯一后缀,或者写之前先做一次去重校验。这条一定要在写入侧拦,读取侧是看不出来的——你只会看到数据少了。

5.2 选型速查表

场景推荐格式理由
大批量离线归档,写一次读多次TDM写入快约一倍,读也略快
边采边存,异常中断要能恢复TDMS流式索引结构对部分写入更友好
通道数在千级以内两者均可差异主要体现在写入耗时,业务上可接受
通道数上万先改数据组织换格式救不了索引开销,要合并通道或拆分文件
磁盘空间紧张TDM + 压缩落地中间文件同样要留 2.5~3 倍空间

6. 二次开发:把读写收成一个 codec 类,回环验证一次到位

第 3、4 章的两段流程都散在对话框类里,句柄和裸数组混着管,加到项目里第二个调用点就容易出事。我一般会先抽一层数据模型,把“文件”这个概念从 DDC 句柄里剥离出来:

struct TdmsChannel { std::string name; std::vector<double> data; }; struct TdmsGroup { std::string name; std::vector<TdmsChannel> channels; }; class TdmsCodec { public: static bool Write(const std::string& path, const std::vector<TdmsGroup>& groups, const std::string& fileType = "TDM"); static bool Read(const std::string& path, std::vector<TdmsGroup>& groups, const std::string& fileType = "TDM"); };

Write内部只干三件事:建文件、按 group/channel 双层循环灌数据、Save 之后立刻 Close。Read内部用第 4 章的修正版,出口把DDCChannelHandle换成TdmsGroup值对象。这样上层业务代码再看不见DDC_前缀,将来要换库、加压缩、挪到后台线程,只动这一个文件。

改完必须做一次回环验证:写进去的数组和读出来的数组逐点比。技巧是先拿全 0 的常量数组跑一遍,长度、组数、通道数一眼就能看出对不对,再换成随机数据。判定用相对误差,别用==:

bool SameAs(const std::vector<double>& a, const std::vector<double>& b) { if (a.size() != b.size()) return false; for (size_t i = 0; i < a.size(); ++i) if (std::fabs(a[i] - b[i]) > 1e-12 * (1.0 + std::fabs(b[i]))) return false; return true; }

计时也别用::GetTickCount(),它精度只有 15.6 毫秒左右,短任务测出来全是 0 或 15,拿它得出的结论没有意义。换成QueryPerformanceCounter,并且把SaveFile和CloseFile分开计,你会发现SaveFile那一行占大头,优化方向也就明确了:减通道数、分批 Save,或者干脆换 TDM。

有一阵子我图省事,直接把 Demo 里的new DDCChannelHandle[channelNum]抄进业务代码,结果通道数跟着配置动态变,某个异常分支先return了,delete[]永远走不到,跑一晚上内存涨了小半个 G。从那以后我每次接这种“句柄 + 裸数组”的第三方库,都强制先把句柄包进 RAII 结构、把数组换成std::vector,再写业务逻辑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询