视觉巡线小车——STM32+OpenMV(二)

目录

前言

一、PID算法

二、配置串口

三、PID调试助手通讯

四、PID参数调试

总结



前言

通过视觉巡线小车------STM32+OpenMV(一),已基本实现了减速电机的开环控制以及速度的采集。本文将对减速电机进行速度闭环控制------采用PID算法实现。

系列文章请查看:视觉巡线小车------STM32+OpenMV系列文章


一、PID算法

PID控制是工程实际中应用最为广泛的调节器控制规律。问世至今70多年来,它以其结构简单、稳定性好、工作可靠、调整方便而成为工业控制的主要技术之一。将偏差的比例(Proportion)、积分(Integral)和微分(Differential)通过线性组合构成控制量,用这一控制量对被控对象进行控制,这样的控制器称PID控制器,其控制流程如下图所示。

模拟PID控制器的控制规律为:

对其离散化处理后得:

将上面离散化处理后的表达式通过C代码实现如下:

cpp 复制代码
/**
 * @brief     
 *  get_speed 速度测量值
    set_Target 目标速度
    P 比例控制参数
    I 积分控制参数
    D 微分控制参数
#define XIAN_FU 7000               //积分限幅值
#define LIMIT(x,min,max) (x)=(((x)<=(min))?(min):(((x)>=(max))?(max):(x)))  //限幅宏定义
 */
int pid_control(float get_speed, float set_Target,float P,float I,float D)
{
	static int Integral,Last_error,LLast_Error;
    int Error,pid_out;      
    Error = set_Target - get_speed;      
 
    Integral +=  Error; 
    LIMIT(Integral,-XIAN_FU,XIAN_FU);//积分限幅
    pid_out = P*Error + I*Integral + D*(Error - Last_error);    
    Last_error = Error;   
 
    return pid_out;
}

二、配置串口

为了方便调试PID参数,本次采用野火PID调试助手进行调参,需要配置串口来传递数据。Cube MX配置串口3,如下:

需要打开串口中断,进行数据接收:

配置完成后重新生成工程。

三、PID调试助手通讯

野火PID调试助手通讯协议如下,可以参考野火官网介绍,很nice!关于本项目 --- [野火]电机应用开发实战指南---基于STM32 文档

protocol.h文件内容:

cpp 复制代码
#ifndef __PROTOCOL_H__
#define __PROTOCOL_H__

/*****************************************************************************/
/* Includes                                                                  */
/*****************************************************************************/
#include "main.h"


#ifdef _cplusplus
extern "C" {
#endif   

/* 数据接收缓冲区大小 */
#define PROT_FRAME_LEN_RECV  128

/* 校验数据的长度 */
#define PROT_FRAME_LEN_CHECKSUM    1

/* 数据头结构体 */
typedef __packed struct
{
  uint32_t head;    // 包头
  uint8_t ch;       // 通道
  uint32_t len;     // 包长度
  uint8_t cmd;      // 命令
//  uint8_t sum;      // 校验和
  
}packet_head_t;

#define FRAME_HEADER     0x59485A53    // 帧头

/* 通道宏定义 */
#define CURVES_CH1      0x01
#define CURVES_CH2      0x02
#define CURVES_CH3      0x03
#define CURVES_CH4      0x04
#define CURVES_CH5      0x05

/* 指令(下位机 -> 上位机) */
#define SEND_TARGET_CMD      0x01     // 发送上位机通道的目标值
#define SEND_FACT_CMD        0x02     // 发送通道实际值
#define SEND_P_I_D_CMD       0x03     // 发送 PID 值(同步上位机显示的值)
#define SEND_START_CMD       0x04     // 发送启动指令(同步上位机按钮状态)
#define SEND_STOP_CMD        0x05     // 发送停止指令(同步上位机按钮状态)
#define SEND_PERIOD_CMD      0x06     // 发送周期(同步上位机显示的值)

/* 指令(上位机 -> 下位机) */
#define SET_P_I_D_CMD        0x10     // 设置 PID 值
#define SET_TARGET_CMD       0x11     // 设置目标值
#define START_CMD            0x12     // 启动指令
#define STOP_CMD             0x13     // 停止指令
#define RESET_CMD            0x14     // 复位指令
#define SET_PERIOD_CMD       0x15     // 设置周期

/* 空指令 */
#define CMD_NONE             0xFF     // 空指令

/* 索引值宏定义 */
#define HEAD_INDEX_VAL       0x3u     // 包头索引值(4字节)
#define CHX_INDEX_VAL        0x4u     // 通道索引值(1字节)
#define LEN_INDEX_VAL        0x5u     // 包长索引值(4字节)
#define CMD_INDEX_VAL        0x9u     // 命令索引值(1字节)

#define EXCHANGE_H_L_BIT(data)      ((((data) << 24) & 0xFF000000) |\
                                     (((data) <<  8) & 0x00FF0000) |\
                                     (((data) >>  8) & 0x0000FF00) |\
                                     (((data) >> 24) & 0x000000FF))     // 交换高低字节

#define COMPOUND_32BIT(data)        (((*(data-0) << 24) & 0xFF000000) |\
                                     ((*(data-1) << 16) & 0x00FF0000) |\
                                     ((*(data-2) <<  8) & 0x0000FF00) |\
                                     ((*(data-3) <<  0) & 0x000000FF))      // 合成为一个字
   

/**
 * @brief   接收数据处理
 * @param   *data:  要计算的数据的数组.
 * @param   data_len: 数据的大小
 * @return  void.
 */
void protocol_data_recv(uint8_t *data, uint16_t data_len);

/**
 * @brief   初始化接收协议
 * @param   void
 * @return  初始化结果.
 */
int32_t protocol_init(void);

/**
 * @brief   接收的数据处理
 * @param   void
 * @return  -1:没有找到一个正确的命令.
 */
int8_t receiving_process(void);

/**
  * @brief 设置上位机的值
  * @param cmd:命令
  * @param ch: 曲线通道
  * @param data:参数指针
  * @param num:参数个数
  * @retval 无
  */
void set_computer_value(uint8_t cmd, uint8_t ch, void *data, uint8_t num);

#ifdef _cplusplus
}
#endif   

#endif

protocol.c文件内容:

cpp 复制代码
/**
  ******************************************************************************
  * @file    protocol.c
  * @version V1.0
  * @date    2020-xx-xx
  * @brief   野火PID调试助手通讯协议解析
  ******************************************************************************
  */

#include "protocol.h"
#include <string.h>
#include "myapp.h"


struct prot_frame_parser_t
{
    uint8_t *recv_ptr;
    uint16_t r_oft;
    uint16_t w_oft;
    uint16_t frame_len;
    uint16_t found_frame_head;
};

static struct prot_frame_parser_t parser;

static uint8_t recv_buf[PROT_FRAME_LEN_RECV];

/**
  * @brief 计算校验和
  * @param ptr:需要计算的数据
  * @param len:需要计算的长度
  * @retval 校验和
  */
uint8_t check_sum(uint8_t init, uint8_t *ptr, uint8_t len )
{
    uint8_t sum = init;

    while(len--)
    {
        sum += *ptr;
        ptr++;
    }

    return sum;
}

/**
 * @brief   得到帧类型(帧命令)
 * @param   *frame:  数据帧
 * @param   head_oft: 帧头的偏移位置
 * @return  帧长度.
 */
static uint8_t get_frame_type(uint8_t *frame, uint16_t head_oft)
{
    return (frame[(head_oft + CMD_INDEX_VAL) % PROT_FRAME_LEN_RECV] & 0xFF);
}

/**
 * @brief   得到帧长度
 * @param   *buf:  数据缓冲区.
 * @param   head_oft: 帧头的偏移位置
 * @return  帧长度.
 */
static uint16_t get_frame_len(uint8_t *frame, uint16_t head_oft)
{
    return ((frame[(head_oft + LEN_INDEX_VAL + 0) % PROT_FRAME_LEN_RECV] <<  0) |
            (frame[(head_oft + LEN_INDEX_VAL + 1) % PROT_FRAME_LEN_RECV] <<  8) |
            (frame[(head_oft + LEN_INDEX_VAL + 2) % PROT_FRAME_LEN_RECV] << 16) |
            (frame[(head_oft + LEN_INDEX_VAL + 3) % PROT_FRAME_LEN_RECV] << 24));    // 合成帧长度
}

/**
 * @brief   获取 crc-16 校验值
 * @param   *frame:  数据缓冲区.
 * @param   head_oft: 帧头的偏移位置
 * @param   head_oft: 帧长
 * @return  帧长度.
 */
static uint8_t get_frame_checksum(uint8_t *frame, uint16_t head_oft, uint16_t frame_len)
{
    return (frame[(head_oft + frame_len - 1) % PROT_FRAME_LEN_RECV]);
}

/**
 * @brief   查找帧头
 * @param   *buf:  数据缓冲区.
 * @param   ring_buf_len: 缓冲区大小
 * @param   start: 起始位置
 * @param   len: 需要查找的长度
 * @return  -1:没有找到帧头,其他值:帧头的位置.
 */
static int32_t recvbuf_find_header(uint8_t *buf, uint16_t ring_buf_len, uint16_t start, uint16_t len)
{
    uint16_t i = 0;

    for (i = 0; i < (len - 3); i++)
    {
        if (((buf[(start + i + 0) % ring_buf_len] <<  0) |
                (buf[(start + i + 1) % ring_buf_len] <<  8) |
                (buf[(start + i + 2) % ring_buf_len] << 16) |
                (buf[(start + i + 3) % ring_buf_len] << 24)) == FRAME_HEADER)
        {
            return ((start + i) % ring_buf_len);
        }
    }
    return -1;
}

/**
 * @brief   计算为解析的数据长度
 * @param   *buf:  数据缓冲区.
 * @param   ring_buf_len: 缓冲区大小
 * @param   start: 起始位置
 * @param   end: 结束位置
 * @return  为解析的数据长度
 */
static int32_t recvbuf_get_len_to_parse(uint16_t frame_len, uint16_t ring_buf_len, uint16_t start, uint16_t end)
{
    uint16_t unparsed_data_len = 0;

    if (start <= end)
        unparsed_data_len = end - start;
    else
        unparsed_data_len = ring_buf_len - start + end;

    if (frame_len > unparsed_data_len)
        return 0;
    else
        return unparsed_data_len;
}

/**
 * @brief   接收数据写入缓冲区
 * @param   *buf:  数据缓冲区.
 * @param   ring_buf_len: 缓冲区大小
 * @param   w_oft: 写偏移
 * @param   *data: 需要写入的数据
 * @param   *data_len: 需要写入数据的长度
 * @return  void.
 */
static void recvbuf_put_data(uint8_t *buf, uint16_t ring_buf_len, uint16_t w_oft,
                             uint8_t *data, uint16_t data_len)
{
    if ((w_oft + data_len) > ring_buf_len)               // 超过缓冲区尾
    {
        uint16_t data_len_part = ring_buf_len - w_oft;     // 缓冲区剩余长度

        /* 数据分两段写入缓冲区*/
        memcpy(buf + w_oft, data, data_len_part);                         // 写入缓冲区尾
        memcpy(buf, data + data_len_part, data_len - data_len_part);      // 写入缓冲区头
    }
    else
        memcpy(buf + w_oft, data, data_len);    // 数据写入缓冲区
}

/**
 * @brief   查询帧类型(命令)
 * @param   *data:  帧数据
 * @param   data_len: 帧数据的大小
 * @return  帧类型(命令).
 */
static uint8_t protocol_frame_parse(uint8_t *data, uint16_t *data_len)
{
    uint8_t frame_type = CMD_NONE;
    uint16_t need_to_parse_len = 0;
    int16_t header_oft = -1;
    uint8_t checksum = 0;

    need_to_parse_len = recvbuf_get_len_to_parse(parser.frame_len, PROT_FRAME_LEN_RECV, parser.r_oft, parser.w_oft);    // 得到为解析的数据长度
    if (need_to_parse_len < 9)     // 肯定还不能同时找到帧头和帧长度
        return frame_type;

    /* 还未找到帧头,需要进行查找*/
    if (0 == parser.found_frame_head)
    {
        /* 同步头为四字节,可能存在未解析的数据中最后一个字节刚好为同步头第一个字节的情况,
           因此查找同步头时,最后一个字节将不解析,也不会被丢弃*/
        header_oft = recvbuf_find_header(parser.recv_ptr, PROT_FRAME_LEN_RECV, parser.r_oft, need_to_parse_len);
        if (0 <= header_oft)
        {
            /* 已找到帧头*/
            parser.found_frame_head = 1;
            parser.r_oft = header_oft;

            /* 确认是否可以计算帧长*/
            if (recvbuf_get_len_to_parse(parser.frame_len, PROT_FRAME_LEN_RECV,
                                         parser.r_oft, parser.w_oft) < 9)
                return frame_type;
        }
        else
        {
            /* 未解析的数据中依然未找到帧头,丢掉此次解析过的所有数据*/
            parser.r_oft = ((parser.r_oft + need_to_parse_len - 3) % PROT_FRAME_LEN_RECV);
            return frame_type;
        }
    }

    /* 计算帧长,并确定是否可以进行数据解析*/
    if (0 == parser.frame_len)
    {
        parser.frame_len = get_frame_len(parser.recv_ptr, parser.r_oft);
        if(need_to_parse_len < parser.frame_len)
            return frame_type;
    }

    /* 帧头位置确认,且未解析的数据超过帧长,可以计算校验和*/
    if ((parser.frame_len + parser.r_oft - PROT_FRAME_LEN_CHECKSUM) > PROT_FRAME_LEN_RECV)
    {
        /* 数据帧被分为两部分,一部分在缓冲区尾,一部分在缓冲区头 */
        checksum = check_sum(checksum, parser.recv_ptr + parser.r_oft,
                             PROT_FRAME_LEN_RECV - parser.r_oft);
        checksum = check_sum(checksum, parser.recv_ptr, parser.frame_len -
                             PROT_FRAME_LEN_CHECKSUM + parser.r_oft - PROT_FRAME_LEN_RECV);
    }
    else
    {
        /* 数据帧可以一次性取完*/
        checksum = check_sum(checksum, parser.recv_ptr + parser.r_oft, parser.frame_len - PROT_FRAME_LEN_CHECKSUM);
    }

    if (checksum == get_frame_checksum(parser.recv_ptr, parser.r_oft, parser.frame_len))
    {
        /* 校验成功,拷贝整帧数据 */
        if ((parser.r_oft + parser.frame_len) > PROT_FRAME_LEN_RECV)
        {
            /* 数据帧被分为两部分,一部分在缓冲区尾,一部分在缓冲区头*/
            uint16_t data_len_part = PROT_FRAME_LEN_RECV - parser.r_oft;
            memcpy(data, parser.recv_ptr + parser.r_oft, data_len_part);
            memcpy(data + data_len_part, parser.recv_ptr, parser.frame_len - data_len_part);
        }
        else
        {
            /* 数据帧可以一次性取完*/
            memcpy(data, parser.recv_ptr + parser.r_oft, parser.frame_len);
        }
        *data_len = parser.frame_len;
        frame_type = get_frame_type(parser.recv_ptr, parser.r_oft);

        /* 丢弃缓冲区中的命令帧*/
        parser.r_oft = (parser.r_oft + parser.frame_len) % PROT_FRAME_LEN_RECV;
    }
    else
    {
        /* 校验错误,说明之前找到的帧头只是偶然出现的废数据*/
        parser.r_oft = (parser.r_oft + 1) % PROT_FRAME_LEN_RECV;
    }
    parser.frame_len = 0;
    parser.found_frame_head = 0;

    return frame_type;
}

/**
 * @brief   接收数据处理
 * @param   *data:  要计算的数据的数组.
 * @param   data_len: 数据的大小
 * @return  void.
 */
void protocol_data_recv(uint8_t *data, uint16_t data_len)
{
    recvbuf_put_data(parser.recv_ptr, PROT_FRAME_LEN_RECV, parser.w_oft, data, data_len);    // 接收数据
    parser.w_oft = (parser.w_oft + data_len) % PROT_FRAME_LEN_RECV;                          // 计算写偏移
}

/**
 * @brief   初始化接收协议
 * @param   void
 * @return  初始化结果.
 */
int32_t protocol_init(void)
{
    memset(&parser, 0, sizeof(struct prot_frame_parser_t));

    /* 初始化分配数据接收与解析缓冲区*/
    parser.recv_ptr = recv_buf;

    return 0;
}

/**
 * @brief   接收的数据处理
 * @param   void
 * @return  -1:没有找到一个正确的命令.
 */


int8_t receiving_process(void)
{
    uint8_t frame_data[128];         // 要能放下最长的帧
    uint16_t frame_len = 0;          // 帧长度
    uint8_t cmd_type = CMD_NONE;     // 命令类型

    while(1)
    {
        cmd_type = protocol_frame_parse(frame_data, &frame_len);
        switch (cmd_type)
        {
        case CMD_NONE:
        {
            return -1;
        }

        case SET_P_I_D_CMD:                     // 修改P、I、D参数
        {
            uint32_t temp0 = COMPOUND_32BIT(&frame_data[13]);
            uint32_t temp1 = COMPOUND_32BIT(&frame_data[17]);
            uint32_t temp2 = COMPOUND_32BIT(&frame_data[21]);

            float p_temp, i_temp, d_temp;

            p_temp = *(float *)&temp0;
            i_temp = *(float *)&temp1;
            d_temp = *(float *)&temp2;

            PID.Velocity_Kp = p_temp;
            PID.Velocity_Ki = i_temp;
            PID.Velocity_Kd = d_temp;
        }
        break;

        case SET_TARGET_CMD:
        {
            int actual_temp = COMPOUND_32BIT(&frame_data[13]);    // 得到数据

            motorA.Target_Speed = actual_temp;
            motorB.Target_Speed = actual_temp;
        }
        break;
        
        case START_CMD:                   // 启动电机
        {
            
        }
        break;

        case STOP_CMD:
        {
            
        }
        break;

        case RESET_CMD:                    // 复位系统
        {
            HAL_NVIC_SystemReset();
        }
        break;

        case SET_PERIOD_CMD:
        {
            //        uint32_t temp = COMPOUND_32BIT(&frame_data[13]);     // 周期数
            //        SET_BASIC_TIM_PERIOD(temp);                          // 设置定时器周期1~1000ms
        }
        break;

        default:
            return -1;
        }

    }
}

/**
  * @brief 设置上位机的值
  * @param cmd:命令
  * @param ch: 曲线通道
  * @param data:参数指针
  * @param num:参数个数
  * @retval 无
  */
void set_computer_value(uint8_t cmd, uint8_t ch, void *data, uint8_t num)
{
    uint8_t sum = 0;    // 校验和
    num *= 4;           // 一个参数 4 个字节

    static packet_head_t set_packet;

    set_packet.head = FRAME_HEADER;     // 包头 0x59485A53
    set_packet.len  = 0x0B + num;      // 包长
    set_packet.ch   = ch;              // 设置通道
    set_packet.cmd  = cmd;             // 设置命令

    sum = check_sum(0, (uint8_t *)&set_packet, sizeof(set_packet));       // 计算包头校验和
    sum = check_sum(sum, (uint8_t *)data, num);                           // 计算参数校验和

    HAL_UART_Transmit(&huart3, (uint8_t *)&set_packet, sizeof(set_packet), 0xffff);   // 发送数据头
    HAL_UART_Transmit(&huart3, (uint8_t *)data, num, 0xffff);                         // 发送参数
    HAL_UART_Transmit(&huart3, (uint8_t *)&sum, sizeof(sum), 0xffff);                 // 发送校验和
}

/**********************************************************************************************/

其中,需要在int8_t receiving_process(void)函数中修改助手下发指令时,对应的数据处理,如PID控制参数的传递,这里采用了如下代码进行传递:

PID.Velocity_Kp = p_temp;

PID.Velocity_Ki = i_temp;
PID.Velocity_Kd = d_temp;

motorA.Target_Speed = actual_temp;
motorB.Target_Speed = actual_temp;

同时也需要修改void set_computer_value(uint8_t cmd, uint8_t ch, void *data, uint8_t num)函数中的串口句柄,如这里使用的是串口三:&huart3

完成上面的修改后,还需要添加以下内容:

在初始化部分加入如下代码:

cpp 复制代码
__HAL_UART_ENABLE_IT(&huart3, UART_IT_RXNE);
protocol_init();//PID上位机调试助手协议初始化。

在中断文件(stm32f1xx_it.c)中的串口3中断处理函数中加入以下内容:

cpp 复制代码
void USART3_IRQHandler(void)
{
  /* USER CODE BEGIN USART3_IRQn 0 */
    uint8_t dr = __HAL_UART_FLUSH_DRREGISTER(&huart3);
	protocol_data_recv(&dr, 1);
  /* USER CODE END USART3_IRQn 0 */
  HAL_UART_IRQHandler(&huart3);
  /* USER CODE BEGIN USART3_IRQn 1 */

  /* USER CODE END USART3_IRQn 1 */
}

相关头文件自行添加引用即可。

四、PID参数调试

强烈推荐参考:3. PID控制器参数整定 --- [野火]电机应用开发实战指南---基于STM32 文档

其中详细介绍了调试助手的使用,以及相关代码。

首先在main函数的while循环中加入如下内容,实时对上位机下发的数据进行处理:

cpp 复制代码
 while (1)
  {
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
   receiving_process();
  }

在定时器中断处理函数中进行闭环控制,并进行数据上发,与上位机同步。如下对电机A进行参数调试的代码:

cpp 复制代码
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
/*******电机控制周期,每十秒对电机控制一次*********/
    if(htim->Instance == TIM2 )
    {
        
            motorA.speed = get_speed_motorA();
            motorB.speed = get_speed_motorB(); 
            motorA.S += motorA.speed;
            motorB.S += motorB.speed;

            int Speed = motorA.speed;
            set_computer_value(SEND_FACT_CMD, CURVES_CH1,&Speed , 1);     // 给通道 1 发送实际值。
           
            motorA.out = pid_control(motorA.speed,motorA.Target_Speed,PID.Velocity_Kp,PID.Velocity_Ki,PID.Velocity_Kd);
           
        Load(motorA.out, motorB.out);
    }

}

同理进行电机B的参数调试,直至找到合适的参数,然后记录下来。

调试方法可以参考3. PID控制器参数整定 --- [野火]电机应用开发实战指南---基于STM32 文档

中的试凑法进行调试。


总结

通过本文,使减速电机实现了速度闭环控制,利用野火PID调试助手进行PID参数的整定,得到满意的参数。

相关推荐
scan112 小时前
单片机串口接收状态机STM32
stm32·单片机·串口·51·串口接收
Qingniu0113 小时前
【青牛科技】应用方案 | RTC实时时钟芯片D8563和D1302
科技·单片机·嵌入式硬件·实时音视频·安防·工控·储能
Mortal_hhh14 小时前
VScode的C/C++点击转到定义,不是跳转定义而是跳转声明怎么办?(内附详细做法)
ide·vscode·stm32·编辑器
深圳市青牛科技实业有限公司14 小时前
【青牛科技】应用方案|D2587A高压大电流DC-DC
人工智能·科技·单片机·嵌入式硬件·机器人·安防监控
Mr.谢尔比15 小时前
电赛入门之软件stm32keil+cubemx
stm32·单片机·嵌入式硬件·mcu·信息与通信·信号处理
LightningJie15 小时前
STM32中ARR(自动重装寄存器)为什么要减1
stm32·单片机·嵌入式硬件
鹿屿二向箔15 小时前
STM32外设之SPI的介绍
stm32
西瓜籽@16 小时前
STM32——毕设基于单片机的多功能节能窗控制系统
stm32·单片机·课程设计
远翔调光芯片^1382879887218 小时前
远翔升压恒流芯片FP7209X与FP7209M什么区别?做以下应用市场摄影补光灯、便携灯、智能家居(调光)市场、太阳能、车灯、洗墙灯、舞台灯必看!
科技·单片机·智能家居·能源
极客小张19 小时前
基于STM32的智能充电桩:集成RTOS、MQTT与SQLite的先进管理系统设计思路
stm32·单片机·嵌入式硬件·mqtt·sqlite·毕业设计·智能充电桩