C 언어 시리즈 #06 — 배열·디케이(decay)·문자열 리터럴·VLA

이 글의 핵심

배열 이름이 언제 첫 요소의 포인터로 바뀌는지, 왜 char s[] = "hi"와 char *p = "hi"의 수정 가능성이 다른지, VLA가 스택에 어떤 비용을 남기는지 정리합니다.

#06 | 전체 목차 | 이전: #05 포인터 · 다음: #07 구조체·비트필드


C의 배열은 길이 정보를 함께 저장하지 않습니다. a[i]에 접근할 때 i가 범위 안에 있다는 것은 작성자가 보장해야 하고, 그러지 못하면 정의되지 않은 동작(UB)이 됩니다. 컴파일러는 범위를 검사하지 않고 그대로 주소 계산을 합니다.

이 성질 때문에 버퍼 오버런은 수십 년 동안 가장 흔한 보안 취약점 유형 중 하나였습니다. 1988년 Morris 웜은 fingerd가 gets로 입력을 고정 크기 버퍼에 읽는 버그를 이용해 퍼졌고, 2001년 Code Red는 IIS 인덱싱 서비스의 버퍼 오버플로를 이용했습니다. 2014년 Heartbleed는 OpenSSL의 TLS heartbeat 구현이 상대가 주장한 payload 길이를 실제로 받은 바이트 수와 대조하지 않고 그만큼 메모리를 읽어 응답에 담은 버그였습니다. 코드에 배열 인덱스가 직접 보이지 않더라도, 얼마만큼 읽고 쓸지를 검증하지 않았다는 점은 같습니다.

이 글에서는 C에서 배열이 언제 어떻게 길이 정보를 잃는지와, 그 결과를 다루는 실무 패턴을 설명합니다.

배열 객체와 범위

int a[5];는 int 다섯 개가 연속으로 놓인 하나의 객체입니다. a[0]부터 a[4]까지만 접근할 수 있고, a[5]를 읽거나 쓰면 UB입니다. 주소 &a[5](끝 바로 다음)를 계산해 비교에 쓰는 것은 허용되지만, 그 위치를 역참조할 수는 없습니다.

UB라는 것은 “옆 메모리를 건드린다”보다 나쁩니다. 디버그 빌드에서는 옆 변수가 바뀌는 정도로 보이던 코드가, 최적화 빌드에서는 컴파일러가 “범위 밖 접근은 일어나지 않는다”고 가정하고 루프 조건을 지워 버리는 식으로 전혀 다른 결과를 낼 수 있습니다.

디케이: 배열이 포인터로 바뀌는 시점

배열 타입의 식은 대부분의 문맥에서 첫 요소를 가리키는 포인터로 변환됩니다. 이를 array-to-pointer decay라고 부릅니다. 예외는 sizeof와 단항 &의 피연산자일 때, 그리고 문자 배열을 초기화하는 문자열 리터럴일 때뿐입니다.

int x[10];
sizeof x;   /* 배열 전체: 10 * sizeof(int), 보통 40 */
x + 1;      /* int * : x[1]의 주소 */
&x;         /* int (*)[10] : 배열 전체를 가리키는 포인터 */
&x + 1;     /* 배열 10칸 전체를 건너뛴 주소 */

x와 &x는 같은 주소 값을 갖지만 타입이 다르기 때문에 +1의 의미가 다릅니다. 2차원 배열을 다룰 때 이 차이가 자주 헷갈리는데, row-major 배치에서 m[r][c]는 1차원으로 펴면 r * cols + c 위치라는 점을 떠올리면 정리가 쉽습니다.

함수 매개변수에서 길이가 사라지는 이유

함수 매개변수를 배열 타입으로 선언하면 컴파일러가 포인터 타입으로 조정합니다. void f(int a[10])은 void f(int *a)와 완전히 같은 선언이고, [10]은 아무 검사도 하지 않는 주석에 가깝습니다. 그래서 함수 안에서 sizeof a는 항상 포인터 크기입니다. GCC와 Clang은 이 경우 -Wsizeof-array-argument 경고를 냅니다.

#include <stdio.h>
#define COUNTOF(a) (sizeof(a) / sizeof((a)[0]))

void f(int a[10]) {
    /* a는 int *. sizeof a는 포인터 크기(64비트에서 8) */
    printf("inside f: sizeof a == %zu\n", sizeof a);
}

int main(void) {
    int xs[10] = {0};
    printf("in main: COUNTOF(xs) == %zu\n", COUNTOF(xs)); /* 10 */
    f(xs);
    return 0;
}

COUNTOF 같은 매크로는 같은 스코프에서 선언된 진짜 배열에만 맞는 값을 냅니다. 포인터에 쓰면 컴파일 오류 없이 엉뚱한 숫자(64비트에서 int *라면 8/4 = 2)가 나옵니다. 그래서 배열을 받는 API는 포인터와 길이를 함께 받거나(void f(const int *a, size_t n)), 길이와 데이터를 묶은 구조체를 받는 형태로 설계합니다.

초기화

int a[10] = {0};은 첫 요소를 0으로, 나머지를 모두 0으로 채웁니다. 초기화 목록이 요소 수보다 짧으면 남은 요소는 0으로 초기화되기 때문입니다. C99의 지정 초기화자(designated initializer)를 쓰면 int d[10] = { [0] = 1, [9] = 2 };처럼 특정 위치만 지정할 수 있어, 열거형 값을 인덱스로 쓰는 조회 테이블을 만들 때 유용합니다.

복합 리터럴 (int[]){1, 2, 3}은 이름 없는 배열을 만들어 sum((int[]){1, 2, 3}, 3)처럼 바로 넘길 수 있습니다. 함수 안에서 만든 복합 리터럴은 자동 저장 기간을 가지므로, 그 블록을 벗어난 뒤에도 포인터를 보관하면 지역 변수 주소를 반환하는 것과 같은 버그가 됩니다.

VLA: 런타임 크기 배열

C99의 가변 길이 배열(VLA) int a[n];은 실행 시점의 n만큼 스택에 공간을 잡습니다. 필요한 만큼만 잡는다는 장점이 있지만, n이 외부 입력에서 온다면 공격자가 스택을 넘치게 할 수 있고, 할당 실패를 감지할 방법도 없습니다. 재귀 함수 안의 VLA는 깊이와 크기를 곱한 만큼 스택을 씁니다. C11부터 VLA는 구현이 지원하지 않아도 되는 선택 기능이 되었고(MSVC는 지원하지 않습니다), Linux 커널은 VLA 사용을 금지하고 있습니다.

실무에서는 상한을 검사한 뒤 malloc/calloc으로 힙에 잡거나, 최대 크기의 고정 버퍼와 실제 사용 길이를 함께 두는 방식이 일반적입니다.

문자열 리터럴과 문자 배열

char s[] = "hi";        /* 3바이트 배열에 "hi\0"를 복사. 수정 가능 */
s[0] = 'H';             /* OK */

const char *p = "hi";   /* 리터럴 자체를 가리킴 */
/* p[0] = 'H';  UB: 리터럴은 수정할 수 없음 */

char s[] = "hi";는 리터럴 내용으로 초기화된 별도의 배열을 만들므로 자유롭게 수정할 수 있습니다. 반면 char *p = "hi";는 리터럴 객체 자체를 가리킵니다. C에서 문자열 리터럴의 타입은 역사적인 이유로 const char[]가 아니라 char[]라서 const 없이 대입해도 컴파일되지만, 수정하는 것은 UB입니다. 대부분의 구현은 리터럴을 읽기 전용 영역에 두므로 쓰는 즉시 세그멘테이션 오류가 나고, 컴파일러가 같은 리터럴을 하나로 합치는 경우도 있어서 읽기 전용이 아닌 환경에서는 다른 곳의 문자열까지 바뀔 수 있습니다. 리터럴을 가리키는 포인터는 항상 const char *로 선언하고, GCC의 -Wwrite-strings를 켜면 실수를 컴파일 단계에서 잡을 수 있습니다.

경계가 있는 복사

strcpy는 목적지 크기를 모르므로, 원본이 목적지보다 짧다는 것을 증명할 수 없는 곳에서는 쓰면 안 됩니다. strncpy도 대안이 아닙니다. 원본이 길면 널 종료 문자를 붙이지 않고, 짧으면 나머지를 0으로 채우느라 불필요한 쓰기를 합니다. snprintf는 목적지 크기를 받아 항상 널 종료를 보장하고, 잘렸는지 반환값으로 알려 줍니다.

#include <stdio.h>

/* 성공 시 0, 잘렸거나 오류면 -1 */
int copy_name(char *dst, size_t dstsz, const char *src) {
    int n = snprintf(dst, dstsz, "%s", src);
    if (n < 0 || (size_t)n >= dstsz) {
        return -1; /* 잘림: 호출자가 처리 방침을 정함 */
    }
    return 0;
}

sizeof dst로 크기를 넘기는 관용구는 dst가 같은 스코프의 배열일 때만 맞습니다. 위처럼 포인터로 받은 경우에는 크기를 인자로 받아야 합니다. 바이너리 데이터는 널 종료와 무관하므로 길이를 명시하는 memcpy를 쓰고, 영역이 겹칠 수 있으면 memmove를 씁니다.

UTF-8 문자열에서 인덱스는 바이트 단위입니다. strlen은 문자 수가 아니라 바이트 수를 반환하고, 바이트 길이로 자르면 멀티바이트 문자 중간이 잘려 깨진 문자열이 될 수 있습니다. 화면에 보이는 글자 단위 처리가 필요하다면 별도의 디코딩 로직이나 라이브러리가 필요합니다.

동적 배열과 크기 계산 오버플로

동적 1차원 배열은 malloc(n * sizeof *a)로 잡습니다. 이때 n * sizeof *a가 size_t 범위를 넘으면 값이 작은 수로 감겨서, 기대보다 훨씬 작은 블록이 할당되고 이후의 쓰기가 힙을 망가뜨립니다. 외부 입력으로 개수를 받는 파서에서 실제로 반복해서 나온 취약점 유형입니다.

#include <stdint.h>
#include <stdlib.h>

int *alloc_ints(size_t n) {
    if (n != 0 && sizeof(int) > SIZE_MAX / n) {
        return NULL; /* 곱셈이 넘침 */
    }
    return malloc(n * sizeof(int));
}

calloc(n, size)은 곱셈 오버플로를 내부에서 검사하고 0으로 초기화까지 하므로 같은 문제를 피할 수 있습니다. realloc으로 배열을 늘릴 때도 같은 검사가 필요하고, p = realloc(p, newsize)처럼 쓰면 실패 시 원래 포인터를 잃어 누수가 생기므로 임시 변수에 받은 뒤 성공했을 때만 대입합니다. realloc은 블록을 다른 위치로 옮길 수 있으므로, 이전 주소를 가리키던 다른 포인터는 모두 무효가 됩니다.

2차원 배열을 행마다 malloc해서 int **로 구성하면 행이 메모리 곳곳에 흩어져 캐시 효율이 떨어지고 해제도 번거롭습니다. 한 번에 rows * cols개를 할당하고 r * cols + c로 접근하되, 인덱스 실수를 줄이려면 작은 인라인 함수로 감싸는 편이 좋습니다.

#include <stddef.h>

static inline size_t idx_rm(size_t r, size_t c, size_t cols) {
    return r * cols + c; /* row-major */
}

행과 열 인자를 뒤바꿔도 정사각형 테스트 데이터에서는 결과가 맞아 보이므로, 테스트는 행과 열 수가 다른 배열로, 특히 마지막 행과 마지막 열 같은 모서리 인덱스를 따로 확인하는 것이 좋습니다.

도구와 문서화

qsort와 bsearch는 표준 라이브러리의 정렬과 이진 탐색입니다. bsearch는 배열이 같은 비교 함수 기준으로 이미 정렬되어 있다고 가정하며, 정렬되지 않은 배열에 쓰면 오류 없이 틀린 결과를 냅니다.

AddressSanitizer(-fsanitize=address)는 실행 중 범위 밖 접근을 잡아 주고, -Wall -Wextra는 위에서 언급한 여러 실수를 컴파일 단계에서 경고합니다. 다만 둘 다 테스트에서 실행된 경로나 컴파일러가 볼 수 있는 패턴만 잡습니다. 공개 함수의 주석에는 포인터가 몇 개의 요소를 가리켜야 하는지, n이 0일 때 어떻게 동작하는지를 적어 두고, 외부 입력을 해석하는 파서나 디코더는 fuzzing으로 경계 조건을 검사하는 것이 좋습니다.

restrict는 컴파일러에게 해당 포인터들이 가리키는 영역이 겹치지 않는다고 약속하는 한정자이며, 실제로 겹치면 UB입니다. aligned_alloc(C11)은 SIMD 버퍼처럼 정렬이 필요한 메모리를 할당하고 일반 free로 해제합니다. 단, C11 원문은 크기가 정렬 값의 배수여야 한다고 요구했고, MSVC는 aligned_alloc을 제공하지 않아 _aligned_malloc과 _aligned_free 짝을 써야 합니다.

유연한 배열 멤버(FAM)와 구조체 배치는 #07에서, 힙 할당기와 Sanitizer는 #09에서 이어서 다룹니다.


다음: #07 구조체·정렬·비트필드


같이 보면 좋은 글