Exploring Hardware with JavaScript on ESP32

Exploring Hardware with JavaScript on ESP32
Author | Teemo’s Mysterious Shop
Editor | Yonie
The main purpose of this article is to describe how to run JavaScript on the ESP32 chip, allowing web front-end developers to also engage with hardware.

The author was previously a web front-end developer, so the article will try to stand from the perspective of a web front-end developer, stripping away the underlying hardware knowledge and focusing on software. Despite this, what we are about to do is a combination of hardware + software, so some basic C language and hardware knowledge will help you better understand this article. But it’s okay if you don’t have that knowledge, because I won’t delve too deep either!

The article will be divided into two parts. The basics will first introduce fundamental knowledge, while the practical part will explain how to run JerryScript on the ESP32 chip.

Basics
Introduction to ESP32 Hardware

First, let’s introduce what the ESP32 is. Simply put, it is a microcontroller that integrates WiFi, Bluetooth, antennas, power amplifiers, power management modules, CPUs, memory, and sensors. In layman’s terms: it can store and run our code, and also has WiFi and Bluetooth capabilities. Let’s take a look at a picture:

Exploring Hardware with JavaScript on ESP32

The larger block on the left is the ESP32 module, which integrates all the mentioned hardware configurations. The board and other components below are designed to facilitate development and necessary circuit connections for the chip to start up, forming a development board. This development board has a power conversion chip, allowing it to support 3.3 – 5V voltage. The small square-shaped component on the right is the cp2102 USB to serial chip, which allows us to connect the computer using a USB cable. This board has all the pins exposed, making it easy to connect various peripheral devices using DuPont wires. When we talk about the ESP32 below, we refer to the entire development board, not just the ESP32 module itself.

The ESP32 uses a dual-core system consisting of two Harvard architecture Xtensa LX6 CPUs, with a clock frequency adjustable between 80MHz and 240MHz. It integrates 520KB SRAM and 448KB ROM on-chip. It has 41 peripheral modules, including common IIC, SPI, UART, PWM, IR, I2S, SDIO, etc. Most common protocols are supported, making it easier for us to communicate with most electronic modules or peripherals without needing to simulate them in software like the 51 microcontroller. For example, the common SSD12864 OLED display has both IIC and SPI interfaces. The BLOX-NEO-6M GPS module uses the UART interface. DC motors and servos can be driven using PWM. Fans, air conditioners, and other devices use IR to transmit signals.

In addition, the ESP32 also integrates on-chip sensors and analog signal processors, such as capacitive touch sensors, Hall sensors, ADC, DAC, etc. If we design a product with a battery, we can easily measure the battery level using ADC, although the measured value may not be accurate.

The above are common peripherals in microcontroller systems, and the ESP32 integrates them all into a single chip system. However, what excites people most about the ESP32 is that it integrates WiFi and Bluetooth. With WiFi and Bluetooth, along with various peripherals and GPIO, we can do many things, such as uploading the values from temperature and humidity sensors directly to a server or remotely issuing commands to turn lights on and off, allowing you to unleash your imagination.

However, hardware programming can be a bit challenging for software engineers, especially for web front-end developers like us, where C language is the first hurdle. I have always wanted to bring JavaScript into hardware programming so that we can use the familiar JavaScript to unleash our creativity. That’s why this article was created.

Introduction to JerryScript

Node.js is powerful, but it is built on V8 and libuv, while the ESP32 on-chip SRAM is only 520KB. Not to mention V8, libuv cannot even run. Therefore, we need a lightweight JavaScript engine designed for embedded systems, and fortunately, we have JerryScript.

JerryScript is a lightweight JavaScript engine that can run on constrained devices such as microcontrollers. It can run on devices with RAM < 64 KB and ROM < 200 KB. It also provides complete ES5.1 syntax support and partial ES6 syntax support, such as arrow functions, Symbol, Promise, etc. While the programming experience is not as pleasant as V8, having these features is already quite good (compared to other embedded JavaScript engines)!

Another important point is that JerryScript’s API is more in line with our programming habits, making it easier for those who are already used to writing Node.js addons to accept. So these two points are our reasons for choosing JerryScript. To help everyone understand more intuitively, we will compare two currently popular JavaScript engines in embedded systems.

Duktape

Duktape currently has 3.7K stars on GitHub, and here is the hello world! from the official website:

#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)
  {
    total += 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;
}
Introduction to FreeRTOS

FreeRTOS is a popular real-time operating system kernel for embedded devices. It is designed to be small and simple, with most of the code written in C. It provides features such as multitasking, mutexes, semaphores, and software timers, allowing users to quickly design applications.

This is the introduction from Wikipedia. In simple terms, it mainly provides a basic tool library for designing multitasking applications, allowing application developers to focus on logic implementation without needing to handle task management and scheduling themselves. Because programming on a microcontroller does not have the concept of multi-processing or multi-threading like Linux, the microcontroller starts up and loads instructions from a specified address, executing them in order.

Microcontrollers generally have only one processor and can only handle one task at a time. If you want two LEDs to blink alternately, you must manually control the execution time of the logic code for the two LEDs inside a while(true){...} loop. If there are 3, 4, or N more later, then all the logic must be written inside, which can become very large.

FreeRTOS tasks allow different logic to run in separate tasks without interference, and each task competes for CPU time based on priority. It is worth noting that even when using FreeRTOS, the entire application remains single-threaded. High-priority tasks must yield CPU time for other low-priority tasks to execute. Remember, microcontrollers have only one processor and can only handle one task at a time.

The entire FreeRTOS task is a linked list, taking the highest priority task from the list to execute, and after execution, it takes the next priority task, looping continuously. However, there are a few differences: FreeRTOS always ensures that high-priority tasks are executed first, so low-priority tasks may not get a chance to run. After each task execution, when taking the next task from the list, it recalculates priorities. Only the task itself can yield CPU time; otherwise, other tasks will not get a chance to run, except during interrupts. FreeRTOS is a real-time operating system, allowing you to precisely control the start and end times of each task.

Practical Part
Running JerryScript and Accepting Serial Input

Having introduced the basic knowledge, let’s now get JerryScript running on the ESP32 and allow the serial port to receive user input and execute it in JerryScript.

First, prepare the ESP-IDF development environment, then create a new empty project. I recommend starting from idf-hello-world. JerryScript will be placed as an external dependency in the deps/JerryScript directory. The JerryScript source code address is: https://jerryscript.net/.

The final project directory will look like this:

- build
- deps
  - jerryscript
- components
- main
- spiffs
- partitions.csv
- CMakeLists.txt
- sdkconfig
  • build is our build directory, where all temporary files during the build process are located for easy cleanup.
  • deps is the directory for third-party dependencies, where JerryScript will be placed as a dependency for easy management and synchronization with the official version.

  • components is the directory for user components, where we place our own written components.

  • main is a special component that serves as the main application program.

  • spiffs is the directory for the built-in file system, where all files will be packed into a binary file for easy flashing to the chip.

  • partitions.csv is the partition table configuration file. Every application needs a partition table, and you can use the default one.

  • CMakeLists.txt is the main build file for the project, from which the entire project build will start.

  • sdkconfig is the configuration file for the project, where system parameters and some user-defined parameters can be configured.

Once everything is prepared, you can start writing code. The CPU model of the ESP32 is Xtensa 32-bit LX6, so we need to write JerryScript’s cross-compilation and link the static library to the main component so that JerryScript can run.

Below is the content of the main CMakeLists.txt file, mainly specifying the source directory of JerryScript for easy use in other components. Then set JERRY_GLOBAL_HEAP_SIZE to 128KB.

JERRY_GLOBAL_HEAP_SIZE indicates the size of memory pre-allocated by the JerryScript virtual machine, which will request the specified size of memory from the system at startup.

Since the ESP32 has a total memory of only 520KB, and JerryScript’s default heap size is also 512KB, it will definitely not compile and will report an overflow error.

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)

After writing the main CMake file, the next step is to write the CMake file for the main component. Using JerryScript is very simple; you only need to link the static library of JerryScript and configure the correct header file paths. JerryScript will be compiled as a static library by default, and we will link it in the main component.

Below is the CMakeLists.txt for the main component. The content is a bit lengthy, so here we only choose the key points for explanation. For details, please refer to the nodemcujs project:

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

The above sets the header file search paths for JerryScript. Next, we will perform cross-compilation of JerryScript and link the compiled static library to the main component:

# 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}")

The above sets the compilation parameters for cross-compilation, targeting the Xtensa processor. Without these parameters, linking will fail. Notably, the -mlongcalls parameter must be added; this parameter, while set as a compilation parameter, actually acts in assembly. If you see the error dangerous relocation: call0: call target out of range, it is likely that you forgot to add this parameter. For details, please refer to the [xtensa-gcc-longcalls][] compiler documentation. Note that all of these need to be written after register_component(), or it will report an error.

After setting the compilation parameters, the next step is to use externalproject_add to compile JerryScript as an external project. You cannot use add_subdirectory; otherwise, CMake will report an error.

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)

The above mainly sets JerryScript as a dependency of the main component, so that when the main component is compiled, JerryScript will be compiled automatically. Then set the cross-compilation toolchain. It is particularly important to turn off ENABLE_LTO=OFF. Why? Because if this option is enabled in JerryScript, it will check whether the compiler ID is GNU; if so, it will forcibly set the compiler to GCC, causing our cross-compilation toolchain settings to fail.

Finally, we will link the compiled static library to the main component:

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)

After compiling JerryScript, the final files will be generated under the main/jerryscript directory in the build directory, which is the path we specified above. We only need the jerry-core.a, jerry-ext.a, and jerry-default-minimal.a static libraries.

${COMPONENT_NAME} is the main.

Next, write the initialization code to initialize the JerryScript virtual machine when the system starts:

#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();
}

Initializing JerryScript is very simple; you just need to call jerry_init(JERRY_INIT_EMPTY). Now we have the JS virtual machine running. vTaskDelay is a function provided by FreeRTOS, which yields the specified CPU time to execute other tasks, preventing the entire application from being blocked here. 1000 / portTICK_PERIOD_MS represents 1000ms, similar to using sleep(1) in Linux. portTICK_PERIOD_MS indicates the execution tick of FreeRTOS in 1ms, which is related to CPU frequency; refer to the [FreeRTOS][] documentation for details.

Now that the integration of JerryScript is complete, we can compile an executable firmware:

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

If there are no errors, an executable firmware will be generated in the build directory, and using make flash will automatically flash the firmware to the ESP32 chip. make flash does not require additional configuration and can be used directly. It will call the built-in [esptool.py][] for flashing.

Note: When flashing the firmware, you need to install the serial driver first. The quality of the boards sold on various platforms varies, and many sellers do not understand the technology and do not know what they are selling. Generally, ESP32 uses the CP2102 driver, and you can download the driver from the official website.

For specific flashing methods, please refer to the nodemcujs [flashing firmware][].

If you encounter compilation errors, please start over. Now that JerryScript is running, we still have no JS code execution. Next, we will open the serial port and input strings received from the serial port into JerryScript for execution, outputting the results back to the serial port.

#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#include "driver/uart.h"
// ... other header files omitted
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 handle data events quickly, as there will be many more data events than
       * other types of events. If we take too much time on data events, 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 increasing your buffer size
        // As an example, we directly flush the rx buffer here 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 a 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, i.e., 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 handle UART event from ISR
  xTaskCreate(uart_event_task, "uart_event_task", 1024 * 2, NULL, 12, NULL);
}

The code is a bit lengthy, so let’s break it into two functions. The handle_uart_input function is responsible for installing the serial driver and starting a task to handle serial input. Why start a task? Because serial input is asynchronous, we cannot block it, so we use a new task to listen for events using [esp-uart-events][] and read input when a serial input event arrives.

The board has a USB to serial chip, and the chip’s pins are connected to UART_NUM_0, so we can read input from this serial port by default, and printf will also output from here, allowing us to use it as a mini JavaScript development board for easy development and debugging. This is the charm of dynamic languages in hardware programming.

With input, we also need a native API for outputting data in JavaScript code. Here we can simply use the built-in print function. In JavaScript code, you can directly use print(message) to output data to the serial port.

#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();
}

Using make flash to compile the updated firmware, flash it to the board, and now open the serial port. Connect to the board and enter var msg = 'hello nodemcujs'; print(msg) to try it out. You can input any valid JavaScript statement and use the print function to output data.

Note: Do not use minicom; instead, use [ESPlorer][]. Since we directly input the serial input into the virtual machine, we only accept displayable characters and line breaks. Other characters, such as control characters, will cause execution to fail.

For the complete code, please refer to the nodemcujs source code.

Using On-chip Storage: Flash

Now that we have embedded the JerryScript virtual machine and established serial interaction, but every reboot resets the data. This clearly does not resemble a standard development board. In this chapter, we will interface with the file system to store user data.

The ESP32 has integrated a 4MB SPI storage chip. SPI is a data exchange protocol, and we need not worry too much about it. Interested readers can look up information themselves. In the following text, we will refer to this storage chip as flash.

The ESP-IDF project supports the spiffs component, and we just need to use it. To use the file system, there are several steps that must be followed:

  1. Partition Table – Divides the disk’s usage, informing the system how many partitions there are and the size of each partition. Each ESP32 flash can contain multiple applications and various types of data (e.g., calibration data, file system data, parameter storage data, etc.). Therefore, we need to introduce the concept of a partition table.

  2. Mount – Reads the partition table configuration, formatting the disk if it has not been initialized.

We will modify the default partition table to add a data partition for storing user-defined data. Create a partitions.csv file in the root directory of the project with the following content:

# 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,

nvs and phy_init partitions can use the default settings, while the factory partition is used to store the App, i.e., the compiled executable code, which can also be understood as the compiled bin file. We specify a size of 1M, and the currently compiled firmware size is around 500KB, which is generally sufficient.

The storage partition is our newly added partition for storing user-defined data, and we do not fill in the offset, which will automatically align with the previous partition. The size is specified as 0x2F0000, providing approximately 2.7M of usable space. Note that this is the maximum size; it cannot be larger. The most common flash size for ESP32 is 4MB. If your flash size is different, you can modify it accordingly, but it must not exceed the partition size and can be smaller.

ESP32 defaults to writing the partition table data to address 0x8000, with a length of 0xC00, which can save up to 95 entries. After the partition table, a MD5 checksum is also saved. Therefore, if you are not clear about the entire partition table, do not modify the partition data recklessly. For detailed explanations, refer to the [partition table][] documentation.

Note: To use a user-defined partition table, you need to specify it in the sdkconfig file. You can use make menuconfig for the graphical interface. The specific method is as follows:

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

After executing make menuconfig, a graphical interface will appear. Go to: Partition Table, select Custom partition table CSV, and then fill in Custom partition CSV file with partitions.csv. Note that this is the name of your partition table file, please modify it according to your situation.

After creating the partition table, we will mount the storage partition during the startup process: if the partition has not been initialized, format the partition and load it again; otherwise, load it directly. We will also print out the usage status.

#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 is set to /spiffs, which acts as a root directory prefix. In the future, when accessing the data partition, you will need to use /spiffs/file. Of course, you can modify it according to your situation. Setting the format_if_mount_failed parameter to true allows automatic formatting if the partition mount fails, which usually happens when the partition has not been formatted. Note that the spiffs file system does not have a directory concept; / is treated as a file name, and we can simulate the concept of directories later.

Once the partition is mounted, we can use the file system APIs to read and write files. We will use esp_spiffs_info to read the file system information and print out the total size and usage status.

Finally, call this function during the startup process:

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

Recompile and flash it. Use the serial port to connect to the board and check the printed partition information. If you see the partition table data printed successfully, it means the file system has been mounted successfully. If it fails, carefully check where the error occurred.

Implementing JS File Modules

Now that we have the concept of files, we can write JS file modules and use require to load file modules, automatically loading and executing the index.js file on boot. This allows JavaScript developers to develop independently of the SDK. Of course, the hardware driver part still requires SDK support, exposing interfaces to JavaScript, which will not be detailed here.

First, let’s take a look at what a file module looks like in Node.js:

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

This module is simple; it only exposes a function that prints its own file name. How do we use this module?

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

a()

By simply using the require function to load this module and assigning it to a variable, this variable references all the implementations exposed by the module. Since we only expose one function, we can call it directly. So where does the module.exports variable come from? Why does __filename equal a.js? How does the return value of require come about? Let’s take a brief look at how Node.js implements this.

When requiring a file module, Node.js reads the content of the file and wraps it with headers and footers, ultimately turning it into:

(function (exports, require, module, __filename, __dirname) {
  // module source code
})

Passing the parameters into this function allows us to use undefined variables like exports in the file module. Finally, the require function returns the exports variable, completing the module loading process. Of course, the implementation in Node.js is much more complex than this; this is just a simple description. For details, please refer to Node.js: require source code: https://duktape.org/

Now that we know how require works, let’s implement a very simple require that only loads file modules from the file system and does not support caching or relative paths. If loading is successful, it returns the exports object of the module; otherwise, it returns undefined.

You can create a user component called jerry-module or write it directly in 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);
}

We define that each native module has an init method, starting with module. The middle part of the name indicates the module name. In the init method, we will register the APIs that the module needs to expose to JavaScript to the global variable, making them available for use in JavaScript. Below is the implementation of the require function.

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

Our implementation here is very simple:

  1. Require only accepts one parameter called name, which indicates the absolute path of the file module.

  2. Then use jerry_port_read_source to read the content of the file. Note that this function requires the jerryscript-port.h header file, and remember to release the file content using jerry_port_release_source after use.

  3. Next, check whether the file exists. If it does not exist or the file content is empty, return undefined, indicating that loading the module failed.

  4. Use jerry_parse_function to construct a JavaScript function. We only implement exports, module, __filename as parameters.

  5. Use jerry_create_object to construct a JavaScript object and use jerry_set_property to set the exports property of this object.

  6. Use jerry_call_function to execute the function with exports, module, filename as parameters, thus executing the file module. module.exports is a reference to exports.

  7. Finally, inside the file module, the value will be assigned to the exports variable, which is the API exposed by the module. We use jerry_get_property to return the exports property, completing the module loading.

Finally, we will call the module initialization function to register the module to the virtual machine after initializing it:

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();
}
index.js file from the file system, so it will automatically execute on boot. This is also very simple. After all operations are completed, use the file API to read the index.js file from the file system and then use jerry_run to execute it.

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

We specify the entry point as /spiffs/index.js. If loading fails, nothing happens. If loading is successful, we use jerry_parse to compile the JS code and then use jerry_run to execute it. Similarly, call this function during the startup process.

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

Now, let’s summarize what the startup process has accomplished:

  1. Mounted the spiffs file system.

  2. Initialized the JerryScript virtual machine.

  3. Registered the global print function for serial output.

  4. Installed the serial driver to pass input to the virtual machine for execution.

  5. Registered the module module.

  6. Loaded and executed the index.js file from the file system.

  7. Very importantly, used vTaskDelay to yield CPU time for other tasks to execute.

Now we have a JavaScript development board, but its functionality is limited, and the driver part and commonly used functional modules have not been implemented. Originally, I also wanted to introduce native modules and timers, but due to space limitations, I will not elaborate further here. For the complete source code, please refer to nodemcujs: https://github.com/nodemcujs/nodemcujs-firmware.

Finally, let me briefly introduce how to upload index.js and custom data to the file system:

  1. Use mkspiffs to create a file image.

  2. Use esptool.py flashing tool to flash the file image to the board.

For complete instructions on creating and flashing file images, please refer to nodemcujs on how to create file images: 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.

Exploring Hardware with JavaScript on ESP32

Leave a Comment

×