✨ Project2: User Programs
- 사용자 프로그램 시스템 일부 개발
- 기본 코드에 사용자 프로그램 로드 및 실행은 가능하지만 I/O나 사용자 상호작용은 불가능
- 이 프로젝트의 목표는 시스템콜을 통해 프로그램이 운영체제와 상호작용 하는 것
- read/write/fork 같은 system call 구현하면 됨 !!
항상 기본 베이스는 Pintos Git Book으로 !!
위에 pdf로는 전반적인 핀토스 흐름 잡기 좋음 !!
✨ Background
- Project1은 모든 코드는 커널의 일부로 실행
- User Program을 운영체제 위에 실행하면 이러한 접근 제한
- 각 프로세스는 시스템 전체를 독점하고 있다고 설계
- 독입적인 주소 공간과 자원을 가짐
- ifnfed VM으로 감싸진 코드 블록을 사용 (VM은 project3에서 사용)
- 하나의 프로세스는 하나의 스레드를 가짐
- Project1은 커널 스레드는 여러 개가 존재할 수 있음
- Project2는 멀티스레딩 지원하지 않음
- PintOS에서 커널 스레드가 독립적인 프로세스 역할
- 각 UserProgram은 하나의 커널 스레드를 통해 실행
- 커널 스레드로 User Program 실행, 시스템 자원 접근, 시스템콜 등을 관리 가능
✨ Source File
- userprog 디렉토리에서 주로 작업
- 특히 process.c, syscall.c, 그리고 필요한 경우 exception.c의 코드 작성
- 나머지 파일들은 주로 하드웨어 레벨의 기능을 설정하거나 커널 내부의 구조를 설정 (대부분의 경우 수정할 필요가 없음)
✨Using the File System
- 파일 시스템 코드 사용
- 파일 시스템 코드를 사용하긴 함
- 근데 프로젝트의 초점은 파일 시스템이 아닌 User Promgram과의 상호작용
- 현재 파일 시스템에는 여러가지 제한 사항 존재하는데 우선 그대로 사용
- Project2에서 파일 시스템 직접 수정하지 않음 !!
- Project2에서는 테스트 프로그램을 사용자 공간에서 실행해야 함
- 프로그램 파일은 PintOS 가상 머신에 복사 과정 필요
Userprogram Pintos 실행 명령어 참고
1. 디스크 이미지 생성
pintos-mkdisk filesys.dsk 2
- filesys.dsk라는 2MB 크기의 디스크 이미지 생성
2. 디스크 이미지 포맷
pintos --fs-disk filesys.dsk -- -f -q
- --fs-disk : 파일 시스템 지정
- -f : 파일 시스템 포맷
- -q : 포맷 후 자동 종료
3. 파일 시스템에 파일 복사 (파일 업로드)
pintos -p file -- -q
4. 프로그램 실행
pintos --fs-disk filesys.dsk -- -q run 'args-single onearg'
+ 파일 시스템에 파일 가져오기 (파일 다운로드)
pintos -g filename -- -q
- 테스트결과 확인 용도
- 프로그램 출력 파일 확인
- 디버깅 목적
복붙 편하게 하기 위해 명령어 아래에 모아둠 !
// 파일 비우기
rm filesys.dsk
//파일 만들기
pintos-mkdisk filesys.dsk 20
// 포맷
pintos --fs-disk=filesys.dsk -- -q -f
// 해당 경로의 파일들
pintos --fs-disk=filesys.dsk -- -q ls
// 파일 생성
pintos --fs-disk=filesys.dsk -p tests/userprog/args-single:args-single -- -q
// 실행
pintos --fs-disk=filesys.dsk -- -q run 'args-single onearg'
// 생성부터 실행까지 한번에 (거의 이것만 씀)
pintos --fs-disk=10 -p tests/userprog/args-single:args-single -- -q -f run 'args-single'
// Native Debug
pintos --gdb --fs-disk=10 -p tests/userprog/args-single:args-single -- -q -f run 'args-single'
디버깅 하는 것을 무서워 하지 말자 !!
✨ Code로 흐름도 보기
[Pintos 가상 환경 실행 ]
pintos --fs-disk=10 -p tests/userprog/args-single:args-single -- -q -f run 'args-single'
- 보통 위의 pintos 명령어로 디스크 생성 → 파일 생성 → 실행
- 위의 핀토스 명령어로 가상 머신 (QEMU) 실행
- 현재 pintos 가상 환경 디스크에 "args-single" 파일 업로드
- 필요시 가상 이미지 초기화
1. init.c 파일의 main() 함수 실행
- Pintos 초기화의 시작점
- main 스레드가 생성되고 점진적으로 스레드 설정
- 일반 스레드랑 만들어지는게 다름
- run_action() 실행
2. run_action()
- 명령어 구분
- run / ls / cat /rm / put / get
- 현재 User Program에서는 run만 구현
3. run_tast()
- process_wait() 함수를 통해서 process_create_initd() 실행
- 자식 프로세스 종료까지 대기
4. process_create_initd()
- thread_create(filename, PRI_DEFAULT, initd, fn_copy)로 프로세스 생성
- Pintos에서는 한 프로세스는 단일 스레드이므로 스레드로 프로세스 생성 (Pintos에서 Thread = Process)
4-1. thread_create( filename, PRI_DEFAULT, initd, fn_copy)
- (커널) 스레드 생성 (프로세스 만드는 중)
t->tf.rip = (uintptr_t)kernel_thread;
t->tf.R.rdi = (uint64_t)function;
t->tf.R.rsi = (uint64_t)aux;
t->tf.ds = SEL_KDSEG;
t->tf.es = SEL_KDSEG;
t->tf.ss = SEL_KDSEG;
t->tf.cs = SEL_KCSEG;
t->tf.eflags = FLAG_IF;
- 스레드 context 설정
- 세그먼트를 SEL_KDSEG (커널 데이터) / SEL_KCSEF (커널 코드)로 설정하여 커널 모드 실행 보장
- initd() 함수 실행
어떻게 세그먼트 선택자들로 커널 모드 / 유저 모드를 왔다갔다 할까 ?
이런 궁금증이 있다면 아래 포스팅을 참고하면 된다.
5. initd()
- 첫 번째 사용자 프로세스 시작
- process_exec() 함수 실행
6. process_exec()
struct intr_frame if_;
if_.ds = if_.es = if_.ss = SEL_UDSEG;
if_.cs = SEL_UCSEG;
if_.eflags = FLAG_IF | FLAG_MBS;
- 커널 모드를 사용자 모드로 변경
- 스레드는 현재 사용자 모드
- 주어진 strtok_r을 통해서 file_name 분리
- load 함수 실행
7. load()
t->pml4 = pml4_create();
process_activate(thread_current());
- 페이지 디렉토리 초기화 및 활성화
file = filesys_open(file_name); // 파일 객채 open
t->runn_file = file; // 현재 프로세스의 실행 중인 파일 설정
file_deny_write(file); // 파일 쓰기 금지
- 실행 파일 열기 ( 파일 구조체를 file에 매핑 )
- 충돌 방지를 위한 파일 쓰기 금기
file_ofs = ehdr.e_phoff;
for (i = 0; i < ehdr.e_phnum; i++) {
struct Phdr phdr;
if (file_ofs < 0 || file_ofs > file_length(file))
goto done;
file_seek(file, file_ofs);
if (file_read(file, &phdr, sizeof phdr) != sizeof phdr)
goto done;
file_ofs += sizeof phdr;
switch (phdr.p_type) {
...
case PT_LOAD:
if (validate_segment(&phdr, file)) {
bool writable = (phdr.p_flags & PF_W) != 0;
uint64_t file_page = phdr.p_offset & ~PGMASK;
uint64_t mem_page = phdr.p_vaddr & ~PGMASK;
uint64_t page_offset = phdr.p_vaddr & PGMASK;
uint32_t read_bytes, zero_bytes;
if (phdr.p_filesz > 0) {
/* Normal segment.
* Read initial part from disk and zero the rest. */
read_bytes = page_offset + phdr.p_filesz;
zero_bytes = (ROUND_UP(page_offset + phdr.p_memsz, PGSIZE) - read_bytes);
} else {
/* Entirely zero.
* Don't read anything from disk. */
read_bytes = 0;
zero_bytes = ROUND_UP(page_offset + phdr.p_memsz, PGSIZE);
}
if (!load_segment(file, file_page, (void *)mem_page, read_bytes, zero_bytes, writable))
goto done;
} else
goto done;
break;
}
}
- 지금 당장 이해에 필요없는 것들은 설명에 제외
- ehdr.e_phnum (프로그램 헤더 테이블 엔트리의 개수) 만큼 반복
- 일반적 ELF 파일에서 5~10개 정도이고 대부분 20개를 넘지 않음
- 만일 넘을 경우 오류 의심
- file_ofs += sizeof phdr은 다음 프로그램 헤더 테이블 엔트리로 파일 오프셋을 이동하기 위해 필요
- 현재 프로그램 헤더 테이블의 타입은 LOAD 밖에 없으므로 case PT_LOAD 실행
- load_segment 함수로 ELF 파일의 코드와 데이터 세그먼트를 메모리에 적재
- read_bytes
- 디스크에서 읽어어와 메모리에 복사해야 할 데이터 크기
- zero_bytes
- 메모리에서 0으로 초기화할 크기
- read_bytes
/* Set up stack. */
if (!setup_stack(if_))
goto done;
- setup_stack 함수
- Pintos에서 사용자 스택을 초기화 (할당된 페이지를 0으로 초기화)
- rsp (스택 포인터)를 사용자 스택의 최상단으로 위치
/* Start address. */
if_->rip = ehdr.e_entry;
- if_->rip = ehdr.e_entry (프로그램 진입점 주소)
- CPU가 다음에 실행할 명령어 주소 rip를 설정하여 프로그램 실행 준비
- 좀 더 구체적으로 ELF 헤더에서 진입점을 가저와 프로그램 실행을 시작할 주소를 설정
- 예를들어 hello.c를 load 한다고 하면, 이 부분에서 hello.c 프로그램이 실행 준비
- 지금은 아직 if_(인터럽트 프레임)에만 저장되어있고, do_iret 함수가 실행되면 hello.c 프로그램 실행 시작
이후 나올 fork 함수 구현에서도 이렇게 레지스터 값을 직접 주는 경우가 있다.
이렇다 하는 방법이 엄청 신기하다 !!
8. process_exec 내부에서 argument_stack 함수
- 참고로 이 함수는 직접 구현 해야한다
void argument_stack(char **argv, int argc, struct intr_frame *if_)
아래의 표를 그대로 표현 한 코드이기 때문에 너무 어렵게 생각하지 않아도 된다.
char *arg_addr[100];
int argv_len;
- arg_addr : 실제 값들이 할당 된 메모리의 주소를 저장하기 위한 배열
- argv_len : 실제 데이터들의 크기를 저장하기 위한 변수
문자열을 스택에 복사
for (int i = argc - 1; i >= 0; i--) {
argv_len = strlen(argv[i]) + 1;
if_->rsp -= argv_len;
memcpy(if_->rsp, argv[i], argv_len);
arg_addr[i] = if_->rsp;
}
주소 데이터 설명
0x4747fffc 'b' argv[3]의 첫 번째 문자
0x4747fffb 'a' argv[3]의 두 번째 문자
0x4747fffa 'r' argv[3]의 세 번째 문자
0x4747fff9 '\0' argv[3]의 종료 문자
0x4747fff8 'f' argv[2]의 첫 번째 문자
0x4747fff7 'o' argv[2]의 두 번째 문자
0x4747fff6 'o' argv[2]의 세 번째 문자
0x4747fff5 '\0' argv[2]의 종료 문자
0x4747fff4 '-' argv[1]의 첫 번째 문자
0x4747fff3 'l' argv[1]의 두 번째 문자
0x4747fff2 '\0' argv[1]의 종료 문자
0x4747fff1 '/' argv[0]의 첫 번째 문자
0x4747fff0 'b' argv[0]의 두 번째 문자
0x4747ffef 'i' argv[0]의 세 번째 문자
0x4747ffee 'n' argv[0]의 네 번째 문자
0x4747ffed '/' argv[0]의 다섯 번째 문자
0x4747ffec 'l' argv[0]의 여섯 번째 문자
0x4747ffeb 's' argv[0]의 일곱 번째 문자
0x4747ffea '\0' argv[0]의 종료 문자
- 스택 포인터를 위에서 부터 내려오면서 데이터를 메모리에 넣음
- argv_len을 argv[i] 데이터의 크기 + 1 할당 (\0 까지 포함)
- arg_addr [i]에는 현재 스택 포인터 주소로 저장
스택 정렬
while (if_->rsp % 8)
*(uint8_t *)(--if_->rsp) = 0;
- 스택 포인터의 주소가 8의 배수가 되도록 정렬
NULL 포인터
if_->rsp -= 8;
memset(if_->rsp, 0, sizeof(char *));
- argv의 끝을 나타내주는 위함
- 메모리 영역을 0으로 초기화하여 빈 공간을 NULL 값으로 채워지도록 보장
문자열 주소
for (int i = argc - 1; i >= 0; i--) {
if_->rsp -= 8;
memcpy(if_->rsp, &arg_addr[i], sizeof(char *));
}
주소 데이터 설명
0x4747ffe9 0x00 정렬 패딩
0x4747ffe8 0x00 정렬 패딩
0x4747ffe0 0x4747fffc argv[3]의 주소
0x4747ffd8 0x4747fff8 argv[2]의 주소
0x4747ffd0 0x4747fff4 argv[1]의 주소
0x4747ffc8 0x4747fff1 argv[0]의 주소
- 포인터이므로 스택 포인터 주소 -8씩 이동
- 실제 데이터가 저장되어 있는 주소를 저장
종료 문자 설정
if_->rsp = if_->rsp - 8;
memset(if_->rsp, 0, sizeof(void *));
주소 데이터 설명
0x4747ffc0 0x00000000 NULL (종료 표시)
새로운 프로그램 실행 보장
if_->R.rdi = argc;
if_->R.rsi = if_->rsp + 8;
- rdi는 x86-64 호출 규약에 의해 argc(명령줄 인수 개수) 를 전달
- rsi는 x86-64 호출 규약에 의해 argv(명령줄 인수 배열의 포인터) 전달
- 스택 포인터에 +8 만큼 높여 배열의 시작 주소 계산
9. do_iret( ) 함수
do_iret(&if_);
- do_iret 함수가 실행되면 hello.c 프로그램 실행 시작
➕번외
아마 대부분 VS Code에서 코드를 작성 할 것이다.
VS Code Extension에 Native Debug라는 툴이 있는데, 이걸로 디버그를 하면 엄청 편하다.
디버그 하는 방법은 이 블로그 포스팅을 참고하면 된다 ! (이 분께서 정리를 잘 해주셨다.)
다만, 한 가지 주의해야 할 것은 지금 PintOS 커널 모드를 만들고 있는 것이다.
하지만 테스트 케이스 같은 경우 사용자 프로그램이기 때문에, 사용자 프로그램 코드에 중단점을 찍어도 넘어가지 않는다.
다시 정리하자면 process_execute 함수의 do_iret() 함수를 실행하면 사용자 프로그램이 시작되는데,
사용자 프로그램에 중단점을 찍어도 디버깅하기가 어렵다 .
커널 모드에 있는 코드만 디버깅이 가능 하다는 점 !!
ex) halt 사용자 프로그래밍 실행
pintos --gdb --fs-disk=10 -p tests/userprog/halt:halt -- -q -f run 'halt'
- do_iret 이후 halt 사용자 프로그래밍이 실행 됨
- halt.c 프로그램 내부에서 커널 코드 호출하면 그 부분은 디버깅 가능
- ex) halt()에 중단점 찍고 사용자 프로그램에서 halt() 함수를 실행하면 디버깅 가능함
'크래프톤 정글' 카테고리의 다른 글
Pintos Project3 - Page Fault Handler (Page Fault 전반적인 과정 이해하기) (0) | 2024.12.02 |
---|---|
Pintos Project3 - VA와 디스크 데이터 매핑 과정 이해하기 (0) | 2024.12.01 |
Pintos Project2 - User Program (커널/사용자 모드 이해하기) (0) | 2024.11.26 |
Pintos Project2 - User Programs 키워드 (0) | 2024.11.21 |
Pintos Project2 - ELF 파일 (0) | 2024.11.21 |