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::byteswap(C++23)之类的工具,就得留意。稳妥的做法是写一个辅助函数,明确按小端解析:
uint32_t readLE32(const uint8_t* p) { return static_cast<uint32_t>(p[0]) | (static_cast<uint32_t>(p[1]) << 8) | (static_cast<uint32_t>(p[2]) << 16) | (static_cast<uint32_t>(p[3]) << 24); }这个函数看起来笨,但它把字节序这件事显式化了,读代码的人一眼就知道这里按小端处理,不会产生歧义。
4. 完整代码实现:一个可编译的 shm.tnf 解析器
4.1 工程结构与依赖
为了让你能直接抄作业,我把代码组织成一个最小的单文件工程,只依赖标准库,不引入任何第三方。这样你在 VS Code 或者 Visual Studio 里新建一个控制台项目,把代码贴进去就能编译。
目录结构建议这样:
tnf_parser/ ├── main.cpp └── shm.tnf (把你的样本文件放这里)编译命令(g++):
g++ -std=c++17 -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::vector<uint8_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::vector<StockInfo> parseRecords(const std::vector<uint8_t>& buf, const TnfHeader& h) { std::vector<StockInfo> 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_cast<const char*>(rec + 0), strnlen(reinterpret_cast<const char*>(rec + 0), 8)); // 名称:偏移 8,长度 16(GBK) std::string rawName(reinterpret_cast<const char*>(rec + 8), strnlen(reinterpret_cast<const 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::vector<uint8_t> buf((std::istreambuf_iterator<char>(ifs)), std::istreambuf_iterator<char>()); 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 常见字段偏移量对照表
下面这张表是我在多个样本上验证过的典型排布,供你起步参考。注意:具体值请以你自己的样本为准,这张表是起点不是终点。
| 字段名 | 起始偏移 | 长度(字节) | 类型 | 说明 |
|---|---|---|---|---|
| 证券代码 | 0 | 8 | ASCII | 不足补\0,如600000 |
| 证券名称 | 8 | 16 | GBK | 中文名,不足补\0 |
| 市场标识 | 24 | 1 | uint8 | 0/1 区分沪深等 |
| 证券类型 | 25 | 1 | uint8 | 股票/基金/债券等 |
| 标志位 | 26 | 2 | uint16 | 停牌、ST 等标志 |
| 板块编号 | 28 | 4 | uint32 | 所属板块索引 |
| 保留 | 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 上用CreateFileMapping+MapViewOfFile,Linux 上用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,换成任何没有文档的二进制格式,思路都是通的。