What!前端也能玩硬件:在ESP32上运行JavaScript

What!前端也能玩硬件:在ESP32上运行JavaScript


image.png



做者 | 提莫的神秘商店编辑 |  Yonie本文的主要目的是描述如何让 ESP32 芯片运行 JavaScript,而且让 web 前端开发人员也能玩转硬件。html

做者以前是 web 前端开发工程师,因此文章会尽可能站在 web 前端开发工程师的角度,抛开底层的硬件知识,去掉一些目前不须要关心的,将重点放在软件上。尽管这样,咱们接下来所要作的是 硬件 + 软件 的一个总体,因此一些基础的 C 语言和硬件知识会让你更好的阅读此文章。没有也没关系,由于高深的我也不会阿!前端

文章会分为 2 个篇幅进行讲解。其中基础篇会先介绍基础知识,实战篇将会介绍如何在 ESP32 芯片上面运行 JerryScript。node

基础篇 ESP32 硬件介绍

首先先介绍一下 ESP32 是个什么东西,简单来讲,它就是一块集成了 WiFi、蓝牙、天线、功率放大器、电源管理模块、CPU、存储器、传感器的单片机微控制器,说人话就是:它能存储并运行咱们的代码,还具备 WiFi 和蓝牙功能。先来看一张图吧:  git

image.png

左边一块比较大的就是 ESP32 模组,上面提到的全部硬件配置都集成在这片模组上。下面的板子和其它元件是为了方便开发以及芯片启动必要的电路链接,而作成的一个开发板。这个开发板加了电源转换芯片,使得支持 3.3 - 5V 电压,右边小的方块型的是 cp2102 USB 转串口芯片,使得咱们可使用 USB 线链接电脑。这个板子把引脚都引出来了,方便使用杜邦线链接各类外围器件。下面咱们说的 ESP32 都表示这整块开发板,而不是 ESP32 模组自己。
github

ESP32 采用两个哈佛结构 Xtensa LX6 CPU 构成双核系统,时钟频率在 80MHz - 240MHz 范围内可调。片上集成了 520KB SRAM, 448KB ROM。拥有 41 个外设模块,包含常见的 IIC, SPI, UART, PWM, IR, I2S, SDIO 等。常见的协议基本都有了,这使得咱们能够更方便的和大部分电子模块或外设进行通讯,而不须要像 51 单片机同样,使用软件模拟实现。好比常见的 SSD12864 oled 屏幕,同时具备 IIC 和 SPI 的接口。BLOX-NEO-6M GPS 模块是使用的 UART 接口。直流电机和伺服机可使用 PWM 驱动。电风扇、空调等用的是 IR 红外线传输信号。web

除此以外,ESP32 还集成了片上传感器和模拟信号处理器。好比电容式触摸传感器,霍尔传感器,ADC,DAC 等。若是咱们设计了有电池的产品,有了 ADC,咱们就能够方便的测量电池的电量,虽然这样测量的值不必定准。编程

以上是单片机系统中很常见的外设,ESP32 将它们都集成在一个片上系统中了。但 ESP32 最让人激动的是,它集成了 WIFI 和 蓝牙。有了 WIFI 和 蓝牙,再配合各类外设,GPIO,咱们就能拿它作不少事情,好比温度湿度传感器的值直接上传到服务器。远程下发执行指令开关灯等,尽能够发挥你的想象。api

但硬件编程对于软件工程师来讲却实门槛有点高,尤为像咱们 web 前端开发工程师,C 语言就是第一道门槛。我一直想将 JavaScript 带到硬件编程中去,这样咱们就可使用熟悉的 JavaScript 发挥咱们的创意。因此才有了本篇文章。缓存

JerryScript 简单介绍

Node.js 很强大,但它是创建在 V8 和 libuv 之上的, ESP32 片上 SRAM 只有 520KB,别说 v8 了,libuv 都跑不起来。因此咱们须要一个轻量的,为嵌入式设计的 JavaScript 引擎,幸运的是,咱们有 JerryScript。bash

JerryScript 是一个轻量级的 JavaScript 引擎,它能够运行在受限制的设备上,例如微控制器,它能在 RAM < 64 KB, ROM < 200 KB 的设备上运行。并且它还提供了完整的 ES5.1 语法支持,以及部分 ES6 语法支持,好比 箭头函数,Symbol, Promise 等。在编程体验上虽然没有 v8 这么爽,但有这些就已经很好了啊(相对于其它的嵌入式 JavaScript 引擎来讲)!

还有一个重要的点是  JerryScript 的 api 更符合咱们的编程习惯,对于已经习惯编写 Node.js addon 的人来讲会更容易接受。因此以上 2 点,是咱们选择 JerryScript 的理由。为了让你们更直观的理解,下面咱们对比 2 个目前在嵌入式比较流行的 JavaScript 引擎。

 duktape

duktape 目前在 GitHub 上面是 3.7K 个 Star,下面是官网的 hello world!

#include <stdio.h>
#include "duktape.h"

/* Adder: add argument values. */
static duk_ret_t native_adder(duk_context *ctx) {
 int i;
 int n = duk_get_top(ctx);  /* #args */
 double res = 0.0;

 for (i = 0; i < n; i++) {
   res += duk_to_number(ctx, i);
 }

 duk_push_number(ctx, res);
 return 1;  /* one return value */
}

int main(int argc, char *argv[]) {
 duk_context *ctx = duk_create_heap_default();

 duk_push_c_function(ctx, native_adder, DUK_VARARGS);
 duk_put_global_string(ctx, "adder");

 duk_eval_string(ctx, "adder(1+2);");
 printf("1+2=%d\n", (int) duk_get_int(ctx, -1));

 duk_destroy_heap(ctx);
 return 0;
}
 JerryScript
#include "jerryscript.h"
#include "jerryscript-ext/handler.h"

static jerry_value_t adder_handler(const jerry_value_t func_value, /**< function object */
                                  const jerry_value_t this_value, /**< this arg */
                                  const jerry_value_t args[],    /**< function arguments */
                                  const jerry_length_t args_cnt)  /**< number of function arguments */
{
 double total = 0;
 uint32_t argIndex = 0;

 while (argIndex < args_cnt)
 {
   double = double + jerry_get_number_value(args[argIndex]);
   argIndex++;
 }
 return jerry_create_number(total);
}

int main (void)
{
 const jerry_char_t script[] = "print(adder(1, 2));";

 /* Initialize engine */
 jerry_init (JERRY_INIT_EMPTY);

 /* Register 'print' function from the extensions */
 jerryx_handler_register_global ((const jerry_char_t *) "print", jerryx_handler_print);

 /* Register 'adder' function from the extensions */
 jerryx_handler_register_global ((const jerry_char_t *) "adder", adder_handler);

 /* Setup Global scope code */
 jerry_value_t parsed_code = jerry_parse (NULL, 0, script, sizeof (script) - 1, JERRY_PARSE_NO_OPTS);

 if (!jerry_value_is_error (parsed_code))
 {
   /* Execute the parsed source code in the Global scope */
   jerry_value_t ret_value = jerry_run (parsed_code);

   /* Returned value must be freed */
   jerry_release_value (ret_value);
 }

 /* Parsed source code must be freed */
 jerry_release_value (parsed_code);

 /* Cleanup engine */
 jerry_cleanup ();

 return 0;
}

FreeRTOS 简单介绍

FreeRTOS 是一个热门的嵌入式设备用即时操做系统核心,它设计小巧且简易,大部分的代码由 C 语言编写。它提供多任务,互斥锁,信号量,和软件定时器等功能,让用户能够快速的进行应用程序设计。

以上是维基百科的介绍,简单来讲主要就是为设计多任务的应用程序提供基本的工具库,让应用开发者能够专一于逻辑的实现,而不必本身实现任务管理和调度。由于在单片机上编程是没有像 Linux 同样的多进程,多线程的概念的,单片机上电启动后就从指定地址加载指令,按照顺序执行完。

单片机通常来讲只有一个处理器,同一时间只能处理一个任务,假如你想让 2 个 LED 交替闪烁,那么你必须在 while(true){...} 循环内手动控制 2 个 LED 逻辑代码的执行时间,假如后续有 3 个,4 个,N 个呢?那么全部的逻辑都得写在里面,会很是庞大。

FreeRTOS 的任务能让各个逻辑跑在单独的 Task 中互不干扰,各 Task 以优先级抢占 CPU 时间。值得注意的是,即便使用了 FreeRTOS,整个应用仍然是单线程的,高优先级任务执行完后,必需要让出 CPU 时间才能让其它低优先级任务执行。记住,单片机只有一个处理器,同一时间只能处理一个任务。

整个 FreeRTOS 的任务是一个链表,从任务列表中取出最高优先级的任务执行,执行完后再取出下一优先级任务,一直循环。不过有几点不一样,FreeRTOS 永远保证高优先级任务先执行,因此低优先级任务有可能没有机会执行。每次执行完任务后,从列表中取出下一个任务时,都会从新计算优先级。执行中的任务只能由任务自身让出 CPU 时间,不然其它任务没有机会执行,固然除了中断。FreeRTOS 是实时操做系统,你能够精确控制各个任务的开始和结束时间。

实战篇 让 JerryScript 运行并接受串口输入

以上介绍完了基础知识,下面咱们开始让 JerryScript 在 ESP32 上面跑起来,并让串口接收用户输入,将它输入 JerryScript 中执行。

首先须要准备好 ESP-IDF 开发环境,而后新建一个空的工程,我推荐从 idf-hello-world 新建。JerryScript 将做为一个外部依赖放在deps/JerryScript 目录。JerryScript 源码地址:https://jerryscript.net/。

最终咱们的工程目录是这样的:

- build
- deps
 - jerryscript
- components
- main
- spiffs
- partitions.csv
- CMakeLists.txt
- sdkconfig
  • build 是咱们的构建目录,构建过程当中的全部临时文件都在这里,方便清理。
  • deps 是存放第三方依赖的目录,JerryScript 将做为一个依赖,这样方便管理,能够和官方保持同步。

  • components 是存放用户组件的目录,咱们本身编写的组件都放在这里。

  • main 是一个特殊的组件,做为应用的主程序。

  • spiffs 是存放内置文件系统的目录,里面的全部文件都会被打包成一个二进制文件,方便烧录到芯片上。

  • partitions.csv 是分区表配置文件,每个应用都须要配置一个分区表,这里使用默认的就好了。

  • CMakeLists.txt 是工程的主构建文件,整个工程的构建将从这里开始。

  • sdkconfig 是工程的配置文件,能够配置系统参数和一些用户自定义的参数。

以上全部都准备好后,能够开始写代码了。ESP32 的 CPU 型号是 Xtensa 32-bit LX6,因此咱们须要编写 JerryScript 的交叉编译,而后将静态库连接到 main 组件中去,这样 JerryScript 才能运行起来。

下面是主 CMakeLists.txt 文件内容,主要是指定 JerryScript 的源码目录,这样方便在其它组件内使用。而后设置 JERRY_GLOBAL_HEAP_SIZE为 128KB。

JERRY_GLOBAL_HEAP_SIZE 表示 JerryScript 虚拟机预先申请的内存大小,在虚拟机启动时就会向系统预先申请指定大小的内存。

由于 ESP32 内存总共才 520KB,而 JerryScript 默认的 heap_size 也是 512KB,这样确定是编译不过的,会报溢出错误。

cmake_minimum_required(VERSION 3.5)

set(JERRYSCRIPT_SOURCE "${CMAKE_SOURCE_DIR}/deps/jerryscript")

# JerryScript setting here
set(JERRY_GLOBAL_HEAP_SIZE "(128)")

include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(nodemcujs)

主 cmake 编写好后,接下来是编写 main 组件的 cmake 文件。要使用 JerryScript 很是简单,只须要连接 JerryScript 的静态库,而后配置正确的头文件路径。JerryScript 默认会编译为静态库,咱们在 main 组件中将它们连接就行。

下面是 main 组件的 CMakeLists.txt,内容有点多,这里只选择关键的讲解,详情请看 nodemcujs 项目:

set(COMPONENT_PRIV_INCLUDEDIRS
   ${JerryScript_SOURCE}/jerry-core/include
   ${JerryScript_SOURCE}/jerry-ext/include
   ${JerryScript_SOURCE}/jerry-port/default/include)

上面是设置 JerryScript 的头文件查找路径。下面将进行 JerryScript 的交叉编译,并把编译后的静态库连接到 main 组件:

# Xtensa processor architecture optimization
set(EXTERNAL_COMPILE_FLAGS -ffunction-sections -fdata-sections -fstrict-volatile-bitfields -mlongcalls -nostdlib -w)
string(REPLACE ";" "|" EXTERNAL_COMPILE_FLAGS_ALT_SEP "${EXTERNAL_COMPILE_FLAGS}")

上面是设置交叉编译的参数,针对 xtensa 处理器,不加这个参数连接通不过。尤为注意 -mlongcalls 参数,此参数虽然被设置为编译参数,但它实际是做用在汇编的。若是你看到 dangerous relocation: call0: call target out of range 这个错误,多半是忘记加这个参数了。详情请看 [xtensa-gcc-longcalls][] 编译器的文档。注意,这里的都须要写在 register_component() 后面,不然会报错。

编译参数设置好后,下面是使用 externalproject_add 将 JerryScript 做为外部工程单独编译,不能使用 add_subdirectory,cmake 会报错。

externalproject_add(jerryscript_build
 PREFIX ${COMPONENT_DIR}
 SOURCE_DIR ${JERRYSCRIPT_SOURCE}
 BUILD_IN_SOURCE 0
 BINARY_DIR jerryscript
 INSTALL_COMMAND "" # Do not install to host
 LIST_SEPARATOR | # Use the alternate list separator
 CMAKE_ARGS
   -DJERRY_GLOBAL_HEAP_SIZE=${JERRY_GLOBAL_HEAP_SIZE}
   -DJERRY_CMDLINE=OFF
   -DENABLE_LTO=OFF # FIXME: This option must be turned off or the cross-compiler settings will be overwritten
   -DCMAKE_C_COMPILER_WORKS=true # cross-compiler
   -DCMAKE_SYSTEM_NAME=Generic
   -DCMAKE_SYSTEM_PROCESSOR=xtensa
   -DCMAKE_C_COMPILER=${CMAKE_C_COMPILER}
   -DEXTERNAL_COMPILE_FLAGS=${EXTERNAL_COMPILE_FLAGS_ALT_SEP}
   -DCMAKE_EXE_LINKER_FLAGS=${CMAKE_EXE_LINKER_FLAGS}
   -DCMAKE_LINKER=${CMAKE_LINKER}
   -DCMAKE_AR=${CMAKE_AR}
   -DCMAKE_NM=${CMAKE_NM}
   -DCMAKE_RANLIB=${CMAKE_RANLIB}
   -DCMAKE_FIND_ROOT_PATH_MODE_PROGRAM=NEVER
)
add_dependencies(${COMPONENT_NAME} jerryscript_build)

上面主要是将 JerryScript 设置为 main 组件的依赖,这样编译 main 组件时会自动编译 JerryScript。而后设置交叉编译工具链。这里须要特别注意,关闭 ENABLE_LTO=OFF,为何?由于 JerryScript 里面开了此选项后,会判断编译器 ID 是否为 GNU,若是是的话,强制设置编译器为 GCC,致使咱们的交叉编译工具链设置失效。

最后,咱们将编译后的静态库连接到 main 组件:

set(COMPONENT_BUILD_PATH ${CMAKE_BINARY_DIR}/${COMPONENT_NAME}/jerryscript)

target_link_libraries(${COMPONENT_NAME}
                     ${COMPONENT_BUILD_PATH}/lib/libjerry-core.a
                     ${COMPONENT_BUILD_PATH}/lib/libjerry-ext.a
                     ${COMPONENT_BUILD_PATH}/lib/libjerry-port-default-minimal.a)

JerryScript 编译完后,会在编译目录的 main/jerryscript 下面生成最终文件,这个路径是咱们上面本身指定的,咱们这里只须要 jerry-core.a jerry-ext.a jerry-default-minimal.a 这三个静态库就好了。

${COMPONENT_NAME} 就是 main

下面编写初始化代码,在系统启动时初始化 JerryScript 虚拟机。

#include <stdio.h>
#include <string.h>

#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"

#include "jerryscript.h"
#include "jerryscript-ext/handler.h"
#include "jerryscript-port.h"

static void start_jerryscript()
{
 /* Initialize engine */
 jerry_init(JERRY_INIT_EMPTY);
}

void app_main()
{
 // init jerryscript
 start_jerryscript();
 while (true)
 {
   // alive check here. but nothing to do now!
   vTaskDelay(1000 / portTICK_PERIOD_MS);
 }
 /* Cleanup engine */
 jerry_cleanup();
}

初始化 JerryScript 很是简单,只须要调用jerry_init(JERRY_INIT_EMPTY) 就能够,如今咱们已经让 js 虚拟机跑起来了。vTaskDelay 是 FreeRTOS 提供的函数,做用是让出指定的 cpu 时间去执行其它任务,不至于将整个应用程序阻塞在这里,1000 / portTICK_PERIOD_MS 表示 1000ms,这跟在 Linux 上使用 sleep(1) 是差很少的。portTICK_PERIOD_MS 表示 FreeRTOS 1ms 内执行的节拍,这跟 CPU 的频率有关,详情请参考 [FreeRTOS][] 文档。

如今 JerryScript 的集成就已经完成了,能够编译出可执行的固件了:

$ mkdir build
$ cd build
$ cmake ..
$ make

若是没有错误,会在编译目录生成可执行固件,使用 make flash 会自动将固件烧录到 ESP32 芯片上。make flash 不须要额外的配置,能够直接使用,它会调用内置的 [esptool.py][] 进行烧写。

注意:烧录固件时,须要先安装串口驱动,某宝上面卖的板子质量良莠不齐,型号众多,不少卖家不懂技术,本身卖的是什么都不知道。通常来讲,ESP32 都是 CP2102 的驱动,去官网下载驱动就好了。

具体的烧录方法请查看 nodemcujs [烧录固件][]。

若是编译出错,请从头开始再来一遍。如今 JerryScript 已经跑起来了,可是咱们尚未 js 代码执行,下面咱们将打开串口,让从串口接收到的字符串输入给 JerryScript 执行,而且将结果从串口输出。

#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#include "driver/uart.h"
// ... 省略其它头文件
static QueueHandle_t uart_queue;
static void uart_event_task(void *pvParameters)
{
 uart_event_t event;
 uint8_t *dtmp = (uint8_t *)malloc(1024 * 2);
 for (;;) {
   // Waiting for UART event.
   if (xQueueReceive(uart_queue, (void *)&event, (portTickType)portMAX_DELAY)) {
     bzero(dtmp, 1024 * 2);
     switch (event.type) {
     /**
      * We'd better handler data event fast, there would be much more data events than
      * other types of events. If we take too much time on data event, the queue might
      * be full.
      */
     case UART_DATA:
       uart_read_bytes(UART_NUM_0, dtmp, event.size, portMAX_DELAY);
       /* Setup Global scope code */
       jerry_value_t parsed_code = jerry_parse(NULL, 0, dtmp, event.size, JERRY_PARSE_NO_OPTS);

       if (!jerry_value_is_error(parsed_code)) {
         /* Execute the parsed source code in the Global scope */
         jerry_value_t ret_value = jerry_run(parsed_code);

         /* Returned value must be freed */
         jerry_release_value(ret_value);
       } else {
         const char *ohno = "something was wrong!";
         uart_write_bytes(UART_NUM_0, ohno, strlen(ohno));
       }

       /* Parsed source code must be freed */
       jerry_release_value(parsed_code);
       // free(dtmp);
       break;
     //Event of UART ring buffer full
     case UART_BUFFER_FULL:
       // If buffer full happened, you should consider encreasing your buffer size
       // As an example, we directly flush the rx buffer here in order to read more data.
       uart_flush_input(UART_NUM_0);
       xQueueReset(uart_queue);
       break;
     //Others
     default:
       break;
     }
   }
 }
 free(dtmp);
 dtmp = NULL;
 vTaskDelete(NULL);
}

/**
* Configure parameters of an UART driver, communication pins and install the driver
*
* - Port: UART0
* - Baudrate: 115200
* - Receive (Rx) buffer: on
* - Transmit (Tx) buffer: off
* - Flow control: off
* - Event queue: on
* - Pin assignment: TxD (default), RxD (default)
*/
static void handle_uart_input()
{
 uart_config_t uart_config = {
     .baud_rate = 115200,
     .data_bits = UART_DATA_8_BITS,
     .parity = UART_PARITY_DISABLE,
     .stop_bits = UART_STOP_BITS_1,
     .flow_ctrl = UART_HW_FLOWCTRL_DISABLE};
 uart_param_config(UART_NUM_0, &uart_config);

 //Set UART pins (using UART0 default pins ie no changes.)
 uart_set_pin(UART_NUM_0, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
 //Install UART driver, and get the queue.
 uart_driver_install(UART_NUM_0, 1024 * 2, 1024 * 2, 20, &uart_queue, 0);

 //Create a task to handler UART event from ISR
 xTaskCreate(uart_event_task, "uart_event_task", 1024 * 2, NULL, 12, NULL);
}

代码有点多,将它拆成 2 个函数来看,handle_uart_input 函数负责安装串口驱动,而后启动一个 [Task][https://www.freertos.org/taskandcr.html] 来处理串口输入。为何要启动一个 task ?由于串口输入是异步的,咱们不能让它阻塞,因此在新的 task 中采用 [esp-uart-events][] 的方式监听事件,等有串口输入的事件到来时再去读取输入并执行。

板子带有一个 USB 转串口芯片,芯片的引脚被链接到 UART_NUM_0,因此咱们能够默认从这个串口读取输入,printf 默认也会从这里输出,这样插上 USB 就能够当作一台 mini 的 JavaScript 开发板了,方便开发和调试。这正是动态语言在硬件编程上的魅力。

有了输入,咱们还须要一个 native api 用于在 JavaScript 代码中输出数据,这里咱们使用自带的 print 就好了。在 JavaScript 代码中能够直接使用 print(message) 来输出数据到串口。

#include "jerryscript.h"
#include "jerryscript-ext/handler.h"

static void handler_print()
{
 /* Register 'print' function from the extensions */
 jerryx_handler_register_global ((const jerry_char_t *) "print",
                                 jerryx_handler_print);
}

void app_main()
{
 // init jerryscript
 start_jerryscript();
 handler_print();
 // handle uart input
 handle_uart_input();
 while (true)
 {
   // alive check here. but nothing to do now!
   vTaskDelay(1000 / portTICK_PERIOD_MS);
 }
 /* Cleanup engine */
 jerry_cleanup();
}

使用 make flash 编译更新后的固件,将它烧录到板子上,如今打开串口,链接上板子,输入 var msg = 'hello nodemcujs'; print(msg) 试试吧。你能够输入任意合法的 JavaScript 语句,使用 print 函数输出数据。

注意:不要使用 minicom,可使用 [ESPlorer][]。由于咱们是将串口的输入直接输入虚拟机执行的,因此只接收可显示字符和换行回车,其它字符好比控制字符会致使执行失败。

完整代码请查看 nodemcujs 源码。

使用片上存储芯片:flash

上面咱们已经实现了内嵌 JerryScript 虚拟机而且打通了串口交互,但每次重启都会重置数据,这显然不是一块标准的开发板,本章节咱们将会对接文件系统用于存储用户数据。

ESP32 已经集成了一片 4MB 的 SPI 存储芯片,SPI 是一种数据交换协议,咱们这里不用太关心,感兴趣的本身查找资料,下文咱们以 flash代指这个存储芯片。

ESP-IDF 工程支持 spiffs 组件,咱们只须要拿来用就好了。要使用文件系统,有这些步骤是必需要作的:

  1. 分区表 - 划分磁盘的用途,告诉系统有几个分区,各个分区大小是多少。每片 ESP32 的 flash 能够包含多个应用程序,以及多种不一样类型的数据(例如校准数据、文件系统数据、参数存储器数据等)。所以,咱们须要引入分区表的概念。

  2. mount - 读取分区表配置,若是尚未被初始化,则对磁盘进行格式化

咱们基于默认的分区表上进行修改,新增一个 data 分区用于存储用户自定义数据。在项目根目录新建 partitions.csv 文件,内容以下:

# Name,   Type, SubType, Offset,  Size, Flags
# Note: if you change the phy_init or app partition offset, make sure to change the offset in Kconfig.projbuild
nvs,      data, nvs,     0x9000,  0x6000,
phy_init, data, phy,     0xf000,  0x1000,
factory,  app,  factory, 0x10000, 1M,
storage,  data, spiffs,  ,        0x2F0000,

nvsphy_init 分区使用默认就行,factory 分区用于存储 App,即编译出来的可执行代码,也能够理解为编译出来的 bin 文件。咱们指定大小为 1M,目前编译出来的固件大小为 500KB 左右,通常来讲够用了。

storage 分区是咱们新加的分区,用于存储用户自定义数据,offset 咱们这里不填写,会自动对齐上一个分区。大小指定为 0x2F0000,差很少有 2.7M 可用空间。注意这是最大了,不能再大,ESP32 最多见的 flash 大小是 4MB,若是你的 flash 大小不同,能够根据状况修改,但不能超过度区大小,能够小于。

ESP32 默认将分区表数据烧写至 0x8000 地址处,长度为 0xC00,最多能够保存 95 个条目,分区表后面还保存有 MD5 校验和,因此若是你不清楚整个分区表,不要乱改分区数据。详细说明请看 [分区表][] 文档。

注意:要使用用户自定义分区表,须要在 sdkconfig 文件中指定,可使用 make menuconfig 图形界面,具体方法以下:

$ mkdir build
$ cd build
$ cmake ..
$ make menuconfig

执行 make menuconfig 后,会出现图形界面,进入:Partition TablePartition Table 选择 Custom partition table CSV。而后Custom partition CSV file 填写 partitions.csv,注意这是你的分区表文件名,请根据你本身的状况修改。

分区表制做好后,接下来咱们在启动流程中 mount storage 分区:若是分区没有被初始化,则格式化分区后再次加载,不然直接加载。而且将使用状况打印出来。

#include "esp_system.h"
#include "esp_spi_flash.h"
#include "esp_heap_caps.h"
#include "esp_err.h"

#include "driver/uart.h"
#include "esp_spiffs.h"

static void mount_spiffs()
{
 esp_vfs_spiffs_conf_t conf = {
   .base_path = "/spiffs",
   .partition_label = NULL,
   .max_files = 5,
   .format_if_mount_failed = true
 };

 esp_err_t ret = esp_vfs_spiffs_register(&conf);

 if (ret != ESP_OK)
 {
   if (ret == ESP_FAIL)
   {
     printf("Failed to mount or format filesystem\n");
   }
   else if (ret == ESP_ERR_NOT_FOUND)
   {
     printf("Failed to find SPIFFS partition\n");
   }
   else
   {
     printf("Failed to initialize SPIFFS (%s)\n", esp_err_to_name(ret));
   }
   return;
 }

 size_t total = 0, used = 0;
 ret = esp_spiffs_info(NULL, &total, &used);
 if (ret != ESP_OK) {
   printf("Failed to get SPIFFS partition information (%s)\n", esp_err_to_name(ret));
 } else {
   printf("Partition size: total: %d, used: %d\n", total, used);
 }
}

bash_path 咱们设置为 /spiffs,这至关于根目录前缀,之后访问数据分区时都要使用 /spiffs/file,固然你能够根据本身状况修改。将format_if_mount_failed 参数设置为 true,能够在分区 mount 失败后自动格式化,这种状况通常是分区未被格式化。注意 spiffs 文件系统是没有目录概念的,/ 只是被当作一个文件名,后续咱们能够本身模拟目录的概念。

挂载分区后,咱们就可使用文件系统的 api 去读写文件了。咱们使用esp_spiffs_info 读取文件系统信息,将总大小和已使用状况打印出来。

最后,在启动流程中调用这个函数:

void app_main()
{
 // mount spiffs
 mount_spiffs();
 // init jerryscript
 start_jerryscript();
 handler_print();
 // handle uart input
 handle_uart_input();
 while (true)
 {
   // alive check here. but nothing to do now!
   vTaskDelay(1000 / portTICK_PERIOD_MS);
 }
 /* Cleanup engine */
 jerry_cleanup();
}

从新编译,而后烧写,使用串口链接上板子查看打印出来的分区信息,若是看到成功打印出分区表数据,则说明文件系统挂载成功了,若是失败了,则仔细检查一遍哪里出错了。

实现 JS 文件模块

上面咱们已经有了文件的概念了,那咱们就能够编写 js 文件模块,而后使用 require 去加载文件模块,而且开机自动加载执行 index.js 文件,这样 JavaScript 开发者来讲就能够脱离 SDK 独立开发了。固然涉及到硬件驱动部分仍是须要 SDK 支持,暴露接口给 JavaScript,这里不进行细说。

先来看一下 Node.js 中的文件模块长什么样:

// a.js
module.exports = function a () {
 console.log(`hello, i am ${__filename}`)
}

这个模块很简单,只对外提供一个函数,函数里面打印出自身的文件名。那么如何使用这个模块呢:

// index.js
var a = require('./a.js')

a()

只须要使用 require 函数加载这个模块,赋值给一个变量,这个变量就引用了模块的全部对外实现。由于咱们对外就暴露一个函数,因此能够直接调用。那么这里的 module.exports 变量是从哪里来的呢?__filename 又为何会等于 a.js 呢?require 的返回值是怎么来的呢?来简单看一下 Node.js 是如何实现的。

当 require 一个文件模块时,Node.js 会读取文件的内容,而后将内容头尾包装一下,最终变成:

(function (exports, require, module, __filename, __dirname) {
 // 模块源码
})

把参数传递进去执行这个函数,因此咱们能够在文件模块中使用 exports 等未定义的变量,最后 require 函数将 exports 变量返回,就完成了一次模块的加载。固然,Node.js 中的实现是比这个要复杂不少的,这里只是简单的描述一下,详情请查看 Node.js: require 源码:https://duktape.org/

知道了 require 是如何工做的,如今咱们来实现一个最简单的 require,它只从文件系统中加载文件模块,而且不支持缓存和相对路径的。若是加载成功,则返回模块的 exports 对象,不然返回 undefined。

能够新建一个 用户组件,叫 jerry-module,也能够直接在 main 中编写。

void module_module_init()
{
 jerry_value_t global = jerry_get_global_object();

 jerry_value_t prop_name = jerry_create_string((const jerry_char_t *)"require");
 jerry_value_t value = jerry_create_external_function(require_handler);
 jerry_release_value(jerry_set_property(global, prop_name, value));
 jerry_release_value(prop_name);
 jerry_release_value(value);

 jerry_release_value(global);
}

咱们规定每一个 native 模块都有一个 init 方法,以 module 开头,中间的 module 表示模块名。在 init 方法中会给 global 变量注册模块自身须要暴露给 JavaScript 的 api,这样 JavaScript 就可使用了。下面是 require 函数的实现。

static jerry_value_t require_handler(const jerry_value_t func_value, /**< function object */
                                    const jerry_value_t this_value, /**< this arg */
                                    const jerry_value_t args[],     /**< function arguments */
                                    const jerry_length_t args_cnt)  /**< number of function arguments */
{
 jerry_size_t strLen = jerry_get_string_size(args[0]);
 jerry_char_t name[strLen + 1];
 jerry_string_to_char_buffer(args[0], name, strLen);
 name[strLen] = '\0';

 size_t size = 0;
 jerry_char_t *script = jerry_port_read_source((char *)name, &size);

 if (script == NULL)
 {
   printf("No such file: %s\n", name);
   return jerry_create_undefined();
 }
 if (size == 0)
 {
   return jerry_create_undefined();
 }

 jerryx_handle_scope scope;
 jerryx_open_handle_scope(&scope);

 static const char *jargs = "exports, module, __filename";
 jerry_value_t res = jerryx_create_handle(jerry_parse_function((jerry_char_t *)name, strLen,
                                         (jerry_char_t *)jargs, strlen(jargs),
                                         (jerry_char_t *)script, size, JERRY_PARSE_NO_OPTS));
 jerry_port_release_source(script);
 jerry_value_t module = jerryx_create_handle(jerry_create_object());
 jerry_value_t exports = jerryx_create_handle(jerry_create_object());
 jerry_value_t prop_name = jerryx_create_handle(jerry_create_string((jerry_char_t *)"exports"));
 jerryx_create_handle(jerry_set_property(module, prop_name, exports));
 jerry_value_t filename = jerryx_create_handle(jerry_create_string((jerry_char_t *)name));
 jerry_value_t jargs_p[] = { exports, module, filename };
 jerry_value_t jres = jerryx_create_handle(jerry_call_function(res, NULL, jargs_p, 3));

 jerry_value_t escaped_exports = jerry_get_property(module, prop_name);
 jerryx_close_handle_scope(scope);

 return escaped_exports;
}

这里咱们的实现很是简单:

  1. require 只接收一个参数叫 name,表示文件模块的绝对路径。

  2. 而后使用 jerry_port_read_source 读取文件的内容,注意使用这个函数须要头文件 jerryscript-port.h,使用完后记得使用jerry_port_release_source 释放文件内容。

  3. 接着判断文件是否存在,若是不存在或者文件内容为空,则返回 undefined,表示加载模块失败。

  4. 使用 jerry_parse_function 构造一个 JavaScript 函数,咱们这里只实现 exports, module, __filename 这三个参数。

  5. 使用 jerry_create_object 构造一个 JavaScript object,使用jerry_set_property 给这个 object 设置 exports 属性。

  6. 使用 jerry_call_functionexports, module, filename 做为参数执行函数,这样文件模块就会执行。module.exportsexports的引用。

  7. 最后,在文件模块内部会赋值给 exports 变量,这就是模块对外暴露的 api,咱们使用 jerry_get_propertyexports 属性返回,就完成了模块加载。

最后,咱们在虚拟机初始化后,调用模块的初始化函数,将模块注册到虚拟机:

void app_main()
{
 // mount spiffs
 mount_spiffs();
 // init jerryscript
 start_jerryscript();
 handler_print();
 // handle uart input
 handle_uart_input();
 // init node core api
 module_module_init();

 while (true)
 {
   // alive check here. but nothing to do now!
   vTaskDelay(1000 / portTICK_PERIOD_MS);
 }
 /* Cleanup engine */
 jerry_cleanup();
}

如今,咱们差最后一步:从文件系统中加载执行 index.js 文件,这样开机启动就会自动执行代码了。实现这个也很简单,在全部操做都完成后,使用文件 api 从文件系统读取 index.js 文件,而后使用 jerry_run执行。

static void load_js_entry()
{
 char *entry = "/spiffs/index.js";
 size_t size = 0;
 jerry_char_t *script = jerry_port_read_source(entry, &size);
 if (script == NULL) {
   printf("No such file: /spiffs/index.js\n");
   return;
 }
 jerry_value_t parse_code = jerry_parse((jerry_char_t *)entry, strlen(entry), script, size, JERRY_PARSE_NO_OPTS);
 if (jerry_value_is_error(parse_code)) {
   printf("Unexpected error\n");
 } else {
   jerry_value_t ret_value = jerry_run(parse_code);
   jerry_release_value(ret_value);
 }
 jerry_release_value(parse_code);
 jerry_port_release_source(script);
}

entry 的入口能够本身修改,咱们指定 /spiffs/index.js。若是加载失败,则什么也不作。若是加载成功,则使用 jerry_parse 编译 js 代码,最后使用 jerry_run 执行。一样,在启动流程中调用这个函数。

void app_main()
{
 // mount spiffs
 mount_spiffs();
 // init jerryscript
 start_jerryscript();
 handler_print();
 // handle uart input
 handle_uart_input();
 // init node core api
 module_module_init();

 // load /spiffs/index.js
 load_js_entry();

 while (true)
 {
   // alive check here. but nothing to do now!
   vTaskDelay(1000 / portTICK_PERIOD_MS);
 }
 /* Cleanup engine */
 jerry_cleanup();
}

如今,咱们整理一下启动流程都作了什么:

  1. mount spiffs 文件系统。

  2. 初始化 JerryScript 虚拟机。

  3. 注册全局 print 函数用于串口输出。

  4. 安装串口驱动,将输入传递给虚拟机执行。

  5. 注册 module 模块。

  6. 从文件系统加载 index.js 文件并执行。

  7. 很重要的一步:使用 vTaskDelay 让出 CPU 时间供其它任务执行。

至此,咱们有了一个 JavaScript 开发板了,但功能有限,驱动部分以及经常使用的功能模块都没有实现。原本还想介绍一下 native 模块和定时器的,篇幅有限,这里就再也不详细介绍了,完整的源码请查看 nodemcujs: https://github.com/nodemcujs/nodemcujs-firmware。

最后再简单介绍一下如何上传 index.js 以及自定义数据到文件系统:

  1. 使用 mkspiffs 制做文件镜像。

  2. 使用 esptool.py 烧录工具将文件镜像烧录到板子。

完整的文件镜像制做和烧录方法请查看 nodemcujs 制做文件镜像: https://github.com/nodemcujs/nodemcujs-firmware#6-%E5%88%B6%E4%BD%9C%E6%96%87%E4%BB%B6%E9%95%9C%E5%83%8F。 

相关文章
相关标签/搜索