
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.
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:
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.
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 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;
}
#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;
}
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.
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.
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:
-
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.
-
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.
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:
-
Require only accepts one parameter called
name
, which indicates the absolute path of the file module. -
Then use
jerry_port_read_source
to read the content of the file. Note that this function requires thejerryscript-port.h
header file, and remember to release the file content usingjerry_port_release_source
after use. -
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.
-
Use
jerry_parse_function
to construct a JavaScript function. We only implementexports, module, __filename
as parameters. -
Use
jerry_create_object
to construct a JavaScript object and usejerry_set_property
to set theexports
property of this object. -
Use
jerry_call_function
to execute the function withexports, module, filename
as parameters, thus executing the file module.module.exports
is a reference toexports
. -
Finally, inside the file module, the value will be assigned to the
exports
variable, which is the API exposed by the module. We usejerry_get_property
to return theexports
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 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:
-
Mounted the spiffs file system.
-
Initialized the JerryScript virtual machine.
-
Registered the global print function for serial output.
-
Installed the serial driver to pass input to the virtual machine for execution.
-
Registered the module module.
-
Loaded and executed the index.js file from the file system.
-
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:
-
Use mkspiffs to create a file image.
-
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.