基于CH582M实现CRC-16校验的串口/RS485协议
文章目录
- 一、 核心逻辑
- 1.核心算法:CRC-16 的 C 语言实现与逻辑拆解
- 2. 发送端(主机)逻辑:“算余数,贴封条”
- 3. 接收端(从机)逻辑:“掐头去尾,重新计算”
- 4. 极简自定义协议帧结构
- 二、 【发送端】核心逻辑与完整代码(主控机)
- 1. 发送端逻辑:“算余数,贴封条”
- 2. 发送端完整 `Main.c`
- 三、 【接收端】核心逻辑与完整代码(从机/充电板)
- 1. 接收端逻辑:“掐头去尾,重新计算”
- 2. 接收端完整 Main.c
一、 核心逻辑
1.核心算法:CRC-16 的 C 语言实现与逻辑拆解
无论是发送端还是接收端,生成和校验防伪标签的核心都是下面这个函数。很多人看到按位异或(^=)和移位(>>=)就头疼,其实它的逻辑非常规律:
// ==========================================// 【基础算法】:标准 Modbus CRC-16 计算// ==========================================uint16_tCalc_CRC16(uint8_t*pBuf,uint8_tlen){uint16_tcrc=0xFFFF;// 1. 预置 16 位寄存器为全 1for(uint8_ti=0;i<len;i++){crc^=pBuf[i];// 2. 把新拉进来的字节与 CRC 寄存器进行异或for(uint8_tj=0;j<8;j++)// 3. 循环 8 次,处理这一个字节的 8 个位{if(crc&0x0001){// 判断最低位是否为 1 (是否够除)crc>>=1;// 先将整体向右移 1 位crc^=0xA001;// 再与工业多项式 0xA001 进行异或}else{crc>>=1;// 最低位为 0,不够除,直接右移跳过}}}returncrc;}代码通俗解析:
为什么初始值是 0xFFFF?
工业界为了防止全零数据段导致校验失效,强制在开始计算前给寄存器“垫”一个全 1 的底(类似电子秤的预先去皮)。
为什么要判断 crc & 0x0001 并全部向右移(>>= 1)?
正常的数学除法是从左往右算的。但在真实的单片机串口(UART / RS485)通信中,硬件规则是低位先行(LSB First)。最右边的位会最先发到线缆上。所以代码必须顺应硬件脾气,盯紧最右边(最低位),并不断把数据往右边推。
0xA001 是什么?
它是国际标准 CRC-16 的生成多项式在十六进制下反转过来的“暗号”。当最低位为 1 时,说明遇到了硬骨头,就拿这个暗号去做一次异或运算(相当于除法里的做减法)。
2. 发送端(主机)逻辑:“算余数,贴封条”
- 主机首先准备好要发送的核心数据(如:包头 + 地址 + 功能码)。
- 将这串核心数据丢进 CRC-16 算法中,计算出一个 16位(2字节)的校验码(余数)。
- 把这 2 个字节的校验码(通常是低位在前,高位在后)拼接在核心数据的尾部。
- 加上包尾,一并发送出串口。
3. 接收端(从机)逻辑:“掐头去尾,重新计算”
- 从机通过串口接收到一整帧完整的数据。
- 检查长度、包头、包尾是否合法。
- 关键点:从机提取出核心数据部分(不含接收到的 CRC 和包尾),用一模一样的 CRC 算法自己重新算一遍。
- 将自己算出的 CRC 与数据帧里带过来的 CRC 进行比对。相等则执行动作,不等则直接丢弃(说明数据在传输路上被干扰了,绝不瞎回复)。
4. 极简自定义协议帧结构
本例不使用冗长复杂的标准 Modbus 寄存器读写规则,而是针对具体动作(如控制继电器启停)设计了以下 6 字节极简自定义协议:
| 名称 | 字节数 | 示例 | 说明 |
|---|---|---|---|
| 包头 | 1 | 0xAA | 固定起始符 |
| 地址 | 1 | 0x01 | 目标设备地址 |
| 功能码 | 1 | 0x01 | 0x01:开启充电,0x02:结束充电 |
| CRC低位 | 1 | 0xXX | CRC校验码的低 8 位 |
| CRC高位 | 1 | 0xXX | CRC校验码的高 8 位 |
| 包尾 | 1 | 0x55 | 固定结束符 |
二、 【发送端】核心逻辑与完整代码(主控机)
1. 发送端逻辑:“算余数,贴封条”
主机的任务非常单纯,不需要处理复杂的中断,只需像打包员一样按规矩发货:
- 准备好要发送的核心数据(包头
AA+ 目标地址01+ 功能码01)。 - 将这串核心数据丢进 CRC-16 算法中,计算出一个 16位(2字节)的校验码。
- 把这 2 个字节的校验码(低位在前,高位在后)拼接在核心数据的尾部。
- 加上包尾
55,一并轰出串口。
2. 发送端完整Main.c
将以下代码放入你的主机工程中,直接调用Send_Charge_Command()函数即可指哪打哪。
#include"CH58x_common.h"// ==========================================// 【基础算法】:标准 Modbus CRC-16 计算// ==========================================uint16_tCalc_CRC16(uint8_t*pBuf,uint8_tlen){uint16_tcrc=0xFFFF;for(uint8_ti=0;i<len;i++){crc^=pBuf[i];for(uint8_tj=0;j<8;j++){// 注意:串口是低位先行,所以统一右移if(crc&0x0001){crc>>=1;crc^=0xA001;}else{crc>>=1;}}}returncrc;}// ==========================================// 【发送动作】:打包并发送指令// ==========================================voidSend_Charge_Command(uint8_ttarget_addr,uint8_tcmd){uint8_tsend_buf[10];uint8_tlen=0;// 1. 组装数据前半截send_buf[0]=0xAA;// 包头send_buf[1]=target_addr;// 目标地址send_buf[2]=cmd;// 功能码len=3;// 2. 召唤 CRC 算法,生成防伪标签uint16_tmy_crc=Calc_CRC16(send_buf,len);// 3. 将 CRC 附加在数据后 (小端模式:低位在前,高位在后)send_buf[len]=my_crc&0xFF;send_buf[len+1]=(my_crc>>8)&0xFF;// 4. 封上包尾send_buf[len+2]=0x55;// 5. 串口发送 (总长6字节)UART2_SendString(send_buf,6);}// ==========================================// 【主机主函数】// ==========================================intmain(){SetSysClock(CLK_SOURCE_PLL_60MHz);/* --- 串口2硬件初始化 --- */GPIOPinRemap(ENABLE,RB_PIN_UART2);GPIOB_SetBits(GPIO_Pin_23);GPIOB_ModeCfg(GPIO_Pin_22,GPIO_ModeIN_PU);GPIOB_ModeCfg(GPIO_Pin_23,GPIO_ModeOut_PP_5mA);UART2_DefInit();UART2_BaudRateCfg(9600);while(1){// 测试:每隔5秒向 0x01 地址发送开启(0x01)指令mDelaymS(5000);Send_Charge_Command(0x01,0x01);}}三、 【接收端】核心逻辑与完整代码(从机/充电板)
1. 接收端逻辑:“掐头去尾,重新计算”
接收端处于被动状态,它的核心任务是“防伪对账”,绝不能瞎回复:
通过串口中断(结合超时机制)接收一整帧数据。
检查数据总长度(至少6字节),并核对包头和包尾。
关键点:提取出核心数据部分(不含接收到的 CRC 和包尾),用一模一样的 CRC 算法自己重新算一遍。
将单片机自己算的 CRC,与发过来的 CRC 进行比对。相等则执行动作并回复,不等则直接丢弃数据(说明在路上被干扰了)。
2. 接收端完整 Main.c
将以下代码放入你的从机(充电板)工程中。
#include"CH58x_common.h"uint8_tRxBuff[100];volatileuint8_trecv_len=0;volatileuint8_trecv_flag=0;uint8_ttrigB;// ==========================================// 【基础算法】:标准 Modbus CRC-16 计算// ==========================================uint16_tCalc_CRC16(uint8_t*pBuf,uint8_tlen){uint16_tcrc=0xFFFF;for(uint8_ti=0;i<len;i++){crc^=pBuf[i];for(uint8_tj=0;j<8;j++){if(crc&0x0001){crc>>=1;crc^=0xA001;}else{crc>>=1;}}}returncrc;}// ==========================================// 【从机主函数:业务解析大脑】// ==========================================intmain(){SetSysClock(CLK_SOURCE_PLL_60MHz);/* --- 串口2硬件初始化 --- */GPIOPinRemap(ENABLE,RB_PIN_UART2);GPIOB_SetBits(GPIO_Pin_23);GPIOB_ModeCfg(GPIO_Pin_22,GPIO_ModeIN_PU);GPIOB_ModeCfg(GPIO_Pin_23,GPIO_ModeOut_PP_5mA);UART2_DefInit();UART2_BaudRateCfg(9600);/* --- 开启接收超时中断 --- */UART2_ByteTrigCfg(UART_7BYTE_TRIG);trigB=7;UART2_INTCfg(ENABLE,RB_IER_RECV_RDY|RB_IER_LINE_STAT);PFIC_EnableIRQ(UART2_IRQn);while(1){if(recv_flag==1){// 1. 长度校验 (带2字节CRC,协议最短6字节)if(recv_len>=6){// 2. 包头包尾特征字校验if(RxBuff[0]==0xAA&&RxBuff[recv_len-1]==0x55){// 3. 计算本地 CRC (recv_len - 3 代表仅取核心数据)uint16_tmy_crc=Calc_CRC16(RxBuff,recv_len-3);// 提取主机发来的 CRCuint16_trecv_crc=RxBuff[recv_len-3]|(RxBuff[recv_len-2]<<8);// 4. 核心对账!if(my_crc==recv_crc){// --- 校验通过,提取控制指令 ---uint8_tdev_addr=RxBuff[1];uint8_tfunc_cmd=RxBuff[2];uint8_treply_buf[20];uint8_treply_len=0;reply_buf[0]=0xBB;reply_buf[1]=dev_addr;reply_buf[2]=func_cmd;switch(func_cmd){case0x01:// 收到开启指令// TODO: 加入 GPIO 驱动继电器闭合的代码reply_buf[3]=0x01;reply_len=7;break;case0x02:// 收到结束指令// TODO: 加入 GPIO 驱动继电器断开的代码reply_buf[3]=0x01;reply_len=7;break;default:reply_len=0;break;}// --- 给主机的应答数据也贴上 CRC 封条 ---if(reply_len>0){uint16_tsend_crc=Calc_CRC16(reply_buf,reply_len-3);reply_buf[reply_len-3]=send_crc&0xFF;reply_buf[reply_len-2]=(send_crc>>8)&0xFF;reply_buf[reply_len-1]=0x55;UART2_SendString(reply_buf,reply_len);}}}}// 无论对错,强制清空接收状态,避免死锁recv_len=0;recv_flag=0;}}}// ==========================================// 【底层苦力:串口中断服务函数】// ==========================================__INTERRUPT __HIGH_CODEvoidUART2_IRQHandler(void){volatileuint8_ti;switch(UART2_GetITFlag()){caseUART_II_RECV_RDY:// 接收达到触发点for(i=0;i!=trigB;i++){RxBuff[recv_len++]=UART2_RecvByte();}break;caseUART_II_RECV_TOUT:// 接收超时(断包,一帧接收完毕)i=UART2_RecvString(&RxBuff[recv_len]);recv_len+=i;recv_flag=1;// 通知主循环开始解析break;}}