Limited Direct Execution

박정빈·2024년 3월 9일

운영체제

목록 보기
4/25

문서Processes에서CPU 가상화를 위한 시분할 방법에 대해 알아보았다.
가상화를 구현하는데는 고려할 사항들이 있다.
첫 번째는 성능이다. 시스템에 과도한 오버헤드(어떤 일을 하기 위한 시간이나 메모리)를 추가하지 않고 가상화를 구현할 수 있을지를 생각해야한다.
두 번째는 제어이다. CPU를 효율적으로 실행하면서 시스템을 제어 할 수 있을지 생각해야한다. 운영체제는 자원 즉 리소스를 관리하기 때문에 제어를 잘하는 것이 중요하다.

운영체제는 시스템을 효율적으로 제어하면서 CPU를 가상화 해야한다. 이때 하드웨어의 지원을 받는다.
제어를 유지하면서 CPU를 효율적으로 가상화 하는 것에 집중해보자!

Limited Direct Execution

운영 체제 개발자들은 Limited Direct Execution라는 기법을 고안했다. Direct Execution - 직접 실행 부분은 간단하다. CPU에서 프로그램을 직접 실행한다는 뜻이다.
이것은 운영체제가 프로그램을 실행하기 위해 프로세스 목록에 프로세스 항목을 만들고, 메모리를 할당하고, 프로그램을 디스크에서 메모리로 로드하고, 진입점(main()과 같은)으로 점프하여 코드를 실행시킨다는 뜻이다. 커널에서 코드로 코드에서 커널로 호출과 반환을 사용하며 직접 실행한다. (제한이 없을때 Unlimited)
 Direct Execution Protocol (Without Limits)
하지만 이러한 접근 방식은 몇가지 질문을 일으킨다.
첫 번째는 운영체제가 시스템을 보호할 수 있을까 에 대한 질문이다. 프로그램을 단순히 실행하는 경우 , 우리가 원하지 않는 작업을 수행하지 않도록 보장할 수 있는 방법이 있을까?
두 번째는 시분할을 위해 제어를 회수할 수 있는 지 여부에 대한 질문이다. 프로세스를 실행하는 경우, 운영체제가 다른 프로세스로 전환을 하여 CPU를 가상화를 할 수 있을지를 알아야한다.

이 질문에 답을 하는 과정에서 가상화에 필요한 요구사항을 더 잘 이해할 것이며 Limited가 의미하는 바를 알게될 것이다.

Restricted Operations 제한된 작업

CPU에서 프로그램이 직접 실행되면 빠르다는 장점이 있다. 하지만 프로세스는 I/O및 기타 제한된 작업을 수행함과 동시에 시스템 전체를 제어할 수는 없어야한다.

한 접근 방법은 I/O및 기타 관련 작업에 대해 어떤 프로세스든 원하는 대로 수행하도록 허용하는 것이다. 그러나 이런 방식은 권한을 확인하는 시스템을 구성할 수 없다. 예를 들어 파일에 액세스 권한을 확인하는 시스템을 구축할 수 없다. 프로세스가 전체 디스크를 변경할 수 있으며 모든 보호가 사라진다.
그러므로 우리가 선택할 방식은 사용자 모드 user mode라고 알려진 방식이다. 이 방식에서 코드는 수행할 수 있는 작업에 제한이 생긴다. 예를 들어 사용자 모드에서 실행 중일 때 프로세스는 I/O요청을 보낼 수 없다. 그러면 프로세서가 예외를 발생시키고, 운영체제가 해당 프로세스를 종료할 것이다.
이를 보완하기 위해 커널 모드 kernel mode가 있다. I/O 요청과 같은 권한이 필요한 작업들을 커널모드에서 수행할 수 있게 한다.
The structure of a typical UNIX system.
위의 사진 출처

하지만 만약 user mode의 프로세스가 Kernel mode 에서 수행 가능한 작업을 원할때 어떻게 해야할까? 이것을 가능하게 하는 것이 system call 이다. system call이 실행되면 trap 명령으로 권한을 바꿔 작업을 수행하고, 수행 후엔 운영체제가 return-from-trap 명령을 통해 원래 권한으로 돌아온다.
user mode 에서 kernel mode로 switch 할 때 trap을 사용한다는 것을 알았다. 그렇다면 운영체제는 trap을 어떻게 사용할 수 있을까? 운영체제는 trap을 사용하기 위해 부팅시 초기화 되는 trap table을 사용한다. trap table 에는 소프트웨어적 사건들을 처리하기 위한 함수가 들어있다. 또한 이러한 함수들에는 system call number 라는 번호가 정의되어 있다.
아래는 실제 프로세스가 trap을 사용하여 작업을 수행하는 과정이다.
Limited Direct Execution Protocol
각 프로세스가 커널 스택을 가지고 있다고 가정하며, 커널로 진입하고 나올 대 하드웨어에 의해 레지스터가 저장되고 복원된다. 이 프로토콜에는 두 단계가 있다.
첫 번째는 부팅 시, 커널이 트랩 테이블을 초기화하고, CPU가 이후 사용을 위해 그 위치를 기억한다.
두 번째는 프로세스를 실행할 때 커널이 몇 가지 설정을 한 뒤 return-from-trap 명령을 사용하여 CPU를 user mode로 전환하고 프로세스를 실행한다.
프로세스가 system call을 하면, OS로 trap 되어 처리되고, return-from-trap을 통해 다시 프로세스에 제어를 반환한다. main()이 return 하여 프로세스가 완료되면(exit() system call 로 trap 됨), OS가 프로세스를 메모리에서 해제하고 프로세스 리스트에서 삭제한다.

안전한 운영체제를 위하여 고려해야할 사항이 더 있다. 
안전한 시스템을 위해서 사용자 입력에도 신중해야한다.
이 중 하나는 system call의 경계에서 인수arguments를 처리하는 것이다.
OS는 사용자가 인수가 올바른지 전달한 내용을 확인하고, 아니라면 호출을 거부해야한다.
예를 들어, write() system call 에서 사용자는 버퍼의 주소를 지정한다.
이때, 나쁜 주소-가령, 커널의 주소 공간의 일부-를 전달하면 이 호출을 거부해야한다.
만약 거부하지 않는다면, 시스템의 다른 프로세스의 메모리를 읽을 수 있다.

시스템 호출 system call 이 프로시저 호출과 동일한 모습을 갖는 이유

open(),read()와 같은 시스템 호출이 C에서 일반적인 프로시저 호출처럼 보인다면, 시스템은 어떻게 둘을 구별하고 올바른 작업을 할까? 대답은 시스템 호출도 프로시저 호출(trap()이 숨겨져있는)이라는 것이다. 구체적으로, open()과 같은 함수를 호출할 때는 C라이브러리에서 프로시저 호출이 실행된다. 여기에는 해당 시스템 호출과 관련된 라이브러리가 커널과 약속한 호출 규약을 사용하여 open()에 대한 인수와 시스템 호출 번호를 각각 스택이나 레지스터에 넣고 trap() 명령어를 실행한다. trap 다음의 라이브러리의 코드는 반환 값을 언팩하고, 시스템 호출을 발행한 프로그램에 제어를 반환한다. 따라서 시스템 호출을 수행하는 C라이브러리의 부분은 인수와 반환 값을 올바르게 처리하고, 하드웨어별 trap 명령어를 실행하기 위해 어셈블리로 직접 작성되어야 한다. 이제 운영체제로 트랩을 걸기 위해 어셈블리 코드를 작성할 필요가 없는 이유를 알았다. 누군가가 이미 작성해 두었기 때문이다. (이해 못함)

Switching Between Processes 프로세스 간 전환

프로세스 간의 전환은 간단해보인다. OS가 단순히 실행중인 프로세스를 중지하고 다른 프로세스를 시작하면 되기 때문이다. 하지만 실제로는 더 까다로운 작업이다. 프로세스가 CPU에서 실행 중인 경우, OS는 실행되지 않는다. 그렇다면 어떻게 CPU 제어를 다시 얻어 프로세스들을 전환할 수 있을까?

A Cooperative Approach: Wait For System Calls 협력적 접근

과거에는 Cooperative Approach:협력적 접근이라는 방식을 채택했다. 이 방식의ㅣ OS는 시스템의 프로세스가 합리적으로 행동할 것이라고 믿는다. 오랫동안 실행되는 프로세스는 주기적으로 CPU를 포기하여 OS가 다른 작업을 할 수 있게 한다. 이 작업은 어떻게 이루어 지는 걸까? 대부분의 프로세스는 명시적인 양보(system call)을 사용하기에 이때, OS에 제어를 전달한다. 또 프로그램은 나쁜짓을 할 때도 제어를 전달한다. 예를 들어 0으로 나누거나 접근불가 메모리에 접근하려하면 OS에 trap을 생성하고, OS가 CPU의 제어를 얻게된다.(이러면 프로세스는 종료돨 것이다.)
이렇게 수동적인 접근방식에서, 나쁜 프로세스가 system call을 하지 않는 경우는 어떻게 될까요?

A Non-Cooperative Approach: The OS Takes Control 비협력적 접근

협력적 접근 방식에서 프로세스가 무한 루프에 빠지면 재부팅을 할 수 밖에 없다. 나쁜 프로세스가 협력을 하지 않을 때 OS가 제어를 얻을 수 있을까? 타이머 인터럽트 timer interrupt를 사용한다. 일정 시간마다 CPU에게 태클(interrupt)을 거는 타이머장치는 현재 실행중인 프로세스를 중지하고, 인터럽트 핸들러 interrupt handler가 실행된다. 이때, OS는 제어를 얻을 수 있다. OS는 타이머 인터럽트가 발생할 때 다음에 실행할 코드를 하드웨어에게 알려줘야한다. 이것은 부팅 시점에 OS가 수행한다. 부팅 시퀀스 중에 OS는 타이머를 시작한다. 이를 통해 OS는 제어가 본인에게 돌아올 것임을 확신할 수 있다. 타이머는 꺼질 수 있다. 동시성을 논할때 더 자세히살펴보자
인터럽트가 발생할 때 하드웨어는, 그 때의 프로그램의 상태를 저장하여 return-from-trap 명령이 올바르게 실행되도록 해야한다. 이 작업은 system call trap으로 커널 내의 하드웨어의 동작과 유사하며, 여러 레지스터가 저장되고 이후 return -from-trap 명령에 의해 쉽게 복원된다.

Saving and Restoring Context 문맥 저장 및 복원

OS가 제어를 되찾았다면, 프로세스 전환 여부를 결정해야한다. 이 결정은 스케줄러에 의해 이루어진다. 프로세스를 전환하기로 결정을 했다면, OS는 실행 중인 프로세스에 대한 레지스터 값을 저장하고, 실행 될 프로세스의 레지스터 값을 복원한다. 그리고 return-from-trap 명령을 실행할 때 다른 프로세스의 실행을 재개한다.

실행 중인 프로세스의 context를 저장하기 위해, OS는 그 프로세스의 범용 레지스터, PC, 커널 스택 포인터를 저장하기 위해 저수준 어셈블리 코드를 실행한다. 그리고 해당 레지스터,PC를 복원하고 커널스택으로 전환한다. 스택을 전환함으로써, 커널은 중단된 프로세스의 context로 전환 호출로 들어가고(원문:enters the call to the switch code) 실행될 프로세스의 context 로 return 된다. 그리고 OS가 return-from-trap 명령을 실행하면 프로세스가 바뀌고 context switching이 완료된다.
아래는 context switching 하는 과정의 타임라인이다.
Limited Direct Execution Protocol (Timer Interrupt)
프로세스 A가 실행되고 타이머 인터럽트에 의해 중단된다. 하드웨어는 해당 프로세스의 커널 스택에 레지스터를 저장하고 커널 모드로 전환한다.
타이머 인터럽트 핸들러에서 OS는 프로세스 A에서 B로 전환하기로 결정한다. switch() 루틴을 호출해서 A의 레지스터 값을 저장하고 B의 레지스터를 복원한다. 그런다음 context를 전환하고 B의 커널 스택을 사용한다. 마지막으로 OS가 return-from-trap을 실행하면 B의 레지스터가 복원되고 실행이 시작된다.

이 프로토콜이 진행되는 동안 레지스터의 저장과 복원이 두 번 일어났다.
첫 번째는 타이머 인터럽트가 발생할 때이다. 이 경우 실행중인 프로세스의 레지스터는 하드웨어에 의해 암시적으로 저장되며 해당 프로세스의 커널 스택을 사용한다.
두 번째는 OS가 A에서 B로 전환 결정을 할 때 이다. 이 경우 커널 레지스터가 OS에 의해 명시적으로 저장되고 해당 프로세스의 프로세스 구조에 메모리로 저장된다.

아래는 xv6의 context switching 코드 이다.

# void swtch(struct context *old, struct context *new);
# Save current register context in old
# and then load register context from new.
.globl swtch
swtch:
# 이전 레지스터를 저장
movl 4(%esp), %eax   # 이전 포인터를 eax 레지스터에 저장
popl 0(%eax)         # 이전 IP 저장
movl %esp, 4(%eax)   # 스택도 저장
movl %ebx, 8(%eax)   # 다른 레지스터들도 저장
movl %ecx, 12(%eax)
movl %edx, 16(%eax)
movl %esi, 20(%eax)
movl %edi, 24(%eax)
movl %ebp, 28(%eax)

# 새로운 레지스터 로드
movl 4(%esp), %eax   # 새로운 포인터를 eax 레지스터에 저장
movl 28(%eax), %ebp  # 다른 레지스터들을 복원
movl 24(%eax), %edi
movl 20(%eax), %esi
movl 16(%eax), %edx
movl 12(%eax), %ecx
movl 8(%eax), %ebx
movl 4(%eax), %esp   # 여기서 스택을 전환
pushl 0(%eax)        # 반환 주소 지정
ret                  # 마지막으로 새로운 컨텍스트로 반환

context switch 와 system call 의 소요 시간

context switch 와 system call 를 하는데 얼마의 시간이 걸릴까? imbench라는 도구를 통해 알 수 있다. 예를 들어 예를 들어, 1996년에 200MHz P6 CPU에서 Linux 1.3.37를 실행할 때, system call은 대략 4 마이크로초가 걸리고, context switch는 대략 6 마이크로초가 걸렸다. 현대 시스템은 성능이 더 향상되어 더 적은 시간이 걸릴 것이다.

CPU 성능을 추적하는 모든 운영체제 작업이 동일하지는 않다. 운영체제 작업은 메모리 집중적이며, 메모리 대역폭은 프로세서의 속도만큼 향상되지 않았다. 따라서, 작업 부하에 관해서는 최신의 프로세서를 사용한다고 해서 운영체제의 성능이 극적으로 향상되지는 않을 것이다.

p2.c 에서 context switching

p2.c
code of p2.c

출력
result of p2.c
시간에 따른 context switching
p2.c 에서 context switching

0개의 댓글