[PINTOS-KAIST] project. 2-1 - 개념 - Dual-mode,Pintos 코드에서의 interrupt와 콘텍스트 저장과 복원, 시스템 콜의 호출과 실행

조해빈·2023년 5월 25일
0

PINTOS

목록 보기
5/9
post-thumbnail

용어 일단 정리

mode bit:

1: 사용자 모드 -> user mode (제한된 기계어 명령만 가능)
0: 모니터 모드(OS 코드 수행) -> kernel mode (특권 명령까지 가능)

  • 구현에 따라 레지스터일 수도, 회로 내의 물리적 비트가 될 수도 있다.
  • 사용자 프로그램의 잘못된 수행으로 다른 프로그램 및 운영체제에 피해가 가지 않도록 하기 위한 보호 장치의 역할을 한다.
  • 위험한 명령(특권 명령)의 경우 커널 모드에서만 수행 가능하도록 하며, 특권 명령이 감지되면 자동으로 커널 모드로 바뀌게 된다.

대부분 메모리의 다수의 프로세스를 띄우는 멀티 프로그래밍 방식을 사용한다. 고로, 운영 체제는 다른 프로그램이나 운영 체제 자체를 부정확하게 실행시키지 못하도록 보장해야 한다.

그러므로 각 프로세스 실행 시, 서로 방해하거나 충돌을 일으키는 경우를 방지하는 보안기법이 필요하다. 이를 위해서 운영체제는 Dual-mode로 작동한다. 즉, 중요한 위험한 상황을 발생시킬 수 있는 명령은 커널모드에서만 작동이 가능하게 하고, 그렇지 않은 연산만 유저모드를 통해서 작동시킨다. 이를 가능하게 하는 것이 모드 비트인 것이다.

Registers는 CPU에 내장된 작은 저장 장치이다. 데이터를 임시로 저장하고 컴퓨터 성능을 높이는 데 도움이 된다. 레지스터의 크기는 컴퓨터 아키텍처에 따라 다를 수 있고, 다양한 유형의 레지스터가 각자의 역할을 수행한다. 여기서 짚고 넘어갈 stack pointer와 program counter도 역시, 레지스터이다.

프로그램 카운터(PC)

‘다음 인스트럭션을 가리키는 레지스터’.
메모리로부터 다음 실행할 명령의 주소를 저장하는 역할을 한다.

스택 포인터 또한 레지스터 중 하나이며, 스택에서 가장 최근 요청의 명령 주소를 저장하는 역할을 한다.

기본적으로 CPU는 매 순간 메모리에 있는 기계어를 한 줄 한 줄 읽어 실행한다. 어디에서? 프로그램 카운터가 가리키고 있는 메모리의 위치에서. CPU의 레지스터에는 프로그램 카운터(PC)라는 레지스터가 있고, 이 친구가 한 줄씩 아래를 가리키며 CPU는 그 안에 든 내용을 한 줄 한 줄 읽는 원리이다.

컴퓨터 프로그램은 CPU에게 작업을 수행하도록 명령한다. 즉, 컴퓨터 프로그램은 이 명령(instructions)으로 이루어져 있고, CPU는 이러한 명령을 차례대로 가져와서 실행한다. 이때 program counter는 다음에 실행할 명령어의 주소를 저장하는 역할을 하는 레지스터인 것이다. Instruction pointer, Instruction address register 혹은 instruction counter라고 부르기도 한다.

그런데 PC가 늘 그렇게 한 칸씩 동일한 순차 이동만 하느냐면 그것은 아니다. 논리에 따라 함수 구조면 함수가 호출된 곳으로 이동하고, 반복문을 돌거나 jump해서 떨어진 위치를 가리키게 되기도 한다.

아예 다른 프로그램에 대한 영역을 가리킬 수도 있다. 이는 그냥 일어나는 경우는 아니고, 인터럽트가 들어왔을 때엔 무조건 OS 쪽의 영역을 가리키게 된다. 이는 하드웨어 적인 원칙이다. 마찬가지로 mode bit 역시 값을 전환하게 된다. 이 내용이 바로 유저모드에서 커널모드로 문맥 전환(Context Switching)되는 원리를 묘사하고 있다.

스택 포인터(SP)

"스택의 마지막 프로그램 요청 주소를 저장하는 레지스터"
함수가 호출되고 현재 가리키고 있는 스택메모리 주소를 저장하고 있고, 스택의 최상단 주소를 가리키고 있다.

중앙처리 장치 안에는 스택에 데이터가 채워진 위치를 가리키는 레지스터인 스택 포인터(SP)를 갖고 있다. 스택포인터가 가리키는 곳까지가 데이터가 채워진 영역이고, 그 이후부터 스택 끝까지는 비어있는 영역이다.

스택에 새로운 항목이 추가되거나 스택에서 데이터가 제거되면, 스택 포인터의 값이 증가하거나 감소한다.

스택은 PUSH와 POP 두가지 동작에 의하여 액세스된다.

PUSH : 스택에 데이터 추가 (오퍼랜드를 스택에 저장하라.)
POP : 스택에서 데이터 제거 (스택에서 제거한 데이터를 오퍼랜드에 저장해라.)

이중 동작 모드(Dual-mode operation)

이중 동작 모드는 쉽게 말해 운영체제를 보호하기 위한 기법이다. 알다시피, 유저와 운영체제는 시스템 자원을 공유한다. 그렇기 때문에 유저 플리케이션에게 어느 정도의 제한을 두지 않으면 사용자가 실수 혹은 고의로 메모리 내의 주요 운영체제 자원을 망가뜨릴 위험이 생기게 된다. 즉, 운영체제의 원활한 작동과 기능을 위해서는 사용자의 시스템 자원 접근을 제한하는 보호 장치가 필수불가결하다. 이러한 보호 장치가 바로 이중 동작 모드(dual-mode operation)이다.

이중 동작 모드의 기본 개념은 사용자가 접근할 수 없는 커널 모드(kernel mode)와 사용자가 접근할 수 있는 사용자 모드(user mode)로 나누어 진다. 커널 모드는 수퍼바이저 모드(supervisor mode), 시스템 모드(system mode), 혹은 특권 모드(privileged mode)로도 불리운다. 위의 이미지에서 볼 수 있듯이, 사용자가 사용하는 응용 프로그램은 사용자 모드에서 작동이 되어지게 된다. 그러다가, 해당 프로그램이 운영체제에게 시스템 사용을 요청하게 되면 커널 모드로 바꾸어서 요청된 시스템을 실행한 뒤에, 다시 사용자 모드로 전환한다.

운영체제 내부에서는 일부 인스트럭션들을 "특권 명령(privileged instruction)"으로 지정하고 있다. 이에 따라, 하드웨어는 특권 명령이 커널 모드에서만 실행되도록 허용한다. 이것이 Dual-mode의 핵심이다.

만일 사용자 모드에서 특권 명령을 실행하려는 시도가 있다면, 즉, 사용자 프로그램이 불법적인 명령을 실행하려 하거나 사용자 주소 공간이 아닌 메모리에 접근을 시도하는 등과 같은 오류가 발생하면! 하드웨어는 이를 실행하지 않고 불법적인 명령으로 간주해 운영체제로 트랩(trap: 소프트웨어적 인터럽트)을 발생시킨다. 트랩은 인터럽트처럼 인터럽트 벡터를 통해 제어를 운영체제에게 넘긴다.

그러니 이제 명확히 이해할 수 있다. 인터럽트나 예외가 발생 시, 모드가 전환되며 제어가 커널로 넘어간다는 것이다.
프로그램을 실행하면서 인터럽트이나 트랩이 발생할 때 하드웨어는 사용자 모드에서 커널 모드로 전환한다(모드 비트 0). 그러므로 운영체제가 컴퓨터의 제어를 얻을 때는 언제나 커널 모드에 있는 거다. 시스템은 사용자 프로그램으로 제어를 넘기기 전에 항상 사용자 모드(모드 비트 1)로 전환한다.

모드 비트가 우리가 다룰 핵심 내용은 아니다만, 어쨌든 이 커널 모드와 사용자 모드를 구분 짓기 위해서 모드 비트(mode bit)라고 하는 하나의 비트가 컴퓨터의 하드웨어에 추가되었다. 이 비트는 커널 모드(0) 또는 사용자 모드(1)를 나타낸다.

또 참고로, Pintos를 포함한 모든 시스템은 처음 부트 시, 하드웨어는 커널 모드에서 시작한다. 운영체제가 적재되고, 이어 사용자 모드에서 사용자 프로세스가 시작된다.

Pintos의 코드들을 보면서 좀 더 심도 있게 인터럽트와 문맥, 문맥 전환에 대해 볼 수 있다.

Pintos 코드에서 다뤄지고 있는 interrupt

IDT (Interrupt Descriptor Table)

미리 정의되어 있는 인터럽트들의 번호실행 코드를 가리키는 주소들이 저장되어 있는 Table.

1️⃣ 컴퓨터 부팅 시 운영체제가 IDT에 인터럽트들을 기록하고,
2️⃣ 인터럽트가 발생하면 IDT를 확인하여 Interrupt 번호에 해당하는 함수를 호출해서 인터럽트를 처리한다.

(참고로 이런 식의 테이블이 앞으로 많이 나올 것이다.) 아주 쉽게 설명해서 한 메뉴판이 미리 있고, 인터럽트가 발생하면, 이 메뉴판에 있는 맞는 처리법 중 하나를 찾아 불러오는 거다.

조금 더 기계어적인 단계에서의 인터럽트 발생-처리 과정을 보자.

프로세스 실행 중 인터럽트 발생 ➡️ 현재 프로세스의 실행 중단
➡️ 현재 수행 중이었던 프로세스의 상태를 해당 프로세스의 PCB(Protocol Control Block)에 저장
➡️ 발생한 인터럽트의 번호를 IDT 에서 확인하여 해당하는 인터럽트 번호 에 해당하는 함수를 호출 해서 실질적인 작업을 수행
➡️ 실행이 중단되었던 프로세스의 PCB를 불러와 CPU 레지스터에 초기화 시켜줌으로서 프로세스를 다시 수행

위에서 말하는 PCB는 이제 Pintos에서 인터럽트 프레임 struct intr_frame *frame이란 이름으로 많~이 보게 될 것이다.

Pintos 내에 이미 제공되고 있는 IDT를 봐 보자. 여기를 딱히 만질 일은 없지만, 대강 이렇게 생겼다. 아래는 threads/interrupt.c 내의 intr_init(), 즉 인터럽트에 대한 초기화를 해주는 함수이다.(구현 과제 상 위 코드들을 이해할 필요는 없다.) intr_init()에 앞서 초기화되고 있는 사항들... 64비트 데이터 버스와 64비트 주소 버스를 갖추고 있는 x86_64 아키텍처에서 가능한 interrupt의 개수가 256으로 설정되었으며, 반복문을 통해 각각에 대한 게이트를 설정 즉 초기화해놓는 것을 볼 수 있다. 인터럽트마다 이름이 있다. 이는 디버깅을 위함이라고 주석에 참조되어 있다.

Dual-mode 간 문맥 전환(Context-switching), Pintos 코드에서의 콘텍스트 저장과 복원

아래의 코드들은 구현 과제를 위해 유의미한 이해와 실사용이 필요하다.

intr_enable()과 intr_disable()은 쉽게 말해서 각각 인터럽트 발생이 가능하게, 불가능하게 하는 함수이며, 갱신된 enum intr_level을 반환한다. intr_level의 값은 ON 아니면 OFF다.Project 1에서 이 함수들을 여러 번 활용할 것이다.

그리고 아래가 좀전에 언급했던 struct intr_frame이다. intr_frame은 인터럽트와 같은 요청이 들어와서 기존까지 실행 중이던 context(레지스터 값 포함)를 메인 메모리의 스택 영역에 저장하기 위한 구조체이다.일단 이 구조체에 대해 설명하기 이전에 문맥과 문맥 전환에 대해 더 알아봐야 겠다.

문맥 == 콘텍스트(Context):
인터럽트 또는 예외가 발생했을 때, 핸들러를 수행한 후 코드로 복귀하려면 프로세서의 상태를 저장하고 복원해야 한다. 프로세서는 레지스터를 기반으로 코드를 수행하므로, 프로세서의 상태는 코드 수행에 관계된 레지스터의 집합이라고 할 수 있음. 이렇게 프로세서의 상태와 관계된 레지스터의 집합을 다른 말로 콘텍스트(Context)라고 한다.

인터럽트 또는 예외로 인해 핸들러가 수행되거나 어떤 이유로 현재 수행 중인 코드를 중단하고 나서 다시 수행해야 한다면 전후의 콘텍스트를 동일하게 유지해야 한다.

콘텍스트를 유지하는 방법은 간단하다. 콘텍스트를 위한 메모리 공간을 할당한 뒤, 정해진 순서대로 레지스터를 저장하고 복원하는 것이다. Pintos는 콘텍스트를 실수 연산에 관련된 FPU 레지스터를 제외한 거의 모든 레지스터를 저장하고 있다.

구조체 intr_frame은 인터럽트가 들어왔을 때, 이전에 레지스터에 열심히 작업하던 context를 switching하기 위해 그 정보를 담을 구조체이다.

구조체 intr_frame는 멤버로 구조체 gp_registers R을 들고 있다.자, 위가 구조체 gp_registers이다. 그저 레지스터의 목록 아닌가? 맞다. 구조체 intr_frame은 이 구조체 gp_registers R을 들고 있다. 말했듯 구조체 intr_frame는 문맥 전환이 발생할 시, 이후 재실행되기 위해 프로세스가 저장해야 하는 정보를 담는다. 기존 스레드가 작업하고 있을 때의 레지스터 값을 인터럽트가 들어오면 switching하기 위해 이 R에 방금까지 실행 중이던 프로세스에 대한 필수 진행 정보들을 싹싹 이 intr_frame에 담는 거다.

인터럽트나 예외가 발생했을 때 프로세서가 하는 역할은 단순히 스택에 이 콘텍스트의 일부를 저장하거나 복원하는 것뿐이다. 다시 말하면 프로세서는 복원하는 콘텍스트가 이전에 자신이 저장한 콘텍스트와 같은지 비교하지 않는다.

이 점은 중요하다. 인터럽트나 예외가 발생했을 때 저장한 콘텍스트가 아닌 다른 콘텍스트로 복원할 수 있음을 뜻하기 때문이다.

멀티태스킹이란 여러 개의 태스크(작업)를 교대로 실행하여 마치 동시에 여러 개의 프로그램이 실행되는 것과 같은 효과를 내는 것이다.

만일 콘텍스트 영역을 여러 개 만들고, 인터럽트가 발생했을 때 이들을 순차적으로 교환한다면 소프트웨어 멀티태스킹을 구현할 수 있는 것이다. 이것이 멀티 프로세스(멀티 스레드)의 구현이다.

그래서... "싹싹 담는" 일에 대한 수행은 아래의 do_iret()이라는 함수에서 수행한다. 어셈블리어로 되어 있는데, 잘 보면 스택포인터(rsp)를 순차적으로 옮겨가며 레지스터들에 기존까지 작업했던 context를 담고 있다. 즉, context를 intr_frame에 담는 과정이라고 보면 되겠다.iretq는 인터럽트 처리를 완료하고 이전에 수행하던 코드로 복귀해 정상적인 프로그램 실행을 재개하게 하는 어셈블리어다. "interrupt return"의 줄인 말이라고 보여진다.

이때 저장된 콘텍스트는 이후 다시 스레드가 lauch될 때 고스란히 읽혀온다. 아래는 thread_launch()의 모습이다.

다시. interrupt frame가 뭐냐? 인터럽트가 들어왔을 때, "레지스터에 작업하던 context를 switching하기 위해 현재의 핵심 정보를 담아놓는 구조체". Virtual Memory의 kernel stack 영역에 위치한다.

정리하여, interrupt frame 구조체가 thread의 context를 의미하고, kernel space에 해당 값을 저장하고 읽는 과정이 곧 context switching의 과정이며, 멀티 프로세스 환경에서는 반드시 CPU가 그 직전에 실행하고 있던 프로세스로 복귀하진 않는다, 이것이 멀티 프로세싱이다, 라고 마무리할 수 있겠다.

정리한다.

커널모드는?
컴퓨터의 모든 자원에 대한 접근 권한을 가진다. 이 모드에서 실행되는 코드는 모든 특수한 명령어를 포함하여 원하는 모든 작업을 수행할 수 있다.

유저모드는?
그렇지 않다. 이는 유저 어플리케이션이 데이터의 손상(한 프로세스가 접근해선 안 되는 자원에 접근하게 되거나 운영체제의 데이터를 수정 삭제하는 등)을 발생하지 않게끔 하는 일종의 보호 체제다.

유저모드가 갖지 못하는 인스트럭션은 "특권 명령"이라고 명명한다. 모든 I/O 장치들에 접근하는 기계어들은 사용자 프로그램이 직접 그 명령을 가지고 실행할 수 없다. 다시 말해 CPU가 I/O를 기다리는 모든 인스트럭션들은 특권 명령이다.

그렇다면 이런 사용자 프로세스가 디스크 읽기와 같은 명령어를 실행하려면 어떻게 해야 할까?

답은 운영체제에게 해달라고 요청하는 것이다.
이 때에, 필요한 것이 시스템 콜(System call)이다.

시스템 콜 (System call)

시스템 콜이란 사용자 프로그램이 운영체제의 서비스를 받기 위해 커널 함수를 호출하는 것이다.

시스템 콜은 운영체제가 제공하는 서비스에 대한 프로그래밍 인터페이스로, 사용자 모드 프로그램이 커널 기능을 사용할 수 있도록 한다. 시스템 콜은 커널 모드에서 실행되며, 실행이 끝나면 다시 사용자 모드로 복귀된다.

이전 프로젝트에서 우리는 CPU가 운영체제에게 넘어가는 경우는 interrupt와 exception라는 두 경우가 있다는 걸 알았다.

우린 앞서 CPU는 메모리에서 기계어를 읽어 한 줄 실행한 다음 다음 기계어 실행에 앞서 매번 interrupt line에 시그널이 들어온 게 있는지 체크한다고 했다. 이 interrupt line에 시그널이 들어오는 것이 곧 인터럽트의 개념이다.

interrupt line을 누가 셋팅하느냐에 따라 interrupt는 다시 둘로 나뉠 수 있다.
1️⃣ 하드웨어 장치들이 인터럽트를 걸어서 CPU가 넘어가는 경우, 2️⃣ 프로그램 소프트웨어가 직접 interrupt line을 셋팅하여 CPU가 넘어가게 요청하는 경우이다. 후자가 시스템 콜이다!

고로 시스템 콜이라는 건 곧 자기 자신에게 인터럽트(트랩)를 거는 것이다. 필요한 상황 하에 프로세스 혼자서는 I/O를 핸들할 수 없기에, 즉 자신의 권한으로는 PC를 커널 영역으로 넘기지 못하기 때문에, 운영체제에게 CPU를 넘기기 위해 자신의 기계어를 통해 자신 스스로에게 인터럽트 라인을 셋팅하는 것이다.

일단 시스템 콜 종류만 후루룩 보고 본론 가자. 시스템 콜은 크게 6가지 목적으로 나뉜다.

  • 프로세스 컨트롤
    프로세스 생성 및 종료
    메모리에 로드, 실행
    프로세스 속성 값 확인, 지정
    wait 이벤트, signal 이벤트
    메모리 할당
  • 파일 메니지먼트
    파일 생성, 파일 삭제
    열기, 닫기
    읽기, 쓰기, Reposition
    파일 속성 값 확인, 지정
  • 디바이스 매니지먼트
    디바이스 요청 및 해제
    읽기, 쓰기, Reposition
    디바이스 속성 확인, 지정
    비 물리적인 디바이스 해제 및 장착
  • 정보 관리
    시간 확인, 시간 지정
    시스템 데이터 확인, 지정
    프로세스, 파일, 디바이스 속성 가져오기
    프로세스, 파일, 디바이스 속성 설정하기
  • 커뮤니케이션
    커뮤니케이션 연결 생성 및 삭제
    메시지 송신, 수신
    상태 정보 전달
    remote 디바이스 해제 및 장착
  • 보안
    Permission 획득
    Permission 설정

아래는 윈도우와 유닉스의 시스템 콜 목록을 비교하고 있다.

우리는 Pintos Project 2에서 총 14가지의 시스템 콜을 구현하게 된다.

syscall 호출과 실행

자... 이제 우린 시스템 콜이 뭔지 알았다.
이제 실제 어떻게 syscall이 호출되는지 그 과정을 살펴본다.위 커널/유저 모드 그림을 예시로 보자.

  1. 유저 프로그램이 메모리에 무언가를 기입하는 write()이라는 시스템 콜을 호출한다.

  2. 해당 시스템 콜 넘버와 인자들, 해당 프로그램의 인터럽트 프레임을 정해진 순서대로 레지스터에 채워(push)준다. 이때 스택 포인터는 인자가 들어간 영역을 가리킬 것이다.

lib/user/syscall.c의 __attribute__ 를 보자. 이 함수는 가장 먼저 시스템 호출 번호를 CPU 레지스터 rax에 복사한다. 그런 뒤 차례대로 인자들을 정해진 순서대로 레지스터에 넣는다. 이것들은 intr_frame이 가지고 있는 R을 메인 메모리의 유저 스택 영역에 넣기 위해 모드가 커널 모드로 전환된다.

  1. Pintos 기준으로 생각하면, 인터럽트 디스크립터 테이블(IDT)를 기억하는가? 이 메뉴판에 가면 주소별로 어떤 종류의 인터럽트를 실행할 것인지가 매핑되어있다. 그림에서의 0x30은 시스템 콜을 처리하는 함수 syscall_handler()를 호줄하는 주소다. 따라서 syscall_handler()이 실행된다.

아래는 userprog/syscall.c에 위치한, 구현이 완료된 시점의 syscall_handler()이다. 매개변수로 이터럽트 프레임 f를 받고, 그 안에 레지스터 구조체 R 안의 rax 레지스터에 담긴 시스콜 넘버를 switch문을 통해 확인한다. 어떤 시스템 콜이 들어온 건지 찾는 것이다. 각 시스템 콜이 몇 개의 매개변수를 받는지는 구현을 하면서 혹은 Pintos Docs를 보면서 확인할 수 있다.

참고로 각 시스템 콜 함수 상단에서 check_address()를 해주는 것 대신 syscall_handler() 내에서 가장 먼저 check_address()를 먼저 해버리고 들어가도 오류가 없는 것을 확인했다. 다만 check_address()가 필요 없는 함수들이 있어 경우에 따라 비효율적일 수 있다.

profile
UE5 공부하는 블로그로 거처를 옮겼습니다!

0개의 댓글