크래프톤 정글

Pintos Project2 - User Program 흐름 잡기

Jerry_K 2024. 12. 1. 18:22

✨ Project2: User Programs

  • 사용자 프로그램 시스템 일부 개발
  • 기본 코드에 사용자 프로그램 로드 및 실행은 가능하지만 I/O나 사용자 상호작용은 불가능
  • 이 프로젝트의 목표는 시스템콜을 통해 프로그램이 운영체제와 상호작용 하는 것
    • read/write/fork 같은 system call 구현하면 됨 !!

 

 

Introduction · GitBook

Project2: User Programs Now that you've worked with Pintos and are becoming familiar with its infrastructure and thread package, it's time to start working on the parts of the system that allow running user programs. The base code already supports loading

casys-kaist.github.io

항상 기본 베이스는 Pintos Git Book으로 !!

 

 

Pintos_2.pdf

 

drive.google.com

위에 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() 함수 실행

 

어떻게 세그먼트 선택자들로 커널 모드 / 유저 모드를 왔다갔다 할까 ?

이런 궁금증이 있다면 아래 포스팅을 참고하면 된다. 

 

Pintos Project2 - User Program (커널/사용자 모드 이해하기)

주의 !!!  User Program 해결에는 큰 도움이 안됨 !!단지 아래와 같은 궁금증을 가지고 있으면 읽어 볼 만 하다.  ✨선행 Key word세그먼트 선택자 CPU에게 이 코드나 데이터가 어디에 있는지 알려주

jerry-k.site

 

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으로 초기화할 크기

 

/* 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_)

 

 

아래의 표를 그대로 표현 한 코드이기 때문에 너무 어렵게 생각하지 않아도 된다.    

Git book Argument Passing 부분

 

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] VSCode를 활용한 Pintos 디버깅

Pintos : Native Debug extension을 이용한 pintos-kaist의 debug 하기.

velog.io

디버그 하는 방법은 이 블로그 포스팅을 참고하면 된다 ! (이 분께서 정리를 잘 해주셨다.)

 

다만, 한 가지 주의해야 할 것은 지금 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() 함수를 실행하면 디버깅 가능함

 


 

User Program 전체 흐름 글 정리