본문 바로가기

운영체제/OSTEP

[운영체제][OSTEP] 06. Meachanism: Limited Direct Execution - System Call과 Trap

물리적인 CPU가 하나임에도 불구하고 여러 프로세스가 동시에 실행되는것 처럼 보이게 하려면(virtualize the cpu), 어떻게 해야 할까?

 

기본 아이디어는 간단한다. 바로 시분할(time sharing) 이다. 한 프로세스를 잠깐 실행하고, 잠시 멈추고, 다른 프로세스를 잠깐 실행하는 것을 계속해서 반복하는 것이다. 하지만 이러한 가상화에는 몇 가지 고려 사항이 있다.

 

첫 번째는 성능(Performance)이다. 시분할을 구현했을 때, 과중한 오버헤드가 발생하면 컴퓨터의 속도가 느려질 것이다.

 

두 번째는 프로세스에 대한 제어(Control)이다.프로세스는 필연적으로 메모리나 CPU, I/O Device와 같은 리소스를 사용한다. 프로세스가 리소스를 사용하는 것을 통제할 수 있어야 한다. 제어권을 잃어버려 프로세스가 영구적으로 리소스를 점유하고 사용한다면? 디스크를 비롯한 I/O Device를 마음대로 이용할 수 있게 하여 컴퓨터 내부의 민감한 정보를 모두 볼 수 있게 된다면? 물리적으로 전력을 차단하기 전까지는 프로세스를 종료할 수 없을 것이다.


Limited Direct Execution

상기한 조건을 만족하기 위해 운영체제 개발자들이 처음 생각한 기술은 Limited Direct Execution (제한적 직접 실행)이다.

 

Limted가 아닌 Direct Execution(직접 실행)의 아이디어는 간단하다. 말 그대로 프로그램을 CPU에서 직접 실행하는 것이다.  이 때 운영체제가 프로그램을 실행하는 과정은 다음과 같다.

 

1. 프로세스 리스트에 프로세스 항목(process entry) 를 만들고 메모리를 할당한다.

2. 디스크에서 프로그램 코드를 읽어 메모리에 탑재한다.

3. main()과 같은 entry point의 위치를 찾아 분기하여 코드를 실행한다.

 

 

https://pages.cs.wisc.edu/~remzi/OSTEP/cpu-mechanisms.pdf

 

Figure 6.1은 기본적인 Direct Execution Protocol이 작동하는 방식을 보여준다. 프로세스에는 아직 아무런 제한도 없다. 그저 일반적인 call return을 통해 main()으로 분기(jump)하고 다시 커널로 돌아온다.

 

매우 간단하지만 이런 방식은 CPU를 가상화라는 측면에서 보았을 때 몇 가지 문제가 발생할 수 있다.

 

Problem 1. 프로세스가 제어권을 갖고 사용자가 원하지 않는 일을 한다면?

Problem 2. Time Sharing을 위해서는 프로세스를 잠깐 멈추고 다른 프로세스로 전환해야 하는데 이를 어떻게 수행할까?

 

즉, 프로세스가 제어권을 갖기 때문에 return 이전까지는 외부에서 개입할 방법(일시정지를 하거나, 종료하거나)이 없다. 그러면 이제 이 문제들을 어떻게 해결했는지 살펴보며 Direct Execution에 Limited가 어떻게 가미되었는지 살펴보자.

 

Problem 1. Restricted Operations

핵심: 어떻게 제한된 연산들을 수행할 것인가?

프로세스에게 시스템에 대한 완전한 통제권을 주지 않으면서도 프로세스가 I/O나 어떤 다른 종류의 제한된 연산(Restricted Operation)들을 수행할수 있게 해줘야 한다. 운영체제와 하드웨어는 어떻게 협력해서 이를 가능하게 할까?

 

사실 직접 실행(Direct Execution)은 빠르다. 프로그램이 어떤 간섭도 없이 CPU에서 직접 실행되기 때문이다. 하지만 이 경우 고민거리가 생긴다. 만약 프로세스가 I/O Request, 혹은 CPU / 메모리와 같은 컴퓨터 자원에 대한 추가 할당같은 특수한 연산을 수행해야 한다면 어떻게 할 것인가?

 

(1) 첫 번째 접근 방식은 프로세스에게 모든 권한을 주는 것이다. 하지만, 이 경우 좋은 시스템이라면 갖춰야 할 많은 기능들을 잃는다. 사용자가 파일에 접근하기 전에 권한을 확인하는 파일 시스템을 구축하고 싶다고 가정해보자. 그런데 만약 모든 사용자가 I/O를 자유롭게 할 수 있다면 이 시스템은 성립할 수가 없다.

 

(2) 이를 해결하기 위해 User Mode로 알려진 CPU의 모드를 도입할수 있다. 유저 모드에서 실행되는 코드에는 제약이 따른다. 이를테면 I/O 요청을 할 수 없다. 유저 모드의 코드가 I/O를 요청하면 예외가 발생하고 OS는 프로세스를 제거한다. 유저 모드의 반대에는 Kernal Mode가 있다. 커널 모드에서는 운영 체제가 실행되며, I/O 요청을 비롯한 기능을 실행할수 있는 모든 권한을 가지고 있다.

 

그러면 사용자 모드의 프로세스는 I/O와 같은 제한된 연산들을 수행하기 위해서 무엇을 해야할까? 이를 위해 사실상 거의 모든 현대의 하드웨어는 유저 프로그램이 시스템 콜(System Call)을 실행하는 기능을 제공한다. 시스템 콜을 통해 커널은 I/O, 프로세스간 통신, 프로세스 생성 및 파괴, 메모리 추가 할당 등의 주요 기능을  유저 프로그램이 사용하게 할 수 있다.

 

시스템 콜을 실행하기 위해서 프로그램은 트랩(trap) 특수 명령어를 사용해야 한다. 트랩 명령어는 커널로 진입하는 동시에 권한을 커널 모드로 상향한다. 커널에 진입하기만 하면 시스템은 권한이 필요한 어떤 연산이라도 수행할수 있으며, 시스템 콜을 호출한 프로세스가 요청한 작업을 수행한다. 작업이 끝나면 return-from-trap 특수 명령어를 통해 유저 프로그램으로 돌아가며, 권한 수준 또한 사용자 모드로 하향한다.

 

하드웨어는 트랩을 실행할 때 트랩을 호출한 프로세스의 레지스터를 충분히 저장해야 return-from-trap 명령어를 통해 올바른 프로세스에 올바른 상태로 돌아올 수 있다. x86 아키텍처 시스템의 경우를 예로 들면 CPU는 프로그램 카운터, 플래그, 그리고 다른 몇몇 레지스터를 프로세스 각각이 갖고 있는 커널 스택에 저장(push)한다. 그러면 return-from-trap 명령어는  이러한 레지스터들을 다시 빼내어(pop) 유저 프로그램의 실행을 재개한다. 시스템 종류에 따라 다른 컨벤션이 사용될 수도 있지만, 기본적인 컨셉은 비슷하다.

 

 

→ 쉽게 말하면, trap을 통해 커널로 진입하기 전에 유저 프로세스의 레지스터 정보를 저장해 놓고, return-from-trap 명령어를 통해 돌아왔을 때 해당 레지스터 정보를 통해 프로세스가 실행 중이던 상태로 돌아가 실행을 재개한다는 것이다.

 

다음으로 직면하는 문제는 트랩 이후 어떤 명령어(코드)를 실행해야 하는가이다. 시스템 콜을 호출한 프로세스는 분기할 명령어의 주소를 구체적으로 명시해선 안된다. 주소를 알면 프로그램은 커널 내부의 주소를 마음대로 점프하며 헤집고 다닐 수 있다. 따라서 커널은 트랩 발생시 실행할 코드를 세심하게 통제해야 한다.

 

커널은 이를 위해 부팅 시에 트랩 테이블(trap table)을 초기화한다. 컴퓨터는 커널 모드에서 부팅되고, 하드웨어를 원하는 대로 구성할 수 있다. OS가 하는 첫 번째 일은, 하드웨어에게 특정한 예외 이벤트가 발생하면 실행할 코드를 알려주는 것이다. 예를 들어, 하드디스크 인터럽트가 발생하거나, 키보드 인터럽트가 발생하거나, 프로그램이 시스템 콜을 호출하는 등의 상황이 벌어졌을 때 어떤 코드가 실행되어야 할지를 지정한다. 운영체제는 특별한 명령어를 통해 하드웨어에게 이러한 트랩 핸들러(trap handler)의 위치를 알려준다. 하드웨어는 정보를 전달받으면 재부팅 전까지 트랩 핸들러들의 위치를 기억하며, 시스템 콜이나 예외가 발생했을 때 무엇을 할 지 알게 된다.

 

 

정확히 어떤 시스템 콜을 호출할지 특정하기 위해 각 시스템 콜에는 번호가 할당된다 (System-call number). 따라서 사용자의 코드는 원하는 시스템 콜의 번호를 레지스터 혹은 스택의 특정 영역에 위치시켜야 한다. 그러면 OS는 트랩 핸들러 내부의 시스템 콜을 조작할때, 시스템 콜 번호가 타당한지 검증하고, 알맞은 코드를 실행한다. 이러한 간접 참조는 일종의 (커널에 대한) 보호(Protection)로 작용한다. 사용자 코드는 커널 내부의 어떤 코드로 점프할 지 알수 없고, 번호를 통해서만 특정 서비스를 요청할 수 있기 때문이다.

 

 

→ 위 내용을 요약하고, 추가적으로 더욱 자세히 설명하면 다음과 같다.

    • 해당 책에서는 Trap Table이라는 추상화된 용어를 사용한다. x86 아키텍처에서는 Trap Table의 역할을 (Interrupt Descriptor Table(IDT)가 수행한다. 
    • Trap Table과 System Call Table을 반드시 구분해야 한다.
    • Trap Table = Trap발생 시 실행할 Trap Handler의 주소를 알려주는 테이블. Trap의 번호와 Trap Handler가 매핑되어 있다.
    • System Call Table = 시스템 콜에 해당하는 Trap이 발생했을 때, 시스템 콜의 번호에 해당하는 적절한 System Call Handler의 주소를 알려준다.

 

 

xv6의 traps.h를 보면 교재에서 말하는 trap table이 무엇인지 대략적인 구조를 파악할 수 있다.

// x86 trap and interrupt constants.

// Processor-defined:
#define T_DIVIDE         0      // divide error
#define T_DEBUG          1      // debug exception
#define T_NMI            2      // non-maskable interrupt
#define T_BRKPT          3      // breakpoint
#define T_OFLOW          4      // overflow
#define T_BOUND          5      // bounds check
#define T_ILLOP          6      // illegal opcode
#define T_DEVICE         7      // device not available
#define T_DBLFLT         8      // double fault
// #define T_COPROC      9      // reserved (not used since 486)
#define T_TSS           10      // invalid task switch segment
#define T_SEGNP         11      // segment not present
#define T_STACK         12      // stack exception
#define T_GPFLT         13      // general protection fault
#define T_PGFLT         14      // page fault
// #define T_RES        15      // reserved
#define T_FPERR         16      // floating point error
#define T_ALIGN         17      // aligment check
#define T_MCHK          18      // machine check
#define T_SIMDERR       19      // SIMD floating point error

// These are arbitrarily chosen, but with care not to overlap
// processor defined exceptions or interrupt vectors.
#define T_SYSCALL       64      // system call
#define T_DEFAULT      500      // catchall

#define T_IRQ0          32      // IRQ 0 corresponds to int T_IRQ

#define IRQ_TIMER        0
#define IRQ_KBD          1
#define IRQ_COM1         4
#define IRQ_IDE         14
#define IRQ_ERROR       19
#define IRQ_SPURIOUS    31

 

 

x86 아키텍처에서는 trap table 대신 Interrupt descriptor table (IDT)이라는 용어를 더욱 많이 사용한다.

그 이전은 Interrupt vector table이라는 개념이었다고 하는데... 일단 IDT까지만...

 

IDT는 interrupt나 exception이 발생했을 때 CPU가 실행해야 하는 Handler의 메모리 주소를 결정하기 위해 사용된다. 위에 있는 xv6의 traps.h와 상당히 비슷해 보인다.

 

 

https://en.wikipedia.org/wiki/Interrupt_descriptor_table

 

Interrupt descriptor table - Wikipedia

From Wikipedia, the free encyclopedia The interrupt descriptor table (IDT) is a data structure used by the x86 architecture to implement an interrupt vector table. The IDT is used by the processor to determine the memory addresses of the handlers to be exe

en.wikipedia.org

그렇다면 실제로 사용자 모드에서 커널 모드로 전환하는 과정은 어떻게 이루어질까?

https://manybutfinite.com/post/cpu-rings-privilege-and-protection/

 

CPU Rings, Privilege, and Protection

You probably know intuitively that applications have limited powers in Intel x86 computers and that only operating system code can perform certain tasks, but do you know how this really works? This p

manybutfinite.com

 

IDT를 보면 0번부터 32번까지의 interrupt number는 Processor Exception을 위한 영역으로 예약되어 있고, 하드웨어 인터럽트를 처리하기 위한 부분도 보인다. IDT는 총 256개의 Interrupt Vector (Handler를 위한 주소 공간)로 이루어져 있는데,

운영체제나 사용자 사용되지 않는 부분에 새로운 Interrupt를 정의할 수 있다. 전통적으로는 0x80에 trap을 지정해왔다고 한다. 물론, 현대의 아키텍처와 운영체제들은 이렇게 직접 인터럽트를 발생시키는 경우는 거의 없고, ISA에서 직접 System Call을 위한 명령어를 제공하는듯 하다 (x86 -> sysenter, x86-64 -> syscall)

 

 

어쩄든, 상기한 IDT는 사실 굉장히 간략하게 표현되어 있다. 부연하자면, IDT의 각 엔트리는 Interrupt number와 그에 해당하는 Gate Descriptor로 이루어져있다. Gate Descriptor에는 Descriptor privilege level(DPL) 및 Gate type(Trap Gate, Interrupt Gate)를 비롯한 여러 정보가 포함되어 있다. Gate Type에 따라서 인터럽트 발생 시 갱신되는 레지스터 및 스택 정보가 달라진다. 일반적으로 System Call을 호출하기 위한 Software Interrupt는 Trap Gate에 해당한다.

 

 

Protection Ring

privilege level은 말 그대로 권한 레벨을 나타낸다. CPU는 Protection Ring이라는, 권한 레벨을 나타내는 0부터 3까지의 단계를 지닌다.여기서 Ring 0에 해당하는 것이 커널 모드 Ring 3에 해당하는 것이 사용자 모드이다. 이 권한 레벨에 대한 정보를 나타내는 데이터는 여러가지가 있지만, 일단 CS Register (Code Segment Register)에 있는 2개의 비트로 나타난다. (00, 01, 10, 11).

 

다시 IDT로 돌아오면, Gate Descriptor의 DPL은 해당 Interrupt를 발생시키기 위한 최소 권한 레벨을 나타낸다. 즉, CPU의 현재 권한 레벨(Current Privilege Level, CPL)이 DPL보다 같거나 작은 상황에서만 (CPL <= DPL) Interrupt number에 해당하는 interrupt를 발생시킬 수 있다는 뜻이다.

 

만약 인터럽트를 발생시키는 것이 가능하다면, 레지스터와 스택의 정보 (ESP, EIP, SS, CS)가 갱신되고, 커널 모드로의 전환이 이루어진다. 굉장히 많이 요약하고 추상화해서 설명하면, 이렇게 레지스터와 스택의 정보가 갱신되는것이 바로 사용자 모드에서 커널 모드로의 전환이 일어난 것이라고 할수 있을 것이다...?

 

사실 방금 설명한 부분들은 오류가 있을 수 있다. 아키텍처 및 운영체제의 구현에 따라 다르고, 구버전 운영체제와 신버전 운영체제에서 각각 미묘한 차이가 있기 때문이다. 앞서 말했듯이, System Call을 위한 명령어를 ISA에서 직접 제공하는 경우도 있고 말이다. 더욱 정확한 실제 구현을 참고하고 싶으면, 아래에 첨부한 글들을 읽어보는 편이 더 좋을 것이다!

 

 

x86-64에서의 linux syscall

https://0xax.gitbooks.io/linux-insides/content/SysCall/linux-syscall-1.html

 

Introduction to system calls · Linux Inside

 

0xax.gitbooks.io

https://0xax.gitbooks.io/linux-insides/content/SysCall/linux-syscall-2.html

 

How the Linux kernel handles a system call · Linux Inside

 

0xax.gitbooks.io

 

MIT의 xv6을 기반으로 하는 설명

 

https://pages.cs.wisc.edu/~gerald/cs537/Summer17/handouts/traps.pdf

 

 

-----------------------------

그렇다면 이제 과정을 요약해서 설명해보자.

 

0. 운영체제는 부팅될 때 Trap Table에 Trap 발생시 수행할 Trap Handler를 초기화한다. (앞서 말한 IDT와 Gate Descriptor의 초기화)

 

 

1. 유저 프로세스가 I/O 작업과 같이 시스템 콜이 필요한 서비스를 요청한다. (read, write 등)

2. 요청을 처리할 라이브러리는 요청 처리를 위한 적절한 시스템 콜의 번호(system-call number)를 레지스터에 저장한다. 보통 이 번호는 eax (x86)나 rax(x86-64) 레지스터에 저장된다.

 

3. 이후 trap을 발생시킨다. trap의 발생 방법은 int 0x80을 직접 호출하거나, syscall을 쓰거나 sysenter를 쓰거나... 아키텍처에 따라 다르다.

 

4. 유저 프로세스의 정보가 저장되고, 커널 모드로 변경되고, 커널 스택에 되돌아가야 할 유저 프로세스의 정보가 push된다.

 

5. 커널은 2번에서 레지스터에 저장한 system call number를 기반으로 어떤 시스템 콜을 호출할지 찾는다.

 

 

다음 링크는 리눅스시스템 콜 테이블이다 (트랩 테이블이 아닌 시스템 콜 테이블이다)

https://github.com/torvalds/linux/blob/master/arch/x86/entry/syscalls/syscall_64.tbl

 

linux/arch/x86/entry/syscalls/syscall_64.tbl at master · torvalds/linux

Linux kernel source tree. Contribute to torvalds/linux development by creating an account on GitHub.

github.com

 

5. 해당하는 System Call을 호출한 후,  커널은 결과를 레지스터(eax 혹은 rax) 에 저장한다. 그리고 커널은 다시 return-from-trap (iret, sys-exit등) 을 발생시킨다. CPU는 4에서 저장한 유저 프로그램의 정보를 커널 스택에서 다시 가져온 후, 유저 프로그램으로 제어권을 반환한다. 유저 프로그램은 레지스터에 저장된 결과값을 읽고 다음 작업을 처리한다.