RISC-V を使って Arduino Uno と同程度のマイコンを作ります。 リポジトリは kanade-k-1228/mysoc です。
「FPGA で CPU を動かす」と聞くと大げさですが、小さな RISC-V コアとメモリと GPIO をつなぐだけなら、かなり現実的です。 むしろ、マイコンの中に何が入っているかを理解するにはかなり良い題材です。
この記事では、PicoRV32 を使った最小 SoC の構成を整理します。
目標
Arduino Uno っぽいものを FPGA 上に作ります。 ただし AVR 互換ではなく、RISC-V の小さな SoC として作ります。
目標は次です。
- RISC-V の C プログラムを動かす
- GPIO で L チカする
- UART で文字を出す
- タイマ割り込みを入れる
- 小さく作る
- 構造を理解できる範囲に保つ
高性能 CPU を作りたいわけではありません。 「マイコンの最小構成」を FPGA 上で見える形にしたい、というのが主目的です。
構成
最小 SoC は次のブロックで作れます。
+---------+ +------------+
| PicoRV32|----->| Bus Decoder|
+---------+ +------------+
|
+--------------+--------------+
| | |
+-------+ +-------+ +-------+
| ROM | | RAM | | MMIO |
+-------+ +-------+ +-------+
|
+-------+-------+
| GPIO / UART |
+---------------+
CPU は命令を読み、メモリアクセスを出します。 バスデコーダはアドレスを見て、ROM、RAM、MMIO (Memory Mapped I/O) のどれにアクセスするかを決めます。
この構成にすると、CPU から見ると周辺回路もメモリに見えます。
volatile uint32_t *gpio = (uint32_t *)0x10000000;
*gpio = 1;
こういうコードで LED を点けられます。 マイコンのレジスタアクセスそのものです。
FPGA
元の構想では TinyFPGA BX を使います。
- FPGA: Lattice iCE40LP8K
- LUT: 7680 個程度
- RAM: 128 Kbit 程度
- クロック: 16 MHz
PicoRV32 はかなり小さい RISC-V コアなので、このクラスの FPGA に入ります。 ただし、乗算器、割り込み、メモリサイズ、UART などを足していくと余裕は減ります。
小 FPGA で SoC を作ると、CPU よりメモリがつらいです。 ソフトウェアを書くとすぐ RAM が欲しくなります。 人類はメモリを食べる。
CPU
CPU として PicoRV32 を使用します。
PicoRV32 は、小ささと高い動作周波数を重視した RISC-V RV32IMC 実装です。 設定で機能を削れます。
最小構成では、まず RV32I だけでよいです。 乗算除算も圧縮命令も後回しにできます。
| 機能 | 最初の設定 |
|---|---|
| ISA | RV32I |
| 乗算除算 | なし |
| 圧縮命令 | なし |
| 割り込み | 最初はなし、あとで追加 |
| メモリ IF | native memory interface |
PicoRV32 の native interface はシンプルです。
CPU が mem_valid を出し、メモリ側が mem_ready を返します。
CPU: mem_valid, mem_addr, mem_wdata, mem_wstrb
MEM: mem_ready, mem_rdata
命令フェッチもデータアクセスも同じインターフェースに出てきます。 バスを 1 本だけ作ればよいので、小さな SoC には向いています。
メモリマップ
最初のメモリマップは単純にします。
| Address | 用途 |
|---|---|
0x0000_0000-0x0000_3fff | ROM / firmware |
0x0001_0000-0x0001_3fff | RAM |
0x1000_0000 | GPIO output |
0x1000_0004 | GPIO input |
0x1000_0010 | UART data |
0x1000_0014 | UART status |
0x1000_0020 | timer counter |
0x1000_0024 | timer compare |
アドレスは雰囲気です。 実装側と linker script が一致していれば動きます。
ROM は初期化済み Block RAM として作ります。 RAM も Block RAM で作ります。 外部メモリなしで完結させるため、最初は 4 KB から 16 KB 程度で我慢します。
バスデコーダ
アドレス上位ビットを見て、どのモジュールへアクセスするか決めます。
wire sel_rom = mem_addr[31:16] == 16'h0000;
wire sel_ram = mem_addr[31:16] == 16'h0001;
wire sel_mmio = mem_addr[31:28] == 4'h1;
読み出しデータは MUX で返します。
assign mem_rdata =
sel_rom ? rom_rdata :
sel_ram ? ram_rdata :
sel_mmio ? mmio_rdata :
32'h00000000;
mem_ready は、Block RAM なら 1 クロック遅らせて返す構成にします。
最初は全部 1 サイクル応答にしておくと楽です。
本気で性能を出すならパイプラインや wait state を考えます。 でも最初は「正しく動く」が正義です。
GPIO
GPIO は一番簡単な MMIO です。
reg [31:0] gpio_out;
always @(posedge clk) begin
if (reset) begin
gpio_out <= 0;
end else if (mmio_we && mem_addr == 32'h1000_0000) begin
gpio_out <= mem_wdata;
end
end
assign led = gpio_out[0];
C からはこう見えます。
#define GPIO_OUT (*(volatile unsigned int *)0x10000000)
int main(void) {
while (1) {
GPIO_OUT = 1;
delay();
GPIO_OUT = 0;
delay();
}
}
この瞬間、自作マイコン感が出ます。 CPU が命令を読んで、バスにアドレスを出して、FPGA の LED が光る。 ロマンと実用性の両立です。
UART
UART があると一気に開発しやすくなります。
printf が使えると、デバッグが人間の世界に戻ってきます。
最小 UART は、送信だけでも十分です。
| レジスタ | 意味 |
|---|---|
UART_DATA | 書くと 1 byte 送信 |
UART_STATUS | bit 0 が送信可能フラグ |
送信機は、スタートビット、8 bit データ、ストップビットを順番に出します。
idle start bit0 bit1 ... bit7 stop idle
1 0 d0 d1 d7 1 1
ボーレートはクロックを分周して作ります。 16 MHz クロックで 115200 bps なら、
分周値を 139 にすれば、だいたい合います。 誤差はありますが、普通の UART ならこの程度は許容されることが多いです。
タイマ
タイマは、周期割り込みや delay に使います。
reg [31:0] timer_counter;
reg [31:0] timer_compare;
always @(posedge clk) begin
timer_counter <= timer_counter + 1'b1;
end
assign timer_irq = (timer_counter == timer_compare);
PicoRV32 の IRQ を有効にすると、外部割り込みを受けられます。 最初はポーリングで十分ですが、RTOS っぽいことをしたくなると割り込みが欲しくなります。
タイマ割り込みでタスクを切り替えると、急にマイコンから OS へ近づきます。 危険な香りがします。
ファームウェア
C のコードを RISC-V 用にコンパイルします。
riscv32-unknown-elf-gcc \
-march=rv32i \
-mabi=ilp32 \
-nostdlib \
-T linker.ld \
start.S main.c \
-o firmware.elf
ELF からメモリ初期化ファイルを作ります。
riscv32-unknown-elf-objcopy -O binary firmware.elf firmware.bin
Verilog の ROM 初期化には $readmemh を使うのが楽です。
reg [31:0] rom [0:1023];
initial begin
$readmemh("firmware.hex", rom);
end
バイナリを hex に変換するスクリプトは必要です。 ここは Makefile で自動化します。
Linker Script
リンカスクリプトでは、ROM と RAM の位置をメモリマップに合わせます。
MEMORY
{
ROM (rx) : ORIGIN = 0x00000000, LENGTH = 16K
RAM (rwx) : ORIGIN = 0x00010000, LENGTH = 16K
}
SECTIONS
{
.text : {
*(.text.start)
*(.text*)
*(.rodata*)
} > ROM
.data : {
*(.data*)
} > RAM AT > ROM
.bss : {
*(.bss*)
*(COMMON)
} > RAM
}
_stack_top も RAM 末尾に置きます。
スタートアップコードで sp に設定します。
la sp, _stack_top
call main
このあたりを自分で書くと、普段マイコン IDE が隠している部分が急に見えます。 便利な IDE、裏でかなり働いている。
シミュレーション
FPGA に書く前に、できるだけシミュレーションします。
- CPU がリセット後に
0x0000_0000から命令を読むか - ROM から正しい命令が返るか
- MMIO 書き込みで GPIO レジスタが変わるか
- UART に期待した byte が出るか
- 未定義アドレスで止まらないか
SoC は、動かなかったときに原因が多すぎます。 CPU、ROM、RAM、バス、リンカスクリプト、スタートアップ、全部が容疑者です。
最初は「GPIO に 1 を書いたら testbench で LED が 1 になる」ぐらいの小さなテストから始めます。 †アジャイル開発†する。
実機でのデバッグ
実機では、UART が命綱です。
まず A を 1 文字出すだけのファームウェアを動かします。
#define UART_DATA (*(volatile unsigned int *)0x10000010)
#define UART_STATUS (*(volatile unsigned int *)0x10000014)
static void putc(char c) {
while ((UART_STATUS & 1) == 0) {
}
UART_DATA = c;
}
int main(void) {
putc('A');
while (1) {
}
}
これが出たら、CPU、ROM、バス、UART、クロック、ピン配置がだいたい生きています。 出なければ全部怪しいです。 そういう時は LED を数本デバッグピンにして、内部状態を外へ出します。
今後
最小構成が動いたら、次を足したいです。
- GPIO の方向レジスタ
- UART 受信
- SPI master
- PWM
- タイマ割り込み
- ブートローダ
- 外部 Flash からの起動
- 簡単な RTOS
ここまで来ると、かなりマイコンです。 市販マイコンを買えば一瞬ですが、自分で作ると一つ一つのレジスタに意味が生えます。
まとめ
PicoRV32 を使うと、小さな FPGA でも RISC-V SoC を作れます。
- CPU、ROM、RAM、MMIO があれば最小マイコンになる
- GPIO だけでも L チカできる
- UART があるとデバッグが急に楽になる
- リンカスクリプトとメモリマップを合わせる必要がある
- 実機に行く前にシミュレーションする
FPGA で CPU を動かすのは、CPU を作るというより「マイコンの中身を配線する」感覚に近いです。 自作 CPU よりは楽で、市販マイコンよりは中身が見える。 ちょうどよい沼です。