モダン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_*
利用時は必ずPUBLIC/PRIVATE/INTERFACE scope キーワードを設定する- 変数を使いすぎない && macroよりfunctionを使う
- headerをsource codeのように扱う
- globalなincludeディレクトリを作らず、targetごとに作ってlinkで渡す
- 外部に出すheaderはinstallで出す
- ライブラリのInterfaceはInterface Libraryとしてエクスポートする
- https://gist.github.com/mbinna/c61dbb39bca0e4fb7d1f73b0d66a4fd1#export-your-librarys-interface-if-you-are-a-library-author
非推奨設定は一部の例外を除いて利用しない
以下に示す設定は、暗黙的な依存関係を作り、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のパスとか)。
参考資料引用部
下記コマンドはターゲットに関わらず設定してしまうため使うべきではありません。 https://qiita.com/shohirose/items/5b406f060cd5557814e9#%E7%8F%BE%E5%9C%A8%E3%81%AF%E9%9D%9E%E6%8E%A8%E5%A5%A8%E3%81%A8%E3%81%AA%E3%81%A3%E3%81%A6%E3%81%84%E3%82%8B%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89
Those commands operate on the directory level. All targets defined on that level inherit those properties. This increases the chance of hidden dependencies. Better operate on the targets directly. https://gist.github.com/mbinna/c61dbb39bca0e4fb7d1f73b0d66a4fd1#forget-the-commands-add_compile_options-include_directories-link_directories-link_libraries
target_*
利用時は必ずPUBLIC/PRIVATE/INTERFACE scope キーワードを設定する
target_*
の設定を行う際の PUBLIC/PRIVATE/INTERFACE
キーワードは省略もできますが、これを明示的に行うことで意図しない依存関係を作る可能性を減らしましょうというbest practiceです。
参考資料引用部
Always explicitly declare properties PUBLIC, PRIVATE, or INTERFACE when using target*. https://gist.github.com/mbinna/c61dbb39bca0e4fb7d1f73b0d66a4fd1#always-explicitly-declare-properties-public-private-or-interface-when-using-target
変数を使いすぎない && 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の呼び出し元にセットできます。
参考資料引用部
setを使用して定義される変数には以下のような問題点があります。 他の文脈へリークしやすい(定義した以降のあらゆる場所で使用可能) https://qiita.com/shohirose/items/5b406f060cd5557814e9#%E5%A4%89%E6%95%B0%E3%82%92%E4%BD%BF%E3%81%84%E9%81%8E%E3%81%8E%E3%81%AA%E3%81%84
Prefer functions over macros whenever reasonable. In addition to directory-based scope, CMake functions have their own scope. This means variables set inside functions are not visible in the parent scope. This is not true of macros. https://gist.github.com/mbinna/c61dbb39bca0e4fb7d1f73b0d66a4fd1#prefer-functions-over-macros-whenever-reasonable
Use function instead of macro Macro overrides variables in caller’s variable scope. Use function which has an own variable scope. https://fujii.github.io/2015/10/10/cmake-best-practice/
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を使ったテストの方法とか、色々書けることはありますが力尽きたのでおしまいです。
はやくビルドシステムで疲弊しないですむ世界になってほしいですね。
参考資料
- CMakeスクリプトを作成する際のガイドライン, @shiohirose
- CMake Best Practices, fujii
- CMake: Best Practices, Henry Schreiner, 2021-2-2
- Effective CMake a random section of best practices, Daniel Pfeifer, May 19, 2017
- https://github.com/boostcon/cppnow_presentations_2017/blob/master/05-19-2017_friday/effective_cmakedaniel_pfeifercppnow_05-19-2017.pdf
- Effective Modern CMake, mbinna
- その他色々(読んだことを思い出したら追記します)
C言語のbitfieldの仕様と実装を調査した話
C言語にはbitfieldという機能がありますが、この機能にはさまざまな落とし穴があり、注意深く利用しても予期せぬバグや移植性の問題を引き起こす可能性があるとよく言われています。 実際、jpcertを確認すると複数の勧告が出ています。
- EXP11-C. ビットフィールド構造体のレイアウトについて勝手な想定をしない
- INT12-C. 式中で使用される単なるintのビットフィールドの型について勝手な想定をしない
- CON32-C. 複数スレッドによる隣接データへのアクセスが必要な場合データ競合を防止する
- DCL39-C. 信頼境界を越えて構造体を渡すとき情報漏えいしない
私は先輩技術者から「とくに組み込みの分野において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言語仕様で実装依存と書かれていた部分の仕様を決めています。
ここから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.
和訳は以下。
- plainなintのbitfieldがsigned int bitfieldにとして扱うかunsigned bitfieldとして扱うか
- デフォルトはsigned int bitfieldとして扱う。ただし
-funsigned-bitfields
オプションで変更できる
- デフォルトはsigned int bitfieldとして扱う。ただし
- 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について見ていきます。
- コンテナー型の決定
- ビットフィールドが宣言された整数型に収まる場合
- 整数型がコンテナー型になる
- 収まらない場合
- ビットフィールドのサイズを超えない程度に最大の整数型がコンテナー型になる
- オーバーサイズビットフィールドの項を参照
- ビットフィールドが宣言された整数型に収まる場合
- ビットフィールドがコンテナサイズより大きくない場合(10.1.8.1 Bit-fields no larger than their container)
- コンテナーのアドレスはコンテナーの型のサイズでアラインされる
- bit offsetマナーはエンディアン依存
- bitfieldは以下に依存してシフトおよびマスクを行い、必要なら符号拡張をしてコンテナーから取り出される
- バイトオーダー
- コンテナサイズ
- フィールド幅
- (抽出式は 10.1.8.2 Bit-field extraction expressions を参照)
bitの並び順がエンディアン依存であることや、取り出し方法や抽出式まで書かれている点が特徴的です。
RISC-V
今回見た中でもっとも新しいアーキテクチャでして、bitfieldに関する仕様が導入されたのが2018年頃、今の形になったのが2024年となっています。
- bitfield仕様に関するIssue等
決まっている内容もARMに比べるとシンプルです。
以下、ドキュメントにかかれている内容のまとめです。
- ビットフィールドはリトルエンディアンでpackされる
- ビットフィールドがその整数型のアライメント境界をまたぐ場合、そのビットフィールドは次のアライメント境界から始まるようにパディングされる
- ビットフィールドのサイズがその整数型のサイズを超える場合、その超える分はパディングとして扱われ、次のビットフィールドは次のアライメント境界から配置される
- (整数型のサイズを超えるbitfieldを作ると、超えた分のビットはundefinedとなる)
bitfieldの仕様についてのまとめ
psABI依存の部分はアーキテクチャごとに細かい差分はありますが、おおむね以下の部分は共通していそうです。
つまり、CPUアーキテクチャのint型のビット幅以下のbitfieldを宣言して、そこに隙間のない(paddingがない)ピッタリのビットを定義すれば、コンパイラやアーキテクチャが変わっても仕様上は想定外のレイアウトにならないはずです。 しかしそれ以外の場合については各アーキテクチャの仕様とコンパイラの実装を確認する必要があるでしょう。 また、この挙動は(めったにないことですが)ABI仕様が変更された場合*1に変わる可能性も考慮が必要かもしれません。
実装依存部の確認
bitfieldの問題としてあげた以下の2点はコンパイラの実装や最適化オプションによって挙動が変わる部分となります。
よって、実際に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
安牌をとるのであれば、伝統的なシフトとマスクを用いた方法を使うべきでしょう。
伝統的なシフトとマスクによる方法と比べるとパフォーマンスが低下する可能性がある
構造体のビットメンバは実用上の欠点がある。
...(中略)...
多くの一般的なコンパイラは、ビットメンバの読み書きに対して、非効率なコードを生成する(プロセッサのワード単位でロード/ストアするほうが効率的)。
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 *)® }
# 生成されたコード 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)を適切に利用して機能を実現します。 具体的には以下のライブラリを用います。
- bit flagが作りたい場合
- MMIOを読み書きしたい場合
- 普通のbitfieldを作りたい場合
- 以下のどれかを使う(個人的にはrust-bitfieldを使っている)
Rustではbitfieldの機能を外部ライブラリが提供しているので、bitfieldの挙動はライブラリの仕様や実装のみで決められます。 これにより、実装依存や未定義動作の問題を減らせたり、移植性の向上や、テストが書きやすくなるなどのメリットを得られるでしょう。
EOF
おしまい
Rustでマイコン(no_std)環境のエラー設計を検討する
背景
趣味で作っている途中の、Rustで書かれたマイコン(no_std環境)で動作するJTAGデバッガアプリケーションのエラー設計を考えています。
今作っているJTAGデバッガアプリケーションでは、インターフェイス・JTAG・DAPなどの階層に分けた構造になるよう設計しています。 この設計に加えてそれぞれの階層の接続部を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を一言で表すと「大本のエラーに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 occursとHow 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より)。
- エラーの発生場所(Location)とエラーの変換元(source)を内包するError型
- そのError型から必要な情報を取り出すためのStackError trait
- Error型にStackError traitを簡単に実装するためのstack_trace_debugマクロ
- procマクロ参考用URL
今回注目したいのは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で実行した結果が出てきます。
実装は以下の2つを用意しています。
- 独自実装(error_manual.rs)
- snafuを使った実装(error_snafu.rs)
独自実装(manual)
StackErrorの説明のところで出したコードほぼそのままです。
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より便利なのかなと思っています。
今回は調べが足りず活用できていませんが、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しか受け付けてくれないという問題に直面しました。コードはこちら
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>
を使うサンプルを追加しました。
エラー型のサイズを出すコードも入れて試した結果、エラー型のサイズが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の設定
- 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.pdfの 2.3.1. PCIe and 40-bit to peripheral address mapping
の Table 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.pdf の Table 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のマニュアルをご確認ください。
以上を行ったコードはこちら
初期化後の入出力方法は、FRレジスタで各種FLAGを確認しながらDRレジスタを読み書きするだけです。
バイナリの作成
Rustを使っていれば cargo objcopy
で簡単にバイナリが生成できます。
詳しくは以下のリンカスクリプトとMakefileを参照してください。
- https://github.com/tnishinaga/pi5_hack/blob/develop/baremetal/XX_easy_rp1_uart/ldscript.lds
- https://github.com/tnishinaga/pi5_hack/blob/develop/baremetal/XX_easy_rp1_uart/Makefile
SDカードへのファイル書き込み
以下のファイルをFAT32でフォーマットしたSDカードに入れてください。
- https://github.com/raspberrypi/firmware/blob/master/boot/bcm2712-rpi-5-b.dtb
- https://github.com/tnishinaga/pi5_hack/blob/develop/baremetal/XX_easy_rp1_uart/config.txt
- ビルドした jtag_kernel.bin
あとは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/drivers/mfd/rp1.c at 77fc1fbcb5c013329af9583307dd1ff3cd4752aa · raspberrypi/linux · GitHub
- linux/include/dt-bindings/mfd/rp1.h at 77fc1fbcb5c013329af9583307dd1ff3cd4752aa · raspberrypi/linux · GitHub
具体的には以下のように調べていきました。
- dmesgより、RP1のメモリは
0x1f00410000
と0x1f00000000
にマップされていそう - dmesgと
mfd/rp1.c
より、chip_idはbase + RP1_SYSINFO_BASE
を読んで取得していそう つまり、どっちかのメモリのoffset 0番地をmmapして読み込んで
0x20001927
が得られたほうがRP1の0x4000_0000
にマップされているはず0x1F_0000_0000
を読んだときに0x20001927が見えたので0x4000.0000(proc addr) <-> 0x1f_0000_0000(Linux VA?)
で確定
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時点のものを参照しました。
通信の概要
通信手順の概要は以下のようになっています。(図がなくてすみません)。
- クライアントからサーバーにテストの設定を送る
- サーバーからクライアントにデータ送信開始の指示を出す
- クライアントからサーバーに一定量データを送る
- 結果をお互いに交換する
もう少し細かい手順を以下に示します。 cはclient、sはserver、矢印はどちら向きの通信かを表しています。 iperf3のテストではコマンド送受信用(cmd)とデータ通信用(data)の2つのコネクションを使用します。 そのため、どちらのコネクションを利用するかを通信方向の後に記します。
- c->s, cmd: Cookie(セッション識別用の文字列)を送る
- s->c, cmd: Param exchange(9)を送る
- c->s, cmd: 設定の書かれたJSONファイルサイズ(32bit, big endian)を送る
- c->s, cmd: JSONファイルの中身を送る
- s->c, cmd: CREATE_STREAMS(10)を送る
- c->s, data: データ通信用のコネクションを作り、そこから手順1と同一のCookieを送る
- s->c, cmd: TEST_START(1)を送る
- s->c, cmd: TEST_RUNNING(2)を送る
- c->s, data: データを送る
- c->s, cmd: TEST_END(4)を送る
- c->s, cmd: EXCHANGE_RESULTS(13)を送る
- c->s, cmd: JSONのファイルサイズ(32bit, big endian)をおくる
- c->s, cmd: 計測結果のJSONファイルを送る
- s->c, cmd: JSONのファイルサイズ(32bit, big endian)をおくる
- s->c, cmd: 計測結果のJSONファイルを送る
- c->s, cmd: IPERF_DONE(16)をおくる
以降、更に詳細について見ていきます。
コマンド
iperf3は1byteのコマンド(正確にはiperfのstate)を送って制御を行っています。
ここで使うコマンドは iperf_api.h
の107行目付近に定義されています。
Cookie
iperf3ではテストの識別のため、cookieと呼ばれるランダムな文字列を使います。
このCookieは abcdefghijklmnopqrstuvwxyz234567
の中からランダムに選ばれた36文字と空文字の合計37byteで構成されています(参考元: iperf/src/iperf_util.c at ec06f7b43854153044c0a5e9ea2845e07262dcf8 · esnet/iperf · GitHub)。
文字テーブルから 0
と 1
が外されている理由は不明です。
理由をご存じの方がいたら教えてください。
設定の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の設計や実装を理解して真似すると速くなるのかもしれません。
以上です。