</>PatchNote
목록으로

2023-05-02

운영체제 #6 — xv6의 trap과 시스템 콜

Operating SystemOS 2023xv6InterruptSystem Call

이론에서 "시스템 콜을 호출하면 trap이 발생하고 mode bit이 kernel mode로 바뀐다"를 배웠다. 실습에서는 그게 xv6 코드 어디에 있는지 찾는다.

커널 모드로 넘어가는 세 가지 경우

유저 모드에서 커널 모드로 control이 전이되는 경우는 셋이다.

  • system call — 유저가 OS에 서비스를 요청할 때.
  • exception — 유저 프로그램이 잘못된 action을 할 때. 잘못된 메모리 주소 접근, 0으로 나누기 같은 것. OS가 마무리 처리를 해줘야 하기 때문에 전이된다.
  • interrupt — OS에 어떤 일이 있다고 알려주는 event. 보통 장치가 관심을 가져달라고 signal을 보낼 때 쓴다.

이 셋 모두 interrupt handling을 통해 처리된다.

interrupt란 하드웨어나 소프트웨어가 당장 주목받아야 할 때 프로세서에게 보내는 신호다. 명령어는 fetch, decode, execute 세 단계로 수행되는데, check interrupt 단계에서 interrupt가 발생했다면 현재 상태(레지스터 등)를 수행 중인 프로세스의 stack에 저장한다. 그리고 어떤 interrupt인지 알아내 ISR(interrupt service routine)로 점프한다. 앞에서 레지스터를 저장한 이유는 ISR을 마친 뒤 원래 흐름으로 돌아가기 위해서다.

interrupt handling을 하려면 OS가 네 가지를 지원해야 한다.

  1. 현재 프로세서의 레지스터 값을 저장한다.
  2. interrupt는 kernel mode에서 동작해야 하므로 그 준비를 한다.
  3. 이 interrupt가 어떤 interrupt인지에 대한 정보를 받을 수 있어야 한다.
  4. 유저와 커널 사이에 적절한 isolation을 유지한다. interrupt는 커널 모드에서 작동하므로 유저 프로그램이 커널에 간섭하지 못하게 해야 한다.

int n과 레지스터

interrupt가 수행될 때 가장 먼저 실행되는 명령어는 int n이다. n번째 interrupt를 발생시킨다.

  • eip — 다음에 실행해야 할 instruction이 위치한 주소를 담는다.
  • esp — 하나의 스택 프레임의 끝 부분 주소, 즉 다음에 데이터를 저장할 위치를 가리킨다. 스택에 데이터가 들어갈수록 esp 값은 작아진다. 주소 공간을 위에서 아래로 그려보면 자연스럽다.

스택 프레임에 쌓이는 순서

main에서 sum(num1, num2, num3)을 호출했다면, num3, num2, num1, return address 순으로 stack에 push된다. 따라서 num3가 가장 큰 주소값, return address가 가장 작은 주소값을 갖는다.

num2를 스택 프레임에서 가져오고 싶다면(int라고 가정) esp + 4 + 2*4 위치를 읽으면 된다. 데이터는 낮은 주소부터 큰 주소로 쓰이므로 esp + 4 + 2*4부터 그 +3까지가 num2다.

IDT와 tvinit

각각의 interrupt는 각자의 entry point를 가진다. entry point는 실제로 시작하는 주소다. 이 주소들을 담고 있는 테이블이 **IDT(Interrupt Descriptor Table)**다.

부팅하면 main.c에서 tvinit()이 호출된다. trap vector init이라는 뜻이고, trap vector는 interrupt의 entry point를 의미한다. tvinit()은 IDT에 trap vector를 채운다. trap vector들은 vectors.S에 정의되어 있고, 각 trap number마다 짧은 어셈블리 코드의 주소가 들어 있는 배열이다. make를 하면 생성된다.

int n 명령어가 끝나면 eip가 이 주소로 세팅된다.

용어를 정리하면, interrupt는 하드웨어적 흐름의 변화(일반적 의미의 interrupt)이고 trap은 소프트웨어적 흐름의 변화다. 시스템 콜도 소프트웨어적 interrupt로 볼 수 있다. 그리고 비어 있는 IDT 엔트리에 새로운 interrupt를 만들 수 있다. 이건 Project 1에서 실제로 써먹는다.

CPL과 DPL

x86에는 privilege level이 있다. 현재 동작 중인 프로세스가 얼마나 큰 접근 권한을 가지는지를 나타낸다. xv6에서는 0(kernel mode)과 3(user mode)만 쓴다. 숫자가 작을 수록 권한이 크다.

  • CPL(Current Privilege Level) — cs 레지스터에 2비트를 차지한다. 현재 권한 레벨이 무엇인지를 나타낸다.
  • DPL(Descriptor Privilege Level) — int n에 해당하는 interrupt를 실행하기 위한 최소 권한이다. IDT에 들어 있다.

그래서 CPL이 DPL보다 숫자적으로 작거나 같아야 interrupt 호출에서 서비스를 수행할 수 있다. 유저 모드에서 실행 중인 프로세스가 커널 모드로 전환하면 CPL은 0으로 설정된다. DPL이 0으로 설정되어 있다면 CPL이 0인 프로세스만 접근할 수 있다.

tvinit() 코드를 보면 SETGATE의 마지막 인자가 DPL인데, 모든 interrupt는 DPL = 0으로 설정하고 마지막에 T_SYSCALLDPL_USER(3)를 넣어준다. 시스템 콜만 유저 모드에서 호출할 수 있어야 하기 때문이다.

int n의 수행 과정

  1. IDT에서 n번째 descriptor를 가져온다.
  2. 현재 cs의 CPL이 IDT의 DPL보다 작거나 같은지 확인한다.
  3. DPL이 CPL보다 권한이 센 경우 PL을 user → kernel로 바꿔야 한다. 이건 유저 모드에서 interrupt를 발생시켰을 때를 의미한다. 커널 모드에서 발생했다면 레벨 변경이 필요 없다.

alltraps

int n이 끝나면 모든 handler는 alltraps로 점프한다. xv6에서는 모든 ISR이 alltraps를 호출한다. alltraps는 CPU 레지스터를 스택 프레임에 push하고 trap을 호출한다. 이때 저장하는 레지스터는 trap에 필요한 스택 프레임을 만드는 것이다.

유저 프로그램에서 시스템 콜까지

실습에서 — user mode에서 시스템 콜 함수의 코드를 복사해서 실행해도 시스템 콜이 정상적으로 실행되지 않는다는 걸 확인하는 문제가 있었다.

usys.S는 매크로다. 유저 모드에서 해당 함수를 호출하면 SYSCALL(name)이 호출된다. 그리고 뒤에서 배운 내용에 따르면 시스템 콜을 호출하면 int 64를 하게 되고, SETGATE를 통해 64번째 엔트리는 DPL을 3으로 설정해서 user mode에서도 interrupt handling에 접근할 수 있도록 되어 있다.

즉 시스템 콜 코드를 유저 공간에 복사한다고 해서 되는 게 아니다. 커널 모드로 넘어가는 게이트를 통과해야 하고, 그 게이트가 DPL 3으로 열려 있는 64번 엔트리다. 이론에서 배운 "시스템 콜은 OS 코드다, 권한이 다르다"가 여기서 코드로 확인된다.

xv6 부팅 과정

실습에서 — xv6의 부팅 전 과정을 자세히 설명하라는 문제가 나왔다.

  1. xv6가 부팅될 때 먼저 BIOS라 불리는 프로그램이 시작된다.
  2. BIOS는 하드웨어의 상태를 체크하고 boot block을 디스크에서 메모리로 load 한다. boot block은 boot disk의 첫 512 바이트에 위치한다. 안에는 boot loader가 들어 있는데, 커널을 메모리로 load하는 명령어들이다. boot loader는 bootasm.Sbootmain.c로 이루어져 있다.
  3. boot block이 메모리에 load되면 BIOS는 boot loader의 주소로 점프한다. eip를 boot loader의 주소(0x7c00)로 세팅하는데, 이건 boot loader의 start function의 주소다.
  4. bootmain에서 커널의 entry point로 점프한다.
  5. main()이 몇몇 장치와 subsystem을 초기화하고 첫 프로세스를 만든다.
  6. kinit1()은 kernel image의 끝 virtual address(end)부터 4MB까지의 page들을 free list에 추가한다. 이 페이지들은 kvmalloc()부터 startothers() 사이에서 kalloc에 의해 할당된다.
  7. kvmalloc()은 kernel page table을 만들고 그것으로 switch한다.
  8. tvinit()이 IDT를 초기화한다. (앞에서 본 그 함수다.)
  9. kinit2()가 4MB부터 PHYSTOP까지의 page들을 free list에 추가한다.
  10. userinit()을 통해 첫 프로세스를 만든다.

tvinit()이 부팅 흐름 안에서 어디쯤 불리는지가 여기서 보인다. 페이지 할당자를 반쯤 세팅하고, 커널 페이지 테이블을 만들고, 그다음에 인터럽트를 받을 준비를 한다.