背景介绍

最近做项目需要进行多台BLDC的力位混合控制,于是开始学习相关知识。但是发现灯哥FOC的B站课程以及例程大部分都是都是基于ESP32的,其STM32的例程是基于标准库的并且用起来效果不是很好,于是想写篇文章记录下用HAL库开发的过程,也是为了自己能够更好的学习,有许多不足之处还请各位大佬指正。

1.硬件介绍

1.1.驱动板

使用灯哥开源FOC驱动板,版本为V3P。自带STM32转接板以及STM32F103C8T6最小系统板以及电源线。

1.2主控芯片

主控芯片为STM32F103C8T6。

1.3.电机

电机采用淘宝上购买的2804云台电机,主要参数如下:极对数为7,额定电压12V。

1.4.接线

UVW三相线不需要分顺序,直接接入即可。由于是开环,可不接AS5600编码器的IIC线。按红正黑负接入电源线,STM32最小系统核心板使用ST-LINK进行调试,转接板上type-c接口用于串口调试发送速度指令。

1.5.原理图

2.CubeMX配置

2.1.RCC时钟源配置

使用外部晶振好处是精度更高,时钟更稳

  • 外部晶振的频率误差通常在 ±10~50ppm,而内部 HSI 误差能到 ±1%(也就是 ±10000ppm)
  • 更稳定的时钟,意味着 PWM 周期、定时器、串口波特率都更准
  • 做 FOC 电机控制时,PWM 频率和死区时间必须精确,否则会导致电机抖动、噪音大

2.2.调试接口配置

选择SWD接口调试

2.3.电机使能引脚配置

打开PA8为推挽输出,默认高电平来使能驱动板工作

2.4.PWM配置

打开定时器2的PWM的123通道,时钟源选择为内部时钟,不设置预分频PSC,ARR的值设置为4499,确保PWM的频率为16kHZ

  • PWM 频率越高,电流环的控制带宽也能做得更高,对电流变化的响应更快。
  • 对于开环 FOC 来说,16kHz 的载波频率足够高,输出的三相正弦电压会更平滑,电机运转更顺滑,抖动和发热都会更小。
  • 对于MOS 管 / 驱动芯片来说,16kHz 的开关频率不算高,开关损耗比较小,驱动管不容易过热。
  • 人耳的可听范围大约是 20Hz–20kHz。
  • 对比 20kHz 甚至更高的频率,它对驱动的要求更低,发热更可控,同时又能兼顾噪音表现,是非常均衡的选择。

2.5.USART配置

选择异步串口通信,方便后续通过串口调速

2.6.DMA配置

不使用DMA时,每当串口收到一个字节(比如字符 'T'),CPU 就要立刻停下手里正在算的 FOC 公式,跑过去把这个 'T' 从串口寄存器搬到你的内存数组里。如果发来 10 个字符,CPU 就要被中断 10 次。高频占用CPU,导致计算电角度的逻辑被切得细碎,电机就会出现瞬间的卡顿或噪音

有了DMA后,在没接收完固定字数字符之前不需要中断CPU去处理数据

DMAsettings中点击Add,添加USART1_RX。

方向Direction: 选择Peripheral To Memory(外设到内存),即从串口读取数据直接存到内存里

模式Mode: 选择Normal(普通模式)

地址增长Increment Address:选择memory

  • 外设 (Peripheral):指的是 USART1

  • 内存 (Memory):指的是你在代码里定义的数组 rx_buffer

  • Normal:搬完指定的长度(比如 64 字节)就停下

  • Increment Address选择Memony后每搬完一个字节,内存地址往后挪一位。第一个字母放 rx_buffer[0],第二个放 rx_buffer[1]。如果不勾选,所有字母都会挤在 rx_buffer[0],导致后面的把前面的覆盖。

NVICsettings中勾选USART1 global interrupt全局中断

  1. 空闲检测:当 \n 传完后,串口线上没数据了。

  2. 硬件触发中断:USART1 硬件检测到“空闲(IDLE)”状态,触发中断

  3. CPU 处理:这时候 CPU 才执行 HAL_UARTEx_RxEventCallback 里面的代码,把字符串转成数字。

2.7.时钟树配置

HCLK处输入72后回车,系统自动帮你配置

2.8.生成代码

第一个部分勾选第2个选项,只生成必要的库

第二个部分勾选上134

然后点击右上角generate code

3.FOC控制算法介绍

 对于一些基本原理大佬稚晖君的文章已经讲的很清楚了,可以拜读大佬的文章:【自制FOC驱动器】深入浅出讲解FOC算法与SVPWM技术 - 知乎。在这里我只引用稚晖君部分内容,更多的是加上自己学习时遇到的问题与思考,如果看了我的部分难以理解,还是去看大佬的文章吧

3.1.FOC概述

FOC(Field-Oriented Control),直译是磁场定向控制,也被称作矢量控制(VC,Vector Control),是目前无刷直流电机(BLDC)和永磁同步电机(PMSM)高效控制的最优方法之一。FOC旨在通过精确地控制磁场大小与方向,使得电机的运动转矩平稳、噪声小、效率高,并且具有高速的动态响应。

3.2.无刷电机原理

根据磁极异性相吸同性相斥的原理,中间永磁体在两侧电磁铁的作用下会被施加一个力矩并发生旋转,这就是电机驱动的基本原理。

那么电机转子转起来就需要两侧磁场的不断变化,而这个换向的操作,就是需要驱动器去完成的。这也是无刷电机有刷电机最大的区别,即不像有刷电机的机械换向,无刷电机是通过电子换向来驱动转子不断地转动。

3.3.BLDC和PMSM的区别

无刷电机其实可以分为无刷直流电机(BLDC)永磁同步电机(PMSM)结构上难以分辨,主要区别在于制造方式(线圈绕组方式)以及(永磁体转子的结构)不同导致的一些特性差异(比如反电动势的波形)。

BLDC的反电动势波形是梯形波,PMSM的反电动势是正弦波

3.4.为什么选择SPWM控制

很多人容易混淆 FOC 和 SVPWM

实际上,FOC 是一种控制架构,它通过坐标变换(Park/Clarke)将交流电机简化为类似直流电机的模型。而 SPWM 或 SVPWM 只是将 FOC 算出的指令转化为实际 PWM 信号的不同调制方法。

高级应用中多采用 SVPWM,但本此实验先采用 SPWM 驱动,其代码实现更加直观,打下信心后续能更好学SVPWM。

3.4.1.反电动势决定控制波形

反电动势是什么?

电机转起来,线圈被磁铁 “扫” 出来的电压,方向和供电相反,所以叫反电动势(Back-EMF)

旋转的永磁体的磁场切割定子里的三相线圈,根据电磁感应,线圈里就会感应出电压,转速越快,磁场切割线圈的速度越快,反电动势就越大。

反电动怎么决定波形的?

如果转矩要想稳,驱动波形必须与反电动势波形匹配,如果不匹配将会发生以下情况:

输入电流最大的时候反电动势并不是最大;电流下降的时候,反电动势在上升,就会造成转矩忽大忽小,产生巨大脉动,电机抖动、噪音大

3.4.2.六步换向法方波驱动

  • 电压形态:每一相的电压在 全开 (12V)全关 (0V) 和 悬空 (High-Z) 之间切换。

  • 导通规律:每一时刻只有两个线圈导通(一进一出),第三个线圈不通电。

  • 电流长相:线圈里的电流是方波或者梯形波

虽然六步换向法的方波比较适合BLDC的反电动势,但在实际制造中,由于气隙磁通分布、定子槽口影响以及加工误差,绝大多数BLDC电机的反电动势更接近于“长得像梯形的波”或者“带毛刺的正弦波”。在六步换向中,调制波形本身就是阶跃的方波,电感将其‘磨’成了带有圆角的方波,但换向瞬间的电流突变和第三相的物理断开是无法消除的,所以永远也不能产生正弦波

并且由于方波并不是平滑的,在换相瞬间,电流从一相跳到另一相,由于电感存在,电流无法瞬间改变,会导致明显的转矩脉动(听起来就是“嗡嗡”声或震动)。

3.4.3.FOC使用SPMW驱动

我们选择所谓适配反电动势的波形本意就是为了驱动波形在变化过程中更加平稳,现在SPWM的正弦波与BLDC反电动势配合,在驱动波形变化的过程中平滑程度明显是优于六步换向法时的,所以哪怕波形不是100%匹配,FOC带来的“平滑性”收益也远大于波形不匹配带来的那一点点小损失。

SPWM驱动时

  • 电压形态三相线圈时刻都在工作(33导通)。每根线上的占空比都在按正弦规律不停起伏。

  • 导通规律:三路电流同时存在,互为补偿。

  • 电流长相:线圈里的电流是平滑的正弦波

那么PWM是怎么调制出SPWM的?

  • STM32 端:输出的是 0V 或 3.3V 的高频PWM方波。

  • 驱动板端:通过 MOS 管,把这个方波变成了 0V 或 12V 的高频PWM方波。

  • 注意:无论在六步还是 SPWM,驱动板输出的瞬时电压永远只有 0V 或 12V,不存在中间电压。只是由于占空比一直在变,于是每个周期内,打开MOS管时间长,占空比高的高频脉冲等效电压就高,效果上就可以输出0-12V的等效电压。

那所谓的“正弦波”是从哪来的?

由于我们的占空比变化规律是按照正弦波的规律来变化的,开关管输出的相当于是一个个正弦波上的点,电感(线圈)对这些高频脉冲进行“时间平均”后,电机感受到的等效电压就是一个正弦波了

4.FOC控制算法实现

关于FOC公式的推导,依然可以参考大佬的文章,我只注明本次实验用到的部分

本次是开环实验,主要是想快速跑通代码来建立信心,所以不需要进行Clark、Park变换,只需要反变换部分,同时也不需要任何的PI参数以及自控知识。

4.1调速逻辑

怎么改变速度呢?

我们想要转速变得更快,就需要产生更快的旋转磁场,那么也就是产生的正弦波速度越快,这与六步换向法的电压增加是不一样的,我们增加正弦波的电压幅值,不改变正弦波的频率,只会让电流变大扭矩变大,磁场旋转速度不变。

那么这个正弦波产生速度是怎么控制的?

我们知道PWM的频率是不变的,一个周期内产生一个方波,也就是一个高频脉冲。那我只要使每个相邻的周期内,高频脉冲的等效电压差变大,电机线圈过滤后的正弦波频率就会变快

那么也就是占空比变化加快。比如之前是两个周期内,方波的占空比由50%-51%,那么我现在两个周期内,我直接由50%-55%,等效的高频脉冲电压差值加大,形成的正弦波的频率也就变快了

占空比计算:

  • 逻辑:占空比 = 振幅 × sin(当前角度)。

在时刻 t1时(角度 30.1°):CCR_A = 4500 * [0.5 * sin(30.1°)] = 1128

在时刻 t2时(角度 30.2°):CCR_A = 4500 * [0.5 * sin(30.2°)] = 1132

那么当我们输入想调节的速度是怎么去改变占空比的呢?

核心公式:新角度 = 旧角度 + 速度 * 时间,当我们输入速度,新角度就会发生变化

电角度 = 机械角度 * 极对数

因为磁场旋转 1 周,电机才转 1/7 圈(7 对极)。所以我们要把“我想让电机转的角度”换算成“我想让磁场转的角度”

     // 1. 计算时间差 dt
    uint32_t now_us = Driver_System_GetMicros();
    float dt = (now_us - myMotor.last_us) * 1e-6f;
    if(dt <= 0 || dt > 0.1f) dt = 1e-3f;
    myMotor.last_us = now_us;

    // 2. 角度累加 (机械角)
    myMotor.shaft_angle = FOC_NormalizeAngle(myMotor.shaft_angle + myMotor.target_speed * dt);

    // 3. 计算电角度
    float electric_angle = FOC_NormalizeAngle(myMotor.shaft_angle * myMotor.pole_pairs);

接下来就是将电角度变成三相电压分量(FOC中的逆变换)

这部分代码首先将算出的angle_el 电角度逆Park变换成Ualpha和Ubeta

逆Clark变换,换算成三个相上此刻瞬间的高频脉冲的等效电压值

void FOC_InverseParkClarke(float Uq, float Ud, float angle_el, float v_bus, float* Ua, float* Ub, float* Uc) {
    // 1. 逆帕克变换
    float Ualpha = -Uq * sinf(angle_el) + Ud * cosf(angle_el);
    float Ubeta  =  Uq * cosf(angle_el) + Ud * sinf(angle_el);

    // 2. 逆克拉克变换 (SPWM 简化模型)
    float v_offset = v_bus / 2.0f;
    *Ua = Ualpha + v_offset;
    *Ub = (_SQRT3 * Ubeta - Ualpha) / 2.0f + v_offset;
    *Uc = (-Ualpha - _SQRT3 * Ubeta) / 2.0f + v_offset;
}

接下来计算所需电压对应的占空比

void Driver_PWM_SetDuty(float dc_a, float dc_b, float dc_c) {
    // 限制占空比在 0~0.9 之间(保护电机,也给电荷泵留空间)
    if(dc_a > 0.9f) dc_a = 0.9f;
    if(dc_b > 0.9f) dc_b = 0.9f;
    if(dc_c > 0.9f) dc_c = 0.9f;

    __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, (uint32_t)(dc_a * PWM_RANGE));
    __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_2, (uint32_t)(dc_b * PWM_RANGE));
    __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_3, (uint32_t)(dc_c * PWM_RANGE));

传入的参数如下

  Driver_PWM_SetDuty(Ua/myMotor.v_bus, Ub/myMotor.v_bus, Uc/myMotor.v_bus);

就实现了开环调速辣!

后面就是所有文件的完整代码了!

4.2动态修改PWM

动态修改占空比driver_pwm.c文件

#include "driver_pwm.h"
#include "tim.h"

void Driver_PWM_Start(void) {
    HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
    HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_2);
    HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_3);
}

void Driver_PWM_SetDuty(float dc_a, float dc_b, float dc_c) {
    // 限制占空比在 0~0.9 之间(保护电机,也给电荷泵留空间)
    if(dc_a > 0.9f) dc_a = 0.9f;
    if(dc_b > 0.9f) dc_b = 0.9f;
    if(dc_c > 0.9f) dc_c = 0.9f;

    __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, (uint32_t)(dc_a * PWM_RANGE));
    __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_2, (uint32_t)(dc_b * PWM_RANGE));
    __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_3, (uint32_t)(dc_c * PWM_RANGE));
}

对应的driver_pwm.h文件

#ifndef __DRIVER_PWM_H
#define __DRIVER_PWM_H
#include "stm32f1xx_hal.h"

#define PWM_RANGE 4500.0f  // 对应 CubeMX 里的 ARR+1

void Driver_PWM_Start(void);
void Driver_PWM_SetDuty(float dc_a, float dc_b, float dc_c);

#endif

标准库通常调用 TIM_SetCompareX 函数。虽然这也没错,但函数调用本身会有入栈、出栈的操作。

HAL库代码的优化:使用了 HAL 库的底层宏 __HAL_TIM_SET_COMPARE。

这是一个直接的内存地址赋值(寄存器操作),没有函数跳转开销。

在主循环里,这种微小的效率提升能保证三相 PWM 的占空比几乎在同一瞬间被写入,避免了三相之间出现微小的相位偏移(相位偏移是导致电机啸叫的原因之一)。

4.2.系统计时器

driver_system.c文件

#include "driver_system.h"

void Driver_System_Init(void) {
    // 开启 DWT 计数器(内核自带的高精度计时器)
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
    DWT->CYCCNT = 0;
    DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
}

uint32_t Driver_System_GetMicros(void) {
    // 根据 72MHz 频率计算微秒
    return DWT->CYCCNT / (SystemCoreClock / 1000000);
}

对应的driver_system.h文件

#ifndef __DRIVER_SYSTEM_H
#define __DRIVER_SYSTEM_H
#include "stm32f1xx_hal.h"

void Driver_System_Init(void);
uint32_t Driver_System_GetMicros(void); // 获取系统启动后的微秒数

#endif

标准库的代码使用 (Last - Now) / 9 * 1e-6 计算dt,这基于 SysTick 的 8 分频(9MHz)。SysTick 是一个 24 位定时器,非常容易溢出。

更糟糕的是,标准库的延时函数(Delay)经常会通过修改 SysTick 的寄存器来实现,这会导致在主循环里获取的时间戳发生跳变或暂时停滞。一旦 dt不准,电机每一步转动的角度就忽大忽小,表现出来就是震动和高频噪音。

优化后改用了 DWT (内核调试计数器)。

DWT 是直接挂在 CPU 时钟上的 32 位计数器,不受任何中断或 HAL_Delay 的干扰。它的精度是 1/72,000,000 秒。这意味着每一帧计算出的dt都是极度准确且连续的,电机的磁场旋转顺滑。

 4.3.FOC逆变换

foc_math.c文件

#include "foc_math.h"

float FOC_NormalizeAngle(float angle) {
    float a = fmodf(angle, 2.0f * PI);
    return (a < 0.0f) ? (a + 2.0f * PI) : a;
}

void FOC_InverseParkClarke(float Uq, float Ud, float angle_el, float v_bus, float* Ua, float* Ub, float* Uc) {
    // 1. 逆帕克变换
    float Ualpha = -Uq * sinf(angle_el) + Ud * cosf(angle_el);
    float Ubeta  =  Uq * cosf(angle_el) + Ud * sinf(angle_el);

    // 2. 逆克拉克变换 (SPWM 简化模型)
    float v_offset = v_bus / 2.0f;
    *Ua = Ualpha + v_offset;
    *Ub = (_SQRT3 * Ubeta - Ualpha) / 2.0f + v_offset;
    *Uc = (-Ualpha - _SQRT3 * Ubeta) / 2.0f + v_offset;
}

对应的foc_math.h文件

#ifndef __FOC_MATH_H
#define __FOC_MATH_H
#include <math.h>

#define PI 3.14159265359f
#define _SQRT3 1.73205081f

float FOC_NormalizeAngle(float angle);
void FOC_InverseParkClarke(float Uq, float Ud, float angle_el, float v_bus, float* Ua, float* Ub, float* Uc);

#endif

4.4.串口调试

driver_uart.c文件

#include "driver_uart.h"
#include "usart.h"      // CubeMX 生成的串口句柄
#include "motor_ctrl.h" // 为了修改 myMotor 结构体
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

uint8_t rx_buffer[UART_RX_BUF_SIZE];

/**
  * @brief 初始化串口 DMA 接收
  */
void Driver_UART_Init(void) {
    // 开启“接收到空闲”中断 DMA 模式
    // 这一行会让串口在收到一帧数据(空闲)后自动触发回调
    HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buffer, UART_RX_BUF_SIZE);
    
    Driver_UART_Print("Serial Control Ready! Send 'T+speed' (e.g. T10.5)\r\n");
}

/**
  * @brief 简易字符串发送
  */
void Driver_UART_Print(const char* str) {
    HAL_UART_Transmit(&huart1, (uint8_t*)str, strlen(str), 100);
}

/**
  * @brief HAL库 串口接收事件回调函数
  * @param Size 本次实际接收到的字节数
  */
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) {
    if (huart->Instance == USART1) {
        // 1. 字符串化处理
        rx_buffer[Size] = '\0';

        // 2. 指令解析:格式为 T + 数字 (例如: T15.5)
        if (rx_buffer[0] == 'T') {
            // 解析 T 之后的浮点数
            float speed = atof((char*)&rx_buffer[1]);
            
            // 3. 修改全局电机对象的目标转速
            myMotor.target_speed = speed;

            // 4. 回显确认
            char feedback[64];
            sprintf(feedback, ">>> New Target Speed: %.2f rad/s\r\n", speed);
            Driver_UART_Print(feedback);
        } else {
            Driver_UART_Print("Unknown Command! Use T[speed]\r\n");
        }

        // 5. 重要:重启 DMA 接收,否则只会触发一次
        HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buffer, UART_RX_BUF_SIZE);
    }
}

对应的driver_uart.h文件

#ifndef __DRIVER_UART_H
#define __DRIVER_UART_H

#include "stm32f1xx_hal.h"

// 接收缓冲区大小
#define UART_RX_BUF_SIZE 64

void Driver_UART_Init(void);
void Driver_UART_Print(const char* str);

#endif

4.5.电机控制函数

motor_ctrl.c文件

#include "motor_ctrl.h"
#include "driver_pwm.h"
#include "driver_system.h"
#include "foc_math.h"
#include "driver_uart.h"

Motor_t myMotor;

void Motor_Init(void) {
    myMotor.v_bus = 12.0f;
    myMotor.v_limit = 3.0f;      // 开环测试建议设小点,防发烫
    myMotor.pole_pairs = 7;      // 极对数
    myMotor.target_speed = 0.0f; 
    myMotor.shaft_angle = 0.0f;
    myMotor.last_us = Driver_System_GetMicros();
    
    Driver_PWM_Start();
		Driver_UART_Init(); // 启动串口监听
}

void Motor_Loop_OpenLoop(void) {
    // 1. 计算时间差 dt
    uint32_t now_us = Driver_System_GetMicros();
    float dt = (now_us - myMotor.last_us) * 1e-6f;
    if(dt <= 0 || dt > 0.1f) dt = 1e-3f;
    myMotor.last_us = now_us;

    // 2. 角度累加 (机械角)
    myMotor.shaft_angle = FOC_NormalizeAngle(myMotor.shaft_angle + myMotor.target_speed * dt);

    // 3. 计算电角度
    float electric_angle = FOC_NormalizeAngle(myMotor.shaft_angle * myMotor.pole_pairs);

    // 4. 计算三相电压
    float Ua, Ub, Uc;
    FOC_InverseParkClarke(myMotor.v_limit, 0, electric_angle, myMotor.v_bus, &Ua, &Ub, &Uc);

    // 5. 输出 PWM
    Driver_PWM_SetDuty(Ua/myMotor.v_bus, Ub/myMotor.v_bus, Uc/myMotor.v_bus);
}

对应的motor_ctrl.h文件

#ifndef __MOTOR_CTRL_H
#define __MOTOR_CTRL_H
#include "stm32f1xx_hal.h"

typedef struct {
    float v_bus;            // 电源电压
    float v_limit;          // 输出电压限制(控制力度)
    int pole_pairs;         // 极对数
    float target_speed;     // 目标速度 (rad/s)
    float shaft_angle;      // 机械角度
    uint32_t last_us;       // 上次运行微秒数
} Motor_t;

extern Motor_t myMotor;

void Motor_Init(void);
void Motor_Loop_OpenLoop(void);

#endif

4.6.main.c文件

在下面3处插入对应代码

/* USER CODE BEGIN Includes */
#include "driver_system.h"
#include "motor_ctrl.h"
/* USER CODE END Includes */


/* USER CODE BEGIN 2 */
 Driver_System_Init(); // 初始化计时器
  Motor_Init();         // 初始化电机逻辑
  
  // 使能引脚 PA8
  HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_SET);
  
  myMotor.target_speed = 10.0f; // 设置开环速度 10 rad/s
  /* USER CODE END 2 */



while (1)
  {
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
		 Motor_Loop_OpenLoop(); // 循环开环控制
  }
  /* USER CODE END 3 */
}

5.实验结果

移植后电机运行视频

原代码电机运行视频

可以看到对比效果还是非常明显的

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐