公開日: 2024年10月14日

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です。このオプションは次の値のいずれかを取ります。

  • softfp

    Pico SDK を Pico 2 向けにビルドする (-DPICO_BOARD=pico2) とデフォルトでこのオプションになります。一見すると FPU を使用せずソフトウェア (ランタイム・ライブラリ) を使用するオプションに見えますがそうではなく、「FPU は使用するが、関数の呼び出し規約 (引数と戻り値) には汎用レジスタを使用する」というオプションです。
  • hard

    FPU を使用し、かつ関数の呼び出し規約に FPU レジスタを使用するオプションです。
  • soft

    FPU を使用せず、ソフトウェア (ランタイム・ライブラリ) を使用するオプションです。

もうひとつ、同ドキュメントに記載されている中で関係ありそうなのは-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/ からコピーしてきます。その他のファイルの中身は次のとおりです。

fptest/main.c
#include "pico/stdlib.h"

float circle(float r);

int main() {
    stdio_init_all();
    volatile float s = circle(1.0f);
    while (1) {
        sleep_ms(1000);
    }
}
fptest/circle.c
float circle(float r)
{
    return r * r * 3.141592f;
}

circle() 関数をわざわざ別のファイルに分けているのは、関数がインライン展開されてしまう?のを避けるためです。main.c に一緒に書いておいたところ、強制的にインライン展開されてしまいました (私が最適化についてよく知らないだけだと思いますが…最適化オプションを切ったりしてみたけど、だめっぽい)。

fptest/CMakeLists.txt
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-abicircle() の呼び出し円の面積の計算
soft

汎用レジスタを使用

ランタイム・ライブラリを使用

softfp

汎用レジスタを使用

FPU を使用

hard

FPU レジスタを使用

FPU を使用

以下、-mfloat-abiの設定ごとの逆アセンブル結果を掲載します (私自身のアセンブラの勉強も兼ねて書いているので、説明がくどかったり間違ったりしているかもしれませんが、大目に見てください。また、ARM アセンブラに関してよくわからなかった点を調べて余談 2 にまとめたので、参考にしてください)。

-mfloat-abi=soft

circle() 関数の呼び出しにも、その実装にも FPU が一切使われていません。代わりに、関数の呼び出しには汎用レジスタが、浮動小数点演算にはランタイム・ライブラリが使われています。

main.c のコンパイル結果:
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. を参照)。
circle.c のコンパイル結果:
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() 関数の実装ではVMOVVLDRVMUL.F32といった FPU 用の命令が使われています。これもマニュアルに書かれているとおりの結果になりました。

circle.c のコンパイル結果:
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=softsoftfpの場合と比較して、circle() 関数の引数や戻り値に汎用レジスタではなく FPU レジスタ (s0) が使われているのがポイントです。

main.c のコンパイル結果:
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() と同じ。
circle.c のコンパイル結果:
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 が一切使われませんでした。

main.c のコンパイル結果:
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:」に分岐する (他の場合と同じ)。
circle.c のコンパイル結果:
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 行目のコメントアウトを解除して

fptest/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.

出典: GCC Manual - Options That Control Optimization
https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html

余談 2: ARM アセンブラメモ

  1. ARM アセンブラでは「@」以降は行末までコメントと見なされる。
  2. PC (プログラム・カウンタ) から読み出される値について:
    LDR などの命令では、現在の PC 値に +4 し、ワードアライン ([1:0] ビットが 0) された値が読み出される ([4] p.469)。
  3. 関数の引数と戻り値の受け渡し:
    __aeabi_なんちゃら() 系の関数は ABI for the Arm 32-bit Architecture の中で定義されているヘルパー関数。これらの関数は、大ざっぱにいって r0 ~ r3 を引数のやり取りに使い、r0 および r1 を戻り値として使用する ([6][7] などを参照)。
  4. 「.n」と「.w」について ([4] p.456):
    • 「.n」は narrow を意味する。アセンブラは命令を 16 ビットでエンコードしなければならない。
    • 「.w」は wide を意味する。アセンブラは命令を 32 ビットでエンコードしなければならない。
  5. 関数の冒頭に「sub sp, #12」(SUB (SP minus immediate)) とあったら:
    意味は、「スタックポインタから 12 バイトを減算する」 ということ。意図は、次のような目的でスタック上に 12 バイト分の領域を確保すること。
    • 関数内で使用する局所変数やデータを保存するため。
    • 関数の引数や戻りアドレスをスタックに保存するため。
    この命令はスタック上限チェックの対象となる ([4] C2.4.237)。

余談 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>」をエンコードしたものであると書かれています。Sddはこのあと計算します。また、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
Raspberry Pi Pico 2 実験室
前の記事 | 目次 | 次の記事 (工事中)

広告