前端也能玩转硬件:在 ESP32 上运行 JavaScript (附可运行固件下载)

本篇文章最终的效果是:编译出一个可在 ESP32 上执行的固件,它能存储并直接运行 JavaScript 代码,这样 JavaScript 开发人员就能直接使用而不需要编写 C 代码,就跟在 PC 上编写 NodeJS 程序一样。

文章比较长,不想看如何实现,只想下载 JavaScript 可执行固件用于开发的同学,请直接拉到文章末尾。想了解如何扩展 JavaScript 接口以实现更多硬件能力的同学,请耐心阅读文章。

ESP32 开发板可以点击右边的按钮购买。

0. 写在前面

本文的主要目的是描述如何让 ESP32 芯片运行 JavaScript,并且让 web 前端开发人员也能玩转硬件。 作者之前是 web 前端开发工程师,所以文章会尽量站在 web 前端开发工程师的角度,抛开底层的硬件知识,去掉一些目前不需要关心的,将重点放在软件上。 尽管这样,我们接下来所要做的是 硬件+软件 的一个整体,所以一些基础的 C 语言和硬件知识会让你更好的阅读此文章。没有也不要紧,因为高深的我也不会阿!

文章会分为 2 个篇幅进行讲解。其中基础篇会先介绍基础知识,有以下几个部分:

  1. ESP32 硬件介绍
  2. JerryScript 简单介绍
  3. FreeRTOS 简单介绍

实战篇将会介绍如何在 ESP32 芯片上面运行 JerryScript,有以下几个部分:

  1. 让 JerryScript 运行并接受串口输入
  2. 使用片上存储芯片:flash
  3. 实现 JS 文件模块

1. ESP32 硬件介绍

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

ESP32 开发板图片

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

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 红外线传输信号。

除此之外,ESP32 还集成了片上传感器和模拟信号处理器。比如电容式触摸传感器,霍尔传感器,ADC,DAC 等。如果我们设计了有电池的产品,有了 ADC,我们就可以方便的测量电池的电量,虽然这样测量的值不一定准。

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

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

2. JerryScript 简单介绍

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

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;
}

3. FreeRTOS 简单介绍

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

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

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

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

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

实战篇

1. 让 JerryScript 运行并接受串口输入

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

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

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

- 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 来处理串口输入。为什么要启动一个 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 源码。

2. 使用片上存储芯片: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();
}

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

3. 实现 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 源码。

知道了 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

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

  1. 使用 mkspiffs 制作文件镜像
  2. 使用 esptool.py 烧录工具将文件镜像烧录到板子

完整的文件镜像制作和烧录方法请查看 nodemcujs 制作文件镜像

再看看其它同类文章吧!

有想说的评论一句吧!