||

如何在单片机中实现JS的单线程非阻塞模型

JavaScript 的单线程非阻塞是一个很经典的模型,其内部通过 事件循环(Event Loop) 来支持这一特性,从某种程度上来说,这非常符合单片机的一些场景。例如,在单片机中,开发者往往希望能同时处理一些任务,并且这些任务之间尽量不要阻塞,本篇博文我将尝试在 STM32 单片机上实现一个简化版本的 Event Loop 事件循环模型。

事件循环

JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题,为了能在单线程中实现非阻塞,就需要用到 事件循环(Event Loop) 模型。

JS 中的事件循环

所有的任务可以分为同步任务和异步任务,同步任务,顾名思义,就是立即执行的任务,同步任务一般会直接进入到主线程中执行;而异步任务,就是异步执行的任务,比如ajax网络请求,setTimeout 定时函数等都属于异步任务,异步任务会通过任务队列( Event Queue )的机制来进行协调。

同步和异步任务分别进入不同的执行环境,同步的进入主线程,即主执行栈,异步的进入 Event Queue 。主线程内的任务执行完毕为空,会去 Event Queue 读取对应的任务,推入主线程执行。 上述过程的不断重复就是 Event Loop (事件循环)。

单片机中的事件循环

在单片机中实现事件循环可以参照 JavaScript 的模型。

首先,对于单片机而然,其有且只有一个线程,所谓的阻塞,就是当单片机中出现类似 delay(100) 这样的调用时,程序会在一个循环里执行空语句,直到符合跳出条件。

单片机在执行 delay(100) 的时候,后续的其他任务不会执行,CPU时间几乎完全被空循环占用,所以就会造成阻塞,后续的其他任务无法执行,除非前面的阻塞被执行完成,这就是单片机中的阻塞模型。

由于单片机是一个实时模型,所以适当的阻塞也是需要被允许的。

所以,在单片机中想实现非阻塞模型,就需要对延时函数做一定的处理,后面我将逐步实现这个简化模型。

实现 EventLoop

要实现 Event Loop 模型,需要提前定义一些数据。这里我参照 JavaScript 的模型,将任务也大体分为宏任务和微任务,系统在启动进入实现循环时,先执行宏任务,之后按照 微任务–> 宏任务 的顺序循环执行。

我们也希望可以在单片机中调用 setTimeoutsetInterval 这样的函数,实现类型 JavaScript 原生的功能。

由于 C 不是面向对象编程的,所以完成像 JavaScript 中 bind 或 apply 之类的方法有一定难度,另外 C 的函数对可变参数支持的不好,如果需要实现类似 JavaScript 中函数传参的功能,就需要对参数进行修改。

在这个模型中,我将函数的参数定义为数组,由于数组的长度是可变的,所以就可以满足对不定参数的函数传参了。

例如,在使用 setTimeout 的近似方法的时候,可以这样写:

fun_params_t *blinkParams = (fun_params_t *)malloc(sizeof(fun_params_t) * 2);
blinkParams[0].param.int32Data = 20;
blinkParams[1].param.int32Data = 0;
FW_SetInterval(FW_LedState, blinkParams, 1500, IMMEDIATE_N);

其中 FW_LedState 是一个函数定义,这个函数接收两个参数,但这两个参数被封装在 fun_params_t 结构体中。

然后在 FW_LedState 中完成对参数的结构:

void FW_LedState(fun_params_t p[])
{
  uint16_t onMs = (uint16_t)(p[0].param.int32Data);
  uint16_t offMs = (uint16_t)(p[1].param.int32Data);
  //...
}

这样一来,就从 blinkParams 中结构出来了两个参数,分别为 onMs 和 offMs 。

这其中就需要定义一些数据类型,由于 C 没有 类 的概念,新的数据类型需要借助结构体来定义,下面我将说明需要用到的数据类型。

基本类型定义

首先是完成对函数传参的定义:

typedef union
{
  int32_t int32Data;
  double doubleData;
  const char* stringData;
} params_union_t;

typedef struct
{
  params_union_t param;
} fun_params_t;

fun_params_t 类型用于函数传参,以便实现类似 bind 的功能。

然后是两个关键的结构体类型:

typedef struct
{
  uint16_t taskId;
  void (*handler)(fun_params_t *p);
  fun_params_t *params;
  uint32_t interval;
  uint32_t runAt;
  el_status_t status;
} el_task_t;

typedef struct
{
  el_task_t *buf[MA_TASK_LEN];
  uint16_t wp;    // 写指针
  uint16_t rp;    // 读指针
  uint16_t size;  // 缓冲区的最大长度
} el_task_buf_t;

其中 el_task_t 是对当前任务的描述,成员释义:

成员 释义
taskId 任务的唯一ID
handler 一个函数指针,即任务的执行函数
params 任务的执行入参,在执行 handler 函数时会被传入
interval 执行周期,对 setTimeout 而言这个值为 0
runAt 任务的执行时间的毫秒时间戳
status 任务的状态

下面是 el_task_buf_t ,这是一个任务缓存结构体,由该类型完成宏任务和微任务的定义,其成员释义为:

成员 释义
buf 是一个任务队列,被定义成指针数组
wp 队列的写指针,当有任务被推入队列时,指针递增
rp 队列的读指针,当有任务被取出时,指针递增
size 队列现存任务的数量

其他有关的枚举类型:

typedef enum
{
  IMMEDIATE_N = 0,
  IMMEDIATE_Y,
} task_immediate_t;

typedef enum
{
  EL_NULL = 0,
  EL_IDLE,
  EL_RUNNING,
  EL_DONE,
} el_status_t;

typedef enum
{
  EL_OK,
  EL_ERR,
  EL_BUSY,
  EL_FULL,
  EL_EMPTY,
} el_ret_t;

宏任务与微任务

处理宏任务和微任务被定义在 event_loop.c 中:

// event_loop.c
static el_task_buf_t MacroTasksBuffer;
static el_task_buf_t MicroTasksBuffer;

首先,通过 el_task_buf_t 定义出静态变量,分别作为宏任务和微任务的缓冲 buffer 。然后在 EL_Init 方法中对宏任务和微任务 Buffer 初始化清零。

void El_Init()
{
  // macro
  MacroTasksBuffer.rp = 0;
  MacroTasksBuffer.wp = 0;
  MacroTasksBuffer.size = 0;
  // micro
  MicroTasksBuffer.rp = 0;
  MicroTasksBuffer.wp = 0;
  MicroTasksBuffer.size = 0;
}

执行任务时,优先处理微任务,然后处理宏任务,任务执行函数被定义在 EL_RunTasks 中。

void EL_RunTasks(void)
{
  uint16_t miSize = MicroTasksBuffer.size;
  uint16_t maSize = MacroTasksBuffer.size;
  // 先运行微任务
  while (EL_EMPTY != _EL_Run(&MicroTasksBuffer) && miSize--);
  // 在运行宏任务
  while (EL_EMPTY != _EL_Run(&MacroTasksBuffer) && maSize--);
}

关联函数处理

上面的代码中执行了宏任务和微任务,是通过 _RL_RUN 来调用的,下面看看该函数的定义。

el_ret_t _EL_Run(el_task_buf_t *taskBuf)
{
  el_task_t *task = _EL_ShiftTask(taskBuf);
  if (task == NULL) {
    return EL_EMPTY;
  }
  return _EL_Call(task);
}

然后是 _EL_Call 函数和 _EL_ShiftTask 函数。

// 从给的的任务对象中取出一个任务
el_task_t* _EL_ShiftTask(el_task_buf_t *taskBuf)
{
  if (taskBuf->size <= 0)
  {
    return NULL;
  }
  el_task_t *task = taskBuf->buf[taskBuf->rp++];
  taskBuf->rp = taskBuf->rp >= MA_TASK_LEN ? 0 : taskBuf->rp;
  taskBuf->size--;
  return task;
}

// 执行传入的任务
el_ret_t _EL_Call(el_task_t *task)
{
  if (task->status == EL_NULL) return EL_ERR;
  // 未到执行时间,重新放回宏任务队列
  if (Sys_GetMillis() < task->runAt)
  {
    EL_PushMacroTask(task);
    return EL_OK;
  }
  task->status = EL_RUNNING;
  task->handler(task->params);
  task->status = EL_DONE;

  // 周期任务
  if (task->interval > 0)
  {
    task->status = EL_IDLE;
    task->runAt = Sys_GetMillis() + task->interval;
    EL_PushMacroTask(task);
    return EL_OK;
  }
  // 非周期任务,执行完成后释放内存
  free(task->params);
  free(task);
  return EL_OK;
}

上面的代码中包含了 EL_PushMacroTask 方法的调用,该方法用来将定义好的任务推入给的的任务 buffer 中,这个任务将会在下一个 tick 中被执行。

el_ret_t EL_PushMacroTask(el_task_t *task)
{
  return _EL_PushTask(&MacroTasksBuffer, task);
}

它内部通过调用 _EL_PushTask 实现推入。

el_ret_t _EL_PushTask(el_task_buf_t *taskBuf, el_task_t *task)
{
  if (taskBuf->size >= MA_TASK_LEN)
  {
    return EL_FULL;
  }
  taskBuf->buf[taskBuf->wp++] = task;
  taskBuf->wp = taskBuf->wp >= MA_TASK_LEN ? 0 : taskBuf->wp;
  taskBuf->size++;
  return EL_OK;
}

以上,全部的管理方法就被定义完毕。

setTimeout 和 setInterval

在 JavaScript 中,setTimeout 和 setInterval 都被按宏任务处理,其回调函数会被推入宏任务队列中,在单片机中我们也实现同样的规则。

首先看 setTimeout 的实现,该函数的实现被定义在 framework.c 中。

uint16_t FW_SetTimeout(void callback(), fun_params_t p[], uint32_t ms, task_immediate_t immediate)
{
  uint32_t runAt = immediate == IMMEDIATE_N ? Sys_GetMillis() + ms : 0;
  return FW_SetTimer(callback, p, runAt, INTERVAL_NONE);
}

可以看到,方法名不同于 setTimeout ,入参也做了一些变化,参数释义如下:

参数 释义
callback 回调函数,等同于 setTimeout 的第一个参数
p 回调函数的入参,如果没有可以传 NULL
ms 等同于 setTimeout 的第二个参数,表示多久后执行回调函数
immediate 是否立即执行回调函数

在这个函数内部,会调用 FW_SetTimer 实现真正的任务创建,其实现如下:

uint16_t FW_SetTimer(void callback(), fun_params_t p[], uint32_t runAt, uint32_t interval)
{
  el_task_t *timeoutTask;
  timeoutTask = (el_task_t *)malloc(sizeof(el_task_t));
  timeoutTask->params = p;
  timeoutTask->handler = callback;
  timeoutTask->interval = interval;
  timeoutTask->runAt = runAt;
  timeoutTask->taskId = Sys_CreateTaskId();
  timeoutTask->status = EL_IDLE;
  EL_PushMacroTask(timeoutTask);
  return timeoutTask->taskId;
}

这个函数内部会开辟一块内存空间,创建新的任务,然后返回任务的Id。

单片机版本的 setInterval 实现与上面类似,只是函数名和传参有些不同。

uint16_t FW_SetInterval(void callback(), fun_params_t p[], uint32_t ms, task_immediate_t immediate)
{
  uint32_t runAt = immediate == IMMEDIATE_N ? Sys_GetMillis() + ms : 0;
  return FW_SetTimer(callback, p, runAt, ms);
}

启动事件循环

以上万事具备,那么就开始搞定事件循环吧。

FW_Start 是启动事件循环的入口函数,它接收一个函数作为参数。

void FW_Start(void handler())
{
  El_Init();
  handler();
  while (1)
  {
    EL_RunTasks();
  }
}

在 main.c 中定义两个任务,并且 在 eventloopTest 函数中由 FW_SetInterval 启动这两个任务:

// main.c
void helloWorld() {
  printf("[%0.8d] hello world\r\n", Sys_GetMillis());
}

void eventloopTest()
{
  fun_params_t *blinkParams = (fun_params_t *)malloc(sizeof(fun_params_t) * 2);
  blinkParams[0].param.int32Data = 20;
  blinkParams[1].param.int32Data = 0;
  FW_SetInterval(FW_LedState, blinkParams, 1500, IMMEDIATE_N);
  FW_SetInterval(helloWorld, NULL, 1000, IMMEDIATE_N);
}

其中的 FW_LedState 任务的函数定义如下。

void FW_LedState(fun_params_t p[])
{
  uint16_t onMs = (uint16_t)(p[0].param.int32Data);
  uint16_t offMs = (uint16_t)(p[1].param.int32Data);
  printf("[%0.8d] Led red one\r\n", Sys_GetMillis());
  LedR_On();
  FW_SetTimeout(LedR_Off, NULL, onMs, IMMEDIATE_N);
}

然后在入口函数中调用 FW_Start 完成启动。

int main(void)
{
  // ...
  /* USER CODE BEGIN WHILE */
  FW_Start(eventloopTest);
  while (1)
  {
    /* USER CODE END WHILE */
  }
}

如果一切正常,就可以在串口中查看到响应的输出了。

对延时函数的处理

在上面的函数 FW_LedState 中,熄灭 LED 是仿照 JavaScript 的方法使用了 setTimeout 来完成,即:

void FW_LedState(fun_params_t p[])
{
  // ...
  uint16_t onMs = (uint16_t)(p[0].param.int32Data);
  // ...
  FW_SetTimeout(LedR_Off, NULL, onMs, IMMEDIATE_N);
}

这里的处理原理是将需要延时一段时间后的处理通过 FW_SetTimeout 定义的宏任务放到下一个 tick 中,在下一个 tick 中判断是否需要执行。

不过,很多时候我们习惯于 delay(100) 这样的调用,在本篇博文实现的事件循环中,对延时函数也有实现:

// 异步延时
void FW_Delay(uint32_t ms)
{
  uint32_t endTime = Sys_GetMillis() + ms;
  while (Sys_GetMillis() < endTime)
  {
    EL_RunTasks();
  }
}

可以看到,在这个异步的延时函数中,延时所干的事情无非是继续执行队列中的任务,在每一个 tick 完成后来查看当前的延时是否已到期。

不过这种实现有一定的问题,这是和使用 SetTimeout 一样的问题:延时不准确。

而这,也是 JavaScript 中 setTimeout 函数的问题,就是它只能保证在给定的时间后执行回调,但是这个时间是多少,就要看队列中的任务被阻塞多久了。

类似文章