Jungle / WIL(Weekly I Learned)
[WIL] 10주
10주차는 Pintos Project 2 User Programs를 중심으로 argument passing, syscall, user pointer 검증, file descriptor, fork/exec/wait, 실행 파일 write deny까지 정리하며 유저 프로그램과 커널이 만나는 경계를 학습한 한 주였다.
10주차 주간 회고: 유저 프로그램과 커널의 경계를 구현한 한 주
10주차는 Pintos Project 2 User Programs에 본격적으로 들어간 주였다. 9주차에는 스레드, 스케줄링, 락, 세마포어처럼 커널 내부의 실행 흐름을 주로 봤다면, 이번 주부터는 유저 프로그램이 커널에게 무엇을 요청하고, 커널이 그 요청을 어디까지 믿을 수 있는지를 다뤘다.
이번 주에 정리한 글은 총 9개였다.
| 글 | 핵심 내용 |
|---|---|
pintos project1 발표 피드백 |
Project 1 발표에서 보완해야 할 설명 방식과 용어 정리 |
User Program 특강 |
Project 2의 큰 방향, argument passing과 syscall 우선순위 |
Project 2 5/2 질문 |
user mode/kernel mode, intr_frame, PML4 같은 기반 개념 |
Project 2 User Program |
ELF, argument passing, syscall 레지스터, user stack 구조 |
Project 2 깃북 요약 |
Project 2 전체 요구사항과 구현 순서 요약 |
Pintos Project2 업무 분담 내용 |
팀별 담당 영역과 구현 인터페이스 정리 |
퀴즈 |
커널 모드, MLFQS, fork/exec, 스택과 힙, #ifdef 복습 |
발표 피드백 |
발표 후 받은 질문과 개선 포인트 정리 |
project 2 argument passing 2분 발표자료 |
argument passing 발표를 위한 스택 구조와 ABI 정리 |
이 글들을 묶어 보면 이번 주의 흐름은 Project 1 발표 피드백 → User Program 개념 이해 → Argument Passing → Syscall Dispatch → User Pointer 검증 → FD/File Syscall → fork/exec/wait → ROX와 통합 테스트 → 발표 개선으로 이어졌다.
1. Project 1 발표 피드백에서 출발한 점
이번 주 초에는 Project 1 발표 피드백을 먼저 정리했다. 피드백의 핵심은 “코드와 일치하는 그림을 직접 그리고, 공통 용어를 정확히 사용하라”는 것이었다. lock 구조체에 holder가 왜 필요한지, 세마포어와 락과 조건변수의 차이가 무엇인지, 발표 자료의 그림이 실제 구현과 맞는지 같은 질문이 나왔다.
이 피드백은 Project 2를 준비하는 데도 그대로 이어졌다. Pintos는 단순히 테스트를 통과시키는 프로젝트가 아니라, 내가 구현한 구조를 다른 사람에게 설명할 수 있어야 하는 프로젝트다. 특히 운영체제 코드는 추상적인 용어가 많기 때문에 비유로만 설명하면 금방 한계가 온다. 이번 주에는 “이 함수가 왜 필요한가”, “이 필드는 어떤 생명주기를 갖는가”, “이 테스트는 어떤 실패를 잡는가”를 더 명확히 말하는 연습이 필요했다.
2. User Program의 큰 그림
Project 2의 목표는 유저 프로그램이 커널과 안전하게 소통할 수 있는 통로를 만드는 것이다. 유저 프로그램은 파일을 직접 열거나, 프로세스를 직접 만들거나, 커널 메모리를 직접 건드릴 수 없다. 대신 syscall이라는 공식 창구를 통해 커널에 요청한다.
흐름을 단순화하면 다음과 같다.
유저 프로그램
-> open(), read(), write(), fork(), exec(), wait() 같은 함수 호출
-> syscall instruction 실행
-> syscall_handler()로 커널 진입
-> syscall 번호와 인자 확인
-> 유저 주소 검증
-> 커널 내부 함수 호출
-> 결과 반환 또는 프로세스 종료
여기서 중요한 점은 커널이 유저 프로그램을 믿으면 안 된다는 것이다. 유저 프로그램은 NULL 포인터를 넘길 수도 있고, 커널 주소를 넘길 수도 있고, 매핑되지 않은 주소를 넘길 수도 있다. 이때 커널 전체가 죽으면 안 된다. 잘못된 유저 프로그램만 exit(-1)로 종료되어야 한다.
User Program 특강에서 특히 강조된 것도 이 부분이었다. Project 2는 처음에는 argument passing과 write() 정도를 구현해서 테스트 출력이 보이게 만드는 것이 중요하다. 그 다음 process_wait()이 임시 구현으로 남아 있으면 테스트가 timeout될 수 있으므로, 프로그램 생성과 종료 흐름도 함께 맞춰야 한다.
3. User Mode, Kernel Mode, intr_frame, PML4
이번 주에는 구현 전에 운영체제의 보호 구조를 다시 정리했다. 유저 모드와 커널 모드를 나누는 이유는 운영체제와 하드웨어를 유저 프로그램의 실수나 악의적인 동작으로부터 보호하기 위해서다.
유저 모드에서는 물리 메모리 전체 접근, 페이지 테이블 변경, 디스크나 키보드 같은 장치 제어, 다른 프로세스 생성과 종료 같은 작업을 직접 할 수 없다. 이런 작업은 반드시 커널 모드에서만 수행되어야 한다.
intr_frame은 유저 프로그램에서 커널로 들어오거나 다시 유저 모드로 돌아갈 때 CPU 상태를 저장하는 구조체다. Project 2에서는 이 구조체를 자주 보게 된다.
| 레지스터 | 역할 |
|---|---|
rip |
유저 프로그램이 다음에 실행할 명령어 주소 |
rsp |
유저 스택의 현재 위치 |
rax |
syscall 번호 또는 반환값 |
rdi, rsi, rdx, r10, r8, r9 |
syscall 인자 전달 |
PML4도 중요한 기반 개념이었다. x86-64에서 PML4는 최상위 페이지 테이블이고, 각 프로세스가 독립적인 가상 주소 공간을 갖게 해준다. 새 유저 프로세스가 생성되면 pml4_create()로 페이지 테이블을 만들고, ELF segment와 유저 스택을 가상 주소에 매핑한다. 문맥 전환 시에는 pml4_activate()를 통해 CPU가 현재 프로세스의 주소 공간을 바라보게 된다.
이 개념들이 잡혀야 Project 2의 load(), setup_stack(), process_exec(), process_fork()가 단순한 함수 이름이 아니라 “유저 주소 공간을 만들고 바꾸는 흐름”으로 보인다.
4. ELF Load와 Argument Passing
Project 2에서 가장 먼저 붙잡아야 할 구현은 argument passing이다. 테스트는 아래처럼 실행될 수 있다.
args-multiple some arguments for you!
유저 프로그램의 main()은 다음과 같은 값을 받아야 한다.
argc = 5
argv[0] = "args-multiple"
argv[1] = "some"
argv[2] = "arguments"
argv[3] = "for"
argv[4] = "you!"
argv[5] = NULL
이를 위해 커널은 커맨드라인 문자열을 토큰으로 나누고, 문자열과 포인터 배열을 유저 스택에 올려야 한다. 핵심 함수는 process_exec(), load(), setup_stack()이고, 문자열 처리를 위해 strtok_r, strlen, memcpy, strlcpy 같은 함수도 확인해야 한다.
스택은 높은 주소에서 낮은 주소 방향으로 자란다. 하지만 argv 배열은 시작 주소부터 증가 방향으로 읽힌다. 그래서 스택에 데이터를 쌓는 순서와 유저 프로그램이 읽는 순서를 동시에 고려해야 한다.
대략적인 흐름은 다음과 같다.
1. 커맨드라인 문자열을 실행 파일 이름과 인자로 분리한다.
2. 실행 파일 이름으로 ELF를 연다.
3. ELF header와 program header를 읽고 segment를 메모리에 적재한다.
4. 유저 스택 페이지를 만든다.
5. 인자 문자열들을 스택에 복사한다.
6. 각 문자열의 주소를 argv 배열 형태로 스택에 올린다.
7. argc는 rdi, argv 주소는 rsi에 넣는다.
8. rsp를 새 스택 위치로 맞춘 뒤 do_iret()으로 유저 모드에 진입한다.
여기서 16바이트 정렬도 신경 써야 한다. x86-64 ABI 규약에서는 함수 호출 시 스택 정렬이 중요하다. 정렬이 틀리면 당장 모든 테스트가 깨지지는 않더라도, ABI 관점에서 올바른 실행 환경을 만든 것이 아니다.
5. Syscall Dispatch와 기본 시스템 콜
Argument passing 다음에는 syscall handler가 필요하다. 유저 프로그램은 lib/user/syscall.c의 wrapper를 통해 syscall instruction을 실행하고, 커널은 userprog/syscall.c의 syscall_handler()에서 요청을 받는다.
syscall 번호는 rax에 들어 있고, 인자는 레지스터에 순서대로 들어 있다.
| 값 | 레지스터 |
|---|---|
| syscall 번호 | rax |
| 1번째 인자 | rdi |
| 2번째 인자 | rsi |
| 3번째 인자 | rdx |
| 4번째 인자 | r10 |
| 5번째 인자 | r8 |
| 6번째 인자 | r9 |
가장 먼저 구현할 syscall은 halt, exit, write다. halt()는 Pintos 전체를 종료하는 syscall이고, exit(status)는 현재 유저 프로세스를 종료한다. write(1, buffer, size)는 stdout 출력이기 때문에, 테스트 메시지를 보기 위해서도 빨리 연결해야 한다.
exit는 단순히 현재 thread를 종료하는 것으로 끝나지 않는다. 현재 프로세스의 exit status를 저장하고, 부모가 wait()에서 읽을 수 있게 해야 한다. 또한 테스트가 기대하는 종료 메시지도 맞춰야 한다.
프로세스이름: exit(status)
이 출력은 유저 프로세스 종료에 대해서만 나와야 한다. 커널 thread나 halt 처리에서 불필요하게 출력하면 테스트가 실패할 수 있다.
6. User Pointer 검증
Project 2에서 가장 위험한 경계는 유저 포인터다. 유저 프로그램이 커널에 넘기는 주소는 모두 의심해야 한다.
예를 들어 아래 요청들은 모두 방어해야 한다.
create(NULL, 0);
open((char *) 0x20101234);
read(fd, kernel_addr, size);
write(fd, bad_ptr, size);
exec((char *) invalid_addr);
검증 기준은 크게 세 가지다.
| 확인할 것 | 이유 |
|---|---|
NULL이 아닌가 |
아무 메모리도 가리키지 않는 주소를 읽으면 안 된다 |
| 유저 주소 영역인가 | 커널 주소를 유저가 넘기면 보호 경계가 깨진다 |
| 실제로 매핑된 주소인가 | 주소값이 유저 영역처럼 보여도 page table에 없을 수 있다 |
문자열은 \0을 만날 때까지 한 글자씩 확인해야 한다. strlen()을 먼저 호출하면, 검증 전에 유저 메모리를 읽어버리는 문제가 생긴다. 버퍼도 시작 주소만 보면 부족하다. read-boundary, write-boundary, exec-boundary, create-bound 같은 테스트는 문자열이나 버퍼가 페이지 경계에 걸쳐 있는 상황을 만든다.
그래서 helper는 다음과 같은 방향이 좋다.
check_user_ptr(uaddr)
-> NULL 확인
-> 유저 주소인지 확인
-> 현재 pml4에 매핑되어 있는지 확인
-> 실패하면 exit(-1)
check_user_buffer(buffer, size)
-> size가 0이면 통과
-> buffer부터 buffer + size - 1까지 범위 검증
-> 페이지 경계를 고려해 필요한 주소를 모두 확인
copy_in_string(uaddr)
-> 문자열 전체를 검증
-> 커널 메모리로 복사
-> 커널 내부 함수에는 복사본을 전달
이번 주에 가장 크게 느낀 점은, user pointer 검증은 “예외 케이스 처리”가 아니라 Project 2 전체의 안전 기반이라는 것이다. 커널은 유저 프로그램이 무슨 값을 넘기든 살아 있어야 한다.
7. File Descriptor와 File Syscall
파일 syscall은 유저 프로그램이 파일 시스템을 직접 만지지 못하게 하기 위한 인터페이스다. 유저 프로그램은 struct file *을 볼 수 없고, 대신 파일 디스크립터 번호만 받는다.
fd의 기본 규칙은 다음과 같다.
| fd | 의미 |
|---|---|
0 |
stdin |
1 |
stdout |
2 이상 |
열린 파일 |
프로세스마다 fd table 또는 fd list가 필요하다. 같은 숫자 2라도 프로세스마다 다른 파일을 의미할 수 있기 때문이다.
프로세스 A: fd 2 -> a.txt
프로세스 B: fd 2 -> b.txt
그래서 struct thread 안에 fd_list, next_fd 같은 필드를 두는 방식이 자연스럽다. open()이 성공하면 새 fd 번호를 발급하고, read, write, close, filesize, seek, tell은 그 fd로 실제 struct file *을 찾아 작업한다.
파일 관련 syscall 흐름은 다음과 같이 정리할 수 있다.
| syscall | 핵심 흐름 |
|---|---|
create(file, size) |
파일명 검증 후 filesys_create() 호출 |
remove(file) |
파일명 검증 후 filesys_remove() 호출 |
open(file) |
파일명 검증 후 filesys_open(), fd 발급 |
filesize(fd) |
fd로 file을 찾고 file_length() 반환 |
read(fd, buffer, size) |
버퍼 검증, fd 0은 stdin, 일반 파일은 file_read() |
write(fd, buffer, size) |
버퍼 검증, fd 1은 stdout, 일반 파일은 file_write() |
seek(fd, position) |
fd로 file을 찾고 file_seek() 호출 |
tell(fd) |
fd로 file을 찾고 file_tell() 반환 |
close(fd) |
fd entry 제거 후 file_close() |
파일 시스템은 내부 동기화가 없기 때문에 여러 프로세스가 동시에 접근하면 꼬일 수 있다. 그래서 file syscall 주변에 전역 filesys_lock을 두는 것도 중요한 설계 포인트다.
8. wait, fork, exec의 생명주기
Project 2에서 가장 얽히는 부분은 부모-자식 관계다. wait, fork, exec는 각각 따로 보면 이해할 수 있지만, 실제 테스트에서는 서로 섞여 나온다.
wait(pid)는 부모가 자식의 종료 상태를 회수하는 syscall이다. 여기서 중요한 조건은 다음과 같다.
- 기다리는 pid가 내 자식이어야 한다.
- 같은 자식은 한 번만 wait할 수 있다.
- 자식이 아직 살아 있으면 부모는 기다려야 한다.
- 자식이 먼저 종료되었더라도 exit status는 남아 있어야 한다.
- 커널에 의해 죽은 자식의 status는
-1로 전달되어야 한다.
이 조건을 만족하려면 자식 thread 자체만 바라보면 위험하다. 자식이 종료되면 thread 구조체가 사라질 수 있기 때문이다. 그래서 부모와 자식 관계를 나타내는 별도 구조체가 필요하다.
struct child_info {
tid_t tid;
int exit_status;
bool waited;
bool exited;
struct semaphore wait_sema;
struct list_elem elem;
};
fork()는 현재 프로세스를 복제한다. 부모에게는 자식 pid가 반환되고, 자식에게는 0이 반환되어야 한다. 이를 위해 부모의 intr_frame, 주소 공간, fd table, 부모-자식 metadata를 복제해야 한다. 특히 fd table은 같은 struct file *을 그대로 공유하기보다 file_duplicate()로 복제하는 쪽이 안전하다.
exec(cmd_line)은 현재 프로세스를 새 프로그램으로 교체한다. fork가 복제라면 exec는 교체다. 성공하면 원래 코드로 돌아오면 안 되고, 실패하면 기존 프로세스에서 -1을 반환해야 한다.
흐름을 비교하면 다음과 같다.
| syscall | 의미 | 반환 |
|---|---|---|
fork(name) |
현재 프로세스를 복제해 자식 생성 | 부모는 자식 pid, 자식은 0 |
exec(cmd_line) |
현재 프로세스의 실행 이미지를 새 프로그램으로 교체 | 성공 시 반환 없음, 실패 시 -1 |
wait(pid) |
자식 종료를 기다리고 status 회수 | 자식 exit status 또는 -1 |
이번 주에는 이 세 syscall이 단순 기능이 아니라 “프로세스 생명주기 관리”라는 하나의 문제로 묶여 있다는 점을 많이 느꼈다.
9. 실행 파일 Write Deny와 통합 테스트
Project 2 후반에는 실행 중인 파일을 쓰지 못하게 막아야 한다. rox-simple, rox-child, rox-multichild 테스트가 이 부분을 확인한다.
핵심은 실행 파일을 load할 때 파일을 열고 file_deny_write()를 호출한 뒤, 프로세스가 살아 있는 동안 그 file 객체를 유지하는 것이다. load가 끝났다고 바로 close하면 실행 중인 파일에 대한 write deny가 풀릴 수 있다.
정리하면 다음 흐름이다.
load(executable)
-> filesys_open()
-> file_deny_write()
-> thread_current()->exec_file에 저장
-> 프로세스 실행 동안 유지
-> process_exit()에서 file_close()
이 기능은 독립적으로 보이지만 실제로는 open, write, exec, wait, process_exit()과 모두 연결된다. 그래서 단품 테스트를 통과한 뒤에도 multi-recurse, multi-child-fd, rox-*에서 다시 문제가 드러날 수 있다.
이번 주에 정리한 테스트 의존성도 이 사실을 잘 보여준다.
| 순서 | 기능 | 대표 테스트 |
|---|---|---|
| 1 | Argument passing | args-none, args-single, args-multiple, args-many, args-dbl-space |
| 2 | Basic syscall | halt, exit |
| 3 | User pointer 검증 | *-null, *-bad-ptr, *-boundary, bad-* |
| 4 | FD table과 file syscall | create-*, open-*, read-*, write-*, close-* |
| 5 | wait와 child status | wait-simple, wait-twice, wait-killed, wait-bad-pid |
| 6 | fork | fork-once, fork-multiple, fork-recursive, fork-read, fork-close |
| 7 | exec | exec-once, exec-arg, exec-missing, exec-boundary, exec-read |
| 8 | executable write deny | rox-simple, rox-child, rox-multichild |
| 9 | 통합/스트레스 | multi-recurse, multi-child-fd |
테스트는 파일명 순서로 푸는 것보다 의존성 순서로 보는 것이 훨씬 좋다. args-*가 깨지면 exec-arg와 multi-recurse도 흔들리고, fd table이 불안정하면 fork-read, fork-close, multi-child-fd가 같이 흔들린다.
10. 팀 작업 분담과 인터페이스 합의
이번 주에는 Project 2를 팀 단위로 어떻게 나눌지도 정리했다. 중요한 것은 담당을 나누더라도 thread.h, process.c, syscall.c 같은 병목 파일의 인터페이스를 먼저 맞추는 것이다.
역할을 크게 나누면 다음과 같다.
| 담당 | 주 책임 |
|---|---|
| Thread 담당 | 부모-자식 관계, exit status, wait, fork 동기화, cleanup |
| FD 담당 | fd table/list, open/read/write/close helper, fd 복제 |
| Syscall Handler 담당 1 | syscall dispatch, halt/exit, pointer 검증, fork/exec/wait 연결 |
| Syscall Handler 담당 2 | 파일 syscall 연결, stdin/stdout 처리, filesys lock |
초반에 반드시 합의해야 할 것은 struct thread 필드, child_info 구조체, fd_entry 구조체, user pointer 검증 정책, filesystem lock 위치다. 이 합의 없이 각자 구현하면 나중에 merge할 때 함수 이름과 상태 저장 위치가 어긋난다.
이번 주에 정리한 기준은 다음과 같다.
잘못된 유저 메모리 주소
-> exit(-1)
파일 없음, 잘못된 fd, 자식 아님, 이미 wait함 같은 논리 실패
-> syscall별 실패값 반환
이 정책을 정해두면 open(NULL)과 open("없는파일")을 다르게 처리할 수 있다. 전자는 잘못된 포인터이므로 유저 프로세스 종료이고, 후자는 정상적인 요청의 실패이므로 -1 반환이다.
11. 발표 피드백과 다음 발표에서 보완할 점
발표 피드백에서는 몇 가지 질문이 남았다. 유저 모드와 커널 모드 전환이 구체적으로 어떻게 일어나는지, do_iret() 이후 유저 모드로 내려갔다가 syscall로 다시 커널 모드에 어떻게 복귀하는지, pointer validation을 비효율적으로 구현하지 않으려면 어떻게 해야 하는지 같은 질문이었다.
이 질문들은 모두 Project 2의 핵심과 맞닿아 있다. argument passing을 설명할 때는 단순히 “스택에 넣는다”가 아니라, rsp, rdi, rsi, ABI, do_iret()까지 연결해야 한다. syscall을 설명할 때도 “handler에서 switch문을 돈다”가 아니라, 유저 wrapper가 레지스터에 값을 넣고 syscall instruction으로 커널에 들어온다는 흐름을 말해야 한다.
또 OOM 테스트, fork/wait/exec 테스트를 더 챙기라는 피드백도 있었다. 이는 단순 기능 구현보다 실패 경로와 자원 회수가 중요하다는 뜻이다. 프로세스 생성이 실패했을 때 child metadata를 정리하는지, fd 복제 중 실패하면 이미 복제한 fd를 닫는지, 부모가 먼저 죽었을 때 자식의 child_info가 dangling pointer가 되지 않는지 같은 부분이 결국 안정성을 결정한다.
발표 방식도 개선할 점이 있었다. 발표 스크립트를 그대로 읽기보다, 핵심 내용을 이해하고 말해야 한다. 발표 시간도 지켜야 하고, 컴퓨터 조작과 시연 흐름도 미리 익혀야 한다. 결국 기술 발표는 내용을 아는 것과 전달하는 것이 둘 다 필요하다.
이번 주에 배운 점
이번 주의 가장 큰 배움은 “커널은 경계를 관리하는 프로그램”이라는 점이었다. Project 1에서는 스레드 사이의 실행 순서와 공유 자원을 관리했다면, Project 2에서는 유저 프로그램과 커널 사이의 신뢰 경계를 관리한다. 유저 포인터를 검증하고, syscall 번호와 인자를 해석하고, fd라는 간접 참조를 통해 파일을 다루게 하는 모든 장치가 그 경계를 지키기 위한 구조였다.
두 번째 배움은 process의 생명주기를 thread 하나로만 보면 안 된다는 점이다. Pintos에서는 유저 프로세스 하나가 thread 하나로 표현되지만, 부모가 자식의 종료 상태를 나중에 읽어야 하므로 thread가 사라진 뒤에도 남아 있어야 하는 정보가 있다. child_info 같은 별도 구조체가 필요한 이유를 여기서 이해했다.
세 번째는 테스트 의존성을 먼저 읽는 습관이다. Project 2는 구현 범위가 넓어서 무작정 파일 순서대로 읽으면 길을 잃기 쉽다. args-*, halt/exit, pointer, fd, wait, fork, exec, rox, multi 순서로 의존성을 잡아두면 지금 실패한 테스트가 어느 층의 문제인지 더 빨리 좁힐 수 있다.
아쉬웠던 점과 다음 액션
아쉬운 점은 아직 fork, exec, wait의 실패 경로를 완전히 내 언어로 설명할 만큼 익숙하지 않다는 것이다. 정상 흐름은 따라갈 수 있지만, 부모가 먼저 종료되는 경우, 자식이 먼저 종료되는 경우, fork 도중 fd 복제가 실패하는 경우, exec load가 실패하는 경우를 모두 안정적으로 설명하려면 더 정리해야 한다.
다음 액션은 세 가지다.
첫째, process_exec(), load(), setup_stack()을 실제 코드 기준으로 다시 읽고 argument passing의 스택 배치를 손으로 그려본다. 둘째, syscall_handler()에서 각 syscall이 어떤 검증 helper를 거쳐야 하는지 표로 정리한다. 셋째, wait, fork, exec, process_exit()의 정상/실패/cleanup 흐름을 상태도처럼 정리한다.
10주차는 유저 프로그램과 커널이 만나는 경계를 처음으로 제대로 만진 주였다. 다음 주에는 이 경계를 더 단단하게 만들고, 단품 테스트를 넘어 multi-*, rox-*, OOM 계열까지 버틸 수 있는 구조로 정리해 나가야겠다.