Proven C BookEnglish GitHub

88 기반 — 바이트, 뷰, 그리고 넘치지 않는 산술

먼저 알아야 할 것

37장 포인터의 규칙 · 정렬과 프로버넌스
38장 배열 · 배열과 경계
81장 넘침을 묻는 법 · 넘치지 않는 계산

돌아보기

37장에서 문자 타입을 가리키는 포인터(char*·signed char*·unsigned char*) 만은 어떤 객체든 바이트 단위로 들여다볼 수 있는 특권을 가진다고 했고, 13장에서는 그 규칙을 어긴 코드가 최적화에서 조용히 무너지는 것을 보았다. 그러면 “안전하게 바이트를 다룬다”는 것은 구체적으로 무엇을 지키는 일인가?

답. 두 가지다. 첫째, 들여다보는 타입을 규칙이 면제한 것으로 고정한다unsigned char다. 둘째, 들여다보는 범위를 잃어버리지 않는다 — 포인터 하나만 들고 다니면 어디까지가 내 땅인지 잊게 되고, 그것이 38장의 경계 침범이다. proven의 기본 어휘는 이 두 가지를 각각 타입으로 만든 것이다.

이 장의 필요성과 맥락

에러 다음이 어휘다. 뷰·바이트·검사 산술·정렬 — 넷 다 뒤의 여섯 장이 계속 쓰는 낱말이라, 여기서 한 번에 세워 두지 않으면 장마다 되풀이해 설명하게 된다. 37장의 프로버넌스와 81장의 검사 산술이 여기서 라이브러리의 타입으로 합쳐진다.

이 장이 끝나면

라이브러리 전체가 딛고 선 네 가지 기본 어휘를 본다 — 원시 바이트를 가리키는 타입, 포인터와 길이를 하나로 묶은 (view), 감기지 않는 크기 계산, 그리고 정렬. 85장의 여섯째 버그(바이트의 숨은 타입)와 38장의 경계 문제가 여기서 타입 차원의 답을 얻는다.

이 장에서 답할 질문

  1. 자르기 함수가 _checked_unchecked 두 판으로 있던데, 후자는 왜 존재하는가?

88.1 바이트에는 이름이 있다

proven_byte_tunsigned char의 별칭이다. 별것 아닌 것 같지만 선언 하나가 계약을 적는다 — “이 포인터는 표현(representation)을 보는 눈이지, 어떤 타입의 값을 가리키는 것이 아니다”.

이 구분이 왜 중요한지는 13장에서 이미 보았다. 같은 기억을 uint32_t*uint16_t*로 번갈아 보는 코드는 앨리어싱 규칙 위반이고, 컴파일러는 그 전제를 최적화에 쓴다. 반면 unsigned char로 보는 것은 표준이 명시적으로 허용한다. 라이브러리가 원시 메모리를 다룰 때 언제나 이 타입을 거치는 이유이고, 그래서 라이브러리를 통해서는 그 버그를 쓸 수가 없다.

88.2 뷰 — 포인터와 길이를 하나로

기억을 가리키는 어휘는 셋뿐이고, 전부 구조체 두 칸이다. 셋의 차이는 소유하는가고칠 수 있는가 둘뿐이다.

/* ① 소유한 덩어리 — "이 기억은 내 것이고, 언젠가 내가 돌려준다" */
typedef struct {
    proven_byte_t *ptr;
    proven_size_t  size;
} proven_mem_t;

/* ② 빌린 읽기 창 — 남의 기억을 들여다보기만 한다 */
typedef struct {
    const proven_byte_t *ptr;
    proven_size_t        size;
} proven_mem_view_t;

/* ③ 빌린 쓰기 창 — 남의 기억을 고칠 수는 있으나 돌려주지는 않는다 */
typedef struct {
    proven_byte_t *ptr;
    proven_size_t  size;
} proven_mem_mut_t;

const 하나가 ②와 ③을 가르고, 이름이 ①과 나머지를 가른다. 이 작은 구조체가 하는 일은 하나다 — 포인터와 길이가 절대로 헤어지지 않게 하는 것. 42장에서 “문자열의 진짜 문제는 길이를 따로 들고 다니는 것”이라 했던 그 문제에 대한 답이다.

타입소유쓰기어디서 오는가
proven_mem_t할당자(89장)
proven_mem_view_t아니오아니오_as_view, 자르기, 리터럴
proven_mem_mut_t아니오할당 결과, 스택 배열, 자르기

표 88.1

소유한 덩어리에서 창을 얻는 함수도 짝으로 있다 — proven_mem_view_from_owned, proven_mem_mut_from_owned. 이름의 from_owned가 “소유는 그대로 두고 창만 낸다”는 뜻이다.

examples/ch88/mem.c

/* 기억을 다루는 네 어휘 — 소유(mem_t), 읽기 뷰, 쓰기 뷰, 그리고 정렬.
   구조체가 어떻게 생겼고 무엇을 약속하는지 눈으로 확인한다. */
#include <proven.h>

/* 바이트 뷰를 글자로 보기 위한 도우미 — 둘 다 {포인터, 길이}라 그대로 옮긴다 */
static proven_u8str_view_t as_text(proven_mem_view_t v)
{
    return (proven_u8str_view_t){ .ptr = v.ptr, .size = v.size };
}

static void show(const char *label, proven_mem_view_t v)
{
    proven_println("{:<14} size={} contents=\"{}\"",
                   PROVEN_ARG(label), PROVEN_ARG(v.size), PROVEN_ARG(as_text(v)));
}

int main(void)
{
    /* ── ① 세 가지 어휘 ──────────────────────────────────────────
       proven_mem_t      { proven_byte_t *ptr; size };   소유한 덩어리
       proven_mem_view_t { const byte *ptr; size };      빌린 읽기 창
       proven_mem_mut_t  { byte *ptr; size };            빌린 쓰기 창
       셋 다 "포인터와 길이"가 헤어지지 않게 묶은 것이다. */
    proven_byte_t storage[16] = "abcdefghijklmno";

    proven_mem_mut_t  mut  = { .ptr = storage, .size = 15 };
    proven_mem_view_t view = { .ptr = storage, .size = 15 };

    /* 쓰기 뷰로는 고칠 수 있고, 읽기 뷰로는 고칠 수 없다(컴파일 오류) */
    mut.ptr[0] = 'A';

    proven_println("sizeof(mem_view_t)={} (pointer + length)",
                   PROVEN_ARG(sizeof(proven_mem_view_t)));
    show("whole view", view);

    /* ── ② 자르기 — 검사판과 무검사판 ─────────────────────────── */
    proven_result_mem_view_t ok = proven_mem_view_slice_checked(view, 3, 5);
    if (proven_is_ok(ok.err)) show("slice(3,5)", ok.value);

    proven_result_mem_view_t bad = proven_mem_view_slice_checked(view, 12, 9);
    proven_println("slice(12,9)     -> err={} (refused, not clamped)",
                   PROVEN_ARG((int)bad.err));

    /* 경계를 이미 확인한 뜨거운 루프에서는 무검사판을 쓴다.
       "위험한 선택은 눈에 보이는 이름으로만" 이라는 규칙이 여기 있다. */
    proven_mem_view_t fast = proven_mem_view_slice_unchecked(view, 0, 3);
    show("unchecked(0,3)", fast);

    /* ── ③ 경계를 지키는 복사와 이동 ─────────────────────────── */
    proven_byte_t dst[8] = {0};
    proven_err_t e1 = proven_mem_copy(dst, sizeof dst,
                                      proven_mem_view_slice_unchecked(view, 0, 5));
    proven_println("copy 5 into 8   -> err={}", PROVEN_ARG((int)e1));

    proven_err_t e2 = proven_mem_copy(dst, sizeof dst, view);   /* 15 > 8 */
    proven_println("copy 15 into 8  -> err={} (on overflow it writes nothing at all)",
                   PROVEN_ARG((int)e2));

    /* 겹치는 영역은 move 로. 65장의 memcpy/memmove 구분이 여기서도 같다 */
    proven_err_t e3 = proven_mem_move(storage + 2, 13,
                                      proven_mem_view_slice_unchecked(view, 0, 5));
    show("source after move", (proven_mem_view_t){ .ptr = storage, .size = 7 });
    proven_println("move overlap    -> err={}", PROVEN_ARG((int)e3));

    /* ── ④ 포인터가 그 덩어리 안에 있는가 ────────────────────── */
    proven_size_t off = 0;
    bool inside = proven_range_contains_ptr(storage, sizeof storage,
                                            storage + 4, 2, &off);
    proven_println("is storage+4 inside? {} (offset {})",
                   PROVEN_ARG(inside), PROVEN_ARG(off));

    /* ── ⑤ 정렬 ──────────────────────────────────────────────── */
    proven_println("align_up(13,8)={}  align_up(16,8)={}  MAX_ALIGN={}",
                   PROVEN_ARG(proven_mem_align_up(13, 8)),
                   PROVEN_ARG(proven_mem_align_up(16, 8)),
                   PROVEN_ARG((proven_size_t)PROVEN_MAX_ALIGN));
    proven_println("is_pow2(8)={} is_pow2(12)={} (an alignment must be a power of two)",
                   PROVEN_ARG(proven_is_pow2(8)), PROVEN_ARG(proven_is_pow2(12)));
    return 0;
}

실행 결과

sizeof(mem_view_t)=16 (pointer + length)
whole view     size=15 contents="Abcdefghijklmno"
slice(3,5)     size=5 contents="defgh"
slice(12,9)     -> err=2 (refused, not clamped)
unchecked(0,3) size=3 contents="Abc"
copy 5 into 8   -> err=0
copy 15 into 8  -> err=2 (on overflow it writes nothing at all)
source after move size=7 contents="AbAbcde"
move overlap    -> err=0
is storage+4 inside? true (offset 4)
align_up(13,8)=16  align_up(16,8)=16  MAX_ALIGN=16
is_pow2(8)=true is_pow2(12)=false (an alignment must be a power of two)

예제가 이 절과 다음 절의 어휘를 전부 한 번씩 돌려 본다. 다섯 자리를 짚는다.

① 뷰는 두 워드다. 출력의 sizeof(mem_view_t)=16이 그것이다(64비트에서 포인터 8 + 길이 8). 포인터 하나(8바이트)보다 크다는 사실이 뷰의 유일한 비용이고, 그 대가로 경계 검사가 가능해진다.

② 쓰기 창으로만 고칠 수 있다. 예제가 mut.ptr[0] = 'A'로 첫 글자를 바꾸자 이후 출력이 전부 A로 시작한다. 같은 자리를 읽기 뷰로 고치려 하면 컴파일 오류다 — const가 그 일을 한다.

③ 자르기에는 두 판이 있다. 다음 절에서 자세히 본다.

④ 복사·이동에도 경계가 있다. proven_mem_copy(dst, dst_cap, src)는 목적지 용량을 반드시 받고, 들어가지 않으면 한 바이트도 쓰지 않고 OUT_OF_BOUNDS를 돌려준다(출력의 copy 15 into 8). 겹칠 수 있으면 proven_mem_move다 — 65장의 memcpy/memmove 구분이 여기서도 그대로다.

⑤ 포인터가 어느 덩어리에 속하는지 물어볼 수 있다. proven_range_contains_ptr가 그것인데, 눈여겨볼 것은 구현이 정수로 비교한다는 점이다. 서로 다른 할당에서 온 포인터를 <>=로 비교하는 것은 계약 밖이므로(37장), 라이브러리는 uintptr_t로 바꾼 뒤 검사한다. 89장의 아레나가 “이 포인터가 내가 내준 것인가”를 확인할 때 쓰는 함수다.

다만 이 우회로가 표준이 보장하는 이식 가능한 검사인 것은 아니다. uintptr_t는 있어도 되고 없어도 되는(선택적) 타입이고, 포인터를 정수로 바꾼 결과가 주소의 크기 순서를 그대로 보존한다는 보장도 표준에는 없다. 표준이 약속하는 것은 “포인터 → uintptr_t → 같은 포인터”의 왕복뿐이다. 평탄한 주소 공간을 쓰는 오늘의 주류 플랫폼에서는 순서 비교가 뜻대로 동작하지만, 세그먼트 주소나 capability 포인터(5장의 CHERI)처럼 주소가 번호 하나가 아닌 기계에서는 다른 이야기가 된다. 그래서 이 함수는 계약이 아니라 proven 의 지원 플랫폼 전제로 읽어야 한다 — “평탄 주소 공간이고 정수 변환이 순서를 보존하는 구현”이 조건이다(94장의 지원 범위 참조).

88.3 자르기 — 이 부에서 가장 자주 쓰는 연산

examples/ch88/view.c

#include <proven.h>
#include <stdio.h>

static void dump(const char *label, proven_mem_view_t v)
{
    printf("%-10s size=%zu bytes:", label, v.size);
    for (proven_size_t i = 0; i < v.size; i++) printf(" %02x", v.ptr[i]);
    printf("\n");
}

int main(void)
{
    /* 원시 바이트는 proven_byte_t 다 — unsigned char 의 별칭이라
       어떤 객체의 표현이든 들여다볼 수 있는 유일한 타입이다 */
    proven_byte_t buf[8] = {0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80};

    /* view = 포인터 + 길이. 길이를 따로 들고 다니지 않는다 */
    proven_mem_view_t all = { .ptr = buf, .size = sizeof buf };
    dump("all", all);

    /* 자르기: 경계를 넘으면 실패가 값으로 온다 */
    proven_result_mem_view_t mid = proven_mem_view_slice_checked(all, 2, 3);
    if (proven_is_ok(mid.err)) dump("slice 2+3", mid.value);

    proven_result_mem_view_t over = proven_mem_view_slice_checked(all, 6, 4);
    printf("%-10s err=%d (refused, not clamped)\n", "slice 6+4", (int)over.err);

    /* 크기 계산도 감기지 않게 한다: 배열 원소 수 x 원소 크기 */
    proven_size_t n = (proven_size_t)-1 / 2;   /* 터무니없이 큰 개수 */
    proven_size_t bytes;
    if (PROVEN_CKD_MUL(&bytes, n, (proven_size_t)8))
        printf("%-10s %zu * 8 overflows — allocation refused\n", "size calc", n);
    else
        printf("%-10s %zu bytes\n", "size calc", bytes);

    /* 정렬 올림: 다음 경계로 밀어 올린다 */
    printf("align_up(13, 8) = %zu\n", proven_mem_align_up(13, 8));
    return 0;
}

실행 결과

all        size=8 bytes: 10 20 30 40 50 60 70 80
slice 2+3  size=3 bytes: 30 40 50
slice 6+4  err=2 (refused, not clamped)
size calc  9223372036854775807 * 8 overflows — allocation refused
align_up(13, 8) = 16

slice 6+4가 이 절의 핵심이다. 8바이트짜리 뷰에서 6번째부터 4바이트를 달라고 했으니 두 바이트가 모자란다. 라이브러리는 있는 만큼 잘라 주지 않는다 — 에러를 돌려준다(2번은 PROVEN_ERR_OUT_OF_BOUNDS다). 있는 만큼 주는 편이 친절해 보이지만, 그러면 호출자는 자기가 받은 것이 요청한 것인지 알 수 없게 된다. 85장의 잘림 문제가 여기서 되풀이되지 않도록 막은 것이다.

네 함수가 짝을 이룬다 — 읽기/쓰기 × 검사/무검사.

함수돌려주는 것언제
proven_mem_view_slice_checked{err, view} 꾸러미기본값. 경계를 모를 때
proven_mem_view_slice_unchecked뷰(검사 없음)경계를 이미 확인한 뜨거운 루프
proven_mem_mut_slice_checked{err, mut} 꾸러미기본값(쓰기)
proven_mem_mut_slice_unchecked쓰기 창(검사 없음)같음

표 88.2

검사판의 계약은 세 줄이다. 길이가 0이 아닌데 포인터가 널이면 INVALID_ARG. offset이 크기를 넘거나 offset + size가 크기를 넘으면 OUT_OF_BOUNDS — 그리고 이 검사가 offset + size > view.size가 아니라 size > view.size - offset으로 적혀 있다는 점이 중요하다. 전자는 덧셈이 감길 수 있지만 후자는 절대 감기지 않는다 — 이 장 뒷부분의 검사 산술과 같은 정신이다. 크기가 0이면 널 포인터에 크기 0인 빈 뷰를 돌려준다 — 빈 것을 가리키는 포인터를 역참조하지 못하게 하는 안전장치다.

문. 자르기 함수가 _checked_unchecked 두 판으로 있던데, 후자는 왜 존재하는가?

답. 경계를 이미 확인한 자리를 위해서다. 반복문 안에서 매 회 같은 검사를 다시 하는 것은 낭비이므로, 루프 앞에서 한 번 확인하고 안쪽에서는 검사 없는 판을 쓰는 식이다.

/* 8바이트씩 끊어 읽는다 — 경계는 루프 조건이 이미 보증한다 */
for (proven_size_t off = 0; off + 8 <= buf.size; off += 8) {
    proven_mem_view_t chunk = proven_mem_view_slice_unchecked(buf, off, 8);
    process(chunk);
}

이름에 _unchecked가 붙어 있다는 점이 중요하다 — 위험한 선택은 눈에 보이는 이름으로만 할 수 있고, 기본값은 언제나 안전한 쪽이다. 51장에서 말한 “계약을 코드로 적는다”의 한 형태이고, 코드 검토에서 _unchecked를 찾아보는 것만으로 위험 지점을 훑을 수 있다는 실용적 이점도 있다.

반례. 뷰를 소유자보다 오래 들고 있기

proven_mem_view_t get_view(void) {
    proven_byte_t local[16] = {0};
    return (proven_mem_view_t){ .ptr = local, .size = sizeof local };
}   /* local 은 여기서 죽는다 — 돌려준 뷰는 이미 유효하지 않다 */

뷰는 빌린 것이다. 소유자가 사라지면 그 순간 유효하지 않게 되고, 그 뒤의 사용은 44장에서 배운 죽은 자동 변수 접근이다. 뷰가 포인터보다 안전한 것은 경계에 대해서이지 수명에 대해서가 아니다 — 수명은 여전히 사람이 지켜야 한다. 그래서 다음 장의 할당자 이야기가 필요해진다.

88.4 넘치지 않는 크기 산술

27장에서 무부호 정수의 감김을 배웠고, 7장에서 그것이 왜 정의된 동작인지 보았다. 감김이 조용하다는 사실은 한 자리에서 특히 위험해진다 — 할당할 바이트 수를 계산할 때다.

void *p = malloc(count * sizeof(item_t));   /* count 가 크면 감긴다 */

count가 충분히 크면 곱이 감겨서 아주 작은 수가 되고, malloc은 그 작은 크기로 성공한다. 그 뒤 프로그램은 원래 의도한 개수만큼 쓰기 시작한다 — 전형적인 힙 넘침이다. 27장에서 “크기 계산”을 오버플로의 실제 사고 사례로 든 것이 이 무늬였다.

라이브러리는 크기 계산에 C23의 검사 산술을 쓴다. 예제의 PROVEN_CKD_MUL이 그것으로, 곱이 넘치면 을 돌려준다(계산 결과가 아니라 넘침 여부가 반환값이다). 넘치면 할당을 아예 시도하지 않는다.

흔한 오해. size_t는 64비트나 되니 넘칠 리 없다”

넘칠 수 있고, 실제로 넘긴다. 곱셈은 값을 제곱 규모로 키우기 때문이다 — 40억 × 40억은 이미 64비트를 넘는다. 게다가 32비트 기계에서 size_t는 여전히 32비트이고, 파일 형식에서 읽어 온 32비트 필드 둘을 곱하는 코드는 그 자리에서 감긴다. 무엇보다 중요한 것은 누가 그 수를 정하는가다. 크기가 프로그램 안의 상수라면 안심해도 좋지만, 파일이나 네트워크에서 온 수라면 그것은 공격자가 고른 피연산자다.

실제 사례. 곱셈 하나가 만든 취약점들

이 무늬는 CVE 목록의 단골이다. 이미지 디코더가 width * height * bytes_per_pixel을 32비트로 계산하다 감긴 사례, 폰트 파서가 글리프 개수를 곱하다 감긴 사례, 압축 해제 코드가 원본 크기를 곱하다 감긴 사례가 반복해서 보고됐다. 공통점은 모두 입력 파일이 크기를 정한다는 것 — 즉 공격자가 곱셈의 피연산자를 고를 수 있다는 것이다. 그래서 요즘 언어와 라이브러리는 크기 계산을 검사 산술로 하거나 아예 넘칠 수 없는 타입으로 다룬다.

88.5 정렬 — 다음 경계로 밀어 올리기

6장에서 배운 정렬(alignment)이 여기서 실용적인 도구가 된다. proven_mem_align_up(13, 8)은 16을 돌려준다 — 13번지에서 시작하는 객체를 8바이트 경계에 맞추려면 16으로 밀어야 하기 때문이다. 다음 장의 아레나가 객체를 줄지어 놓을 때 매번 하는 계산이 바로 이것이다.

이름무엇메모
proven_mem_align_up(a, n)an의 배수로 올린다넘치거나 n이 2의 거듭제곱이 아니면 0
proven_uintptr_align_up(p, n)주소를 올린다포인터 산술 대신 정수로
proven_is_pow2(n)2의 거듭제곱인가정렬 값 검사
PROVEN_MAX_ALIGNalignof(max_align_t)보통 16. 어떤 타입이든 담는다
PROVEN_DEFAULT_ALIGNMENT8문자열·버퍼 같은 바이트 자료의 기본

표 88.3

실패를 0으로 알린다는 점이 이 함수들의 특이한 계약이다. 정렬이 2의 거듭제곱이 아니거나 올림이 넘치면 0을 돌려주므로, 결과를 그대로 크기로 쓰기 전에 0인지 보아야 한다. 라이브러리 안에서는 아레나가 그 검사를 대신해 주므로, 이 함수를 직접 부를 일은 자기 자료구조를 만들 때뿐이다.

두 상수의 쓰임이 다르다는 것도 알아 둘 만하다. 바이트 배열이나 문자열은 PROVEN_DEFAULT_ALIGNMENT(8)면 충분하고, 어떤 타입이 올지 모르는 범용 할당에는 PROVEN_MAX_ALIGN을 쓴다. 89장의 힙 할당자가 이 둘을 갈라 쓰는 이유이기도 하다 — 기본 정렬 이하의 요청은 malloc으로(제자리 성장이 가능하다), 그보다 엄격한 요청은 정렬 할당으로 보낸다.

복습 정리

이 장의 어휘.

이름무엇계약
proven_byte_tunsigned char 별칭표현을 보는 유일한 합법 창
proven_mem_view_t읽기 전용 뷰(ptr+size)빌린 것 — 소유자보다 오래 살 수 없다
proven_mem_mut_t쓰기 가능 뷰같음
..._slice_checked부분 뷰 만들기범위를 넘으면 에러, 자르지 않음
PROVEN_CKD_MUL/ADD검사 산술넘치면 참 — 계산은 버린다
proven_mem_align_up정렬 올림정렬은 2의 거듭제곱

표 88.4

기억을 보는 어휘를 갖췄다. 다음은 기억을 얻는 이야기다 — 그리고 그 자리에서 이 라이브러리의 가장 특징적인 결정을 만나게 된다.