IT/Linux Kernel

디버깅을 통해 배우는 리눅스 커널의 구조와 원리 1, 도서 공부하기 14 - 커널 내부 프로세스 생성과 종료 과정

변화의 물결1 2025. 4. 9. 10:31

 

 

안녕하세요.

 

 이전 내용에서는 유저프로세스, 커널 프로세스에 대해서 확인했습니다. 이번에는 이 내용을 간단히 정리하면서 나머지 부분을 확인합니다.

 


 

1. 프로세스 생성 과정 확인, _do_fork() 함수

 

 유저 프로세스는 fork -> sys_clone -> _do_fork 흐름으로 호출했고,

 커널 프로세스에서 kthread_create -> ... -> kthread 스레드 요청 -> ... -> _do_fork 흐름으로 프로세스를 생성했습니다.  최종적으로 동일하게 _do_fork() 함수를 호출했습니다.

 

 _do_fork() 함수의 동작은 크게 두 단계로 볼 수 있습니다.

 1단계는 프로세스 생성, 2단계는 프로세스의 실행요청으로 볼 수 있습니다.

 

1) _do_fork() 함수 소스코드 분석

 

 한 줄씩 보는 것이 아니라, 중요한 부분 위주로 확인합니다.

rpi_kernel_src# vim linux/kernel/fork.c

 

 copy_process() 함수를 호출해 부모 프로세스의 메모리 및 시스템 정보를 자식 프로세스에게 복사합니다. 이후에 다시 확인할 것입니다.

 

2217줄 : p 포인터 형 변수에 오류 검사를 하며 태스크 디스크립터의 주소를 담고 있는 p 포인터 변수에 오류가 있으면 오류코드를 반환하고 종료합니다.

 

2224줄 : ftrace의 sched_process_fork 이벤트를 활성화했을 때 동작 메시지이며 이전 ftrace에서 확인할 수 있습니다.

 

 

 

2226,7줄 : pid를 계산해서 지역변수에 저장하며, nr은 정수 타입으로 _do_fork() 함수가 종료 시  반환합니다.

 

2238줄 : 생성한 프로세스를 깨우는 동작입니다.

 

 

 

2) copy_process() 함수 분석

 

 _do_fork() 함수에서 호출했고 부모 프로세스의 메모리 및 시스템 정보를 자식 프로세스에게 복사하는 작업을 합니다. 동일한 fork.c 파일에 함수가 있습니다.

  

1737줄 : dup_task_struct() 함수는 생성할 프로세스의 태스크 디스크립트인 task_struct 구조체와 프로세스가 실행될 스택 공간을 할당하고 구조체 주소를 반환합니다.

 

1867줄 : 태스크 디스크립터인 task_struct 구조체의 스케줄링 관련 정보를 초기화합니다.

 

1885~1890줄 : 파일 디스크립터, 파일 디스크립터 테이블을 초기화하고 부모 file_struct 구조체의 내용을 자식 프로세스에게 복사합니다. 생성 플래그 중 CLONE_FILES로 프로세스를 생성할 경우 참조 카운트만 증가합니다.

 

1891줄 : 프로세스가 등록한 시그널 핸들러 정보인 sighand_struct 구조체를 생성해서 복사합니다.

 

 전체적으로 copy_process() 함수 코드를 보면 부모 프로세스의 리소스를 새로 생성하는 프로세스의 task_struct구조체에 복제하는 과정이라고 볼 수 있습니다.

 

 

 

3) wake_up_new_task() 함수 분석

 

 copy_process() 함수로 프로세스를 생성만 하면 되는 것이 아니라 실행까지 해야 합니다. 그래서 마지막 단계로 wake_up_new_task() 함수를 호출합니다. 이 함수는 프로세스 상태를 TASK_RUNNING으로 변경하고 현재 실행 중인 CPU번호를 thread_info구조체의 cpu필드에 저장하며, 런큐에 프로세스를 추가합니다.

 

 rpi_kernel_src# vim linux/kernel/sched/core.c

 

 

2401줄 : 프로세스 상태를 TASK_RUNNING으로 변경합니다.

 

2404줄 : __set_task_cpu() 함수를 호출해서 프로세스의 thread_info 구조체의 cpu필드에 현재 실행 중인 CPU 번호를 저장합니다.

 

2406~2410줄 : 태스크가 속한 런큐(runqueue, rq)를 찾아서 락을 획득하고 런큐의 시간의 최신으로 업데이트합니다. activate_task() 함수를 통해 p를 런큐에 추가합니다.

 조금 더 추가한다면, post_init_entity_util_avg()는 새로 생성된 태스크의 CPU 사용률과 로드 통계를 초기화하여, CFS 스케줄러가 올바르게 태스크를 배정할 수 있도록 하는 함수입니다.

 

2412줄 : 트레이싱(trace) 이벤트를 발생시켜서 새로운 태스크가 깨어났음을 기록합니다.

 

2413줄 : 현재 실행 중인 태스크와 p를 비교하여 선점(preemption)할지를 결정합니다.

WF_FORK 플래그는 이 태스크가 fork()에 의해 생성된 것임을 의미합니다. 여기서 조금 더 추가하면 커널스레드는 CFS 스케줄러를 사용하지만, 반드시는 아니라서 sched_setscheduler() 함수 등을 사용해서 RT스케줄러로 변경 및 실행 가능해서 분석이 동일하지 않을 수 있습니다.

 

 

 

2. 프로세스 종료 흐름 파악

 

 프로세스의 종료는 유저 애플리케이션에서 exit() 함수를 호출할 때와 종료 시그널을 전달받았을 때 크게 두 가지 흐름으로 종료됩니다. 흐름을  빠르게 확인해 봅니다.

 

1) exit() 시스템 콜 실행

 

 유저 프로세스가 정해진 순서에 따라 종료해야 할 때 exit() 함수를 호출합니다. 이후 시스템 콜이 발생된 후 해당 시스템 콜인 sys_group_exit() 함수 호출하고, do_exit() 함수로 이어집니다.

 

 

(1) do_exit() 함수 확인

 

 함수 선언부의 __noreturn 키워드는 실행 후 자신을 호출한 함수로 돌아가지 않는다는 뜻이며 code 인자는 프로세스 종료 코드를 의미합니다. 예로 "kill -9 [pid]" 경우 9가 전달됩니다.

 

802~807줄 : unlikely() 함수로 많은 경우 PF_EXITING 플래그가 설정되지는 않겠지만, 그런 경우 조건문을 실행합니다(브런치 예측 - cpu성능향상이 있을 수 있고, 예측 실패 시 성능이 조금 성능 저하가 발생).

 

 이것은 do_exit() 함수를 처리하는 중에 다시 do_exit() 함수를 호출할 때 예외처리하는 경우라고 생각하면 됩니다. state에 TASK_UNINTERRUPTIBLE 상태로 바꾸고 schedule() 함수를 호출해서 휴먼상태에 진입합니다.

 

810줄 : 프로세스의 task_struct 구조체의 flag 필드를 PF_EXITING으로 바꾸고 종료할 프로세스가 처리할 시그널이 있으면 retarget_shared_pending() 함수를 실행해서 시그널을 태신 처리할 프로세스를 선정합니다.

 

859줄 : 프로세스의 메모리 디스크립터인 mm_struct 구조체의 리소스를 해제하고 메모리 디스크립터의 사용 카운터를 1만큼 감소합니다.

 

867~8줄 : exit_files() 함수는 프로세스가 열었던 파일 디스크립터를 정리하는 역할을 하고 exit_fs() 함수는 프로세스가 사용하던 파일 시스템 컨텍스트를 정리하는 역할을 한다.

 

892줄 : 부모 프로세스에게 현재 프로세스가 종료 중이라는  사실을 통지합니다.

 

929줄 : 프로세스가 완전히 종료될 때 호출되며, TASK_DEAD 상태로 변경하고 스케줄링에서 제거하는 역할을 한다. do_task_dead() 함수는 마무리 처리 부분이라 코드를 한번 더 확인합니다.

 

pi/rpi_kernel_src# vim linux/kernel/exit.c

 

 

 

do_exit() 함수의 동작 방식을 다시 확인하면

 

 - init 프로세스가 종료하면 강제 커널 패닉 유발 : 보통 부팅과정에서 발생

 - 이미 프로세스가 do_exit() 함수를 실행해서 종료 도중 또 do_exit() 호출했는지 확인

 - 프로세스 리소스(파일 디스크립터, 가상메모리, 시그널) 등 해제

 - 부모 프로세스에게 자신이 종료되고 있다고 알림

 - 프로세스의 실행 상태를 task_struct 구조체의 state필드에 TASK_DEAD로 설정

 - do_task_dead() 함수를 호출해 스케줄링 실행

   (프로세스는 자신의 스택 메모리 공간을 해제할 수 없기 때문에 __schedule() 함수가 호출하여 '다음에 실행하는 프로세스'가 종료되는 프로세스의 스택 메모리 공간을 해제시켜 줍니다.)

 

 

(2) do_task_dead() 함수 분석

 

3527줄 : 프로세스의 상태를 TASK_DEAD 플래그로 바꿉니다.

 

3528줄 : 종료될 때 실행되는 함수이므로 이 시점에서 프로세스가 suspend(절전 모드)로 인해 멈추면 안 되기 때문에 PF_NOFREEZE를 설정합니다. 만약 TASK_DEAD 상태에서 프로세스가 멈추면, 커널이 프로세스를 정리하지 못하고, 좀비 프로세스처럼 남을 가능성이 생길 수도 있기 때문입니다.

 

3532줄 : __schedule(false) 함수를 호출해 스케줄링을 요청합니다. 여기서 false 인자는 선점 스케줄링을 실행하지 않겠다는 의미입니다.

 

 참고) 코드상에 current 변수는 이름에서도 알 수 있듯이 현재 실행 중인 프로세스 태스크 디스크립터 자료구조인 task_struct 구조체입니다.

 

rpi_kernel_src# vim linux/kernel/sched/core.c

 

 

  

(3) do_task_dead() 함수 이후

 

 __schedule(false) 이후 끝일 것 같지만, 아직도 조금 남아 있습니다. 앞에서 말했던 자신의 프로세스가 해제 못하는 부분을 다음 프로세스에게 알려 해제하는 한다고 했습니다. 그렇게 하기 위한 작업이 남아 있습니다.

 

 책에서 끔찍한 비유를 하지만, 간단하게, 자신의 스택 메모리 공간을 해제하고 소멸시켜 달라고 부탁하는 것이라고 마무리하고 있습니다. 마지막까지 따라가 봅니다.

 

동일 파일(core.c)에 있는 __schedule() 함수를 확인합니다.

 

3515줄 : context_switch() 함수를 호출해 컨텍스트 스위칭을 실행합니다.

 

 

 

 파일(core.c)에 있는 context_switch() 함수를 확인합니다. context_switch() 함수는 알아야 할 내용이 더 있으므로 '10장 스케줄러'에서 다시 한번 확인합니다.

 

2829줄 : finisih_task_switch() 함수를 호출합니다. schedule() 함수를 호출하면 결국 finisih_task_switch() 호출한다는 것을 알 수 있습니다.

 

 

 

 파일(core.c)에 있는 finisih_task_switch() 함수를 확인합니다.

 

2697~2710줄 : TASK_DEAD일 때 put_task_stack() 함수를 호출해서 프로세스의 스택 메모리 공간을 해제하고 put_task_struct() 함수를 호출해서 프로세스의 자료구조인 task_struct 위치의 메모리를 해제합니다.

 

 

 

 finish_task_switch() 함수를 ftrace 필터 설정(set_ftrace_filter)에 걸고 한번 4_4_user_exit_process 프로그램을 다시 실행시켜 로그를 확인했습니다.

 예전 글의 clone_process_debug_ftrace_setting.sh 내용에 추가했습니다.

  

 4_4_user_exit_process의 종료 흐름이며 10124번째 줄에 pid-1231 프로세스에서 pid가 10인 rcu_sched프로세스로 스케줄링됩니다.

 

 10125~10133번째 줄에서 rcu_sched 프로세스는 finish_task_switch() 함수에서 4_4_user_exit_process-1231 프로세스의 마지막 리소스를 정리합니다.

 

 

 

 위에서 본 것처럼 4_4_user_exit_process-1231 프로세스는 do_exit() 함수에서 태스크 디스크립터와 여러 필드를 해제했습니다. 그러나 자신의 스택 공간에서 실행 중이니 스스로 자신의 스택공간을 해제할 수 없었기 때문에 스케줄링을 한 후 다음에 실행하는 프로세스인 rcu_sched가 종료되는 4_4_user_exit_process-1231 프로세스의 스택 메모리 공간을 해제합니다.

 

이것으로 프로세스의 생성과 종료 과정을 조금 깊게 확인해 보았습니다.

 

 

감사합니다.

 

 

<참고 자료>

1. [도서] 디버깅을 통해 배우는 리눅스 커널의 구조와 원리 p183 ~ 202, wikibook

 

 

반응형