pintos에서의 System Call의 흐름
이번 포스팅에서는 현재 과제로 수행하고 있는 아주 간단한 OS인 pintos에서 System Call이 어떻게 이루어지는지 알아보겠습니다.우선 시스템 콜이라는 개념을 소개하기전에 User 영역과 Kernel 영역에 대한 이야기를 먼저하겠습니다.
User Memory vs Kernel Memory
흔히 프로세스가 하나의 컴퓨터를 그대로 추상화한 단위라고 많이 얘기합니다.즉,컴퓨터가 가진 요소들을 프로세스가 동일하게 독립적으로 가지고 있다고 볼 수 있습니다.간단하게 컴퓨터는 CPU,메모리,IO장치를 최소한으로 가집니다.이를 프로세스에게 적용시켜보면 프로세스는 자신만의 메모리 주소,자신만의 가상 CPU 상태(레지스터를 통해),자신만의 I/O 상태 (file,socket 등)또한 가질 수 있습니다.즉,프로세스 본인이 느끼기에는 자신만이 사용하는 CPU,메모리영역,I/O영역이 있도록 설계가 된것입니다.
그러면 각각의 프로세스들은 자신들의 고유한 메모리 영역을 가진채 다양한 명령어들을 실행시키며 작업을 수행합니다.
그러면 이러한 프로세스들을 관리해주는 역할을 누가 수행해줄까요? 이가 바로 저희가 흔히 얘기하는 OS라는 소프트웨어입니다.여기서 저는 OS가 소프트웨어라는 표현을 사용했습니다.그러면 OS 또한 자신이 사용하는 메모리 영역이 존재해야할 것입니다.이가 바로 Kernel Memory 영역입니다.즉,OS가 배타적으로 사용할 수 있는 자신만의 메모리 영역이라고 생각하시면 됩니다.이러한 OS는 프로세스를 관리하는것 외에도 하드웨어와 가장 가까운 소프트웨어로서 하드웨어와 직접 소통할수 있는 유일한 소프트웨어입니다.
결론적으로 OS라는 소프트웨어가 사용하는 메모리 영역이 Kernel Memory입니다.그리고 저희가 자주 접하는 프로그램(Process)들은 User영역에서 돌아가면서 자신들이 할당받은 메모리 주소에만 접근하며 작업을 수행합니다.그리고 이러한 유저 영역을 사용하는 프로그램들이 구동될 때 파일을 읽어오거나 파일을 쓰거나,혹은 키보드를 통해 메세지를 입력하는 등 다양한 부분에서 하드웨어의 지원이 필요합니다.이러한 경우 응용 프로그램은 시스템 콜을 사용해서 방금 말씀드린 하드웨어의 지원을 커널을 통해 받을 수 있습니다.
System Call
저희는 지금까지 저희가 작동시킨 App 또는 프로그램들이 유저 영역에서 돌아간다는것을 알게되었습니다.그러면 유져 영역와 커널 영역간의 어떠한 소통을 할 수 있는 창구를 하나 정도는 만들어 두지 않았을까라는 생각을 할 수도 있습니다.이러한 창구가 바로 지금부터 알아볼 System Call입니다.위키피디아에 의하면 시스템 콜은 다음과 같은 정의를 가집니다.
시스템 콜은 운영체제의 커널이 제공하는 서비스에 대해,응용 프로그램의 요청에 따라 커널에 접근하기 위한 인터페이스이다.보통 C와 같은 고급 언어로 작성된 프로그램은 직접 시스템 콜을 사용할수 없기에 고급 API를 통해 시스템 콜을 호출할 수 있다.
상당히 긴 정의입니다만,해석을 해보겠습니다.응용 프로그램,즉 저희가 작성한 코드에서 커널이 제공하는 서비스를 사용해야할 경우에 필요한 것이 바로 시스템 콜입니다.하지만 이와 같은 시스템 콜을 C와 같은 고급 언어에서 사용하기 위해서는 해당 시스템 콜을 직접 사용할 수 없고 각각의 언어에 정의된 API를 호출해서 사용해야합니다.즉,이번에 제가 진행한 pintos과제의 경우도 lib/user/syscall.h
를 실제 작성된 테스트 코드에서 호출하며 이를 통해 userprog/syscall.h
에 실제로 존재하는 시스템 콜이 호출되어집니다.
System Call이 작동하는 법
지금까지 글을 읽으면 시스템 콜이 유저 모드에서 돌아가는 프로그램이 커널 모드에서 수행할 수 있는 다양한 작업을 필요로 할때 사용하는것이라고 이해할 수 있습니다.단순히 이렇게 얘기하면 마치 유저 프로세스가 커널 프로세스에게 “내가 필요한 작업을 수행해주세요“라고 부탁하는것처럼 이해할수 있습니다.하지만 실상은 조금 다릅니다.
지금부터 하는 설명은 pintos의 단일 프로세스(쓰레드) 환경을 기준으로 작성된 것입니다.
태초에 쓰레드가 생성 되는 메모리 영역은 커널 영역입니다.그리고 예를 들어 해당 쓰레드에게 ls -als
라는 명령어를 수행하도록 하도록합니다.그러면 이때 exec()이라는 시스템 콜의 결과로 해당 쓰레드를 커널 모드에서 유저 모드로 변경시킵니다.사실 이때도 해당 쓰레드가 실제로 메모리에 존재하는곳은 여전히 커널모드입니다.그러면 어떻게 커널에서 작업하던 쓰레드를 유저영역에서 작업하도록 할수 있을까요? 이는 바로 해당 쓰레드가 가르키는 rsp레지스터의 주소를 커널 영역에서 유저 영역 변경시킵니다.이렇게 되면 실제로 해당 쓰레드가 명령어를 수행하며 사용하는 메모리 영역이 유저 메모리 영역으로 바뀌게 됩니다.
정리해보면 실제로 유저모드와 커널모드간의 차이는 해당 쓰레드가 가지는 rsp 레지스터가 가지는 주소가 어떤 영역에 존재하는지 따라 나뉘게 됩니다.
이제부터 코드를 보면 실제로 어떻게 시스템 콜이 작동하는지 알아봅시다.우선 유저 영역에서 작업하던 프로세스가 시스템 콜을 호출하여 커널 모드로 진입하였다고 가정해봅시다.그러면 자연스레 해당 시스템 콜을 마치고 나면 원래 유저 영역에서 작업하던 명령어로 돌아와야합니다.즉,하던일을 계속해야한다는것입니다.이를 가능하게 하는 구조체가 아래의 intr_frame
입니다.
struct intr_frame {
/* Pushed by intr_entry in intr-stubs.S.
These are the interrupted task's saved registers. */
struct gp_registers R;
uint16_t es;
uint16_t __pad1;
uint32_t __pad2;
uint16_t ds;
uint16_t __pad3;
uint32_t __pad4;
/* Pushed by intrNN_stub in intr-stubs.S. */
uint64_t vec_no; /* Interrupt vector number. */
/* Sometimes pushed by the CPU,
otherwise for consistency pushed as 0 by intrNN_stub.
The CPU puts it just under `eip', but we move it here. */
uint64_t error_code;
/* Pushed by the CPU.
These are the interrupted task's saved registers. */
uintptr_t rip;
uint16_t cs;
uint16_t __pad5;
uint32_t __pad6;
uint64_t eflags;
uintptr_t rsp;
uint16_t ss;
uint16_t __pad7;
uint32_t __pad8;
}
그러면 프로세스가 하던일로 돌아가게 하려면 어떻게 해줘야 할까요? 하나의 프로세스가 시스템 콜을 호출한 순간의 CPU의 레지스터 상태를 백업해두고 이를 시스템 콜 작업이 마친 뒤 사용하여 해당 쓰레드가 기존 작업을 수행하도록 해야합니다.즉,위의 intr_frame
은 시스템 콜을 호출한 그 순간의 레지스터의 상태를 백업해두는 구조체라고 생각하시면 됩니다.
최종적으로 시스템 콜이 어떻게 처리되는 정리해보겠습니다.하나의 프로세스가 시스템 콜을 호출합니다.그러면 해당 호출은 서두에 말씀드린 lib/user/syscall.c
의 시스템 콜을 호출합니다.그리고 해당 syscall.c
은 어셈블리 명령어의 집합인 syscall-entry.S
라는 파일을 호출합니다.여기 어셈블리어를 수행해주는 파일에서 현재 프로세스의 rsp을 커널 영역으로 바꿔주고 앞서 말씀드린 시스템 콜을 호출한 순간의 레지스터 값들을 intr_frame
를 만들어 백업시켜줍니다.이후 만들어진 intr_frame
을 저희가 구현한 userprog/syscall.c
의 syscall_handler
의 인자로 넘겨줍니다.
그렇게 되면 저희가 시스템 콜 함수들을 만들고 해당 함수의 리턴값들을 intr_frame
의 rax로 넘겨주며 시스템 콜을 작업을 완료합니다.
사실 이번주 과제를 수행하며 피부로 와닿았던 점은 실제 어플리케이션 레벨에서 사용하는 다양한 프레임워크들이 실제로 OS가 동작하는 원리를 많이 모방하여 만들어졌구나라는것을 알게되었습니다.