C 제어문·함수·배열: off-by-one 을 ASan 으로 잡기
C 강좌 2. Apple clang 21 로 실행: if·반복문·함수·배열, = 와 == 경고, 배열이 포인터가 되는 원리와 sizeof 함정, 범위 밖 쓰기를 ASan 으로 찾기.
시리즈: C 강좌 2/6 (초급). 1강을 마쳤거나 hello world 정도는 컴파일할 수 있는 분을 위한 글입니다. 다 읽으면 if, 반복문, 직접 만든 함수를 쓰고, 배열을 함수에 제대로 넘기고, C 에서 가장 흔한 배열 버그인 "한 칸 더 쓰기"를 몇 시간이 아니라 몇 초 만에 찾을 수 있습니다.
백준 같은 코딩 테스트 사이트에 C 로 제출했는데 내 컴퓨터에서는 맞는 답이 나오고 채점에서는 "런타임 에러"나 "틀렸습니다"가 나온다면, 원인의 상당수가 이 글에서 다루는 배열 범위 밖 접근입니다. 자바라면 ArrayIndexOutOfBoundsException 으로 멈췄을 일을 C 는 그냥 계속 실행하고, 그다음 무슨 일이 일어날지는 컴파일 옵션에 달려 있습니다. 이 글은 그것을 말 그대로 보여 줍니다. 버그 하나, 빌드 네 가지, 결과 네 가지. 모든 코드는 Apple Silicon Mac(M1 Pro), macOS 26, Apple clang 21.0.0 에서 늘 -std=c17 -Wall -Wextra 로 컴파일해 실행했고, 출력 속 경로는 ~/demo/ 로 바꿨습니다.
if·반복문·함수를 프로그램 하나로
작은 성적표 프로그램입니다. 이 절에서 설명할 요소가 모두 들어 있습니다.
#include <stdio.h>
#define N 5
double average(const int scores[], int n) {
int sum = 0;
for (int i = 0; i < n; i++)
sum += scores[i];
return (double)sum / n;
}
char grade(int score) {
if (score >= 90) return 'A';
else if (score >= 80) return 'B';
else if (score >= 70) return 'C';
return 'F';
}
int main(void) {
int scores[N] = {88, 92, 79, 65, 95};
for (int i = 0; i < N; i++)
printf("student %d: %3d -> %c\n", i + 1, scores[i], grade(scores[i]));
printf("average: %.1f\n", average(scores, N));
int i = 0;
while (i < N && scores[i] < 90) /* first student with an A */
i++;
if (i < N) printf("first A: student %d\n", i + 1);
return 0;
}
student 1: 88 -> B
student 2: 92 -> A
student 3: 79 -> C
student 4: 65 -> F
student 5: 95 -> A
average: 83.8
first A: student 2
용어를 한 번씩 짚고 갑니다.
#define N 5는 전처리기 상수입니다. 컴파일 전에 모든N이5로 바뀝니다.int scores[N]은 배열입니다. int 5 개가 메모리에 나란히 놓이고, 인덱스는0부터4까지입니다.- 함수는 반환 타입(
double,char), 이름, 매개변수를 가집니다.const int scores[]는 "이 함수는 배열을 바꾸지 않는다"는 약속입니다. for (초기화; 조건; 증감)은 조건이 참인 동안 반복하고,while은 조건만 봅니다.&&는 "그리고"이며 단락 평가를 합니다.i < N && scores[i] < 90에서i가 5 가 되면 오른쪽은 평가하지 않으므로scores[5]를 읽지 않습니다. 좌우를 바꾸면 버그가 됩니다.
컴파일러가 경고해 주는 버그 네 개
아래 파일은 컴파일도 되고 실행도 됩니다.
#include <stdio.h>
int sign(int x) {
if (x > 0) return 1;
if (x < 0) return -1;
} /* forgot: return 0 */
int main(void) {
int n = 5;
if (n = 0) /* meant n == 0 */
printf("n is zero\n");
printf("n is now %d, sign(n) = %d\n", n, sign(n));
int a[3] = {1, 2, 3};
a[3] = 4; /* index 3 does not exist */
switch (n) {
case 0: printf("zero\n");
case 1: printf("one\n"); /* fall through */
}
return 0;
}
clang 경고(각각 첫 줄만):
traps.c:6:1: warning: non-void function does not return a value in all control paths [-Wreturn-type]
traps.c:10:11: warning: using the result of an assignment as a condition without parentheses [-Wparentheses]
traps.c:10:11: note: use '==' to turn this assignment into an equality comparison
traps.c:15:5: warning: array index 3 is past the end of the array (that has type 'int[3]') [-Warray-bounds]
3 warnings generated.
실행 결과:
n is now 0, sign(n) = 0
zero
one
n = 0 은 대입이라 if 는 거짓이 되고 n 값도 망가졌습니다. sign(0) 은 0 을 출력했지만 이건 순전히 운입니다. 반환 값 없이 함수 끝에 도달한 뒤 그 값을 쓰는 것은 정의되지 않은 동작이고, 반환 레지스터에 남아 있던 값이 그대로 나옵니다. 실제로 이 파일의 초안(main 에 반복문이 하나 더 있던 버전)은 sign(n) = 1, 위 파일을 -O2 로 빌드하면 sign(n) = -1 이 나왔습니다. 빌드 셋, 답 셋입니다. 그리고 switch 는 break 가 없어서 다음 case 로 흘러내려(fall through) zero 와 one 을 둘 다 찍었습니다. 이 경고는 -Wall -Wextra 에 들어 있지 않습니다. -Wimplicit-fallthrough 를 더하면:
traps.c:19:5: warning: unannotated fall-through between switch labels [-Wimplicit-fallthrough]
traps.c:19:5: note: insert 'break;' to avoid fall-through
의도한 흘러내림이라면 표시를 남깁니다. C23 에서는 [[fallthrough]];(clang 21 에서 -std=c23 으로 컴파일 확인), C17 에서는 clang 이 __attribute__((fallthrough)); 를 제안합니다.
-Wall -Wextra 로도 잡히지 않는 반복문 함정이 하나 더 있습니다.
for (unsigned i = 2; i >= 0; i--) /* i >= 0 is always true */
i=2
i=1
i=0
i=4294967295
i=4294967294
(5 줄에서 멈추게 했습니다.) unsigned 는 음수가 될 수 없으니 0 - 1 이 4,294,967,295 로 돌아갑니다. -Wtype-limits 나 -Wtautological-unsigned-zero-compare 를 켜야만 result of comparison of unsigned expression >= 0 is always true 가 나옵니다. 배열을 거꾸로 도는 코드에서 size_t 인덱스로 자주 만드는 실수입니다.
배열은 포인터가 된다: 함수 안의 sizeof 는 거짓말을 한다
#include <stdio.h>
void show(int arr[]) {
printf("inside show: sizeof(arr) = %zu\n", sizeof(arr));
}
int main(void) {
int a[5] = {1, 2, 3, 4, 5};
printf("in main: sizeof(a) = %zu\n", sizeof(a));
printf("elements: %zu\n", sizeof(a) / sizeof(a[0]));
show(a);
printf("a = %p\n", (void *)a);
printf("&a[0] = %p\n", (void *)&a[0]);
printf("&a = %p\n", (void *)&a);
printf("a + 1 = %p (+%td bytes)\n", (void *)(a + 1), (char *)(a + 1) - (char *)a);
printf("&a + 1 = %p (+%td bytes)\n", (void *)(&a + 1), (char *)(&a + 1) - (char *)a);
return 0;
}
sizeoftrap.c:4:54: warning: sizeof on array function parameter will return size of 'int *' instead of 'int[]' [-Wsizeof-array-argument]
in main: sizeof(a) = 20
elements: 5
inside show: sizeof(arr) = 8
a = 0x16f38e470
&a[0] = 0x16f38e470
&a = 0x16f38e470
a + 1 = 0x16f38e474 (+4 bytes)
&a + 1 = 0x16f38e484 (+20 bytes)
배열을 함수에 넘기면 C 는 첫 요소의 주소만 넘깁니다. 이것을 배열의 포인터 변환(array decay)이라고 합니다. 매개변수 목록의 int arr[] 는 int *arr 와 완전히 같은 뜻이라, show 안의 sizeof(arr) 는 포인터 크기인 8 입니다. 주소를 보면 차이가 보입니다. a, &a[0], &a 는 같은 주소를 찍지만 a + 1 은 int 하나(4 바이트), &a + 1 은 배열 전체(20 바이트)만큼 움직입니다. &a 의 타입이 "int 5 개짜리 배열을 가리키는 포인터"이기 때문입니다. 주소는 주소 공간 무작위화 때문에 실행할 때마다 달라집니다.
여기서 나오는 규칙: 함수는 배열 길이를 알아낼 수 없다. 길이는 매개변수로 넘깁니다.
double average(const int scores[], int n); /* C API 는 모두 이 모양 */
int n = (int)(sizeof a / sizeof a[0]); /* a 가 진짜 배열인 곳에서만 유효 */
off-by-one: 버그 하나, 결과 넷
가장 흔한 배열 버그는 한 칸 더 쓰는 것입니다.
#include <stdio.h>
int main(void) {
int guard = 7;
int a[5];
for (int i = 0; i <= 5; i++) /* <= should be < */
a[i] = i * 100;
printf("a[4]=%d guard=%d\n", a[4], guard);
return 0;
}
경고는 없습니다. 인덱스가 변수라서 상수 인덱스만 보는 -Warray-bounds 의 대상이 아닙니다. 네 가지로 빌드했습니다.
| 빌드 | 출력 | 종료 코드 |
|---|---|---|
clang -O0 (기본) |
Abort trap: 6 |
134 |
clang -O0 -fno-stack-protector |
a[4]=400 guard=500 |
0 |
clang -O2 |
a[4]=400 guard=7 |
0 |
clang -fsanitize=address -g |
stack-buffer-overflow ... offbyone.c:7 |
134 |
-O0에서 Apple clang 은 스택 보호 코드를 넣습니다(nm으로 실행 파일에___stack_chk_fail이 있는 것을 확인). 넘친 쓰기가 감시 값(canary)을 건드려main끝에서 비정상 종료했습니다. 줄 번호는 없습니다.- 스택 보호를 끄자
a[5]가 정확히guard자리에 들어가 조용히 500 이 됐습니다. 데이터는 망가졌는데 종료 코드는 0. 가장 나쁜 경우입니다. -O2에서는 정상처럼 보였습니다. 버그는 그대로이고 최적화 때문에 메모리 배치가 달라졌을 뿐입니다. "릴리스 빌드에서는 돌아간다"는 아무것도 증명하지 않습니다.- AddressSanitizer(ASan)는 첫 번째 잘못된 쓰기에서 멈췄습니다.
==74769==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x00016b9b63b4 ...
WRITE of size 4 at 0x00016b9b63b4 thread T0
#0 0x0001044489c0 in main offbyone.c:7
This frame has 1 object(s):
[32, 52) 'a' (line 5) <== Memory access at offset 52 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow offbyone.c:7 in main
위에서부터 읽으면 됩니다. 4 바이트 쓰기가 7번째 줄에서, a 의 20 바이트(오프셋 32~52) 바로 다음에 일어났다. 진단은 이게 전부입니다. ASan 은 메모리 접근마다 검사를 넣어 느려지지만(아래에서 측정), "가끔 이상하다"를 "항상 그 줄에서 멈춘다"로 바꿔 줍니다.
흔한 실수 → 실제로 일어난 일 → 해결
| 실수 | 실제로 본 것 | 해결 |
|---|---|---|
if (n = 0) |
조건은 거짓, n 은 덮어써짐 |
==. -Wparentheses 를 무시하지 않기 |
return 빠뜨림 |
sign(0) 이 빌드에 따라 0, 1, -1 |
모든 경로에서 return. -Wreturn-type |
break 없는 switch |
zero 와 one 둘 다 |
break;, 의도했다면 [[fallthrough]]; |
unsigned i; i >= 0; i-- |
0 을 지나 4294967295 | int 를 쓰거나 for (unsigned i = n; i-- > 0; ) |
함수 안 sizeof(arr) |
20 대신 8 | 길이를 매개변수로 |
a[N] 에 i <= N |
비정상 종료, 조용한 손상, "정상" | i < N. -fsanitize=address 로 테스트 |
ASan 비용은? (실측)
ASan 을 평소 빌드에 켜 둬도 되는지 판단하려고, int 100 만 개 배열을 200 번 훑는 작업(2 억 번 읽기, 값은 시드를 고정한 생성기)을 각 7 회 실행해 중앙값을 냈습니다. 매번 조금씩 짧은 범위를 더해서, 최적화가 결과 하나를 200 번 재사용하지 못하게 했습니다.
| 빌드 | 중앙값 | 느려진 배율 | 실행 파일 |
|---|---|---|---|
-O0 |
190.1 ms | 33,528 B | |
-O0 -fsanitize=address |
314.4 ms | 1.7 배 | 51,272 B |
-O2 |
10.2 ms | 33,512 B | |
-O2 -fsanitize=address |
35.7 ms | 3.5 배 | 51,320 B |
ASan 을 켠 -O2 가 그냥 -O0 보다 다섯 배 넘게 빨랐습니다. 첫 버전은 매번 배열 전체를 더했는데, -O2 숫자가 이상하게 빨라서 범위를 바꾸고서야 제대로 된 값이 나왔습니다. 벤치마크를 짤 때는 "컴파일러가 건너뛸 수 있는 일"이 없는지 꼭 의심해야 합니다. 초보자에게 권하는 설정: 개발과 테스트는 늘 -g -fsanitize=address,undefined.
솔직한 한계와 버전 메모
- 실행은 arm64 macOS 26 의 Apple clang 21.0.0 에서만 했습니다. 리눅스의 gcc, clang 도 같은
-fsanitize=address를 지원합니다(실행하지 않음). MSVC 는/fsanitize=address(실행하지 않음). - 스택 보호가 기본으로 켜지는지는 플랫폼과 배포판에 따라 다릅니다. -O0 에서의 비정상 종료는 여기서 Apple clang 이 보인 동작이지 보장이 아닙니다.
for (int i = 0; ...)안의 변수 선언은 C99 부터입니다.[[fallthrough]]는 C23 이고-std=c23으로 컴파일했습니다.-std=c17에서는 같은 파일이 다른 C23 기능인nullptr때문에use of undeclared identifier 'nullptr'로 실패했습니다.- 가변 길이 배열(
int a[n])은 C99 에 들어왔다가 C11 에서 선택 사항이 됐습니다. 본문에서는 쓰지 않았습니다. - 주소는 실행마다 다르고, 시간은 한 대에서 잰 값입니다.
연습 문제
1. 아래 함수는 배열 평균을 돌려줘야 하는데 {10, 20, 30, 40, 50} 에 대해 15.0 을 돌려줍니다. 왜일까요? 어떻게 고칠까요?
double avg(int a[]) {
int n = sizeof(a) / sizeof(a[0]);
int s = 0;
for (int i = 0; i < n; i++) s += a[i];
return (double)s / n;
}
2. 배열을 제자리에서 뒤집는 void reverse(int a[], int n) 과 최댓값을 돌려주는 int max_of(const int a[], int n) 을 작성하고, {3, 9, -2, 7, 5} 와 원소 하나짜리 배열로 -fsanitize=address,undefined 를 켜고 시험해 보세요.
정답
1. 함수 안의 a 는 포인터라서 n 이 8 / 4 = 2 가 되고, 10 과 20 만 평균을 냈습니다. clang 은 -Wsizeof-array-argument 와 'sizeof (a)' will return the size of the pointer, not the array itself [-Wsizeof-pointer-div] 두 가지를 경고합니다. double avg(const int a[], int n) 으로 바꾸고, a 를 선언한 곳에서 avg(a, (int)(sizeof a / sizeof a[0])) 로 호출합니다.
2. ASan, UBSan 에서 문제없이 돈 풀이 예시입니다.
void reverse(int a[], int n) {
for (int i = 0, j = n - 1; i < j; i++, j--) {
int tmp = a[i];
a[i] = a[j];
a[j] = tmp;
}
}
int max_of(const int a[], int n) {
int m = a[0];
for (int i = 1; i < n; i++)
if (a[i] > m) m = a[i];
return m;
}
5 7 -2 9 3
max = 9
one = 42, max = 42
i <= j 가 아니라 i < j 라서 가운데 원소를 자기 자신과 바꾸지 않고, 원소가 하나면 반복문 본문이 한 번도 실행되지 않습니다. max_of 는 0 이 아니라 a[0] 에서 시작하므로 모두 음수인 배열에서도 맞습니다.
다음 강의
3강에서는 배열의 포인터 변환에서 살짝 보였던 상자를 엽니다. 포인터, malloc 과 free, 그리고 해제 후 사용, 이중 해제, 메모리 누수를 ASan 이 어떻게 알려 주는지 봅니다.
자주 묻는 질문
함수 안에서 sizeof(arr) 가 왜 8 인가요?
배열 매개변수는 사실 포인터이기 때문입니다. void f(int arr[]) 는 void f(int *arr) 와 같은 뜻이라, 64 비트 Mac 에서 sizeof(arr) 는 int 5 개의 20 이 아니라 포인터 크기 8 입니다. clang 은 -Wsizeof-array-argument 로 경고합니다. 길이는 별도 매개변수로 넘기고, 호출하는 쪽에서 sizeof a / sizeof a[0] 으로 구합니다.
C 는 배열 범위를 검사하지 않나요?
하지 않습니다. int a[5] 에 a[5] 를 써도 컴파일되고 대개 실행됩니다. 실험에서 같은 버그가 -O0 에서는 스택 보호 기능 때문에 비정상 종료(종료 코드 134), 스택 보호를 끄면 옆 변수가 7 에서 500 으로 조용히 바뀌었고, -O2 에서는 멀쩡해 보였습니다. 개발할 때 -fsanitize=address 를 붙이면 정확한 줄에서 stack-buffer-overflow 로 멈춥니다.
AddressSanitizer 를 붙이면 얼마나 느려지나요?
배열을 읽는 벤치마크에서 -O0 은 약 1.7 배(190 ms → 314 ms), -O2 는 약 3.5 배(10.2 ms → 35.7 ms) 느려졌고, 실행 파일은 약 33 KB 에서 51 KB 가 됐습니다. 테스트와 디버그 빌드에서는 늘 켜 둬도 되는 비용입니다.