基于STM32F103的CAN总线无刷电机控制实战方案

发布时间:2026/10/26 22:01:54
基于STM32F103的CAN总线无刷电机控制实战方案

简介:面向学习STM32F103与CAN总线电机控制的开发者,这份资料以无刷电机命令控制为主线,适合已具备单片机基础、希望打通通信与电机驱动链路的中级学习者。压缩包共202个文件、约577KB,包含大量.h头文件与.c源文件,配套Keil工程文件、.hex与.axf编译输出及.bat脚本,另有.d、.o、.crf等编译中间文件,可直接导入或对照工程结构复现实验。已有661人学习。内容围绕CAN报文帧、滤波器与波特率配置、三相逆变驱动、霍尔位置采集和PWM换相展开,并涉及六步换相与FOC等控制策略;源码按电机控制、CAN收发、中断处理模块组织,便于读者理解远程设定转速、方向及回读状态的实现路径。资料中还保留了学习笔记与调试记录,可供排查通信异常、梳理配置流程时参考,也便于按自身需求裁剪和优化代码。

1. 一根 CAN 线接管无刷电机:这套 F103 方案到底解决什么问题

台架上摆着一台小功率无刷电机,驱动器只引出两根电源线和两根 CAN 线。上位机下发一条 8 字节报文,电机从静止平滑爬到 3000 rpm,再发一条,稳稳停在 0。这个场景在云台、泵类、小车底盘、风机模组里到处都是。这套基于 STM32F103 的 CAN 通信命令控制无刷电机方案干的就是这件事:F103 解析 CAN 报文、跑转速闭环、输出六路互补 PWM,功率板把三相电流灌进电机绕组。

它适合手里已经有 F103 最小系统板、想从“点灯”跨到“真机控制”的开发者,也适合把一堆电位器调速改造成总线调速的老项目。难度不算高,但坑全在细节里——采样点差几个 tq、死区少配一个寄存器、换相表错一个数,电机就会用抖动和啸叫回复你。

2. 硬件与工程骨架:从 F103 最小系统到三相功率级的接线表

先把“能转”这件事的电路确认掉,再谈代码。这套方案的硬件分三块:F103 主控、CAN 收发器、三相功率级。主控只负责逻辑,功率级负责电流,中间的隔离和采样决定了后面调试顺不顺。

2.1 功率级与采样电阻:先把“能转”这件事的电路确认掉

三相功率级常见做法是六只 N 沟道 MOS 组成三相全桥,上桥三只、下桥三只,每相的中点接电机。上桥需要自举,所以每一相都要配一只自举二极管和自举电容,容量按 0.1~1 μF 取,太小了上桥在高占空比时掉压,太大了充电慢、低速启动会失步。这部分我在板子上踩过坑:自举电容给到 4.7 μF,结果 100% 占空比跑两秒上桥就开始丢脉冲,示波器一看自举电压已经塌到 6 V 以下。

母线电流采样用一只 5 mΩ 的采样电阻串在下桥公共端,配一只运放做 20 倍同相放大,输出接到 F103 的 PA0。相电流采样要更精细的话,可以在每相下桥各放一只采样电阻,用三路 ADC 同步采样,但 F103 只有两个独立 ADC,三电阻方案必须靠注入通道轮转,代码复杂度上一个台阶。这套命令控制型方案对转矩精度要求不高,母线单电阻够用了,代码里用“过流就降占空”的粗暴限幅即可。

引脚分配这张表建议直接抄,改板子的时候对着改:

功能引脚配置说明
CAN_RXPA11上拉输入,CAN1 默认映射
CAN_TXPA12复用推挽 50 MHz
PWM_UH / ULPA8 / PB13TIM1_CH1 / CH1N
PWM_VH / VLPA9 / PB14TIM1_CH2 / CH2N
PWM_WH / WLPA10 / PB15TIM1_CH3 / CH3N
霍尔 H1/H2/H3PB3 / PB4 / PB5上拉输入 + EXTI,需关闭 JTAG
母线电流PA0ADC1_IN0,规则通道
调试串口 TXPA2USART2_TX,打日志用

注意 PB3/PB4/PB5 复位后默认是 JTDO/NJTRST/JTDI,不关 JTAG 就读不到霍尔。这一条后面还会单独说。

2.2 工程目录与时钟、GPIO 初始化顺序

工程不需要上 RTOS,一个裸机主循环加几个中断足够。目录按职责分,别按外设分,后期加功能才不会乱:

文件职责
main.c时钟、外设初始化,主循环跑状态机
bsp_can.c/hCAN1 初始化、过滤器、收发中断、Bus-Off 恢复
bsp_pwm.c/hTIM1 六路互补 PWM、死区、换相表、占空比接口
bsp_hall.c/h霍尔 EXTI、换相触发、转速测量
bsp_adc.c/h电流、温度采样与一阶滤波
motor.c/h命令解析、状态机、转速环 PI
protocol.c/h报文打包解包、校验

初始化顺序有讲究:时钟 → GPIO → TIM1 → 霍尔 EXTI → ADC → CAN。CAN 放最后,因为总线一挂上就可能来报文,中断一进就会读还没初始化的电机状态。TIM1 的六路输出也要最后才使能主输出(TIM_CtrlPWMOutputs),否则上电瞬间 IO 处于不确定状态,桥臂可能直通。

时钟这边,F103 标准配置是 HSE 8 MHz 经 PLL 九倍频到 72 MHz,APB1 分频后 36 MHz 给 CAN 和 TIM3,APB2 保持 72 MHz 给 TIM1。TIM1 挂在 APB2 上,20 kHz 中心对齐 PWM 对应ARR = 72M / (2 × 20k) = 1800,预分频给 0;TIM3 挂在 APB1 上,36 MHz 计数,配 36 分频得到 1 MHz,ARR = 999正好 1 ms 一次中断,用来跑转速环和命令超时检测。

3. CAN 命令协议:8 个字节里怎么塞下启停、转速、方向与回读

CAN 标准帧一次只有 8 字节载荷,怎么切这块蛋糕决定了后面上位机好不好写。我的原则是:下行帧管“要它干什么”,上行帧管“它现在怎么样”,两边字段不交叉,状态机只认下行帧。

3.1 报文 ID 分配与 8 字节字段定义

ID 规划直接用标准帧,11 位够用。0x200~0x20F 划给主机下发,0x180~0x18F 划给驱动器回传,这样一台总线上挂多台驱动器时,改低 4 位就能区分节点。驱动器只开一个掩码过滤器接收 0x200~0x20F,其余全挡在门外,中断里不用做二次判断。

下行帧 0x201 的字段:

字节含义说明
0命令字0x00 停机 / 0x01 启动 / 0x02 刹车 / 0x03 清故障
1标志位bit0 方向(0 正转 1 反转),其余保留
2~3目标转速int16 大端,单位 rpm
4加速度档实际加速度 = 值 × 100 rpm/s,0 表示直接跳变
5校验前 5 字节异或
6~7预留后续加位置环时用

上行帧 0x181 的字段:字节 0 状态机(0 待机 / 1 运行 / 2 故障 / 3 刹车),字节 1 故障码,字节 2~3 实际转速,字节 4~5 母线电流(0.01 A 为单位),字节 6 温度,字节 7 校验。回传周期固定 20 ms,用 TIM3 的 1 ms 计数分频,不要跟命令走——上位机断线了也要能看到电机还在转。

命令字里 0x02 刹车和 0x00 停机的区别要说清:停机是关 PWM 让电机自由滑行,刹车是三相下桥全开做能耗制动,后者在带惯性负载时必须留超时保护,否则绕组很快就烫。代码里给刹车加了 3 秒强制退出。

3.2 打包、校验与中断接收的落地代码

先看 CAN 初始化。500 kbps 在 APB1 = 36 MHz 下算:36 M / 500 k = 72 个时间份额,取预分频 4 得到 18 tq,同步段 1 tq、BS1 15 tq、BS2 2 tq,采样点落在 (1+15)/18 ≈ 88.9%,这个值对大多数收发器都友好。

/* bsp_can.c —— 500 kbps @ APB1 = 36 MHz,采样点 88.9% */ void can1_init(void) { GPIO_InitTypeDef gpio; CAN_InitTypeDef can; CAN_FilterInitTypeDef filter; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1, ENABLE); /* PA11 = CAN_RX 上拉输入;PA12 = CAN_TX 复用推挽 */ gpio.GPIO_Pin = GPIO_Pin_11; gpio.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIOA, &gpio); gpio.GPIO_Pin = GPIO_Pin_12; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); CAN_DeInit(CAN1); CAN_StructInit(&can); can.CAN_ABOM = ENABLE; /* 自动离线恢复,拔插线不至于挂死 */ can.CAN_NART = DISABLE; /* 允许自动重传 */ can.CAN_Mode = CAN_Mode_Normal; can.CAN_SJW = CAN_SJW_1tq; can.CAN_BS1 = CAN_BS1_15tq; can.CAN_BS2 = CAN_BS2_2tq; can.CAN_Prescaler = 4; /* (1+15+2)*4 = 72 tq -> 36M/72 = 500k */ CAN_Init(CAN1, &can); /* 过滤器 0:只放行 0x200~0x20F 标准帧,进 FIFO0 */ filter.CAN_FilterNumber = 0; filter.CAN_FilterMode = CAN_FilterMode_IdMask; filter.CAN_FilterScale = CAN_FilterScale_32bit; filter.CAN_FilterIdHigh = (uint16_t)(0x200 << 5); /* STDID 左移 5 位对齐 */ filter.CAN_FilterIdLow = 0x0000; filter.CAN_FilterMaskIdHigh = (uint16_t)(0x7F0 << 5); /* 低 4 位不参与比较 */ filter.CAN_FilterMaskIdLow = 0x0000; filter.CAN_FilterFIFOAssignment = CAN_FIFO0; filter.CAN_FilterActivation = ENABLE; CAN_FilterInit(&filter); CAN_ITConfig(CAN1, CAN_IT_FMP0, ENABLE); NVIC_EnableIRQ(USB_LP_CAN1_RX0_IRQn); }

CAN_FilterIdHigh这里是最容易写错的一行:标准帧的 11 位 ID 在过滤器里要左移 5 位才能和 RTR、IDE 位对齐,漏了移位就永远匹配不上。掩码 0x7F0 表示高 7 位参与比较、低 4 位任意,于是 0x200~0x20F 全部放行。

协议解析这一层用结构体承载,别在中断里直接操作全局变量:

/* protocol.h —— 下行命令的内存表示 */ typedef struct { uint8_t cmd; /* 0x00 停机 0x01 启动 0x02 刹车 0x03 清故障 */ uint8_t flag; /* bit0: 0 正转 1 反转 */ int16_t rpm; /* 目标转速,大端解析 */ uint8_t acc; /* 加速度档位,实际值 = acc * 100 rpm/s */ } MotorCmd_t; static inline uint8_t xor_sum(const uint8_t *p, uint8_t n) { uint8_t s = 0; while (n--) s ^= *p++; return s; } /* 返回 0 解析成功,-1 长度或校验失败 */ int motor_cmd_parse(const uint8_t *data, uint8_t len, MotorCmd_t *out) { if (len < 6) return -1; if (xor_sum(data, 5) != data[5]) return -1; out->cmd = data[0]; out->flag = data[1]; out->rpm = (int16_t)((data[2] << 8) | data[3]); /* 大端拼装 */ out->acc = data[4]; return 0; }

接收中断里做两件事:解析、投递。投递用双缓冲,因为一个MotorCmd_t是 6 字节,赋值不是原子的,主循环有可能读到一半被中断打断。

/* bsp_can.c —— 接收中断:解析后投递到 shadow,主循环搬进 current */ void USB_LP_CAN1_RX0_IRQHandler(void) { CanRxMsg rx; if (CAN_GetITStatus(CAN1, CAN_IT_FMP0) == RESET) return; CAN_Receive(CAN1, CAN_FIFO0, &rx); if (rx.StdId == 0x201 && rx.DLC == 8) { MotorCmd_t c; if (motor_cmd_parse(rx.Data, rx.DLC, &c) == 0) { __disable_irq(); cmd_shadow = c; /* 6 字节拷贝,关中断保护 */ cmd_shadow_new = 1; __enable_irq(); cmd_alive_ms = 0; /* 喂通信看门狗 */ } } CAN_ClearITPendingBit(CAN1, CAN_IT_FMP0); }

cmd_alive_ms是命令超时计数,主循环里每毫秒加一,超过 500 ms 没有新命令就自动停机。这条保护是血泪经验——上位机崩了、线被扯了,电机还按最后一条命令满速转,现场没人敢靠近。

上行帧的打包反过来写,注意字节 7 的校验覆盖前 7 字节:

/* protocol.c —— 状态回传帧 0x181 */ void motor_status_build(uint8_t *data, const MotorState_t *st) { data[0] = st->state; data[1] = st->fault; data[2] = (uint8_t)(st->rpm >> 8); data[3] = (uint8_t)(st->rpm & 0xFF); data[4] = (uint8_t)(st->current_ma >> 8); data[5] = (uint8_t)(st->current_ma & 0xFF); data[6] = st->temp_c; data[7] = xor_sum(data, 7); }

发送用轮询模式加超时,别用中断——20 ms 一帧的节奏下,轮询三个邮箱完全够,中断发送反而容易在 Bus-Off 时卡死。

4. 换相与调速:霍尔六步换相表、20 kHz PWM 与转速环 PI

协议通了不代表电机会转。无刷电机的核心是“知道转子在哪”和“按节拍给它通电”,前者靠霍尔,后者靠换相表和 PWM。

4.1 霍尔换相表与六路互补 PWM 的配置

霍尔三根线接 PB3/PB4/PB5,三个上拉,每来一个跳变就对应一次换相。六个有效霍尔状态(1~6)对应六个导通组合,0 和 7 是非法状态,必须立刻关断三相——转子停在两个霍尔中间、或者有一根霍尔线断了,都会读到这两个值。

/* bsp_pwm.c —— 六步换相表:给出上桥 PWM 相和下桥常通相,第三相悬空 */ typedef struct { uint8_t pwm_phase; uint8_t low_phase; } Step_t; static const Step_t STEP_TABLE[7] = { {0, 0}, /* 0 占位,非法状态不查表 */ {1, 2}, /* 步 1:A+ B- */ {1, 3}, /* 步 2:A+ C- */ {2, 3}, /* 步 3:B+ C- */ {2, 1}, /* 步 4:B+ A- */ {3, 1}, /* 步 5:C+ A- */ {3, 2}, /* 步 6:C+ B- */ }; /* 霍尔值 (H3H2H1) -> 换相步序号,0 表示非法 */ static const uint8_t HALL_TO_STEP[8] = {0, 1, 3, 2, 6, 4, 5, 0}; static void bldc_apply_step(uint8_t step, uint16_t duty) { if (step == 0 || step > 6) { /* 非法状态:三相六路全关,防止桥臂直通 */ TIM1->CCER &= ~(TIM_CCER_CC1E | TIM_CCER_CC1NE | TIM_CCER_CC2E | TIM_CCER_CC2NE | TIM_CCER_CC3E | TIM_CCER_CC3NE); return; } const Step_t *s = &STEP_TABLE[step]; uint16_t ccr[4] = {0, 0, 0, 0}; uint8_t role[4] = {0, 0, 0, 0}; /* 1 = 上桥 PWM,2 = 下桥常通,0 = 悬空 */ role[s->pwm_phase] = 1; role[s->low_phase] = 2; for (uint8_t i = 1; i <= 3; i++) { uint16_t ce = (uint16_t)(TIM_CCER_CC1E << (4 * (i - 1))); uint16_t cne = (uint16_t)(TIM_CCER_CC1NE << (4 * (i - 1))); if (role[i] == 1) { /* 上桥斩波,下桥互补 */ ccr[i] = duty; TIM1->CCER |= (ce | cne); } else if (role[i] == 2) { /* CCR=0 -> 上桥恒关,互补下桥恒开 */ ccr[i] = 0; TIM1->CCER |= (ce | cne); } else { /* 悬空:两路输出都关 */ TIM1->CCER &= (uint16_t)~(ce | cne); } } TIM1->CCR1 = ccr[1]; TIM1->CCR2 = ccr[2]; TIM1->CCR3 = ccr[3]; }

这段代码里最关键的是“下桥常通相把 CCR 设成 0”。TIM1 工作在 PWM 模式 1,通道输出在CNT < CCR时有效;CCR 等于 0 时上桥永远无效、互补下桥永远有效,正好是下桥常通。反过来写 CCR = ARR+1 会把上桥锁死导通,等于给电源来个短路,这个先后顺序搞反一次,MOS 就没了。

PWM 配置本身没几行,但死区必须配:

/* TIM1:中心对齐 20 kHz,互补输出,死区约 1 us @72 MHz */ TIM_TimeBaseStructInit(&tb); tb.TIM_Prescaler = 0; tb.TIM_CounterMode = TIM_CounterMode_CenterAligned1; tb.TIM_Period = 1800; /* 72M/(2*1800) = 20 kHz */ tb.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM1, &tb); TIM_OCStructInit(&oc); oc.TIM_OCMode = TIM_OCMode_PWM1; oc.TIM_OutputState = TIM_OutputState_Enable; oc.TIM_OutputNState = TIM_OutputNState_Enable; oc.TIM_Pulse = 0; oc.TIM_OCPolarity = TIM_OCPolarity_High; oc.TIM_OCNPolarity = TIM_OCNPolarity_High; oc.TIM_OCIdleState = TIM_OCIdleState_Reset; /* 空闲时上桥关断 */ oc.TIM_OCNIdleState= TIM_OCNIdleState_Reset; TIM_OC1Init(TIM1, &oc); TIM_OC2Init(TIM1, &oc); TIM_OC3Init(TIM1, &oc); TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_OC3PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_BDTRStructInit(&bdtr); bdtr.TIM_DeadTime = 72; /* 72 * 13.9 ns ≈ 1 us */ bdtr.TIM_AutomaticOutput = TIM_AutomaticOutput_Enable; TIM_BDTRConfig(TIM1, &bdtr); TIM_ARRPreloadConfig(TIM1, ENABLE); TIM_CtrlPWMOutputs(TIM1, ENABLE); /* 主输出最后开 */

死区取 1 μs 是折中:太小了上下桥直通,太大了低速时电压畸变严重、转矩脉动明显。手里的 MOS 开关时间在 100 ns 量级的话,0.5~1 μs 都是安全区间。

4.2 转速环 PI:把“给定 rpm”变成“占空比”

转速测量走的是“定时窗口计数”法:TIM3 每 1 ms 中断一次,中断里读取这一毫秒内霍尔跳变的次数,除以 6 得到电周期数,再除以极对数得到机械转数,乘以 60000 就是 rpm。假设极对数是 7,1 ms 内跳变 N 次,则rpm = N * 1000 / (6 * 7)。

这个算法的毛病低转速时特别明显:100 rpm 时 1 ms 内可能一次跳变都没有,测出来是 0,下一毫秒又蹦到 200,PI 的微分项(这里没有)和积分项会被这种量化噪声带飞。我一般会做两件事:一是加一阶低通fb = fb * 0.7 + new * 0.3,二是低速段(< 200 rpm)改用测周法,捕获两次同相跳变的时间间隔反算转速。

PI 本体放在 1 ms 中断里跑,输出直接是 TIM1 的比较值:

/* motor.c —— 1 kHz 转速环,输出 0 ~ 1800 的比较值 */ #define KP 0.80f #define KI 0.02f #define DUTY_MAX 1800 #define INTEG_MAX 60000 static int32_t integ = 0; void speed_loop_1ms(void) { int16_t fb; int32_t err, out; if (motor_state != ST_RUN) { integ = 0; bldc_set_duty(0); return; } fb = hall_get_rpm(); /* 已做过一阶低通 */ err = (int32_t)current_cmd.rpm - fb; integ += err; if (integ > INTEG_MAX) integ = INTEG_MAX; /* 抗积分饱和 */ if (integ < -INTEG_MAX) integ = -INTEG_MAX; out = (int32_t)(KP * err + KI * integ); /* 电流限幅优先于转速环:母线电流超阈值就直接压占空比 */ if (bus_current_ma > OVERCURRENT_MA) { out = (int32_t)(last_duty * 0.9f); integ -= err; /* 同时把这一拍的积分退掉 */ } if (out < 0) out = 0; if (out > DUTY_MAX) out = DUTY_MAX; last_duty = (uint16_t)out; bldc_set_duty((uint16_t)out); }

方向不参与 PI 运算,由命令帧的 bit0 决定,换向时先把占空比降到 0、等 50 ms 再切方向,避免反接制动电流把母线电压顶上去。加速度档位acc在命令层做斜坡限制:每个 1 ms 周期把ramp_rpm往target_rpm靠acc * 100 / 1000rpm,PI 的给定用ramp_rpm而不是target_rpm,这样上位机直接甩一个 3000 过来,电机也不会猛蹿。

5. 联调排查:CAN 不通、电机不转、转起来抖的五个现场记录

前面几章按顺序写下来,代码看着都对,真上电还是大概率出问题。这一章把调试台上最常见的五个现场按“现象 → 原因 → 解决”记一遍,顺序基本就是我自己的排查顺序。

5.1 通信层的三个坑

现象一:CAN 分析仪一条报文都收不到,但把 F103 设成环回模式自测完全正常。

原因基本都在物理层。PA12 被配成普通推挽输出而不是复用推挽,这是最常见的一条;其次是收发器没供电或者只给了 3.3 V 而 RX 输出电平摆幅不够,F103 读到的永远是隐性;第三是整个总线上一只 120 Ω 终端电阻都没有,短线台架上偶尔能通,一旦线长超过半米就全是错误帧。

解决办法是拿示波器看 CAN_H 和 CAN_L,发帧瞬间应该有明显的差分跳变;没有就查供电和 TX 配置,有跳变但分析仪收不到就查终端电阻。总线的两个物理末端各接一只 120 Ω,中间节点不要接。

现象二:能收不能发,或者跑几分钟就掉线,重新上电又好了。

两个可能:一是没开CAN_ABOM,一旦因为干扰进入 Bus-Off 就永久挂死,必须复位才能恢复;二是波特率采样点不匹配,自己配到 75%,对方是 87.5%,短帧勉强能过,长帧或高负载下错误计数一路涨到离线。

解决是把CAN_ABOM = ENABLE打开,让硬件在检测到 128 次连续 11 个隐性位后自动恢复;采样点按 87.5% 配,也就是 BS1 段占到 16 个 tq 里的 14 个。现场干扰大的话,把 SJW 从 1 tq 加到 3 tq 也能明显减少误帧。

现象三:命令帧确实收到了,中断也进了,但电机纹丝不动。

这时候先别怀疑电机。把HALL_TO_STEP查表结果打到串口上,如果一直是 0 或者 7,说明霍尔根本没读进来——十有八九是 PB3/PB4/PB5 还挂在 JTAG 上。F103 复位后默认开启 JTAG-DP,这五个脚里的三个被调试口占着,GPIO 读回来是固定值。

解决是在 GPIO 初始化前调一次重映射:

/* 关闭 JTAG-DP,保留 SW-DP,释放 PB3/PB4/PB5 给霍尔用 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE); GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE);

改完记得确认调试器还是 SWD 模式,选 JTAG 就连不上了。

5.2 电机与功率级的两个坑

现象四:一按启动就过流保护,或者某一只 MOS 特别烫。

优先怀疑死区。TIM_DeadTime忘了配或者配成 0,上下桥在切换瞬间同时导通,母线直接短路,电流尖峰能把保护触发得比 ADC 采样还快。另一种情况是自举电容没充满就开上桥,上桥驱动电压不足、工作在线性区,发热量比短路还夸张。

解决是把死区配到 0.5~1 μs 并在示波器上看上下桥驱动波形的间隙;启动流程里加一段“下桥三相全开 100 ms”的预充电,让自举电容充满再进入换相。这一条我在小功率板子上吃过亏,省掉预充电后前三次启动必定失步。

现象五:低速时抖动明显,回传的转速数值上下跳几十转。

这不是电机的问题,是测速分辨率的问题。1 ms 窗口在低转速下只能数到零星的几个跳变沿,量化误差被 PI 的积分项放大,占空比跟着抖,转矩就跟着抖。空载时看起来只是轻微啸叫,带载时会明显听到“嗡嗡”的拍频。

解决分两步:低于 200 rpm 切测周法,用输入捕获量两次霍尔跳变的时间间隔;同时把 KI 减半、给反馈加一阶低通,fb = fb * 0.7 + raw * 0.3。改完低速段的转速波动能从 ±80 rpm 压到 ±15 rpm 以内。

6. 二次开发:把写死的命令改成可配置的参数表

这套代码跑通之后,最常见的改造需求是“现场要调参数但不想重新烧固件”。KP、极对数、过流阈值、加速度上限这些东西散在代码各处,改一次编译一次,效率极低。我的做法是在协议层再开一组 ID,把参数做成表。

/* param.c —— 参数表:ID + 类型 + 地址,读写走 0x2F0/0x2F1 */ typedef enum { P_U32, P_I32 } PType_t; typedef struct { uint16_t id; PType_t type; void *addr; } ParamItem_t; static uint32_t param_kp_x1000 = 800; /* KP = 0.800 */ static int32_t param_pole_pairs = 7; static uint32_t param_oc_ma = 8000; /* 过流阈值 8 A */ static const ParamItem_t PARAM_TABLE[] = { {0x01, P_U32, &param_kp_x1000}, {0x02, P_I32, &param_pole_pairs}, {0x03, P_U32, &param_oc_ma}, };

0x2F0 是写参数:字节 0~1 参数 ID,字节 2~5 是 int32 小端值,字节 6~7 保留。0x2F1 是读参数,驱动器回一帧同样的格式。写完之后往 Flash 的最后一页(F103C8 是 1 KB/页)存一份,上电时先读 Flash 覆盖默认值。

这里有个必须注意的顺序:写 Flash 会关中断几十毫秒,如果这时候电机正在跑,CAN 接收中断被挂起,cmd_alive_ms停止累加,回来之后可能直接被判超时停机。所以写 Flash 前先强制进 ST_STOP,写完再恢复,别图省事在运行态直接擦写页。

另一个改造方向是把STEP_TABLE和HALL_TO_STEP一起做成可重排的。霍尔装反、相线接错在打样阶段太常见了,与其拆焊不如在协议里加一条“霍尔映射”参数,用三个 3 位字段描述 H1/H2/H3 的实际顺序,初始化时重排HALL_TO_STEP再跑。我用这招在没法返修的样机上救回过两台。

验证方法也简单:写一个最小上位机脚本,用 USB-CAN 盒子把 0x2F0 全表扫一遍写进去,再读回比对,比对通过之后跑一次 0→3000→0 的阶跃,看超调量和调节时间有没有比改之前差。

从那以后我每次改协议字段,都会先写一个只做收发、不接电机的验证固件,让它自己回环跑一晚上,确认没有丢帧和校验失败,再上功率级。省下的是 MOS 和电机,换来的是能睡觉的晚上。希望帮到你。

本文还有配套的精品资源,点击获取