SNSへはこちら

Longan NanoでRISC-Vチャレンジ(2) - C言語に(部分的に)対応

前回に引き続き、作業していきたいと思います。

今回は C 言語に(ちょっぴり)対応させて、そこでLチカできるようにすることを目標にしていきます。若干ハマった部分もあるのでそれも含めて書いていきましょう。

やったこと

C 言語に対応させるために以下を行いました。

  • スタートアップコードの変更
    • RAM を正常に扱えるようにする
    • main をコールする
  • レジスタ構造体の定義と使用

一方で、面倒なので .data セクションのコピー.bss セクションのゼロ初期化は行っていません。

スタートアップコードを変更

そのままでも動くのですが、スタックがうまく使えません。というか、RAM そのものにアクセスできないようです。アクセスを試みると例外を吐いて落ちます。
前回のものを整理し、おまじないを加えてできたスタートアップコードが以下です。

上の # Start から # End の間がおまじないです。これがないと RAM が使えません。こちらの GitHub リポジトリを参考にしました。

このルーチンで何をやっているかは以下に示します。

  • おまじない部分
    1. _start0800 のアドレス(語弊あり)に 0x0800 0000 を加算した値をレジスタに格納
    2. そのレジスタの示すアドレスにジャンプ
  • その後の部分
    1. gp を初期化
    2. sp を初期化
    3. main をコール
    4. 万が一 main から返ってきてしまったら、その場で無限ループを決める

何故おまじないが必要なのか

ハマりどころです。ここからは理屈を知りたい人向けですので、「とりあえず使えるようになればいいや」という方はスルーして下さい。

リセット後の挙動

まずこれまでおざなりにしていましたが(それでも偶然動いていた)、とかく組み込みというのはリセット後にどこからプログラムを実行するのかが大事になってきます。
データシートによると、この GD32VF マイコンはパワーオンリセット後は 0x0000 0000 からスタートするということです。

このアドレスはどこなのでしょうか。メモリマップを見ると、こんな記述が見つかります。

エイリアス領域らしいです。詳しいところはよくわかりませんが、どうやら通常起動時には Flash を指すようです。つまり、0x0800 0000(Flash の開始アドレス) が 0x0000 0000 にミラーされるということですね。これまで偶然動いていたのは、この 0x0800 0000_start 関数そのものが記述されていたからだったのでした。

ジャンプ命令の仕様とauipc命令

RISC-V のジャンプ命令には、即値をオペランドに取るものと、レジスタをオペランドに取るものの2種類があります。これまでずっと即値を使ってきましたが、これだとエイリアス領域をベースにしたアクセスになってしまいます。詳しいことは以下で述べますね。

ジャンプ命令の仕様

そもそもジャンプ命令の機械語命令長は、オペランドの種類関係なく 32bit です。メモリ空間が 32bit なので、これではフルにアクセスできないことになります。即値を使う場合は現在の pc の値から近い範囲のみでのジャンプが可能と言う訳なのです。そして、この即値は pc からの変位を置くことになっています。なので、

j func

と書いた場合は、func のアドレスそのものが置かれるのではなく、その命令自身のアドレスと func のアドレスとの差分が置かれることになります。
一方、レジスタをオペランドとして用いた場合は、特に制限なく 32bit の絶対アドレスを用いることができます。

...ということは、コンパイラさんはコードが 0x0800 0000 に配置されているという前提でこれら即値オペランドを生成するけれど、実際のプログラムはエイリアスされた 0x0000 0000 から始まるので...問題が起きます。

auipc命令

上の問題が大きく関わってくるのがこの auipc 命令です。この命令は auipc rd, imm とした場合、rd <- pc + (imm << 12); pc++ とする命令です。そうです。この命令は pc の値そのものが関わってくるのです。

この命令は la 命令(バイナリのシンボルが指すアドレスをレジスタにロードする命令)で使われます。例えば上のアセンブリコードで la sp, _stack なんて言うものがありますが、実はこれ擬似命令で、以下の2命令に展開されます。

 800008a:  auipc    sp,0x18008
 800008e:  addi sp,sp,-138 # 20008000 <_stack>

ご説明しましょう。まず auipc で、sppc + 0x18008000 が代入されます。この命令アドレスは 0x0800008a なので、sp には 0x2000808a が代入されることになりますね。
続いてそこから 0x8a を引き去るので、結果として sp0x20008000 を指します。これは RAM 領域です。めでたしめでたし...

となるのが通常ですが、実際はそうなりません。先程のエイリアス領域からプログラムが実行されるからです。とすると、0x0800 008aauipc 命令は実際のところ 0x0000 008a というエイリアス領域からアクセスされてしまいます。すると結果として sp0x18008000 を指すことになってしまうのです。

以上から、スタックポインタを初期化するルーチンに到達する前にエイリアス領域が抜け出して、本来の ROM 領域のアドレスにジャンプしておく必要があるということが分かります。つまり、上のおまじないはエイリアス領域を抜け出すためのコードなのです。以上説明でした。

C言語のファイルを作成してLチカしてみる

レジスタ構造体を作る前に、本当に C 言語で動くようになったのか確認してみましょう。

#define reg(n) (*(volatile unsigned int *)(n))
#define bit(n) (1 << (n))

int main(void){
    reg(0x40021000 + 0x18) |= bit(4); // RCU_APB2EN
    reg(0x40011000 + 0x04) &= ~(0xF << 20); // GPIOC_CTL1
    reg(0x40011000 + 0x04) |= (0b0010 << 20); // GPIOC_CTL1

    while(1) {
        reg(0x40011000 + 0x0C) ^= bit(13);
        for(volatile long long i=0; i<100000; i++) asm volatile("nop");
    }
    return 0;
}

コンパイルしましょう。

$ riscv-elf-gcc -march=rv32imac -mabi=ilp32 -std=gnu11 -Os -c -o main.o main.c
$ riscv-elf-gcc -march=rv32imac -mabi=ilp32 -c -o start.o start.s
$ riscv-elf-gcc -march=rv32imac -mabi=ilp32 -Tlinker.ld -nostartfiles -o blink.elf main.o start.o

1〜2Hz 周期くらいでチカチカするはずです。ちゃんとスタック(ローカル変数)も使えていますね。

最後にレジスタ構造体を作ってみる

流石にアドレス直打ちはつらいので、構造体で対応します。ちょっと長いので、gist へのリンクのみを貼っておきます。

これらをインクルードしてしまえばC言語でそれっぽく書けます。

#include "rcu.h"
#include "gpio.h"

int main(void){
    RCU->APB2EN |= RCU_APB2EN_PCEN;
    GPIOC->CTL1 &= ~(GPIO_CTL1_MD13_Msk | GPIO_CTL1_CTL13_Msk);
    GPIOC->CTL1 |= (GPIO_CTL_MD_OUTPUT_LOW << GPIO_CTL1_MD13_Pos) | (GPIO_CTL_CTL_GPIO_OUTPUT_PP << GPIO_CTL1_CTL13_Pos);

    while(1) {
        for(volatile long long i=0; i<100000; i++) asm volatile("nop");
        GPIOC->OCTL ^= bit(13);
    }
    return 0;
}

いやはや、快適そのものです。