Krafton Jungle/5. PintOS

[PintOS 2주차] Day 2

munsik22 2025. 5. 16. 10:40

개념 설명

더보기

Introduction #

소스 파일

userprog 디렉토리 내부에는 다음과 같은 파일들이 있다.

  • process.c, process.h : ELF 바이너리를 로드하고 프로세스를 시작한다.
  • syscall.c, syscall.h : 사용자 프로그램이 어떤 커널 기능에 접근하고 싶어할 때 system call을 발생시킨다.
  • exception.c, exception.h : 사용자 프로그램이 권한이 있거나 금지된 작업을 수행하면 커널에 exception이나 fault로 trap시킨다.
  • gdt.c, gdt.h : Global Descriptor Table(GDP)는 x86-64 시스템의 부분들을 다루고 있다. 프로젝트에서 이 파일을 수정하면 안 된다.
  • tss.c, tss.h : Task-State Segment(TSS)는 x86 아키텍처의 태스크 스위칭에서 사용되던 개념이다. 프로젝트에서 이 파일을 수정하면 안 된다.

파일 시스템 사용하기

사용자 프로그램이 파일 시스템에서 로드되고, 구현해야 하는 상당수의 시스템 콜이 파일 시스템을 다루기 때문에 파일 시스템에 대한 코드도 마주해야 한다. 하지만 이번 프로젝트의 목적은 파일 시스템이 아니기 때문에 filesys 디렉토리 안에 filesys.hfile.h가 제공되어 있고, 프로젝트에서 파일 시스템 코드를 수정할 필요는 없다.

 

전체 테스트를 하려면 userprog/build에서 make check를 하면 되고, 하나씩 테스트하려면 다음 명령어를 입력한다.

pintos --fs-disk=10 -p tests/userprog/args-single:args-single -- -q -f run 'args-single onearg'

사용자 프로그램이 동작하는 방법

핀토스는 일반적인 C 프로그램들도 메모리에 fit하고 우리가 구현한 시스템 콜만 사용한다면 실행할 수 있다. 단, malloc()은 이번 프로젝트에서 메모리 할당을 위해 요구되는 시스템 콜이 없기 때문에 구현될 수 없다.

 

핀토스는 process.c에서 제공되는 로더로 ELF 실행파일을 로드할 수 있다. ELF는 리눅스 등 다양한 OS에서 목적파일, 공유 라이브러리, 실행파일 등을 위해 사용하는 파일 포맷이다.


가상 메모리 레이아웃

핀토스의 VM은 사용자 VM과 커널 VM의 두 영역으로 나뉜다. 사용자 VM은 VA 0에서 KERN_BASE의 범위를 가진다. 커널 VM은 나머지 VAS를 차지한다.

 

사용자 VM은 프로세스마다 주어진다. 커널이 프로세스를 스위칭할 때, 프로세서의 페이지 디렉토리 베이스 레지스터(pml4_activate())를 바꿈으로써 사용자 VAS를 바꾼다.

 

커널 VM은 전역이다. 어떤 사용자 프로세스나 커널 스레드가 실행중인지에 관계없이 항상 같은 방식으로 매핑된다. 핀토스에서 커널 VM은 KERN_BASE에서 시작해서 일대일로 PM에 매핑되어있다. 즉, VA KERN_BASE는 PA 0에 접근하고, VA KERN_BASE + 0x1234는 PA 0x1234에 접근하는 식이다.

 

사용자 프로그램은 각자의 사용자 VM에만 접근할 수 있다. 커널 VM으로의 접근 시도는 page_fault()에 의해 다뤄지는 page fault를 야기하고, 해당 프로세스는 종료된다. 커널 스레드는 커널 VM과 실행중인 프로세스의 사용자 VM 둘 다에 접근할 수 있다. 그러나 커널에서조차도 매핑되지 않은 사용자 VA로의 접근 시도는 page fault를 발생시킨다.


사용자 메모리에 접근하기

시스템 콜의 일부로서, 커널은 종종 사용자 프로그램에 의해 제공되는 포인터를 통해서 메모리에 접근해야 한다. 사용자가 NULL 포인터(매핑되지 않은 VM으로의 포인터)나 커널 VAS로의 포인터(KERN_BASE 위로)를 제공할 수도 있기 때문에 조심스럽게 접근해야 한다. 이러한 종류의 invalid한 포인터들은 문제가 되는 프로세스를 종료하고 리소스를 free시킴으로써 커널이나 다른 실행중인 프로세스를 손상시키는 일 없이 거부되어야 한다.

 

이것을 명확하게 하기 위한 두 가지 방법이 있다.

  1. 사용자가 제공하는 포인터의 유효성을 검증하고 역참조하기: 사용자 메모리 접근을 다루는 가장 간단한 방법이다. mmu.cvaddr.h의 코드를 보면 된다.
  2. KERN_BASE 아래의 사용자 포인터만 체크하고 역참조하기: 유효하지 않은 포인터는 page fault를 발생시키는데, exception.cpage_fault()를 수정함으로써 다룰 수 있다. 이 방법은 프로세서의 MMU의 이점을 사용해서 일반적으로 더 빠르기 때문에 Linux를 포함한 실제 커널에서 사용된다.

또한 리소스의 leak가 발생하면 안 된다. 시스템 콜이 락을 소유하거나 메모리를 할당받는다고 가정하자. 유효하지 않은 포인터를 만나면 락을 release하거나 메모리 페이지를 free하는 것을 잊으면 안 된다. 포인터를 역참조하기 전에 검증을 한다면, 이것은 직관적이어야한다. 메모리 접근에서 에러 코드를 return할 방법이 없기 때문에, Invalid한 포인터가 page fault를 야기하는지를 다루는 것이 더 어렵다. 두 번째 방법을 선택한다면 다음 코드를 고려해보자.

/* Reads a byte at user virtual address UADDR.
 * UADDR must be below KERN_BASE.
 * Returns the byte value if successful, -1 if a segfault
 * occurred. */
static int64_t
get_user (const uint8_t *uaddr) {
    int64_t result;
    __asm __volatile (
    "movabsq $done_get, %0\n"
    "movzbq %1, %0\n"
    "done_get:\n"
    : "=&a" (result) : "m" (*uaddr));
    return result;
}

/* Writes BYTE to user address UDST.
 * UDST must be below KERN_BASE.
 * Returns true if successful, false if a segfault occurred. */
static bool
put_user (uint8_t *udst, uint8_t byte) {
    int64_t error_code;
    __asm __volatile (
    "movabsq $done_put, %0\n"
    "movb %b2, %1\n"
    "done_put:\n"
    : "=&a" (error_code), "=m" (*udst) : "q" (byte));
    return error_code != -1;
}

Argument Passing #

보통 Argument에 대해 설명을 한 것을 보면 Parameter와 혼용되어 설명된 경우가 많은데,

  • Parameter(매개변수)는 함수를 정의할 때 사용되는 변수를 의미한다.
  • Argument(인자/인수)는 함수가 호출될 때 넘기는 변수값을 의미한다.
int add(int x, int y) {
    return x + y;
}

int a = 10;
int b = 20;
int c = add(a, b);

여기서 x, y는 argument를 받기 위한 parameters이고, add()로 넘겨진 a, b가 arguements이다.

x86-64 Calling Convention

여기서는 64비트 x86-64에서의 일반적인 함수 호출에 사용되는 규칙의 중요한 포인트들을 정리한다. 호출 규칙는 다음과 같다.

  1. 사용자 레벨의 응용프로그램은 시퀀스를 전달하기 위해 정수 레지스터들을 사용한다: %rdi, %rsi, %rdx, %rcx, %r8, %r9
  2. 호출자는 스택에 다음 인스트럭션의 주소(리턴 주소)를 push하고 피호출자의 첫 번째 인스트럭션으로 점프한다: CALL
  3. 피호출자가 실행된다.
  4. 피호출자가 리턴값을 가지면 RAX 레지스터에 저장한다.
  5. 피호출자는 스택에서 리턴 주소를 pop하고 특정된 주소로 점프함으로써 리턴한다: RET

Program Startup Details

void _start (int argc, char *argv[]) {
    exit (main (argc, argv));
}
  • _start()main()이 리턴하면 exit()을 호출하는 main()의 래퍼 함수다.

커널은 사용자 프로그램이 실행을 시작하도록 허용하기 이전에 초기 함수의 인자들을 레지스터에 넣어야 한다. 인자들은 일반적인 호출 규칙과 같은 방식으로 넘겨진다.

 

/bin/ls -l foo bar라는 커맨드의 인자들을 다루는 방법을 보자.

  • 커맨드를 단어 단위로 나눈다: /bin/ls, -l, foo, bar
  • 스택의 top에 단어들을 위치시킨다. 어차피 포인터로 참조되기 때문에 순서는 상관없다.
  • 각 문자열 + NULL 포인터(sentinel)를 스택에 우→좌 순서로 push한다: argv
  • %rsiargv(argv[0]의 주소)로 가리키고 %rdiargc로 설정한다.
  • 마지막에 가짜 리턴 주소를 push한다. entry 함수가 리턴하지는 않지만, 그 스택 프레임도 다른 함수들처럼 같은 구조를 가져야 한다.

인수 전달 구현하기

기존의 process_exec()는 새로운 프로세스로 인수를 전달하는 것을 지원하지 않는다. 단순히 함수 이름만을 인수로 넘기는 것 대신에 여러 단어로 나누도록 process_exec()를 확장함으로써 이 기능을 구현해야 한다. 예를 들어, process_exec("grep foo bar")는 foo와 bar를 두 인수로 넘기면서 grep을 실행해야 한다. 인수를 파싱하는 데 string.c에 구현된 strtok_r()를 사용할 수 있다.


키워드 파고들기

💭 GP 레지스터 종류와 용도를 다 외우고 있어야 하나?

 

GP (General Purpose) Register는 x86, x86_64 아키텍처에서 범용으로 사용 가능한 CPU 내부 저장 공간이다. 각각의 레지스터는 연산, 주소 계산, 인자 전달 등 특정한 역할을 우선적으로 수행하지만, 이름 그대로 범용(general-purpose)이므로 자유롭게도 쓰인다.

  • x86-64 기준 주요 GP 레지스터와 용도
레지스터 주 용도 (일반적인 관례)
rax 연산 결과 저장 (return 값 등)
rbx 베이스 레지스터 (보존 필요)
rcx 루프 카운터, 4번째 인자
rdx 연산 보조, 3번째 인자
rsi 소스 주소, 2번째 인자
rdi 목적지 주소, 1번째 인자
rsp 스택 포인터
rbp 프레임 포인터
r8~r9 5~6번째 함수 인자 (System V ABI 기준)
r10~r11 임시 용도 (caller가 보존 안 해도 됨)
r12~r15 보존해야 하는 임시 저장소 (callee가 저장해야 함)
  • 시스템 콜 인자 전달 레지스터는 반드시 알아야 한다: 리눅스 syscall에서 rdi, rsi, rdx, r10, r8, r9 순서로 인자 전달됨
💭 rax 레지스터는 리턴값을 담는데 사용하는 걸로 알고있는데, 이것도 관례상 그런건가?

 

rax 레지스터가 리턴값을 담는 건 “관례”가 아니라 “규약”, 즉 컴파일러, 운영체제, 바이너리 포맷 모두가 지켜야 할 규칙이다.

  • 단순한 관례(convention)이 아니라 ABI 규약이다. (참고)
  • 하드웨어적으로 rax만 리턴값을 담아야 하는 건 아니지만, 그렇게 하면 컴파일러가 이해를 못한다 (ABI 위반)
  • 커널처럼 직접 어셈블리를 짜는 경우에는 rax가 아닌 다른 걸 쓸 수도 있지만, 표준 컴파일된 C 코드와 상호운용하려면 반드시 rax를 써야 한다.

유저 모드에서 사용자 모드로 전환하는 것을 컨텍스트 스위칭이라고 표현하는게 적절한가? (참고)

 

엄밀하게는 '컨텍스트 스위칭(context switch)'는 아니지만 비공식적으로 ‘문맥 전환’이라고 부를 수는 있다.

  • 컨텍스트 스위칭 (Context Switch)
    • 운영체제가 현재 실행 중인 스레드 또는 프로세스를 멈추고, 다른 실행 단위로 전환하는 과정
    • 이때 저장/복원되는 정보는:
      레지스터 값, 스택 포인터, 프로그램 카운터, 페이지 테이블 (주소 공간), 스케줄링 관련 데이터 등...
    • 즉, 실행 주체가 바뀌는 것 (ex. A 프로세스 → B 프로세스)
  • 유저 모드 ↔ 커널 모드 전환
    • 실행 주체는 같음 (ex. 프로세스 A가 계속 실행 중)
    • 다만 CPU 권한 수준만 바뀜
      • 유저 모드 → 커널 모드 (예: 시스템 콜, 인터럽트)
      • 커널 모드 → 유저 모드 (예: iretq, 시스템 콜 리턴)
    • 보통 mode switch 또는 privilege level change라고 부른다.

"유저 모드 → 커널 모드 전환"은 정확히는 ‘컨텍스트 스위칭’이 아니라 ‘모드 전환’이다. 단, 레지스터나 스택 전환이 포함되기 때문에 넓은 의미의 문맥 전환처럼 느껴질 수는 있다.


💭 user memory에서 0x400000부터 0까지는 빈 공간으로 알고 있는데, 왜 비어있는거지?

 

유저 메모리 공간에서 0x00000000 ~ 0x00400000(=4MB) 구간이 비어 있는 이유는 OS가 일부러 보호 영역으로 비워두었기 때문이다.

  • 핵심 목적: 널 포인터 접근 방지 (NULL pointer protection)
    • C 언어 등에서 NULL 포인터는 주소 0을 의미한다 (int *p = NULL;)
    • 만약 사용자 프로그램이 실수로 *p = 1; 같은 코드를 실행하면:
      주소 0을 참조하게 됨 → 심각한 버그 or 악성 동작
    • 이를 방지하기 위해 운영체제는 주소 0 근처를 매핑하지 않음:
      접근 시 바로 page fault 발생커널이 잡아서 에러 처리
  • 왜 0x400000(4MB)까지 비워둘까?
    • PintOS (또는 리눅스 ELF 규약)는 실행 파일의 로딩 시작 주소를 0x00400000 (4MB)로 정해둔다.
    • ELF 포맷에서는 텍스트/데이터 섹션을 0x00400000 이후에 위치시키는 게 일반적
    • 그래서 그 전 영역은:
      • NULL 보호
      • ELF 헤더 맞춤
      • 정렬된 메모리 공간 확보 (페이지 단위 4KB × 1024 = 4MB)
  • 실제로 사용자 프로그램이 이 영역에 접근하면?
    • 페이지 테이블에 매핑이 없음
    • page fault 발생
    • OS (또는 PintOS)가 kill() 또는 exit(-1)로 해당 프로세스 종료

🔹 유저 프로세스의 가상 메모리 구조

높은 주소 ─────────────────────────────
0x47480000   ← USER_STACK (유저 스택 시작 주소)
     │
     ▼
[스택 영역]
  - setup_stack()에서 esp가 여기서 시작됨
  - 아래로 (감소 방향) 확장됨

───────────────────────────────
[힙 영역]
  - malloc 등에서 사용
  - 위로 (증가 방향) 확장됨
  - 시작 지점: .bss/.data 끝 주소

───────────────────────────────
[코드 + 데이터 세그먼트]
  - ELF 로더가 프로그램의 .text, .data, .bss 세그먼트를 로딩
  - 시작 주소: 보통 0x400000

───────────────────────────────
0x00000000   ← NULL 보호 영역
낮은 주소 ─────────────────────────────

코드 흐름 파악하기

핀토스도, 커널도 C로 짜여진 프로그램이기 때문에, 핀토스를 부팅해서 실행하는 것도 결국에는 어딘가에 있는 main 프로그램을 실행시키는 것이다. run과 관련해서 프로그램을 실행시키는 로직을 따라가는 것부터 시작해보자.

💡 init.c에 핀토스가 부팅될 때 실행되는 main()run_action(), run_task()가 정의되어 있다.
  • 프로세스와 스레드 생성하기: process_create_initd()
  • 프로그램의 VA 설정하기: init_thread()thread_create()kernel_thread()
  • 실행파일 로드하기: kernel_thread()initd()process_exec()load()
  • 실행파일 시작하기: kernel_thread()initd()process_exec()do_iret()

strtok?

일반적으로 문자열을 자르는 함수는 strtok라고 알고 있었는데, 이 함수가 thread-safe하지 않아서 권장되지 않는다고 한다. 대신 이를 개선한 버전인 strtok_s가 나오게 되었다.

char *strtok_s(char *_String, const char *_Delimit, char **_Context);
  • String : 자르기 전의 문자열
  • Delimit : 자르는 기준 문자열
  • Context : 자른 후의 문자열
#include <stdio.h>
#include <string.h>
#include <stdint.h>
struct test {
    uint64_t a;
    uint64_t b;
};

void func(const char *name, struct test *t);

int main() {
    char string[] = "grep foo bar";
    char *context = NULL;
    char *res = strtok_s(string, " ", &context);
    char *argv[10];
    int argc = 0;

    while (res) {
        argv[argc++] = res;
        res = strtok_s(NULL, " ", &context);
    }
    argv[argc] = '\0'

    struct test t;
    t.a = (uint64_t) argv;
    t.b = argc;

    char *name = argv[0];

    func(name, &t);
    return 0;
}

void func(const char *name, struct test *t) {
    printf("Hello, %s\n", name);
    int argc = t->b;
    char **argv = (char **)t->a;

    for (int i = 0; i < argc; i++) {
        printf("%s\n", argv[i]);
    }
}

위 코드의 실행 결과는 다음과 같다.

 

불행하게도 윈도우 환경에서와 UNIX 환경에서 이름이 다른 함수들이 여럿 존재하고, strtok_s도 이에 해당한다. 핀토스 강의자료에서 strtok_s()가 아닌 strtok_r()이 등장한 이유도 이 때문이다.

'Krafton Jungle > 5. PintOS' 카테고리의 다른 글

[PintOS 2주차] Day 4-5  (0) 2025.05.19
[PintOS 2주차] Day 3  (0) 2025.05.17
[PintOS 2주차] Day 1  (0) 2025.05.15
[PintOS 1주차] Bonus: Advanced Scheduler  (0) 2025.05.15
[PintOS 1주차] Day 8: 마무리  (0) 2025.05.15