/home/tnishinaga/TechMEMO

日々行ったこと、面白かったことを書き留めます。

モダンCMake入門

CやC++などのビルドのための仕組みを持たない言語で書かれた大規模なコードをビルドする際、とくに複数の環境でのビルドが必要な場合はCMakeなどのビルドシステムジェネレレーターとNinjaなどのビルドシステムを用います。 この記事ではそういったところで使うCMakeのモダンな書き方について紹介します。

この記事の目的

この記事を通して少しでもモダンCMakeが書ける人が増えたり、世の中のビルドで悩む人が少しでも減らせたら嬉しいです。

TL;DR

モダンCMakeは難しいのでChatGPTとかLLMと相談しながら書くのがオススメ

目次

モダンCMakeとは

CMakeは2.8.x以前までディレクトリベースのビルドジェネレーターでしたが、10年前の2014年に出てきたversion 3.0からはターゲットベースのビルドジェネレーターに変わりました。 このバージョンv3.0以降のお作法で作られたCMakeは モダンCMake と呼ばれています。

モダンCMakeの一番の特徴は、ソースコードをターゲット(target)という単位で隔離し、targetごとに設定を行ったり、依存関係を明示的に取り扱うようになったことでしょう。 これらを行うことでtargetは明示された依存関係にあるコードやヘッダーしか見えなくなるので、意図しないコードをリンクしてしまうなどの問題を未然に防ぐことができます。 また、後述のbest practiceに則ったプロジェクトを作れば、ターゲットごとに細かく設定を変えられるなどのメリットが生まれ、より柔軟なビルドを行えるようになります。

モダンCMakeの具体例

実行バイナリtargetの追加

実行バイナリのターゲットは add_executable() を用いて作成します。 たとえば、以下のようにすると実行バイナリAというターゲットが生成されます。

project(Sample)

add_executable(A a.c)

ライブラリターゲットの追加

ライブラリターゲットは add_library() を用いて作成します。 たとえば、以下のようにするとOBJECTライブラリBというターゲットを作れます。

add_library(B OBJECT b.c b.h)

ターゲットとソースコードの間にキーワードをセットすることで、ライブラリの種類を以下の4つから選択できます。

  • DYNAMIC
    • ダイナミックライブラリ(*.soとか)をつくる
  • STATIC
    • スタティックライブラリ(*.aとか)をつくる
  • OBJECT
    • オブジェクトライブラリ(*.o)をつくる
  • INTERFACE
    • ソースを持たないライブラリを作る

DYNAMICとSTATICは、基本外部にコードを提供するために使われます。前者は共有ライブラリが作られ、後者は静的ライブラリが作られます。

OBJECTはオブジェクトファイル(*.o*.obj)の状態でリンク可能なライブラリを作れます。 この方法は、ライブラリ化のためのリンクが行われない分ビルドのコストが下がったり、weakシンボルの上書きで面倒が発生しにくいなどのメリットがあります。 プロジェクト内部だけで使われるライブラリを作る際は、静的ライブラリよりこちらのオブジェクトライブラリを作ると良いでしょう。

最後のINTERFACEライブラリは、ソースファイル(*.c, .cpp, .S等)を持たないライブラリです。定数やAPIなどの書かれたヘッダーのみを共有したい場合に使います。 従来のディレクトリベースのビルドシステムでヘッダファイルを共有する際は共用の include/ ディレクトリにいれるなどで対応してきたかと思いますが、モダンCMakeではヘッダーだけのInterfaceライブラリを作成して各種ライブラリや実行ファイルに明示的にリンクするやり方のほうがbest practiceとなります。 このようにすることで、ヘッダファイルも細かい単位で依存関係を制御したり、installの設定を行えるようになります。

参考: https://github.com/toeb/moderncmake/blob/master/sample02/greeter/CMakeLists.txt

ターゲット間のリンクと伝搬設定

ターゲット間の依存関係は target_link_libraries() を用いて記述します。 たとえばバイナリAがライブラリBをリンクする場合は以下のように書きます。

cmake_minimum_required(VERSION 3.13)
project(Sample)

add_executable(A a.c)
target_link_libraries(A 
    PRIVATE B
)

add_library(B OBJECT b.c b.h)
target_link_libraries(B
    PUBLIC C
)
target_compile_definitions(B PRIVATE I_AM_B)

add_library(C OBJECT c.c c.h)
target_link_libraries(C
    PRIVATE D
)
target_compile_definitions(C PUBLIC I_AM_C)

add_library(D OBJECT d.c d.h)
target_compile_definitions(D PUBLIC I_AM_D)

CMake v3.0以上では target_* 設定をする際、そこでセットする値をターゲット外に伝搬させる(PUBLIC)かさせないか(PRIVATE)を設定できるようになっています。 モダンCMakeではこの伝搬設定をうまく使ってコンパイルオプションやinclude pathなどを依存先に伝えて、ライブラリや実行ファイルのビルドを行っていきます。

実際に上記の例をCMAKE_EXPORT_COMPILE_COMMANDS=1 をセットしながらビルドして、実際のビルドコマンドと共に伝搬の様子を見てみましょう。

この例では以下の図のようにライブラリが D->C->B->A の順に依存関係を構築しています。

まず、DはI_AM_DをPUBLICとしているので、ライブラリDをリンクしているCにもI_AM_Dが伝搬します。

{
  "directory": "/Users/tnishinaga/projects/tnishinaga/blog/2025-05-31_cmake/cmake_codes/cmake_propagate/build",
  "command": "/usr/bin/cc -DI_AM_C -DI_AM_D   -arch arm64 -o CMakeFiles/C.dir/c.c.o -c /Users/tnishinaga/projects/tnishinaga/blog/2025-05-31_cmake/cmake_codes/cmake_propagate/c.c",
  "file": "/Users/tnishinaga/projects/tnishinaga/blog/2025-05-31_cmake/cmake_codes/cmake_propagate/c.c",
  "output": "CMakeFiles/C.dir/c.c.o"
},

Cは同じくI_AM_CをPUBLICとしているので、I_AM_CはライブラリBにも伝搬します。 一方、CはDをPRIVATEとしてリンクしているので、I_AM_DはライブラリBに伝搬しません。

{
  "directory": "/Users/tnishinaga/projects/tnishinaga/blog/2025-05-31_cmake/cmake_codes/cmake_propagate/build",
  "command": "/usr/bin/cc -DI_AM_B -DI_AM_C   -arch arm64 -o CMakeFiles/B.dir/b.c.o -c /Users/tnishinaga/projects/tnishinaga/blog/2025-05-31_cmake/cmake_codes/cmake_propagate/b.c",
  "file": "/Users/tnishinaga/projects/tnishinaga/blog/2025-05-31_cmake/cmake_codes/cmake_propagate/b.c",
  "output": "CMakeFiles/B.dir/b.c.o"
},

最後にライブラリBは実行バイナリAにリンクされていますが、BはI_AM_BをPRIVATEで定義しているのでI_AM_BはAに伝搬しません。 一方、ライブラリBはライブラリCをPUBLICでリンクしているので、ライブラリCがPUBLICで持っているI_AM_CはライブラリBを介して実行バイナリAに伝搬します。

{
  "directory": "/Users/tnishinaga/projects/tnishinaga/blog/2025-05-31_cmake/cmake_codes/cmake_propagate/build",
  "command": "/usr/bin/cc -DI_AM_C   -arch arm64 -o CMakeFiles/A.dir/a.c.o -c /Users/tnishinaga/projects/tnishinaga/blog/2025-05-31_cmake/cmake_codes/cmake_propagate/a.c",
  "file": "/Users/tnishinaga/projects/tnishinaga/blog/2025-05-31_cmake/cmake_codes/cmake_propagate/a.c",
  "output": "CMakeFiles/A.dir/a.c.o"
}

このようにモダンCMakeではtargetの持つ値をうまく伝搬させながらビルドを行っていきます。

ターゲットのinclude設定

モダンCMakeでのinclude設定は以下のようにします。

target_include_directories(A 
    PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include
)

includeの設定もlink同様PUBLICやPRIVATEのキーワードを付けて伝搬の設定が可能です。 そのため、ヘッダファイルはproject内に収めて target_include_directories に伝搬設定付きで登録するというのがモダンCMake的なbest practiceとなります。

たとえばプロジェクト内外から libhoge/hoge.h のようにヘッダーを参照できるようにしたい場合は、以下のようなディレクトリ構成にした上でCMakeLists.txtに target_include_directories(A PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include) のように書くと実現できます。

.
├── CMakeLists.txt
└──  include
     └── libhoge
         └── hoge.h

これに加えてINTERFACEライブラリの仕組みなどを組み合わせることで、従来のディレクトリベースの仕組みを使わずに、より安全安心なビルドが行えるようになります。

参考: https://github.com/toeb/moderncmake/blob/master/sample02/greeter/CMakeLists.txt

モダンCMakeのbest practice

モダンCMakeより前に使われていた設定の多くはCMake v3.0以降も使えますが、これらの中にはモダンCMakeの良さを破壊する設定が多くあるので、非推奨とされていたり別のbest practiceが紹介されていることが多いです。 すべての紹介はできないので詳細は参考資料を見ていただきたいのですが、基本的には「すべてのターゲットに影響する」ような機能の利用を避けるべきということが書かれています。 このような設定を用いるとtargetの独立性を破壊してしまったり、targetごとの細かい設定ができなくなってしまうので、利用する際は注意が必要です。

この記事では個人的に最低限守りたい以下の部分に絞って紹介します。

非推奨設定は一部の例外を除いて利用しない

以下に示す設定は、暗黙的な依存関係を作り、targetの独立性を破壊し意図しないファイルとリンクする可能性があるので非推奨とされています。

  • add_definitions
  • include_directories
  • add_compile_definitions
  • add_compile_options
  • link_directories
  • link_libraries

これらの設定をtargetの範囲に絞って適用する設定(target_*から始まるもの)があるので、それらを用いることで非推奨設定の利用を回避できます。

非推奨設定の例

たとえば以下のようなディレクトリ構成のプロジェクトがあったとき、main.c から #include <libhoge/hoge.h> のようにして hoge.h をincludeしたいとします。

.
├── CMakeLists.txt
├── libfuga
│   ├── CMakeLists.txt
│   ├── fuga.c
│   └── fuga.h
├── libhoge
│   ├── CMakeLists.txt
│   ├── hoge.c
│   └── hoge.h
└── main
    ├── CMakeLists.txt
    └── main.c

このとき ./CMakeLists.txt 内に include_directories(.) と書くと、コンパイルオプションに -I./ がセットされて ./ 以下のすべてのディレクトリをincludeで参照できるようになります。 その結果、CMakeの設定上は依存関係を記述していないのに main.c から libhoge/hoge.h をincludeできるようになってしまいます。libfuga/fuga.h も同様です。

このようなことを行うと、プログラマは意図しない依存関係の影響を受ける可能性が出てきます。 パッと思いつくトラブルの例としては、CMake上では依存関係のないはずの libhoge を削除したら main がビルドできなくなることなどがあるでしょう。

この例では include_directories を扱ったのでそこまで怖くないかもしれませんが、これが link_libraries だったらどうでしょうか。 プログラマが意図しないライブラリが勝手にリンクされていて、それが意図せずweakな関数を上書きしてしまうなどといったことがあるかもしれません。

非推奨設定の代替

モダンCMakeの具体例 で書いたように、モダンCMakeではこのような設定をしなくてもターゲット設定(target_*)と伝搬設定(PUBLIC/PRIVATE/INTERFACE)を使えばより安全で同等な機能を実現できます。

さきほどの例の場合、libhoge側で hoge.h をもつinterface libraryのtargetを作るか、libhogeのディレクトリをPUBLICでtarget_include_directories 設定に登録したうえで、main側の target_link_libraries() 設定でlibhogeのtargetをリンクすれば、mainからlibhogeのヘッダーファイルを読めるようになります。

注意したい部分として、これらの非推奨設定は禁止されているわけではありません。 そのプロジェクトすべてのtargetにどうしても必要な設定を行いたい場合などに、これらの非推奨設定を用いても害がないことが保証できて、かつこちらのほうがシンプルに書ける場合に、必要最低限を注意して利用するのは良いと思います(e.g. すべてのtargetが依存する組み込みToolchainのincludeのパスとか)。

参考資料引用部

target_* 利用時は必ずPUBLIC/PRIVATE/INTERFACE scope キーワードを設定する

target_* の設定を行う際の PUBLIC/PRIVATE/INTERFACE キーワードは省略もできますが、これを明示的に行うことで意図しない依存関係を作る可能性を減らしましょうというbest practiceです。

参考資料引用部

変数を使いすぎない && macroよりfunctionを使う

どちらも変数のリークや上書きを避けるための best practiceです。

変数を使いすぎない

CMakeは変数のスコープが難しいので、変数の多用は避けたほうが良いです。

具体的には 「CMakeスクリプトを作成する際のガイドライン-変数を使い過ぎない 」の例にあるように、ソースファイルを変数に登録してから add_library などに渡す設定をするのは、以下のような問題を起こしうるので避けたいです。

  • 親の設定したソースファイルを子が利用してしまう可能性がある
    • 親の変数設定は子に引き継がれるため

たとえば以下のようなディレクトリ構成がありまして、

.
├── CMakeLists.txt
└── libhoge
    └── CMakeLists.txt

./CMakeLists.txt が以下、

cmake_minimum_required(VERSION 3.13)

project(A)

set(top_variable "top variable")

add_subdirectory(libhoge)

message("${top_variable} in top")
message("${libhoge_hogehoge} in top")

./libhoge/CMakeLists.txtが以下の場合、

project(libhoge)

set(libhoge_hogehoge HOGEHOGE)

message("${top_variable} in libhoge")
message("${libhoge_hogehoge} in libhoge")

つまりAが親でlibhogeが子の場合、親の設定した変数 top_variable は libhoge 内に引き継がれます。 実際にconfigureを実行してみると以下のような出力が得られ、libhogeから親の変数が参照できていることがわかります。

~/p/t/b/2/c/02_cmake_variable ❯❯❯ cmake -S . -B build/
top variable in libhoge
HOGEHOGE in libhoge
top variable in top
 in top
-- Configuring done (0.1s)
-- Generating done (0.0s)
-- Build files have been written to: /Users/tnishinaga/projects/tnishinaga/blog/2025-05-31_cmake/cmake_codes/02_cmake_variable/build

CMakeでよくみる書き方として、ソースコードを変数srcsに一度格納してからライブラリや実行バイナリのソース部にセットするというものがありますが、これは今説明した変数の引き継ぎ問題を引く可能性があるので避けたいやり方になります。

たとえばさきほどのような構成のプロジェクトがあったとして、 親のプロジェクトA と 子のlibhoge の両方とも変数srcsソースコードを入れて add_library(TARGET ${srcs}) のようにしてライブラリを作っている場合に、子のlibhoge内でsrcsの中身を設定し忘れるとlibhogeは親のAのsrcs変数を参照してライブラリを作ってしまう可能性があります。

こういったトラブルを避けるため、変数は不必要に利用しないようにしたほうが良いとされています。 今回の例の場合、以下のように変数を使わずライブラリを作れば変数関係の問題を避けられるので、より良い書き方とされています。

# ./CMakeLists.txt
project(A)

add_library(A PRIVATE a.c)

add_subdirectory(libhoge)


# ./libhoge/CMakeLists.txt
project(libhoge)

add_library(libhoge PRIVATE hoge.c)

macroよりfunctionを使う

CMakeには設定等をまとめて行う仕組みとしてmacroとfunction機能を用意していますが、macroは変数のスコープがそのmacro内に閉じないので利用は避けることをおすすめします。

functionの方は変数のスコープがそのfunction内に閉じるので、このようなことをしたいときはfunctionを使うほうが良いです。 function内の変数をfunction外でも利用したい場合は、set(var PARENT_SCOPE) を使うとその変数をfunctionの呼び出し元にセットできます。

参考資料引用部

Q&A

モダンCMakeとそのbest practiceを書き続けるにはどうすればいいの?

これ、すごくむずかしいです。

CMakeには公式のLinterはないですし、非公式のLinterもモダンCMakeのbest practiceにしたがっているか教えてくれる機能があるかは不明です。

私の場合は CMakeの公式ドキュメントの cmake-variables などをひととおり読んだ後、「CMake best practice」と書かれた記事を大量に読んで学びましたが、それでも一部best practiceでない書き方をしてしまいます。

最近だとChatGPTなどのLLMに聞くと8割がた正しいCMakeの書き方を教えてくれるので、LLMを相棒にしながらCMakeを書くのが今現在もっともよいCMakeを書く方法かもしれません。

もしくはBazelなどより新しいビルドシステムに移るのという手もあるでしょう。 あちらはLinter等も充実しているのでCMakeよりは嬉しいことが多いかもしれませんし、隣の芝生は青く見えるだけかもしれません。

私はCMakeでディレクトリベースのビルドがしたいんだけど?

CMake v3.0以上のCMakeはターゲットベースで使われることを想定して作られているツールなので、CMakeを使うならCMakeのお作法に従ったほうが得だと思います。 合わないならCMakeを使わずにMakefileなどディレクトリベースの仕組みを使ったり、自作のビルドシステムを作ると良いんじゃないでしょうか。

おわり

私の主戦場は組み込みなので、リンカスクリプトの設定をPUBLICで伝搬させる方法とか、nestしたオブジェクトライブラリはオブジェクトが伝搬されない場合があることとか、ctestを使ったテストの方法とか、色々書けることはありますが力尽きたのでおしまいです。

はやくビルドシステムで疲弊しないですむ世界になってほしいですね。

参考資料

  1. CMakeスクリプトを作成する際のガイドライン, @shiohirose
  2. CMake Best Practices, fujii
  3. CMake: Best Practices, Henry Schreiner, 2021-2-2
  4. Effective CMake a random section of best practices, Daniel Pfeifer, May 19, 2017
  5. Effective Modern CMake, mbinna
  6. その他色々(読んだことを思い出したら追記します)

C言語のbitfieldの仕様と実装を調査した話

C言語にはbitfieldという機能がありますが、この機能にはさまざまな落とし穴があり、注意深く利用しても予期せぬバグや移植性の問題を引き起こす可能性があるとよく言われています。 実際、jpcertを確認すると複数の勧告が出ています。

私は先輩技術者から「とくに組み込みの分野においてC言語のbitfieldは使うべきではない」と教わって生きてきました。 本記事ではbitfieldの仕様や問題点、なぜ仕様を避けるべきか、緩和策や代替案について検討します。

目次

TL;DR

  • bitfieldの仕様は難しい
    • gccの場合は最終的にはpsABIがほとんどの振る舞いを決めている
    • clangはわからない
  • bitfield関係の実装依存問題を解決するのも難しい
    • 生成されるコードを推測しづらい
    • テストで確認できることとできないことがある
  • 安牌を取るなら伝統的なシフトとマスクを用いるほうが良い

bitfieldにはどのような問題があるのか

bitfieldについては、以下の問題が語られる事が多いです。

  • bitfieldの仕様の多くが実装依存で移植性に課題がある
  • バイスレジスタ制御にbitfieldを適用すると、意図しないアクセスが行われる可能性がある
  • 伝統的なシフトとマスクによる方法と比べると、最適化が難しくパフォーマンス影響がある

ひとつめの問題は移植性の問題です。 bitfieldはC言語の仕様書で決まっている部分が非常に少なく、決められていない部分は、コンパイラの仕様書、ABI仕様書、またはコンパイラ実装依存となっています。たとえば、bitfieldの順番やレイアウト等はC言語の仕様で決められておらず、その他の部分はコンパイラの実装やCPUアーキテクチャのABIごとに異なる可能性があります。 そのためbitfield利用の際は、想定利用環境での仕様の確認および生成されるコードの確認が必須です。また、他環境でコードを利用するためには同じ確認が必要となります。そして環境間で仕様に差がある場合は移植ができません。そのため、移植性(portability)に難があると言われます。

ふたつめはデバイスレジスタ制御にbitfieldを適用するのは危険という話です。 MMIOなどのデバイスレジスタ制御では、そのレジスタにアクセスすること自体が意味を持つ(副作用がある)場合があるため、読み書きのタイミングやビット幅は適切になるよう制御したいです。 一方、bitfieldを利用した場合に生成されるアセンブリコードはコンパイルオプションや実装依存のため、読み書きのタイミングやビット幅の制御が行えません。また、レイアウト等の問題を引く可能性もあります。 よって、bitfieldをMMIOのメモリに割り当てて読み書きするような用途で利用するのは危険であり、使うべきではありません。

3つめはコンパイラのバージョン等にもよりますが、伝統的なシフトとマスクを用いてビット制御を行う場合に比べると数命令程度命令数が増える場合があります。

以降、各問題について詳しく見ていきましょう。

bitfieldの仕様

bitfieldに関する仕様をすべて把握するためには、以下の3つの資料に当たる必要があります。 加えて、このすべての資料で言及されていない部分については実装依存となるため、生成されたコードを確認する必要があります。

C言語の仕様

最初にC言語の仕様書を確認します。

Bitfieldのビット配置等について書かれていそうな部分を一部抜粋します。 通常文が和訳内容で、() 内は筆者の補足、{}内は筆者の感想を記します。

13 An implementation may allocate any addressable storage unit large enough to hold a bit-field. If enough space remains, a bit-field that immediately follows another bit-field in a structure shall be packed into adjacent bits of the same unit. If insufficient space remains, whether a bit-field that does not fit is put into the next unit or overlaps adjacent units is implementation-defined. The order of allocation of bit-fields within a unit (high-order to low-order or low-order to high-order) is implementation-defined. The alignment of the addressable storage unit is unspecified.

§ 6.7.3.2, pp.106, ISO/IEC 9899:2024 (en) — N3220 working draft

§ 6.7.3.2は構造体や共用体の仕様を決めている部分です。ここでは以下のbitfield仕様を決めています。

  • (コンパイラの?)実装はビットフィールドを格納するのに十分なサイズのアドレス可能な記憶領域を割り当てられる(may)
  • (記憶単位内に)十分な空きスペースがあり、構造体内で1つのビットフィールドの直後に別のビットフィールドが続く場合、同じ記憶単位内の隣接するビットに詰め込まれる必要があります(shall)
  • (記憶単位内に)十分な空きスペースがない場合、そのビットフィールドが次の記憶単位に格納されるか、隣接するビットと重なるかは実装依存
  • (記憶)単位内でビットがビットフィールドが割り当てられる順序(高位ビットから低位ビット、または低位ビットから高位ビット)は実装依存
  • アドレス可能な記憶単位のアライメントは不定(unspecified)

また、Annex J以降にはビットフィールドのPortability issues(移植性の問題)について書かれています。

  • 不定(unspecified)
    • ビットフィールドのアライメント(19, J.1 Unspecified behavior)
  • 未定義動作(Undefined behavior)
    • (offsetofマクロの話なので省略)
  • 実装依存(Implementation-defined behavior)
    • int(signedでもunsignedでもない)のbit-fieldをsigned intとして扱うか、unsigned intとして扱うか
    • bool, signed int, unsigned int, bit-precise integer 型以外のbit-field型を許可するか
    • bit-fieldにアトミック型を許可するか
    • bit-fieldがストレージユニットの境界をまたげるか
    • ユニット内のbit-field割当順序
    • 構造体の非bit-fieldメンバーのアライメント
      • J.3.10 Structures, unions, enumerations, and bit-fieldsより引用

まとめると、bitfieldの仕様で明確に決まっているのは「記憶単位内に十分な空きスペースがあり、構造体内で1つのビットフィールドの直後に別のビットフィールドが続く場合、同じ記憶単位内の隣接するビットに詰め込まれる必要がある」というルールだけです。それ以外の部分は実装依存となっています。

bitfieldに関するコンパイラのドキュメント

実装依存部の仕様を見るために、clangとgccのドキュメントを当たります。

gcc

GCCは4.9 Structures, Unions, Enumerations, and Bit-Fields節で、C言語仕様で実装依存と書かれていた部分の仕様を決めています。

Structures unions enumerations and bit-fields implementation (Using the GNU Compiler Collection (GCC))

ここからbitfieldに関係する部分を一部抜粋して引用します。

Whether a “plain” int bit-field is treated as a signed int bit-field or as an unsigned int bit-field (C90 6.5.2, C90 6.5.2.1, C99 and C11 6.7.2, C99 and C11 6.7.2.1). By default it is treated as signed int but this may be changed by the -funsigned-bitfields option.

Allowable bit-field types other than _Bool, signed int, and unsigned int (C99 and C11 6.7.2.1). Other integer types, such as long int, and enumerated types are permitted even in strictly conforming mode.

Whether atomic types are permitted for bit-fields (C11 6.7.2.1). Atomic types are not permitted for bit-fields.

Whether a bit-field can straddle a storage-unit boundary (C90 6.5.2.1, C99 and C11 6.7.2.1). Determined by ABI.

The order of allocation of bit-fields within a unit (C90 6.5.2.1, C99 and C11 6.7.2.1). Determined by ABI.

The alignment of non-bit-field members of structures (C90 6.5.2.1, C99 and C11 6.7.2.1). Determined by ABI.

https://gcc.gnu.org/onlinedocs/gcc-14.2.0/gcc/Structures-unions-enumerations-and-bit-fields-implementation.html より引用

和訳は以下。

  • plainなintのbitfieldがsigned int bitfieldにとして扱うかunsigned bitfieldとして扱うか
    • デフォルトはsigned int bitfieldとして扱う。ただし -funsigned-bitfields オプションで変更できる
  • bitfieldに(Cの仕様で決められた)_Bool, signed int, unsigned int以外を使えるか
    • long intのような他の整数型や列挙型は、厳密に適合するモードであっても許可される
  • ビットフィールド型にAtomic typeは許可されるか
    • 許可されない
  • 以下、ABI依存
    • ビットフィールドがストレージユニットの境界を跨ぐことができるか
    • ユニット内のビットフィールドの割当順序(並び順)
    • 構造体のビットフィールド以外のメンバのアラインメント

Cの仕様では実装依存とされている部分の一部はgccの仕様としてカバーしており、その他の部分は各CPUアーキテクチャのABI依存となっているようです。

gcc-3.x頃のbitfield仕様

過去に遡って仕様を確認したところ、gccのbitfieldの仕様が定まったのはgcc-4.0.4からのようです。 それまでの仕様は非常に簡素で、実装依存部のほとんどはgcc側で決めているようです。実装依存の挙動を確認したい際はgcc 3.x系を使うとよいのかもしれません。

clang

私が確認した限りではclangのbitfieldの仕様が書かれたドキュメントを見つけられませんでした。 本当にドキュメントが存在しない場合、clangではbitfieldのすべての挙動が実装依存となってしまいます。 流石にそんなことはないはずなので、これらの仕様がどこにあるかご存じの方がいれば教えていただけると嬉しいです。

CPUアーキテクチャのABIで定められたbitfieldの仕様

bitfieldの仕様はCPUアーキテクチャのABI仕様書にも記されています。 以下、bitfieldに関する記述を見つけられた主要なCPUアーキテクチャABIの資料一覧です。

順番に中身をみていきます。

x86 SystemV ABI

Linux等で使われているABIです。

x32

順番前後しますが、bitfieldの記述に関してはほぼx64と同様なのでそちらを参照してください。 bitfieldのレイアウトが図示されている点はx64のABIよりわかりやすくて嬉しいです。

x64(AMD64)

x86 psABIs / x86-64 psABI · GitLab

この資料のpp.19-20より、bitfieldに関する記述を引用します。

C struct and union definitions may include bit-fields that define integral values of a specified size.

The ABI does not permit bit-fields having the type m64, m128, m256 or m512. Programs using bit-fields of these types are not portable. Bit-fields that are neither signed nor unsigned always have non-negative values. Although they may have type char, short, int, or long (which can have negative values), these bit-fields have the same range as a bit-field of the same size with the corresponding unsigned type. Bit-fields obey the same size and alignment rules as other structure and union members. Also:

•bit-fields are allocated from right to left

•bit-fields must be contained in a storage unit appropriate for its declared type

•bit-fields may share a storage unit with other struct / union members

Unnamed bit-fields’ types do not affect the alignment of a structure or union.

System V Application Binary Interface AMD64 Architecture Processor Supplement(With LP64 and ILP32 Programming Models) Version 1.0, November 18, 2024, pp.19-20 より引用

ざっと和訳するとこんな感じです。

  • __m64型等をbitfieldに使うのはABIとしては不許可
  • 符号付きでも符号なしでもないbitfield(char型、short型、int型、long型)は、常に負でない値を持つ
    • {つまり、素のint型のbitfieldはunsigned intとして解釈されるということ?}
  • ビットフィールドは、他の構造体や共用体のメンバーと同じサイズとアライメントの規則に従う
  • bitfieldは右から左に確保される
    • (他のABIでいうところのLittle-Endian順)
  • bitfieldは宣言された型に適したストレージ・ユニットに含まれなければならない
  • bitfieldは他の構造体/union メンバーとストレージ・ユニットを共有することができる

Microsoft ABI

x32

すぐに見つけられなかったので省略します。

x64

microsoft ABIのbitfieldに関する記述は非常に簡素です。

Structure bit fields are limited to 64 bits and can be of type signed int, unsigned int, int64, or unsigned int64. Bit fields that cross the type boundary will skip bits to align the bitfield to the next type alignment. For example, integer bitfields may not cross a 32-bit boundary.

和訳するとこんな感じ

  • bitfieldのサイズは最大64bit
  • 許容される型は以下
    • signed int
    • unsigned int
    • (signed) int64
    • unsigned int64
  • 型境界を超えるbitfieldは次の型のアライメントに合わせるためにskip
    • 例: int(32bit) bitfieldは32bitの境界を超えてはならない(may)

困ったことにbitの並び順についてはルールがないため、bitfieldの中身がどのような順序となるか実装依存となります。 そのためか、gccには互換を取るための -mms-bitfields というオプションがあるようです。

ARM(aapcs)

ARMのpsABIはbitfieldの仕様が今回調べた中ではもっとも詳細に書かれていました。 そのため、他のABIでも書かれている基本的な部分のみに絞って紹介します。 また、ARM32とARM64のbitfieldに関する記述はほぼ同様なので今回はARM64について見ていきます。

https://github.com/ARM-software/abi-aa/blob/05abf4f78dd7837774c4880fc0e6c01ce9e41ba8/aapcs64/aapcs64.rst#bit-fields

  • コンテナー型の決定
    • ビットフィールドが宣言された整数型に収まる場合
      • 整数型がコンテナー型になる
    • 収まらない場合
      • ビットフィールドのサイズを超えない程度に最大の整数型がコンテナー型になる
      • オーバーサイズビットフィールドの項を参照
  • ビットフィールドがコンテナサイズより大きくない場合(10.1.8.1 Bit-fields no larger than their container)
    • コンテナーのアドレスはコンテナーの型のサイズでアラインされる
    • bit offsetマナーはエンディアン依存
    • bitfieldは以下に依存してシフトおよびマスクを行い、必要なら符号拡張をしてコンテナーから取り出される

bitの並び順がエンディアン依存であることや、取り出し方法や抽出式まで書かれている点が特徴的です。

RISC-V

今回見た中でもっとも新しいアーキテクチャでして、bitfieldに関する仕様が導入されたのが2018年頃、今の形になったのが2024年となっています。

決まっている内容もARMに比べるとシンプルです。

https://github.com/riscv-non-isa/riscv-elf-psabi-doc/blob/3a79e936eec5491078b1133ac943f91ef5fd75fd/riscv-cc.adoc#bit-fields

以下、ドキュメントにかかれている内容のまとめです。

  • ビットフィールドはリトルエンディアンでpackされる
  • ビットフィールドがその整数型のアライメント境界をまたぐ場合、そのビットフィールドは次のアライメント境界から始まるようにパディングされる
  • ビットフィールドのサイズがその整数型のサイズを超える場合、その超える分はパディングとして扱われ、次のビットフィールドは次のアライメント境界から配置される
    • (整数型のサイズを超えるbitfieldを作ると、超えた分のビットはundefinedとなる)

bitfieldの仕様についてのまとめ

psABI依存の部分はアーキテクチャごとに細かい差分はありますが、おおむね以下の部分は共通していそうです。

  • ビットの並び順はlittle-endian(右から左)
  • 許容される型はC言語仕様と同様

つまり、CPUアーキテクチャのint型のビット幅以下のbitfieldを宣言して、そこに隙間のない(paddingがない)ピッタリのビットを定義すれば、コンパイラアーキテクチャが変わっても仕様上は想定外のレイアウトにならないはずです。 しかしそれ以外の場合については各アーキテクチャの仕様とコンパイラの実装を確認する必要があるでしょう。 また、この挙動は(めったにないことですが)ABI仕様が変更された場合*1に変わる可能性も考慮が必要かもしれません。

実装依存部の確認

bitfieldの問題としてあげた以下の2点はコンパイラの実装や最適化オプションによって挙動が変わる部分となります。

  • バイスレジスタ制御にbitfieldを適用すると、意図しないアクセスが発生する可能性がある
  • 伝統的なシフトとマスクによる方法と比べると、最適化が難しくパフォーマンス影響がある

よって、実際にCのコードと命令列を見比べながら、それぞれの問題について見ていきます。

バイスレジスタ制御にbitfieldを適用すると、意図しないアクセスが発生する可能性がある

これはbitfieldへのアクセス手順が実装依存という点が問題となります。

bitfieldはあるメモリの1bitだけ書き換えるなどができるので、以下のようにしてMMIOレジスタ書き換え等にも使いたくなります。 しかし、このようなコードは書くべきではありません。

// https://godbolt.org/z/oeqba9q83
#include <stdint.h>

typedef struct {
    uint32_t b0_7:8;
    uint32_t b8_23: 16;
    uint32_t b24_31:8;
} MMIO_REG;

void hoge(volatile uint32_t *addr, uint8_t data) {
    volatile MMIO_REG *reg = (volatile MMIO_REG *)addr;
    reg->b0_7 = data & 0xff;
}

こちらのコードをgcc-4.5.4でコンパイルすると、以下のようにデータを1byteでstore(strb)するコードが生成されます。

# gcc 4.5.4で生成されたコード
hoge:
        strb    r1, [r0, #0]
        bx      lr

bitfieldにはどのような問題があるのか でも述べたように、MMIOなどのアクセスには読み込みに副作用がある場合や、アクセスするbit幅によって読み書き結果に差が出る場合があります。 そのため、このようなコードが生成されると、意図しない挙動が発生する可能性があります

なお、このコードを新しめのgcc(14.2.0)1でビルドすると32bit load, 32bit storeのコードが生成されるので、同様の問題は起こりません。

# gcc 13.1.0で生成されたコード
hoge:
        ldr     r3, [r0]
        bic     r3, r3, #255
        orr     r3, r1, r3
        str     r3, [r0]
        bx      lr

ただし、この挙動については仕様で決まっていないため、今後変更される場合があります。

どうしてもbitfieldを使いたい場合は、以下のようにMMIOを直接書き換えないようにすれば実装依存の影響を受けにくくなるはずですが、絶対大丈夫という保証はありません。

#include <stdint.h>

void hoge(volatile uint32_t *addr, uint8_t data) {
    volatile MMIO_REG reg = *(volatile MMIO_REG *)addr;
    reg.b0_7 = data & 0xff;
    *(volatile MMIO_REG *)addr = reg;
}
# gcc 4.5.4の生成したコード
hoge:
        ldr     r3, [r0, #0]
        sub     sp, sp, #8
        str     r3, [sp, #4]
        strb    r1, [sp, #4]
        ldr     r3, [sp, #4]
        str     r3, [r0, #0] <- r0(data)にストア
        add     sp, sp, #8
        bx      lr

安牌をとるのであれば、伝統的なシフトとマスクを用いた方法を使うべきでしょう。

伝統的なシフトとマスクによる方法と比べるとパフォーマンスが低下する可能性がある

構造体のビットメンバは実用上の欠点がある。

...(中略)...

多くの一般的なコンパイラは、ビットメンバの読み書きに対して、非効率なコードを生成する(プロセッサのワード単位でロード/ストアするほうが効率的)。

https://ja.wikipedia.org/wiki/ビットフィールド より引用

wikipediaにこう書かれているように、bitfieldを用いると非効率なコードを生成する場合があります。 以下に非効率なコードを生成する例を紹介します。

// 非効率なコードを生成する例
// https://godbolt.org/z/b3E7n3jfz
#include <stdint.h>

typedef struct {
    uint32_t b0_7:8;
    uint32_t b8_23: 16;
    uint32_t b24_31:8;
} MMIO_REG;

void hoge(
    uint32_t *addr, 
    uint8_t b0_7, 
    uint16_t b8_23,
    uint8_t b24_31
    ) {
    MMIO_REG *reg 
        = (MMIO_REG *)addr;
    reg->b0_7 = b0_7;
    reg->b8_23 = b8_23;
    reg->b24_31 = b24_31;
}

こちらのCコードをgcc 14.2.0でコンパイルした結果を以下に示します。

hoge:
        strb    r1, [r0]
        ldr     ip, .L2
        ldr     r1, [r0]
        and     r2, r2, ip
        bic     r1, r1, ip, lsl #8
        orr     r1, r1, r2, lsl #8
        str     r1, [r0]
        strb    r3, [r0, #3]
        bx      lr
.L2:
        .word   65535

wikipediaの話通り、32bitストアと8bitストアを行う効率の悪いコードが生成されました。

このコードはさまざまな点で意図通りではありません。 Cのコードの意図が32bitのbitfieldすべてを一度に書き換えることであれば、ストアが2度に分かれているのは意図に反しますし、パフォーマンスも低下します。 それぞれのフィールドごとにメモリストアを行ってほしいという意図だとしても、その意図は守れていません。

以下のようにCのコードを書き換えれば1つ目の意図通りのコードを出力もできますが、このコードもコンパイラのバージョンや最適化オプションなどで変わる可能性があります。2つ目もvolatileをつければ意図通りになるはずですが、こちらもコンパイラやオプション次第となります。

// https://godbolt.org/z/nsdosjnhf
#include <stdint.h>

typedef struct {
    uint32_t b0_7:8;
    uint32_t b8_23: 16;
    uint32_t b24_31:8;
} MMIO_REG;

void hoge(
    uint32_t *addr, 
    uint8_t b0_7, 
    uint16_t b8_23,
    uint8_t b24_31
    ) {
    MMIO_REG reg = *(MMIO_REG*)addr;
    reg.b0_7 = b0_7;
    reg.b8_23 = b8_23;
    reg.b24_31 = b24_31;
    *addr = *(uint32_t *)&reg;
}
# 生成されたコード
hoge:
        orr     r1, r1, r2, lsl #8
        orr     r1, r1, r3, lsl #24
        str     r1, [r0]
        bx      lr

やはり安牌をとるのであれば、挙動を読みやすい伝統的なシフトとマスクを用いた方法を使うべきでしょう。

その他色々

bitfieldの定義を1bitでも誤ってはいけない

bitfield内のビット定義を1bitでも多くするか、少なくすると困ったことが起こります。

1bit多く定義した場合

以下のようにbitfieldの定義を誤ってコンテナサイズより1bitでも大きくしてしまうと、bitfieldのサイズはコンテナサイズに収まらなくなります。以下の例の場合、bitfieldのサイズは8となります。

// https://godbolt.org/z/7x4EqraqW
#include <stdint.h>
#include <assert.h>

typedef struct {
    uint32_t b0_7:8;
    uint32_t b8_23: 16;
    uint32_t ext:1;
    uint32_t b24_31:8;
} MMIO_REG;

void hoge(void) {
    static_assert(
        sizeof(MMIO_REG) == 4, 
    "invalid size");
}

このミスはstatic_assertなどを用いてコンパイル時に検知するコードを入れると防げます。 逆に言えば、static_assertさえいれておけば定義のミスを検知する仕組みが入れられるメリットがあるとも言えるかもしれません。

こちらの問題は伝統的なシフトとマスクを用いた方法に切り替えても発生しますし、むしろ気づくのが難しくなる予想です。

1bit少なく定義した場合

以下のコードは16bit幅のb0_15を定義したつもりが、誤って1bit少ない15bitで定義してしまった例になります。

typedef struct {
    uint32_t b0_15:15;
    uint32_t b16_31:16;
} MMIO_REG;

bitfield内のどこかの定義を誤って1bit短くしてしまった場合はちょっと面倒なことになります。 具体的な例は以下です。

  • sizeof(MMIO_REG) では誤りに気がつけない
    • bitfieldがコンテナサイズ(uint32_t)に収まっているため
  • b0_15 に215より大きな値を入れないとコンパイラの警告が出ない
  • b0_15以降のビットすべてのビットオフセットがずれる
    • 今回の場合は b16_31 にセットした値が意図したビットオフセットに挿入されない
  • padding bitがどこに挿入されるかはコンパイラやABIの仕様・実装依存

このバグをコンパイル時やテストで見つけるのは結構難しいです。見つけられるとすればコードレビューのときだけで、そこで見つけられなかった場合はruntimeのバグとして対処するしかなくなります。

テストで見つける方法があるとすれば、伝統的なシフトとマスクを用いた方法で作った値とbitfieldで作った値を比較する仕組みを作るとかでしょうか。(そこまでしてbitfieldを使う意味とは……?)

なお、伝統的なシフトとマスクを用いた方法を使った場合でもこの問題自体を防ぐことはできませんが、伝統的な方法はビットオフセットが独立して定義されているため、1つミスをしても他に波及しないメリットがあります。一方、そのせいでバグの発覚が遅れる可能性もあり、一長一短です。

テストが書きづらい

bitfieldの振る舞いはプロセッサのABIに依存するため、コードの移植性に難があります。 仮にコードを他のアーキテクチャに移植する予定がなかったとしても、テストを作る際に困る場合があります。

組み込み開発でもユニットテストを書いて実行したいと考えたとき、第一候補は実デバイスqemu等の上でテストを行うことと思います。 しかし、これらのうえでテストを組むコストや実行時間を考えると、HALを用意するなどして可能な限りx86_64のPC上でテストを実行したいとなる場合があります。 しかし、コードの移植性に難がある場合、HALがその差を吸収しきれずテストが作れなくなる可能性が出てきてしまいます。

これを避けるのであれば、bitfieldより伝統的な方法を用いた方が、アーキテクチャに左右される部分が少なく適していると思われます。

まとめ

  • bitfieldの仕様は難しい
    • gccの場合は最終的にはpsABIがほとんどの振る舞いを決めている
    • clangはわからない
  • bitfield関係の実装依存問題を解決するのも難しい
    • 生成されるコードを推測しづらい
    • テストで確認できることとできないことがある
  • 安牌を取るなら伝統的なシフトとマスクを用いるほうが良い

bitfieldはそのほとんどがCが言語の仕様で定まっておらず、正確な挙動を把握するにはコンパイラの仕様、プロセッサのABI仕様、コンパイラの実装、生成されるコードすべての確認が必要となります。 また、コンパイラのバージョンを更新したり、オプションを変更するたびにバグが起こらないか確かめる必要もあるかもしれません。 その他にも、コンパイラの仕様やバグによってはプロセッサのABI仕様と異なる挙動をする可能性もありますし、(めったにないことですが)ABI自体が変わることもありえます。 これらすべての可能性を検討し、適切に運用するのはなかなか難しいと思います。 そのため、とくに複数人での開発の場や、実務のコードに持ち込む際には議論が必要でしょう。

それRustだとどうなの?

趣味ではRustで組み込み開発をしているので、Rustでのbitfieldの扱いを書いておきます。

RustにはBitfieldはありません。 どうしてもRustでbitfieldを使いたい場合は、まずbitfieldに期待する機能を考えたあと、その期待する機能に応じて第三者の作ったライブラリ(crate)を適切に利用して機能を実現します。 具体的には以下のライブラリを用います。

Rustではbitfieldの機能を外部ライブラリが提供しているので、bitfieldの挙動はライブラリの仕様や実装のみで決められます。 これにより、実装依存や未定義動作の問題を減らせたり、移植性の向上や、テストが書きやすくなるなどのメリットを得られるでしょう。

EOF

おしまい


  1. gcc 5.4から14.2.0と同じコードが得られたので、4.5.4から5.4の間に何らかの変更があったようです

*1:大昔の話になりますが、ARMは初期のABI(OABI)から今使われているEABIに変わったことがあります。RISC-Vなど新しいアーキテクチャが今後発展の過程でABIを変更する可能性はあるかもしれません。

Rustでマイコン(no_std)環境のエラー設計を検討する

背景

趣味で作っている途中の、Rustで書かれたマイコン(no_std環境)で動作するJTAGデバッガアプリケーションのエラー設計を考えています。

今作っているJTAGデバッガアプリケーションでは、インターフェイスJTAGDAPなどの階層に分けた構造になるよう設計しています。 この設計に加えてそれぞれの階層の接続部をtraitで抽象化することで、特定層以下の実装を自由にできるというメリットを得ようとしています。

コードのイメージ

設計を階層化したので、エラーも階層構造にしたいです。 単純にエラー分けて変換していくだけであれば、各階層のカスタムエラーにFromを実装していけば良いはずです。 一方、この設計は注意しないと変換元のコンテクストが失われてデバッグが困難になりそうです。 また、no_std環境なのでstdで一般的な仕組みや方法の多くはそのまま採用できないでしょう。

デバッグの難易度を減らしながら階層化もできる、変換も簡単。そんなエラーをno_std環境で実現するにはどうすればいいんだろう?と調べ始めたのが本記事の発端です。

TL;DR

  • no_std環境でもStackErrorの仕組みを使えばエラーの発生場所と経路がわかる
  • StackErrorを使うコストは以下
    • FLASH: 100KB以下(コードの規模やログの充実具合による。実際は半分程度と予想)
    • RAM: 最大160Byte程度(Error型に加えるコンテクスト次第で増減)
  • StackErrorの仕組みは独自実装でもできるが、snafuがあると楽

要件定義

エラーに求めるもの

プログラムの実行中に発生したエラーの原因を解析するためには、以下のような情報がほしいです。

  • そのエラーの発生場所
  • そのエラーの発生原因
    • 補足情報もあると嬉しい(たとえばファイルIOエラーならファイル名とか)

また、Rustのようにエラーの発生場所と報告位置がずれやすい*1 環境では以下の情報もほしいです。

  • そのエラーの来歴
    • そのエラーはいつどこで発生したのか
    • どの関数を通って来たのか

また、これらの情報は可能な限り自動的にエラーに付与されてくれると手間がなくて嬉しいです。

no_stdのエラー設計は難しい

上記の希望はstd環境であれば比較的簡単に満たせると思います。 一方、no_std(without alloc)環境ではbacktraceが(気軽に)使えない*2ので手軽に来歴情報を取れません。 logを多用すればデバッグという目的は達成できるかもしれませんが、各所に適切にログを仕込むのは面倒で難しいですし、no_stdの実行環境はプログラムメモリが貴重なので多用できない点も厳しいです。 なにか良い方法はないでしょうか?

既存設計調査

いろいろ調べていたところ、κeenさんの投稿経由で GreptimeDBのエラー設計ブログ記事を見つけました。

この記事では、よく使われているthiserror+anyhowの代わりにsnafuというcrateとmacroを併用したStackErrorという仕組みを提案しています。 記事や実装を読んだところ、この設計は私の求めているものに近そうです。

StackErrorとは

StackErrorの概要図

個人的にStackErrorを一言で表すと「大本のエラーにLocation情報を付け加えていくことで、エラーのコンテクストと来歴を記録する仕組み」と考えています。

GreptimeDBの記事のA good error report should be...では、「良いエラーレポート」と「それを実現するためにエラーはどんな情報を持っているべきか」が書かれています。 ここに書かれている内容より、GreptimeDBのエラーの目指す方向と私の希望がだいたいマッチしていそうだと思いました。

A good error report is not only about how it gets constructed, but what is more important, to tell what human can understand from its cause and trace. We call it Stacked Error.

(skip)

This example shows the critical information that an error should contain:

  • The root cause that tells what is happening.
  • The full context stack that can be used in debugging or figuring out where the error occurs.
  • What happens from the user's perspective. Decide whether we need to expose the error to users.

GreptimeDBの記事のA good error report should be... より引用

次のCapture system backtrace to detect when error occursHow error looks like with virtual user stack では、エラーの原因と来歴を調べる方法としてbacktraceは情報過多かつサイズや速度のコストがかかる問題があるため、代わりにユーザーコード上でエラーのstack(virtual user stack)を管理して表示する仕組みであるStackErrorを提案しています。

StackErrorの実現方法

StackErrorの機能は以下の3つを組み合わせて実現しているようです(How error looks like with virtual user stackおよびMacro Detailsより)。

今回注目したいのはError型の設計です。 StackErrorのError型は、エラーの発生場所を記録するlocationとエラーの変換元のsourceを内包することで、エラーの来歴を追えるようにしています。 たとえば、呼び出した先の関数がエラーを返してきた場合、Error型のlocationに現在のコードの位置(ファイル名、行・列番号)を記録し、そのエラー自体をsourceに格納して、さらに上の関数にエラーを返すということを繰り返します。 これにより。どこかにあるエラーハンドラーは上がってきたErrorのsourceを辿っていくことで、そのエラーがどこを辿ってきたのか、エラーの根本はどこにあり、何が起こったのかを知ることができます。

文章では伝えづらいので、StackErrorのError型のアイデアのみ(を正確ではありませんがおおむね)実装した動くサンプルを用意しました。 動作確認用コードはこちら

// 上記サンプルから一部抜粋

#[derive(Debug)]
pub struct ErrorSubA {
    _location: &'static core::panic::Location<'static>,
}

impl ErrorSubA {
    #[track_caller]
    pub const fn new() -> Self {
        Self {
            _location: core::panic::Location::caller(),
        }
    }
}

#[derive(Debug)]
pub enum Error {
    A {
        source: ErrorSubA,
        location: &'static core::panic::Location<'static>,
    },
}

impl From<ErrorSubA> for Error {
    #[track_caller]
    fn from(value: ErrorSubA) -> Self {
        Self::A {
            source: value,
            location: core::panic::Location::caller(),
        }
    }
}

fn err() -> Result<(), Error> {
    let err: Result<(),ErrorSubA> = Err(ErrorSubA::new());
    let _ = err?;
    Ok(())
}

fn main() {
    println!("{:?}", err());
}

ErrorSubAは呼び出し先の関数で生成されたエラーを想定、Errorは呼び出し元のエラーを想定しています。

ErrorSubAは生成された時点の場所を記録するようになっています。 また、ErrorSubAからErrorにFrom traitで変換される際は、Error型のsourceにErrorSubA型のエラーを格納しつつ、この変換処理の呼び出し元の場所をlocationに記録する仕組みになっています。

err関数はErrorSubAを生成してErrでくるんだ後、?Result<(), Error>に暗黙の変換を行った後、main関数に返しています。 main関数では戻り値のDebug出力を行うようになっているので、ここでErrorSubAがErrorに変換された場所と、ErrorSubAが生成された場所が表示されます。 実行結果はこんな感じになります。

Err(A { source: ErrorSubA { _location: Location { file: "src/main.rs", line: 48, col: 41 } }, location: Location { file: "src/main.rs", line: 49, col: 13 } })

48行目がErrorSubAを生成した場所で、49行目が?でError型に変換した場所なので、エラーの来歴をきちんと記録できていそうです。

以上でStackErrorのError型の解説を終わります。 その他の実装や設計の細かい部分は元記事を参照してください。

StackErrorの利点

StackErrorはErrorを何度も包んでいく設計なので、最終的なError型のメモリサイズが大きくなったり、バイナリサイズが大きくなるデメリットがあったりするのかなと思いましたが、そうでもないようです。

元記事のLow overhead and binary size benefits of virtual stacked errorsによると、Backtraceを使う仕組みではデバッグシンボルを含めてバイナリサイズが700MB必要だったが、StackErrorの場合は(stripped binaryでも動くので)170MBで済むようになったそうです。 また、(StackErrorを使った場合に)LocationとDisplayを削除しても100KB程度しかサイズが変わらなかったことから、StackErrorのオーバーヘッドは100KB以下と見積もっているようです。マイコン環境ではちょっと重めのオーバーヘットですが、最近のマイコンFLASHサイズは大きいのでたぶん問題ないでしょう。

StackError利用時のError型サイズの考察

Error型のメモリサイズ増加には触れられていないので考えてみます。

StackErrorのError型の最小要素はLocationとsourceの2つです。 Locationはcore::panic::Locationおよびsnafu::Locationのどちらも同じようになっていて、以下の3つの要素で構成されています。

  • ファイルパスへの参照(&str)
  • 行番号(u32)
  • 列番号(u32)

&str はFATポインタなので、32bit環境でのサイズは8byte(ポインタ4byte, サイズ4byte)です。よって、32bit環境のLocation型のサイズは8+4*2=16byteになります。 また、StackErrorのError型のサイズはLocationとsourceで決まるので、StackErrorのError型の最小サイズは16*n(byte)(nはErrorの入れ子の深さ)となります。 雑な見積もりですが入れ子の深さが10を超えることはないと思うので、nの最大サイズは10と仮定します。

最初にcore::panic::Locationを使うError型のサイズを考えます。 core::panic::LocationはLocation::caller関数で取得します。この関数は &'static Location を返すので、Error型はlocationとして &'static Location を内包します。 &'static Location のサイズは4byteなので、core::panic::Locationを使うError型のサイズは4*n(byte)となり、n=10の場合は40byteとなります。 一見RAMのコストは少なく見えますが、代わりに参照先のLocationが.rodataかコード内に埋め込まれてコードサイズが少し増える予想です。

次にsnafu::Locationを使ったError型のサイズを考えます。 snafu::LocationはLocation::generate()(内部的にはLocation::default()を呼ぶ)またはLocation::default()関数を用いて生成します。 これらの関数はLocation自体を返すので、これを内包するError型のサイズは16*n(byte)となり、n=10の場合は160byteとなります。 core::panicを使う場合に比べてRAMのコストは大幅に増えますし(減らし方の検討中。補足参照)、const fnでは無いのでコードサイズも増えるかもしれません。

その他、Error型に別途コンテクストを足すたびにサイズは大きくなります。その場合はエラーの設計を見直して入れ子の深さを調整する必要があるでしょう。

設計&実装

GreptimeDBのStackErrorはまさに自分の欲しいものだと確証を得たので、ぜひこれを自分のプログラムでも使いたいです。 しかし、既存の実装はstdやallocに依存しているためそのままno_std環境に流用できません。 悩みましたが、StackErrorの仕組みやアイデアは十分咀嚼できたかなと思うので、今回は咀嚼したアイデアを元に独自のエラーを作ってみることにしました。ただし、簡単のため今回はError型の部分のみ実装します。

というわけでできたサンプルコードがこちらになります。 taskfileを用意しているので、cortex-m-errorディレクトリ以下で task run するとexample以下のサンプルをqemuで実行した結果が出てきます。

github.com

実装は以下の2つを用意しています。

独自実装(manual)

StackErrorの説明のところで出したコードほぼそのままです。

github.com

ErrorにFrom traitを実装しているので、型変換を ? で行えるのが便利です。

実行結果はこんな感じです。

task: [run] qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel target/thumbv7m-none-eabi/release/examples/error_manual
Timer with period zero, disabling
Err(A { source: ErrorSubA { _location: Location { file: "examples/error_manual.rs", line: 59, col: 19 } }, location: Location { file: "examples/error_manual.rs", line: 60, col: 13 } })

snafuを使った実装

GreptimeDBと同様にsnafuを使って実装したパターンです。

snafuはthiserrorとanyhowを一緒にしたようなcrateです。 thiserror+anyhowはErrorを?(From trait)で変換しますが、snafuはcontext関数を使ってエラーにコンテクストをつけながら変換を行います。 ? によるエラー型の変換ができないのは少し不便ですが、補足に書いた問題を引かないという点や、任意のタイミングでcontextを足せるという部分ではthiserrorより便利なのかなと思っています。

github.com

今回は調べが足りず活用できていませんが、snafuはその他いろいろな機能があるので、独自実装に比べると今後エラー設計を便利に行えるのではないかと期待しています。 また、snafuは他のライブラリに依存していないので、依存ライブラリのライセンス等を気にしなくて良い点も嬉しいです。

コードサイズの比較

独自実装とsnafuを使った場合のコードサイズを比較してみました。

結果は24byte差でsnafu版のほうが小さくなりました。

task: [size] cargo size --release --example "error_manual"
    Finished `release` profile [optimized + debuginfo] target(s) in 0.02s
   text    data     bss     dec     hex filename
  11248       0       8   11256    2bf8 error_manual

task: [size] cargo size --release --example "error_snafu"
    Finished `release` profile [optimized + debuginfo] target(s) in 0.04s
   text    data     bss     dec     hex filename
  11224       0       8   11232    2be0 error_snafu

snafu実装ではLocationの取得をconst fnでやってないので、最適化に差が出でてsnafu版のほうがコードサイズが大きくなるかなと思ってましたが、何故かmanualより少ないです。

コードの規模が小さすぎるせいもあり、現時点では原因がよくわからないというのが正直なところです。

まとめ

  • no_std環境でもStackErrorの仕組みを使えばエラーの発生場所と経路がわかる
  • StackErrorを使うコストは以下
    • FLASH: 100KB以下(コードの規模やログの充実具合による。実際は半分程度と予想)
    • RAM: 最大160Byte程度(Error型に加えるコンテクスト次第で増減)
  • StackErrorの仕組みは独自実装でもできるが、snafuがあると楽

補足

thiserrorはStackErrorの実装には不向き

最近(2024/12/06)thiserrorがno_std対応しました(リリース記事)。 Errorといえばthiserror+anyhowがデファクトと思っていたのでno_std環境でもthiserrorを使ってStackErrorを作れたらおもしろいかなと思いましたが、結論から言えばthiserrorを使うメリットを感じられるような使い方はできませんでした。

GreptimeDBの説明

thiserror mainly implements the std::convert::From trait for your error types, so that you can simply use ? to propagate the error you receive. Consequently, this also means you cannot define two error variants from the same source type. Considering you are performing some I/O operations, you won't know whether an error is generated in the write path or the read path. This is also an important reason we don't use thiserror: the context is blurred in type.

https://greptime.com/blogs/2024-05-07-error-rust#how-we-usually-handle-an-error-in-rust より引用

この記述によるとthiserrorはコンテクストの型判定が曖昧なので、同じソースの型から2つのエラー型を定義できないそうです。 例として io::Error がRead方向で起こったのかWrite方向で起こったのかを分けられないとのこと。

本当かなと思って試したらたしかに実装がconflictしてビルドが通りませんでした(コードはこちら)。 Fromで勝手に変換されるのは便利ですが、こういうこともあるんですね。

私が躓いたところ

自分がthiserrorで実装を試したところ、thiserrorの #[from] macroは型の要素としてsourceまたはbacktraceしか受け付けてくれないという問題に直面しました。コードはこちら

StackErrorをthiserrorで実装しようとして失敗した画像

location情報を入れられないとエラーの来歴がわからなくなるので、希望を満たせません。 Fromを自分で実装すれば回避できるかもしれませんが、そうするとthiserrorを使う必要があるのか疑問です。 よって、thiserrorはStackErrorの実装には不向きと判断しました。

snafuのErrorのサイズを減らす方法

レビュアーの桜花ちゃんによると、snafuはcontext関数でエラーを変換する際、構造体の中身の足りない部分はGenerateImplicitDataを用いて生成しているとのことです。 そのため、GenerateImplicitData traitをcore::panic:::Locationに実装(はできないのでNewtype Patternで新しい型を作って実装)し、その型の値をErrorの中に持たせればsnafu版でもError型のサイズを抑えられるかもしてないとのことです。 まだ試してないので、試したら追記します。

2025/01/04 17:20追記

snafu版のlocationに &'static core::panic::Location<'static> を使うサンプルを追加しました。

github.com

エラー型のサイズを出すコードも入れて試した結果、エラー型のサイズが32->8byteに減るのを確認しました。 snafuを使ってもエラー型のサイズを16*nから4*nに減らせていそうです。

task: [run] qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel target/thumbv7m-none-eabi/release/examples/error_snafu_small
Timer with period zero, disabling
sizeof(err) = 8
Err(A { location: LocationRef(Location { file: "examples/error_snafu_small.rs", line: 58, col: 17 }), source: ErrorSubA { location: LocationRef(Location { file: "examples/error_snafu_small.rs", line: 57, col: 42 }) } })

代わりにコードサイズは11408byteと一番大きくなってしまいました。 素のsnafu版(error_snafu)に比べて80byte増です。 原因は未調査ですが、今のところ最大サイズが読みにくいRAMを使用されるよりはコードサイズの増加は許容かなぁと考えています。

~/p/t/n/cortex-m-error ❯❯❯ task size
task: [size] cargo size --release --example "error_snafu"
    Finished `release` profile [optimized + debuginfo] target(s) in 0.07s
   text    data     bss     dec     hex filename
  11320       0       8   11328    2c40 error_snafu

task: [size] cargo size --release --example "error_snafu_small"
   Compiling cortex-m-error v0.1.0 (/Users/tnishinaga/projects/tnishinaga/nostd_error/cortex-m-error)
    Finished `release` profile [optimized + debuginfo] target(s) in 0.41s
   text    data     bss     dec     hex filename
  11400       0       8   11408    2c90 error_snafu_small

task: [size] cargo size --release --example "error_manual"
    Finished `release` profile [optimized + debuginfo] target(s) in 0.02s
   text    data     bss     dec     hex filename
  11336       0       8   11344    2c50 error_manual

謝辞

今回のエラー設計を考えるに当たり、以下の方々に初期相談やレビューをしていただきました。 みなさまありがとうございました。

*1:エラーを?(question mark operator)を使って気軽に上の関数に送れてしまう弊害(と個人的に思っている。試作段階では?を使えるよう戻り値を設計しつつ、その場でunwrapしたほうが良いという考え

*2:std::backtrace::Backtraceは使えませんが、mini_backtraceなどを使ってbacktraceを実現する方法はあります。ただし、anyhowやsnafuなど有名なライブラリはstd::backtrace::Backtrace以外をサポートしていないので気軽に使えるとは言いづらい状況です

Baremetalで遊ぶ Raspberry Pi 5 RP1 UART編

Raspberry Pi 5のほとんどのIOはPCIeで接続されたRP1というチップを経由して行われるので、私はbaremetalではPCIeコントローラーのドライバを書かないとRP1経由でIO制御ができないと思っていました。

そのため先日なんとかPCIeコントローラーのドライバを書き上げたのですが、実はfirmwareがPCIeコントローラーを初期化したあとリセットせずにkernelを立ち上げてくれる機能がfirmwareにあることを後で知りました。

By default, the PCIe X4 interface is reset before loading the kernel so that the PCIe RC is in a clean state. For bare metal / bringup you can add pciex4_reset=0 to config.txt because writing a bare metal PCIe RC driver probably isn't everyone's cup of tea :) https://forums.raspberrypi.com/viewtopic.php?p=2159255#p2159255 より引用

つまり、このオプションを使えばPCIeドライバを書かなくてもRP1のペリフェラルが使えます。

今回はこのオプションを活用し、baremetalでRP1を使ってGPIO PINのUART端子からシリアル出力を行ってみました。 動きはしたものの完動はせずという状況ですが、原因がわからないので一旦ここまで動きましたという共有をしたいと思います。

(PCIeコントローラーの解説はまた後日)

やるべきこと

  • RP1のメモリとCPUメモリのメモリマップの確認
    • どこを読み書きして設定するか調べる
  • GPIOの設定
    • UARTの信号をGPIOから出力可能にする
  • UARTの設定

RP1のメモリとCPUメモリのメモリマップ

PCI/PCIeではデバイスのメモリ空間をCPUのメモリ空間にマップしたあと、CPUがマップされたメモリにアクセスすることでデバイスの制御等を行います。 RP1はPCIe経由でCPUとつながっているので、一般的なPCIeデバイスと同様に特定のCPUメモリを読み書きすると、RP1のMMIO経由でRP1のペリフェラルを制御できます。

Pi5のCPUのメモリ空間は64bitで、RP1のプロセッサメモリ空間は32bitです。 これらのアドレスの対応は以下のようになっています。

CPU RP1
0x1f_0000_0000 0x4000_0000

このマッピングの理由はPCIeコントローラーの設定とPCIのBAR設定によるものなのですが、今回は解説を省略します。

RP1のメモリマップは rp1-peripherals.pdf2.3.1. PCIe and 40-bit to peripheral address mappingTable 2. Peripheral Address Map に示されています。 今回扱うペリフェラルはuart0(0x40030000) と iobank0(0x400d0000) の2つです。

また、先程のアドレスの対応表より、Pi5のCPUから uart0(0x1f_0003_0000) と iobank0(0x1f_000d_0000) にアクセスすると、MMIO経由で各種ペリフェラルの設定を行えるとわかります。

GPIO端子の設定

UARTの信号は以下のようにGPIOを経由してPi5のGPIO端子に出力されています。

SoC内部[UART peripheral(PL011) -> GPIO(iobank) -> GPIO(padsbank)]-> Pi5のGPIO端子 

GPIOの設定が未設定だとUARTの信号をGPIOでフィルターしてしまい、外部から我々が観測できない状態になってしまいます。 これを防ぐために適切な設定を行います。

GPIOの設定でやることは以下の2つです。

  • GPIOのFunctionをUARTに設定する
  • GPIOのOutputをEnableにする

RP1のGPIOはRP2040(Raspberry Pi Pico)とほぼ同じなので、Picoを使ったことがある人なら簡単に行えるかもしれません。

GPIOのFunctionをUARTに設定する

RP1のGPIO Functionは iobank0(0x1f_000d_0000) の GPIOx_CTRLレジスタののFUNCSELで設定します。

Raspberry PiのGPIO端子でUARTが出ているのは GPIO 14と15です。 これはそのままRP1のGPIO番号と対応しています。

GPIO Functionと機能の対応は rp1-peripherals.pdfTable 4. GPIO function selection にかかれています。 これによると GPIO14と15はFunction a4をセットするとUART0のTX/RXにつながるようです。 よって、FUNCSELには4を設定すればよいです。 一緒に念の為 OEOVER と OUTOVER も0にセットしておきましょう。

GPIOのOutputをEnableにする

RP1のGPIOで出力を有効にするには、さらにpadsbank0(0x1f_000f_0000)の制御が必要です。

これもGPIOごとに1つレジスタが割り当てられていて、レジスタの7bit目にあるOD(Output Disable)を0にすると出力できるようになります。

UART(PL011)

みんな大好きARM社のUARTペリフェラルです。

とりあえず出力するだけの初期化手順は以下になります。

  • CRレジスタのUARTENビットに0を書き込んでUARTをDISABLEDする
  • LCR_HレジスタFIFOを無効化
  • FLAGレジスタのBUSYが0になるのを待つ
  • 各種入出力の設定
  • IMSCレジスタで割り込みを全マスク
    • 0xffff_ffffを書き込めばOK
  • IBRDとFBRDレジスタでbaudrate生成用clock divisorを設定
  • CRレジスタのUARTENビットに0を書き込んでUARTをENABLEにする

IBRD/FBRDレジスタ設定に必要なF_UARTCLKパラメーターはRP1のマニュアルにかかれています。

UART interfaces have an independent baud clock (clk_uart), typically 48MHz. 2.5.5. Other Peripheral clocks, Raspberry Pi RP1 Peripherals

よって、以下のように計算すればIBRDとFBRDの設定値が求まります。

let baudrate: u64 = 115200;
let peripheral_clock_hz:u64 = 54 * 1000 * 1000; // 54MHz
// 最初に1000倍して小数点以下3桁分を整数で計算する
let div_x1000:u64 = peripheral_clock_hz * 1000 / 16 / baudrate;

let ibrd:u32 = (div_x1000 / 1000) as u32;
let fbrd:u32 = (((div_x1000 % 1000) * 64 + 500) / 1000) as u32;

その他設定方法の詳細はPL011のマニュアルをご確認ください。

以上を行ったコードはこちら

github.com

初期化後の入出力方法は、FRレジスタで各種FLAGを確認しながらDRレジスタを読み書きするだけです。

github.com

バイナリの作成

Rustを使っていれば cargo objcopy で簡単にバイナリが生成できます。

詳しくは以下のリンカスクリプトMakefileを参照してください。

SDカードへのファイル書き込み

以下のファイルをFAT32でフォーマットしたSDカードに入れてください。

あとはPi 5にSDカードを入れて電源をONにすればGPIO端子からUARTの出力が出てくるはずなのですが、なぜかシリアル出力が確認できません。 同じコードをSWD経由で読み込むとシリアル出力が確認できるのでレジスタの設定ミスもなさそうですし、SDカードから起動した場合のみpanicしているなどの状況も確認できていません。

多分なにかを見逃している気がするので、気がついた方は教えていただけると嬉しいです。

Raspberry Pi 5のRP1に搭載されているPIOは今のところ簡単には使えないという話

私が海外でPi5発売されてから楽しみにしていたことの一つは「RP1に搭載されているPIOが使えるか」です。

画像は https://datasheets.raspberrypi.com/rp1/rp1-peripherals.pdf のFigure2,pp.6 より引用(一部加筆)

海外でのPi5発売とほぼ同時期に公開されたRP1のマニュアルをみると、Cortex-M3コアにPIOが接続されている様子が示されていました。 また、同マニュアルのpp.12よりRP1のコアクロックは200MHzらしいです。 (PicoのRP2040と同じなら)PIOはコアクロックと同じ速度で動くはずなので、理論値で最大100MHzくらいの信号を出力できるかもしれず、ワクワクしていました。

時が経ち2024年2月13日、ついにPi5が日本でも発売され、嬉しいことに1台購入することができました。 そこで早速RP1のPIOを使う方法を調べてみたのですが、どうやら今のところ使えなさそうとわかりました。

以下、調べたことをつらつらと書いていきます。

(PIOについては手前味噌ですが こちらの資料 をご参照ください)

なぜPIOは使えないのか

一言で言えば「PIO制御用ペリフェラルがPCIeから見えるメモリにmapされていないから」らしいです。

Pi5でPIOが使えるかはMichaelBellさんがすでに調査して共有してくれています。

rp1-hacking/PIO.md at main · MichaelBell/rp1-hacking · GitHub

こちらには以下のように書かれています。

The PIO registers are accessible at address 0xf000_0000 from the RP1. Additionally the FIFOs (only) are accessible at 0x40178000 and hence from Linux (at 0x1f_00178000). rp1-hacking/PIO.md at main · MichaelBell/rp1-hacking · GitHub より引用

つまり、LinuxからはPIOのFIFOしかアクセスできず、PIOの制御レジスタはRP1のプロセッサからしかアクセスできないメモリにいるようです。 本当にそうなっているのでしょうか?

メモリマップの調査

PCIeデバイスの制御は、デバイスの持つメモリをホストのメモリにマップし、そこにアクセスして行います(参考: https://osdev.jp/wiki/PCI-Memo)。

RP1はPCIe経由で接続されているので、RP1のメモリもホストのメモリの何処かにマップされています。

どこにマップされているかは、以下の起動時のログやドライバを眺めたり、mmapして実際にメモリを読んでみると、 RP1の 0x4000_0000 がホストの 0x1F_0000_0000 にマウントされているとわかります。

[    1.767860] pci 0000:01:00.0: BAR 1: assigned [mem 0x1f00000000-0x1f003fffff]
[    1.775026] pci 0000:01:00.0: BAR 2: assigned [mem 0x1f00400000-0x1f0040ffff]
[    1.782192] pci 0000:01:00.0: BAR 0: assigned [mem 0x1f00410000-0x1f00413fff]
...
[    1.836165] rp1 0000:01:00.0: bar0 len 0x4000, start 0x1f00410000, end 0x1f00413fff, flags, 0x40200
[    1.845252] rp1 0000:01:00.0: bar1 len 0x400000, start 0x1f00000000, end 0x1f003fffff, flags, 0x40200
[    1.854518] rp1 0000:01:00.0: enabling device (0000 -> 0002)
[    1.861119] rp1 0000:01:00.0: chip_id 0x20001927

具体的には以下のように調べていきました。

Linuxから見えるPIOレジスタ?のチェック

RP1のマニュアルによるとPIOはRP1のメモリの0x40178000にマップされているらしいです。 そこでLinuxから 0x1f_0017_8000 にアクセスしたところ、こんな値が読めました。

(余談: このメモリは32bitずつしか読み込めないです。8bitずつ読み込むと上位24bitが0xffになります。なぜ...?)

# 左が 0x40178000 からのoffset、右が読み込んだ32bit値

map ok
read pio registers
0x000000 : 00000000
0x000004 : 00000000
0x000008 : 00000000
0x00000c : 00000000
0x000010 : 418c2041
0x000014 : 2d8ef0c6
0x000018 : 42c753b3
0x00001c : d61f584b
0x000020 : 70696f33
0x000024 : 00000000
0x000028 : 00000001
0x00002c : 00000000
0x000030 : 00000000
0x000034 : 00000000
0x000038 : 00000001

MichaelBellさんの調査結果より、RP1のPIOのFSTATレジスタはオフセット0x04にいるはずです。 このレジスタには何らかの値が入っているはずなので、0が読み出されるのはなにか変です。 よって、少なくともこのアドレスにPIOの制御レジスタがないことは確かそうです。

0xF000_0000 にあると言われているPIOレジスタLinuxから読めないのか

RP1のマニュアルの「Table 1. Address Map summary」と「Table 2. Peripheral Address Map」を見る限り 0xF000_0000 は外部に見えるアドレスが割り当てられてなさそうなので、厳しそうです。

画像は https://datasheets.raspberrypi.com/rp1/rp1-peripherals.pdf のTable 1. Address Map summary,pp.7 より引用

以上より、今のところPIOはLinuxから簡単に扱えなさそうという結論になりました。

MichaelBellさんはどうやってPIOを使おうとしているか

フォーラムでの投稿やgithubのコードを見る限りでは、G33katWorkさんのRP1リバースエンジニアリングの成果を元にRP1でPIOを制御するコードを動かそうとされているようです。

GitHub - G33KatWork/RP1-Reverse-Engineering: Experiments on the RP1

rp1-hacking/launch_core1 at main · MichaelBell/rp1-hacking · GitHub

まだコードをきちんと理解できていないのですが、どうもShared SRAMに自作のコードを置いたあと、WatchDogを制御してリセットをかけてRP1のCore1で自作コードを動かしているみたいです。

このやり方についてRaspberry Pi エンジニアのjdbさんは以下のように言われています。

You are free to hack around with the hardware, but for anyone else wanting to experiment with this, heed these warnings: The RP1 firmware expects unfettered access to the entirety of shared SRAM, starting at 0x2000_0000. Arbitrary modification of any part of the memory region may cause side-effects up to and including loss of data and hardware damage. Using RP1 and PIO on Raspberry Pi 5 - Raspberry Pi Forums より引用

意訳すると「やるのは自由だけど、壊れるかもしれないから気をつけてね」という感じでしょうか。

その後の投稿で「core1もそのうち使えるようにする」と言われているので、それを気長に待つと良いのかなと思います。 (こういうHackやるようにメモリ1Gだけどお手頃価格なPi5出たら嬉しいなー)

おしまい

以上です。

参考文献

baremetal Rustでcritical-sectionを使うメモ

趣味でRustを使ってarm64向けのBaremetal開発を行っています。 今日はその環境でdefmtを使おうとして躓いたのでメモを残しておきます。

critical-section

defmt_rttはcritical-sectionというcrateに依存しています。

critical-sectionはRustでatomicな処理をエミュレーションするためなどに使うcrateです。 defmtではログの出力部にでも使われているのでしょう。

https://crates.io/crates/critical-section

critical-sectionをビルドするには一部メソッドの実装が必要です。 ただし、これらはcortex-mなどのbaremetal開発で使う主要なcrateには実装済みなので、自分で実装が必要になることはほぼないと思います。

cortex-m/cortex-m/src/critical_section.rs at master · rust-embedded/cortex-m · GitHub

今回Baremetal Arm64用の実装は見つけられなかったので、自分で実装することにしました。

ちなみに、critical-sectionの一部メソッドを実装せずにビルドすると、リンカが以下のようなエラーを出してビルドに失敗します。

 function `critical_section::release':
          /home/tnishinaga/.cargo/registry/src/index.crates.io-6f17d22bba15001f/critical-section-1.1.2/src/lib.rs:200: undefined reference to `_critical_section_1_0_release'

SingleCoreCriticalSection

自分でcritical-section用の処理を実装する方法は、critical-sectionのREADMEにかかれています。

# critical-section v1.1.2, READMEより引用
# https://github.com/rust-embedded/critical-section/tree/7ac14a65144bea29eed06947f47b9aef51512cf4

# #[cfg(not(feature = "std"))] // needed for `cargo test --features std`
# mod no_std {
// This is a type alias for the enabled `restore-state-*` feature.
// For example, it is `bool` if you enable `restore-state-bool`.
use critical_section::RawRestoreState;

struct MyCriticalSection;
critical_section::set_impl!(MyCriticalSection);

unsafe impl critical_section::Impl for MyCriticalSection {
    unsafe fn acquire() -> RawRestoreState {
        // TODO
    }

    unsafe fn release(token: RawRestoreState) {
        // TODO
    }
}
# }

single coreでの実装例は以下です。 acquireで割り込みを禁止し、releaseで割り込みを再度有効化しています。 シングルコアで割り込み禁止して、atomicなメモリアクセスをエミュレーションしているようです。

// https://github.com/rust-embedded/cortex-m/blob/master/cortex-m/src/critical_section.rs より引用


use critical_section::{set_impl, Impl, RawRestoreState};

use crate::interrupt;
use crate::register::primask;

struct SingleCoreCriticalSection;
set_impl!(SingleCoreCriticalSection);

unsafe impl Impl for SingleCoreCriticalSection {
    unsafe fn acquire() -> RawRestoreState {
        let was_active = primask::read().is_active();
        interrupt::disable();
        was_active
    }

    unsafe fn release(was_active: RawRestoreState) {
        // Only re-enable interrupts if they were enabled before the critical section.
        if was_active {
            interrupt::enable()
        }
    }
}

ところで、私の作っているコードはまだ割り込み周りの実装を行っていないので割り込みは常に無効状態で、シングルコアでしか動かしていません。 ということは、acquireとreleaseの両方で何もしなくても大丈夫なはずです。 よって、ほぼサンプルそのままのコードを実装してcritical-sectionの実装は完了ということにしました。

以上です。

iperf3の実装を読んでRustで簡易版を実装してみた

iperfを使うことはあっても中の通信がどうなっているのか知らなかったので、調べてみました。

自作のコードで簡単なスループット計測が行えることをゴールとします。

環境

今回の調査はm1 mac book上でiperfを実行し、その通信内容をwiresharkで見ながら行いました。

iperfのバージョンは以下になります。

~ ❯❯❯ iperf3-darwin -v
iperf 3.8.1 -  -- Apple version iperf3-107 (cJSON 1.7.13)
Darwin hostname.local 23.1.0 Darwin Kernel Version 23.1.0: Mon Oct  9 21:28:12 PDT 2023; root:xnu-10002.41.9~6/RELEASE_ARM64_T8103 arm64
Optional features available: sendfile / zerocopy

コードは2023/12/22時点のものを参照しました。

github.com

通信の概要

通信手順の概要は以下のようになっています。(図がなくてすみません)。

  1. クライアントからサーバーにテストの設定を送る
  2. サーバーからクライアントにデータ送信開始の指示を出す
  3. クライアントからサーバーに一定量データを送る
  4. 結果をお互いに交換する

もう少し細かい手順を以下に示します。 cはclient、sはserver、矢印はどちら向きの通信かを表しています。 iperf3のテストではコマンド送受信用(cmd)とデータ通信用(data)の2つのコネクションを使用します。 そのため、どちらのコネクションを利用するかを通信方向の後に記します。

  1. c->s, cmd: Cookie(セッション識別用の文字列)を送る
  2. s->c, cmd: Param exchange(9)を送る
  3. c->s, cmd: 設定の書かれたJSONファイルサイズ(32bit, big endian)を送る
  4. c->s, cmd: JSONファイルの中身を送る
  5. s->c, cmd: CREATE_STREAMS(10)を送る
  6. c->s, data: データ通信用のコネクションを作り、そこから手順1と同一のCookieを送る
  7. s->c, cmd: TEST_START(1)を送る
  8. s->c, cmd: TEST_RUNNING(2)を送る
  9. c->s, data: データを送る
  10. c->s, cmd: TEST_END(4)を送る
  11. c->s, cmd: EXCHANGE_RESULTS(13)を送る
  12. c->s, cmd: JSONのファイルサイズ(32bit, big endian)をおくる
  13. c->s, cmd: 計測結果のJSONファイルを送る
  14. s->c, cmd: JSONのファイルサイズ(32bit, big endian)をおくる
  15. s->c, cmd: 計測結果のJSONファイルを送る
  16. c->s, cmd: IPERF_DONE(16)をおくる

以降、更に詳細について見ていきます。

コマンド

iperf3は1byteのコマンド(正確にはiperfのstate)を送って制御を行っています。

ここで使うコマンドは iperf_api.h の107行目付近に定義されています。

github.com

iperf3ではテストの識別のため、cookieと呼ばれるランダムな文字列を使います。 このCookieabcdefghijklmnopqrstuvwxyz234567 の中からランダムに選ばれた36文字と空文字の合計37byteで構成されています(参考元: iperf/src/iperf_util.c at ec06f7b43854153044c0a5e9ea2845e07262dcf8 · esnet/iperf · GitHub)。

文字テーブルから 01 が外されている理由は不明です。 理由をご存じの方がいたら教えてください。

設定のJSON

テストの序盤ではクライアントからサーバーに設定をJSONで送ります。 実際にiperf3-darwin -c localhost コマンドを実行すると以下のようなJSONが送られます。

{
    "tcp": true,
    "omit": 0,
    "time": 10,
    "parallel": 1,
    "len": 131072,
    "pacing_timer": 1000,
    "client_version": "3.8.1"
}

各設定の種類や詳細については send_parameters関数 から読んでいくと良さそうです。

最低限動けば良いレベルであれば、テスト時間を示すtimeとTCPのブロックサイズを決めるlenだけ注意していれば良かったです。 それ以外のomitやpacing_timerについては、どの部分に影響のある設定か把握しきれていません。

結果のJSON

計測が終わった後、サーバーとクライアントの両方で計測した結果を交換します。

実際にやり取りされているJSONはこんな感じになっています。

{
    "cpu_util_total": 85.569230769230771,
    "cpu_util_user": 10.461538461538462,
    "cpu_util_system": 75.1076923076923,
    "sender_has_retransmits": 1,
    "streams": [
        {
            "id": 1,
            "bytes": 506824,
            "retransmits": 0,
            "jitter": 0,
            "errors": 0,
            "packets": 0,
            "start_time": 0,
            "end_time": 0.000238
        }
    ]
}

streamsの中身は配列になっており、idは1番開始になっています。 オリジナルの実装では1秒毎の計測結果をstreamsに入れて送っているようですが、n秒分まとめて送っても受け付けてもらえました。

iperf/src/iperf_api.c at ec06f7b43854153044c0a5e9ea2845e07262dcf8 · esnet/iperf · GitHub

サンプルコードの実装と動作確認

以上でなんとなく仕組みがわかったので、簡単なiperf3クライアントをRustで実装してみました。 とりあえず動くこと優先で作ったので、コードが汚いのはご了承ください。

// main.rs

use heapless;
use log::debug;
use num_enum::{FromPrimitive, IntoPrimitive};
use rand::{Rng, RngCore, SeedableRng};
use rand_xorshift;
use serde::{Deserialize, Serialize};

const DEFAULT_TCP_BLOCKSIZE: usize = 128 * 1024;

#[derive(IntoPrimitive, FromPrimitive, Debug)]
#[repr(u8)]
enum IperfApi {
    TestStart = 1,
    TestRunning = 2,
    ResultRequest = 3,
    TestEnd = 4,
    StreamBegin = 5,
    StreamRunning = 6,
    StreamEnd = 7,
    AllStreamsEnd = 8,
    ParamExchange = 9,
    CreateStreams = 10,
    ServerTerminate = 11,
    ClientTerminate = 12,
    ExchangeResults = 13,
    DisplayResults = 14,
    IperfStart = 15,
    IperfDone = 16,
    #[num_enum(default)]
    Unknown,
}

#[derive(Serialize, Deserialize)]
struct IperfConfig {
    tcp: bool,
    omit: i32,
    time: i32,
    parallel: isize,
    /// block size
    len: usize,
    /// pacing timer in microseconds
    pacing_timer: i32,
    client_version: &'static str,
}

impl Default for IperfConfig {
    fn default() -> Self {
        Self {
            tcp: true,
            omit: 0,
            time: 10,
            parallel: 1,
            /// DEFAULT_TCP_BLOCKSIZE
            len: DEFAULT_TCP_BLOCKSIZE,
            /// DEFAULT_PACING_TIMER
            pacing_timer: 1000,
            client_version: "3.8.1",
        }
    }
}

#[derive(Serialize, Deserialize, Default, Debug)]
struct IperfResult<const N: usize> {
    cpu_util_total: f64,
    cpu_util_user: f64,
    cpu_util_system: f64,
    sender_has_retransmits: i32,
    streams: heapless::Vec<IperfStreamResult, N>,
}

#[derive(Serialize, Deserialize, Default, Debug)]
struct IperfStreamResult {
    id: i32,
    bytes: u64,
    retransmits: i32,
    jitter: i32,
    errors: i32,
    packets: i32,
    start_time: f64,
    end_time: f64,
}

use rand::distributions::Alphanumeric;
use std::error::Error;
use std::time::{Duration, SystemTime};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpSocket, TcpStream};

fn make_cookie(cookie: &mut [u8; 37], seed: u64) {
    let mut rng = rand_xorshift::XorShiftRng::seed_from_u64(seed);
    // do not forget '\0' termination
    // TODO: choose from "abcdefghijklmnopqrstuvwxyz234567"
    // ref: make_cookie function
    let (last, c) = cookie.split_last_mut().unwrap();
    c.iter_mut().for_each(|x| *x = rng.sample(Alphanumeric));
    *last = b'\0';
    debug!("cookie: {:?}", String::from_utf8(cookie.to_vec()).unwrap());
}

async fn iperf3_client() -> Result<(), Box<dyn Error>> {
    // Overview
    //  1. c->s Cookie(セッション識別用の文字列)を送る
    //  2. s->c Param exchange(9)を送る
    //  3. c->s 設定の書かれたJSONファイルのサイズを4byteで送る
    //  4. c->s JSONファイルの中身を送る
    //  5. s->c CREATE_STREAMS(10)を送る
    //  6. c->s Cookieを送る
    //  7. s->c TEST_START(1)を送る
    //  8. s->c TEST_RUNNING(2)を送る
    //  9. c->s データを送る
    //  10. c->s TEST_END(4)を送る
    //  11. s->c EXCHANGE_RESULTS(13)を送る
    //  12. c->s JSONのファイルサイズを4byteでおくる
    //  13. c->s 計測結果のJSONファイルを送る
    //  14. s->c JSONのファイルサイズを4byteでおくる
    //  15. s->c 計測結果のJSONファイルを送る
    //  16. c->s IPERF_DONE(16)をおくる

    // Connect to a peer
    let mut command_stream = TcpStream::connect("127.0.0.1:5201").await?;
    let addr = command_stream.local_addr().unwrap();
    println!("command_stream addr = {:?}", addr);

    let seed = SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .unwrap()
        .as_secs();
    let mut rng = rand_xorshift::XorShiftRng::seed_from_u64(seed);

    // create cookie
    let mut cookie = [0u8; 37];
    make_cookie(&mut cookie, seed);

    command_stream.write(&cookie).await?;

    let mut ack = [0u8; 1];
    command_stream.read(&mut ack).await?;

    match IperfApi::try_from(ack[0]).unwrap() {
        IperfApi::ParamExchange => (),
        _ => unimplemented!(),
    }

    // crate json
    let config = IperfConfig::default();
    let config_str = serde_json_core::to_string::<IperfConfig, 512>(&config)
        .unwrap()
        .to_string();
    let config_str_size = u32::try_from(config_str.len()).unwrap();
    println!("config_str: {:?}", config_str);

    // send json size
    command_stream.write(&config_str_size.to_be_bytes()).await?;
    // send json
    command_stream.write(config_str.as_bytes()).await?;

    // read command
    let mut ack = [0u8; 1];
    command_stream.read(&mut ack).await?;
    match IperfApi::try_from(ack[0]).unwrap() {
        IperfApi::CreateStreams => (),
        _ => unimplemented!(),
    }
    println!("got CreateStreams");

    let mut data_stream = TcpStream::connect("127.0.0.1:5201").await?;

    // resend cookie
    println!("send cookie to data stream");
    data_stream.write(&cookie).await?;

    let addr = data_stream.local_addr().unwrap();
    println!("data stream addr = {:?}", addr);

    // get test start
    println!("waiting to receive TestStart");
    let mut ack = [0u8; 1];
    command_stream.read(&mut ack).await?;
    match IperfApi::try_from(ack[0]).unwrap() {
        IperfApi::TestStart => (),
        _ => unimplemented!(),
    }

    println!("waiting to receive TestRunning");
    let mut ack = [0u8; 1];
    command_stream.read(&mut ack).await?;
    match IperfApi::try_from(ack[0]).unwrap() {
        IperfApi::TestRunning => (),
        _ => unimplemented!(),
    }

    let mut stream_result = IperfStreamResult::default();
    stream_result.id = 1;

    let start_time = SystemTime::now();
    while start_time.elapsed()?.as_secs() < config.time.try_into().unwrap() {
        // send data
        // ref: https://hanya-orz.hatenablog.com/entry/2020/08/05/131158
        let mut data = [0; DEFAULT_TCP_BLOCKSIZE];
        rng.fill_bytes(&mut data);
        let _n = data_stream.write_all(&data).await?;
        // stream_result.bytes += u64::try_from(n).unwrap();
        stream_result.bytes += u64::try_from(DEFAULT_TCP_BLOCKSIZE).unwrap();
    }
    let end_time = SystemTime::now();

    stream_result.end_time = end_time.duration_since(start_time).unwrap().as_secs_f64();

    command_stream.write(&[IperfApi::TestEnd.into()]).await?;

    let mut ack = [0u8; 1];
    command_stream.read(&mut ack).await?;
    match IperfApi::try_from(ack[0]).unwrap() {
        IperfApi::ExchangeResults => (),
        _ => unimplemented!(),
    }
    println!("got ExchangeResults");

    let mut result: IperfResult<1> = IperfResult::default();
    result.streams.push(stream_result).unwrap();
    let result_str = serde_json_core::to_string::<IperfResult<1>, 512>(&result).unwrap();
    let result_str_size = u32::try_from(result_str.len()).unwrap();
    println!("result_str: {:?}", result_str);

    // send json size
    command_stream.write(&result_str_size.to_be_bytes()).await?;
    command_stream.flush().await?;
    // send json
    command_stream.write(result_str.as_bytes()).await?;

    // read result length
    let mut server_result_size_buf = [0u8; 4];
    command_stream.read(&mut server_result_size_buf).await?;
    let mut buf = [0; 512];
    command_stream.read(&mut buf).await?;

    command_stream.write(&[IperfApi::IperfDone.into()]).await?;

    Ok(())
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    iperf3_client().await.unwrap();

    Ok(())
}
# Cargo.toml

[package]
name = "iperf3-rs"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
heapless = { version = "0.8.0", features = ["serde"] }
log = { version = "0.4.20", default-features = false }
num_enum = { version = "0.7.1", default-features = false }
rand = { version = "0.8.5", default-features = false }
rand_xorshift = "0.3.0"
# rust-fsm = { version = "0.6.1", default-features = false, features = ["dsl"] }
serde = { version = "1.0.193", default-features = false, features = [
    "derive",
    "serde_derive",
] }
serde-json-core = "0.5.1"
tokio = { version = "1", features = ["full"] }

実行結果

上記コードを実行したところ、とりあえずエラーを出さずテストを完了できました。 結果は大体20Gbps程度でした。

-----------------------------------------------------------
Server listening on 5201
-----------------------------------------------------------
Accepted connection from 127.0.0.1, port 54195
[  5] local 127.0.0.1 port 5201 connected to 127.0.0.1 port 54196
[ ID] Interval           Transfer     Bitrate         Rwnd
[  5]   0.00-1.00   sec  2.27 GBytes  19.5 Gbits/sec  1.22 MBytes
[  5]   1.00-2.00   sec  2.33 GBytes  20.0 Gbits/sec  1.22 MBytes
[  5]   2.00-3.00   sec  2.27 GBytes  19.5 Gbits/sec  2.24 MBytes
[  5]   3.00-4.00   sec  2.28 GBytes  19.6 Gbits/sec  2.24 MBytes
[  5]   4.00-5.00   sec  2.27 GBytes  19.5 Gbits/sec  2.24 MBytes
[  5]   5.00-6.00   sec  2.32 GBytes  20.0 Gbits/sec  2.24 MBytes
[  5]   6.00-7.00   sec  2.30 GBytes  19.8 Gbits/sec  2.24 MBytes
[  5]   7.00-8.00   sec  2.33 GBytes  20.0 Gbits/sec  2.24 MBytes
[  5]   8.00-9.00   sec  2.30 GBytes  19.7 Gbits/sec  2.24 MBytes
[  5]   9.00-10.00  sec  2.33 GBytes  20.0 Gbits/sec  2.24 MBytes
[  5]  10.00-10.00  sec   128 KBytes  9.36 Gbits/sec  2.12 MBytes
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval           Transfer     Bitrate
[  5]   0.00-10.00  sec  23.0 GBytes  19.8 Gbits/sec                  receiver

ちなみに本家iperf3を使うと117Gbps程度出たので、約6倍程度の性能差があります。

~ ❯❯❯ iperf3-darwin -c 127.0.0.1
Connecting to host 127.0.0.1, port 5201
[  5] local 127.0.0.1 port 54220 connected to 127.0.0.1 port 5201
[ ID] Interval           Transfer     Bitrate         Retr  Cwnd          RTT
[  5]   0.00-1.00   sec  13.3 GBytes   114 Gbits/sec    0   4.00 MBytes   1ms
[  5]   1.00-2.00   sec  13.7 GBytes   117 Gbits/sec    0   4.00 MBytes   1ms
[  5]   2.00-3.00   sec  13.7 GBytes   118 Gbits/sec    0   4.00 MBytes   1ms
[  5]   3.00-4.00   sec  13.6 GBytes   117 Gbits/sec    0   4.00 MBytes   1ms
[  5]   4.00-5.00   sec  13.5 GBytes   116 Gbits/sec    0   4.00 MBytes   1ms
[  5]   5.00-6.00   sec  13.6 GBytes   117 Gbits/sec    0   4.00 MBytes   1ms
[  5]   6.00-7.00   sec  13.7 GBytes   117 Gbits/sec    0   4.00 MBytes   1ms
[  5]   7.00-8.00   sec  13.7 GBytes   117 Gbits/sec    0   4.00 MBytes   1ms
[  5]   8.00-9.00   sec  13.5 GBytes   116 Gbits/sec    0   4.00 MBytes   1ms
[  5]   9.00-10.00  sec  13.7 GBytes   117 Gbits/sec    0   4.00 MBytes   1ms
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec   136 GBytes   117 Gbits/sec    0             sender
[  5]   0.00-10.00  sec   136 GBytes   117 Gbits/sec                  receiver

iperf Done.

もうちょっとiperfの設計や実装を理解して真似すると速くなるのかもしれません。

以上です。