简介:面向STM32F103与无刷电机控制学习者的CAN通信命令控制实践包,适合已具备单片机基础、希望打通通信协议与电机驱动环节的开发者。内容围绕CAN报文帧结构、标识符与数据字段配置,结合STM32内置CAN控制器的收发编程,并延伸到三相逆变驱动、霍尔位置采集、PWM换相与转速方向远程设定等电机控制要点。压缩包共202个文件,约577KB,以55个h头文件与54个c源文件为主体,配合工程编译产物、Keil工程配置及少量说明文档,便于按模块查阅底层驱动与工程组织。已有661人学习,可从中获取CAN基础梳理、F103 CAN模块配置思路、六步换相或FOC策略参考、模块化程序结构以及学习过程中的问题记录,适合作为课程设计、毕业设计或自学项目的对照材料。
1. 命令发出去电机不转:先看清 F103+CAN+无刷电机这套工程
无刷电机、STM32F103、CAN 通信这三个词凑在一起,调试台上最常见的画面不是电机呼啸,而是分析仪显示帧发成功了,电调却隔几秒响一声,电机纹丝不动。这套工程要解决的就是这段“最后一公里”:F103 用 bxCAN 收上位机或主控的命令,解析成使能、目标转速、加速度和停止指令,再落到 PWM 油门或带 CAN 接口的驱动器报文上,同时把失联、过流、堵转这些保护放在本地。它适合做机器人关节、云台、小车驱动、泵和风机控制,也适合刚接触 CAN 的开发者拿来做第一条完整链路。反直觉的地方在于,CAN 通不等于电机能转,电调解锁时序和油门映射才是最容易翻车的地方。
2. 协议先立住:CAN 帧 ID、8 字节数据和 500 kbps 怎么定
2.1 为什么先定协议再写电机代码
CAN 和串口调试最大的区别是,帧 ID 本身就是地址,数据区只有 8 字节,没有“先发一串字符串试试”的余地。很多工程一开始只写“收到 0x181 就把油门加大”,等要加第二台电机、要读故障、要做失联保护时,发现 ID 和字节全被占满,只能回头改协议。我一般会先把命令帧、反馈帧、心跳帧三类分开,命令用单播 ID,反馈用另一段 ID,广播只保留使能和急停,后面加电机时不用动已有逻辑。
选型上,标准帧 11 位 ID 足够覆盖一台小设备里的电机数量,过滤器配置也比扩展帧简单;500 kbps 在 1 米左右的板间总线上余量充足,10 ms 的命令周期既能让人感觉到响应,又不会把总线压满。下面这张表是这套工程里常见的 ID 划分,实际项目可以按节点数量增减,但命令和反馈不要混在同一段 ID 里。
| CAN ID | 方向 | 用途 | 周期 |
|---|---|---|---|
| 0x180 | 主控到电机 | 广播使能、广播停止 | 事件触发 |
| 0x181 到 0x184 | 主控到电机 1 到 4 | 单播控制命令 | 10 ms |
| 0x281 到 0x284 | 电机 1 到 4 到主控 | 状态、转速、故障码 | 20 ms |
| 0x7FF | 电机到主控 | 心跳与故障上报 | 100 ms |
提示:0x000 到 0x07F 属于高优先级段,除非有硬实时需求,否则不要把普通控制命令塞进去,否则调试时抓包会很乱。
2.2 一条控制命令的 8 字节布局
8 字节看着少,但把命令字、模式、目标值、加速度、标志和校验排好,单电机控制足够用。这套工程里我用的是小端布局,目标值用 int16,方便表示正反转或者带符号的转速偏差;校验用前 7 字节异或,不追求加密,只用来挡住总线上的错帧和调试时的手误。
| 字节 | 含义 | 说明 |
|---|---|---|
| 0 | 命令字 | 0x01 使能,0x02 设目标,0x03 停止,0x04 清故障 |
| 1 | 模式 | 0x00 归一化油门,0x01 目标转速 |
| 2 到 3 | 目标值 | int16,小端;油门范围 0 到 1000,转速单位 rpm |
| 4 到 5 | 加速度 | int16,单位“每秒多少油门单位”或 rpm/s |
| 6 | 标志 | bit0 方向,bit1 急停,其余保留 |
| 7 | 校验 | byte0 到 byte6 异或 |
typedef struct { uint8_t cmd; // 命令字:使能、设目标、停止、清故障 uint8_t mode; // 0 油门模式,1 转速模式 int16_t value; // 目标值,小端 int16_t accel; // 斜坡加速度 uint8_t flags; // bit0 方向,bit1 急停 } MotorCmd_t; uint8_t MotorCmd_Pack(const MotorCmd_t *cmd, uint8_t data[8]) { data[0] = cmd->cmd; data[1] = cmd->mode; data[2] = (uint8_t)(cmd->value & 0xFF); data[3] = (uint8_t)((cmd->value >> 8) & 0xFF); data[4] = (uint8_t)(cmd->accel & 0xFF); data[5] = (uint8_t)((cmd->accel >> 8) & 0xFF); data[6] = cmd->flags; data[7] = data[0] ^ data[1] ^ data[2] ^ data[3] ^ data[4] ^ data[5] ^ data[6]; return 8; } uint8_t MotorCmd_Unpack(const uint8_t data[8], uint8_t len, MotorCmd_t *out) { uint8_t sum = 0; uint8_t i; if (len != 8 || out == 0) { return 0; } for (i = 0; i < 7; i++) { sum ^= data[i]; } if (sum != data[7]) { return 0; } out->cmd = data[0]; out->mode = data[1]; out->value = (int16_t)((uint16_t)data[2] | ((uint16_t)data[3] << 8)); out->accel = (int16_t)((uint16_t)data[4] | ((uint16_t)data[5] << 8)); out->flags = data[6]; return 1; }这段代码只做两件事:打包时把结构体压成 8 字节并算校验,解包时先检查长度和异或,再把小端字节拼回 int16。参数上,value在油门模式下限制在 0 到 1000,在转速模式下按电机额定转速做上限;accel为 0 时不要理解成“瞬间到位”,工程里会回落到默认斜坡,避免一帧误码把油门从 0 拉到满。校验失败不要直接停机,先累加错误计数,连续多次失败再进故障态,否则总线上一个干扰帧就会让电机顿一下。
2.3 500 kbps 波特率与过滤器配置
F103 的 bxCAN 挂在 APB1 上,常见配置是 36 MHz。要得到 500 kbps,可以取预分频 9、BS1 为 6 个时间量子、BS2 为 1 个时间量子,时间量子总数是 1+6+1=8,36 MHz 除以 9 再除以 8 正好是 0.5 MHz,采样点落在 87.5% 附近。这个采样点对短线板间通信够用,线长超过几米时再考虑把 BS1 加大、采样点往 80% 左右调,同时降低波特率做对比测试。
CAN_HandleTypeDef hcan; void CAN_Init_500k(void) { hcan.Instance = CAN1; hcan.Init.Prescaler = 9; // 36 MHz / 9 = 4 MHz hcan.Init.Mode = CAN_MODE_NORMAL; hcan.Init.SyncJumpWidth = CAN_SJW_1TQ; hcan.Init.TimeSeg1 = CAN_BS1_6TQ; // 采样点约 87.5% hcan.Init.TimeSeg2 = CAN_BS2_1TQ; 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); } void CAN_Filter_Init(void) { CAN_FilterTypeDef f; f.FilterBank = 0; f.FilterMode = CAN_FILTERMODE_IDMASK; f.FilterScale = CAN_FILTERSCALE_32BIT; f.FilterIdHigh = (0x180 << 5) & 0xFFFF; // 只收 0x180 到 0x18F f.FilterIdLow = 0; f.FilterMaskIdHigh = (0x7F0 << 5) & 0xFFFF; f.FilterMaskIdLow = 0; f.FilterFIFOAssignment = CAN_RX_FIFO0; f.FilterActivation = ENABLE; HAL_CAN_ConfigFilter(&hcan, &f); HAL_CAN_Start(&hcan); HAL_CAN_ActivateNotification(&hcan, CAN_IT_RX_FIFO0_MSG_PENDING); }初始化代码里,Prescaler、TimeSeg1、TimeSeg2三个参数决定波特率和采样点,改一个就要重新算总线时序;AutoBusOff打开后,节点在总线关闭后能自己恢复,但现场如果一直反复进入总线关闭,还是要先查终端电阻和波特率,不要只靠自动恢复。过滤器用 32 位掩码模式,FilterIdHigh和FilterMaskIdHigh都左移 5 位,是因为标准帧 ID 在过滤器寄存器里从 bit5 开始对齐;掩码0x7F0表示高 7 位参与匹配,正好覆盖 0x180 到 0x18F 这 16 个 ID。只接收自己关心的段,比全收再软件过滤省中断,也少一类“为什么没进回调”的排查。
2.4 接收中断里的状态机
CAN 接收回调里最忌讳做长耗时动作。F103 的 CAN 中断里浮点运算、PID、串口打印都要往后放,回调只做解包、校验、状态迁移和置标志,PWM 更新交给定时器或者主循环。下面这段状态机把“使能”和“设目标”分开,电机必须先收到使能命令,再收到目标命令才会进入运行态;清故障还要求一个固定魔术字节,防止调试时误发一帧就把故障清了。
typedef enum { MOTOR_IDLE = 0, MOTOR_ENABLED, MOTOR_RUN, MOTOR_FAULT } MotorState_t; volatile MotorState_t g_state = MOTOR_IDLE; volatile uint32_t g_last_cmd_ms = 0; volatile uint16_t g_can_error_cnt = 0; void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) { CAN_RxHeaderTypeDef rx; uint8_t data[8]; MotorCmd_t cmd; if (HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &rx, data) != HAL_OK) { return; } if (!MotorCmd_Unpack(data, rx.DLC, &cmd)) { g_can_error_cnt++; return; } g_last_cmd_ms = HAL_GetTick(); switch (cmd.cmd) { case 0x01: // 使能 if (g_state == MOTOR_FAULT) { break; } g_state = MOTOR_ENABLED; break; case 0x02: // 设目标 if (g_state == MOTOR_ENABLED || g_state == MOTOR_RUN) { Motor_ApplyCommand(&cmd); g_state = MOTOR_RUN; } break; case 0x03: // 停止 Motor_Stop(); g_state = MOTOR_IDLE; break; case 0x04: // 清故障,要求 mode 为 0xA5 if (cmd.mode == 0xA5) { Motor_ClearFault(); g_state = MOTOR_IDLE; } break; default: g_can_error_cnt++; break; } }这段回调的逻辑说明很直接:先取帧,再解包,校验不过只加错误计数;命令字 0x01 只负责把状态从空闲推到使能,0x02 才真正写目标值,0x03 立即停,0x04 清故障必须带魔术字节。参数上,g_last_cmd_ms是后面做失联保护的依据,10 ms 命令周期下,200 ms 没收到新命令就降速;g_can_error_cnt可以映射到反馈帧里,方便上位机看链路质量。中断里只做这些,PWM 的斜坡和限幅放到 1 ms 或 10 ms 定时任务里,避免回调时间抖动。
3. 把 CAN 命令落到无刷电机:PWM 映射、电调校准与启动时序
3.1 50 Hz PWM 与 1~2 ms 油门映射
常见无刷电调吃的是 50 Hz PWM,高电平 1 ms 对应最低油门,1.5 ms 对应中位,2 ms 对应最高油门。F103 的 TIM3 挂在 72 MHz,预分频 71 得到 1 MHz 计数,自动重装 19999 得到 20 ms 周期,比较值 1000 到 2000 正好对应 1 ms 到 2 ms。也有 400 Hz 刷新型电调,周期变成 2.5 ms,比较值范围要重新算,不能把 50 Hz 的参数直接套上去。
void PWM_Init(void) { // 省略 GPIO 和时基初始化,核心是 72 MHz / 72 = 1 MHz,20 ms 周期 htim3.Instance = TIM3; htim3.Init.Prescaler = 71; htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 19999; htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(&htim3); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); } uint16_t Throttle_ToCCR(uint16_t throttle_permille) { if (throttle_permille > 1000) { throttle_permille = 1000; } // 1000 到 2000 对应 1 ms 到 2 ms,留 50 的死区保护 return 1050 + (uint16_t)((uint32_t)throttle_permille * 900 / 1000); }这段映射把 0 到 1000 的归一化油门压到 1050 到 1950 的比较值,而不是直接 1000 到 2000。原因是电调端点附近容易触发保护,尤其是电池电压偏高时,1 ms 和 2 ms 的极限值会让个别电调进入校准态或者直接不响应。参数上,throttle_permille来自协议里的value,超过 1000 按 1000 处理;如果换 400 Hz 电调,Period改成 2499,映射区间也要跟着改,不要只改一个宏就上电。
3.2 上电别急着给油:电调解锁与软启动
电调上电后要听到提示音才代表解锁完成,这之前给油大概率没反应,或者直接进入校准流程。我一般会让程序上电先输出 1 ms,保持 3 秒,再允许使能命令;收到目标后也不是立刻跳到目标值,而是按加速度斜坡往上走。下面这段代码把启动和斜坡分开,Motor_Startup只在初始化时调用一次,Motor_RampTask放在 10 ms 定时任务里。
static uint8_t g_armed = 0; static uint16_t g_target_permille = 0; static uint16_t g_out_permille = 0; static uint16_t g_slew_per_sec = 200; void Motor_Startup(void) { __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 1000); // 1 ms 低油门 HAL_Delay(3000); // 等电调自检提示音 g_armed = 1; } void Motor_RampTask(uint32_t now_ms) { static uint32_t last_ms = 0; uint32_t dt_ms = now_ms - last_ms; uint16_t step; if (!g_armed) { return; } if (dt_ms == 0) { return; } last_ms = now_ms; step = (uint16_t)((uint32_t)g_slew_per_sec * dt_ms / 1000); if (step == 0) { step = 1; } if (g_out_permille < g_target_permille) { g_out_permille += step; if (g_out_permille > g_target_permille) { g_out_permille = g_target_permille; } } else if (g_out_permille > g_target_permille) { g_out_permille -= step; if (g_out_permille < g_target_permille) { g_out_permille = g_target_permille; } } __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, Throttle_ToCCR(g_out_permille)); }启动代码里HAL_Delay(3000)放在初始化阶段可以接受,但不要放在 CAN 中断或者主循环里反复调用;斜坡任务用now_ms算时间差,g_slew_per_sec是每秒允许变化的油门单位,200 表示从 0 到 500 要 2.5 秒,适合中小功率电机空载起步。参数上,3 秒不是固定值,电调说明书要求更长就加长;如果上电一直响但不解锁,先查油门信号是不是真的 1 ms,再查电调是不是进入了最高油门校准态。做电调行程校准的时候,记得拆桨,最高油门上电等提示音,再拉到最低油门等确认,这套流程血泪经验比代码本身更值得写进调试记录。
3.3 开环转速命令与闭环的边界
协议里如果写“目标转速 3000 rpm”,F103 只输出 PWM 给普通电调,这是开环油门,不是转速闭环。负载一变,同样油门下的实际转速会掉,电池电压从满电掉到低电,转速也会漂。常见做法是命令层用归一化油门,反馈层用编码器或霍尔算 rpm,需要精确转速时再上 PID;如果电机驱动器本身带 CAN 接口并且支持转速模式,那就把Motor_ApplyCommand换成驱动器报文打包,F103 只做协议转换和限幅。
| 控制方式 | 优点 | 边界 |
|---|---|---|
| 开环油门 | 接线简单,响应快,普通电调就能用 | 负载变化掉速,低速一致性差 |
| 编码器闭环 | 转速可测,能做 PID 和堵转判断 | 需要额外传感器,参数要整定 |
| 驱动器 CAN 转速模式 | 控制精度高,故障码丰富 | 协议随驱动器不同,要单独适配 |
这张表不是让你一定要上闭环,而是提醒协议里的“目标值”单位要和执行层一致。开环工程里把value当油门用,就不要在注释里写 rpm;否则后面接手的人会以为发 3000 就是 3000 转,调半天发现只是 30% 油门。
3.4 代码:命令到 CCR 的落地
命令落到执行层只做三件事:单位换算、限幅、写目标。下面这段Motor_ApplyCommand把协议里的value按模式转成 0 到 1000 的油门目标,加速度为 0 时回落到默认值,急停标志直接清目标。它不直接写 CCR,而是写g_target_permille,让斜坡任务去追,这样 CAN 帧来得再快,电机也不会跟着跳。
void Motor_ApplyCommand(const MotorCmd_t *cmd) { int32_t target; if (cmd->flags & 0x02) { // 急停标志 g_target_permille = 0; g_slew_per_sec = 1000; return; } if (cmd->mode == 0x00) { target = cmd->value; // 0 到 1000 油门 } else { // 假设 6000 rpm 对应满油门,仅做开环近似 target = (int32_t)cmd->value * 1000 / 6000; } if (target < 0) { target = 0; } if (target > 1000) { target = 1000; } g_target_permille = (uint16_t)target; g_slew_per_sec = (cmd->accel == 0) ? 200 : (uint16_t)cmd->accel; }逻辑说明:急停优先于一切模式,直接把目标清零并把斜坡加到 1000,让电机尽快停;油门模式直接限幅,转速模式按假定的 6000 rpm 满量程换算,这个 6000 必须按你的电机和桨的实际参数改。参数说明:g_slew_per_sec来自协议第 4 到 5 字节,为 0 时用 200 的默认值,防止上位机漏填导致电机瞬间全油。代码里没有直接调用__HAL_TIM_SET_COMPARE,是为了让 CAN 接收和 PWM 输出解耦,后面加失联保护、故障降额都只改目标值,不用动中断。
4. 避坑与排查:CAN 不通、电机不转、一给油就抖的现场记录
4.1 物理层与终端电阻排查
现象:分析仪能发出帧,F103 的接收回调一次都不进,示波器看 CANH 和 CANL 上有波形但差分幅度很小。原因:总线只在分析仪一端接了 120 欧终端电阻,F103 板子上没有,或者两端都接了但中间还挂了一个 120 欧,等效负载变重;也有现场是双绞线用了普通排线,长度一过 30 cm 就开始出错。解决:先断电量两端电阻,总线两端各 120 欧,中间节点不接;换双绞线,缩短支线;把分析仪、F103、电机驱动器共地,再抓差分波形看显性电平和隐性电平是否干净。
现象:上电后 F103 的 TEC 错误计数一直涨,偶尔进总线关闭,电机驱动器那边也报总线错误。原因:节点之间波特率不一致,比如 F103 配了 500 kbps,驱动器还是 250 kbps;或者两个节点用了同一个发送 ID,仲裁时互相顶。解决:先把所有节点断开,只留 F103 和分析仪,确认 500 kbps 下能正常收发;再逐个加节点,每加一个看一次错误计数;ID 规划表贴在调试台上,发送 ID 不允许重复,过滤器掩码逐段核对。
4.2 协议与状态机排查
现象:分析仪能看到 F103 发出反馈帧,状态字也是“使能”,但电机就是不转。原因:上位机只发了 0x02 设目标,没有先发 0x01 使能,状态机卡在
MOTOR_IDLE;也有情况是使能帧发了,但校验字节算错,解包直接返回 0,错误计数在涨但没人看。解决:把反馈帧里的状态字和错误计数打出来,先发使能,确认状态从 IDLE 变 ENABLED,再发目标;用分析仪看每一帧的 byte7 是不是前 7 字节异或,调试脚本里不要手填校验。现象:电机能转,但一给油就抖,低速时尤其明显,听声音像在来回切油门。原因:PWM 频率和电调不匹配,50 Hz 电调吃了 400 Hz 信号;或者斜坡任务周期太长,10 ms 任务里又做了串口打印,实际刷新变成 50 ms;也有加速度参数给得太大,目标值一步跳 300。解决:先确认电调规格书上的 PWM 频率,50 Hz 和 400 Hz 的
Period不能混用;把斜坡任务放到固定 10 ms 定时器里,串口打印移到空闲任务;加速度从 200 开始试,别一上来就 2000。
4.3 电机与供电侧排查
- 现象:电调上电有提示音,但给油只响不转,或者转一下就停。原因:电池电压低于电调低压保护点;电调没有做行程校准,1 ms 被识别成非零油门;三相线顺序不对或者霍尔线接触不良;也有大功率电机在空载下被限流限得太死。解决:先测电池电压,换满电电池对比;拆桨做电调行程校准,最高油门等提示音再最低油门;三相线两两对调试转向,霍尔线按驱动器说明核对;空载电流和堵转电流分开记录,不要用满载参数去套空载测试。
注意:以上排查只要涉及上电给油,一律拆桨或者把电机可靠固定
本文还有配套的精品资源,点击获取