-
[PintOS] Project 2 - User Memory Access (1)Projects/Krafton_Jungle_4 2024. 3. 18. 20:02728x90
User Memory Access
arguement passing이 끝난 후, 이제 user memory access 부분을 하고 있다. 깃북을 보자
system call을 사용하기 위해서는 유저 가상 주소 공간에 있는 데이터들을 읽고 쓸 수 있는 방법을 제시해야 한다. 인자들을 얻을 때는 문제가 되지 않지만 만약 시스템 콜로 얻게 된 인자 즉, 유저가 넘겨준 포인터들이 올바르지 못한 곳을 가리키고 있다면?
이것이 User Memory Access의 주요 문제이고 해결해야만 한다. 문제 케이스는 세 가지 정도로 나뉜다.
- 포인터가 null을 가리킬 경우
- 포인터가 매핑되지 않은 가상 메모리를 가리킬 경우
- 포인터가 커널 메모리를 가리킬 경우
깃북을 좀 더 읽어보면, 이를 어떻게 해결할 지 좀 더 자세히 나온다.
유저가 전달한 잘못된 포인터에 문제 없이 잘 대응하기 위해서는 적어도 2개의 합리적인 방법이 있습니다. 첫 번째 방법은 유저가 전달한 포인터에 문제가 없는지 검사한 후에 역참조하는 것입니다. 만일 당신이 이 방법을 선택한다면, thread/mmu.c 와 include/threads/vaddr.h에 있는 함수들을 살펴보세요. 이 방법이 유저 메모리 접근을 가장 쉽게 처리할 수 있는 방법입니다.
오케이 일단 하란 대로 해야지.
나는 여기서 아이디어를 얻어서, syscall.c의 syscall_handler 내에서 유저가 유효한 주소를 부르고 있는지 검사하는 방법으로 구현하려고 하였다. thread/mmu.c와 include/threads/vaddr.h에 이를 위한 메서드와 함수들을 활용할 예정이다.
왜 syscall.c인가?
시스템 콜이 불렸을 때를 확인해야 하므로, syscall_handler가 호출되었을 때를 확인하는 것이 맞다고 생각했다.
만약 시스템 콜이 아니라 다른 방법으로 유효하지 않은 곳을 접근하려 할 때는 어떻게 해야하지? 라는 생각이 좀 들었다.
check_address
void check_address(struct intr_frame *intr_f) { printf("checking if address is invalid.. \n"); printf("your requested address is: %x \n", intr_f->rsp); // printf("your request's rsi is: %x \n", intr_f->R.rsi); if (is_user_vaddr(intr_f->rsp)) { printf("your address is user's \n"); } if (!is_user_vaddr(intr_f->rsp)) { thread_exit(); exit(-1); } if (intr_f->rsp == NULL) { thread_exit(); exit(-1); } // if (intr_f->rsp) // { // thread_exit(); // exit(-1); // } }
check_address라는 함수를 만들어서 system call handler에서 address를 확인할 수 있도록 하였다.
하지만 2번 케이스인 '포인터가 매핑되지 않은 가상 메모리를 가리키는 경우'에 대한 아이디어가 떠오르지 않았고, 매핑되지 않았다는 것에 대한 의미가 잘 와닿지 않았다.
그러다 vaddr.h에 나와있는 매크로들은 사용했지만 mmu.c에 나와있는 함수들은 사용하지 않았다는 생각이 문득 들었고, 이들을 보았다.
mmu.c에 나와있는 함수들은 대부분 pml4와 관련되어 있었다.
pml4란?
https://pongpongi.tistory.com/11
여기에 자세히 나와있다 ^^
mmu.c에 나와있는 함수 중 pml4e_walk에 대한 주석을 보면, 다음과 같이 말하고 있다.
페이지 맵 레벨 4, pml4의 가상 주소 VADDR에 대한 페이지 테이블 항목의 주소를 반환합니다.
PML4E에 VADDR에 대한 페이지 테이블이 없는 경우 동작은 CREATE에 따라 달라집니다.
CREATE가 참이면 새 페이지 테이블이 생성되고 해당 페이지 테이블에 대한 포인터가 반환됩니다.
그렇지 않으면 null 포인터가 반환됩니다.그리고 pml4_get_page에 대한 주석은 다음과 같다.
pml4에서 사용자 가상 주소 UADDR에 해당하는 실제 주소를 조회합니다.
해당 물리적 주소에 해당하는 커널 가상 주소를 반환하거나 UADDR이 매핑되지 않은 경우 널 포인터를 반환합니다.찾았다! 주석을 통해 유추해 보자. 우리는 이전에 thread 구조체에 pml4라는 요소를 넣어주는 것을 확인한 적 있다.
그렇다면 이 pml4를 확인하여 무언가를 해야하는데, pml4는 최종적으로 page table까지 가서 물리적 주소와 매핑이 되어 있는지 여부를 알기 위한 첫 번째 페이지 테이블의 주소이다. 그리고 pml4 테이블에는 유추해 보았을때, 유저 가상 주소와 pml4의 인덱스 값이 매핑되어 있을 것이다.
그리고 만약 매핑되어 있지 않다면, 그것은 2번 케이스에 해당되는 것이라고 생각하게 되었다.
그렇다면 우리는 현재 쓰레드의 pml4를 pml4_get_page를 통해 확인하면 될 것이다.
여기서 pml4_get_page의 두 번째 인자인 uaddr에 무엇이 들어가야할지에 대한 고민이 있다.
나는 일단 스택 포인터의 주소를 넣어주기로 했다.
다음과 같이 수정하였다.
check_address 1차 수정
void check_address(struct intr_frame *intr_f) { printf("checking if address is invalid.. \n"); printf("your requested address is: %x \n", intr_f->rsp); if (is_kernel_vaddr(intr_f->rsp)) { printf("requested address is kernel's address \n"); exit(-1); } if (intr_f->rsp == NULL) { printf("requested address is NULL \n"); exit(-1); } if (pml4_get_page(thread_current()->pml4, intr_f->rsp) == NULL) { printf("requested address is not mapped \n"); exit(-1); } else { printf("syscall request is valid, moving to syscall handler... \n"); } }
일단 여기까지 했을 때는 page fault가 발생하면서 터진다.
디버깅을 하고 있는데, is_user_vaddr까지는 잘 들어온다.
syscall_handler를 더 수정해야할 것 같다.
시스템콜의 번호에 따라서 케이스를 나눠 해당하는 시스템콜로 처리해주는 것이 필요하다.그렇게 하기 위해서는 시스템 콜 번호가 interrupt frame의 어디에 저장되는지를 알아야 한다.
이제 syscall gitbook을 읽어야 할 때가 온 것 같다. 읽어보자!
읽고 난 후 구현을 하던 와중에, page_fault 에러가 계속 났다...
내가 계속 찜찜했던 부분인 push_stack 함수의 마지막 부분이 걸렸다.
stack에 push를 하고 난 다음엔 rdi와 rsi 값을 설정해주어야 하는데, rdi는 argc값으로 설정하고 rsi 값은 argv 배열의 시작점으로 설정해야 한다.
그러나 나는 intr_f->R.rsi = argv[0]과 같이 직접적으로 가르키고 있었고, 이는 유효하지 않은 주소였던 것 같다.
따라서 rsi를 rsp 포인터가 가리키고 있는 위치에서 8만큼만 올리면서 문제는 해결되었다.
void push_stack(struct intr_frame *intr_f, char **argv, int argc) { char *addrv[128]; for (int i = argc - 1; i >= 0; i--) // 명령어 인자 데이터 넣기 { size_t len = strlen(argv[i]) + 1; intr_f->rsp -= len; memcpy((void *)intr_f->rsp, argv[i], len); addrv[i] = intr_f->rsp; } intr_f->rsp = intr_f->rsp - sizeof(uint8_t *); // 스택 포인터를 8의 배수로 반올림 memset((void *)intr_f->rsp, 0, sizeof(uint8_t *)); intr_f->rsp = intr_f->rsp - sizeof(char *); // 스택 포인터를 8의 배수로 반올림 memset((void *)intr_f->rsp, 0, sizeof(char *)); for (int j = argc - 1; j >= 0; j--) { intr_f->rsp -= sizeof(char *); memcpy((void *)intr_f->rsp, &addrv[j], sizeof(char *)); } intr_f->rsp = intr_f->rsp - sizeof(void (*)()); // return할 함수가 있을 시 return address 설정 memset((void *)intr_f->rsp, 0, sizeof(void (*)())); intr_f->R.rdi = argc; // intr_f->R.rsi = argv[0]; - 수정한 부분 intr_f->R.rsi = intr_f->rsp + 8; }
이제 exit과 halt는 뭔가 뭔가 되는 것 같다....?
문제가 해결되고 난후의 syscall_handler 코드는 다음과 같다.
void syscall_handler(struct intr_frame *f UNUSED) { // TODO: Your implementation goes here. printf("system call!\n"); // printf("system call number: %d \n", f->R.rax); check_address(f); switch (f->R.rax) { case SYS_HALT: halt(); break; case SYS_EXIT: // printf("exitting...\n"); exit(f->R.rdi); break; case SYS_EXEC: break; case SYS_WAIT: break; case SYS_CREATE: break; case SYS_REMOVE: break; case SYS_OPEN: break; case SYS_FILESIZE: break; case SYS_READ: break; case SYS_WRITE: break; case SYS_SEEK: break; case SYS_TELL: break; case SYS_CLOSE: break; case SYS_MMAP: break; } }
exit, halt
void exit(int status) { printf("%s: exit(%d)\n", thread_current()->name, status); thread_exit(); } void halt(void) { power_off(); }
일단 했다 치고 넘어가보자.
다음 단계는 create이다.
그런데, 파일 디스크립터와 파일 디스크립터 테이블에 대한 이해가 좀 더 필요해 보인다. 이것부터 학습하자.
파일 디스크립터와 테이블에 대해 학습을 하던 중, 문득 이런 생각이 들었다.
과연 나는 시스템 콜 호출의 과정을 이해하고 있을까?
아무리 구현을 어찌 저찌 해낸다 한들 왜 그렇게 했는지, 어떻게 그렇게 했는지를 설명 못하면 구현을 한 의미가 없다.
그래서 이해를 하기 위해 동료에게도 물어보고, 글도 찾아보았고 이제 그 내용을 좀 정리해보겠다.
- 유저 프로그램에서 시스템 콜을 부르면 user/lib/syscall.c 내에 있는 시스템 콜 함수가 호출된다.
- 시스템 콜 함수는 해당 파일 안의 매크로인 syscall1 ~ syscall6을 통하여 인자의 개수를 7개로 고정한 뒤 syscall 함수를 부른다.
깃북에 자세한 내용이 나와있다.더보기x86-64에서는 제조사가 syscall이라는 시스템을 위한 특별한 명령어를 제공한다.
이 명령어는 시스템 콜 핸들러를 호출하는 빠른 방법을 제공한다.
요즘엔 syscall 명령어가 x86-64에서 시스템 콜을 불러올 때 가장 흔하게 사용되는 수단이다.
PintOS에서 유저 프로그램은 시스템 콜을 만들기 위해 syscall을 불러운다.
syscall 명령어를 불러오기 전에 시스템 콜 번호와 추가적인 인자는 레지스터에 일반적인 방법으로 설정된다.
%rax는 시스템 콜 번호이다.4번째 인자는 %r10이다. %rcx가 아니다.
syscall_handler()가 제어권을 얻으면 시스템 콜 번호는 rax에 있고, 인자는 %rdi, %rsi, %rdx, %r10, %r8, %r9 순서로 전달된다.시스템 콜 핸들러를 호출한 호출자의 레지스터는 전달받은 struct intr_frame에 접근할 수 있다. (intr_frame은 커널 스택에 있다.)
함수 리턴 값을 위한 x86-64의 관례는 그 함수 리턴 값을 rax 레지스터에 넣는 것이다.
값을 리턴하는 시스템 콜도 struct intr_frame의 rax 멤버를 수정하는 식으로 만들어질 수 있다. include/lib/user/syscall.h 를 포함하는 유저 프로그램이 보게 되는 시스템 콜들의 프로토타입들이다. 이 헤더파일들과 include/lib/user에 있는 모든 것들은 유저 프로그램만 사용한다.
각 시스템 콜의 번호는 include/lib/syscall-nr.h에 정의되어 있다. - syscall 함수에서는 레지스터 값을 세팅해중 뒤 syscall instruction(syscall\n 부분)을 부른다.
- syscall instruction은 userprog에 있는 syscall_init()을 실행하고, 그 안의 syscall_entry()로 이어진다.
syscall_entry()는 syscall_entry.S를 실행하고, 어셈블리어로 구성된 코드이다.
여기서부터 사용자 프로그램이 아닌 시스템 콜 코드이다! - syscall_entry의 진행 사항은 다음과 같다.
- rbx, r12 값을 임시로 저장해두고 rsp 역시 rbx에 값을 옮겨둔다
- 스레드를 생성할 때 두었던 커널 스택 공간을 가리키는 rsp값(tss에 저장되어 있는)을 꺼내서 유저의 rsp에 덮어쓴다.
이 때부터 커널 스택 공간으로 들어간다. - syscall_handler의 첫 번째 인자(%rdi)를 만들기 위해 rsp에 intr_handler 구조체에 들어가는 순서대로 레지스터 값을 push한다.
- rsp의 내용을 rdi로 옮겨준다 (movq %rsp, %rdi)
- syscall_handler를 호출한다
결국 유저 프로그램의 실행 정보는 syscall_handler로 전달되는 intr_frame에 저장된다.
이제 syscall_handler를 조금 더 수정해줘야 할 것 같다.
syscall_handler를 호출할 때 이미 인터럽트 프레임에는 해당 시스템 콜 넘버에 맞는 인자 수만큼 들어있다.
그러니 각 함수별로 필요한 인자 수만큼 인자를 넣어준다. 이때 rdi, rsi, ...얘네들은 특정 값이 있는 게 아니라 그냥 인자를 담는 그릇의 번호 순서이다. 어떤 특정 인자와 매칭되는 게 아니라 첫번째 인자면 rdi, 두번째 인자면 rsi 이런 식이니 헷갈리지 말 것.
나는 레지스터별로 인자들을 담는 것이 어떤 역할에 따라서 넣는 줄 알았는데... 그냥 순서별로 넣나 보다..
여기까지 왔을 때는 system call의 과정이 어떻게 이뤄지는지 너무 궁금해졌다. 해당 블로그에서 정리해둔 좋은 글이 있어서 이를 따라가보려고 한다.
system call 실행 흐름
pintos 부팅 ~ 파일 실행 전까지
pintos --fs-disk=10 -p tests/userprog/args-single:args-single -- -q -f run 'args-single onearg'
다음과 같은 명령어를 우리의 터미널에 입력해보았다고 하자.
명령줄이 어떻게 파싱되는지는 이전의 과정에서 설명했으니 넘어가겠다.
1. qemu 에뮬레이터 실행
mac -> linux : docker의 ubuntu를 활용했다.
linux -> pintOS : 바로 이 qemu를 이용한다.2. int main() 실행: kernel command line : -q -f put args-single run 'args-single onearg'
에뮬레이터 실행 이후 바로 Pintos가 부팅된다.
Pintos는 init.c를 실행하면서 시작된다.- argv = read_command_line()에서 우리가 입력한 input 코드를 읽어서 파싱한다.
3. main() 안의 thread_init() 실행: main thread 생성 및 실행
thread_init()을 통해 main이라는 이름의 스레드가 세팅되고 메모리가 할당된다.
스레드 구조체 내의 interrupt frame 멤버인 tf의 하위 멤버인 rsp에 커널 스택 포인터 위치를 저장한다.
즉, main 스레드의 rsp를 스택의 가장 끝 영역이자 커널 스택 포인터의 위치로 설정한다.4. palloc_init() 실행: Pintos booting with ~~ / base_mem: ~~ / ext_mem: ~~
명령어를 읽는 작업을 끝나면 각종 초기화 세팅을 한다. 그 중 하나로, 페이지 할당 초기화 작업이다.
5. tss_init() 실행
main 스레드의 rsp를 스택의 가장 끝 영역으로 설정하는지에 대한 이유를 설명하셨는데 잘 이해 안된다.
일단 넘어가기..6. fsutil_put() 실행: Putting 'args-single' into the file system..
가상 디스크에 있는 명령어를 복사해서 하드에 넣어주는 것인가? 싶다
7. run_action() -> run_task() 실행: Executing 'args-single onearg'
run_task에서는 process_create_initd()를 실행해서 'args-single onearg'에 대한 프로세스를 생성한다.
또한 이 때, wait()을 통해 무한 루프로 들어가는데 이전에는 타이머 인터럽트로 이를 방해했으나 여기서는 사용되지 않는다.8. process_create_initd() -> initd() 실행: 첫 유저 프로세스 실행
thread_create()에서 initd()를 스레드의 실행할 함수로 넣어서 해당 스레드가 하는 역할이자 작업을 할당한다.
또한 파일 이름으로 스레드의 이름을 정한다 즉, 스레드 이름 = 실행 파일 이름이다.
initd()는 첫 번째 유저 프로세스를 실행하는 함수인데, 다음부터는 fork()를 통해 프로세스를 생성하면 되기 때문에 첫 프로세스만 initd()로 만든다.
이후 initd()는 process_init()과 process_exec()를 실행한다.9. process_exec() 실행
이전 과제에서 했던 작업인 argument parsing 및 유저 커널 스택에 정보를 올리는 작업(load)을 수행한다.
이 작업을 수행하는 주체 역시도 커널 스레드이다.
load()를 통해 사용자 프로세스 작업을 수행하기 위한 인터럽트 프레임 구조체 내 정보를 유저 커널 스택에 쌓는다.
이후 push_stack() 함수를 실행해 입력받은 인자들 역시 유저 커널 스택에 쌓는다.
여기까지 봐오면서 헷갈리는 점은 다음과 같다.
커널 스레드란 무엇인가?
유저 커널 스택이란 무엇인가?
결국 load()에서 _if에 들어갈 여러가지 정보들을 레지스터에 저장하는 과정을 거치고
load()가 끝나고 나면 나면 do_iret()을 수행하는데 여기서 드디어 진짜 사용자 프로세스로 CPU가 넘어가며 load에서 수정했던 _if의 값으로 레지스터 값을 수정한다.10. do_iret() 실행: arg.c로 넘어간다
여기서 각~종 어셈블리어를 통해서 레지스터에 값을 적재하는 과정을 거친다.
그리고 addq라는 어셈블리어를 통해 arg.c로 넘어가 main을 실행한다고 한다~..11. argc -> main 실행
args라는 값을 실행하라고 어셈블리어 명령을 통해 레지스터에 넣어주었고, 이 arg.c 에서 msg 함수를 실행하는데, 이게 중요하다.
12. msg() -> vmsg() -> write() : system call!!
msg 안에는 vmsg라는 함수가 있는데, vmsg에서 바로 write()라는 시스템 콜이 나오게 된다.
이는 유저 모드에서 커널 모드로 들어가겠다~ 고 선언하는 코드이다. 아직 유저모드이다!
따라서 lib/user/syscall.c에 위치해 있다.#define syscall3(NUMBER, ARG0, ARG1, ARG2) ( \ syscall(((uint64_t) NUMBER), \ ((uint64_t) ARG0), \ ((uint64_t) ARG1), \ ((uint64_t) ARG2), 0, 0, 0)) int write (int fd, const void *buffer, unsigned size) { return syscall3 (SYS_WRITE, fd, buffer, size); }
결국 유저 프로그램이 호출하는 write()는 인자가 세 개인 함수인 것이다!
13. syscall() : 어셈블리어로 진입
시스템 콜을 요청하면, 인자로 받은 값들을 레지스터에 하나씩 입력한다.
write()함수는 syscall 3로 인자가 3개인 함수이다.
따라서 인자 세개를 넣고 나머지값은 0으로 레지스터에 차례로 넣게 된다.
그러고 나면 x86-64에서 지원하는 어셈블리 명령어인 'syscall\n'을 호출한다.
__attribute__((always_inline)) static __inline int64_t syscall (uint64_t num_, uint64_t a1_, uint64_t a2_, uint64_t a3_, uint64_t a4_, uint64_t a5_, uint64_t a6_) { int64_t ret; register uint64_t *num asm ("rax") = (uint64_t *) num_; register uint64_t *a1 asm ("rdi") = (uint64_t *) a1_; register uint64_t *a2 asm ("rsi") = (uint64_t *) a2_; register uint64_t *a3 asm ("rdx") = (uint64_t *) a3_; register uint64_t *a4 asm ("r10") = (uint64_t *) a4_; register uint64_t *a5 asm ("r8") = (uint64_t *) a5_; register uint64_t *a6 asm ("r9") = (uint64_t *) a6_; __asm __volatile( "mov %1, %%rax\n" "mov %2, %%rdi\n" "mov %3, %%rsi\n" "mov %4, %%rdx\n" "mov %5, %%r10\n" "mov %6, %%r8\n" "mov %7, %%r9\n" "syscall\n" : "=a" (ret) : "g" (num), "g" (a1), "g" (a2), "g" (a3), "g" (a4), "g" (a5), "g" (a6) : "cc", "memory"); return ret; }
13. syscall-entry.s: 어셈블리어인 시스템 콜의 엔트리
복잡해보이지만 그냥 인터럽트 프레임 구조체에 있던 값들을 레지스터에 옮기고 계산한다.
여기서 커널 모드로의 전환이 이뤄지는 과정을 볼 수 있다.
- movabs ($tss) %r12:
이제 커널 스택 포인터를 찾아야 한다.
왜냐면 커널 모드를 진입하기 위한 작업이 커널을 호출해 커널 스택에 push/pop을 해야 하는 것인데, 이 작업을 수행하려면 커널 스택을 가리키고 있는 포인터를 알아야 하기 때문이다. 그래서 이 어셈블리어 명령을 통해 우리는 커널 스택 포인터를 찾는다. 그리고, - movq 4(r%12) %rsp
작업을 하면 tss 값(얘는 커널 스택 포인터)을 %rsp에 넣어줌으로써 커널 스택 포인터로 이동할 수 있다.
이 때부터 커널 모드로 진입하여 ring0의 특권을 갖는다. 작업이 끝나면 sysretq를 반환한다.
14. syscall_handler(): 찐 syscall...
이제 위 작업이 끝나면 syscall_handler()를 호출한다.
이 때 시스템 콜의 번호를 보고 write로 넘겨준다.
Reference
'Projects > Krafton_Jungle_4' 카테고리의 다른 글
[PintOS] Project 3 - Virtual Memory git book, Introduction (2) 2024.03.23 [PintOS] Project 2 - System Call (3) (0) 2024.03.20 [PintOS] Project 2 - System Call (2) (0) 2024.03.19 [PintOS] Project 2 - Argument Passing (2) (4) 2024.03.16 [PintOS] Project 2 - Argument Passing (1) (0) 2024.03.15