はじめに
こんにちは.だいみょーじんです.
この記事は,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に向けてINIT-SIPI-SIPI信号(INIT信号1回,その後SIPI信号2回)を送ることで,APを起動します.
INIT-SIPI-SIPIを受信したAPは16bitリアルモードで起動し,その後32bitプロテクトモードを経由して64bitロングモードに移行します.
UEFI環境では最初から64bitロングモードであるBSPとの大きな違いです.
AP起動時の制約
そこで問題になるのが,16bitリアルモードの窮屈さです.
16bitリアルモードのアドレス指定は,16bitのセグメントレジスタを4bit左シフトし,16bitのオフセットを足したものが実アドレスになります.
実アドレスは20bitなので,メモリがどれだけ大きくても,先頭の1MiBまでのアドレスしか指定できないことになります.
つまり16bitリアルモードではメモリは先頭1MiBしか使えないのです.
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

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とします.
ここまでが前回の記事で書いた内容です.
新たな問題
これでBSPからAPを起動できていましたが,開発用PCが壊れてしまったので買い替えたところ,VMwareでAPが起動しなくなってしまいました.
原因を調査したところ,それまで自由に使えていた物理アドレス0x1000..0x2000が,メモリマップの変更に伴って使えなくなってしまったことが原因でした.
とりあえず応急処置としてboot_loader.binの配置場所を0x1000から0x2000に変更することで再びAPを起動できるようにはなりましたが,環境が変わる度にソースコードをいじって調整するのはOSとしてダサいです…
解決策
この問題をXでつぶやいたところ,日本のOS開発者コミュニティであるosdev-jpのつよつよエンジニアたちからboot_loader.binを位置独立コードにするというアイデアおよびサンプルコードをいただきました.
丁度ゴールデンウィークが近かったので,早速やってみることに.
AP起動用のメモリ確保を変更
それまで「0x1000番地から0x10000番地までをAP起動用に確保する」としていたのを,「1MiB以内で十分大きなConventional MemoryをAP起動用に確保する」に変更しました.
あとは,その領域でAPが実行するboot_loader.binを位置独立コードにするだけです.
boot_loader.binの位置独立化
まず,APが起動時に実行するboot_loader.binが何をやっているのかをおさらいしましょう.
- 16bitリアルモードで起動
- 32bitプロテクトモードに移行
- 64bitロングモードに移行
-
kernel.elfに飛ぶ
つまり,boot_loader.binには3つの動作モードが混在しており,各動作モードに合わせた方法でそれぞれを位置独立化する必要があります.
AP起動直後の状態
各動作モードの位置独立化に入る前に,APが起動直後にどんな状態にあるのかを把握しておきましょう.
BSPがAPに送るSIPI信号の最下位8bit(Vector)の値が,APのCSレジスタの上位8ビットに設定され,CSレジスタの下位8bitおよびIPレジスタは0になります.
16bitリアルモードの位置独立化
いよいよ位置独立化に入ります.
16bitリアルモードは,セグメントレジスタの値を下の図のように調整することで実現できます.
boot_loader.binとスタックがどこに置かれようが,セグメントレジスタに適切な値を設定することで位置の違いを吸収できるのです.
では上の図の状態に持っていくためのboot_loader.binのアセンブリコードを見てみましょう.
APの起動直後にCSレジスタがboot_loader.binの先頭を指し示しているので,それをDS,ES,FS,GSレジスタにコピーします.
.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の一番底からスタックが伸びていくようにします.
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_nullsegment_descriptor_32bit_codesegment_descriptor_32bit_datasegment_descriptor_32big_stacksegment_descriptor_64bit_kernel_codesegment_descriptor_64bit_kernel_datasegment_descriptor_64bit_application_datasegment_descriptor_64bit_application_code
これらのセグメントディスクリプタを,boot_loader.binの.data領域に以下のように記述しておきます.
.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セグメントレジスタの値を指定することで,書き写しを実行します.
# 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に書き写します.
# 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で書いておきます.
.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に上書きします.
# 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プロテクトモードに移行します.
# 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で初期化しておきます.
.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をメモリ上に配置した際に事前に書き込まれます.
.data
.align 0x1000
temporary_pml4_table:
.space 0x1000
APはtemporary_pml4_tableの物理アドレスをCR3レジスタに設定します.
つまり,以下のコードのようにsegment_descriptor_32bit_dataのBaseとtemporary_pml4_tableのアドレスを足した値をCR3レジスタに書き込みます.
# 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を求める関数です.
# 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で指定しておきます.
ljmp_destination:
.long 0xdeadbeef
.word (segment_descriptor_64bit_kernel_code - segment_descriptor_null)
ジャンプ先はmain64という関数ですが,これもPML4テーブルと同様にsegment_descriptor_32bit_codeのBaseを足し合わせてからljmp_destinationに設定します.
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ロングモードに移行するための各種フラグを設定します.
# 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ロングモードに移行します.
# Move to 64bit mode.
ljmp *(%edi)
64bitロングモードにおける位置独立化は,あらゆるアドレスを現在のプログラムカウンタRIPレジスタからの相対オフセットで指定するRIP相対によって実現します.
near jump命令やnear call命令はもともとRIP相対です.
しかし,データにアクセスしたい場合は,RIP相対を明示する必要があります.
例えば以下のコードのようにmessage64を表示したい場合は,message64(%rip)のように指定します.
.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自作入門の知識が必要になります.








