native 크래시를 처음 만났을 때 가장 막막했던 건 어디서 났는지가 안 보인다는 점이었다.
Java/Kotlin이었으면 NullPointerException이 어느 파일 몇 번 라인에서 났는지 stack trace에 다 찍힌다.
native는 signal 11 (SIGSEGV) 같은 시그널 번호와 메모리 주소만 있었다.
NDK 코드를 직접 안 짜도 이 로그는 만난다.
의존하는 라이브러리가 내부적으로 native면 거기서 터진 게 시그널로 올라온다.
이번 편은 그 시그널 종류를 정리해본다.
시그널이 뭔지부터
native 크래시는 전부 시그널로 옴.
그래서 시그널이 뭔지부터 봄.
시그널은 Unix 계열 OS가 프로세스에게 뭔가 잘못됐다고 알리는 메커니즘임.
Android도 Linux 기반이라 같은 메커니즘을 씀.
native 코드에서 잘못된 일이 일어나면 OS가 시그널을 보냄.
시그널이 처리되지 않으면 프로세스가 종료됨.
우편함에 꽂히는 통지서에 가까움. OS가 통지서를 꽂고, 응답이 없으면 강제 종료시킴.
예외 처리가 안됨.
시그널 자체를 잡아서 하는 방법도 있지만, 대부분 시그널이 나오는 문제는 어차피 앞에 하나 잡는다고 해결이 안되는 경우가 많음.
Android의 debuggerd(크래시 처리 데몬)가 치명적 크래시로 잡는 시그널은 여섯 가지임.
SIGSEGV(11)는 잘못된 메모리 접근임.SIGABRT(6)는 의도적 종료임.abort()가 호출된 것임.SIGBUS(7)는 메모리 정렬이나 매핑 오류임.SIGILL(4)는 잘못된 명령어임.SIGFPE(8)는 산술 예외임.SIGTRAP(5)는 디버거 트랩임.
가장 자주 만나는 건 SIGSEGV와 SIGABRT임.
나머지는 특정 조건에서 등장함.
시그널 번호만 보고 어느 범주 사고인지 좁힐 수 있으면 분석 시간이 줄어듦.
하나씩 보자.
SIGSEGV: 잘못된 메모리 접근
가장 흔한 native 크래시임.
프로세스가 접근 권한이 없는 메모리 주소를 읽거나 쓰려고 할 때 발생함.
logcat에는 이렇게 찍힘.
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
code 값으로 더 세분화됨.
SEGV_MAPERR는 매핑되지 않은 주소 접근임. NULL 역참조가 대표적임.
SEGV_ACCERR는 매핑은 있지만 권한이 없는 경우임. 읽기 전용 메모리에 쓰기가 대표적임.
자주 만드는 코드 패턴은 이런 것들임.
// 패턴 1: NULL 역참조
Foo *p = NULL;
p->value = 1; // SEGV_MAPERR, fault addr 0x0
// 패턴 2: dangling pointer
free(buf);
buf[0] = 'x'; // SEGV. 해제된 메모리 접근
// 패턴 3: 배열 경계 위반
char arr[10];
arr[100] = 'x'; // SEGV. 스택 영역 침범 시
// 패턴 4: 수명이 끝난 메모리 반환
char* get_data() {
char buf[100];
return buf; // 함수 종료 후 buf 수명도 끝남
}
같은 시도가 Java/Kotlin에서는 NullPointerException이나 ArrayIndexOutOfBoundsException이 됨.
JVM이 접근 전에 검사하고 예외를 던지기 때문임.
native는 그 검사가 없고 OS 레벨에서 시그널로 잡힘.
SIGABRT: 의도적 종료
프로세스가 스스로 abort()를 호출했을 때 발생함.
보통 뭔가 잘못된 걸 감지한 코드가 의도적으로 죽는 경우임.
signal 6 (SIGABRT), code -6 (SI_TKILL)자주 만나는 SIGABRT 원인은 여러 가지임.
- 코드가
abort()를 직접 호출한 경우 - 디버그 빌드에서
assert()가 깨진 경우 - 스택 카나리 검증이 실패해
__stack_chk_fail()이 호출된 경우 - C++ 예외가 안 잡혀
std::terminate()가 호출된 경우 strcpy같은 함수가 경계 위반을 감지한 경우- AddressSanitizer가 메모리 오류를 감지하고 abort한 경우
- bionic 내부 검사가 double free나 heap 손상을 감지한 경우
SIGABRT는 logcat에 왜 abort 했는지 메시지가 함께 나올 때가 많음.
Abort message: 'stack corruption detected'
Abort message: 'free(): invalid pointer'Java/Kotlin으로 치면 uncaught exception과 비슷한 위치.
다만 native는 시스템이나 런타임이 감지한 오류가 더 많고, 메시지가 짧음.
SIGBUS: 정렬 / 매핑 오류
SIGSEGV와 비슷하지만 다름.
주소 자체는 유효한데 다른 이유로 접근에 실패한 경우.
signal 7 (SIGBUS), code 1 (BUS_ADRALN), fault addr 0x...원인은 크게 둘.
하나는 정렬 오류(BUS_ADRALN)임. 일부 프로세서는 정렬되지 않은 주소에서 특정 작업을 거부함.
바이트 버퍼를 (uint32_t*)로 캐스팅해 읽을 때 자주 발생함.
다른 하나는 매핑은 됐지만 물리적으로 접근이 불가능한 경우임. mmap한 파일이 잘려 페이지가 비어 있는 경우 등임.
정렬 오류 패턴은 이런 형태.
uint8_t buf[10];
uint32_t value = *(uint32_t*)(buf + 1); // buf+1은 4의 배수 아님
SIGBUS는 아키텍처에 따라 동작이 갈림.
구형 ARM이나 atomic 명령어는 unaligned access에서 SIGBUS를 냄.
반면 ARM64에서는 SIGBUS가 안 나는 경우가 많음.
다만 atomic 연산이나 SIMD 명령어는 ARM64에서도 정렬을 요구함.
그래서 x86 에뮬레이터에서 잘 돌던 코드가 구형 ARM 실기기에서 SIGBUS로 죽기도 함.
JVM이 메모리 정렬을 추상화해 신경 안 써도 됐던 영역인데, native는 프로세서가 직접 드러남.
SIGILL: 잘못된 명령어
CPU가 해석할 수 없는 명령어를 실행하려 할 때 발생함.
signal 4 (SIGILL), code 1 (ILL_ILLOPC)자주 만드는 패턴은 이런 것들임.
- 함수 포인터가 잘못된 곳을 가리켜 데이터 영역으로 점프한 경우
- 스택이 손상돼 리턴 주소가 깨지고 코드 아닌 영역으로 복귀한 경우
armeabi-v7a로 빌드한 코드가 호환 안 되는 옛 디바이스에서 실행된 경우
상대적으로 드물지만 만나면 뭔가 크게 잘못됐다는 신호.
메모리 손상 이후 연쇄적으로 나타나기도 해서 원인 추적이 까다로울 때가 있음.
SIGFPE: 산술 예외
산술 연산 중 CPU가 처리할 수 없는 연산이 발생했을 때 나옴.
signal 8 (SIGFPE), code 1 (FPE_INTDIV)자주 만드는 패턴은 정수 연산 쪽임.
int z = x / 0; // 0으로 나누기
int n = INT_MIN;
int r = n / -1; // 결과가 INT_MAX + 1이라 int 범위 초과
이름이 Floating-Point Exception이지만 실제로는 정수 연산 오류에서도 발생함.
부동소수점 연산은 보통 NaN이나 Infinity로 처리돼 시그널까지 안 감.
SIGFPE는 아키텍처별 동작 차이가 큼.
x86에서는 int / 0이 즉시 SIGFPE로 죽음.
반면 ARM 계열에서는 SDIV/UDIV 명령이 0으로 나누기를 0을 반환하는 방식으로 처리함.
시그널이 안 나고 0이 그냥 결과로 나옴.
그래서 같은 코드를 x86 에뮬레이터에서 돌리면 SIGFPE, ARM 실기기에서 돌리면 조용히 0임.
하지만 아키텍처에 따라 다르니 코드에서 확실하게 처리하는게 안전함.
SIGTRAP: 디버거 트랩
대부분 디버거 사용 중에 나오지만 가끔 release 빌드에서도 봄.
흔한 시그널은 아님.
만나면 디버거가 attach돼 있는지, 라이브러리가 trap을 의도적으로 넣었는지 확인하면 됨.
시그널이 아닌 종료도 있음
위는 OS가 시그널로 보낸 프로세스 종료들임.
native 작업에서 만나는 비정상 종료에는 시그널 아닌 것들도 있음.
셋을 같이 의식해야 함.
- ANR(Application Not Responding): 메인 스레드가 5초 이상 블로킹되면 시스템이 앱 응답 없음으로 판단해 종료함. 무거운 JNI 호출을 메인 스레드에서 하거나, JNI 안에서 락을 오래 대기하면 ANR로 이어짐. 이거는 아마 MainTread 에서 많은걸 처리하던 초기 안드로이드 개발자라면 많이 보던 문제일 것.
- Low Memory Kill임: 시스템 메모리가 부족하면 Android의
lmkd가 우선순위 낮은 프로세스부터 죽임. native heap 누수나 mmap 누수가 쌓이면 결국 lmkd가 죽임. 직접적인 크래시 시그널이 없어 원인 추적이 어려움. - Java 측 예외로 인한 종료: native에서 Java 측 예외가 발생하면 Java 레이어로 전파돼 uncaught exception으로 앱이 죽을 수 있음.
도구가 일부러 내는 크래시도 있음
위 시그널들은 코드가 문제를 일으켜 발생한 것들임.
추가로 도구가 문제를 감지하고 일부러 abort시키는 경우가 있음.
디버그 빌드에서만 켜짐.
- AddressSanitizer(ASan)가 heap overflow, use-after-free, double free 같은 메모리 오류를 접근 시점에 잡아 abort시킴.
- UndefinedBehaviorSanitizer(UBSan)가 undefined behavior를 런타임에 감지해 abort시킴.
- FORTIFY_SOURCE가
strcpy같은 표준 함수의 경계 위반을 감지해 abort시킴.
셋 다 SIGABRT로 종료되지만, logcat에 왜 abort 했는지 상세 정보가 찍힘.
이런 의도적 abort는 오히려 디버깅에 유리함.
원인 시점에 즉시 죽으니, 증상이 한참 뒤에 나타나는 경우보다 추적이 쉬움.
각 도구를 어떻게 켜고 쓰는지는 3편에서 다룸.
시그널 분류표
여기까지를 한 장으로 정리하면 이렇게 됨.
| 시그널 | 의미 | 자주 보는 원인 |
|---|---|---|
| SIGSEGV | 잘못된 메모리 접근 | NULL deref, dangling pointer, OOB |
| SIGABRT | 의도적 종료 | __stack_chk_fail, ASan 검출, double free |
| SIGBUS | 정렬/매핑 오류 | ARM에서 unaligned access, mmap 오류 |
| SIGILL | 잘못된 명령어 | 함수 포인터 손상, 호환 안 되는 CPU 명령 |
| SIGFPE | 산술 예외 | 정수 0 나누기, INT_MIN / -1 |
| SIGTRAP | 디버거 트랩 | 디버거 사용 중 |
시그널이 아닌 종료는 ANR, Low Memory Kill, Java 측 예외 셋임.
이 분류는 4편에서 크래시를 추적할 때 첫 단서가 됨.
시그널 번호 하나로 어느 범주 사고인지 좁히는 게 추적의 시작임.
마무리 메모
native 크래시 종류를 보면 이 어려운걸 왜 쓰나 싶어진다. 내가 봐도 그렇다. 근데 단순한 앱 개발이 아닌 기능 개발을 하다보면 필수적으로 쓰게되는게 native다. 시그널은 어차피 검색하면 다 나오니까 외울 필요는 없다. 그냥 한 번 읽고 시그널이 보통 어떤 케이스에 나오고, 어떻게 접근하면 되는지 이해만 해두면 된다.
'모바일' 카테고리의 다른 글
| Android NDK 입문 (4) - native 크래시 디버깅 방법, addr2line과 Ghidra (0) | 2026.05.24 |
|---|---|
| Android NDK 입문 (3) - AddressSanitizer, 스택 카나리 알고 네이티브 크래시 미리 막기 (0) | 2026.05.24 |
| Android NDK 입문 (1) - NDK를 왜 쓰는가, JNI와 네이티브 개발 기초 (0) | 2026.05.23 |
| Google Play 데이터 보안: AdMob 사용 시 "기기 또는 기타 ID 선언되지 않음" 경고 해결 (0) | 2026.05.23 |
| Android 개발자 인증 정리 - Play Console 등록 가이드 (0) | 2026.04.19 |
