RT-Thread一网打尽【万字长文慎点】

一口Linux 2021-07-28 11:50


(1)RT-Thread开发环境搭建



对于学习嵌入式实时操作系统,首先第一步要进行的是搭建好对应的开发环境,只有对应的环境搭建好了,才能进行下一步的开发工作。


RT-Thread为了方便广大开发者进行入门或者深入使用,其官网提供了十分丰富的参考文档,官网的文档中心链接:

https://www.rt-thread.org/document/site/


RT-Thread的文档中心提供了入门学习,进阶学习,应用开发这三个层次的文档,开发者可以根据自己的实际情况进行选择。然而,对于一些没有基础的入门级开发者,面对如此复杂繁多的文档,难免会不知道如何下手,不知道先看哪一篇文档比较好


在上完Sean老师的课程后,我进行了一些关于搭建开发环境的总结,主要是关于ENV配置工具和BSP包的使用。

RT-Thread课程列表


在开发环境搭建之前,要先下载好ENV配置工具和RT-Thread相关源码,ENV配置工具的下载链接如下:

https://www.rt-thread.org/page/download.html


注意:在安装ENV工具之前,需要确保电脑已经正确安装了Git管理工具并配置到系统的环境变量当中,Git管理工具可以从以下链接进行下载,具体安装方式可自行上网查询。

https://git-scm.com/downloads


ENV工具的安装路径不能包含中文和空格,应该使用全英文路径。


RT-Thread的相关源码,下载链接:

https://gitee.com/rtthread/rt-thread?_from=gitee_search


下载完成后,解压出来的文件夹如下图所示。


关于ENV工具的使用


ENV是RT-Thread提供的一个辅助开发工具,使用ENV可以很方便地构建出RT-Thread的工程项目。ENV提供了IAR、MDK、GCC工程的构建,集成了menuconfig配置裁剪工具,软件包管理功能,等等。


其中,menuconfig管理工具,沿用的是Linux kernel的Kconfig机制,当RT-Thread内核需要增加或减少某个功能时,可以使用menuconfig方便地进行配置,而不用从源码端一步步进行移植,提供了开发者的开发效率。


关于ENV工具的使用方法,可以参考RT-Thread的官方文档里面关于ENV的章节,有详细的介绍。

https://www.rt-thread.org/document/site/programming-manual/env/env/#


关于BSP包的使用


RT-Thread针对不同的芯片平台,提供了一些已经构建好的BSP(Board Support Package)板级支持包,开发者可以直接使用这些BSP包进行扩展开发,目前RT-Thread提供的BSP包如下图所示。


由于介子开发板使用的主控芯片是AT32F407,所以,开发板是可以基于at32这个BSP包进行开发的。进入bsp/at32/at32f407-start目录,可以看到BSP包的主要构成包括:工程模板,通过scons构建的工程,rtconfig.py构建脚本等,如下图所示。


在安装和配置好ENV工具后,在at32f407-start目录里面,右键打开ENV工具命令行终端,如下图所示。


RT-Thread是使用scons命令来进行工程构建的,如果是使用IAR或MDK来进行工程开发,可以使用以下命令构建对应的工程。构建IAR工程:scons --target=iar。构建MDK工程:scons --target=mdk5或scons --target=mdk4


menuconfig是一种图形化配置工具,,开发者可以使用menuconfig工具对RT-Thread内核进行配置和裁剪,关于menuconfig的快捷键介绍,如下图所示。


注意,每次使用menuconfig对BSP进行配置后,都需要使用scons命令对相应的工程进行重新生成,以便配置能生效。


细心的开发者会发现,对于at32f407开发板,其BSP包里面的工程,需要依赖整个RT-Thread源码才能被正确编译,然而整个RT-Thread源码里面又包含了其他跟AT32不相关的BSP包,这样会导致整个工程很庞大和臃肿。


为了让BSP包生成跟AT32芯片相关的工程项目,移除其他不相关的芯片BSP包,可以使用以下命令:scons --dist,命令执行后,会在BSP包目录里面生成dist目录,这个目录里面包含了整个AT32的工程,可以把这个工程复制到任何目录下使用,如下图所示。


要使用一个BSP包进行开发,一般会经过以下步骤:

1、使用menuconfig命令,对RT-Thread的内核和组件进行配置。

2、配置完成后,根据自己的编译器情况,使用scons --target=xxx生成对应的工程。

3、打开对应的工程,即可进行代码编写开发。

4、可以使用scons --dist命令,移除不相关的芯片,把工程单独抽离出来。


通俗地总结一下,ENV是一个工具包,这个工具包里面集成了一些命令,我们只需要在ENV的命令行窗口输入指定的命令就可以进行对应的操作。


menuconfig是工具包里面其中一个命令,这个命令可以调用一个图形化配置界面,可以让开发者方便地对RT-Thread内核进行配置和修改。


scons也是工具包里面其中个一个命令,这个命令是用来构建我们常用的IAR或MDK工程的,使用scons命令,就可以很方便地生成IAR或MDK工程,这样就不用手动地往工程里面添加源文件和头文件。


(2)RT-Thread启动过程分析

在一些不使用操作系统的单片机软件工程里面,除了汇编启动文件之外,普遍认为程序入口就是main函数,很多程序代码都是从main函数开始进行分析的。


而对于RT-Thread实时操作系统,程序在跑到main函数之前,其实是进行了一系列的启动流程初始化工作,而这些初始化操作是针对RT-Thread内核和具体的板卡进行的,用户不需要干预这个启动流程。


在进入main函数之前,RT-Thread进行了如图所示的启动操作。


不带操作系统的单片机程序,一般都会从启动文件startup_xx.s直接跳转到main函数开始执行,而带RT-Thread操作系统的程序,在进入main函数之前,还进行了如上图所示的一系列操作。以上的操作看似复杂繁多,但其实主要是在调用main函数之前,调用了rtthread_startup函数。关于如何在调用main函数之前,调用rtthread_startup函数,不同的编译器有不同的操作。


对于MDK编译器,主要是使用了MDK的扩展功能 $Sub$$  $Super$$ ,而对于IAR编译器,则是通过__low_level_init()函数,对于GCC编译器,则是通过entry函数,这些函数都是会在调用main函数之前被调用的。


MDK编译器为例,给main函数添加一个 $Sub$$ 前缀,就形成了一个新的功能函数,这个功能函数会在调用main函数之前被调用,这是MDK编译器所规定的,如下图所示。


关于程序从启动文件跳转到main函数入口的关系,总结概括如下图所示。


$Sub$$main函数里面,主要是调用了rtthread_startup()函数,这个函数是RT-Thread规定的统一启动入口,这个函数主要进行了如图所示的一系列初始化工作。


以下是关于rtthread_startup()函数里面各个函数的具体说明


1、关于rt_hw_board_init()函数,主要是初始化了中断向量表,完成了系统时钟的初始化,如果有使用到系统组件的话,同时初始化系统组件,并且设置打印信息的输出控制台,同时初始化系统堆内存,程序代码如下图所示。


2、关于rt_show_version()函数,主要是在信息控制台初始化成功后,打印RT-Thread内核的系统版本信息,这个函数的具体实现,如下图所示。


3、关于rt_system_timer_init()rt_system_scheduler_init()函数,主要是初始化了系统定时器链表和RT-Thread系统调度器,由于调度器的实现原理略为复杂,此处暂不展开论述。


4、关于rt_application_init()函数,主要是创建了一个名为main的主线程,这个线程的函数入口是main_thread_entry,这里有两种创建方式,二选一,如果使用了系统堆内存,则使用动态创建的方式,线程使用的内存资源可以动态进行申请或释放,如果没有使用系统堆内存,则使用静态创建的方式,线程使用的内存资源是固定好的,不能被释放,函数实现如下图所示。


5、关于rt_system_timer_thread_init()函数,主要是初始化软件定时器的列表,并且采用静态方式创建一个名为timer的软件定时器,并且把软件定时器线程放入调度器里面,函数实现如下图所示。


6、关于rt_thread_idle_init()函数,主要是根据芯片CPU的数量,使用静态方式创建空闲线程,实际上,空闲线程并不空闲,这个线程在系统没有任何用户线程调度的时候,就会被调度起来,这个空闲线程主要是检查系统有没有已经消亡的线程,如果有,则把消亡线程的资源进行回收,如果系统使能了电源管理,则会让系统进行低功耗模式,函数的具体实现,如下图所示。


7、关于rt_system_scheduler_start()函数,主要是开始使能操作系统调度器,调度器启动后,会根据系统的调度规则,从线程就绪列表里面,选择优先级最高的线程进行启动。


8、从以上分析可知,RT-Thread系统在启动的时候,至少会启动一个main主线程和一个idle空闲线程,如果系统配置有使能软件定时器,还会启动一个timer定时器线程,也就是说,系统一旦启动后,就会有两个(或三个)线程在进行调度,如下图所示。


(3)RT-Thread自动初始化机制分析

相信不少工程师在阅读RT-Thread相关源代码的时候,都会经常看到如下图所示的宏定义,按照宏定义的命名来理解,这些宏定义似乎都是对一些初始化函数进行某些声明工作。



如上图所示,通过对源码的跟踪发现,这些INIT_XXX_EXPORT的宏定义,最终都是调用了INIT_EXPORT这个宏定义,而这个宏定义,就是把该初始化函数放在自定义的rti_fn符号段里面,源码在rtdef.h头文件里面,如下图所示。



把初始化函数放到自定义的符号段里面,有什么作用呢?答案就是,可以通过这种方式,让这些初始化函数被隐式调用,不用手动往RT-Thread的初始化过程里面添加该函数。


什么是隐式调用?隐式调用的意思就是,当我们往工程代码里面添加某个系统组件或外接设备的时候,这个组件或设备都需要进行初始化,而这个初始化函数,我们不需要在main函数或RT-Thread的启动函数里面直接添加调用,这样可以避免修改RT-Thread的启动过程代码。


先来看一下RT-Thread的启动函数调用流程,留意红色方框里面的内容,如下图所示。



启动函数里面,rt_components_board_init() 与 rt_components_init()这两个函数是专门用来处理自动初始化的,这两个函数的原型和注释,如下图所示。



从上面的函数原型可以看出,这两个函数都是从符号段区间里面,通过for循环不断遍历符号段里面的初始化函数,并获取这些初始化函数的指针,然后进行调用,以达到对设备或组件初始化的目的。


rt_components_board_init()函数最先执行,这个函数是用来初始化芯片相关的硬件的,这个函数会遍历用 INIT_BOARD_EXPORT(fn)声明的函数列表。


rt_components_init()函数是在系统启动后,在main线程里面被调用执行,这个函数是用来初始化其他用 INIT_XXX_EXPORT(fn)声明的函数列表的。


目前RT-Thread内核里面,用来实现自动初始化功能的宏定义接口,如下图所示。



综上所述,要使用RT-Thread的自动初始化流程,可以概括为以下如图所示的步骤。



为什么初始化函数加入了符号段之后,就可以被自动调用?符号段是什么?使用这种方式有什么好处?


把函数加入符号段,其实就是使用了MDK编译器的__attribute__((section(x)))关键字,对函数进行声明,通过section关键字进行声明的函数,在编译器进行链接的时候,就会自动收集这些函数并把他们放到一个集中的区域里面,查看以下.map文件可知。


如上图红框所示,rt_hw_pin_init和rt_hw_usart_init都是使用 INIT_BOARD_EXPORT(fn)声明的函数,因此,它们是存放在橙色竖线所在的区间的,使用rt_components_board_init()函数就可以对这个区间进行遍历。


如上图蓝框和绿框所示,它们分别是用 INIT_COMPONENT_EXPORT(fn)和 INIT_APP_EXPORT(fn)声明的函数,这些函数是存放在红色竖线所在的区间的,使用rt_components_init()函数就可以对这个区间进行遍历。


从上面的分析可以看出,使用符号段的方式来存放初始化函数,好处就是当我需要添加某一个初始化函数的时候,就不需要再去改动RT-Thread的启动代码了,直接通过section关键字,把初始化函数添加到相应的符号段即可。



以上就是RT-Thread的自动初始化机制分析,正是由于采用了这种机制,所以,当我们对内核或组件进行裁剪的时候,并不需要修改RT-Thread的初始化函数,也可以对组件进行初始化。

(4)RT-Thread 多线程学习总结

多线程是实时操作系统里面最重要的知识点之一,要学习RTOS,多线程是必须(没错,是必须)要熟练掌握的内容,只有熟练掌握多线程的使用,才能在平时的项目工作里面用好实时操作系统。


关于多线程的使用和管理,RT-Thread官方提供了比较丰富的文档作为参考,具体内容可以查看以下链接:

https://www.rt-thread.org/document/site/programming-manual/thread/thread/

本文是对RT-Thread多线程学习后的总结,并尝试从如图所示的以下几个方面进行总结。


什么是多线程?


在单片机上学习RT-Thread的多线程之前,要先把“进程”这个概念先放一边,因为单片机是没有多进程概念的。单片机运行操作系统,不管多少个任务,他们都是多个(或单个)线程之间进行处理这些任务,单片机一般不涉及多进程。


什么是多线程?在哪些情况下要用到多线程?先来举一个音乐播放器的例子,这个音乐播放器要做以下这些基本的工作:读取音乐文件并播放、读取歌词并显示、读取MV文件并播放。


如果这三个基本的工作不用多线程来完成,单片机使用裸机的方式去做这三个工作的话,必然会造成音乐播放卡顿,歌词显示不同步,MV视频播放与音乐不同步。


因为单片机做这三件事情的时候,是Step by Step的,必须完成一件事情之后,再去做下一件事情,这三件事情是有先后顺序的,并且不断循环重复,如下图所示。

而如果采用多线程这种方式来完成这个工作,这个过程就变得相对简单了,比如针对音乐播放器这个场景,可以设计这几个线程来处理:音乐文件读取线程,歌词文件读取线程,MV文件读取线程,音视频和歌词显示线程。


此处只为举例描述多线程的概念,不考虑音视频编解码的复杂过程,不考虑线程同步,实际上音乐播放器的实现比此处描述更复杂)


音乐文件读取线程只负责从磁盘读取音乐文件,歌词文件读取线程和MV文件读取线程也是同样的道理,它们只做文件读取工作,而音视频和歌词显示线程,是负责把读取到的数据进行显示。这几个线程的工作过程,如下图所示。

如上图所示,这几个任务看上去是“同时”进行的,每个任务都只完成自己的事情,通过多线程,就可以把原本串行完成的任务改为并行完成,大大提高了工作效率。


所以,通俗地对多线程进行理解,就是把一个比较大型的任务,拆分为多个小型的任务,然后通过合理的调度方式,让这几个小型的任务“同时”运行,当这几个小型任务完成后,大型的任务也随之完成,这样可以大大提高任务的完成效率。


多线程的几种状态


对于运行RT-Thread操作系统,线程都处于以下五种状态的其中一种(初始状态、就绪状态、运行状态、挂起状态、关闭状态),通过调用操作系统提供的接口函数,可以让线程在这五种状态中进行来回切换。



关于这五种线程状态的描述,如下表所示:


多线程的API函数


如上图的状态机所示,多线程可以通过调用系统提供的函数接口,在多个状态之间进行切换。这些API函数在官方提供的参考文档里面都有详细的说明描述,以下列举一些比较常用的函数接口。

上下滑动查看 API 函数


多线程的应用示例


多线程的应用示例,主要是为了验证以上的多线程API接口函数,并且通过实验现象观察多线程的运行情况,主要有以下三个示例:


示例码下载链接:

https://github.com/embediot/rtthread_study_notes

1、线程动态创建与静态创建、线程退出示例。


这个示例主要是通过动态方式创建线程1,,通过静态方式创建线程2,线程1的优先级比线程2的优先级低,因此可以被线程2抢占。线程2运行10次后就会主动退出,初始化代码如下图所示。


2、相同优先级线程的时间片轮转调度示例。


这个示例主要是通过动态方式创建线程1和线程2,这两个线程都是相同的优先级,并且共用一个线程入口函数,主要是通过传入不同的线程参数以区分线程1和线程2。线程2运行所占用的时间片比线程1要少,因此线程2运行的时间比较短,初始化代码如下图所示。


3、线程调度器的钩子函数使用示例。


这个示例主要测试了线程在进行调度时,关于钩子函数的调用情况。通过线程调度器的钩子函数,打印出线程间的切换信息,初始化的代码如下图所示。


多线程应用的注意事项


在使用RT-Thread实时操作系统进行多线程应用开发的时候,应该要注意以下事项:


1、RT-Thread的线程调度器是抢占式的,也就是能够保证就绪队列里面,最高优先级的任务总能获得CPU的使用权,在任务设计的时候,要充分考虑好任务的优先级。


2、在硬件中断服务程序运行期间,如果有高优先级的任务就绪,那么被中断的低优先级任务将被挂起,高优先级的任务将会获得CPU的使用权。


3、每个线程都有独立的线程栈,用来保存线程调度时上下文的信息,因此在创建线程分配栈空间的时候,要充分考虑栈的大小。


4、在线程的循环体里面,应该要设置某些条件,在必要的时候主动让出CPU的使用权,特别对于高优先级的线程,如果程序里面有死循环操作而又不主动让出CPU使用权,那么这个线程将会一直占用CPU,并且低优先级的线程永远不会被调度执行。


5、对于没有一直循环执行的线程,线程执行完毕后,资源的回收情况实际上是在空闲线程里面进行的,线程变为关闭状态后,不代表资源马上被回收。


6、系统空闲线程是最低优先级且永远为就绪状态的,空闲线程是一个死循环,永远不会被挂起,但可以被其他高优先级任务抢占,空闲线程主要执行僵尸线程的资源回收工作。


7、空闲线程也可以设置钩子函数,用来进行功耗管理,看门狗喂狗等工作。


8、通过动态方式创建的线程,需要设置好系统堆内存的大小,而通过静态方式创建的线程,线程栈和线程句柄在程序编译的时候就已经确定,不能被动态分配,也不能被释放。


9、大多数线程都是在不断循环执行的,无需进行删除,一般不推荐主动删除线程。线程运行完毕后,系统调度器将会自动把线程加入僵尸队列,资源回收工作将在空闲线程里面进行。


(5)RT-Thread线程间同步学习总结

多线程之间同步是继多线程学习之后,需要重点掌握的又一个重要内容。一个实时操作系统里面,如果只有多线程而没有线程间同步,各个线程都无序运行,那么必然会导致整个系统的运行出现各种问题。


正是由于一个较大的任务拆分为多个小任务,这些小任务是由多个线程去执行的,那么,这些小任务之间必然会存在着千丝万缕的关系,小任务的运行更不能只管自扫门前雪,不管他人瓦上霜,因此,线程间同步是必须掌握的内容。


关于多线程之间的同步,RT-Thread官方提供了比较丰富的文档作为参考,具体可以查看以下链接:

https://www.rt-thread.org/document/site/programming-manual/ipc1/ipc1/


本文尝试从以下几个方面总结一下RT-Thread线程间同步的学习过程


什么是线程间同步,为什么需要线程同步?


上一篇文章 RT-Thread学习笔记 -- (4)RT-Thread多线程学习总结 里面提及到,音乐播放器通过多线程工作的时候,需要通过合理的调度方式,才能让各个线程协同工作。而这里所说的“合理的调度方式”,其中一种方式就是指多线程同步。


什么是线程间同步?通俗一点来说,线程间同步是指多个线程之间进行协商工作的方式。前面已经说过,线程在工作的时候,虽然只专心在做一件事情,但线程在工作的时候,并不能只埋头苦干,而不顾其他线程的状态,因此必须要有一种方式,来告知其他线程关于自身的工作状态。


为什么需要线程同步?继续用音乐播放器来举例说明,按照前文的图例,假如不使用多线程播放音乐,会有什么后果呢?后果有可能是,音乐文件读取线程比歌词文件读取线程跑得慢,导致歌曲还没播放到那一步,而歌词反而先显示出来了,还有其他可能性,导致歌曲,歌词,MV三者播放的顺序乱套了,不能同步显示。


如果要让音乐播放能正常工作,就需要在4个工作线程之间加入线程同步机制。比如歌词文件读取线程可能运行得比较快,而音乐文件读取线程读取音乐比较慢,那么,这两个线程之间就需要进行同步,快的线程要稍微等一下,等慢的线程发送一个同步消息,这样两者才能一起愉快地继续运行。


线程间同步的方式


针对RT-Thread实时操作系统,线程间同步主要有三种方式:信号量,互斥量,事件集。这三种线程同步机制各有优缺点,在实际开发工作里面,需要根据不同的应用场景进行区分使用。


信号量是一种非常灵活的线程同步方式,通过信号量可以衍生出多种功能,比如,锁、线程同步、资源计数,后面讲述的互斥量也可以通过二值型信号量来实现。生活中的停车场应用场景,就是信号量的一种具体体现。


1、当停车场空的时候,停车场的管理员发现有很多空车位,此时会让外面的车陆续进入停车场获得停车位。

2、当停车场的车位满的时候,管理员发现已经没有空车位,将禁止外面的车进入停车场,车辆在外排队等候。

3、当停车场内有车离开时,管理员发现有空的车位让出,允许外面的车进入停车场;待空车位填满后,又禁止外部车辆进入。


在这个场景里面,管理员就相当于信号量,管理员手中空车位的个数就是信号量的值(非负数,动态变化),停车位相当于公共资源(临界区),车辆相当于线程,车辆通过获得管理员的允许取得停车位,就类似于线程通过获得信号量访问公共资源。


信号量是没有“所有权”这种概念的,也就是说,对于一个非二值型的信号量,多个线程可以对其进行获取/释放操作,也可以递归获取信号量,因此,对于非二值型信号量,在使用过程中可能会出现线程优先级翻转和线程死锁的问题。


系统内核提供以下信号量的API函数接口,如下图所示。


互斥量,顾名思义,就是一种相互排斥的信号量,是一种特殊形式的二值型信号量。这种情况就类似于一个停车位,当A汽车占据了停车位(获取到互斥量)的时候,其他汽车就不能获取该车位了,必须等A汽车离开该车位的时候,才能有机会争夺该车位。


互斥量跟信号量不同的是,互斥量只有两种状态值,对于拥有互斥量的线程,表示该线程已经拥有了该互斥量的所有权和控制权,该互斥量只能由该线程来进行释放,这样就可以解决线程递归获取互斥量出现的死锁问题。


对于信号量中存在的优先级翻转问题,在互斥量里面不会出现,这是因为互斥量里面实现了高优先级继承算法。优先级继承是通过在高优先级线程尝试获取共享资源而被挂起的期间内,将低优先级的线程的优先级提升到高优先级线程的优先级别,从而解决优先级翻转引起的问题。


系统内核提供以下互斥量的API函数接口,如下图所示。


事件集,也是线程同步的一种机制,但与信号量或互斥锁不同,事件集是可以实现一对多或多对多同步的。也就是说,一个线程发出一个(或多个)事件,一个(或多个)等待该事件的线程在获取到该事件后,就可以获得运行的权限。


事件集是使用一个32位无符号整型的变量来表示的,每一个位表示一个事件,这些事件可以是“逻辑与”或“逻辑或”的关系。换句话说,就是可以让一个线程等待一个事件(逻辑或)到达就执行,或者让一个线程等待所有事件(逻辑与)到达才执行。


事件集在某些场合里面是可以替代信号量的。但事件集与信号量不同,事件集的事件在清除之前,是不能累计的,也就是说,一个线程发送了多次同一事件,由于不能累计,也就相当于只发了一次该事件,直到该事件被清除。


系统内核提供以下事件集的API函数接口,如下图所示。


多线程同步的应用示例


多线程同步的应用示例,主要是为了验证信号量,互斥量,事件集的API接口函数,并且通过实验现象观察这三种线程同步方式的运行情况。


示例源码下载链接:

https://github.com/embediot/rtthread_study_notes


号量示例主要演示了一个“生产者-消费者”的设计模式,生产者线程不断生产产品(数值加1)放入仓库(循环数组),消费者线程不断从仓库里面取出产品,仓库的读写操作都需要使用信号量的锁机制进行同步。


互斥量示例主要创建了三个动态线程,这三个动态线程不断争夺这个互斥量的使用权,通过实验现象可以观察到,持有互斥锁的线程的优先级,会通过优先级继承算法,调整到等待线程优先级中的最高优先级。


事件集示例主要初始化了一个事件集和两个线程,一个线程发送事件,另一个线程等待事件。等待事件的线程,分别使用了“逻辑与”和“逻辑或”这两种事件接收方式。


具体示例的实现可以查看工程源码,在synchronize.h头文件中,打开相应的宏定义开关,重新编译工程并下载到开发板即可。


线程间同步的注意事项


在进行多线程间同步的时候,关于信号量,互斥量,事件集这三种线程同步方式,有以下一些注意事项:


1、中断与线程间的互斥,不能采用信号量(锁)的方式,应该采用开关中断的方式。


2、资源计数类型的应用场景,多数都是混合方式的线程间同步,由于单个的资源处理存在线程的多重访问,因此需要对资源进行锁方式的互斥操作。


3、在使用信号量的时候,应该要注意优先级翻转的问题,合理安排任务的优先级。


4、不能递归获取信号量,否则有可能会造成“死锁”的情况。


5、线程不能长时间占用互斥量,在获得互斥量之后,不能再更改持有互斥量线程的优先级。


6、不能在中断服务程序里面使用互斥量。


7、事件集仅能用于线程同步,不能用于线程间传输数据。


8、事件集不会形成队列,在清除事件之前,发送多次跟发送一次都是同样的效果。


(6)RT-Thread线程间通信

本篇文章继续总结关于RT-Thread多线程相关的最后一个重要知识点:线程间通信。前面的文章多次提及到,一个大的任务拆分为多个小任务,这些小任务之间必然存在着各种各样的关系,导致这些小任务的线程不能各自为政,必须要考虑其他任务线程的运行情况。


既然已经有了线程间同步,可以让多个线程之间进行相互沟通,那为啥还需要线程间通信呢?线程间通信到底是什么东西,这种方式有什么应用场景?


关于多线程之间的通信,RT-Thread官方提供了比较丰富的文档作为参考,具体可以查看以下链接:

https://www.rt-thread.org/document/site/programming-manual/ipc2/ipc2/

 

本文尝试从以下几个方面总结一下RT-Thread线程间通信的学习过程


线程间通信的相关概念


什么是线程间通信?通信,顾名思义,就是双方需要进行沟通与对话。通俗地概括,就是A线程在工作运行期间,有某些数据或者信息,要告诉B线程,让B线程接收到这些数据或信息后,能够继续完成指定的任务和工作。

 

两个线程之间为什么要进行通信呢?还是那句话,多个任务线程并不是独立的,它们在工作的时候是需要根据业务场景进行一定的沟通的,还是以音乐播放器举例,当歌词读取线程把歌词从硬盘里面读出来了,要把这一串读到的歌词告诉给显示线程,让它把歌词显示出来。这个“告诉”的动作,就是通过线程间通信来进行的。


既然都是为了协调线程的工作状态,线程间同步和线程间通信这两者有什么区别呢?区别就是线程间同步能做的事情太有限了,线程间同步只是告诉一下对方“别跑太快,等等我嘛~”,而线程间通信,就是有一大堆的数据和信息要告知对方,万一A线程有很多话要跟B线程说,线程同步这种方式就不能满足要求了,所以需要线程间通信。


线程间通信的方式


针对RT-Thread实时操作系统,线程间通信主要有三种方式:邮箱,消息队列,信号。这三种线程间通信机制都有各自的特点,在实际开发工作里面,需要根据不同的应用场景进行区分使用。


邮箱是线程间通信的其中一种方式,这个邮箱的概念,跟我们生活中使用的邮箱概念,其实是大同小异的,在生活中,如果我们有信件要寄,就把信件往邮筒一扔就可以了,邮局会负责把信件送往目的地。


同样的道理,当A线程有信件(即数据)要发送给B线程,只需要调用操作系统提供的邮箱相关接口函数,把数据发送出去,操作系统就会负责把数据转发到目标线程,整个转发过程是怎样实现的,收和发的线程都不需要关心。


使用邮箱进行线程间通信,特点是开销低,效率高。这是因为,每个邮件信息最多只能是4个字节的内容,所以,这个邮件信息可以是某个数据块的指针,通过指针传递的方式,来传输更多的数据。



邮箱在使用过程中,可能会存在邮箱空或邮箱满的情况,在邮箱空的情况下,接收邮件的线程会选择挂起等待,或者等超时时间到来。在邮箱满的情况下,发送邮件的线程会选择挂起或直接返回一个邮箱满的返回值。


系统内核提供以下邮箱相关的API函数接口,如下图所示。


消息队列是另外一种比较常用的线程间通信方式,相当于邮箱的扩展。跟邮箱不同的是,消息队列是可以接收不定长的数据的,并且把这个不定长的数据复制到自身线程的内存空间。


消息队列其实就是一个数据存储空间,这个存储空间遵循先进先出的原则,也就是说,不管是什么消息,等待消息的线程获得的是最先进入队列的消息。


消息队列控制块里面,其实有两个链表,一个链表是用来挂接空的消息块(也就是没有内容的消息队列),另一个链表是用来挂接存有消息的消息块,具体抽象如下图所示。



当线程A要发送一个消息时,先从空闲消息块链表取出一个块空间,把消息装进去后,把这个消息块挂接到非空消息块链表的队尾。如果使用紧急方式发送消息,则把该消息块挂接到非空消息链表的队首。线程获取消息的时候,总是会获取链表头的消息的。


系统内核提供以下消息队列相关的API函数接口,如下图所示。


信号,在软件层次上其实相当于一种软中断的方式,这种中断机制是操作系统模拟出来的,一个线程收到一个信号,跟硬件处理器收到一个硬件中断请求,这个过程基本上是类似的。


当一个线程在正常运行期间,如果其他线程有突发的事件或异常通知需要处理,就可以通过信号的方式发送出去,线程在正常运行期间不需要等待信号的到来(因为不知道信号什么时候会到来)。


收到信号的线程,对各种信号的处理有以下三种方法:


1、类似中断的处理程序,可以针对需要处理的信号指定处理函数,由该函数来处理。

2、直接忽略某个信号,对该信号不做任何处理,就像未发生过一样。

3、使用系统保留的默认值来处理该信号。



系统内核提供以下信号相关的API函数接口,如下图所示。

 

多线程通信的应用示例


多线程通信的应用示例,主要是为了验证邮箱,消息队列,信号的API接口函数,并且通过实验现象观察这三种线程通信方式的运行情况。


示例源码下载链接:

https://github.com/embediot/rtthread_study_notes


邮箱示例主要是初始化了2个静态线程,一个静态的邮箱对象,线程 2 发送邮件,共发送 11 次,线程 1 接收邮件,共接收到 11 封邮件,将邮件内容打印出来,并判断结束。


消息队列示例主要初始化了2个静态线程,线程 1 会从消息队列中收取消息,线程 2 定时给消息队列发送普通消息和紧急消息。由于线程 2 发送消息 “I” 是紧急消息,会直接插入消息队列的队首,所以线程 1 在接收到消息 “B” 后,接收的是该紧急消息,之后才接收消息“C”。


信号示例主要是创建了 1 个线程,在安装信号时,信号处理方式设为自定义处理,定义的信号的处理函数为 thread1_signal_handler(),待此线程运行起来安装好信号之后,给此线程发送信号,此线程将接收到信号,并打印信息。


具体示例的实现可以查看工程源码,在thread_communication.h头文件中,打开相应的宏定义开关,重新编译工程并下载到开发板即可。


线程间通信的注意事项


在进行多线程间通信的时候,关于邮箱、消息队列、信号这三种线程间通信方式,有以下一些注意事项:


1、使用邮箱进行线程间通信时,由于一封邮件最多只能是4个字节长度,因此如果要传递较多数据信息,可以使用结构体进行信息封装,通过指针方式进行传递。


2、邮件发送是非阻塞的,因此可以应用于中断服务程序中。但邮件接收是阻塞的,可以设置接收超时的时间,不能在中断服务程序里面使用邮件接收。


3、当邮箱没有邮件且超时时间不为0 ,邮件的接收过程自动变为阻塞方式。当邮箱满了后,发送线程可以选择挂起等待或直接返回邮箱满的错误码。


4、消息队列是一种异步的通信方式,消息队列里面的消息总是遵循先进先出的原则。


5、可以在线程或中断服务程序里面可以给消息队列发送消息,但不能在中断服务程序里面接收消息。


6、可以往消息队列里面发送紧急消息,紧急消息会被放置到消息队列的链表头,会首先被等待的线程获取。


7、信号跟信号量不同,不能混淆两者的概念,信号是软件层面上的一种软中断方式。


8、线程不会用阻塞的方式等待信号的到来,因为线程自身也不知道这个信号(软中断)什么时候会到。


9、线程对信号的处理,可以设置为捕捉信号,忽略信号,使用默认方式处理信号。


(7)RT-Thread中断管理学习总结

本文的内容是关于RT-Thread中断管理的学习总结,包括简单地介绍了什么是中断,裸机中断与RT-Thread中断有什么区别,RT-Thread是如何处理中断的,RT-Thread内核提供哪些中断相关的接口,等等。


关于RT-Thread中断管理相关的内容,官方提供了比较丰富的文档作为参考,具体可以查看以下链接:

https://www.rt-thread.org/document/site/programming-manual/interrupt/interrupt/#rt-thread


本文尝试从以下几个方面总结一下RT-Thread中断管理的学习过程。



中断相关的概念描述

 

什么是中断?中断,顾名思义就是一项正在进行的工作,突然间被其他事情打断,导致原来正在进行的工作不能继续正常进行,而需要去把其他事情处理完,才能回来继续进行原来的工作。


如何通俗地理解中断?想象一下这样的场景,周末你正在家里愉快地写着代码,突然间你的手机铃声响了,你必须停下手里的工作,记录代码写到哪个阶段,然后就去接这个电话了。“写代码”就是正在进行的工作,“电话响起”就是中断事件。


这个电话是媳妇打过来的,她让你去菜市场买点韭菜和猪肉,晚上包饺子吃,媳妇的话哪敢不听,于是你觉得菜市场买东西比较重要,挂掉电话后就去买东西了,买完东西回来后,再接着写刚刚还没完成的代码。“菜市场买东西”就是中断服务程序,这就是一个典型的中断处理过程。


关于中断的操作模式和特权级别,Cortex-M的处理器有三种状态划分,分别是:特权级处理模式,特权级线程模式,用户级线程模式。这三种状态的关系,如下图所示。


从上图可以看出,中断或异常的服务程序,总是处于特权级处理模式的。而RT-Thread系统内核复位上电时启动的主线程(main线程),是运行在特权级线程模式的。其他用户创建的线程,是运行在用户级线程模式的。


为什么处理器要区分特权级和用户级?特权,顾名思义就是处理器如果工作在这个级别下,权限就会比较高,就可以访问一些特殊的寄存器,以防止用户级的代码访问这些特殊寄存器,对数据进行破坏。中断由于其特殊性,所以,中断函数是工作在特权级别下的。


裸机中断与操作系统中断两者有什么区别呢?我们在裸机代码中处理硬件中断的时候,一般只要编写中断处理函数就可以了,这种方式处理中断,简单且直接。


然而,有了操作系统之后,所有的东西都变了,要考虑的问题就多了很多。因为操作里面运行了很多线程,中断来了之后,就要告知操作系统,把当前运行线程的信息保存到栈里面,再去处理中断服务程序,处理完中断要再回去处理线程,此时又可能涉及到线程切换调度,而线程切换本身又需要PendSV中断参与。


所以,在裸机处理中断和在操作系统中处理中断,简直就是天壤之别。


RT-Thread 中断处理机制


了解过Cortex-M系列单片机的工程师,一般都知道在芯片的汇编启动文件startup_xxx.s里面,有一个中断向量表,所有的中断都是通过这个中断向量表来进行处理的。


当一个中断异常触发的时候,处理器将会判断是哪个中断源,然后跳转到固定位置进行处理,每个中断服务程序的地址入口必须是放到统一的地址上,也就是需要设置到NVIC的中断向量偏移寄存器里面,中断向量表如下图所示。


其实,不管有没有操作系统的参与,一旦硬件发送中断和异常之后,中断的入口都是在这个中断向量表的。区别无非就是在裸机环境下,直接处理中断服务程序,而在有操作系统的情况下,需要先保留线程的运行情况,然后再处理中断,处理完中断后,再恢复线程的运行环境。


硬件中断的优先级是最高的,任何线程的优先级都要低于硬件中断,因此,只要发生了硬件中断事件,系统就必须要进行相应的处理。


RT-Thread在处理中断的时候,一般都会有三个阶段:中断前导程序,中断服务程序,中断后续程序,这三个阶段,如下图所示。


中断前导程序的主要工作是,当中断事件发生的时候,处理器的硬件会把当前CPU相关的寄存器参数自动压入中断栈里面。程序需要调用rt_interrupt_enter()函数,把全局变量rt_interrupt_nest进行加1操作,这个全局变量是用来记录中断的嵌套层数的。


用户中断服务程序的主要工作分两种情况,一种是不进行线程切换,另一种是进行线程切换。不进行线程切换的话,中断服务程序和中断后续程序运行完成后,将返回被中断的线程。


而如果要进行线程切换,则会调用rt_hw_context_switch_interrupt() 函数进行上下文切换,这个函数主要是设置变量rt_interrupt_to_thread,然后触发PendSV中断。


在这里要注意一下:由于PendSV中断的优先级最低,不能进行中断抢占,因此即使触发了该中断,但由于此时还在用户中断处理函数里面,所以PendSV中断还处于等待阶段,只有退出了中断后续程序,才会进行PendSV中断处理,才会进行线程的上下文切换。所以,线程的上下文切换是不会在用户中断里面进行的,是在中断结束后进行的。


中断后续程序的主要工作是,通知系统内核离开中断状态,通过调用rt_interrupt_leave()函数,将全局变量rt_interrupt_nest进行减1操作,然后从中断栈里面恢复恢复CPU相关的寄存器参数。


这里恢复CPU寄存器参数的时候需要注意,如果在用户中断里面涉及到线程切换,那么这个时候就需要恢复到新的线程CPU寄存器参数,而不是恢复到被中断打断的线程CPU寄存器参数。


RT-Thread操作系统在处理中断的时候,通常采用“上半部分(Top Half)”和“底半部分(Bottom Half)”这种方式。原因在于,操作系统本身不会对中断服务程序的处理时间做任何假设和限制,但为了保证系统的实时性,用户需要保证中断服务程序在尽可能短的时间内完成。


如何理解“上半部分(Top Half)”和“底半部分(Bottom Half)”这种中断处理方式?还是以买菜为例。媳妇来电话让你到菜市场买菜(中断事件),但你考虑到如果长时间中断不写代码,会导致思路断链,为了避免这种情况(避免长时间处理中断服务),完全可以在网上下单购买(短时间的中断处理),生鲜超市收到下单信息(信号量、邮件、消息队列),就会安排快递小哥送货上门,买菜这么耗时的工作就由其他人(其他线程)去完成了。


“上半部分(Top Half)”和“底半部分(Bottom Half)”这种中断处理方式,主要是应用在一些需要耗时处理中断事务的场合,比如数据的接收和处理。通常接收数据的时间比较短,只要把接收到的数据保存下来即可,但处理数据的过程就可能比较耗时,这样就需要分开来处理,上半部分就是接收数据,底半部分就是耗时的数据处理。


RT-Thread中断相关的API函数接口


为了把操作系统和硬件底层的中断异常隔离开来,RT-Thread系统内核把中断和异常封装为一组抽象的接口,具体的函数接口如下图所示。


RT-Thread中断相关的应用示例


RT-Thread中断相关的应用示例,主要是为了验证中断相关的API接口函数,例如全局中断开关的使用示例,通过按键中断示例来验证“上半部分(Top Half)”和“底半部分(Bottom Half)”这种中断处理方式。


示例源码下载链接:

https://github.com/embediot/rtthread_study_notes


全局中断开关示例,主要是为了验证多线程访问同一个变量时,使用开关全局中断的方式对该全局变量进行临界区保护。


按键中断示例,主要是为了验证“上半部分(Top Half)”和“底半部分(Bottom Half)”这种中断处理方式。通过按键触发中断事件,在中断服务函数里面发送邮件,通知线程进行相应的处理。


在irq_test.h头文件里面,通过打开相应的宏定义开关,重新编译工程源码,下载到开发板即可验证实验现象,如下图所示。


RT-Thread中断应用的注意事项


中断是一种异常,当系统发生中断异常的时候就必须要进行处理,在RT-Thread实时操作系统里面处理中断的时候,如果不及时处理或对中断处理不当,轻则会造成系统出错或逻辑混乱,重则会导致系统毁灭性地瘫痪。


在处理RT-Thread中断异常的时候,有以下注意事项:


1、中断服务程序工作在特权级处理模式,优先级比任何线程要高,任何线程都不能抢占中断服务程序。


2、在操作系统里面,可以支持中断嵌套,高优先级中断可以抢占低优先级中断,线程的重新调度是在所有中断都处理完之后才重新启动的。


3、在Cortex-M架构里面,中断发生时CPU的寄存器入栈是由硬件自动完成的,中断的前导程序通常只是记录中断的嵌套层数。


4、RT-Thread采用独立的内存空间作为中断栈,而不是采用线程栈作为中断栈,这种方式随着线程的增加,减少内存占用的效果也越明显。


5、建议采用“上半部分(Top Half)”和“底半部分(Bottom Half)”这种方式来处理中断异常,中断服务程序的处理时间应尽可能短。


6、使用全局中断开关是禁止多线程访问临界区最简单的一种方式,这种方式可以应用在任何场合,但要注意这种方式对系统实时性影响巨大,使用不当会破坏系统的实时性能。使用全局中断锁的时间应尽可能短。


7、全局中断开关支持多级中断嵌套使用,每次调用rt_hw_interrupt_enable()函数,可以让系统恢复到关中断之前的状态(这个状态有可能是关中断也有可能是开中断)。


8、中断服务程序是运行在特权处理模式下的,在这种运行模式里面是不能使用挂起当前线程操作的相关函数的,因为中断服务程序的运行环境里面根本不存在线程。


-- END --




专辑 | 【开源】嵌入式物联网应用开发

专辑 | 嵌入式Linux应用程序开发

专辑 | 嵌入式Linux开发环境搭建

专辑 | 物联网BLE裸机程序开发

专辑 | 物联网BLE应用程序开发

专辑 | RT-Thread学习笔记



评论 (0)
热门推荐
X
广告
我要评论
0
0
点击右上角,分享到朋友圈 我知道啦
请使用浏览器分享功能 我知道啦