1주 차에 Alarm Clock, Priority Scheduling과 같이 동기화와 스케줄링에 대해 다루었다면 2~3주 차는 User Program이 Pintos에서 돌아갈 수 있도록 구현을 진행한다.
User Program을 OS에 로드하고, System Call을 통해 User Program과 커널이 상호작용하는 방법에 대해 공부하고 그 내용을 기록하려고 한다.
전체 플로우
이전 프로젝트(1주차) 에서는 테스트 코드를 커널에 바로 컴파일했기 때문에 커널 안에서 상호작용하는 특정 함수들을 필요로 했다. 이 번주부터는 유저프로그램을 실행하여 운영체제를 테스트한다.
커널 모드와 유저모드
운영체제는 보안과 안정성을 위해 하드웨어의 접근 권한을 두 가지 모드로 나누어 놨다. 그 두가지를 각각 커널 모드와 유저 모드라고 한다.
왜 나누었는가?
- 사용자가 실수로 메모리를 망가트리거나, 악성 코드가 전체 시스템을 먹통으로 만드는 것을 방지하기 위해
- 유저모드는 제한된 권한으로 실행돼서, 안전하게 동작하도록 유도
구분 | 유저 모드 (User Mode) | 커널 모드 (Kernel Mode) |
권한 수준 | 낮음 | 높음 (모든 자원 접근 가능) |
가능한 작업 | 일반적인 사용자 프로그램 실행 (ex. 게임, 웹 브라우저 등) | 시스템 자원 제어 (CPU, 메모리, 디스크, 네트워크 등) |
직접 하드웨어 접근 | ❌ 불가능 | ✅ 가능 |
실패 시 영향 | 해당 프로그램만 크래시 | 시스템 전체 영향을 줄 수 있음 |
시스템 콜은 무엇인가?
위에서 설명했듯 운영체제의 안정성을 위해 직접 하드웨어를 접근하는 작업들을 제한해야 했다. 하지만 우리는 종종 하드웨어에 접근하는 작업을 해야 할 필요가 있다.
예를 들면
- 파일 열기, 쓰기
- 메모리 할당
- 프로세스 생성
- 네트워크 통신
위와 같은 작업을 포함한 다양한 기능을 수행하기 위해 커널 모드로 전환하고 OS가 직접 처리하도록 해야 한다.
시스템 콜(System Call)은 위와 같이 OS가 직접 처리를 해야 하는 작업을 수행하기 위해 운영체제가 제공하는 인터페이스다.
시스템 콜의 주요 종류
1. 프로세스 제어(Process Control)
- 프로세스를 생성, 종료하거나, 다른 프로그램으로 대체하는 등의 작업을 함
- 예시:
- fork() – 새로운 프로세스 생성
- exec() – 현재 프로세스를 새로운 프로그램으로 대체
- exit() – 현재 프로세스 종료
- wait() – 자식 프로세스가 끝날 때까지 대기
2. 파일 조작(File Manipulation)
- 파일을 읽고 쓰고 열고 닫는 등, 파일 시스템과 상호작용하는 기능
- 예시:
- open() – 파일 열기
- read() / write() – 파일 읽기/쓰기
- close() – 파일 닫기
- lseek() – 파일 오프셋 이동
3. 디렉토리 및 파일 시스템 관련
- 디렉터리 생성/삭제, 파일 속성 확인 등
- 예시:
- mkdir(), rmdir() – 디렉터리 생성/삭제
- stat() – 파일 정보 가져오기
- unlink() – 파일 삭제
- rename() – 이름 변경
4. 장치 조작(Device Manipulation)
- I/O 장치에 접근하거나 제어할 때 사용
- 예시:
- ioctl() – 디바이스 특성 제어
- read(), write() – 장치에서 데이터 입출력
5. 정보 유지(Information Maintenance)
- 시간, PID, UID 등의 시스템 정보를 얻음
- 예시:
- getpid() – 현재 프로세스 ID
- gettimeofday() – 현재 시간 얻기
- uname() – 시스템 정보 가져오기
6. 통신(Communication)
- 프로세스 간 통신(IPC), 네트워크 통신 등을 담당
- 예시:
- pipe() – 파이프 생성
- shmget() / shmat() – 공유 메모리 사용
- socket() – 소켓 생성
- send(), recv() – 네트워크 데이터 송수신
7. 메모리 관리(Memory Management)
- 메모리 할당/해제, 주소 공간 조작
- 예시:
- mmap() – 파일 또는 디바이스를 메모리에 매핑
- brk(), sbrk() – 힙 메모리 조절
그리고 우리는 이번 Pintos User Program 프로젝트를 통해 시스템 콜을 이용해 유저 프로그램과 운영체제가 상호 작용 하는 것을 구현해 보게 될 것이다.
Pintos에서 시스템 콜이 일어나는 과정
/* Wait for a subprocess to finish. */
#include <syscall.h>
#include "tests/lib.h"
#include "tests/main.h"
void
test_main (void)
{
int pid;
if ((pid = fork ("child-simple"))){
msg ("wait(exec()) = %d", wait (pid));
} else {
exec ("child-simple");
}
}
위 코드는 테스트 코드 예시다. 우리는 위와 같은 유저 프로그램을 Pintos 운영 체제에 실행시켜야 하는 것이다. 위 테스트 코드에서는 3개 정도의 주요 시스템 콜이 발생할 것이다.
- fork()
- wait()
- exec()
1. 유저 영역에서 fork() 호출
유저 프로그램에서 fork가 호출되면 아래의 코드가 호출이 된다.
- lib/user/syscall.c
pid_t fork(const char *thread_name) {
return (pid_t) syscall1(SYS_FORK, thread_name);
}
시스템 콜은 각자 정해진 번호가 있고 그 번호는 include/lib/user/syscall_nr.h 에 정의되어 있다.
#ifndef __LIB_SYSCALL_NR_H
#define __LIB_SYSCALL_NR_H
/* System call numbers. */
enum {
/* Projects 2 and later. */
SYS_HALT, /* Halt the operating system. */
SYS_EXIT, /* Terminate this process. */
SYS_FORK, /* Clone current process. */
SYS_EXEC, /* Switch current process. */
SYS_WAIT, /* Wait for a child process to die. */
SYS_CREATE, /* Create a file. */
SYS_REMOVE, /* Delete a file. */
SYS_OPEN, /* Open a file. */
SYS_FILESIZE, /* Obtain a file's size. */
SYS_READ, /* Read from a file. */
SYS_WRITE, /* Write to a file. */
SYS_SEEK, /* Change position in a file. */
SYS_TELL, /* Report current position in a file. */
SYS_CLOSE, /* Close a file. */
/* Project 3 and optionally project 4. */
SYS_MMAP, /* Map a file into memory. */
SYS_MUNMAP, /* Remove a memory mapping. */
/* Project 4 only. */
SYS_CHDIR, /* Change the current directory. */
SYS_MKDIR, /* Create a directory. */
SYS_READDIR, /* Reads a directory entry. */
SYS_ISDIR, /* Tests if a fd represents a directory. */
SYS_INUMBER, /* Returns the inode number for a fd. */
SYS_SYMLINK, /* Returns the inode number for a fd. */
/* Extra for Project 2 */
SYS_DUP2, /* Duplicate the file descriptor */
SYS_MOUNT,
SYS_UMOUNT,
};
#endif /* lib/syscall-nr.h */
fork 함수는 내부에 있는 syscall() 함수를 호출하는데 이 syscall 함수는 함수에 전달되는 인자의 개수에 따라 1개에서 6개까지 전달이 가능하다. x86-64 ABI에 따라 최대 6개의 인자를 레지스터로 전달한다.
#define syscall1(NUMBER, ARG0) ( \
syscall(((uint64_t) NUMBER), \
((uint64_t) ARG0), 0, 0, 0, 0, 0))
메크로를 통해 syscall() 함수에 넘긴다.
__attribute__((always_inline))
static __inline int64_t syscall (uint64_t num_, uint64_t a1_, uint64_t a2_,
uint64_t a3_, uint64_t a4_, uint64_t a5_, uint64_t a6_) {
int64_t ret;
register uint64_t *num asm ("rax") = (uint64_t *) num_;
register uint64_t *a1 asm ("rdi") = (uint64_t *) a1_;
register uint64_t *a2 asm ("rsi") = (uint64_t *) a2_;
register uint64_t *a3 asm ("rdx") = (uint64_t *) a3_;
register uint64_t *a4 asm ("r10") = (uint64_t *) a4_;
register uint64_t *a5 asm ("r8") = (uint64_t *) a5_;
register uint64_t *a6 asm ("r9") = (uint64_t *) a6_;
__asm __volatile(
"mov %1, %%rax\n"
"mov %2, %%rdi\n"
"mov %3, %%rsi\n"
"mov %4, %%rdx\n"
"mov %5, %%r10\n"
"mov %6, %%r8\n"
"mov %7, %%r9\n"
"syscall\n"
: "=a" (ret)
: "g" (num), "g" (a1), "g" (a2), "g" (a3), "g" (a4), "g" (a5), "g" (a6)
: "cc", "memory");
return ret;
}
이코드는 어셈블러 코드를 이용해 시스템 콜을 발생한다. 일부 제공받은 자료에는 과거 32비트 자료로 설정되어 esp와 같이 레지스터를 표현하고 int $0x30을 사용해 인터럽트 기반의 시스템 콜을 사용한 것 같으나 현재는 64 비트 기반으로 수정되어 syscall을 이용해 x86-64에서 지원하는 시스템 콜 명령어를 사용했다고 한다.
2. 커널 부팅 및 초기화
int
main (void) {
uint64_t mem_end;
char **argv;
/* Clear BSS and get machine's RAM size. */
bss_init ();
/* Break command line into arguments and parse options. */
argv = read_command_line ();
argv = parse_options (argv);
/* Initialize ourselves as a thread so we can use locks,
then enable console locking. */
thread_init ();
console_init ();
/* Initialize memory system. */
mem_end = palloc_init ();
malloc_init ();
paging_init (mem_end);
#ifdef USERPROG
tss_init ();
gdt_init ();
#endif
/* Initialize interrupt handlers. */
intr_init ();
timer_init ();
kbd_init ();
input_init ();
#ifdef USERPROG
exception_init ();
syscall_init ();
#endif
/* Start thread scheduler and enable interrupts. */
thread_start ();
serial_init_queue ();
timer_calibrate ();
#ifdef FILESYS
/* Initialize file system. */
disk_init ();
filesys_init (format_filesys);
#endif
#ifdef VM
vm_init ();
#endif
printf ("Boot complete.\n");
/* Run actions specified on kernel command line. */
run_actions (argv);
/* Finish up. */
if (power_off_when_done)
power_off ();
thread_exit ();
}
이 코드는 커널의 main 함수이다. 여기에서 syscall_init() 을 호출한다.
MSR (Model-Specific Register) 초기화
MSR은 특정 CPU에 존재하는 하드웨어 제어용 특수 레지스터 집합이라고 한다. 이 레지스터들은 CPU 내부의 특정 기능을 제어하거나 상태를 읽기 위해서만 사용되며 일반 레지스터와는 다르다고 한다.
void
syscall_init (void) {
write_msr(MSR_STAR, ((uint64_t)SEL_UCSEG - 0x10) << 48 |
((uint64_t)SEL_KCSEG) << 32);
write_msr(MSR_LSTAR, (uint64_t) syscall_entry);
/* The interrupt service rountine should not serve any interrupts
* until the syscall_entry swaps the userland stack to the kernel
* mode stack. Therefore, we masked the FLAG_FL. */
write_msr(MSR_SYSCALL_MASK,
FLAG_IF | FLAG_TF | FLAG_DF | FLAG_IOPL | FLAG_AC | FLAG_NT);
}
- MSR_LSTAR : 64 비트 syscall 진입점 주소
- MSR_STAR : sysretq 시 세그먼트 셀렉터 정보
- MSR_SYSCALL_MASK : syscall 진입 시 마스킹할 RFLAGS 비트
이 함수는 커널이 부팅될 때 한번 호출되어 syscall 명령을 처리하기 위한 초기화 작업을 설정하는 코드라고 보면 된다.
3. 커널 모드 진입
- userprog/syscall_entry.s 에 커널 모드 진입과 관련된 코드가 있다.
syscall_entry:
movq %rbx, temp1(%rip) ; RBX는 callee-saved → 메모리에 저장 (temp1)
movq %r12, temp2(%rip) ; R12도 callee-saved → 메모리에 저장 (temp2)
movq %rsp, %rbx /* Store userland rsp */
movabs $tss, %r12
movq (%r12), %r12
movq 4(%r12), %rsp /* Read ring0 rsp from the tss */
/* Now we are in the kernel stack */
push $(SEL_UDSEG) ; segment selector for SS (유저 스택 세그먼트)
push %rbx ; 유저 스택 포인터 (이전에 저장한 RSP)
push %r11 ; 유저 모드 RFLAGS (자동 저장 안 됨)
push $(SEL_UCSEG) ; segment selector for CS (유저 코드 세그먼트)
push %rcx ; 유저 모드 RIP (자동 저장 안 됨)
subq $16, %rsp /* skip error_code, vec_no */
push $(SEL_UDSEG) /* if->ds */
push $(SEL_UDSEG) /* if->es */
push %rax
movq temp1(%rip), %rbx ; rbx 복원
push %rbx
pushq $0
push %rdx
push %rbp
push %rdi
push %rsi
push %r8
push %r9
push %r10
pushq $0 /* skip r11 */
movq temp2(%rip), %r12 ; r12 복원
push %r12
push %r13
push %r14
push %r15
movq %rsp, %rdi ; 현재 스택 (intr_frame 포인터)을 첫번 째 인자로 전달
위 코드는 syscall 명령을 통해 커널 모드로 진입했을 때 실행되는 어셈블리 진입점이다. 단계별로 간단히 설명하면
- callee-saved register를 보존해야 한다. ex) rbx, r12
- 현재 유저 모드가 있는 rsp 값을 rbx에 저장한다. 유저 스택을 백업하는 것이다.
- tss(Task State Segment) 구조체 주소를 r12에 저장한다.
- 인터럽트 프레임 구성 (유저 -> 커널 전환 문맥 저장)
- syscall은 iretq로 복귀하기 위해 SS, RSP, RFLAGS, CS, RIP 값을 직접 스택에 푸시해야 한다
- 이 값은 int 명령과 달리 자동으로 값들을 저장하지 않아 수동 저장이 필요하다고 한다.
- 유저 세그먼트 저장을 한다. (문맥 재현용)
- 레지스터 백업
- 유저 프로세스의 모든 레지스터 상태를 스택에 백업한다.
- 이 스택은 struct intr_frame 형태로 구성되어 syscall_handler() 인자로 전달 가능하다.
- 핸들러 호출 인자 설정
유저 코드 실행 중 syscall 명령이 발생하면 CPU가 자동으로 syscall_entry 어셈블러로 점프하여 syscall_entry를 통해 커널 스택으로 전환을 하고 유저 레지스터를 백업한다. 이후 syscall_handler()를 호출한다.
4. syscall_handler() 호출
void
syscall_handler (struct intr_frame *f UNUSED) {
switch (f->R.rax)
{
case SYS_HALT:
break;
case SYS_EXIT:
break;
case SYS_FORK:
break;
case SYS_EXEC:
break;
case SYS_CREATE:
break;
case SYS_REMOVE:
break;
case SYS_OPEN:
break;
case SYS_FILESIZE:
break;
case SYS_READ:
break;
case SYS_WRITE:
break;
case SYS_SEEK:
break;
case SYS_TELL:
break;
case SYS_CLOSE:
break;
default:
printf ("system call!\n");
thread_exit ();
break;
}
}
- syscall_handler()가 호출되면 유저 프로그램의 문맥(Context)은 intr_frame 구조체에 담겨있고 시스템 콜 번호는 f->R.rax 레지스터에 저장되어 있다.
- syscall의 인자들은 레지스터 (rdi, rsi, rdx 등)를 통해 추출한다.
- 결과값은 f->R.rax에 저장되어 유저 모드로 복귀할 때 리턴값으로 전달된다.
요약
이렇게 커널 모드와 유저 모드에 대한 설명과 유저 프로그램이 커널에 시스템 콜을 발생하는 과정을 살펴봤다. 그럼 이제 우리는 유저 프로그램이 커널에 요청한 시스템 콜을 처리하여 유저 프로그램에 적절한 인자값을 전달하는 프로그램을 구현하면 되는 것이다.
다음 포스팅에서는 시스템 콜을 구현하기 전 알아야 할 추가적인 이론과 몇 가지 구현을 설명하겠다.
'크래프톤 정글' 카테고리의 다른 글
[pintos] Week2~3: User Program Part.3 (2) | 2025.05.20 |
---|---|
[pintos] Week2~3: User Program Part.2 (0) | 2025.05.19 |
[pintos] Week1: Priority Scheduling - Part.3 (1) | 2025.05.12 |
[pintos] Week1: Priority Scheduling - Part.2 (0) | 2025.05.12 |
[pintos] Week1: Priority Scheduling - Part.1 (1) | 2025.05.12 |