
SBPF AssemblyでSolanaプログラムを書く方法
現在、Solanaエコシステムでは、プログラム最適化の限界を競う並行レースが繰り広げられています。
大きな流れとして、PinocchioのようなライブラリがRust開発に革命を起こし、計算効率を桁違いに向上させています。一方、最も低いレベルでは、コンパイラへの不信で結束した熱心な開発者たちが、さらに一歩先へ進んでいます。RustやCのようなコンパイル言語でSolanaプログラムを書く代わりに、すべての命令から最大限の性能を引き出すため、バイトコードを手作業で緻密に組み立てています。
こうした低レベルでの改善は、VMにそのネイティブ言語で直接指示を与えることで初めて実現できます。その言語がsBPF Assemblyです。これは拡張Berkeley Packet Filter(eBPF)のSolana独自版であり、すべてのオンチェーンプログラム内で使用、実行されるバイトコードです。
sBPF Assemblyを書くことで、開発者はSolana Virtual Machineの最下層インターフェースへ直接アクセスできます。RustコンパイラとLLVMも最適化を試みますが、言語構文の表現力が不足していたり、より適切なコンパイル判断に必要なコンテキストが足りなかったりするため、アセンブリによって命令レベルを完全に制御できる熟練開発者のコードと比べ、最適ではないバイトコードが生成されることも少なくありません。
この高度な制御には使いやすさを犠牲にする面がありますが、コンピュートユニットの使用量とバイナリサイズ、ひいてはrentを大幅に削減できます。こうした削減は、競合が激しく、競争性が高く、性能が重要な処理で特に大切です。
同時に、すべてのプログラムをアセンブリで書くべきではないという意見にも一理あります。
状況は劇的に改善していますが、従来のツールは限られていました。さらに重要なのは、性能向上の代償として、正しさを手動で検証する必要が生じ、監査コストも増えることが多い点です。これは自動化ツールが不足しており、構文も読み書きや理解が難しいためです。
逆に、コンパイル言語はブラックボックスであり、コンパイラが下した判断を覆い隠すという見方もできます。そのため、アセンブリがもたらす透明性と制御性によって、コンパイル言語では容易に見えないものが明らかになることがあります。実際、最近のRust SDKにおける性能面のブレークスルーの大半は、コンパイラより効率的なバイトコードを手作業で生成できると気づいたことから生まれました。
この記事では、以下について学びます。
- sBPF Assemblyとは何か、また仮想マシンを直接制御する仕組み
- Berkeley Packet FilterからeBPFへの進化と、Solanaが採用した理由
- sBPFの仮想マシンアーキテクチャ、命令セット、メモリモデル
- 開発環境をセットアップし、sBPFプログラムをビルドする方法
- 実践的なmemoの例を使った段階的なアセンブリプログラミング
- 低レベルコードを書く際に不可欠なセキュリティ上の考慮事項
Assemblyとは?
Assemblyは、人間が読める形式のマシンコードです。CPUまたはVMの命令セットに直接対応する、最も低水準のプログラミング言語です。
アセンブリは変数や関数の代わりに、レジスタ(CPU内の高速な一時記憶領域)、メモリアドレス(RAMまたはディスク上の物理的な位置)、そしてload(メモリからの読み取り)、store(メモリへの保存)、算術演算、jump(制御フロー)などの基本的な操作を使用します。
アセンブリの各命令は、対応するマシンコードの命令と1対1で紐づきます。
この1対1の対応により、どのレジスタがデータを保持するか、メモリへどうアクセスするか、どの順序で処理するかまで、プロセッサが実行する内容をプログラマーが正確に制御できます。
1つの関数から数十個の命令が生成されることもある高水準言語とは異なり、アセンブリでは不透明な抽象化を介さず、マシンの動作を完全に把握して制御できます。
Berkeley Packet Filter(BPF)とeBPFとは?
Berkeley Packet Filter(BPF)は、Unixカーネル内でネットワークパケットを効率的にフィルタリングするための仮想マシンとして、1992年に誕生しました。初期のBPFはシンプルな命令セットとレジスタベースのアーキテクチャを採用し、サンドボックス化されたコードをカーネル内で安全に実行できました。
Extended Berkeley Packet Filter(eBPF)はこの概念を現代化し、パケットフィルタから汎用仮想マシンへと発展させました。eBPFでは64ビットアーキテクチャ、より多くのレジスタ、豊富な命令セットが導入され、ネットワーキング、セキュリティ、システム監視のための複雑なプログラムをカーネル空間で安全に実行できるようになりました。
SolanaがeBPFを採用したのは、組み込みのサンドボックス機能を備えた、実績ある安全な実行環境だったためです。サンドボックス化によって、プログラムがシステムリソースへアクセスしたり、ノードをクラッシュさせたり、他のプログラムに干渉したりすることを防ぎます。また、決定論的な実行により、すべてのvalidatorが同一の結果を生成します。
さらに、レジスタベースのアーキテクチャと成熟したツールチェーンは、高性能なオンチェーン実行に最適でした。既存のLLVMバックエンドにより、開発者はRustのような高水準言語からコンパイルすることもできました。
sBPF仮想マシンアーキテクチャ
Solanaプログラムが実行されると、ランタイムはsBPFバイトコードをメモリへロードし、安全性を確保するための静的検証(無限ループ、不正なメモリアクセス、命令の適切な使用の確認)を行った後、仮想マシン内で実行します。
VMは制御された64ビット実行環境を提供します。プログラムはホストシステムや他のプログラムから完全に隔離され、リソースへのアクセスはすべてランタイムを介して行われます。
sBPF命令セットアーキテクチャ
sBPFは11個の64ビットレジスタ(r0~r10)で動作します。r10は読み取り専用のフレームポインタ、r0は戻り値レジスタとして機能します。
命令は一貫した形式に従い、opcodeが操作(算術、論理、メモリアクセス、jump)を指定し、operandがソース/デスティネーションレジスタ、オフセット、即値のいずれか、または複数を示します。
主な命令カテゴリーには、ALU演算(加算、減算、ビット演算)、メモリ操作(load/store)、制御フロー(条件付き/無条件jump)があります。
sBPFのメモリモデル
sBPFプログラムは構造化されたメモリレイアウト内で動作します。ローカル変数と関数呼び出し用の4KBスタック、動的割り当て用のヒープ、バイトコードと定数を含む読み取り専用プログラムデータ、そしてプログラムが実行中にアクセスできるSolanaアカウントにマッピングされたアカウントデータ領域で構成されます。
すべてのメモリアクセスで境界チェックが行われ、プログラムは指定領域外のメモリへアクセスできません。
sBPFにおけるSolana syscall
sBPFプログラムは、システムリソースへ直接アクセスしたり、I/O操作を実行したりできません。代わりに、制御をSolanaランタイムへ移す特殊命令であるsyscallを通じてサービスを要求します。
sBPF Assemblyでは、call命令とcallシンボルを使用してsyscallを呼び出します。callシンボルはアセンブル時にコンパイラによってcall targetへ変更されます。現在、syscallはテキストベースの動的再配置を介して呼び出されます。これは、JITコンパイル時にシンボルを32ビットのMumur3ハッシュへマッピングする複雑な文字列ルックアップテーブル方式です。しかし、これを静的syscallへ置き換えて呼び出し規約を大幅に簡素化する提案が進行中です。syscallを呼び出す際は、引数をレジスタ1~5で渡します。レジスタ5はスタックスピルとして機能することもあり、戻り値はr0へ書き戻されます。
一般的なsyscallには、メモリ操作(sol_memcpy、sol_memcmp)、暗号関数(ハッシュ化、署名検証)、ログ記録、クロスプログラム呼び出しがあります。
通常のsBPF命令はすべてCUコストが1ですが、syscallのコンピュートユニット計算は大きく異なります。各syscallはプロトコルへの追加時にベンチマークされ、それを基に基本呼び出しコストが決まります(たとえば、CPI呼び出しは1000です)。場合によっては、消費されるデータ量に応じて追加の変動コストも適用されます。
SBPF Assemblyチュートリアル
環境をセットアップする
従来、sBPF Assemblyを書くにはSolanaの完全なツールチェーンが必要でした。肥大化して複雑なうえ、プラットフォームにも依存するプロセスでした。
このため、Dean LittleはsBPF SDKを開発し、sBPFプログラムのブートストラップ、ビルド、コンパイル、テスト、デプロイに対応する完全なエンドツーエンドソリューションを提供しました。
Cargoを使用すれば、どのオペレーティングシステムにもSDKをインストールできます。
cargo install --git https://github.com/blueshift-gg/sbpf.gitコードを詳しく見る前に、構文ハイライト、自動補完、エラー検出を利用できるVS Code sBPF Assembly拡張機能もインストールすることをおすすめします。
プロジェクトをセットアップする
次のコマンドで新しいプロジェクトのスキャフォールドを作成します。
sbpf init <name_of_the_project>これにより、MolluskのRustテストを含むプロジェクトが作成されます。
代わりにTypeScriptテストを含むスキャフォールドを作成する場合は、次のコマンドで初期化できます。
sbpf init <name_of_the_project> --ts-tests MemoのsBPF Assembly例
memoより単純なプログラムは想像しにくいでしょう。だからこそ、sBPF Assemblyの入門に最適です。
このプログラムが行うことは1つだけです。送信された任意の命令データを受け取り、ブロックチェーンへ記録します。アカウントも複雑なロジックもなく、Solana Virtual Machineを純粋に命令レベルで制御します。
まず、プログラム全体を見てみましょう。
.equ NUM_ACCOUNTS, 0x00
.equ DATA_LEN, 0x08
.equ DATA, 0x10
.globl entrypoint
entrypoint:
ldxdw r0, [r1+NUM_ACCOUNTS]
ldxdw r2, [r1+DATA_LEN]
add64 r1, DATA
call sol_log_
exit定数を定義する
プログラムは、Solanaランタイムのシリアライズ済み入力領域の構造に対応する3つの定数を定義するところから始まります。
.equ NUM_ACCOUNTS, 0x00 // Account count offset
.equ DATA_LEN, 0x08 // Data length offset
.equ DATA, 0x10 // Data start offsetこれらのオフセットは、Solanaランタイムがメモリ内に命令データを格納する位置に対応します。
VMがプログラムを呼び出すと、レジスタr1を通じて構造化バッファが渡されます。これらの定数を使えば、その構造内を参照できます。
sbpf.xyzのようなツールは、アカウントと命令データのレイアウトに基づいて、これらのオフセットを自動計算できます。
エントリポイントを作成する
続いて、プログラムのエントリポイントと検証処理を作成します。
.globl entrypoint
entrypoint:
ldxdw r0, [r1+NUM_ACCOUNTS].globlエントリポイントディレクティブは、entrypointシンボルをグローバルに参照可能にするようリンカーへ指示します。Solanaランタイムはこのシンボルを探し、プログラムの実行開始位置を判断します。ちなみに、通常はentrypointと呼びますが、実際にはどのような名前でも構いません。
最初の命令では、アセンブリ特有の巧妙な検証手法を使います。r0は戻り値レジスタであり、0以外の戻り値はエラーコードとして扱われます。そのため、アカウント数をr0へ直接ロードすると、1つ以上のアカウントが渡された場合、このプログラムは0以外のエラーコードで強制終了します。
これにより、入力されたアカウントを手動で読み飛ばし、VM内の命令データのオフセットを検証する必要がなくなります。
sol_log syscallを呼び出す
最後に、sol_log_ syscallを実行できます。
ldxdw r2, [r1+DATA_LEN] ; Load memo length
add64 r1, DATA ; Point r1 to memo data
call sol_log_ ; Log the memo
exit ; Exit with r0 valuesol_log_ syscallは、r2にメッセージの長さ、r1にログへ記録するメッセージへのポインタを受け取ります。幸い、シリアライズ済み入力領域では、ランタイムが命令データの先頭に64ビットの長さカウンタを自動的に付加します。そのため、次の操作だけでsol_log_ syscallを呼び出せます。
- DATA_LENオフセットにある値をr2へロードする
- r1が命令データの開始オフセットを指すようにする
2つのレジスタが正しい値を指したら、syscallを呼び出し、r0にある値をそのまま返して終了します。
プログラムをビルドしてデプロイする
プログラムのビルドには、Claire FanがRustで開発したSBPF独自の組み込みアセンブラを使用できます。これは5MBのRustバイナリであり、Solanaプラットフォームツールで必要となる2GB超のLLVMツールチェーンを置き換えます。
ビルドするには、次のコマンドを実行するだけです。
sbpf buildプログラムのビルド後は、次のコマンドでデプロイできます。
sbpf deployプログラムを操作する
用意されたテストを使ってプログラムをテストするには、sbpf testを実行します。または、sbpf e2eを実行すれば、1つのコマンドでビルド、デプロイ、テストまでの全パイプラインを実行できます。
sBPF Assemblyにおけるセキュリティ上の考慮事項
アセンブリコードを書くということは、セキュリティに全責任を負うということです。ミスを検出してくれるコンパイラはありません。すべての命令がプログラムの安全性へ直接影響するため、以下の基本的なセキュリティ原則が不可欠です。
入力検証
アセンブリプログラムでは、すべての入力を手動で検証する必要があります。使用前に、アカウント数、データ長、バッファサイズを必ず確認してください。
たとえば、このmemoプログラムでは、アカウント数をr0へロードすることで自動検証を実現しました。アカウントが誤って渡された場合、プログラムは失敗します。データを処理する際は、メモリへアクセスする前に、長さが想定範囲内にあるか確認してください。
メモリ境界チェック
sBPFには自動的な境界チェックがありません。配列やバッファへアクセスする前に、読み書き操作が割り当て済みの境界内に収まることを手動で確認してください。メモリアクセス前に簡単な境界チェックを行うだけで、クラッシュやデータ破損を防げます。
jgt r2, MAX_LENGTH, error # Check if length exceeds limit
ldxb r3, [r1+r2] # Safe to load if check passesレジスタ管理
レジスタは、プログラムの重要な状態を保持します。関数やsyscallを呼び出す際は、重要な値をスタックや別のレジスタへ保存して維持してください。
フレームポインタ(r10)とr0の戻り値には、特に注意が必要です。これらを破損すると、プログラムがクラッシュしたり、セキュリティ脆弱性が生じたりする可能性があります。
算術演算の安全性
算術演算では、オーバーフローを手動で検出することが極めて重要です。大きな値を加算する前に、結果が64ビットの上限を超える可能性がないか確認してください。
除算では、ランタイムエラーを防ぐために明示的なゼロチェックが必要です。
syscallパラメータの検証
syscallには有効なパラメータが必要であり、入力が不正な場合は失敗します。syscallを呼び出す前に、レジスタに適切なポインタ、長さ、値が格納されていることを確認してください。 不正なパラメータは処理を失敗させるだけでなく、コンピュートユニットも不必要に消費します。
まとめ
sBPF Assemblyは万人向けではありません。まさにそこが重要です。
ほとんどの開発者はRustを使い続け、最適化をコンパイラに任せるべきです。しかし、最後の1コンピュートユニットまで最適化する場合や、性能が極めて重要なインフラストラクチャを構築する場合、アセンブリは高水準言語にはない完全な制御を提供します。
ここでは、sBPFがSolanaのアーキテクチャにどう組み込まれているかという基礎から、最初のmemoプログラムの構築までを解説しました。
memoの例は単純に見えるかもしれませんが、より複雑なプログラムでも使用する次の基本原則を示しています。
- レジスタの直接操作
- 手動でのメモリ管理
- 明示的なsyscall処理
こうした性能向上には代償があります。安全網を速度と、抽象化を制御性と引き換えにすることになります。Rustコードをすでに最適化してもなお性能が必要な場合、1マイクロ秒単位の差が重要なインフラストラクチャを構築する場合、またはコンパイラではうまく最適化できない処理が必要な場合に、アセンブリを選ぶべきです。
それ以外の場合は、余計な苦労を避けてRustを使い続けてください。
ツールは改善を続け、コミュニティも成長しており、性能上のメリットは明白です。ただし、「大きな力には、物事を壊さないという大きな責任が伴う」ことを忘れないでください。
sBPF Assemblyの使い方についてさらに学びたい場合は、BlueshiftのAssembly入門コースを読み、そこに用意されているチャレンジでスキルを試してください。
関連記事
Heliusを購読
Solana開発の最新情報や新しい記事の公開通知を受け取れます


