这几天和群里小V同学讨论一个项目时,偶然发现了 ProtoThreads,简称PT,用其来实现arduino的多线程控制很方便。这里摘录几篇介绍的文章。
一、以下转自http://www.arduino.cn/thread-5833-1-1.html
1楼、背景——
想象一个这样的情况,请不要在意这样奇怪的情景——
一个四位的数码管,由于要“同时”显示,因此每5ms刷新一次。(求别说MAX7219之类的IC……)
同时要处理一个矩阵键盘,设计是每10ms扫描一行,同时还有去抖处理,需要在检测到按键后再延时40ms检查一次。
检测到有效按键,在数码管上显示某个值,比如1234。
同时还能从串口接收数据,如果有数据收到,马上在数码管上显示某个值,比如5678,停留1s,期间按矩阵键盘不会有任何反应。
程序怎么写?晕了没?
比如说,去抖的时候,如果直接用delay(40)的话,那数码管的5ms刷新怎么办?串口收到的数据办?
基于这种超烦的(划掉)多任务处理,为了编程方便,让我们祭出嵌入式操作系统这一个神器!!!
哎!别走!!!妹子等我说完,我不打算讲高深理论哎!!(旁白:反正你也不是这个专业的,也讲不出)
嵌入式操作系统是用来处理这类超烦的(划掉)多任务处理的情况,常见的有uCos、RT-Thread等等,有兴趣的可以去看看。
但是Arduino,编译一个文件出来,如果你有留意的话,体积很大,而arduino本身的内存就不多,再移植一个就Orz了。所以,上面说的,不能用!!!哎!别走啊喂!!!
鉴于大家做些小作品,不需要用到如此高深的操作系统,只要简单地处理一下这些多任务的问题,所以,让我们祭出Adam Dunkels大神的ProtoThreads!
2、ProtoThreads与嵌入式操作系统简介
ProtoThreads是一个通过宏(#define)写出来的神奇的模拟多线程(理解成多任务先)的库,里面全是头文件,找不到.cpp等程序文件。它的核心利用了C语言switch语句的特性。说是嵌入式系统,但这其实还只算是一个调度器,所以,并不能说是一个完整的操作系统。
操作系统最核心的功能是:在等待某个事件发生的时候,比如说定时一段时间、有无按键、串口上有无数据等等,操作系统帮你将单片机从当前的任务中临时切换到另一个任务运行,直到指定事件发生了再回来接着运行,这样就是变相实现了多任务处理,节省了CPU时间,还极大地减少开发难度(我会说我学过嵌入式系统后就再也不想做流水线式的设计了吗?)。
ProtoThreads在较大程度上实现出操作系统的核心功能,而且,每个新建一个任务,只需额外增加16bit即两个字节的空间(引入我的定时器宏则为6个字节)。除了核心功能外,还增加了信号量、延时这两个功能(仅限于我提供的库),我大概想到消息队列、标志怎么写了,但是没空写(旁白:其实是懒吧?)。
但是!!有缺点!!我说过了,它利用了switch语句的特性,所以,我非常不建议在任务中使用switch这个语句,除非你能保证在你的switch语句内不会切换任务。其次,请慎用内部变量,尤其是循环变量,在切换任务时有一定的可能性发生不可预料的错误,要用,请一定加上static修饰。
讲完,下面讲讲怎么用。
3、任务准备工作
首先,每个任务都必须要有一个记录变量,记录任务的状态,便于返回。语句:
static struct pt xxx;
这个xxx你们自己取好了。下面的全部都是xxx。请一定要加上前面的static struct。
好,然后要初始化一个任务。在setup()函数里面用这个语句:
PT_INIT(&xxx);
这样就初始化成功啦~记得要加个&符号。(一定要哦~)
4、编写任务
每个任务在程序里面,就算是一个独立函数,在里面处理你要做的东西就可以啦。
函数格式如下:
static int 任务名(struct pt *pt)
{
PT_BEGIN(pt);
//你的处理过程
PT_END(pt);
}
如果不太懂ProtoThreads内部结构,就只改任务名好了,然后就这样写。PT_BEGIN(pt);一定要在开头,PT_END(pt);一定要在结尾,别漏,否则编译错误,运行到这里的时候这个任务就彻底结束了。
由于处理过程一般是循环处理的,所以处理过程一般是while(1){处理内容},作用就像loop(){}函数啦。
下面是一些等待某种信号所用到的宏:(部分,我没有全部讲,只挑了一些常用的,有兴趣的可以自己看源代码)
PT_WAIT_UNTIL(pt,条件);
这个语句的功能是,如果条件不成立,那么暂时退出当前任务,先处理别的任务,再回来看看。如果条件成立了,那么继续往下执行。第一个变量pt我个人建议别改啦。
PT_WAIT_WHILE(pt,条件);
作用和上面的相反,条件成立则切换任务,条件不成立则继续执行。
PT_WAIT_THREAD(pt,任务x名);
等到任务x完成了(任务x运行到PT_END了)才继续执行。x应为一次性任务而不是循环任务。
PT_RESTART(pt);
重启当前任务
PT_EXIT(pt);
退出并注销当前任务
PT_YIELD(pt);
把CPU让给别的任务用一下下,用完了我再继续用。
(下面的是定时器,该宏是我自己写的,用之前请在#include "pt.h" 的前面,前面啊!加上一句#define PT_USE_TIMER)
先说明一下,下面的定时不一定完全准确的,可能会有点点的误差,可能偏后。如果遇上了很烦的任务,有可能会使延时延后。但是正常情况下,直接用就好了。
如果要很精确的延时,请用delay语句或者计时器,但是,绝大多数情况下,绝大多数情况!绝大多数情况!请用下面的语句代替delay延时!这样才能把CPU让给别的任务使用。
PT_TIMER_DELAY(pt,延时毫秒数);
字面上的意思,不用多说了吧?最大值约为49.7天,估计没人会延时辣么久吧……
PT_TIMER_MICRODELAY(pt,延时微秒数);
字面上的意思,不用多说了吧?注意,最小精度与arduino的版本有关,与micros()有精度一致。
PT_TIMER_WAIT_TIMEOUT(pt,条件,毫秒数);
如果条件成立了,或者超时了,就继续运行,否则切换任务。
又说完啦~就这些~
5、信号量(Semaphore)
妹子求别走T_T
我不得不解释一个专业术语,因为这二货很有用(信号量:你才二)……
我不上定义了,直接用例子说,不是我发明的例子。
停车场。停车。停车场里面的车位是固定的,假设没有一辆车占多个车位的情况。在这种情况下,剩余车位数就是一个“信号量”了。进一量车,剩余车位数就减一;出一量车,剩余车位数就加一。如果剩余车位数为0,那么想进来的车就只能在外面淋雨了。
对,信号量也是这样用。得到了一个信号量,任务继续运行,得不到,一边呆着去。
具体有什么用呢?比如说,一楼写着的,监控串口的任务读到数据了,要占用数码管。那么我们命令一个信号量为土豪,土豪只有一个。每次矩阵键盘要显示数据,先申请一个土豪,写数据,然后释放土豪,如果申请不到就在墙角不断画圈圈。监控串口的任务一旦申请到土豪就劫持1s,不让矩阵键盘用。这样就可以达到要求啦~
下面是用法:
要用的话,请在#include "pt.h"前面加上一句 #define PT_USE_SEM
首先要创建一个信号量,这个一定是全局变量:
static struct pt_sem 信号量名;
接着请在setup()函数里面给它初始化:
PT_SEM_INIT(&信号量名,数量);
信号量名前面有个&,别忘了。数量就相当于停车场的总车位数。
然后要用啦。任务要停一辆车进去:
PT_SEM_WAIT(pt,&信号量名);
信号量名前面有个&,别忘了。一个语句只能停一辆车,土豪好多车就用多次。
任务要开一辆车出来:
PT_SEM_SIGNAL(pt,&信号量名);
信号量名前面有个&,别忘了。用一次出一辆。
当然,对于一个任务来说,信号量没上限,就是说,你可以在停车场内再开辟新的车位,不断用PT_SEM_SIGNAL()就好了。
其实信号量这货解决的问题中,比较出名的是生产者与消费者问题。简单地说,消费者要买,必须要生产者生产才能买到,没生产出来,消费者只能等。
6、例子
大家翻了那么久都累了伐……给个例子呗……
要求:板载LED以4秒一周期的速率闪烁。一旦收到串口发来的信息,不管信息量多少,快闪5次。用ProtoThreads写。
//首先启用定时器库和信号量库,下面会用到
#define PT_USE_TIMER
#define PT_USE_SEM
//引用库
#include "pt.h"
static struct pt thread1,thread2; //创建两个任务
static struct pt_sem sem_LED; //来个LED的信号量,同一时间只能一个任务占用
unsigned char i; //循环变量,写在这里其实不合适
void setup() {
//初始化13口和串口
pinMode(13,OUTPUT);
Serial.begin(115200);
PT_SEM_INIT(&sem_LED,1); //初始化信号量为1,即没人用
//初始化任务记录变量
PT_INIT(&thread1);
PT_INIT(&thread2);
}
//这是LED慢速闪烁的任务
static int thread1_entry(struct pt *pt)
{
PT_BEGIN(pt);
while (1)
{
PT_SEM_WAIT(pt,&sem_LED); //LED有在用吗?
//没有
digitalWrite(13,!digitalRead(13));
PT_TIMER_DELAY(pt,1000);//留一秒
PT_SEM_SIGNAL(pt,&sem_LED);//用完了。
PT_YIELD(pt); //看看别人要用么?
}
PT_END(pt);
}
//这是LED快速闪烁的任务,如果有串口消息,快速闪5次
static int thread2_entry(struct pt *pt)
{
PT_BEGIN(pt);
while (1)
{
PT_WAIT_UNTIL(pt, Serial.available()); //等到有串口消息再继续
PT_SEM_WAIT(pt,&sem_LED);//我要用LED啊!
//抢到使用权了,虐5次
for (i=0;i<5;i++)
{
digitalWrite(13,HIGH);
PT_TIMER_DELAY(pt,200);
digitalWrite(13,LOW);
PT_TIMER_DELAY(pt,200);
}
while (Serial.available())
Serial.read();
//清空串口数据,防止又来
PT_SEM_SIGNAL(pt,&sem_LED); //归还LED使用权了
}
PT_END(pt);
}
void loop() {
//依次调用即可
thread1_entry(&thread1);
thread2_entry(&thread2);
}
7、后记
版权问题:ProtoThreads的基本代码是由Adam Dunkels编写了,详情请看Readme.md,我个人只扩展了pt-timer.h这一个库。转载及使用ProtoThreads的基本代码请遵循Adam Dunkels的声明。转载及使用我写的pt-timer.h请署名“逍遥猪葛亮”。
欢迎转载、使用、修改等等,提一提“逍遥猪葛亮”我会很高兴的。百度Arduino吧里面的是我写的,所以不存在非法复制粘贴的问题吧?
二、以下转自http://www.geek-workshop.com/forum.php?mod=viewthread&tid=610&extra=page%3D1
先上一段简单的代码look look
#include <pt.h> static int counter1,counter2,state1=0,state2=0; static int protothread1(struct pt *pt) { PT_BEGIN(pt); while(1) { PT_WAIT_UNTIL(pt, counter1==1); digitalWrite(12,state1); state1=!state1; counter1=0; } PT_END(pt); } static int protothread2(struct pt *pt) { PT_BEGIN(pt); while(1) { PT_WAIT_UNTIL(pt, counter2==5); counter2=0; digitalWrite(13,state2); state2=!state2; } PT_END(pt); } static struct pt pt1, pt2; void setup() { pinMode(12,OUTPUT); pinMode(13,OUTPUT); PT_INIT(&pt1); PT_INIT(&pt2); } void loop () { protothread1(&pt1); protothread2(&pt2); delay(1000); counter1++; counter2++; }
此段代码演示了如何使用PT库来实现12、13脚led分别隔1秒、5秒闪烁,已经在arduino09上测试通过
sorry,无注释。。别急,这只是个演示
这篇文章会不断更新,分别讲述PT库的原理和应用
让大家能开发出更复杂的程序
好介绍开始了~
Protothread是专为资源有限的系统设计的一种耗费资源特别少并且不使用堆栈的线程模型,其特点是:
◆ 以纯C语言实现,无硬件依赖性;
◆ 极少的资源需求,每个Protothread仅需要2个额外的字节;
◆ 可以用于有操作系统或无操作系统的场合;
◆ 支持阻塞操作且没有栈的切换。
使用Protothread实现多任务的最主要的好处在于它的轻量级。每个Protothread不需要拥有自已的堆栈,所有的Protothread 共享同一个堆栈空间,这一点对于RAM资源有限的系统尤为有利。相对于操作系统下的多任务而言,每个任务都有自已的堆栈空间,这将消耗大量的RAM资源, 而每个Protothread仅使用一个整型值保存当前状态。
咱们来结合一个最简单的例子来理解ProtoThreads的原理吧,就拿上面的闪烁灯代码来说
#include <pt.h>//ProtoThreads必须包含的头文件 static int counter1,counter2,state1=0,state2=0; //counter为定时计数器,state为每个灯的状态 static int protothread1(struct pt *pt) //线程1,控制灯1 { PT_BEGIN(pt); //线程开始 while(1) //每个线程都不会死 { PT_WAIT_UNTIL(pt, counter1==1); //如果时间满了1秒,则继续执行,否则记录运行点,退出线程1 digitalWrite(12,state1); state1=!state1;//灯状态反转 counter1=0; //计数器置零 } PT_END(pt); //线程结束 } static int protothread2(struct pt *pt) //线程2,控制灯2 { PT_BEGIN(pt); //线程开始 while(1) { //每个线程都不会死 PT_WAIT_UNTIL(pt, counter2==5); //如果时间满了5秒,则继续执行,否则记录运行点,退出线程2 counter2=0; //计数清零 digitalWrite(13,state2); state2=!state2; //灯状态反转 } PT_END(pt); //线程结束 } static struct pt pt1, pt2; void setup() { pinMode(12,OUTPUT); pinMode(13,OUTPUT); PT_INIT(&pt1); //线程1初始化 PT_INIT(&pt2); //线程2初始化 } void loop () //这就是进行线程调度的地方 { protothread1(&pt1); //执行线程1 protothread2(&pt2); //执行线程2 delay(1000); //时间片,每片1秒,可根据具体应用设置大小 counter1++; counter2++; }
看上面的代码,你会发现很多大写的函数,其实那些都是些宏定义(宏定义用大写是约定俗成的..),如果把这些宏都展开你就能更好的理解他的原理了:
#include <pt.h>//ProtoThreads必须包含的头文件 static int counter1,counter2,state1=0,state2=0; //counter为定时计数器,state为每个灯的状态 static int protothread1(struct pt *pt) //线程1,控制灯1 { { char PT_YIELD_FLAG = 1; switch((pt)->lc) {//用switch来选择运行点 case 0: //此乃初始运行点,线程正常退出或刚开始都从这开始运行 while(1) //每个线程都不会死 { do { (pt)->lc=12;//记录运行点 case 12: if(!(counter1==1)) { return PT_WAITING; //return 0 } } while(0) digitalWrite(12,state1); state1=!state1;//灯状态反转 counter1=0; //计数器置零 } } PT_YIELD_FLAG = 0; pt->lc=0; return PT_ENDED; // return 1 } } static int protothread2(struct pt *pt) //线程2,控制灯2 { { char PT_YIELD_FLAG = 1; switch((pt)->lc) {//用switch来选择运行点 case 0: //线程开始 while(1) //每个线程都不会死 { do { (pt)->lc=39; case 39://记录运行点 if(!(counter2==5)) { return PT_WAITING; //return 0 } } while(0) counter2=0; //计数清零 digitalWrite(13,state2); state2=!state2; //灯状态反转 } } PT_YIELD_FLAG = 0; pt->lc=0; return PT_ENDED; // return 1 } } static struct pt pt1, pt2; void setup() { pinMode(12,OUTPUT); pinMode(13,OUTPUT); pt1->lc=0; //线程1初始化 pt2->lc=0; //线程2初始化 } void loop () //这就是进行线程调度的地方 { protothread1(&pt1); //执行线程1 protothread2(&pt2); //执行线程2 delay(1000); //时间片,每片1秒,可根据具体应用设置大小 counter1++; counter2++; }
好了,终于扩展完了。。
分析一下上面的代码,就知道其实ProtoThreads是利用switch case 来选择运行点的,每个线程中的堵塞,其实就是判断条件是否成立,不成立则return,所以说每个线程都很有雷锋精神,舍己为人,呵呵。有一点要注意那就 是每个线程只能够在我们指定的地方堵塞,至于堵塞点,那就要看具体应用了。
由于线程是反复被调用的,因此,写程序的时候不能像写一般的函数一样使用局部变量,因为每次重新调用都会把变量初始化了,如果要保持变量,可以把它定义为static的
在pt.h中定义了很多功能:
PT_INIT(pt) 初始化任务变量,只在初始化函数中执行一次就行
PT_BEGIN(pt) 启动任务处理,放在函数开始处
PT_END(pt) 结束任务,放在函数的最后
PT_WAIT_UNTIL(pt, condition) 等待某个条件(条件可以为时钟或其它变量,IO等)成立,否则直接退出本函数,下一次进入本 函数就直接跳到这个地方判断
PT_WAIT_WHILE(pt, condition) 和上面一个一样,只是条件取反了
PT_WAIT_THREAD(pt, thread) 等待一个子任务执行完成
PT_SPAWN(pt, child, thread) 新建一个子任务,并等待其执行完退出
PT_RESTART(pt) 重新启动某个任务执行
PT_EXIT(pt) 任务后面的部分不执行,直接退出重新执行
PT_YIELD(pt) 锁死任务
PT_YIELD_UNTIL(pt, cond) 锁死任务并在等待条件成立,恢复执行
在pt中一共定义四种线程状态,在任务函数退出到上一级函数时返回其状态
PT_WAITING 等待
PT_EXITED 退出
PT_ENDED 结束
PT_YIELDED 锁死
比如PT_WAIT_UNTIL(pt, condition) ,通过改变condition可以运用的非常灵活,如结合定时器的库,把condition改为定时器溢出,那就是个时间触发系统了,再把condition改为其他条件,就是事件触发系统了
暂时写这么多吧
三、MALC还做了个带定时器的库http://www.geek-workshop.com/thread-666-1-1.html
#include <PT_timer.h> #include <pt.h> int servopin=8;//定义舵机接口数字接口7 int myangle=100;//定义角度变量 int val=0; static struct pt pt1,pt2; PT_timer servotimer; static int servoMove(struct pt *pt) { PT_BEGIN(pt); while(1) { servotimer.setTimer(25); PT_WAIT_UNTIL(pt,servotimer.Expired()); int pulsewidth;//定义脉宽变量 myangle%=156;//视舵机而定,防止越界 pulsewidth=myangle*11+500;//将角度转化为500-2205的脉宽值 digitalWrite(servopin,HIGH);//将舵机接口电平至高 delayMicroseconds(pulsewidth);//延时脉宽值的微秒数 digitalWrite(servopin,LOW);//将舵机接口电平至低 } PT_END(pt); } String inString = ""; static int angleRead(struct pt *pt) { PT_BEGIN(pt); while(1) { PT_WAIT_UNTIL(pt, Serial.available()>0); int inChar = Serial.read(); if (isDigit(inChar)) inString += (char)inChar; if (inChar == ' '||inChar=='\n') { myangle=inString.toInt(); Serial.print("myangle="); Serial.println(myangle); inString = ""; } } PT_END(pt); } void setup() { pinMode(servopin,OUTPUT);//设定舵机接口为输出接口 PT_INIT(&pt1); PT_INIT(&pt2); Serial.begin(9600); } void loop()// { servoMove(&pt1); angleRead(&pt2); }
ServoMove呢就是自己模拟出来的舵机函数,为什么不用servo库呢?因为arduino自身的servo库有很多限制,第一PWM不能用了,第 二最小角度只有1度,当然这个程序里舵机的精度也是1度,只要把相应的变量改成float就能精确到小数了,不过最大的好处是舵机脚可以任意设定
servomove中限定了最大角度为155度,这个与舵机有关
angleRead中使用了stringtoInt,每次可以输入一个角度,角度可以以 空格 或 回车 结尾(操蛋的VC2010 SerialMonitor发送数据不带 回车的。。那就空格了)
程序中使用了定时器,自己用C++写的,第一次写库,照葫芦画瓢了。。。
感谢czad的扩展库翻译http://www.geek-workshop.com/forum.php?mod=viewthread&tid=184
这个库用起来非常简单:
PT_Timer t;//定义一个定时器
t.setTimer(unsinged long time) //定时时间,单位ms
t.Expired()//判断定时器是否溢出,是返回值>0
在上面的代码中,一个舵机周期是20ms,前面的约2.5毫秒是信号周期,剩下的10多ms全是无用的低电平,然而又必不可少,所以果断用定时器取代delay
四、库的下载地址,请查阅上面引用的文章网址。
本文章只做备忘记录使用,这几天和V同学做的项目中用到了这个库,这里受项目未公开原因,就不贴出原代码了。