ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32裸写CANOpen主机:从底层驱动到伺服控制实战

STM32裸写CANOpen主机:从底层驱动到伺服控制实战 1. 项目缘起与整体设计思路1.1 为什么要在STM32上裸写CANOpen主机手头有个项目一台STM32F407的板子要当CANOpen主机去控制底下挂着的几个伺服驱动器。一开始想的是直接上现成的CANOpen协议栈比如CANopenNode或者商用库但评估下来发现几个问题一是现成协议栈代码量不小移植进来Flash和RAM都吃紧二是项目里主机只需要用到PDO和SDONMT和心跳这些基础功能协议栈里一大半功能用不上三是后期维护要自己完全掌控代码逻辑不想被第三方库的版本更新牵着走。所以最终决定在STM32上独立实现一个精简版CANOpen主机。所谓“独立实现”不是说从零发明协议而是不依赖完整协议栈自己按CiA 301标准把CANOpen的通信机制、对象字典、状态机这些核心部分用C语言写出来直接跑在STM32的CAN外设上。这个方案适合谁适合那些对CANOpen协议有一定了解、需要在资源受限的MCU上做主机控制、又不想被庞大协议栈绑架的嵌入式开发者。如果你之前只用过串口或Modbus对CAN总线还比较陌生建议先把CAN的帧格式和仲裁机制搞清楚再来看这篇内容不然中间有些细节会卡住。1.2 整体架构怎么搭整个方案分四层从下往上依次是硬件层STM32的bxCAN外设配收发器接CAN总线波特率设1Mbps根据总线长度和节点数调整。驱动层CAN初始化、发送、接收中断加上一个环形缓冲区管理接收到的报文。协议层CANOpen的核心——COB-ID分配、SDO客户端、PDO收发、NMT状态机、心跳/节点保护。应用层对象字典的本地映射、伺服控制逻辑、状态切换流程。这个分层的好处是每层职责清晰调试的时候可以逐层验证。比如先确认CAN能正常收发标准帧再往上测SDO读对象字典最后跑PDO控制伺服。注意不要一上来就把所有层写完再调试那样出了问题根本不知道是哪一层的锅。我踩过这个坑后来改成每写完一层就用CAN分析仪抓包验证效率高很多。1.3 关键设计取舍COB-ID的分配方式CANOpen预定义连接集里每个节点的COB-ID跟节点ID绑定。比如SDO请求是0x600NodeIDSDO响应是0x580NodeIDTPDO1是0x180NodeIDRPDO1是0x200NodeID。我选择直接用预定义连接集不做动态分配因为项目里节点数量固定没必要引入复杂的配置流程。对象字典的实现完整对象字典是个大表但主机端其实只需要访问从节点的对象字典本地不需要维护完整字典。所以我的做法是本地只维护一个精简的索引表记录需要访问的对象的索引、子索引、数据类型和长度SDO读写的时候按这个表来组包。PDO的映射主机发送RPDO给伺服伺服回TPDO给主机。映射关系在伺服那边配置好主机端只需要按固定的数据长度和格式解析就行。这里要注意字节序——CANOpen默认小端但有些伺服厂商会搞成大端调试的时候如果数据对不上先查字节序。2. CAN底层驱动与报文管理2.1 STM32 bxCAN的初始化要点STM32的bxCAN外设配置有几个关键参数配错了后面全白搭。我以STM32F407为例用HAL库来写但逻辑对标准库也适用。首先是波特率。CAN的波特率由APB1时钟、预分频系数、BS1和BS2段长度决定。公式是波特率 APB1时钟 / (预分频 × (1 BS1 BS2))假设APB1是42MHz要设1Mbps可以取预分频6BS14BS22那么42000000 / (6 × (1 4 2)) 42000000 / 42 1000000正好1Mbps。采样点位置在(1BS1)/(1BS1BS2) 5/7 ≈ 71.4%这个位置比较标准通信稳定性好。初始化代码大概长这样CAN_HandleTypeDef hcan; hcan.Instance CAN1; hcan.Init.Prescaler 6; hcan.Init.Mode CAN_MODE_NORMAL; hcan.Init.SyncJumpWidth CAN_SJW_1TQ; hcan.Init.TimeSeg1 CAN_BS1_4TQ; hcan.Init.TimeSeg2 CAN_BS2_2TQ; hcan.Init.TimeTriggeredMode DISABLE; hcan.Init.AutoBusOff ENABLE; hcan.Init.AutoWakeUp DISABLE; hcan.Init.AutoRetransmission ENABLE; hcan.Init.ReceiveFifoLocked DISABLE; hcan.Init.TransmitFifoPriority DISABLE; HAL_CAN_Init(hcan);这里AutoBusOff建议开总线出问题的时候能自动恢复。AutoRetransmission也建议开CANOpen的SDO传输需要可靠重传。2.2 过滤器配置的坑CAN过滤器是新手最容易翻车的地方。STM32F407有28个过滤器组可以配成掩码模式或列表模式。我的做法是只接收需要的COB-ID其他全部过滤掉减少中断负担。主机需要接收的COB-ID有0x580NodeIDSDO响应0x180NodeIDTPDO10x280NodeIDTPDO2如果用了的话0x700NodeID心跳报文如果节点ID是1那就是0x581、0x181、0x281、0x701。用列表模式配四个过滤器组最直接CAN_FilterTypeDef filter; filter.FilterMode CAN_FILTERMODE_IDLIST; filter.FilterScale CAN_FILTERSCALE_16BIT; filter.FilterIdHigh 0x581 5; filter.FilterIdLow 0x181 5; filter.FilterMaskIdHigh 0x281 5; filter.FilterMaskIdLow 0x701 5; filter.FilterFIFOAssignment CAN_RX_FIFO0; filter.FilterBank 0; filter.FilterActivation ENABLE; HAL_CAN_ConfigFilter(hcan, filter);注意16位列表模式下每个过滤器组只能放两个ID所以上面四个ID需要两个过滤器组。我图省事写在一起是错的实际要分两组配。这个坑我调了半天才发现抓包看到报文进不来查了手册才知道。2.3 接收环形缓冲区的设计CAN接收中断里不能做太耗时的操作所以中断里只做一件事把报文塞进环形缓冲区然后置个标志位。主循环里再慢慢解析。环形缓冲区用数组加读写指针实现大小取32或64够用了。结构体大概这样typedef struct { uint32_t id; uint8_t data[8]; uint8_t dlc; } CanFrame; typedef struct { CanFrame buf[64]; volatile uint8_t head; volatile uint8_t tail; } CanRingBuf;中断里写head主循环读tail注意head和tail要加volatile不然编译器优化会出问题。这个细节看着小但实际调试的时候如果发现数据偶尔丢八成就是没加volatile。2.4 发送流程与超时处理发送用HAL_CAN_AddTxMessage但要注意发送邮箱满的情况。如果三个邮箱都满了这个函数会返回HAL_ERROR。我的处理方式是发送失败就等一小会儿重试重试三次还不行就报错。uint8_t CanSend(uint32_t id, uint8_t *data, uint8_t len) { CAN_TxHeaderTypeDef txHeader; uint32_t txMailbox; txHeader.StdId id; txHeader.IDE CAN_ID_STD; txHeader.RTR CAN_RTR_DATA; txHeader.DLC len; for (int i 0; i 3; i) { if (HAL_CAN_AddTxMessage(hcan, txHeader, data, txMailbox) HAL_OK) { return 0; } HAL_Delay(1); } return 1; }HAL_Delay在中断里不能用所以这个函数只能在主循环或任务里调。如果要在中断里发得用非阻塞的方式记录邮箱状态下次再发。3. CANOpen协议核心实现3.1 SDO客户端的上传下载流程SDO是CANOpen里最基础也最常用的通信方式用来读写从节点的对象字典。主机作为SDO客户端从节点是SDO服务器。**SDO上传读**的流程主机发请求COB-ID 0x600NodeID数据为[0x40, index_low, index_high, subindex, 0, 0, 0, 0]。命令字0x40表示“读”。从节点回响应COB-ID 0x580NodeID数据为[0x43, index_low, index_high, subindex, data0, data1, data2, data3]。命令字0x43表示“读响应4字节数据”。主机解析数据。命令字的高位有含义0x40是请求0x43是4字节响应0x4B是2字节响应0x4F是1字节响应。解析的时候要根据命令字判断数据长度。**SDO下载写**的流程主机发请求COB-ID 0x600NodeID数据为[0x23, index_low, index_high, subindex, data0, data1, data2, data3]。命令字0x23表示“写4字节”。从节点回响应COB-ID 0x580NodeID数据为[0x60, index_low, index_high, subindex, 0, 0, 0, 0]。命令字0x60表示“写成功”。如果写的是2字节命令字用0x2B1字节用0x2F。从节点返回0x80表示错误后面四个字节是错误码。我封装了两个函数uint8_t SDO_Read(uint8_t nodeId, uint16_t index, uint8_t subIndex, uint32_t *value); uint8_t SDO_Write(uint8_t nodeId, uint16_t index, uint8_t subIndex, uint32_t value, uint8_t size);SDO_Read里发完请求后要等响应超时时间设100ms。超时了返回错误调用者决定重试还是放弃。实操心得SDO传输一定要加超时不然从节点掉线了主机会死等。我一开始没加超时调试的时候从节点断电主机直接卡死查了半天才发现是这里的问题。3.2 PDO的收发与映射解析PDO是过程数据对象用来传实时数据比如伺服的位置、速度、状态字。PDO分TPDO从节点发和RPDO从节点收。主机控制伺服主要用RPDO发控制字和目标位置用TPDO读状态字和实际位置。PDO的COB-IDRPDO10x200NodeIDRPDO20x300NodeIDTPDO10x180NodeIDTPDO20x280NodeIDPDO的数据长度和内容由映射参数决定映射在从节点那边配置。主机端只需要按约定的格式组包和解析。比如RPDO1映射了控制字2字节和目标速度4字节那主机发的数据就是6字节前2字节控制字后4字节速度值小端格式。void SendRPDO1(uint8_t nodeId, uint16_t controlWord, int32_t targetVelocity) { uint8_t data[6]; data[0] controlWord 0xFF; data[1] (controlWord 8) 0xFF; data[2] targetVelocity 0xFF; data[3] (targetVelocity 8) 0xFF; data[4] (targetVelocity 16) 0xFF; data[5] (targetVelocity 24) 0xFF; CanSend(0x200 nodeId, data, 6); }TPDO的解析反过来从接收缓冲区里拿到0x180NodeID的报文按映射顺序拆出状态字和实际位置。PDO的传输类型也要注意同步传输还是异步传输事件驱动还是定时驱动。这些在从节点配置主机端一般不用管但要知道从节点的TPDO什么时候会来不然解析的时候会等不到数据。3.3 NMT状态机与节点控制NMT是网络管理主机通过NMT报文控制从节点的状态。NMT报文的COB-ID固定是0x000数据2字节第一个字节是命令第二个字节是节点ID0表示所有节点。命令有0x01启动远程节点进入Operational0x02停止远程节点进入Stopped0x80进入Pre-operational0x81复位节点0x82复位通信伺服控制的一般流程是上电后从节点在Pre-operational主机先配置PDO映射和参数用SDO配置完发NMT启动命令让从节点进入Operational然后开始PDO通信。void NMT_Start(uint8_t nodeId) { uint8_t data[2] {0x01, nodeId}; CanSend(0x000, data, 2); }注意NMT报文优先级最高COB-ID最小所以总线上如果有NMT报文其他报文都要让路。调试的时候如果发现PDO延迟大先看看是不是NMT发太频繁了。3.4 心跳与节点保护心跳是从节点定期发的报文COB-ID 0x700NodeID数据1字节表示节点状态0x00Boot-up0x04Stopped0x05Operational0x7FPre-operational主机通过心跳判断从节点是否在线。如果超过心跳周期没收到就认为节点掉线做相应处理比如报警、停机。心跳周期在从节点配置一般设100ms到1s。主机端维护一个计时器每个节点一个收到心跳就重置超时就标记离线。typedef struct { uint8_t nodeId; uint32_t lastHeartbeat; uint8_t online; } NodeStatus; NodeStatus nodes[8]; void CheckHeartbeat(void) { for (int i 0; i 8; i) { if (nodes[i].nodeId 0) continue; if (HAL_GetTick() - nodes[i].lastHeartbeat 500) { nodes[i].online 0; } } }节点保护是另一种机制主机主动发请求从节点回应。心跳是被动的节点保护是主动的。我选心跳因为实现简单从节点配置一下就行主机端不用发请求。4. 伺服控制实战与状态切换4.1 伺服状态机的理解CANOpen伺服遵循CiA 402协议状态机比CANOpen本身还复杂。简单说伺服有几个状态Not Ready to Switch On、Switch On Disabled、Ready to Switch On、Switched On、Operation Enabled、Quick Stop Active、Fault Reaction Active、Fault。控制字0x6040和状态字0x6041是状态切换的关键。控制字的位有特定含义Bit 0Switch OnBit 1Enable VoltageBit 2Quick StopBit 3Enable OperationBit 7Fault Reset状态字的位Bit 0Ready to Switch OnBit 1Switched OnBit 2Operation EnabledBit 3FaultBit 6Switch On Disabled上电后主机要按顺序发控制字让伺服从Switch On Disabled一路走到Operation Enabled。顺序是发0x0006Shutdown等状态字Bit 0置位发0x0007Switch On等状态字Bit 1置位发0x000FEnable Operation等状态字Bit 2置位每一步都要等状态字确认不能跳步。我一开始图快直接发0x000F结果伺服不动查了半天才发现状态机没走完。4.2 位置模式的控制流程伺服进入Operation Enabled后可以设模式。位置模式是0x6060 1。然后设目标位置0x607A启动运动。控制流程SDO写0x6060 1设位置模式SDO写0x607A 目标位置RPDO发控制字0x001FEnable Operation 新目标点等状态字Bit 10Target Reached置位目标位置是32位有符号数单位是脉冲或用户单位取决于0x6091的配置。void MoveToPosition(uint8_t nodeId, int32_t position) { SDO_Write(nodeId, 0x607A, 0, position, 4); uint16_t controlWord 0x001F; SendRPDO1(nodeId, controlWord, 0); }实操心得目标位置写进去后控制字的Bit 4New Set-point要产生一个上升沿伺服才会认新目标。如果连续发同样的控制字伺服可能不响应。我的做法是每次发之前先清Bit 4再置Bit 4。4.3 速度模式与转矩模式速度模式设0x6060 3目标速度写0x60FF。转矩模式设0x6060 4目标转矩写0x6071。速度模式的控制字和位置模式类似也是0x001F。转矩模式要注意转矩限制0x6072设最大转矩别设太大把电机烧了。模式切换的时候要先让伺服停下来再切模式不然可能报错。我试过运行中直接切模式伺服直接Fault复位了半天才恢复。4.4 错误处理与复位伺服报错的时候状态字Bit 3置位。这时候要读错误码0x603F看是什么错。常见错误0x2310过流0x3210过压0x8611位置超差处理完错误后发控制字0x0080Fault Reset复位然后重新走状态机。void ResetFault(uint8_t nodeId) { uint16_t controlWord 0x0080; SendRPDO1(nodeId, controlWord, 0); HAL_Delay(10); controlWord 0x0006; SendRPDO1(nodeId, controlWord, 0); }复位后要等状态字确认再重新使能。5. 调试技巧与常见问题排查5.1 抓包工具的选择与使用调试CANOpen抓包工具是必须的。我用的是USB-CAN分析仪配合上位机软件。抓包能看到总线上所有报文包括COB-ID、数据、时间戳。抓包的时候注意几点先确认波特率对不对波特率错了什么都抓不到看SDO请求和响应是否配对命令字是否正确看PDO的周期是否稳定有没有丢帧看心跳是否按时到达如果抓不到报文先查硬件CAN_H和CAN_L有没有接反终端电阻有没有接120欧姆收发器供电是否正常。5.2 常见问题速查表现象可能原因排查方法总线无报文波特率不对、接线错误、终端电阻缺失查波特率配置、量CAN_H和CAN_L电压SDO超时从节点不在Pre-operational、COB-ID不对抓包看请求是否发出、从节点是否响应PDO数据不对字节序错误、映射不匹配对比从节点映射参数、检查大小端伺服不使能状态机没走完、控制字顺序错抓包看控制字序列、读状态字心跳丢失心跳周期配置错、从节点掉线抓包看0x700NodeID报文总线错误波特率偏差大、线太长、干扰查采样点、缩短线长、加屏蔽5.3 我踩过的坑坑一过滤器配置错误。前面提过16位列表模式一个组只能放两个ID我一开始放了四个结果只收到两个ID的报文。后来改成两个组才正常。坑二SDO没加超时。从节点断电后主机死等整个系统卡死。加了超时后超时返回错误上层可以做降级处理。坑三控制字顺序错。直接发0x000F想一步到位伺服不响应。必须按Shutdown→Switch On→Enable Operation的顺序来。坑四字节序搞反。伺服的位置反馈是大端我按小端解析数据完全不对。后来查了手册才发现改成大端解析就对了。坑五PDO发送太快。1ms发一次RPDO总线负载太高导致SDO偶尔超时。后来改成5ms一次稳定多了。5.4 性能优化建议如果总线负载高可以降低PDO发送频率非关键数据用SDO传合并PDO把多个数据映射到一个PDO里用同步PDO减少仲裁开销提高波特率从500K提到1M如果CPU负载高可以接收中断里只做入队解析放主循环用DMA搬CAN数据部分STM32支持减少不必要的SDO读写缓存常用参数6. 对象字典的精简实现6.1 本地索引表的设计主机端不需要完整对象字典但需要知道要访问的对象的属性。我设计了一个索引表typedef struct { uint16_t index; uint8_t subIndex; uint8_t size; uint32_t value; } ObjectEntry; ObjectEntry objTable[] { {0x6040, 0, 2, 0}, // 控制字 {0x6041, 0, 2, 0}, // 状态字 {0x6060, 0, 1, 0}, // 模式 {0x607A, 0, 4, 0}, // 目标位置 {0x6064, 0, 4, 0}, // 实际位置 {0x6071, 0, 2, 0}, // 目标转矩 };SDO读写的时候先查表找到对象再按size组包。这样代码简洁也方便扩展。6.2 数据类型的处理CANOpen的数据类型有BOOLEAN、INTEGER8/16/32、UNSIGNED8/16/32、REAL32等。主机端主要用INTEGER和UNSIGNED。读写的时候要注意符号扩展。比如读一个INTEGER16高字节的符号位要扩展到32位int16_t val16 (data[1] 8) | data[0]; int32_t val32 (int32_t)val16;写的时候反过来取低16位。REAL32就是float直接memcpy就行注意大小端。6.3 字典的扩展与维护项目后期如果要加新对象只需要在表里加一行SDO函数不用改。这是查表法的好处。如果对象很多可以按索引排序用二分查找加速。不过一般主机访问的对象就几十个线性查找够用了。7. 从节点配置与联调7.1 从节点的PDO映射配置从节点的PDO映射一般用厂商工具配配好后存在从节点的非易失存储里。主机端要知道映射内容才能正确组包和解析。配置的时候注意RPDO1映射控制字和目标值TPDO1映射状态字和实际值传输类型选异步或事件驱动周期设合适如果从节点支持可以用SDO在线改映射但改完要复位通信才生效。7.2 联调步骤联调按这个顺序来确认硬件连接终端电阻供电抓包确认从节点心跳正常用SDO读从节点的设备类型0x1000确认通信正常配置PDO映射如果需要发NMT启动命令让从节点进入Operational走伺服状态机使能伺服发位置/速度指令观察运动测试错误处理和复位每一步都要抓包确认不要跳步。7.3 多节点控制如果总线上有多个伺服每个节点的COB-ID不同主机要分别处理。心跳要分别监控SDO要分别发PDO要分别组包。节点多了总线负载会高要算一下总负载率。1Mbps下每帧最多130位左右如果10个节点每个1ms发一次TPDO负载率大概10 × 130 / 1000 1.3超过1了肯定不行。得降低频率或提高波特率。8. 代码组织与移植建议8.1 文件结构我的代码分这几个文件can_driver.c/hCAN初始化、发送、接收中断、环形缓冲区canopen.c/hSDO、PDO、NMT、心跳cia402.c/h伺服状态机、控制字/状态字处理obj_dict.c/h对象字典索引表main.c应用逻辑这样分文件移植的时候只需要改can_driver.c里的硬件相关部分其他不用动。8.2 移植到其他STM32型号不同STM32的CAN外设略有差异但bxCAN的基本逻辑一样。移植的时候注意时钟配置不同波特率参数要重算过滤器数量不同F1有14个F4有28个中断向量不同改中断服务函数名HAL库的CAN API基本一致改改初始化参数就行。8.3 移植到其他CAN控制器如果换成其他MCU比如NXP或Infineon的CAN驱动要重写但CANOpen层不用动。这就是分层的好处。CANOpen层只依赖两个函数CanSend和接收回调。把这两个函数适配好上层就能跑。9. 实际项目中的经验总结9.1 稳定性设计工业现场干扰大CAN总线容易出错。我的做法是开AutoBusOff总线错误自动恢复SDO加超时和重试心跳监控节点掉线报警关键数据用SDO确认不只用PDO9.2 实时性考虑PDO的实时性要求高发送周期要稳定。我用定时器中断触发PDO发送不用HAL_Delay避免主循环阻塞影响周期。接收中断优先级设高一点保证报文不丢。但中断里不要做耗时操作入队就行。9.3 可维护性代码里加足够的注释特别是COB-ID的计算、命令字的含义、状态字的位定义。这些过几个月自己都会忘。对象字典的索引表单独放一个文件加新对象的时候只改这个文件不用翻遍代码。调试信息用串口打印但要注意打印不能影响实时性。我的做法是关键事件打印周期性的数据不打印或者用条件编译控制。9.4 测试用例我写了几个测试用例单节点SDO读写测试单节点PDO收发测试多节点心跳监控测试伺服状态机切换测试错误注入测试拔线、断电、发错误命令每个用例都有预期结果跑一遍确认没问题再上现场。这个方案在项目里跑了半年多控制四台伺服1Mbps总线5ms PDO周期没出过通信问题。后来有个新项目要控制八台我把PDO周期改成10ms负载率降下来也很稳定。最后分享一个小技巧如果调试的时候发现SDO偶尔超时但抓包看从节点明明回了那可能是主机的接收缓冲区满了。把缓冲区加大或者加快主循环的解析速度就能解决。我遇到过这个问题查了两天才发现是缓冲区溢出。
返回列表