Posted in

单片机协作式多任务:C语言主循环与状态机

写单片机程序时,我们常常从一盏 LED 开始:点亮、延时、熄灭、再延时。几行代码就能看到结果,很有成就感。

但需求很快会变成:LED 要闪,按键要响应,传感器要采样,串口还要接收命令。把这些代码依次塞进 while (1),却发现按键有时迟钝,串口有时漏数据。程序看起来安排了很多事情,执行时却总被某一段等待卡住。

这时值得认识一个朴素而实用的思路:协作式多任务(Cooperative Multitasking)——每个任务只做当前能做的一小步,然后主动交还执行机会。

一段延时,为什么会拖住整个程序?

先看一个常见写法,下面的函数名代表具体开发板提供的硬件接口:

while (1) {
    led_toggle();
    delay_ms(500);
    button_task();
}

假设 delay_ms() 是阻塞延时,那么等待的这 500 毫秒里,主循环不会调用 button_task() 检查按键。一次短暂的按下、松开可能全发生在这段等待中,任务便错过了点击。即使中断仍然能够运行,主循环里的按键处理也只能排队等着。

问题就在于:LED 暂时没有事情可做,却占着主循环不放。

换一种安排就顺畅了:LED 每次被调用时检查一下时间,时间到了就切换,没到就返回。按键任务每次只读取当前按键状态,记住是否已经按下,后续调用发现松开时,再认定完成了一次 click(点击)。主循环不断轮流拜访它们。

Arduino 官方的 Blink Without Delay 示例 就展示了这种用时间检查替代阻塞延时的做法。

C语言示例:主循环调度LED与按键任务

在裸机程序中,这种写法常被称为 Super Loop(超级循环)加非阻塞任务。当任务需要记住执行进度时,再配上状态机,就能形成一个轻量的协作式多任务框架。

#include <stdint.h>
#include <stdbool.h>

/* 由开发板的驱动层实现。 */
void board_init(void);
uint32_t millis(void);
void led_toggle(void);
bool button_is_down(void);  /* 按下返回 true,松开返回 false */
/* 由业务层实现,处理一次点击后快速返回。 */
void on_button_click(void);

static void led_task(void)
{
    static uint32_t last_ms = 0;
    uint32_t now = millis();

    if ((uint32_t)(now - last_ms) >= 500U) {
        last_ms = now;
        led_toggle();
    }
}

static void button_task(void)
{
    static bool waiting_release = false;
    bool down = button_is_down();

    if (!waiting_release && down) {
        waiting_release = true;  /* 记住按下,下次再检查 */
    } else if (waiting_release && !down) {
        waiting_release = false; /* 按下后松开,完成一次 click */
        on_button_click();
    }
}

int main(void)
{
    board_init();

    while (1) {
        led_task();
        button_task();
    }
}

这段 C 代码展示的是调度骨架:millis() 需要返回递增的毫秒计数,led_toggle() 负责翻转 GPIO,button_is_down() 快速读取当前按键是否按下,on_button_click() 执行点击后的动作,例如切换工作模式。硬件接口需要接入具体芯片的驱动,点击处理函数需要按业务实现,不能只复制这段代码就直接烧录运行。

按键点击检测:多次调用怎样识别一次 click?

这里约定一次 click 是「按下后再松开」,不区分短按和长按。关键是 static 变量 waiting_release:函数返回后,它仍然保存着上一次的进度。

调用时刻 当前按键 任务的判断与动作 返回后保存的状态
第一次 松开 还没有按下,直接返回 等待按下
第二次 按下 记录已经按下,然后返回 等待松开
第三次及后续多次 仍按下 还没松开,直接返回 等待松开
后来某一次 松开 按下、松开完整发生,处理一次 click 等待下一次按下
再下一次 仍松开 没有新的按下,直接返回 等待按下

第二次调用并没有用 while 等待松手。它把「等待松开」保存下来就返回,所以用户按住按键的这段时间,LED 等其他任务仍然可以执行。直到后来的某次调用读到松开,才完成这一轮流程。

一次 click 跨越了多次任务调用:每次调用只观察一步,状态把这些步骤连接起来。 这正是协作式多任务要表达的设计思路。

程序每轮都调用 LED 任务,但 LED 并不会每轮都翻转。last_ms 记住上次切换时间,只有间隔达到 500 毫秒时才执行动作。其余调用都很快返回,按键因此能得到持续检查。

为突出状态保存,上面的按键示例省略了机械按键消抖,假设读到的是稳定输入;直接读取真实机械按键可能因为抖动而重复识别点击。实际接入时,可以再保存「候选输入」和「变化时间」,每次调用检查候选输入是否已连续稳定,例如达到 20 毫秒,确认后再交给上述按下、松开逻辑。具体消抖时长需要按硬件验证,整个过程都不用阻塞延时。

主循环还必须足够频繁地检查输入,才能观察到按下和松开;如果两者都发生在相邻两次调用之间,单靠轮询无法识别。on_button_click() 也应快速返回,耗时业务可以交给其他任务分步推进。

这里所说的「同时忙几件事」,是多个任务交替推进。CPU 在主循环中仍然按顺序执行;在这种手写状态机方案中,每个任务也没有独立的线程栈。

非阻塞状态机:把传感器等待拆成多个步骤

周期任务比较容易理解,更有代表性的是这种业务流程:发出测量命令,等传感器转换完成,再读取结果。

直觉写法往往是:

sensor_start();
delay_ms(20);
sensor_read();

要让这段流程参与协作,就把它拆成两个阶段,把等待期间需要记住的信息保存下来:

static void sensor_task(void)
{
    enum { START, WAIT };
    static unsigned state = START;
    static uint32_t start_ms;
    uint32_t now = millis();

    switch (state) {
    case START:
        sensor_start();
        start_ms = millis();
        state = WAIT;
        break;

    case WAIT:
        if ((uint32_t)(now - start_ms) >= 20U) {
            sensor_read();
            state = START;
        }
        break;
    }
}

把它加入主循环即可。首次调用发出命令并记录时间;后续调用发现时间没到就返回;满足条件后读取结果,开始下一轮。这是一个持续测量的简化示例,20 毫秒是假设值,实际等待时间应以传感器手册为准。

state 保存「做到哪一步」,start_ms 保存「从什么时候开始等」。下一次进入函数时,程序据此继续推进。需要跨调用保留的数据,要放在 static 变量或任务上下文结构体中。

这里还需要一个前提:sensor_start() 和 sensor_read() 必须快速、有界地完成。如果底层 I²C 驱动一直等待总线恢复,外层状态机仍会被卡住。硬件支持就绪标志时,也可以检查标志来决定何时读取,并为等待设置超时。

协作式多任务框架需要哪些设计要素?

一个小型协作式框架,通常离不开下面这些东西:

要素 解决的问题 最简单的实现
调度入口 谁来调用各个任务 while (1) 轮流调用
时间基准 是否到了执行时机 毫秒计数与间隔比较
任务上下文 下次从哪里继续 状态、时间戳、处理偏移量
触发条件 为什么现在要执行 时间到、输入变化、数据到达
超时与恢复 条件一直不满足怎么办 超时后报错、重试或回到初始状态
执行时间预算 其他任务要等多久 限制每次处理的数据量和步骤数

任务少的时候,这些要素可以直接写在函数里。任务多了,再把周期、状态和回调整理成结构体或任务表。理解协作机制并不需要先搭建复杂的调度器。

时间基准要可靠

毫秒计数通常由定时器中断更新,主循环读取。对 32 位无符号计数,建议用 now - last_ms 判断经过的时间,避免直接写 now >= last_ms + interval 时遇到计数回绕的问题。前面的减法写法能处理一次回绕,但前提是实际经过时间没有达到完整的计数周期;32 位毫秒计数约每 49.7 天回绕一次。

如果芯片不能原子读取 32 位计数,例如某些 8 位单片机,millis() 还需要通过短暂的临界区等方式取得一致快照。仅把计数声明为 volatile,不能保证一次读取的原子性。

示例中的 last_ms = now 表示从本次实际执行时刻重新计时,简单,但延迟会累积为周期漂移。固定节拍的任务可以采用 last_ms += interval,同时明确落后时是跳过旧周期,还是有限度补执行,避免忙着补课拖住其他任务。

每个任务都要有「做多少就返回」的边界

串口处理任务若写成「一直读到没有数据」,在持续输入时可能长时间不返回。可以改成每轮最多处理一定数量的字节,下次继续。

循环一轮的最长时间,可以粗略理解为各任务单次最坏耗时,加上调度开销和中断占用。一个任务耗时很长,其他任务的响应就会受影响。因此,要根据按键响应、通信吞吐和采样要求,确定每轮允许花多少时间。

中断和主循环之间,要有明确的交接方式

常见安排是:中断记录事件或把数据放入缓冲区,主循环处理业务。布尔标志适合表达「需要处理」,但多次事件可能合并成一次;需要保留次数就用计数器,需要保留每次数据就用队列或环形缓冲区。

同时要明确缓冲区满了怎么办,以及哪些共享数据需要保护。协作式任务之间没有随时发生的线程抢占,中断仍可能在主循环执行期间修改数据。

如何拆分任务并验证响应时间?

先写出业务步骤,再标出其中所有的等待:等时间、等输入、等外设就绪、等通信回应。

每遇到一个等待点,就问自己三个问题:

  1. 返回之前要记住什么? 例如当前状态、开始时间、已处理的字节数。
  2. 下次凭什么继续? 例如时间到了、标志置位、缓冲区有了完整消息。
  3. 如果一直等不到,怎么办? 例如超时重试、记录错误、释放资源。

这样拆下来,一个原本从头执行到尾的长函数,就会变成几段短小、可重复调用的处理步骤。联调时可以让传感器故意不响应,让串口持续输入,观察 LED 和按键是否仍然正常;也可以用 GPIO 或计时器测量任务耗时,判断是否满足响应要求。

协作式多任务、Protothreads与RTOS怎么选?

对于任务数量有限、流程清楚、内存紧张的单片机项目,主循环加状态机通常是一个实用起点。调用顺序直观,状态容易观察,也便于逐步增加功能。

如果业务不断叠加、等待链条越来越复杂,可以进一步了解协程或 Protothreads。Contiki 的 Protothreads 文档 展示了轻量的协作式执行方式:代码可以表达等待与继续,由应用负责调度。若需要抢占、任务优先级和更完整的同步机制,则可以评估 RTOS;即使如此,驱动耗时和任务执行预算仍然需要设计。

协作式多任务带来的改变,是让我们重新审视每一次等待:LED 等下一次翻转时,按键可以继续扫描;传感器等转换时,串口可以继续收数据。每个任务都记住自己的进度,并及时返回,一个简单的主循环就能把几件事安排得井井有条。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注