CAN收发代码优化(1)

Tutorial: 卓越工程师 Category: 嵌入式 Published: 2026-10-23 11:05:29 Views: 0 Likes: 0 Comments: 0

185.1.8 CAN收发代码优化(1)

/* Canif/canif.h:本课新建的 CAN 业务接口头文件。 */
#ifndef __CANIF_H__ /* 如果还没有定义 __CANIF_H__,就编译下面的内容,防止头文件被重复包含。 */
#define __CANIF_H__ /* 定义头文件保护宏;它要和上面的判断宏保持一致。 */
#include "main.h" /* 引入工程的基础声明,使 uint32_t、uint8_t 和 Error_Handler() 等名称可用。 */

void Can_Filter_Init(void); /* 声明 CAN 过滤器配置与 CAN 启动函数。 */
uint32_t Can_Send_Msg(uint32_t id, uint32_t len,uint8_t *buf); /* 声明发送封装:传入标准 ID、长度和数据首地址。 */
uint32_t Can_Recv_Msg(uint32_t *id, uint8_t *buf); /* 声明接收封装:通过指针带回 ID 和数据,并用返回值带回 DLC。 */

#endif /* 结束 __CANIF_H__ 头文件保护范围。 */

/* Canif/canif.c:本课从 main.c 拆出的 CAN 业务代码。 */
#include "canif.h" /* 引入本模块公开的函数声明,并让定义与声明保持一致。 */
#include "can.h" /* 引入 hcan、CAN 结构体、宏和 HAL CAN 函数声明。 */
#include "usart.h" /* 引入本工程重定义的 printf 宏,用 USART1 输出调试信息。 */

void Can_Filter_Init(void) /* 定义 CAN 过滤器初始化函数;本课同时把 CAN 启动放入此函数。 */
{
	CAN_FilterTypeDef Can_FilterConfig; /* 定义局部过滤器配置结构体。 */

	Can_FilterConfig.FilterBank = 0; /* 使用过滤器组 0。 */
	Can_FilterConfig.FilterMode = CAN_FILTERMODE_IDMASK; /* 选择“标识符 + 掩码”过滤模式。 */
	Can_FilterConfig.FilterScale = CAN_FILTERSCALE_16BIT; /* 选择 16 位过滤器尺度。 */
	Can_FilterConfig.FilterIdHigh = 0x0; /* 将第一个 16 位标识符配置为 0。 */
	Can_FilterConfig.FilterIdLow = 0x0; /* 将第二个 16 位标识符配置为 0。 */
	Can_FilterConfig.FilterMaskIdHigh = 0x0; /* 高 16 位掩码全为 0,即相应位均不参与匹配。 */
	Can_FilterConfig.FilterMaskIdLow = 0x0; /* 低 16 位掩码全为 0,与上一行共同形成“全部接收”。 */
	Can_FilterConfig.FilterFIFOAssignment = CAN_FILTER_FIFO0; /* 把通过该过滤器的报文送入接收 FIFO0。 */
	Can_FilterConfig.FilterActivation = CAN_FILTER_ENABLE; /* 启用过滤器组 0。 */
	Can_FilterConfig.SlaveStartFilterBank = 14; /* 设置从 CAN 的起始过滤器组;本单 CAN 工程中该项没有实际作用。 */

	if (HAL_CAN_ConfigFilter(&hcan, &Can_FilterConfig) != HAL_OK) /* 把上述配置写入 CAN;失败时进入错误处理。 */
	{
		Error_Handler(); /* 配置失败后停在工程统一的错误处理函数中。 */
	}

	HAL_CAN_Start(&hcan); /* 启动 CAN 外设;课堂代码没有检查该函数的返回值。 */
}

uint32_t Can_Send_Msg(uint32_t id, uint32_t len,uint8_t *buf) /* 定义发送封装,参数由 main.c 传入。 */
{
	CAN_TxHeaderTypeDef TxMessage; /* 定义局部发送帧头结构体。 */
	uint32_t Tx_Mail = CAN_TX_MAILBOX0; /* 定义邮箱结果变量;HAL 随后会把实际选中的邮箱写回这里。 */
	TxMessage.StdId = id; /* 使用调用者传入的 11 位标准标识符。 */
	TxMessage.IDE = CAN_ID_STD; /* 固定发送标准帧,因此本函数不接收 IDE 参数。 */
	TxMessage.DLC = len; /* 使用调用者传入的数据长度;经典 CAN 的合法范围为 0~8。 */
	TxMessage.RTR  = CAN_RTR_DATA; /* 固定发送数据帧,而不是远程帧。 */

	HAL_CAN_AddTxMessage(&hcan, &TxMessage,buf, &Tx_Mail); /* 把帧头和数据请求加入一个空闲发送邮箱。 */
	printf("Send ok!\r\n"); /* 课堂代码无条件打印成功;此时尚未根据 HAL 返回值判断结果。 */
}

uint32_t Can_Recv_Msg(uint32_t *id, uint8_t *buf) /* 定义接收封装:id 和 buf 都用于把结果带回调用者。 */
{
	CAN_RxHeaderTypeDef RxMessage; /* 定义局部接收帧头结构体,用于承接标准 ID 和 DLC 等字段。 */

	HAL_CAN_GetRxMessage(&hcan, CAN_FILTER_FIFO0,&RxMessage, buf); /* 尝试从 FIFO0 取出一帧;本课还没有检查返回值。 */

	*id = RxMessage.StdId; /* 解引用 id,把接收帧的标准标识符写回 main.c 中的变量。 */

	return RxMessage.DLC; /* 把接收帧 DLC 作为函数返回值交给 main.c。 */
}

/* main.c:以下只收录本课涉及的用户代码,不重复 CubeMX 生成的系统初始化代码。 */
#include "string.h" /* 声明上一课已经使用的 memset()。 */
#include "canif.h" /* 引入本课新增的三个 CAN 封装函数声明。 */

uint8_t Tx_Data[]={7,8,5,24,11,2,13,9}; /* 保留上一课的 8 字节发送数据。 */
uint8_t Rx_Data[8]; /* 保留上一课的 8 字节接收缓冲区。 */

Can_Filter_Init(); /* 代替 main.c 中原来的过滤器配置代码和 HAL_CAN_Start() 调用。 */

#if 1 /* 本课讲解接收封装时切换为 1,使接收分支参与编译。 */
	uint32_t Recv_ID = 0; /* 定义变量,用来接收 Can_Recv_Msg() 通过指针写回的标准 ID。 */
	uint32_t Recv_Len = Can_Recv_Msg(&Recv_ID, Rx_Data); /* 传入 ID 变量地址和接收缓冲区,并保存返回的 DLC。 */
	if(Recv_Len>0) /* DLC 大于 0 时,课堂代码把它当作收到数据并进入打印分支。 */
	{
		printf("recv id 0x%x:\r\n", Recv_ID); /* 以十六进制打印接收到的标准 ID。 */
		printf("recv length %u:\r\n", Recv_Len); /* 以无符号十进制打印 DLC。 */
		printf("recv:"); /* 输出数据行提示文字。 */
		for(int i=0;i<Recv_Len;i++) /* 按收到的 DLC 控制循环次数,不再固定循环 8 次。 */
			printf(" %u ",Rx_Data[i]); /* 逐字节打印接收缓冲区中的有效数据。 */
		printf("\r\n"); /* 一帧数据打印完成后回车换行。 */

		memset(Rx_Data, 0, 8); /* 打印完成后把 8 字节接收缓冲区清零。 */
	}else{ /* Recv_Len 不大于 0 时进入课堂设置的错误提示分支。 */
		printf("recv eorro\r\n"); /* 原课程源码把 error 拼成了 eorro,这里依照源码保留。 */
	}
#else /* 把条件改为 0 时编译发送分支。 */
	Can_Send_Msg(0x12, 8,Tx_Data); /* 发送标准 ID 0x12、DLC 8 和 Tx_Data 中的数据。 */
#endif /* 结束收发分支的条件编译。 */

源码范围说明:代码块按第 185 课结束时的讲解进度整理,只收录新建的 canif.c/.h 及 main.c 中受影响的业务代码。归档的 02Can_Test_Ok 已包含第 186 课新增的邮箱/FIFO 判断和发送返回值处理,这些后续语句没有提前抄入本课。课堂阶段代码中的无返回值、未检查 HAL 返回值、eorro 拼写等问题也未擅自改写,只在逐行注释和正文中指出。

一、代码优化

本课不是改变 CAN 位率、过滤规则或报文内容,而是把上一课堆在 main.c 中的代码拆成接口,使主函数只表达“初始化、发送、接收”这些业务动作。拆分之后的调用关系是:

main.c
  ├─ Can_Filter_Init()                配置过滤器并启动 CAN
  ├─ Can_Send_Msg(id, len, buf)       组帧并请求发送
  └─ Can_Recv_Msg(&id, buf)           读取帧,并带回 ID、DLC 和数据
              │
              └─ canif.c → STM32 HAL CAN 接口 → bxCAN 硬件

这里的“优化”主要是代码组织优化。函数是否真正可靠,还取决于是否检查 HAL 返回值、FIFO 状态和发送完成状态;本课先完成拆分,第 186 课再继续补这些机制。

1.1 复制工程

  • 先复制上一课的整个工程目录,是为了保留可运行的原始版本,并在副本上进行结构调整。这样新版本出现问题时,可以直接与旧版本对照。
  • 视频把副本目录改为 02Can_Test_Ok,但副本内部的 Keil 工程文件仍叫 01Can_Test.uvprojx,课程没有继续改内部名称。
  • 同时打开两个同名 Keil 工程时,应查看窗口标题或工程文件完整路径,不能只看 01Can_Test 这个名称判断当前修改的是哪一份。
  • 复制工程后还应留意相对路径、输出目录和用户代码是否都在副本内,避免新工程仍引用旧工程目录中的文件。

1.2 主函数优化

1.2.1 分成一个函数

原 main.c 同时承担过滤器配置、启动 CAN、构造发送帧、读取接收帧和打印数据等工作,代码细节很多。课程将相对独立的逻辑拆为三个函数:

函数本课职责main.c 中的效果
Can_Filter_Init()配置过滤器并调用 HAL_CAN_Start()多行初始化代码缩成一次调用
Can_Send_Msg()根据参数构造标准数据帧并调用 HAL 发送接口调用处只需提供 ID、长度和缓冲区
Can_Recv_Msg()调用 HAL 取帧,通过参数带回 ID 和数据,通过返回值带回 DLC调用处不必直接操作接收帧头结构体

封装的价值不只是“主函数看起来短了”,更重要的是形成清晰的职责边界:调用者说明想做什么,canif.c 负责怎样调用底层 CAN 驱动。以后改变发送检查或接收策略时,修改可以集中在接口模块中。

1.2.2 函数接口

课程先在 stm32f1xx_hal_can.h 底部查看了四个接口:

HAL 接口准确含义本课进度
HAL_CAN_AddTxMessage()选择一个空闲发送邮箱,写入帧头和数据并置位发送请求;返回 HAL_OK 只说明请求成功加入邮箱已用于发送封装,但尚未检查返回值
HAL_CAN_GetTxMailboxesFreeLevel()返回当前空闲发送邮箱数量,bxCAN 共三个发送邮箱,因此结果范围是 0~3本课只预览,第 186 课才加入代码
HAL_CAN_GetRxFifoFillLevel()返回指定接收 FIFO 中等待读取的报文数量本课只预览,第 186 课才加入代码
HAL_CAN_GetRxMessage()从指定 FIFO 读取一帧到帧头和数据缓冲区;FIFO 为空时,本项目 HAL 会返回 HAL_ERROR已用于接收封装,但尚未检查返回值

发送邮箱与接收 FIFO 的方向不能混淆:

程序准备报文 → 3 个发送邮箱 → CAN 总线
CAN 总线 → 过滤器 → FIFO0 / FIFO1 → 程序读取报文

“发送邮箱重新空闲”表示该邮箱不再有待发送请求,但严谨的软件还应结合 HAL_CAN_AddTxMessage() 返回值、发送完成/错误标志或回调判断结果,不能只靠一条无条件的 Send ok!。同理,接收端应先确认 FIFO 有报文,或至少检查 HAL_CAN_GetRxMessage() 是否返回 HAL_OK,再读取帧头和数据。

1.3 写代码前准备

1.3.1 新建文件夹

课程在工程副本的文件系统中创建 Canif 文件夹,用来保存 CAN 接口模块。这里的目录名表示 CAN interface;它与 Keil 工程树中随后建立的逻辑分组 Bsp 不是同一个概念:

  • Canif 是磁盘上的真实目录;
  • Bsp 是 Keil 工程树中的逻辑分组,本身不一定对应同名文件夹;
  • BSP 是 Board Support Package(板级支持包)的缩写,课程用它集中管理新增的板级/接口代码。
1.3.2 建立新文件

在 Canif 目录中新建:

Canif/
├─ canif.c    函数定义
└─ canif.h    公开声明

Windows 隐藏已知文件扩展名时,把文本文件重命名为 canif.c 可能实际得到 canif.c.txt。应在资源管理器中开启“文件扩展名”显示,并确认文件类型和完整名称。视频提到的课程常见问题文档中,相关内容位于“51 和 C 语言常见问题”的 5.2 节,而不是最初查找的 STM32 文档。

1.3.3 找到工程

应从刚复制的 02Can_Test_Ok 目录打开工程,再核对 Keil 窗口显示的完整路径。由于内部工程名没有修改,只看工程树名称容易误改上一课的 01Can_Test。

1.3.4 添加文件

课程中的配置分两步:

  1. 在 Keil 工程树中创建 Bsp 逻辑分组,通过 Add Existing Files to Group 把 Canif/canif.c 加入构建;
  2. 在 Options for Target → C/C++ → Include Paths 中加入 ..\Canif,让预处理器能够通过 #include "canif.h" 找到头文件。

只在磁盘上创建 .c 文件并不代表 Keil 会编译它;只在工程树中加入 .c 文件,也不代表编译器自动知道自定义头文件目录。二者缺一不可。归档工程的 01Can_Test.uvprojx 中可以看到 Bsp 分组、..\Canif\canif.c 和包含路径 ..\Canif,与视频操作一致。

1.4 封装函数

1.4.1 封装示例
1.4.1.1 封装代码示例

课程先封装 Can_Filter_Init():把上一课 main.c 中从 CAN_FilterTypeDef Can_FilterConfig; 到 HAL_CAN_Start(&hcan); 的整段代码移入 canif.c,再在原位置改为一次 Can_Filter_Init(); 调用。

该函数名虽然叫“过滤器初始化”,实际上完成两件事:

  1. 配置并启用一个掩码全为 0、接收全部报文的过滤器,把报文分配到 FIFO0;
  2. 启动 CAN 外设。

过滤器配置失败时会调用 Error_Handler();课堂代码没有检查 HAL_CAN_Start() 的返回值。因此,封装只改变代码位置,并没有自动增加错误处理。

1.4.1.2 封装头文件配置

三个头文件各有不同职责:

文件在本课中的作用
canif.h向 main.c 公开三个封装函数,并通过 main.h 获得基础类型和工程声明
can.h向 canif.c 提供 hcan、CAN 结构体、宏和 HAL 函数声明
usart.h提供工程自定义的 printf(...) 宏;该宏最终调用 HAL_UART_Transmit()

#ifndef → #define → #endif 是头文件保护。第一次包含 canif.h 时宏还没有定义,内容会参与编译;同一编译单元再次包含时,条件不成立,从而避免声明被反复展开。

函数必须“先声明、后使用”。main.c 包含 canif.h 后,编译器才能检查调用时的参数数量和类型;canif.c 自己也包含 canif.h,能够尽早发现函数定义与公开声明不一致的问题。

1.4.1.3 发送函数封装
1.4.1.3.1 发送函数定义及参数传递

Can_Send_Msg(uint32_t id, uint32_t len, uint8_t *buf) 的三个输入参数分别是:

参数类型作用
iduint32_t写入 TxMessage.StdId;标准 ID 的有效范围是 0x000~0x7FF
lenuint32_t写入 TxMessage.DLC;经典 CAN 有效范围是 0~8
bufuint8_t *指向待发送数据的首字节

调用 Can_Send_Msg(0x12, 8, Tx_Data) 时,数组表达式 Tx_Data 会转换为指向首元素的指针,所以函数不必复制整个数组就能读取 8 个字节。这里常被口语化地说成“数组传地址”,更准确地说是“数组实参在函数调用表达式中发生数组到指针的转换”。

课程把会变化的 ID、DLC 和数据地址设计成参数,把本实验固定不变的内容写在函数内部:

  • IDE = CAN_ID_STD:只发送 11 位标准帧;
  • RTR = CAN_RTR_DATA:只发送数据帧,不发送远程帧;
  • Tx_Mail:从 main.c 的全局变量移到函数内部,缩小变量作用域。

Tx_Mail 传给 HAL_CAN_AddTxMessage() 的是地址。HAL 会把实际选中的邮箱以 CAN_TX_MAILBOX0/1/2 位掩码写回该变量,所以它初始化为 CAN_TX_MAILBOX0 并不等于强制 HAL 只能使用邮箱 0。

本课结束时的发送封装还有两个阶段性问题,代码块按课堂原样保留:

  1. 函数声明为返回 uint32_t,函数体却没有 return。如果调用者读取这个返回值,结果不可靠;本课 main.c 尚未读取它,第 186 课才补上明确返回值。
  2. HAL_CAN_AddTxMessage() 的返回值没有判断,Send ok! 又是无条件打印,因此这条日志只能证明程序执行到了打印语句。

此外,课堂只给 CAN_TxHeaderTypeDef 的 StdId、IDE、DLC 和 RTR 赋值,没有给本项目 HAL 结构体中的 TransmitGlobalTime 赋值。该局部结构体没有整体清零,HAL 实现又会读取此成员;稳健代码应明确初始化它,但这不是本课实际写入的内容,因此没有擅自补进上方源码。

1.4.1.4 接收函数封装
1.4.1.4.1 接收函数定义及参数传递

接收方向与发送方向相反:发送函数由 main.c 把数据交给 canif.c,接收函数则要把 canif.c 读到的数据带回 main.c。因此它使用两个输出指针:

  • uint32_t *id 指向 main.c 的 Recv_ID。函数内部执行 *id = RxMessage.StdId;,直接修改调用者变量;
  • uint8_t *buf 指向 Rx_Data 的首字节,HAL 直接把帧的数据段写入这个数组;
  • return RxMessage.DLC; 用普通返回值带回数据长度,避免再增加一个长度指针参数。

调用时必须写 Can_Recv_Msg(&Recv_ID, Rx_Data):Recv_ID 是单个整数,需要用 & 取得地址;Rx_Data 是数组表达式,调用时会自动转换为首元素地址,所以不写 &。

主函数把打印循环从固定的 i < 8 改为 i < Recv_Len,这样收到 DLC 小于 8 的帧时只处理有效字节。接收缓冲区仍固定为 8 字节,符合经典 CAN 单帧最大 8 字节的范围。

本课结束时接收封装仍存在以下边界,下一课才继续处理:

  1. 没有先检查 FIFO 填充数量,也没有判断 HAL_CAN_GetRxMessage() 的返回值。FIFO 为空时,本项目 HAL 会直接返回 HAL_ERROR,不会为 RxMessage 和 buf 写入一帧新数据;随后读取未初始化的局部 RxMessage.StdId/DLC 是不可靠的。如果碰巧得到一个大于 8 的不确定 Recv_Len,后面的循环还可能越过 Rx_Data[8] 的边界继续读取。
  2. 返回类型是无符号 uint32_t,因此原笔记中的“返回值小于 0 表示失败”不成立。主函数实际判断的是 Recv_Len > 0 与“不大于 0”。
  3. 经典 CAN 允许 DLC 为 0 的合法帧,这种帧会被当前主函数归入错误分支,所以“返回 0 代表失败”并不是通用接口设计。
  4. HAL 接收接口的第二个参数按语义应写 CAN_RX_FIFO0。课堂使用的 CAN_FILTER_FIFO0 属于过滤器分配宏;在本项目 HAL 中二者数值碰巧同为 0,所以代码能够指向 FIFO0,但概念上不应混用。
  5. printf("recv eorro\r\n") 中的 eorro 是源码拼写错误,应理解为 recv error。为遵守源码原样保留要求,代码块没有直接修改字符串。

接收失败时还会在无限循环中连续打印错误信息,阻塞式串口输出可能大量占用 CPU。第 186 课加入 FIFO 判断后,若主函数仍在无报文时打印错误,该现象依然存在;实际项目通常会减少轮询日志、增加延时,或改用中断/事件驱动,但这些都不属于本课源码。

二、编译

课程在每一步封装后都进行 Build,用编译器错误反推遗漏项:

  1. 把 Tx_Mail 移入发送函数后,main.c 中残留的相关语句会因名称已不在作用域内而报错;删除不再使用的旧发送代码后恢复编译。
  2. 调用 Can_Send_Msg(0x12, 8, Tx_Data) 时漏写分号,编译器最终定位到第 65 行附近;补上分号后编译通过。
  3. 再按一次 Build 可能让本次输出更集中地显示尚存错误,但不会“自动忽略或修复警告”。仍应阅读警告,例如非 void 函数没有返回值。

“0 Error”只说明语法、类型检查和链接满足当前编译器要求,不等于运行逻辑已经可靠。本课代码还没有进行实物收发验证,邮箱/FIFO 检查和硬件测试在第 186 课继续完成。

本笔记对接口含义的核对依据是工程内实际使用的 STM32F1 HAL:stm32f1xx_hal_can.h/.c 明确给出了发送邮箱空闲数量、接收 FIFO 填充数量以及 FIFO 为空时 HAL_CAN_GetRxMessage() 返回 HAL_ERROR 的行为;因此结论与本项目所用库版本对应,而不是凭名称猜测。