운영체제의 근본 개념 중 하나는 프로세스이다. 프로세스는 운영체제에 의해 스케쥴링 되고 제어되는 동적 entity이다.
추상적으로 프로세스는 실행 중인 프로그램, 현재 값, 상태 정보, 운영체제가 프로세스를 관리하는 데 사용하는 리소스로 구성된다.
Linux와 같은 Unix 기반 운영체제에서 특정 시점에 여러 프로세스가 동시에 실행되고 각 프로세스는 독립된 환경에서 모든 시스템 리소스에 접근하고 제어할 수 있는 것으로 보이지만, 실제로 어느 한순간에 단일 프로세스만이 실행되고 있다.
현재 실행 중인 프로세스를 빠르게 전환(context switching)함으로써 운영체제는 동시 프로세스 실행이 이루어지는 것처럼 보이게 한다.
- 멀티 프로그래밍(멀티 태스킹) : os가 여러 실행단계에 있는 프로세스들 간에 자원을 분배하는 능력
- 멀티 프로세싱 : 여러개의 cpu, processor를 갖춘 시스템 (multi core)
process의 일부는 program 의 실행으로 구성된다. program은 일련의 command와 관련된 data로 구성된 비활성, 정적 entity이다.
- source program
- executable program
Library function
프로그램이 복잡할 수록 function의 사용은 필수적이다. 함수는 작업을 수행하기 위한 선언과 명령으로 구성된 집합으로 사용자 정의 함수와 라이브러리 함수가 있다.
대부분의 UNIX 시스템에서 라이브러리 파일의 표준 위치는 /usr/lib 디렉토리이다. 추가적인 라이브러리 파일은 /usr/local/lib 디렉토리에서 찾을 수 있다.
컴파일에서 사용되는 라이브러리는 기본적으로 static library와 shared object library로 나뉜다.
static library는 프로그램 링크 단계에서 사용되는 object 파일의 모음이다. 참조된 코드는 라이브러리에서 추출되어 실행 파일 이미지에 통합된다.
shared object library는 여러 어플리케이션이 공유할 수 있는 재배치 가능한 object 파일을 포함한다. 컴파일 중에는 library object code가 실행 파일 코드에 포함되지 않고 객체에 대한 참조만 포함한다.
shared object library를 사용하는 실행 파일이 메모리에 로드될 때 적절한 shared object library가 로드되어 이미지에 첨부된다. 만약 shared object library가 이미 메모리에 있다면 이 복사본이 참조된다.
shared object library는 static library보다 더 복잡하다. 리눅스에서는 기본적으로 공유 객체 라이브러리가 존재할 경우 이를 사용하며, 그렇지 않으면 정적 라이브러리를 사용한다.
char* ascii(int start, int finish) {
char* b = new char(finish - start + 1);
for (int i = start; i <= finish; ++i)
b[i - start] = char(i);
return b;
}
#include <cctype>
char* change_case(char* s) {
char* t = &s[0];
while (*t) {
if (isalpha(*t))
*t += islower(*t) ? -32 : 32;
++t;
}
return s;
}
각 라이브러리 파일을 object code로 컴파일하고 libmy_demo.a 아카이브 를 생성하여 아카이브에 추가한다.
$ g++ -c change_case.cxx
$ ls -1
change_case.cc
change_case.o
$ g++ -c ascii.cxx
$ ls -1
ascii.cc
ascii.o
change_case.cc
change_case.o
$ ar cr libmy_demo.a ascii.o change_case.o
$ ls -1
ascii.cc
ascii.o
change_case.cc
change_case.o
libmy_demo.a
my_demo library의 함수 prototype은 해당 header file인 my_demo.h에 위치한다.
#ifndef MY_DEMO_H
#define MY_DEMO_H
char* ascii(int, int);
char* change_case(char*);
#endif
System Calls
program에서 사용되는 미리 정의된 함수들 중 하나는 system call이다. 형식상으로는 라이브러리 함수와 비슷하지만 system call 은 호출한 process 를 대신하여 os가 직접 작업을 수행하도록 요청한다. os가 실행하는 코드는 kernel 내에 있으며 kernel은 일반적으로 memory에 영구적으로 유지되는 중앙 제어 프로그램이다.
system call은 이 코드에 대한 고급/중급 언어 인터페이스 역할을 한다.
커널의 무결성을 보호하기 위해 system call을 실행하는 process는 일시적으로 user mode 에서 system mode(root) 로 전환해야한다. 이러한 context switching 은 일정한 overhead를 수반하고 경우에 따라 동일한 작업을 수행하는 library 함수보다 system call이 덜 효율적일 수 있다.
apropos 명령을 통해 system manual page 를 검색하여 특정 키워드와 관련된 command, function, system call 에 대한 설명을 볼 수 있다.
$ apropos copy
asn1_copy_node (3) - API function
bcopy (3) - copy byte sequence
BIO_ssl_copy_session_id (3ssl) - SSL BIO
BN_copy (3ssl) - copy BIGNUMs
BN_dup (3ssl) - copy BIGNUMs
BN_MONT_CTX_copy (3ssl) - Montgomery multiplication
copysign (3) - copy sign of a number
copysignf (3) - copy sign of a number
copysignl (3) - copy sign of a number
cp (1) - copy files and directories
cpgr (8) - copy with locking the given file to the password or group file
cpio (1) - copy files to and from archives
cppw (8) - copy with locking the given file to the password or group file
dd (1) - convert and copy a file
debconf-copydb (1) - copy a debconf database
EVP_MD_CTX_copy (3ssl) - EVP digest routines
EVP_MD_CTX_copy_ex (3ssl) - EVP digest routines
EVP_PKEY_copy_parameters (3ssl) - public key parameter and comparison functions
getutmp (3) - copy utmp structure to utmpx, and vice versa
getutmpx (3) - copy utmp structure to utmpx, and vice versa
git-checkout-index (1) - Copy files from the index to the working tree
install (1) - copy files and set attributes
memccpy (3) - copy memory area
memcpy (3) - copy memory area
memmove (3) - copy memory area
mempcpy (3) - copy memory area
ntfscp (8) - copy file to an NTFS volume.
objcopy (1) - copy and translate object files
rcp (1) - secure copy (remote file copy program)
rsync (1) - a fast, versatile, remote (and local) file-copying tool
scp (1) - secure copy (remote file copy program)
ssh-copy-id (1) - install your public key in a remote machine's authorized_keys
stpcpy (3) - copy a string returning a pointer to its end
stpncpy (3) - copy a fixed-size string, returning a pointer to its end
strcpy (3) - copy a string
strncpy (3) - copy a string
svnversion (1) - Produce a compact version number for a working copy.
va_copy (3) - variable argument lists
wcpcpy (3) - copy a wide-character string, returning a pointer to its end
wcpncpy (3) - copy a fixed-size string of wide characters, returning a pointer to its end
wcscpy (3) - copy a wide-character string
wcsncpy (3) - copy a fixed-size string of wide characters
wmemcpy (3) - copy an array of wide-characters
wmemmove (3) - copy an array of wide-characters
wmempcpy (3) - copy memory area
일부 라이브러리 함수는 내장된 system call을 가지고 있다. 예를 들어, C++ 삽입(<<) 및 추출(>>) 연산자는 기본 시스템 호출인 read와 write를 사용한다.
Linking Object Code
C/C++로 프로그래밍할 때, 추가적인 라이브러리 파일(standard library 에 포함되지 않은 시스템 호출과 라이브러리 함수의 객체 코드를 포함하는)을 컴파일 시 지정할 수 있다. 이를 위해 -l 컴파일러 옵션을 사용하며, 뒤에 lib 접두사와 .a 확장자가 없는 라이브러리 이름을 지정한다.
# gcc 컴파일러 프로그램의 링크 로더(link-loader) 부분에 libm.a에 있는 수학 라이브러리 객체 코드를 소스 프로그램 prgm.c로부터 생성된 객체 코드와 결합할 것을 지시
$ gcc prgm.c -lm
표준 위치에 있지 않은 라이브러리가 필요할 경우 컴파일할 때 링크 옵션을 줘서 컴파일러에 이를 알릴 수 있다. GNU 컴파일러는 -L 옵션을 사용하며, 뒤에 검색할 추가 디렉터리(또는 디렉터리들)를 지정한다. 컴파일러에 명령줄로 전달된 파일의 처리는 순차적으로 이루어진다. 링크 옵션은 정의되지 않은(해결되지 않은) 참조 오류를 피하기 위해 보통 명령 시퀀스의 끝에 배치된다.
라이브러리 함수에는 종종 소스 프로그램에 추가 헤더 파일을 포함해야한다. 헤더 파일에는 필수적인 함수 원형(prototype), 매크로 정의, 정의된 상수와 같은 정보가 포함되어있다. 적절한 헤더 파일을 포함하지 않으면 프로그램이 올바르게 컴파일되지 않는다. 반대로, 적절한 헤더 파일을 포함하고 관련 라이브러리를 링크하지 않으면 프로그램이 올바르게 컴파일되지 않는다.
Managing Failures
대부분의 경우 system call 또는 library 함수가 실패하면 -1 값을 반환하고 errno라는 외부 전역 변수에 실제 오류를 나타내는 값을 할당한다. 모든 오류 코드에 대한 정의된 상수는 errno.h 헤더 파일에 있다.
프로그램이 system call이나 library 함수의 반환값을 확인하여 성공 여부를 확인하는 습관을 가지는 것이 좋다. 호출이 실패하면 프로그램은 적절한 조치를 취해야한다. 일반적인 조치는 짧은 오류 메세지를 표시하고 프로그램을 종료하는 것이다. 라이브러리 함수 perror는 오류 메세지를 생성하는 데 사용할 수 있다.
#include <iostream>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>
using namespace std;
int main(int argc, char *argv[]) {
int n_char = 0;
char buffer[10];
cout << "n_char = " << n_char << " errno = " << errno << endl;
n_char = write(1, "enter a word : ", 15);
n_char = read(0, buffer, 10);
cout << "n_char = " << n_char << " errno = " << errno << endl;
if(n_char == -1) {
perror(argv[0]);
exit(1);
}
n_char = write(1, buffer, n_char);
return 0;
}
Executable File Format
linux 환경에서 system 에 실행할 수 있는 실행가능한 형태로 컴파일된 소스 파일은 ELF(Executable and Linking Format) 라는 특별한 형식으로 저장된다. ELF 형식의 파일에는 header (hardware, program 특성을 지정하는데 사용), program text, data, relocation information, symbol table, 문자열 테이블 정보 가 포함되어있다.
ELF 형식의 파일은 os에서 실행가능하도록 표시되며 command line에서 해당 이름을 입력하여 실행할 수 있다.
UNIX의 이전 버전에서는 실행 파일을 a.out 형식(Assembler Output Format)으로 저장했다. 현재는 이 형식이 사용되지는 않지만 컴파일 과정과 여전히 연관이 있다. C/C++ 프로그램 파일이 컴파일되면 컴파일러는 기본적으로 실행 파일을 a.out이라는 파일에 저장한다.
#include <iostream>
#include <fcntl.h> // open
#include <libelf.h> // ELF 라이브러리
#include <unistd.h> // close
#include <cstring> // strerror
#include <gelf.h> // ELF 헤더 관련 라이브러리
void print_architecture(Elf32_Half machine) {
switch (machine) {
case EM_386:
std::cout << "Architecture: Intel 80386" << std::endl;
break;
case EM_X86_64:
std::cout << "Architecture: AMD x86-64" << std::endl;
break;
case EM_ARM:
std::cout << "Architecture: ARM" << std::endl;
break;
case EM_AARCH64:
std::cout << "Architecture: AARCH64" << std::endl;
break;
default:
std::cout << "Architecture: Unknown" << std::endl;
break;
}
}
int main(int argc, char *argv[]) {
if (argc != 2) {
std::cerr << "Usage: " << argv[0] << " <file>" << std::endl;
return 1;
}
const char *filename = argv[1];
// ELF 라이브러리 초기화
if (elf_version(EV_CURRENT) == EV_NONE) {
std::cerr << "ELF library initialization failed: " << elf_errmsg(-1) << std::endl;
return 1;
}
int fd = open(filename, O_RDONLY);
if (fd < 0) {
std::cerr << "Failed to open file: " << strerror(errno) << std::endl;
return 1;
}
Elf *elf = elf_begin(fd, ELF_C_READ, nullptr);
if (elf == nullptr) {
std::cerr << "elf_begin() failed: " << elf_errmsg(-1) << std::endl;
close(fd);
return 1;
}
if (elf_kind(elf) != ELF_K_ELF) {
std::cout << "The file is not an ELF format." << std::endl;
elf_end(elf);
close(fd);
return 0;
}
GElf_Ehdr ehdr;
if (gelf_getehdr(elf, &ehdr) == nullptr) {
std::cerr << "gelf_getehdr() failed: " << elf_errmsg(-1) << std::endl;
elf_end(elf);
close(fd);
return 1;
}
// 아키텍처 출력
print_architecture(ehdr.e_machine);
elf_end(elf);
close(fd);
return 0;
}
System Memory
UNIX에서 실행 프로그램이 커널에 의해 시스템 메모리로 읽혀 실행되면, 그것은 프로세스가 된다.
시스템 메모리를 두 개의 구분된 영역이나 공간으로 나눌 수 있다. 첫 번째는 user space로 user process가 실행되는 공간이다.
system은 이 공간 내에서 개별 사용자 프로세스를 관리하고 프로세스가 서로 간섭받지 않도록 한다. user space 에서 실행되는 프로세스는 user process 라고 하며 user mode에 있다고 한다.
두번째는 kernel space라고 불리는 영역으로, 커널이 실행되고 서비스를 제공하는 공간이다. 사용자 프로세스는 시스템 호출을 통해서만 커널 공간에 접근할 수 있다. 사용자 프로세스가 시스템 호출을 통해 커널 코드를 실행하면, 그 프로세스는 일시적으로 kernel process로 알려지며 kernel mode에 있다고 한다.
커널 모드에서는 프로세스가 특수한 (루트) 권한을 가지며 중요한 시스템 데이터 구조에 접근할 수 있다. 사용자 모드에서 커널 모드로의 이러한 모드 전환을 context switch 이라고 한다.
UNIX 환경에서 커널은 재진입 가능(reentrant)이기 때문에 여러 프로세스가 동시에 커널 모드에 있을 수 있다.
만약 시스템에 하나의 프로세서만 있다면, 어느 시점에서든 하나의 프로세스만 진행될 수 있으며, 나머지 프로세스들은 대기 상태에 놓인다.
운영 체제는 프로그램 상태 워드(Program Status Word, PSW)에 저장된 비트를 사용하여 현재 프로세스의 모드를 추적한다.
Process Memory
각 process는 고유의 독립된 주소 공간에서 실행된다. 시스템 메모리에 존재할 때 3개의 segment로 나눌 수 있다.(text, data, stack)
- text segment
- 실행 가능한 프로그램 코드와 상수 데이터를 포함한다.
- 운영 체제에 의해 읽기 전용으로 표시되며, 프로세스에 의해 수정될 수 없다.
- 여러 프로세스가 동일한 텍스트 세그먼트를 공유할 수 있다.
- 만약 프로그램의 두 번째 복사본이 동시에 실행되어야 한다면, 시스템은 중복된 텍스트 세그먼트를 다시 로드하는 대신 이전에 로드된 텍스트 세그먼트를 참조한다.
- 필요시, 공유 텍스트는 C/C++ 컴파일러를 사용할 때 기본적으로 설정되어 있으나, 컴파일 라인에서 -N 옵션을 사용하여 비활성화할 수 있다.
- data segment
- 데이터 세그먼트는 텍스트 세그먼트와 연속적으로 연결되어 있다.(가상적으로)
- 두가지로 나눌 수 있다.
- 초기화된 데이터(예: C/C++에서 static으로 선언되었거나 위치에 의해 static으로 지정된 변수)
- 초기화되지 않은 데이터
- 라이브러리 메모리 할당 루틴(예: new, malloc, calloc 등)은 brk와 sbrk 시스템 호출을 사용하여 데이터 세그먼트의 크기를 확장한다.
- 새로 할당된 공간은 현재 초기화되지 않은 데이터 영역의 끝에 추가된다.
- 이 사용 가능한 메모리 영역은 때때로 힙(heap)이라고 불린다.
- stack segment
- 자동 식별자, 레지스터 변수, 함수 호출 정보의 저장을 위해 프로세스에서 사용된다.
- main 함수의 식별자 i, showit 함수의 buffer2, 그리고 for 루프 내에서 showit 함수가 호출될 때 저장되는 스택 프레임 정보가 스택 세그먼트에 위치한다.
- 필요에 따라 스택 세그먼트는 초기화되지 않은 데이터 세그먼트 쪽으로 확장된다.
- 스택의 영역 너머에는 프로세스의 명령줄 인수와 환경 변수가 포함되어 있다.
u area
텍스트, 데이터, 스택 세그먼트 외에도 운영 체제는 각 프로세스에 대해 u 영역(user area)이라는 영역을 유지한다.
u area에는 해당 프로세스에 특정한 정보(예: 열린 파일, 현재 디렉터리, 시그널 동작, 회계 정보)와 프로세스 사용을 위한 시스템 스택 세그먼트가 포함되어 있다.
프로세스가 시스템 호출을 수행할 때(예: main 함수에서 write 시스템 호출을 할 때), 시스템 호출을 위한 스택 프레임 정보는 시스템 스택 세그먼트에 저장된다.
이 정보는 다시 프로세스가 일반적으로 접근할 수 없는 영역에 운영 체제에 의해 유지된다. 따라서 이 정보가 필요하다면 프로세스는 특별한 시스템 호출을 사용하여 접근해야한다.
프로세스 자체와 마찬가지로 프로세스의 u 영역의 내용도 운영 체제에 의해 페이지 인(page in) 및 페이지 아웃(page out)된다.
Process Memory Address
운영 체제는 각 사용자 프로세스 세그먼트와 관련된 가상 주소를 추적한다. 이 주소 정보는 프로세스에 사용할 수 있으며, 외부 변수 etext, edata, end를 참조하여 얻을 수 있다. 이 세 변수의 주소(내용이 아님)는 각각 텍스트, 초기화된 데이터, 초기화되지 않은 데이터 세그먼트 위의 첫 번째 유효한 주소에 해당한다.
#include <stdio.h> // printf 함수 선언 포함
#define PRADDR(X) printf(#X " at %p and value = %d \n", (void*)&X, X)
extern int etext, edata, end;
static char s = 'S';
int a, b = 1;
void sub1(int p);
int main(int argc, char* argv[]) {
static int c, d = 1;
char m, n = 'n';
printf("main at %p and sub1 at %p \n", (void*)main, (void*)sub1);
printf("end of text segment at %p \n", (void*)&etext); // etext로 수정
PRADDR(s);
PRADDR(b);
PRADDR(c);
PRADDR(d);
printf("end of statics & initialized externals at %p \n", (void*)&edata);
PRADDR(a);
printf("end of uninitialized externals at %p \n", (void*)&end);
PRADDR(m);
PRADDR(n);
printf("argc at %p and value = %d \n", (void*)&argc, argc);
printf("argv at %p and value = %p \n", (void*)&argv, (void*)argv);
for (b = 0; b < argc; b++) {
printf("argv[%d] at %p and value = %p or %s \n", b, (void*)&argv[b], (void*)argv[b], argv[b]);
}
sub1(c);
return 0;
}
void sub1(int p) {
static int t;
char v;
PRADDR(t);
PRADDR(p);
PRADDR(v);
}
$ ./display_process_address
main at 0x4004f4 and sub1 at 0x400700
end of text segment at 0x400846
s at 0x601020 and value = 83
b at 0x601024 and value = 1
c at 0x601048 and value = 0
d at 0x601028 and value = 1
end of statics & initialized externals at 0x60102c
a at 0x601040 and value = 0
end of uninitialized externals at 0x601050
m at 0x7fffcbf8b0ce and value = 0
n at 0x7fffcbf8b0cf and value = 110
argc at 0x7fffcbf8b0bc and value = 1
argv at 0x7fffcbf8b0b0 and value = 0x7fffcbf8b1b8
argv[0] at 0x7fffcbf8b1b8 and value = 0x7fffcbf8c85f or ./display_process_address
t at 0x601044 and value = 0
p at 0x7fffcbf8b08c and value = 0
v at 0x7fffcbf8b09f and value = 0
Creating Process
system에는 새로운 process를 생성할 수 있는 메커니즘이 있어야한다는 것은 분명하다.
부트스트래핑 중에 커널에 의해 생성된 몇 가지 특수한 초기 프로세스(예: init)를 제외하고, Linux 환경에서 모든 프로세스는 fork 시스템 호출에 의해 생성된다.
프로세스를 시작하는 프로세스는 parent라고 하며, 새로 생성된 프로세스는 child이라고 한다.
$ man 2 fork
FORK(2) Linux Programmer's Manual FORK(2)
NAME
fork - create a child process
SYNOPSIS
#include <unistd.h>
pid_t fork(void);
성공하면 fork는 부모 프로세스에는 자식 프로세스의 프로세스 ID(고유한 정수 값)를 반환하고, 자식 프로세스에는 0을 반환한다.
fork의 반환 값을 확인함으로써 프로세스가 부모인지 자식인지 쉽게 알 수 있다.
Linux 시스템에서 프로세스 상태 테이블을 확인하면(프로세스 상태 명령어 ps 참조), 낮은 프로세스 ID(1, 2, 3 등)를 가진 여러 프로세스가 존재한다. (예: init, keventd, kswapd)
파일 시스템을 검색해 보면 /sbin/init이라는 시스템 프로그램이 있지만, 다른 프로세스들(keventd, kswapd 등)에 대한 시스템 프로그램 파일은 존재하지 않는다.
init 프로세스를 제외한 keventd, kswapd 등의 낮은 프로세스 ID를 가진 프로세스들은 실제로는 커널 스레드(Kernel Threads)이다. 이러한 커널 스레드는 사용자 공간에서 실행되는 일반적인 사용자 프로세스가 아니라, 커널 공간에서 실행되는 프로세스이다. 따라서 파일 시스템에서 keventd나 kswapd와 같은 시스템 프로그램 파일을 찾을 수 없다. 이 프로세스들은 커널 자체에 의해 생성되고 관리되며, 실행 파일 형태로 존재하지 않는다.
- 커널 스레드(Kernel Threads)의 역할:
- keventd, kswapd와 같은 프로세스들은 커널 내부에서 특정 작업을 수행하기 위해 실행되는 스레드
- keventd: 커널 이벤트 데몬으로, 커널 내의 다양한 이벤트를 처리한다.
- kswapd: 메모리 관리 서브시스템의 일부로, 사용 가능한 메모리가 부족할 때 페이지 교체를 수행하는 데 사용된다.
- keventd, kswapd와 같은 프로세스들은 커널 내부에서 특정 작업을 수행하기 위해 실행되는 스레드
- 사용자 공간과 커널 공간:
- init은 사용자 공간에서 실행되는 첫 번째 프로세스이며, /sbin/init이라는 실행 파일 형태로 존재한다.
- 반면, 커널 스레드는 커널 코드의 일부로 실행되며 사용자 공간에서 실행되는 프로그램 파일이 없다.
- 커널 내에서 생성 및 관리:
- 커널 부팅 과정에서 커널 자체가 keventd, kswapd 등의 스레드를 생성한다.
- 이러한 스레드는 커널 코드에서 직접 정의되며, 커널이 메모리 관리, 디바이스 관리, 파일 시스템 관리 등의 다양한 작업을 수행하도록 돕는다.
- 프로세스 ID (PID):
- 이러한 커널 스레드들은 프로세스 테이블에 등록되기 때문에 ps 명령어로 확인할 수 있다.
- 하지만 이들은 일반적인 사용자 프로세스가 아니라 커널 내에서 특수한 목적으로 실행되는 스레드이다.
#include <iostream>
#include <unistd.h>
#include <cstdlib>
using namespace std;
int main() {
cout << "Hello\n";
fork();
cout << "bye\n";
return 0;
}
$ ./fork_process
Hello
bye
bye