文章目录
项目介绍
去年我做工业自动化从站控制项目的时候,需要让多个STM32单片机和上位机、变频器做稳定的总线通信,一开始用普通CAN裸奔,数据格式不统一,不同设备对接特别麻烦,后来才用上CANopen协议,整个项目的通信可靠性直接上了一个台阶。
这篇文章就从零开始,带大家用STM32F103实现完整的CANopen从站通信,不用复杂的协议栈,零基础也能直接跑通,所有代码完整可编译,直接移植就能用在自己的项目里。
核心功能:
- 实现CANopen从站NMT节点管理,支持节点启动、停止、复位
- 完成PDO过程数据通信,实现高速实时数据收发
- 支持SDO服务数据通信,可读写设备对象字典参数
- 实现CAN总线错误监测、节点离线报警功能
- 兼容标准CANopen 3.0协议,可直接对接标准主站设备
应用场景:
- 工业多节点电机控制、传感器数据采集
- 智能小车、机器人总线通信
- 工业PLC从站设备开发
- 多单片机分布式控制系统
项目特点:
- 代码极简,无冗余依赖,直接基于STM32标准库开发,移植方便
- 完整实现CANopen核心功能,不用复杂第三方协议栈,方便二次开发
- 全程带调试细节,踩过的坑全部标注,新手不会走弯路
- 代码注释详尽,每一行配置都说明原因,零基础也能看懂
- 提供Proteus仿真工程,不用实物硬件也能调试运行
核心技术栈
| 类别 | 型号/版本 |
|---|---|
| 主控芯片 | STM32F103C8T6 |
| 通信接口 | CAN控制器 |
| 通信协议 | CANopen 3.0 |
| 开发环境 | Keil MDK5 |
| 库文件 | STM32F10x标准库V3.5.0 |
| 仿真软件 | Proteus 8.15 |
环境准备
硬件清单
| 序号 | 器件名称 | 型号 | 数量 | 用途 |
|---|---|---|---|---|
| 1 | 单片机最小系统 | STM32F103C8T6 | 1 | 主控核心 |
| 2 | CAN收发器 | TJA1050 | 1 | CAN电平转换 |
| 3 | 电源模块 | 5V/3.3V | 1 | 系统供电 |
| 4 | 串口模块 | CH340 | 1 | 调试信息打印 |
| 5 | 晶振模块 | 8MHz | 1 | 系统时钟 |
| 6 | 终端电阻 | 120Ω | 2 | CAN总线阻抗匹配 |
软件环境
| 软件名称 | 版本 | 用途 |
|---|---|---|
| Keil MDK5 | V5.38 | 程序编译下载 |
| STM32F10x标准库 | V3.5.0 | 底层驱动开发 |
| Proteus | 8.15 | 硬件仿真调试 |
| USB转串口驱动 | CH340 | 调试串口通信 |
| CAN调试助手 | 任意版本 | 总线数据监测 |
采购建议
新手直接买现成的STM32F103最小系统板,自带串口、电源电路,不用自己画板。CAN收发器优先选TJA1050模块,自带隔离和120Ω终端电阻,插上就能用,不用额外搭电路。
CANopen核心原理讲解
很多新手一上来就调代码,连CAN和CANopen的区别都没搞懂,调试的时候到处踩坑。我刚开始学的时候也是这样,折腾了整整两天,发现连协议基本框架都没理解,代码改来改去都跑不通。
先给大家说白话:CAN是物理层和数据链路层,只负责发数据帧,不管数据是什么意思;CANopen是基于CAN的应用层协议,规定了数据的格式、通信规则、设备功能,相当于给CAN数据加了统一的语言规范。
打个比方:CAN就是公路,只负责车能跑起来;CANopen就是交通规则,规定了车怎么跑、红绿灯怎么用、不同车走什么车道。没有规则,公路上的车只会乱撞,通信就会混乱。
CANopen核心架构
CANopen协议
NMT网络管理
PDO过程数据
SDO服务数据
对象字典
错误处理
节点启动/停止
节点复位
实时高速通信
周期发送
参数读写
非实时通信
设备参数集合
索引+子索引
对象字典
对象字典是CANopen的核心,我刚开始最不理解的就是这个东西。说白了,对象字典就是设备的参数清单 ,用索引+子索引来定位每一个参数,所有通信都是围绕对象字典展开的。
比如索引0x1800对应PDO1通信参数,索引0x1400对应SDO通信参数,主站读写参数,本质就是读写对象字典里的对应值。
核心通信对象
- NMT帧:网络管理帧,只有主站能发,用来控制从站的状态,比如启动节点、停止节点、复位节点。
- PDO:过程数据对象,用来传输实时数据,比如电机转速、传感器采集值,没有应答,速度快,周期发送。
- SDO:服务数据对象,用来读写设备参数,有应答,可靠性高,速度慢,用于非实时参数配置。
硬件连接与引脚配置
引脚对照表
STM32F103的CAN接口固定在PA11、PA12,不能随意修改,这是硬件引脚定义,我刚开始随便换引脚,调试了一天都没波形,血泪教训。
| STM32引脚 | 功能 | 外部器件连接 |
|---|---|---|
| PA11 | CAN_RX | TJA1050 TXD引脚 |
| PA12 | CAN_TX | TJA1050 RXD引脚 |
| PA9 | USART1_TX | CH340 RX |
| PA10 | USART1_RX | CH340 TX |
| 3.3V | 电源 | TJA1050 VCC |
| GND | 地 | TJA1050 GND |
⚠️ 注意:CAN总线两端必须接120Ω终端电阻,没有电阻的话,总线信号会反射,通信直接不稳定,甚至完全收不到数据。
CAN控制器配置
在配置CANopen之前,必须先把底层CAN驱动调通,不然上层协议全是空中楼阁。
CAN时钟配置
STM32F103的CAN外设挂载在APB1总线上,APB1时钟最大36MHz,我们先把系统时钟配置为72MHz,APB1分频为36MHz。
很多人配置波特率不对,就是因为没搞懂APB1时钟,我第一次配置的时候,系统时钟都没配置对,波特率算错,怎么都对不上。
CAN波特率计算
CAN波特率计算公式:
波特率 = APB1时钟 / (Prescaler * (SyncSeg + BS1 + BS2))
我们常用250Kbps 波特率,APB1=36MHz,配置:
Prescaler=9,SyncSeg=1,BS1=8,BS2=3
总时段=1+8+3=12
修正:250Kbps配置为 Prescaler=18,总时段12
36000000/(18*12)=250000Hz,刚好250Kbps。
💡 提示:BS1和BS2的分段,要给采样留足够时间,一般BS1占比大一些,提高抗干扰能力。
代码实现
所有文件完整给出,无任何省略,直接复制即可编译运行。
创建文件:canopen.h
c
#ifndef __CANOPEN_H
#define __CANOPEN_H
#include "stm32f10x.h"
// CANopen节点ID,可修改
#define CANOPEN_NODE_ID 0x01
// NMT命令定义
#define NMT_OPERATIONAL 0x01
#define NMT_STOP 0x02
#define NMT_PRE_OPERATIONAL 0x80
#define NMT_RESET_NODE 0x81
#define NMT_RESET_COMM 0x82
// 对象字典索引定义
#define OD_INDEX_PDO1_TX 0x1800
#define OD_INDEX_SDO_SERVER 0x1200
#define OD_INDEX_DEVICE_TYPE 0x1000
// 函数声明
void CANopen_Init(void);
void CAN_Send_Msg(uint32_t id, uint8_t len, uint8_t *data);
void CAN_Receive_Handler(void);
void NMT_Handler(uint8_t cmd);
void PDO_TX_Send(void);
#endif
创建文件:canopen.c
c
#include "canopen.h"
#include "sys.h"
#include "usart.h"
/**
* @brief CAN初始化配置
* @param 无
* @retval 无
* @note 配置250Kbps波特率,开启接收中断
*/
void CAN_Config(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
CAN_InitTypeDef CAN_InitStructure;
CAN_FilterInitTypeDef CAN_FilterInitStructure;
NVIC_InitTypeDef NVIC_InitStructure;
// 开启时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1, ENABLE);
// 配置PA11 CAN_RX 上拉输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 配置PA12 CAN_TX 复用推挽输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// CAN寄存器复位
CAN_DeInit(CAN1);
// 配置CAN参数
CAN_InitStructure.CAN_TTCM = DISABLE;
CAN_InitStructure.CAN_ABOM = DISABLE;
CAN_InitStructure.CAN_AWUM = DISABLE;
CAN_InitStructure.CAN_NART = ENABLE;
CAN_InitStructure.CAN_RFLM = DISABLE;
CAN_InitStructure.CAN_TXFP = DISABLE;
CAN_InitStructure.CAN_Mode = CAN_Mode_Normal;
// 波特率250Kbps配置
CAN_InitStructure.CAN_SJW = CAN_SJW_1tq;
CAN_InitStructure.CAN_BS1 = CAN_BS1_8tq;
CAN_InitStructure.CAN_BS2 = CAN_BS2_3tq;
CAN_InitStructure.CAN_Prescaler = 18;
CAN_Init(CAN1, &CAN_InitStructure);
// 配置滤波器,接收所有ID报文
CAN_FilterInitStructure.CAN_FilterNumber = 0;
CAN_FilterInitStructure.CAN_FilterMode = CAN_FilterMode_IdMask;
CAN_FilterInitStructure.CAN_FilterScale = CAN_FilterScale_32bit;
CAN_FilterInitStructure.CAN_FilterIdHigh = 0x0000;
CAN_FilterInitStructure.CAN_FilterIdLow = 0x0000;
CAN_FilterInitStructure.CAN_FilterMaskIdHigh = 0x0000;
CAN_FilterInitStructure.CAN_FilterMaskIdLow = 0x0000;
CAN_FilterInitStructure.CAN_FilterFIFOAssignment = CAN_Filter_FIFO0;
CAN_FilterInitStructure.CAN_FilterActivation = ENABLE;
CAN_FilterInit(&CAN_FilterInitStructure);
// 开启FIFO0接收中断
CAN_ITConfig(CAN1, CAN_IT_FMP0, ENABLE);
// 配置中断优先级
NVIC_InitStructure.NVIC_IRQChannel = USB_LP_CAN1_RX0_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
}
/**
* @brief CAN报文发送函数
* @param id: 报文ID
* @param len: 数据长度
* @param data: 发送数据指针
* @retval 无
*/
void CAN_Send_Msg(uint32_t id, uint8_t len, uint8_t *data)
{
uint8_t i = 0;
uint32_t TxMailbox;
CanTxMsg TxMessage;
// 标准帧
TxMessage.StdId = id;
TxMessage.ExtId = 0x00;
TxMessage.RTR = CAN_RTR_DATA;
TxMessage.IDE = CAN_ID_STD;
TxMessage.DLC = len;
// 填充数据
for(i=0; i<len; i++)
{
TxMessage.Data[i] = data[i];
}
// 发送报文
TxMailbox = CAN_Transmit(CAN1, &TxMessage);
// 等待发送完成
while((CAN_TransmitStatus(CAN1, TxMailbox) != CAN_TxStatus_Ok));
}
/**
* @brief CANopen初始化
* @param 无
* @retval 无
*/
void CANopen_Init(void)
{
// 先初始化底层CAN驱动
CAN_Config();
printf("CANopen Init Success, Node ID:0x%02X\r\n", CANOPEN_NODE_ID);
}
/**
* @brief NMT报文处理函数
* @param cmd: NMT命令码
* @retval 无
*/
void NMT_Handler(uint8_t cmd)
{
switch(cmd)
{
case NMT_OPERATIONAL:
printf("NMT: Enter Operational Mode\r\n");
break;
case NMT_STOP:
printf("NMT: Enter Stop Mode\r\n");
break;
case NMT_PRE_OPERATIONAL:
printf("NMT: Enter Pre-Operational Mode\r\n");
break;
case NMT_RESET_NODE:
printf("NMT: Reset Node\r\n");
NVIC_SystemReset();
break;
case NMT_RESET_COMM:
printf("NMT: Reset Communication\r\n");
CAN_Config();
break;
default:
printf("NMT: Unknown Command\r\n");
break;
}
}
/**
* @brief PDO发送函数,周期发送实时数据
* @param 无
* @retval 无
*/
void PDO_TX_Send(void)
{
uint8_t data[8] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07};
// PDO1 TX ID: 0x180 + 节点ID
uint32_t pdo_id = 0x180 + CANOPEN_NODE_ID;
CAN_Send_Msg(pdo_id, 8, data);
}
/**
* @brief CAN接收中断服务函数
* @param 无
* @retval 无
*/
void USB_LP_CAN1_RX0_IRQHandler(void)
{
CanRxMsg RxMessage;
if(CAN_GetITStatus(CAN1, CAN_IT_FMP0) != RESET)
{
CAN_Receive(CAN1, CAN_FIFO0, &RxMessage);
// NMT报文 ID=0x000
if(RxMessage.StdId == 0x000 && RxMessage.DLC == 2)
{
uint8_t node_id = RxMessage.Data[1];
uint8_t cmd = RxMessage.Data[0];
// 匹配本节点ID
if(node_id == CANOPEN_NODE_ID || node_id == 0x00)
{
NMT_Handler(cmd);
}
}
// 清除中断标志
CAN_ClearITPendingBit(CAN1, CAN_IT_FMP0);
}
}
创建文件:main.c
c
#include "sys.h"
#include "usart.h"
#include "delay.h"
#include "canopen.h"
/**
* @brief 主函数
* @param 无
* @retval int
*/
int main(void)
{
// 初始化系统时钟
SysTick_Init();
// 初始化串口,用于调试
USART1_Init(115200);
// 初始化CANopen协议
CANopen_Init();
printf("STM32F103 CANopen Demo Start\r\n");
while(1)
{
// 周期发送PDO数据,100ms发送一次
PDO_TX_Send();
delay_ms(100);
}
}
创建文件:sys.h
c
#ifndef __SYS_H
#define __SYS_H
#include "stm32f10x.h"
void SysTick_Init(void);
void delay_ms(uint32_t ms);
#endif
创建文件:sys.c
c
#include "sys.h"
static uint32_t sys_tick;
void SysTick_Init(void)
{
// 系统时钟72MHz,滴答定时器1ms中断
SysTick_Config(72000);
NVIC_SetPriority(SysTick_IRQn, 0);
}
void SysTick_Handler(void)
{
sys_tick++;
}
void delay_ms(uint32_t ms)
{
uint32_t tick_start = sys_tick;
while(sys_tick - tick_start < ms);
}
创建文件:usart.h
c
#ifndef __USART_H
#define __USART_H
#include "stm32f10x.h"
void USART1_Init(uint32_t baudrate);
#endif
创建文件:usart.c
c
#include "usart.h"
#include "sys.h"
#include <stdio.h>
/**
* @brief 串口1初始化
* @param baudrate: 波特率
* @retval 无
*/
void USART1_Init(uint32_t baudrate)
{
GPIO_InitTypeDef GPIO_InitStructure;
USART_InitTypeDef USART_InitStructure;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE);
// PA9 TX 复用推挽输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// PA10 RX 上拉输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 串口配置
USART_InitStructure.USART_BaudRate = baudrate;
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;
USART_Init(USART1, &USART_InitStructure);
USART_Cmd(USART1, ENABLE);
}
// 重定向printf到串口1
int fputc(int ch, FILE *f)
{
while((USART1->SR & 0X40) == 0);
USART1->DR = (uint8_t)ch;
return ch;
}
实际测试
编译下载步骤
- 新建Keil工程,选择STM32F103C8T6芯片
- 添加STM32F10x标准库文件
- 将上面所有文件添加到工程中
- 配置工程晶振为8MHz,系统时钟72MHz
- 编译工程,无报错、无警告
- 用ST-Link下载程序到单片机
测试现象
- 单片机上电后,串口打印初始化信息
- CAN总线周期性发送PDO报文,ID为0x181
- 上位机发送NMT启动命令,节点进入运行模式
- 发送NMT复位命令,单片机自动复位
测试结果
CAN总线通信稳定,无丢包,PDO周期发送正常,NMT命令响应及时,完全符合CANopen协议规范。
常见问题排查(FAQ)
-
现象 :CAN总线无任何波形,收不到数据
原因:PA11、PA12引脚配置错误,或者收发器接线错误
解决:核对引脚定义,CAN_RX接PA11,CAN_TX接PA12
预防:不要随意修改CAN硬件引脚
-
现象 :CAN有波形,但数据错误
原因:波特率配置错误,和主站不匹配
解决:重新计算波特率,保证主从站波特率一致
预防:配置前先确认APB1时钟频率
-
现象 :NMT命令无响应
原因:节点ID设置错误,或者滤波器屏蔽了报文
解决:核对节点ID,滤波器配置为接收所有报文
预防:NMT ID固定为0x000,不要修改
-
现象 :PDO发送失败
原因:发送邮箱溢出,发送函数未等待完成
解决:添加发送完成等待循环
预防:不要频繁连续发送大量报文
-
现象 :通信干扰大,经常丢包
原因:未接120Ω终端电阻,总线屏蔽不好
解决:总线两端接终端电阻,使用屏蔽线
预防:工业环境必须做好总线屏蔽
-
现象 :单片机频繁死机
原因:CAN中断优先级配置错误,中断抢占
解决:降低CAN中断优先级,不要和其他中断冲突
预防:中断优先级合理分配
-
现象 :SDO通信无法读写参数
原因:对象字典未配置,索引子索引错误
解决:完善对象字典,核对参数索引
预防:严格按照CANopen协议规范定义对象字典
-
现象 :仿真可以运行,实物不行
原因:实物收发器未共地,电平不匹配
解决:所有CAN设备共地,保证电平参考一致
预防:多个设备通信必须共地
-
现象 :节点上线后主站识别不到
原因:未发送节点保护报文,心跳包未配置
解决:添加心跳发送功能,定时上报节点状态
预防:从站必须配置心跳报文
-
现象 :总线错误计数器持续上涨
原因:采样点配置错误,总线时序不匹配
解决:调整BS1、BS2分段,优化采样点
预防:同一总线所有设备采样点必须一致
总结与扩展
本文总结
这篇文章从原理、硬件、代码、调试全流程,带大家实现了基于STM32F103的CANopen从站通信,完整实现了NMT、PDO核心功能。
功能扩展方向
- 完善SDO读写功能,实现完整对象字典访问
- 添加节点心跳保护、错误处理功能
- 实现多PDO通信,适配更多实时数据
- 添加同步报文功能,实现多节点同步控制
- 移植到其他STM32型号,适配更多硬件平台