ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

STM32F103与CAN总线的无刷电机命令控制实践

STM32F103与CAN总线的无刷电机命令控制实践

简介:面向学习无刷电机控制与 CAN 总线通信的嵌入式开发者,这份 STM32F103 工程实践资料把电机驱动与 CAN 命令控制放在同一套源码中,适合已掌握单片机基础、希望打通“上位机指令—总线报文—三相逆变—电机执行”链路的中级学习者。压缩包共 202 个文件,约 577KB,以 55 个 .h 头文件、54 个 .c 源文件为核心,配套 Keil 工程文件(.uvprojx、.uvoptx)、编译中间文件与 hex 输出,工程按外设驱动、总线收发和电机控制模块组织,能还原可编译的 F103 项目结构。目前已有 661 人学习。资料围绕 CAN 标识符、DLC 与数据字段的收发配置,以及霍尔信号采集、PWM 换相和转速方向设定展开,包含 stm32f10x_can.c 等外设驱动与电机控制模块,便于读者对照理解命令解析、报文滤波和中断处理流程,并在此基础上修改波特率、控制策略或扩展远程状态回传。

1. 从三个电机、一束乱线说起:为什么用总线命令控制无刷电机

调试台上摆着三台无刷电机,每台都要单独拉电源线、单独接调速电位器,想让它们按同一个节奏走,只能靠手拧,拧完这一台再回头看一眼那一台。把控制权收拢到一条总线上,用 STM32F103 的 bxCAN 接收命令帧,让每个驱动节点自己解析使能、方向和转速,硬件成本几乎没有增加,真正难的是把协议字段、位时序、换相逻辑和保护策略设计对。这套方案适合需要多路电机协同的场合——小车底盘、机械臂关节、小型传送分拣台,也适合作为课程设计或毕业设计里“通信 + 电机控制”双主线的题目。下面按需求、架构、实现、验收的顺序,把整套东西完整摊开。

2. 需求与总体方案:从一条帧到四条链路的职责分工

2.1 需求拆解:把“能转起来”翻译成可测的指标

很多人第一次做这类题目,卡点不在代码,而在验收标准。电机空载转起来太容易了,随便给六步换相表按顺序通电它就能转,但这离“用 CAN 命令控制”差得很远。命令控制至少隐含四件事:可寻址(总线上多个节点,谁听谁的)、可重复(同一个目标转速给十次,结果一致)、可观测(上位机知道当前转速、电流、故障码)、可保护(出问题能自己封锁输出而不是烧管)。

我一般会在动手画原理图之前,先把这几条写成一张可测的指标表,后面所有的设计取舍都围绕它转。比如下面这张表,是我在某模拟项目里实际用过的验收口径:

指标项目标值验收方式
通信速率与帧格式500 kbps,标准数据帧用分析仪抓帧看波形与 ACK
命令下发周期10 ms 一帧,抖动小于 2 ms上位机侧打时间戳统计
调速范围200 ~ 3000 rpm 连续可调霍尔测速对比设定值
稳态误差额定转速 ±1.5% 以内连续采样 60 s 取均值
阶跃响应0 到 1500 rpm 上升时间小于 300 ms,超调小于 10%上位机录曲线回放
故障响应过流或堵转 20 ms 内封锁 PWM 并上报故障码人为堵转试验

这张表的意义在于,它把“通信”和“电机”两个原本割裂的部分绑在了一起。命令周期 10 ms 意味着速度环的采样周期不能比它慢太多,否则上位机改一次设定值要等好几拍才有反应;故障响应 20 ms 则直接决定了过流检测是放在 ADC 中断里还是放在主循环里轮询。这些数字一旦定下来,后面的架构基本就没得选了。

还有一个容易被忽略的需求是节点号。总线上的每个驱动节点要能从帧 ID 里认出“这条命令是给我的”,而不是靠软件里写死的判断。通常的做法是用 ID 的高位做节点地址段,比如 0x180 到 0x18F 这一段分配给控制命令,低 4 位就是节点号,这样一条总线上挂 16 个节点也不用改代码结构,改一个宏定义就行。

2.2 总体架构:通信层、控制层、驱动层、保护层

整套东西按职责切成四层,层与层之间只用全局状态结构体交互,不互相直接调函数,这样调试的时候可以单独把某一层“摘出来”测。

从上往下看,最上面是上位机或者主控节点,它按 10 ms 周期往总线上发命令帧;中间是本节点,也就是 STM32F103 这一侧,它同时挂在 CAN 总线和功率级之间;最下面是三相全桥和电机本体,霍尔信号作为唯一的转子位置反馈回到主控。

  • 通信层:bxCAN 外设负责物理收发,过滤器负责筛选,中断服务程序只做一件事——把收到的帧丢进环形缓冲区,然后立刻返回。所有协议解析、CRC 校验、命令分发都放到主循环里做,避免在中断里跑耗时逻辑。
  • 控制层:1 kHz 的定时器中断里跑速度环 PID,输入是设定转速与实测转速之差,输出是 PWM 占空比。设定值由通信层写入,控制层只读,两边通过一个带读写屏障的全局结构体交换,避免中断与主循环同时改同一个变量。
  • 驱动层:TIM1 输出三路互补 PWM,加上死区时间,驱动三相全桥的六个 MOS 管;换相逻辑根据霍尔状态查表决定哪两相通电。
  • 保护层:过流靠 ADC 注入通道加模拟看门狗,堵转靠“有占空比但霍尔长时间不翻转”判断,过温靠 NTC 分压。任何一条触发,立刻清 MOE 位封锁六路输出,同时把故障码写进状态帧。

模块之间的数据流是这样的:命令帧进来先过 CRC 和序列号检查,合法则更新目标转速和使能标志;控制层每个控制周期读一次目标值,算出占空比写进比较寄存器;保护层在每个 PWM 周期都检查一次电流采样值,一旦越限直接动手,不等控制层反应过来。这个顺序很重要——保护不能依赖控制层的执行节奏,否则速度环一旦卡住,保护也跟着失效。

在资源分配上,F103C8T6 这类 48 脚、64KB Flash 的型号完全够用:CAN 占一个外设,TIM1 做 PWM,TIM2 做 1 kHz 控制节拍,TIM3 做霍尔输入捕获,ADC1 两通道分别采母线电流和温度,剩下的 Flash 装协议解析和 PID 绰绰有余。不需要上 RTOS,一个主循环加两个中断就能跑得很稳。

2.3 命令协议设计:8 字节里怎么塞下所有字段

CAN 标准帧的数据段最多 8 字节,这个限制逼着人做取舍。我的建议是:控制命令和状态上报用两条独立的 ID,不要试图用一条 ID 双向复用,否则区分方向还得额外占一个字节。

ID 分配如下:

CAN ID方向用途周期
0x180 + 节点号主 → 从控制命令(使能、停机、调速)10 ms
0x190 + 节点号从 → 主状态上报(转速、电流、故障码)20 ms
0x1A0 + 节点号主 → 从参数读写(PID 系数、限幅值)按需
0x7FF主 → 全部广播急停按需

控制命令帧的 8 个字节这样分:

字节字段说明
0命令字0x01 使能,0x02 停机,0x03 速度给定,0x04 清故障
1标志位bit0 方向,bit1~bit3 加速度档位,bit4 电气制动
2~3目标转速int16,大端,单位 rpm,负值表示反转
4保留预留扩展,填 0
5序列号0~255 循环,用于丢帧检测
6~7校验CRC16-CCITT,覆盖字节 0~5

选大端而不是小端,是因为抓包工具默认按大端显示,调试时对着分析仪看数值能直接读出来,省掉心算字节序的功夫。序列号这个字段看着可有可无,实际很值钱:上位机连发十帧,从机收到的序列号应该是连续的,中间断了几号就说明丢了几帧,这个信息在排查总线负载和终端电阻问题时比任何日志都直观。

状态上报帧则是另一套字段:字节 0~1 是实测转速,字节 2~3 是母线电流(单位 10 mA),字节 4 是母线电压,字节 5 是故障码位图,字节 6 是当前占空比百分比,字节 7 是节点温度。故障码按位定义,过流、过压、欠压、堵转、过温、编码器异常各占一位,这样一帧就能把所有异常状态带回去,不用为每种故障单独开一条报文。

提示:协议一旦定下来就写进头文件用宏定义,不要在代码里到处出现 0x180 这种裸数字。后面节点号一改,全局替换一次就完事,否则漏掉一处就是几个小时的排查。

2.4 关键选型:为什么是 bxCAN 加六步换相

有人会问,为什么不直接做 FOC。答案是算力和成本。F103 是 Cortex-M3 内核,72 MHz 主频,没有硬件浮点,跑单电阻采样的 FOC 不是不行,但电流环要 16 kHz 以上,加上 Clark/Park 变换和 SVPWM 计算,CPU 占用会吃掉一大半,剩下的余量留给 CAN 通信和上层逻辑就很紧张了。而这类题目的核心诉求是“通信命令控制”,电机侧只要能稳定调速、响应够快就行,六步换相配合霍尔反馈,在风机、泵、小车底盘这类负载上完全够用。

另一个选型点是 CAN 外设。F103 的 bxCAN 是硬件控制器,位时序、CRC、应答、仲裁全部硬件完成,软件只需要读写邮箱。用 GPIO 加定时器软件模拟 CAN 时序的做法在实验室里能跑通,但它对中断延迟极其敏感,一旦电机侧的中断优先级压过来,位时序就会漂移,表现是“空载正常、带载丢帧”——这种玄学问题排查起来非常耗时间。老老实实用硬件外设,把省下来的精力放在协议和保护上,是更划算的账。

3. CAN 通信层实现:位时序、过滤器与命令解析

3.1 位时序与波特率:36 MHz 怎么凑出 500 kbps

F103 的 CAN1 挂在 APB1 上,最高 36 MHz。CAN 波特率的计算公式是:波特率 = APB1 时钟 / (预分频系数 × (1 + BS1 + BS2)),其中 1 是同步段,BS1 是相位缓冲段 1,BS2 是相位缓冲段 2,三者之和就是一个位包含的时间份额(tq)总数。

以 500 kbps 为例,36 MHz / 500 kbps = 72,也就是说预分频系数乘以 tq 总数要等于 72。常见的凑法是预分频取 4,tq 总数取 18,即 BS1 = 15tq、BS2 = 2tq。这时候采样点位置在 (1 + 15) / 18 ≈ 88.9%,正好落在 CAN 规范推荐的 87.5% 附近。采样点靠后一点的好处是:信号在长线上传播、经过收发器延迟之后,采样时刻仍然在稳定区,抗干扰余量更大。

预分频BS1BS2tq 总数实际波特率采样点
415tq2tq18500 kbps88.9%
97tq2tq10400 kbps80.0%
412tq5tq18500 kbps72.2%
1613tq4tq18125 kbps77.8%

表里第三行同样是 500 kbps,但采样点在 72.2%,这就是典型的“短线上一切正常,线一长就随机丢帧”的配方。同一波特率下选哪一组,不要只看能不能通,要看采样点是不是在 85% 到 90% 之间。

下面的初始化代码把上面这些参数落到寄存器上,同时把自动离线恢复和发送优先级一起配好:

/* APB1 = 36MHz,目标 500kbps:4 * (1 + 15 + 2) = 72 */ static void CAN_Bus_Init(void) { GPIO_InitTypeDef gpio; CAN_InitTypeDef can; CAN_FilterInitTypeDef filter; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, 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_TTCM = DISABLE; /* 关闭时间触发模式 */ can.CAN_ABOM = ENABLE; /* 自动离线恢复:总线抖动后自己回来 */ can.CAN_AWUM = DISABLE; can.CAN_NART = DISABLE; /* 允许自动重传,命令帧不能随便丢 */ can.CAN_RFLM = DISABLE; /* FIFO 不锁定,溢出时覆盖最旧帧 */ can.CAN_TXFP = ENABLE; /* 按 ID 优先级排队,急停帧先发 */ can.CAN_Mode = CAN_Mode_Normal; can.CAN_SJW = CAN_SJW_1tq; /* 同步跳转宽度 1tq */ can.CAN_BS1 = CAN_BS1_15tq; can.CAN_BS2 = CAN_BS2_2tq; can.CAN_Prescaler = 4; CAN_Init(CAN1, &can); /* 过滤器组 0:32 位掩码模式,只接收 0x180~0x18F 段 */ filter.CAN_FilterNumber = 0; filter.CAN_FilterMode = CAN_FilterMode_IdMask; filter.CAN_FilterScale = CAN_FilterScale_32bit; filter.CAN_FilterIdHigh = (uint16_t)(0x180 << 5); /* STDID 左移 5 位对齐 */ filter.CAN_FilterIdLow = 0x0000; filter.CAN_FilterMaskIdHigh = (uint16_t)(0x7F0 << 5); /* 高 7 位参与匹配 */ filter.CAN_FilterMaskIdLow = 0x0000; filter.CAN_FilterFIFOAssignment = CAN_Filter_FIFO0; filter.CAN_FilterActivation = ENABLE; CAN_FilterInit(&filter); CAN_ITConfig(CAN1, CAN_IT_FMP0, ENABLE); /* FIFO0 收到有效帧即触发中断 */ }

几个参数值得单独说。ABOM 位是自动离线恢复,CAN 控制器在错误计数超过 255 时会进入离线状态,如果不打开这一位,节点会一直挂在离线状态上不再收发,现场表现就是“拔掉线重插才好”,实际上软件根本没重启,只是硬件自己复位了错误计数。NART 位是禁止自动重传,默认应该关掉,让硬件重传——命令帧在总线上被干扰吃掉一次,重传是成本最低的补救。TXFP 位决定三个发送邮箱的排队方式,打开后按 ID 数值优先级发送,而不是先来先发,这样 0x7FF 的急停帧就能插到普通调速帧前面去。

3.2 过滤器与中断接收:让 CPU 只处理自己的帧

过滤器的作用是在硬件层面把不相干的报文挡掉。一条总线上可能挂着十来个节点,每个节点都在发状态帧,如果全部接收再在软件里判断 ID,中断次数会翻好几倍,CPU 全消耗在进中断出中断上了。

F103 的 bxCAN 有 14 组过滤器,可以配置成 32 位掩码模式。上面那段代码里,ID 寄存器填的是 0x180 << 5,掩码填的是 0x7F0 << 5,意思就是“高 7 位必须等于 0x180 的高 7 位,低 4 位随便”。这样 0x180 到 0x18F 这 16 个 ID 全部放行,节点号在低 4 位里怎么改都能收到。如果一台设备只做单个节点,把掩码改成 0x7FF << 5,那就是精确匹配,只有 ID 完全相等才收。

接收中断里只做搬运,不做业务:

#define RX_RING_SIZE 16 typedef struct { uint32_t std_id; uint8_t data[8]; uint8_t len; } CanFrame_t; static CanFrame_t rx_ring[RX_RING_SIZE]; static volatile uint8_t rx_head = 0; /* 中断写 */ static volatile uint8_t rx_tail = 0; /* 主循环读 */ /* USB_LP_CAN1_RX0_IRQHandler 中调用 */ void CAN_Rx_Isr(void) { CanRxMsg msg; uint8_t next; if (CAN_GetITStatus(CAN1, CAN_IT_FMP0) == RESET) { return; } CAN_Receive(CAN1, CAN_FIFO0, &msg); next = (uint8_t)((rx_head + 1) % RX_RING_SIZE); if (next != rx_tail) { /* 环形缓冲区未满才写入 */ rx_ring[rx_head].std_id = msg.StdId; rx_ring[rx_head].len = msg.DLC; for (uint8_t i = 0; i < msg.DLC && i < 8; i++) { rx_ring[rx_head].data[i] = msg.Data[i]; } rx_head = next; } else { /* 缓冲区满:丢弃并计数,这个计数是排查总线负载的关键线索 */ g_can_rx_overflow++; } CAN_ClearITPendingBit(CAN1, CAN_IT_FMP0); }

环形缓冲区的大小要跟主循环的最长执行时间匹配。主循环里如果有耗时操作(比如 Flash 写入参数),16 帧的深度在 500 kbps、10 ms 一帧的节奏下能撑 160 ms,够用了。溢出计数器一定要留着,调试阶段每隔一秒往串口打一次,如果这个数一直在涨,说明主循环某处卡住了,而不是通信本身有问题——这就是很多人浪费时间的“方向性错误”。

3.3 命令解析状态机:把字节流变成电机动作

主循环拿到帧之后,走一遍固定流程:先验长度,再验 CRC16,再验序列号连续性,最后才按命令字分发。这个顺序不能颠倒,因为 CRC 没过的帧里面的数值是不可信的,拿它去改目标转速可能会导致电机突然加速。

/* CRC16-CCITT,多项式 0x1021,初值 0xFFFF */ static uint16_t crc16_ccitt(const uint8_t *buf, uint8_t len) { uint16_t crc = 0xFFFF; while (len--) { crc ^= (uint16_t)(*buf++) << 8; for (uint8_t i = 0; i < 8; i++) { crc = (crc & 0x8000) ? (uint16_t)((crc << 1) ^ 0x1021) : (uint16_t)(crc << 1); } } return crc; } static void CAN_Command_Dispatch(const CanFrame_t *f) { uint16_t crc_calc, crc_recv; int16_t target_rpm; if (f->len != 8) { g_can_err_len++; return; } crc_calc = crc16_ccitt(f->data, 6); /* 覆盖字节 0~5 */ crc_recv = (uint16_t)((f->data[6] << 8) | f->data[7]); if (crc_calc != crc_recv) { g_can_err_crc++; /* 校验失败直接丢弃 */ return; } /* 序列号连续性检查:差 1 为正常,否则说明中间丢帧 */ if ((uint8_t)(g_last_seq + 1) != f->data[5] && g_last_seq != 0xFF) { g_can_lost_frames += (uint8_t)(f->data[5] - g_last_seq - 1); } g_last_seq = f->data[5]; switch (f->data[0]) { case 0x01: /* 使能 */ g_motor.enable = 1; g_motor.dir = (f->data[1] & 0x01) ? -1 : 1; g_motor.acc_step = (f->data[1] >> 1) & 0x07; break; case 0x02: /* 停机(按斜坡减速) */ g_motor.ramp_target = 0; break; case 0x03: /* 速度给定 */ target_rpm = (int16_t)((f->data[2] << 8) | f->data[3]); if (target_rpm > 3500) target_rpm = 3500; if (target_rpm < -3500) target_rpm = -3500; g_motor.ramp_target = target_rpm; /* 交给斜坡函数平滑 */ break; case 0x04: /* 清故障 */ if (g_motor.fault == 0) { g_motor.fault_clear_req = 1; } break; default: g_can_err_cmd++; break; } }

这里的限幅是在协议层做的第二道保险。控制层里当然还会再限一次,但协议层限一次可以避免异常帧把斜坡函数的目标值一下子推到很离谱的数,导致后面跟着一串连锁反应。斜坡函数的加速度档位来自字节 1 的 bit1 到 bit3,分成八档,档位越高每毫秒允许变化的转速越大,这样同一条命令帧既能做平稳启停,也能做快速响应,不用改固件。

序列号检查里有个细节:上电后第一帧的序列号是随机的,所以用 0xFF 做“未初始化”的哨兵值,第一帧只记录不比较,从第二帧开始才算连续性。不加这个判断的话,上电瞬间会统计出一个巨大的丢帧数,日志全被污染了。

4. 电机控制层实现:换相、速度环与保护

4.1 六步换相与 TIM1 互补 PWM 加死区

三相无刷电机的六步换相,本质上是按霍尔状态决定哪两相通电、第三相悬空。三个霍尔传感器在 360 度电角度里给出六种有效组合,每一种对应一组上下桥臂的开关状态。用一张查表就够了,不需要在运行中做任何计算。

TIM1 是 F103 上的高级定时器,带互补输出和死区插入,正好用来驱动三相全桥。配置的关键点是:PWM 模式 1、中心对齐计数、死区时间按 MOS 管关断时间留余量、MOE 位(主输出使能)单独控制以便故障时快速封锁。

/* 霍尔状态到 PWM 通道的映射:1 = 该相上桥导通,0 = 该相下桥导通,0xFF = 悬空 */ static const uint8_t hall_to_pwm[8][3] = { {0xFF, 0xFF, 0xFF}, /* 0: 无效 */ {1, 0, 0xFF}, /* 1: H1 */ {1, 0xFF, 0}, /* 2: H2 */ {0xFF, 1, 0}, /* 3: H1+H2 */ {0, 1, 0xFF}, /* 4: H3 */ {0, 0xFF, 1}, /* 5: H1+H3 */ {0xFF, 0, 1}, /* 6: H2+H3 */ {0xFF, 0xFF, 0xFF} /* 7: 无效 */ }; static void Motor_Apply_Commutation(uint8_t hall) { const uint8_t *ph = hall_to_pwm[hall & 0x07]; /* 三相都不导通时直接封锁输出,避免直通 */ if (ph[0] == 0xFF && ph[1] == 0xFF && ph[2] == 0xFF) { TIM1->BDTR &= ~TIM_BDTR_MOE; return; } for (uint8_t i = 0; i < 3; i++) { uint16_t compare = 0; if (ph[i] == 0xFF) { /* 悬空相:两路都关,比较值给 0 */ compare = 0; } else if (ph[i] == 1) { compare = g_motor.pwm_duty; /* 上桥按占空比斩波 */ } else { compare = g_motor.pwm_period; /* 下桥常通 */ } TIM1->CCR1 = 0; /* 占位,实际按通道索引写 CCR1/CCR2/CCR3 */ (void)compare; } /* 实际工程里用 switch 或指针表写 CCR1~CCR3 与 CCER 的极性位 */ TIM1->BDTR |= TIM_BDTR_MOE; }

上面这段为了突出换相表的结构,把写寄存器的部分简化成了占位。实际写法通常是用三元素数组映射到 CCR1、CCR2、CCR3,并且注意 CCER 里的 CCxP 极性位——高侧有效电平和低侧有效电平是反的,配置错一个极性,表现就是“一上电就烧管”。我的建议是把极性配置和死区配置放在初始化函数的最后,并且在使能 MOE 之前先确认六路输出都是关断状态,上电时序稳一点,调试阶段少烧几个管子。

死区时间按公式算:DTG[7:0] 的取值决定插入的延时,具体分四档公式,最常用的是 DTG = 死区时间(ns) × 72 / 1000(主频 72 MHz,单位换算后落在第一档区间)。工程上一般留 500 ns 到 1 us,具体看 MOS 管和驱动芯片的关断时间,手册里都有,照着取就行,别凭感觉。死区给太小会直通,给太大会让低速时的转矩脉动变明显,两头都不讨好。

4.2 速度环 PID:1 kHz 定时中断里的闭环

控制环放在 TIM2 的更新中断里跑,周期 1 ms。之所以选 1 kHz,是因为命令帧 10 ms 才来一次,控制环快十倍左右既能跟上设定值变化,又不会让 CPU 太累。PID 的输出是占空比,输出范围限制在 0 到 PWM 周期之间。

积分饱和是速度环里最容易翻车的地方之一,所以代码里做了两件事:一是输出限幅的同时反向修正积分项,二是设定值变化时先经过斜坡函数,避免阶跃过大直接把积分项顶到天花板上。

typedef struct { float kp, ki, kd; float err_last; float integral; float out_max; /* 占空比上限,对应 PWM 周期 */ float out_min; } Pid_t; /* 1kHz 定时中断中调用,dt 固定为 0.001 s */ float PID_Calc(Pid_t *p, float target, float actual, float dt) { float err = target - actual; float out; p->integral += p->ki * err * dt; /* 抗积分饱和:先按输出限幅反推积分项的上界 */ if (p->integral > p->out_max) p->integral = p->out_max; if (p->integral < p->out_min) p->integral = p->out_min; out = p->kp * err + p->integral + p->kd * (err - p->err_last) / dt; p->err_last = err; /* 输出限幅,超出部分不回灌到积分项,靠上面的钳位兜住 */ if (out > p->out_max) out = p->out_max; if (out < p->out_min) out = p->out_min; return out; }

参数整定的顺序我一般是这样:先把 ki 和 kd 归零,只加 kp,慢慢加到电机能跟上设定值、但开始有轻微振荡,然后往回退一点到临界值的 60% 左右;接着加 ki,从很小的值开始,观察稳态误差收敛的速度,加到能消掉静差又不明显超调为止;kd 在速度环里作用有限,通常给一个很小的值压制一下过冲就行,给大了反而会放大霍尔测速的量化噪声。

霍尔测速的分辨率是个绕不过去的坑。三个霍尔在低速时每个电周期只有六个跳变,1 ms 采一次,转速越低,两次跳变之间的时间越长,算出来的速度越粗糙,低速段容易出现“一顿一顿”的抖动。通常的做法是低速时改用“测周法”,即测两个霍尔跳变之间的时间间隔来算速度,高速时用“测频法”数固定时间窗内的跳变次数,两者按转速分段切换。这一步不做,低速性能基本没法看。

4.3 保护与状态上报:让上位机看得见

保护逻辑分成两级。第一级是硬件级的,母线电流经过采样电阻送到比较器,比较器输出直接接到 TIM1 的刹车输入(BKIN),一旦触发硬件立刻清 MOE,不经过任何软件路径,响应时间在微秒级。第二级是软件级的,ADC 注入通道在每个 PWM 周期采一次电流,模拟看门狗设一个略低于硬件阈值的门限,触发后进中断记录故障码并上报,同时软件封锁输出。

两级保护配合的意义在于:硬件保护负责“不烧”,软件保护负责“说清楚为什么”。只有硬件保护的话,现场看到的现象就是电机突然停了,没有任何信息,排查全靠猜。

故障码用位图,一帧带回去:

位故障判定条件
bit0过流采样电流 > 阈值持续 3 个 PWM 周期
bit1过压母线电压 > 上限
bit2欠压母线电压 < 下限
bit3堵转占空比 > 30% 且霍尔 500 ms 无跳变
bit4过温NTC 换算温度 > 85℃
bit5霍尔异常读到 0 或 7 状态持续 10 ms
bit6通信超时超过 200 ms 未收到有效命令帧
bit7参数错误Flash 中的 PID 参数超出合法范围

通信超时这一位特别值得说。它是“命令控制”这个模式下的安全底线:上位机挂了、线断了、总线被干扰了,从机不能傻乎乎地保持最后一条命令继续转。我一般设 200 ms 超时,超时后按当前加速度档位斜坡减速到零,而不是直接切断输出——直接切断会让负载自由滑行,某些场合反而更危险。减速到位之后再清使能位,并把 bit6 置起来,上位机恢复通信后能看到这个故障码,知道刚才断过。

状态上报帧按 20 ms 周期发,比命令帧慢一半,理由是状态量变化没那么快,而且总线负载要留余量。500 kbps 下一条 8 字节标准帧大约占 110 到 130 位(含帧间隔和可能的填充位),算下来一条总线在 500 kbps 时理论极限大概是每秒 4000 帧左右,实际留 30% 余量,16 个节点、每个节点 10 ms 发一帧命令加 20 ms 发一帧状态,总共每秒 2400 帧,还在安全区内。如果继续往上加节点,就得把状态帧周期拉长或者改成事件触发,否则总线负载一高,优先级低的帧会被一直推迟,表现出来就是“某些节点偶尔不响应”。

5. 联调避坑:CAN 与电机两侧最容易翻车的五个点

5.1 CAN 侧三个高频问题

终端电阻少接或多接。现象是短线上跑一整天都没事,线一拉到两米以上、或者节点数增加到四个以上,就开始随机丢帧,用分析仪看波形会发现上升沿有明显的台阶和振铃。原因是 CAN 是差分总线,两端各需要一个 120 欧姆的终端电阻来做阻抗匹配,很多人只在一端接了,或者每个节点都焊了一个,前者反射严重,后者把总线负载拉低到 40 欧姆以下,收发器驱动能力不够。解决方法是把总线两端各接一个 120 欧姆,中间节点一律不接,接完之后用万用表量 CAN_H 和 CAN_L 之间的直流电阻,应该在 60 欧姆左右,这个数值是最直接的判断依据。

位时序采样点太靠前。现象和上面很像,也是长线或多节点时丢帧,但波形看起来很干净,没有振铃。原因是 BS1 和 BS2 的分配不合理,采样点落在 70% 附近,信号经过收发器延迟和线缆传播之后,采样时刻恰好在电平跳变的边沿上。解决方法是按 3.1 节那张表重新配,把采样点推到 87.5% 附近;如果现场没法改固件,就把波特率降一档,容忍度立刻好很多。这一条之所以容易踩,是因为实验室的短线束上两种配置都能通,问题只在现场暴露,属于典型的“血泪经验”型坑。

阻塞式发送。现象是主循环偶尔卡住几十毫秒甚至上百毫秒,表现为其他任务(比如按键扫描、串口输出)间歇性失灵。原因是在主循环里写了while (CAN_Transmit(...) == CAN_TxStatus_NoMailBox);这种阻塞等待,一旦三个发送邮箱都满,程序就在这里死等,而邮箱要等总线空闲、仲裁胜利之后才能发出去,总线负载高的时候等待时间不可控。解决方法是发送也走队列加中断:主循环只把待发帧塞进软件队列,中断里检查CAN_IT_TME(发送邮箱空中断)再取一帧填进去,主循环永远不阻塞。这个改动代码量不大,但对系统实时性的提升非常明显。

5.2 电机侧两个高频问题

死区时间不足导致上下桥直通。现象是带载运行一段时间后 MOS 管发热严重,示波器看桥臂中点波形,会在开关瞬间看到明显的电流尖峰,严重时直接炸管。原因是死区配置得太小,或者干脆没配,上管还没完全关断下管就导通了,母线电流瞬间短路。解决方法是先从驱动芯片手册里查到关断延迟时间,死区按这个值加 30% 到 50% 的余量取,然后在示波器上实测桥臂中点波形确认没有重叠;同时检查 MOE 位的使能顺序,先配好六路输出状态再使能,不要在初始化中途短暂打开。

霍尔相序与实际接线不匹配。现象是电机能转但方向反了,或者启动时剧烈抖动、带载就堵转,换相表改了又改还是不对。原因是三个霍尔线接的顺序和代码里假设的顺序不一致,六种有效状态的排列被打乱了。解决方法是别猜,用最原始的办法:手动给某一相通电,记录此时三个霍尔的读数,再换下一相,把六种状态和对应的通电相实际测一遍,生成真实的换相表写进代码。这个过程十分钟能做完,比对着代码推演两个小时靠谱得多。测完之后再验证一遍,正转时霍尔读数应该按 1→3→2→6→4→5 或反向这个顺序循环出现,不符合就说明还有线接错了。

6. 验收与答辩:怎么证明这套方案真的能用

验收这件事,最忌讳的是“演示一下就完事”。电机转了、指示灯亮了,这只是功能跑通,不是方案可用。我一般会把验收拆成四个阶段,每个阶段都有明确的通过条件和记录方式,答辩的时候直接把记录表摊在桌上,比任何口头描述都有说服力。

阶段检查内容通过条件记录方式
一、静态通信只上电不接电机,总线两端接 120 欧姆连续收发 10 分钟,丢帧为 0,CRC 错误为 0分析仪抓包 + 串口打印计数器
二、空载运行接电机不带负载,命令周期 10 ms200 到 3000 rpm 全范围可调,稳态误差小于 ±1.5%上位机录转速曲线
三、带载与阶跃加额定负载的 50%,做 0 到 1500 rpm 阶跃上升时间小于 300 ms,超调小于 10%同上,导出 CSV
四、故障注入人为短路一相、拔掉总线、堵住转子20 ms 内封锁输出,故障码正确,恢复后可重启录像 + 故障码截图

第一阶段一定要单独做,而且要在接电机之前做。很多人上来就把电机接好通电,结果通信一有问题就怀疑是电机干扰,两边同时排查,效率极低。把通信单独跑通、跑够十分钟不丢帧,等于先把一半的变量固定住。

第四阶段的故障注入是答辩时最出彩的部分。拔总线那一项尤其值得做:当着评委的面把 CAN 线拔掉,电机应该按斜坡减速停下来,状态帧停止发送,重新插上之后能在 200 ms 内恢复响应并且上报通信超时故障位。这个演示直接证明了“命令控制”的完整闭环——不只是能听命令,还能在命令消失的时候做出安全反应。

答辩讲的时候,我的建议是按“问题、方案、证据”三段走,不要一上来就讲寄存器。比如:“多电机联动时每个都要单独调速,线束复杂且无法同步;我用一条 CAN 总线把命令下发统一起来,节点号放在 ID 低 4 位,一条帧控制一个节点;证据是这张抓包截图,16 个节点、10 ms 周期、连续 10 分钟零丢帧。”三句话把背景、做法、结果说清楚,剩下的时间留给评委提问。

提问环节高频出现的几个问题,提前准备好答案:为什么不用 FOC(算力和成本,负载类型不需要);CAN 波特率为什么是 500 k(速率和总线长度的折中,40 米以内);如果两个节点同时发同一条 ID 会怎样(仲裁机制,ID 小的赢,输的那个自动重发,不会冲突);通信断了电机怎么处理(斜坡减速加故障位上报)。这些问题答得上来,说明方案是真的想清楚了。

从那以后我每次做带总线的电机项目,都会强制走一遍“先通信、后电机、再故障”的三段验收,不管时间多紧都不跳过第一步。跳过的那次,在联调现场花了两天时间才定位到一个 120 欧姆电阻没焊的问题。希望这套流程能帮到你,少走一点弯路。

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

返回列表