디버깅을 통해 배우는 리눅스 커널의 구조와 원리 1, 도서 공부하기 12 - 4_4_user_process생성과 종료 과정 분석
안녕하세요.
이전 글에서 4_4_user_process라는 사용자 프로그램을 만들어 ftrace 메시지까지 만들어 봤습니다. 책에서는 raspbian_proc로 만들어 테스트한 내용입니다. 내용은 거의 동일합니다.
ftrace 메시지를 바탕으로 프로세스가 어떻게 생성되고 종료하는지 과정을 분석하는 내용입니다.
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에게 시그널 전달
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. 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 등)에 의해 중단되었거나 디버깅 도구에 의해 추적 중일 때 나타납니다.
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 파일에 정의되어 있습니다.
rpi_kernel_src $ vim linux/arch/arm/include/uapi/asm/signal.h
8390~8396 번째 줄을 보면 프로세스가 종료될 때 콜 스택을 흐름을 볼 수 있습니다. 8404번째 줄을 보면 4_4_user_process 프로세스가 종료하는 동작을 볼 수 있습니다.
5. 4단계 부모 프로세스에게 시그널 전달 확인
8405번째 줄을 보면 4_4_user_process 프로세서는 자신의 소멸될 것을 부모 프로세스인 bash(10675)에게 sig=17(SIGCHLD) 시그널을 전달하고 부모 프로세스가 확인 전까지 잠시 Z(Zombie)로 변경되는 것을 알 수 있습니다.
8407번째 줄에서 bash가 깨어나는 것을 확인할 수 있습니다.
6. exit() 함수로 프로세스 종료되는 과정 확인
앞 내용에서는 kill 명령어로 종료 시그널을 보내 4_4_user_process 프로세스를 종료했습니다. 다른 방법으로 프로세스가 POSIX exit 시스템 콜을 호출해서 스스로 종료하는 것을 알아보겠습니다.
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
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) 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파일을 만들었습니다.
디버깅을 통해 배우는 리눅스 커널의 구조와 원리 1, 도서 공부하기 4 - 전처리 코드 생성과 objdump
안녕하세요. 라즈베리 파이 커널을 설치하고 나머지 뒷부분을 마무리하도록 하겠습니다. 리눅스 커널을 빌드하는 과정에서 전처리코드를 생성하는 방법이 나옵니다.커널에는 많은 양에
remnant24c1.tistory.com
만약, 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() 함수를 호출하여 프로그램 종료를 처리합니다.
감사합니다.
<참고 자료>
1. [도서] 디버깅을 통해 배우는 리눅스 커널의 구조와 원리 p155~p169, wikibook