嵌入式系统CRC-16校验原理与C语言实现详解

发布时间:2026/6/22 15:15:02
嵌入式系统CRC-16校验原理与C语言实现详解
1. 项目概述为什么嵌入式系统离不开CRC校验在嵌入式开发里尤其是涉及通信、存储或者传感器数据采集的场景数据在传输过程中“变味”是常有的事。电磁干扰、时序不稳、存储器偶发翻转都可能让一个关键的参数值从0x55变成0xAA而系统却浑然不觉继续执行错误的逻辑。这时候一种高效、可靠的差错检测机制就成了守护数据完整性的“门神”。循环冗余校验也就是我们常说的CRC正是扮演这个角色的经典算法。你可能在Modbus、CAN、USB、SD卡协议或者任何一个需要可靠数据交换的地方见过它的身影。它不像奇偶校验那样只能检一位错也不像校验和那样容易被有规律的错误“蒙混过关”。CRC通过一个预设的“生成多项式”对数据进行多项式除法运算得到一个短小的校验码。发送方附上这个码接收方重新算一遍一比对就能以极高的概率发现数据是否在传输中发生了哪怕一位的变化。今天我们就以工业控制和通信领域非常常见的CCITT CRC-16标准生成多项式为0x1021为例掰开揉碎了讲清楚它的原理并给出一份可以直接在资源受限的嵌入式MCU上跑起来的C语言实现代码。这份代码源自飞思卡尔现恩智浦的智能传感框架手册我们不仅要看懂它还要弄明白它为什么这么写以及在实际项目中怎么用好它、调好它。2. CRC-16算法核心原理深度拆解要理解代码先得搞懂算法背后的数学游戏。别被“多项式”吓到我们可以把它想象成一种特定规则的“指纹提取器”。2.1 多项式与二进制一种巧妙的映射CRC的核心是一个生成多项式对于CCITT CRC-16这个多项式是G(x) x^16 x^12 x^5 1。在计算机里我们习惯用二进制来表示它。这里有个关键点多项式的最高次项系数默认为1并且在二进制表示中通常省略最高位的1。所以x^16对应二进制第16位从0开始计数。x^12对应二进制第12位。x^5对应二进制第5位。1对应二进制第0位。将它们对应的位设为1得到一个17位的二进制数1 0001 0000 0010 0001。省略最高位的1因为所有CRC生成多项式最高位都是1这是约定我们就得到了关键的16位数值0x1021。这就是代码中#define POLY_CRC16_GENERATOR 0x1021的由来。这个0x1021就是整个CRC计算的“黄金法则”所有异或操作都围绕它进行。2.2 模二除法CRC计算的本质CRC校验码的计算过程可以抽象为“模二除法”。所谓“模二”就是运算不考虑进位和借位加减法都等价于异或XOR操作。计算步骤如下附加零在待发送的原始数据帧看作一个很长的二进制数后面附加上16个0因为CRC-16生成16位校验码。做除法用这个附加了0的新数据帧除以生成多项式0x1021遵循模二除法规则。得余数除法最终得到的余数一定是16位或更少就是CRC校验码。组成发送帧将这个余数CRC码替换掉之前附加的16个0形成最终发送的数据。接收方收到数据后用整个数据帧包含原始数据和CRC码除以同一个生成多项式0x1021。如果余数为0则认为数据传输正确否则说明传输过程中发生了错误。注意这里有一个非常重要的细节也是不同CRC标准的区别之一——初始值。标准的模二除法是从0开始的。但很多CRC实现包括CCITT的一个常用变种有时被称为CCITT-FALSE会使用一个非零的初始值如0xFFFF并且对结果进行异或操作。这样做可以增强对前导0错误的检测能力。我们待会要分析的代码使用的初始值就是0xFFFF。2.3 逐位运算从理论到芯片的桥梁模二除法在数学上很清晰但让单片机直接做超长二进制数的除法效率太低。因此实际实现采用了逐位Bit-by-Bit算法它模拟了除法器中移位寄存器的行为非常适合嵌入式系统。其核心思想是初始化一个16位的寄存器crc16为初始值如0xFFFF。将数据字节的最高位MSB与CRC寄存器最高位“对齐”考虑。如果CRC寄存器当前最高位为1则在寄存器左移一位后与生成多项式0x1021进行异或如果为0则只左移。同时根据当前数据比特是1还是0决定是否在左移后的CRC寄存器最低位加1。重复步骤3-4处理完一个字节的所有8个比特。处理完所有数据字节后再额外进行16次空移位和可能的异或操作处理附加的0。这个过程就像有一个16位的滑动窗口数据比特一位一位地移入多项式的异或操作在特定条件下触发最终寄存器里剩下的值就是CRC结果。3. 代码逐行解析与嵌入式实现要点现在我们对照着飞思卡尔手册里的那段C代码看看逐位算法是如何落地的。我会把代码重新排版并加上详细注释。#define POLY_CRC16_GENERATOR 0x1021 // CCITT CRC-16 生成多项式 uint16 ccitt_crc16_cal(uint32 anumBytes, uint8 *apBuf) { // 1. 初始化CRC寄存器为0xFFFF。这是CCITT CRC-16常用初始值非零初值能避免全零数据产生零CRC的问题。 uint16 crc16 0xffff; uint8 *p8 (uint8*)apBuf; // 数据指针 uint8 bit; uint16 xor_flag; // 标志位用于判断是否需要异或多项式 // 2. 主循环处理每一个输入字节 while(anumBytes--) { uint8 v; // v用于测试数据字节的每一个比特从最高位(0x80)开始 v 0x80; bit 0; // 内层循环处理当前字节的8个比特 do { // 关键判断检查当前CRC寄存器的最高位第15位是否为1 if (crc16 0x8000) { xor_flag 1; // 最高位为1本轮移位后需要异或多项式 } else { xor_flag 0; // 最高位为0只移位不异或 } // CRC寄存器左移一位。最高位移出最低位补0。 crc16 crc16 1; // 处理输入数据比特如果当前数据比特为1则加到CRC寄存器的最低位补上左移后空出的LSB if (*p8 v) { // 检查数据字节当前比特位是否为1 crc16 crc16 1; // 等价于 crc16 | 0x0001 } // 执行核心的模二除法步骤如果标志位为1则与生成多项式异或 if (xor_flag) { crc16 crc16 ^ POLY_CRC16_GENERATOR; // 0x1021 } // 将测试位v右移准备检查数据字节的下一个比特 v v 1; } while(bit 8); // 循环8次处理完一个字节 // 3. 移动到下一个数据字节 p8; } // 4. 后处理模拟处理附加的16个0 bit 0; do { // 原理与内层循环相同但不再有新的数据比特输入相当于输入为0 if (crc16 0x8000) { xor_flag 1; } else { xor_flag 0; } crc16 crc16 1; // 空移位 // 注意这里没有 crc16 crc16 1因为附加的比特是0 if (xor_flag) { crc16 crc16 ^ POLY_CRC16_GENERATOR; } } while(bit 16); // 循环16次 // 5. 返回计算得到的16位CRC值 return crc16; }3.1 关键操作解读左移、加一与异或这段代码的灵魂在于内层do...while循环里的三个操作它们精确模拟了硬件移位寄存器的行为crc16 1这是模拟寄存器的移位操作。想象一个16位的硬件移位寄存器每个时钟周期所有位向左移动一位。最高位MSB被移出并用于判断最低位LSB空出等待新的输入。crc16 1当数据比特为1时执行。这对应着将数据流的当前比特移入寄存器空出的LSB。因为左移后LSB是0所以“加1”操作正好将其置1。如果数据比特是0则LSB保持为0无需操作。crc16 ^ POLY_CRC16_GENERATOR这是模二除法的核心。当被移出的MSB为1时即xor_flag1说明当前部分余数“大于等于”生成多项式需要做一次“减法”。在模二运算中减法就是异或。异或0x1021这个操作就相当于减去模二生成多项式。3.2 初始值与后处理的必要性为什么初始值是0xFFFF使用全1初始值可以确保即使数据帧开头是一串0CRC寄存器也不会保持全0状态而失去检错能力。它让算法对数据中开头部分的变化更敏感。为什么最后要处理16个0这是为了完成整个“附加16个0再求余”的完整模二除法过程。在逐位算法中数据比特全部移入后寄存器里并不是最终的余数还需要继续移位相当于处理附加的0直到所有数据比特的影响都“移出”寄存器。这16次空移位确保了这一点是算法正确性不可或缺的一步。4. 优化方向与查表法实战逐位算法清晰易懂但效率是硬伤。每个字节需要8次循环每次循环包含多次判断、移位和可能的算术运算。在高速通信如CAN总线或处理大块数据如固件校验时这可能成为性能瓶颈。4.1 查表法以空间换时间的经典策略查表法Look-Up Table LUT是CRC计算最常用的优化手段。其原理是一个字节的数据256种可能与当前CRC寄存器值的高8位或低8位运算后会产生一个固定的影响。我们可以预先计算出这256种情况对应的CRC变化值存成一个256大小的表格。这样计算一个字节的CRC就变成了几次加载和异或操作速度提升几十倍。对于CCITT CRC-16初始值0xFFFF一个标准的查表法实现如下// 预先计算好的CRC查表多项式为0x1021初始值为0xFFFF const uint16 crc16_ccitt_table[256] { 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7, 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef, 0x1231, 0x0210, 0x3273, 0x2252, 0x52b5, 0x4294, 0x72f7, 0x62d6, 0x9339, 0x8318, 0xb37b, 0xa35a, 0xd3bd, 0xc39c, 0xf3ff, 0xe3de, 0x2462, 0x3443, 0x0420, 0x1401, 0x64e6, 0x74c7, 0x44a4, 0x5485, 0xa56a, 0xb54b, 0x8528, 0x9509, 0xe5ee, 0xf5cf, 0xc5ac, 0xd58d, 0x3653, 0x2672, 0x1611, 0x0630, 0x76d7, 0x66f6, 0x5695, 0x46b4, 0xb75b, 0xa77a, 0x9719, 0x8738, 0xf7df, 0xe7fe, 0xd79d, 0xc7bc, 0x48c4, 0x58e5, 0x6886, 0x78a7, 0x0840, 0x1861, 0x2802, 0x3823, 0xc9cc, 0xd9ed, 0xe98e, 0xf9af, 0x8948, 0x9969, 0xa90a, 0xb92b, 0x5af5, 0x4ad4, 0x7ab7, 0x6a96, 0x1a71, 0x0a50, 0x3a33, 0x2a12, 0xdbfd, 0xcbdc, 0xfbbf, 0xeb9e, 0x9b79, 0x8b58, 0xbb3b, 0xab1a, 0x6ca6, 0x7c87, 0x4ce4, 0x5cc5, 0x2c22, 0x3c03, 0x0c60, 0x1c41, 0xedae, 0xfd8f, 0xcdec, 0xddcd, 0xad2a, 0xbd0b, 0x8d68, 0x9d49, 0x7e97, 0x6eb6, 0x5ed5, 0x4ef4, 0x3e13, 0x2e32, 0x1e51, 0x0e70, 0xff9f, 0xefbe, 0xdfdd, 0xcffc, 0xbf1b, 0xaf3a, 0x9f59, 0x8f78, 0x9188, 0x81a9, 0xb1ca, 0xa1eb, 0xd10c, 0xc12d, 0xf14e, 0xe16f, 0x1080, 0x00a1, 0x30c2, 0x20e3, 0x5004, 0x4025, 0x7046, 0x6067, 0x83b9, 0x9398, 0xa3fb, 0xb3da, 0xc33d, 0xd31c, 0xe37f, 0xf35e, 0x02b1, 0x1290, 0x22f3, 0x32d2, 0x4235, 0x5214, 0x6277, 0x7256, 0xb5ea, 0xa5cb, 0x95a8, 0x8589, 0xf56e, 0xe54f, 0xd52c, 0xc50d, 0x34e2, 0x24c3, 0x14a0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405, 0xa7db, 0xb7fa, 0x8799, 0x97b8, 0xe75f, 0xf77e, 0xc71d, 0xd73c, 0x26d3, 0x36f2, 0x0691, 0x16b0, 0x6657, 0x7676, 0x4615, 0x5634, 0xd94c, 0xc96d, 0xf90e, 0xe92f, 0x99c8, 0x89e9, 0xb98a, 0xa9ab, 0x5844, 0x4865, 0x7806, 0x6827, 0x18c0, 0x08e1, 0x3882, 0x28a3, 0xcb7d, 0xdb5c, 0xeb3f, 0xfb1e, 0x8bf9, 0x9bd8, 0xabbb, 0xbb9a, 0x4a75, 0x5a54, 0x6a37, 0x7a16, 0x0af1, 0x1ad0, 0x2ab3, 0x3a92, 0xfd2e, 0xed0f, 0xdd6c, 0xcd4d, 0xbdaa, 0xad8b, 0x9de8, 0x8dc9, 0x7c26, 0x6c07, 0x5c64, 0x4c45, 0x3ca2, 0x2c83, 0x1ce0, 0x0cc1, 0xef1f, 0xff3e, 0xcf5d, 0xdf7c, 0xaf9b, 0xbfba, 0x8fd9, 0x9ff8, 0x6e17, 0x7e36, 0x4e55, 0x5e74, 0x2e93, 0x3eb2, 0x0ed1, 0x1ef0 }; uint16 crc16_ccitt_fast(uint32 length, const uint8 *data) { uint16 crc 0xffff; // 初始值 while (length--) { // 关键步骤取CRC的高8位与当前数据字节异或作为查表索引 uint8 index (uint8)((crc 8) ^ *data); // 用索引查表得到中间值再与CRC的低8位组合运算 crc (crc 8) ^ crc16_ccitt_table[index]; } return crc; }查表法原理简析index (crc 8) ^ data将CRC寄存器的高8位与输入字节异或得到一个0-255的索引。这步操作融合了旧CRC值的影响和新数据。crc (crc 8) ^ table[index]将CRC寄存器低8位移动到高8位左移8位然后与查表得到的16位值异或。这个查表值已经包含了生成多项式0x1021的影响。循环处理所有字节最后得到的crc就是结果。注意标准的CCITT查表法实现通常不需要像逐位法那样最后处理16个0因为查表运算本身已经隐含了这个过程。实操心得生成这个256字节的查找表本身也需要计算。你可以写一个小程序用逐位算法为0x00到0xFF这256个值分别计算CRC结果存成数组。或者网上可以找到各种标准CRC的现成表。务必确认表格对应的多项式、初始值、输入输出是否反转等参数与你的需求完全一致。4.2 选择逐位法还是查表法这是一个经典的权衡取决于你的具体项目特性逐位算法查表法代码空间极小(几十字节)较大(512字节的常量数组)运行速度慢(每字节约几十个时钟周期)极快(每字节约几个时钟周期)内存占用仅需几个变量需额外512字节ROM常量区适用场景对ROM极度敏感数据量小或速率极低的场合如低速串口配置对速度有要求数据量大或通信速率高且ROM充足的场合如CAN、USB、文件校验在资源丰富的现代32位MCU上无脑用查表法。在8位小ROM的MCU上如果CRC计算不频繁逐位法仍是可靠选择。飞思卡尔原手册提供逐位法可能正是为了最大限度地保证代码的通用性和可移植性不依赖特定的内存布局。5. 嵌入式应用实战与调试技巧理解了原理和代码最终目的是要用起来。在嵌入式项目里集成CRC校验有几个必须注意的坑。5.1 集成到通信协议中以常见的串口通信帧为例一个简单的应用层协议帧可以设计为[帧头][长度][命令字][数据载荷...][CRC16低字节][CRC16高字节][帧尾]发送端在组好帧头、长度、命令、数据后调用ccitt_crc16_cal函数计算这些部分的CRC值然后将CRC的两个字节附加在数据后面。接收端收到完整帧后用同样的方法计算帧头到数据部分的CRC再与接收到的CRC字节比较。如果相等则通过校验。关键细节字节顺序CRC结果是16位的uint16在内存中存储为两个字节。这就涉及大端序还是小端序的问题。uint16 crc 0x1234;在内存中可能是0x12, 0x34大端也可能是0x34, 0x12小端这取决于CPU架构。通信协议必须明确规定CRC字节的传输顺序。Modbus协议规定CRC低字节在前高字节在后小端序。你的代码在组帧和校验时必须遵循同一顺序。// 示例将CRC值以大端序高字节在前放入发送缓冲区 tx_buffer[data_len] (uint8)(crc_result 8); // 高字节 tx_buffer[data_len 1] (uint8)(crc_result); // 低字节 // 接收端从缓冲区提取CRC假设以大端序传输 uint16 received_crc (uint16)(rx_buffer[data_len] 8) | rx_buffer[data_len 1];5.2 调试与验证如何确认你的CRC算对了这是最容易出问题的一步。你写好了代码怎么知道它计算的CRC值对不对使用在线计算器交叉验证找几个可靠的在线CRC计算工具搜索“CRC calculator”输入相同的测试数据和参数多项式0x1021初始值0xFFFF输入输出不反转结果异或值0x0000对比结果是否一致。构造已知测试向量这是最可靠的方法。许多标准协议文档会提供测试用例。例如对于CCITT CRC-16一个经典的测试是字符串“123456789”的CRC结果应该是0x29B1。你可以用你的代码计算这个字符串的CRC看结果是否匹配。uint8 test_data[] {1, 2, 3, 4, 5, 6, 7, 8, 9}; uint16 crc ccitt_crc16_cal(9, test_data); printf(CRC: 0x%04X\n, crc); // 应该输出 0x29B1与参考实现对比使用其他经过验证的库如Linux内核的lib/crc-ccitt.c计算相同数据对比结果。逻辑分析仪抓包在真实的通信中用逻辑分析仪或示波器的串口解码功能抓取通信双方发送的数据帧。手动计算CRC并与抓取到的CRC字节对比这是最直接的现场验证。5.3 常见问题排查清单当你发现CRC校验总是不通过时可以按照这个清单逐一排查现象可能原因排查方法与标准测试向量不符1. 多项式错误不是0x10212. 初始值错误不是0xFFFF3. 未进行后处理16次空移位4. 输入/输出反转配置错误检查代码中的#define、初始化语句和最后的空循环。确认算法是“CRC-16/CCITT-FALSE”变种。通信双方校验不通过1.字节顺序不一致最常见2. 计算的数据范围不一致3. 一方使用了查表法另一方是逐位法但实现有偏差检查发送端拼接CRC和接收端提取CRC的代码。确认计算CRC的起始指针和长度完全相同。查表法结果错误1. 查找表数据错误2. 查表算法逻辑错误索引计算或组合运算用逐位法为0x00-0xFF生成表格与你使用的表对比。单步调试检查index的计算和crc的更新公式。偶尔校验通过但数据明显错误CRC本身不是100%可靠存在极低的漏检率。对于关键数据需结合其他校验如序列号、重传理解CRC的局限性它主要用于检测随机错误对故意篡改或特定模式的错误可能失效。一个真实的坑我曾经调试一个与传感器通信的模块CRC始终对不上。最后发现传感器手册里写的多项式是0x1021但示例代码里初始值用的是0x0000而不是我惯用的0xFFFF。所以永远以设备厂商提供的示例代码或协议文档为准CRC的变种太多了。6. 进阶思考CRC的变种与选择“CRC-16”其实是一个大家族除了CCITT的0x1021常见的还有CRC-16/MODBUS多项式0x8005初始值0xFFFF输入输出反转。CRC-16/USB多项式0x8005初始值0xFFFF结果异或0xFFFF。CRC-16-IBM多项式0x8005初始值0x0000。它们的核心算法模二除法一样区别在于四个参数宽度16位。多项式如0x1021, 0x8005。初始值计算开始前CRC寄存器的值如0x0000, 0xFFFF。结果处理计算完成后是否对结果进行异或XOR OUT如0x0000, 0xFFFF。输入/输出反转计算前是否将每个输入字节的比特序反转Reflect In输出前是否将整个CRC寄存器的比特序反转Reflect Out。在开始一个项目时第一件事就是确认协议使用的是哪种CRC变种。你可以根据这些参数调整我们上面分析的逐位算法或重新生成对应的查找表。最后虽然查表法很快但在一些对实时性要求变态高或者RAM特别紧张连查表访问都嫌慢的场合还有更进一步的优化如基于切片Slicing的查表法或直接使用芯片的硬件CRC外设如果MCU有的话。对于大多数应用掌握逐位法的原理和查表法的实现已经足以应对99%的嵌入式CRC需求了。理解原理能让你在出问题时知道从哪里下手而高效的实现则能让你的系统跑得更顺畅。