Kanade Electronicskanaelecom

PicoRV32 で自作マイコン

FPGA 上に小さな RISC-V SoC を作る

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 だけでよいです。 乗算除算も圧縮命令も後回しにできます。

機能最初の設定
ISARV32I
乗算除算なし
圧縮命令なし
割り込み最初はなし、あとで追加
メモリ IFnative 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_3fffROM / firmware
0x0001_0000-0x0001_3fffRAM
0x1000_0000GPIO output
0x1000_0004GPIO input
0x1000_0010UART data
0x1000_0014UART status
0x1000_0020timer counter
0x1000_0024timer 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_STATUSbit 0 が送信可能フラグ

送信機は、スタートビット、8 bit データ、ストップビットを順番に出します。

idle  start  bit0 bit1 ... bit7  stop  idle
  1     0     d0   d1       d7    1     1

ボーレートはクロックを分周して作ります。 16 MHz クロックで 115200 bps なら、

16000000115200138.9

分周値を 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 よりは楽で、市販マイコンよりは中身が見える。 ちょうどよい沼です。