
1. 从一次数据提取需求说起为什么要啃 shm.tnf 这块硬骨头做量化或者搞行情数据分析的朋友大概率都动过这样一个念头通达信本地缓存了那么多行情数据能不能直接读出来自己用毕竟实时接口要授权、要维护连接而本地文件就安安静静躺在硬盘里读一次就有一份干净的数据。这个想法很自然但真正动手的人不多原因也很简单——通达信的数据文件是二进制格式没有公开文档字段含义全靠猜。shm.tnf就是这类文件里比较典型的一个。它通常出现在通达信的安装目录下和T0002、vipdoc这些目录并列。名字里的shm一般理解为共享内存shared memory相关的落盘快照tnf则是通达信自定义的一种表结构文件后缀。它记录的是股票代码、名称、市场归属、板块分类等基础元数据可以理解成通达信内部的一张证券信息总表。你平时在软件里看到的股票列表、板块成分底层很大一部分就来自这个文件。为什么值得专门写一篇逆向解析的教程因为这件事的收益和门槛都很明确。收益是一旦解析成功你就拥有了一个完全离线、不依赖任何接口的证券基础信息库可以拿来做代码校验、板块映射、名称补全甚至给自建的行情数据库做初始化。门槛是你得懂一点 C、懂一点二进制文件结构、还得有耐心去对偏移量。网上关于shm.tnf的资料非常零散大多是丢一段代码就完事偏移量怎么来的、字段为什么这么排、遇到不同版本怎么办几乎没人讲清楚。我这篇东西就是想把这块补上。目标读者是有 C 基础、想做本地数据解析、但没怎么碰过二进制逆向的开发者。全文会从文件结构分析讲到完整可编译的代码再到偏移量的逐字段拆解最后把我踩过的坑和验证方法一并交代。你照着做应该能在一个下午之内跑通自己的解析器。提示本文所有分析基于公开可获取的本地文件格式研究仅用于个人数据管理与学习目的。请遵守软件许可协议不要用于任何商业分发或破解用途。2. 动手之前shm.tnf 的文件结构到底长什么样2.1 先用十六进制视角建立直觉在写任何代码之前我强烈建议你先用十六进制工具把文件打开看一眼。Windows 上可以用 HxD跨平台的话xxd或者 010 Editor 都行。这一步不能省因为它能帮你建立对文件长相的直觉后面看偏移量才不会觉得是在背天书。打开之后你会发现shm.tnf并不是那种从头到尾一条条记录平铺的简单结构。它更像是一个带头部描述的表开头一段是文件级元信息记录数量、记录长度、版本标识之类后面才是真正的记录区。记录区里每一条记录长度固定字段紧密排列没有分隔符也没有对齐填充——这也是二进制格式的典型特征省空间但读起来费劲。我第一次打开的时候看到开头几个字节是类似54 4E 46这样的 ASCII翻译过来正好是TNF基本可以确认这是文件魔数magic number。魔数的作用是快速校验文件类型防止你把别的文件误当成shm.tnf来解析。这个习惯在逆向里非常重要先找魔数再找长度字段最后才去抠业务字段。2.2 头部字段的合理推断由于官方没有文档头部字段的含义需要通过观察 验证来推断。我的做法是找两个记录数量明显不同的shm.tnf文件对比它们头部相同位置的字节哪个字段的值跟着记录数量成比例变化哪个字段就大概率是记录数或记录长度。按这个思路头部通常包含这么几类信息魔数/版本标识固定几个字节用来识别文件类型和格式版本。记录总数一个 32 位整数表示后面有多少条记录。单条记录长度一个 32 位整数表示每条记录占多少字节。这个字段极其关键它决定了你循环读取时的步长。保留字段一些暂时看不出用途的字节可能是校验、时间戳或者对齐填充。这里要强调一个经验记录长度字段是解析的命门。如果这个值读错了后面所有记录的起始位置都会错位读出来的全是乱码。所以拿到文件后第一件事就是用文件总大小减去头部大小再除以记录数去反推记录长度和头部里读到的值做交叉验证。两者对得上说明你的头部解析是对的。2.3 记录区的字段排布规律记录区是重头戏。每条记录里通常包含股票代码、股票名称、市场代码、证券类型、板块归属等信息。字段的排布一般遵循定长 紧凑的原则代码字段常见是 6 到 8 个字节的 ASCII比如600000这种不足部分用\0补齐。名称字段定长字节数组中文名称通常是 GBK 编码这点后面会专门讲是个大坑。市场/类型字段1 到 2 个字节的枚举值比如 0 代表深市、1 代表沪市之类具体映射要靠对比验证。其他标志位可能包含停牌标志、ST 标志、板块编号等。字段之间没有分隔符所以你只能靠偏移量 长度来定位。这也是为什么标题里专门强调偏移量详解——偏移量就是这张表的坐标系错一个字节后面全崩。3. 逆向解析的核心思路从字节流到结构体3.1 为什么用 C 而不是 Python很多人会问解析二进制文件 Python 不是更香吗struct.unpack一行搞定。这话没错Python 做原型验证确实快。但真要做到高性能、可嵌入、可分发C 的优势就出来了。shm.tnf动辄几万条记录如果你要频繁读取、做实时映射Python 的解释开销和 GIL 限制会成为瓶颈。而 C 可以直接把文件mmap到内存用指针强转成结构体数组读取几乎是零拷贝。另外如果你最终想把解析器集成进一个 C 写的行情引擎或者交易系统那用 C 就是顺理成章的事。当然C 写二进制解析也有代价内存对齐和字节序这两个坑必须自己处理。这也是后面代码里要重点讲的部分。3.2 内存对齐最容易被忽略的隐形杀手C 结构体默认会做内存对齐。什么意思呢假设你定义了一个结构体里面有char[8]和int编译器可能会在char[8]后面偷偷插入几个填充字节让int的起始地址落在 4 字节边界上。这在正常编程里是好事能提升访问速度。但在解析二进制文件时这就是灾难——因为文件里的数据是紧凑排列、没有填充的你按对齐后的结构体去读字段位置全错。解决办法有两个用#pragma pack(push, 1)把结构体强制设为 1 字节对齐取消所有填充。干脆不用结构体手动按偏移量逐字段读取。我个人的习惯是两者结合用#pragma pack(1)定义结构体方便代码可读同时在关键字段上再用偏移量做一次断言校验双保险。#pragma pack(push, 1) struct TnfRecord { char code[8]; // 证券代码 char name[16]; // 证券名称GBK uint8_t market; // 市场标识 uint8_t secType; // 证券类型 uint16_t reserved; // 保留/标志位 // ... 其余字段按实际偏移量补充 }; #pragma pack(pop)#pragma pack(push, 1)和#pragma pack(pop)要成对出现前者开启紧凑模式后者恢复默认避免影响其他头文件。3.3 字节序小端才是常态x86 平台是小端序little-endian通达信作为 Windows 平台的老牌软件文件里的多字节整数基本都是小端存储。也就是说一个0x00000001在文件里存的是01 00 00 00。如果你在读取时直接按大端解释读出来的数字会离谱到让你怀疑人生。在 C 里只要你是在 x86/x64 平台上直接读通常不需要手动转换因为 CPU 本身就是小端。但如果你要写跨平台代码或者用std::byteswapC23之类的工具就得留意。稳妥的做法是写一个辅助函数明确按小端解析uint32_t readLE32(const uint8_t* p) { return static_castuint32_t(p[0]) | (static_castuint32_t(p[1]) 8) | (static_castuint32_t(p[2]) 16) | (static_castuint32_t(p[3]) 24); }这个函数看起来笨但它把字节序这件事显式化了读代码的人一眼就知道这里按小端处理不会产生歧义。4. 完整代码实现一个可编译的 shm.tnf 解析器4.1 工程结构与依赖为了让你能直接抄作业我把代码组织成一个最小的单文件工程只依赖标准库不引入任何第三方。这样你在 VS Code 或者 Visual Studio 里新建一个控制台项目把代码贴进去就能编译。目录结构建议这样tnf_parser/ ├── main.cpp └── shm.tnf (把你的样本文件放这里)编译命令gg -stdc17 -O2 main.cpp -o tnf_parserWindows 上用 MSVC 的话直接建空项目加main.cpp即可。注意如果你之前遇到过Microsoft Visual C 14.0 is required这类报错那是缺少 VC 运行库或构建工具装一下对应的 Redistributable 和 Build Tools 就能解决和本文的解析逻辑无关。4.2 头部解析与记录长度校验先读头部拿到记录数和记录长度然后立刻做一致性校验。这一步是整个解析器的地基。#include cstdint #include cstdio #include cstring #include string #include vector #include fstream #include iostream struct TnfHeader { uint32_t magic; // 魔数预期为 TNF 相关标识 uint32_t version; // 格式版本 uint32_t recordCount; // 记录总数 uint32_t recordSize; // 单条记录字节数 uint32_t reserved; // 保留字段 }; bool parseHeader(const std::vectoruint8_t buf, TnfHeader h) { if (buf.size() sizeof(TnfHeader)) return false; std::memcpy(h, buf.data(), sizeof(TnfHeader)); // 交叉校验文件剩余大小应能被记录长度整除 size_t bodySize buf.size() - sizeof(TnfHeader); if (h.recordSize 0) return false; if (bodySize % h.recordSize ! 0) { std::cerr 警告记录长度与文件大小不匹配偏移量可能需调整\n; } return true; }这里sizeof(TnfHeader)是 20 字节5 个 uint32。如果你的样本文件头部不是这个长度就需要根据实际观察调整。校验逻辑比解析逻辑更重要因为它能在第一时间告诉你偏移量对不对。4.3 逐记录读取与字段提取头部通过后就可以按recordSize为步长循环读取了。每条记录内部再按偏移量取字段。struct StockInfo { std::string code; std::string name; uint8_t market; uint8_t secType; }; std::string gbkToUtf8(const std::string gbk); // 见 4.4 节 std::vectorStockInfo parseRecords(const std::vectoruint8_t buf, const TnfHeader h) { std::vectorStockInfo result; const uint8_t* base buf.data() sizeof(TnfHeader); for (uint32_t i 0; i h.recordCount; i) { const uint8_t* rec base i * h.recordSize; StockInfo info; // 代码偏移 0长度 8 info.code.assign(reinterpret_castconst char*(rec 0), strnlen(reinterpret_castconst char*(rec 0), 8)); // 名称偏移 8长度 16GBK std::string rawName(reinterpret_castconst char*(rec 8), strnlen(reinterpret_castconst char*(rec 8), 16)); info.name gbkToUtf8(rawName); // 市场偏移 24长度 1 info.market rec[24]; // 证券类型偏移 25长度 1 info.secType rec[25]; result.push_back(std::move(info)); } return result; }注意strnlen的用法它能在指定最大长度内找到字符串结束符避免越界读取。二进制文件里字符串不一定有\0结尾所以这个保护很有必要。4.4 中文名称的 GBK 转码处理这是整个解析里最容易翻车的地方。通达信是老软件名称字段用的是GBK 编码不是 UTF-8。你如果直接把它当 UTF-8 输出到控制台会看到一堆乱码甚至触发编码异常。Windows 上可以用MultiByteToWideChar做转换#ifdef _WIN32 #include windows.h std::string gbkToUtf8(const std::string gbk) { if (gbk.empty()) return {}; int wlen MultiByteToWideChar(CP_ACP, 0, gbk.c_str(), (int)gbk.size(), nullptr, 0); std::wstring wstr(wlen, L\0); MultiByteToWideChar(CP_ACP, 0, gbk.c_str(), (int)gbk.size(), wstr[0], wlen); int ulen WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), (int)wstr.size(), nullptr, 0, nullptr, nullptr); std::string utf8(ulen, \0); WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), (int)wstr.size(), utf8[0], ulen, nullptr, nullptr); return utf8; } #else std::string gbkToUtf8(const std::string gbk) { return gbk; // 非 Windows 平台需引入 iconv 等库 } #endifLinux/macOS 下没有CP_ACP需要借助iconv或者第三方库。如果你只是做原型验证可以先跳过转码把原始字节 dump 出来用十六进制看确认字段位置对了再处理编码。4.5 主函数与输出验证把上面几块拼起来主函数负责读文件、调解析、打印结果。int main(int argc, char** argv) { const char* path (argc 1) ? argv[1] : shm.tnf; std::ifstream ifs(path, std::ios::binary); if (!ifs) { std::cerr 无法打开文件: path \n; return 1; } std::vectoruint8_t buf((std::istreambuf_iteratorchar(ifs)), std::istreambuf_iteratorchar()); TnfHeader h{}; if (!parseHeader(buf, h)) { std::cerr 头部解析失败\n; return 1; } std::cout 记录数: h.recordCount 记录长度: h.recordSize \n; auto stocks parseRecords(buf, h); for (size_t i 0; i stocks.size() i 20; i) { std::cout stocks[i].code stocks[i].name market (int)stocks[i].market \n; } return 0; }先只打印前 20 条方便肉眼核对。如果代码和名称能对上你在通达信里看到的列表说明解析基本成功。5. 偏移量逐字段拆解与验证方法5.1 偏移量不是猜出来的是对出来的很多人以为偏移量是逆向大神凭感觉猜的其实不是。偏移量的确定是一个系统性对比过程。我的标准流程是这样的用十六进制工具打开文件找到第一条记录把它的字节序列抄下来。在通达信软件里找到对应的第一只股票记下它的代码、名称、市场。把字节序列和已知信息做匹配哪几个字节正好是代码的 ASCII哪几个字节翻译成 GBK 正好是名称匹配上的位置就是偏移量长度就是字段长度。用第二条、第三条记录重复验证确保规律稳定。这个过程听起来笨但它是唯一可靠的方法。任何别人给的偏移量你都得自己验证一遍因为不同版本的通达信、不同的数据文件字段排布可能不一样。5.2 常见字段偏移量对照表下面这张表是我在多个样本上验证过的典型排布供你起步参考。注意具体值请以你自己的样本为准这张表是起点不是终点。字段名起始偏移长度字节类型说明证券代码08ASCII不足补\0如600000证券名称816GBK中文名不足补\0市场标识241uint80/1 区分沪深等证券类型251uint8股票/基金/债券等标志位262uint16停牌、ST 等标志板块编号284uint32所属板块索引保留32剩余-视记录长度而定如果recordSize是 40那保留区就是 8 字节如果是 48保留区就是 16 字节。记录长度减去已知字段长度剩下的就是保留区这个减法能帮你快速判断记录长度是否合理。5.3 用断言把偏移量焊死光靠打印核对还不够我习惯在代码里加断言把关键偏移量的假设固化下来。这样一旦样本换了、格式变了程序会立刻报错而不是默默输出错误数据。// 在 parseRecords 里加校验 if (info.code.size() ! 6 info.code.size() ! 8) { std::cerr 第 i 条记录代码长度异常: info.code \n; } if (info.market 3) { std::cerr 第 i 条记录市场值异常: (int)info.market \n; }市场值一般不会超过 3 或 4如果读出来是 200 多那基本可以断定偏移量错了。这种值域校验是逆向里非常实用的技巧业务字段往往有合理的取值范围超出范围就说明你读错位置了。6. 踩坑实录那些让我熬夜的诡异问题6.1 记录长度读错导致的整体错位这是我踩的第一个大坑。一开始我按 40 字节步长读结果从第二条记录开始代码字段全是乱码。排查了半天最后发现实际记录长度是 44 字节我少读了 4 个字节导致每条记录都往前错位。教训永远用文件大小 - 头部大小除以记录数来反推记录长度和头部字段交叉验证。两者不一致时以反推值为准因为文件大小是客观事实。6.2 中文名称截断引发的越界名称字段是定长的但有些名称比较长可能刚好占满 16 字节而没有\0结尾。如果你用strlen去读它会一直往后找\0直接越界读到下一条记录甚至读到文件尾导致崩溃。解决办法一律用strnlen(ptr, maxLen)把最大长度卡死。这个函数是 POSIX 标准Windows 上也有对应实现实在不行自己写一个循环版本。6.3 不同版本文件的字段漂移通达信更新过很多次不同版本的shm.tnf字段排布可能有细微差别。我遇到过某个版本在名称字段后面多插了一个字节的标志位导致后面所有字段偏移量整体后移 1。应对策略把偏移量做成可配置的而不是硬编码。可以定义一个OffsetConfig结构体从配置文件或命令行参数读取。这样换版本时只改配置不用重新编译。struct OffsetConfig { size_t codeOff 0; size_t nameOff 8; size_t marketOff 24; size_t typeOff 25; };6.4 大文件读取的内存问题如果shm.tnf很大几十 MB 甚至上百 MB一次性读进vector虽然简单但内存占用会比较高。更优雅的做法是用内存映射文件memory-mapped file让操作系统按需分页加载。Windows 上用CreateFileMappingMapViewOfFileLinux 上用mmap。这样你拿到的就是一个指向文件内容的指针解析逻辑完全不用改只是数据来源从vector换成了裸指针。对于需要频繁读取的场景这个优化很值得做。7. 解析之后的延伸玩法与实用建议7.1 把解析结果落成 CSV 或 SQLite解析出来只是第一步真正好用还得落地成方便查询的格式。我一般会做两个输出CSV方便用 Excel 或者 pandas 快速查看适合人工核对。SQLite方便程序查询尤其是做代码到名称的映射时一条 SQL 就搞定。落库的时候记得给代码字段建索引几万条记录的查询性能会差很多。7.2 和实时行情数据做关联shm.tnf提供的是静态元数据实时行情是动态数据。两者结合的方式很简单用代码字段做 key把名称、市场、板块信息 join 到行情记录上。这样你的行情表里就不只有冷冰冰的代码还有可读的名称和分类做分析和展示都方便很多。7.3 定期更新与增量校验通达信会不定期更新证券列表新股上市、退市、改名。所以解析器不能只跑一次要定期重新解析并且做增量对比哪些是新代码、哪些消失了、哪些名称变了。这个 diff 结果本身就是有价值的信息。7.4 几个能省你半天时间的建议先小后大先用一个记录数少的样本文件跑通再上大文件。小文件出问题好定位。十六进制常开调试时把十六进制视图和程序输出并排放一眼就能看出偏移量对不对。版本留痕每次解析成功把文件大小、记录数、记录长度、魔数记下来形成自己的格式档案下次遇到新版本能快速比对。别迷信单一来源网上找到的偏移量表只能当参考一定要用自己的样本验证。我见过好几份互相矛盾的资料最后发现是版本不同导致的。我个人在实际操作中的体会是逆向解析这件事耐心比技巧更重要。偏移量对不上是常态对上了才是惊喜。把校验做扎实把假设显式化剩下的就是时间问题。这套方法不只适用于shm.tnf换成任何没有文档的二进制格式思路都是通的。