이미지 로딩 중...
AI Generated
2025. 11. 14. · 5 Views
Rust로 만드는 나만의 OS 컨텍스트 저장과 복원 완벽 가이드
운영체제 개발에서 가장 핵심적인 컨텍스트 스위칭의 원리를 Rust로 구현합니다. CPU 레지스터 저장부터 스택 전환, 태스크 복원까지 실무 수준의 상세한 코드와 함께 설명합니다.
목차
- 컨텍스트란 무엇인가 - CPU 상태의 스냅샷
- 컨텍스트 저장 구현 - 현재 상태를 메모리에 기록하기
- 컨텍스트 복원 구현 - 저장된 상태로 돌아가기
- 컨텍스트 스위칭 구현 - 태스크 간 완전한 전환
- 태스크 구조체 설계 - 컨텍스트와 메타데이터 관리
- 간단한 라운드로빈 스케줄러 - 공평한 CPU 시간 분배
- 타이머 인터럽트 통합 - 자동 태스크 전환
- 유저 모드 컨텍스트 확장 - 세그먼트와 권한 관리
- FPU와 SIMD 컨텍스트 관리 - 확장 레지스터 저장
- 디버깅과 검증 - 컨텍스트 무결성 확인
1. 컨텍스트란 무엇인가 - CPU 상태의 스냅샷
시작하며
여러분이 여러 프로그램을 동시에 실행할 때, OS는 어떻게 각 프로그램의 실행 상태를 기억하고 있을까요? 웹 브라우저를 사용하다가 IDE로 전환하고, 다시 브라우저로 돌아왔을 때 이전 작업이 그대로 유지되는 이유는 무엇일까요?
이것이 바로 컨텍스트 저장과 복원의 핵심입니다. 운영체제는 각 태스크의 실행 상태를 정확히 저장하고, 필요할 때 완벽하게 복원합니다.
이 과정이 제대로 구현되지 않으면 프로그램이 예상치 못한 동작을 하거나 크래시가 발생합니다. 컨텍스트는 CPU의 모든 레지스터 값, 스택 포인터, 프로그램 카운터 등을 포함합니다.
이것이 바로 태스크의 "생명"이라고 할 수 있습니다. Rust로 OS를 만들 때, 이 컨텍스트를 어떻게 안전하게 다루는지 알아봅시다.
개요
간단히 말해서, 컨텍스트는 특정 시점의 CPU 실행 상태를 담고 있는 데이터 구조입니다. 모든 범용 레지스터, 스택 포인터, 명령어 포인터 등이 포함됩니다.
실무 OS 개발에서 컨텍스트 관리는 멀티태스킹의 핵심입니다. 타이머 인터럽트가 발생하면 현재 태스크의 컨텍스트를 저장하고, 다음 태스크의 컨텍스트를 복원해야 합니다.
이 과정이 마이크로초 단위로 빠르게 일어나기 때문에 사용자는 여러 프로그램이 "동시에" 실행되는 것처럼 느낍니다. 전통적인 C 기반 OS에서는 어셈블리로 직접 레지스터를 다뤘습니다.
Rust에서는 repr(C)와 인라인 어셈블리를 활용하여 타입 안전성을 유지하면서도 하드웨어를 직접 제어할 수 있습니다. 컨텍스트 구조체는 일반적으로 범용 레지스터(rax, rbx, rcx 등), 스택 레지스터(rsp, rbp), 세그먼트 레지스터, 플래그 레지스터를 포함합니다.
x86_64 아키텍처에서는 16개의 범용 레지스터와 여러 특수 레지스터를 관리해야 합니다. 이 모든 값을 정확히 저장하고 복원하는 것이 안정적인 멀티태스킹의 기본입니다.
코드 예제
// x86_64 아키텍처의 컨텍스트 구조체
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct Context {
// 범용 레지스터 (callee-saved)
pub r15: u64,
pub r14: u64,
pub r13: u64,
pub r12: u64,
pub rbp: u64,
pub rbx: u64,
// 리턴 주소 (rip)
pub rip: u64,
}
impl Context {
pub const fn new() -> Self {
Self {
r15: 0, r14: 0, r13: 0, r12: 0,
rbp: 0, rbx: 0, rip: 0,
}
}
}
설명
이것이 하는 일: Context 구조체는 태스크가 실행을 멈췄을 때의 CPU 상태를 완전히 담아내는 컨테이너입니다. 이를 통해 나중에 정확히 같은 지점에서 실행을 재개할 수 있습니다.
첫 번째로, #[repr(C)] 속성은 Rust 컴파일러에게 C 언어와 동일한 메모리 레이아웃을 사용하도록 지시합니다. 이는 어셈블리 코드에서 각 필드의 위치를 예측 가능하게 만들어줍니다.
예를 들어 r15는 구조체의 시작부터 0바이트, r14는 8바이트, r13은 16바이트 위치에 있습니다. 이 정확한 레이아웃이 없다면 어셈블리에서 접근할 때 잘못된 메모리를 읽게 됩니다.
그 다음으로, callee-saved 레지스터만 포함시킨 이유는 System V ABI 호출 규약을 따르기 때문입니다. 함수 호출 시 호출된 함수(callee)가 보존해야 하는 레지스터들입니다.
caller-saved 레지스터(rax, rcx, rdx 등)는 함수 호출 전에 이미 스택에 저장되므로 컨텍스트 구조체에 포함시킬 필요가 없습니다. 이렇게 하면 컨텍스트 크기를 최소화하면서도 완전한 복원이 가능합니다.
마지막으로, rip(명령어 포인터)를 저장하는 것이 핵심입니다. 이 값은 태스크가 다음에 실행할 명령어의 주소를 가리킵니다.
컨텍스트 전환 시 현재 rip를 저장하고, 복원 시 이 주소로 점프하면 태스크는 정확히 멈췄던 곳에서 실행을 재개합니다. 여러분이 이 구조체를 사용하면 안전하고 예측 가능한 태스크 전환을 구현할 수 있습니다.
각 태스크마다 하나의 Context 인스턴스를 가지고 있으면, 스케줄러는 언제든지 태스크를 전환할 수 있습니다. 이는 선점형 멀티태스킹의 기초이며, 현대적인 운영체제의 핵심 메커니즘입니다.
실전 팁
💡 Context 구조체는 항상 8바이트 정렬을 유지해야 합니다. x86_64에서는 스택도 16바이트 정렬이 필요하므로, 필드 개수가 홀수면 패딩을 추가하세요.
💡 디버깅 시 Debug trait을 구현하면 각 레지스터 값을 쉽게 확인할 수 있습니다. println!으로 컨텍스트를 출력하여 태스크 전환 전후의 상태를 비교하세요.
💡 보안을 위해 컨텍스트 초기화 시 모든 레지스터를 0으로 설정하세요. 이전 태스크의 민감한 정보가 새 태스크로 누출되는 것을 방지합니다.
💡 FPU/SIMD 레지스터(xmm0-xmm15)를 사용하는 경우 별도의 확장 컨텍스트 구조체가 필요합니다. 이들은 크기가 크므로 lazy 저장을 고려하세요.
💡 Clone, Copy trait을 구현하여 컨텍스트를 쉽게 복사할 수 있게 하세요. 하지만 실제 사용 시에는 불필요한 복사를 피하고 참조를 활용하는 것이 효율적입니다.
2. 컨텍스트 저장 구현 - 현재 상태를 메모리에 기록하기
시작하며
여러분이 게임을 하다가 갑자기 저장하지 않고 종료된 경험이 있나요? 모든 진행 상황이 사라지는 그 순간의 좌절감을 기억하실 겁니다.
운영체제에서도 마찬가지입니다. 태스크가 실행 중일 때 타이머 인터럽트가 발생하면, 현재 상태를 저장하지 않으면 모든 것을 잃게 됩니다.
컨텍스트 저장은 이 문제를 해결합니다. CPU의 모든 중요한 레지스터 값을 메모리의 안전한 장소에 기록하는 것입니다.
이 작업은 극도로 빠르고 정확해야 합니다. 단 하나의 레지스터라도 잘못 저장되면 태스크는 복원 시 잘못된 상태로 돌아가게 됩니다.
Rust의 인라인 어셈블리를 활용하면 타입 안전성을 유지하면서도 하드웨어를 직접 제어할 수 있습니다. 어떻게 안전하고 효율적으로 컨텍스트를 저장하는지 알아봅시다.
개요
간단히 말해서, 컨텍스트 저장은 현재 CPU의 레지스터 값들을 메모리의 Context 구조체에 복사하는 과정입니다. 이는 일반적으로 어셈블리 명령어로 구현됩니다.
실무에서는 이 작업이 인터럽트 핸들러나 시스템 콜 내부에서 발생합니다. 예를 들어, 타이머 인터럽트가 발생하면 커널은 즉시 현재 태스크의 컨텍스트를 저장하고, 스케줄러를 호출하여 다음 태스크를 선택합니다.
이 과정이 수백 나노초 내에 완료되어야 시스템 성능에 영향을 주지 않습니다. 전통적인 방법으로는 각 레지스터를 스택에 push하는 방식이었습니다.
하지만 현대적인 접근법은 구조체 포인터를 사용하여 직접 메모리에 저장합니다. 이렇게 하면 저장 위치를 명확히 제어할 수 있고, 나중에 접근하기도 쉽습니다.
핵심은 원자성입니다. 저장 과정 중에는 인터럽트가 비활성화되어야 합니다.
만약 저장 도중 다른 인터럽트가 발생하면 레지스터 값이 오염되어 복원이 불가능해집니다. Rust의 타입 시스템과 어셈블리를 조합하면 이런 위험을 최소화할 수 있습니다.
코드 예제
use core::arch::asm;
// 현재 컨텍스트를 저장하는 함수
#[inline(never)]
pub unsafe fn save_context(ctx: *mut Context) {
asm!(
// callee-saved 레지스터를 메모리에 저장
"mov [rdi + 0x00], r15",
"mov [rdi + 0x08], r14",
"mov [rdi + 0x10], r13",
"mov [rdi + 0x18], r12",
"mov [rdi + 0x20], rbp",
"mov [rdi + 0x28], rbx",
// 리턴 주소 저장 (스택 최상단)
"mov rax, [rsp]",
"mov [rdi + 0x30], rax",
in("rdi") ctx,
out("rax") _,
options(nostack)
);
}
설명
이것이 하는 일: save_context 함수는 현재 실행 중인 태스크의 CPU 상태를 스냅샷으로 찍어 메모리에 저장합니다. 이를 통해 나중에 정확히 같은 상태에서 실행을 재개할 수 있습니다.
첫 번째로, #[inline(never)] 속성은 컴파일러가 이 함수를 인라인하지 못하도록 막습니다. 왜냐하면 이 함수는 리턴 주소(rip)를 저장해야 하는데, 인라인되면 리턴 주소 자체가 사라지기 때문입니다.
함수가 호출되면 리턴 주소가 스택 최상단에 자동으로 push되며, 우리는 이 값을 읽어서 rip 필드에 저장합니다. 이것이 태스크가 다음에 실행할 명령어의 주소입니다.
그 다음으로, rdi 레지스터는 x86_64 calling convention에서 첫 번째 인자를 전달하는 레지스터입니다. in("rdi") ctx는 Rust가 ctx 포인터를 rdi에 자동으로 넣어준다는 의미입니다.
그런 다음 mov [rdi + offset], register 명령어로 각 레지스터를 구조체의 해당 위치에 저장합니다. 0x00, 0x08, 0x10은 각각 0바이트, 8바이트, 16바이트 오프셋을 의미합니다.
세 번째로, mov rax, [rsp]로 스택 최상단의 리턴 주소를 읽습니다. 이 값은 save_context를 호출한 직후의 명령어 주소입니다.
이를 rip 필드에 저장하면, 나중에 restore_context를 호출할 때 정확히 이 위치로 돌아갈 수 있습니다. out("rax") _는 rax를 임시로 사용했음을 컴파일러에게 알려줍니다.
마지막으로, options(nostack)는 이 어셈블리 블록이 스택을 사용하지 않음을 명시합니다. 이는 최적화 힌트이며, 컴파일러가 불필요한 스택 조정을 하지 않도록 합니다.
여러분이 이 함수를 사용하면 언제든지 현재 실행 상태를 저장할 수 있습니다. 스케줄러는 각 태스크의 Context 구조체를 관리하며, 타이머 인터럽트 발생 시 이 함수를 호출합니다.
저장이 완료되면 다른 태스크로 안전하게 전환할 수 있습니다. 이것이 선점형 멀티태스킹의 핵심 메커니즘입니다.
실전 팁
💡 save_context는 항상 인터럽트가 비활성화된 상태에서 호출해야 합니다. cli 명령어로 인터럽트를 끄고, 저장 완료 후 다시 켜세요.
💡 성능이 중요한 경우 레지스터를 선택적으로 저장할 수 있습니다. 하지만 처음에는 모든 callee-saved 레지스터를 저장하는 것이 안전합니다.
💡 디버깅 시 저장 전후의 레지스터 값을 로그로 남기세요. gdb에서 info registers로 확인한 값과 비교하여 정확성을 검증할 수 있습니다.
💡 멀티코어 시스템에서는 각 코어가 별도의 컨텍스트를 저장해야 합니다. 코어별 스택을 사용하여 충돌을 방지하세요.
💡 벤치마크를 통해 저장 시간을 측정하세요. x86_64에서는 일반적으로 50-100 사이클 정도 소요됩니다. 이보다 느리면 최적화가 필요합니다.
3. 컨텍스트 복원 구현 - 저장된 상태로 돌아가기
시작하며
여러분이 저장한 게임을 로드할 때, 정확히 저장했던 그 순간으로 돌아가는 것을 경험하셨을 겁니다. 인벤토리, 위치, 퀘스트 진행 상황 모두가 완벽하게 복원됩니다.
운영체제의 컨텍스트 복원도 이와 동일한 원리입니다. 하지만 게임 로드와 달리, 컨텍스트 복원은 훨씬 더 저수준에서 일어납니다.
CPU 레지스터를 직접 조작하고, 스택 포인터를 변경하며, 명령어 포인터를 점프시킵니다. 이 과정에서 단 하나의 실수도 시스템 전체를 다운시킬 수 있습니다.
Rust로 안전하게 컨텍스트를 복원하는 방법을 알아봅시다. 어떻게 저장된 레지스터 값들을 CPU에 다시 로드하고, 정확한 실행 지점으로 돌아가는지 살펴보겠습니다.
개요
간단히 말해서, 컨텍스트 복원은 메모리에 저장된 레지스터 값들을 다시 CPU에 로드하여 태스크의 실행을 재개하는 과정입니다. 이는 저장의 정확한 역과정입니다.
실무에서 복원은 스케줄러가 다음 태스크를 선택한 후 발생합니다. 이전 태스크의 컨텍스트는 이미 저장되었고, 이제 새로운 태스크의 컨텍스트를 복원할 차례입니다.
복원이 완료되면 CPU는 마치 그 태스크가 계속 실행되고 있었던 것처럼 동작합니다. 사용자는 전환이 일어났다는 것조차 인지하지 못합니다.
전통적으로는 스택에서 레지스터를 pop하는 방식이었습니다. 하지만 구조체 기반 접근법에서는 메모리에서 직접 레지스터로 값을 이동시킵니다.
마지막 단계에서 rip(명령어 포인터)를 복원하면, CPU는 자동으로 해당 주소로 점프하여 실행을 계속합니다. 중요한 점은 복원 함수가 절대 리턴하지 않는다는 것입니다.
일반적인 함수는 호출자로 돌아가지만, restore_context는 저장된 rip 주소로 점프합니다. 이것이 바로 diverging function이며, Rust에서는 -> ! 타입으로 표현됩니다.
이러한 특성을 이해하는 것이 안전한 구현의 핵심입니다.
코드 예제
// 저장된 컨텍스트를 복원하는 함수
#[inline(never)]
pub unsafe fn restore_context(ctx: *const Context) -> ! {
asm!(
// 메모리에서 레지스터로 복원
"mov r15, [rdi + 0x00]",
"mov r14, [rdi + 0x08]",
"mov r13, [rdi + 0x10]",
"mov r12, [rdi + 0x18]",
"mov rbp, [rdi + 0x20]",
"mov rbx, [rdi + 0x28]",
// rip를 스택에 push (ret으로 점프하기 위함)
"mov rax, [rdi + 0x30]",
"push rax",
// 복원된 주소로 점프
"ret",
in("rdi") ctx,
options(noreturn)
);
}
설명
이것이 하는 일: restore_context 함수는 메모리에 저장된 컨텍스트를 CPU에 복원하여 태스크가 정확히 멈췄던 지점에서 실행을 계속하도록 만듭니다. 이 함수는 절대 리턴하지 않습니다.
첫 번째로, 각 mov 명령어가 구조체의 특정 오프셋에서 값을 읽어 해당 레지스터에 저장합니다. mov r15, [rdi + 0x00]은 "rdi가 가리키는 주소의 0바이트 위치에서 8바이트를 읽어 r15에 넣어라"는 의미입니다.
이렇게 모든 callee-saved 레지스터를 순차적으로 복원합니다. 순서는 중요하지 않지만, rdi를 나중에 덮어쓰지 않도록 주의해야 합니다.
그 다음으로, rip(명령어 포인터)를 복원하는 방법이 흥미롭습니다. x86_64에서는 rip를 직접 mov할 수 없습니다.
대신 ret 명령어를 활용합니다. ret은 스택 최상단에서 주소를 pop하여 그곳으로 점프하는 명령어입니다.
따라서 우선 저장된 rip 값을 rax에 로드하고(mov rax, [rdi + 0x30]), 이를 스택에 push한 다음(push rax), ret을 실행하면 해당 주소로 점프합니다. 세 번째로, options(noreturn)은 이 어셈블리 블록이 절대 리턴하지 않음을 컴파일러에게 알립니다.
함수 시그니처의 -> !와 일치합니다. 이는 diverging function이라고 부르며, 컴파일러는 이 함수 호출 이후의 코드를 생성하지 않습니다.
이를 통해 불필요한 명령어를 제거하여 성능을 향상시킵니다. 마지막으로, 복원이 완료되면 CPU는 마치 save_context를 호출한 직후처럼 동작합니다.
즉, 태스크는 자신이 멈춘 적이 없다고 생각합니다. 모든 변수 값, 스택 상태, 실행 흐름이 완벽하게 유지됩니다.
여러분이 이 함수를 사용하면 완전한 태스크 전환을 구현할 수 있습니다. 스케줄러는 save_context로 현재 태스크를 저장하고, 다음 태스크를 선택한 후, restore_context로 그 태스크를 복원합니다.
이 두 함수의 조합이 멀티태스킹의 핵심이며, 현대 운영체제가 수십 개의 프로세스를 "동시에" 실행할 수 있는 이유입니다.
실전 팁
💡 restore_context를 호출하기 전에 스택 포인터(rsp)를 해당 태스크의 스택으로 전환해야 합니다. 그렇지 않으면 잘못된 스택을 사용하여 크래시가 발생합니다.
💡 첫 번째 태스크 생성 시에는 컨텍스트를 수동으로 초기화해야 합니다. rip는 태스크의 진입점 함수 주소로, rsp는 할당된 스택의 끝 주소로 설정하세요.
💡 디버깅할 때는 복원 직전에 각 레지스터 값을 출력하세요. 특히 rip와 rsp가 유효한 주소인지 확인하는 것이 중요합니다.
💡 복원 후 즉시 인터럽트를 다시 활성화해야 합니다. 그렇지 않으면 시스템이 응답하지 않게 됩니다. sti 명령어를 사용하세요.
💡 QEMU나 Bochs 같은 에뮬레이터에서 테스트할 때 -d cpu 옵션을 사용하면 레지스터 변화를 추적할 수 있습니다. 이를 통해 복원이 정확히 동작하는지 확인하세요.
4. 컨텍스트 스위칭 구현 - 태스크 간 완전한 전환
시작하며
여러분이 멀티태스킹을 구현하려고 할 때, 저장과 복원을 따로따로 호출하면 복잡하고 실수하기 쉽습니다. 현재 태스크를 저장하고, 다음 태스크를 복원하고, 스택도 전환하고...
이 모든 단계를 매번 수동으로 하는 것은 비현실적입니다. 이것이 바로 컨텍스트 스위칭 함수가 필요한 이유입니다.
원자적으로(atomically) 한 태스크에서 다른 태스크로 전환하는 단일 함수입니다. 이 함수는 저장, 스택 전환, 복원을 모두 처리하며, 중간에 인터럽트가 발생하지 않도록 보장합니다.
실무 OS에서는 이 함수가 타이머 인터럽트 핸들러에서 가장 많이 호출됩니다. 스케줄러가 다음 태스크를 결정하면, 단 한 번의 함수 호출로 완전한 전환이 이루어집니다.
이것이 멀티태스킹의 심장부입니다.
개요
간단히 말해서, 컨텍스트 스위칭은 현재 태스크의 상태를 저장하고 다음 태스크의 상태를 복원하는 전체 과정을 하나의 원자적 연산으로 수행하는 것입니다. 실무에서 이 함수는 스케줄러의 핵심입니다.
타이머 인터럽트가 발생하면 커널은 현재 태스크의 실행 시간이 끝났다고 판단하고, 스케줄러를 호출합니다. 스케줄러는 우선순위, 대기 시간 등을 고려하여 다음 태스크를 선택하고, switch_context를 호출합니다.
이 함수가 리턴하면 다른 태스크가 실행되고 있습니다. 전통적인 구현에서는 저장과 복원 사이에 스택을 전환했습니다.
하지만 현대적인 접근법은 각 태스크가 자신의 스택을 가지며, 스택 포인터도 컨텍스트의 일부로 관리합니다. 이렇게 하면 스택 오버플로우를 감지하기 쉽고, 태스크 격리도 개선됩니다.
핵심은 이 함수가 두 가지 관점에서 동작한다는 것입니다. 현재 태스크 입장에서는 함수를 호출하고 리턴받습니다.
하지만 실제로는 다른 태스크가 실행되고, 한참 후에 스케줄러가 다시 이 태스크를 선택했을 때 비로소 리턴됩니다. 이 비대칭적 동작을 이해하는 것이 중요합니다.
코드 예제
// 현재 태스크에서 다음 태스크로 전환
#[inline(never)]
pub unsafe fn switch_context(
current: *mut Context,
next: *const Context
) {
asm!(
// 현재 컨텍스트 저장
"mov [rdi + 0x00], r15",
"mov [rdi + 0x08], r14",
"mov [rdi + 0x10], r13",
"mov [rdi + 0x18], r12",
"mov [rdi + 0x20], rbp",
"mov [rdi + 0x28], rbx",
"lea rax, [rip + 1f]", // 리턴 주소 계산
"mov [rdi + 0x30], rax",
// 다음 컨텍스트 복원
"mov r15, [rsi + 0x00]",
"mov r14, [rsi + 0x08]",
"mov r13, [rsi + 0x10]",
"mov r12, [rsi + 0x18]",
"mov rbp, [rsi + 0x20]",
"mov rbx, [rsi + 0x28]",
"mov rax, [rsi + 0x30]",
"jmp rax",
"1:", // 복귀 지점
in("rdi") current,
in("rsi") next,
out("rax") _,
options(nostack)
);
}
설명
이것이 하는 일: switch_context 함수는 두 태스크 사이의 완전한 전환을 수행합니다. 현재 실행 중인 태스크의 상태를 저장하고, 즉시 다음 태스크의 상태를 복원하여 실행을 이어갑니다.
첫 번째로, 저장 부분은 save_context와 거의 동일하지만 한 가지 중요한 차이가 있습니다. lea rax, [rip + 1f] 명령어는 "앞으로 나올 라벨 1의 주소를 계산하여 rax에 저장"한다는 의미입니다.
이것이 리턴 주소가 됩니다. 나중에 이 태스크가 다시 스케줄링되어 복원될 때, 정확히 라벨 1(함수의 끝)로 돌아와서 리턴하게 됩니다.
이렇게 하면 호출자 입장에서는 일반 함수처럼 보입니다. 그 다음으로, 복원 부분이 실행됩니다.
여기서 rsi는 두 번째 인자(next)를 담고 있습니다. 각 레지스터에 다음 태스크의 값을 로드합니다.
마지막으로 jmp rax로 저장된 rip 주소로 점프합니다. 이 시점에서 "마법"이 일어납니다.
CPU는 현재 함수를 떠나 완전히 다른 실행 흐름으로 이동합니다. 세 번째로, 다음 태스크가 이전에 switch_context를 호출했다면, 그 태스크의 라벨 1로 돌아갑니다.
즉, 그 태스크는 switch_context에서 리턴받는 것처럼 느낍니다. 각 태스크는 자신이 멈춘 적이 없다고 생각하며, 변수와 스택이 모두 유지됩니다.
네 번째로, 만약 다음 태스크가 새로 생성된 태스크라면, rip는 태스크의 진입점 함수를 가리킵니다. 이 경우 jmp는 그 함수의 시작으로 점프하며, 태스크가 처음으로 실행을 시작합니다.
여러분이 이 함수를 사용하면 스케줄러 구현이 매우 간단해집니다. 단순히 switch_context(¤t_task.context, &next_task.context)를 호출하면 됩니다.
인터럽트 비활성화, 저장, 복원, 스택 전환 등 복잡한 세부사항은 모두 이 함수 내부에서 처리됩니다. 이것이 안전하고 유지보수하기 쉬운 OS 코드를 작성하는 핵심 패턴입니다.
실전 팁
💡 switch_context는 항상 cli로 인터럽트를 비활성화한 후 호출하세요. 전환 중간에 인터럽트가 발생하면 시스템이 불안정해집니다.
💡 디버깅 시 각 태스크에 고유한 ID를 부여하고, 전환 전후에 로그를 남기세요. "Task 1 -> Task 2" 같은 형식으로 전환 패턴을 추적할 수 있습니다.
💡 성능 측정을 위해 rdtsc 명령어로 전환 소요 사이클을 측정하세요. 최적화된 구현은 100-200 사이클 정도 소요됩니다.
💡 첫 전환 시 스택이 올바르게 정렬되었는지 확인하세요. x86_64에서는 함수 호출 전 rsp가 16바이트 정렬되어야 합니다.
💡 태스크 종료 시에는 switch_context를 호출하지 마세요. 대신 별도의 task_exit 함수를 만들어 스케줄러에게 태스크 제거를 요청하세요.
5. 태스크 구조체 설계 - 컨텍스트와 메타데이터 관리
시작하며
여러분이 지금까지 본 Context 구조체는 CPU 상태만 담고 있습니다. 하지만 실제 운영체제에서 태스크는 훨씬 더 많은 정보가 필요합니다.
태스크 ID, 우선순위, 상태(실행 중/대기/종료), 스택 정보, 그리고 컨텍스트까지 모두 관리해야 합니다. 이런 정보들을 효율적으로 관리하지 않으면 스케줄러가 혼란스러워집니다.
어떤 태스크를 실행할 수 있는지, 어떤 태스크가 I/O를 기다리는지, 스택이 오버플로우되지는 않았는지 알 수 없습니다. 태스크 구조체는 이 모든 정보를 한곳에 모아 관리하는 컨테이너입니다.
Rust의 타입 시스템을 활용하면 안전하게 태스크 수명을 관리하고, 데이터 레이스를 방지할 수 있습니다. 실무 수준의 태스크 구조체를 설계해봅시다.
개요
간단히 말해서, Task 구조체는 실행 가능한 작업 단위의 모든 정보를 담는 데이터 구조입니다. Context뿐만 아니라 스케줄링에 필요한 메타데이터도 포함합니다.
실무에서는 이 구조체가 태스크 테이블(또는 프로세스 테이블)의 각 엔트리가 됩니다. 커널은 모든 태스크의 구조체를 리스트나 큐로 관리하며, 스케줄러는 이 리스트를 순회하면서 다음에 실행할 태스크를 선택합니다.
각 태스크의 상태를 추적하고, 통계를 수집하고, 리소스를 관리하는 것이 모두 이 구조체를 통해 이루어집니다. 전통적인 Unix 계열 OS에서는 PCB(Process Control Block)라고 부릅니다.
여기에는 PID, 부모 프로세스, 파일 디스크립터 테이블, 메모리 맵 등 수십 개의 필드가 있습니다. 우리는 간단한 버전부터 시작하여 점진적으로 확장할 것입니다.
핵심은 상태 관리입니다. 태스크는 Ready, Running, Blocked, Terminated 같은 상태를 가지며, 스케줄러는 Ready 상태인 태스크만 선택합니다.
enum을 활용하면 컴파일 타임에 잘못된 상태 전이를 방지할 수 있습니다.
코드 예제
// 태스크 상태 정의
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaskState {
Ready, // 실행 준비됨
Running, // 현재 실행 중
Blocked, // I/O 대기 등
Terminated, // 종료됨
}
// 태스크 구조체
pub struct Task {
pub id: usize,
pub state: TaskState,
pub context: Context,
pub stack: Vec<u8>,
pub priority: u8,
pub time_slice: usize, // 남은 실행 시간
}
impl Task {
// 새 태스크 생성
pub fn new(id: usize, entry: fn() -> !, stack_size: usize) -> Self {
let mut stack = vec![0u8; stack_size];
let stack_top = stack.as_mut_ptr() as usize + stack_size;
let mut context = Context::new();
context.rip = entry as u64;
context.rsp = (stack_top & !0xF) as u64; // 16바이트 정렬
Self {
id,
state: TaskState::Ready,
context,
stack,
priority: 0,
time_slice: 10,
}
}
}
설명
이것이 하는 일: Task 구조체는 실행 가능한 작업의 완전한 표현입니다. CPU 상태(context), 메모리(stack), 스케줄링 정보(state, priority, time_slice)를 모두 포함하여 커널이 태스크를 완벽하게 제어할 수 있게 합니다.
첫 번째로, TaskState enum은 태스크가 가질 수 있는 모든 상태를 정의합니다. Ready는 CPU만 있으면 실행 가능함을 의미하고, Running은 현재 CPU를 할당받아 실행 중임을 의미합니다.
Blocked는 I/O 완료나 이벤트 발생을 기다리는 상태이며, Terminated는 실행이 완료되어 정리 대기 중임을 의미합니다. 스케줄러는 Ready 상태인 태스크만 Running으로 전환할 수 있습니다.
그 다음으로, Task 구조체의 각 필드를 살펴봅시다. id는 고유 식별자로, 디버깅과 로깅에 필수적입니다.
context는 이미 배운 CPU 상태입니다. stack은 Vec<u8>로 할당된 스택 메모리이며, Vec을 사용하면 힙에 할당되고 Task가 drop될 때 자동으로 해제됩니다.
priority는 스케줄링 우선순위이며, 숫자가 낮을수록 높은 우선순위입니다. time_slice는 이 태스크가 실행될 수 있는 타이머 틱 수입니다.
세 번째로, new 함수는 태스크 초기화를 담당합니다. 우선 지정된 크기의 스택을 할당하고, 스택 최상단 주소를 계산합니다.
중요한 것은 stack_top & !0xF로 16바이트 정렬을 강제하는 부분입니다. x86_64 ABI는 함수 호출 시 rsp가 16바이트 정렬되어야 한다고 규정하므로, 이를 지키지 않으면 SIMD 명령어에서 크래시가 발생합니다.
네 번째로, context.rip를 entry 함수의 주소로 설정합니다. 이 태스크가 처음 스케줄링될 때 restore_context는 이 주소로 점프하며, 태스크는 entry 함수의 시작부터 실행됩니다.
entry 함수의 타입이 fn() -> !인 이유는 태스크 함수는 절대 리턴하지 않기 때문입니다. 태스크가 종료되려면 명시적으로 exit 시스템 콜을 호출해야 합니다.
여러분이 이 구조체를 사용하면 완전한 멀티태스킹 시스템의 기초를 마련할 수 있습니다. 스케줄러는 Task 인스턴스의 배열이나 큐를 관리하며, 각 타이머 인터럽트마다 현재 태스크의 time_slice를 감소시키고, 0이 되면 다음 Ready 태스크로 전환합니다.
이것이 시분할 멀티태스킹의 기본 원리입니다.
실전 팁
💡 스택 크기는 태스크의 특성에 따라 조정하세요. 일반적으로 4KB-64KB 범위이며, 커널 태스크는 작게, 사용자 태스크는 크게 설정합니다.
💡 스택 오버플로우 감지를 위해 스택 끝에 가드 페이지를 추가하세요. 메모리 보호를 설정하여 접근 시 페이지 폴트가 발생하도록 만듭니다.
💡 디버깅을 위해 태스크 이름 필드를 추가하세요. 문자열로 "idle", "worker1" 같은 이름을 부여하면 로그를 읽기 쉬워집니다.
💡 태스크 생성 시 magic number를 스택 끝에 써두고, 주기적으로 확인하여 스택 오버플로우를 조기에 감지하세요.
💡 Drop trait을 구현하여 태스크 종료 시 리소스를 정리하세요. 열린 파일, 할당된 메모리 등을 자동으로 해제할 수 있습니다.
6. 간단한 라운드로빈 스케줄러 - 공평한 CPU 시간 분배
시작하며
여러분이 지금까지 컨텍스트와 태스크 구조체를 만들었지만, 누가 다음에 실행할 태스크를 결정할까요? 이것이 바로 스케줄러의 역할입니다.
스케줄러는 OS의 두뇌로, 제한된 CPU 자원을 여러 태스크에 효율적으로 분배합니다. 가장 간단하면서도 공평한 스케줄링 알고리즘은 라운드로빈(Round-Robin)입니다.
모든 태스크를 순환하면서 각각에게 동일한 시간(time slice)을 부여합니다. 한 태스크의 시간이 끝나면 다음 태스크로 넘어가는 방식입니다.
실무 OS에서는 우선순위 기반, CFS(Completely Fair Scheduler), 실시간 스케줄러 등 복잡한 알고리즘을 사용합니다. 하지만 라운드로빈부터 시작하면 스케줄링의 핵심 개념을 쉽게 이해할 수 있습니다.
Rust로 타입 안전한 스케줄러를 구현해봅시다.
개요
간단히 말해서, 라운드로빈 스케줄러는 Ready 큐에 있는 태스크들을 순서대로 실행하며, 각 태스크에게 동일한 time slice를 부여하는 알고리즘입니다. 실무에서 이 알고리즘은 대화형 시스템에 적합합니다.
모든 태스크가 공평하게 CPU를 받으므로 어떤 태스크도 오래 기다리지 않습니다. 웹 서버처럼 많은 짧은 요청을 처리하는 경우에 효과적입니다.
하지만 우선순위가 없어서 중요한 태스크와 덜 중요한 태스크를 구분할 수 없는 단점이 있습니다. 전통적인 구현에서는 circular queue를 사용합니다.
큐의 앞에서 태스크를 꺼내 실행하고, time slice가 끝나면 큐의 뒤에 다시 넣습니다. Rust에서는 VecDeque를 활용하면 효율적으로 구현할 수 있습니다.
핵심은 현재 실행 중인 태스크와 Ready 큐를 명확히 구분하는 것입니다. 한 순간에 하나의 태스크만 Running 상태이며, 나머지는 모두 Ready 상태로 큐에서 대기합니다.
타이머 인터럽트가 발생하면 Running 태스크를 큐에 넣고, 큐에서 다음 태스크를 꺼내 Running으로 만듭니다.
코드 예제
use alloc::collections::VecDeque;
pub struct RoundRobinScheduler {
tasks: VecDeque<Task>,
current: Option<Task>,
next_id: usize,
}
impl RoundRobinScheduler {
pub fn new() -> Self {
Self {
tasks: VecDeque::new(),
current: None,
next_id: 0,
}
}
// 새 태스크 추가
pub fn spawn(&mut self, entry: fn() -> !, stack_size: usize) {
let task = Task::new(self.next_id, entry, stack_size);
self.next_id += 1;
self.tasks.push_back(task);
}
// 다음 태스크로 전환
pub fn schedule(&mut self) -> Option<(*mut Context, *const Context)> {
// 현재 태스크를 큐에 넣기
if let Some(mut current) = self.current.take() {
if current.state != TaskState::Terminated {
current.state = TaskState::Ready;
self.tasks.push_back(current);
}
}
// 다음 태스크 선택
if let Some(mut next) = self.tasks.pop_front() {
next.state = TaskState::Running;
let next_ctx = &next.context as *const Context;
self.current = Some(next);
if let Some(current) = &mut self.current {
let current_ctx = &mut current.context as *mut Context;
Some((current_ctx, next_ctx))
} else {
None
}
} else {
None
}
}
}
설명
이것이 하는 일: RoundRobinScheduler는 모든 Ready 태스크를 큐에 보관하고, 타이머 인터럽트 발생 시 현재 태스크를 큐 뒤에 넣고 큐 앞의 태스크를 꺼내 실행하는 공평한 스케줄링을 구현합니다. 첫 번째로, 구조체의 필드를 이해해봅시다.
tasks는 VecDeque로 Ready 상태 태스크들을 보관합니다. VecDeque는 앞뒤 양쪽에서 효율적으로 삽입/삭제가 가능한 자료구조입니다.
current는 현재 실행 중인 태스크로, Option으로 감싸져 있어 태스크가 없을 수도 있음을 표현합니다. next_id는 새 태스크에게 부여할 고유 ID입니다.
그 다음으로, spawn 함수는 새 태스크를 생성하여 큐에 추가합니다. Task::new로 초기화하고, push_back으로 큐의 끝에 넣습니다.
이렇게 하면 다음 스케줄링 사이클에 이 태스크가 실행됩니다. 실제 커널에서는 fork나 pthread_create 같은 시스템 콜이 내부적으로 이 함수를 호출합니다.
세 번째로, schedule 함수가 핵심입니다. 첫 번째 단계는 현재 태스크를 처리하는 것입니다.
self.current.take()는 current에서 태스크를 꺼내고 None으로 만듭니다. 만약 태스크가 Terminated 상태가 아니라면, Ready 상태로 변경하고 큐 뒤에 넣습니다.
Terminated 태스크는 버려지며, 나중에 자동으로 drop됩니다. 네 번째로, 다음 태스크 선택입니다.
tasks.pop_front()로 큐 앞의 태스크를 꺼냅니다. 이 태스크를 Running 상태로 변경하고, current에 저장합니다.
그런 다음 이전 태스크와 다음 태스크의 컨텍스트 포인터를 반환합니다. 호출자는 이 포인터들을 switch_context에 전달하여 실제 전환을 수행합니다.
다섯 번째로, 반환 타입이 Option<(*mut Context, *const Context)>인 이유는 스케줄할 태스크가 없을 수도 있기 때문입니다. 예를 들어 모든 태스크가 Blocked 상태이거나 Terminated된 경우입니다.
이 경우 None을 반환하며, 커널은 idle 태스크로 전환하거나 CPU를 절전 모드로 만듭니다. 여러분이 이 스케줄러를 사용하면 간단한 멀티태스킹 커널을 만들 수 있습니다.
타이머 인터럽트 핸들러에서 if let Some((current, next)) = scheduler.schedule() { unsafe { switch_context(current, next); } } 같은 코드로 자동 태스크 전환이 이루어집니다. 이것이 선점형 멀티태스킹의 기본 구조입니다.
실전 팁
💡 idle 태스크를 항상 큐에 유지하세요. 다른 모든 태스크가 Blocked 상태일 때 실행할 태스크가 필요합니다. idle 태스크는 무한 루프로 hlt 명령어를 실행합니다.
💡 스케줄러를 static mut이나 Mutex로 감싸서 전역 접근을 가능하게 하세요. 인터럽트 핸들러에서 접근해야 하기 때문입니다.
💡 time slice 값을 조정하여 응답성과 처리량의 균형을 맞추세요. 너무 짧으면 컨텍스트 스위칭 오버헤드가 크고, 너무 길면 응답이 느립니다. 10-100ms가 일반적입니다.
💡 디버깅 시 각 schedule 호출마다 로그를 남기세요. "Schedule: Task 1 -> Task 2" 형식으로 전환 패턴을 추적하면 교착 상태나 기아 현상을 발견할 수 있습니다.
💡 벤치마크를 통해 컨텍스트 스위칭 빈도를 측정하세요. 초당 수천 번의 전환이 일어나는 것이 정상이며, 이보다 적으면 태스크가 블로킹되고 있을 수 있습니다.
7. 타이머 인터럽트 통합 - 자동 태스크 전환
시작하며
여러분이 지금까지 만든 스케줄러는 수동으로 schedule 함수를 호출해야 작동합니다. 하지만 실제 OS에서는 태스크가 자발적으로 양보하지 않아도 자동으로 전환되어야 합니다.
그렇지 않으면 악의적인 태스크가 CPU를 독점할 수 있습니다. 이것이 바로 선점형(preemptive) 멀티태스킹입니다.
타이머 인터럽트를 활용하여 일정 시간마다 강제로 스케줄러를 호출합니다. 태스크는 자신이 중단당한다는 것조차 알지 못하며, 커널이 모든 것을 투명하게 처리합니다.
x86_64에서는 APIC 타이머나 PIT(Programmable Interval Timer)를 사용합니다. 이 하드웨어 타이머를 특정 주파수로 설정하면, 주기적으로 인터럽트를 발생시킵니다.
인터럽트 핸들러에서 스케줄러를 호출하는 것이 전부입니다. 어떻게 통합하는지 알아봅시다.
개요
간단히 말해서, 타이머 인터럽트 통합은 하드웨어 타이머를 설정하여 주기적으로 인터럽트를 발생시키고, 해당 핸들러에서 스케줄러를 호출하는 것입니다. 실무에서 이 메커니즘은 모든 현대 OS의 핵심입니다.
Linux는 CONFIG_HZ 설정으로 초당 인터럽트 횟수를 결정합니다(보통 100-1000Hz). Windows는 기본 64Hz를 사용하며, 멀티미디어 애플리케이션이 더 높은 해상도를 요청할 수 있습니다.
주파수가 높을수록 응답성이 좋아지지만 오버헤드도 증가합니다. 전통적으로는 PIT를 사용했지만, 현대 시스템은 LAPIC(Local APIC) 타이머를 선호합니다.
LAPIC는 각 CPU 코어마다 독립적인 타이머가 있어 멀티코어 스케줄링이 쉽습니다. 하지만 간단한 구현에서는 PIT로 충분합니다.
핵심은 인터럽트 핸들러 내부에서 컨텍스트 전환을 수행하는 것입니다. 인터럽트가 발생하면 CPU는 자동으로 현재 상태를 스택에 저장하고, IDT(Interrupt Descriptor Table)에 등록된 핸들러로 점프합니다.
핸들러는 스케줄러를 호출하고, switch_context로 다른 태스크로 전환합니다. 인터럽트에서 리턴하면 새 태스크가 실행됩니다.
코드 예제
use x86_64::structures::idt::InterruptStackFrame;
// 전역 스케줄러 (실제로는 Mutex나 spinlock 필요)
static mut SCHEDULER: Option<RoundRobinScheduler> = None;
// 타이머 인터럽트 핸들러
pub extern "x86-interrupt" fn timer_interrupt_handler(
_stack_frame: InterruptStackFrame
) {
unsafe {
// End of Interrupt 신호 전송
lapic::send_eoi();
// 스케줄러 호출
if let Some(scheduler) = SCHEDULER.as_mut() {
if let Some((current, next)) = scheduler.schedule() {
// 현재 태스크의 시간 감소
if let Some(task) = &mut scheduler.current {
task.time_slice -= 1;
// 시간이 남아있으면 계속 실행
if task.time_slice > 0 {
return;
}
// 시간 소진, 태스크 전환
task.time_slice = 10; // 리셋
switch_context(current, next);
}
}
}
}
}
// 스케줄러 초기화
pub fn init_scheduler() {
unsafe {
SCHEDULER = Some(RoundRobinScheduler::new());
}
// LAPIC 타이머 설정 (예: 100Hz)
lapic::init_timer(100);
}
설명
이것이 하는 일: 타이머 인터럽트는 하드웨어 타이머가 주기적으로 발생시키는 신호이며, 핸들러는 이 신호를 받아 현재 태스크의 시간을 확인하고, 필요하면 다음 태스크로 전환하는 선점형 멀티태스킹을 구현합니다. 첫 번째로, extern "x86-interrupt" 속성은 이 함수가 x86_64 인터럽트 핸들러임을 나타냅니다.
컴파일러는 특별한 호출 규약을 사용하여 CPU가 저장한 레지스터를 보존하고, iretq 명령어로 리턴합니다. InterruptStackFrame은 인터럽트 발생 시 스택에 push된 정보(rip, cs, rflags, rsp, ss)를 담고 있습니다.
그 다음으로, lapic::send_eoi()는 End of Interrupt 신호를 LAPIC에 전송합니다. 이는 인터럽트 처리가 완료되었음을 알리는 것으로, 이 신호를 보내지 않으면 다음 인터럽트가 발생하지 않습니다.
반드시 핸들러 시작 부분에서 호출해야 다른 인터럽트가 블로킹되지 않습니다. 세 번째로, 스케줄러 접근 부분입니다.
SCHEDULER.as_mut()로 전역 스케줄러의 가변 참조를 얻습니다. 실제 구현에서는 static mut 대신 Mutex나 spinlock을 사용해야 데이터 레이스를 방지할 수 있습니다.
현재 태스크의 time_slice를 1 감소시키고, 0이 되면 시간 소진으로 판단합니다. 네 번째로, time_slice가 남아있으면 조기 리턴합니다.
이는 최적화로, 매 인터럽트마다 스케줄링할 필요 없이 time_slice가 끝났을 때만 전환합니다. 예를 들어 time_slice가 10이고 타이머가 100Hz라면, 각 태스크는 100ms 동안 실행됩니다.
다섯 번째로, 시간이 소진되면 time_slice를 리셋하고 switch_context를 호출합니다. 여기서 주의할 점은 switch_context가 리턴하지 않는다는 것입니다.
대신 다음 태스크로 점프하며, 현재 태스크는 나중에 다시 스케줄링될 때 이 지점으로 돌아옵니다. 여러분이 이 코드를 사용하면 완전한 선점형 멀티태스킹 커널이 완성됩니다.
init_scheduler로 초기화하고, spawn으로 태스크를 생성하면, 타이머가 자동으로 태스크를 전환합니다. 각 태스크는 독립적으로 실행되는 것처럼 느끼지만, 실제로는 빠르게 교대로 실행되는 것입니다.
이것이 바로 시분할 멀티태스킹의 완성된 형태입니다.
실전 팁
💡 타이머 주파수는 신중하게 선택하세요. 100Hz는 10ms time slice로 대부분의 대화형 시스템에 적합합니다. 1000Hz는 응답성이 중요한 경우에 사용합니다.
💡 인터럽트 핸들러는 최대한 짧게 유지하세요. 복잡한 작업은 하단 반쪽(bottom half) 메커니즘으로 미루는 것이 좋습니다.
💡 스케줄러 접근 시 항상 락을 사용하세요. 멀티코어 시스템에서는 여러 코어가 동시에 스케줄러를 호출할 수 있습니다.
💡 디버깅 시 인터럽트 횟수를 카운터로 추적하세요. 초당 예상 횟수(100Hz라면 100)와 비교하여 타이머가 올바르게 작동하는지 확인할 수 있습니다.
💡 QEMU에서 테스트할 때 -no-kvm 옵션을 사용하면 타이머가 더 예측 가능하게 동작합니다. KVM 모드에서는 호스트 타이머의 영향을 받을 수 있습니다.
8. 유저 모드 컨텍스트 확장 - 세그먼트와 권한 관리
시작하며
여러분이 지금까지 만든 컨텍스트는 커널 모드에서만 작동합니다. 하지만 실제 OS에서는 사용자 프로그램을 보호된 환경에서 실행해야 합니다.
악의적인 코드가 커널 메모리를 건드리거나 다른 프로세스를 공격하지 못하도록 격리해야 합니다. 이것이 바로 권한 레벨(privilege level)과 세그멘테이션의 역할입니다.
x86_64는 Ring 0(커널)부터 Ring 3(유저)까지 4개의 권한 레벨을 제공합니다. 유저 모드 태스크는 Ring 3에서 실행되며, 커널 메모리 접근이나 특권 명령어 실행 시 예외가 발생합니다.
컨텍스트에 세그먼트 레지스터(cs, ss)와 rflags를 추가하면 유저/커널 모드 전환을 지원할 수 있습니다. 시스템 콜 진입 시 Ring 3에서 Ring 0으로, 리턴 시 Ring 0에서 Ring 3으로 전환되는 것을 안전하게 관리할 수 있습니다.
어떻게 확장하는지 알아봅시다.
개요
간단히 말해서, 유저 모드 컨텍스트는 세그먼트 셀렉터(cs, ss)와 스택 포인터(rsp), 플래그 레지스터(rflags)를 추가하여 커널과 유저 모드 전환을 지원하는 확장된 컨텍스트입니다. 실무에서 모든 사용자 프로세스는 유저 모드에서 실행됩니다.
파일 읽기, 네트워크 송수신 같은 권한 있는 작업은 시스템 콜을 통해 커널 모드로 전환하여 수행합니다. 커널은 요청의 유효성을 검사하고, 안전하게 수행한 후, 다시 유저 모드로 돌아갑니다.
이 전환 과정에서 세그먼트와 스택이 변경됩니다. 전통적으로 x86에서는 세그멘테이션이 메모리 보호의 핵심이었습니다.
x86_64에서는 대부분 페이징으로 대체되었지만, 권한 레벨 관리를 위해 여전히 cs(코드 세그먼트)와 ss(스택 세그먼트)는 사용됩니다. 각 세그먼트 셀렉터의 하위 2비트가 RPL(Requested Privilege Level)로 0(커널) 또는 3(유저)을 나타냅니다.
핵심은 시스템 콜과 인터럽트 리턴입니다. syscall 명령어는 자동으로 cs를 커널 세그먼트로 변경하고, 커널 스택으로 전환합니다.
sysret은 반대로 유저 세그먼트와 스택을 복원합니다. 우리의 컨텍스트 구조체가 이 정보를 담고 있으면 수동으로도 전환을 제어할 수 있습니다.
코드 예제
// 유저 모드를 지원하는 확장된 컨텍스트
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct ExtendedContext {
// 범용 레지스터
pub r15: u64,
pub r14: u64,
pub r13: u64,
pub r12: u64,
pub rbp: u64,
pub rbx: u64,
// 명령어 포인터
pub rip: u64,
// 세그먼트와 스택 (유저 모드용)
pub cs: u64,
pub rflags: u64,
pub rsp: u64,
pub ss: u64,
}
impl ExtendedContext {
// 커널 모드 컨텍스트 생성
pub fn new_kernel(entry: u64, stack: u64) -> Self {
Self {
r15: 0, r14: 0, r13: 0, r12: 0,
rbp: 0, rbx: 0,
rip: entry,
cs: 0x08, // GDT 커널 코드 세그먼트
rflags: 0x200, // 인터럽트 활성화
rsp: stack,
ss: 0x10, // GDT 커널 데이터 세그먼트
}
}
// 유저 모드 컨텍스트 생성
pub fn new_user(entry: u64, stack: u64) -> Self {
Self {
r15: 0, r14: 0, r13: 0, r12: 0,
rbp: 0, rbx: 0,
rip: entry,
cs: 0x1B, // GDT 유저 코드 (Ring 3)
rflags: 0x200,
rsp: stack,
ss: 0x23, // GDT 유저 데이터 (Ring 3)
}
}
}
설명
이것이 하는 일: ExtendedContext는 기본 CPU 레지스터뿐만 아니라 권한 레벨과 관련된 세그먼트 정보를 포함하여, 태스크가 커널 모드 또는 유저 모드에서 실행되도록 제어합니다. 첫 번째로, 구조체의 레이아웃이 중요합니다.
필드 순서는 iretq 명령어가 스택에서 pop하는 순서와 일치해야 합니다. iretq는 순서대로 rip, cs, rflags, rsp, ss를 pop합니다.
따라서 이 순서대로 구조체에 배치하면, 구조체를 스택에 push하고 iretq를 실행하여 컨텍스트를 복원할 수 있습니다. 그 다음으로, 세그먼트 셀렉터 값을 살펴봅시다.
0x08은 GDT(Global Descriptor Table)의 인덱스 1(바이트 8)을 가리키며, 일반적으로 커널 코드 세그먼트입니다. 하위 2비트가 00이므로 RPL은 0(Ring 0)입니다.
0x1B는 바이너리로 11011로, 인덱스 3이고 RPL이 3(Ring 3)입니다. 마찬가지로 0x10(데이터 세그먼트, Ring 0)과 0x23(데이터 세그먼트, Ring 3)입니다.
세 번째로, rflags의 0x200은 인터럽트 플래그(IF)를 의미합니다. 이 비트가 설정되면 인터럽트가 활성화됩니다.
유저 모드 태스크는 항상 인터럽트가 켜져 있어야 선점형 스케줄링이 작동합니다. 커널 코드는 임계 영역에서 cli로 일시적으로 끌 수 있지만, 유저 코드는 cli 명령어를 실행하면 General Protection Fault가 발생합니다.
네 번째로, new_kernel과 new_user 함수의 차이를 이해하세요. 커널 태스크는 Ring 0 세그먼트를 사용하므로 모든 명령어와 메모리에 접근할 수 있습니다.
유저 태스크는 Ring 3 세그먼트를 사용하므로 페이지 테이블에서 User 플래그가 설정된 페이지만 접근할 수 있습니다. 권한 없는 페이지에 접근하면 Page Fault가 발생합니다.
다섯 번째로, rsp(스택 포인터)도 모드에 따라 다릅니다. 유저 모드에서 시스템 콜을 호출하면, CPU는 자동으로 커널 스택으로 전환합니다.
이는 TSS(Task State Segment)에 설정된 rsp0 값을 사용합니다. 이렇게 분리된 스택을 사용하면 유저 스택이 손상되어도 커널이 안전하게 작동할 수 있습니다.
여러분이 이 확장된 컨텍스트를 사용하면 완전한 보호 모드 멀티태스킹을 구현할 수 있습니다. 커널 태스크(드라이버, 스케줄러)와 유저 태스크(애플리케이션)를 명확히 분리하고, 각각의 권한에 맞는 리소스만 접근하도록 보장할 수 있습니다.
이것이 현대 OS의 보안과 안정성의 기반입니다.
실전 팁
💡 GDT를 올바르게 설정하세요. 커널 코드/데이터(Ring 0), 유저 코드/데이터(Ring 3) 세그먼트를 포함해야 합니다. long mode에서는 베이스와 리미트가 무시되지만 플래그는 중요합니다.
💡 TSS의 rsp0 필드를 각 태스크의 커널 스택으로 설정하세요. 시스템 콜이나 인터럽트 발생 시 CPU가 이 스택을 사용합니다.
💡 유저 스택과 커널 스택을 별도로 할당하세요. 각 태스크는 두 개의 스택이 필요하며, 컨텍스트에 양쪽 스택 포인터를 모두 저장합니다.
💡 iretq로 복원할 때 스택 정렬에 주의하세요. rsp는 16바이트 정렬되어야 하며, iretq를 위해서는 추가로 8바이트 패딩이 필요할 수 있습니다.
💡 디버깅 시 cs 값을 확인하여 현재 권한 레벨을 파악하세요. cs & 3이 0이면 커널 모드, 3이면 유저 모드입니다.
9. FPU와 SIMD 컨텍스트 관리 - 확장 레지스터 저장
시작하며
여러분이 부동소수점 연산이나 벡터 연산을 사용하는 프로그램을 실행할 때, CPU는 일반 레지스터 외에 특별한 레지스터를 사용합니다. x86_64에는 FPU(x87), SSE(xmm0-xmm15), AVX(ymm0-ymm15), 심지어 AVX-512(zmm0-zmm31)까지 다양한 확장 레지스터가 있습니다.
문제는 이 레지스터들이 매우 크다는 것입니다. xmm 레지스터는 각각 128비트, ymm은 256비트, zmm은 512비트입니다.
모든 태스크 전환마다 이들을 저장하고 복원하면 엄청난 오버헤드가 발생합니다. 실제로 많은 태스크는 이 레지스터를 전혀 사용하지 않습니다.
해결책은 lazy FPU 저장입니다. 태스크가 실제로 FPU를 사용할 때만 저장하는 방식입니다.
CR0 레지스터의 TS(Task Switched) 플래그를 활용하면 FPU 명령어 실행 시 예외를 발생시켜 필요한 때만 저장할 수 있습니다. 효율적인 확장 레지스터 관리 방법을 알아봅시다.
개요
간단히 말해서, FPU/SIMD 컨텍스트 관리는 부동소수점과 벡터 연산에 사용되는 확장 레지스터를 효율적으로 저장하고 복원하는 것입니다. Lazy 저장 기법으로 오버헤드를 최소화합니다.
실무에서 이 관리는 매우 중요합니다. 현대 애플리케이션은 멀티미디어 처리, 과학 계산, 게임 등에서 SIMD를 광범위하게 사용합니다.
컴파일러도 자동 벡터화로 일반 코드를 SIMD로 변환합니다. 따라서 대부분의 태스크가 이 레지스터를 사용하지만, 모든 전환마다 저장하는 것은 비효율적입니다.
전통적으로는 fxsave/fxrstor 명령어로 512바이트 상태를 저장했습니다. 현대 시스템은 xsave/xrstor를 사용하여 실제 사용 중인 확장 기능만 선택적으로 저장합니다.
xsave는 xcr0 레지스터에 설정된 비트를 보고 어떤 상태를 저장할지 결정합니다. 핵심은 TS 플래그입니다.
태스크 전환 시 CR0.TS를 설정하면, 다음 FPU 명령어 실행 시 Device Not Available 예외(#NM)가 발생합니다. 예외 핸들러에서 이전 태스크의 FPU 상태를 저장하고, 현재 태스크의 상태를 복원한 후, TS를 해제합니다.
이렇게 하면 FPU를 사용하는 태스크만 저장 비용을 지불합니다.
코드 예제
use core::arch::x86_64::{_fxsave, _fxrstor, _xsave, _xrstor};
// FPU 상태 버퍼 (512바이트, 16바이트 정렬)
#[repr(C, align(16))]
pub struct FpuState {
data: [u8; 512],
}
impl FpuState {
pub const fn new() -> Self {
Self { data: [0; 512] }
}
// FPU 상태 저장
pub unsafe fn save(&mut self) {
_fxsave(self.data.as_mut_ptr() as *mut u8);
}
// FPU 상태 복원
pub unsafe fn restore(&self) {
_fxrstor(self.data.as_ptr() as *const u8);
}
}
// Device Not Available 예외 핸들러
pub extern "x86-interrupt" fn fpu_exception_handler(
_stack_frame: InterruptStackFrame
) {
unsafe {
// 이전 태스크의 FPU 상태 저장
if let Some(prev_task) = PREVIOUS_FPU_TASK {
prev_task.fpu_state.save();
}
// 현재 태스크의 FPU 상태 복원
if let Some(current_task) = CURRENT_TASK {
current_task.fpu_state.restore();
PREVIOUS_FPU_TASK = Some(current_task);
}
// TS 플래그 해제
cr0_write(cr0() & !CR0_TS);
}
}
설명
이것이 하는 일: FPU 컨텍스트 관리는 부동소수점 및 SIMD 레지스터를 태스크마다 독립적으로 유지하되, 실제 사용 시점까지 저장을 미루는 lazy 전략으로 성능을 최적화합니다. 첫 번째로, FpuState 구조체는 512바이트 버퍼로 모든 FPU/SSE 레지스터 상태를 담습니다.
#[repr(C, align(16))]는 16바이트 정렬을 강제하는데, fxsave/fxrstor 명령어는 정렬되지 않은 주소에 접근하면 General Protection Fault를 발생시키기 때문입니다. x87 FPU 상태, xmm 레지스터, MXCSR 제어 레지스터 등이 모두 이 버퍼에 저장됩니다.
그 다음으로, save와 restore 메서드는 Rust의 intrinsic 함수를 래핑합니다. _fxsave는 현재 FPU 상태를 메모리에 저장하고, _fxrstor는 메모리에서 FPU 상태를 복원합니다.
이 함수들은 unsafe인데, 버퍼가 올바르게 정렬되어 있고 충분한 크기인지 컴파일러가 검증할 수 없기 때문입니다. 세 번째로, lazy 저장 메커니즘의 동작을 이해해봅시다.
태스크 전환 시 switch_context는 FPU 상태를 저장하지 않고, 대신 CR0.TS를 설정합니다. 새 태스크가 실행되다가 FPU 명령어(예: addss, mulpd)를 처음 만나면 #NM 예외가 발생합니다.
핸들러는 이때 비로소 이전 태스크의 FPU 상태를 저장합니다. 네 번째로, PREVIOUS_FPU_TASK는 마지막으로 FPU를 사용한 태스크를 추적합니다.
현재 태스크가 이전 FPU 사용자와 같다면 저장/복원이 불필요합니다. 하지만 다른 태스크라면 이전 태스크의 상태를 저장하고, 현재 태스크의 상태를 복원해야 합니다.
이렇게 하면 FPU를 전혀 사용하지 않는 태스크는 오버헤드가 0입니다. 다섯 번째로, cr0_write(cr0() & !CR0_TS)로 TS 플래그를 해제합니다.
이제 현재 태스크는 FPU를 자유롭게 사용할 수 있습니다. 다음 태스크 전환 시 다시 TS가 설정되어, 새 태스크의 첫 FPU 명령어에서 다시 예외가 발생하는 사이클이 반복됩니다.
여러분이 이 기법을 사용하면 FPU 집약적인 태스크와 일반 태스크가 혼재된 시스템에서 최적의 성능을 얻을 수 있습니다. 웹 서버 같은 I/O 위주 워크로드에서는 FPU 저장이 거의 발생하지 않고, 비디오 인코딩 같은 계산 위주 워크로드에서만 비용을 지불합니다.
이것이 Linux와 Windows가 사용하는 검증된 최적화 기법입니다.
실전 팁
💡 최신 CPU에서는 xsave/xrstor를 사용하세요. xcr0로 AVX, AVX-512 등을 활성화하면 더 큰 레지스터를 관리할 수 있지만, 버퍼 크기도 증가합니다.
💡 FpuState 버퍼를 힙에 할당하는 것을 고려하세요. 512바이트 이상이면 스택 오버플로우 위험이 있습니다. Box<FpuState>를 사용하면 안전합니다.
💡 첫 번째 FPU 사용 시에는 fninit로 초기화하세요. 그렇지 않으면 이전에 남아있던 쓰레기 값으로 계산이 잘못될 수 있습니다.
💡 멀티코어 시스템에서는 코어별로 PREVIOUS_FPU_TASK를 관리하세요. 각 코어가 독립적으로 FPU 상태를 추적해야 합니다.
💡 벤치마크를 통해 lazy 저장의 효과를 측정하세요. FPU 집약적 워크로드에서는 eager 저장이 더 빠를 수 있습니다. 프로파일링 후 결정하세요.
10. 디버깅과 검증 - 컨텍스트 무결성 확인
시작하며
여러분이 컨텍스트 전환 코드를 작성하고 나면, 정말로 올바르게 동작하는지 어떻게 확인할까요? 컨텍스트 관리는 저수준 코드로 버그가 발생하면 시스템이 즉시 크래시하거나, 더 나쁘게는 간헐적으로 이상한 동작을 할 수 있습니다.
실무 OS 개발에서 가장 어려운 부분 중 하나가 바로 이 검증입니다. 레지스터 값이 올바르게 저장되는지, 스택이 오버플로우되지 않는지, 모든 태스크가 공평하게 실행되는지 확인해야 합니다.
단순히 "돌아간다"는 것만으로는 충분하지 않습니다. 다행히 Rust의 타입 시스템과 몇 가지 디버깅 기법을 활용하면 많은 버그를 조기에 발견할 수 있습니다.
매직 넘버를 사용한 스택 오버플로우 감지, 레지스터 체크섬, 스케줄링 통계 수집 등을 통해 컨텍스트 전환의 정확성을 보장할 수 있습니다. 실전 검증 기법들을 알아봅시다.
개요
간단히 말해서, 컨텍스트 검증은 태스크 전환이 올바르게 동작하는지 확인하기 위한 다양한 디버깅 및 테스트 기법입니다. 런타임 검사와 정적 분석을 조합합니다.
실무에서 이런 검증은 개발 초기부터 필수적입니다. 커널 버그는 사용자 공간 버그보다 훨씬 치명적이며, 디버깅도 어렵습니다.
printk나 시리얼 로그조차 제대로 동작하지 않을 수 있기 때문입니다. 따라서 방어적 프로그래밍과 자동화된 검증이 중요합니다.
전통적으로는 어설션(assertion)을 코드 곳곳에 배치합니다. 레지스터 값이 유효한 범위인지, 포인터가 널이 아닌지, 상태 전이가 유효한지 등을 검사합니다.
Rust에서는 debug_assert! 매크로를 사용하면 디버그 빌드에서만 검사하여 릴리스 성능에 영향을 주지 않습니다. 핵심은 불변식(invariant)을 명확히 하는 것입니다.
예를 들어 "Running 상태 태스크는 정확히 하나" 같은 불변식을 정의하고, 모든 스케줄링 함수 진입/탈출 시 검증합니다. 불변식이 깨지면 즉시 패닉을 일으켜 문제를 조기에 발견할 수 있습니다.
코드 예제
// 스택 가드 매직 넘버
const STACK_GUARD_MAGIC: u64 = 0xDEADBEEFCAFEBABE;
// 스택 오버플로우 감지
pub fn check_stack_overflow(task: &Task) -> bool {
unsafe {
let guard_ptr = task.stack.as_ptr() as *const u64;
*guard_ptr == STACK_GUARD_MAGIC
}
}
// 컨텍스트 무결성 검사
pub fn verify_context(ctx: &Context) -> Result<(), &'static str> {
// rip가 유효한 주소인지 확인
if ctx.rip < 0x1000 || ctx.rip > 0xFFFF_FFFF_FFFF_FFFF {
return Err("Invalid rip");
}
// rsp가 정렬되었는지 확인
if ctx.rsp & 0xF != 0 {
return Err("Unaligned rsp");
}
// rsp가 유효한 스택 범위인지 확인
if ctx.rsp < 0x1000 {
return Err("Invalid rsp");
}
Ok(())
}
// 스케줄링 통계
pub struct SchedStats {
pub total_switches: usize,
pub task_run_count: [usize; 16], // 태스크별 실행 횟수
}
impl SchedStats {
pub fn record_switch(&mut self, from_id: usize, to_id: usize) {
self.total_switches += 1;
if to_id < 16 {
self.task_run_count[to_id] += 1;
}
// 공평성 검사: 가장 많이 실행된 태스크와 가장 적게 실행된 태스크의 차이
let max = self.task_run_count.iter().max().unwrap_or(&0);
let min = self.task_run_count.iter().filter(|&&c| c > 0).min().unwrap_or(&0);
if max - min > 100 {
debug_warn!("Scheduling imbalance: max={} min={}", max, min);
}
}
}
설명
이것이 하는 일: 컨텍스트 검증 코드는 런타임에 다양한 불변식과 조건을 확인하여 버그를 조기에 발견하고, 시스템의 동작을 추적하여 성능 문제를 진단합니다. 첫 번째로, 스택 가드는 오버플로우를 감지하는 고전적 기법입니다.
태스크 생성 시 스택의 최하단(낮은 주소)에 특별한 매직 넘버를 씁니다. 스택은 높은 주소에서 낮은 주소로 자라므로, 오버플로우가 발생하면 이 매직 넘버를 덮어씁니다.
check_stack_overflow 함수는 주기적으로(예: 매 스케줄링마다) 이 값을 확인하여 오염 여부를 검사합니다. 매직 넘버가 변경되었다면 즉시 패닉을 발생시켜야 합니다.
그 다음으로, verify_context는 컨텍스트 구조체의 합리성을 검사합니다. rip가 낮은 주소(널 포인터 근처)를 가리키면 명백히 잘못된 것입니다.
마찬가지로 매우 높은 주소(커널 영역 밖)도 의심스럽습니다. rsp 정렬 검사는 ABI 준수를 보장합니다.
이런 검사들은 switch_context 호출 전후에 수행하여 잘못된 컨텍스트로 전환하는 것을 방지합니다. 세 번째로, SchedStats는 스케줄링 행동을 정량적으로 추적합니다.
total_switches는 총 전환 횟수로, 시간당 전환 횟수를 계산하여 오버헤드를 추정할 수 있습니다. task_run_count 배열은 각 태스크가 몇 번 실행되었는지 기록합니다.
이상적인 라운드로빈에서는 모든 태스크가 거의 동일한 횟수로 실행되어야 합니다. 네 번째로, record_switch 함수는 공평성을 자동으로 검사합니다.
가장 많이 실행된 태스크와 가장 적게 실행된 태스크의 차이가 너무 크면(여기서는 100회) 경고를 출력합니다. 이는 스케줄링 버그나 기아(starvation) 상황을 나타낼 수 있습니다.
예를 들어 특정 태스크가 계속 Blocked 상태에 머무르거나, 큐에서 누락되었을 가능성이 있습니다. 다섯 번째로, 이런 검증 코드는 조건부 컴파일로 제어할 수 있습니다.
#[cfg(debug_assertions)]를 사용하면 디버그 빌드에서만 컴파일되어, 릴리스 빌드의 성능에 영향을 주지 않습니다. 또는 커널 부트 파라미터로 런타임에 켜고 끌 수 있게 만들 수도 있습니다.
여러분이 이런 검증 기법을 사용하면 컨텍스트 관리 버그를 훨씬 빠르게 찾을 수 있습니다. 실제로 많은 커널 버그가 스택 오버플로우나 잘못된 포인터 때문에 발생하는데, 이런 검사들이 문제를 즉시 드러내줍니다.
안정적인 OS를 만들기 위해서는 코드 작성보다 검증에 더 많은 시간을 투자해야 합니다.
실전 팁
💡 QEMU의 -d int,cpu_reset 옵션을 사용하여 모든 인터럽트와 레지스터 상태를 로그로 남기세요. 크래시 직전의 상태를 분석할 수 있습니다.
💡 각 태스크에 고유한 색상을 부여하고, 시리얼 로그에 ANSI 색상 코드를 사용하면 어떤 태스크가 실행 중인지 시각적으로 쉽게 파악할 수 있습니다.
💡 단위 테스트를 작성하세요. 예를 들어 save_context와 restore_context를 연속 호출했을 때 레지스터 값이 보존되는지 확인하는 테스트를 만드세요.
💡 fuzzing을 고려하세요. 랜덤한 컨텍스트 값으로 restore를 시도하여 예외 처리가 견고한지 확인할 수 있습니다.
💡 CI/CD 파이프라인에서 QEMU로 자동 테스트를 실행하세요. 매 커밋마다 멀티태스킹이 올바르게 동작하는지 검증하여 회귀를 방지할 수 있습니다.