【从零开始实现stm32无刷电机FOC】【实践】【7.2/7 完整代码编写】

时间:2024-10-04 07:18:13

目录

  • stm32cubemx配置
    • 芯片选择
    • 工程配置
    • stm32基础配置
    • SPI的配置
    • 定时器的配置
    • ADC的配置
    • 中断优先级的配置
    • 生成工程
  • 工程代码编写
    • FOC代码结构搭建
    • 电机编码器角度读取
    • PWM产生
    • FOC开环代码编写
    • 确定电机正负旋转方向
    • 电机旋转速度计算
    • 多圈逻辑角度
    • 电流采样
    • 极对数
    • 转子角度确定
  • 闭环控制
    • 控制函数接口定义
    • CMSIS-DSP提供的PID控制器
    • 位置控制
    • 速度控制
    • 力矩(电流)控制
    • 位置-速度-力矩控制
    • PID系数调节
  • 注意点

本节使用stm32cubemx配置外设,生成keil工程,代码适配本文的硬件电路板(可参考上节的硬件设计内容)。

  • 点击查看本文开源的完整FOC工程https://gitee.com/best_pureer/stm32_foc

stm32cubemx配置

芯片选择

选择stm32f103c8t6后,点击Start Project。
在这里插入图片描述

工程配置

切换到Projeck Manager页面,设定好项目名称、项目路径,选择生成keil工程。
在这里插入图片描述

stm32基础配置

  • 配置时钟

这里选择晶振:
在这里插入图片描述
切换到Clock Configuration页面,首先选择PLLCLK,再HCLK输入72,再点击确认会自动生成72MHz的主频。
在这里插入图片描述

  • SWD接口配置

用于烧录和调试程序
在这里插入图片描述

  • 串口配置

用于打印一些调试信息,选择串口2,因为串口1会被定时器1的pwm通道占用
在这里插入图片描述

  • LED灯配置

在这里插入图片描述

  • CMSIS-DSP数学库配置

第一步:
在这里插入图片描述
第二步:
在这里插入图片描述

SPI的配置

用于读取MT6701,这里的SPI波特率不要设置太高,因为stm32f103c8t6的计算能力有限,角度读的太快算不过来。
从MT6701的数据手册中可以看到,应该在CLK的每2个脉冲时读取DO的数据,而且CLK空闲电平是低电平,因此设置CPOL为Low,CPHA为2个边沿。
由于D13先发送,因此是先高位传输,即MSB大端传输方式。
在这里插入图片描述
这里将SPI的模式设置为全双工Full-Duplex Master,而不设置为只读取模式Receive Only Master。虽然单片机的SPI只需要读取MT6701的数据,无需SPI写入数据,但是实测设置为只读取模式时,貌似是每读完1个字节进一次读取完成中断,而我们想要的是每读完3个字节进一次读取完成中断,全双工模式能做到这个效果。
注意不要使用硬件片选NSS(对应芯片CSN读取使能引脚),因为硬件片选NSS是单片机disable对应SPI外设后才会拉低。在这里插入图片描述
这里选择手动操作SPI的片选引脚,设置片选信号所在引脚为GPIO输出模式,操作逻辑是:每次在SPI读取MT6701前拉低该引脚,在刚进入读取完成中断时拉高该引脚。
在这里插入图片描述
后续使用DMA读取SPI数据,全双工模式下,接收和发送DMA均需要打开:
在这里插入图片描述

定时器的配置

  • 用于电机速度计算的定时器

首先勾选时钟来自内部时钟源,再创建一个宏定义,用于速度计算间隔。motor_speed_calc_freq是FOC代码里手动设置的宏,speed_calc_freq这个宏给cubemx用,这样就将cubemx的参数与FOC代码里的宏绑定到了一起,好处就是FOC代码与cubemx独立开,FOC代码用于其他厂商单片机不用改太多的代码。
在这里插入图片描述
首先设置分频为72-1,72MHz的时钟分频下来后每1us定时器计数加1。定时器计数容量为N时,定时器溢出中断频率是1秒/N微妙=1000000/N赫兹,我们想要定时器溢出中断频率等于motor_speed_calc_freq,那么需要1000000/N=motor_speed_calc_freq,因此这里的计数容量设置为N=1000000/motor_speed_calc_freq。
由于cubemx是不认识motor_speed_calc_freq的,因此这里选择No check不检查参数类型。
在这里插入图片描述
开启定时器中断函数,用于计算电机速度:
在这里插入图片描述

  • 用于产生电机PWM的高级定时器

高级定时器的使用非常关键,非常重要,对于外设配置项的说明请查看前文定时器章节
stm32f103c8t6只有一个高级定时器:TIM1。
首先选择时钟源,然后开启3个PWM正通道,由于本文使用的集成驱动芯片DRV8313自带互补PWM功能,因此不开启PWM负通道。
这里注意不勾选Activate-Break-Input,这个只是帮你配置刹车引脚的GPIO参数,刹车功能依然是有效的。不勾选的原因是经过我的各种尝试,触发刹车后,依然进不去刹车中断函数TIM1 break interrupt,所以选择自己配置刹车引脚GPIO为外部中断。
在这里插入图片描述
手动设置刹车引脚为外部中断,这里不设置也没事,本文代码是因为想要触发刹车后串口打印一下告知刹车被触发了:
在这里插入图片描述
开启外部中断函数:
在这里插入图片描述
回到高级定时器的配置,再次提醒对于外设配置项的说明请查看前文定时器章节

  • PWM的频率是非常高的,我设置的20KHz,因此不进行预分频。
  • 计数方式选择中心对齐方式3。
  • 计数容量定义了一个cubemx宏,与FOC代码里的宏进行绑定,这样做可以使得FOC代码独立在各种平台上使用,勾选No check不检查数值类型。数值计算过程:定时器计数不分频情况下,每 1 72 ∗ 1000000 \frac{1}{72*1000000} 7210000001秒计数加1,计数容量为N时,计数溢出时间为 N 72000000 \frac{N}{72000000} 72000000N,则计数溢出频率为 72000000 N \frac{72000000}{N} N72000000,而我们设定的计数溢出频率=motor_pwm_freq,即 72000000 N = m o t o r _ p w m _ f r e q \frac{72000000}{N}=motor\_pwm\_freq N72000000=motor_pwm_freq,因此计数容量 N = 72000000 m o t o r _ p w m _ f r e q N=\frac{72000000}{motor\_pwm\_freq} N=motor_pwm_freq72000000
  • 重复计数器设置为4,即每5个定时器溢出产生1个更新事件,更新事件会触发ADC采样,本文FOC代码是放在ADC采样完成中断里计算的,这样FOC代码的计算频率等于20KHz/5=4KHz,这个频率不要太快,因为stm32f103c8t6的算力有限,计算一次FOC代码大约需要120us。
  • 输出触发事件来源选择更新事件,自动触发ADC采样。
  • BRK State刹车功能可以开启,也可以关闭,建议验证FOC算法阶段先关闭,本文配套硬件有刹车引脚LED灯提示。对应DRV8313的Fault引脚信号,刹车电平选择Low低电平。
  • 3个PWM Generation Channel的配置项都相同,默认值刚好是我们想要配置的值。关键项是Mode选择PWM mode 1,因为DRV8313内部是NMOS,所以CH Polarity有效电平是高电平。
    在这里插入图片描述

ADC的配置

ADC的使用非常关键,非常重要,对于外设配置项的说明请查看前文ADC章节
使用双ADC同步采样两条电机相线上采样电阻放大后的电压差,首先给ADC1和ADC2分配通道,IN0对应PA0引脚,IN0对应PA1引脚,ADC1是主ADC。
在这里插入图片描述
两个ADC都分配好通道后,主ADC的模式下拉框里才会出现同步采样的选项,由于只有注入式采样才能绑定到高级定时器的触发事件,因此选择注入式同步采样。
在这里插入图片描述
接下来配置ADC选项:

  • 同步采样模式下,一定要开启Continuous连续转换模式。
  • 关闭常规采样,打开注入采样。
  • 每个ADC只采集一个通道,因此Number Of Conversions设置为1,ADC1的Rank序列中的Channel通道选择通道0,ADC2的Rank序列中的Channel通道选择通道1,采样时间可以设置大一点,我们代码中限制PWM占空比最高为90%即可留出充足的时间给ADC采样。
  • 外部触发源选择定时器1的触发事件。
    在这里插入图片描述
    在这里插入图片描述
    开启ADC采样完成中断,FOC代码会放在这个中断函数里进行:
    在这里插入图片描述

中断优先级的配置

读取磁编码器SPI的DMA中断优先级设置到最优先,电机角度获取一定要及时准确。
接下来速度计算的定时器中断和运行FOC代码的ADC中断其次,速度计算和FOC代码必须尽量优先。
连到刹车引脚的外部中断优先级放到最低即可,刹车功能在刹车引脚有效时被硬件触发,和中断无关,这个外部中断只是为了串口打印几句消息告知触发刹车了。
在这里插入图片描述

生成工程

至此,stm32cubemx相关配置已经完毕,接下来生成keil工程,这里勾选了Generate peripheral initialization as a pair of '.c/.h' files per peripheral,方便功能模块分离。
在这里插入图片描述

此时keil工程编译是无法通过的,编译器可能没选到verison 6,而且两个宏motor_speed_calc_freqmotor_pwm_freq我们还没写到代码里。
首先选择编译器为version 6,如果没有出现version 6,你需要下载最新keil版本。
在这里插入图片描述
接下来我会使用vscode进行代码编辑,keil用来编译工程和烧录调试代码。
接下来定义motor_speed_calc_freqmotor_pwm_freq

  • 首先在Drivers文件夹下创建motor文件夹,这个文件夹专门用来放置电机FOC驱动代码。
  • 然后motor文件夹下创建conf.h,这个文件专门用来放置FOC代码相关的工程配置,将Drivers文件夹添加到头文件路径中。
  • 最后在main.h文件的用户代码区把conf.h文件include进来即可将工程编译通过。
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

Src/usart.c中定义fputc,方便后续使用printf函数:

//usart.c
/* USER CODE BEGIN 1 */
#include <stdio.h>
int fputc(int c, FILE *stream)
{
  uint8_t ch[] = {(uint8_t)c};
  HAL_UART_Transmit(&huart2, ch, 1, HAL_MAX_DELAY);
  return c;
}
/* USER CODE END 1 */

在Inc文件夹下创建一个全局宏定义文件global_def.h,方便调用:

//global_def.h
#pragma once
#ifndef PI
#define PI 3.14159265358979
#endif
#define deg2rad(a) (PI * (a) / 180)
#define rad2deg(a) (180 * (a) / PI)
#define max(a, b) ((a) > (b) ? (a) : (b))
#define min(a, b) ((a) < (b) ? (a) : (b))

由于本文使用的daplink是低成本版本,为了保证传输稳定性,下载程序的频率选择为1MHz。

工程代码编写

在stm32hal库中,应用逻辑的代码实现变得非常方便,基本规律就是:外设读取+读取完成中断。

FOC代码结构搭建

根据本文硬件情况补充FOC代码里的conf.h

#pragma once

// 电机物理参数:
#define POLE_PAIRS 7 // 极对数

// 电路参数:
#define R_SHUNT 0.02           // 电流采样电阻,欧姆
#define OP_GAIN 50             // 运放放大倍数
#define MAX_CURRENT 2          // 最大q轴电流,安培A
#define ADC_REFERENCE_VOLT 3.3 // 电流采样adc参考电压,伏
#define ADC_BITS 12            // ADC精度,bit

// 单片机配置参数:
#define motor_pwm_freq 20000      // 驱动桥pwm频率,Hz
#define motor_speed_calc_freq 930 // 电机速度计算频率,Hz

// 软件参数:
#define position_cycle 6 * 3.14159265358979 // 电机多圈周期,等于正半周期+负半周期

Drivers/motor文件夹下创建motor_runtime_param.cmotor_runtime_param.h,用于放置电机运行过程中的各种参数。
关于转子角度和电机角度的区别,前文位置控制说明了一部分,后面内容有代码上的讲解。

//motor_runtime_param.c
#include "motor_runtime_param.h"

float motor_i_u;
float motor_i_v;
float motor_i_d;
float motor_i_q;
float motor_speed;
float motor_logic_angle;
float encoder_angle;
float rotor_zero_angle;
//motor_runtime_param.h
#pragma once
#include "conf.h"
#define rotor_phy_angle (motor_logic_angle - rotor_zero_angle) // 转子物理角度
#define rotor_logic_angle rotor_phy_angle *POLE_PAIRS          // 转子多圈角度
extern float motor_i_u;
extern float motor_i_v;
extern float motor_i_d;
extern float motor_i_q;
extern float motor_speed;
extern float motor_logic_angle; // 电机多圈角度
extern float encoder_angle;     // 编码器直接读出的角度
extern float rotor_zero_angle;  // 转子d轴与线圈d轴重合时的编码器角度

Drivers/motor文件夹下创建foc.cfoc.h,准备用于放置FOC代码。
添加好头文件路径:
在这里插入图片描述
添加好C文件:
在这里插入图片描述

电机编码器角度读取

从MT6701数据手册可知,SPI数据一共有3个字节:
在这里插入图片描述采用DMA方式SPI读取3个字节,再取前14位组合成角度值,这里我不使用4bit的Status信息,也不进行crc校验,这不影响角度读取,你可自行运用这些数据提高程序健壮性。
Src/spi.c中的USER CODE区域实现读取成功的回调函数,还要实现读取失败的回调函数,每次进入读取完毕函数后立即关闭SPI片选引脚,并且在即将退出回调函数的时候打开SPI片选引脚并启动下一次读取。

//spi.c
/* USER CODE BEGIN 1 */
#include <stdio.h>
#include "motor/motor_runtime_param.h"
#include "motor/foc.h"	
#include "arm_math.h"
uint8_t mt6701_rx_data[3];
void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi)
{
  if (hspi->Instance == SPI1)
  {
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET);
    int angle_raw = (mt6701_rx_data[1] >> 2) | (mt6701_rx_data[0] << 6);
    encoder_angle = 2 * 3.1415926 * angle_raw / ((1 << 14) - 1);
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET);

    HAL_SPI_TransmitReceive_DMA(&hspi1, mt6701_rx_data, mt6701_rx_data, 3);
    return;
  }
}

void HAL_SPI_ErrorCallback(SPI_HandleTypeDef *hspi)
{
  printf("HAL_SPI_ErrorCallback:%d\n", hspi->ErrorCode);
  if (hspi->Instance == SPI1)
  {
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET);
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET);
    HAL_SPI_TransmitReceive_DMA(&hspi1, mt6701_rx_data, mt6701_rx_data, 3);
  }
  return;
}
/* USER CODE END 1 */

在main函数的while(1)之前调用一次DMA方式的SPI读取,即可自动连续获取角度值,然后在while(1)里面打印一下角度验证一下代码,手动旋转一下电机,结果会在 0 0 0 2 π 2\pi 2π弧度值之间变化:

//main.c
//......
/* USER CODE BEGIN Includes */
#include <stdio.h>
#include "motor/motor_runtime_param.h"
#include "motor/foc.h"
#include "global_def.h"
/* USER CODE END Includes */
//......
/* USER CODE BEGIN 2 */
extern uint8_t mt6701_rx_data[3];
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET);
HAL_SPI_TransmitReceive_DMA(&hspi1, mt6701_rx_data, mt6701_rx_data, 3);
HAL_Delay(100);//延时一会,让角度变量被赋值,不然角度会是0
/* USER CODE END 2 */
//......
while(1)
{
//......
	/* USER CODE BEGIN 3 */
    printf("%f\n", encoder_angle);
    HAL_Delay(100);
//......

在此再提醒:读取角度的SPI中断优先级一定要最高,否则片选信号可能没有及时关闭,导致角度读取无法接续。

PWM产生

开启PWM输出:

//main.c
//......
  HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1);
  HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_2);
  HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_3);
  /* USER CODE END 2 */
//......

自己定义一个函数set_pwm_duty,方便设置PWM占空比。htim1.Instance->ARR是定时器计数容量。
有两个细节:

  • 限制占空比最高为90%,留出一个电流稳定的时段,有利于减少电机抖动以及给后续ADC采样提供稳定电流时段。原因是:占空比接近100%时,会出现mos关闭后瞬间开启的情况,而且此时是电流通过,导致电流非常不稳定;不必要限制最低占空比,因为q轴为0时,由于SVPWM中的000矢量和111矢量两个零力矩矢量平分了一个PWM周期,因此FOC控制下的PWM最低占空比就是50%。
    在这里插入图片描述

  • 操作3个通道时关闭了中断,防止通道不同步。

//main.c
/* USER CODE BEGIN 0 */
void set_pwm_duty(float d_u, float d_v, float d_w)
{
  d_u = min(d_u, 0.9);
  d_v = min(d_v, 0.9);
  d_w = min(d_w, 0.9);
  __disable_irq();
  __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, d_u * htim1.Instance->ARR);
  __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_2, d_v * htim1.Instance->ARR);
  __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_3, d_w * htim1.Instance->ARR);
  __enable_irq();
}
/* USER CODE END 0 */

FOC开环代码编写

FOC开环就是实现一下SVPWM代码,输入目标d轴q轴强度和旋转的目标转子位置,输出电机相线pwm占空比,前文已经实现了SVPWM纯C语言代码,这里将其放到单片机中,也就是要将纯C的数学函数换做CMSIS-DSP里的函数:

//foc.c
#include "foc.h"
#include "arm_math.h"
#include "motor_runtime_param.h"
#include <stdbool.h>
#define rad60 deg2rad(60)
#define SQRT3 1.73205080756887729353
#define deg2rad(a) (PI * (a) / 180)
#define rad2deg(a) (180 * (a) / PI)
#define max(a, b) ((a) > (b) ? (a) : (b))
#define min(a, b) ((a) < (b) ? (a) : (b))

static void svpwm(float phi, float d, float q, float *d_u, float *d_v, float *d_w)
{
    d = min(d, 1);
    d = max(d, -1);
    q = min(q, 1);
    q = max(q, -1);
    const int v[6][3] = {{1, 0, 0}, {1, 1, 0}, {0, 1, 0}, {0, 1,