マルチコアプロセッサ対応 - OS自作情報

ここではx86_64アーキテクチャのマルチコアプロセッサ対応OSの作り方をRustでの簡単な実装例を交えながら説明していきます。

なお本稿と同じ内容のPDF版をBOOTHにて販売しております。もし気に入ってくださいましたら、購入していただけると幸いです。今後の励みになります。

はじめに

ここではx86_64向けOSの自作において、最近のCPUではもはや標準となったマルチコアプロセッサの対応の仕方を解説します。

具体的には、Multiboot2 Specificationに準拠したブートローダから起動し最低限の初期化を行って他のプロセッサを起動させた後、それぞれのコアで文字列を出力するところまで行います。
マルチコアプロセッサ用のタスクマネジメントは複雑で著者自身も研究中ですので、ここでは説明しません。

初期化手順は各プロセッサのLocal APIC IDを特定し、PC起動時から動作しているプロセッサのLocal APICを介してIPIというものを送信し、起動したプロセッサを16bitリアルモードからロングモードまで設定する、という流れになります。

UEFIにはMpService Protocolというものが存在しており(EFI Platform Initialization Specification 1.2, Volume 2:Driver Execution Environment Core Interfaceに詳細な説明が載っています)、これを利用できないかと思うかもしれませんが、 MpService ProtocolはあくまでUEFIアプリケーション内で並列処理を行うために提供されており、各プロセッサが予め指定した時間以内に制御をUEFIに戻さない場合そのプロセッサは強制終了されてしまうため、永続的に利用することは不可能です。(MpService Protocolについて教えてくださった @SatoshiTanda さん、ありがとうございます。) そのため、MpServiceを利用せず、Local APICを利用した初期化を行います。

想定読者と想定環境

本書の対象読者は以下のような方です。

今回はマルチコア対応が目標であるため、Rustを理解していない方でも内容が理解できるように心がけておりますが、文法がわからない場所は適宜Rustのドキュメントを参照してください。 開発環境はLinux(Ubuntu, Fedora, Arch Linux, openSUSEなど)のGUI環境を想定しています。 サンプルコードのビルドはUbuntu 20.04 LTS上のrustc 1.62.0-nightly (e7575f967 2022-04-14)での動作を確認しております。 動作確認はVirtualBoxとQEMUで行っています。

用語説明

マルチコアプロセッサの初期化手順を説明するにあたっていくつか必要となる用語があるのでここで説明します。 

環境構築

サンプルコードをビルドするにあたって必要なmake、gcc、lldとブータブルメディアを作成するのに必要なgrub2、xorriso、およびRustコンパイラのインストールに必要なcurlをインストールします。 Ubuntuでは以下のコマンドでインストールできます。

  $ sudo apt-get install -y curl grub2 make gcc \ 
    lld xorriso grub-common xorriso

grub2はisoファイルを作成するために使用しますが、OSによってはUEFI用のブートファイルを作成しないパッケージしかない場合があります。openSUSEではgrub2-x86_64-efiをインストールしてください。 UEFI環境下で起動できるISOファイルを作成したい場合、https://ftp.gnu.org/gnu/grub/にアクセスし、最新のソースコードをダウンロードして以下のようにコンパイルしインストールしてください。

  $ ./configure --enable-grub-mkfont --with-platform=efi
  $ make
  $ sudo make install

続いてRustのインストールを行います。現在はRustコンパイラのunstableな機能を使用しているため、nightlyビルドを利用します。そのため、引数でnightlyコンパイラをインストールするように指定を行っています。

その次にベアメタル環境のクロスコンパイルに必要なrust-srcコンポーネントの追加を行います。

  $ curl https://sh.rustup.rs -sSf | \
    sh -s -- -y --default-toolchain nightly
  $ source $HOME/.cargo/env
  $ rustup component add rust-src

これで環境構築は完了です。

ソースコードの取得

本書では、マルチコアプロセッサ対応のために必要な部分のソースコードのみを解説することとします。 そのためサンプルコードを先にGitHubから取得して必要なところのみ参照していただきたいと思います。 好きなディレクトリに移動して以下のように実行してください。

    $ git clone https://github.com/PG-MANA/MultiCoreOS.git
    $ cd MultiCoreOS

これで作業ディレクトリに入ることができます。

BSPの初期化

MultiCoreOSはGRUB2によって1MiB以降のメモリ領域に配置されたあと、BSPがboot_entryにジャンプするようにしています。 GRUB2がboot_entryを呼んだ時点ではBSPは32bitプロテクトモードのページング無効化、セグメントは0から4GBまでのコードセグメントとデータセグメントがセットされています。 ひとまずこれをロングモードにするためにストレートマッピングでページングを設定して行きます。

この処理はAPの初期化でも出てくるので、一度目を通しておいてもらえると良いと思います。

.equ MULTIBOOT_CHECK_MAGIC, 0x36d7628 /* Multiboot2 magic code */
.equ STACK_SIZE, 0x8000
.equ IO_MAP_SIZE,0xffff
.extern boot_main

.section .text
.align 4

.code32
boot_entry:
  mov   $(stack + STACK_SIZE), %esp

  push  $0
  popfd
  push  $0      /* for 64bit pop */
  push  %ebx    /* Multiboot Informationのアドレスの保存 */

  cmp   $MULTIBOOT_CHECK_MAGIC, %eax
  jne   fin

  /* 割り込み禁止 */
  mov  $0xff, %al
  out  %al, $0x21
  nop
  out  %al, $0xa1
  cli

  /* TSSセグメント記述子にアドレスを記入 */
  mov   $tss, %eax
  mov   $tss_descriptor_address, %ebp
  mov   %ax, 2(%ebp)
  shr   $16, %eax
  mov   %al, 4(%ebp)
  mov   %ah, 7(%ebp)
  /* ロングモード対応か確認 */
  pushfd
  pop   %eax
  mov   %eax, %ecx
  xor   $(1 << 21), %eax
  push  %eax
  popfd
  pushfd
  pop   %eax
  push  %ecx
  /* EFLAGS復元 */
  popfd
  xor   %ecx, %eax
  jz    cpuid_not_supported
  mov   $0x80000000, %eax
  /* 拡張CPUIDは有効か? */
  cpuid
  cmp   $0x80000001, %eax
  jb    fin
  mov   $0x80000001, %eax
  cpuid
  test  $(1 << 29), %edx
  /* Long Mode Enable Bit */
  jz    fin

  /* Paging */
  /* 2MiBページングを有効化 */
  /* PML4->PDP->PD */
  /* 先頭4GiBを仮想アドレス = 物理アドレスでマップ */
  xor   %ecx,  %ecx
pde_setup:
  mov   $0x200000, %eax
  mul   %ecx
  or    $0b10000011, %eax
  mov   %eax, pd(,%ecx, 8)
  inc   %ecx
  cmp   $2048, %ecx
  jne   pde_setup

  xor   %ecx, %ecx
pdpte_setup:
  mov   $4096, %eax
  mul   %ecx
  add   $pd, %eax
  or    $0b11, %eax
  mov   %eax, pdpt(,%ecx, 8)
  inc   %ecx
  cmp   $4, %ecx
  jne   pdpte_setup

/* pml4_setup: */
  mov   $pdpt, %eax
  or    $0b11, %eax
  mov   %eax, (pml4)

/* setup_64: */
  /* CR3にPML4のアドレスをセットし、
     CR4のPAEフラグとMSR(0xc0000080)のLMEとNXEをセット、
     最後にページングを有効化しGDTをセット */
  mov   $pml4, %eax
  mov   %eax, %cr3
  mov   %cr4, %eax
  or    $(1 << 5), %eax
  mov   %eax, %cr4
  mov   $0xc0000080, %ecx
  rdmsr
  or    $(1 << 8 | 1 << 11), %eax
  wrmsr
  mov   %cr0, %eax
  or    $(1 << 31 | 1), %eax
  lgdt  gdtr0
  mov   %eax, %cr0
  ljmp $main_code_segment_descriptor, $jump_to_rust


fin:
  cli
  hlt
  jmp fin

.code64

jump_to_rust:
  xor   %ax, %ax
  mov   %ax, %es
  mov   %ax, %ss
  mov   %ax, %ds
  mov   %ax, %fs
  mov   %ax, %gs
  mov   $tss_descriptor, %ax
  ltr   %ax

  pop   %rdi
  mov   $main_code_segment_descriptor, %rsi
  mov   $user_code_segment_descriptor, %rdx
  mov   $user_data_segment_descriptor, %rcx
  jmp   boot_main
  
.section .data

.comm stack, STACK_SIZE, 0x1000

/* PAGE DIRECTPRY (8byte * 512) * 4 */
.comm pd, 0x4000, 0x1000

/* PAGE DIRECTPRY POINTER TABLE (8byte * 512[4 entries are used]) */
.comm pdpt, 0x1000, 0x1000

/* PML4 (8byte * 512[1 entry is used]) */
.comm pml4, 0x1000, 0x1000

tss:
  .rept     25
    .long    0
  .endr
  .word     0
  .word     tss_io_map - tss
tss_io_map:
  .rept     IO_MAP_SIZE / 8
    .byte   0xff
  .endr
  .byte     0xff
tss_end:

.align 8

gdt:
    .quad    0

.equ  main_code_segment_descriptor, . - gdt
    .quad   (1 << 41) | (1 << 43) | (1 << 44) | \
            (1 << 47) | (1 << 53)

.equ user_code_segment_descriptor, . - gdt
    .quad   (1 << 41) | (1 << 43) | (1 << 44) | \
            (3 << 45) | (1 << 47) | (1 << 53)

.equ user_data_segment_descriptor, . - gdt
    .quad   (1 << 41) | (1 << 44) | (3 << 45) | \
            (1 << 47)| (1 << 53)

tss_descriptor_address:
.equ  tss_descriptor, tss_descriptor_address - gdt
    .word    (tss_end - tss) & 0xffff/* Limit(Low) */
    .word    0                       /* Base(Low) */
    .byte    0                       /* Base(middle) */
    .byte    0b10001001              /* 64bit TSS + DPL:0 + P:1 */
    .byte    ((tss_end - tss) & 0xff0000) >> 0x10
    /* Limit(High)+Granularity */
    .byte    0                       /* Base(Middle high) */
    .long    0                       /* Base(High) */
    .word    0                       /* Reserved */
    .word    0                       /* Reserved */

gdtr0:
  .word    . - gdt - 1
  .quad    gdt

大雑把に説明を行うと、CPUIDを使ってロングモードに対応しているか確認したあと、先頭4GiBのストレートマッピングでページングを行い、各種設定の後ロングジャンプでロングモードに移行しています。 その後Multiboot2 Information構造体のアドレスをPOPしたあと、Rustで書かれたboot_mainに飛んでいます。

boot_mainでは最初にMultiboot2 Information構造体の解析とそこで得られた情報をもとに簡易メモリ管理機能などの初期化を行います。

メモリ管理

GRUB2が渡してくるMultiboot2 Information構造体にはシステムに関する様々な情報が格納されており、その一つにメモリマップがありメモリ領域の各区間の属性(利用中か予約済みか利用可能か)が配列になって格納されています。 本来はその情報をもとに独自のメモリマップを構築すべきですが、ここでは簡略化のためそのまま利用します。

注意すべきは、このメモリマップではOSがすでに使用しているメモリ領域は「利用可能」となっているために、OSのELFヘッダ情報をもとに「使用済み」に変更してやる必要があります。 その点も考慮したメモリ管理システムがsrc/memory.rsです。

メモリ管理は本書の目的ではありませんので、詳しい説明は割愛します。中身が気になる方はGitHubにおいてあるソースコードを参照してください。 メモリ管理は簡易的に作成しているので、一部のPCでは動かないかもしれません。

画面表示

MultiCoreOSではシリアルポートとフレームバッファに文字を表示しています。 フレームバッファではGRUB2に付属しているunicode.pf2を利用しています。 説明すると長くなりますし、本書の目的外ですので、ここでは説明しませんが興味のある方はprintモジュールを覗いてみてください。

ACPI

ACPI PM Timer

ACPI Timerは多くのACPI準拠PCに搭載されているタイマーで常に3579545Hzでカウントアップしています。 通常はこのタイマーをもとにLocal APIC Timerの周波数を測定し、その後はOSのタイマーとしては余り使われませんが、ここでは簡易的にビジーウェイトを実装するために利用しています。

APの初期化に必ずACPI PM Timerを使用しなければならないというわけではありませんので、別のタイマーを使用しても構いません。 これも説明すると長くなりますので割愛します。 なお、実装はsrc/acpi_pm_timer.rsにあります。

MADT

ここからがマルチコア対応の本格的な処理となります。 ACPIのテーブルの中には"Multiple APIC Description Table"とよばれる、APICに関するテーブルが存在します。

APICとはPICの後継の割込管理システムで、マルチコアなどの考慮がなされています。 APICはLocal APICとI/O APICと2つに分かれており、Local APICは各コアに一つずつ存在し、I/O APICはCPU外部の割込をどのLocal APICに渡すかを管理するためにPCに一つ以上存在しています。

MADTではこのLocal APICのリストとI/O APICなどの情報を持っており、外部機器割り込みを管理する上では欠かせない情報です。

ここではMADTが保有しているLocal APICのリストから各APのLocal APIC IDを特定して、各APに対し初期化を行います。 後述するICRのブロードキャストモードを利用して一斉にAPを初期化することもできるのですが、セマフォなどの対応が難しく、BSD系のOSもAPを個別に初期化する方法を取っていたためこちらを採用します。

MADTに関する処理はsrc/acpi.rsにあります。

#[repr(C, packed)]
struct MADT {
    signature: [u8; 4],
    length: u32,
    revision: u8,
    checksum: u8,
    oem_id: [u8; 6],
    oem_table_id: [u8; 8],
    oem_revision: u32,
    creator_id: [u8; 4],
    creator_revision: [u8; 4],
    flags: u32,
    local_interrupt_controller_address: u32,
    /* interrupt_controller_structure: [struct; n] */
}

MADTは、ChecksumやOEMIDのあとに"Interrupt Controller Structure"という構造体が配列になって並んでいます。

Interrupt Controller Structureにはいくつか種類があり、各構造体の1バイト目(Type)を見て判定します。 このうち、各プロセッサのLocal APIC IDを持っているのはTypeが0である"Processor Local APIC Structure"と9である"Processor Local x2APIC Structure"です。

各構造体は"Advanced Configuration and Power Interface (ACPI) Specification Version 6.4"のP.173とP.179に説明があります。

Processor Local APIC Structureの構造 Processor Local x2APIC Structureの構造

構造体のうち、"APIC ID"と"X2 APIC ID"が各プロセッサのLocal APIC IDです。 X2 APIC IDとはx2APICにおけるLocal APIC IDであり、x2APICは従来のAPICの拡張版となります。

APIC IDは8bitしかないため256以上のプロセッサでは正しくAPを識別することができませんが、これをX2APICでは32bitに拡張しました。 また従来のAPICではLocal APICの制御はMMIO(メモリにデータを書き込んで行う方式)でしたが、X2APICではMSRというレジスタを利用することで高速に通信できるようにしました。 ここではX2APICは扱いませんが、興味のある人は実装してみてください。ここでは"X2APIC ID"の値も確認し255以下であれば初期化するようにします。

両方の構造体には"Flag"というメンバが存在しますが、そのうち0bit目は"Enabled"といってこのプロセッサが有効かどうかを示しています。

注意してもらいたいのはQEMUなどのエミュレータでは各構造体のこのフラグが必ず立っていますが、実機ではそうと限らないということです。 手元の環境(2コア2スレッドのCPU)で試したところ、"Processor Local APIC Structure"は4つあるのに実際に"Enabled"フラグがTrueであるのは2つだけでした。 無効になっているプロセッサに後述の初期化を行っても応答がないので注意してください。 したがって各プロセッサのIDも連続ではないことがあり、Local APIC IDを0から単純にカウントアップしながら初期化を試みても失敗する可能性があるので気をつけてください。

RustではIteratorを用いてfor文の中でLocal APIC IDを1つづつ取り出して作業できるようにしています。

#[derive(Clone)]
pub struct ApicIdList {
    base_address: usize,
    length: usize,
    pointer: usize,
}


impl Iterator for ApicIdList {
    type Item = u32;
    fn next(&mut self) -> Option<Self::Item> {
        if self.pointer >= self.length {
            return None;
        }
        let entry_address =
            self.base_address + self.pointer;
        let record_type =
            unsafe { *(entry_address as *const u8) };
        let record_length =
            unsafe { *((entry_address + 1) as *const u8) };
        self.pointer += record_length as usize;
        match record_type {
            0 => {
                if unsafe {
                    *((entry_address + 4) as *const u32) & 1
                } == 1
                {
                    /* Enabled */
                    Some(unsafe {
                        *((entry_address + 3) as *const u8)
                    } as u32)
                } else {
                    self.next()
                }
            }
            9 => {
                if unsafe {
                    *((entry_address + 8) as *const u32) & 1
                } == 1
                {
                    /* Enabled */
                    Some(unsafe {
                        *((entry_address + 4) as *const u32)
                    })
                } else {
                    self.next()
                }
            }
            _ => self.next(),
        }
    }
}

Local APIC

Local APICはAPの初期化に欠かせないデバイスで、実装はsrc/local_apic.rsにあります。

pub fn get_apic_id() -> u8 {
    (unsafe {
        (core::ptr::read_volatile(
            (0xfee00020usize) as *const u32,
        ) >> 24)
            & 0xff
    }) as u8
}

pub fn send_interrupt_command(
    destination: u32,
    delivery_mode: u8,
    trigger_mode: u8,
    level: u8,
    vector: u8,
) {
    assert!(delivery_mode < 8);
    let mut data: u64 = ((trigger_mode as u64) << 15)
        | ((level as u64) << 14)
        | ((delivery_mode as u64) << 8)
        | (vector as u64);
    assert!(destination <= 0xff);
    data |= (destination as u64) << 56;
    let high = (data >> 32) as u32;
    let low = data as u32;
    unsafe {
        core::ptr::write_volatile(
            (0xfee00000usize + (0x30 + 1) * 0x10)
                as *mut u32,
            high,
        );
        core::ptr::write_volatile(
            (0xfee00000usize + (0x30) * 0x10) as *mut u32,
            low,
        );
    }
}

APICは前述の通りPICと呼ばれる割り込みコントローラの進化版です。 Local APICは各プロセッサに一つずつ存在していて、メモリアクセスによって制御でき0xfee00000にマップされています。

Local APIC IDは0xfee00020の24bit〜32bitの位置に格納されています。get_apic_idはそのIDを取得するための関数です。

APを起動させるためにはAPICの"Interrupt Command Register"(ICR)を利用します。レジスタの各ビットは、以下のようになっています。

Interrupt Command Registerの構造

各フィールドの意味は後ほど説明しますが、send_interrupt_commandは各レジスタの値を適切な位置にセットしICRに書き込む作業を行っています。 ICRに書き込むことで発行される割り込みをInterProcessor Interrupts(IPI)と呼びます。

APの初期化処理

さて本題のAPの初期化を行っていきます。この処理はsrc/ap.rsのinit_apで行っています。


// APが起動したかどうかの確認用フラグ
static AP_BOOT_COMPLETE_FLAG: AtomicBool =
    AtomicBool::new(false);

pub fn init_ap(
    apic_id_list: ApicIdList,
    pm_timer: &AcpiPmTimer,
) {
    /* ap_boot.s */
    extern "C" {
        fn ap_entry();
        fn ap_entry_end();
        static mut ap_os_stack_address: u64;
    }
    let ap_entry_address = ap_entry as *const fn() as usize;
    let ap_entry_end_address =
        ap_entry_end as *const fn() as usize;

    let boot_code_address = unsafe {
        MEMORY_MANAGER
            .alloc_with_align(
                ap_entry_end_address - ap_entry_address,
                0x1000,
            )
            .unwrap()
    };
    assert!(
        boot_code_address <= 0xff000,
        "Address :{:#X}",
        boot_code_address
    );

    let vector = ((boot_code_address >> 12) & 0xff) as u8;
    /* 起動用のアセンブリコードをコピー */
    unsafe {
        core::ptr::copy_nonoverlapping(
            ap_entry_address as *const u8,
            boot_code_address as *mut u8,
            ap_entry_end_address - ap_entry_address,
        )
    };

    /* BSP用のPerCpuDataを作成し、local_apic_idをセット */
    let mut per_cpu_data =
        create_per_cpu_data(unsafe { &MEMORY_MANAGER });
    let bsp_apic_id = get_apic_id() as u32;
    per_cpu_data.local_apic_id = bsp_apic_id;

    let mut num_of_cpu = 1usize;
    'ap_init_loop: for apic_id in apic_id_list {
        if apic_id == bsp_apic_id {
            continue;
        }
        num_of_cpu += 1;
        if apic_id > 0xff {
            panic!("Please enable x2APIC");
        }

        let stack_size = 0x8000;
        let stack = unsafe {
            MEMORY_MANAGER
                .alloc_with_align(stack_size, 0x10)
                .unwrap()
        };
        /* スタックのアドレスを起動するAPが取得できるようにメモする */
        unsafe {
            *(((&mut ap_os_stack_address as *mut _
                as usize)
                - ap_entry_address
                + boot_code_address)
                as *mut u64) = (stack + stack_size) as u64
        };

        AP_BOOT_COMPLETE_FLAG.store(
            false,
            core::sync::atomic::Ordering::Relaxed,
        );

        send_interrupt_command(
            apic_id, 0b101, /*INIT*/
            1, 1, /*Assert*/
            0,
        );

        pm_timer.busy_wait_us(100);

        send_interrupt_command(
            apic_id, 0b101, /*INIT*/
            1, 0, /* De-Assert */
            0,
        );

        pm_timer.busy_wait_ms(10);

        send_interrupt_command(
            apic_id, 0b110, /* Startup IPI*/
            0, 1, vector,
        );

        pm_timer.busy_wait_us(200);

        send_interrupt_command(
            apic_id, 0b110, /* Startup IPI*/
            0, 1, vector,
        );
        for _wait in 0..5000
        /* APの初期化完了まで5秒待つ */
        {
            if AP_BOOT_COMPLETE_FLAG
                .load(core::sync::atomic::Ordering::Relaxed)
            {
                continue 'ap_init_loop;
            }
            pm_timer.busy_wait_ms(1);
        }
        panic!("Cannot init CPU(APIC ID: {})", apic_id);
    }

    if num_of_cpu != 1 {
        println!("Found {} CPUs", num_of_cpu);
    }
}

上から順に追っていきます。

/* ap_boot.s */
extern "C" {
    fn ap_entry();
    fn ap_entry_end();
    static mut ap_os_stack_address: u64;
}
let ap_entry_address = ap_entry as *const fn() as usize;
let ap_entry_end_address = ap_entry_end as *const fn() as usize;

これは、src/asm/ap_boot.sで定義しているAPの初期化コードのap_entryとAP用のスタックアドレスポインタのap_os_stack_addressのアドレスを取得しています。 ap_entry_endは、ap_entryの最後の部分に配置してあるラベルでap_entryのサイズを計算するために利用します。

let boot_code_address = unsafe {
    MEMORY_MANAGER
        .alloc_with_align(
            ap_entry_end_address - ap_entry_address,
            0x1000,
        )
        .unwrap()
};
assert!(boot_code_address <= 0xff000);

let vector = ((boot_code_address >> 12) & 0xff) as u8;
/* 起動用のアセンブリコードをコピー */
unsafe {
    core::ptr::copy_nonoverlapping(
        ap_entry_address as *const u8,
        boot_code_address as *mut u8,
        ap_entry_end_address - ap_entry_address,
    )
};

APは起動する際にリアルモードで動作を開始します。

このためAP用のブートコードはメモリ先頭1MB以内に存在する必要があり、起動処理を行う前にこの様にメモリ先頭にコピーを行います。 また後述するStartup-IPIで、ブートコードの位置をページ番号で指定する関係上、4KiBアライメント上に確保を行う必要もあります。

後のPerCpuDataの初期化は後で説明するので飛ばします。

let mut num_of_cpu = 1usize;
'ap_init_loop: for apic_id in apic_id_list {
    if apic_id == bsp_apic_id {
        continue;
    }
    num_of_cpu += 1;
    if apic_id > 0xff {
        panic!("Please enable x2APIC");
    }

    let stack_size = 0x8000;
    let stack = unsafe {
        MEMORY_MANAGER
            .alloc_with_align(stack_size, 0x10)
            .unwrap()
    };
    /* スタックのアドレスを起動するAPが取得できるようにメモする */
    unsafe {
        *(((&mut ap_os_stack_address as *mut _
            as usize)
            - ap_entry_address
            + boot_code_address)
            as *mut u64) = (stack + stack_size) as u64
    };

    AP_BOOT_COMPLETE_FLAG.store(
        false,
        core::sync::atomic::Ordering::Relaxed,
    );

    send_interrupt_command(
        apic_id, 0b101, /*INIT*/
        1, 1, /*Assert*/
        0,
    );

    pm_timer.busy_wait_us(100);

    send_interrupt_command(
        apic_id, 0b101, /*INIT*/
        1, 0, /* De-Assert */
        0,
    );

    pm_timer.busy_wait_ms(10);

    send_interrupt_command(
        apic_id, 0b110, /* Startup IPI*/
        0, 1, vector,
    );

    pm_timer.busy_wait_us(200);

    send_interrupt_command(
        apic_id, 0b110, /* Startup IPI*/
        0, 1, vector,
    );
    
    for _wait in 0..5000
    /* APの初期化完了まで5秒待つ */
    {
        if AP_BOOT_COMPLETE_FLAG
            .load(core::sync::atomic::Ordering::Relaxed)
        {
            continue 'ap_init_loop;
        }
        pm_timer.busy_wait_ms(1);
    }
    panic!("Cannot init CPU(APIC ID: {})", apic_id);
}

ここがAPの初期化で一番肝心なところとなります。

先に説明したMADTから"Processor Local APIC Structure"を一つずつ取り出し、有効なAPであるものを順に処理していきます。 取り出したLocal APIC IDがBSPのIDでないことを確認した後、初期化するAP用にスタックを確保します。stack_size分だけメモリを確保したあと、確保したアドレスをコピー先のap_os_stack_addressに書き込んでいます。

ap_os_stack_addressは、ap_entry_endラベルの直前に同じくラベルとして定義してあり8Byte変数となっています。先の処理でap_entryからap_entry_endの区間はboot_code_addressにコピーされるので、(ap_os_stack_address - ap_entry_address) + boot_code_addressがコピーした先のap_os_stack_addressの位置となります。このようにスタックアドレスを書き込むことでAPが起動時にスタックアドレスを知ることができ、スタック操作が可能となります。

次にAP_BOOT_COMPLETE_FLAGをfalseに設定します。APを一斉に起動すると様々なデータに一斉にアクセスすることになり変数の値が別のAPに書き換えられるなど、想定外の結果になる可能性があります。

それでは困るので、各APが初期化が完了したか確認しながら次のAPを初期化できるように、初期化が完了したらAPがAP_BOOT_COMPLETE_FLAGをtrueに設定しBSPに初期化が完了したことを伝えられるようにします。 AP_BOOT_COMPLETE_FLAGはアトミック変数ですが、これはコンパイラにこの変数が外部から書き換えられることを示すことで、後述のfor文が最適化により無限ループになることを防いでいます。

send_interrupt_commandの説明に入ります。 先に説明したIPIを発生させるには、ICRのDelivery Modeでコマンドを指定し、Destination及びDestination Shorthandで送信先プロセッサを指定します。

Destination ShorthandはDestinationで指定したAPIC IDのみにコマンドを送信するNo Shorthandモードや自分を含めたすべてのプロセッサに送信するモードや自分以外のすべてのプロセッサに送信するモードなどがあります。

ここではNo Shorthandモードのみを使用します。Delivery Modeを0にすると、指定したAPIC IDを持つプロセッサに対してvectorで指定した割り込み番号の割り込みを発生させることができます。多くのOSではプロセッサ間の同期処理やタスクスケジューラの調整などで利用されています。

最初のIPIはDelivery Modeが0b101ですが、これは指定したAPに初期化処理を行うように指示します。内部でどのような初期化をしているかはわかりませんが、まずはこの処理を行う必要があるようです。 いくつかの既存のOSを調べた限りでは、このIPIはLevel Triggerで行うようです。Vectorは0にします。

Level TriggerでAssertした場合、De-Assertする必要がありますが、これは先程のIPIのLevelをDe-Assertにして再度行います。Intel Software Developer's Manualを読む限りではDestinationに関わらず、すべてのプロセッサに対して割り込みがかかるようですがFreeBSDではDestinationを指定しているのでとりあえずそうすることとします。(freebsd-de-assert)

IPIを発行すると指定したAPは初期化処理を行いますが、即座には終わらないのでしばらく待つ必要があります。 Intel Software Developer's Manual Vol. 3Aの"8.4.4.1 Typical BSP Initialization Sequence"によれば、発行して10ミリ秒待てば良いようです。

次にIPIのDelivery Modeを0b110にしてStartup IPIを発行します。 これは指定したAPにvectorで指定したスタートアッププログラムを実行するように指示するものです。vectorには4KiB単位におけるリアルアドレス空間のページ番号を指定します。 同じくIntel Software Developer's Manualを見ると、Edge Trigger Modeで200マイクロ秒間隔で2回発行するようです。

Startup IPIの説明によると、何らかの問題でStartup IPIが発行できなかった場合は再送が行われないとあるため、その対策のようです。すでにStartup IPIが発行されている場合2回目のIPIは無視されます。

一連のIPIが完了するとAP_BOOT_COMPLETE_FLAGをビジーループで監視し、APの初期化が完了するのを待ちます。 ここでは5秒待っても完了しない場合、何らかの問題が生じたと判断しパニックを起こして停止するようにしています。

APの起動処理

一連の設定でAPはvectorで指定したアドレスにあるプログラムの実行を始めます。 リアルモードで起動したAPを各設定を行いロングモードに移行した後、AP_BOOT_COMPLETE_FLAGをtrueにすることでBSPに初期化が完了したことを通知するようにします。

APが最初に実行するのはap_entryです。


.code16
ap_entry:
    /* 16bitリアルモードではCS・DSレジスタはアドレス計算時に4bitした値が
       オフセットと合算され実際のアドレスが求められる。
       "cs:ip(address = cs * 16 + ip)" */
    cli
    /* CSレジスタからap_entryのアドレスを計算する */
    mov     %cs, %ax
    mov     %ax, %ds
    /* 全てのメモリデータアクセスにはDSレジスタが使用される */
    xor     %ebx, %ebx  /* EBX = 0 */
    mov     %ax, %bx
    shl     $4, %ebx    /* EBX <<=4 ( EBX *= 16 ) */

    /* ljmplとGDTのベースアドレスを調整 */
    add     %ebx, ljmpl_32_address - ap_entry
    add     %ebx, gdtr_32bit - ap_entry + 2

    lgdt    (gdtr_32bit - ap_entry)

    mov     %cr0, %eax
    and     $0x7fffffff, %eax   /* ページング無効 */
    or      $0x00000001, %eax   /* 32bitプロテクトモード */
    mov     %eax, %cr0

    /* Long JMP */
    .byte 0x66, 0xea    /* オペコードと32bitアドレスプレフィックス */
ljmpl_32_address:
    .long (ap_init_long_mode - ap_entry)    /* オフセット */
    .word gdt_32bit_code_segment_descriptor /* コードセグメント */


.code32
ap_init_long_mode:
    mov     $gdt_32bit_data_segment_descriptor, %ax
    mov     %ax, %ds

    /* ljmplのベースアドレスを調整 */
    mov    $(ljmpl_64_address - ap_entry), %eax
    add     %ebx, (%ebx, %eax)

    mov     $pml4, %eax
    mov     %eax, %cr3
    mov     %cr4, %eax
    or      $(1 << 5), %eax
    mov     %eax, %cr4                  /* Set PAE flag */
    mov     $0xc0000080, %ecx
    rdmsr
    or      $(1 << 8 | 1 << 11), %eax   /* Set LME and NXE flags */
    wrmsr
    mov     %cr0, %eax
    or      $(1 << 31 | 1), %eax        /* Set PG flag */
    lgdt    gdtr0
    mov     %eax, %cr0

    /* Long JMP */
    .byte   0xea                        /* オペコード */
ljmpl_64_address:
    .long (ap_init_x86_64 - ap_entry)   /* オフセット */
    .word main_code_segment_descriptor  /* コードセグメント */


.code64
ap_init_x86_64:
    xor     %ax, %ax
    mov     %ax, %es
    mov     %ax, %ss
    mov     %ax, %ds
    mov     %ax, %fs
    mov     %ax, %gs
    /* スタックをセット */
    mov     $(ap_os_stack_address - ap_entry), %eax
    add     %ebx, %eax      /* EBXがベースアドレスを保持してる */
    mov     (%eax), %rsp
    lea     ap_boot_main, %rax
    jmp    *%rax            /* "*"は絶対ジャンプ */


.align  16

gdt_32bit:
    .quad   0
.equ gdt_32bit_code_segment_descriptor, . - gdt_32bit
    .word   0xffff, 0x0000, 0x9b00, 0x00cf
.equ gdt_32bit_data_segment_descriptor, . - gdt_32bit
    .word   0xffff, 0x0000, 0x9200, 0x00cf
    .word   0
gdtr_32bit:
    .word  . - gdt_32bit - 1
    .long  gdt_32bit - ap_entry

.align 8

ap_os_stack_address:
    .quad   0

ap_entry_end:

まず、ap_entryで最初にやることは実行しているメモリ番地を取得することです。

4Kibアライメントされていることはわかっているため、アドレスのうち下位0bitから数えて12bit目以上の値を取得できれば良いです。

ここで思い出してもらいたいことは、リアルモードでは機械語の実効アドレスを計算する際に自動的にCSの値を4bit左シフトした値を加算していることです。(CSレジスタというとロングモードに慣れていると実行中のセグメントセレクタが格納されているとつい思いがちですが...) そのため、CSレジスタの値を取り出すことで実行中のap_entryの位置がわかります。 CSレジスタの値をDSレジスタにもコピーすることでメモリデーターにアクセスする際もap_entryからのオフセットを指定することで正しく実効アドレス計算がなされるようになります。 またEBXにCSを4bit左シフトした値を格納しておきます。

次の処理ではロングジャンプ先の調整を行っています。 少し先を見ると、

/* Long JMP */
    .byte 0x66, 0xea    /* オペコードと32bitアドレスプレフィックス */
ljmpl_32_address:
    .long (ap_init_long_mode - ap_entry)    /* オフセット */
    .word gdt_32bit_code_segment_descriptor /* コードセグメント */

というように、LJMP命令が分解されて記載されています。

これは先ほどと同じ様にap_entryが配置される先が未定であるため、ジャンプ先をap_entryからのオフセットにしておき実行時にap_entryのアドレスを加算することで実際のアドレスを生成するようにしています。 ap_entryのアドレスはEBXに格納してあるので、これをljmpl_32_addressに加算することでLJMP命令を完成させています。

その後、32bit用の暫定GDTを設定しCR0レジスタを最低限設定したあとに先程完成させたLJMP命令ですぐ下のap_init_long_modeに移動します。

ap_init_long_modeでは、先ほどと同じ様にロングモードに移行するためのLJMP命令のジャンプ先アドレスの調整を行った後、ページテーブルを設定します。 ここで使用しているPML4ラベルはOS自体の起動処理で作成したページテーブルであり、BSPと共通のものとなっています。こうすることで素早くページングの設定を終えることができます。

その後、OS起動時と同じ処理を行いロングジャンプを経てロングモードに移行します。

ロングジャンプ先であるap_init_x86_64では、レジスタの初期化後、init_ap内で書き込んでおいたap_os_stack_addressからスタックアドレスを取得しRSPにセットします。Rustなどの高級言語ではスタックを使用するためジャンプする前に設定する必要があるのです。 こうして、APはRustで書かれたap_boot_mainにジャンプします。

ap_boot_mainはinit_apと同じくsrc/ap.rsにあります。

#[no_mangle]
extern "C" fn ap_boot_main() -> ! {
    let mut per_cpu_data =
        create_per_cpu_data(unsafe { &MEMORY_MANAGER });
    per_cpu_data.local_apic_id = get_apic_id() as u32;
    drop(per_cpu_data);
    println!(
        "Hello! Local Apic id = {}",
        get_per_cpu_data().local_apic_id
    );
    AP_BOOT_COMPLETE_FLAG
        .store(true, core::sync::atomic::Ordering::Relaxed);
    loop {
        unsafe { asm!("hlt") };
    }
}

ap_boot_mainでは、CPU固有の構造体を作成し、そこにAPIC IDを保存し、IDを表示してからAP_BOOT_COMPLETE_FLAGをtrueにして、hltループに入っています。

マルチコアプロセッサではタスクプロセスのランキューや、割り込み情報など各プロセッサごとに持たせておきたい情報があります。 各プロセッサ用の構造体を配列にして、Local APIC IDをインデックス値にしてアクセスするという方法もありますが、ここではGSレジスタを利用する方法を紹介します。

GSレジスタはCSレジスタなどと同じくセグメントセレクタですが、FSレジスタとGSレジスタは別にベースアドレスを保持することができます。 レジスタは各プロセッサごとにありますから、GSレジスタに各プロセッサごとの構造体のアドレスを格納すれば良さそうです。

GSレジスタのベースアドレスは直接操作することができないため、MSR(Model Specific Registers)の0xC0000101に書き込むことで更新できます。 またはCR4レジスタの16bit目を1にすることで、RDGSBASE/WRGSBASEという命令が使用できるようになります。これを使えば直接扱えますが、すべてのCPUでサポートされているわけではなくIntelのCPUでは第三世代Coreプロセッサ以降が対応しています。

ここではMSRを使って設定しており、その作業はcreate_per_cpu_dataで行っています。

#[repr(C)]
pub struct PerCpuData {
    self_pointer: usize,
    local_apic_id: u32,
}


fn create_per_cpu_data(
    memory_manager: &MemoryManager,
) -> &'static mut PerCpuData {
    let address = memory_manager
        .alloc(core::mem::size_of::<PerCpuData>())
        .unwrap();
    let mut d =
        unsafe { &mut *(address as *mut PerCpuData) };
    d.self_pointer = address;
    let edx: u32 = (address >> 32) as u32;
    let eax: u32 = address as u32;
    unsafe {
        asm!("wrmsr", in("eax") eax, in("edx") edx, in("ecx") 0xC0000101u32)
    };
    return d;
}

create_per_cpu_dataでは、メモリ管理からPerCpuData構造体のサイズ分だけメモリを確保した後、self_pointerに構造体自身のアドレスを設定します。 なぜこのようにするかは後で説明します。 その後構造体のアドレスをMSRの0xC0000101に書き込み、GSレジスタのベースアドレスを更新します。

ここでは、設定の観点から構造体のアドレスをそのままap_boot_mainに返していますが、任意の場所からPerCpuData構造体にアクセスするにはget_per_cpu_dataを使用します。

fn get_per_cpu_data() -> &'static mut PerCpuData {
    let address: usize;
    unsafe {
        asm!("mov {}, gs:0",out(reg) address);
        &mut *(address as *mut PerCpuData)
    }
}

ここで肝心なのは、mov {}, gs:0です。

"{}"というのはインラインアセンブリの機能でここに任意のレジスタをコンパイラが割り振り、"out(reg) var"でそのレジスタの値を変数に代入してくれます。 つまりこのアセンブリはaddress = gs:0という命令です。 インデックスレジスタとしてGSを指定することで GS.base + 0 = GS.baseのメモリ番地の値を代入できます。

注意するのは、GSレジスタのベースアドレスを代入するのではなくGSレジスタのベースアドレスが指すメモリの値を代入していることです。 残念ながらMOV命令ではメモリアクセスせずにGSレジスタのベースアドレスを取得する方法がないようです。

そこで先程設定したself_pointerメンバ変数の出番です。 self_pointerには構造体のアドレスが代入されてますので、これを構造体のトップに置いておけばgs:0にアクセスすることで構造体のアドレスをself_pointer変数を参照することによって目的を達成できます。 このようにしてPerCpuDataの各メンバにアクセスすることができます。(C言語のマクロなど、構造体のメンバのオフセットを取得できる場合はセルフポインタを利用せずとも、"gs:メンバのオフセット"で直接アクセスできると思います。) ここではAPIC IDしか保存してませんが、ここに変数を追加することでプロセッサごとのランキューなど実現できます。

GSレジスタはアプリケーション側からでも参照及び変更できるので、システムコールなどでアプリケーションからOSに制御が切り替わる際にGSレジスタのベースアドレスをOS用に変更したい場合があります。 そのためにSWAPGS命令という命令が用意されており、これはMSRの0xC0000102にある値と0xC0000101(つまり現在のGSレジスタのベースアドレス)を交換する命令です。(SWAPGS命令はSpectreで問題となった命令で、IntelのCPUではセキュリティパッチが適用されているそうです。)

この命令を使うことで、システムコールが呼ばれた直後にswapgs命令を発行することで素早くPerCpuData構造体にアクセスすることができます。 ここでは深く説明しませんので、気になる方は調べてみてください。

話を戻しますと、get_per_cpu_dataでPerCpuDataのアドレスを取り出せるので、get_per_cpu_data().local_apic_idで正しく値が取り出せていることを確認したあと、AP_BOOT_COMPLETE_FLAGをtrueにして、hltループに入ります。

こうして、BSPはビジーループから抜け出し、次のAPの初期化に進みます。

おわりに

ここでは各APを初期化してロングモードに移行し、hltループに入るまでを説明しました。 本来ならば、ここから割り込みテーブルやタスクスケジューラの初期化、排他処理などをする必要がありますが、説明するととても長くなるので、ここでは扱いません。 しかし、ここまでくればあとはBSPと同じ様に処理できますし、タスクスケジューラの設計などは皆さんの腕の見せ所だと思います。

皆さんが本書をみて、素晴らしいマルチコアOSを開発してくれたら幸いです。

Info

Soft.Taprix.org
Taprix.org