はじめに
RISC-Vのアセンブラを自作してみたくなったので作りました。
作る過程でRISC-Vのアーキテクチャや命令エンコーディングの仕組みも自然に学べるだろうという狙いもありました。
実装言語にCommon Lispを選んだのは単純に使ってみたかったからです。
REPLでインタラクティブに動かしながら開発できる点が、
試行錯誤の多い低レイヤー実装と相性がよかったです。
2. RV32Iとは
RISC-Vはカリフォルニア大学バークレー校が開発したオープンなISA(命令セットアーキテクチャ)です。
RV32Iはその中でも最も基本的な32ビット整数命令セットで、
以下の6種類の命令フォーマットで構成されています。
| 型 | 用途 |
|---|---|
| R型 | レジスタ間演算(ADD, SUB, AND ...) |
| I型 | 即値演算・ロード(ADDI, LW ...) |
| S型 | ストア(SW, SH, SB) |
| B型 | 分岐(BEQ, BNE, BLT ...) |
| U型 | 上位即値(LUI, AUIPC) |
| J型 | ジャンプ(JAL) |
全命令が固定長32ビットで、フォーマットは以下のようになっています。
R型: [ funct7(7) | rs2(5) | rs1(5) | funct3(3) | rd(5) | opcode(7) ]
I型: [ imm[11:0](12) | rs1(5) | funct3(3) | rd(5) | opcode(7) ]
S型: [ imm[11:5](7) | rs2(5) | rs1(5) | funct3(3) | imm[4:0](5) | opcode(7) ]
B型: [ imm[12|10:5](7) | rs2(5) | rs1(5) | funct3(3) | imm[4:1|11](5) | opcode(7) ]
U型: [ imm[31:12](20) | rd(5) | opcode(7) ]
J型: [ imm[20|10:1|11|19:12](20) | rd(5) | opcode(7) ]
アセンブラの仕事はこのフォーマットに従って、
テキストの命令をビット列に変換することです。
3. 実装の構成
3つのモジュールで構成されています。
input.s
↓ parser.lisp
テキストをトークン化し、命令ごとのデータ構造に変換
↓ encoder.lisp
各命令をRV32Iのビットフォーマットにエンコード
↓ assembler.lisp
リトルエンディアンでoutput.binに書き出し
ファイル構成:
code/
├── main.lisp
├── package.lisp
├── rv32i-assembler.asd
└── src/
├── utils.lisp レジスタテーブル・バリデーション・文字列処理
├── parser.lisp トークン化・命令解析・ラベル処理
├── encoder.lisp 命令エンコード
└── assembler.lisp バイナリ出力
4. パーサの実装
トークン化
1行をスペース・カンマ・括弧で分割してトークン列にします。
"addi x1, x0, 42" → ["addi", "x1", "x0", "42"]
命令判定
先頭トークンをハッシュテーブルで引いて命令タイプ(R/I/S/B/U/J型)を判定します。
タイプに応じてオペランドの取り出し順が変わります。
ラベルの2パス処理
ラベルを使った分岐を実現するために2パス方式を採用しました。
1パス目:全行を読みながらラベルのアドレスを記録
loop: → アドレス8
2パス目:即値フィールドにラベルがあれば相対アドレスに変換
bne x1, x2, loop → bne x1, x2, (8 - 16) = -8
RV32Iは全命令が固定長4バイトなので、
行番号×4でアドレスが確定します。
これにより前方参照も自然に解決できます。
5. エンコーダの実装
基本方針
各命令タイプごとに、オペランドを決められたビット位置に配置して
32ビット整数を組み立てます。
ビットシフトとビットORの組み合わせだけで実装できます。
例としてR型(ADD x3, x1, x2)のエンコードは以下のようになります。
funct7=0b0000000 → ビット31-25に配置
rs2=2 → ビット24-20に配置
rs1=1 → ビット19-15に配置
funct3=0b000 → ビット14-12に配置
rd=3 → ビット11-7に配置
opcode=0b0110011 → ビット6-0に配置
→ 0x00208133
各フィールドを所定のビット位置にシフトしてORで合成するだけです。
即値の符号拡張
I型・S型・B型の即値は符号付き12ビットです。
2の補数表現を使い、2048以上の値は負数として扱い4096を引いて符号拡張します。
B型のビット配置
B型は即値のビットが非連続に配置されているのが特徴です。
12ビットの即値を4つのフィールドに分解して、
それぞれ決められた位置に配置します。
6. 詰まったところ・工夫した点
アルゴリズム自体は難しくない
実装してみて気づいたことですが、アセンブラのアルゴリズム自体はそこまで難しくありません。
RV32Iの仕様書を見ながら、命令フォーマットの通りにビットを並べるだけです。
中国語の辞書を引くのに中国語を完全に理解している必要がないのと同じで、
RV32Iのアーキテクチャを完全に理解していなくても、
仕様書の定義に従って変換処理を書けば動くものができてしまいます。
アーキテクチャへの理解はそんなに深まりませんでした。
Common Lispの書き方
詰まったのはアルゴリズムよりもLisp自体の書き方でした。
パース結果を表現する方法や、
ハッシュテーブルの扱い、マクロを使った命令定義の共通化など、
Lispらしい書き方を探りながら実装を進めました。
実装にはAIも活用しました。
7. 動作確認
Venus(RISC-Vシミュレータ)上で以下のテストを実施しました。
基本命令テスト
addi x1, x0, 10
addi x2, x0, 20
add x3, x1, x2 ; x3 = 30
sub x4, x2, x1 ; x4 = 10
and x5, x1, x2 ; x5 = 0
or x6, x1, x2 ; x6 = 30
ラベルを使ったループ
addi x1, x0, 0
addi x2, x0, 5
loop:
addi x1, x1, 1
bne x1, x2, loop
; → x1 = 5
全レジスタテスト
x1〜x31に値を書き込み、全て加算してx1に集計。
1+2+3+...+31 = 496 が得られることを確認。
メモリ読み書きテスト
アドレス0と512KBへのSW/LWを確認。
算術限界テスト
符号付き最大値・最小値・オーバーフロー・
符号あり/なし比較(SLT/SLTU)・算術シフト(SRA)を確認。
スタック操作テスト
sp(x2)を使ったプッシュ・ポップと
JALによる関数呼び出しの模擬を確認。
8. まとめ・今後
Common Lispでアセンブラを自作することで、RV32Iの命令フォーマットや
2の補数表現など低レイヤーの基礎を実装を通じて学べました。
ソースコードはGitHubで公開しています。
https://github.com/H1rla/lisp-rv32i-assembler
今後の予定
- 疑似命令(NOP/MV/LI等)の対応
現状は疑似命令未対応のため実用にはやや不便です。 - 特権命令(CSR命令・例外処理)への対応