이미지 로딩 중...

Rust로 만드는 나만의 OS x86_64 레지스터 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 11. 14. · 5 Views

Rust로 만드는 나만의 OS x86_64 레지스터 완벽 가이드

x86_64 아키텍처의 핵심인 레지스터를 Rust로 다루는 방법을 배웁니다. OS 개발에 필수적인 범용 레지스터, 세그먼트 레지스터, 제어 레지스터의 구조와 활용법을 실전 코드와 함께 알아봅니다.


목차

  1. 범용 레지스터(RAX, RBX, RCX, RDX) - 데이터 처리의 핵심
  2. 세그먼트 레지스터(CS, DS, SS) - 메모리 접근 제어
  3. 제어 레지스터 CR0 - CPU 동작 모드 제어
  4. 제어 레지스터 CR3 - 페이지 테이블 베이스 주소
  5. RFLAGS 레지스터 - CPU 상태와 제어 플래그
  6. MSR 레지스터 - 모델별 시스템 제어
  7. 스택 포인터(RSP, RBP) - 함수 호출과 로컬 변수
  8. 명령 포인터(RIP) - 코드 실행 위치

1. 범용 레지스터(RAX, RBX, RCX, RDX) - 데이터 처리의 핵심

시작하며

여러분이 OS 커널을 개발하면서 함수 호출 규약을 구현하거나 시스템 콜을 처리할 때 이런 상황을 겪어본 적 있나요? "어떤 레지스터에 반환값을 넣어야 하지?", "왜 특정 레지스터만 사용해야 하지?" 같은 의문을 가져본 경험 말입니다.

이런 문제는 실제 저수준 시스템 프로그래밍에서 자주 발생합니다. x86_64 아키텍처는 특정 레지스터를 특정 용도로 사용하는 관례가 있고, 이를 모르면 디버깅하기 어려운 버그를 만들 수 있습니다.

특히 인라인 어셈블리를 사용할 때는 레지스터 선택이 성능과 안정성에 직접적인 영향을 미칩니다. 바로 이럴 때 필요한 것이 범용 레지스터의 정확한 이해입니다.

RAX, RBX, RCX, RDX 같은 레지스터들은 각각 고유한 역할과 용도를 가지고 있으며, 이를 올바르게 활용하면 효율적이고 안전한 시스템 코드를 작성할 수 있습니다.

개요

간단히 말해서, 범용 레지스터는 CPU가 데이터를 직접 처리할 수 있는 가장 빠른 저장 공간입니다. x86_64 아키텍처에서는 16개의 64비트 범용 레지스터(RAX, RBX, RCX, RDX, RSI, RDI, RBP, RSP, R8-R15)를 제공합니다.

이들 레지스터가 필요한 이유는 메모리 접근보다 수백 배 빠른 연산이 가능하기 때문입니다. OS 개발에서는 특히 시스템 콜 인터페이스, 함수 호출 규약(calling convention), 인터럽트 핸들러 등에서 레지스터를 직접 다뤄야 합니다.

예를 들어, Linux 시스템 콜은 RAX에 시스템 콜 번호를, RDI, RSI, RDX 등에 인자를 전달하는 규약을 사용합니다. 기존 C 기반 OS 개발에서는 인라인 어셈블리로 레지스터를 다뤘다면, Rust에서는 asm! 매크로와 타입 안전성을 결합하여 더 안전하게 레지스터를 조작할 수 있습니다.

이들 레지스터의 핵심 특징은 첫째, 64비트 전체(RAX), 하위 32비트(EAX), 하위 16비트(AX), 하위 8비트(AL/AH)로 부분 접근이 가능하다는 점입니다. 둘째, 각 레지스터는 관례적인 용도가 있어서 RAX는 반환값, RCX는 카운터, RDX는 곱셈/나눗셈의 확장 등으로 사용됩니다.

이러한 특징들이 컴파일러 최적화와 ABI 호환성을 위해 매우 중요합니다.

코드 예제

use core::arch::asm;

// RAX 레지스터 읽기 (시스템 콜 반환값 등)
pub fn read_rax() -> u64 {
    let value: u64;
    unsafe {
        // RAX 레지스터의 값을 value 변수로 이동
        asm!("mov {}, rax", out(reg) value);
    }
    value
}

// 여러 범용 레지스터를 한번에 읽기 (컨텍스트 스위칭 등)
pub fn save_general_registers() -> [u64; 4] {
    let rax: u64;
    let rbx: u64;
    let rcx: u64;
    let rdx: u64;

    unsafe {
        // 각 레지스터 값을 변수로 저장
        asm!(
            "mov {0}, rax",
            "mov {1}, rbx",
            "mov {2}, rcx",
            "mov {3}, rdx",
            out(reg) rax,
            out(reg) rbx,
            out(reg) rcx,
            out(reg) rdx,
        );
    }

    [rax, rbx, rcx, rdx]
}

설명

이것이 하는 일: 위 코드는 x86_64 아키텍처의 범용 레지스터 값을 Rust에서 안전하게 읽어오는 방법을 보여줍니다. 첫 번째 함수는 단일 레지스터(RAX)를, 두 번째 함수는 여러 레지스터를 동시에 읽습니다.

첫 번째로, read_rax() 함수에서 unsafe 블록 안의 asm! 매크로는 인라인 어셈블리를 실행합니다. mov {}, rax 명령은 RAX 레지스터의 값을 중괄호로 표시된 위치(출력 피연산자)로 복사하며, out(reg) value는 컴파일러에게 "적절한 레지스터를 선택해서 그 값을 value 변수에 저장하라"고 지시합니다.

이 방식이 중요한 이유는 Rust의 타입 시스템과 어셈블리를 연결하면서도 안전성을 유지하기 때문입니다. 그 다음으로, save_general_registers() 함수는 컨텍스트 스위칭이나 예외 처리에서 실제로 필요한 패턴을 구현합니다.

여러 개의 mov 명령을 하나의 asm! 블록에 넣어 원자적으로 실행하며, {0}, {1} 등의 번호는 아래의 출력 피연산자 순서와 매칭됩니다. 내부에서는 각 레지스터의 현재 값이 임시 레지스터나 스택을 거치지 않고 직접 지역 변수로 복사되므로 오버헤드가 최소화됩니다.

마지막으로, 배열 [rax, rbx, rcx, rdx]를 반환함으로써 프로세스의 실행 상태를 스냅샷처럼 저장할 수 있습니다. 이는 멀티태스킹 OS에서 태스크 전환 시 필수적인 작업이며, 나중에 이 값들을 역순으로 레지스터에 복원하면 정확히 중단된 지점부터 실행을 재개할 수 있습니다.

여러분이 이 코드를 사용하면 시스템 콜 구현, 인터럽트 핸들러 작성, 프로세스 스케줄러 개발 등에서 레지스터를 직접 제어할 수 있습니다. 특히 Rust의 타입 안전성 덕분에 레지스터 크기 불일치(예: 32비트 값을 64비트 레지스터에 쓸 때)로 인한 버그를 컴파일 타임에 잡을 수 있고, 메모리 안전성 보장으로 스택 오버플로우나 버퍼 오버런 같은 치명적인 버그를 예방할 수 있습니다.

또한 zero-cost abstraction 원칙에 따라 이러한 고수준 코드가 순수 어셈블리와 동일한 성능을 냅니다.

실전 팁

💡 레지스터를 수정하는 코드를 작성할 때는 반드시 clobber_abi("C")나 명시적 clobber 리스트를 사용하여 컴파일러에게 어떤 레지스터가 변경되는지 알려주세요. 그렇지 않으면 컴파일러 최적화로 인해 예상치 못한 값이 덮어씌워질 수 있습니다.

💡 RAX는 함수 반환값에 사용되므로 여러분의 어셈블리 코드가 함수를 호출한다면 RAX가 덮어씌워진다고 가정해야 합니다. 중요한 값은 미리 다른 레지스터나 스택에 백업하세요.

💡 디버깅 시에는 GDB의 info registers 명령으로 모든 레지스터 값을 확인할 수 있습니다. Rust의 println!으로는 볼 수 없는 하드웨어 상태를 직접 관찰할 수 있어 매우 유용합니다.

💡 x86_64 System V ABI에 따르면 RBX, RBP, R12-R15는 callee-saved(피호출자 저장) 레지스터이므로, 여러분의 함수가 이들을 수정한다면 반드시 원래 값을 복원해야 합니다. 그렇지 않으면 호출자의 데이터가 손상됩니다.

💡 성능 최적화를 위해 자주 사용하는 변수는 레지스터 변수로 선언할 수 있지만, Rust 컴파일러(LLVM)는 이미 매우 효율적인 레지스터 할당을 수행하므로 대부분의 경우 수동 최적화보다 컴파일러를 신뢰하는 것이 좋습니다.


2. 세그먼트 레지스터(CS, DS, SS) - 메모리 접근 제어

시작하며

여러분이 유저 모드와 커널 모드를 분리하거나 각 프로세스마다 독립적인 메모리 공간을 제공하려고 할 때 어떤 메커니즘을 사용해야 할까요? 단순히 가상 메모리만으로는 충분하지 않고, CPU 레벨에서의 권한 분리가 필요합니다.

이런 문제는 멀티태스킹 OS에서 필수적으로 해결해야 하는 과제입니다. 악의적인 프로세스가 커널 메모리에 접근하거나, 다른 프로세스의 데이터를 훔쳐보는 것을 하드웨어 수준에서 방지해야 합니다.

이것이 바로 보안과 안정성의 핵심입니다. 바로 이럴 때 필요한 것이 세그먼트 레지스터입니다.

CS(Code Segment), DS(Data Segment), SS(Stack Segment) 등의 레지스터는 메모리 접근 시 권한 검사와 주소 변환을 담당하며, 특히 CS 레지스터의 하위 2비트(CPL, Current Privilege Level)는 현재 실행 권한을 결정합니다. x86_64에서는 세그멘테이션이 대부분 레거시 기능이 되었지만, 권한 수준(Ring 0-3) 제어와 시스템 콜 진입점 설정에는 여전히 필수적입니다.

개요

간단히 말해서, 세그먼트 레지스터는 메모리 접근 시 권한을 검사하고 세그먼트 디스크립터를 가리키는 16비트 셀렉터를 저장합니다. x86_64에서는 CS, DS, ES, FS, GS, SS 총 6개의 세그먼트 레지스터가 있습니다.

이들 레지스터가 필요한 이유는 CPU가 메모리 접근마다 자동으로 권한 검사를 수행하게 하기 위함입니다. 예를 들어, Ring 3(유저 모드)에서 실행 중인 프로세스가 Ring 0(커널 모드)의 메모리에 접근하려고 하면 CPU가 자동으로 General Protection Fault(#GP)를 발생시킵니다.

이는 소프트웨어적인 검사보다 훨씬 빠르고 안전합니다. 기존 x86 32비트에서는 세그멘테이션이 메모리 주소 변환에도 사용되었지만, x86_64에서는 대부분의 세그먼트 베이스가 0으로 고정되고 플랫 메모리 모델을 사용합니다.

하지만 FS와 GS는 예외로, 스레드 로컬 스토리지(TLS)와 CPU 로컬 데이터 구조체 접근에 여전히 활발히 사용됩니다. 핵심 특징은 첫째, CS 레지스터의 하위 2비트(RPL)가 현재 권한 수준(CPL)을 나타낸다는 점입니다.

둘째, 세그먼트 레지스터는 GDT(Global Descriptor Table)나 LDT(Local Descriptor Table)의 인덱스를 저장하며, 실제 디스크립터는 CPU 내부 캐시에 저장됩니다. 셋째, 일부 명령어(MOV to/from segment registers)는 권한이 필요하거나 성능 저하를 일으킬 수 있어 신중하게 사용해야 합니다.

코드 예제

use core::arch::asm;

// CS(Code Segment) 레지스터 읽기 - 현재 권한 수준 확인
pub fn read_cs() -> u16 {
    let cs: u16;
    unsafe {
        // CS 레지스터 값을 읽음 (하위 2비트가 CPL)
        asm!("mov {0:x}, cs", out(reg) cs, options(nomem, nostack));
    }
    cs
}

// 현재 권한 수준(CPL) 추출
pub fn current_privilege_level() -> u8 {
    (read_cs() & 0b11) as u8  // 하위 2비트가 CPL (0=Ring 0, 3=Ring 3)
}

// SS(Stack Segment) 레지스터 읽기
pub fn read_ss() -> u16 {
    let ss: u16;
    unsafe {
        // SS 레지스터는 스택 세그먼트를 가리킴
        asm!("mov {0:x}, ss", out(reg) ss, options(nomem, nostack));
    }
    ss
}

// 모든 세그먼트 레지스터 상태 저장
#[repr(C)]
pub struct SegmentRegisters {
    pub cs: u16,
    pub ds: u16,
    pub es: u16,
    pub fs: u16,
    pub gs: u16,
    pub ss: u16,
}

pub fn save_segment_registers() -> SegmentRegisters {
    let mut regs = SegmentRegisters {
        cs: 0, ds: 0, es: 0, fs: 0, gs: 0, ss: 0,
    };

    unsafe {
        asm!(
            "mov {0:x}, cs",
            "mov {1:x}, ds",
            "mov {2:x}, es",
            "mov {3:x}, fs",
            "mov {4:x}, gs",
            "mov {5:x}, ss",
            out(reg) regs.cs,
            out(reg) regs.ds,
            out(reg) regs.es,
            out(reg) regs.fs,
            out(reg) regs.gs,
            out(reg) regs.ss,
            options(nomem, nostack)
        );
    }

    regs
}

설명

이것이 하는 일: 위 코드는 x86_64의 세그먼트 레지스터 값을 읽어와 현재 CPU 권한 수준과 메모리 세그먼트 설정을 확인합니다. 이는 OS 커널이 자신의 실행 컨텍스트를 파악하거나 디버깅할 때 필수적입니다.

첫 번째로, read_cs() 함수는 CS 레지스터를 읽습니다. 여기서 중요한 것은 {0:x} 형식 지정자인데, 이는 16비트 레지스터(x)를 의미합니다.

options(nomem, nostack)는 이 어셈블리 코드가 메모리나 스택을 수정하지 않음을 컴파일러에게 알려 최적화를 가능하게 합니다. CS 레지스터는 읽기만 가능하고 직접 쓸 수 없으며, far jump나 far call, 인터럽트, 시스템 콜 등을 통해서만 변경됩니다.

그 다음으로, current_privilege_level() 함수는 CS 값의 하위 2비트를 추출합니다. 이 비트는 CPL(Current Privilege Level)을 나타내며, 0이면 커널 모드(Ring 0), 3이면 유저 모드(Ring 3)입니다.

Ring 1과 2는 이론적으로 존재하지만 현대 OS에서는 거의 사용하지 않습니다. 이 정보는 시스템 콜 핸들러에서 호출자의 권한을 확인하거나, 커널 패닉 시 컨텍스트를 출력할 때 유용합니다.

save_segment_registers() 함수는 모든 세그먼트 레지스터를 구조체로 저장합니다. 이는 컨텍스트 스위칭이나 예외 처리 시 필요하지만, 실제로는 x86_64에서 대부분의 세그먼트 레지스터(DS, ES, SS)가 CS와 동일한 값을 가지므로 저장할 필요가 적습니다.

하지만 FS와 GS는 TLS(Thread Local Storage)나 per-CPU 데이터 접근에 사용되므로 반드시 저장하고 복원해야 합니다. 여러분이 이 코드를 사용하면 시스템 콜 진입 시 권한 수준 검증, 커널 패닉 시 디버그 정보 출력, 컨텍스트 스위칭 구현 등을 할 수 있습니다.

특히 current_privilege_level()은 "현재 커널 모드인가?"를 확인하는 빠른 방법으로, 일부 크리티컬 섹션에서 어서션으로 사용하면 버그를 조기에 발견할 수 있습니다. 예를 들어 assert_eq!(current_privilege_level(), 0, "This function must be called in kernel mode")처럼 사용할 수 있습니다.

실전 팁

💡 CS 레지스터는 직접 mov 명령으로 수정할 수 없습니다. 대신 far jump(ljmp), far return(lret), 또는 sysret/iret 명령을 사용해야 하며, 이때 스택에 새로운 CS와 RIP를 설정해야 합니다. 잘못 설정하면 Triple Fault로 CPU가 리셋됩니다.

💡 FS와 GS 레지스터의 베이스 주소는 MSR(Model Specific Register)인 FS.BASE(0xC0000100)와 GS.BASE(0xC0000101)로 설정합니다. wrmsrl 명령을 사용하며, 이는 TLS나 per-CPU 변수 접근에 필수적입니다.

💡 swapgs 명령은 GS 레지스터의 베이스를 커널 GS.BASE(MSR 0xC0000102)와 교환합니다. 시스템 콜 진입 시 유저 GS를 저장하고 커널 GS로 전환할 때 사용하며, 나갈 때 다시 호출하여 복원합니다. 이 명령을 빠뜨리면 유저 공간 데이터를 손상시킬 수 있습니다.

💡 GDT(Global Descriptor Table)를 설정할 때 각 세그먼트 디스크립터의 DPL(Descriptor Privilege Level) 필드가 중요합니다. 커널 코드 세그먼트는 DPL=0, 유저 코드 세그먼트는 DPL=3으로 설정해야 하며, 이것이 맞지 않으면 권한 검사가 실패합니다.

💡 64비트 모드에서도 null 세그먼트(GDT 인덱스 0)는 사용할 수 없습니다. DS를 0으로 설정하면 #GP 예외가 발생하므로, 최소한 하나의 유효한 데이터 세그먼트 디스크립터를 GDT에 등록해야 합니다.


3. 제어 레지스터 CR0 - CPU 동작 모드 제어

시작하며

여러분이 부트로더에서 커널로 점프하면서 보호 모드에서 롱 모드(64비트)로 전환해야 한다면 어떻게 해야 할까요? 또는 페이징을 활성화하거나 쓰기 보호를 설정하고 싶다면요?

이런 작업은 OS 초기화 과정에서 반드시 거쳐야 하는 단계입니다. CPU 모드 전환, 메모리 보호 기능 활성화, 캐시 정책 설정 등은 모두 하드웨어 레벨에서 제어되며, 잘못 설정하면 CPU가 리셋되거나 예측 불가능한 동작을 할 수 있습니다.

바로 이럴 때 필요한 것이 제어 레지스터 CR0입니다. CR0는 CPU의 기본 동작 모드를 제어하는 32비트 레지스터로, 보호 모드 활성화(PE), 페이징 활성화(PG), 쓰기 보호(WP), 캐시 비활성화(CD) 등 중요한 플래그를 포함합니다.

CR0를 올바르게 설정하는 것은 안정적인 OS 부팅과 메모리 보호의 기초입니다. 각 비트의 의미를 정확히 이해하고 순서에 맞게 설정해야 합니다.

개요

간단히 말해서, CR0는 CPU의 기본 동작 모드와 보호 기능을 제어하는 제어 레지스터입니다. 32개의 비트 중 일부만 정의되어 있고, 각 비트는 특정 기능을 활성화하거나 비활성화합니다.

이 레지스터가 필요한 이유는 CPU 모드 전환과 메모리 보호 기능을 하드웨어 수준에서 제어하기 위함입니다. 예를 들어, 리얼 모드에서 보호 모드로 전환하려면 CR0.PE 비트를 1로 설정해야 하고, 페이징을 사용하려면 CR0.PG 비트를 1로 설정해야 합니다.

또한 CR0.WP 비트를 설정하면 커널 모드에서도 읽기 전용 페이지에 쓰기를 시도하면 페이지 폴트가 발생하여 커널 버그를 조기에 발견할 수 있습니다. 기존 어셈블리 기반 부트로더에서는 CR0를 직접 조작했다면, Rust OS 개발에서는 타입 안전한 비트플래그 구조체를 만들어 실수를 방지할 수 있습니다.

주요 CR0 비트는 다음과 같습니다: PE(bit 0) - 보호 모드, MP(bit 1) - 모니터 코프로세서, EM(bit 2) - 에뮬레이션, TS(bit 3) - 태스크 전환됨, ET(bit 4) - 확장 타입, NE(bit 5) - 숫자 오류, WP(bit 16) - 쓰기 보호, AM(bit 18) - 정렬 마스크, NW(bit 29) - Not Write-through, CD(bit 30) - 캐시 비활성화, PG(bit 31) - 페이징. 이 중 PE, WP, PG가 OS 개발에서 가장 중요하며, 나머지는 레거시이거나 특수한 경우에만 사용됩니다.

코드 예제

use core::arch::asm;
use bitflags::bitflags;

bitflags! {
    /// CR0 레지스터의 비트 플래그
    pub struct Cr0Flags: u64 {
        /// Protection Enable - 보호 모드 활성화
        const PROTECTED_MODE = 1 << 0;
        /// Monitor Coprocessor - FPU 모니터링
        const MONITOR_COPROCESSOR = 1 << 1;
        /// Emulation - FPU 에뮬레이션
        const EMULATION = 1 << 2;
        /// Task Switched - FPU 컨텍스트 저장 지연
        const TASK_SWITCHED = 1 << 3;
        /// Numeric Error - x87 FPU 오류 보고
        const NUMERIC_ERROR = 1 << 5;
        /// Write Protect - 읽기 전용 페이지 쓰기 보호
        const WRITE_PROTECT = 1 << 16;
        /// Alignment Mask - 정렬 검사 활성화
        const ALIGNMENT_MASK = 1 << 18;
        /// Not Write-through - 캐시 쓰기 정책
        const NOT_WRITE_THROUGH = 1 << 29;
        /// Cache Disable - 캐시 비활성화
        const CACHE_DISABLE = 1 << 30;
        /// Paging - 페이징 활성화
        const PAGING = 1 << 31;
    }
}

/// CR0 레지스터 읽기
pub fn read_cr0() -> Cr0Flags {
    let value: u64;
    unsafe {
        asm!("mov {}, cr0", out(reg) value, options(nomem, nostack));
    }
    Cr0Flags::from_bits_truncate(value)
}

/// CR0 레지스터 쓰기 (특정 비트만 수정)
pub unsafe fn write_cr0(flags: Cr0Flags) {
    asm!("mov cr0, {}", in(reg) flags.bits(), options(nostack));
}

/// CR0 레지스터에 플래그 추가 (비트 OR)
pub unsafe fn enable_cr0_flags(flags: Cr0Flags) {
    let current = read_cr0();
    write_cr0(current | flags);
}

/// CR0 레지스터에서 플래그 제거 (비트 AND NOT)
pub unsafe fn disable_cr0_flags(flags: Cr0Flags) {
    let current = read_cr0();
    write_cr0(current & !flags);
}

// 사용 예: 쓰기 보호와 페이징 활성화
pub unsafe fn enable_memory_protection() {
    enable_cr0_flags(Cr0Flags::WRITE_PROTECT | Cr0Flags::PAGING);
}

설명

이것이 하는 일: 위 코드는 CR0 레지스터를 타입 안전하게 읽고 쓰는 추상화를 제공합니다. bitflags 크레이트를 사용하여 각 비트에 의미있는 이름을 부여하고, 비트 연산을 안전하게 수행할 수 있습니다.

첫 번째로, Cr0Flags 구조체는 bitflags 매크로로 정의되어 있습니다. 이는 각 CR0 비트를 상수로 정의하고, 비트 OR(|), AND(&), NOT(!) 연산을 자동으로 구현해줍니다.

예를 들어 Cr0Flags::WRITE_PROTECT | Cr0Flags::PAGING은 두 비트를 모두 설정한 값을 반환하며, 타입 시스템 덕분에 실수로 잘못된 비트를 설정하는 것을 방지할 수 있습니다. from_bits_truncate는 정의되지 않은 비트를 무시하고 유효한 비트만 추출합니다.

그 다음으로, read_cr0()write_cr0()는 실제 하드웨어와 상호작용합니다. mov {}, cr0 명령은 CR0의 64비트 값(실제로는 하위 32비트만 사용)을 범용 레지스터로 복사하고, 반대로 mov cr0, {}는 범용 레지스터 값을 CR0에 씁니다.

이 명령들은 Ring 0(커널 모드)에서만 실행 가능하며, Ring 3에서 실행하면 General Protection Fault가 발생합니다. 따라서 모든 CR0 조작 함수는 unsafe로 표시되어 있습니다.

enable_cr0_flags()disable_cr0_flags() 함수는 read-modify-write 패턴을 구현합니다. 현재 CR0 값을 읽고, 원하는 비트만 수정한 후, 다시 씁니다.

이는 다른 비트를 보존하면서 특정 기능만 활성화하거나 비활성화할 수 있어 안전합니다. 예를 들어 페이징을 활성화할 때 실수로 보호 모드를 비활성화하는 일을 방지합니다.

마지막으로, enable_memory_protection() 함수는 실제 사용 예시입니다. 쓰기 보호(WP)를 활성화하면 커널 모드에서도 읽기 전용으로 매핑된 페이지에 쓰기를 시도하면 페이지 폴트가 발생합니다.

이는 커널 코드 섹션을 보호하거나, Copy-on-Write를 구현할 때 유용합니다. 페이징(PG)을 활성화하면 가상 메모리 시스템이 작동하며, 이후 모든 메모리 접근은 페이지 테이블을 통해 변환됩니다.

여러분이 이 코드를 사용하면 OS 부트 과정에서 안전하게 CPU 모드를 전환하고, 런타임에 메모리 보호 기능을 동적으로 조정할 수 있습니다. 특히 타입 안전성 덕분에 "PE 비트를 끄면 안 되는데 실수로 껐다" 같은 치명적인 버그를 컴파일 타임에 잡을 수 있습니다.

또한 bitflags의 Debug 구현으로 println!("{:?}", read_cr0())처럼 현재 설정을 사람이 읽을 수 있는 형태로 출력할 수 있어 디버깅이 용이합니다.

실전 팁

💡 CR0.PG를 활성화하기 전에 반드시 CR3에 유효한 페이지 테이블 주소를 설정하고, CR4.PAE(Physical Address Extension) 또는 EFER.LME(Long Mode Enable)를 먼저 설정해야 합니다. 순서를 틀리면 General Protection Fault나 Triple Fault가 발생합니다.

💡 CR0.WP 비트는 보안에 매우 중요합니다. 일부 공격자는 커널 취약점을 이용해 이 비트를 끄고 읽기 전용 메모리를 수정하려고 시도합니다. 런타임에 주기적으로 이 비트가 켜져 있는지 확인하는 것이 좋습니다.

💡 CR0.CD와 CR0.NW 비트는 캐시 정책을 제어합니다. 일반적으로 둘 다 0으로 설정하여 캐시를 활성화하지만, 하드웨어 초기화나 DMA 버퍼 접근 시에는 캐시를 일시적으로 비활성화해야 할 수 있습니다. 캐시를 끄면 성능이 10-100배 저하되므로 주의하세요.

💡 CR0.TS 비트는 FPU/SSE 컨텍스트를 지연 저장(lazy save)하기 위해 사용됩니다. 태스크 전환 시 이 비트를 설정하고, FPU 명령 실행 시 #NM 예외가 발생하면 그때 컨텍스트를 저장/복원합니다. 이는 FPU를 사용하지 않는 프로세스의 컨텍스트 전환을 빠르게 만듭니다.

💡 mov cr0 명령은 직렬화(serializing) 명령이 아니므로, 앞선 명령의 효과가 아직 적용되지 않았을 수 있습니다. CR0 수정 후 JMP나 IRET 같은 직렬화 명령을 실행하거나, 명시적으로 메모리 배리어를 사용해야 합니다.


4. 제어 레지스터 CR3 - 페이지 테이블 베이스 주소

시작하며

여러분이 가상 메모리 시스템을 구현하면서 각 프로세스마다 독립적인 주소 공간을 제공하려고 할 때, 어떻게 CPU에게 "이 프로세스의 페이지 테이블은 여기 있어"라고 알려줄까요? 이런 문제는 멀티태스킹 OS의 핵심입니다.

프로세스 A가 주소 0x1000에 접근할 때와 프로세스 B가 같은 주소에 접근할 때 서로 다른 물리 메모리를 가리켜야 하며, 이를 위해서는 각 프로세스마다 다른 페이지 테이블을 사용해야 합니다. 컨텍스트 스위칭 시 페이지 테이블을 전환하는 것은 메모리 격리의 기본 메커니즘입니다.

바로 이럴 때 필요한 것이 제어 레지스터 CR3입니다. CR3는 현재 활성화된 페이지 테이블의 최상위 레벨(PML4 또는 PML5) 물리 주소를 저장하며, CPU는 모든 메모리 접근 시 CR3가 가리키는 페이지 테이블을 사용하여 가상 주소를 물리 주소로 변환합니다.

CR3를 변경하는 순간 전체 가상 주소 공간이 바뀌므로, 이는 프로세스 간 완벽한 메모리 격리를 제공합니다. 또한 TLB(Translation Lookaside Buffer) 플러시와도 밀접한 관련이 있습니다.

개요

간단히 말해서, CR3는 페이지 테이블 계층 구조의 최상위 테이블 물리 주소를 저장하는 64비트 레지스터입니다. x86_64에서는 4단계 페이징(PML4) 또는 5단계 페이징(PML5)을 사용하며, CR3는 이 계층 구조의 루트를 가리킵니다.

이 레지스터가 필요한 이유는 CPU가 가상 주소를 물리 주소로 변환할 때 어느 페이지 테이블을 참조해야 하는지 알아야 하기 때문입니다. 예를 들어, 프로세스 전환 시 스케줄러는 새로운 프로세스의 페이지 테이블 주소를 CR3에 로드하여 메모리 컨텍스트를 전환합니다.

이 한 번의 레지스터 쓰기로 수백만 개의 페이지 매핑이 동시에 바뀝니다. 기존 방식에서는 CR3 변경 시 항상 전체 TLB가 플러시되어 성능 저하가 발생했지만, 최신 CPU는 PCID(Process Context ID) 기능을 통해 TLB 엔트리에 태그를 달아 불필요한 플러시를 줄일 수 있습니다.

CR3의 구조는 다음과 같습니다: 비트 12-51은 페이지 테이블 물리 주소(4KB 정렬, 즉 하위 12비트는 항상 0), 비트 0-11은 플래그 영역입니다. 중요한 플래그는 PWT(Page-level Write-Through, bit 3)와 PCD(Page-level Cache Disable, bit 4)로 페이지 테이블 자체의 캐싱 정책을 제어합니다.

PCID를 사용하는 경우 비트 0-11에 12비트 프로세스 ID를 저장할 수 있습니다. 비트 63은 일부 CPU에서 "TLB 플러시 억제" 비트로 사용되어, CR3를 변경해도 TLB를 플러시하지 않을 수 있습니다.

코드 예제

use core::arch::asm;
use bitflags::bitflags;

/// 물리 주소를 나타내는 타입 (4KB 정렬 보장)
#[repr(transparent)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PhysAddr(u64);

impl PhysAddr {
    /// 4KB 정렬된 물리 주소 생성
    pub fn new(addr: u64) -> Option<Self> {
        if addr & 0xFFF == 0 && addr < (1u64 << 52) {
            Some(PhysAddr(addr))
        } else {
            None  // 정렬되지 않았거나 52비트 초과
        }
    }

    pub fn as_u64(self) -> u64 {
        self.0
    }
}

bitflags! {
    /// CR3 레지스터 플래그
    pub struct Cr3Flags: u64 {
        /// Page-level Write-Through - 페이지 테이블 캐시 정책
        const PAGE_WRITE_THROUGH = 1 << 3;
        /// Page-level Cache Disable - 페이지 테이블 캐시 비활성화
        const PAGE_CACHE_DISABLE = 1 << 4;
        /// No TLB Flush (일부 CPU만 지원)
        const NO_TLB_FLUSH = 1 << 63;
    }
}

/// CR3 레지스터 읽기 - 현재 페이지 테이블 주소
pub fn read_cr3() -> (PhysAddr, Cr3Flags) {
    let value: u64;
    unsafe {
        asm!("mov {}, cr3", out(reg) value, options(nomem, nostack));
    }

    let addr = PhysAddr(value & 0x000F_FFFF_FFFF_F000);  // 비트 12-51
    let flags = Cr3Flags::from_bits_truncate(value & 0xFFF);  // 비트 0-11

    (addr, flags)
}

/// CR3 레지스터 쓰기 - 페이지 테이블 전환
///
/// # Safety
/// - `addr`은 유효한 페이지 테이블 구조를 가리켜야 함
/// - 이 함수를 호출한 코드가 매핑된 페이지는 새 페이지 테이블에도 존재해야 함
pub unsafe fn write_cr3(addr: PhysAddr, flags: Cr3Flags) {
    let value = addr.as_u64() | flags.bits();
    asm!("mov cr3, {}", in(reg) value, options(nostack));
}

/// TLB 전체 플러시 (현재 CR3 재로드)
pub fn flush_tlb_all() {
    let (addr, flags) = read_cr3();
    unsafe {
        write_cr3(addr, flags);  // 같은 값을 다시 써서 TLB 플러시
    }
}

/// 특정 가상 주소의 TLB 엔트리만 플러시
pub fn flush_tlb_single(vaddr: u64) {
    unsafe {
        asm!("invlpg [{}]", in(reg) vaddr, options(nostack));
    }
}

// 사용 예: 프로세스 컨텍스트 전환
pub unsafe fn switch_to_address_space(new_page_table: PhysAddr) {
    write_cr3(new_page_table, Cr3Flags::empty());
    // TLB가 자동으로 플러시됨 (PCID 미사용 시)
}

설명

이것이 하는 일: 위 코드는 CR3 레지스터를 안전하게 읽고 쓰며, 페이지 테이블 주소의 정렬을 타입 시스템으로 보장합니다. 또한 TLB 플러시 기능도 제공합니다.

첫 번째로, PhysAddr 타입은 물리 주소를 나타내며, new() 함수는 4KB 정렬(하위 12비트가 0)과 52비트 범위를 검증합니다. 이는 x86_64가 최대 52비트 물리 주소를 지원하고, 페이지 테이블은 반드시 4KB 정렬되어야 한다는 하드웨어 요구사항을 타입 시스템에 반영한 것입니다.

Option<Self>를 반환하여 잘못된 주소는 컴파일 타임이나 런타임에 거부할 수 있습니다. 그 다음으로, read_cr3() 함수는 CR3 값을 읽고 주소 부분(비트 12-51)과 플래그 부분(비트 0-11)으로 분리합니다.

이는 CR3가 "주소 + 플래그" 형태로 구성되어 있기 때문입니다. 비트 마스킹(& 0x000F_FFFF_FFFF_F000)을 통해 주소만 추출하고, 플래그는 from_bits_truncate로 유효한 비트만 파싱합니다.

이렇게 분리하면 "현재 어느 페이지 테이블을 사용 중인가?"와 "어떤 캐시 정책을 사용하는가?"를 독립적으로 파악할 수 있습니다. write_cr3() 함수는 가장 주의해야 하는 함수입니다.

CR3를 변경하는 순간 모든 가상 주소의 의미가 바뀌므로, 현재 실행 중인 코드가 매핑되지 않으면 페이지 폴트가 발생합니다. 따라서 커널 코드 영역은 모든 프로세스의 페이지 테이블에 동일하게 매핑되어 있어야 합니다(보통 상위 절반 주소 공간).

또한 write_cr3() 실행 후 CPU는 TLB를 플러시하므로, 이후 몇 번의 메모리 접근은 페이지 테이블을 다시 워킹해야 하여 느려집니다. flush_tlb_all() 함수는 명시적으로 TLB를 플러시하는 방법을 보여줍니다.

페이지 테이블 엔트리를 수정했지만 CR3는 바꾸지 않았다면, 이전 매핑이 TLB에 캐시되어 있어 변경사항이 반영되지 않을 수 있습니다. 이때 CR3에 같은 값을 다시 쓰면 하드웨어가 TLB를 플러시합니다.

flush_tlb_single()은 더 효율적인 방법으로, invlpg 명령을 사용하여 특정 가상 주소의 TLB 엔트리만 무효화합니다. 단일 페이지 매핑만 변경했다면 전체 TLB를 플러시하는 것보다 훨씬 빠릅니다.

여러분이 이 코드를 사용하면 프로세스 스케줄러에서 컨텍스트 전환을 구현하거나, fork() 시스템 콜에서 Copy-on-Write를 위해 페이지 테이블을 복제하거나, 커널 페이지 매핑을 동적으로 변경할 수 있습니다. 특히 PhysAddr 타입 덕분에 "실수로 가상 주소를 CR3에 썼다"는 버그를 방지할 수 있으며, unsafe 경계를 명확히 하여 안전한 코드와 위험한 코드를 분리할 수 있습니다.

성능 최적화 측면에서는 PCID 기능을 추가로 구현하면 TLB 플러시 오버헤드를 크게 줄일 수 있습니다.

실전 팁

💡 CR3를 변경하기 전에 새로운 페이지 테이블이 현재 커널 코드와 스택을 매핑하고 있는지 반드시 확인하세요. 그렇지 않으면 write_cr3() 다음 명령을 페치할 수 없어 페이지 폴트가 발생합니다. 일반적으로 커널은 모든 프로세스 페이지 테이블의 상위 절반에 동일하게 매핑됩니다.

💡 단일 페이지 매핑만 변경했다면 invlpg를 사용하고, 여러 페이지를 변경했다면 CR3 재로드를 사용하세요. 일반적으로 10개 이상의 페이지를 변경했다면 전체 플러시가 더 효율적입니다.

💡 PCID(Process Context IDentifier) 기능을 사용하면 CR3 변경 시 TLB를 유지할 수 있습니다. CR4.PCIDE 비트로 활성화하고, CR3의 하위 12비트에 프로세스 ID를 넣으면 됩니다. 이는 컨텍스트 전환 성능을 크게 향상시킵니다.

💡 페이지 테이블 자체도 메모리에 있으므로 캐싱 정책이 중요합니다. 일반적으로 PWT와 PCD는 0(캐시 활성화)으로 설정하지만, 실시간 시스템이나 DMA가 페이지 테이블을 수정하는 경우 캐시를 비활성화해야 할 수 있습니다.

💡 read_cr3() 결과를 프로세스 제어 블록(PCB)에 저장하여, 각 프로세스의 주소 공간을 추적하세요. 디버깅 시 "이 프로세스가 사용하는 페이지 테이블은 무엇인가?"를 쉽게 확인할 수 있습니다.


5. RFLAGS 레지스터 - CPU 상태와 제어 플래그

시작하며

여러분이 조건 분기를 구현하거나, 인터럽트를 일시적으로 비활성화하거나, 산술 연산의 결과(오버플로우, 캐리 등)를 확인하려고 할 때 어떻게 해야 할까요? 이런 작업은 저수준 시스템 프로그래밍에서 매일 발생합니다.

"두 숫자를 더했는데 오버플로우가 발생했나?", "인터럽트를 끄고 크리티컬 섹션을 실행해야 하는데 어떻게 하지?", "디버거가 단일 스텝 모드로 실행하려면?" 같은 질문들입니다. 바로 이럴 때 필요한 것이 RFLAGS 레지스터입니다.

RFLAGS는 CPU의 현재 상태를 나타내는 다양한 플래그들을 저장하는 64비트 레지스터로, 조건 플래그(ZF, CF, SF 등), 제어 플래그(DF, IF), 시스템 플래그(IOPL, NT, TF 등)로 구성됩니다. RFLAGS를 이해하면 조건 분기 최적화, 인터럽트 제어, 디버깅 지원 등 OS의 핵심 기능을 구현할 수 있습니다.

개요

간단히 말해서, RFLAGS는 CPU의 현재 실행 상태와 조건을 나타내는 64비트 레지스터입니다. 각 비트는 특정 조건이나 모드를 나타내며, 일부는 명령 실행 결과로 자동 설정되고, 일부는 소프트웨어가 직접 제어합니다.

이 레지스터가 필요한 이유는 첫째, 조건 분기 명령(JZ, JC, JNE 등)이 RFLAGS의 플래그를 기반으로 동작하기 때문입니다. 둘째, 인터럽트 활성화/비활성화를 IF(Interrupt Flag) 비트로 제어할 수 있습니다.

셋째, 디버거가 TF(Trap Flag)를 설정하여 단일 스텝 실행을 구현할 수 있습니다. 예를 들어, 스핀락을 구현할 때 인터럽트를 일시적으로 비활성화하여 데드락을 방지하는데, 이는 IF 비트를 조작하여 수행합니다.

기존 어셈블리에서는 pushf/popf 명령으로 RFLAGS를 스택에 저장/복원했다면, Rust에서는 더 명시적이고 타입 안전한 인터페이스를 제공할 수 있습니다. 주요 RFLAGS 비트는 다음과 같습니다: - 상태 플래그: CF(bit 0, Carry), PF(bit 2, Parity), AF(bit 4, Auxiliary), ZF(bit 6, Zero), SF(bit 7, Sign), OF(bit 11, Overflow) - 산술/논리 연산 결과 - 제어 플래그: DF(bit 10, Direction) - 문자열 명령 방향 - 시스템 플래그: TF(bit 8, Trap), IF(bit 9, Interrupt), IOPL(bit 12-13, I/O Privilege Level), NT(bit 14, Nested Task), RF(bit 16, Resume), VM(bit 17, Virtual-8086 Mode), AC(bit 18, Alignment Check), VIF(bit 19, Virtual Interrupt), VIP(bit 20, Virtual Interrupt Pending), ID(bit 21, CPUID) OS 개발에서는 특히 IF, IOPL, TF가 중요합니다.

코드 예제

use core::arch::asm;
use bitflags::bitflags;

bitflags! {
    /// RFLAGS 레지스터의 플래그
    pub struct RFlags: u64 {
        /// Carry Flag - 캐리/보로우 발생
        const CARRY = 1 << 0;
        /// Parity Flag - 하위 8비트의 1 개수가 짝수
        const PARITY = 1 << 2;
        /// Auxiliary Carry Flag - BCD 연산용
        const AUXILIARY_CARRY = 1 << 4;
        /// Zero Flag - 결과가 0
        const ZERO = 1 << 6;
        /// Sign Flag - 결과가 음수
        const SIGN = 1 << 7;
        /// Trap Flag - 단일 스텝 모드 (디버깅)
        const TRAP = 1 << 8;
        /// Interrupt Enable Flag - 인터럽트 활성화
        const INTERRUPT_ENABLE = 1 << 9;
        /// Direction Flag - 문자열 명령 방향 (0=증가, 1=감소)
        const DIRECTION = 1 << 10;
        /// Overflow Flag - 부호 있는 오버플로우
        const OVERFLOW = 1 << 11;
        /// I/O Privilege Level (bit 12-13) - I/O 권한 수준
        const IOPL_LOW = 1 << 12;
        const IOPL_HIGH = 1 << 13;
        /// Nested Task Flag - 태스크 중첩
        const NESTED_TASK = 1 << 14;
        /// Resume Flag - 디버그 예외 억제
        const RESUME = 1 << 16;
        /// Virtual-8086 Mode Flag
        const VIRTUAL_8086 = 1 << 17;
        /// Alignment Check Flag - 정렬 검사
        const ALIGNMENT_CHECK = 1 << 18;
        /// Virtual Interrupt Flag
        const VIRTUAL_INTERRUPT = 1 << 19;
        /// Virtual Interrupt Pending
        const VIRTUAL_INTERRUPT_PENDING = 1 << 20;
        /// CPUID 지원 여부
        const ID = 1 << 21;
    }
}

/// RFLAGS 레지스터 읽기
pub fn read_rflags() -> RFlags {
    let value: u64;
    unsafe {
        // pushfq는 RFLAGS를 스택에 푸시, pop으로 레지스터에 저장
        asm!("pushfq; pop {}", out(reg) value, options(nomem));
    }
    RFlags::from_bits_truncate(value)
}

/// RFLAGS 레지스터 쓰기 (주의: 모든 비트를 변경하지 마세요)
pub unsafe fn write_rflags(flags: RFlags) {
    asm!("push {}; popfq", in(reg) flags.bits(), options(nomem));
}

/// 인터럽트 비활성화 (IF=0)
pub fn disable_interrupts() {
    unsafe {
        asm!("cli", options(nomem, nostack));
    }
}

/// 인터럽트 활성화 (IF=1)
pub fn enable_interrupts() {
    unsafe {
        asm!("sti", options(nomem, nostack));
    }
}

/// 인터럽트가 활성화되어 있는지 확인
pub fn interrupts_enabled() -> bool {
    read_rflags().contains(RFlags::INTERRUPT_ENABLE)
}

/// 인터럽트를 비활성화하고 이전 상태를 반환
/// (크리티컬 섹션 진입용)
pub fn disable_interrupts_and_save() -> bool {
    let was_enabled = interrupts_enabled();
    disable_interrupts();
    was_enabled
}

/// 이전 상태로 인터럽트 복원
/// (크리티컬 섹션 탈출용)
pub fn restore_interrupts(was_enabled: bool) {
    if was_enabled {
        enable_interrupts();
    }
}

// 사용 예: 크리티컬 섹션 보호
pub fn critical_section<F, R>(f: F) -> R
where
    F: FnOnce() -> R,
{
    let was_enabled = disable_interrupts_and_save();
    let result = f();
    restore_interrupts(was_enabled);
    result
}

설명

이것이 하는 일: 위 코드는 RFLAGS 레지스터를 읽고, 특히 인터럽트 활성화/비활성화를 안전하게 제어하는 유틸리티를 제공합니다. 크리티컬 섹션 보호와 같은 고수준 패턴도 구현합니다.

첫 번째로, read_rflags() 함수는 pushfq 명령을 사용합니다. 이 명령은 RFLAGS의 64비트 값을 스택에 푸시하고, 바로 pop 명령으로 범용 레지스터에 저장합니다.

RFLAGS를 직접 범용 레지스터로 옮길 수 있는 명령은 없으므로 이런 방식을 사용합니다. options(nomem)은 이 코드가 메모리를 수정하지 않음(스택 사용은 예외)을 명시하여 최적화를 돕습니다.

읽어온 값은 from_bits_truncate로 파싱되어 각 플래그를 개별적으로 확인할 수 있습니다. 그 다음으로, disable_interrupts()enable_interrupts() 함수는 각각 cli(Clear Interrupt)와 sti(Set Interrupt) 명령을 사용합니다.

이들은 IF 비트만 조작하며, 다른 플래그는 건드리지 않습니다. CLI 명령 실행 후에는 외부 인터럽트(타이머, 키보드, 네트워크 등)가 모두 무시되지만, NMI(Non-Maskable Interrupt)와 예외(페이지 폴트, 디바이드 바이 제로 등)는 여전히 발생할 수 있습니다.

따라서 인터럽트를 끈다고 해서 모든 비동기 이벤트를 막을 수는 없습니다. disable_interrupts_and_save()restore_interrupts() 패턴은 매우 중요합니다.

크리티컬 섹션에 진입할 때 인터럽트를 끄고, 나올 때 원래 상태로 복원해야 합니다. 만약 이미 인터럽트가 꺼진 상태에서 크리티컬 섹션에 진입했다면, 나올 때 인터럽트를 켜면 안 됩니다(상위 호출자가 끈 것이므로).

이 패턴은 중첩된 크리티컬 섹션을 올바르게 처리합니다. 마지막으로, critical_section() 함수는 RAII(Resource Acquisition Is Initialization) 패턴의 Rust 버전입니다.

클로저를 받아 인터럽트를 끄고 실행한 후, 결과를 반환하기 전에 인터럽트 상태를 복원합니다. 패닉이 발생해도 스택 언와인딩 과정에서 복원이 보장되지 않으므로, 실제 프로덕션 코드에서는 더 견고한 스코프 가드(예: scopeguard 크레이트)를 사용해야 합니다.

여러분이 이 코드를 사용하면 스핀락 구현 시 인터럽트로 인한 데드락 방지, 공유 데이터 구조 접근 시 원자성 보장, 하드웨어 레지스터 조작 시 경쟁 조건 방지 등을 할 수 있습니다. 특히 critical_section() 같은 고수준 추상화를 사용하면 "인터럽트를 끄는 것을 잊었다"거나 "켜는 것을 잊었다" 같은 실수를 방지할 수 있습니다.

성능 측면에서는 CLI/STI 명령이 매우 빠르지만(1-2 사이클), TLB 플러시나 캐시 일관성 프로토콜을 트리거할 수 있어 자주 호출하면 성능 저하가 발생할 수 있습니다.

실전 팁

💡 인터럽트를 끈 상태로 오래 실행하면 타이머 인터럽트를 놓쳐 시스템 시계가 느려지거나, 하드웨어 버퍼가 오버플로우할 수 있습니다. 크리티컬 섹션은 가능한 한 짧게 유지하세요(일반적으로 수십 마이크로초 이내).

💡 일부 하드웨어 작업(예: APIC 레지스터 접근)은 인터럽트가 꺼진 상태에서 수행해야 합니다. 하지만 페이지 폴트 핸들러나 스케줄러 같은 복잡한 코드는 인터럽트가 켜진 상태에서 실행해야 재진입이 가능합니다.

💡 TF(Trap Flag) 비트를 설정하면 모든 명령 후 #DB(Debug) 예외가 발생합니다. 이는 디버거의 단일 스텝 기능을 구현하는 메커니즘이며, GDB 같은 도구가 내부적으로 사용합니다.

💡 IOPL(I/O Privilege Level) 비트는 유저 모드에서 in/out 명령을 실행할 수 있는 권한을 제어합니다. 일반적으로 0(최고 권한 필요)으로 설정하여 유저 프로세스가 직접 하드웨어에 접근하지 못하게 합니다.

💡 멀티코어 시스템에서 CLI/STI는 현재 CPU 코어의 인터럽트만 제어합니다. 다른 코어는 영향을 받지 않으므로, 크리티컬 섹션 보호에는 스핀락이나 뮤텍스를 함께 사용해야 합니다.


6. MSR 레지스터 - 모델별 시스템 제어

시작하며

여러분이 시스템 콜 진입점을 설정하거나(SYSCALL/SYSRET), CPU별 데이터 구조체에 접근하거나(GS.BASE), 고급 기능(APIC, 성능 카운터 등)을 제어하려고 할 때 어떤 메커니즘을 사용해야 할까요? 이런 작업은 현대 OS 개발의 필수 요소입니다.

고속 시스템 콜을 구현하지 않으면 성능이 10배 이상 저하될 수 있고, per-CPU 데이터 접근이 비효율적이면 멀티코어 확장성이 떨어집니다. 또한 하드웨어 성능 카운터를 활용하지 못하면 병목 지점을 찾기 어렵습니다.

바로 이럴 때 필요한 것이 MSR(Model Specific Register)입니다. MSR은 CPU 모델별로 정의된 특수 레지스터로, 일반 레지스터나 제어 레지스터로는 접근할 수 없는 고급 기능을 제어합니다.

rdmsr/wrmsr 명령으로 접근하며, 각 MSR은 32비트 주소로 식별됩니다. 중요한 MSR을 활용하면 고성능 시스템 콜, 효율적인 TLS, 정밀한 성능 측정 등을 구현할 수 있습니다.

개요

간단히 말해서, MSR은 CPU의 고급 기능과 설정을 제어하는 64비트 레지스터 집합입니다. 각 MSR은 32비트 인덱스(ECX 레지스터에 지정)로 식별되며, rdmsr 명령으로 읽고 wrmsr 명령으로 씁니다.

이들이 필요한 이유는 x86_64의 확장 기능들이 일반 레지스터나 메모리 공간으로는 표현할 수 없는 설정과 상태를 가지기 때문입니다. 예를 들어, SYSCALL 명령은 IA32_LSTAR MSR(0xC0000082)에 저장된 주소로 점프하는데, 이는 인터럽트 디스크립터 테이블(IDT)보다 훨씬 빠른 메커니즘입니다.

FS.BASE와 GS.BASE MSR은 세그먼트 레지스터의 베이스 주소를 64비트로 설정할 수 있게 하여 TLS 구현에 필수적입니다. 기존 x86 아키텍처에서는 이런 고급 기능들이 없었지만, x86_64와 최신 CPU에서는 수백 개의 MSR이 추가되어 성능 모니터링, 전력 관리, 보안 기능 등을 제공합니다.

주요 MSR은 다음과 같습니다: - IA32_EFER (0xC0000080): 확장 기능 활성화 레지스터 (SCE, LME, NXE 비트 등) - IA32_STAR (0xC0000081): SYSCALL/SYSRET용 세그먼트 셀렉터 - IA32_LSTAR (0xC0000082): SYSCALL 진입점 주소 (Long Mode) - IA32_FMASK (0xC0000084): SYSCALL 시 마스킹할 RFLAGS 비트 - IA32_FS_BASE (0xC0000100): FS 세그먼트 베이스 주소 - IA32_GS_BASE (0xC0000101): GS 세그먼트 베이스 주소 - IA32_KERNEL_GS_BASE (0xC0000102): 커널 GS 베이스 (SWAPGS용) - IA32_APIC_BASE (0x1B): APIC 베이스 주소와 활성화

코드 예제

use core::arch::asm;

/// MSR 주소 상수
pub mod msr {
    pub const IA32_EFER: u32 = 0xC000_0080;
    pub const IA32_STAR: u32 = 0xC000_0081;
    pub const IA32_LSTAR: u32 = 0xC000_0082;
    pub const IA32_CSTAR: u32 = 0xC000_0083;
    pub const IA32_FMASK: u32 = 0xC000_0084;
    pub const IA32_FS_BASE: u32 = 0xC000_0100;
    pub const IA32_GS_BASE: u32 = 0xC000_0101;
    pub const IA32_KERNEL_GS_BASE: u32 = 0xC000_0102;
    pub const IA32_APIC_BASE: u32 = 0x0000_001B;
}

/// MSR 레지스터 읽기
///
/// # Safety
/// - `msr` 주소가 유효해야 함
/// - 읽기 권한이 있어야 함 (일반적으로 Ring 0만 가능)
pub unsafe fn read_msr(msr: u32) -> u64 {
    let low: u32;
    let high: u32;

    // rdmsr: ECX=MSR 주소, EDX:EAX=읽은 64비트 값
    asm!(
        "rdmsr",
        in("ecx") msr,
        out("eax") low,
        out("edx") high,
        options(nomem, nostack)
    );

    ((high as u64) << 32) | (low as u64)
}

/// MSR 레지스터 쓰기
///
/// # Safety
/// - `msr` 주소가 유효해야 함
/// - 쓰기 권한이 있어야 함
/// - `value`가 해당 MSR의 유효한 값이어야 함 (잘못된 값은 #GP 발생)
pub unsafe fn write_msr(msr: u32, value: u64) {
    let low = value as u32;
    let high = (value >> 32) as u32;

    // wrmsr: ECX=MSR 주소, EDX:EAX=쓸 64비트 값
    asm!(
        "wrmsr",
        in("ecx") msr,
        in("eax") low,
        in("edx") high,
        options(nomem, nostack)
    );
}

/// FS.BASE 설정 (TLS용)
pub unsafe fn set_fs_base(base: u64) {
    write_msr(msr::IA32_FS_BASE, base);
}

/// GS.BASE 설정 (per-CPU 데이터용)
pub unsafe fn set_gs_base(base: u64) {
    write_msr(msr::IA32_GS_BASE, base);
}

/// 커널 GS.BASE 설정 (SWAPGS와 함께 사용)
pub unsafe fn set_kernel_gs_base(base: u64) {
    write_msr(msr::IA32_KERNEL_GS_BASE, base);
}

/// SYSCALL 진입점 설정
pub unsafe fn set_syscall_entry(handler: u64, user_cs: u16, kernel_cs: u16) {
    // STAR: 상위 32비트에 세그먼트 셀렉터
    // 비트 32-47: 커널 CS/SS
    // 비트 48-63: 유저 CS/SS (SYSRET 시 사용)
    let star = ((kernel_cs as u64) << 32) | ((user_cs as u64) << 48);
    write_msr(msr::IA32_STAR, star);

    // LSTAR: SYSCALL 진입점 주소
    write_msr(msr::IA32_LSTAR, handler);

    // FMASK: SYSCALL 시 RFLAGS에서 클리어할 비트
    // 일반적으로 IF(인터럽트)를 끄고 DF(방향)를 클리어
    write_msr(msr::IA32_FMASK, 0x300);  // IF | DF
}

설명

이것이 하는 일: 위 코드는 MSR을 안전하게 읽고 쓰는 저수준 인터페이스와, 주요 MSR(FS.BASE, GS.BASE, SYSCALL 설정)을 다루는 고수준 함수를 제공합니다. 첫 번째로, read_msr() 함수는 rdmsr 명령을 사용합니다.

이 명령은 ECX 레지스터에 지정된 MSR 인덱스를 읽어, 결과를 EDX(상위 32비트):EAX(하위 32비트) 레지스터 쌍에 반환합니다. Rust 코드는 이 두 값을 결합하여 64비트 정수로 반환합니다.

options(nomem, nostack)는 이 명령이 메모리나 스택을 건드리지 않음을 명시합니다. MSR 접근은 Ring 0에서만 가능하므로, 유저 모드에서 실행하면 General Protection Fault가 발생합니다.

그 다음으로, write_msr() 함수는 반대 과정을 수행합니다. 64비트 값을 상위/하위 32비트로 분리하여 EDX와 EAX에 넣고, wrmsr 명령을 실행합니다.

잘못된 MSR 주소나 유효하지 않은 값을 쓰면 #GP 예외가 발생하므로 매우 조심해야 합니다. 일부 MSR 비트는 읽기 전용이거나 예약되어 있어, 쓰기 전에 현재 값을 읽고 특정 비트만 수정하는 것이 안전합니다.

set_fs_base()set_gs_base() 함수는 세그먼트 베이스 주소를 설정합니다. 이는 TLS(Thread Local Storage)와 per-CPU 데이터 접근에 핵심적입니다.

예를 들어, 스레드 라이브러리는 각 스레드마다 고유한 FS.BASE를 설정하고, 스레드 로컬 변수는 FS:offset 형태로 접근합니다. 커널은 각 CPU마다 고유한 GS.BASE를 설정하여, 현재 CPU 번호, 현재 실행 중인 태스크 등의 정보를 빠르게 접근할 수 있습니다.

마지막으로, set_syscall_entry() 함수는 SYSCALL/SYSRET 메커니즘을 설정합니다. STAR MSR은 코드/스택 세그먼트 셀렉터를 저장하는데, 커널 CS는 SYSCALL 시 사용되고, 유저 CS는 SYSRET 시 사용됩니다.

LSTAR MSR은 SYSCALL 명령이 점프할 핸들러 함수 주소입니다. FMASK MSR은 SYSCALL 진입 시 RFLAGS에서 클리어할 비트를 지정하는데, 일반적으로 IF(인터럽트)를 끄고 DF(방향 플래그)를 클리어합니다.

이 세 MSR을 올바르게 설정하면 INT 0x80보다 10배 이상 빠른 시스템 콜을 구현할 수 있습니다. 여러분이 이 코드를 사용하면 고성능 시스템 콜 인터페이스 구현, 멀티스레드 애플리케이션의 TLS 지원, 멀티코어 커널의 per-CPU 데이터 관리 등을 할 수 있습니다.

특히 set_syscall_entry()는 OS 초기화 과정에서 한 번만 호출하면 되고, 이후 유저 프로그램은 syscall 명령으로 빠르게 커널에 진입할 수 있습니다. 성능 측면에서 SYSCALL은 인터럽트 게이트보다 50-100 사이클 정도 빠르며, 이는 I/O 집약적인 애플리케이션에서 큰 차이를 만듭니다.

실전 팁

💡 MSR 접근은 상대적으로 느린 작업(수십-수백 사이클)이므로, 자주 읽거나 쓰면 성능 저하가 발생합니다. 가능하면 부팅 시 한 번만 설정하거나, 값을 캐시해서 사용하세요.

💡 IA32_EFER MSR의 SCE(System Call Extensions) 비트를 1로 설정해야 SYSCALL/SYSRET 명령이 활성화됩니다. LME(Long Mode Enable) 비트는 64비트 모드 전환에 필수이고, NXE(No-Execute Enable) 비트는 실행 불가능 페이지를 지원합니다.

💡 SWAPGS 명령은 GS.BASE와 KERNEL_GS.BASE MSR을 교환합니다. SYSCALL 진입 시 한 번, SYSRET 시 한 번 호출하여 유저/커널 GS를 전환합니다. 빠뜨리면 유저 공간 데이터가 손상되거나 커널 데이터가 누출될 수 있습니다.

💡 일부 MSR은 CPU 모델에 따라 존재하지 않거나 다른 동작을 할 수 있습니다. CPUID 명령으로 기능 지원 여부를 먼저 확인하는 것이 안전합니다. 예를 들어, RDFSBASE/WRFSBASE 명령(CPUID.07H.EBX[bit 0])이 지원되면 MSR 없이도 FS.BASE를 빠르게 변경할 수 있습니다.

💡 성능 모니터링 카운터(PMC)도 MSR을 통해 접근합니다. IA32_PERF_GLOBAL_CTRL, IA32_PERFEVTSELx, IA32_PMCx 등의 MSR로 캐시 미스, 분기 예측 실패, 명령 실행 수 등을 측정할 수 있습니다.


7. 스택 포인터(RSP, RBP) - 함수 호출과 로컬 변수

시작하며

여러분이 함수 호출 규약을 구현하거나, 스택 추적(backtrace)을 생성하거나, 컨텍스트 스위칭 시 스택을 전환해야 할 때 어떤 레지스터를 사용해야 할까요? 이런 작업은 OS 개발의 기본 중의 기본입니다.

함수 호출 없이는 코드 재사용이 불가능하고, 스택 추적 없이는 크래시 디버깅이 매우 어렵습니다. 또한 프로세스마다 독립적인 스택을 제공하지 않으면 멀티태스킹 자체가 불가능합니다.

바로 이럴 때 필요한 것이 스택 포인터 레지스터들입니다. RSP(Stack Pointer)는 현재 스택의 최상단을 가리키고, RBP(Base Pointer)는 현재 스택 프레임의 베이스를 가리킵니다.

이 두 레지스터를 올바르게 관리하면 함수 호출, 예외 처리, 프로세스 관리가 안정적으로 동작합니다. 특히 커널 개발에서는 유저 스택과 커널 스택을 분리하고, 인터럽트 발생 시 스택을 전환하는 것이 보안과 안정성에 필수적입니다.

개요

간단히 말해서, RSP는 스택의 현재 위치를 가리키는 64비트 레지스터이고, RBP는 현재 함수 프레임의 시작 위치를 가리킵니다. 스택은 높은 주소에서 낮은 주소로 성장하며(x86_64 관례), PUSH는 RSP를 감소시키고 CALL은 반환 주소를 푸시합니다.

이들 레지스터가 필요한 이유는 첫째, 함수 로컬 변수와 매개변수를 저장할 공간이 필요하기 때문입니다. 둘째, 함수 호출 시 반환 주소를 저장하여 나중에 돌아올 수 있어야 합니다.

셋째, 예외 처리 시 스택을 역추적하여 호출 체인을 파악할 수 있습니다. 예를 들어, 패닉이 발생했을 때 "어떤 함수에서 어떤 함수를 호출했는지"를 출력하려면 RBP를 따라가며 스택 프레임을 탐색해야 합니다.

x86_64 System V ABI에 따르면, 함수 진입 시 RSP는 16바이트 정렬되어야 하고(SSE 명령 요구사항), RBP는 스택 프레임 포인터로 사용하거나 범용 레지스터로 사용할 수 있습니다(컴파일러 최적화 옵션에 따라). 스택의 전형적인 레이아웃은 다음과 같습니다(높은 주소 → 낮은 주소): - 함수 인자 (7번째 이상) - 반환 주소 (CALL이 푸시) - 이전 RBP (현재 함수가 푸시) - 로컬 변수 - 임시 데이터 - (현재 RSP가 가리킴)

코드 예제

use core::arch::asm;

/// 현재 스택 포인터(RSP) 읽기
pub fn read_rsp() -> u64 {
    let rsp: u64;
    unsafe {
        // RSP의 현재 값을 읽음
        asm!("mov {}, rsp", out(reg) rsp, options(nomem, nostack));
    }
    rsp
}

/// 현재 베이스 포인터(RBP) 읽기
pub fn read_rbp() -> u64 {
    let rbp: u64;
    unsafe {
        asm!("mov {}, rbp", out(reg) rbp, options(nomem, nostack));
    }
    rbp
}

/// 스택 포인터 설정 (컨텍스트 스위칭용)
///
/// # Safety
/// - `new_rsp`는 유효한 스택 영역을 가리켜야 함
/// - 스택은 16바이트 정렬되어야 함
/// - 호출 후 이전 스택으로 돌아갈 수 없음
pub unsafe fn set_rsp(new_rsp: u64) {
    asm!("mov rsp, {}", in(reg) new_rsp);
}

/// 스택 프레임 정보
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct StackFrame {
    pub rbp: u64,
    pub rip: u64,  // 반환 주소
}

impl StackFrame {
    /// 현재 스택 프레임 읽기
    pub fn current() -> Self {
        let rbp = read_rbp();
        let rip: u64;
        unsafe {
            // RBP+8 위치에 반환 주소가 저장됨
            asm!(
                "mov {}, [{}]",
                out(reg) rip,
                in(reg) rbp + 8,
                options(readonly)
            );
        }
        Self { rbp, rip }
    }

    /// 이전 스택 프레임으로 이동
    pub fn previous(&self) -> Option<Self> {
        if self.rbp == 0 {
            return None;
        }

        unsafe {
            let prev_rbp: u64;
            let prev_rip: u64;

            // [RBP]에는 이전 RBP가 저장됨
            asm!(
                "mov {}, [{}]",
                out(reg) prev_rbp,
                in(reg) self.rbp,
                options(readonly)
            );

            // [RBP+8]에는 반환 주소가 저장됨
            asm!(
                "mov {}, [{}]",
                out(reg) prev_rip,
                in(reg) self.rbp + 8,
                options(readonly)
            );

            if prev_rbp == 0 || prev_rip == 0 {
                None
            } else {
                Some(Self { rbp: prev_rbp, rip: prev_rip })
            }
        }
    }
}

/// 스택 추적(backtrace) 생성
pub fn backtrace(max_frames: usize) -> alloc::vec::Vec<u64> {
    let mut frames = alloc::vec::Vec::new();
    let mut current_frame = StackFrame::current();

    for _ in 0..max_frames {
        frames.push(current_frame.rip);

        match current_frame.previous() {
            Some(frame) => current_frame = frame,
            None => break,
        }
    }

    frames
}

/// 새로운 스택 생성 및 초기화 (태스크 생성용)
///
/// # Safety
/// - `stack_bottom`은 유효하고 충분한 크기의 메모리 영역이어야 함
/// - `entry_point`는 유효한 함수 포인터여야 함
pub unsafe fn setup_initial_stack(
    stack_bottom: *mut u8,
    stack_size: usize,
    entry_point: extern "C" fn() -> !,
) -> u64 {
    // 스택 최상단 주소 계산 (스택은 아래로 성장)
    let stack_top = (stack_bottom as usize + stack_size) as *mut u64;

    // 16바이트 정렬
    let mut rsp = (stack_top as u64) & !0xF;

    // 더미 반환 주소 푸시 (entry_point는 반환하지 않음)
    rsp -= 8;
    *(rsp as *mut u64) = 0;

    // 진입점 주소 푸시 (RET 명령이 여기로 점프)
    rsp -= 8;
    *(rsp as *mut u64) = entry_point as u64;

    rsp
}

설명

이것이 하는 일: 위 코드는 스택 포인터를 읽고 조작하며, 스택 프레임을 탐색하여 백트레이스를 생성하는 기능을 제공합니다. 또한 새로운 태스크를 위한 스택 초기화도 구현합니다.

첫 번째로, read_rsp()read_rbp() 함수는 단순히 레지스터 값을 읽습니다. 이는 디버깅이나 스택 사용량 모니터링에 유용합니다.

예를 들어, "스택이 오버플로우에 가까운가?"를 확인하려면 현재 RSP와 스택 베이스 주소의 차이를 계산하면 됩니다. options(nomem, nostack)는 이 읽기 작업이 메모리나 스택을 수정하지 않음을 명시합니다.

그 다음으로, StackFrame 구조체는 스택 프레임을 추상화합니다. current() 메서드는 현재 RBP와 반환 주소(RBP+8 위치)를 읽습니다.

이는 x86_64 함수 프롤로그가 "push rbp; mov rbp, rsp" 형태로 시작하고, CALL 명령이 반환 주소를 푸시한 후 RBP를 저장하기 때문입니다. previous() 메서드는 이전 프레임으로 이동하는데, RBP가 가리키는 위치에 이전 RBP가 저장되어 있으므로 이를 따라가면 호출 체인을 역추적할 수 있습니다.

backtrace() 함수는 이를 활용하여 전체 호출 스택을 배열로 반환합니다. 최대 프레임 수를 제한하여 무한 루프를 방지합니다(손상된 스택의 경우).

각 반환 주소(RIP)는 심볼 테이블과 대조하여 "어느 함수인지"를 파악할 수 있습니다. 이는 커널 패닉이나 어서션 실패 시 디버그 정보를 출력하는 데 필수적입니다.

마지막으로, setup_initial_stack() 함수는 새로운 태스크를 위한 스택을 준비합니다. 스택은 높은 주소에서 낮은 주소로 성장하므로, stack_bottom + stack_size가 초기 RSP입니다.

16바이트 정렬을 보장한 후, 더미 반환 주소(0)와 진입점 주소를 푸시합니다. 나중에 컨텍스트 스위칭 코드가 이 스택으로 전환하고 RET 명령을 실행하면, 진입점 함수로 점프하여 태스크가 시작됩니다.

이 패턴은 프로세스 생성이나 코루틴 구현에 사용됩니다. 여러분이 이 코드를 사용하면 크래시 리포트에 상세한 콜스택 출력, 프로세스 스케줄러의 태스크 생성, 스택 사용량 모니터링 등을 구현할 수 있습니다.

특히 backtrace() 함수는 panic 핸들러에서 호출하여 "어디서 터졌는지"를 즉시 파악할 수 있게 해줍니다. 성능 측면에서는 스택 접근이 L1 캐시에 히트하면 매우 빠르지만, 깊은 재귀나 큰 로컬 변수는 캐시 미스를 유발할 수 있습니다.

실전 팁

💡 스택 오버플로우는 탐지하기 어려운 버그입니다. 각 스택의 끝에 가드 페이지(언매핑된 페이지)를 두면, 오버플로우 시 페이지 폴트가 발생하여 조기에 발견할 수 있습니다.

💡 x86_64 ABI는 RSP를 16바이트 정렬하도록 요구합니다. CALL 명령이 8바이트를 푸시하므로, 함수 진입 시 RSP는 8의 배수이지만 16의 배수는 아닙니다. 따라서 함수 프롤로그는 보통 "sub rsp, 8"을 추가하여 정렬을 맞춥니다.

💡 RBP를 스택 프레임 포인터로 사용하지 않는 최적화(-fomit-frame-pointer)를 켜면 하나의 레지스터를 절약할 수 있지만, 디버깅과 스택 추적이 어려워집니다. 프로덕션 커널에서는 일부 크리티컬 경로에만 적용하세요.

💡 각 CPU 코어는 고유한 커널 스택을 가져야 합니다. 인터럽트 핸들러가 유저 스택을 사용하면 보안 취약점이 생기고, 커널 스택을 공유하면 경쟁 조건이 발생합니다. TSS(Task State Segment)에 커널 스택 주소를 설정하여 자동 전환이 가능합니다.

💡 스택 추적 시 RBP가 유효한 범위 내에 있는지 검증하세요. 손상된 RBP를 따라가면 임의의 메모리를 읽어 또 다른 예외를 발생시킬 수 있습니다. 스택 범위를 벗어나면 추적을 중단하세요.


8. 명령 포인터(RIP) - 코드 실행 위치

시작하며

여러분이 함수 점프를 구현하거나, Position Independent Code(PIE)를 작성하거나, 반환 주소를 조작하여 특정 함수를 호출하려고 할 때 어떤 레지스터를 다뤄야 할까요? 이런 작업은 동적 링킹, 보안 메커니즘(ROP 방지), 디버깅 도구 등에서 필수적입니다.

RIP(Instruction Pointer)는 CPU가 다음에 실행할 명령의 주소를 저장하는 레지스터로, 일반적으로 직접 읽거나 쓸 수 없지만 간접적인 방법으로 조작할 수 있습니다. 특히 x86_64에서는 RIP-relative 주소 지정 모드가 추가되어, 절대 주소 대신 RIP를 기준으로 한 상대 주소를 사용할 수 있습니다.

이는 PIE/PIC 구현의 핵심입니다. RIP를 이해하면 함수 호출 메커니즘, 점프 테이블, 트램폴린 코드 등을 구현할 수 있습니다.

개요

간단히 말해서, RIP는 CPU가 현재 실행 중인(또는 다음에 실행할) 명령의 가상 주소를 저장하는 64비트 레지스터입니다. 일반적인 mov 명령으로는 직접 접근할 수 없지만, JMP, CALL, RET, 인터럽트 등의 제어 흐름 명령으로 간접적으로 조작할 수 있습니다.

이 레지스터가 중요한 이유는 첫째, 모든 제어 흐름(분기, 호출, 예외)이 RIP를 변경하는 것이기 때문입니다. 둘째, x86_64의 RIP-relative 주소 지정 모드는 "현재 명령 위치에서 상대적으로 X바이트 떨어진 곳"을 참조할 수 있어, 코드를 임의의 주소에 로드해도 동작하는 PIC를 가능하게 합니다.

셋째, 반환 주소(스택에 저장된 RIP)를 조작하면 함수 실행 흐름을 제어할 수 있는데, 이는 보안 공격(ROP, Return Oriented Programming)의 기반이기도 합니다. RIP를 읽는 방법은 제한적입니다.

x86_64에서는 lea rax, [rip] 같은 명령이 직접 지원되지 않지만, call 다음의 주소를 읽거나, 예외 프레임에서 저장된 RIP를 읽을 수 있습니다. RIP-relative 주소 지정의 예: asm mov rax, [rip + offset] ; RIP + offset 주소의 데이터를 RAX로 로드 이는 컴파일 타임에 offset을 계산하여, 런타임에 코드가 어디에 로드되든 올바른 데이터를 참조할 수 있게 합니다.

코드 예제

use core::arch::asm;

/// 현재 명령 포인터(RIP) 읽기
///
/// CALL 명령의 부작용을 이용: CALL이 반환 주소(다음 RIP)를 스택에 푸시
pub fn read_rip() -> u64 {
    let rip: u64;
    unsafe {
        asm!(
            "call 1f",          // 다음 명령(1f)으로 CALL
            "1:",               // 레이블 1
            "pop {}",           // 스택에서 반환 주소(=RIP) 팝
            out(reg) rip,
            options(nomem)
        );
    }
    rip
}

/// RIP-relative 데이터 접근 예제
///
/// 전역 변수를 RIP 기준으로 접근 (PIE 지원)
#[inline(always)]
pub fn read_rip_relative_u64(offset: i32) -> u64 {
    let value: u64;
    unsafe {
        // lea로 주소를 계산한 후 간접 로드
        asm!(
            "lea {tmp}, [rip + {offset}]",
            "mov {value}, [{tmp}]",
            offset = in(reg) offset,
            tmp = out(reg) _,
            value = out(reg) value,
            options(readonly)
        );
    }
    value
}

/// 함수 반환 주소 읽기 (스택에 저장된 RIP)
///
/// # Safety
/// - 현재 함수가 스택 프레임을 가지고 있어야 함
pub unsafe fn read_return_address() -> u64 {
    let rbp = read_rbp();
    // RBP+8 위치에 반환 주소 저장됨
    *((rbp + 8) as *const u64)
}

/// 함수 반환 주소 조작 (주의: 매우 위험!)
///
/// # Safety
/// - `new_address`는 유효한 코드 주소여야 함
/// - 스택 프레임이 일치해야 함
/// - 이 함수 사용은 일반적으로 권장되지 않음
pub unsafe fn hijack_return_address(new_address: u64) {
    let rbp = read_rbp();
    *((rbp + 8) as *mut u64) = new_address;
}

/// Position Independent Code 예제
///
/// 전역 변수에 RIP-relative로 접근
static mut GLOBAL_COUNTER: u64 = 0;

pub fn increment_counter() {
    unsafe {
        // Rust 컴파일러가 자동으로 RIP-relative 접근 생성
        GLOBAL_COUNTER += 1;
    }
}

/// 명시적 RIP-relative 접근 (어셈블리 수준)
pub fn increment_counter_explicit() {
    unsafe {
        asm!(
            // GLOBAL_COUNTER의 주소를 RIP 기준으로 계산
            "lea rax, [rip + {counter}]",
            "add qword ptr [rax], 1",
            counter = sym GLOBAL_COUNTER,
            out("rax") _,
            options(nostack)
        );
    }
}

/// 예외 프레임에서 RIP 읽기 (인터럽트 핸들러용)
#[repr(C)]
pub struct InterruptFrame {
    pub rip: u64,
    pub cs: u64,
    pub rflags: u64,
    pub rsp: u64,
    pub ss: u64,
}

impl InterruptFrame {
    /// 예외 발생 시점의 명령 주소
    pub fn instruction_pointer(&self) -> u64 {
        self.rip
    }

    /// 예외 발생 후 다음 명령으로 건너뛰기 (명령 길이 필요)
    pub fn skip_instruction(&mut self, instruction_length: u8) {
        self.rip += instruction_length as u64;
    }
}

설명

이것이 하는 일: 위 코드는 직접 접근이 제한된 RIP를 읽는 트릭과, RIP-relative 주소 지정을 활용하는 방법을 보여줍니다. 첫 번째로, read_rip() 함수는 CALL 명령의 부작용을 이용합니다.

CALL 명령은 반환 주소(CALL 다음 명령의 RIP)를 스택에 푸시하므로, 즉시 다음 명령으로 CALL한 후 스택을 팝하면 그 시점의 RIP를 얻을 수 있습니다. "1f"는 forward 레이블 1을 의미하고, "1:"은 레이블 정의입니다.

이 기법은 약간의 스택 오버헤드가 있지만, RIP를 읽는 가장 직접적인 방법입니다. 그 다음으로, read_rip_relative_u64() 함수는 RIP 기준 상대 주소로 데이터를 읽습니다.

LEA 명령으로 "RIP + offset" 주소를 계산하고, 그 주소에서 데이터를 로드합니다. 이는 전역 변수나 상수 테이블에 접근할 때 유용하며, 코드의 로드 주소와 무관하게 동작합니다.

Rust 컴파일러는 PIE 모드에서 전역 변수 접근을 자동으로 RIP-relative로 변환하지만, 명시적 어셈블리가 필요한 경우도 있습니다. read_return_address()hijack_return_address() 함수는 스택에 저장된 반환 주소를 조작합니다.

반환 주소는 RBP+8 위치에 있으며(System V ABI), 이를 변경하면 함수가 반환할 때 다른 주소로 점프합니다. 이는 디버깅, 프로파일링, 또는 보안 샌드박스 구현에 사용될 수 있지만, 매우 위험하므로 신중해야 합니다.

잘못 사용하면 스택 프레임이 맞지 않아 크래시가 발생합니다. increment_counter_explicit() 함수는 명시적 RIP-relative 접근을 보여줍니다.

sym GLOBAL_COUNTER는 Rust의 심볼을 어셈블리 레이블로 변환하고, LEA 명령이 이 심볼의 주소를 RIP 기준으로 계산합니다. 이렇게 생성된 코드는 ASLR(Address Space Layout Randomization)이 적용된 환경에서도 정상 동작합니다.

마지막으로, InterruptFrame 구조체는 인터럽트나 예외 발생 시 CPU가 스택에 푸시하는 정보를 나타냅니다. RIP 필드는 예외가 발생한 정확한 명령 주소이므로, 이를 읽으면 "어디서 터졌는지" 알 수 있습니다.

skip_instruction() 메서드는 예외를 일으킨 명령을 건너뛰는데, 예를 들어 "디바이드 바이 제로는 0을 반환하고 계속 실행"하는 정책을 구현할 때 사용합니다. 여러분이 이 코드를 사용하면 동적 로더(ELF 파서)에서 재배치 처리, 예외 핸들러에서 크래시 위치 파악, 보안 샌드박스에서 ROP 탐지 등을 구현할 수 있습니다.

특히 RIP-relative 접근은 현대 x86_64 코드의 표준이며, 이를 제대로 활용하면 코드 크기 감소와 성능 향상을 동시에 얻을 수 있습니다. 보안 측면에서는 반환 주소 검증(shadow stack)이나 제어 흐름 무결성(CFI) 같은 고급 보호 기법을 구현할 수 있습니다.

실전 팁

💡 RIP-relative 주소 지정은 ±2GB 범위만 지원합니다(32비트 부호 있는 오프셋). 큰 바이너리에서는 전역 변수가 코드와 2GB 이상 떨어지면 링커 오류가 발생할 수 있습니다. 이 경우 -mcmodel=large를 사용하세요.

💡 함수 포인터 호출(call rax)과 간접 점프(jmp rax)는 RIP를 레지스터 값으로 설정합니다. 이는 가상 함수 테이블(vtable)이나 함수 포인터 배열 구현에 사용되지만, 보안 취약점의 원인이기도 합니다. Intel CET나 ARM PAC 같은 하드웨어 보호 기능을 활용하세요.

💡 예외 핸들러에서 RIP를 수정하여 예외를 일으킨 명령을 재실행하거나 건너뛸 수 있습니다. 하지만 명령 길이는 고정되어 있지 않으므로(1-15바이트), 정확히 계산하려면 디스어셈블러가 필요합니다.

💡 ROP(Return Oriented Programming) 공격을 방어하려면 스택 카나리, Shadow Stack(Intel CET), 반환 주소 암호화 등을 사용하세요. 스택에 저장된 반환 주소가 변조되지 않았는지 검증하는 것이 핵심입니다.

💡 PIE/PIC


#Rust#x86_64#OS개발#레지스터#시스템프로그래밍

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.