2、实时操作系统(RTOS)基础概念

好,咱们开始聊RTOS。说实话,我早年做工业控制时,一直用前后台系统。那时候觉得,不就一个主循环加几个中断嘛,够用了。直到有一次,一个多轴运动控制的项目把我折腾得够呛——几个中断抢资源,主循环里一个延时就把整个系统卡住了。嗯,从那以后,我才真正开始认真研究RTOS。

2.1 前后台系统 vs RTOS

先说说前后台系统。说白了,就是一个大循环(后台)加上中断服务(前台)。代码结构大概这样:

void main(void)
{
    while(1)
    {
        // 后台:轮询各种标志
        if(flag_adc)  { process_adc(); }
        if(flag_uart) { process_uart(); }
        // 延时等待
        delay_ms(10);
    }
}

void ISR_ADC(void)
{
    flag_adc = 1;  // 前台:置个标志就跑
}

这种结构简单,但问题也明显。你想想看,如果process_adc()执行时间长了,process_uart()就得等着。我在一个温控项目中就吃过这个亏——ADC处理里加了个滤波算法,结果UART接收超时,数据丢了一串。

RTOS就不一样了。它把任务拆开,让每个任务看起来像在“独享”CPU。实际上,调度器在背后快速切换。我习惯这么比喻:前后台系统像只有一个窗口的银行,所有人排队;RTOS像多个窗口,每个窗口服务一个客户,窗口间快速轮换。

前后台系统适合简单、任务少的场景。一旦任务超过3-5个,或者有严格的时间要求,我建议你直接上RTOS。

2.2 任务(Task)

任务,是RTOS里最基本的执行单元。每个任务都有自己的栈空间、优先级和状态。任务的状态一般有四种:

  • 运行态:正在使用CPU
  • 就绪态:啥都准备好了,等调度器分配CPU
  • 阻塞态:在等某个事件(比如信号量、消息队列)
  • 挂起态:被暂停了,需要别人唤醒

我见过不少新手犯一个错:把任务写得特别大,一个任务里塞了几百行代码。其实,任务应该短小精悍。我个人习惯,一个任务就是一个独立的功能模块,比如“读取传感器”、“处理通信协议”、“控制电机”。

void task_sensor(void *param)
{
    while(1)
    {
        read_adc();
        vTaskDelay(pdMS_TO_TICKS(100)); // 100ms采集一次
    }
}

void task_control(void *param)
{
    while(1)
    {
        // 等待传感器数据就绪
        xSemaphoreTake(sem_data_ready, portMAX_DELAY);
        calculate_pid();
        set_motor();
    }
}
任务栈大小要留余量。我一般按实际使用量的1.5倍来分配。曾经有个项目,任务栈只给了128字节,结果跑着跑着就栈溢出,查了三天才找到原因。

2.3 调度器(Scheduler)

调度器是RTOS的心脏。它决定下一个该运行哪个任务。常见的调度策略有两种:

调度策略特点适用场景
优先级抢占式高优先级任务随时可以打断低优先级任务实时性要求高的工业控制
时间片轮转同优先级任务轮流执行固定时间片多个同等重要的任务

我建议在工业控制中优先使用优先级抢占式。为什么?因为紧急事件(比如急停信号)必须立刻响应。你想想看,如果电机过流了,还要等当前任务的时间片用完才处理,那设备可能就烧了。

不过要注意优先级反转的问题。我曾经在一个项目中,低优先级任务占着互斥锁,中优先级任务一直跑,高优先级任务等锁等到超时。解决办法?用优先级继承协议,或者干脆把临界区做短一点。

2.4 内核对象

内核对象是任务间通信和同步的桥梁。我重点说三个最常用的:

信号量(Semaphore)

信号量就是个计数器。二值信号量像一把钥匙,谁拿到谁干活。计数信号量像停车场空位,空位有几个就能停几辆车。

// 二值信号量:中断中释放,任务中等待
void ISR_Button(void)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xSemaphoreGiveFromISR(sem_button, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

void task_button(void *param)
{
    while(1)
    {
        xSemaphoreTake(sem_button, portMAX_DELAY);
        // 处理按键事件
    }
}
在中断中释放信号量,一定要用FromISR版本。我见过有人直接在中断里调用xSemaphoreGive(),结果系统直接死机。记住:中断上下文和任务上下文是不同的世界。

消息队列(Message Queue)

消息队列用于传递数据。比如一个任务采集数据,另一个任务处理数据。队列可以缓冲多个消息,避免数据丢失。

typedef struct {
    uint16_t adc_value;
    uint32_t timestamp;
} sensor_data_t;

// 发送任务
void task_send(void *param)
{
    sensor_data_t data;
    while(1)
    {
        data.adc_value = read_adc();
        data.timestamp = get_tick();
        xQueueSend(queue_sensor, &data, 0);
        vTaskDelay(10);
    }
}

// 接收任务
void task_recv(void *param)
{
    sensor_data_t data;
    while(1)
    {
        if(xQueueReceive(queue_sensor, &data, pdMS_TO_TICKS(100)) == pdTRUE)
        {
            process_data(&data);
        }
    }
}

互斥锁(Mutex)

互斥锁解决的是资源互斥访问问题。比如两个任务都要写同一个串口,不加锁的话,数据就乱套了。

互斥锁和二进制信号量有啥区别?互斥锁有优先级继承机制,能防止优先级反转。所以,保护共享资源时,我建议用互斥锁,别用信号量。

void task_log(void *param)
{
    while(1)
    {
        xSemaphoreTake(mutex_uart, portMAX_DELAY);
        // 安全地使用串口
        uart_send("Log data...\n");
        xSemaphoreGive(mutex_uart);
        vTaskDelay(1000);
    }
}

2.5 实时性指标

做工业控制,实时性就是生命。三个核心指标你得搞清楚:

  • 响应时间(Latency):从事件发生到任务开始处理的时间。我一般要求控制在1ms以内。
  • 抖动(Jitter):响应时间的波动范围。抖动越小,系统越稳定。比如一个周期任务,每次执行间隔应该是10ms,但实际可能是9.8ms到10.2ms之间波动,这个波动就是抖动。
  • 吞吐量(Throughput):单位时间内能处理的任务数量。说白了就是系统能扛多重的活。
响应时间和吞吐量往往是一对矛盾。你追求极致的响应时间,频繁切换任务,吞吐量就会下降。反过来,你想提高吞吐量,任务切换少了,响应时间就可能变长。这个平衡点,需要根据具体项目来调。

我记得有个项目,客户要求响应时间小于500μs。我一开始用FreeRTOS默认配置,结果响应时间在800μs左右。后来我把中断优先级调高,把调度器Tick频率从100Hz提到1000Hz,又把一些不紧急的任务放到空闲钩子里,最终压到了400μs以内。嗯,调优的过程就像解谜,挺有意思的。

测量实时性指标时,别用仿真器。仿真器本身就会引入延迟。我习惯用GPIO翻转+示波器来测,简单又准确。

好了,这一章的基础概念就聊到这儿。下一章我们深入讲讲任务创建和管理的细节。记住一句话:RTOS不是万能药,但用好了,它能让你的系统脱胎换骨。