01
前言
没玩过四驱小车的一定不知道它,玩过四驱小车的也不一定知道它,它就是我们这一期的主角——麦克纳姆小车,在开始学习之前,先来看看几张有意思的动图。
它不仅能左扭扭右扭扭和摇摇头:
还能像螃蟹一样行走,然后原地转圈圈。
这么神奇的功能是如何实现的呢?下面正片开始,让我们一起学习驱动麦克纳姆小车。
本期教程开发环境如下:
-
软件:STM32CubeIDE(V1.13.0)
-
硬件:Nano机器人控制板(芯片:STM32F103RCT6)
Nano机器人控制板拥有4个带编码器的电机接口,4个舵机接口,串口通信接口、SWD下载调试接口、航模遥控接口、USB5V输出接口以及方便与树莓派直接连接的40PIN接口等,板载资源丰富,调试方便!
本教程的主要内容如下(基于HAL库):
-
驱动麦克纳姆小车实现基础运动方式;
-
测量霍尔编码器电机的转速及PID调速;
-
使用遥控器实现对电机的控制;
-
实现串口发送数据对电机的控制。
麦克纳姆轮:
麦克纳姆轮的运动原理是驱动麦克纳姆小车的关键,麦克纳姆轮是轮毂和围绕轮毂的辊子组成的,辊子是一种没有动力的从动小滚轮,麦克纳姆轮辊子轴线和轮毂轴线夹角是45度,并且有互为镜像关系的A、B轮两种,或者会被称为左旋轮和右旋轮。一般会在轮毂上面有标识A和B或L和R。
麦克纳姆轮的原理:
约定轮子前进时的方向为电机正转,轮子后退的方向为电机反转。
电机正转或反转时,辊子在移动方向上由于滚动无法提供前进的力,而在辊子轴线方向上辊子无法滚动并且与地面摩擦产生辊子轴向上的摩擦力,即斜向右前或左后方向,从而A轮的速度方向是斜向右前或左后;B轮同理。
-
A轮(L轮):
-
B轮(R轮)
本教程的麦克纳姆轮安装组合为ABBA,麦克纳姆轮的安装方式不止一种,注意根据麦克纳姆轮的运动情况选择正确的安装组合。安装完成后的小车就是下面的样子。
由于速度是可以正交分解的,我们将A轮分解成轴向向右和垂直轴向向前的速度分量,或者轴向向左和垂直轴向向后的速度分量。同理分解B轮的速度。根据相同速度在相反方向可以抵消的原理,我们可以得到ABBA组合的麦克纳姆小车的几种运动方式和速度分量的情况如下:
电机与板子的接线:
接下来,根据NANO驱动板的原理图将接线接好,这块板子自带2个TB6612FNG芯片,1个TB6612FNG芯片可以实现两路电机的控制,驱动电机完成正反转以及PWM调速的功能。
电机驱动芯片图中绿框为电机的输出引脚,红框为连接STM32的驱动引脚,四个电机口A、B、C、D在NANO驱动板的位置分别是①、②、③、④,本教程的四个电机与NANO驱动板的连接为:左前轮 —— ①;右前轮 —— ②;左后轮 —— ③;右后轮 —— ④。另外,注意驱动四个电机需要通过图中的12V电源接口进行供电。至此,硬件部分连接完毕,下面开始进入软件部分。
02
第一个功能——驱动麦克纳姆小车实现基础运动方式
从引脚接线图中可以看到,四个电机通过AIN1、AIN2、BIN2、BIN2、CIN1、CIN2、DIN1、DIN2给出正反转信号,在STM32CubeIDE中只需将这八个对应的引脚设置为GPIO_Output。前面提到的2个TB6612FNG芯片给出的四路PWM信号与STM32F103RCT6的接口为PC6、PC7、PC8、PC9,对其进行如下配置。
-
PWMA —— PC6 —— TIM8_CH1
-
PWMB —— PC7 —— TIM8_CH2
-
PWMC —— PC8 —— TIM8_CH3
-
PWMD —— PC9 —— TIM8_CH4
现在我们一起从新建工程到完成以上的配置。
(一)通用基础配置
首先,打开STM32CubeIDE,按照下面的操作新建一个工程,NANO驱动板的芯片为STM32F103RCT6,注意根据板载芯片选择对应的型号:
新建工程完成后就会自动进入STM32CubeMx界面。
接着进行时钟源的选择(高速外部时钟)、下载和调试接口的配置、时钟的配置(72MHz)。
(二)电机的配置
以上通用基础设置完成后,终于可以进入电机配置啦!
根据上面提到的,电机配置分为两个步骤,步骤一是配置定时器8的四个PWM产生通道。如下图所示,打开TIM8通道1、2、3、4的PWM输出模式,本教程中使用的是减速电机,设置PWM输出频率为18KHz,根据下面的计算公式,此处将预分频数(psc)设置为3,自动重装载值(arr)设置为999,并将四个通道都设置为PWM1模式。
$$frequency = sys clk/ (psc+1)*(arr+1)$$
步骤二是配置电机的八个正反转接口为端口输出功能,鼠标左键点击芯片上的引脚即可选择端口的模式,此处八个都配置为GPIO_Output。
到此第一个功能的配置就完成了,再完成以下的设置,按住ctrl+s即可生成对应的代码。
(三)编写电机驱动程序
接下来在STM32CubeIDE中编写程序,在 USER CODE BEGIN 0下 编写电机控制的相关函数
/**
* @brief 控制电机进行正转、反转、停止
* @param None
* @retval None
*/
// 各轮子分别正转
void Left_Front_Go() // 左前轮正转——AIN
{
HAL_GPIO_WritePin(Front_DirPort,AIN1,GPIO_PIN_SET);
HAL_GPIO_WritePin(Front_DirPort,AIN2,GPIO_PIN_RESET);
}
void Right_Front_Go() // 右前轮正转——BIN
{
HAL_GPIO_WritePin(Front_DirPort,BIN1,GPIO_PIN_SET);
HAL_GPIO_WritePin(Front_DirPort,BIN2,GPIO_PIN_RESET);
}
void Left_Behind_Go() // 左后轮正转——CIN
{
HAL_GPIO_WritePin(Behind_DirPort,CIN1,GPIO_PIN_SET);
HAL_GPIO_WritePin(Behind_DirPort,CIN2,GPIO_PIN_RESET);
}
void Right_Behind_Go() // 右后轮正转——DIN
{
HAL_GPIO_WritePin(Behind_DirPort,DIN1,GPIO_PIN_SET);
HAL_GPIO_WritePin(Behind_DirPort,DIN2,GPIO_PIN_RESET);
}
//各轮子分别反转
void Left_Front_Back() // 左前轮反转——AIN
{
HAL_GPIO_WritePin(Front_DirPort,AIN1,GPIO_PIN_RESET);
HAL_GPIO_WritePin(Front_DirPort,AIN2,GPIO_PIN_SET);
}
void Right_Front_Back() // 右前轮反转——BIN
{
HAL_GPIO_WritePin(Front_DirPort,BIN1,GPIO_PIN_RESET);
HAL_GPIO_WritePin(Front_DirPort,BIN2,GPIO_PIN_SET);
}
void Left_Behind_Back() // 左后轮反转——CIN
{
HAL_GPIO_WritePin(Behind_DirPort,CIN1,GPIO_PIN_RESET);
HAL_GPIO_WritePin(Behind_DirPort,CIN2,GPIO_PIN_SET);
}
void Right_Behind_Back() // 右后轮反转——DIN
{
HAL_GPIO_WritePin(Behind_DirPort,DIN1,GPIO_PIN_RESET);
HAL_GPIO_WritePin(Behind_DirPort,DIN2,GPIO_PIN_SET);
}
// 各轮子分别停止
void Left_Front_Stop() // 左前轮停止——AIN
{
HAL_GPIO_WritePin(Front_DirPort,AIN1,GPIO_PIN_RESET);
HAL_GPIO_WritePin(Front_DirPort,AIN2,GPIO_PIN_RESET);
}
void Right_Front_Stop() // 右前轮停止——BIN
{
HAL_GPIO_WritePin(Front_DirPort,BIN1,GPIO_PIN_RESET);
HAL_GPIO_WritePin(Front_DirPort,BIN2,GPIO_PIN_RESET);
}
void Left_Behind_Stop() // 左后轮停止——CIN
{
HAL_GPIO_WritePin(Behind_DirPort,CIN1,GPIO_PIN_RESET);
HAL_GPIO_WritePin(Behind_DirPort,CIN2,GPIO_PIN_RESET);
}
void Right_Behind_Stop() // 右后轮停止——DIN
{
HAL_GPIO_WritePin(Behind_DirPort,DIN1,GPIO_PIN_RESET);
HAL_GPIO_WritePin(Behind_DirPort,DIN2,GPIO_PIN_RESET);
}
/**
* @brief 控制麦克纳姆小车的各种运动方式
* @param 运动方向,四个电机的PWM值
* @retval None
*/
void MotorControl(char direction,int left_front_MotorPWM, int right_front_MotorPWM,int left_behind_MotorPWM, int right_behind_MotorPWM)
{
switch(direction)
{
case 0: // 停止
Left_Front_Stop();
Right_Front_Stop();
Left_Behind_Stop();
Right_Behind_Stop();
left_front_MotorPWM = 0;
right_front_MotorPWM = 0;
left_behind_MotorPWM = 0;
right_behind_MotorPWM = 0;break;
case 1: // 前进
Left_Front_Go();
Right_Front_Go();
Left_Behind_Go();
Right_Behind_Go();break;
case 2: // 后退
Left_Front_Back();
Right_Front_Back();
Left_Behind_Back();
Right_Behind_Back();break;
case 3: // 左移
Left_Front_Back();
Right_Front_Go();
Left_Behind_Go();
Right_Behind_Back();break;
case 4: // 右移
Left_Front_Go();
Right_Front_Back();
Left_Behind_Back();
Right_Behind_Go();break;
case 5: // 左旋
Left_Front_Back();
Right_Front_Go();
Left_Behind_Back();
Right_Behind_Go();break;
case 6: // 右旋
Left_Front_Go();
Right_Front_Back();
Left_Behind_Go();
Right_Behind_Back();break;
case 7: // 左前方移动
Left_Front_Stop();
Right_Front_Go();
Left_Behind_Go();
Right_Behind_Stop();break;
case 8: // 右前方移动
Left_Front_Go();
Right_Front_Stop();
Left_Behind_Stop();
Right_Behind_Go();break;
case 9: // 左后方移动
Left_Front_Back();
Right_Front_Stop();
Left_Behind_Stop();
Right_Behind_Back();break;
case 10: // 右后方移动
Left_Front_Stop();
Right_Front_Back();
Left_Behind_Back();
Right_Behind_Stop();break;
case 11: // 绕前轴中点左旋
Left_Front_Stop();
Right_Front_Stop();
Left_Behind_Back();
Right_Behind_Go();break;
case 12: // 绕前轴中点右旋
Left_Front_Stop();
Right_Front_Stop();
Left_Behind_Go();
Right_Behind_Back();break;
case 13: // 绕后轴中点左旋
Left_Front_Back();
Right_Front_Go();
Left_Behind_Stop();
Right_Behind_Stop();break;
case 14: // 绕后轴中点右旋
Left_Front_Go();
Right_Front_Back();
Left_Behind_Stop();
Right_Behind_Stop();break;
default:break;
}
__HAL_TIM_SET_COMPARE(&htim8,TIM_CHANNEL_1,left_front_MotorPWM);
__HAL_TIM_SET_COMPARE(&htim8,TIM_CHANNEL_2,right_front_MotorPWM);
__HAL_TIM_SET_COMPARE(&htim8,TIM_CHANNEL_3,left_behind_MotorPWM);
__HAL_TIM_SET_COMPARE(&htim8,TIM_CHANNEL_4,right_behind_MotorPWM);
}
main函数部分如下:
int main()
{
while(1)
{
MotorControl(7,500,500,500,500); // 左前方移动
HAL_Delay(2000);
MotorControl(0,0,0,0,0);
HAL_Delay(2000);
MotorControl(8,500,500,500,500); // 右前方移动
HAL_Delay(2000);
MotorControl(0,0,0,0,0);
HAL_Delay(2000);
MotorControl(9,500,500,500,500); // 左后方移动
HAL_Delay(2000);
MotorControl(0,0,0,0,0);
HAL_Delay(2000);
MotorControl(10,500,500,500,500); // 右后方移动
HAL_Delay(2000);
MotorControl(0,0,0,0,0);
HAL_Delay(2000);
}
}
麦克纳姆小车的运动方式十分丰富,本教程在电机控制的代码中列出了14种运动方式,有兴趣的小伙伴可以自己设计运动轨迹。
(四)下载程序到板子上
本教程使用ST-LINK下载器,按照接线图将ST-LINK 连接到驱动板上的SWD接口,并用STM32CubeProgrammer烧写程序,点击"Connect"连接下载器,打开程序生成的.elf文件,点击'"Download"即可下载工程文件到板子上。
注意:若使用其他下载方式,需要生成".hex"的,只需在STM32CubeIDE中做如下设置:
打开对应工程文件的配置,按照下面的配置勾选生成内部".hex"文件
(五)效果演示
将程序烧写完成后,麦克纳姆小车的一些运动效果如下:
03
第二个功能——测量霍尔编码器电机的转速及PID调速
完成了麦克纳姆小车的基础功能之后,那么有没有方法能够实时获取电机的转速,以便我们对其进行调节呢?答案当然是有的!往下看:
(一)电机转速测量原理
首先要明白测速的原理是什么,本例程中使用的电机由三部分组成:减速器,电机以及霍尔编码器。编码器是一种将角速度或角位转换成数字脉冲的旋转式传感器,使用者可以通过编码器测量电机的速度信息。
霍尔编码器工作原理:霍尔编码器通过电磁转换,将机械的位移转化为脉冲信号,并且输出A、B两相的方波信号,A、B两相脉冲信号相位相差90°,通过检测规定时间内的脉冲数,以及A、B两相脉冲信号的相对位置,便能得到编码器的值与其运动方向。
编码器测速原理:根据本文使用的减速电机参数,电机为11线的霍尔编码器减速电机,减速比为1:30,即转动一圈可以输出11 * 30=330个脉冲,通过STM32四倍频(STM32的编码器模式可以设置为对TI1、TI2两路信号同时进行脉冲计数,即四倍频)后,电机转动一圈得到的脉冲数为330 * 4=1320,通过定时器中断设置速度采样周期为50ms,即每50ms读取一次定时器中的计数器值。要得到最终的速度,还需要考虑轮胎的大小,本教程所用的麦克纳姆轮的轮胎直径尺寸为65mm,所以周长C为3.14*6.5=20.41cm。
参考以上参数,然后便可以计算轮子的转动速度。根据测速原理:设采样周期内传入的脉冲数为N,电机转动一圈得到的脉冲数为1320,而轮子转动一圈的运动距离为20.41cm。那么得到脉冲数N时运动的距离S=(0.2041 * N/1320),再除以规定的采样周期时间便可以得到运动的速度,完整的计算公式如下:
车轮周长 规定时间内得到的脉冲数 0.2041 N
车轮转速(cm/s)= ----------------- * --------------------- = -------- * ----------- = 0.3092424 * N(cm/s)
电机转动一圈的脉冲数 定时器规定时间 1320 50*0.001
注意:该速度为每个麦克纳姆轮的转速,并非小车实际的运行速度。
(二)编码器测速在STM32CubeIDE中的配置
记得测速之前要保持第一个功能的电机配置,接下来看看四路编码器A、B、C、D相接口对应在STM32上的IO口。
根据图中的IO口,分别找到定时器2、3、4、5进行模式配置,以下以定时器2为例,其他定时器只需进行相同的配置即可。
配置完定时器2和定时器3后,需要再使用一个定时器,利用其产生50ms中断来读取当前的小车速度值,本次教程中采用定时器6产生中断。周期为50ms,计算方法为 $T=(arr+1)*(psc+1)/Tclk$,此处将预分频数(psc)设置为499,自动重装载值(arr)设置为7199,即可产生周期为50ms的中断,同时打开定时器6的中断。
另外,配置串口1作为打印输出,将其设置为Asynchronous异步串口,不打开硬件流,设置波特率为115200,8位数据字长,无校验,1位停止位,收发模式,16倍过采样。
配置完成后不要忘记按住ctrl+s键生成代码。
(三)编码器测速代码
完成配置后,在 USER CODE BEGIN 0中编写编码器脉冲和获取当前轮子转速的函数。
/**
* @brief 读取定时器2、3、4、5的计数值(编码器脉冲值)
* @retval None
*/
short encoderPulse[4]={0};
void GetEncoderPulse()
{
encoderPulse[0] = -((short)__HAL_TIM_GET_COUNTER(&htim2));
encoderPulse[1] = -((short)__HAL_TIM_GET_COUNTER(&htim3));
encoderPulse[2] = ((short)__HAL_TIM_GET_COUNTER(&htim4));
encoderPulse[3] = ((short)__HAL_TIM_GET_COUNTER(&htim5));
__HAL_TIM_GET_COUNTER(&htim2) = 0; //计数值重新清零
__HAL_TIM_GET_COUNTER(&htim3) = 0;
__HAL_TIM_GET_COUNTER(&htim4) = 0; //计数值重新清零
__HAL_TIM_GET_COUNTER(&htim5) = 0;
}
/**
* @brief 根据得到的编码器脉冲值计算速度 单位:m/s
* @retval 编码器脉冲值
*/
float CalActualSpeed(int pulse)
{
return (float)(0.003092424 * pulse);
}
在 USER CODE BEGIN 4中编写定时器6的中断回调函数。
/**
*定时器6中断回调函数,每50ms调用一次
*/
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
float c_left_front_Speed,c_right_front_Speed,c_left_behind_Speed,c_right_behind_Speed;
if(htim==(&htim6))
{
GetEncoderPulse();
c_left_front_Speed = CalActualSpeed(encoderPulse[0]); //获得当前的速度值
c_right_front_Speed = CalActualSpeed(encoderPulse[1]);
c_left_behind_Speed = CalActualSpeed(encoderPulse[2]);
c_right_behind_Speed = CalActualSpeed(encoderPulse[3]);
printf("{c_left_front_Speed is: %.2f m/s}\r\n",c_left_front_Speed);
printf("{c_right_front_Speed is: %.2f m/s}\r\n",c_right_front_Speed);
printf("{c_left_behind_Speed is: %.2f m/s}\r\n",c_left_behind_Speed);
printf("{c_right_behind_Speed is: %.2f m/s}\r\n",c_right_behind_Speed);
printf("-----------------------------------------");
}
}
另外,注意STM32CubeIDE在打印浮点数的时候需要再进行下面的配置,才能成功打印,否则会报错。
(四)测速效果
将程序下载到STM32机器人控制板上,通过COONEO机器人调试助手与上位机进行通信,即可观察到在不同PWM值下,小车的运动速度。
当输出占空比为50%的PWM信号时,小车的速度如下图:
当输出占空比为70%的PWM信号时,小车的速度如下图:
从图中可以看到,在实际运行时,即使控制的PWM信号占空比一致,四个电机的转速仍存在一定的偏差。那如何才能使四个电机的转速几乎一致呢?此时PID控制就要嘎嘎上大分了。
(五)增量式PID控制
PID,一个在各种控制中随处可见的算法,是专治各种不调的神奇存在。
PID可以分为位置式PID与增量式PID,本教程中使用的是增量式PID。关于PID的具体控制原理知识不在此进行详细介绍,重点介绍的为在本例程中采用的增量式PID。
-
增量式PID是通过改变输出量的大小来控制被控量的稳定,增量式PID与位置式PID不同,增量式返回的数值为当前时刻的控制量与上一时刻的控制量的差值,以此差值作为新的控制量进行反馈。
-
举个例子:设定小车的速度为0.2m/s,通过编码器进行测速得到速度反馈,与设定值产生了偏差$e_k$,系统中保存了上一次的偏差$e{k-1}$还有上上次的的偏差$e{k-2}$,这三个值作为输入量通过增量式PID的计算公式得到$Δu(k)$,将上一次经过PID计算后的$u(k-1)$加上本次的增量$Δu(k)$,便得到本次控制周期的PID输出$u(k)$。将输出值经过二次的计算转换后,得到可以对电机转速进行控制的PWM占空比,进而对小车的运动速度进行控制。
-
增量式PID的公式:$K_p$比例系数、$K_i$积分系数、$K_d$微分系数、$e(k)$偏差。
$$ Δu(k)=K_p[e(k)-e(k-1)]+K_ie(k)+K_d[e(k)-2e(k-1)+e(k-2)] $$
了解增量式PID的基础知识后,就可以将进入程序的编写了。
(六)PID调速代码
维持上面的配置,在 USER CODE BEGIN 4中加入PID的相关函数。
/**
* 定义PID的结构体,结构体内存储PID参数、误差、限幅值以及输出值
*/
typedef struct
{
float Kp;
float Ki;
float Kd;
float last_error; //上一次偏差
float prev_error; //上上次偏差
int limit; //限制输出幅值
int pwm_add; //输出的PWM值
}PID;
/**
* @brief PID相关参数的初始化
* @param PID的结构体指针
*/
void PID_Init(PID *p)
{
p->Kp = Velocity_Kp;
p->Ki = Velocity_Ki;
p->Kd = Velocity_Kd;
p->last_error = 0;
p->prev_error = 0;
p->limit = limit_value;
p->pwm_add = 0;
}
/**
* @brief PID相关参数的初始化
* @param PID的结构体指针
*/
void PID_Cal(int targetSpeed,int currentSpeed,PID *p)
{
int error = targetSpeed - currentSpeed; // 得到目标速度与当前速度的误差
p->pwm_add += p->Kp*(error - p->last_error) + p->Ki*error + p->Kd*(error - 2*p->last_error+p->prev_error); // 根据增量PID公式计算得到输出的增量
p->prev_error = p->last_error; // 记录上次误差
p->last_error = error; // 记录本次误差
if(p->pwm_add>p->limit) p->pwm_add=p->limit; // 限制最大输出值
if(p->pwm_add<-p->limit) p->pwm_add=-p->limit;
}
然后在 USER CODE BEGIN 4中调整定时器6中断回调函数中调用的PID函数,这样就会以50ms为周期对当前的速度进行调整,直到速度达到目标值。
/**
* 定时器6中断回调函数,每50ms调用一次
*/
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
float c_left_front_Speed,c_right_front_Speed,c_left_behind_Speed,c_right_behind_Speed;
if(htim==(&htim6))
{
GetEncoderPulse();
c_left_front_Speed = CalActualSpeed(encoderPulse[0]); //获得当前的速度值
c_right_front_Speed = CalActualSpeed(encoderPulse[1]);
c_left_behind_Speed = CalActualSpeed(encoderPulse[2]);
c_right_behind_Speed = CalActualSpeed(encoderPulse[3]);
PID_Cal((left_front_TargetSpeed)*100,(left_front_Speed)*100,&Left_front_Motor_PID);
PID_Cal((right_front_TargetSpeed)*100,(right_front_Speed)*100,&Right_front_Motor_PID);
PID_Cal((left_behind_TargetSpeed)*100,(left_behind_Speed)*100,&Left_behind_Motor_PID);
PID_Cal((right_behind_TargetSpeed)*100,(right_behind_Speed)*100,&Right_behind_Motor_PID);
MotorRun(Left_front_Motor_PID.pwm_add,Right_front_Motor_PID.pwm_add,Left_behind_Motor_PID.pwm_add,Right_behind_Motor_PID.pwm_add);
}
}
在 USER CODE BEGIN 0中编写电机驱动函数。
/**
* @brief 电机驱动函数
* @retval 左前轮脉冲值,右前轮脉冲值,左后轮脉冲值,右后轮脉冲值
*/
void MotorRun(int left_front_MotorPWM, int right_front_MotorPWM,int left_behind_MotorPWM, int right_behind_MotorPWM)
{
// 左前轮
if(left_front_MotorPWM>=0) Left_Front_Go();
else if(left_front_MotorPWM<0) Left_Front_Back();
// 右前轮
if(right_front_MotorPWM>=0) Right_Front_Go();
else if(right_front_MotorPWM<0) Right_Front_Back();
// 左后轮
if(left_behind_MotorPWM>=0) Left_Behind_Go();
else if(left_behind_MotorPWM<0) Left_Behind_Back();
// 右后轮
if(right_behind_MotorPWM>=0) Right_Behind_Go();
else if(right_behind_MotorPWM<0) Right_Behind_Back();
__HAL_TIM_SET_COMPARE(&htim8,TIM_CHANNEL_1,myabs(left_front_MotorPWM));
__HAL_TIM_SET_COMPARE(&htim8,TIM_CHANNEL_2,myabs(right_front_MotorPWM));
__HAL_TIM_SET_COMPARE(&htim8,TIM_CHANNEL_3,myabs(left_behind_MotorPWM));
__HAL_TIM_SET_COMPARE(&htim8,TIM_CHANNEL_4,myabs(right_behind_MotorPWM));
}
在main函数的while循环中设置目标速度值为0.2m/s。
while (1)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
left_front_TargetSpeed = 0.2;
right_front_TargetSpeed = 0.2;
left_behind_TargetSpeed = 0.2;
right_behind_TargetSpeed = 0.2;
}
将程序下载到NANO驱动板之后,就可以开始进行PID的调参了,学会如何调参是PID控制的重要一环,PID调参一般遵循以下原则:
-
先调整比例系数$Kp$,然后再调整积分系数$Ki$,最后再调整微分系数$Kd$。
-
调整$Kp$时,从小到大进行调整,选取偏小的比例系数使得输出曲线基本贴合目标直线。
-
调整$Ki$,用$Ki$消除静态误差。
-
调整$Kd$,提高调速的响应速度。
接下来我们一起看看如何一步一步正确进行PID调参。
-
第一组PID参数:$Kp$=5.0,$Ki$=0.0 , $Kd$=0.0。
可以看到左右轮子的速度里目标速度0.2m/s差距较大,因此需要继续增加$Kp$的值。
-
第二组PID参数:$Kp$=26.0,$Ki$=0.0 , $Kd$=0.0。
可以看到当前的速度有所提高,接近目标速度,此时加入$Ki$进行微调。
第三组PID参数:$Kp$=26.0,$Ki$=0.04 , $Kd$=0.0。
可以看到当前的速度值接近于目标值0.2m/s,可以看到$Ki$能够减小静态误差,使得最终速度值更接近于0.2m/s,当前的速度值与目标值的偏差仅有0.1~0.2,速度已经较为稳定。
同时可以观察到,速度从0m/s到0.2m/s的时间需要大约0.5s(如下图),加入$Kd$可以增加调速系统的响应速度,更快达到目标值。
第四组PID参数:$Kp$=26.0,$Ki$=0.04 , $Kd$=0.2。
通过曲线可以看到最终的速度值较为稳定,并且响应时间为0.35s,较前几组的调试有所加快。但实际效果不是很明显,因为通常微分作用应用在惯性较大的系统中,所以在小车的速度控制中效果不那么的明显,而且当$Kd$设置过大,会引起系统出现震荡,难以稳定,因此在小车速度控制的应用中可以考虑去除微分作用,即$Kd$=0。
至此编码器测速和PID调速就结束啦!赶紧上手试试你的PID调速效果。
04
第三个功能——使用遥控器实现对电机的控制
没有遥控怎么能算一辆合格的小车呢!那遥控又该怎么实现?下面我们一起来看看如何通过遥控控制麦克纳姆小车。
(一)使用PPM信号进行遥控
本教程使用PPM信号进行遥控,一般的遥控器接收机拥有SBUS模式、IBUS模式、PPM模式以及普通的PWM输出模式。使用PPM模式的理由是可以利用其能够产生多路PWM信号的特点,控制不同的外设,创造更多有意思的玩法!
根据PPM信号的格式,可以通过两种方法读取到其中每个PWM的信息:
-
使用STM32的外部中断(本教程使用),当触发外部中断定时器进行计数,读取定时器的计数值便可以获得每个PWM的脉宽。
-
使用STM32的定时器,利用定时器的输入捕获功能,测量每个PWM的脉冲宽度。
其实两种读取PPM数据的方法本质上相同,都是通过统计下降沿或上升沿的方法统计脉冲,并利用定时器的计数功能获得高低电平的脉宽。
注意:若使用定时器的输入捕获功能读取PPM信号数据,则需要留意其他使用同一定时器外设将不能正常工作,例如在控制板上遥控接收器接口使用了定时器3的输入捕获功能,则同样使用到定时器3作为编码器模式的电机将不能正常读取编码器的脉冲值,需选择使用其他电机接口或改变PPM的读取方式。
(二)STM32CubeIDE中遥控所需的配置
PPM信号在STM32中对应的是PB0口,将其设置为外部中断的模式。
另外,选择定时器7作为计数,并使能定时器的中断。
另外,不要忘了保持第一个功能的电机配置。
遥控器:
本教程使用乐迪T8S遥控器配套的接收机,连接到板上的遥控接口,注意使用接收机时使用的是接收机上的PPM输出接口,然后通过乐迪遥控的配置方式,将接收器配置为PPM模式,然后打开遥控器开关进行对码,即可成功连接。那么新手该如何正确使用这款遥控机呢?
-
通过乐迪T8S遥控器配套的软件查询各遥控通道。
本教程仅用到遥控器左右两边的摇杆,如图所示:
记得安装遥控器的驱动,将遥控器与电脑连接成功后,打开配套的软件,选择遥控器对应的端口号,未连接之前端口号旁边是“OPEN”,电机“OPEN”后为“CLOSE”,即连接成功,本教程使用的是遥控器的默认设置,此时通过拨动摇杆测出不同摇杆方向对应的CH通道。
例如将右边摇杆向上或下拨动时,对应的CH2通道会出现变化,通过测量得知,左右摇杆对应有四个通道:
右摇杆左右拨动——CH1
右摇杆上下拨动——CH2
左摇杆上下拨动——CH3
右摇杆左右拨动——CH4
注意,只有CH3通道值的变化与其他通道是不同的,在"BASIC"中也可以查看,这是由于T8S出厂默认CH3油门通道为反相,其他通道为正向,因此在编写代码的时候需要注意。
2.遥控接收机与板子的连接。
T8S标配的是R8EF接收机,本教程使用PPM信号进行遥控,只需将PPM/CH2的三个引脚对应连接到板子的PPM口上即可。
完成遥控发射机和接收机的对码后(即接收机指示灯停止闪烁并常量)就可以编写程序通过左右摇杆对其进行控制啦!
(三)PPM遥控代码
在 USER CODE BEGIN 4中加入外部中断函数。
/**
* 外部中断回调函数
*/
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if(GPIO_Pin == GPIO_PIN_0) //遥控接口——PB0
{
PPM_Time = __HAL_TIM_GET_COUNTER(&htim7); // 获得定时器7的计数值
__HAL_TIM_GET_COUNTER(&htim7) = 0; // 计数值清零
if(PPM_Time > 0)
{
PPM_Time++;
}
if(PPM_Okay == 1)
{
PPM_Databuf[PPM_Sample_Cnt] = PPM_Time;
PPM_Sample_Cnt++;
if(PPM_Sample_Cnt > 8) PPM_Okay = 0;
}
if(PPM_Time>=3000)
{
PPM_Okay = 1;
PPM_Sample_Cnt = 0;
}
}
}
在main函数的while加入打印函数,分别观察PPM的6个通道值。
while(1)
{
printf("PPM_Data[0] is %d\r\n",PPM_Databuf[0]);
printf("PPM_Data[1] is %d\r\n",PPM_Databuf[1]);
printf("PPM_Data[2] is %d\r\n",PPM_Databuf[2]);
printf("PPM_Data[3] is %d\r\n",PPM_Databuf[3]);
printf("PPM_Data[4] is %d\r\n",PPM_Databuf[4]);
printf("PPM_Data[5] is %d\r\n",PPM_Databuf[5]);
}
本教程使用乐迪T8S遥控器配套的接收机,连接到板上的遥控接口,注意使用接收机时使用的是接收机上的PPM输出接口,然后通过乐迪遥控的配置方式,将接收器配置为PPM模式,然后打开遥控器开关进行对码,成功连接后打开串口调试助手,就可以看到各个通道的值啦!
通过遥控控制电机的代码如下,注意在电机的遥控控制中同样应当设置一定的“死区”,避免出现油门归零时电机仍在转动的情况。
在 USER CODE BEGIN 0中编写电机的遥控函数。
/**
* @brief 电机遥控函数
* @retval None
*/
void MotorRemoteControl()
{
// 标志位:CH0——右边的左右 CH1——右边的前后 CH2——左边的前后 CH3——左边的左右
static uint8_t CH0_flag=0,CH1_flag = 0,CH2_flag = 0,CH3_flag = 0;
if(PPM_Databuf[0]==0||PPM_Databuf[1]==0||PPM_Databuf[2]==0||PPM_Databuf[3]==0)
{
Left_Front_Stop();
Right_Front_Stop();
Left_Behind_Stop();
Right_Behind_Stop();
}
else
{
if(PPM_Databuf[0]<1650&&PPM_Databuf[0]>1350) CH0_flag = 0; // 死区
else if((PPM_Databuf[0]>0)&&(PPM_Databuf[0]<=1350)) CH0_flag = 1;
else if(PPM_Databuf[0]>=1650) CH0_flag = 2;
if(PPM_Databuf[1]<1650&&PPM_Databuf[1]>1350) CH1_flag = 0; // 死区
else if((PPM_Databuf[1]>0)&&(PPM_Databuf[1]<=1350)) CH1_flag = 1;
else if(PPM_Databuf[1]>=1650) CH1_flag = 2;
if(PPM_Databuf[2]<1650&&PPM_Databuf[2]>1350) CH2_flag = 0; // 死区
else if((PPM_Databuf[2]>0)&&(PPM_Databuf[2]<=1350)) CH2_flag = 1;
else if(PPM_Databuf[2]>=1650) CH2_flag = 2;
if(PPM_Databuf[3]<1650&&PPM_Databuf[3]>1350) CH3_flag = 0; // 死区
else if((PPM_Databuf[3]>0)&&(PPM_Databuf[3]<=1350)) CH3_flag = 1;
else if(PPM_Databuf[3]>=1650) CH3_flag = 2;
if((CH0_flag==0)&&(CH1_flag==0)&&(CH2_flag==0)&&(CH2_flag==0))
{
Left_Front_Stop();
Right_Front_Stop();
Left_Behind_Stop();
Right_Behind_Stop();
}
if((CH0_flag!=0)&&(CH1_flag==0))
MotorRun(PPM_Databuf[0]-1500,1500-PPM_Databuf[0],1500-PPM_Databuf[0],PPM_Databuf[0]-1500); // 左平移、右平移
if((CH1_flag==1)&&(CH0_flag==0))
MotorRun(300+1500-PPM_Databuf[1],300+1500-PPM_Databuf[1],300+1500-PPM_Databuf[1],300+1500-PPM_Databuf[1]); // 前进+提速
if((CH1_flag==2)&&(CH0_flag==0))
MotorRun(1500-PPM_Databuf[1]-300,1500-PPM_Databuf[1]-300,1500-PPM_Databuf[1]-300,1500-PPM_Databuf[1]-300); // 后退+提速
if((CH3_flag!=0)&&(CH2_flag==0))
MotorRun(PPM_Databuf[3]-1500,1500-PPM_Databuf[3],PPM_Databuf[3]-1500,1500-PPM_Databuf[3]); // 左旋、右旋
if(((CH0_flag==1)&&(CH1_flag==1))||((CH0_flag==2)&&(CH1_flag==2)))
MotorRun(0,1500-PPM_Databuf[1],1500-PPM_Databuf[1],0); // 左前移、右后移
if(((CH0_flag==2)&&(CH1_flag==1))||((CH0_flag==1)&&(CH1_flag==2)))
MotorRun(1500-PPM_Databuf[1],0,0,1500-PPM_Databuf[1]); // 右前移、左后移
if(((CH2_flag==2)&&(CH3_flag==1))||((CH2_flag==2)&&(CH3_flag==2)))
MotorRun(0,0,PPM_Databuf[3]-1500,1500-PPM_Databuf[3]); // 绕前轴中点左右旋
if(((CH2_flag==1)&&(CH3_flag==1))||((CH2_flag==1)&&(CH3_flag==2)))
MotorRun(PPM_Databuf[3]-1500,1500-PPM_Databuf[3],0,0); // 绕后轴中点左右旋
}
}
(三)遥控效果演示
上述程序中一共有14种运行方式,这两组动图仅展示了部分遥控功能,更多功能可以上手试试哟!
05
第四个功能——实现串口发送数据对电机的控制
在机器人控制中,单片机(Arduino/STM32)与上位机(Raspberry Pi/NVIDIA Jetson nano)之间的通信经常采用串口通信的方式,因此,学会使用串口通讯实现对电机的控制对今后的学习也很有帮助,那应该如何使用STM32的串口通信以及根据自己定义的协议来完成数据的接收与发送呢?
(一)制定串口的数据协议
本教程目前共制定了两条串口协议,如下所示:
-
'e' :反馈两个电机的编码器脉冲计数值,该计数值达到最大值或最小值时自动清零。
-
'm' l_f/b_speed r_f/b_speed :'m'为控制标志,l_f/b_speed为左前后轮的速度值,r_f/b_speed为右前后轮的速度值,该值单位为 cm/s,注意中间是空格。
(二)STM32CubeIDE中串口的配置
为了提高串口通信的效率,减少因为字节传输而不断引起中断导致资源的浪费,可以采用DMA+串口空闲中断的方式对数据进行接收。
-
DMA:Direct Memory Access,可以实现一个数据从一个地址空间拷贝到另一个地址空间,并且在数据拷贝过程中无需CPU的干预,在数据拷贝结束后才告知CPU进行处理。因此使用DMA功能可以释放CPU资源。
-
串口空闲中断:普通的串口处理数据方式为单字节接收,并且接收一帧数据时,需要自行判断帧头帧尾确定是否为一帧完整数据,并且当数据量大会导致频繁进入中断。而采用串口空闲中断,在串口空闲时(发送完一帧数据)产生中断,并且可以在中断服务函数中计算得到的数据长度,对整帧数据进行处理。
本教程记得维持第一个功能的电机配置,并在第二个功能的串口1配置基础上打开DMA和中断功能。
(三)串口控制电机运转代码
打开工程文件中的stm32f1xx_it.c,找到串口1的中断服务函数,在中断服务函数中加入以下代码。
/**
* 串口1的中断函数
*/
void USART1_IRQHandler(void)
{
/* USER CODE BEGIN USART1_IRQn 0 */
uint32_t tmp_flag = 0;
uint32_t temp;
tmp_flag =__HAL_UART_GET_FLAG(&huart1,UART_FLAG_IDLE); // 获取IDLE标志位
if((tmp_flag != RESET)) // idle标志被置位
{
__HAL_UART_CLEAR_IDLEFLAG(&huart1);// 清除标志位
HAL_UART_DMAStop(&huart1);
temp = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx);// 获取DMA中未传输的数据个数
rx_len = BUFFER_SIZE - temp; // 总计数减去未传输的数据个数,得到已经接收的数据个数
recv_end_flag = 1; // 接受完成标志位置1
}
HAL_UART_Receive_DMA(&huart1,uart1_rx_buffer,BUFFER_SIZE); // 重新打开DMA接收
/* USER CODE END USART1_IRQn 0 */
HAL_UART_IRQHandler(&huart1);
/* USER CODE BEGIN USART1_IRQn 1 */
/* USER CODE END USART1_IRQn 1 */
}
在main函数中的主循环开始前使能串口1的中断。
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); //使能IDLE中断
HAL_UART_Receive_DMA(&huart1,uart1_rx_buffer,BUFFER_SIZE);
while(1)
{
USART_Receive(); // 串口1的接收函数
}
另外,在 USER CODE BEGIN 0中对PID部分和获取当前轮子转速的内容进行如下修改,另外加上均值滤波函数。
/**
* @brief 根据得到的编码器脉冲值计算速度 单位:cm/s
* @retval 编码器脉冲值
* @return
*/
float CalActualSpeed(int pulse)
{
return (float)(0.3092424 * pulse);
}
/**
* @brief 均值滤波
* @param 需要滤波的数组,滤波次数
* @retval 滤波后的值
*/
int AverageFilter(short *array, uint8_t num)//均值滤波
{
int tmp = 0;
uint8_t i;
for(i = 0; i < num; i++)
tmp += array[i];
tmp = tmp / num;
return tmp;
}
/**
* @brief 读取定时器2、3、4、5的计数值(编码器脉冲值)
* @retval None
*/
void GetEncoderPulse()
{
uint8_t i = filterNum - 1;
for(;i > 0;i--)
{
left_front_Encoder[i-1] = left_front_Encoder[i]; //使用数组来保存两次的编码值
right_front_Encoder[i-1] = right_front_Encoder[i]; //最后一个用于保留上一次的值
left_behind_Encoder[i-1] = left_behind_Encoder[i];
right_behind_Encoder[i-1] = right_behind_Encoder[i];
}
left_front_Encoder[filterNum-1] = -((short)__HAL_TIM_GET_COUNTER(&htim2));
right_front_Encoder[filterNum-1] = -((short)__HAL_TIM_GET_COUNTER(&htim3));
left_behind_Encoder[filterNum-1] = ((short)__HAL_TIM_GET_COUNTER(&htim4));
right_behind_Encoder[filterNum-1] = ((short)__HAL_TIM_GET_COUNTER(&htim5));
last_encoderPulse[0] = encoderPulse[0];
last_encoderPulse[1] = encoderPulse[1];
last_encoderPulse[2] = encoderPulse[2];
last_encoderPulse[3] = encoderPulse[3];
encoderPulse[0] = AverageFilter(left_front_Encoder, filterNum); //左电机的编码器脉冲值
encoderPulse[1] = AverageFilter(right_front_Encoder, filterNum); //右电机的编码器脉冲值
encoderPulse[2] = AverageFilter(left_behind_Encoder, filterNum); //左电机的编码器脉冲值
encoderPulse[3] = AverageFilter(right_behind_Encoder, filterNum); //右电机的编码器脉冲值
encoderValue[0] = encoderPulse[0]-last_encoderPulse[0]; // 脉冲
encoderValue[1] = encoderPulse[1]-last_encoderPulse[1];
encoderValue[2] = encoderPulse[2]-last_encoderPulse[2];
encoderValue[3] = encoderPulse[3]-last_encoderPulse[3];
if(encoderPulse[0]>32000||encoderPulse[0]<-32000) TIM2->CNT = 0;
if(encoderPulse[1]>32000||encoderPulse[1]<-32000) TIM3->CNT = 0;
if(encoderPulse[2]>32000||encoderPulse[2]<-32000) TIM4->CNT = 0;
if(encoderPulse[3]>32000||encoderPulse[3]<-32000) TIM5->CNT = 0;
}
/**
* @brief PID相关参数的初始化
* @param PID的结构体指针
*/
int PID_Cal(int targetSpeed,int currentSpeed,PID *p)
{
int error = targetSpeed - currentSpeed;
if(error>50)error=0;
if(error<-50)error=0; //避免跳动
p->pwm_add += p->Kp*(error - p->last_error) + p->Ki*error + p->Kd*(error - 2*p->last_error+p->prev_error);
p->prev_error = p->last_error;
p->last_error = error;
if(p->pwm_add>p->limit) p->pwm_add=p->limit;
if(p->pwm_add<-p->limit) p->pwm_add=-p->limit;
}
在 USER CODE BEGIN 4中修改定时器中断6的回调函数。
/**
* 定时器6中断回调函数,每50ms调用一次
*/
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
float c_left_front_Speed,c_right_front_Speed,c_left_behind_Speed,c_right_behind_Speed;
if(htim==(&htim6))
{
GetEncoderPulse();
c_left_front_Speed = CalActualSpeed(encoderValue[0]); //获得当前的轮子转速
c_right_front_Speed = CalActualSpeed(encoderValue[1]);
c_left_behind_Speed = CalActualSpeed(encoderValue[2]);
c_right_behind_Speed = CalActualSpeed(encoderValue[3]);
PID_Cal(left_front_TargetSpeed,c_left_front_Speed,&Left_front_Motor_PID);
PID_Cal(right_front_TargetSpeed,c_right_front_Speed,&Right_front_Motor_PID);
PID_Cal(left_behind_TargetSpeed,c_left_behind_Speed,&Left_behind_Motor_PID);
PID_Cal(right_behind_TargetSpeed,c_right_behind_Speed,&Right_behind_Motor_PID);
MotorRun(Left_front_Motor_PID.pwm_add,Right_front_Motor_PID.pwm_add,Left_behind_Motor_PID.pwm_add,Right_behind_Motor_PID.pwm_add);
if(left_front_TargetSpeed==0) Left_front_Motor_PID.pwm_add = 0; //PID增量清零
if(right_front_TargetSpeed==0) Right_front_Motor_PID.pwm_add = 0;
if(left_behind_TargetSpeed==0) Left_behind_Motor_PID.pwm_add = 0; //PID增量清零
if(right_behind_TargetSpeed==0) Right_behind_Motor_PID.pwm_add = 0;
}
}
在 USER CODE BEGIN 0下 加入串口1的接收中断函数。
/**
* @brief 串口1的接收函数
* @retval None
*/
void USART_Receive(void)
{
int empty_num = 0; // 空格所在的位置
if(recv_end_flag == 1) // 串口中断接收标志位
{
if(uart1_rx_buffer[0] == 0x6d) // 若接收到'm'
{
char_len = rx_len - 2; // 发送的数据长度
for(int i = 1;i<rx_len;i++) // 对接收的数据进行空格检索
{
if(uart1_rx_buffer[i]==0x20) // 若接收到空格' '
{
motor_num_buffer[empty_len] = atof(uart1_rx_buffer+i); // 存储串口发送的每个速度值
empty_len++;
empty_num = i+1;
}
}
if(empty_len == 4)
{
if(empty_num == char_len) // 最后的空格位置等于串口发送的数据长度,例如m 20 20 20 ,格式不对
{
printf("Your input is wrong!\r\n");
empty_num = 0;
}
else
{
printf("Good!\r\n"); // 串口指令发送正确后打印Good!
left_front_TargetSpeed = motor_num_buffer[0];
right_front_TargetSpeed = motor_num_buffer[1];
left_behind_TargetSpeed = motor_num_buffer[2];
right_behind_TargetSpeed = motor_num_buffer[3];
}
}
else printf("Your input is wrong!\r\n");
empty_len = 0;
}
else if(uart1_rx_buffer[0] == 0x65) // 若接收到'e'
{
int left_front_Pulse=0,right_front_Pulse=0,left_behind_Pulse=0,right_behind_Pulse=0;
left_front_Pulse = encoderPulse[0]; // 获得当前的编码器脉冲值,该值每50ms进行清零
right_front_Pulse = encoderPulse[1];
left_behind_Pulse = encoderPulse[2];
right_behind_Pulse = encoderPulse[3];
printf("%d,%d,%d,%d\r\n",left_front_Pulse,right_front_Pulse,left_behind_Pulse,right_behind_Pulse); // 打印每个电机的编码器脉冲值
}
recv_end_flag = 0; // 标志位置零等待下一次接收
rx_len = 0;
memset(uart1_rx_buffer,0,rx_len); // 初始化接收数组
}
}
(四)串口控制效果演示
-
串口发送指令"m 0 0 0 0"时,返回"Good!",且电机停止,表示已经成功设置四个轮子的速度。
2.当发送"m 20 20 20 20"时,四个轮子以20cm/s的速度转动
3.当发送"m -25 -25 -25 -25"时,四个轮子以25cm/s的速度反转。
4.当发送"e"时,可以得到当前编码器脉冲计数值。
到这里关于麦克纳姆小车的教程就全部结束了,能看到这里的小伙伴已经赢过很多人啦,不管收获多少先给自己点个赞!再详细的教程都不如自己亲自动手试试,期待收到大家分享自己的学习过程。
欢迎加入我们的交流群,该群面向热爱机器人研发的朋友们,方便大家一起学习、分享、交流智能机器人创造,结识更多志同道合的小伙伴。更有不定期的社区专属福利哦!关注公众号并在对话框发送“入群”,即可获取入群方式。
内容:Rhyme
版面:SUSU
审核:X
创作不易,如果喜欢这篇内容,请您也转发给您的朋友,一起分享和交流创造的乐趣,也激励我们为大家创作更多的机器人研发攻略,让我们一起learning by doing!