◈ 프로세스 생성
프로세스 생성과정을 알면 다음과 같은 내용을 알 수 있습니다.
• 프로세스가 부모 프로세스로부터 어떻게 복제되는지
• 생성된 프로세스가 어떻게 실행되는지
• 프로세스 자료구조는 어떻게 처리하는지
먼저 알아두어야 할 것은 유저 프로세스와 커널 프로세스의 생성과정이 다릅니다.
• 유저 프로세스: 유저 공간에서 프로세스를 생성하는 라이브러이(GNU C: glibc)의 도움을 받아 커널에게 프로세스 생성요청을 한다
• 커널 프로세스: 커널 내부의 kthread_create() 함수를 호출해서 커널 프로세스를 생성한다.
다만, 둘 다 프로세스를 생성할 때 kernel_clone() 함수를 호출한다는 공통점이 있습니다.
시스템 콜 흐름 요약
우리가 유저 영역(User Space)에서 호출하는 함수들에 따라 커널 내부에서는 다음과 같은 흐름을 탑니다:
| 유저 호출 함수 | 커널 시스템 콜 서비스 루틴 | 실제 호출되는 핵심 함수 |
| fork() | sys_fork() | kernel_clone() |
| vfork() | sys_vfork() | kernel_clone() |
| clone() | sys_clone() | kernel_clone() |
결국 fork, vfork, clone 모두 마지막에는 kernel/fork.c에 있는 copy_process()를 호출하기 위해 kernel_clone()을 거치게 됩니다.
※ _do_fork() 함수 → kernel_clone() 함수
현재 리눅스 커널에서 과거의 do_fork() 역할을 대신하는 함수는 kernel_clone()입니다.
과거 리눅스 커널 버전에서는 프로세스 생성을 위한 핵심 로직을 do_fork()가 담당했지만,
커널이 업데이트되면서 코드 구조가 정리며 v5.10 버전 부근부터 do_fork()는 사라지고 kernel_clone()으로 대체되었습니다.
기존의 do_fork()는 인자가 너무 많아 복잡하고 확장이 어려웠습니다.
이를 개선하기 위해 인자들을 하나의 구조체(struct kernel_clone_args)에 담아 전달하는 방식으로 리팩토링되었고,
이에 맞춰 이름도 현대적인 clone 체계에 통합된 것입니다.
kernel_clone() 함수는 kernel/fork.c 파일에 위치하고 있습니다.
• 파일 경로: linux/kernel/fork.c
• 함수 정의: 이 파일 안에서 long kernel_clone(struct kernel_clone_args *args)라는 형태로 정의되어 있습니다.
1. kernel_clone() 함수 소개
리눅스에서 구동중인 모든 프로세스는 kernel_clone() 함수가 실행할 때 생성됩니다.
리눅스에서는 프로세스 생성을 전담하는 프로세스가 있는데, init과 kthreadd 프로세스입니다.
유저 레벨 프로세스는 init 프로세스, 커널 레벨 프로세스(커널 스레드)는 kthreadd 프로세스가 생성합니다.
그런데, 프로세스를 생성할 때 프로세스에게 필요한 리소스를 각각 할당받으면 시간이 오래걸려서, 이미 생성된 부모 프로세스를 복제하여 리소스를 물려받는 것입니다.
리눅스 커널에서 활용하는 '코드의 성능이나 속도를 개선하기 위한 기법'중 대표적인 방법중 하나가, '이미 만들어진 리소스를 가져다 쓰는 것' 입니다.
리눅스 커널에서는 속도 개선을 위해 반복 실행되는 코드를 줄이려는 노력을 기울인 흔적을 볼 수 있습니다.
커널 메모리 할당자인 슬럽 메모리 할당자(Slub MEmory Allocator), 드라이버에서 메모리 할당을 요청할 때 자주 쓰는 구조체를 정의해서 해당 구조체에 대한 메모리를 미리 확보하여, 메모리 할당을 요청하면 이미 확보한 메모리 주소를 알려줍니다.
새로운 프로세스의 자료구조를 초기화 하는 것 보다 이미 생성된 프로세스의 데이터를 복제하는 것이 더 효율적이기 때문이죠.
1.1 kernel_clone() 함수 선언부와 반환값 확인
1.1.1 먼저, kernel_clone() 함수의 반환값을 확인해보겠습니다.

(1) pid_t 자료형의 정체
pid_t는 Process ID Type의 약자로, 프로세스 식별자를 저장하기 위한 전용 자료형입니다.
- 본체는 정수형: 내부적으로는 int를 typedef로 정의한 것입니다. (typedef __kernel_pid_t pid_t;)
- 사용 이유: 하드웨어나 OS 아키텍처에 따라 PID의 크기가 달라질 수 있는데, 코드에 int라고 직접 쓰는 대신 pid_t를 사용하면 이식성(Portability)이 좋아집니다. 즉, 커널 설정에 따라 16비트나 32비트 정수로 유연하게 대응할 수 있습니다.
(2) 왜 kernel_clone()은 pid_t를 반환할까?
함수의 마지막 줄인 return nr;이 수행하는 역할 때문입니다. 이 함수는 새로운 프로세스를 만드는 '복제 공장'의 결과물을 알려줘야 합니다.
부모와 자식의 갈림길
clone이나 fork는 호출되는 순간 프로세스가 두 개로 나뉩니다. 이때 누가 부모고 누가 자식인지 구분하는 기준이 바로 반환값인 pid_t입니다.
- 부모 프로세스에게: 새로 태어난 자식의 PID를 반환합니다. (양수 값) 부모는 이 번호를 알아야 나중에 자식을 기다리거나(wait) 관리할 수 있습니다.
- 자식 프로세스에게: 0을 반환합니다. 자식은 "내 PID가 0인가?"라고 오해하는 게 아니라, "반환값이 0이니 내가 방금 태어난 자식이구나!"라고 인지하게 됩니다.
- 실패 시: 음수(-1 등)를 반환하여 프로세스 생성에 실패했음을 알립니다.
(3) 내부 흐름 요약
앞서 보신 코드의 흐름을 다시 연결해 보면 이렇습니다.
- 사용자가 clone() 호출
- 커널이 struct kernel_clone_args에 인자들을 담음
- kernel_clone() 실행: 실제로 메모리를 복사하고 새로운 프로세스 제어 블록(PCB)을 만듦
- 결과 보고: 성공적으로 자식이 만들어지면, kernel_clone()은 그 자식의 주민등록번호(PID)를 pid_t 타입으로 부모에게 넘겨주며 마무리됩니다.
1.1.2 kernel_clone() 함수에 전달하는 인자 (struct kernel_clone_args) 상세 설명
이 구조체는 clone, fork, vfork 등 모든 프로세스 생성 요청을 하나로 처리하기 위한 데이터 바구니 역할을 합니다.

주요 필드의 의미는 다음과 같습니다:
(1) .flags (프로세스 공유 설정)
가장 중요한 필드입니다. 새로운 프로세스가 부모와 무엇을 공유할지 결정합니다.
- 의미: 파일 시스템 정보, 열려 있는 파일(FD), 가상 메모리, 신호 핸들러 등을 공유할지 여부를 비트 플래그로 나타냅니다.
- 코드의 특이점: (lower_32_bits(clone_flags) & ~CSIGNAL) 부분은 입력받은 플래그 중 하위 32비트만 취하되, 뒤에서 설명할 exit_signal 부분은 제외하고 순수한 실행 옵션만 남기는 과정입니다.
(2) .exit_signal (종료 알림 신호)
자식 프로세스가 죽었을 때 부모에게 어떤 신호를 보낼지 정의합니다.
- 의미: 보통 일반적인 fork()의 경우 SIGCHLD를 보냅니다. 하지만 clone()을 통해 생성된 스레드나 특수한 프로세스는 신호를 보내지 않거나 다른 신호를 보내도록 설정할 수 있습니다.
- 코드의 특이점: clone_flags의 하위 바이트에 이 신호 값이 저장되어 있어 & CSIGNAL 연산으로 추출합니다.
(3) .stack (사용자 스택 주소)
새로운 프로세스가 사용할 스택 메모리의 시작 지점입니다.
- 의미: fork()는 부모의 스택을 그대로 복사하지만, clone()(특히 스레드 생성 시)은 부모가 미리 할당해둔 별도의 메모리 영역을 자식의 스택으로 지정해 줍니다. newsp가 바로 그 주소입니다.
(4) .pidfd, .parent_tid, .child_tid (식별자 관련 포인터)
이 세 필드는 프로세스의 ID(PID)를 사용자 공간의 변수에 써주기 위한 주소값들입니다.
- .pidfd: 최신 커널 기법으로, PID 대신 파일 디스크립터(FD)를 통해 프로세스를 관리하고 싶을 때 사용합니다. (코드에서는 parent_tidptr를 재활용하기도 함)
- .parent_tid: 부모 프로세스의 메모리 영역에 자식의 PID를 써줄 주소입니다. (CLONE_PARENT_SETTID 플래그 설정 시 동작)
- .child_tid: 자식 프로세스의 메모리 영역에 자신의 PID를 써줄 주소입니다. (CLONE_CHILD_SETTID 플래그 설정 시 동작)
(5) .tls (Thread Local Storage)
- 의미: 스레드마다 고유하게 가지는 저장소(TLS)의 정보입니다.
- 역할: 새로운 스레드가 생성될 때, 해당 스레드만의 고유한 데이터 영역(예: errno 변수나 스레드 로컬 변수들)을 가리키는 포인터를 설정합니다. 아키텍처에 따라 특정 레지스터(예: x86의 GS/FS 세그먼트)에 이 값이 로드됩니다.
요약 표
| 필드명 | 설명 |
| .flags | 생성될 프로세스의 속성(네임스페이스 공유 여부, 파일 시스템 정보 공유 등)을 결정합니다. |
| .pidfd | 새로운 프로세스를 가리키는 파일 디스크립터를 반환받기 위한 포인터입니다. |
| .child_tid | 자식 프로세스의 메모리에 자식의 TID를 기록할 주소입니다. |
| .parent_tid | 부모 프로세스의 메모리에 자식의 TID를 기록할 주소입니다. |
| .exit_signal | 자식이 종료될 때 부모에게 보낼 신호(보통 SIGCHLD)입니다. |
| .stack | 자식 프로세스가 사용할 사용자 공간 스택의 시작 주소입니다. |
| .tls | Thread Local Storage(TLS)를 위한 데이터 구조 주소입니다. |
이 구조체에 담긴 데이터들은 결국 kernel_clone() 내부에서 호출되는 copy_process() 함수로 전달되어, 실제 Task Descriptor(task_struct)를 생성하는 밑바탕이 됩니다.
과거에는 fork, vfork, clone이 각각 개별적인 내부 구현을 가지고 있었습니다. 하지만 이는 코드 중복과 버그를 유발했기에, 최근 커널은 다음과 같은 구조로 통합되었습니다.
- 사용자 공간: clone() 호출
- 시스템 콜 계층: 아키텍처별 인자 순서 정리
- 구조체 래핑: kernel_clone_args에 담기
- 핵심 로직: kernel_clone() -> copy_process() 실행
유저모드와 커널모드를 나누는 기준?
메모리 접근 권한과 실행 권힌 기준으로 두 모드로 분류합니다.
1.2 유저 레벨 프로세스를 생성할 때, kernel_clone() 함수의 처리 흐름
유저 공간에서 kernel_clone() 함수를 호출하면, 리눅스에서 제공하는 라이브러리의 도움을 받아 커널에게 프로세스 생성을 요청합니다.
라이브러리 코드가 실행되며 시스템 콜을 발생시키며, 리눅스 커널 계층에서는 kernel_clone() 함수에 대응하는 시스템 콜 핸들러인 sys_clone() 함수를 호출합니다.
sys_clone() 함수 분석
유저 공간ㅇ네서 kernel_clone() 함수를 호출하면 커널 공간에서 실행되는 sys_clone() 함수의 코드를 봅시다.

※ 커널에서 에공하는 SYSCALL_DEFINE5 매크로와 함께 함수이름을 지정하면 커널 소스를 빌드하는 과정에서 지정한 함수 이름 앞에 'sys_' 접두사를 붙여서 심벌을 생성합니다. 즉, 다음과 같이 시스템 콜 함수를 정의하면 sys_clone() 시스템 콜 함수가 생성됩니다.
세부 동작은 '시스템 콜'에서 설명하겠습니다.
와 같이, sys_clone() 함수는 kernel_clone() 함수를 그대로 호출합니다.
유저 레벨에서 생성한 프로세스와 스레드를, 커널은 동등하게 처리합니다.
유저 레벨 프로세스 생성시의 처리 흐름
리눅스 커널 6.1 버전에서 유저 레벨 프로세스가 생성되는 흐름은 크게
[유저 공간의 요청] → [시스템 콜 진입] → [커널 내부 복제] → [자식 프로세스 실행]
의 4단계로 요약할 수 있습니다.
앞서 살펴본 kernel_clone_args 구조체가 어떻게 실제 프로세스로 변하는지 그 경로를 따라가 보겠습니다.
(1) 유저 공간 (User Space)
사용자가 fork(), vfork(), 또는 pthread_create()를 호출합니다. 현대적인 C 라이브러리(glibc)는 내부적으로 모두 clone()또는 clone3() 시스템 콜을 호출하여 커널에 요청을 보냅니다.
(2) 시스템 콜 계층 (System Call Entry)
CPU의 모드가 유저 모드에서 커널 모드로 전환되며, SYSCALL_DEFINE 매크로로 정의된 함수가 실행됩니다.
• 인자 정리: 아키텍처별로 다른 레지스터 값을 수집하여 struct kernel_clone_args를 채웁니다.
• 함수 호출: 준비된 구조체 포인터를 가지고 kernel_clone() 함수를 호출합니다.
(3) 커널 내부의 핵심 로직 (The Core: copy_process)
kernel_clone()은 실제 복제 작업을 수행하는 copy_process()를 호출합니다. 여기가 리눅스 프로세스 생성의 심장부입니다.
• dup_task_struct(): 부모 프로세스의 task_struct(PCB)와 커널 스택을 그대로 복사합니다. 이제 자식도 부모와 똑같은 '상태'를 가집니다.
• copy_creds(): 권한(UID, GID 등)을 복사합니다.
• 자원 복사 (The copy_... functions): args->flags에 따라 자원을 공유하거나 새로 만듭니다.
• copy_files(): 열려 있는 파일 목록 복사/공유.
• copy_mm(): 메모리 주소 공간(Virtual Memory) 복사. (보통 Copy-on-Write 기법 사용)
• copy_namespaces(): 네임스페이스(Container 기술의 핵심) 처리.
• alloc_pid(): 새로운 프로세스에게 고유한 PID 번호를 할당합니다.
(4) 스케줄링 및 실행 (Wake Up)
복제가 완료되었다고 해서 바로 실행되는 것은 아닙니다.
• wake_up_new_task(): 생성된 자식 프로세스를 실행 대기 큐(Runqueue)에 넣습니다.
• 스케줄러의 선택: CPU 스케줄러가 자식 프로세스를 선택하면, 자식은 ret_from_fork라는 경로를 통해 유저 공간으로 '리턴'하게 됩니다.
• 결과 반환: 이때 부모에게는 자식의 PID가, 자식에게는 0이 반환되며 두 프로세스는 각자의 길을 가게 됩니다.
흐름 요약
| 단계 | 주요 함수 | 역할 |
| 요청 | clone() | 유저의 프로세스 생성 요청 |
| 준비 | kernel_clone() | 인자를 구조체(args)로 패키징 |
| 복제 | copy_process() | 메모리, 파일, 스택 등 실질적인 자원 복사 |
| 할당 | alloc_pid() | 새로운 주민등록번호(PID) 부여 |
| 시작 | wake_up_new_task() | 스케줄러에 등록하여 실행 대기 |
한 걸음 더: Copy-on-Write (CoW)
copy_mm() 단계에서 리눅스는 부모의 거대한 메모리를 통째로 복사하지 않습니다. 대신 "일단 같이 쓰고, 누군가 내용을 수정하려고 할 때만 그 부분만 복사하자"는 전략을 씁니다. 덕분에 프로세스 생성이 매우 빠르고 효율적입니다.
1.3 커널 프로세스를 생성할 때, kernel_clone() 함수의 처리 흐름
1.3.1 커널 프로세스란?
커널 프로세스란 시스템 콜 없이 커널 함수로 생성되어 커널 공간에서만 실행되는 프로세스를 뜻합니다.
대표적인 예로 커널 스레드가 있으며, 커널 공간에서 시스템 리소스(메모리, 전원) 관리를 수행합니다.

1단계: kthreadd 프로세스에게 커널 프로세스 생성을 요청
- kthread_create() 함수를 호출해 kthreadd 프로세스에게 커널 프로세스 생성을 요청하며, kthreadd 프로세스를 깨웁니다.
2단계 커널 프로세스 생성
- kthreadd 프로세스는 깨어나 자신에게 커널 프로세스 생성을 요청했는지 점검합니다. 프로세스를 생성해달라는 요청이 있으면 프로세스를 생성합니다.
1.3.2 커널 스레드를 생성하는 예제 코드

kthread_create() 함수를 실행하면, 커널 스레드를 생성하는 kthreadd 프로세스에게 커널 스레드 생성 요청을 하며, 이후 kthreadd 스레드는 kernel_clone() 함수를 실행해서 프로세스를 생성합니다.
리눅스 커널(v6.1 기준)에서 새로운 프로세스나 스레드가 생성되는 과정은 매우 체계적인 상속과 복제의 흐름을 따릅니다. 유저 레벨 호출부터 커널 내부 깊숙한 곳까지의 함수 호출 흐름을 단계별로 정리해 드릴게요.
(1) 진입 단계: 시스템 콜 인터페이스
모든 것은 유저 공간의 fork(), vfork(), clone() 호출에서 시작됩니다. 이들은 아키텍처별 매크로를 거쳐 커널의 공통 진입점으로 모입니다.
• SYSCALL_DEFINE5/6(clone, ...): 아키텍처별로 다른 인자 순서를 정리합니다.
• kernel_clone(): 생성 요청을 하나로 추상화하여 struct kernel_clone_args에 담고 실질적인 복제 작업을 지시합니다.
(2) 핵심 단계: copy_process()
이 함수는 리눅스 프로세스 생성의 '공장'과 같습니다. 여기서 부모 프로세스의 자원을 어떻게 복사할지 결정됩니다.
1) dup_task_struct(): 부모의 task_struct를 복사하여 새로운 자식용 객체를 만듭니다. (이 시점에는 부모와 거의 쌍둥이입니다.)
2) copy_creds(): 사용자 ID(UID), 그룹 ID(GID), 권한(Capability) 등을 복사합니다.
3) sched_fork(): 자식 프로세스의 스케줄링 관련 데이터(우선순위 등)를 초기화합니다.
4) 자원 복제 (Flags에 따른 분기):
• copy_files(): 열려 있는 파일 디스크립터 복사.
• copy_fs(): 루트 디렉토리, 현재 작업 디렉토리 정보 복사.
• copy_mm(): 가장 중요한 부분입니다. 부모의 메모리 주소 공간을 복사하며, 효율을 위해 Copy-on-Write(CoW) 기법을 설정합니다.
• copy_thread(): 자식 프로세스가 처음 실행될 때 사용할 CPU 레지스터 값(EIP/RIP, ESP 등)을 설정합니다.
5) alloc_pid(): 새로운 프로세스에게 고유한 PID 번호를 부여합니다.
(3) 실행 단계: 스케줄러 등록 및 복귀
복제가 끝났다고 해서 프로세스가 즉시 실행되는 것은 아닙니다. CPU를 할당받을 자격을 얻어야 합니다.
• wake_up_new_task(): 생성된 자식 프로세스를 스케줄러의 실행 큐(Runqueue)에 넣습니다. 이제 자식은 언제든 CPU를 잡고 실행될 준비가 된 상태(TASK_RUNNING)가 됩니다.
• ret_from_fork (어셈블리): 자식 프로세스가 처음으로 CPU를 할당받아 실행을 시작하는 지점입니다. 여기서 유저 모드로 돌아가며 자식에게는 반환값 0을 던져줍니다.
전체 흐름도 (Call Stack)

요약하자면
리눅스는 "부모를 똑같이 베낀 뒤(copy_process), 바꿀 부분만 살짝 바꾸고(flags), 스케줄러에게 실행을 부탁한다(wake_up)"는 전략을 취합니다.
커널은 보통 시스템 부팅 과정에서 대부분의 커널 스레드를 생성합니다. 생성 후 바로 일을 시작하며, 이후 background 작업으로 주기적으로 실행됩니다.
한편, 커널이 동적으로 커널 스레드가 필요하다고 판단할 때, 커널 스레드를 생성할 때도 있습니다.
• 리눅스 드라이버에서 많은 워크를 워크큐에 큐잉하면 커널은 커널 스레드의 종류인 워커 스레드를 더 생성한다.
• 커널에서 메모리가 부족하면 페이지를 확보하는 일을 하는 kswapd 스레드를 깨워 실행합니다.
2. 유저 레벨 프로세스 실행 실습
2.1 ps 명령어를 이용한 프로세스 목록 확인
ps 명령어로 프로세스 목록에서 grep으로 bash 이름의 프로세스를 확인합니다.

터미널을 하나 더 열고 위와 같이 명령어를 실행하면, PID가 8512인 프로세스가 하나 더 생성된 것을 볼 수있습니다. 또 하나 창을 열고 한번 더 실행하면 PID가 8633인 프로세스가 생성되는 것을 알 수 있습니다.
이것으로 새로운 프로그램을 실행하면 이에 해당하는 프로세스가 생성된다는 사실을 알 수 있습니다.

추가로 하나 더 실험해 보겠습니다. 새로 만든 2개의 터미널 창에 geany라는 에디터 프로그램을실행합니다. 그리고 ps와 grep 명령어를 입력합니다.

PID가 8790, 8800 프로세스인 geany 프로세스가 생성되었습니다. 또 하나 PPID는 위에서 본 각 터미널의PID로 되어 있는 것을 알 수 있습니다. 여기서 알 수 있는 것은 대부분의 유저 레벨 프로세스는 셀이나 다른프로세스를 통해 시작하고, 스스로 실행하지 못한다는 것입니다.
터미널, geany 프로그램 같은 프로그램을 실행하면 메모리에 로딩돼 동작하는 것이 프로세스이고, 유저레벨에서 실행하는 프로세스는 유저의 액션에 의해 대부분 생성된다는 것입니다.
2.2 유저 프로세스 실습 코드
2.2.1 유저 프로세스 소스 코드
ftrace 실습을 위해서 리눅스 시스템 프로그램 만들어 프로세스를 생성합니다.
그러기 위해서 c언어로 간단한 소스 코드를 하나 작성합니다.
소스 코드를 위한 디렉터리를 하나 만들어 c파일 하나를 생성합니다.
--- 4_4_user_process.c 내용
소스코드의 내용은, for 루프문에서 "Raspberry Pi OS tracing" 문자열을 출력하고 3초 동안 휴면 상태로진입한 후 다시 깨는 작업을 500번 반복을 하는 것입니다.
sleep과 fork 같은 시스템 관리하는 함수를 직접 호출하면 저수준 프로그래밍이라고 하며, 이는 저수준함수(API)를 사용했다고 합니다.
2.2.2 Makefile
소스코드를 좀 더 쉽게 컴파일하기 위해서 Makefile을 만듭니다. 출력 파일명과 소스코드 파일 명을gcc에게 알려줍니다.
--- Makefile 내용
2.2.3 컴파일과 실행
Makefile을 작성했다면 컴파일하고, 에러가 없다면 프로그램을 실행합니다.
입력한 문자가 3초마다 출력됩니다.

2.3 ftrace 설정
이전 글에서 사용했던 ftrace_setting.sh파일을 수정해서 사용합니다.
파일 이름을 clone_process_debug_ftrace_setting.sh 바꿔 놓습니다.
핵심적으로 수정한 부분은 콜 스택에 출력할 함수를 설정하는 것입니다.
sched_process_fork, sched_process_exit 이벤트를 활성화하고 이를 통해 프로세스가 종료하고 생성하는동작을 추적합니다.
2.4 유저 프로세스와 ftrace 로그 확보
위에서 작업한 ftrace 스크립트와 4_4_user_process파일 이용해서 프로세스에 분석에 필요한 로그를만들어 보겠습니다.
두 개의 터미널 창이 필요합니다. 첫 번째 터미널에는 유저 프로세스 생성을 위해 만든 프로그램과 ftrace 실행합니다. 두 번째 터미널에 프로세스 확인하고 종료하는 하는 작업을 할 것입니다.
2.4.1 첫 번째 터미널 작업
첫 번째 터미널 창에서 ftrace 설정 셀 스크립트를 실행하고 만든 프로그램을 실행합니다.
두 번째 터미널 의해 프로그램이 종료되면 ftrace를 종료합니다.

2.4.2 두 번째 터미널 작업
첫 번째 터미널에서 실행한 프로그램의 프로세스를 확인합니다. 몇 3~5초 기다린 다음에 kill 명령으로프로세스를 종료합니다.

2.4.3 ftrace 메시지 확인
get_ftrace.sh 파일을 수행한 디렉터리에 ftrace_log.c 파일이 생성된 것을 알 수 있습니다.

이것으로 원하는 유저 프로세스의 ftrace 메시지를 가져오는 것까지 확인해 보았습니다.
3. ftrace 메시지로 프로세스 생성과 종료 과정 분석
3.1 ftrace_log.c 분석 준비
분석할 내용은 get_ftrace.sh를 실행 디렉터리에 ftrace_log.c 파일로 되어 있을 것입니다.
파일을 열어 확인합니다.
이전에 봤던 메시지 형태입니다. 그런데 얼마 실행하지 않은 것 같은데 많은 양의 메시지가 저장되어 있습니다. 전체 다 보는 것은 아니고 실행했던 파일 부분을 찾아서 보겠습니다.
rpi_kernel_src $ vim ftrace_log.c

분석을 위한 단계를 4단계로 나누고 있습니다. 아래 단계 순서로 분석을 진행합니다.
PID, 프로그램 이름 등과 같은 메시지는 작업환경에 따라서 달라질 수 있습니다.
1단계 : 프로세스 생성
4_4_user_process 프로세스가 생성되며 부로 프로세스 PID는 10675입니다.
2단계 : 4_4_user_process 프로세스 실행
4_4_user_process는 3초 간격으로 실행과 휴면을 반복
3단계 : 프로세스 종료
책과 달리, 터미널1에서 kill [pid] 종료했고 4_4_user_process 프로세스 종료되는 동작
4단계 : 부모 프로세스에게 시그널 전달
종료하는 4_4_user_process 프로세스는 부모 프로세서인 bash에게 시그널 전달
3.2 1단계 프로세스 생성 확인
유저 공간에서 리눅스 시스템 저수준 함수로 fork() 함수를 호출하면 fork 시스템 콜이 발생해 커널 모드로 실행흐름이 변경됩니다. 이후 커널 모드에서 시스템 콜 번호에 해당하는 시스템 콜 핸들러 함수인 sys_clone() 함수를 호출합니다.

4061번째 줄 왼쪽에 "bash-10675"는 PID가 10675 프로세스라는 의미입니다.
4063~4067번째 줄까지는 콜 스택으로 실행 중인 것을 보여줍니다. 이전에 본 것처럼 콜 스택이므로 아래에서 위로 방향으로 수행하고, fork() 함수에 의해 sys_clone()가 호출됩니다.
4068번째 줄을 보면 child_comm=bash로 자식 프로세스 이름도 bash로 복제됩니다. 그러나 프로세스가 생성되는 마지막 단계에서 자식 프로세스의 이름이 바뀌는데 이 동작은 task_rename 이벤트를 ftrace에서 활성화해줘야 합니다.
여기서는 child_pid가 15752인 것을 확인하고 아래 4070번째 줄에 pid-15752가 4_4_user_proces 인 것을 확인합니다.
3.3 2단계: 4_4_user_process 프로세스 실행 확인
4081~ 4532 번째 줄을 보다 보면 4_4_user_process 실행정보를 볼 수 있습니다.
4081, 4276, 4532번째 줄을 프로세스가 실행하다가 다음 시간에 스케줄링을 휴면상태로 들어간다는 것을 보여줍니다.
각 메시지 시간 대를 소수점 밑을 빼고 보면 17779 -> 17782 -> 17785로 3초 간격으로 프로세스가 깨어나는 것을 알 수 있습니다. 이는 소스코드 상에서 메시지를 출력하고 sleep() 함수로 슬립 3초로 했기 때문입니다.
메시지를 간단하게 확인해 보면 아래와 같습니다.
prev_comm=4_4_user_proces: 이전에 실행 중이던 프로세스 이름은 4_4_user_process
prev_pid=15752: 해당 프로세스의 PID는 15752
prev_prio=120: 이전 프로세스의 우선순위는 120
prev_state=S: 이전 프로세스가 스위칭될 때 sleep 상태(S)
next_comm=swapper/1: 다음 실행될 프로세스 이름은 swapper/1

참고로, sched_switch 이벤트의 prev_state 필드에서 나타내는 것은 Linux 커널의 태스크 상태(Task State)입니다. 이 상태들은 일반적으로 프로세스가 현재 어떤 상황에 있는지를 보여줍니다. 주요 상태는 다음과 같습니다:
R (Running or Runnable): 실행 중이거나 실행 가능 상태. 즉, 프로세스가 현재 CPU에서 실행 중이거나 CPU를 기다리고 있는 상태입니다.
S (Sleeping): 인터럽트 가능한 수면 상태. 특정 이벤트(예: 입출력 완료)를 기다리는 동안 CPU를 사용하지 않는 상태입니다.
D (Uninterruptible Sleep): 인터럽트 할 수 없는 수면 상태. 주로 디스크 I/O 작업 중에 발생하며, 커널 레벨 작업에서 이벤트를 기다리고 있는 상태를 의미합니다.
Z (Zombie): 좀비 상태. 프로세스가 종료되었지만, 부모 프로세스가 아직 해당 프로세스의 종료 상태를 수집하지 않은 상태입니다.
T (Stopped or Traced): 중지된 상태. 프로세스가 신호(SIGSTOP 등)에 의해 중단되었거나 디버깅 도구에 의해 추적 중일 때 나타납니다.
3.4 3단계 프로세스 종료 확인
4_4_user_process 프로세스를 종료할 때 kill 명령을 사용했습니다. kill 명령어는 프로세스를 종료시키는 시그널을 전달하는 명령어입니다. 아래와 같이 kill 명령어를 전달할 때 시그널 핸들러 실행은 '시그널' 장에서 나옵니다. 여기서는 아래와 같이 종료하는 흐름과 메시지를 확인합니다.

터미널 창에서 "kill -9 15752" 명령어를 입력했고, 이는 pid가 15752인 4_4_user_process 프로세스에게 sig=9를 전달합니다.
8387번째 줄을 보면 4_4_user_process에게 전달된 시그널 sig=9가 있음을 알 수 있습니다.

시그널 타입은 signal.h 파일에 정의되어 있습니다.

8390~8396 번째 줄을 보면 프로세스가 종료될 때 콜 스택을 흐름을 볼 수 있습니다. 8404번째 줄을 보면 4_4_user_process 프로세스가 종료하는 동작을 볼 수 있습니다.
3.5 4단계: 부모 프로세스에게 시그널 전달 확인
8405번째 줄을 보면 4_4_user_process 프로세서는 자신의 소멸될 것을 부모 프로세스인 bash(10675)에게 sig=17(SIGCHLD) 시그널을 전달하고 부모 프로세스가 확인 전까지 잠시 Z(Zombie)로 변경되는 것을 알 수 있습니다.
8407번째 줄에서 bash가 깨어나는 것을 확인할 수 있습니다.

3.6 exit() 함수로 프로세스 종료되는 과정 확인
앞 내용에서는 kill 명령어로 종료 시그널을 보내 4_4_user_process 프로세스를 종료했습니다. 다른 방법으로 프로세스가 POSIX exit 시스템 콜을 호출해서 스스로 종료하는 것을 알아보겠습니다.
3.6.1 소스코드 수정
4_4_user_process.c 소스코드를 스스로 종료할 수 있게 수정을 합니다.
원본 파일을 복사해서 수정합니다.
수정된 내용은 stdlib.h 헤더파일 추가, PROC_TIMES 횟수를 3회로 변경, exit(EXIT_SUCCESS) 함수를 통해 종료하는 부분입니다.
rpi_kernel_src/app_src# cp 4_4_user_process.c 4_4_user_exit_process.c
rpi_kernel_src/app_src# vim 4_4_user_exit_process.c
--- 4_4_user_exit_process.c 내용
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#define PROC_TIMES 3
#define SLEEP_DURATION 3 //second unit
int main()
{
int proc_times = 0;
for(proc_times=0; proc_times < PROC_TIMES;proc_times++)
{
printf("Raspberry Pi OS tracing \n");
sleep(SLEEP_DURATION);
}//for
exit(EXIT_SUCCESS);
return 0;
} //main
소스 파일이름이 변경되었기 때문에 Makefile 부분도 수정합니다.
컴파일하고 실행파일을 만듭니다.
rpi_kernel_src/app_src# vim Makefile
rpi_kernel_src/app_src# make
--- Makefile 내용
4_4_user_exit_process: 4_4_user_exit_process.c
gcc -o 4_4_user_exit_process 4_4_user_exit_process.c
3.6.2 4_4_user_exit_process 프로그램 실행
4_4_user_process 프로그램에서 메시지를 얻은 것 같이 실행합니다.
rpi_kernel_src# sh ./clone_process_debug_ftrace_setting.sh
rpi_kernel_src# ./4_4_user_exit_process
rpi_kernel_src# sh ./get_ftrace.sh

3.6.3 ftrace 메시지 확인
참고로 실행파일은 로그와 셀 스크립트가 있는 곳으로 복사했고, 전에 받은 로그파일은 다른 이름으로 변경해서 백업했습니다.
이제는 파일 보는 것이 조금은 어렵지 않을 것입니다.
403번째 줄을 보면 pid 893인 bash에 의해 pid 1121인 4_4_user_exit_process가 fork 된 것을 알 수 있습니다.

다음으로 exit 부분을 확인합니다.
1093번째 줄부터 4_4_user_exit_process의 exit 콜 스택을 볼 수 있습니다. 흐름을 보면
__wake_up_parent -> do_group_exit -> do_exit순으로 함수가 호출되었습니다.

위의 함수 호출 순서에 주의해야 할 것이 있습니다. 그런데, 주의할 내용을 알기 전에 알고 있어야 지식이 있습니다. 간단하게 설명하고 다시 확인하겠습니다.
콜 스택 출력의 do_group+0x50/0xe8 먼저 보겠습니다.
이것의 의미는 do_group() 함수의 시작주소에서 0x50 떨어진 코드라는 것을 의미합니다. 이것은 함수의 어디에서 실행되고 있는 알려주는 정보입니다. 0xe8은 함수의 전체 크기입니다.
그럼 특별할 것이 없다고 생각할 수 있지만, ARMv7 프로세스는 파이프라인을 적용한 아키텍처이기 때문에 실제로 호출되는 코드의 주소보다 +0x4바이트를 출력합니다.
그런데 __wake_up_parent은 0x0으로 되어 있습니다. 먼저 __wake_up_parent 함수의 시작 주소를 찾아봅니다.
우리는 이전 4번째(아래링크 참고) 글에서 objdump 명령어를 사용한 적이 있습니다. 해더파일 정보와 심벌 주소 등 알아보기 위해서 objdump 명령어로 System.map파일을 만들었습니다.
만약, System.map파일을 만들지 않았다면 위의 링크를 참고해서 생성한 후 아래내용을 확인하면 됩니다.
__wake_up_parent() 함수주소가 c0227314라는 것을 알 수 있습니다.
rpi_kernel_src# cat ../kernel_obj/System.map | grep __wake_up_parent

이제 디버깅이 가능한 압축되지 않은 리눅스 커널 파일인 vmlinux 이용해서 c0227314 주소 기준으로 -0x4에 무엇이 있는지 확인합니다.
awk, sed 명령어를 사용해서 검색할 수도 있지만, 한 줄로 쉽게 확인하기 위해서 간단한 명령어로 주소의 위아래를 10줄 정도 출력합니다.
rpi_kernel_src# objdump -d ../kernel_obj/vmlinux | cat -n | grep -A 10 -B 10 | head -n 21

c0227314는 __wake_up_parent 함수의 시작 주소이고 -0x4(c0227310)는 do_group_exit() 함수를 호출하는 sys_exit_group() 함수 주소라는 것을 알 수 있습니다.
즉, __wake_up_parent -> do_group_exit -> do_exit순으로 함수가 호출된 것처럼 보였지만, 실제로는 sys_exit_group -> do_group_exit -> do_exit순으로 함수가 호출된 것을 알 수 있고, exit() 함수를 호출하면 커널 공간에서 실행되는 sys_exit_group() 함수가 호출된다는 것까지 알 수 있습니다.
그래서 ftrace 메시지에 함수 오프셋(offset)이 0x0이라면 호출된 함수이름이 정확한 것인지 확인이 필요합니다.
나머지 부분은 종료과정으로 pid가 893인 bash 프로세스에게 sig=17(SIGCHLD) 시그널을 보내는 것을 알 수 있습니다.
메시지 내용을 확인하면 아래와 같습니다.
sig=17 (SIGCHLD) : 시그널 종류이고
errno=0 (오류 없음) : 시그널이 정상적으로 전달되었음을 알려주고
code=1 (SI_USER) : 시그널이 사용자 공간에서 발생했다는 것을 알려주고
sa_handler=55a6c : 시그널 핸들러 함수 주소(유저공간)

이것으로 유저공간에서 특정 함수를 호출하면 리눅스 커널에서 어떤 함수 흐름으로 코드가 실행되는지 파악할 수 있었다고 생각합니다.
책에서도 디버깅하는 방법을 자세히 알려주고 있지만, 실제로 실습하려면 개별적으로 자료를 찾고 검증하는 방법을 찾아야 하는 과정이 추가로 필요할 것입니다.
<참고 TIP, return 0과 exit()>
exit(EXIT_SUCCESS) 호출도 하지만, return 0을 해도 종료가 되는데 무슨 차이인지 궁금해서 GPT와 Gemini의 도움받은 내용을 정리해 보았습니다. 직접 확인해보지 않았지만, 대략 이렇겠구나 참고하면 어떨까 합니다.
return 0:
exit(EXIT_SUCCESS):
<1> exit() 함수
exit() 함수를 직접 호출합니다.

<2> return 0;
main() 함수 종료 시 __libc_start_main()을 통해 exit()을 간접적으로 호출합니다.

두 방식 모두 C 표준 라이브러리의 정리 작업(버퍼 비우기, 파일 닫기, atexit() 핸들러 실행 등)을 수행합니다. 추가로 return 0 은 main함수를 호출한 곳으로 돌아가는 과정이 추가됩니다.
__libc_start_main()의 역할은?
__libc_start_main()은 C 프로그램의 시작점을 설정하고 main() 함수를 호출하는 중요한 역할을 합니다. main() 함수가 종료되면 __libc_start_main()은 main()의 반환 값을 받아 exit() 함수를 호출하여 프로그램 종료를 처리합니다.
감사합니다.
참고: https://remnant24c1.tistory.com/504#google_vignette
'Embedded : : Linux > : : Linux Kernel' 카테고리의 다른 글
| [프로세스] 커널 내부 프로세스의 생성 과정 (0) | 2024.10.11 |
|---|---|
| [프로세스] 커널 쓰레드 (0) | 2024.10.10 |
| [프로세스] 프로세스 확인하기 (0) | 2024.10.10 |
| 커널 디버깅용 debugfs 드라이버 코드 (0) | 2024.10.10 |
| ftrace 설정과 로그 분석하기 (0) | 2024.10.10 |