System Call

어플리케이션에서 OS의 기능 사용

커널에는 화면에 글자를 출력하는 printk 함수를 가지고 있다.
어플리케이션에서 화면에 글자를 출력하고 싶으면, 커널의 함수를 호출해야 하는데 일반적으로 다른 바이너리의 함수를 호출하는건 불가능하겠지만 같은 가상주소를 공유(?) 하고 있는 커널함수는 가능하다.

앱에서 OS 화면에 출력하기

rpn을 실행하면 graphics.hppfont.hpp에 정의된 FillRectangle, WriteString을 사용하여 "Hello RPN World" 를 운영체제 화면에 출력할 것이기 때문에 커널 바이너리에서 필요한 가상주소를 먼저 찾아와야 한다.

nm 은 바이너리에 포함된 심볼들을 출력하는 도구이고, -C 는 맹글링된 이름을 디맹글링 해주는 옵션이다.

  • 심볼타입: T == .text, B == .bss

d800cdfa-6ec2-48be-a7bd-50980762c45c
d800cdfa-6ec2-48be-a7bd-50980762c45c

decltype은 컴파일 타임에 헤더의 정보로 심볼에 해당하는 타입으로 교체하는 작업이다.
나머지 함수도 printk 처럼 타입을 지정해도 되지만 길어지기 때문에 헤더를 추가하고 decltype으로 가져왔다.

// rpn.cpp
...
#include "../../kernel/graphics.hpp"
#include "../../kernel/font.hpp"

// 커널의 주소를 그대로 가져와서 호출
auto& printk = *reinterpret_cast<int (*)(const char*, ...)>(0x00000000001108e0);
auto& fill_rect = *reinterpret_cast<decltype(FillRectangle)*>(0x0000000000148e10);
auto& write_str = *reinterpret_cast<decltype(WriteString)*>(0x0000000000148d30);
auto& scrn_writer = *reinterpret_cast<decltype(screen_writer)*>(0x00000000002630c8);

...
extern "C" int main(int argc, char** argv) {
  ...
  fill_rect(*scrn_writer, Vector2D<int>{100, 10}, Vector2D<int>{200, 200}, ToColor(0x66ff66));
  write_str(*scrn_writer, Vector2D<int>{100 + 50, 10 + 50}, "Hello RPN World!", ToColor(0xff66ff));

  return static_cast<int>(Pop());
}

결국엔 타입 모양과 가상주소만 알고 있으면, 커널의 함수라도 유저 앱이 직접 호출해서 문제가 많아진다.

fab4ac56-2a2c-43e9-a7d5-7bd3bbd96a96
fab4ac56-2a2c-43e9-a7d5-7bd3bbd96a96

x86 에서의 커널 보호 방식

x86-64 아키텍쳐에는 0~3 까지의 권한 레벨이라는 개념이 있고, 각 레벨마다 실행할 수 있는 명령어가 다르다.
ex) ring 3은 hlt, lgdt, mov crX 등의 명령을 실행할 수 없음

CPU는 현재 실행되고있는 코드 세그먼트에서 CPL(Current Privilege Level)을 확인하고 명령을 통해 실행되는 세그먼트나 게이트가 요구하는 DPL(Descriptor Privilege Level) 을 비교하여 권한이 맞지 않는 경우 GP(General Protection Fault) 예외를 발생시킨다.

64bit(long mode) 에서는 세그먼트 보호 방식보다는 페이징 보호 방식을 사용한다.
페이지테이블 엔트리에 있는 U/S bit 가 1인 경우에만 ring 3 (CPL=3) 에서 접근할 수 있고, 권한이 안되면 PF(Page Fault)가 발생한다.

CPL의 세팅 (Segment Selector)

현재 우리의 운영체제에서 인터럽트를 초기 세팅할때 IDT(Interrupt Descriptor Table)에 인터럽트 조건에 따라 실행될 핸들러 함수와 함수가 존재하는 세그먼트셀렉터(kKernelCS)를 세팅한다.

dc76340d-c691-4371-929a-2a911ad9ed47
dc76340d-c691-4371-929a-2a911ad9ed47

인터럽트가 발생하면 IDT[InterruptVector].segment_selector로 GDT를 가져온 후 CPU의 CPL과 GDT의 DPL 을 비교하여 권한을 체크하고 segment_selector의 RPL로 CPU의 CPL을 세팅하게 된다.

kKernelCS = 1 << 3 이기 때문에 TI와 RPL이 0 이다.

GDT는 특정 메모리 영역에 대한 권한 등의 정보가 담긴 테이블이라고 볼 수 있다.

이 모든 작업은 IDT, GDT를 세팅해주면 CPU가 특정 상황(인터럽트 등)이 발생했을 때 알아서 한다.
ring 3 &rarr ring 0 경로는 하드웨어 인터럽트(DPL 체크안함)를 사용하거나 시스템 콜(int 0x80)로 DPL 3 을 통해 RPL 0 이 세팅된 경로를 운영체제에서 일부러 뚫어서 권한상승이 이뤄진다.

  • CPL: 현재 실행중인 코드의 권한 레벨
  • DPL: 세그먼트, 게이트가 요구하는 권한 레벨. 현재 CPL과 비교해서 실행 가능한지 체크하는 용도이다.
  • RPL: “CS” 세그먼트셀렉터가 지정한 권한 레벨. 결국엔 이걸로 CPL이 세팅되기 때문에 이걸로 바꿔줘 라는 요청의 의미가 담겨있다.

1. 유저권한 메모리 세팅

원래 커널에 대한 GDT만 초기화 했었는데, DPL 3 권한의 코드, 데이터 세그먼트를 GDT에 추가해야 한다.

void SetupSegments() {
  gdt[0].data = 0;
  SetCodeSegment(gdt[1], DescriptorType::kExecuteRead, 0, 0, 0xfffff);
  SetDataSegment(gdt[2], DescriptorType::kReadWrite, 0, 0, 0xfffff);
                                                   // DPL
  SetCodeSegment(gdt[3], DescriptorType::kExecuteRead, 3, 0, 0xfffff);
  SetDataSegment(gdt[4], DescriptorType::kReadWrite, 3, 0, 0xfffff);
  LoadGDT(sizeof(gdt) - 1, reinterpret_cast<uintptr_t>(&gdt[0]));
}

cs, ss 셀렉터들을 원하는 걸 넣어서 앱을 실행시킬 수 있는 CallApp 함수를 추가한다. 여기에서 각각의 GDT인덱스를 지정하고 RPL이 3인 cs, ss를 전달하면 앱은 CPL이 3인 상태에서 실행될 것이다.

현재는 ring 0이기 때문에 mov로 cs, ss 를 세팅해도 될 것 같지만, intel 매뉴얼을 기준으로 CPL이 변경되지 않는 경우에만 mov가 허용된다. (GP 예외발생)
그래서 낮은권한으로의 세그먼트 변경은 반드시 far return 방식으로 두 세그먼트를 동시에 설정해야한다.

global CallApp  ; void CallApp(int argc, char** argv, uint16_t cs, uint16_t ss, uint64_t rip, uint64_t rsp);
CallApp:
    push rbp
    mov rbp, rsp
    push rcs    ; SS
    push r9     ; RSP
    push rdx    ; CS
    push r8     ; RIP
    o64 retf

위에서 말했듯 x86-64에서는 CPL이 3일 때 메모리 PTE에서 U/S bit 가 1로 세팅되어야 PF 예외가 발생하지 않는다고 했다.
SetupPageMap 함수에서 유저앱의 페이지테이블을 만들 때 user 플래그를 세팅해줘야 한다.
커널의 페이지는 U/S bit가 0이기 때문에 유저앱에서 커널메모리에 접근하려하면 PF가 발생한다.

page_map[entry_index].bits.user = 1;

2. 스택, 인자 세팅, CallApp

유저권한 메모리를 세팅하는 연장선이다.
지금까지는 앱 실행 시 rsp를 세팅해주지 않았기 때문에 커널의 스택 공간을 사용하고 있었고, CallApp 함수를 잘 보면 far return 할때 RSP도 함께 세팅되기 때문에 이걸 활용해서 U/S bit를 세팅한 스택공간을 전달해줘야 한다.

마찬가지로 argv도 MakeArgVector 함수를 통해 커널의 std::vector<char*> argv 에 저장되는데, 컨테이너는 커널의 스택영역이고 내부에선 new가 호출되어 동적으로 관리되니 데이터는 커널의 힙 영역을 사용한다.

일단 함수 호출 과정은 이렇게 변한다.
U/S bit가 설정된 stack_frame_addr, args_frame_addr 를 각각 1 page 씩 할당하고, args 는 최대 32개만 고정값으로 받게된다.

argv는 MakeArgVector 함수에서 각각의 파라미터로 전달된 문자열 포인터를 앞에다가 배치하고(널널하게 32개), 실제 문자열 데이터는 뒤쪽에 배치하면서 args_frame_addr 페이지를 채운다.

  LinearAddress4Level stack_frame_addr{0xffff'ffff'ffff'e000};
  if (auto err = SetupPageMaps(stack_frame_addr, 1)) {
    return err;
  }

  LinearAddress4Level args_frame_addr{0xffff'ffff'ffff'f000};
  if (auto err = SetupPageMaps(args_frame_addr, 1)) {
    return err;
  }

  // 0xf000                       0xf100                              0xffff
  // |-- char* argv[32] (256 byte) --|----... argbuf (0xf00 byte) ...----|
  auto argv = reinterpret_cast<char**>(args_frame_addr.value)
  int argv_len = 32;  // argv = sizeof(char**) * 32 = 256bytes
  auto argbuf = reinterpret_cast<char*>(args_frame_addr.value + sizeof(char**) * argv_len);
  int argbuf_len = 4096 - sizeof(char**) * argv_len;
  auto argc = MakeArgVector(command, first_arg, argv, argv_len, argbuf, argbuf_len);
  if (argc.error) {
    return argc.error;
  }

  auto entry_addr = elf_header->e_entry;
  CallApp(argc.value, argv, 3 << 3 | 3, 4 << 3 | 3, entry_addr,
      stack_frame_addr.value + 4096 - 8);

스택도 유저공간에서 사용하고, rsp도 유저공간에서 사용되는 것을 볼 수 있다.

0f0bb311-4d25-44d8-b786-5e2b39b213bc
0f0bb311-4d25-44d8-b786-5e2b39b213bc

디버깅 시 유의

유저어플리케이션에서 ni를 한번 실행하면 PF 에러가 발생했었다.

확인해보니 si는 정상적으로 동작하는데 ni만 문제되는 상황이였고, main함수의 프롤로그가 실행되지 않아서 스택이 완성되기 이전이라면 ni가 명령어 하나가 아닌 브레이크까지 그냥 쭉 실행되는 문제가 있었다.

원인은 잘 모르겠지만, 프롤로그가 지나야 ni가 정상적으로 동작했다.

TSS 설정

CPU가 권한레벨을 변경할 때 참고하는 태스크(CPU) 상태를 저장해둔 세그먼트이다.

예전에는 태스크 전환도 x86 CPU가 하드웨어적으로 해주길 기대했고, 컨텍스트 스위칭을 위한 모든 정보가 TSS에 저장되어 있었다.
요즘은 컨텍스트 스위칭이 소프트웨어적으로 구현되어있지만, 아직도 유저모드(CPL=3)에서 커널모드(CPL=0)으로 전환되면서 스레드별로 커널 스택을 지정할때는 TSS.RSP0 값이 사용된다. (인터럽트 등)

TR 레지스터가 가리키는 주소(TSS)의 104 바이트 중 0x4:0xC 영역이 TSS.RSP0 영역이 되는데, 지금은 TR레지스터가 0이라서 0x4:0xC 주소의 값이 커널모드 전환 시 RSP에 복원되고 있었다.

커널의 RSP가 0이되면 스택이 아래로 자라면서 주소가 오버플로우 되어 이전에 맨 마지막 페이지로 설정한 앱 ARGV 영역을 덮어쓰게된다. 그것을 방지하기 위해 TSS를 지정해줘야 한다.

TSS 세팅하기

싱글코어 시스템이기 때문에 그냥 8페이지 크기의 영역을 할당해서 TSS에 세팅한다.

GDT는 8byte짜리 구조체로 base 주소 지정에는 4byte만 사용하는데, 64bit에서는 segmentation에서 주소를 지정하지 않고 0으로 초기화되기 때문에 굳이 구조체 크기가 늘어나지 않았다.

유일하게 TSS에서만 실제 8byte 주소를 담아야해서 ltr로 TR에 로드할때 연속된 두개의 GDT 엔트리 슬롯(16byte)을 사용해서 TSS주소를 담게 된다.

global LoadTR
LoadTR: ; void LoadTR(uint16_t sel);
    ltr di
    ret

const uint16_t kTSS = 5 << 3;

void SetSystemSegment(SegmentDescriptor& desc,
                      DescriptorType type,
                      uint32_t base,
                      uint32_t limit) {
  SetCodeSegment(desc, type, descriptor_privilege_level, base, limit);
  desc.bits.system_segment = 0;
  desc.bits.long_mode = 0;
}

void InitializeTSS() {
  const int kRSP0Frames = 8;   // 32kb
  auto [ stack0, err ] = memory_manager->Allocate(kRSP0Frames);
  if (err) {
    Log(kError, "failed to allocate rsp0: %s\n", err.Name());
    exit(1);
  }
  uint64_t rsp0 =
    reinterpret_cast<uint64_t>(stack0.Frame()) + kRSP0Frames * 4096;
  // tss의 0x4:0xC 영역에 rsp0 주소 저장
  tss[1] = rsp0 & 0xffffffff;
  tss[2] = rsp0 >> 32;

  // tss 주소를 연속된 두개의 GDT 엔트리 슬롯에 나눠서 저장
  uint64_t tss_addr = reinterpret_cast<uint64_t>(&tss[0]);
  SetSystemSegment(gdt[kTSS >> 3], DescriptorType::kTSSAvailable, 0,
    tss_addr & 0xffffffff, sizeof(tss) - 1);
  gdt[(kTSS >> 3) + 1].data = tss_addr >> 32;
  LoadTR(kTSS);
}

컨텍스트 스위칭 수정

현재 컨텍스트 스위칭

  1. 태스크가 실행되는 중에 지정한 타이머가 만료되면 인터럽트 주소에 값이 세팅되고 CPU는 인터럽트가 발생했다는것을 알게된다.
  2. RIP, CS, RFLAGS 를 현재 태스크 스택에 푸시하고 InterruptFrame 형태로 묶어서 인터럽트 함수로 점프한다.
    이때 User → Kernel 전환이라면 TSS.RSP0 주소의 커널스택으로 전환 후 푸시한다. (추가로 RSP, SS 까지 푸시한다.)
    __attribute__((interrupt))
    void IntHandlerLAPICTimer(InterruptFrame* frame) {
      LAPICTimerOnInterrupt();
    }
    
  3. 이후부터는 소프트웨어의 영역이다. 틱을 실행하면서 태스크 타이머의 만료인지 확인하고, 만료됐다면 SwitchTask를 실행하게 된다.
    SwitchContext에서는 이전 태스크의 레지스터들을 rsi에 저장하고, 다음 컨텍스트를 rdi에서 복원한다.
    Task* next_task = running_[current_level_].front();
    SwitchContext(&next_task->Context(), &current_task->Context());
    
    global SwitchContext
    SwitchContext:    ; 
        mov [rsi + 0x40], rax
        mov [rsi + 0x48], rbx
        ...
        lea rax, [rsp + 8]
        mov [rsi + 0x70], rax  ; RSP
        mov [rsi + 0x78], rbp
        mov rax, [rsp]
        mov [rsi + 0x08], rax  ; RIP == SwitchContext의 ret addr
        ...  
        ; iret 을 위한 스택프레임 세팅 
        push qword [rdi + 0x28] ; SS
        push qword [rdi + 0x70] ; RSP
        push qword [rdi + 0x10] ; RFLAGS
        push qword [rdi + 0x20] ; CS
        push qword [rdi + 0x08] ; RIP
        mov rax, [rdi + 0x40]
        mov rbx, [rdi + 0x48]
        ...
        o64 iret
    
  4. iret이 실행되면 실행할 태스크의 컨텍스트로 CPU가 덮어쓰여진다. 첫 실행이라면 엔트리포인트에서 시작되고, 스케줄링에 의해 실행됐다면 SwitchContext의 ret addr부터 실행되기 때문에 TaskManager의 스택이 남을 것을 걱정하지는 않아도된다.

문제점

User → Kernel 의 경우 TSS.RSP0 이 가리키는 하나의 커널 스택에서 인터럽트나 시스템콜 등이 처리될 것이다.

인터럽트가 발생해서 SwitchTask가 호출됐다면 현재 RIP, CS, RFLAGS, RSP, SS 가 스택에 저장되고, 인터럽트 핸들러로 이동된다.
SwitchContext가 호출되면서 현재 컨텍스트가 객체에 저장되고 나중에 다시 CPU를 점유할때 복원된다.

유저 프로세스가 여러개 실행되는 상황을 생각해보자. 동일한 커널스택을 사용하기 때문에 user Auser Buser C 이렇게 전환되면, B에서 C로 전환될 때 RSP0으로 커널스택이 세팅되면서 이전에 쓰던 내용들이 덮어쓰여질 수도 있다.

또 SwitchContext에 의해 커널스택으로 RSP가 세팅되어 페이지폴트가 발생하는 문제도 있다.

결국 커널 스택을 유저 태스크의 일부 상태로 간주하는 설계가 잘못된 것이다.

코드 수정

권한변경이 시작되자마자 TSS.RSP0으로 변경되기 때문에 태스크 전환이 필요한 경우엔 인터럽트에 의해 push된 RSP 값을 어떻게든 얻어서 SwitchTask에 전달해줘야 한다.

extern "C" void LAPICTimerOnInterrupt(const TaskContext& ctx_stack) {
  const bool task_timer_timeout = timer_manager->Tick();
  NotifyEndOfInterrupt();

  if (task_timer_timeout) {
    task_manager->SwitchTask(ctx_stack);
  }
}

타이머 인터럽트 함수에서 LAPICTimerOnInterrupt를 호출해야 하는데, InterruptFrame과 TaskContext는 구조가 다르기 때문에 인터럽트가 발생하면 TaskContext에 InterruptFrame 의 값을 채워줘야 한다.

인터럽트가 발생하자마자 유저 태스크 컨텍스트의 모든 레지스터를 백업할 수 있도록 인터럽트 함수를 어셈블리로 구현한다.
(C로 작성하면 프롤로그 등에 의해 깔끔한 유저태스크가 아니게됨)

global IntHandlerLAPICTimer
IntHandlerLAPICTimer:  ; void IntHandlerLAPICTimer();
    push rbp
    mov rbp, rsp

    ; TaskContext 형태에 맞춰서 스택에 push해두고 LAPICTimerOnInterrupt 함수 호출
    sub rsp, 512
    fxsave [rsp]
    push r15
    ...
    push r8
    push qword [rbp]         ; RBP
    push qword [rbp + 0x20]  ; RSP
    push rsi
    ...
    push rax

    mov ax, fs
    mov bx, gs
    mov rcx, cr3

    ; 인터럽트 발생 시점에 push된 레지스터들은 rbp를 통해 가져온다.
    push rbx                 ; GS
    push rax                 ; FS
    push qword [rbp + 0x28]  ; SS
    push qword [rbp + 0x10]  ; CS
    push rbp                 ; reserved1
    push qword [rbp + 0x18]  ; RFLAGS
    push qword [rbp + 0x08]  ; RIP
    push rcx                 ; CR3

    ; rsp를 ctx_stack으로 지정함. void LAPICTimerOnInterrupt(const TaskContext& ctx_stack)
    mov rdi, rsp
    call LAPICTimerOnInterrupt

    add rsp, 8*8  ; CR3 부터 GS 까지 무시
    pop rax
    ...
    pop rsi
    add rsp, 16   ; RSP, RBP를 무시
    pop r8
    ...
    pop r15
    fxrstor [rsp]

    mov rsp, rbp
    pop rbp
    iretq

SwitchTask 에서는 컨텍스트의 백업과 복원을 하게된다. RotateCurrentRunQueue 는 태스크 큐에서 현재 실행중인 태스크를 큐의 맨 뒤로 보내는 작업을 한다. (스케줄링)

void TaskManager::SwitchTask(const TaskContext& current_ctx) {
  // 실행중이던 컨텍스트 값 업데이트. (task_ctx는 과거의 값임)
  TaskContext& task_ctx = task_manager->CurrentTask().Context();
  memcpy(&task_ctx, &current_ctx, sizeof(TaskContext));

  // 이전 SwitchTask 작업. 작업 큐 로테이트해서 새로운 태스크 할당 (없으면 그대로)
  Task* current_task = RotateCurrentRunQueue(false);
  // CurrentTask()는 로테이트 이후라서 실행할 태스크, current_task는 로테이트 전 태스크
  if (&CurrentTask() != current_task) {
    // CurrentTask의 컨텍스트를 CPU에 복원
    RestoreContext(&CurrentTask().Context());
  }
}

유저 태스크에서 인터럽트 핸들러 함수 IntHandlerLAPICTimer 에 들어온 직후의 스택 모습이다. [rbp+0x08] = RIP, [rbp+0x20] = RSP 값인 것을 확인할 수 있다.

fc4b5672-eaa2-402d-bd56-2bd5e8d6a3be
fc4b5672-eaa2-402d-bd56-2bd5e8d6a3be

System Call

커널영역은 페이지테이블의 U/S bit가 0이기 때문에 ring3에서 접근할 수 없어서 유저앱은 커널코드를 실행할 수 없게된다.

시스템콜을 구현하는 방법도 여러가지인데, syscall 명령을 구현하거나 인터럽트를 활용할 수도 있고, io_uring 메모리 큐 방식도 있다.

syscall 방식 구현

syscall 명령은 ring3 의 낮은 권한 코드에서 ring0 의 코드를 실행할 수 있도록 도와주는 어셈블리 명령어이다.

eax에 지정한 번호에 해당하는 ring0 코드를 실행하게 되는데, 운영체제에서 매핑해둔 ring0 명령만 실행할 수 있도록 게이트 역할을 하는 함수인 것이다.

1. 유저 태스크 측 Syscall 함수

bits 64
section .text

global SyscallLogString
SyscallLogString:
    mov eax, 0x80000000
    mov r10, rcx
    syscall
    ret

asm으로 구현된 함수더라도 c++에서 함수 호출 시 호출자가 call 명령 실행하기 전에 인자들을 RDI, RSI, RDX, RCX, R8, R9 순서로 저장하고 호출하고,
syscall 함수가 호출되면 CPU 사양에 의해 자동으로 RCX에 RIP, R11에 RFLAGS가 들어간다.

그래서 리눅스(System V AMD64 ABI) 규약에서는 시스템콜을 호출할 때 인자를 RDI, RSI, RDX, R10, R8, R9 순서로 넣는데 RCX만 다르기 때문에 편해서 MikanOS에서도 이 규약 따라간다.

리눅스에서 쓰는 방법처럼 RCX를 R10에 넣고 호출하면 인자를 모두 전달할 수 있게된다.

2. 시스템콜 등록

global WriteMSR
WriteMSR:        // void WriteMSR(uint32_t msr, uint64_t value);
  mov rdx, rsi
  shr rdx, 32    // edx에 rsi(value) 상위비트
  mov eax, esi   // eax에 rsi 하위비트(esi)
  mov ecx, edi   // ecx에 msr 저장
  wrmsr
  ret

static constexpr uint32_t kIA32_EFER  = 0xc0000080;
static constexpr uint32_t kIA32_STAR  = 0xc0000081;
static constexpr uint32_t kIA32_LSTAR = 0xc0000082;
static constexpr uint32_t kIA32_FMASK = 0xc0000084;

void InitializeSyscall() {
  // LMA(0x400) | LME(0x100) | syscall 명령 사용(0x1)
  WriteMSR(kIA32_EFER, 0x0501u);

  // IA32_LSTAR 레지스터: syscall이 실행됐을때 호출되는 엔트리함수를 등록
  WriteMSR(kIA32_LSTAR, reinterpret_cast<uint64_t>(SyscallEntry));

  // syscall, sysret 시 커널용 CS, SS과 스위칭하기 위한 값을 설정하는 레지스터
  // syscall 은 value에서 [47:32]의 값이 CS, [47:32]+8 의 값이 SS에 기록된다.
  // sysret도 이런 자체 규칙이라 자세한건 생략한다. 
  WriteMSR(kIA32_STAR, static_cast<uint64_t>(8) << 32 |
                       static_cast<uint64_t>(16 | 3) << 48);

  // syscall을 호출할때 RFLAGS 에서 지워버릴 비트
  // 보통 인터럽트 플래그를 지워서 시스템콜 실행중에는 인터럽트가 걸리지않도록 한다.
  WriteMSR(kIA32_FMASK, 0);
}

3. SyscallEntry

이 함수에서는 결국엔 syscall_table 에서 시스템콜 번호에 해당하는 함수포인터를 찾아와서 call 하는것이 목적이다.

함수 호출 전후로 레지스터를 백업하고 복원해서 원래 상태를 최대한 유지한다.

extern syscall_table
global SyscallEntry
SyscallEntry:
    push rbp
    push rcx  ; original RIP
    push r11  ; original RFLAGS

    mov rcx, r10
    and eax, 0x7fffffff
    ; 시스템콜 번호는 0x80000000 부터 시작하는데
    ; 값을 그대로 쓰면 table크기가 부담돼서 마스킹한다. -> 0 부터 시작
    mov rbp, rsp
    and rsp, 0xfffffffffffffff0
    ; 그냥 스택포인터가 무조건 16의 배수여야 한다는 System V AMD64 규약을 지키는것

    call [syscall_table + 8 * eax]
    ; rbx, r12-r15는 callee-saved라서 유지하지 않는다.
    ; rax는 반환값이라서 유지하지 않는다. 

    ; 내가 수정했던것만 다시 되돌려놓는다. 
    mov rsp, rbp

    pop r11
    pop rcx
    pop rbp
    o64 sysret    ; ret 하면서 RIP(RCX), RFLAGS(R11), SS 복원

4. 커널태스크 측 Syscall 함수

파라미터 2개만 사용하는데, 검증 후 Log 함수로 OS 화면에 글자를 표시해주는 “커널함수” 이다.

namespace syscall {

#define SYSCALL(name) \
  int64_t name( \
    uint64_t arg1, uint64_t arg2, uint64_t arg3, \
    uint64_t arg4, uint64_t arg5, uint64_t arg6)

SYSCALL(LogString) {
  if (arg1 != kError && arg1 != kWarn && arg1 != kInfo && arg1 != kDebug) {
    return -1;
  }

  const char* s = reinterpret_cast<const char*>(arg2);
  if (strlen(s) > 1024) {
    return -1;
  }
  Log(static_cast<LogLevel>(arg1), "%s", s);
  return 0;
}

#undef SYSCALL      // 실수로 사용하지 못하게

} // namespace syscall

using SyscallFuncType = int64_t (uint64_t, uint64_t, uint64_t,
                                 uint64_t, uint64_t, uint64_t);

extern "C" std::array<SyscallFuncType*, 1> syscall_table {
  /* 0x00 */ syscall::LogString,
};

5. IST 설정

syscall 명령은 유저 → 커널로 권한이 변경되어 TSS.RSP0이 사용될 줄 알았지만, 실제로는 변경해주지 않으면 유저스택을 그대로 사용한다고 한다.

만약 syscall을 통해 커널함수가 실행되는 중 인터럽트가 발생한다면 CPL=0 에서 RPL=0 이 요청되기 때문에 ring이 변하지 않고 스택이 TSS.RSP0 으로 전환되지 않아서 인터럽트 핸들러가 유저스택에서 실행돼버리는 버그가 발생한다.

지금은 페이지 테이블 하나만 공용으로 사용하고 있지만 이후 유저프로세스를 여러개 실행할 수 있게 구현하는 경우엔 프로세스마다 자신의 페이지테이블을 사용하게 된다.
타이머인터럽트가 유저스택에서 실행되면 컨텍스트 스위칭이 발생하면서 다른 프로세스의 CR3 값을 세팅해버리면서 사용중이던 스택 페이지가 unused 페이지로 변할 수 있다.

TSS.IST(interrupt stack table)는 x86-64 시스템에서 인터럽트 핸들러를 실행할때 사전에 설정해둔 스택을 반드시 사용하게 하는 기능이다.

// Timer 인터럽트용 IST는 TSS.IST1 이다. 
const int kISTForTimer = 1;

void InitializeTSS() {
  SetTSS(1, AllocateStackArea(8));    // TSS.RSP0 세팅 (8*4k 짜리)
  SetTSS(7 + 2 * kISTForTimer, AllocateStackArea(8));   // TSS.IST1 주소할당 (8*4k 짜리)
  ...
}

void InitializeInterrupt() {
  SetIDTEntry(idt[InterruptVector::kLAPICTimer],
              MakeIDTAttr(DescriptorType::kInterruptGate, 0 /* DPL */,
                          true /* present */, kISTForTimer /* IST */),
              reinterpret_cast<uint64_t>(IntHandlerLAPICTimer),
              kKernelCS);
  ... // 다른 인터럽트 추가 
}

syscall 사용

유저앱에서 시스템콜 함수를 사용해보면 syscall 어셈 명령어를 통해 커널의 CS, SS 로 스위칭(권한상승) 후 SyscallEntry(핸들러) 로 호출할 시스템콜 번호가 전달되면서 커널의 함수가 실행되는 원리이다.

rpn에서 작성한 코드로 운영체제 화면에 출력하는게 가능해졌다.

cfcb8ab5-3031-48de-803c-8da3e1d64642
cfcb8ab5-3031-48de-803c-8da3e1d64642

System Call 추가

터미널에 출력 (write)

현재 운영체제에는 동적링커가 없기 때문에 표준 라이브러리 함수가 static 링킹되어 앱에 포함된다.

9ea89035-7d65-43af-bcd4-997592912a41
9ea89035-7d65-43af-bcd4-997592912a41

Newlib이 표준라이브러리 역할을 한다. printf가 적당히 서식을 지정해서 extern 된 write(시스템콜 래퍼) 를 호출하도록 구현되어 있다.

간단하게 nm 으로 살펴보면 T는 해당 오브젝트 파일에 본체가 구현되어있는 것이고, U(undefined symbol)는 extern되어 외부에서 구현된것을 사용할 것 이라는 의미이다.
write 는 외부 함수이며, printf 함수들은 문자열을 포매팅 후 외부의 write 함수를 호출하게된다.

kdh@DESKTOP-MHEA7GE:~/min-os/devenv/x86_64-elf/lib$ nm libc.a | grep -B 10 -A 10 _write_r
lib_a-vdprintf.o:
                 U _free_r
                 U _impure_ptr
                 U _vasnprintf_r
0000000000000000 T _vdprintf_r
                 U _write_r
0000000000000080 T vdprintf

lib_a-writer.o:
0000000000000000 T _write_r
                 U errno
                 U write

커널이 빌드될때 newlib_support.cpp 에 write를 구현해두면 커널에서 printf 를 사용할 수 있게된다. 마찬가지로 앱 빌드 시 애플리케이션 용 newlib_support.cpp 를 작성해서 함께 빌드하면 앱에 newlib 의 printf 함수가 정적링킹된다.

시스템 콜을 통해 터미널에 write 하는건데, 터미널이 많아졌을때 어떤 터미널에 write할지도 결정할 수 있어야한다. 어플리케이션도 하나의 Task이기 때문에 TaskID로 식별하는 것이 좋다.

구현해야하는 write 함수는 newlib의 헤더파일을 보면 시그니쳐를 확인할 수 있다.

printf 코드를 추가했더니 newlib에 포함된 일부 오브젝트가 -mcmodel=small 로 빌드되어 있어서 에러를 만들었다. 우리의 앱은 0xffff8000... 베이스 주소를 사용하기 때문에 -mcmodel=large 옵션을 추가해서 newlib을 재빌드 해야한다.

프로그램 종료 (exit)

유저 어플리케이션은 터미널에서 CallApp 함수를 호출하며 실행됐다. 레지스터들을 스택에 세팅해둔 후 retf 명령으로 점프하며 유저앱이 실행되도록 했다.

유저 앱이 종료된다는 것은 ring3에서 ring0으로 권한 변경 후 CallApp 호출 직후로 상태를 되돌려 놓은 후 다시 유저앱으로 돌아오지 않는 것이다.

끝나지 않는 시스템콜을 호출하면 권한 변경과 유저앱으로 돌아오지 않는 것은 해결할 수 있게 된다. 이후 컨텍스트만 적당히 되돌려놓으면 프로그램을 정상적으로 종료할 수 있게된다.

b9f69c75-1513-46c1-8026-f2376e6d1506
b9f69c75-1513-46c1-8026-f2376e6d1506

  1. CallApp: 커널 스택에 레지스터들을 저장
  2. CallApp: 애플리케이션 스택으로 전환
  3. CallApp: 애플리케이션 실행
  4. SyscallEntry.exit: 커널 스택으로 전환
  5. SyscallEntry.exit: 커널 스택에서 레지스터들을 복원
  6. SyscallEntry.exit: CallApp() 호출 다음줄로 복귀
// call CallApp 에서 리턴주소가 스택에 저장된다. (exit때 이쪽으로 복귀)
                                // user ss | RPL(3)
int ret = CallApp(argc.value, argv, 3 << 3 | 3, entry_addr,
                  stack_frame_addr.value + 4096 - 8,
                  &task.OSStackPointer());   // CallApp() 에서 저장


global CallApp
CallApp:  ; int CallApp(int argc, char** argv, uint16_t ss,
          ;             uint64_t rip, uint64_t rsp, uint64_t* os_stack_ptr);
    push rbx
    push rbp

    push r12
    push r13
    push r14
    push r15
    mov [r9], rsp   ; param_6(r9): *os_stack_ptr = RSP

    push rdx        ; SS
    push r8         ; RSP (애플리케이션 스택)
    add rdx, 8      ; 어차피 SS와 CS가 붙어있음
    push rdx        ; CS
    push rcx        ; RIP
    o64 retf        ; 애플리케이션으로 진입


global SyscallEntry
SyscallEntry:
    ...
.exit:
    mov rsp, rax  ; syscall은 스택을 변경하지 않아서 직접 커널스택을 가리키게 해야한다. 
    mov eax, edx

    pop r15
    pop r14
    pop r13
    pop r12
    pop rbp
    pop rbx
    ret      ; CallApp의 다음줄로 점프한다. (CallApp에서 RIP를 push해둠)

앱에 이벤트 전달 (ReadEvent)

지금은 키보드 입력이나 마우스 이동을 추적하는 이벤트들을 어플리케이션 단에서 획득할 수 있는 방법이 없다.

usb 인터럽트 처리과정

마우스, 키보드 입력이 발생하면 인터럽트가 발생하고 task_manager를 통해 커널태스크(taskid=1)에 인터럽트가 발생했다는 메시지를 전달한다.

  __attribute__((interrupt))
  void IntHandlerXHCI(InterruptFrame* frame) {
    task_manager->SendMessage(1, Message{Message::kInterruptXHCI});
    NotifyEndOfInterrupt();
  }

void InitializeInterrupt() {
  set_idt_entry(InterruptVector::kXHCI, IntHandlerXHCI);
  ...
}

전달되는 순간 메시지가 없어서 이벤트 루프에서 슬립되어있던 메인태스크(커널)가 깨어나며 메시지 타입을 확인하여 XHCI 이벤트를 처리하는 과정을 밟게된다.

  // event loop
  while (true) {
    __asm__("cli");
    const auto tick = timer_manager->CurrentTick();
    __asm__("sti");

    sprintf(str, "%010lu", tick);
    FillRectangle(*main_window->InnerWriter(), {20, 44}, {8 * 10, 16}, {0xc6, 0xc6, 0xc6});
    WriteString(*main_window->InnerWriter(), {20, 44}, str, {0, 0, 0});
    layer_manager->Draw(main_window_layer_id);

    __asm__("cli");
    auto msg = main_task.ReceiveMessage();
    if (!msg) {
      main_task.Sleep();
      __asm__("sti");
      continue;
    }
    __asm__("sti");

    switch (msg->type) {
    case Message::kInterruptXHCI:
      usb::xhci::ProcessEvents();
      break;

처리함수에서 usb 기기 이벤트 메시지로 변경

커널에서 처리함수를 실행하면서 XHCI 기기마다 미리 등록해둔 default_observer 함수가 실행되는데 KeyPushMouseMove, MouseButton 메시지로 변경되어 커널(메인태스크) 또는 전달할 태스크에 SendMessage 를 호출해준다.

// default_observer 함수 등록. Message::kInterruptXHCI 처리과정에서 실행
void InitializeMouse() {
  ...
  usb::HIDMouseDriver::default_observer =
    [mouse](uint8_t buttons, int8_t displacement_x, int8_t displacement_y) {
      mouse->OnInterrupt(buttons, displacement_x, displacement_y);
    };
  ...
}

void Mouse::OnInterrupt(uint8_t buttons, int8_t displacement_x, int8_t displacement_y) {
  ...
  if (drag_layer_id_ == 0) {
    SendMouseMessage(newpos, posdiff, buttons, previous_buttons_);
  }

  previous_buttons_ = buttons;
}


// 마우스 메시지로 변경 후 태스크에 전달
void SendMouseMessage(Vector2D<int> newpos, Vector2D<int> posdiff,
                      uint8_t buttons, uint8_t previous_buttons) {
  // 현재 마우스가 올라가있는 레이어 태스크에 메시지를 전달해준다. 
  const auto act = active_layer->GetActive();
  const auto layer = layer_manager->FindLayer(act);
  const auto task_it = layer_task_map->find(act);

  // Message::kMouseMove 메시지 전송
  const auto relpos = newpos - layer->GetPosition();
  if (posdiff.x != 0 || posdiff.y != 0) {
    Message msg{Message::kMouseMove};
    msg.arg.mouse_move.x = relpos.x;
    msg.arg.mouse_move.y = relpos.y;
    msg.arg.mouse_move.dx = posdiff.x;
    msg.arg.mouse_move.dy = posdiff.y;
    msg.arg.mouse_move.buttons = buttons;
    task_manager->SendMessage(task_it->second, msg);
  }

  // Message::kMouseButton 메시지 전송
  if (previous_buttons != buttons) {
    const auto diff = previous_buttons ^ buttons;
    for (int i = 0; i < 8; ++i) {
      if ((diff >> i) & 1) {
        Message msg{Message::kMouseButton};
        msg.arg.mouse_button.x = relpos.x;
        msg.arg.mouse_button.y = relpos.y;
        msg.arg.mouse_button.press = (buttons >> i) & 1;
        msg.arg.mouse_button.button = i;
        task_manager->SendMessage(task_it->second, msg);
      }
    }
  }
}

앱에서 이벤트 메시지 읽기

각 태스크에 직접 메시지들을 전달했고, task.ReceiveMessage(); 호출해서 읽어올 수 있게 됐다. 하지만 이 메시지는 커널메모리에서 관리하는 태스크 객체에 있기 때문에 시스템콜로 읽어와야 한다.

현재 태스크에 저장된 메시지를 읽어오고 그걸 arg1에 저장해주면 SyacallReadEvent 를 호출한 어플리케이션에서 사용할 수 있게된다.

SYSCALL(ReadEvent) {
  const auto app_events = reinterpret_cast<AppEvent*>(arg1);
  const size_t len = arg2;

  auto& task = task_manager->CurrentTask();
  while (i < len) {
    __asm__("cli");
    auto msg = task.ReceiveMessage();
    if (!msg && i == 0) {
      task.Sleep();
      continue;
    }
    __asm__("sti");

    switch (msg->type) {
    case Message::kMouseMove:
      app_events[i].type = AppEvent::kMouseMove;
      app_events[i].arg.mouse_move.x = msg->arg.mouse_move.x;
      app_events[i].arg.mouse_move.y = msg->arg.mouse_move.y;
      app_events[i].arg.mouse_move.dx = msg->arg.mouse_move.dx;
      app_events[i].arg.mouse_move.dy = msg->arg.mouse_move.dy;
      app_events[i].arg.mouse_move.buttons = msg->arg.mouse_move.buttons;
      ++i;
      break;
  ...
}

Sleep을 위한 처리

어플리케이션에서 ReadEvent를 호출하고 메시지가 없다면 블록상태로 대기할 수 있도록 시스템콜 내부에서 메시지가 없을때 태스크를 Sleep 상태로 전환하도록 구현되어 있다.
Sleep은 내부에서 컨텍스트 스위칭을 하게되는데 Timer쪽은 IST로 커널 스택을 사용하도록 수정했지만 여기(시스템콜)는 유저스택을 사용하는 컨텍스트 스위칭이 발생하는 지점이다.

extern GetCurrentTaskOSStackPointer
extern syscall_table
global SyscallEntry
SyscallEntry:
    push rbp
    push rcx  ; original RIP
    push r11  ; original RFLAGS

    push rax  ; 시스템콜의 번호를 저장해둔다. (시스템콜 호출 후 아래에서 비교)

    mov rcx, r10
    and eax, 0x7fffffff
    mov rbp, rsp

    ; 시스템콜을 커널스택에서 실행하기 위한 준비 
    and rsp, 0xfffffffffffffff0
    push rax    ; 반환값용 레지스터 rax, rdx
    push rdx
    cli
    call GetCurrentTaskOSStackPointer
    sti
    mov rdx, [rsp + 0]  ; RDX
    mov [rax - 16], rdx
    mov rdx, [rsp + 8]  ; RAX
    mov [rax - 8], rdx

    lea rsp, [rax - 16]
    pop rdx
    pop rax
    and rsp, 0xfffffffffffffff0

    call [syscall_table + 8 * eax]
    ...
ESC
Type to search...