macOS에서 LLDB로 C++ 디버깅하기: 브레이크포인트, 워치포인트, frame variable, GDB 명령 대응표

macOS에서 printf 디버깅의 한계

cout 100개를 찍어도 버그를 못 찾을 때

세그폴트 위치를 찾으려고 std::cout을 수십 개 넣어도, 출력이 버퍼링되어 크래시 직전 줄이 찍히지 않는 경우가 많습니다. LLDB는 macOS·iOS의 기본 디버거로, 멈춘 지점의 변수 값과 호출 스택을 직접 볼 수 있게 해 줍니다.

디버거가 필요한 전형적인 상황(배열 범위 초과 세그폴트, 널 포인터, 무한 루프, 어디선가 값이 덮어써지는 메모리 오염, 1000번 중 500번째에만 나는 버그)과 각 상황에 어떤 기능을 쓰는지는 GDB 기초 #4-1의 문제 시나리오에 정리해 두었습니다. 도구만 다를 뿐 접근은 같습니다. 이 글은 그 흐름을 LLDB 명령으로 옮기면서, LLDB와 macOS에서만 다른 부분에 집중합니다.

  • macOS의 디버거 권한과 코드 서명 (가장 먼저 부딪히는 벽)
  • frame variable과 expression의 차이
  • 브레이크포인트·워치포인트·백트레이스의 LLDB 문법
  • dSYM과 릴리스 빌드 디버깅
  • GDB 명령어 대응표

요구 환경: LLDB(macOS: xcode-select --install로 설치되는 Command Line Tools에 포함, Linux: apt install lldb). 빌드 시 -g.


시작하기 전에: macOS의 디버거 권한

Linux에서 GDB를 쓰던 사람이 macOS에서 처음 LLDB를 쓰면, 명령어보다 권한 문제에서 먼저 막힙니다. macOS는 한 프로세스가 다른 프로세스의 메모리를 읽고 멈추는 것(태스크 포트 접근)을 보안상 제한하기 때문입니다.

디버거 사용 권한

Xcode를 설치하고 처음 디버깅할 때 관리자 암호를 묻는 대화상자가 뜨는 것이 이 권한입니다. SSH로 접속한 세션처럼 대화상자를 띄울 수 없는 환경에서는 디버깅이 조용히 실패합니다. 명령줄에서 미리 켜 둘 수 있습니다.

sudo DevToolsSecurity -enable          # 디버거 사용 허용
sudo dseditgroup -o edit -a "$USER" -t user _developer   # 현재 사용자를 _developer 그룹에 추가

CI 러너처럼 GUI 없이 LLDB로 테스트를 돌리는 환경에서 “attach failed”나 “process exited” 같은 애매한 에러가 난다면 이 설정부터 확인합니다.

Hardened Runtime과 get-task-allow

직접 빌드한 바이너리는 보통 ad-hoc 서명만 있어서 별 문제 없이 디버깅됩니다. 문제는 Hardened Runtime으로 서명된 바이너리입니다(공증을 위해 codesign --options runtime으로 서명한 배포용 빌드). Hardened Runtime은 기본적으로 디버거 접근을 막으므로, 디버깅하려면 com.apple.security.get-task-allow 권한을 넣어 서명해야 합니다.

<!-- debug.entitlements -->
<plist version="1.0"><dict>
  <key>com.apple.security.get-task-allow</key><true/>
</dict></plist>
codesign -s - --force --options runtime --entitlements debug.entitlements ./myapp

Xcode의 Debug 구성은 이 권한을 자동으로 넣고, Release/Archive 구성은 넣지 않습니다. 그래서 “Xcode에서는 디버깅되는데, 배포용으로 서명한 같은 앱에는 붙지 않는다”는 상황이 생깁니다. 공증(notarization)은 get-task-allow가 있는 바이너리를 거부하므로, 이 권한은 디버그 빌드에만 넣어야 합니다.

SIP로 보호되는 바이너리

/usr/bin, /System 아래의 시스템 바이너리는 SIP(System Integrity Protection)로 보호되어 디버거를 붙일 수 없습니다. 예를 들어 lldb /usr/bin/python3로 파이썬 확장 모듈을 디버깅하려 하면 실패합니다. Homebrew나 pyenv로 설치한 인터프리터를 쓰거나, 시스템 바이너리를 복사해 ad-hoc 서명한 사본을 쓰는 방식으로 우회합니다. 같은 이유로 SIP 보호 바이너리를 거쳐 실행되는 프로세스에서는 DYLD_LIBRARY_PATH 같은 DYLD_* 환경 변수가 지워집니다.


-g로 디버그 빌드하고 LLDB 시작하기

# 디버그 정보(-g), 최적화 끄기(-O0)
clang++ -g -O0 main.cpp -o myapp
# CMake
cmake -B build -DCMAKE_BUILD_TYPE=Debug && cmake --build build
lldb ./myapp
(lldb) run                     # 실행
(lldb) run arg1 arg2           # 인자와 함께
(lldb) settings set target.env-vars MY_CONFIG=debug   # 환경 변수
(lldb) process attach --name myapp   # 실행 중인 프로세스에 붙기 (1절의 권한 필요)
(lldb) quit

macOS에서 clang++ -g로 컴파일과 링크를 한 번에 하면 링크 단계에서 dsymutil이 호출되어 myapp.dSYM 디렉터리가 생깁니다. 오브젝트 파일을 따로 만들고 링크하는 빌드(CMake 등)에서는 디버그 정보가 .o 파일에 남아 있고 실행 파일은 그 경로를 참조만 합니다. 그래서 빌드 디렉터리를 지우거나 다른 머신으로 실행 파일만 옮기면 소스 줄과 변수가 보이지 않게 됩니다. 실행 파일을 옮겨야 한다면 dsymutil ./myapp으로 dSYM을 만들어 함께 옮깁니다(아래 dSYM 절).


breakpoint set으로 멈출 지점 정하기

(lldb) breakpoint set --name processData      # 함수
(lldb) b processData                          # 축약
(lldb) breakpoint set --file main.cpp --line 15
(lldb) b main.cpp:15
(lldb) breakpoint set --func-regex '^process' # 정규식으로 여러 함수
(lldb) breakpoint set --method update         # 모든 클래스의 update 메서드
# 조건부: i가 500일 때만 멈춤
(lldb) breakpoint set --file main.cpp --line 20 --condition 'i == 500'
# 이미 만든 브레이크포인트에 조건 추가
(lldb) breakpoint modify --condition 'ptr == nullptr' 2
# 처음 499번은 건너뛰기
(lldb) breakpoint modify --ignore-count 499 1
(lldb) breakpoint list
(lldb) breakpoint disable 2
(lldb) breakpoint enable 2
(lldb) breakpoint delete 1
(lldb) breakpoint delete          # 모두 삭제 (확인 질문)

조건식은 브레이크포인트에 도달할 때마다 평가되므로, 매우 자주 지나가는 줄에 복잡한 조건을 걸면 프로그램이 눈에 띄게 느려집니다. 반복 횟수로 충분하다면 --ignore-count가 조건식보다 가볍습니다.

LLDB에만 있는 편리한 기능은 브레이크포인트 명령입니다. 멈출 때마다 실행할 명령을 붙여 두면, 멈추지 않고 로그만 남기는 “로그 포인트”로 쓸 수 있습니다.

(lldb) breakpoint command add 1
> frame variable i value
> continue
> DONE

실습: 조건부 브레이크로 범위 초과 찾기

// bp_demo.cpp — clang++ -g -O0 -o bp_demo bp_demo.cpp
#include <iostream>
void fillArray(int* arr, int size) {
    for (int i = 0; i <= size; ++i) {  // 버그: <=
        arr[i] = i;
    }
}
int main() {
    int arr[5];
    fillArray(arr, 5);
    std::cout << "done\n";
}
$ lldb ./bp_demo
(lldb) breakpoint set --file bp_demo.cpp --line 5 --condition 'i == size'
(lldb) run
# i == 5에서 멈춤: 배열 크기와 같은 인덱스에 쓰려는 순간
(lldb) frame variable i size
(int) i = 5
(int) size = 5

스택 배열의 범위 초과는 이 예제처럼 크래시 없이 조용히 다른 변수를 덮어쓰는 경우가 많습니다. 디버거로는 “의심되는 조건”을 알아야 잡을 수 있으므로, 이런 버그는 AddressSanitizer(-fsanitize=address)로 먼저 돌려 보는 편이 빠릅니다(Sanitizers #16-2).


watchpoint로 값이 바뀌는 순간 잡기

(lldb) watchpoint set variable counter             # 쓰기 시 멈춤 (기본)
(lldb) watchpoint set variable -w read_write counter
(lldb) watchpoint set expression -- &arr[5]        # 주소로 지정 (expression은 주소를 받음)
(lldb) watchpoint set expression -s 4 -- ptr       # ptr이 가리키는 4바이트
(lldb) watchpoint list
(lldb) watchpoint delete 1

watchpoint set variable은 변수 이름을, watchpoint set expression은 주소로 평가되는 표현식을 받는다는 점이 GDB의 watch와 다릅니다. -w 같은 옵션은 변수 이름 앞에 둡니다.

// wp_demo.cpp — clang++ -g -O0 -o wp_demo wp_demo.cpp
#include <iostream>
void corruptData(int* arr) { arr[5] = 999; }
int main() {
    int arr[10] = {0};
    corruptData(arr);
    std::cout << arr[5] << "\n";
}
$ lldb ./wp_demo
(lldb) breakpoint set --name main
(lldb) run
(lldb) watchpoint set variable arr[5]
(lldb) continue
Watchpoint 1 hit:
old value: 0
new value: 999
(lldb) bt
* frame #0: wp_demo`corruptData(arr=0x...) at wp_demo.cpp:3
  frame #1: wp_demo`main at wp_demo.cpp:6

제한: 워치포인트는 CPU의 디버그 레지스터를 쓰는 하드웨어 기능이라 동시에 걸 수 있는 개수가 적습니다(x86-64는 4개, Apple Silicon도 몇 개 수준). 감시할 수 있는 크기도 한 번에 최대 8바이트 정도로 제한됩니다. x86-64 하드웨어는 “읽기 전용” 감시를 지원하지 않으므로 -w read는 플랫폼에 따라 거부되거나 읽기/쓰기로 동작합니다. 지역 변수에 건 워치포인트는 그 함수가 반환되면 의미가 없어지므로, 스택이 재사용되면서 엉뚱한 곳에서 멈추기 전에 지웁니다.


bt와 frame select로 호출 스택 보기

(lldb) bt                  # 현재 스레드의 호출 스택
(lldb) bt 5                # 위쪽 5개 프레임만
(lldb) bt all              # 모든 스레드
(lldb) frame select 2      # 2번 프레임으로 (f 2)
(lldb) up / down           # 한 프레임씩 이동
(lldb) frame info

thread backtrace --extended true는 지역 변수를 보여 주는 옵션이 아니라, macOS에서 GCD 큐 등을 통해 비동기로 넘어온 경우 작업을 큐에 넣은 쪽의 스택까지 이어서 보여 주는 옵션입니다. 각 프레임의 변수는 frame select N 후 frame variable로 봅니다.

// bt_demo.cpp — clang++ -g -O0 -o bt_demo bt_demo.cpp
void level3(int* p) { *p = 42; }   // p가 null이면 크래시
void level2(int* p) { level3(p); }
void level1(int* p) { level2(p); }
int main() { int* p = nullptr; level1(p); }
$ lldb ./bt_demo
(lldb) run
Process ... stopped
* thread #1, stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
(lldb) bt
* frame #0: bt_demo`level3(p=0x0000000000000000) at bt_demo.cpp:2
  frame #1: bt_demo`level2(p=0x0000000000000000) at bt_demo.cpp:3
  frame #2: bt_demo`level1(p=0x0000000000000000) at bt_demo.cpp:4
  frame #3: bt_demo`main at bt_demo.cpp:5
(lldb) frame select 3
(lldb) frame variable p
(int *) p = nullptr

macOS에서는 세그폴트가 Linux의 SIGSEGV 대신 Mach 예외 EXC_BAD_ACCESS로 표시됩니다. address=0x0이면 널 포인터, 0이 아닌 작은 값(예: 0x8)이면 널 포인터의 멤버에 접근한 경우입니다.


frame variable과 expression의 차이

LLDB에서 값을 보는 명령은 두 갈래이고, 이 차이를 아는 것이 GDB에서 넘어올 때 가장 중요합니다.

frame variable (v)expression (p, expr)
동작디버그 정보에서 값을 읽기만 함표현식을 컴파일해 대상 프로세스에서 실행
속도빠름느림 (Clang으로 컴파일)
가능한 것변수, 멤버(./->), 배열 인덱스, 역참조함수 호출, 연산, 캐스트, 대입
부작용없음호출한 함수가 상태를 바꾸거나 크래시할 수 있음
(lldb) v                          # 현재 프레임의 모든 지역 변수와 인자
(lldb) v p->x arr[3]              # 멤버, 배열 원소
(lldb) v *ptr
(lldb) p vec.size()               # 함수 호출이 필요하면 expression
(lldb) p x * 2 + offset
(lldb) expr counter = 0           # 값 변경 (부작용 주의)

GDB에서는 거의 모든 것을 print로 하지만, LLDB에서는 보기만 할 때는 v를 기본으로 쓰는 편이 안전합니다. p로 getter를 호출했는데 그 getter가 지연 초기화를 하거나 락을 잡는다면, 디버깅하는 행위 자체가 프로그램 상태를 바꿉니다. 특히 멀티스레드 코드에서 다른 스레드가 잡고 있는 락을 요구하는 함수를 p로 호출하면 디버거가 멈춘 채 응답하지 않게 됩니다.

std::vector, std::string, std::map 같은 표준 컨테이너는 LLDB에 내장된 data formatter가 보기 좋게 풀어 줍니다(libc++ 기준). 원래 내부 구조를 보고 싶으면 v --raw vec을 씁니다.

(lldb) v vec
(std::vector<int>) vec = size=3 {
  [0] = 1
  [1] = 2
  [2] = 3
}
(lldb) parray 10 buf             # 포인터가 가리키는 원소 10개 (GDB의 *buf@10)
(lldb) v -f x flags              # 16진수로
(lldb) memory read -c 16 -f x ptr   # ptr부터 16바이트

GDB의 *arr@10 문법은 LLDB에서 동작하지 않습니다. 대신 parray(포인터 배열), v arr(고정 크기 배열은 그대로 펼쳐짐), memory read를 씁니다.


step·next·finish 단계 실행

명령축약동작
thread step-overnext, n다음 줄, 함수 호출은 한 번에 실행
thread step-instep, s함수 안으로 들어감
thread step-outfinish현재 함수가 반환될 때까지
thread until 4242번 줄까지 (루프 탈출에 유용)
process continuecontinue, c다음 브레이크포인트까지

step은 기본적으로 디버그 정보가 없는 함수와 std:: 네임스페이스 함수에는 들어가지 않습니다(target.process.thread.step-avoid-regexp 기본값이 ^std::). std::sort에 넘긴 비교 함수로 들어가고 싶은데 step이 건너뛰는 것은 이 설정 때문이며, 비교 함수에 브레이크포인트를 거는 편이 간단합니다.


릴리스 빌드와 dSYM 심볼 파일

크래시 리포트를 분석하려면 그 바이너리와 정확히 같은 빌드의 dSYM이 있어야 합니다. dSYM은 UUID로 바이너리와 짝을 이룹니다.

# 릴리스 빌드에서 dSYM 생성 후 바이너리의 디버그 정보 제거
clang++ -g -O2 -c main.cpp -o main.o
clang++ main.o -o myapp
dsymutil myapp -o myapp.dSYM
strip -S myapp                       # 디버그 심볼 제거 (배포용)

# 짝이 맞는지 UUID로 확인
dwarfdump --uuid myapp myapp.dSYM

LLDB는 바이너리 옆에 있는 dSYM이나 Spotlight로 찾을 수 있는 dSYM을 자동으로 로드합니다. 찾지 못하면 직접 추가합니다.

(lldb) target create ./myapp
(lldb) target symbols add ./myapp.dSYM

-O2 빌드는 변수가 “optimized out”으로 표시되고 줄 이동이 들쭉날쭉합니다. 릴리스에서만 나는 버그를 추적할 때는 -O2 -g(CMake의 RelWithDebInfo)로 빌드해 최적화는 유지하면서 디버그 정보를 남깁니다.

Linux 서버의 core dump도 LLDB로 열 수 있습니다(lldb ./myapp -c core.12345). core dump 수집 설정과 분석 순서는 세그폴트 디버깅 #49-1에 있습니다.


LLDB와 GDB 명령어 대응표

기능GDBLLDB
함수 브레이크포인트break mainb main / breakpoint set -n main
파일:라인break main.cpp:15b main.cpp:15
조건부break main.cpp:20 if i == 50br s -f main.cpp -l 20 -c 'i == 50'
조건 추가condition 1 i == 50br modify -c 'i == 50' 1
N번 건너뛰기ignore 1 499br modify -i 499 1
목록 / 삭제info breakpoints / delete 1br list / br delete 1
워치포인트watch xwatchpoint set variable x (w s v x)
주소 워치watch *(int*)0x1234w s e -- 0x1234
변수 보기print xv x (보기만) / p x (표현식)
지역 변수 전부info localsv (인자 포함)
배열 N개print *arr@10parray 10 arr
메모리x/16xb ptrmemory read -c 16 -f x ptr (x/16xb도 지원)
스택btbt
모든 스레드 스택thread apply all btbt all
프레임 이동frame 2f 2
레지스터info registersregister read
역어셈블disassembledisassemble --frame
설정 파일~/.gdbinit~/.lldbinit

LLDB의 긴 명령은 명사 동사 옵션 구조(breakpoint set --name)라서 처음에는 장황하지만, 앞글자만으로 축약이 되므로(br s -n) 익숙해지면 GDB와 타이핑 양이 비슷합니다. help <명령>과 apropos <단어>로 명령을 찾을 수 있습니다.

어느 쪽을 쓸까: macOS에서는 GDB가 코드 서명 문제로 설정이 번거롭고 Apple Silicon을 지원하지 않으므로 LLDB가 사실상 유일한 선택입니다. Linux에서는 GCC로 빌드한 코드라면 GDB의 지원이 더 성숙하고, Clang/libc++ 기반이면 LLDB도 잘 동작합니다.


attach failed·optimized out 등 에러 메시지별 해결

에러원인해결
소스 줄·변수 이름이 안 보임-g 없이 빌드, 또는 .o/dSYM을 찾지 못함-g로 재빌드, dsymutil로 dSYM 생성 (디버그 빌드·dSYM 절)
error: attach failed / process exited with status -1디버거 권한 없음, Hardened RuntimeDevToolsSecurity -enable, get-task-allow 권한 (디버거 권한 절)
EXC_BAD_ACCESS (address=0x0)널 포인터 역참조bt → 프레임 이동 → v
<variable not available> / optimized out-O2 이상-O0 -g 또는 RelWithDebInfo, register read
watchpoint set 실패하드웨어 워치포인트 개수·크기 초과기존 워치포인트 삭제, 8바이트 이하로
p 명령이 멈추고 응답 없음호출한 함수가 다른 스레드의 락을 기다림v로 보기, expr --timeout 지정
run 직후 정상 종료되어 버그를 못 봄자식 프로세스에서 버그 발생자식 프로세스에 process attach --name <이름> --waitfor

마지막 항목에 대해 덧붙이면, LLDB에는 target.process.follow-fork-mode 설정이 있지만 Linux와 FreeBSD에서만 동작하고 macOS에서는 지원되지 않습니다. macOS에서는 다른 터미널에서 --waitfor로 자식 프로세스 이름을 기다렸다가 붙는 방법이 일반적입니다.


~/.lldbinit 설정과 원격 디버깅

~/.lldbinit에 자주 쓰는 설정을 둡니다.

settings set target.x86-disassembly-flavor intel
settings set stop-disassembly-display no-debuginfo
command alias bfl breakpoint set -f %1 -l %2

원격 디버깅: 대상 머신에서 lldb-server를 띄우고 개발 머신에서 연결합니다.

# 대상 (Linux)
lldb-server platform --listen "*:1234" --server
# 개발 머신
(lldb) platform select remote-linux
(lldb) platform connect connect://192.168.1.100:1234
(lldb) target create ./myapp
(lldb) run

디버거를 붙이면 프로세스가 멈추므로, 운영 중인 서비스에는 붙이지 않고 core dump를 수집해 오프라인에서 분석하는 편이 안전합니다.


LLDB 정리

도구용도
디버거 권한DevToolsSecurity, get-task-allow, SIP 바이너리는 불가
breakpoint set조건·반복 횟수·명령을 붙여 필요한 순간에만 멈춤
watchpoint set값이 바뀌는 순간 멈춤 (하드웨어 개수 제한)
bt / frame select호출 경로와 각 프레임의 상태
v vs p보기만 할 때는 v, 계산·호출이 필요할 때만 p
dSYM바이너리와 UUID로 짝, 릴리스 크래시 분석에 필수

이전 글: [C++ 실전 가이드 #4-1] GDB 기초


같이 보면 좋은 글