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,你需要转变什么?
说白了,就是思维模式的转变:
| 对比维度 | 裸机思维 | RTOS思维 |
|---|---|---|
| 执行方式 | 顺序执行,一个任务跑完才到下一个 | 并发执行,任务间按优先级抢占 |
| 延时处理 | HAL_Delay() 空转等待 | nxsig_usleep() 让出CPU,调度其他任务 |
| 数据共享 | 全局变量,到处可读写 | 信号量/队列/互斥锁,受控访问 |
| 中断处理 | 中断里做大量处理 | 中断只做标记,具体处理交给任务 |
| 资源管理 | 手动管理,容易冲突 | 系统提供机制,自动协调 |
我的建议:刚开始转RTOS时,别急着把所有功能都拆成任务。先挑两三个关键功能试试水,比如按键+LED+串口打印。跑通了再逐步增加。一口吃不成胖子,RTOS也是。
嗯,这一章的内容就到这里。记住一句话:裸机是你围着代码转,RTOS是代码围着任务转。这个思维转过来,后面的路就好走了。