如何开发智能小车的驱动器软件

驱动器软件总体架构

先来看整个机器人系统的整体架构,不难看出,其实驱动器软件的上下游是硬件外设和中间件软件,那想要开发驱动器软件就需要对上下游非常熟悉吗?不一定,但是作为一个嵌入式工程师需要懂得如何和对应开发者进行交流。

首先对于硬件开发者,他们会给我们提供一份原理图,以及一般性的我们需要知道芯片的使用手册,这样才能辅助我们去正确的使用硬件的各个IO,避免如最低级的正负级接反等问题;对于中间件软件,我们则需要和他们一起制定一个协议,这个协议约定了双方如何进行正确的数据交互。

说到这里,大家就很明确对于此处的嵌入式开发者而言需要做什么事情了。一方面要正确的使用板卡资源和外设资源,并对其数据进行加工处理来做一些开发者企图实现的功能。另一方面要将处理的数据与上游进行交互从而达到更高效的数据利用,当然了必要时也需要接收来自上游数据的指令。

此处举一个例子来看

FreeRTOS 快速上手

搭建软件环境

以Keil为例

https://www.keil.com/download/product

Stm32配置keil5编译版本

FreeRTOS使用

RTOS全称为 Real Time Operation System,即实时操作系统。RTOS强调的是实时性,又分为硬实时和软实时。硬实时要求在规定的时间内必须完成操作,不允许超时;而软实时里对处理过程超时的要求则没有很严格。RTOS的核心就是任务调度。

FreeRTOS是RTOS的一种,尺寸非常小,可运行于微控制器上。微控制器是尺寸小,资源受限的处理器,它在单个芯片上包含了处理器本身、用于保存要执行的程序的只读存储器(ROM或Flash)、所执行程序需要的随机存取存储器(RAM),一般情况下程序直接从只读存储器执行。

暂时无法在飞书文档外展示此内容

任务创建

关于任务创建,FreeRTOS 提供了 API 给我们使用。格式如下

   /* 创建 DemoTaskCreate 任务 */
DemoTaskCreate_Handle = xTaskCreateStatic((TaskFunction_t   )DemoTaskCreate,        //任务函数
                    (const char*    )"DemoTaskCreate",      //任务名称
                    (uint32_t       )128,   //任务堆栈大小
                    (void*          )NULL,              //传递给任务函数的参数
                    (UBaseType_t    )3,     //任务优先级
                    (StackType_t*   )DemoTaskCreate_Stack,  //任务堆栈
                    (StaticTask_t*  )&DemoTaskCreate_TCB);  //任务控制块
if(NULL != DemoTaskCreate_Handle)/* 创建成功 */
vTaskStartScheduler();   /* 启动任务,开启调度 */

代码实例

//Task priority    //任务优先级
#define START_TASK_PRIO        1

//Task stack size //任务堆栈大小        
#define START_STK_SIZE         256  

//Task handle     //任务句柄
TaskHandle_t StartTask_Handler;

//Task function   //任务函数
void start_task(void *pvParameters);

//Main function //主函数
int main(void)
{ 
  systemInit(); //Hardware initialization //硬件初始化
        
        //Create the start task //创建开始任务
   xTaskCreate((TaskFunction_t )start_task,             //Task function   //任务函数
                (const char*    )"start_task",          //Task name       //任务名称
                (uint16_t       )START_STK_SIZE,        //Task stack size //任务堆栈大小
                (void*          )NULL,                  //Arguments passed to the task function //传递给任务函数的参数
                (UBaseType_t    )START_TASK_PRIO,       //Task priority   //任务优先级
                (TaskHandle_t*  )&StartTask_Handler);   //Task handle     //任务句柄                                            
   vTaskStartScheduler();  //Enables task scheduling //开启任务调度        
}
 
//Start task task function //开始任务任务函数
void start_task(void *pvParameters)
{
    taskENTER_CRITICAL(); //Enter the critical area //进入临界区
        
    //Create the task //创建任务
    xTaskCreate(Balance_task,  "Balance_task",  BALANCE_STK_SIZE,  NULL, BALANCE_TASK_PRIO,  NULL);        //Vehicle motion control task //小车运动控制任务
    xTaskCreate(MPU6050_task, "MPU6050_task", MPU6050_STK_SIZE, NULL, MPU6050_TASK_PRIO, NULL);            //IMU data read task //IMU数据读取任务 
    xTaskCreate(show_task,     "show_task",     SHOW_STK_SIZE,     NULL, SHOW_TASK_PRIO,     NULL);        //The OLED display displays tasks //OLED显示屏显示任务
    xTaskCreate(led_task,      "led_task",      LED_STK_SIZE,      NULL, LED_TASK_PRIO,      NULL);        //LED light flashing task //LED灯闪烁任务
    xTaskCreate(pstwo_task,    "PSTWO_task",    PS2_STK_SIZE,      NULL, PS2_TASK_PRIO,      NULL);        //Read the PS2 controller task //读取PS2手柄任务
    xTaskCreate(data_task,     "DATA_task",     DATA_STK_SIZE,     NULL, DATA_TASK_PRIO,     NULL);        //Usartx3, Usartx1 and CAN send data task //串口3、串口1、CAN发送数据任务
        
    vTaskDelete(StartTask_Handler); //Delete the start task //删除开始任务

    taskEXIT_CRITICAL();            //Exit the critical section//退出临界区
}

运行状态

运动控制与PID使用

PID 原理介绍

为了更好的控制机器人行走,电机控制算法通常使用PID算法,PID(proportion integration differentiation)其实就是指比例,积分,微分控制。当我们得到系统的输出后,将输出经过比例,积分,微分3种运算方式,重新叠加到输入中,从而控制系统的行为,让它能精确的到达我们指定的状态。基本形态如下图所示:

比例环节是对偏差瞬间作出反应,偏差只要产生,控制器立即产生控制作用, 使控制量向减少偏差的方向变化。

控制作用的强弱数值表示为误差值与比例系数Kp的乘积,取决于比例系数Kp, 比例系数Kp越大,控制作用越强, 则过渡过程越快, 控制过程的静态偏差也就越小; 但Kp越大,也越容易产生振荡, 就会破坏系统的稳定性。

所以, 比例系数Kp选择须恰当, 以期达到过渡时间少、静态偏差小而又稳定的效果。

积分部分的表达式为误差积分值与比例系数Ki的乘积, 从式中可看出,只要存在偏差, 则它的控制作用就不断的增加。 只有在偏差e(t)=0时, 它的积分才是一个常数,控制作用才是一个不会增加的常数。 可见,积分部分可以消除系统的偏差。

积分环节的调节作用虽然会消除静态误差,但也会降低系统的响应速度,增加系统的超调量。积分常数Ti越大,积分的积累作用越弱,这时系统在过渡时不会产生振荡; 但是增大积分常数Ti会减慢静态误差的消除过程,消除偏差所需的时间也较长, 但可以减少超调量,提高系统的稳定性。

实际的控制系统中除了消除静态误差外,还要求加快调节过程。在偏差出现的瞬间,或在偏差变化的瞬间, 不但要对偏差量做出立即响应(比例环节的作用), 而且要根据偏差的变化趋势预先适当的纠正。为了实现这一功能作用,须在 PI 控制器的基础上加入微分环节,形成 PID 控制器。

微分环节的作用是阻止偏差的变化。它是根据偏差的变化趋势(变化速度)进行控制。偏差变化的越快,微分控制器的输出就越大,并能在偏差值变大之前进行修正。微分作用的引入, 将有助于减小超调量, 克服振荡, 使系统趋于稳定, 它加快了系统的跟踪速度。

大家可以通过以下案例更加直观性的通过ROS的视角来看PID的原理和作用。

https://gitee.com/xiaobairisk/ros-pid-controller

参考链接:https://zhuanlan.zhihu.com/p/406496635

工程实例

在电机控制中,我们一般性的会将轮速转变为PWM去计算,那么如何获取PWM以及发布目标PWM变成了一个更加直接的问题。

origincar_controller 中使用了PI运算。通过获取编码器值(当前PWM值),以及设定的目标PWM值进行PI调速。

首先需要解决当前PWM值获取。

// 获取当前编码器值
//编码器原始数据转换为车轮速度,单位m/s
MOTOR_A.Encoder= Encoder_A_pr*CONTROL_FREQUENCY*Wheel_perimeter/Encoder_precision;  
MOTOR_B.Encoder= Encoder_B_pr*CONTROL_FREQUENCY*Wheel_perimeter/Encoder_precision;  
MOTOR_C.Encoder= Encoder_C_pr*CONTROL_FREQUENCY*Wheel_perimeter/Encoder_precision; 
MOTOR_D.Encoder= Encoder_D_pr*CONTROL_FREQUENCY*Wheel_perimeter/Encoder_precision;

对于阿克曼结构车辆而言,该如何获取目标位置呢?其中需要对目标速度进行轮速解算,也就是计算出最终需要达到的PWM值。

// 结算目标PWM值
{
    float R, Ratio=636.56, AngleR, Angle_Servo;

    //对于阿克曼小车Vz代表右前轮转向角度
    AngleR=Vz;
    R=Axle_spacing/tan(AngleR)-0.5f*Wheel_spacing;
    
    //前轮转向角度限幅(舵机控制前轮转向角度),单位:rad
    AngleR=target_limit_float(AngleR,-0.49f,0.32f);
    
    //运动学逆解
    if(AngleR!=0)
    {
        MOTOR_A.Target = Vx*(R-0.5f*Wheel_spacing)/R;
        MOTOR_B.Target = Vx*(R+0.5f*Wheel_spacing)/R;                        
    }
    else 
    {
        MOTOR_A.Target = Vx;
        MOTOR_B.Target = Vx;
    }
    //舵机PWM值,舵机控制前轮转向角度
    Angle_Servo    =  -0.628f*pow(AngleR, 3) + 1.269f*pow(AngleR, 2) - 1.772f*AngleR + 1.573f;
    Servo=SERVO_INIT + (Angle_Servo - 1.572f)*Ratio;

    
    //车轮(电机)目标速度限幅
    MOTOR_A.Target=target_limit_float(MOTOR_A.Target,-amplitude,amplitude); 
    MOTOR_B.Target=target_limit_float(MOTOR_B.Target,-amplitude,amplitude); 
    MOTOR_C.Target=0;                         //没有使用到
    MOTOR_D.Target=0;                         //没有使用到
    Servo=target_limit_int(Servo,800,2200);   //舵机PWM值限幅
}

PI计算

int Incremental_PI (float Encoder,float Target)
{         
     static float Bias,Pwm,Last_bias;
     Bias=Target-Encoder;         //计算偏差
     Pwm+=Velocity_KP*(Bias-Last_bias)+Velocity_KI*Bias; 
     if(Pwm>16700)Pwm=16700;
     if(Pwm<-16700)Pwm=-16700;
     Last_bias=Bias;             //保存上一次偏差 
     return Pwm;    
}

人机显示屏交互

人机交互在驱动板中的体现很多,比如LED等、蜂鸣器、用户按键、继电器等,但是其中最为直观的外设当为OLED显示屏。

首先介绍为什么要引入OLED显示屏,从设计之初的考虑来看,最重要的一个原因是方便大家调试MCU代码,如大家可以看到当前状态下关键传感器信息,如陀螺仪等;此外也可以显示一些控制器传下来的信息,如WIFI0的IP地址。

接下来介绍一下OLED的主要优点:

  • OLED 显示屏具备主动发光的特点,几乎没有视角限制,视角一般可达到 170 度,具有较宽的视角,从侧面也不会失真;

  • OLED 显示屏低温特性好,在零下 40 摄氏度都能正常显示;

  • OLED 显示屏的响应时间很快,大约是几微秒到几十微秒;

SSD1306驱动芯片实例介绍

以OriginCar使用的0.96寸SSD1306 OLED显示屏为例,以下为其原理图

如何开发显示屏

此处给出参考链接:SSD1306开发介绍,更关键的一点是在目前的调试开发中,大家该如何做二次开发。

在origincar_controller工程中,已经给大家封装好了使用函数

//oled.h
void OLED_WR_Byte(u8 dat,u8 cmd);            
void OLED_Display_On(void);
void OLED_Display_Off(void);
void OLED_Refresh_Gram(void);                                                                          
void OLED_Init(void);
void OLED_Clear(void);
void OLED_DrawPoint(u8 x,u8 y,u8 t);
void OLED_ShowChar(u8 x,u8 y,u8 chr,u8 size,u8 mode);
void OLED_ShowNumber(u8 x,u8 y,u32 num,u8 len,u8 size);
void OLED_ShowString(u8 x,u8 y,const u8 *p);

接下来给出显示实例

// show.c
void oled_show(void)
{  

     ...
     //显示屏第1行显示内容//
    OLED_ShowString(0,0,"Akm "); 

     //阿克曼、差速、四驱、履带车显示陀螺仪零点
     OLED_ShowString(55,0,"BIAS");
     if( Deviation_gyro[2]<0)  OLED_ShowString(90,0,"-"),OLED_ShowNumber(100,0,-Deviation_gyro[2],3,12);  //Zero-drift data of gyroscope Z axis
     else                      OLED_ShowString(90,0,"+"),OLED_ShowNumber(100,0, Deviation_gyro[2],3,12);        //陀螺仪z轴零点漂移数据        
    
     ...
     
     OLED_Refresh_Gram();
}

实际开发中大家需要在类似本例中oled_show中使用OLED_ShowString/OLED_ShowNumber即可,注意在最后需要刷新一次OLED内容OLED_Refresh_Gram;

串口通信

串口通讯(Serial Communication)是一种设备间非常常用的串行通讯方式,因为它简单便捷,因此大部分电子设备都支持该通讯方式,其通讯协议可分层为协议层和物理层。物理层规定通信协议中具有机械、电子功能的特性,从而确保原始数据在物理媒体的传播;协议层主要规定通讯逻辑,统一双方的数据打包、解包标准。

物理层

RS232是一种串行数据传输形式,称其为串行连接,最经典的标志就是 9 针孔的 DB9 电缆RS232电压表示逻辑 1 ,0的范围大极大的增强了容错率,主要用于工业设备直接通信。

两个通讯设备的“DB9 接口”之间通过串口信号线建立起连接,串口信号线中使用“RS-232 标准”传输数据信号。由于 RS-232 电平标准的信号不能直接被控制器直接识别,所以这些信号会经过一个“电平转换芯片”转换成控制器能识别的“TTL 标准”的电平信号,才能实现通讯。

USB转串口:主要用于设备(如STM32)与其他设备通信。

电平转换芯片一般有CH340、PL2303、CP2102、FT232,使用的时候电脑要按照电平转换芯片的驱动(虚拟出一个串口)

协议层

串口通讯的协议层中,规定了数据包的内容,它由启始位、主体数据、校验位以及停止位组成,通讯双方的数据包格式要约定一致(一样的起始位 数据 校验位 停止位)才能正常收发数据

  • 通讯的起始和停止信号

串口通讯的一个数据包从起始信号开始,直到停止信号结束。数据包的起始信号由一个逻辑 0 的数据位表示,而数据包的停止信号可由 1 或 2 个逻辑 1 的数据位表示

1个停止位:停止位位数的默认值。

2个停止位:可用于常规USART模式、单线模式以及调制解调器模式。

  • 有效数据

在数据包的起始位之后紧接着的就是要传输的主体数据内容,也称为有效数据,有效数据的长度常被约定为 5、6、7 或 8 位长

  • 数据校验

偶校验:校验位使得一帧中的7或8个LSB数据以及校验位中1的个数为偶数。

例如:数据=00110101,有4个1,如果选择偶校验(在USART_CR1中的PS=0),校验位将是0,最后数据检验如果数据有偶数个1则数据传输没有出错(但不是绝对的,如果同时两个数据为发送错误(0变成1)则还是偶数个1)

奇校验:此校验位使得一帧中的7或8个LSB数据以及校验位中1的个数为奇数。 例如:数据=00110101,有4个1,如果选择奇校验(在USART_CR1中的PS=1),校验位将是1,最后数据检验如果数据有奇数个1则数据传输没有出错,但同样不是绝对的(同时两个1变成0)

实例说明

以origincar_controller为例,使用USB转串口的方式,将OriginCar主板与电脑PC连接,并在电脑端打开串口助手,即可接收到来自OriginCar主板的如下数据,关于数据解析待下节分享。

此处大家应该思考一个问题,数据是如何发出来的?

void data_task(void *pvParameters)
{
   u32 lastWakeTime = getSysTickCnt();
        
   while(1) {        
        vTaskDelayUntil(&lastWakeTime, F2T(RATE_20_HZ));
        data_transition(); 
        USART1_SEND(); //串口1发送数据
        USART3_SEND(); //串口3(ROS)发送数据
        USART5_SEND(); //串口5发送数据
        CAN_SEND();    //CAN发送数据        
    }
}

首先如上所示,OriginCar主板基于 FreeRTOS进行周期性的数据发送,即data_task 函数,接下来以USART3_SEND为例看数据如何发送。

void USART3_SEND(void)
{
    unsigned char i = 0;        
    for(i=0; i<24; i++) {
        usart3_send(Send_Data.buffer[i]);
    }         
}

如上,此处只是将Send_Data的数据给了usart3_send函数,那么Send_Data数据是什么呢?其实就是协议层封装数据啦。再看usart3_send

void usart3_send(u8 data)
{
    USART3->DR = data;
    while((USART3->SR&0x40)==0);        
}

到此处,大家直接思考的问题就变成了USART3是什么?USART3->DRUSART3->SR是什么?

此处其实使用到了STM32固件库编程方式,大家可以通过查看固件库手册来对寄存器进行操作。

状态寄存器:USARTX->SR

其用于描述USART的工作状态,为编程者提供一个串口的实时状态,一般而言,发送时需要判断上一帧有没有发送完毕;接收时需要判断一帧数据有没有接收完毕,二者需要一个标志位进行状态表示,这其中的标志就在此寄存器中。

数据寄存器:USARTX->DR

发送和接收虽然是两个动作,但是在单片机内部是一个数据寄存器,这两个操作的唯一区别方法就是,执行写操作就是发送数据寄存器(TDR),执行读操作的时候就是接受数据寄存器(RDR)。这也就解释了为什么上面的代码中,读和写都是使用的DR寄存器。