C语言学习-ProtoThread

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

一、简介

随着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所以无法保证恢复栈变量代码中需要保存状态的变量需要使用静态局部变量代替

代码并不是真的“多线程”编写代码逻辑时需要格外小心

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6