Arduino 基于状态机实现简易的协程 - 几种非阻塞程序设计风格

想要处理的场景类似生产者 - 消费者模型:在一个顺序执行的函数中间,先要向生产者发送请求,比如让ADC 开始采样,过一段时间,等ADC 采样完成,获得返回的数据之后函数才能接着顺序执行。这是单片机程序里常见的问题,ADC 完成采样需要时间,没有返回的数据函数就不能接着执行,和网络编程中向服务器请求数据差不多,都是必须等待另一个并行运行的单元返回必要的数据,不同点在于,单片机程序不能在生产者那边想办法,硬件是死的。

阻塞轮询方式

最简单最直接的方法就是原地死等,也就是让控制流被阻塞,原地循环查询一个标志,直到生产者准备好才跳出死循环。把阻塞点之前的部分称为“阶段1”,之后为“阶段2”,函数在阶段1 的结尾向生产者发出请求,在间隙等待数据,在阶段2 的开头获取数据,然后继续执行。用伪代码表示:

void func(void) {
  发送请求;
  
  while(!ready);
  
  获取数据;
}

只要数据没准备好,ready == false,函数就卡在while 循环上。如果是在能用多线程的操作系统环境下,可以把函数整个放在一个线程里,任由它阻塞,还有别的线程干活,但是一个OS 对很多单片机是不可承受之重,而对于简单的任务,除了协程,还有别的非阻塞处理方式。

非阻塞大循环

这个套路经常用在单片机程序的主循环里。顾名思义,如果这个函数不是一次顺序执行就结束,里面有别的需要反复执行的东西,比如监控一个按键、显示一个转圈loading 图形、控制呼吸灯之类的,那么可以在整个函数内部包一个大循环,等待数据的同时兼顾干别的活:

void func(void) {
  发送请求;
  
  while(ture) {
    if(ready) {
			获取数据;
      return;
    }
    
    读取按键;
    做别的事;
    呼吸灯;
  }
}

这和一些小游戏的主循环是类似的原理,缺点是不够灵活,如果程序规模变大一些,存在很多内部要请求各种数据的函数,同时呼吸灯和监控按键始终不能停,那这么多函数里就得复制粘贴很多遍呼吸灯和读取按键的代码,不方便。

一勺烩状态机

倒也有解决代码重复的方法,就是把函数调用都尽量改成状态机的状态切换,相当于把所有函数都合并到一起,根据当前的状态执行对应函数的代码,类似这样:

void main(void) {
  setup();   //只需要执行一次的系统初始化
  
  int state = MAIN;
  int func_param;
  bool sent = false;
    
  while(true) {
    switch(state) {
      case MAIN:
        执行主循环代码;
        if(去调用func) {
          state = FUNC;  //函数调用转换成状态切换;
          func_param = 0xff; //用局部变量代替函数传参
          break;
        }
        别的事;
        break;
        
      case FUNC:
        if(!sent) {  //尚未发送请求时先发送请求
  	      发送请求;
  	      sent = true;
        }
        else if(ready) {
          sent = false;
          获取数据;
          state = MAIN;  //返回主循环
        }
        break;
    }
      
    读取按键;
    做别的事;
    呼吸灯;
  }
}

如此一来,公共的呼吸灯和读取按键部分就不用重复了,每次大循环都能照顾到它们。func 的代码中增加了对sent 变量的判断,用来避免重复发送请求。程序整体比较简单的话,用这么个状态机一勺烩看着也挺清爽的,所有东西都在一起,阅读代码不用跳来跳去。缺点是:

  1. 上面的例子是手动管理状态切换,很不灵活,程序复杂后容易出错;
  2. 还是程序复杂后的问题,代码都粘在一起,没有函数边界的分隔,后续维护容易翻车;
  3. 这对那些坚信一个函数不能超过二十行的强迫症患者是个灾难 :-p ;

状态转换栈

模仿正常函数的调用和返回机制,把状态转换也用一个栈来管理,从而不用手动硬编码状态返回点:

void main(void) {
  setup();   //只需要执行一次的系统初始化
  
  StateStack ss;    //用来存储状态的栈,假装已经实现好了
  int state = MAIN;
  int func_param;
  bool sent = false;
    
  while(true) {
    switch(state) {
      case MAIN:
        执行主循环代码;
        if(去调用func) {
          state = FUNC;  //函数调用转换成状态切换;
          ss.push(MAIN);  //记录状态返回点
          func_param = 0xff; //用局部变量代替函数传参
          break;
        }
        别的事;
        break;
        
      case FUNC:
        if(!sent) {  //尚未发送请求时先发送请求
  	      发送请求;
  	      sent = true;
        }
        else if(ready) {
          sent = false;
          获取数据;
          state = ss.pop();  //返回主循环
        }
      break;
    }
      
    读取按键;
    做别的事;
    呼吸灯;
  }
}

这样写就有点像汇编程序的风格了。因为返回点不是硬编码的,所以FUNC 状态可以从多个不同的状态进入,然后正确的返回“调用者”。如果真想使用这种设计,可以写一个宏,把状态切换和保存返回点的部分封装一下。也可以进一步把每个状态内代码使用的临时变量也放进栈里,退出状态时再把这些变量释放掉,但是感觉有点过度设计了。总而言之,在原生的调用栈上再实现另一层转换栈,也算是软件领域常见的层层套娃设计风格。

定时任务

仔细想一下,上面的设计根本上还是不够高效。无论是无阻塞大循环还是后面的状态机,现在func 函数只是可以在等待数据的同时做一点其他简单的活,如果预先知道生产者大概要花多少时间准备好,那么func 函数里持续检查ready 标志就是不必要的,相当于明知生产者没准备好,但还坚持不懈的问人家你好了没。如果可以预定在一段时间后再过来检查标志就好了,这期间,无论是去做别的事,还是让单片机睡眠省电,总之都更有效率。此外,如果能让多个带有数据请求的函数同时等待,总的等待时间就能减少。

先假装已经有了这么一个能实现定时调用函数的调度器,改进上面代码的思路是:主函数负责启动,用初始参数调用一次func,函数里要完成的工作不需要和主函数同步数据。比如,func 函数要做的就是等ADC 采样完成就把数据显示出去,做完就结束了。那么就可以借助调度器,第一次由主函数调用,func 运行到请求数据的地方就返回,把自身添加到调度器,预定1ms 后由调度器再调用一次func 函数,这次func 函数从请求数据的后面开始执行,先检查数据有没有准备好,ready == ture 的话就获取数据,完成其他工作,然后彻底退出,还没ready 就再跟调度器预定一次。这样一来,主函数不需要关注func 走到哪儿了,func 函数就像在后台运行一样。

//用来标记函数是被调度器调用的还是手动调用
enum class Caller {
  Scheduler,
  Human,
};

//神奇的调度器定义成全局变量,免得还要传参
Scheduler sch;

void main() {
  setup();
  
	while(true) {
    if(执行func) {
      func(Caller::Human, 0xff);
    }
    做别的事;
  }
}

//func 的第一个参数用来标记调用者,在手动调用的场合,第一个参数值选择Caller::Human,此时才需要用传入的参数func_param 更新函数内的静态变量,
//如果是调度器在调用函数,第一个参数被设置为Caller::Scheduler。
void func(Caller cal, int func_param = 0) {
  static int param;
  static bool sent = false;
  
  if(cal == Caller::Human) {
    param = func_param;
  }
  
  if(!sent) {
    发送请求;
    sent = true;
    sch.add_task(func, 1);  //把自身添加到调度器中,预定1ms 后执行
  }
  else if(ready) {
    获取数据;
    sent = false;  //状态复位,函数退出,不再定时执行
    做别的事;
  }
  else {
    sch.add_task(func, 1);  //必须再次预定,更新下一次定时执行的时间
  }
}

func 函数的这种设计是为了在主循环里手动调用函数时用传入的参数初始化函数内的静态变量,之后被调度器调用时,如果还要再传一次相同的参数就不方便了,所以函数内判断是被调度器调用时就不管输入的参数,按照之前的值运行。当然可以使用更简化的设计,让函数的参数为空,然后用全局变量传参数,这样就不用判断调用者了,但是全局变量太多了不方便维护,在调用时输入参数看起来更自然。

调度器用函数指针调用func 函数时,参数列表右边是可以缺几个的,只要函数内部不使用没传入的参数就没关系。这是因为调用函数时,参数是调用方压进栈里的,并且是按照从右往左的顺序,第一个参数最后入栈,所以被调用的函数只能确定第一个参数的位置,其他参数在栈中的位置只能在用的时候计算出来。如果函数里一个参数都用不到,那么实际上一个参数都不传也没问题。单片机的传参方式没这么规矩,很多时候都使用寄存器传递参数,但结论不变。

调度器应该每次执行任务后就删除任务,也就是单次执行,这样比较简单。要是需要循环执行,就让函数每次执行都重新添加任务。如果用的是Arduino,那么能实现定时任务的调度器库已经有不少了,拿来应该就能用。不过现成的调度器库不好兼容上面这种传递参数的方式,所以要选择简化的方案,函数的参数为空,只用全局变量传参。

上面的例子中缺了之前的呼吸灯和按键监控,这两个可以放在主循环里,也可以和func 一样,交给调度器定时执行。

用状态机实现协程

说了这么多,根本没提到标题里的协程,确实有点标题党了。协程本身只是个可以在中间暂停、之后又能恢复运行的函数,以func 函数为例,就像它第一次运行时在发送请求后暂停并返回了,下一次调用将使函数从暂停的地方恢复执行,这么说好像也没什么大不了的?确实。用协程实现的“同时运行”就像是比较粗糙的线程,在OS 控制下,线程代码被中途暂停,控制权切换到其他线程,或者说把CPU 的资源切换出去,而协程则是正在运行的代码主动间歇地交出控制权,也能实现所有代码雨露均沾、齐头并进的效果。

其实上面那个func 已经算是个简易的协程函数了,至少用起来比较像。函数第一次调用时sent == false,在这个状态下,函数发了请求然后就退出了,同时sent 被赋值为true,也就是转移到了下一个状态。无论func 之后被谁再次调用,它都会跳到上一次停下来的地方继续执行,也就是检查ready。如果成功获得了数据,sent 又被复位到false 状态,类似于协程退出,再下一次调用将回到开头重新执行。

这其实就是基于状态机的协程的基本原理,只不过func 里面只有一个“存档点”,用一个布尔变量表示一下前后两个阶段就可以了,如果函数被分成了更多阶段,自然就要用状态值替代布尔变量,里面的if-else 也应该改为switch 结构。除了状态机,函数在恢复运行时还要获得之前阶段的有用数据,因此在上面的实现中,除了表示状态的变量sent 定义成了static 静态变量,param 也是静态的,就是要把传入的初始化参数保存起来,这些静态变量也可以定义成全局变量。

不可重入

到此为止和正经协程还是有差距的,首先,func函数不能重入。通常来说,只要函数涉及到了全局的或者静态的变量,那函数就是不可重入的了,其他不可复制的外部资源也算,比如硬件上的寄存器。func 在运行过程中可能被中断,即这一次运行还没退出,状态没复位的时候,如果此时有另一个地方又手动调用了func,那么所有这些全局或静态的变量都会被覆盖,等之前中断的func 函数被调度器调用时,原本的变量已经一团糟了,自然不可能正确运行。正经的协程通常不是直接调用一个函数,而是把要调用的函数封装成一个协程对象,函数运行涉及的变量都放在对象里面,初次调用协程必须创建一个不同的对象,这样就可重入了,用到的变量都是独立的。

要把上面的代码进一步升级成这种可重入的协程也很简单,不用再判断调用者是谁了,增加一个参数用来传入结构体指针,再去掉其他参数。原本的静态变量都挪到结构体里面,初次手动调用时初始化结构体并传入指针,之后给调度器添加任务时把结构体指针也加进去。

//只能用来调用func 的结构体
struct FuncTask {
  bool sent = false;
  int param = 0;
};

Scheduler sch;
  
void main() {
  FuncTask f1;
  f1.param = 0xff;
  func(&f1);  //main 函数实际中不会退出,所以可以安全的取main 函数局部变量的指针
  FuncTask f2;
  f2.param = 0xaa;
  func(&f2);
  
  while(true) {
    别的事;
  }
}

void func(FunkTask *context) {
  if(!context->sent) {
    根据参数发送请求;
    context->sent = true;
    sch.add_task(func, 1, context);  //把自身添加到调度器中,预定1ms 后执行,还有结构体指针
  }
  else if(ready) {
    根据参数获取对应的数据;
    context->sent = false;
    做别的事;
  }
  else {
    sch.add_task(func, 1, context);
  }
}

把“发送请求”改成“根据参数发送请求”的原因就是上面提到的条件,两个函数不能用相同的方式同时访问同一个生产者。还有个不可避免的全局变量,就是调度器,虽然函数没有直接修改sch 变量,但在加入任务的时候会修改调度器的内部数据,如果在这时候中断了,也会出问题,所以调度器内部应该考虑整一个类似多线程同步锁的机制,或者干脆让它不能在这一步中断。

可以看到,想保证函数可重入还是挺麻烦的,对单片机程序而言,很多函数内部要控制硬件,本质上就不可能改成理想的可重入形式,只能加上同步锁,而且加上锁之后要怎么处理还是个麻烦,因为程序可以看作是单线程的,函数遇到同步锁的时候不能原地等待解锁,负责解锁的人这时候运行不了…… 综上,实践中我一般是不考虑可重入的问题,能避免就避免,必须要能重入的函数就尽量写的简单。

返回值

其次的不足就是对返回数据的处理。之前的例子前提都很简单,协程函数的调用者不依赖函数的返回值,就是单纯启动一下,发射后不管,因此函数被谁调用都没什么关系,只管做完自己的事就行。一旦需要协程函数返回什么数据,显然,不能通过协程函数的返回值实现,因为函数实际是在调度器控制下运行结束的,没什么好办法把返回值交给需要的代码,所以就和中断服务函数一样,func 函数需要输出的数据只能通过全局变量传出去。

P.S.

虽然没有明说,但是上面的伪代码里其实涉及了一些C++ 的特性,不多,用C 实现也没问题。还有标题上提到的“Arduino”,本来是打算用Arduino 的库做个实际可用的实现,写到一半就开始犯懒,所以就这样吧,反正也不算很复杂的东西,伪代码足够表达了。

  • 1
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值