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

컴퓨터 구조 (Architecture)/컴퓨터의 개념 및 실습

[Chapter 10] And, Finally... The Stack - 인터럽트 메커니즘

피그브라더 2020. 2. 9. 21:44

TRAP 명령어를 활용하면 운영체제의 코드에 해당하는 서비스 루틴을, JSR/JSRR 명령어를 활용하면 프로그래머가 직접 작성한 서브 루틴을 실행한 뒤 원래 프로그램의 실행 흐름으로 돌아온다. 그리고 둘 다 돌아올 때는 RET(= JMP R7) 명령어를 사용한다. 인터럽트 메커니즘도 어찌 보면 비슷할 수 있다. 외부 장치에 의해 인터럽트가 발생하면 그 장치가 요청한 인터럽트 서비스 루틴을 실행하러 잠시 어디론가 갔다가 다시 돌아오기 때문이다. 하지만 인터럽트 메커니즘은 TRAP 서비스 루틴 호출이나 JSR/JSRR 서브 루틴 호출과 그 방식이 아예 다르다. 인터럽트 메커니즘의 중요한 핵심은 해당 인터럽트 서비스 루틴에 갔다가 돌아왔다는 사실을 프로세서가 전혀 알지 못해야 한다는 것이며, 이를 위해선 인터럽트 서비스 루틴 호출 직전의 프로세서 상태 정보 전부를 '스택(Stack)이라는 메모리 공간'에 백업을 했다가 루틴 종료 직후에 복원해야 한다. 앞선 포스팅까지는 스택을 설명하지 않았으므로 인터럽트 메커니즘도 완전히 설명할 수 없었다. 이번 포스팅에서는 스택에 대해 한 번 알아보고, 이를 바탕으로 인터럽트 메커니즘이 어떻게 동작하는지까지 알아보도록 하자.

 

1. 스택 (Stack)

1-1. 정의 및 구현 방식

스택은 LIFO(Last-In First-Out)라는 용어로 정의할 수 있다. 즉, 가장 첫 번째에 넣은 값이 가장 마지막에 꺼내지고, 가장 마지막에 넣은 값이 가장 처음에 꺼내지는 구조이다. 이것이 스택 정의의 전부이며, 이를 어떻게 구현하는지에 대한 내용은 정의와 관련이 없다. 여러 가지 방식으로 구현할 수 있기 때문이다. 가장 전형적인 구현 방식은 다음과 같다. 메모리의 특정 연속적인 공간들을 스택을 위한 공간으로 약속하고, 현재 가장 마지막에 넣은 값이 위치한 공간을 가리키고 있는 스택 포인터(Stack Pointer)라는 것을 두는 것이다. 이렇게 하면 값을 넣거나 뺄 때마다 이미 들어있는 값들의 위치를 바꿀 필요가 없고, 스택 포인터의 값만 일정 크기만큼 증가/감소시켜주면 된다. LC-3에서는 스택 포인터의 값을 R6 레지스터에 저장하는 것을 원칙으로 삼는다.


1-2. 기본 연산 : Push, Pop

Push는 데이터를 스택에 넣는 연산, Pop은 데이터를 스택에서 꺼내는 연산을 의미한다. LC-3에서 Push 연산과 Pop 연산을 각각 어떤 코드로 구현하고 있는지 한 번 알아보자. 참고로 LC-3에서 스택은 값을 넣을 때마다 스택 포인터가 감소하는 방향으로 움직인다(데이터가 아래로 쌓인다). 또한 두 연산 모두 연산이 제대로 수행되었는지 여부를 R5에 담아서 전달하기 때문에, 이 루틴이 종료된 직후에 BRz 명령어를 사용하면 R5를 바로 검사할 수 있다(RET 명령어 직전 명령어가 STR 또는 ADD이기 때문).

 

1-2-1. Push

MAX : 스택 포인터 최솟값(꽉 차 있을 때)의 음수에 해당하는 값

R1, R2 : Callee-save 필요

R5 (반환 값) : Caller-save 필요 (성공/실패 여부를 나타내는 값)

 

 

1-2-2. Pop

EMPTY : 스택 포인터의 최댓값(비어 있을 때)이 음수에 해당하는 값

R1, R2 : Callee-save 필요

R0, R5 (반환 값) : Caller-save 필요 (Pop 된 값, 성공/실패 여부를 나타내는 값)

 

 

 

 

2. 프로세서의 상태 정보

앞선 포스팅에서, 인터럽트 메커니즘의 두 번째 단계인 '인터럽트 요청의 처리'는 이후 포스팅에서 설명하겠다고 하고 설명을 생략한 바 있다. 본 포스팅에서 다루는 스택을 아직 설명하지 않았었기 때문이다. 인터럽트 요청을 처리하기 위해서는 우선 현재 프로세서의 모든 상태 정보를 스택에 저장해야 한다. 그렇다면 프로세서의 상태 정보로는 무엇이 있을까? 크게 세 가지로 나눌 수 있다. PSR(Program Status Register), PC(Program Counter), GPR(General Purpose Registers)이 바로 그것이다. 각각에 대해 한 번 알아보자.


2-1. PSR (Program Status Register)

PSR은 다음과 같이 생긴 16비트 레지스터이다. 여기서 눈여겨볼 부분은 크게 세 부분인데, 하나는 현재 실행 중인 프로그램의 특권 여부를 나타내는 PSR[15], 다른 하나는 현재 실행 중인 프로그램의 우선순위를 나타내는 PSR[10:8], 나머지 하나는 Condition Codes 레지스터에 해당하는 PSR[2:0]이다. 각각에 대해 간단히 알아보자.

 

 

2-1-1. P (= PSR[15])

현재 운영체제의 코드를 실행 중이라면 PSR[15]가 0으로 되어 있을 것이고, 이는 일반 유저 프로그램은 접근할 수 없는 중요한 자원들에 접근할 권한을 가진 상태라는 것을 의미한다. 반대로 현재 일반 유저 프로그램의 코드를 실행 중이라면 PSR[15]가 1로 되어 있을 것이고, 이 상태에서는 몇 가지 중요한 자원들에 접근할 수가 없게 된다. 전자를 슈퍼바이저(Superviosr) 모드, 후자를 유저(User) 모드라고 부른다.

 

2-1-2. PL (= PSR[10:8])

현재 실행 중인 프로그램의 우선순위를 나타내는 값이다. PL 값이 클수록 우선순위가 높음을 의미한다. 만약 외부 장치에서 발생하는 인터럽트의 우선순위가 현재 PSR의 PL 값보다 크다면 해당 인터럽트 요청을 처리해야 하는 것이고, 아니라면 무시하게 된다.

 

2-1-3. N/Z/P (= PSR[2:0])

우리가 지금까지 조건 분기를 위해 사용했던 Condition Codes 레지스터들에 해당한다. 즉 N/Z/P가 각각 따로 존재하는 1비트 레지스터가 아니라 PSR이라는 16비트 레지스터의 상위 세 비트 부분이었던 것이다.


2-2. PC (Program Counter)

서비스 루틴이나 서브 루틴과 마찬가지로 원래 프로그램의 실행 흐름으로 돌아오기 위해서는 PC 값도 반드시 저장해야 한다. 현재 실행 중인 코드의 위치를 나타낸다고 볼 수 있으므로 이것도 당연히 프로세서의 상태 정보에 해당한다.


2-3. GPR (Register)

CPU에 존재하는 8개의 레지스터들을 의미한다. 인터럽트 요청을 처리하고 돌아왔을 때 그러한 사실을 프로세서가 눈치채지 못하려면 레지스터들의 상태도 반드시 이전과 동일해야 한다. 따라서 GPR도 프로세서의 상태 정보에 해당한다.

 

3. 상태 정보를 저장하는 두 종류의 스택

3-1. 유저 스택 (User Stack)

유저 프로그램이 사용하는 스택으로, 유저 프로그램이 위치하는 메모리 공간에 위치한다. 앞서 말했듯 유저 스택에 접근하기 위한 스택 포인터는 R6 레지스터에 저장하는 것이 관습이다. 인터럽트 서비스 루틴이 호출됨으로써 유저 모드에서 슈퍼바이저 모드로 이동하는 경우에는 현재 R6의 값을 Saved.USP 레지스터에 저장하여 유저 스택 포인터를 기억해둬야 한다. 그리고 Saved.SSP 레지스터에 저장되어 있는 슈퍼바이저 스택 포인터를 R6로 로드해야 한다. 슈퍼바이저 모드에서는 유저 스택이 아닌 슈퍼바이저 스택을 사용하기 때문이다.

 


3-2. 슈퍼바이저 스택 (Supervisor Stack)

운영체제 코드가 실행될 때 사용하는 스택으로, 운영체제 코드가 위치하는 메모리 공간에 위치한다. 마찬가지로 슈퍼바이저 스택에 접근하기 위한 스택 포인터는 R6 레지스터에 저장하는 것이 관습이다. 인터럽트 서비스 루틴이 종료되어 유저 모드로 이동하는 경우에는 현재 R6의 값을 Saved.SSP 레지스터에 저장하여 슈퍼바이저 스택 포인터를 기억해둬야 한다. 그리고 Saved.USP 레지스터에 저장되어 있는 유저 스택 포인터를 R6로 로드해야 한다. 유저 모드에서는 슈퍼바이저 스택이 아닌 유저 스택을 사용하기 때문이다.

 

 

4. LC-3 인터럽트 메커니즘

4-1. 첫 번째 단계 : 인터럽트 신호의 발생

우선, 인터럽트를 발생시킬 권한이 있는 입출력 장치의 상태 레지스터는 interrupt enable bit가 CPU에 의해 1로 설정된다(운영체제 코드의 역할로 추정). 그러한 입출력 장치가 데이터를 교환할 준비가 되면 상태 레지스터의 ready bit는 1이 되는데, 이렇게 ready bit와 interrupt enable bit가 둘 다 1이 되면 AND 게이트에 의해 인터럽트 요청 신호(Interrupt Request Signal)가 활성화된다. 

 

그리고 그렇게 발생된 여러 장치의 인터럽트 요청 신호들 중 가장 우선순위가 높은 인터럽트 요청 신호가 현재 프로세스의 우선순위보다 높다면 그 입출력 장치에 해당하는 인터럽트 신호(INT Signal)가 활성화된다. 여기서 말하는 우선순위가 바로 앞에서 설명했던 PSR 레지스터의 PL 비트이다. 내부적으로는 우선순위 인코더(Priority Encoder)라는 회로가 인터럽트 요청 신호와 함께 날아오는 우선순위 값들 중 가장 우선순위가 높은 것을 선별해내고 이를 현재 PSR의 PL 비트와 비교하게 된다.

 

한편 CPU는 명령어 사이클 중 STORE 단계가 끝나면 인터럽트 신호(INT Signal)의 활성화 여부를 검사하기 위한 상태로 돌입한다. 만약 인터럽트 신호가 활성화되어 있지 않다면 평소와 같이 FETCH 단계의 첫 번째 상태로 돌입함으로써 명령어 실행을 계속한다. 반면, 인터럽트 신호가 활성화되어 있다면 인터럽트 서비스 루틴을 호출하기 위한 상태로 돌입하여 컨트롤 유닛이 바로 다음에 이어서 설명할 과정을 밟게 된다. 참고로 인터럽트 신호 유무를 STORE 단계가 끝난 시점에 검사하는 것은 명령어 사이클의 'Atomic' 특성을 유지하기 위함이다. 한 명령어는 완전히 실행이 되거나 아예 실행이 되지 않거나 하는 것이 가장 안전하기 때문이다.


4-2. 두 번째 단계 : 인터럽트 요청의 처리

인터럽트 요청을 처리하고 돌아왔을 때 그 사실을 프로세서가 눈치채지 못하려면, 현재의 프로세서 상태 정보를 전부 저장해야 한다. 앞서 말했듯 프로세서의 상태 정보로는 PSR, PC, GPR이 있는데, 이 중 GPR은 인터럽트 서비스 루틴 내에서 Callee-save를 하면 되므로 실제로 백업해야 하는 레지스터는 PSR과 PC이다.

 

이러한 상태 정보들은 슈퍼바이저 스택에 저장하므로, 우선 현재의 R6 값을 Saved.USP에 저장해 두고 Saved.SSP에 저장되어 있는 슈퍼바이저 스택 포인터를 R6로 가져와야 한다. (만약 어떤 인터럽트 서비스 루틴을 실행 중일 때 또 다른 인터럽트가 발생한 상황이라면 이 작업을 할 필요가 없다. 이미 특권을 가지고 있는(P = 0) 슈퍼 모드로서 슈퍼바이저 스택을 사용 중일 것이기 때문이다.)

 

이제 슈퍼바이저 스택에 PSR과 PC의 값을 차례로 Push 하여 상태 정보를 백업한다. 그리고 현재 PSR의 값을 적절히 바꿔줘야 한다. 먼저, PSR[15]는 0으로 설정하여 슈퍼바이저 모드로 진입해야 한다. 다음으로, PSR[10:8]은 인터럽트 요청 신호와 함께 날아오는 우선순위 값으로 설정해줘야 한다. 마지막으로, Condition Codes 레지스터에 해당하는 PSR[2:0]은 모두 0으로 초기화를 해줘야 한다.

 

한편, 인터럽트를 발생시킨 외부 장치는 인터럽트 요청 신호와 우선순위 값 말고도 인터럽트 벡터(INTV)라는 것을 함께 보낸다. 이는 앞서 설명한 트랩 벡터와 유사한 역할을 수행하는 것으로, 아래 그림에서 보이는 인터럽트 벡터 테이블 내에서 특정 인터럽트 서비스 루틴의 시작 주소를 찾는 데에 사용된다. 즉 CPU는 전달받은 INTV를 Sign Extension 시켜서 얻어낸 메모리 주소로 접근하여 호출할 인터럽트 서비스 루틴의 시작 주소를 알아내고, 이를 MDR에 저장한 뒤 이어서 PC에 저장되게 함으로써 해당 인터럽트 서비스 루틴을 호출한다. 이로써 해당 인터럽트 서비스 루틴의 실행이 개시된다.

 

 

인터럽트 서비스 루틴의 실행이 마무리되면, 이제 아무 일이 없었다는 듯이 원래대로 돌아가야 한다. 이를 위해서는 우선 해당 서비스 루틴 내에서 Callee-save 했던 GPR들을 복원해야 한다. 그리고 슈퍼 모드에서만 실행이 허용되는 RTI 명령어를 수행하여 저장해 두었던 프로세서의 나머지 상태 정보들을 복원하는 작업을 진행한다. 즉 슈퍼바이저 스택에 저장했던 PC와 PSR을 차례로 Pop 하여 원래대로 복원하고, 만약 PSR[15]가 다시 1이 되는 상황이라면(즉 유저 모드로 돌아간다면), 현재의 R6 값을 Saved.SSP에 저장해 두고 Saved.USP에 저장되어 있던 유저 스택 포인터를 R6로 옮겨야 한다. 따라서 모든 인터럽트 서비스 루틴의 마지막 명령어는 RTI일 것이라는 추측을 할 수 있다.

 

5. 내부 인터럽트 (Internal Interrupt)

5-1. 기본

지금까지 설명한 인터럽트는 외부 인터럽트(External Interrupt) 또는 비동기적 예외(Asynchronous Exception)라고도 부르는데, 명령어 실행 흐름과 관련 없이 외부 장치에 의해 발생하는 예외 상황이기 때문이다. 반면, 명령어의 실행 결과로 발생하는 예외 상황도 존재한다. 이를 동기적 예외(Synchronous Exception) 또는 내부 인터럽트(Internal Interrupt)라고 부른다. 즉 내부 인터럽트는 프로세서 내부에서 명령어를 실행하는 과정에서 예상치 못한 사건이 발생하는 상황을 가리킨다. 통상적으로 인터럽트라고 하면 외부 인터럽트(비동기적 예외)를 의미하는 것이며, 예외라고 하면 내부 인터럽트(동기적 예외)를 의미한다.

 

그런데 사실 해당 용어들에 대한 정의는 책마다도 조금씩 다르다. 따라서 용어보다는 예외 상황이 개념적으로 다음과 같이 두 가지로 나뉜다는 것을 기억하는 것이 중요하다. 하나는 명령어 실행 흐름과 관련 없이 외부 요인에 의해 발생하는 비동기적인 예외 상황이고, 다른 하나는 명령어 실행 결과에 의해 발생하는 동기적인 예외 상황이다.


5-2. 예시

슈퍼바이저 모드에서만 실행이 허용되는 특정 명령어(EX. RTI)를 유저 모드에서 실행하는 경우가 대표적이다. 또는 사용하지 않는 opcode를 사용하는 경우, 0으로 나누는 연산을 수행하는 경우, 시스템 메모리와 같이 함부로 접근하면 안 되는 메모리 영역에 접근을 시도하는 경우도 이에 해당한다.


5-3. 메커니즘

외부 인터럽트와 동일한 방식으로 처리된다. 즉, 시스템 내부에서 발생할 수 있는 각 예외 상황에는 이미 특정 벡터 값이 약속되어 있어서, 특정 예외 상황이 발생하면 그것에 해당하는 인터럽트 요청 신호와 인터럽트 벡터가 함께 날아오게 된다. 우선순위 값의 경우 바뀌지 않으므로 특별히 날아오지 않는다. 나머지 과정은 외부 인터럽트를 처리하는 방식과 완전히 동일하다.

 

6. 스택의 응용 분야

6-1. 수식 계산 (Evaluating Arithmetic Expressions)

어떤 ISA의 경우에는 덧셈, 곱셈 등의 연산을 수행할 때 중간 결과물을 레지스터가 아니라 스택에 저장하기도 한다. 예를 들어서 (A+B) x (C+D)라는 수식을 계산하기 위해서 다음과 같은 과정을 거칠 수 있다.

 

 

스택을 이용하여 덧셈을 하는 루틴을 가상으로 구현해본다면, 다음과 같을 것이다.

 


6-2. 자료형 변환 (ASCII ↔ Binary)

우리가 키보드로 '259'라고 입력하면 실제로 CPU에 전달되는 값은 2에 해당하는 아스키 코드 값, 5에 해당하는 아스키 코드 값, 9에 해당하는 아스키 코드 값이다. 따라서 실제로 259라는 값을 얻어내기 위해서는 이러한 아스키 코드 값의 열을 바이너리 표현식으로 바꿀 필요가 있다. 반대로, 우리가 259라는 값을 모니터로 출력할 때는 내부적으로 이를 아스키 코드 값의 열로 바꾸는 변환 작업이 필요하다. 즉 2에 해당하는 아스키 코드 값, 5에 해당하는 아스키 코드 값, 9에 해당하는 아스키 코드 값으로 바꾼 뒤 출력을 진행하는 것이다. 이러한 양방향 변환 작업을 진행하는 데 있어서 스택이 활용될 수 있다. 스택을 활용하여 이러한 변환 작업을 수행하는 코드의 경우 여기에서는 생략하도록 하겠다. 중요한 내용은 아니기 때문이다.