写单片机程序时,我们常常从一盏 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,同时明确落后时是跳过旧周期,还是有限度补执行,避免忙着补课拖住其他任务。
每个任务都要有「做多少就返回」的边界
串口处理任务若写成「一直读到没有数据」,在持续输入时可能长时间不返回。可以改成每轮最多处理一定数量的字节,下次继续。
循环一轮的最长时间,可以粗略理解为各任务单次最坏耗时,加上调度开销和中断占用。一个任务耗时很长,其他任务的响应就会受影响。因此,要根据按键响应、通信吞吐和采样要求,确定每轮允许花多少时间。
中断和主循环之间,要有明确的交接方式
常见安排是:中断记录事件或把数据放入缓冲区,主循环处理业务。布尔标志适合表达「需要处理」,但多次事件可能合并成一次;需要保留次数就用计数器,需要保留每次数据就用队列或环形缓冲区。
同时要明确缓冲区满了怎么办,以及哪些共享数据需要保护。协作式任务之间没有随时发生的线程抢占,中断仍可能在主循环执行期间修改数据。
如何拆分任务并验证响应时间?
先写出业务步骤,再标出其中所有的等待:等时间、等输入、等外设就绪、等通信回应。
每遇到一个等待点,就问自己三个问题:
- 返回之前要记住什么? 例如当前状态、开始时间、已处理的字节数。
- 下次凭什么继续? 例如时间到了、标志置位、缓冲区有了完整消息。
- 如果一直等不到,怎么办? 例如超时重试、记录错误、释放资源。
这样拆下来,一个原本从头执行到尾的长函数,就会变成几段短小、可重复调用的处理步骤。联调时可以让传感器故意不响应,让串口持续输入,观察 LED 和按键是否仍然正常;也可以用 GPIO 或计时器测量任务耗时,判断是否满足响应要求。
协作式多任务、Protothreads与RTOS怎么选?
对于任务数量有限、流程清楚、内存紧张的单片机项目,主循环加状态机通常是一个实用起点。调用顺序直观,状态容易观察,也便于逐步增加功能。
如果业务不断叠加、等待链条越来越复杂,可以进一步了解协程或 Protothreads。Contiki 的 Protothreads 文档 展示了轻量的协作式执行方式:代码可以表达等待与继续,由应用负责调度。若需要抢占、任务优先级和更完整的同步机制,则可以评估 RTOS;即使如此,驱动耗时和任务执行预算仍然需要设计。
协作式多任务带来的改变,是让我们重新审视每一次等待:LED 等下一次翻转时,按键可以继续扫描;传感器等转换时,串口可以继续收数据。每个任务都记住自己的进度,并及时返回,一个简单的主循环就能把几件事安排得井井有条。
