简介:一套基于STM32F103单片机与CAN通信实现无刷电机命令控制的完整工程资源,面向嵌入式开发者和电机控制初学者,适用于无人机、电动车及工业自动化等需要远程调速与状态监控的场景。项目完整覆盖CAN报文收发、三相逆变器驱动、霍尔传感器位置检测与PWM换相控制,能够展示从上位机下发指令到电机实际响应的全链路实现,帮助读者建立系统级认识。资源包共202个文件,压缩后约577KB,以C源码和头文件为主,同时含有编译生成的axf、hex固件以及Keil工程配置,便于直接打开研究或烧录验证。已有661人浏览学习,包内除工程源码外,还整理了CAN模块配置方法、六步换相与FOC控制思路、模块化程序结构及开发者学习笔记,清除了上手障碍。对于希望快速入门STM32 CAN总线电机控制或基于F103做二次开发的读者,是一份兼具参考性和实用性的资料。
1. 项目定位与技术选型:为什么是F103+CAN+无刷电机
在设备调试现场,最让人头疼的往往不是算法写不出来,而是多台无刷电机各自为政,PWM线、方向线、使能线拉了一捆,一上电就互相干扰。这个项目就是经典的“STM32F103 + CAN通信 + 无刷电机”组合:上位机或主控通过CAN总线发送命令帧,节点板解析帧ID和Data字段,控制一台无刷电机启停、调速、回报状态。核心价值在于把多电机控制从“每台电机一对线”变成“一根总线挂所有节点”,适合正在做机器人底盘、分拣机构、多轴联动设备的开发者,也适合想把电机控制纳入总线协议体系的同学拿去当工程框架。
2. 系统架构与硬件设计:从CAN收发器到电机驱动桥的完整链路
2.1 整体架构:CAN总线如何把主机和电机节点串起来
这个系统在物理上分成两类节点:主机节点和电机控制节点。主机可以是另一片STM32F103,也可以是带CAN口的工控板、树莓派加扩展模块,甚至是一台USB-CAN分析仪。电机控制节点才是项目的主体,它要做的事情可以拆成三条链路:接收链路(CAN收发器→bxCAN外设→接收中断→命令解析)、控制链路(命令解析结果→定时器PWM→栅极驱动→三相全桥→无刷电机)、反馈链路(霍尔传感器→GPIO/输入捕获→换相和测速)。
用CAN而不是I²C或UART,是因为无刷电机控制现场有两个硬指标:一是实时性,命令帧从总线上出现到电机开始响应,整个过程应该在几百微秒内完成;二是抗干扰,电机驱动部分的大电流开关会让普通串口信号劣化,而CAN的差分信号和错误检测机制能容忍较高的共模噪声。再加上CAN总线天然支持多主机仲裁,这意味着以后增加节点时不需要改任何现有代码的地址配置,直接把新节点挂到总线上就行。
协议层面,项目采用了CAN 2.0标准帧,波特率按500kbps配置,应用层协议则完全自定义。主机发命令帧,节点回状态帧,用帧ID区分命令类型,用Data段携带参数。这种“物理层标准、应用层自定义”的做法在电机控制场景里非常常见,既保留了CAN的可靠性,又让协议足够精简,便于在中断里快速解析。
2.2 器件选型和接线:每个引脚都要有数
主控芯片用的是STM32F103C8T6,72MHz主频,128KB Flash,20KB RAM。在方波六步换相方案下,这颗芯片的计算余量非常充足,即使把PID闭环、状态回报、命令解析全部跑在中断里,CPU占用率依然很低。CAN收发器选择的是常见的TJA1050,它把MCU侧的TXD/RXD电平转换成CAN_H和CAN_L差分信号,直接驱动双绞线。
电机驱动部分有两种做法:一种是直接购买集成好的无刷驱动模块,MCU只输出PWM和方向信号,风险低但控制粒度受模块限制;另一种是用三相栅极驱动芯片加6个MOS管自己搭全桥,灵活性高,适合需要深入改驱动时序的项目。本文按后者讲解,因为源码工程里配套的是分立方案。
引脚分配遵循一个原则:CAN和霍尔这类实时信号优先挂在有中断映射的引脚上,PWM输出挂在高级定时器通道上。推荐分配如下:
| 引脚 | 功能 | 接线对象 |
|---|---|---|
| PA11 | CAN_RX | TJA1050的RXD |
| PA12 | CAN_TX | TJA1050的TXD |
| PA0/PA1/PA2 | 霍尔A/B/C | 电机霍尔传感器输出 |
| PA8/PA9/PA10 | PWM/方向 | 栅极驱动芯片输入 |
| PB0/PB1 | 使能/故障输入 | 驱动板Fault信号 |
霍尔传感器通常是开漏输出,必须在PA0/PA1/PA2上各接一个4.7kΩ到10kΩ的上拉电阻,否则电平浮空会导致换相状态乱跳。供电方面,电机母线建议单独供电,控制板用LDO降压到5V给MCU和收发器供电,两者只共地,不在同一块铜皮上走大电流。总线两端各接一个120Ω终端电阻,其中一个可以直接焊在节点板的CAN_H和CAN_L之间,方便单节点调试。
3. 核心代码实现:CAN初始化、命令解析与PWM调速三段式
3.1 CAN外设初始化:波特率、过滤器与接收中断
用标准外设库配置CAN1,首先打开GPIOA和CAN1的时钟,把PA11、PA12配置为复用推挽输出。这里有个容易踩的细节:CAN的TX和RX引脚在F103上属于“复用功能开漏”还是“复用功能推挽”取决于收发器类型,对于TJA1050这种电平型收发器,配置成复用推挽即可。
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 | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11 | GPIO_Pin_12; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); CAN_InitStructure.CAN_TTCM = DISABLE; CAN_InitStructure.CAN_ABOM = ENABLE; // 自动离线恢复 CAN_InitStructure.CAN_AWUM = ENABLE; // 自动唤醒 CAN_InitStructure.CAN_NART = DISABLE; // 禁止自动重发,便于调试 CAN_InitStructure.CAN_RFLM = DISABLE; // FIFO不锁定 CAN_InitStructure.CAN_TXFP = DISABLE; // 按ID优先级发送 CAN_InitStructure.CAN_Mode = CAN_Mode_Normal; // 位时间 = 1 + BS1 + BS2 = 8 tq // 36MHz / 9 / 8 = 500kbps,采样点75% CAN_InitStructure.CAN_SJW = CAN_SJW_1tq; CAN_InitStructure.CAN_BS1 = CAN_BS1_5tq; CAN_InitStructure.CAN_BS2 = CAN_BS2_2tq; CAN_InitStructure.CAN_Prescaler = 9; 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(CAN1, &CAN_FilterInitStructure); CAN_ITConfig(CAN1, CAN_IT_FMP0, ENABLE); // FIFO0消息挂号中断 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); }CAN挂在APB1总线上,主频72MHz时APB1是36MHz。重点看波特率计算:位时间由同步段1tq、传播/相位段BS1 5tq、相位段BS2 2tq组成,共8tq。36MHz除以预分频9得到4MHz的tq时钟,再除以8得到500kbps,采样点位于75%处,这是CAN总线最常用的配置。如果换用8MHz外部晶振以外的时钟源,需要重新计算这三个参数,代码里写死的数值不能照搬。
过滤器配置成掩码模式且全部置0,表示接收所有帧。实际项目中建议后期改成CID掩码过滤,只接收主机下发的命令帧,减少中断频率。CAN_ABOM置ENABLE是关键——总线出现严重错误导致节点离线时,硬件会自动恢复,否则节点会一直沉默到复位。
3.2 报文接收与命令解析:把CAN帧变成电机控制参数
接收中断里做的事情越少越好。进入中断后先从FIFO0读出CanRxMsg结构体,根据标准帧ID做switch分发,解析Data段参数,然后立刻清中断标志。项目里命令帧ID按功能划分:0x01启停、0x02目标速度、0x03查询状态,全部采用标准帧。
void USB_LP_CAN1_RX0_IRQHandler(void) { CanRxMsg RxMsg; int16_t speed_value; uint8_t err_code = 0; CAN_Receive(CAN1, CAN_FIFO0, &RxMsg); if (RxMsg.IDE == CAN_Id_Standard) { switch (RxMsg.StdId) { case CMD_START: // 0x01 Motor_Enable(1); break; case CMD_STOP: // 0x02 Motor_Enable(0); break; case CMD_SET_SPEED: // 0x03 // Data[0]高字节,Data[1]低字节,int16带符号,单位RPM speed_value = (int16_t)((RxMsg.Data[0] << 8) | RxMsg.Data[1]); Speed_SetTarget(speed_value); break; case CMD_QUERY_STATUS: // 0x04 err_code = Motor_GetErrorCode(); Status_Report(err_code); break; default: break; } } CAN_ClearITPendingBit(CAN1, CAN_IT_FMP0); }命令解析的关键在Data字段的边界定义。速度值用int16传输,范围是-32768到32767RPM,对绝大多数无刷电机够用;如果后续要支持更高的分辨率,可以把单位改成“0.1RPM”,或者把速度值拆成转速和方向两个字节。这里使用大端序(高字节在前),和CAN分析仪里常见的十六进制显示习惯一致,方便调试时直接对照。
状态帧的组装在另一个函数里完成,节点主动上报当前转速、母线电压和错误码。状态上报的意义不只是给上位机看,它也是心跳机制的基础——主机通过“连续一段时间没收到某节点的状态帧”来判断节点是否掉线,后面第4章会展开讲。
3.3 方波换相与PID调速:霍尔反馈闭环
方波驱动即六步换相,根据三路霍尔信号的状态组合决定三相桥臂的通断组合。霍尔信号是三个引脚的二进制组合,8种状态中6种有效,对应6个电压矢量。换相表是这类项目的核心数据,务必和实际接线顺序保持一致。
const uint8_t Hall_CommutTable[8] = { 0, // 0b000 非法状态 PWM_PHASE_AB, // 0b001 PWM_PHASE_AC, // 0b010 PWM_PHASE_BA, // 0b011 PWM_PHASE_BC, // 0b100 PWM_PHASE_CA, // 0b101 PWM_PHASE_CB, // 0b110 0, // 0b111 非法状态 }; void Hall_Update(void) { uint8_t hall_raw; uint8_t phase; hall_raw = (uint8_t)(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) | (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_1) << 1) | (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_2) << 2)); phase = Hall_CommutTable[hall_raw]; if (phase != 0) { TIM_SetCompare1(TIM1, PWM_ON); // 上桥臂PWM TIM_SetCompare2(TIM1, PWM_OFF); // 对应相下桥臂关闭 // 其余桥臂按phase定义切换 } }霍尔信号每变化一次,就代表转子转过一个电气60度角,代码里用GPIO读取的方式完成换相。更高效的做法是把三路霍尔接到定时器的输入捕获通道,利用定时器编码器模式直接得到速度和方向,但GPIO读表方式简单直接,适合工程起步。
转速闭环用增量式PID实现。目标速度来自CAN命令帧,当前速度通过霍尔换相频率换算:转速(rpm) = 电气频率 × 60 ÷ 极对数,而电气频率可以从单位时间内的换相次数算出来,也可以用输入捕获测霍尔周期。PID更新放在定时器中断里,以1kHz频率执行。
int16_t Speed_PID_Update(int16_t target_rpm, int16_t current_rpm) { int32_t error = target_rpm - current_rpm; static int32_t integral = 0; int32_t derivative = error - last_error; int32_t output; integral += error; if (integral > INTEGRAL_LIMIT) integral = INTEGRAL_LIMIT; if (integral < -INTEGRAL_LIMIT) integral = -INTEGRAL_LIMIT; output = KP * error + KI * integral + KD * derivative; output = CLIP(output, -PWM_PERIOD, PWM_PERIOD); // 限幅到PWM周期 last_error = error; return (int16_t)output; }PID参数整定有个血泪经验:先把KI和KD置0,只保留KP,从很小的值开始加,直到电机出现轻微震荡再退回到70%左右;然后加KI消除静差;最后加KD抑制超调。积分限幅INTEGRAL_LIMIT一定要有,否则电机堵转时积分会无限增大,输出饱和后转速恢复会异常缓慢。
4. 调试与组网:从单节点调通到一主多从的状态上报
4.1 通信物理层:终端电阻、电平和波特率验证
CAN通信调不通时,先别急着看代码。用万用表量一下节点板上CAN_H和CAN_L之间的电阻,如果只有一根总线且两端都没接终端电阻,测出来是“断路”,接上两个120Ω后应该在60Ω左右。这一步能排除一大半“通信时好时坏”的问题。
再看总线电平:示波器挂CAN_H对地,静态时应约2.5V,通信时显性电平会拉到3.5V左右;CAN_L则相反,显性时掉到1.5V。如果测到的静态电平是0V或5V,说明收发器没有正常工作,大概率是TXD/RXD接反,或者收发器的电源和地没共好。差分信号的好处是两条线互相绞合后抗共模干扰强,但前提是收发器电源必须与控制板共地,浮地会导致CAN_H和CAN_L对地电平整体漂移,表现为能发不能收。
波特率验证更直接:用USB-CAN分析仪挂在总线上,把分析仪配成500kbps,如果能正常看到主机发的帧,说明物理层和波特率都没问题。如果分析仪上显示总线错误或者CRC错误帧,优先怀疑本机的BS1/BS2/Prescaler组合算错了波特率,而不是怀疑干扰。
4.2 一主多从组网:ID分配、心跳和状态帧
单节点调通后,扩展多节点只需要做两件事:给每个节点分配不同的基础ID,让主机下发命令时带上节点地址。推荐按地址段划分ID空间,给未来协议升级留出余地:
| 帧类型 | ID范围 | 方向 | 说明 |
|---|---|---|---|
| 命令帧 | 0x101~0x1FF | 主机→节点 | 低字节为节点地址 |
| 状态帧 | 0x201~0x2FF | 节点→主机 | 低字节为节点地址 |
| 广播帧 | 0x300 | 主机→所有节点 | 全体启停/急停 |
这种划分方式的优势在于过滤器配置非常简单:主机只收0x2xx范围内的状态帧,节点只收0x1xx范围内的命令帧,用掩码模式一条过滤器即可完成,不需要为每个节点单独配置。
心跳机制是组网稳定性的核心。每个节点以500ms为周期主动上报状态帧,帧内Data[0]是当前转速高字节,Data[1]是低字节,Data[2]是错误码,Data[3]是节点自检标志。主机在接收中断里更新该节点的时间戳,主循环里周期性轮询所有节点时间戳,超过1.5秒没更新就标记该节点掉线,触发急停或声音报警。这样即使某个节点因为过流保护复位,主机也能在2秒内感知到,而不是等到操作人员发现设备不转了才排查。
4.3 调试工具与上位机快速验证
调试阶段我一般不用完整上位机,而是用Python的python-can库写一个几十行的脚本,模拟主机发送命令帧。这样可以精确控制发送时机和帧内容,配合CAN分析仪抓总线报文,比在嵌入式代码里打断点高效得多。
import can import time bus = can.interface.Bus(channel='can0', interface='socketcan', bitrate=500000) # 启动电机 bus.send(can.Message(arbitration_id=0x101, data=[], is_extended_id=False)) time.sleep(0.5) # 设置目标速度 600 RPM speed = 600 data = [(speed >> 8) & 0xFF, speed & 0xFF] bus.send(can.Message(arbitration_id=0x103, data=data, is_extended_id=False)) # 等待节点状态帧 while True: msg = bus.recv(timeout=1.0) if msg and 0x201 <= msg.arbitration_id <= 0x2FF: rpm = (msg.data[0] << 8) | msg.data[1] print(f"current rpm: {rpm}")Python脚本的每个字段都与嵌入式端协议严格对应:0x101是1号节点的启动命令,0x103是1号节点的调速命令,Data字段大端序。用这种“脚本当主机”的方式调通一个节点后,再逐步仿真多节点场景,比如循环给1到4号节点轮流发调速命令,观察每个节点的实际转速是否跟上目标值。
5. 踩坑实录:CAN通信与电机驱动的五个常见问题
5.1 现象一:FIFO里有报文,但接收中断一次都不触发
中断就是进不去,但用CAN分析仪能看到总线上确实有帧。排查后发现总线上的帧确实进了CAN1的FIFO1,而不是FIFO0。
原因:初始化时把过滤器分配给了FIFO1,却开启了CAN_IT_FMP0中断使能。报文进FIFO1时触发的是FMP1中断,两个中断向量编号不同,使能错位就收不到。
解决:检查CAN_FilterInitStructure.CAN_FilterFIFOAssignment,要么把它改成CAN_Filter_FIFO0,要么把中断使能改成CAN_IT_FMP1并注册CAN1_RX1_IRQn。顺手确认NVIC里注册的中断通道名和FIFO编号一致,这是标准库工程里翻车频率最高的一处。
5.2 现象二:电机上电后高频异响,转子抖动不转
上电后电机没有平滑转动,而是“咔咔”响,手拨一下转子能转一小段,然后又卡住。用逻辑分析仪抓霍尔信号,发现三路信号变化顺序和换相表里的状态映射对不上。
原因:霍尔传感器接线顺序和代码中换相表定义的A/B/C顺序不一致。比如霍尔物理上按A-C-B排列,代码却按A-B-C读取,导致同一个转子位置被解释成另一个电气角,换相矢量落后或超前。
解决:用一根细线逐个交换霍尔输入引脚,观察哪个组合下电机能顺畅启动。找到正确顺序后,要么改硬件接线,要么改Hall_CommutTable数组的映射顺序。特别注意无刷电机有电角度对齐问题,霍尔装反会导致零位误差,换相表和实际的60度区间对不上。
5.3 现象三:两个节点点对点通信正常,挂第三个节点后随机丢帧
两个节点时一切正常,加入第三个节点后主机偶尔收不到状态帧,用CAN分析仪看还有错误帧出现。万用表量总线电阻,只有一端有120Ω,另一端没接。
原因:组网后总线上的反射信号增强,缺少终端电阻导致信号边沿振铃,在500kbps波特率下采样点刚好踩在振铃区域就会采样错误。
解决:总线两端各接一个120Ω终端电阻。注意“终端电阻”不是“每个节点都接”,是物理最远的两端各一个。如果节点间距离较远,还要检查分支线长度,CAN规范里分支应该尽可能短,过长的支线相当于一根天线,会反射信号。
5.4 现象四:电机空载正常,带负载后速度上不去还伴随电流尖峰
给电机加上负载后,实际转速明显低于目标转速,母线电流波动剧烈。代码里PID输出已经到限幅值,但转速就是提不上去。
原因:方波驱动在低速大扭矩场景下换相噪声大,PID调节速度跟不上负载突变;更关键的是没有做电流限幅,大电流冲击下母线电压跌落,又反过来影响霍尔信号质量,形成恶性循环。
解决:先把加速过程做斜坡限制,目标速度变化时每次只允许50RPM/10ms的步进,让电流平滑上升;同时在PWM周期里加入电流采样,超过阈值直接关闭PWM输出,而不是等PID慢慢回调。从控制逻辑上看,方波驱动的扭矩能力上限由母线电压决定,PID参数调得再激进也无法突破物理极限。
5.5 现象五:把CAN引脚重映射到PB8/PB9后,代码没改就收不到帧
为了避开PA11/PA12上的其他复用功能,把CAN1重映射到PB8/PB9,但只改了GPIO初始化,没有重映射AFIO,结果总线上一片寂静。
原因:F103的CAN1重映射需要打开AFIO时钟,并调用GPIO_PinRemapConfig(GPIO_Remap1_CAN1, ENABLE),否则GPIO虽然配置了,但内部信号还走默认引脚PA11/PA12。
解决:重映射三步走——打开AFIO时钟、调用GPIO_PinRemapConfig、把GPIO_Pin换成PB8/PB9。这类“引脚没反应”的问题有一个共同排查思路:先看外设时钟开没开,再看AFIO重映射开没开,最后看NVIC对应中断通道注册没注册,顺序反了都会静默失败。
6. 二次开发:把单节点命令控制扩展成分布式运动系统
这套框架改造方向很多,我实际用过的一个路径是往“位置模式”扩展。霍尔六步换相只能提供电气60度分辨率,定位精度不够,改造方法是加一颗磁编码器,比如AS5600这类I²C输出芯片,替换霍尔作为转子位置反馈源;速度环保留原来的PID结构,位置环在其外层,CAN命令从“目标转速”扩展成“目标角度”,帧ID增加一条0x05,Data字段变成目标角度值。
协议扩展时有一个技巧:不要在现有switch-case里无限加case,而是把命令解析升级成状态机加命令表。命令表结构体包含帧ID、数据长度、处理函数指针,新命令进来时查表调用对应函数。这样后续加命令、加节点、加功能都不再动中断里的主逻辑,只需要在表格里追加一行。我习惯在每张命令表初始化时把帧ID、方向、长度、单位写进注释里,因为CAN帧协议最怕的就是改了字段后两边不同步。
如果目标是FOC无感控制,那这套工程里能用的是CAN命令解析和PID框架,换相部分要全部重写。FOC需要Clark/Park变换、SVPWM和角度观测器,F103的算力做单电机FOC勉强够,但建议换成F103的进阶型号,留出余量给观测器和电流环。
验证方法很朴素:先用脚本单发一条位置命令,看电机是否转到目标角度并稳定,再连续下100条随机位置命令,记录每次的到位误差和超调量。从那以后我每次改协议都会先在帧格式表里写好ID、长度和单位三者对应的关系,再动手改代码,几乎没再犯过“字段解析错位”的毛病。希望帮到你。
本文还有配套的精品资源,点击获取