일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- Project 1
- multi-oom
- 핀토스 프로젝트 2
- 핀토스 프로젝트 3
- 아직도 실험 중
- 가테
- alarm clock
- 일지 시작한 지 얼마나 됐다고
- 핀토스 프로젝트
- 내일부터
- 핀토스 프로젝트 1
- 셋업
- 마일섬
- botw
- 글루민
- 바빠지나?
- 핀토스 프로젝트 4
- 파란 장미
- 자 이제 시작이야
- 글리치
- 제발흰박
- 끝
- 노가다
- 황금 장미
- PINTOS
- Today
- Total
거북이의 쉼터
(2022.04.09) Kernel Abstraction (2) 본문
암만 생각해도 책이 너무 난잡하다. 갑자기 왜 인터럽트 신나게 설명하다가 Syscall이랑 Upcall로 빠지는거야... 그리고 마스킹을 먼저 설명한 뒤에 스택을 한꺼번에 설명하던가.... 교수님 제발 다음 학기부터는 다른 책으로 진행해주세요....
0. 인터럽트 도입부
우선 다음을 Interrupt, Exception, None of them으로 구분해보자. 비동기적으로 HW에서 보내는 것은 Interrupt, 프로그램이 말그래도 예외적인 행동을 할 시에 발생되는 Exception으로 구분하면 간단하다.
- Keyboard input : Interrupt
- Writing a data to read only memory : Exception
- Segmentation fault : Exception
- Send a signal to a process : None
- A packet arrived to network card : Interrupt
- TLB flush : None
이제 구체적으로 인터럽트에 대해 좀 더 구체적으로 알아보도록 하자.
HW 측면에서 봤을 때 모든 HW는 IRQ라인으로 연결되는 하나의 선을 가진다. 또, 모든 IRQ 라인은 Programmable Interrupt Controller (줄여서 PIC) 라는 HW 서킷에 연결된다. 간단히 설명하자면 HW에서 보내는 모든 인터럽트는 일단 PIC로 모여서 후에 OS에 의해 처리되는 것이다. PIC의 동작에 관한 구체적인 내용은 여기 설명되어 있으니 참조하도록 한다.
간단히 예를 들어 I/O를 진행하려고 하는 프로세스가 있다고 하자. 디바이스에서 I/O가 이루어질 동안 CPU는 디바이스와는 비동기적으로 움직일 수 있다. 물론 커널에서 특정 주기마다 I/O가 완료가 되었는지 확인하는 Polling 기법을 활용한다면 구현은 쉽겠지만 커널이 I/O 진행이 이루어지는 동안 다른 일을 온전히 할 수 없다는 단점이 있다. 이를 위해 인터럽트를 활용하는 것이다. DMA를 이용해 디바이스 측에서는 I/O가 자체적으로 이루어지게 한 뒤, CPU에서 커널은 해야 할 다른 일을 진행하게 한다. 만약 DMA가 전부 완료가 된다면 디바이스에서 CPU로 인터럽트를 보내 CPU에서 I/O 작업이 마무리된 이후 해야 할 일들을 하게 하는 것이다.
그러면 PIC에서 보내온 인터럽트를 OS에서 어떻게 처리하는지가 중요하다. 인터럽트마다 어떤 동작을 할 지 OS는 미리 지정을 해 놓아야 하며, 이를 구분하기 위해 어떤 인터럽트가 들어오는지를 구별할 수 있는 식별 인자가 필요하다. 인터럽트는 무한 개가 아닐 것이기에 그 수를 제한할 수 있으며, 종류 별로 숫자를 붙여 구분하기로 하였다. 이것이 바로 인터럽트 벡터이다. 구체적인 동작에서 설명이 되어있듯, PIC 컨트롤러는 인터럽트 벡터의 번호를 프로세서에 전송한다. 이를 이용하여 어떤 동작을 하게될지는 OS에서 결정한다.
OS에서 입력된 인터럽트 벡터를 활용하는 방법은 아래 그림처럼 테이블을 활용하는 것이다.
OS에서 사전에 커널 공간 내에 설정해둔 인터럽트 벡터 테이블에서 적절한 핸들러를 골라 실행하게 한다. 해당 테이블은 보호된 레지스터에 의해서 접근할 수 있으며, 커널 공간에 해당 테이블을 두는 이유는 유저 어플리케이션이 이를 악의적으로 활용하는 것을 막기 위함이다. x86에서는 0~31번은 프로세서 exception 용도로 사용되고 있으며, 32~255번은 HW 인터럽트 용도로 활용되고 있다. 엔트리 개수가 총 256인 이유는 핀의 개수가 8개이기에 256개의 다른 신호를 지정할 수 있어서이다.
이제 어떤 핸들러를 실행할지도 결정이 되었으니 실행을 하면 된다. 실행이 이루어지게 하기 전 알아두어야 할 사항이 아직 남아있다. 유저 모드에서 유저 프로세스가 실행이 될 때, program counter와 스택은 유저 공간에 해당하는 곳을 가리킬 것이다. 그런데 인터럽트는 그 특성 상, 커널 모드에서 실행이 되며, program counter와 스택 또한 커널 공간에 해당하는 곳을 이용할 것이다. 최종적으로는 memory protection과 더불어 모드 변경까지 일어나야 하는데, 이 사항들을 변경하는 것이 단계적으로 일어난다면 보안상에 문제가 생길 수 있다. 때문에 여러 명령들이 "atomic"하게 이루어지도록 해야 한다. atomic하다는 것은 중간에 다른 명령이 끼어들 수 없고, 해당 명령이 한 번 실행될 때 온전히 끝까지 실행됨을 의미한다. "atom"이 더는 나누어질수 없는 것에서 기인한 것을 생각하면 이해가 쉬울 것이다. 인터럽트가 발생하면 program counter, stack pointer, memory protection, kernel/user mode 변환이 atomic하게 일어나야 하는 것이다.
또 한가지는 유저 프로세스 입장에서는 중간에 인터럽트가 발생했다는 사실을 전혀 몰라야 한다는 것이다. 이는 반대로, 인터럽트가 발생했을 때의 유저 프로세스 상태를 그대로 저장했다가 다시 유저 프로세스 실행으로 복귀할 때 해당 상태를 복원해서 실행해야 함을 의미한다. 인터럽트가 일어나면, CPU는 그 상태를 메모리에 임시적으로 저장한 뒤, 커널 모드로 atomic하게 전환하는 과정을 거쳐 인터럽트를 실행하고, 복귀할 때 저장해놓은 상태를 CPU에 복원하는 과정을 가진다. 이러한 과정을 통해 유저 프로세스는 인터럽트가 일어난 사실조차 모르게 된다. 이제 본격적으로 인터럽트가 실행되는 과정을 구체적으로 살펴보자.
1. 인터럽트 마스킹
우선 인터럽트가 동시에 여러개 들어온 상황을 생각해보자. 인터럽트 내에서 인터럽트를 또 받을 수 있다면 예외 처리등 여러 골치아픈 문제가 생길 것이다. 이러한 혼란을 막기 위해서 대부분의 프로세서는 인터럽트 핸들러가 실행될 동안에는 interrupt disable을 통해 추가적인 인터럽트가 접수되는 것을 방지한다. 물론 이 동안 발생하는 인터럽트는 완전히 무시되는 것은 아닌, 잠시동안 그 처리가 연기되는 것일 뿐이다. 다시 interrupt enable이 이루어지면 pending 되었던 인터럽트가 프로세서로 접수된다. 물론 저장하는 양에도 한계가 있기 때문에 너무 오랫동안 인터럽트가 비활성화된다면 일부 인터럽트는 손실될 수 있다.
인터럽트를 활성화, 비활성화하는 명령어는 자명히 privileged instruction이다. 만약 아니라면 영구적으로 인터럽트 처리를 방지하도록 하여 타이머 인터럽트를 막아둔 뒤 무한 루프를 돌리는 악의적인 프로세스로부터 시스템을 보호할 수 없기 때문이다.
2. 인터럽트 스택
인터럽트는 커널 모드에서 실행된다는 것을 고려할 때, 실행에 사용될 스택을 커널 공간에 하나 만드는 것은 어찌보면 당연한 선택이다. 이렇게 인터럽트 시에 커널 공간에서 사용되는 스택을 인터럽트 스택이라고 한다. 기존 유저 프로세스에서 사용되는 유저 공간의 스택을 사용하지 않는 이유로는 크게 두 가지가 있는데, 유저 스택 포인터가 신뢰할 수 있는지 여부를 알 수 없기 때문이며, 나머지 하나는 커널에서 사용될 변수를 유저 공간에 저장할 경우 변조 등의 보안상 문제를 야기할 수 있기 때문이다. 멀티 프로세서 환경에서는 각 "프로세서"는 각각 개별적인 인터럽트 스택을 가지고 있으며, 커널이 각 프로세서에 대해서 개별적으로 인터럽트를 처리할 수 있게 한다.
인터럽트가 발생하면 스택을 가리키는 레지스터가 커널 내의 인터럽트 스택을 가리키게 함과 동시에, 기존에 CPU에 있었던 주요한 레지스터가 스택에 저장된다. 이후 커널 내에서 인터럽트 핸들러가 실행되면 나머지 레지스터가 인터럽트 스택에 저장이 되며, 모든 인터럽트 동작이 끝나게 되면 역순으로 유저 프로세스로 복귀된다. 이에 관해서는 후술할 그림과 함께 더 자세히 보도록 하자.
각 프로세서가 커널 스택을 가지고 있던 것에서 더 심화될 경우 각 "프로세스"가 유저 스택과 커널 스택 두 가지를 갖게 할 수 있다. 기존에는 커널 공간을 사용하는 것이 많이 비싼 것이었기에 프로세서 단위로 커널 스택을 할당해줬지만 요즘은 메모리 가격이 훨씬 저렴해졌기 때문에, 각 프로세스 또는 쓰레드 별로 커널 스택을 배정할 수 있게 되었다.
3. 인터럽트 스택 쌓기 및 인터럽트 전체 동작 과정
이제 구체적으로 인터럽트 스택에 어떤 내용이 들어가야 하는지를 x86의 경우를 예시로 살펴보자. 다음의 과정이 순차적으로 진행되어야 하는데,
- Save current stack pointer (esp, ss register), processor status word (eflags register), instruction pointer (eip, cs register) to internal, temporary HW register
- Switch to kernel stack & put SP, PSW, PC on stack
- Optionally save an error code.
- Switch to kernel mode
- Vector through interrupt table & Invoke the interrupt handler
- Interrupt handler saves registers it might clobber
이 때, "current"란 인터럽트가 일어나기 직전 유저 모드에서 실행되던 프로세스의 환경을 의미한다. SP와 PC의 경우 segment를 나타내는 레지스터와, 그 세그먼트 내에서의 위치를 나타내는 pointer의 쌍으로 구성되기 때문에 두 개씩 저장되는 것이며, 현재의 privilege level은 EFLAGS 레지스터가 아닌 cs 레지스터의 하위 비트에 저장된다. EFLAGS 레지스터를 저장하는 이유는 명령어를 실행하면서 그 부산물로서 생성되는 condition codes가 저장되기 때문이고, interrupt mask 여부같은 프로세스의 환경을 나타내는 비트 또한 위치해있기 때문이다. 커널 모드로 전환되고 인터럽트 핸들러가 실행이 되면 다른 인터럽트가 실행되지 않도록 인터럽트 마스킹 또한 가동된다.
인터럽트가 들어왔을 때의 각 단계를 그림으로 살펴보자. 인터럽트가 실행되기 직전에는 인터럽트 스택은 비어있으며, SP, PC는 유저 영역을 가리키고 있다.
인터럽트가 실행되면 인터럽트 스택이 위 과정을 따라 세팅되고, SP는 해당 인터럽트 스택을 가리키게 된다. 어떤 인터럽트 핸들러를 실행할지를 찾아 PC 또한 설정된다. 이제 CPU의 주요 레지스터는 커널 쪽을 가르키고 있게 된다.
이 때, 인터럽트 스택에 가장 먼저 세팅되는 SP, PSW, PC는 유저 프로세스 실행으로 복귀하기 위해 반드시 사용되어야 하는 중요한 레지스터 값이다. 때문에 해당 레지스터 값이 소프트웨어에 의해 훼손되거나 중간에 옮겨지는 것이 끊기는 것을 막기 위해 해당 과정은 HW, 특히 CPU의 지원으로 atomic하게 인터럽트 스택에 세팅된다. 인터럽트 발생시 HW에 의해 커널 모드로 변경이 일어난다고도 할 수 있는 것이다.
핸들러가 실행되면서, 핸들러는 나머지 부수적인 레지스터를 전부 스택에 저장한다. 이는 다시 유저 프로세스로 복귀할 때 모두 pop을 하면서 값을 복구해 줄 것이다.
이 때, SP가 중복해서 저장되는 것을 볼 수 있다. 이 두 SP 레지스터는 실질적으로 다른 값이다. 처음에 저장된 SP는 HW에 의해 세팅된 값으로 유저 스택의 SP를 가리키고 있는 값이며, 나중에 핸들러에 의해 push된 SP는 인터럽트 스택을 가리키는 값이다. 이 중 커널 쪽을 가리키는 SP는 iret 직전의 인터럽트 스택의 적당한 지점을 찾아갈 때 사용될 것이며, 나머지 레지스터는 유저 프로세스 때의 값을 그대로 보존하고 있을 것이기에 유저 레벨 복구에 사용된다.
핸들러에서 push하는 레지스터는 atomic하게 실행될 필요가 없다. 어차피 인터럽트 핸들러가 실행되기 이전에 인터럽트 마스킹이 이뤄졌을 것이다. 또한 반드시 필요한 레지스터는 atomic하게 push 처리가 된거고, 그 외 레지스터는 구현상 복구가 필요할 수도, 그렇지 않을 수도 있으니 자율에 맡겨놓은 것이다.
이렇게 인터럽트 핸들러 실행을 위한 준비가 완료가 되면 핸들러가 필요한 작업을 수행할 것이다. 그 핸들러 실행의 막바지에 다다르면 핸들러는 스택에 저장해 두었던 레지스터 값들을 사용해 레지스터를 복구한다. 그 뒤에는 다음 상태로 SP를 설정한 뒤 iret 명령어를 사용해 커널 모드에서 유저 모드로 복귀한다. iret이 호출될 때는 반드시 해당 형태로 스택이 구성되어 있어야 한다.
iret에서는 다음과 같은 작업들이 atomic하게 일어나는데,
- Restore program counter
- Restore program stack
- Restore processor status word/condition codes
- Switch to user mode
이는 인터럽트가 발생했을 때의 역과정이라고 생각하면 된다. 인터럽트가 종료되면 인터럽트 마스킹 또한 해제시키도록 한다.
4. System Call의 동작 및 구현시 주의사항
이제 다음으로 syscall이 사용될 경우 그 동작과, 구현시 주의사항을 살펴보자. syscall은 이전에 설명한 것처럼, 유저 모드에서 특권 명령을 수행할 수 있도록 OS에서 유저 프로세스의 요청을 받아 대신 수행하는 특수한 명령이었다. 유저는 커널이 해당 명령을 어떻게 구현했는지 신경쓰지 않고 일반 라이브러리 함수처럼 사용하면 됐었다.
모든 인자를 올바르게 세팅한 뒤에 유저 프로그램은 커널로 통제권을 넘기기 위해 trap을 발생시킨다. interrupt나 exception처럼 해당 trap이 발생하면 유저-커널 모드 스위칭이 일어나게 된다. 때문에 x86에서는 syscall에서 발생하는 trap 명령어를 software interrupt라는 뜻의 int라고 정의했다.
유저가 넣어준 인자를 가지고 커널 모드에서 기능이 실행된다는 특성 때문에 해당 syscall은 잘못 사용될 경우 상당히 위험해질 수 있다. 실수 또는 악의적인 행동으로 인해 검증 없이 사용한다면, 커널의 영역을 읽고 쓰는 일이 발생할 수 있고, 시스템 자체가 터질 수도 있다. 유저 어플리케이션에서의 문제가 시스템 전체를 터뜨려서는 안되기 때문에 system call을 설계할 때는 유저가 넣어주는 인자는 반드시 문제를 소지할 가능성이 있다는 전제 하에 구현할 필요가 있다.
syscall을 수행하면서 커널로 통제권을 넘기고 유저가 넣어주는 파라미터를 검증하기 위해서 sysall은 stub의 쌍을 이용한다. User stub는 파라미터를 셋업하고 올바른 syscall 핸들러를 trap 명령을 사용해 커널로 통제권을 넘기면서 호출하며, Kernel stub은 유저 측에서 넘겨준 파라미터를 검증한 뒤, 문제가 없다면 기능을 수행하도록 하는 중간자 역할을 수행한다.
이 때 주목할 만한 것은 유저의 파라미터를 검증할 때 copy를 한 뒤에 검증한다는 것이다. 즉, write(fd, buf, length) 같이 syscall을 한다면 Kernel stub 쪽에서 buf 값이 copy가 된다는 뜻이다. 왜 이런 과정이 필요한 것일까?
강의와 책에 의하면 TOCTOU (Time Of Check Time Of Use) 공격을 방지하기 위함이라고 한다. 풀어서 설명하면 검사할 때와 실제로 사용할 때의 값이 달라지는 경우에 문제가 생길 수 있다는 것이다. 복사를 하지 않는다면 Kernel stub이 값을 검증한 뒤에 유저 측에서 값을 바꾼다면 악의적인 값이 그대로 syscall의 인자로서 사용될 수도 있기 때문이다. 예를 들어 open을 할 때 정상적인 파일 이름을 넣었다가, 검사가 끝난 이후에는 /etc/passwd 처럼 읽어서는 안 되는 파일 이름으로 바꾼다면 파일은 문제 없이 열릴 것이다. 이러한 문제를 원천 차단하기 위해 복사를 해서 사용하게 된다. 복사해서 복사된 값만을 사용한다면 중간에 원본 값이 바뀌더라도 복사본에는 영향이 없기 때문이다.
5. Upcall의 동작
마지막으로 Upcall이란 것을 살펴보고 끝내자. UNIX에서는 signal, 윈도우에서는 asynchronous event라고도 불리는 Upcall이란 유저 레벨의 event delivery라고 할 수 있다. 커널에서 HW의 인터럽트 신호를 받아 처리할 필요가 있듯, 유저 어플리케이션 또한 어떠한 신호를 주고 받으면서 이에 맞는 행동을 할 필요성이 생겼다. 유저 프로세스에게 어떤 이벤트가 바로 처리되어야 함을 알리기 위해서, 여러 쓰레드를 돌리면서 이들을 종합하기 위해 작업이 종료되었음을 알기 위해서, 비동기 I/O가 끝났음을 알기 위해서 등등 다른 프로세스, 또는 커널이 신호를 보내서 유저 프로세스에 통보할 경우가 있다. 이를 위해 유저 레벨에서 신호를 주고 받아 처리하기 위해 Upcall이 생긴 것이다.
강의에서 예시로 든 것은 세그먼트 폴트이다. 세그먼트 폴트가 발생했다는 것은 유저 프로세스에서 SIGSEGV 시그널을 받았다는 것을 의미한다. 해당 SIGSEGV는 OS에서 유저 프로세스로 보낸 것이다. 그럼 SIGSEGV는 어디서 처리되는 것일까? 따로 핸들러를 구현한 적이 없음에도 Segmentation fault (core dumped) 라는 문장이 printf되는 것은 어딘가에는 핸들러가 있기 때문이다. 그 위치는 바로 libc code이며, 컴파일러에 의해 해당 libc가 프로그램에 포함되어 SIGSEGV 시그널을 받으면 기본 핸들러가 동작할 수 있도록 만들어 놓은 것이다. 이 때 해당 SIGSEGV 시그널 또한 Upcall이며, 이를 처리하는 핸들러는 유저 모드에서 동작하게 된다.
Upcall은 인터럽트와 비교하면 유사한 점이 많다. 누가 신호를 받아서 어느 영역에서 그 신호를 처리하는가에 차이가 있을 뿐이다. Signal 핸들러는 인터럽트 벡터에, Signal 스택은 인터럽트 스택에 대응하는 개념이며, 원래 프로세스로 복귀하기 위해 레지스터가 저장 및 복구되는 점과 시그널 마스킹이 일어나 시그널 핸들링 동안 다른 시그널이 pending 되도록 하는 점 또한 비슷하다. 단, 시그널 핸들러와 그 테이블, 시그널 스택은 유저 영역에 위치하며, 해당 과정 또한 유저 모드에서 실행된다. 인터럽트에서는 인터럽트 스택을 세팅하는 주체가 HW였다면, Upcall에서는 시그널 스택을 쌓는 주체는 커널이다.
'코딩 삽질 > OS 요약 정리' 카테고리의 다른 글
(2022.04.10) Scheduling (0) | 2022.04.10 |
---|---|
(2022.04.09) Concurrency and thread (0) | 2022.04.09 |
(2022.04.09) Programming Interface (0) | 2022.04.09 |
(2022.04.08) Kernel Abstraction (1) (0) | 2022.04.08 |
(2022.04.08) Introduction (0) | 2022.04.08 |