[운영체제 아주 쉬운 세 가지 이야기 - Virtualization] 6. Direct Execution
이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.
개요
CPU를 가상화하기 위해서 운영체제는 여러 작업들이 동시에 실행되는 것처럼 보이도록 물리적인 CPU를 공유한다. 기본적인 아이디어는 간단하다. 한 프로세스를 잠시 동안 실행하고 다른 프로세스를 또 잠깐 실행하고, 이런 식으로 계속해서 잠깐씩 실행시키면 된다. 이러한 방식처럼 CPU 시간을 나누어 씀으로써 가상화를 구현할 수 있다.
그러나 이러한 가상화 기법을 구현하기 위해서는 몇가지 문제를 해결해야 한다.
첫 번째는 성능 저하이다. 시스템에 과중한 오버헤드를 주지 않으면서 가상화를 구현할 수 있을까?
두 번째는 제어 문제이다. CPU에 대한 통제를 유지하면서 프로세스를 효율적으로 실행시킬 수 있는 방법은 무엇인가?
운영체제의 입장에서는 자원 관리의 책임자로서 특히 제어 문제가 중요하다. 제어권을 상실하면 한 프로세스가 영원히 실행을 계속할 수 있고 컴퓨터를 장악하거나 접근해서는 안 되는 정보에 접근하게 된다. 제어권을 유지하면서 성능 저하가 없도록 하는 것이 운영체제를 구축하는 데 핵심적인 도전 과제이다.
기본 원리 : 제한적 직접 실행
운영체제 개발자들은 프로그램을 빠르게 실행하기 위하여 제한적 직접 실행(Limited Direct Execution)이라는 기법을 개발하였다.
이 아이디어의 “직접 실행”에 해당하는 부분은 간단하다. 프로그램을 CPU 상에서 그냥 직접 실행시키는 것이다.
따라서 운영체제가 프로그램을 실행하기 시작할 때 프로세스 목록에 해당 프로세스 항목을 만들고 메모리를 할당하며 프로그램 코드를 디스크에 탑재하고 main() 같은 진입점을 찾아 그 지점으로 분기하여 사용자 코드를 실행하기 시작한다.
위 그림은 아무 제한이 없는 기본적인 직접 실행 프로토콜이다. 프로그램의 main()으로 분기하고 커널로 되돌아 가기 위해 일반적인 호출과 리턴을 사용하였다. 간단하지만 이 접근법은 CPU를 가상화함에 있어 몇 가지 문제가 있다.
첫 번째 문제는 프로그램을 직접 실행시킨다면 프로그램이, 운영체제가 원치않는 일을 하지 않는다는 것을 어떻게 보장할 수 있는가?
두 번째 문제는 프로세스 실행 시, 운영체제는 어떻게 프로그램의 실행을 중단하고 다른 프로세스로 전환시킬 수 있는가, 즉, CPU를 가상화하는데 필요한 시분할(time sharing) 기법을 어떻게 구현할 수 있느냐는 것이다.
“제한”에 해당하는 부분은 이 두 가지 문제를 해결하기 위해 시스템 콜, 입출력, CPU 점유 시간 등은 OS가 통제할 수 있게 제한한다는 것이다.
문제점 1 : 제한된 연산
직접 실행의 장점은 빠르게 실행된다는 것이다. 기본적으로 프로그램이 하드웨어 CPU에서 실행되기 때문이다. 그러나 CPU에서 직접 실행시키면 새로운 문제가 발생한다. 만일 프로세스가 특수한 종류의 연산을 수행하길 원한다면 어떻게 될 것인가? 이러한 연산에는 디스크 입출력 요청이나 CPU 또는 메모리와 같은 시스템 자원에 대한 추가할당 요청 등이 포함된다.
프로세스가 원하는 대로 할 수 있게 방치하는 방안이 있다. 그러나 이 방안은 바람직한 시스템을 구축하는 데에는 방해 요인이다. 파일에 대한 접근을 허용하기 전에 접근 권한을 검사하는 파일 시스템을 구현하는 것을 예를 들어 보자. 프로세스가 디스크에 대하여 입출력하는 것을 제한하지 않으면 프로세스는 전체 디스크를 읽고 쓸 수 있기 때문에 접근 권한을 검사하는 기능이 아무런 의미가 없다.
이 떄문에 사용자 모드(user mode)라고 알려진 새로운 모드가 도입되었다. 사용자 모드에서 실행되는 코드는 할 수 있는 일이 제한된다. 예를 들어 프로세스가 사용자 모드에서 실행 중이면 입출력 요청을 할 수 없도록 설정한다. 이때 입출력 요청을 하면 프로세서가 예외를 발생시키고, 운영체제는 해당 프로세스를 제거한다.
커널 모드(kernel mode)는 사용자 모드와 대비되는 모드로서 운영체제의 중요한 코드들이 실행된다. 이 모드에서 실행되는 모든 특수한 명령어를 포함하여 원하는 모든 작업을 수행할 수 있다.
그럼 사용자 프로세스가 디스크 읽기와 같은 특권 명령어를 실행해야 할 때는 어떻게 해야 하는가? 이런 제한 작업의 실행을 허용하기 위하여 거의 모든 현대 하드웨어는 사용자 프로세스에게 시스템 콜을 제공한다. 이러한 기능에는 파일 시스템 접근, 프로세스 생성 및 제거, 다른 프로세스와의 통신 및 메모리 할당 등이 포함된다. 대부분의 운영체제는 수백 개의 시스템 콜을 제공한다.
시스템 콜을 실행하기 위해 프로그램은 trap 특수 명령어를 실행해야 한다. 이 명령어는 커널 안으로 분기하는 동시에 특권 수준을 커널 모드로 상향 조정한다. 커널 모드로 진입하면 운영체제는 모든 명령어를 실행할 수 있고 이를 통하여 프로세스가 요청한 작업을 처리할 수 있다. 완료되면 운영체제는 return-from-trap 특수 명령어를 호출하여 특권 수준을 사용자 모드로 다시 하향 조정하면서 호출한 사용자 프로그램으로 리턴한다.
하드웨어는 trap 명령어를 수행할 떄 주의가 필요하다. 운영체제가 return-from-trap 명령어 실행 시 사용자 프로세스로 제대로 리턴할 수 있게 호출한 프로세스의 필요한 레지스터들을 저장해야 한다. 예를 들어, x86 아키텍처에서는 프로그램 카운터, 플래그와 다른 몇 개의 레지스터를 각 프로세스의 커널 스택(kernel stack)에 저장한다. return-from-trap 명령어가 이 값들을 스택에서 pop하여 사용자 모드 프로그램의 실행을 다시 시작한다.
하지만 아직 해결되지 않은 문제는 trap 명령어가 운영체제 코드의 어디를 실행할지 어떻게 아느냐는 것이다. 호출한 프로세서는 분기할 주소를 명시할 수 없다. 주소를 명시한다는 것은 커널 내부의 원하는 지점을 접근할 수 있다는 것이기에 위험하다.
커널은 부팅 시에 트랩 테이블(trap table)을 만들고 이를 이용하여 시스템을 통제한다. 컴퓨터가 부팅될 때는 커널 모드에서 동작하기 때문에 하드웨어를 원하는대로 제어할 수 있다.
운영체제가 하는 초기 작업 중 하나는 하드웨어에게 예외 사건이 일어났을 때 어떤 코드를 실행해야 하는지 알려주는 것이다. 예를 들어, 하드 디스크 인터럽트가 발생하면, 키보드 인터럽트가 발생하면, 또는 프로그램이 시스템 콜을 호출하면 무슨 코드를 실행해야 하는가? 운영체제는 특정 명령어를 사용하여 하드웨어에게 트랩 핸들러(trap handler)의 위치를 알려준다. 하드웨어는 이 정보를 전달받으면 해당 위치를 기억하고 있고, 시스템 콜과 같은 예외적인 사건이 발생했을 때 하드웨어는 무엇을 해야 할지 (어느 코드로 분기해야 할 지) 알 수 있다.
하드웨어에게 트랩 테이블의 위치를 알려주는 것은 매우 강력한 기능이고 이 역시 특권 명령어이다.
위 그림은 이 메커니즘을 요약해서 나타내고 있다. 각 프로세스는 고유의 커널 스택을 가지고, 커널 모드로 진입하거나 나갈 때 하드웨어에 의해 프로그램 카운터와 범용 레지스터 등의 레지스터가 저장되고 복원되는 용도로 사용된다.
LDE 프로토콜은 두 단계로 진행된다.
부팅 시 커널은 트랩 테이블을 초기화하고 CPU는 나중에 사용하기 위하여 테이블의 위치만 기억한다. 커널은 이러한 작업을 커널 모드에서만 사용할 수 있는 명령어를 이용하여 수행한다.
프로세스를 실행할 때는 return-from-trap을 이용하여 사용자 프로세스를 시작할 때 몇 가지 작업을 수행한다. 새로운 프로세스를 위한 노드를 할당하여 프로세스 리스트에 삽입하고, 메모리를 할당하는 등의 작업이 포함된다. return-from-trap 명령어는 CPU를 사용자 모드로 전환하고 프로세스 실행을 시작한다. 프로세스가 시스템 콜을 호출하면 운영체제로 다시 트랩된다.
운영체제는 시스템 콜을 처리하고 return-from-trap 명령어를 사용하여 다시 제어를 프로세스에게 넘긴다. 프로세스는 이후 자신의 할 일을 다하면 main()에서 리턴한다. 종료시킬 때 exit() 시스템 콜을 호출하고 다시 운영체제로 트랩된다. 이 시점에 운영체제는 정리 작업을 하게 되어 모든 일이 완료된다.
문제점 2 : 프로세스 간 전환
직접 실행의 두 번째 문제점은 시분할 기법을 위한 프로세스 간 전환을 할 수 있어야 한다는 점이다. CPU에서 프로세스가 실행 중이라는 건 운영체제는 실행중이지 않다는 것이기에 운영체제는 CPU를 제어할 수 없다. 운영체제는 어떻게 CPU를 다시 획득하여 프로세스를 전환할 수 있는가?
협조 방식 : 시스템 콜 기다리기
협조 방식은 과거 몇몇 시스템에서 채택되었다. 이 방식은 운영체제가 프로세스들이 합리적으로 행동할 것이라고 신뢰한다. 오랜 시간 실행될 가능성이 있는 프로세스는 알아서 주기적으로 CPU를 포기하여 운영체제에게 넘겨준다고 믿는 것이다. yield() 시스템 콜이나 다른 시스템 콜들을 호출하여 CPU를 포기한다. 또는 프로세스가 어떤 수를 0으로 나누기와 같은 비정상적인 연산을 해도 트랩이 일어나 운영체제에게 CPU 제어가 넘어간다. 하지만 프로세스가 무한 루프에 빠져 시스템 콜을 호출할 수 없을 때는 운영체제가 CPU를 제어할 수 없게 된다.
비협조 방식 : 운영체제가 전권을 행사
프로세스가 시스템 콜을 호출하지 않아 운영체제에게 제어를 넘기지 않을 경우 하드웨어의 추가적인 도움없이는 운영체제가 할 수 있는 일은 거의 없다. 컴퓨터를 재부팅한다면 해결할 수 있긴 하다.
또는 해결책으로 타이머 인터럽트(timer interrupt)를 사용할 수 있다. 인터럽트가 발생하면 현재 수행 중인 프로세스는 중단되고 미리 구성된 운영체제의 인터럽트 핸들러(interrupt handler)가 실행된다. 이 시점에서 운영체제는 CPU 제어권을 다시 얻게 되고 일을 할 수 있다.
운영체제는 하드웨어에게 타이머 인터럽트가 발생했을 때 실행해야 할 코드를 알려주어야 한다. 부팅될 때 운영체제가 이런 준비를 한다. 부팅 과정 진행 중에 운영체제는 타이머를 시작한다. 타이머가 시작되면 운영체제는 자신에게 제어가 돌아올 것이라는 것을 알고 부담 없이 사용자 프로그램을 실행할 수 있다. 또한 타이머는 특정 명령어를 수행하여 끌 수도 있다.
인터럽트 발생 시 하드웨어에게도 약간의 역할이 있다. 인터럽트가 발생했을 때 실행 중이던 프로그램의 상태를 저장하여 나중에 return-from-trap 명령어가 프로그램을 다시 시작할 수 있도록 해야 한다.
이 동작은 시스템 콜이 호출되었을 때 하드웨어가 하는 동작과 유사하다. 다양한 레지스터가 커널 스택에 저장되고, return-from-trap 명령어가 실행될 때 이 값들이 복원된다.
문맥의 저장과 복원
시스템 콜을 통하여 협조적으로 하던, 또는 타이머 인터럽트를 통하여 강제적으로 하던, 운영체제가 제어권을 다시 획득하면 중요한 결정을 내려야 한다. 현재 실핼 중인 프로세스를 계속 실행할 것인지 아니면 다른 프로세스로 전환할 것인지를 결정해야 한다. 이 결정은 운영체제의 스케줄러가 내리고, 추후에 공부할 부분이다.
다른 프로세스로 전환하기로 결정되면 운영체제는 문맥 교환(context switch)이라고 알려진 코드를 실행한다. 문맥 교환은 개념적으로는 간단하다.
운영체제가 해야 하는 작업은 현재 실행 중인 프로세스의 레지스터 값을 커널 스택 같은 곳에 저장하고 곧 실행될 프로세스의 커널 스택으로부터 레지스터 값을 복원하는 것이 전부이다. 그렇게 함으로써 운영체제는 return-from-trap 명령어가 마지막으로 실행될 때 현재 실행 중이던 프로세스로 리턴하는 것이 아니라 다른 프로세스로 리턴하여 실행을 다시 할 수 있다.
프로세스 전환을 위해 운영체제는 어셈블리 코드를 사용하여 현재 실행 중인 프로세스의 범용 레지스터와 PC, 현재 커널 스택 포인터를 저장한다. 그리고 곧 실행될 프로세스의 범용 레지스터, PC를 복원하고 커널 스택을 이 프로세스의 커널 스택으로 전환한다. 이로써 운영체제는 인터럽트된 프로세스 문맥에서 전환 코드를 호출하고, 실행될 프로세스 문맥으로 리턴할 수 있다. 운영체제가 마지막으로 return-from-trap 명령어를 실행하면 곧 실행될 프로세스가 현재 실행 중인 프로세스가 되고 문맥 교환이 마무리 된다.
위 그림은 모든 과정의 연대표가 나와있다.
병행성이 걱정
만약 시스템 콜을 처리하는 도중에 타이머 인터럽트가 발생하거나, 하나의 인터럽트를 처리하고 있을 때 다른 인터럽트가 발생한다면 어떻게 될까?
커널에서 처리하기 더 어려워질 것고, 운영체제는 어떤 일이 생기는가에 대해 신중하게 고려할 필요가 있다. 이 주제는 병행성에 관한 것으로 추후에 자세하게 다룰 것이다.
운영체제가 할 수 있는 간단한 해법은 인터럽트를 처리하는 동안 인터럽트를 불능화하는 것이다. 이럴 경우 하나의 인터럽트가 처리되는 동안 다른 어떤 인터럽트도 CPU에게 전달되지 않는다.
물론 인터럽트를 너무 오랫동안 불능화시키면 인터럽트를 놓치고 기술적으로도 좋지 않기에 운영체제는 이 작업을 신중하게 해야 한다.
또한 운영체제는 내부 자료 구조에 동시에 접근하는 것을 방지하기 위해 많은 정교한 락(lock) 기법을 개발해 왔다. 이 잠금 기법은 커널 안에서 동시에 다수의 활동이 진행될 수 있게 허용한다.
요약
우리는 CPU 가상화를 구현하기 위한 핵심적인 저수준 기법에 관해 공부했다. 이런 기법들을 묶어서 제한적 직접 실행이라 부른다.
기본적인 아이디어는 간단하다. CPU에서 실행하고 싶은 프로그램을 실행시킨다. 그러나 운영체제가 CPU를 사용하지 못하더라도 프로세스의 작업을 제한할 수 있도록 하드웨어를 셋업해야 한다.
운영체제는 CPU에 안전 장치를 준비해 놓을 수 있다. 우선 부팅할 때 트랩 핸들러 함수를 셋업하고 인터럽트 타이머를 시작시키고 그런 후에 제한된 모드에서만 프로세스가 실행되도록 한다.
이로써 운영체제는 프로세스를 효율적으로 실핼할 수 있고, 특별한 연산을 수행할 때, 즉 프로세스가 CPu를 독점하거나, 다른 프로세스로 전환해야할 때만 개입한다.
사용자 모드와 커널 모드
사용자 모드는 게임과 같은 프로세스가 실행되고 제한된 권한을 가지고 있다. 커널 모드는 커널 코드, 드라이버, 시스템 콜 처리 등의 중요한 작업을 하고 모든 자원에 접근 가능하다.
사용자 모드는 자신의 가상 주소 공간에만 접근이 가능하고, 커널 모드는 모든 프로세스의 메모리, 하드웨어에 접근 가능하다.
사용자 모드는 User Stack을 사용하고, 커널 모드는 Kernel Stack을 사용한다.
사용자 모드에서 커널 모드로 전환할 때는 Trap 명령어를 사용하고, 커널 모드에서 사용자 모드로 전환할 때는 Return-from-trap 명령어를 사용한다.
커널 스택
커널 스택은 프로세스가 커널 모드에 진입했을 때 사용하는 전용 스택 공간이다.
보안상 커널 모드에서 실행되는 함수들이 유저 스택에 쌓이면 안되고, 유저 코드가 버그를 내면 스택을 손상시킬 수 있으므로 분리된 별도의 커널 스택을 사용하는 것이다. 커널 스택은 8KB ~ 16KB 정도의 작은 크기를 가지고, 프로세스마다 하나씩 할당된다.
참고