- (2/18(月)):スタック退避手順に関して追記しました。Armv7辺りの32bitアーキを対象にしています。
はじめに
はじめまして。
これまでは個人的に興味があったRTOSのZephyrに関する記事を書いてきましたが、今回からは実際に仕事で扱っているLinuxに関して投稿していこうと思います。(Zephyrは一旦休憩します)
OSの役割は上記リンク先の先頭にある「ハードウェアの抽象化 & 隠蔽化」と「システムを効率良く利用する」を参照ください。
Linux OSとは
皆さん、Linux OSと言われて何を想像しますか?
RHEL、CentOS、Debianなどのディストリビューションでしょうか。
それともその中に含まれているコアの部分、つまりカーネルでしょうか。
おそらく、前者をイメージされる人の方が多いと思いますが、本記事では後者のLinuxカーネルにスコープにあてて記載していきます。
Linuxカーネルとは
Linuxカーネルはソースコードを理解することは比較的難易度が高いと思います。
・規模が大きい
・関数ポインタが多すぎでクロスリファレンサで追えない
・H/Wの知識も必要
など理由は様々ですが、途中で挫折する方も多いのではないでしょうか。
そういった方々の一助となれば幸いです。
なお、私も「Linuxカーネルを理解しているか?」と言われれれば、(規模や日々のコードの変化などが理由で)回答に困ります。ただし、対象コードを読めば理解はできます。
Linuxカーネルの入手方法、バージョン名と開発/保守スタイル
ここからはメインライン(主流)のカーネルについて述べます。各ディストリビューションの大元となるカーネルです。
##Linuxカーネルの入手方法
ポータルサイトはThe Linux Kernel Archivesになります。2019/2/13現在でLatest Stable Kernelは4.20.8です。
HTTP https://www.kernel.org/pub/
GIT https://git.kernel.org/
RSYNC rsync://rsync.kernel.org/pub/
上記で取得できますので、ご自分の使用環境に合わせてダウンロードして下さい。
##Linuxカーネルのバージョン名
現在の4.20.8を例で示すと4がメジャーバージョン、20がマイナーバージョン、8がリビジョン番号です。
2.4や2.6が出始めた頃は、マイナーバージョンが偶数の版数を「安定板」、奇数のバージョンを「開発版」と呼んでいましたが、現在はそのような区別はありません。
リビジョン番号は、Bug Fix/Security Fixで機能追加はありません。
##開発/保守スタイル
Linuxカーネルのバージョンリリースの間隔は約65日です。新バージョンがリリースされた直後の2週間をマージウインドウと言って次版で取り込む機能を各サブシステムのツリーからLinusがpullする期間があります。ウインドウが閉じた後はデバッグ期間でrc(release candidate)版がリリースされます。通常はrc7からrc8までリリースします。その後、新バージョンを正式リリースします。
正式リリースされた後は保守フェーズに入り、4.20.1、4.20.2、...とインクリメントされていきます。通常のバージョンは次々版のリリース(2、3カ月)までサポートされます。一方、Long Term Support(LTS)版もあり、これはGreg-KH氏が年に1度、特定のバージョンを採用して長期サポートを行います。
現在サポートしているLTS版を示します。
Longterm release kernels Version | Maintainer | Released | Projected EOL |
---|---|---|---|
4.19 | Greg Kroah-Hartman | 2018-10-22 | Dec, 2020 |
4.14 | Greg Kroah-Hartman | 2017-11-12 | Jan, 2024 |
4.9 | Greg Kroah-Hartman | 2016-12-11 | Jan, 2023 |
4.4 | Greg Kroah-Hartman | 2016-01-10 | Feb, 2022 |
3.16 | Ben Hutchings | 2014-08-03 | Apr, 2020 |
余談ですが、東芝や日立が中心となって取り組んでいるCivil Infrastructure ProjectというThe Linux Foundation傘下のコラボラティブプロジェクトがあり、このプロジェクトではSLTS(Super Long Term Support)を実現しています。
交通機関や発電所などの社会基盤では十年以上サポートを必要とする一方で、影響範囲が大きいカーネルの版数を上げることは現実的に困難です。こういった市場に対してCIPでは十年以上の長期サポート(不具合改修パッチのバックポート)を行います。
※LTSやCIPのアプローチ vs CI/CDのアプローチ、結局は製品形態によってケースバイケースだと思いますが、私は非常に興味があります。
Linuxカーネルのソースコードの読み方
始めに述べておきますが、読み方に正解はないと思います。
私は、下記の2点を意識して読みます。
・関数1から呼び出している関数2は深追いせず、関数1の役割が何をするのか大雑把に概要を理解する。(関数2は関数上部のコメントを読むレベル)
概要を掴めたら、少しづつ詳細を理解していく。
いきなり、一文一文を厳密に理解しようとして、深くまで入り込んでしまって読解が全然進まない、という人もいます。そうならないように上記の読み方をします。
大きい範囲で概要を理解して、その後に各範囲を見ていくイメージです。
・なぜその処理が必要なのかを常に考える。
これは少し難易度が高いので後回しでも良いと思います。
Linuxカーネルの各階層
Linuxカーネルのトップにあるディレクトリをざっと見てみます。
・arch:アーキテクチャ依存部。x86やArm、Power、MIPSなどがディレクトリで用意されている。
・block:ブロックI/Oレイヤ。I/Oスケジューラなどが含まれます。
・crypto:暗号/復号化レイヤ。
・drivers:各種ドライバ。Linuxカーネルの半分の規模を占める。
・fs:ファイルシステム。extやfat、nfsなどがディレクトリで用意されている。
・include:ヘッダファイル群。アーキテクチャ依存部はarch/xxx/includeに配置。
・init:初期化処理。
・ipc:プロセス間通信レイヤ。
・kernel:カーネルコア部。スケジューラやタイマーなどが含まれる。
・lib:共通ライブラリ。対象アーキテクチャで高速化命令が存在しない場合などに使用されることが多い。e.g. memcpy、memcmp、strcmp
・mm:メモリ管理。アーキテクチャ非依存部。アーキテクチャ依存部はarch/xxx/mmに配置。
・net:各種ネットワーク。e.g. ipv4/6、bluetooth、wimax
・security:セキュリティ。SELinuxやtomoyoなど。
・sound:ALSAのカーネル側コード。
・virt:仮想化。KVMコードなど。
#Linuxカーネルの呼び出し例
本記事では、ここまでコードを読むことがなかったため、一例でprintf( )を実行した場合を見てみます。ラズパイなどで身近な存在になっているArmアーキテクチャのコードを例で見ていきましょう。
1.glibcでprintf( )を実行します。
2.glibcで内部でprintfに対応するシステムコールwriteシステムコールをsvc(Super Visor Call)命令を用いてソフトウェア例外を発生させます。
このとき、r7にwriteのシステムコール番号、r0からr6までに引数を入れます。
ちなみにwriteのシステムコール番号はカーネルのarch/arm/tools/syscall.tblで4と定義されています。
時間がある方はカーネルの上記ファイルを見てみると良いと思います。
全てのシステムコールの番号とエントリが記載されています。
なお、ここでポイントなのは、通常の関数呼び出しでないということです。
ほとんどのシステムコールはglibcが内部で行ってくれているため、気づかないですが、Linuxカーネルに処理を依頼するときは必ずsvc命令でソフトウェア例外を発生させます。
2.カーネルのベクターテーブルの下記4行目にトラップします。(svc命令を実行するとCPUが自動的に4行目に飛ばしてくれます。同時に、CPUのプロセッサモードをユーザーモードからスーパーバイザーモードへ変更します)
1.L__vectors_start:
2 W(b) vector_rst
3 W(b) vector_und
4 W(ldr) pc, .L__vectors_start + 0x1000
5 W(b) vector_pabt
6 W(b) vector_dabt
7 W(b) vector_addrexcptn
8 W(b) vector_irq
9 W(b) vector_fiq
少し話が逸れますが、上記は例外ベクタテーブルであり、全ての例外を定義しています。
2行目:リセット例外
3行目:未定義命令例外
4行目:ソフトウェア例外
5行目:プリフェッチアボート例外
6行目:データアボート例外
7行目:アドレス例外
8行目:irq割込み例外(irq割込みが発生すると本エントリから処理されます)
9行目:fiq割込み例外
この__vectors_start(ベクターテーブル)はリンカで0xffff0000に配置されています。
従ってldr命令でpcに0xffff1000を入れています。
次にざっくり飛ばしますが(笑)、重要な箇所は以下のvector_swiです。
なお、r0-r6の引数を保持したレジスタ、r7のシステムコール番号を保持したレジスタは使用(破壊)していないことに注目してください。writeシステムコール関数で使用するため、システムコールの前処理でこれらのレジスタを触ることはありません。2行目でスタックポインタで指すアドレスを更新したあとに2行目でr0からr12の値をスタックに退避しています。この退避した値はシステムコール処理からユーザー空間に復帰するときにスタックからレジスタに復元して整合性を保ちます。
/*=============================================================================
* SWI handler
*-----------------------------------------------------------------------------
*/
.align 5
ENTRY(vector_swi)
#ifdef CONFIG_CPU_V7M
1 v7m_exception_entry
#else
2 sub sp, sp, #PT_REGS_SIZE
3 stmia sp, {r0 - r12} @ Calling r0 - r12
4ARM( add r8, sp, #S_PC )
5ARM( stmdb r8, {sp, lr}^ ) @ Calling sp, lr
6THUMB( mov r8, sp )
7THUMB( store_user_sp_lr r8, r10, S_SP ) @ calling sp, lr
8 mrs saved_psr, spsr @ called from non-FIQ mode, so ok.
9 TRACE( mov saved_pc, lr )
10 str saved_pc, [sp, #S_PC] @ Save calling PC
11 str saved_psr, [sp, #S_PSR] @ Save CPSR
12 str r0, [sp, #S_OLD_R0] @ Save OLD_R0
#endif
:
:
local_restart:
ldr r10, [tsk, #TI_FLAGS] @ check for syscall tracing
stmdb sp!, {r4, r5} @ push fifth and sixth args
tst r10, #_TIF_SYSCALL_WORK @ are we tracing syscalls?
bne __sys_trace
invoke_syscall tbl, scno, r10, __ret_fast_syscall★
:
ENDPROC(vector_swi)
3行目から12行目までの処理で呼び出し時のレジスタやCPSRをスタックや変数に保存しています。
この部分を図を示しながら説明します。
3行目でsp(スタックポインタ(現在のスタックの位置を示す役割))をPT_REGS_SIZE分引き下げています。
PT_REGS_SIZEは下記のように定義されており、pt_regs構造体のサイズを示します。
DEFINE(PT_REGS_SIZE, sizeof(struct pt_regs));
そしてpt_regs構造体及びその構造体に設定するエントリは下記の通りです。
サイズは4byte×18の72byte(hexで0x48)です。
#ifndef __KERNEL__
struct pt_regs {
long uregs[18];
};
#endif /* __KERNEL__ */
#define ARM_cpsr uregs[16]
#define ARM_pc uregs[15]
#define ARM_lr uregs[14]
#define ARM_sp uregs[13]
#define ARM_ip uregs[12]
#define ARM_fp uregs[11]
#define ARM_r10 uregs[10]
#define ARM_r9 uregs[9]
#define ARM_r8 uregs[8]
#define ARM_r7 uregs[7]
#define ARM_r6 uregs[6]
#define ARM_r5 uregs[5]
#define ARM_r4 uregs[4]
#define ARM_r3 uregs[3]
#define ARM_r2 uregs[2]
#define ARM_r1 uregs[1]
#define ARM_r0 uregs[0]
#define ARM_ORIG_r0 uregs[17]
元々は以下のSP(0xD0001000と仮定)の位置でした。
そして2行目で下図のようになります。(SP = 0xD0000FB8)
3行目でシステムコール呼び出し時のr0からr12の値を退避します。
4行目から12行目でスタックは下図のようになります。全てシステムコール呼出し時のレジスタ値の退避処理です。
ここでORIG_r0って?と思われる方もいらっしゃるとおもいます。
Armでは、SPで示しているアドレス(r0を示している箇所)をシステムコールの復帰値として利用します。
従って、そのままではr0の値を復元できないため、ORIG_r0のアドレスにもr0を退避して、復帰時にはr0はこのORIG_r0を復元します。
そして最後の★で下記マクロを実行します。
.macro invoke_syscall, table, nr, tmp, ret, reload=0
#ifdef CONFIG_CPU_SPECTRE
mov \tmp, \nr
cmp \tmp, #NR_syscalls @ check upper syscall limit
movcs \tmp, #0
csdb
badr lr, \ret @ return address
.if \reload
add r1, sp, #S_R0 + S_OFF @ pointer to regs
ldmccia r1, {r0 - r6} @ reload r0-r6
stmccia sp, {r4, r5} @ update stack arguments
.endif
ldrcc pc, [\table, \tmp, lsl #2] @ call sys_* routine★
#else
cmp \nr, #NR_syscalls @ check upper syscall limit
badr lr, \ret @ return address
.if \reload
add r1, sp, #S_R0 + S_OFF @ pointer to regs
ldmccia r1, {r0 - r6} @ reload r0-r6
stmccia sp, {r4, r5} @ update stack arguments
.endif
ldrcc pc, [\table, \nr, lsl #2] @ call sys_* routine★
#endif
.endm
★でsys_write( )を呼び出します。その後、例えば、シリアルコンソールに出力する場合はtty層を通じてdrivers/tty/serial/serial_core.c(抽象化レイヤ)、amba-p1010.c(AMBA PrimeCell UARTドライバ)の流れで処理を行い文字列を出力します。
端折りすぎて載せない方が良いかもと思いましたが、もしかすると雰囲気を理解していただけるかもしれませんので一応載せておきます。
アセンブラなど出てきますので、理解できなくても問題ありません。
ただ、pritnfの場合はこのような流れで実行されているということを大体理解してもらえれば結構です。
以下にポイントを示します。
・printfはwriteシステムコールを発行する
・システムコールの発行はsvc命令で行う
・r0 - r6に引数、r7にシステムコール番号を格納してsvc命令を発行する。
この時、プロセッサモードはユーザーモードからスーパーバイザーモードに変更される。割込みも禁止される。
・ベクターテーブルに飛んできた後にvector_swiを実行する。
・各レジスタをスタックに退避する(システムコール実行後にユーザー空間に復帰する時にスタックから復元する)
・システムコールはarch/arm/tools/syscall.tblに定義されており、sys_xxxxという形式の関数である。
次回からは体裁を整えて丁寧に書くようにします。
第2回:【読解入門】Linuxカーネル (スケジューラ編その1)
第3回:【読解入門】Linuxカーネル (スケジューラ編その2)
第4回:【読解入門】Linuxカーネル (スケジューラ編その3-1)
第5回:【読解入門】Linuxカーネル (スケジューラ編その3-2)
第6回:【読解入門】Linuxカーネル (スケジューラ編その3-3)
第7回:【読解入門】Linuxカーネル (スケジューラ編その4-1)