1、基本概念
1.1、循环冗余检测(Cyclic Redundancy Check,CRC)
CRC编码也被称为多项式编码(polynomial code),因为该编码能够将要发送的比特串看作是系数为 0 和 1 的一个多项式。对比特串操作被解释为多项式算术。
1.2、CRC参数
D:D数据拥有 d 比特
G:发送方和接收方需要协商一个 r+1 比特模式,称为生成多项式(G),G 的最高有效位比特(最高位)和 最低有效位比特(最低位)必须为 1
R:发送方选择 r 个附加比特,称为 R(CRC校验码)
(1)计算:R 是数据 D 通过模 2 除法除 G 运算得到的(姑且这么说)余数,这个 R 就是 FCS(检测帧序列),发送时把 R 附加到数据 D 后面。
(2)检验:一共接收有 d+r 个比特,用模 2 算术恰好能够被 G 整除(没有余数),即 (D+R)/ G,如果余数为 0,接收方认为数据正确而被接收,否则接收方知道出现了差错。
1.3、CRC原理解释
所有 CRC 计算采用模 2 算术,即在加法中不进位,在减法中不借位,意味加法和减法是相同的,等价于操作数的按位异或(XOR)运算,而不是需要借位运算。
例如:
D = 10110011,d = 8
G = 11001,r = 4
通过计算得到 R = 0100
在这种情况下传输 12 个比特是 101100110100
1.4、例子(一步一步分析)
CRC标准有8、12、16、32比特生成多项式G,一般采用32比特。
举一个例子使用 CRC-8 算法求 101001110100001 的 CRC。
CRC-8 标准的 h(x) = x8 + x7 + x6 + x4 + x2 + 1,既 g 是9位的二进制串111010101。
- 首先需要在被除数A后加 8 个比特位0(标准位数减 1)。
- 进行模2除法运算。注意每次都是模2运算,即异或。
- 最后得到余数C就是CRC校验值。注意余数位数必须比除数少1位,如果不够前面加0补齐。
如果数据的比对位为 1,就与 h(x) 进行异或运算然后向后移一位
如果数据的比对位为 0,就直接后移一位
直到剩余数据长度与标准相等。
经过运算后,最终得到的r是10001100,这就是 CRC 校验码。
1.5、生成多项式
注意:
- 位宽:文献中提到的生成多项式经常会说到多项式的位宽(Width,简记为W),这个位宽不是多项式对应的二进制数的位数,而是位数减1。比如CRC8中用到的位宽为8的生成多项式,其实对应得二进制数有九位:100110001。计算出的CRC校验值长度是位宽。
- 16进制表示:多项式表示和二进制表示都很繁琐,交流起来不方便,因此,文献中多用16进制简写法来表示,
- 省略最高位:因为生成多项式的最高位肯定为1,最高位的位置由位宽可知,故在简记式中,将最高的1统一去掉了,如CRC32的生成多项式简记为
0x04C11DB7
实际上表示的是0x104C11DB7
。这样简记除了方便外,在编程计算时也有它的用处。
2、代码编程原理
以 CRC32 为例
数据都是 8 bit(16进制)
声明1:网上有很多代码,可以看到很多例子不一样(大部分应该没有问题),其实只是他们没有声明自己算法顺序
- 算法顺序
(1)正向算法:就是一般讲解的多项式,例如CRC32 = x32 + x26 + x23 + x22 + x16 + x12 + x11 + x10 + x8 + x7 + x5 + x4 + x2 + x + 1
,2进制就是100000100110000010001110110110111
,16 进制就是0x104C11DB7
,但是会去掉最高位实用0x04C11DB7
,这样的话就需要高八位判断,然后左移。
(2)反向算法:反向算法优化的更好,同样 CRC32 是一样的,只是把二进制倒序,2进制就是11101101101110001000001100100000
,16 进制就是0xedb88320
,这样的话就需要八低位判断,然后右移。
声明2:为什么先移位再异或?
- 原因:因为不论是 CRC 8/16/32,应该是 9/17/33 位,但是二进制只取了 8/16/32 位,即默认省略了最高位的 1,根据规则最高位必须为 1,都统一省略,方便位数声明。(同为 1,异或为 0)
2.1、CRC 校验规则
(1)如果仅使用上面所介绍的规则,这显然不够。
如果在字符串前面加0,并不影响校验值,这就不符合我们的预期了。比如,我们想校验的1字节1001 1100,现在在前面补1字节0,变成2字节0000 0000 1001 1100,结果两个得到的校验值是一样的。所以在实际应用中,CRC校验过程做了一些改变:增加了“余数初始值”、“结果异或值”、“输入数据反转”、“输出数据反转”四个概念。
(2)工程中常用CRC校验规则
- 余数初始值:即在计算开始前,先给变量CRC赋的初值。
- 结果异或值:即在计算结束后,得到的变量CRC与这个值进行异或操作,就得到了最终的校验值。
- 输入数据反转:即在计算开始前,将需要校验的数据反转,如数据位1011,反转后为1101。
- 输出数据反转:即在计算结束后,与结果异或值异或之前,计算值反转,如计算结果为1011,反转后为1101。
接下来以 CRC-32 校验为例,讲解工程中使用的 CRC 校验编程实现。具体实现时,以字节为单位进行计算。const CRC_32 crc_32 = { 0x04c11db7,0xffffffff,0xffffffff,TRUE,TRUE };
数据 1 字节 = 8 bit
- 预置 1 个 32 位的变量 CRC,存放校验值,首先赋初值
0xffffffff
; - 查看规则判断第一个数据是否需要反转,若需要,则按反转,若不需要,直接进入第3步。这里需要反转;
- 把第1个数据按照步骤2处理后,与 32 位的变量CRC的高8位相异或,把结果放于变量CRC,低 24 位数据不变;
- 检查左移后的移出位;
- 如果移出位为 0,左移一位;如果移出位为 1,变量 CRC 左移一位后与多项式 0x04c11db7 进行异或;
- 重复步骤4和5,直到左移 8 次(和数据长度一致),这样整个 8 位数据全部进行了处理;
- 重复步骤2到步骤6,进行通讯信息帧下一个数据(字节)的处理;
- 将该通讯信息帧所有字节按上述步骤计算完成后,将得到的 32 位变量CRC,查看规则是否需要反转,这里需要反转;
- 最后,与结果异或值异或,得到的变量 CRC 即为 CRC 校验值;
这里有两种方法实现,基础都是一样的,分为:按位计算法、查表法
2.2、按位计算法
数据虽然是一个一个读取的,但是每次都会移位 8 bit,即下一位刚好在上一位移出时接上下一位,而且异或 8 次,结果还是本身,所以无论是拼接到一起还是 8 bit 计算结果是一样的。
就是根据规则一遍又一遍的计算得到 CRC 即可。
分为正向计算和反向计算,正向计算容易理解,反向计算仔细想想,结果是一样的
#include <stdio.h>
#include <malloc.h>
typedef unsigned char u8;
typedef unsigned short u16;
typedef unsigned int u32;
typedef unsigned long u64;
//方法一
u8 reverse8(u8 data) {
u8 i;
u8 temp = 0;
for (i = 0; i < 8; i++) // 8 bit反转
temp |= ((data >> i) & 0x01) << (7 - i);
return temp;
}
u32 reverse32(u32 data) {
u8 i;
u32 temp = 0;
for (i = 0; i < 32; i++) // 32 bit反转
temp |= ((data >> i) & 0x01) << (31 - i);
return temp;
}
//正向计算
u32 crc32(u8* addr, int num) {
u8 data;
u32 crc = 0xffffffff; //初始值
int i;
for (; num > 0; num--) {
data = *addr++;
data = reverse8(data); //字节反转
crc = crc ^ (data << 24); //与crc初始值高8位异或
for (i = 0; i < 8; i++) { //循环8位
if (crc & 0x80000000) //左移移出的位为1,左移后与多项式异或
crc = (crc << 1) ^ 0x04c11db7;
else {
crc <<= 1; //否则直接左移
}
}
}
crc = reverse32(crc); //字节反转
crc = crc ^ 0xffffffff; //最后返与结果异或值异或
return crc; //返回最终校验值
}
//反向计算
u32 crc32_reverse(u8 array[], int len) {
u8 data;
u32 crc = 0xffffffff;
for (int i = 0; i < len; i++) {
data = array[i];
crc = crc ^ data;
for (int bit = 0; bit < 8; bit++) { //循环8位
if (crc & 0x00000001) //右移移出的位为1,右移后与多项式异或
crc = (crc >> 1) ^ 0xedb88320;
else {
crc >>= 1; //否则直接右移
}
}
}
return crc ^ 0xffffffff;
}
int main() {
//data 是要CRC计算的数据都是 16 bit 的 16 进制
u8 data[] = {
0x51, 0x51, 0x51, 0x51, 0x51, 0x51,
0x51, 0x51, 0x51, 0x51, 0x51, 0x51,
0x51, 0x51, 0x51, 0x51, 0x51, 0x51
};
//方法一
int data_len = sizeof(data) / sizeof(data[0]);
u32 crc = crc32(data, data_len);
printf("crc 检验和为:%x\n", crc);
u32 crc_reverse = crc32_reverse(data, data_len);
printf("crc_reverse 检验和为:%x\n", crc_reverse);
return 0;
}
2.3、查表法(如果想弄其他表字节对应修改异或值、是否反转、输入输出前异或值即可)
如果每次像上面这样计算,当数据很长时性能就不好,每个都要从头计算一边,所以就有了查表法,因为都是 8 bit 数据,那么 28 最多只有 256 种情况,所以先计算出这张表。(正向表和方向表不一样,所以网上不一样,但是结果一样)
就是把第二个循环(中间那个循环)单独列出来,计算得到对应表。
#include <stdio.h>
#include <malloc.h>
typedef unsigned char u8;
typedef unsigned short u16;
typedef unsigned int u32;
typedef unsigned long u64;
//方法二:查表法
//生成正向计算表
u32 crc32_table[256];
void create_crc32_table() {
u32 c;
for (int i = 0; i < 256; i++) {
c = (u32)i << 24;
for (int bit = 0; bit < 8; bit++) {
if (c & 0x80000000) {
c = (c << 1) ^ 0x04c11db7; //CRC32正向二进制
}
else {
c = c << 1;
}
}
crc32_table[i] = c;
}
}
u32 use_crc32_table(u8 array[], int len) {
u8 data;
u32 crc = 0xffffffff; //初始值
for (int i = 0; i < len; i++) {
data = reverse8(array[i]); //是否反转
u8 n = (crc >> 24) ^ data;
crc = crc32_table[n] ^ (crc << 8);
}
crc = reverse32(crc); //是否反转
return crc ^ 0xffffffff; //输出前异或
}
//生成反向计算表
u32 crc32_table_reverse[256];
void create_crc32_table_reverse() {
u32 c;
for (int i = 0; i < 256; i++) {
c = (u32)i;
for (int bit = 0; bit < 8; bit++) {
if (c & 1) {
c = (c >> 1) ^ (0xedb88320);
}
else {
c = c >> 1;
}
}
crc32_table_reverse[i] = c;
}
}
u32 use_crc32_table_reverse(u8 array[], int len) {
u32 crc = 0xffffffff;
for (int i = 0; i < len; i++) {
crc = crc32_table_reverse[(crc ^ array[i]) & 0xff] ^ (crc >> 8);
}
return crc ^ 0xffffffff;
}
int main() {
//data 是要CRC计算的数据都是 16 bit 的 16 进制
u8 data[] = {
0x51, 0x51, 0x51, 0x51, 0x51, 0x51,
0x51, 0x51, 0x51, 0x51, 0x51, 0x51,
0x51, 0x51, 0x51, 0x51, 0x51, 0x51
};
//方法二
create_crc32_table();
u32 crc32 = use_crc32_table(data, data_len);
printf("crc_table 检验和为:%x\n", crc32);
create_crc32_table_reverse();
u32 crc32_reverse = use_crc32_table_reverse(data, data_len);
printf("crc_table_reverse 检验和为:%x\n", crc32_reverse);
// for (int i = 0; i < 32; i++) {
// for (int j = 0; j < 8; j++) {
// printf("%8x ", crc32_table[i * 8 + j]);
// }
// printf("\n");
// }
// for (int i = 0; i < 32; i++) {
// for (int j = 0; j < 8; j++) {
// printf("%8x ", crc32_table_reverse[i * 8 + j]);
// }
// printf("\n");
// }
return 0;
}
2.4、结果及CRC32生成表
2.4.1、结果
2.4.2、正向 CRC32 生成表
2.4.3、反向 CRC32 生成表
3、通用正向按位计算代码
记录了常见的 CRC8/16/32 多项式及规则:https://crccalc.com/
http://www.ip33.com/crc.html
3.1、各种声明——type.h
#ifndef __TYPE_H__
#define __TYPE_H__
#include <stdio.h>
/*****************************************************************************
*function: 自定义字符长度
*type : 8 bit/ 16 bit/ 32 bit/ 64 bit
******************************************************************************/
typedef unsigned char u8;
typedef unsigned short u16;
typedef unsigned int u32;
typedef unsigned long u64;
#define FALSE 0
#define TRUE 1
#endif
3.2、函数接口定义和CRC 参数模型定义——crc.h
#ifndef __CRC_H__
#define __CRC_H__
#include "type.h"
/*****************************************************************************
*function: 定义结构体
*type : 8
******************************************************************************/
typedef struct {
u8 Poly; //多项式
u8 InitValue; //初始值
u8 XorOut; //结果异或值
bool InputReverse; //(是否)输入反转
bool OutputReverse; //(是否)输出反转
}CRC_8;
/*****************************************************************************
*function: 定义结构体
*type : 16
******************************************************************************/
typedef struct {
u16 Poly; //多项式
u16 InitValue; //初始值
u16 XorOut; //结果异或值
bool InputReverse; //(是否)输入反转
bool OutputReverse; //(是否)输出反转
}CRC_16;
/*****************************************************************************
*function: 定义各种类型的结构体
*type : 32
******************************************************************************/
typedef struct {
u32 Poly; //多项式
u32 InitValue; //初始值
u32 XorOut; //结果异或值
bool InputReverse; //(是否)输入反转
bool OutputReverse; //(是否)输出反转
}CRC_32;
/*****************************************************************************
*function: 常见 CRC 参数模型(16进制)
*first : 多项式
*second : 初始值
*third : 结果异或值
*fourth : 输入反转
*fifth : 输出反转
******************************************************************************/
const CRC_8 crc_8 = { 0x07,0x00,0x00,FALSE,FALSE };
const CRC_8 crc_8_ITU = { 0x07,0x00,0x55,FALSE,FALSE };
const CRC_8 crc_8_EBU = { 0x1D,0xff,0x00,FALSE,FALSE };
const CRC_8 crc_8_ROHC = { 0x07,0xff,0x00,TRUE,TRUE };
const CRC_8 crc_8_DARC = { 0x39,0x00,0x00,TRUE,TRUE };
const CRC_8 crc_8_WCDMA = { 0x9B,0x00,0x00,TRUE,TRUE };
const CRC_8 crc_8_MAXIM = { 0x31,0x00,0x00,TRUE,TRUE };
const CRC_8 crc_8_DVB_S2 = { 0xD5,0x00,0x00,FALSE,FALSE };
const CRC_8 crc_8_I_CODE = { 0x1D,0xFD,0x00,FALSE,FALSE };
const CRC_8 crc_8_CDMA2000 = { 0x9B,0xff,0x00,FALSE,FALSE };
const CRC_16 crc_16_A = { 0x1021,0xC6C6,0x0000,TRUE ,TRUE };
const CRC_16 crc_16_X5 = { 0x1021,0xffff,0xffff,TRUE,TRUE };
const CRC_16 crc_16_IBM = { 0x8005,0x0000,0x0000,TRUE,TRUE };
const CRC_16 crc_16_DNP = { 0x3d65,0x0000,0xffff,TRUE,TRUE };
const CRC_16 crc_16_ARC = { 0x8005,0x0000,0x0000,TRUE,TRUE };
const CRC_16 crc_16_USB = { 0x8005,0xffff,0xffff,TRUE,TRUE };
const CRC_16 crc_16_MAXIM = { 0x8005,0x0000,0xffff,TRUE,TRUE };
const CRC_16 crc_16_CCITT = { 0x1021,0x0000,0x0000,TRUE,TRUE };
const CRC_16 crc_16_MODBUS = { 0x8005,0xffff,0x0000,TRUE,TRUE };
const CRC_16 crc_16_RIELLO = { 0x1021,0xB2AA,0x0000,TRUE,TRUE };
const CRC_16 crc_16_KERMIT = { 0x1021,0x0000,0x0000,TRUE ,TRUE };
const CRC_16 crc_16_XMODEM = { 0x1021,0x0000,0x0000,FALSE,FALSE };
const CRC_16 crc_16_DECT_R = { 0x0589,0x0000,0x0001,FALSE,FALSE };
const CRC_16 crc_16_DECT_X = { 0x0589,0x0000,0x0000,FALSE,FALSE };
const CRC_16 crc_16_DDS110 = { 0xC867,0xFFFF,0x0000,FALSE,FALSE };
const CRC_16 crc_16_MCRF4XX = { 0x1021,0xFFFF,0x0000,TRUE,TRUE };
const CRC_16 crc_16_GENIBUS = { 0x1021,0xFFFF,0xFFFF,FALSE,FALSE };
const CRC_16 crc_16_T10_DIF = { 0x8BB7,0x0000,0x0000,FALSE,FALSE };
const CRC_16 crc_16_BUYPASS = { 0x8005,0x0000,0x0000,FALSE,FALSE };
const CRC_16 crc_16_EN13757 = { 0x3D65,0x0000,0xFFFF,FALSE,FALSE };
const CRC_16 crc_16_TMS37157 = { 0x1021,0x89EC,0x0000,TRUE ,TRUE };
const CRC_16 crc_16_CDMA2000 = { 0xC867,0xFFFF,0x0000,FALSE,FALSE };
const CRC_16 crc_16_TELEDISK = { 0xA097,0x0000,0x0000,FALSE,FALSE };
const CRC_16 crc_16_AUG_CCITT = { 0x1021,0x1D0F,0x0000,FALSE,FALSE };
const CRC_16 crc_16_CCITT_FALSE = { 0x1021,0xffff,0x0000,FALSE,FALSE };
const CRC_32 crc_32 = { 0x04c11db7,0xffffffff,0xffffffff,TRUE,TRUE };
const CRC_32 crc_32_C = { 0x1EDC6F41,0xFFFFFFFF,0xFFFFFFFF,TRUE,TRUE };
const CRC_32 crc_32_D = { 0xA833982B,0xFFFFFFFF,0xFFFFFFFF,TRUE,TRUE };
const CRC_32 crc_32_Q = { 0x814141AB,0x00000000,0x00000000,FALSE,FALSE };
const CRC_32 crc_32_XFER = { 0x000000AF,0x00000000,0x00000000,FALSE,FALSE };
const CRC_32 crc_32_MPEG2 = { 0x04c11db7,0xffffffff,0x00000000,FALSE,FALSE };
const CRC_32 crc_32_BZIP2 = { 0x04C11DB7,0xFFFFFFFF,0xFFFFFFFF,FALSE,FALSE };
const CRC_32 crc_32_POSIX = { 0x04C11DB7,0x00000000 ,0xFFFFFFFF,FALSE,FALSE };
const CRC_32 crc_32_JAMCRC = { 0x04C11DB7,0xFFFFFFFF,0x00000000,TRUE,TRUE };
/*****************************************************************************
*function: CRC 函数声明
*type : crc8/crc16/crc32
******************************************************************************/
u8 crc8(u8* addr, int num, CRC_8 type);
u16 crc16(u8* addr, int num, CRC_16 type);
u32 crc32(u8* addr, int num, CRC_32 type);
/*****************************************************************************
*function: 反转函数声明
*type : reverse8/reverse16/reverse32
******************************************************************************/
u8 reverse8(u8 data);
u16 reverse16(u16 data);
u32 reverse32(u32 data);
#endif
3.3、函数实现——crc.cpp
#include <stdio.h>
#include "type.h"
#include "CRC.h"
/*****************************************************************************
*function name:reverse8
*function: 字节反转,如1100 0101 反转后为1010 0011
*input:1字节
*output:反转后字节
******************************************************************************/
u8 reverse8(u8 data)
{
u8 i;
u8 temp = 0;
for (i = 0; i < 8; i++) //字节反转
temp |= ((data >> i) & 0x01) << (7 - i);
return temp;
}
/*****************************************************************************
*function name:reverse16
*function: 双字节反转,如1100 0101 1110 0101反转后为1010 0111 1010 0011
*input:双字节
*output:反转后双字节
******************************************************************************/
u16 reverse16(u16 data)
{
u8 i;
u16 temp = 0;
for (i = 0; i < 16; i++) //反转
temp |= ((data >> i) & 0x0001) << (15 - i);
return temp;
}
/*****************************************************************************
*function name:reverse32
*function: 32bit字反转
*input:32bit字
*output:反转后32bit字
******************************************************************************/
u32 reverse32(u32 data)
{
u8 i;
u32 temp = 0;
for (i = 0; i < 32; i++) //反转
temp |= ((data >> i) & 0x01) << (31 - i);
return temp;
}
/*****************************************************************************
*function name:crc8
*function: CRC校验,校验值为8位
*input:addr-数据首地址;num-数据长度(字节);type-CRC8的算法类型
*output:8位校验值
******************************************************************************/
u8 crc8(u8* addr, int num, CRC_8 type)
{
u8 data;
u8 crc = type.InitValue; //初始值
int i;
for (; num > 0; num--)
{
data = *addr++;
if (type.InputReverse == TRUE)
data = reverse8(data); //字节反转
crc = crc ^ data; //与crc初始值异或
for (i = 0; i < 8; i++) //循环8位
{
if (crc & 0x80) //左移移出的位为1,左移后与多项式异或
crc = (crc << 1) ^ type.Poly;
else //否则直接左移
crc <<= 1;
}
}
if (type.OutputReverse == TRUE) //满足条件,反转
crc = reverse8(crc);
crc = crc ^ type.XorOut; //最后返与结果异或值异或
return(crc); //返回最终校验值
}
/*****************************************************************************
*function name:crc16
*function: CRC校验,校验值为16位
*input:addr-数据首地址;num-数据长度(字节);type-CRC16的算法类型
*output:16位校验值
******************************************************************************/
u16 crc16(u8* addr, int num, CRC_16 type)
{
u8 data;
u16 crc = type.InitValue; //初始值
int i;
for (; num > 0; num--)
{
data = *addr++;
if (type.InputReverse == TRUE)
data = reverse8(data); //字节反转
crc = crc ^ (data << 8); //与crc初始值高8位异或
for (i = 0; i < 8; i++) //循环8位
{
if (crc & 0x8000) //左移移出的位为1,左移后与多项式异或
crc = (crc << 1) ^ type.Poly;
else //否则直接左移
crc <<= 1;
}
}
if (type.OutputReverse == TRUE) //满足条件,反转
crc = reverse16(crc);
crc = crc ^ type.XorOut; //最后返与结果异或值异或
return(crc); //返回最终校验值
}
/*****************************************************************************
*function name:crc32
*function: CRC校验,校验值为32位
*input:addr-数据首地址;num-数据长度(字节);type-CRC32的算法类型
*output:32位校验值
******************************************************************************/
u32 crc32(u8* addr, int num, CRC_32 type)
{
u8 data;
u32 crc = type.InitValue; //初始值
int i;
for (; num > 0; num--)
{
data = *addr++;
if (type.InputReverse == TRUE)
data = reverse8(data); //字节反转
crc = crc ^ (data << 24); //与crc初始值高8位异或
for (i = 0; i < 8; i++) //循环8位
{
if (crc & 0x80000000) //左移移出的位为1,左移后与多项式异或
crc = (crc << 1) ^ type.Poly;
else //否则直接左移
crc <<= 1;
}
}
if (type.OutputReverse == TRUE) //满足条件,反转
crc = reverse32(crc);
crc = crc ^ type.XorOut; //最后返与结果异或值异或
return(crc); //返回最终校验值
}
3.4、主函数调用——main.cpp
#include <stdio.h>
#include <malloc.h>
#include "type.h"
#include "CRC.h"
/*****************************************************************************
*explain: 8 bit数据
******************************************************************************/
u8 data[] = {
0x51, 0x51, 0x51, 0x51, 0x51, 0x51,
0x51, 0x51, 0x51, 0x51, 0x51, 0x51,
0x51, 0x51, 0x51, 0x51, 0x51, 0x51
};
int main() {
int len = sizeof(data) / sizeof(data[0]);
u32 crc = crc32(data, len, crc_32);
printf("%x\n", crc);
return 0;
}
4、参考
1、循环冗余检验 (CRC) 算法原理
https://www.cnblogs.com/esestt/archive/2007/08/09/848856.html
2、CRC校验详解(附代码示例)
https://blog.csdn.net/u013073067/article/details/86621770
来源:CSDN
作者:有人_295
链接:https://blog.csdn.net/weixin_42109012/article/details/103467566