|

EOS:单片机上单线程非阻塞模型的完整实现

eventloop-mcu 是一个在单片机上运行的事件循环模型库,参考了JavaScript的事件循环模型,并做了简化,使其可以在单片机上顺利运行,基于此模型,可以在单片机上实现类似 JavaScript 的异步编程。

本篇博文介绍其实用,eventloop-mcu 后文中称 eos

GitHub 项目地址:eventloop-mcu:一个在单片机上运行的事件循环模型库

Features

  • 多任务,基于异步实现的多任务,类似于运行了 RTOS 操作系统;
  • 轻量,核心部分占用的 RAM 小于 1Kb;
  • JavaScript like;
  • 拥有 el_setTimeout 、el_setInterval 、el_nextTick 和 el_requestAnimationFrame 等异步函数;
  • 拥有异步延时函数 el_delay,等待期间可继续执行其他任务;
  • 事件驱动,基于异步编程,可以通过事件驱动各种应用;
  • 移植方便,两个函数即可实现核心功能的移植;

移植

移植核心部分只需要实现两个函数:

  • Bsp_Get_Tick:获取嘀嗒定时器的 tick ,即系统启动后的毫秒时间戳;
  • Bsp_Delay_Ms:阻塞式延时函数;

eos 要求 Bsp_Get_Tick 达到 1ms 的精度,所以请确保定时器中断能产生 1ms 的嘀嗒周期。

STM32 HAL

以 stm32 为例,如果工程是通过 STM32CubeMX 创建,默认是开启系统嘀嗒定时器的,且滴答定时器的终端周期是 1ms ,那么在 stm32 hal 项目上移植就非常简单,在 bsp.h 中做一下定义即可:

// stm32_hal/bsp.h
/* Time-delay function and tick timer definition */
#define Bsp_Get_Tick            HAL_GetTick
#define Bsp_Delay_Ms            HAL_Delay

Arduino

Arduino 的移植也非常简单,由于 arduino 标准函数中默认就带了 millisdelay 两个函数,所以移植只需要做一下映射定义即可:

// arduino/bsp.h
/* Time-delay function and tick timer definition */
#define Bsp_Get_Tick            millis
#define Bsp_Delay_Ms            delay

构建配置

与编译构建有关的配置全部定义在 build.h 文件中,文件如下:

#ifndef __USER_EL_BUILD_H__
#define __USER_EL_BUILD_H__

// 选择需要使能的平台,当前开启了 STM32 HAL
#define BSP_USE_ARM_STM32_HAL

// eventloop 相关 buffer 长度定义
#define DF_MAX_TASK_LEN     32
#define DF_EVENT_BUF_LEN    32
#define DF_MAX_LISTENERS    16

// 是否启用下面的基本外设驱动
#define ENABLE_PWM_DRIVER
#define ENABLE_GPIO_DRIVER

// 是否启用按键监听
#define ENABLE_BUTTON_DEVICE
#ifdef ENABLE_BUTTON_DEVICE
#define DF_BUTTON_COUNTER   4
#endif // ENABLE_BUTTON_DEVICE

// ...

#endif

示例

在单片机中使用事件循环模型只需要引入 eos.h 头文件即可:

#include "eos.h"

当然,为了能编译成功,还需要将 eos.h 所在的目录加入到 Include Paths 中。

周期任务

使用 el_setInterval 定义的任务为周期任务,类似于 JavaScript 中的 setInterval 函数。

#include <stdio.h>
#include "main.h"
#include "eos.h"

void echoCounter()
{
  static uint16_t count = 0;
  printf("Count: %d\r\n", count++);
}

int main(void)
{
  // ...

  /**
   * @brief 每隔 1 秒执行一次 echoCounter 函数
   * 
   * 依次输出:
   * Count: 0
   * Count: 1
   * Count: 2
   * ...
   */
  el_setInerval(echoCounter, 1000, NULL, IMMEDIATE_N);
  // 启动事件循环
  el_startLoop();
  // ...
}

延时任务

el_setTimeout 用于定义一个延时任务,与 JavaScript 中的 setTimeout 表现类似,表示在一段时间后尽快执行回调函数,回调函数执行完成后会立即从内存中释放。

#include <stdio.h>
#include "main.h"
#include "eos.h"

void helloWorld()
{
  printf("hello world!\r\n");
}

int main(void)
{
  // ...

  /**
   * @brief 一秒钟后执行 helloWorld 函数
   * 
   * 输出:
   * hello world!
   */
  el_setTimeout(helloWorld, 1000, NULL);
  // 启动事件循环
  el_startLoop();
  // ...
}

事件监听

事件监听可以分为同步监听和异步监听,同步监听是立即执行的,异步监听则会在下一个 tick 中处理事件回调时执行。

同步事件监听

同步事件监听其实是同步的发布订阅模式,当事件被触发后,会直接执行回调函数,不必等到下一个 tick ,这是与异步不同的地方。

假设有 EVENT_SELF_ADD 自定义事件,以同步事件的方式触发该事件:

#include <stdio.h>
#include "main.h"
#include "eos.h"

void helloWorld()
{
  printf("hello world!\r\n");
}
int main(void)
{
  // ...
  el_addEventListener(EVENT_SELF_ADD, helloWorld);
  el_emitEvent(EVENT_SELF_ADD, NULL);
  // 启动事件循环
  el_startLoop();
  // ...
}

由代码可以发现,通过 el_addEventListener 函数可以定义事件的回调函数,同步触发就是直接通过 el_emitEvent 触发事件回调函数,这一过程没有任务异步操作,所以是同步触发。

异步事件监听

异步事件监听的表现与 JavaScript DOM 事件比较类似。

异步事件被一个事件消息队列维护,当事件被触发时,并不会立刻执行事件的回调函数,而是将事件推入事件队列中,在下一个 tick 中依次处理事件队列中的事件,触发回调函数。

需要注意,与 JavaScript 不同的是,eos 的事件监听只绑定事件和回调,并不绑定实例(触发源),这是出于节省资源的考虑,所以需要在回调函数中通过回调参数来判断事件源。

eos 中,当系统就绪并准备开始事件循环时,会触发 EVENT_EL_LOAD 事件,用户可以监听该事件,以便知道事件循环何时开始。

由于 eos 中的所有事件默认都是异步的,EVENT_EL_LOAD 也不例外,当第一轮 tick 的微任务执行完毕后,才会开始执行 EVENT_EL_LOAD 的回调函数。

#include <stdio.h>
#include "main.h"
#include "eos.h"

void onLoad()
{
  printf("eventloop loaded\r\n");
}

void helloWorld()
{
  printf("hello world!\r\n");
}

int main(void)
{
  // ...
  el_addEventListener(EVENT_EL_LOAD, onLoad);
  el_nextTick(helloWorld, NULL);
  // 启动事件循环
  el_startLoop();
  // ...
}

由于在每一个 tick 中,微任务会先被执行,所以 HelloWorld 函数比 onLoad 先执行,上面的例子最终输出为:

hello world!
eventloop loaded

自定义事件

与 JavaScript 类似,你也可以定义自己的事件,然后在需要的时候触发该事件的回调。自定义事件时只需要在 bsp.h 文件中扩展 et_type_t 类型的枚举值即可,见:

// bsp.h
// ...
typedef enum
{
  EVENT_NONE = 0,
  EVENT_EL_LOAD,
#ifdef ENABLE_BUTTON_DEVICE
  EVENT_BTN_PRESS,
  EVENT_BTN_LONG_PRESS,
  EVENT_BTN_RELEASE,
  EVENT_BTN_CLICK,
  EVENT_BTN_DCLICK,
#endif // ENABLE_BUTTON_DEVICE
  // ...
  // Add other event enumeration values here
  // ...
} et_type_t;
// ...

需要注意得失,有效事件的枚举值不能为 0 ,所以请确保枚举值 EVENT_NONE 始终等于 0 ,不要删除该枚举值。

nextTick

eos 中也有 nextTick 函数,其名称为 el_nextTick ,与 JavaScript(node环境)的 process.nextTick 功能类似。el_nextTick 中的回调函数总是会在下一次 tick 中优先执行。

#include <stdio.h>
#include "main.h"
#include "eos.h"

void echoCounter()
{
  static uint16_t count = 0;
  printf("Count: %d\r\n", count++);
}

void helloWorld()
{
  printf("hello world!\r\n");
}

int main(void)
{
  // ...

  /**
   * @brief el_nextTick 的回调函数执行早于 el_setTimeout 的回调函数
   * el_nextTick 的回调会被放入微任务队列,el_setTimeout 的回调会被放入宏任务队列
   * 每次 tick 中微任务总是先于宏任务执行
   * 
   * 输出:
   * hello world!
   * Count: 0
   */
  el_setTimeout(echoCounter, 0, NULL);
  el_nextTick(helloWorld, NULL);
  // 启动事件循环
  el_startLoop(startTestTasks);
  // ...
}

requestAnimationFrame

eos 中也支持 requestAnimationFrame 函数,名称为 el_requestAnimationFrame ,使用效果与 web 端的 requestAnimationFrame 函数类似,但原理不同。

在单片机中,el_requestAnimationFrame 可用于需要周期性刷新显示的场景,比如需要让一块屏幕按照给定的帧率进行刷新时,就可以通过该函数设置刷新函数以及期望的帧率。

下面的示例代码,表示以每秒 30 帧的速度执行 helloWorld 函数:

#include <stdio.h>
#include "main.h"
#include "eos.h"

void helloWorld()
{
  printf("hello world!\r\n");
}

int main(void)
{
  // ...
  el_requestAnimationFrame(helloWorld, 30, NULL);
  // 启动事件循环
  el_startLoop();
  // ...
}

要让 el_requestAnimationFrame 设置的回调函数顺利执行,还需要在 MCU 的 tickTimer 中断函数中调用 el_onIncTick 函数,由于这不是该段代码的主要目的,所以不在这里展开。

多任务/多线程

eos 允许用户定义多个周期任务,这与 JavaScript 表现一直,所以借助 el_setInterval 可以实现多任务。多任务的执行效果类似于多线程,但本质上不是多线程,只是单线程非阻塞模型的一种异步表现,也是该模型的特性之一。

下面的例子将创建两个周期任务,分别控制两个 led 闪烁。

#include <stdio.h>
#include "main.h"
#include "eos.h"

void ledBlink(fun_params_t p[])
{
  // 假设已定义 el_led_on 和 el_led_off 两个函数
  el_led_t *led = (el_led_t *)p[0].param.pointer;
  uint32_t onMs = (uint32_t)p[1].param.int32Data;
  if (onMs == 0)
    return;
  el_led_on(led);
  fun_params_t *params = (fun_params_t *)malloc(sizeof(el_led_t));
  memcpy(params, led, sizeof(el_led_t));
  // 点亮一段时间后熄灭LED
  el_setTimeout(el_led_off, onMs, params);
}

int main(void)
{
  // ...
  el_led_t *ledR = el_led_regist(GPIOB, GPIO_PIN_0, "Red", EL_PIN_LOW);
  el_led_t *ledG = el_led_regist(GPIOB, GPIO_PIN_5, "Green", EL_PIN_LOW);

  fun_params_t *blinkParamsR = (fun_params_t *)malloc(sizeof(fun_params_t) * 2);
  blinkParamsR[0].param.pointer = (uint32_t)ledR;
  blinkParamsR[1].param.int32Data = 10;
  el_setInterval(ledBlink, 1000, blinkParamsR, IMMEDIATE_N);

  fun_params_t *blinkParamsG = (fun_params_t *)malloc(sizeof(fun_params_t) * 2);
  blinkParamsG[0].param.pointer = (uint32_t)ledG;
  blinkParamsG[1].param.int32Data = 20;
  el_setInterval(ledBlink, 2000, blinkParamsG, IMMEDIATE_N);

  // 启动事件循环
  el_startLoop();
  // ...
}

上面的例子编译执行后,可以看到 Red 和 Green 两个 led 分别按照 1 秒和 2 秒为周期点亮,每次点亮 10ms ,这两个周期任务互相没有干扰,独自执行,类似于两个线程在分别运行。

监听用户输入

按钮点击事件

eso 的外设库中实现了一个简单的按钮驱动,使用轮训的方式处理用户点击,支持的事件有:

  • EVENT_BTN_PRESS:按钮被按下时触发;
  • EVENT_BTN_RELEASE:按钮被释放后触发;
  • EVENT_BTN_LONG_PRESS:按钮被按下并保持一段时间后触发;
  • EVENT_BTN_CLICK:按钮完成一次单击后触发;
  • EVENT_BTN_DCLICK:按钮完成一次双击后触发;

假设项目构建时已经启用了按键监听,并且启用了 GPIO 驱动,且在 bsp.c 中完成了对应的函数实现,例如对于按键而言,由于其依赖 GPIO 驱动,所以需要实现 GPIO 读写函数,对应在 stm32_hal 的 bsp.c 中为如下(已实现):

// stm32_hal/bsp.c
#include "bsp.h"

// ...

// Define gpio read and write functions
#ifdef ENABLE_GPIO_DRIVER
extern void __user_el_gpio_writePin(el_btn_port_def *port, el_btn_pin_def pin, el_pin_set_t state)
{
  HAL_GPIO_WritePin(port, pin, (GPIO_PinState)state);
}

extern el_pin_set_t __user_el_gpio_readPin(el_btn_port_def *port, el_btn_pin_def pin)
{
  return (el_pin_set_t)HAL_GPIO_ReadPin(port, pin);
}
#endif // ENABLE_GPIO_DRIVER

// ...

实现对按钮的长按和单击监听,示例如下:

#include <stdio.h>
#include "main.h"
#include "eos.h"

void onLongPress(fun_params_t p[])
{
  char *btnName = (char *)params[0].param.stringData;
  printf("[%s] long press\r\n");
}

void onClick(fun_params_t p[])
{
  char *btnName = (char *)params[0].param.stringData;
  printf("[%s] clicked\r\n");
}

int main(void)
{
  // ...

  el_btn_t *btn1 = el_button_regist(GPIOB, GPIO_PIN_0, "Btn1", EL_PIN_LOW);
  el_btn_t *btn2 = el_button_regist(GPIOB, GPIO_PIN_1, "Btn2", EL_PIN_HIGH);
  el_addEventListener(EVENT_BTN_LONG_PRESS, onLongPress);
  el_addEventListener(EVENT_BTN_CLICK, onClick);
  // 每隔 10ms 扫描一次按键
  el_setInterval(el_button_scan, 10, NULL, IMMEDIATE_N);

  // 启动事件循环
  el_startLoop();
  // ...
}

当用户对注册的按钮进行操作时,就会触发 EVENT_BTN_LONG_PRESSEVENT_BTN_CLICK 事件,通过回调函数即可处理该事件。

按键的事件回调会传入两个参数,分别是:

  • 按键名,即按键注册时传入的按键名;
  • 触发时间,即触发该事件时的毫秒时间戳;

回调函数的参数会通过一个 fun_params_t 类型的指针传入,回调函数对入参进行结构,拿到参数即可。

类似文章