Raspberry Pi Pico 2 を入手したので動作確認する (浮動小数点演算編)
Raspberry Pi Pico 2 (以下 Pico 2) では CPU コアが Cortex-M33 となり、FPU (浮動小数点演算処理装置) が利用できるようになりました。これは個人的にうれしいポイントです。そこで本稿では、浮動小数点演算を行うサンプルプログラムを作り、(疑うわけじゃありませんが) 本当に FPU を利用するコードが生成されるのかを確認します。つまり Pico 2 の動作確認というよりは GCC の検証ということになります。
目次
概要
Cortex-M33 の FPU は Armv8-M Floating-point extension (FPv5) アーキテクチャの実装であり、IEEE 754 に準拠した単精度の浮動小数点演算をサポートします [1]。GCC では浮動小数点演算に関するオプションとして-mfloat-abi
があり、この値によって FPU を使ったコードが生成されるか否かが決まります。そこで本稿では、Pico SDK を使って Pico 2 (Cortex-M33) 向けのサンプルアプリケーションを作成し、-mfloat-abi
の値をいろいろ変えてコンパイル結果を比較してみたいと思います。
確認に用いた開発環境とバージョン
- OS: FreeBSD 13.3-RELEASE-p5 GENERIC amd64
- arm-none-eabi-binutils 2.43
- arm-none-eabi-gcc 14.2.0
- arm-none-eabi-newlib 4.4.0.20231231
- gmake 4.4.1
- Pico SDK 2.0.0
浮動小数点に関するコンパイル・オプション
GCC の ARM 特有のコンパイル・オプションは [2] に記載されています。浮動小数点演算に関して重要なのは-mfloat-abi
です。このオプションは次の値のいずれかを取ります。
Pico SDK を Pico 2 向けにビルドする (softfp
-DPICO_BOARD=pico2
) とデフォルトでこのオプションになります。一見すると FPU を使用せずソフトウェア (ランタイム・ライブラリ) を使用するオプションに見えますがそうではなく、「FPU は使用するが、関数の呼び出し規約 (引数と戻り値) には汎用レジスタを使用する」というオプションです。
FPU を使用し、かつ関数の呼び出し規約に FPU レジスタを使用するオプションです。hard
FPU を使用せず、ソフトウェア (ランタイム・ライブラリ) を使用するオプションです。soft
もうひとつ、同ドキュメントに記載されている中で関係ありそうなのは-mfpu
です。RP2350 ではfpv5-sp-d16
を指定するのが適切かと思いますが、省略すれば-mcpu
と-march
から自動的に判断されるようです。実際、このオプションの有無ではコンパイル結果は変わらなかったので、以降、このオプションには触れません。ちなみに「sp」は single-precision つまり単精度を表し、「d16」は (32 ビットの単精度レジスタ 32 本を 64 ビットの倍精度レジスタ 16 本と見なして) 倍精度レジスタを 16 本持つということを表すようです。
実験に用いたサンプルプログラム
浮動小数点演算のサンプルプログラムとして、与えられた円の半径から面積を計算する Pico SDK アプリケーションを作ります。開発環境の構築方法については前回の記事を参照してください。また、Pico SDK を使った独自アプリケーションの作りかたについては [3] を参照してください。
ディレクトリ構成は次のようにしました。
~/pico
├── fptest
│ ├── circle.c
│ ├── CMakeLists.txt
│ ├── main.c
│ └── pico_sdk_import.cmake
└── pico-sdk
pico_sdk_import.cmake は pico-sdk/external/ からコピーしてきます。その他のファイルの中身は次のとおりです。
#include "pico/stdlib.h"
float circle(float r);
int main() {
stdio_init_all();
volatile float s = circle(1.0f);
while (1) {
sleep_ms(1000);
}
}
float circle(float r)
{
return r * r * 3.141592f;
}
circle() 関数をわざわざ別のファイルに分けているのは、関数がインライン展開されてしまう?のを避けるためです。main.c に一緒に書いておいたところ、強制的にインライン展開されてしまいました (私が最適化についてよく知らないだけだと思いますが…最適化オプションを切ったりしてみたけど、だめっぽい)。
cmake_minimum_required(VERSION 3.13)
# set path to tools.
set(tools $ENV{HOME}/pico/arm-none-eabi)
if (PICO_BOARD)
set(tools $ENV{HOME}/${PICO_BOARD}/arm-none-eabi)
endif()
set(PICO_TOOLCHAIN_PATH ${tools})
set(picotool_DIR ~/pico/tools/picotool)
include(pico_sdk_import.cmake)
project(fptest C CXX ASM)
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)
pico_sdk_init()
add_executable(fptest
main.c
circle.c
)
# to use USB CDC.
pico_enable_stdio_usb(fptest 1)
# create map/bin/hex/uf2 file in addition to ELF with picotool.
pico_add_extra_outputs(fptest)
#target_compile_options(fptest PUBLIC -mfloat-abi=softfp)
#target_link_options(fptest PUBLIC -mfloat-abi=softfp)
target_link_libraries(fptest pico_stdlib)
4 ~ 8 行目では、PICO_BOARD
に指定された値によって使用するツールチェインを切り分けています。また、29 行目と 30 行目は-mfloat-abi
オプションを変更するための記述です (「余談 1: GCC に与えるオプションの順序」も参考まで)。デフォルト以外の値に変更するときはコメントアウトを解除します。
ビルド方法や Pico 2 への書き込み方法は前回の記事を参照してください。
実験方法
-mfloat-abi
オプションを次のように変えて、それぞれのコンパイル結果を逆アセンブルして比較します。
-mfloat-abi=softfp
(Pico SDK のデフォルト)-mfloat-abi=hard
-mfloat-abi=soft
逆アセンブルには次のように objdump コマンドを使います。
% cd ~/pico/fptest/
% arm-none-eabi-objdump -d build/CMakeFiles/fptest.dir/main.c.obj
% arm-none-eabi-objdump -d build/CMakeFiles/fptest.dir/circle.c.obj
実験結果
結果を次の表に示します。GCC のマニュアルに書かれているとおりの結果になりました。
-mfloat-abi | circle() の呼び出し | 円の面積の計算 |
---|---|---|
soft | 汎用レジスタを使用 | ランタイム・ライブラリを使用 |
softfp | 汎用レジスタを使用 | FPU を使用 |
hard | FPU レジスタを使用 | FPU を使用 |
以下、-mfloat-abi
の設定ごとの逆アセンブル結果を掲載します (私自身のアセンブラの勉強も兼ねて書いているので、説明がくどかったり間違ったりしているかもしれませんが、大目に見てください。また、ARM アセンブラに関してよくわからなかった点を調べて余談 2 にまとめたので、参考にしてください)。
-mfloat-abi=soft
circle() 関数の呼び出しにも、その実装にも FPU が一切使われていません。代わりに、関数の呼び出しには汎用レジスタが、浮動小数点演算にはランタイム・ライブラリが使われています。
00000000 <main>:
0: b500 push {lr}
2: b083 sub sp, #12
4: f7ff fffe bl 0 <stdio_init_all>
8: f04f 507e mov.w r0, #1065353216
c: f7ff fffe bl 0 <circle>
10: 9001 str r0, [sp, #4]
12: f44f 707a mov.w r0, #1000
16: f7ff fffe bl 0 <sleep_ms>
1a: e7fa b.n 12 <main+0x12>
2: | スタック上に 12 バイト分の領域を確保する (なぜ 12 バイトなのかはよくわからない)。 |
8: | circle() に引数を渡すのに汎用レジスタ r0 が使われている。イミディエイト値の 1065353216 (0x3f800000) は、半径として与えた 1.0 の IEEE 754 単精度内部表現。 |
10: | circle() の戻り値 (r0) をスタックに格納する (意味のない処理だが、main.c の 7 行目で局所変数 s に volatile をつけたので、コンパイラがしかたなくこうしてくれたと思われる)。 |
12: | sleep_ms() に渡す引数として 1000 をセットする。 |
1a: | PC - 12 = ((main + 0x1a) + 4) - 12 = main + 18 (= main + 0x12) の位置、つまりラベル「12:」に無条件で分岐する ([4] C2.4.19、E2.1.382、余談 2 の 2. および 4. を参照)。 |
00000000 <circle>:
0: b508 push {r3, lr}
2: 4601 mov r1, r0
4: f7ff fffe bl 0 <__aeabi_fmul>
8: 4901 ldr r1, [pc, #4]
a: f7ff fffe bl 0 <__aeabi_fmul>
e: bd08 pop {r3, pc}
10: 40490fd8 .word 0x40490fd8
0: | lr (リンク・レジスタ。関数の戻りアドレスを保持している) の値を push する。なんで一緒に r3 を push するのかは不明。コンパイラの設計だとか、ABI の規約だとか、lr とセットで保存するだとかの理由が考えられる (ChatGPT より)。 |
2: | r0 を r1 にコピーする。つまり r0 にも r1 にも半径の値が格納される。そして両方とも __aeabi_fmul() に渡される。 |
4: | ランタイム・ライブラリの __aeabi_fmul() を使って、(半径 × 半径) を計算する (戻り値は r0 に格納される)。 |
8: | PC + 4 = ((circle + 8) + 4) + 4 = circle + 16 (= circle + 0x10) の位置に置かれている値 (つまりラベル「10:」にある 0x40490fd8) を r1 にロードする (余談 2 の 2. も参照)。 |
a: | __aeabi_fmul() を使って (3.141592 × 半径2) を計算する (戻り値は r0 に格納される)。 |
e: | 関数の戻りアドレスを pop して pc (プログラム・カウンタ) にロードする。 |
10: | 3.141592 の IEEE 754 単精度内部表現。 |
-mfloat-abi=softfp
main.c のコンパイル結果は-mfloat-abi=soft
の場合とまったく同じでした。「-mfloat-abi=softfp
を指定すると関数の引数と戻り値に汎用レジスタを使う」というマニュアルの記述を裏付ける結果になったと思います (ちょっとうれしい)。一方、circle() 関数の実装ではVMOV
、VLDR
、VMUL.F32
といった FPU 用の命令が使われています。これもマニュアルに書かれているとおりの結果になりました。
00000000 <circle>:
0: ee07 0a90 vmov s15, r0
4: ed9f 7a04 vldr s14, [pc, #16]
8: ee67 7aa7 vmul.f32 s15, s15, s15
c: ee67 7a87 vmul.f32 s15, s15, s14
10: ee17 0a90 vmov r0, s15
14: 4770 bx lr
16: bf00 nop
18: 40490fd8 .word 0x40490fd8
0: | 汎用レジスタ r0 を使って渡された半径の値を FPU のレジスタ s15 にコピーする ([4] C2.4.390)。 |
4: | PC + 16 = ((circle + 4) + 4) + 16 = circle + 24 (= circle + 0x18) の位置に置かれている値 (つまりラベル「18:」にある 0x40490fd8) を s14 にロードする ([4] C2.4.363 T2 および余談 2 の 2.)。 |
8: | (半径 × 半径) を計算して結果を s15 に格納する ([4] C2.4.408 T2)。 |
c: | (3.141592 × 半径2) を計算して結果を s15 に格納する。 |
10: | s15 に格納されている値を戻り値として r0 にコピーする。 |
14: | lr (リンク・レジスタ) に格納されているアドレスに戻る。 |
18: | 3.141592 の IEEE 754 単精度内部表現。 |
-mfloat-abi=hard
-mfloat-abi=hard
の場合はリンク時に「lstdc++ がない」とかいわれてビルドに失敗してしまいました。一応コンパイルはできたので、結果を掲載します。-mfloat-abi=soft
やsoftfp
の場合と比較して、circle() 関数の引数や戻り値に汎用レジスタではなく FPU レジスタ (s0) が使われているのがポイントです。
00000000 <main>:
0: b500 push {lr}
2: b083 sub sp, #12
4: f7ff fffe bl 0 <stdio_init_all>
8: eeb7 0a00 vmov.f32 s0, #112
c: f7ff fffe bl 0 <circle>
10: ed8d 0a01 vstr s0, [sp, #4]
14: f44f 707a mov.w r0, #1000
18: f7ff fffe bl 0 <sleep_ms>
1c: e7fa b.n 14 <main+0x14>
1e: bf00 nop
2: | スタック上に 12 バイト分の領域を確保する (なぜ 12 バイトなのかはよくわからない)。 |
8: | circle() に引数を渡すのに FPU レジスタ s0 が使われている ([4] C2.4.395)。イミディエイト値の 112 は、1.0 の IEEE 754 単精度内部表現である 0x3f800000 に変換される (余談 3)。 |
10: | circle() の戻り値 (s0) をスタックに格納する (意味のない処理だが、main.c の 7 行目で局所変数 s に volatile をつけたので、コンパイラがしかたなくこうしてくれたと思われる)。 |
14: | 以降の処理は他の場合の main() と同じ。 |
00000000 <circle>:
0: eddf 7a03 vldr s15, [pc, #12]
4: ee20 0a00 vmul.f32 s0, s0, s0
8: ee20 0a27 vmul.f32 s0, s0, s15
c: 4770 bx lr
e: bf00 nop
10: 40490fd8 .word 0x40490fd8
0: | PC + 12 = ((circle + 0) + 4) + 12 = circle + 16 (= circle + 0x10) の位置に置かれている値 (つまりラベル「10:」にある 0x40490fd8) を s15 にロードする ([4] C2.4.363 T2 および余談 2 の 2.)。 |
4: | s0 には引数で渡された半径の値が格納されている。つまり (半径 × 半径) を計算して結果を s0 に格納している ([4] C2.4.408 T2)。 |
8: | (3.141592 × 半径2) を計算して結果を s0 に格納する。 |
c: | lr (リンク・レジスタ) に格納されているアドレスに戻る。 |
10: | 3.141592 の IEEE 754 単精度内部表現。 |
参考: 初代 Pico
参考までに、初代 Pico 向けにビルド (-DPICO_BOARD=pico
) した結果も掲載します。Pico のマイコン RP2040 の CPU コアである Cortex-M0+ には FPU が搭載されていません。結果は予想どおり、Pico 2 の-mfloat-abi=soft
の場合と同様に FPU が一切使われませんでした。
00000000 <main>:
0: b500 push {lr}
2: b083 sub sp, #12
4: f7ff fffe bl 0 <stdio_init_all>
8: 20fe movs r0, #254
a: 0580 lsls r0, r0, #22
c: f7ff fffe bl 0 <circle>
10: 9001 str r0, [sp, #4]
12: 20fa movs r0, #250
14: 0080 lsls r0, r0, #2
16: f7ff fffe bl 0 <sleep_ms>
1a: e7fa b.n 12 <main+0x12>
2: | スタック上に 12 バイト分の領域を確保する (なぜ 12 バイトなのかはよくわからない)。 |
a: | r0 の値を 22 ビット左論理シフトして、結果を r0 に格納する ([5] A.6.7.35)。254 << 22 = 0x3f800000 (1.0 の IEEE 754 単精度内部表現) となる。 |
10: | circle() の戻り値 (r0) をスタックに格納する (意味のない処理だが、main.c の 7 行目で局所変数 s に volatile をつけたので、コンパイラがしかたなくこうしてくれたと思われる)。 |
14: | r0 の値を 2 ビット左論理シフトして、結果を r0 に格納する。250 << 2 = 1000 となる。 |
1a: | ラベル「12:」に分岐する (他の場合と同じ)。 |
00000000 <circle>:
0: b510 push {r4, lr}
2: 1c01 adds r1, r0, #0
4: f7ff fffe bl 0 <__aeabi_fmul>
8: 4901 ldr r1, [pc, #4]
a: f7ff fffe bl 0 <__aeabi_fmul>
e: bd10 pop {r4, pc}
10: 40490fd8 .word 0x40490fd8
0: | lr (リンク・レジスタ。関数の戻りアドレスを保持している) の値を push する。なんで一緒に r4 を push するのかは不明。コンパイラの設計だとか、ABI の規約だとか、lr とセットで保存するだとかの理由が考えられる (ChatGPT より)。 |
2: | r0 で渡された値 (半径) に 0 を足して r1 に格納する。結果的に r0 の値を r1 にコピーしている。理由はよくわからないがMOV ではなくADDS 命令を使っている ([5] A6.7.2)。 |
4: | 以降の処理は他の場合と同じ。 |
まとめ
- GCC のオプションに
-mfloat-abi=softfp
をつければ、浮動小数点演算に FPU を使用するコードが生成される。ただし関数の呼び出しには汎用レジスタが使われる。 - Pico SDK を利用するとデフォルトでこのオプションがつく。
-mfloat-abi=hard
にすると関数の呼び出しにも FPU レジスタが使われる。ただし Pico SDK のビルドに失敗する?(保留)
余談 1: GCC に与えるオプションの順序
前出の CMakeLists.txt のように、pico_sdk_init() から戻ってきたあとで-mfloat-abi
オプションの値をセットすると、Pico SDK がセットした値を上書きすることができます。Pico SDK のデフォルトは-mfloat-abi=softfp
ですが、上の CMakeLists.txt の 29 行目のコメントアウトを解除して
target_compile_options(fptest PUBLIC -mfloat-abi=hard)
とすれば、GCC には次のように渡されて、あとから指定されたhard
が優先されます。
% arm-none-eabi-gcc ... -mfloat-abi=softfp ... -mfloat-abi=hard ...
実際にそうなっているのを確認したければ、make (gmake) コマンドの実行時に VERBOSE=1 をつけることで、GCC に渡されるオプションを逐一表示させることができます。
実は「あとから指定されたオプションが優先される」というのは実際に GCC のマニュアルに書かれているのを見たわけではないのですが、最適化オプションに限っては次のような記述があり、おそらく他のオプションでも同様の挙動になるのではないかと思います。
If you use multiple -O options, with or without level numbers, the last such option is the one that is effective.
https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html
余談 2: ARM アセンブラメモ
- ARM アセンブラでは「@」以降は行末までコメントと見なされる。
- PC (プログラム・カウンタ) から読み出される値について:
LDR などの命令では、現在の PC 値に +4 し、ワードアライン ([1:0] ビットが 0) された値が読み出される ([4] p.469)。 - 関数の引数と戻り値の受け渡し:
__aeabi_なんちゃら() 系の関数は ABI for the Arm 32-bit Architecture の中で定義されているヘルパー関数。これらの関数は、大ざっぱにいって r0 ~ r3 を引数のやり取りに使い、r0 および r1 を戻り値として使用する ([6]、[7] などを参照)。 - 「.n」と「.w」について ([4] p.456):
- 「.n」は narrow を意味する。アセンブラは命令を 16 ビットでエンコードしなければならない。
- 「.w」は wide を意味する。アセンブラは命令を 32 ビットでエンコードしなければならない。
- 関数の冒頭に「
sub sp, #12
」(SUB (SP minus immediate)) とあったら:
意味は、「スタックポインタから 12 バイトを減算する」 ということ。意図は、次のような目的でスタック上に 12 バイト分の領域を確保すること。- 関数内で使用する局所変数やデータを保存するため。
- 関数の引数や戻りアドレスをスタックに保存するため。
余談 3: イミディエイト値 112 の IEEE 754 単精度内部表現への変換
-mfloat-abi=hard
のところで見たように、「vmov.f32 s0, #112
」とすると、「イミディエイト値 112」が「与えられた半径 1.0 の IEEE 754 単精度内部表現である 0x3f800000」に変換されて s0 に格納されます。この変換のやり方は [4] C2.4.395 に書かれています。
この命令eeb7 0a00
(2 進数で1110 1110 1011 0111 0000 1010 0000 0000
) を記述にしたがってデコードしてみると、まずビットの並びから
D = 0b (*1) imm4H = 7h (*1) imm4L = 0h Vd = 0000b size = 10b
(*1) | 末尾のb は 2 進数を、h は 16 進数を表すことにする。 |
であることがわかります。size == 10b
の場合、命令は「VMOV.F32 <Sd>, #<imm>
」をエンコードしたものであると書かれています。Sd
のd
はこのあと計算します。また、imm = imm4H:imm4L = 70h = 112
です (「:
」はビットを連結することを表します)。
これらを踏まえて、同じページの「Decode for this encoding」のところに書かれている疑似コードにしたがって計算すると、size == 10b
のとき
dp_operation = 0 (false) d = UInt(Vd:D) = UInt(00000b) = 0 imm32 = VFPExpandImm(imm4H:imm4L, 32) = VFPExpandImm(112, 32)
となります。VFPExpandImm(imm8, N)
の中身は [4] E2.1.429 に書かれています。仮引数の値をimm8 = 112 (= 01110000b)
、N = 32
として計算していくと
E = 8 F = N - E - 1 = 23 sign = imm8[7] = 0b (*2) exp = NOT(imm8[6]):Replicate(imm8[6], E - 3) = 0:11111b = 011111b (*3) frac = imm8[5:0]:Zeros(F - 4) = 110000:0000000000000000000b = 1 1000 0000 0000 0000 0000 0000b (*4) 戻り値 sign:exp:frac = 3f800000h
(*2) | imm[n] はimm の第 n ビットの値を表す。 |
(*3) | Replicate(x, N) はx で指定されたビット列をN 回複製して連結したビット列を返す ([4] E2.1.341)。 |
(*4) | imm[m:n] はimm の第 m ~ n ビットの値を表す。 |
そこで [4] C2.4.395 に戻って
imm32 = VFPExpandImm(112, 32) = 3f800000h
となり、「Operation for all encodings」というところを見ると
S[d] (= S[0]) = imm32 (= 3f800000h)
つまり s0 に 0x3f800000 が格納されることがわかります。
参考資料
[1] | Arm Cortex-M33 Processor Technical Reference Manual - About the FPU https://developer.arm.com/documentation/100230/0100/Floating-Point-Unit/About-the-FPU |
[2] | GCC Manuals - ARM Options https://gcc.gnu.org/onlinedocs/gcc/ARM-Options.html |
[3] | Getting started with Raspberry Pi Pico-series (6 September 2024) pp.37-39 https://datasheets.raspberrypi.com/pico/getting-started-with-pico.pdf |
[4] | Armv8-M Architecture Reference Manual (ID09082024) https://developer.arm.com/documentation/ddi0553/latest/ |
[5] | Armv6-M Architecture Reference Manual (ID070218) https://developer.arm.com/documentation/ddi0419/latest/ |
[6] | Run-time ABI for the Arm Architecture (2024Q3) 第 5.1.2 節 https://github.com/ARM-software/abi-aa/blob/main/rtabi32/rtabi32.rst |
[7] | Procedure Call Standard for the Arm Architecture (2024Q3) 第 6.1.1 節 https://github.com/ARM-software/abi-aa/blob/main/aapcs32/aapcs32.rst |
広告