81 넘침을 묻는 법 — <stdckdint.h>
먼저 알아야 할 것
돌아보기
7장에서 부호 없는 넘침은 정의된 감아 돌기이고 부호 있는 넘침은 계약 밖이라 했고, 66장에서는 calloc이 원소 수와 크기의 곱을 검사한다고 했다. 그러면 곱셈이 넘치는지 우리가 직접 확인하려면 어떻게 적는가?
답. 바로 그 “직접 확인”이 반세기 동안 틀리게 적혀 온 코드다. 부호 있는 값에서는 넘친 뒤에 검사하는 것 자체가 이미 계약 밖이라 컴파일러가 검사 코드를 지워 버릴 수 있고(13장의 편집자), 부호 없는 값에서도 나눗셈을 끼운 관용구는 읽기 어렵고 틀리기 쉽다. C23은 이 자리를 언어 차원에서 정리했다.
이 장의 필요성과 맥락
이 장이 끝나면
ckd_add·ckd_sub·ckd_mul이 정확히 무엇을 약속하는지, 그리고 기존 코드의 크기 계산을 어떻게 이 도구로 옮기는지까지 본다.이 장에서 답할 질문
ckd_*는 함수인가 매크로인가? 인자를 여러 번 평가하지는 않는가?
81.1 사후 검사라는 함정
가장 흔한 손수 짠 검사부터 본다.
반례. 더한 다음에 넘쳤는지 보기
int sum = a + b;
if (sum < a) { /* 넘쳤다 */ } /* ← 이 검사가 사라질 수 있다 */a와 b가 부호 있는 정수라면 a + b가 넘치는 순간 이미 정의되지 않은 동작이다. 그 뒤의 검사는 “넘치지 않았다면”이라는 전제 위에서만 뜻이 있으므로, 컴파일러는 sum < a가 결코 참이 될 수 없다고 판단해 조건문을 통째로 지운다. 실제로 이 무늬 때문에 여러 프로젝트에서 검사가 조용히 사라졌고, 그중에는 보안 검사도 있었다.
부호 없는 값이라면 감아 돌기가 정의되어 있으므로 위 검사는 동작한다. 다만 곱셈으로 가면 그마저 까다로워진다.
if (n != 0 && bytes / n != sz) { /* 넘쳤다 */ } /* 맞지만 읽기 어렵다 */그래서 컴파일러들이 저마다 확장을 내놓았다 — GCC와 Clang의 __builtin_add_overflow 계열, MSVC의 SafeInt, 여러 프로젝트의 자체 매크로. 잘 동작했지만 이식성이 없었고, 이식성을 확보하려면 프로젝트마다 같은 껍데기를 다시 짜야 했다. 65장의 strlcpy 이야기와 같은 무늬다 — 현실이 먼저 답을 찾고 표준이 뒤늦게 추인한다.
81.2 C23의 답 — ckd_add·ckd_sub·ckd_mul
examples/ch81/ckdint.c
/* 넘침을 값이 아니라 "일어났는가"로 받는다 — C23 <stdckdint.h> */
#include <limits.h>
#include <stdckdint.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
/* 흔한 할당 계산: 원소 n 개 x 크기 sz. 곱셈이 넘치면 그릇이 작아진다. */
static void *alloc_array(size_t n, size_t sz)
{
size_t bytes;
if (ckd_mul(&bytes, n, sz)) { /* 참이면 넘쳤다 */
printf(" the size arithmetic overflowed - nothing is allocated\n");
return NULL;
}
return malloc(bytes);
}
int main(void)
{
/* 판정과 결과 읽기를 반드시 갈라 적는다 (한 표현식에 섞으면 순서가 없다) */
int r;
bool over = ckd_add(&r, INT_MAX, 1);
printf("INT_MAX = %d\n", INT_MAX);
printf("ckd_add(INT_MAX, 1) overflow? %s r = %d (the wrapped value)\n", over ? "yes" : "no", r);
over = ckd_add(&r, 1, 2);
printf("ckd_add(1, 2) overflow? %s r = %d\n", over ? "yes" : "no", r);
/* 타입이 섞여도 수학적 값으로 판정한다 */
signed char c;
over = ckd_add(&c, 200, 100);
printf("signed char <- 300 overflow? %s c = %d\n", over ? "yes" : "no", c);
/* 부호 없는 뺄셈: 감아 도는 것은 정의된 동작이지만, 여기서도 "넘쳤다"고 알린다 */
unsigned u;
over = ckd_sub(&u, 3u, 5u);
printf("unsigned <- 3 - 5 overflow? %s u = %u\n", over ? "yes" : "no", u);
printf("\nsize arithmetic for an allocation\n");
void *ok = alloc_array(1000, sizeof(int));
printf(" 1000 x %zu -> %s\n", sizeof(int), ok ? "allocated" : "refused");
free(ok);
void *bad = alloc_array(SIZE_MAX / 2, sizeof(int));
printf(" SIZE_MAX/2 x %zu -> %s\n", sizeof(int), bad ? "allocated" : "refused");
free(bad);
return 0;
}
실행 결과
INT_MAX = 2147483647
ckd_add(INT_MAX, 1) overflow? yes r = -2147483648 (the wrapped value)
ckd_add(1, 2) overflow? no r = 3
signed char <- 300 overflow? yes c = 44
unsigned <- 3 - 5 overflow? yes u = 4294967294
size arithmetic for an allocation
1000 x 4 -> allocated
the size arithmetic overflowed - nothing is allocated
SIZE_MAX/2 x 4 -> refused
읽는 법은 이렇다. 세 매크로 모두 모양이 같다.
bool 넘쳤는가 = ckd_add(&결과, 왼쪽, 오른쪽);약속이 네 가지다.
- 무한 정밀도로 계산한 뒤 그릇에 넣는다. 판정은 “수학적 결과가 결과 타입에 들어가는가”이고, 중간 계산에서 넘치는 일이 없다. 그래서 인자의 타입이 서로 달라도, 심지어 인자보다 결과 타입이 좁아도 정확히 판정한다 — 예제의
signed char <- 300이 그 확인이다. - 결과 타입은 첫 인자(포인터)가 정한다. 인자들의 승격 규칙(28장)이 결과를 좌우하지 않는다는 뜻이라, “어느 타입으로 계산되는가”를 두고 고민할 일이 없다.
- 넘쳐도 결과는 저장된다. 그 값은 결과 타입으로 감아 돈 값이다. 예제의
INT_MAX + 1이-2147483648로 남은 것이 그것이다 — 부호 있는 값이라도 이 자리에서는 정의된 동작이라는 점이 중요하다. - 부호 없는 감아 돌기도 “넘쳤다”고 알린다. 예제의
3u - 5u가 그 확인이다. 값 자체는 정의된 결과(4294967294)지만, 검사 산술은 수학적 값과 달라졌음을 알린다. 크기 계산에서 이것이 필요한 성질이다.
문. ckd_*는 함수인가 매크로인가? 인자를 여러 번 평가하지는 않는가?
답. 표준이 정한 것은 매크로이지만, 인자를 여러 번 평가하지 않도록 못박혀 있다(구현은 대개 컴파일러 내장 기능으로 확장한다). 그래서 ckd_add(&r, i++, j) 같은 코드도 안전하다.
다만 다른 함정이 있다. 판정과 결과를 한 표현식 안에 섞으면 안 된다.
printf("%s %d", ckd_add(&r, a, b) ? "넘침" : "정상", r); /* 위험 */r을 읽는 시점과 ckd_add가 r에 쓰는 시점 사이에 순서가 정해져 있지 않아, 옛 값을 찍을 수 있다(20장의 순서 이야기다). 실제로 이 장의 예제를 처음 쓸 때 이 실수로 엉뚱한 값이 찍혔다. 판정을 변수에 받고, 그다음 줄에서 결과를 쓴다 — 이 한 줄 규율이면 끝난다.
81.3 어디에 쓰는가 — 크기 계산이 첫 번째다
예제 뒷부분의 alloc_array가 전형이다. 할당 크기 계산은 검사 산술이 가장 절실한 자리다. n * sz가 넘치면 작은 그릇을 얻고 큰 줄 알고 쓰는 상태가 되어, 곧바로 버퍼 넘침 사고로 이어진다. 유명한 취약점 여럿이 정확히 이 경로였다.
| 자리 | 예전 관용구 | 지금 |
|---|---|---|
| 배열 할당 | n && SIZE_MAX/n < sz 검사 | ckd_mul(&bytes, n, sz) |
| 버퍼 확장 | cap * 2 그냥 계산 | ckd_mul(&cap2, cap, 2) |
| 길이 합치기 | len1 + len2 + 1 | ckd_add 두 번 |
| 인덱스 계산 | base + off 그냥 | ckd_add(부호 있는 값이면 필수) |
| 입력에서 온 수 | atoi 뒤 그냥 사용 | strtol(66장) + 범위 검사 |
표 81.1
마지막 줄이 중요하다. 검사 산술은 계산의 넘침을 잡을 뿐, 애초에 값이 너무 큰 것을 걸러 주지는 않는다. 입력 해석 단계의 검사(66장)와 계산 단계의 검사는 서로를 대신하지 못한다.
흔한 오해. “ckd_*를 쓰면 정수 넘침 걱정이 끝난다”
세 가지가 남는다. 첫째, 나눗셈은 이 헤더에 없다 — INT_MIN / -1은 여전히 스스로 막아야 하는 계약 밖이다. 둘째, 변환은 검사하지 않는다. 넓은 타입에서 좁은 타입으로 대입하는 것은 산술이 아니라 변환이라(7장의 잘림), 여기서 값이 망가지는 것은 ckd_*의 소관이 아니다. 셋째, 부동소수점은 대상이 아니다(73장).
요약하면 검사 산술은 “덧셈·뺄셈·곱셈이 그릇을 넘겼는가”라는 좁고 분명한 질문에 답하는 도구다. 좁아서 좋은 도구이기도 하다.
81.4 이 도구가 없는 곳에서
C23 헤더를 아직 못 쓰는 환경이라면 두 단계로 대비한다.
#if defined(__has_include)
# if __has_include(<stdckdint.h>)
# include <stdckdint.h>
# define HAVE_CKDINT 1
# endif
#endif
#ifndef HAVE_CKDINT /* GCC·Clang 확장으로 메운다 */
# define ckd_add(r, a, b) __builtin_add_overflow((a), (b), (r))
# define ckd_sub(r, a, b) __builtin_sub_overflow((a), (b), (r))
# define ckd_mul(r, a, b) __builtin_mul_overflow((a), (b), (r))
#endif인자 순서가 다르다는 점만 주의하면 된다 — 표준은 결과 포인터가 앞, 컴파일러 내장은 뒤다. 이런 껍데기를 한 자리에 모아 두는 것이 57장에서 말한 이식성 층의 실제 모습이고, 제12부의 라이브러리도 같은 일을 한다.
플랫폼 노트. 검사를 도구에 맡기는 길
코드를 고치지 않고 넘침을 잡는 방법도 있다. GCC와 Clang의-fsanitize=signed-integer-overflow(UBSan)는 실행 중에 넘침을 잡아 보고하고, -ftrapv는 넘침에서 프로그램을 세운다. 둘 다 시험용이다 — 넘침이 실제로 일어난 실행에서만 드러나므로, 검사 산술처럼 “일어날 수 있는 자리를 코드로 막는” 것과는 성격이 다르다. 17장의 디버거 이야기와 같은 결론이다: 도구는 관찰을 돕지만 계약을 대신하지 않는다.복습 정리
| 기억할 것 | 요지 |
|---|---|
| 사후 검사 | 부호 있는 값에서는 검사 자체가 계약 밖 — 지워질 수 있다 |
| 모양 | bool 넘침 = ckd_add(&결과, a, b) |
| 판정 기준 | 수학적 결과가 결과 타입에 들어가는가 |
| 결과 타입 | 첫 인자가 정한다. 승격 규칙에 휘둘리지 않는다 |
| 넘쳐도 | 감아 돈 값이 저장된다(정의된 동작) |
| 부호 없는 값 | 감아 돌기도 “넘침”으로 알린다 |
| 한 표현식 | 판정과 결과를 섞지 않는다 |
| 적용 1순위 | 할당 크기 계산 |
| 없는 곳 | __builtin_*_overflow 로 껍데기를 만든다 |
표 81.2
넘침을 계약대로 묻는 법을 익혔다. 다음 장은 이 부의 마지막이자, C23이 언어에 손댄 가장 눈에 띄는 변화다 — 매크로였던 것들이 키워드가 된 이야기다.