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-over | next, n | 다음 줄, 함수 호출은 한 번에 실행 |
thread step-in | step, s | 함수 안으로 들어감 |
thread step-out | finish | 현재 함수가 반환될 때까지 |
thread until 42 | 42번 줄까지 (루프 탈출에 유용) | |
process continue | continue, 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 명령어 대응표
| 기능 | GDB | LLDB |
|---|---|---|
| 함수 브레이크포인트 | break main | b main / breakpoint set -n main |
| 파일:라인 | break main.cpp:15 | b main.cpp:15 |
| 조건부 | break main.cpp:20 if i == 50 | br s -f main.cpp -l 20 -c 'i == 50' |
| 조건 추가 | condition 1 i == 50 | br modify -c 'i == 50' 1 |
| N번 건너뛰기 | ignore 1 499 | br modify -i 499 1 |
| 목록 / 삭제 | info breakpoints / delete 1 | br list / br delete 1 |
| 워치포인트 | watch x | watchpoint set variable x (w s v x) |
| 주소 워치 | watch *(int*)0x1234 | w s e -- 0x1234 |
| 변수 보기 | print x | v x (보기만) / p x (표현식) |
| 지역 변수 전부 | info locals | v (인자 포함) |
| 배열 N개 | print *arr@10 | parray 10 arr |
| 메모리 | x/16xb ptr | memory read -c 16 -f x ptr (x/16xb도 지원) |
| 스택 | bt | bt |
| 모든 스레드 스택 | thread apply all bt | bt all |
| 프레임 이동 | frame 2 | f 2 |
| 레지스터 | info registers | register read |
| 역어셈블 | disassemble | disassemble --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 Runtime | DevToolsSecurity -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 기초
같이 보면 좋은 글
- C++ GDB 기초 | 브레이크포인트·워치포인트
- C++ 디버깅 기초 | GDB·LLDB 브레이크포인트·워치포인트·단계 실행
- C++ Segmentation fault | core dump
- C++ Sanitizers | ASan·TSan으로 메모리 버그·data race 자동 탐지
- VS Code C++ 설정 | IntelliSense·빌드·디버깅