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

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


image.png



做者 | 提莫的神祕商店編輯 |  Yonie本文的主要目的是描述如何讓 ESP32 芯片運行 JavaScript,而且讓 web 前端開發人員也能玩轉硬件。html

做者以前是 web 前端開發工程師,因此文章會盡可能站在 web 前端開發工程師的角度,拋開底層的硬件知識,去掉一些目前不須要關心的,將重點放在軟件上。儘管這樣,咱們接下來所要作的是 硬件 + 軟件 的一個總體,因此一些基礎的 C 語言和硬件知識會讓你更好的閱讀此文章。沒有也沒關係,由於高深的我也不會阿!前端

文章會分爲 2 個篇幅進行講解。其中基礎篇會先介紹基礎知識,實戰篇將會介紹如何在 ESP32 芯片上面運行 JerryScript。node

基礎篇 ESP32 硬件介紹

首先先介紹一下 ESP32 是個什麼東西,簡單來講,它就是一塊集成了 WiFi、藍牙、天線、功率放大器、電源管理模塊、CPU、存儲器、傳感器的單片機微控制器,說人話就是:它能存儲並運行咱們的代碼,還具備 WiFi 和藍牙功能。先來看一張圖吧:  git

image.png

左邊一塊比較大的就是 ESP32 模組,上面提到的全部硬件配置都集成在這片模組上。下面的板子和其它元件是爲了方便開發以及芯片啓動必要的電路鏈接,而作成的一個開發板。這個開發板加了電源轉換芯片,使得支持 3.3 - 5V 電壓,右邊小的方塊型的是 cp2102 USB 轉串口芯片,使得咱們可使用 USB 線鏈接電腦。這個板子把引腳都引出來了,方便使用杜邦線鏈接各類外圍器件。下面咱們說的 ESP32 都表示這整塊開發板,而不是 ESP32 模組自己。
github

ESP32 採用兩個哈佛結構 Xtensa LX6 CPU 構成雙核系統,時鐘頻率在 80MHz - 240MHz 範圍內可調。片上集成了 520KB SRAM, 448KB ROM。擁有 41 個外設模塊,包含常見的 IIC, SPI, UART, PWM, IR, I2S, SDIO 等。常見的協議基本都有了,這使得咱們能夠更方便的和大部分電子模塊或外設進行通訊,而不須要像 51 單片機同樣,使用軟件模擬實現。好比常見的 SSD12864 oled 屏幕,同時具備 IIC 和 SPI 的接口。BLOX-NEO-6M GPS 模塊是使用的 UART 接口。直流電機和伺服機可使用 PWM 驅動。電風扇、空調等用的是 IR 紅外線傳輸信號。web

除此以外,ESP32 還集成了片上傳感器和模擬信號處理器。好比電容式觸摸傳感器,霍爾傳感器,ADC,DAC 等。若是咱們設計了有電池的產品,有了 ADC,咱們就能夠方便的測量電池的電量,雖然這樣測量的值不必定準。編程

以上是單片機系統中很常見的外設,ESP32 將它們都集成在一個片上系統中了。但 ESP32 最讓人激動的是,它集成了 WIFI 和 藍牙。有了 WIFI 和 藍牙,再配合各類外設,GPIO,咱們就能拿它作不少事情,好比溫度溼度傳感器的值直接上傳到服務器。遠程下發執行指令開關燈等,盡能夠發揮你的想象。api

但硬件編程對於軟件工程師來講卻實門檻有點高,尤爲像咱們 web 前端開發工程師,C 語言就是第一道門檻。我一直想將 JavaScript 帶到硬件編程中去,這樣咱們就可使用熟悉的 JavaScript 發揮咱們的創意。因此纔有了本篇文章。緩存

JerryScript 簡單介紹

Node.js 很強大,但它是創建在 V8 和 libuv 之上的, ESP32 片上 SRAM 只有 520KB,別說 v8 了,libuv 都跑不起來。因此咱們須要一個輕量的,爲嵌入式設計的 JavaScript 引擎,幸運的是,咱們有 JerryScript。bash

JerryScript 是一個輕量級的 JavaScript 引擎,它能夠運行在受限制的設備上,例如微控制器,它能在 RAM < 64 KB, ROM < 200 KB 的設備上運行。並且它還提供了完整的 ES5.1 語法支持,以及部分 ES6 語法支持,好比 箭頭函數,Symbol, Promise 等。在編程體驗上雖然沒有 v8 這麼爽,但有這些就已經很好了啊(相對於其它的嵌入式 JavaScript 引擎來講)!

還有一個重要的點是  JerryScript 的 api 更符合咱們的編程習慣,對於已經習慣編寫 Node.js addon 的人來講會更容易接受。因此以上 2 點,是咱們選擇 JerryScript 的理由。爲了讓你們更直觀的理解,下面咱們對比 2 個目前在嵌入式比較流行的 JavaScript 引擎。

 duktape

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

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

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

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

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

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

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

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

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

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

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

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

 /* Initialize engine */
 jerry_init (JERRY_INIT_EMPTY);

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

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

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

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

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

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

 /* Cleanup engine */
 jerry_cleanup ();

 return 0;
}

FreeRTOS 簡單介紹

FreeRTOS 是一個熱門的嵌入式設備用即時操做系統核心,它設計小巧且簡易,大部分的代碼由 C 語言編寫。它提供多任務,互斥鎖,信號量,和軟件定時器等功能,讓用戶能夠快速的進行應用程序設計。

以上是維基百科的介紹,簡單來講主要就是爲設計多任務的應用程序提供基本的工具庫,讓應用開發者能夠專一於邏輯的實現,而不必本身實現任務管理和調度。由於在單片機上編程是沒有像 Linux 同樣的多進程,多線程的概念的,單片機上電啓動後就從指定地址加載指令,按照順序執行完。

單片機通常來講只有一個處理器,同一時間只能處理一個任務,假如你想讓 2 個 LED 交替閃爍,那麼你必須在 while(true){...} 循環內手動控制 2 個 LED 邏輯代碼的執行時間,假如後續有 3 個,4 個,N 個呢?那麼全部的邏輯都得寫在裏面,會很是龐大。

FreeRTOS 的任務能讓各個邏輯跑在單獨的 Task 中互不干擾,各 Task 以優先級搶佔 CPU 時間。值得注意的是,即便使用了 FreeRTOS,整個應用仍然是單線程的,高優先級任務執行完後,必需要讓出 CPU 時間才能讓其它低優先級任務執行。記住,單片機只有一個處理器,同一時間只能處理一個任務。

整個 FreeRTOS 的任務是一個鏈表,從任務列表中取出最高優先級的任務執行,執行完後再取出下一優先級任務,一直循環。不過有幾點不一樣,FreeRTOS 永遠保證高優先級任務先執行,因此低優先級任務有可能沒有機會執行。每次執行完任務後,從列表中取出下一個任務時,都會從新計算優先級。執行中的任務只能由任務自身讓出 CPU 時間,不然其它任務沒有機會執行,固然除了中斷。FreeRTOS 是實時操做系統,你能夠精確控制各個任務的開始和結束時間。

實戰篇 讓 JerryScript 運行並接受串口輸入

以上介紹完了基礎知識,下面咱們開始讓 JerryScript 在 ESP32 上面跑起來,並讓串口接收用戶輸入,將它輸入 JerryScript 中執行。

首先須要準備好 ESP-IDF 開發環境,而後新建一個空的工程,我推薦從 idf-hello-world 新建。JerryScript 將做爲一個外部依賴放在deps/JerryScript 目錄。JerryScript 源碼地址:https://jerryscript.net/。

最終咱們的工程目錄是這樣的:

- build
- deps
 - jerryscript
- components
- main
- spiffs
- partitions.csv
- CMakeLists.txt
- sdkconfig
  • build 是咱們的構建目錄,構建過程當中的全部臨時文件都在這裏,方便清理。
  • deps 是存放第三方依賴的目錄,JerryScript 將做爲一個依賴,這樣方便管理,能夠和官方保持同步。

  • components 是存放用戶組件的目錄,咱們本身編寫的組件都放在這裏。

  • main 是一個特殊的組件,做爲應用的主程序。

  • spiffs 是存放內置文件系統的目錄,裏面的全部文件都會被打包成一個二進制文件,方便燒錄到芯片上。

  • partitions.csv 是分區表配置文件,每個應用都須要配置一個分區表,這裏使用默認的就好了。

  • CMakeLists.txt 是工程的主構建文件,整個工程的構建將從這裏開始。

  • sdkconfig 是工程的配置文件,能夠配置系統參數和一些用戶自定義的參數。

以上全部都準備好後,能夠開始寫代碼了。ESP32 的 CPU 型號是 Xtensa 32-bit LX6,因此咱們須要編寫 JerryScript 的交叉編譯,而後將靜態庫連接到 main 組件中去,這樣 JerryScript 才能運行起來。

下面是主 CMakeLists.txt 文件內容,主要是指定 JerryScript 的源碼目錄,這樣方便在其它組件內使用。而後設置 JERRY_GLOBAL_HEAP_SIZE爲 128KB。

JERRY_GLOBAL_HEAP_SIZE 表示 JerryScript 虛擬機預先申請的內存大小,在虛擬機啓動時就會向系統預先申請指定大小的內存。

由於 ESP32 內存總共才 520KB,而 JerryScript 默認的 heap_size 也是 512KB,這樣確定是編譯不過的,會報溢出錯誤。

cmake_minimum_required(VERSION 3.5)

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

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

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

主 cmake 編寫好後,接下來是編寫 main 組件的 cmake 文件。要使用 JerryScript 很是簡單,只須要連接 JerryScript 的靜態庫,而後配置正確的頭文件路徑。JerryScript 默認會編譯爲靜態庫,咱們在 main 組件中將它們連接就行。

下面是 main 組件的 CMakeLists.txt,內容有點多,這裏只選擇關鍵的講解,詳情請看 nodemcujs 項目:

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

上面是設置 JerryScript 的頭文件查找路徑。下面將進行 JerryScript 的交叉編譯,並把編譯後的靜態庫連接到 main 組件:

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

上面是設置交叉編譯的參數,針對 xtensa 處理器,不加這個參數連接通不過。尤爲注意 -mlongcalls 參數,此參數雖然被設置爲編譯參數,但它實際是做用在彙編的。若是你看到 dangerous relocation: call0: call target out of range 這個錯誤,多半是忘記加這個參數了。詳情請看 [xtensa-gcc-longcalls][] 編譯器的文檔。注意,這裏的都須要寫在 register_component() 後面,不然會報錯。

編譯參數設置好後,下面是使用 externalproject_add 將 JerryScript 做爲外部工程單獨編譯,不能使用 add_subdirectory,cmake 會報錯。

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

上面主要是將 JerryScript 設置爲 main 組件的依賴,這樣編譯 main 組件時會自動編譯 JerryScript。而後設置交叉編譯工具鏈。這裏須要特別注意,關閉 ENABLE_LTO=OFF,爲何?由於 JerryScript 裏面開了此選項後,會判斷編譯器 ID 是否爲 GNU,若是是的話,強制設置編譯器爲 GCC,致使咱們的交叉編譯工具鏈設置失效。

最後,咱們將編譯後的靜態庫連接到 main 組件:

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

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

JerryScript 編譯完後,會在編譯目錄的 main/jerryscript 下面生成最終文件,這個路徑是咱們上面本身指定的,咱們這裏只須要 jerry-core.a jerry-ext.a jerry-default-minimal.a 這三個靜態庫就好了。

${COMPONENT_NAME} 就是 main

下面編寫初始化代碼,在系統啓動時初始化 JerryScript 虛擬機。

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

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

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

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

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

初始化 JerryScript 很是簡單,只須要調用jerry_init(JERRY_INIT_EMPTY) 就能夠,如今咱們已經讓 js 虛擬機跑起來了。vTaskDelay 是 FreeRTOS 提供的函數,做用是讓出指定的 cpu 時間去執行其它任務,不至於將整個應用程序阻塞在這裏,1000 / portTICK_PERIOD_MS 表示 1000ms,這跟在 Linux 上使用 sleep(1) 是差很少的。portTICK_PERIOD_MS 表示 FreeRTOS 1ms 內執行的節拍,這跟 CPU 的頻率有關,詳情請參考 [FreeRTOS][] 文檔。

如今 JerryScript 的集成就已經完成了,能夠編譯出可執行的固件了:

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

若是沒有錯誤,會在編譯目錄生成可執行固件,使用 make flash 會自動將固件燒錄到 ESP32 芯片上。make flash 不須要額外的配置,能夠直接使用,它會調用內置的 [esptool.py][] 進行燒寫。

注意:燒錄固件時,須要先安裝串口驅動,某寶上面賣的板子質量良莠不齊,型號衆多,不少賣家不懂技術,本身賣的是什麼都不知道。通常來講,ESP32 都是 CP2102 的驅動,去官網下載驅動就好了。

具體的燒錄方法請查看 nodemcujs [燒錄固件][]。

若是編譯出錯,請從頭開始再來一遍。如今 JerryScript 已經跑起來了,可是咱們尚未 js 代碼執行,下面咱們將打開串口,讓從串口接收到的字符串輸入給 JerryScript 執行,而且將結果從串口輸出。

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

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

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

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

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

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

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

代碼有點多,將它拆成 2 個函數來看,handle_uart_input 函數負責安裝串口驅動,而後啓動一個 [Task][https://www.freertos.org/taskandcr.html] 來處理串口輸入。爲何要啓動一個 task ?由於串口輸入是異步的,咱們不能讓它阻塞,因此在新的 task 中採用 [esp-uart-events][] 的方式監聽事件,等有串口輸入的事件到來時再去讀取輸入並執行。

板子帶有一個 USB 轉串口芯片,芯片的引腳被鏈接到 UART_NUM_0,因此咱們能夠默認從這個串口讀取輸入,printf 默認也會從這裏輸出,這樣插上 USB 就能夠當作一臺 mini 的 JavaScript 開發板了,方便開發和調試。這正是動態語言在硬件編程上的魅力。

有了輸入,咱們還須要一個 native api 用於在 JavaScript 代碼中輸出數據,這裏咱們使用自帶的 print 就好了。在 JavaScript 代碼中能夠直接使用 print(message) 來輸出數據到串口。

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

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

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

使用 make flash 編譯更新後的固件,將它燒錄到板子上,如今打開串口,鏈接上板子,輸入 var msg = 'hello nodemcujs'; print(msg) 試試吧。你能夠輸入任意合法的 JavaScript 語句,使用 print 函數輸出數據。

注意:不要使用 minicom,可使用 [ESPlorer][]。由於咱們是將串口的輸入直接輸入虛擬機執行的,因此只接收可顯示字符和換行回車,其它字符好比控制字符會致使執行失敗。

完整代碼請查看 nodemcujs 源碼。

使用片上存儲芯片:flash

上面咱們已經實現了內嵌 JerryScript 虛擬機而且打通了串口交互,但每次重啓都會重置數據,這顯然不是一塊標準的開發板,本章節咱們將會對接文件系統用於存儲用戶數據。

ESP32 已經集成了一片 4MB 的 SPI 存儲芯片,SPI 是一種數據交換協議,咱們這裏不用太關心,感興趣的本身查找資料,下文咱們以 flash代指這個存儲芯片。

ESP-IDF 工程支持 spiffs 組件,咱們只須要拿來用就好了。要使用文件系統,有這些步驟是必需要作的:

  1. 分區表 - 劃分磁盤的用途,告訴系統有幾個分區,各個分區大小是多少。每片 ESP32 的 flash 能夠包含多個應用程序,以及多種不一樣類型的數據(例如校準數據、文件系統數據、參數存儲器數據等)。所以,咱們須要引入分區表的概念。

  2. mount - 讀取分區表配置,若是尚未被初始化,則對磁盤進行格式化

咱們基於默認的分區表上進行修改,新增一個 data 分區用於存儲用戶自定義數據。在項目根目錄新建 partitions.csv 文件,內容以下:

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

nvsphy_init 分區使用默認就行,factory 分區用於存儲 App,即編譯出來的可執行代碼,也能夠理解爲編譯出來的 bin 文件。咱們指定大小爲 1M,目前編譯出來的固件大小爲 500KB 左右,通常來講夠用了。

storage 分區是咱們新加的分區,用於存儲用戶自定義數據,offset 咱們這裏不填寫,會自動對齊上一個分區。大小指定爲 0x2F0000,差很少有 2.7M 可用空間。注意這是最大了,不能再大,ESP32 最多見的 flash 大小是 4MB,若是你的 flash 大小不同,能夠根據狀況修改,但不能超過度區大小,能夠小於。

ESP32 默認將分區表數據燒寫至 0x8000 地址處,長度爲 0xC00,最多能夠保存 95 個條目,分區表後面還保存有 MD5 校驗和,因此若是你不清楚整個分區表,不要亂改分區數據。詳細說明請看 [分區表][] 文檔。

注意:要使用用戶自定義分區表,須要在 sdkconfig 文件中指定,可使用 make menuconfig 圖形界面,具體方法以下:

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

執行 make menuconfig 後,會出現圖形界面,進入:Partition TablePartition Table 選擇 Custom partition table CSV。而後Custom partition CSV file 填寫 partitions.csv,注意這是你的分區表文件名,請根據你本身的狀況修改。

分區表製做好後,接下來咱們在啓動流程中 mount storage 分區:若是分區沒有被初始化,則格式化分區後再次加載,不然直接加載。而且將使用狀況打印出來。

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

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

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

 esp_err_t ret = esp_vfs_spiffs_register(&conf);

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

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

bash_path 咱們設置爲 /spiffs,這至關於根目錄前綴,之後訪問數據分區時都要使用 /spiffs/file,固然你能夠根據本身狀況修改。將format_if_mount_failed 參數設置爲 true,能夠在分區 mount 失敗後自動格式化,這種狀況通常是分區未被格式化。注意 spiffs 文件系統是沒有目錄概念的,/ 只是被當作一個文件名,後續咱們能夠本身模擬目錄的概念。

掛載分區後,咱們就可使用文件系統的 api 去讀寫文件了。咱們使用esp_spiffs_info 讀取文件系統信息,將總大小和已使用狀況打印出來。

最後,在啓動流程中調用這個函數:

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

從新編譯,而後燒寫,使用串口鏈接上板子查看打印出來的分區信息,若是看到成功打印出分區表數據,則說明文件系統掛載成功了,若是失敗了,則仔細檢查一遍哪裏出錯了。

實現 JS 文件模塊

上面咱們已經有了文件的概念了,那咱們就能夠編寫 js 文件模塊,而後使用 require 去加載文件模塊,而且開機自動加載執行 index.js 文件,這樣 JavaScript 開發者來講就能夠脫離 SDK 獨立開發了。固然涉及到硬件驅動部分仍是須要 SDK 支持,暴露接口給 JavaScript,這裏不進行細說。

先來看一下 Node.js 中的文件模塊長什麼樣:

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

這個模塊很簡單,只對外提供一個函數,函數裏面打印出自身的文件名。那麼如何使用這個模塊呢:

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

a()

只須要使用 require 函數加載這個模塊,賦值給一個變量,這個變量就引用了模塊的全部對外實現。由於咱們對外就暴露一個函數,因此能夠直接調用。那麼這裏的 module.exports 變量是從哪裏來的呢?__filename 又爲何會等於 a.js 呢?require 的返回值是怎麼來的呢?來簡單看一下 Node.js 是如何實現的。

當 require 一個文件模塊時,Node.js 會讀取文件的內容,而後將內容頭尾包裝一下,最終變成:

(function (exports, require, module, __filename, __dirname) {
 // 模塊源碼
})

把參數傳遞進去執行這個函數,因此咱們能夠在文件模塊中使用 exports 等未定義的變量,最後 require 函數將 exports 變量返回,就完成了一次模塊的加載。固然,Node.js 中的實現是比這個要複雜不少的,這裏只是簡單的描述一下,詳情請查看 Node.js: require 源碼:https://duktape.org/

知道了 require 是如何工做的,如今咱們來實現一個最簡單的 require,它只從文件系統中加載文件模塊,而且不支持緩存和相對路徑的。若是加載成功,則返回模塊的 exports 對象,不然返回 undefined。

能夠新建一個 用戶組件,叫 jerry-module,也能夠直接在 main 中編寫。

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

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

 jerry_release_value(global);
}

咱們規定每一個 native 模塊都有一個 init 方法,以 module 開頭,中間的 module 表示模塊名。在 init 方法中會給 global 變量註冊模塊自身須要暴露給 JavaScript 的 api,這樣 JavaScript 就可使用了。下面是 require 函數的實現。

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

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

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

 jerryx_handle_scope scope;
 jerryx_open_handle_scope(&scope);

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

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

 return escaped_exports;
}

這裏咱們的實現很是簡單:

  1. require 只接收一個參數叫 name,表示文件模塊的絕對路徑。

  2. 而後使用 jerry_port_read_source 讀取文件的內容,注意使用這個函數須要頭文件 jerryscript-port.h,使用完後記得使用jerry_port_release_source 釋放文件內容。

  3. 接着判斷文件是否存在,若是不存在或者文件內容爲空,則返回 undefined,表示加載模塊失敗。

  4. 使用 jerry_parse_function 構造一個 JavaScript 函數,咱們這裏只實現 exports, module, __filename 這三個參數。

  5. 使用 jerry_create_object 構造一個 JavaScript object,使用jerry_set_property 給這個 object 設置 exports 屬性。

  6. 使用 jerry_call_functionexports, module, filename 做爲參數執行函數,這樣文件模塊就會執行。module.exportsexports的引用。

  7. 最後,在文件模塊內部會賦值給 exports 變量,這就是模塊對外暴露的 api,咱們使用 jerry_get_propertyexports 屬性返回,就完成了模塊加載。

最後,咱們在虛擬機初始化後,調用模塊的初始化函數,將模塊註冊到虛擬機:

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

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

如今,咱們差最後一步:從文件系統中加載執行 index.js 文件,這樣開機啓動就會自動執行代碼了。實現這個也很簡單,在全部操做都完成後,使用文件 api 從文件系統讀取 index.js 文件,而後使用 jerry_run執行。

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

entry 的入口能夠本身修改,咱們指定 /spiffs/index.js。若是加載失敗,則什麼也不作。若是加載成功,則使用 jerry_parse 編譯 js 代碼,最後使用 jerry_run 執行。一樣,在啓動流程中調用這個函數。

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

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

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

如今,咱們整理一下啓動流程都作了什麼:

  1. mount spiffs 文件系統。

  2. 初始化 JerryScript 虛擬機。

  3. 註冊全局 print 函數用於串口輸出。

  4. 安裝串口驅動,將輸入傳遞給虛擬機執行。

  5. 註冊 module 模塊。

  6. 從文件系統加載 index.js 文件並執行。

  7. 很重要的一步:使用 vTaskDelay 讓出 CPU 時間供其它任務執行。

至此,咱們有了一個 JavaScript 開發板了,但功能有限,驅動部分以及經常使用的功能模塊都沒有實現。原本還想介紹一下 native 模塊和定時器的,篇幅有限,這裏就再也不詳細介紹了,完整的源碼請查看 nodemcujs: https://github.com/nodemcujs/nodemcujs-firmware。

最後再簡單介紹一下如何上傳 index.js 以及自定義數據到文件系統:

  1. 使用 mkspiffs 製做文件鏡像。

  2. 使用 esptool.py 燒錄工具將文件鏡像燒錄到板子。

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

相關文章
相關標籤/搜索