はじめに
NixOS と Neovim で ESP32-S3 の ESP-IDF 開発環境を作る話です。
かなりマニアックな組み合わせなので、想定読者は狭いと思います。
それでも、同じような環境で ESP32-S3 を触りたい方の参考になればうれしいです。
この記事では、Nix flake の devShell に ESP-IDF を載せ、direnv で普段のシェルへ合流させます。
あわせて、Neovim の clangd が ESP-IDF のヘッダを解決できる状態を作ります。
私自身、電子工作も ESP-IDF も今回が入口です。
そのため、この記事は「慣れている人の最短手順」ではなく、「初めて触った人間がどこで引っかかり、どう納得したか」の記録として読んでください。
誤りやもっとよい方法があれば、ご指摘いただけると助かります。
対象読者
- NixOS 上で ESP32-S3 の開発環境を作りたい方
- Neovim と clangd で ESP-IDF のコードを書きたい方
- 公式 installer ではなく Nix の devShell に開発環境を閉じ込めたい方
ゴール
この記事で作る状態は次の 2 つです。
- NixOS 上で ESP-IDF の hello world をビルドできる
- Neovim 上で clangd が ESP-IDF のヘッダを解決できる
この記事では、センサや BLE の実装までは扱いません。
まずは「プロジェクトに入ると ESP-IDF が使える」「エディタ上で include が壊れない」という土台を作ります。
これから作る環境
今回の環境は次の組み合わせです。
| 項目 | 内容 |
|---|---|
| OS | NixOS 26.04 |
| エディタ | Neovim 0.12.3 |
| LSP | clangd(clang-tools) |
| MCU | ESP32-S3 |
| パッケージ集 | Nixpkgs 25.11(devShell 用に固定) |
| シェルの環境管理 | Nix flake の devShell + direnv |
| ESP-IDF の供給 | nixpkgs-esp-dev(コミュニティ製 flake) |
| ビルドツール | CMake、Ninja |
| プログラミング言語 | C、Python(ESP-IDF のビルド用) |
OS は NixOS 26.04 ですが、devShell の Nixpkgs は 25.11 に固定しています。
理由は後述しますが、最新の Nixpkgs では nixpkgs-esp-dev の overlay が要求する Python 3.10 が削除済みで、そのままではビルドできなかったためです。
この記事で作る状態は、次のリポジトリにまとまっています。
今回使うハードウェア
ESP32-S3
ESP32-S3 は Espressif Systems の SoC です。
Wi-Fi と Bluetooth 5 (LE) を内蔵し、CPU には Xtensa LX7 のデュアルコアを採用しています。
GPIO が豊富で、USB OTG や軽量な機械学習処理向けのベクトル命令も使えます。
この記事では、その前段として hello world のビルドまでを扱います。
ブレークアウトボード
Freenove の ESP32 / ESP32-S3(WROVER/WROOM)用ブレークアウトボードを使用しています。
プロジェクトフォルダ作成
今回は参考例としてesp32s3-idf-nixos-sampleという名前で作成します。
> mkdir esp32s3-idf-nixos-sample
> cd esp32s3-idf-nixos-sample
gitの初期化も行います。
> git init
> git add .
> git commit -m "init project"
githubにリポジトリをpushします。自分はすでにghコマンドでGitHubの認証を終えているため、していない方はgh auth loginで行ってください。
> gh repo create --private --source=. --remote=origin --push
ESP-IDF を NixOS にいれる
ESP-IDF は、Espressif が提供する公式の開発フレームワークです。
中には、I2C や BLE のドライバ、FreeRTOS、CMake と idf.py によるビルドシステム、クロスコンパイラ、書き込みツールが一式で入っています。
通常であれば、公式の install.sh で必要なツールチェーンを入れます。
今回は、NixOS環境なのでこちらのnixpkgs-esp-dev を flakeを利用してdevShell に載せる形にしました。
> touch flake.nix
flake.nix は次のように書きました。
{
description = "ESP32-S3 ESP-IDF development shell";
inputs = {
# esp-dev の overlay は最新 nixos-unstable / 26.05 では python310 が削除済みで
# 壊れるため、python310 が残る安定版 (25.11) に固定する。
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
nixpkgs-esp-dev = {
url = "github:mirrexagon/nixpkgs-esp-dev";
# esp-idf を「この flake の nixpkgs」でビルドさせ、nixpkgs を 1 本化する。
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{ nixpkgs, nixpkgs-esp-dev, ... }:
let
system = "x86_64-linux";
pkgs = import nixpkgs {
inherit system;
# esptool 依存の python ecdsa は CVE-2024-23342 で insecure 指定。
# 手元のファーム署名/書き込み用途ではリスクが実質無いため許可する。
config.permittedInsecurePackages = [
"python3.13-ecdsa-0.19.1"
];
overlays = [ nixpkgs-esp-dev.overlays.default ];
};
in
{
devShells.${system}.default = pkgs.mkShell {
name = "esp32s3-idf-dev";
buildInputs = [ pkgs.esp-idf-xtensa ];
};
};
}
ここは nixpkgs-esp-dev の issue #109 に載っていた flake.nix をほぼそのまま参考にしています。
Nixpkgs は 25.11 に固定し、inputs.nixpkgs.follows = "nixpkgs"; で nixpkgs-esp-dev 側の Nixpkgs も同じものを使わせます。
python3.13-ecdsa-0.19.1 は Nixpkgs で insecure として扱われていたため、そのままでは devShell を評価できませんでした。
nixpkgs-esp-dev の README にも、Security note として「The Python ecdsa package used by esptool is marked as insecure」と書かれています。
そのため、#109 の例に合わせて config.permittedInsecurePackages に追加しました。
最後に、ESP32-S3 の CPU は Xtensa 系なので、ESP-IDF は esp-idf-xtensa を指定しました。
permittedInsecurePackages の許可は、この devShell の評価に閉じています。
システム全体の Nix 設定には追加していません。
direnv で普段のシェルに合流させる
devShell には nix develop でも入れます。
ただ、私の普段のシェルは zsh で、プロンプトやエイリアスもそこに寄せています。
nix develop で別のシェルに入るより、今のシェルに必要な環境変数だけが足されるほうが作業しやすいです。
そこで direnv を使います。
direnv は、ディレクトリごとに環境変数を読み込み、そこから出ると元に戻すツールです。
nix-direnv と組み合わせると、.envrc に use flake と書くだけで、そのプロジェクトの devShell が今のシェルに合流します。
home-manager で direnv を有効にする
direnv はシェルのフックとして動くため、最初にシェル側へ組み込んでおく必要があります。
私は home-manager で dotfiles を管理しているので、次のように設定しました。
home-manager を使っていない場合は、お使いの方法で direnv と nix-direnv を入れ、シェルにフックを追加してください。
# 私の場合: home-manager のモジュールとして追加した例
{ ... }:
{
programs.direnv = {
enable = true;
nix-direnv.enable = true;
enableZshIntegration = true;
};
}
enableZshIntegration = true によって、zsh の設定に direnv のフックが追加されます。
以降に開くシェルでは、ディレクトリを移動するたびに direnv が .envrc を確認します。
すでに開いているシェルがフック導入前のものなら、そのシェルにはまだ direnv が効いていません。
その場合だけ、次のコマンドで今の zsh にフックを読み込みます。
> eval "$(direnv hook zsh)"
プロジェクト側に .envrc を置く
プロジェクト直下に .envrc を置きます。
> touch .envrc
use flake
direnv は、許可していない .envrc を自動では実行しません。
リポジトリを取得しただけで未知のコードが動くのを防ぐためです。
そのため、各プロジェクトで初回に一度だけ許可します。
> direnv allow
.envrc を書き換えたときも、再度 direnv allow が必要です。
devShell が入ったことを確認する
devShell の環境が今のシェルに合流したか確認します。
> idf.py --version
ESP-IDF v5.5.2
版が表示されれば、use flake によって ESP-IDF 入りの devShell が読み込まれています。
ここまで来ると、プロジェクトのディレクトリへ入るだけで idf.py が使えるようになります。
ESP-IDF プロジェクトの骨組みを作る
devShell が使えるようになったので、ESP-IDF のプロジェクト本体を用意します。
この節を終えると、ディレクトリは次の形になります。
esp32s3-idf-nixos-sample/
├── flake.nix # devShell に ESP-IDF を載せる(作成済み)
├── flake.lock # 依存の固定(自動生成)
├── .envrc # direnv 用(作成済み)
├── CMakeLists.txt # この節で作る: プロジェクト全体の定義
├── sdkconfig.defaults # この節で作る: ターゲット = esp32s3
├── main/ # この節で作る: main コンポーネント
│ ├── CMakeLists.txt # main コンポーネントの定義
│ └── main.c # app_main(最初は ESP_LOGI だけ)
├── sdkconfig # idf.py build が自動生成
└── build/ # idf.py build が自動生成
自分で作るのは CMakeLists.txt、main/、sdkconfig.defaults の 3 つです。
sdkconfig と build/ は、ビルド時に自動生成されます。
まず、トップの CMakeLists.txt を置きます。
> touch CMakeLists.txt
cmake_minimum_required(VERSION 3.22)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
idf_build_set_property(MINIMAL_BUILD ON)
project(esp32s3-idf-nixos-sample)
include(...project.cmake) で ESP-IDF のビルド定義を読み込みます。
MINIMAL_BUILD は、main とその依存だけをビルド対象にして、ビルドを軽くする設定です。
つぎに、ターゲットを ESP32-S3 に固定するため sdkconfig.defaults を置きます。
> touch sdkconfig.defaults
CONFIG_IDF_TARGET="esp32s3"
sdkconfig.defaults は設定の初期値です。
idf.py がこれを元に、実際の設定ファイル sdkconfig(生成物)を作ります。
最後に main/ を作り、まずは動作確認用の最小のソースを置きます。
> mkdir main
> touch main/main.c main/CMakeLists.txt
#include "esp_log.h"
static const char *TAG = "hello_world";
void app_main(void)
{
ESP_LOGI(TAG, "boot");
}
idf_component_register(SRCS "main.c"
INCLUDE_DIRS "")
ここまでできたら、ターゲットを設定してビルドします。
> idf.py set-target esp32s3
> idf.py build
Project build complete. が出れば、Nix の devShell と ESP-IDF の組み合わせが成立しています。
この最小の main.c は ESP_LOGI しか使っていないため、追加の依存を書かなくてもビルドできます。
この点は、次の節で公式サンプルに差し替えたときに効いてきます。
Hello World をビルドする
ESP-IDF 公式のサンプルを、プロジェクトの main/main.c と main/CMakeLists.txt にコピーします。コピーするプログラムとファイルについて次のリンク先を参照してください。
examples/get-started/hello_world CMakeLists.txtのSRCSがリンク先と異なるので注意
main/main.c の差分(クリックで展開)
-#include "esp_log.h"
-
-static const char *TAG = "hello_world";
+/*
+ * SPDX-FileCopyrightText: 2010-2022 Espressif Systems (Shanghai) CO LTD
+ *
+ * SPDX-License-Identifier: CC0-1.0
+ */
+
+#include <stdio.h>
+#include <inttypes.h>
+#include "sdkconfig.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "esp_chip_info.h"
+#include "esp_flash.h"
+#include "esp_system.h"
void app_main(void)
{
- ESP_LOGI(TAG, "boot");
+ printf("Hello world!\n");
+
+ /* Print chip information */
+ esp_chip_info_t chip_info;
+ uint32_t flash_size;
+ esp_chip_info(&chip_info);
+ printf("This is %s chip with %d CPU core(s), %s%s%s%s, ",
+ CONFIG_IDF_TARGET,
+ chip_info.cores,
+ (chip_info.features & CHIP_FEATURE_WIFI_BGN) ? "WiFi/" : "",
+ (chip_info.features & CHIP_FEATURE_BT) ? "BT" : "",
+ (chip_info.features & CHIP_FEATURE_BLE) ? "BLE" : "",
+ (chip_info.features & CHIP_FEATURE_IEEE802154) ? ", 802.15.4 (Zigbee/Thread)" : "");
+
+ unsigned major_rev = chip_info.revision / 100;
+ unsigned minor_rev = chip_info.revision % 100;
+ printf("silicon revision v%d.%d, ", major_rev, minor_rev);
+ if(esp_flash_get_size(NULL, &flash_size) != ESP_OK) {
+ printf("Get flash size failed");
+ return;
+ }
+
+ printf("%" PRIu32 "MB %s flash\n", flash_size / (uint32_t)(1024 * 1024),
+ (chip_info.features & CHIP_FEATURE_EMB_FLASH) ? "embedded" : "external");
+
+ printf("Minimum free heap size: %" PRIu32 " bytes\n", esp_get_minimum_free_heap_size());
+
+ for (int i = 10; i >= 0; i--) {
+ printf("Restarting in %d seconds...\n", i);
+ vTaskDelay(1000 / portTICK_PERIOD_MS);
+ }
+ printf("Restarting now.\n");
+ fflush(stdout);
+ esp_restart();
}
CMakeLists.txt に PRIV_REQUIRES spi_flash を足す理由
hello world のサンプルをそのままビルドすると、main/CMakeLists.txt に PRIV_REQUIRES spi_flash を追加する必要がありました。
理由について調べてみると、これは ESP-IDF のコンポーネントの扱いによるものでした。
ESP-IDF では、main も 1 つのコンポーネントとして扱われます。
あるコンポーネントは、自分が依存宣言したコンポーネントのヘッダだけを #include できます。
ただし、全コンポーネントに自動で足される 共通依存 があります。
最初の最小 main.c が使っていた ESP_LOGI(log コンポーネント)は共通依存に含まれるため、依存を明示しなくてもビルドできました。
一方、hello world のサンプルは esp_flash_get_size() を使います。
この関数のヘッダ esp_flash.h は spi_flash コンポーネントが提供しており、共通依存には含まれていません。
そのため、main が spi_flash に依存すると明示しない限り、esp_flash.h が見つからずにビルドが失敗します。
今回 spi_flash は main.c の実装内だけで使います。
公開ヘッダに出す依存ではないため、REQUIRES ではなく非公開依存の PRIV_REQUIRES を使います。
main/CMakeLists.txt の差分(クリックで展開)
idf_component_register(SRCS "main.c"
+ PRIV_REQUIRES spi_flash
INCLUDE_DIRS "")
ビルドと書き込み
direnv で devShell に入った状態で、ターゲット設定とビルドを行います。
> idf.py build
Project build complete. と表示されれば成功です。
続けて、実機を USB 接続し、シリアルポートを確認します。
> ls /dev/ttyACM*
手元では /dev/ttyACM0 として見えていたので、次のように書き込みとモニタを実行しました。
> idf.py -p /dev/ttyACM0 flash monitor
次のような権限エラーが出る場合は、一時的にポートに対して読み書き権限を与えることで解消します。
> idf.py -p /dev/ttyACM0 flash monitor
Usage: idf.py [OPTIONS] COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]...
Try 'idf.py --help' for help.
Error: Invalid value for '-p' / '--port': Path '/dev/ttyACM0' is not readable.
> sudo chmod a+rw /dev/ttyACM0
モニタの終了は Ctrl-] です。
Hello world! とチップ情報が表示され、10 秒カウントダウンして再起動を繰り返せば成功です。
手元では、次のようなログがとれました。Restarting in 10 seconds... から Restarting in 0 seconds... までカウントダウンし、再起動しているのがわかります。
esptool.py v4.9.0
Serial port /dev/ttyACM0
Connecting....
Chip is ESP32-S3 (QFN56) (revision v0.2)
Features: WiFi, BLE, Embedded PSRAM 8MB (AP_3v3)
Crystal is 40MHz
MAC: 28:84:85:a5:b2:08
Uploading stub...
Running stub...
Stub running...
Changing baud rate to 460800
Changed.
Configuring flash size...
...
Hash of data verified.
Leaving...
Hard resetting via RTS pin...
Executing action: monitor
--- esp-idf-monitor 1.7.0 on /dev/ttyACM0 115200
--- Quit: Ctrl+] | Menu: Ctrl+T | Help: Ctrl+T followed by Ctrl+H
I (24) boot: ESP-IDF v5.5.2 2nd stage bootloader
I (145) cpu_start: Multicore app
I (156) app_init: Application information:
I (160) app_init: Project name: esp32s3-idf-nixos-sample
I (178) app_init: ESP-IDF: v5.5.2
I (221) spi_flash: detected chip: gd
I (253) main_task: Started on CPU0
I (263) main_task: Calling app_main()
Hello world!
This is esp32s3 chip with 2 CPU core(s), WiFi/BLE, silicon revision v0.2, 2MB external flash
Minimum free heap size: 392752 bytes
Restarting in 10 seconds...
Restarting in 9 seconds...
Restarting in 8 seconds...
Restarting in 7 seconds...
Restarting in 6 seconds...
Restarting in 5 seconds...
Restarting in 4 seconds...
Restarting in 3 seconds...
Restarting in 2 seconds...
Restarting in 1 seconds...
Restarting in 0 seconds...
Restarting now.
Neovim で clangd にヘッダを解決させる
ビルドが通っても、エディタ上では #include "esp_flash.h" などが赤くなることがあります。
これはファームウェアのビルド失敗とは別の問題です。
clangd は、ESP-IDF のビルドが使っている include path や define を自力では知らないからです。
そこで clangd に、CMake が生成する compile_commands.json を読ませます。
compile_commands.json とは
- プロジェクト内の各ソースファイルを「どのコンパイラで、どんな include path やマクロ、フラグでビルドするか」を 1 件ずつ記録した JSON です。
- CMake が
idf.py buildの中で生成し、build/compile_commands.jsonに置かれます。 - clangd がこれを読むことで、
#include "esp_log.h"のようなヘッダの実体を把握し、定義ジャンプや補完ができるようになります。
この節では、次の 3 つを設定します。
- clangd 本体を Neovim から使えるようにする
- clangd に Xtensa のツールチェーンを教える(
--query-driver) - プロジェクトに
.clangdを置いてcompile_commands.jsonを参照させる
clangd を用意する(Mason を使わない構成)
私は Neovim の LSP を Mason で管理せず、Nix(home-manager)側でまとめて入れています。
clangd と clang-format は clang-tools パッケージに含まれるので、これを Neovim に渡します。
home-manager を使っていない場合は、お使いの方法で clang-tools を Neovim から呼べるようにしてください。
# 私の場合: Neovim に渡すパッケージへ clang-tools を追加した例
programs.neovim = {
enable = true;
extraPackages = with pkgs; [
clang-tools # clangd と clang-format を含む
# ...ほかの LSP
];
};
Neovim を立ち上げ、コマンドラインで:echo exepath('clangd') と実行するとLSPが機能しているか確認できます。
clangd に Xtensa のツールチェーンを教える
clangd 本体は、Xtensa 向けの system include path や、ターゲット固有のマクロを標準では知りません。
そこで --query-driver を使い、ESP-IDF のクロスコンパイラ(xtensa-esp32s3-elf-gcc)に問い合わせて、それらを取り込ませます。
nvim-lspconfig の clangd 設定で、cmd に --query-driver を渡します。
-- nvim-lspconfig の clangd 設定に渡す例
clangd = {
cmd = {
"clangd",
"--background-index",
"--clang-tidy",
"--header-insertion=never",
-- Nix ストアのハッシュに依存しないよう glob で指定する
"--query-driver=**/bin/xtensa-*-elf-*,**/bin/riscv32-*-elf-*",
},
}
--query-driver が効くのは、その xtensa-esp32s3-elf-gcc が Neovim の PATH にあるときだけです。
このツールチェーンは devShell が提供するので、direnv で devShell に入ったディレクトリから Neovim を起動してください。
devShell の外で開くと、clangd はコンパイラを見つけられません。
プロジェクトに .clangd を置く
最後に、プロジェクト直下へ .clangd を置きます。
compile_commands.json の場所(build/)を指定し、あわせて汎用の clangd が解釈できない Xtensa 固有のフラグを取り除きます。
CompileFlags:
CompilationDatabase: build
Remove:
- -mlongcalls
- -mtext-section-literals
- -fno-tree-switch-conversion
- -fstrict-volatile-bitfields
- -fno-shrink-wrap
- -freorder-blocks
compile_commands.json には -mlongcalls のような Xtensa 用のフラグが入っています。
これらは汎用の clangd が理解できずエラーの原因になるため、Remove で落としておきます。
動作を確認する
direnv で devShell に入った状態のディレクトリから、Neovim でソースを開きます。
> nvim main/main.c
ESP_LOGI や #include "esp_log.h" の上で定義ジャンプ(gd)が効き、ESP-IDF のヘッダへ飛べれば成功です。
:LspInfo で clangd が attach していることも確認できます。
補足: nix develop から抜ける方法
direnv を入れる前に nix develop で devShell に入っていた場合、抜けるには exit を使います。
> exit
Ctrl-D でも抜けられます。
まとめ
- NixOS 上で ESP32-S3 の SDK である ESP-IDF を使用するには、Nixpkgs の固定と、Python の
ecdsaパッケージをpermittedInsecurePackagesで許可する設定が必要だった。 - direnv を利用することで、シェルの環境をプロジェクトフォルダに閉じることができる。
- Neovim の clangd には、
compile_commands.json、--query-driver、.clangdを用意すると、ESP-IDF のヘッダと Xtensa 向けのコンパイル設定を解決させられる。
参考
-
ESP-IDF Build System / Component Requirements …
REQUIRES/PRIV_REQUIRESと共通依存の説明 - nixpkgs-esp-dev … ESP-IDF を Nix パッケージとして提供する flake
- ESP-IDF hello_world サンプル … 今回コピーした題材