
이번 주 목표
이번 주의 핵심은 "사용자 프로그램과 커널 사이의 경계를 직접 만들어보기"이다.
지난 주가 커널 내부에서 스레드가 어떻게 생성되고, 잠들고, 스케줄링되는지를 다뤘다면, 이번 주는 그 위에서 실제 사용자 프로그램이 어떻게 실행되는지를 따라간다. 이제는 커널 안쪽의 흐름만 보는 것이 아니라, 사용자 프로그램이 커널에게 어떻게 요청을 보내고, 커널은 그 요청을 어떻게 안전하게 처리하는지를 이해해야 한다.
구체적으로는 세 가지 큰 줄기를 따라간다.
첫째, 사용자 프로그램을 실행하기 위한 ELF 로딩 흐름을 이해한다. 실행 파일을 단순히 여는 것이 아니라, ELF 헤더를 읽고, 세그먼트를 검증하고, 사용자 가상 주소 공간에 올리는 과정을 따라간다.
둘째, Argument Passing을 구현한다. 예를 들어 cat sample.txt같은 명령어가 들어오면, PintOS는 이 전체 문자열을 하나의 파일 이름으로 처리하면 안된다. 실행해야 할 프로그램은 cat이고, sample.txt는 그 프로그램에게 전달해야 할 인자이다. 커널은 그 문자열들을 사용자 스택에 올리고, 사용자 프로그램이 argc, argv 형태로 읽을 수 있도록 첫 실행 환경을 만들어준다.
셋째, System Call의 기본 구조를 이해하고 일부 파일 관련 시스템 콜을 구현한다. 사용자 프로그램은 파일 생성, 열기, 닫기 같은 커널 자원에 직접 접근할 수 없다. 그래서 syscall 명령을 통해 커널로 진입하고, 커널은 전달된 syscall 번호와 인자를 해석해 적절한 작업을 수행한다.
이번 주는 한 마디로, 사용자 프로그램이 커널 위에서 안전하게 실행될 수 있도록 "경계와 통로"를 직접 만들어보는 시간이다.
어디까지, 어떻게 시도했는가?
🤝 우리 팀의 협업 방식
이번 주에도 각자 브랜치에서 구현을 진행하고, 코어타임에 서로의 코드를 비교하는 방식을 진행했다. 다만 지난주와는 협업 방식이 조금은 달랐다.
먼저 Argument Passing은 가장 진도가 느린 팀원을 기준으로 main 브랜치에 merge 했다. 빠르게 많이 구현한 사람의 코드를 바로 채택하기보다는, 팀 전체가 최소한 같은 흐름을 이해하고 넘어가는 쪽을 선택했다. 이 부분은 단순히 코드 완성도를 맞추는 것보다, 모두가 같은 출발선에서 다음 단계로 넘어가는 데 의미가 있었다.
Argument Passing을 마친 뒤에는 다시 main 브랜치에서 각자 브랜치를 따서 System Call을 진행했다. System Call은 구현해야 할 함수가 많았기 때문에, AI에게 "4명이 나누어 구현하려면 어떻게 역할을 나누는 것이 좋을지" 물어보고 역할을 분배했다.
동현: exit / wait / exec
지현: create / remove / open / close / filesize
민석: read / write / seek / tell
원우: halt / fork
나는 create, remove, open, close, filesize를 맡았다. 주로 파일 관련 syscall과 fd table을 다루는 부분이었다.
이 과정에서 재미있었던 점은 fd table 구현 방식이 팀원과 달랐다는 것이다. 나는 fd table을 배열 형태로 구현했다. fd 0과 1은 stdin, stdout으로 예약하고, 실제 파일은 fd 2부터 순차적으로 빈 칸을 찾아 할당하는 방식이었다. 반면 다른 팀원은 리스트 형태의 fd table 샘플을 만들어두었다. 각자 구현을 진행해야 하다 보니, 샘플 fd table을 만들었던 것이고 다른 방식으로 진행했음을 알았다.
코어타임에서는 배열 방식과 리스트 방식의 장단점을 비교했다. 배열은 구조가 단순하고 fd 번호로 바로 접근할 수 있다는 장점이 있었다. 반면, 리스트는 유동적으로 관리하기 좋지만, fd를 찾을 때 탐색이 필요하고 구현이 상대적으로 복잡해질 수 있었다. 최종적으로는 이번 프로젝트 규모와 PintOS 테스트 흐름을 고려했을 때 배열 방식이 더 단순하고 안정적이라고 판단해서, fd table은 배열 방식으로 채택되었다.
이번 주 협업에서 느낀 점은, 같은 기능을 구현하더라도 자료구조 선택에 따라 코드의 모양이 꽤 달라진다는 것이다. 단순히 "내 코드가 맞다"가 아니라, 팀 코드로 가져갔을 때 어떤 방식이 더 읽기 쉽고 유지하기 좋은지를 함께 판단해야 한다.
📦 Argument Passing
가장 먼저 붙잡은 부분은 Argument Passing이었다.
처음에는 단순히 명령어 문자열을 공백 기준으로 나누면 되는 문제라고 생각했다. 하지만 막상 들여다보니, 문자열을 자르는 것은 시작일 뿐이었다.
예를 들어 cat sample.txt라는 명령어가 들어왔다고 해보자. PintOS가 이 전체 문자열을 그대로 실행 파일 이름으로 보면 안된다. 실제로 실행해야 하는 프로그램은 cat이고 sample.txt는 그 프로그램에게 전달해야 할 인자이다.
즉, 커널은 먼저 명령어를 이렇게 나눠야 한다.
argv[0] = "cat"
argv[1] = "sample.txt"
argv[2] = NULL
argc = 2
이때 사용한 함수가 strtok_r()이다. 공백을 기준으로 문자열을 하나씩 잘라내고, 첫 번째 토큰은 실행 파일 이름으로 사용한다. 그래서 filesys_open()에는 "cat"만 넘겨야 한다. "cat sample.txt" 전체를 넘기면 그런 이름의 실행 파일을 찾으려 하기 때문에 실패한다.
문제는 여기서 끝나지 않는다. 나눈 인자들을 사용자 프로그램이 실제로 읽을 수 있어야 한다. 이를 위해 커널은 인자 문자열들을 사용자 스택에 직접 올린다.
순서는 생각보다 섬세하다. 먼저 "sample.txt", "cat" 같은 문자열을 스택에 복사한다. 그 다음 각 문자열이 놓인 주소를 다시 argv 배열 형태로 쌓는다. 마지막에는 argv의 끝을 표시하는 NULL 포인터와 fake return address를 넣는다.
그리고 x86-64 호출 규약에 맞춰 rdi에는 argc, rsi에는 argv 배열의 시작 주소를 넣어준다. 이렇게 해야 사용자 프로그램이 시작했을 때 자연스럽게 main(int argc, char *argv[]) 형태로 인자를 받을 수 있다.
이 과정을 이해하고 나서야 Argument Passing이 단순한 문자열 파싱 문제가 아니라는 걸 알게 되었다. 커널이 사용자 프로그램의 첫 실행 환경을 직접 만들어주는 작업이었다.
🧭 ELF Loader 흐름 이해
Argument Passing을 구현하려면 자연스럽게 process_exec()와 load()의 흐름을 따라가야 했다.
처음에는 load() 함수가 너무 길어서 어디부터 봐야 할지 막막했다. ELF 헤더, 프로그램 헤더, 페이지 테이블, 스택 같은 단어들이 한꺼번에 나오니, 함수 하나가 아니라 작은 미로처럼 느껴졌다.
그래서 먼저 흐름을 크게 나눠서 보기로 했다.
process_exec()
→ 기존 실행 컨텍스트 정리
→ load() 호출
→ 실행 파일 열기
→ ELF 헤더 검증
→ 세그먼트 로딩
→ 사용자 스택 생성
→ 인자 전달
→ entry point로 이동
process_exec()는 현재 프로세스의 기존 실행 컨텍스트를 정리하고, 새 실행 파일을 로드하기 위해 load()를 호출한다. 이후, 실제 작업의 대부분은 load() 안에서 이루어진다.
load()에서는 먼저 페이지 테이블을 만들고, 실행 파일을 연다. 그 다음 ELF 헤더를 읽어 이 파일이 실행 가능한 형식인지 확인한다. 이후, 프로그램 헤더를 하나씩 보면서 로드 가능한 세그먼트를 사용자 가상 주소 공간에 올린다.
여기까지 끝나면 setup_stack()으로 사용자 스택을 만든다. 그리고 프로그램이 처음 실행될 위치, 즉 entry point를 rip에 저장한다.
Argument Passing은 바로 이 흐름의 마지막에 들어간다. 실행 파일을 메모리에 올리고, 사용자 스택까지 만든 뒤, 사용자 프로그램으로 넘어가기 직전에 인자들을 스택에 올려야 한다.
이렇게 순서를 나눠서 보고 나니 load()가 조금 덜 무섭게 느껴졌다. 길고 복잡한 함수처럼 보였지만, 결국은 "실행 파일을 열고, 검증하고, 메모리에 올리고, 시작 준비를 하는 과정"이었다.
🔌 System Call
다음으로는 System Call을 구현했다.
사용자 프로그램은 커널 자원에 직접 접근할 수 없다. 예를 들어 sample.txt 파일을 열고 싶다고 해서, 사용자 프로그램이 파일 시스템 내부 구조를 직접 만질 수는 없다. 대신 open("sample.txt") 같은 시스템 콜을 호출하고, 커널이 그 요청을 대신 처리한다.
흐름은 생각보다 명확하다.
사용자 프로그램
→ open("sample.txt") 호출
→ syscall 번호와 인자를 레지스터에 담음
→ 커널 모드로 진입
→ syscall_handler()에서 번호 확인
→ 실제 커널 함수 실행
→ 결과를 사용자 프로그램에 반환
PintOS에서는 syscall 번호가 rax에 들어오고, 인자는 rdi, rsi, rdx 같은 레지스터를 통해 전달된다. 그래서 syscall_handler()는 f->R.rax 값을 보고 어떤 syscall인지 판단한다. 예를 들어 SYS_OPEN이면 syscall_open()으로, SYS_CREATE이면 syscall_create()로 분기하는 식이다.
이번 주에는 파일 관련 syscall을 중심으로 구현했다. 내가 맡은 부분은 create, remove, open, filesize, close였다.
이 중에서 가장 중요하게 느껴졌던 건 open이었다. filesys_open()으로 파일을 열면 커널 내부의 파일 객체인 struct file *이 반환된다. 그런데 이 포인터를 사용자 프로그램에게 그대로 넘겨주면 안 된다. 사용자 프로그램은 커널 내부 주소를 직접 다루면 안되기 때문이다.
그래서 커널은 열린 파일을 현재 스레드의 fd_table에 저장하고, 사용자 프로그램에게는 fd 번호만 돌려준다.
open("sample.txt")
→ filesys_open("sample.txt")
→ struct file * 생성
→ fd_table[2]에 저장
→ 사용자 프로그램에는 2 반환
이후, 사용자 프로그램은 sample.txt 파일 자체를 직접 들고 있는 것이 아니라, 2라는 fd 번호를 통해 커널에게 다시 요청한다. 예를 들어 filesize(2)를 호출하면, 커널은 fd_table[2]를 찾아 실제 파일 객체에 접근한다.
이 구조를 보고 나니 fd가 단순한 숫자가 아니라는 걸 알게 되었다. fd는 사용자 프로그램이 커널 내부 자원을 직접 만지지 않고도 사용할 수 있게 해주는 안전한 손잡이 같은 역할을 한다.
🧱 사용자 주소 검증
System Call을 구현하면서 사용자 주소 검증도 함께 신경 써야 했다.
open("sample.txt")에서 넘어오는 file은 문자열 자체가 아니라 사용자 메모리 주소이기 때문에, 커널은 이 값을 그대로 믿으면 안된다. NULL 포인터인지 먼저 확인하고, 그 다음 is_user_vaddr()로 사용자 영역 주소인지 검사해야 했다. 잘못된 주소가 들어오면 단순히 실패 값을 반환하는 것이 아니라 해당 프로세스를 exit(-1)로 종료해야 한다는 점에서, System Call은 기능 구현이면서 동시에 커널을 보호하는 경계이기도 했다.
🗂️ File Descriptor
System Call을 구현하면서 fd의 의미도 조금 더 분명해졌다.
처음에는 fd를 그냥 "파일 번호" 정도로만 생각했다. 그런데 구현하면서 보니 fd는 사용자 프로그램과 커널 내부 파일 객체 사이를 이어주는 중간 번호였다.
사용자 프로그램은 커널 내부의 struct file *를 직접 알 수 없다. 대신 커널이 파일을 열어두고, 사용자 프로그램에게는 숫자 하나만 넘겨준다. 그 숫자가 바로 fd이다.
PintOS에서는 fd 0과 fd 1이 미리 정해져 있다.
fd 0 = stdin
fd 1 = stdout
fd 2부터 = 사용자가 open()으로 연 파일
그래서 fd_alloc()은 2번부터 빈 칸을 찾는다. 빈 자리를 찾으면 현재 스레드의 fd_table에 열린 파일 포인터를 저장하고, 그 인덱스 번호를 사용자 프로그램에게 반환한다.
open("sample.txt")
→ fd_table[2] = sample.txt의 file 객체
→ 사용자 프로그램에는 fd 2 반환
반대로 close(fd)가 호출되면 fd_free()가 실행된다. 이 함수는 fd 번호에 해당하는 파일을 닫고, fd_table의 해당 칸을 다시 NULL로 비운다. 그래야 나중에 다른 파일을 열 때 그 fd 번호를 다시 사용할 수 있다.
filesize(fd)처럼 fd를 인자로 받는 syscall은 먼저 fd_to_file()을 호출한다. fd 번호만으로는 파일 크기를 알 수 없기 때문에, fd_table에서 실제 struct file *를 찾아온 뒤 file_length() 같은 커널 함수를 호출한다.
정리하면 흐름은 이렇다.
fd_alloc() : 열린 파일을 fd_table에 등록하고 fd 번호를 발급
fd_to_file() : fd 번호로 실제 파일 객체를 찾음
fd_free() : 파일을 닫고 fd_table 자리를 비움
구현 전에는 fd가 그냥 파일 번호처럼 보였는데, 직접 fd table을 만들고 나니 생각이 달라졌다. 사용자 프로그램에게는 단순한 숫자 하나지만, 커널에게는 열린 파일을 기억하고 다시 찾아가기 위한 약속된 인덱스였다.
🎤 발표와 회고
이번 주 발표에서는 내가 Argument Passing 파트를 맡았다. 특히 strtok_r() 함수에 대해 설명했다.
처음에는 문자열을 공백 기준으로 나누는 함수 정도로만 생각했는데, 실제로는 원본 문자열을 직접 수정하고, save_ptr을 통해 다음 토큰의 위치를 기억한다는 점이 중요했다.
Argument Passing에서 strtok_r()은 단순한 보조 함수가 아니었다. cat sample.txt 같은 명령어가 들어왔을 때, 실행 파일 이름인 cat과 인자인 sample.txt를 분리하는 출발점이었다. 이 분리가 제대로 되어야 filesys_open()에는 실행 파일 이름만 넘기고, 나머지 인자들은 사용자 스택에 올릴 수 있었다.
마지막으로는 프로젝트 회고도 발표했다. 회고에서는 크게 네 가지를 정리했다.
첫째, 학습 방식이다. 지난 주와 마찬가지로 모르는 것을 함께 찾아보며 학습했다. 특히 이번 주에는 포인터와 이중 포인터, 예를 들어 char ** 같은 개념을 계속 마주쳤기 때문에 혼자 넘기기보다 같이 확인하는 과정이 많았다.
둘째, 협업 방식이다. 각자 구현을 진행하되, 코어타임에 모여 서로의 코드를 비교하고 어떤 구현을 main에 merge할지 논의했다. Argument Passing은 팀 전체의 이해도를 맞추는 방향으로, System Call은 역할을 나누어 병렬로 진행하는 방향으로 진행했다.
셋째, merge 과정이다. System Call에서는 각자 맡은 syscall이 달랐기 때문에 충돌 가능성이 있었다. 그래서 브랜치별로 변경 내용을 확인하고, 충돌이 나는 부분은 직접 확인하면서 merge 했다.
넷째, 남은 계획이다. 흐름을 따라가며 학습은 했지만, 완성된 코드 자체를 끝까지 내 손으로 따라가지는 못한 부분이 남았다. 특히 exec, wait, fork처럼 프로세스 생명주기와 연결되는 부분은 다음에 더 깊게 보고 싶다.
이번 주는 단순히 코드를 나눠서 구현한 시간이 아니라, 팀 코드가 만들어지는 과정을 직접 겪은 시간이었다. 같은 문제를 서로 다른 방식으로 풀고, 그 중 어떤 방식을 팀의 기준으로 삼을지 함께 결정했다는 점에서 지난 주보다 협업의 밀도가 조금 더 높아졌다.
이번 주 아쉬웠던 점
이번 주 가장 아쉬웠던 점은 처음부터 전체 흐름을 크게 잡지 못했다는 것이다.
Argument Passing을 처음 봤을 때는 단순히 문자열을 공백 기준으로 나누는 문제라고 생각했다. 하지만 실제로는 그 뒤에 사용자 스택 구조가 있었다. 문자열을 어디에 올릴지, 주소는 어떤 순서로 쌓을지, 8바이트 정렬은 왜 필요한지, fake return address는 왜 들어가는지까지 모두 연결되어 있었다. 여기에 rdi ,rsi 레지스터에 argc, argv 를 넣는 과정까지 이어지니, 단순 문자열 처리라고 보기에는 훨씬 깊은 문제였다.
돌이켜보면 스택 구조를 먼저 그림으로 그렸다면 덜 헤맸을 것 같다. 코드부터 따라가려 하니 rsp가 움직일 때마다 머릿속 구조도 같이 흔들렸다. 이번 주에 가장 크게 느낀 건, 메모리나 스택을 다루는 문제는 눈으로 보이게 그려야 이해가 빨라진다는 점이었다.
System Call도 비슷했다. 처음에는 create, open, close 같은 함수를 하나씩 채우면 된다고 생각했다. 그런데 구현하다 보니 각 syscall은 따로 떨어진 함수가 아니었다. fd_table 초기화, fd 할당과 해제, 사용자 포인터 검증, 파일 객체 자원 해제가 모두 이어져 있었다.
특히 포인터를 다루는 부분이 아직 익숙하지 않았다. const char *file은 파일 이름 자체가 아니라 파일 이름 문자열이 저장된 주소이고,struct file * 도 파일 객체 전체가 아니라 그 객체가 있는 주소이다. 말로 쓰면 당연해 보이지만, 코드 안에서는 이 차이를 계속 되짚어야 했다.
그래도 이번 주에는 막히는 지점을 조금 더 구체적으로 알아차릴 수 있었다. “나는 지금 syscall 전체를 모르는 게 아니라 fd table의 역할을 헷갈리고 있구나”, “파일 이름을 모르는 게 아니라 포인터가 전달되는 방식을 헷갈리고 있구나”처럼 문제를 더 잘게 나눠볼 수 있었다.
아직 완전히 익숙해졌다고 말하기는 어렵지만, 적어도 어디서 헷갈리는지 알게 된 것만으로도 다음에 다시 붙잡을 실마리는 생겼다.
다음 주 계획
다음 주는 PROJECT 3, Virtual Memory로 넘어간다.
이번 주까지는 사용자 프로그램이 커널 위에서 어떻게 실행되고, 시스템 콜을 통해 커널 자원을 어떻게 요청하는지를 봤다. 다음 주부터는 그 사용자 프로그램이 사용하는 메모리를 운영체제가 어떻게 관리하는지 더 깊게 들어간다.
핵심은 가상 메모리이다. 지금까지는 프로그램이 필요한 메모리를 바로 올려두는 흐름에 가까웠다면, 다음 주에는 필요한 시점에 페이지를 가져오고, 부족하면 다른 페이지를 내보내는 구조를 직접 구현해야 한다.
구체적으로는 Lazy Loading, Page Fault Handling, Supplemental Page Table(SPT), Swap In/Out, Page Replacement 같은 개념을 다룬다. 특히 Project 3에서는 page fault가 단순한 오류만을 의미하지 않는다. 접근한 페이지가 아직 메모리에 올라오지 않았기 때문에, 커널이 그 페이지를 찾아와야 하는 신호가 될 수 있다. 이 부분이 가장 낯설면서도 중요할 것 같다.
다음 주에는 먼저 전체 흐름을 크게 잡고 들어가고 싶다.
사용자 프로그램이 메모리에 접근
→ page fault 발생
→ SPT에서 해당 페이지 정보 조회
→ frame 확보
→ 파일 또는 swap에서 데이터 로드
→ page table에 매핑
→ 실행 재개
이번 주에 Argument Passing과 System Call을 하면서 전체 흐름을 늦게 잡아서 많이 헤맸다. 그래서 다음 주에는 구현부터 들어가기보다, 페이지와 프레임, page table과 SPT, swap의 역할을 먼저 구분해서 정리할 생각이다.
결국 다음 주 목표는 “메모리가 부족해도 프로그램이 계속 실행되는 것처럼 보이게 만드는 구조”를 이해하는 것이다. 사용자 프로그램 입장에서는 큰 메모리를 자유롭게 쓰는 것처럼 보이지만, 실제로는 커널이 뒤에서 페이지를 가져오고 내보내며 관리한다. 그 착시를 운영체제가 어떻게 만들어내는지 직접 따라가보고 싶다.
10주차 팀원 블로그
Week 10 Learned
1. What이번 주는 "Pintos Threads 위에 User Program 실행 흐름을 올리기 시작한 주"였다.10주차의 큰 축은 세 가지였다. 첫째는 Pintos Project 2 - User Program 자체였다. Jungle/Week10/AGENTS.md를 보면 이번 단계의 핵
minishell.tistory.com
WIL 10주차. Argument Passing, Syscall, Wait/Exec 구현 흐름
WIL. Pintos Project 2 - Argument Passing을 구현하며 이해한 것Project 1까지는 커널 내부 테스트 함수가 실행됐다. alarm-single, priority-donate-one 같은 테스트는 user program을 로드하는 것이 아니라 커널 안에 있
app2.tistory.com
크래프톤 정글 10주차 WIL
WIL - Pintos User Programs 학습 정리 이번 주에는 Pintos Project 2인 User Programs를 중심으로 학습했다. 처음에는 argument passing만 구현하면 될 것처럼 보였지만, 실제로는 사용자 프로그램 실행 흐름, ELF 로
velog.io
'Krafton-Jungle > WIL' 카테고리의 다른 글
| Week 11. 정글 끝까지(PintOS) - Virtual Memory (0) | 2026.06.06 |
|---|---|
| Week 9. 정글 끝까지(PintOS) - Threads (0) | 2026.05.02 |
| Week 8. 탐험 준비 (1) | 2026.04.23 |
| Week 7. 탐험 준비 (0) | 2026.04.16 |
| Week 6. 탐험 준비 (0) | 2026.04.09 |
