[핀토스/Userprog] Project 2 핵심 아키텍처 총정리: 인자 전달(Argument Passing), 유저 스택 프레임, 시스템 콜 디스패치 및 메모리 보안 검증

코딩이 어려운 사람·2026년 10월 20일

8주차

목록 보기
5/5

소주제

  1. 명령행 인자 전달(Argument Passing)과 토큰화 파이프라인: process_create_initd() vs load()
  2. strtok_r() 재진입성 분리와 문자열 종단('\0') vs NULL 포인터 물리 메모리 식별
  3. 메모리 할당과 자료형 크기 규약: palloc_get_page() 및 PGSIZE vs sizeof(PGSIZE) 오류 분석
  4. x86-64 System V ABI 스택 프레임(Stack Setup) 레이아웃과 16바이트 정렬 메커니즘
  5. x86-64 시스템 콜 진입과 레지스터 디스패치 파이프라인: syscall/sysret 및 intr_frame 반환값(%rax)
  6. 유저 포인터 3단계 보안 검증과 멀티페이지 버퍼 경계(Page Boundary) 검사 알고리즘
  7. 파일 I/O 시스템 콜 규약: SYS_READ(쓰기 권한 검증) vs SYS_WRITE(읽기 권한 검증)의 물리적 차이
  8. 파일 시스템 동시성 제어: struct lock filesys_lock 상호 배제와 락 누수(Deadlock) 방지
  9. 컴파일러(GCC) vs IDE 진단(IntelliSense) 분리 및 Clock Skew 파일 타임스탬프 동기화
  10. 핀토스 Project 2 Userprog 핵심 아키텍처 심층 Q&A 5선

1. 명령행 인자 전달(Argument Passing)과 토큰화 파이프라인: process_create_initd() vs load()

운영체제에서 사용자가 셸(Shell)을 통해 프로그램을 실행할 때 전달하는 문자열(예: /bin/echo hello world)은 단일 텍스트 스트림 형태로 커널에 전달됩니다. 커널은 이 문자열을 프로그램 실행 바이너리 파일명과 개별 인자(Argument)들로 분해하여 유저 프로세스의 초기 가상 메모리 공간에 재배치해야 합니다.

[핀토스 명령행 인자 전달 제어 흐름도]

$ /bin/echo hello world
  │
  ▼
process_create_initd(file_name)
  ├─ 1. 원본 명령행 문자열 복사본 생성 (페이지 할당: palloc_get_page)
  ├─ 2. 첫 번째 토큰 분리: "/bin/echo" (스레드 생성용 이름 추출)
  └─ 3. initd 메인 스레드 생성 (thread_create)
        │
        ▼ (initd 스레드 컨텍스트)
initd(void *f_name)
  └─ process_exec(f_name)
        └─ load(f_name, &intr_frame)
              ├─ 1. ELF 바이너리 디스크 로드 및 가상 메모리 매핑
              ├─ 2. 명령행 인자 토큰화 (strtok_r 파싱 -> argv[], argc)
              ├─ 3. 유저 스택 프레임 구성 (argument_stack / setup_stack)
              └─ 4. 유저 프로세스 진입 (do_iret -> Ring 3 전환)

1.1 process_create_initd()와 load()의 역할 분리

  • process_create_initd(const char *file_name):
    • 사용자로부터 전달받은 전체 명령행 문자열에서 스레드 이름으로 사용할 첫 번째 토큰(바이너리 파일명)만을 추출합니다.
    • 나머지 인자들을 유실하지 않고 후속 프로세스 로더로 전달하기 위해 새 커널 페이지를 할당받아 원본 문자열을 복사한 뒤 thread_create()의 인자로 전달합니다.
  • load(const char *file_name, struct intr_frame *if_):
    • 실제 실행 파일(ELF)을 디스크에서 열어 텍스트(Text), 데이터(Data), BSS 세그먼트를 유저 주소 공간에 적재합니다.
    • 전체 명령행 문자열을 개별 토큰으로 분해하고, x86-64 호출 규약(Calling Convention)에 부합하도록 사용자 스택 포인터(if_->rsp)를 이동시키며 스택 프레임을 구축합니다.

2. strtok_r() 재진입성 분리와 문자열 종단('\0') vs NULL 포인터 물리 메모리 식별

명령행 문자열을 공백 문자로 파싱할 때 표준 C 라이브러리 함수인 strtok() 대신 재진입(Reentrant)이 가능한 strtok_r()를 사용해야 합니다. 멀티스레드 커널 환경에서 strtok()은 내부 정적(Static) 포인터를 공유하므로 스레드 간 문맥 교환 발생 시 파싱 상태가 오염되는 데이터 레이스가 발생합니다.

[strtok_r()을 통한 명령행 토큰화 및 메모리 상태 전이]

초기 상태:
  / b i n / e c h o   h e l l o   w o r l d \0
  ▲                 ▲           ▲           ▲
  0                 9           15          21

1회 호출: token = strtok_r(command, " ", &save_ptr);
  / b i n / e c h o \0 h e l l o   w o r l d \0
  ▲                 ▲  ▲
  token             │  save_ptr
                    공백이 '\0'로 치환됨 (argv[0] 확정)

2회 호출: token = strtok_r(NULL, " ", &save_ptr);
  / b i n / e c h o \0 h e l l o \0 w o r l d \0
                       ▲         ▲  ▲
                       token     │  save_ptr (argv[1] 확정)

3회 호출: token = strtok_r(NULL, " ", &save_ptr);
  / b i n / e c h o \0 h e l l o \0 w o r l d \0
                                    ▲         ▲
                                    token     save_ptr (argv[2] 확정)

4회 호출: token = strtok_r(NULL, " ", &save_ptr); -> 반환값: NULL (토큰화 종료, argc = 3)

2.1 문자열 종단 문자('\0')와 NULL 포인터의 물리적 차이

  • '\0' (NUL 문자, ASCII Code 0x00):
    • 크기: 1바이트(char).
    • 역할: 연속된 메모리 바이트 배열에서 문자열의 끝을 나타내는 제어 문자입니다. strtok_r()은 구분자(공백) 위치의 바이트를 '\0'로 덮어써서 원본 문자열을 여러 개의 독립된 문자열 조각으로 물리 분할합니다.
  • NULL (널 포인터, (void *)0):
    • 크기: 8바이트(x86-64 포인터).
    • 역할: 어떤 유효한 메모리 객체도 가리키지 않는 주소 값입니다. argv[argc]의 끝을 나타내는 센티넬(Sentinel) 값이나 함수 포인터 실패 반환값으로 사용됩니다.

2.2 인자 배열(argv)과 인자 개수(argc) 바인딩 규약

char *argv[128];
int argc = 0;
char *token, *save_ptr;

for (token = strtok_r(command_copy, " ", &save_ptr); token != NULL;
     token = strtok_r(NULL, " ", &save_ptr)) {
    argv[argc] = token;
    argc++;
}
argv[argc] = NULL; /* POSIX 표준: argv 배열의 끝은 NULL 포인터로 종단 */
  • argc는 전체 인자의 총 개수(바이너리 실행 파일명 포함)입니다.
  • 유효한 인자 포인터의 인덱스는 0부터 argc - 1까지이며, argv[argc]는 반드시 NULL이어야 합니다. 따라서 argv 배열의 크기는 최소 argc + 1 이상이어야 합니다.

3. 메모리 할당과 자료형 크기 규약: palloc_get_page() 및 PGSIZE vs sizeof(PGSIZE) 오류 분석

핀토스 커널 메모리 할당자 palloc_get_page()는 4KB(4096 바이트) 단위의 물리 페이지를 반환합니다. 인자 전달 시 커널 스택(한 페이지 4KB)이 오버플로우되는 것을 방지하기 위해 명령행 문자열 복사본은 별도 페이지에 할당해야 합니다.

3.1 PGSIZE vs sizeof(PGSIZE) 매크로 연산 오류

  • PGSIZE는 <round.h> 및 <vaddr.h>에 정의된 상수로, 물리 페이지 크기인 4096 (0x1000) 정수 리터럴을 의미합니다.

  • sizeof(PGSIZE)는 컴파일러에 의해 정수 리터럴 4096의 자료형 크기로 치환됩니다. C 표준에서 4096 리터럴은 int 또는 unsigned int형이므로 sizeof(int) = 4바이트가 계산됩니다.

  • 치명적 오류 시나리오:

    /* 잘못된 코드: 4바이트만 복사되어 버퍼 오버런 및 데이터 손실 발생 */
    strlcpy(dest, src, sizeof(PGSIZE));
    
    /* 올바른 코드: 4096바이트 한 페이지 전체 크기 지정 */
    strlcpy(dest, src, PGSIZE);

3.2 배열 감쇄(Array Decay)와 sizeof 연산자

  • 스택이나 전역에 선언된 배열 char buf[512];에 대해 sizeof(buf)를 호출하면 배열의 전체 바이트 크기인 512가 반환됩니다.
  • 그러나 함수의 매개변수로 전달되거나 포인터 변수로 선언된 char *ptr;에 대해 sizeof(ptr)를 호출하면 포인터 변수 자체의 크기인 8바이트(x86-64 기준)가 반환됩니다. 포인터에 sizeof를 적용하여 버퍼 크기를 산출하는 행위는 메모리 오염의 주원인이 됩니다.

3.3 커널 페이지 할당 실패 방어 및 자원 정리(done)

char *fn_copy = palloc_get_page(PAL_ZERO);
if (fn_copy == NULL) {
    return TID_ERROR; /* 물리 메모리 고갈에 따른 안전한 종료 */
}
strlcpy(fn_copy, file_name, PGSIZE);

tid_t tid = thread_create(file_name, PRI_DEFAULT, initd, fn_copy);
if (tid == TID_ERROR) {
    palloc_free_page(fn_copy); /* 스레드 생성 실패 시 할당받은 페이지 메모리 누수 방지 */
}

4. x86-64 System V ABI 스택 프레임(Stack Setup) 레이아웃과 16바이트 정렬 메커니즘

프로그램 로딩 완료 후, 유저 프로세스가 최초로 실행(main(int argc, char **argv))될 때 인자들을 메모리에서 읽어올 수 있도록 System V AMD64 ABI 규약에 맞추어 유저 스택(0x47480000)에 데이터를 배치해야 합니다.

[x86-64 유저 스택 프레임(Stack Frame) 상세 메모리 구조]

높은 주소 (USER_STACK: 0x47480000)
+-------------------------------------------------------+
| 인자 문자열 2: "world\0" (6 바이트)                    |
+-------------------------------------------------------+
| 인자 문자열 1: "hello\0" (6 바이트)                    |
+-------------------------------------------------------+
| 인자 문자열 0: "/bin/echo\0" (10 바이트)               |
+-------------------------------------------------------+ ◄── 문자열 끝 주소
| 스택 정렬 패딩 (Zero Padding: 0~15 바이트)            | (%rsp % 16 == 0 정렬)
+-------------------------------------------------------+
| argv[3] = 0x0000000000000000 (8 바이트 NULL 센티넬)    |
+-------------------------------------------------------+
| argv[2] = 0x4747FFF2 (8 바이트: "world" 가리킴)       |
+-------------------------------------------------------+
| argv[1] = 0x4747FFEC (8 바이트: "hello" 가리킴)       |
+-------------------------------------------------------+
| argv[0] = 0x4747FFE2 (8 바이트: "/bin/echo" 가리킴)   |
+-------------------------------------------------------+ ◄── argv 시작 주소 (%rsi)
| 가짜 반환 주소 (Fake Return Address: 8 바이트 0x0)     |
+-------------------------------------------------------+ ◄── 최종 유저 스택 포인터 (%rsp)
낮은 주소

4.1 6단계 스택 구축 파이프라인 (Stack Setup Sequence)

  1. 인자 문자열 역순 복사 (Right-to-Left):
    • argv[argc - 1]부터 argv[0] 순서로 문자열 길이(strlen + 1)만큼 스택 포인터(%rsp)를 하향 이동하며 실제 문자 데이터를 복사합니다.
    • 스택에 적재된 각 문자열의 시작 주소를 추후 포인터 배열을 채우기 위해 별도 배열(uint64_t arg_addr[])에 기록합니다.
  2. 16바이트 정렬 패딩 (Stack Alignment Padding):
    • x86-64 ABI는 함수 호출 시 스택 포인터가 16바이트 배수여야 함을 규정합니다. (SSE/AVX 벡터 레지스터 연산 시 16바이트 정렬되지 않은 메모리에 접근하면 하드웨어 일반 보호 예외 #GP 발생).
    • 문자열 복사 완료 후 스택 포인터의 하위 비트를 검사하여 0x00 바이트를 채워 %rsp를 8의 배수 혹은 16의 배수로 내림(Round Down)합니다.
  3. argv[argc] NULL 종단 포인터 푸시:
    • 0x0000000000000000 (8바이트) 값을 스택에 푸시합니다.
  4. argv[i] 주소 포인터 역순 푸시:
    • 1단계에서 저장해 둔 문자열 주소들을 argv[argc - 1]부터 argv[0]까지 8바이트씩 스택에 푸시합니다.
  5. 가짜 반환 주소 (Fake Return Address) 푸시:
    • 진입 지점인 main() 함수가 반환될 경우를 대비하여 스택 최하단에 8바이트 크기의 0x0을 푸시합니다. (유저 프로그램 종료 규약).
  6. 레지스터 바인딩:
    • %rdi: 인자 개수 argc 저장.
    • %rsi: argv 배열의 시작 주소(스택 상에서 argv[0] 포인터가 위치한 주소) 저장.

5. x86-64 시스템 콜 진입과 레지스터 디스패치 파이프라인: syscall/sysret 및 intr_frame 반환값(%rax)

사용자 프로세스는 권한이 제한된 Ring 3(User Mode)에서 실행되므로, 파일 입출력이나 프로세스 생성 등 커널 자원을 요구할 때 syscall 기계어 명령어를 통해 Ring 0(Kernel Mode)으로 특권을 전이합니다.

[x86-64 syscall / sysret 하드웨어 특권 전이 파이프라인]

[Ring 3: 유저 프로세스]
  mov $SYS_WRITE, %rax   ; 시스템 콜 번호 (9)
  mov $1, %rdi           ; 인자 1 (fd = STDOUT)
  mov $buffer, %rsi      ; 인자 2 (버퍼 시작 주소)
  mov $12, %rdx          ; 인자 3 (출력 크기)
  syscall                ; 하드웨어 특권 전이 실행!
  │
  ├─ 1. CPU 하드웨어가 복귀 주소를 %rcx에 백업 (%rcx = %rip)
  ├─ 2. CPU 하드웨어가 플래그를 %r11에 백업 (%r11 = %rflags)
  ├─ 3. MSR(IA32_LSTAR)에 등록된 커널 syscall_entry 주소로 점프
  ├─ 4. CPL 0 (Ring 0) 전환 및 커널 스택으로 전환
  ▼
[Ring 0: 커널 공간 - syscall_entry in threads/intr-stubs.S]
  ├─ 5. intr_frame 구조체 형태로 모든 유저 레지스터를 커널 스택에 푸시 (Context Save)
  ├─ 6. syscall_handler(struct intr_frame *f) C 함수 호출
  │      │
  │      ▼
[syscall_handler() in userprog/syscall.c]
  ├─ 7. 시스템 콜 번호 판별: switch (f->R.rax)
  ├─ 8. 인자 레지스터 역참조: %rdi, %rsi, %rdx, %r10, %r8, %r9
  ├─ 9. 유저 포인터 보안 검증 및 작업 수행
  └─ 10. 반환값 기록: f->R.rax = result;  ([중요] 핵심)
  │
  ▼
[syscall_entry 복귀 루틴]
  ├─ 11. 커널 스택의 intr_frame 레지스터 복원 (Context Restore)
  └─ 12. sysret 기계어 명령어 실행!
         ├─ %rcx 값을 %rip에 로드하여 유저 코드로 복귀
         ├─ %r11 값을 %rflags로 복구
         └─ CPL 3 (Ring 3) 환원 -> 유저 프로세스 계속 실행

5.1 시스템 콜 규약과 f->R.rax 반환값의 실체

  • 일반 C 함수는 CPU의 물리 %rax 레지스터를 통해 반환값을 넘기므로 return result;를 사용합니다.
  • 그러나 시스템 콜 핸들러는 인터럽트 프레임(intr_frame) 컨텍스트 내부에서 동작합니다. 핸들러 함수가 종료된 후 syscall_entry 어셈블리 복귀 코드가 스택에 저장되어 있던 레지스터 값들을 CPU 물리 레지스터로 복원(pop)하므로, 반드시 f->R.rax = result;와 같이 구조체 필드에 반환값을 기록해야 합니다. 일반 return을 수행하면 복구 과정에서 해당 값이 스택의 이전 값으로 덮어씌워져 유저 프로그램이 반환값을 수신하지 못합니다.

5.2 핀토스 시스템 콜 번호와 인자 레지스터 매핑

시스템 콜번호 (%rax)인자 1 (%rdi)인자 2 (%rsi)인자 3 (%rdx)반환값 (%rax)
SYS_HALT0없음없음없음반환 없음 (시스템 종료)
SYS_EXIT1int status없음없음반환 없음 (프로세스 종료)
SYS_FORK2const char *thread_name없음없음자식 프로세스 pid_t
SYS_EXEC3const char *cmd_line없음없음성공 시 미반환, 실패 시 -1
SYS_WAIT4pid_t pid없음없음자식의 종료 상태코드
SYS_CREATE5const char *fileunsigned initial_size없음성공 여부 (bool)
SYS_REMOVE6const char *file없음없음성공 여부 (bool)
SYS_OPEN7const char *file없음없음파일 디스크립터 (fd)
SYS_FILESIZE8int fd없음없음파일 크기 (int)
SYS_READ9int fdvoid *bufferunsigned size실제 읽은 바이트 수
SYS_WRITE10int fdconst void *bufferunsigned size실제 쓴 바이트 수
SYS_SEEK11int fdunsigned position없음없음
SYS_TELL12int fd없음없음현재 파일 오프셋
SYS_CLOSE13int fd없음없음없음

6. 유저 포인터 3단계 보안 검증과 멀티페이지 버퍼 경계(Page Boundary) 검사 알고리즘

커널은 최고 특권 레벨(Ring 0)로 동작하므로, 유저가 전달한 잘못된 포인터를 무단 역참조하면 커널 패닉(Kernel Crash)이 발생하거나, 커널 내부 메모리가 유저에 의해 변조되는 Confused Deputy 보안 취약점에 노출됩니다. 따라서 모든 유저 포인터는 역참조 이전에 커널 수준에서 완벽히 검증되어야 합니다.

[유저 포인터 3단계 보안 검증 파이프라인]

유저 포인터 (void *vaddr)
  │
  ├─ [1단계: NULL 검사]
  │    if (vaddr == NULL) -> 즉시 비정상 종료 (exit(-1))
  │
  ├─ [2단계: 가상 주소 경계 검사]
  │    if (!is_user_vaddr(vaddr)) -> 즉시 비정상 종료 (exit(-1))
  │    (KERN_BASE 0x8004000000 이상의 커널 메모리 주소 침범 차단)
  │
  └─ [3단계: 물리 페이지 매핑 검사]
       if (pml4_get_page(thread_current()->pml4, vaddr) == NULL)
           -> 즉시 비정상 종료 (exit(-1))
       (페이지 테이블에 매핑되지 않은 세그멘테이션 폴트 주소 차단)

6.1 단일 주소 검증 함수 (check_address)

void check_address(const void *addr) {
    struct thread *curr = thread_current();
    if (addr == NULL || !is_user_vaddr(addr) || pml4_get_page(curr->pml4, addr) == NULL) {
        exit(-1); /* 오류 코드 -1과 함께 프로세스 즉각 종료 */
    }
}

6.2 4KB 페이지 경계를 넘는 멀티페이지 버퍼 검사 (check_buffer)

유저가 전달하는 I/O 버퍼는 수 바이트에서 수십 킬로바이트에 달할 수 있으며, 여러 4KB 물리 페이지 경계에 걸쳐 존재할 수 있습니다. 버퍼의 시작 주소가 유효하더라도 버퍼 중간이나 끝부분이 매핑되지 않은 메모리에 걸쳐 있다면 커널이 I/O 도중 페이지 폴트를 유발하게 됩니다.

[4KB 페이지 경계를 가로지르는 버퍼 검증 구조]

   페이지 0x1000 (매핑 완료)            페이지 0x2000 (미매핑 상태!)
+-------------------------------+-------------------------------+
|             | [  Buffer Start | Buffer End ] |                |
+-------------------------------+-------------------------------+
              ▲                                ▲
              0x1F00                           0x2100 (size = 512B)
              (유효 주소)                      (유효하지 않은 주소: 차단 필수!)
void check_buffer(const void *buffer, size_t size, bool to_write) {
    /* 1. 포인터 오버플로우 방어: buffer + size가 주소 공간 상한선을 넘는지 검사 */
    if (buffer == NULL || !is_user_vaddr(buffer) || !is_user_vaddr(buffer + size)) {
        exit(-1);
    }

    /* 2. 버퍼가 걸쳐 있는 모든 4KB 페이지의 시작 주소를 순회 검증 */
    char *ptr = (char *)pg_round_down(buffer);
    char *end = (char *)pg_round_down(buffer + size - 1);

    while (ptr <= end) {
        check_address(ptr);
        
        /* 3. 쓰기 작업(SYS_READ 등)인 경우 페이지가 쓰기 가능한지(Writable) 검증 */
        if (to_write) {
            /* 가상 메모리 관리(Project 3) 확장 시 pml4 쓰기 플래그 확인 */
        }
        ptr += PGSIZE;
    }
}

7. 파일 I/O 시스템 콜 규약: SYS_READ(쓰기 권한 검증) vs SYS_WRITE(읽기 권한 검증)의 물리적 차이

파일 입출력 시스템 콜에서 가장 빈번하게 발생하는 개념적 오류는 시스템 콜의 이름(READ/WRITE)과 메모리 버퍼에 요구되는 하드웨어 접근 권한(Access Permission)의 방향을 혼동하는 것입니다.

[SYS_READ vs SYS_WRITE의 데이터 흐름과 버퍼 권한 비교]

1. SYS_WRITE(fd, buffer, size): "데이터를 파일/콘솔로 출력"
   [유저 버퍼] ──────────(커널이 데이터를 읽음)──────────► [출력 장치 / 파일]
   요구 권한: 버퍼 메모리는 반드시 "읽기 가능(Readable)"해야 함!
   (코드 세그먼트와 같은 읽기 전용 상수 메모리도 출력 가능)

2. SYS_READ(fd, buffer, size): "데이터를 파일/키보드에서 입력받음"
   [입력 장치 / 파일] ──(커널이 데이터를 써넣음)─────────► [유저 버퍼]
   요구 권한: 버퍼 메모리는 반드시 "쓰기 가능(Writable)"해야 함! [중요]
   (읽기 전용 페이지(.text)에 read()를 시도하면 커널이 쓰기 예외를 유발하므로 차단 필수)
비교 항목SYS_WRITESYS_READ
시스템 콜 목적유저 메모리의 데이터를 파일 또는 화면에 기록파일 또는 키보드 입력을 유저 메모리에 적재
커널의 버퍼 접근 동작커널이 유저 버퍼를 읽음 (Read)커널이 유저 버퍼에 씀 (Write)
필수 유저 버퍼 권한읽기 가능 (Readable)쓰기 가능 (Writable)
표준 I/O 스트림fd == 1 (STDOUT_FILENO): putbuf() 호출fd == 0 (STDIN_FILENO): input_getc() 호출
파일 디스크립터 처리fd >= 2: file_write(file, buffer, size)fd >= 2: file_read(file, buffer, size)

8. 파일 시스템 동시성 제어: struct lock filesys_lock 상호 배제와 락 누수(Deadlock) 방지

핀토스의 기본 파일 시스템(filesys/)은 다중 스레드 환경에서 디스크 블록 할당이나 디렉터리 탐색 시 동시 접근을 제어하는 락 메커니즘을 내장하고 있지 않습니다. 여러 프로세스가 동시에 파일을 생성, 읽기, 쓰기를 수행하면 Inode 구조체 파손 및 디스크 블록 충돌이 발생합니다.

8.1 전역 파일 시스템 락 (filesys_lock) 설계

모든 파일 시스템 관련 시스템 콜 처리는 동기화 락을 통해 임계 구역(Critical Section)을 상호 배제(Mutual Exclusion)해야 합니다.

struct lock filesys_lock;

void syscall_init(void) {
    lock_init(&filesys_lock);
    /* 시스템 콜 인터럽트 등록 */
}

8.2 조기 리턴 및 오류 발생 시 락 누수(Lock Leak) 방지

파일 I/O 도중 유효하지 않은 버퍼 주소 접근 등으로 exit(-1)을 호출할 때 획득했던 filesys_lock을 해제하지 않고 프로세스가 종료되면, 시스템 전체 스레드가 영구적인 데드락에 빠집니다.

/* SYS_READ 구현 예시: 안전한 락 해제 패턴 */
case SYS_READ: {
    int fd = f->R.rdi;
    void *buffer = (void *)f->R.rsi;
    unsigned size = f->R.rdx;

    /* 1. 파일 시스템 락을 잡기 전 유저 버퍼 쓰기 권한을 먼저 완벽히 검증 */
    check_buffer(buffer, size, true);

    if (fd == 0) {
        /* 키보드 입력 처리 */
        uint8_t *buf = (uint8_t *)buffer;
        for (unsigned i = 0; i < size; i++) {
            buf[i] = input_getc();
        }
        f->R.rax = size;
    } else if (fd >= 2) {
        /* 파일 입력 처리 */
        struct file *file = get_file_from_fd(fd);
        if (file == NULL) {
            f->R.rax = -1;
            break;
        }
        lock_acquire(&filesys_lock);
        off_t bytes = file_read(file, buffer, size);
        lock_release(&filesys_lock); /* 작업 완료 즉시 락 해제 */
        f->R.rax = bytes;
    } else {
        f->R.rax = -1;
    }
    break;
}

9. 컴파일러(GCC) vs IDE 진단(IntelliSense) 분리 및 Clock Skew 파일 타임스탬프 동기화

개발 환경(VS Code, Docker, WSL, Linux Host) 간의 아키텍처 차이로 인해 발생하는 진단 불일치 현상을 정확히 파악해야 불필요한 코드 수정을 방지할 수 있습니다.

9.1 IntelliSense vs GCC 컴파일러 진단 분리

  • VS Code IntelliSense: 로컬 클라이언트에서 정적 분석 엔진을 통해 소스코드를 검사합니다. 핀토스 빌드 시스템의 특수한 include 경로, -msoft-float, 매크로 정의(#define PGSIZE)를 인식하지 못하여 무해한 코드에도 적색 밑줄(Error Squiggle)을 표시할 수 있습니다.
  • GCC 컴파일러: 타겟 크로스 컴파일러로 실제 kernel.bin 바이너리를 생성하는 유일한 판정 주체입니다. 코딩 시 에디터 밑줄에 의존하지 않고, 반드시 컨테이너/터미널 환경에서 make 컴파일러 출력 로그를 기준으로 오류를 판별해야 합니다.

9.2 Clock skew detected 경고의 물리적 원인과 해결

  • 발생 원인: Windows 호스트 OS와 Docker/WSL 가상 머신 간의 시스템 시계(RTC)가 동기화되지 않아, 소스코드의 최종 수정 타임스탬프(mtime)가 가상 머신의 현재 시각보다 미래의 시각으로 기록된 경우 GNU Make가 발생시키는 경고입니다.

  • 영향: Make가 파일 간의 수정 선후 관계를 판별하지 못해 증분 빌드가 실패하거나 이전 캐시가 유지됩니다.

  • 해결 방안:

    # 1. 터미널 환경에서 모든 파일의 타임스탬프를 현재 시각으로 일괄 동기화
    find . -type f -exec touch {} +
    
    # 2. 클린 리빌드 수행
    cd userprog/build
    make clean && make

10. 핀토스 Project 2 Userprog 핵심 아키텍처 심층 Q&A 5선

Q1. SYS_READ 호출 시 유저 버퍼에 대해 읽기 권한이 아닌 쓰기(Writable) 권한을 검증해야 하는 정확한 이유는?

답변: SYS_READ 시스템 콜의 물리적 동작은 커널이 디스크 파일이나 콘솔 장치로부터 바이트 스트림을 인출하여 유저가 지정한 메모리 주소(buffer)에 저장하는 작업입니다. 즉, 커널이 유저 메모리 공간에 직접 쓰기(Write) 연산을 수행합니다. 만약 유저 프로그램이 코드 세그먼트(.text)나 읽기 전용 상수 영역(R-X)의 주소를 SYS_READ의 버퍼로 전달했을 때 쓰기 가능 여부를 사전에 검증하지 않으면, 커널이 쓰기 시도를 하는 순간 CPU 하드웨어 예외인 페이지 폴트(#PF)가 발생하여 커널이 비정상 종료됩니다. 따라서 버퍼 공간이 쓰기 가능한 메모리인지 반드시 사전 검증해야 합니다.

Q2. 시스템 콜 처리 함수에서 값을 반환할 때 일반 C 함수의 return 문 대신 f->R.rax에 값을 대입해야 하는 하드웨어적 이유는?

답변: syscall 명령어를 통해 유저 모드에서 커널 모드로 진입할 때, syscall_entry 어셈블리 루틴은 유저 프로세스의 실행 상태(모든 범용 레지스터 값)를 커널 스택 상의 struct intr_frame에 푸시하여 보존합니다. 커널 내부의 C 함수인 syscall_handler가 일반 return 문을 사용하면 핸들러 자체의 반환값으로만 사용될 뿐 유저 레지스터 보존 영역에는 영향을 주지 못합니다. 핸들러 반환 후 어셈블리 복귀 루틴이 커널 스택의 intr_frame 레지스터들을 다시 CPU 레지스터로 복원(pop)하여 sysret을 수행하므로, 스택 상에 보존된 유저의 %rax 복원 위치인 f->R.rax에 명시적으로 값을 써넣어야만 유저 프로세스로 복귀했을 때 시스템 콜의 반환값을 올바르게 수신할 수 있습니다.

Q3. Stack Setup 시 인자 문자열을 먼저 푸시한 뒤 스택 정렬 패딩을 넣고, 그 다음에 argv 포인터들을 푸시하는 메모리 구조적 이유는?

답변: 인자 문자열은 가변 길이(char[]) 데이터이므로 문자열들을 모두 스택에 푸시한 시점의 스택 포인터(%rsp)는 임의의 바이트 주소에 위치하게 됩니다. x86-64 System V ABI 규약은 함수 진입 및 포인터 참조 시 메모리 버스 정렬을 위해 스택 포인터가 16바이트 경계에 맞추어질 것을 요구합니다. 따라서 가변 데이터 저장이 완료된 직후 0~15바이트의 제로 패딩을 삽입하여 스택 주소를 정렬합니다. 그 위에 푸시되는 argv 배열의 요소들은 고정 크기(8바이트 포인터)이므로 정렬 상태가 유지되며, 프로세스 진입점인 main(int argc, char **argv) 함수가 첫 번째 인자(argv[0])부터 8바이트 단위로 고속 캐시 정렬 참조를 수행할 수 있게 됩니다.

Q4. 유저 포인터 검증 시 단일 주소 check_address(buffer)만으로 불충분하고 check_buffer(buffer, size)로 멀티페이지를 검사해야 하는 물리적 이유는?

답변: 가상 메모리는 4KB 고정 크기 페이지 단위로 물리 프레임에 매핑됩니다. 유저가 전달한 버퍼의 크기가 수 바이트에서 수십 킬로바이트일 경우, 버퍼의 시작 주소는 매핑된 유효 페이지(예: 0x1FFF)에 위치하지만 버퍼의 나머지 데이터 영역은 매핑되지 않은 다음 페이지(예: 0x2000)에 걸쳐 존재할 수 있습니다. 시작 주소만 검사하고 파일 I/O를 수행하면 커널이 버퍼의 중간 데이터를 읽거나 쓰는 도중 하드웨어 페이지 폴트가 발생하여 커널 패닉이 일어납니다. 따라서 버퍼가 점유하는 시작 페이지부터 끝 페이지까지의 모든 4KB 경계 주소를 순회하며 물리 매핑 및 접근 권한을 전수 검증해야 합니다.

Q5. 핀토스 시스템 콜 핸들러에서 파일 읽기/쓰기 시 filesys_lock을 획득해야 하는 운영체제 설계상의 이유는?

답변: 핀토스의 기본 파일 시스템 코드는 스레드 안전성(Thread-safety)이 확보되어 있지 않습니다. 여러 프로세스가 동시에 file_read()나 file_write()를 호출할 경우 내부의 Inode 메타데이터, 파일 오프셋 포인터, 디스크 블록 할당 테이블(Free Map)을 보호하는 동기화 메커니즘이 없어 경쟁 상태(Race Condition)가 발생합니다. 이로 인해 동일 디스크 블록에 데이터가 중복 기록되거나 파일 시스템 메타데이터가 파손되는 치명적 손상이 일어납니다. 따라서 모든 파일 시스템 API 접근을 전역 filesys_lock으로 상호 배제하여 한 번에 단 하나의 스레드만 파일 시스템 연산을 수행하도록 보장해야 합니다.


핀토스 Userprog 핵심 아키텍처 다이어그램

profile
그사세가 아닌 내세상으로 만들기

0개의 댓글