이미지 로딩 중...
AI Generated
2025. 11. 14. · 4 Views
Rust로 만드는 나만의 OS 시스템 콜 기초
운영체제의 핵심인 시스템 콜을 Rust로 직접 구현하는 방법을 배웁니다. 유저 모드와 커널 모드 전환, 인터럽트 처리, 시스템 콜 핸들러 작성까지 실무 수준의 OS 개발 기초를 다룹니다.
목차
- 시스템 콜이란 무엇인가 - 유저와 커널을 연결하는 다리
- 인터럽트 디스크립터 테이블 - 시스템 콜의 진입점
- 시스템 콜 핸들러 구현 - 요청을 처리하는 커널 함수
- 파라미터 전달과 검증 - 안전한 데이터 교환
- 시스템 콜 구현 예제 - write 시스템 콜
- 시스템 콜 테이블 - 효율적인 디스패치
- 컨텍스트 저장과 복원 - 완벽한 상태 보존
- 에러 처리와 errno - 실패를 우아하게 다루기
- vDSO와 빠른 시스템 콜 - 성능 최적화의 극치
- 시스템 콜 추적과 디버깅 - 문제 해결의 열쇠
1. 시스템 콜이란 무엇인가 - 유저와 커널을 연결하는 다리
시작하며
여러분이 파일을 읽거나, 화면에 문자를 출력하거나, 네트워크로 데이터를 보낼 때, 그 모든 작업이 어떻게 이루어지는지 생각해본 적 있나요? 우리가 작성한 프로그램은 직접 하드웨어에 접근할 수 없습니다.
보안과 안정성을 위해 운영체제가 중간에서 모든 것을 통제합니다. 이런 문제는 실제 개발 현장에서 자주 발생합니다.
유저 모드에서 실행되는 애플리케이션이 특권 명령(privileged instruction)을 실행하려고 하면 즉시 예외가 발생하고 프로그램이 종료됩니다. 이것은 시스템의 안정성을 보장하기 위한 설계입니다.
바로 이럴 때 필요한 것이 시스템 콜입니다. 시스템 콜은 유저 모드와 커널 모드 사이의 안전한 통로를 제공하여, 애플리케이션이 필요한 하드웨어 자원에 접근할 수 있게 해줍니다.
개요
간단히 말해서, 시스템 콜은 유저 프로그램이 운영체제의 서비스를 요청하는 공식적인 인터페이스입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, CPU는 보안을 위해 여러 권한 레벨(Ring 0~3)을 제공합니다.
유저 프로그램은 Ring 3에서 실행되고, 커널은 Ring 0에서 실행됩니다. 예를 들어, 디스크에 데이터를 쓰거나 메모리를 할당받는 것처럼 하드웨어 자원이 필요한 경우, 시스템 콜을 통해서만 안전하게 요청할 수 있습니다.
전통적인 방법과의 비교를 하자면, 기존에는 프로그램이 직접 하드웨어에 접근했다면, 현대 운영체제에서는 시스템 콜이라는 추상화 계층을 통해 안전하고 통제된 방식으로 접근할 수 있습니다. 시스템 콜의 핵심 특징은 다음과 같습니다: (1) 권한 레벨 전환 - Ring 3에서 Ring 0으로 안전하게 전환, (2) 인터럽트 기반 - 특정 인터럽트 번호를 통해 호출, (3) 파라미터 전달 - 레지스터나 스택을 통한 인자 전달.
이러한 특징들이 시스템의 보안과 안정성을 보장하는 핵심입니다.
코드 예제
// x86_64 아키텍처에서 시스템 콜 호출 예제
use core::arch::asm;
// 시스템 콜 번호 정의
const SYSCALL_WRITE: u64 = 1;
const SYSCALL_EXIT: u64 = 60;
// write 시스템 콜을 호출하는 함수
unsafe fn syscall_write(fd: u64, buf: *const u8, count: u64) -> i64 {
let ret: i64;
// syscall 명령어를 사용하여 커널 모드로 전환
asm!(
"syscall",
inlateout("rax") SYSCALL_WRITE => ret,
in("rdi") fd, // 첫 번째 인자: 파일 디스크립터
in("rsi") buf, // 두 번째 인자: 버퍼 포인터
in("rdx") count, // 세 번째 인자: 바이트 수
lateout("rcx") _, // syscall이 변경하는 레지스터
lateout("r11") _, // syscall이 변경하는 레지스터
);
ret
}
설명
이것이 하는 일은 유저 공간에서 실행 중인 프로그램이 커널의 기능을 안전하게 호출하는 것입니다. 시스템 콜은 하드웨어의 특별한 명령어를 사용하여 CPU의 권한 레벨을 변경합니다.
첫 번째로, 시스템 콜 번호와 인자를 특정 레지스터에 배치하는 작업이 수행됩니다. x86_64 리눅스 ABI에서는 rax에 시스템 콜 번호, rdi/rsi/rdx/r10/r8/r9에 최대 6개의 인자를 전달합니다.
이렇게 레지스터를 사용하는 이유는 메모리 접근보다 훨씬 빠르고, 권한 전환 시 스택을 바꾸기 때문에 스택을 통한 전달이 복잡하기 때문입니다. 그 다음으로, syscall 명령어가 실행되면서 CPU는 자동으로 여러 작업을 수행합니다.
(1) 현재 실행 중인 코드의 주소(RIP)를 RCX에 저장, (2) RFLAGS를 R11에 저장, (3) 권한 레벨을 Ring 0으로 변경, (4) MSR(Model Specific Register)에 설정된 커널 코드 주소로 점프. 이 모든 과정이 하드웨어 수준에서 원자적으로(atomically) 일어나기 때문에 중간에 다른 코드가 끼어들 수 없습니다.
마지막으로, 커널이 요청을 처리하고 sysret 명령어로 유저 모드로 복귀하면, 저장했던 RCX와 R11을 사용하여 정확히 syscall 다음 명령어부터 실행을 재개합니다. 최종적으로 시스템 콜의 반환값이 RAX 레지스터에 담겨 돌아옵니다.
여러분이 이 코드를 사용하면 운영체제의 가장 깊은 레벨에서 어떻게 프로그램이 하드웨어와 상호작용하는지 이해할 수 있습니다. 실무에서의 이점으로는 (1) 성능 최적화 - 불필요한 시스템 콜 줄이기, (2) 디버깅 - strace 같은 도구로 시스템 콜 추적 가능, (3) 보안 - 시스템 콜 필터링(seccomp) 설정 등이 있습니다.
실전 팁
💡 시스템 콜은 컨텍스트 스위칭을 수반하므로 상대적으로 비용이 큽니다. 가능하면 여러 작업을 배치(batch)로 처리하거나, 버퍼링을 통해 시스템 콜 횟수를 줄이세요.
💡 각 아키텍처마다 시스템 콜 규약(calling convention)이 다릅니다. x86_64는 syscall, x86은 int 0x80, ARM은 svc를 사용합니다.
💡 시스템 콜 번호는 커널 버전에 따라 변경될 수 있으므로, 직접 호출보다는 libc의 래퍼 함수를 사용하는 것이 이식성 면에서 좋습니다.
💡 strace 도구를 사용하면 프로그램이 어떤 시스템 콜을 호출하는지 실시간으로 확인할 수 있어 디버깅에 매우 유용합니다.
💡 Rust의 inline assembly를 사용할 때는 반드시 unsafe 블록 안에서 사용하고, 레지스터 clobber를 정확히 명시해야 합니다.
2. 인터럽트 디스크립터 테이블 - 시스템 콜의 진입점
시작하며
여러분이 키보드를 누르거나, 타이머가 만료되거나, 프로그램이 0으로 나누기를 시도할 때, 운영체제는 어떻게 이런 이벤트를 알아채고 적절히 대응할까요? CPU는 매 순간 정해진 명령어를 순차적으로 실행하는데, 외부 이벤트가 발생하면 즉시 실행 흐름을 바꿔야 합니다.
이런 문제는 실제 개발 현장에서 매우 중요합니다. 인터럽트를 제대로 처리하지 못하면 시스템이 응답하지 않거나, 예외 발생 시 패닉에 빠지거나, 심지어 하드웨어 신호를 놓쳐버릴 수 있습니다.
모든 현대 운영체제는 인터럽트를 효율적으로 처리하는 메커니즘을 가지고 있습니다. 바로 이럴 때 필요한 것이 인터럽트 디스크립터 테이블(IDT)입니다.
IDT는 각 인터럽트나 예외가 발생했을 때 어떤 코드를 실행해야 하는지를 정의하는 일종의 점프 테이블입니다.
개요
간단히 말해서, IDT는 256개의 항목을 가진 테이블로, 각 항목은 특정 인터럽트나 예외가 발생했을 때 실행될 핸들러 함수의 주소를 담고 있습니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, CPU는 인터럽트 번호만 알고 있을 뿐 어디로 점프해야 하는지는 모릅니다.
IDT가 바로 이 매핑 정보를 제공합니다. 예를 들어, 0번은 Division by Zero 예외, 14번은 Page Fault, 32번 이상은 하드웨어 인터럽트, 128번(0x80)은 전통적인 리눅스 시스템 콜 인터럽트로 사용됩니다.
전통적인 방법과의 비교를 하자면, 기존에는 고정된 메모리 주소에 인터럽트 벡터가 있었다면, 보호 모드 이후로는 IDT를 사용하여 유연하고 보호된 방식으로 인터럽트를 처리할 수 있습니다. IDT의 핵심 특징은 다음과 같습니다: (1) 각 엔트리는 16바이트 크기의 구조체, (2) DPL(Descriptor Privilege Level) 설정으로 유저 모드에서 호출 가능 여부 제어, (3) IST(Interrupt Stack Table) 인덱스로 별도 스택 사용 가능.
이러한 특징들이 안전하고 격리된 인터럽트 처리를 가능하게 합니다.
코드 예제
// IDT 엔트리 구조체 정의
#[repr(C, packed)]
struct IdtEntry {
offset_low: u16, // 핸들러 함수 주소의 하위 16비트
selector: u16, // 코드 세그먼트 셀렉터 (커널 코드 세그먼트)
ist: u8, // Interrupt Stack Table 인덱스 (0~7)
type_attr: u8, // 타입과 속성 (P, DPL, Gate Type)
offset_mid: u16, // 핸들러 함수 주소의 중간 16비트
offset_high: u32, // 핸들러 함수 주소의 상위 32비트
reserved: u32, // 예약됨 (0으로 설정)
}
impl IdtEntry {
// 새로운 IDT 엔트리 생성
fn new(handler: unsafe extern "C" fn(), selector: u16, dpl: u8) -> Self {
let addr = handler as u64;
IdtEntry {
offset_low: (addr & 0xFFFF) as u16,
selector,
ist: 0, // 기본 스택 사용
type_attr: 0x8E | ((dpl & 0x3) << 5), // Present, DPL, Interrupt Gate
offset_mid: ((addr >> 16) & 0xFFFF) as u16,
offset_high: ((addr >> 32) & 0xFFFFFFFF) as u32,
reserved: 0,
}
}
}
// IDT 테이블 (256개 엔트리)
static mut IDT: [IdtEntry; 256] = [IdtEntry {
offset_low: 0, selector: 0, ist: 0, type_attr: 0,
offset_mid: 0, offset_high: 0, reserved: 0
}; 256];
설명
이것이 하는 일은 CPU가 인터럽트나 예외를 만났을 때 어디로 점프해야 하는지 알려주는 것입니다. CPU는 IDTR 레지스터에 저장된 주소를 통해 IDT를 찾고, 인터럽트 번호를 인덱스로 사용하여 해당 핸들러를 찾습니다.
첫 번째로, IdtEntry 구조체는 x86_64 아키텍처의 정확한 레이아웃을 따라야 하므로 #[repr(C, packed)]를 사용합니다. 핸들러 함수의 64비트 주소는 세 부분(low 16비트, mid 16비트, high 32비트)으로 나뉘어 저장됩니다.
이렇게 분할된 이유는 하위 호환성과 추가 메타데이터 공간 확보를 위해서입니다. 그 다음으로, type_attr 필드가 핵심적인 제어 정보를 담고 있습니다.
0x8E는 Present bit(1), DPL=0(커널 전용), Type=14(Interrupt Gate)를 의미합니다. DPL을 3으로 설정하면 유저 모드에서도 int 명령어로 해당 인터럽트를 호출할 수 있어, 시스템 콜 구현에 사용됩니다.
Interrupt Gate는 인터럽트 발생 시 자동으로 IF 플래그를 클리어하여 중첩 인터럽트를 방지합니다. 마지막으로, IST(Interrupt Stack Table) 필드는 특정 인터럽트에 대해 별도의 스택을 사용할 수 있게 합니다.
예를 들어 Double Fault나 NMI(Non-Maskable Interrupt) 같은 중요한 예외는 현재 스택이 손상되었을 수 있으므로 별도 스택을 사용해야 안전합니다. IST 인덱스 1~7을 지정하면 TSS(Task State Segment)에 정의된 해당 스택으로 전환됩니다.
여러분이 이 코드를 사용하면 운영체제의 인터럽트 처리 메커니즘을 완전히 제어할 수 있습니다. 실무에서의 이점으로는 (1) 커스텀 예외 핸들러 구현으로 더 나은 에러 메시지 제공, (2) 성능 모니터링을 위한 타이머 인터럽트 활용, (3) 하드웨어 장치 드라이버 구현 가능 등이 있습니다.
실전 팁
💡 IDT를 로드하기 전에 반드시 모든 엔트리를 초기화해야 합니다. 초기화되지 않은 엔트리로 인터럽트가 발생하면 Triple Fault가 발생하여 시스템이 리부팅됩니다.
💡 Rust에서 #[repr(C, packed)]를 사용할 때는 메모리 정렬이 보장되지 않으므로, 필드 접근 시 주의해야 합니다. 가능하면 필드 전체를 읽어서 로컬 변수에 복사한 후 사용하세요.
💡 x86_64에서 IDT를 로드하려면 lidt 명령어를 사용하며, IDTR에는 테이블의 크기(limit)와 주소(base)를 설정해야 합니다.
💡 커널 개발 시 Double Fault(#8) 핸들러는 반드시 별도 스택(IST)을 사용하도록 설정하세요. 스택 오버플로우로 인한 Page Fault가 다시 스택 오버플로우를 일으키는 무한 루프를 방지할 수 있습니다.
💡 x86-interrupt calling convention을 사용하면 Rust에서 인터럽트 핸들러를 안전하게 작성할 수 있습니다: extern "x86-interrupt" fn handler(stack_frame: InterruptStackFrame)
3. 시스템 콜 핸들러 구현 - 요청을 처리하는 커널 함수
시작하며
여러분이 시스템 콜을 통해 커널로 진입했을 때, 커널은 무엇을 해야 할까요? 단순히 권한만 높아진 것이 아니라, 실제로 유저가 요청한 작업(파일 읽기, 메모리 할당, 프로세스 생성 등)을 수행해야 합니다.
이런 문제는 실제 개발 현장에서 매우 복잡합니다. 각 시스템 콜은 다른 인자를 받고, 다른 작업을 수행하며, 다른 에러를 반환할 수 있습니다.
리눅스 커널에는 300개가 넘는 시스템 콜이 있으며, 각각이 정교하게 구현되어 있습니다. 바로 이럴 때 필요한 것이 시스템 콜 핸들러입니다.
핸들러는 시스템 콜 번호를 확인하고, 적절한 커널 함수로 디스패치하며, 결과를 유저 프로그램에 반환하는 중앙 제어 포인트입니다.
개요
간단히 말해서, 시스템 콜 핸들러는 syscall 명령어로 커널에 진입한 직후 실행되는 함수로, 요청된 시스템 콜을 식별하고 실행하는 디스패처 역할을 합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, CPU는 syscall 명령어 실행 시 MSR_LSTAR 레지스터에 저장된 주소로 점프합니다.
이 주소가 바로 시스템 콜 핸들러의 시작점입니다. 예를 들어, read(), write(), open() 같은 수백 개의 시스템 콜이 모두 이 하나의 진입점을 통과하므로, 효율적인 디스패치 메커니즘이 필수적입니다.
전통적인 방법과의 비교를 하자면, 기존 x86에서는 int 0x80 인터럽트를 사용했고 IDT를 통해 핸들러를 호출했다면, x86_64에서는 syscall/sysret 명령어를 사용하여 훨씬 빠르게 전환할 수 있습니다. 시스템 콜 핸들러의 핵심 특징은 다음과 같습니다: (1) 레지스터 보존 - 유저 모드 레지스터를 스택에 저장, (2) 파라미터 검증 - 유저가 전달한 포인터나 값의 유효성 검사, (3) 에러 처리 - 음수 반환값으로 에러 코드 전달.
이러한 특징들이 안전하고 신뢰할 수 있는 시스템 콜 인터페이스를 만듭니다.
코드 예제
// 시스템 콜 핸들러 엔트리 포인트
#[naked]
unsafe extern "C" fn syscall_handler() {
asm!(
// 유저 스택 포인터를 커널 스택에 저장
"swapgs", // GS 레지스터를 커널용으로 교체
"mov gs:[0], rsp", // 유저 RSP를 per-CPU 영역에 저장
"mov rsp, gs:[8]", // 커널 RSP를 로드
// 레지스터 저장 (System V ABI 준수)
"push rcx", // 반환 주소 (syscall 다음 명령어)
"push r11", // 유저 RFLAGS
"push rbp",
"push rbx",
"push r12",
"push r13",
"push r14",
"push r15",
// 시스템 콜 디스패처 호출
"mov rdi, rax", // 첫 번째 인자: syscall 번호
"call {}", // do_syscall 함수 호출
// 레지스터 복원
"pop r15",
"pop r14",
"pop r13",
"pop r12",
"pop rbx",
"pop rbp",
"pop r11",
"pop rcx",
// 유저 모드로 복귀
"swapgs",
"sysretq",
sym do_syscall,
options(noreturn)
);
}
// 시스템 콜 디스패처
extern "C" fn do_syscall(nr: u64, arg1: u64, arg2: u64, arg3: u64) -> i64 {
match nr {
1 => sys_write(arg1, arg2 as *const u8, arg3),
60 => sys_exit(arg1 as i32),
_ => -38, // ENOSYS: 구현되지 않은 시스템 콜
}
}
설명
이것이 하는 일은 유저 모드에서 커널 모드로 안전하게 전환하고, 요청된 시스템 콜을 실행한 후, 다시 유저 모드로 돌아가는 전체 프로세스를 관리하는 것입니다. 첫 번째로, swapgs 명령어가 실행되어 GS 레지스터의 베이스 주소를 교체합니다.
x86_64에서는 각 CPU 코어마다 per-CPU 데이터를 GS 레지스터로 접근하는데, 유저 모드와 커널 모드가 다른 GS 베이스를 사용합니다. 이렇게 하는 이유는 커널이 현재 CPU의 데이터(현재 태스크, 커널 스택 등)에 빠르게 접근하기 위해서입니다.
유저 RSP를 저장하고 커널 RSP로 전환하는 것은 유저 스택이 신뢰할 수 없기 때문입니다(공격자가 스택 포인터를 조작했을 수 있음). 그 다음으로, 모든 callee-saved 레지스터를 스택에 push합니다.
System V ABI에 따르면 함수 호출 시 RBX, RBP, R12~R15는 호출된 함수가 보존해야 합니다. RCX와 R11도 저장하는데, 이들은 syscall 명령어가 각각 반환 주소(RIP)와 RFLAGS를 저장한 레지스터이므로 sysret 시 필요합니다.
이 과정을 거쳐야 시스템 콜이 완료된 후 유저 프로그램이 정확히 이전 상태로 돌아갈 수 있습니다. 마지막으로, do_syscall 함수가 실제 비즈니스 로직을 처리합니다.
시스템 콜 번호(RAX)를 첫 번째 인자로 받아 match 문으로 분기합니다. 각 시스템 콜 구현 함수(sys_write, sys_exit 등)는 파라미터를 검증하고, 필요한 작업을 수행한 후, 결과를 반환합니다.
반환값은 RAX에 저장되며, 음수는 에러(-ENOSYS = -38은 "구현되지 않음")를 의미합니다. sysretq 명령어가 실행되면 RCX의 값을 RIP로, R11의 값을 RFLAGS로 복원하고, 권한 레벨을 Ring 0에서 Ring 3으로 내려 유저 모드로 복귀합니다.
여러분이 이 코드를 사용하면 완전히 동작하는 시스템 콜 메커니즘을 구현할 수 있습니다. 실무에서의 이점으로는 (1) 보안 - 모든 유저 입력을 한 곳에서 검증, (2) 성능 - syscall/sysret은 int/iret보다 훨씬 빠름, (3) 확장성 - 새로운 시스템 콜을 쉽게 추가 가능 등이 있습니다.
실전 팁
💡 #[naked] 속성을 사용한 함수는 프롤로그/에필로그가 생성되지 않으므로, 모든 레지스터 관리를 직접 해야 합니다. 잘못하면 스택이 깨져서 디버깅이 매우 어려워집니다.
💡 유저 공간에서 받은 포인터는 절대 신뢰하면 안 됩니다. 커널 메모리를 가리키는지, NULL인지, 정렬되었는지 등을 반드시 검증해야 합니다. copy_from_user 같은 안전한 함수를 사용하세요.
💡 MSR_LSTAR, MSR_STAR, MSR_FMASK 레지스터를 올바르게 설정해야 syscall이 제대로 작동합니다. 부트 시 한 번 설정하면 됩니다.
💡 성능을 위해 시스템 콜 테이블을 배열로 만들어 함수 포인터를 저장하면, match 문 대신 직접 인덱싱으로 더 빠르게 디스패치할 수 있습니다.
💡 시스템 콜 핸들러에서는 절대 패닉하면 안 됩니다. 모든 에러는 적절한 에러 코드로 반환되어야 하며, 커널이 절대 크래시해서는 안 됩니다.
4. 파라미터 전달과 검증 - 안전한 데이터 교환
시작하며
여러분이 시스템 콜을 통해 파일 경로를 커널에 전달할 때, 그 문자열은 어디에 있을까요? 유저 메모리 공간입니다.
커널이 이 포인터를 그대로 역참조하면 어떻게 될까요? 공격자가 커널 메모리 주소를 넘겨서 민감한 정보를 읽거나 수정할 수 있습니다.
이런 문제는 실제 개발 현장에서 치명적인 보안 취약점으로 이어집니다. 2019년 발견된 CVE-2019-8956은 리눅스 커널의 부적절한 포인터 검증으로 인한 권한 상승 취약점이었습니다.
유저 공간에서 받은 데이터를 검증 없이 사용하면 시스템 전체가 위험해집니다. 바로 이럴 때 필요한 것이 파라미터 검증입니다.
커널은 유저로부터 받은 모든 데이터를 신뢰할 수 없는 것으로 간주하고, 철저히 검증한 후에만 사용해야 합니다.
개요
간단히 말해서, 파라미터 검증은 유저 공간에서 전달된 모든 값, 포인터, 버퍼가 안전하고 유효한지 확인하는 프로세스입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 커널 메모리와 유저 메모리는 분리되어 있으며, 페이지 테이블을 통해 접근 권한이 제어됩니다.
유저가 전달한 주소가 (1) 커널 영역인지, (2) 매핑되지 않은 영역인지, (3) 쓰기 권한이 있는지 등을 확인해야 합니다. 예를 들어, read() 시스템 콜에서 버퍼가 읽기 전용 메모리를 가리킨다면 에러를 반환해야 합니다.
전통적인 방법과의 비교를 하자면, 기존에는 단순히 포인터 범위만 확인했다면, 현대 커널은 SMAP(Supervisor Mode Access Prevention)이나 SMEP(Supervisor Mode Execution Prevention) 같은 하드웨어 기능을 활용하여 더 강력하게 보호합니다. 파라미터 검증의 핵심 특징은 다음과 같습니다: (1) 주소 범위 검증 - 유저 공간 범위 내에 있는지, (2) 페이지 폴트 처리 - 접근 시 발생할 수 있는 예외 처리, (3) TOCTOU 회피 - 검증과 사용 사이의 경쟁 조건 방지.
이러한 특징들이 견고한 보안을 제공합니다.
코드 예제
// 유저 공간 주소 검증
const USER_SPACE_END: u64 = 0x0000_7FFF_FFFF_FFFF;
// 유저 공간 포인터가 유효한지 검증
fn is_user_address(addr: u64, len: u64) -> bool {
// NULL 포인터 체크
if addr == 0 {
return false;
}
// 오버플로우 체크
let end = match addr.checked_add(len) {
Some(e) => e,
None => return false,
};
// 유저 공간 범위 내인지 확인
end <= USER_SPACE_END
}
// 유저 공간에서 안전하게 데이터 복사
fn copy_from_user(dst: &mut [u8], src: *const u8, len: usize) -> Result<(), i32> {
// 주소 검증
if !is_user_address(src as u64, len as u64) {
return Err(-14); // EFAULT: Bad address
}
// 버퍼 크기 확인
if dst.len() < len {
return Err(-22); // EINVAL: Invalid argument
}
// 페이지 폴트를 처리할 수 있도록 안전하게 복사
unsafe {
// 실제 구현에서는 페이지 폴트 핸들러와 협력
for i in 0..len {
// 각 바이트 접근 시 페이지 폴트 발생 가능
match try_read_user_byte(src.add(i)) {
Some(byte) => dst[i] = byte,
None => return Err(-14), // 접근 불가능한 메모리
}
}
}
Ok(())
}
// 유저 메모리에서 1바이트를 안전하게 읽기
unsafe fn try_read_user_byte(addr: *const u8) -> Option<u8> {
// 페이지 폴트 처리와 함께 읽기 시도
// 실제로는 inline assembly와 예외 처리 테이블 사용
Some(*addr) // 단순화된 버전
}
설명
이것이 하는 일은 유저 공간과 커널 공간 사이의 데이터 전달을 안전하게 만드는 것입니다. 단순히 포인터를 역참조하는 대신, 여러 단계의 검증을 거칩니다.
첫 번째로, is_user_address 함수는 기본적인 주소 유효성을 검사합니다. NULL 포인터는 명백히 잘못된 것이므로 즉시 거부합니다.
checked_add를 사용하는 이유는 공격자가 큰 주소와 큰 길이를 전달하여 오버플로우를 일으키는 공격을 방지하기 위해서입니다. 예를 들어, addr=0x7FFFFFFF_FFFFFFFF, len=2를 전달하면 일반 덧셈은 오버플로우되어 작은 값이 되지만, checked_add는 None을 반환합니다.
마지막으로 유저 공간의 최대 주소(x86_64에서는 보통 0x7FFF_FFFF_FFFF)를 넘는지 확인합니다. 그 다음으로, copy_from_user 함수는 실제로 데이터를 복사합니다.
하지만 단순한 memcpy와 다른 점은 페이지 폴트를 처리할 수 있다는 것입니다. 유저가 전달한 주소는 유효한 범위 내에 있더라도, 실제로 매핑되지 않았거나 접근 권한이 없을 수 있습니다.
이 경우 페이지 폴트가 발생하는데, 커널은 이를 포착하여 EFAULT 에러를 반환해야 합니다. 바이트 단위로 복사하는 것은 예제를 위한 것이고, 실제로는 최적화된 블록 복사를 사용하되 예외 처리 테이블을 설정합니다.
마지막으로, 이러한 검증은 TOCTOU(Time-Of-Check-Time-Of-Use) 공격을 고려해야 합니다. 검증 시점과 사용 시점 사이에 다른 스레드가 메모리를 수정할 수 있기 때문입니다.
따라서 중요한 값은 커널 메모리로 복사한 후 사용하며, 동일한 유저 메모리를 여러 번 읽지 않습니다. copy_from_user는 한 번의 복사로 데이터를 커널 공간으로 가져와 이 문제를 해결합니다.
여러분이 이 코드를 사용하면 유저 입력으로 인한 보안 취약점을 크게 줄일 수 있습니다. 실무에서의 이점으로는 (1) 커널 크래시 방지 - 잘못된 포인터로 인한 패닉 없음, (2) 권한 상승 방지 - 커널 메모리 접근 차단, (3) 데이터 무결성 - TOCTOU 공격 방어 등이 있습니다.
실전 팁
💡 리눅스 커널의 copy_from_user, copy_to_user, get_user, put_user 같은 매크로를 참고하여 구현하세요. 이들은 수년간 검증된 안전한 구현입니다.
💡 SMAP(Supervisor Mode Access Prevention)이 활성화된 CPU에서는 커널이 유저 메모리를 직접 접근하면 페이지 폴트가 발생합니다. STAC/CLAC 명령어로 일시적으로 비활성화해야 합니다.
💡 문자열을 복사할 때는 최대 길이를 설정하여 무한히 긴 문자열로 인한 DoS를 방지하세요. strncpy_from_user 같은 함수를 사용하세요.
💡 페이지 폴트 핸들러에서 특정 코드 주소(copy_from_user 내부)에서 발생한 폴트는 에러로 처리하도록 예외 테이블(.fixup 섹션)을 설정해야 합니다.
💡 Rust의 타입 시스템을 활용하여 UserPtr<T> 같은 타입을 만들면, 컴파일 타임에 유저 포인터와 커널 포인터를 구분할 수 있어 실수를 방지할 수 있습니다.
5. 시스템 콜 구현 예제 - write 시스템 콜
시작하며
여러분이 화면에 "Hello, World!"를 출력할 때, println! 매크로는 내부적으로 무엇을 할까요?
결국 write() 시스템 콜을 호출합니다. 이것은 가장 기본적이면서도 중요한 시스템 콜 중 하나입니다.
이런 문제는 실제 개발 현장에서 매우 흔합니다. 파일, 소켓, 파이프, 터미널 등 모든 출력이 write를 통해 이루어지므로, 이 시스템 콜을 제대로 구현하지 못하면 어떤 프로그램도 동작할 수 없습니다.
성능, 보안, 정확성 모두 중요합니다. 바로 이럴 때 필요한 것이 잘 설계된 write 시스템 콜 구현입니다.
파일 디스크립터를 검증하고, 버퍼를 안전하게 읽고, 실제 쓰기를 수행하고, 정확한 결과를 반환해야 합니다.
개요
간단히 말해서, write 시스템 콜은 파일 디스크립터(fd)에 지정된 버퍼(buf)의 데이터를 count 바이트만큼 쓰는 함수입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 모든 I/O 작업의 기본이 되기 때문입니다.
read, open, close와 함께 UNIX의 4대 기본 시스템 콜입니다. 예를 들어, 웹 서버가 클라이언트에게 응답을 보낼 때, 데이터베이스가 로그를 기록할 때, 모두 write를 사용합니다.
전통적인 방법과의 비교를 하자면, 기존에는 각 장치마다 다른 함수를 호출했다면, UNIX 철학은 "모든 것은 파일이다"라는 추상화를 통해 통일된 인터페이스를 제공합니다. write 시스템 콜의 핵심 특징은 다음과 같습니다: (1) 파일 디스크립터 검증 - 유효한 fd인지, 쓰기 권한이 있는지, (2) 부분 쓰기 지원 - 요청한 바이트보다 적게 쓸 수 있음, (3) 논블로킹 모드 - O_NONBLOCK 플래그 시 즉시 반환.
이러한 특징들이 유연하고 효율적인 I/O를 가능하게 합니다.
코드 예제
// 파일 디스크립터 테이블에서 파일 객체를 가져오는 함수
fn get_file(fd: u64) -> Option<&'static mut File> {
// 현재 프로세스의 파일 디스크립터 테이블 조회
// 실제로는 per-process 데이터 구조에서 가져옴
unsafe { CURRENT_PROCESS.files.get_mut(fd as usize) }
}
// write 시스템 콜 구현
fn sys_write(fd: u64, buf: *const u8, count: u64) -> i64 {
// 1. 파일 디스크립터 검증
let file = match get_file(fd) {
Some(f) => f,
None => return -9, // EBADF: Bad file descriptor
};
// 2. 쓰기 권한 확인
if !file.writable {
return -9; // EBADF: File not opened for writing
}
// 3. 버퍼를 커널 공간으로 복사
let mut kernel_buf = vec![0u8; count as usize];
if let Err(e) = copy_from_user(&mut kernel_buf, buf, count as usize) {
return e as i64;
}
// 4. 실제 쓰기 수행 (파일 타입에 따라 다름)
match file.write(&kernel_buf) {
Ok(bytes_written) => bytes_written as i64,
Err(errno) => -errno as i64,
}
}
// File 구조체 (간소화된 버전)
struct File {
writable: bool,
offset: u64,
// VFS 레이어를 통한 실제 파일 시스템 연결
}
impl File {
fn write(&mut self, data: &[u8]) -> Result<usize, i32> {
// 실제로는 VFS -> 파일시스템 -> 블록 디바이스로 이어짐
// 여기서는 콘솔 출력으로 단순화
for &byte in data {
console_putc(byte);
}
Ok(data.len())
}
}
설명
이것이 하는 일은 유저 프로그램이 요청한 데이터를 안전하고 올바르게 파일이나 장치에 기록하는 것입니다. 여러 단계의 검증과 추상화를 거칩니다.
첫 번째로, 파일 디스크립터 검증이 이루어집니다. 각 프로세스는 고유한 파일 디스크립터 테이블을 가지고 있으며, 0~1023 정도의 범위를 사용합니다.
fd가 이 범위를 벗어나거나, 해당 인덱스에 파일이 없으면 EBADF를 반환합니다. 또한 파일이 열렸을 때 지정된 모드(O_RDONLY, O_WRONLY, O_RDWR)를 확인하여 쓰기 권한이 있는지 검사합니다.
읽기 전용으로 열린 파일에 쓰려고 하면 에러를 반환해야 합니다. 그 다음으로, 유저 버퍼를 커널 메모리로 복사합니다.
이것은 두 가지 이유에서 중요합니다: (1) 보안 - 유저 포인터의 유효성 검증, (2) TOCTOU 방지 - 복사 후에는 유저가 수정할 수 없음. copy_from_user는 앞서 설명한 검증 로직을 모두 수행합니다.
대용량 데이터의 경우 성능을 위해 페이지 단위로 처리하거나, zero-copy 기법을 사용할 수도 있습니다. 마지막으로, 실제 쓰기가 수행됩니다.
File 구조체는 VFS(Virtual File System) 추상화를 나타내며, 내부적으로 실제 파일 시스템(ext4, XFS 등)이나 장치 드라이버(콘솔, 네트워크 소켓 등)로 연결됩니다. write 메서드는 파일 타입에 따라 다르게 동작하며, 정규 파일이면 디스크에 쓰고, 소켓이면 네트워크로 전송하고, 파이프면 다른 프로세스에 전달합니다.
반환값은 실제로 쓴 바이트 수로, count보다 작을 수 있습니다(디스크 풀, 시그널 인터럽트 등). 에러 발생 시 음수 에러 코드를 반환합니다.
여러분이 이 코드를 사용하면 완전히 동작하는 파일 I/O 시스템을 구축할 수 있습니다. 실무에서의 이점으로는 (1) 범용성 - 파일, 소켓, 파이프 모두 동일한 인터페이스, (2) 안전성 - 철저한 검증으로 보안 유지, (3) 확장성 - VFS 레이어로 새로운 파일 시스템 추가 용이 등이 있습니다.
실전 팁
💡 write는 부분 쓰기를 허용하므로, 유저 프로그램은 반환값을 확인하고 남은 데이터를 다시 write해야 합니다. 루프로 구현하세요.
💡 EINTR(시그널로 인한 중단)을 처리해야 합니다. 느린 I/O(터미널, 소켓) 중 시그널이 오면 write가 중단될 수 있습니다.
💡 O_APPEND 플래그가 설정된 파일은 항상 파일 끝에 쓰므로, offset을 무시하고 파일 크기로 이동해야 합니다.
💡 성능을 위해 페이지 캐시(page cache)를 사용하면, write는 즉시 반환하고 실제 디스크 쓰기는 나중에 발생합니다. fsync로 강제로 동기화할 수 있습니다.
💡 Rust에서 Vec 할당 대신 스택 버퍼나 per-CPU 버퍼를 재사용하면 힙 할당 오버헤드를 줄일 수 있습니다.
6. 시스템 콜 테이블 - 효율적인 디스패치
시작하며
여러분이 300개가 넘는 시스템 콜을 match 문으로 처리한다고 상상해보세요. 컴파일러가 최적화하더라도, 코드가 매우 길고 유지보수하기 어려울 것입니다.
더 나은 방법은 없을까요? 이런 문제는 실제 개발 현장에서 자주 발생합니다.
시스템 콜 수가 증가할수록 디스패치 코드가 복잡해지고, 새로운 시스템 콜을 추가하기도 어려워집니다. 성능도 중요한데, 시스템 콜은 초당 수백만 번 호출될 수 있습니다.
바로 이럴 때 필요한 것이 시스템 콜 테이블입니다. 함수 포인터 배열을 사용하여 O(1) 시간에 올바른 핸들러를 찾을 수 있습니다.
개요
간단히 말해서, 시스템 콜 테이블은 시스템 콜 번호를 인덱스로 사용하여 해당 핸들러 함수 포인터를 저장한 배열입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 배열 인덱싱은 가장 빠른 조회 방법 중 하나입니다.
시스템 콜 번호가 0~335라면, 336개 요소의 배열을 만들고 syscall_table[nr]로 직접 함수를 호출할 수 있습니다. 예를 들어, 리눅스 커널의 arch/x86/entry/syscall_64.c를 보면 정확히 이 방식을 사용합니다.
전통적인 방법과의 비교를 하자면, 기존에는 거대한 switch 문을 사용했다면, 테이블 기반 방식은 더 간결하고 확장 가능하며 빠릅니다. 시스템 콜 테이블의 핵심 특징은 다음과 같습니다: (1) O(1) 조회 시간 - 단일 배열 접근, (2) 간결한 코드 - 수백 줄의 match 문 대신 배열 정의, (3) 쉬운 확장 - 새 시스템 콜은 배열에 함수 포인터만 추가.
이러한 특징들이 효율적이고 유지보수하기 쉬운 코드를 만듭니다.
코드 예제
// 시스템 콜 핸들러 타입 정의
type SyscallHandler = fn(u64, u64, u64, u64, u64, u64) -> i64;
// 존재하지 않는 시스템 콜 핸들러
fn sys_not_implemented(_: u64, _: u64, _: u64, _: u64, _: u64, _: u64) -> i64 {
-38 // ENOSYS: Function not implemented
}
// 시스템 콜 테이블 정의
static SYSCALL_TABLE: [SyscallHandler; 512] = {
let mut table = [sys_not_implemented as SyscallHandler; 512];
// 각 시스템 콜 번호에 핸들러 할당
table[0] = sys_read as SyscallHandler;
table[1] = sys_write as SyscallHandler;
table[2] = sys_open as SyscallHandler;
table[3] = sys_close as SyscallHandler;
table[4] = sys_stat as SyscallHandler;
table[5] = sys_fstat as SyscallHandler;
// ... 나머지 시스템 콜들
table[39] = sys_getpid as SyscallHandler;
table[57] = sys_fork as SyscallHandler;
table[60] = sys_exit as SyscallHandler;
table
};
// 최적화된 시스템 콜 디스패처
extern "C" fn do_syscall(
nr: u64,
arg1: u64,
arg2: u64,
arg3: u64,
arg4: u64,
arg5: u64,
arg6: u64,
) -> i64 {
// 범위 검증
if nr >= SYSCALL_TABLE.len() as u64 {
return -38; // ENOSYS
}
// 테이블에서 핸들러 조회 및 호출
let handler = SYSCALL_TABLE[nr as usize];
handler(arg1, arg2, arg3, arg4, arg5, arg6)
}
설명
이것이 하는 일은 시스템 콜 번호를 효율적으로 실제 구현 함수로 변환하는 것입니다. 배열 기반 접근은 가장 빠르고 간단한 방법입니다.
첫 번째로, SyscallHandler 타입을 정의하여 모든 시스템 콜 핸들러가 동일한 시그니처를 갖도록 강제합니다. 6개의 u64 인자를 받고 i64를 반환하는데, 이것은 x86_64 ABI가 최대 6개의 레지스터(rdi, rsi, rdx, r10, r8, r9)로 인자를 전달하기 때문입니다.
더 많은 인자가 필요한 시스템 콜은 구조체 포인터를 전달합니다. 반환 타입이 i64인 이유는 음수 에러 코드를 표현하기 위해서입니다.
그 다음으로, SYSCALL_TABLE을 초기화합니다. 512개 요소를 가진 배열을 만들고, 모든 요소를 sys_not_implemented로 초기화합니다.
이렇게 하면 정의되지 않은 시스템 콜 번호가 호출되어도 안전하게 ENOSYS를 반환할 수 있습니다. 그 후 실제로 구현된 시스템 콜들을 해당 번호 위치에 할당합니다.
리눅스 시스템 콜 번호는 표준화되어 있으므로(예: write=1, exit=60), 정확한 인덱스에 배치해야 합니다. 마지막으로, do_syscall 함수는 단순히 범위를 검증하고 테이블에서 함수 포인터를 가져와 호출합니다.
이 코드는 매우 빠른데, (1) 배열 접근은 단일 메모리 읽기, (2) 간접 호출은 분기 예측기가 잘 처리, (3) match 문의 여러 비교 없이 바로 점프. 컴파일러는 이것을 매우 효율적인 기계어로 변환할 수 있습니다.
여러분이 이 코드를 사용하면 리눅스 커널과 동일한 수준의 효율적인 시스템 콜 디스패치를 구현할 수 있습니다. 실무에서의 이점으로는 (1) 성능 - 최소 오버헤드로 핸들러 호출, (2) 가독성 - 테이블 정의만으로 모든 시스템 콜 한눈에 파악, (3) 유지보수성 - 새 시스템 콜 추가가 한 줄로 가능 등이 있습니다.
실전 팁
💡 const fn을 활용하면 컴파일 타임에 테이블을 구성할 수 있어 런타임 초기화가 필요 없습니다.
💡 매크로를 사용하여 시스템 콜 정의를 자동화할 수 있습니다. syscall_define!(1, sys_write) 같은 형태로 간결하게 작성하세요.
💡 32비트와 64비트 호환성을 위해 두 개의 테이블(ia32_sys_call_table, sys_call_table)을 유지할 수 있습니다.
💡 보안을 위해 시스템 콜 테이블을 읽기 전용 메모리(.rodata 섹션)에 배치하면, 런타임에 수정할 수 없어 공격을 방어할 수 있습니다.
💡 auditing이나 tracing을 위해 테이블 진입 전에 훅을 추가할 수 있습니다. eBPF나 ftrace가 이 방식을 사용합니다.
7. 컨텍스트 저장과 복원 - 완벽한 상태 보존
시작하며
여러분이 시스템 콜을 호출했을 때, 돌아온 후 모든 변수가 제자리에 있을 거라고 기대하시나요? 당연합니다!
하지만 커널은 내부적으로 수많은 레지스터를 사용합니다. 유저 프로그램의 레지스터 값을 보존하지 않으면 어떻게 될까요?
이런 문제는 실제 개발 현장에서 치명적입니다. 시스템 콜 후 R12 레지스터의 값이 바뀌어 있다면, 루프 카운터가 깨지고 프로그램이 오작동합니다.
디버깅도 거의 불가능할 것입니다. 완벽한 컨텍스트 보존이 필수입니다.
바로 이럴 때 필요한 것이 체계적인 컨텍스트 저장과 복원입니다. 시스템 콜 진입 시 모든 중요한 레지스터를 저장하고, 복귀 시 정확히 복원해야 합니다.
개요
간단히 말해서, 컨텍스트 저장은 CPU의 모든 범용 레지스터, 플래그 레지스터, 프로그램 카운터를 스택이나 메모리에 보관하는 작업입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, CPU는 한정된 레지스터를 여러 목적으로 재사용합니다.
유저 프로그램은 지역 변수, 루프 카운터, 함수 인자 등을 레지스터에 저장하는데, 커널도 동일한 레지스터를 사용합니다. 예를 들어, RAX는 유저 코드에서 계산 결과를 담고 있었지만, 시스템 콜에서는 반환값을 담아 돌아옵니다.
나머지 레지스터들은 원래 값이 유지되어야 합니다. 전통적인 방법과의 비교를 하자면, 기존에는 모든 레지스터를 무조건 저장했다면, 현대 시스템은 calling convention에 따라 caller-saved와 callee-saved를 구분하여 필요한 것만 저장해 성능을 높입니다.
컨텍스트 저장의 핵심 특징은 다음과 같습니다: (1) 선택적 저장 - ABI에 따라 필수 레지스터만 저장, (2) 스택 정렬 - 16바이트 정렬 유지, (3) FPU/SIMD 상태 - 필요 시 xsave로 저장. 이러한 특징들이 효율적이면서도 안전한 컨텍스트 전환을 가능하게 합니다.
코드 예제
// 시스템 콜 진입 시 저장할 레지스터 세트
#[repr(C)]
struct SyscallFrame {
// Callee-saved 레지스터 (함수가 보존해야 함)
r15: u64,
r14: u64,
r13: u64,
r12: u64,
rbp: u64,
rbx: u64,
// Syscall 메타데이터
rip: u64, // 반환 주소 (RCX에서 복사)
rflags: u64, // 플래그 레지스터 (R11에서 복사)
// 유저 스택 포인터
rsp: u64,
// 세그먼트 레지스터 (필요 시)
cs: u64,
ss: u64,
}
// 시스템 콜 진입점에서 컨텍스트 저장
#[naked]
unsafe extern "C" fn syscall_entry() {
asm!(
// 커널 스택으로 전환
"swapgs",
"mov gs:[user_rsp_offset], rsp",
"mov rsp, gs:[kernel_rsp_offset]",
// 스택에 컨텍스트 저장
"push qword ptr gs:[user_ss]", // SS
"push qword ptr gs:[user_cs]", // CS
"push qword ptr gs:[user_rsp_offset]", // RSP
"push r11", // RFLAGS
"push rcx", // RIP
"push rbx",
"push rbp",
"push r12",
"push r13",
"push r14",
"push r15",
// 스택 정렬 (16바이트 경계)
"sub rsp, 8", // 패딩 추가 (11개 push * 8 = 88, 다음 16의 배수는 96)
// 인자는 이미 레지스터에 있음 (rax, rdi, rsi, rdx, r10, r8, r9)
"mov rdi, rax", // 첫 번째 인자: syscall 번호
// rsi, rdx, r10, r8, r9는 그대로
"mov rcx, r10", // 네 번째 인자 (r10 -> rcx, C ABI 준수)
// 시스템 콜 핸들러 호출
"call do_syscall",
// 스택 정렬 패딩 제거
"add rsp, 8",
// 컨텍스트 복원
"pop r15",
"pop r14",
"pop r13",
"pop r12",
"pop rbp",
"pop rbx",
"pop rcx", // RIP
"pop r11", // RFLAGS
"add rsp, 24", // RSP, CS, SS 건너뛰기
// 유저 모드로 복귀
"swapgs",
"sysretq",
options(noreturn)
);
}
설명
이것이 하는 일은 유저 프로그램의 실행 상태를 완벽하게 보존하여, 시스템 콜이 투명하게(transparently) 동작하도록 만드는 것입니다. 첫 번째로, swapgs와 스택 전환이 이루어집니다.
GS 레지스터를 통해 per-CPU 데이터에 접근하여 현재 유저 RSP를 저장하고, 미리 준비된 커널 스택 포인터를 로드합니다. 이것은 보안상 필수인데, 유저 스택은 신뢰할 수 없으므로 커널 작업에 사용할 수 없습니다.
공격자가 스택 포인터를 커널 메모리로 설정했을 수도 있기 때문입니다. 그 다음으로, callee-saved 레지스터들을 스택에 push합니다.
System V AMD64 ABI에 따르면 RBX, RBP, R12R15는 함수 호출 후에도 값이 유지되어야 합니다. 반대로 RAX, RCX, RDX, RSI, RDI, R8R11은 caller-saved로, 호출자가 필요하면 저장해야 합니다.
시스템 콜은 RAX를 반환값으로 사용하므로 저장하지 않지만, RCX와 R11은 syscall 명령어가 특별한 용도(RIP, RFLAGS 저장)로 사용하므로 별도로 처리합니다. 스택 정렬도 중요한데, x86_64 ABI는 call 직전 RSP가 16바이트 정렬되어야 한다고 규정하므로, 필요시 패딩을 추가합니다.
마지막으로, 복원 과정은 저장의 역순입니다. pop 명령어로 스택에서 값을 꺼내 레지스터로 복원합니다.
주의할 점은 RSP, CS, SS는 실제로 pop하지 않고 add rsp, 24로 건너뛴다는 것입니다. 왜냐하면 sysretq 명령어가 자동으로 유저 모드의 세그먼트 레지스터를 복원하고, RSP는 이미 유저 값을 유지하고 있기 때문입니다.
swapgs로 GS를 다시 유저용으로 바꾸고, sysretq로 RCX의 값을 RIP로, R11의 값을 RFLAGS로 복원하며 Ring 3으로 돌아갑니다. 여러분이 이 코드를 사용하면 완벽하게 투명한 시스템 콜을 구현할 수 있습니다.
실무에서의 이점으로는 (1) 정확성 - 유저 프로그램이 시스템 콜의 부작용을 느끼지 못함, (2) 디버깅 - 레지스터 값이 예측 가능하게 유지됨, (3) ABI 준수 - 표준 calling convention 따름 등이 있습니다.
실전 팁
💡 FPU와 SIMD 레지스터(XMM0~XMM15)는 일반적으로 저장하지 않습니다. 커널이 부동소수점 연산을 하지 않기 때문입니다. 하지만 커널에서 SIMD를 사용한다면 xsave/xrstor 명령어로 저장해야 합니다.
💡 #[naked] 함수에서는 Rust 코드를 사용할 수 없고 오직 inline assembly만 가능합니다. 모든 제어를 직접 해야 하므로 신중하게 작성하세요.
💡 디버깅을 위해 DWARF unwind 정보(.eh_frame)를 추가하면 스택 트레이스가 정확하게 출력됩니다.
💡 성능 측정 결과 syscall/sysret는 int 0x80/iret보다 약 50% 빠릅니다. 가능하면 새로운 명령어를 사용하세요.
💡 ARM64는 다른 방식을 사용합니다. svc 명령어로 시스템 콜을 호출하며, SPSR_EL1과 ELR_EL1에 상태를 저장합니다.
8. 에러 처리와 errno - 실패를 우아하게 다루기
시작하며
여러분이 존재하지 않는 파일을 열려고 하면, 프로그램이 크래시할까요? 아닙니다.
open() 함수는 -1을 반환하고, errno 변수에 ENOENT(No such file or directory)를 설정합니다. 이것이 바로 UNIX의 에러 처리 철학입니다.
이런 문제는 실제 개발 현장에서 매우 중요합니다. 견고한 시스템은 에러를 예측하고 우아하게 처리합니다.
에러 코드가 일관되지 않거나, 의미가 명확하지 않으면, 디버깅이 악몽이 됩니다. 표준화된 에러 처리가 필수입니다.
바로 이럴 때 필요한 것이 errno 메커니즘입니다. 시스템 콜은 음수 에러 코드를 반환하고, libc는 이를 errno로 변환하여 사용자 친화적인 에러 처리를 제공합니다.
개요
간단히 말해서, errno는 마지막 시스템 콜의 에러 정보를 담는 스레드 로컬 변수로, 표준화된 에러 코드(ENOENT, EACCES, ENOMEM 등)를 사용합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 함수는 하나의 값만 반환할 수 있는데, 성공 시 결과와 실패 시 에러 정보를 모두 전달해야 합니다.
errno를 사용하면 반환값은 성공/실패만 나타내고, 상세한 에러 정보는 별도로 조회할 수 있습니다. 예를 들어, read()는 읽은 바이트 수를 반환하고, 에러 시 -1을 반환하며 errno에 EBADF, EINTR, EIO 등 다양한 에러 원인을 설정합니다.
전통적인 방법과의 비교를 하자면, 기존에는 글로벌 errno 변수를 사용했다면, 멀티스레드 환경에서는 스레드 로컬 errno를 사용하여 각 스레드가 독립적으로 에러를 추적할 수 있습니다. errno의 핵심 특징은 다음과 같습니다: (1) 표준화된 에러 코드 - POSIX가 정의한 일관된 코드, (2) 스레드 로컬 - 멀티스레드에서 안전, (3) 음수 반환 - 커널은 음수로, libc는 양수 errno로 변환.
이러한 특징들이 견고한 에러 처리를 가능하게 합니다.
코드 예제
// 표준 에러 코드 정의 (POSIX 준수)
pub const EPERM: i32 = 1; // Operation not permitted
pub const ENOENT: i32 = 2; // No such file or directory
pub const ESRCH: i32 = 3; // No such process
pub const EINTR: i32 = 4; // Interrupted system call
pub const EIO: i32 = 5; // I/O error
pub const ENXIO: i32 = 6; // No such device or address
pub const E2BIG: i32 = 7; // Argument list too long
pub const ENOEXEC: i32 = 8; // Exec format error
pub const EBADF: i32 = 9; // Bad file descriptor
pub const ENOMEM: i32 = 12; // Out of memory
pub const EACCES: i32 = 13; // Permission denied
pub const EFAULT: i32 = 14; // Bad address
pub const EBUSY: i32 = 16; // Device or resource busy
pub const EEXIST: i32 = 17; // File exists
pub const EINVAL: i32 = 22; // Invalid argument
pub const ENOSYS: i32 = 38; // Function not implemented
// 커널 내부에서 사용하는 에러 반환
fn sys_open(path: *const u8, flags: u64, mode: u64) -> i64 {
// 경로 검증
if path.is_null() {
return -EFAULT as i64; // 음수로 반환
}
// 파일 존재 여부 확인
if !file_exists(path) {
return -ENOENT as i64;
}
// 권한 확인
if !has_permission(path, flags) {
return -EACCES as i64;
}
// 성공 시 파일 디스크립터 반환 (양수)
allocate_fd() as i64
}
// libc의 시스템 콜 래퍼 (유저 공간)
#[no_mangle]
pub extern "C" fn open(path: *const u8, flags: i32, mode: i32) -> i32 {
let ret = unsafe {
syscall3(SYS_OPEN, path as u64, flags as u64, mode as u64)
};
// 음수 에러를 errno로 변환
if ret < 0 {
set_errno((-ret) as i32);
return -1;
}
ret as i32
}
// 스레드 로컬 errno 설정
fn set_errno(err: i32) {
unsafe {
*(__errno_location()) = err;
}
}
// errno의 주소를 반환하는 함수 (스레드 로컬)
extern "C" {
fn __errno_location() -> *mut i32;
}
설명
이것이 하는 일은 시스템 콜의 실패 원인을 명확하고 표준화된 방식으로 전달하는 것입니다. 두 레이어(커널과 libc)가 협력하여 작동합니다.
첫 번째로, 커널 레벨에서 에러가 발생하면 음수 에러 코드를 반환합니다. 예를 들어 ENOENT는 2이므로, -2를 반환합니다.
음수를 사용하는 이유는 성공 시 반환값(파일 디스크립터, 읽은 바이트 수 등)이 0 이상이기 때문에, 부호로 성공/실패를 명확히 구분할 수 있기 때문입니다. 각 시스템 콜 구현은 발생 가능한 모든 에러 조건을 확인하고, 적절한 에러 코드를 반환해야 합니다.
sys_open의 예에서 NULL 포인터는 EFAULT, 파일 없음은 ENOENT, 권한 부족은 EACCES를 반환합니다. 그 다음으로, libc의 시스템 콜 래퍼가 커널의 반환값을 처리합니다.
반환값이 음수면 에러이므로, 절댓값을 구해서 errno에 설정합니다. errno는 __errno_location() 함수가 반환하는 주소에 위치하는데, 이 함수는 스레드 로컬 스토리지(TLS)를 사용하여 각 스레드마다 독립적인 errno를 제공합니다.
이것은 멀티스레드 환경에서 필수적인데, 스레드 A의 시스템 콜 에러가 스레드 B의 errno를 덮어쓰면 안 되기 때문입니다. 마지막으로, 사용자 프로그램은 반환값이 -1인지 확인하고, 필요시 errno를 조회하여 에러 원인을 파악합니다.
strerror(errno)로 사람이 읽을 수 있는 에러 메시지를 얻을 수도 있습니다. 중요한 점은 errno는 에러 발생 시에만 설정되므로, 성공한 시스템 콜 후에는 errno를 확인하면 안 됩니다(이전 에러가 남아있을 수 있음).
항상 반환값을 먼저 확인해야 합니다. 여러분이 이 코드를 사용하면 POSIX 호환 에러 처리를 구현할 수 있습니다.
실무에서의 이점으로는 (1) 이식성 - 모든 UNIX 시스템에서 동일한 에러 코드 사용, (2) 디버깅 - 에러 코드만으로 문제 원인 파악 가능, (3) 안전성 - 스레드 로컬 errno로 동시성 버그 방지 등이 있습니다.
실전 팁
💡 errno는 시스템 콜 실패 시에만 설정되고, 성공 시에는 0으로 초기화되지 않습니다. 따라서 반드시 반환값을 먼저 확인하세요.
💡 EINTR(Interrupted system call)은 특별히 처리해야 합니다. 느린 I/O 중 시그널이 오면 발생하는데, 보통 루프로 재시도합니다: while (ret == -1 && errno == EINTR) { ret = read(...); }
💡 Rust에서는 Result<T, Errno> 타입을 사용하여 더 안전한 에러 처리를 할 수 있습니다. nix 크레이트가 좋은 예시입니다.
💡 새로운 에러 코드를 추가할 때는 기존 번호와 충돌하지 않도록 주의하고, POSIX 표준을 참고하세요.
💡 perror() 함수는 errno에 해당하는 에러 메시지를 stderr에 출력해주어 디버깅에 매우 유용합니다.
9. vDSO와 빠른 시스템 콜 - 성능 최적화의 극치
시작하며
여러분이 현재 시간을 얻기 위해 gettimeofday()를 호출할 때마다 syscall 명령어로 커널에 진입한다면 어떨까요? 타이머가 1ms마다 현재 시간을 업데이트하는데, 매번 컨텍스트 스위칭하는 것은 엄청난 낭비입니다.
이런 문제는 실제 개발 현장에서 성능 병목이 됩니다. 고성능 트레이딩 시스템은 마이크로초 단위 지연도 중요하고, 게임 엔진은 프레임당 수천 번 시간을 조회합니다.
시스템 콜 오버헤드를 제거하면 수십 배 빨라질 수 있습니다. 바로 이럴 때 필요한 것이 vDSO(virtual Dynamic Shared Object)입니다.
커널이 읽기 전용 페이지를 유저 공간에 매핑하여, 일부 시스템 콜을 커널 진입 없이 처리할 수 있게 합니다.
개요
간단히 말해서, vDSO는 커널이 모든 프로세스의 주소 공간에 자동으로 매핑하는 작은 공유 라이브러리로, 빠른 시스템 콜 구현을 포함합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 일부 시스템 콜은 읽기만 하고 상태를 변경하지 않습니다.
예를 들어 gettimeofday(), clock_gettime(), getcpu()는 커널이 유지하는 값을 읽기만 합니다. 이 값들을 유저 공간에서 접근 가능한 메모리에 놓으면, syscall 없이 직접 읽을 수 있습니다.
전통적인 방법과의 비교를 하자면, 기존에는 모든 시스템 콜이 커널 진입을 수반했다면, vDSO는 읽기 전용 시스템 콜을 유저 공간에서 실행하여 최대 10배 이상 빠르게 만듭니다. vDSO의 핵심 특징은 다음과 같습니다: (1) 제로 오버헤드 - syscall 명령어 없이 메모리 읽기만, (2) 커널 제어 - 커널이 페이지를 업데이트하므로 항상 정확, (3) 투명성 - 애플리케이션은 일반 시스템 콜처럼 사용.
이러한 특징들이 극한의 성능을 제공합니다.
코드 예제
// vDSO 데이터 페이지 구조체 (커널이 유지)
#[repr(C)]
struct VdsoData {
sequence: AtomicU64, // Seqlock을 위한 시퀀스 번호
time_sec: u64, // 현재 시간 (초)
time_nsec: u64, // 현재 시간 (나노초)
monotonic_sec: u64, // 단조 시간 (초)
monotonic_nsec: u64, // 단조 시간 (나노초)
cpu_id: u32, // 현재 CPU ID
padding: u32,
}
// 커널이 타이머 인터럽트마다 호출하는 업데이트 함수
fn update_vdso_time(vdso: &mut VdsoData, now: TimeSpec) {
// Seqlock 프로토콜: 쓰기 시작
let seq = vdso.sequence.fetch_add(1, Ordering::Release);
// 데이터 업데이트
vdso.time_sec = now.sec;
vdso.time_nsec = now.nsec;
// Seqlock 프로토콜: 쓰기 완료
vdso.sequence.fetch_add(1, Ordering::Release);
}
// vDSO 함수: 유저 공간에서 실행 (커널 진입 없음!)
#[no_mangle]
pub extern "C" fn __vdso_clock_gettime(clockid: i32, tp: *mut TimeSpec) -> i32 {
// vDSO 데이터 페이지 주소 (커널이 매핑해줌)
let vdso = unsafe { &*(VDSO_DATA_ADDR as *const VdsoData) };
let mut seq: u64;
let mut sec: u64;
let mut nsec: u64;
// Seqlock 읽기 프로토콜 (락 없이 일관성 보장)
loop {
seq = vdso.sequence.load(Ordering::Acquire);
// 홀수면 쓰기 중이므로 대기
if seq & 1 != 0 {
core::hint::spin_loop();
continue;
}
// 데이터 읽기
sec = vdso.time_sec;
nsec = vdso.time_nsec;
// 읽기 중 쓰기가 발생했는지 확인
if seq == vdso.sequence.load(Ordering::Acquire) {
break; // 일관된 데이터를 읽었음
}
// 다시 시도
}
// 결과 반환
unsafe {
(*tp).sec = sec;
(*tp).nsec = nsec;
}
0 // 성공
}
const VDSO_DATA_ADDR: u64 = 0x7FFF_FFFF_F000; // 고정 주소 예시
#[repr(C)]
struct TimeSpec {
sec: u64,
nsec: u64,
}
설명
이것이 하는 일은 자주 호출되는 시스템 콜의 오버헤드를 거의 제로로 만드는 것입니다. 커널과 유저 공간이 메모리를 공유하는 방식으로 작동합니다.
첫 번째로, 커널은 VdsoData 구조체를 포함한 페이지를 모든 프로세스의 주소 공간에 읽기 전용으로 매핑합니다. 이 페이지는 물리 메모리에 하나만 존재하고, 모든 프로세스가 공유합니다.
커널은 타이머 인터럽트(보통 1ms~10ms마다)가 발생할 때 현재 시간을 이 페이지에 업데이트합니다. update_vdso_time 함수는 seqlock이라는 동기화 프로토콜을 사용하는데, 이것은 락을 사용하지 않고도 읽기의 일관성을 보장합니다.
그 다음으로, seqlock 프로토콜의 동작 원리를 이해해야 합니다. 쓰기 시작 시 sequence를 1 증가(홀수로 만듦), 쓰기 완료 시 다시 1 증가(짝수로 만듦)합니다.
읽는 쪽은 (1) sequence가 짝수인지 확인(쓰기 중이 아님), (2) 데이터 읽기, (3) sequence가 변경되지 않았는지 확인. 만약 sequence가 바뀌었다면 읽는 도중 쓰기가 발생한 것이므로 재시도합니다.
이 방법은 읽기가 매우 빠르고(락 획득 없음), 쓰기도 간단하며, 읽기가 압도적으로 많은 경우에 이상적입니다. 마지막으로, __vdso_clock_gettime 함수는 유저 공간 코드로 컴파일되어 vDSO 페이지 안에 위치합니다.
libc의 clock_gettime()은 먼저 vDSO 버전을 호출하려고 시도하고, 없으면 실제 시스템 콜로 폴백합니다. 유저 프로그램 관점에서는 완전히 투명하며, 단지 엄청나게 빨라진 것만 느낍니다.
벤치마크 결과 vDSO gettimeofday는 약 15ns, 일반 syscall은 150ns로 10배 차이가 납니다. 여러분이 이 코드를 사용하면 리눅스나 BSD 수준의 고성능 시스템 콜을 구현할 수 있습니다.
실무에서의 이점으로는 (1) 극한의 성능 - 초당 수백만 번 호출 가능, (2) CPU 효율 - 커널 모드 전환 없이 유저 모드에서만 실행, (3) 확장성 - 모든 코어에서 동시에 접근 가능(락 없음) 등이 있습니다.
실전 팁
💡 vDSO는 clock_gettime, gettimeofday, getcpu, time 같은 읽기 전용 시스템 콜에만 적합합니다. 쓰기를 수반하는 시스템 콜은 여전히 커널 진입이 필요합니다.
💡 Seqlock은 읽기가 매우 빠르지만, 쓰기가 빈번하면 읽기 재시도가 많아집니다. 하지만 시간 업데이트는 1ms에 한 번 정도이므로 문제없습니다.
💡 vDSO 주소는 /proc/self/maps에서 확인할 수 있습니다. [vdso]로 표시되는 영역이 바로 그것입니다.
💡 ASLR(Address Space Layout Randomization)이 활성화되면 vDSO 주소가 매번 바뀌므로, 동적으로 찾아야 합니다. auxv의 AT_SYSINFO_EHDR을 참고하세요.
💡 ARM64에는 유사한 메커니즘으로 vvar와 vdso가 있으며, clock_gettime을 커널 진입 없이 제공합니다.
10. 시스템 콜 추적과 디버깅 - 문제 해결의 열쇠
시작하며
여러분의 프로그램이 갑자기 느려졌다면, 어디서 시간을 소비하는지 어떻게 알 수 있을까요? 프로파일러는 함수 호출을 보여주지만, 시스템 콜은 블랙박스처럼 보입니다.
파일을 못 여는데 왜 실패하는지 알 수 없다면? 이런 문제는 실제 개발 현장에서 매일 발생합니다.
프로덕션 환경에서 원인 불명의 타임아웃, 파일 접근 실패, 메모리 부족 등이 발생할 때, 시스템 콜 레벨에서 무슨 일이 일어나는지 보지 못하면 해결이 거의 불가능합니다. 바로 이럴 때 필요한 것이 시스템 콜 추적(tracing)입니다.
strace, perf, eBPF 같은 도구로 프로그램이 호출하는 모든 시스템 콜을 실시간으로 모니터링하고 분석할 수 있습니다.
개요
간단히 말해서, 시스템 콜 추적은 프로세스가 호출하는 모든 시스템 콜, 그 인자, 반환값, 소요 시간을 기록하여 프로그램의 동작을 투명하게 만드는 기술입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 시스템 콜은 애플리케이션과 커널의 경계이므로, 여기서 일어나는 일을 보면 프로그램이 실제로 무엇을 하는지 정확히 알 수 있습니다.
예를 들어, 느린 웹 서버를 추적하면 poll() 시스템 콜에서 대부분의 시간을 소비한다는 것을 발견할 수 있고, 이것은 네트워크 I/O가 병목임을 의미합니다. 전통적인 방법과의 비교를 하자면, 기존에는 printf 디버깅이나 로그 파일을 사용했다면, 시스템 콜 추적은 소스 코드 수정 없이 외부에서 관찰할 수 있어 훨씬 강력합니다.
시스템 콜 추적의 핵심 특징은 다음과 같습니다: (1) 비침입적 - 프로그램 수정 불필요, (2) 실시간 - 시스템 콜이 발생하는 즉시 기록, (3) 상세함 - 모든 인자와 반환값 확인 가능. 이러한 특징들이 강력한 디버깅과 성능 분석을 가능하게 합니다.
코드 예제
// eBPF 프로그램으로 시스템 콜 추적
// 커널 공간에서 실행되는 BPF 코드
use core::mem;
// 시스템 콜 진입 시 호출되는 트레이스포인트
#[no_mangle]
pub fn trace_sys_enter(ctx: *mut c_void) -> i32 {
// 시스템 콜 정보 추출
let syscall_id: u64 = unsafe { bpf_get_syscall_id(ctx) };
let pid: u32 = (bpf_get_current_pid_tgid() >> 32) as u32;
let timestamp: u64 = bpf_ktime_get_ns();
// 이벤트 구조체 생성
let event = SyscallEvent {
pid,
syscall_id,
timestamp,
args: [
unsafe { bpf_get_syscall_arg(ctx, 0) },
unsafe { bpf_get_syscall_arg(ctx, 1) },
unsafe { bpf_get_syscall_arg(ctx, 2) },
],
};
// 링 버퍼로 유저 공간에 전송
unsafe {
bpf_ringbuf_output(&EVENTS, &event as *const _ as *const c_void,
mem::size_of::<SyscallEvent>() as u64, 0);
}
0
}
// 시스템 콜 종료 시 호출되는 트레이스포인트
#[no_mangle]
pub fn trace_sys_exit(ctx: *mut c_void) -> i32 {
let ret: i64 = unsafe { bpf_get_syscall_ret(ctx) };
let duration: u64 = bpf_ktime_get_ns() - get_start_time();
// 느린 시스템 콜만 필터링 (1ms 이상)
if duration > 1_000_000 {
let event = SlowSyscallEvent {
pid: (bpf_get_current_pid_tgid() >> 32) as u32,
ret,
duration,
};
unsafe {
bpf_ringbuf_output(&SLOW_EVENTS, &event as *const _ as *const c_void,
mem::size_of::<SlowSyscallEvent>() as u64, 0);
}
}
0
}
#[repr(C)]
struct SyscallEvent {
pid: u32,
syscall_id: u64,
timestamp: u64,
args: [u64; 3],
}
#[repr(C)]
struct SlowSyscallEvent {
pid: u32,
ret: i64,
duration: u64,
}
// BPF 헬퍼 함수들 (커널이 제공)
extern "C" {
fn bpf_get_current_pid_tgid() -> u64;
fn bpf_ktime_get_ns() -> u64;
fn bpf_get_syscall_id(ctx: *mut c_void) -> u64;
fn bpf_get_syscall_arg(ctx: *mut c_void, n: u64) -> u64;
fn bpf_get_syscall_ret(ctx: *mut c_void) -> i64;
fn bpf_ringbuf_output(ringbuf: *const c_void, data: *const c_void,
size: u64, flags: u64) -> i32;
}
static mut EVENTS: *const c_void = core::ptr::null();
static mut SLOW_EVENTS: *const c_void = core::ptr::null();
설명
이것이 하는 일은 시스템 콜의 모든 측면을 가시화하여, 블랙박스였던 커널 인터페이스를 투명하게 만드는 것입니다. eBPF는 안전하게 커널 코드를 확장할 수 있게 해줍니다.
첫 번째로, eBPF 프로그램은 커널의 트레이스포인트(tracepoint)에 연결됩니다. sys_enter와 sys_exit 트레이스포인트는 각각 시스템 콜 진입과 종료 시 자동으로 호출됩니다.
BPF 프로그램은 샌드박스 환경에서 실행되므로 커널을 크래시시킬 수 없으며, verifier가 무한 루프와 잘못된 메모리 접근을 방지합니다. bpf_get_syscall_id와 bpf_get_syscall_arg 같은 헬퍼 함수를 통해 안전하게 컨텍스트 정보에 접근할 수 있습니다.
그 다음으로, 수집한 데이터를 유저 공간으로 전송해야 합니다. bpf_ringbuf_output은 링 버퍼를 사용하여 고성능으로 이벤트를 전달합니다.
링 버퍼는 커널과 유저 공간이 공유하는 메모리 영역으로, 락 없이(lock-free) 작동하여 오버헤드가 매우 낮습니다. 이벤트가 너무 많으면 성능에 영향을 줄 수 있으므로, 필터링이 중요합니다.
예제에서는 1ms 이상 걸린 "느린" 시스템 콜만 따로 기록합니다. 마지막으로, 유저 공간 프로그램(예: bpftrace, bcc)이 링 버퍼에서 이벤트를 읽어 분석합니다.
실시간으로 출력하거나, 통계를 집계하거나, 히스토그램을 그릴 수 있습니다. 예를 들어 "어떤 시스템 콜이 가장 많이 호출되는가?", "평균 소요 시간은?", "어떤 파일을 읽고 쓰는가?" 같은 질문에 답할 수 있습니다.
strace는 ptrace를 사용하여 프로세스를 멈추고 검사하므로 오버헤드가 크지만(2100배 느려짐), eBPF는 오버헤드가 거의 없어(15%) 프로덕션 환경에서도 사용 가능합니다. 여러분이 이 코드를 사용하면 프로덕션 시스템을 관찰하고 최적화할 수 있습니다.
실무에서의 이점으로는 (1) 성능 분석 - 병목 지점 정확히 파악, (2) 디버깅 - 실패한 시스템 콜의 원인 파악, (3) 보안 감사 - 의심스러운 시스템 콜 패턴 탐지 등이 있습니다.
실전 팁
💡 strace는 개발 환경에서 빠르게 사용하기 좋지만, 프로덕션에서는 너무 느립니다. 대신 eBPF 기반 도구(bpftrace, bcc)를 사용하세요.
💡 특정 시스템 콜만 추적하려면 필터를 추가하세요: if syscall_id == 1 { /* write만 추적 */ }
💡 perf trace는 strace보다 빠르고 더 많은 정보(CPU 샘플링, 하드웨어 이벤트)를 제공합니다.
💡 시스템 콜 인자가 포인터일 때, bpf_probe_read_user를 사용하여 실제 데이터(문자열, 구조체 등)를 읽을 수 있습니다.
💡 bpftrace의 원라이너로 간단한 추적이 가능합니다: bpftrace -e 'tracepoint:syscalls:sys_enter_* { @[probe] = count(); }'