저번 글에서 핀토스 부팅부터 시작해 시스템콜이 호출되기까지의 과정을 정리했다. 다시 보니 빠진 내용이 많아 수정하면서 보완할 예정. 저거 공부하느라 한동안 밀렸던 과제를 다시 진행한다. 오늘은 저번에 구현한 exit() 수정 및 write(), open(), close(), filesize(), seek(), tell()을 구현해보자.
1. 과제 구현
exit() & thread 구조체 수정
지난 번 글에서 exit()을 미처 완성하지 못한 것을 깨달았다. 여기서 수정 과정에 대해 기록한다. exit()은 현재 프로세스를 종료시키는 시스템 콜이다. 이때 종료시키려는 스레드의 status를 바꿔줘야 한다. 따라서 아래처럼 exit_status 멤버를 스레드 구조체에 하나 만들어준 다음, exit에서 해당 구조체 멤버값을 인자로 받은 status(종료라면 0이 들어올 것)을 넣어준 뒤 thread_exit()을 실행한다.
thread 구조체 수정 & init_thread()에서 초기화
@/include/threads/thread.h
struct thread { // 이 struct thread 자체가 프로세스 디스크립터
/* Owned by thread.c. */
(...생략)
/* Owned by thread.c. */
struct intr_frame tf; /* Information for switching */
unsigned magic; /* Detects stack overflow. */
/* --- Project2: User programs - system call --- */
int exit_status; // _exit(), _wait() 구현 때 사용
/* --- Project2: User programs - system call --- */
};
static void
init_thread (struct thread *t, const char *name, int priority) {
(...)
/* project 2: system call*/
t->exit_status = 0;
}
exit()
@/userprog/syscall.c
/* 현재 프로세스를 종료시키는 시스템 콜 */
void exit(int status)
{
struct thread *t = thread_current();
t->exit_status = status;
printf("%s: exit%d\n", t->name, status); // Process Termination Message
/* 정상적으로 종료됐다면 status는 0 */
/* status: 프로그램이 정상적으로 종료됐는지 확인 */
thread_exit();
}
근데 막상 타고 들어가보니까 thread_exit()을 실행하면 process_exit()을 실행하는데, 여기에 또 짜야하는 코드가 있다. 이 부분은 나중에 구현하기로 하고 일단 패스.
write() 일부 구현 & thread 구조체 수정
지난 글에서 부팅부터 시작해 system call 호출까지 오는 과정을 쭉 훑어봤다. 그때 system call 다음에 출력하는 숫자가 있었다.
system call 다음에 오는 숫자는 바로 10이다. 이전에 우리는 system call 핸들러의 스켈레톤을 구현한 적이 있다. 유저 프로세스의 스택에 저장되어 있던 시스템 콜 넘버를 받아와서 출력한 건데 10이면 write에 해당한다. 즉, 위 상황은 시스템 콜 write를 호출하고서 process_wait()에 들어가 무한 대기에 빠져있는 것. 왜냐면 우리는 아직 write() 함수를 구현하지 않았기 때문이다.
이는 make check을 이용해 pass/fail을 확인할 때도 마찬가지인데, 왜냐면 이미 저장되어 있는 정답값과 write함수로 우리가 만들어야 할 값을 비교해야 pass인지 fail인지를 확인할 수 있을 것이다. 하지만 현재 write를 구현하지 않았으니 우리가 값 자체를 출력할 수 없는 상황이다. 따라서 우리는 1번 파일 디스크립터(STDOUT)를 갖는 파일을 생성해 얘를 write()으로 출력해주는 작업을 해줘야 한다.
과제 구현 테스트를 위해 먼저 write() 함수 일부를 구현해보자. (전체 아님! write()은 해야할 작업이 많다..) 파일 디스크립터 번호가 1인(출력을 하라는) 경우에 한해 값을 출력하는 함수를 작성한다. 이때, 버퍼에 들어있는 값을 size만큼 출력하는 putbut() 함수를 이용한다.
write() 일부 구현
int write (int fd, const void *buffer, unsigned size) {
if (fd == STDOUT_FILENO)
putbuf(buffer, size);
return size;
}
putbuf(): 버퍼 안에 들어있는 값 중 사이즈 N만큼을 console로 출력
이때도 다른 값이 콘솔로 출력하는 것을 막기 위해(동기화) 콘솔을 하나의 자원으로 설정하고 console lock을 거는 것을 확인할 수 있다.
/* Writes the N characters in BUFFER to the console. */
void
putbuf (const char *buffer, size_t n) {
acquire_console ();
while (n-- > 0)
putchar_have_lock (*buffer++);
release_console ();
}
d
process_create_initd(): 프로세스 생성시 스레드 이름 parsing 작업 추가
이전 과제에서 이 부분을 만들었다가 필요없다고 생각해서 지웠는데, 보니까 해당 프로세스 이름(우리는 args-single)만을 프로세스 이름으로 넘겨줘야 한다. 따라서 파싱 작업을 다시 아래에 추가.
tid_t
process_create_initd (const char *file_name) {
(...)
/* project 2. system call */
char *token, *last;
token = strtok_r(file_name, " ", &last);
tid = thread_create (token, PRI_DEFAULT, initd, fn_copy);
/* project 2. system call */
(...)
return tid;
}
process_wait(): write() 테스트 확인을 위해 무한 루프 해제
지금 우리가 하는 작업은 자식 프로세스를 생성해서 테스트하는 게 아니다보니 무한루프에 들어가면 빠져나올 수 없다. 따라서 이 작업을 유한한 상태로 잠시 돌린다. 이는 fork 완성 후에 다시 원상태로 돌릴 것.
int
process_wait (tid_t child_tid UNUSED) {
(...)
/* --- Project 2: Command_line_parsing ---*/
//while (1){}
for(int i = 0; i < 100000000; i++); // 테스트를 위해 잠시 무한루프 해제 -> fork 완성 전까지만
/* --- Project 2: Command_line_parsing ---*/
return -1;
}
이러면 이제 테스트를 확인할 수 있다. (다시 말하지만 write()은 아래에서 수정해야 한다.)
open() 구현
원래 Gitbook에서 제공하는 함수 순서와 지금 글에서 구현하는 함수 순서가 좀 다른데, 맨 처음에 구현하라고 말하는 fork()의 양이 상당한지라 쉬운 것부터 구현하기 위해 현재 흐름대로 하고 있음을 적어둔다.
지금 우리가 작업하는 시스템 콜은 하나같이 파일을 다루고 있다. 파일을 생성하고, 삭제하고, 파일에 write을 하고 등등.. 이는 모든 것을 파일로 처리한다는 리눅스의 철학과도 같다. 그러면 파일은 무엇일까? 나중에 마지막 주차인 file system 과제에서 다룰 것 같지만, 책에서 공부한 내용을 조금만 정리해보자.
저장 장치 역시 가상화 개념이 들어간다. 두 가지 가상화 개념이 있는데, 그 중 하나가 파일이다. 파일은 읽거나 쓸 수 있는 순차적인 바이트의 배열이다. 각 파일은 저수준 이름을 갖고 있으며, 보통 숫자로 표현되나 사용자는 이 이름을 알지 못한다. 우리는 이 이름을 inode number라고 부른다.(이따 나올 것). 각 파일은 아이노드 번호와 연결되어 있다.
우리가 여기서 다룰 open() 함수는 파일을 열어보는 개념이지만, 책에서는 해당 파일이 이미 존재할 경우에는 open()을 수행하고 없다면 새로 생성(create())한다고 한다. 우리는 이에 대해 이미 create()를 구현했으니 pintOS에서는 open()과 create()를 구별해서 사용하는 듯.
이 open()의 역할은 무엇일까? 사용자 프로세스가 파일에 접근하기 위해 요청하는 시스템 콜이다. 운영체제에게 이 파일을 열 수 있는 권한을 요청하는 것인데, open()에서 중요한 항목은 반환값이다. 여기서 반환하는 값은 파일 디스크립터 번호(fd)인데, 이 fd는 각 프로세스마다 존재하는 정수값이다. 열린 파일을 읽고 쓰는데 사용하는데, 이 파일 디스크립터는
1) 동작에 대한 수행 자격을 부여하는 역할(핸들러)이자 - 그래서 write()에서 fd값이 STDOUT_FILENO와 같은지를 체크하는 것. STDOUT_FILENO라는 숫자 자체가 콘솔에 출력하는 권한을 부여해준다고 보면 되겠다. 즉, 운영체제가 사용자 프로세스에게 권한을 주는 것. 이를 사용하여 권한이 허용하는 읽기 혹은 쓰기 같은 접근을 처리할 수 있다.
2)또는 파일 객체 자체를 가리키는 포인터로도 볼 수 있다.
운영체제는 프로세스마다 파일 디스크립터를 따로 관리한다고 했다. 핀토스에서는 프로세스가 곧 스레드이기에, 스레드 구조체에 파일 디스크립터를 관리하는 테이블을 하나 생성한다. 이것이 바로 파일 디스크립터 테이블(File descriptor table, FDT)이다(얘도 하나의 파일 형태). 스레드마다 갖고 있는 이 배열이 어떤 파일이 열려있는지를 관리한다. 이때 각 리스트 내 요소는 struct file을 가리키는 포인터에 해당하며, 이 스레드 구조체가 읽고 쓰는 실제 파일의 정보를 추적하는 데 쓰인다. 아래에 파일 디스크립터 테이블 구조체를 멤버로 하나 넣어준다.
파일 디스크립터 테이블 역시 하나의 파일 구조체 형태이며, 따라서 스레드 구조체 내에 파일 구조체 형태로 fdt를 선언한다. 이어서 우리가 해당 스레드에서 여러 파일을 관리할텐데, 해당 파일에 대한 인덱스 값을 넣기 위한 용도인 fdidx를 선언한다.
@/include/threads/thread.h
struct thread { // 이 struct thread 자체가 프로세스 디스크립터
(...)
/* --- Project2: User programs - system call --- */
int exit_status; // _exit(), _wait() 구현 때 사용
struct file **file_descriptor_table; //FDT
int fdidx; // fd index
/* --- Project2: User programs - system call --- */
};
이제 open()을 구현할 차례. GItbook에 주어진 함수 형태를 보면 아래와 같이 해당 파일을 가리키는 포인터를 인자로 받는다. 이를 토대로 함수를 구현해보자.
int open (const char *file) {
check_address(file); // 먼저 주소 유효한지 늘 체크
struct file *file_obj = filesys_open(file); // 열려고 하는 파일 객체 정보를 filesys_open()으로 받기
// 제대로 파일 생성됐는지 체크
if (file_obj == NULL) {
return -1;
}
int fd = add_file_to_fd_table(file_obj); // 만들어진 파일을 스레드 내 fdt 테이블에 추가
// 만약 파일을 열 수 없으면] -1을 받음
if (fd == -1) {
file_close(file_obj);
}
return fd;
}
우리는 먼저 해당 주소가 유효한지부터 체크한다(check_address()). 그리고 파일을 열어야 하는데, 이미 주어진 함수 filesys_open()을 사용한다. filesys_open()은 주어진 이름을 갖는 파일을 open하는 함수이다. 여기서 inode가 나오는데, 우리가 입력한 파일 이름을 컴퓨터가 알고 있는 파일 이름으로 바꾸는 과정이라고만 알아두자. 이 filesys_open()은 file_open()을 반환하고, 이 file_open은 해당 파일 구조체에 inode 관련 정보를 멤버로 넣어준 뒤 다시 file 구조체를 반환한다.
그러고 나면 우리는 해당 파일 구조체 객체를 filesys_open()의 함수의 반환값으로 받게 된다. 이를 file_obj라고 하자. 얘가 제대로 생성됐는지 체크한 다음, 이 파일 객체를 현재 돌고 있는 스레드의 파일 디스크립터 테이블에 추가해 스레드가 이 파일을 관리할 수 있도록 해준다. 여기서 fd table에 파일을 추가하도록 add_file_to_fd_table()을 구현하자. add_to_fd_table()에서는 fdt에 빈 자리가 날 때까지 fd 값을 계속 1씩 올린다. 그래서 자리가 나면 해당 자리에 파일을 배치하고 해당 디스크립터 값(=fdt의 인덱스)를 반환한다.
@userprog/syscall.c
/* 파일을 현재 프로세스의 fdt에 추가 */
int add_file_to_fd_table(struct file *file) {
struct thread *t = thread_current();
struct file **fdt = t->file_descriptor_table;
int fd = t->fdidx; //fd값은 2부터 출발
while (t->file_descriptor_table[fd] != NULL && fd < FDCOUNT_LIMIT) {
fd++;
}
if (fd >= FDCOUNT_LIMIT) {
return -1;
}
t->fdidx = fd;
fdt[fd] = file;
return fd;
}
코드를 보면 fdt가 하나의 배열처럼 되어 있다. 그렇다. 파일 디스크립터 테이블은 파일을 담는 배열에 해당한다. 이를 위해 thread_create()에서 새로운 스레드를 생성할 때 fdt를 위한 하나의 페이지를 할당해준다. 이때, 페이지의 크기는 (1<<12) 인데, 파일 구조체 주소 크기가 8바이트(1<<3)이므로 이를 분리하면 (1<<9)만큼의 공간을 할당받는 것과 같다. 이 크기를 매크로로 설정해준다.(FDT_LIMIT) 이를 위해 파일 디스크립터 테이블을 담는 하나의 페이지 공간을 할당해준다.
@/threads/thread.c
tid_t
thread_create (const char *name, int priority,
thread_func *function, void *aux) {
(...)
/* --- project 2: system call --- */
t->file_descriptor_table = palloc_get_multiple(PAL_ZERO, FDT_PAGES);
if (t->file_descriptor_table == NULL) {
return TID_ERROR;
}
t->fdidx = 2; // 0은 stdin, 1은 stdout에 이미 할당
t->file_descriptor_table[0] = 1; // stdin 자리: 1 배정
t->file_descriptor_table[1] = 2; // stdout 자리: 2 배정
}
이때 적어준 매크로 값은 thread.h에서 구현한다.
@/include/threads/thread.h
(...)
/* --- project 2: system call --- */
#define FDT_PAGES 3
#define FDT_COUNT_LIMIT FDT_PAGES *(1<<9) // limit fdidx
여기까지 open() 구현 완료.
filesize() 구현
열려있는 파일을 파일 디스크립터 테이블에서 찾아 해당 파일의 크기를 반환하는 함수이다. 파일의 크기는 어디에 저장되어 있을까?
struct file -> struct inode -> struct inode_disk data -> off_t length에 정보가 담겨있다.
여기서 inode는 위에서 얘기했듯 해당 파일을 컴퓨터가 읽을 수 있는 형태를 가리키는 일종의 메타데이터이다. 즉, 파일의 메타데이터를 담고 있는 자료구조가 inode. 여기서 inode_disk 구조체는 inode 데이터를 디스크로부터 읽어들이는 역할인듯하다. 그 안에 있는 멤버에 파일 크기(길이) 정보가 담겨있다.
filesys/file.c에 가면 file_length() 함수가 있다. 이 함수는 파일 구조체 포인터를 인자로 받아 파일의 메타데이터 inode 안에 있는 length를 반환한다.
이를 구현하려면 먼저 파일 디스크립터 값을 인자로 받아 파일 디스크립터 테이블에서 해당 파일을 가리키는 구조체를 반환하는 함수가 필요하다. 이를 위해 fd_to_struct_filep()를 구현한다.
/* fd 값을 넣으면 해당 file을 반환하는 함수 */
struct file *fd_to_struct_filep(int fd) {
if (fd < 0 || fd >= FDCOUNT_LIMIT) {
return NULL;
}
struct thread *t = thread_current();
struct file **fdt = t->file_descriptor_table;
struct file *file = fdt[fd];
return file;
}
(*항상 예외 케이스 고려할 것! fd 값이 0보다 작거나 파일 디스크립터 배열에 할당된 범위를 넘어서면 파일이 없다는 뜻이니 NULL을 반환해야 한다.)
file의 크기를 구하는 함수는 이미 제공해주는 함수인 file_length()를 이용한다.
/* file size를 반환하는 함수 */
int filesize(int fd) {
struct file *fileobj = fd_to_struct_filep(fd);
if (fileobj == NULL) {
return -1;
}
file_length(fileobj);
read() 구현
이번에는 해당 파일로부터 값을 읽어 버퍼에 넣는 함수인 read()를 구현한다. read 함수는 인자로 파일 디스크립터, 버퍼, 그리고 버퍼의 사이즈를 인자로 받는다.
먼저 버퍼가 유효한 주소인지 check_address()로 체크한다. 그 다음에는 fd_to_struct_filep()로 파일 객체를 찾는다.
여기서 Gitbook을 보면, fd값이 0일 경우(=STDIN) input_getc() 함수를 사용해 키보드 입력을 읽어오라고 하였다. 이 부분을 먼저 구현해준다.
Reads size bytes from the file open as fd into buffer.
Returns the number of bytes actually read (0 at end of file),
or -1 if the file could not be read (due to a condition other than end of file).
fd 0 reads from the keyboard using input_getc().
그 다음에는 파일을 읽어들일 수 없는 케이스에서는 -1을 반환하라고 되어 있다. 대표적으로 fd값이 1일 경우(=STDOUT) 출력을 나타내기에 -1을 반환한다.
그 외 나머지는 fd로부터 파일 객체를 찾은 뒤, size 바이트 크기만큼 파일을 읽어 버퍼에 넣어준다.
이때, lock을 이용해 커널이 파일을 읽는 동안 다른 스레드가 이 파일을 건드리는 것을 막아야 한다. 그렇지 않으면 원래 읽어들이려는 파일값과 다른 값을 읽는 케이스가 발생할 수 있다. 이를 위해 먼저 userprog/syscall.h에 파일 시스템과 관련된 lock인 filesys_lock을 하나 선언한다. 또한, 시스템 콜을 초기화하는 syscall_init()에도 역시 lock을 초기화하는 함수 lock_init()을 선언해줘야 한다.
@/include/userprog/syscall.h
struct lock filesys_lock;
@/userprog/syscall.c
void syscall_init (void) {
(...)
lock_init(&filesys_lock);
}
int read(int fd, void *buffer, unsigned size) {
// 유효한 주소인지부터 체크
check_address(buffer); // 버퍼 시작 주소 체크
check_address(buffer + size -1); // 버퍼 끝 주소도 유저 영역 내에 있는지 체크
unsigned char *buf = buffer;
int read_count;
struct file *fileobj = fd_to_struct_filep(fd);
if (fileobj == NULL) {
return -1;
}
/* STDIN일 때: */
if (fd == STDIN_FILENO) {
char key;
for (int read_count = 0; read_count < size; read_count++) {
key = input_getc();
*buf++ = key;
if (key == '\0') { // 엔터값
break;
}
}
}
/* STDOUT일 때: -1 반환 */
else if (fd == STDOUT_FILENO){
return -1;
}
else {
lock_acquire(&filesys_lock);
read_count = file_read(fileobj, buffer, size); // 파일 읽어들일 동안만 lock 걸어준다.
lock_release(&filesys_lock);
}
return read_count;
}
write() 구현
위에서 일부만 구현했던 write()를 이번에 전부 구현한다. 위에서는 syscall number가 1일 경우(=STDOUT, 화면에 출력해줘야 하는 경우) 버퍼에 있는 값에서 size만큼 출력하도록 함수를 구현했다. 이제 다른 fd값이 들어왔을 경우를 마저 작성해주자.
먼저 fd = 0(=STDIN)인 경우, 표준 입력에 대한 파일 디스크립터 값은 해당 함수와 관련이 없으므로 -1을 반환해준다.
그 외 나머지 fd 값의 경우, 버퍼로부터 값을 읽어와 해당 파일에 작성해준다. 이때 역시 read와 마찬가지로 다른 스레드가 동시에 파일에 접근하면 문제가 발생할 수 있으니 lock을 걸도록 하자. 그다음에는 이미 만들어져있는 함수인 file_write()를 이용해 size byte만큼 버퍼로부터 값을 읽어서 file에 작성한다.
int write (int fd, const void *buffer, unsigned size) {
check_address(buffer);
struct file *fileobj = fd_to_struct_filep(fd);
int read_count;
if (fd == STDOUT_FILENO) {
putbuf(buffer, size);
read_count = size;
}
else if (fd == STDIN_FILENO) {
return -1;
}
else {
lock_acquire(&filesys_lock);
read_count = file_write(fileobj, buffer, size);
lock_release(&filesys_lock);
}
}
write()을 구현하고서 테스트를 돌렸는데..뭔가 이상하다. all fail이 뜬다. 확인해보니 인자가 하나만 출력되더라.
대체 무슨 문제지????? 하고서 거의 몇 시간을 뜯어보니..
시스템 콜 핸들러 문제였다...
1. break()를 작성하지 않았고
2. 곧바로 thread_exit()으로 넘어가게 해놔서 메세지를 하나만 출력하고 곧바로 스레드가 종료(thread_exit())되는 것이었다.
고치기 전/후 코드를 보면 아래와 같다.
고치기 전
/* The main system call interface */
void
syscall_handler (struct intr_frame *f UNUSED) {
switch(sys_number) {
(...)
case SYS_WRITE:
f->R.rax = write(f->R.rdi, f->R.rsi, f->R.rdx);
(...)
}
thread_exit();
}
고친 후
/* The main system call interface */
void
syscall_handler (struct intr_frame *f UNUSED) {
switch(sys_number) {
(...)
case SYS_WRITE:
f->R.rax = write(f->R.rdi, f->R.rsi, f->R.rdx);
break;
default:
thread_exit();
(...)
}
}
이제는 제대로 통과되는 것을 확인할 수 있다. 다시 본론으로 넘어가자.
seek() 구현
seek()는 열려있는 파일 fd에 쓰거나 읽을 바이트 위치를 인자로 넣어줄 position 위치로 변경하는 함수이다. 파일을 읽거나 쓸 때 기본 세팅은 항상 파일의 시작 위치로 되어 있다. 이를 우리가 입력해줄 position 위치부터 읽을 수 있도록 해당 position을 찾는 함수이다. 이를 위해 파일 객체 내 멤버 값인 pos를 position으로 변경해준다.
void seek (int fd, unsigned position);
위 seek() 함수는 인자로 fd와 position을 받는다. fd를 이용해 파일을 찾고 해당 파일 객체의 pos를 입력받은 position으로 변경한다. 이를 위해 제공 함수 file_seek()를 이용한다. 사실 파일 구조체 내 멤버 값만 바꾸면 되는 거라 굳이 이 함수를 쓰지 않아도 간단하다.
@/userprog/syscall.c
void seek(int fd, unsigned position) {
if (fd < 2) {
return;
}
struct file *file = fd_to_struct_filep(fd);
check_address(file);
if (file == NULL) {
return;
}
file_seek(file, position);
}
@/filesys/file.c
void
file_seek (struct file *file, off_t new_pos) {
ASSERT (file != NULL);
ASSERT (new_pos >= 0);
file->pos = new_pos;
}
tell() 구현
tell()는 seek()와 비슷한 개념이다. 위에서 설명했듯, 파일을 읽으려면 어디서부터 읽어야 하는지에 대한 위치 pos를 파일 내 구조체 멤버에 정보로 저장한다. fd 값을 인자로 넣어주면 해당 파일의 pos를 반환하는 함수가 tell()이다. 이 역시 제공 함수 file_tell() 함수를 이용하면 간단하다.
unsigned tell (int fd) {
if (fd <2) {
return;
}
struct file *file = fd_to_struct_filep(fd);
check_address(file);
if (file == NULL) {
return;
}
return file_tell(fd);
}
close() 구현
파일 디스크립터 관련 마지막 시스템 콜인 close()! 열려있는 파일을 파일 디스크립터 테이블에서 찾아 해당 파일을 닫는 함수이다.
이어서 filesys/file.c에 있는 file_close()를 이용해 종료한다. file_close()는 file 포인터를 인자로 받아 해당 파일을 종료시킨다. 이를 위해 현재 스레드 내 파일 디스크립터 테이블에 들어 있는 해당 파일을 가리키는 포인터를 테이블 내에서 제거해준다.
지금까지 파일 디스크립터 관련 시스템 콜 구현을 진행했다. 다음은 바로 이어서 프로세스 관련 시스템 콜인 fork(), exec(), wait()을 구현하고 마무리!(산 넘어 산..)