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
그림. int a[5] 에 i = 0 부터 5 까지 쓰는 루프라는 버그를 네 가지로 빌드한다. -O0 에서는 스택 보호 기능이 알아채고 Abort trap: 6, 종료 코드 134 로 죽는다. -O0 에 -fno-stack-protector 를 붙이면 종료 코드 0 으로 끝나고 옆 변수 guard 가 7 에서 500 으로 조용히 바뀐다. -O2 에서는 종료 코드 0, guard=7 이 출력되어 멀쩡해 보인다. -fsanitize=address 에서는 offbyone.c 7번째 줄에서 stack-buffer-overflow 로 멈춘다.
같은 off-by-one 버그를 네 가지로 빌드하면 결과도 네 가지. 줄 번호까지 알려 주는 것은 AddressSanitizer 뿐. Apple clang 21, arm64 macOS.
  • -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
막대그래프. -O0 은 190.1 밀리초, -O0 + ASan 은 314.4 로 약 1.7 배 느리다. -O2 는 10.2 밀리초, -O2 + ASan 은 35.7 로 약 3.5 배 느리지만 ASan 없는 -O0 보다 훨씬 빠르다.
AddressSanitizer 의 비용: int 100 만 개 배열을 200 번 훑기(2 억 번 읽기). 7 회 중앙값, Apple Silicon Mac(M1 Pro).

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 가 됐습니다. 테스트와 디버그 빌드에서는 늘 켜 둬도 되는 비용입니다.

  • #C 언어
  • #초급
  • #배열
  • #함수
  • #AddressSanitizer
  • #off-by-one

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑