ch10. system calls
사용자 프로그램이 커널에
요청을 보내는 공식 창구
write() 함수 한 줄을 호출했을 뿐이지만, 그 이면에는 CPU 권한 전환, 레지스터 저장, 디스패치 테이블 탐색, 주소 검증, 그리고 예외 복구까지 아우르는 정교한 과정이 숨어 있습니다. 시스템 호출은 결코 단순한 함수 호출이 아닙니다.
큰 그림: 시스템 호출은 "사용자 프로그램이 커널에 의뢰하는 공식 창구"입니다OVERVIEW
사용자 프로그램은 어떻게 디스크를 읽고, 파일을 쓰며, 프로세스를 생성하고, 메모리를 할당받을 수 있을까요?
만약 모든 프로그램이 하드웨어를 직접 제어한다면, 악의적이거나 버그가 있는 프로그램 하나가 메모리 전체를 훼손하거나 디스크를 망가뜨릴 수 있습니다. 그래서 운영체제는 '커널'이라는 권한 있는 관리자 층을 둡니다. 사용자 모드는 권한이 제한된 공간이고, 커널 모드는 하드웨어와 핵심 자원에 온전히 접근할 수 있는 특권 공간입니다. 시스템 호출(System Call)은 철저히 분리된 이 두 세계를 잇는 유일하고 안전한 통로입니다.
은행 금고에서 직접 돈을 꺼낼 수는 없습니다. 창구 직원에게 신분증과 출금 요청서를 제출하면, 직원이 이를 확인한 뒤 돈을 내어줍니다. 시스템 호출도 마찬가지입니다. "파일에 쓰고 싶다"거나 "메모리가 더 필요하다"고 요청하면, 커널이 권한과 유효성을 철저히 검사한 후 작업을 대신 수행합니다.
- ①
- POSIX API와 실제 커널 시스템 호출의 차이 이해하기.
- ②
- 시스템 호출이 커널 내부에서 어떤 핸들러와 서비스 루틴을 거쳐 처리되는지 파악하기.
- ③
- x86 아키텍처의 두 가지 진입 방식인
int $0x80과sysenter의 비교. - ④
- 인자 전달 방식, 사용자 주소 검증, 예외 테이블(Exception Table)과 복구(Fix-up) 메커니즘.
핵심: 겉보기엔 일반적인 "함수 호출" 같지만, 시스템 호출은 CPU 권한 수준이 변경되고, 기존 레지스터와 스택 상태가 저장되며, 커널의 공통 진입점을 거쳐 특정 서비스 루틴으로 분기한 뒤 다시 사용자 모드로 복귀하는 매우 정교한 일련의 절차입니다.
핵심 O/X 퀴즈
1. 시스템 호출은 사용자 모드 프로그램이 커널 기능을 요청하기 위한 공식 인터페이스이다.
2. 사용자 프로그램은 일반적으로 디스크나 CPU 같은 하드웨어를 직접 제어해야 한다.
3. 시스템 호출은 단순한 C 함수 호출과 완전히 동일하며 CPU 권한 변화가 없다.
POSIX API와 시스템 호출: 우리가 호출하는 함수와 커널의 실제 요청은 다릅니다API vs SYSCALL
프로그램 코드에서 open(), write(), fork()를 호출할 때 이를 흔히 '시스템 호출'이라고 부르지만, API와 실제 커널 요청은 엄밀히 구분해야 합니다. API는 어떤 서비스를 어떻게 요청해야 하는지 정의한 '인터페이스'이고, 시스템 호출은 소프트웨어 인터럽트나 전용 명령어를 통해 커널에 명시적으로 작업을 요청하는 '행위' 자체를 뜻합니다.
- 1 API → 0 syscall
sin()이나abs()같은 수학 함수는 커널 개입 없이 사용자 모드에서 전적으로 처리됩니다.- 1 API → 1 syscall
write()함수는 커널의sys_write()시스템 호출과 1:1로 정확히 대응합니다.- 1 API → N syscalls
malloc()함수는 상황과 요청 크기에 따라 내부적으로brk()나mmap()등 여러 시스템 호출을 선택적으로 사용합니다.- N APIs → 1 syscall
- 여러 종류의 API 함수가 커널 내부에서는 결국 동일한 하나의 시스템 호출을 공유하기도 합니다.
POSIX 표준은 "커널의 내부 구현 방식"이 아니라 응용 프로그램이 사용할 "API"를 규정합니다. 따라서 POSIX를 준수하는 운영체제라 하더라도 내부 구조가 반드시 Linux와 동일할 필요는 없습니다. 응용 프로그램이 기대하는 함수 시그니처와 반환값의 의미만 올바르게 제공하면 됩니다.
errno 처리: 커널은 작업 실패 시 음수 형태의 오류 코드를 반환합니다. C 라이브러리(libc)의 래퍼(wrapper) 루틴이 이를 해석하여 errno 전역 변수에 양수 오류 번호를 설정하고, 사용자 프로그램에는 -1을 반환합니다. 즉, 커널이 직접 errno 변수를 수정하는 것이 아닙니다.
핵심 O/X 퀴즈
1. POSIX 표준은 주로 커널 내부의 시스템 호출 구현 방식을 직접 규정한다.
2. libc의 래퍼 루틴은 시스템 호출을 사용자가 쉽게 호출할 수 있게 감싸는 역할을 할 수 있다.
3. 커널은 실패한 시스템 호출에 대해 직접 libc의 errno 변수를 설정한다.
시스템 호출 핸들러와 서비스 루틴: 공항 검색대와 목적지 게이트Figure 10-1 / sys_call_table
사용자 프로그램이 시스템 호출을 실행하면 CPU는 즉시 커널 모드로 전환됩니다. 이때 수백 개의 시스템 호출 중 어떤 작업을 원하는지는 시스템 호출 번호를 통해 전달합니다. x86 아키텍처에서는 이 식별 번호를 eax 레지스터에 담습니다.
- 시스템 호출 핸들러
- 모든 시스템 호출이 가장 먼저 거쳐 가는 공통 진입점입니다. 레지스터 상태 저장, 호출 번호 유효성 검사, 적절한 서비스 루틴 호출, 상태 복원 및 사용자 모드 복귀를 총괄합니다.
- 시스템 호출 서비스 루틴
- 요청된 시스템 호출을 실제로 수행하는 구체적인 C 함수입니다. 관례상
xyz()함수에 대한 서비스 루틴은sys_xyz()라는 이름을 갖습니다.
공항의 보안 검색대가 바로 '핸들러'입니다. 모든 승객은 예외 없이 이 검색대를 통과해야 합니다. 검색을 마친 승객들은 각자의 탑승권에 적힌 게이트로 흩어집니다. write 승객은 sys_write 게이트로, fork 승객은 sys_fork 게이트로 향하는 것과 같은 이치입니다.
커널 메모리에는 sys_call_table이라는 디스패치 배열이 있습니다. 이 배열의 n번째 항목에는 시스템 호출 번호 n에 해당하는 서비스 루틴의 메모리 주소가 저장되어 있습니다. eax 값이 n이면 커널은 sys_call_table[n]을 참조하여 해당 루틴을 호출합니다. 만약 아직 구현되지 않은 번호가 전달되면, 기본적으로 sys_ni_syscall()이 호출되어 -ENOSYS 오류를 반환합니다.
핵심 O/X 퀴즈
1. 리눅스 x86에서 시스템 호출 번호는 일반적으로 eax 레지스터를 통해 전달된다.
2. 모든 시스템 호출은 각자 완전히 다른 커널 진입점을 사용하므로 공통 핸들러가 존재하지 않는다.
3. sys_call_table은 시스템 호출 번호와 실제 서비스 루틴의 메모리 주소를 매핑해 놓은 디스패치 테이블이다.
커널에 진입하고 빠져나오는 두 가지 경로: int $0x80과 sysenterTWO ENTRY PATHS
x86 리눅스 환경에서 사용자 모드에서 커널 모드로 넘어가는 방법은 크게 두 가지로 나뉩니다.
오래전부터 사용된 전통적이고 범용적인 인터럽트 방식입니다. IDT의 128번 벡터(0x80)를 통해 진입하며, CPU가 많은 레지스터 상태를 자동으로 스택에 저장합니다. 호환성이 뛰어나지만 상대적으로 전환 속도가 느립니다.
인텔 펜티엄 II 아키텍처부터 도입된 "Fast System Call" 전용 명령어입니다. MSR(Model-Specific Register)을 활용하여 빠르게 컨텍스트를 전환합니다. 속도는 매우 빠르지만, 커널 코드가 일부 상태를 직접 수동으로 저장해야 하는 번거로움이 있습니다.
모든 하드웨어가 sysenter를 지원하는 것은 아니며, 구버전의 라이브러리들은 여전히 int $0x80을 기본으로 사용합니다. 리눅스 커널은 vsyscall page라는 메커니즘을 통해 현재 구동 중인 하드웨어 환경에 가장 최적화된 방식을 동적으로 자동 선택합니다.
int $0x80은 공항의 일반 입국 심사와 같습니다. 절차가 복잡하지만 시스템이 알아서 많은 것을 처리해 줍니다. 반면 sysenter는 VIP 전용 패스트트랙 게이트입니다. 통과 속도는 빠르지만, 사전에 등록된 정보와 엄격한 전용 규격을 정확히 맞춰야만 이용할 수 있습니다.
핵심 O/X 퀴즈
1. int $0x80은 x86 리눅스에서 전통적으로 시스템 호출을 발생시키기 위해 널리 쓰이던 명령어이다.
2. sysenter는 int $0x80의 오버헤드를 극복하고 더 빠른 커널 진입을 제공하기 위해 도입된 명령어이다.
3. sysenter가 널리 보급되었기 때문에 현대의 리눅스 커널은 더 이상 int $0x80을 지원하지 않는다.
int $0x80 진입 과정: IDT 128번 게이트 통과하기system_call() / SAVE_ALL / DPL 3
사용자 프로그램이 int $0x80 명령을 실행하면, CPU는 인터럽트 디스크립터 테이블(IDT)에서 128번 벡터를 조회합니다. 커널은 부팅 초기화 단계인 trap_init() 함수에서 set_system_gate(0x80, &system_call)를 호출하여 이 경로를 미리 세팅해 둡니다.
- 세그먼트 셀렉터
- 커널의 코드 세그먼트를 명시적으로 가리킵니다.
- 오프셋 (Offset)
- 공통 핸들러인
system_call()함수의 시작 메모리 주소입니다. - 타입 (Type)
- 트랩 게이트(Trap Gate)로 설정됩니다. 이는 핸들러가 실행되는 동안 마스크 가능한 하드웨어 인터럽트를 자동으로 차단하지 않음을 의미합니다.
- DPL (Descriptor Privilege Level) = 3
- 권한 수준이 3(사용자 모드)으로 설정되어 있어, 특권이 없는 일반 사용자 프로그램도 이 게이트를 합법적으로 호출할 수 있습니다. 이것이 바로 시스템 호출이 사용자 프로그램의 '공식 진입로'가 될 수 있는 이유입니다.
thread_info 구조체 주소를 빠르게 계산해 냅니다.TIF_SYSCALL_TRACE나 TIF_SYSCALL_AUDIT 같은 시스템 호출 로깅/디버깅 플래그가 켜져 있다면, do_syscall_trace()를 먼저 호출하여 기록을 남깁니다.-ENOSYS를 덮어쓰고 즉시 복귀 절차를 밟습니다.sys_call_table[eax * 4] 위치에서 해당 서비스 루틴의 주소를 꺼내어 제어권을 넘깁니다. (예: write 시스템 호출인 경우 sys_write() 실행)핵심 O/X 퀴즈
1. int $0x80 명령어는 IDT의 128번 벡터를 참조하여 커널의 공통 진입점인 system_call 로 제어를 넘긴다.
2. 시스템 호출 게이트의 DPL이 3으로 설정되어 있기 때문에 권한이 가장 낮은 사용자 모드 코드도 이 게이트를 통과할 수 있다.
3. 전달된 시스템 호출 번호가 유효하지 않아도 커널은 오류를 무시하고 sys_call_table의 임의 위치를 강제로 실행한다.
system_call()에서 빠져나오기: 사용자 모드 복귀 전, 남은 작업 확인하기종료 경로 / iret
서비스 루틴의 작업이 완료되었다고 해서 곧바로 사용자 프로그램으로 돌아가는 것은 아닙니다. 커널은 제어권을 넘겨주기 직전에 "돌아가기 전에 추가로 처리해야 할 시스템 작업이 있는지" 꼼꼼히 살핍니다.
- 반환값 기록
- 서비스 루틴이 반환한 결과값(eax 레지스터에 담김)을 커널 스택 내 사용자 레지스터 보존 영역(저장된 eax 위치)에 기록합니다. 이를 통해 사용자 모드로 복귀했을 때 해당 값을 읽을 수 있습니다.
- 인터럽트 비활성화
- 플래그 상태를 확인하고 후속 작업을 안전하게 결정하기 위해 로컬 하드웨어 인터럽트를 일시적으로 비활성화합니다.
- thread_info 플래그 검사
- 대기 중인 시그널(Signal)이 있는지, 새로운 스케줄링(Rescheduling)이 필요한지 등 지연된 작업 플래그를 검사합니다.
- restore_all → iret
- 처리할 특별한 플래그가 전혀 없다면, 이전에 저장했던 레지스터 상태들을 스택에서 팝(pop)하여 복원한 뒤,
iret명령어를 호출하여 권한을 낮추며 사용자 모드로 돌아갑니다. - work_pending 경로
- 만약 시그널 처리나 스케줄링이 요구된다면, 복귀를 잠시 미루고 해당 작업을 모두 완료한 후에야 사용자 공간으로 돌아갑니다.
관공서 창구에서 메인 민원 처리를 마쳤습니다. 그런데 문을 나서려는 순간 직원이 붙잡으며 말합니다. "잠시만요, 방금 추가 서류가 접수되었습니다" 혹은 "다른 부서에 들러서 서명 하나 더 하셔야 합니다." 아무 문제가 없다면 바로 귀가하겠지만, 남은 절차가 있다면 출구 앞에서 모든 문제를 깨끗이 해결하고 나서야 건물을 빠져나갈 수 있습니다.
핵심 O/X 퀴즈
1. 시스템 호출 서비스 루틴의 최종 실행 결과(반환값)는 사용자 모드로 돌아갔을 때 eax 레지스터를 통해 확인할 수 있도록 스택에 안전하게 기록된다.
2. 서비스 루틴이 끝나면 커널은 어떤 예외나 검사 과정도 없이 즉시 iret 명령어를 실행하여 문맥을 전환한다.
3. restore_all 레이블이 이끄는 경로는 저장된 레지스터를 원상 복구하고 전통적인 iret 명령을 통해 사용자 모드로 돌아갈 때 사용된다.
sysenter: 고속 시스템 호출을 위한 전용 진입로MSR / TSS esp0 / sysenter_entry()
sysenter 명령어는 메모리 접근 오버헤드를 줄이기 위해 CPU 내부에 있는 세 개의 특수 레지스터(MSR: Model-Specific Register)를 적극 활용합니다.
- SYSENTER_CS_MSR
- 커널 모드로 진입할 때 사용할 코드 세그먼트(CS) 셀렉터 값을 미리 담아둡니다. sysenter 실행 시 즉각적으로 이 값이 적용됩니다.
- SYSENTER_EIP_MSR
- 명령어 실행 후 점프할 커널 진입점의 메모리 주소입니다. 리눅스에서는
sysenter_entry()함수의 주소가 할당됩니다. - SYSENTER_ESP_MSR
- 커널 모드 스택과 관련된 정보를 제공합니다. 단, 직접적인 커널 스택 주소라기보다는 이를 찾기 위한 TSS 구조체 접근 단서를 제공하는 역할을 합니다.
TSS esp0 기반 스택 설정의 비밀: 사용자 모드의 라이브러리 래퍼 루틴은 보안상 현재 프로세스의 커널 스택 주소를 직접 알 수 없습니다. 리눅스는 문맥 교환(Context Switch)이 일어날 때마다 새롭게 활성화되는 프로세스의 커널 스택 포인터를 로컬 TSS 구조체의 esp0 필드에 착실히 저장해 둡니다. sysenter_entry() 진입 코드는 일단 MSR이 가리키는 임시 위치로 제어권을 넘겨받은 뒤, 이 TSS의 esp0 값을 읽어와 진짜 커널 스택 포인터를 esp 레지스터에 로드하는 방식으로 딜레마를 해결합니다.
운영체제가 부팅될 때 각 CPU 코어는 enable_sep_cpu() 함수를 통해 이 MSR 값들을 초기화합니다. sysenter는 하드웨어 인터럽트 방식보다 실행 사이클이 짧아 빠르지만, int $0x80 명령이 자동으로 스택에 푸시해주던 사용자 세그먼트 레지스터, 스택 포인터, EFLAGS, 복귀 주소(EIP) 등을 커널 진입 코드가 일일이 소프트웨어적으로 구성해야 하는 트레이드오프가 있습니다.
핵심 O/X 퀴즈
1. sysenter 명령어는 빠른 커널 모드 진입을 위해 하드웨어적으로 SYSENTER_CS_MSR, SYSENTER_EIP_MSR 등의 특수 레지스터를 참조한다.
2. 사용자 모드 래퍼 루틴은 현재 프로세스의 정확한 커널 스택 주소를 알아낸 뒤 이를 직접 SYSENTER_ESP_MSR에 주입해야 한다.
3. sysenter는 속도가 비약적으로 빠르지만, int $0x80 방식에 비해 일부 CPU 상태 저장 작업을 소프트웨어적으로 커널 코드가 직접 처리해야 하는 수고가 따른다.
vsyscall page: 하드웨어 지원 수준을 사용자 공간에 알려주는 동적 브리지__kernel_vsyscall / sysenter_setup()
C 표준 라이브러리(libc)는 가능하면 빠른 sysenter를 사용하고 싶어 하지만, 코드가 실행되는 현재 CPU나 커널이 이를 지원하지 않을 수도 있습니다. 이를 해결하기 위해 커널은 초기화 과정인 sysenter_setup()에서 특별한 메모리 페이지(vsyscall page)를 하나 준비해 둡니다. 그리고 새로운 ELF 실행 파일이 메모리에 로드될 때, 이 페이지를 각 프로세스의 주소 공간 상단에 동적으로 매핑해 줍니다.
- __kernel_vsyscall
- libc 래퍼가 시스템 호출을 발생시키기 위해 일관되게 호출하는 vsyscall page 내부의 핵심 진입 함수입니다. 실제 내부 명령어가 무엇일지는 커널이 결정해 둔 상태입니다.
- CPU가 sysenter를 미지원할 때
- 커널은 부팅 시 이 페이지 공간에 기존
int $0x80명령어를 사용하는 코드를 배치해 둡니다. - CPU가 sysenter를 완벽히 지원할 때
- 커널은 이 페이지에
sysenter를 활용하는 최적화된 코드를 덮어씁니다. 이때 커널 진입 전 사용자 스택에 복귀 시 필요한 레지스터(ecx, edx, ebp)를 백업하고, ebp에 현재의 사용자 스택 포인터를 보존하는 작업도 이 코드 내에서 이루어집니다. - 매우 오래된 커널 환경
- 만약 vsyscall page 자체가 매핑되지 않는 구식 환경이라면, libc는 이를 감지하고 스스로
int $0x80을 직접 하드코딩하여 실행하는 하위 호환성(Fallback) 로직을 탑재하고 있습니다.
int $0x80 방식과 완벽히 동일한 공통 시스템 호출 흐름(번호 유효성 검사 → 테이블 디스패치 → 실제 서비스 루틴 실행)을 따릅니다.핵심 O/X 퀴즈
1. vsyscall page는 프로세스 주소 공간에 매핑되어, 사용자 코드가 최적의 시스템 호출 진입 명령을 투명하게 호출할 수 있도록 돕는다.
2. CPU가 최신 명령어인 sysenter를 지원하지 못하는 환경이라면, vsyscall page 메커니즘은 작동을 멈추고 프로그램을 강제 종료시킨다.
3. sysenter 경로에서는 기존 int 명령이 하드웨어 차원에서 알아서 백업해주던 사용자 세그먼트나 플래그 상태를 sysenter_entry() 코드가 직접 소프트웨어적으로 커널 스택에 구성해야 한다.
sysexit와 SYSENTER_RETURN: 빠르게 빠져나오되, 원래 자리로 정확히 복귀하기sysexit / edx·ecx
서비스 루틴이 작업을 성공적으로 마치고 반환되었을 때, 지연된 처리 플래그가 없다면 커널은 iret 대신 전용 명령어인 sysexit을 이용해 사용자 모드로의 가장 빠른 복귀를 시도합니다.
- edx
- 가장 중요한 복귀 주소(SYSENTER_RETURN 레이블의 주소)를 담습니다. 사용자 공간에서 CPU가 다시 실행을 이어갈 위치입니다.
- ecx
- 복원해야 할 사용자 모드의 스택 포인터(esp) 값을 담아둡니다.
- ebp
- 커널 스택 프레임 정보가 악의적으로 유출되는 것을 막기 위해 0으로 클리어(Zeroing)합니다.
sysexit 명령이 CPU에서 실행되면, 하드웨어는 SYSENTER_CS_MSR+16 공식에 따라 사용자 코드 세그먼트 권한을 복원하고, edx 값을 eip에 밀어 넣습니다. 이와 동시에 SYSENTER_CS_MSR+24로 사용자 데이터 세그먼트를 셋업하며 ecx 값을 스택 포인터 esp로 복구한 뒤 사용자 모드로 완전히 돌아갑니다. 이제 제어권은 앞서 언급한 vsyscall page 영역 내의 SYSENTER_RETURN 코드로 떨어집니다.
SYSENTER_RETURN 코드 조각의 임무는 매우 단순하고 명료합니다 — 앞서 __kernel_vsyscall()이 커널로 뛰어들기 직전 사용자 스택 최상단에 푸시해두었던 ebp, edx, ecx 레지스터 값을 하나씩 팝(pop)하여 원래 상태로 복원하고, 최종적으로 ret 명령을 실행하여 이 모든 절차를 트리거했던 libc 래퍼 함수로 부드럽게 제어 흐름을 반환합니다.
들어가고 나오는 과정이 완벽한 대칭을 이룹니다. 커널 진입 시 __kernel_vsyscall()이 레지스터를 백업하고 sysenter로 급행열차를 탔다면, 복귀 시에는 커널이 sysexit으로 SYSENTER_RETURN 정거장에 승객을 정확히 내려주고, 정거장에 남겨뒀던 짐(레지스터)을 되찾아 최종 목적지로 향하게 하는 왕복 티켓 시스템과 같습니다.
주의: 시그널 처리나 스케줄링 등 잔여 작업 플래그가 하나라도 켜져 있다면, 커널은 이 고속 복귀 경로를 과감히 포기하고 공통된 느린 경로(Slow Path)로 빠져 전통적인 iret 명령어를 이용해 안전하게 돌아갑니다. 빠른 길은 환경이 완벽히 깨끗할 때만 허용됩니다.
핵심 O/X 퀴즈
1. sysexit 명령어는 sysenter와 완벽히 짝을 이루며, 사용자 모드로의 가장 빠르고 효율적인 복귀를 담당한다.
2. sysenter 명령으로 진입한 시스템 호출은 내부 상태와 상관없이 반드시 sysexit 명령으로만 사용자 공간으로 돌아가야 한다.
3. SYSENTER_RETURN 코드 영역은 vsyscall page 내부에 위치하며, 커널 진입 직전에 저장해 두었던 사용자 범용 레지스터들을 원상 복구하는 역할을 충실히 수행한다.
시스템 호출 인자 전달: 스택이 아닌 레지스터를 통한 효율적 전달REGISTER PASSING
시스템 호출은 어떤 작업을 할지 알려주는 번호만으로는 부족합니다. write(fd, buf, count)처럼 어느 파일에, 어떤 데이터를, 얼만큼 쓸지 명시하는 구체적인 인자가 필수적입니다. x86 시스템에서는 속도 최적화를 위해 메모리에 기반한 사용자 스택 대신 CPU 레지스터에 인자를 직접 담아 커널에 전달하는 방식을 채택합니다.
| 레지스터 | 역할 및 전달 내용 | 호출 예시 (write) |
|---|---|---|
| eax | 시스템 호출 번호 (식별자) | __NR_write = 4 |
| ebx | 1번째 필수 인자 | fd (파일 디스크립터 정수) |
| ecx | 2번째 필수 인자 | buf (사용자 버퍼의 메모리 주소) |
| edx | 3번째 필수 인자 | count (기록할 데이터 바이트 수) |
| esi | 4번째 인자 | — |
| edi | 5번째 인자 | — |
| ebp | 6번째 인자 | — |
이 규약에 따라 최대 6개의 인자까지 레지스터만으로 넘길 수 있습니다. 만약 함수가 6개 이상의 인자를 요구하거나 전달해야 할 데이터가 거대한 구조체라면, 사용자 주소 공간 내의 구조체가 위치한 메모리 포인터 하나만을 레지스터에 담아 넘기는 간접 참조(우회) 방식을 활용합니다. 커널 진입 단계에서 SAVE_ALL 매크로가 이 레지스터들을 커널 스택에 차례대로 푸시해 놓으면, 이후 실행되는 C 언어 기반의 서비스 루틴은 이 스택 레이아웃을 바탕으로 일반적인 함수 인자를 읽듯 자연스럽게 값에 접근할 수 있습니다.
특수한 상황 — 예를 들어, fork() 계열 시스템 호출처럼 자식 프로세스 생성을 위해 부모의 전체 레지스터 컨텍스트를 완벽히 복제해야 하는 서비스 루틴의 경우 — 일반 변수가 아닌 pt_regs 구조체 포인터 타입의 인자를 선언하여, SAVE_ALL이 구성해 둔 레지스터 저장 영역에 직접적이고 광범위하게 접근합니다.
핵심 O/X 퀴즈
1. 32비트 x86 리눅스에서 시스템 호출 번호는 eax에 담기며, 함수 인자들은 ebx, ecx, edx, esi, edi, ebp 레지스터 순서로 차례로 할당되어 전달된다.
2. 시스템 호출 인자는 일반 C 함수 호출과 동일하게, 언제나 사용자 스택 공간에 푸시된 채로 커널 공간으로 메모리 복사 과정을 거쳐 전달된다.
3. 전달해야 할 인자의 개수가 6개를 초과하거나 구조체와 같이 부피가 큰 데이터 형식을 전달할 경우, 데이터가 위치한 사용자 메모리의 포인터 주소만 레지스터에 담아 효율적으로 우회 전달할 수 있다.
인자 검증과 access_ok(): 커널은 사용자 포인터를 절대 무비판적으로 신뢰하지 않습니다addr_limit.seg / PAGE_OFFSET
사용자가 악의적이거나 실수로 커널 내부 주소를 포인터 인자로 넘겼을 때, 커널이 이를 의심 없이 읽거나 쓰려고 한다면 시스템 핵심 메모리가 통째로 유출되거나 치명적으로 훼손될 수 있습니다. 따라서 커널은 사용자 공간에서 넘어온 모든 주소의 안전성을 철저히 검사해야만 합니다.
- 1차 방어선: 거친 검사 (access_ok)
- "이 주소가 커널 전용 영역을 침범하려고 시도하는가?" — 매크로를 통해 계산된 끝 주소가
PAGE_OFFSET경계선과 현재 프로세스의thread_info.addr_limit.seg상한선을 넘지 않는지 가볍게 계산해 봅니다. 이는 완전히 안전하다는 보장이 아니라, 최소한의 필요조건을 따지는 검문입니다. - 2차 방어선: 세밀한 검사 (Page Fault 유도)
- 메모리에 실제로 접근하는 순간 권한 문제나 매핑 누락으로 인해 페이지 폴트 하드웨어 예외가 발생하면, 커널 내부의 예외 테이블(exception table)과 복구 로직(fix-up 코드)이 개입하여 이를 우아하게
-EFAULT반환 에러로 둔갑시킵니다. (다음 섹션에서 상세히 다룹니다.)
access_ok(type, addr, size) 매크로 함수의 주된 역할은 addr+size 연산 과정에서 정수 오버플로가 발생하지 않는지, 그리고 그 덧셈의 최종 주소가 thread_info.addr_limit.seg 값을 침범하지 않는지 논리적으로 확인하는 것입니다. 일반적인 응용 프로세스의 addr_limit.seg 값은 커널 시작점인 PAGE_OFFSET과 일치합니다. 예외적으로 커널 내부에서 사용자 API를 직접 에뮬레이션해야 할 때는 get_fs나 set_fs 매크로를 사용해 이 상한선을 임시로 재조정하기도 합니다.
성능과 보안성 간의 아슬아슬한 균형: 시스템 호출이 일어날 때마다 해당 포인터가 완벽히 유효한 매핑 상태인지 정밀하게 페이지 테이블을 순회하며 검증한다면 엄청난 성능 하락이 발생합니다. 잘못된 포인터를 던지는 비정상적 프로그램은 통계적으로 소수이므로, 리눅스는 일단 가벼운 덧셈으로 큰 울타리 경계망(access_ok)만 친 다음, 실제 메모리를 만질 때 하드웨어가 뱉어내는 페이지 폴트에 의존하는 낙관적 방어(Optimistic Protection) 기법을 취합니다.
핵심 O/X 퀴즈
1. access_ok() 매크로는 악의적인 사용자 포인터가 커널 코어 주소 공간을 가리키는 위험한 상황을 1차적으로 차단하기 위한 논리적 범위 한계 검사이다.
2. access_ok() 검증 루틴을 무사히 통과한 주소라면 해당 포인터가 무조건 프로세스 메모리에 정상적으로 매핑되어 있고, 접근 권한 역시 완벽하게 100% 보장되는 상태임이 확증된다.
3. 만일 사용자 프로그램이 악의적으로 커널 주소를 포인터 인자로 던졌는데도 커널 차원의 엄격한 주소 검증 절차가 없다면, 커널의 기밀 메모리 데이터가 탈취되거나 파괴될 심각한 보안 구멍이 발생한다.
사용자 주소 공간 안전하게 접근하기: get_user, put_user, copy_from_userTable 10-1
대부분의 커널 서비스 루틴은 사용자 공간에 마련된 데이터 버퍼를 빈번하게 읽고 씁니다. 커널 코드는 보안 원칙상 아무리 사용자 포인터라 할지라도 일반적인 C 언어 포인터 다루듯 *ptr = value 형태로 직접 역참조(Dereferencing)해서는 안 되며, 반드시 커널이 제공하는 래핑된 전용 함수나 매크로를 통과해야 합니다.
- get_user(x, ptr)
- 사용자 공간 메모리 주소에서 1, 2, 4바이트 단위의 원시 정수형 데이터를 안전하게 읽어 변수 x에 담습니다. 도중 문제가 생기면 -EFAULT 코드를 반환합니다.
- put_user(x, ptr)
- 변수 x에 담긴 원시 정수형 데이터를 사용자 공간 특정 메모리 주소에 조심스럽게 기록합니다. 실패 시 -EFAULT가 반환됩니다.
- copy_from_user(to, from, n)
- 임의의 사용자 공간 버퍼 블록에서 커널 내부 공간으로 주어진 n바이트만큼의 데이터를 안전하게 일괄 복사해 옵니다.
- copy_to_user(to, from, n)
- 반대로 커널 공간에서 처리 완료된 n바이트의 데이터를 응용 프로그램이 볼 수 있게 사용자 공간 버퍼로 밀어 넣습니다.
- strncpy_from_user
- 사용자 공간에 놓인 널(NULL) 종료 C 스타일 문자열의 끝을 찾아가며 커널로 안전하게 복사해 옵니다.
- strlen_user / strnlen_user
- 사용자 공간에 존재하는 문자열의 길이를 측정합니다. 커널 영역 침범이나 무한 루프를 막기 위한 방어 코드가 포함되어 있습니다.
- clear_user
- 사용자 공간의 특정 메모리 블록 크기 전체를 0(Null) 바이트로 덮어씌워 초기화합니다.
가끔 커널 코드를 보다 보면 함수 이름 맨 앞에 밑줄 두 개가 연달아 붙은 특별한 변형판(예: __get_user, __copy_from_user)을 마주칠 수 있습니다. 이 함수들은 상위 로직에서 access_ok()를 통한 주소 거친 검증이 이미 확실하게 수행되었다고 전제하여, 함수 내부의 중복된 주소 검사 루틴을 과감히 생략합니다. 이는 오버헤드를 극단적으로 줄이려는 최적화 기법입니다.
회사 건물 1층 로비의 엄격한 신원 확인 게이트(access_ok)를 상상해 보십시오. 방문증을 발급받아 이미 사무실 구역에 진입한 직원이 특정 회의실 책상을 여러 번 오가며 짐을 나를 때(데이터 반복 접근), 매 걸음마다 경비원이 따라붙어 신분증을 반복해서 요구하지는 않습니다. 가장 바깥 울타리에서 한 번 크게 신뢰도를 확인했다면, 내부에서는 제한적이지만 자유로운 접근 권한을 부여해 효율성을 높이는 원리와 동일합니다.
핵심 O/X 퀴즈
1. copy_from_user() 함수는 이름 그대로 사용자 공간 메모리에 위치한 데이터를 커널 내부 공간으로 안전하게 끌어와 복사하는 데 쓰이는 블록 단위 전송 함수이다.
2. 커널 내부 C 코드를 작성할 때는 신뢰도와 속도를 위해 사용자 공간의 포인터 변수를 언제나 일반 포인터 변수처럼 직접 * 역참조 연산자를 써서 다루어도 전혀 무방하다.
3. 함수 명칭 앞에 언더바 두 개(__)가 붙어있는 사용자 메모리 접근 변형 함수들은, 통상적으로 이미 주소 범위 검증이 끝났다고 믿고 내부 체크를 생략하여 실행 속도를 끌어올린다.
동적 주소 검사와 fix-up 메커니즘: 예외를 안전한 오류 코드로 변환하는 안전장치exception table / __ex_table / .fixup
앞서 설명한 access_ok() 함수는 PAGE_OFFSET 기반의 매우 1차원적인 범위 검사만 수행할 뿐입니다. 주소값이 PAGE_OFFSET보다 낮더라도, 실제로 해당 영역에 물리 메모리 프레임이 할당(매핑)되어 있지 않은 허공의 주소라면 어떻게 될까요? 커널이 그곳에 손을 뻗는 순간 얄짤없이 하드웨어 페이지 폴트(Page Fault) 예외가 터져 나옵니다. 하지만 명심할 것은, 커널 모드에서 발생한 페이지 폴트가 무조건적으로 "커널을 짜놓은 개발자의 치명적인 멍청한 버그(Oops)"를 의미하지는 않는다는 점입니다.
- ① 정상적인 Demand Paging / COW 동적 할당
- 사용자 주소 공간 페이지에 접근하는 건 맞는데, 아직 물리 프레임이 늦게 할당되었거나 쓰기 방지(Write-Protected)가 걸린 복제(COW) 상태일 때.
- ② vmalloc 영역의 지연된 동기화
- 올바른 커널 동적 메모리 주소지만, 커널 프로세스별 페이지 테이블 갱신이 한 템포 늦어 엔트리가 비어 있을 때.
- ③ 치명적인 커널 내부 버그 또는 진짜 하드웨어 고장
- 포인터 계산 실수나 램 칩 오류 등으로 복구 불가능한 커널 패닉(Oops) 상황.
- ④ 악질적이거나 실수로 잘못 전달된 사용자 인자 (오늘의 주인공)
- 응용 프로그램이 넘긴 사용자 버퍼 포인터 주소가 엉터리라서, 커널이 그 인자를 맹신하고 접근하다 허공을 찔렀을 때 발생하는 에러.
__get_user_1 레이블bad_get_user: edx 레지스터 0으로 클리어, eax에 -EFAULT 에러 담고 ret__get_user_2 레이블bad_get_user: edx 레지스터 0으로 클리어, eax에 -EFAULT 에러 담고 retstrlen_user 문자열 탐색 스캔 어셈블리 명령어do_page_fault() 인터럽트 핸들러가 긴급 출동합니다.search_exception_tables(eip) 함수 호출 — 핸들러는 직전에 페이지 폴트 문제를 일으킨 범인 명령어의 주소(eip)를 들고 커널의 전역 예외 테이블 장부를 뒤지기 시작합니다.-EFAULT 상수를 반환하여 → 서비스 루틴이 "사용자가 이상한 포인터를 줬어"라는 오류 코드를 래퍼 루틴에 전달할 수 있게 징검다리 역할을 해줍니다.커널 설계 철학은 시스템이 완벽하게 오류 없이 굴러갈 것이라고 헛된 가정을 하지 않습니다. 대신 오류가 날 법한 위험천만한 메모리 접근 지점들을 사전에 꼼꼼히 마킹(__ex_table 섹션)해 두고, 실제로 하드웨어 예외가 터지면 제어 흐름을 우아하게 잡아채어 소프트웨어적인 안전망 복구 코드(.fixup 섹션)로 미끄러지듯 연결합니다. 무시무시한 하드웨어 크래시 예외를 그저 "-1 반환"이라는 평화로운 소프트웨어 오류 코드로 마법처럼 탈바꿈시키는 핵심 장치입니다.
핵심 O/X 퀴즈
1. 커널 모드 권한 하에서 메모리 페이지 폴트가 발생하면, 상황 불문 언제나 시스템을 정지시켜야 하는 치명적인 커널 버그(Kernel Panic)로 취급하고 처리해야 한다.
2. exception table 구조체는 위험이 도사리는 사용자 공간 접근 커널 어셈블리 명령어의 주소와, 해당 명령이 실패했을 때 탈출할 비상구인 fix-up 코드의 주소를 1:1 쌍으로 강력하게 매핑해 놓은 일종의 색인표이다.
3. 백업용 fix-up 코드가 존재하는 핵심적인 이유는 엉뚱한 사용자 메모리 주소 접근 시 뻥 터지는 하드웨어 예외를 포착해, 응용 프로그램이 알아들을 수 있는 -EFAULT 같은 정갈한 오류 반환값으로 마사지하기 위함이다.
커널 래퍼 매크로와 전체 과정 총정리: 시스템 호출은 한 줄짜리 함수 뒤에 숨겨진 거대한 약속입니다_syscall0~6 / 전체 흐름
일반 응용 프로그램과 달리, 커널 모드 영역에서 동작하는 커널 스레드(Kernel Thread) 데몬들은 덩치가 큰 외부 라이브러리인 libc의 래퍼 함수들을 가져다 쓸 수 없습니다. 이런 제약을 해결하기 위해 리눅스 커널 소스 트리는 필요한 시스템 호출 래퍼 루틴을 인라인 어셈블리로 아주 손쉽게 찍어내듯 선언할 수 있는 _syscall0부터 _syscall6까지의 매직 매크로 세트를 기본으로 제공합니다. 매크로 이름 뒤에 붙은 숫자는 시스템 호출 번호 자체를 제외한, 함수가 요구하는 순수 '인자의 개수'를 직관적으로 나타냅니다.
- 레지스터 인자 세팅
- 호출 번호 eax = __NR_write (4번) 할당. 이어 1번째 인자 ebx = fd, 2번째 인자 ecx = buf 주소, 3번째 인자 edx = 기록할 count 바이트 수를 차곡차곡 담습니다.
- 커널 인터럽트 진입
- 모든 준비를 마치고 int $0x80 어셈블리 명령어를 강하게 내리쳐 커널을 깨웁니다.
- 반환값 최종 마사지
- 커널에서 돌아온 결괏값 eax가 음수 대역(오류 발생을 의미)이라면 이를 절대값(양수)으로 뒤집어 범용 errno 변수에 기록하고, 사용자 코드에는 일괄적으로 -1을 리턴합니다. 정상 성공 시에는 eax의 양수 결괏값을 그대로 토스합니다.
이제 지금까지 흩어져 있던 조각들을 모아, write() 함수가 호출되는 순간부터 끝날 때까지의 웅장한 왕복 여정을 하나의 서사시로 이어보겠습니다.
write(fd, buf, count) API를 코드에서 호출 → C 라이브러리(libc) 내부 래퍼 루틴이 시스템 호출 번호와 인자 3개를 eax, ebx, ecx, edx 레지스터에 맞게 정렬합니다.__kernel_vsyscall() 함수를 호출 → 현재 구동 중인 하드웨어 환경(CPU 지원 여부)에 맞춰 int $0x80 또는 고속 sysenter 명령어 중 최적의 경로를 타고 실행됩니다.SAVE_ALL 매크로가 레지스터 상태를 커널 스택에 안전하게 보존 → 현재 thread_info 구조체 접근 후 대기 중인 추적 플래그 여부를 확인하고, 요청된 시스템 호출 번호가 상한선 내에 있는지 유효성을 검토합니다.sys_call_table[4] 인덱스에서 sys_write() 함수의 주소를 찾아내 실행 권한을 위임합니다. 서비스 루틴 내부에서는 파일 디스크립터(fd)의 유효성, 쓰기 권한, 버퍼 메모리의 안정성(access_ok 체크)을 다각도로 검증한 후, copy_from_user()를 이용해 사용자 버퍼의 데이터를 안전하게 커널 캐시 메모리로 복사해 옵니다.사용자 eax 레지스터 백업 슬롯 위치에 덮어쓰여집니다. 복귀 직전 마지막으로 thread_info 내에 보류된 시그널이나 컨텍스트 스위칭 재스케줄링 플래그가 없는지 면밀히 확인합니다.sysexit(또는 iret) 명령을 통해 특권 모드를 반납하고 복귀합니다 → 사용자 주소 공간에 대기 중이던 SYSENTER_RETURN 레이블 코드가 백업 레지스터들을 마저 팝(Pop)하여 원상 복구하고 → 최종적으로 libc 래퍼 루틴이 음수/양수 여부를 판단해 errno 변수 세팅 및 최종 API 성공값을 반환하면서 모든 여정이 끝납니다.마지막 한 줄 요약: 응용 프로그램을 작성하는 개발자에게 시스템 호출은 그저 한 줄의 편리한 라이브러리 함수 호출처럼 친숙하게 다가오지만, 그 수면 아래 운영체제 커널의 입장에서는 시스템의 절대적 보안, 다양한 하드웨어 호환성, 극한의 성능 최적화, 그리고 예측 불가능한 메모리 예외 복구(Fix-up)까지 동시에, 완벽하게 통제해 내야만 하는 엄청나게 거대하고 정교한 오케스트라 연주와 같습니다.