一、简介
随着RTOS的应用,程序在开发的时候,程序逻辑也变得越来越清晰。但是RTOS因为体量比较大,在一些内存比较小的MCU中无法应用。所以,在裸机的开发中,通常是使用一个while(1)来作为整个程序的大循环。当有条件需要执行的时候,CPU通常是处于“死等”的状态,或者先运行其他程序,等程序在while(1)中“绕回来”的时候再进行检查。这样的效率明显比较低。那有没有一种方法能像RTOS那样可以“并行”执行呢?说到这里就需要介绍protoThread了。
ProtoThread是一个极简的C语言协程库,由几个简单的.h文件构成。ProtoThread主要是利用switch case内嵌循环的特殊语法来实现的。因为ProtoThread完全是利用C语言的语法特性,所以ProtoThread可以适用所有C/C++项目。
二、基本原理
达夫设备
达夫设备(Duff's device)是串行复制(serial copy)的一种优化实现,实现展开循环,进而提高执行效率。见如下代码:
void test_switch(uint8_t num)
{
printf("test_switch enter,num:%d",num);
uint8_t i = 2;
switch(num)
{
case 0: do{ printf("case 0");
case 1: printf("case 1");
case 2: printf("case 2");
case 3: printf("case 3");
}while(i--);
}
printf("test_switch exit");
}
int main(void)
{
for(uint8_t i = 0;i < 3;i++)
{
test_switch(i);
}
return 0;
}
第一眼看到这样的代码,一定很怀疑是否可以执行。但是它就是可以执行的。那结果是什么呢?见下图:
可以看到,每一次调用该函数时,都是从对应编号的case开始执行,且该case下方的case也都会执行。那是因为每个case后没有break或return,程序就会继续向下执行。而执行到最后,有一个do{}while的循环。程序则会回到循环开头继续执行。是不是学到了新知识。这也是我们后边要说到的protoThread的基础。
演变
如果上边的代码能理解了,那我们看一下由上述代码演变而来的代码:
uint8_t test_for(uint8_t num)
{
printf("test_for enter,num:%d",num);
static uint8_t i = 0;
static uint8_t state = 0;
switch(state)
{
case 0:
for(i = 0;i < 10;i++)
{
printf("for,%d",i);
state = 1;
return i;
case 1:
printf("case 1");
}
}
return 0;
}
int main(void)
{
for(uint8_t i = 0;i < 3;i++)
{
test_for(i);
}
return 0;
}
可以看到上述代码,case 1在一个for循环之内,而for循环,又在case 0之内。是不是有点绕,那看一下结果是什么。
为了方便观察结果,我在每一个节点处都打印了信息。可以看到,这里一共调用了10次test_for函数。第一次进入函数后,因为state为0,则执行了for循环。在for循环中将state状态置为1后,就退出了。第二次进入函数,因为state为1,则执行case1。然后,因为此时还在for循环内,则继续执行for循环。通过打印变量i的值,可以看到i值此时为1,表明,此时是第二次执行for语句。后续的调用都是先执行了case 1,然后又继续执行for循环,最后退出。而每一次调用test_for函数,也都只是运行一次for循环而已。需要注意的是,从第二次调用test_for函数,进入for循环都是从“半路”插入的,就是case1的地方。所以,也不会触发for循环的i=0赋值。
进化
如果上边的代码也理解了,那我们再来一个更复杂的。使用宏定义。
#define BEGIN() static int state=0; switch(state) { case 0:
#define YIELD(x) do { state=__LINE__; return x; case __LINE__: printf("YIELD"); } while (0)
#define END() }
uint8_t test_declare(uint8_t num)
{
printf("test_declare enter,num:%d",num);
static int i = 0;
BEGIN();
printf("after begin");
for (i = 0; i < 10; i++)
{
printf("for,i:%d",i);
YIELD(i);
printf("after yield,i:%d",i);
}
END();
return 0;
}
int main(void)
{
for(uint8_t i = 0;i < 3;i++)
{
test_declare(i);
}
return 0;
}
这里为什么要用宏来代码部分代码呢?有两部分原因,其一是为了便于我们理解后续的ProtoThread。另一方面呢,虽然不用宏也能写,但是在语法形式上,看起来就非常怪异。尤其是当逻辑代码比较多时,非常难以理解。为了突出算法的逻辑,优化语法的形式也是非常必要的。
这里解释一下各个宏定义。
BEGIN()只是申请了一个变量,然后产生一个switch后就结束了。
END()只是把switch的“}”补齐。
YIELD()稍微有点复杂。里边使用编译宏__LINE__。__LINK__会输出语句被调用时的行号。这里将行号赋值给一个case是为了让case更具唯一性,减少命名的负担。而do{} while(0)只是用来保证宏的安全性。这里边只是赋值state,然后就退出了。后边还包含了一个case。
如果该代码看不懂的话,可以结果test_for函数来看,会比较容易理解。
好了,解释了这么多,来看一下运行结果:
还是一共调用10次test_declare函数。第一次调用,执行了BEGIN()函数,执行case0,即顺序向下执行,执行了打印“after begin”。然后进入for循环开始执行。在YIELD中将行号赋值为state后,则退出。第二次进入后,执行BEGIN()函数,因为state变量已经在上一次调用中被赋值为行号。所以此时,则执行运行case _LINE_,打印“YIELD”。执行结束后,因为没有return/break,程序继续向下执行打印“after yield”。结束后,继续向下,因为在for循环里,则判断循环条件,并继续执行打印“for”。再次执行YIELD()时,赋值行号给state,退出。后续的调用,重复上述步骤。
三、ProtoThread
1.宏定义介绍
typedef unsigned short lc_t;
typedef struct {
lc_t lc;
char status;
} pt_t;
struct pt_sem {
unsigned int count;
};
typedef struct pt_sem pt_sem_t;
#define PT_WAITING 0
#define PT_YIELDED 1
#define PT_EXITED 2
#define PT_ENDED 3
LC_INIT()
初始化LC值,赋值为0
#define LC_INIT(s) s = 0;
LC_RESUME()
开始执行,创建一个switch
#define LC_RESUME(s) switch(s) { case 0:
LC_SET()
赋值行号,执行case。即相当于在此设置一个断点,后续可以从switch直接跳转到此处执行。
#define LC_SET(s) s = __LINE__; case __LINE__:
LC_END()
补全 "}"
#define LC_END(s) }
PT_INIT()
初始化PT变量,内部调用LC_INIT来初始化。
#define PT_INIT(pt) LC_INIT((pt)->lc)
PT_THREAD()
protoThread的声明
#define PT_THREAD(name_args) char name_args
PT_BEGIN()
ProtoThread的起始,内部调用LC_RESUME创建一个switch。
#define PT_BEGIN(pt) { char PT_YIELD_FLAG = 1; PT_YIELD_FLAG = PT_YIELD_FLAG; LC_RESUME((pt)->lc)
PT_END()
ProtoThread的结束,与PT_BEGIN()对应。内部补全“}”。并初始化参数值。且返回PT_ENDED值。
#define PT_END(pt) LC_END((pt)->lc); PT_YIELD_FLAG = 0; \
PT_INIT(pt); return PT_ENDED; }
PT_WAIT_UNTIL()
条件函数,只有满足condition条件才会继续向下执行,否则return PT_WAITING值。
#define PT_WAIT_UNTIL(pt, condition) \
do { \
LC_SET((pt)->lc); \
if(!(condition)) { \
return PT_WAITING; \
} \
} while(0)
PT_WAIT_WHILE()
条件函数,满足cond则return PT_WAITING值,否则向下执行。
#define PT_WAIT_WHILE(pt, cond) PT_WAIT_UNTIL((pt), !(cond))
PT_SCHEDULE()
判断pt函数是否还在运行。结束返回0,还在运行返回1.
#define PT_SCHEDULE(f) ((f) < PT_EXITED)
PT_WAIT_THREAD()
条件函数,判断pt函数是否运行完成。满足则return。否则继续向下执行。
#define PT_WAIT_THREAD(pt, thread) PT_WAIT_WHILE((pt), PT_SCHEDULE(thread))
PT_EXIT()
初始化参数,返回PT_EXITED
#define PT_SCHEDULE(f) ((f) < PT_EXITED)
PT_YIELD()
断点函数,再次运行则退出
#define PT_YIELD(pt) \
do { \
PT_YIELD_FLAG = 0; \
LC_SET((pt)->lc); \
if(PT_YIELD_FLAG == 0) { \
return PT_YIELDED; \
} \
} while(0)
PT_YIELD_UNTIL()
判断函数,第一次运行且满足条件则向下运行,否则退出
#define PT_YIELD_UNTIL(pt, cond) \
do { \
PT_YIELD_FLAG = 0; \
LC_SET((pt)->lc); \
if((PT_YIELD_FLAG == 0) || !(cond)) { \
return PT_YIELDED; \
} \
} while(0)
PT_SEM_INIT()
信号值初始化
#define PT_SEM_INIT(s, c) (s)->count = c
PT_SEM_WAIT()
条件函数,满足信号值>0的条件,则向下执行,否则退出,返回PT_WAITING。
#define PT_SEM_WAIT(pt, s) \
do { \
PT_WAIT_UNTIL(pt, (s)->count > 0); \
--(s)->count; \
} while(0)
2.实例分析
1).两个pt_thread函数每执行一次就跳到对方函数执行,两个函数看起来是并行的。
static pt_t pt_1;
static pt_t pt_2;
static int protothread1_flag = 0;
static int protothread2_flag = 0;
static int pt_thread1(pt_t *pt)
{
printf("[pt_1]:enter");
PT_BEGIN(pt);
while(1)
{
printf("[pt_1]:before yield");
PT_YIELD(pt);
printf("[pt_1]:after yield");
}
PT_END(pt);
}
static int pt_thread2(pt_t *pt)
{
printf("[pt_2]:enter");
PT_BEGIN(pt);
while(1)
{
printf("[pt_2]:before yield");
PT_YIELD(pt);
printf("[pt_2]:after yield");
}
PT_END(pt);
}
int main(void)
{
PT_INIT(&pt_1);
PT_INIT(&pt_2);
for(uint8_t i = 0;i < 10;i++)
{
pt_thread1(&pt_1);
pt_thread2(&pt_2);
}
while(1);
return 0;
}
结果如下:
可以看到,在第一次进入pt_thread1函数时,运行了PT_BEGIN,然后打印before。运行PT_YIELD时,插入断点,则直接跳出并且进入pt_thread2函数。第一次pt_thread2函数运行与pt_thread1相同,运行PT_BEGIN,然后打印before。运行PT_YIELD时,插入断点,直接退出。再次运行pt_thread1时,直接从第一次的断点处开始执行。所以并没有先打印before,而是先打印了after。打印后,因为还在while(1)循环中,所以从循环的起始开始运行。打印before后,再次运行PT_YIELD,则直接退出。再次调用pt_thread2运行与pt_thread1相同。
2).PT_YIELD比较简单,这里看一下PT_YIELD_UNTIL()
static pt_t pt_1;
static pt_t pt_2;
static int protothread1_flag = 0;
static int protothread2_flag = 0;
static int pt_thread1(pt_t *pt)
{
printf("[pt_1]:enter");
static int cnt = 0;
cnt++;
if(cnt < 5)
{
protothread1_flag = 1;
}
else
{
protothread1_flag = 0;
}
PT_BEGIN(pt);
while(1)
{
printf("[pt_1]:before yield until");
PT_YIELD_UNTIL(pt,protothread1_flag != 0);
printf("[pt_1]:after yield until");
}
PT_END(pt);
}
static int pt_thread2(pt_t *pt)
{
printf("[pt_2]:enter");
static int cnt = 0;
cnt++;
if(cnt < 5)
{
protothread2_flag = 1;
}
else
{
protothread2_flag = 0;
}
PT_BEGIN(pt);
while(1)
{
printf("[pt_2]:before yield until");
PT_YIELD_UNTIL(pt,protothread2_flag != 0);
printf("[pt_2]:after yield until");
}
PT_END(pt);
}
int main(void)
{
PT_INIT(&pt_1);
PT_INIT(&pt_2);
for(uint8_t i = 0;i < 10;i++)
{
pt_thread1(&pt_1);
pt_thread2(&pt_2);
}
while(1);
return 0;
}
结果如下:
这里要先理解PT_YIELD_UNTIL()函数的意思。这个我们在上文说过:“判断函数,第一次运行且满足条件则向下运行,否则退出”。因为前5次,protothread1_flag和protothread2_flag的被置1,满足protothread_flag != 0的条件。所以,程序向下运行,打印相关信息。从第6次开始,protothread_flag被置为0,不满足protothread_flag != 0的条件。所以,当运行PT_YIELD_UNTIL时,直接退出。
3).通常如果使用PT_YIELD_UNTIL,会使逻辑变得很复杂。所以,该函数在实际应用中使用的并不多。PT_WAIT_UNTIL函数则相对简单一些。PT_WAIT_UNTIL会根据条件语句来判断,直到条件不满足时,才会让出当前执行权限,功能上比PT_YIELD_UNTIL和PT_YIELD更灵活。
static pt_t pt_1;
static pt_t pt_2;
static int protothread1_flag = 0;
static int protothread2_flag = 0;
static int pt_thread1(pt_t *pt)
{
printf("[pt_1]:enter");
PT_BEGIN(pt);
while(1)
{
printf("[pt_1]:before wait");
PT_WAIT_UNTIL(pt,protothread2_flag != 0);
printf("[pt_1]:after wait");
protothread1_flag = 1;
protothread2_flag = 0;
}
PT_END(pt);
}
static int pt_thread2(pt_t *pt)
{
printf("[pt_2]:enter");
PT_BEGIN(pt);
while(1)
{
protothread2_flag = 1;
printf("[pt_2]:before wait");
PT_WAIT_UNTIL(pt,protothread1_flag != 0);
printf("[pt_2]:after wait");
protothread1_flag = 0;
}
PT_END(pt);
}
int main(void)
{
PT_INIT(&pt_1);
PT_INIT(&pt_2);
for(uint8_t i = 0;i < 10;i++)
{
pt_thread1(&pt_1);
pt_thread2(&pt_2);
}
while(1);
return 0;
}
看结果:
第一次调用pt_thread1时,程序打印before,因为protothread2_flag为默认值0。不满足“protothread2_flag != 0”的条件,所以直接让出当前执行权限。调用pt_thread2时,将protothread2_flag赋值为1,且打印before。因为protothread1_flag为默认值0,也不满足“protothread1_flag != 0”的条件,所以让出当前执行权限。再次回到pt_thread1中,因为在pt_thread2中将protothread2_flag赋值为1.则满足“protothread2_flag != 0”的条件,继续向下执行。赋值protothread1_flag=1、protothread2_flag=0。再次运行PT_WAIT_UNTIL,因为不满足“protothread2_flag != 0”的条件,所以让出执行权限。再次调用pt_thread2,因为在pt_thread1中赋值protothread1_flag=1。满足PT_WAIT_UNTIL的“protothread1_flag != 0”条件,所以继续运行,并赋值protothread1_flag=0。因为还在while(1)循环中,则继续从头开始执行循环。因为不满足PT_WAIT_UNTIL的“protothread1_flag != 0”条件,则让出运行权限。周而复始。此时看起来就像两个函数在同时执行。
4).除了PT_WAIT_UNTIL这种让出执行权限的场景,在平时还需要用到“生产者-消费者”的场景,protoThread同样也提供了这样的场景。
static pt_t pt_1;
PT_THREAD(test_pt_wait(pt_t *pt))
{
static uint8_t cnt = 0;
static pt_sem_t temp_sem;
cnt++;
printf("test_pt_wait,cnt:%d",cnt);
if(cnt > 5)
{
PT_SEM_SIGNAL(pt,&temp_sem);
}
PT_BEGIN(pt);
PT_SEM_INIT(&temp_sem,0);
PT_SEM_WAIT(pt,&temp_sem);
printf("after PT_SEM_WAIT");
PT_END(pt);
}
int main(void)
{
PT_INIT(&pt_1);
printf("start test_pt_wait");
while(PT_SCHEDULE(test_pt_wait(&pt_1)));
printf("end test_pt_wait");
while(1);
return 0;
}
结果如下:
这里使用PT_SCHEDULE来做条件判断。只有test_pt_wait完整退出后,程序才会继续往下走。否则,一直运行test_pt_wait函数。
程序第一次进入test_pt_wait函数中后,初始化一个类似“计数信号量”的值。只有当这个值非零时,程序才会往下执行,否则让出执行权。但是这里限制,只有在第五次进入test_pt_wait函数时,“技术信号量”才会有所增加。所以,在前五次,程序只是进来,发现不满足条件,就直接退出了。只有第6次,才会继续向下运行。运行则完整退出。程序也就退出PT_SCHEDULE,且退出while循环。打印最终日志。
四、总结
ProtoThread是一种针对C语言封装后的宏库函数,为C语言模拟了一种无堆栈的轻量线程环境,能够实现模拟线程的条件阻塞、信号量操作等操作系统中特有的机制,从而使程序实现“多线程”操作。每个ProtoThread线程仅增加10行代码和2字节RAM的额外硬件资源消耗。对于资源紧缺且不能移植操作系统的嵌入式系统,使用protoThread能够方便直观的设计多任务程序,能够实现用线性程序结构处理事件驱动型程序和状态机程序,简化了该类程序的设计。
优点:
代码简单,占用空间小,只消耗2字节RAM的额外资源。适用于廉价的嵌入式设备。
可移植性好,因为完全基于C语言语法实现。C51/ARM等平台均支持。
缺点:
因为是stackless,所以无法保证恢复栈变量,代码中需要保存状态的变量需要使用静态局部变量代替。
代码并不是真的“多线程”,编写代码逻辑时需要格外小心。