RISC-V OSを作ろう (9) ~ マルチコア (OSの起動)

執筆者 : 高橋 浩和

※ 「RISC-V OSを作ろう」連載記事一覧はこちら
※ 「RISC-V OS」のコードはgithubにて公開しています。


はじめに

今回からRISC-V OSをマルチコア環境に対応させていこうと思います。今回は、マルコチアOSとして立ち上がるところまでを見ていきます。

方針

タスクはどのCPU上でも実行可能とします。各CPU上でスケジューラが独立して動作し、READY状態だがまだどのCPUでも実行されていないタスクを見つけて実行権を与えるものとします。少しでも早くタスクを見つけたスケジューラがタスクを実行する権利を得ます。

スケジューラのタスク操作ではコア間の競合が発生しますが、これはロック変数を用いてガードします。

タスクの状態を変更した時、他コアにそのことを伝えるためにコア間割り込みを用いることとします。

システム全体に関係する処理は、コア0が担当します。

マルチコア構成

メモリ割り付け

各コアにコアローカル領域を割り当てます。コアローカル領域の実現にTLS(Thread Local Storage)の機能を用いることにします。

コアローカル領域にはそのコアだけが利用するもの(コアローカル変数、例外スタック、割り込みスタック)を割り付けます。

コアローカルメモリ域の配置

下記はリンカディレクティブファイルです。system_tdataセクションは、初期値ありのコアローカル変数用の領域です。system_tbssセクションは初期値無しコアローカル変数用の利用域です。tlsセクションは、各コアが実際に動作する時に参照するコアローカル領域となります。この中にそれぞれのコア用のコアローカル変数、例外スタック、割り込みスタックを配置します。今回の実装では、system_tdatasystem_tbssセクションは、初期化処理のみで利用します。

_CLV_SIZE = 1024;
_STACK_SIZE = 4096;
_TLS_SIZE = _CLV_SIZE + _STACK_SIZE;
_MAXCORE = 8;

SECTIONS
{
            :
            :
            :
  /* tls data backup section */
  .system_tdata : {
    PROVIDE(_tdata_start = .);
    main.o(.tdata .tdata.*)
    primitives.o(.tdata .tdata.*)
    PROVIDE(_tdata_end = .);
  } >ram_system AT>ram_system

  .system_tbss : {
    PROVIDE(_tbss_start = .);
    main.o(.tbss .tbss.*)
    primitives.o(.tbss .tbss.*)
    PROVIDE(_tbss_end = .);
  } >ram_system AT>ram_system

  .tls :{
    . = ALIGN(16);
    PROVIDE(_tls_start = .);
    . = . + _TLS_SIZE * _MAXCORE;
    . = ALIGN(16);
    PROVIDE(_tls_end = .);
  } >ram_system AT>ram_system
            :
            :
            :
}

OSの起動

リセットエントリ

各コアが同時にリセットエントリ_startから動作を始めます。リセットエントリでは、コアID(HartID*1 )を見て処理を切り分ける必要があります。MHARTIDレジスタがHartIDを保持しています(1)*2

リセットエントリでは、tp(スレッドポインタ)、MSCRATCH、sp(スタックポインタ)を、それぞれのコア用の領域を指すように初期化します。tpには各コア用に用意された領域の先頭を指させます(2)。この領域の先頭域には後ほど説明するコアローカル変数を配置します。

RISC-V OSを作ろう (8) ~ 簡易メモリ保護と同様に、MASCATCHレジスタに例外スタックの先頭を指させます(3)。この領域は例外・割り込み発生時の作業領域(スクラッチ領域)としても利用します。例外・割り込みはそれぞれのコアで独立して発生するため、それぞれのコア専用の領域を用意する必要があります。

スタックポインタは割込みスタックの底を指すように初期化します(4)。初期化処理は割込みスタックを利用して動作します。

    .section .reset,"ax",@progbits
    .option norelax
    .globl _start
_start:
    la    gp, __global_pointer$

    csrr  t0, mhartid                                (1)

    /* tp = _tls_start + mhartid*_TLS_SIZE */        (2)
    la    tp, _tls_start
    la    t1, _TLS_SIZE
    mul   t2, t1, t0
    add   tp, tp, t2

    /* mscratch = tp + _CLV_SIZE */                  (3)
    la    t1, _CLV_SIZE
    add   t3, tp, t1
    csrw  mscratch, t3

    /* sp = mscratch + _STACK_SIZE */                (4)
    la    t1, _STACK_SIZE
    add   sp, t3, t1

    j     main

OSの初期化

初期化しなければならない資源には2種類あります。すべてのコアで共有する資源と、コアローカルな資源です。本OSでは、共有資源の初期化はブートコア(ここではコア0)に任せることとしました(D)。他コアは共有資源の初期化完了を待ち合わせるだけにします(E)。完了を待ち合わせるのは、初期化が完了していない共有資源(タスクオブジェクトなど)に、別のコアからアクセスしてしまうことを防ぐためです。

__thread指定子で定義したグローバル変数は、tpレジスタ相対でアクセスするコードが生成されます。CurrentTaskThisCore変数をこのスレッドローカル変数(コアローカル変数)として定義します(A)。
CurrentTaskは今までと同じく実行中タスクを指し示します。新しく追加したThisCoreは、現在コードを実行しているコンテキストが割りついているコアを指し示します。どのコアからも同じ変数名で参照されますが、コア毎に異なる値を保持することになります。

これらの変数はコアローカル変数域に配置されるため、コアローカル領域の初期化(setupTLS関数)が完了するまで利用することができません。これら変数の初期化は(B1)(B2)で行っています。

__thread TaskIdType CurrentTask;                       (A)
__thread CoreIdType ThisCore;


void main(void) {
    TaskIdType task;

    SetTrapVectors((unsigned long)trap_vectors + MTVEC_VECTORED_MODE);

    setupTLS();
    ThisCore = GetHartID();                            (B1)

    if (ThisCore == BootCore) {                        (D)
        clearbss();

        for (task = 0; task < NUMBER_OF_TASKS; task++) {
            InitTask(task);
        }
    }

    sync_cores();                                      (E)

    SetupPMP();

    StartTimer();
    EnableIPI();

    _print_message("Core%x started.\n", ThisCore);

    SpinLock(&lock_sched);
    CurrentTask = TASKIDLE + ThisCore;                 (B2)
    TaskControl[CurrentTask].coreid = ThisCore;
    load_context(&TaskControl[CurrentTask].sp);        (F)
}

コアローカルなメモリ域の初期化

コンパイラは初期値ありのコアローカル変数をsystem_tdataセクションに割り付けます。初期値無しコアローカル変数はsystem_tbssセクションに割り付けます。これらのセクションの情報を用い、setupTLS関数にてそれぞれのコアが実際に利用するコアローカル変数域の初期化を行ないます。

  • system_tdataセクションからコアローカル変数域のデータ域に値をコピー
  • コアローカル変数域のbss域をクリア。クリアする大きさはsystem_tbssセクションのサイズ。

現在の実装では、OS起動後にsystem_tdata・system_tbssセクションのメモリ領域は使われず無駄になっています。コード単純化のためにこの実装を採用していますが、コア0にはtlsセクション内の領域ではなく、system_tdata・system_tbssセクションをそのまま使わせるという実装も考えられます。その場合、セクション配置も面倒になりますが、コア1以降のコアローカル変数域の初期化が完了するまでコア0がsystem_tdataセクションにある変数の更新をしないことを保証しなければなりません。

static void setupTLS(void)
{
    extern unsigned char _tls_start[];
    extern unsigned char _tdata_start[];
    extern unsigned char _tdata_end[];
    extern unsigned char _tbss_start[];
    extern unsigned char _tbss_end[];

    const unsigned int tdata_size = _tdata_end - _tdata_start;
    const unsigned int tbss_size = _tbss_end - _tbss_start;
    register unsigned char* threadp asm("tp");

    mem_copy(threadp, _tdata_start, tdata_size);
    mem_clear(&threadp[tbss_size], &threadp[tdata_size + tbss_size]);
}

コアの待ち合わせ

コア間待ち合わせ関数sync_coresの実装は単純です。ブートコア(コア0)は共有資源の初期化が完了するとコア間共有変数notyetをFALSEに更新します。それ以外のコアは、notyet変数がFALSEになるまで待ち続けます。

注目点はMemBarrier関数です。

コンパイラは実行結果が変わらない範囲で命令の配置を入れ替え、命令の実行効率を高めます。C言語表記上ではnotyet変数より先に更新しているデータ構造が、notyet変数より後に更新される命令が生成されることもあります。これは他のコアから見た時には問題です。MemBarrier()には、このコンパイラの最適化による命令の並び替えを抑制する効果があります。MemBarrier()を跨いで命令並び替えが行われないようにします。

また最近のCPUアーキテクチャでは、命令実行の入れ替え(リオーダリング)が行なわれるため、記述された命令の順序で実行されるとは限りません。命令を実行するコアからみて最終的な演算結果が同じになる場合、効率よく実行できる順序に動的に命令を並び替えて実行します。表記上はnotyet変数より先に更新しているデータ構造が、他のコアから見るとnotyet変数が既にFALSEになっているにも関わらずデータ更新が完了していないということがあり得ます。これを防ぐために、notyet変数更新前にMemBarrier()を置き、MemBarrier()を挟んだメモリ更新命令(store)の実行順序入れ替えを抑制します。本OSのMemBarrier()fence w, w命令にて実現しています。

static const CoreIdType BootCore = CORE0;


static void sync_cores(void)
{
    volatile static _Bool notyet = TRUE;

    if (ThisCore == BootCore) {
        MemBarrier();
        notyet = FALSE;
    } else {
        while (notyet);
    }
}

タイマの起動

タイマは各コアで独立して動作させます。各コアでそれぞれタイマを起動し、それぞれのコアにタイマ割込みを発生させることとします。

RISC-Vのタイマ割り込みで説明したように、mtimecmpレジスタを制御することによりタイマ割込みを発生させます。mtimecmpレジスタはコア毎に用意されており、それぞれのコアが自コア用のmtimecmpレジスタの初期設定を行ないます *3

__thread volatile unsigned long * reg_mtimecmp = ((unsigned long *)0x2004000U);

static void StartTimer(void)
{
    reg_mtimecmp += ThisCore;
    *reg_mtimecmp = *reg_mtime + INTERVAL;
    EnableTimer();
}

コア間割り込み設定

タイマ割り込みとコア間割り込みは一つのエントリ(int_handler)を共有させることとします*4。この割り込みエントリ(int_handler)が登録されたトラップベクタを用意します。割り込みエントリの詳細については、次回の記事で説明します。

    .text
    .globl trap_vectors
    .type trap_vectors,@function
    .balign 256
trap_vectors:
    j   exc_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   int_handler          /* software interrupt */
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   int_handler          /* timer interrupt */
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler    /* external interrupt */
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .size trap_vectors,.-trap_vectors

トラップベクタをMTVECレジスタに登録します。

    SetTrapVectors((unsigned long)trap_vectors + TVEC_VECTORED_MODE);

EnableIPI関数にて、MIEレジスタのMSIEビットを立てることによりソフトウェア割り込みを許可します。本OSでは他コアのスケジューラ起動を要求するコア間割り込みとしてソフトウェア割り込みを利用します。

コア間割り込みの詳細については次回の記事で説明することにします。

    .global EnableIPI
    .type EnableIPI,@function
    .balign 4
EnableIPI:
    csrsi  mie, MIE_MSIE
    ret
    .size EnableIPI,.-EnableIPI

タスク起動

初期化が完了したらそれぞれのコアでタスクを起動します(F)。起動するタスクとしてそれぞれのコア用に用意されたIDLEタスクを選択しています。IDLEタスクについては、次回の記事でもう少し詳しく説明します。

起動するタスクとしてIDLEタスクを選択した理由は、コードが単純になるためです。代わりにIdle関数の先頭でScheduleシステムコールを呼びだし、再スケジューリングを要求するようにしました*5

起動するタスクとしてIDLEタスク以外を選択することも可能ですが、コア間で調整する必要が生まれるため、コードが少々複雑になります。

void Idle(void)
{
    Schedule();
    while (1) {
        /* do nothing */
    }
}

最後に

今回はマルチコア環境でのOS起動のところまでを追いかけました。次回はマルチコア環境のスケジューラ、スピンロック、コア間割り込みについて見ていきます。

おまけ

今回もいくつかアセンブリコードが登場しましたが、アセンブリコード中ではRISC-Vアーキテクチャで定義された命令以外に、疑似命令(pseudoinstruction)と呼ばれる抽象的な表現の命令も利用することができます。疑似命令を使うことによりコードの記述性・可読性を高めることができます。

有名なところではnopがあります。RISC-Vアーキテクチャはnop命令を持っていませんが、アセンブリ命令としてはnopを使うことができます。生成されたオブジェクトにはnopと同等の何も効果のない命令が埋め込まれます。

アセンブリ命令としては1命令ですが、2つのRISC-V命令に置き換えられるものもあります。よく利用されるla rd, symbol命令は、symbolアドレスの上位ビットをrdレジスタに読み込む命令と、rdレジスタにsymbolアドレスの下位ビットを足しこむ命令の2命令に置き換えられます*6。固定長命令1つの中にシンボルのアドレスを全て埋め込むことができないため、RISC-Vのアセンブラはこのような手段を採っています*7

情報が少し古いように思いますが、RISC-V Assembly Programmer's Manualも参考になります。

*1:正確にはハードウェアスレッドID。1つの物理コア中に複数の実行コンテキスト(レジスタセット一式と思っておけば、ほぼ正しい)を持たせることも可能です。各実行コンテキストをハードウェアスレッドと呼びます。

*2:RISC-VアーキテクチャはHartIDが連番になっていることを保証していませんが、qemuの実装ではHartIDは連番になっています。本OSはHartIDが連番であることを利用してリセットエントリの実装を単純化しています。

*3:初期値ありコアローカル変数を使ってみるために、このような記述でreg_mtimecmp変数の初期化してみましたが、reg_mtimecmp = (unsigned long *)0x2004000U + ThisCore と単純に記述することでも十分です。

*4:別々に用意しても両者は殆ど同じアセンブリコードになり無駄が多いため

*5:Scheduleシステムコール呼び出しが無くとも、タイマ割り込み発生のタイミングで再スケジューリングが行なわれます

*6:アドレス長によっては3命令になります。

*7:ARM系のコンパイラでは、PC相対でアクセスできる領域にアドレスを保持するread-onlyデータを埋め込み、そのread-onlyデータをloadするというコードを生成するものが多いように思います。