2017년 6월 9일 금요일

Yocto 환경에서의 embedded linux systems programming

이번 posting에서는 오래만에 linux kernel & device driver가 아닌 user space에서의 system programming 기법과 관련한 내용을 소개하는 시간을 갖고자 한다.

RIoT board(i.MX6 Solo) + yocto project + linux systems programming with GNU C
부제: 알아두면 유용한 linux systems programming 기법

<목차>
1. 머릿말
2. 개발 환경 소개(Yocto Project)
3. Process 예제 소개
4. Signal 예제 소개
5. Thread(POSIX thread) 예제 소개
6. Socket  예제 소개
7. Shared memory & Message Queue 예제 소개
8. 맺음말


1. 머릿말
사실 "UNIX/Linux 환경에서의 system programming" 기법과 관련해서는 그간 두세기(?)에 걸쳐 개발자 생활을 해오면서 지겹도록 많이 접해본 사항인지라, 특별히 관련 내용을 정리해야할 필요성을 느끼지 못했다. 하지만, system programming(user space programming)이라는게 kernel/device driver programming(아래 link 참조)과 뗄 내야 뗄 수 없는 상관 관계를 갖고 있으므로, kernel/device driver programming을 보다 효율적으로 진행하기 위해서라도 관련 내용을 소개하는 것도 나름 의미가 있겠다는 생각이 들었다.


시중에 UNIX/Linux systems programming을 주제로 하는 참고 서적은 무수히 많이 나와 있다. 그 중에서 자타가 공인하는 우수도서 4권을 아래에 소개해 본다.

    
그림 1.1 유명한 UNIX/Linux system programming 관련 서적

주1: 앞의 두권의 책은 그야말로 UNIX 계열(System V, BSD, Linux, OS X 등)에서 모두 사용 가능한 책이며, 뒤의 두권의  책은 Linux에 특화된 책이다. 
주2: 각 책마다 일장 일단이 있겠으나, 개인적으로는 4번째 책이 가장 좋은 것 같다(물론 위의 4권 모두 훌륭한 책임에는 틀림 없다).

이번 posting에서는 위의 책에서 다루는 내용 중, 아래와 같이 실제 필드에서 많이 활용되는 공통 주제를 중심으로 내용을 구성해 보고자 한다(물론 이 밖에도 추가로 언급할만한 다양한 주제가 있겠으나, 지면 관계상 나머지 부분은 독자 여러분의 몫으로 돌리도록 하겠다.).
       ➨ Process
       ➨ Signal
       ➨ Thread
       ➨ Socket
       ➨ Message Queue
       ➨ Shared memory

아이러니(?)하게도, 본 posting에서 소개하는 example code는 위의 책에 나오는 내용이 아니라, 아래 책에서 소개된 내용을 기준으로 하게 되었다. 그 이유는 책의 내용 자체는 그림 1.1의 책 내용에 못 미치는 감이 없지 않으나, 예제 부분 만큼은 training 용으로 간결하고 쉽게 만들어져 있는 까닭에 본 posting의 취지(기초 course)와 잘 부합한다고 판단되었기 때문이다.

그림 1.2 Jerry Cooperstein's book(http://www.coopj.com/)

하지만, 내용 중 일부는 (그림 1.2의 내용 중 부족하다고 판단되는 부분에 한해서) 위의 그림 1.1의 내용을 참조하여 소개할 예정이므로, 독자 여러분께서는 각자 자신이 처한(혹은 원하는) 상황에 맞게 책을 선별하여 study를 하면 될 것으로 보인다. 분명한 것은 여러 권의 책을 보는 것 보다는 한권의 책을 제대로 이해하는 것이 낫다는 사실이다.

마지막으로, 이번 posting에서 언급하는 example source code는 아래 github에서 확인 가능하다(이 자리를 빌어 원저자인 Jerry Cooperstein에게 감사의 마음을 전한다).


2. 개발 환경 소개(Yocto Project)
이번 posting에서 소개하는 각종 linux system programming 예제는 모두 아래와 같은 환경에서 동작 시험을 진행하였다.

그림 2.1 RIoT board and Yocto Project

RIoT board 및 Yocto project(특히 toolchain 및 application package 추가 방법 관련 주목)와 관련해서는 이전 posting의 내용을 참조해 주기 바란다.
위의 내용을 참조로 아래와 같은 cross-toolchain 환경이 준비되었다고 가정하자.

source /opt/poky/2.1.2/environment-setup-armv7a-neon-poky-linux-gnueabi
arm-poky-linux-gnueabi-gcc --version
arm-poky-linux-gnueabi-gcc (GCC) 5.3.0
Copyright (C) 2015 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.


<NFS booting 관련>
이번 posting에서 사용한 예제는 모두 NFS booting 상태에서 동작을 검증하였다. NFS booting과 관련해서는 아래 posting의 2.4절을 참조하기 바란다.



3. Process 예제 소개
이번 절에서는 process 생성 및 동작 등과 관련한 함수인 fork( ), daemon( ), pipe( ), execlp( ), exit( ) 등의 함수 사용 예제를 다루고자 한다.

그림 3.1 fork( ), exit( ), wait( ), execve( ) 함수의 동작 원리(참고문헌 [1]에서 발췌)

3.1 fork 예제 
아래 코드는 fork( ) 함수를 사용하여 child process를 생성하는 예제이다.

pid = fork( ) 호출 시, pid == 0 이면, child process에 해당하고, pid > 0 면, parent process에 해당한다. child process는 다시 process_dir( ) 함수를 호출(recursive 호출)하고, 연이어 exit( ) 함수를 호출하는 형태로 되어 있고, parent process는 wait( ) 함수를 호출하여 child process가 종료될 때까지 대기하는 형태로 구성되어 있다.

코드 3.1 lab3_ls.c

<How to build>
$ cd s_13
$ ../genmake
  => Makefile이 없을 경우 만들어 낸다.
$ make

그림 3.2 s_13 디렉토리 내용 전체 build 모습

<How to run>
# cd /sbin/mybin
  => 편의상 이 디렉토리에 test binary를 복사해 두었다.
# ./lab3_ls


 그림 3.3 lab3_ls 실행 모습

그림 3.4 lab3_ls 실행 상태에서 ps한 결과 - parent/child process가 늘어나는 모습 

3.2 daemon 예제
아래 예제는 daemon( )  함수를 사용하여 daemon process를 만드는 예제이다. 

daemon process는 init process가 자신의 parent process이면서, background로 동작하는 process이다. 아래 코드는 아주 간단한 코드로 daemon( ) 함수 호출 후, pause( ) 함수를 호출하여 signal이 들어올 때까지 sleep 상태에 있게 된다. 따라서 이 daemon process를 종료하기 위해서는 target board 상에서 예를 들어 "kill -SIGUSR1 <pid>" 명령을 실행해 주면 된다.


코드 3.2 lab7_daemon.c

<How to build>
$ cd s_13
$ ../genmake
$ make

<How to run>
# cd /sbin/mybin
# ./lab7_daemon

그림 3.5 lab7_daemon 실행 모습

3.3 pipe 예제
아래 코드는 pipe를 통해 parent process와 child process가 통신하는 예제이다.

pipe( ) 함수를 호출하게 되면 두개의 file descriptor(아래 코드의 경우 filedes[0], filedes[1])를 통해 접근 가능한 buffer가 커널에 만들어지게 된다. 이때 filedes[1]을 통해 write한 data는 filedes[0]를 통해 read가 가능한 형태(First in first out 방식으로 동작함)가 되는데, 이를 pipe라고 부른다. 아래 코드는 child process가 execlp( ) 함수를 통해 "ls -l /usr/bin" 명령을 실행한 결과를 filedes[1]으로 write하고, parent process는 이 내용을 filedes[0]을 통해 read한 후, STDOUT으로 출력하는 모습을 보여준다. 한가지 재밌는 부분은 parent 및 child process 모두 filedes[0], filedes[1]을 확보하고 있는 상태에서, 아래 예제의 경우는 child filedes[1] -> parent filedes[0] 만 사용하고, 나머지 경우 즉, parent filedes[1] -> child filedes[0]는 사용하지 않고 있으므로, 해당 file descriptor는 모두 close해 주어야 한다는 사실이다. 끝으로 dup2( int oldfd, int newfd) 함수를 사용하여 STDOUT_FILENO file descriptor 값을 filedes[1] 값으로 바꿔주는(복사하는) 부분이 보이는데, 이는 execlp( ) 함수를 통해 실행된 결과(stdout으로 출력되는 결과)가 filedes[1]으로 출력되도록 하기 위해 필요한 조치로 이해하면 된다.

참고로, 아래 예제의 경우는 execlp( ) 함수를 사용하여 shell 명령을 수행했으나, popen( ) 함수를 사용하여 이를 대신할 수도 있다.

코드 3.3 lab1_pchild_exec.c

<How to build>
cd s_14
../genmake
$ make

<How to run>
# cd /sbin/mybin
./lab1_pchild_exec


그림 3.6 lab1_pchild_exec 실행 모습


4. Signal 예제 소개
이번 절에서는 process 혹은 thread 등에서 자주 사용되는 signal의 동작 원리를 파악해 보고자 한다.

그림 4.1 signal 전달과 handler 실행 동작 원리(참고문헌 [1]에서 발췌)

4.1 sigaction 예제 1
아래 코드는 sigaction( ) 함수를 사용하여 signal handler를 처리하는 예제이다.

sigaction을 통해 signal handler를 등록하는 방법은 두가지가 있는데, 하나(signal number만 넘기고자 하는 경우)는 sa_handler callback 함수를 이용하는 경우이고, 다른  하나(signal number 외에도 다른 추가 정보를 전달하고자 하는 경우)는 sa_sigaction callback 함수를 활용하는 경우이다. 아래 코드는 sa_handler callback 함수를 사용하여, 두개의 signal handler 즉, sig_int( )와 sig_quit( ) 함수를 각각 등록하고 있다. 이 중 sig_int( )는 SIGINT signal 수신 시 호출되며, sig_quit( ) 함수의 경우는 SIGQUIT signal을 수신할 경우 호출되도록 설정(sigaction( ) 함수 사용)되어 있다. 각각의 signal handler 함수 내부 코드를 살펴 보면, sig_int( ) 함수는 sleep( ) 함수를 호출하며, sig_quit( ) 함수는 abort( ) 함수를 호출하여 process를 비정상 종료시킴을 알 수 있다.


참고 사항: signal handler를 등록하는 또 다른 방법으로는 signal 함수를 사용한 아래와 같은 (전통적인) 방법이 있으나, 이 방법 보다는 sigaction( ) 함수가 보다 우수한 API이므로, 가급적 이 함수를 사용하기 바란다.
signal (SIGINT, sig_int);

코드 4.1 lab2_sigaction.c

<How to build>
$ cd s_16
$ ../genmake
$ make

<How to run>
# cd /sbin/mybin
# ./lab2_sigaction
  => Ctrl-C(SIGINT signal 발생) 및 Ctrl-\(SIGQUIT signal 발생) 키를 눌러 각각의 handler가 동작하는 것을 확인해 본다.

그림 4.2 lab2_sigaction 실행 모습

4.2 sigaction 예제 2

아래 코드는 sigaction( ) 함수를 사용하여 signal handler를 처리하는 또 다른 예제이다.

아래 코드는 4.1절의 예제와는 달리, signal set 개념을 사용하여 SIGINT signal 처리 중에는 SIGQUIT signal이 blocking되도록 하는 모습을 보여주고 있다.

----------------------------------------------------------------------------------------------------------------
sigemptyset (&sigmask);   /* signal set을 clear 한다. */
..
sigaddset (&sigmask, SIGQUIT);  /* SIGQUIT을 signal set에 추가(block signal로 지정)한다. */
..
act_int.sa_handler = sig_int;  /* SIGINT handler 등록 */
act_int.sa_mask = sigmask;  /* signal set(mask)을 sigaction의 sa_mask 값으로 설정 => 
                                             SIGINT handler 처리 중, sigmask로 설정한 signal에 대해서는 
                                             block 해라 ! */
----------------------------------------------------------------------------------------------------------------

코드 4.2 lab3_block.c

<How to build>
cd s_16
../genmake
make

<How to run>
# cd /sbin/mybin
# ./lab3_block
  => Ctrl-C(SIGINT signal 발생)를 누른 후, Ctrl-\(SIGQUIT signal 발생)를 5초 이내에 누를 경우, 이는 block되며, 5초가 지난  후 처리(무시되는 것이 아님에 주의)된다.

그림 4.3 lab3_block 실행 모습

4.3 sigprocmask 예제
아래 코드는 sigprocmask( ) 함수를 사용하여 signal을 block하거나 해제하는 것을 보여주는는 예제이다.

sigprocmask( ) 함수를 사용하면 지정된 signal set에 대해 block, unblock 등의 작업을 진행할 수 있다. 특히 아래 코드에서와 같이 sigprocmask( ) 함수의 첫번째 인자로 SIG_SETMASK를 지정할 경우, 두번째 인자로 사용된 signal set의 내용이 새로운 signal mask 값으로 지정되게 되고, 이전 signal mask 정보는 세번째 인자인 oldset에 저장되게 된다.


한편 sigfillset( ) 함수를 사용하면, 모든 signal이 선택되므로, 아래 코드에서는 5초간 모든 signal을 block했다가, 다시 예전 상태로 회복하고 있음을 알 수 있다.

코드 4.3 blocksig.c
<How to build>
cd s_16
../genmake
make

<How to run>
# cd /sbin/mybin
# ./blocksig
  => 5초 이내에 Ctrl-C(SIGINT signal 발생), Ctrl-\(SIGQUIT signal 발생) 등을 눌러 봐야 모두 block됨을 알 수 있다. 5초가 경과된 후, 앞서 전달된 signal이 먹히게 된다.

그림 4.4 blocksig 실행 모습


4.4 sigqueue 예제
아래 코드는 sigaction( ) 함수와 sigqueue( ) 함수를 사용하여 signal 외에도 추가 정보를 전달하는 것을 보여주는 예제이다.

Signal은 간단하면서도 편리한 IPC(Inter process communication) 방법 중 하나이다. 아래 코드는 sigqueue( ) 함수를 사용하여, signal number 이외의 추가 정보(특히 integer 값과 string)를 전달하고 수신하는 내용을 보여준다. 이를 위해서는 반드시 act.sa_flags = SA_SIGINFO 설정을 해 주어야만 한다.


코드 4.4 lab1_siginfo.c

<How to build>
cd s_17
../genmake
make

<How to run>
# cd /sbin/mybin
# ./lab1_siginfo
  => signal number, pid 등과 함께 message string도 전달되고 있다.

그림 4.5 lab1_siginfo 실행 모습

4.5 sigsetjmp & siglongjmp 예제
아래 코드는 sigsetjmp( ) 함수와 siglongjmp( ) 함수를 사용한 예제(non-local goto)이다.

C에서 goto 문은 local 함수 내에서만 jump operation이 가능한데 반해, setjmp & longjmp 함수를 이용하면 함수 간의 jump operation이 가능하다. 아래 코드는 sigsetjmp( ) 함수를 사용하여 process의 현재 환경 정보(PC 등 CPU register 정보) 및 signal mask 값 등을 저장(global 변수 env 및 savesigs)해 두고, signal이 발생할 경우, signal handler sig_int( ) 함수 내에서 siglongjmp( ) 함수를 사용하여 sigsetjump( ) 함수 위치로 다시 jump하는 예를 보여준다. 참고로, 최초 sigsetjmp( ) 호출인지, 아니면 siglongjmp( ) 함수에 의한 sigsetjmp( ) 함수 재 호출인지 여부를 판단하는 것은 sigsetjmp( ) 함수가 return하는 값에 의해 가능하다.

코드 4.5 lab2_siglongjmp.c

<How to build>
cd s_17
../genmake
make

<How to run>
# cd /sbin/mybin
# ./lab2_siglongjmp
  => Ctrl-C 키를 누를 때 마다, siglongjmp( ) 함수가 호출되어, sigsetjmp( )에서 지정해 둔 위치(sigsetjmp( ) 함수 위치)로 jump하게 된다.


그림 4.6 lab2_siglongjmp 실행 모습


5. Thread(POSIX thread) 예제 소개
이번 절에서는 pthread API를 사용하여 thread를 생성하고, 생성된 thread 들 간에 상호 작용하는 방법에 관하여 설명하고자 한다.

5.1 thread 생성 기본 예제
아래 코드는 pthread_create( ) 함수와 pthread_join( ) 함수를 사용한 thread 관련 예제이다.

pthread_create( ) 함수는 새로운 thread를 생성(여기서는 process( ) 함수)시켜 주고, pthread_exit( ) 함수는 자기 자신(thread)을 종료시킬 때 사용한다. 한편, pthread_join( ) 함수는 특정 thread가 종료(pthread_exit( ) 함수 호출 or return)되기를 기다린 후, 해당 thread를 정리(clean up and remove)하는 역할을 한다. pthread_join( )을 process 관련 함수로 비유해 보자면, waitpid( ) 함수와 비슷한 역할을 한다고 볼 수 있다. 또한, 아래 코드에는 소개되지 않았으나, pthread_join( ) 함수와 유사한 기능을 하는 함수로 pthread_detach( ) 함수가 있는데, 이 함수는 thread가 join을 할 필요 없이, pthread_exit( ) 이나 return 문 수행 후, 자동으로 thread 관련 정보가 정리되도록 하고자 할 때 사용한다.

참고로, main thread가 return하거나, 임의의 thread가 exit( ) 함수를 호출하게 되면, 프로세스 내의 모든 thread는 즉시 종료하게 된다. 이는 다시말해 각 thread를 위해 할당되었던 memory가 자동으로 정리됨을 뜻한다. 따라서 일반적으로는 반드시 pthread_join( )이나 pthread_detach( ) 함수를 사용하는 것이 정답이겠으나, exit( ) 함수를 써서 process를 곧바로 종료하는 간단한 코드의 경우에는 이들 함수를 사용하지 않을 수도 있다(이 경우, memory leak이 발생하는 것은 아님).

추가적으로, 아래 코드에서 한가지 재밌는 부분이 있는데, 이는 volatile 키워드 사용과 관련된 부분이다.

       volatile char running = 1;
       volatile static long long counter = 0;

만일 volatile 키워드를 제거한 상태에서 아래 Makefile(코드 5.2)에서와 같이 -O2 최적화 option을 주어 compile을 하게 되면, 전혀 예상하지 못한 결과를 나올 수도 있게 된다. 아래 주석 부분을 읽어 보기 바란다.

--------------------------------------------------------------------------------------------------------------------------------
void *process (void *arg)  /* non-main thread */
{
    while (running) {   /* 이 thread 내에서 running 변수 값의 변동 사항이 없으니, 
                                  compiler는 running 변수의 내용을 memory에서 매번 가져올 필요가
                                  없다고 판단하여, 항상 1이라고 판단하게 됨 */
        counter++;   /* 여기서 1씩 증가시킨 내용이 main thread에는 반영되지 않음 */
    };
    pthread_exit (NULL);
}

int main (int argc, char **argv)  /* main thread */
{
    ...
    /* Every so often, look at the counter and print it out. */
    for (i = 0; i < 10; i++) {
        sleep (1);
        printf ("%lld\n", counter);   /* main thread 내에서 counter 값을 갱신하는 부분이 
                                                   없으므로, compiler가 역시 매번 이 값을 memory에서
                                                   가져오지 않도록 코드를 정리하여, for loop 내내 초기 
                                                   값인 0이 출력되게 됨 */
    }
    /* Tell the processing thread to quit. */
    running = 0;  /* 여기서 수정한 내용이 다른 thread에게 반영되지 않음 */
    ...
}
--------------------------------------------------------------------------------------------------------------------------------

코드 5.1 lab1_counter.c

코드 5.2 Makefile


<How to build>
cd s_18
../genmake
make

<How to run>
# cd /sbin/mybin
# ./lab1_counter
  => 아래 내용은 위의 코드 5.1의 내용을 그대로 실행한 결과와 코드 5.1에서 volatile 키워드를 제거한 후 실행한 두가지 결과를 보여준다. volatile 키워드를 제거할 경우, 계속 0 값만 출력되고, pthread_join( ) 에서 block이 되므로 영원히 program이 종료되지 않게 된다.

그림 5.1 lab1_counter 실행 모습

5.2 signal & thread 사용 예제
아래 코드는 thread와 signal을 연관지어 사용한 예제이다.

우선 SIGINT signal을 pthread_sigmask( ) 함수를 사용하여 block으로  설정해 둔 상태에서, 첫번째 thread(sigfun 함수)를 생성하고, 연이어 9개의 thread(모두 동일한 fun 함수 사용하여 만듦)를 생성한다. 첫번째 thread handler인 sigfun( ) 함수에서는 sigfillset( ) 함수를 호출하여 모든 signal mask 값을 blocking 조건으로 만든 후, sigwait( ) 함수를 사용하여 signal이 들어오기를 기다린다. 이는 마치 main thread에서 signal handler를 직접 등록하여 사용하는 경우와 동일한 상황에 해당한다(즉, signal handler를 사용하지 않고도 thread 함수 내에서 비슷한 효과를 누릴 수 있음). 나머지 9개 thread의 handler 함수인 fun( )에서는 pause( ) 함수를 호출하여 (signal이 들어올 때까지) 대기 상태에 있게 된다. 하지만, 이 경우는 main thread에서 SIGINT를 blocking으로 해 두었기 때문에 SIGINT를 전달 받을 수가 없으므로, 무작정 대기 상태에 있게 된다.

코드 5.3 lab2_signal.c

<How to build>
cd s_18
../genmake
make

<How to run>
# cd /sbin/mybin
# ./lab2_signal
  => Ctrl-C 키를 누를 경우, sigfun thread 함수 내의 sigwait( ) 이후의 코드가 실행됨을 알 수 있다. 이때 fun( ) 함수 내부에서는 (SIGINT signal이 block되어 있으므로) 어떠한 변화도 일어나지 않는다.

그림 5.2 lab2_signal 실행 모습

5.3 signal & thread 사용 예제
아래 코드는 thread와 signal을 연관지어 사용한  또 다른 예제이다.

main thread는 SIGINT signal을 위한 signal handler 함수 sighand( )를 등록한 후, 10개의 thread를 생성(thread 함수는 tfunc( ))한다. main thread에서 생성한 sighan( ) handler는 10개의 thread에서도 동일하게 사용된다. 또한 10개의 thread를 위한 thread handler 내에서는 pause( ) 함수를 호출하여 signal이 전달되기를 기다린다.
이후 main thread는 pthread_sigmask( ) 함수를 사용하여 SIGINT signal을 blocking으로 설정하지만, 이는 이미 생성된 10개의 thread에게는 적용되지 않는다.
마지막으로 main thread는 10번의 SIGINT signal을 날리게 되고, 이를 모두 수신한 10개의 thread는 하나씩 pthread_join( ) 함수를 통해 join하게 된다.

코드 5.4 lab3_sighandler.c

<How to build>
cd s_18
../genmake
make

<How to run>
# cd /sbin/mybin
./lab3_sighandler

그림 5.3 lab3_sighandler 실행 모습

5.4 mutex 사용 예제
아래 코드는 thread에서 mutex를 어떻게 사용하는지를 보여주는 예제이다.

아래 코드는 위의 5.1 예제와 동일한 역할을 수행하는 코드로, 앞서 언급한 volatile 키워드 대신  static 키워드를 사용하고, mutex를 사용하여 counter 변수를 보호하는 내용이 추가되었다.
pthread mutex 사용법은 매우 간단한데, 사용 전에 mutex 변수를 초기화(pthread_mutex_t 변수 선언 및 pthread_mutex_init( ) 함수 호출) 하고, critical section 전후에서 아래와 같은 mutex lock/unlock 함수를 각각 호출해 주면 된다.
pthread_mutex_lock( ) 및 pthread_mutex_unlock( )

참고로, 아래 코드 중 mutex 부분을 semaphore로 교체할 수도 있는데, 이를 요약/정리해 보면 다음과 같다.

-----------------------------------------------------------------------------------------------
sem_t c_sem;   /* semaphore 변수 선언 */
sem_init (&c_sem, 0, 1);  /* semaphore 초기화 - main thread에서 수행 */
sem_wait (&c_sem);  /* critical section  직전 호출 */
sem_post (&c_sem);  /* critical section  직후 호출 */
-----------------------------------------------------------------------------------------------

코드 5.5 lab1_mutex.c

<How to build>
cd s_19
../genmake
make

<How to run>
# cd /sbin/mybin
./lab1_mutex
  => static keyword를 사용한 결과, program이 원하는 형태로 동작함을 알 수 있다(그림 5.1의 결과와 비교해 보기 바람).

그림 5.4 lab1_mutex 실행 모습

5.5 condition variable 사용 예제
아래 코드는 condition variable을 사용하여 thread 간에 동기화를 맞추는 방법을 보여주는 예제이다.

먼저 main thread는 3개의 thread를 만들고, 사용자로 부터 숫자 값을 입력 받도록 한다. 사용자 입력 값이 숫자인 경우에는 이 값을 counter 변수에 더하고, pthread_cond_broadcast( ) 함수를 사용하여 이 사실을 나머지 thread에게 알린다(마치 signal 처럼). 만일 사용자 입력이 EOF(Ctrl-D 입력)일 경우에는 역시 이 사실을 나머지 thread에게 알려 종료를 유도하도록 한다(pthread_join 유도).

한편, 3개의 thread는 무한 loop을 돌면서 counter 값을 1씩 줄이고, 이를 화면에 출력한다. counter 값을 줄일 때는 thread 간에 충돌 문제가 발생할 수 있으니, mutex를 사용하여 순서(동기화)를 조절하도록 한다. counter 값이 0보다 작을 경우에는 pthread_cond_wait( ) 함수를 호출하여 counter가 0보다 커질 때까지 대기 상태에 들어가게 된다. 마지막으로 main thread로 부터 EOF 입력이 있었다는 신호를 받을 경우, thread 자신을 종료(pthread_exit)하도록 한다.


코드 5.6 lab3_cond.c

<How to build>
cd s_19
../genmake
make

<How to run>
# cd /sbin/mybin
./lab3_cond
  => 먼저 숫자 10을 입력하면 3개의 thread에 의해 counter가 1씩 줄어들게 되고, 5를 입력할 경우에도 counter가 1씩 줄어드는 것을 알 수 있다. 마지막으로 Ctrl-D를 누를 경우, program이 종료되는 것을 보여준다.

그림 5.5 lab3_ cond 실행 모습

5.6 producer/consumer 예제
아래 코드는 condition variable을 사용하여 thread 간에 동기화를 맞추는 전형적인 예인 producer/consumer 예제이다. 편의상 source code에 대한 설명은 독자 여러분의 몫으로 넘기고자 한다.

코드 5.4 lab4_prodcons.c

<How to build>
cd s_19
../genmake
make

<How to run>
# cd /sbin/mybin
./lab4_prodcons

그림 5.6 lab4_ prodcons 실행 모습


6. Socket 예제 소개
이번 절에서는 socket을 사용하여 client/server 간에 상호 통신(network IPC)하는 예제를 소개하고자 한다. Socket은 이미 널리 알려져 있는 만큼, 여기서는 domain socket, tcp, udp 등 아주 기본적인 내용(예제 코드)은 소개 대상에서 제외하기로 하겠다. 단, 이 부분에 관심있는 독자는 아래 code를 참고해 주기 바란다.

그림 6.1 stream socket을 사용한 client/server system call 개요(참고문헌 [1]에서 발췌)

그림 6.2 datagram socket을 사용한 client/server system call 개요(참고문헌 [1]에서 발췌)

6.1 concurrent server 예제 - client
6.1 ~ 6.5절에서는 복수개의 tcp client와 통신하는 concurrent server를 위한 다양한 처리 기법(select, poll, epoll, pthread)에 관하여 소개하는 시간을 갖고자 한다. 

이를 위해 먼저 concurrent server와 통신하는 client program를 살펴 보도록 하자.
---------------------------------------------------------------------------------------------------------------------------------------------------------------
<대략적인 코드 흐름>
a) open_sockets( ) 함수를 호출하여 3개의 socket을 열고 각각을 연결(connect)한다.
  => 서버 hostname(or ip address)는 argument로 입력 받으며,
  => (아래 코드에는 안 보이나)서버와는 tcp 7177 port로 통신한다.
b) write_random_sockets( ) 함수를 호출하여 서버로 특정 message를 보내고, 이에 대한 응답을 받는다.
  => send( ), recv( ) 함수 사용함.
c) 마지막으로, close_sockets( ) 함수를 호출하여 앞서 open한 3개의 socket을 모두 닫는다.
d) exit( ) 함수를 호출하여 program을 종료한다.
---------------------------------------------------------------------------------------------------------------------------------------------------------------

코드 6.1 lab2_client.c

<How to build>
cd s_28
../genmake
make

<How to run>
# cd /sbin/mybin
./lab2_client localhost
  => 아래 6.3 ~ 6.6절의 lab2_server_X가 먼저 실행되어 있는 상태에서 이 명령을 수행해야 함.
  => 3번의 tcp connection을 시도하여 간단히 data를 보내고 응답을 받게 됨.

그림 6.3 lab2_client 실행 모습

6.2 concurrent server 예제 - socket server w/ select
복수개의 file descriptor를 모니터링 하다가 그 중에서 어느 하나가 I/O(read/write)가 가능하다고 판단될 경우 이를 선택하는 행위를 I/O multiplexing이라고 한다. 대표적인 I/O multiplexing 기법으로는 select나 poll이 있으며, linux의 경우는 I/O multiplexing을 개선한 epoll이라는 기능도 특별히 갖고 있다. 한편 I/O multiplexing을 활용하는 대표적인 예로는 FIFO, socket server 등이 있다.

6.1절의 socket client와 통신하는 서버(select 문 사용)의 대략적인 동작 흐름을 정리해 보면 다음과 같다.
---------------------------------------------------------------------------------------------------------------------------------------------------------------
a) cd = malloc (ncons * sizeof (int)) 를 사용하여, 복수 개(= ncons)의 client socket descriptor를 담을 buffer를 준비(할당)한다.
b) get_socket( ) 함수를 호출한다.
  => socket( ) 함수를 사용하여 socket을 하나 open하고,
  => bind( ) 함수를 사용하여 AF_INET, 7177 tcp port 등의 정보를 설정(socket binding)한다.
  => listen( ) 함수를 호출하여 client로 부터의 connect 요청이 오기를 기다린다.
  => socket descriptor(= sd)를 return한다.
c) FD_SET macro를 이용하여 sd 값을 select 대상으로 지정한다.
d) for loop(무한 loop)을 돌며 아래 내용을 반복한다.
   d-1) sd와 fdmax 값 중 큰 값을 fdmax로 지정 후, select( ) 함수를 호출하여 client로 부터의 요청이 오기를 기다린다(fdmax + 1 이하의 file descriptor 중에 서 반응이 있을 때까지 대기함을 의미)
   d-2) client로 부터의 연결 요청이 있을 경우, 대기 상태를 빠져 나와 accept_one( ) 함수를 호출한다.
      => accept_one( )에서는 accept( ) 함수를 호출하고, return된 결과를 cd buffer에 추가하는 역할을 한다.
   d-3) FD_SET macro를 사용하여 accept( ) 함수가 return해 준  file descriptor를 select의 대상으로 지정한 후, fdmax를 다시 갱신한다.
   d-4) handle_client( ) 함수를 호출한다.
       => data를 수신(recv)한 후, 응답 메시지를 client로 보낸다(send).
   d-5) 통신을 완료한 socket은 close하도록 한다. 또한 FD_CLR macro를 사용하여 해당 file descriptor를 select 대상에서 제거한다. 마지막으로 fdmax 값을 재조정한다.
e) for loop을 만일 빠져나온다면, sd, cd 등 socket descriptor를 위한 buffer를 모두 해제하고, exit( ) 함수를 호출한다.
---------------------------------------------------------------------------------------------------------------------------------------------------------------

참고: 앞서도 잠시 언급했지만, file descriptor 조작을 위한 select 기법에서 제공하는 macro에는 아래와 같은 것들이 있다.
FD_ZERO() , FD_SET() , FD_CLR() , and FD_ISSET()



코드 6.2 lab2_server_select.c

<How to build>
cd s_28
../genmake
make

<How to run>
# cd /sbin/mybin
./lab2_server_select
  => 이 상태에서 lab2_client를 실행해 주어야 아래와 같은 결과가 나온다.

그림 6.4 lab2_server_select 실행 모습

6.3 concurrent server 예제 - socket server w/ poll
아래 코드는 poll 방식을 사용하여 socket server를 구성한 예를 보여준다. 자세한 설명은 생략하기로 한다.

코드 6.3 lab2_server_poll.c

<How to build>
cd s_28
../genmake
make

<How to run>
# cd /sbin/mybin
./lab2_server_poll
  => 이 상태에서 lab2_client를 실행해 주어야 아래와 같은 결과가 나온다.

그림 6.5 lab2_server_poll 실행 모습

6.4 concurrent server 예제 - socket server w/ epoll
아래 코드는 linux에만 있는 epoll 방식을 사용하여 socket server를 구성한 예를 보여준다. 자세한 설명은 생략하기로 한다.

참고: epoll은 아래의 3가지 API로 구성되어 있다.
a) epoll_create( )
  => epoll 인스턴스를 생성하고 관련 file descriptor를 return한다.
  예) epfd = epoll_create (nfd)
b) epoll_ctl( )
  => epoll 인스턴스와 관련된 list를 조작(add, remove, modify)하는 역할을 한다.
  예) epoll_ctl (epfd, EPOLL_CTL_ADD, fd, &ep_event[i])
c) epoll_wait( )
  => epoll 인스턴스와 관련된 ready 항목을 return한다.
  예) nev = epoll_wait (epfd, ep_event, maxevents, timeout)
--------------------------------------------------------------------------------------------------------


<How to build>
cd s_28
../genmake
make

<How to run>
# cd /sbin/mybin
./lab2_server_epoll
  => 이 상태에서 lab2_client를 실행해 주어야 아래와 같은 결과가 나온다.

그림 6.6 lab2_server_epoll 실행 모습

6.5 concurrent server 예제 - socket server w/ pthread
아래 코드는 pthread 사용하여 socket server를 구성한 예를 보여준다. 자세한 설명은 생략하기로 한다.


코드 6.5 lab2_server_pthread.c

<How to build>
cd s_28
../genmake
make

<How to run>
# cd /sbin/mybin
./lab2_server_pthread
  => 이 상태에서 lab2_client를 실행해 주어야 아래와 같은 결과가 나온다.

그림 6.7 lab2_server_pthread 실행 모습

6.6 netlink socket을 통한 kernel과의 통신 예제
이 절에서는 일반적인 socket(inet, domain socket)이 아니라, netlink socket을 통해 kernel에서 전달한 message를 사용자 영역의 program에서 받는 예제를 소개해 보고자 한다(kernel <-> userspace 간의 통신).

먼저 kernel module(sender routine)에서 수행하는 일련의 절차를 정리해 보면 다음과 같다.
------------------------------------------------------------------------------------------------------------------------------------------------------------
a) netlink_kernel_create(..., NL_EXAMPLE, ...) 함수를 사용하여 kernel 내부에 사용자 정의 protocol(= NL_EXAMPLE) 용 netlink socket(일종의 hook)을 하나 만든다.
b) kernel thread를 하는 띄운다(handler 함수 : my_sender_thread( ) 함수)
c) my_sender_thread( ) kernel thread 내에서는 아래 내용을 반복 수행한다.
    c-1) alloc_skb( ) 함수를 사용하여 socket buffer(= skb)를 하나 할당한다.
    c-2) 사용자 정의 message(NETLINK_MESSAGE)를 socket buffer에 복사한다.
    c-3) NETLINK_CB (skb).dst_group = NL_GROUP 등의 설정을 한다(사용자 영역에서 packet을 구분을 위해 필요한 절차임).
    c-4) 다음으로 netlink_broadcast( ) 함수를 사용하여 socket buffer를 netlink socket으로 쏘아 올린다.
    c-5) 5초간 sleep 후, 다시 c-1) 부터 작업을 다시 반복한다.
------------------------------------------------------------------------------------------------------------------------------------------------------------

코드 6.6 lab2_nl_sender.c

다음으로 application program(receiver routine)에서 수행하는 절차를 요약해 보면 다음과 같다.
------------------------------------------------------------------------------------------------------------------------------------------------------------
a) socket (PF_NETLINK, SOCK_RAW, NL_EXAMPLE) 함수를 사용하여 netlink socket을 하나 open한다.
  => 3가지 argument인 PF_NETLINK, SOCK_RAW, NL_EXAMPLE 각각이 모두 중요한 의미를 갖는다.
b) bind (nls, (struct sockaddr *)&addr, sizeof (addr)) 함수를 호출하여 socket binding 작업을 진행한다.
  => bind 시 addr.nl_family는 AF_NETLINK로 설정하고, addr.nl_groups는 NL_GROUP(= 1)로 설정한다.
  => 이 과정을 통해서 sender에서 보낸 message 수신이 가능해 짐.
c) recv( ) 함수를 사용하여 message를 수신한 후, 이를 화면에 출력한다(무한 반복).
  => 주의: netlink socket을 통해 전달된 data는 아래와 같은 data format을 따른다.

struct nlmsghdr {
    __u32       nlmsg_len;  /* Length of message including header */
    __u16       nlmsg_type; /* Message content */
    __u16       nlmsg_flags;    /* Additional flags */
    __u32       nlmsg_seq;  /* Sequence number */
    __u32       nlmsg_pid;  /* Sending process port ID */
};
------------------------------------------------------------------------------------------------------------------------------------------------------------

코드 6.7 lab2_nl_receive_test.c

<How to build>
cd s_27
../genmake
make

<How to run>
# cd /sbin/mybin
insmod ./lab2_nl_sender.ko
  => netlink socket을 통해 message를 내보내는 kernel module을 구동시킨다.


그림 6.8 lab2_nl_sender.ko 실행 모습

./lab2_nl_receive_test
  => netlink socket을 통해 message를 수신하는 application program을 실행시킨다.
  => 주의: 당연히 위의 kernel module이 먼저 설치되어 있어야 아래와 같은 message가 출력된다.

그림 6.9 lab2_nl_receive_test 실행 모습


7. Shared memory & Message Queue 예제 소개
User space의 process or thread 간에 상호 data를 안전하게 교환하는 방법은 system programming 작성시 매우 중요하게 고려되는 부분이다. 이러한 기법을 통상 IPC(Inter-process communication)라고 칭하는데, 이번 절에서는 IPC 기법의 대표적인 예라 할 수 있는 shared memory 기법과 message queue 기법에 관하여 소개하고자 한다.

<대표적인 Linux IPC 기법 정리>
  - Signal: signal을 보통 IPC라고 칭하지는 않으나, IPC 기능 처럼 활용 가능할 수 있기에 여기에 언급함.
  - Socket: network IPC라고도 함.
  - Shared memory: (중간에 kernel이 개입하지 않으므로)가장 빠른 방법. 단 코딩 시 별도의 동기화 방법(semaphore)이 추가되어야 함.
  - Message Queue: 사용하기 간편한 방법(동기화 문제는 kernel이 자동으로 해결해 줌)

7.1 shared memory 사용 예제(System V)
이번 절에서는 가장 빠른 IPC 방법으로 볼 수 있는 shared memory를 사용하여 process간에 data를 공유하는 기법에 관해 소개하고자 한다.

그림 7.1 shared memory 개요(참고문헌 [1]에서 발췌)

<System V shared memory를 사용하는 일반적인 절차>
a) shmget( ) 함수를 사용하여 shared memory  영역을 할당 받거나, 이미 만들어진 영역이 있다면, 해당 id 값는 역할을 한다. 아래의 예 처럼, KEY(integer 값임)와 SIZE 등이 argument로 넘어간다.
   예) shmid = shmget (KEY, SIZE, IPC_CREAT | 0666)
   => shared memory를 생성하는 process와 이를 사용하는 process는 KEY 값을 가지고, shared memory를 구분한다.
b) shmat( ) 함수를 사용하여 해당 shared memory 영역에 attach 시킨다.
   예) shm_area = shmat (shmid, (void *)0, 0)
   => return된 주소(shm_area)는 process에서 직접 사용 가능한 virtual address임.
   => 이 시점 부터 process는 shm_area를 program 내의 일반 memory 처럼 사용 가능하게 됨.
        예) memcpy (shm_area, &iflag, 4)
c) shared memory에 대한 사용을 끝내기 위해서는 shmdt( ) 함수를 사용하여 detach 시킨다.
   예) shmdt (shm_area)
d) shmget( ) 으로 할당한 shared memory를 제거하기 위해서는 shmctl( ) 함수를 사용한다.
   예) shmctl (shmid, IPC_RMID, 0)
------------------------------------------------------------------------------------------------------------------------------------

참고: POSIX shared memory 사용 방법도 위의 방법과 비교해 크게 다르지 않다. 함수 API만 가지고 간략해 요약해 보면 다음과 같다.
<open>
shm_fd = shm_open (NAME, O_RDWR | O_CREAT | O_EXCL, 0666)

<mmap으로 virtual 주소 전환>
shm_area = mmap (NULL, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0)

<memcpy로 필요한 내용 복사>
memcpy (shm_area, &iflag, 4)

<munmap으로 virtual 주소 해제>
munmap (shm_area, SIZE)

<shared memory 삭제시 호출>
shm_unlink (NAME)
----------------------------------------------------------------------------------------------------------------

코드 7.1 lab1_v_shm.c

<How to build>
cd s_30
../genmake
make

<How to run>
# cd /sbin/mybin

<Terminal 창 1>
# ./lab1_v_shm create
  => System V shared memory를 생성한다.
./lab1_v_shm send
  => shared memory에 값을 쓴다.


그림 7.2 lab1_v_shm(sender) 실행 모습
<Terminal 창 2>
./lab1_v_shm receive
  => shared memory에서 값을 가져온다.

그림 7.3 lab1_v_shm(receiver) 실행 모습

7.2 message queue 사용 예제(POSIX)
이번 절에서는 POSIX message queue를 이용하여 process 간에 message를 교환하는 방법에 관하여 설명하고자 한다. 전통적으로는 System V message queue를 많이 사용해 왔으나, 최근 들어서는 System V message queue에 여러가지 문제가 있는 관계로 POSIX message queue를 사용하는 추세이다.

POSIX message queue를 사용하기 위해서는 kernel config를 아래와 같이 조정해 주어야 한다.
bitbake -c devshell virtual/kernel
  => 별도의 terminal을 띄우고 kernel source directory로 이동한다(kernel build 환경이 자동으로 설정됨)
  -------------------------------------------------------------------------------
     $ make menuconfig
              General setup -->
                     [*] POSIX Message Queues
     $ make zImage
  -------------------------------------------------------------------------------

그림 7.4 message queue의 개요(client/server IPC로 사용 예)(참고문헌 [1]에서 발췌)

<POSIX message queue를 사용하기 위한 일반적인 절차>
a) mq_open( ) 함수를 사용하여 새로운 message queue를 만들거나, 기존에 만들어진 queue가 있다면 file descriptor를 return해 준다.
  예) msg_fd = mq_open (NAME, O_RDWR | O_CREAT | O_EXCL, 0666, &attr)
  => POSIX message queue와 System V message queue가 결정적으로 다른 부분은 file descriptor를 사용하느냐 그렇지 않느냐 여부이다.
  => System V message queue의 경우 file descriptor 방식이 아니다 보니, select or poll 등과 연계하여 사용할 수 없는 단점이 있다. 또한 message queue 동작 확인을 위해 별도의 전용 program(ipcs, ipcrm)이 필요한 것도 불편한 점이다.
b) mq_send( ) 함수를 이용하여 queue에 message를 write한다.
  예) mq_send (msg_fd, some_text, size, 0)
c) mq_receive( ) 함수를 이용하여 queue로 부터 message를 읽어 들인다.
  예) mq_receive (msg_fd, some_text, size, NULL)
d) mq_close( ) 함수를 이용하여 앞서 open한 message queue를 닫는다.
  예) mq_close (msg_fd)
e) mq_unlink( ) 함수를 호출하여 message queue name을 삭제하고, 이를 표시(process가 message queue를 모두 close하는 시점에 제거하도록)해 둔다.
 예) mq_unlink (NAME)
------------------------------------------------------------------------------------------------------------------------------------

코드 7.2 lab1_p_mq.c

<How to build>
cd s_32
../genmake
make

<How to run>

<Terminal 창 1>
./lab1_p_mq create
  => POSIX message queue를 생성한다.
./lab1_p_mq send
  => message queue를 통해 message를 전달한다.


그림 7.5 lab1_p_mq(sender) 실행 모습


<Terminal 창 2>
./lab1_p_mq receive
  => message queue를 통해 message를 수신한다.


그림 7.6 lab1_p_mq(receiver) 실행 모습


8. 맺음말
지금까지 다양한 Linux System Programming 기법에 관하여 살펴 보았다. 앞서도 언급했다시피 지면 관계상 모든 기법을 소개하는데는 한계가 있다. 따라서, 설명이 부족한 부분에 대해서는 아래 References 절에 열거되어 있는 다양한 참고 서적을 참조해 줄 것을 당부드린다.

<이번 posting의 의의/목적>
1) 간단한 예제 program을 실제 target board에서 돌려 보고, 각각의 code의 전체적인 동작 과정을 이해해 봄으로써, linux system programming에 대한 전반적인 이해를 도와줌.

<아쉬운 점>
1) process 관련 다양한 기법에 대한 설명이 부족함.
2) (기초적인 부분이라고 생각해)File I/O 관련 내용을 뺌.
3) time & timer 관련 설명 누락
4) shared library 등 library 생성 관련 누락
5) API에 사용법 대한 명확한 언급(description)이 없음 - 이 부분은 아래 참고 서적을 보는 것이 타당하다고 판단.
6) (제목이 Yocto 환경에서의...임에도 불구하고)yocto toolchain만 사용했을 뿐, recipe 작성 등에 대한 설명이 누락되어 있음.


References
1. The Linux Programming Interface - A Linux and UNIX System Programming Handbook, Michael Kerrisk.
  => 가장 방대한 분량을 자랑하는 책임. 가장 훌륭한 책으로 보임.
2. Advanced programming in the UNIX environment 3rd edition, W.Richard Stevens, Stephen A. Rago.
  => 훌륭한 책임(UNIX programming에 관한한 bible에 해당하는 책임).
3. UNIX Systems Programming(2nd edition), Communication, Concurrency, And Threads, Kay Robbins and Steven Robbins.
  => 오래된 책임에도 의외로 괜찮은 책임. 분량도 적당하고 특별한 예제도 담겨 있음.
4. Linux system programming, 2nd edition, Robert Love.
  => 위의 책들에 비해 분량이 상대적으로 적음(간결하게 필요한 내용만 정리되어 있음).
5. Linux Program Development, Jerry Cooperstein.
  => 앞서도 언급했다시피, training용으로 만든 책이라서 code가 간결하고, 이해하기 쉬움. 

6. UNIX Network Programming Vol1/2, 3rd edition, W.Richard Stevens
  => Socket, IPC 기법 관련하여 잘 정리된 책(bible)

Slowboot

댓글 1개:

  1. 정리 정말 잘해놓으셧네요, 레퍼런스 책까지 정말 감사합니다.^^

    답글삭제