因为疫情原因,我小机器人的底层单片机代码没人搞了,没人弄了就得我自己上。硕士时候有点儿基础,现在一边儿做一边儿学,争取用一天时间把机器人的底层STM32代码给搞出来。
雨哥最NB的地方就是学东西和做东西都很快,其中的原因就是雨哥一般是一边儿做事一边儿总结,在学习的时候顺便把工作干了,在工作的时候顺便把知识学了。所以建议各位老铁们多总结,不总结,今天学点儿东西明天就全忘了。
复习的时候写一个博客,把心得跟大家共享:
作为一台AppleZhang的小型智能车(差速轮)的协处理器,单片机的作用就是接受上面工控机的控制信号来驱动各个外设,同时将自己读取的外设信号上传到顶层工控机上。
我的差速小车的底层控制器有几个功能需求:
第一步,我们先使用PWM来控制轮胎,使轮胎能够按照我们的需求进行定功率旋转:这一个过程需要几个GPIO参与:
我们用几组引脚来驱动轮胎,PA15和PB3用来驱动左轮前进后退,PB4和PB6用来驱动右轮前进后退;配置普通GPIO输出的方法比较简单,一共分为4个步骤:
1. 打开负责这个GPIO的APB时钟 2. 定义一个GPIO_InitTypedef的变量 3. 设置这个变量(Pin,Mode,Speed) 4. 使用GPIO_Init(...)函数,对GPIO进行配置;代码如下:
非常重要:在STM32F103系列单片机中,以上四个GPIO默认配置为JTAG调试引脚,所以在机器人控制中会出现GPIO不受控或输出错误的情况,此时,我们要显式的将JTAG模式关闭;代码如下:
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);
GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable,ENABLE);
STM32单片机想要输出PWM波形,需要配置以下几个内容:
1.打开GPIO使能开关,GPIO引脚为复用输出并使能
2.选择一个Timer(例如TIM1),打开使能开关,构建并配置TIM_TimeBaseInitTypedef结构体
3.构建一个TIM_OCInitTypedef(Output Configure)结构体,并配置到定时器通道上
【注意:一个通道被配置为PWM,其他通道也只能配置为PWM,不可做其他用途了】
4.使能Timer的PWM输出功能,各个通道的预装载功能
5.使能Timer
6.通过配置寄存器的CCRX,就能控制PWM的输出了。
参考了平衡小车之家的部分内容(文末有链接)在我这台小机器人上使用了Timer1的CH1和CH4作为输出;配置代码如下:
光定功率旋转轮子对一个机器人来讲肯定不行,所以在这一个小节,我们要使用编码器结合PID算法对机器人进行定速控制。
stm32的定时器中具有编码器模式,编码器模式依赖AB相正交编码器(我小机器人上的编码器)首先对其进行初始化:
1. 首先对Timer和GPIO进行初始化,步骤跟PWM的初始化一样。
2. 使用TIM_EncoderInterfaceConfig(TIMX,MODE,IC1,IC2)将timer配置为正交编码器捕获模式。其中,mode就是为了正交编码器而配置的,这里有几个图讲解了它的原理,其中mode是控制响应哪一跟线的编码,共有三种模式。IC1是选择A,B相的触发边沿,如果配置mode为TIM_EncoderMode_T12(两根线都响应),IC1和2都配置为TIM_ICPolarity_BothEdge,那么也就是说任何一个相的升降都会引起编码器数值变化,其中2根线,每个线1个上升+1个下降,即其数值为单个编码器单边的4倍。
TIM的编码器模式通过判断某一根线电位变化时另一根线的电平来确定电机是正转还是反转。
3. 配置TIM_ICInitTypeDef实例的各项参数,包括滤波参数,滤波参数如下图:
4. 开启Timer整体配置
5. 编写一个Read_Encoder()程序用来获取编码器的信息
完整的编码器初始化程序如下图:
编码器读取程序如图(注意,为了保证符号的准确性,在读取的时候要将TIMX的16位寄存器强制转换为int16,即short类型):
我使用ST-Link实时观测变量发现,我这个小轮子从0开始转,转一圈儿,累计增加的数值为0x331~0x33A;将它变为10进制为:0x331=817 0x33A=826;我们就取个整数820。
轮子转一圈儿的距离很容易计算:
一圈计时器累加值为n,则每个计数对应的轮子移动距离为
即 在我的小车里,这个值为:0.07*3.14/820=,即0.28毫米。
别说,我10块钱买这个小轮儿还挺好的。
很多工程控制都会用到PID,PID的思路很简单,假设我们开车,想把车维持在60km/s的速度上,那么车速如果只有十几而且不加速,那么了我们就要往死里踩油门,当车子嗖一下子窜出去了,我们就得松油门;如果车速是58,59的样子,那么我们就轻轻,或者几乎不踩油门。如果速度是65,那么我们就松开油门,如果速度是120,那么我们就得猛踩刹车。
这就是PID算法中的P因子。我们控制车速,参考的就是当前的速度和目标速度,如果这两个速度差的很大,那么我们就多给点儿油速度小,我们就少给点儿油。我们踩油门的程度就大致符合:
油门=(目标车速-当前车速)X一个固定的力道(Propotion)
这就是PID控制中的P(Percentage)。使用这种方式控制轮子的方式可以称作P算法,现在我们把它实现一下:
在这个程序里,与P值的代码就三句(红框),第一句:计算当前速度与目标速度的差值;第二句:差值乘上一个比例因子;第三句,返回这个结果。这个程序有三个输入,分别为当前速度,目标速度以及最大油门。返回值就是基于当前速度和目标速度计算出的油门数值。
但是在生活中我遇到过这样一个问题:我有一辆小摩托,在我启动小摩托后,为了达到我想要的速度,我肯定是给足了油门,但是此时我的小摩托嗖一下就窜出去了(有一个很大的加速度),骑鬼火的社会人比较喜欢这种加速感,但生活不是鬼火,毕竟车头一翘阎王爷笑。当我发现我的小鬼火要翘头,我得赶快把油门松开。
此时我速度还没达到目标速度,油门还得捏着(P比例因子告诉我要捏住油门),但是因为我不能加速太快了,所以我还得松点儿油门。此时在P因子外,又有一种因子影响了油门,它就是:
微分比例因子(Derivative)
P因子和D因子在一起就构成了PD算法:
油门=P*(目标速度-当前速度)+D*(当前速度-上一时刻的速度)
参考了D值,刚刚的算法我们可以改为:
PID算法中,我们已经掌握了P因子和D因子,当然,这这样可能会导致另一个问题:我上一秒的速度是0,因为加速太快导致第二秒的速度一下子升高到了59km/h,按照常理,按照P因子作用,我此时应该不踩油门了(因为目标速度-当前速度≈0),但是由于D因子的存在(D因子发现我加速太快了),我会猛踩刹车。最后结果就是我速度到59了,踩刹车,速度下降,加速,又踩刹车;这样也会达到目标速度,但是这种加速是非常不稳定的。
所以,我们综合了微分,积分与比例因子,构建了完整的PID函数:
刚才的算法问题在于PID很难调,一般情况下轮子都面临着很强的抖动。所以我们换个思路来思考控制速度的问题(当然不是说PID不行,这个只是提供了一种更稳定的控制方法,具体采用哪种大家自行思考)
之前我们思考的逻辑,或者说数学模型是关于速度和油门之间的关系,速度矩阵是自变量,踩油门的力道是一个因变量。现在我们自变量不变,还是速度矩阵,而因变量我们变为踩油门力道的变化值,用一个公式来表示:
公式的左边,就是踩油门力度的增量。举个例子,按照PID算法,如果我们速度值很低,而目标速度很高,那么我们就要通过:
这就必然会导致我们将油门踩到底,而这种控制,对车来讲是很恐怖的。
现在,我们不直接配置E(油门量)值,而是使用一个参数对油门的变化量进行调节:
我们就构建了这样一个十分简单的代码:
当然,对这个模型的处理需要一些数学公式和建模的推导,能我后续有时间会专门发博客来进行建模。但是从我目前小机器人的运行状态情况来看,这个模型所控制轮胎的稳定性要超越所有PID,它的不足之处就在于超低速状态下(低于0.05m/s以下),轮胎速度的控制性不好,出现了动一下停一下的现象。
在上面的小节中我们提到了一个机器人速度,我们目前已经获得了机器人编码器的数值,但是在单片机中,我们很难知道机器人行走了x米的距离花了多长时间。所以,为了让机器人能够准确的获得自己的速度值,我们就要参考一个小学学到的公式:
(速度=路程/时间)
现在路程我们可以获得了,至于时间,我们就要通过定时器获得。
我们在前面已经用掉了单片机的Timer1(PWM输出),Timer2和Timer4(获取编码器)。现在STM32F103C8T6这个单片机中还有两个定时器可以选用:
我们使用Timer3来获取中的t值。
Timer3的初始化跟之前一样,而且timer3实现的是timer的基本功能,不需要GPIO的参与。我们只需要为timer3设置好时钟周期,同时设置好延时即可。
初始化代码如下:
比较关键的一个地方是中断的处理函数,我们在这个函数中读取轮胎的实际速度(在这个函数中,我们可以控制一个小灯,实现对CPU占用的显示,在一个定频处理周期中,CPU占用的时间越长,灯就越亮):
接下来我们就可以多主控制函数进行编写了,可以把读取速度和速度控制的函数都放进来:
重要!注意: 我们在计算速度时所采用的数据类型为浮点型(float或double),在定义比例因子宏的时候(例如PULSE_PERMETER),我们最好将数据定位为浮点,如3700.0,3700f,否则整形和整形相乘,单片机会默认数据为整形而忽略掉小数点,造成速度始终为0,或在0,1之间跳变。
单片机端串口的底层逻辑如下:
接下来我们一步一步看:
STM32的发送和接收是通过数据寄存器USART_DR来实现的,这是一个双寄存器,包含了TDR和RDR。当向该寄存器写数据时,串口就会自动发送;当收到数据时,也存在该寄存器中。void USART_SendData(...)的意思就是想USART_DR寄存器写入数据。USART_ReceiveData(...)相反。
串口状态32位寄存器USART_SR【State Register】反映了串口的状态,它的各个位代表的内容如下图:
使用这个函数,就可以获取串口寄存器各个位的数值:
FlagStatus USART_GetFlagStatus(USART_TypeDef* USARTx, uint16_t USART_FLAG);
这里关注两位:Bit5:RXNE;Bit6:TC
RXNE(读数据寄存器非空):当该位被置一时,说明已经有数据被接收了,并且可以读出来了。此时应尽快读取USART_DR。
读取USART_DR或向该位写0,都可以清除该位。
TC(发送完成):当该位被置位时,说明USART_DR中的数据已经被发送完成了。如果设置了这个位的中断,则会产生中断。
清零该位的两种方法:a、读取USART_SR,写USART_DR。b、直接向该位写0。
初始化串口很简单,一共分为三个步骤:
完整的初始化代码如下图:
现在我们初始完成了串口,我们就要进行数据的交互与处理了。
如果我们想让单片机与上位机进行通讯,我们就必须要使用某种协议, 为了构建单片机与上位机都能够理解的通讯信息,我们提出一个基于串口的通讯协议,我们就把它称为AppleZhang协议。
这个协议是一个半双工的协议,首先,上位机向单片机发送一个数据段请求,然后,下位机基于上位机的请求,输出相应数据:
因为串口传输数据的时候可能会出现误码,所以我们要适当改良AppleZhang协议,添加一个误码重置机制:
完整的单片机端的底层串口处理函数如下(注意标出颜色的为关键函数):
在这里我们来写一个程序作为例子:
在这里有一个很有意思的现象,注意我画框的地方;由于STM32单片机为小端模式,所以低位在前高位在后,例如,我encoder的数值为10(0x0000000a),那么memcpy后赋值出的4个Byte为 0a 00 00 00。这个在解析的时候要着重处理,或者干脆就一个字节一个字节的赋值。
由此,串口交互的框架就基本完成了,可以在这个框架中添加自己的上层协议,比如超声波之类的,也可以向我一样定义几个宏:
步进电机采用了28BYJ48,它一共有4个相,通过循环控制各个相线的高低就能够驱动电机转动,同时可以很方便的获得角度。
我们采用4个引脚控制这个电机,分别为B12,B13,B14,B15。这个电机可以用来控制机器人头部的转动,从而获得更灵活的摄像头视角。
我们采用定速的方式控制这个电机,首先,我们做一个变量存放这个电机当前旋转的角度:然后再做一个变量存放电机的目标角度。同时,因为这个电机有4个相线,我们要定义一个名为phase的变量,告诉电机当前哪个相的位置被激活。
然后对GPIO进行初始化:
编写一个控制头部转动的程序,原理就是根据目标位置和当前位置的差异,控制头部旋转:
然后在周期控制函数里反复调用这个转头的过程:
就可以了。
这一部分没什么可说的,我的LED两个引脚分别连到了PA2和PA3上,初始化一下,直接设置GPIO就好了。
电池电量的读取可以依赖于电池电压(虽然直接读电压不准,但是电压基本能反映电量而且比较方便)。
以12V锂离子电池为例,磷酸铁锂电池的电压一般在11.3~12.6V之间,我们将12.6V计为100,代表满电;将11.3V计为0,代表没电。我们就可以构建一个简单的线性模型:
, Bat代表电量百分比,V代表电池电压。
原则上我们把电池电压和电量的关系带入函数:
就可以算出来了。但是实际上,单片机只能读取3V以下的电压,所以,我们要使用某种方法,先对电池电压进行分压后再进行操作。
电池电量的检测硬件配置可以如下:
这样的配置就将12V分压了20/100倍为2.4V。
单片机以内部参考的ADC有12位分辨率,以最大量程为3.3V为例,其测量电压范围在3.3/(0xfff)=3.3/4095=0.805mV
11.3V/5=2260mV,12.6V/5=2520mV, 所以测量电压的最高分辨率为:0.3%,这个也是符合预期的,因为在电压测量上,电池本身硬件的误差率就会超过10%左右。
下面我们来进行ADC的软件配置:
单片机ADC的启动大致分为以下几个过程:
1. 根据ADC通道配置单片机的GPIO,GPIO的GPIO_Mode要配置为GPIO_MODE_AIN
2. 使能GPIO与ADC时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA |RCC_APB2Periph_ADC1 , ENABLE );
3. 配置ADC_InitTypedef结构体信息,包括:
4. ADC去初始化(ADC_DeInit())
5. ADC和GPIO初始化(ADC_Init和GPIO_Init)
6. 校准ADC
完整初始化代码如下:
使用ADC读取电池电压的代码如下:
参考文档:
http://m.elecfans.com/article/817206.html
https://blog.csdn.net/qq_38721302/article/details/83447870
https://www.cnblogs.com/wuhoudezhenyu/p/11839697.html
https://wenku.baidu.com/view/a92569d9168884868762d6d8.html
https://www.cnblogs.com/wuhoudezhenyu/p/11839697.html
https://blog.csdn.net/wang328452854/article/details/50579832