これは #NervesJP Advent Calendar 2025 の23日目です。
はじめに
このところ Pub/Sub のプラットフォーム Zenoh に注目しております。Zenoh の Elixir API である Zenohex の超入門も書いてみました(参考文献参照)。これは Nerves で使う場合も難しくなくそのまま導入できます。
上でご紹介した導入の方法は Zenoh を client というモードで使うもので、全部のノードが同一LAN内にある場合にはこれだけで使えます。これが間にNAPTが入ってるWiFiルータ越しにとか、インターネットを使って広域分散でとか、ちょっとネットワークが複雑になるともうひとひねり必要になります。これに対応するためのモードには peer モードとか router モードがあって、これを使うには zenohd という deamon (unix process) を用意します。
zenohd は作るのもいごかすのもそんなに難しくないです。何かと Elixir するときには Nerves でいごかしてしまう自分に取っては Nerves で zenohd がいごけば何かと楽ちんです。そこで今回 Nerves にも組み込んでみたのでご紹介します…
…と記事を書こうとしたんですが、いごかねぇ。どうにもいごかねぇ。τゎぃぇ、記事を落としたままにするのも忍びないので、目次の残骸は残しておいて、冬休みの宿題にします。
…と思ってたのが、結局、夏休みの宿題になってしまいました。
zenohd を使わなくて良い場合
Zenoh client として Nerves を使う場合は zenohd がなくても良いです。
mix.exs ファイルに依存関係を書いて mix deps.get して、あとは Elixir プログラミングをお楽しみください。
defp deps do
[
+ {:zenohex, "~> 0.7.1"}
# {:dep_from_hexpm, "~> 0.3.0"},
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
]
end
zenohd を使うと複雑なネットワークでも使えるようにする
ネットワークが複雑になる場合、ルータを越えたりするための技がいくつかあります。その一つに zenohd を使うという手があります。ただし zenohd は全部のノードに存在する必要はないです。のであえてエッジノードに実装しなくても、同一LAN内に Nerves ではない Elixir マシンがあって、それが zenohd を喋るのでも構いません。むしろこの方が設計としては普通かもです。
ただし、ネットワーク内のどこかに zenohd が必要となったときに、ちょいとネットワーク内に準備できると簡単でよろしいです。ということで今回は Nerves に zenohd を入れてみます。
実装
今回は Nerves を起動するだけで zenohd が使えるように、Nerves の電源ONで自動的に立ち上げるようにします。
今回はのターゲットマシンは Raspberry Pi2 model B (RPi2)とBeagleBone 系のマシンです。BeagleBone は今回も BeagleBone Green (BBG) を使います。
おおまかに以下で実装します。
-
zenohdのバイナリ本体をrootfs_overlayでファームウェアイメージに埋め込む - アプリ起動時にそのバイナリを UNIX daemon として起動する
前者、これらのアーキテクチャではゼロからRustのクロスビルド環境を用意する羽目になりました。RPi4/5 は既存のバイナリを流用するだけで済んだはずです。
後者はいくつか方法がありますが、今回は muontrap を使ってみました。初体験です。
Unixバイナリの自動起動
zenohd のバイナリの話は重たいので、先にこちらの話をします。
自動起動で muontrap を使うにはlib/nerves_zenohd/application.ex の start/2 の中で MuonTrap.Daemon を子プロセスにします。
defmodule NervesZenohd.Application do
use Application
require Logger
@target Mix.target()
@zenohd_bin "/usr/bin/zenohd"
@zenohd_config "/etc/zenoh/config.json5"
@impl true
def start(_type, _args) do
children = zenohd_children() ++ target_children()
Supervisor.start_link(children, strategy: :one_for_one, name: NervesZenohd.Supervisor)
end
defp zenohd_children() do
cond do
@target == :host ->
[]
File.exists?(@zenohd_bin) ->
[{MuonTrap.Daemon, [@zenohd_bin, ["--config", @zenohd_config], [name: :zenohd_daemon]]}]
true ->
Logger.warning("#{@zenohd_bin} not found for target #{@target}; skipping zenohd startup")
[]
end
end
end
File.exists?/1 でバイナリの有無を見てます。まだ zenohd を用意できていないアーキテクチャのターゲットに入れて間違えてブートしたとき、対応するバイナリが無ければ警告ログを出すだけにして先に進みます。
バイナリの組み込み方
zenohd はCPUアーキテクチャに依存するネイティブバイナリなので、ターゲットのアーキテクチャごとに別のファイルが必要です。共通のファイル(設定ファイルなど)と、ターゲット専用のバイナリを別ディレクトリに分けて、config/config.exs 側で合成するようにしました。
config :nerves,
:firmware,
rootfs_overlay:
["rootfs_overlay", "rootfs_overlay-#{Mix.target()}"]
|> Enum.filter(&File.dir?/1)
|> Enum.map(&Path.expand/1)
rootfs_overlay/etc/zenoh/config.json5 # 共通
rootfs_overlay-rpi2/usr/bin/zenohd # rpi2専用
rootfs_overlay-bbb/usr/bin/zenohd # bbb専用
共通の config.json5 は以下の内容です。
{
mode: "peer",
listen: {
endpoints: ["tcp/0.0.0.0:7447"]
},
}
Enum.filter(&File.dir?/1) で実在するディレクトリだけを拾うようにしてあるので、zenohd をまだ用意していないターゲット(rpi3 とか)でも mix firmware はちゃんと通ります。その場合は先程の File.exists?/1 により「起動はするけどzenohdは動かない」ファームウェアになります。
zenohd バイナリの準備
こちらがなかなか大変でした。簡単な方から順に。
RPi4だったら簡単なはずだったバイナリ構築
RPi4 とか RPi5 とかを使えば、手元に別途あった zenohd(armv7・glibcビルド)がちょうどこの armv7-nerves-linux-gnueabihf と一致していたので、クロスビルドせず rootfs_overlay-rpi2/usr/bin/zenohd にそのまま置くだけで済んだはずでした。
| ターゲット | ツールチェーン |
|---|---|
| rpi4, rpi5, rpi0_2 | aarch64-nerves-linux-gnu |
| rpi2, rpi3, bbb | armv7-nerves-linux-gnueabihf |
| rpi, rpi0 | armv6-nerves-linux-gnueabihf |
実機で動かしてないのですが、RPi4 用のバイナリらしきものができました。
% MIX_TARGET=rpi2 mix deps.get # nerves_toolchain_armv7_nerves_linux_gnueabihf + nerves_system_rpi2 を取得
% MIX_TARGET=rpi2 mix firmware
% MIX_TARGET=rpi2 mix burn # または mix upload
.fw ファイルの実体はzipアーカイブで、data/rootfs.img がsquashfsイメージです。
% unzip -j firmware.fw data/rootfs.img
% unsquashfs -f -d extracted rootfs.img usr/bin/zenohd
% file extracted/usr/bin/zenohd
extracted/usr/bin/zenohd: ELF 32-bit LSB pie executable, ARM, EABI5 version 1 (SYSV), ...
ゼロからクロスビルドする
BBB系列のアーキテクチャやRPi2の場合は armv7-nerves-linux-gnueabihf(32bit, hard-float)で、RPi4系とはアーキテクチャが違うため使い回せません。手元にRustのクロスビルド環境が無かったので、一から用意しました。
rustupとarmv7ターゲットの導入
普段使ってない環境を導入するので Claude に協力してもらいながらドキドキで導入します。
% brew install rustup
% rustup-init -y --default-toolchain stable --profile minimal
% rustup target add armv7-unknown-linux-gnueabihf
Nerves純正ツールチェーンの取得
ここが今回のキモです。クロスビルドのリンカには、汎用のarmv7クロスコンパイラではなくBBBのNervesシステムが実際に使っているツールチェーンそのものを使います。なのでバイナリのコンパイルする前に、先に mix deps.get します。
% MIX_TARGET=bbb mix deps.get
# nerves_toolchain_armv7_nerves_linux_gnueabihf を ~/.nerves/artifacts/ にダウンロード
cargoにリンカを教える
zenoh のソース側の .cargo/config.toml に追記します。
この辺になるとわけがわからず、Claude さんの言うがままにやって結局うまく行った感じです。
[target.armv7-unknown-linux-gnueabihf]
linker = "/Users/kikuyuta/.nerves/artifacts/nerves_toolchain_armv7_nerves_linux_gnueabihf-darwin_arm-13.2.0/bin/armv7-nerves-linux-gnueabihf-gcc"
ar = "/Users/kikuyuta/.nerves/artifacts/nerves_toolchain_armv7_nerves_linux_gnueabihf-darwin_arm-13.2.0/bin/armv7-nerves-linux-gnueabihf-ar"
ビルド
ようやく cargo でバイナリを作成します。
% cd zenoh
% cargo build --release --bin zenohd --target armv7-unknown-linux-gnueabihf
Finished `release` profile [optimized] target(s) in 3m 20s
3分ちょっとで終わりました。中身はこんなふうに出来てます。
% file target/armv7-unknown-linux-gnueabihf/release/zenohd
... ELF 32-bit LSB pie executable, ARM, EABI5 version 1 (SYSV),
dynamically linked, interpreter /lib/ld-linux-armhf.so.3, ...
アーキテクチャの確認とoverlayへの配置(BBBの場合)
これを rootfs_overlay-bbb という名前のディレクトリを作って、そこに置きます。
% mkdir -p rootfs_overlay-bbb/usr/bin
% cp target/armv7-unknown-linux-gnueabihf/release/zenohd rootfs_overlay-bbb/usr/bin/zenohd
% unzip -j firmware.fw data/rootfs.img
% unsquashfs -f -d extracted rootfs.img usr/bin/zenohd
% file extracted/usr/bin/zenohd
extracted/usr/bin/zenohd: ELF 32-bit LSB pie executable, ARM, EABI5 version 1 (SYSV), ...
ファームウェアへの組み込みを確認できたので、mix upload で実機に書き込みます。
アーキテクチャの確認とoverlayへの配置(RPi2の場合)
これを rootfs_overlay-rpi2 という名前のディレクトリを作って、そこに置きます。
% mkdir -p rootfs_overlay-rpi2/usr/bin
% cp target/armv7-unknown-linux-gnueabihf/release/zenohd rootfs_overlay-rpi2/usr/bin/zenohd
% unzip -j firmware.fw data/rootfs.img
% unsquashfs -f -d extracted rootfs.img usr/bin/zenohd
% file extracted/usr/bin/zenohd
extracted/usr/bin/zenohd: ELF 32-bit LSB pie executable, ARM, EABI5 version 1 (SYSV), ...
ファームウェアへの組み込みを確認できたので、mix upload で実機に書き込みました。
動作確認
BBBは192.168.10.9/24で、RPi2は192.168.10.10/24で、Nervesファームウェアを作って、相互にチェックをしました。

右側がRPi2Bで真ん中がBBGです。左の白い箱は両者を接続している3ポートのSW-HUBです。青いケーブルは母艦のMacbookPro M1につながってます。
BBBがクライアントでRPi2にzenohdをもたせる場合
BBBは一旦 zenohd を外したファームウェアを作ります。
% vi config/target.exs # IPアドレスを dchp ではなく 192.168.10.9/255.255.255.0 にする
% mv rootfs_overlay-bbb /tmp/rootfs_overlay-bbb.bak
% MIX_TARGET=bbb mix deps.get
% MIX_TARGET=bbb mix firmware
% MIX_TARGET=bbb mix upload 192.168.10.9 # ないしは mix burn してSDを焼いてBBBに挿してブート
% mv /tmp/rootfs_overlay-bbb.bak rootfs_overlay-bbb
RPi2は普通にzenohd付きのファームウェアを作ります。
% vi config/target.exs # IPアドレスを dchp ではなく 192.168.10.10/255.255.255.0 にする
% MIX_TARGET=rpi2 mix deps.get
% MIX_TARGET=rpi2 mix firmware
% MIX_TARGET=rpi2 mix upload 192.168.10.9 # ないしは mix burn してSDを焼いて挿してブート
準備ができたので順に試験をしていきます。
iex(3)> config = Zenohex.Config.default |> Zenohex.Config.update_in(["connect", "endpoints"], fn _ -> ["tcp/192.168.10.10:7447"] end)
# ここでめっちゃメッセージが出ます。
まず、zenohd につながるように明示的にIPアドレスをしていしておきます。
iex(4)> {:ok, session} = Zenohex.Session.open(config)
{:ok, #Reference<0.3315461763.2567307265.3930>}
iex(5)> {:ok, info} = Zenohex.Session.info(session)
{:ok,
%Zenohex.Session.Info{
zid: "321374b12ee19ee54207bb3ba97b1786",
routers_zid: [],
peers_zid: ["1ddc5e81a291c7b799ff54826e0c8c6"]
}}
iex(6)> info.peers_zid
["1ddc5e81a291c7b799ff54826e0c8c6"]
セッションを作ってみて、peer に出てくるかを確認します。
iex(7)> {:ok, sub_session} = Zenohex.Session.open(config)
{:ok, #Reference<0.3315461763.2567307265.4068>}
iex(8)> {:ok, pub_session} = Zenohex.Session.open(config)
{:ok, #Reference<0.3315461763.2567307265.4105>}
iex(9)> {:ok, _sub} = Zenohex.Session.declare_subscriber(sub_session, "test/ping", self())
{:ok, #Reference<0.3315461763.2567307265.4135>}
送信(pub)のセッションと受信(sub)のセッションを作ります。そしてsubで待ち構えます。
iex(10)> Zenohex.Session.put(pub_session, "test/ping", "hello from bbb")
:ok
iex(11)> flush
%Zenohex.Sample{
attachment: nil,
congestion_control: :drop,
encoding: "zenoh/bytes",
express: false,
key_expr: "test/ping",
kind: :put,
payload: "hello from bbb",
priority: :data,
timestamp: nil
}
:ok
put すると sub 側にメッセージが送られます。sub側はプロセスのメッセージキューに溜まるので、flush/0 で読み出します。
後は丁寧にセッションを閉じておしまいです。
iex(12)> Zenohex.Session.close(session)
:ok
iex(13)> Zenohex.Session.close(sub_session)
:ok
iex(14)> Zenohex.Session.close(pub_session)
:ok
RPi2がクライアントでBBBにzenohdをもたせる場合
今度は役割が逆です。まずzenohdつきでBBBのファームウェアを作ります。
% vi config/target.exs # IPアドレスを dchp ではなく 192.168.10.9/255.255.255.0 にする
% MIX_TARGET=bbb mix deps.get
% MIX_TARGET=bbb mix firmware
% MIX_TARGET=bbb mix upload 192.168.10.9 # ないしは mix burn してSDを焼いてBBBに挿してブート
つぎにzenohdを外したRPi2のファームウェアを作ります。
% vi config/target.exs # IPアドレスを dchp ではなく 192.168.10.10/255.255.255.0 にする
% mv rootfs_overlay-rpi2 /tmp/rootfs_overlay-rip2.bak
% MIX_TARGET=rpi2 mix deps.get
% MIX_TARGET=rpi2 mix firmware
% MIX_TARGET=rpi2 mix upload 192.168.10.9 # ないしは mix burn してSDを焼いて挿してブート
% mv /tmp/rootfs_overlay-rpi2.bak rootfs_overlay-rpi2
同様に試験を行います。
iex(2)> config = Zenohex.Config.default() |> Zenohex.Config.update_in(["connect", "endpoints"], fn _ -> ["tcp/192.168.10.9:7447"] end)
# ...snip...
iex(3)> {:ok, session} = Zenohex.Session.open(config)
{:ok, #Reference<0.3683017025.2567045122.198484>}
iex(4)> {:ok, info} = Zenohex.Session.info(session)
{:ok,
%Zenohex.Session.Info{
zid: "7cba631c7563c3036a3ecc6fc6dad52f",
routers_zid: [],
peers_zid: ["2b305b5d4d25d08c9f4238203d4efd3b"]
}}
iex(5)> info.peers_zid
["2b305b5d4d25d08c9f4238203d4efd3b"]
iex(6)> {:ok, sub_session} = Zenohex.Session.open(config)
{:ok, #Reference<0.3683017025.2567045122.198666>}
iex(7)> {:ok, pub_session} = Zenohex.Session.open(config)
{:ok, #Reference<0.3683017025.2567045122.198695>}
iex(8)> {:ok, _sub} = Zenohex.Session.declare_subscriber(sub_session, "test/ping", self())
{:ok, #Reference<0.3683017025.2567045122.198728>}
iex(9)> Zenohex.Session.put(pub_session, "test/ping", "hello from bbb")
:ok
iex(10)> Zenohex.Session.put(pub_session, "test/ping", "hello from rpi2")
:ok
iex(11)> flush()
%Zenohex.Sample{
attachment: nil,
congestion_control: :drop,
encoding: "zenoh/bytes",
express: false,
key_expr: "test/ping",
kind: :put,
payload: "hello from rpi2",
priority: :data,
timestamp: nil
}
:ok
iex(12)> Zenohex.Session.close(session)
:ok
iex(13)> Zenohex.Session.close(sub_session)
:ok
iex(14)> Zenohex.Session.close(pub_session)
:ok
まとめ
Nervesにzenohdを埋め込んでお手軽な Zenohd マシンを作成してみました。
在庫のあるBBBやRPi2アーキテクチャを使ったのが茨の道でした。