以下の内容はhttps://www.valinux.co.jp/blog/entry/20260331より取得しました。


ARM64 OSを作ろう (3) ~ 割り込みとタイムシェアリング

執筆者 : 金沢工業大学 工学部 情報工学科 橘 真吾 (インターン生)
監修者:高橋 浩和

※ 「ARM64 OSを作ろう」連載記事一覧はこちら
※ 「ARM64 OS」のコードはGitHubのタイマー割り込みタイムシェアリングにて公開しています。


1. はじめに

前回はハードコードされた複数のタスクが切り替わりながら同時実行できる仕組みを作りました。今回は、タイマー割り込みを契機にタスクスケジューラを呼び出すことで、タイムシェアリング機能を実現します。


2. 概要

ARM64は、制御対象のデバイスからの通知を「割り込み」として受け取ります。 Raspberry Pi 4は、これら割り込みの交通整理を担当する割り込みコントローラGICv2(Generic Interrupt Controller v2)を搭載しています。GICv2は、複数のデバイスからの割り込み要求のうち最も優先度の高い要求からARM64コアに通知します。 割り込み通知を受けたARM64は、それまで動作していた処理(タスクなど)を中断し、割り込みに対応するハンドラを起動します。起動するハンドラは予めベクタテーブルと呼ばれるメモリ領域に登録しておく必要があります。

本記事では、デバイスとして「タイマーコントローラ」を利用します。タイマーに一定周期で割り込みを発生させ、タイマーハンドラを起動します。タイマーハンドラからタスクスケジューラを呼び出すことで、タスク切り替えを自動的に行なわせ、タイムシェアリングを実現します。

ここまではCPU実行モードを意識せずにコードを書いてきましたが、今回からはARM64のEL1という例外レベル(特権レベル)を前提とするコードを書いていきます。EL1はOSを実行するための例外レベルです。 ARM64の実行モードの詳細については、「ARMv8-A AArch64ベアメタルプログラミング」、「新Linuxカーネル解読室 - Linuxの起動 ~ARM64編~ (1)」の説明を参考にしてください。

3. EL1モード設定

起動直後、ARM64の動作モードをEL2からEL1に遷移させます。ここはコード内のコメントで概要を説明します。

armv8def.h

#ifndef _ARMV8DEF_H_
#define _ARMV8DEF_H_

#define DAIF_F  0x1U
#define DAIF_I  0x2U
#define DAIF_A  0x4U
#define PSR_MODE_EL1h  0x00000005    /* EL1でSP_EL1を使用する設定 */
#define PSR_MODE64_BIT     (0<<4)
#define PSR_F_BIT   (DAIF_F << 6)
#define PSR_I_BIT   (DAIF_I << 6)
#define PSR_A_BIT   (DAIF_A << 6)
#define HCR_EL2_RW   (1 << 31)       /* EL1をAArch64(64bit)で動作させる */

#endif  /* _ARMV8DEF_H_ */

start.S

ARM64 OSはこれまでの連載通り、_startからEL2モードで動作を開始します。_start関数は、EL1モードに遷移しつつEL1_start関数を呼び出します。 EL1モードは、64ビットモードかつハンドラモード(EL1h、割り込み発生時のスタック自動切換え機能は利用しない設定)で動作させることにします。

#include "armv8def.h"

    .section .reset,"ax",@progbits
    .globl _start
_start:    /* EL2モードで、ここから実行を開始します */

    /* EL1モードを、64bitモード(AArch64)で利用するために必要。 */
    mov x3, #(HCR_EL2_RW)
    msr HCR_EL2, x3
    isb

    /* EL1に遷移した時、EL1_startから実行を始める */
    ldr x0, =EL1_start
    msr ELR_EL2, x0

    /* EL1に遷移した時のPSTATE(ステータスレジスタ)の値(割り込み禁止、64ビットモード(AArch64)、ハンドラモード)を設定。 */
    mov x0, PSR_A_BIT | PSR_I_BIT | PSR_F_BIT | PSR_MODE64_BIT | PSR_MODE_EL1h
    msr    SPSR_EL2, x0

    eret    /* EL2からEL1へ遷移 */

EL1_start:       /* EL1モードのエントリ */
    ldr x4, =_stack_end
    mov sp, x4

    bl main
    b .

4. 割り込み処理

ベクタテーブルの登録

ARM64では割り込みや例外が発生した時に呼び出すハンドラを、ベクタテーブルに登録する必要があります。 ARM64のベクタテーブル仕様の詳細については、「ARMv8-A AArch64ベアメタルプログラミング」を参照してください。

ベクタテーブルは各ELで用意する必要がありますが、本OSはEL1のみを利用するため、EL1用のベクタテーブルのみ用意します。*1

今回は、ベクタテーブルには割り込み用のハンドラのみ登録します。ベクタテーブルのその他のエントリにはダミーハンドラundefined_handlerを登録しておきます。 ベクタテーブルは16エントリあり、そのうち6番目のエントリ(Current EL with SPx / IRQ用のエントリ)に割り込みエントリIrqHandlerを登録します。EL1hモード(EL1モードかつハンドラモード)動作している時に割り込みが発生すると、このエントリが使用されます。

作成したベクタテーブルは、VBAR_EL1システムレジスタに登録します(SetVectorTable関数)。

    .section ".entry.text", "ax"

    .global default_vector_table

    .balign 0x800
default_vector_table:
// Current EL with SP0
    .balign 0x80
curr_el_sp0_sync:       // Synchronous
    b undefined_handler

    .balign 0x80
curr_el_sp0_irq:        // IRQ/vIRQ
    b undefined_handler

    .balign 0x80
curr_el_sp0_fiq:        // FIQ/vFIQ
    b undefined_handler

    .balign 0x80
curr_el_sp0_serr:       // SError/vSError
    b undefined_handler

// Current EL with SPx
    .balign 0x80
curr_el_spx_sync:       // Synchronous
    b undefined_handler

    .balign 0x80
curr_el_spx_irq:        // IRQ/vIRQ
    b IrqHandler

    .balign 0x80
curr_el_spx_fiq:        // FIQ/vFIQ
    b undefined_handler

    .balign 0x80
curr_el_spx_serr:       // SError/vSError
    b undefined_handler

// Lower EL using AArch64
    .balign 0x80
lower_el_a64_sync:       // Synchronous
    b undefined_handler

    .balign 0x80
lower_el_a64_irq:        // IRQ/vIRQ
    b undefined_handler

    .balign 0x80
lower_el_a64_fiq:        // FIQ/vFIQ
    b undefined_handler

    .balign 0x80
lower_el_a64_serr:       // SError/vSError
    b undefined_handler

// Lower EL using AArch32
    .balign 0x80
lower_el_a32_sync:       // Synchronous
    b undefined_handler

    .balign 0x80
lower_el_a32_irq:        // IRQ/vIRQ
    b undefined_handler

    .balign 0x80
lower_el_a32_fiq:        // FIQ/vFIQ
    b undefined_handler

    .balign 0x80
lower_el_a32_serr:       // SError/vSError
    b undefined_handler

    .balign 0x80
undefined_handler:
    eret

タイマーハンドラの実装

次に、割り込みエントリIrqHandlerを見ていきます。

割り込みエントリの中からそのままC関数を呼ぶと、割り込み発生前に実行中だった処理のレジスタを壊してしまいます。 そのため、IrqHandlerの冒頭でCaller-savedレジスタを退避してから関数呼び出しを行い、最後にCaller-savedレジスタを復元してeretで元の処理へ復帰させます。

Caller-savedレジスタとは、関数呼び出し時に呼び出し元が必要に応じて保存するレジスタで、ARM64では x0〜x18 が該当します。 タイマー割り込みは通常の関数呼び出しとは異なり、実行中の処理へ突然割り込むため、ハンドラ側で明示的に退避・復元しておく必要があります。

また割り込みエントリ中から、InterruptHandlerを関数呼び出しするため、x30(LR)レジスタも保存します。

例えば、最小構成では次のように実装できます。

.global IrqHandler
    .balign 0x80
IrqHandler:
    // 割り込み発生時、割り込まれたコンテキストのPCとPSTATEの値がそれぞれELR_EL1とSPSR_EL1に退避される

    //  x18, x30(LR)を退避
    stp x18, x30, [sp, #-16]!

    // caller-savedレジスタを退避
    stp x0,  x1,  [sp, #-16]!
    stp x2,  x3,  [sp, #-16]!
    stp x4,  x5,  [sp, #-16]!
    stp x6,  x7,  [sp, #-16]!
    stp x8,  x9,  [sp, #-16]!
    stp x10, x11, [sp, #-16]!
    stp x12, x13, [sp, #-16]!
    stp x14, x15, [sp, #-16]!
    stp x16, x17, [sp, #-16]!

    bl  InterruptHandler

    // 逆順でcaller-savedレジスタを復元
    ldp x16, x17, [sp], #16
    ldp x14, x15, [sp], #16
    ldp x12, x13, [sp], #16
    ldp x10, x11, [sp], #16
    ldp x8,  x9,  [sp], #16
    ldp x6,  x7,  [sp], #16
    ldp x4,  x5,  [sp], #16
    ldp x2,  x3,  [sp], #16
    ldp x0,  x1,  [sp], #16

    // 最後に x18, x30 を復元
    ldp x18, x30, [sp], #16

    // eret命令により、ELR_EL1とSPSR_EL1の値がそれぞれPCとPSTATEに書き戻される
    eret

このコードでは、割り込み処理の前にレジスタをスタックへ保存し、C関数InterruptHandlerを呼び出した後で元に戻しています。 これにより、割り込み前に動いていた処理のレジスタ状態を壊さずにタイマー処理を実行できます。

割り込み処理の開始と終了

割り込み処理InterruptHandlerでは、GICv2を介して以下の手順で処理を行ないます。

  1. ACK操作: GICv2から割り込み要因を取得します。この操作はGICv2に対するACK操作(割り込み処理開始)も兼ねています。
  2. 割り込み処理本体: 割り込み要因に応じた処理を行う
  3. EOI操作: GICv2にEOIを送り、割り込み処理の終了を通知する

InterruptHandlerの中でGICC_IARを読み出して割り込みIDを取得し、処理の最後にGICC_EOIRへ割り込みIDを書き戻して割り込み終了を通知できるようになります。

void InterruptHandler(void)
{
    uint32_t iar = ReadGICC32(GICC_IAR);    // GICC_IARレジスタから割り込みIDを取得する。ACK操作を兼ねる。
    uint32_t irq = GetIRQIdFromIAR(iar);    // 割り込みIDから割り込み番号を計算する。割り込みIDの下位10ビットが割り込み番号(IRQ)となる。

    if (irq == GEN_TIMER_INTID) {    // Raspberry Pi 4環境では、30番です
        Timer();     // 割り込み番号がタイマー割り込みを指していたら、タイマー処理を行う。
    }

    WriteGICC32(GICC_EOIR, iar);    // GICC_IARレジスタを介して、割り込み処理の完了を通知する(EOI操作)
}

Timer関数の実装

Timer()では、タイマー割り込みが発生したことを表示し、次のタイマー割り込み時刻を設定しています。

void Timer(void)
{
    print_message("\nTimer\n\n");
    WriteSysReg(CNTP_CVAL_EL0, ReadSysReg(CNTPCT_EL0) + TICK_CYCLES);    // 次にタイマー割り込みを発生させる時刻を設定
}

CNTPCT_EL0は現時刻を表すタイマーのカウンタ値CNTP_CVAL_EL0は次にタイマー割り込みを発生させる時刻(比較値)です。 CNTPCT_EL0の値がCNTP_CVAL_EL0の値と等しくなると、タイマー割り込みを発生します。*2

GICv2とタイマーコントローラの初期化

起動時にSetupGIC関数、StartTimer関数を使ってGICv2とタイマーコントローラ(Generic Timer)の初期化を行います。 手順が長いので、下記のコードを参照ください。

gicctl.cのコード

gicv2def.hのコード

起動時のmain関数にSetupGIC()StartTimer()を追加します。 load_context()呼び出し前に、EnableInt()を呼び出して、割り込みを許可します。

void main(void)
{
    clearbss();
    SetVectorTable(); // ベクタテーブルを登録
    SetupGIC(); // GICv2を初期化
    StartTimer(); // Generic Timerを初期化

    InitTask(TASK1, Task1);
    InitTask(TASK2, Task2);
    InitTask(TASK3, Task3);

    CurrentTask = TASK1;
    EnableInt(); // 割り込み許可、これ以降CPUはタイマー割り込みを受け付ける
    load_context(&TaskControl[CurrentTask].sp);
}

SetupGICの役割

SetupGIC()は、割り込みコントローラであるGIC(Generic Interrupt Controller)を初期化する関数です。 この処理によって、CPUが割り込みを受け取れるようになります。

ベクタテーブルだけを登録しても、GIC側の設定ができていなければIRQは届きません。 そのため、割り込み処理を動かすにはSetVectorTable()に加えてSetupGIC()も必要になります。

StartTimer()は、Generic Timerの割り込みを有効化し、最初のタイマー割り込み時刻を設定する関数です。

static void StartTimer(void)
{
    ActivateInterrupt(GEN_TIMER_INTID, 16U, FALSE);    // Generic Timerの割り込みをGICに登録する
    WriteSysReg(CNTP_CVAL_EL0, ReadSysReg(CNTPCT_EL0) + TICK_CYCLES );    // 初回のタイマー割り込み時刻を設定する
    WriteSysReg(CNTP_CTL_EL0, CNTP_CTL_EL0_ENABLE);    // タイマーを有効化する
}

StartTimer()は、GICの初期化が完了していることを前提にしているため、SetVectorTable()SetupGIC()StartTimer()の順に呼び出す必要があります。

文字列出力が崩れないようにするには

タイマー割り込みを有効にすると、タスクの実行中だけでなく、文字列を出力している途中にも割り込みが入るようになります。 その結果、複数のタスクやタイマーハンドラの出力が途中で混ざり、UARTの表示が崩れることがあります。

これを防ぐために、print_message()の実行中だけ一時的に割り込みを禁止します。

まず、primitive.Sに割り込みの禁止・許可を行う関数を追加します。

primitive.S

#include "armv8def.h"

    .global EnableInt
    .type EnableInt,@function
EnableInt:
    MSR   DAIFClr,  #(DAIF_A | DAIF_F | DAIF_I)
    ret
    .size EnableInt,.-EnableInt

    .global DisableInt
    .type DisableInt,@function
DisableInt:
    MSR   DAIFSet,  #(DAIF_A | DAIF_F | DAIF_I)
    ret
    .size DisableInt,.-DisableInt

DisableInt()DAIFSetを使ってAFIビットを立て、IRQなどの割り込みを禁止します。 逆にEnableInt()DAIFClrを使ってそれらのビットをクリアし、再び割り込みを受け付けるようにします。

続いて、main.cprint_message()にこれらを追加します。

main.c

extern void EnableInt(void);
extern void DisableInt(void);

void print_message(const char* s, ...){
    DisableInt();
    va_list ap;
    va_start (ap, s);

    while (*s) {
        if (*s == '%' && *(s+1) == 'x') {
            unsigned long v = va_arg(ap, unsigned long);
            _Bool print_started = FALSE;
            int i;

            s += 2;
            for (i = 15; i >= 0; i--) {
                unsigned long x = (v & 0xFUL << i*4) >> i*4;
                if (print_started || x != 0U || i == 0) {
                    print_started = TRUE;
                    uart_putchar(x < 10 ? ('0' + x) : ('a' + x - 10));
                }
            }
        } else {
            if (*s == '\n') {
                uart_putchar('\r');
            }
            uart_putchar(*s++);
        }
    }

    va_end (ap);
    EnableInt();
}

このようにprint_message()の先頭でDisableInt()を呼び、最後にEnableInt()を呼ぶことで、1回の文字列出力が終わるまで割り込みが入らないように しています。

これにより、たとえばTask1\nを出力している途中でタイマー割り込みが入ってTimer\nが割り込む、といった現象を防げます。 要するに、UART出力を途中で中断されないクリティカルセクションとして扱っているわけです。

ただし、この方法は文字列出力中は割り込み遅延が発生するため、長い出力を多用するとリアルタイム性には不利です。 今回は動作確認用の簡易実装として、まずは出力の整合性を優先しています。


5. 動作確認

タイマー割り込みが一定周期で発生し、画面に "Timer" と表示されることを確認します。この段階ではタイマー割り込みを契機としたタスク切り替えは行いません。

以下に途中までの完全なコードを掲示しておきます。

github.com

実行結果:

U-Boot> fatload mmc 0:1 0x1000 boot_arm64os.scr
297 bytes read in 72 ms (3.9 KiB/s)
U-Boot> source 0x1000
## Executing script at 00001000
8312 bytes read in 89 ms (90.8 KiB/s)
54874 bytes read in 23 ms (2.3 MiB/s)
Working FDT set to 3000000
## Booting kernel from Legacy Image at 04000000 ...
   Image Name:   
   Image Type:   AArch64 U-Boot Standalone Program (uncompressed)
   Data Size:    8248 Bytes = 8.1 KiB
   Load Address: 04000000
   Entry Point:  04000000
   Verifying Checksum ... OK
   Loading Standalone Program
## Starting application at 0x04000000 ...
Task1
Task2
Task3
Task1
Task2
Task3
Task1
:
(中略)
:
Task2
Task3
Task1
Task2
Task3
Task1

Timer

Task2
Task3
Task1
Task2
Task3
Task1
Task2
Task3
Task1
:
(中略)
:
Task2
Task3
Task1
Task2
Task3
Task1

Timer

Task1
Task2
:
:

6. スケジューリング

タスクスケジューラ

今まではタスク自身が明示的にタスクスケジューラScheduleを呼び出す協調型のマルチタスクでしたが、今回はタイマーハンドラから強制的にタスクを切り替える「プリエンプティブ・マルチタスク」を実現します。

実現にあたっては、注意点があります。

  1. タスクスケジューラ実行中にタイマー割り込みが入らないこと タスクスケジューラ実行中にタイマー割り込みが入り、そこから起動されたタイマーハンドラがタスクスケジューラを呼び出すとコンテキストが壊れます。

  2. タスクスケジューラはタスクコンテキスト上で呼び出すこと 実行状態はタスク状態(割り込み・例外発生状態ではない)であることが必要です。 また、タスクスケジューラはタスクスタックを使って動作する前提で実装されています。

1.の課題を解決するため、本OSではタスクスケジューラは全割り込み禁止で実行することにします。 2.については、本OSは「割り込みスタック」を用いず、割り込みハンドラはタスクスタックを流用します*3。そのため、割り込み処理完了後(EOI後)であれば、スケジューラを呼び出しても整合性が保てます。

タスクスケジューラは割り込み禁止状態から呼び出すための_Scheduleと、割り込み許可状態から呼び出すためのScheduleを用意します。 Schedule関数は割り込み禁止(DisableInt関数)した後、タスクスケジューラの本体関数_Scheduleを呼び出すこととします。

void _Schedule(void)
{
    TaskIdType from = CurrentTask;
    CurrentTask = ChooseNextTask();
    TaskSwitch(&TaskControl[from], &TaskControl[CurrentTask]);
}

void Schedule(void)
{
    DisableInt();
    _Schedule();
    EnableInt();
}

タイマーハンドラ

タイマーハンドラ本体(Timer関数)が0以外を戻り値とした場合、タスクスケジューラを呼び出すものとします。戻り値はx0レジスタに格納されています。 Timerの戻り値の確認とスケジューラ呼び出しの判定はすべてC関数側で完結します。

int InterruptHandler(void)
{
    int preempt = 0;

    uint32_t iar = ReadGICC32(GICC_IAR);

    uint32_t irq = GetIRQIdFromIAR(iar);

    if (irq == GEN_TIMER_INTID) {
        preempt = Timer();                    // 0以外でプリエンプト要求
    }

    WriteGICC32(GICC_EOIR, iar);

    if (preempt) {
        _Schedule();                           // スケジューラ呼び出し
    }
    return 0;
}

IrqHandlerアセンブリコードから見ると、bl InterruptHandlerの一命令で「タイマー処理→EOI→必要ならスケジュール」までが完結します。

割り込みエントリの修正

ARM64(EL1ハンドラモード)では、割り込み発生時に復帰アドレス(PC)がELR_EL1に保存され、割り込み発生前のプロセッサ状態(PSTATE)がSPSR_EL1に、ハードウェアによって自動的に保存されます。 しかし今回は、InterruptHandlerの延長でタスクスケジューラを呼び出し別タスクに切り替わるようになりました。この時に次の割り込みが発生するとELR_EL1SPSR_EL1が上書きされ、元のタスクの復帰情報が失われてしまいます。 これを防ぐため、ELR_EL1SPSR_EL1の値もスタックへ保存・復元するように IrqHandlerを更新します。

.global IrqHandler
    .balign 0x80
IrqHandler:
    // 割り込み発生時、割り込まれたコンテキストのPCとPSTATEの値がそれぞれELR_EL1とSPSR_EL1に退避される

    //  x18, x30(LR)を退避
    stp x18, x30, [sp, #-16]!

    // caller-savedレジスタを退避
    stp x0,  x1,  [sp, #-16]!
    stp x2,  x3,  [sp, #-16]!
    stp x4,  x5,  [sp, #-16]!
    stp x6,  x7,  [sp, #-16]!
    stp x8,  x9,  [sp, #-16]!
    stp x10, x11, [sp, #-16]!
    stp x12, x13, [sp, #-16]!
    stp x14, x15, [sp, #-16]!
    stp x16, x17, [sp, #-16]!

    // ELR_EL1とSPSR_EL1の値を退避
    mrs x0, ELR_EL1
    mrs x1, SPSR_EL1
    stp x0, x1, [sp, #-16]!

    bl  InterruptHandler

    // ELR_EL1とSPSR_EL1の値を復元
    ldp x0, x1, [sp], #16
    msr ELR_EL1, x0
    msr SPSR_EL1, x1

    // 逆順でcaller-savedレジスタを復元
    ldp x16, x17, [sp], #16
    ldp x14, x15, [sp], #16
    ldp x12, x13, [sp], #16
    ldp x10, x11, [sp], #16
    ldp x8,  x9,  [sp], #16
    ldp x6,  x7,  [sp], #16
    ldp x4,  x5,  [sp], #16
    ldp x2,  x3,  [sp], #16
    ldp x0,  x1,  [sp], #16

    // 最後に x18, x30 を復元
    ldp x18, x30, [sp], #16

    // eret命令により、ELR_EL1とSPSR_EL1の値がそれぞれPCとPSTATEに書き戻される
    eret

タイムスライス

各タスクに タイムスライス(持ち時間)を与えます。Timer() が呼ばれるたびにこれを減算し、0になった際に _Schedule() を呼び出すことで、公平な実行時間の割り当てを実現します。

struct TaskControl {
    void (*entry)(void);
    unsigned long sp;
    long time_slice;    // タイムスライス値
    long remaining_time;    // 今回の周期のCPU割り当て残り時間
    unsigned long task_stack[STACKSIZE] __attribute__((aligned(16)));
} TaskControl[NUMBER_OF_TASKS] = {
    {.entry = Task1, .time_slice = 2},
    {.entry = Task2, .time_slice = 4},
    {.entry = Task3, .time_slice = 1},
};
int Timer(void)
{
    timer_print_message("\nTimer\n\n");
    WriteSysReg(CNTP_CVAL_EL0, ReadSysReg(CNTPCT_EL0) + TICK_CYCLES);
    // タイムスライスを消費し、使い切ったらプリエンプト要求(タスク切り替え要求)を出す
    if (--TaskControl[CurrentTask].remaining_time <= 0) {
        TaskControl[CurrentTask].remaining_time = TaskControl[CurrentTask].time_slice;
        return 1;                       // プリエンプト要求を出す
    }
    return 0;                           // プリエンプト要求しない
}

タイムシェアリングの導入に合わせて、Timer関数内の出力をprint_messageからtimer_print_messageへ変更しています。タイマーハンドラはすでに割り込み禁止状態で実行されているためDisableIntの呼び出しは不要です。割り込みコンテキストから呼ばれる出力関数であることを明示する意味で、タスクコンテキスト用のprint_messageとは関数を分けています。

timer_print_messageの実装は以下の通りです。

void timer_print_message(const char* s, ...){
    va_list ap;
    va_start (ap, s);

    while (*s) {
        if (*s == '%' && *(s+1) == 'x') {
            unsigned long v = va_arg(ap, unsigned long);
            _Bool print_started = FALSE;
            int i;

            s += 2;
            for (i = 15; i >= 0; i--) {
                unsigned long x = (v & 0xFUL << i*4) >> i*4;
                if (print_started || x != 0U || i == 0) {
                    print_started = TRUE;
                    uart_putchar(x < 10 ? ('0' + x) : ('a' + x - 10));
                }
            }
        } else {
            if (*s == '\n') {
                uart_putchar('\r');
            }
            uart_putchar(*s++);
        }
    }

    va_end (ap);
}

タスク初期化処理

タスクスケジューラを割り込み禁止状態で動作するように変更したため、全てのタスクは割り込み禁止状態から動作を始めます。今までは各タスクのエントリ関数から実行を開始するようにしていましたが、これを共通関数TaskEntryから実行を始めるように変更します。TaskEntry関数は割り込み許可(EnableInt関数)操作を行なった後、各タスクのエントリ関数を呼び出します。

struct TaskControl {
    void (*entry)(void);
    unsigned long sp;
    long time_slice;
    long remaining_time;
    unsigned long task_stack[STACKSIZE] __attribute__((aligned(16)));
} TaskControl[NUMBER_OF_TASKS] = {
    {.entry = Task1, .time_slice = 2},
    {.entry = Task2, .time_slice = 4},
    {.entry = Task3, .time_slice = 1},
};

static void TaskEntry(void)
{
    EnableInt();
    TaskControl[CurrentTask].entry();
}

InitTask関数では、タスクがTaskEntryから実行を開始するようにスタックを操作します。

static void InitTask(TaskIdType task)
{
    context* p = (context *)&TaskControl[task].task_stack[STACKSIZE] - 1;
    p->lr = (unsigned long)TaskEntry;
    TaskControl[task].sp = (unsigned long)p;
    TaskControl[task].remaining_time = TaskControl[task].time_slice;
}

load_context()呼び出し前やその実行中にタイマー割り込みが入るとOSデータやスタックの整合性がとれなくなりクラッシュする恐れがあります。これを防ぐためmain関数内では割り込みを許可せず、最初のタスクのTaskEntry()が呼ばれてから許可するようにします。

最終形

void main(void)
{
    clearbss();
    SetVectorTable(); // ベクタテーブルを登録
    SetupGIC(); // GICv2を初期化
    StartTimer(); // Generic Timerを初期化

    InitTask(TASK1);
    InitTask(TASK2);
    InitTask(TASK3);

    CurrentTask = TASK1;
    load_context(&TaskControl[CurrentTask].sp);
}

タスク実行コードの修正

タスクスケジューラScheduleを明示的に呼び出さずともタスクが自動的に切り替わることを確認します。Task1、Task2からタスクスケジューラ呼び出しするコードを削除します。

void Task1(void)
{
    while (1) {
        print_message("Task1\n");
        maybe_resched();
    }
}

void Task2(void)
{
    while (1) {
        print_message("Task2\n");
        maybe_resched();
    }
}

void Task3(void)
{
    while (1) {
        print_message("Task3\n");
        Schedule();
    }
}

7. 最終動作確認

これでC関数によるタイムシェアリングのコードを実装したので実行してみましょう。 タスクが自動的に切り替わりながら動作していることが分かります。

実行結果:

U-Boot 2023.07.02 (Dec 13 2025 - 21:44:31 +0900)

DRAM:  948 MiB (effective 7.9 GiB)
RPI 4 Model B (0xd03115)
Core:  211 devices, 17 uclasses, devicetree: board
MMC:   mmcnr@7e300000: 1, mmc@7e340000: 0
Loading Environment from FAT... Unable to read "uboot.env" from mmc0:1... 
In:    serial
Out:   vidconsole
Err:   vidconsole
Net:   eth0: ethernet@7d580000
PCIe BRCM: link up, 5.0 Gbps x1 (SSC)
starting USB...
Bus xhci_pci: Register 5000420 NbrPorts 5
Starting the controller
USB XHCI 1.00
scanning bus xhci_pci for devices... 5 USB Device(s) found
       scanning usb for storage devices... 1 Storage Device(s) found
Hit any key to stop autoboot:  0 
U-Boot> fatload mmc 0:1 0x1000 boot_arm64os.scr
297 bytes read in 72 ms (3.9 KiB/s)
U-Boot> source 0x1000
## Executing script at 00001000
106712 bytes read in 92 ms (1.1 MiB/s)
54874 bytes read in 23 ms (2.3 MiB/s)
Working FDT set to 3000000
## Booting kernel from Legacy Image at 04000000 ...
   Image Name:   
   Image Type:   AArch64 U-Boot Standalone Program (uncompressed)
   Data Size:    106648 Bytes = 104.1 KiB
   Load Address: 04000000
   Entry Point:  04000000
   Verifying Checksum ... OK
   Loading Standalone Program
## Starting application at 0x04000000 ...
Task1
Task1

Timer

Task1
Task1

Timer

Task2
Task2

Timer

Task2
Task2

Timer

Task2
Task2

Timer

Task2
Task2

Timer

Task3
Task1
Task1

Timer

Task1
:
:

8. 最後に

タイマー割り込みはハードウェア依存部が多く難解ですが、今回の実装でOSとしての基本骨格が完成しました。 次回は、この基本骨格上に時限待ち機能と同期機構(セマフォ)を実現します。

ここで紹介したプログラムはGitHubにて公開しています。

github.com

github.com

おまけ

armv8util.h ファイルに、ARM64システムレジスタを操作する時に便利なマクロを用意しています。 システムレジスタの値を読み出すマクロ関数ReadSysReg()、システムレジスタに値を書き込むマクロ関数WriteSysReg()を使うことができます。

#define TOSTRING(x)  #x

#define WriteSysReg(sysreg, val) do {                    \
     uint64_t _r = val;                                    \
     asm volatile("msr " TOSTRING(sysreg) ", %0" : : "r" (_r));       \
     } while (0)

#define ReadSysReg(sysreg) ({                          \
      uint64_t _r;                                        \
      asm volatile("mrs  %0, " TOSTRING(sysreg) : "=r" (_r));         \
     _r; })

*1:本OSでは、EL2で動作するハイパバイザ向け設定は行わないため、発生した割り込みはEL1モードに届けられます。

*2:現在の実装では、タイマー周期がTICK_CYCLESより少しだけ長くなります。

*3:各タスクには割り込み処理に耐えうる十分なスタック容量が必要です




以上の内容はhttps://www.valinux.co.jp/blog/entry/20260331より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14