안녕하세요. IT 엘도라도 에 오신 것을 환영합니다.
글을 쓰는 것은 귀찮지만 다시 찾아보는 것은 더 귀찮습니다.
완전한 나만의 것으로 만들기 위해 지식을 차곡차곡 저장해 보아요.   포스팅 둘러보기 ▼

컴퓨터 구조 (Architecture)/CSAPP

[CSAPP] Exceptional Control Flow

피그브라더 2020. 3. 24. 17:18

1. Introduction

1-1. 예외적인 제어 흐름 (Exceptional Control Flow, ECF)

프로세서에 전력이 공급된 시점부터 전력 공급이 끊기는 시점까지, 프로그램 카운터(PC)는 다음과 같은 값들을 갖는다. a_k는 명령어 I_k의 시작 주소를 의미한다. 이와 같이 실행하는 명령어에 따라 PC의 값이 변해가는 흐름을 제어 흐름(Control Transfer)이라고 부른다.

 

 

제어 흐름의 양상은 크게 세 종류이다. 첫 번째는 메모리에 연속적으로 할당되어 있는 명령어들을 순차적으로 실행하는 경우이다. 이는 가장 기본적이면서 대부분의 시간을 차지하는 제어 흐름에 해당한다. 두 번째는 프로그램 변수로 표현되는 프로그램 상태의 변화에 반응하여 제어 흐름이 갑자기 바뀌는 경우이다. 대표적으로 jump, call, return 등의 명령어를 수행하는 경우가 이에 해당한다. 마지막 세 번째가 바로 이번 포스팅의 주제인 예외적인 제어 흐름(Exceptional Control Flow, ECF)이다. 이는 시스템 상태의 변화에 반응하여(프로그램의 실행과 관련이 있을 수도 있고 없을 수도 있음) 제어 흐름이 갑자기 바뀌는 경우를 가리킨다.


1-2. ECF의 종류

ECF는 컴퓨터 시스템의 모든 수준(Level)에서 나타난다. 하드웨어, 운영체제, 응용 프로그램 수준에서 나타날 수 있는 ECF를 정리하면 다음 표와 같다. 다만 이는 엄밀한 구분은 아니기 때문에, 앞으로 등장할 각 종류의 ECF의 개념을 제대로 이해하는 것이 훨씬 더 중요하다는 것을 기억하자.

 

Level 설명
하드웨어 (Hardware) 하드웨어 수준에서 특정 이벤트의 발생이 감지되면 그 이벤트에 해당하는 예외 핸들러로 제어가 이동된다.
운영체제 (Operating System) 커널은 문맥 전환(Context Switch)을 통해 한 유저 프로세스가 가지고 있던 제어를 또 다른 프로세스에게 넘겨줄 수 있다.
응용 프로그램 (Application) 응용 프로그램들은 시스템 콜(또는 트랩)을 통해 운영체제에 진입하여 특정 서비스를 요청할 수 있으며, 한 프로세스는 다른 프로세스에게 시그널(Signal)을 전송하여 제어가 수신자 측의 시그널 핸들러로 넘어가도록 할 수도 있다. 또한, 개별 프로그램은 일반적인 호출/리턴 스택 규칙을 깨고 다른 함수 내 임의의 주소로 비지역 점프(Nonlocal Jump)를 수행함으로써 특정 예외 상황에 반응할 수도 있다.

1-3. ECF를 공부해야 하는 이유

첫째, 중요한 시스템 개념을 이해하기 위해서이다. ECF는 운영체제가 입출력, 프로세스, 그리고 가상 메모리를 구현하기 위한 기본적인 메커니즘이다. 이와 같은 시스템 개념들을 이해하려면 ECF를 먼저 이해해야 한다.

 

둘째, 응용 프로그램이 운영체제에게 서비스를 요청하는 방식을 이해할 수 있다. 응용 프로그램은 ECF의 한 종류인 시스템 콜(또는 트랩)을 이용하여 운영체제에게 특정 서비스를 요청한다. 예를 들어, 응용 프로그램은 시스템 콜을 통해 운영체제에게 디스크에 데이터를 쓰거나, 네트워크로부터 데이터를 읽거나, 새로운 프로세스를 만들거나, 현재 프로세스를 종료시키기 위한 서비스를 요청할 수 있다.

 

셋째, 흥미로운 응용 프로그램을 작성할 수 있기 때문이다. 운영체제는 응용 프로그램에게 여러 가지 동작을 수행할 수 있는 강력한 ECF 메커니즘을 제공한다. 예를 들어, 앞서 말했듯이 새로운 프로세스를 만들거나 특정 프로세스들이 종료되기를 기다릴 수 있고, 더불어서 다른 프로세스들에게 특정 이벤트의 발생을 알리거나 이와 같은 이벤트들을 감지하여 특정 방식으로 반응할 수도 있다. 만약 ECF를 이해한다면 이러한 기능들을 활용하여 유닉스 쉘이나 웹 서버와 같은 흥미로운 프로그램들을 작성할 수 있다.

 

넷째, 동시성(Concurrency)을 이해하기 위해서이다. 컴퓨터 시스템의 동시성은 ECF를 기반으로 구현된다. 동시성의 대표적인 예시로는 응용 프로그램의 실행을 간섭하는 예외 핸들러나 시그널 핸들러, 그리고 겹치는 시간대에 동시에 실행되는 프로세스나 쓰레드 등이 있다. 이와 같은 동시성의 개념을 이해하기 위해서는 ECF를 먼저 알아야 한다.

 

마지막으로, 소프트웨어 수준의 예외가 동작하는 원리를 이해할 수 있다. C++이나 자바와 같은 언어들은 소프트웨어 수준에서 예외를 처리하기 위한 메커니즘(try, catch, throw 등)을 제공한다. 이러한 소프트웨어 수준의 예외는 프로그램으로 하여금 비지역 점프(Nonlocal Jump)를 수행하도록 함으로써 구현된다. 비지역 점프는 응용 프로그램 수준의 ECF에 해당하며, C 언어에서는 setjmplongjmp 함수를 통해 비지역 점프 메커니즘을 지원한다. 이러한 Low-level 함수들을 이해하면 소프트웨어 수준의 High-level 예외를 이해할 수 있게 된다.

 

2. Exception

2-1. 예외 (Exception)

예외(Exception)란 프로세서 상태의 변화에 대한 반응으로 나타나는 제어 흐름의 갑작스러운 변화를 뜻하며, 하드웨어와 운영체제의 협력에 의해 구현되는 낮은 수준의 ECF이다. 하드웨어의 구현 방식에 영향을 받기 때문에 예외와 관련한 상세 내용은 시스템마다 다를 수밖에 없다. 따라서 여기서는 보편적인 예외의 개념과 예외 처리 방식에 대해 설명하도록 한다.

 

다음은 보편적인 예외 발생 및 처리 양상을 보여준다. 프로세서는 I_curr 명령어를 실행하던 도중 특정 프로세서의 상태가 변화했음을 감지하게 된다. 이러한 프로세서 상태의 변화를 이벤트(Event)라고 부른다. 프로세서 상태는 내부적으로 여러 비트로 인코딩 되어 있으며, 프로세서에게 신호를 전달함으로써 이벤트 발생을 알리게 된다. 이때, 이벤트는 I_curr 명령어의 실행과 유관할 수도 있고 무관할 수도 있다. 예를 들어, I_curr이 0으로 나누기 연산을 수행하는 명령어이기 때문에 발생한 이벤트일 수도 있고, 타이머 장치가 인터럽트 신호를 보내거나 입출력 요청 작업이 완료되어 발생한 이벤트일 수도 있다.

 

 

프로세서는 특정 이벤트의 발생을 감지하는 순간, 예외 테이블(Exception Table)이라고 불리는 점프 테이블을 참조하여 해당되는 예외 핸들러(Exception Handler)로의 간접 프로시저 호출(Indirect Procedure Call)을 수행한다. 그렇게 호출된 예외 핸들러가 처리를 마치면 다음 세 가지 동작 중 하나를 수행하게 된다. 어떤 동작을 수행할지는 예외의 종류에 따라 결정된다.

  • I_curr로 리턴.
  • I_next로 리턴.
  • abort 루틴으로 리턴하여 해당 프로그램을 종료

2-2. 예외 처리 (Exception Handling)

한 시스템 내에서 발생할 수 있는 모든 예외는 자신만의 예외 번호(Exception Number)를 가진다. 예외 번호는 음이 아닌 정수로서, 모든 예외는 고유한 예외 번호를 가진다는 것이 보장된다. 어떤 예외 번호는 프로세서를 설계하는 사람들에 의해 부여되며, 또 어떤 예외 번호는 커널(메모리에 상주하는 운영체제 코드)을 설계하는 사람들에 의해 부여된다. 전자에 해당하는 예외로는 0으로 나누기 연산(Divide by Zero), 페이지 폴트(Page Fault) 등이 있으며, 후자에 해당하는 예외로는 시스템 콜(System Call), 외부 입출력 장치에 의한 인터럽트(Interrupt) 등이 있다.

 

컴퓨터에 파워가 공급되어 시스템이 부팅되는 순간, 운영체제는 예외 테이블(Exception Table)을 메모리에 할당 및 초기화한다. 여기에는 각 예외에 해당하는 예외 핸들러의 주소들이 저장이 된다. 다음은 예외 테이블의 생김새를 보여준다.

 

 

이후 프로세서는 런타임 시에 특정 이벤트의 발생을 감지하면 해당 이벤트의 예외 번호 k를 결정한다. 그리고 프로세서는 예외 테이블의 k번째 엔트리를 참조함으로써 해당되는 예외 핸들러를 간접적으로 호출하게 된다. 다음 그림은 프로세서가 예외 테이블의 k번째 엔트리에 접근하기 위한 주소를 계산하는 방식을 보여준다. 참고로 예외 테이블의 시작 주소는 예외 테이블 베이스 레지스터(Exception Table Base Register)라고 불리는 특별한 종류의 CPU 레지스터에 저장이 되어 있다.

 

 

이쯤에서 일반적인 함수의 호출과 예외 핸들러의 호출의 차이를 짚고 넘어가 보자. 언뜻 보기에는 예외를 처리하기 위해 예외 핸들러를 호출하는 것이 일반적인 함수 호출과 그리 달라 보이지 않는다. 그러나 이 둘은 다음과 같이 중요한 몇 가지의 차이점을 가지고 있다. 예외 처리를 제대로 이해하기 위해선 이 둘의 차이를 이해하는 것이 매우 중요하다.

  • 스택에 푸시하는 복귀 주소가 현재 명령어(I_curr)의 주소일 수도, 다음 명령어(I_next)의 주소일 수도 있다.
  • 복귀 주소뿐만 아니라 다른 추가적인 프로세서 상태 정보도 스택에 푸시한다. 이는 예외 핸들러가 리턴할 때 모든 프로세서 상태 정보를 원래대로 되돌려 놓기 위함이다. 실제로, x86-64 CPU는 예외 핸들러 호출 시 컨디션 코드 등의 프로세서 상태 정보를 저장하고 있는 EFLAGS라는 레지스터의 값을 스택에 푸시한다. 이렇게 하면 예외 핸들러가 리턴하고 돌아왔을 때 원래 프로그램은 아무 일이 없었던 것처럼 실행을 재개할 수 있다.
  • 커널 모드로 실행된다. 즉, 일반적인 함수와 달리 예외 핸들러는 시스템 자원들에 대한 접근 권한을 갖는다. 따라서 위에서 언급했던 데이터들도 유저 스택이 아닌 커널 스택에 푸시가 된다.

일단 하드웨어가 예외 발생을 감지하여 예외 핸들러의 호출까지 마치고 나면, 나머지 작업은 소프트웨어(예외 핸들러의 처리)에게 맡긴다. 그리고 처리를 끝낸 예외 핸들러는 예외 종류에 따라 원래 프로그램의 실행 흐름으로 돌아가거나 abort 루틴으로 리턴하여 해당 프로그램을 종료시킨다. 만약 돌아가야 하는 상황이라면, "Return from Interrupt"라는 특별한 명령어를 실행하여 원래 프로그램의 실행 흐름으로 돌아가도록 한다. 이 명령어는 스택에 푸시되어 있는 복귀 주소와 각종 프로세서 상태 정보를 팝 하여 원래대로 되돌려 놓고, (유저 모드로 돌아가는 경우라면) 현재 프로세서의 상태를 유저 모드로 바꿔주는 역할을 수행한다.


2-3. 예외 종류 (Exception Classes)

예외는 다음과 같이 크게 네 종류로 구분할 수 있다. 이때 각각에 대하여 통용되는 용어까지 명시했다. 즉, 보통 Interrupt와 Trap(System Call)은 이름 그대로 부르고 나머지를 묶어서 예외라고 부르는 경향이 있다. 물론 용어에 대한 정의가 책마다 조금씩 다르고 통용된다의 기준도 필자의 주관이기 때문에 용어에 너무 집착하진 말자. 중요한 것은 예외 상황을 개념적으로 구분할 줄 아는 것이다.

 


2-4. ① Interrupt

프로세서 외부의 입출력 장치들로부터 전달받는 신호에 의해 발생하는 예외로, 명령어의 실행 결과로 발생하는 예외가 아니기 때문에 비동기적 예외(Asynchronous Exception)에 해당한다. 인터럽트에 해당하는 예외 핸들러는 인터럽트 핸들러(Interrupt Handler)라고 부르며, 인터럽트 핸들러는 처리가 끝나면 다음 명령어(I_next)로 리턴한다.

 

외부 입출력 장치가 인터럽트를 발생시키는 방법은 간단하다. 프로세서의 인터럽트 핀에 신호를 전달하고 시스템 버스에 예외 번호를 실어 보내는 것이다. 그러면 프로세서는 현재 명령어의 실행이 끝난 직후 인터럽트 핀을 보고 인터럽트 발생을 감지할 수 있다. 이후 프로세서는 시스템 버스에서 읽어 들이는 예외 번호를 사용하여 적절한 인터럽트 핸들러를 호출하게 된다. 


2-5. ② Trap (System Call)

특정 명령어를 실행하여 의도적으로 발생시키는 예외로, 명령어의 실행 결과로 발생하는 예외이므로 동기적 예외(Synchronous Exception)에 해당한다. 트랩에 해당하는 예외 핸들러는 트랩 핸들러(Trap Handler)라고 부르며, 트랩 핸들러도 처리가 끝나면 인터럽트 핸들러와 마찬가지로 다음 명령어(I_next)로 리턴한다.

 

트랩의 가장 중요한 용도는 바로 일반적인 함수와 유사한 인터페이스로 커널의 서비스를 요청하는 것이다. 이를 시스템 콜(System Call)이라고 부른다. 커널이 제공하는 서비스의 대표적인 예시로는 파일 읽기(read), 프로세스 만들기(fork), 새로운 프로그램 로드하기(execve), 현재 프로세스 종료시키기(exit) 등이 있다. 프로세서는 유저 프로그램이 시스템 콜을 통해 이러한 서비스들을 요청할 수 있도록 syscall n 명령어를 제공한다. (실제로 C 언어의 read, write, execve, _exit 등의 함수는 내부적으로 syscall n 명령어에 의해 구현이 된다.)  명령어를 실행하여 호출되는 예외 핸들러는 레지스터로 전달되는 인자들의 값을 적절히 해석한 뒤, 요청된 커널 루틴을 호출하게 된다. 여기서 인자로 전달되는 시스템 콜의 번호(n)는 요청하려는 시스템 콜의 고유 번호이다. 이는 커널에 존재하는 시스템 콜 테이블에서 요청된 커널 루틴의 주소를 저장하는 엔트리의 인덱스에 해당한다. 참고로 시스템 콜 테이블과 예외 테이블은 다른 것이므로 혼동하지 말도록 하자.


2-6. ③ Fault

특정 명령어의 실행 결과로 초래된 (회복 가능한) 에러에 의해 발생하는 예외로, 명령어의 실행 결과로 발생하는 예외이므로 동기적 예외(Synchronous Exception)에 해당한다. 폴트에 해당하는 예외 핸들러는 폴트 핸들러(Fault Handler)라고 부르며, 폴트 핸들러는 에러를 고치는 것에 성공하면 현재 명령어(I_curr, Faulting 명령어)로 리턴하고 에러를 고칠 수 없으면 커널에 존재하는 abort 루틴으로 리턴하여 해당 프로그램을 종료시킨다.

 

Fault의 대표적인 예시는 바로 페이지 폴트(Page Fault)이다. 이는 가상 메모리 파트에서 자세히 다룰 예정이므로 여기선 간단히만 소개하도록 하겠다. 명령어가 메인 메모리에 존재하지 않는 가상 페이지에 접근하려 시도하면 예외가 발생하여 페이지 폴트 핸들러가 호출된다. 핸들러는 디스크에 위치해 있는 요청된 가상 페이지를 메인 메모리에 로드하게 되고, 로드 작업이 완료되면 Faulting 명령어로 복귀한다. 그러면 이제는 접근하고자 하는 가상 페이지가 메인 메모리에 존재하므로 예외가 발생하지 않고, 아무 일도 없었던 것처럼 정상적으로 프로그램 실행이 재개된다.


2-7. ④ Abort

특정 명령어의 실행 결과로 초래된 (회복 불가능한) 에러에 의해 발생하는 예외로, 명령어의 실행 결과로 발생하는 예외이므로 동기적 예외(Synchronous Exception)에 해당한다. 어볼트에 해당하는 예외 핸들러는 어볼트 핸들러(Abort Handler)라고 부르며, 어볼트 핸들러는 다른 핸들러들과 달리 원래 프로그램의 실행 흐름으로 리턴하지 않고 무조건 abort 루틴으로 리턴하여 프로그램을 종료시킨다. Parity 에러와 같이 치명적인 하드웨어 에러들에 의해 발생하는 예외가 여기에 해당한다.


2-8. x86-64 리눅스 예외

다음은 x86-64 리눅스 시스템에서 발생할 수 있는 예외의 대표적인 몇 가지 사례들이다. 

 

정의 주체 예외 번호 설명 구분 핸들러 동작
CPU (x86-64) 0 Divide error Fault Abort ("Floating exceptions")
13 General protection fault Fault Abort ("Segmentation faults")
14 Page fault Fault Restarting the faulting instruction
18 Machine check Abort Abort
운영체제 (리눅스) 32-255 OS-defined exceptions Interrupt or trap -

※ 예외 번호 0 ~ 31 : x86-64 설계자가 정의(부여). 모든 x86-64 시스템에서 동일.
※ 예외 번호 32 ~ 255 : 리눅스 커널 설계자가 정의(부여). 인터럽트 및 트랩에 해당.

 

그리고 다음은 리눅스가 제공하는 시스템 콜의 대표적인 몇 가지 사례들이다. 각 시스템 콜은 고유 번호를 가지고 있으며, 이는 커널에 존재하는 시스템 콜 테이블에서의 인덱스를 나타낸다. 앞서 말했듯 시스템 콜 테이블은 예외 테이블과 다르다는 것에 주의하도록 하자.

 

번호 이름 기능 번호 이름 기능
0 read Read file 33 pause Suspend process until signal arrives
1 write Write file 37 alarm Schedule delivery of alarm signal
2 open Open file 39 getpid Get process ID
3 close Close file 57 fork Create process
4 stat Get info about file 59 execve Execute a program
9 mmap Map memory page to file 60 _exit Terminate process
12 brk Reset the top of the heap 61 wait4 Wait for a process to terminate
32 dup2 Copy file descriptor 62 kill Send signal to a process

 

C 프로그램은 syscall 함수를 직접 실행하여 시스템 콜을 수행할 수도 있다. 그러나 보통은 C 표준 라이브러리가 제공하는 Wrapper 함수들을 사용하여 시스템 콜을 수행하게 된다. Wrapper 함수들은 전달되는 인자들을 적절히 해석하고, 알맞은 시스템 콜 명령어를 실행하도록 하며, 시스템 콜의 수행 상태 정보를 호출부에게 반환 값으로 전달해준다. 참고로, 이와 같은 시스템 콜 및 그것의 Wrapper 함수들을 통틀어서 시스템 수준 함수(System-level Function)라고 부르도록 할 것이니 기억해두길 바란다.

 

아래의 두 그림은 각각 두 종류의 시스템 콜(write, _exit)을 수행하는 C 언어 프로그램과 그것의 컴파일 결과를 보여준다. 참고로 syscall 명령어를 사용하려면 먼저 %rax에 해당 시스템 콜 번호를 저장해야 하며, 이후의 인자들은 %rdi, %rsi, %rdx, %r10, %r8, %r9에 차례대로 저장해야 한다. 그리고 해당 시스템 콜의 반환 값은 %rax에 저장이 될 것이다.

 

 

3. Process

3-1. 기본

프로세스(Process)란 실행 중인 프로그램의 한 인스턴스를 의미하며, 각각의 프로세스는 특정 문맥에서 실행된다. 문맥(Context)이란 프로그램이 올바르게 실행되기 위해 필요한 상태 정보들의 집합이다. 예를 들어 메모리의 스택 및 프로그램 코드/데이터, 범용 레지스터, 프로그램 카운터, 환경 변수, 파일 기술자 등이 이에 해당한다.

 

쉘에서 실행 파일의 이름을 입력하면 쉘은 새로운 프로세스를 하나 생성한 뒤 그 프로세스의 문맥에서 해당 실행 파일을 실행한다. 이처럼 각 응용 프로그램은 새로운 프로세스를 생성한 뒤 그 문맥에서 자신의 코드 혹은 다른 응용 프로그램의 코드를 실행하는 것이 가능하다.

 

프로세스라는 개념에 의해 각 응용 프로그램에게 제공되는 핵심적인 두 가지 추상화는 다음과 같다.

  • 자신이 프로세서를 독차지하고 있는 듯한 착각을 제공하는 하나의 독립적인 논리적 제어 흐름 (Independent Logical Control Flow)
  • 자신이 메모리를 독차지하고 있는 듯한 착각을 제공하는 하나의 사적인 주소 공간 (Private Address Space)

3-2. 논리적 제어 흐름 (Logical Control Flow)

프로세스는 각 프로그램에게 자신이 프로세서를 독차지하고 있다는 착각을 제공한다. 만약 디버깅 프로그램을 사용하여 특정 프로그램의 실행을 따라가 본다면 매 순간의 PC 값은 해당 프로그램의 명령어 또는 동적으로 링킹 된 공유 라이브러리의 명령어에 해당하는 주소일 것이다. 이와 같은 PC 값의 열을 논리적 제어 흐름(Logical Control Flow) 또는 단순히 논리적 흐름(Logical Flow)이라고 한다. 예를 들어 세 개의 프로세스 A, B, C가 하나의 시스템에서 실행된다고 할 때, 각각의 논리적 제어 흐름을 그림으로 나타내면 다음과 같다.

 

 

위 그림을 보면 실제로는 각 프로세스가 프로세서를 돌아가면서 사용하고 있다는 것을 알 수 있다. 다른 프로세스가 프로세서를 사용하고 있을 때 자신은 가만히 기다리는 것이다. 이를 Preempted 또는 Suspended 되었다고 표현한다. 그러나 각 프로세스의 문맥에서 실행되는 프로그램의 입장에서는 자신이 프로세서를 독차지하고 있다는 착각을 하게 된다. 결과적으로는 자신의 메모리 로케이션이나 레지스터의 값들이 전혀 변하지 않은 채로 실행이 재개되기 때문이다.


3-3. 동시적 흐름 (Concurrent Flow)

동시적 흐름(Concurrent Flow)이란 또 다른 논리적 흐름과 실행이 시간적으로 겹치는 논리적 흐름을 말한다. 그리고 그렇게 실행되는 논리적 흐름들은 동시적으로(Concurrently) 실행된다고 표현한다. 예를 들어, 위 예시에서 프로세스 A와 프로세스 B는 동시적으로 실행되지만 프로세스 B와 프로세스 C는 동시적으로 실행되지 않는다.

 

여러 개의 논리적 흐름이 동시적으로 실행되는 현상을 동시성(Concurrency)이라고 부르며, 각 프로세스가 프로세서를 돌아가면서 사용하는 현상은 멀티태스팅(Multitasking)이라고 부른다. 한편 각 프로세스가 자신의 논리적 흐름 내에서 실제로 프로세스를 사용하고 있는 각각의 시간을 타임 슬라이스(Time Slice)라고 부르기 때문에, 멀티태스킹은 타임 슬라이싱(Time Slicing)이라 부르기도 한다.

 

참고로 동시적 흐름의 개념은 CPU의 코어 개수와 무관하다. 코어가 한 개인 CPU에서도 두 개의 논리적 흐름이 겹치기만 하면 동시적 흐름이 나타날 수 있는 것이다. 만약 코어가 여러 개인 CPU에서 서로 다른 코어의 논리적 흐름이 겹친다면, 이는 병렬적 흐름(Parallel Flow)이라고 부른다. 병렬적 흐름은 동시적 흐름의 진부분 집합이며, '실제로 동시에 실행'되는 흐름이라고 볼 수 있다.


3-4. 사적 주소 공간 (Private Address Space)

주소를 n비트로 표현하는 컴퓨터에서 주소 공간(Address Space)이란 2^n개의 주소로 이뤄진 집합(0, 1, 2, . . . , 2^n-1)을 의미한다. 프로세스는 각 프로그램에게 사적 주소 공간(Private Address Space)을 제공함으로써 그 프로그램이 메모리를 독차지하고 있는 듯한 착각을 만들어 낸다. 사적(Private)이라고 표현하는 이유는 해당 공간의 특정 주소에 해당하는 메모리 바이트를 다른 프로세서가 쓰거나 읽을 수 없기 때문이다. 각각의 사적 주소 공간은 내용물이 서로 다르지만, 사적 주소 공간의 구조는 모두 다 동일하다. 예를 들어, x86-64 리눅스 시스템에서는 각 프로세스가 아래와 같은 구조의 사적 주소 공간을 가진다. (참고로 메모리는 하나인데 어떻게 각 프로세스가 아래와 같은 구조의 주소 공간을 똑같이 가질 수 있는지 의문이 들 수 있다. 이와 관련해서는 이후 포스팅에서 다룰 가상 메모리(Virtual Memory) 파트를 참고하길 바란다.)

 

[Figure] x86-64 Linux Private Address Space


3-5. 유저/커널 모드 (User and Kernel Mode)

CPU 특정 컨트롤 레지스터의 모드 비트(Mode Bit)는 현재 프로세스의 특권(Privilege) 유무를 나타낸다. 모드 비트가 세팅되어 있으면 커널 모드(Kernel Mode) 또는 슈퍼바이저 모드(Supervisor Mode)이며, 모드 비트가 세팅되어 있지 않으면 유저 모드(User Mode)이다. 특권 유무를 나타내는 프로세스의 모드는 실행 가능한 명령어의 범위와 접근 가능한 메모리의 범위를 결정한다.

 

커널 모드의 프로세스는 특권(Privilege)을 가지기 때문에 어떤 명령어든지 실행할 수 있고, 시스템 내 모든 메모리 주소 공간에 접근할 수 있다. 반면에 유저 모드의 프로세스는 특권이 없기 때문에 몇 가지 제약을 받는다. 먼저, 유저 모드의 프로세스는 특권이 필요한 몇몇 명령어들을 실행할 수 없다. 예를 들어, 유저 모드의 프로세스가 프로세서를 Halt 시키거나, 모드 비트를 수정하거나, 입출력 연산을 수행하는 명령어를 실행하려고 하면 Fatal Protection Fault 예외가 발생할 것이다. 또한, 유저 모드의 프로세스는 주소 공간 중 커널 영역의 데이터와 코드에 직접 접근할 수 없다. 대신에 시스템 콜 인터페이스를 통해 간접적으로만 커널 영역의 데이터와 코드에 접근할 수 있다.

 

최초에 응용 프로그램이 실행될 때 생성되는 프로세스는 유저 모드이다. 프로세스가 유저 모드에서 커널 모드로 바뀌는 경우는 인터럽트, 폴트, 시스템 콜 등의 예외가 발생할 때이다. 예외가 발생하면 제어가 예외 핸들러로 넘어가면서 프로세서에 의해 모드가 커널 모드로 바뀌고, 응용 프로그램의 코드로 돌아갈 때 프로세서에 의해 모드가 다시 유저 모드로 바뀌게 된다.


3-6. 문맥 전환 (Context Switch)

문맥 전환(Context Switch)은 멀티태스킹(Multitasking)을 구현하는 기본적인 메커니즘으로, 운영체제 커널에 의해 수행이 되는 높은 수준의 ECF에 해당한다. 문맥 전환 메커니즘은 앞에서 다루었던 낮은 수준의 ECF에 해당하는 예외 메커니즘을 기반으로 구현이 된다. 먼저 문맥이라는 것이 정확히 무엇인지 알아보고, 문맥 전환은 언제 어떻게 발생하는지 알아보도록 하자.

 

3-6-1. 문맥 (Context)

커널은 각 프로세스의 문맥을 관리한다. 문맥(Context)은 커널이 잠들어 있는 프로세스를 다시 실행하는 데 필요한 모든 상태 정보를 의미한다. 대표적으로 범용 레지스터, 프로그램 카운터, 상태 레지스터, 유저 스택, 커널 스택, 기타 커널 자료 구조(EX. 페이지 테이블, 프로세스 테이블, 파일 테이블) 등의 정보들이 이에 해당한다.

 

3-6-2. 문맥 전환 (Context Switch)

프로세스 A를 실행하고 있을 때 또 다른 프로세스로 제어가 넘어가야 하는 특정 이벤트가 발생하면 커널의 스케쥴러(Scheduler) 루틴이 호출된다. 스케쥴러는 프로세스 A를 잠들게 하고, 현재 잠들어 있는 다른 프로세스들 중에서 새로 실행할 프로세스 B를 선택한다. 이러한 과정을 스케쥴링(Scheduling)이라고 하며, 프로세스 B가 스케쥴러에 의해 스케쥴 되었다(Scheduled)고 표현한다. 이렇게 새로 실행할 프로세스 B를 선택되고 나면, 스케쥴러는 프로세스 A에서 프로세스 B로의 문맥 전환을 수행하게 된다. 문맥 전환(Context Switch)은 현재 프로세스의 문맥을 저장하고, 새로 실행할 프로세스의 문맥을 복원하며, 제어를 해당 프로세스로 넘겨주게 된다.

 

3-6-3. 문맥 전환이 발생하는 시점

그렇다면 문맥 전환은 언제 발생할까? 먼저, 커널이 시스템 콜을 수행하고 있을 때 발생할 수 있다. 만약 어떤 시스템 콜이 특정 이벤트의 발생을 기다린다면, 이는 스케쥴러를 호출하여 현재 프로세스를 잠들게 하고 다른 프로세스에게 제어를 넘겨주는 문맥 전환을 수행한다. 예를 들어, read 시스템 콜은 디스크에게 요청한 데이터가 도착할 때까지 다른 프로세스로의 문맥 전환을 수행하고, sleep 시스템 콜은 정해진 시간 동안 현재 프로세스를 잠들게 하고 다른 프로세스로의 문맥 전환을 수행한다. 또한, 문맥 전환은 인터럽트의 결과로 발생할 수도 있다. 예를 들어, 대부분의 시스템들은 주기적으로(1ms ~ 10ms 간격) 타이머 인터럽트를 발생시키는 메커니즘을 가지고 있다. 타이머 인터럽트가 발생하면 커널은 현재 프로세스가 너무 오래 실행되었다고 판단하고 새로운 프로세스로의 문맥 전환을 수행한다. 또한, 디스크가 요청된 데이터를 메모리에 로드하는 작업을 완료하고 프로세서에게 인터럽트를 발생시키면 커널은 현재 프로세스를 잠들게 하고 원래 프로세스로 제어를 넘겨주는 문맥 전환을 수행한다.

 

다음 그림은 프로세스 A와 프로세스 B 사이의 문맥 전환 예시를 보여준다. 유저 모드로 실행되고 있던 프로세스 A는 read 시스템 콜을 통해 커널의 트랩 핸들러를 호출한다. 그러면 해당 트랩 핸들러는 디스크 컨트롤러에게 DMA Transfer를 요청하고, 디스크 컨트롤러의 메모리 로드 작업이 끝나면 프로세서에게 인터럽트를 걸 수 있도록 준비시킨다. 그런데 디스크 읽기 작업은 상당히 많은 시간을 소요하기 때문에, 트랩 핸들러는 가만히 기다리지 않고 스케쥴러를 호출하여 프로세스 B로의 문맥 전환을 수행한다. 그렇게 프로세스 B가 실행되다가 디스크 읽기 작업이 완료되어 인터럽트가 발생하면 다시 스케쥴러를 호출하여 프로세스 A로의 문맥 전환을 수행한다.

 

'컴퓨터 구조 (Architecture) > CSAPP' 카테고리의 다른 글

[CSAPP] Virtual Memory  (28) 2020.03.27
[CSAPP] Linking  (13) 2020.03.22
[CSAPP] Cache Memory  (6) 2020.03.22
[CSAPP] Memory Hierarchy  (6) 2020.03.17
[CSAPP] Pipelining - Performance  (0) 2020.03.15