개발 · 컴퓨터공학 / / 2021. 10. 9. 18:34

UNIX - main function, process, fork, exec

728x90
반응형

Program Layout

main function

int main(int argc, char *argv[]);

UNIX는 C기반으로 되어있고, C언어는 main 함수를 기준으로 프로그램이 시작된다.

argc는 command - line arguments의 개수, argv는 arguments에 대한 pointer의 array이다.

 

새로운 프로세스를 만들 때 exec system call을 사용하는데, exec를 통해 main을 실행하기 전에 호출되는 start up code가 있다. shell에서는 main이 아니라 이 start up code부터 실행하도록 명령한다. 

kernel에서 이 start up routine을 처리하는데 comman-line arguments 처리와 환경설정을 해준다. 

 

$ ./echoarg arg1 TEST foo
argv[0]: ./echoarg
argv[1]: arg1
argv[2]: TEST
argv[3]: foo

위와같이 프로그램이 실행되면 exec가 command-line arguments를 프로그램으로 넘겨준다.

프로그램을 ./으로 실행하는 이유는 환경변수 PATH 때문인데, PATH directory는 명령어가 있는 디렉토리의 경로들을 담고 있다. 해당 경로가 PATH에 설정되어있지 않으면 명령어가 있는 절대경로 혹은 상대경로를 통해 명령어에 접근해야한다.

따라서 위의 경우 echoarg라는 이름만으로는 상대경로를 알 수 없어 PATH에 설정하지 않는 한 실행시킬 수 없지만, ./echoarg를 통해 상대경로로 접근하면 명령어를 실행할 수 있는 것이다.

 

int main(int argc, char *argv[], char *envp[]);

사실 main함수의 매개변수로는 envp(environment pointer)라는 것이 더 있다.

envp는 환경변수와 관련있는데, 환경변수는 위 그림처럼 HOME, PATH, SHELL 등 다양하다. environment strings를 보면 HOME의 경우 값이 /home/sar\0으로 되어있는데, \0은 문자열이기 때문에 마지막을 나타내는 기호이다.

 위에서 언급했듯 PATH는 명령어를 디렉토리에서 찾는 순서를 지정한다. 위 그림에서 :/bin:/usr/bin과 같이 되어있는 것은 /bin에서 명령어를 찾고, 없을 경우 다음 경로인 /usr/bin에서 명령어를 찾는다는 의미이다. 만약 위에서 ./을 통해 현재 디렉토리의 명령어를 실행하고 싶다면 PATH에 현재 디렉토리도 찾아보라는 의미로 ':.'을 추가해야한다. 

이러한 세팅들을 하는 것이 envp 변수이고, <stdlib.h>에 default로 정의되어있다.

Memory Layout a C Program

C 프로그램을 컴파일하면 실행파일이 생성된다. 이 실행파일이 메모리를 어떻게 차지하는 것인지 알아보자. 

text(code) segment는 프로그램이 실행되는 코드 부분 즉 기계어 부분을 말한다. 

initialized data segment 는 static 변수와 global 변수의 초깃값을 세팅하는 부분을 메모리에 올려주는 부분이다. 

uninitialized data segment 의 경우 초기값이 없는 static / global 변수들을 메모리에 올려주는 부분이다. 

stack은 function call과 관련이 있다. function은 호출될 때에 메모리를 할당하고 return시 메모리를 해제한다. main에서 어떤 함수를 부르고 또 그 안에서 다른 함수를 부를 때 함수에 대한 메모리가 stack에 들어가게 된다. 함수를 호출하면 stack의 top에 할당되고, 함수가 끝나면 top에 있던 메모리가 pop되머 해제된다.

heap은 프로그램 실행 중에 할당되는 동적 할당 메모리가 저장되는 공간이다. C에서 malloc과 같은 것을 볼 수 있다.

 

stack와 heap은 메모리 할당과 해제에 따라서 동적으로 차지하는 크기가 변한다. 

disk에 program file을 저장할 때는 initialized data와 text segment가 저장되어있지만, stack과 heap, uninitialized data는 프로그램이 메모리에 올라올 때 할당된다. 

$ size /usr/bin/cc /bin/sh  
text     data   bss     dec     hex   filename  
79606     1536   916   82058   1408a   /usr/bin/cc
619234    21120 18260  658614   a0cb6   /bin/sh

위와 같이 disk에 있는 cc파일과 sh파일을 보면 데이터의 크기를 알 수 있는데, text가 code의 크기, data가 initialized data, bss는 uninitialized data이다. dec과 hex는 total size를 10진 / 16진으로 표현한 것이다.

 

example

다음 예시를 보자.

/* init.c */
int myarray[50000]={0};
int main(){
   return 0;
}
/* noninit.c */
int myarray[50000];
int main(){
   return 0;
}

같은 의미의 위 두 코드 중 앞선 것은 initialized data, 뒤따른 것은 uninitialized data이다. 위 두 코드의 실행파일을 각각 만들고 크기를 비교해보면 다음과 같다.

[root@localhost root]# cc noninit.c -o noninit
[root@localhost root]# cc init.c -o init
[root@localhost root]# ls -l init noninit
-rwxr-xr-x    1 root     root       214604  4월  7 17:00 init
-rwxr-xr-x    1 root     root        14598  4월  7 17:00 noninit
[root@localhost root]# size init noninit   
text    data     bss     dec     hex filename    
706  200272       4  200982   31116 init    
706     252  200032  200990   3111e noninit

init 실행파일은 initialized data이므로 disk를 차지하는 양이 더 크고, noninit은 더 작다. size를 통해 확인해보면 text 즉 코드의 크기는 같고, data의 경우 initialized data를 사용한 init이 크고, bss는 uninitialized data를 사용한 noninit이 더 크다. 

Process

프로세스는 프로그램이 실행되는 순간의 인스턴스를 말한다. 프로그램은 크기가 고정이지만 프로세스는 실행하는 매 순간마다 크기가 다르다. 프로세스는 프로그램 코드, 변수 data, stack, heap 공간, cpu 하드웨어와 레지스터, 프로그램 stack으로 이루어져 있다. 프로세스는 각각 id가 있다.

shell은 명령어와 arguments를 통해 프로세스를 만들어 실행한다. 위 그림의 경우, shell이 cat이라는 명령어와  file1, file2 argument를 통해 cat 프로세스를 만들어 실행하고, 오른쪽 그림의 경우 '|' pipe를 통해 ls 프로세스의 출력이 wc프로세스의 입력으로 들어가도록 한다. 

 

프로세스의 환경에 대해서 알아보자.

UNIX의 모든 프로세스는 다른 프로세스에 의해서 실행된다. 특히 fork와 exec system call에 의해 프로세스가 생성된다. 프로세스의 최상위에는 init라는 system call이 있다. 모든 프로세스는 init으로부터 나오게 된다. 

Creation Process 

getpid(2) / getppid(2)

#include <unistd.h>

pid_t getpid(void);
// Returns: the process ID of the calling process

pid_t getppid(void);
// Returns: the parent process ID of the calling process

프로세스의 pid를 알아내는 system call이다. getpid는 부르고 있는 프로세스의 id를 getppid는 그 프로세스를 만든 부모 process의 id를 알아낸다. 모든 프로세스의 아이디는 고유하며, non-negative integer이고, process id는 재사용된다.

fork(2)

#include <unistd.h>

pid_t fork(void);
// Returns: 0 in child, process ID of child in parent, -1 on error

process를 만드는 system call이다. fork로 만든 child process에서 실행하면 return 값이 0이고, 0이외의 값은 parent에서 child의 process id를 return한 것이다. -1의 경우는 error이다. 

 parent process를 fork하여 생성한 child process는 내용이 같지만 별도의 프로세스이다.

child process는 parent process의 copy이기 때문에 data space, heap, stack이 같다. 하지만 메모리 공간을 공유하지는 않으며, text segment 즉 코드 부분은 parent와 child process가 공유한다. 

위 그림에서 process A가 parent, process B가 child라고 하자. Text 즉 code가 차지하는 메모리공간은 parent와 child가 같지만, 다른 data의 공간은 서로 다르다. 

 

fork를 실행하면 child가 생성되는데 여기서 child의 코드는 fork를 return하는 동작부터 시작한다. parent에서 fork의 return pid는 child의 pid이지만, child에서의 fork의 return pid는 0이다. 

 

다음 예시 코드를 보자

#include <unistd.h>

main()
{
   pid_t pid;
   printf (“Just one process so far\n”);
   printf (“Calling fork …\n”);

   pid = fork();

   if (pid == 0)
      printf(“I’m the child\n”);
   else if (pid > 0)
      printf(“I’m the parent, child has pid %d\n”, pid);
   else
      printf(“Fork returned error code, no child\n”);
}

fork를 하였을 때 반환된 pid가 0이면 child, 0보다 큰 경우 parent로 child의 pid를 fork로 반환받은 것이다.

Running new program with exec

exec

exec는 execve system call과 라이브러리 5개로 총 6가지 형태가 있다. 

exec는 child process가 parent process와 다른 새로운 프로그램을 실행하게 한다. child process는 parent의 copy이지만 다른 프로세스처럼 동작하기 위해 변화시킨다. 이때 process id는 바뀌지 않지만 exec로 인해서 text, data, heap, stack이 다른 프로세스의 것으로 변경된다. 

 

#include <unistd.h>

int execl(const char *pathname, const char *arg0,…/*NULL*/ );

int execv(const char *pathname, char *const argv[]);

int execle(const char *pathname, const char *arg0,…/*NULL*/,char *const envp[]);

int execve(const char *pathname, char *const argv[], char *const envp[]);

int execlp(const char *filename, const char *arg0,.../*NULL*/ );

int execvp(const char *filename, char *const argv[]);
 
// All six return: -1 on error, no return on success

pathname이 실행 프로그램이고, exec중 어떤 것은 arg의 마지막에 NULL이 들어간다. 

exec 중 'e'가 들어가는 경우 envp 매개변수를 필요로 하고,

'p' 가 들어가는 경우 pathname 이 아니라 filename 을 필요로 하고 file을 찾을 때 PATH environment를 사용한다. 

$ echo $PATH
PATH=/bin:/usr/bin:/usr/local/bin/:. 

위와 같이 PATH를 확인하였을 때 PATH값의 의미는 /bin 에서 명령어를 찾고 없다면 다음 /usr/bin → /usr/local/bin → '.' (current directory) 순으로 명령어를 찾는다. 

'l' 이 들어가는 경우 함수가 argument의 list를 취한다. 

'v'가 들어가는 경우 함수가 argument의 array 형태를 취한다. 

 

example

다음 예제 코드를 보자

/* runls -- ls를 수행하기 위해 “execl”을 수행한다 */

#include <unistd.h>
main(){
	printf("executing ls\n");
	
	execl("/bin/ls", "ls", "-l", (char *)0);
	
	/* 만일 execl이 복귀하면, 호출은 실패한 것이다. 따라서...*/
	
	perror("execl failed to run ls");
	exit(1);
}

execl이므로 파일명 다음 매개변수로 argument의 list를 취하므로 첫 번째 인자 arg[0]인 'ls'는 크게 의미가 없다. arg[1]에 -1이 들어갔고, 다음으로 list의 마지막에는 NULL character가 매개변수로 들어간다. 

 이 프로그램을 실행하면 ls라는 프로세스를 실행하는 프로그램이 된다. 실행 후 execl로 복귀하지 않으므로 perror는 실패한 경우에만 실행된다.

 

이번에는 execv 에 대한 예제 코드를 보자

/* runls2 -- ls를 수행하기 위해 execv를 사용한다.  p.95 (128) */ 
#include <unistd.h>

main()
{
	char *const av[]={"ls", "-l", (char *)0};
	
	execv("/bin/ls", av);
	
	/* 반복하거니와 여기까지 수행이 진행되면 오류를 의미 */
	perror("execv failed");
	exit(1);
}

execl은 argument 하나하나를 매개변수로 넣었다면 execv는 하나의 array로 만들어서 array 를 매개변수로 넣는다. 위 execl과 실행결과는 같다. 마찬가지로 실패한 경우에만 execv 실행 후 돌아와서 perror를 호출한다. 

 

main() /* p.96 (130) */
{
	char * const argin[] = {"myecho", "hello", "world", (char *)0};
	
	execvp(argin[0],argin);
}

execvp는 'p'가 들어가기 때문에 pathname이 아니라 filename이 매개변수로 들어간다. 

위 코드에서 실행하는 myecho는 아래의 코드로 구성되어있다고 하자.

/* myecho -- 명령줄 인수를 그대로 출력한다 */
main(int argc, char** argv)
{
	while( --argc > 0)
		printf(“%s ", *++argv);
		
	printf("\n");
}

위 execvp함수의 매개변수로  전달한 argin이 argv로 전달되어 argv[0] = "myecho", argv[1] = "hello", argv[2] = "world"이 된다.

 

Using exec and fork together

example

exec와 fork를 함께 쓰는 경우에 대해서 알아보자

/* runls3 -- 부프로세스에서 ls를 수행한다 */
#include <unistd.h>

int fatal(char *s){
   perror(s);
   exit(1);
}

main(){
   pid_t pid;
   switch (pid = fork()){
   case -1:
      fatal("fork failed");
   case 0:
      /*자식이 exec을 호출 */
      execl("/bin/ls", "ls", "-l", (char*)0);
      fatal("exec failed");
      break;
   default:
      /* 부모가 자식이 끝날 때까지 수행이 일시 중단하기 위해 wait을 호출 */
      wait((int*)0);
      printf("ls completed\n");
      exit(0);
   }	
}

fork를 실행하여 pid에 return값을 담았을 때, 0인 경우는 child가 호출한 경우이고, 0이 아닌 양수인 경우 parent에서 실행하는 것이다. child가 호출한 경우에는 execl을 통해 ls 프로세스를 실행하고, parent의 경우 wait를 실행하는데, wait는 자신이 만든 child 프로세스가 종료될 때 까지 기다리고 그 다음을 실행한다.

 위 코드를 그림을 통해 확인하자

위 그림에서 왼쪽이 parent이고, 오른쪽이 child이다. parent가 fork를 실행하면 wait을 통해 child의 프로세스가 끝나기를 기다리고, child는 execl을 실행하고 실행이 끝나면 parent로 돌아와 다음 과정을 실행한다. 

 

다음 예제 코드를 보자

/* docommand --run shell command, first version */
int docommand(char *command)
{
    pit_t pid;
    if((pid = fork()) < 0 )
    	return (-1);
    
    if(pid==0) /* child */
    {
    	execl("/bin/sh", "sh", "-c", command, (char *)0);
    	perror("execl");
    	exit(1);
    }
    
    /* code for parent */
    /* wait until child exits */
    wait((int *)0);
    return(0);
}

command를 통해 문자열을 받고, fork를 통해 child인 경우 문자열을 통해 입력받은 이름의 프로세스를 shell이 실행하도록 매개변수로 전달하는 코드이다. 

 

Inherited data and file descriptors

fork로 child를 생성하면, child는 parent의 모든 속성을 상속받는다.

parent가 open한 모든 file들도 child에서 open되어있다. 즉 parent entry의 file descriptor가 가리키고 있는 file table과 거기에 대응하는 i-node가 있는데, child가 생성되면 child의 file descriptor가 가리키고 있는 file table들이 각각 parent가 가리키는 file table들과 같다는 것이다. 또한 parent가 read, write하면서 변화되는 offset도 child와 똑같다. 

/* fork는 "data"가 최소한 20문자 이상임을 가정한다 */
main(){
   int fd;
   pid_t pid;
   char buf[10];  /* 화일 자료를 저장할 버퍼 */
   if((fd = open("data", O_RDONLY)) == -1)
      fatal("open failed");
   
   read(fd, buf, 10);   /*화일 포인터를 전진 */
   printpos("Before fork", fd);
   
   switch(pid = fork()) {
   case -1:    /* 오류 */
      fatal("fork failed");
      break;
   case 0:     /* 자식 */
      printpos("Child before read", fd);
      read(fd, buf, 10);
      printpos("Child after read", fd);
      break;
   default:    /* 부모 */
      wait((int *)0);
      printpos("Parent after wait", fd);
   }
}

child와 parent는 file descriptor가 같은 file table을 가리키고 있기 때문에, child가 parent의 open file들을 공유한다고 했다. 그리고 parent가 read/write하면서 변화되는 file offset도 공유한다. 

data 파일을 read only로 open하고, read를 통해 data의 값을 buf로 읽는다. 

printpos라는 함수는 lseek를 통해 file descriptor의 offset 위치를 print 해주는 함수이다.

위 코드에서는 read로 10만큼 읽었으므로 10이 출력될 것이다. 

fork로 프로세스를 생성하고 pid의 값에 따라 parent에서 실행했다면 child를 대기, child는 printpos를 통해 file descriptor의 offset 위치를 출력한다.

 여기서 offset도 parent와 child가 공유하므로, 10이 출력될 것이고, read를 통해 10 읽고 나서 printpos는 20이 출력될 것이다. 

child의 프로세스가 끝나고 parent의 wait 다음 printpos를 실행하게되면 offset을 공유하므로 그대로 20이 출력된다. 

 

fork를 통해 상속받는 값들은 다음과 같다.

Real user ID, real group ID, effective user ID, effective group ID
Supplementary group IDs
Process group ID
Session ID
Controlling terminal
The set-user-ID and set-group-ID flags
Current working directory
Root directory
File mode creation mask
Signal mask and dispositions
The close-on-exec flag for any open file descriptors
Environment
Attached shared memory segments
Memory mappings
Resource limits

parent와 child간에 서로 다른 값은 다음과 같다

The return value from fork
The process IDs are different
The two processes have different parent process IDs: the parent process ID of the child is the parent; the parent process ID of the parent doesn't change
The child's tms_utime, tms_stime, tms_cutime, and tms_cstime values are set to 0
File locks set by the parent are not inherited by the child
Pending alarms are cleared for the child
The set of pending signals for the child is set to the empty set

parent와 child는 각자 반환하는 return값이 다르고, 각각의 parent process ID가 다르다.

또한 프로세스의 실행시간을 결정하는 변수들의 값이 다르다. file lock은 상속되지 않는다.

 

exec and open files

fork를 통해 child process를 생성하고 parent는 exec로 다른 프로그램을 실행하더라도, child의 process가 접근하고 있는 기존에 open했던 파일들은 변하지 않는다. 

close-on-exec flag

child가 exec을 실행하면 parent가 open한 file descriptor가 자동으로 close되는 것에 대한 flag이다. on 일 경우 자동으로 close되도록 설정된다. 

#inlucde <fcntl.h>
.
.
int fd;
fd = open(“file”, O_RDONLY);
.
.
fcntl(fd, F_SETFD, 1);	/* close-on-exec 플래그를 on으로 설정 */
fcntl(fd, F_SETFD, 0);	/* close-on-exec 플래그를 off으로 설정 */
res = fcntl(fd, F_GETFD, 0);	/* res==1:on, res==0:off */

위 코드는 플래그를 설정하는 코드이다. flag를 1로 설정한 상태로 fork로 child process를 생성하고, child에서 exec를 실행하면, 자동적으로 file descriptor가 close된다. 

 

다음 예제 코드를 보자

int main(int argc, char** argv)
{
  int fd;
  pid_t pid;
	
  fd = open(“test”, O_RDONLY);
	
  pid = fork();
  
  if(pid == 0){
    execl(“exec",“child",0);
  }
  if(pid<0){
    perror("MAIN PROCESS");
  }
	
  wait(NULL);
  printf("MAIN PROCESS EXIT\n");
  close(fd);

  return 0;
}
/* exec.c */

int main(int argc, char** argv)
{
  if( read(3, buff, 512) < 0){
    perror("EXEC PROCESS");
    exit(1);
  }
	
  printf("EXEC PROCESS EXIT\n”);
	
  return 0;
}

위 코드에서 fork로 생성한 child process는 exec.c프로그램을 실행한다. 

parent process에서 fd에 open한 test파일의 file descriptor로 3이 들어갈 것인데, fork로 생성한 child에서 exec를 실행하고, 해당 exec 프로그램에서 file descriptor 3을 읽으면, open한 적이 없는데도 에러가 나지 않는다. 즉 parent에서 open했던 test파일의 file descriptor가 적용이 되는 것이다. 

그래서 close-on-exec flag와 같은 설정으로 child에서 exec를 실행하였을 때 기존의 file descriptor들이 close되도록 처리해주는 것이 필요하다.

 

728x90
반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유