はじめに
本稿では, AArch64(ARM64)向けの簡単なタスクディスパッチャを実装し, OSのタスク切り替え処理の概要を示すとともに, AArch64での関数呼び出し規約について解説します。
マルチタスクと多重プログラミング環境
Windows, FreeBSD, Linux, BeOS, Solaris などの普及により, マルチタスクOS がパソコン上で利用できるようになってきています. これらのOS のカタログをみると「64 ビットマルチタスクOS」などと書かれています.
では, この「タスク」や「マルチタスク」, 「マルチタスクOS」とは, どんな意味なのでしょう?
マルチタスクを理解する上で重要な概念が, 「多重プログラミング」という概念です. ここでは, まず多重プログラミングについて解説します.
あらゆるプログラムは, 大きく分けて以下の3 つの処理をくりかえし行っています.
-
入力処理
メモリやハードディスクなどの外部記憶から命令やデータを読み込む処理です. -
演算処理
入力処理によって読み込んだ命令やデータにしたがって, 計算を行います. -
出力処理
演算処理の結果を画面やプリンタに出力します.
実は, 上記の処理の中で, CPU を使用するのは, 演算処理を行っているときだけなのです. 入力処理や出力処理を行っている間は, CPU は使用されていません.
あるプログラムが入出力を行っている間に, 別のプログラムの演算処理を行わせるようにし, 複数のプログラム間でCPU を交替で使用するようにすると, 高価な計算機をより効率的に使用することができます.
このようして, 複数のプログラム間で1 つの計算機を共有しながら処理を進める方法を, 「多重プログラミング」といい, 多重プログラミングを行える計算機環境を「多重プログラミング環境」といいます.
タスクとマルチタスク環境
多重プログラミング環境では, 常に複数のプログラムが交互に動いています. このため, 多重プログラミング環境では, ある時点で動作している複数のプログラムの単位を意識し, 明確に区別する必要があります.
一般に, 多重プログラミング環境上で動作している1 つのプログラムのまとまりのことを, 「タスク」と呼びます.
なお, 「タスク」というのは, 多重プログラミング環境におけるプログラムの1 単位を表す概念であり, その実現方式は, 各OS ごとに様々です.
このため, 使用しているOS の文化的背景や, OS 内部での処理方式の違い(プログラムの単位を切り分ける際の手法) などによって, タスクのことを「ジョブ」, 「プロセス」, 「スレッド」などと呼ぶこともあります.
多重プログラミング環境とは, 複数のタスクが動作する環境ということができます. そこで, 多重プログラミングのことを, 「マルチタスキング」, 同様に多重プログラミング環境のことを, 「マルチタスク環境」と
いうこともあります.
これ以降, 本稿では「マルチタスキング」, 「マルチタスク環境」という用語を使用していくことにします.
マルチタスクOS
マルチタスク環境において, 各プログラムにCPU やメモリを割り当てる作業, 言い替えれば, マルチタスクキングのサポートは, 通常OS が行います. マルチタスキングをサポートするOS のことを「マルチタスクOS」と呼びます。
したがって, マルチタスクOS とは, 以下のような機能を持ったソフトウエアだということができます.
-
計算資源の抽象表現をアプリケーションに提供する
プログラムにハードウエアに対する仮想的なインターフェースを提供する -
計算機上の計算資源を分配する
CPU などの資源を管理し, 複数のプログラム(タスク) への資源の分配を行う.
これ以降, 特に断らない限り, マルチタスクOS のことを単にOS と表記します.
コンテキスト
マルチタスクOS では, タスクの実行を途中で中断と再開を行えるようにする必要があります. タスクを中断・再開するためには, タスクが中断した時点の計算機の状態を一時的に記憶しておき, タスクの再開時にその状態を復元する必要があります.
かといって, 計算機に搭載している全ての資源(メモリや外部装置) の状態を記憶するのは不可能です.
そこで, 通常マルチタスクOS は, 必要に応じて, 各タスクに固有な資源の状態だけを退避・復元します. コンテキストの退避・復元処理は, 主に割り込みやCPU例外の発生時やディスパッチ時に行われます. OS によって退避・復元されるタスクの実行時情報を「コンテキスト」と呼びます.
コンテキストの具体的な定義は, それぞれのOS の仕様によって異なりますがRTOS(リアルタイムOS)のようにCPUとメモリ資源だけを管理対象の資源とする場合は、タスクがメモリに保存しているデータだけを退避・復元すれば良いわけです.
ここでは,プログラムが使用するデータを, その格納場所にしたがって, 次のように
分類することでコンテキストの定義を考えてみることにしましょう.
-
レジスタ内部のデータ
通常, CPU は, メモリ上のデータを一度CPU 内部のレジスタという記憶領域に読み込んでから, 演算を行います. マルチタスクOS では, 各タスクが独立してCPU を使用できるようにする必要がありますから, これらレジスタの内容はタスクのコンテキストの一部となります. -
スタック上のデータ
C の一時変数は, メモリ上のスタック領域というメモリ領域に保存されています. スタック領域にあるデータは, スタックポインタという特殊なレジスタを介してアクセスされます. 一時変数を各タスクに割り当てる場合, 各タスクごとに独立したスタック領域を割り当てることとスタックポインタをコンテキストとして退避・復元する必要があります. -
静的領域上のデータ
C の大域変数は, プログラムの静的領域というところに保存されます. 静的領域上のデータを各タスクごとに割り当てる場合は, 各タスク毎に独立したメモリ空間を割り当て, メモリ空間情報をコンテキストの一部として退避・復元する必要があります. メモリ空間をタスク毎に割り当てる場合は, MMU(Memory Management
Unit) というハードウエアの支援が必要となりますので本稿では単純化のためにタスク間で静的領域上のデータを共有するものとします。
以上の議論から, 本稿では, CPU のレジスタだけをコンテキストとして保存すれば良いことが分かります(スタックポインタもレジスタの一種ですからスタックポインタを切り替えることでスタック上のデータは待避復元可能です)。
AArch64のレジスタ構成
AArch64には, 次のようなレジスタが搭載されています。
-
汎用レジスタ(31 本)
X0 ~ X30までの31本の汎用レジスタを搭載しています。 -
スタックポインタレジスタ(4 本)
ユーザプログラム用/カーネル用/ハイパーバイザ用/セキュリティモニタ用の各動作モード毎(用途毎)に独立したスタックポインタを搭載しています。プログラム中からは現在の動作モードのスタックポインタをspレジスタという名前で参照できます。 -
例外リンクレジスタ(3 本)
割込みやシステムコール処理後の復帰先アドレスを格納するレジスタです。カーネル/ハイパーバイザ/セキュリティモニタの各処理から復帰する際の復帰先のアドレスを記録するためのレジスタが搭載されています(ユーザプログラム中での復帰先アドレスは、汎用レジスタのx30が使用されます)。 -
セーブドプログラムステータスレジスタ(3 本)
割込みやシステムコール処理実行前のCPUの状態を保存するレジスタです。カーネル/ハイパーバイザ/セキュリティモニタの各処理から復帰する際に本レジスタの内容でCPUの状態を復元してから呼出元に復帰します。 -
プログラムカウンタ(1本)
実行中の命令を指し示す内部レジスタです。プログラム中から直接参照することはできません。 -
浮動小数点/ベクタレジスタ
本稿では, 単純化のため扱いませんが, AArch64は, 浮動小数点命令をサポートしており, 32 本の 64 ビットレジスタを使用した浮動小数点命令やSIMD命令を利用することが可能です。 -
システムレジスタ
本稿では, 単純化のため扱いませんが, メモリ管理やキャッシュ、割込み、例外処理を制御するためのシステムレジスタが搭載されています。
単純化のために本稿では, 汎用レジスタを切り替えることでタスク切り替えを行うことにします。
Application Binary InterfaceとAArch64での汎用レジスタの利用用途
一般にアプリケーションプログラムとOSやライブラリ、プログラム言語などのシステムソフトウエアとのインターフェース規約を「Application Binary Interface(ABI)」といいます。
ABIには、プログラムのサブルーチンを呼び出す際のレジスタの使用規約やアプリケーションバイナリのフォーマット、ライブラリリンク時のレジスタの使用規約が含まれています。
バイナリファイルの形式やデータ型はレジスタ使用法に付随する約束事だと解釈して大きくとらえると、ABIとは、CPUの各レジスタの利用方法に関する約束事を規定した仕様書ということができます。
AArch64の関数呼び出し規約
関数呼び出し規約とは、ABIに規定されたCの関数呼び出しの前後で保存されるレジスタについての規約です。CPUのレジスタの状態を呼出元で保存するか(caller save)、呼び出された側で保存するか(callee save)を規定した規約でレジスタ毎に呼出元、呼び出された側のいずれかで保存するかを規定しています。
AArch64の場合は, Procedure Call Standard for the ARM 64-bit Architectureという文書でABIが規定されており、関数呼び出し規約は 以下のようになります。
レジスタ | caller save/callee save | 用途 |
---|---|---|
SP | 関数呼び出し間で保存不要 | スタックポインタ |
x30(LR) | callee save | リンクレジスタ。関数復帰アドレスを格納する |
x29(FP) | callee save | フレームポインタ。関数内のローカル変数などの格納領域アドレスを格納する |
x19-x28 | callee save | 呼び出された側で保存するレジスタ |
x18 | callee save | プラットフォームレジスタ。呼び出された側で保存する |
x16-x17 | callee save | ライブラリの動的リンクに使用するレジスタ呼び出された側で保存する |
x9-x15 | caller save | テンポラリレジスタ |
x8 | callee save | 間接リゾルトレジスタ |
x0-x7 | callee save | 関数の引数や返値(x0)の格納に使用するレジスタ |
タスク切り替え処理
タスク切り替え処理時に待避・復元するレジスタは、プロセッサの関数呼び出し規約に従って決定します。
関数内の処理の途中でOSカーネルによって強制的にタスクが切り替えられる(OSがタスクを強制的に切り替えることをカーネルレベルプリエンプションと言います)ことがあります。
一般にC言語でOSを記載する場合, 関数呼び出し規約に従って、タスク切り替え処理を実施する前にC言語の処理系がcaller savedレジスタの待避を行っています。
このため、OSのタスク切り替え処理では、関数呼び出し規約上、呼出元関数が保存していないレジスタであるcallee savedレジスタとスタックポインタ/フレームポインタ/リンクレジスタ待避・復元することで、元のタスクに復帰した場合のレジスタ状態が再現されることを保証することが可能となります。
AArch64の場合は, x0-x8の引数/返値およびx16-x30までのcallee savedレジスタとスタックポインタを切り替えれば良いわけです。
コンテキスト待避・復元処理
タスク切り替え処理時のレジスタの状態は、各タスクのスタック領域中に保存します。
コンテキスト切り替え時のスタック操作の内容を図示すると以下のようになります.
C言語の関数プロトタイプで記載するとコンテキスト切り替え処理は以下のようになります。
各スレッド毎のスタックポインタをポインタ変数で管理し, コンテキスト切り替え処理時にスタックポインタを指し示すポインタ変数を更新するようにすることでタスクの切り替え時のコンテキスト格納先をタスクの管理情報内に保存・更新し、切り替え先のタスクのコンテキスト保存先アドレスを取得可能にしています。
/** AArch64のスタック切り替え
@param[in] _prev 切り替えられるスレッドのスタックアドレスを格納しているポインタ変数のアドレ
ス
@param[in] _next 切り替えるスレッドのスタックアドレスを格納しているポインタ変数のアドレス
*/
void hal_do_context_switch(void **_prev, void **_next);
コンテキスト切り替えに必要なレジスタ操作を行うために, コンテキスト切り替え処理はアセンブラのサブルーチンとして記述します。
実際のコードを以下に示します。
/** AArch64のスタック切り替え
@param[in] prev 切り替えられるスレッドのスタックアドレスを格納しているポインタ変数のアドレ
ス
@param[in] next 切り替えるスレッドのスタックアドレスを格納しているポインタ変数のアドレス
@note Procedure Call Standard for the ARM 64-bit Architecture
( http://infocenter.arm.com/help/topic/com.arm.doc.ihi0055b/IHI0055B_aapcs64.pdf )
で規定された 引数パラメタ(r0-r7, および, 間接リゾルト(r8)),
intra-procedure-call temporary register(r16,r17), Platform Register(r18),
callee savedレジスタ(r19-28),
フレームポインタ(FP=r29), リンクレジスタ(LR=r30)を保存する
@note stp命令を使用する関係上, r15も保存している
*/
hal_do_context_switch:
stp x29, x30, [sp, #-16]! /* スタックポインタを16バイト上行してからx29,x30を格納 */
stp x27, x28, [sp, #-16]!
stp x25, x26, [sp, #-16]!
stp x23, x24, [sp, #-16]!
stp x21, x22, [sp, #-16]!
stp x19, x20, [sp, #-16]!
stp x17, x18, [sp, #-16]!
stp x15, x16, [sp, #-16]!
stp x7, x8, [sp, #-16]!
stp x5, x6, [sp, #-16]!
stp x3, x4, [sp, #-16]!
stp x1, x2, [sp, #-16]!
stp xzr,x0, [sp, #-16]! /* 16バイト境界に合わせるために, ゼロレジスタ(ダミー)とx0を保存 */
# Switch stack
mov x19, sp
str x19, [x0] /* 第1引数で与えられたアドレスにSPの内容を格納 */
ldr x19, [x1] /* 第2引数で与えられたアドレスからSPの内容を復元 */
mov sp, x19
# Next Thread
ldp x1, x0, [sp], #16 /* スタックからダミー(x1にゼロが読み込まれる)とX0の内容を復元し, スタックポインタを16バイト下降 */
ldp x1, x2, [sp], #16
ldp x3, x4, [sp], #16
ldp x5, x6, [sp], #16
ldp x7, x8, [sp], #16
ldp x15, x16, [sp], #16
ldp x17, x18, [sp], #16
ldp x19, x20, [sp], #16
ldp x21, x22, [sp], #16
ldp x23, x24, [sp], #16
ldp x25, x26, [sp], #16
ldp x27, x28, [sp], #16
ldp x29, x30, [sp], #16
ret /* x30(LR)の指し示す先に復帰 */
タスク開始関数呼出処理
タスクの開始関数を
void func(void *arg);
という形式で定義することにします。
タスク生成時に上記の形式の関数のアドレスと引き渡す引数を前節のコンテキストの形式でスタック上に記録することでタスク生成関数を作成します。
スタックのアドレスはポインタ変数で指し示すことができますので、本処理はC言語で記載することが可能です。
典型的な実装では, タスク開始関数から復帰(return)した際に, 自タスクの終了処理が呼ばれるようにタスク開始関数を呼び出す関数を用意します。典型的には以下のようなコードになります。
/** スレッドを開始する
*/
void
thr_thread_start(void (*fn)(void *), void *arg){
psw_enable_interrupt(); /* 割り込みを許可する */
fn(arg); /* スレッド開始関数を呼び出す */
thr_exit_thread(0); /* スレッド開始関数から帰ったら自スレッドを終了する */
/* ここには来ない. */
return ;
}
タスク開始時用のコンテキストの設定
タスクのスタック割当て時にコンテキスト切り替え処理から上記のタスク開始関数を呼び出す関数(thr_thread_start)が呼び出されるようにスタックの内容を初期化します。
AArch64では, ret命令による関数から復帰は, リンクレジスタの指し示す先のアドレスへのレジスタ指定ジャンプと同義です.
コンテキスト切り替え時のコンテキストのリンクレジスタにタスク開始関数を呼び出す関数(thr_thread_start)を指定することで, コンテキスト切り替え処理を契機としてthr_thread_startへジャンプさせることができます。
以下の処理で第1引数を格納するレジスタ(x0)にタスク開始関数のアドレスを, 第2引数を格納するレジスタ(x1)にタスク開始関数の引数の値を設定していますので, コンテキスト切り替え処理関数からの復帰(ret命令実行)により , 「タスク開始関数を呼び出す関数(thr_thread_start)をタスク開始関数のアドレスとその引数の値を引き渡して呼び出した」かのように振る舞います。
/** スレッドスタックにスレッド開始関数情報をセットする
@param[in] thr スレッド管理情報
@param[in] fn スレッドの開始関数
@param[in] arg スレッドの開始関数の引数
*/
void
hal_setup_thread_function(struct _thread *thr, void (*fn)(void *), void *arg) {
addr_t *sp;
thread_attr_t *attr = &thr->attr;
sp = (addr_t *)thr_refer_thread_info(thr);
--sp; /* スレッド管理情報の一つ上から引数を積み上げる */
*sp-- = (addr_t)thr_thread_start; /* x30(LR)にタスク開始関数を呼び出す関数のアドレスを設定 */
*sp-- = (addr_t)0;
*sp-- = (addr_t)0;
*sp-- = (addr_t)0;
*sp-- = (addr_t)0;
*sp-- = (addr_t)0; /* x25 */
*sp-- = (addr_t)0;
*sp-- = (addr_t)0;
*sp-- = (addr_t)0;
*sp-- = (addr_t)0;
*sp-- = (addr_t)0; /* x20 */
*sp-- = (addr_t)0;
*sp-- = (addr_t)0;
*sp-- = (addr_t)0;
*sp-- = (addr_t)0;
*sp-- = (addr_t)0; /* x15 */
*sp-- = (addr_t)0; /* x8 */
*sp-- = (addr_t)0;
*sp-- = (addr_t)0;
*sp-- = (addr_t)0; /* x5 */
*sp-- = (addr_t)0;
*sp-- = (addr_t)0;
*sp-- = (addr_t)0;
*sp-- = (addr_t)arg;/* 第2引数(x1)にタスク開始関数の引数の値を設定 */
*sp-- = (addr_t)fn; /* 第1引数(x0)にタスク開始関数を設定 */
*sp = (addr_t)0xdeadbeef; /* スレッド初期化時のdummy領域の値 */
attr->stack = sp; /* スタックポインタを更新する */
}
このとき, リンクレジスタには, タスク開始関数を呼び出す関数(thr_thread_start)のアドレスが入っていますので, thr_thread_startからreturnによる復帰は行えません(return しても, thr_thread_startが再度呼び出されます)。
このため, thr_thread_start内で, 自タスク終了関数(thr_exit_thread)を呼び出して自タスクを終了し, 他のタスクにCPUを明け渡します。
自スレッド終了処理
thr_exit_thread内部では, 自タスクをレディキューから外し, スケジュール対象外とした後に, タスクスケジューラ(sched_schedule)を呼び出すことで, タスクスケジューラからthr_exit_thread関数に復帰することがないようにしています。
/** 自スレッドの終了
@param[in] code 終了コード
*/
void
thr_exit_thread(int code){
psw_t psw;
psw_disable_interrupt(&psw);
rdq_remove_thread(current); /* レディーキューから外す */
current->exit_code = (exit_code_t)code; /* 終了コードを設定 */
current->status = THR_TSTATE_EXIT; /* スレッドを終了状態にする */
reaper_add_exit_thread(current); /*< 回収スレッドにスレッドの回収を依頼する */
out:
psw_restore_interrupt(&psw);
sched_schedule(); /* 自スレッド終了に伴うスケジュール */
return;
}
補足: スレッドスタックの解放タイミングについて
スケジューラを呼び出す際に自スレッドのスタックにコンテキストを積み上げる必要があるため, スレッド終了処理内部でスタックを解放することはできません。
もし、スタックを解放してしまうと、自スレッド終了処理からスケジューラ内のディスパッチ処理が完了するまでの間にスタックに使用していたメモリを他の用途に使用されコンテキストを破壊してしまうためです。ユニプロセッサカーネルでかつ非排他処理区間内でのカーネルレベルプリエンプションを許さない環境では問題は発生しませんが資源の有効期間の観点からディスパッチ完了後にスタックを解放する方が望ましいでしょう。
このため, スレッド管理情報の解放とスタックの解放を行うために別のスレッドとして回収スレッドを導入します。自スレッド終了時にreaper_add_exit_threadを呼び出し, ディスパッチ後に回収スレッドを起動するようにしています。
UNIXでは, 親プロセスやinitプロセスがwait(2)システムコールを呼び出すことで上記の回収スレッドの役割を果たします。
タスク切り替え処理のサンプルプログラム
上記の処理を実際に実装したサンプルをhttps://github.com/takeharukato/sample-tsk-sw.git に公開しています。
Linux用のAArch64のクロスコンパイラ(aarch64-none-linux-gnueabi-gcc/aarch64-none-linux-gnueabi-ldなど)へのパスを環境変数PATHに設定し,
export CROSS_COMPILE=aarch64-none-linux-gnueabi-
export CPU=aarch64
を実行して環境変数を設定した上で,
make menuconfig
make
を実行するとkernel.elf
というAArch64用のサンプルプログラムが生成されます。
qemu-system-aarch64 -M virt -cpu cortex-a57 -nographic -kernel kernel.elf
を実行することで, QEmuのAArch64システムシミュレータ上でタスク切り替え処理が実行されます。
$ qemu-system-aarch64 -M virt -cpu cortex-a57 -nographic -kernel kernel.elf
boot
threadA
threadB
threadC
threadA
threadB
threadA
threadB
threadA
threadB
...