-
[xv6] Page tables 상세 분석 2카테고리 없음 2023. 7. 15. 02:42
글의 참고
글의 전제
- 밑줄로 작성된 글은 강조 표시를 의미한다.
- 그림 출처는 항시 그림 아래에 표시했다.
글의 내용
: 앞 글에서 모든 유저 프로세스는 `exec 시스템 콜`을 실행한다는 것을 확인했다. 이 글은 `sys_exec` 부터 분석을 시작한다. `sys_exec`의 인자부터 알아보자. 첫 번째 인자 `argv`는 전체 인자를 포함하고 있다. 근데, 몇 개의 인자를 전달하는지를 전달하지 않고 있다. `xv6`는 인자의 개수는 전달하지 않고, `argv`의 마지막 인자를 `0`으로 나타냄으로써, 마지막 인자를 나타낸다. 그 다음에 `argv` 인자에 포함된 변수들에 주소가 들어간다.
// initcode.S #Initial process execs /init. # This code runs in user space. #include "syscall.h" #include "traps.h" # exec(init, argv) .globl start start: pushl $argv pushl $init pushl $0 // where caller pc would be movl $SYS_exec, %eax int $T_SYSCALL # for(;;) exit(); exit: movl $SYS_exit, %eax int $T_SYSCALL jmp exit # char init[] = "/init\0"; init: .string "/init\0" # char *argv[] = { init, 0 }; .p2align 2 argv: .long init .long 0
// sysfile.c ... ... int sys_exec(void) { char *path, *argv[MAXARG]; int i; uint uargv, uarg; if(argstr(0, &path) < 0 || argint(1, (int*)&uargv) < 0){ return -1; } memset(argv, 0, sizeof(argv)); for(i=0;; i++){ if(i >= NELEM(argv)) return -1; if(fetchint(uargv+4*i, (int*)&uarg) < 0) return -1; if(uarg == 0){ argv[i] = 0; break; } if(fetchstr(uarg, &argv[i]) < 0) return -1; } return exec(path, argv); } ...
: 아래의 그림을 보면 좀 더 이해가 쉽게 될 것이다.
: 시스템 콜에서 제일 어려운 부분은 바로 시스템 콜 인자를 파싱하는 부분이다. 먼저, 시스템 콜 인자를 파싱하는데 핵심은 이전 ESP의 위치다. 유저 프로세스에서 `int n`을 호출하면, 권한 변경으로 TSS로 부터 커널 스택을 로드하고 거기에 `SS, ESP, EFLAGS, CS, EIP`를 저장한다고 했다. 그런데, 저 커널 스택에 ESP에는 어떤 값이 들어있는 걸까? 바로 위에서 `int $T_SYSCALL`을 호출하기 전에 ESP 값이 들어간다. 결국, `struct trapframe`안에 들어있는 `ESP`는 `pushl $0`을 가리키고 있게 된다. `ESP`가 가리키는 값이 `pushl $0`인 이유는 2가지가 있다.
0" `exec`함수를 호출하지 않고, `sys_exec` 시스템 콜을 직접 호출
1" `cdecl` 함수 호출 규약: 예를 들어서, 다른 시스템 콜들은 `usys.S`에 아래와 같이 간단하게 정의되어 있다.
//usys.S #include "syscall.h" #include "traps.h" #define SYSCALL(name) \ .globl name; \ name: \ movl $SYS_ ## name, %eax; \ int $T_SYSCALL; \ ret SYSCALL(fork) SYSCALL(exit) SYSCALL(wait) SYSCALL(pipe) SYSCALL(read) SYSCALL(write) SYSCALL(close) SYSCALL(kill) SYSCALL(exec) SYSCALL(open) SYSCALL(mknod) SYSCALL(unlink) SYSCALL(fstat) SYSCALL(link) SYSCALL(mkdir) SYSCALL(chdir) SYSCALL(dup) SYSCALL(getpid) SYSCALL(sbrk) SYSCALL(sleep) SYSCALL(uptime)
: 위의 코드에서 `SYSCALL(exec)`는 어떻게 만들어질까?
.globl exec exec: movl $SYS_exec int $T_SYSCALL ret
: 이렇게 `SYSCALL(XXXX)` 매크로를 통해 만들어진 함수들은 `user.h`에 선언되어 있다. `exec`가 유저 레벨에서 어떻게 호출되는지 한 번 살펴보자.
// system calls int fork(void); int exit(void) __attribute__((noreturn)); int wait(void); int pipe(int*); int write(int, const void*, int); int read(int, void*, int); int close(int); int kill(int); int exec(char*, char**); int open(const char*, int); int mknod(const char*, short, short); int unlink(const char*); int fstat(int fd, struct stat*); int link(const char*, const char*); int mkdir(const char*); int chdir(const char*); int dup(int); int getpid(void); char* sbrk(int); int sleep(int); int uptime(void); ....
: 아래와 같이 `exec` 함수에 2개의 인자를 전달할 경우, `usys.S`에서 어셈블리언어로 작성된 `exec` 심볼로 전달이된다. `xv6`는 32비트 x86 이므로, `cdecl` 호출 규약을 따른다. 이러면, 스택에는 아래와 같이 파라미터들이 들어오게 된다.
ESP+8 : argv
ESP+4 : argc
ESP : return address//init.c .... if(pid == 0){ exec("sh", argv); printf(1, "init: exec sh failed\n"); exit(); } ....
: `initcode.S`를 다시 가져와 보자. 최초의 유저 프로세스(initproc)는 유저 레벨의 `exec` 함수를 통해서 `$SYS_exec`를 호출하지 않기 때문에, 아래와 같이 직접 `cdecl` 규약을 지켜서 `exec 시스템 콜`에 전달할 파라미터를 수동으로 스택에 푸쉬하는 것이다. 이제 `pushl $0`를 설명하 수 있게됬다. 이 코드는 결국 `return address`를 의미한다.
// initcode.S #Initial process execs /init. # This code runs in user space. #include "syscall.h" #include "traps.h" # exec(init, argv) .globl start start: pushl $argv pushl $init pushl $0 // where caller pc would be movl $SYS_exec, %eax int $T_SYSCALL # for(;;) exit(); exit: movl $SYS_exit, %eax int $T_SYSCALL jmp exit # char init[] = "/init\0"; init: .string "/init\0" # char *argv[] = { init, 0 }; .p2align 2 argv: .long init .long 0
`eax`에 시스템 콜 기능 번호를 넣고, `시스템 콜 인터럽트`를 발생시킨다.
결국, 아래 `myproc()->tf->esp`가 가리키는 값은 `pushl $0` 이고, `(myproc()->tf->esp)+4`가 가리키는 값은 `pushl $init`이 된다.
: `argX`과 `fetchX`는 그 역할의 구분이 조금 모호하다 생각이 든다. 그러나 `argint`와 `fetctint`가 함께 사용되며, `argstr`와 `fetchstr`이 함께 사용된다. 그리고 다음과 같은 특징이 있다.
1. `XXXint` 함수는 ESP의 주소가 유효한지를 판단한다.
2. `XXXstr` 함수는 ESP가 가리키는 문자열의 주소가 유효한지를 판단한다.: 2개의 주소 모두 최초의 유저 프로세스(initproc)를 기준으로 [0:4096B] 범위안에 들어와야 한다. 왜냐면, `userinit` 함수에 아래의 내용이 들어있다. `initproc`의 사이즈는 4096B다. 거기다가, 모든 유저 프로세스의 시작지점은 가상 주소 `0`이다. 그러므로, 최초의 유저 프로세스인 `initproc`은 `sys_exec`을 호출하는 시점에 [0:4096B] 밖에 사용하지 못한다(다른 유저 프로세스들은 다른 곳에서 설명한다).
void userinit(void)
{
....
....
inituvm(p->pgdir, _binary_initcode_start, (int)_binary_initcode_size);
p->sz = PGSIZE;
....
p->tf->esp = PGSIZE;
....
}: 다시 돌아와서, 시스템 콜 파싱의 핵심 함수는 `argint`다. 이 함수를 통해서 특정 위치의 인자들을 추출해내기 때문이다. 모든 함수들이 인자의 주소와 프로세스 크기를 검사해서 인자의 주소가 더 크면, `-1`을 리턴하고 있다. 위에서 설명했지만, `initproc`을 기준으로 addr은 모두 4096B 보다 작아야 정상이다.
: `argint`는 전달받은 번호에 매핑되는 인자의 주소를 준다. 그리고 `fetchstr`에서 루프문이 있는데, 인자의 길이를 구하는 코드다. 나머지는 모두 어렵지 않을 것이라 생각된다.
// syscall.c ... ... // User code makes a system call with INT T_SYSCALL. // System call number in %eax. // Arguments on the stack, from the user call to the C // library system call function. The saved user %esp points // to a saved program counter, and then the first argument. // Fetch the int at addr from the current process. int fetchint(uint addr, int *ip) { struct proc *curproc = myproc(); if(addr >= curproc->sz || addr+4 > curproc->sz) return -1; *ip = *(int*)(addr); return 0; } // Fetch the nul-terminated string at addr from the current process. // Doesn't actually copy the string - just sets *pp to point at it. // Returns length of string, not including nul. int fetchstr(uint addr, char **pp) { char *s, *ep; struct proc *curproc = myproc(); if(addr >= curproc->sz) return -1; *pp = (char*)addr; ep = (char*)curproc->sz; for(s = *pp; s < ep; s++){ if(*s == 0) return s - *pp; } return -1; } // Fetch the nth 32-bit system call argument. int argint(int n, int *ip) { return fetchint((myproc()->tf->esp) + 4 + 4*n, ip); } // Fetch the nth word-sized system call argument as a pointer // to a block of memory of size bytes. Check that the pointer // lies within the process address space. int argptr(int n, char **pp, int size) { int i; struct proc *curproc = myproc(); if(argint(n, &i) < 0) return -1; if(size < 0 || (uint)i >= curproc->sz || (uint)i+size > curproc->sz) return -1; *pp = (char*)i; return 0; } // Fetch the nth word-sized system call argument as a string pointer. // Check that the pointer is valid and the string is nul-terminated. // (There is no shared writable memory, so the string can't change // between this check and being used by the kernel.) int argstr(int n, char **pp) { int addr; if(argint(n, &addr) < 0) return -1; return fetchstr(addr, pp); } ... ...
Figure 2-3 shows the layout of the user memory of an executing process in xv6. Each user process starts at address 0. The bottom of the address space contains the text for the user program, its data, and its stack. The heap is above the stack so that the heap can expand when the process calls sbrk. Note that the text, data, and stack sections are layed out contiguously in the process’s address space but xv6 is free to use non-contiguous physical pages for those sections. For example, when xv6 expands a process’s heap, it can use any free physical page for the new virtual page and then program the page table hardware to map the virtual page to the allocated physical page. This flexibility is a major advantage of using paging hardware.
The stack is a single page, and is shown with the initial contents as created by `exec`. Strings containing the command-line arguments, as well as an array of pointers to them, are at the very top of the stack. Just under that are values that allow a program to start at main as if the function call main(argc, argv) had just started. To guard a stack growing off the stack page, xv6 places a guard page right below the stack. The guard page is not mapped and so if the stack runs off the stack page, the hardware will generate an exception because it cannot translate the faulting address. A realworld operating system might allocate more space for the stack so that it can grow beyond one page.: 헤드가 다음 노드(프리 페이지)를 가리키도록 해서 다음 `kalloc`에 지체없이 바로 페이지를 할당하 수 있도록 하고 있다.