카테고리 없음

제한적 직접 실행(LDE, Limited Direct Execution)

이매애진 2024. 5. 3. 00:08
작성자 임혜진
일 시 2024. 5. 2  (목) 18:00 ~ 21:00
장 소 복지관 b128-1호
참가자 명단 임혜진, 이재영, 성창민, 김명원, 장원준
 사 진

 

이번 장의 핵심질문은 제어를 유지하면서 효과적으로 CPU를 가상화하는 방법이다. 프로세스가 제어권을 가지지 못하도록 해야하는 것이 매우 중요한데, 그러기 위해서는 하드웨어와 운영체제의 지원이 필수적이다.

초기에는 직접 실행(Direct Execution) 방식이었다. 이는 프로그램을 CPU 상에서 그냥 직접 실행시키는 것이다. 

프로세스가 CPU를 제때 잘 반납하면 좋겠지만 현실은 그렇지 않다. 악의적인 프로그램이 컴퓨터를 장악할 수도 있고, CPU를 가상화하는 데 필요한 시분할(time sharing)기법을 구현할 수도 없다. '제한적 직접 실행'으로 이 문제들을 해결할 수 있다. 직접 실행의 문제점과 해결방안에 대해서 차근차근 살펴보자.

 


📍 직접 실행 프로토콜의 문제점 1 : 제한된 연산  커널모드와 유저모드 제공 !!!

먼저 악의적인 프로그램이 컴퓨터를 장악하는 것을 막기 위해서, 하드웨어(CPU)는 두 가지 실행 모드를 제공한다.

  • 사용자 모드(user mode)
    • 응용 프로그램(어플리케이션)이 실행되는 모드이다.
    • 사용자 모드에서 실행되는 코드는 할 수 있는 일이 제한된다. ex) I/O 입출력, 디스크 읽기 등은 불가
  • 커널 모드(kernel mode)
    • 특권 명령어를 포함하여 CPU가 제공하는 모든 기계어를 사용할 수 있다.
    • 운영체제가 실행되는 모드이다.
    • 레지스터에 접근이 가능하다.

사용자 모드에서는 I/O입출력, 디스크 읽기 같은 일이 제한된다고 했다. 만약 사용자 모드에서 I/O 입출력을 요청한다면, 프로세서(CPU)가 예외를 발생시키고 운영체제로 프로세스 제어를 양도한다. 그러면 운영체제는 해당 프로세스를 제거한다.
그렇다면 I/O입출력 요청은 어떻게 할 수 있을까? 사용자 모드에서 I/O 입출력을 하고자 한다면, 하드웨어가 제공하는 system call을 이용한다. 시스템 콜은 하드웨어가 사용자 프로세스에게 제공하는 인터페이스이다. 유저 모드와 커널 모드 사이에서 정보를 주고 받게 도와주는 그 경계면이라고 할 수 있다. 시스템 콜을 호출하면 CPU의 특권 수준이 커널 모드으로 상향된다. 커널 모드로 진입하면 운영체제는 모든 명령어를 실행할 수 있으므로 I/O입출력도 가능하다. 

이 과정에 대해 좀 더 자세히 알아보자. 시스템 콜을 실행하기 위해 프로그램은 trap이라는 특수 명령어를 실행해야 한다. Trap이 발생하는 경우는 3가지가 있다. Exception, Interrupt, Syscall이다. 간단히 알아보고 넘어가자.

  • Exception
    • 내부(CPU와 메모리 사이)에서 발생한 에러 (internal error) 
    • ex) 어떤 수를 0으로 나눔, page fault, bus error 등
  • Interrupt
    • 외부(CPU와 메모리 밖!에 연결된 장치들 간)에서 발생한 에러 (external interrupt)
    • 누가 Interrupt를 걸었는지 알아내서 Interrupt handler, jump table 등 이용
  • Syscall
    • CPU가 명령어를 실행하는데(Fetch, Decode, Execution의 사이클) INT 명령어를 만남
    • 예를 들어 Fetch했는데 그 명령어가 INT 0x80. INT라는 명령어를 처리할 때 trap이 걸리는 것이다.

 

syscall 호출에 대해 더 자세히 알아보자. CPU가 메모리의 Code로부터 명령어를 하나씩 가져와서 실행 중이다. 그러다가 write()라는 함수를 호출했다. write()라는 함수는 라이브러리에 존재하길래, 라이브러리 내에 있는 함수에 가봤다. write()면 뭔가 외부로 출력하는(?) 역할을 하는 명령어가 있어야 할 것 같은데.. 그런건 없다. movl 4, %eax; INT 0x80 ... 이라는 명령어가 존재한다. 이 명령어를 가져와서 실행하면 trap이 발생한다. trap이 발생하면 그 즉시 커널 모드로 전환된다. 그러면 이제 프로세스가 요청한 작업을 진짜 실행해야 하는데.. 그 부분에 해당하는 코드가 어떤 것인지 어떻게 찾을까?

이미지 출처: 임베디드 시스템 엔지니어를 위한 리눅스 커널 분석(남상규)-https://wiki.kldp.org/KoreanDoc/html/EmbeddedKernel-KLDP/idt.html

먼저 IDT에 접근한다. IDT란 interrupt descipt table로 trap이 생길 수 있는 경우들을 모두 담고 있는 table로 위 그림처럼 생겼다. trap 발생 시 먼저 IDT에 접근하여 왜 발생한 트랩인지, 원인이 무엇인지를 파악하는 것이다. INT 0x80 명령어에 의해 우리는 IDT의 0x80 인덱스의 칸으로 간다. 해당 칸에서는 system_call() 함수가 있다. trap이 발생한 원인이 Exception이 아닌, Interrupt가 아닌, Syscall인 것이다. system_call()함수에서는 sys_call_table에 접근할 수 있는 함수를 호출한다. 인자로는 특정 숫자를 넘겨주어야 하는데, 이 숫자가 바로 우리가 eax 레지스터에 저장해두었던 4라는 값이다! 아까 INT 명령어를 수행하기 전에 레지스터에 값을 넘겨주었는데, 이때 사용하기 위해서이다. 함수를 호출하여 4라는 인자를 전달하며 sys_call_table에 접근하면 4번 인덱스에 위치한 칸인 sys_write()함수에 드디어! 접근할 수 있다. sys_write()는 진짜로 값을 뭔가 써주는 그런 명령어가 있는 함수이다. 이런 명령어는 프로세스가 해서는 안되는 특권 명령어들이다. 이런 식으로 시스템 콜을 통해 파일 시스템 접근, 프로세스 생성 및 제거, 다른 프로세스와의 통신 및 메모리 할당 등 프로세스가 해서는 안되는 특권 명령어들을 운영체제가 수행할 수 있는 것이다.

아래 그림을 통해 전체 틀과 경로를 확인할 수 있다. 나는 write()를 예로 설명했는데, 아래 이미지에서는 fork() 시스템콜이 호출되는 과정에 대해 설명하고 있다. fork()와 write()는 sys_call_table에서의 위치만 다를 뿐 맥락과 흐름은 똑같다. 

이미지 출처: 임베디드 시스템 엔지니어를 위한 리눅스 커널 분석(남상규)-https://wiki.kldp.org/KoreanDoc/html/EmbeddedKernel-KLDP/system-call-table.html

 

아래는 이미지의 출처가 되는 책의 위키독스 링크이다. 정리가 엄청 잘 되어있어서 직접 읽어보기를 강력 추천한다.
https://wiki.kldp.org/KoreanDoc/html/EmbeddedKernel-KLDP/coltrol-flow.html

 

시스템 콜의 흐름

리눅스 내에서 시스템 콜이 발생하면 진행되는 흐름은 다음과 같다. 사용자 프로세스libc.a아규먼트 스택에 넣음시스템 콜 번호 저장트랩(trap) 발생system_call()IDT에 의해 트랩을 시작진짜 핸들러

wiki.kldp.org

 

참고로 OStep교재에서는 이 과정을 트랩 테이블(trap-table)과 트랩 핸들러(trap-handler) 정도로 간단히 짚고 넘어가는 것 같다. 커널이 부팅할 때 트랩 테이블을 만들고, 트랩 테이블에는 트랩 핸들러가 정의되어 있다. 그래서 만약 fork()라는 시스템 콜을 호출하면 이 시스템 콜에 해당하는 트랩 테이블의 칸으로 가서 트랩 핸들러의 주소를 알아낸다.  그리고 이 주소로 분기한다.

이렇게 명령어를 실행해서 프로세스가 요청한 작업을 모두 처리하면, 운영체제는 return-from-trap이라는 특수 명령어를 호출한다. 이 명령어는 trap과 반대되는 것으로, 특권 수준을 사용자 모드로 다시 하향 조정하면서 호출한 사용자 프로그램으로 리턴한다. 

이렇게 하드웨어가 trap을 수행할 때 주의해야 할 점이 몇 가지있는데, trap을 호출한 프로세스의 레지스터들을 저장해야 한다는 것이다. 운영체제가 return-from-trap을 수행했을 때 다시 제대로 돌아갈 수 있어야 하기 때문이다. 다시 돌아가기 위해 필요한 최소한의 레지스터들(ex.PC)만 커널 스택(kernel stack)에 저장을 한다. 이렇게 저장해두었던 레지스터들은 return-from-trap 수행 시 커널 스택에서 pop해서 다시 CPU레지스터에 덮어쓴다.

다시 정리해보자. LDE 프로토콜은 두 단계로 나눌 수 있다.

  • 전반부(부팅 시)
    • 커널은 트랩 테이블을 초기화, CPU는 테이블 내 트랩테이블의 위치를 기억(트랩핸들러 주소를 다 알고 있음)
  • 후반부(프로세스를 실행할 때)
    • return-from-trap 명령어를 통해 CPU를 사용자 모드로 전환하고 프로세스 실행을 시작
    • 프로세스가 시스템콜을 호출하면 운영체제로 다시 트랩된다.
    • 운영체제는 시스템 콜을 처리하고 return-from-trap 명령어를 사용하여 다시 제어를 프로세스에게 넘긴다.
    • 프로세스는 이후 자신의 할 일을 다하면 main()에서 리턴한다. 이때 일반적으로 스텁 코드로 리턴, 스텁 코드가 프로그램 종료시킴.
    • exit()을 호출해서 다시 운영체제로 트랩
    • 끝!

 


📍 직접 실행 프로토콜의 문제점 2 : 프로세스 간 전환  하드웨어의 도움, 타이머 인터럽트!

직접 실행은 프로세스 간에 전환을 할 수 없다. 어떤 프로세스가 CPU에서 실행되고 있을 때, 운영체제가 프로세스로부터 CPU를 어떻게 다시 획득할 수 있는가? 프로세스가 협조 하느냐 마냐에 따라 두 가지 방식이 있다.

  • 협조 방식 : 시스템 콜 기다리기
    • 그냥 프로세스를 신뢰하고 기다리는 것이다. 너가 CPU를 양보할 줄도 아는 착한 프로세스겠지..라고 믿는 것이다.
    • 착한 프로세스일 경우, 프로세스는 시스템 콜을 호출하여 CPU 제어권을 운영체제에게 넘겨준다. (yield라는 시스템 콜이 있다.)
    • 혹은 어떤 수를 0으로 나누거나 접근할 수 없는 메모리에 접근하려고 하는 등의 비정상적인 행위를 한다면 운영체제로 트랩이 일어난다.
    • 나쁜 프로세스거나, 의도치못하게 버그에 걸려 무한루프를 돌게 되면 어떡해? 유일한 방법이 컴퓨터를 다시 부팅하는 것이다.. 이건 합리적인 해결책이 아니다.
  • 비협조 방식 : 운영체제가 전권을 행사, 하드웨어의 도움
    • 하드웨어가 도와줘야만 한다. 타이머 인터럽트(timer interrupt)를 사용한다.
    • 인터럽트가 발생하면 프로세스는 중단되고 인터럽트 핸들러(interrupt handler)가 실행된다. 이 시점에서 운영체제는 CPU 제어권을 다시 가진다.

 

어떤 방식으로든지 프로세스를 전환시키기로 결정했다면, 문맥 교환(context switch)을 해야 한다. 문맥 교환이라고 알려진 코드를 실행하면 된다. switch() 루틴을 호출하면 실행 중이던 프로세스의 레지스터를 해당 프로세스의 PCB(Process Control Block)에 옮긴다. 그리고 실행 중이던 커널 스택이 아니라 새로 실행할 프로세스의 커널 스택을 사용하도록 스택 포인터를 바꾼다.

메모리에는 커널 공간(Kernel Space)가 있고 이 안에는 프로세스 별로 커널 스택이 존재한다. PCB는 이 커널 스택 안에 존재한다. PCB는 Process Control Block으로, 프로세스의 관리를 위한 정보를 가지고 있는 운영체제 커널의 자료 구조이다.

문맥 교환의 코드이다.

 

지금까지 공부한 내용을 역할을 구분해 순서대로 나타내면 아래와 같다.

 

 

이번주 모각코에서는 운영체제를 복습하는 시간을 가졌다. 시험 때 이 부분이 어려웠었는데 블로깅하면서 좀 더 정리할 수 있었다!