[OSTEP] 스터디 2주차 - 가상화의 세계 part.3

운영체제는 CPU 가상화를 위해 제한적 직접 실행이라는 기법을 사용한다. 이 기법의 기본 아이디어는 프로그램을 CPU에서 직접 실행시키되, 운영체제가 CPU 제어권을 잃지 않도록 프로세스의 행동에 제한을 두는 것이다.


기본 원리 : 제한적 직접 실행

프로그램을 실행할 때 운영체제는 다음과 같은 절차를 따른다:

  1. 프로세스를 위한 메모리를 할당하고 프로그램을 메모리에 적재한다.
  2. CPU를 사용자 모드로 전환하고 프로그램의 main() 함수로 이동한다.
  3. 프로그램이 실행되면서 시스템 콜이 호출되면 커널 모드로 전환되고 운영체제가 해당 요청을 처리한다.
  4. 요청 처리가 완료되면 다시 사용자 모드로 돌아가 프로그램 실행을 계속한다.

이런 직접 실행 방식은 CPU 가상화를 구현하는 데 있어 몇 가지 문제를 야기한다.

첫째, 프로그램을 그대로 실행시킨다면 운영체제가 원하지 않는 동작을 프로그램이 수행하지 않는다는 것을 어떻게 보장할 수 있을까? 프로그램에 아무런 제한을 두지 않는다면, 운영체제의 통제를 벗어나는 일이 발생할 수 있다.

둘째, 프로세스를 실행할 때 운영체제가 CPU 사용을 적절히 분배하려면 프로그램 실행을 중단하고 다른 프로세스로 전환할 수 있어야 한다. 이를 시분할(time sharing)이라고 하는데, 직접 실행 방식으로는 이를 구현하기 어렵다.

여기서 시분할(time sharing)이란 여러 프로세스가 CPU를 돌아가며 사용하도록 하여, 마치 동시에 실행되는 것처럼 보이게 하는 기술을 말하는데, 이는 CPU 가상화를 위해 필수적인 개념이다.

이 문제들에 대한 해답을 찾아가는 과정에서 우리는 CPU 가상화에 필요한 사항들을 더 잘 이해하게 될 것이다. 그리고 “제한적 직접 실행”이라는 이름에 담긴 의미, 즉 프로그램 실행에 일정한 제한을 가하는 것의 중요성도 깨닫게 될 것이다.

만약 프로그램 실행을 제한하지 않는다면 운영체제는 아무것도 통제할 수 없게 되고, 그저 단순한 라이브러리에 지나지 않게 된다. 이는 운영체제가 제 역할을 하기 위해서는 매우 바람직하지 않은 상황이 될 것이다.


문제점 1: 제한된 연산

그러나 프로세스가 모든 연산을 수행하도록 허용하면 시스템의 안정성과 보안에 문제가 생길 수 있다. 따라서 사용자 모드에서 실행되는 코드는 특정 연산에 제한을 받는다. 제한된 연산을 수행하려면 시스템 콜을 통해 운영체제에 요청해야 한다.

하드웨어는 두 가지 실행 모드를 제공하여 운영체제를 지원한다:

  1. 사용자 모드(user mode): 응용 프로그램이 실행되는 모드로, 하드웨어 자원에 대한 접근이 제한된다.
  2. 커널 모드(kernel mode): 운영체제가 실행되는 모드로, 모든 하드웨어 자원에 접근할 수 있는 권한을 가진다.

프로세스가 제한된 연산을 수행하려면 사용자 모드에서 커널 모드로 전환되어야 한다. 이를 위해 하드웨어는 다음과 같은 특수 명령어를 제공한다:

  • trap: 사용자 모드에서 커널 모드로 전환하는 명령어
  • return-from-trap: 커널 모드에서 사용자 모드로 돌아가는 명령어

또한 운영체제는 트랩이 발생했을 때 실행할 코드의 주소를 담고 있는 트랩 테이블(trap table)을 하드웨어에 알려주어야 한다.

이러한 하드웨어의 지원을 바탕으로, 프로세스는 제한된 연산이 필요할 때 시스템 콜을 호출하여 trap을 발생시키고, 운영체제는 요청받은 연산을 대신 수행한 뒤 return-from-trap을 통해 다시 프로세스에게 제어를 넘겨준다.

왜 시스템 콜은 일반적인 프로시져 콜처럼 생겼을까?
open()이나 read()같은 시스템 콜을 호출하는 코드를 보면, C언어의 일반적인 함수 호출 코드와 매우 유사하다. 그렇다면 시스템 콜과 함수 호출이 동일한 형태라면, 운영체제는 어떻게 이 둘을 구분하여 시스템 콜에 맞는 동작을 수행할 수 있겠는가?
답은 간단하다. 시스템 콜도 사실 함수 호출이지만, 그 안에는 trap이라는 특수 명령어가 숨어있다.
예를 들어 open() 시스템 콜을 호출하는 코드를 생각해 보겠다. 이 코드가 실행되면 사실 C 라이브러리 내부의 open() 함수가 호출된다. 이 라이브러리 함수는 운영체제 커널과 미리 약속된 규칙에 따라, open()에 전달된 인자들과 시스템 콜 번호를 정해진 위치(스택이나 특정 레지스터)에 저장한다. 그러고 나서 trap 명령어를 실행하여 운영체제로 제어를 넘긴다.
운영체제는 trap 핸들러를 실행하여 요청받은 시스템 콜을 처리하고, 그 결과를 다시 약속된 위치에 저장한다. 이제 trap 명령어에 의해 실행이 중단되었던 라이브러리 함수로 다시 제어가 돌아오면, 라이브러리 함수는 이 결과값을 받아 시스템 콜을 호출한 원래 프로그램에 반환한다.
여기서 주목할 점은, 시스템 콜을 호출하는 라이브러리 함수의 코드는 대부분 어셈블리어로 작성되어 있다는 것이다. 이는 함수의 인자와 반환값을 처리하고 trap을 실행하는 과정이 하드웨어마다 조금씩 다르기 때문에, 이 부분은 어셈블리어로 구현되어야 한다.
다행히 프로그램 개발자가 직접 어셈블리 코드를 작성할 필요는 없다. 시스템 콜을 C 함수처럼 호출하기만 하면, 나머지는 라이브러리가 알아서 처리해 주기 때문이다.

문제점 2: 프로세스 간 전환

운영체제는 여러 프로세스를 번갈아 가며 실행해야 하므로, CPU 제어권을 다시 얻어 문맥 교환을 수행할 수 있어야 한다. 이를 위해 하드웨어 타이머를 사용하여 주기적으로 인터럽트를 발생시킨다. 타이머 인터럽트가 발생하면 운영체제는 CPU 제어권을 되찾아 현재 프로세스의 상태를 저장하고 다음에 실행할 프로세스의 정보를 복원하여 실행을 계속한다.

협조 방식: 시스템 콜 호출시 까지 대기

초기의 몇몇 운영체제들은 협조적(cooperative) 스케줄링 방식을 사용했다. 대표적인 예로는 초기 버전의 매킨토시 운영체제나 제록스 알토 시스템이 있다. 이 방식에서 운영체제는 프로세스들이 공정하게 CPU를 양보할 것이라고 가정한다. 만약 어떤 프로세스가 너무 오랫동안 CPU를 독점할 것 같으면, 그 프로세스는 정기적으로 CPU 사용권을 운영체제에게 넘겨줘서 다른 프로세스들이 실행될 수 있게 해야 한다.

자연스럽게 다음과 같은 질문을 할 수 있다. “이 이상적인 상황에서 프로세스는 어떻게 자발적으로 CPU를 양보할까?” 대부분의 프로세스는 파일 입출력, 네트워크 통신, 새 프로세스 생성 등의 작업을 위해 시스템 콜을 자주 호출하게 된다. 프로세스가 시스템 콜을 호출하면 자연스럽게 CPU 제어권이 운영체제로 넘어가게 된다. 심지어 어떤 운영체제들은 yield라는 시스템 콜을 제공하기도 하는데, 이 호출은 특별히 할 일이 없어도 자발적으로 CPU를 양보하는 역할을 한다.

응용 프로그램의 오작동 처리하기
운영체제는 종종 비정상적으로 동작하는 프로세스를 적절히 처리해야 한다. 비정상적으로 동작하는 프로세스란 오작동 하도록 의도적으로 설계되었거나 의도치 않은 버그로 인해 해서는 안 될 행위를 하려는 프로세스를 말한다. 현재 시스템에서 운영체제가 이러한 프로세스들을 처리하는 방법은 간단하다. 해당 프로세스를 종료시킨다. 원 스트라이크에 아웃! 과도하게 엄격하다고 볼 수 있을지는 모르겠으나. 프로세스가 허락된 영역 외의 메모리에 대한 접근을 시도하거나. 불법적인 명령어를 실행할 때 해당 프로세스를 종료시키는 것 외에. 이를 처리하는 다른 방법이 특별히 존재하지 않는다.

만약 프로세스가 잘못된 행동을 하면 대개 트랩(trap)이 발생하고 CPU 제어권이 운영체제에게 넘어간다. 예를 들어 0으로 나누기를 시도한다거나 접근 권한이 없는 메모리 영역에 접근하려 하면 트랩이 발생하는 식이다. 트랩이 발생하면 운영체제는 문제를 일으킨 프로세스를 강제 종료시킬 수 있다.

그러니까 협조적 스케줄링 방식에서는 운영체제가 시스템 콜이나 트랩(불법 연산)이 발생하기를 기다렸다가 CPU를 다시 획득하는 방식이다. 하지만 이런 수동적인 방법에는 문제가 있다. 만약 어떤 프로세스가 악의적으로든, 실수로든 무한 루프에 빠져서 전혀 시스템 콜을 호출하지 않는다면 문제가 발생할 수 있다.


비협조 방식: 운영체제가 제어권 확보

이제, 프로세스들이 자발적으로 CPU를 양보하지 않고 계속 실행을 독점하려 할 때는 어떻게 해야 할까? 안타깝게도 하드웨어의 추가적인 지원 없이는 운영체제가 할 수 있는 일이 거의 없다. 사실 협조적 스케줄링에서 프로세스가 무한 루프에 빠졌을 때 운영체제가 할 수 있는 유일한 방법은 컴퓨터를 재부팅하는 것뿐이었다. 이는 오랫동안 모든 컴퓨터 문제를 해결하는 만능키 같은 역할을 해왔다. 하지만 CPU 제어권을 되찾기 위해 매번 재부팅을 한다는 건 그리 현명한 방법은 아니다.

핵심 질문 : 협조 없이 제어를 얻는 방법
프로세스가 비협조적인 상황에서도 CPU의 할당을 위한 제어권을 어떻게 하면 획득 할 수 있을까? 어떻게 하면 악의적인 프로세스가 컴퓨터를 장악하는 것을 방지할 수 있을까?

이 문제에 대한 해결책은 수십 년 전 초기 컴퓨터 시스템을 설계했던 엔지니어들에 의해 고안되었다. 그 해결책은 바로 타이머 인터럽트(timer interrupt)를 이용하는 것이다. 타이머 장치를 프로그래밍하여 일정 시간(보통 몇 밀리초)마다 인터럽트를 발생시킨다. 타이머 인터럽트가 발생하면, 현재 실행 중이던 프로세스는 즉시 중단되고 미리 정해진 인터럽트 핸들러(interrupt handler)로 제어가 넘어간다. 이 핸들러는 운영체제의 코드다. 따라서 이 시점에서 운영체제는 CPU를 다시 장악하게 되고, 원하는 작업(현재 프로세스를 중단하고 다른 프로세스로 전환하는 등)을 수행할 수 있게 되는 것이다.

팁 : 타이머 인터럽트를 이용한 제어권 확보
타이머 인터럽트 기능을 사용하면 프로세스가 비협조적으로 행동하는 상황에서도 운영체제가 실행될 수 있다. 타이머 인터럽트는 운영체제가 컴퓨터를 제어하는 데 있어 근간이 되는 핵심기능이다.

물론 운영체제는 타이머 인터럽트가 발생했을 때 어떤 코드를 실행해야 하는지 하드웨어에 알려주어야 한다. 이 작업은 시스템이 부팅되는 동안 이루어진다. 부팅 과정에서 운영체제는 타이머를 시작시키고, 타이머 인터럽트가 발생하면 자신에게 제어가 넘어올 것이라는 사실을 알기에 안심하고 사용자 프로그램을 실행할 수 있게 되는 것이다. 물론 운영체제는 필요할 때 타이머를 멈출 수도 있다. 

타이머 인터럽트가 발생했을 때 하드웨어도 해야 할 일이 있다. 현재 실행 중이던 프로그램의 상태(레지스터 값들)를 저장해서, 추후 인터럽트 처리가 끝나고 해당 프로그램으로 복귀할 때 그대로 실행을 재개할 수 있도록 해야 한다. 이 과정은 시스템 콜이 호출되었을 때 일어나는 일과 매우 유사하다. 다양한 레지스터 값들이 커널 스택에 저장되었다가, ‘return-from-trap’ 명령어에 의해 복원된다.


문맥의 저장과 복원

운영체제가 시스템 콜이나 타이머 인터럽트를 통해 CPU 제어권을 되찾았다면, 그 다음에는 중요한 결정을 내려야 한다. 바로 “현재 실행 중이던 프로세스를 계속 실행할 것인가, 아니면 다른 프로세스로 전환할 것인가”를 결정해야 하는 것이다. 이 결정은 운영체제 내의 스케줄러(scheduler)라는 모듈이 담당한다.

만약 스케줄러가 다른 프로세스로 전환하기로 결정했다면, 운영체제는 문맥 교환(context switch)이라 불리는 작업을 수행한다. 문맥 교환의 기본 개념은 간단하다. 현재 실행 중인 프로세스의 레지스터 값들을 모두 저장하고(주로 커널 스택에), 다음에 실행할 프로세스의 레지스터 값들을 복원하는 것이다. 이렇게 하면 ‘return-from-trap’ 명령어가 실행될 때, 원래 실행 중이던 프로세스로 돌아가는 것이 아니라 새로운 프로세스로 가서 실행을 시작하게 된다.

문맥 교환을 위해 운영체제는 저수준 어셈블리어를 사용한다. 현재 프로세스의 범용 레지스터, 프로그램 카운터(PC), 커널 스택 포인터 등을 모두 저장하고, 다음 프로세스의 값들을 복원한다. 이렇게 하면 인터럽트가 발생했을 때의 프로세스에서 문맥 교환 코드를 호출하고, 새 프로세스의 문맥으로 복귀하게 되는 것이다. ‘return-from-trap’이 실행되면, 새 프로세스가 실행 중인 프로세스가 되고, 문맥 교환이 완료된다.

문맥 교환 과정에서는 레지스터 값의 저장과 복원이 두 번 일어난다는 점에 주목해야 한다.

  1. 첫 번째는 타이머 인터럽트 등에 의해 인터럽트가 발생했을 때다. 이때는 하드웨어가 자동으로 현재 프로세스의 사용자 레지스터를 커널 스택에 저장한다.
  2. 두 번째는 운영체제가 프로세스 전환을 결정했을 때다. 이때는 운영체제 커널이 현재 프로세스의 나머지 레지스터(커널 레지스터)를 프로세스의 PCB(Process Control Block)에 저장한다.

이렇게 하면 마치 원래 실행 중이던 프로세스 A가 아니라, 새 프로세스 B에서 인터럽트가 발생한 것처럼 보이게 된다.


병생실행으로 인한 문제

다만 인터럽트나 시스템 콜 처리 도중에 다른 인터럽트가 발생하면 까다로운 상황이 연출될 수 있다. 이를 방지하기 위해 운영체제는 인터럽트 처리 중에는 추가 인터럽트를 불가능하게 하거나, 내부 자료구조에 대한 동시 접근을 막기 위한 잠금 기법을 사용한다.

운영체제는 인터럽트나 트랩을 처리하는 도중에 또 다른 인터럽트가 발생할 경우를 매우 신중하게 다뤄야 한다. 운영체제가 취할 수 있는 가장 간단한 방법은 인터럽트를 처리하는 동안 추가 인터럽트를 막는 것이다. 이렇게 하면 인터럽트 처리 루틴이 실행되는 동안에는 다른 인터럽트가 CPU로 전달되지 않는다. 물론 운영체제는 이런 인터럽트 차단을 매우 신중하게 사용해야 한다. 인터럽트를 너무 오랫동안 비활성화하면 중요한 이벤트를 놓칠 수 있고, 시스템 성능에도 안 좋은 영향을 줄 수 있다.

운영체제는 또한 ‘락(lock)’이라고 불리는 정교한 기법들을 사용하여, 커널 내부의 자료구조에 동시에 접근하는 것을 방지한다. 이런 락 메커니즘은 커널 내에서 여러 활동이 동시에 일어날 수 있도록 허용하면서도, 자료의 일관성을 유지할 수 있게 해 준다. 하지만 병행성을 다루는 장에서 보게 될 것처럼, 이런 락 기법은 상당히 복잡해질 수 있고, 때로는 찾기 힘든 버그를 만들어내기도 한다.


요약

지금까지 우리는 CPU 가상화를 구현하기 위한 중요한 저수준 기법인 ‘제한적 직접 실행’에 대해 알아보았다. 이 기법의 핵심 아이디어는 간단하다. 바로 실행하고자 하는 프로그램을 CPU에서 직접 실행시키되, 운영체제가 CPU 제어권을 잃지 않도록 프로세스의 행동에 제한을 걸어두는 것이다.

이런 접근 방식은 우리 일상생활에서도 찾아볼 수 있다. 아기가 있는 집이라면 아기 보호 장치를 설치하는 것에 익숙할 것이다. 위험한 물건이 있는 서랍을 잠그고, 전기 콘센트에 안전 덮개를 씌우는 식이다. 이렇게 안전 장치를 마련해 두면 대부분의 위험 요인은 차단되므로, 아기가 자유롭고 안전하게 돌아다닐 수 있다.

운영체제도 이와 비슷한 방식으로 CPU에 안전 메커니즘을 마련한다. 시스템이 부팅될 때 트랩 핸들러를 설정하고, 타이머 인터럽트를 시작시킨 다음, 프로세스들이 제한된 모드에서만 실행되도록 한다. 이렇게 하면 운영체제는 프로세스를 효율적으로 실행시키면서도, 특별한 작업(프로세스의 CPU 독점, 다른 프로세스로의 전환 등)이 필요할 때만 개입할 수 있다.

이로써 우리는 CPU 가상화의 기본 개념을 배웠습니다. 그러나 아직 중요한 질문이 남아 있다. “특정 시점에 어떤 프로세스를 실행해야 할까?” 이는 운영체제의 스케줄러가 답해야 할 질문이며, 우리가 다음에 공부할 주제이기도 하다.