RTthread基础知识

1.rtthread启动

Startupxx.s---->$$Sub$$main()

系统启动先从汇编代码startupxx.s开始运行,主要会在汇编代码运行reset_Handler(复位向量汇编代码),调用systeminit(一些初始化),之后调用__main(注意这个main还不是我们的main函数,但是最终会跳到main函数)。

__main()---->$sub$$main()---->rtthread_startup()---->rt_applicition_init()---->main_thread_entry--->main()

其他初始化:

rt_hw_board_init()完成必要初始化包括堆栈内存系统的初始化和硬件资源模块

rt_show_version:打印版本

rt_system_timer_init:系统时间初始化

rt_system_scheduler_init:调度系统初始化

rt_application_init:创建主main线程

rt_system_timer_thread_init:创建time线程

rt_thread_idle_init:创建空闲线程

rt_system_scheduler_start:启动调度

图 1 启动流程图

2.cortex-m CPU架构基础

1.寄存器简介

Cortex-M 系列 CPU 的寄存器组里有 R0~R15 共 16 个通用寄存器组和若干特殊功能寄存器,如下图所示。

                                图 2 cortex-m系列寄存器

通用寄存器组里的 R13 作为堆栈指针寄存器 (Stack Pointer,SP);R14 作为连接寄存器 (Link Register,LR),用于在调用子程序时,存储返回地址;R15 作为程序计数器 (Program Counter,PC),其中堆栈指针寄存器可以是主堆栈指针(MSP),也可以是进程堆栈指针(PSP)。

特殊功能寄存器包括程序状态字寄存器组(PSRs)、中断屏蔽寄存器组(PRIMASK, FAULTMASK, BASEPRI)、控制寄存器(CONTROL),可以通过 MSR/MRS 指令来访问特殊功能寄存器,例如:

MRS R0, CONTROL ; 读取 CONTROL 到 R0 中
MSR CONTROL, R0 ; 写入 R0 到 CONTROL 寄存器中

程序状态字寄存器里保存算术与逻辑标志,例如负数标志,零结果标志,溢出标志等等。中断屏蔽寄存器组控制 Cortex-M 的中断除能。控制寄存器用来定义特权级别和当前使用哪个堆栈指针。

如果是具有浮点单元的 Cortex-M4 或者 Cortex-M7,控制寄存器也用来指示浮点单元当前是否在使用,浮点单元包含了 32 个浮点通用寄存器 S0~S31 和特殊 FPSCR 寄存器(Floating point status and control register)。

2.主堆栈和进程堆栈

Cortex-m3使用双堆栈PSP(进程堆栈)和MSP(主堆栈)。

MSP:复位后缺省使用的堆栈指针,用于操作系统内核以及异常处理例程(包括中断服务例程)

PSP:由用户的进程代码使用

复位结束后,所有代码都是使用主堆栈。异常处理程序可以通过改变其在退出时使用的EXC_RETURN值来改变线程模式使用的堆栈。所有异常继续使用主堆栈,堆栈指针r13是分组寄存器,在SP_main()和SP_process之间切换。在任何时候,进程堆栈和主堆栈只有一个是可见的,由r13指示

3.特权访问和用户访问

权限级别分为特权非特权(用户)

代码可以是特权执行和非特权执行,非特权执行时对有些资源的访问受到限制或不允许访问;特权执行可以访问所有资源。处理模式始终是特权访问,线程模式可以是特权也可以是非特权访问。

线程模式在复位后为特权访问,但是可以通过MSR指令清零CONTROL[0],将他配置成用户访问

3.工作模式

工作模式分为线程模式处理(handle)模式

线程模式可以工作在特权级和用户级,处理模式总是工作在特权级。

如果进入异常或中断处理则进入处理模式,其他情况则为线程模式。

处理模式总是用MSP作为堆栈

线程模式可选择MSP和PSP。

复位后默认进入线程模式,特权级,使用MSP堆栈。

图 3 操作模式和特权级别

4.中断向量表

上电最开始运行MCU内部的ROM,通常是芯片必要初始化,如Flash、RAM、时钟等,然后跳转到用户Flash区域运行代码。

Stm32中用户flash地址是0x08000000,cortex-m3规定用户flash区域前面必须是一张中断向量表。

Cortex-M 中断控制器名为 NVIC(嵌套向量中断控制器),支持中断嵌套功能。当一个中断触发并且系统进行响应时,处理器硬件会将当前运行位置的上下文寄存器自动压入中断栈中,这部分的寄存器包括 PSR、PC、LR、R12、R3-R0 寄存器。

图 4 嵌套向量中断控制器

当系统正在服务一个中断时,如果有一个更高优先级的中断触发,那么处理器同样会打断当前运行的中断服务程序,然后把这个中断服务程序上下文的 PSR、PC、LR、R12、R3-R0 寄存器自动保存到中断栈中。

  1. PendSV系统调用

PendSV 也称为可悬起的系统调用,它是一种异常,可以像普通的中断一样被挂起,它是专门用来辅助操作系统进行上下文切换的。PendSV 异常会被初始化为最低优先级的异常。每次需要进行上下文切换的时候,会手动触发 PendSV 异常,在 PendSV 异常处理函数中进行上下文切换。在下一章《内核移植》中会详细介绍利用 PendSV 机制进行操作系统上下文切换的详细流程。

3.自动初始化组件

实现手段是将需要初始化的函数接口通过链接器指令放在特殊的section中,当程序最终链接成一个image后,会形成一个标准格式的文件,其中armcc中叫做ARM ELF;

ELF文件就有将代码分成称为section的区域(段)可以指定自己的代码,放在指定名称的段中,且可以指定这个section段的ROM地址。这样当我们设计完成初始化接口后,通过链接器的指令以及链接脚本文件将我们的初始化代码放在特定的地方,并利用命名规则来做顺序排序等需要调用初始化的时候可以利用这些section的地址转换成函数指针直接批量循环调用。

通常在MDK的工程文件链接器参数中看到--keep *.o(.rti_fn.*),这是为了保证在链接阶段这些自定义段不被删除,同时也可以看出rti.fn就是自动初始化组件的section名字。

图 5 自动初始化宏

图 6 自动初始化宏,上图表示fn被放置于指定段中

其中fn表示为需要保存的函数名

图 7 map文件

可以看到在map文件中的

 .rti_fn.0       0x0800e224   Section        4  components.o(.rti_fn.0)

是通过INIT_EXPORT(rti_start, "0")完成,这里把函数改为__rt_init_rti_start存入.rti_fn.0这个地方。

综上:INIT_BOARD_EXPORT等宏,链接器会收集所有被申明的初始化函数,放到RTI段(section)中,具体的段名根据不同的宏确定,位于RO段中。

RTI段在系统初始化时自动调用所有函数。具体可以在map文件中查看.rti_fn.0。

rt_components_board_init() 函数执行的比较早,主要初始化相关硬件环境,执行这个函数时将会遍历通过 INIT_BOARD_EXPORT(fn) 申明的初始化函数表,并调用各个函数。

rt_components_init() 函数会在操作系统运行起来之后创建的 main 线程里被调用执行,这个时候硬件环境和操作系统已经初始化完成,可以执行应用相关代码。rt_components_init() 函数会遍历通过剩下的其他几个宏申明的初始化函数表。

RT-Thread 的自动初始化机制使用了自定义 RTI 符号段,将需要在启动时进行初始化的函数指针放到了该段中,形成一张初始化函数表,在系统启动过程中会遍历该表,并调用表中的函数,达到自动初始化的目的。

4.程序内存分布

一般MCU包含的存储空间有:片内Flash与片内RAM,RAM相当于内存,Flash相当于硬盘,编译器会将一个程序分类成好几个部分,分别存储在MCU不同的存储区。

Program size包括:

  1. code:代码段,存放程序代码
  2. RO-data:只读数据段,存放程序中定义的常量
  3. RW-data:读写数据段,存放初始化为非0值的全局变量
  4. ZI-data:0数据段,存放未初始化的全局变量及初始化为0的变量

RO段:code+RO-data,表示程序占用Flash空间的大小

RW段:RW-data及ZI-data,表示运行时占用的RAM大小

ROM:code+RO-data+RW-data,表示烧写程序时占用Flash的大小

程序运行前需要烧写文件到STM32的flash,一般是bin文件或者hex文件(被称为可执行映像文件)

图 8 内存分布

STM32上电启动后默认从Flash启动,启动之后会将RW段中的RW-data搬运到RAM中,但不会搬运RO段,因为RO段从主要存储代码和常量,CPU在执行代码时是从flash中读取的,不会搬运到内存(RAM),此外会根据编译器给出的ZI地址和大小分配ZI段。RAM中除了RW和ZI段,剩余的称为动态内存堆,应用程序的申请和释放都在该空间。

5.内核常用API

1.线程管理

1.简介

Rthread线程调度器是抢占式的,主要工作就是从就绪列表中找出最高优先级线程,保证最高优先级线程总能运行。

线程栈:用于存放函数中的局部变量,函数中的局部变量从栈空间申请;函数中局部变量初始化时从寄存器分配,当这个函数再调用另一个函数时,这些局部变量将存放在栈中。

图 9 线程栈

RT-Thread 线程的优先级是表示线程被调度的优先程度。每个线程都具有优先级,线程越重要,赋予的优先级就应越高,线程被调度的可能才会越大。

RT-Thread 最大支持 256 个线程优先级 (0~255),数值越小的优先级越高,0 为最高优先级。在一些资源比较紧张的系统中,可以根据实际情况选择只支持 8 个或 32 个优先级的系统配置;对于 ARM Cortex-M 系列,普遍采用 32 个优先级。最低优先级默认分配给空闲线程使用,用户一般不使用。在系统中,当有比当前线程优先级更高的线程就绪时,当前线程将立刻被换出,高优先级线程抢占处理器运行。

时间片:每个线程都有时间片这个参数,但时间片仅对优先级相同的就绪态线程有效。系统对优先级相同的就绪态线程采用时间片轮转的调度方式进行调度时,时间片起到约束线程单次运行时长的作用,其单位是一个系统节拍(OS Tick)。

注意:线程函数如果是无限循环模式(即存在(while(1))),那么线程中必须有让出CPU使用权的动作,比如循环中调用延时函数或者主动挂起。

空闲线程(idle):是系统创建的最低优先级的线程,线程状态永远是就绪。当系统中已无其他就绪线程存在时,调度器将调度到空闲线程。

空闲线程的作用:若某线程运行完毕,系统将自动删除线程:自动执行 rt_thread_exit() 函数,先将该线程从系统就绪队列中删除,再将该线程的状态更改为关闭状态,不再参与系统调度,然后挂入 rt_thread_defunct 僵尸队列(资源未回收、处于关闭状态的线程队列)中,最后空闲线程会回收被删除线程的资源。

空闲线程也提供了接口来运行用户设置的钩子函数,在空闲线程运行时会调用该钩子函数,适合处理功耗管理、看门狗喂狗等工作。空闲线程必须有得到执行的机会,即其他线程不允许一直while(1)死卡,必须调用具有阻塞性质的函数;否则例如线程删除、回收等操作将无法得到正确执行。

2.API

1.创建:rt_thread_create/init()

动态线程创建:

系统会从动态堆内存中分配一个线程句柄以及按照参数中指定的栈大小从动态堆内存中分配相应的空间

rt_thread_t rt_thread_create(const char*name,
                            void(*entry)(void*parameter),
                            void*parameter,
                            rt_uint32_t stack_size,
                            rt_uint8_t priority,
                            rt_uint32_t tick
);

Name:线程名

Entry:线程入口函数

Parameter:线程入口函数参数

stack_size:线程栈大小,单位字节

Priority:线程优先级,数值越小优先级越大,0表示最高优先级

Tick:线程时间片大小,单位是操作系统的时钟节拍。

Rt_thread_create()创建的线程使用rt_thread_delete()删除。

静态线程初始化:

静态线程的线程句柄(或者说线程控制块指针)、线程栈由用户提供。静态线程是指线程控制块、线程运行栈一般都设置为全局变量,在编译时就被确定、被分配处理,内核不负责动态分配内存空间

rt_err_t rt_thread_init(struct rt_thread* thread,const char*name,
                        void(*entry)(void* parameter),
                         void*parameter,
                        void*stack_start,
                        rt_uint32_t stack_size,
                        rt_uint8_t priority,
                        rt_uint32_t tick
);

Name:线程名

Entry:线程入口函数

Parameter:线程入口函数参数

Stack_start:线程栈起始地址

stack_size:线程栈大小,单位字节

Priority:线程优先级,数值越小优先级越大,0表示最高优先级

Tick:线程时间片大小,单位是操作系统的时钟节拍。

Rt_thread_init()初始化的线程,使用rt_thread_detach()将线程对象从线程队列和内核管理器中脱离。

2.启动:rt_thread_startup()

使用rt_thread_startup函数将创建/初始化的线程进入就绪态,等待调度。

rt_err_t rt_thread_startup(rt_thread_t thread);

3.获得当前线程:rt_thread_self()

在程序的运行过程中,相同的一段代码可能会被多个线程执行,在执行的时候可以通过下面的函数接口获得当前执行的线程句柄

rt_thread_t rt_thread_self(void);
4.使线程让出处理器资源:rt_thread_yield()

调用该函数后,当前线程首先把自己从它所在的就绪优先级线程队列中删除,然后把自己挂到这个优先级队列链表的尾部,然后激活调度器进行线程上下文切换(如果当前优先级只有这一个线程,则这个线程继续执行,不进行上下文切换动作)。

rt_err_t rt_thread_yield(void);

rt_thread_yield() 函数和 rt_schedule() 函数比较相像,但在有相同优先级的其他就绪态线程存在时,系统的行为却完全不一样。执行 rt_thread_yield() 函数后,当前线程被换出,相同优先级的下一个就绪线程将被执行。而执行 rt_schedule() 函数后,当前线程并不一定被换出,即使被换出,也不会被放到就绪线程链表的尾部,而是在系统中选取就绪的优先级最高的线程执行(如果系统中没有比当前线程优先级更高的线程存在,那么执行完 rt_schedule() 函数后,系统将继续执行当前线程)。

5.使线程睡眠:

在实际应用中,我们有时需要让运行的当前线程延迟一段时间,在指定的时间到达后重新运行,这就叫做 “线程睡眠”。

rt_err_t rt_thread_sleep(rt_tick_t tick);
rt_err_t rt_thread_delay(rt_tick_t tick);
rt_err_t rt_thread_mdelay(rt_int32_t ms);

这三个函数接口的作用相同,调用它们可以使当前线程挂起一段指定的时间,当这个时间过后,线程会被唤醒并再次进入就绪状态。

6.挂起和线程恢复:

当线程调用 rt_thread_delay() 时,线程将主动挂起;当调用 rt_sem_take(),rt_mb_recv() 等函数时,资源不可使用也将导致线程挂起。处于挂起状态的线程,如果其等待的资源超时(超过其设定的等待时间),那么该线程将不再等待这些资源,并返回到就绪状态;或者,当其他线程释放掉该线程所等待的资源时,该线程也会返回到就绪状态。

rt_err_t rt_thread_suspend(rt_thread_t thread);

注意:该函数只能用来挂起当前线程。

恢复线程就是让挂起的线程重新进入就绪状态,并将线程放入系统的就绪队列中;如果被恢复线程在所有就绪态线程中,位于最高优先级链表的第一位,那么系统将进行线程上下文的切换。

rt_err_t rt_thread_resume (rt_thread_t thread);
7.控制线程:
rt_err_t rt_thread_control(rt_thread_t thread, rt_uint8_t cmd, void* arg);

Thread:线程句柄

Cmd:指示控制命令

Cmd命令包括:

RT_THREAD_CTRL_CHANGE_PRIORITY:动态更改线程的优先级;

RT_THREAD_CTRL_STARTUP:开始运行一个线程,等同于 rt_thread_startup() 函数调用;

RT_THREAD_CTRL_CLOSE:关闭一个线程,等同于 rt_thread_delete() 或 rt_thread_detach() 函数调用。

Arg:控制参数

2.时钟管理

1.简介

任何操作系统都需要提供一个时钟节拍,以供处理所有与时间相关的事件,如线程的延时、线程时间片的轮转、定时器超时等,时钟节拍使特定的周期性中断,这个中断可以看作系统心跳。

时钟节拍有配置为中断触发模式的硬件定时器产生,当中断到来时,调用rt_tick_increase,对全局变量rt_tick进行自加,通知操作系统已经过去一个系统时钟,不同硬件定时器中断实现都不同,以STM32定时器为例:

void SysTick_Handler(void)
{
    
    /* 进入中断 */
    
    rt_interrupt_enter();
    ……
    
    rt_tick_increase();
    
    /* 退出中断 */
    
    rt_interrupt_leave();
}

2.定时器

定时器是指从指定时刻开始,经过一定的指定时间后触发一个事件。

硬件定时器:芯片本身提供的定时功能,一般是有外部晶振提供给芯片输入时钟,芯片向软件模块提供一组配置寄存器,接受控制输入,到达设定时间值后芯片中断控制器产生时钟中断。硬件定时器的精度一般很高,可以达到纳秒级别,并且是中断触发方式。

软件定时器:由操作系统提供的一类系统接口,它构建在硬件定时器基础之上,使系统能够提供不受数目限制的定时器服务。

Rtthread提供两类定时器:单次触发定时器和周期触发定时器。

定时器可以分为HADR_TIMER模式和SOFT_TIMER模式。

默认是HARD_TIMER模式,即定时器超时后,超时函数是在系统时钟中断的上下文环境中运行,中断上下文中的执行方式决定了定时器的超时函数不应该调用任何会让上下文挂起的系统函数;也不能够执行非常长的时间,否则会导致中断的响应时间加长或抢占其他线程执行时间。

SOFT_TIMER模式,系统会在初始化时创建一个timer线程,然后定时器超时函数会在timer线程的上下文中执行。

定时器工作机制:

在 RT-Thread 定时器模块中维护着两个重要的全局变量:

(1)当前系统经过的 tick 时间 rt_tick(当硬件定时器中断来临时,它将加 1);

(2)定时器链表 rt_timer_list。系统新创建并激活的定时器都会按照以超时时间排序的方式插入到 rt_timer_list 链表中。

如下图所示,系统当前 tick 值为 20,在当前系统中已经创建并启动了三个定时器,分别是定时时间为 50 个 tick 的 Timer1、100 个 tick 的 Timer2 和 500 个 tick 的 Timer3,这三个定时器分别加上系统当前时间 rt_tick=20,从小到大排序链接在 rt_timer_list 链表中,形成如图所示的定时器链表结构。

而 rt_tick 随着硬件定时器的触发一直在增长(每一次硬件定时器中断来临,rt_tick 变量会加 1),50 个 tick 以后,rt_tick 从 20 增长到 70,与 Timer1 的 timeout 值相等,这时会触发与 Timer1 定时器相关联的超时函数,同时将 Timer1 从 rt_timer_list 链表上删除。同理,100 个 tick 和 500 个 tick 过去后,与 Timer2 和 Timer3 定时器相关联的超时函数会被触发,接着将 Timer2 和 Timer3 定时器从 rt_timer_list 链表中删除。

如果系统当前定时器状态在 10 个 tick 以后(rt_tick=30)有一个任务新创建了一个 tick 值为 300 的 Timer4 定时器,由于 Timer4 定时器的 timeout=rt_tick+300=330, 因此它将被插入到 Timer2 和 Timer3 定时器中间,形成如下图所示链表结构:

3.定时器 API

1.创建删除定时器

动态创建:

rt_timer_t rt_timer_create(const char* name,                           
                            void(*timeout)(void* parameter),
                            void* parameter,
                            rt_tick_t time,
                            rt_uint8_t flag
);

Name:定时器名称

void (*timeout)(void* parameter):超市函数指针

Parameter:超时函数参数

Time:超时时间

Flag:参数

#define RT_TIMER_FLAG_ONE_SHOT      0x0     /* 单次定时     */#define RT_TIMER_FLAG_PERIODIC      0x2     /* 周期定时     */
#define RT_TIMER_FLAG_HARD_TIMER    0x0     /* 硬件定时器   */#define RT_TIMER_FLAG_SOFT_TIMER    0x4     /* 软件定时器   */

上述参数可以以“或”逻辑赋给flag。

硬件定时器:回调函数将在时钟中断的服务例程上下文中调用

软件定时器:回调函数将在系统时钟timer线程的上下文中被调用

系统不再使用动态定时器:

rt_err_t rt_timer_delete(rt_timer_t timer);

2.初始化和脱离定时器

静态创建定时器

void rt_timer_init(rt_timer_t timer,const char*name,
                   void(*timeout)(void*parameter),
                   void*parameter,
                   rt_tick_t time,
                   rt_uint8_t flag
);

Timer:定时器句柄

Name:定时器名称

void (*timeout)(void* parameter):超市函数指针

Parameter:超时函数参数

Time:超时时间

Flag:参数

脱离定时器:

rt_err_t rt_timer_detach(rt_timer_t timer);

3.启动和停止定时器

定时器被创建或者初始化后,并不会被立即启动,必须要在调用启动定时器函数接口后,才开始工作。

rt_err_t rt_timer_start(rt_timer_t timer);

停止定时器:

rt_err_t rt_timer_stop(rt_timer_t timer);

  1. 控制定时器
rt_err_t rt_timer_control(rt_timer_t timer, rt_uint8_t cmd, void*arg);

timer:定时器句柄

Cmd:控制命令

Arg:控制参数

Cmd支持的命令:

#define RT_TIMER_CTRL_SET_TIME      0x0 /* 设置定时器超时*/
#define RT_TIMER_CTRL_GET_TIME      0x1/* 获得定时器超时时间*/       #define RT_TIMER_CTRL_SET_ONESHOT 0x2 /* 设置定时器为单次定时器*/
#define RT_TIMER_CTRL_SET_PERIODIC  0x3     /* 设置定时器为周期型定时器 */

3.线程间同步

核心:在访问临界区的时候只允许一个(一类)线程运行。

进入退出临界区的方法:

  1. 调用rt_hw_interrupt_disable()进入临界区,调用rt_hw_interrupt_enable()退出临界区
  2. 调用rt_enter_critical()进入临界区,调用rt_exit_critical()退出临界区

1.信号量

1.简介

信号量是一种轻型的用于解决线程间同步问题的内核对象,线程可以获取和释放它,从而达到同步互斥的目的。

信号量工作示意图如下图所示,每个信号量对象都有一个信号量值和一个线程等待队列,信号量的值对应了信号量对象的实例数目、资源数目,假如信号量值为 5,则表示共有 5 个信号量实例(资源)可以被使用,当信号量实例数目为零时,再申请该信号量的线程就会被挂起在该信号量的等待队列上,等待可用的信号量实例(资源)。

图 10 信号量

2.信号量API

创建信号量:

rt_sem_t rt_sem_create(constchar*name,
                        rt_uint32_t value,
                        rt_uint8_t flag);

Name:信号量名称

Value:信号量初始值

Flag:信号量标志

RT_IPC_FLAG_FIFO:先入先出

RT_IPC_FLAG_PRIO:按优先级

删除信号量:

rt_err_t rt_sem_delete(rt_sem_t sem);

静态信号量初始化:

rt_err_t rt_sem_init(rt_sem_t  sem,const char*name,
                    rt_uint32_t    value,
                    rt_uint8_t     flag)

脱离信号量:

rt_err_t rt_sem_detach(rt_sem_t sem);

获取信号量:

线程通过获取信号量来或者信号量资源,当信号量值大于0时,线程将获得信号量,并且相应的信号量值减一。如果信号量值等于0,则说明信号量资源实例不可用,申请该信号量的线程将根据参数time的情况直接返回、挂起等待一段时间、或者永久等待,直到有其他线程或中断释放该信号量。如果在参数time指定的时间内依然得不到信号量,则线程超时返回,返回值是RT_ETIMEOUT。

无等待获取信号量:

rt_err_t rt_sem_trytake(rt_sem_t sem);

该函数相当于:rt_sem_take(sem,RT_WAITING_NO),当资源不可用时,直接返回,不会等待该信号量。

释放信号量:

释放信号量可以唤醒挂起在该信号量上的线程。

rt_err_t rt_sem_release(rt_sem_t sem);

当信号量值为0,并且有线程等待这个信号量时,释放信号量将唤醒等待在该信号量线程队列中的第一个线程,由他获取信号量,否则将信号量值加1。

PS:信号量的初始值设置为0

生产者消费者模型可以用信号量实现。

3.信号量的使用场合
1.线程同步

使用信号量作为两个线程之间的同步,信号量的初始值设置为0,表示有0个资源实例,如果尝试获得该资源,将在这个信号量上等待。

当持有信号量的线程完成它处理的工作时,释放这个信号量,并且唤醒等待该信号量上的线程。

2.锁

信号量作为锁来使用,通常应该将信号量资源实例初始化为1,代表系统默认有1个资源可用,这类用作锁的信号量始终在01之间变动(所有也叫做二值信号量)。

当线程需要访问临界资源时,需要先获取这个资源锁,当线程成功获取资源锁时,其他要访问该资源的线程将会被挂起,因为此时这个锁已经被锁上。当获取信号的线程处理完毕,退出临界区时,它会释放信号量并开锁,而挂在锁上的第一个等待线程将被唤醒以访问临界区。

值得注意的是:

用信号量来保护临界区会出现优先级反转问题,即低优先级的线程持有资源,导致高优先级的线程被阻塞。

优先级翻转举例:

假设有三个任务ABC优先级分别是高中低。首先是C执行并且占有共享资源,这时候B就绪,由于优先级低于BC会被阻塞,随后A也要访问共享资源,但是由于此时共享资源被C持有,并且CB阻塞,因此A需要等B执行完毕,并且C也执行完毕放出共享资源,才能执行,从而导致优先级翻转。

图 11 优先级翻转

因此一般不使用信号量作为锁来保护临界资源。

3.中断与线程同步

信号量也可以用于中断与线程间的同步。例如中断触发,中断服务程序需要通知线程进行相应的数据处理。此时信号量初始值为0,线程获取信号量后被挂起。当中断触发,先进行相应动作,例如从硬件I/O口读出数据,并确认中断清除中断源,随后释放一个信号量来唤醒挂起在该信号量上的线程,进行后续的数据处理。

图 12 串口中断与数据处理线程通过信号量通信

图 13 串口中断调用FIFO回调函数

4.资源计数

信号量也可以认为是一个递增或者递减的计数器(信号量非负值),资源计数适合于线程间工作处理速度不匹配的场合。

2.互斥量

互斥量又叫相互排斥的信号量,是一种特殊的二值信号量。

1.简介

互斥量与信号量不同的是:拥有互斥量的线程拥有互斥量的所有权,互斥量支持递归访问且能防止线程优先级翻转;并且互斥量只能由持有的线程释放,而信号量则可以由任何线程释放。

互斥量的状态只有两种,开锁和闭锁(两种状态值),当有线程持有时,互斥量处于闭锁状态,这个线程拥有它的所有权。当线程释放它时,互斥量开锁,线程失去它的所有权。

当一个线程持有互斥量时,其他线程不能够对它进行开锁或者持有它,持有该互斥量的线程可以再次获得这个锁而不被挂起。而信号量递归可能发送死锁。

2.优先级继承

使用信号量会导致线程优先级翻转问题如图8所示。

在rtthread操作系统中,互斥量可以解决优先级反转问题,通过优先级继承。优先级继承是通过在线程A在尝试获取共享资源而被挂起期间,将线程C的优先级提升到线程A的优先级级别,从而能够解决优先级翻转问题。

图 14 优先级继承

由图可知当低优先级的线程持有共享资源并阻塞高优先级线程时,会将低优先级线程的优先级提升到高优先级级别,防止被其他线程抢占CPU,当低优先级线程释放共享资源时,其优先级回到初始设定值。

3.互斥量API

创建删除互斥量

rt_mutex_t rt_mutex_create(constchar*name, rt_uint8_t flag);

Name:互斥量名称

Flag:flag 标志已经作废,无论用户选择 RT_IPC_FLAG_PRIO 还是 RT_IPC_FLAG_FIFO,内核均按照 RT_IPC_FLAG_PRIO 处理。

删除互斥量:

当删除互斥量时,所有等待此互斥量的线程都会被唤醒

rt_err_t rt_mutex_delete (rt_mutex_t mutex);

初始化互斥量:

静态互斥量对象的内存是在系统编译时由编译器分配,一般放于读写数据或者未初始化数据段中

rt_err_t rt_mutex_init (rt_mutex_t mutex, const char* name, rt_uint8_t flag);

Mutex:互斥量句柄

Name:互斥量名称

Flag:flag 标志已经作废,无论用户选择 RT_IPC_FLAG_PRIO 还是 RT_IPC_FLAG_FIFO,内核均按照 RT_IPC_FLAG_PRIO 处理。

脱离互斥量:

rt_err_t rt_mutex_detach (rt_mutex_t mutex);

获取互斥量:

rt_err_t rt_mutex_take(rt_mutex_t mutex, rt_int32_t time);

如果互斥量没有被其他线程控制,那么申请该互斥量的线程将成功获得该互斥量。如果互斥量已经被当前线程控制,那么该互斥量的计数加1,当前线程也不会挂起等待。

如果互斥量被其他线程占有,则当前线程在该互斥量上挂起等待,直到其他线程释放它,或者等待时间超时

无等待获取互斥量:

rt_err_t rt_mutex_trytake(rt_mutex_t mutex);

这个函数相当于:rt_mutex_take(mutex,RT_WAITING_NO);即当线程申请的互斥量资源不可用时,它不会等待在该互斥量上,而是直接返回-RT_ETIMEOUT

释放互斥量:

当线程完成互斥量的访问后,应该尽快释放它占据的互斥量,使得其他线程能够及时获取该互斥量

rt_err_t rt_mutex_release(rt_mutex_t mutex);

值得注意的是:

只有拥有该互斥量所有权的线程才能够释放它,每释放一次,它持有的计数减1,当互斥量持有的计数值为0时(即持有的线程已经释放所有的持有操作),它变为可用,等待该互斥量上的线程将被唤醒。如果线程运行的优先级被互斥量提升,那么当互斥量释放后,线程恢复为持有互斥量前的优先级。

互斥量不能在中断服务例程中使用

4.互斥量使用场合

互斥量的使用比较单一,因为它是信号量的一种,并且它是以锁的形式存在。在初始化的时候,互斥量永远都处于开锁的状态,而被线程持有的时候则立刻转为闭锁的状态。互斥量更适合于:

(1)线程多次持有互斥量的情况下。这样可以避免同一线程多次递归持有而造成死锁的问题。

(2)可能会由于多线程同步而造成优先级翻转的情况。

实例:

void mutex_thread_entry1()

{

    rt_thread_mdelay(100);

    if(mutex_thread2->current_priority != mutex_thread3->current_priority)

    {

        rt_kprintf(" thread2 prio =  %d  thtead3 prio =  %d\n",mutex_thread2->current_priority,mutex_thread3->current_priority);

    }

    else

    {

         rt_kprintf(" thread2 prio = thtead3 prio =  %d\n",mutex_thread3->current_priority);

    }

}

void mutex_thread_entry2()

{

    rt_err_t res;

    rt_kprintf("thread2 prio = %d\n",mutex_thread2->current_priority);

    rt_thread_mdelay(50);



    res = rt_mutex_take(mutex_demo,RT_WAITING_FOREVER);

    if(res == RT_EOK)

    {

        rt_mutex_release(mutex_demo);

    }

}

void mutex_thread_entry3()

{

    rt_err_t res;

    rt_tick_t tick;

    rt_kprintf("thread3 prio = %d\n",mutex_thread3->current_priority);

    res = rt_mutex_take(mutex_demo,RT_WAITING_FOREVER);

    if(res != RT_EOK)

    {

        rt_kprintf("thread3 take mutex fail\n");

    }

    tick = rt_tick_get();

    while (rt_tick_get() - tick < (RT_TICK_PER_SECOND / 2)) ;//长时间延时,以便线程一能够在互斥锁释放前,读到线程2线程3的优先级
       

    rt_mutex_release(mutex_demo);
 

}

3.事件集

1.简介

事件集可以实现一对多、多对多的同步;即一个线程与多个事件的关系可以设置为:其中任意一个事件唤醒线程,或者几个事件都到达后才唤醒线程;事件也可以是多个线程同步多个事件。线程通过“逻辑与”,“逻辑或”将一个或多个事件关联起来,形成事件组合。

Rtthread事件集的特点:

  1. 事件只与线程相关,事件间相互独立:每个线程可拥有32个事件标志位;采用一个无符号整型数记录,每个bit代表一个事件。
  2. 事件只能用于同步,不传输数据
  3. 事件无排队性,即多次发送同一事件,等同于只发一次。
  4. 每个线程拥有一个事件信息标记,有三个属性:RT_EVENT_FLAG_AND(逻辑与)、RT_EVENT_FLAG_OR(逻辑或)以及RT_EVENT_FLAG_CLEAR(清除标记)
2.事件集API

创建事件集:

rt_event_t rt_event_create(const char* name, rt_uint8_t flag);

Name:事件集名

Flag: RT_IPC_FLAG_FIFO 或 RT_IPC_FLAG_PRIO

删除事件集:

rt_err_t rt_event_delete(rt_event_t event);

在删除前会唤醒所有挂起在该事件集上的线程。

初始化事件集:

rt_err_t rt_event_init(rt_event_t event, const char* name,rt_uint8_t flag);

Event:事件集句柄

Name:事件集名

Flag: RT_IPC_FLAG_FIFO 或 RT_IPC_FLAG_PRIO

脱离事件集:

rt_err_t rt_event_detach(rt_event_t event);

发送事件:

使用该接口时,通过参数set指定的事件标志来设定event事件集对象的事件标志值,然后遍历等待在event事件集对象上的等待线程链表,判断是否有线程事件激活要求与当前event对象事件标志值匹配,如果有则唤醒该线程。

rt_err_t rt_event_send(rt_event_t event,rt_uint32_t set);

Event:事件集对象的句柄

Set:发送一个或多个事件的标志值

接收事件:

内核使用32位无符号整数来标识事件集,它的每一位代表一个事件,因此可同时等待接收32个事件,可通过“逻辑与”,“逻辑或”组合。

rt_err_t rt_event_recv(rt_event_t event,
                         rt_uint32_t set,
                         rt_uint8_t option,
                         rt_int32_t timeout,  
                         rt_uint32_t* recved
);

当用户调用这个接口时,系统首先根据 set 参数和接收选项 option 来判断它要接收的事件是否发生,如果已经发生,则根据参数 option 上是否设置有 RT_EVENT_FLAG_CLEAR 来决定是否重置事件的相应标志位,然后返回(其中 recved 参数返回接收到的事件);如果没有发生,则把等待的 set 和 option 参数填入线程本身的结构中,然后把线程挂起在此事件上,直到其等待的事件满足条件或等待时间超过指定的超时时间。如果超时时间设置为零,则表示当线程要接受的事件没有满足其要求时就不等待,而直接返回 - RT_ETIMEOUT。

Event:事件集对象句柄

Set:接收线程感兴趣的事件

Option:接收选项

Timeout:指定超时时间

Recved:指向接收的事件

Option可选:

/* 选择 逻辑与 或 逻辑或 的方式接收事件 */RT_EVENT_FLAG_ORRT_EVENT_FLAG_AND/* 选择清除重置事件标志位 */RT_EVENT_FLAG_CLEAR
3.事件集使用场合

事件集可使用于多种场合,它能够在一定程度上替代信号量,用于线程间同步。一个线程或中断服务例程发送一个事件给事件集对象,而后等待的线程被唤醒并对相应的事件进行处理。但是它与信号量不同的是,事件的发送操作在事件未清除前,是不可累计的,而信号量的释放动作是累计的。事件的另一个特性是,接收线程可等待多种事件,即多个事件对应一个线程或多个线程。同时按照线程等待的参数,可选择是 “逻辑或” 触发还是 “逻辑与” 触发。这个特性也是信号量等所不具备的,信号量只能识别单一的释放动作,而不能同时等待多种类型的释放。

图 15 数据处理线程解析出命令,主线程做出响应

4.完成量

1.简介

完成量是一种轻量级的线程间同步机制。

完成量是一种更加轻便的线程间同步的是一种方式,可以理解为轻量级的二值信号量,可以用于线程和线程见的同步,也可以用于线程和中断之间的同步。

信号量一种非常灵活的同步方式,可以运用在多种场合中。形成锁、同步、资源计数等关系,也能方便的用于线程与线程、中断与线程间的同步中。

完成量不支持在某个线程中调用了rt_completion_wait,还未唤醒退出时,在另一个线程中调用该函数。

PS:当完成量用于线程和中断之间的同步时,中断函数只能调用rt_completion_done接口,而不能调用rt_completion_wait接口,因为wait接口是阻塞型接口,不能用于中断中。

完成量控制块结构体:

struct rt_completion{   rt_uint32_t flag;//表征当前完成量对象的状态   /* suspended list */   rt_list_t suspended_list;};

Flag可取:

#define RT_COMPLETED    1//表征当前完成量对象已经完成量某一个工作,可以继续下一个工作#define RT_UNCOMPLETED  0//表征当前需要等待某一个工作结束才能继续下一个工作

2.完成量API

初始化完成量:

void rt_completion_init(struct rt_completion *completion)

完成量只支持静态对象,不支持动态生成对象,在调用接口时需要传递一个静态的完成量对象指针。

在完成量初始化接口中,只完成两件事,设置flag为RT_UNCOMPLETED,然后初始化完成量对象的suspend线程链表。

等待完成量:

等待完成量接口用于等待某一个动作完成,根据timeout的不同可以设置超时退出或者永久等待处理。

rt_err_t rt_completion_wait(struct rt_completion *completion,                            rt_int32_t            timeout)

completion:完成量对象指针

timeout:单位是tick,当timeout为0时返回RT_ETIMEOUT

等待接口中会做:

  1. 调用 rt_thread_self 获取当前线程对象,为后续挂起当前线程做准备。
  2. 调用 rt_hw_interrupt_disable 和 rt_hw_interrupt_enable 完成接下来的原子操作。
  3. 如果调用该接口前,已经调用 rt_completion_done 指示完成了某个工作,那么直接设置 flag 为 RT_UNCOMPLETED,为下一次等待做准备,函数退出。
  4. 如果调用该接口时,还未完成某个工作,判断当前完成量对象的等待线程链(suspended_list)是否已经有线程在等待该完成量了,如果已经有,则程序直接异常挂起,如果没有正在等待的线程,那么流程继续。
  5. 执行挂起当前线程的动作(因为挂起的时当前线程,因此需要后续执行 rt_schedule 才会真正执行挂起)。
  6. 将当前线程挂在了完成量对象的 suspended_list 链表中。
  7. 执行 RT_DEBUG_NOT_IN_INTERRUPT,确保当前函数不是在中断函数中执行。
  8. 启动定时器,设置超时唤醒当前线程的逻辑。
  9. 执行 rt_schedule(); ,真正挂起当前线程。

图 16 等待完成量

指示完成:

指示完成接口用于只是某一动作已经完成,指示完成后,其他线程可以获取到该完成状态,并继续运行。

void rt_completion_done(struct rt_completion *completion)

指示完成会做:

1.如果当前 flag 已经是完成状态,接口直接返回。

2.当前 flag 为未完成状态,设置接下来的原子操作保护。

3.获取当前完成量挂起线程链表,并唤醒该线程。

图 17 指示完成

完成量的使用:

在等待线程中,或者其他线程中调用 rt_completion_init 初始化完成量对象。

在等待线程中,处理完一定逻辑后,需要等待某个工作完成时,调用 rt_completion_wait 接口,等待信号量完成后,程序阻塞等待。

子指示完成的线程中,执行 rt_completion_done 接口,通知等待线程,可以继续执行工作。

可能需要往复循环。

图 18 完成量的使用

4.线程间通信

在裸机编程中经常用全局变量进行功能间的通信。在rtthread操作系统中提供邮箱、消息队列、信号用于线程间的通信。

1.邮箱

1.简介

邮箱是操作系统中一种经典的线程间通信方式。其优点是开销低,效率较高。邮箱中的每一封邮件只能容纳固定4字节内容(32位操作系统指针的大小即为4字节,所有邮箱刚好容纳一个指针)。

图 19 邮箱

非阻塞方式的邮箱发送过程能过安全的应用于中断服务中,是线程、中断服务、定时器向线程发送消息的有效手段。邮件的收取过程可能是阻塞的,这由邮箱中是否有邮件以及收取邮件的超时时间决定。当邮箱中不存在邮件且超时时间不为0时,邮件的收取方式变为阻塞。阻塞的情况只能由线程进行邮件的收取。

发送邮件:

当一个线程向邮箱发送邮件时,如果邮箱没满,将把邮件复制到邮箱;如果邮箱满了,发送线程可以设置超时时间,选择等待挂起或直接返回,如果发送线程选择挂起等待,那么当邮箱中的邮件被收取空间空出来时,等待挂起的发送线程将被唤醒继续发送。

接收邮件:

当一个线程从邮箱接收邮件,如果邮箱是空,接收线程可以选择等待挂起直到接收到新邮件而唤醒,或可以设置超时时间。当达到设置的超时时间,依然没有收到邮件,这时等待的线程将被唤醒并返回。如果邮箱中存在邮件,那么接收线程将复制邮箱中的4字节邮件到缓存。

2.邮箱API

创建邮箱:

rt_mailbox_t rt_mb_create (const char* name, rt_size_t size, rt_uint8_t flag);

Name:邮箱名

Size:邮箱容量(可以存放多少邮件)

Flag:RT_IPC_FLAG_FIFO 或 RT_IPC_FLAG_PRIO

删除邮箱:

rt_err_t rt_mb_delete (rt_mailbox_t mb);

初始化邮箱:

 rt_err_t rt_mb_init(rt_mailbox_t mb, const char*name,  void*msgpool,                    rt_size_t size,                    rt_uint8_t flag)

Mb:邮箱句柄

Name:邮箱名

Msgpool:缓冲区指针

Size:邮箱容量

Flag:RT_IPC_FLAG_FIFO 或 RT_IPC_FLAG_PRIO

如果缓存区字节数是N,邮箱容量应该是N/4。

脱离邮箱:

rt_err_t rt_mb_detach(rt_mailbox_t mb);

发送邮件:

线程或者中断服务例程可以通过邮箱给其他线程发送邮件

当邮箱中的邮件已经满时,发送邮件的线程或者中断程序会收到 -RT_EFULL 的返回值

rt_err_t rt_mb_send (rt_mailbox_t mb, rt_uint32_t value);

Mb:邮箱句柄

Value:邮箱内容

等待方式发送邮件:

rt_err_t rt_mb_send_wait (rt_mailbox_t mb,                      rt_uint32_t value,                     rt_int32_t timeout);

Mb:邮箱句柄

Value:邮箱内容

Timeout:超时时间

rt_mb_send_wait() 与 rt_mb_send() 的区别在于有等待时间,如果邮箱已经满了,那么发送线程将根据设定的 timeout 参数等待邮箱中因为收取邮件而空出空间。如果设置的超时时间到达依然没有空出空间,这时发送线程将被唤醒并返回错误码

发送紧急邮件:

发送紧急邮件的过程与发送邮件几乎一样,唯一的不同是,当发送紧急邮件时,邮件被直接插队放入了邮件队首,这样,接收者就能够优先接收到紧急邮件,从而及时进行处理。

rt_err_t rt_mb_urgent (rt_mailbox_t mb, rt_ubase_t value);

接收邮件:

只有当邮箱中有邮件,接收者才能立即取到邮件并且返回RT_EOK,否则根据超时时间等待,或者挂起在等待队列,或直接返回。

rt_err_t rt_mb_recv (rt_mailbox_t mb, rt_uint32_t* value, rt_int32_t timeout);

Mb:邮箱句柄

Value:邮箱内容

Timeout:超时时间

3.邮箱使用场合

邮箱是一种简单的线程间消息传递方式,特点是开销比较低,效率较高。在 RT-Thread 操作系统的实现中能够一次传递一个 4 字节大小的邮件,并且邮箱具备一定的存储功能,能够缓存一定数量的邮件数 (邮件数由创建、初始化邮箱时指定的容量决定)。邮箱中一封邮件的最大长度是 4 字节,所以邮箱能够用于不超过 4 字节的消息传递。由于在 32 系统上 4 字节的内容恰好可以放置一个指针,因此当需要在线程间传递比较大的消息时,可以把指向一个缓冲区的指针作为邮件发送到邮箱中,即邮箱也可以传递指针,例如:

struct msg{   
        ​​​​​​​rt_uint8_t *data_ptr;
        rt_uint32_t data_size;
};

对于这样一个消息结构体,其中包含了指向数据的指针 data_ptr 和数据块长度的变量 data_size。当一个线程需要把这个消息发送给另外一个线程时,可以采用如下的操作:

struct msg* msg_ptr;

msg_ptr =(structmsg*)rt_malloc(sizeof(structmsg));

msg_ptr->data_ptr = ...;
/* 指向相应的数据块地址 */
msg_ptr->data_size = len;

/* 数据块的长度 *//* 发送这个消息指针给 mb 邮箱 */
rt_mb_send(mb, (rt_uint32_t)msg_ptr);

而在接收线程中,因为收取过来的是指针,而 msg_ptr 是一个新分配出来的内存块,所以在接收线程处理完毕后,需要释放相应的内存块:

struct msg* msg_ptr;
if(rt_mb_recv(mb, (rt_uint32_t*)&msg_ptr) == RT_EOK)
{
    /* 在接收线程处理完毕后,需要释放相应的内存块 */
    rt_free(msg_ptr);
}

实例:

void mb_thread1_entry()

{

    struct mbmsg *mb;

    mb = (struct mbmsg*)rt_malloc(sizeof(struct mbmsg));

    mb->vla = "HELLO RTTHREAD";

    mb->size = rt_strlen((mb)->vla);

    rt_kprintf("rtthread1 send mb\n");

   

    rt_mb_send(mail_t,(rt_ubase_t)mb);

}



void mb_thread2_entry()

{

    struct mbmsg* mb;

    rt_err_t res;

    rt_kprintf("thread2 wait mb\n");

    res = rt_mb_recv(mail_t,(rt_ubase_t*)&mb,RT_WAITING_FOREVER);

    if(res == RT_EOK)

    {

        rt_kprintf("thread2 recv ok,str = %s\n",mb->vla);

    }

    rt_mb_delete(mail_t);    

}

2.消息队列

消息队列是邮箱的一种扩展,可以用于线程间的消息交换、使用串口接收不定长数据

1.简介

消息队列能够用于接收来自线程或者中断服务例程中的不固定长度的消息,并把消息缓存在自己的内存空间。其他线程可以从消息队列中读取相应的信息,当消息队列是空时,可以挂起读取线程,有新消息到达时,挂起的现象将被唤醒并接收处理消息。消息队列是一种异步的通信方式。消息队列遵守先进先出(FIFO)原则。

图 20 消息队列

每个消息队列中包含多个消息框,每个消息框可以存放一条消息;消息队列中第一条和最后一条消息框被称为消息链表头(msg_queue_head )和消息链表尾(msg_queue_head ),有些消息框可能是空的,通过msg_queue_free形成一个空闲消息框链表,消息框的总数即为消息队列的长度。

图 21 消息队列控制块结构体

2.消息队列API

创建消息队列:

rt_mq_t rt_mq_create(const char* name,         
                  rt_size_t msg_size,            
                  rt_size_t max_msgs,
                    rt_uint8_t flag);

Name:消息队列名

msg_size:消息队列中一条信息的最大长度,单位字节

max_msgs:消息队列的最大个数

Flag: RT_IPC_FLAG_FIFO 或 RT_IPC_FLAG_PRIO

删除消息队列:

rt_err_t rt_mq_delete(rt_mq_t mq);

初始化消息队列:

rt_err_t rt_mq_init(rt_mq_t mq, const char* name,void*msgpool,                         rt_size_t msg_size,
                         rt_size_t pool_size,
                          rt_uint8_t flag);

Mq:消息队列句柄

Name:消息队列名

Msgpool:指向存放消息的缓冲区指针

msg_size:一条消息的最大长度

pool_size:存放消息的缓冲区大小

Flag: RT_IPC_FLAG_FIFO 或 RT_IPC_FLAG_PRIO

脱离消息队列:

rt_err_t rt_mq_detach(rt_mq_t mq);

发送消息:

线程或者中断服务程序都可以给消息队列发送消息,当发送消息时,消息队列对象先从空闲消息链表上取下一个空闲消息块,把发送的消息复制到消息块上,然后把该消息块挂载在消息链表的尾部。当空闲消息链表上有可用的空闲消息块时,发送者才能成功发送消息;当空闲消息链表上无可用消息块时,说明消息队列已满,此时发送消息的线程或者中断服务程序会收到一个错误码

rt_err_t rt_mq_send (rt_mq_t mq, void* buffer, rt_size_t size);

Mq:消息队列句柄

Buffer:消息内容

Size:消息大小

等待方式发送消息:

rt_err_t rt_mq_send_wait(rt_mq_t mq,const void*buffer,                         rt_size_t   size,
                         rt_int32_t  timeout);

Mq:消息队列句柄

Buffer:消息内容

Size:消息大小

Timeout:超时时间

rt_mq_send_wait() 与 rt_mq_send() 的区别在于有等待时间,如果消息队列已经满了,那么发送线程将根据设定的 timeout 参数进行等待。如果设置的超时时间到达依然没有空出空间,这时发送线程将被唤醒并返回错误码。

发送紧急消息:

从空闲消息链表上取下来的消息块会挂在队首,这样接收者优先收到紧急消息。

rt_err_t rt_mq_urgent(rt_mq_t mq, void* buffer, rt_size_t size);

接收消息:

rt_ssize_t rt_mq_recv (rt_mq_t mq, void* buffer,                    rt_size_t size,
                      rt_int32_t timeout);

Mq:消息队列句柄

Buffer:消息内容

Size:消息大小

Timeout:超时时间

3.消息队列使用场合

消息队列应用于发送不定长消息的场合,包括线程与线程间的消息交换,以及中断服务例程中给线程发送消息(中断服务例程不能接收消息)

  1. 发送消息

消息队列和邮箱的明显不同是消息的长度并不限定在 4 个字节以内;另外,消息队列也包括了一个发送紧急消息的函数接口。但是当创建的是一个所有消息的最大长度是 4 字节的消息队列时,消息队列对象将蜕化成邮箱。这个不限定长度的消息,也及时的反应到了代码编写的场合上,同样是类似邮箱的代码:

struct msg{
    rt_uint8_t *data_ptr;        /* 数据块首地址 */
    rt_uint32_t data_size;      /* 数据块大小   */
};

和邮箱例子相同的消息结构定义,假设依然需要发送这样一个消息给接收线程。在邮箱例子中,这个结构只能够发送指向这个结构的指针(在函数指针被发送过去后,接收线程能够正确的访问指向这个地址的内容,通常这块数据需要留给接收线程来释放)。而使用消息队列的方式则大不相同:

void send_op(void*data, rt_size_t length){
    struct msg msg_ptr;
    msg_ptr.data_ptr = data;              /* 指向相应的数据块地址 */
    msg_ptr.data_size = length;           /* 数据块的长度 */

    /* 发送这个消息指针给 mq 消息队列 */
    rt_mq_send(mq,(void*)&msg_ptr,sizeof(structmsg));
}

注意,上面的代码中,是把一个局部变量的数据内容发送到了消息队列中。在接收线程中,同样也采用局部变量进行消息接收的结构体:

void message_handler(){
  struct msg msg_ptr;/* 用于放置消息的局部变量 */
    
   /* 从消息队列中接收消息到 msg_ptr 中 */
    if(rt_mq_recv(mq,(void*)&msg_ptr,sizeof(structmsg), RT_WAITING_FOREVER)>0)
    {
        /* 成功接收到消息,进行相应的数据处理 */
    }
}

因为消息队列是直接的数据内容复制,所以在上面的例子中,都采用了局部变量的方式保存消息结构体,这样也就免去动态内存分配的烦恼了(也就不用担心,接收线程在接收到消息时,消息内存空间已经被释放)。

实例:

void mq_thread1() //发送消息

{

    msgQueue mg;

        rt_err_t res;

    mg.val = "HELLO WORLD";

    mg.size = rt_strlen(mg.val);



    rt_kprintf("thread1 send msg\n");

    res = rt_mq_send(mq,(void *)&mg,sizeof(msgQueue));

        if(res != RT_EOK)

        {

            rt_kprintf("thread1 send msg fail\n");

        }

}

void mq_thread2()//接收消息

{

    msgQueue mg;

    rt_err_t res;

    rt_size_t size  = sizeof(msgQueue);

        rt_kprintf("thread2 wait msg\n");

    res = rt_mq_recv(mq,(void *)&mg,size,RT_WAITING_FOREVER);

    if(res == RT_EOK)

    {

        rt_kprintf("thread2 recv OK! msg = %s\n",mg.val);

    }

        else

        {

            rt_kprintf("fail\n");

        }

}

  1. 同步消息

在一般的系统设计中会经常遇到要发送同步消息的问题,这个时候就可以根据当时状态的不同选择相应的实现:两个线程间可以采用 [消息队列 + 信号量或邮箱] 的形式实现。发送线程通过消息发送的形式发送相应的消息给消息队列,发送完毕后希望获得接收线程的收到确认,工作示意图如下图所示:

图 22 消息队列发送同步消息

根据消息确认的不同,可以把消息结构体定义成:

struct msg{
    /* 消息结构其他成员 */
    structrt_mailbox ack;
};

/* 或者 */
struct msg{
    /* 消息结构其他成员 */
    structrt_semaphore ack;
};

第一种类型的消息使用了邮箱来作为确认标志,而第二种类型的消息采用了信号量来作为确认标志。邮箱作为确认标志,代表着接收线程能够通知一些状态值给发送线程;而信号量作为确认标志只能够单一的通知发送线程,消息已经确认接收。

3.信号

1.简介

信号(又称为软中断信号),在软件层次上是对中断机制的一种模拟,在原理上,一个线程收到一个信号与处理器收到一个中断请求可以说是类似的。

信号在 RT-Thread 中用作异步通信,POSIX 标准定义了 sigset_t 类型来定义一个信号集,然而 sigset_t 类型在不同的系统可能有不同的定义方式,在 RT-Thread 中,将 sigset_t 定义成了 unsigned long 型,并命名为 rt_sigset_t,应用程序能够使用的信号为 SIGUSR1(10)和 SIGUSR2(12)。

信号本质是软中断,用来通知线程发生了异步事件,用做线程之间的异常通知、应急处理。一个线程不必通过任何操作来等待信号的到达,事实上,线程也不知道信号到底什么时候到达,线程之间可以互相通过调用 rt_thread_kill() 发送软中断信号。

收到信号的线程对各种信号有不同的处理方法,处理方法可以分为三类:

第一种是类似中断的处理程序,对于需要处理的信号,线程可以指定处理函数,由该函数来处理。

第二种方法是,忽略某个信号,对该信号不做任何处理,就像未发生过一样。

第三种方法是,对该信号的处理保留系统的默认值。

如下图所示,假设线程 1 需要对信号进行处理,首先线程 1 安装一个信号并解除阻塞,并在安装的同时设定了对信号的异常处理方式;然后其他线程可以给线程 1 发送信号,触发线程 1 对该信号的处理。

图 23 信号

当信号被传递给线程 1 时,如果它正处于挂起状态,那会把状态改为就绪状态去处理对应的信号。如果它正处于运行状态,那么会在它当前的线程栈基础上建立新栈帧空间去处理对应的信号,需要注意的是使用的线程栈大小也会相应增加。

2.信号API

安装信号:

如果线程要处理某一信号,那么就需要在线程中安装该线程。安装的线程主要用来确定信号值以及线程针对该信号值的动作之间的映射关系,即线程将要处理那个信号,该信号被传递给线程时将要执行哪些操作。

rt_sighandler_t rt_signal_install(int signo, rt_sighandler_t[] handler);

Signo:信号值(只有SIGUSR1和SIGUSR2给用户使用)

Handle:设置信号的处理方式

在安装信号时,设定handle参数,决定该信号的不同的处理方法。可分为三种:

  1. 自定义处理函数,由该函数来处理
  2. 参数设置为SIG_IGN,忽略参数
  3. 参数设置位SIG_DFL,系统会调用默认的处理函数_signal_default_handler()。

阻塞信号:

信号阻塞也可以说是信号屏蔽,如果该信号被阻塞,则该信号将不会传递给安装此信号的线程,也不会引发软中断处理。

void rt_signal_mask(int signo);

解除信号阻塞:

线程中可以安装好几个信号,使用此函数可以对其中一些信号给予 “关注”,那么发送这些信号都会引发该线程的软中断。调用 rt_signal_unmask() 可以用来解除信号阻塞:

void rt_signal_unmask(int signo);

发送信号:

当需要进行异常处理时,可以给设定了处理异常的线程发送信号

int rt_thread_kill(rt_thread_t tid, int sig);

等待信号:

等待set信号到来,如果没有等到这个信号,则将线程挂起,直到等到这个信号或者等待时间超时,如果等到了该信号,则将指向该信号体的指针存入si

int rt_signal_wait(const rt_sigset_t *set,
                        rt_siginfo_t[] *si,
                        rt_int32_t timeout
);

Set:指定等待的信号

Si:指向存储等到信号信息的指针

Timeout:指定等待时间

5.内存管理

在计算机系统中,存储空间通常分为两种:内部存储空间和外部存储空间。

内部存储空间:访问速度比较快,能够按照变量地址随机访问(RAM:随机存储器)

外部存储空间:保存的内容相对比较固定,掉电数据不会丢失(ROM:只读存储器)

计算机系统中,变量、中间数据一般存放在RAM,只有实际使用时才将它们从RAM调到CPU计算。一些数据需要的内存大小需要在程序运行过程中根据实际情况确定,这就要求系统具有对内存空间进行动态管理的能力。

由于实时操作系统对时间的严格要求,内存管理也会比通用操作系统要求苛刻。要求如下:

  1. 分配内存的时间必须是确定的。

一般内存管理算法是根据需要存储的数据的长度在内存中去寻找一个与这段数据相适应的空闲内存块,然后将数据存储在里面。而寻找这样一个空闲内存块所耗费的时间是不确定的,因此对于实时系统来说,这就是不可接受的,实时系统必须要保证内存块的分配过程在可预测的确定时间内完成,否则实时任务对外部事件的响应也将变得不可确定。

  1. 对内存碎片的管理

随着内存不断被分配和释放,整个内存区域会产生越来越多的碎片(因为在使用过程中,申请了一些内存,其中一些释放了,导致内存空间中存在一些小的内存块,它们地址不连续,不能够作为一整块的大内存分配出去),系统中还有足够的空闲内存,但因为它们地址并非连续,不能组成一块连续的完整内存块,会使得程序不能申请到大的内存。对于通用系统而言,这种不恰当的内存分配算法可以通过重新启动系统来解决 (每个月或者数个月进行一次),但是对于那些需要常年不间断地工作于野外的嵌入式系统来说,就变得让人无法接受了。

  1. 针对不同的资源环境,选择合适的内存分配算法

嵌入式系统的资源环境也是不尽相同,有些系统的资源比较紧张,只有数十 KB 的内存可供分配,而有些系统则存在数 MB 的内存,如何为这些不同的系统,选择适合它们的高效率的内存分配算法,就将变得复杂化。

RT-Thread操作系统在内存管理上,根据不同的资源等,提出了不同的内存分配算法:内存堆管理和内存池管理。

1.内存堆管理

内存堆管理可以分为:小内存管理算法(针对小内存块)、slab管理算法(针对大内存块)、memheap管理算法(针对多内存堆)。

小内存管理算法主要针对系统资源比较少,一般用于小于 2MB 内存空间的系统;而 slab 内存管理算法则主要是在系统资源比较丰富时,提供了一种近似多内存池管理算法的快速算法。除上述之外,RT-Thread 还有一种针对多内存堆的管理算法,即 memheap 管理算法。memheap 方法适用于系统存在多个内存堆的情况,它可以将多个内存 “粘贴” 在一起,形成一个大的内存堆,用户使用起来会非常方便。

1.小内存管理算法

小内存管理算法是一个简单的内存分配算法。初始时,它是一块大的内存。当需要分配内存块时,将从这个大的内存块上分割出相匹配的内存块,然后把分割出来的空闲内存块还回给堆管理系统中。每个内存块都包含一个管理用的数据头,通过这个头把使用块与空闲块用双向链表的方式链接起来,如下图所示:

图 24 小内存管理算法

每个内存块(不管是已分配的内存块还是空闲的内存块)都包含一个数据头,其中包括:

1)magic:变数(或称为幻数),它会被初始化成 0x1ea0(即英文单词 heap),用于标记这个内存块是一个内存管理用的内存数据块;变数不仅仅用于标识这个数据块是一个内存管理用的内存数据块,实质也是一个内存保护字:如果这个区域被改写,那么也就意味着这块内存块被非法改写(正常情况下只有内存管理器才会去碰这块内存)。

2)used:指示出当前内存块是否已经分配。

内存管理的表现主要体现在内存的分配与释放上,小型内存管理算法可以用以下例子体现出来。

如下图所示的内存分配情况,空闲链表指针 lfree 初始指向 32 字节的内存块。当用户线程要再分配一个 64 字节的内存块时,但此 lfree 指针指向的内存块只有 32 字节并不能满足要求,内存管理器会继续寻找下一内存块,当找到再下一块内存块,128 字节时,它满足分配的要求。因为这个内存块比较大,分配器将把此内存块进行拆分,余下的内存块(52 字节)继续留在 lfree 链表中,如下图分配 64 字节后的链表结构所示。

25 小内存实例1

26 小内存实例2

另外,在每次分配内存块前,都会留出 12 字节数据头用于 magicused 信息及链表节点使用。返回给应用的地址实际上是这块内存块 12 字节以后的地址,前面的 12 字节数据头是用户永远不应该碰的部分(注:12 字节数据头长度会与系统对齐差异而有所不同)。

释放时则是相反的过程,但分配器会查看前后相邻的内存块是否空闲,如果空闲则合并成一个大的空闲内存块。

2.Slab管理算法

RT-Thread slab 分配器是在 DragonFly BSD 创始人 Matthew Dillon 实现的 slab 分配器基础上,针对嵌入式系统优化的内存分配算法。最原始的 slab 算法是 Jeff Bonwick Solaris 操作系统而引入的一种高效内核内存分配算法。

RT-Thread slab 分配器实现主要是去掉了其中的对象构造及析构过程,只保留了纯粹的缓冲型的内存池算法。slab 分配器会根据对象的大小分成多个区(zone),也可以看成每类对象有一个内存池,如下图所示:

27 slab内存管理

一个 zone 的大小在 32K 128K 字节之间,分配器会在堆初始化时根据堆的大小自动调整。系统中的 zone 最多包括 72 种对象,一次最大能够分配 16K 的内存空间,如果超出了 16K 那么直接从页分配器中分配。每个 zone 上分配的内存块大小是固定的,能够分配相同大小内存块的 zone 会链接在一个链表中,而 72 种对象的 zone 链表则放在一个数组(zone_array[])中统一管理。

下面是内存分配器主要的两种操作:

1)内存分配

假设分配一个 32 字节的内存,slab 内存分配器会先按照 32 字节的值,从 zone array 链表表头数组中找到相应的 zone 链表。如果这个链表是空的,则向页分配器分配一个新的 zone,然后从 zone 中返回第一个空闲内存块。如果链表非空,则这个 zone 链表中的第一个 zone 节点必然有空闲块存在(否则它就不应该放在这个链表中),那么就取相应的空闲块。如果分配完成后,zone 中所有空闲内存块都使用完毕,那么分配器需要把这个 zone 节点从链表中删除。

2)内存释放

分配器需要找到内存块所在的 zone 节点,然后把内存块链接到 zone 的空闲内存块链表中。如果此时 zone 的空闲链表指示出 zone 的所有内存块都已经释放,即 zone 是完全空闲的,那么当 zone 链表中全空闲 zone 达到一定数目后,系统就会把这个全空闲的 zone 释放到页面分配器中去。

3.Memheap管理算法

memheap 管理算法适用于系统含有多个地址可不连续的内存堆。使用 memheap 内存管理可以简化系统存在多个内存堆时的使用:当系统中存在多个内存堆的时候,用户只需要在系统初始化时将多个所需的 memheap 初始化,并开启 memheap 功能就可以很方便地把多个 memheap(地址可不连续)粘合起来用于系统的 heap 分配。

memheap 工作机制如下图所示,首先将多块内存加入 memheap_item 链表进行粘合。当分配内存块时,会先从默认内存堆去分配内存,当分配不到时会查找 memheap_item 链表,尝试从其他的内存堆上分配内存块。应用程序不用关心当前分配的内存块位于哪个内存堆上,就像是在操作一个内存堆。

28 memheap内存管理

4.内存堆管理API

这几类内存堆管理算法在系统运行时只能选择其中之一或者完全不使用内存堆管理器,他们提供给应用程序的 API 接口完全相同。

内存堆初始化:

void rt_system_heap_init(void* begin_addr, void* end_addr);

该函数会把begin_addr,end_addr区域的内存空间作为内存堆来使用。

begin_addr:堆内存区域起始地址

end_addr:堆内存区域结束地址

在使用memheap时,必须要在系统初始化的时候进行堆内存的初始化,调用以下接口:

rt_err_t rt_memheap_init(struct rt_memheap  *memheap,const char  *name, 
                            void*start_addr,
                           rt_uint32_t size)

如果有多个不连续的 memheap 可以多次调用该函数将其初始化并加入 memheap_item 链表

Memheap:memheap控制块

Name:内存堆名

start_addr:堆内存区域起始地址

Size:堆内存大小

分配内存块:

从内存堆上分配指定大小的内存块

void *rt_malloc(rt_size_t nbytes);

该函数会从系统堆空间中找到合适大小的内存块,并把内存块可用地址返回给用户。

Nbytes:需要分配的内存块的大小

失败返回:RT_NULL

     对rt_malloc 的返回值进行判空是非常有必要的。应用程序使用完从内存分配器中申请的内存后,必须及时释放,否则会造成内存泄漏。

释放内存块:

void rt_free (void *ptr);

重分配内存块:

在已分配内存块的基础上重新分配内存块的大小(增加或者缩小)

void *rt_realloc(void*rmem, rt_size_t newsize);

在进行重新分配内存块时,原来的内存块数据保持不变(缩小的情况下,后面的数据被自动截断)

Rmem:指向已分配的内存块

Newsize:重新分配的内存大小

rt_realloc 函数用于修改一个原先已经分配的内存块的大小。使用这个函数,你可以使一块内存扩大或者缩小。如果它用于扩大一个内存块,那么这块内存原先的内容依然保留,新增加的内存添加到原先内存块的后面,新内存并未以任何方式进行初始化。如果它用于缩小一个内存块,该内存尾部的部分内存便被拿掉,剩余部分内存的原先内容依然保留。

如果原先的内存块无法改变大小,rt_realloc 将分配另一块正确大小的内存,并把原先那块内存的内容复制到新的块上。因此,在使用 rt_realloc 之后,你就不能再使用指向旧内存块的指针,而是应该改用 rt_realloc 所返回的新指针。

如果 rt_realloc 的第一个参数为 NULL, 那么它的行为就和 rt_malloc 一样

分配多内存:

从内存堆中分配连续内存地址的多个内存块

  void*rt_calloc(rt_size_t count, rt_size_t size);

Count:内存块的数量

Size:内存块的容量

设置内存钩子函数

在分配内存块过程中,用户可设置一个钩子函数

void rt_malloc_sethook(void (*hook)(void *ptr, rt_size_t size));

设置的钩子函数会在内存分配完成后进行回调。回调时,会把分配到的内存块地址和大小做为入口参数传递进去。

Hook:钩子函数指针

Hook接口如下:

void hook(void *ptr,rt_size_t size);

PtrL:分配到的内存块指针

Size:分配到的内存块大小

释放内存钩子函数:

void rt_free_sethook(void(*hook)(void *ptr));

设置的钩子函数会在调用内存释放完成前进行回调。回调时,释放的内存块地址会做为入口参数传递进去(此时内存块并没有被释放)。

Hook:钩子函数指针

Hook接口如下:

void hook(void*ptr);

Ptr:带释放的内存块指针

5.动态内存总结

使用总结

检查从 rt_malloc 函数返回的指针是否为 NULL

不要访问动态分配内存之外的内存

不要向 rt_free 传递一个并非由 rt_malloc 函数返回的指针

在释放动态内存之后不要再访问它

使用 sizeof 计算数据类型的长度,提高程序的可移植性

常见的动态内存错误:

对 NULL 指针进行解引用

对分配的内存进行操作时越过边界

释放并非动态分配的内存

释放一块动态分配的内存的一部分 (rt_free(ptr + 4))

动态内存被释放后继续使用

内存碎片:频繁的调用内存分配和释放接口会导致内存碎片,一个避免内存碎片的策略是使用 内存池 + 内存堆 混用的方法。

1.内存池管理

1.简介

内存堆管理器可以分配任意大小的内存块,非常灵活和方便。但其也存在明显的缺点:一是分配效率不高,在每次分配时,都要空闲内存块查找;二是容易产生内存碎片。为了提高内存分配的效率,并且避免内存碎片,RT-Thread 提供了另外一种内存管理方法:内存池(Memory Pool)。

内存池是一种内存分配方式,用于分配大量大小相同的小内存块,它可以极大地加快内存分配与释放的速度,且能尽量避免内存碎片化。此外,RT-Thread 的内存池支持线程挂起功能,当内存池中无空闲内存块时,申请线程会被挂起,直到内存池中有新的可用内存块,再将挂起的申请线程唤醒。

内存池的线程挂起功能非常适合需要通过内存资源进行同步的场景,例如播放音乐时,播放器线程会对音乐文件进行解码,然后发送到声卡驱动,从而驱动硬件播放音乐。

图 29 内存池

如上图所示,当播放器线程需要解码数据时,就会向内存池请求内存块,如果内存块已经用完,线程将被挂起,否则它将获得内存块以放置解码的数据;

而后播放器线程把包含解码数据的内存块写入到声卡抽象设备中 (线程会立刻返回,继续解码出更多的数据);

当声卡设备写入完成后,将调用播放器线程设置的回调函数,释放写入的内存块,如果在此之前,播放器线程因为把内存池里的内存块都用完而被挂起的话,那么这时它将被将唤醒,并继续进行解码。

图 30 内存池控制块结构体

内存池在创建时先向系统申请一大块内存,然后分成同样大小的多个小内存块,小内存块直接通过链表连接起来(此链表也称为空闲链表)。每次分配的时候,从空闲链表中取出链头上第一个内存块,提供给申请者。从下图中可以看到,物理内存中允许存在多个大小不同的内存池,每一个内存池又由多个空闲内存块组成,内核用它们来进行内存管理。当一个内存池对象被创建时,内存池对象就被分配给了一个内存池控制块,内存控制块的参数包括内存池名,内存缓冲区,内存块大小,块数以及一个等待线程队列。

内核负责给内存池分配内存池控制块,它同时也接收用户线程的分配内存块申请,当获得这些信息后,内核就可以从内存池中为内存池分配内存。内存池一旦初始化完成,内部的内存块大小将不能再做调整。

每一个内存池对象由上述结构组成,其中 suspend_thread 形成了一个申请线程等待列表,即当内存池中无可用内存块,并且申请线程允许等待时,申请线程将挂起在 suspend_thread 链表上。

2.内存池API

创建内存池:

创建内存池操作将会创建一个内存池对象并从堆上分配一个内存池。创建内存池是从对应内存池中分配和释放内存块的先决条件,创建内存池后,线程便可以从内存池中执行申请、释放等操作。

rt_mp_t rt_mp_create(const char* name,                         rt_size_t block_count,                         rt_size_t block_size);

使用该函数接口可以创建一个与需求的内存块大小、数目相匹配的内存池,前提当然是在系统资源允许的情况下(最主要的是内存堆内存资源)才能创建成功。创建内存池时,需要给内存池指定一个名称。然后内核从系统中申请一个内存池对象,然后从内存堆中分配一块由块数目和块大小计算得来的内存缓冲区,接着初始化内存池对象,并将申请成功的内存缓冲区组织成可用于分配的空闲块链表。

Name:内存池名称

block_count:内存块数量

block_size:内存块大小

删除内存池:

rt_err_t rt_mp_delete(rt_mp_t mp);

删除内存池时,会首先唤醒等待在该内存池对象上的所有线程(返回 -RT_ERROR),然后再释放已从内存堆上分配的内存池数据存放区域,然后删除内存池对象。

初始化内存池

创建内存池不同的是,此处内存池对象所使用的内存空间是由用户指定的一个缓冲区空间,用户把缓冲区的指针传递给内存池控制块,其余的初始化工作与创建内存池相同

rt_err_t rt_mp_init(rt_mp_t mp, const char*name,void*start,
                          rt_size_t size,                        rt_size_t block_size);

初始化内存池时,把需要进行初始化的内存池对象传递给内核,同时需要传递的还有内存池用到的内存空间,以及内存池管理的内存块数目和块大小,并且给内存池指定一个名称。这样,内核就可以对该内存池进行初始化,将内存池用到的内存空间组织成可用于分配的空闲块链表。

Mp;内存池对象

Name:内存池名

Start;内存池的起始位置

Size:内存池数据区域大小

block_size:内存块容量

内存池块个数 = size / (block_size + 4 链表指针大小),计算结果取整数。

内存池数据区总大小 size 设为 4096 字节,内存块大小 block_size 设为 80 字节;则申请的内存块个数为 4096/ (80+4)= 48 个

脱离内存池:

rt_err_t rt_mp_detach(rt_mp_t mp);

分配内存块:

void *rt_mp_alloc (rt_mp_t mp,rt_int32_t time);

其中 time 参数的含义是申请分配内存块的超时时间。如果内存池中有可用的内存块,则从内存池的空闲块链表上取下一个内存块,减少空闲块数目并返回这个内存块;如果内存池中已经没有空闲内存块,则判断超时时间设置:若超时时间设置为零,则立刻返回空内存块;若等待时间大于零,则把当前线程挂起在该内存池对象上,直到内存池中有可用的自由内存块,或等待时间到达。

释放内存块:

void rt_mp_free(void*block);

6.中断管理

中断在嵌入式系统中也很常见,当 CPU 正在处理内部数据时,外界发生了紧急情况,要求 CPU 暂停当前的工作转去处理这个 异步事件。处理完毕后,再回到原来被中断的地址,继续原来的工作,这样的过程称为中断。实现这一功能的系统称为 中断系统,申请 CPU 中断的请求源称为 中断源。中断是一种异常,异常是导致处理器脱离正常运行转向执行特殊代码的任何事件,如果不及时进行处理,轻则系统出错,重则会导致系统毁灭性地瘫痪。所以正确地处理异常,避免错误的发生是提高软件鲁棒性(稳定性)非常重要的一环。如下图是一个简单的中断示意图。

图 31 中断

1.RT-Thread中断工作机制

1.中断向量表

中断向量表是所有中断程序的入口,Cortex-M系列的处理过程是:把一个函数(用户中断服务程序)和中断向量表中的中断向量联系起来。当中断向量对应的中断发生时,被挂接的用户中断服务程序就会被调用执行。

图 32 Cortex-M中断处理过程

在Cortex-M上,所有中断都采用中断向量表的方式进行处理,即当一个中断触发时,处理器将直接判定是哪个中断源,然后直接调转到相应的固定位置进行处理,每个中断服务程序必须排列在一起放在统一的地址上,(这个地址必须要设置NVIC到中断向量表便宜寄存器中)。中断向量表一般由一个数组定义或在起始代码中给出,默认采用起始代码给出:

图 33 中断向量表

注意代码中的[WEAK]标识,它是符号弱化标识,若在整个代码链接时遇到了相同名称的符号,那么使用符号弱化标识的代码将被自动丢弃。

2.中断处理过程

在中断管理过程中,将中断处理程序分为中断前导程序、中断服务程序、中断后继程序三部分。

图 34 中断处理过程

中断前导程序的主要工作:

  1. 保护CPU中断现场,不同CPU架构的实现方式存在差异

对于Cortex-M来说,该工作由硬件自动完成,当一个中断触发并且系统进行响应时,处理器硬件会将当前运行部分的上下文寄存器自动压入栈中,这部分寄存器包括:PSR(程序状态寄存器)、PC(程序计数器)、LR(连接寄存器)、R12、R3-R0寄存器

  1. 通知内核进入中断状态,调用rt_interrupt_enter()函数,作用是把全局变量rt_interrutp_nest加1,用它来记录中断嵌套层数。
void rt_interrupt_enter(void)
{    
    rt_base_t level;
    level = rt_hw_interrupt_disable();   
    rt_interrupt_nest ++;
    rt_hw_interrupt_enable(level);
}

中断服务程序:

在中断服务程序(ISR)中,分为两种情况,第一种情况是不进行线程切换,这种情况下用户中断服务程序和中断后继程序运行完毕后退出中断模式,返回被中断的线程。

另一种情况是涉及到线程的切换,这种情况下会调用rt_hw_context_switch_interrupt()函数进行上下文的切换,该函数跟CPU的架构有关。

在Cortex-M架构中,rt_hw_context_switch_interrupt()函数实现流程如下:

图 35 rt_hw_context_switch_interrupt()函数

它将设置需要切换的线程rt_interrupt_to_thread变量,然后触发PendSV异常(PendSV异常是专门用来辅助上下文切换的,且被初始化为最低优先级的异常)。PendSV异常被触发后,不会立即处理PendSV中断处理程序,因为此时还在中断处理中,只有当中断处理程序运行结束,真正退出中断处理后,才进行PendSV异常处理程序。

中断后继程序的主要工作:

  1. 通知内核离开中断状态,调用rt_interrupt_leave()函数,将全局变量rt_interrupt_nest减1。
void rt_interrupt_leave(void){ 
   rt_base_t level;    
   level =rt_hw_interrupt_disable();
   rt_interrupt_nest --;
   rt_hw_interrupt_enable(level);
}

  1. 恢复中断前的上下文,如果中断处理过程中未进行线程切换,那么恢复from线程的CPU上下文,如果在中断中进行线程切换,那么恢复to线程的CPU上下文。这部分实现跟CPU架构有关。Cortex-M架构中的实现流程:

图 36 中断后继程序

3.中断嵌套:

在允许中断嵌套的情况下,在执行中断服务程序的过程中,如果出现高优先级的中断,当前中断服务程序的执行将被打断,以执行优先级的中断服务程序,当高优先级的中断服务程序处理完毕后,被打断的中断服务程序又继续执行。如果需要进行线程调度,线程的上下文切换将在所有中断处理程序都运行结束时才发生。

图 37 中断嵌套

4.中断栈

在中断处理过程中,在系统响应中断前,软件代码(或处理器)需要把当前线程的上下文保存下来(通常保存在当前线程的线程栈中),再调用中断服务程序进行中断响应、处理。在进行中断处理时(实质是调用用户的中断服务程序函数),中断处理函数中很可能会有自己的局部变量,这些都需要相应的栈空间来保存,所以中断响应依然需要一个栈空间来做为上下文,运行中断处理函数。中断栈可以保存在打断线程的栈中,当从中断中退出时,返回相应的线程继续执行。

中断栈也可以与线程栈完全分离开来,即每次进入中断时,在保存完打断线程上下文后,切换到新的中断栈中独立运行。在中断退出时,再做相应的上下文恢复。使用独立中断栈相对来说更容易实现,并且对于线程栈使用情况也比较容易了解和掌握(否则必须要为中断栈预留空间,如果系统支持中断嵌套,还需要考虑应该为嵌套中断预留多大的空间)。

RT-Thread 采用的方式是提供独立的中断栈,即中断发生时,中断的前期处理程序会将用户的栈指针更换到系统事先留出的中断栈空间中,等中断退出时再恢复用户的栈指针。这样中断就不会占用线程的栈空间,从而提高了内存空间的利用率,且随着线程的增加,这种减少内存占用的效果也越明显。

在 Cortex-M 处理器内核里有两个堆栈指针,一个是主堆栈指针(MSP),是默认的堆栈指针,在运行第一个线程之前和在中断和异常服务程序里使用;另一个是线程堆栈指针(PSP),在线程里使用。在中断和异常服务程序退出时,修改 LR 寄存器的第 2 位的值为 1,线程的 SP 就由 MSP 切换到 PSP。

5.中断底半部

RT-Thread 不对中断服务程序所需要的处理时间做任何假设、限制,但如同其他实时操作系统或非实时操作系统一样,用户需要保证所有的中断服务程序在尽可能短的时间内完成(中断服务程序在系统中相当于拥有最高的优先级,会抢占所有线程优先执行)。这样在发生中断嵌套,或屏蔽了相应中断源的过程中,不会耽误嵌套的其他中断处理过程,或自身中断源的下一次中断信号。

当一个中断发生时,中断服务程序需要取得相应的硬件状态或者数据。如果中断服务程序接下来要对状态或者数据进行简单处理,比如 CPU 时钟中断,中断服务程序只需对一个系统时钟变量进行加一操作,然后就结束中断服务程序。这类中断需要的运行时间往往都比较短。但对于另外一些中断,中断服务程序在取得硬件状态或数据以后,还需要进行一系列更耗时的处理过程,通常需要将该中断分割为两部分,即上半部分(Top Half)和底半部分(Bottom Half)。在上半部分中,取得硬件状态和数据后,打开被屏蔽的中断,给相关线程发送一条通知(可以是 RT-Thread 所提供的信号量、事件、邮箱或消息队列等方式),然后结束中断服务程序;而接下来,相关的线程在接收到通知后,接着对状态或数据进行进一步的处理,这一过程称之为底半处理。

图 38 底半部处理

2.中断管理API

1.中断服务程序挂接:

系统把用户的中断服务程序(handler)和指定的中断号关联起来

rt_isr_handler_t rt_hw_interrupt_install(int vector,                                        rt_isr_handler_t  handler,                                        void *param,                                        char*name);

Vector:挂载的中断号

handler:中断服务程序

Param:传递给中断服务程序的参数

Name:中断名

调用rt_hw_interrupt_install()后,当这个中断源产生中断时,系统将自动调用装载的中断服务程序。

PS:中断服务程序运行在特权模式

2.中断源管理:

通常在ISR准备某个处理某个中断信号之前,我们需要先屏蔽该中断源,在ISR处理完状态或数据后,及时打开之前被屏蔽的中断源。

频闭中断源可以保证接下来的处理过程中硬件状态和数据不会收到干扰。

void rt_hw_interrupt_mask(int vector);

调用 rt_hw_interrupt_mask 函数接口后,相应的中断将会被屏蔽(通常当这个中断触发时,中断状态寄存器会有相应的变化,但并不送达到处理器进行处理)

打开中断源:

void rt_hw_interrupt_umask(int vector);

调用 rt_hw_interrupt_umask 函数接口后,如果中断(及对应外设)被配置正确时,中断触发后,将送到到处理器进行处理。

3.全局中断开关

全局中断开关也叫做中断锁,是禁止多线程访问临界区最简单的一种方式,即通过关闭中断的方式,来保证当前线程不会被其他事件打扰(因为整个系统不再响应那些可以触发线程重新调度的外部事件),也就是当前线程不会被抢占(除非这个线程自己放弃处理器控制权),关闭全局中断:

rt_base_t rt_hw_interrupt_disable(void);

恢复中断:

void rt_hw_interrupt_enable(rt_base_t level);
  1. 使用中断锁来操作临界区的方法可以应用于任何场合,且其他几类同步方式都是依赖于中断锁而实现的。中断锁可以说是最强大和高效的同步方式。只是使用中断锁的主要问题是,在中断关闭期间系统不再响应任何中断,也就不能响应外部事件。所以中断锁对系统实时性影响巨大,使用不当会导致系统毫无实时性;使用得当则是一种快捷高效的同步方式。

例如,为了保证一行代码(例如赋值)的互斥运行,最快速的方法是使用中断锁而不是信号量或互斥量:

 /* 关闭中断 */    level=rt_hw_interrupt_disable();    a = a + value;    /* 恢复中断 */    rt_hw_interrupt_enable(level);

在使用中断锁时,需要确保关闭中断的时间非常短,例如上面代码中的 a = a + value; 也可换成另外一种方式,例如使用信号量:

    /* 获得信号量锁 */    rt_sem_take(sem_lock, RT_WAITING_FOREVER);    a = a + value;    /* 释放信号量锁 */    rt_sem_release(sem_lock);

这段代码在 rt_sem_take rt_sem_release 的实现中,已经存在使用中断锁保护信号量内部变量的行为,所以对于简单如 a = a + value; 的操作,使用中断锁将更为简洁快速。

  1. 函数 rt_base_t rt_hw_interrupt_disable(void) 和函数 void rt_hw_interrupt_enable(rt_base_t level) 一般需要配对使用,从而保证正确的中断状态。在 RT-Thread 中,开关全局中断的 API 支持多级嵌套使用,简单嵌套中断的代码如下代码所示:

图 39 简单嵌套中断使用

这个特性可以给代码的开发带来很大的便利。例如在某个函数里关闭了中断,然后调用某些子函数,再打开中断。这些子函数里面也可能存在开关中断的代码。由于全局中断的 API 支持嵌套使用,用户无需为这些代码做特殊处理。

4.中断通知

当整个系统被中断打断,进入中断处理函数时,需要通知内核当前已经进入到中断状态。针对这种情况,可通过以下接口:

void rt_interrupt_enter(void);
​​​​​​​void rt_interrupt_leave(void);

这两个接口分别用在中断前导程序和中断后续程序中,均会对 rt_interrupt_nest(中断嵌套深度)的值进行修改:

每当进入中断时,可以调用 rt_interrupt_enter() 函数,用于通知内核,当前已经进入了中断状态,并增加中断嵌套深度(执行 rt_interrupt_nest++);

每当退出中断时,可以调用 rt_interrupt_leave() 函数,用于通知内核,当前已经离开了中断状态,并减少中断嵌套深度(执行 rt_interrupt_nest --)。注意不要在应用程序中调用这两个接口函数。

使用 rt_interrupt_enter/leave() 的作用是,在中断服务程序中,如果调用了内核相关的函数(如释放信号量等操作),则可以通过判断当前中断状态,让内核及时调整相应的行为。例如:在中断中释放了一个信号量,唤醒了某线程,但通过判断发现当前系统处于中断上下文环境中,那么在进行线程切换时应该采取中断中线程切换的策略,而不是立即进行切换。

但如果中断服务程序不会调用内核相关的函数(释放信号量等操作),这个时候,也可以不调用 rt_interrupt_enter/leave() 函数。

在上层应用中,在内核需要知道当前已经进入到中断状态或当前嵌套的中断深度时,可调用 rt_interrupt_get_nest() 接口,它会返回 rt_interrupt_nest。如下:

rt_uint8_t rt_interrupt_get_nest(void);

3.中断与轮询

当驱动外设工作时,其编程模式到底采用中断模式触发还是轮询模式触发往往是驱动开发人员首先要考虑的问题,并且这个问题在实时操作系统与分时操作系统中差异还非常大。因为轮询模式本身采用顺序执行的方式:查询到相应的事件然后进行对应的处理。所以轮询模式从实现上来说,相对简单清晰。例如往串口中写入数据,仅当串口控制器写完一个数据时,程序代码才写入下一个数据(否则这个数据丢弃掉)。相应的代码可以是这样的:

/* 轮询模式向串口写入数据 */    while (size)    {        /* 判断 UART 外设中数据是否发送完毕 */        while (!(uart->uart_device->SR & USART_FLAG_TXE));        /* 当所有数据发送完毕后,才发送下一个数据 */        uart->uart_device->DR =(*ptr & 0x1FF);
        ++ptr; 
         --size;
    }

在实时系统中轮询模式可能会出现非常大问题,因为在实时操作系统中,当一个程序持续地执行时(轮询时),它所在的线程会一直运行,比它优先级低的线程都不会得到运行。而分时系统中,这点恰恰相反,几乎没有优先级之分,可以在一个时间片运行这个程序,然后在另外一段时间片上运行另外一段程序。

所以通常情况下,实时系统中更多采用的是中断模式来驱动外设。当数据达到时,由中断唤醒相关的处理线程,再继续进行后续的动作。例如一些携带 FIFO(包含一定数据量的先进先出队列)的串口外设,其写入过程可以是这样的,如下图所示:

40 串口外设

线程先向串口的 FIFO 中写入数据,当 FIFO 满时,线程主动挂起。串口控制器持续地从 FIFO 中取出数据并以配置的波特率(例如 115200bps)发送出去。当 FIFO 中所有数据都发送完成时,将向处理器触发一个中断;当中断服务程序得到执行时,可以唤醒这个线程。这里举例的是 FIFO 类型的设备,在现实中也有 DMA 类型的设备,原理类似。

对于低速设备来说,运用这种模式非常好,因为在串口外设把 FIFO 中的数据发送出去前,处理器可以运行其他的线程,这样就提高了系统的整体运行效率(甚至对于分时系统来说,这样的设计也是非常必要)。但是对于一些高速设备,例如传输速度达到 10Mbps 的时候,假设一次发送的数据量是 32 字节,我们可以计算出发送这样一段数据量需要的时间是:(32 X 8) X 1/10Mbps = 25us。当数据需要持续传输时,系统将在 25us 后触发一个中断以唤醒上层线程继续下次传递。假设系统的线程切换时间是 8us(通常实时操作系统的线程上下文切换时间只有几个 us),那么当整个系统运行时,对于数据带宽利用率将只有 25/(25+8) =75.8%。但是采用轮询模式,数据带宽的利用率则可能达到 100%。这个也是大家普遍认为实时系统中数据吞吐量不足的缘故,系统开销消耗在了线程切换上(有些实时系统甚至会如本章前面说的,采用底半处理,分级的中断处理方式,相当于又拉长中断到发送线程的时间开销,效率会更进一步下降)。

通过上述的计算过程,我们可以看出其中的一些关键因素:发送数据量越小,发送速度越快,对于数据吞吐量的影响也将越大。归根结底,取决于系统中产生中断的频度如何。当一个实时系统想要提升数据吞吐量时,可以考虑的几种方式:

1)增加每次数据量发送的长度,每次尽量让外设尽量多地发送数据;

2)必要情况下更改中断模式为轮询模式。同时为了解决轮询方式一直抢占处理机,其他低优先级线程得不到运行的情况,可以把轮询线程的优先级适当降低。

  • 21
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值