시스템 콜 fork 부터는 TIL에 정리하지 않아 몰아서 정리하려 한다
그치만 그건 어렵기에 걍 완성된 코드랑
어떻게 흐름이 진행되는지 다루겠다
그리고난 extra 과제인 dup2까지 클리어했기에 이를 포함한 흐름이다
extra 까지 하지 않았을 경우와 흐름에서 차이가 있음을 알아두자
일단 초기화부터 해야 한다
초기 설정도 같이 해주며 말이다
초기화하고 설정 해주는 역할을 맡고 있다
기본적으로 부팅할때에 한번만 실행된다
실행시, 모델별 레지스터(MSR)들을 설정한다
이건 어차피 기본적으로 구현되어 있어 신경 쓸 거 아니다
이곳에서 해줘야 할건 파일시스템 동기화를 위한 전역 락 초기화와
파일 디스크립터 관리 위해 추가한 자료구조 초기화 해주면 된다
난 전역 락과 파일 디스크립터 관리 자료구조와 그 보호락을 초기화 해주었다
그리고 이곳에서 시스템 콜 핸들러를 호출한다
switch
문을 통해 들어오는 번호를 확인하고 해당하는 함수 호출해준다
ARG0(f)
, ARG1(f)
… 등으로 레지스터 값 매크로로 받게 해놓았다
(첫번째 인자, 두번째 인자, …)
RET()
는 시스템 콜 반환값을 f->R.rax
에 넣어주는 역할을 한다
switch (SC_NO(f)) {
case SYS_HALT: system_halt(); /* 전원 종료 */
case SYS_EXIT: system_exit((int)ARG0(f)); /* 현재 프로세스 종료 */
case SYS_FORK: RET(f, system_fork((const char *)ARG0(f), f)); /* 프로세스 복제 */
case SYS_EXEC: RET(f, system_exec((const char *)ARG0(f))); /* 새 프로그램 실행 */ break;
case SYS_WAIT: RET(f, system_wait((tid_t)ARG0(f))); /* 자식 프로세스 대기 */
case SYS_CREATE: RET(f, system_create((const char *)ARG0(f), (unsigned)ARG1(f))); break;
case SYS_REMOVE: RET(f, system_remove((const char *)ARG0(f))); break;
case SYS_OPEN: RET(f, system_open((const char *)ARG0(f))); break;
case SYS_CLOSE: system_close((int)ARG0(f)); break;
case SYS_FILESIZE: RET(f, system_filesize((int)ARG0(f))); break;
case SYS_READ: RET(f, system_read((int)ARG0(f), (void *)ARG1(f), (unsigned)ARG2(f))); break;
case SYS_WRITE: RET(f, system_write((int)ARG0(f), (const void *)ARG1(f), (unsigned)ARG2(f))); break;
case SYS_SEEK: system_seek((int)ARG0(f), (unsigned)ARG1(f)); break;
case SYS_TELL: RET(f, system_tell((int)ARG0(f))); break;
/* dup2 extra 과제 */
case SYS_DUP2: RET(f, system_dup2((int)ARG0(f), (int)ARG1(f))); break;
default: system_exit(-1); __builtin_unreachable();
}
위와 같이 각각에 맞춰 시스템콜 함수 호출해준다
시스템 콜 다룰때에는 직접 접근하고 역참조하고 이런건 오류도 그렇고 상당히 위험하다
그렇기에 중간에서 메모리 검증하고 버퍼에 대신 받아서 복사하게 하는 등의 안정장치가 필요하다
이를 위해 만든 헬퍼 함수들이다
assert_user_range(const void *uaddr, size_t size)
uaddr
부터 size
만큼의 바이트를 검사한다
총 두가지를 검사하는데
주소 영역이 유저 영역이 맞는지(is_user_vaddr
) 확인하고
해당 범위내의 페이지가 현제 프로세스의 페이지테이블에 매핑되어 있는지(pml4_get_page
) 확인한다
만약 잘못된 주소라면 system_exit(-1)
로 프로세스 종료한다
copy_in(void *kdst, const void *usrc, size_t n)
이름처럼 복사해온다
사용자 영역 메모리 usrc
로부터 커널 버퍼 kdst
로 말이다
pml4_get_page
로 물리주소 얻고 페이지 경계까지 복사한다
이후 usrc
와 kdst
의 포인터 증가시켜가며 남은 바이트 이어 복사하다가 잘못된 주소 나오면 system_exit(-1)
해준다
copy_out(void *udst, const void *ksrc, size_t n)
이름 보면 추측 가능하다
커널 데이터 ksrc
로부터 사용자 버퍼 udst
로 복사한다
똑같이 페이지 단위로 진행한다
copy_in_string(char *kdst, const char *usrc, size_t max_len)
사용자 영역의 문자열을 커널 버퍼로 복사한다
문자열 길이가 오버하지 않게 최대 max_len
바이트까지 복사한다 (어지간해선 넘을 수가 없다)
그 전까지 문자열 돌면서 pml4_get_page
로 유효성 검사해준다
성공 조건은 중간에 NUL문자 \0
을 만나는 것이다
만약 max_len
안에 이를 찾지못하면 그냥 \0
추가하고 false반환한다
fd_table
각 프로세스에서 참조할 파일들을 위해 fd table이 존재한다
이를 관리하기위해 process.c
헤더의 스레드 구조체에 관련 변수를 넣어줬다
struct file **fd_table; // 파일 포인터 배열
int fd_cap; // 한계
bool fd_table_from_palloc; // exec 누수 관리용
주석 보면 알 수 있듯이
배열 저장용이랑 테이블 용량, 그리고 누수 관리용이다
테이블 palloc으로 받았는지 확인해서 메모리 해제한다
그리고 파일 디스크립터 숫자 0 = STDIN, 1 = STDOUT으로 사용해야 한다
이를 위해 매크로도 지정해주었다
#define STDIN_FD ((struct file*)-1)
#define STDOUT_FD ((struct file*)-2)
값은 의미 없고 일관성만 지키면 된다
그래도 0이나 1은 양수이고 NULL로도 쓰일 수 있는 값이기에 혼동 방지용으로 음수 사용했다
음수 순서를 저장할 일은 없으니 훨 안전하다
각 스레드가 실행될때에 파일 테이블을 준비해준다
cur->fd_table = (struct file **)palloc_get_page(PAL_ZERO);
cur->fd_cap = PGSIZE / (int)sizeof(cur->fd_table[0]);
cur->fd_table_from_palloc = true;
cur->fd_table[0] = (struct file *)-1; /* stdin */
cur->fd_table[1] = (struct file *)-2; /* stdout */
0으로 채운 페이지 할당받고 fd크기로 나눠 용량도 정해준다
이후 현재 테이블 palloc으로 받았음을 체크해주고
0번과 1번 테이블을 stdin, stdout으로 설정해주면 준비 끝이다
fd_alloc()
새 FD 할당해준다
fd_alloc(struct file *f)
현재 스레드의 fd_table
에서 빈 칸을 찾아
그 위치에 파일 포인터 f
를 넣고 FD 번호를 리턴한다
좀 더 구체화 된 동작이다 ↓
fd_ensure_table()
호출
현재 스레드에 fd_table이 없다면 위에 처럼 파일 테이블 준비
만약을 대비한 함수다
fd_cap - 1
까지 순회하며 NULL
을 찾는다
찾으면 거기에 f
저장 후 그 인덱스 반환
만약 자리가 없으면 -1 호출
이렇게 할당된 FD는 프로세스 내부에서 유일한 핸들로 사용되고, 실제 파일을 가리키는 포인터(struct file *
)
와 연결된다
dup2 같은 경우는 fd table 번호만 다르고 실제로는 같은 파일을 가리킨다
여러 파일 디스크립터가 동일한 파일을 가리킬때 이 파일을 관리하는게 핵심이라 볼 수 있다
기본적으로 pintos에서는 file_close()
하면 걍 메모리 해제하고 사용 해제 처리해버린다
그치만…
dup2를 통해 여러 파일 디스크립터가 파일 하나 참조 중인데 그걸 그냥 닫아버리면
남은 디스크립터들이 상당히 곤란해진다
이를 막기 위해 파일 참조 카운트를 사용했다
file_reaf
구조체와 해시 테이블을 사용해 관리했는데
해시 테이블말고 연결리스트도 좋다
struct file_ref
구조체
struct file_ref {
struct file *fp;
int refcnt;
struct hash_elem elem;
};
열린 파일 포인터 *fp
와 그 포인터를 참조하는 파일 디스크립터들의 참조 개수 refcnt
를 기록한다
hash_elem elem은 해시테이블의 원소 중 하나라는 뜻이다
list_elem elem이면 리스트 원소였을 터다
해시 테이블 file_ref_ht
file_ref
들을 해시 테이블이다
syscall_init
할때 초기화 되고 전용 락(file_ref_lock
)도 쥐어줬다
fdref_inc(struct file *fd)
파일 포인터 fp
의 참조 카운터 증가시킨다
파일 새로 열거나 fork, dup2할때에 쓰인다
fp
가 STDIN_FD
(-1) 또는 STDOUT_FD
(-2) 인 경우는 참조 카운터 필요 없다
그냥 센티널값이라 바로 true
리턴해주면 된다
이후 ref_find(fp)
로 해세 테이블에 fp
키 유무 확인한다
없을 경우
malloc
으로 struct file_ref
할당하고 fp
설정하고 refcnt
1로 설정해준다
그리고 해시 테이블에 삽입하면 끝
malloc
실패시 false
반환
있을 경우
해당 file_ref
가져와 refcnt++
로 값 올려준다
작업이 끝났으면 true
반환한다
fdref_dec(struct file *fp)
파일 포인터 fp
의 참조 카운터 감소시킨다
똑같이 STDIN_FD
(-1) 또는 STDOUT_FD
(-2) 인 경우는 그냥 반환한다
얜 void
타입이라 반환값도 필요없다
해시 테이블에서 fp
에 해당하는 file_ref
를 찾아온다
refcnt
를 1감소시킨다
만약, 이로 인해 refcnt
가 0이 되었다면 이제 누구도 참조하지 않는다는 거다
0이 되었을때
해시 테이블에서 삭제
실제 파일 닫기 file_close(fp)
free(r)
로 file_ref
구조체도 메모리 해제
함수 종료
물론, refcnt
가 0보다 크다면 그냥 종료하면 된다
이를 통해 요구사항을 명백히 충족할 수 있었다
이 dup2의 요구사항을 지키기가 어려운 과제였다
여기서 CS:APP과의 차이가 존재하는데
기본적으로 POSIX 방식의 dup2를 책에서는 서술하는데
우리가 구현해야 하는 것은 이와는 좀 다르다
POSIX에서는 inode table을 참조하는 open file table을 fork해도 자식 desciptor table은 부모와 같은 open file을 참조한다
그치만 Pintos에서는 fork 시 자식은 별개의 open file table(부모와 똑같은 inode table을 참조)을 참조한다
[POSIX]
Parent FD table --> [open file desc X] --> [inode A]
Child FD table --> [open file desc X] --> [inode A]
(오프셋/플래그 공유)
[Pintos(보편 구현)]
Parent FD table --> [struct file P] --> [inode A]
Child FD table --> [struct file C] --> [inode A]
(P와 C는 오프셋 독립, inode는 동일)
작게 도식화
이를 구현하기 위해 fdref_inc
과정에서 해시 테이블을 사용하는 것이다
아무튼 여기까지 구현하면 테스트 케이스는 무리 없이 통과 가능한 dup2 로직을 완성해낸 것이다
system_dup2(int oldfd, int newfd)
위에서 사실상 다 구현했기에 여기서는 조건만 간략히 검사한다
oldfd
와 newfd
가 범위 내인지 확인하고, 둘이 같은 지 확인한 뒤에
oldfd
가 가리키는 파일의 유무를 확인하고 이미 newfd
가 열려 있는지 확인한다
범위 밖이거나 없으면 실패 반환
둘이 같으면 이미 되어있으니 바로 성공 처리
newfd
가 열려있으면 조용히 닫아준다 (system_close
호출)
그리고 나서 fdref_inc
호출해준다
system_fork(const char *thread_name, struct intr_frame *parent_if)
copy_in_string
으로 검사 한번 해주고 process_fork (const char *name, struct intr_frame *if_ UNUSED)
호출한다
process_fork
는 thread_create
로 자식 스레드 생성 전 자식 상태 추적을 위해 child_status
구조체 만든다
이 구조체에 자식 tid, 종료 코드, 로드 성공 여부 등등 다양한 정보 기록해 놓는다
그리고 부모의 children
리스트에 연결해준다
thread_create
를 호출시 새 스레드가 실행할 함수로 __do_fork
를 지정하고 인자로 fork_args
구조체를 전달
자식 스레드는 __do_fork
부터 실행 시작
__do_fork(void *aux)
__do_fork
에서 실행 컨텍스트 복사하고 자원 복제
fork_args
로부터 부모 스레드 포인터와 레지스터 상태 parent_if
등을 받아온다
페이지 테이블 복제
새 페이지 테이블(pml4_create()
)을 만들고 해당 페이지 테이블로 주소공간 전환(pml4_activate
)
이후, 부모의 페이지 테이블 순회하며 사용자 메모리 영역 모두 복사
파일 디스크립터 테이블 복제
파일 포인터가 STDIN
이나 STDOUT
일 경우 동일한 값을 복사
일반 파일인 경우 file_duplicate
로 복사하고 해시테이블에 넣은 뒤 참조 1로 설정한다
만약 해시테이블에 존재하는 경우 기존에 만든 파일 구조체 가리키고 하고 참조++ 한다
만약 위 과정 중 어느 지점에서라도 실패할 경우
fork_rollback
을 통해 복제된 자원 정리하고 프로세스 종료한다
자식의 fd_table을 훑으며 전부 fdref_dec
호출해 닫고 테이블 해제한다
이후, error
로 이동하여 로드 실패 처리하고 부모가 대기 중이었다면 load_sema
up시켜 알리고
자식스레드는 thread_exit()
로 종료
실패가 아닌 성공일 경우, 로드 성공 처리하고 load_sema
up 시킨다
이 경우 부모 쪽에서 fork 호출 마무리하고 자식 tid 반환해준다
유의할 점은 자식 쪽에서 RAX레지스터를 0으로 수정해야 한다는 거다
fork 완료후 부모쪽은 자식의 tid를, 자식은 0을 반환해야 한다
process_exit()
이름 보면 알수 있듯이 프로세스 종료다
이 경우 자원 정리가 중요하다
if (cur->fd_table) {
for (int i = 0; i < cur->fd_cap; i++) {
struct file *p = cur->fd_table[i];
if (!p) continue;
cur->fd_table[i] = NULL;
fdref_dec(p);
}
}
현재 프로세스의
fd_table
검사하며 유효한 파일 포인터 전부 닫는다
process_exec
에서 실행 중인 프로그램 파일 열 때 file_deny_write()
를 걸어 실행 중 수정 당하지 않게 해놨다
현재 스레드의 exec_file
포인터에 이를 저장하고 있으니 프로세스 종료 시 모두 해제해야 한다
if (cur->exec_file) {
file_allow_write(cur->exec_file);
file_close(cur->exec_file);
cur->exec_file = NULL;
}
file_allow_write
로 쓰기 허용 풀고 파일을 닫아 다른데서 수정, 삭제 가능케 한다
fd_table
을 palloc
과 malloc
으로 할당했기에 이를 해제해줘야 한다
fd_table_from_palloc
이라는 플래그로 palloc
으로 통해 할당했다는 것이 확인되면 palloc_free_page
로
malloc
이면 그냥 free
로 해제해주면 된다
fd_table
포인터는 NULL로 fd_cap
도 0으로 설정해주면 끝이다
현재 프로세스가 부모라면 child_status
구조체 정리한다
부모와 자식 간에도 ref_cnt
가 존재하기에 이를 1씩 깍아주는 것도 잊지말고 말이다
당연하게 0이되면 메모리 해제해주고
현재 프로세스가 자식일 경우 ref_cnt
줄이고 스스로의 상태를 exited = true
로 설정해
부모에서 이를 확인 가능케 해준다
이렇게 다해주고 나서 process_cleanup()
을 호출해주면 나머지도 다 처리해준다
여기까지가 프로젝트 2 구현 fork와 dup2의 핵심이었다