3、RTOS核心概念速览:任务、调度器、临界区、同步与通信机制

好,咱们进入正题。从裸机跳到RTOS,你最先要面对的,就是一堆新概念。任务、调度器、临界区、信号量……听着挺唬人,对吧?

其实没那么复杂。我刚开始接触RTOS那会儿,也觉得这些东西玄乎。后来做项目做多了,发现核心就那么几个。你把这几个概念吃透了,RTOS的大门就算敲开了。

这一章,咱们就快速过一遍这些核心概念。不深究细节,先搭个骨架。

3.1 任务:不再是死循环,而是独立线程

裸机编程里,你的代码通常是一个大while(1)循环。所有功能排队执行。LED闪一下,再去扫按键,再去算数据……

RTOS里不一样。你把一个大循环拆成几个独立的小循环,每个小循环就是一个任务。

任务是什么?

任务就是一段独立的程序,有自己的栈空间,有自己的优先级。它看起来就像是一个独立的CPU。

我习惯把任务想象成一个“人”。LED任务是个勤快的工人,每隔100ms就去闪一下灯。按键任务是个侦察兵,时刻盯着按键有没有被按下。通信任务是个邮递员,负责把数据从一个地方搬到另一个地方。

每个任务都有自己的任务控制块(TCB)。TCB就是任务的身份证,记录了任务的栈指针、状态、优先级等信息。操作系统就是靠TCB来管理所有任务的。

// 一个典型的任务函数
void led_task(void *parameter)
{
    while(1)
    {
        // 点亮LED
        HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET);
        // 延时100ms,注意:这里不是裸机的HAL_Delay
        vTaskDelay(pdMS_TO_TICKS(100));
        // 熄灭LED
        HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

你看,任务函数里还是一个while(1)循环。但关键区别在于vTaskDelay。这个函数会让任务“睡觉”,把CPU让给其他任务。这就是RTOS和裸机最大的不同——任务可以主动让出CPU。

小提示:任务栈大小要算好。我见过不少新手,任务栈开得太小,结果跑着跑着就栈溢出了。系统直接死给你看。一般先给个512字节,跑起来后通过API查看实际使用量,再调整。

3.2 调度器:谁该干活,谁该休息

任务有了,谁来决定哪个任务先跑?这就是调度器的活儿。

调度器是RTOS的心脏。它按照某种规则,决定当前该执行哪个任务。最常见的规则就是优先级抢占式调度。

说白了就是:谁优先级高,谁先跑。高优先级的任务准备好了,低优先级的就得让路。

注意:优先级不是越高越好。我曾经在一个项目里,把通信任务的优先级设得特别高。结果它一直抢CPU,低优先级的界面刷新任务卡得要死。用户按个键,半秒才有反应。后来把通信任务优先级降了一级,世界清净了。

调度器的工作流程大致是这样的:

  1. 系统时钟节拍(Tick)中断触发
  2. 调度器检查所有就绪任务
  3. 找出优先级最高的那个
  4. 保存当前任务的上下文(寄存器、栈指针等)
  5. 恢复新任务的上下文
  6. CPU开始执行新任务

这个过程叫上下文切换。它发生得非常快,通常几十微秒。你感觉不到,但系统内部一直在忙活。

下面这张图,展示了三个任务在调度器管理下的运行情况:

RTOS 调度器工作示意 t 任务A (高优先级) 阻塞 任务A 阻塞 任务A 就绪 任务B (中优先级) 就绪 就绪 任务B 就绪 就绪 任务B 就绪 就绪 任务C (低优先级) 就绪 就绪 就绪 任务C 就绪 就绪 任务C 说明: 高优先级任务A抢占CPU时,中优先级任务B和低优先级任务C只能等待。 当任务A阻塞(等待延时或资源)时,调度器才让任务B运行。

3.3 临界区:别让共享数据乱掉

多任务同时跑,就会遇到一个问题:共享数据保护。

你想想看,任务A正在往一个全局变量里写数据,写到一半,调度器切到任务B了。任务B也去读写这个变量。等切回任务A时,数据已经乱了。

这就是竞争条件。

解决的办法就是临界区。临界区是一段代码,在这段代码执行期间,不允许被其他任务或中断打断。

临界区的实现方式:

  • 关中断:最简单粗暴。进入临界区前关掉所有中断,出来再打开。但关中断时间不能太长,否则会影响系统实时性。
  • 使用调度锁:禁止任务切换,但中断仍然可以响应。比关中断温和一些。
  • 使用互斥量:这是更优雅的方式,后面会讲。
// 使用关中断保护临界区
taskENTER_CRITICAL();
{
    // 访问共享变量
    shared_counter++;
    // 更新共享缓冲区
    buffer[shared_counter % BUFFER_SIZE] = new_data;
}
taskEXIT_CRITICAL();

避坑指南:我曾经在一个项目里,为了图省事,在临界区里调了一个延时函数。结果系统直接死机。为什么?因为关中断期间,系统时钟节拍中断进不来,调度器没法工作。延时函数永远等不到超时。记住:临界区里不要做耗时操作。

3.4 同步与通信机制:任务之间怎么聊天

任务之间不是孤立的。它们需要互相配合,传递数据,或者通知对方“我干完了”。这就需要同步与通信机制。

RTOS提供了几种标准工具:队列、信号量、互斥量。我把它们比作任务之间的“聊天工具”。

3.4.1 队列:任务间的数据管道

队列是任务间传递数据的主要方式。它是一个先进先出(FIFO)的缓冲区。一个任务往队列里放数据,另一个任务从队列里取数据。

我习惯把队列想象成一个“邮箱”。发信的任务把信投进去,收信的任务定时去取。如果邮箱满了,发信的任务可以选择等待,或者直接丢弃。

// 创建队列
QueueHandle_t xQueue;
xQueue = xQueueCreate(5, sizeof(uint32_t)); // 队列长度5,每个元素4字节

// 任务A:发送数据
void sender_task(void *parameter)
{
    uint32_t data = 100;
    while(1)
    {
        // 发送数据,如果队列满则等待100ms
        if(xQueueSend(xQueue, &data, pdMS_TO_TICKS(100)) == pdPASS)
        {
            // 发送成功
        }
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

// 任务B:接收数据
void receiver_task(void *parameter)
{
    uint32_t received_data;
    while(1)
    {
        // 接收数据,如果队列空则一直等待
        if(xQueueReceive(xQueue, &received_data, portMAX_DELAY) == pdPASS)
        {
            // 处理接收到的数据
        }
    }
}

小技巧:队列不仅可以传数据,还可以传指针。比如传一个结构体的指针,这样效率更高。但要注意,指针指向的内存必须是全局的或者静态分配的,不能是局部变量。

3.4.2 信号量:任务间的红绿灯

信号量更像是一个“旗子”或“令牌”。它用来通知某个事件发生了,或者控制对某个资源的访问数量。

信号量分两种:

类型 特点 典型用途
二值信号量 只有0和1两个状态 通知事件发生(比如数据到了)
计数信号量 可以有多个资源计数 管理有限资源(比如5个缓冲区)

我举个例子。你在做串口接收,中断里收到一个字节,想通知任务来处理。用二值信号量就很合适:

// 中断服务函数
void USART_IRQHandler(void)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    // 读取数据
    uint8_t data = USART->DR;
    // 存入缓冲区
    rx_buffer[rx_index++] = data;
    // 释放信号量,通知任务
    xSemaphoreGiveFromISR(xBinarySemaphore, &xHigherPriorityTaskWoken);
    // 如果有更高优先级的任务被唤醒,立即切换
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// 任务函数
void uart_task(void *parameter)
{
    while(1)
    {
        // 等待信号量
        if(xSemaphoreTake(xBinarySemaphore, portMAX_DELAY) == pdPASS)
        {
            // 处理接收到的数据
            process_rx_data();
        }
    }
}

3.4.3 互斥量:解决资源争抢的终极武器

互斥量是二值信号量的“升级版”。它专门用来保护共享资源,比如一个硬件外设、一块内存区域。

互斥量有个关键特性:优先级继承。这是什么意思?

你想想看,低优先级任务拿着互斥量,高优先级任务在等。如果中间还有个中优先级任务在跑,低优先级任务就一直拿不到CPU,互斥量就释放不了。高优先级任务也一直等。这就是优先级反转。

互斥量的优先级继承机制,会临时把低优先级任务的优先级提升到高优先级任务的级别,让它尽快释放资源。问题就解决了。

什么时候用互斥量,什么时候用二值信号量?

  • 互斥量:保护共享资源,需要优先级继承。同一个任务可以“递归”获取(递归互斥量)。
  • 二值信号量:事件通知,不需要优先级继承。通常用于中断和任务之间的同步。
// 创建互斥量
SemaphoreHandle_t xMutex;
xMutex = xSemaphoreCreateMutex();

// 使用互斥量保护共享资源
void write_to_shared_memory(uint8_t *data, uint32_t len)
{
    // 获取互斥量
    if(xSemaphoreTake(xMutex, portMAX_DELAY) == pdPASS)
    {
        // 安全地写入共享内存
        for(uint32_t i = 0; i < len; i++)
        {
            shared_memory[i] = data[i];
        }
        // 释放互斥量
        xSemaphoreGive(xMutex);
    }
}

注意:互斥量不能在中断服务函数里使用。因为中断里不能阻塞等待。如果你需要在中断里保护资源,用二值信号量或者直接关中断。

3.5 本章小结

好了,核心概念咱们过了一遍。你记住几个关键点:

  • 任务是独立的小循环,有自己的栈和优先级。
  • 调度器按优先级决定谁跑,上下文切换是它的核心操作。
  • 临界区保护共享数据,但别在里面干耗时的事。
  • 队列传数据,信号量做通知,互斥量保护资源。

这些概念,你光看可能觉得抽象。别急,后面几章咱们会动手写代码,把这些概念一个个用起来。到时候你就发现,其实没那么难。