拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

CRC循环冗余校验:参数模型、逐位/查表实现与Modbus排错

CRC循环冗余校验:参数模型、逐位/查表实现与Modbus排错 前阵子帮朋友看一个 Modbus RTU 的通讯故障从站时不时不回数据抓串口波形看着帧长对得上可主站就是判定校验错。折腾半天最后发现是两端对 CRC 的初值和反射参数理解不一致——一边按 CRC-16/CCITT 算一边按 CRC-16/MODBUS 算物理层完全正常纯粹是软件上各算各的。这事儿之后我把 CRC循环冗余校验从最原始的逐位直接计算到工程里常用的查表法重新梳理了一遍顺手把手头的几个坑也记了下来。CRC 这东西门槛不高但细节极多多项式怎么选、初值给多少、输入要不要按位反转、输出要不要再异或一遍、查表法的表到底怎么生成任何一个参数对不上结果就是差之毫厘谬以千里。它广泛用在串口通讯、以太网帧尾、Flash 存储、压缩包完整性、单总线温度传感器等场景是嵌入式、网络、运维几类工程师都绕不开的基础知识。下面我按原理—直接实现—查表优化—参数速查—排错实录—性能取舍的顺序把这块彻底讲透代码以 C 为主附少量 Python 做交叉验证方便不同基础的读者直接抄作业。1. CRC 到底在算什么先把参数模型讲透很多人第一次接触 CRC脑子里只有一句它就是算个校验码至于为什么同一个数据用不同工具算出来的值不一样就说不清了。问题就出在 CRC 并不是一个单一算法而是一族算法。你在网上随便找个在线计算器一定要先选对参数模型否则算出来的值只能自己跟自己比。1.1 从除法的余数说起CRC 的数学本质是模 2 多项式除法取余数。把待校验的数据看成一串二进制系数左移若干位取决于 CRC 位宽然后除以一个约定的生成多项式余数就是校验码。这里的模 2 除法和普通除法最大的区别是加减法都等价于异或XOR不进位也不借位。举个生活化的类比。你在超市买一堆散装商品收银台不是把单价加起来完事而是要用一个固定规则算出一个防错码事后拿这个码去反查有没有录错。CRC 就是这个防错码只不过它的规则是用异或做除法。生成多项式的位数决定了校验码的位宽8 位多项式算出 8 位 CRC16 位算出 16 位32 位算出 32 位。位宽越大能检出的错误模式越多但计算和传输开销也越大。模 2 除法还有一个重要性质它对数据是线性的。具体说CRC(A XOR B) CRC(A) XOR CRC(B)在初值为 0 且不做输出异或的前提下。这条性质正是查表法能成立的根本原因后面第 3 章会重点用到这里先埋个伏笔。1.2 五个参数决定一个 CRC工程上要完整描述一个 CRC 算法必须给出下面这组参数缺一不可。行业里比较通用的一套定义来自 Ross Williams 整理的参数模型常被称作 CRC 目录大家交流时直接报名字就行。参数含义典型取值示例CRC-32Width校验码位宽32Poly生成多项式省略最高位0x04C11DB7Init寄存器初值0xFFFFFFFFRefIn输入数据是否按字节位反转trueRefOut输出结果是否位反转trueXorOut输出前再异或的值0xFFFFFFFF这里有几个细节值得单独拎出来讲。Poly 为什么省略最高位因为生成多项式的最高位一定是 1且位宽为 Width1写出来冗余所以约定俗成只写低 Width 位。比如 CRC-16/MODBUS 的多项式实际是 x^16 x^15 x^2 1写成十六进制就是 0x18005省略最高位后记作 0x8005。Init 为什么常常不是 0初值非 0 能额外检出数据前面补零这一类错误。如果初值为 0那么在前导零数据上 CRC 会有可预测的规律容易被漏检。0xFFFF 和 0xFFFFFFFF 是最常见的两种初值。RefIn 和 RefOut 是不是一回事不是。RefIn 说的是每个数据字节在进入运算前先按位反过来bit7 变 bit0RefOut 说的是最终结果再整体反转一次。绝大多数情况下两者要么同时为真、要么同时为假但也有例外比如 CRC-12/UMTS 是 RefInfalse、RefOuttrue所以一定要按参数表来不能想当然。1.3 反射到底反的是什么反射这个词最容易被误读成多此一举。其实它来自硬件实现的历史。早期的串行 CRC 电路比如经典的 LFSR 移位寄存器在按位处理时一个方向是从高位往低位移另一个方向是从低位往高位移。为了让常见的字节数据能边收边算硬件往往采用低位先入的结构这就等价于把字节做了位反转。从代码角度看RefIntrue 的算法有一个非常显著的特征循环体内的判断条件是crc 1移位方向是右移而 RefInfalse 的算法判断crc 0x8000以 16 位为例移位方向是左移。这两种写法看起来差不多但算出来的东西完全不同。很多新手抄代码时只抄了循环体没注意它对应哪种反射配置结果就是我按文档实现了啊怎么对不上。还有一个经常被忽略的点RefOut 在软件实现里通常不需要单独做一次反转操作。如果你用的是 LSB-first低位先出的实现它天然就把结果反过来了只要参数表里 RefIn 和 RefOut 都为 true直接在最后异或 XorOut 即可中间那步反转已经被隐含在算法结构里了。这一点在第 2 章的代码里会体现得很清楚。2. 直接计算法最笨也最可靠的基准实现不管后面用什么花哨的优化我都会先写一版逐位直接计算把它当作金标准。原因很简单查表法的表如果生成错了你根本不知道错在哪而逐位法逻辑直观、容易对照参数表核验是排错时唯一能信的东西。2.1 逐位法的核心循环先看不做输入反射的版本对应 RefInfalse以 16 位为例#include stdint.h #include stddef.h /* MSB-first适用于 RefInfalse 的模型例如 CRC-16/CCITT-FALSE */ uint16_t crc16_msb(const uint8_t *data, size_t len, uint16_t poly, uint16_t init, uint16_t xorout) { uint16_t crc init; for (size_t i 0; i len; i) { crc ^ (uint16_t)data[i] 8; /* 数据对齐到高字节 */ for (int b 0; b 8; b) { if (crc 0x8000) crc (uint16_t)((crc 1) ^ poly); else crc (uint16_t)(crc 1); } } return (uint16_t)(crc ^ xorout); }整个流程就三步把当前字节异或到寄存器高字节然后移 8 次每次看最高位是否为 1是就左移后异或多项式不是就单纯左移。这里的看最高位其实就是模 2 除法中够不够除的判定够除就减一次模 2 下减法就是异或。2.2 反射版本的写法差异再看支持输入反射的版本对应 RefIntrue逻辑几乎一样但方向全反了/* LSB-first适用于 RefInRefOuttrue 的模型例如 CRC-16/MODBUS */ uint16_t crc16_lsb(const uint8_t *data, size_t len, uint16_t poly_ref, uint16_t init, uint16_t xorout) { uint16_t crc init; for (size_t i 0; i len; i) { crc ^ data[i]; /* 数据对齐到低字节 */ for (int b 0; b 8; b) { if (crc 1) crc (uint16_t)((crc 1) ^ poly_ref); else crc (uint16_t)(crc 1); } } return (uint16_t)(crc ^ xorout); }注意这里的poly_ref不是参数表里的 0x8005而是它按 16 位反转后的值 0xA001。这是 LSB-first 实现最容易踩的坑参数表给的多项式是 MSB-first 形式反射实现必须先把多项式整体位反转。0x8005 的二进制是 1000 0000 0000 0101反转后是 1010 0000 0000 0001也就是 0xA001。这个换算关系在写查表法时同样要用到务必记牢。为了让你直观感受两种实现的差异我在同一份数据上分别跑一下数据算法模型结果123456789CRC-16/MODBUS0x4B37123456789CRC-16/CCITT-FALSE0x29B1123456789CRC-16/ARC0xBB3D123456789CRC-80xF4123456789CRC-32/ISO-HDLC0xCBF43926提示字符串 123456789 是 CRC 界的标准测试向量几乎所有参数模型都有公开的 check 值。自己实现完算法第一件事就是拿它验证比拿真实数据验证高效得多。2.3 手算一遍 CRC-8建立肌肉记忆光看代码还是虚我建议你亲手算一次。以 CRC-8poly0x07init0x00不反射xorout0x00处理单个字节 0x31也就是字符 1为例。0x31 的二进制是 0011 0001。寄存器初值 0x00异或后仍是 0x31然后开始 8 次移位轮次移位前最高位操作移位后10011 00010左移0110 0010 (0x62)20110 00100左移1100 0100 (0xC4)31100 01001左移后异或 0x071000 1111 (0x8F)41000 11111左移后异或 0x070001 1001 (0x19)50001 10010左移0011 0010 (0x32)60011 00100左移0110 0100 (0x64)70110 01000左移1100 1000 (0xC8)81100 10001左移后异或 0x071001 0111 (0x97)所以 CRC-8 对单字节 0x31 的结果是 0x97。第 3 轮和第 4 轮的左移后被截断了最高位是模 2 运算的自然结果不要试图保留那一位寄存器只有 8 位。手算两三遍之后你对crc 0x8000这个判断条件的直觉就建立起来了看别人代码也不会再懵。2.4 直接法的性能账够不够用逐位法的计算量是 8 次循环/字节每次循环包含一次判断、一次移位、可能一次异或。在 72MHz 的 Cortex-M3 上一个 16 位 CRC 处理 1KB 数据大约要 100~200 微秒级别如果数据量是几十 MB 的文件校验那就完全不能忍了。所以直接法的定位很明确用于理解、用于验证、用于极低频次的小数据校验比如一帧只有 8 字节的配置命令用它完全没问题。但如果你的场景是每秒几十兆的以太网数据流、几 MB 的固件镜像校验、或者高频采样的传感器数据流直接法会成为明显瓶颈。这时候就该查表法上场了。3. 查表法把 8 次循环压缩成一次查表查表法的核心思想是把每字节 8 次移位判断这件事提前算好做成一张 256 项的表格运行时只需要一次异或加一次查表就能处理一个字节。速度提升通常在 5~10 倍这在嵌入式里是非常划算的交易。3.1 为什么能查表靠的是线性性质回到第 1 章提到的那条性质CRC 对数据是线性的。这意味着一个字节的 8 位数据对 CRC 结果的贡献可以被拆成 8 个独立的部分每一部分只跟它所在的位置也就是它是这个字节的第几位有关跟它前后的数据无关。换句话说一个字节byte对寄存器的扰动量只取决于byte的值本身一共只有 256 种可能。于是我们完全可以预处理出这 256 种扰动量运行时直接查出来异或进去就行。这就是查表法的全部秘密没有任何魔法。需要强调的是这个线性性质要求算法本身是线性的。大多数标准 CRC 都满足但如果某天你遇到一个在 CRC 外面又套了非线性变换的私有协议那查表法就不一定成立了得具体分析。3.2 256 项表是怎么生成的表的生成可以直接复用逐位法的逻辑只不过输入从任意数据变成了0x00 到 0xFF 这 256 个值。以 MSB-first 的 16 位 CRC 为例static uint16_t crc_table[256]; void crc16_table_init(uint16_t poly) { for (int i 0; i 256; i) { uint16_t crc (uint16_t)(i 8); for (int b 0; b 8; b) { if (crc 0x8000) crc (uint16_t)((crc 1) ^ poly); else crc (uint16_t)(crc 1); } crc_table[i] crc; } }关键点在于生成表时用的是不反射的多项式poly不要用反射后的值。这一点跟第 2.2 节的反射实现刚好相反特别容易搞混。区分方法是看表的使用方式——如果运行时是table[(crc 8) ^ data[i]]用高字节做索引那表就是 MSB-first 生成的如果是table[(crc ^ data[i]) 0xFF]用低字节做索引那表就得用反射后的多项式生成。反射版的表生成代码长这样void crc16_table_init_ref(uint16_t poly_ref) /* poly_ref 0xA001 对应 0x8005 */ { for (int i 0; i 256; i) { uint16_t crc i; /* 低位对齐 */ for (int b 0; b 8; b) { if (crc 1) crc (uint16_t)((crc 1) ^ poly_ref); else crc (uint16_t)(crc 1); } crc_table[i] crc; } }3.3 驱动表的运行时实现表建好之后主循环就变得非常短/* MSB-first 查表 */ uint16_t crc16_fast(const uint8_t *data, size_t len, uint16_t init, uint16_t xorout) { uint16_t crc init; for (size_t i 0; i len; i) crc (uint16_t)((crc 8) ^ crc_table[(crc 8) ^ data[i]]); return (uint16_t)(crc ^ xorout); } /* LSB-first 查表对应 RefInRefOuttrue */ uint16_t crc16_fast_ref(const uint8_t *data, size_t len, uint16_t init, uint16_t xorout) { uint16_t crc init; for (size_t i 0; i len; i) crc (uint16_t)((crc 8) ^ crc_table[(crc ^ data[i]) 0xFF]); return (uint16_t)(crc ^ xorout); }对比第 2 章的逐位版本内层那 8 次循环彻底消失了取而代之的是一次数组索引。用size_t、uint16_t这些固定宽度类型是有必要的避免在 8 位单片机上因为整型提升导致移位出错这个坑我在 51 和 AVR 上各踩过一次。3.4 半字节表与 slicing-by-N用空间换速度256 项的表每项 2 字节16 位 CRC 要占 512 字节 Flash32 位 CRC 就是 1KB。对于 RAM 只有几 KB 的老单片机这不算小数目。于是有了半字节表nibble table只建 16 项表每次处理半个字节4 位算一个字节要查两次表。空间从 512 字节降到 32 字节速度大约是 256 项表的一倍多一点耗时。方案表大小16 位 CRC每字节查表次数适用场景逐位法08 次移位极低频、验证用半字节表32 字节2RAM 极度受限的 MCU256 项表512 字节1通用首选slicing-by-42KB0.25高吞吐软件校验slicing-by-84KB0.125大文件/网络流校验如果是在 PC 或者有 Cache 的应用处理器上还可以上 slicing-by-4 或 slicing-by-8一次吃掉 4 个或 8 个字节建 4~8 张 256 项的表用字节间组合的方式并行算速度能再翻几倍。代价是表占用 2KB~8KB且对数据的首尾对齐处理要细心。Linux 内核、zlib 这些库里都有成熟实现自己写的话建议先跑通 256 项版本确认参数对了再考虑升级。注意无论用哪种查表方案生成的表都必须跟运行时使用的多项式方向一致。我见过不止一次表和算法不匹配导致校验值全错的案例排查起来非常费时间因为表本身看起来没毛病。4. 常用 CRC 参数速查与实际场景对号入座网上的在线 CRC 计算器如果不给参数选项算出来的值基本没法用。下面这张表是我这些年实际用到的参数模型整理需要时直接对号入座能省掉大量试错时间。名称位宽PolyInitRefInRefOutXorOutCheck(123456789)CRC-880x070x00falsefalse0x000xF4CRC-8/MAXIM80x310x00truetrue0x000xA1CRC-16/ARC160x80050x0000truetrue0x00000xBB3DCRC-16/MODBUS160x80050xFFFFtruetrue0x00000x4B37CRC-16/CCITT-FALSE160x10210xFFFFfalsefalse0x00000x29B1CRC-16/XMODEM160x10210x0000falsefalse0x00000x31C3CRC-32/ISO-HDLC320x04C11DB70xFFFFFFFFtruetrue0xFFFFFFFF0xCBF43926CRC-32/MPEG-2320x04C11DB70xFFFFFFFFfalsefalse0x000000000x0376E6E7CRC-32C320x1EDC6F410xFFFFFFFFtruetrue0xFFFFFFFF0xE30692834.1 串口通讯里的 CRC-16/MODBUSModbus RTU 用的是 CRC-16/MODBUS这个组合在工业现场极其常见。它的特点是初值 0xFFFF、输入输出都反射、输出不再异或。帧结构是从站地址 功能码 数据 CRC 低字节 CRC 高字节注意校验码在报文里是低字节在前的因为反射算法本身就是低位先行。实际调试时我习惯先用在线工具按参数表算一个已知报文的 CRC然后拿自己的实现去比对。如果对不上八成是反射或者字节序出了问题。另外提醒一句Modbus 对 CRC 的校验要求是接收端全帧重算一旦不匹配直接丢帧不回复所以你抓不到任何错误响应只能从超时去判断这一点在设计主站重试策略时要考虑进去。4.2 存储与压缩包完整性校验以太网帧尾用的 FCS 是 CRC-32/ISO-HDLC江湖上常叫 CRC-32 标准版压缩格式、文件校验、固件镜像也大量用 32 位 CRC。这里的参数很典型初值全 F、输入输出反射、输出再异或全 F。很多人在实现 32 位版本时把uint32_t写成了unsigned long在 32 位和 64 位平台上长度不一致一换平台结果就变了这个坑值得警惕。当你看到invalid compressed data -- crc error这类报错本质就是解压工具边解压边重算 CRC跟压缩包里存的 CRC 对不上。它说明数据在传输或存储过程中发生了改变而不是压缩包格式不对。排查方向在第 5 章细说。4.3 单总线温度传感器与查表像常见的单总线温度传感器数据帧末尾带一个 CRC-8/MAXIM 校验字节用来确认这一帧 9 个字节的暂存器数据没被读错。地线干扰大的现场如果没有这层校验读出来的温度会出现明显的跳变毛刺。它的参数是 poly0x31反射后是 0x8C、init0x00、输入输出反射、输出不异或。顺便说一句查表法计算温度这个说法。在传感器领域查表法有两个不同的含义一个是用预存的温度-阻值对照表做非线性校正比如 NTC 热敏电阻另一个是用查表法算 CRC 校验。两者都叫查表法但完全不是一回事。调试时先分清你说的是哪一种能省掉很多无效沟通。5. 踩坑与排查实录这些错误长什么样理论讲完来点真东西。下面这些都是我在实际项目里真金白银踩出来的按现象—可能原因—排查手段整理遇到问题可以直接照表查。5.1 千兆链路接收方向 CRC 错误计数猛涨现象很典型百兆跑得好好的一协商到千兆就开始出现大量接收 CRC 错误误码计数蹭蹭涨严重时丢包、重传、吞吐反而比百兆还低。这个现象在千兆以太网里非常常见原因通常不在协议栈而在链路物理层。可能原因排查手段判断依据网线质量不达标换一根确定合格的双绞线对比一换就好基本实锤水晶头压接不良/接触电阻大目视 换头重压插拔时错误数有变化变压器带宽或共模抑制不够换用规格匹配的变压器百兆正常千兆异常接口时序延时配置不当调整接口的收发延时参数对比误码率随参数明显变化供电/地平面噪声大示波器看电源纹波、地弹与业务负载相关对端发送侧本身有问题换一台对端设备做交叉验证换对端后现象消失我个人的排查顺序是先换线、再换端口、再调时序、最后查电源因为前两步成本最低、命中率最高。经验上百兆没问题而千兆出问题八成是链路裕量不够——千兆对线缆带宽、回波损耗、串扰的要求严格得多百兆能凑合用的线千兆就未必。另外别忽略统计口径接收 CRC 错误意味着帧到达时帧尾校验失败说明链路上已经发生了位错误这是物理层的问题不是软件层的。5.2 解压时提示 CRC 错误invalid compressed data -- crc error 这个报错逻辑上只有一种解释解压出来的数据算出来的 CRC 跟包里记录的不一致。常见原因和对策如下。下载不完整或传输损坏重新获取安装包并用官方公布的校验和对完整文件做一次校验确认拿到的东西是完整的。存储介质写坏换一块盘或换一个分区重试。如果错误位置每次都不一样大概率是介质或内存问题。解压工具版本过旧老版本工具对某些压缩格式支持不全升级到较新版本再试。文件系统或驱动层问题某些非标准挂载参数下读写可能出现异常换一个稳定的挂载方式验证。我遇到过最离谱的一次是内存条有隐性故障大文件解压到某个位置必然报 CRC 错误换机器立刻就好。所以如果同一个包在别的机器上能正常解压就别在软件上死磕了先怀疑硬件。5.3 两端算不出同一个值先查这五项这是 CRC 排错里最高频的问题。我总结了五个必查项按命中率从高到低排列。多项式方向搞反反射实现用了不反射的多项式或反过来。检查是不是漏了把 0x8005 换成 0xA001 这一步。初值不同一方 0x0000一方 0xFFFF。这是第二高发的原因文档里往往藏在角落。输出异或没做32 位 CRC 尤其常见最后漏了^ 0xFFFFFFFF。处理范围不同一方校验了整帧含地址和功能码另一方只校验数据段。字节序或尾插顺序不同计算值一样但写进报文时的字节排列不一样看起来就是对不上。验证手段很简单拿标准向量 123456789 各算一遍对照第 4 章的表格。如果标准向量都对不上那就是算法本身有问题如果标准向量对得上但实际报文对不上那就是第 4、5 项的问题也就是处理范围或字节序。5.4 查表法专属的坑查表法还有几个自己特有的问题逐位法不会遇到。表生成用了反射后的多项式但运行时用高字节做索引方向和索引方式必须配套两者错配会得到一组看起来随机但每次都一样的值。表的类型宽度不够32 位 CRC 的表必须存成uint32_t误写成uint16_t会高位截断值全错。多线程环境共享表但初始化有竞争表是只读的初始化一次即可如果初始化放在懒加载里记得加锁或用编译期常量表。把表当全局变量放 RAM 导致栈或堆溢出嵌入式里建议加const放到 Flash减少 RAM 占用。独家小技巧我通常会在单元测试里同时保留逐位法和查表法两套实现用随机数据对拍。两套实现参数一致时结果必然相同只要有一个字节不同就说明某一边写错了。这个双实现对拍的做法帮我抓出过好几次细微的移位错误强烈建议你也加上。6. 性能取舍与硬件加速最后聊聊性能和实现方式的取舍。CRC 这件事没有最优解只有最适合当前场景的解。6.1 软件实现的选型思路如果是单片机上的低速串口通讯数据量小、频率低逐位法完全够用代码量小、易审计出问题好定位。如果数据量上了几十 KB 甚至更多256 项查表是性价比最高的选择512 字节的表换来的提速非常划算。如果跑在应用处理器上、要处理大文件或者网络流那 slicing-by-8 值得考虑但记得先确认编译器和平台对未对齐访问、字节序的处理方式避免踩到性能陷阱。场景推荐方案理由单帧配置命令32 字节逐位法代码简单维护成本低传感器周期采样逐位法或半字节表RAM 紧张数据量小串口/总线通讯256 项表通用平衡点固件镜像校验256 项表或 slicing-by-4数据量大需要吞吐网络转发/文件系统slicing-by-8 或硬件性能敏感6.2 用上硬件 CRC 单元不少现代 MCU 和处理器自带硬件 CRC 外设配好多项式和初值后写数据进去读结果就行比软件快得多而且不占 CPU。用硬件 CRC 有几个必须确认的点。第一硬件支持的多项式是固定的还是可配置的。有些芯片只支持一两种固定多项式比如只支持某个标准 32 位 CRC如果你的协议用的是 16 位多项式那就用不了。第二硬件是否支持输入输出反射。很多硬件单元只做 MSB-first反射要靠软件预先把数据反转反而更慢。第三初值和输出异或是否可配。有些硬件把初值写死在寄存器里需要手动预加载。第四数据宽度对齐要求。32 位宽的硬件 CRC 通常要求按字写入处理不整除的尾部数据时要单独用软件补算。这些细节在数据手册里往往分散在不同章节用之前一定要把相关寄存器说明翻完别只看到一个CRC字样就直接上。6.3 校验策略别把 CRC 当安全手段最后强调一个观念问题。CRC 是检错码不是纠错码更不是安全校验。它能高效地检出传输和存储过程中的随机位错误但它对精心构造的篡改没有抵抗力——攻击者可以同时修改数据和 CRC让校验照样通过。需要防篡改的场景得用带密钥的消息认证码这是两码事。另外别指望 CRC 能纠正错误。收到校验失败的数据正确做法是丢弃并请求重传如果有重传机制而不是试图修它。CRC 的数学结构决定了它只能告诉你这数据大概率坏了不能告诉你坏在哪一位。我在实际项目里的体会是CRC 的价值不在于它多先进而在于它足够简单、足够快、足够可靠几十年来在各种协议里被反复验证。把它用对关键就三件事——参数对齐、范围一致、字节序统一。这三件事做到位剩下的事情它会自己搞定。再分享一个我踩过的小坑收尾。有次我图省事用 32 位 CRC 的表去算 16 位 CRC想着位宽大一点更保险结果两端根本对不上——因为位宽直接决定多项式长度和运算结构不是算得更多就能兼容。后来我养成了个习惯每个项目建一个crc.ccrc.h把所有参数模型都实现一遍附上标准向量自测新项目直接拷过去用。这个文件我从五年前一直维护到现在省下的调试时间早就不知道多少倍了。
返回列表