
简介SCoop-master.zip 是一份面向 Arduino 开发者的多线程库源码包用于在单片机上实现并发任务调度缓解传统顺序执行时多任务响应迟缓的问题适合已掌握基础 Arduino 编程、希望提升程序并行处理能力的中级开发者。压缩包共 58 个文件约 726KB其中 29 个 ino 示例覆盖多任务闪烁、定时器与性能测试等典型场景7 个 h 头文件与 7 个 cpp 源文件构成库的核心实现另有 12 个 txt 说明与变更日志、1 份 PDF 版用户指南可按模块查阅。已有 735 人学习下载。读者能获得可直接复用的调度器、TimerUp/TimerDown 定时组件与 IOFilter 滤波模块配合示例还可理解线程同步、资源分配与优先级安排的实践方式并借助版本日志与用户手册排查移植中的常见问题从而快速把多线程能力应用到自己的 Arduino 项目中。1. 一块板子同时干三件事SCoop 在 Arduino 上解决的是什么问题一个 UNO 上同时要扫按键、刷数码管、每 500ms 往串口报一次数据最先写出来的版本一定是几个delay摞在一起——按键按下去没反应数码管还闪一下暗一下。SCoop 这个压缩包就是冲这个场景来的一套跑在 Arduino 上的协作式多任务调度用sleep()把阻塞的delay拆成可让出的时间片让几个看起来「同时」跑的任务轮流占用那唯一一个核心。它适合内存只有 2KB、任务以轮询 IO 为主、实时性要求停在毫秒级的板子反过来需要硬实时、任务里有大段阻塞、或者任务数量上到两位数的场合它给不了你要的确定性。后面几章按「先看懂调度模型、再跑通例程、最后把阻塞代码改造掉」的顺序往下拆。2. 协作式调度的底层逻辑不用 RTOS 也能跑多任务的三个前提2.1 抢占式与协作式在 2KB RAM 上的分水岭先把一个容易被含糊过去的问题说清楚多任务不等于多核AVR 从头到尾只有一个执行流所谓「同时跑三个任务」本质是把三个函数的执行时间切成碎片交替塞进同一条时间线。真正的差别在于谁来切。抢占式调度FreeRTOS 那一类靠定时器中断强行打断当前任务把寄存器压进任务自己的栈里换另一个任务的栈指针继续跑。代价很直接每个任务一份独立栈512 字节起步是常态一个 UNO 只有 2048 字节 RAM扣掉全局变量和串口缓冲区撑死开三四个任务稍微多声明一个局部大数组就栈溢出现象是莫名其妙的复位或者变量被改。它在关键时刻能保证响应时间但对 8 位小板子来说RAM 是硬约束。协作式调度反过来没人打断你是你自己主动交出 CPU。所有任务共用一个栈任务的上下文不是寄存器快照而是「我下次该从哪一行继续」——具体到实现就是一个函数指针加几个状态标记。切一次的开销从几百个时钟周期降到几十个RAM 占用从「每任务 512 字节」降到「每任务几十字节」。代价同样明确一个任务只要不让出其他任务全部饿死。这是这套模型的全部风险和全部用法后面所有坑都从这一条派生出来。判断你的项目适不适合 SCoop问自己一句话就够了——我能不能保证每个任务在几毫秒内一定走到让出点。能就合适不能就该换抢占式或者干脆用状态机手写。2.2 任务对象模型函数、周期、状态三元组SCoop 把「任务」抽象成一个带周期属性的可重入块。拆开看就三个要素函数体任务的正文本质是一个只在让出点处切走的循环体。唤醒时刻一个绝对时间戳单位毫秒记录「下一次该轮到我了」。状态位标记这个任务是首次运行、运行中还是被暂停。调度器mySCoop维护一张任务表主循环里调一次yield()它就沿着表扫一遍把当前millis()和每个任务的唤醒时刻比一下到点的执行一次没到点的跳过。扫描是 O(n) 的n 是你的任务数三五个任务的开销小到可以忽略。sleep(ms)做的事情极其朴素把当前任务的唤醒时刻设成millis() ms然后立刻返回调度器。注意它不是把 CPU 挂起而是让当前任务退出本轮把时间让给表里其他到点的任务。所以sleep(0)的语义是「我这一轮干完了下一轮马上还找我」等价于一次纯让出在需要紧凑轮询的时候常用。库里有两种声明任务的写法语义完全一样只是风格差异defineTaskLoop(name){ ... }直接写函数体最省事适合逻辑单一的小任务。defineTask(name)再补name::setup()和name::loop()相当于给任务配一个自己的初始化函数setup()在mySCoop.start()时统一调用一次适合需要初始化的外设任务。#include SCoop.h // 风格一宏直接展开成任务体适合简单轮询 defineTaskLoop(taskBlink) { digitalWrite(LED_BUILTIN, !digitalRead(LED_BUILTIN)); sleep(500); // 交出 CPU 500ms期间其他任务照常跑 } // 风格二带独立初始化适合需要配置外设的任务 defineTask(taskSensor) void taskSensor::setup() { Serial.begin(115200); // 只在该任务首次启动前执行一次 pinMode(A0, INPUT); } void taskSensor::loop() { int v analogRead(A0); Serial.println(v); sleep(1000); }这段代码里两个任务共用同一个栈taskSensor::setup()的调用时机是mySCoop.start()内部不是全局setup()这点后面第 5 章还会提到——把外设初始化写错位置是新手最常踩的第一个坑。2.3 让出点放在哪把 delay 逐条翻译成 sleep拿到一份阻塞式代码改成 SCoop 任务的机械动作只有一条所有delay(x)替换成sleep(x)所有长时间的while等待拆成带sleep()的状态机。听起来简单实际改动量取决于原代码的等待有多深。一段典型的阻塞代码长这样void loop() { digitalWrite(TRIG, LOW); delayMicroseconds(2); digitalWrite(TRIG, HIGH); delayMicroseconds(10); digitalWrite(TRIG, LOW); long d pulseIn(ECHO, HIGH); // 最长阻塞 1 秒默认超时 Serial.println(d); delay(200); }pulseIn是这里最要命的一行默认超时 1 秒一旦回波丢失整个调度器被焊死一秒。改造成任务后要这么写defineTaskLoop(taskSonar) { digitalWrite(TRIG, LOW); delayMicroseconds(2); digitalWrite(TRIG, HIGH); delayMicroseconds(10); digitalWrite(TRIG, LOW); // 用非阻塞方式代替 pulseIn限时轮询 主动让出 unsigned long t0 micros(); long dur -1; while (digitalRead(ECHO) LOW) { if (micros() - t0 30000UL) break; // 30ms 超时别等满 1 秒 } t0 micros(); while (digitalRead(ECHO) HIGH) { if (micros() - t0 30000UL) break; yield(); // 关键轮询里必须让出 } dur micros() - t0; Serial.println(dur / 58); sleep(200); }逻辑说明第一个while等回波拉高第二个while量高电平宽度。两处都加了超时上限把最坏阻塞从 1 秒压到 30 毫秒第二个循环里插了yield()微秒级轮询里让出一次的开销在几十微秒量级对测量精度的影响可以接受换来的是这段时间里其他任务还能跑。参数说明超时阈值 30000 微秒对应约 5 米的量程上限声速换算常见系数 58 微秒/厘米如果你的量程只要 2 米直接把它改成 12000超时越短任务被拖住的时间越短。sleep(200)是任务周期注意它是「上一轮结束后再等 200ms」不是固定 200ms 周期任务体本身耗时越长实际周期越接近 200ms 加执行时间——这个偏差在第 6 章会用时间戳量出来。选型理由再说透一点为什么不干脆上 FreeRTOS如果你的板子是 ESP32 这类 RAM 充足的平台FreeRTOS 确实是更稳的选择SCoop 的价值区间明确落在 AVR 和早期 8 位平台上——RAM 论字节算、Flash 只有 32KB、还要塞下 LCD 库和传感器库。这种板子上协作式省下来的那 1.5KB 栈空间往往就是「能编译进去」和「编译进去就复位」的差别。3. 从 zip 到可编译库目录、安装路径与第一个双任务骨架3.1 解压后先认这几个文件SCoop-master.zip解压出来通常是一个带-master后缀的目录里面是标准 Arduino 库结构。先别急着往 IDE 里拖花一分钟把这几个文件认清楚后面的编译错误基本都能对上号。文件 / 目录作用需要动的可能性SCoop.h主头文件任务宏、调度器类的声明都在这里一般不改出问题时用来查宏的真实定义SCoop.cpp调度器实现yield()、任务表扫描逻辑一般不改library.properties库元信息供新版 IDE 和 arduino-cli 识别目录名不合法时可能要改examples/官方例程通常含基础任务、定时器、事件几组建议先全编译一遍keywords.txtIDE 语法高亮关键字表不影响编译需要特别注意的是目录名。Arduino 的库规范要求文件夹名不能带连字符、空格和点号SCoop-master里的-在部分版本的工具链上会导致「库无法识别」或者「找不到头文件」。稳妥做法是解压后立刻重命名成SCoop别偷懒。提示不同发布批次的头文件宏名可能略有差异比如任务声明宏在某些版本里带了额外参数。编译报「宏参数数量不匹配」时直接打开SCoop.h搜宏定义比在网上翻帖子快得多。3.2 装进 libraries 并做一次空编译验证装库有两条路手工拷贝和命令行编译。手工拷贝适合只用 IDE 的场合命令行适合需要批量验证多个例程。# 1. 解压并去掉目录名里的非法字符 unzip SCoop-master.zip mv SCoop-master SCoop # 2. 找到 Arduino 的库目录Linux/macOS 通常是下面这个路径 # Windows 一般是 文档/Arduino/libraries mkdir -p ~/Arduino/libraries cp -r SCoop ~/Arduino/libraries/ # 3. 先只验证库能不能被识别不急着编译例程 arduino-cli lib list | grep -i scoop # 4. 编译 examples 下的第一个例程做冒烟测试 arduino-cli compile --fqbn arduino:avr:uno \ ~/Arduino/libraries/SCoop/examples/第一个例程目录名命令说明--fqbn arduino:avr:uno指定目标板如果你用的是 Nano 就换成arduino:avr:nano注意 Nano 老版 bootloader 还要加--board-options cpuatmega328old否则串口能烧进去但程序不跑这个现象很容易被误判成库的问题。第 3 步的lib list只是确认目录被索引到不代表能编译真正的验证在第 4 步——只要例程能过编译说明路径、目录名、头文件引用这三关都过了。编译通过但下载后没反应先排查两件事一是板型和实际硬件是否一致二是例程里用的引脚在你的板子上是否存在。UNO 的LED_BUILTIN是 13某些兼容板把它接到别的脚上例程里的闪烁任务看起来「没跑」其实是灯根本没接在那。3.3 双任务骨架LED 闪烁与串口心跳例程跑通之后直接上自己的骨架。下面这份是两个任务的完整可编译版本一个做 500ms 闪烁一个做 1 秒串口心跳两个任务频率不同、互不阻塞。#include SCoop.h const uint8_t LED_PIN 13; // 任务一状态灯500ms 翻转一次 defineTaskLoop(taskBlink) { digitalWrite(LED_PIN, !digitalRead(LED_PIN)); sleep(500); } // 任务二心跳上报1 秒一次带运行时长 defineTaskLoop(taskHeartbeat) { static unsigned long lastReport 0; unsigned long now millis(); Serial.print(F(uptime)); Serial.print(now / 1000); Serial.print(F(s heap_free)); Serial.println(freeRam()); // 观察 RAM 余量防慢性泄漏 sleep(1000); } void setup() { pinMode(LED_PIN, OUTPUT); Serial.begin(115200); mySCoop.start(); // 启动调度器内部会调用各任务的 setup() } void loop() { yield(); // 主循环只做一件事把 CPU 交给调度器 }逻辑说明mySCoop.start()做两件事——把每个任务标记为就绪然后返回。真正干活的是loop()里的yield()它每被调用一次就扫一遍任务表。注意全局setup()里不要再写业务代码所有业务都进任务体否则那段代码只在开机时跑一次很容易误以为任务没启动。freeRam()不是标准函数AVR 上常见的实现方式是拿栈指针和堆顶相减用来盯住 RAM 余量。加这一行的理由是协作式调度共用一个栈任务里一旦出现大数组表现不是编译报错而是运行一段时间后随机复位有个余量读数就好定位了。参数说明sleep(500)的单位是毫秒传 0 表示本轮立即让出、下一轮马上回来传负数在部分版本上等价于无限挂起需要外部事件唤醒别拿它当延时用。两个任务的周期是各自独立的taskBlink的实际周期约等于 500ms 加上一轮扫描和 IO 的开销通常偏差在个位数毫秒用示波器量 P13 能看出来。3.4 sleep 精度、任务周期与 RAM 预算的参数表把几个会反复调到的参数集中列一下改代码的时候直接对照比翻源码快。参数典型取值影响调整建议sleep()周期10 ~ 1000 ms决定任务响应延迟和 CPU 占用按「人眼可感知」定灯 500ms、传感器 100~1000ms任务数量3 ~ 8 个每轮扫描 O(n)每个任务占几十字节状态超过 8 个先考虑合并别硬堆单次任务执行时长建议 5 ms超过就拖累其他任务的周期用micros()打点量超了就拆分栈余量建议保留 300 字节不够时随机复位、变量被篡改加freeRam()心跳打印长期观察串口波特率115200低于 9600 时一次打印阻塞十几毫秒调试期可降速上线前调高或关日志关于扫描开销给个可复现的估算方式在loop()里空跑yield()十万次记下耗时除以次数就是单次扫描的均值。三个任务的场景下这个值通常在十几微秒量级也就是说任务数翻倍带来的额外开销远小于你在任务体里多写一个Serial.print的代价。所以优化顺序永远是先砍任务体里的阻塞再考虑减任务数。4. 任务间同步事件、软定时器与共享变量的读写边界4.1 SCoopEvent用等待代替轮询标志位多任务一上来就会遇到「A 任务采到数据B 任务负责处理」的协作需求。最直觉的写法是全局标志位加轮询volatile bool dataReady false; defineTaskLoop(taskProducer) { // ... 采集 dataReady true; sleep(100); } defineTaskLoop(taskConsumer) { if (dataReady) { // 每轮都问一遍白烧 CPU dataReady false; // ... 处理 } sleep(10); }这段能跑但有两个毛病消费任务每 10ms 醒一次空转纯粹浪费更麻烦的是时序——生产和消费的周期一旦对上可能出现「生产者连续写两次、消费者只处理一次」的丢帧。库里的SCoopEvent就是为这个场景准备的语义接近二值信号量生产者set()消费者wait()wait()内部会把当前任务挂起直到事件被置位才继续期间完全不占扫描时间。#include SCoop.h SCoopEvent evtData; // 事件对象跨任务共享 volatile int sharedValue 0; defineTaskLoop(taskProducer) { sharedValue analogRead(A0); // 先写数据 evtData.set(); // 再置事件顺序不能反 sleep(100); } defineTaskLoop(taskConsumer) { evtData.wait(); // 挂起直到有事件不占 CPU int v sharedValue; // 醒来后立刻取走 Serial.println(v); // 不再 sleep下一轮直接回到 wait() }逻辑说明set()和wait()的调用顺序是这段代码的关键。必须先写数据再置事件反过来的话消费者可能被唤醒却读到上一轮的旧值。消费者任务里不写sleep()是刻意的——它的节奏完全由生产者驱动自己再加延时只会累积滞后。参数说明这类事件对象一般是二值的连续set()两次只算一次不排队。如果你的场景要求「每次生产都必须被消费一次」需要的是计数信号量或者一个小的环形缓冲队列别硬用二值事件凑丢数据的 bug 会非常难查。4.2 defineTimerRun不用 millis 手写时间差的周期任务手写周期任务的标准套路是记一个lastTime每轮算millis() - lastTime interval。这套写法没错但代码里散落五六处之后可读性会迅速崩掉。SCoop 提供了宏形式的软定时器把「周期」这件事从业务代码里抽出去。#include SCoop.h volatile uint16_t adcValue 0; // 每 200ms 触发一次宏展开后由调度器统一计时 defineTimerRun(timerSample, 200) { adcValue analogRead(A0); } defineTaskLoop(taskReport) { Serial.println(adcValue); sleep(1000); } void setup() { Serial.begin(115200); mySCoop.start(); } void loop() { yield(); }逻辑说明timerSample是定时器名200是周期毫秒数花括号里是到点执行的代码。它和defineTaskLoop的区别在于任务体执行完由自己决定下一次什么时候醒定时器则是由调度器按固定周期反复触发业务代码里不需要写sleep()。参数说明软定时器的精度受限于扫描周期实际触发时刻会有一个「扫描粒度」量级的抖动任务多、任务体长的时候抖动会变大。它适合采样、刷新显示这类对相位不敏感的工作如果你要做的是精确的脉冲输出或者波特率生成还是得用硬件定时器软定时器顶不上。一个常见误用是往定时器体里塞耗时逻辑。定时器体执行期间调度器是被占住的体越长其他任务的周期被拉得越歪。经验阈值和任务体一样控制在几毫秒以内超了就改成「定时器只置标志任务里干重活」。4.3 共享变量与临界区关中断的那几行代码协作式调度有个天然优势任务之间的切换只发生在让出点所以两个任务之间不存在「执行到一半被打断」的问题。这意味着普通任务之间共享一个int不加任何保护也是安全的——只要这个变量不在中断里被改。一旦涉及到中断结论就反过来了。中断可以在任意指令之间发生包括一个 16 位变量正在被写的中间时刻主任务读到的就是一个高低字节拼接错乱的值。volatile uint32_t pulseCount 0; // 在中断里累加必须 volatile // 中断服务函数只做最轻的计数 void onPulse() { pulseCount; } // 任务里安全读取 32 位计数值 uint32_t readPulseCount() { uint32_t snapshot; noInterrupts(); // 进入临界区 snapshot pulseCount; // 拷贝是原子的在关中断保护下 interrupts(); // 退出临界区 return snapshot; }逻辑说明volatile只保证编译器每次都从内存读、不把它缓存进寄存器不保证读写的原子性。32 位变量在 8 位 AVR 上要分四次读写中间被中断插进来就会撕裂。关中断包住拷贝动作是这套平台上最省事的临界区写法。参数说明临界区里的代码越短越好一般就一行赋值。关中断期间millis()的计数会丢如果临界区长达几毫秒系统时间的漂移就能被测出来。经验值是把临界区控制在几十微秒内超过这个量级就得重新设计数据结构比如换成「中断只置标志、任务里处理」的模式。5. 避坑与排查五个把调度器拖垮的写法5.1 坑一任务体里直接写 delay()现象加了第二个任务之后第一个任务的闪烁频率明显变慢串口输出出现整段整段的空档示波器上看到某一路 PWM 占空比周期性抽搐。原因delay()是忙等它在等的时候不会调用yield()整个调度器停摆。很多驱动库内部也埋着delay()比如某些型号的温湿度传感器读取、某些 OLED 的刷新函数你代码里干干净净库函数里照样把时间线焊死几十毫秒。解决全局搜一遍delay(包括第三方库的源文件。能找到替代的换成sleep()不能改库的把调用拆成「发命令—让出—读结果」两段中间用状态机隔开。实在改不动的老库只能把它挪到频率最低的那个任务里并且接受它对其他任务的周期污染。5.2 坑二while 等外设应答等于把整条时间线焊死现象偶尔整机卡住一两秒按键完全无响应之后又自己恢复。串口日志显示卡住期间没有任何输出。原因某段代码写了while (!deviceReady())或者while (Serial.available() 0)这类无超时等待。外设没应答的时候这个循环永远不退出其他任务一次都轮不到。解决所有等待循环强制加超时上限这是没有商量余地的硬规则。写法上推荐统一成一个模式——记起始时间循环里判断超时超时就走错误分支而不是继续等。等待次数超过十几轮的循环体里加一次yield()让别的任务有机会跑。上面第 2 章那个超声波改造就是这套模式的样板。5.3 坑三串口打印太多任务周期被撑爆现象调试信息一开任务周期从 100ms 变成 180ms事件偶尔丢失关掉打印立刻恢复正常。原因115200 波特率下传一个字符大约 87 微秒一行 40 字符的日志就是 3.5 毫秒而且Serial.print在发送缓冲区满时是阻塞的。一个任务里连打五行十几毫秒就没了其他任务的周期全部跟着漂。解决分两步。一是把日志分级上线版本只留错误级二是把多条打印合并成一行用F()宏把字符串常量放进 Flash 省 RAM用Serial.print拼接而不是多次调用。真要高频打点改用「任务里存环形缓冲、低频任务统一输出」的方式把阻塞集中到一个不影响时序的地方。5.4 坑四sleep 周期小于任务执行时间现象任务看上去「跑飞了」实际执行频率远低于设定值而且各个任务的节奏互相牵扯改一个参数别的也跟着变。原因sleep(n)的语义是「本轮结束后等 n 毫秒」不是「每 n 毫秒执行一次」。如果任务体本身要跑 30ms而sleep只给了 10ms实际周期就是 40ms 左右并且任务体耗时一旦波动周期跟着抖。多个任务都这么写累积误差会让人误以为是调度器不准。解决先量后调。在任务体首尾各打一个micros()把耗时打到串口上看清楚真实开销再定sleep值。周期要求严格的场合改成「记录上次执行时刻、按绝对时间算下一次」的写法让误差不累积。另外sleep(0)千万别用在耗时任务上那等于把 CPU 全占住。5.5 坑五在中断服务函数里调调度 API现象随机死机、复位或者某个任务再也不跑了加打印也看不出规律。原因中断服务函数运行在自己的上下文里此时调度器的任务表可能正处于被修改的中间状态。在中断里调sleep()、yield()或者事件对象的set()等于在别人改一半的数据结构上再插一手行为完全不可预期。解决中断里只做最小动作——置一个volatile标志、往环形缓冲写一个字节、给计数器加一。把「响应」这件事交给一个专门的任务让它轮询标志位。这条规则和裸机编程里的「中断要短」是同一件事只是多任务环境下违反它的后果更隐蔽。写完中断服务函数习惯性检查一遍里面有没有出现任何 SCoop 的符号。6. 进阶验证把阻塞驱动改成状态机并用 micros() 量出调度抖动改造的最后一步是把驱动层也变成可让出的。以一个需要 20ms 转换时间的传感器为例原来是一句「发命令、等 20ms、读结果」改造成三段式状态机IDLE状态发命令并记时间戳WAIT状态只判断时间是否到点、到点前sleep(2)READ状态取结果并回到IDLE。整个过程中最长的阻塞从 20ms 降到 2ms其他任务的周期立刻稳下来。改造完必须量一遍别凭感觉判断「应该没问题了」。测量方法很土但有效在目标任务的首尾各取一次micros()把差值存进一个静态数组攒够 100 个样本后打印最大最小值。defineTaskLoop(taskProbe) { static uint32_t worst 0; uint32_t t0 micros(); // ... 被测任务体 uint32_t dt micros() - t0; if (dt worst) worst dt; if (counter % 100 0) { Serial.print(F(worst_us)); Serial.println(worst); worst 0; } sleep(20); }同一块 UNO 上改造前后的实测差异大致是这样的量级场景平均周期偏差最大单次阻塞现象改造前含 pulseIn30 ~ 80 ms1000 ms超时按键偶发无响应改造后限时轮询 yield2 ~ 5 ms30 ms按键跟手灯不抖再叠加串口日志8 ~ 15 ms40 ms周期明显漂移表里的数字不是标准答案不同板子、不同任务数差别很大但趋势是稳定的抖动的主要来源永远是最长的那一次不让出而不是调度器本身的开销。所以优化的着力点非常明确量出最大阻塞在哪就去拆哪一段。血泪教训是早期我图省事直接拿例程改把传感器读取原样丢进任务里跑了一晚上没出事装机之后每隔几小时就卡一次。后来加了这个worst_us打点才发现卡顿来自一个我没注意的库函数内部的delay(50)。从那以后我每次接新库进项目第一件事是搜它源码里的delay和while等待第二件事是把worst_us打点加进骨架里宁可多占几十字节 RAM也不要在现场靠猜。希望帮到你。本文还有配套的精品资源点击获取