0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

アプリケーションプロセッサの起動を改良してみた

Posted at

はじめに

こんにちは.だいみょーじんです.
この記事は,x64マルチプロセッサ環境で他のプロセッサを起動させる方法の続編であり,第45回自作OSもくもく会で発表した内容をまとめ,自作OSアドベントカレンダー2025の21日目の記事として公開したものです.
私が開発しているRust製の自作OS,HeliOSにおける複数プロセッサの起動処理を改良した話です.

前回の復習

BSPとAP

x64のマルチプロセッサ環境には,APとBSPという2種類のプロセッサが存在します.

  • BSP (Bootstrap Processor)
    • 電源ボタンを押して最初に起動するプロセッサ
    • 必ずひとつ存在
    • UEFI環境ではBSPが/EFI/BOOT/BOOTX64.EFIを実行
  • AP (Application Processor)
    • BSP以外のプロセッサ
    • 0個以上存在
    • 他のプロセッサから起動指示を受けなければ,停止したまま

bsp_ap.drawio.png

そして,BSPからAPに向けてINIT-SIPI-SIPI信号(INIT信号1回,その後SIPI信号2回)を送ることで,APを起動します.
INIT-SIPI-SIPIを受信したAPは16bitリアルモードで起動し,その後32bitプロテクトモードを経由して64bitロングモードに移行します.
UEFI環境では最初から64bitロングモードであるBSPとの大きな違いです.

boot_ap.drawio (1).png

AP起動時の制約

そこで問題になるのが,16bitリアルモードの窮屈さです.
16bitリアルモードのアドレス指定は,16bitのセグメントレジスタを4bit左シフトし,16bitのオフセットを足したものが実アドレスになります.
実アドレスは20bitなので,メモリがどれだけ大きくても,先頭の1MiBまでのアドレスしか指定できないことになります.
つまり16bitリアルモードではメモリは先頭1MiBしか使えないのです.

real_mode_address.drawio.png

APは起動直後に先頭1MiBしか使えないという制約は,SIPI信号の構造からも読み取れます.
AP起動時の実行開始アドレスは,SIPI信号の最下位8bitのVectorという領域の値に,0x1000を掛けた値になります.
AP起動時の実行開始アドレスは,最大でも0xff000なので,APは1MiB以内で起動することが想定されています.

Intel® 64 and IA-32 Architectures Software Developer's Manual December 2023 Vol3. 表11-12
image.png

AP起動時の制約に対する解決策

そこで前回の記事では,メモリの先頭1MiBのうち,OSの動作確認に使用していたQEMU,VirtualBox,VMware,GPD MicroPCで共通して自由に使える領域であった物理アドレス0x1000から0x10000をAPの起動に利用することにしました.

BSPはアセンブリで書かれたboot_loader.binを物理アドレス0x1000に配置し,その後APを起動します.
APはboot_loader.binを実行することで,boot_loader.binの直後の領域にログを書き残しながら,16bitリアルモードから32bitプロテクトモードを経由して64bitロングモードに移行し,最後にkernel.elfの実行に移行します.
APの実行開始アドレスは物理アドレス0x1000であり,APのスタックの底は物理アドレス0x10000とします.

processor_boot_loader.drawio.png

ここまでが前回の記事で書いた内容です.

新たな問題

これでBSPからAPを起動できていましたが,開発用PCが壊れてしまったので買い替えたところ,VMwareでAPが起動しなくなってしまいました.
原因を調査したところ,それまで自由に使えていた物理アドレス0x1000..0x2000が,メモリマップの変更に伴って使えなくなってしまったことが原因でした.

vmware_error.drawio.png

とりあえず応急処置としてboot_loader.binの配置場所を0x1000から0x2000に変更することで再びAPを起動できるようにはなりましたが,環境が変わる度にソースコードをいじって調整するのはOSとしてダサいです…

解決策

この問題をXでつぶやいたところ,日本のOS開発者コミュニティであるosdev-jpのつよつよエンジニアたちからboot_loader.binを位置独立コードにするというアイデアおよびサンプルコードをいただきました.

丁度ゴールデンウィークが近かったので,早速やってみることに.

AP起動用のメモリ確保を変更

それまで「0x1000番地から0x10000番地までをAP起動用に確保する」としていたのを,「1MiB以内で十分大きなConventional MemoryをAP起動用に確保する」に変更しました.

allocation.drawio.png

あとは,その領域でAPが実行するboot_loader.binを位置独立コードにするだけです.

boot_loader.binの位置独立化

まず,APが起動時に実行するboot_loader.binが何をやっているのかをおさらいしましょう.

  • 16bitリアルモードで起動
  • 32bitプロテクトモードに移行
  • 64bitロングモードに移行
  • kernel.elfに飛ぶ

boot_loader_bin.drawio.png

つまり,boot_loader.binには3つの動作モードが混在しており,各動作モードに合わせた方法でそれぞれを位置独立化する必要があります.

AP起動直後の状態

各動作モードの位置独立化に入る前に,APが起動直後にどんな状態にあるのかを把握しておきましょう.
BSPがAPに送るSIPI信号の最下位8bit(Vector)の値が,APのCSレジスタの上位8ビットに設定され,CSレジスタの下位8bitおよびIPレジスタは0になります.

init_ap.drawio.png

16bitリアルモードの位置独立化

いよいよ位置独立化に入ります.
16bitリアルモードは,セグメントレジスタの値を下の図のように調整することで実現できます.
boot_loader.binとスタックがどこに置かれようが,セグメントレジスタに適切な値を設定することで位置の違いを吸収できるのです.

real_mode_segment.drawio.png

では上の図の状態に持っていくためのboot_loader.binのアセンブリコードを見てみましょう.
APの起動直後にCSレジスタがboot_loader.binの先頭を指し示しているので,それをDS,ES,FS,GSレジスタにコピーします.

main.s
	.text
	.code16
main16:	# IP == 0x0000
0:	# Disable interrupts.
	cli
	# Initialize the segment registers.
	movw	%cs,	%dx
	movw	%dx,	%ds
	movw	%dx,	%es
	movw	%dx,	%fs
	movw	%dx,	%gs

SSレジスタだけは少し離れた場所にあるので,BSPがboot_loader.binをメモリ上に配置した際に,boot_argument_ssに,APのSSに入れるべき値をあらかじめ書いておいて,ABがそれをSSに読み込むようにします.
ベースポインタBPレジスタとスタックポインタSPレジスタは0クリアしておくことにより,最初にpushした時にアンダーフローを起こしてSSの一番底からスタックが伸びていくようにします.

main.s
	movw	boot_argument_ss,	%ss
	xorw	%bp,	%bp
	xorw	%sp,	%sp

boot_argument_ss:
	.word	0xdead

これで16bitリアルモードの位置独立化は完了です.

32bitプロテクトモードの位置独立化

16bitリアルモードの段階で,32bitプロテクトモードの位置独立化を準備します.
16bitリアルモードではセグメントレジスタがそのままセグメントの位置を示していますが,32bitプロテクトモードでは,各セグメントの位置,大きさ,権限などの情報を記載したセグメントディスクリプタの配列をGDT(Global Descriptor Table)として持っておいて,セグメントレジスタはGDT内のセグメントディスクリプタのオフセット等を指定するという構造になっています.
今回はGDTに以下のようなセグメントを用意します.

  • segment_descriptor_null
  • segment_descriptor_32bit_code
  • segment_descriptor_32bit_data
  • segment_descriptor_32big_stack
  • segment_descriptor_64bit_kernel_code
  • segment_descriptor_64bit_kernel_data
  • segment_descriptor_64bit_application_data
  • segment_descriptor_64bit_application_code

これらのセグメントディスクリプタを,boot_loader.bin.data領域に以下のように記述しておきます.

main.s
	.data
	.align	0x10
gdt_start:
	# [Intel 64 and IA-32 Architectures Software Developer's Manual December 2023](https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html) Vol.3A 3.4.5 Segment Descriptors, Figure 3-8. Segment Descriptor
segment_descriptor_null:			# 0x00
	.word	0x0000	# Limit 15:00
	.word	0x0000	# Base 15:00
	.byte	0x00	# Base 23:16
	.byte	0x00	# Type, S, DPL, P
	.byte	0x00	# Limit 19:16, AVL, L, D/B, G
	.byte	0x00	# Base 31:24
segment_descriptor_32bit_code:			# 0x08
	.word	0xffff	# Limit 15:00
	.word	0x0000	# Base 15:00
	.byte	0x00	# Base 23:16
	.byte	0x9a	# Type, S, DPL, P
	.byte	0xcf	# Limit 19:16, AVL, L, D/B, G
	.byte	0x00	# Base 31:24
segment_descriptor_32bit_data:			#0x10
	.word	0xffff	# Limit 15:00
	.word	0x0000	# Base 15:00
	.byte	0x00	# Base 23:16
	.byte	0x92	# Type, S, DPL, P
	.byte	0xcf	# Limit 19:16, AVL, L, D/B, G
	.byte	0x00	# Base 31:24
segment_descriptor_32bit_stack:			#0x18
	.word	0xffff	# Limit 15:00
	.word	0x0000	# Base 15:00
	.byte	0x00	# Base 23:16
	.byte	0x92	# Type, S, DPL, P
	.byte	0xcf	# Limit 19:16, AVL, L, D/B, G
	.byte	0x00	# Base 31:24
segment_descriptor_64bit_kernel_code:		# 0x20
	.word	0xffff	# Limit 15:00
	.word	0x0000	# Base 15:00
	.byte	0x00	# Base 23:16
	.byte	0x9a	# Type, S, DPL, P
	.byte	0xaf	# Limit 19:16, AVL, L, D/B, G
	.byte	0x00	# Base 31:24
segment_descriptor_64bit_kernel_data:		# 0x28
	.word	0xffff	# Limit 15:00
	.word	0x0000	# Base 15:00
	.byte	0x00	# Base 23:16
	.byte	0x92	# Type, S, DPL, P
	.byte	0xcf	# Limit 19:16, AVL, L, D/B, G
	.byte	0x00	# Base 31:24
segment_descriptor_64bit_application_data:	# 0x30
	.word	0xffff	# Limit 15:00
	.word	0x0000	# Base 15:00
	.byte	0x00	# Base 23:16
	.byte	0xf2	# Type, S, DPL, P
	.byte	0xcf	# Limit 19:16, AVL, L, D/B, G
	.byte	0x00	# Base 31:24
segment_descriptor_64bit_application_code:	# 0x38
	.word	0xffff	# Limit 15:00
	.word	0x0000	# Base 15:00
	.byte	0x00	# Base 23:16
	.byte	0xfa	# Type, S, DPL, P
	.byte	0xaf	# Limit 19:16, AVL, L, D/B, G
	.byte	0x00	# Base 31:24
gdt_end:

各セグメントディスクリプタ内のBaseは,そのセグメントの物理メモリ上の位置を示しており,ソースコード上は0としています.
32bitプロテクトモードを位置独立化するためには,16bitリアルモードにおける各セグメントの位置を,対応する32bitセグメントディスクリプタのBaseに書き写せばよいです.
それをやるのが以下の関数で,第1引数にGDTの位置を,第2引数に書き写し先の32bitセグメントディスクリプタの位置を,第3引数に書き写し元の16bitセグメントレジスタの値を指定することで,書き写しを実行します.

main.s
# set_segment_base(gdt_start: u16, segment_selector_bit32: u16, segment_register_bit16: u16)
set_segment_base16:
0:
	enter	$0x0000,	$0x00
	pushw	%di
	movw	0x04(%bp),	%di	# %di = gdt_start
	addw	0x06(%bp),	%di	# %di = gdt_start + segment_selector_bit32
	movw	0x08(%bp),	%ax	# %ax = segment_register_bit16
	movw	%ax,		%dx	# %dx = segment_register_bit16
	shlw	$0x04,		%dx	# %dx = segment_register_bit16 << 4
	movw	%dx,	0x02(%di)	# Set base 15:00
	movw	%ax,		%dx	# %dx = segment_register_bit16
	shrw	$0x0c,		%dx	# %dx = segment_register_bit16 >> 12
	movb	%dl,	0x04(%di)	# Set base 23:16
	xorb	%dl,	%dl		# %dl = 0
	movb	%dl,	0x07(%di)	# Set base 31:24
	popw	%di
	leave
	ret

上の関数を使ってCSレジスタをsegment_descriptor_32bit_codeのBaseに書き写し,DSレジスタをsegment_descriptor_32bit_dataのBaseに書き写し,SSレジスタをsegment_descriptor_32big_stackのBaseに書き写します.

main.s
	# Set 32bit code segment base.
	pushw	%cs
	leaw	(segment_descriptor_32bit_code - segment_descriptor_null),	%dx
	pushw	%dx
	leaw	gdt_start,	%dx
	pushw	%dx
	call	set_segment_base16
	addw	$0x0006,	%sp
	# Set 32bit data segment base.
	pushw	%ds
	leaw	(segment_descriptor_32bit_data - segment_descriptor_null),	%dx
	pushw	%dx
	leaw	gdt_start,	%dx
	pushw	%dx
	call	set_segment_base16
	addw	$0x0006,	%sp
	# Set 32bit stack segment base.
	pushw	%ss
	leaw	(segment_descriptor_32bit_stack - segment_descriptor_null),	%dx
	pushw	%dx
	leaw	gdt_start,	%dx
	pushw	%dx
	call	set_segment_base16
	addw	$0x0006,	%sp

このように各32bitセグメントの位置を調整することで,各セグメントの位置をずらすことなくシームレスに16bitリアルモードから32bitプロテクトモードに移行し,32bitプロテクトモードでも引き続き位置独立な状態を維持することができます.
GDTの32bitセグメントの調整ができたところで,そのGDTの読み込みに進みましょう.
gdtrという場所にGDTの大きさをwordで,位置をlongで書いておきます.

main.s
	.data
    .align	0x4
	.word	0x0000
gdtr:
	.word	gdt_end - gdt_start - 1
	.long	0xdeadbeef # This will be overwritten by set_gdb_base function.

GDTはboot_loader.bin.data領域に置かれます.
そしてboot_loader.binはメモリマップに応じて動的に決められた場所に配置されるので,GDTの場所も動的に決まります.
なのでソースコード上は0xdeadbeefという無効な値を適当に書いておいて,実行時にGDTの実アドレスを求めて上書きするようにします.
それをやるのが以下の関数です.
第1引数にgdtrのアドレス,第2引数にGDTのアドレス,第3引数にDSレジスタの値を指定すると,GDTの物理アドレスを計算して,それをgdtrに上書きします.

main.s
# set_gdb_base(gdtr: u16, gdt_start: u16, data_segment: u16)
set_gdt_base:
.set	FLAGS_CF,	1
0:
	enter	$0x0000,	$0x00
	pushw	%di
	movw	0x04(%bp),	%di	# %di = gdtr
	movw	0x06(%bp),	%ax	# %ax = gdt_start
	movw	0x08(%bp),	%dx	# %dx = data_segment
	shlw	$0x04,		%dx	# %dx = data_segment << 4
	addw	%dx,		%ax	# %ax = gdt_start + (data_segment << 4)
	pushfw				# Save FLAGS.
	movw	%ax,	0x02(%di)	# Write GDT base low.
	movw	0x08(%bp),	%ax	# %ax = data_segment
	shrw	$0x0c,		%ax	# %ax = data_segment >> 12
	popw	%dx			# Read FLAGS.
	andw	$FLAGS_CF,	%dx
	jz	2f
1:	# If GDT base low addition carried over.
	incw	%ax			# %ax = (data_segment >> 12) + 1
2:	# End if.
	movw	%ax,	0x04(%di)	# Write GDT base high.
	popw	%di
	leave
	ret

gdtrの準備が整ったところで,いよいよ16bitリアルモードから32bitプロテクトモードに移行します.

main.s
	# Move to 32bit protected mode.
	lgdt	gdtr # Load GDT.
	movl	%cr0,	%edx
	andl	$0x7fffffff,	%edx	# Disable paging,
	orl	$0x00000001,	%edx	# Enable 32bit protected mode.
	movl	%edx,	%cr0
	ljmp	$(segment_descriptor_32bit_code - segment_descriptor_null),	$main32 # Long jump to 32bit protected mode.

移行時にsegment_descriptor_32bit_codeがCSレジスタに設定されるので,その後残りのセグメントレジスタを設定します.
具体的には,DS,ES,FS,GSレジスタにsegment_descriptor_32bit_dataを,SSレジスタにsegment_descriptor_32bit_stackを設定します.
そして,ベースポインタEBPレジスタとスタックポインタESPレジスタには0x00010000を入れます.
16bitリアルモードでは64KiBのスタック領域を用意しておいて,ベースポインタBPレジスタとスタックポインタSPレジスタを0クリアすることで,最初にpushしたときにちょうど64KiB向こうからスタックが伸びてくれましたが,32bitプロテクトモードにおけるベースポインタEBPレジスタとスタックポインタESPレジスタは0クリアしてしまうと最初にpushしたときに4GiB向こうまで飛んでしまうので,16bitリアルモードで使用していたスタック領域をそのまま引き継ぐために0x00010000で初期化しておきます.

main.s
	.code32
main32:
0:	# Set 32bit data segment.
	movw	$(segment_descriptor_32bit_data - segment_descriptor_null),	%dx
	movw	%dx,	%ds
	movw	%dx,	%es
	movw	%dx,	%fs
	movw	%dx,	%gs
	movw	$(segment_descriptor_32bit_stack - segment_descriptor_null),	%dx
	movw	%dx,	%ss
	movl	$0x00010000,	%ebp
	movl	$0x00010000,	%esp

これで32bitプロテクトモードも位置独立化できました.

64bitロングモードの位置独立化

32bitプロテクトモードの段階で,次の64bitロングモードの位置独立化を準備します.
まずはページングを有効化するため,BSPが用意したPML4テーブルをCR3レジスタに設定します.
boot_loader.binにはPML4テーブルを置くための1ページ分のスペースtemporary_pml4_tableがあり,その内容はBSPがboot_loader.binをメモリ上に配置した際に事前に書き込まれます.

main.s
	.data
	.align	0x1000
temporary_pml4_table:
	.space	0x1000

APはtemporary_pml4_tableの物理アドレスをCR3レジスタに設定します.
つまり,以下のコードのようにsegment_descriptor_32bit_dataのBaseとtemporary_pml4_tableのアドレスを足した値をCR3レジスタに書き込みます.

main.s
	# Set temporary CR3.
	leal	segment_descriptor_32bit_data,	%edx
	pushl	%edx
	call	get_segment_base
	addl	$0x00000004,	%esp
	addl	$temporary_pml4_table,	%eax
	movl	boot_argument_cr3,	%edx
	andl	$0x00000fff,	%edx
	orl	%edx,	%eax
	movl	%eax,	%cr3

上のコードから呼び出されているget_segment_baseは,セグメントディスクリプタから,そのBaseを求める関数です.

main.s
# get_segment_base(segment_descriptor_address: u32) -> u32
get_segment_base:
0:
	enter	$0x0000,	$0x00
	pushl	%esi
	movl	0x08(%ebp),	%esi	# %esi = segment_descriptor_address
	movl	0x04(%esi),	%edx	# %edx = segment_descriptor_high
	movl	%edx,		%eax	# %eax = segment_descriptor_high
	shrl	$0x18,		%eax	# %eax = segment_discriptor_high >> 0x18
	shll	$0x08,		%eax	# %eax = (segment_discriptor_high >> 0x18) << 0x08
	andl	$0x000000ff,	%edx	# %edx = segment_descriptor_high && 0x000000ff
	addl	%edx,		%eax	# %eax = ((segment_discriptor_high >> 0x18) << 0x08) + (segment_descriptor_high && 0x000000ff)
	shll	$0x10,		%eax	# %eax = (((segment_discriptor_high >> 0x18) << 0x08) + (segment_descriptor_high && 0x000000ff)) << 0x10
	movl	(%esi),		%edx	# %edx = segment_descriptor_low
	shrl	$0x10,		%edx	# %edx = segment_descriptor_low >> 0x10
	addl	%edx,		%eax	# %eax = ((((segment_discriptor_high >> 0x18) << 0x08) + (segment_descriptor_high && 0x000000ff)) << 0x10) + (segment_descriptor_low >> 0x10)
	popl	%esi
	leave
	ret

これでPML4テーブルをCR3レジスタに設定できたので,64bitロングモードに移行します.
ljmp_destinationという領域を置き,移行時のジャンプ先のアドレスをlongで,セグメントをwordで指定しておきます.

main.s
ljmp_destination:
	.long	0xdeadbeef
	.word	(segment_descriptor_64bit_kernel_code - segment_descriptor_null)

ジャンプ先はmain64という関数ですが,これもPML4テーブルと同様にsegment_descriptor_32bit_codeのBaseを足し合わせてからljmp_destinationに設定します.

main.s
	leal	segment_descriptor_32bit_code,	%edx
	pushl	%edx
	call	get_segment_base
	addl	$0x00000004,	%esp
	addl	$main64,	%eax
	leal	ljmp_destination,	%edi
	movl	%eax,	(%edi)

続いて,64bitロングモードに移行するための各種フラグを設定します.

main.s
	# Set PAE.
	movl	%cr4,	%edx
	orl	$0x00000020,	%edx
	movl	%edx,	%cr4
	# Set LME and NXE.
	movl	$0xc0000080,	%ecx
	rdmsr
	orl	$0x00000900,	%eax
	wrmsr
	# Set PG.
	movl	%cr0,	%edx
	orl	$0x80000000,	%edx
	mov	%edx,	%cr0

最後に,EDIレジスタにljmp_destinationのアドレスが入った状態でmain64関数にジャンプし,64bitロングモードに移行します.

main.s
	# Move to 64bit mode.
	ljmp	*(%edi)

64bitロングモードにおける位置独立化は,あらゆるアドレスを現在のプログラムカウンタRIPレジスタからの相対オフセットで指定するRIP相対によって実現します.
near jump命令やnear call命令はもともとRIP相対です.
しかし,データにアクセスしたい場合は,RIP相対を明示する必要があります.
例えば以下のコードのようにmessage64を表示したい場合は,message64(%rip)のように指定します.

main.s
    .text
	.code64
main64:
    ...
	# Print message64.
	leaq	message64(%rip),	%rdi
	call	puts64
    ...
    .data
message64:
	.string	"Hello from an application processor in 64bit mode!\n"

あとは,前回の記事で説明したのと同じ方法で,kernel.elfに移行します.

まとめ

  • 複数のプロセッサを内蔵する環境において,全てのプロセッサを並列に動かすため,BSPからAPを起動
  • メモリマップが変動してもAPを起動できるように,boot_loader.binを位置独立化
  • 16bitリアルモードでは,セグメントレジスタを調整することで位置独立化
  • 32bitプロテクトモードでは,セグメントディスクリプタを調整することで位置独立化
  • 64bitロングモードでは,RIP相対アドレスにより位置独立化

最後に一言

Intelのx86s開発計画が頓挫したことにより,x64マルチプロセッサ対応OSの開発者は今後も1MiB以内で,リアルモードからAPを起動することになります.
リアルモードやプロテクトモードは最近のOS自作入門書であるゼロからのOS自作入門では扱われておらず,2006年発売の30日でできる!OS自作入門の知識が必要になります.

参考文献

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?