출처
Randal E. Bryant & David R. O'Hallaron. (2015). Computer Systems: A Programmer's Perspective (3rd ed.). Pearson.
강의 노트 (2015) https://www.cs.cmu.edu/afs/cs/academic/class/15213-f15/www/schedule.html
프로그램을 시작한 순간부터 프로그램을 종료할 때까지 프로그램 카운터는 instruction ik에 대응되는 주소 시퀀스 a0, a1, ..., an-1 를 다룬다. ak에서 ak+1로의 이동을 제어 이동 control transfer이라고 한다. 이러한 제어 이동의 시퀀스를 프로세서의 제어 흐름 flow of control이라고 한다.
제어 흐름 중 가장 간단한 유형은 ik와 ik+1이 메모리에서 인접해 있는 부드러운 시퀀스이다. ik+1이 ik와 인접하지 않는 상황은 일반적으로 jump, call, return과 같은 프로그램 명령에 의해 유발된다. 이러한 명령들은 프로그램이 프로그램 변수로 표현되는 프로그램 내부 상태 변화 changes in program state에 반응할 수 있게 해주는 필수적인 메커니즘이다.
하지만 시스템은 프로그램 변수에 의해 포착되지 않는 시스템 상태에서의 변화 changes in system state에도 반응할 수 있어야 한다. 시스템은 프로그램의 실행과 관계 없이 동작하기도 한다. 예를 들면 다음과 같다.
- 하드웨어 타이머가 일정한 간격으로 타임아웃됨
- 패킷이 네트워크 어댑터에 도착하고 메모리에 저장됨
- 디스크에 데이터를 요청한 프로세스는 데이터가 준비되었다는 알림을 받을 때까지 sleep 상태에 빠짐
- 자식 프로세스를 생성한 부모 프로세스는 자식 프로세스가 종료되면 알림을 받음
- 사용자가 키보드에 Ctrl-C를 입력하면 프로세스가 종료됨
- 어떤 명령이 Divide by zero을 시도하면 프로세스가 종료됨
현대 시스템은 이러한 시스템 상태 변화에 반응하기 위해 제어 흐름에 갑작스러운 변화, 즉 예외적인 제어 흐름 exceptional control flow (ECF)를 만든다. ECF는 컴퓨터 시스템의 모든 레벨에서 발생한다.
- 하드웨어 레벨에서는 하드웨어에 의해 감지된 이벤트가 이벤트 핸들러로 갑작스럽게 제어를 이동시킨다.
- 운영체제 레벨에서는 컨텍스트 스위칭을 통해 커널이 하나의 사용자 프로세스에서 다른 사용자 프로세스로 제어를 이동시킨다.
- 애플리케이션 레벨에서는 프로세스가 수신 프로세스 쪽의 시그널 핸들러로 갑작스럽게 제어를 이동시키는 시그널을 다른 프로세스에게 보낼 수 있다. 프로그램은 에러에 반응할 때 평상시의 스택 규칙을 회피하고 다른 함수 안의 임의의 위치로 nonlocal jump를 만들 수 있다.
개발자로서 ECF를 이해해야 하는 이유는 다음과 같다.
- ECF를 이해하면 중요한 시스템 개념을 이해하는 데 도움이 된다. ECF는 운영체제가 입출력, 프로세스, 가상 메모리를 구현하는 데 사용하는 기본 메커니즘이다.
- ECF를 이해하면 애플리케이션이 운영체제와 어떻게 상호작용하는지 이해하는 데 도움이 된다. 애플리케이션은 운영체제에 서비스를 요청하기 위해 트랩이나 시스템 콜로 알려진 ECF를 사용한다. 예를 들어, 디스크에 데이터를 쓸 때, 네트워크로부터 데이터를 읽을 때, 새로운 프로세스를 생성할 때, 현재 프로세스를 종료할 때 시스템 콜을 호출한다.
- ECF를 이해하면 흥미로운 애플리케이션 프로그램을 만드는 데 도움이 된다. 운영체제는 새로운 프로세스를 생성하고, 프로세스가 종료되길 기다리고, 다르 프로세스에게 시스템에서 발생한 예외적인 이벤트를 알리고, 이러한 이벤트들을 감지하고 반응하는 강력한 ECF 메커니즘을 애플리케이션 프로그램에게 제공한다. ECF 메커니즘을 이해하면 Unix Shell이나 웹 서버와 같은 흥미로운 프로그램을 만들 수 있다.
- ECF를 이해하면 동시성을 이해하는 데 도움이 된다. ECF는 컴퓨터 시스템에서 동시성을 구현하기 위한 기본 메커니즘이다. 다음은 동시성 작동의 예시이다: 애플리케이션의 실행을 중단시키는 예외 처리기, 동시간에 실행되는 프로세스와 스레드, 프로그램의 실행을 중단시키는 시그널 핸들러.
- ECF를 이해하면 소프트웨어 예외들이 작동하는 방식을 이해하는 데 도움이 된다. C++이나 자바와 같은 언어는 try, catch, throw 문을 통한 소프트웨어 예외 메커니즘을 제공한다. 소프트웨어 예외는 프로그램이 nonlocal jump를 만들 수 있게 해준다. (즉, 일반적인 call/return 스택 규칙을 위반하는 jump이다.) nonlocal jump는 애플리케이션 레벨의 ECF이며 C에서 setjmp나 longjmp 함수를 통해 제공된다. 이러한 저수준 함수들을 이해하면 고수준 소프트웨어 예외가 어떻게 구현될 수 있는지 이해하는 데 도움이 될 것이다.
이 장에서는 애플리케이션이 어떻게 운영체제와 상호작용하는지 배울 것이다. 애플리케이션과 운영체제의 상호작용은 ECF와 관련이 깊다.
- 이번 장에서 가장 먼저 다룰 것은 예외이다. 예외는 하드웨어와 운영체제의 교차점에 있다. (운영체제와 하드웨어)
- 그다음엔 시스템 콜을 다룰 것이다. 시스템 콜은 애플리케이션에게 운영체제로의 진입점을 제공한다. (운영체제와 하드웨어 타이머)
- 그다음에 추상화의 레벨로 가서 프로세스와 시그널을 다룰 것이다. 이들은 애플리케이션과 운영체제의 교차점에 있다. (운영체제)
- 마지막으로 nonlocal jump를 다룰 것인데 이는 애플리케이션 레벨의 ECF이다. (C 표준 라이브러리)
1. 예외 Exceptions
예외는 부분적으로는 하드웨어에 의해 또 부분적으로는 운영체제에 의해 구현되는 예외적인 제어 흐름의 한 형태이다. 예외는 부분적으로 하드웨어에 의해 구현되기 때문에 시스템에 따라 구체 사항이 다르다. 하지만 모든 시스템에서 기본 아이디어는 같다.
예외는 프로세서의 상태에서의 어떤 변화에 대한 반응으로 제어 흐름에 갑작스럽게 일어난 변화이다.
위 그림에서 프로세서가 명령 Icurr을 실행하고 있을 때 프로세서의 상태에 어떤 중요한 변화, 이벤트가 발생했다. 상태 state는 프로세서 안에 여러 비트와 시그널로 인코딩된다.
이벤트는 현재 명령의 실행과 직접적으로 관련이 있을 수도 있다. 이러한 이벤트의 예는 다음과 같다.
- 가상 메모리 페이지 폴트가 발생하거나,
- 계산 오버플로우가 발생하거나
- 어떤 명령이 0으로 나누기를 시도할 때
반대로 이벤트가 현재 명령의 실행과 관련이 없을 수도 있다. 이러한 이벤트의 예는 다음과 같다.
- 시스템 타이머가 작동하거나
- I/O 요청이 완료됐을 때
어떤 경우이든지, 프로세서가 이벤트가 발생했다는 것을 인지했을 때 프로세서는 exception table이라는 jump table을 통해 운영체제 서브루틴 (예외 처리기 exception handler)에게 간접적인 프로시저 호출(예외)을 만든다. exception handler는 특정 종류의 이벤트를 처리하도록 특별히 설계된 것이다. exception handler가 예외 처리를 끝내면 예외를 불러일으킨 이벤트의 유형에 따라 다음 세 가지 중 한 가지 일이 일어난다.
1. 핸들러가 현재 명령 Icurr에게 제어를 돌려놓는다. Icurr은 이벤트가 발생했을 때 실행 중이던 명령이다.
2. 핸들러가 예외가 발생하지 않았다면 실행되었을 명령 Inext에게 제어를 돌려놓는다.
3. 핸들러가 중단된 프로그램을 중지시킨다.
(1) 예외 처리 Exception handling
예외 처리는 하드웨어와 소프트웨어가 긴밀하게 협력하여 이루어지기 때문에 예외는 이해하기 어려울 수 있다. 어떤 컴포넌트가 어떤 업무를 수행하는지 헷갈리기 쉽다. (%rip 변경은 하드웨어가 하고, 예외의 결과로 실행되는 코드는 운영체제 커널이 결정한다.)
시스템에서 발생할 수 있는 예외의 모든 유형은 고유한 비음수 정수인 exception number를 배정받는다. 어떤 숫자는 프로세서의 설계자에 의해 배정된다. 다른 숫자들은 운영체제 커널(메모리에 있는 운영체제의 부분이다.)의 설계자에 의해 배정된다. 전자의 예시는 divide by zero, 페이지 폴트, 메모리 접근 위반, 중단점 breakpoints, 계산 오버플로우이다. 후자의 예시는 시스템 콜과 외부 입출력 장치로부터 온 시그널이다.
시스템 부트 타임 (컴퓨터가 초기화되거나 전원이 켜졌을 때)에 운영체제는 exception table이라는 jump table을 할당하고 초기화한다. 이는 exception table의 항목 k가 exception k를 위한 핸들러의 주소를 보유할 수 있게 하기 위함이다.
다음은 exception table의 형식이다.
런타임 (시스템이 어떤 프로그램을 실행하고 있을 때)에 프로세서는 이벤트가 발생했음을 감지하고 이에 해당하는 exception number k를 정한다. 그다음 프로세서는 exception table의 항목 k를 통해 해당하는 핸들러에게 indirect 프로시저 호출을 함으로써 예외를 유발한다.
다음은 프로시저가 적절한 exception handler의 주소를 만들기 위해 exception table을 어떻게 이용하는지를 보여준다.
exception number는 exception table의 인덱스이다. exception table의 주소는 exception table base register라는 특별한 CPU 레지스터에 저장되어 있다.
예외는 프로시저 콜과 유사하지만 몇 가지 다른 점들도 있다.
- 프로시저 콜에서 그런 것처럼 예외도 프로세스가 예외 처리로 넘어가기 전에 스택에 return address를 push해둔다. 하지만 exception의 유형에 따라 return address는 현재 명령일 수도 있고 다음 명령일 수도 있다.
- 프로세서는 핸들러가 return했을 때 중단되었던 프로그램이 재시작하는 데 필요할 수도 있는 추가적인 프로세서 상태도 push한다. 예를 들어 x86-64 시스템은 현재 condition code를 저장하고 있는 EFLAGS 레지스터를 스택에 push한다.
- 사용자 프로그램에서 커널로 제어가 이동할 때 모든 것은 사용자의 스택이 아니라 커널의 스택에 push된다.
- exception handler는 커널 모드에서 실행된다. 즉, exception handler는 모든 시스템 리소스에 완전한 접근 권한을 가진다.
하드웨어가 예외를 유발하고 나면 나머지 모든 일들은 exception handler에 의해 소프트웨어에서 이루어진다. 핸들러가 이벤트를 처리하고 나면 핸들러는 선택적으로 "return from interrupt"라는 특별한 명령을 실행함으로써 중단되었던 프로그램으로 리턴한다. "return from interrupt" 명령은 exception이 사용자 프로그램을 중단시켰다면 프로세서의 제어와 데이터 레지스터에 적절한 state들을 pop해서 되돌려놓고 중단된 프로그램으로 제어를 return시킨다.
(2) 예외의 유형
예외는 4가지 유형으로 나눠질 수 있다. interrupt, trap, fault, abort이다.
Interrupt
Interrupt는 프로세서 외부에 있는 입출력 장치로부터 온 시그널의 결과로 비동기적으로 발생한다. 하드웨어 인터럽트는 특정 명령의 실행에 의한 것이 아니라는 점에서 비동기적이다. 하드웨어 인터럽트에 대한 exception handler는 interrupt handler라고 불린다.
다음은 interrupt의 처리 과정을 보여준다. 네트워크 어댑터, 디스크 컨트롤러 그리고 timer chips와 같은 입출력 장치는 프로세서 칩의 pin에 시그널을 주고 interrupt를 발생시킨 장치를 알려주는 exception number를 시스템 버스에 보냄으로써 인터럽트를 발생시킨다.
현재 명령의 실행이 끝나고 나서 프로세서는 interrupt pin이 높아졌음을 알아차리고 시스템 버스로부터 온 exception number를 읽어서 적절한 interrupt handler를 호출한다. 핸들러가 return할 때 핸들러는 다음 명령(만약 interrupt가 발생하지 않았다면 실행되었을 현재 명령 다음에 있는 명령이다.)에게 제어를 넘긴다. 이에 따라 프로그램은 interrupt가 발생하지 않은 것처럼 실행을 지속한다.
다른 예외 유형들은 현재 명령을 실행한 결과로 동기적으로 발생한다. 이러한 명령들을 faulting instruction이라고 한다.
Trap과 시스템 콜 (intentional)
Trap은 명령의 실행 결과로 발생하는 의도적인 예외이다. interrupt handler처럼 trap handler도 다음 명령에게 제어를 return한다. 시스템 콜이라는 사용자 프로그램과 커널 사이의 procedure-like 인터페이스는 트랩을 통해 제공된다.
사용자 프로그램은 파일을 읽거나 (read), 새로운 프로세스를 만들거나 (fork), 새로운 프로그램을 로드하고나 (execve), 현재 프로세스를 종료시키는 (exit) 서비스를 커널에게 요청해야할 때가 있다. 이러한 커널 서비스에 통제된 상태로 접근할 수 있도록 프로세서는 사용자 프로그램이 서비스 n을 요청하고 싶을 때 실행시킬 수 있는 특별한 syscall n 명령을 제공한다. syscall 명령은 exception handler로의 trap을 유발한다. exception handler는 인자를 해독해서 적절한 커널 루틴을 호출한다.
다음은 시스템 콜이 처리되는 모습을 보여준다.
개발자의 관점에서 시스템 콜은 일반적인 함수 호출과 비슷하다. 그러나 둘의 구현 내용은 상당히 다르다. 일반적인 함수 호출은 유저 모드에서 실행된다. 유저 모드는 함수가 실행시킬 수 있는 명령의 유형이 제한한다. 그리고 일반적인 함수는 자신을 호출한 함수와 같은 스택에 접근한다. 반면에 시스템 콜은 커널 모드에서 실행된다. 커널 모드는 시스템 콜이 privileged 명령들을 실행할 수 있게 허용한다. 그리고 시스템 콜은 커널에 정의된 스택에 접근한다.
Fault (unintentional)
Fault는 핸들러가 고쳐줄 수도 있는 error condition로 인해 발생한다. fault가 발생했을 때 프로세서는 제어를 fault handler에게 넘긴다. 핸들러가 error condition을 고치는 것이 가능하다면 fault를 발생시킨 명령으로 return해서 그것을 재실행시킨다. 그렇지 않다면 핸들러는 커널에서 abort 루틴으로 return한다. abort 루틴은 fault를 발생시킨 애플리케이션 프로그램을 종료시킨다.
다음은 fault가 처리되는 모습을 보여준다.
fault의 전형적인 예는 page fault exception이다. page fault exception은 명령이 참조하는 가상 주소가 가리키는 페이지가 메인 메모리에 있지 않아서 디스크로부터 가져와야 할 때 발생한다. 페이지는 (일반적으로 4KB) 가상 메모리의 연속적인 블록이다. page fault handler는 디스크로부터 적절한 페이지를 로드하고 fault를 발생시킨 명령으로 제어를 return한다. 명령이 다시 실행될 때, 이제 메인 메모리에 적절한 페이지가 있게 되고 명령은 faulting 없이 무사히 실행될 수 있게 된다.
Abort (unintentional)
Abort는 회복될 수 없는 치명적인 에러에 의해 발생한다. 일반적으로 DRAM이나 SRAM의 비트가 오염됐을 때 발생하는 패리티 에러와 같은 하드웨어 에러에 의해 발생한다. Abort handler는 애플리케이션 프로그램으로 절대 제어를 return하지 않는다. 다음 그림에서처럼 handler는 abort routine으로 제어를 return한다. abort routine은 애플리케이션 프로그램을 종료시킨다.
(3) Linux/x86-64에서의 예외
x86-64 시스테메 정의된 예외들을 살펴보자. 여기에는 256개의 예외 유형들이 있다. 0~31은 인텔 아키텍처에 의해 정의된 예외들로 모든 x86-64 시스템에서 동일하다. 32~255는 운영체제의 의해 정의된 interrupt와 trap이다.
Linux /x85-64 Faults와 Aborts
Divide error
divide error (예외 0번)은 애플리케이션이 0으로 나누려고 시도할 때 또는 나누기 명령의 결과가 피연산자보다 지나치게 클 때 발생한다. Unix는 divide error를 복구하려고 시도하지 않고 프로그램을 종료시킨다. Linux shell은 일반적으로 divide error를 "Floating exceptions"이라고 보고한다.
General protection fault
악명높은 protection fault (예외 13번)은 많은 이유로 발생한다. 일반적으로는 프로그램이 가상 메모리에서 정의되지 않은 영역을 참조하거나 프로그램이 읽기 전용 text segment에 write하려고 시도할 때 발생한다. Linux는 protection fault를 복구하려고 시도하지 않는다. Linux shell은 일반적으로 protection fault를 "Segmentation faults"라고 보고한다.
Page fault
Page fault (예외 14번)는 fault를 발생시킨 명령이 재시작되는 대표적인 exception이다. 핸들러는 디스크에 있는 적절한 가상 메모리 페이지를 물리 메모리 페이지에 매핑하고 그다음 fault를 발생시킨 명령을 재시작한다.
Machine check
machine check (예외 18번)은 fault를 발생시키는 명령의 실행 중에 감지된 치명적인 하드웨어 에러의 결과로 발생한다. machine check 핸들러는 애플리케이션 프로그램으로 제어를 절대 return하지 않는다.
Linux/x86-64 시스템 콜 (Trap)
Linux는 애플리케이션 프로그램이 커널에게 서비스를 요청하고 싶을 때 사용하는 시스템 콜을 매우 많이 제공한다.
다음은 대표적인 Linux 시스템 콜의 목록이다.
모든 시스템 콜은 커널에 있는 jump table에서의 오프셋에 해당하는 고유한 정수 숫자를 가진다. 이 jump table은 exception table과는 다르다.
C 프로그램은 syscall 함수를 이용해서 시스템 콜을 직접 호출할 수 있다. 하지만 실제로 시스템 콜을 직접 호출할 일은 잘 없다. C 표준 라이브러리가 대부분의 시스템 콜에 대해서 wrapper 함수를 제공한다. wrapper 함수는 인자들을 잘 포장해서, 적절한 시스템 콜 명령과 함께 커널로 trap되고, 시스템 콜의 return status를 함수를 호출한 프로그램에게 전달한다. 시스템 콜이든 관련 wrapper 함수든 이 책에서는 모두 system-level 함수라고 부르겠다.
시스템 콜은 x86-64 시스템에서 syscall이라고 불리는 trapping 명령을 통해 제공된다. 프로그램이 Linux system call을 직접적으로 호출하기 위해 어떻게 syscall을 사용하는지 배우는 것은 꽤 흥미롭다. Linux 시스템 콜을 위한 모든 인자들은 스택이 아니라 일반 목적 레지스터를 통해 전달된다. 관습적으로 레지스터 %rax가 syscall number를 저장하고, 최대 6개의 인자를 순서대로 %rdi, %rsi, %rdx, %r10, %r8, %r9 레지스터에 저장한다. 시스템 콜로부터 return할 때는 %rcx와 %r11 레지스터는 destroy되고 %rax가 return 값을 저장한다. -4095에서 -1 사이의 음수 return 값은 음수 errno에 해당하는 에러를 나타낸다.
예를 들어, printf 대신에 system-level 함수인 write를 사용하여 작성된 다음과 같은 hello 프로그램이 있다고 해보자.
int main ()
{
write (1, "hello, world\n", 13);
_exit (0);
}
이 프로그램의 어셈블리어 버전은 다음과 같다.
.section .data
string:
.ascii "hello, world\n"
string_end:
.equ len, string_end - string
.section .text
.global main
main:
First, call write (1, "hello, world\n", 13)
movq $1, %rax write is system call 1
movq $1, $rdi Arg1: stdout has descriptor 1
movq $string, %rsi Arg2: hello world string
movq $len, %rdx Arg3: string length
syscall Make the system call
Next, call _exit (0)
movq $60, %rax _exit is system call 60
movq $0, %rdi Arg1: exit status is 0
syscall Make the system call
syscall number를 %rax에 저장하고 인자들을 %rdi, %rsi, %rdx에 저장한 후에 syscall 명령을 호출한다.
'Computer System > CSAPP' 카테고리의 다른 글
[CSAPP] 8장 예외적인 제어 플로우 (3/4) Process Control (1) | 2025.01.22 |
---|---|
[CSAPP] 8장 예외적인 제어 플로우 (2/4) Process (0) | 2025.01.19 |
[CSAPP] 11장 네트워크 프로그래밍 (4/4) (1) | 2025.01.16 |
[CSAPP] 11장 네트워크 프로그래밍 (3/4) (0) | 2025.01.16 |
[CSAPP] 10장 시스템 레벨 입출력 (0) | 2025.01.14 |