文章目录
-
- [1. 项目概述与设计背景](#1. 项目概述与设计背景)
-
- [1.1 系统功能定义](#1.1 系统功能定义)
- [1.2 硬件准备清单](#1.2 硬件准备清单)
- [2. 硬件连接与系统架构](#2. 硬件连接与系统架构)
-
- [2.1 CAN总线终端电阻原理](#2.1 CAN总线终端电阻原理)
- [2.2 系统硬件拓扑结构](#2.2 系统硬件拓扑结构)
- [2.3 引脚接线详细说明](#2.3 引脚接线详细说明)
- [3. CubeMX配置详解](#3. CubeMX配置详解)
-
- [3.1 工程创建与基础配置](#3.1 工程创建与基础配置)
- [3.2 CAN参数计算与配置](#3.2 CAN参数计算与配置)
- [3.3 串口配置(仅Master需要)](#3.3 串口配置(仅Master需要))
- [4. 软件流程设计](#4. 软件流程设计)
-
- [4.1 整体数据流向分析](#4.1 整体数据流向分析)
- [4.2 CAN过滤器工作原理](#4.2 CAN过滤器工作原理)
- [5. 代码实现:公共基础代码](#5. 代码实现:公共基础代码)
-
- [5.1 main.c文件头部配置](#5.1 main.c文件头部配置)
- [5.2 CAN过滤器配置函数](#5.2 CAN过滤器配置函数)
- [5.3 串口重定向函数(仅Master需要)](#5.3 串口重定向函数(仅Master需要))
- [6. 代码实现:发送节点](#6. 代码实现:发送节点)
-
- [6.1 Node A主程序代码](#6.1 Node A主程序代码)
- [6.2 Node B主程序代码](#6.2 Node B主程序代码)
- [7. 代码实现:主控接收端](#7. 代码实现:主控接收端)
-
- [7.1 Master主程序初始化](#7.1 Master主程序初始化)
- [7.2 CAN接收中断回调函数](#7.2 CAN接收中断回调函数)
- [7.3 完整的Master main函数结构](#7.3 完整的Master main函数结构)
- [8. 编译与烧录流程](#8. 编译与烧录流程)
-
- [8.1 工程编译](#8.1 工程编译)
- [8.2 程序烧录](#8.2 程序烧录)
- [9. 调试与验证步骤](#9. 调试与验证步骤)
-
- [9.1 验证前的准备工作](#9.1 验证前的准备工作)
- [9.2 验证步骤与预期结果](#9.2 验证步骤与预期结果)
- [9.3 验证失败的处理方法](#9.3 验证失败的处理方法)
- [10. 常见问题排查与解决方案](#10. 常见问题排查与解决方案)
-
- [10.1 完全收不到数据](#10.1 完全收不到数据)
- [10.2 数据接收时断时续](#10.2 数据接收时断时续)
- [10.3 程序卡死在初始化阶段](#10.3 程序卡死在初始化阶段)
- [11. 系统功能扩展思路](#11. 系统功能扩展思路)
-
- [11.1 添加数据校验机制](#11.1 添加数据校验机制)
- [11.2 实现命令下发功能](#11.2 实现命令下发功能)
- [11.3 增加更多采集节点](#11.3 增加更多采集节点)
- [12. 技术总结](#12. 技术总结)
1. 项目概述与设计背景
在工业自动化、汽车电子以及复杂的嵌入式系统中,设备之间的通信至关重要。传统的UART串口通信在多节点组网时存在抗干扰能力差、布线复杂、主从机制死板等缺陷。CAN(Controller Area Network)总线凭借其差分信号传输带来的高抗干扰性、自动仲裁机制以及双线挂载多设备的特性,成为了工业现场的首选通信方案。
本项目将手把手带领大家实现一个基于CAN总线的多节点数据采集系统。我们将构建一个由"1个主接收端(Master)"和"2个数据发送节点(Node A & Node B)"组成的微型网络。这个网络虽然简单,但涵盖了CAN总线应用的核心知识点,非常适合零基础学习者入门学习。
1.1 系统功能定义
整个系统的功能分配非常明确,每个节点都有自己独特的职责。主节点负责监听总线并汇总显示数据,从节点负责采集并上报数据。这种主从架构是工业现场最常见的组网方式之一。
节点A (ID: 0x11) 的主要功能是模拟采集温度数据。温度传感器每500毫秒采集一次当前环境温度,并通过CAN总线向整个网络广播。温度数据以单字节形式发送,数值范围设定在20到40摄氏度之间,模拟真实的室内温度变化情况。每一次数据发送都会触发板载LED灯的翻转,这样开发者可以通过观察LED的闪烁频率直观判断节点是否正常工作。
节点B (ID: 0x22) 的主要功能是模拟采集湿度数据。与温度采集不同,湿度数据每秒(1000毫秒)更新一次。湿度数值的模拟范围设定在50%到80%之间,这是一个比较舒适的室内湿度区间。湿度数据同样通过CAN总线进行广播,但由于采集周期较长,LED的闪烁频率也会相应较慢,这种差异可以帮助我们区分不同的节点。
主控端 (Master) 是整个系统的数据汇聚中心。它不主动发送任何数据,专注于监听CAN总线。当总线上有数据经过时,Master通过硬件中断的方式立即获取数据帧,然后解析其中的标准ID来识别数据来源(是温度数据还是湿度数据)。最终通过UART串口将解析后的数据打印到电脑屏幕上。在调试阶段,这种可视化输出非常重要,它能帮助开发者直观看到整个通信系统的运行状态。
1.2 硬件准备清单
为了完成这个项目,我们需要准备以下硬件设备。这些设备在淘宝或电子元器件商店都很容易购买到,整套成本大约在50元左右。
微控制器方面,我们需要3块STM32F103C8T6核心板。这是ST公司推出的一款经典型单片机,性价比极高,内部集成了完整的CAN控制器硬件,非常适合学习CAN总线通信。每块核心板的价格大约在10到15元之间,选择兼容版(蓝色PCB)即可,不需要追求原厂。
CAN收发器是CAN通信中不可或缺的器件。STM32内部集成的是CAN控制器(属于协议层),它输出的是TTL电平信号,无法直接驱动CAN总线。我们需要使用TJA1050或SN65HVD230将TTL信号转换为差分信号。TJA1050需要5V供电,而SN65HVD230可以直接使用3.3V供电,对初学者更友好。建议购买现成的CAN收发器模块,带有DB9接口或插针接口的那种。
调试工具方面,一块ST-Link V2下载器是必需的。它不仅用于下载程序,还可以作为调试器使用单步执行代码。如果已经有其他调试器如J-Link或CMSIS-DAP也可以使用。
辅助材料包括面包板用于临时搭建电路、杜邦线用于连接各个模块、以及2个120欧姆的终端电阻。终端电阻的作用是消除信号反射,在CAN总线中非常重要,必须安装在总线两端的节点上。
2. 硬件连接与系统架构
2.1 CAN总线终端电阻原理
CAN总线最核心的硬件要求是终端电阻。在解释终端电阻之前,我们需要理解什么是信号反射。当高频信号沿传输线传播时,如果遇到阻抗不连续的点(如线缆末端),部分信号会反射回来,与原信号叠加造成干扰。CAN总线作为高速通信网络,其信号边沿非常陡峭,反射问题尤为突出。
在CAN总线的物理层标准中,要求在总线两端(也就是距离最远的两个节点)各并联一个120欧姆的电阻。这个阻值是经过精密计算的,与总线的特性阻抗相匹配。当信号到达总线末端时,电阻将信号完全吸收,从而彻底消除反射。在实际工程中,如果忘记安装终端电阻,通信距离稍长就会导致数据错误,这也是初学者最常遇到的问题之一。
2.2 系统硬件拓扑结构
整个系统的硬件连接可以划分为三个部分:CAN总线网络、Master主控系统、采集节点系统。下面通过Mermaid流程图来展示它们之间的物理连接关系。
采集节点 B
采集节点 A
CAN总线网络
主控端系统
UART TX
PA11 RX
PA12 TX
PA11/PA12
PA11/PA12
STM32 Master
STM32F103C8T6
USB转TTL
CH340G
CAN收发器
TJA1050
CAN_H (黄/绿线)
120Ω
终端电阻
CAN_L (蓝/白线)
STM32 Node A
ID: 0x11
CAN收发器
STM32 Node B
ID: 0x22
CAN收发器
120Ω
终端电阻
2.3 引脚接线详细说明
所有STM32F103系列芯片的CAN引脚是固定的,不需要像其他外设那样可以任意映射到任意引脚上。对于F103C8T6,CAN1默认映射到PA11和PA12引脚。具体接线如下表所示,这些引脚在所有三个节点上都是一致的。
| STM32引脚 | 功能说明 | 连接到CAN收发器 |
|---|---|---|
| PA11 | CAN_RX (接收) | RXD引脚 |
| PA12 | CAN_TX (发送) | TXD引脚 |
| 5V或3.3V | 供电正极 | VCC引脚 |
| GND | 供电负极 | GND引脚 |
需要特别注意收发器的供电电压。TJA1050必须使用5V供电,而SN65HVD230可以使用3.3V供电。在购买收发器模块时,建议选择支持宽电压输入的模块,这样可以使用STM32开发板的5V输出供电,避免额外的电源问题。
CAN收发器的CAN_H和CAN_L引脚需要连接到总线电缆上。这里有一个容易出错的地方:CAN_H必须连接到所有节点的CAN_H,CAN_L必须连接到所有节点的CAN_L,不能交叉连接。如果接反了,虽然不会烧毁设备,但通信绝对无法建立。
3. CubeMX配置详解
3.1 工程创建与基础配置
为了让代码能够实际落地,我们必须从配置开始。STM32CubeMX是ST官方提供的图形化配置工具,它可以自动生成HAL库的初始化代码,大大降低了开发门槛。即使是零基础的小白,只要按照步骤操作,也能完成复杂的外设配置。
首先打开CubeMX,点击"New Project"创建新工程。在左侧的MCU选择器中,搜索"STM32F103C8T6",然后选择对应的型号。选中之,后点击"Start Project"进入配置界面。
进入配置界面后,我们需要依次配置以下几个关键部分。**RCC(时钟配置)**部分:在右侧的引脚视图中找到RCC相关引脚,然后在左侧的配置栏中找到"RCC"选项,展开后找到"High Speed Clock (HSE)",选择"Crystal/Ceramic Resonator"。这表示我们将使用外部晶振作为系统时钟源,相比内部RC振荡器,外部晶振频率更精确,对CAN通信的波特率稳定性至关重要。
**SYS(系统调试接口)**部分:找到"SYS"选项,将"Debug"设置为"Serial Wire"。这允许我们使用SWD接口进行调试和程序下载,同时释放出PA13和PA14引脚供其他功能使用。很多初学者忘记配置这个选项,导致后续无法进行调试。
**Clock Configuration(时钟树配置)**部分:这是最关键的一步。点击顶部的"Clock Configuration"标签页进入时钟树配置界面。在"System Clock Mux"中选择PLL作为时钟源,然后在PLL Source Mux中选择HSE。重要的是将HCLK设置为72MHz,这是STM32F103的标准最高主频。设置完成后,CubeMX会自动计算并配置所有分频器和倍频器。
3.2 CAN参数计算与配置
CAN总线的波特率配置是整个系统中最重要的参数之一。所有连接到同一总线上的设备必须配置完全相同的波特率,否则通信无法建立。波特率的计算涉及到APB1时钟频率和多个时序参数。
STM32F103的CAN控制器挂载在APB1总线上。根据上一步的配置,APB1的时钟频率为36MHz(注意:APB1的频率是HCLK的一半,即72MHz/2=36MHz)。CAN控制器的工作时序基于"时间量子"(Time Quanta)的概念。一个时间量子是CAN控制器工作的基本时间单位,由APB1时钟经过预分频后得到。
波特率的计算公式如下:
波特率 = APB1时钟频率 ÷ (预分频系数 × (1 + BS1 + BS2))
其中BS1是时间段1的量子数,BS2是时间段2的量子数。SJW是同步跳转宽度,用于补偿不同节点之间的时钟误差。
为了得到常用的500kbps波特率,我们需要让APB1时钟36MHz除以某个整数等于500kHz。即:
36MHz ÷ 500kHz = 72
这意味着我们需要让(预分频系数 × (1 + BS1 + BS2)) = 72。一个常用的配置组合是:预分频系数=9,BS1=5,BS2=2,验证一下:9 × (1 + 5 + 2) = 9 × 8 = 72,完全正确。
在CubeMX中的具体配置步骤如下:首先在左侧找到"Connectivity"分类,展开后点击"CAN"。在弹出的配置界面中,勾选"Master Mode"(注意这只是表示启用CAN外设,并非主从关系)。然后在参数设置区域按照以下数值进行配置:
| 参数名称 | 设置值 | 说明 |
|---|---|---|
| Prescaler | 9 | 预分频系数,将36MHz分频得到4MHz |
| Time Quanta in Bit Segment 1 | 5 Times | BS1段长度 |
| Time Quanta in Bit Segment 2 | 2 Times | BS2段长度 |
| ReSynchronization Jump Width | 1 Time | 同步跳转宽度 |
| Time Triggered Communication Mode | Disable | 禁用时间触发模式 |
| Automatic Bus-Off Management | Enable | 开启自动总线关闭管理 |
| Automatic Wake-Up Mode | Enable | 开启自动唤醒 |
| Automatic Retransmission | Enable | 开启自动重传 |
| Receive Fifo Locked Mode | Disable | 禁用FIFO锁定 |
| Transmit Fifo Priority | Disable | 禁用优先级发送 |
| Operating Mode | Normal | 正常工作模式(重要!) |
特别提醒:Operating Mode必须选择Normal,如果选择Loopback模式,CAN控制器只会自发自收,无法与外部设备通信。这是初学者最容易忽略的设置。
完成CAN配置后,还需要配置NVIC(嵌套向量中断控制器)。在CAN配置界面中找到"NVIC Settings"标签,勾选"USB Low priority or CAN RX0 interrupt",将其设置为Enabled。这是接收中断的入口,必须开启才能接收到数据。
3.3 串口配置(仅Master需要)
作为主控端,Master需要通过串口将接收到的数据打印到电脑上。在CubeMX中配置串口的方法如下:
在"Connectivity"分类下找到"USART1",点击进入配置界面。将Mode设置为"Asynchronous"(异步模式)。在"Parameter Settings"中,保持默认的8N1配置(8位数据位、无校验、1位停止位),波特率设置为115200。这个波特率是初学者最常用的设置,与绝大多数串口调试助手兼容。
配置完成后,别忘了在"NVIC Settings"中开启USART1的中断。虽然这里不使用中断方式进行串口通信,但开启中断可以让HAL库更好地处理数据。
4. 软件流程设计
4.1 整体数据流向分析
在编写代码之前,我们先梳理一下数据的流转逻辑。这有助于从宏观上理解整个系统的工作方式,即使遇到问题也能快速定位。
系统上电后,三个节点首先进行各自的初始化。Master节点需要配置CAN过滤器、启动CAN外设、开启接收中断、初始化串口。Node A和Node B需要配置CAN过滤器、启动CAN外设、配置发送参数。初始化完成后,所有节点进入正常工作状态。
数据采集和发送由两个从节点主动发起。Node A每500毫秒读取一次模拟的温度数据,填充到发送缓冲区,然后调用HAL库的发送函数将数据帧推送到CAN总线上。数据帧包含发送者的标准ID(0x11)、数据长度(8字节)以及实际的数据内容。
数据帧在CAN总线上传播,被所有连接到总线的节点接收。Master节点的CAN控制器收到数据帧后,首先检查过滤器的掩码设置。如果ID匹配,数据会被放入接收FIFO,并触发RX0中断。在中断服务程序中,Master读取数据帧,解析其中的ID和数据内容,最后通过串口输出到电脑屏幕上。
串口终端 CAN总线 节点B (0x22) 节点A (0x11) 主控端 (0x01) 串口终端 CAN总线 节点B (0x22) 节点A (0x11) 主控端 (0x01) 系统初始化 采集温度 loop [NodeA数据采集周期 (500ms)] 采集湿度 loop [NodeB数据采集周期 (1000ms)] 配置CAN过滤器 启动CAN外设 开启RX0中断 打印启动信息 模拟温度值+1 发送CAN帧 (ID=0x11) 触发RX0中断 解析数据来源 打印温度数据 模拟湿度值+1 发送CAN帧 (ID=0x22) 触发RX0中断 解析数据来源 打印湿度数据
4.2 CAN过滤器工作原理
CAN过滤器是CAN控制器中一个非常重要但又经常被初学者忽视的功能。它的作用类似于邮件分拣员:CAN总线上每秒可能流通着成千上万个数据帧,我们的节点只需要接收特定ID的数据,这时就需要过滤器来筛选。
STM32F103的CAN控制器提供14个过滤器组(Filter Bank),每个过滤器组可以配置为掩码模式或列表模式。在掩码模式下,我们需要设置一个ID和一个掩码。掩码中为1的位表示该位必须匹配,为0的位表示该位可以是任意值。例如,如果我们设置ID=0x11,掩码=0x7FF(所有位都关心),那么只有ID完全等于0x11的数据帧才会被接收。
如果我们设置ID=0x00,掩码=0x000(所有位都不关心),那么无论ID是什么,数据帧都会被接收。这是最宽松的配置,适合学习阶段使用。实际工程项目中,我们通常会根据需要接收的ID范围来配置掩码,以过滤掉不相关的总线流量。
5. 代码实现:公共基础代码
5.1 main.c文件头部配置
为了方便零基础学习者理解,我们不封装复杂的库文件,所有的代码都直接写在main.c中。这种方式虽然不够优雅,但非常适合学习理解每个步骤的作用。
在使用CubeMX生成工程后,我们需要手动添加一些代码来实现我们的功能。首先打开Core/Src/main.c文件,找到用户代码区域。
文件名:Core/Src/main.c
c
/* USER CODE BEGIN Includes */
#include <stdio.h> // 标准输入输出库,提供printf函数
#include <string.h> // 字符串处理库
/* USER CODE END Includes */
在文件开头引入必要的头文件。stdio.h提供了printf函数的支持,这在调试阶段打印信息非常有用。string.h提供了一些字符串处理函数,虽然本例中使用不多,但属于常规引入。
5.2 CAN过滤器配置函数
接下来在用户代码区域0中添加CAN过滤器配置函数。这个函数是整个CAN通信的基础,如果不配置过滤器,CAN控制器会默认拒绝接收所有数据帧,导致通信完全无法工作。
文件名:Core/Src/main.c
c
/* USER CODE BEGIN 0 */
// CAN发送数据结构体定义
CAN_TxHeaderTypeDef TxHeader; // 发送帧头结构体
uint8_t TxData[8]; // 发送数据缓冲区,8字节
uint32_t TxMailbox; // 发送邮箱编号
// CAN接收数据结构体定义
CAN_RxHeaderTypeDef RxHeader; // 接收帧头结构体
uint8_t RxData[8]; // 接收数据缓冲区,8字节
// CAN过滤器配置函数
// 说明:STM32的CAN控制器需要配置过滤器才能接收数据
// 如果不配置,默认会拒绝所有接收请求
void CAN_Filter_Config(void)
{
CAN_FilterTypeDef sFilterConfig;
// 配置过滤器参数
sFilterConfig.FilterBank = 0; // 使用过滤器组0(共14组,0-13)
sFilterConfig.FilterMode = CAN_FILTERMODE_IDMASK; // 掩码模式:对比ID和掩码决定是否接收
sFilterConfig.FilterScale = CAN_FILTERSCALE_32BIT; // 32位宽度模式
sFilterConfig.FilterIdHigh = 0x0000; // 验证码高16位:0表示完全匹配
sFilterConfig.FilterIdLow = 0x0000; // 验证码低16位
sFilterConfig.FilterMaskIdHigh = 0x0000; // 掩码高16位:0表示不关心该位
sFilterConfig.FilterMaskIdLow = 0x0000; // 掩码低16位:0表示不关心该位
// 综合以上设置:本过滤器接收所有ID的数据帧
sFilterConfig.FilterFIFOAssignment = CAN_RX_FIFO0; // 接收到的报文放入FIFO0队列
sFilterConfig.FilterActivation = ENABLE; // 使能过滤器
sFilterConfig.SlaveStartFilterBank = 14; // 双CAN模式下从CAN起始组号(单CAN忽略)
// 调用HAL库函数配置过滤器
if (HAL_CAN_ConfigFilter(&hcan, &sFilterConfig) != HAL_OK)
{
// 配置失败进入错误处理函数
Error_Handler();
}
}
/* USER CODE END 0 */
这段代码的核心在于过滤器参数的理解。FilterId和FilterMaskId都设置为0,配合掩码模式(IDMASK),意味着不进行任何过滤,接收所有ID的数据帧。这是学习阶段最简单粗暴的配置方式,可以确保我们能够收到所有节点发送的数据。实际项目中,应该根据需要只接收特定ID范围的数据,以提高效率。
5.3 串口重定向函数(仅Master需要)
Master节点需要将接收到的数据通过串口打印到电脑上。HAL库默认不提供printf的支持,我们需要手动实现串口重定向。这段代码利用了ARM编译器的特性,将标准库的printf输出重定向到USART1。
文件名:Core/Src/main.c (仅Master)
c
/* USER CODE BEGIN 0 */
// 重定向printf函数到UART1
// 这段代码使用了GCC编译器的特性
#ifdef __GNUC__
// GCC编译器下的实现
int _write(int file, char *ptr, int len)
{
// 调用HAL库的UART发送函数
HAL_UART_Transmit(&huart1, (uint8_t*)ptr, len, HAL_MAX_DELAY);
return len;
}
#else
// 非GCC编译器(如Keil MDK)下的实现
int fputc(int ch, FILE *f)
{
HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
return ch;
}
#endif
/* USER CODE END 0 */
这段代码的作用是拦截标准库的写入操作,将原本要输出到控制台的数据改为通过UART1发送。使用时直接调用printf函数即可,就像在电脑上使用printf一样。需要特别注意的是,GCC编译器和Keil编译器对重定向的实现方式不同,这里通过条件编译同时兼容两种编译器。
6. 代码实现:发送节点
6.1 Node A主程序代码
发送节点的核心逻辑是:初始化CAN外设 → 循环采集数据 → 发送数据帧 → 延时 → 重复。下面是Node A的完整代码实现。
文件名:Core/Src/main.c (Node A)
c
/* USER CODE BEGIN 2 */
// 第一步:配置CAN过滤器
CAN_Filter_Config();
// 第二步:启动CAN外设
// 必须调用此函数才能开始CAN通信
if (HAL_CAN_Start(&hcan) != HAL_OK)
{
// 启动失败进入错误处理
Error_Handler();
}
// 第三步:配置发送帧头参数
TxHeader.StdId = 0x11; // 标准ID:0x11 (节点A的标识)
TxHeader.ExtId = 0x00; // 扩展ID:未使用,设为0
TxHeader.IDE = CAN_ID_STD; // 帧类型:标准帧 (11位ID)
TxHeader.RTR = CAN_RTR_DATA; // 帧格式:数据帧 (不是远程帧)
TxHeader.DLC = 8; // 数据长度:8字节 (最大支持8字节)
TxHeader.TransmitGlobalTime = DISABLE; // 不发送时间戳
// 初始化发送数据
memset(TxData, 0, 8); // 清零数据缓冲区
/* USER CODE END 2 */
/* USER CODE BEGIN WHILE */
// 模拟温度传感器初始值
uint8_t temperature = 20; // 初始温度20度
uint8_t counter = 0; // 发送计数器
while (1)
{
// 模拟温度数据变化
// 温度在20-40度之间循环变化
temperature++;
if (temperature > 40)
{
temperature = 20;
}
counter++;
// 填充发送数据
// 协议设计:第0字节=温度值,第1-3字节=版本号(0x01 0x02 0x03)
TxData[0] = temperature; // 实际温度值
TxData[1] = 0x01; // 协议版本号
TxData[2] = 0x02; // 节点类型标识
TxData[3] = counter; // 发送计数器
TxData[4] = 0xAA; // 填充字节,用于调试观察
TxData[5] = 0xBB; // 填充字节
TxData[6] = 0xCC; // 填充字节
TxData[7] = 0xDD; // 填充字节
// 发送数据帧
// 参数说明:
// &hcan: CAN句柄
// &TxHeader: 发送帧头信息
// TxData: 要发送的数据
// &TxMailbox: 存储发送邮箱编号的变量(用于查询发送状态)
if (HAL_CAN_AddTxMessage(&hcan, &TxHeader, TxData, &TxMailbox) != HAL_OK)
{
// 发送失败处理
// 实际项目中可以添加LED闪烁来指示错误
// 这里选择静默忽略,继续下一次发送
}
// 翻转板载LED指示灯(PC13)
// 通过观察LED闪烁可以判断程序是否正常运行
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
// 延时500ms
// Node A每500ms发送一次数据
HAL_Delay(500);
/* USER CODE END WHILE */
}
这段代码有几个关键点需要理解。首先是TxHeader的配置,每个节点必须有唯一的StdId,这样接收方才能区分数据来源。DLC设置8表示本次发送8字节数据,这是CAN2.0A标准帧允许的最大数据长度。其次是HAL_CAN_AddTxMessage函数,这是HAL库提供的发送函数,内部会自动选择空闲的发送邮箱。如果所有邮箱都被占用,函数会返回错误码。
板载LED的翻转是一个很好的调试手段。如果LED不亮,说明程序可能卡死在初始化阶段;如果LED常量不闪烁,说明主循环没有正常执行;只有LED按预期频率闪烁,才能证明程序正常运行。
6.2 Node B主程序代码
Node B的代码与Node A几乎完全相同,唯一的区别是StdId和数据内容。如果同时只有一个STM32开发板,可以先烧录Node A的代码测试,测试完毕后修改以下几行再烧录到另一块板子上。
文件名:Core/Src/main.c (Node B)
c
/* USER CODE BEGIN 2 */
// CAN过滤器配置(与Node A完全相同)
CAN_Filter_Config();
// 启动CAN外设
if (HAL_CAN_Start(&hcan) != HAL_OK)
{
Error_Handler();
}
// 配置发送帧头 - 这里改为Node B的ID
TxHeader.StdId = 0x22; // 标准ID:0x22 (Node B的标识)
TxHeader.ExtId = 0x00; // 扩展ID
TxHeader.IDE = CAN_ID_STD; // 标准帧
TxHeader.RTR = CAN_RTR_DATA; // 数据帧
TxHeader.DLC = 8; // 8字节数据
TxHeader.TransmitGlobalTime = DISABLE;
memset(TxData, 0, 8);
/* USER CODE END 2 */
/* USER CODE BEGIN WHILE */
// 模拟湿度传感器初始值
uint8_t humidity = 50; // 初始湿度50%
uint8_t counter = 0;
while (1)
{
// 模拟湿度数据变化
// 湿度在50-80%之间循环变化
humidity++;
if (humidity > 80)
{
humidity = 50;
}
counter++;
// 填充发送数据
// 协议设计:第0字节=湿度值,第1字节=节点标识
TxData[0] = humidity; // 实际湿度值
TxData[1] = 0x02; // 节点类型标识(与Node A不同)
TxData[2] = 0x00; // 保留
TxData[3] = counter; // 发送计数器
TxData[4] = 0x11; // 填充字节
TxData[5] = 0x22; // 填充字节
TxData[6] = 0x33; // 填充字节
TxData[7] = 0x44; // 填充字节
// 发送数据帧
if (HAL_CAN_AddTxMessage(&hcan, &TxHeader, TxData, &TxMailbox) != HAL_OK)
{
// 发送失败处理
}
// 翻转LED指示灯
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
// 延时1000ms
// Node B每1000ms发送一次(比Node A慢)
HAL_Delay(1000);
/* USER CODE END WHILE */
}
对比Node A和Node B的代码,主要差异在于StdId(0x11 vs 0x22)、数据含义(温度 vs 湿度)以及发送周期(500ms vs 1000ms)。正是因为StdId的不同,Master才能准确区分数据的来源。
7. 代码实现:主控接收端
7.1 Master主程序初始化
Master节点的初始化过程比发送节点多一些步骤,因为它不仅需要启动CAN接收,还需要开启串口输出。特别是CAN接收中断的激活,是保证实时接收数据的关键。
文件名:Core/Src/main.c (Master)
c
/* USER CODE BEGIN 2 */
// 第一步:配置CAN过滤器
// Master也需要配置过滤器,否则无法接收数据
CAN_Filter_Config();
// 第二步:启动CAN外设
if (HAL_CAN_Start(&hcan) != HAL_OK)
{
Error_Handler();
}
// 第三步:激活CAN接收中断
// 当FIFO0中有待处理的报文时,产生中断通知CPU
// 这是实现实时接收的关键机制
if (HAL_CAN_ActivateNotification(&hcan, CAN_IT_RX_FIFO0_MSG_PENDING) != HAL_OK)
{
Error_Handler();
}
// 第四步:初始化串口输出
// 打印系统启动信息到电脑
printf("========================================\r\n");
printf(" CAN Master Station Started\r\n");
printf(" System Clock: 72MHz\r\n");
printf(" CAN Baudrate: 500kbps\r\n");
printf("========================================\r\n");
printf("Waiting for CAN data...\r\n");
printf("\r\n");
/* USER CODE END 2 */
/* USER CODE BEGIN WHILE */
while (1)
{
// Master节点的主循环不需要做任何事情
// 所有数据接收都在中断回调函数中完成
// 这里可以添加其他辅助功能,如LED状态指示
// LED常量亮表示系统正常运行
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET);
// 主循环延时
HAL_Delay(1000);
/* USER CODE END WHILE */
}
Master节点的设计采用了中断驱动模式。主循环几乎是空闲的,所有数据接收工作都在中断回调函数中完成。这种设计有多个优点:首先,CPU不需要轮询检查接收状态,效率更高;其次,中断响应速度快,不会丢失任何数据帧;第三,主循环可以腾出来处理其他任务,如显示刷新、按键处理等。
7.2 CAN接收中断回调函数
中断回调函数是整个Master节点的核心。当CAN控制器收到有效数据帧并通过过滤器筛选后,会自动调用这个函数。在这个函数中,我们需要读取数据帧的内容并进行解析处理。
文件名:Core/Src/main.c (Master 回调函数)
c
/* USER CODE BEGIN 4 */
// CAN接收FIFO0消息挂起中断回调函数
// 参数:hcan - CAN句柄
// 返回值:无
// 说明:当FIFO0中有新消息时由硬件自动调用
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan)
{
// 从FIFO0读取接收到的消息
// 参数说明:
// hcan: CAN句柄
// CAN_RX_FIFO0: 从FIFO0读取
// &RxHeader: 存储接收帧头信息的结构体
// RxData: 存储接收数据的缓冲区
if (HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &RxHeader, RxData) == HAL_OK)
{
// 成功读取到数据帧
// 根据标准ID判断数据来源
switch (RxHeader.StdId)
{
case 0x11:
// 来自Node A (温度节点)
// 解析数据:第0字节是温度值
printf("[Node A] Temperature: %d C | ", RxData[0]);
printf("NodeID: 0x%02X | ", RxHeader.StdId);
printf("DLC: %d | ", RxHeader.DLC);
printf("Raw: %02X %02X %02X %02X\r\n",
RxData[0], RxData[1], RxData[2], RxData[3]);
break;
case 0x22:
// 来自Node B (湿度节点)
// 解析数据:第0字节是湿度值
printf("[Node B] Humidity: %d %% | ", RxData[0]);
printf("NodeID: 0x%02X | ", RxHeader.StdId);
printf("DLC: %d | ", RxHeader.DLC);
printf("Raw: %02X %02X %02X %02X\r\n",
RxData[0], RxData[1], RxData[2], RxData[3]);
break;
default:
// 来自未知节点
printf("[Unknown Node] ID: 0x%02X | DLC: %d | Data:",
RxHeader.StdId, RxHeader.DLC);
for (int i = 0; i < RxHeader.DLC; i++)
{
printf(" %02X", RxData[i]);
}
printf("\r\n");
break;
}
// 收到数据后翻转LED作为指示
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
}
else
{
// 读取失败(通常不会发生)
printf("[Error] CAN receive failed!\r\n");
}
}
/* USER CODE END 4 */
这个回调函数的设计包含了几个重要的知识点。首先是通过StdId来区分不同的数据发送节点,这是多节点通信中的基本做法。其次是RxHeader中包含的丰富信息,除了StdId还有DLC(数据长度)、IDE(帧类型)、RTR(远程帧标志)等,可以用于更复杂的数据协议设计。第三是Raw数据的打印,通过十六进制格式可以观察到完整的原始数据,便于调试分析。
7.3 完整的Master main函数结构
为了让零基础学习者能够完整理解,这里给出Master节点main函数的完整结构,包括所有用户代码区域的位置。
文件名:Core/Src/main.c (Master 完整框架)
c
/* Includes ------------------------------------------------------------------*/
#include "main.h"
#include "can.h"
#include "usart.h"
#include "gpio.h"
/* Private includes ----------------------------------------------------------*/
/* USER CODE BEGIN Includes */
#include <stdio.h>
/* USER CODE END Includes */
/* Private typedef -----------------------------------------------------------*/
/* USER CODE BEGIN PTD */
/* USER CODE END PTD */
/* Private define ------------------------------------------------------------*/
/* USER CODE BEGIN PD */
/* USER CODE END PD */
/* Private macro -------------------------------------------------------------*/
/* USER CODE BEGIN PM */
/* USER CODE END PM */
/* Private variables ---------------------------------------------------------*/
/* USER CODE BEGIN PV */
CAN_TxHeaderTypeDef TxHeader;
CAN_RxHeaderTypeDef RxHeader;
uint8_t TxData[8];
uint8_t RxData[8];
uint32_t TxMailbox;
/* USER CODE END PV */
/* Private function prototypes -----------------------------------------------*/
void SystemClock_Config(void);
/* USER CODE BEGIN PFP */
/* USER CODE END PFP */
/**
* @brief The application entry point.
* @retval int
*/
int main(void)
{
/* USER CODE BEGIN 1 */
/* USER CODE END 1 */
/* MCU Configuration--------------------------------------------------------*/
/* Reset of all peripherals, Initializes the Flash interface and the Systick. */
HAL_Init();
/* USER CODE BEGIN Init */
/* USER CODE END Init */
/* Configure the system clock */
SystemClock_Config();
/* USER CODE BEGIN SysInit */
/* USER CODE END SysInit */
/* Initialize all configured peripherals */
MX_GPIO_Init();
MX_CAN_Init();
MX_USART1_UART_Init();
/* USER CODE BEGIN 2 */
// === Master节点初始化代码开始 ===
// 1. 配置CAN过滤器
CAN_Filter_Config();
// 2. 启动CAN外设
if (HAL_CAN_Start(&hcan) != HAL_OK)
{
Error_Handler();
}
// 3. 激活接收中断
if (HAL_CAN_ActivateNotification(&hcan, CAN_IT_RX_FIFO0_MSG_PENDING) != HAL_OK)
{
Error_Handler();
}
// 4. 串口打印启动信息
printf("========================================\r\n");
printf(" CAN Master Station Started\r\n");
printf("========================================\r\n");
// === Master节点初始化代码结束 ===
/* USER CODE END 2 */
/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
// 主循环可以空闲,或处理其他任务
HAL_Delay(1000);
/* USER CODE END WHILE */
}
/* USER CODE BEGIN 3 */
}
/**
* @brief System Clock Configuration
* @retval None
*/
void SystemClock_Config(void)
{
// 由CubeMX自动生成,勿手动修改
RCC_OscInitTypeDef RCC_OscInitStruct = {0};
RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};
RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE;
RCC_OscInitStruct.HSEState = RCC_HSE_ON;
RCC_OscInitStruct.HSEPredivValue = RCC_HSE_PREDIV_DIV1;
RCC_OscInitStruct.HSIState = RCC_HSI_ON;
RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON;
RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE;
RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9;
if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK)
{
Error_Handler();
}
RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK|RCC_CLOCKTYPE_SYSCLK
|RCC_CLOCKTYPE_PCLK1|RCC_CLOCKTYPE_PCLK2;
RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1;
RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2;
RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1;
if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_2) != HAL_OK)
{
Error_Handler();
}
}
/* USER CODE BEGIN 0 */
// CAN过滤器配置函数
void CAN_Filter_Config(void)
{
CAN_FilterTypeDef sFilterConfig;
sFilterConfig.FilterBank = 0;
sFilterConfig.FilterMode = CAN_FILTERMODE_IDMASK;
sFilterConfig.FilterScale = CAN_FILTERSCALE_32BIT;
sFilterConfig.FilterIdHigh = 0x0000;
sFilterConfig.FilterIdLow = 0x0000;
sFilterConfig.FilterMaskIdHigh = 0x0000;
sFilterConfig.FilterMaskIdLow = 0x0000;
sFilterConfig.FilterFIFOAssignment = CAN_RX_FIFO0;
sFilterConfig.FilterActivation = ENABLE;
sFilterConfig.SlaveStartFilterBank = 14;
if (HAL_CAN_ConfigFilter(&hcan, &sFilterConfig) != HAL_OK)
{
Error_Handler();
}
}
// printf重定向函数
#ifdef __GNUC__
int _write(int file, char *ptr, int len)
{
HAL_UART_Transmit(&huart1, (uint8_t*)ptr, len, HAL_MAX_DELAY);
return len;
}
#else
int fputc(int ch, FILE *f)
{
HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
return ch;
}
#endif
/* USER CODE END 0 */
/* USER CODE BEGIN 4 */
// CAN接收中断回调函数
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan)
{
if (HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &RxHeader, RxData) == HAL_OK)
{
switch (RxHeader.StdId)
{
case 0x11:
printf("[Node A] Temp: %d C\r\n", RxData[0]);
break;
case 0x22:
printf("[Node B] Humi: %d %%\r\n", RxData[0]);
break;
default:
printf("[Unknown] ID: 0x%X\r\n", RxHeader.StdId);
break;
}
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
}
}
/* USER CODE END 4 */
8. 编译与烧录流程
8.1 工程编译
完成代码编写后,需要进行编译生成可执行文件。在Keil MDK中,点击菜单栏的"Project" → "Build Target"(或直接按F7快捷键)开始编译。首次编译会耗时较长,因为需要编译整个HAL库。后续编译会快很多,因为只编译修改过的文件。
编译过程中会在底部的Build Output窗口显示编译信息。如果代码没有问题,最后会显示"0 Error(s), 0 Warning(s)"。如果有错误,需要根据错误提示进行修改。最常见的错误包括头文件未包含、变量未定义、函数调用参数不匹配等。
8.2 程序烧录
编译成功后,需要将生成的hex文件烧录到STM32芯片中。连接好ST-Link下载器,确保开发板已上电。在Keil中点击"Download"按钮(或按F8快捷键)开始烧录。
烧录过程中,ST-Link的指示灯会闪烁。烧录成功后,可以在Keil的调试界面查看程序是否按预期运行。如果使用CubeProgrammer工具,可以更直观地看到烧录进度和结果。
9. 调试与验证步骤
9.1 验证前的准备工作
在开始验证之前,需要确保所有硬件连接正确。首先检查CAN收发器的供电是否正常,TJA1050需要5V供电,LED应该亮起;SN65HVD230使用3.3V供电,LED状态取决于模块设计。其次检查CAN_H和CAN_L的连接,确保所有节点的CAN_H连在一起,CAN_L连在一起。
然后检查终端电阻的安装。在总线两端的节点(通常是Master和Node B)的CAN_H与CAN_L之间应该各并联一个120欧姆电阻。如果使用面包板,可以直接插上电阻;如果使用专用CAN模块,模块上通常自带终端电阻拨码开关,拨到ON位置即可。
最后准备串口调试助手。Master节点的USART1通过PA9(TX)输出数据。连接USB转TTL模块:USB转TTL的RX接STM32的PA9,USB转TTL的GND接STM32的GND。打开串口调试助手(如SecureCRT、Putty、MobaXterm等),设置波特率115200,数据位8,停止位1,无校验,然后打开串口。
9.2 验证步骤与预期结果
完成以上准备工作后,给所有板子上电。复位Master板子,应该立即在串口调试助手中看到以下输出:
========================================
CAN Master Station Started
========================================
Waiting for CAN data...
如果没有看到这行输出,说明串口连接有问题,或者程序没有正常启动。检查串口调试助手的设置是否正确,检查USB转TTL模块的驱动是否安装成功。
正常情况下,等待几秒钟后,应该能看到Node A和Node B发送的数据交替出现:
[Node A] Temp: 20 C
[Node A] Temp: 21 C
[Node B] Humi: 50 %
[Node A] Temp: 22 C
[Node A] Temp: 23 C
[Node B] Humi: 51 %
[Node A] Temp: 24 C
...
观察这个输出,可以发现:Node A的数据每500ms更新一次,数值递增;Node B的数据每1000ms更新一次,数值递增;两者的更新频率不同,这与代码中的延时时间一致。
同时观察三块开发板的LED灯。Master板的LED应该随着每次数据接收而闪烁;Node A的LED每500ms闪烁一次;Node B的LED每1000ms闪烁一次。这种视觉反馈可以直观地确认每个节点都在正常工作。
9.3 验证失败的处理方法
如果无法看到预期的数据输出,需要按照以下顺序进行排查。首先检查硬件层面:CAN收发器是否正常供电(5V或3.3V根据模块型号而定);CAN_H和CAN_L是否接反;120欧姆终端电阻是否安装;所有板子的GND是否共连。其次检查软件层面:CubeMX中CAN的工作模式是否设置为Normal而不是Loopback;NVIC中是否开启了CAN RX0中断;CAN过滤器是否正确配置。
如果使用示波器或逻辑分析仪,可以直接观察PA12(CAN_TX)引脚上是否有数据波形。正常情况下,应该能看到差分信号。如果TX引脚有波形但总线上没有,可能是收发器损坏或连接问题。
10. 常见问题排查与解决方案
10.1 完全收不到数据
这是最常见的问题。现象是串口调试助手中完全没有输出,Master似乎没有接收到任何数据。造成这个问题的原因有多个方面,需要系统性地排查。
第一个原因是CAN收发器未正确供电。很多初学者会忽略这一点,导致收发器根本不工作。解决方法是用万用表测量收发器模块的VCC和GND之间的电压,确保供电正常。TJA1050模块需要5V供电,如果只接了3.3V,它不会工作但也不会损坏。
第二个原因是CAN_H和CAN_L接反。CAN总线是差分信号,H和L不能接反。解决方法是对照原理图或模块丝印,确认所有节点的CAN_H接在一起,CAN_L接在一起。
第三个原因是最常见的:CAN过滤器配置被遗漏或配置错误。在CubeMX生成的初始代码中,并没有过滤器配置的代码,必须手动添加。如果使用了本教程提供的CAN_Filter_Config函数仍然无法接收,请检查该函数是否在main函数中被调用。
第四个原因是中断未开启。在CubeMX中需要同时在CAN配置界面和NVIC设置中开启中断,缺一不可。
10.2 数据接收时断时续
这个问题表现为串口有输出,但数据会突然丢失或显示乱码。造成这个问题的常见原因包括终端电阻缺失或阻值不对、多个节点未共地、以及总线负载过重。
终端电阻是CAN总线中最容易被忽视的元件。根据CAN标准,总线两端必须安装120欧姆终端电阻。如果缺少终端电阻,信号在总线末端无法被吸收,会反射回来造成干扰,导致远距离通信失败。即使通信距离很短(如实验室环境),最好也加上终端电阻以确保信号质量。
共地问题也很重要。虽然CAN是差分信号,但所有节点的GND必须连接在一起。如果某个节点浮地(没有共地),信号参考点不一致,可能导致接收错误。
10.3 程序卡死在初始化阶段
如果板子上电后LED不亮也不闪烁,串口没有输出,可能是因为程序卡死在了初始化阶段。最常见的原因是HAL库初始化失败,通常是因为CubeMX中的时钟配置与实际硬件不符。
检查开发板上焊接的晶振是8MHz还是其他频率。在CubeMX中,HSE应该选择"Crystal/Ceramic Resonator",而不是"Bypass Clock Source"。如果不确定,可以先用示波器测量晶振引脚是否有波形。
另一个可能的原因是程序下载到了错误的地址。确保ST-Link的SWDIO和SWCLK连接正确,且在CubeProgrammer中选择的是正确的芯片型号。
11. 系统功能扩展思路
11.1 添加数据校验机制
当前的数据协议非常简单,只传输了原始数值。在实际工程中,需要添加数据校验以确保数据的可靠性。可以在数据帧中加入CRC校验码或简单的累加和校验。发送端在填充数据时计算校验码并放在特定字节,接收端收到后重新计算校验码进行比对,如果不匹配则丢弃该帧。
11.2 实现命令下发功能
当前系统是单向通信(从节点到Master)。可以扩展为双向通信,让Master能够向从节点下发命令。例如,让Master发送ID为0x01的命令帧,从节点收到后执行相应动作(如校准传感器、改变采样频率等)。这需要在从节点中也实现接收功能。
11.3 增加更多采集节点
当前系统有2个从节点,理论上CAN总线最多可以支持2^11=2048个不同的节点(标准帧11位ID)。可以按照相同的步骤增加更多节点,只需要给每个节点分配唯一的ID,并在Master的switch-case中添加对应的处理分支即可。
12. 技术总结
通过本项目,我们成功实现了一个完整的CAN总线多节点通信与数据采集系统。从硬件连接到软件配置,从代码编写到调试验证,涵盖了CAN总线应用开发的各个方面。
与传统的UART串口相比,CAN总线具有明显的优势:差分信号传输提供了极高的抗干扰能力,适合工业环境;多主站架构使得任意节点都可以主动发送数据,无需轮询;内置的错误检测和自动重传机制保证了通信的可靠性;最远可达1公里的通信距离使其特别适合大型设备的组网。
虽然本项目的硬件简单、协议简单,但涉及的知识点却是CAN通信的核心基础。掌握这些内容后,可以进一步学习CANopen、DeviceNet等高层协议,或者将CAN总线应用于汽车电子、工业机器人、楼宇自动化等更复杂的系统中。
希望这篇教程能够帮助零基础的学习者快速入门STM32的CAN总线开发。