1. 裸机与RTOS的思维转变:从超级循环到任务调度

说实话,很多从51单片机或者简单STM32项目转过来的朋友,第一次接触RTOS时都会有点懵。我当年也是这样——明明我的超级循环跑得好好的,为什么要搞这么复杂?

但等你真正遇到一个需要同时处理按键扫描、LCD刷新、传感器采集、WiFi通信的项目时,你就会明白:超级循环就像一个人同时端三碗水,迟早要洒。

超级循环的痛,你懂吗?

先看看我们最熟悉的裸机写法:

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_USART1_UART_Init();
    
    while(1)
    {
        key_scan();        // 按键扫描,约5ms
        lcd_refresh();     // LCD刷新,约20ms
        sensor_read();     // 传感器读取,约50ms
        uart_send_data();  // 串口发送,约10ms
        delay_ms(10);      // 简单延时
    }
}

这个代码看起来挺规整对吧?但实际跑起来问题就来了。我在一个智能家居项目里就吃过这个亏——按键扫描要等传感器读完才能执行,结果用户按了键,灯要等100ms才响应。用户体验?别提了。

超级循环的核心问题就三个:

  • 实时性差:一个任务卡住,后面全排队
  • 耦合度高:改一个功能,可能影响整个循环时序
  • 扩展性弱:加个新功能,循环周期就变长

核心转变点:裸机是"我主动去检查每个任务",RTOS是"每个任务主动告诉我它准备好了"。

NuttX的任务:不再是排队,而是分时复用

在NuttX里,我们把每个独立的功能模块拆成一个任务(Task)。每个任务都有自己的栈空间、优先级和运行状态。

你想想看,这就像公司里不同部门各司其职——销售部不用等研发部下班才能干活。

/* 创建两个独立的任务 */
int main(void)
{
    /* 创建按键扫描任务,优先级100 */
    task_create("key_task", 100, 2048, key_task_entry, NULL);
    
    /* 创建传感器读取任务,优先级120 */
    task_create("sensor_task", 120, 2048, sensor_task_entry, NULL);
    
    /* 创建LCD刷新任务,优先级80 */
    task_create("lcd_task", 80, 4096, lcd_task_entry, NULL);
    
    /* 启动调度器,再也不会返回了 */
    nsh_system();
    return 0;
}

/* 每个任务都是独立的死循环 */
static int key_task_entry(int argc, char *argv[])
{
    while(1)
    {
        key_scan();
        /* 任务主动让出CPU,睡10ms */
        nxsig_usleep(10000);
    }
    return 0;
}

这里有个关键点:nxsig_usleep()。在裸机里我们用HAL_Delay()是傻等,CPU空转。但在RTOS里,任务调用延时函数时,CPU会立刻切换到其他就绪的任务。这就是"任务调度"的精髓。

我的习惯:创建任务时,优先级不要拍脑袋定。我一般先给所有任务设成同一个优先级,跑起来后用top命令看CPU占用率,再根据实际需求调整。这样更科学。

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

任务之间经常需要同步。比如传感器读完了,LCD才能显示新数据。裸机里我们用一个全局变量flag搞定,但RTOS里这么做容易出问题——你永远不知道另一个任务什么时候会改这个变量。

NuttX提供了信号量(Semaphore)来解决这个问题。说白了,信号量就是一个计数器,任务可以"等待"它,也可以"释放"它。

/* 定义一个信号量 */
static sem_t g_sensor_sem;

/* 初始化信号量,初始值为0 */
nxsem_init(&g_sensor_sem, 0, 0);

/* 传感器任务:读完后释放信号量 */
static int sensor_task_entry(int argc, char *argv[])
{
    while(1)
    {
        sensor_read();
        /* 释放信号量,通知LCD任务 */
        nxsem_post(&g_sensor_sem);
        nxsig_usleep(50000);
    }
}

/* LCD任务:等待信号量 */
static int lcd_task_entry(int argc, char *argv[])
{
    while(1)
    {
        /* 等待信号量,没有数据时就阻塞在这里 */
        nxsem_wait(&g_sensor_sem);
        lcd_display(sensor_data);
    }
}

我曾经在一个四轴飞行器项目里,因为没用信号量,直接用全局变量传数据,结果两个任务同时读写导致姿态数据错乱,飞机直接翻了个跟头。嗯,从那以后我再也不敢偷懒了。

避坑指南:信号量的初始值很关键。0表示"没有资源可用",任务会阻塞等待。如果设成1,就成了二值信号量,可以用来实现互斥访问。设成N,就是计数信号量,最多允许N个任务同时访问资源。

消息队列:任务间的"快递员"

信号量只能传递"有没有"这个信息,但很多时候我们需要传递具体的数据。比如串口收到一帧数据,要交给协议解析任务去处理。

这时候就该消息队列(Message Queue)上场了。它就像一个邮筒——发送方往里扔信,接收方定时来取信。

/* 定义消息队列句柄 */
static mqd_t g_uart_mq;

/* 定义消息结构体 */
struct uart_msg_s {
    uint8_t data[64];
    uint8_t len;
};

/* 创建消息队列,最多存放10条消息 */
struct mq_attr attr;
attr.mq_maxmsg = 10;
attr.mq_msgsize = sizeof(struct uart_msg_s);
attr.mq_flags = 0;

g_uart_mq = mq_open("uart_queue", O_CREAT | O_RDWR, 0666, &attr);

/* 串口中断服务程序:收到数据后发送到队列 */
static int uart_isr(int irq, void *context, void *arg)
{
    struct uart_msg_s msg;
    msg.data[0] = USART1->DR;
    msg.len = 1;
    
    /* 注意:中断里不能阻塞,要用非阻塞发送 */
    mq_send(g_uart_mq, (const char *)&msg, sizeof(msg), 0);
    return OK;
}

/* 协议解析任务:从队列中取数据 */
static int protocol_task_entry(int argc, char *argv[])
{
    struct uart_msg_s msg;
    
    while(1)
    {
        /* 阻塞等待消息,没有数据时任务休眠 */
        mq_receive(g_uart_mq, (char *)&msg, sizeof(msg), NULL);
        protocol_parse(msg.data, msg.len);
    }
}

你发现没有?消息队列天然解决了生产者和消费者的问题。串口中断是生产者,协议解析任务是消费者。队列在中间做缓冲,哪怕生产者突然爆发一批数据,也不会丢。

经验之谈:消息队列的大小要根据最坏情况来设。我一般取"最大可能积压量×1.5"。太小会丢消息,太大浪费RAM。STM32的RAM本来就金贵,别浪费。

知识体系总览

说了这么多,我画个图帮你理清思路。从裸机到RTOS,核心就是这三个转变:

裸机 → RTOS 思维转变核心 裸机超级循环 while(1) { 任务A(); // 排队执行 任务B(); // 等A完成 任务C(); // 等B完成 delay(); // CPU空转 } ❌ 实时性差 ❌ 耦合度高 ❌ 扩展性弱 NuttX RTOS 任务调度 任务A(按键扫描) 优先级100,栈2KB 任务B(传感器读取) 优先级120,栈2KB 任务C(LCD刷新) 优先级80,栈4KB NuttX 调度器 根据优先级 轮转调度 任务主动让出CPU 信号量/队列同步 ✅ 实时响应 ✅ 低耦合 ✅ 易扩展

从裸机到RTOS,你需要转变什么?

说白了,就是思维模式的转变:

对比维度 裸机思维 RTOS思维
执行方式 顺序执行,一个任务跑完才到下一个 并发执行,任务间按优先级抢占
延时处理 HAL_Delay() 空转等待 nxsig_usleep() 让出CPU,调度其他任务
数据共享 全局变量,到处可读写 信号量/队列/互斥锁,受控访问
中断处理 中断里做大量处理 中断只做标记,具体处理交给任务
资源管理 手动管理,容易冲突 系统提供机制,自动协调

我的建议:刚开始转RTOS时,别急着把所有功能都拆成任务。先挑两三个关键功能试试水,比如按键+LED+串口打印。跑通了再逐步增加。一口吃不成胖子,RTOS也是。

嗯,这一章的内容就到这里。记住一句话:裸机是你围着代码转,RTOS是代码围着任务转。这个思维转过来,后面的路就好走了。

专注资料整理