본문 바로가기

computer security/RISC-V

chapter 1. operating system interfaces

operating system : 하드웨어와 사용자 간의 인터페이스를 제공하고, 컴퓨터 시스템의 자원을 효율적으로 관리하는 소프트웨어

- 자원관리(resource management) : CPU, 메모리, 저장장치 등 시스템 자원 관리

- 사용자 인터페이스 제공 : CLI(Command Line Interface), GUI(Graphical User Interface)

- 프로세스 관리(process management) : 프로세스의 생성, 실행, 중지, 종료 등 관리, CPU 스케줄링을 통해 프로세스들 동시에 실행

- 메모리 관리, 파일 시스템 관리, 입출력장치 관리, 보안 및 접근 제어

 

Process and memory

1. 프로세스와 메모리

xv6의 프로세스는 user space memory(instructions, data, and stack)와 커널에 의해 관리되는 프로세스 상태로 구성.

- 유저 공간 메모리 : 프로레스가 실행하는 코드(명령어), 사용하는 데이터, 함수 호출시 사용하는 스택 메모리

- 커널 상태 : 커널이 각 프로세스에 대해 유지하는 정보(PID, CPU register 값, 실행 우선 순위, 메모리 관리 정보 등) 의미

time-sharing : xv-6는 time-sharing 방식으로 CPU를 여러 프로세스가 번갈아 사용할 수 있도록 멀티태스킹 구현. 프로세스가 실행을 마치거나 일정 시간이 지나면 커널이 레지스터 값 저장, 다른 프로세스 복원하여 실행 이어감.

 

2. 주요 시스템 호출

-fork : 새로운 프로세스 생성, 자식 프로세스 반환

-exit : 현재 프로세스 종료, 자원 해제. 프로세스는 wait 호출로 종요 상태 확인 가능

-wait : 자식 프로세스가 종료될 때까지 기다리고, 종료 상태 반환. 자식이 없으면 즉시 -1 반환

-exec : 호출한 프로세스를 새 프로그램으로 대체. 실행 파일을 로드하여 해당 프로그램 실행

-getpid : 현재 프로세스의 PID 반환

-kill :  지정한 PID의 프로세스 종료

-sbrk : 프로세스의 메모리를 동적으로 증가시키고 새로운 메모리의 시작 주소 반환

 

3. fork와 exec

fork 

새로운 프로세스를 생성하여 부모와 동일한 메모리 복사본을 가진 자식 프로세스 생성

반환값으로 부모와 자식 구분 가능

-부모 프로세스 : 자식 프로세스의 PID 반환

-자식 프로세스 : 0 반환

부모와 자식은 독립적으로 실행. 서로의 메모리와 레지스터에 영향을 주지 않음.

int pid = fork();
if (pid > 0){
	printf("parent: child=%d\n", pid);
    pid = wait((int *)0); // 자식 프로세스가 종료될 때까지 대기
} else if (pid == 0){
	printf("child: exiting\n");
    exit(0);
} else {
	printf("fork error\n");
}

 

fork 후 부모와 자식은 동시에 실행되기 때문에 출력 순서는 부모-자식 관계에 따라 다를 수 있음.

 

exec

현재 프로세스의 메모리 내용을 새로운 프로그램으로 대체.

exec 호출 후 성공 시 호출한 프로그램으로 대체되어 이전 코드로 돌아오지 않으며, 새로운 프로그램이 실행.

char *argv[3];
argv[0] = "echo";
argv[1] = "hello";
argv[2] = 0;
exec("/bin/echo", argv);
printf("exec error\n");

 

위 코드에서 exec가 성공하면 /bin/echo 프로그램이 실행되어 "hello" 출력.

 

4. xv6의 메모리 관리

xv6는 대부분의 메모리를 암시적으로 할당.

-fork 호출 시 부모 메모리 복사하여 자식 메모리 생성

-exec 호출 시 프로그램 실행에 필요한 메모리 할당

sbrk 시스템 호출을 통해 동적으로 메모리 할당.

 

5. xv6 셸의 구조 : fork와 exec 시스템 호출을 사용하여 사용자가 입력한 명령어 실행.

 

6. fork와 exec를 분리한 이유 : I/O 리디렉션과 같은 기능 구현을 위해.

 ex. echo hello > output.txt : fork 후 자식 프로세스에서 파일 출력을 설정한 후 exec로 명령어 실행

 

7. copy-on-write 최적화 : 자식 프로세스가 생성될 때 메모리를 모두 복사하는 대신, 읽기 전용으로 공유하고 쓰기 시점에 복사.

 

 

I/O and File descriptors

1. file descriptor

커널이 관리하는 객체를 나타내는 small integer. 

프로세스는 file descriptor를 통해 파일, 디렉터리, 장치에 대해 읽고 쓰기를 수행.

다음과 같은 방법으로 생성 : 파일 열기(open), 파이프 생성(pipe), 기존 디스크립터 복제(dup)

file, pipe, device를 바이트 스트림으로 추상화하여 동일한 인터페이스로 접근할 수 있도록 함.

standard file descriptor : 0(standard input), 1(standard output), 2(standard error)

 

2.file descriptor의 read/write

xv6에서 각 프로세스는 file descriptor table을 가지며, descriptor 번호를 테이블의 인덱스로 사용.

read(fd, buf, n) : 

-파일 디스크립터 fd에서 되채 n 바이트르 읽고, 이를 버퍼 buf에 저장.

-변환값은 읽은 바이트 수이며, 파일 끝에 도달하면 0을 반환.

-읽기가 끝나면 파일 오프셋(offset)을 읽은 바이트 수만큼 증가.

write(fd, buf, n):

-fd에 buf의 데이터를 최대 n바이트까지 씀.

-반환값은 실제로 쓴 바이트 수. 오류가 발생하면 n보다 적은 바이트 반환.

-쓰기가 끝나면 파일 오프셋(offset)을 읽은 바이트 수만큼 증가.

 

char buf[512];
int n;
for(;;){
	n = read(0, buf, size buf); // 표준 입력에서 읽기
    if (n == 0) break; // EOF 도달 시 종료
    if (n < 0){
    	fprintf(2, "read error\n"); // 표준 에러에 메시지 출력
        exit(1);
    }
    if (write(1, buf, n) != n){ // 표준 출력에 쓰기
    	fprintf(2, "write error\n");
        exit(1);
    }
}

 

이 프로그램은 표준 입력에서 데이터를 읽고, 표준 출력으로 복사.

cat 명령어는 입력이 파일, 콘솔, 파이프인지에 관계없이 동일하게 동작. 이는 파일 디스크립터 인터페이스가 I/O 객체를 추상화했기 때문.

 

3. file descriptor와 fork/exec

fork

-자식 프로세스는 부모 프로세스의 file descriptor table을 복사

-부모와 동일한 file descriptor로 같은 파일에 접근 가능

exec

-프로세스의 메모리만 새로운 프로그램으로 대체. 파일 디스크립터 테이블은 유지

-덕분에 I/O redirection 쉽게 구현 가능

char *argv[2];
argv[0] = "cat";
argv[1] = 0; 
if (fork() == 0){ // 자식 프로세스 생성
	close(0); // 표준 입력 닫기
    open("input.txt", O_RDONLY); // input.txt 파일을 열어 디스크립터 0에 연결
    exec("cat", argv); // cat 프로그램 실행
}

- close : 표준 입력 디스크립터를 닫음.

- open : input.txt 파일을 열고, 가장 작은 사용 가능한 디스크립터에 할당.

- exec : 새 프로그램(cat)을 실행하여 표준 입력이 input.txt로부터 데이터를 읽도록 만듦.

 

4. dup 시스템 호출

dup : 기존 파일 디스크립터를 복제하고, 새 디스크립터 번호를 반환. 복제된 디스크립터는 같은 I/O 객체를 가리키며, 파일 오프셋 공유.

int fd = dup(1); // 표준 출력 디스크립터 복제
write(1, "hello ", 6);
write(fd, "world\n", 6);

- write는 복제된 디스크립터와 원본 디스크립터 모두 동일한 파일에 순차적으로 데이터를 씀.

ls > output.txt 2>&1

- 2>&1에서 dup을 이용하여  에러 출력(2)을 표준 출력(1)으로 리디렉션.

 

5. fork와 dup의 오프셋 공유

부모와 자식 프로세스는 파일 디스크립터 테이블을 공유하므로, 파일 오프셋도 공유.

if (fork() == 0){
	write(1, "hello ", 6);
    exit(0);
} else {
	wait(0); // 자식 프로세스가 종료될 때까지 대기
    write(1, "world\n", 6);
}

-출력 결과는 hello world. 자식 프로세스가 hello를 쓰고 종료하면, 부모 프로세스가 world를 이어서 씀.

 

6. open 시스템 호출 flag

open은 파일을 열 때 동작을 제어하는 여러 플래그 사용.

- O_RDONLY : 읽기 전용

- O_WRONLY : 쓰기 전용

- O_RDWR : 읽기 및 쓰기

- O_CREATE : 파일이 없을 경우 새로 생성

- O_TRUNC : 기존 파일 내용을 0으로 초기화

 

Pipe

1. pipe란?

pipe는 커널이 관리하는 작은 버퍼로, 두 개의 파일 디스크립터(읽기 전용, 쓰기 전용)를 통해 프로세스 간 통신을 가능하게 한다. 

한 프로세스가 파이프의 write end에 데이터를 쓰면, 다른 프로세스는 파이프의 read end에서 그 데이터를 읽는다.

파이프를 통해 두 프로세스가 동시에 실행되면서 데이터 스트림을 주고받을 수 있는 구조 제공.

 

int p[2];
char *argv[2];
argv[0] = "wc"; 
argv[1] = 0; // 인자 배열 종료 표시
pipe(p); // 파이프 생성 : p[0]은 read end, p[1]은 write end

if (fork() == 0){
	close(0); // 표준 입력을 닫음
    dup(p[0]); // p[0](read end)을 descriptor 0으로 복제
    close(p[0]); // 더 이상 필요 없는 p[0] 닫기
    close(p[1]); // 자식은 write end를 사용하지 않으므로 닫음
    exec("/bin/wc", argv); // wc 프로그램 실행
} else {
	close(p[0]); // 부모는 read end를 사용하지 않으므로 닫음
    write(p[1], "hello world\n", 12); // 파이프에 데이터 쓰기
    close(p[1]); // 쓰기 끝 닫기
}

- pipe(p) : 커널은 p[0](read end)과 p[1](write end)을 생성하고 이를 파일 디스크립터로 반환.

- fork() 호출 후, 부모와 자식 프로세스는 파이프의 동일한 파일 디스크립터 공유.

- 자식 프로세스 : 표준 입력을 닫고 p[0]을 디스크립터 0으로 복제하여 파이프의 read end를 표준 입력으로 닫음. exec를 호출하여 wc 프로그램을 실행하여 표준 입력을 통해 파이프의 데이터 읽음.

- 부모 프로세스 : 파이프의 읽기 끝 p[0]은 사용하지 않으므로 닫음.

- 결과 : 자식 프로세스가 실행한 wc 프로그램은 pipe의 read end(p[0])에서 데이터를 읽어 단어 수를 세고 출력.

 

2. 중요한 동작 원리

파이프의 끝을 닫아야 읽기/쓰기가 끝남.

- 파이프에서 read는 쓰기 끝이 닫힐 때까지 blocking.

- 자식 프로세스는 exec를 호출하기 전에 반드시 파이프의 쓰기 끝(p[1])을 닫아야 함.

- 그렇지 않으면 wc는 입력이 끝나지 않았다고 생각하여 계속 대기.

fork와 파이프를 이용한 I/O 리디렉션

- fork로 부모와 자식이 동일한 파일 디스크립터 테이블을 복사하기 때문에, 부모가 생성한 파이프를 자식이 그대로 사용 가능.

- 부모와 자식이 각각 파이프의 쓰기 끝과 읽기 끝을 사용하여 데이터 주고받음.

 

3. 파이프의 장점

자동 정리 : 파일과 달리 자동 정리되므로 임시 파일을 직접 삭제할 필요가 없다.

메모리 효율성 : 메모리 버퍼를 사용하여 데이터를 주고받기 때문에 디스크 공간을 차지하지 않는다.

병령 처리 가능 : 두 프로세스를 동시에 실행하여 병렬 처리 가능.

 

예시 : echo와 wc 비교

1) 파이프 사용

echo hello world | wc

- echo가 데이터를 파이프에 쓰고, wc가 동시에 데이터를 읽어 병렬로 실행.

 

2) 임시 파일 사용

echo hello world >/tmp/xyz; wc </tmp/xyz

- echo가 임시 파일 /tmp/xyz에 데이터를 쓰고 종료된 후에야 wc가 실행될 수 있음. 임시 파일을 수동으로 관리해야 하며, 디스크 공간도 필요.

 

 

File system

1. 기본 개념

xv6 파일 시스템은 데이터 파일(data files)과 디렉토리(directories) 제공.

- 데이터 파일 : 해석되지 않은 바이트 배열로 이루어진 파일

- 디렉토리 : 데이터 파일 및 다른 디렉토리에 대한 이름 기반 참조를 포함하며, 트리 구조 형성

디렉토리 구조는 루트 디렉토리(/)에서 시작하며, 경로(/a/b/c)를 통해 특정 파일이나 디렉토리를 지정할 수 있다.

- 절대 경로 : /로 시작하는 경로

- 상대 경로 : 현재 디렉토리를 기준으로 하는 경로

 

2. 경로와 디렉토리 변경 예시

다음 두 코드는 동일한 파일을 연다.

chdir("a/"); // 현재 디렉토리를 /a로 변경
chdir("b"); // /a 아래의 b로 변경
open("c", O_RDONLY); // /a/b/c 파일 열기

open("/a/b/c", O_RDONLY); // / 절대 경로로 직접 열기

- 첫 번째 코드는 현재 디렉토리를 /a/b로 변경한 후 상대 경로로 파일을 연다.

- 두 번째 코드는 절대 경로를 사용하여 파일을 연다.

 

3. 파일 생성 및 디렉토리 생성

mkdir : 새로운 디렉토리 생성

open + O_CREATE 플래그 : 새로운 데이터 파일 생성

mknod : 새로운 디바이스 파일 생성

mkdir("/dir"); // /dir 디렉토리 생성
fd = open("dir/file", O_CREATE|O_WRONLY); // /dir/file 파일 생성 및 열기
close(fd); // 파일 닫기
mknod("/console", 1, 1); // major 1, minor 1 디바이스 파일 생성

- mknod : 디바이스 파일을 생성한다. 이 파일은 특정 커널 장치로 연결되며, major number과 minor number로 커널 디바이스를 고유하게 식별한다.

 

4. link와 unlink

파일 이름과 파일 내용은 분리되어 있다.

- 파일 이름은 디렉토리에 저장된 링크(entry)이며, 링크는 실제 파일(내용)을 가리키는 inode를 참조한다. 

- 하나의 파일이 여러 이름(링크)를 가질 수 있으며, 이 경우 모두 같은 inode를 참조하게 된다.

다음 코드는 동일한 파일을 가리키는 두 개의 링크를 만든다.

open("a", O_CREATE|O_WRONLY); // 파일 a 생성
link("a", "b"); // a와 같은 파일을 가리키는 링크 b 생성

- 이후 fstat 호출을 통해 a와 b가 같은 inode 번호를 공유하고, 링크 개수(nlink)가 2로 설정된 것을 확인할 수 있다.

 

unlink

- unlink는 파일 시스템에서 링크(파일 이름)을 제거한다.

- 파일의 링크 개수(nlink)가 0이 되고 파일 디스크립터가 모두 닫히면, inode와 파일 내용이 삭제된다.

unlink("a"); // a 링크 제거, b는 여전히 존재하므로 파일은 삭제되지 않음

 

또한 다음 예시는 이름 없는 임시 파일을 생성하고 자동으로 삭제되도록 하는 방법이다.

fd = open("/tmp/xyz", O_CREATE|O_RDWR); // 임시 파일 생성
unlink("tmp/xyz"); // 링크 제거(파일은 여전히 열려 있음)

이 파일은 열려 있는 동안에만 유지되며, 파일 디스크립터가 닫히거나 프로세스가 종료되면 자동으로 삭제된다.

 

5. 유저 레벨 파일 명령어

Unix에서는 파일 명령어(mkdor, ln, rm)를 커널이 아닌 유저 레벨 프로그램으로 구현했다.

- 이러한 설계는 사용자가 새로운 명령어를 추가할 수 있도록 확장성을 제공한다.

xv6에서도 마찬가지로 파일 명령어는 유저 레벨 프로그램으로 구현되어 있다.

 

6. cd 명령어가 특별한 이유

대부분의 명령어(mkdir, ln, rm)는 유저 레벨 프로그램으로 구현할 수 있지만, cd 명령어는 예외이다.

- cd는 쉘 자체의 현재 디렉토리를 변경해야 하기 때문에, 쉘에 내장된 명령어로 구현되어 있다.

- 만약 cd가 일반 명령어였다면, 쉘이 자식 프로세스를 포크하고 자식이 cd를 실행하게 되며, 자식의 디렉토리만 변경되고 부모(쉘)의 디렉토리는 변경되지 않기 때문에 원하는 동작을 할 수 없다.

 

 

Real world

1. Unix의 영향

Unix의 가장 큰 혁신 중 하나는 "표준 파일 디스크립터", 파이프와 같은 기능, 그리고 이를 다루기 위한 간편한 쉘 명령어 문법이다.

- 이로 인해 범용적으로 재사용 가능한 프로그램을 작성하는 문화가 형성되었으며, 이를 software tools라 부른다. 

- 이러한 도구들은 Unix의 강력함과 인기의 주요 원인이 되었으며, Unix의 쉘은 최초의 스크립트 언어(scripting language)로 간주된다.

-Unix 시스템 호출 인터페이스는 오늘날까지 Linux, macOS와 같은 운영체제에서 사용되고 있다.

 

2. POSIX 표준과 xv6

Unix 시스템 호출 인터페이스는 POSIX(Portable Operating System Interface) 표준으로 정의되었다. 

그러나 xv6는 POSIX 호환성을 목표로 하지 않으며, 단순함과 명확성을 우선으로 한다.

 

3. Unix의 통합 접근 방식

Unix는 파일, 디렉터리, 디바이스와 같은 다양한 자원을 파일 이름(file name)과 파일 디스크립터(file descriptor)라는 통합된 인터페이스로 접근할 수 있도록 했다.

- plan9라는 운영체제는 이 아이디어를 확장하여 네트워크, 그래프 등 더 많은 자원을 파일처럼 다룰 수 있도록 함.

- 그러나 대부분의 Unix 계열 운영체제는 Plan9와 같은 방향을 따르지 않고, 기존 Unix 방식을 유지함.

 

4. 다른 운영체제 인터페이스 모델

Unix 이전의 운영체제인 Multics는 파일 스토리지를 메모리처럼 보이도록 추상화했다.

- 이로 인해 Unix와는 매우 다른 인터페이스를 제공했지만, 설계가 복잡.

- Multics의 복잡성은 Unix 설계자들이 단순성을 목표로 삼게 함.

 

5. 보안과 사용자 개념 부재

- xv6는 사용자 개념을 제공하지 않고, Unix 용어로 표현하면 모든 프로세스가 루트 권한으로 실행된다.

- 사용자 간 보호 기능이 없기 때문에 보안은 고려되지 않는다.

 

6. xv6를 통한 운영체제 개념 학습

xv6는 Unix와 유사한 인터페이스를 구현하지만, 그 개념은 Unix에 국한되지 않고 운영체제에 적용될 수 있다.

모든 운영체제는 다음과 같은 기능을 반드시 제공해야 한다.

- 프로세스 관리 : 여러 프로세스를 하드웨어 위에서 동시에 실행.

- 프로세스 격리 : 프로세스가 서로 영향을 주지 않도록 격리.

- 프로세스 간 통신(IPC) : 제한된 방식으로 프로세스 간 통신 허용.

 

'computer security > RISC-V' 카테고리의 다른 글

Chapter 6. Locking  (7) 2025.01.14
Chapter 5. Interrupts and device drivers  (6) 2025.01.13
Chapter 4. Traps and system calls  (4) 2025.01.09
Chapter 3. Page tables  (6) 2025.01.07
chapter 2. Operating system organization  (7) 2025.01.06