[Operating System] – “제한적 직접 실행 원리(Limited Direct Execution)”

[Operating System] – “제한적 직접 실행 원리(Limited Direct Execution)”

10월 19, 2024

이 글은 도서 Operating Systems: Three Easy Pieces 를 읽고, 제가 이해한 방식대로 정리한 것임을 밝힙니다.
blog-driven-development 나 blog-driven-learning 은 위험합니다. 저의 글을 끊임없이 의심하고, 검증하고, 혹시라도 틀린 내용이 있거나, 논리적 비약이 있다면 가감없이 알려주시면 감사하겠습니다 :)

CPU 가상화의 문제점

운영체제는 한 프로세스를 잠깐 실행하고, 다른 프로세스를 또 잠깐 실행하고.. 를 반복하여 여러 프로세스들을 잠깐씩 실행시켜, 여러 개의 프로세스가 동시에 실행시키는 것처럼 보이도록 합니다. 이렇게 CPU 의 시간을 나누어 씀으로서 가상화를 구현하는 거죠. 기본 아이디어는 간단해 보이지만 가상화를 구현하기 위해서는 몇 가지의 문제점을 해결해야 합니다.

  1. 시스템에 과도한 부하를 주지 않으며 가상화를 구현할 수 있어야 합니다. CPU 에게 어떤 프로세스를 실행시키고, 그 작업을 중지시키고, 다른 프로세스의 정보를 주며 이 프로세스를 실행하라고 해야 합니다. 오늘은 고깃집 알바, 내일은 물류센터 알바, 모레는 예식장 아르바이트를 한다면 저는 적응도 하지 못하고 미쳐버릴 겁니다.
  2. CPU 에 대한 통제를 유지하며 프로세스를 효율적으로 실행시킬 수 있어야 합니다. 너무 많은 알바의 종류 수에 미쳐버린 제가 하나의 아르바이트만 쭉 출근해 버리거나, 맘대로 다른 아르바이트에 지원해서는 안 되겠죠.

이처럼, 운영체제는 CPU 에 대한 제어권을 유지하며, 시스템에 오버헤드를 주지 않도록, CPU 가상화를 구현할 수 있어야 합니다.

제한적 직접 실행(Limited Direct Execution)

옛날 옛적, 프로그램은 단순히 CPU 상에서 “직접 실행” 되었습니다. 프로세스 목록에 프로세스 항목을 만들고, 프로세스 메모리를 할당하고, 프로그램 코드를 디스크에서 찾아 탑재하고, 사용자의 코드를 실행시킨 거죠. 그렇게 코드를 직접 실행시켜 버리고, 사용자의 코드가 실행이 완료되면, 프로세스의 메모리를 반환하고 프로세스 항목에서 해당 프로세스를 제거하는 간단한 방식을 사용했던 겁니다.

이 방법은 간단했지만 문제가 있었습니다. 첫째로, 사용자의 코드 안에 운영체제가 원치 않는 동작이 포함되어 있다는 것을 운영체제가 어떻게 보장해줄 수 있을까요? 둘째, 우리가 이전에 배웠던 CPU 가상화를 위한 사분할 기법을, 운영체제에 오버헤드를 주지 않고 어떻게 구현할 수 있을까요? 어쩌면 운영체제는 프로그램에게 모든 것을 주지 않고, 어떤 “제한” 을 주어야 하는 것이 아닐까요?

제한된 연산

프로그램을 하드웨어 CPU 에서 직접 실행시켜 버리는 것은 빠르다는 장점이 있습니다. 하지만 만약 프로세스가 디스크의 입출력 요청, 시스템 자원에 대한 추가 할당을 요청하는 등의 특수한 종류의 연산을 수행해야 한다면 어떻게 해야 할까요? 단순히 “프로세스가 모든 디스크 및 자원에 접근하는 것을 허용하는 것” 처럼 상남자스러운 방법 말고 말이에요. 만약 그렇게 모든 접근을 허용한다면 운영체제에서 파일의 권한 시스템, 유저의 권한 시스템을 구현하는 것이 무의미해져 버리잖아요?

user mode & kernel mode

이때, 똑똑하신 분들께서는 사용자 모드와 커널 모드(user mode & kernel mode) 라는 것을 도입합니다. 사용자 모드에서 실행되는 코드는 할 수 있는 일이 제한되고, 커널 모드에서는 운영체제의 중요한 코드들이 실행되도록 하는 것이죠. 커널 모드에서는 원하는 모든 코드와 작업들을 실행, 수행할 수 있습니다.

하지만 어쩔 때는 사용자 모드가 커널 모드에서 실행 가능한 작업들을 수행해야 하는 경우가 있습니다. 현대 하드웨어의 대부분은 user mode 프로세스에게 시스템 콜을 제공하여 이런 문제를 해결합니다. 이런 시스템 콜들을 사용하기 위해선, 프로그램은 먼저 trap 이라는 명령어를 실행해야 합니다. trap 명령어를 실행함으로서, 권한을 커널 모드로 상향하여 원하는 작업을 수행할 수 있도록 해 줍니다. 작업이 완료되면, 운영체제는 return-from-trap 특수 명령어를 실행해 권한을 user mode 로 다시 조정하여 그것을 호출한 사용자 프로그램으로 리턴하게 됩니다. 내부적으로는, trap 명령어를 실행하면 운영체제는 호출한 프로세스의 필요한 레지스터를 저장하고, return-from-trap 명령어가 제대로 사용자 프로그램으로 리턴되도록 보장하는 작업이 이루어지죠.

지금까지의 내용을 정리해 보자면,

  1. 운영체제는 어떤 프로세스가 시스템의 자원에 접근하는 것을 통제할 책임이 있습니다.
  2. 하여, 수행할 수 있는 일이 제한된 user mode 와 원하는 모든 작업을 수행할 수 있는 kernel mode 는 분리되어 있습니다.
  3. 만약 어떤 프로세스가 디스크에 접근하거나, 새로운 메모리 할당을 요구한다면 user mode 에서는 그것을 수행할 수 없으므로 trap 이라는 특수 명령어를 사용해 user mode 프로세스를 kernel mode 로 변경해야 합니다.
  4. 운영체제는 kernel mode 로 변경하기 전 trap 을 호출한 프로세스의 필요한 정보들을 저장하고(kernel stack), kernel mode 로 권한을 상향 조절한 후 원하는 작업을 수행합니다.
  5. 원하는 작업 수행 후, 운영체제는 return-from-tap 명령어를 통해서 user mode 프로그램을 다시 시작합니다.

그렇다면, 이런 의문이 들 수 있습니다. 아래의 시나리오를 머릿속으로 떠올려 보세요.

  1. 어떤 프로세스가 디스크에 접근하고자 하는 system call 을 호출합니다.
  2. 디스크에 접근하는 것은 사용자 모드(user mode) 프로세스에서는 제한되어 있으므로, trap 특수 명령어를 호출합니다.
  3. trap 명령어는 프로세스의 권한을 커널 모드(kernel mode) 로 바꾸고, 원하는 작업(디스크 접근)을 수행합니다.
  4. 그러면, “어떤 시스템 콜이 운영체제의 어떤 명령어를 실행해야 하는지” 는 어떻게 알 수 있을까요?, 예컨대, c 프로그램의 read(), open() 과 같은 함수가 호출되면 운영체제 내부의 어떤 코드를 실행해야 하는지 알 수 있냐는 겁니다.

특정 시스템 콜을 호출한 프로세스가 시스템 콜을 다루는 커널의 내부 주소를 직접 명시할 수 없다는 것은 자명합니다. 왜냐하면 user mode 프로그램은 커널 내부 코드에 맘대로 접근할 수 없어야 하기 때문이죠. 프로세스를 통제하는 것이 운영체제의 책임인 만큼, trap 명령어 발생 시 운영체제는 커널의 어떤 코드를 실행해야 할지를 신중하게 통제해야 합니다.

trap table

이를 위해 커널은 시스템 부팅 시 trap table 이라는 것을 초기화합니다. trap table“이 시스템 콜에 대해서 커널 내부의 어떤 코드를 실행해야 하는가?” 를 저장하고 있는 포인터 배열입니다. 운영체제는 특수 명령어를 통해서 하드웨어에게 예외 사건(시스템 콜, 인터럽트 등)이 발생하면 커널의 어떤 코드를 실행해야 하는지 알려주고, 하드웨어는 그 주소를 기억하고 있다가 예외가 발생하면 해당 커널 핸들러(커널 내부의 어떤 코드)로 분기되도록 하는 역할을 합니다.

정리해 보자면, trap table 은 아래와 같이 동작하는 거죠.

  1. 시스템 부팅 시, 커널은 trap table 이라는 것을 초기화합니다. trap table 내부에는 시스템 콜이나 인터럽트와 같은 사건이 발생했을 때에 커널의 어떤 코드를 실행해야 할지에 대한 정보가 저장되어 있습니다.
  2. 운영체제는 하드웨어에게 trap table 이 가지고 있는 정보를 이용해, 특수 명령어로 “이 사건이 발생하면, 너는 이 커널 내부의 코드로 분기하면 돼~” 를 알려줍니다.
  3. 운영체제 내부에서 사용자 모드의 프로세스가 커널 모드에서 수행 가능한 작업을 필요로 하면(시스템 콜이 발생하면), trap 명령어가 호출되고 운영체제는 kernel stacktrap 을 호출한 프로그램의 레지스터와 PC 정보를 저장합니다.
  4. 하드웨어는 부팅 시에 운영체제로부터 어떤 시스템 콜이 발생했을 때 어떤 코드를 실행해야 하는지 알고 있으므로, 프로세스가 요청한 시스템 콜에 대한 커널 코드로 분기합니다.
  5. 운영체제는 trapsystem call 을 처리하고, 저장해 두었던 kernel stack 의 정보로부터 레지스터를 복원한 후 return-from-trap 특수 명령어를 호출하여 프로세스를 사용자 모드로 전환합니다.
  6. 마침내, 프로그램은 원하는 작업을 수행하고 exit() 을 통한 trap 을 호출하게 되고, 운영체제는 같은 작업을 통해 커널 내부의 코드를 실행합니다. 프로세스의 메모리를 반환하고, 실행 중인 프로세스 목록에서 해당 프로세스를 제거하는 거죠.
운영체제 (kernel mode)하드웨어프로그램(user mode)
(부팅 시)
trap table 초기화
(부팅 시)
syscall 핸들러 주소 기억
1. 프로세스 목록에 항목 추가
2. 프로그램을 위한 메모리 할당
3. 프로그램을 메모리에 탑재
4. argv 를 사용자 스택에 저장
5. 레지스터, PCkernel stack 에 저장
6. return-from-trap 특수 명령어 사용
1. kernel stack 으로부터 레지스터 복원
2. 사용자 모드로 이동
3. main() 으로 분기
1. main() 실행
2. 일련의 작업 후, system call 호출
3. trap 특수 명령어 사용
1. 레지스터를 프로세스의 kernel stack 에 저장
2. kernel mode 로 이동
3. syscall 핸들러(트랩 핸들러) 로 분기
1. trap 처리
2. syscall 작업 수행
3. return-from-trap 특수 명령어 사용
1. kernel stack 으로부터 레지스터 복원
2. 사용자 모드로 이동
3. 트랩 이후의 프로그램 실행 위치(PC 값) 으로 분기
1. main() 에서 분기
2. exit() 을 통해 trap
1. 프로세스의 메모리 반환
2. 프로세스를 목록에서 제거

결국, 위와 같은 상황이었던 겁니다.

프로세스 간 전환

이제 우리는 운영체제가 어떻게 시스템에 대한 권한을 잃지 않으며 프로세스를 실행시킬 수 있는지를 알게 되었습니다. 글의 머리에서 던져졌던 질문, “사용자의 코드 안에 운영체제가 원치 않는 동작이 포함되어 있다는 것을 운영체제가 어떻게 보장해줄 수 있을까요?” 를 해결한 것이죠. 이제 두 번째 문제를 해결할 시간입니다.

“시스템에 오버헤드를 주지 않으며, 사분할 기법을 어떻게 구현할 수 있을까요?”

운영체제가 수행해 줬던 CPU 가상화의 아이디어는, 여러 개의 프로세스를 아주 잠깐씩 실행하여 마치 여러 개의 프로세스가 동시에 실행되는 것만 같은, 여러 개의 CPU 가 동시에 실행되는 것만 같은 환상을 주는 것이었습니다. 아래의 그림처럼 말이죠.

          cpu
          
process 1 -  -  --
process 2  -  -   -
process 3   -  -   -

위의 아이디어를 구현하기 위해서는 “실행 중인 프로세스를 중지” 하고 “재시작” 할 수 있어야 합니다. 위에서 말한, “직접 실행 방법(CPU 상에서 프로그램을 직접 실행)” 방식으로 프로그램을 실행한다면 운영체제의 입장에서는 실행 중인 프로그램을 중지할 수 있는 방법이 없습니다. 운영체제는 어떻게 CPU 를 다시 획득하여 프로세스를 전환할 수 있는 걸까요?

Cooperative Approach

초기 운영체제에서는 협조(cooperative) 방식을 택했습니다. 너무 오랫동안 실행될 가능성이 있는 프로세스가 주기적으로 CPU 를 포기할 것이라고 가정했던 것입니다. 이러한 유형의 운영체제는 yield 시스템 콜을 제공했는데, 운영체제에게 제어권을 넘겨 다른 프로세스를 실행할 수 있게끔 하는 것이었죠.

The yield subroutine forces the current running process or thread to relinquish use of the processor. If the run queue is empty when the yield subroutine is called, the calling process or kernel thread is immediately rescheduled. If the calling process has multiple threads, only the calling thread is affected. The process or thread resumes execution after all threads of equal or greater priority are scheduled to run.

yield 서브루틴은 현재 실행 중인 프로세스 또는 스레드가 프로세서 사용을 포기하도록 강제합니다. yield 서브루틴이 호출될 때 실행 대기열이 비어 있으면 호출 프로세스 또는 커널 스레드의 스케줄이 즉시 재조정됩니다. 호출 프로세스에 여러 스레드가 있는 경우 호출 스레드만 영향을 받습니다. 우선 순위가 같거나 더 높은 모든 스레드가 실행되도록 예약된 후에 프로세스 또는 스레드가 실행을 재개합니다.

https://www.ibm.com/docs/en/aix/7.2?topic=y-yield-subroutine

만약 운영체제가 비정상적인 행동을 하게 되면, trap 이 일어나고, 운영체제는 위에서 배운 trap 의 동작처럼 CPU 를 획득하게 되고 그 프로세스를 종료할 수 있게 됩니다. 결국, 운영체제는 아래의 상황에서만 CPU 를 획득할 수 있었던 겁니다.

  1. 응용 프로그램이 yield 시스템 콜을 호출하여 제어권을 넘겨줄 때
  2. 비정상적인 행동(0으로 나누기, 허용되지 않은 메모리에 접근 시도) 때문에 trap 이 일어날 때

그러면.. 만약, 두 상황 모두 아닌 경우면 어떻게 될까요? 예컨대, 무한 루프에 빠져 버그도, yield 시스템 콜도 호출하지 않는 상황이라면 운영 체제는 니가 뭘 할 수 있는데 상황에 빠지는 게 아닐까요? 대체 뭘 할 수 있을까요?

Non-Cooperative Approach

Cooperative Approach 방식에서 프로세스가 실수로든, 악의적으로든 시스템 콜을 호출하지 않아 운영체제에게 CPU 제어권을 넘기지 않을 경우(yield 등을 호출하기를 거부하여 운영체제에게 CPU 권한을 주지 않음), 그것을 해결할 수 있는 일은 거의 없습니다. 전원 버튼을 꾸욱-눌렀다 다시 누르는, “재부팅 방법” 말고는 말이죠. 맞아요! 위의 Cooperative Approach 방식에서는 위와 같은 문제가 있었습니다. “프로세스가 운영체제에게 비협조적인 경우” 죠. 오래 전 이 문제를 해결하기 위해, 똑똑하셨던 선배님들은 타이머 인터럽트(Timer Interrupt) 라는 방법을 고안해 내셨습니다. 몇 밀리세컨드마다, 인터럽트를 발생시켜 현재 수행 중인 프로세스를 중지시키고, 운영체제의 인터럽트 핸들러(Interrupt Handler) 가 실행되도록 하는 거죠. 이 때, 운영체제는 CPU 의 제어권을 얻어 원하는 일을 할 수 있게 됩니다.

마이크로프로세서에서 인터럽트(interrupt), 끼어듦, 또는 가로막기란, 마이크로프로세서(CPU)가 프로그램을 실행하고있을 때, 입출력하드웨어 등의 장치에 예외상황이 발생하여 처리가 필요할 경우에 마이크로프로세서에게 알려 처리할 수 있도록 하는 것을 말한다.

https://ko.wikipedia.org/wiki/%EC%9D%B8%ED%84%B0%EB%9F%BD%ED%8A%B8

시스템이 부팅될 때 운영체제는 타이머를 시작하게 되고, 따라서 몇 밀리세컨드마다 제어권을 획득할 수 있게 되었으므로 응용 프로그램을 부담없이 실행시킬 수 있게 됩니다. 하드웨어의 입장에서는, 실행이 중지되었을 때 그 프로세스의 상태를 저장하여 return-from-trap 명령어가 그 프로세스를 중지된 시점부터 다시 잘 시작될 수 있도록 할 책임이 있습니다. 위에서 배웠던 system call 이 일어났을 때 하드웨어의 동작 과정과 매우 유사하죠?

Context Switch

이제, 우리는 압니다. 어떻게 운영체제가 실행 중인 프로세스로부터, 심지어 그것이 운영체제에게 비협조적인 경우에도 CPU 제어권을 다시 얻는지를요. 제어권을 얻은 운영체제는 “이 시점에서, 어떤 프로세스를 잠시 멈추고 어떤 프로세스를 실행시킬 것인가?” 를 결정해야 합니다. 이것은 운영체제의 스케쥴러에 의해서 결정되는데 다음 글에서 알아볼 주제이기도 합니다.

어쨌든, 지금은 블랙박스 속에 있는 개념이지만, 운영체제가 현재 프로세스를 잠시 중지하고 다른 프로세스를 실행시키기로 결정했다면 그것이 해야 할 일은 현재의 프로세스 상태를 잠시 저장하고, 다른 프로세스의 상태를 가져와 복원시키는 겁니다(현재 프로세스의 레지스터와 PC 값을 kernel stack 에 저장하고, 다른 프로세스의 값을 읽어와 복원합니다). 그리고 이것을 문맥 교환(Context Switch) 라고 합니다.

운영체제 (kernel mode) 하드웨어프로세스(user mode)
(부팅 시)
trap table 초기화
(부팅 시)
syscall 핸들러 주소 기억
(부팅 시)
interrupt timer 시작
(부팅 시)
~ms 후 CPU 인터럽트
프로세스 A 실행 중 …
1. timer interrupt 발생
2. 프로세스 A 의 레지스터를 kernel stack 에 저장
3. kernel mode 로 이동
4. 기억하고 있던 trap handler 로 분기
1. trap 처리
2. switch() 루틴 호출
3. 프로세스 A 의 레지스터를 A 의 프로세스 구조(struct proc) 에 저장
4. 프로세스 B 의 구조로부터 B 의 레지스터 복원
5. return-from-trap 특수 명령어 호출
1. 프로세스 B 의 커널 스택을 B 의 레지스터로 저장
2. BPC(프로그램 카운터) 로 분기
프로세스 A 실행 중 …

레지스터의 저장과 복원은 timer inturrupt 가 발생했을 때 하드웨어에 의해서 한 번, 운영체제가 스케쥴러에 의해 프로세스 A 를 프로세스 B 로 전환하기로 했을 때 운영체제에 의해 두 번 발생하게 됩니다. 위와 같은 과정을 통해서 말이죠 :)

요약

우리는 이번 포스트에서 어떻게 운영체제가 실행 중인 프로세스에 대한 제어권을 유지하고, 한 프로세스를 어떻게 다른 프로세스로 전환시킬 수 있는지에 대한 – 운영체제가 사용하는 저수준 기법들에 대해 배웠습니다. 그리고 이들을 묶어 제한적 직접 실행이라고 부릅니다. CPU 가상화 라는 마법을 이해하기 위해선 “특정 순간에 실행할 수 있는 프로세스가 여러 개 있을 때, 어떤 프로세스를 실행시켜야 마땅한가?” 에 대해 더 알아보아야 합니다. 예상하셨다시피, 다음 포스트에서 우리는 이 질문에 답할 것입니다.

Leave A Comment

Avada Programmer

Hello! We are a group of skilled developers and programmers.

Hello! We are a group of skilled developers and programmers.

We have experience in working with different platforms, systems, and devices to create products that are compatible and accessible.