
이번주는 계획대로 돌아간 적이 없다.
하지만 PintOS는 계획대로 돌아간다.
왜 알아서 돌아가지를 않는가?
우리는 그만 돌아버렸다.
다시 돌아온 PintOS 주차다.
아니 애초에 돌아온적이 없다. 계속 핀토스 주차다.
이번주에 팀원들 전부 멘탈 나가서 계획도 다 망가지고 허겁지겁 구현하는데 바빴다.
그래도 일단 뭘 구현했는지 더듬어보면....
뭔가 내용 자체는 간단하면서 구현하는데 오래 걸린 부분이다.
핀토스에서는 프로세스를 시작하기 전에 사용자가 입력한 명령어 문자열을 그냥 통째로 넘기는게 아니라, argc, argv 형태로 바꿔서 유저 스택에 올려줘야 한다. 그걸 구현했다.
먼저 명령어를 파싱 해줘야 한다. strtok_r()를 이용해서 문자열을 공백 단위로 자른다.
여기서 주의할 점은 실행 파일 이름을 버리는게 아니라, 그게 argv[0]이 된다는 것이다. 예를 들어 echo hello면 argv[0]은 echo, argv[1]은 hello가 된다.
그리고 실행 파일을 열 때는 전체 문자열이 아니라 argv[0], 즉 프로그램 이름만 사용해야 한다. 처음에는 그냥 명령어 전체를 넘기면 되는거 아닌가 싶었는데, 그렇게 하면 args-multiple some arguments 같은 문자열 전체를 파일 이름으로 찾으려고 해서 당연히 실행 파일을 못 찾는다.
파싱된 인자들은 사용자 스택에 복사해야 한다.
여기서 중요한건 파싱된 문자열은 아직 커널 메모리에 있다는 점이다. 유저 프로그램은 커널 메모리를 직접 접근하면 안 되기 때문에, 문자열들을 유저 스택에 다시 복사해줘야 한다.
스택은 아래 방향으로 자라기 때문에 인자 문자열들을 뒤에서부터 차례대로 넣었다. 그리고 각 문자열이 유저 스택 어디에 저장됐는지 주소를 따로 기억해두고, 그 주소들을 다시 argv 배열 형태로 스택에 넣었다.
그 다음 8바이트 정렬을 맞추고, argv[argc]에는 NULL을 넣어준다. 마지막으로 rdi에는 argc, rsi에는 argv의 시작 주소를 넣어준다.
이렇게 해줘야 유저 프로그램의 main(argc, argv)가 우리가 평소 쓰던 것처럼 동작한다.
정확히 말하면 load()는 ELF 파일을 읽고 시작 주소를 rip에 넣고, 인자들은 스택과 레지스터에 세팅한다. 이후 do_iret()을 통해 커널 모드에서 유저 모드로 넘어가면서 프로그램이 시작된다.
이번 프로젝트에서 나는 파일 관련 메타 시스템 콜과, 입출력 시스템 콜을 구현했다.
시스템 콜은 유저 프로그램이 커널 기능을 쓰기 위해 요청하는 통로다. 유저 프로그램은 파일 시스템이나 커널 메모리에 직접 접근할 수 없기 때문에, open, read, write 같은 요청을 시스템 콜로 커널에게 부탁한다.
이번 구현에서 syscall_handler()에서 시스템 콜 번호를 보고 어떤 함수를 실행할지 나누었다. x86-64에서는 시스템 콜 번호가 rax에 들어오고, 인자는 순서대로 rdi, rsi, rdx 같은 레지스터에 들어온다. 그래서 핸들러에서 이 값을 꺼내 각 시스템 콜 함수로 넘겨주었다.
시스템 콜에서 가장 귀찮았던 부분은 포인터 검증이었다.
유저 프로그램이 커널에게 이상한 주소를 넘길 수 있기 때문이다. 예를 들어 NULL을 넘기거나, 커널 주소를 넘기거나, 매핑되지 않은 주소를 넘기면 커널이 그대로 접근하다가 터질 수 있다.
그래서 문자열은 check_string(), 버퍼는 check_buffer()로 검사했다.
특히 read, write는 버퍼가 여러 페이지에 걸쳐 있을 수 있어서 시작 주소 하나만 검사하면 안 된다. 페이지 경계를 넘어가는 경우도 확인해야 해서 페이지 단위로 검사하도록 했다.
파일을 열면 커널 내부에서는 struct file *이 생기는데, 유저 프로그램에게 이 포인터를 그대로 줄 수는 없다. 그래서 정수 번호인 파일 디스크립터를 준다.
fd 0은 표준 입력, fd 1은 표준 출력으로 예약해두고, 실제 파일은 fd 2부터 사용했다.
파일을 열면 fd table에 struct file *을 저장하고, 유저 프로그램에는 fd 번호만 반환한다. 이후 read, write, close 같은 시스템 콜은 이 fd 번호를 다시 fd table에서 찾아 실제 파일 객체로 바꿔서 처리한다.
처음에는 read, write를 구현했다.
read는 fd 0이면 키보드 입력을 읽어온다. 그래서 input_getc()로 한 글자씩 읽어서 유저 버퍼에 넣었다.
fd가 2 이상이면 fd table에서 파일을 찾고 file_read()로 파일 내용을 읽어온다. 이때 유저 버퍼에 값을 써야 하므로 쓰기 가능한 버퍼인지 검사해야 한다.
write는 반대로 fd 1이면 화면 출력이다. 이 경우 putbuf()로 버퍼 내용을 출력한다.
fd가 2 이상이면 파일에 쓰는 경우라서 fd table에서 파일을 찾고 file_write()를 호출한다.
여기서도 마찬가지로 버퍼 검증이 중요했다. 잘못된 주소를 넘겼을 때 커널이 죽는게 아니라 해당 프로세스를 exit(-1)로 종료해야 하기 때문이다.
추가로
create, remove, open, close, filesize, seek, tell을 구현했다.
원래 구현할 생각이 없었는데 어쩌다보니 떠맡고 구현했다. 그래도 카이스트 강의에서 제공해주는 힌트가 있어서 활용했다.
create와 remove는 파일 이름 문자열을 검사한 뒤 파일 시스템 함수로 넘겨주면 됐다.
open은 파일을 열고, 성공하면 fd table에 등록한 뒤 fd 번호를 반환한다. 만약 fd table에 넣을 수 없으면 열었던 파일을 다시 닫아줘야 한다.
close는 fd로 파일을 찾고, 실제 파일을 닫은 뒤 fd table에서 해당 칸을 비워준다.
filesize, seek, tell은 fd로 파일을 찾은 다음 각각 파일 길이 확인, 위치 이동, 현재 위치 확인을 해준다.
이쪽은 로직 자체는 어렵지 않았는데, fd table이 없으면 D 파트의 read, write도 진행이 안 되기 때문에 생각보다 중요한 기반 작업이었다.
파일 시스템 함수들은 여러 프로세스가 동시에 접근할 수 있다.
그래서 파일을 만들거나 열거나 읽고 쓰는 부분에는 전역 락인 filesys_lock을 걸어주었다. 사실 세밀하게 락을 나누는 방법도 있겠지만, 이번 단계에서는 일단 파일 시스템 접근을 크게 하나로 감싸는 방식으로 구현했다.
덕분에 구조는 단순해졌지만, 어디서 락을 잡고 어디서 풀어야 하는지 신경을 꽤 써야 했다. 중간에 실패해서 return 할 때 락을 안 풀면 그대로 교착 상태가 날 수 있기 때문이다.
이번주 역시나 바빴다. 시간 분배가 망한것도 여전했고 오히려 개인별로 구현해서 합친다고 했다가 뭔가 더 꼬여버린거 같기도 했다.
발표도 일이 꼬이고 복잡해진거 위주로 얘기를 했는데, 오히려 코치님은 팀끼리 싸운줄 알고 오해를 하셨다. 딱히 그런건 없었다.... 각자 힘든 일이 있었던거지...
다음주는 가상 메모리를 다룬다.
2주씩이나 하는데, 얼마나 힘들지 가늠도 안간다.
그리고 몸이 영 제정신이 아닌데, 잘 할수 있을지 의문이다.