树莓派智能小车
树莓派智能小车
软件部分
准备以下软件:
-
SDFormatter:格式化sd卡
-
vscode:pc和树莓派进行通讯使用
-
VNC Viewer:树莓派界面查看
1 为SD卡安装并烧录树莓派系统
参考:
如果SD卡之前存储过数据,需要先格式化:
-
将SD卡插入读卡器中,再把读卡器插入电脑。
-
打开系统烧录器Raspberry Pi Imager,操作系统选择Raspberry Pi OS(32-bit)
-
选择要烧录到的SD卡
-
点击齿轮图标,配置烧录到SD卡上的系统
-
烧录之前的配置:
- 开启SSH连接【通过ssh来实现pc和树莓派之间的通讯。树莓派默认不开启ssh,在电脑上打开boot分区,在里面新建一个名字为ssh的空文件夹,树莓派系统由此知道在启动系统的时候要开启ssh连接。(这一步在烧录之后做)】
- 设置树莓派wifi上网【树莓派在开启的时候必须先连上wifi(必须和pc连接的同一个wifi),才能够通过ssh和pc通讯。树莓派上的部分软件的安装也需要网络。如果没有wifi,则可以用电脑创建一个个人热点,然后树莓派连接个人热点实现和pc的通讯及网络的访问。(这一步在烧录之后做)建议使用个人热点】
-
烧录后的配置:
-
在电脑上打开烧录完成后新系统的boot分区(在电脑里面是一个磁盘的图标),新建一个
wpa_supplicant.conf
的文件,并在里面写上树莓派的wifi配置命令。配置如下:country=CN ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev update_config=1 network={ ssid="wtp" psk="abc@123_666" key_mgmt=WPA-PSK priority=1 }
-
-
-
开始烧录(裸连很慢)
-
烧录好后,由于需要远程开发,所以需要获取树莓派系统的IP地址。如果前面填写的WIFI设置没有问题,则树莓派上电之后应该会自动联网,在路由器管理之类的地方应该能看到联网的设备。如果没有自动连上网,就要为树莓派接一下显示屏和键盘,在图形化系统里面手动连一下WIFI
2 配置开发环境
2.1 VScode配置SSH连接
在Remote SSH插件中配置SSH连接:
2.2 安装C和Python环境
第一次接触嵌入式编程,这里把C和Python编写嵌入式代码的方式都尝试了下。对树莓派的GPIO口编程主要有2个库:
- wiringPi库:适用于C、C++、Python的库,板载编码(BROAD)或wiringPi编码来标识引脚。
- RPi.GPIO库:适用于Python的库,使用树莓派的BCM(Broadcom)编码来标识引脚。
参考:树莓派4B-WiringPi库的安装和使用 (C和Python版)_树莓派4b安装wiringpi_电子芯吧客的博客-CSDN博客
3 GPIO编程
全程采用的都是针对GPIO引脚编程,控制和读取GPIO引脚的状态。
-
采用wiringPi库编程:基于树莓派的WiringPi库硬件初始化及GPIO口详解_贞君老凡的博客-CSDN博客
-
采用RPI.GPIO库编程:树莓派硬件编程——(一)用RPi.GPIO库输出信号_创客老师Apple的博客-CSDN博客基于树莓派的WiringPi库硬件初始化及GPIO口详解_贞君老凡的博客-CSDN博客
重要依据就是树莓派3B的GPIO引脚图:
可以使用命令在终端查看:
gpio read 引脚号 # 查看某个引脚的电位,默认是wPi编码,加-g参数是BCM编码
gpio readall # 查看当前所有引脚信息和电位情况
注意:
-
GPIO扩展板上的铭文是BCM编码,其中一般用Python语言是用BCM编码,用C语言一般是用wiringPi编码
-
对GPIO引脚编程前,只需要执行1次且仅1次初始化引脚的操作:
- wiringPi库是:
wiringPiSetup()
(使用wPi编码)或wiringPiSetupGpio()
(使用BCM编码) - RPI.GPIO库是:
GPIO.setup()
- wiringPi库是:
-
工作结束后,最好将引脚的电位复原。使用RPI.GPIO库可以很方便地使用
GPIO.cleanup()
实现这一点,但是wiringPi库就得自己手写 -
Python的RPI.GPIO库比C语言的wiringPi库写的更好,在一些PWM控制等方面效果更好,也更稳定。并且Python可用的库多呀!!
4 多线程
本项目使用树莓派的Raspberry Pi OS,它是基于Debian的操作系统,因此我使用UNIX环境下的pthread线程库来编写多线程程序。
4.1 终止线程的编写
参考:
异步通信:进程不知道信号何时到达,也不必通过任何操作来等待信号的到达。
通过信号机制来实现异步通信:软中断信号(signal,又简称为信号)用来通知进程发生了异步事件。在软件层次上是对中断机制的一种模拟,在原理上,一个进程收到一个信号与处理器收到一个中断请求可以说是一样的。信号是进程间通信机制中唯一的异步通信机制。
在POSIX兼容平台上,我们使用键盘的CTRL+C来触发SIGINT
信号(软件中断,又称信号)来终止当前进程。由于SIGINT
是可以被捕获的,也就是会执行信号处理函数的,故按照信号处理函数逻辑,可能进程不会退出,即不一定能终止,所以要处理好进程退出的exit(0)
。
我用的是重写SIGINT
信号的信号处理函数,在其中完成电机停止、舵机复位、引脚清理、关闭各线程等工作,然后用exit(0)
退出整个进程。
关闭各线程则是通过遍历各线程的TID来调用pthread_cancel()
实现的。
附常见信号含义:
SIGHUP 终止进程 终端线路挂断
SIGINT 终止进程 中断进程
SIGQUIT 建立CORE文件终止进程,并且生成core文件
SIGILL 建立CORE文件 非法指令
SIGTRAP 建立CORE文件 跟踪自陷
SIGBUS 建立CORE文件 总线错误
SIGSEGV 建立CORE文件 段非法错误
SIGFPE 建立CORE文件 浮点异常
SIGIOT 建立CORE文件 执行I/O自陷
SIGKILL 终止进程 杀死进程
SIGPIPE 终止进程 向一个没有读进程的管道写数据
SIGALARM 终止进程 计时器到时
SIGTERM 终止进程 软件终止信号
SIGSTOP 停止进程 非终端来的停止信号
SIGTSTP 停止进程 终端来的停止信号
SIGCONT 忽略信号 继续执行一个停止的进程
SIGURG 忽略信号 I/O紧急信号
SIGIO 忽略信号 描述符上可以进行I/O
SIGCHLD 忽略信号 当子进程停止或退出时通知父进程
SIGTTOU 停止进程 后台进程写终端
SIGTTIN 停止进程 后台进程读终端
SIGXGPU 终止进程 CPU时限超时
SIGXFSZ 终止进程 文件长度过长
SIGWINCH 忽略信号 窗口大小发生变化
SIGPROF 终止进程 统计分布图用计时器到时
SIGUSR1 终止进程 用户定义信号1
SIGUSR2 终止进程 用户定义信号2
SIGVTALRM 终止进程 虚拟计时器到时
4.2 线程编写注意事项
(1)线程函数的格式可以随便,不一定要是void* thread_func(void* args)
,如果你知道要传入什么类型的参数,返回什么类型的值,也可以写成ret_type thread_func(arg_type args)
。
(2)区分线程内部代码和与线程内部代码在同一个文件中的其他代码。其他代码和该作为线程执行的函数之间无关系,因此其他代码的执行是在调用这段代码的线程中执行的。
(3)注意向线程传参的值传递和地址传递改变变量的问题。如
void* motor_thread(void* args) {
struct MotorParam param = *(struct MotorParam*)args; // 获取参数,转换为对应的类型
param.key_pressed = 'W';// 修改按键为前进
}
上面这段代码对args
所对应的结构体的修改是无效的,因为解引用出来的变量被赋给了一个motor_thread线程的栈上变量param
,因此做出的修改是对args
所对应的结构体的拷贝的修改,不是对其本身的修改。应该改成以下代码:
void* motor_thread(void* args) {
struct MotorParam* param = (struct MotorParam*)args; // 现在param是指针,不是临时变量的拷贝
param->key_pressed = 'W';// 对地址的修改可以影响到另一个线程
}
(3)死锁问题
注意不要在锁内部写可能会导致当前线程阻塞的代码!如:
void* keyboard_action_thread(void* args) {
struct MotorParam param = *(struct MotorParam*)args;
while (1) {
if (param.dist <= 5) {
param.key_pressed = 'E';
} else {
pthread_mutex_lock(&mutex_param);
param.key_pressed = getchar();
pthread_mutex_unlock(&mutex_param);
}
sem_post(&sem_keyboard);
}
}
以上代码中else
内部的锁mutex_param
上锁后,解锁前有getchar()
会等待用户从终端输入,导致阻塞。应该改为:
void* keyboard_action_thread(void* args) {
unsigned char ch = 'E';
while (1) {
if (param->dist <= 5) {
pthread_mutex_lock(&mutex_param);
param->key_pressed = 'E';
pthread_mutex_unlock(&mutex_param);
} else {
ch = getchar(); // getchar()写锁外面,避免锁内阻塞
pthread_mutex_lock(&mutex_param);
param->key_pressed = ch;
pthread_mutex_unlock(&mutex_param);
}
sem_post(&sem_keyboard);
}
}
如此一来也能避免为线程设置优先级来限制其竞争锁mutex_param
的速度。
4.3 读写锁
参考:
优化本项目中未来拓展可能造成的多读少写场景提高并发度,这里偷懒,直接用的pthread_rwlock_t
。
5 循迹代码
循迹过程中,由于轨道的不规则性,急转弯和平滑的转弯在同样的算法下会有不同的效果。其原因在于无法预知转弯时弯道的曲率半径,导致小车的红外传感器容易冲出弯道导致全部接收管都输出高电平。
解决方案:让小车在一探测到弯道时开始减速通过,并在传感器超出弯道时(这是不可避免的)原路回退,并向后往相反的方向转弯一小段时间(若遇到左转弯,则当超出弯道时,车身向后退并向右后方转弯),然后再尝试慢速前进,直到探测到连续的直道时提速。
“一小段时间”的处理方法:可以利用I2C等总线协议中的电平等待手段:用while
循环来实现即时的等待,替代sleep
。例:
while (digitalRead(tracker.line_m1) == LOW && digitalRead(tracker.line_m2) == LOW); // 等待中间引脚变为低电平
后续发现对于轨道不是很平直的情况需要及时微调行进方向来提高小车循迹准确度,因此将循环等待改为:
while (digitalRead(tracker.line_m1) == LOW || digitalRead(tracker.line_m2) == LOW) {
if (digitalRead(tracker.line_m1) == LOW && digitalRead(tracker.line_m2) == LOW)
delay(100); // 彻底不在轨道上时,需要大转弯
else
; // 略微偏离轨道时,需要即时调整
}
6 TCP C/S通信
参考:
-
UNIX环境下的C/S端。这部分涉及到较多计算机网络的知识,这里不与展开,下次单独开一篇博客讲讲。
-
Qt编写TCP上位机
注意:
- 在写协议栈中规定收发数据的结构体时,需要留心内存对齐和结构体内部成员对齐的问题。本例中由于数据都是1字节的倍数,因此不存在在计网三级项目中遇到的需要规定内存对齐&改变结构体成员顺序手动拼凑位数的问题。但是仍然存在着结构体成员对齐导致结构体整体大小变化的问题,这用强转位结构体的方法在解析传输过来的数据时不会有大问题,但如果用按字节读取的方式则会出现问题。这里回顾一下以前写的博客:C/C++中的内存对齐和位域 - 3的4次方 - 博客园 (cnblogs.com)
- 对于short,int,long,float,double以及对应的无符号类型等存储单元大于一个字节的数据需要考虑字节序问题,对于结构体变量需要考虑字节序问题时,只需要考虑单个元素的字节序问题就好;
- 一般还是少开线程单独负责收发数据,将收发数据的代码安插在产生或变动数据的地方就地完成收发工作也挺好
- 接收和发送要用不同的缓冲区
- 发送参数时最好用带长度的函数,否则0容易被认为是结束符导致发送过的参数被截断(如果不想要传递特定长度的函数,可以设计一种数据格式,比如数据头+数据部分,数据头说明数据部分的长度,然后在接收端每次接收时接收固定长度的数据或者先接收数据头,再根据数据头中说明的数据部分长度接收指定长度的数据部分,科大参考:QT tcpsocket 发送/接收数据_qtcpsocket 最大接收报文_cammyn的博客-CSDN博客)
- 在处理QT上位机和树莓派上下位机的
TcpParam
参数内容同步时,遇到了比较尴尬的情况:由于下位机会不断地和上位机通信来让上位机同步TcpParam
,导致上位机的QCheckBox::stateChanged
信号会一直被触发,该信号连接的槽函数也就跟着触发。而该信号的槽函数的功能就是修改TcpParam
,导致TcpParam
会不断地被改动,导致复选框会不断的自己勾上又取消。解决方案有2种:要么上位机只向下位机写控制信号,下位机只向上位机写显示数据;要么就不写QCheckBox::stateChanged
信号的槽函数,改为将槽函数与其父类QAbstractButton::clicked
信号相连接,使得复选框状态改变时由于没有与之连接的槽函数,于是不会触发任何动作,但是用户点击时会触发点击信号绑定的槽函数,实现对应的动作。
硬件部分
硬件清单:
- 树莓派3B
- 直流电机+L298N电机驱动模块
- 数码管+TM1637驱动芯片
- 超声波传感器HC-SR04
- 红外线传感器TCRT5000
- 有源蜂鸣器
- 温度传感器DHT11
- 舵机SG90
0 PWM控制
PWM的原理和使用参考:
脉冲宽度调制(Pulse-width modulation,PWM),简称脉宽调制,对模拟信号电平进行数字编码的方法,即将数字电压转化为模拟电压,使之不再是突变的,而是渐变的,可以调节为高低电平之间的值。
PWM周期: \(周期(T) = 1 / 频率(f)\),比如:频率50Hz,其周期为20ms。
占空比:\(duty ratio = Ton/(Ton + Toff)\),就是指在一个周期T内,信号处于高电平的时间Ton占据整个信号周期T的百分比,例如方波的占空比就是50%。
分辨率:就是占空比最小能达到多少,如8位的PWM,理论的分辨率就是1 - 255,10位PWM就是:1 - 1023。假如规定:当t=0时,称占空比为0%,t=T时称占空比为100%,那么8位即为把100%的占空比分为256(2的8次方)个档位,10位即为将其分为1024个档位,档位越多,分辨率就越高。
下图中前一周期,占空比20%,后一周期占空比50%。
总之,占空比越大,输出的模拟电压就越大。
需要注意的是,PWM输出的模拟信号不能和数字信号混用,即一个端口不能同时输出数字信号和模拟信号。
另外,PWM信号是无法使用数字信号的digitalRead(引脚号)
来读取电位的,因为数字信号就相当于PWM占空比为100%的情形,PWM占空比小于100时是测得的仍是低电平。
1 TM1637驱动芯片
本项目中使用TM1637芯片来驱动数码管。
原理和使用:
关于TM1637的原理和使用可以参考以下2篇博客:
在看博客之前,先形成以下认识:
微处理器的数据通过两线总线接口和TM1637通信,采用I2C总线协议:
- 在输入数据时当CLK是高电平时, DIO上的信号必须保持不变;
- 只有CLK上的时钟信号为低电平时,DIO上的信号才能改变。【CKL周期中间那一段底电平是用来DIO传输数据的】
- 开始数据传输的条件:CLK为高电平时,DIO由高电平变低电平;
- 结束数据传输的条件:CLK为高电平时,DIO由低电平变为高电平。
- 传输数据:在CLK下降沿后由DIO输入的第一个字节作为一条指令。
- 当正确传输8位数据后TM1637会第九个时钟在DIO管脚上给出一个ACK信号,把DIO拉低。
- 手动捕获硬件自动发出的ACK信号,实现同步。
- 数据传输过程中涉及到3种指令:每个指令都带有start和ack(即代码中手动开启传输数据和结束传输数据)
- 数据指令:后不接数据
- 地址指令:后接数据
- 显示指令:在数据之后
其他就结合代码来看了。
关于了解到的几种总线协议
SPI 和 I2C(IIC)是同步传输协议,特征是:设备有主机(master)和从机(slave)的区分;主机在通讯时发送时钟信号。
SPI 的信号:
- CS: 从机片选信号,表示主机将于该选定的从机通讯。低电平有效。在多从机的系统中,主机控制多条 CS 信号线,每条连接到一个从机。
- SCK: 串行时钟线,从主机连接到每一个从机。
- MOSI: 主出从入数据线。SPI 的数据线上,数据是单向的。因此需两条信号线。
- MISO: 主入从出数据线。
在 SPI 系统中,只允许有一个主机。如果主机在工作时发现任何 CS 线被其他设备拉低,将报告一个系统错误,并退出运行。
I2C 的特点和信号:
I2C 也可以是多从系统,它是通过地址信息来选择从机的。因此,它去了片选信号线。
I2C 允许在同一系统中有多个主机,他通过一套仲裁协议来解决主机的冲突。在一个系统中,允许设备在主机和从机间转换角色。
- SCL: 时钟信号线。
- SDA: 数据信号线。
I2C 通过复杂的协议减少了连接线,并允许多主多从。但它的代价是低的传输速度。
I2C 定义的传输模式:
- 标准模式:最高 100kbit/s,双向;
- 快速模式:最高 400kbit/s,双向,兼容标准模式;
- 快速模式Plus:最高 1Mbit/s,双向,兼容前两种模式;
- 超级快速模式:最高 5Mbit/s,单向(主机只发送),不兼容,不支持多主。
在实际使用中,主要是标准模式和快速模式。
UART 是一种异步串行通讯协议,它通过收发双方精准的本地时钟来定时采样或切换信号电平。
UART 的收发双方是一对一的,且无主从之分。任何一方都可以在任何时刻发送数据。
UART 的双方必须采用事先约定的相同“波特率”(定时标准)来通讯。目前也有一些接收方通过对固定信号的检测来确定波特率的技术,但应用不广泛。
UART 的信号:
- RXD: 接收信号线
- TXD: 发送信号线
通讯的双方是将此二线交叉对接的。
USART 是对 UART 的扩展。它除了支持异步传输之外,也支持同步传输。但目前较少应用。
2 线路接法
一般的元件都会有GND接地线、VCC电源线、以及IO引脚。所要做的就是GND接树莓派上的标有GND的GPIO口,VCC接树莓派上或电源上的xxV口。IO口则自由选择GPIO口。
对于元件的工作电压,需要注意并联和串联导致的分流和分压,比如电机如果由树莓派供电很可能转不动,要用电源供电。
3 电机
所采用的是直流电机*4,小车每侧2个组成一组,由L29N电机驱动模块的一侧引脚控制。其中,每个电机由2根引脚控制,一根负责正转,一根负责反转。可以使用树莓派上的5V电压(驱动能力小,电机转得慢),如果使用外接电源9V或者12V,电机驱动能力强,转得快。
L29N电机驱动板接线如下:
电机接线如下:
4 超声波
原理:
- 使用IO口TRIG触发测距,并发送至少10us的高电平信号;
- 模块自动发送8个40khz的方波,自动检测是否有信号返回;
- 如果有信号返回,通过IO口的ECHO输出一个高电平,高电平持续的时间就是超声波从发射到返回的时间。因此:\(距离=(高电平时间\times 声速(340M/S))\div 2\)
因此本模块使用方法简单,控制口TRIG发送一个10US以上的高电平,就可以在输出口ECHO等待高电平输出。一有输出就可以开始计时,当ECHO变为低电平时就可以结束计时,时间就为此次测距的时间,方可算出距离。
电器参数:
时序图:
5 数码管
将数字按照所给的7段数码管对应一个字节的各位形成的二进制数输出到对应引脚即可。
6 蜂鸣器
7 红外线
由于循迹的路面状态只有黑白两种,且我们不需要太高的精度,红外对管对黑白色的感应比较明显,因此采用红外对管。
组成:红外收发对管由发射管和接收管组成。黑色的是发射管(黑色是因为避免外部干扰),白色的是接收管,中间蓝色(或白色)的是调节灵敏度的电位器(通过螺丝刀旋转来调节)。有一个电源指示灯(通电就亮)和每对收发管对应的输出指示灯(接收到反射信号就亮,没收到就灭)。
原理:红外发射管发射光线到路面后,红外光遇到白底则被反射,接收管接收到反射光,输出低电平,输出指示灯亮;红外光遇到黑线时则被吸收,接收管没有接收到反射光,输出高电平,输出指示灯灭。
注意:
-
使用的是4个红外对外收发管,要达到比较好的循迹效果,轨道宽度最好是在2个电工黑胶布的宽度
-
可能要稍微将管头往前方和四周掰一些
8 温湿度传感器
所采用的的DHT11温度传感器,其采用半双工串行通信。一次完整的数据传输为40bit,高位先出。具体细节见芯片手册。
9 按键
涉及到按键抖动的问题。这个问题在高中物理电学那块和数电中都学过,可以回顾一下。注意本项目的按键是按下为高电平,弹起为低电平。
如何使用代码解决防抖,思路是检测到键被按下后,等待一小段时间。如果键还是被按下(高电平),则就是真正的按下了。同样的,等待直到电位变化稳定为低电平,则是键已弹起。
10 舵机云台
参考:[树莓派系列] 使用WiringPi库入门模拟舵机-SG90(C和Python)-电子芯吧客(www.icxbk.com)
舵机的结构和原理:
舵机主要由马达、减速齿轮、控制电路组成,其中绿色的是电位器,用来确定舵机输出轴角度位置。
其引脚如下:
舵机内部的基准电路的控制信号频率一般为频率为50HZ,即周期是20ms的脉宽调制(PWM)信号,而脉冲的高电平部分一般为0.5ms-2.5ms范围,对于舵盘的位置为0-180度,呈线性变化(\(y=\frac{1}{90}x+1.5\))。也就是说,给舵机输出一定的脉宽,它的输出轴就会保持在一个对应的角度,无论外界转矩怎样改变,直到给它提供另一个宽度的PWM脉冲,它才会改变输出角度到新的对应的位置上。
其实现是舵机内部的比较器将外加脉冲信号和内部基准电路产生的50Hz的基准信号相比较,判断出转动的方向和大小,从而产生电机的转动信号。控制电路板接受来自控制线的控制信号,控制电机转动,电机带动一系列齿轮组,减速后传动至输出舵盘。舵机的输出轴和位置反馈电位计是相连的,舵盘转动的同时,带动位置反馈电位计,电位计将输出一一个电压信号到控制电路板,进行反馈,然后控制电路板根据所在位置决定电机的转动方向和速度,从而达到目标角度后停止。
以180度SG90小舵机为例,假定1.5ms的脉冲宽度为舵机的0角度,那么对应的控制关系如下:
![]() |
![]() |
---|
调频:为了通过调整向舵机控制线输入的PWM占空比来控制转轴转动角度,我们需要知道树莓派处理器的时钟频率(经查询是19.2MHz)。这里还可以用pwmSetClock()
函数来设置分频来得到自己想要的输出频率,可以回顾一下汇编的课件。
这块我不是很懂(算不明白),就直接试了100Hz和200Hz,发现Python用100Hz效果很好,C语言两个频率都行,但是200Hz有时会出Bug(舵机无法正确复位,即转回到0度)
注意:
- 舵机的转动需要时间。因此,要控制转动的速度不要太快,且输出PWM脉冲后要
delay
一段时间让其转动到位。并且PWM的每次增量应该调的适宜,比如这个SG90就是180度舵机,一般是调转5个角度(就是上图中那几个),太精确的角度需要分频分的好,否则舵机会“抽搐”; - 舵机不要重复转到相同的位置,即上一次是转到10度的位置,下一次就不要转到10度的位置了,这样会抖动,且对电机也不好;
- 舵机无法工作一般是2个原因,工作电压不稳定或PWM脉冲设置的不好