Claude Media
Claude Codeでsvd2rustを使う — SVDからPACを作りレジスタ定義を検証する

Claude Codeでsvd2rustを使う — SVDからPACを作りレジスタ定義を検証する

svd2rustでSVDファイルからRustのレジスタアクセスcrate(PAC)を生成し、オフセットとビットフィールドをSVDと突き合わせる検証をClaude Codeに任せる手順を、hooksとCLAUDE.mdの設定例で示します。

svd2rustは、マイコンのSVDファイル(レジスタ定義のXML)から、型安全にレジスタを読み書きできるRustのcrateを生成するコマンドラインツールです。生成物はPAC(Peripheral Access Crate)と呼ばれます。Claude Codeに任せるなら、生成そのものより、生成物がSVDと食い違っていないかを機械的に確かめる反復を先に組むのが要点です。

この記事では、生成コマンドの流れ、生成コードの読み方、オフセットとビットフィールドをSVDと突き合わせるスクリプト、そしてそれをhooksとCLAUDE.mdに固定する設定を扱います。

svd2rustは何を生成するのか

SVDは、マイコンが持つペリフェラルの一覧、各レジスタのメモリ上の位置、各レジスタの役割を記述したXMLです。svd2rustはこのファイルを読み、ペリフェラルにアクセスする型安全なAPIを持つcrateを出力します。

対応するターゲットは、--targetフラグで選びます。

値対象
cortex-m対象Cortex-M(フラグ省略時の既定)
msp430対象MSP430
riscv対象RISC-V
xtensa-lx対象Xtensa LX6
none対象上記以外(Cortex-Aなど、svd2rustが未対応のアーキテクチャ向けに使える)

Cortex-Mではlib.rsのほかに、リンカが参照するdevice.xとbuild.rsも出力されます。MSP430も同じ3ファイルです。riscvとnoneではlib.rsだけが出力されます。

SVDからPACを生成する

インストールはcargo install svd2rustです。Cortex-Mの場合の流れは次のとおりです。lib.rsは複数のモジュールが1ファイルにインラインで入った未整形のコードなので、formで分割してからcargo fmtをかけます。

cargo install svd2rust form
svd2rust -i STM32F30x.svd
rm -rf src
form -i lib.rs -o src/ && rm lib.rs
cargo fmt

生成したcrateのCargo.tomlには、rtという任意のfeatureを用意し、次の依存を置く決まりです。Cortex-Mの例を示します。

[dependencies]
critical-section = { version = "1.0", optional = true }
cortex-m = "0.7.6"
cortex-m-rt = { version = "0.6.13", optional = true }
vcell = "0.1.2"
 
[features]
rt = ["cortex-m-rt/device"]

RISC-Vなどターゲットごとに必要な依存とバージョンが違います。--targetを変えるときは、依存も一次ソースの該当節で見直します。

生成コードの読み方 — 検証の対象はここにある

検証の観点を決めるため、生成コードの構造を押さえておきます。

ペリフェラルは、Peripherals::take()で一度だけ取得できるシングルトンです。2回目以降の呼び出しはNoneを返します。take()はcritical-sectionのfeatureを有効にしたcrateで使え、実装はcortex-mなどのcrateが提供します。

各ペリフェラルはRegisterBlock構造体にderefし、構造体の各フィールドが1つのレジスタに当たります。アクセサの///コメントには、オフセットと説明が入ります。

/// 0x00 - Control register 1
pub const fn cr1(&self) -> &CR1 { &self.cr1 }
/// 0x04 - Control register 2
pub const fn cr2(&self) -> &CR2 { &self.cr2 }

レジスタにはread / write / modifyのうち、アクセス属性に合う方法だけが生えます。

  • 読み取り専用のレジスタはreadだけ
  • 書き込み専用のレジスタはwriteだけ
  • 読み書き可能なレジスタは3つすべて

writeのクロージャは、リセット値から始めて指定のビットフィールドだけ変えた値を書きます。|w| wを渡すとリセット値がそのまま書かれます。modifyは1回の読み出しと1回の書き込みで、他のビットを保ちます。

readは単一の揮発性ロード命令で値を読み、読み取り可能なビットだけを持つR構造体を返します。予約ビットや書き込み専用ビットには触れません。ResetValueトレイトのreset_valueは、リセット後のレジスタ値を返します。

ビットフィールドのアクセサにも、位置がBit 0 - …やBits 1:7 - …の形でコメントされます。この「オフセット」と「ビット位置」が、SVDのaddressOffsetとbitOffset / bitWidthから作られる値です。Claudeに確かめさせたいのは、まさにこの2種類の数字です。

ビットフィールドを間違えないための3つの観点

生成コードはSVDの記述をそのまま信じて作られます。SVDだけでは判断できない部分はunsafeとして残るため、確認は観点を分けると進めやすくなります。

1つ目はunsafeの境界です。 bits()で数値を書く操作は、SVDだけでは予約済みのビットパターンを書いてしまうか判断できないためunsafeになります。bitsに渡した値はフィールド幅でマスクされます。2ビットのフィールドに6を渡すと、6 & 3の2が書かれます。

2つ目はenumeratedValuesの有無です。SVDに<enumeratedValues>があると、variant()やoutput()のような名前付きの書き込みが生成され、予約パターンを書けない場合はbitsも安全になります。SVDに自分で<enumeratedValues>を足して再生成しても、既存の呼び出しコードは壊れません。

3つ目は命名規則です。svd2rust 0.32で識別子の既定の書式が変わりました。フィールド読み出し型の接尾辞は_RからRになるなど、旧版と生成名が異なります。旧版の命名に戻すには--ident-formats-theme legacyを渡します。バージョンの違うsvd2rustで再生成すると、名前の差で大量のコンパイルエラーが出るので、CLAUDE.mdにバージョンを固定して書きます。

オフセットとビット位置をSVDと突き合わせる

実機がなくても、SVDと生成コードを文字列で突き合わせれば、オフセットの取り違えは見つかります。次のスクリプトは、SVDの各レジスタについて「0x04 - Control register 2」の形のコメントが生成コードにあるかを数えます。

# tools/check_pac.py — SVDと生成コードのオフセットを突き合わせる
import re, sys
import xml.etree.ElementTree as ET
 
svd, src = sys.argv[1], sys.argv[2]
code = re.sub(r"\s+", " ", open(src).read())
missing = []
for p in ET.parse(svd).getroot().iter("peripheral"):
    for r in p.iter("register"):
        desc = re.sub(r"\s+", " ", (r.findtext("description") or "").strip())
        off = int(r.findtext("addressOffset"), 0)
        if f"{off:#04x} - {desc}" not in code:
            missing.append((p.findtext("name"), r.findtext("name"), hex(off)))
for m in missing:
    print("MISSING", *m)
sys.exit(1 if missing else 0)

生成物の書式は、#[doc = "0x0c - …"]の形と///の形が混在することがあるため、src/全体を1つに連結したファイルを渡すか、スクリプトを読み込むファイルごとに呼びます。derivedFromや配列、クラスタを使うSVDでは、説明文が書き換わってMISSINGが出る場合があります。出た行はSVD側の定義と照らして、本当の食い違いか書式差かを判断します。

ビット位置も同じ方法で確かめられます。フィールドごとにBit {lsb}(幅1)またはBits {lsb}:{msb}の形で検索するだけで、位置の取り違えを拾えます。

Claude Codeに反復させる設定

検証スクリプトは、Claudeが呼び忘れない場所に固定します。

CLAUDE.mdに再生成の手順を書く

## PAC(svd2rust)
- `src/` は svd2rust の生成物。直接編集しない。SVDを直して再生成する
- 再生成: `svd2rust -i device.svd && rm -rf src && form -i lib.rs -o src/ && rm lib.rs && cargo fmt`
- svd2rust のバージョンは `<固定したバージョン>` に揃える(識別子の書式が0.32で変わっているため)
- 再生成後は `python tools/check_pac.py device.svd src_all.rs` と
  `cargo check --target thumbv7em-none-eabihf` を実行して、結果を報告する

<固定したバージョン>と--targetのトリプルは、手元のマイコンとsvd2rustのバージョンに合わせて書き換えます。

生成物の直接編集をhookで止める

src/はSVDから再生成できる成果物なので、手で直すと次の再生成で消えます。PreToolUseのhookで、Edit / Writeの対象パスがsrc/のとき、終了コード2で止められます。

#!/bin/bash
# .claude/hooks/protect-pac.sh
FILE_PATH=$(cat | jq -r '.tool_input.file_path // empty')
if [[ "$FILE_PATH" == */src/* ]]; then
  echo "Blocked: src/ は生成物です。SVDを直して再生成してください" >&2
  exit 2
fi
exit 0
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-pac.sh" }
        ]
      }
    ]
  }
}

終了コード2のときにstderrの内容がClaudeへのフィードバックとして渡され、Claudeは別の手順を選び直します。スクリプトはchmod +xで実行可能にします。

Claudeに頼むときの指示

SVDが誤っている可能性を伝えておくと、Claudeが生成物だけを疑ってsrc/に手を入れる動きを防げます。

device.svd から PAC を再生成して、tools/check_pac.py の結果を見せて。
MISSING が出たら、SVD側の addressOffset と description を示して、
SVDの誤りなのか、生成コードの書式差なのかを切り分けて。
src/ は直接編集せず、直すならSVDを直して再生成して。

MISSINGが出たとき、Claudeは該当のレジスタの定義をSVDから引き出し、src/の該当箇所と並べて説明します。判断が付かないときは、データシートの該当ページとの照合が必要です。データシートの比較そのものはClaude APIでデータシートを比較する記事で扱っています。

生成時のオプションで検証しやすくする

検証の手がかりを増やすオプションが3つあります。いずれも再生成時に付けるフラグです。

--impl-debugでレジスタ全体をダンプする

--impl-debugを付けると、全レジスタとレジスタブロックにcore::fmt::Debugの実装が生成されます。フィールドが定義されたレジスタは各フィールドの値が、フィールドのないレジスタは値そのものが出力されます。読み出しで副作用が起きるレジスタ(read action付き)は読まれず、(not read/has read action!)と表示されます。書き込み専用は(write only register)です。

// 単一レジスタ
println!("RTC_CNT {:#?}", unsafe { &*esp32s3::RTC_CNTL::ptr() }.options0);
// ペリフェラル全体
println!("RTC_CNT {:#?}", unsafe { &*esp32s3::RTC_CNTL::ptr() });

出力をSVDのresetValueと並べると、リセット直後の値がSVDどおりかを実機で確認するとき、Claudeに差分を読ませられます。--impl-debug-featureにfeature名を渡すと、Debug実装をそのfeatureの裏に隠せるので、通常ビルドのサイズを増やさずに済みます。

--atomicsで割り込みとの競合を避ける

--atomicsを付けると、set_bits / clear_bits / toggle_bitsが使えるようになります。read-modify-writeを介さずにビットを操作でき、同じレジスタの別々のビットを別のコンテキストから同時に呼んでもデータ競合になりません。RISC-Vではatomic拡張のないチップでは動きません。portable-atomicを依存に足す必要もあります。

--edition=2024で新しい版にそろえる

生成コードの既定のRustエディションは2021です。2024向けの機能を使うなら--edition=2024を渡します。生成コードは安定版Rust 1.85.0以降でコンパイルできると、READMEに書かれています。それより新しい安定版で失敗したときは、issueの報告先になります。

割り込みを検証に含める

SVDには割り込みも記述され、生成crateのルートにInterrupt列挙型として出力されます。cortex-mのNVIC APIと組み合わせて使います。

use cortex_m::peripheral::Peripherals;
use stm32f30x::Interrupt;
 
let p = Peripherals::take().unwrap();
let mut nvic = p.NVIC;
nvic.enable(Interrupt::TIM2);

rtfeatureを有効にすると、ベクタテーブルの割り込み部分が生成crateによって埋められ、ハンドラ登録用のinterrupt属性(Cortex-MとMSP430)かマクロ(その他)が使えます。Claudeに割り込みハンドラを書かせるときは、SVDに載った割り込み名とInterrupt列挙型の識別子が揃っているかを、レジスタと同じ要領で確かめさせます。

よくあるつまずき

  • take()がNoneを返す: 2回呼んだ場合の仕様です。1回だけ呼び、渡して使います
  • take()が使えない: critical-sectionのfeatureと、その実装を提供するcrateが必要です
  • 未整形のlib.rsが巨大で読めない: formで分割し、cargo fmtをかけます
  • 再生成でコンパイルエラーが急増する: 識別子の書式が変わったバージョン間の差です。CLAUDE.mdで固定したバージョンを確認します

次に進む前に

PACができたあとの流れは、一般のRustプロジェクトと似ています。cargo checkやclippyを回す反復の作り方は、AxumのRust API開発の記事が参考になります。実行時のpanicを辿る手順はRustのpanicとbacktraceの記事にまとめています。

PACは検証の入口にすぎません。このスクリプトが保証するのは、SVDと生成コードのオフセット・ビット位置の一致までです。SVD自体がデータシートと合っているかは、実機かデータシートで確かめる別の作業になります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. docs.rs/svd2rust/latest/svd2rust
  2. github.com/rust-embedded/svd2rust
  3. code.claude.com/docs/en/hooks-guide
この記事を共有:XはてブLinkedIn