如何在单片机中实现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 的模型,将任务也大体分为宏任务和微任务,系统在启动进入实现循环时,先执行宏任务,之后按照 微任务–> 宏任务 的顺序循环执行。
我们也希望可以在单片机中调用 setTimeout
和 setInterval
这样的函数,实现类型 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 函数的问题,就是它只能保证在给定的时间后执行回调,但是这个时间是多少,就要看队列中的任务被阻塞多久了。