1. 프로세스 디버깅
라즈베리 파이에서 gdb로 glibc 디버깅
ftrace로 유저 프로그램 실행 과정 확인
1.1 glibc의 kernel_clone() 함수를 gdb로 디버깅하기
kernel_clone() 함수를 호출하면 시스템 콜을 발생시켜 커널 공간에서 sys_clone() 함수를 호출함을 배웠습니다.
이번엔 gdb를 이용해 유저 모드에서 kernel_clone() 함수를 호출하면 어떤 코드에서 시스템 콜을 호출하는지 알아보겠습니다.
유저 프로세스 실습 코드 소개하기
소스를 분석하기 전에, "새로운 프로세스를 생성하기 위해 fork() 함수를 호출한 프로세스는 부모 프로세스, 복제되어 새롭게 생성된 프로세스를 자식 프로세스"라고 설명할 수 있습니다.
(1) gcb 사용법
1) 디버깅을 위한 컴파일
GDB가 소스 코드를 읽을 수 있도록 반드시 -g 옵션을 붙여 컴파일해야 합니다. 최적화 옵션은 -O0으로 끄는 것이 정신 건강에 이롭습니다.
컴파일 명령어: 디버깅 심볼을 포함하고, 인라인 최적화를 방지하기 위해 -g와 -O0 플래그를 사용합니다.
2) GDB 시작 및 실행
• 시작: gdb ./my_program
• 인자 전달하며 시작: gdb --args ./my_program arg1 arg2
• 프로그램 실행: run (또는 r)
3) 브레이크포인트 (Breakpoint) 설정
프로그램을 멈추고 싶은 지점을 정하는 단계입니다.
| 명령어 | 약어 | 설명 |
| break main | b main | main 함수 시작 부분에 중단점 설정 |
| break 15 | b 15 | 현재 파일의 15번째 라인에 설정 |
| break file.c:20 | b file.c:20 | 특정 파일의 특정 라인에 설정 |
| info break | i b | 설정된 모든 중단점 목록 확인 |
| delete 1 | d 1 | 1번 중단점 삭제 |
4) 코드 실행 제어
프로그램이 멈춘 상태에서 한 단계씩 움직이는 방법입니다.
• next (n): 다음 줄로 넘어갑니다. (함수 호출 시 내부로 들어가지 않고 그냥 실행함)
• step (s): 다음 줄로 넘어갑니다. (함수 호출 시 내부로 진입함)
• continue (c): 다음 중단점을 만날 때까지 프로그램을 계속 실행합니다.
• finish: 현재 실행 중인 함수를 끝까지 실행하고 빠져나옵니다.
5) 데이터 확인 및 수정
멈춘 지점에서 변수 값이 무엇인지 확인하는 단계입니다.
• print 변수명 (p): 변수의 현재 값을 출력합니다. (예: p my_var)
• display 변수명: 코드가 한 줄씩 실행될 때마다 해당 변수 값을 자동으로 계속 보여줍니다.
• list (l): 현재 멈춘 지점 주변의 소스 코드를 보여줍니다.
• set variable 변수명 = 값: 실행 중에 변수 값을 강제로 바꿉니다. (로직 테스트 시 유용)
6) 스택 추적 (Backtrace)
프로그램이 Segmentation Fault 등으로 죽었을 때, 어떤 함수 호출 경로를 거쳐 여기까지 왔는지 확인하는 가장 중요한 기능입니다.
• backtrace (bt): 현재 호출 스택을 모두 보여줍니다.
• frame 번호 (f): 스택의 특정 단계로 이동하여 그 당시의 지역 변수 상태를 확인합니다.
7) GDB만의 꿀팁: TUI 모드
터미널에서 마치 GUI 디버거처럼 소스 코드를 실시간으로 보면서 디버깅할 수 있는 모드입니다.
• 실행법: GDB 안에서 layout src 입력 또는 실행 시 gdb -tui ./my_program
• 화면 깨짐 방지: 화면이 깨지면 Ctrl + L을 눌러 새로고침하세요.
💡 한 줄 요약
gdb 실행 -> b로 멈출 곳 지정 -> r로 실행 -> n/s로 한 줄씩 이동 -> p로 값 확인 -> bt로 에러 추적
(2) GDB와 LLDB 비교
두 도구는 지향하는 목표가 같기 때문에 전체적인 흐름은 매우 비슷하지만, 명령어의 문법(Syntax) 체계가 다릅니다. GDB는 오래된 만큼 명령어가 다소 파편화되어 있는 반면, LLDB는 명령어 - 대상 - 동작 구조로 훨씬 체계적이고 구조화되어 있습니다. 하지만 사용자의 편의를 위해 LLDB가 GDB 스타일의 별칭(Alias)을 많이 지원하므로, 금방 적응하실 수 있을 거예요.
가장 자주 사용하는 핵심 명령어들을 비교해 보았습니다.
| 기능 | GDB 명령어 | LLDB 명령어 (정식) | LLDB 단축어 / 별칭 |
| 실행 | run | process launch | r |
| 중단점 설정 | break main | breakpoint set --name main | b main |
| 라인 중단점 | break file.c:10 | breakpoint set --file file.c --line 10 | br s -f file.c -l 10 |
| 다음 줄 실행 | next | thread step-over | n (or next) |
| 함수 내부 진입 | step | thread step-in | s (or step) |
| 변수 출력 | print var | expression var | p var |
| 객체 출력 | po obj (macOS) | expression --obj-c -- obj | po obj |
| 백트레이스 | backtrace | thread backtrace | bt |
| 중단점 확인 | info break | breakpoint list | br li |
2) 주요 개념 차이
명령어의 구조화
• GDB: info break, info share, info args 처럼 info 뒤에 파라미터를 붙이는 방식이 많습니다.
• LLDB: [객체] [동작] [옵션] 구조입니다.
• 예: breakpoint(객체) set(동작) --name(옵션) main
• 처음엔 길어 보이지만, 규칙이 일정해서 익숙해지면 명령어를 유추하기 쉽습니다.
강력한 'Expression' 기능
LLDB의 expression(줄여서 p)은 단순히 변수 값을 보여주는 것을 넘어, 디버깅 중에 실제 코드를 실행하는 능력이 매우 뛰어납니다. 복잡한 함수를 호출하거나 실행 중에 변수 값을 논리적으로 계산해서 결과를 보는 기능이 GDB보다 조금 더 직관적이고 강력하다는 평을 받습니다.
플랫폼 및 언어 궁함
• GDB: 리눅스 환경과 GCC 컴파일러에 최적화되어 있습니다.
• LLDB: macOS/iOS 환경, Clang 컴파일러와 찰떡궁합입니다. (Swift나 최신 C++ 지원이 빠릅니다.)
3) GDB 사용자가 LLDB로 넘어올 때 팁
이미 GDB에 익숙하시다면, LLDB가 제공하는 GDB 호환 별칭 덕분에 크게 고생하지 않으셔도 됩니다. r, b, n, s, p, bt 같은 기본 단축키는 두 도구에서 똑같이 작동합니다.
하지만 LLDB의 진가를 느끼시려면 help 명령어를 활용해 보세요.
(lldb) help breakpoint set
위와 같이 입력하면 해당 명령어의 정석적인 구조와 사용 가능한 모든 옵션을 아주 친절하게 설명해 줍니다.
(3) ARM64 / Kernel 6.1+ 맞춤형 C 코드
POSIX 표준 API인 fork(), printf(), sleep()을 사용하고 있기 때문에, C 언어 소스 코드 자체는 아키텍처(ARM64)나 커널 버전(6.1+)에 독립적입니다.
중요한 점은 Kernel 6.1 이상 및 최신 glibc 환경(AArch64)에서 fork()가 내부적으로 어떻게 동작하는지 파악하고 gdb를 세팅하는 것입니다. 최신 환경에서 fork()는 내부적으로 clone이 아닌 clone3 시스템 콜을 호출할 확률이 매우 높습니다.
아래는 수정된 완성형 코드와, 해당 환경에 맞춘 GDB 디버깅 가이드입니다.
특히 시스템 프로그래밍에서 자식 프로세스가 종료되었을 때 좀비(Zombie) 프로세스로 남지 않도록 wait(NULL)처리를 부모 프로세스에 추가하였고, 디버깅 시 직관적으로 프로세스를 구분할 수 있도록 getpid()를 포함했습니다.
💡 시스템 레벨 디버깅 참고 사항
커널 6.1 이상의 AArch64 환경에서는 fork() 호출 시 커널 내부적으로 clone3 시스템 콜을 거치게 됩니다. GDB를 통한 어셈블리/레지스터 레벨의 추적 외에도, 유저 스페이스에서 커널로 넘어가는 경계면을 빠르게 확인하시려면 strace를 활용하는 것도 좋은 접근입니다.
• 시스템 콜 추적:이 명령어를 사용하면 자식 프로세스(-f)까지 따라가며, ARM64 환경에서 실제 어떤 시스템 콜(clone3 등)이 인가되고 리턴되는지, 부모가 wait4로 어떻게 자식을 회수하는지 커널 관점에서 명확하게 확인하실 수 있습니다. TRACE32 같은 장비로 커널 소스 레벨 디버깅을 하시기 전, 동작을 검증하기에 유용합니다.
make로 실행파일을 만든 후 실행하면 아래와 같은 결과를 볼 수 있습니다.

(4) ARM64 & Kernel 6.1+ GDB 디버깅 핵심 포인트
최신 glibc(2.34 이상)와 Kernel 6.1+ 환경에서는 fork() 래퍼 함수가 기존의 clone (syscall 220) 대신 clone3 (syscall 435) 시스템 콜을 사용하도록 구현되어 있습니다.
1) GDB 실행 및 시스템 콜 Catch 설정
Code Snippet
2) 디버깅 진행 및 ARM64 레지스터 확인
break fork에서 멈춘 후 stepi (명령어 단위 실행)를 통해 어셈블리 레벨로 내려가면, glibc 내부의 fork 구현체를 따라갈 수 있습니다. catch syscall clone3에서 멈추었을 때, AArch64 아키텍처의 호출 규약(Calling Convention)에 따라 레지스터를 확인하면 시스템 콜의 진입 상태를 정확히 볼 수 있습니다.
• AArch64 Syscall Number: x8 레지스터에 저장됩니다. (435 = clone3)
• Arguments: x0 ~ x5 레지스터에 저장됩니다. (clone3의 경우 x0에 struct clone_args의 포인터, x1에 구조체의 크기가 들어갑니다.)
Code Snippet
GDB로 프로세스 생성 과정 디버깅하기
이제 생성한 파일을 과정을 디버깅해 보겠습니다. 먼저 gdb를 실행합니다.

"b main" 명령어로 main() 함수에 브레이크포인터(Break point)를 설정합니다. "r" 명령어를 입력해서 gdb 프로그램을 실행합니다.

위 상태가 브레이크포인트에 멈춘 상태이며, 여기서부터 gdb로 디버깅할 수 있습니다. "list"명령어로 소스코드를 확인합니다.

"n"명령어로 소스코드를 라인단위로 확인할 수 있고, "s"명령어로 함수 내부로 진입할 수 있습니다. "n"을 한번 입력하면 fork() 코드로 이동하고, fork() 부분에서 "s" 명령어를 눌러 함수 내부로 진입합니다.

결과가 생각하고 다르게 No such file or directory라고 표시됩니다. __libc_fork() 함수는 sysdeps/nptl/fork.c 에 있는데 현재 gdb환경에서 sysdeps/nptl/fork.c 소스코드가 없다고 해석하면 됩니다.
보통 gdb에서 라이브러리 파일에 내부에 진입해서 디버깅 시 볼 수 있는 메시지이며, 이 라이브러리를 컴파일한 소스코드 위치를 알려줍니다.
라이브러리는 C형식의 소스코드는 없지만 어셈블리 코드는 확인할 수 있습니다. "layout asm" 명령어를 입력해 gdb의 소스코드 출력 설정을 어셈블리 코드 형식으로 바꿉니다. (다시 C 형식으로 바꾸려면, "layout src"라고 하면 됩니다.)
상단 ">" 0xb6ef43e4 주소 부분이 브레이크포인트가 걸린 위치입니다.

조금 복잡해지긴 하는데요, 어셈블리 코드의 실행 흐름을 디버깅하려면 ARM 레지스터가 어떻게 바뀌는지도 알아야 합니다. 추가적으로 "layout reg" 명령어를 입력하면 레지스터 정보도 나타납니다.
lr(r14)는 복귀 레지스터이며 0x104d4로 되어 있습니다.

"nexti"명령어를 입력해서 어셈블리 명령어 단위로 단계별 실행되는 것을 확인할 수 있습니다. push 명령이 실행된 후 sp(r13)와 pc레지스터가 변경된 것을 알 수 있습니다.

소프트웨어 인터럽트를 발생시켜 시스템 콜을 실행하기 직전 코드에 브레이크포인터를 겁니다. "svc 0x00000000" 코드의 명령어가 실행되면 유저 모드에서 커널 모드로 스위칭되는 것입니다.

위 내용을 토대로 시스템콜을 실행하기 직전 어떤 인자로 시스템 콜을 호출하는지 확인할 수 있습니다.
(svc 0x00000000 지점에서 nexti 진행하지 하지 않고 마우스 스크롤로 내려보면 다음 코드를 볼 수 있습니다.)
0xb6ef442c ... fork+72 줄을 보면 ldr명령어로 0xb6ef45a0 주소의 값을 r0 레지스터에 저장합니다. 0xb6ef45a0 주소를 보면 0x01200011이 있습니다.
0x01200011 값은 이전 내용에서 한번 다루었습니다. 매크로를 OR 연산한 값입니다. 그럼 어떤 값인가 하면, 아래와 같습니다. 즉, 자식프로세스의 스레드 아이디를 설정하고 자식 프로세스를 생성한다는 플래그입니다.


0x01200011 = CLONE_CHILD_SETTID | CLONE_CHILD_CLEARTID | SIGCHLD

위의 내용의 0xb6ef4430 ... fork+76 줄을 좀 더 보면 r7레지스터에 120을 저장합니다. 이는 시스템 콜을 발생하기 전에 유저 공간에서 r7레지스트에 시스템 콜 번호를 지정합니다.
유저 공간에서 fork함수를 호출하면 커널 공간에서 sys_clone() 함수가 호출되는 이유입니다.
0xb6ef4434 ... fork+80 줄은 ARM 프로세서에서 소프트웨어 인터럽트를 발생하는 명령어입니다. svc는 Superviser Call을 의미하며 ARM 프로세서의 프로그램 카운터를 소프트웨어 인터럽트 백터인 vector_swi로 브랜치 합니다.
슈퍼바이저 모드로 스위칭하고 vector_swi 레이블을 실행합니다.
이 부분은 책에 나온 부분과 약간 소스코드가 다른 부분이 있습니다. 기능이 변경되었다는 것이 아니라 형식이 다르게 되어 있습니다.

svc 명령어를 실행하기 직전 유저 공간에서 실행 중인 레지스터는 커널 공간의 소프트웨어 인터럽트 백터인 vector_swi를 통해 sys_clone() 함수로 전달됩니다.

위에서 확인한 내용을 요약하자면,
- 유저공간에서 fork() 함수를 호출하면 r7레지스터에 시스템 콜 번호인 120 저장
- svc 0x00000000 명령어 실행
- 첫 번째 인자로 매크로 값 저장, 0x01200011
커널을 좀 더 깊게 알기 위해서는 어셈블리어(Assembly Language)에 대한 학습도 필요하다고 생각됩니다.
1.2 리눅스 유틸리티 프로그램을 이용한 실행 추적
ftrace를 이용해서 whoami 유틸리티 프로그램을 간단하게 추적해 보는 것입니다. 이미 ftrace는 이전 내용을 보셨다면 어렵지 않게 따라 할 수 있을 것입니다.
유저 공간에서 fork() 시스템 콜 함수를 호출하면 유저 프로세스가 실행된다고 이미 알고 있을 것입니다. 그런데 유저 프로세스를 생성하는 목적은 크게 2가지로 분류할 수 있습니다.
- fork() 시스템 콜 함수로 호출해 같은 작업을 프로그램을 여러 프로세스가 나눠서 실행
- exec() 시스템 콜 함수로 새로운 프로그램을 생성해서 실행
보통 첫 번째 방법을 대부분 방식을 이용하지만 이번에는 두 번째 방법인 이미 만들어 놓은 프로그램 파일을 실행할 때를 알아보겠습니다.
ftrace 로그 설정
기본적은 설정은 동일하고 이벤트 설정과 콜 스택을 지정하는 명령어 부분만 확인해 보겠습니다. ftrace_log 디렉터리를 하나 만들고 그 안에서 작업을 했습니다.
(1) ftrace 이벤트 설정 부분
ftrace 이벤트에서 sched_ 로 시작하면 보통 프로세스 스케줄링 동작을 출력하는 것을 예상할 수 있습니다. 그중에 프로세스 생성, 실행, 종료, 자원해제 한 동작을 추적하는 이벤트를 지원합니다. 이 이벤트를 활성화합니다.
sched_process_exit : 프로세스 생성
sched_process_fork : 프로세스 실행
sched_process_free : 프로세스 종료
sched_process_exec : 프로세스 자원(메모리, 태스크 디스크립터) 해제
(2) 함수의 콜 스택을 위한 필터 설정을 지정하는 명령 이벤트 설정 부분
이름을 포함하는 함수들의 호출 정보 (진입 시점과 반환 시점)를 기록합니다. 즉, 콜 스택을 보기 위해서 필터를 지정합니다.
sys_clone: 새로운 프로세스 (또는 스레드)를 생성하는 시스템 콜입니다.
do_exit: 현재 프로세스를 종료시키는 커널 함수입니다.
search_binary_handler.part.*: 실행 가능한 바이너리 파일을 찾고 해당 형식에 맞는 로더를 호출하는 함수입니다. exec() 계열 시스템 콜 처리 과정에서 중요한 역할을 합니다.
책에 있는 search_binary_handler 하면 로그 파일에 함수가 나타나지 않습니다. 아래 명령어로 ftrace가 추적할 수 있는 함수 목록을 확인하여 search_binary_handler와 유사한 이름이 있는지 찾아봅니다. 그러면 search_binary_handler.part.x 가 있다는 것을 확인할 수 있습니다.
copy_process.part.*: 새로운 프로세스를 생성하는 핵심 함수인 copy_process 함수의 특정 부분입니다. 새로운 프로세스를 생성하는 복잡한 과정을 수행합니다.
이 과정을 효율적으로 관리하고 코드를 모듈화 하기 위해 개발자들은 이 함수를 여러 개의 작은 부분으로 나눌 수 있습니다. .part 뒤의 숫자는 이러한 세부 단계를 나타내는 일종의 인덱스라고 볼 수 있습니다.
책에서 copy_process.part.5로 하면 에러가 발생합니다. 사용하는 라즈베리파이 커널버전에 따라 다를 수 있으니 *로 하신 후 확인 log파일에서 part. 뒤를 확인하시면 됩니다.


ftrace 로그 분석
위에서 실행한 ftrace의 로그를 파일에는 whoami 명령어를 입력했을 때 프로세스 실행 정보가 있습니다.

26~31번 줄은 이전 글에서 본 것처럼 _do_fork 함수를 호출해 프로세스를 생성하는 동작입니다.
33번 줄은 sched_process_fork 이벤트로 프로세스가 생성한 세부 정보를 알 수 있습니다. pid가 832인 bash 프로세스가 pid가 1354인 프로세스를 생성한다는 의미입니다. 생성하는 프로세서의 이름이 부모 프로세스의 이름과 같지만 프로세스 생성이 끝난 시점에는 자신만의 이름을 갖습니다. 복제되는 단계에서 부모이름과 리소스를 복제합니다.
34~39번 줄은 콜스택으로 39번 줄에서 호출방향으로 생각하면 됩니다. 38번 줄의 execve( ) 함수는 execve 시스템 콜의 핸들러 함수 이므로 유저 공간에서 execve POSIX 시스템 콜 발생했다는 사실을 알 수 있습니다. 이는 fork( ) 시스템 콜 이후에 execve( ) 시스템 콜을 유저 공간에서 실행했다는 사실을 알 수 있습니다.
41번 줄은 sched_process_exec 이벤트를 통해 프로세스의 실행 정보를 출력한 것입니다. 이것은 실행을 시작한 pid가 1354인 whoami 프로세스가 /usr/bin/whoami 인 것으로 알 수 있습니다.
sys_execve() 함수가 호출된 후 다음 함수 흐름으로 exec_binprm() 함수가 호출됩니다.
1700번 줄에서 whoami 프로세스가 실행을 시작하는 동작을 ftrace의 sched_process_exec 이벤트로 출력합니다. /usr/bin/whoami 자체는 프로세스가 아닙니다. 커널이 이 파일을 메모리에 적제 해서 실행할 때가 프로세스입니다.

다시 ftrace의 42~47번 줄을 보면 종료되는 로그입니다. root라는 결과를 출력하고 프로세스가 바로 종료한 것입니다. 이것은 유저 공간에서 exit() 함수를 호출하면 실행되는 코드의 흐림입니다.
마지막으로 46번 줄에 실행주소 오프셋이 __wake_up_parent+0x0 인 부분이 있습니다. 이것은 이전 내용에 이야기한 것처럼 한번 더 확인 이 필요합니다.
ARM 아키텍처에서는 파이프라인을 적용하기 때문에 실제로 실행된 코드 주소에서 +0x04만큼 떨어진 주소를 프로그램 카운터로 저장합니다.
실제 어셈블리 코드를 보면 sys_exit_group()+0xc가 됩니다. 그렇기 때문에 sys_exit_group()의 마지막 코드에서 do_group_exit() 함수를 호출합니다.
책에서는 cat 명령어도 분석하지만, 내용이 비슷하기 때문에 넘어가도록 하겠습니다. 그리고 책 내용이 pi 3로 제작되었기 때문에 조금 확인해 가며 봐야 할 부분들이 있는 것 같습니다.
감사합니다.
<참고 자료>
1. [도서] 디버깅을 통해 배우는 리눅스 커널의 구조와 원리 p259 ~269 , wikibook
2. Blog: https://remnant24c1.tistory.com/537#google_vignette
'Embedded : : Linux > : : Linux Kernel' 카테고리의 다른 글
| [인터럽트 하반부] 인터럽트 하반부와 IRQ 스레드 (0) | 2024.10.12 |
|---|---|
| FD 번호할당 alloc_fd() | RCU 로직 | expand_files (0) | 2024.10.12 |
| 커널 스레드 (0) | 2024.10.12 |
| [프로세스] current : 프로세스의 태스크 디스크립터에 접근하는 매크로 함수 (0) | 2024.10.12 |
| do_fork() 함수, 그리고 5.x 이후 (0) | 2024.10.12 |