
2026/04/24 13:57
ESP32-S3 の第 2 コア上で ESP-IDF とともにネイティブ Rust を実行させる
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
本プロジェクトは、Core 0 に FreeRTOS を、Core 1 に無 OS の Rust ランタイムを組み合わせることで、双核 ESP32-S3 システムを実装する独自の設計を採用している。主たる成果は、OS オーバーヘッドなしで安全かつ低レベルなクロスコー通信を実現することにある。相互妨害を防ぐため、構成では Core 1 を
CONFIG_FREERTOS_UNICORE で明示的に停止し、SRAM の特定アドレス(内部 RAM 128KB)をアプリケーションスタックのために隔離する。データ交換は、内部 RAM に配置されたアトミック変数とカスタムアッセンブリ_trampoline を使用してコア間を安全にジャンプさせることで行われる。このセットアップは、標準的な静的リンカと、スタンドアロンバイナリを使用するホットスワップ可能なモードの区別を行っている。重要なのは、ファームウェアが Core 1 を再起動する前に、MMU を介して Rust パーティションを手動でマッピングすることである。今後の実行では、C ブートローダーがヘッダーを読み取り、間接ジャンプ用の Rust エントリーポイントを見つける。このアーキテクチャは双核ハードウェア上での高度な組み込みタスクを可能にすると同時に、ホットスワップ可能なコンポーネントを効果的に管理するためには、手動のメモリ管理と特定のフラッシュ手順が必要となる。本文
ESP32 シリコン上でホットスワップ可能な二范式環境の構築
私はすでに RP2350 と
no_std ラスト(Rust)と何年も付き合ってきたが、Rust がどのように設計されているのか、安全でありながら意外にもシンプルだということに本当に感銘を受けた。しかし、最新のプロジェクトでは Wi-Fi や Bluetooth Low Energy (BLE) が必要となり、RP2350 は内蔵の無線ハードウェアを持たないため、ESP32-S3 に乗り換えることになった。
ESP32-S3 は素晴らしいチップであるが、問題はここにある:Wi-Fi や Bluetooth の機能の大部分は、FreeRTOS を基盤とした C ベースの SDK である Espressif 製 ESP-IDF フレームワーク内にある。コミュニティ製の ESP-IDF 一部用の Rust ラッパーや、Espressif 自身による一部の Rust サポートはあるが、どちらも常に不確かな存在だ—成熟した C API に比べてドキュメントは薄いし、重要な機能が常に 1 つか 2 つ不足している。
つまり、私は二つの不完全なオプションの間で迷わされた:
- Rust に全投げる。 私が好む言語機能と crates を得られるが、ESP32-S3 上の
エコシステムはまだ若すぎる。出荷製品において、未熟な HAL(ハードウェア抽象化層)で未定義の動作に遭遇することを午前 2 時に引き起こすリスクは冒したくない。no_std - ESP-IDF (C) に全投げる。 信頼性のある Wi-Fi および BLE ス tack を得られるが、ビジネスロジック、音声処理、データ処理など Rust が本当に光り輝く分野では C で書くことになり、すべてが退屈になってしまう。
その時、思い出したのは ESP32-S3 に二つの CPU コアが存在することだった。
ESP-IDF の Kconfig 設定の中に
CONFIG_FREERTOS_UNICORE というオプションが埋められている。これを有効にすると、FreeRTOS は Core 0 上のみを実行し、Core 1 はただ… そのまま停止状態になり、何もせずにいる。それを見て私は思った:Core 0 を ESP-IDF(Wi-Fi, BLE, システムタスク)が所有し、Core 1 を目覚めさせて、RTOS とは完全に切り離された状態で独自の bare-metal Rust コードを実行させることはできないだろうか?
両コアは同じメモリ空間を共有しているので、両者間のデータ転送は比較的容易だ(ただし unsafe Rust を使用する必要がある)。また、Core 1 は FreeRTOS に管理されないため、時間厳守の音声処理ループを割り断するスケジューラも存在しない。
これが完全に非理性的ではないことを自分に納得させるために、いよいよ仕事に着手した。これがどのように組み合わさっているかをご紹介しよう。
パート 0: Bare コア上の静的リンクされた Rust
より簡単なアプローチから始めよう:Rust を静的ライブラリとして構築し、コンパイル時に ESP-IDF ファームウェアにリンクさせ、Core 1 を手動で起動して実行するものだ。これは他のすべての機能の基礎となる。
ステップ 1: Bare-Metal コアのためのメモリ確保(C サイド)
FreeRTOS の外で目覚めた Core 1 は、動的に割り当てられたスタックを獲得しない—そのコアには OS が存在しないためである。我々は ESP-IDF のヒープアロケータが接触しない RAM の一片を手動で確保する必要がある。
ESP-IDF はまさにこの用途のための
SOC_RESERVE_MEMORY_REGION マクロを提供している。これはブートローダーとメモリアロケータに、特定のアドレス範囲を禁帯域として扱うよう指示する:
#include "heap_memory_layout.h" // Core 1 のスタックおよびデータ用に内部 SRAM の 128KB を確保する。 // 二つの 16 進数値は、確保された領域の開始アドレスと終了アドレスを定義する。 // 0x3FCE9710 - 0x3FCC9710 = 0x20000 = 131072 bytes = 128KB。 // "rust_app" はデバッグ用のラベルに過ぎない—起動ログに表示される。 SOC_RESERVE_MEMORY_REGION(0x3FCC9710, 0x3FCE9710, rust_app);
なぜ 128KB か? これは組み込みスタックに加えて作業メモリとして十分な適切なデフォルト値である。Rust コードがどれだけ RAM を必要とするかによってこの範囲を調整することができる—ただし、アドレスは ESP32-S3 の内部 SRAM 領域内にあること、および ESP-IDF が使用している領域と重ならないように確認すること。
ステップ 2: C サイドから Core 1 を目覚める
これは Core 0 で実行されている主要な ESP-IDF アプリケーションの仕事だ:
- システムの設定(Wi-Fi, パーipherals など—私たちのテストケースでは単に起動)を行う。
- Core 1 を目覚め、我々の Rust コードを指すようにする。
- 通常の FreeRTOS の業務に従事する。
xTaskCreatePinnedToCore を使用せず、代わりに ESP32-S3 のハードウェアレジスタに直接アクセスして Core 1 を起動する。ブートアドレスを設定し、クロックを有効にし、ストールから解放し、リセットラインをパルスさせる。Core 1 は完全に FreeRTOS に依存せずに目覚める。
すべての動作が正常であることを確認するために、Core 0 は Core 1 の Rust コードでループ増量されている共有カウンター変数 (
RUST_CORE1_COUNTER) を読み取る。
#include <stdio.h> #include <stdint.h> #include "esp_log.h" #include "esp_cpu.h" #include "heap_memory_layout.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "soc/system_reg.h" #include "soc/soc.h" static const char *TAG = "rust_app_core"; // ヒープアロケータが使用しないようにメモリを確保する。 // (ステップ 1 と同じマクロ—コンパイルされた C ファイルに含まれている必要がある) SOC_RESERVE_MEMORY_REGION(0x3FCC9710, 0x3FCE9710, rust_app); // ---- 外部シンボル ---- // これらは他のファイルで定義され、リンク時に解決される: // rust_app_core_entry — Rust 関数(.a ライブラリから) // app_core_trampoline — スタックポインタを設定するアセンブリスタブ // _rust_stack_top — リンカスクリプトからのアドレス(確保された 128KB のトップ) extern void rust_app_core_entry(void); extern void ets_set_appcpu_boot_addr(uint32_t); extern uint32_t _rust_stack_top; extern void app_core_trampoline(void); /* * Core 1 を ESP32-S3 ハードウェアレジスタを直接操作することで起動する。 * これにより FreeRTOS は全くバイパスされる—Core 1 は我々のコードを実行し、 * スケジューラ、割り込み(設定しない限り)、OS なしで動作する。 */ static void start_rust_on_app_core(void) { ESP_LOGI(TAG, "Starting Rust on Core 1..."); ESP_LOGI(TAG, " Stack: 0x3FCC9710 - 0x3FCE9710 (128K)"); /* 1. リセット後に Core 1 が開始すべき場所を伝える。 * この ROM 関数は、CPU が起動時に読み取るレジスタにこのアドレスを書き込む。 * 私たちのアセンブリトラampoline を指し示す。 */ ets_set_appcpu_boot_addr((uint32_t)app_core_trampoline); /* 2. Core 1 のハードウェアレベルでの目覚めシーケンス。 * これらのレジスタ書き込みは、第 2 の CPU コアのためのクロック、ストール、リセット * 信号を制御する。 */ // クロックゲートを有効にする—Core 1 はクロック信号なしでは動作できない。 SET_PERI_REG_MASK(SYSTEM_CORE_1_CONTROL_0_REG, SYSTEM_CONTROL_CORE_1_CLKGATE_EN); // RUNSTALL ビットをクリアする。ストール状態のコアは命令の途中で凍結している。 CLEAR_PERI_REG_MASK(SYSTEM_CORE_1_CONTROL_0_REG, SYSTEM_CONTROL_CORE_1_RUNSTALL); // リセットラインにパルスを与える:有効にし、すぐに無効にする。 // これにより Core 1 は再起動し、上記で設定したアドレスにジャンプする。 SET_PERI_REG_MASK(SYSTEM_CORE_1_CONTROL_0_REG, SYSTEM_CONTROL_CORE_1_RESETING); CLEAR_PERI_REG_MASK(SYSTEM_CORE_1_CONTROL_0_REG, SYSTEM_CONTROL_CORE_1_RESETING); ESP_LOGI(TAG, "Core 1 released"); } // このカウンターは Rust コード内に存在する。AtomicU32 で、 // #[no_mangle] があるため、C のリンカはこの正確な名称でこれを発見できる。 extern volatile uint32_t RUST_CORE1_COUNTER; void app_main(void) { ESP_LOGI(TAG, "Core 0: Starting IDF app"); // Core 1 を目覚め、Rust コードを起動する start_rust_on_app_core(); // Core 0 は通常の FreeRTOS のように継続して動作する。 // ここでは共有カウンターを読み取って両コアが生存していることを証明するだけだ。 while (1) { ESP_LOGI(TAG, "Rust Core 1 counter: %lu", (unsigned long)RUST_CORE1_COUNTER); vTaskDelay(pdMS_TO_TICKS(1000)); // 1 秒ごとに表示 } }
ステップ 3: アセンブリトラampoline
CPU コアがリセットから目覚めた際、スタックは存在しない。そしてスタックが存在しない場合、C や Rust の関数を呼び出すことはできない—関数呼び出しには復帰アドレスやローカル変数を格納する場所が必要だからだ。
ESP32-S3 は Xtensa インストラクションセットアーキテクチャを使用しており、レジスタ
a1 がスタックポインタとして機能する。我々の小さなアセンブリスタブは、確保されたメモリの地址を a1 に読み込み、その後 Rust へジャンプするだけだ—まさにこれしかない—ただ二つの命令だ。
このコードは IRAM セクション(
.iram1)に配置されており、これは内部 RAM をマップしている。これは重要であり、なぜならコアが最初に起動する際、フラッシュキャッシュの設定がまだ行われていない可能性があるからだ。IRAM 内のコードは常にアクセス可能である。
app_core_trampoline.S
/* * app_core_trampoline.S * * Core 1 用の最小限の起動コード。スタックポインタを確保されたメモリ領域に設定し、 * その後 Rust のエントリーポイントにジャンプする。 * * コアリセット後にすぐに利用可能であるように、フラッシュキャッシュが構成される * 前に IRAM (.iram1) に配置する。 */ .section .iram1, "ax" /* "ax" = 割り当て可能 + 実行可能 */ .global app_core_trampoline .type app_core_trampoline, @function .align 4 /* Xtensa は 4 バイトのアライメントを必要とする */ app_core_trampoline: /* 確保された 128KB のスタックのトップをレジスタ a1 に読み込む。 * Xtensa ではスタックは下方向に成長するため、"top" は最高地址を意味し、 * スタックはこの地点から低いアドレスに向かって成長する。 */ movi a1, _rust_stack_top /* Rust のエントリー関数にジャンプする。call0 は "ウィンドウレス" 呼び出しで、 * レジスタウィンドウの回転なしであり、bare-metal スタートアップに適している。 * この関数は決して戻らない—無限ループを含む。 */ call0 rust_app_core_entry .size app_core_trampoline, . - app_core_trampoline
ステップ 4: CMake とリンカスクリプトで接続する
ESP-IDF は CMake をビルドシステムとして使用する。我々はそれを三つの追加物について知らせておく必要がある:我々のアセンブリファイル、事前コンパイルされた Rust ライブラリ、そして
_rust_stack_top の位置を定義するカスタムリンカスクリプトだ。
CMakeLists.txt
# C ソースおよびアセンブリトラampoline をコンポーネントソースとして登録する。 # ESP-IDF は "main/" 以下の各ディレクトリを "コンポーネント" として構築する。 idf_component_register( SRCS "main.c" "app_core_trampoline.S" INCLUDE_DIRS "." ) # コンパイルされた Rust 静的ライブラリ (.a ファイル) についてリンカに伝える。 # この .a ファイルは `cargo build` によって生成され、main/lib/ にコピーされる。 add_prebuilt_library(rust_app "${CMAKE_CURRENT_SOURCE_DIR}/lib/libesp_rust_app.a") # Rust ライブラリをコンポーネントにリンクする。INTERFACE は、このコンポーネントに依存するものはすべて # Rust のシンボルも受け取ると意味する。 target_link_libraries(${COMPONENT_LIB} INTERFACE rust_app) # カスタムリンカスクリプトを注入する。これによりアセンブリトラampoline は、 # _rust_stack_top の数値値を知る。 target_link_options(${COMPONENT_LIB} INTERFACE "-T${CMAKE_CURRENT_SOURCE_DIR}/rust_stack.ld")
rust_stack.ld
/* * カスタムリンカスクリプトフラグメント。 * * 確保された 128KB ブロックの END を _rust_stack_top として定義する。 * スタックは下方向に成長するため、"top" は最高地址である。 * アセンブリトラampoline はこの値をレジスタ a1 に読み込む。 */ _rust_stack_top = 0x3FCE9710;
ここでの接続は:リンカスクリプトがシンボル (
_rust_stack_top) を提供する → アセンブリトラampoline がそのシンボルを参照してスタックポインタを設定する → C コードがハードウェア起動シーケンスを開始して Core 1 をトラampoline で開始する、という流れだ。
ステップ 5: Bare-Metal Rust アプリケーション
最後に、Core 1 で実際に実行されるコードがある。それは完全に
no_std—オペレーティングシステム、アロケータ、標準ライブラリは存在しない。生硬件へのアクセスのみだ。
ここで重要な技法は
AtomicU32 である。原子操作は、二つのコアが同時に同じアドレスにアクセスしても安全な方法でメモリを読み書きする特別な CPU 命令だ。共有カウンターのために AtomicU32 を使用することで、ミュテックス(mutex)なしで競合条件を回避できる—実際には OS/bare-metal の境界間ではミュテックスは簡単に機能しないだろう。
spin_loop ヒントは CPU に「私は意図的にバジーウェイト中である」と伝える—一部のアーキテクチャ上ではこれは電力消費の削減や他のハードウェアスレッドへのリソース譲与を実現する。ここでは、瞬時にオーバーフローしないようにカウンターを遅らせるための単純な遅延としても機能する。
// no_std: Rust 標準ライブラリなしで動作中。 // 私たちの下には OS なし—ヒープなし、スレッドなし、println! ない。 #![no_std] // no_main: Rust の通常の main() エントリーポイントを使用しない。 // 代わりに、Core 1 はアセンブリから呼び出される rust_app_core_entry() によって進入する。 #![no_main] use core::panic::PanicInfo; use core::sync::atomic::{AtomicU32, Ordering}; // すべての no_std バイナリにはパニックハンドラが必要である。何かが間違っているとき(配列の範囲外、None の unwrap など)、 // この関数が呼び出される。 // デバッガーが接続されていない bare-metal コアでは、我々は何もできない—そのため無限ループにしているだけだ。 // 生産システムは LED を切り替えたり、Core 0 が読み取れる共有エラーフラグを書いたりするかもしれない。 #[panic_handler] fn panic(_info: &PanicInfo) -> ! { loop {} } // 共有カウンター。両コアはこの変数を同一メモリ空間に存在するため見ることができる。 // // #[unsafe(no_mangle)] は、Rust がコンパイル中にこのシンボルを改名することを防ぐ。 // それがない場合、Rust は"_ZN12esp_rust_app18RUST_CORE1_COUNTER17h..."のようなものを生成し、 // C コードはこの名称でこれを発見できないだろう。 // // AtomicU32 により、読み書きが CPU レベルで原子となるため、Core 0 は決して「破損した」(半分書き込まれた)値を見ることはない。 #[unsafe(no_mangle)] pub static RUST_CORE1_COUNTER: AtomicU32 = AtomicU32::new(0); // アセンブリトラampoline がスタックポインタを設定した後呼び出されるエントリーポイントである。 // `-> !` リターン型は「この関数は決して戻らない」と意味し、無限ループを実行する。 // // `extern "C"` は C 呼び出し規約を使用するため、アセンブリコード(および C リンカ)はこの関数を正しく呼び出せる。 #[unsafe(no_mangle)] pub extern "C" fn rust_app_core_entry() -> ! { loop { // カウンターを原子で 1 インクリメントする。 // Ordering::Relaxed は、この単一操作の原子性以外のメモリ順序保証が不要であることを意味する。 // (シンプルなカウンターの場合、Relaxed は十分だ) RUST_CORE1_COUNTER.fetch_add(1, Ordering::Relaxed); // バジーウェイトループとしての単純な遅延。spin_loop() は CPU ヒントで、 // 「私は本当に作業をしていないように回っている」と言う—一部のアーキテクチャ上ではこれは電力節約や他のハードウェアスレッドを餓死させるのを防ぐ。 for _ in 0..1_000_000 { core::hint::spin_loop(); } } }
ステップ 6: Rust ビルドの設定 (Cargo.toml)
ESP-IDF のビルドシステムは、標準 C に互換的な静的アーカイブ (.a ファイル) を期待する。デフォルトでは
cargo build は Rust 固有の .rlib ファイルを生成し、これは Rust ツールチェーンのみが理解できる。我々は Cargo に staticlib を出力するように伝える必要がある。
また、制限されたフラッシュを持つマイクロコントローラー上では、すべての Kilobyte が重要であるため、厳密なサイズ最適化を適用する。
Cargo.toml
[package] edition = "2024" name = "esp_rust_app" rust-version = "1.88" version = "0.1.0" # C に互換な静的ライブラリ (.a ファイル) を出力する。 # これは、Rust コードを ESP-IDF プロジェクトにリンクすることを可能にし、 # どのような C ライブラリと同様にリンクできるようにする。 [lib] crate-type = ["staticlib"] [dependencies] # esp-hal は ESP32-S3 用低レベルハードウェアアクセスを提供する。 # 私たちはまだその機能のほとんどを使用していないが、原子操作に必要となるクリティカルセクション実装を設定する。 esp-hal = { version = "~1.0", features = ["esp32s3"] } # no_std 環境での安全割り込み処理に必要なクリティカルセクション実装を提供する。 critical-section = "1.2.0" [profile.dev] # Rust のデフォルトデバッグビルドは未最適化であり、巨大なバイナリを生成する。 # 組み込み開発でも "s"(サイズ最適化)を使用すべき—これをなくすとフラッシュを溢れさせるかもしれない。 opt-level = "s" [profile.release] # コンプライラタに単一のコード生成ユニットを使用させる。これはコンパイルが遅いが、LLVM がクレイト全体を一気に見て、 # より良いクロス関数最適化(インライン化、死んだコードの削除)を実行することを可能にする。 codegen-units = 1 debug = 2 # デバッグシンボルを保持する(デバイス上の GDB に有用) debug-assertions = false # リリースで assert!() チェックを無効化 incremental = false # インクリメンタルコンパイルを無効化してクリーンなビルドのため # "太い" リンク時間最適化 (LTO)。リンカは ALL コード(依存関係を含む)を単一のユニットとして分析し、 # 使用されていない関数を激しく削除し、クレイト境界間でインライン化する。これは二進コードサイズを大幅に削減する— # LTO をなしのときより 30-50% 小さくなることが多い。 lto = 'fat' opt-level = 's' # スピードよりサイズの最適化 overflow-checks = false # リリースで整数オーバーフローチェックを無効化
ビルドとテスト
- Rust ライブラリをビルド:
# ESP32-S3 の Xtensa CPU をターゲットにして Rust コードをビルド。 # これは target/xtensa-esp32s3-none-elf/release/ に .a ファイルを生成する。 cargo build --release --target xtensa-esp32s3-none-elf # コンパイルされたライブラリを CMakeLists.txt が期待する場所にコピー。 cp target/xtensa-esp32s3-none-elf/release/libesp_rust_app.a \ /path/to/idf-project/main/lib/ - ESP-IDF プロジェクトをビルドしてフラッシュ:
idf.py build flash monitor
シリアルモニター上でカウンターがインクリメントしていることを確認できる—これが Core 1 が FreeRTOS に依存せずに我々の Rust コードを独立して実行している証明だ。
パート 1: 実行時にロードする Rust (ホットスワップ可能プログラム)
パート 0 の静的リンクアプローチは機能するが、限界がある:Rust コードはコンパイル時にファームウェアに焼き付けられている。我々が Rust プログラムを変更するたびに、完全な ESP-IDF プロジェクトを再ビルドし、すべてを再リンクし、完全にファームウェアを再フラッシュしなければならない。
どうすれば実行時に Rust プログラムをスワップできるだろう?考えてみろ:ESP-IDF ファームウェアはブートローダーのように振る舞い、ハードウェア環境(Wi-Fi, BLE, パーipherals)を設定する。Rust プログラムは独自のフラッシュパーティションに住んでおり、独立して更新できる。Core 0 は新しい Rust プログラムをフラッシュに書き込み、Core 1 をリセットして実行させることもでき—完全なファームウェア再ビルドなしで。
これは特に R コードがユーザー提供コンテンツである場合に有用だ—例えば、エンドユーザーが更新できるカスタマイズ可能な音声処理パイプラインなど。
これを実行するためには、いくつかの変更が必要になる。
ステップ 1: Rust を独立したバイナリとしてビルドする
パート 0 では、Cargo は ESP-IDF バイナリにリンクされる静的ライブラリ (.a ファイル) を構築した。今は Cargo に独立した実行可能バイナリを生成させ、独自のエントリーポイントを持ち、特定のメモリアドレスでロードされ飛び込むことができるものを作る必要がある。
まず、
[lib] セクションを Cargo.toml から削除して、Cargo がライブラリではなくバイナリを構築する:
(更新版)Cargo.toml
[package] edition = "2024" name = "esp_rust_app" rust-version = "1.88" version = "0.1.0" # [lib] セクションなし—我々はライブラリではなく独立したバイナリを望む。 # Cargo はエントリーポイントとして src/main.rs を探し求める。 [dependencies] esp-hal = { version = "~1.0", features = ["esp32s3"] } critical-section = "1.2.0" [profile.dev] # 組み込みでもデビルドはサイズ最適化を必要とする—未最適化 Rust は巨大なバイナリを生成し、フラッシュに収まらない。 opt-level = "s" [profile.release] codegen-units = 1 # LLVM 最適化のために単一のコード生成ユニット debug = 2 debug-assertions = false incremental = false lto = 'fat' # すべてのクレイトを横断した完全なリンク時間最適化 opt-level = 's' # サイズのための最適化 overflow-checks = false
次に、我々のバイナリをどのようにリンクするかを Rust ツールチェーンに伝えるために
.cargo/config.toml が必要になる。もはや ESP-IDF にリンクしていないため、独自のリンカスクリプトを提供し、標準起動コードを無効化する必要がある:
.cargo/config.toml
[target.xtensa-esp32s3-none-elf] rustflags = [ "-Clink-arg=-Tlink.x", # カスタムリンカスクリプトを使用 "-Clink-arg=-nostdlib", # C 標準ライブラリをリンクしない "-Clink-arg=-nostartfiles", # デフォルト起動コードを含めない "-Clink-arg=-Wl,--no-gc-sections", # すべてのセクションを保持(ゴミ回収しない) "-Clink-arg=-Wl,--no-check-sections", # セクション重なりチェックをスキップ "-Clink-arg=-mtext-section-literals", # Xtensa 特定:リテラルプールをインライン化 "-Clink-arg=-Wl,--entry=rust_app_core_entry", # ELF エントリーポイントを設定 ] [env] [build] # デフォルトビルドターゲット—毎回 --target を渡す必要なし target = "xtensa-esp32s3-none-elf" [unstable] # ターゲットのために `core` ライブラリをソースから構築する。 # Xtensa ターゲットは事前構築標準ライブラリを搭載していないため、 # Cargo は `core` そのものをコンパイルする必要がある。 build-std = ["core"]
リンカスクリプト
パート 0 では、Rust コードからの
.bss(未初期化グローバル変数)および .data(初期化されたグローバル変数)セクションは ESP-IDF リンカによって処理され、メインファームウェアのメモリレイアウトの一部になった。今は独立したバイナリを構築しているため、すべての場所を示すために独自のリンカスクリプトが必要になる。
これはパズルの重要な部分だ。リンカスクリプトは二つのメモリ領域を定義する:
FLASH_TEXT(コードがフラッシュに住んでいる場所で、MMU を通じて仮想アドレスにマップされる)および DRAM(SOC_RESERVE_MEMORY_REGION マクロで確保された 128KB の RAM)。
link.x
/* Rust エントリー関数を ELF エントリーポイントとして宣言 */ ENTRY(rust_app_core_entry) MEMORY { /* * FLASH_TEXT: 私たちのコードがアドレス空間にマッピングされる場所。 * 0x42400000 は仮想地址—MMU は実行時にフラッシュパーティションをこの領域にマップする(C で設定する)。 * 512K はほとんどの Rust プログラムには十分であるべきだ。 */ FLASH_TEXT (rx) : ORIGIN = 0x42400000, LENGTH = 512K /* * DRAM: SOC_RESERVE_MEMORY_REGION で確保された 128KB ブロック。 * これは両コアが直接アクセスできる物理的 SRAM である。 * 私たちのスタック、.data、および .bss はすべてここに存在する。 */ DRAM (rw) : ORIGIN = 0x3FCC9710, LENGTH = 128K } SECTIONS { /* * バイナリのオフセット 0 での 4 バイトヘッダー。 * これは単純な慣習:バイナリの最初の 4 バイトは rust_app_core_entry のアドレスを含む。 * C ブートローダーはここにジャンプすべきであることを知るためにこれを読む。 */ .header : { LONG(rust_app_core_entry) } > FLASH_TEXT /* * Xtensa は関数リテラルプール(インストラクションで使用される定数)を * .literal セクションに置く。私たちはエントリー関数のリテラルおよびコードを最初に入力し、 * バイナリの開始部近くにあることを保証する。 */ .entry_lit : { KEEP(*(.literal.rust_app_core_entry)) } > FLASH_TEXT .entry : { KEEP(*(.text.rust_app_core_entry)) } > FLASH_TEXT /* すべての残りのコードおよび不揮発データはフラッシュに入る */ .text : { *(.literal .literal.*) /* Xtensa リテラルプール */ *(.text .text.*) /* 実行可能コード */ *(.rodata .rodata.*) /* 読み取り専用データ(文字列、定数) */ } > FLASH_TEXT /* * .data: 初期化されたグローバル/静的変数。 * これらは実行時 (VMA) に DRAM で存在するが、初期値は * フラッシュ (LMA) に格納される。Rust スタートアップコードはそれらを * 使用前にフラッシュから RAM にコピーしなければならない。 * * "AT> FLASH_TEXT" の部分は意味する:「コンテンツをフラッシュに置くが、 * DRAM のアドレスのように割り当てる。」 */ .data : { _data_start = .; *(.data .data.*) _data_end = .; } > DRAM AT> FLASH_TEXT _data_load = LOADADDR(.data); /* .data コンテンツが住んでいるフラッシュアドレス */ /* * .bss: 未初期化グローバル/静的変数。 * NOLOAD はリンカがこのセクションのためにバイナリに何も格納しないことを意味し、 * スタートアップコードは起動時に領域をゼロ化する。 */ .bss (NOLOAD) : { _bss_start = .; *(.bss .bss.* COMMON) _bss_end = .; } > DRAM /* 私らが必要としないセクションを破棄する—バイナリのスペースを節約する */ /DISCARD/ : { *(.eh_frame) /* エキ셉ション処理フレーム (no_std で不使用) */ *(.eh_frame_hdr) *(.stack) *(.xtensa.info) /* Xtensa ツールチェーンメタデータ */ *(.comment) /* コンパイラバージョン文字列 */ } }
Rust から .data と .bss の初期化
Rust コードが ESP-IDF にリンクされたライブラリである間、IDF スタートアップコードは
.data をフラッシュから RAM へのコピーおよび .bss のゼロ化を処理した。今は独立しているため、自分自身で行う必要がある。これは静的またはグローバル変数をアクセスする前に起こらなければならない—そうでないとゴミを読み取るだろう。
// これらのシンボルはリンカスクリプト (link.x) によって定義される。 // データを含んでいない—彼らの *アドレス* がデータである。 // 例えば、&_data_start は .data が開始する RAM アドレスを与える。 unsafe extern "C" { static _data_start: u8; // .data の開始 (RAM) static _data_end: u8; // .data の終了 (RAM) static _data_load: u8; // .data の初期値の開始 (フラッシュ) static _bss_start: u8; // .bss の開始 (RAM) static _bss_end: u8; // .bss の終了 (RAM) } /// .data 初期値をフラッシュから RAM にコピーし、.bss をゼロ化する。 /// 静的/グローバル変数をアクセスする前に呼び出す必要がある。 unsafe fn init_sections() { // .data セクションが占めるバイト数を計算する let data_size = &raw const _data_end as usize - &raw const _data_start as usize; if data_size > 0 { // 初期値をフラッシュ(リンカがそれらを格納した場所)からコピーし、 // RAM(プログラムが実行時にそれらに期待する場所)へ。 core::ptr::copy_nonoverlapping( &raw const _data_load, // ソース:フラッシュ &raw const _data_start as *mut u8, // 宛先:RAM data_size, ); } // .bss セクションが占めるバイト数を計算する let bss_size = &raw const _bss_end as usize - &raw const _bss_start as usize; if bss_size > 0 { // .bss をゼロ化する。C および Rust はどちらも未初期化グローバルは // ゼロで始まると仮定する。これなしでは、それらは RAM に以前存在したものを保持し、 // ブートローダーからのゴミを含むだろう。 core::ptr::write_bytes(&raw const _bss_start as *mut u8, 0, bss_size); } }
Rust エントリーポイントの更新
我々の Rust バイナリがもはや ESP-IDF プロジェクトにリンクされていないため、C/Rust 境界間で名前によってグローバル変数を共有することはできない(共有リンカパスがない)。代わりに、両側は共有カウンターの固定メモリ地址について合意する。C サイドはそのアドレスから読み取る;Rust サイドはそこに書き込む。
このデモでは、我々が予約したメモリの開始領域 (
0x3FCC9710) をカウンターアドレスとして使用している。実際のシステムでは、より構造化されたアプローチを望む—おそらく固定地址にある共有ヘッダーで共有データのレイアウトを定義するもの。
// 共有カウンターの固定メモリ地址。 // C サイドおよび Rust サイドはこの地址について合意しなければならない。 // 私たちは予約した DRAM 領域の非常に最初を使用している。 const COUNTER_ADDR: usize = 0x3FCC9710; // #[unsafe(link_section = ".text.rust_app_core_entry")] はこの関数を特定リンクセクションに配置し、 // 見つけやすくする。 #[unsafe(no_mangle)] #[unsafe(link_section = ".text.rust_app_core_entry")] pub extern "C" fn rust_app_core_entry() -> ! { // まず初めに:静的任何东西を触る前に .data および .bss を初期化する。 // 私たちがこれをスキップすると、すべてのグローバル変数がゴミを含んでいるかもしれない。 unsafe { init_sections(); } // 共有カウンターの原子参照を作成する。 // 私たちは生メモリ地址を AtomicU32 ポインタにキャストする。 // これは unsafe な理由は、このアドレスが以下であることを我々が主張するため: // 1. 有効およびアライメントされている // 2. 他の任何东西のために使用されていない // 3. 両コアによってアクセス可能である let counter = unsafe { &*(COUNTER_ADDR as *const AtomicU32) }; // カウンターをゼロに初期化する(ゴミが残っている場合) counter.store(0, Ordering::Relaxed); loop { // 共有カウンターを原子でインクリメントする counter.fetch_add(1, Ordering::Relaxed); // バジーウェイト遅延(以前と同じ) for _ in 0..1_000_000 { core::hint::spin_loop(); } } }
ステップ 2: ESP-IDF プロジェクトを更新して実行時にバイナリをロードする
もはやリンクされたライブラリではなく独立したバイナリであるため、ESP-IDF サイドはいくつかの変更が必要になる。
フラッシュパーティションを作成する
Rust バイナリは独自のフラッシュパーティションを必要とする。我々はファームウェア(主要な ESP-IDF ファームウェアが住んでいる)の後の
rust_app エントリを追加:
partitions.csv
nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x1F0000, rust_app, data, 0x40, 0x200000, 0x80000,
rust_app パーティションはオフセット 0x200000(フラッシュ内 2MB)で開始し、サイズは 0x80000(512KB)だ。サブタイプ 0x40 は恣意的なカスタム値—ESP-IDF がすでに使用していないもの只要けばいい—後で見つけるために名前およびタイプによってパーティションを見つけることができるからである。
MMU を通じてパーティションをメモリにマップする
ESP32-S3 では、フラッシュ内のコードは直接実行可能ではなく、メモリ管理単位 (MMU) を通じて CPU アドレス空間にマップされる必要がある。これは通常 ESP-IDF がメインファームウェアに対して自動的に行うが、我々の独立した Rust バイナリでは手動で行う必要がある。
以下の関数は
rust_app パーティションをフラッシュに見つけ、ページ単位で仮想アドレス 0x42400000(リンカスクリプトがターゲットにする同じ地址)にマップする。マップ後、CPU はこの領域からコードを実行できる—通常のメモリのようにである。
#include <string.h> #include "esp_partition.h" #include "hal/mmu_hal.h" #include "hal/cache_hal.h" // Rust バイナリがマッピングされる仮想地址。 // これは link.x 内の FLASH_TEXT と合致する ORIGIN でなければならない。 #define RUST_VADDR 0x42400000 // バイナリのヘッダーから読み取るエントリーポイント地址を保持する uint32_t rust_entry_addr = 0; static void load_rust_app(void) { // partitions.csv で定義した "rust_app" パーティションを見つける。 // 私たちはタイプ (DATA) およびサブタイプ (0x40、我々のカスタム値) によって検索する。 const esp_partition_t *part = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, 0x40, "rust_app"); if (!part) { ESP_LOGE(TAG, "rust_app パーティションが見つからない!"); return; } // MMU はページで動作するため(ESP32-S3 では通常 64KB)、我々はその数を計算し、 // それぞれをマップする。 uint32_t page_size = CONFIG_MMU_PAGE_SIZE; uint32_t pages = (part->size + page_size - 1) / page_size; // 切り上げ uint32_t actual_mapped_size = 0; for (uint32_t i = 0; i < pages; i++) { uint32_t mapped = 0; // 1 ページをマップする:仮想地址 → 物理フラッシュ地址 mmu_hal_map_region(0, MMU_TARGET_FLASH0, RUST_VADDR + (i * page_size), // 仮想地址 part->address + (i * page_size), // フラッシュ地址 page_size, &mapped); actual_mapped_size += mapped; } // この領域のためのキャッシュを無効化し、CPU が以前のマップから陳腐なデータを提供しないようにする。 cache_hal_invalidate_addr(RUST_VADDR, part->size); ESP_LOGI(TAG, "Rust app at 0x%lx (%lu bytes, flash 0x%lx) にマッピング", (unsigned long)RUST_VADDR, (unsigned long)actual_mapped_size, (unsigned long)part->address); }
ブート関数を更新する
start_rust_on_app_core 関数はもはや Core 1 を目覚める前にフラッシュから Rust バイナリをロードする。それはバイナリの最初の 4 バイト(.header セクション)からエントリーポイント地址を読み取り、アセンブリトラampoline が読み取るためにグローバル変数に格納する。
static void start_rust_on_app_core(void) { // ステップ 1: フラッシュから Rust バイナリをアドレス空間にマップする load_rust_app(); // ステップ 2: バイナリの 4 バイトヘッダーからエントリーポイントを読み取る。 // 私たちのリンカスクリプトは LONG(rust_app_core_entry) をオフセット 0 に配置し、 // ため RUST_VADDR での最初の 4 バイトは関数の地址を含む。 uint32_t entry = *(volatile uint32_t *)RUST_VADDR; rust_entry_addr = entry; // グローバルに格納してトラampoline が読むために ESP_LOGI(TAG, "Rust entry at 0x%lx", (unsigned long)entry); // ステップ 3: 以前と同じハードウェアブートシーケンス ets_set_appcpu_boot_addr((uint32_t)app_core_trampoline); SET_PERI_REG_MASK(SYSTEM_CORE_1_CONTROL_0_REG, SYSTEM_CONTROL_CORE_1_CLKGATE_EN); CLEAR_PERI_REG_MASK(SYSTEM_CORE_1_CONTROL_0_REG, SYSTEM_CONTROL_CORE_1_RUNSTALL); SET_PERI_REG_MASK(SYSTEM_CORE_1_CONTROL_0_REG, SYSTEM_CONTROL_CORE_1_RESETING); CLEAR_PERI_REG_MASK(SYSTEM_CORE_1_CONTROL_0_REG, SYSTEM_CONTROL_CORE_1_RESETING); ESP_LOGI(TAG, "Core 1 released"); }
メイン関数を更新する
もはや名前によって
RUST_CORE1_COUNTER に参照できない(Rust バイナリが C プロジェクトにリンクされていないため)ため、我々はその既知メモリ地址から直接カウンターを読み取る:
// Rust コードはそのカウンターをこの固定地址に書き込む。 // 両側はこのことについて合意しなければならない—それは Rust コードで COUNTER_ADDR と定義されている。 #define RUST_COUNTER_ADDR 0x3FCC9710 void app_main(void) { ESP_LOGI(TAG, "Core 0: Starting IDF app"); start_rust_on_app_core(); // 共有カウンターの揮発するポインタを作成する。 // "volatile" は C コンパイラに伝える:「この値はいつでも変化できる(別の CPU コアが書き込んでいるため)ため、常にメモリから読み取る—レジスタにキャッシュしない。」 volatile uint32_t *counter = (volatile uint32_t *)RUST_COUNTER_ADDR; while (1) { ESP_LOGI(TAG, "Rust Core 1 counter: %lu", (unsigned long)*counter); vTaskDelay(pdMS_TO_TICKS(1000)); } }
アセンブリトラampoline を更新する
トラampoline はもはや
call0 rust_app_core_entry を使用できない—このシンボルが C プロジェクトのリンカ段階に存在しないため。代わりに、C コードが填充したグローバル変数 rust_entry_addr からエントリー地址を読み取り、間接ジャンプを実行する:
/* * app_core_trampoline.S (実行時ロード用更新版) * * 以前と同じ仕事:スタックポインタを設定し、その後 Rust にジャンプ。 * しかし今、Rust エントリー地址はリンカ時に知られていない—C コードによって * rust_entry_addr グローバル変数に格納されている。 */ .section .iram1, "ax" .global app_core_trampoline .type app_core_trampoline, @function .align 4 app_core_trampoline: /* スタックポインタを設定する(以前と同じ) */ movi a1, _rust_stack_top /* グローバル変数からエントリー地址をロードする。 * movi は rust_entry_addr の地址を a2 に読み込む、 * その後 l32i はその地址の値を a0 に読み込む。 */ movi a2, rust_entry_addr l32i a0, a2, 0 /* a0 = *(rust_entry_addr) */ /* Rust エントリーポイントに間接ジャンプ */ jx a0 .size app_core_trampoline, . - app_core_trampoline
ステップ 3: ビルドとフラッシュ
今では我々は二つの独立したビルドステップを持っている—one は Rust バイナリ用、もう一つは ESP-IDF ファームウェア用—and 二つの独立したフラッシュステップだ。
ESP-IDF サイドをビルドしてフラッシュ:
# ESP-IDF プロジェクトをビルド(もはや Rust コードは一切含まれない) idf.py build # メインファームウェアおよびパーティションテーブルをフラッシュする idf.py flash
Rust バイナリをビルドしてフラッシュ:
# 独立した Rust バイナリをビルド cargo build --release --target xtensa-esp32s3-none-elf # ELF フォーマットから生バイナリに変換する。 # ELF ファイルはメタデータ(セクションヘッダー、デバッグ情報など)を含み、我々が不要なもの—objcopy はすべてをstripsし、CPU が実行する生マシンコードのみを出力する。 xtensa-esp32s3-elf-objcopy -O binary \ 'target/xtensa-esp32s3-none-elf/release/esp_rust_app' \ rust_app.bin # 生バイナリを rust_app パーティションにフラッシュする。 # 0x200000 は partitions.csv で定義したオフセットだ。 esptool.py --port /dev/ttyACM0 write_flash 0x200000 rust_app.bin
二つのフラッシュステップは独立している。Rust バイナリを更新するには ESP-IDF ファームウェアを再ビルドまたは再フラッシュする必要がない—単に新しい
rust_app.bin を同じパーティションオフセットにフラッシュするだけだ。
動作が確認されているか
シリアルモニターを開く (
idf.py monitor または 115200 baud の任意のターミナル) し、このような出力を見られるべきだ:
ESP-ROM:esp32s3-20210327 Build:Mar 27 2021 rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT) ... I (47) boot: パーティションテーブル: I (50) boot: ## ラベル 用途 タイプ ST オフセット レングス I (56) boot: 0 nvs WiFi データ 01 02 00009000 00006000 I (62) boot: 1 phy_init RF データ 01 01 0000f000 00001000 I (69) boot: 2 factory ファクトリアプリ 00 00 00010000 001f0000 I (75) boot: 3 rust_app 不明なデータ 01 40 00200000 00080000 I (82) boot: パーティションテーブルの終了 ... I (202) heap_init: 初期化中。動的割当てのための RAM 利用可能: I (209) heap_init: At 3FC93BD8 len 00035B38 (214 KiB): RAM I (214) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM I (219) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM I (224) heap_init: At 600FE000 len 00001FE8 (7 KiB): RTCRAM ... I (279) main_task: app_main() を呼び出す。 I (279) rust_app_core: Core 0: IDF アプリ開始 I (280) rust_app_core: Rust app at 0x42400000 にマッピング (524288 bytes, flash 0x200000) I (283) rust_app_core: Rust entry at 0x42400024 I (287) rust_app_core: Core 1 released I (291) rust_app_core: Rust Core 1 counter: 34538 I (1295) rust_app_core: Rust Core 1 counter: 12369571 I (2295) rust_app_core: Rust Core 1 counter: 24670917 I (3295) rust_app_core: Rust Core 1 counter: 36972284 I (4295) rust_app_core: Rust Core 1 counter: 49273651
この出力で確認すべきことは:
- パーティションテーブルがオフセット
に我々の0x200000
パーティションを示していること。rust_app
ログは、確保された 128KB 領域(heap_init
で開始)が動的割当て利用可能としてリストされていない—0x3FCE9710
が機能した。SOC_RESERVE_MEMORY_REGION- MMU マッピングが成功—Rust バイナリは
にマップされている。0x42400000 - カウンターが増量しており、Core 1 は生きており、Rust を実行し、合意されたメモリ地址を通じて原子カウンターを介して Core 0 とデータを共有していること。
次に何が
このセットアップは両方の世界最善を与える:ESP-IDF および FreeRTOS が Core 0 で Wi-Fi, BLE, およびシステムタスクを管理する一方、Core 1 はゼロスケジューラ干渉で完全スピードで bare-metal Rust コードを実行する。データは原子操作を通じた共有メモリを通じてそれら間を流れる。
ここからは多くの方向に進めることができる:
- Core 1 での割り込みの設定。
- コア間の適切な共有メモリプロトコルの構築。
- Rust プログラムがクラッシュした場合のエラー回復の実装。
- Core 0 が Wi-Fi を通じて Rust バイナリを更新し、Core 1 をホット再起動する機能の追加。
ESP32-S3 のデュアルコアアーキテクチャは、関心の分離—for と非常に異なる二つのソフトウェアパラダイムを並行して実行するため—驚くほどクリーンな境界に turned out to be です。