do_exit() 함수 분석
do_exit() 함수로 커널이 프로세스를 종료 시키는 세부 동작 못지 않게 프로세스가 종료되는 흐름을 파악하는 것이 중요합니다. 그 이유는 무엇일까요? 

유저 어플리케이션 프로세스나 커널 프로세스가 예외 상황에서 의도하지 않게 종료해서 문제가 발생하는 경우가 있습니다. 이런 문제를 만났을 때 커널 어느 함수부터 분석을 해야할 지 결정할 수 있습니다.

이번에는 do_exit() 함수가 어떻게 실행되는지 알아봅시다.

- exit() 시스템 콜 실행
유저 어플리케이션 프로세스가 자신을 종료하려면 리눅스 저수준 함수로 exit() 함수를 호출합니다. 이 때 시스템 콜을 발생시킨 다음 sys_group_exit() 시스템콜 핸들러를 실행합니다.

- 다른 프로세스가 프로세스 종료 시그널을 전달했을 경우
프로세스 종료 시그널을 다른 프로세스가 전달했을 경우에도 do_exit() 함수가 호출될 수 있습니다. 또한 커널 함수로 send_signal() 함수를 호출하면 특정 프로세스 종료 시그널을 전달할 수 있습니다. 프로세스 종료 시그널을 받은 프로세스는 do_exit() 함수를 실행해서 자신을 종료합니다.

이렇게 프로세스를 종료할 때 do_exit() 함수를 호출합니다. 이 함수는 프로세스가 쓰는 리소스를 해제하는 역할을 수행합니다.

먼저 do_exit() 함수에 전달되는 인자와 반환값을 확인합시다.
void __noreturn do_exit(long code);

do_exit() 함수는 프로세스에 대한 리소스를 정리한 후 do_task_dead() 함수를 호출한 후 schedule() 함수를 실행합니다. 따라서 do_exit() 함수를 끝까지 실행하지 않습니다. 그래서 함수 선언부에 __noreturn 이란 매크로로 지정한 것입니다.

do_exit() 함수에서 do_task_dead() 함수를 호출해서 schedule() 함수 실행으로 함수 흐름을 마무리하는 이유는 무엇일까요?

그 동안 프로세스 실행 흐름에 대해 살펴봤듯이 프로세스는 자신의 스택 공간에서 함수를 실행합니다. do_exit() 함수는 프로세스 스택 공간에서 실행을 시작하는 것입니다.

그런데 프로세스 리소스를 해제하는 함수를 호출하면서 태스크 디스크립터 대부분 멤버와 스택 공간까지 해제합니다. do_exit() 함수 실행을 끝낸 다음에 복귀를 못하는 것입니다. 따라서 do_task_dead()/schedule() 함수를 호출해서 do_exit() 함수 실행을 마무리합니다.

이번에는 이 함수에 전달하는 code란 인자를 점검합시다. code라는 인자는 프로세스 종료 코드를 의미합니다.

만약 kill -9 라는 명령어로 프로세스를 종료하면 code 인자로 9가 전달됩니다.

다음은 Trace32로 do_exit() 함수를 호출했을 때 브레이크 포인트를 걸고 본 디버깅 정보입니다.
-000|do_exit(?)
-001|do_group_exit(exit_code = 9)
-002|get_signal(?)
-003|do_notify_resume(regs = 0A4233EC0, thread_flags = 9)
-004|work_pending(asm)

"kill -9 [PID]" 명령어로 프로세스를 종료하니 exit_code 인자에 9가 전달된 것입니다.  

do_exit() 함수는 다음 순서로 프로세스를 종료합니다.
- 이미 프로세스가 종료 중 인데 다시 do_exit() 함수를 호출하는지 점검합니다.
- 프로세스 리소스(파일 디스크립터, 가상 메모리, 시그널) 등등을 해제합니다.
- 부모 프로세스에게 자신이 종료되고 있다고 알립니다.
- 프로세스 실행 상태를 struct task_struct state 멤버에 TASK_DEAD 로 설정합니다.
- do_task_dead() 함수를 호출해서 schedule() 함수를 실행하여 스케줄링 됩니다..

분석할 do_exit() 함수 코드는 다음과 같습니다.
1 void __noreturn do_exit(long code)
2 {
3 struct task_struct *tsk = current;
4 int group_dead;
...
5 if (unlikely(in_interrupt()))
6 panic("Aiee, killing interrupt handler!");
7 if (unlikely(!tsk->pid))
8 panic("Attempted to kill the idle task!");
...
9 if (unlikely(tsk->flags & PF_EXITING)) {
10 pr_alert("Fixing recursive fault but reboot is needed!\n");
11
12 tsk->flags |= PF_EXITPIDONE;
13 set_current_state(TASK_UNINTERRUPTIBLE);
14 schedule();
15 }
16
17 exit_signals(tsk);  /* sets PF_EXITING */
...
18 exit_mm();
...
19 exit_files(tsk);
20 exit_fs(tsk);
...
21 do_task_dead();
...
22}

먼저 다음 코드를 보겠습니다.
5 if (unlikely(in_interrupt()))
6 panic("Aiee, killing interrupt handler!");
7 if (unlikely(!tsk->pid))
8 panic("Attempted to kill the idle task!");

특이한 상황에 부팅 도중 init 프로세스가 중료하는 경우가 있습니다. 이 때 다음와 같은 커널 로그와 함께 커널 패닉이 발생합니다.
[  837.981513 / 10-11 11:11:00.958][4] Kernel panic - not syncing: Attempted to kill init! exitcode=0x0000000b
[  837.981513 / 10-11 11:11:00.958][4] 
[  837.981547 / 10-11 11:11:00.958][6] CPU6: stopping
[  837.981571 / 10-11 11:11:00.958][6] CPU: 6 PID: 339 Comm: mmc-cmdqd/0 Tainted: P        W  O   3.18.31-perf #1

init 프로세스는 유저 레벨 프로세스를 생성하고 관장하는 역할을 수행합니다. 그래서 init 프로세스가 불의의 상황으로 종료하면 강제로 커널 패닉을 유발합니다.
보통 리눅스 커널 버전을 업그레이드 후 root 파일 시스템이나 다바이스 노드를 생성 못했을 때 init 프로세스가 종료됩니다.
 
다음 코드를 보겠습니다.
9 if (unlikely(tsk->flags & PF_EXITING)) {
10 pr_alert("Fixing recursive fault but reboot is needed!\n");
11
12 tsk->flags |= PF_EXITPIDONE;
13 set_current_state(TASK_UNINTERRUPTIBLE);
14 schedule();
15 }

프로세스가 종료 중이면 do_exit() 함수에서 호출되는 exit_signals() 함수에서  태스크 디스크립터 struct task_struct flags를 PF_EXITING로 설정합니다.
void exit_signals(struct task_struct *tsk)
{
...
tsk->flags |= PF_EXITING;

프로세스가 종료 중인데 다시 do_exit() 함수를 실행해서 중복 종류를 시도하는 경우 예외 처리 코드입니다.

프로세스 태스크 디스크립터 멤버인 state에 TASK_UNINTERRUPTIBLE로 상태를 지정하고 schedule() 함수를 호출해서 다른 프로세스가 동작하도록 합니다.

다음 코드를 보겠습니다.
17 exit_signals(tsk);  /* sets PF_EXITING */

프로세스 struct task_strtuct.flags를 PF_EXITING 으로 변경합니다.
종료할 프로세스가 처리할 시그널이 있으면 retarget_shared_pending() 함수를 실행해서 시그널을 대신 처리할 프로세스를 선정합니다.

다음 코드를 보겠습니다.
18 exit_mm();

프로세스의 메모리 디스크립터(struct mm_struct) 리소스를 해제하고 메모리 디스크립터 사용 카운트를 1만큼 감소합니다.

다음 코드를 봅시다.
19 exit_files(tsk);
20 exit_fs(tsk);

프로세스에 쓰고 있는 파일 디스크립터 정보를 해제합니다.

schedule() 함수를 호출하면, finish_task_switch() 함수에서 do_exit() 함수로 종료하는 프로세스의 태스크 디스크립터와 스택 메모리를 해제합니다.
static struct rq *finish_task_switch(struct task_struct *prev)
__releases(rq->lock)
{
...
if (unlikely(prev_state == TASK_DEAD)) {
if (prev->sched_class->task_dead)
prev->sched_class->task_dead(prev);

kprobe_flush_task(prev);

/* Task is done with its stack. */
put_task_stack(prev);

put_task_struct(prev);
}

put_task_stack() 함수를 호출해서 프로세스 스택 메모리 공간을 해제하여 커널 메모리 공간에 반환합니다. 바로 put_task_struct() 함수를 실행해서 프로세스를 표현하는 자료구조인 struct task_struct 가 위치한 메모리를 해제합니다.

참고로, finish_task_switch() 함수에 ftrace 필터를 걸고 이 함수가 호출되는 함수 흐름을 확인하면 다음과 같습니다.
->transport-8537  [002] d..3 714.329199: finish_task_switch+0x28/0x20c <-__schedule+0x2bc/0x84c
     ->transport-8537  [002] d..3   714.329206: <stack trace>
 => schedule+0x3c/0x9c
 => schedule_timeout+0x288/0x378
 => unix_stream_read_generic+0x588/0x754
 => unix_stream_recvmsg+0x70/0x94
 => sock_read_iter+0xd4/0x100
 => new_sync_read+0xd8/0x11c
 => vfs_read+0x118/0x178
 => SyS_read+0x60/0xc0
 => ret_fast_syscall_+0x4/0x28

프로세스 스케줄링 동작 때 호출되는 함수입니다.


+ Recent posts