운영체제 수업을 듣다 보면 시스템 콜이라는 용어를 자주 접하게 됩니다. 하지만 실제로 시스템 콜이 어떻게 동작하고 어떤 과정을 거쳐 구현되는지 막연하게 알고 있을겁니다. 이번 글에서는 MIT의 교육용 운영체제인 xv6에 새로운 시스템 콜을 추가해보면서 시스템 콜의 내부 동작 원리를 확인해보겠습니다.
참고로 저는 OSSU 커리큘럼을 참고했고 RISC-V 버전이 아닌 x86을 사용했습니다.
시스템 콜은 사용자 프로그램이 운영체제의 서비스를 요청하는 인터페이스입니다. 일반적인 함수 호출과 달리, 시스템 콜은 사용자 모드(User Mode)에서 커널 모드(Kernel Mode)로의 전환을 필요로합니다.
예를 들어 C 프로그램에서 printf("Hello World\n")를 실행하면 내부적으로는 다음과 같은 일이 일어납니다
printf() 함수가 write() 시스템 콜을 호출이 과정에서 하드웨어적인 특권 레벨 변경, 레지스터 상태 저장/복원, 메모리 보호 등 복잡한 작업들이 처리됩니다.
다음으로 xv6에서 시스템 콜이 실행되는 전체 과정을 살펴보겠습니다.
아래는 getpid()를 호출했을 때의 실행 흐름입니다.
[사용자 프로그램] getpid() 호출
↓
[usys.S] 어셈블리 래퍼 실행
↓
[하드웨어] 인터럽트 발생 (int $T_SYSCALL)
↓
[trap.c] 트랩 핸들러 실행
↓
[syscall.c] 시스템 콜 디스패처
↓
[sysproc.c] sys_getpid() 커널 함수 실행
↓
[결과 반환] 사용자 프로그램으로 복귀
1단계: 사용자 API 호출
사용자 프로그램에서 getpid()를 호출하면, 이는 실제 구현체가 아닌 래퍼함수입니다. 이 래퍼 함수의 역할은 실제 시스템 콜을 호출하는 것입니다.
2단계: 어셈블리 래퍼 실행
usys.S 파일에 정의된 어셈블리 코드가 실행됩니다
.globl getpid
getpid:
movl $SYS_getpid, %eax # 시스템 콜 번호를 eax 레지스터에 저장
int $T_SYSCALL # 소프트웨어 인터럽트 발생
ret # 결과값(eax)과 함께 반환
여기서 SYS_getpid는 syscall.h에 정의된 시스템 콜 고유 번호이고, T_SYSCALL은 시스템 콜용 인터럽트 벡터입니다.
3단계: 커널 모드 전환
int $T_SYSCALL 명령이 실행되면 하드웨어적으로 다음 일들이 일어납니다
4단계: 트랩 처리
trap.c의 trap() 함수가 실행되어 인터럽트 원인을 분석합니다
void trap(struct trapframe *tf)
{
if(tf->trapno == T_SYSCALL){
if(myproc()->killed)
exit();
myproc()->tf = tf;
syscall(); // 시스템 콜 디스패처 호출
if(myproc()->killed)
exit();
return;
}
// 다른 인터럽트 처리...
}
5단계: 시스템 콜 디스패치
syscall.c의 syscall() 함수가 실제 커널 함수를 찾아 실행합니다
void syscall(void)
{
int num;
struct proc *curproc = myproc();
num = curproc->tf->eax; // 시스템 콜 번호 추출
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
curproc->tf->eax = syscalls[num](); // 함수 실행 후 결과를 eax에 저장
} else {
cprintf("%d %s: unknown sys call %d\n",
curproc->pid, curproc->name, num);
curproc->tf->eax = -1;
}
}
6단계: 커널 함수 실행
syscalls[] 배열을 통해 실제 커널 함수(예: sys_getpid())가 실행됩니다
int sys_getpid(void)
{
return myproc()->pid;
}
위 내용을 바탕으로 initial-xv6 프로젝트를 진행하겠습니다. getreadcount()는 시스템이 시작된 후 read() 시스템 콜이 총 몇 번 호출되었는지를 반환하는 함수입니다.
read() 시스템 콜 호출 횟수를 추적해야 함시스템 콜을 추가하려면 다음 5개 파일을 수정해야 합니다
| 파일명 | 역할 | 수정 내용 |
|---|---|---|
syscall.h | 시스템 콜 번호 정의 | 새로운 시스템 콜 번호 매크로 추가 |
user.h | 사용자 API 선언 | 함수 프로토타입 선언 |
usys.S | 어셈블리 래퍼 | 시스템 콜 래퍼 함수 생성 |
sysproc.c | 커널 함수 구현 | 실제 기능을 수행하는 커널 함수 작성 |
syscall.c | 시스템 콜 테이블 | 번호와 함수를 매핑하는 테이블 수정 |
먼저 read() 시스템 콜의 호출 횟수를 추적하는 메커니즘을 구현해야 합니다.
전역 카운터 변수 추가 (src/sysproc.c)
#include "types.h"
#include "x86.h"
#include "defs.h"
#include "date.h"
#include "param.h"
#include "memlayout.h"
#include "mmu.h"
#include "proc.h"
// 전역 변수로 read() 호출 횟수 추적
int readcount = 0;
int
sys_fork(void)
{
return fork();
}
// ... 기존 함수들 ...
read() 시스템 콜 수정 (src/sysfile.c)
sys_read() 함수가 호출될 때마다 카운터를 증가시키도록 수정합니다
int
sys_read(void)
{
struct file *f;
int n;
char *p;
readcount++; // read 호출 횟수 증가
if(argfd(0, 0, &f) < 0 || argint(2, &n) < 0 || argptr(1, &p, n) < 0)
return -1;
return fileread(f, p, n);
}
여기서 주목할 점은 readcount 변수가 sysproc.c에 정의되어 있으므로 extern 키워드를 사용해서 외부 변수임을 명시해야 한다는 것입니다.
src/syscall.h 수정
// System call numbers
#define SYS_getreadcount 22 // 새로운 시스템 콜 번호 추가
시스템 콜 번호는 1부터 시작하며, 기존에 정의된 마지막 번호 다음을 사용합니다. 중복되거나 0을 사용하면 안 됩니다.
src/user.h 수정
사용자 프로그램에서 getreadcount()를 호출할 수 있도록 함수 프로토타입을 선언합니다
struct stat;
struct rtcdate;
// ... 기존 시스템콜 ...
int uptime(void);
int getreadcount(void); // 새로운 시스템 콜 선언
src/usys.S 수정
매크로를 사용해서 어셈블리 래퍼 함수를 생성합니다
#include "syscall.h"
#include "traps.h"
#define SYSCALL(name) \
.globl name; \
name: \
movl $SYS_ ## name, %eax; \
int $T_SYSCALL; \
ret
SYSCALL(fork)
SYSCALL(exit)
SYSCALL(wait)
SYSCALL(pipe)
SYSCALL(read)
SYSCALL(write)
SYSCALL(close)
SYSCALL(kill)
SYSCALL(exec)
SYSCALL(open)
SYSCALL(mknod)
SYSCALL(unlink)
SYSCALL(fstat)
SYSCALL(link)
SYSCALL(mkdir)
SYSCALL(chdir)
SYSCALL(dup)
SYSCALL(getpid)
SYSCALL(sbrk)
SYSCALL(sleep)
SYSCALL(uptime)
SYSCALL(getreadcount) # 새로운 시스템 콜 래퍼 추가
SYSCALL(getreadcount) 매크로는 다음과 같은 어셈블리 코드를 생성합니다
.globl getreadcount
getreadcount:
movl $SYS_getreadcount, %eax
int $T_SYSCALL
ret
src/sysproc.c 수정
실제 기능을 수행하는 커널 함수를 구현합니다
#include "types.h"
#include "x86.h"
#include "defs.h"
#include "date.h"
#include "param.h"
#include "memlayout.h"
#include "mmu.h"
#include "proc.h"
extern int readcount; // sysfile.c에 정의된 외부 변수
int
sys_fork(void)
{
return fork();
}
// ... 기존 함수들 ...
int
sys_getreadcount(void)
{
return readcount;
}
이 함수는 몇 가지 중요한 특징이 있습니다
sys_ 접두사를 가집니다.argint, argptr 등)를 통해 추출합니다.eax 레지스터로 전달됩니다.src/syscall.c 수정
마지막으로 시스템 콜 번호와 실제 함수를 연결하는 테이블을 수정합니다:
#include "types.h"
#include "defs.h"
#include "param.h"
#include "memlayout.h"
#include "mmu.h"
#include "proc.h"
#include "x86.h"
#include "syscall.h"
// User code makes a system call with INT T_SYSCALL.
// System call number in %eax.
// Arguments on the stack, from the user call to the C
// library system call function. The saved user %esp points
// to a saved program counter, and then the first argument.
// Fetch the int at addr from the current process.
// ... 기타 함수들 ...
extern int sys_chdir(void);
extern int sys_close(void);
extern int sys_dup(void);
extern int sys_exec(void);
extern int sys_exit(void);
extern int sys_fork(void);
extern int sys_fstat(void);
extern int sys_getpid(void);
extern int sys_kill(void);
extern int sys_link(void);
extern int sys_mkdir(void);
extern int sys_mknod(void);
extern int sys_open(void);
extern int sys_pipe(void);
extern int sys_read(void);
extern int sys_sbrk(void);
extern int sys_sleep(void);
extern int sys_unlink(void);
extern int sys_wait(void);
extern int sys_write(void);
extern int sys_uptime(void);
extern int sys_getreadcount(void); // 새로운 함수 선언
// 시스템 콜 테이블
static int (*syscalls[])(void) = {
[SYS_fork] sys_fork,
[SYS_exit] sys_exit,
[SYS_wait] sys_wait,
[SYS_pipe] sys_pipe,
[SYS_read] sys_read,
[SYS_kill] sys_kill,
[SYS_exec] sys_exec,
[SYS_fstat] sys_fstat,
[SYS_chdir] sys_chdir,
[SYS_dup] sys_dup,
[SYS_getpid] sys_getpid,
[SYS_sbrk] sys_sbrk,
[SYS_sleep] sys_sleep,
[SYS_uptime] sys_uptime,
[SYS_open] sys_open,
[SYS_write] sys_write,
[SYS_mknod] sys_mknod,
[SYS_unlink] sys_unlink,
[SYS_link] sys_link,
[SYS_mkdir] sys_mkdir,
[SYS_close] sys_close,
[SYS_getreadcount] sys_getreadcount // 새로운 매핑 추가
};
여기서 syscalls[] 배열은 함수 포인터 배열이며, 배열 인덱스는 시스템 콜 번호와 대응됩니다.
모든 파일을 수정한 후 xv6를 빌드합니다
$ make clean
$ make
시스템 콜이 제대로 작동하는지 확인하기 위한 간단한 테스트 프로그램을 작성해보겠습니다
// test_getreadcount.c
#include "types.h"
#include "stat.h"
#include "user.h"
int
main(int argc, char *argv[])
{
int count1, count2;
char buf[1];
int fd;
printf(1, "Initial read count: %d\n", getreadcount());
fd = open("README", 0);
if(fd < 0){
printf(1, "Cannot open README\n");
exit();
}
count1 = getreadcount();
printf(1, "Before read operations: %d\n", count1);
read(fd, buf, 1);
read(fd, buf, 1);
read(fd, buf, 1);
count2 = getreadcount();
printf(1, "After 3 read operations: %d\n", count2);
printf(1, "Read count increased by: %d\n", count2 - count1);
close(fd);
exit();
}
Makefile 수정
테스트 프로그램을 빌드하려면 Makefile의 UPROGS 섹션에 추가해야 합니다
UPROGS=\
_cat\
_echo\
_forktest\
_grep\
_init\
_kill\
_ln\
_ls\
_mkdir\
_rm\
_sh\
_stressfs\
_usertests\
_wc\
_zombie\
_test_getreadcount\ # 테스트 프로그램 추가
$ make
$ make qemu-nox
# xv6가 부팅된 후
$ test_getreadcount
Initial read count: 0
Before read operations: 0
After 3 read operations: 3
Read count increased by: 3
$
현재 구현에서 readcount는 단순한 전역 변수입니다. 멀티프로세싱 환경에서는 race condition이 발생할 수 있습니다. 예를 들어, 두 개의 프로세스가 동시에 read()를 호출하면 카운터가 올바르게 증가하지 않을 수 있습니다.
더 안전한 구현을 위해서는 락(lock)을 사용해야 합니다. 사실 락에 대한 부분은 마지막 프로젝트에서 언급되기 때문에 테스트코드에서는 lock을 쓰지 않아도 통과가 됩니다.
struct spinlock readcount_lock;
int readcount = 0;
// 초기화 코드 (main.c에서)
void main(void)
{
// ... 기존 초기화 ...
initlock(&readcount_lock, "readcount");
// ...
}
// sys_read()에서
int sys_read(void)
{
// ... 기존 코드 ...
acquire(&readcount_lock);
readcount++;
release(&readcount_lock);
// ... 나머지 코드 ...
}
시스템 콜 호출은 상당한 오버헤드를 가집니다
getreadcount() 같은 단순한 함수도 일반 함수 호출보다 수십 배에서 수백 배 느릴 수 있습니다.
이번 글에서는 xv6에 새로운 시스템 콜을 추가하는 전체 과정을 살펴보았습니다. 단순해 보이는 시스템 콜 하나를 추가하기 위해서도 사용자 공간부터 커널 공간까지의 전체 흐름을 이해하고 여러 파일을 일관성 있게 수정해야 함을 확인했습니다.
다음 글에서는 xv6에 스케줄링 기능을 추가해보겠습니다.