Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

はじめに - Microbit で学ぶ組み込み Rust プログラミング

この本では、Microbit(v2)と Rust を使って、シンプルで楽しいプロジェクトを作っていきます。このボードの正式名称は「micro:bit」です。本書では、microbit と micro:bit の両方を区別なく使います。Microbit は学習用途で広く使われており、LED マトリクス、マイク、ボタン、スピーカー、Bluetooth など、いくつかの組み込みコンポーネントを備えています。

前提知識

  • Rust の基礎: Rust の基本的な理解があることを前提としています。本書では、この言語の基礎は扱いません。Rust が初めての方には、公式の Rust 本から始めることをおすすめします。ほかのリソースはこちらでも見つけられます。

ハードウェアを知る

EC サイトで「Micro Bit V2」を検索して、自分のニーズに合ったものを選べます。ボード単体を買ってもよいですし、バッテリーや micro USB ケーブルのようなアクセサリーを含むパッケージを買っても構いません。販売者によっては、追加のセンサーが付いたキットを提供している場合もあります。どのバージョンが自分のプロジェクトに最も合っているかは、自分で判断してください。販売店を探すには、公式の microbit website も参照できます。

microbit

注: 私はアクセサリー(micro USB ケーブル)付きの Micro Bit V2.21 を購入しました。手元に届くのは別の V2 バージョンかもしれませんが、V2 系である限り問題ありません(古い V1 ではなく)。また、進めながらほかのセンサーも購入しました(でも今は気にしなくて大丈夫です)。

なぜこの本?

micro:bit を使った組み込み Rust を扱う「Discovery」というすでに良い本があります。なので、「なぜまた別の本を書くの?」と思うかもしれません。まあ、書いてはいけない理由もないですよね? :) 正直なところ、私が何かを学び、本当に深く掘り下げるための最良の方法の 1 つは、それをほかの人に教えることです。人に説明すると、自分の理解もより深まります。つまりこの本は、私が学ぶ過程をそのまま共有し、皆さんにも一緒に付き合ってもらうためのものです。

ESP32Raspberry Pi Pico 向けのほかの「impl Rust」本と同じように、この本も楽しく、実践的なものになるようにしています。どこかの誰かに役立ててもらえたらうれしいです。それが、この本を書く目的です。

その他の学習リソース

  • The Embedded Rust Book : これは、組み込み Rust をこれから始めるなら非常に良いリソースです。この本に取りかかる前に読む必要はありませんが、出発点としては良い場所です。 本書でもできる限り説明していきますが、私が何かを見落としたり、十分に明確に説明できなかったりした場合には、この本が本当に役に立ちます。いずれにしても、一読することを強くおすすめします。

  • Discovery: これは先ほど触れた本です。micro:bit を使った組み込み Rust プログラミングを扱っています。読む順番は自由で、「impl Rust for Microbit」から始めてその後に「Discovery」を読んでもよいですし、その逆でも構いません。

  • The Rusty Bits [Youtube] : これは私のお気に入りの YouTube チャンネルの 1 つです。microbit を使った組み込み Rust プログラミングに関するすばらしい動画があります。

ライセンス

「impl Rust for Microbit」ブック(このプロジェクト)は、以下のライセンスの下で配布されています。

  • この本に含まれるコードサンプルおよび独立した Cargo プロジェクトは、MIT LicenseApache License v2.0 の両方の条件に基づいてライセンスされています。
  • この本に含まれる文章は、Creative Commons の CC-BY-SA v4.0 ライセンスの条件に基づいてライセンスされています。

このプロジェクトを支援する

GitHub でこのプロジェクトにスターを付けたり、この本をほかの人に共有したりすることで、この本を支援できます 😊

免責事項

この本で共有している実験やプロジェクトは私の環境では動作しましたが、結果は異なる場合があります。実験中に発生する可能性のある問題や損害について、私は責任を負いません。注意して進め、必要な安全対策を講じてください。

Micro:bitのハードウェア詳細

ここで説明する内容はすべて、公式の Microbit ドキュメントですでに詳しく扱われています。ここでは、ハードウェアの詳細について簡単に説明します。さらに踏み込んだ技術的な詳細については、公式ドキュメントはこちらを読んでください。

ボードの中核にあるのは nRF52833 system-on-chip (SoC) です。ここですべてのコードが実行されます。これは floating point unit(FPU) を備えた 32-bit Arm Cortex-M4 プロセッサをベースにしています。128KB の RAM(そう、たった 128KB!)を搭載し、64 MHz で動作します。

microbit の詳細

  • Buttons A and B: 入力として使える 2 つのユーザーボタンです。たとえば、シンプルなゲームを作る場合、プレイヤーを動かしたりアクションをトリガーしたりするのに使えます。
  • 5x5 LED Matrix: この赤色 LED のグリッドには、テキスト、記号、またはアニメーションを表示できます。
  • Edge Connector Pins: 0、1、2、3V、GND とラベル付けされたピンを使って、センサー、LED、モーターなどの外部コンポーネントを接続できます。
  • Microphone: 音量レベルを検出したり、音声入力に応答したりするために使われます(これが、私が最初にこのボードを購入したときの楽しい部分でした)
  • Speaker: ボードから直接サウンドやトーンを再生できます。
  • USB Connector: プログラミングや電源供給のために、ボードをコンピューターに接続するのに使います。
  • Battery Connector: USB に接続していないときは、バッテリーを使ってボードに電源を供給できます(アクセサリ付きで購入した場合は、このためのバッテリーとケーブルが付属します)
  • BLE Antenna: Bluetooth 通信を有効にするため、ボードを他のデバイスにワイヤレスで接続できます。

今のところは、ほかの詳細は気にしないでください。

データシートとマニュアル

データシートや技術マニュアルは、ピン配置、電気的仕様、通信方式、その他のハードウェアコンポーネントの詳細を理解するのに役立ちます。

  • Overview: この Web ページ "https://tech.microbit.org/hardware/2-0-revision/" では、microbit の各コンポーネントの概要を確認できます

  • nRF52833: 前に述べたように、microbit は nRF52833 System-on-Chip (SoC) を利用しています。そのピンをどのように設定し、入出力操作を管理するかを理解するには、公式の製品仕様書を読むことが重要です。ドキュメントにはこちらからアクセスできます。

  • LSM303AGR: 動きの検出や姿勢追跡に使われる低消費電力の 3 軸加速度計および磁力計センサーです。データシートにはこちらからアクセスできます

  • Schematic: これは、デバイスの電気的接続やコンポーネントを示した詳細な図を提供します。PDF 形式の完全な回路図は GitHub のこちらで公開されています。詳細な回路図情報は、この Web ページでも確認できます: https://tech.microbit.org/hardware/schematic/

ここやドキュメント内の情報量に圧倒されないでください。楽しい演習から始めて、少しずつ進めていきます。後から自分のペースで詳細を確認しに戻ることもできます。

開発環境

ここでは、あなたのマシンにすでに Rust がインストールされていて、基本も理解していることを前提にします。そうでない場合は、少しついていくのが難しいかもしれません。まず Rust の基本を学んでから、これに戻ってくることを強くおすすめします。

probe-rs

probe-rs は、組み込み ARM および RISC-V デバイスを扱うためのツールキットです。ファームウェアのフラッシュ、プログラムのデバッグ、各種デバッグプローブ経由でのログ出力をサポートしています。

このプロジェクトには、次のようなツールが含まれています。

  • cargo-flash - ターゲットにファームウェアをすばやくフラッシュする
  • cargo-embed - 複数チャネルとコマンド入力をサポートするフル機能の RTT ターミナルを開く

これを使って、私たちのプログラムを micro:bit にフラッシュし(つまり、コードをデバイスに書き込んで実行し)、動かします。また、デバッグ目的にも使用します。

詳細はこちらをご覧ください。セットアップ手順については、インストールガイドを参照してください。

次のコマンドを実行すると、インストールが正常に完了したことを確認できます。

cargo embed --version

クロスコンパイルターゲット

micro:bit は ARM Cortex-M プロセッサ上で動作するため、そのアーキテクチャ向けに Rust コードをコンパイルする必要があります。そのため、クロスコンパイル用の特定のコンパイルターゲットを設定する必要があります。

micro:bit v2 の正しいターゲットは次のとおりです。

thumbv7em-none-eabihf

このターゲットは、Rust に組み込まれているツールチェーンマネージャーを使って追加できます。

rustup target add thumbv7em-none-eabihf

追加したら、プロジェクトのビルドやフラッシュ時にこのターゲットを指定できます。たとえば、次のようにします。

cargo build --release --target thumbv7em-none-eabihf

また、cargo embed のようなツールを実行するときにも、プロジェクトが正しく設定されていれば自動的に使用されます(この部分については後の章で扱います)。

クイックスタート

すべてがどのように動作するのかという理論や概念に入る前に、まずは実際に手を動かしてみましょう。このシンプルなコードを使って、microbit の LEDマトリクスに点滅エフェクトを作成します。

microbit には 5x5 の LEDマトリクスがあり、これを制御してパターン、文字、またはアニメーションを表示できます。各 LED はオンまたはオフにでき、さまざまなエフェクトを作成できます。

点滅

コード全体

今はコードについて心配しなくて大丈夫です。次の章で説明します。このコードは、左上隅の LED を点灯し、短い遅延のあとでループ内で消灯するだけです。これにより、点滅エフェクトが作成されます。

#![no_std]
#![no_main]

use embedded_hal::{delay::DelayNs, digital::OutputPin};
use microbit::{board::Board, hal::timer::Timer};

use cortex_m_rt::entry;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    let mut board = Board::take().unwrap();
    let mut timer = Timer::new(board.TIMER0);

    let _ = board.display_pins.col1.set_low();
    let mut row1 = board.display_pins.row1;

    loop {
        let _ = row1.set_low();
        timer.delay_ms(500);
        let _ = row1.set_high();
        timer.delay_ms(500);
    }
}

クイックスタートプロジェクトをクローンする

私が作成したクイックスタートプロジェクトをクローンし、プロジェクトフォルダーに移動して実行できます。

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp/blinky

フラッシュ - Run Rust Run

あとは、コードをデバイスにフラッシュして、その動作を確認するだけです。

プロジェクトフォルダーから次のコマンドを実行してください。

#![allow(unused)]
fn main() {
cargo embed
}

これで、ディスプレイマトリクスの最上段にある最初の LED が点滅し始めるはずです。正常にフラッシュできて点滅エフェクトが確認できたなら、おめでとうございます!

抽象化レイヤー

組み込み Rust に取り組むと、PAC、HAL、BSP といった用語をよく目にします。これらは、ハードウェアとやり取りするのを助ける異なるレイヤーです。各レイヤーは、柔軟性と使いやすさのバランスがそれぞれ異なります。

まずは、最も高い抽象化レベルから最も低いレベルへと見ていきましょう。

注: 本書全体を通して、各演習の要件に最も適したクレートを使用します。演習によっては Board Support Package (BSP) を使い、別の演習では Hardware Abstraction Layer (HAL) を直接使うことがあります。

Board Support Package (BSP)

Rust では Board Support Crate とも呼ばれる BSP は、特定の開発ボード向けに調整されたものです。これは HAL とボード固有の設定を組み合わせ、LED、ボタン、センサーなどのオンボードコンポーネントに対するすぐに使えるインターフェースを提供します。これにより、開発者は低レベルのハードウェア詳細を扱うのではなく、アプリケーションロジックに集中できます。micro:bit にも Board support crate があり、こちらから確認できます。

クイックスタートの章では、実際にこれを使いました。

BSP のコード例

// 1 行目の最初の LED を点灯する
use cortex_m_rt::entry;
use embedded_hal::digital::OutputPin;
use microbit::board::Board;

#[entry]
fn main() -> ! {
    let mut board = Board::take().unwrap();

    let _ = board.display_pins.col1.set_low();
    let mut row1 = board.display_pins.row1;
    let _ = row1.set_high();

    loop {}
}

Hardware Abstraction Layer (HAL)

HAL は BSP のすぐ下のレベルに位置します。Raspberry Pi Pico や ESP32 ベースのボードのようなものを扱う場合、主に使うのは HAL レベルです。本書では、いくつかの BSP の例を見たあと、より HAL に重点を置いていきます。

HAL は PAC の上に構築されており、マイクロコントローラーの周辺機能に対して、より単純で高水準なインターフェースを提供します。低レベルのレジスタを直接扱う代わりに、HAL はタイマーの設定、シリアル通信のセットアップ、GPIO ピンの制御といった作業を簡単にするメソッドやトレイトを提供します。

HAL は通常、embedded-hal のトレイトを実装しています。これらは GPIO、SPI、I2C、UART のような周辺機能に対する標準的でプラットフォーム非依存のインターフェースです。これにより、互換性のある HAL を使う限り、異なるハードウェア間で動作するドライバーやライブラリを書きやすくなります。

後ほど、nrf52833-hal を見ていきます。ご覧のとおり、このクレートはもはや特定の開発ボード向けではなく、nRF52833 チップに結び付いています。そのため、別の開発ボードが同じチップを使っていれば、ほぼ同じコードを使えます。

HAL のコード例

// 1 行目の最初の LED を点灯する
use cortex_m_rt::entry;
use embedded_hal::digital::OutputPin;
use nrf52833_hal::gpio::{p0, Level};
use nrf52833_hal::pac::Peripherals;

#[entry]
fn main() -> ! {
    let p = Peripherals::take().unwrap();
    let port0 = p0::Parts::new(p.P0);
    let mut col1 = port0.p0_28.into_push_pull_output(Level::High);
    let mut row1 = port0.p0_21.into_push_pull_output(Level::Low);

    col1.set_low().unwrap();
    row1.set_high().unwrap();

    loop {}
}

これを BSP のコードと比べると、BSP のコードのほうが読みやすいことがわかるでしょう。しかし、HAL レベルでは物事がより複雑になります。組み込みプログラミングや電子回路の背景知識がないと、こうした用語は奇妙に思えるかもしれません。心配はいりません。これらは後で順を追って説明していきます。


注記:

HAL より下のレイヤーを直接使うことはめったにありません。多くの場合、PAC は単体で使うのではなく、HAL を介してアクセスします。利用可能な HAL がないチップを扱っているのでなければ、通常は下位レイヤーを直接操作する必要はありません。本書では、BSP と HAL のレイヤーに焦点を当てます。


Peripheral Access Crate (PAC)

PAC は最も低いレベルの抽象化です。これは、マイクロコントローラーの周辺機能に型安全なアクセスを提供する自動生成されたクレートです。これらのクレートは通常、メーカーの SVD (System View Description) ファイルから、svd2rust のようなツールを使って生成されます。PAC は、ハードウェアレジスタを直接扱うための、構造化された安全な方法を提供します。

PAC のコード例

// 1 行目の最初の LED を点灯する

use cortex_m_rt::entry;
use nrf52833_pac::Peripherals;

#[entry]
fn main() -> ! {
    let p = Peripherals::take().unwrap();
    let gpio0 = p.P0;

    gpio0.pin_cnf[21].write(|w| {
        w.dir().output();
        w.input().disconnect();
        w.pull().disabled();
        w.drive().s0s1();
        w.sense().disabled();
        w
    });
    gpio0.pin_cnf[28].write(|w| {
        w.dir().output();
        w.input().disconnect();
        w.pull().disabled();
        w.drive().s0s1();
        w.sense().disabled();
        w
    });

    gpio0.outclr.write(|w| w.pin28().clear());
    gpio0.outset.write(|w| w.pin21().set());

    loop {}
}

Raw MMIO

Raw MMIO (memory-mapped IO) とは、特定のメモリアドレスを読み書きすることでハードウェアレジスタを直接扱うことを意味します。このアプローチは従来の C スタイルのレジスタ操作に相当し、伴う潜在的なリスクのため、Rust では unsafe ブロックの使用が必要です。この領域には触れません。このアプローチを使っている人は見たことがありませんし、仮に使っていたとしても、本書の範囲外です。

コード例

// 1 行目の最初の LED を点灯する

#![no_main]
#![no_std]

extern crate panic_halt as _;

use nrf52833_pac as _;

use core::mem::size_of;
use cortex_m_rt::entry;

const GPIO_P0: usize = 0x5000_0000;

const PIN_CNF: usize = 0x700;
const OUTSET: usize = 0x508;
const OUTCLR: usize = 0x50c;

const DIR_OUTPUT: u32 = 0x1;
const INPUT_DISCONNECT: u32 = 0x1 << 1;
const PULL_DISABLED: u32 = 0x0 << 2;
const DRIVE_S0S1: u32 = 0x0 << 8;
const SENSE_DISABLED: u32 = 0x0 << 16;

#[entry]
fn main() -> ! {
    let pin_cnf_21 = (GPIO_P0 + PIN_CNF + 21 * size_of::<u32>()) as *mut u32;
    let pin_cnf_28 = (GPIO_P0 + PIN_CNF + 28 * size_of::<u32>()) as *mut u32;
    unsafe {
        pin_cnf_21.write_volatile(
            DIR_OUTPUT | INPUT_DISCONNECT | PULL_DISABLED | DRIVE_S0S1 | SENSE_DISABLED,
        );
        pin_cnf_28.write_volatile(
            DIR_OUTPUT | INPUT_DISCONNECT | PULL_DISABLED | DRIVE_S0S1 | SENSE_DISABLED,
        );
    }

    let gpio0_outset = (GPIO_P0 + OUTSET) as *mut u32;
    let gpio0_outclr = (GPIO_P0 + OUTCLR) as *mut u32;
    unsafe {
        gpio0_outclr.write_volatile(1 << 28);
        gpio0_outset.write_volatile(1 << 21);
    }

    loop {}
}

参考

cargo-generate を使った micro:bit プロジェクトテンプレート

micro:bit のプロジェクトセットアップと学習を簡単にするために、再利用可能なプロジェクトテンプレートを作成しました。開始するには cargo-generate ツールを使用します。

cargo-generate とは何ですか?
cargo-generate は、あらかじめ用意されたテンプレートを使って新しい Rust プロジェクトをすばやく作成し、定型的なセットアップやコードを避けられるようにするツールです。

詳細はこちらで確認できます。

前提条件

cargo-generate をインストールする前に、libssl-dev がインストールされていることを確認してください。

Ubuntu または Debian 系のシステムでは、次を実行します。

sudo apt install libssl-dev

次に、以下で cargo-generate をインストールします。

cargo install cargo-generate

ステップ 1: 新しいプロジェクトを生成する

cargo-generate をインストールしたら、次のコマンドを使って新しいプロジェクトを生成できます。

cargo generate --git https://github.com/ImplFerris/mb2-template.git --rev 88d339b

注: セットアップを再現可能にするため、cargo generate コマンドに特定の rev(revision)値を含めています。これがないと、将来的なテンプレートの変更によって、このチュートリアルとの互換性が失われる可能性があります。

プロジェクト名の入力を求められます。

その後、"BSP" または "HAL" の選択を求められます。

その後、その名前の新しいディレクトリが作成されます。そこに移動します。

cd your-project-name

これで micro:bit プロジェクトをビルドして実行する準備ができました。

micro:bit にコードを書き込んで実行するには、次を使用します。

cargo embed

ヘルプとトラブルシューティング

演習に取り組んでいる際にバグ、エラー、そのほかの問題に直面した場合は、以下の方法でトラブルシューティングし、解決してください。

1. 動作するコードと比較する

完全なコード例を確認するか、比較用に参照プロジェクトをクローンしてください。自分のコードと Cargo.toml の依存関係のバージョンを注意深く確認しましょう。構文エラーやロジックエラーがないか確認してください。必要な機能が有効になっていない場合、または機能の不一致がある場合は、演習で示されているとおりに正しい機能を有効にしてください。

バージョンの不一致が見つかった場合は、新しいバージョンで動作するようにコードを調整する(調べて解決策を見つけましょう。学習し、理解を深めるための素晴らしい方法です)か、依存関係を更新してチュートリアルで使われているバージョンに合わせてください。

2. GitHub Issues を検索または報告する

同じ問題に他の人が遭遇していないか、GitHub の issue ページを確認してください: https://github.com/ImplFerris/microbit-book/issues?q=is%3Aissue

見つからない場合は、新しい issue を作成し、問題を明確に説明してください。

3. コミュニティに質問する

Rust Embedded コミュニティは Matrix チャットで活発に活動しています。Matrix チャットは、安全で分散型の通信のためのオープンネットワークです。

この本で扱うトピックに関連する、役立つ Matrix チャンネルをいくつか紹介します。

  • 組み込みデバイス Working Group
    #rust-embedded:matrix.org
    組み込み開発で Rust を使うことに関する一般的な議論の場です。

  • Nordic チップ / nRF 開発
    #nrf-rs:matrix.org
    Rust を Nordic Semiconductor のチップ(micro:bit v2 で使われている nRF52 シリーズなど)で使うことに特化しています。

  • Probe-rs によるデバッグ
    #probe-rs:matrix.org
    probe-rs デバッグツールキットに関するサポートや議論の場です。

  • 組み込みグラフィックス
    #rust-embedded-graphics:matrix.org
    組み込みシステム向け描画ライブラリ embedded-graphics を扱うための場です。

Matrix アカウントを作成してこれらのチャンネルに参加すれば、経験豊富な開発者から助けを得られます。

さらに多くのコミュニティチャットルームは、Awesome Embedded Rust - Community Chat Rooms section で見つけられます。

4. Discord

Embedded Rust には非公式の Discord コミュニティがあり、そこで質問したり、トピックについて議論したり、経験を共有したり、プロジェクトを紹介したりできます。特に学習者や一般的な議論に役立ちます。

ほとんどの HAL や組み込みエコシステムのメンテナーは、Matrix のほうでより活発に活動している点に留意してください。それでも、この Discord サーバーは学んだり他の人と交流したりするのに良い場所です。

こちらから参加してください: https://discord.gg/NHenanPUuG

プロジェクトのウォークスルー

点滅する効果を生み出す最初のプログラムのフラッシュと実行には成功しました。しかし、コードやプロジェクト構造の詳細については、まだ見ていません。このセクションでは、テンプレートを使う代わりに、同じプロジェクトを最初から作り直します。途中で、コードと設定の各部分を順に説明していきます。このチャレンジに挑戦する準備はできていますか?

新しいプロジェクトを作成する

まずは、標準的な Rust のバイナリプロジェクトを作成します。次のコマンドを使用してください。

cargo new blinky

この段階では、プロジェクトには通常どおり次のファイルが含まれます。

├── Cargo.toml
└── src
    └── main.rs

最終的な目標は、次のプロジェクト構造に到達することです。

├── .cargo
│   └── config.toml
├── Cargo.toml
├── Embed.toml
├── memory.x
└── src
    └── main.rs

依存関係

まず、プロジェクトに必要な依存関係を追加します。Cargo.toml ファイルを次のエントリで更新してください。

cortex-m-rt = "0.7.3"
microbit-v2 = "0.15.0"
embedded-hal = "1.0.0"

Board Support Package (BSP) アプローチを使用しているため、microbit-v2 クレートが micro:bit v2 向けのボードサポート層を提供します。

また、他の 2 つの依存関係である cortex-m-rt と embedded-hal についても、別のセクションでより詳しくその役割を説明します。

ランタイム

最初の依存関係である cortex-m-rt から始めましょう。このクレートは、Cortex-M マイクロコントローラー向けのスタートアップコードと最小限のランタイムを提供します。すでにご存じかもしれませんが、ここで使う micro:bit も Cortex-M コアをベースにしています。

なぜ必要なのでしょうか?

組み込み開発では、通常、その下にある OS は存在しません(ただし、マイクロコントローラー向けの専用オペレーティングシステムは存在します)。つまり、プログラムをどのように開始するか、メモリをどのように初期化するか、ボタンの押下やデータの受信のようなイベントにデバイスがどのように応答するかなど、すべてを自分で設定しなければならないということです。

これらすべてを機能させるために、ランタイムクレートを使います。組み込み Rust におけるランタイムは、main 関数の前に実行される最小限のスタートアップコードを提供し、メモリ(スタックやヒープなど)をセットアップし、プログラムが割り込みにどう反応すべきかを定義するのを助けます。


エントリーポイント

開発者の視点では、プログラムの実行時に最初に実行されるコードは main 関数のように思えるかもしれません。しかし、実際にはそうではありません。ほとんどの言語では、最終的に main を呼び出す前にランタイムシステムが環境をセットアップします。

それに対して、micro:bit のような組み込みシステムには標準のランタイムがありません。その代わりに、cortex-m-rt クレートが提供するもののようなカスタムランタイムを使います。この構成では、プログラムのエントリーポイントを明示的に指定する必要があります。micro:bit v2 では、これは cortex-m-rt が提供する #[entry] 属性 を使って行い、これによって最初に実行する関数をランタイムに伝えます。

no_main

Rust コンパイラに通常のプログラムのエントリーポイントを使わないことを伝えるために、#![no_main] ディレクティブを使います。その代わりに、自分たちでエントリーポイントと main 関数を用意します。

![no_main] を追加すると、デフォルトのプログラムのスタートアップロジックが無効になり、組み込みランタイム(cortex-m-rt など)が制御を引き継げるようになります。

コードを変更する

これらの属性を含めるように、プログラムを更新しましょう。コードエディタでプロジェクトを開き、src/main.rs ファイルを次のように変更してください。

#![no_std]
#![no_main]

use cortex_m_rt::entry;

#[entry]
fn main() {
    println!("Hello, world!");
}

コードを更新すると、rust-analyzer から次のようなエラーが表示されるでしょう。

#![allow(unused)]
fn main() {
error: `#[entry]` function must have signature `[unsafe] fn() -> !`
}

これは、#[entry] 関数が決して return してはいけないためです。プログラムが無期限に実行され、終了しないことを示すため、戻り値の型は !(「never 型」と呼ばれます)でなければなりません。この要件は、組み込みシステムやベアメタルシステムでは非常に重要です。従来のオペレーティングシステム上で動作するアプリケーションとは異なり、プログラムが終了したあとに制御を返す先の OS が存在しないためです。

これを修正するには、main 関数のシグネチャを次のように更新してください。

#[entry]
fn main() -> ! {
    loop {
        // ずっと実行し続ける
    }
}

Clippy を有効にしている場合は、「empty loop {} wastes CPU cycles.」という警告が表示されるかもしれません。今のところは、この警告は安全に無視できます。

参考資料

Embedded HAL

embedded-hal クレートは、組み込み Rust エコシステムの中核です。これは、I/O、SPI、I2C、PWM、タイマーなどのための共通のハードウェア抽象化トレイトの基盤を提供します。これらのトレイトは標準インターフェースを作り、高レベルのドライバー、たとえばセンサーや無線デバイス向けのドライバーを、異なるハードウェアプラットフォーム間で動作させられるようにします。

ドライバーは embedded-hal の上に構築されたジェネリックなライブラリとして書かれるため、Cortex-M や AVR マイクロコントローラーから組み込み Linux システムまで、幅広いターゲットをサポートできます。

クイックスタートの例では、embedded-hal のトレイトを使って micro:bit ボード上のピンとタイマーを制御しました。set_lowset_high 関数は OutputPin トレイト由来で、delay_ms 関数は DelayNs トレイト由来です。どちらも embedded-hal の一部です。

それでも、なぜトレイトを使わずに set_lowset_high 関数を直接書かないのか、と疑問に思うかもしれません。これを説明するために、LED をオンまたはオフにする単純な関数を 2 つのバージョンで考えてみましょう:

#![allow(unused)]
fn main() {
// 具体的なピン型の例(架空の MicrobitPin)
struct MicrobitPin;

impl MicrobitPin {
    fn set_low(&mut self) {
        // ピンを low に設定するハードウェア固有のコード
    }
    fn set_high(&mut self) {
        // ピンを high に設定するハードウェア固有のコード
    }
}
}

アプリケーション/ドライバーのコード:

#![allow(unused)]
fn main() {
fn control_led_concrete(pin: &mut MicrobitPin, light_up: bool) {
    if light_up {
        pin.set_low();
    } else {
        pin.set_high();
    }
}
}

これは LED を制御するためのアプリケーションコードです。この関数は MicrobitPin 型でしか動作しません。では、アプリケーションやドライバーを移植して、ほかのマイクロコントローラーもサポートしたい場合はどうでしょうか。その場合は、新しいクレートを書くか、それらを処理するための別個のロジックを追加しなければなりません。

#![allow(unused)]
fn main() {
fn control_led_another_mcu(pin: &mut AnotherMcuPin, light_up: bool) {
    if light_up {
        pin.set_low();
    } else {
        pin.set_high();
    }
}
}

では、これを embedded-hal のトレイトベースのアプローチと比較してみましょう:

#![allow(unused)]
fn main() {
use embedded_hal::digital::OutputPin;

// OutputPin を実装する任意のピン型でこの関数は動作する
fn control_led_generic<P: OutputPin>(pin: &mut P, light_up: bool) {
   if light_up {
       let _ = pin.set_low();
   } else {
       let _ = pin.set_high();
   }
}
}

OutputPin トレイトを使うことで、この関数はそのトレイトを実装する任意のハードウェアプラットフォームで動作します。これにより、ボードごとに書き直さなくても、コードを再利用可能かつポータブルにできます。

このトレイトベースのアプローチこそが、embedded-hal が組み込み Rust で非常に重要である理由です - 異なるハードウェア間で機能する共通インターフェースを提供するからです。

no_std Rust環境

通常のRustプログラムを書く場合、完全な標準ライブラリ(std)にアクセスできます。これにより、heap allocation、スレッド、ファイルシステム、ネットワーキングといった機能を利用できます。しかし、これらの機能はすべて、ある前提に依存しています。それは、下層にオペレーティングシステムが存在することです。

embedded systemsでは、通常オペレーティングシステムがありません。ファイルシステムもありません。ネットワークスタックもありません。自分で用意しない限りheap allocatorもありません。ハードウェア上で直接動作します。

そこで登場するのがno_stdです。

コードの先頭にこの1行を追加すると:

#![allow(unused)]
#![no_std]
fn main() {
}

Rustコンパイラに対してこう伝えることになります。「標準ライブラリは不要です。coreの言語機能だけでやりくりします。」

Rustは最小限のcore crateのみをリンクするようになり、これには基本的な型、エラー処理などの必須要素が含まれます。これは、多くのembedded applicationsのロジックを書くのに十分です。

コードの修正

このディレクティブを含めるように、プログラムを更新しましょう。コードエディタでプロジェクトを開き、src/main.rsファイルを次のように修正してください:

#![no_std]
#![no_main]

use cortex_m_rt::entry;

#[entry]
fn main() -> ! {
    loop {}
}

LEDマトリクス

micro:bitボード上では、オンボードLEDが5x5のマトリクス状に配置されており、合計25個のLEDがあります。各LEDが専用のGPIO(General Purpose I/O)ピンに接続された1個または2個のLEDしか持たない他のボードとは異なり、micro:bitではLEDごとに別々のGPIOピンを使うことはできません。もしそうすると、GPIOピンがLEDだけで全部使い切られてしまい、センサーやそのほかの入力に使えるピンが残らなくなります。

多重化: ピンを共有して多くのLEDを制御する

LEDごとに1本のピンを使う代わりに、micro:bitの5x5マトリクスでは10本のGPIOピンだけを使用します。内訳は、行用が5本、列用が5本です。LEDはグリッド状に配線されており、それぞれのLEDは1本の行と1本の列の交点にあります。

  • 行ピンは電力を供給します(ロジックHIGHに設定)。
  • 列ピンはグラウンドへの経路を提供します(ロジックLOWに設定)。

適切な行と列を選ぶことで、マイクロコントローラーは1つのLEDを点灯できます。

microbit

1つのLEDが点灯する仕組み

特定のLED、たとえば2行3列のLEDを点灯させるには、次のようにします。

  1. 2行目をHIGHに設定します。これにより、その行に電圧が供給されます。
  2. 3列目をLOWに設定します。これにより、その列がグラウンドに接続されます。
  3. 電流は行から、その交点にあるLEDを通って列へ流れ、LEDが点灯します。

LEDマトリクス

高速スキャンによる複数LEDの点灯

異なる行にある複数のLEDを点灯させたい場合、micro:bitは一度に1行ずつ、非常に高速に点灯させます。

たとえば次のようになります。

  • 1行目を有効にし、適切な列をLOWに設定していくつかのLEDを点灯します。
  • 次に1行目を無効にし、2行目を有効にして、列ピンを更新します。
  • これを5行すべてについて高速に繰り返します。

このスキャンは非常に高速に行われるため(1秒あたり数十回)、私たちの目にはちらつきが検出できません。その代わり、安定した画像として見えます。この効果は残像効果として知られています。

注: このスキャンが内部でどのように行われているかを気にする必要はありません。コードでは、必要な列をLOWに、対象の行をHIGHに設定するだけです。残りはmicro:bitが処理してくれます。

GPIOピンのマッピング

この章ではこの情報はあまり役に立ちませんが、後でHALを扱うときに、どの行と列がどのピンに対応しているかを知っておく必要があります。

micro:bit V2の回路図によると、LEDマトリクスのピンはnRF52833マイクロコントローラー上の次のGPIOに接続されています。

マトリクスの役割役割ポートピン
ROW1ソースP021
ROW2ソースP022
ROW3ソースP015
ROW4ソースP024
ROW5ソースP019
COL1シンクP028
COL2シンクP011
COL3シンクP031
COL4シンクP105
COL5シンクP030

参考資料

コアロジックの実装

ここまでは新規プロジェクトから始め、前の数章では主に理論に焦点を当ててきました。少し退屈に感じたかもしれません(あるいは、見方によっては面白かったかもしれません)。このセクションでは方針を切り替え、実際にコードを書くことに集中して、より実践的で取り組みやすい内容にしていきます。

注意してください。まだマイクロコントローラー固有のいくつかの設定を行う必要があるため、このコードはまだコンパイルも実行もできません。ですが心配はいりません。順を追って進めていきます。今は、コアロジックを構築することに集中しましょう。

インポート

まずは必要なインポートから始めましょう。embedded HAL が提供する DelayNs トレイトと OutputPin トレイトを使用します。DelayNs トレイトを使うと、LED をオン/オフする間に遅延を入れられ、目で確認できる点滅効果を作れます。これがないと、LED は速すぎて見えないほどの速度で切り替わってしまいます。

前述のとおり、OutputPin トレイトは、マイクロコントローラーの出力ピンの状態を変更するためのメソッド(set_low, set_high)を提供します。これを使って、対象の LED に接続された出力ピンを LOW 状態と HIGH 状態の間で切り替えます。

#![allow(unused)]
fn main() {
use embedded_hal::{delay::DelayNs, digital::OutputPin};
use microbit::{board::Board, hal::timer::Timer};
}

さらに、microbit crate から必要な struct もインポートします。

メイン関数

それでは、main 関数を更新しましょう。

まず、次を呼び出して micro:bit ボードのペリフェラルへのアクセスを取得します。

#![allow(unused)]
fn main() {
let mut board = Board::take().unwrap();
}

この行により、ボードのシングルトンインスタンスが取得されます。これには、ピン、タイマーなど、マイクロコントローラーのすべてのハードウェアペリフェラルへのハンドルが含まれています。take() メソッドが Option を返すのは、競合やデータ競合を引き起こしかねない複数の可変アクセスを防ぐために、プログラムの存続期間中にペリフェラルを取得できるのは一度だけだからです。ここで unwrap() を呼び出すのは安全です。私たちのプログラムでは take() を一度しか呼び出さない想定だからです。

ボードのペリフェラルを取得したら、次のステップはタイマーインスタンスを作成することです。

#![allow(unused)]
fn main() {
let mut timer = Timer::new(board.TIMER0);
}

ディスプレイマトリクス

LED Matrix セクションで学んだように、1 行目 1 列目の LED を点灯させるには、列 1 を LOW に設定します(つまり GND に接続します)。その後、ループの中で、行 1 を HIGH(つまり電源に接続)と LOW の間で切り替え続け、その間に 500 ms の遅延を入れて点滅効果を作ります。

#![allow(unused)]
fn main() {
let _ = board.display_pins.col1.set_low();
let mut row1 = board.display_pins.row1;

loop {
    let _ = row1.set_low();
    timer.delay_ms(500);
    let _ = row1.set_high();
    timer.delay_ms(500);
}
}

パニックハンドラ

Rust における panic が何であるかについては、すでに基本的なイメージを持っているものとします。panic が発生しても、プログラムはすぐには終了しません。代わりに、制御は標準ライブラリが提供するパニックハンドラに渡されます。デフォルトでは、panic を起こしたスレッドのスタックの巻き戻しを開始します。しかし、ユーザーが panic 時に abort するよう選択している場合、プログラムは巻き戻しを行わずにただちに終了します。

この段階でプログラムをビルドしようとすると、次のエラーが表示されます。

error: `#[panic_handler]` function required, but not found

error: unwinding panics are not supported without std

これは、標準ライブラリを使用していないため、カスタムのパニックハンドラを定義する必要があることを意味します。

no_std におけるパニックハンドラ

no_std 環境では、自前のパニックハンドラを用意する必要があります。これを行ってくれる crate があり、必要な挙動に応じて 1 つ選べます。

たとえば次のようなものがあります。

  • プログラムを即座に abort したい場合は、panic-abort crate を使えます。

  • 無限ループに入ることでプログラム(または現在のスレッド)を停止させたい場合は、panic-halt crate を使えます。

これらの crate のソースコードを確認すると、単なるシンプルな関数であることがわかります。

次のように、これらの crate のいずれかをインポートすることもできます。

#![allow(unused)]
fn main() {
use panic_halt as _;
}

あるいは、パニックハンドラ関数を自分で定義することもできます。ここではそれを行います。

この関数には #[panic_handler] 属性を付ける必要があり、core::panic::PanicInfo への参照を受け取らなければなりません。また、この関数は決して返ってこないため、戻り値の型は ! です。

以下がその関数です(panic-halt が提供するものと等価です)。src/main.rs ファイルを更新し、次のコードを含めましょう。

#![allow(unused)]
fn main() {
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}
}

参考

ターゲット

では、これをビルドしてみましょう。あれ、まだビルドできず、次のエラーが表示されます。

rustc: found duplicate lang item `panic_impl`
the lang item is first defined in crate `std` (which `test` depends on)

解決策は簡単です。ターゲットプラットフォームを明示的に指定するだけで済みます。ターゲットを指定しない場合、コンパイラはデフォルトでホストマシン向けにビルドします。ホストマシン向けのビルドには、std に含まれる独自のパニックハンドラが含まれます。これにより、あなたのコード側でもパニックハンドラを提供しているため競合が発生し、duplicate lang item エラーになります。

micro:bit は浮動小数点ユニット (FPU) を備えた ARM Cortex-M4 の 32 ビットプロセッサを使用しているため、使用すべき正しいターゲットは thumbv7em-none-eabihf です。このターゲットは、クイックスタートのセクションですでに追加しました。

そのため、次のコマンドでそのままビルドできます。

cargo build --target thumbv7em-none-eabihf

.cargo/config.toml

修正すべき点が 2 つあります。1 つ目は、コードエディタがまだ panic 関数をハイライトし、重複エラーを表示する可能性があることです。2 つ目は、ビルドのたびに毎回ターゲットを入力するのが不便なことです。

これを解決するために、.cargo ディレクトリ内に config.toml ファイルを作成し、そこでデフォルトのターゲットを設定できます。

.cargo ディレクトリを作成するために、プロジェクトのルート(Cargo.toml があるのと同じディレクトリ)で次のコマンドを実行してください。続いて、.cargo ディレクトリ内に config.toml を作成します。

mkdir .cargo
cd .cargo

config.toml を次の内容に更新します。

[build]
target = "thumbv7em-none-eabihf"

次に、ターゲットを指定せずにビルドコマンドを実行してみてください。正常にコンパイルされるはずです。

cargo build

ここで、ここまでに作成した src/main.rs のコードを見てみましょう。

#![no_std]
#![no_main]

use cortex_m_rt::entry;

use embedded_hal::{delay::DelayNs, digital::OutputPin};
use microbit::{board::Board, hal::timer::Timer};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    let mut board = Board::take().unwrap();
    let mut timer = Timer::new(board.TIMER0);

    let _ = board.display_pins.col1.set_low();
    let mut row1 = board.display_pins.row1;

    loop {
        let _ = row1.set_low();
        timer.delay_ms(500);
        let _ = row1.set_high();
        timer.delay_ms(500);
    }
}

まだですか?

プログラムのビルドには成功しましたが、もうこれをフラッシュして(micro:bit の永続メモリに書き込み、実行して)よいのでしょうか? まだですが、かなり近づいています。その前に、あといくつか完了しなければならない手順があります。

cargo embed を実行してみると、次のエラーが表示されます。

...other warnings...
WARN probe_rs::flashing::loader: No loadable segments were found in the ELF file.
Error No loadable segments were found in the ELF file.

このエラーは、コンパイラが作成したファイル(ELF ファイル)に、フラッシャーが micro:bit に書き込むべき実際のコードやデータが含まれていないことを意味します。言い換えると、そのファイルはフラッシャーの視点では空です。

なぜこうなったのでしょうか? それは、コンパイラがプログラムをメモリ上のどこに配置すればよいかを知らないためです。cortex-m-rt crate を使っているとはいえ(これによりスタートアップコードやその他のサポートが提供されます)、リンカーは依然として micro:bit のメモリレイアウトを把握する必要があります。この情報がないと、実際のプログラムを出力ファイルに配置する処理をスキップしてしまいます。

エラーの修正

これを解決するには、link.x という特別なスクリプトを使うようコンパイラに伝える必要があります。このスクリプトは cortex-m-rt crate によって提供され、コードやデータをメモリ上にどのように配置するかをコンパイラに伝えます。

.cargo/config.toml を次のように更新してください。

[target.thumbv7em-none-eabihf]
rustflags = ["-C", "link-arg=-Tlink.x"]

この行は、コンパイラに「メモリレイアウトを決定するために link.x を使ってください」と伝えるフラグを追加しています。

memory.x

cortex-m-rt crate のドキュメントによると、マイクロコントローラのメモリレイアウトを定義するために memory.x というファイルが必要です。このファイルは、フラッシュと RAM がどこから始まり、どれくらいの大きさなのかをリンカーに伝えます。

では……それはどこにあるのでしょうか?

この時点で実際にデバイスへフラッシュしてみると、プログラムは micro:bit に書き込まれて実行されるはずです。これは、memory.x ファイルが nrf52833-hal crate から自動的に取り込まれるためです。ここで確認できます: https://github.com/nrf-rs/nrf-hal/blob/master/nrf52833-hal/memory.x

ただし、曖昧さを避けるためには、自分たちのプロジェクト内で memory.x ファイルを定義しておくほうが望ましいです。そこで、プロジェクトのルートフォルダに memory.x という名前のファイルを作成し、次の内容を記述してください。

MEMORY
{
  FLASH : ORIGIN = 0x00000000, LENGTH = 512K
  RAM : ORIGIN = 0x20000000, LENGTH = 128K
}

これは、フラッシュと RAM がどこから始まり、どれだけのメモリが利用可能かをリンカーに伝えます。

  • ORIGIN = 0x00000000 は、フラッシュメモリがアドレス 0x00000000 から始まることを意味します。
  • LENGTH = 512K は、フラッシュメモリのサイズが 512 キロバイト(512 × 1024 バイト)であることを意味します。
  • ORIGIN = 0x20000000 は、RAM がアドレス 0x20000000 から始まることを意味します。
  • LENGTH = 128K は、RAM のサイズが 128 キロバイトであることを意味します。

これらのアドレスとサイズは、nRF52833 のドキュメントに基づいています。


この時点で、プロジェクトフォルダは次のようになっているはずです。

├── .cargo
│   └── config.toml
├── Cargo.toml
├── memory.x
└── src
    └── main.rs

フラッシュする

それでは、プログラムをビルドしてフラッシュしてみましょう。

cargo flash

# OR

cargo embed

今回は、ELF ファイルに有効なロード可能セグメントが含まれるため、フラッシャーはそれを micro:bit のフラッシュメモリに書き込めるようになります。

すべてうまくいけば、プログラムは micro:bit 上で実行されているはずです。最初の LED が点滅しているのが見えるでしょう。

参考資料

LEDで遊ぼう

ふう……長い章でしたね。始めたばかりなら、少し圧倒されたかもしれません。ですが、ここでひと休みして、LED マトリクスで遊んでみましょう!

BSP には、シンプルで初心者にも扱いやすい API が用意されています。micro:bit 上の LED マトリクスの実際の配置に合わせた 2 次元配列を定義するだけで使えます。LED を点灯するには 1、消灯するには 0 を使います。残りの処理はすべて BSP が裏側で引き受けてくれます。

これは、ハート形を表示する LED マトリクスの例です。Ferris(カニ)の形も作ってみたのですが、あまりそれらしく見えず、これがカニだと納得してもらう必要がありそうでした。なので、代わりに素直にハート形を表示することにします。

#![allow(unused)]
fn main() {
// ハート形の LED マトリクス
let led_matrix = [
    [0, 1, 0, 1, 0],
    [1, 1, 1, 1, 1],
    [1, 1, 1, 1, 1],
    [0, 1, 1, 1, 0],
    [0, 0, 1, 0, 0],
];
}

Display

BSP は Display 構造体を提供しており、ボードの display_pins を使って初期化できます。いくつか便利な関数が用意されていますが、特に重要なのは showclear です。show 関数は 2 次元配列を受け取り、与えた値に応じて LED を点灯します。内部でどのように動作しているのか気になる場合は、ソースコードをこちらで確認できます。

テンプレートからプロジェクトを作成

これからは、新しいプロジェクトを始めるたびに .cargo/config.tomlmemory.x を作成したり、依存関係を手動で追加したりすることはしません(基本的な依存関係だけであっても)。代わりに、テンプレートベースの方法を使って、プロジェクトのセットアップをずっと簡単にします。

テンプレートを使って新しいプロジェクトを生成するには、次のコマンドを実行します。

cargo generate --git https://github.com/ImplFerris/mb2-template.git --rev 88d339b

プロジェクト名の入力を求められたら、led-matrix のような名前を入力してください。

"BSP" または "HAL" を選択するよう求められたら、"BSP" を選んでください。

プロジェクトが作成されたら、src/main.rs を次のコードで更新します。

完全なコード

#![no_std]
#![no_main]

use embedded_hal::delay::DelayNs;
use microbit::{board::Board, display::blocking::Display, hal::timer::Timer};

use cortex_m_rt::entry;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    let board = Board::take().unwrap();

    let mut timer = Timer::new(board.TIMER0);

    let mut display = Display::new(board.display_pins);
    let led_matrix = [
        [0, 1, 0, 1, 0],
        [1, 1, 1, 1, 1],
        [1, 1, 1, 1, 1],
        [0, 1, 1, 1, 0],
        [0, 0, 1, 0, 0],
    ];
    loop {
        display.show(&mut timer, led_matrix, 1000);
        display.clear();
        timer.delay_ms(1000);
    }
}

このコードの内容はほとんどすべて直感的でシンプルですが、ひとつだけ気になる点があります。show 関数の 3 番目の引数、つまり 1000 を渡しているあれは何でしょうか?

これは duration_ms と呼ばれますが、その duration は何に使われているのでしょうか? いいえ、点滅効果のためではありません。点滅はすでに次のコードで別途処理しています。

#![allow(unused)]
fn main() {
display.clear();
timer.delay_ms(1000);
}

では、duration_ms は実際には何をしているのでしょうか?

前に学んだように、micro:bit の 5x5 LED マトリクスは多重化されています。一度に点灯するのは 1 行だけで、列は画像に応じて設定されます。内部では show 関数が各行を順番に処理し、正しい LED を点灯させ、delay.delay_us(...) で少し待ってから、次の行に移ります。

この走査は非常に高速に行われるため、画像全体が一度に表示されているように見えます。duration_ms の値は、この走査をどれくらいの時間繰り返すかをディスプレイに指示します。

要するに: duration_ms(3 番目の引数)は、画像が画面に表示され続ける時間を制御します。その値を調整して、変化を確認してみてください。

既存のプロジェクトをクローン

私が作成したプロジェクトをクローン(または参照)して、led-matrix フォルダーに移動することもできます。

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp/led-matrix

書き込み

プログラムを micro:bit に書き込むと、点滅するハート形が表示されるはずです

cargo flash

文字を表示する

LEDマトリクスにハートの形を表示できました。次はもう一歩進んで、そこにいくつかの文字を表示してみましょう。

まずは文字 'R' を表示してみましょう。

#![allow(unused)]
fn main() {
// 'R' のマトリクス
[
    [1, 1, 1, 0, 0],
    [1, 0, 0, 1, 0],
    [1, 1, 1, 0, 0],
    [1, 0, 1, 0, 0],
    [1, 0, 0, 1, 0],
],
}

テンプレートからプロジェクトを作成する

テンプレートを使って新しいプロジェクトを生成するには、次のコマンドを実行します。

cargo generate --git https://github.com/ImplFerris/mb2-template.git --rev 88d339b

プロジェクト名の入力を求められたら、led-char のような名前を入力します。

プロジェクトが作成されたら、src/main.rs を次のコードで更新します。

完全なコード

#![no_std]
#![no_main]

use embedded_hal::delay::DelayNs;
use microbit::{board::Board, display::blocking::Display, hal::timer::Timer};

use cortex_m_rt::entry;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    let board = Board::take().unwrap();

    let mut timer = Timer::new(board.TIMER0);

    let mut display = Display::new(board.display_pins);
    let led_matrix = [
        [1, 1, 1, 0, 0],
        [1, 0, 0, 1, 0],
        [1, 1, 1, 0, 0],
        [1, 0, 1, 0, 0],
        [1, 0, 0, 1, 0],
    ];
    loop {
        display.show(&mut timer, led_matrix, 1000);
        display.clear();
        timer.delay_ms(1000);
    }
}

既存のプロジェクトをクローンする

作成済みのプロジェクトをクローン(または参照)して、led-char フォルダに移動することもできます。

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp/led-char

書き込み

このプログラムを micro:bit に書き込むと、文字が表示されるはずです。

cargo flash

スクロール効果

このセクションでは、1 文字に対するスクロール効果を作成します。文字は右から左へスクロールし、端の外へ消えた後、再び右側から現れます。

microbit-bsp という別の BSP クレートもあり、テキストのスクロールを組み込みでサポートしています。ただし、これは Embassy フレームワークと非同期プログラミング(async)を使用します。まだ Embassy や async の概念を導入していないため、ここではそのクレートを使わないことにします。

したがって、今のところは microbit-v2 クレートを使い、自分たちでスクロールのロジックを実装します。

ロジック

2 次元配列を使って LED マトリクスを点灯させる方法は、すでに知っています。では、その知識を使ってスクロール効果を作る方法を考えてみてください。これを実現する方法は複数あります。まずは自分なりのロジックを考えてみることをおすすめします。

以下では、その実装方法の一例を示します。ただし、これはあくまで 1 つのアプローチであり、最も効率的または洗練された解決策とは限らない点に注意してください。

全体のコード

#![no_std]
#![no_main]

use embedded_hal::delay::DelayNs;
use microbit::{board::Board, display::blocking::Display, hal::timer::Timer};
use cortex_m_rt::entry;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    let board = Board::take().unwrap();
    let mut timer = Timer::new(board.TIMER0);
    let mut display = Display::new(board.display_pins);

    // 'R' の 5x5 表現
    let r_char = [
        [1, 1, 1, 0, 0],
        [1, 0, 0, 1, 0],
        [1, 1, 1, 0, 0],
        [1, 0, 1, 0, 0],
        [1, 0, 0, 1, 0],
    ];

    let mut offset = 0;

    loop {
        let mut frame = [[0; 5]; 5];

        for row in 0..5 {
            for col in 0..5 {
                let char_col = col as isize + offset - 4;

                if char_col >= 0 && char_col < 5 {
                    frame[row][col] = r_char[row][char_col as usize];
                } else {
                    frame[row][col] = 0;
                }
            }
        }

        display.show(&mut timer, frame, 500);
        timer.delay_ms(100);

        offset += 1;
        if offset > 8 {
            offset = 0;
        }
    }
}

画面上に文字のどの部分を表示するかを制御するために、offset という変数を使います。offset が増えるにつれて、文字は左に移動します。

offset を使ったスクロール

この LED マトリクスは横 5 列です。文字を右から入れて、全体を表示し、そのまま左へ流して画面外に出すには、5 ステップを超える段階を考慮する必要があります。

順を追って見ていきましょう:

  • 文字は最初、右側で完全に画面外にあります。

  • その後、1 列ずつ表示領域に入ってきます。

  • 中央で完全に表示されます。

  • 最後に、1 列ずつ左へ出ていき、見えなくなります。

この一連の流れ全体をカバーしたいわけです:

[off-screen right] --> [entering display] --> [fully visible] --> [leaving display] --> [off-screen left]

これには合計 9 ステップ(offset は 0 から 8)かかります:

Offset画面上で起こること文字の何列が表示されるか表示領域に対する文字の位置
0文字の最初の列がいちばん右に現れる1文字の大部分は右側で画面外にある
1最初の2 列が現れる2文字がさらにスライドして入ってくる
2最初の 3 列が現れる3半分見えている
3最初の 4 列が現れる4ほぼ完全に見えている
4文字全体が完全に表示される5通常どおりの見え方になる
5最初の列が左側から消え始める4文字が左へ流れ始める
6中央と右側だけが見えている3文字のさらに多くの部分が画面外へ出ている
7文字の最後の部分だけが残る2ほとんど消えかけている
8文字が完全に消える0左側で完全に画面外に出ている

フレームの作成

ディスプレイに送る新しい 5x5 のフレームを作成します。フレーム内の各 LED について:

その位置に表示すべき文字の列(r_char)を計算します。これは次の式で行います:

#![allow(unused)]
fn main() {
let char_col = col as isize + offset - 4;
}

これにより、文字がゆっくり左へずれていきます。

char_col が有効な範囲(0 ~ 4)にあるかを確認します。範囲内であれば、そのピクセルを文字からコピーします。そうでなければ、0(LED オフ)を設定します。

ループしてアニメーションさせる

これをループの中で繰り返します:

  • 現在のフレームを 500 ms 表示し、その後 100 ms 待つ

  • offset を増やす

  • 9 に達したら offset を 0 に戻す

これにより、文字が右から左へスクロールして消え、再び現れるように見えます。

offset、LED マトリクスの "col"、char_col の関係

理解を深めるために、offset が増えるにつれてこれらの値がどのように変化するかを見ていきましょう。

offset = 0

R の最初の列(インデックス 0)だけが右端の列に表示されます。

char_col = col + offset - 4 = col - 4

col01234
char_col-4-3-2-10
. . . . #
. . . . #
. . . . #
. . . . #
. . . . #

注: 0 と 1 をそのまま使うと、形がはっきり見えにくくなることがあります。そのため、この図では # 記号で LED が点灯している場所(つまり値 1)を表しています。ドット(.)は消灯している LED(値 0)を表します。

offset = 1

3 列目と 4 列目に、それぞれ文字の 0 列目と 1 列目が表示されます。

char_col = col + 1 - 4 = col - 3

col01234
char_col-3-2-101
. . . # #
. . . # .
. . . # #
. . . # .
. . . # .

offset = 4

offset が 4 のケースに進みましょう。この時点では、文字全体がディスプレイに完全に表示されています。

char_col = col + 4 - 4 = col

これは、char_col と col が等しいことを意味するので、frame 配列は元の文字配列とそのまま一致します。

col01234
char_col01234
# # # . .
# . . # .
# # # . .
# . # . .
# . . # .

offset = 5

char_col = col + 5 - 4 = col + 1

col01234
char_col12345
# # . . .
. . # . .
# # . . .
. # . . .
. . # . .

ここで、各 char_col は対応する col より 1 大きくなります。char_col が 5 になると、範囲外になります(配列のインデックスは 0 から 4 までしかないためです)。

これを防ぐために、境界チェックを追加します。char_col が有効範囲の外にある場合は、その列を 0 で埋めます。これにより、文字がスクロールして外へ出ていくときの消えていく効果が生まれます。

if char_col >= 0 && char_col < 5 {
    frame[row][col] = r_char[row][char_col as usize];
} else {
    frame[row][col] = 0;
}

完全なコード

#![no_std]
#![no_main]

use embedded_hal::delay::DelayNs;
use microbit::{board::Board, display::blocking::Display, hal::timer::Timer};
use cortex_m_rt::entry;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    let board = Board::take().unwrap();
    let mut timer = Timer::new(board.TIMER0);
    let mut display = Display::new(board.display_pins);

    // 'R' の 5x5 表現
    let r_char = [
        [1, 1, 1, 0, 0],
        [1, 0, 0, 1, 0],
        [1, 1, 1, 0, 0],
        [1, 0, 1, 0, 0],
        [1, 0, 0, 1, 0],
    ];

    let mut offset = 0;

    loop {
        let mut frame = [[0; 5]; 5];

        for row in 0..5 {
            for col in 0..5 {
                let char_col = col as isize + offset - 4;

                if char_col >= 0 && char_col < 5 {
                    frame[row][col] = r_char[row][char_col as usize];
                } else {
                    frame[row][col] = 0;
                }
            }
        }

        display.show(&mut timer, frame, 500);
        timer.delay_ms(100);

        offset += 1;
        if offset > 8 {
            offset = 0;
        }
    }
}

既存のプロジェクトをクローンする

作成したプロジェクトをクローン(または参照)して、led-scroll フォルダーに移動することもできます。

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp/led-scroll

フラッシュ

このプログラムを micro:bit にフラッシュできます。

cargo flash

スマイリーボタン

この章では、micro:bit の 2 つのオンボードボタンの使い方を見ていきます。Smiley Buttons プロジェクトは、インタラクティブな入力を導入する、初心者にやさしい優れた演習です。内蔵ボタン A と B を押すことで、micro:bit の LED 画面に異なる表情を表示します。

  • ボタン A を押すと、うれしい顔 😊 を表示します

  • ボタン B を押すと、悲しい顔 🙁 を表示します

ボタンを理解する

🪝 micro:bit のドキュメントより:

ボタンは一般的な反転電気モードで動作し、プルアップ抵抗によってボタンが離されているときは論理値 ‘1’ が保証され、ボタンが押されているときは論理値 ‘0’ になります

最初は少し技術的に聞こえるかもしれませんので、もう少しわかりやすく説明します。

ボタンが押されていないとき、micro:bit は入力を論理 HIGH(つまり 1)として読み取ります。これは、入力ピンの電圧レベルを高く保つプルアップ抵抗があるためです。

ボタンが押されると、回路はグラウンドに接続され、micro:bit は論理 LOW(つまり 0)を読み取ります。

micro:bit のボタン

ボタンを押すことは直感的には何かを有効にすることのように思えるかもしれませんが、ハードウェアは反転した方式で動作します。コードでは、ボタンが押されたことを検出するために LOW 信号を確認します。そのため、ボタン入力に対して is_low() メソッドを使い、押されているかどうかを確認します。

テンプレートからプロジェクトを作成する

テンプレートを使って新しいプロジェクトを生成するには、次のコマンドを実行します。

cargo generate --git https://github.com/ImplFerris/mb2-template.git --rev 88d339b

プロジェクト名を求められたら、smiley-buttons のような名前を入力します

"BSP" または "HAL" を選択するよう求められたら、"BSP" を選択します。

絵文字用のマトリクス

以下は、うれしい顔と悲しい顔を表す 2 次元配列です。

#![allow(unused)]
fn main() {
let happy_face = [
        [0, 0, 0, 0, 0],
        [0, 1, 0, 1, 0],
        [0, 0, 0, 0, 0],
        [1, 0, 0, 0, 1],
        [0, 1, 1, 1, 0],
    ];

    let sad_face = [
        [0, 0, 0, 0, 0],
        [0, 1, 0, 1, 0],
        [0, 0, 0, 0, 0],
        [0, 1, 1, 1, 0],
        [1, 0, 0, 0, 1],
    ];
}

初期化

いつものように、まずボードを初期化し、その後でディスプレイとタイマーを初期化します。また、扱いやすいように、ボードから button_a と button_b を取得して変数に保存します。

#![allow(unused)]
fn main() {
    let board = Board::take().unwrap();

    let mut display = Display::new(board.display_pins);
    let mut timer = Timer::new(board.TIMER0);

    let mut button_a = board.buttons.button_a;
    let mut button_b = board.buttons.button_b;
}

ボタン入力とスマイリーの表示

ボタンとディスプレイの初期化が完了したので、ボタン入力に反応し、LED 画面に適切な表情を表示するループを書けます。

#![allow(unused)]
fn main() {
    loop {
        let a_pressed = button_a.is_low().unwrap_or(false);
        let b_pressed = button_b.is_low().unwrap_or(false);

        if a_pressed {
            display.show(&mut timer, happy_face, 1000);
            timer.delay_ms(100);
        } else if b_pressed {
            display.show(&mut timer, sad_face, 1000);
            timer.delay_ms(100);
        }
    }
}

このループでは、.is_low() メソッドを使って各ボタンの状態を確認します。micro:bit のボタンはアクティブローなので、このメソッドはボタンが押されているときに true を返します。潜在的なエラーに対処するために .unwrap_or(false) を使っています。何らかの理由で結果を読み取れない場合は、単に false を返し、ボタンは押されていないものとして扱われます。

ボタン A が押されると、うれしい顔のパターンが 1 秒間 LED ディスプレイに表示されます。同様に、ボタン B が押されると、悲しい顔が表示されます。

各表示の後には 100 ミリ秒の短い遅延が入り、視覚的にわかりやすい効果を与えるとともに、ボタンを押し続けていることによる繰り返し更新を防ぎます。

完全なコード

この演習には、前回にはなかった新しい import が含まれています: embedded_hal::digital::InputPin。この trait は Embedded HAL の一部であり、入力ピンの状態を読み取るための is_low()is_high() のようなメソッドを提供します。

#![no_std]
#![no_main]

use cortex_m_rt::entry;
use embedded_hal::{delay::DelayNs, digital::InputPin};
use microbit::{display::blocking::Display, hal::Timer, Board};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    let board = Board::take().unwrap();

    let mut display = Display::new(board.display_pins);
    let mut timer = Timer::new(board.TIMER0);

    let mut button_a = board.buttons.button_a;
    let mut button_b = board.buttons.button_b;

    let happy_face = [
        [0, 0, 0, 0, 0],
        [0, 1, 0, 1, 0],
        [0, 0, 0, 0, 0],
        [1, 0, 0, 0, 1],
        [0, 1, 1, 1, 0],
    ];

    let sad_face = [
        [0, 0, 0, 0, 0],
        [0, 1, 0, 1, 0],
        [0, 0, 0, 0, 0],
        [0, 1, 1, 1, 0],
        [1, 0, 0, 0, 1],
    ];

    loop {
        let a_pressed = button_a.is_low().unwrap_or(false);
        let b_pressed = button_b.is_low().unwrap_or(false);

        if a_pressed {
            display.show(&mut timer, happy_face, 1000);
            timer.delay_ms(100);
        } else if b_pressed {
            display.show(&mut timer, sad_face, 1000);
            timer.delay_ms(100);
        }
    }
}

既存のプロジェクトをクローンする

私が作成したプロジェクトをクローン(または参照)して、bsp/smiley-buttons フォルダーに移動することもできます。

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp/smiley-buttons

書き込み

コードが完成したら、次のコマンドを使ってプログラムを micro:bit に書き込めます。

cargo flash

プログラムがデバイス上で動作すると、button A を押すとうれしい顔が表示され、button B を押すと LED ディスプレイに悲しい顔が表示されます。

タッチセンシング

micro:bit V2 には、基本的なタッチセンシングのサポートが組み込まれています。これにより、ボード前面の特定のピンや金色のロゴに誰かが触れたことを検出できます。機械式ボタンとは異なり、この機能は、指がピンに近づいたときの電荷のわずかな変化を検出して動作します。これにより、タッチスクリーンをタップするのと同じように、単純なタッチでアクションをトリガーできるインタラクティブなプロジェクトを作成できます。

micro:bit でタッチセンシングが可能なのは、身体がピンに接触したときの電圧変化を検出する特別な回路とソフトウェアがあるためです。

タッチセンシングの仕組み

micro:bit V2 は、特定の GPIO ピン(P0、P1、P2)とロゴ(micro:bit v2 ピンマップ に示されているように、P1_04 GPIO ピンに接続されています)で静電容量式タッチセンシングを使用します。これは、通常は機械式スイッチを押すことを伴う一般的なデジタル入力とは異なります。

静電容量式センシングは、静電容量の変化の検出に基づいています。身体は導体であり、触れたり近づいたりすると micro:bit のピンとの間にコンデンサを形成します。ボードは、ピンが電気的に充電または放電するのにかかる時間を監視し、指があると、身体による追加の静電容量のためにこの時間が変化します。

弱いプルアップ抵抗

タッチセンシングモードでは、通常 10 MΩ の内部の弱いプルアップ抵抗が使用され、GPIO ピンに接続されます。この抵抗はピンを電源電圧(約 3.0V)まで引き上げ、触れていないときは入力を論理 HIGH 状態に保ちます。

タッチセンシング

ピン(またはロゴ)に触れると、指が導体として働き、(身体と周囲の環境を介して)グラウンドへの経路を作るため、ピンがわずかに放電します。その結果、検出可能な電圧降下が生じ、論理 LOW として読み取られます。

ピンの設定

タッチセンシングを使用する場合、ピンはフローティング入力として設定する必要があります。このモードでは、他にピンを駆動するものがないため、ピン上の電圧が微小な電流(人が触れたときに生じるようなもの)の影響を受けるようになります。

#![allow(unused)]
fn main() {
let mut touch_input = board.pins.p1_04.into_floating_input();
}

これにより、デフォルトのプルダウン抵抗が無効になり、外部の静電容量がピン電圧に影響を与えられるようになります。

コードでタッチを検出する

ピンを設定したら、is_low() を使用して現在の電圧レベルを確認できます:

#![allow(unused)]
fn main() {
if touch_input.is_low().unwrap() {
    // ピンに触れている
}
}

シンプルなタッチ

micro:bit のロゴに触れたときに、LED マトリクスに文字や絵文字を表示するシンプルなプログラムを書いてみましょう。この例では、ロゴに触れている間は電圧の絵文字シンボル (⚡) を表示します。

micro:bit ロゴ

テンプレートからプロジェクトを作成する

テンプレートを使用して新しいプロジェクトを生成するには、次のコマンドを実行します:

cargo generate --git https://github.com/ImplFerris/mb2-template.git --rev 88d339b

プロジェクト名を尋ねられたら、smiley-buttons のような名前を入力します

"BSP" または "HAL" を選択するよう求められたら、"BSP" オプションを選択します。

ボード、タイマー、ディスプレイを初期化する

まずは、通常どおりボード、タイマー、ディスプレイをセットアップします:

#![allow(unused)]
fn main() {
let board = Board::take().unwrap();
let mut timer = Timer::new(board.TIMER0);
let mut display = Display::new(board.display_pins);
}

電圧記号用の LED マトリクス

#![allow(unused)]
fn main() {
let led_matrix = [
    [0, 0, 0, 1, 0],
    [0, 0, 1, 0, 0],
    [0, 1, 1, 1, 0],
    [0, 0, 1, 0, 0],
    [0, 1, 0, 0, 0],
];
}

ロゴのピンをタッチ入力として設定する

GPIO ピン p1_04(内部的に小さな micro:bit ロゴに接続されています)をフローティング入力として設定します。これにより、静電容量式センシングを使用してタッチを検出できます。

ロゴに触れると、micro:bit は LED マトリクスに電圧記号を 500 ミリ秒間表示し、その後ディスプレイをクリアします。

#![allow(unused)]
fn main() {
// Logo に接続されたピン
let mut touch_input = board.pins.p1_04.into_floating_input();

loop {
    if touch_input.is_low().unwrap() {
        display.show(&mut timer, led_matrix, 500);
    } else {
        display.clear();
    }
}
}

完全なコード

#![no_std]
#![no_main]

use embedded_hal::digital::InputPin;
use microbit::{board::Board, display::blocking::Display, hal::timer::Timer};

use cortex_m_rt::entry;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    let board = Board::take().unwrap();
    let mut timer = Timer::new(board.TIMER0);
    let mut display = Display::new(board.display_pins);
    let led_matrix = [
        [0, 0, 0, 1, 0],
        [0, 0, 1, 0, 0],
        [0, 1, 1, 1, 0],
        [0, 0, 1, 0, 0],
        [0, 1, 0, 0, 0],
    ];

    // Logo に接続されたピン
    let mut touch_input = board.pins.p1_04.into_floating_input();

    loop {
        if touch_input.is_low().unwrap() {
            display.show(&mut timer, led_matrix, 500);
        } else {
            display.clear();
        }
    }
}

既存のプロジェクトをクローンする

私が作成したプロジェクトをクローンする(または参照する)こともでき、bsp/logo-touch フォルダーに移動します。

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp/logo-touch

フラッシュ

コードが完成したら、次のコマンドを使用してプログラムを micro:bit にフラッシュできます:

cargo flash

nRF HAL の紹介

ほかの例に進む前に、まず nRF51、nRF52、nRF91 ファミリーのマイクロコントローラー向け Hardware Abstraction Layer(HAL)を紹介します。

すでにご存じかもしれませんが、micro:bit v2 は Nordic の nRF52833 マイクロコントローラーを使用しています。

これまでは BSP レベルのクレートを扱ってきました。ここからは、HAL へさらに 1 層深く進みます。そのために、nRF52833 もサポートしている nrf-hal を使用します。

詳細については、nrf-hal GitHub リポジトリ を参照してください。さまざまなユースケース向けの例も含まれています。

Blinky の書き換え

シンプルにするために、nrf-hal を使って blinky の例を書き換えてみましょう。

プロジェクトをゼロから作成する場合、通常は nrf52833-hal を依存関係として手動で追加します。ただし、ここではすでにそれを含んでいるテンプレートを使用します。テンプレートの Cargo.toml には、次のような行があります。

nrf52833-hal = "0.18.0" # テンプレート内ではバージョンが異なる可能性があります

テンプレートからプロジェクトを作成

テンプレートを使って新しいプロジェクトを生成するには、次のコマンドを実行します。

cargo generate --git https://github.com/ImplFerris/mb2-template.git --rev 88d339b

プロジェクト名の入力を求められたら、led-blinky のような名前を入力してください(すでにこの名前のプロジェクトがある場合は、別の名前を使うか、私のように HAL ベースのプロジェクトを別のフォルダーに配置してください)。

「BSP」または「HAL」を選択するよう求められたら、「HAL」を選択してください。

ペリフェラル

組み込みシステムにおいて、ペリフェラルはマイクロコントローラー(MCU)の機能を拡張するハードウェアコンポーネントです。これにより、MCU は入力と出力、通信、タイミング処理などを担うことで、外部の世界とやり取りできるようになります。

CPU はプログラムロジックの実行を担当する一方で、ペリフェラルはハードウェアとのやり取りという実作業の大部分を担い、多くの場合 CPU の処理を肩代わりします。これにより、CPU は重要なタスクに集中でき、ペリフェラルは専用の機能を独立して、または最小限の監視のもとで処理できます。

オフロード

オフロードとは、特定のタスクを CPU 経由でソフトウェアとして直接実行するのではなく、ハードウェアペリフェラルに委譲する実践を指します。これにより、性能が向上し、消費電力が低減され、並行動作が可能になります。たとえば、次のような例があります。

  • UART ペリフェラルは、CPU が他のロジックの処理を続けている間も、DMA(Direct Memory Access)を使ってバックグラウンドでデータの送受信を行えます。
  • Timer は、CPU の介入なしに、正確な遅延や周期的な割り込みを生成するよう設定できます。
  • PWM コントローラーは、CPU が継続的にピンをトグルし続けなくても、モーターを連続的に駆動できます。

オフロードは、限られた処理能力を効率的に活用するための、組み込みシステムにおける重要な設計戦略です。

一般的なペリフェラルの種類

以下は、組み込みシステムでよく使われる代表的なペリフェラルの種類です。

PeripheralDescription
GPIO (General Purpose Input/Output)ボタン、LED、センサーなどの外部ハードウェアとやり取りするために、入力または出力として設定できるデジタルピンです。
UART (Universal Asynchronous Receiver/Transmitter)デバイス間でデータを送受信するために使われるシリアル通信インターフェースで、デバッグや Bluetooth のようなモジュールの接続によく使われます。
SPI (Serial Peripheral Interface)マスター・スレーブ構成を用いて、マイクロコントローラーを SD カード、ディスプレイ、センサーなどのペリフェラルに接続するために使われる高速な同期通信プロトコルです。
I2C (Inter-Integrated Circuit)センサーやメモリチップのような低速ペリフェラルをマイクロコントローラーに接続するために使われる 2 線式シリアル通信プロトコルです。
ADC (Analog-to-Digital Converter)センサーやその他の信号源からのアナログ信号を、マイクロコントローラーが処理できるデジタル値に変換します。
PWM (Pulse Width Modulation)電力供給を制御できる信号を生成し、LED の調光、モーターの速度制御、サーボの駆動によく使われます。
Timer遅延の生成、時間間隔の測定、イベントのカウント、または特定の時刻での動作のトリガーに使われます。
RTC (Real-Time Clock)システムの電源がオフのときでも、通常はバッテリーによって保持され、現在の時刻と日付を追跡します。

Rust におけるペリフェラル

組み込み Rust では、ペリフェラルはシングルトンモデルを使ってアクセスします。Rust の中核的な目標の 1 つは安全性であり、それはハードウェアアクセスの管理方法にも及びます。プログラム内の 2 つの部分が同時に同じペリフェラルを誤って制御してしまわないようにするため、Rust はこのシングルトンアプローチによって排他的な所有権を強制します。

シングルトンパターン

シングルトンパターンは、各ペリフェラルのインスタンスがプログラム全体で 1 つしか存在しないことを保証します。これにより、複数のコードが同じハードウェアリソースを同時に変更しようとすることで生じる一般的なバグを防げます。

このパターンは、すでに BSP クレートで見てきました。そこでは、ボード全体(すべてのペリフェラル、ピン、設定を含む)がシングルトンとしてラップされています。

nRF hal でも、ペリフェラルはシングルトンオブジェクトとして公開されています。

コードを変更する

それでは、Rust のシングルトンモデルを使ってマイクロコントローラーのペリフェラルのインスタンスを作成するように、src/main.rs ファイルを変更してみましょう。

ステップ 1: Peripherals をインポートする

まず、src/main.rs を開いて、必要な import を追加します。

#![allow(unused)]
fn main() {
use nrf52833_hal::pac::Peripherals;
}

ステップ 2: ペリフェラルの所有権を取得する

main 関数の中に、次の行を追加します。

#![allow(unused)]
fn main() {
let peripherals = Peripherals::take().unwrap();
}

この行は、次のことを行います。

  • デバイスのペリフェラルの所有権を取得します。
  • Peripherals 構造体のインスタンスを返します。この構造体には、GPIO などのすべてのハードウェアブロックへのアクセスが含まれています。
  • プログラムの存続期間中に呼び出せるのは 1 回だけです。もう一度呼び出すと、None が返されます。

GPIO ピン

最初の LED を点灯させるには、最初の列を LOW に設定し、その後で最初の行を HIGH に設定する必要がありました。これで回路が閉じ、LED が点灯します。

BSP クレートを使っていたときは、とてもわかりやすいものでした。BSP は row1、row2、あるいは col1、col2 のような名前を提供してくれるので、それらをコード内で簡単に使えました。また、LED マトリクス全体を表現するために 2 次元配列を使うこともできました。

しかし HAL を使う場合は、GPIO ピンを直接扱います。row1 や col1 の代わりに、p0_28、p0_21 などのピン名を使います。これらはマイクロコントローラ上の実際のピンであり、入力または出力として設定します。

GPIO とは何か?

GPIO は General Purpose Input Output の略です。これは、コードから制御できるマイクロコントローラ上のピンです。

各 GPIO ピンは次のいずれかになります。

  • 出力 : HIGH(電源のようなもの)または LOW(GND のようなもの)に設定できます
  • 入力 : センサーやボタンから HIGH か LOW かを読み取れます

GPIO ピンは、コードから制御するスイッチのようなものだと考えてください。ピンを HIGH に設定すると、電源のように振る舞います。LOW に設定すると、GND への経路のように振る舞います。

いくつかのピンを HIGH に、ほかのピンを LOW に設定することで、LED、ボタン、センサーなどを制御できます。

どの GPIO を使うべきかを知るには?

どの GPIO ピンを使うべきかを調べるには、通常、使用しているマイクロコントローラやボードのデータシートまたは技術リファレンスマニュアルを確認します。micro:bit のドキュメントにあるピンマップも参照できます。

わかりやすくするために、LED マトリクス内の各行と各列が nRF52833 マイクロコントローラ上の実際の GPIO ピンにどのように接続されているかを示す表を以下に示します。

マトリクス上の役割電気的役割ポートピン
ROW1ソースp021
ROW2ソースp022
ROW3ソースp015
ROW4ソースp024
ROW5ソースp019
COL1シンクp028
COL2シンクp011
COL3シンクp031
COL4シンクp105
COL5シンクp030

Pin 列は GPIO ピン番号を示しています。Port はまだ紹介していない用語です。これは単に、マイクロコントローラによってまとめて管理される GPIO ピンのグループです。マイクロコントローラでは、GPIO ピンは Port 0(p0)や Port 1(p1)のようなポートに整理されていることがよくあります。各ポートは複数のピンを制御できます。

私たちの目標は、1 行目 1 列目にある最初の LED を点灯させることです。最初の行の GPIO ピンは p0_21 です。最初の列の GPIO ピンは p0_28 です。

コードを変更する

では、src/main.rs ファイルを変更して、GPIO ポートと必要な特定のピンを初期化しましょう。

まず、必要なインポートを追加します。

#![allow(unused)]
fn main() {
use nrf52833_hal::gpio::Level; 
use nrf52833_hal as hal;
}

ここで、Level はピンの論理レベルを表す列挙型です。Level::High(論理的な高電圧)または Level::Low(論理的な低電圧)のいずれかになります。

また、nrf52833_hal クレートを hal というエイリアスでインポートしています。これにより、コード全体でモジュールや型を参照するときに、クレートの完全な名前を毎回書かずに済みます。

main 関数の中に、次のコードを追加します。

#![allow(unused)]
fn main() {
let port0 = hal::gpio::p0::Parts::new(peripherals.P0);
let _col1 = port0.p0_28.into_push_pull_output(Level::Low);
let mut row1 = port0.p0_21.into_push_pull_output(Level::High);
}

このコードが行うことは次のとおりです。

  • port0 により、Port 0 内の個々のピンにアクセスできます。
  • _col1プッシュプル出力 として設定され、LOW に駆動されます。これは、そのピンが GND に接続されることを意味します。これにより、その列へ電流が流れ込めるようになり、列が「有効化」されます。
  • row1プッシュプル出力 として設定されますが、こちらは HIGH に駆動されます。これは、そのピンが電源(3.3V など)に接続されることを意味します。これにより、電流の供給元を与えることで、その行が「選択」されます。

プッシュプル出力とは?

プッシュプル出力は、マイクロコントローラの GPIO ピンでよく使われる電気的な構成です。このモードでは、ピンは出力を HIGH と LOW の両方に能動的に駆動できます。

  • HIGH に設定されると、ピンは電源電圧(通常は 3.3V または 5V)に接続され、接続された部品へ電流を供給できます。
  • LOW に設定されると、ピンは GND に接続され、電流を吸い込むことができます。

これは、ピンが浮いた状態のままになることがなく、常に明確な電圧レベルを持つことを意味します。LED やリレーのようなデジタル出力を駆動したり、論理信号を制御したりするのに適しています。

ラインを LOW に引き下げることしかできず、HIGH にするには外部プルアップ抵抗が必要な オープンドレインオープンコレクタ 出力とは異なり、プッシュプル はその両方を行えるため、汎用出力にとってシンプルで信頼性の高い方式です。

組み込みシステムにおけるタイマー

組み込みシステムでは、timer はクロックサイクルをカウントする特別なハードウェア周辺機能です。これにより、時間を測定したり、遅延後に何かをトリガーしたりできます。CPU時間を浪費する単純なループとは異なり、タイマーはハードウェア上でカウントを続けるため、CPUはほかの処理を行ったり、スリープ状態に入ったりできます。

タイマーは次のような用途に使用できます。

  • LEDを一定間隔で点滅させる
  • 操作にどれくらい時間がかかるかを測定する
  • 周期的なタスクをトリガーする
  • 正確な遅延を生成する
  • モーターやLEDのPWM信号を制御する

タイマーは、どのマイクロコントローラーでも最も便利な周辺機能の1つです。ほとんどのマイクロコントローラーには、TIMER0、TIMER1 などの複数のハードウェアタイマーがあります。それぞれは独立して動作します。

nRF52833のタイマー

nRF52833マイクロコントローラーには、カウンターモードを備えた5つの32ビットタイマーが含まれています。現時点では、タイマーの内部動作についてこれ以上深くは扱いません。

LEDをオンにしてからオフにするまでの間に遅延を入れて、点滅効果を作るためにタイマーを使用します。

コードを変更する

それでは、タイマー周辺機能を初期化するために src/main.rs ファイルを更新しましょう。

まず、必要なimportを追加します。

#![allow(unused)]
fn main() {
use nrf52833_hal::timer::Timer;
}

次に、main関数の中に以下の行を追加します。

#![allow(unused)]
fn main() {
let mut timer0 = Timer::new(peripherals.TIMER0);
}

この行は、HALのTimer抽象化を使ってハードウェアタイマーTIMER0を初期化します。後で、このインスタンスを使ってLEDのオンとオフの間に遅延を作成します。

すべてを組み合わせる: nrf-hal を使って LED を点滅させる

ペリフェラルのセットアップ、タイマーの初期化、必要な GPIO ピンの出力設定が完了したら、メインループはとてもシンプルになります。これは BSP の例で行ったこととよく似ています。

ループ内では、タイマーによる遅延を挟みながら row1 の状態を high と low の間で切り替えます。これによって点滅効果が生まれます。

#![allow(unused)]
fn main() {
    loop {
        timer0.delay_ms(500);
        row1.set_high().unwrap();

        timer0.delay_ms(500);
        row1.set_low().unwrap();
    }
}

完全なコード

#![no_std]
#![no_main]

use cortex_m_rt::entry;

// Embedded HAL のトレイト
use embedded_hal::delay::DelayNs;
use embedded_hal::digital::OutputPin;

// nRF HAL
use nrf52833_hal::gpio::Level;
use nrf52833_hal::pac::Peripherals;
use nrf52833_hal::{self as hal, timer::Timer};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    let peripherals = Peripherals::take().unwrap();
    let mut timer0 = Timer::new(peripherals.TIMER0);

    let port0 = hal::gpio::p0::Parts::new(peripherals.P0);
    let _col1 = port0.p0_28.into_push_pull_output(Level::Low);
    let mut row1 = port0.p0_21.into_push_pull_output(Level::High);

    loop {
        timer0.delay_ms(500);
        row1.set_high().unwrap();

        timer0.delay_ms(500);
        row1.set_low().unwrap();
    }
}

既存のプロジェクトをクローンする

私が作成したプロジェクトをクローン(または参照)して、hal/led-blinky フォルダーに移動することもできます。

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/hal/led-blinky

フラッシュ

このプログラムを micro:bit に書き込むと、点滅効果が確認できるはずです

cargo flash

Embassy

ここまでは、ブロッキングモードで動作するコードを扱ってきました。これは、たとえばしばらく delay したり、ボタンが押されるのを待ったりするようプログラムに指示すると、その処理が終わるまで CPU が停止して待機し、その後で続行することを意味します。これは理解しやすく、小規模なプログラムではうまく機能しますが、センサーを読み取りながら入力も待ち受ける、といった複数のタスクを互いにブロックさせず同時に処理したい場合には制約になります。

そこで登場するのが Embassy です。Embassy は、組み込みシステム向けに設計された非同期ランタイムです。これにより、Rust の async/await 機能を使ってノンブロッキングなコードを書けるようになります。待機して CPU 時間を無駄にする代わりに、タスクは一時停止してほかのタスクに実行を譲れるため、プロセッサをより有効に活用でき、より応答性が高く省電力なアプリケーションを実現できます。

たとえば Embassy を使えば、複雑な割り込みベースのコードを手作業で書かなくても、LED を点滅させながらタッチ入力やボタン入力を同時に待ち受けることができます。

HAL

Embassy は、複数のマイクロコントローラファミリ向けに非同期対応の Hardware Abstraction Layer (HAL) を提供しており、安全で Rust らしい API を通じて、低レベルのレジスタを直接扱わずにハードウェアを操作できます。

公式 HAL には embassy-stm32 (STM32)、embassy-nrf (nRF52/53/54/91)、embassy-rp (RP2040)、embassy-mspm0 (TI MSPM0) があります。Embassy はさらに、esp-hal (ESP32)、ch32-hal (CH32V)、mpfs-hal (PolarFire)、py32-hal (Puya PY32) などのコミュニティ製 HAL とも連携できるため、多くのプラットフォームで移植性の高い非同期コードを簡単に書けます。

すぐに使える機能

Embassy には、組み込み開発を容易にする多くの組み込み機能が備わっています。たとえば、タイマーや遅延を扱う embassy-time、ネットワーク機能を提供する embassy-net、USB デバイス機能を構築するための embassy-usb など、さまざまな機能が含まれています。

Embassy を使ったコード例(公式サイトより)

use defmt::info;
use embassy_executor::Spawner;
use embassy_nrf::gpio::{AnyPin, Input, Level, Output, OutputDrive, Pin, Pull};
use embassy_nrf::Peripherals;
use embassy_time::{Duration, Timer};

// asyncタスクを宣言する
#[embassy_executor::task]
async fn blink(pin: AnyPin) {
    let mut led = Output::new(pin, Level::Low, OutputDrive::Standard);

    loop {
        // 時間管理はグローバルに利用できるため、ハードウェアタイマーをいじる必要はありません。
        led.set_high();
        Timer::after_millis(150).await;
        led.set_low();
        Timer::after_millis(150).await;
    }
}

// Main 自体も async タスクです。
#[embassy_executor::main]
async fn main(spawner: Spawner) {
    // embassy-nrf HAL を初期化する。
    let p = embassy_nrf::init(Default::default());

    // spawn されたタスクはバックグラウンドで並行して実行される。
    spawner.spawn(blink(p.P0_13.degrade())).unwrap();

    let mut button = Input::new(p.P0_11, Pull::Up);
    loop {
        // GPIO イベントを非同期に待機し、その間ほかのタスクを
        // 実行したり、コアをスリープさせたりできる。
        button.wait_for_low().await;
        info!("Button pressed!");
        button.wait_for_high().await;
        info!("Button released!");
    }
}

役立つリソース

Embassy をサポートする Microbit BSP クレート

これまで、ブロッキングモードで動作する microbit-v2 BSP クレートを使ってきました。ここでは、Embassy による async プログラミングをサポートする別の BSP クレート microbit-bsp を紹介します。Embassy との統合に加えて、このクレートには scroll 関数のような便利なユーティリティも含まれており、LED マトリクスにスクロールテキストを簡単に表示できます。

それでは、このクレートを使ってシンプルな async プログラムを作成してみましょう。

Embassy Project Template

これまでは、この本のために特別に設計されたカスタムプロジェクトテンプレートを使ってきました。Ulf Lilleengen によって作成された Embassy Project Template も利用できます。このテンプレートは Embassy ベースのプロジェクト向けに設計されており、幅広いマイクロコントローラーをサポートしています。実際、これは microbit-bsp クレートをメンテナンスしているのと同じ人物によって作成されました。

cargo generate --git https://github.com/lulf/embassy-template.git

ターゲットのマイクロコントローラーを選択するよう求められたら、"nrf52833" を選んでください。これにより、nrf52833 チップ(micro:bit v2 を動かしているチップ)向けの Embassy サポートが設定された新しいプロジェクトが作成されます。

もともと、私は Embassy プロジェクトを生成するためにこのテンプレートを使っていました。しかし、執筆時点では embassy-nrf の最新の GitHub リビジョンが含まれていませんでした。私は embassy-nrf と microbit-bsp の両方にあるいくつかの新機能を使いたかったため、カスタムテンプレートに切り替えました。

それでも、これは優れていて便利なテンプレートなので、ここに残してあります。この本を読み終えてさらにいろいろ試してみたくなったときに役立つでしょう。

テンプレートからプロジェクトを作成

このプロジェクトでは、microbit-bsp(Embassy 対応)を使用します。テンプレートを使って新しいプロジェクトを生成するには、次のコマンドを実行してください。

cargo generate --git https://github.com/ImplFerris/mb2-template.git --rev 3d07b56
  • プロジェクト名の入力を求められたら、"led-scroll" のような名前を入力します。

  • async を使うかどうかを尋ねられたら、"true" を選択します。

  • "BSP" と "HAL" のどちらを使うかの選択を求められたら、"BSP" を選択します。

プロジェクトが生成されたら、Cargo.toml ファイルを開いてください。そこには microbit-bsp クレートと、その他の Embassy 関連クレートが含まれていることがわかります。

BSP のボイラープレートコード

src/main.rs ファイルを開いてください。そこには、Microbit 構造体のインスタンスを作成するボイラープレートコードがあります。これにより、ボードの周辺機器にアクセスできます。

#[embassy_executor::main]
async fn main(_spawner: Spawner) -> ! {
    let board = Microbit::default();

    loop {
        Timer::after_secs(1).await;
    }
}

このテンプレートには、Timer を使って 1 秒待機するシンプルなループが含まれています。これを削除して、このプロジェクト用に独自のループロジックを書いていきます。

ディスプレイを初期化する

LED マトリクスディスプレイを使うには、まずボードからその所有権を取得する必要があります。

#![allow(unused)]
fn main() {
let mut display = board.display;
}

これで内蔵の 5x5 LED ディスプレイにアクセスできるようになり、パターンやアニメーションを表示し始めることができます。

明るさの調整

BSP クレートには、LED マトリクスの明るさを制御する便利な関数があります。明るさの値は 0(Brightness::MIN)から 10(Brightness::MAX)まで指定できます。さまざまな値を試して、LED の明るさがどのように変わるかを確認してみてください。

#![allow(unused)]
fn main() {
display.set_brightness(Brightness::new(5));
}

スクロールするテキスト

BSP クレートには、LED ディスプレイ上でテキストをスクロールさせるための関数が 2 つ用意されています。scroll 関数はテキストの長さに基づいて時間を自動計算し、scroll_with_speed は Duration を指定することでスクロール速度を完全に制御できます。

この例では、テキストがどれくらいの速さでスクロールするかを制御できるように、scroll_with_speed を使います。

#![allow(unused)]
fn main() {
display.scroll_with_speed("EMBASSY", Duration::from_secs(10)).await;
}

完全なコード

メインループの中では、テキスト "EMBASSY" をディスプレイ上で繰り返しスクロールさせます。各スクロールの後に、embassy_time::Timer を使って短い待機を入れてから繰り返します。

#![no_std]
#![no_main]

use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use microbit_bsp::Microbit;
use microbit_bsp::display::Brightness;
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(_spawner: Spawner) -> ! {
    let board = Microbit::default();
    let mut display = board.display;

    display.set_brightness(Brightness::new(5));

    loop {
        display
            .scroll_with_speed("EMBASSY", Duration::from_secs(10))
            .await;
        Timer::after_secs(1).await;
    }
}

既存のプロジェクトをクローンする

私が作成したプロジェクトをクローン(または参照)して、bsp-embassy/led-scroll フォルダに移動することもできます。

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp-embassy/led-scroll

書き込み

プログラムを micro:bit に書き込んで、スクロールするテキストを確認できます。また、明るさの値を調整して観察してみてください。

cargo flash

スピーカー

micro:bit v2 には組み込みスピーカーが搭載されているため、追加のハードウェアを接続しなくても、音やトーン、さらにはシンプルなメロディまで再生できます。スピーカーは内部で GPIO ピンの 1 つに接続されており、ソフトウェアから適切な信号を送ることで、さまざまな音高を出せます。

microbit-bsp API を使う

microbit-bsp crate を使うと、スピーカーを簡単に利用できます。低レベルな詳細をすべて処理してくれる高水準 API が提供されています。内部では、PWM(パルス幅変調)と呼ばれるものを使ってトーンを生成しています。PWM が何かはまだ気にしなくて大丈夫です。これについては後の章で扱います。ここでは API を使うだけにしましょう。

コード例

この例は、microbit-bsp crate の公式 GitHub リポジトリ から取られています。組み込みスピーカーを使ってシンプルなメロディを再生します。この crate は NamedPitch のような補助 enum を提供しており、各バリアントが音符を表します。そのため、なじみのある音名を使って独自の曲を簡単に定義できます。

#![no_std]
#![no_main]

use embassy_executor::Spawner;
use embassy_time::Timer;
use microbit_bsp::{
    embassy_nrf::pwm::SimplePwm,
    speaker::{NamedPitch, Note, PwmSpeaker},
    Microbit,
};
use {defmt_rtt as _, panic_probe as _};

const TUNE: [(NamedPitch, u32); 18] = {
    #[allow(clippy::enum_glob_use)]
    use NamedPitch::*;
    [
        (D4, 1),
        (DS4, 1),
        (E4, 1),
        (C5, 2),
        (E4, 1),
        (C5, 2),
        (E4, 1),
        (C5, 3),
        (C4, 1),
        (D4, 1),
        (DS4, 1),
        (E4, 1),
        (C4, 1),
        (D4, 1),
        (E4, 2),
        (B4, 1),
        (D5, 2),
        (C4, 4),
    ]
};

#[embassy_executor::main]
async fn main(_s: Spawner) {
    let board = Microbit::default();
    defmt::info!("Application started!");
    let mut speaker = PwmSpeaker::new(SimplePwm::new_1ch(board.pwm0, board.speaker));
    loop {
        defmt::info!("Playing tune!");
        for (pitch, ticks) in TUNE {
            speaker.play(&Note(pitch.into(), 200 * ticks)).await;
        }
        Timer::after_secs(5).await;
    }
}

ここでは、TUNE 配列で定義された各音高を順に処理し、指定された tick 数だけ再生します。長さは単純に ticks * 200 ミリ秒です。したがって、ある音符の ticks = 2 であれば、400 ms 再生されます。

トーンを鳴らす

このセクションでは、ボタンが押されたときに音を鳴らすシンプルなプログラムを作成します。面白くするために、micro:bit ボード上の両方のボタンを使います。Button A が押されたら、音程 A4 のトーンを再生します。Button B が押されたら、トーンを停止します。

ボタンの仕組みと、Buttons の章でボタン押下を検出する方法には、すでに慣れているはずです。したがって、このセクションではそれらの詳細には再び立ち入りません。

テンプレートからプロジェクトを作成する

このプロジェクトでは、microbit-bsp(Embassy あり)を使用します。テンプレートを使って新しいプロジェクトを生成するには、次のコマンドを実行します。

cargo generate --git https://github.com/ImplFerris/mb2-template.git --rev 3d07b56
  • プロジェクト名の入力を求められたら、"play-tone" のような名前を入力します。

  • async を使用するかどうかを尋ねられたら、"true" を選択します。

  • "BSP" または "HAL" のどちらかを選ぶよう求められたら、"BSP" を選択します。

初期化

まず、ボードを初期化しましょう。このボードインスタンスから、pwm0 ペリフェラルと内蔵スピーカーにアクセスできます。この両方を、基本的な PWM 出力を設定する embassy-nrf crate のヘルパー構造体 SimplePwm に渡します。

次に、その SimplePwm インスタンスを microbit-bsp crate の構造体 PwmSpeaker に渡します。これにより、音程と長さを指定してトーンを再生できるようになります。

#![allow(unused)]
fn main() {
let board = Microbit::default();
let mut speaker = PwmSpeaker::new(SimplePwm::new_1ch(board.pwm0, board.speaker));
}

ボタン

microbit-v2 crate とは異なり、microbit-bsp crate ではボタンは別個の buttons 構造体にまとめられていません。代わりに、以下のように board.btn_aboard.btn_b を使って直接アクセスできます。

#![allow(unused)]
fn main() {
let mut button_a = board.btn_a;
let mut button_b = board.btn_b;
}

待って...その時を

ここでは、ボタン押下を継続的に確認するためにループを使います。どちらのボタンが押されたかに応じて、再生状態が変化します。Button A が押される(つまり low になる)と、トーンの再生を開始します。Button B が押される(つまり low になる)まで、再生し続けます。

wait_for_low() async 関数を使うことで、ボタンが押されるまでプログラムを一時停止できます。しかも、ブロックしたり CPU リソースを無駄にしたりしません。

#![allow(unused)]
fn main() {
loop {
        button_a.wait_for_low().await;
        speaker.start_note(Pitch::Named(NamedPitch::A4));
        button_b.wait_for_low().await;
        speaker.stop();
    }
}

完全なコード

#![no_std]
#![no_main]

use embassy_executor::Spawner;
use embassy_nrf::pwm::SimplePwm;
use microbit_bsp::{
    Microbit,
    speaker::{NamedPitch, Pitch, PwmSpeaker},
};
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(_spawner: Spawner) -> ! {
    let board = Microbit::default();

    let mut button_a = board.btn_a;
    let mut button_b = board.btn_b;

    let mut speaker = PwmSpeaker::new(SimplePwm::new_1ch(board.pwm0, board.speaker));

    loop {
        button_a.wait_for_low().await;
        speaker.start_note(Pitch::Named(NamedPitch::A4));
        button_b.wait_for_low().await;
        speaker.stop();
    }

}

既存のプロジェクトをクローンする

作成済みのプロジェクトをクローン(または参照)して、bsp-embassy/play-tone フォルダーに移動することもできます。

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp-embassy/play-tone

書き込み

このプログラムを micro:bit に書き込むと、トーンが聞こえるはずです

cargo flash

Embassy の美しさ

このセクションでは、バックグラウンドタスクを導入して、音を鳴らす演習を拡張します。これにより、Embassy の async タスクモデルの強力さを実感できます。

メインタスクは、ディスプレイ上に "EMBASSY" という文字列を継続的にスクロール表示します。同時に、バックグラウンドタスクはボタンが押されるのを待機します。どちらのボタンが押されたかに応じて、前の例と同じように音の再生を開始または停止します。

Embassy を使わずに同じ結果を実現することも可能ですが、その場合ははるかに多くの手間と複雑さが必要になります。Embassy は組み込み開発をシンプルにします。

前の演習を変更してもよいですし、最初からプロジェクトを作成してもかまいません。

テンプレートからプロジェクトを作成する

このプロジェクトでは、microbit-bsp(Embassy あり)を使用します。テンプレートを使って新しいプロジェクトを生成するには、次のコマンドを実行します。

cargo generate --git https://github.com/ImplFerris/mb2-template.git --rev 3d07b56
  • プロジェクト名の入力を求められたら、"background-tasks" のような名前を入力してください。

  • async を使用するかどうかを尋ねられたら、"true" を選択してください。

  • "BSP""HAL" のどちらを選ぶか尋ねられたら、"BSP" を選択してください。

初期化

いつものように、まずボード、ディスプレイ、スピーカーを初期化します。

#![allow(unused)]
fn main() {
let board = Microbit::default();
let mut display = board.display;
let speaker = PwmSpeaker::new(SimplePwm::new_1ch(board.pwm0, board.speaker)); // ここでは Speaker を mut にしていません
}

Spawner 引数を使う

これまで、main 関数に渡される Spawner 引数は使っておらず、その目的についても説明していませんでした。このセクションでは、これを使い始めます。そのため、使えるように引数名からアンダースコアを外してください。

// async fn main(_spawner: Spawner) -> ! {
// 変更後:
async fn main(spawner: Spawner) -> ! {

Spawner を使うと、バックグラウンドタスクを起動できます。これを使って、まもなく定義する button_task を開始します。

Button Task

では、ボタン入力を処理するバックグラウンドタスクを定義しましょう。これは #[embassy_executor::task] 属性が付いたシンプルな async タスクです。2 つのボタンとスピーカーの所有権を受け取ります。

ループの中では、まず Button A が押されるのを待ちます(low になる)。それが起きたら、音の再生を開始します。次に Button B が押されるのを待ち、それが押されたら音を停止します。

#![allow(unused)]

fn main() {
#[embassy_executor::task]
async fn button_task(
    mut button_a: Input<'static>,
    mut button_b: Input<'static>,
    mut speaker: PwmSpeaker<'static, PWM0>,
) {
    loop {
        button_a.wait_for_low().await;
        speaker.start_note(Pitch::Named(NamedPitch::A4));

        button_b.wait_for_low().await;
        speaker.stop();
    }
}
}

タスクを起動する

button_task を定義したので、Spawner を使って main 関数からこれを起動できます。

#![allow(unused)]
fn main() {
 spawner
        .spawn(button_task(board.btn_a, board.btn_b, speaker))
        .unwrap();
}

これで完了です。たったこの 1 行で、ボタンタスクはバックグラウンドで動き始め、ボタン入力を待ちながら、メインタスクは自身の処理を続けます。

メインタスクのループ

メインタスクはシンプルです。ディスプレイ上に "EMBASSY" という文字列をループでスクロールし続けます。各スクロールの後に、タイマーを使って短い待ち時間を入れます。

#![allow(unused)]
fn main() {
 loop {
        display
            .scroll_with_speed("EMBASSY", Duration::from_secs(10))
            .await;
        Timer::after_millis(300).await;
    }
}

このループが継続的に実行されている間、先ほど起動したボタンタスクはバックグラウンドで動作し、メインタスクをブロックしません。これこそが Embassy と async の美しさです。

完全なコード

#![no_std]
#![no_main]

use embassy_executor::Spawner;
use embassy_nrf::{gpio::Input, peripherals::PWM0, pwm::SimplePwm};
use embassy_time::{Duration, Timer};
use microbit_bsp::{
    Microbit,
    speaker::{NamedPitch, Pitch, PwmSpeaker},
};
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(spawner: Spawner) -> ! {
    let board = Microbit::default();
    let mut display = board.display;

    let speaker = PwmSpeaker::new(SimplePwm::new_1ch(board.pwm0, board.speaker));

    spawner
        .spawn(button_task(board.btn_a, board.btn_b, speaker))
        .unwrap();

    loop {
        display
            .scroll_with_speed("EMBASSY", Duration::from_secs(10))
            .await;
        Timer::after_millis(300).await;
    }
}

#[embassy_executor::task]
async fn button_task(
    mut button_a: Input<'static>,
    mut button_b: Input<'static>,
    mut speaker: PwmSpeaker<'static, PWM0>,
) {
    loop {
        button_a.wait_for_low().await;
        speaker.start_note(Pitch::Named(NamedPitch::A4));

        button_b.wait_for_low().await;
        speaker.stop();
    }
}

既存のプロジェクトを clone する

自分が作成したプロジェクトを clone(または参照)して、bsp-embassy/background-tasks フォルダへ移動することもできます。

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp-embassy/background-tasks

書き込み

これで、プログラムを micro:bit に書き込めます。

cargo flash

書き込みが完了すると、ディスプレイ上に "EMBASSY" の文字列が継続的にスクロール表示されます。ボタンを押して音の開始や停止を行うことができ、スクロールを中断することなくバックグラウンドでスムーズに動作します。

micro:bit v2 で Rust コードを書いて "Happy Birthday" を再生する

このセクションでは、micro:bit のスピーカーで "Happy Birthday" の曲を再生します。

microbit-bsp クレートが提供する関数群は micro:bit には非常に便利ですが、ESP32 や Raspberry Pi Pico のような異なる MCU 間でも使えるものが欲しいと考えました。そこで、Quarter や Half などの音楽用語を使って、音符と長さをより分かりやすく定義できる別のクレートを作成しました。次のセクションでは、そのクレートを使って、より再利用しやすく移植性の高い方法で曲を再生していきます。

このために、tinytones というクレートを使います。このクレートには "Happy Birthday" の曲が組み込まれているため、音程や長さを自分で定義する必要はありません。

このクレートは、自分自身のメロディーを定義するための Pitch enum と Tone struct も提供します。また、Quarter や Half のような音楽上の長さを、曲のテンポに基づいた実際の時間値へ変換するヘルパー関数も含まれています。

テンプレートからプロジェクトを作成する

このプロジェクトでは、microbit-bsp(Embassy 付き)を使用します。テンプレートを使って新しいプロジェクトを生成するには、次のコマンドを実行してください。

cargo generate --git https://github.com/ImplFerris/mb2-template.git --rev 3d07b56
  • プロジェクト名の入力を求められたら、"play-song" のような名前を入力します。

  • async を使うかどうかを尋ねられたら、"true" を選択します。

  • "BSP" または "HAL" のどちらを使うか尋ねられたら、"BSP" を選択します。

Cargo.toml を更新する

tinytones クレートを追加します。Cargo.toml ファイルを開き、次の行を追加してください。

tinytones = { version="0.1.0" }

インポート

このプログラムに必要なインポートは次のとおりです。main.rs ファイルを開き、以下のように更新してください。

#![allow(unused)]
fn main() {
// テンプレートに最初から含まれているもの
use embassy_executor::Spawner;
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};

// 追加のインポート
use embassy_nrf::pwm::SimplePwm;
use microbit_bsp::{
    Microbit,
    speaker::{Note, Pitch, PwmSpeaker},
};
use tinytones::{Tone, songs};

}

初期化

まず、ボードを初期化します。このボードインスタンスから、pwm0 ペリフェラルと内蔵スピーカーにアクセスできます。これらの両方を、基本的な PWM 出力を設定する embassy-nrf クレートのヘルパー struct である SimplePwm に渡します。

次に、この SimplePwm インスタンスを microbit-bsp クレートの struct である PwmSpeaker に渡します。これにより、音程と長さを指定して音を再生できるようになります。

#![allow(unused)]
fn main() {
let board = Microbit::default();
let mut speaker = PwmSpeaker::new(SimplePwm::new_1ch(board.pwm0, board.speaker));
}

曲を選ぶ

tinytones クレートは、組み込みの曲やメロディーのセットを提供しています。完全な一覧はドキュメントで確認できます。

この例では、"Happy Birthday" の曲を再生します。そのために、Tone::new を呼び出して Tone struct を初期化します。第 1 引数は曲のテンポ(曲がどれくらい速く、または遅く再生されるか)です。曲に用意されている定義済みテンポを使うこともできますし、自分で指定することもできます(例: 150)。第 2 引数はメロディーで、音符のリスト(それぞれに音程と長さがある)です。

#![allow(unused)]
fn main() {
let song = Tone::new(songs::happy_birthday::TEMPO, songs::happy_birthday::MELODY);
}

曲をループ再生する

曲を読み込んだら、再生できます。Toneiter() メソッドを提供しており、これによってメロディー内の各音符を順番に取り出すイテレーターが得られます。各音符は (pitch, duration) のペアです。

ループの中では、各音符を 1 つずつ処理していきます。音程が Rest の場合、それは無音の休符を意味します。その場合は、Timer::after_millis を使ってその音符の長さだけ待機します。

それ以外の音符については、speaker.play() メソッドを使って音を再生します。ここには Note を渡しますが、これは pitch.freq_u32() を使って音程を周波数へ変換し、そこに音符の長さを渡すことで作成します。

#![allow(unused)]
fn main() {
loop {
    for (pitch, note_duration) in song.iter() {
        if pitch == tinytones::note::Pitch::Rest {
            Timer::after_millis(note_duration).await;
            continue;
        }

        speaker
            .play(&Note(
                Pitch::Frequency(pitch.freq_u32()),
                note_duration as u32,
            ))
            .await;
    }
    Timer::after_secs(5).await;
}
}

曲全体の再生が終わったら、再びループして最初から再生する前に 5 秒待機します。

完全なコード

#![no_std]
#![no_main]

use embassy_executor::Spawner;
use embassy_nrf::pwm::SimplePwm;
use embassy_time::Timer;
use microbit_bsp::{
    Microbit,
    speaker::{Note, Pitch, PwmSpeaker},
};
use tinytones::{Tone, songs};
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(_spawner: Spawner) -> ! {
    let board = Microbit::default();
    let mut speaker = PwmSpeaker::new(SimplePwm::new_1ch(board.pwm0, board.speaker));

    let song = Tone::new(songs::happy_birthday::TEMPO, songs::happy_birthday::MELODY);
    loop {
        for (pitch, note_duration) in song.iter() {
            if pitch == tinytones::note::Pitch::Rest {
                Timer::after_millis(note_duration).await;
                continue;
            }

            speaker
                .play(&Note(
                    Pitch::Frequency(pitch.freq_u32()),
                    note_duration as u32,
                ))
                .await;
        }
        Timer::after_secs(5).await;
    }
}

既存のプロジェクトをクローンする

作成済みのプロジェクトをクローンして(または参照して)、bsp-embassy/play-song フォルダーへ移動することもできます。

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp-embassy/play-song

書き込み

プログラムを micro:bit に書き込むと、メロディーが聞こえるはずです。

cargo flash

カスタムチューン

//TODO

温度センサー

温度センサーは、温度を測定するために使用される入力デバイスです。家庭内のさまざまな場所で見つけることができ、たとえば暖房や冷房システムを制御するサーモスタットなどに使われています。多くの温度センサーはディスプレイ付きのデバイスに内蔵されているため、温度の読み取り値を直接確認できます。

micro:bit には、nRF52 プロセッサの内部に温度センサーが含まれています。これはチップの内部温度を測定するもので、周囲の気温のおおよその値を知ることができます。

注: この読み取り値はチップの内部温度を反映するものであり、周囲の気温を直接測定したものではありません。ただし、周囲の温度のおおよその目安にはなります。

このセンサーの精度はおよそ +/-5°C(未校正)で、-40°C から 105°C の範囲の温度を検知できます。

テンプレートからプロジェクトを作成

今回も Embassy を使いますが、今回は BSP なしで進めます。代わりに、embassy-nrf HAL を直接使って作業します。

cargo generate --git https://github.com/ImplFerris/mb2-template.git --rev 3d07b56
  • プロジェクト名を尋ねられたら、"temperature" のような名前を入力します。

  • async を使うかどうかを尋ねられたら、"true" を選択します。

  • "BSP" と "HAL" のどちらを使うかを選ぶよう求められたら、"HAL" を選択します。

コード全体

今回は、いきなり完全なコード例から始めます。理由は単純で、ここで関わる概念は、先に説明しようとするとかなりの理論が必要になるからです。そこで、まずはコードを実行してから、それを一歩ずつ分解して見ていきます。

この例では、embassy-nrf HAL が公開している "TEMP" ペリフェラル(温度センサー)を使います。HAL は "Temp" という struct も提供しており、これを使って温度ハードウェアを操作できます。

ただし、もう 1 つ必要なものがあります。それは Interrupt Request Handler と呼ばれるものの設定です。はい、これはまだ説明していない新しい概念です。割り込みハンドラの経験があるなら素晴らしいことです。もしなくても心配いりません。これが何を意味し、どのように動くのかを詳しく見ていきます。

#![no_std]
#![no_main]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};

use embassy_nrf::{
    bind_interrupts,
    temp::{self, Temp},
};

bind_interrupts!(struct Irqs {
    TEMP => temp::InterruptHandler;
});

#[embassy_executor::main]
async fn main(_spawner: Spawner) -> ! {
    let p = embassy_nrf::init(Default::default());
    let mut temp = Temp::new(p.TEMP, Irqs);

    loop {
        let value = temp.read().await;
        info!("temperature: {}℃", value.to_num::<u16>());
        Timer::after_secs(1).await;
    }
}

ループの中では、温度を読み取り、それを defmt の "info!" マクロを使って表示しているだけです。これを micro:bit に書き込むと、温度の読み取り値がコンピューターのコンソールに表示され始めます。なかなかすごいですよね? これはこれまであまり活用してこなかったことです。でもその通りで、ボード上でプログラムを実行しながら、コンピューターでライブログを見ることがちゃんとできるのです!

既存のプロジェクトをクローンする

私が作成したプロジェクトをクローン(または参照)して、hal-embassy/temperature フォルダーへ移動することもできます。

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/hal-embassy/temperature

実行

プログラムを micro:bit に書き込むと、ログがコンピューターに表示されるはずです。

cargo run

ここで表示される温度は、正確な室温ではありません。先ほど述べたように、このセンサーが測定しているのは周囲の空気ではなく、チップ内部の温度です。そのため、より正確な室温を得たい場合は、自分で校正する必要があります。つまり、さまざまな環境でテストし、実際の温度計と比較し、その結果に基づいてコード内の値を調整する必要があります。

より正確な温度の読み取り値が必要であれば、外部センサーを購入して使用することもできます。それらの使い方は、今後の章で見ていきます。

割り込み要求(IRQ)

割り込み要求(IRQ)は、周辺機器(例: センサー、タイマー)によってトリガーされるハードウェア信号です。この信号はただちに CPU の注意を引きつけ、現在の処理を一時停止させて、割り込みサービスルーチン(ISR)と呼ばれる専用のルーチンを通じてそのイベントを処理できるようにします。ISR の完了後、CPU は以前のコンテキストを復元し、元のタスクを再開します。

IRQ がなければ、CPU は各周辺機器で何かが起きたかどうかを継続的に確認(ポーリング)し続ける必要があります。これは時間とエネルギーを浪費し、特にほとんどの時間で何も変化していない場合には非効率です。

なぜポーリングではなく IRQ を使うのですか?

次のたとえを考えてみてください。あなたは大好きなビデオゲームをプレイしていて、全集中してラスボスを倒そうとしています。同時に、友人が訪ねてくる予定です。

ポーリング: 数秒おきにゲームを止めてドアを確認し続けます。これは気が散り、非効率で、ゲームプレイを台無しにします。

IRQ(ドアベル): ドアベルを設置します。友人が到着したら、それを押します。あなたはベルの音を聞き、すばやくゲームを一時停止してドアを開け、その後、ちょうど中断した場所からゲームに戻ります。


このセクションでは、独自の ISR を定義するための詳細や関連するすべての手順には踏み込みません。それには専用の章が必要です。ここでは、HAL が提供する割り込みマクロとハンドラーサポートについて、まずは軽く導入するだけにします。

TEMP ペリフェラルの割り込みハンドラー

最初に浮かぶ疑問は、こうかもしれません。そもそも、ここでなぜ割り込みハンドラーが必要なのでしょうか? なぜ今この話をしているのでしょうか?

温度センサーも、単なる別のペリフェラルにすぎません。温度を測定したい場合、CPU はセンサーに測定開始を要求し、その後結果を待つ必要があります。しかし、測定には少し時間がかかるため、CPU が何もしないまま値の準備ができたかを確認し続けるのは無駄です(これをポーリングと呼びます)。よりよい方法は、準備ができたときにセンサー側から割り込みで通知してもらうことです。

割り込みの設定には複数の手順がありますが、ここではこのケースに直接関係する部分だけに注目します。

まず、TEMP 割り込みをそのハンドラーにバインドしました

embassy-nrf クレートには、bind_interrupts! というマクロが用意されており、特定の Interrupt を対応するハンドラーに接続するのに役立ちます。

一般的な使い方は次のようになります。

#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs{
    INTERRUPT_NAME => INTERRUPT_HANDLER;
    INTERRUPT_NAME2 => INTERRUPT_HANDLER2;
});
}

このケースでは、TEMP 割り込みを temp::InterruptHandler ハンドラーにバインドします。これも embassy-nrf が提供しています。

#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
    TEMP => temp::InterruptHandler;
});
}

ここでは基本的に、「TEMP ペリフェラルから割り込みが来たら、temp::InterruptHandler に処理させる」と伝えているわけです。

次に、Temp 構造体を初期化しました

先ほど定義した Irqs 構造体と p.TEMP ペリフェラルを渡して、Temp ドライバーを作成しました。

#![allow(unused)]
fn main() {
let mut temp = Temp::new(p.TEMP, Irqs);
}

こう思ったかもしれません――「ちょっと待って、この Irqs ってユニット構造体には見えないよね」と。その通りで、そうは見えません。でも実際にはユニット構造体です。先ほど使ったマクロが、そのユニット構造体を生成しています。マクロ展開後にどのような形になるのかは、すぐ後でお見せします。

最後に、温度を非同期に読み取りました

あとは、次のように温度を読み取るだけです。

#![allow(unused)]
fn main() {
let value = temp.read().await;
}

nRF52833 のドキュメントより: TEMP は START タスクをトリガーすることで開始されます。温度測定が完了すると、DATARDY イベントが生成され、測定結果を TEMP レジスタから読み取ることができます。

この関数は内部で、温度測定を開始するようセンサーへ要求を送ります。その後、データの準備ができたときにセンサーが通知できるよう、割り込みを有効にします。

そして、結果を非同期に待ちます。センサーが測定を終えると、割り込みを発生させます。その割り込みは temp::InterruptHandler によって処理され、待機していた read() 関数を起こします。これにより先へ進み、温度の値を読み取れるようになります。


温度割り込みハンドラー

temp::InterruptHandler 構造体の定義を見ると、これが Handler トレイトを実装していることがわかります。中心となるのは on_interrupt 関数で、TEMP 割り込みを受信したときに何を行うかを定義しています。

先ほど述べたように、ここで行うことはあまり多くありません。単に、センサーを待っていた read() 関数を起こすよう Embassy に知らせるだけです。

#![allow(unused)]
fn main() {
impl interrupt::typelevel::Handler<interrupt::typelevel::TEMP> for InterruptHandler {
    unsafe fn on_interrupt() {
        let r = pac::TEMP;
        r.intenclr().write(|w| w.set_datardy(true));
        WAKER.wake();
    }
}
}

この関数はまず、再度発火しないように割り込みを無効化します。その後 WAKER.wake() を呼び出して、温度の値を待っていた async タスクを再開します。これにより、read() 関数は処理を続行して結果を読み取れるようになります

bind_interrupts マクロを展開する

先ほど使った bind_interrupts! マクロに戻りましょう。

#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
    TEMP => temp::InterruptHandler;
});
}

一見すると、この構文は少し奇妙に見えるかもしれません。特に struct の部分です。まるで struct をインスタンス化しようとしているように見えますが、そうではありません。これは単なるマクロへの入力です。マクロは内部で Irqs という名前のユニット struct を作成し、それに必要な割り込みバインディング用の trait を実装します。

ユニット struct はフィールドを持たない空の struct で、最も一般的にはマーカーとして使われます。また、そのサイズは 0 バイトです。

気になる場合は、cargo expand を使って生成されるコードを確認できます。おおよそ次のようになります。

#![allow(unused)]
fn main() {
use embassy_nrf::interrupt::typelevel;

struct Irqs;

// これは無視します: ... impl Copy and Clone for Irqs

#[allow(non_snake_case)]
#[no_mangle]
unsafe extern "C" fn TEMP() {
    <temp::InterruptHandler as typelevel::Handler<typelevel::TEMP,>>::on_interrupt();
}

unsafe impl typelevel::Binding<typelevel::TEMP, temp::InterruptHandler,> for Irqs {
}
}

ここでは、Irqs という名前のユニット struct が作られています。また、TEMP() という関数も作られます。これは、TEMP 割り込みが発生したときにハードウェアが実際に呼び出す割り込みハンドラです。その関数の中で、temp::InterruptHandleron_interrupt() を呼び出しています。その後、Irqs struct に対して Binding trait を実装しています。これによって Embassy に、TEMP 割り込みが私たちのハンドラに接続されていることを伝えます。

マーカー

Irqs struct の中には関数を何も定義していないのに、なぜその Irqs struct に対して Binding trait を実装するのか疑問に思うなら、その理由を理解するために Temp::new 関数を見る必要があります。

#![allow(unused)]
fn main() {
// let mut temp = Temp::new(p.TEMP, Irqs);

pub fn new(
    _peri: impl Peripheral<P = TEMP> + 'd,
    _irq: impl interrupt::typelevel::Binding<interrupt::typelevel::TEMP, InterruptHandler> + 'd,
) -> Self {
    into_ref!(_peri);

    // 温度値を通知する割り込みを有効化する
    interrupt::TEMP.unpend();
    unsafe { interrupt::TEMP.enable() };

    Self { _peri }
}
}

ちょっと待ってください。2 番目の引数 _irq はまったく使われていません。では、これは何のためにあるのでしょうか。

直接使われていなくても、その型は重要です。Embassy はこれによって、TEMP 割り込み用のハンドラを設定済みかどうかを確認しています。_irq の型は、TEMP に対する Binding trait を実装していなければなりません。そうでなければ、コードはコンパイルできません。

たとえば、次のように書いたとします。

#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
    TWISPI0 => twis::InterruptHandler<peripherals::TWISPI0>;
});
}

マクロは TWISPI0 割り込みに対する Binding trait を、次のように生成します。

#![allow(unused)]
fn main() {
// マーカーを理解するために Binding trait に注目できるよう、これはコメントアウトしています
// #[allow(non_snake_case)]
// #[no_mangle]
// unsafe extern "C" fn TWISPI0() {
//     <twis::InterruptHandler<
//         peripherals::TWISPI0,
//     > as typelevel::Handler<
//         typelevel::TWISPI0,
//     >>::on_interrupt();
// }

unsafe impl typelevel::Binding<typelevel::TWISPI0,twis::InterruptHandler<peripherals::TWISPI0>,> for Irqs {

}
}

しかし、これは Temp::new が期待しているものとは一致しません。Temp::new が期待しているのは、TEMP 割り込みに対する Binding です。したがって、コンパイラはエラーを出します。

microbit v2 の内蔵マイク

micro:bit v2 には、周囲の環境の音量を検出できるオンボードの MEMS(Micro-Electro-Mechanical Systems)マイクが搭載されています。このマイクにより、拍手、声、音楽、その他の音に反応するような、音に基づくインタラクティブな動作が可能になります。

基板前面の、マイクのすぐ上には小さな LED インジケーターがあります。この LED は、マイクの電源が入っているときに点灯し、デバイスがアクティブに音を拾っていることを視覚的に示します。

ピンと ADC

microbit の内部では、マイクは nRF52833 チップの P0.05 ピンに接続されています。nRF52833 では、このピンは通常のデジタルピンとしても、アナログ入力(AIN3)としても動作できます。

マイクは音声信号をこのピンに送り、SAADC(Successive Approximation Analog-to-Digital Converter)と呼ばれるチップの一部が、その信号をプログラムで利用できる数値に変換します。ADCs(Analog to Digital Converters)については後の章でさらに詳しく見ていきますが、ここではひとまず、ADC によって音量レベルを数値として読み取れることを知っておいてください。

BSP を使った音量レベル

microbit-bsp クレートを使うと作業が簡単になります。これは、内蔵マイクを簡単に扱える Microphone 構造体を提供します。この構造体は sound_level() というメソッドを公開しており、マイクを有効にして検出された音量レベルを返します。

返される値の範囲は 0 から 255 で、数値が大きいほど音が大きいことを表します。ただし、これはデシベルのような標準的な単位ではありません。単に、周囲の環境がどの程度うるさいかの大まかな目安を示すものです。

Rustでmicro:bitのマイクの音量レベルを表示する

まずはシンプルなRustプログラムから始めましょう。このプログラムでは、micro:bitのマイクから取得した音量レベルをシステムコンソールに表示します。これにより、静かな部屋や騒がしい場所など、さまざまな環境で音量レベルがどのように変化するかを観察できます。

後で、拍手や突然の音を検出したときにmicro:bitがLEDマトリクスに絵文字を表示する、楽しいプロジェクトも作成します。

テンプレートからプロジェクトを作成する

このプロジェクトでは、microbit-bsp(Embassy付き)を使用します。テンプレートを使って新しいプロジェクトを生成するには、次のコマンドを実行します。

cargo generate --git https://github.com/ImplFerris/mb2-template.git --rev 3d07b56
  • プロジェクト名の入力を求められたら、"mic-sound-level" のような名前を入力します。

  • asyncを使用するかどうかを尋ねられたら、"true" を選択します。

  • "BSP" と "HAL" のどちらを使用するかを尋ねられたら、"BSP" を選択します。

割り込みのバインド

温度センサーと加速度センサーの章で、すでに似たようなコードを見てきました。ここでは、saadc::InterruptHandler をSAADC割り込みにバインドしています。

これは、SAADCがマイクからの信号を数値に変換し終えるたびに、割り込みハンドラに通知されることをシステムに伝えるものです。この場合は、embassy-nrfクレートが提供する割り込みハンドラを使用しており、これらのイベントの処理をバックグラウンドで担ってくれます。

#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
    SAADC => saadc::InterruptHandler;
});
}

これにより、マイクのセットアップ時に使用するユニット構造体 "Irqs" が得られます。

マイク

マイクを使うには、Microphone::new() を呼び出して4つの引数を渡します。これには、SAADC(board.saadc)、マイクの入力ピン(board.microphone)、マイクを有効化するためのピン(board.micen)、そして先ほど作成した割り込み用のユニット構造体 "Irqs" が含まれます。

#![allow(unused)]
fn main() {
let mut mic = Microphone::new(board.saadc, Irqs, board.microphone, board.micen);
}

マイクのセットアップが完了したら、sound_level() 関数を呼び出せます。これによりマイクがオンになり、音のサンプルが取得され、0から255までの数値が返されます。その後、この値をコンソールに表示できます。

#![allow(unused)]
fn main() {
info!("Sound Level: {}", mic.sound_level().await);
}

完全なコード

#![no_std]
#![no_main]

use defmt::info;
use embassy_executor::Spawner;
use embassy_nrf::{
    bind_interrupts,
    saadc::{self},
};
use embassy_time::Timer;
use microbit_bsp::{Microbit, mic::Microphone};
use {defmt_rtt as _, panic_probe as _};

bind_interrupts!(struct Irqs {
    SAADC => saadc::InterruptHandler;
});

#[embassy_executor::main]
async fn main(_spawner: Spawner) -> ! {
    let board = Microbit::default();

    let mut mic = Microphone::new(board.saadc, Irqs, board.microphone, board.micen);
    loop {
        info!("Sound Level: {}", mic.sound_level().await);
        Timer::after_millis(100).await;
    }
}

既存のプロジェクトをクローンする

作成済みのプロジェクトをクローン(または参照)して、bsp-embassy/mic-sound-level フォルダーに移動することもできます。

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp-embassy/mic-sound-level

実行

それではプログラムをテストしてみましょう。次を使ってmicro:bitに書き込みます。

cargo run

実行が始まると、コンピューターのコンソールに音量レベルの読み取り値が表示されるはずです。

micro:bitの近くで拍手をしたり音を立てたりしてみてください。周囲が騒がしいと音量レベルの値が上がり、静かになると再び下がることがわかるでしょう。

シンプルな組み込み Rust プロジェクト: 拍手すると microbit にスマイリーを表示する

前の例では、micro:bit に内蔵されたマイクから音量レベルを読み取り、その値をシステムコンソールに出力する方法を見てきました。今回は、それをさらに一歩進めます。このシンプルな Rust プロジェクトでは、音量レベルの検出を使って拍手を認識し、micro:bit の 5x5 LED マトリクスにスマイリーフェイスを表示します。

プロジェクトを作成する

値を出力するまでの手順は前のセクションと同じです。同じ手順をもう一度たどる代わりに、プロジェクトをクローンして "clap2smile" のような名前に変更することをおすすめします。

または、私が作成したプロジェクトをクローンして、それをベースに作業することもできます。

git clone https://github.com/ImplFerris/microbit-projects
# 任意のディレクトリにコピー
cp microbit-projects/bsp-embassy/mic-sound-level clap2smile
cd clap2smile 

スマイリーフェイス

microbit-v2 crate を使っていたときは、スマイリーを表現するためにシンプルな 2 次元マトリクスを使っていました。microbit-bsp crate では、Frame 構造体と Bitmap 構造体を使って形状を定義する必要があります。

5x5 LED マトリクスの各行は、Bitmap::new 関数を使って 2 進数 (u8) として表現します。

#![allow(unused)]
fn main() {
let smile_frame = Frame::<5, 5>::new([
    Bitmap::new(0b00000, 5),
    Bitmap::new(0b01010, 5),
    Bitmap::new(0b00000, 5),
    Bitmap::new(0b10001, 5),
    Bitmap::new(0b01110, 5),
]);

led_matrix.set_brightness(Brightness::MAX);
}

しきい値

拍手を検出するには、音量レベルのしきい値を定義する必要があります。micro:bit のマイクは 0 から 255 までの値を返し、値が大きいほど音が大きいことを示します。拍手は通常、この値に急激なスパイクを発生させます。

#![allow(unused)]
fn main() {
const CLAP_THRESHOLD: u8 = 180;
}

しきい値は 180〜200 あたりから始めることができます。拍手にどれくらいの大きさを求めるかという好みや、周囲の環境に応じてこの値を調整できます。

そしてロジックはシンプルです。音量レベルがこのしきい値を超えたら、それを拍手とみなし、スマイリーフェイスの表示をトリガーします。

#![allow(unused)]
fn main() {
if mic.sound_level().await > CLAP_THRESHOLD {
    led_matrix
        .display(smile_frame, Duration::from_secs(1))
        .await;
}
}

完全なコード

#![no_std]
#![no_main]

// use defmt::info;
use embassy_executor::Spawner;
use embassy_nrf::{
    bind_interrupts,
    saadc::{self},
};
use embassy_time::{Duration, Timer};
use microbit_bsp::{
    Microbit,
    display::{Bitmap, Brightness, Frame},
    mic::Microphone,
};
use {defmt_rtt as _, panic_probe as _};

bind_interrupts!(struct Irqs {
    SAADC => saadc::InterruptHandler;
});

const CLAP_THRESHOLD: u8 = 180;

#[embassy_executor::main]
async fn main(_spawner: Spawner) -> ! {
    let board = Microbit::default();
    let mut led_matrix = board.display;

    let mut mic = Microphone::new(board.saadc, Irqs, board.microphone, board.micen);

    let smile_frame = Frame::<5, 5>::new([
        Bitmap::new(0b00000, 5),
        Bitmap::new(0b01010, 5),
        Bitmap::new(0b00000, 5),
        Bitmap::new(0b10001, 5),
        Bitmap::new(0b01110, 5),
    ]);

    led_matrix.set_brightness(Brightness::MAX);

    loop {
        // info!("音量レベル: {}", mic.sound_level().await);
        if mic.sound_level().await > CLAP_THRESHOLD {
            led_matrix
                .display(smile_frame, Duration::from_secs(1))
                .await;
        }
        Timer::after_millis(100).await;
    }
}

既存のプロジェクトをクローンする

私が作成したプロジェクトをクローンする(または参照する)こともできます。そして bsp-embassy/clap2smile フォルダに移動します。

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp-embassy/clap2smile

実行する

それでは、プログラムをテストしてみましょう。次を使って micro:bit に書き込みます。

cargo run

実行が始まったら、micro:bit の近くで拍手してみてください。LED マトリクスにスマイリーフェイスが表示されるはずです。ちなみに、これは拍手そのものを特別に検出しているわけではなく、単に音量レベルがしきい値を超えたかどうかをチェックしているだけです。そのため、叫び声のような大きな音でもスマイリーがトリガーされます。

加速度センサーによるモーションセンシング

この章では、micro:bit v2 に搭載されている加速度センサーを見ていきます。この小さなセンサーは、動き、向き、さらにはボードを振ったり傾けたりするようなジェスチャーまで検出できます。歩数計(歩くときの動きを測定する)、ジェスチャーで操作するゲーム、転倒検出などの楽しいプロジェクトに利用できます。

加速度センサーの実世界での例

気づかないうちに、すでに加速度センサーを使ったことがあるはずです。たとえば、スマートフォンには小型の MEMS 加速度センサーが入っており、向きの変化(縦向きから横向きへの切り替えなど)を検出したり、歩数を数えたり、傾きに基づく操作を可能にしたりしています。ゲームコントローラーも、傾きや動きを検知するために、しばしばジャイロスコープとあわせて加速度センサーを使用しています。ロケットやナビゲーションシステムでは、速度や方向の変化を測定するために加速度センサーが使われています。自動車では、加速度センサーを使ったセンサーが急激な減速を検出し、衝突時には数ミリ秒以内にエアバッグの展開を作動させます。

micro:bit v2 の LSM303AGR センサー

micro:bit v2 は、"LSM303AGR" というチップを使用しています。このチップには、3 軸加速度センサーと 3 軸磁力計の両方が含まれています。今のところは加速度センサーに注目します。磁力計は、後の別の章で使用します。nRF52833 チップ(micro:bit のメインプロセッサー)は、I²C(一般には I2C と書かれます)と呼ばれるシンプルなプロトコルを介して、これらのセンサーと通信します。

micro:bit の LSM303AGR

このセンサーは、X、Y、Z の 3 軸に沿った加速度を測定します。これにより、ボードが空間内でどのように動いたり回転したりしているかがわかります。

LSM303AGR チップのさらに詳しい技術情報と データシート は、公式ドキュメントのこちらで確認できます。

加速度センサーはどのように動作するのでしょうか?

加速度センサーは、加速度、つまり物体がどれくらい速くなったり、遅くなったり、向きを変えたりしているかを測定できる小さなセンサーです。では、実際にはどのようにそれを行っているのでしょうか?

魔法のビー玉のようなものだと考えてみましょう

箱の中に小さなビー玉が入っているところを想像してください。箱を傾けたり振ったりすると、ビー玉は中で転がります。もしビー玉が左側を押していたら、それは箱が右に動いていることを意味します。もし下側を押していたら、箱は上向きに動いていることを意味します。

加速度センサーも、これとよく似た仕組みで動作します。チップの内部には、デバイスが振られたり、傾けられたり、動かされたりするとわずかに動く小さな部品があります(MEMS(Micro-Electro-Mechanical Systems)と呼ばれます)。これらの動きは非常に小さいため目で見ることはできませんが、特別な回路がそれらがどれくらい、そしてどの方向に動いたかを検出します。

3つの軸: X、Y、Z

加速度センサーは、3つの方向の動きを測定します。X軸は左右の動きを検出し、Y軸は前後の動きを測定し、Z軸は上下の動きを捉えます。

LSM303AGR 加速度センサーの軸

各軸でどれくらいの加速度が発生しているかを見ることで、デバイスが静止しているのか、動いているのか、傾いているのか、あるいは落下しているのかさえ判断できます。

microbit 上の加速度センサーの軸

  • X軸は、ボード上でボタンAからボタンBに向かって水平方向に伸びています。ボードが右に動いたり右に傾いたりすると、正の値になります。

  • Y軸は、USBコネクタから金色のエッジコネクタ(0、1、3V、GNDのようなピン番号が記された金色の線)に向かって垂直方向に伸びています。ボードがUSBコネクタ側へ下向きに傾くと、正の値になります。

  • Z軸は、ボードに対して垂直です。ボードが表を下にして置かれているとき(LEDマトリクスが机に向いている状態)に正の値となり、LEDマトリクスを上に向けて平らに置かれているときには負の値になります。

microbit 加速度センサーの軸

平らな面の上で静止しているとき、micro:bit は通常、重力の影響により Z軸でおよそ +1g を示し、X軸とY軸は 0g に近い値のままになります。

参考資料

  • 加速度センサーがどのように動作するのかをより深く理解したい場合は、この動画を参照してください。
  • How Accelerometers Work - The Learning Circuit: 加速度と加速度センサーの入門として、これも役立つ別の動画です

micro:bit v2 で LSM303AGR 加速度センサーと通信する方法

micro:bit v2 には、加速度センサー(および磁力計)用の LSM303AGR というチップが搭載されていることを学びました。では、micro:bit 上の nRF52 プロセッサは、実際にはどのようにしてこのチップと通信しているのでしょうか。

このセンサーと通信するために、I2C というプロトコルを使います。まだ I2C については扱っていないので、ここで簡単に見てみましょう。細かいところまでは立ち入りません。この演習で必要なことが理解できる程度にとどめます。

I2C(Inter-Integrated Circuit)シリアルバス

I2C(「アイ・スクエアド・シー」と発音され、I²C または IIC とも表記されます)は、マイクロコントローラがセンサー、ディスプレイ、メモリチップなどの外部デバイスとデータをやり取りできるようにする、シンプルな 2 線式通信プロトコルです。

I2C は、わずか 2 本の線を使ってデバイス同士がやり取りするチャットシステムのようなものだと考えてください。

2 本の線:

  • SDA(Serial Data Line): 実際のデータが流れる線です。nRF52 プロセッサとセンサーの間でメッセージを送受信するために使われます。
  • SCL(Serial Clock Line): 信号機のような役割をします。デバイスに、いつ自分の番が来たのかを知らせます。交差点で車が青信号を待つのと同じように、デバイスはクロック信号を待ってデータを送受信します。

micro:bit LSM303AGR の I2C

最初に話し始めるのは誰か? コントローラとデバイスの役割を理解する

I2C 通信では、1 つのデバイスが通信の開始と制御を担当します。ほかのデバイスは、要求されたときに応答します。従来、これらの役割は master と slave と呼ばれていましたが、現在では別の用語も使われています。

  • Controller(以前の "master"): 通信を開始し、タイミングを制御するデバイスです。この場合、micro:bit の nRF52 チップがこれに当たります。

  • Peripheral または Device(以前の "slave"): コントローラからの指示を受け取り、応答するチップです。この場合、LSM303AGR センサーがこれに当たります。

コントローラを質問する人、デバイスをそれに答える人だと考えると分かりやすいでしょう。

I2C アドレス: コントローラが相手を識別する方法

I2C バス上の各デバイスには、それぞれ固有のアドレスがあります。通りに並ぶ家にそれぞれ番号があるのと同じです。これによって、コントローラはどのデバイスと通信しているのかを識別できます。

コントローラは、データを送る前にまずデバイスのアドレスを送信します。そのアドレスを持つデバイスだけが応答し、ほかのデバイスはすべて何も応答しません。

LSM303AGR のデータシートにあるとおり、加速度センサーのスレーブアドレスは 0011001b で、磁気センサーのスレーブアドレスは 0011110b です。

例:

  • LSM303AGR の加速度センサーのアドレスは 0x19(2進数: 0011001)です。
  • 磁力計の部分のアドレスは 0x1E(2進数: 0011110)です。

このように、両方とも同じチップの中にありますが、nRF52 は正しいアドレスを使うことで、それぞれと個別に通信できます。

micro:bit v2 上の I2C バス

micro:bit v2 では、I2C バスが内部用と外部用の 2 つに分かれています。内部 I2C は、nRF52833 プロセッサと、モーションセンサーやインターフェースチップなどのオンボード部品との通信に使われます。外部 I2C は、外部アクセサリを接続するためにエッジコネクタ(エッジコネクタとは何か気になる場合は、ハードウェア詳細セクション を参照してください)へ引き出されています。

  • 内部 I2C では、nRF52833 の GPIO ピン P0.08 を SCL(I2C_INT_SCL)に、P0.16 を SDA(I2C_INT_SDA)に使用します。
  • 外部 I2C では、nRF52833 の GPIO ピン P0.26 を SCL(I2C_EXT_SCL、エッジコネクタのピン P19)に、P1.00 を SDA(I2C_EXT_SDA、エッジコネクタのピン P20)に使用します。

加速度センサーは内部チップなので、内部 I2C バスを介して通信します。

ここでは、"microbit-bsp" クレートを使用します。このクレートでは、内部の SDA 線と SCL 線に次のようにアクセスできます。

#![allow(unused)]
fn main() {
let sda = board.i2c_int_sda;
let scl = board.i2c_int_scl;
}

加速度センサーから値を読み取るシンプルな Rust プログラム

まずは、加速度センサーから値を読み取って表示するシンプルな Rust プログラムを書いてみましょう。X、Y、Z の各軸に沿った加速度を、ミリ重力(mg)単位で表示します。読み取った値はシステムコンソールに出力します。

後続の章では、これらの値を使ったいくつかの楽しい演習を見ていきます。まずは、始めてみましょう!

テンプレートからプロジェクトを作成する

このプロジェクトでは、microbit-bsp(Embassy と組み合わせて)を使用します。テンプレートを使って新しいプロジェクトを生成するには、次のコマンドを実行します。

cargo generate --git https://github.com/ImplFerris/mb2-template.git --rev 3d07b56
  • プロジェクト名の入力を求められたら、"accelerometer-print" のような名前を入力します。

  • async を使用するかどうかを尋ねられたら、"true" を選択します。

  • "BSP" または "HAL" のどちらを選ぶかを尋ねられたら、"BSP" を選択します。

Cargo.toml を更新する

"[lsm303agr](https://docs.rs/lsm303agr/latest/lsm303agr/)" クレートは、このプロジェクトで新たに導入する依存関係です。これは LSM303AGR センサー向けのプラットフォーム非依存な Rust ドライバーであり、デバイスからデータを読み取るための便利な関数群を提供します。また、このドライバーに対して "async" 機能も有効にしています。

lsm303agr = { version = "1.1.0", features = ["async"] }
static_cell = { version = "2" }

また、static_cell クレートも追加します。これは、静的メモリを安全に作成およびアクセスするためのツールを提供します。これを使って、TWIM(I2C)ドライバーが必要とする固定サイズの RAM バッファを確保します。このバッファは 'static ライフタイムを持ち、RAM 上に存在している必要がありますが、static_cell はそれを安全に管理するのに役立ちます。

import を更新する

始める前に、I2C ペリフェラル、タイミング、そして LSM303AGR センサードライバーに必要な import を更新しましょう。

#![allow(unused)]
fn main() {
use defmt_rtt as _;
use embassy_executor::Spawner;
use embassy_time::{Delay, Duration, Timer};

// I2C用
use embassy_nrf::{self as hal, twim::Twim};
use hal::twim;

// LSM303AGRドライバー
use lsm303agr::{AccelMode, AccelOutputDataRate, Lsm303agr};

// BSPクレート
use microbit_bsp::Microbit;
}

ボードを初期化する

いつものように、まずテンプレートに含まれている main 関数内の既存の行をすべて削除します。次に、ボードを初期化するために以下の行を追加します。

#![allow(unused)]
fn main() {
let board = Microbit::default();
}

I2C 割り込み

以前、温度センサーの章で割り込みについて説明しましたので、ここまで来ればその仕組みの基本的な理解はあるはずです。I2C 通信では、TWISPI0 ペリフェラルに関連付けられた割り込みを有効にする必要があります。

TWISPI0 は nRF52833 上の共有ハードウェアペリフェラルで、設定に応じて I2C(TWI)または SPI インターフェースとして機能します。

これを行うために、embassy-nrf クレートの bind_interrupts! マクロを使用します。このマクロは "Irqs" という名前のユニット構造体を定義し、TWISPI0 割り込みを適切なハンドラーに接続します。また、このマクロは Irqs に必要な Binding トレイトも実装し、正しい割り込みが設定されていることをコンパイル時に型チェックで保証します。(ここで何を説明しているのかよくわからない場合は、bind_interrupts! マクロの展開セクションを参照してください。)

#![allow(unused)]
fn main() {
hal::bind_interrupts!(struct Irqs {
    TWISPI0 => twim::InterruptHandler<hal::peripherals::TWISPI0>;
});
}

センサーを初期化する

センサーを初期化しましょう。そのために、まず embassy-nrf と BSP クレートを使用して I2C インターフェースを作成します。I2C インターフェースを生成したら、それを Lsm303agr ドライバーに渡してセンサーを初期化します。

必要な import を追加します:

#![allow(unused)]
fn main() {
use embassy_nrf::{self as hal, twim::Twim};
use hal::twim;
use lsm303agr::Lsm303agr;
}

マスターモードの Two Wire Interface (TWIM)

embassy-nrf クレートが提供する I2C 互換の TWIM ドライバーを初期化します。このペリフェラルにより、nRF プロセッサーは I2C マスターとして動作し、LSM303AGR センサーのようなデバイスと通信できます。

Twim インスタンスを作成します。これには、I2C ペリフェラル (TWISPI0)、割り込みハンドラー (Irqs)、I2C ピン (SDA と SCL)、および設定 (ここではデフォルトのまま使用します) が必要です。

#![allow(unused)]
fn main() {
static RAM_BUFFER: ConstStaticCell<[u8; 16]> = ConstStaticCell::new([0; 16]);
let twim_config = twim::Config::default();
let twim0 = Twim::new(
    board.twispi0,
    Irqs, 
    board.i2c_int_sda, // 内部 I2C SDA、GPIO ピン: P0_08
    board.i2c_int_scl, // 内部 I2C SCL、GPIO ピン: P0_08
    twim_config,
    RAM_BUFFER.take(),
);
}

最後のパラメーターは、TWIM ドライバーがデータ送信時に使用する RAM バッファーです。データがフラッシュメモリに保存されている場合 (コード内に記述した固定のバイト配列など)、それを I2C 経由で直接送信することはできません。ドライバーはまずこのバッファーを使ってそのデータを RAM にコピーします。ConstStaticCell を使用して RAM 上に安全にバッファーを作成し、.take() でそれに安全にアクセスできます。16 バイトのバッファーは、センサーとの通信のような一般的な I2C タスクには通常十分です。RAM バッファーが送信対象のデータを保持するには小さすぎる場合、TWIM ドライバーは panic し、RAMBufferTooSmall エラーを返します。

Lsm303agr ドライバー

TWIM が初期化されたら、それを Lsm303agr::new_with_i2c 関数に渡せます。この関数は、指定した I2C インターフェースを使用してセンサードライバーの新しいインスタンスを作成します。

#![allow(unused)]
fn main() {
let mut sensor = Lsm303agr::new_with_i2c(twim0);
}

Embedded HAL トレイト: embassy-nrf HAL と lsm303agr ドライバーをつなぐ

これは、embedded-hal トレイトが HAL とドライバーをどのようにつなぐのかを理解するための任意のセクションです。ここでは、Twim と Lsm303agr がこのトレイトシステムを通じてどのように接続されているかを簡単に見ていきます。今は読み飛ばして、興味があれば後で戻ってきても構いません。

lsm303agr のようなドライバーは、micro:bit だけでなく、多くの異なるボードで動作するように作られています。これを可能にしているのが embedded-hal トレイトで、I2C のような共通インターフェイスを定義しています。これらのトレイトは特定のマイクロコントローラーに依存しません。実際のハードウェアサポートは embassy-nrf のような HAL が担い、micro:bit v2 で使われている nRF52833 のようなチップ向けの実装を提供します。

このセクションでは、これらのコンポーネントがどのように組み合わさるのかを見ていきます。具体的には、embassy-nrf の Twim インスタンスを lsm303agr ドライバーに渡し、embedded-hal トレイトを使って LSM303AGR センサーと通信できるようにする方法を確認します。

I2C トレイトは次の関数を定義しています。

  • transaction: 一連の読み取りと書き込みを、1 回の I2C トランザクションとして実行します。これは HAL が実装しなければならない中核のメソッドです。他の関数(read、write、write_read)はこの上に構築されています。
  • read: I2C スレーブからバイトを読み取ります。これにはデフォルト実装があり、内部で transaction を呼び出します。
  • write: スレーブにバイトを書き込みます。これにも transaction を使うデフォルト実装があります。
  • write_read: 数バイトを書き込んだ後、バスを解放せずにスレーブから読み取ります。これも transaction を使って実装されています。

トレイト定義はこちらで確認できます。

I2c トレイトと HAL の統合

embedded-hal トレイトのアプローチ

これは、embedded-hal が提供するトレイトの 1 つである I2c トレイトを説明する図の例です。学んだとおり、このトレイトは 4 つの関数を定義しており、transaction 関数は HAL が実装する必要があります。

lsm303agr ドライバーは、I2c トレイトを実装している任意の I2C インターフェイスで動作します。図に示されているように、複数の HAL がこのトレイトを実装できます。たとえば、embassy-nrf HAL は Twim 構造体に対する I2c の実装を提供しており、それを lsm303agr と組み合わせて使用できます。

同様に、esp-hal(ESP32 用)のような別の HAL も I2c トレイトを実装しています。つまり、互換性のある I2C 実装を渡すだけで、同じ lsm303agr ドライバーを異なるプラットフォームで使用できます。このトレイトベースの設計によって、ドライバーはプラットフォーム非依存になり、ESP32 や nRF ベースの micro:bit のような異なるボード間で再利用できるようになります。

ドライバー(lsm303agr)側

次に、ドライバー側で何が起きているのかを例を使って見てみましょう。"sensor.acceleration()" を呼び出すと、最終的に embedded-hal-async が提供する I2c トレイトの "write_read" 関数の呼び出しにつながります(async 版を使用しているためです)。同様に、sensor.init() を呼び出すと、"write" の呼び出しにつながります。

以下は、加速度センサーを読み取るために lsm303agr クレート内部で使われている関数の簡略版です。

#![allow(unused)]
fn main() {
// sensor.acceleration() の呼び出し => read_accel_3_double_registers() の呼び出し => i2c.write_read() の呼び出し
async fn read_3_double_registers<R: RegRead<(u16, u16, u16)>>(
        &mut self,
        address: u8,
    ) -> Result<R::Output, Error<E>> {
        let mut data = [0; 6];

    // ここで、ドライバーは提供された I2C インターフェイスを使い、その "write_read" 関数を呼び出します
    self.i2c
        .write_read(address, &[R::ADDR | 0x80], &mut data)
        .await
        .map_err(Error::Comm)?;

    Ok(R::from_data((
        u16::from_le_bytes([data[0], data[1]]),
        u16::from_le_bytes([data[2], data[3]]),
        u16::from_le_bytes([data[4], data[5]]),
    )))
}
}

この関数は lsm303agr クレート内で定義されており、提供された I2C インターフェイスに対して "write_read" を使用しています。今回の場合、その I2C インターフェイスは embassy-nrf の Twim 構造体のインスタンスです。

HAL(embassy-nrf)側

以下は、Twim 構造体が I2c トレイトをどのように実装しているかです(これは Github repository でも確認できます)。

#![allow(unused)]
fn main() {
// トレイト実装
impl<'d, T: Instance> embedded_hal_async::i2c::I2c for Twim<'d, T> {
    async fn transaction(&mut self, address: u8, operations: &mut [Operation<'_>]) -> Result<(), Self::Error> {
        self.transaction(address, operations).await 
    }
}

// ...
// ...
impl<'d, T: Instance> Twim<'d, T> {
    ...
    pub async fn transaction(&mut self, address: u8, mut operations: &mut [Operation<'_>]) -> Result<(), Error> {
        // 関数の完全なロジックですが、現時点では私たちにとって重要ではありません。
        Ok(())
    }
// ...
}
}

ここで、疑問に思うかもしれません。Twim 型が embedded_hal_async::i2c::I2c トレイトを実装しているなら、write_read、read、write のような残りの必須関数はどこで定義されているのでしょうか。

答えは、Twim の実装内で手動では定義されていない、ということです。そして、それで問題ありません。

なぜなら、embedded-hal-async クレートは、transaction 関数だけを使ってこれらのメソッド(read、write、write_read)のデフォルト実装を提供しているからです。

つまり、Twim が transaction メソッドを実装すると、他の必要なメソッドはすべてトレイトのデフォルト実装を通じて自動的に利用可能になります。

たとえば、以下は write 関数のデフォルト実装です。

#![allow(unused)]
fn main() {
 #[inline]
    async fn write(&mut self, address: A, write: &[u8]) -> Result<(), Self::Error> {
        self.transaction(address, &mut [Operation::Write(write)]) // => transaction を呼び出します
            .await
    }
}

この設計パターンこそが、embedded Rust を非常に強力でモジュール的なものにしています。HAL クレートは最小限のロジックだけを実装すればよく、ドライバークレートは異なるプラットフォーム間で再利用可能なまま保たれます。

加速度センサーの値を出力する

センサーインスタンスに対して「init」関数を呼び出します。これによりレジスタが初期化され、加速度センサーが読み取り可能な状態に準備されます。

#![allow(unused)]
fn main() {
sensor.init().await.unwrap();
}

動作モードと出力データレート(ODR)の設定

次に、加速度センサーの動作モードと出力データレート(ODR)を設定します。

データシート(27 ページ)で説明されているとおり、LSM303AGR は加速度センサーの 3 つの動作モード、高分解能モード、ノーマルモード、低消費電力モードをサポートしています。

高分解能モードは、12 ビットの分解能で最も高い測定精度を提供します。このモードは、動きや傾きの小さな変化に対して非常に敏感であるため、精度が重要なアプリケーションに適しています。ただし、この精度向上の代償として、他のモードと比べて消費電力は高くなります。

**出力データレート(ODR)**は、センサーが新しい加速度データをどの程度の頻度で提供するかを決定します。ODR を 50 Hz に設定すると、センサーは 1 秒間に 50 回読み取り値を更新します。このレートは、歩行、傾き、ジェスチャーの検出といった人間スケールの動作の大半に対して十分な応答性を持ちながら、消費電力を比較的低く抑えられます。

#![allow(unused)]
fn main() {
 sensor
    .set_accel_mode_and_odr(
        &mut Delay,
        AccelMode::HighResolution,
        AccelOutputDataRate::Hz50,
    )
    .await
    .unwrap();
}

50 Hz の出力データレートでは、起動時間は約 7 ms で、高分解能モードでの消費電流は約 12.6 µA です。50 Hz の ODR と高分解能モードを選ぶことで、精度とエネルギー効率のバランスが良い構成になります。これにより、プロセッサーに過度な負荷をかけたりバッテリーを急速に消耗したりすることなく、システムは詳細な動作情報を取得できます。

値を読み取る

ループ内では、スニペット sensor.accel_status().await.unwrap().xyz_new_data() を使って、新しい加速度センサーの読み取り値が利用可能かどうかを確認します。 「accel_status」関数は加速度センサーのステータスレジスタを読み取り、「xyz_new_data」関数はそのレジスタ内の特定のフラグを確認して、X、Y、Z の 3 軸すべてについて新しいデータが利用可能かどうかを判定します。

新しいデータが利用可能であれば、「acceleration」関数を使って最新の X、Y、Z の値を取得します。これらの値はその後、生のセンサーデータからミリ重力(mg)に変換されます。

完全なコード

以下は、これらをすべてまとめた完全なコードです。

#![no_std]
#![no_main]

use embassy_nrf::{self as hal, twim::Twim};
use hal::twim;

use defmt_rtt as _;
use embassy_executor::Spawner;
use embassy_time::{Delay, Timer};
use lsm303agr::{AccelMode, AccelOutputDataRate, Lsm303agr};
use microbit_bsp::Microbit;
use static_cell::ConstStaticCell;
use {defmt_rtt as _, panic_probe as _};

hal::bind_interrupts!(struct Irqs {
    TWISPI0 => twim::InterruptHandler<hal::peripherals::TWISPI0>;
});

#[embassy_executor::main]
async fn main(_spawner: Spawner) -> ! {
    let board = Microbit::default();

    let twim_config = twim::Config::default();
    static RAM_BUFFER: ConstStaticCell<[u8; 16]> = ConstStaticCell::new([0; 16]);

    let twim0 = Twim::new(
        board.twispi0,
        Irqs,
        board.i2c_int_sda,
        board.i2c_int_scl,
        twim_config,
        RAM_BUFFER.take(),
    );

    let mut sensor = Lsm303agr::new_with_i2c(twim0);
    sensor.init().await.unwrap();
    sensor
        .set_accel_mode_and_odr(
            &mut Delay,
            AccelMode::HighResolution,
            AccelOutputDataRate::Hz50,
        )
        .await
        .unwrap();

    loop {
        if sensor.accel_status().await.unwrap().xyz_new_data() {
            let data = sensor.acceleration().await.unwrap();
            // ミリ重力(mg)値
            let x = data.x_mg();
            let y = data.y_mg();
            let z = data.z_mg();
            defmt::info!("x:{}, y:{}, z:{}", x, y, z);
        }
        Timer::after_secs(1).await;
    }
}

既存のプロジェクトをクローンする

私が作成したプロジェクトをクローンする(または参照する)こともでき、bsp-embassy/accelerometer-print フォルダーへ移動できます。

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp-embassy/accelerometer-print

実行

プログラムを micro:bit に書き込むと、コンピューター上に読み取り値が表示されるはずです

cargo run

ボードの向きを変えたり、振ったり、さまざまな方向に傾けたりしてみてください。ボードの動きに応じて X、Y、Z の値が変化するのが分かります。

観察

さまざまな向きに置いたときの micro:bit の加速度計の値を見てみましょう。

X が正で 1g に近い

micro:bit を、金色のロゴが右側になるように置くと(図のとおり)、X 軸の値は +1000 mg に近くなります。Y と Z の値は 0 に近くなります。

microbit x 正で 1g に近い

X が負で 1g に近い

micro:bit を、金色のロゴが左側になるように置くと(図のとおり)、X 軸の値は -1000 mg に近くなります。Y と Z の値は 0 に近くなります。

microbit x 負で 1g に近い


Y が正で 1g に近い

micro:bit を、USB コネクタが下を向くように置くと(図のとおり)、Y 軸の値は +1000 mg に近くなります。X と Z の値は 0 に近くなります。

microbit y 正で 1g に近い

Y が負で 1g に近い

micro:bit を、エッジコネクタが下を向くように置くと(図のとおり)、Y 軸の値は -1000 mg に近くなります。X と Z の値は 0 に近くなります。

microbit y 負で 1g に近い


Z が正で 1g に近い

micro:bit を表を下にして置くと(LED マトリクスが下向き)、Z 軸の値は +1000 mg に近くなります。 X と Y の値は 0 に近くなります。

microbit z 正で 1g に近い

Z が負で 1g に近い

micro:bit を表を上にして置くと(LED マトリクスが上向き)、Z 軸の値は -1000 mg に近くなります。 X と Y の値は 0 に近くなります。

microbit z 負で 1g に近い

その他の向き

micro:bit を手に持ち、さまざまな方向にゆっくり傾けてみましょう。ボードを回転させると、X、Y、Z の値がどのように変化するかを観察してください。

重力

観察してみると、Micro:bit をチップ側(LED マトリクスの反対側)を上にしてテーブルの上に置いておくと、Z 値はおよそ +1000 になります。ですが、ちょっと待ってください。加速度の定義は、時間に対する速度の変化率です。では、Micro:bit が動いていないのに、なぜ 0 ではない値が得られるのでしょうか?

それは、重力があるからです。

地球上の標準的な重力加速度を表す 1 g(1000 mg)は、約 9.8 m/s² です。そして、加速度計が反応しているのはまさにこれです。

チップ側が上向きなのに、なぜ正になるのか?

チップ側が上向きのときに Z 値が正で、LED マトリクス側が上向きのときに負になることが実際には何を意味しているのだろう、と疑問に思っているなら、私と同じです。私も不思議に思いました。最初は気にしないようにしていましたが、ずっと引っかかっていました。そこで、ついにもう一度調べてみたところ、その混乱を解消してくれる Movella の素晴らしい記事 を見つけました。

1g近くで正になるMicro:bitのZ値

簡略化した単軸 MEMS 加速度計 - 画像の元資料: Movella

ご存じのとおり、センサーチップは Micro:bit の裏側(LED マトリクスの反対側)に搭載されています。チップ側が上を向いている状態で、上の画像のように加速度計が配置されているところを想像してください。m と記された黒い箱が質量であり(専門的には「プルーフマス」と呼ばれます)、ばねのように見える部分は実際にばねであって、抵抗器と混同してはいけません。

センサーが重力と動きにどう反応するか

MEMS 加速度計の基本原理は、ごく小さな質量がチップの内部でばねによって支えられているというものです。加速度が発生すると、その質量はその場にとどまろうとし(慣性によるものです)、加速度の向きに応じてばねは圧縮されたり伸びたりします。

さて、重力は常に下向き、つまり地球の中心に向かって引っ張っています。そのため、Micro:bit をチップ側を上にしてテーブルの上に置いていると、重力は内部の質量を下向きに引っ張ります。これによってばねは圧縮され、これまで触ったことのあるどんなばねと同じように、元の形に戻ろうとします。その押し返す力は上向き、つまり正の Z 方向です。そして、センサーが +1 g として測定しているのがこれです。

Micro:bitの上面図の加速度計

画像出典: lsm303agr データシート

加速度計が上向き(正の Z 軸方向)に加速されると、内部の質量は慣性によってその場にとどまろうとします。これによってばねも圧縮され、上向きの力が生じます。センサーは、この増加した力を Z 軸方向の正の加速度として解釈します。

同様に、加速度計が下向き(負の Z 軸方向)に加速されると、内部のプルーフマスは上向きに引っ張られ、ばね・ダンパー系は伸びます。加速度計はこれを負の加速度として検出します。

Micro:bit を裏返して LED マトリクスが上を向くようにすると、Z 軸は今度は下向きになります。重力は依然として質量を下向きに引っ張りますが、Z 軸が反転しているので、その引っ張る向きは今度は負の Z 方向になります。これによってばねは伸びます。なぜなら、質量は依然としてその引っ張りに抵抗しているからです。ばねは下向き(-Z 方向)に引っ張り、そのためセンサーの読み取り値はおよそ -1000 になります。

Rustでmicrobitのシェイク検出コードを書く

加速度センサーの読み取り値をシステムコンソールに出力することには、すでに成功しています。みなさんはmicrobitをさまざまな方向に傾けて、値がどのように変化するかを確認してみたと思います。それはそれで面白いのですが、単に値を表示するだけではあまりわくわくしません。この章では、micro:bitを振ると音を鳴らすプログラムを書きます。

テンプレートからプロジェクトを作成する

このプロジェクトでは、microbit-bsp(Embassyあり)を使用します。テンプレートを使って新しいプロジェクトを生成するには、次のコマンドを実行します。

cargo generate --git https://github.com/ImplFerris/mb2-template.git --rev 3d07b56
  • プロジェクト名の入力を求められたら、"accelerometer-print" のような名前を入力します。

  • async を使うかどうかを尋ねられたら、"true" を選択します。

  • "BSP" または "HAL" のどちらを使うかを尋ねられたら、"BSP" を選択します。

Cargo.tomlを更新する

Cargo.tomlファイルを開き、次の行を追加します。

lsm303agr = { version = "1.1.0", features = ["async"] }
static_cell = { version = "2" }

シェイクを検出するには?

シェイクを検出するには、まず加速度センサーの読み取り値の大きさを計算する必要があります。大きさを計算する式は次のとおりです。

$$ \text{magnitude} = \sqrt{x^2 + y^2 + z^2} $$

計算例

1つのサンプル読み取り値を使って、その大きさを計算してみましょう。

サンプル値: x = 81, y = -105, z = -1018

#![allow(unused)]
fn main() {
x = 81          => x^2 = 6561
y = -105        => y^2 = 11025
z = -1018       => z^2 = 1036324

magnitude^2 = 6561 + 11025 + 1036324 = 1053910
magnitude   = sqrt(1053910) ≈ 1026.6
}

もう1つのサンプル読み取り値を見てみましょう: x:-875, y:-567, z:-143

#![allow(unused)]
fn main() {
x^2          = (-875)^2   = 765625
y^2          = (-567)^2   = 321489
z^2          = (-143)^2   = 20449

magnitude^2  = 765625 + 321489 + 20449 = 1107563
magnitude    = sqrt(1107563) ≈ 1052.4
}

Microbitを振っているときの読み取り値

さらにいくつかのサンプル読み取り値を試して、その大きさを計算してみてください。静止時のほとんどの値は1000に近いはずです。では、前のプログラムを実行して加速度センサーの値を表示してみましょう。今回は、micro:bitをしっかり振って、読み取り値を観察してください。

以下は、デバイスを振っているときに私が記録したサンプルの1つです: x:-1265, y:-2029, z:1657

#![allow(unused)]
fn main() {
x^2          = (-1265)^2  = 1600225
y^2          = (-2029)^2  = 4116841
z^2          = (1657)^2   = 2745649

magnitude^2  = 1600225 + 4116841 + 2745649 = 8462715
magnitude    = sqrt(8462715) ≈ 2909.07
}

おお、今度は大きさがほぼ3000です!これは静止時の値から大きく跳ね上がっており、強いシェイクが起きていることをはっきり示しています。

コードでシェイクを検出するには、しきい値(たとえば 2000)を決めて、計算した大きさと比較します。大きさがこのしきい値より大きければ、シェイクが発生したと判断できます。

Embedded Rust (#![no_std]) で平方根(sqrt)を計算する方法

sqrt() 関数は、no_std 環境ではデフォルトでは利用できません。libm のようなcrateを使えば no_std 向けの数学関数を利用できますが、ここではもっと簡単で効率のよい方法を使います。

平方根を計算する代わりに、しきい値を二乗して、大きさの二乗と直接比較できます。たとえば、しきい値が 2000 なら、これを二乗して 4,000,000 にします。次に、この値と大きさの二乗を比較します。

#![allow(unused)]
fn main() {
// これの代わりに:
2909.07 > 2000

// こうします:
8462715 > 4000000
}

シェイクを検出するコード

しきい値の比較ロジックをRustコードにしてみましょう。以下の関数は、x、y、z の加速度センサーの読み取り値を受け取り、シェイクが検出された場合に true を返します。

#![allow(unused)]
fn main() {
const SHAKE_THRESHOLD_MG: i32 = 2000;
const SHAKE_THRESHOLD_SQUARED: i64 = (SHAKE_THRESHOLD_MG * SHAKE_THRESHOLD_MG) as i64;

fn detect_shake(x: i32, y: i32, z: i32) -> bool {
    let mag_sq = x as i64 * x as i64 + y as i64 * y as i64 + z as i64 * z as i64;
    mag_sq > SHAKE_THRESHOLD_SQUARED
}
}

完全なコード

デバイスの振り方や求める感度に応じて、しきい値やディレイのタイミングを調整する必要があるかもしれません。

#![no_std]
#![no_main]

use defmt::info;
use embassy_nrf::{self as hal, pwm::SimplePwm, twim::Twim};
use hal::twim;

use defmt_rtt as _;
use embassy_executor::Spawner;
use embassy_time::{Delay, Timer};
use lsm303agr::{AccelMode, AccelOutputDataRate, Lsm303agr};
use microbit_bsp::{
    Microbit,
    speaker::{NamedPitch, Pitch, PwmSpeaker},
};
use static_cell::ConstStaticCell;

#[panic_handler]
fn panic(panic_info: &core::panic::PanicInfo) -> ! {
    info!("{:?}", panic_info);
    loop {}
}

hal::bind_interrupts!(struct Irqs {
    TWISPI0 => twim::InterruptHandler<hal::peripherals::TWISPI0>;
});

const SHAKE_THRESHOLD_MG: i32 = 2000;

const SHAKE_THRESHOLD_SQUARED: i64 = (SHAKE_THRESHOLD_MG * SHAKE_THRESHOLD_MG) as i64;

fn detect_shake(x: i32, y: i32, z: i32) -> bool {
    let mag_sq = x as i64 * x as i64 + y as i64 * y as i64 + z as i64 * z as i64;
    mag_sq > SHAKE_THRESHOLD_SQUARED
}

#[embassy_executor::main]
async fn main(_spawner: Spawner) -> ! {
    // let p = embassy_nrf::init(Default::default());
    let board = Microbit::default();

    static RAM_BUFFER: ConstStaticCell<[u8; 16]> = ConstStaticCell::new([0; 16]);
    let twim_config = twim::Config::default();
    let twim0 = Twim::new(
        board.twispi0,
        Irqs,
        board.i2c_int_sda,
        board.i2c_int_scl,
        twim_config,
        RAM_BUFFER.take(),
    );

    let mut speaker = PwmSpeaker::new(SimplePwm::new_1ch(board.pwm0, board.speaker));

    let mut sensor = Lsm303agr::new_with_i2c(twim0);
    sensor.init().await.unwrap();
    sensor
        .set_accel_mode_and_odr(
            &mut Delay,
            AccelMode::HighResolution,
            AccelOutputDataRate::Hz50,
        )
        .await
        .unwrap();

    loop {
        if sensor.accel_status().await.unwrap().xyz_new_data() {
            let data = sensor.acceleration().await.unwrap();
            let x = data.x_mg();
            let y = data.y_mg();
            let z = data.z_mg();
            if detect_shake(x, y, z) {
                // info!("SHAKE => x:{}, y:{}, z:{}", x, y, z);
                speaker.start_note(Pitch::Named(NamedPitch::A4));
                Timer::after_millis(100).await;
                speaker.stop();
            }
        }
        Timer::after_millis(50).await;
    }
}

Quick startプロジェクトをクローンする

私が作成した quick startプロジェクトをクローンして、プロジェクトフォルダへ移動し、実行できます。

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp-embassy/shake-detect

書き込み - Run Rust Run

あとはコードをmicro:bitに書き込んで、実際に動かしてみるだけです。

プロジェクトフォルダから次のコマンドを実行します。

#![allow(unused)]
fn main() {
cargo run
}

では、microbitを振ってみてください。シェイクが検出されると音が鳴るはずです。

応用例

シェイク検出でほかに何ができるでしょうか? 楽しくて創造的な可能性がたくさんあります!

いくつかアイデアを紹介します。

  • サイコロを振る: 振るたびに 1 から 6 のランダムな数を生成します。

  • 色やアニメーションを変えるために振る: micro:bitが振られるたびに、LEDパターンを更新したり新しいフレームを表示したりします。

  • ゲームを操作するために振る: シンプルなゲームで、ジャンプ、移動、アクションのトリガーなどの入力としてシェイクを使います。

  • 歩数計: 小さく繰り返されるシェイクを検出して、基本的な歩数計のように歩数を数えます。

Bluetooth

Bluetooth は説明不要でしょう。おそらく毎日のように使っているはずです。たとえば、ワイヤレスヘッドホンをスマートフォンに接続したり、ワイヤレスマウスやスマートウォッチを使ったりする場面です。スマートホームでは、Bluetooth はさまざまなデバイスをつなぐのに役立ちます。たとえば、スマートフォン上の Bluetooth 対応アプリを使って、照明、家電、サーモスタットを制御できます。

ご存じでしたか?

Bluetooth という名前は、Harald "Bluetooth" Gormsson 王にちなんで付けられました。会議中に、Intel の Jim Kardach が一時的なコードネームとしてこの名前を提案しました。Kardach は後に、「Harald Bluetooth 王は、ちょうど私たちが短距離無線リンクで PC 業界と携帯電話業界を結び付けようとしていたのと同じように、スカンジナビアを統一したことで有名だった」と述べています

カテゴリ

Bluetooth 技術は、大きく 2 つの主要な種類に分けられます。Bluetooth Classic と Bluetooth Low Energy(BLE)です。

Bluetooth Classic

Bluetooth Classic は Bluetooth の元のバージョンで、ワイヤレスヘッドセット、スピーカー、マウスなど、継続的なデータ転送を必要とするデバイスで一般的に使われています。BLE が導入される前は単に「Bluetooth」と呼ばれていましたが、現在は BLE と区別するために Bluetooth Classic と呼ばれています。より高いデータレートを提供するため、オーディオストリーミングのようなリアルタイムアプリケーションに最適です。

Bluetooth Low Energy (BLE)

BLE は低消費電力向けに設計されており、少量のデータをときどき送信するデバイスに適しています。これは、フィットネストラッカーや環境センサーなどのバッテリー駆動の IoT デバイスで特に役立ちます。Classic と比べて、BLE はレイテンシが低く、接続が確立された後にデータの送受信を開始するまでの時間が短くなります。

Dual Mode

多くの最新デバイスは Bluetooth Classic と BLE の両方をサポートしており、この機能は「Dual Mode」として知られています。たとえば、スマートフォンは音楽のストリーミングに Classic を使い、スマートウォッチとの接続に BLE を使うことがあります。

一般に、Bluetooth Classic はリアルタイムの音声や映像などの継続的なデータ伝送により適しており、BLE はヘルスモニター、センサー、そのほかの小型ガジェットとの低消費電力通信に最適です。

Microbit Bluetooth

micro:bit は Nordic S113 SoftDevice を介して、Bluetooth Low Energy(BLE)による Bluetooth 5.1 をサポートしています。Bluetooth Classic はサポートしていませんが、その BLE 機能は、センサーデータをスマートフォンに送信したり、アプリから LED を制御したり、ほかの micro:bit と通信したりといった、多くの実用的なアプリケーションには十分です。

⚠️ 注意: これはこの本の中でも難しい章の 1 つかもしれません。Bluetooth Low Energy(BLE)を扱うのは簡単ではありません。学び、理解しなければならないことがたくさんあります。Rust プログラムを実行する前に、SoftDevice(Bluetooth 用の特別なファームウェア)を micro:bit に書き込む必要があります。また、プログラムが SoftDevice に使用される領域を上書きしないように、memory.x ファイルも更新する必要があります。

BLE

Bluetooth Low Energyを扱うには、いくつかの重要な概念を理解する必要があります。ここでは、シンプルに、細かい説明を詰め込みすぎずに使い始めるのに十分な範囲だけを取り上げます。では、準備を整えて、さっそく始めましょう。

BLEスタック

以下の図は、Bluetooth Low Energy(BLE)のプロトコルスタックを示しています。BLEスタックは、BLEデバイス間の通信の基盤です。Controller(下位レイヤー)については、今回の目的には不可欠ではないため、詳しくは扱いません。ただし、GAPやGATTなど、Host部分の重要な概念を理解することは大切です。

Bluetooth LEプロトコルスタック

GAP => デバイスがどのように接続し、通信するか

GAP(Generic Access Profile)は、BLEデバイスがどのようにアドバタイズし、接続し、通信を確立するかを定義します。これは、デバイスの役割(例: central、peripheral)、接続パラメーター、セキュリティモードを扱います。GAPは、デバイス同士がどのように互いを見つけ、通信を開始するかを担います。

GATT => デバイスがどのようにデータを交換し、構造化するか

GATT(Generic Attribute Profile)は、BLEデバイスがどのようにデータを交換するかを定義します。これは、データをサービスとキャラクタリスティックの階層として整理し、クライアント(例: スマートフォンアプリ)がBLE peripheral(例: センサー)からデータを読み取り、書き込み、更新を購読できるようにします。

参考資料

さらに深く理解したい場合は、次のリソースを参照してください

Generic Access Profile(GAP)

GAP(Generic Access Profile)は、Bluetooth Low Energy(BLE)デバイスが互いを検出し、接続し、通信する方法を制御する一連のルールです。

BLE の通信タイプ

BLE は、通信するための主な方法として 接続型通信ブロードキャスト通信 の 2 つをサポートしています。

接続型通信 : 2 台のデバイスが直接接続を確立し、双方向にデータを送受信できるようになります。たとえば、スマートウォッチがスマートフォンに接続し、心拍数、通知、歩数などのデータを継続的に共有します。

ブロードキャスト通信: デバイスは、直接接続を確立せずに、近くにあるすべてのデバイスにデータを送信します。たとえば、店舗内の Bluetooth ビーコンは、通信範囲内にあるすべてのスマートフォンに販促メッセージをブロードキャストします。

デバイスのロール

これらのロールは、現実世界の人間同士のコミュニケーションのように考えるとわかりやすいです。会話における役割によって人の関わり方が変わるのと同様に、Bluetooth Low Energy(BLE)デバイスにもそれぞれ固有のロールがあります。

📢 ブロードキャスター(コネクションレス): 情報(アドバタイズメント)を送信しますが、接続はできません。
たとえば、ショッピングモール内のビーコンは、近くにあるスマートフォンに割引情報を継続的に送信します。スマートフォンはその情報を受信できますが、ビーコンには接続できません。

📡 オブザーバー(コネクションレス): Bluetooth アドバタイズメントを受信しますが、他のデバイスに接続することはできません。
たとえば、スマートフォンアプリが近くの店舗を検出するためにビーコンをスキャンしても、それらに接続はしません。

📱 セントラル(コネクション指向): このデバイスは他のデバイスを探索し、それらに接続したり、アドバタイズメントデータを読み取ったりします。通常は、より高い処理能力と多くのリソースを備えています。同時に複数の接続を処理できます。

たとえば、スマートフォンはスマートウォッチ、フィットネストラッカー、ワイヤレスイヤホンに同時に接続できます。

⌚ ペリフェラル(コネクション指向): このデバイスはアドバタイズメントをブロードキャストし、セントラルデバイスからの接続要求を受け入れます。 たとえば、フィットネストラッカーは自身をアドバタイズすることで、スマートフォンがそれを見つけて接続し、健康データを同期できるようにします。

セントラルとペリフェラル

BLE ペリフェラルの検出モードとアドバタイズメントフラグ

BLE ペリフェラルは複数の検出モードを取ることができ、それによってセントラルデバイスからどのように検出されるかが変わります。これらのモードは、アドバタイジングパケット内のアドバタイズメントフラグを使用して設定します。

検出モード

  1. 非発見可能

    • アドバタイジングが有効でないとき、または接続が確立されたときのデフォルトモードです。
    • 発見も接続もできません。
  2. 制限付き発見可能

    • 省電力のため、限られた時間だけ 発見可能になります。
    • 接続が確立されない場合、デバイスはアイドル状態になります。
  3. 一般発見可能

    • 接続が確立されるまで、無期限に アドバタイズします。

アドバタイズメントフラグ

これらのフラグは、検出モードと BLE のサポートレベルを示します。ビット単位 OR(|)を使って組み合わせます。

BitDescription
0制限付き発見可能モード(一時的なアドバタイジング)。
1一般発見可能モード(無期限にアドバタイズ)。
2デバイスが Bluetooth Classic(BR/EDR)をサポートしていない(またはサポートしたくない)場合に設定します。
3デバイスが Bluetooth Low Energy(LE)と Classic Bluetooth を同時に使用できる場合に設定します(Controller レベル)。
4デバイスが Bluetooth Low Energy(LE)と Classic Bluetooth を同時に実行できる場合に設定します(Host レベル)。
5-7予約済み

後で Bluetooth には softdevice crate を使用します。これは、アドバタイズメントフラグを渡せるアドバタイズメントビルダーを提供します。

たとえば、ペリフェラルを無期限にアドバタイズし、Bluetooth Classic をサポートしないことを示すには、次のように設定します。

#![allow(unused)]
fn main() {
LegacyAdvertisementBuilder::new()
    .flags(&[
         Flag::GeneralDiscovery,    // 0b0000_0010     (ビット 1 を設定)
         Flag::LE_Only,             // 0b0000_0100    (ビット 2 を設定)
   ])
}

指向性アドバタイジングと無指向性アドバタイジング

アドバタイズメントが、特定のセントラルデバイス向けなのか、近くにある任意のデバイス向けなのかを示します。

  • 無指向性: 近くにいる任意のセントラルまたはオブザーバーに送信されます。ペリフェラルが任意のデバイスからの接続を受け付ける場合に使用します。

  • 指向性: Bluetooth アドレスで識別される 1 台の特定のセントラルに送信されます。応答できるのはそのデバイスだけです。

接続可能アドバタイジングと非接続可能アドバタイジング

これにより、セントラルデバイスがペリフェラルとの接続を開始できるかどうかが決まります。

  • 接続可能: セントラルはペリフェラルに接続要求を送信できます。

  • 非接続可能: ペリフェラルはアドバタイズメントを送信するだけで、接続要求は受け入れません。

スキャン可能アドバタイジングと非スキャン可能アドバタイジング

セントラルがスキャン要求を介してペリフェラルから追加情報を要求できるかどうかを定義します。

  • スキャン可能: ペリフェラルはスキャン要求を受け入れ、追加情報(scan response)で応答します。

  • 非スキャン可能: ペリフェラルはスキャン要求に応答しません。

nrf-softdevice crate でデバイスを接続可能、スキャン可能、かつ無指向性(つまり近くにいる任意のセントラルから見える状態)にするには、次のように記述します。

#![allow(unused)]
fn main() {
peripheral::ConnectableAdvertisement::ScannableUndirected {
   adv_data: &ADV_DATA,
   scan_data: &SCAN_DATA,
}
}

これにより、SoftDevice は任意のセントラルデバイスからの接続要求を受け付け、スキャン要求に追加データで応答し、特定のデバイスを対象にせず近くにいるすべてのデバイスに対してアドバタイズします。

属性プロトコル(ATT)と汎用属性プロファイル(GATT)

前の章では、GAP レイヤーがアドバタイジングを通じて Bluetooth LE デバイス同士を見つけるのに役立つことを学びました。接続後は、データを送受信するための方法が必要になります。ここで ATT レイヤーと GATT レイヤーが登場します。これらは、データがどのように構造化され、デバイス間でどのように送受信されるかを定義します。

クライアント・サーバーモデル

GATT には 2 つの役割があります: Server と Client。サーバーはデータを属性として保持し、クライアントはこのデータにアクセスします。通常、ペリフェラルデバイス(センサーなど)がサーバーとして動作し、セントラルデバイス(スマートフォンなど)がクライアントとして機能します。

Central-Peripheral と Server-Client

GATT におけるクライアントとサーバーの役割は、GAP におけるペリフェラルとセントラルの役割とは独立しています。つまり、セントラルデバイスはクライアントにもサーバーにもなれ、ペリフェラルデバイスにも同じことが当てはまります。

たとえば、スマートフォンとフィットネストラッカーのシナリオでは、フィットネストラッカー(ペリフェラル)は通常 GATT サーバーとして動作し、心拍数や歩数などのセンサーデータを保存します。一方、スマートフォン(セントラル)は GATT クライアントとして動作し、このデータを読み取ってアプリに表示します。

ただし、スマートフォンが設定情報をトラッカーに送る必要がある場合(例: 画面の明るさを調整する、アラームを設定するなど)、一時的にスマートフォンがサーバーとなり、フィットネストラッカーがクライアントとしてそれらの設定を受信します。

属性プロトコル(ATT)- 基盤

ATT は、データが属性としてどのように保存されるかを定義します。属性は基本的な土台であり、構成要素でもあります。各属性には、一意のハンドル、タイプ(16 ビットの識別子または 128 ビット UUID)、パーミッション(例: 読み取り可能、書き込み可能)、およびデータ(実際の値)があります。クライアントはデータを読み取り、書き込み、またはサブスクライブできます。

汎用属性プロファイル(GATT)- データの整理

GATT は、データに構造と意味を加えることで ATT を拡張します。これは、データがどのようにグループ化され、アクセスされるかを定義します。

GATT は属性を次のように整理します:

  • Characteristic: デバイスが共有できる単一のデータ項目です。他のデバイスはこれを読み取り、書き込み、または更新を受け取ることができます。たとえば、Heart Rate Measurement characteristic は現在の心拍数を保持し、それが変化したときに更新を送信できます。

  • Service: 関連するキャラクタリスティックをまとめた集合です。たとえば、Heart Rate Service には、心拍数測定とセンサーの身体上の位置に関するキャラクタリスティックが含まれます。

  • Profiles: 関連するサービスの集合です(例: Heart Rate Service、Device Information Service)。

次の図は、Heart Rate Sensor のプロファイル、サービス、およびキャラクタリスティックを示しています GAT

キャラクタリスティックディスクリプター

ディスクリプターは、追加情報を提供したり、キャラクタリスティックの振る舞いを制御したりするためのオプションの属性です。

最もよく使われるディスクリプターは Client Characteristic Configuration Descriptor(CCCD)です。これにより、クライアント(スマートフォンなど)は、サーバー(心拍数センサーなど)からの notification または indication を有効化または無効化できます。

たとえば Heart Rate Service では、クライアントは CCCD に書き込んで更新をサブスクライブできます。これにより、心拍数センサーは、スマートフォンが繰り返し問い合わせなくても、新しいデータをスマートフォンにプッシュできます。

UUID

属性における UUID の部分を改めて見てみましょう。各サービスとキャラクタリスティックは、一意の ID 値を持つ必要があります。UUID は、Bluetooth-SIG が定義した標準 UUID(16 ビット)またはカスタム UUID(128 ビット)のいずれかです。

事前定義された UUID の一覧は、こちらで確認できます: https://www.bluetooth.com/specifications/assigned-numbers/

Heart Rate Service 用の事前定義 UUID: GAT

Heart Rate Monitor characteristic 用の事前定義 UUID: GAT

カスタム UUID:

カスタム UUID は、標準の事前定義済み Bluetooth サービスに含まれていないサービスを実装する場合に使用します。ただし、心拍数モニターやバッテリーレベルのような一般的なサービスを実装する場合は、Bluetooth 仕様で提供されている公式 UUID を使用するのが最善です。

カスタム UUID を生成するには、UUID Generator にアクセスして、サービスとキャラクタリスティック用の一意な UUID を作成できます。

Embedded Rustコードを書いて、micro:bitのBluetooth Low Energy (BLE) をスマートフォンに接続する

BLEスタックが何であるかを見て、基本的な概念を理解しました。次は、その知識を実際に使ってみる番です。スマートフォンとmicro:bitの間でデータを送受信できる、シンプルな(ナレーター: 著者は嘘をついていました)プロジェクトを書いていきます。

このプログラムには、BatteryService というBLEサービスを1つ含めます。接続されたデバイス(スマートフォンなど)は、現在のバッテリーレベルを読み取るか、バッテリーレベルが変化するたびに更新を受け取るために購読できます。

テンプレートからプロジェクトを作成する

今回もEmbassyを使いますが、今度はBSPは使いません。その代わりに、embassy-nrf HAL を直接扱います。

cargo generate --git https://github.com/ImplFerris/mb2-template.git --rev 3d07b56
  • プロジェクト名の入力を求められたら、"hell-ble" のような名前を入力します。

  • async を使用するか尋ねられたら、"true" を選択します。

  • "BSP" または "HAL" のどちらを使うか選ぶよう求められたら、"HAL" を選択します。

プロジェクト構成

この演習は、nrf-softdevice リポジトリにある ble_bas_peripheral_notify.rs の例をベースに、分かりやすさのために少し構成を変更したものです。さらに多くの例はこちらで確認できます。

以下は、これから作成する src フォルダーの構成です。Bluetooth関連の共通セットアップと設定を格納するための ble モジュールを作成し、バッテリーサービスのロジック用に別の service.rs ファイルを用意します。

.
├── ble
│   ├── adv.rs
│   ├── config.rs
│   └── mod.rs
├── main.rs
└── service.rs

1 directory, 5 files

依存関係

nrf-softdevice クレート

このクレートは、Nordic のクローズドソースな SoftDevice Bluetooth スタックに対する Rust バインディングを提供します。SoftDevice は、起動時に最初に実行され、その後で制御をあなたのアプリに渡す、事前コンパイル済みの C バイナリです。十分に実績があり、Bluetooth 認証向けに事前認証済みです。詳細はこちら: nrf-softdevice GitHub

このクレートを使って、このプロジェクトで Bluetooth を処理します。これを使用するには、クレートに対して次の feature を指定する必要があります。

  • SoftDevice モデルをちょうど 1 つ。モデルによって、クレートがサポートする役割は異なります。micro:bit は s113 SoftDevice を使用するため、それを使用する必要があります。このモデルでは、クレートは peripheral の役割のみをサポートします。central モードはサポートしていません。

  • サポートされている nRF チップをちょうど 1 つ。micro:bit では、すでに nrf52833 を使用していることがわかっています。

したがって、次の依存関係を使って Cargo.toml を更新する必要があります。

# nrf-softdevice = { version = "0.1.0", features = ["ble-peripheral", "ble-gatt-server", "s113", "nrf52833", "critical-section-impl", "defmt"] }
# 競合の問題を修正するため、最新の(現時点での)リビジョンを使用
nrf-softdevice = { git = "https://github.com/embassy-rs/nrf-softdevice/", rev = "5949a5b", features = [
    "ble-peripheral",
    "ble-gatt-server",
    "s113",
    "nrf52833",
    "defmt",
] }
nrf-softdevice-s113 = { version = "0.1.2" }

これに加えて、今回のユースケースで必要となる ble-peripheralble-gatt-server といった追加 feature もいくつか有効にしています。

StaticCell クレート

"StaticCell" クレートは、変数を実行時に初期化する必要がありつつ、その変数に static ライフタイムが必要な場合に便利です。

次の依存関係を使って Cargo.toml を更新してください。

static_cell = "2"

Heapless クレート

heapless クレートの主な考え方は、動的メモリ(ヒープ)を必要としないデータ構造を使うことです。代わりに、すべてが固定サイズのメモリ領域に格納されます。

次の依存関係を使って Cargo.toml を更新してください。

heapless = "0.8"

たとえば、heapless::Vec は Rust の通常の Vec に似ていますが、大きな違いが 1 つあります。最大サイズがあらかじめ固定されており、そのサイズを超えて拡張できないことです。これは、ヒープメモリが利用できないことが多い Embedded Rust で特に役立ちます。

Futures

futures クレートは、非同期コードを書くためのコアツールを提供します。バッテリーレベル通知処理と GATT サーバータスクを並行して実行するために、ここではその select!pin_mut! マクロを使用します。

futures = { version = "0.3.29", default-features = false }

SoftDevice ファームウェア

Bluetooth プログラムを実行する前に、SoftDevice と呼ばれる特別なファームウェアを micro:bit に書き込む必要があります。このファームウェアは、micro:bit v2 で使われている nRF52833 チップのメーカーである Nordic Semiconductor によって提供されています。

このチップでは、メモリ効率のよい Bluetooth Low Energy (BLE) プロトコルスタックである SoftDevice S113 を使用します。これはペリフェラルとして最大 4 つの同時 BLE 接続をサポートし、データをブロードキャストすることもできます。

ダウンロード

  1. Nordic Semi のダウンロードページにアクセスします: https://www.nordicsemi.com/Products/Development-software/S113/Download

  2. パッケージをダウンロードします。私の場合は、DeviceDownload.zip という名前のファイルを取得しました。

  3. DeviceDownload.zip を展開すると、s113_nrf52_7.3.0.zip という別のファイルがあります。

  4. その zip も展開してください。中には、次のような内容があります:

.
├── s113_nrf52_7.3.0_API
├── s113_nrf52_7.3.0_license-agreement.txt
├── s113_nrf52_7.3.0_release-notes.pdf
└── s113_nrf52_7.3.0_softdevice.hex

1 directory, 3 files

目的は .hex ファイルです。これは、バイナリのファームウェアデータを読みやすいテキスト形式で格納する特別なファイル形式(Intel HEX)です。

SoftDevice をフラッシュする

micro:bit にファームウェアを書き込むには、次のコマンドを実行します:

probe-rs download s113_nrf52_7.3.0_softdevice.hex --binary-format Hex --chip nRF52833_xxAA

他の演習で SoftDevice ファームウェアが上書きされている可能性があるため、BLE の演習に戻ってくるたびにこの手順をやり直す必要がある点に注意してください。

memory.x ファイルを更新する

SoftDevice を書き込んだら、Rust プログラムが SoftDevice の使用するメモリ領域を上書きしないようにする必要があります。

そのために、memory.x リンカスクリプトを更新します。このファイルは、Rust コンパイラがアプリケーションで使用できるメモリ領域を定義します。

元の memory.x ファイル

MEMORY
{
  FLASH : ORIGIN = 0x00000000, LENGTH = 512K
  RAM : ORIGIN = 0x20000000, LENGTH = 128K
}

更新後の memory.x ファイル

MEMORY
{
  /* 注 1 K = 1 KiBi = 1024 バイト */
  MBR                               : ORIGIN = 0x00000000, LENGTH = 4K
  SOFTDEVICE                        : ORIGIN = 0x00001000, LENGTH = 112K
  FLASH                             : ORIGIN = 0x0001C000, LENGTH = 396K
  RAM                               : ORIGIN = 0x20003410, LENGTH = 117744
}

これによりリンカは、SoftDevice に予約されたフラッシュおよび RAM 領域の後ろからプログラムを開始するため、実行時のメモリ競合を防げます。

これらの値はどこから来るのか?

これらの値は、hex ファイルと一緒に提供される PDF ドキュメント(s113_nrf52_7.3.0_release-notes.pdf)に基づいています:

フラッシュメモリ

SoftDevice は、0x00001000 から始まる先頭 112 KB (0x1C000) のフラッシュメモリを使用します。つまり、アプリは 0x0001C000 より後ろから開始する必要があり、使用可能なフラッシュは 512 KB - 112 KB - 4 KB (MBR) = 396 KB になります。

RAM の計算

SoftDevice が必要とする最小 RAM 量は、どのように設定するかによって変わります。リリースノートによると、少なくとも 4.4 KB (0x1198) が必要で、さらにコールスタック用として約 1.8 KB (0x700) を使用する可能性があります(最悪の場合)。しかし実際には、予約すべき正確な量を推測したり手計算したりする必要はありません。

便利なのは、プログラム実行時に nrf-softdevice crate が必要な RAM 量を正確に教えてくれることです。

コツは次のとおりです。まずは小さめに RAM を予約します。たとえば、プログラムの RAM 開始アドレスを 0x20001fa8 に設定してみてください(つまり、最初は約 8 KB を予約することになります)。プログラムを書き込んで実行すると、次のようなログが表示されます:

softdevice RAM: 13328 bytes

この場合、必要なのは 13328 バイトで、16 進数では 0x3410 です。これが、最終的な memory.x でプログラムの RAM 開始位置を 0x20003410 に設定している理由です。チップ上の総 RAM は 128 KB = 131072 バイトなので、アプリで使える残りの RAM は次のようになります:

# remaining_ram = total_ram - softdevice_ram
remaining_ram = 131072 - 13328 = 117744 bytes

これが、SoftDevice が必要分を確保したあとにプログラムが使える RAM です。

SoftDevice の RAM 計算を理解するにあたって助けてくれた Dario Nieuwenhuis に特別な感謝を伝えます。

注: 後で SoftDevice の設定が変わった場合は、memory.x 内の RAM の開始位置と長さを調整する必要があるかもしれません。ただし心配はいりません。nrf-softdevice crate が実行時に必要な RAM 量を正確に教えてくれます。その値に合わせて調整し、プログラムを再実行できます。

RAM                               : ORIGIN = 0x20003410, LENGTH = 117744

また、SoftDevice に必要以上のメモリを割り当てると、nrf-softdevice crate が警告してくれます。

BLE モジュール

まずは ble モジュールを作成しましょう。

ble/mod.rs ファイル

ここでは、シンプルな関数を定義します。この関数は softdevice_config(これはこの後 config.rs ですぐに定義します)を呼び出して SoftDevice の設定を取得します。次に、その設定を使って SoftDevice を有効化します。

#![allow(unused)]
fn main() {
pub mod adv;
pub mod config;

use crate::ble::config::softdevice_config;

use {defmt_rtt as _, panic_probe as _};

use nrf_softdevice::Softdevice;

pub fn get_soft_device() -> &'static mut Softdevice {
    let sd_config = softdevice_config();
    Softdevice::enable(&sd_config)
}

}

これにより、Softdevice 構造体のインスタンスが得られます。後で main 関数から get_soft_device() を呼び出し、そのインスタンスに対してさらに処理を行います。

ble/adv.rs ファイル

次に、アドバタイズデータを準備するためのモジュールを作成します。アドバタイズの背後にある中核的な考え方については、すでに GAP セクション で説明しました。

アドバタイズペイロードの準備には LegacyAdvertisementBuilder を使用します。ここでは、デバイスを discoverable にするためのフラグを設定します。たとえば、接続されるまでアドバタイズを継続するために GeneralDiscovery を使い、Bluetooth Classic をサポートしていないことを示すために LE_Only を使います。

また、central デバイスにサポート内容を知らせるため、BATTERY サービスの 16 ビット UUID も追加します。DEVICE_NAME は、後で config.rs ファイルで定義する定数("implRust")です。

#![allow(unused)]
fn main() {
use crate::ble::config::DEVICE_NAME;

use {defmt_rtt as _, panic_probe as _};

use nrf_softdevice::ble::{
    advertisement_builder::{
        Flag, LegacyAdvertisementBuilder, LegacyAdvertisementPayload, ServiceList, ServiceUuid16,
    },
    peripheral,
};

static ADV_DATA: LegacyAdvertisementPayload = LegacyAdvertisementBuilder::new()
    .flags(&[Flag::GeneralDiscovery, Flag::LE_Only])
    .services_16(ServiceList::Complete, &[ServiceUuid16::BATTERY])
    .full_name(DEVICE_NAME)
    .build();

static SCAN_DATA: [u8; 0] = []; 

pub fn get_adv() -> peripheral::ConnectableAdvertisement<'static> {
    peripheral::ConnectableAdvertisement::ScannableUndirected {
        adv_data: &ADV_DATA,
        scan_data: &SCAN_DATA,
    }
}
}

最後に、peripheral::ConnectableAdvertisement を使って ScannableUndirected アドバタイズタイプを返します。これは、私たちのデバイスが次のように振る舞うことを意味します。

  • 近くにいる任意の central に対してアドバタイズする(特定の相手を対象にしない)
  • 接続要求を受け付ける
  • 追加情報を含めてスキャン要求に応答する

SoftDevice の設定 - ble/config.rs

それでは、SoftDevice を実行するために必要な設定を行いましょう。以下のコードはすべて ble/config.rs ファイル内に記述します。

インポート

#![allow(unused)]
fn main() {
use {defmt_rtt as _, panic_probe as _};
use core::mem;
use nrf_softdevice::raw;
}

デバイス名

まず、デバイス名を定義します。これは、他のデバイスが micro:bit をスキャンしたときに表示されます。有効な名前であれば自由に変更できます。

#![allow(unused)]
fn main() {
pub const DEVICE_NAME: &str = "implRust";
}

クロック設定

Bluetooth では、アドバタイズ、スキャン、接続の維持といった処理を行うために正確なタイミングが必要です。そのタイミングは低周波クロックに依存します。ここでは、クロックソースとして内部 RC 発振器を設定します。

#![allow(unused)]
fn main() {
const fn clock_config() -> Option<raw::nrf_clock_lf_cfg_t> {
    Some(raw::nrf_clock_lf_cfg_t {
        source: raw::NRF_CLOCK_LF_SRC_RC as u8,
        // rc_ctiv: 16,
        rc_ctiv: 4,
        rc_temp_ctiv: 2,
        // accuracy: raw::NRF_CLOCK_LF_ACCURACY_500_PPM as u8,
        accuracy: raw::NRF_CLOCK_LF_ACCURACY_20_PPM as u8,
    })
}
}

内部 RC クロックはそれほど高精度ではなく、時間の経過や温度変化に伴って少しずつ誤差が生じることがあります(時間経過によるドリフト)。そのため、定期的に再較正するように設定します。

  • rc_ctiv は、タイミングのドリフトを補正するためにチップが定期的な再較正をどの程度の頻度で実行するかを制御します。

  • rc_temp_ctiv は温度変化に基づく追加のトリガーを設定するもので、顕著な温度変動を検出した場合にチップが再較正を行います。

  • accuracy: 20 PPM は、このクロックにどの程度の精度を期待できるかを BLE スタックに伝えます。PPM の値が低いほどタイミング精度が高くなり、Bluetooth がより安定して動作しやすくなります。

GAP 接続設定

これは、デバイスが処理できる Bluetooth 接続数と、各接続に割り当てる時間を設定します。

#![allow(unused)]
fn main() {
const fn gap_conn_config() -> Option<raw::ble_gap_conn_cfg_t> {
    Some(raw::ble_gap_conn_cfg_t {
        conn_count: 2,
        event_length: 24,
    })
}
}
  • conn_count: 2 は、デバイスが最大 2 つの Bluetooth 接続を同時に維持できることを意味します。

  • event_length は、Bluetooth の各通信サイクルで各接続を処理するためにチップが確保する時間を設定します(単位は 1.25 ミリ秒)。値を 24 にすると 1 回の間隔あたり約 30 ミリ秒となり、接続を切らさずに安定してデータを送受信するのに十分な時間になります。

GAP デバイス名設定

この設定では、他のデバイス(たとえばスマートフォン)がスキャン時に見る Bluetooth デバイス名を設定します。SoftDevice はデフォルトでは汎用的な名前を使用しますが、ここでは独自の名前で上書きします。

#![allow(unused)]
fn main() {
fn gap_device_name() -> Option<raw::ble_gap_cfg_device_name_t> {
    Some(raw::ble_gap_cfg_device_name_t {
        p_value: DEVICE_NAME.as_ptr() as _,
        current_len: DEVICE_NAME.len() as u16,
        max_len: DEVICE_NAME.len() as u16,
        write_perm: unsafe { mem::zeroed() },
        _bitfield_1: raw::ble_gap_cfg_device_name_t::new_bitfield_1(
            raw::BLE_GATTS_VLOC_STACK as u8,
        ),
    })
}
}
  • p_value は、独自のデバイス名 ("implRust") が格納されているメモリを指します。
  • current_len と max_len は、名前の現在の長さと最大長を定義します。
  • write_perm は、接続しているクライアントが名前を変更できるかどうかを制御します。ここでは zeroed() を使って書き込みアクセスを無効にしています。
  • _bitfield_1 には、名前の保存場所を設定する vloc が含まれます。ここでは BLE_GATTS_VLOC_STACK を使用しており、これは名前がフラッシュメモリ(不揮発性メモリ)に保存され、実行時には書き換えできないことを意味します。

GAP ロール数

この設定では、デバイスが同時に処理できる Bluetooth の動作数の上限を定めます。

#![allow(unused)]
fn main() {
const fn gap_role_count() -> Option<raw::ble_gap_cfg_role_count_t> {
    Some(raw::ble_gap_cfg_role_count_t {
        adv_set_count: raw::BLE_GAP_ADV_SET_COUNT_DEFAULT as u8,
        periph_role_count: raw::BLE_GAP_ROLE_COUNT_PERIPH_DEFAULT as u8,
    })
}
}
  • adv_set_count は、使用するアドバタイズハンドルの数です。ここではデフォルト値のままにしています。

  • periph_role_count は、micro:bit がペリフェラルとして動作しているときに同時接続できるデバイス数を定義します。ここでもデフォルト設定(1 接続を許可)を使用します。

GATT 接続設定

これは、ATT(Attribute Protocol)パケットのサイズを設定します。簡単に言うと、BLE で一度に送受信できるデータ量を制御します。

#![allow(unused)]
fn main() {
const fn gatt_conn_config() -> Option<raw::ble_gatt_conn_cfg_t> {
    Some(raw::ble_gatt_conn_cfg_t { att_mtu: 256 })
}
}

att_mtu は Attribute Maximum Transmission Unit の略です。これは、デバイス間で一度に送受信できるデータ量を定義します。デフォルトの MTU は 23 バイトで、かなり小さい値です。ここではこれを 256 まで増やし、1 つの BLE パケットでより大きなデータ塊を送受信できるようにしています。これによりスループットが向上し、小さな断片を送る際のオーバーヘッドを減らせます

GATT 属性テーブルサイズ

この設定では、属性テーブルのサイズを設定します。属性テーブルは、サービス定義、キャラクタリスティック、ディスクリプタなど、他のデバイスが BLE 経由で読み書きできるデータを格納するために使われます。

#![allow(unused)]
fn main() {
const fn gatts_attr_tab_size() -> Option<raw::ble_gatts_cfg_attr_tab_size_t> {
    Some(raw::ble_gatts_cfg_attr_tab_size_t {
        attr_tab_size: raw::BLE_GATTS_ATTR_TAB_SIZE_DEFAULT,
    })
}
}

ここではデフォルトサイズを使っています。通常はほとんどの小規模な BLE 構成で十分です。ただし、多くのサービスを追加する場合は、これを増やす必要があるかもしれません。サイズは 4 の倍数にしてください。そうしないとエラーになります。

まとめ

この関数は、上で定義したすべての設定をまとめて、Softdevice::enable() に渡す最終的な設定を構築します

#![allow(unused)]
fn main() {
// SoftDevice の設定
pub fn softdevice_config() -> nrf_softdevice::Config {
    nrf_softdevice::Config {
        clock: clock_config(),
        conn_gap: gap_conn_config(),
        conn_gatt: gatt_conn_config(),
        gatts_attr_tab_size: gatts_attr_tab_size(),
        gap_role_count: gap_role_count(),
        gap_device_name: gap_device_name(),
        ..Default::default()
    }
}
}

config.rs の完全な内容

```rust
use {defmt_rtt as _, panic_probe as _};

use core::mem;

use nrf_softdevice::raw;

pub const DEVICE_NAME: &str = "implRust";

const fn clock_config() -> Option<raw::nrf_clock_lf_cfg_t> {
    Some(raw::nrf_clock_lf_cfg_t {
        source: raw::NRF_CLOCK_LF_SRC_RC as u8,
        // rc_ctiv: 16,
        rc_ctiv: 4,
        rc_temp_ctiv: 2,
        // accuracy: raw::NRF_CLOCK_LF_ACCURACY_500_PPM as u8,
        accuracy: raw::NRF_CLOCK_LF_ACCURACY_20_PPM as u8,
    })
}

const fn gap_conn_config() -> Option<raw::ble_gap_conn_cfg_t> {
    Some(raw::ble_gap_conn_cfg_t {
        conn_count: 2,
        event_length: 24,
    })
}

const fn gatt_conn_config() -> Option<raw::ble_gatt_conn_cfg_t> {
    Some(raw::ble_gatt_conn_cfg_t { att_mtu: 256 })
}

fn gap_device_name() -> Option<raw::ble_gap_cfg_device_name_t> {
    Some(raw::ble_gap_cfg_device_name_t {
        p_value: DEVICE_NAME.as_ptr() as _,
        current_len: DEVICE_NAME.len() as u16,
        max_len: DEVICE_NAME.len() as u16,
        write_perm: unsafe { mem::zeroed() },
        _bitfield_1: raw::ble_gap_cfg_device_name_t::new_bitfield_1(
            raw::BLE_GATTS_VLOC_STACK as u8,
        ),
    })
}

const fn gap_role_count() -> Option<raw::ble_gap_cfg_role_count_t> {
    Some(raw::ble_gap_cfg_role_count_t {
        adv_set_count: raw::BLE_GAP_ADV_SET_COUNT_DEFAULT as u8,
        periph_role_count: raw::BLE_GAP_ROLE_COUNT_PERIPH_DEFAULT as u8,
    })
}

const fn gatts_attr_tab_size() -> Option<raw::ble_gatts_cfg_attr_tab_size_t> {
    Some(raw::ble_gatts_cfg_attr_tab_size_t {
        attr_tab_size: raw::BLE_GATTS_ATTR_TAB_SIZE_DEFAULT,
    })
}

// SoftDevice の設定
pub fn softdevice_config() -> nrf_softdevice::Config {
    nrf_softdevice::Config {
        clock: clock_config(),
        conn_gap: gap_conn_config(),
        conn_gatt: gatt_conn_config(),
        gatts_attr_tab_size: gatts_attr_tab_size(),
        gap_role_count: gap_role_count(),
        gap_device_name: gap_device_name(),
        ..Default::default()
    }
}

参考

GATT サービス - "service.rs"

このプログラムは、バッテリーサービスと呼ばれる単一の GATT サービスを定義します。

バッテリーサービス

nrf_softdevice クレートは、GATT サービスとキャラクタリスティックを定義するための gatt_service マクロと characteristic 属性を提供しています。サービスとそのキャラクタリスティックの両方に対して UUID を指定できます。

この例では、標準の 16 ビット UUID 0x180F を使用しています。これは、バッテリーサービスにあらかじめ定義されている識別子です。バッテリーレベルキャラクタリスティックには、現在のバッテリーレベルを表す標準 UUID 0x2A19 を使用します。

カスタムの非標準サービスまたはキャラクタリスティックを実装する場合は、代わりにカスタムの 128 ビット UUID を生成して使用する必要があります。

#![allow(unused)]

fn main() {
#[nrf_softdevice::gatt_service(uuid = "180f")]
pub struct BatteryService {
    #[characteristic(uuid = "2a19", read, notify)]
    battery_level: i16,
}
}

また、read と notify キーワードを使って権限も指定します。read を指定すると、接続されたデバイスは必要なときにいつでもバッテリーレベルを取得できます。notify を指定すると、バッテリーレベルが変化したときにクライアントへ更新をプッシュできます。

GATT サーバー

GATT サーバーは、接続された GATT クライアント(たとえばスマートフォンやコンピューター)からアクセスできるサービスとキャラクタリスティックをホストする役割を担います。

BatteryService 型のフィールドを持つ Server struct を定義します。次に、この struct に #[gatt_server] 属性マクロを付けます。これにより必要なボイラープレートコードがすべて生成され、GATT サーバーを初期化するための Server::new() のような便利な関数が利用できるようになります。

#![allow(unused)]
fn main() {
#[nrf_softdevice::gatt_server]
pub struct Server {
    bas: BatteryService,
}
}

service.rs の完全なコード

Server struct に "notify_battery_value" というメソッドを追加しました。これは接続が確立されるとバックグラウンドで実行されます。これは実際のバッテリー状態ではありません。デモのために、バッテリーレベルを増減させてシミュレートしているだけです。

この関数内では、現在のバッテリーレベルを使って self.bas.battery_level_notify() を呼び出します。クライアントがバッテリーレベルキャラクタリスティックの通知を購読していれば、自動的に更新を受け取ります。そうでない場合でも値自体は更新されており、クライアントは必要に応じて読み取れます。

#![allow(unused)]

fn main() {
use defmt::{info, unwrap};
use embassy_time::Timer;
use nrf_softdevice::ble::Connection;

#[nrf_softdevice::gatt_service(uuid = "180f")]
pub struct BatteryService {
    #[characteristic(uuid = "2a19", read, notify)]
    battery_level: i16,
}

#[nrf_softdevice::gatt_server]
pub struct Server {
    bas: BatteryService,
}

impl Server {
    pub async fn notify_battery_value(&self, connection: &Connection) {
        let mut battery_level = 100;
        let mut charging = false;
        loop {
            if battery_level < 20 {
                charging = true;
            } else if battery_level >= 100 {
                charging = false;
            }

            if charging {
                battery_level += 5;
            } else {
                battery_level -= 5;
            }

            match self.bas.battery_level_notify(connection, &battery_level) {
                Ok(_) => info!("Battery Level: {=i16}", &battery_level),
                Err(_) => unwrap!(self.bas.battery_level_set(&battery_level)),
            };

            Timer::after_secs(2).await
        }
    }
}
}

メイン

ここからは main.rs ファイルを作業していきます。まず、main.rs ファイルを次の必要なインポートで変更します。

#![allow(unused)]
#![no_std]
#![no_main]
fn main() {
mod ble;
mod service;
use crate::ble::get_soft_device;
use crate::service::*;

use {defmt_rtt as _, panic_probe as _};

use defmt::{info, unwrap};
use embassy_executor::Spawner;
use embassy_nrf::interrupt;
use futures::future::{Either, select};
use futures::pin_mut;
use nrf_softdevice::Softdevice;
use nrf_softdevice::ble::{gatt_server, peripheral};
}

優先度

BLE を処理する SoftDevice とアプリケーションコードの間で安全にやり取りできるようにするには、アプリケーションは SoftDevice よりも低い割り込み優先度で動作する必要があります。

SoftDevice は BLE の動作のために高い割り込み優先度レベルを使用します。アプリケーションは、自身の割り込みにそれより高い、または同等の優先度レベルを使用してはいけません。

次の関数は、GPIOTE や TIMER のような重要なハードウェア割り込みに対して、nRF ドライバーが優先度レベル 2 を使用するよう設定します。

#![allow(unused)]
fn main() {
// アプリケーションは softdevice より低い優先度で動作する必要があります
fn nrf_config() -> embassy_nrf::config::Config {
    let mut config = embassy_nrf::config::Config::default();
    config.gpiote_interrupt_priority = interrupt::Priority::P2;
    config.time_interrupt_priority = interrupt::Priority::P2;
    config
}
}

main 関数内にある既存のコードをすべて削除し、この設定で HAL を初期化するために次の行を追加します。

async fn main(spawner: Spawner) {
    let _ = embassy_nrf::init(nrf_config());

    // この行の後に残りのコードを追加します
}

Softdevice を初期化する

まず、ble モジュールで定義した get_soft_device 関数を呼び出して Softdevice インスタンスを取得します。これを使って、service モジュールで定義した Server 構造体を初期化します。

次に、embassy タスクの助けを借りて、バックグラウンドで sd.run() を実行します。

#![allow(unused)]
fn main() {
let sd = get_soft_device();
let server = unwrap!(Server::new(sd));
unwrap!(spawner.spawn(softdevice_task(sd)));
}

このタスクは次のように定義します。

#![allow(unused)]
fn main() {
#[embassy_executor::task]
pub async fn softdevice_task(sd: &'static Softdevice) -> ! {
    sd.run().await
}
}

接続

ここからメインループに入ります。各ループでは、BLE 接続を受け付けるためにアドバタイズを行います。接続が確立されると、GATT サーバーとバッテリー通知タスクを並行して開始します。

ここでは複数のことが同時に行われているので、それぞれを順に説明します。

#![allow(unused)]
fn main() {
loop {
        let config = peripheral::Config::default();

        let adv = ble::adv::get_adv();

        let conn = unwrap!(peripheral::advertise_connectable(sd, adv, &config).await);
        info!("advertising done! I have a connection.");

        let battery_fut = server.notify_battery_value(&conn);
        let gatt_fut = gatt_server::run(&conn, &server, |e| match e {
            ServerEvent::Bas(e) => match e {
                BatteryServiceEvent::BatteryLevelCccdWrite { notifications } => {
                    info!("battery notifications: {}", notifications)
                }
            },
        });

        pin_mut!(battery_fut);
        pin_mut!(gatt_fut);

        match select(battery_fut, gatt_fut).await {
            Either::Left((_, _)) => {
                info!("Battery Notification encountered an error and stopped!")
            }
            Either::Right((e, _)) => {
                info!("gatt_server run exited with error: {:?}", e);
            }
        };
    }
}

アドバタイズ

まず、"peripheral::Config::default()" を使ってアドバタイズ用のデフォルト設定を初期化します。次に、先ほど定義した get_adv() を呼び出してアドバタイズメントペイロードを取得します。その後、アドバタイズデータと設定を指定して "advertise_connectable" を呼び出します。

#![allow(unused)]
fn main() {
let conn = unwrap!(peripheral::advertise_connectable(sd, adv, &config).await);
}

この行はアドバタイズパケットのブロードキャストを開始し、スマートフォンのようなセントラルデバイスが接続するまで待機します。接続が確立されると、接続されたクライアントとやり取りするために使用する Connection オブジェクトが返されます。

GATT 接続

接続を取得したら、"notify_battery_value""gatt_server::run" を呼び出します。どちらも async 関数です。ただし、見てわかるように、ここではまだ ".await" を使っていません。代わりに、future を変数に格納して、それらに対して pin_mut! を呼び出しています。

#![allow(unused)]
fn main() {
let battery_fut = server.notify_battery_value(&conn);
let gatt_fut = gatt_server::run(&conn, &server, |e| match e {
    ServerEvent::Bas(e) => match e {
        BatteryServiceEvent::BatteryLevelCccdWrite { notifications } => {
            info!("battery notifications: {}", notifications)
        }
    },
});
}

gatt_server::run 関数は GATT サーバーを実行し、こちらが渡したコールバックを使ってイベントを受け取ります。これらのイベントは ServerEvent という enum で表され、この enum は nrf_softdevice クレートの #[gatt_server] proc macro によって自動生成されます(service.rs ファイル内の Server 構造体にこのマクロを付与しました)。

その後、2 つの pin 済み future に対して select を使用します。これにより、どちらか一方が完了するまで待機し、もう一方は自動的にキャンセルされます。

  • バッテリー通知タスクは無限ループで動作し、更新を継続的に送信し、エラーが発生した場合にのみ終了します。

  • GATT サーバーも、エラーが発生するか、クライアントが切断するまで(たとえば DisconnectError の場合など)継続して動作します。

この構成により、どちらか一方が終了したときにクリーンに抜けて、ループを再開し、新しい接続を受け付けられるようになります。

main.rs の全内容

#![no_std]
#![no_main]
mod ble;
mod service;
use crate::ble::get_soft_device;
use crate::service::*;

use {defmt_rtt as _, panic_probe as _};

use defmt::{info, unwrap};
use embassy_executor::Spawner;
use embassy_nrf::interrupt;
use futures::future::{Either, select};
use futures::pin_mut;
use nrf_softdevice::Softdevice;
use nrf_softdevice::ble::{gatt_server, peripheral};

// アプリケーションは softdevice より低い優先度で実行しなければなりません
fn nrf_config() -> embassy_nrf::config::Config {
    let mut config = embassy_nrf::config::Config::default();
    config.gpiote_interrupt_priority = interrupt::Priority::P2;
    config.time_interrupt_priority = interrupt::Priority::P2;
    config
}

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    // まず peripherals access crate を取得します。
    let _ = embassy_nrf::init(nrf_config());

    let sd = get_soft_device();
    let server = unwrap!(Server::new(sd));
    unwrap!(spawner.spawn(softdevice_task(sd)));

    loop {
        let config = peripheral::Config::default();

        let adv = ble::adv::get_adv();

        let conn = unwrap!(peripheral::advertise_connectable(sd, adv, &config).await);
        info!("advertising done! I have a connection.");

        let battery_fut = server.notify_battery_value(&conn);
        let gatt_fut = gatt_server::run(&conn, &server, |e| match e {
            ServerEvent::Bas(e) => match e {
                BatteryServiceEvent::BatteryLevelCccdWrite { notifications } => {
                    info!("battery notifications: {}", notifications)
                }
            },
        });

        pin_mut!(battery_fut);
        pin_mut!(gatt_fut);

        match select(battery_fut, gatt_fut).await {
            Either::Left((_, _)) => {
                info!("Battery Notification encountered an error and stopped!")
            }
            Either::Right((e, _)) => {
                info!("gatt_server run exited with error: {:?}", e);
            }
        };
    }
}

#[embassy_executor::task]
pub async fn softdevice_task(sd: &'static Softdevice) -> ! {
    sd.run().await
}

nRF Connect を使って、Rust で動く microbit をスマートフォンに接続する

コーディング部分は完了しました。次はプログラムを実行し、nRF Connect mobile app を使って micro:bit に接続してみましょう。コンピューターを使いたい場合は、デスクトップ版のアプリも利用できます。

既存のプロジェクトをクローンする

問題が発生した場合は、私が作成したプロジェクトをクローンして(または参照して)、hello-ble フォルダーに移動してください。

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/hal-embassy/hello-ble

フラッシュ

プログラムを micro:bit にフラッシュできます。

cargo run

次のような出力が表示されるはずです。

softdevice RAM: 13328 bytes 

RAM 不足に関する panic や、必要以上の RAM を割り当てたという警告が表示された場合は、このガイド の手順に従って SoftDevice の RAM 設定を調整してください。

どうやって接続するの?

コードをフラッシュしたら、nRF Connect モバイルアプリを開きます。設定した Bluetooth 名(私の場合は "implRust")をスキャンして、それに接続してください。

nRF Connect

成功すると、システムコンソールに次のように出力されます。

advertising done! I have a connection.

アプリには、サポートされているサービスとキャラクタリスティックが表示されます。

値を読む

現在のバッテリーレベルを読むには、以下の画像で強調表示されているアイコンをタップしてください。

nRF Connect

通知を購読する

バッテリーレベルが変化したときに自動更新を受け取るには、キャラクタリスティックの下にある 3 本の下向き矢印のアイコン(画像のとおり)をタップしてください。これで、micro:bit からの通知をスマートフォンが購読します。

nRF Connect

次は?

ここで行ったのは、Nordic が提供しているデモ用モバイルアプリを使っただけです。実際のシナリオでは、自分自身のアプリを構築する必要があったり、既存のアプリに合う形でデータを送る方法を考える必要があったりするでしょう。

TrouBLE - Rustで書かれた組み込みデバイス向け Bluetooth Low Energy (BLE) ホスト実装

前の章では、micro:bit からスマートフォンへデータ(バッテリーレベル)を送信するために nrf-softdevice crate を使用しました。ここでは、Rust で書かれたクロスプラットフォームな BLE Host スタックである TrouBLE を紹介します。TrouBLE はまだ初期段階にあり(最初のバージョンは 2025 年 3 月に crates.io でリリースされました)

TrouBLE は、将来的な認証取得を目標とした、Rust で書かれた組み込みデバイス向け Bluetooth Low Energy (BLE) Host 実装です。

BLE Host

BLE スタックでは、システムは 2 つの部分に分かれています。

  • Controller : 低レベルの無線処理を担当します。
  • Host : GATT、L2CAP、ATT、SMP などの高レベルプロトコルを管理します。
Bluetooth LEプロトコルスタック

これら 2 つのコンポーネントは Host Controller Interface (HCI) を介して通信します。これは標準化されたプロトコルで、UART、USB、あるいはメモリ内共有バッファなど、さまざまなトランスポート上で動作します。

Controller-Agnostic

この分離により、同じ Host スタックを異なる Controller 間で動作させることができます。TrouBLE は、bt-hci crate の必要なトレイトを実装している任意の Controller で動作します(bt-hci は Bluetooth 通信用の embedded-hal のようなものです)。つまり、Rust で 1 つの BLE アプリケーションを書けば、次のようなプラットフォーム間で再利用できます。

  • Nordic nRF52(SoftDevice Controller 経由)
  • ESP32
  • Raspberry Pi Pico W
  • Apache NimBLE
  • UART HCI

今後さらに多くのプラットフォームがサポートされる可能性があります。サポートされている Controller の最新一覧は、TrouBLE GitHub リポジトリを参照してください。

microbit向け(より正確には、nRF52 向け) - Softdevice vs Softdevice Controller

前の章では、nrf-softdevice crate を使用しました。この方法では、デバイスに SoftDevice ファームウェアを書き込み、メモリレイアウト(memory.x 経由)を設定する必要があります。nrf-softdevice crate は、Nordic のオリジナルの SoftDevice に対する Rust ラッパーとして機能します。Softdevice は、完全なクローズドソースのプリコンパイル済み Bluetooth スタックです。SoftDevice には Controller(Link Layer)層と Host(GAP、GATT、L2CAP など)層の両方が含まれており、完全な BLE 実装を提供します。

対照的に、SoftDevice Controller は nRF52 および nRF53 シリーズ向けに設計された、より新しいソリューションです。オリジナルの SoftDevice とは異なり、これは Controller 部分のみです。これはそれ自体が Rust ライブラリではなく、nrf-sdc crate がそれに対する Rust バインディングを提供します。この crate は bt-hci トレイトを実装します。ただし、Controller 側しか扱わないため、BLE 機能を完成させるには依然として Host スタックが必要です。そして、そこで Trouble(しゃれです)が登場します。TrouBLE は、SoftDevice Controller と組み合わせて使える Host スタックです。

  • 古いアプローチ: nrf-softdevice crate とともに完全な SoftDevice(controller + host)を使用します。完全に認証済みです。
  • 新しいアプローチ: nrf-sdc crate と Rust ベースの Trouble host stack とともに SoftDevice Controller を使用します。より柔軟ですが、商用利用には認証が必要になります。

外部コンポーネント

ここまでは、micro:bit v2 の組み込み機能を使ってきました。これからは、センサー、ディスプレイ、入力デバイスなどの外部コンポーネントを使用していきます。内容に沿って進めるには、モジュール、ジャンパーワイヤー、ブレッドボードなどの追加部品を購入する必要があるかもしれません。

ワニ口クリップワイヤー

ワニ口クリップワイヤー(crocodile clips とも呼ばれます)は、外部コンポーネントを micro:bit のエッジコネクタ、特に P0、P1、P2 と表示された大きなピンに接続するためのシンプルな方法です。

microbit のワニ口クリップワイヤー

これらのクリップには、いくつかの種類があります:

  • 両端タイプ: 両側にワニ口クリップが付いています。

  • ハイブリッドタイプ: 片側がワニ口クリップで、もう片側がブレッドボードやモジュールに差し込むためのオスまたはメスのヘッダーになっています。

microbit のワニ口クリップ ハイブリッドワイヤー

ブレッドボード

ほとんどの演習はブレッドボードなしでも進められます。ただし、場合によってはあると便利です。ブレッドボードを使うと、はんだ付けをせずに回路を組むことができます。複数のコンポーネントを接続したり、配線を整理したり、さまざまな構成を試したりするのが簡単になります。

ブレッドボード

拡張ボード

microbit のエッジコネクタには、多くの GPIO ピン、電源ライン、通信インターフェース(I2C、SPI、UART など)が引き出されています。P0、P1、P2 のように大きなパッドとして利用できるピンもありますが、それ以外は小さく、間隔も狭いため、直接接続するのは困難です。

プロジェクトによっては、拡張ボード(ブレークアウトボード)が必要になることがあります。これはエッジコネクタに差し込んで使用し、標準的なヘッダーを通してすべてのピンにアクセスできるようにするものです。これにより、ジャンパーワイヤーやブレッドボードを使ったコンポーネントの接続がずっと簡単になります。

拡張ボードの一覧は、microbit 公式サイトのこちらで確認できます。 また、お住まいの地域の EC サイトや店舗で検索して、より安価な代替品を探すこともできます。さらに、microbit の "v2" 向けであることも確認してください。

必要になるまでは購入しないでください。演習ではこれを使用しません。

LDR (光依存抵抗)

ここから本書の次の段階に入り、外部部品を使い始めます。このセクションでは、フォトセルやフォトレジスタとも呼ばれる LDR (Light Dependent Resistor) から始め、これを Micro:bit に接続します。

LDR は、当たる光の量に応じて抵抗値が変化します。光が明るいほど抵抗は低くなり、光が暗いほど抵抗は高くなります。そのため、光の検知、自動照明、周囲の明るさレベルの監視といった用途に最適です。

pico2

必要な部品

  • LDR (光依存抵抗)
  • 抵抗器(通常は 10kΩ): 分圧回路を作るために必要です
  • ワニ口クリップ: ハイブリッドクリップまたは両端ワニ口クリップのどちらでも使用できます。ほとんどの場合、両端ワニ口クリップで十分です。

前提知識

これを扱うには、分圧回路とは何か、そしてそれがどのように動作するかを理解しておく必要があります。また、ADC とは何か、そしてそれがどのように機能するかも理解しておく必要があります。このセクションでは、これらの概念を紹介します。

分圧回路

分圧回路は、2つの直列抵抗を使って入力電圧 (\( V_{in} \)) をより低い出力電圧 (\( V_{out} \)) に下げる、シンプルな回路です。入力電圧に接続される抵抗を \( R_{1} \)、もう一方の抵抗を \( R_{2} \) と呼びます。出力電圧は \( R_{1} \) と \( R_{2} \) の接続点から取り出され、\( V_{in} \) の一部に相当する電圧が得られます。

回路

分圧回路

出力電圧 (Vout) は、次の式で計算できます:

\[ V_{out} = V_{in} \times \frac{R_2}{R_1 + R_2} \]

\( V_{out} \) の計算例

与えられた値:

  • \( V_{in} = 3.3V \)
  • \( R_1 = 10 k\Omega \)
  • \( R_2 = 10 k\Omega \)

値を代入します:

\[ V_{out} = 3.3V \times \frac{10 k\Omega}{10 k\Omega + 10 k\Omega} = 3.3V \times \frac{10}{20} = 3.3V \times 0.5 = 1.65V \]

出力電圧 \( V_{out} \) は 1.65V です。

fn main() {
    // コードを編集できます
    // 値を変更してコードを実行できます
    let vin: f64 = 3.3;
    let r1: f64 = 10000.0;
    let r2: f64 = 10000.0;

    let vout = vin * (r2 / (r1 + r2));

    println!("出力電圧 Vout は: {:.2} V", vout);
}

使用例

分圧回路は、ノブを回すと抵抗値が変化し、それに応じて出力電圧を調整できるポテンショメータのような用途で使用されます。また、光センサーやサーミスタのような抵抗性センサーの測定にも使われます。この場合、既知の電圧を印加し、マイクロコントローラーが中点の電圧を読み取ることで、温度などのセンサー値を求めます。

分圧回路シミュレーション







式: Vout = Vin × (R2 / (R1 + R2))

値を代入した式: Vout = 3.3 × (10000 / (10000 + 10000))

出力電圧 (Vout): 1.65 V

Falstad Web サイトのシミュレーター

図を作成するために、Web サイト https://www.falstad.com/circuit/ を使用しました。回路図を描くための優れたツールです。作成したファイル voltage-divider.circuitjs.txt をダウンロードしてインポートすると、回路を試すことができます。

LDR はどのように動作するのか?

LDR が何であるかについては、すでに紹介しました。もう一度繰り返します。LDR は、当たる光の量に応じて抵抗値が変化します。光が明るいほど抵抗値は低くなり、光が暗いほど抵抗値は高くなります。

ドラキュラ: LDR をドラキュラだと考えてみてください。太陽光の下では、彼は弱くなります(抵抗値が低くなるのと同じです)。しかし、暗闇では強くなります(抵抗値が高くなるのと同じです)。

LDR の製造にどのような半導体材料が使われているのか、またなぜこのような振る舞いをするのかについては、ここでは詳しく扱いません。興味があれば、この記事を読み、さらに調べてみることをおすすめします。

最大の明るさでの出力例

LDR は最大の明るさにさらされると抵抗値が低くなり、出力電圧(\( V_{out} \))は大幅に低くなります。

voltage-divider-ldr1

弱い光での出力例

光が少なくなると、LDR の抵抗値は増加し、出力電圧も上昇します。

voltage-divider-ldr2

完全な暗闇での出力例

暗闇では、LDR の抵抗値は高くなり、その結果、出力電圧 (\( V_{out} \)) も高くなります。

voltage-divider-ldr3

分圧回路における LDR のシミュレーション

明るさの値を調整して、R2(LDR)の抵抗値がどのように変化するかを確認できます。また、明るさを上げたり下げたりしたときに、\( V_{out} \) 電圧がどのように変化するかも確認できます。





50%

式: \( V_\text{out} = V_\text{in} \times \frac{R_2}{R_1 + R_2} \)

値を代入した式: Vout = 3.3 × 999 / (10000 + 999)

出力電圧 (Vout): 0.25 V

Circuitjs

上記の図は、Falstad の Web サイトを使って私が作成したものです。私が作成した回路ファイル voltage-divider-ldr.circuitjs.txtFalstad サイト にインポートして、いろいろ試してみてください。

ADC(アナログ-デジタル変換器)

アナログ-デジタル変換器(ADC)は、アナログ信号(音、光、温度のような連続信号)をデジタル信号(離散的な値で、通常は 1 と 0 で表される)に変換するために使用されるデバイスです。この変換は、マイクロコントローラー(例: nrF52833、Raspberry Pi Pico)のようなデジタルシステムが現実世界とやり取りするために必要です。たとえば、温度や音を測定するセンサーはアナログ信号を生成するため、デジタルデバイスで処理するにはデジタル形式に変換する必要があります。

ADC

ADC の分解能

ADC の分解能とは、ADC がアナログ信号をどれだけ正確に測定できるかを指します。分解能はビット数で表され、分解能が高いほど測定はより正確になります。

  • 8 ビット ADC は、0 から 255 までのデジタル値を生成します。
  • 10 ビット ADC は、0 から 1023 までのデジタル値を生成します。
  • 12 ビット ADC は、0 から 4095 までのデジタル値を生成します。

ADC の分解能は、次の式で表せます。 \[ \text{Resolution} = \frac{\text{Vref}}{2^{\text{bits}} - 1} \]

Microbit v2 (nRF52833)

nRF52833 は、12 ビット 8 チャネルのアナログ-デジタル変換器(ADC)を備えています。したがって、0 から 4095 までの値を返します(4096 通りの値)

\[ \text{Resolution} = \frac{3.3V}{2^{12} - 1} = \frac{3.3V}{4095} \approx 0.000805 \text{V} \approx 0.8 \text{mV} \]

分圧回路における ADC 値と LDR の抵抗

LDR と固定抵抗で構成された分圧回路では、出力電圧 \( V_{\text{out}} \) は次のようになります。

\[ V_{\text{out}} = V_{\text{in}} \times \frac{R_{\text{LDR}}}{R_{\text{LDR}} + R_{\text{fixed}}} \]

これは前の章で説明したものと同じ式で、\({R_2}\) を \({R_{\text{LDR}}}\) に、\({R_1}\) を \({R_{\text{fixed}}}\) に置き換えただけです

  • 明るい光(LDR の抵抗が低い): \( V_{\text{out}} \) は低下し、その結果 ADC 値も低くなります。
  • 薄暗い光(LDR の抵抗が高い): \( V_{\text{out}} \) は上昇し、その結果 ADC 値も高くなります。

ADC 値の計算例

明るい光:

LDR の抵抗値が明るい光の下で \(1k\Omega\) だとします(固定抵抗は \(10k\Omega\) です)。

\[ V_{\text{out}} = 3.3V \times \frac{1k\Omega}{1k\Omega + 10k\Omega} \approx 0.3V \]

ADC 値は次のように計算されます。 \[ \text{ADC value} = \left( \frac{V_{\text{out}}}{V_{\text{ref}}} \right) \times (2^{12} - 1) \approx \left( \frac{0.3}{3.3} \right) \times 4095 \approx 372 \]

暗闇:

LDR の抵抗値が非常に弱い光の下で \(140k\Omega \) だとします。

\[ V_{\text{out}} = 3.3V \times \frac{140k\Omega}{140k\Omega + 10k\Omega} \approx 3.08V \]

ADC 値は次のように計算されます。 \[ \text{ADC value} = \left( \frac{V_{\text{out}}}{V_{\text{ref}}} \right) \times (2^{12} - 1) \approx \left( \frac{3.08}{3.3} \right) \times 4095 = 3822 \]

ADC 値を電圧に戻す

ここで、ADC 値を入力電圧に戻したい場合は、ADC 値に分解能(0.8mV)を掛けます。

たとえば、ADC 値が 3822 の場合を考えてみましょう。

\[ \text{Voltage} = 3822 \times 0.8mV = 3057.6mV \approx 3.06V \]

シングルエンドモードと差動モード

シングルエンドモード

シングルエンドモードでは、ADC は 1 つの信号ピンの電圧をグラウンド基準で測定します。これは、ある点が床からどれくらい高いかを測るようなものだと考えられます。この方法はシンプルで使いやすく、必要な入力ピンも 1 本だけです。ただし、特に長い配線を使う場合には電気的ノイズの影響を受けやすく、負の電圧は測定できません。

このモードは、LDR を使って光のレベルを読み取る場合、赤外線または超音波センサーで距離を測定する場合、アナログセンサーモジュールを使って空気やガスの品質を監視する場合などで一般的に使用されます。先ほど説明した計算は、いずれもシングルエンド ADC 測定に基づいています。

差動モード

差動モードでは、ADC は 2 つの入力ピン間の差を測定し、グラウンドを完全に無視します。これは、2 人がどこに立っているかに関係なく、その身長差を比べるようなものです。

このモードは、両方の入力に共通して現れるノイズを打ち消すのに役立ち、グラウンドより上にも下にも振れる信号を含む、非常に小さな電圧変化を検出できます。追加のピンが必要で、セットアップもやや複雑になりますが、熱電対を使った温度測定や、ひずみゲージを使った圧力や力の検出など、高精度が求められる用途で有用です。

参考

nRF52833 の Analog to Digital Converter (ADC)

nRF52833 は 12 ビットの逐次比較レジスタ(SAR)ADC を搭載しており、Nordic では SAADC と呼ばれています。また、アナログ測定用に最大 8 つの入力チャネルをサポートしています。

以下は、アナログ入力チャネル(AIN0 ~ AIN7)と、nRF52833 上で対応する GPIO ピンの対応表です。

アナログ入力GPIO ピン
AIN0P0.02
AIN1P0.03
AIN2P0.04
AIN3P0.05
AIN4P0.28
AIN5P0.29
AIN6P0.30
AIN7P0.31

Microbit ピン配置

Microbit エッジコネクタ ADC

この表は、nRF52833 のどのアナログ入力チャネルに micro:bit v2 のエッジコネクタからアクセスできるかを示しています。大きなピン(Ring 0、Ring 1、Ring2)は、競合なく安全かつ直接アナログ入力にアクセスできます。小さなピン(3、4、10)も ADC 対応ですが、LED マトリクスと共有されているため、ディスプレイが無効な場合にのみアナログ入力に使用すべきです。

microbit ピンGPIO (nRF52833)アナログ入力アクセス方法共有先注記
0P0.02AIN0大きなリング完全に利用可能
1P0.03AIN1大きなリング完全に利用可能
2P0.04AIN2大きなリング完全に利用可能
3P0.31AIN7小さなピン 3LED 列 3ディスプレイオフ時のみ使用可能
4P0.28AIN4小さなピン 4LED 列 1ディスプレイオフ時のみ使用可能
10P0.30AIN6小さなピン 10LED 列 5ディスプレイオフ時のみ使用可能

参考資料

LDR と Microbit を使用した周囲光に基づく LED の自動制御

この演習では、周囲の明るさに応じて LED を制御します。目標は、暗い環境で LED を自動的に点灯させることです。

部屋の照明をオン・オフして、閉め切った部屋でこれを試すことができます。部屋の照明を消すと、部屋が十分に暗ければ LED が点灯し、照明を再び点けると LED は消灯するはずです。あるいは、感度のしきい値を調整したり、光センサー(LDR)を手や何か物で覆ったりして、異なる明るさレベルを再現することもできます。

注: 部屋の明るさの条件や使用している LDR に応じて、ADC しきい値を調整する必要がある場合があります。

LDR を Microbit に接続する回路

  1. LDR の片側をグラウンドに接続します
  2. LDR のもう片側を Micro:bit の Pin 0(Analog In)に接続します。
  3. 10K オームの抵抗を Pin 0 と 3V の間に接続します。
  4. これにより分圧回路が構成され、Pin 0 の電圧が明るさに応じて変化します。
フォトレジスタを Microbit に接続した回路

回路例

これは、LDR、ワニ口クリップ、オス-オスのジャンパーワイヤー、およびブレッドボードを使って私が組み立てた回路の写真です。

LDR と Microbit の回路

暗いとき LDR と Microbit の回路

Microbit と Rust で光に反応する LED 回路を作る

それでは始めましょう。前のいくつかのセクションで理論は十分に扱いました。ここからはコードを書いていきます。ADC 値の閾値を定義します。それを上回った場合(部屋が暗くなるとそうなるはずです)、LED マトリクスに ⚡ の絵文字を表示します。

テンプレートからプロジェクトを作成する

このプロジェクトでは、microbit-bsp(Embassy を使用)を使います。テンプレートを使って新しいプロジェクトを生成するには、次のコマンドを実行します。

cargo generate --git https://github.com/ImplFerris/mb2-template.git --rev 3d07b56
  • プロジェクト名の入力を求められたら、"led-dracula" のような名前を入力します。

  • async を使うかどうか尋ねられたら、"true" を選択します。

  • "BSP" と "HAL" のどちらを使うか選ぶよう求められたら、"BSP" を選択します。

ディスプレイを初期化する

まず、micro:bit のディスプレイを初期化します。次に、5x5 の ⚡ 絵文字パターンを定義し、ディスプレイの明るさを最大に設定します。

#![allow(unused)]
fn main() {
let board = Microbit::default();
let mut display = board.display;

#[rustfmt::skip]
const FLASH: [u8; 5] = [
    0b00010,
    0b00100,
    0b01110,
    0b00100,
    0b01000,
];
display.set_brightness(Brightness::MAX);
}

ADC を設定する

再び bind_interrupts! マクロを使って、SAADC 割り込みを対応するハンドラーに接続します。

#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
    SAADC => saadc::InterruptHandler;
});
}

それでは ADC を設定しましょう。シングルエンドモードで構成します。これは、1 本のピンの電圧をグラウンド基準で測定するという意味です。この場合は micro:bit の P0 ピンを使っており、これは nRF52833 チップ上の GPIO ピン P0.02 に対応しています。

#![allow(unused)]
fn main() {
let channel_config = ChannelConfig::single_ended(board.p0);
let config = saadc::Config::default();
let mut adc = Saadc::new(board.saadc, Irqs, config, [channel_config]);
}

サンプルを読み取る

次に ADC 値を読み取ります。これをループ内で行い、結果を閾値と比較します。sample 関数は、設定したチャネル数と同じ要素数を持つ可変バッファを必要とします。今回は 1 つのチャネルしか使わないため、要素数 1 の配列を渡します。ADC は変換を実行し、その結果を配列の最初の(そして唯一の)要素に格納します。

#![allow(unused)]
fn main() {
let mut buf = [0; 1];
adc.sample(&mut buf).await;
}

絵文字を表示する

ADC 値が取得できれば、LED ディスプレイの制御は簡単です。値がある閾値(この場合は 3500)より大きければ、絵文字を表示します。そうでなければ、ディスプレイをクリアします。セットアップに応じて感度を高くしたり低くしたりできるよう、閾値は調整できます。

#![allow(unused)]
fn main() {
const THRESHOLD: i16 = 3500;
}
#![allow(unused)]
fn main() {
if buf[0] > THRESHOLD {
    display.apply(display::fonts::frame_5x5(&FLASH));
    display.render();
} else {
    display.clear();
}
```rust

}

最終コード

#![no_std]
#![no_main]

use embassy_executor::Spawner;
use embassy_nrf::{
    bind_interrupts,
    saadc::{self, ChannelConfig, Saadc},
};
use microbit_bsp::{
    Microbit,
    display::{self, Brightness},
};

use {defmt_rtt as _, panic_probe as _};

bind_interrupts!(struct Irqs {
    SAADC => saadc::InterruptHandler;
});

const THRESHOLD: i16 = 3500;

#[embassy_executor::main]
async fn main(_spawner: Spawner) -> ! {
    let board = Microbit::default();
    let mut display = board.display;

    let config = saadc::Config::default();
    let channel_config = ChannelConfig::single_ended(board.p0);
    let mut adc = Saadc::new(board.saadc, Irqs, config, [channel_config]);

    #[rustfmt::skip]
    const FLASH: [u8; 5] = [
        0b00010,
        0b00100,
        0b01110,
        0b00100,
        0b01000,
    ];

    display.set_brightness(Brightness::MAX);
    loop {
        let mut buf = [0; 1];
        adc.sample(&mut buf).await;
        if buf[0] > THRESHOLD {
            display.apply(display::fonts::frame_5x5(&FLASH));
            display.render();
        } else {
            display.clear();
        }
    }
}

既存のプロジェクトをクローンする

私が作成したプロジェクトをクローン(または参照)して、ldr-dracula フォルダに移動することもできます。

git clone https://github.com/ImplFerris/microbit-projects
cd microbit-projects/bsp-embassy/ldr-dracula

書き込み

あとはコードを micro:bit に書き込み、実際に動かしてみるだけです。

プロジェクトフォルダから次のコマンドを実行します。

#![allow(unused)]
fn main() {
cargo run
}

次に、部屋の明かりを消したり、LDR をより暗い場所に移動したり、あるいは単に手で覆ってみてください。十分に暗くなると、LED マトリクスに ⚡ の絵文字が表示されるはずです。期待どおりに反応しない場合は、環境により合うように閾値を調整できます。