Operating sysetem - Direct execution
◼︎ Limited Direct Execution (제한적 직접 실행 원리)
앞에서 계속 말했듯 CPU를 가상화 하기 위해 물리적인 CPU를 공유하여 여러 작업들이 동시에 실행되는 것 처럼 보이게 한다. 이것은 한 프로세스를 잠시동안 실행하고 또 다른 프로세스를 또 잠깐 실행하여 이를 반복하여 가상화를 하게 된다. 아마 이런 느낌….

그러나 이런 가상화를 구현하기 위해서는 몇가지 문제를 해결해야 한다.
- 성능 저하 -> 시스템에 과부화를 주지 않고 가상화를 어떻게 구현할까?
- 제어 문제 -> 프로세스를 효율적으로 실행시킬 수 있는 방법은 무엇일까? (특히 중요하다!)
기본 원리: 제한적 직접 실행
운영체제 개발자들은 프로그램을 빠르게 실행하기 위해 제한적 직접 실행(Limited Direct Execution)이라는 기법을 고안했다. “직접 실행”은 CPU에서 직접 실행시킨다는 뜻이다. 즉, 프로그램을 프로세서에 올리고 데이터와 변수,코드를 가져와 메모리에 저장하고 시작점으로 분기해 코드를 실행한다.

이것은 제한 없는 직접 실행 프로토콜이다. 이 제한 없는 직접 실행은 문제를 일으킨다. 첫 번째 문제는 프로그램과 운영체제가 원치않는 일을 하지 않는 것을 보장할 수 없다는 것이고 두 번째 문제는 프로세스 실행 시 운영체제는 프로그램의 실행을 중단하고 다른 프로세스로 전환 시킬 수 없다는 것이다. 즉, 시분할(time sharing)을 어떻게 할 것이냐이다.
문제점 1: 제한된 연산
직접 실행을 하면 굉장히 빠르다는 장점이 있다. 하지만 이렇게 되면 문제가 있다. 프로세스가 특수한 종류의 연산(ex. convolution, GCD, LCM ….)을 요구하면 디스크의 입출력 요청이나 시스템 자원들에 대한 추가 할당 요청을 해야하는 등 CPU 자체에서만 해결이 되지 않는 문제가 있다.
파일에 대한 접근을 허용하기 전에 접근 권한을 검사하는 파일 시스템을 구현한다고 하자. 우선 프로세스가 디스크에 대해 입출력 하는 것을 제한하지 않으면 권한을 검사하는 기능이 아무런 의미가 없을 것이다. 그래서 사용자 모드(user mode)라는 것이 도입되었다. 사용자 모드에서는 할 수 있는 일이 제한된다. 만약 허용되지 않은 입출력 요청을 하면 운영체제는 해당 프로세스를 제거한다. 이와 대비되는 모드로는 커널 모드(kernel mode)가 있다. 운영체제의 중요한 코드들이 실행되며 모든 작업을 수행할 수 있다.
만약 사용자 모드에 있을 때 특권 명령어를 실행해야 하면 어떻게 해야할까? 이런 제한 작업에 대한 실행을 허용하기 위해 거의 모든 현대 하드웨어는 사용자 프로세스에게 시스템 콜(system call)을 제공한다. 이런 시스템 콜의 기능에는 파일 시스템 접근, 프로세스 생성 및 제거, 다른 프로세스와의 통신 및 메모리 할당 등이 있다.
시스템 콜을 실행 하기 위해서는 trap 특수 명령어를 실행해야 한다. 이 명령어는 커널 안으로 분기하는 동시에 특권 수준을 커널 모드로 상향 시켜 프로세스가 모든 명령어를 처리할 수 있다. 완료되면 운영체제는 return-from-trap 명령어를 호출한다. 이 명령어는 다시 사용자 모드로 되 돌려준다. 하드웨어는 trap 명령어를 수행할 때 호출한 프로세스의 필요한 레지스터들을 저장해야한다. 왜냐하면 return-from-trap 명령어를 통해 사용자 프로세스로 다시 리턴할 수 있도록 하기 위함이다.
x86에서는 프로그램 카운터, 플래그와 다른 몇개의 레지스터를 각 프로세스의 커널 스택(kernel stack)에 저장한다. return-from-trap 명령어는 이 값의 스택을 팝(pop)시켜 사용자 모드 프로그램의 실행을 다시 시작한다.

trap이 운영체제 코드의 어디를 실행할 지는 모른다는 문제가 있다. 호출한 프로세서는 주소를 명시할 수 없다. 왜냐하면 커널 내부의 원하는 지점을 접근할 수 있다는 것이기 때문에 위험하다. 그래서 커널이 임의의 코드를 실행하기 위해서는 접근 권한 검사가 끝난 후에 분기해야 한다.
커널은 부팅 시에 트랩 테이블(trap table)을 만들고 이를 이용하여 시스템을 통제한다. 컴퓨터가 부트될 때는 커널 모드에서 동작하기 때문에 하드웨어를 원하는대로 통제할 수 있다. 하드웨어에게 예외사건이 일어났을 때 운영체제가 어디로 분기할 지 알려준다. 이 역할을 트랩 핸들러(trap handler)가 한다. 하드웨어는 이 정보로 해당 위치를 기억하고 있다. 트랩 테이블은 트랩 핸들러의 주소를 저장하고 있다. 어떠한 예외가 들어 왔을때 어디를 실행하라는 것을 명시하고 있는 것은 트랩 핸들러이다. 그렇기 때문에 이것은 커널에서 이루어져야 하며 하드웨어에 주소를 저장해 둬야 한다.
트랩 테이블은 trap Exeption에 대한 트랩 핸들러들의 주소과 보관된 테이블이다. (운영체제 개발자가 이 handler 함수를 디자인한다.) 트랩 테이블은 부팅시 CPU에 저장된다.

LDE 프로토콜은 두 단계로 진행된다. 부팅과 실행이다. 부팅에서는 항상 커널 모드이고 항상 트랩 테이블을 초기화하고 하드웨어(CPU)는 이 테이블의 위치를 기억해둔다. 그리고 실행 시작할 때는 몇 가지 사전 작업을 한 후 return-from-trap을 이용해 사용자 모드로 전환하여 프로세스를 실행한다. 그러다 프로세스가 시스템 콜을 하면 트랩하여 커널 모드로 전환 돼 시스템 콜의 임무를 수행 한 후 다시 return-from-trap을 통해 프로세스로 돌아간다. 프로세스가 끝나면 return을 하면서 종료를 하는데 이 것은 또 커널 모드로 가야 하기 때문에 trap을 통해 운영체제에서 정리작업을 통해 모든 일을 완료 시킨다.
문제점 2: 프로세스 간 전환
운영체제는 실행 중인 프로세스를 계속 실행할 것인지, 멈추고 다른 프로세스를 실행할 것인지 결정해야 하는데 이것이 쉽지 않다. 이것을 여러 방식을 통해 해결한다.
협조 방식: 시스템 콜 기다리기
협조(cooperative) 방식은 예전의 시스템에서 채택한 방식이었다. 이 방식은 운영체제가 프로세스들이 합리적으로 행동할 것이라는 신뢰에 바탕이 깔려있다. 너무 오래 실행할 가능성이 있는 프로세스는 운영체제가 다른 작업을 실행할 결정을 하도록 주기적으로 CPU를 포기할 것이라고 가정한다.
이런 이상적인 상황에서 프로세스는 시스템 콜을 통해 CPU의 제어권을 운영체제에 넘겨주는데 이때 운영체제가 다른 프로세스를 실행할 수 있게 하는 것이다. 혹은 응용 프로그램이 비정상적인 행위를 하면 운영체제로 제어가 넘어가게 된다. 즉, 트랩이 일어나게 된다. 이러면 운영체제는 프로세스를 종료할 수 있게 된다.
이렇게 협조 방식에서는 시스템 콜을 기다리거나 불법적인 연산이 일어나서 트랩이 걸리기를 기다려야 한다. 되게 “수동적(passive)”인 방법같이 보인다. 그래서 만약 뭔가 잘못돼서 무한루프에 걸려 콜을 호출 할 수 없으면 어떻게 해야할까?
비협조 방식: 운영체제가 전권을 행사
협조 방식에서 무한루프에 걸리게 되면 이를 해결하는 방법을 컴퓨터를 다시 부팅하는 것이었다. 그러면 CPU의 제어권을 무조건 운영체제가 다시 가져올 수 있기 때문이다.
비협조적인 상황에서 CPU의 제어를 가져오기 위한 방법은 이미 컴퓨터 시스템 개발자가 아주 오래전에 발명해두었다. 바로 타이머 인터럽트(timer interrupt)를 사용하는 것이다. 이 타이머는 수 밀리 초마다 인터럽트를 발생시키고 인터럽트가 발생하면 현재 수행 중인 프로세스는 중단되고 미리 구성된 운영체제의 인터럽트 핸들러(interrupt handler)가 실행이 된다. 이 과정을 통해 운영체제는 CPU를 주기적으로 계속 얻을 수 있게 된다.
인터럽트 발생 시 시스템 콜이 호츨되었을 때 하드웨어가 하는 동작처럼 레지스터가 커널 스택에 저장되고 return-from-trap 명렬어를 통해 복원되게 해야한다.
문맥의 저장과 복원
둘 중 어느 방식이던간에 운영체제가 제어권을 다시 획득하면 중요한 결정을 내려야 한다. 현재 실행중인 프로세스를 계속 실행할 것인가?와 다른 프로세스로 전환할 것인가?이다. 이 결정을 스케줄러(scheduler)를 통해 하게된다.
만약 다른 프로세스로 전환하기로 결정되면 운영체제는 문맥 교환(context switch)를 하게된다. 문맥 교환이란 운영체제가 해야하는 작업은 현재 실행 중인 프로세스의 레지스터 값을 커널 스택 같은 곳에 저장하고 곧 실행될 프로세스의 커널 스택으로부터 레지스터 값을 복원하는 것이 전부이다.

손을 커널 스택이라 생각하면 되고 닭다리가 프로세스라고 생각하면 안먹으면 손에 저장하고 먹으면 실행한다고 생각하면 된다. 이렇게 레지스터 값을 저장해 두어 다른 프로세스로 리턴하여 실행을 다시 할 수 있게 된다.

이 예시에서 보면 프로세스 A가 실행중이다가 타이머 인터럽트에 의해 중단된다. 하드웨어는 A의 레지스터를 커널 스택에 저장하고 커널 모드에 진입한다. 그리고 A의 레지스터를 A의 proc 구조에 저장하고 proc 구조에 저장되어 있던 B의 레지스터를 복원한 후 B의 커널 스택을 B의 레지스터로 저장하게 된다. 그리고 프로세스 B로 바뀌게 된다. 만약 나중에 시간이 지나면 또 타이머 인터럽트에 걸려 이 과정을 반복할 것이다.
이 코드는 xv6의 문맥 교환 코드이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#void swtch(struct context **old, struct context *new);
#
#Save current register context in old
#and then load register context from new.
.globl swtch
swtch:
#Save old registers
movl 4(%esp), %eax # put old ptr into eax
popl 0(%eax) # save the old IP
movl %esp, 4(%eax) # and stack
movl %ebx, 8(%eax) # and other registers
movl %ecx, 12(%eax)
movl %edx, 16(%eax)
movl %esi, 20(%eax)
movl %edi, 24(%eax)
movl %ebp, 28(%eax)
#Load new registers
movl 4(%esp), %eax # put new ptr into eax
movl 28(%eax), %ebp # restore other registers
movl 24(%eax), %edi
movl 20(%eax), %esi
movl 16(%eax), %edx
movl 12(%eax), %ecx
movl 8(%eax), %ebx
movl 4(%eax), %esp # stack is switched here
pushl 0(%eax) # return addr put in place
ret # finally return into new ctxt
병행성이 걱정
만약 시스템 콜을 처리하는 도중에 타이머 인터럽트가 발생하면 어떻게 될까? 나중에 병행성에 대해 배울 때 알려주겠지만 일단 이것을 해결하는 간단한 해법은 인터럽트를 처리하는 동안 인터럽트를 불능화시키는 것이다. 그리고 내부 자료 구조에 동시에 접근하는 것을 방지하는 락(lock) 기법이란 것이 있다. 이런 것이 버그를 만들기도 한다.