현대 운영체제는 유저 공간에 있는 프로세스가 시스템과 상호작용할 수 있는 인터페이스를 제공한다.
시스템콜은 하드웨어와 user-space의 프로세스간의 레이어를 제공한다. 크게 3가지 목적이 있는데, 첫째는 추상화된 하드웨어 인터페이스를 제공하는 것이다. 어떤 디스크를 이용하는지 알 필요 없이 파일을 읽고 쓸 수 있다. 두번째로 시스템콜은 시스템의 보안과 안정성을 보장한다. 커널은 권한, 유저, 및 다른 기준으로 접근을 제한할 수 있다. 마지막으로 user-space와 시스템 사이의 단일 레이어는 프로세스로 하여금 가상화된 시스템을 제공한다. 따라서 프로세스가 시스템의 자원을 함부로 접근하지 않는다. 리눅스에선 시스템콜은 커널로 진입할 수 있는 유일한 방법이다(단, exception과 trap은 제외).
애플리케이션들은 시스템 콜을 직접 실행하는 것이 아닌 유저공간에서 API를 호출해 사용하도록 되어있다. 유닉스 계열에서 가장 일반적인 API는 POSIX 표준이다. 리눅스에서의 시스템 콜 인터페이스는 C 라이브러리에 의해 부분적으로 제공된다.
시스템 콜은 long 타입을 반환하는데, 보통 이 값이 음수이면 실패를 의미하고, 0이면 성공을 의미한다. 또한 시스템 콜이 에러를 반환하면, 에러코드를 전역변수인 errno
에 쓴다. 시스템 콜의 정의 코드는 아래와 같다.
asmlinkage long sys_getpid(void)
모든 시스템 콜은 asmlinkage
를 modifier로 가진다. 또한 호환성을 위해 시스템 콜은 long을 반환한다. 함수명은 naming convention으로 앞에 sys_
를 붙인다.
리눅스에선 각각의 시스템 콜은 syscall number라는 고유한 숫자를 부여받는다. 이는 한번 할당되면 변하지 않는다. 시스템 콜이 삭제되어도 해당 syscall number는 재사용될 수 없다. 커널은 등록된 시스템 콜을 system call table에 저장한다.
리눅스에서의 시스템 콜은 다른 운영체제에 비해 빠르다. 이는 리눅스의 context switch가 빠르고, 시스템 콜 핸들러와 시스템 콜 자체가 간단하기 때문이다.
User-space의 애플리케이션이 커널 코드를 직접 실행할 수 없고, 커널에 시스템 콜을 실행하고 싶고, 커널모드로 전환하고 싶다는 신호를 커널에 보내야한다. 커널에 신호를 보내는 방법은 interrupt
이다: 예외를 발생시켜 커널모드에서 예외 핸들러를 실행시키는 것. x86에서 시스템 콜 핸들라의 인터럽트 번호는 128이다. 최근 x86 프로세서는 sysenter라는 기능을 도입했다. 기존에 인터럽트 번호를 이용한 실행보다 더 빠르고 더 특별한 방법을 커널에 trap 한다고 한다. 핵심은 user-space가 커널에 진입하기위해 예외나 trap을 발생시키면 시스템 콜 핸들러가 실행된다는 것이다.
시스템 콜이 유효한지 확인하기 위해 시스템 콜 번호를 커널로 전달하여 확인한다. system_call() 함수는 전달받은 시스템 콜 번호를 NR_syscalls
와 비교한다. 만약 NR_syscalls
보다 크거나 같다면 -ENOSYS
를 반환한다.
대부분의 시스템 콜은 하나 이상의 parameter를 요구한다. 따라서 trap중에 user-space에서 커널에 인자를 전달해야한다. 가장 간편한 방법은 시스템 콜 번호가 전달되는 것과 마찬가지로, 인자를 레지스터에 저장하는 방법이다. 다만 레지스터 개수에 한계가 있기 때문에, 6개 이상의 인자가 존재하는 경우, 해당 데이터가 담긴 포인터를 하나의 레지스터에 저장하여 진행한다.
리눅스에서 시스템 콜의 구현은 시스템 콜 핸들러의 행동을 고려하지 않는다. 시스템 콜을 등록하는 것은 쉽지만, 이를 구현하고 디자인 하는 것이 정말 어렵다.
시스템 콜을 구현하기 앞서, 그것의 목적을 정의해야한다. 시스템 콜은 명확하게 하나의 목적만을 가져야한다. 시스템 콜은 또한 간단명료한 인터페이스와 함께 적은 수의 인자를 가져야한다. 또한 시스템 콜의 동작이 가지는 의미가 변하지 않도록 설계해야하고, 새로운 기능의 추가나 호환성을 해치지 않는 선에서 버그를 수정할 수 있는지 등도 중요하다. 많은 시스템 콜은 플래그를 인자로 갖는데, 이 옵션은 하위 호환성을 해치지 않으면서 새로운 기능을 가능하도록 한다(여러 동작들을 구분하기 위함이 아니다! 시스템 콜 하나에는 하나의 목적을 가진 동작만 허용하는 것이 기본 원칙이다).
시스템 콜을 작성할 때 portablity와 robustness의 필요성에 대해 생각해야한다.
시스템 콜은 인자로 들어온 값들이 유효하고 옳은지 반드시 확인해야한다. File descriptor가 유효한지, PID가 유효한지, 접근하고자 하는 리소스에 권한이 있는지 등 면밀하게 확인해야한다. 중요하게 확인해야 할 것중 하나가 유저가 제공한 포인터들의 유효성이다. 아래의 사항들을 확인해본다.
커널은 위의 사항을 체크하기 위해 두 방법을 제공한다. 유저 공간에 쓰는 경우, copy_to_user()
를 이용한다. 유저 공간에서 읽어오는 경우, copy_from_user()
를 이용한다. 두 함수 모두 실패한 바이트 수를 반환하고, 성공하면 0을 반환한다.
SYSCALL_DEFINE3(silly_copy,
unsigned long *, src,
unsigned long *, dst,
unsigned long len)
{
unsigned long buf;
if(copy_from_user(&buf, src, len)) // 실패하면 참, -EFAULT 반환
return -EFAULT;
if(copy_to_user(dst, &buf, len))
return -EFAULT;
return len;
}
마지막으로 유효한 권한인지 확인해야한다. 과거에는 시스템 콜을 위해 루트 권한이 필요했고, suser()
함수를 통해 루트인지 확인하였다. 현재는 이것이 삭제되고 capable()
함수를 통해 caller의 특정 자원에 대한 특정 권한을 확인한다. 권한이 있다면 nonzero를 반환하고, 권한이 없다면 0을 반환한다.
시스템 콜 실행중에 커널은 프로세스의 컨텍스트에 놓인다. current
포인터가 현재 태스크(시스템 콜을 호출한)를 가리킨다. Process Context에서 커널은 sleep 할 수 있고, fully preemptible하다. 이 두가지 가능성이 중요한데, 7장 부터 이에 관해 설명한다.
시스템 콜을 작성하면, 아래와 같이 등록할 수 있다.
시스템 콜 테이블의 매 다섯번째 엔트리마다 번호를 적어주는 것이 규약이다.
리눅스에서는 시스템 콜에 접근할 수 있는 매크로를 제공한다. _syscalln()
을 이용해 실행하고자 하는 시스템 콜을 실행할 수 있다(n은 0-6까지, 인자 수).
#define __NR_foo 283
_syscall0(long, foo)
int main() {
long stack_size;
stack_size = foo();
printf("The kernel stack size is %ld\n", stack_size);
return 0;
}
이전까지는 시스템 콜을 구현하고 등록하는 방법에 대해 논하였지만, 실제로는 구현하는 것을 권하지 않는다. 다음은 장단점과 대안에 관한 설명이다.
장점:
단점:
대안:
read()
와 write()
를 한다. ioctl()
을 이용해 설정값을 조작하거나 정보를 받아온다.리눅스는 단순히 새로운 abstraction을 지원하기 위해 시스템 콜을 추가하는 행위를 피하고 있고, 이로 인해 시스템 콜 레이어가 굉장히 깔끔하다. 때문에 상대적으로 안정적이고, 요소가 완벽한 운영체제라 할 수 있다.