在搭建完clion的开发环境后,我决定重写之前的项目并优化完善,争取做出完全可落地的东西,也结合要写的论文内容一同学习下去。
因此,首当其冲的就是回到步进电机控制领域,把之前使用中断溢出进行步进电机控制的方案进行进一步优化。
目前使用中断溢出控制步进电机,有如下几个问题:
1.有时电压不稳定/电压太低,电机转不起来。
2.电机频率太高有响声,频率太低有气声,需要设置一个合理的范围值,同时不会因为数值的事情用很长时间去调试电机。
3.电机转向会出问题,或是反应不过来,或是接线有问题。
4.只能通过改变psc预分频器和arr自动重装载值去改变电机速度,无法通过改变占空比的方式控制电机,且驱动器上合适的细分数需要自己调节。
因此我这次使用了四种方式控制电机
1.模拟io控制
2.中断溢出控制
3.定时器比较通道控制
4.定时器pwm控制
芯片选用的是stm32f103zet6,全程未使用编码器/PID,电机驱动器用的是最简单常见的TB6612,主打一个好复现且好上手。电机控制比较稳定,可以根据自己需要选择控制方式。本篇文章使用的硬件和这两篇文章里介绍的一致:
stm32精密控制步进电机(基础篇)_stm32 步进电机-CSDN博客
stm32精密控制步进电机(升级篇)_stm32微机控制-CSDN博客
与之前不同的是我把使能引脚也加上了(PA7控制ENA-;ENA+,PUL+和DIR+接到5v或3.3v引脚),这里可以用万用表验证一下。
四种方式的详细代码我放在了github上,都使用了freertos系统,欢迎移步下载,可以试着点个star,谢谢:
https://github.com/Re-restart/four_ways_to_control_stepper
怎么完成电机接线
Aout1与Aout2(A+与A-)是同相,Bout1与Bout2(B+与B-)是同相。把同相的两条线拧在一起,会发现难以拧动电机;或是直接测试电阻,两个引脚之间电阻极大,那这两个电机引脚就是同相的。
如果驱动器是9v的,建议接12v电源+DCDC,这样电流会更加稳定,电机抖动现象就可以减轻很多。如果外部不是稳定的开关电源,只靠板子和12v电池供电,最好不要设置板子的脉冲和方向引脚为推挽上拉,设置推挽会抢占一些电压,可能给驱动器分配的电压就变少了。
进行电机微秒级延时
众所周知,HAL_Delay和Osdelay都是毫秒延时。这种情况下需要一个定时器去负责微秒的延时,我使用的方式是把TIM4里的预分频器psc设为71,定时器时钟源频率是72MHZ,定时器输入时钟频率就是:
把arr值设置为65535,这样读取出来的寄存器值就是1微秒增加一次,最后比较出的值小于要执行的微秒数就可以。
void delay_us(uint16_t us)
{
__HAL_TIM_SET_COUNTER(&htim4, 0); // 清零了 TIM4 的计数器,因此不再需要 start_time 变量来记录初始时间
while (__HAL_TIM_GET_COUNTER(&htim4) < us); // 等待计数达到 us
}
另外还有一种做法是设置一个volatile变量(代表这个变量不要被编译器优化,可能在外设或中断中改变它的值,每次访问该变量时需要从内存中读取),start_time是用来读取中断的初始值的,和清零函数__HAL_TIM_SET_COUNTER(&htim4, 0)的作用一致。
为了处理溢出问题,可以利用无符号整数的特性,即(uint16_t)(delay_time-start_time)。
volatile uint16_t start_time=0;
volatile uint16_t delay_time=0;
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
/* USER CODE BEGIN Callback 0 */
/* USER CODE END Callback 0 */
if (htim->Instance == TIM4) {
delay_time++;
}
/* USER CODE BEGIN Callback 1 */
/* USER CODE END Callback 1 */
}
void delay_us(uint16_t us)
{
start_time=delay_time;
while((uint16_t)(delay_time-start_time)<us);
}
按键进行外部中断控制
先新建系统任务控制函数,利用两个板子自带的按钮做外部中断,控制电机左转/右转。这个按键逻辑是默认flag=1,flag_key=1。按下按键后,防抖10s,并把flag和flag_key全置为0。
按键松开后,按键引脚恢复到之前的电平,因为也不会同时摁,就用flag,flag_key和按键引脚松开后的状态共同判断,区分电机状态并执行stepper_turn函数。
void StartDefaultTask(void *argument)
{
/* USER CODE BEGIN StartDefaultTask */
/* Infinite loop */
for(;;) {
if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET) {
osDelay(10);
flag = 0;
}
if(flag == 0 && flag_key == 1) {
if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) {
stepper_turn(120,360,32,CW);
}
flag = 1;
}
/
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_4) == GPIO_PIN_RESET) {
osDelay(10);
flag_key = 0;
}
if(flag_key == 0 && flag == 1) {
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_4) == GPIO_PIN_SET) {
stepper_turn(120,360,32,CCW);
}
}
flag_key = 1;
}
电机引脚初始设置
中断和模拟io控制电机都是PA5为PUL-控制引脚,PA6为DIR-控制引脚,PA7为ENA-控制引脚。输入捕获和pwm用的都是TIM3-Channel1,也就是PC6。不是不能设置推挽引脚哈,这个主要看外界的电压状态,如果外界供电稳定,引脚电压也够,那全设置推挽上拉也没什么问题。但是我需要大多数情况下都没问题的引脚配置,所以会设置开漏不上拉,只有PA7是推挽上拉。
/*Configure GPIO pins : PA5 PA6 */
GPIO_InitStruct.Pin = GPIO_PIN_5|GPIO_PIN_6;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
/*Configure GPIO pin : PA7 */
GPIO_InitStruct.Pin = GPIO_PIN_7;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_PULLUP;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
/*Configure GPIO pin Output Level */
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5|GPIO_PIN_6, GPIO_PIN_RESET);
/*Configure GPIO pin Output Level */
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_SET);
//
void HAL_TIM_MspPostInit(TIM_HandleTypeDef* timHandle)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
if(timHandle->Instance==TIM3)
{
/* USER CODE BEGIN TIM3_MspPostInit 0 */
/* USER CODE END TIM3_MspPostInit 0 */
__HAL_RCC_GPIOC_CLK_ENABLE();
/**TIM3 GPIO Configuration
PC6 ------> TIM3_CH1
*/
GPIO_InitStruct.Pin = GPIO_PIN_6;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);
__HAL_AFIO_REMAP_TIM3_ENABLE();
/* USER CODE BEGIN TIM3_MspPostInit 1 */
/* USER CODE END TIM3_MspPostInit 1 */
}
}
模拟IO控制步进电机
通过编写stepper_turn函数控制模拟IO。这里面注意一下,ENA-引脚是推挽上拉状态,初始为SET,此时ENA-引脚电平为0v。
已知ENA+引脚电平是3.3v或5v,构成使能信号有效,电机pulse引脚开始翻转,电机转动。电机停止转动时,使能引脚恢复到reset状态。
电机转动的时候,使能引脚PA7其实是有电压的,2v~3v的样子。可以看作这个引脚是来激活驱动器的。如果讨厌这个使能引脚/板子引脚资源比较少,可以不接使能+和使能-引脚,PUL+和DIR+接到5v或3.3v引脚,PUL-和DIR-用于控制电机即可。
tim是周期,angle是角度,subdivide是电机驱动器的细分数,dir是电机旋转方向。
void stepper_turn(int tim,float angle,float subdivide,uint8_t dir)
{
int n,i;
/*根据细分数求得步距角被分成多少个方波*/
n=(int)(angle/(1.8/subdivide));
if(dir==CW) //顺时针
{
HAL_GPIO_WritePin(MOTOR_DIR_GPIO_PORT,MOTOR_DIR_PIN,HIGH);
}
else if(dir==CCW)//逆时针
{
HAL_GPIO_WritePin(MOTOR_DIR_GPIO_PORT,MOTOR_DIR_PIN,LOW);
}
/*开使能*/
HAL_GPIO_WritePin(MOTOR_EN_GPIO_PORT,MOTOR_EN_PIN,GPIO_PIN_SET);
/*模拟方波*/
for(i=0;i<n;i++)
{
HAL_GPIO_WritePin(MOTOR_PUL_GPIO_PORT,MOTOR_PUL_PIN,LOW);
delay_us(tim/2);
HAL_GPIO_WritePin(MOTOR_PUL_GPIO_PORT,MOTOR_PUL_PIN,HIGH);
delay_us(tim/2);
}
/*关使能*/
HAL_GPIO_WritePin(MOTOR_EN_GPIO_PORT,MOTOR_EN_PIN,GPIO_PIN_RESET);
}
中断溢出控制步进电机
和之前文章里的类似,这里不再详细讲解。逻辑稍微改良了一下,不再通过读取引脚状态计数,而是每溢出两次,脉冲引脚翻转一次。这是因为中断需要保持实时性,需要尽量处理简单的逻辑。
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
/* USER CODE BEGIN Callback 0 */
/* USER CODE END Callback 0 */
if (htim->Instance == TIM2) {
HAL_IncTick();
}
/* USER CODE BEGIN Callback 1 */
if(htim->Instance==TIM5)
{
pulse=pulse+1;
if(pulse%2==0){
Pulse_Toggle();
}
__HAL_TIM_CLEAR_IT(&htim5, TIM_IT_UPDATE);
}
/* USER CODE END Callback 1 */
}
中断跟其他方式相比,比较容易出现失控,所以启动之前需要先关闭中断,停止电机运行,然后再打开电机驱动和使能。
for(;;) {
if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET) {
osDelay(10);
flag = 0;
}
if(flag == 0 && flag_key == 1) {
if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) {
StopMotor();
Dir_CW();
StartMotor();
HAL_GPIO_WritePin(MOTOR_EN_GPIO_PORT,MOTOR_EN_PIN,GPIO_PIN_SET);
}
flag = 1;
}
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_4) == GPIO_PIN_RESET) {
osDelay(10);
flag_key = 0;
}
if(flag_key == 0 && flag == 1) {
if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_4) == GPIO_PIN_SET) {
StopMotor();
Dir_CC();
StartMotor();
HAL_GPIO_WritePin(MOTOR_EN_GPIO_PORT,MOTOR_EN_PIN,GPIO_PIN_SET);//打开使能
}
flag_key = 1;
}
if(pulse > 3200) {
pulse = 0;
StopMotor();
BlinkLEDs();
}
}
然后我把比较重要的操作都封装成函数,这样便于调用。HAL_TIM_Base_MspDeInit用于关闭定时器中断,HAL_TIM_Base_MspInit打开定时器中断。
void StopMotor(void) {
HAL_TIM_Base_MspDeInit(&htim5);
HAL_GPIO_WritePin(MOTOR_EN_GPIO_PORT,MOTOR_EN_PIN,GPIO_PIN_RESET);
}
void StartMotor(void) {
HAL_TIM_Base_MspInit(&htim5);
}
定时器比较通道控制步进电机
在这里通过PC6引脚控制步进电机,不需要考虑什么引脚推挽还是开漏的问题,直接设置通道就可以了。如果这里不加延时,电机会一直转。我设置过像HAL_TIM_OC_DelayElapsedCallback一样的回调函数去按照比较通道执行顺序计数,但是并不好用,会发现初始的库文件并没有弱定义这个函数,遂放弃这种方式。
void stepper_turn(uint8_t dir)
{
if(dir==CW) //顺时针
{
HAL_GPIO_WritePin(MOTOR_DIR_GPIO_PORT,MOTOR_DIR_PIN,HIGH);
}
else if(dir==CCW)//逆时针
{
HAL_GPIO_WritePin(MOTOR_DIR_GPIO_PORT,MOTOR_DIR_PIN,LOW);
}
/*开使能*/
HAL_GPIO_WritePin(MOTOR_EN_GPIO_PORT,MOTOR_EN_PIN,GPIO_PIN_SET);
/* 启动比较输出并使能中断 */
HAL_TIM_OC_Start_IT(&htim3,TIM_CHANNEL_1);
for(uint16_t i=0;i<2000;i++) {
delay_us(1000);
}
stepper_stop();
}
void stepper_stop(void) {
HAL_TIM_OC_Stop_IT(&htim3,TIM_CHANNEL_1);
HAL_GPIO_WritePin(MOTOR_EN_GPIO_PORT,MOTOR_EN_PIN,GPIO_PIN_RESET);
}
在定时器内设置速度,使能比较通道后关闭中断,需要中断时再去调用并打开中断。这里面电机速度计算过程如下,可算得电机速度周期是1.375ms。
void MX_TIM3_Init(void)
{
/* USER CODE BEGIN TIM3_Init 0 */
/* USER CODE END TIM3_Init 0 */
TIM_ClockConfigTypeDef sClockSourceConfig = {0};
TIM_MasterConfigTypeDef sMasterConfig = {0};
TIM_OC_InitTypeDef sConfigOC = {0};
/* USER CODE BEGIN TIM3_Init 1 */
/* USER CODE END TIM3_Init 1 */
htim3.Instance = TIM3;
htim3.Init.Prescaler = 899;
htim3.Init.CounterMode = TIM_COUNTERMODE_UP;
htim3.Init.Period = 109;
htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE;
if (HAL_TIM_Base_Init(&htim3) != HAL_OK)
{
Error_Handler();
}
sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL;
if (HAL_TIM_ConfigClockSource(&htim3, &sClockSourceConfig) != HAL_OK)
{
Error_Handler();
}
if (HAL_TIM_OC_Init(&htim3) != HAL_OK)
{
Error_Handler();
}
sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET;
sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE;
if (HAL_TIMEx_MasterConfigSynchronization(&htim3, &sMasterConfig) != HAL_OK)
{
Error_Handler();
}
sConfigOC.OCMode = TIM_OCMODE_TOGGLE;
sConfigOC.Pulse = 0;
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
if (HAL_TIM_OC_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_1) != HAL_OK)
{
Error_Handler();
}
/* USER CODE BEGIN TIM3_Init 2 */
/*使能比较通道*/
TIM_CCxChannelCmd(TIM3,TIM_CHANNEL_1,TIM_CCx_ENABLE);
HAL_TIM_OC_Stop_IT(&htim3,TIM_CHANNEL_1);
/* USER CODE END TIM3_Init 2 */
HAL_TIM_MspPostInit(&htim3);
}
定时器PWM方式控制步进电机
其实就是把使能比较通道方式换成pwm通道。 sConfigOC.Pulse/(arr+1)这个值是占空比,可以把它设置在tim周期的50%到80% 之间。
但其实这种方式个人认为并不利于电机控制,因为如果想旋转需要的角度,需要设置主从定时器和输入触发源,而且cubeide会初始化很多配置,其中就包括psc,arr和pulse占空比,不利于程序本身的赋值。而且开启PWM时,必须同时开启AFIO时钟!!必须配置对应引脚为复用输出!HAL_TIM_MspPostInit(&htim3);就是这个定时器引脚定义函数,所以下面的定时器配置里是HAL_TIM_PWM_Start(&htim3,TIM_CHANNEL_1);,假如设置HAL_TIM_PWM_Stop,在freertos里再打开,是驱动不了步进电机的。。。。。这件事情卡了我很久。
好处是它和输出比较方式都随时转换方向,而且不需要考虑推挽还是开漏引脚,控制是真的很稳。
void MX_TIM3_Init(void)
{
/* USER CODE BEGIN TIM3_Init 0 */
/* USER CODE END TIM3_Init 0 */
TIM_ClockConfigTypeDef sClockSourceConfig = {0};
TIM_MasterConfigTypeDef sMasterConfig = {0};
TIM_OC_InitTypeDef sConfigOC = {0};
/* USER CODE BEGIN TIM3_Init 1 */
/* USER CODE END TIM3_Init 1 */
htim3.Instance = TIM3;
htim3.Init.Prescaler = 719;
htim3.Init.CounterMode = TIM_COUNTERMODE_UP;
htim3.Init.Period = tim_per-1;
htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE;
if (HAL_TIM_Base_Init(&htim3) != HAL_OK)
{
Error_Handler();
}
sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL;
if (HAL_TIM_ConfigClockSource(&htim3, &sClockSourceConfig) != HAL_OK)
{
Error_Handler();
}
if (HAL_TIM_PWM_Init(&htim3) != HAL_OK)
{
Error_Handler();
}
sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET;
sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE;
if (HAL_TIMEx_MasterConfigSynchronization(&htim3, &sMasterConfig) != HAL_OK)
{
Error_Handler();
}
sConfigOC.OCMode = TIM_OCMODE_PWM1;
sConfigOC.Pulse = tim_per/2;
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
if (HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_1) != HAL_OK)
{
Error_Handler();
}
/* USER CODE BEGIN TIM3_Init 2 */
HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_1);
HAL_TIM_PWM_Start(&htim3,TIM_CHANNEL_1);
/* USER CODE END TIM3_Init 2 */
HAL_TIM_MspPostInit(&htim3);
}
几种方式的优缺点总结
个人认为这里面设置最简单,相对来说最可控,占用单片机内部资源最少的还是模拟io方式,延时的问题通过定时器TIM4解决后,大大增加了可控的精度。而且实际测试时,12v供电它的引脚电压基本能稳到10v,且电机发热时一样可运行,也不需要特定的gpio驱动,是和模拟i2c一样的,比较好的方式,之后做项目可能会多用这种电机控制方式。
如果特定项目需要快速切换电机方向,建议选择定时器比较通道控制,或以PWM方式控制电机。
另外,发现TIM1是没有办法设置TIM_OCMODE_TOGGLE方式的,只能设置TIM_OCMODE_TIMING,也查了相关手册,并没介绍这个地方,之后可能会继续完善看看这是怎么回事。