기록공간

2-2장. Mechanism : 제한적 직접 실행 원리 본문

OS

2-2장. Mechanism : 제한적 직접 실행 원리

입코딩 2020. 2. 19. 22:17
반응형

CPU를 가상화하기 위해서 운영체제는 여러 작업들이 동시에 실행되는 것처럼 보이도록 물리적인 CPU를 공유한다. 한 프로세스를 잠시 동안 실행하고 다른 프로세스를 또 잠깐 실행하고, 이런 식으로 계속해서 잠깐씩 실행시키면 된다. 이렇게 CPU 시간을 나누어 씀으로써 가상화를 구현할 수 있다. 

 

그러나 이러한 가상화 기법을 구현하기 위해서는 몇 가지 문제를 해결해야 한다. 첫 번째는 성능저하이다. 시스템에 큰 오버헤드를 주지 않으면서 가상화를 구현할 수 있을 것인가? 두 번째는 제어 문제이다. CPU에 대한 통제를 유지하면서 프로세스를 효율적으로 실행시킬 수 있는 방법은 무엇인가?

 

운영체제의 입장에서는 자원 관리의 책임자로서 특히 제어 문제가 중요하다. 제어권을 상실하면 한 프로세스가 영원히 실행을 계속할 수 있고 컴퓨터를 장악하거나 접근해서는 안 되는 정보에 접근하게 된다. 제어권을 유지하면서 성능 저하가 없도록 하는 것이 운영체제를 구축하는 데 핵심적인 과제이다.

 

 

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

운영체제 개발자들은 프로그램을 빠르게 실행하기 위해 제한적 직접 실행(Limited Direct Execution) 이라는 기법을 개발하였다. "직접 실행"은 프로그램을 CPU 상에서 그냥 직접 실행시키는 것이다.

 

운영체제가 프로그램을 실행하기 시작할 때 프로세스 목록에 해당 프로세스 항목을 만들고, 메모리를 할당하며, 프로그램 코드를 디스크에서 탑재하고, 진입점(main() 같은것)을 찾아 그 지점으로 분기하여 사용자 코드를 실행한다. 

 

다음 그림은 기본적인 직접 실행 프로토콜을 보여준다. 프로그램의 main()으로 분기하고 커널로 되돌아 가기 위해 일반적인 호출과 리턴을 사용하였다. 하지만 이 접근법은 CPU 가상화에 있어 몇 가지 문제를 일으킨다.

 

첫 번째는 프로그램을 직접 실행시킨다면 프로그램이 운영체제가 원치 않는 일을 하지 않는다는 것을 어떻게 보장할 수 있는가? 그리고 두 번째 문제는 프로세스 실행 시, 운영체제는 어떻게 프로그램의 실행을 중단하고 다른 프로세스로 전환 시킬 수 있는가? 이다. 즉, CPU를 가상화하는 데 필요한 시분할(Time sharing)기법을 어떻게 구현할 수 있느냐는 것이다.

 

프로그램을 제어하지 못하는 운영체제는 단순한 라이브러리에 불과하기 때문에 이 문제는 운영체제 역할을 하기 위해서는 정말 중요한 문제이다. 이제 이 문제들을 운영체제는 어떻게 해결하는지 알아보도록 하자.

 

문제점 1 : 제한된 연산

직접 실행의 장점은 빠르게 실행된다는 것이다. 기본적으로 프로그램의 하드웨어 CPU에서 실행되기 때문이다. 하지만 CPU에서 직접 실행시키면 새로운 문제가 발생한다. 만일 프로세스가 특수한 종류의 연산을 수행하길 원한다면 어떻게 될 것인가? 이러한 연산에는 디스크 입출력 요청이나 CPU 또는 메모리와 같은 시스템 자원에 대한 추가할당 요청 등이 포함된다.

 

프로세스가 원하는 대로 할 수 있게 방치하는 방안이 있다. 그러나 이 방안은 바람직한 시스템을 구축하는 데에는 방해 요인이다. 파일에 대한 접근을 허용하기 전에 접근 권한을 검사하는 파일 시스템을 구현하는 것을 예로 들어보자. 프로세스가 디스크에 대하여 입출력하는 것을 제한하지 않으면 프로세스는 전체 디스크를 읽고 쓸 수 있기 때문에 접근 권한을 검사하는 기능이 아무런 의미가 없게된다.

 

이 때문에 사용자 모드(User mode)라고 알려진 새로운 모드가 도입되었다. 사용자 모드에서 실행되는 코드는 할 수 있는 일이 제한된다. 예를 들어, 프로세스가 사용자 모드에서 실행 중인 경우 입출력 요청을 할 수 없도록 설정한다. 이때 입출력 요청을 하면 프로세서가 예외를 발생시키고, 운영체제는 해당 프로세스를 제거한다.

 

커널 모드(Kernel mode)는 사용자 모드와 대비되는 모드로서 운영체제의 중요한 코드들이 실행된다. 이 모드에서 실행되는 코드는 모든 특수한 명령어를 포함하여 원하는 모든 작업을 수행할 수 있다

 

그러면 우리는 사용자 모드에서 디스크 입출력과 같은 특권 명령을 실행해야 하는데, 도데체 어떻게 해야 하는 것일까? 이러한 제한 작업의 실행을 허용하기 위하여 거의 모든 현대 하드웨어는 사용자 프로세스에게 시스템 콜을 제공한다. 시스템 콜 기능에는 파일 시스템 접근, 프로세스 생성 및 제거, 다른 프로세스와의 통신 및 메모리 할당 등이 포함된다. 대부분의 운영체제는 수백 개의 시스템 콜을 제공한다. 

 

시스템 콜을 실행하기 위해서 프로그램은 trap 특수 명령어를 실행해야 한다. 이 명령어는 커널 안으로 분기하는 동시에 특권 수준을 커널 모드로 상향 조정한다. 커널 모드로 진입하면 운영체제는 모든 명령어를 실행할 수 있고 이를 통하여 프로세스가 요청한 작업을 처리할 수 있다. 완료 후에는 retrun-from-trap 특수 명령어를 호출한다. 이 명령어는 특권 수준을 사용자 모드로 다시 하향 조정하면서 호출한 사용자 프로그램을 리턴한다.

 

하드웨어는 trap명령어를 수행할 때 주의가 필요하다. 호출한 프로세스의 필요한 레지스터들을 저장해야 한다. 운영체제가 return-from-trap 명령어 실행 시 사용자 프로세스로 제대로 리턴할 수 있도록 하기 위함이다. 예를 들면 x86에서는 프로그램 카운터(PC), 플래그와 다른 몇 개의 레지스터를 각 프로세스의 커널 스택(Kernel stack)에 저장한다. return-from-trap 명령어가 이 값들을 스택에서 팝(pop)하여 사용자 모드 프로그램의 실행을 다시 시작한다. 어떤 하드웨어 시스템이든 기본적인 개념은 위에 비슷하다.

 

아직 다루지 않은 중요한 사항이 있다. 그것은 trap이 운영체제 코드의 어디를 실행할지 어떻게 아느냐는 것이다. 호출한 프로세서는 분기할 주소를 명시할 수 없다. 주소를 명시한다는 것은 커널 내부의 원하는 지점을 접근할 수 있다는 것이기 때문에 위험하다. 커널이 임의의 코드를 실행하기 위해서는 접근 권한 검사가 끝난 후 분기해야 한다. 이러한 문제 때문에 커널은 trap 발생 시 어떤 코드를 실행할지 신중하게 통제해야 한다.

 

커널은 부팅 시에 트랩 테이블(Trap table)을 만들고 이를 이용하여 시스템을 통제한다. 컴퓨터 부트 시에는 커널 모드에서 동작하기 때문에 하드웨어를 원하는 대로 제어할 수 있다. 운영체제가 하는 초기 작업 중 하나는 하드웨어에게 예외 사건이 일어났을 때 어떤 코드를 실행해야 하는지 알려주는 것이다. 

 

운영체제는 특정 명령어를 사용하여 하드웨어에게 트랩 핸들러(Trap handler)의 위치를 알려준다. 하드웨어는 이 정보를 전달받으면 해당 위치를 기억하고 있다. 따라서 시스템 콜과 같은 예외적인 사건이 발생했을때 하드웨어는 무엇을 해야 할지(즉, 어느 코드로 분기하여 실행할지) 알 수 있다.

 

다음은 제한적 직접 실행 프로토콜을 요약해서 나타내는 것이다.

 

부팅시 커널 모드에서는 트랩 테이블을 초기화 하고 하드웨어에서는 시스템 콜 핸들러의 주소를 저장해놓는다. 시스템 콜 상황에서 하드웨어에서 어떠한 작업을 해야 할지를 알도록 하기 위함이다. 그리고 프로그램을 실행하면 커널 모드에서는 프로세스를 목록 추가, 프로그램을 위한 메모리 할당, 프로그램을 메모리에 탑재, argv 사용자 스택에 저장, 그리고 레지스터와 PC를 커널 스택에 저장한 후 return-from-trap을 한다.

 

그 후 하드웨어에서는 커널 스택으로 부터 레지스터를 복원하고 사용자 모드로 전환하며 main으로 분기한다. 그러면 프로그램은 이제 사용자 모드에서 main()을 실행한다. 그리고 만약 시스템 콜을 하게 된다면 다시 운영체제로 트랩한다.

 

트랩을 하면 하드웨어에서는 레지스터를 커널 스택에 저장하고 커널 모드로 전환하여 트랩 핸들러로 분기 한다. 이제 커널 모드에서는 트랩을 처리하고 요청 받은 syscall의 임무를 수행한 다음 return-from-trap을 한다. 다시 하드웨어에서는 커널 스택으로부터 레지스터를 복원하고, 사용자 모드로 전환하여 트랩 이후의 PC로 분기하며 사용자 모드의 프로그램으로 돌아간다.

 

main()이 끝나면 리턴하며 또 운영체제로 트랩한다.(exit() 시스템 호출) 그러면 운영체제는 프로세스의 메모리를 반환하고 목록에서 제거한다.

 

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

직접 실행의 두 번째 문제점은 프로세스 간 전환을 할 수 있어야 한다는 것이다. 프로세스 간 전환은 간단해야 하며, 운영체제는 실행 중인 프로세스를 계속 실행할지, 멈추고 다른 프로세스를 실행할 것인지 결정해야 한다. 하지만 직접 실행으로 CPU에서 프로세스가 실행 중이라는 것은 운영체제는 실행 중이지 않다는 것을 의미한다. 그러면 운영체제가 실행하고 있지 않다면 도데체 어떻게 이런 일들을 할 수 있는 것일까? 이건 정말 심각한 문제이다. 운영체제가 어떠한 조치도 할 수 없는 상황이기 때문이다.

 

협조 방식 : 시스템 콜 기다리기

협조(Cooperative) 방식으로 알려진 방법은 과거의 몇몇 시스템에서 채택되었던 방식이다. 이 방식은 운영체제가 프로세스들이 합리적으로 행동할 것이라고 신뢰한다. 너무 오랫동안 실행할 가능성이 있는 프로세스는 운영체제가 다른 작업을 실행할 결정을 할 수 있도록 주기적으로 CPU를 포기할 것이라고 가정한다. 그러면 어떻게 CPU를 포기할 수 있을까?

 

이런 유형의 운영체제는 yield 시스템 콜을 제공하는데 이 시스템 콜은 운영체제에게 제어를 넘겨 운영체제가 다른 프로세스를 실행할 수 있게 한다.

 

응용 프로그램이 비정상적인 행위를 하게 되면 운영체제에게 제어가 넘어간다. 예를 들어 응용 프로그램이 어떤 수를 0으로 나누는 연산을 실행하거나 접근할 수 없는 메모리에 접근하려고 하면 운영체제로의 트랩이 일어난다. 그러면 운영체제는 다시 CPU를 획득하여 해당 행위를 하는 프로세스를 종료할 수 있다.

 

이렇게 이 방식의 운영체제는 시스템 콜이 호출되기를 기다리거나 불법적인 연산이 일어나기를 기다려서 CPU의 제어권을 다시 획득한다. 이런 방식은 매우 수동적이기 때문에 프로세스가 무한루프에 빠져 시스템 콜을 호출할 수 없을때 CPU의 제어권을 획득할 수 없다.

 

비협조 방식 : 운영체제가 전권을 행사

프로세스가 시스템 콜을 호출하기를 거부하거나 실수로 호출하지 않아 운영체제에게 제어를 넘기지 않을 경우 하드웨어의 추가적인 도움없이는 운영체제가 할 수 있는 일은 거의 없다. 앞서 본 방식에서 이런 경우 할 수 있는 일은 컴퓨터를 다시 부팅하는 것 밖에 없다. 그럼 어떻게 CPU의 제어권을 다시 운영체제가 획득하게 할 수 있을까?

 

그 해결책은 바로 타이머 인터럽트(Timer interrupt)를 이용하는 것이다. 타이머 장치는 수 밀리 초마다 인터럽트를 발생시키도록 프로그램 가능하다. 인터럽트가 발생하면 현재 수행 중인 프로세스는 중단되고 미리 구성된 운영체제의 인터럽트 핸들러(Interrupt handler)가 실행된다. 이 시점에서 운영체제는 CPU의 제어권을 다시 얻게 되고 자신이 원하는 일을 할 수 있다. 

 

운영체제는 하드웨어에게 타이머 인터럽트가 발생했을때 실행해야 할 코드를 알려주어야 한다. 부팅 시 운영체제가 이런 준비를 한다. 부팅 과정 진행 중에 운영체제는 타이머를 시작한다. 타이머가 시작되면 운영체제는 자신에게 제어가 돌아올 것이라는 것을 알고 부담없이 사용자 프로그램을 실행할 수 있다. 

 

인터럽트 발생시 하드웨어에게도 약간의 역할이 있다. 인터럽트 발생시 실행중이던 프로그램의 상태를 저장하여 나중에 return-from-trap 명령어가 프로그램을 다시 시작할 수 있도록 해야 한다. 이러한 일련의 동작은 시스템 콜이 호출되었을 때 하드웨어가 하는 동작과 매우 유사하다. 다양한 레지스터가 커널 스택에 저장되고, return-from-trap 명령어를 통하여 복원된다.

 

문맥의 저장과 복원

시스템 콜을 통해여 협조적으로 하던, 또는 타이머 인터럽트를 통하여 약간은 강제적으로 하던, 운영체제가 제어권을 다시 획득하면 중요한 결정을 내려야 한다. 즉, 현재 실행중인 프로세스를 계속 실행할 것인지 아니면 다른 프로세스로 전환할 것인지를 결정해야한다. 이 결정은 운영체제의 스케줄러(Scheduler)라는 부분에 의해 내려진다.

 

다른 프로세스로 전환하기로 결정되면 운영체제는 문맥 교환(Context switch)이라고 알려진 코드를 실행한다. 문맥 교환은 개념적으로는 간단하다. 운영체제가 해야하는 작업은 현재 실행 중인 프로세스의 레지스터 값을 커널 스택 같은 곳에 저장하고 곧 실행될 프로세스의 커널 스택으로부터 레지스터 값을 복원하는 것이 전부이다. 그렇게 함으로써 운영체제는 return-from-trap 명령어가 마지막으로 실행될 때 현재 실행중이던 프로세스로 리턴하는 것이 아니라 다른 프로세스로 리턴하여 실행을 다시 시작할 수 있다.

 

프로세스 전환을 위해 운영체제는 저수준 어셈블리 코드를 사용하여 현재 실행중인 프로세스의 범용 레지스터, PC뿐 아니라 현재 커널 스택 포인터를 저장한다. 그리고 곧 실행된 프로세스의 범용 레지스터, PC를 복원하고 커널 스택을 이 프로세스의 커널 스택으로 전환한다. 이로써 운영체제는 인터럽트된 프로세스 문맥에서 전환 코드를 호출하고, 실행될 프로세스 문맥으로 리턴할 수 있다. 운영체제가 마지막으로 return-from-trap 명령어를 실행하면 곧 실행될 프로세스가 현재 실행 중인 프로세스가 된다. 이로써 문맥 교환이 마무리 된다.

 

아래는 이 모든 과정의 연대표를 나타낸 것이다.

 

xv6 문맥 교환 코드

 

반응형

'OS' 카테고리의 다른 글

2-4장. 스케줄링 : 멀티 레벨 피드백 큐(MLFQ)  (2) 2020.02.25
2-3장. 스케줄링 : 개요  (0) 2020.02.21
2-1장. 프로세스 API  (0) 2020.02.14
2장. 가상화  (0) 2020.02.07
1-4장. 운영체제 개요 - 설계 목표와 역사  (0) 2020.02.04
Comments