简介:SCoop-master.zip 是面向 Arduino 平台的多线程库压缩包,适用于需要并发处理多任务、提高程序响应速度的中高级开发者。该库允许在单片机上同时运行多个任务,解决传统顺序编程中任务互相等待的问题,常用于传感器采集、显示刷新、通信处理等场景。压缩包共含 58 个文件,其中 29 个 .ino 示例程序演示多线程基本用法与典型应用,7 个 .h 与 7 个 .cpp 构成库的核心实现,12 个 .txt 覆盖版本变更日志、readme 说明和关键字索引,另有 1 份 SCoop 用户指南 PDF 帮助系统学习。包体仅 726KB,目录按组件划分,包含原生 SCoop、SchedulerARMAVR、IOFilter、TimerDown、TimerUp 等模块,便于定向查阅。目前已有 733 人学习下载。通过阅读源码和运行示例,读者可以掌握 Arduino 多线程的调度机制、线程同步与资源管理要点,并快速将多线程模式应用到自己的项目中。
1. SCoop-master.zip 能为 Arduino 做什么:从 delay 阻塞到协作式多任务
做 Arduino 项目的人迟早会遇到同一个烦恼:主循环里一旦写了 delay(1000),按键响应就全部卡住。用定时器中断可以绕开,但逻辑一多,中断函数和状态机交织起来,整个程序就变成了黑匣子。SCoop-master.zip 就是为这个场景准备的:一个基于协作式调度的 Arduino 多任务库,让你在一个程序里同时定义多个"看起来像线程"的任务,每个任务自己决定什么时间睡、什么时间醒。它不像 RTOS 那样吃资源,也不需要学复杂的消息队列,适合想快速把阻塞逻辑拆成并发结构的开发者。下面直接进入安装、写法和排错。
2. 安装 SCoop-master.zip 到 Arduino IDE:目录命名诀窍与最小并发示例
2.1 从 zip 到 libraries:两步装好库
从代码托管平台下载的 SCoop-master.zip 解压后,第一层是 SCoop-master 目录,里面才是 SCoop.h、examples、src 这些内容。这里藏着一个新手最容易踩的坑:Arduino IDE 的库管理器靠目录名识别库名,目录叫 SCoop-master 或 SCoop-master-copy,都会导致 IDE 遇到#include "SCoop.h"时报找不到文件。我每次拿到这种 -master 后缀的库,第一件事就是把文件夹改名为 SCoop,再放到用户库目录。
# 解压并安装到 Arduino 用户库目录 unzip SCoop-master.zip mv SCoop-master ~/Arduino/libraries/SCoopWindows 用户注意区分两个 libraries 目录:Arduino IDE 安装目录下那个是全局库目录,但通常需要管理员权限才能写入;用户文档目录下的 Documents\Arduino\libraries 是默认库目录,不需要额外权限,我一般放这里。放好之后重启 Arduino IDE,打开"文件 → 示例"如果能看到 SCoop 分类,说明识别成功。没看到也不要紧,新建一个空程序只写#include "SCoop.h",能编译过就算装上。
装好后的目录结构大致是这样:
SCoop/ ├── SCoop.h ├── examples/ │ ├── 01.Basics │ └── 02.Signal └── src/ └── SCoop.cpp只要 SCoop.h 在 SCoop 目录的第一层,这个库就能被正常 include。如果 IDE 提示多个库定义冲突,十有八九是你解压出了两个 SCoop 文件夹,一个在安装目录一个在用户目录,删掉其中一个就好。
2.2 最小可运行示例:两个 LED 各自闪烁
下面这段代码是 SCoop 最典型的使用方式:两个任务,一个每 200ms 翻转一次 LED_A,一个每 600ms 翻转一次 LED_B。在单线程的单片机上,它们看起来是并发跑的。
#include "SCoop.h" const int LED_A = 10; const int LED_B = 11; DEFINE_TASK(ledA) { while (1) { digitalWrite(LED_A, HIGH); sleep(200); digitalWrite(LED_A, LOW); sleep(200); } } DEFINE_TASK(ledB) { while (1) { digitalWrite(LED_B, HIGH); sleep(600); digitalWrite(LED_B, LOW); sleep(600); } } void setup() { pinMode(LED_A, OUTPUT); pinMode(LED_B, OUTPUT); mySCoop.start(); } void loop() { mySCoop.loop(); }DEFINE_TASK(ledA)这个宏在全局作用域里定义一个名字叫 ledA 的任务体,任务体必须写成无限循环结构。SCoop 会在mySCoop.loop()被调用的那一刻开始按顺序执行任务。sleep(200)不是简单忙等:它让当前任务挂起 200ms,同时把执行权交还给调度器,这是整个库能实现"多任务"的关键。参数单位是毫秒,最小精度取决于 Arduino 的 millis() 实现,这一点在后面会单独踩到。
mySCoop.start()负责初始化调度器并注册所有任务,必须放在 setup() 里。mySCoop.loop()必须放在 Arduino 的 loop() 函数中,它是整个调度器的驱动源。如果你在 loop() 里先写了一个 delay(500) 再做其他事,所有任务都会跟着变慢甚至停摆,这个坑后面避坑章节会专门讲。
如果你的两个任务都想用同样的 sleep 周期,输出会出现稳定的交替;如果某个任务用了 yield() 而不是 sleep,它会在每轮调度中频繁被选中,无形中压缩其他任务的执行间隔。想让任务周期性执行,第一选择永远是 sleep,yield 只适合"我没什么可干,先让给别人"的场景。
提示:刚才这个例子只要接两个 LED 就能直接看到效果。包里自带的 examples 目录里有基础示例,可以先跑那个示例确认库本身没问题,再改成你自己的引脚和周期。
3. 协作式调度原理:sleep 与 yield 背后的任务切换机制
3.1 单芯片上的多任务:私有栈与调度循环
SCoop 本质是一个协作式调度器。它没有用 RTOS 那种抢占式内核,而是为每个任务分配一块私有栈,调度器维护一个任务队列,按顺序轮转执行。任务调用 sleep 或 yield 时,把自己的执行现场保存进栈,跳回调度器;调度器再找到下一个任务,恢复它的现场继续跑。整个过程在 AVR 这类单核 MCU 上完全裸机实现,不依赖操作系统。
协作式调度的最大特点是"自觉"。调度器不会在时间片耗尽时强行切换任务,只要一个任务不主动让出 CPU,其他任务就永远没有机会执行。所以任务体里不能有用 while(1) 空转的代码,哪怕只是很短时间的忙等,都会造成整个系统卡死。这也是为什么 SCoop 任务里不能使用 Arduino 原生的 delay(),delay 是阻塞等待,不交还 CPU。遇到这些库函数时要用 SCoop 提供的 sleep 替代。
我理解这套机制时习惯把 main loop 比作一个分时柜台:每个任务是一个顾客,sleep 相当于顾客说"我先去旁边坐着,你们继续办业务"。顾客不主动让位,柜台就卡在那里。你的代码里如果有一个顾客一直在柜台前说个不停,其他顾客就只能干等,这在协作式调度里是最常见的事故源头。
任务切换的开销并不大。在 16MHz 的 AVR 芯片上,一次切换通常只需要保存少量寄存器和栈指针,几十微秒级别,对传感器采样、按键扫描、串口数据处理这类场景完全够用。但如果你需要微秒级的高频输出,比如软件模拟 PWM 或精确波形,就不适合用 SCoop,那种需求还是回到硬件定时器比较稳妥。
3.2 三种并发原语:Task、Signal、Timer
SCoop 不只是一个 DEFINE_TASK,它还提供 DEFINE_SIGNAL 负责任务间通知,以及 Timer 组件做周期回调。把它们放在一起看,才能判断一个需求该用哪种原语。
常用原语对照:
| 原语 | 行为 | 典型场景 |
|---|---|---|
| Task | 独立任务体,循环内 sleep 或 yield | LED 控制、按键扫描、串口解析 |
| Signal | 任务等待信号,其他任务发信号唤醒 | 按键事件、任务间握手 |
| Timer | 按固定周期触发一段逻辑 | 周期采样、超时看门狗 |
Task 是这几类原语的地基,Signal 和 Timer 最终也都挂在调度器上。定义任务时可以指定栈大小,比如DEFINE_TASK(name, stackSize),不过默认栈在大多数场景下够用,只有任务里放了大数组时才会遇到问题,这一点在避坑章节细说。
任务体和普通函数的差别值得留意:普通函数退出后局部变量就销毁了,而任务从 sleep 醒来会回到上次离开的位置,继续使用之前的局部变量。所以任务里完全可以用局部变量保存状态,不需要一律写 static。但因为每个任务有自己的私有栈,栈大小有限,局部变量里的大数组会明显挤占栈空间,后面有大数组的坑。
3.3 yield 与 sleep 怎么选
协作式调度里有两个让出 CPU 的入口,它们行为不同。选错了任务周期会变得很奇怪。
| 函数 | 行为 | 使用场景 |
|---|---|---|
| sleep(ms) | 挂起当前任务至少 ms 毫秒,到点后重新排队 | 周期任务、延时 |
| yield() | 立即让出当前时间片,下一轮马上又轮到 | 短任务切走、释放 CPU |
新手最容易把 yield 当成 sleep(0) 用。实际效果差很多:yield 只是把当前任务放到队列尾,下一次调度马上又到它,适合处理完一小段不需要延时的逻辑后立刻让人;sleep(0) 在 SCoop 里会让任务进入睡眠队列,唤醒时机由调度器决定,周期反而被拉长。如果你只是想"给别人一点时间"就用 yield,想"ms 毫秒后再来"就用 sleep。
sleep 的时间长度也要选得合理。我一般这样定:按键扫描这种需要快速响应的任务,用 sleep(5) 或 sleep(10);LED 闪烁或指示灯这类视觉任务,用 100ms 以上;传感器采样按信号变化速度,温度湿度慢变量 200ms 没问题,IMU 快变量可能要到 10ms。时间片设得太短不一定是好事,sleep(1) 的调度开销占比大,而且 millis() 本身的精度大约 1 到 2ms,实际结果并不稳定。设得太长又会迟滞响应,后面可以用串口时间戳验证,我一般先按需求定一个周期上限,再往小调。
4. 任务间同步实践:Signal 事件通知替代全局变量轮询
4.1 Signal 与 wait:按键事件驱动 LED 翻转
当两个任务需要协作时,最简单的方式是共享一个全局变量,一个写一个读。但这种裸轮询在协作式调度下有隐患,后面会讲。SCoop 自带的 Signal 原语就是为"等一个事件再继续"设计的。下面是一个按键控制 LED 翻转的例子。
#include "SCoop.h" const int BTN_PIN = 2; const int LED_PIN = 13; DEFINE_SIGNAL(btnSignal); DEFINE_TASK(buttonScan) { while (1) { if (digitalRead(BTN_PIN) == LOW) { btnSignal.signal(); sleep(20); // 简单防抖,防止重复触发 } sleep(5); } } DEFINE_TASK(ledToggle) { while (1) { btnSignal.wait(); digitalWrite(LED_PIN, !digitalRead(LED_PIN)); } } void setup() { pinMode(BTN_PIN, INPUT_PULLUP); pinMode(LED_PIN, OUTPUT); mySCoop.start(); } void loop() { mySCoop.loop(); }DEFINE_SIGNAL(btnSignal)定义了一个信号量。btnSignal.signal()把信号置位,btnSignal.wait()让当前任务挂起,直到信号被置位后再继续。这个 wait 和 sleep 一样也会让出 CPU,不会拖着整个调度器不跑。信号被消费后自动复位,所以 ledToggle 不会因为一个信号而翻转多次。
注意 signal 与 wait 的时序关系:如果 buttonScan 在 ledToggle 等待时连续发了两次 signal,第二次信号可能被丢弃,因为信号本质是"事件通知"而不是计数器。代码里我用了 20ms 防抖,又把扫描周期定为 5ms,基本能避免一次按键产生多次信号。实际按键按下时间通常大于 50ms,这个组合够用。
这个模式能泛化到很多场景:传感器数据准备好了通知处理任务,串口收到完整帧后通知解析任务,状态机转移后通知显示任务。Signal 把任务之间的耦合从"不断轮询标志位"变成"事件驱动",代码意图清楚得多,排错也容易。
4.2 共享变量的正确打开方式
并不是所有同步都需要 Signal。如果两个任务间要传递的是一个数值,比如传感器采到的当前温度,用 Signal 反而绕。常见做法是定义一个带 volatile 修饰的全局变量,生产任务写,消费任务读,单写单读场景下简单可靠。volatile 是必须的,它告诉编译器这个变量可能被外部修改,别把它优化进寄存器。
volatile int currentTemp = 0; // 采样任务里写 currentTemp = readSensor(); // 上报任务里读 int t = currentTemp;我几乎不会让两个任务同时写同一个变量。如果确实需要多任务共享并修改同一个数据,协作式调度下也不能完全保证安全,因为任务可能在执行到一半时被 sleep 打断,另一个任务又开始修改,回到原任务时数据已经不是原来的样子。这种情况要关中断保护,SCoop 没有为这种情况提供现成锁,实际项目里我会尽量避免多写场景,而是把变量归一个任务所有,其他任务通过 Signal 请求"拿到最新值"。
4.3 为什么不要在任务里 while(!flag)
有不少同学会在任务里写while(!flag) { yield(); }来等一个标志。这在协作式调度下确实能转起来,但缺点明显:一是如果另一个任务很久都不修改 flag,当前任务会频繁被调度选中,白占 CPU;二是多任务多标志场景下,这种等待容易喂出死锁,任务 A 等 B 的标志,任务 B 等 A 的标志,两个任务同时卡死在等待循环里。用 Signal 的好处是不轮询、不空转,语义也清晰,维护代码的人更容易看懂"这个任务在等一个事件"。
如果你需要"最多等 50ms,等不到就干别的"这种超时逻辑,SCoop 的标准 Signal 没有超时参数。我一般会在等待任务里加一个计数变量,配合扫描任务判断超时,或者直接用 sleep 轮询一个标志位。这算是 Signal 的一个边界,知道它在哪里,就不会写出无限等待的任务。
5. SCoop 使用避坑手册:五个我踩过的调度器深坑
5.1 三个让整个系统"躺平"的写法
第一个坑:任务里用 delay()。现象是某个任务调用 delay(1000) 后,其他灯不闪了,串口也不输出了,感觉芯片死机。原因其实很简单:delay 是阻塞式等待,它把 CPU 独占在中断和循环里,根本不回到 mySCoop.loop(),调度器失去驱动源。解决方法是把任务里的原生 delay 全部替换成 SCoop 的 sleep。这个坑我每次帮人看代码都能碰到,大多是从网上复制的例程忘了改。
第二个坑:在硬件中断 ISR 里调用 signal 或 sleep。现象是偶尔触发一次按键,系统就重启或者调度顺序全乱。原因是 SCoop 的调度器不是中断安全的,ISR 里调用库函数会破坏任务现场和栈数据。解决方法是 ISR 里只置一个 volatile 标志位,回到任务里再根据标志位调用 signal。很多实时性需求根本不需要在中断里发信号,任务轮询的延迟只有几毫秒。
第三个坑:在 mySCoop.loop() 前后放入重量级阻塞逻辑。现象是所有任务整体变慢,时序漂移,看起来像单个任务里写了 delay。原因是 loop() 是调度器的唯一驱动源,它被长时间占用,所有任务都断粮。解决方法是把阻塞逻辑也拆成独立任务,或者在阻塞代码里定期调用一次 mySCoop.loop()。我见过最夸张的情况,有人在 loop() 里放了个读写 SD 卡的函数,整个系统响应延迟到了秒级。
5.2 两个和内存、时序相关的隐性故障
第四个坑:任务体内定义很大的局部数组。现象是程序刚上电能跑,几分钟或几小时后随机死机,看门狗复位后又能跑。原因很可能是任务私有栈默认大小不够,数组越界踩坏了相邻任务或调度器的数据。解决方法是把大数组改成 static 放到全局区,或者调大任务栈配置。我一般先查代码里有没有超过 64 字节的局部数组,有就优先移出去,再考虑调栈。
// 不推荐:大数组在任务栈里 DEFINE_TASK(badTask) { int buffer[128]; // ... } // 推荐:数组放全局区 static int buffer[128]; DEFINE_TASK(goodTask) { // 使用 buffer }这里解释一下为什么不推荐第一种写法:AVR 上的 int 占 2 字节,128 个元素就是 256 字节,很可能已经超出默认任务栈的一半。而 SCoop 的任务栈是从堆里分配的,数组越界不会像电脑上那样立刻报错,表现就是运行一段时间后随机崩。把数组挪到全局区后,任务栈只看指针,占用小得多。
第五个坑:对 sleep 的精度预期过高。现象是用 sleep(10) 做高频采样,示波器或串口看到实际间隔是 10ms 到 16ms 乱跳。原因是 sleep 基于 millis() 实现,底层定时器分频精度有限,加上任务排队的随机延迟,实际唤醒间隔并不精确。解决方法是把任务改成绝对时间计算,每次醒来读 millis() 并计算下一次唤醒应该发生在什么时刻,而不是只依赖 sleep 的周期。第六节的采样框架就是这个思路。
注意:如果你同时遇到两个坑,先处理栈溢出再处理精度。栈溢出会导致随机死机,所有排查手段都会被它带偏。把局部大数组清出任务栈后,再去验证时间精度。
6. 进阶技巧:用 SCoop 搭建多路传感器采样与任务切换验证
6.1 一个可复用的采样任务状态机
把采样逻辑从主循环里抽出来,做成一个按需切换采样频率的任务,是 SCoop 最值得用的场景之一。下面这个例子定义了两个传感器,平时每 200ms 采样一次,一旦某个读数超过阈值就切换到 20ms 快速采样。
#include "SCoop.h" const int SENSOR1 = A0; const int SENSOR2 = A1; volatile int sensor1Value = 0; volatile int sensor2Value = 0; DEFINE_TASK(sensorSampler) { enum SampleMode { SLOW, FAST }; SampleMode mode = SLOW; unsigned long lastSample = 0; while (1) { unsigned long now = millis(); int interval = (mode == FAST) ? 20 : 200; if (now - lastSample >= interval) { lastSample = now; sensor1Value = analogRead(SENSOR1); sensor2Value = analogRead(SENSOR2); if (sensor1Value > 800 || sensor2Value > 800) { mode = FAST; } else { mode = SLOW; } } sleep(5); // 让出 CPU,同时保证 20ms 的快速采样不被 sleep 阻塞 } }这个状态机的核心是绝对时间判断。lastSample 记录上一次真正采样的时间,now - lastSample 超过当前模式的间隔才触发采样。sleep(5) 只是用来让出 CPU,并不直接控制采样周期,所以 20ms 快速模式下也能保证时间分辨率。两个传感器数值用 volatile 修饰,供上报任务读取。
使用时可以再加一个上报任务,读取这两个共享变量,按自己的节奏做串口打印或数据上传。这种结构把采样频率控制、数据读取和上报解耦,后续想改采样策略只需要动 sensorSampler 一个任务。两个任务一个写一个读,AVR 上 int 的读写是原子的,这里不需要额外加锁。
6.2 用串口时间戳验证任务切换是否健康
我从第一次用 SCoop 就养成一个习惯:写完任务先不接外设,直接加一段串口时间戳打印,验证任务是否在交替执行。
#include "SCoop.h" DEFINE_TASK(taskA) { while (1) { Serial.print("A:"); Serial.println(micros()); sleep(50); } } DEFINE_TASK(taskB) { while (1) { Serial.print("B:"); Serial.println(micros()); sleep(50); } } void setup() { Serial.begin(115200); mySCoop.start(); } void loop() { mySCoop.loop(); }打开串口监视器,看到 A 和 B 交替出现,时间戳差值大致稳定在 50ms 上下,说明两个任务都在正常睡眠和唤醒。如果出现连续两个 A 或者连续两个 B,先检查另一个任务的循环里有没有 delay 或空转循环。注意这里用 micros() 打印的是每次任务真正运行的时机,比串口自带的毫秒时间戳更直接。
这个验证方法我一直留到项目最后,因为它能第一时间暴露哪个任务在"吃独食"。比如接上 SD 卡后突然连读 B 两次,那就是 SD 卡代码阻塞了调度器。从那以后我每次用 SCoop 写新任务,都会先跑一遍时间戳检查再往上加业务逻辑,确认所有任务都正常让位才接传感器和显示屏。这个习惯帮我省下很多排队查 bug 的时间,希望帮到你。
本文还有配套的精品资源,点击获取