本文摘自公众号:一枚嵌入式码农
链接:https://mp.weixin.qq.com/s/7Nw4CMnpC-WpW-gtgGOurQ?scene=1&click_id=2

很多嵌入式工程师都有这样的困惑:项目不大,用RTOS感觉杀鸡用牛刀;但任务一多,代码就乱成一锅粥。今天我们就来聊聊,不用RTOS,怎么把多任务处理得井井有条。

一、先搞清楚:为什么需要"多任务"?

假设你正在做一个智能温控器项目,需要同时处理这些事情:

  • 每100ms读取一次温度传感器
  • 每500ms刷新一次LCD显示
  • 实时响应按键操作
  • 每1秒检查一次是否需要开启加热

如果用最原始的写法,代码可能是这样的:

int main(void)
{
    System_Init();

    while(1)
    {
        Read_Temperature();    // 读温度
        Update_LCD();          // 刷屏
        Check_Key();           // 检测按键
        Control_Heater();      // 控制加热
    }
}

看起来挺简洁?但问题来了:

    1. 时序乱套:每个函数执行时间不一样,根本没法保证"每100ms读一次温度"
    1. 互相拖累:LCD刷新慢,其他任务都得等着
    1. 响应迟钝:按键可能要等好久才能被检测到

这就是典型的"伪多任务"——看着像并行,实际是串行排队。

二、时间片轮询:最简单的多任务方案

解决上面问题的第一步,就是给每个任务加上"时间管理"。核心思想很简单:记录每个任务上次执行的时间,到点了才执行

2.1 基本原理

先看一张示意图:

在这里插入图片描述

每个任务都有自己的执行周期,系统不断检查"时间到了没",到了就执行,没到就跳过。

2.2 代码实现

// 任务控制结构体
typedef struct {
    uint32_t last_run;      // 上次执行时间
    uint32_t interval;      // 执行间隔(ms)
    void (*task_func)(void); // 任务函数指针
} Task_t;

// 获取系统时间(通常用SysTick实现)
extern uint32_t Get_SysTick_Ms(void);

// 任务调度函数
void Task_Run(Task_t *task)
{
    uint32_t now = Get_SysTick_Ms();

    // 时间到了,执行任务
    if(now - task->last_run >= task->interval)
    {
        task->last_run = now;
        task->task_func();
    }
}

使用起来也很直观:

// 定义各个任务
Task_t task_temp   = {0, 100, Read_Temperature};   // 100ms读温度
Task_t task_lcd    = {0, 500, Update_LCD};         // 500ms刷屏
Task_t task_key    = {0, 20,  Check_Key};          // 20ms检测按键
Task_t task_heater = {0, 1000, Control_Heater};    // 1秒控制加热

int main(void)
{
    System_Init();

    while(1)
    {
        Task_Run(&task_temp);
        Task_Run(&task_key);
        Task_Run(&task_lcd);
        Task_Run(&task_heater);
    }
}

这样一来,每个任务都能按照自己的节奏执行,互不干扰。

三、进阶版:任务表驱动

上面的写法有个小问题:任务多了,main函数里要写一堆Task_Run()。我们可以用数组把任务统一管理起来。

3.1 任务表设计

// 任务表
Task_t task_table[] = {
    {0, 20,   Check_Key},         // 按键检测,优先级最高
    {0, 100,  Read_Temperature},  // 温度采集
    {0, 500,  Update_LCD},        // 显示刷新
    {0, 1000, Control_Heater},    // 加热控制
};

#define TASK_NUM  (sizeof(task_table) / sizeof(task_table[0]))

// 调度器
void Scheduler_Run(void)
{
    for(uint8_t i = 0; i < TASK_NUM; i++)
    {
        Task_Run(&task_table[i]);
    }
}

int main(void)
{
    System_Init();

    while(1)
    {
        Scheduler_Run();
    }
}

这样做的好处是:增删任务只需要改表,不用动主循环

3.2 架构示意图

在这里插入图片描述

四、状态机:让任务学会"分段执行"

时间片轮询解决了"什么时候执行"的问题,但还有一个坑:如果某个任务执行时间太长怎么办

比如LCD刷新需要50ms,那在这50ms里,其他任务都得干等着。这时候就需要状态机出场了。

4.1 问题场景

假设有个LED呼吸灯任务,需要:

    1. 亮度从0渐变到100(耗时1秒)
    1. 保持最亮500ms
    1. 亮度从100渐变到0(耗时1秒)
    1. 保持最暗500ms
    1. 循环往复

如果用阻塞式写法:

void Breath_LED(void)
{
    // 渐亮 - 阻塞1秒!
    for(int i = 0; i <= 100; i++) {
        Set_PWM(i);
        Delay_Ms(10);
    }
    Delay_Ms(500);  // 又阻塞500ms

    // 渐暗 - 又阻塞1秒!
    for(int i = 100; i >= 0; i--) {
        Set_PWM(i);
        Delay_Ms(10);
    }
    Delay_Ms(500);
}

这个函数一跑就是3秒,其他任务全部卡死。

4.2 状态机改造

把长任务拆成多个状态,每次只执行一小步:

typedef enum {
    LED_FADE_IN,    // 渐亮
    LED_HOLD_ON,    // 保持亮
    LED_FADE_OUT,   // 渐暗
    LED_HOLD_OFF    // 保持暗
} LED_State_t;

void Breath_LED_StateMachine(void)
{
    static LED_State_t state = LED_FADE_IN;
    static uint8_t brightness = 0;
    static uint32_t hold_start = 0;

    switch(state)
    {
        case LED_FADE_IN:
            brightness++;
            Set_PWM(brightness);
            if(brightness >= 100) {
                state = LED_HOLD_ON;
                hold_start = Get_SysTick_Ms();
            }
            break;

        case LED_HOLD_ON:
            if(Get_SysTick_Ms() - hold_start >= 500) {
                state = LED_FADE_OUT;
            }
            break;

        case LED_FADE_OUT:
            brightness--;
            Set_PWM(brightness);
            if(brightness == 0) {
                state = LED_HOLD_OFF;
                hold_start = Get_SysTick_Ms();
            }
            break;

        case LED_HOLD_OFF:
            if(Get_SysTick_Ms() - hold_start >= 500) {
                state = LED_FADE_IN;
            }
            break;
    }
}

现在每次调用只执行一小步,立刻返回,不会阻塞其他任务。

4.3 状态机执行流程

在这里插入图片描述

五、中断+标志位:处理紧急事件

有些事情等不得,比如串口收到数据、外部信号触发。这时候就需要中断来帮忙了。

但要注意一个原则:中断里只做最少的事,复杂处理放到主循环

5.1 错误示范

// 串口中断 - 错误写法!
void USART1_IRQHandler(void)
{
    uint8_t data = USART1->DR;

    // 在中断里解析协议?大忌!
    if(data == 0xAA) {
        Parse_Protocol();    // 可能耗时很长
        Execute_Command();   // 更长...
    }
}

中断里干太多活,会导致其他中断被延迟,系统响应变差。

5.2 正确做法:标志位+缓冲区

// 全局标志和缓冲区
volatile uint8_t rx_flag = 0;
volatile uint8_t rx_buffer[64];
volatile uint8_t rx_index = 0;

// 串口中断 - 只收数据,设标志
void USART1_IRQHandler(void)
{
    uint8_t data = USART1->DR;

    rx_buffer[rx_index++] = data;

    // 收到帧尾,设置标志
    if(data == '\n') {
        rx_flag = 1;
    }
}

// 主循环中处理
void Process_UART_Data(void)
{
    if(rx_flag)
    {
        rx_flag = 0;

        // 在这里慢慢解析,不影响中断
        Parse_Protocol(rx_buffer, rx_index);
        rx_index = 0;
    }
}

5.3 中断与主循环的配合

在这里插入图片描述

六、什么时候该上RTOS?

裸机多任务方案虽好,但也有边界。当你遇到这些情况时,可能就该考虑RTOS了:

    1. 任务之间需要严格的优先级抢占:比如电机控制必须在10us内响应
    1. 有复杂的任务同步需求:多个任务需要等待同一个事件
    1. 任务数量超过10个:管理起来越来越乱
    1. 需要动态创建/删除任务:裸机方案很难做到
    1. 团队协作开发:RTOS提供了更好的模块化边界

记住一句话:能用简单方案解决的,就别上复杂的。很多产品用裸机跑得好好的,没必要为了"高大上"而引入RTOS。

总结

回顾一下今天讲的内容:

在这里插入图片描述

这四板斧组合起来,足以应对大多数中小型嵌入式项目。代码清晰、资源省、调试方便,何乐而不为?

当然,如果你的项目确实复杂到需要RTOS,那也别硬撑。工具是为项目服务的,选择合适的才是最好的。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐