RTX_RTOS之01-CMSIS_RTOS2_Tutorial自译中文版

文章目录

教程

本教程介绍了在 Arm Cortex-M 微控制器上使用占用空间小的实时操作系统。如果您习惯于在小型 8 位/16 位微控制器上编写基于过程的“C”代码,您可能会怀疑是否需要这样的操作系统。如果您不熟悉在实时嵌入式系统中使用 RTOS,您应该在放弃这个想法之前阅读本章。RTOS 的使用代表了一种更复杂的设计方法,从本质上促进了由 RTOS 应用程序编程接口 (API) 强制执行的结构化代码开发。

RTOS 结构允许您采用更加面向对象的设计方法,同时仍然使用“C”进行编程。RTOS 还在小型微控制器上为您提供多线程支持。这两个功能实际上在设计理念上产生了相当大的转变,使我们不再考虑程序化的“C”代码和流程图。相反,我们考虑基本的程序线程和它们之间的数据流。使用实时操作系统还有一些额外的好处,这些好处可能不会立即显现出来。由于基于 RTOS 的项目由定义明确的线程组成,因此有助于改进项目管理、代码重用和软件测试。

这样做的代价是 RTOS 具有额外的内存要求和增加的中断延迟。通常,Keil RTX5 RTOS 需要 500 字节的 RAM 和 5k 字节的代码,但请记住,某些 RTOS 代码无论如何都会复制到您的程序中。我们现在有一代小型低成本微控制器,它们具有足够的片上存储器和处理能力来支持 RTOS 的使用。因此,使用这种方法进行开发更容易。

我们将首先看看为基于 Cortex-M 的微控制器设置一个介绍性的 RTOS 项目。接下来,我们将介绍每个 RTOS 原语以及它们如何影响我们应用程序代码的设计。最后,当我们对 RTOS 的特性有了清晰的了解后,我们再仔细看看 RTOS 的配置选项。如果您习惯于在不使用 RTOS(即裸机)的情况下对微控制器进行编程,那么在您完成本教程的过程中需要了解两件重要的事情。在第一部分中,我们将专注于创建和管理线程。这里的关键概念是将它们视为并行并发对象运行。在第二部分,我们将看看如何在线程之间进行通信。在本节中,关键概念是并发线程的同步。

使用 Keil RTX5 的第一步

RTOS 本身由一个调度程序组成,该调度程序支持程序线程的循环、抢占式和协作式多任务处理,以及时间和内存管理服务。额外的 RTOS 对象支持线程间通信,包括信号线程和事件标志、信号量、互斥锁、消息传递和内存池系统。正如我们将看到的,中断处理也可以由 RTOS 内核调度的优先线程来完成。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0gNZys2U-1635564117953)(https://arm-software.github.io/CMSIS_5/RTOS2/html/rtos_components.png)]

访问 CMSIS-RTOS2 API

要访问我们应用程序代码中的任何 CMSIS-RTOS2 功能,必须包含以下头文件。

#include < cmsis_os2.h >

该头文件由 Arm 维护,作为 CMSIS-RTOS2 标准的一部分。对于 Keil RTX5,这是默认 API。其他 RTOS 将拥有自己的专有 API,但可能会提供一个包装层来实现 CMSIS-RTOS2 API,因此它们可以用于需要与 CMSIS 标准兼容的地方。

线程

典型的“C”程序的构建块是我们调用以执行特定过程然后返回到调用函数的函数。在 CMSIS-RTOS2 中,执行的基本单位是“线程”。线程与“C”过程非常相似,但有一些非常根本的区别。

unsigned int procedure (void) {
  ...
        return(ch);                     
}
 
void thread (void) {
  while(1) {
    ...
        }
}       
 
__NO_RETURN void Thread1(void*argument) {
  while(1) {
    ...
  }
}

虽然我们总是从我们的“C”函数返回,但一旦启动,RTOS 线程必须包含一个循环,以便它永远不会终止,从而永远运行。您可以将线程视为在 RTOS 中运行的小型自包含程序。借助 Arm Compiler,可以使用__NO_RETURN宏优化线程。此属性降低了调用永不返回的函数的成本。

一个 RTOS 程序由多个线程组成,这些线程由 RTOS 调度程序控制。此调度程序使用 SysTick 计时器来生成周期性中断作为时基。调度器会为每个线程分配一定的执行时间。因此thread1将运行 5 毫秒,然后取消计划以允许thread2运行类似的时间;thread2将让位于thread3并最终将控制权传回thread1. 通过以循环方式将这些运行时片段分配给每个线程,我们可以看到所有三个线程彼此并行运行。

从概念上讲,我们可以将每个线程视为执行我们程序的特定功能单元,所有线程同时运行。这导致我们采用更加面向对象的设计,其中每个功能块都可以单独编码和测试,然后集成到一个完全运行的程序中。这不仅在我们最终应用程序的设计上强加了一个结构,而且还有助于调试,因为可以轻松地将特定错误隔离到特定线程。它还有助于在以后的项目中重用代码。创建线程时,还会为其分配自己的线程 ID。这是一个变量,它充当每个线程的句柄,在我们想要管理线程的活动时使用。

osThreadId_t id1, id2, id3;

为了使线程切换过程发生,我们有 RTOS 的代码开销,我们必须专用 CPU 硬件定时器来提供 RTOS 时间参考。另外,每次切换正在运行的线程时,我们都要将所有线程变量的状态保存到一个线程栈中。此外,有关线程的所有运行时信息都存储在由 RTOS 内核管理的线程控制块中。因此,“上下文切换时间”,即保存当前线程状态和加载并启动下一个线程的时间,是一个至关重要的数字,将取决于 RTOS 内核和底层硬件的设计。

线程控制块包含有关线程状态的信息。此信息的一部分是其运行状态。在给定的系统中,只有一个线程可以运行,所有其他线程都将挂起但准备运行。RTOS 具有多种线程间通信方法(信号、信号量、消息)。在这里,一个线程可能会被挂起以等待另一个线程或中断在它恢复就绪状态之前发出信号,然后它可以被 RTOS 调度程序置于运行状态。
RTX_RTOS之01-CMSIS_RTOS2_Tutorial自译中文版

在任何给定时刻,一个线程可能正在运行。剩余的线程将准备好运行并由内核调度。线程也可能正在等待等待 OS 事件。发生这种情况时,它们将返回就绪状态并由内核调度。

启动实时操作系统

为了构建一个简单的 RTOS,我们将每个线程声明为标准的“C”函数,并为每个函数声明一个线程 ID 变量。

void thread1 (void);    
void thread2 (void);
 
osThreadId thrdID1, thrdID2;

一旦处理器离开复位向量,我们将main()正常进入函数。进入后main(),我们必须调用osKernelInitialize()来设置 RTOS。在osKernelInitialize()函数成功完成之前,不可能调用任何 RTOS 函数。一旦osKernelInitialize()完成,我们就可以创建更多线程和其他 RTOS 对象。这可以通过创建启动器线程来完成,在下面的示例中称为app_main(). 在app_main()线程内部,我们创建了启动应用程序运行所需的所有 RTOS 线程和对象。正如我们稍后将看到的,也可以在应用程序运行时动态创建和销毁 RTOS 对象。接下来,我们可以调用osKernelStart()启动 RTOS 和调度器任务切换。您可以在启动 RTOS 之前运行您想要的任何初始化代码来设置外设和初始化硬件。

void app_main(void *argument) {
  T_led_ID1 = osThreadNew(led_Thread1, NULL, &ThreadAttr_LED1);
  T_led_ID2 = osThreadNew(led_Thread2, NULL, &ThreadAttr_LED2);
  osDelay(osWaitForever);
  while (1)
    ;
}
 
void main(void) {
  IODIR1 = 0x00FF0000;               // Do any C code you want
  osKernelInitialize();              // Initialize the kernel
  osThreadNew(app_main, NULL, NULL); // Create the app_main() launcher thread
  osKernelStart();                   // Start the RTOS
}

创建线程时,它们也被分配了优先级。如果有许多线程准备运行并且它们都具有相同的优先级,则将以循环方式为它们分配运行时间。但是,如果具有更高优先级的线程准备好运行,RTOS 调度程序将取消调度当前运行的线程并启动高优先级线程运行。这称为基于优先级的抢占式调度。在分配优先级时,您必须小心,因为高优先级线程将继续运行,直到它进入等待状态,或者直到具有相同或更高优先级的线程准备运行为止。

创建线程

一旦 RTOS 运行,就会有许多系统调用用于管理和控制活动线程。该文档列出了所有线程管理功能。

正如我们在前面中看到的,该app_main()线程用作启动器线程来创建应用程序线程。这是分两个阶段完成的。首先定义一个线程结构;这允许我们定义线程操作参数。

osThreadId thread1_id; // thread handle
 
static const osThreadAttr_t threadAttr_thread1 = {
        “Name_String ",      //Human readable Name for debugger
    Attribute_bits Control_Block_Memory,
    Control_Block_Size,
    Stack_Memory,
    Stack_Size,
    Priority,
    TrustZone_ID,
    reserved};

线程结构要求我们定义线程函数的名称、它的线程优先级、任何特殊属性位、它的 TrustZone_ID 和它的内存分配。这是相当多的细节,但我们将在本应用笔记结束时涵盖所有内容。一旦定义了线程结构,就可以使用osThreadNew() API 调用创建线程。然后从应用程序代码内创建线程,这通常是在app_main()线程内,但可以在任何线程内的任何点创建线程。

thread1_id = osThreadNew(name_Of_C_Function, argument,&threadAttr_thread1);

这将创建线程并启动它运行。也可以在线程启动时将参数传递给线程。

uint32_t startupParameter = 0x23;
thread1_id = osThreadNew(name_Of_C_Function, (uint32_t)startupParameter,&threadAttr_thread1);

线程管理和优先级

创建线程时,会为其分配优先级。RTOS 调度程序使用线程的优先级来决定应该调度哪个线程运行。如果有多个线程准备运行,则优先级最高的线程将被置于运行状态。如果高优先级线程准备好运行,它将抢占低优先级正在运行的线程。重要的是,在 CPU 上运行的高优先级线程不会停止运行,除非它阻塞了 RTOS API 调用或被更高优先级的线程抢占。线程的优先级在线程结构中定义,以下优先级定义可用。默认优先级是osPriorityNormal。该osPriority_t值指定线程的优先级。

一旦线程开始运行,就会有少量的 RTOS 系统调用用于管理正在运行的线程。然后也可以从另一个函数或从它自己的代码中提升或降低线程的优先级。

osStatus   osThreadSetPriority(threadID, priority);
osPriority osThreadGetPriority(threadID);

除了创建线程,一个线程还可以从 RTOS 中删除另一个活动线程。同样,我们使用线程 ID 而不是线程的函数名称。

osStatus = osThreadTerminate (threadID1);

如果一个线程想要终止自己,那么有一个专用的退出函数。

osThreadExit ( void )

最后,还有一种线程切换的特殊情况,即正在运行的线程将控制权传递给下一个具有相同优先级的就绪线程。这用于实现称为协作线程切换的第三种调度形式。

osStatus osThreadYield();  //switch to next ready to run thread at the same priority

内存管理

创建每个线程时,会为其分配自己的堆栈,用于在上下文切换期间存储数据。这不应与原生 Cortex-M 处理器堆栈混淆;它实际上是分配给线程的内存块。RTOS 配置文件中定义了默认堆栈大小(我们稍后会看到),除非我们覆盖它以分配自定义大小,否则该内存量将分配给每个线程。如果线程定义结构中的堆栈大小值设置为零,则将默认堆栈大小分配给线程。如有必要,可以通过在线程结构中定义更大的堆栈大小来为线程提供额外的内存资源。Keil RTX5 支持多种内存模型来分配此线程内存。默认模型是全局内存池。在这个模型中,每个被创建的RTOS对象(线程、消息队列、信号量等)都从一个内存块中分配内存。

如果一个对象被销毁,它所分配的内存将返回到内存池。这具有内存重用的优点,但也引入了可能出现的内存碎片问题。

全局内存池的大小在配置文件中定义:

#define OS_DYNAMIC_MEM_SIZE         4096

每个线程的默认堆栈大小在线程部分定义:

#define OS_STACK_SIZE               256

还可以为每种不同类型的 RTOS 对象定义对象特定的内存池。在此模型中,您可以定义特定对象类型的最大数量及其内存要求。然后 RTOS 计算并保留所需的内存使用量。

通过启用配置文件每个部分中提供的“对象特定内存”选项,对象特定模型再次在 RTOS 配置文件中定义:

#define OS_SEMAPHORE_OBJ_MEM 1
#define OS_SEMAPHORE_NUM 1

在需要固定内存分配的简单对象的情况下,我们只需要定义给定对象类型的最大数量。对于更复杂的对象(例如线程),我们需要定义所需的内存使用量:

#define OS_THREAD_OBJ_MEM 1
#define OS_THREAD_NUM 1
#define OS_THREAD_DEF_STACK_NUM 0
#define OS_THREAD_USER_STACK_SIZE 1024

要对线程使用特定于对象的内存分配模型,我们必须提供整体线程内存使用情况的详细信息。最后,可以静态分配线程堆栈内存。这对于必须严格定义内存使用的安全相关系统很重要。

多个实例

RTOS 的一个有趣的可能性是您可以创建相同基本线程代码的多个运行实例。例如,您可以编写一个线程来控制 UART,然后创建相同线程代码的两个运行实例。在这里,UART 代码的每个实例都可以管理不同的 UART。然后我们可以创建分配给不同线程句柄的线程的两个实例。还传递了一个参数,以允许每个实例识别它负责的 UART。

#define UART1 (void *) 1UL
#define UART2 (void *) 2UL
 
ThreadID_1_0 = osThreadNew (thread1, UART1, &ThreadAttr_Task1);
ThreadID_1_1 = osThreadNew (thread1, UART0, &ThreadAttr_Task1);

可连接的线程

CMSIS-RTOS2 中的一个新功能是能够在“可连接”状态下创建线程。这允许作为标准线程创建和执行 thead。此外,第二个线程可以通过调用osThreadJoin()加入它。这将导致第二个线程取消调度并保持等待状态,直到已加入的线程终止。这允许创建一个临时的可连接线程,该线程将从全局内存池中获取一块内存,该线程可以执行一些处理然后终止,将内存释放回内存池。可以通过设置线程属性结构中的可连接属性位来创建可连接线程,如下所示:

static const osThreadAttr_t ThreadAttr_worker = {
        .attr_bits = osThreadJoinable
};

创建线程后,它将按照与“普通”线程相同的规则执行。任何其他线程都可以通过使用操作系统调用加入它:

osThreadJoin (< joinable_thread_handle >);

一旦osThreadJoin()被调用,该线程将取消调度,并进入等待状态,直到可连接线程终止。

时间管理

除了将您的应用程序代码作为线程运行之外,RTOS 还提供了一些可以通过 RTOS 系统调用访问的计时服务。

时间延迟

这些计时服务中最基本的是一个简单的计时器延迟功能。这是在您的应用程序中提供时序延迟的一种简单方法。尽管 RTOS 内核大小被引用为 5 KB,但延迟循环和简单调度循环等功能通常是非 RTOS 应用程序的一部分,无论如何都会消耗代码字节,因此 RTOS 的开销可能比它立即出现的要少。

void osDelay (uint32_t ticks)

此调用会将调用线程置于 WAIT_DELAY 状态指定的毫秒数。调度程序会将执行传递给处于 READY 状态的下一个线程。

当计时器到期时,线程将离开 WAIT_DELAY 状态并移动到 READY 状态。当调度程序将线程移动到 RUNNING 状态时,线程将恢复运行。如果线程随后继续执行而没有任何进一步阻塞 OS 调用,它将在其时间片结束时被取消调度并置于就绪状态,假设另一个具有相同优先级的线程已准备好运行。

绝对时间延迟

除了osDelay()函数从调用它的那一刻开始给出相对时间延迟之外,还有一个延迟函数可以暂停线程直到特定时间点:

osStatus osDelayUntil (uint32_t ticks)  

该osDelayUntil() ,直到内核定时器的具体值达到蜱功能将停止一个线程。有许多内核函数允许您读取当前的 SysTick 计数和内核滴答计数。
RTX_RTOS之01-CMSIS_RTOS2_Tutorial自译中文版

虚拟定时器

CMSIS-RTOS API 可用于定义任意数量的虚拟计时器,用作倒计时计时器。当它们到期时,它们将运行用户回调函数来执行特定操作。每个计时器都可以配置为单次或重复计时器。首先定义一个定时器结构来创建一个虚拟定时器:

static const osThreadAttr_t ThreadAttr_app_main = {
  const char * name // symbolic name of the timer
  uin32_t attr_bits // None
  void* cb_mem      // pointer to memory for control block
  uint32_t cb_size  // size of memory control block
}

这定义了计时器的名称和回调函数的名称。然后必须由 RTOS 线程实例化计时器:

osTimerId_t timer0_handle;
timer0_handle = osTimerNew (&callback, osTimerPeriodic ,( void *)<parameter>, &timerAttr_timer0);

这将创建计时器并将其定义为周期性计时器或单次计时器 ( osTimerOnce() )。当计时器到期时,下一个参数将参数传递给回调函数:

osTimerStart (timer0_handle,0x100);

然后可以在线程中的任何点启动计时器,计时器启动函数通过其句柄调用计时器并定义以内核滴答为单位的计数周期。

空闲线程

RTOS 提供的最终计时器服务并不是真正的计时器,但这可能是讨论它的最佳场所。如果在我们的 RTOS 程序期间,我们没有线程运行,也没有线程准备运行(例如,它们都在等待延迟函数),那么 RTOS 将开始运行空闲线程。该线程在 RTOS 启动并以最低优先级运行时自动创建。空闲线程函数位于 RTX_Config.c 文件中:

__WEAK __NO_RETURN void osRtxIdleThread (void *argument) {
  (void)argument;
 
  for (;;) {}
}

您可以向该线程添加任何代码,但它必须遵守与用户线程相同的规则。空闲恶魔的最简单用途是在微控制器不执行任何操作时将其置于低功耗模式。

__WEAK __NO_RETURN void osRtxIdleThread (void *argument) {
  (void)argument;
 
  for (;;) {
    __WFE();
  }
}

接下来会发生什么取决于微控制器中选择的电源模式。至少,CPU 将暂停,直到 SysTick 计时器产生中断并且调度程序的执行将恢复。如果有线程准备运行,则应用程序代码的执行将恢复。否则,空闲的恶魔将重新进入,系统将重新进入休眠状态。

线程间通信

到目前为止,我们已经看到了如何将应用程序代码定义为独立线程,以及如何访问 RTOS 提供的计时服务。在实际应用程序中,我们需要能够在线程之间进行通信以使应用程序有用。为此,典型的 RTOS 支持多个不同的通信对象,这些对象可用于将线程链接在一起以形成有意义的程序。CMSIS-RTOS2 API 支持与线程和事件标志、信号量、互斥体、邮箱和消息队列的线程间通信。在第一部分中,关键概念是并发性。在本节中,关键概念是同步多个线程的活动。

线程标志

Keil RTX5 支持每个线程最多三十二个线程标志。这些线程标志存储在线程控制块中。可以暂停线程的执行,直到系统中的另一个线程设置了特定线程标志或线程标志组。

该osThreadFlagsWait()系统调用将暂停线程的执行,并把它放入wait_evnt状态。在osThreadFlagsWait() API 调用中设置的至少一个标志被设置后,线程的执行才会开始。也可以定义一个周期性超时,在此之后等待线程将移回就绪状态,以便在被调度程序选中时可以恢复执行。osWaitForever (0xFFFF) 的值定义了无限超时时间。

osEvent osThreadFlagsWait (int32_t flags,int32_t options,uint32_t timeout);

线程标志选项如下:
RTX_RTOS之01-CMSIS_RTOS2_Tutorial自译中文版

如果指定了标志模式,则当任何一个指定标志被设置(逻辑或)时,线程将恢复执行。如果使用osFlagsWaitAll选项,则必须设置模式中的所有标志(逻辑与)。任何线程都可以在任何其他线程上设置标志,并且线程可以清除自己的标志:

int32_t osThredFlagsSet (osThreadId_t  thread_id, int32_t flags);
int32_t osThreadFlagsClear (int32_t signals);

事件标志

事件标志的操作方式与线程标志类似,但必须创建,然后充当所有正在运行的线程都可以使用的全局 RTOS 对象。

首先,我们需要创建一组事件标志,这与创建线程的过程类似。我们定义了一个事件标志属性结构。属性结构定义了 ASCII 名称字符串、属性位和内存滞留。如果我们使用的是静态内存模型。

osEventFlagsAttr_t {
  const char *name;   ///< name of the event flags
  uint32_t attr_bits; ///< attribute bits (none)
  void *cb_mem;       ///< memory for control block
  uint32_t cb_size;   ///< size of provided memory for control block
};

接下来我们需要一个句柄来控制对事件标志的访问:

osEventFlagsId_t EventFlag_LED;

然后我们可以创建事件标志对象:

EventFlag_LED = osEventFlagsNew (&EventFlagAttr_LED);

信号量

与线程标志一样,信号量是一种在两个或多个线程之间同步活动的方法。简而言之,信号量是一个容纳多个标记的容器。当线程执行时,它将到达 RTOS 调用以获取信号量标记。如果信号量包含一个或多个标记,线程将继续执行并且信号量中的标记数量将减一。如果信号量中当前没有标记,则线程将处于等待状态,直到有标记可用。在执行过程中的任何时候,线程都可以向信号量添加一个标记,导致其标记计数增加 1。

上图说明了使用信号量来同步两个线程。首先,必须使用初始标记计数创建和初始化信号量。在这种情况下,信号量是用单个标记初始化的。两个线程都将运行并到达其代码中的某个点,在该点它们将尝试从信号量获取标记。到达这一点的第一个线程将从信号量中获取标记并继续执行。第二个线程也将尝试获取标记,但由于信号量是空的,它将停止执行并进入等待状态,直到信号量标记可用。

同时,执行线程可以将标记释放回信号量。当这种情况发生时,等待线程将获取标记并离开等待状态进入就绪状态。一旦处于就绪状态,调度程序会将线程置于运行状态,以便线程可以继续执行。虽然信号量有一组简单的操作系统调用,但它们可能是更难完全理解的操作系统对象之一。在本节中,我们将首先了解如何将信号量添加到 RTOS 程序,然后继续了解最有用的信号量应用程序。

要在 CMSIS-RTOS 中使用信号量,您必须首先声明信号量属性:

osSemaphoreAttr_t {
  const char *name;   ///< name of the semaphore
  uint32_t attr_bits; ///< attribute bits (none)
  void *cb_mem;       ///< memory for control block
  uint32_t cb_size;   ///< size of provided memory for control block
};

接下来声明信号量句柄:

osSemaphoreId_t sem1;

然后在一个线程中,信号量容器可以用许多标记初始化:

sem1 = osSemaphoreNew (maxTokenCount,initalTokencount,& osSemaphoreAttr_t );

了解信号量标记也可能在线程运行时创建和销毁很重要。因此,例如,您可以使用零标记初始化一个信号量,然后使用一个线程在信号量中创建标记,而另一个线程将它们删除。这允许您将线程设计为生产者和消费者线程。

一旦信号量被初始化,就可以以类似于事件标志的方式获取标记并将其发送到信号量。所述osSemaphoreAcquire()调用用于阻塞线程旗语标记可用直到。超时时间也可以指定为 0xFFFF 是无限等待。

osStatus osSemaphoreAcquire ( osSemaphoreId_t semaphore_id, uint32_t ticks);

一旦线程使用完信号量资源,它就可以向信号量容器发送一个标记:

osStatus osSemaphoreRelease ( osSemaphoreId_t semaphore_id);
使用信号量

尽管信号量具有一组简单的操作系统调用,但它们具有广泛的同步应用程序。这使得它们可能是最难理解的 RTOS 对象。在本节中,我们将了解信号量的最常见用途。这些摘自Allen B. Downey 的免费书籍“The Little Book of Semaphores”。

同步线程

同步两个线程的执行是信号量最简单的用法:

osSemaphoreId_t sem1;
static const osSemaphoreAttr_t semAttr_SEM1 = {
    .name = "SEM1",
};
 
void thread1(void) {
  sem1 = osSemaphoreNew(5, 0, &semAttr_SEM1);
  while (1) {
    FuncA();
    osSemaphoreRelease(sem1)
  }
}
 
void task2(void) {
  while (1) {
    osSemaphoreAcquire(sem1, osWaitForever);
    FuncB();
  }
}

在这种情况下,信号量用于确保FuncA()中的代码在FuncB()中的代码之前执行。

多路复用

多路复用用于限制可以访问代码关键部分的线程数。例如,这可能是一个访问内存资源的例程,并且只能支持有限数量的调用。

osSemaphoreId_t multiplex;
static const osSemaphoreAttr_t semAttr_Multiplex = {
    .name = "SEM1",
};
 
void thread1(void) {
  multiplex = osSemaphoreCreate(5, 5, &semAttr_Multiplex);
  while (1) {
    osSemaphoreAcquire(multiplex, osWaitForever);
    processBuffer();
    osSemaphoreRelease(multiplex);
  }
}

在这个例子中,我们用五个标记初始化多路信号量。在线程可以调用该processBuffer()函数之前,它必须获得一个信号量标记。一旦函数完成,标记被发送回信号量。如果超过五个线程尝试调用processBuffer(),则第六个线程必须等待直到线程完成processBuffer()并返回其标记。因此,多路信号量确保最多五个线程可以processBuffer()“同时”调用该函数。

会合(集合)

信号量信号的一种更通用的形式是集合点。集合点确保两个线程到达某个执行点。两者都不能继续,直到两者都到达会合点。

osSemaphoreId_t arrived1, arrived2;
static const osSemaphoreAttr_t semAttr_Arrived1 = {
    .name = "Arr1",
};
 
static const osSemaphoreAttr_t semAttr_Arrived2 = {
    .name = "Arr2",
};
 
void thread1(void) {
  arrived1 = osSemaphoreNew(2, 0);
  arrived1 = osSemaphoreNew(2, 0);
  while (1) {
    FuncA1();
    osSemaphoreRelease(arrived1);
    osSemaphoreAcquire(arrived2, osWaitForever);
    FuncA2();
  }
}
 
void thread2(void) {
  while (1) {
    FuncB1();
    os_sem_Release(arrived2);
    os_sem_Acquire(arrived1, osWaitForever);
    FuncB2();
  }
}

在上述情况下,两个信号量将确保两个线程会合,然后继续执行FuncA2()和FuncB2()。

屏障

尽管集合点对于同步代码执行非常有用,但它仅适用于两个函数。屏障是一种更通用的集合形式,用于同步多个线程。

osSemaphoreId_t count, barrier;
static const osSemaphoreAttr_t semAttr_Counter = {
    .name = "Counter",
};
 
static const osSemaphoreAttr_t semAttr_Barier = {
    .name = "Barrier",
};
 
unsigned int count;
 
void thread1(void) {
  Turnstile_In = osSemaphoreNew(5, 0, &semAttr_SEM_In);
  Turnstile_Out = osSemaphoreNew(5, 1, &semAttr_SEM_Out);
  Mutex = osSemaphoreNew(1, 1, &semAttr_Mutex);
  while (1) {
    osSemaphoreAcquire(Mutex, osWaitForever); // Allow one task at a time to
                                              // access the first turnstile
    count = count + 1; // Increment count
    if (count == 5) {
      osSemaphoreAcquire(Turnstile_Out,
                         osWaitForever); // Lock the second turnstile
      osSemaphoreRelease(Turnstile_In);  // Unlock the first turnstile
    }
    osSemaphoreRelease(Mutex); // Allow other tasks to access the turnstile
    osSemaphoreAcquire(Turnstile_In, osWaitForever); // Turnstile Gate
    osSemaphoreRelease(Turnstile_In);
    critical_Function();
  }
}

在这段代码中,我们使用一个全局变量来计算到达屏障的线程数。当每个函数到达屏障时,它将等待直到它可以从计数器信号量中获取一个标记。一旦获得,计数变量将增加一。一旦我们增加了计数变量,一个标记被发送到计数器信号量,以便其他等待的线程可以继续。接下来,屏障代码读取计数变量。如果这等于等待到达屏障的线程数,我们将向屏障信号量发送一个标记。

在上面的例子中,我们同步了五个线程。前四个线程将增加计数变量,然后在屏障信号量处等待。第五个也是最后一个到达的线程将增加计数变量并向屏障信号量发送一个标记。这将允许它立即获取屏障信号量标记并继续执行。通过屏障后,它立即向屏障信号量发送另一个标记。这允许其他等待线程之一恢复执行。该线程在屏障信号量中放置另一个标记,触发另一个等待线程,依此类推。屏障代码的最后一部分称为旋转门,因为它一次允许一个线程通过屏障。在我们的并发执行模型中,这意味着每个线程都在屏障处等待,直到最后一个线程到达,然后所有线程同时恢复。在下面的练习中,我们为一个包含屏障代码的线程创建五个实例。但是,屏障可用于同步五个独特的线程。

信号量警告

信号量是任何 RTOS 都非常有用的特性。然而,信号量可能会被滥用。您必须始终记住,信号量中的标记数量不是固定的。在程序运行期间,信号量标记可能会被创建和销毁。有时这很有用,但如果您的代码依赖于固定数量的信号量可用的标记,则必须非常小心,始终将标记返回给它。您还应该排除意外创建额外新标记的可能性。

互斥体

Mutex 代表“互斥”。实际上,互斥体是信号量的特殊版本。与信号量一样,互斥锁是标记的容器。不同之处在于互斥锁只能包含一个不能被创建或销毁的标记。互斥锁的主要用途是控制对外围设备等芯片资源的访问。出于这个原因,互斥体标记是二进制和有界的。除此之外,它的工作方式与信号量相同。首先我们必须声明互斥体容器并初始化互斥体:

osMutexId_t uart_mutex;
 
osMutexAttr_t {
  const char *name;   ///< name of the mutex
  uint32_t attr_bits; ///< attribute bits
  void *cb_mem;       ///< memory for control block
  uint32_t cb_size;   ///< size of provided memory for control block
};

创建互斥锁时,可以通过设置以下属性位来修改其功能:

Bitmask Description
osMutexRecursive 同一个线程可以多次使用互斥锁而不锁定自身。
osMutexPrioInherit 虽然线程拥有互斥锁,但它不能被更高优先级的线程抢占。
osMutexRobust 通知获取互斥锁的线程前一个所有者已终止。

一旦声明了互斥锁,就必须在线程中创建。

uart_mutex = osMutexNew (&MutexAttr);

然后任何需要访问外设的线程必须首先获取互斥标记:

osMutexAcquire(osMutexId_t mutex_id,uint32_t ticks);

最后,当我们完成外围设备时,必须释放互斥锁:

osMutexRelease ( osMutexId_t mutex_id);

互斥的使用比信号量的使用严格得多,但在控制对底层芯片寄存器的绝对访问时是一种更安全的机制。

互斥警告

显然,当您完成芯片资源时,您必须小心返回互斥标记,否则您将有效地阻止任何其他线程访问它。您还必须非常小心地对控制互斥标记的函数使用osThreadTerminate()调用。Keil RTX5 被设计为占用空间很小的 RTOS,因此它甚至可以在非常小的 Cortex-M 微控制器上运行。因此,没有线程删除安全。这意味着如果您删除控制互斥标记的线程,您将销毁互斥标记并阻止对受保护外围设备的任何进一步访问。

数据交换

到目前为止,所有的线程间通信方式都只用于触发线程的执行;它们不支持线程之间的程序数据交换。显然,在实际程序中,我们需要在线程之间移动数据。这可以通过读取和写入全局声明的变量来完成。除了一个非常简单的程序外,试图保证数据完整性将是极其困难的,并且容易出现不可预见的错误。线程之间的数据交换需要更正式的异步通信方法。

CMSIS-RTOS 提供了两种线程间数据传输的方法。第一种方法是一个消息队列,它在两个线程之间创建一个缓冲数据“管道”。消息队列旨在传输整数值。

数据传输的第二种形式是邮件队列。这与消息队列非常相似,不同之处在于它传输数据块而不是单个整数。

消息队列和邮件队列都提供了一种在线程之间传输数据的方法。这允许您将设计视为由数据流互连的对象(线程)的集合。数据流由消息和邮件队列实现。这提供了数据的缓冲传输和线程之间定义良好的通信接口。从基于邮件和消息队列连接的线程的系统级设计开始,您可以对项目的不同子系统进行编码,如果您在团队中工作,这尤其有用。此外,由于每个线程都有明确定义的输入和输出,因此很容易隔离以进行测试和代码重用。

消息队列

要设置消息队列,我们​​首先需要分配内存资源:

osMessageQId_t Q_LED;
 
osMessageQueueAttr_t {
  const char *name;   ///< name of the message queue
  uint32_t attr_bits; ///< attribute bits
  void *cb_mem;       ///< memory for control block
  uint32_t cb_size;   ///< size of provided memory for control block
  void *mq_mem;       ///< memory for data storage
  uint32_t mq_size;   ///< size of provided memory for data storage
};

一旦声明了消息队列句柄和属性,我们就可以在线程中创建消息队列:

Q_LED = osMessageNew(DepthOfMesageQueue,WidthOfMessageQueue,&osMessageQueueAttr);

创建消息队列后,我们可以将数据从一个线程放入队列:

osMessageQueuePut (Q_LED,&dataIn,messagePrioriy, osWaitForever );

然后从另一个队列中读取:

result = osMessageQueueGet(Q_LED,&dataOut,messagePriority,osWaitForever);
扩展消息队列

在上一个例子中,我们定义了一个字宽的消息队列。如果您需要发送大量数据,还可以定义一个消息队列,其中每个插槽可以容纳更复杂的数据。首先,我们可以定义一个结构来保存我们的消息数据:

typedef struct {
  uint32_t duration;
  uint32_t ledNumber;
  uint8_t priority;
} message_t;

然后我们可以定义一个消息队列,它被格式化为接收这种类型的消息:

Q_LED = osMessageQueueNew (16, sizeof (message_t),&queueAttr_Q_LED );  
内存池

我们可以设计一个消息队列来支持大量数据的传输。然而,这种方法有一个开销,因为我们正在“移动”队列中的数据。在本节中,我们将着眼于设计一个数据保持静态的更高效的“零拷贝”邮箱。CMSIS-RTOS2 支持以固定块内存池的形式动态分配内存。首先,我们可以声明内存池属性:

osMemoryPoolAttr_t {
  const char *name;   ///< name of the memory pool
  uint32_t attr_bits; ///< attribute bits
  void *cb_mem;       ///< memory for control block
  uint32_t cb_size;   ///< size of provided memory for control block
  void *mp_mem;       ///< memory for data storage
  uint32_t mp_size;   ///< size of provided memory for data storage
} osMemoryPoo

以及内存池的句柄:

osMemoryPoolId_t mpool;

对于内存池本身,我们需要声明一个结构,其中包含我们在每个内存池中需要的内存元素:

typedef struct {
  uint8_t LED0;
  uint8_t LED1;
  uint8_t LED2;
  uint8_t LED3;
} memory_block_t;

然后我们可以在我们的应用程序代码中创建一个内存池:

mpool = osMemoryPoolNew (16, sizeof (message_t),&memorypoolAttr_mpool);

现在我们可以在一个线程中分配一个内存池槽:

memory_block_t *led_data;
led_data = (memory_block_t *) osMemoryPoolAlloc (mPool, osWaitForever );

然后用数据填充它:

led_data->LED0 = 0;
led_data->LED1 = 1;
led_data->LED2 = 2;
led_data->LED3 = 3;

然后可以将指向内存块的指针放在消息队列中:

osMessagePut(Q_LED,(uint32_t)led_data, osWaitForever );

现在可以通过另一个任务访问数据:

osEvent event;
memory_block_t *received;
event = osMessageGet(Q_LED, osWatiForever);
 received = (memory_block *)event.value.p;
led_on(received->LED0);

一旦内存块中的数据被使用,该块必须释放回内存池以供重用。

osPoolFree(led_pool,received);

为了创建零副本邮箱系统,我们可以将存储数据的内存池与用于传输指向已分配内存池槽的指针的消息队列结合起来。这样消息数据保持静态,我们在线程之间传递一个指针。备注:其实这里和rtthread的邮箱操作十分的相识,都是传递的指针。

配置

到目前为止,我们已经了解了 CMSIS-RTOS2 API。这包括线程管理功能、时间管理和线程间通信。现在我们已经清楚地了解了 RTOS 内核的功能,我们可以更详细地查看配置文件。

RTX_Config.h 是所有基于 Cortex-M 的微控制器的*配置文件。与其他配置文件一样,它是一个模板文件,它将所有必要的配置呈现为一组菜单选项(在配置向导视图中查看时)。

系统配置

在我们讨论系统配置部分中的设置之前,值得一提的是缺少什么。在 CMSIS-RTOS 的早期版本中,有必要将 CPU 频率定义为 RTOS 配置的一部分。在 CMSIS-RTOS2 中,CPU 频率现在取自“SystemCoreClock”变量,该变量被设置为 CMSIS-Core 系统启动代码的一部分。如果您使用的是新微控制器,则需要检查该值是否设置正确。

正如我们之前看到的,我们可以设置分配给“全局动态内存池”的内存量。接下来,我们可以定义以赫兹为单位的滴答频率。这定义了 SysTick 中断率,默认设置为 1 ms。通常,我会将这个频率保留为默认设置。然而,处理器时钟速度变得越来越快。如果您使用的是高性能设备,您可以考虑使用更快的滴答率。

“循环线程”切换在此部分中也默认启用。同样,我建议将这些设置保留为默认状态,除非您强烈要求更改它们。系统配置设置还允许我们在 RTOS 运行时控制发送到事件记录器的消息范围。

最后,如果我们从中断中设置线程标志,它们将被保留在队列中,直到它们被处理。根据您的应用程序,您可能需要增加此队列的大小。

线程配置

在线程配置部分,我们定义了 CMSIS-RTOS2 线程所需的基本资源。我们为每个线程分配一个“默认线程堆栈空间”(默认情况下,这是 200 字节)。当您创建线程时,将从全局动态 Mmemory 池分配此内存。但是,如果我们启用对象特定的内存分配,RTOS 将定义一个专用于线程使用的内存区域。如果切换到对象特定的内存分配,则需要提供有关线程内存数量和大小的详细信息,以便 RTOS 可以计算最大内存需求。

对于特定于对象的内存分配,我们必须定义将运行的最大用户线程数(不计算空闲或计时器线程)。我们还必须定义具有默认堆栈大小的线程数以及具有自定义粘性大小的线程所需的总内存量。一旦我们定义了用户线程使用的内存,我们就可以为空闲线程分配内存。在开发过程中,CMSIS-RTOS 可以捕获堆栈溢出。启用此选项时,线程堆栈空间溢出将导致 RTOS 内核调用osRtxErrorNotify()位于 RTX_Config.c 文件中的函数。此函数获取错误代码,然后陷入无限循环。堆栈检查选项旨在在调试期间使用,应在最终应用程序中禁用以最小化内核开销。但是,如果最终版本中需要增强错误保护,则可以修改osRtxErrorNotify()函数。

// OS Error Callback function
__WEAK uint32_t osRtxErrorNotify (uint32_t code, void *object_id) {
  (void)object_id;
  switch (code) {
    case osRtxErrorStackUnderflow:
      // Stack overflow detected for thread (thread_id=object_id)
      break;
    case osRtxErrorISRQueueOverflow:
      // ISR Queue overflow detected when inserting object (object_id)
      break;
    case osRtxErrorTimerQueueOverflow:
      // User Timer Callback Queue overflow detected for timer (timer_id=object_id)
      break;
    case osRtxErrorClibSpace:
      // Standard C/C++ library libspace not available: increase OS_THREAD_LIBSPACE_NUM
      break;
    case osRtxErrorClibMutex:
      // Standard C/C++ library mutex initialization failed
      break;
    default:
      // Reserved
      break;
  }
  for (;;) {}
//return 0U;
}

还可以在运行时监视最大堆栈内存使用情况。如果您选中“堆栈使用水印”选项,则会将模式 (0xCC) 写入每个堆栈空间。在运行期间,此水印用于计算最大内存使用量。在 Arm Keil MDK 中,这个数字是在 View - Watch Window - RTX RTOS 窗口的线程部分报告的。

此部分还允许我们选择线程是在特权模式还是非特权模式下运行。最后一个选项允许我们为用户线程定义处理器操作模式。如果您想要轻松的生活,请将其设置为“特权模式”,您将可以完全访问所有处理器功能。但是,如果您正在编写安全关键或安全应用程序,则可以使用“非特权模式”来防止线程访问关键处理器寄存器,从而限制运行时错误或入侵尝试。

系统定时器配置

与 CMSIS-RTOS 一起使用的默认计时器是 Cortex-M SysTick 计时器,它几乎存在于所有 Cortex-M 处理器上。SysTick 定时器的输入通常是 CPU 时钟。可以通过重载内核计时器函数来使用不同的计时器,如OS Tick API文档中所述。

结论

在本教程中,我们已经完成了 CMSIS-RTOS2 API,并介绍了一些与使用 RTOS 相关的关键概念。学习如何使用 RTOS 进行开发的唯一真正方法是在实际项目中实际使用它

相关参考

CMSIS_RTOS2_Tutorial

That’s all.Check it.

上一篇:【RTOS】《多任务抢占式调度器》笔记


下一篇:rtos学习之支持多优先级