Proven C Book←↑→

38 포인터의 규칙 — 정렬과 프로버넌스

먼저 알아야 할 것

36장 객체, 주소, 포인터 · 포인터의 정체
4장 주소의 특수 지식 · 정렬과 하위 비트
15장 그래서 C는 추상적인 언어다 · 추상 기계의 계약

돌아보기

4장에서 “4바이트 짐은 4의 배수 번호에”라는 정렬 규칙을 배웠다. 그런데 포인터는 그냥 수를 담는 변수인데 — 아무 수나 담으면 왜 안 되는가?

답. 담는 것 자체는 막히지 않는 경우가 많다 — 사고는 따라갈 때 난다. int*로 역참조한다는 것은 “그 주소에서 4바이트를 int의 눈으로 읽겠다”는 뜻인데(36장), 그 주소가 int의 정렬(4의 배수)을 어기고 있으면 4장의 그 결과들 — 느려지거나, 기계에 따라 그 자리에서 버스 폴트 — 이 따라온다. 표준의 눈으로는 정렬이 맞지 않는 타입으로의 캐스트부터가 이미 계약 밖이다. 포인터의 타입은 눈이자 계약이다.

이 장의 필요성과 맥락

포인터의 문법을 배웠으니 이번에는 계약이다. 이 장이 배열(39장)보다 앞에 오는 이유가 있다 — 배열과 포인터를 오가는 순간부터 정렬과 출처가 실제로 문제가 되기 때문이다. 4장(정렬)과 15장(추상 기계)에서 각각 예고한 두 실을 여기서 하나로 묶는다.

이 장이 끝나면

포인터에 걸린 두 가지 깊은 규칙을 배운다 — 4장의 정렬이 포인터 캐스트에 거는 제약(그리고 char*만의 특권), 15장에서 예고한 프로버넌스(출처 딱지)의 실무 감각까지. 뒤쪽 절은 이 책에서 드물게 아직 표준 본문에 없는 것을 다룬다 — 왜 그런 개념이 필요해졌고, 어디까지 정리됐고, 들어오면 무엇이 달라지며, 그것을 하드웨어로 강제하는 기계가 이미 있다는 것까지. 이 부의 안전 수칙 중 가장 “계약”다운 장이다.

이 장에서 답할 질문

  1. 이런 규칙을 어긴 코드도 “내 컴퓨터에서는 잘 도는” 경우가 많지 않은가?
  2. 다 정리했으면 그냥 표준에 넣으면 될 텐데, 왜 별책인가?
  3. 그러면 이 규칙은 결국 CHERI 같은 특수한 기계를 위한 준비인가?

38.1 정렬과 캐스트 — char*의 특권#

포인터 캐스트(타입 바꿔 보기)는 문법상 자유롭지만, 계약상으로는 좁다. 규칙을 실무 문장으로 줄이면 — 더 엄격한 정렬을 요구하는 타입 으로의 캐스트는 위험하다. char*(정렬 1)를 int*(정렬 4)로 바꿔 따라가는 것이 전형적 위반이다. 반대 방향은 안전하다 — 그리고 여기에 C의 의도된 특권이 있다: 문자 타입을 가리키는 포인터 (char*, signed char*, unsigned char*)는 어떤 객체든 바이트 단위로 들여다볼 수 있다. 정렬 1이라 어디든 가리킬 수 있고, 표준이 명시적으로 “모든 객체의 표현은 바이트의 눈으로 읽어도 된다”고 허락해 둔 통로다(14장의 엄격한 앨리어싱에도 이 예외가 뚫려 있다). 3장에서 “칸 안은 그저 비트”라 했던 그 관점의 공식 통로가 unsigned char*인 셈이다.

각 타입의 정렬 요구는 alignof로 물을 수 있다(C23 키워드):

examples/ch37/align.c

#include <stdio.h>

int main(void)
{
    printf("alignof(char)   = %zu\n", alignof(char));
    printf("alignof(int)    = %zu\n", alignof(int));
    printf("alignof(double) = %zu\n", alignof(double));
    return 0;
}

실행 결과

alignof(char)   = 1
alignof(int)    = 4
alignof(double) = 8

38.1.1 두 특권을 나란히 놓으면#

36장에서 예고한 두 통로를 여기서 정리한다. 둘은 하는 일이 다르다.

void *char * 계열
무엇을 잊는가가리키는 타입을 잊는다아무것도 잊지 않는다 — 표현을 그대로 본다
역참조불가(크기를 모른다)가능 — 한 바이트씩
산술불가(표준상)가능 — 바이트 단위
앨리어싱해당 없음어떤 객체의 표현도 읽을 수 있다(예외 통로)
왕복 보장객체 포인터 ↔ void * 는 원래 값으로 돌아온다바이트로 읽는 것과 포인터를 되찾는 것은 다른 문제다

표 38.1 — void * 와 char * 계열의 특권 비교

memcpy의 원형이 void *를 받으면서 내부에서는 바이트로 복사하는 이유가 여기 있다 — 받을 때는 타입을 잊고, 다룰 때는 바이트로 본다. 표준이 void *와 문자 타입 포인터에 같은 표현을 요구한 것도 이 조합을 위해서다 (36장의 워드 주소 기계 이야기).

흔한 오해. “char *로 뜯어볼 수 있으니, 뜯어본 값으로 포인터를 만들어도 된다”

두 가지가 뒤섞인 생각이다. 읽는 것은 허락돼 있다 — 어떤 객체의 표현이든 unsigned char *로 바이트를 읽을 수 있다. 그러나 그렇게 얻은 바이트를 다시 포인터로 조립해 다른 객체를 가리키게 만드는 것은 별개 문제다. 그 포인터에 붙은 출처(프로버넌스)가 원래 객체의 것이기 때문이다 — 다음 절의 주제가 정확히 이것이다.

실무의 결론은 간단하다. 표현을 옮길 때는 memcpy로 값을 옮기고, 포인터가 필요하면 원래 객체에서 다시 얻는다. 바이트를 조립해 만든 주소는 x86-64 에서는 대개 동작하지만, CHERI(capability hardware enhanced RISC instructions) 처럼 포인터에 권한이 실린 기계에서는 그 자리에서 막힌다(36장).

38.2 프로버넌스 — 번호가 같아도 출처가 다르면#

15장의 예고를 실무 감각으로 회수한다. 순진한 그림에서 포인터는 번호일 뿐이니, 번호만 맞으면 어떻게 만들었든 따라가도 될 것 같다 — 그러나 현대 C의 계약은 그렇지 않다. 포인터에는 출처(프로버넌스)가 있다: 어떤 객체에서 유래했는가라는 보이지 않는 딱지다. 실무 규칙 세 줄로 줄인다.

실제 그 객체와 일치해도 — 계약 밖이다.

문. 이런 규칙을 어긴 코드도 “내 컴퓨터에서는 잘 도는” 경우가 많지 않은가?

답. 많다 — 그리고 그것이 이 규칙들이 위험한 이유다. 위반의 대가를 걷는 것은 기계가 아니라 컴파일러의 최적화다(14장): “포인터는 출처 밖을 가리키지 않는다”는 전제로 재배열·캐싱을 하므로, 위반 코드는 최적화 수준이나 컴파일러 판이 바뀔 때 조용히 다른 동작이 된다. “지금 돌았다”는 계약 준수의 증거가 아니라는 것 — 15장의 “추상 기계 위에서 옳은가”가 기준이라는 것 — 이 이 장의 결론이고, 54장에서 이 주제의 전모를 본다.

38.2.1 표준에 아직 없는 낱말#

여기서 정직하게 밝힐 것이 있다. 프로버넌스는 C23 표준 본문의 용어가 아니다. C23을 처음부터 끝까지 뒤져도 그 낱말로 정의된 조항은 없다. 그런데도 이 책이 한 절을 들이는 이유는 셋이다.

  1. 컴파일러는 이미 이 개념 위에서 최적화하고 있다. 표준에 없다는 것이 “지켜도 그만 안 지켜도 그만”을 뜻하지 않는다.
  2. 표준 위원회는 이것을 명문화하는 작업을 별책 기술 명세로 마무리했다 (2025년 ISO/IEC TS 6010). 본문 편입이 남았을 뿐이다.
  3. 그 명세가 하는 일은 새 규칙을 얹는 것이 아니라, 이미 있던 규칙들의 빈틈을 메우고 서로 아귀가 맞게 다듬는 것이다.

세 번째가 이 절의 핵심이다. 앞으로 이 개념이 표준에 들어와도 새로 배울 문법은 없다. 지금 지켜야 하는 규칙이 그대로 남되, 왜 지켜야 하는지를 표준이 처음으로 한 낱말로 설명하게 된다.

38.2.2 빈틈은 어디에 있었나 — 이웃한 두 변수#

이 개념이 없으면 설명이 막히는 자리를 하나만 보자. 전역에 나란히 놓인 두 변수다.

int y = 2, x = 1;
int *p = &x + 1;      /* x 의 "한 칸 지난" 자리 — 만드는 것은 합법 */
int *q = &y;
if (p == q)           /* 참일 수 있다 */
    *p = 11;          /* 그러나 이 줄은? */

물음이 둘로 갈린다. p == q가 참일 수 있는가 — 그렇다. 두 변수가 실제로 이웃해 놓이면 주소가 겹치고, 그때 비교 결과가 어느 쪽이 될지는 표준이 정해 두지 않았다(unspecified). 그러면 참인 그 안에서 *p로 y를 고쳐도 되는가 — 안 된다. p는 x에서 태어났고, 끝 다음 자리는 가리키는 것만 합법이지 따라가는 것은 아니다.

순진한 그림이 무너지는 자리가 여기다. “같다고 답한 두 포인터를 서로 바꿔 쓸 수 없다”는 말은 포인터 = 번호 그림에서는 자기모순이다. 그리고 실제 컴파일러가 그 모순 쪽에 서 있다 — 최적화를 켜면 GCC 도 Clang 도 “p와 q는 다른 객체에서 왔으니 겹칠 리 없다”는 전제로 코드를 옮기고 지운다. 그래서 위 코드는 컴파일러와 최적화 수준에 따라 y가 11이 되기도 하고 그대로이기도 하다. 14장의 엄격한 앨리어싱과는 별개라는 점도 중요하다 — -fno-strict-aliasing을 켜도 이 전제는 사라지지 않는다.

표준 본문은 이 상황에 대해 침묵했다. 그 결과 세 당사자의 말이 어긋난 채로 20년이 흘렀다. 표준의 문언은 “포인터는 값”으로 읽히고, 컴파일러는 “출처가 다르면 다른 것”으로 최적화하고, 프로그래머는 “내 기계에서는 돌던데”라고 말한다. 심판이 없으니 컴파일러가 버그를 낸 것인지 프로그램이 계약을 어긴 것인지도 판정할 수 없다.

38.2.3 낱말이 태어난 자리 — DR260#

이 낱말이 공식 문서에 처음 나온 것은 2004년, 결함 보고(Defect Report) 260에 대한 위원회 답변에서다. 요지는 두 문장으로 줄어든다.

“ 구현은 비트 패턴의 유래(origin)를 추적해도 된다. 유래가 다른 포인터는 비트가 같아도 서로 다른 것으로 취급해도 된다. ”

허가는 났다. 그러나 그 허가는 표준 본문에 반영되지 않았다. 정의도 없었고, 어디까지가 허용인지도 없었다. 그 상태로 컴파일러들은 이 답변을 근거 삼아 출처 기반 앨리어스 분석을 밀고 나갔다.

정의 없는 개념이 최적화의 근거가 되면 무슨 일이 생기는가 — 그것이 이 이야기의 교훈이다. 어떤 관용구가 합법인지 아무도 단정할 수 없게 된다. 오래된 저수준 코드의 절반이 회색지대에 잠긴다.

38.2.4 명문화 — 설문 조사에서 기술 명세까지#

명문화 작업이 특이한 방식으로 시작됐다는 점을 짚어 둘 만하다. 먼저 한 일은 조문을 쓰는 것이 아니라 묻는 것이었다. 케임브리지의 연구자들이 2013년에 포인터와 객체 표현에 관한 마흔두 문항짜리 설문을 돌려 실무 C 프로그래머와 구현자가 실제로 무엇을 합법이라 여기는지를 모았고, 그 분석이 위원회 문서로 올라갔다.1 그다음에야 쟁점을 스무 문항(Q1~Q20)으로 추려 위원회에 「이건 합법이어야 하는가」를 물었다.2 표준을 고치기 전에 현실이 무엇을 하고 있는지부터 재는 순서다.

그 위에 모델이 올라갔다. 이름은 PNVI-ae-udi 인데, 이름을 욀 필요는 없고 실무적으로는 세 줄이다.

  1. 객체(할당)마다 보이지 않는 출처 딱지가 하나 붙는다. 포인터 값은 「출처 + 주소」의 쌍이다.
  2. 포인터를 정수로 바꾸면 그 객체의 주소가 노출된다(address exposed).
  3. 정수를 포인터로 되돌릴 때는, 노출된 적 있는 객체 가운데 그 주소를 품은 것을 찾아 출처를 되살린다. 노출된 적이 없으면 되살릴 출처가 없다 — 그렇게 만든 포인터는 따라갈 수 없다.

한 줄로 줄이면 이렇다. 주소를 정수로 내보낸 적 있는 객체만, 그 정수로 되찾을 수 있다. uintptr_t 왕복이 왜 공식 통로이고, 바이트를 주물러 조립한 주소가 왜 통로가 아닌지가 이 한 줄에서 함께 나온다 — 두 규칙이 따로 있는 것이 아니라 하나의 모델에서 파생된다는 것, 이것이 “다듬는다”는 말의 뜻이다.

이 작업의 결과가 ISO/IEC TS 6010(2025) — 「C를 위한 출처 인지 기억 객체 모델」이다.3 표준 본문이 아니라 별책 기술 명세(Technical Specification)로 나왔고, 스스로를 이렇게 규정한다 — 완전한 언어 명세가 아니라, ISO/IEC 9899:2018 에 암묵적으로 들어 있던 기억 객체 모델을 조이고 명확히 하는 문서. 적합한 구현은 「그 차이가 9899 에 이미 통합된 것처럼」 행동해야 한다.

경계도 스스로 밝혀 둔다 — 이 명세는 부분객체(subobject)의 출처는 다루지 않는다. 구조체 멤버를 가리키는 포인터가 어디까지 걸어도 되는지 같은 물음은 아직 열려 있다는 뜻이다.

문. 다 정리했으면 그냥 표준에 넣으면 될 텐데, 왜 별책인가?

답. 구현 경험 없이 본문을 고치는 것은 위험하다는 판단 때문이다. 명세 자신이 그 사정을 적어 두었다 — C 위원회(WG14)와 C++ 쪽의 소위원회가 모두 전체 방향을 승인했으되 「구현 경험을 조건으로」 했다는 것이다. 같은 시기에 Clang/LLVM 과 GCC 쪽 개발자들과도 EuroLLVM·GNU Tools Cauldron 에서 논의가 오갔다.

별책으로 먼저 내면 컴파일러 쪽이 실제로 구현해 보고, 실무 코드가 얼마나 깨지는지 재 볼 시간이 생긴다. 표준화의 속도가 왜 이렇게 느린가에 대한 1장의 답이 여기 그대로 있다 — 한번 못 박으면 30년을 가기 때문이다.

번호와 이름은 외울 것이 아니다. 남길 것은 하나다 — 포인터의 출처 개념은 누군가의 취향이 아니라, 표준화 절차를 밟아 문서로 굳어진 것이며, 다음 판의 본문 후보다.

38.2.5 들어오면 무엇이 달라지는가#

가장 중요한 답부터 말하면 — 대부분의 코드에는 아무 일도 일어나지 않는다. 새 키워드도, 새 문법도, 다시 컴파일해야 할 이유도 없다. 달라지는 것은 “회색”의 개수다. 지금 회색지대에 잠겨 있는 관용구들이 흑과 백으로 갈린다.

관용구지금(본문 기준)출처 모델이 들어오면
uintptr_t 왕복보장은 있으나 근거가 흐리다합법 — 정수로 바꾼 그 순간이 곧 “노출”
포인터 표현을 memcpy 로 복사회색합법 — 출처까지 함께 옮겨진다
태그 포인터(4장)회색왕복 통로를 지키면 합법
XOR 연결 리스트회색불법 — 두 출처를 섞은 값에는 출처가 없다
객체 밖으로 민 포인터불법(배열 조항으로)불법 — 이유가 “출처”로 통일된다
바이트·입출력으로 조립한 주소회색불법 — 노출된 적 없는 출처는 되살릴 수 없다

표 38.2 — 출처 모델이 들어오면 갈리는 관용구

표에서 읽어야 할 것은 개별 판정이 아니라 판정이 가능해졌다는 사실이다. 지금은 저 칸들에 “아마도”라고 쓸 수밖에 없다. 그 밖에 달라지는 것들을 꼽으면 이렇다.

흔한 오해. “아직 표준에 없는 개념이니, 당장은 신경 쓰지 않아도 된다”

방향이 정반대다. 표준에 들어오면 그때 위험해지는 것이 아니라, 지금이 더 위험하다. 컴파일러는 이미 이 전제로 최적화하고 있는데 표준 본문에는 그 사실을 알려 주는 조항이 없어서, 위반한 코드가 왜 깨졌는지 설명해 줄 문서가 없기 때문이다. 명문화는 새 족쇄가 아니라 이미 채워져 있던 족쇄의 사용 설명서다.

38.2.6 출처를 하드웨어가 들고 다니는 기계#

이 개념이 종이 위의 이야기만은 아니다. 포인터에 출처를 실어 하드웨어가 강제하는 기계가 이미 있다.

실제 사례. 능력 기반 아키텍처

CHERI. 36장에서 크기 이야기로 만난 그 구조다. 여기서는 출처의 관점에서 다시 본다. CHERI 의 포인터는 「주소 + 범위 + 권한 + 유효 태그 비트 하나」다. 결정적인 것은 마지막 항목이다 — 태그는 데이터와 같은 자리에 있지 않고 대역 밖(out-of-band)에 따로 저장되며, 보통의 명령으로는 세울 수 없다. 정수 연산으로 주소를 주물러 그 자리에 써 넣으면 하드웨어가 태그를 지운다. 태그를 잃은 값은 더 이상 포인터가 아니라 그냥 수이고, 따라가려는 순간 그 자리에서 막힌다.

Arm Morello. 2022년 초에 출하된 실물 시험 보드다.5 태그 비트를 기억 장치까지 나르도록 확장했다 — 즉 이 개념을 지원하려면 CPU 코어만이 아니라 기계 전체를 손봐야 한다.

CHERIoT. 같은 발상을 아주 작은 쪽으로 밀어 붙인 갈래다. 사물인터넷용 32비트 RISC-V 를 겨냥하고, 마이크로소프트가 설계한 명령 집합 확장으로 시작해 실물 보드까지 나와 있다. 서버에서만 되는 이야기가 아니라는 증거다.

새 발상은 아니다. 태그 붙은 기계는 오히려 오래된 계보다. 36장에서 본 IBM System/38·AS/400 의 128비트 태그 포인터, 그리고 그보다 앞선 버로스 B5000 계열의 태그 워드가 같은 생각을 하드웨어로 구현했다. C 가 태어난 기계 계열이 태그 없는 쪽이었을 뿐이다.

플랫폼 노트. 헷갈리기 쉬운 이웃

AArch64 의 TBI(top byte ignore)·MTE(memory tagging extension), x86-64 의 LAM 은 이것과 다른 이야기다. 이들은 주소의 상위 바이트를 무시하거나 거기에 “색”을 칠해 두고 접근할 때 맞춰 보는 장치다 — 잘못된 접근을 확률적으로 잡는 데 쓸모가 있지만, 포인터가 범위와 권한을 들고 다니는 것은 아니다. 출처를 강제하는 것은 능력(capability) 계열이고, 색칠은 검출 보조다.

정리하면 심판이 둘이다. x86-64 에서 출처를 어긴 코드의 대가를 걷는 것은 컴파일러이고(그래서 조용하고 뒤늦다), 능력 기반 기계에서는 하드웨어가 그 자리에서 걷는다(그래서 시끄럽고 즉시다). 어느 쪽이든 걷는다는 것이 요점이다.

문. 그러면 이 규칙은 결국 CHERI 같은 특수한 기계를 위한 준비인가?

답. 아니다. 순서가 반대다. 출처 규칙은 CHERI 때문에 생긴 것이 아니라, 평범한 x86-64 위의 최적화 컴파일러가 이미 그 전제로 돌고 있기 때문에 필요해졌다. CHERI 는 그 규칙을 하드웨어로 옮겨 놓았을 뿐이다. 그래서 이 규칙을 지킨 코드는 덤을 하나 받는다 — 새 기계로 옮길 때 손댈 것이 거의 없다는 덤이다. 반대로 “x86-64 에서 잘 돌더라”에 기대어 쓴 코드는 최적화 수준이 바뀔 때 한 번, 기계가 바뀔 때 또 한 번 무너진다.

포인터의 규칙까지 갖췄다. 이제 이 도구들을 갖고 연속된 기억 — 배열 — 으로 간다. 26장부터 미뤄 온 line[100]의 외상이 다음 장에서 청산된다.

주

  1. WG14 N2013 「C memory object and value semantics: the space of de facto and ISO standards」와 N2014 「What is C in practice? (Cerberus survey v2): Analysis of Responses」, 2016. ↩
  2. WG14 N2219·N2263 「Clarifying Pointer Provenance (Q1-Q20)」, Memarian·Gomes·Sewell, 2018. ↩
  3. ISO/IEC TS 6010:2025, Programming languages — C — A provenance-aware memory object model for C. 2025년 5월 발행. 작업 초안은 WG14 N3231 로 공개돼 있다. ↩
  4. Rust 1.84.0 release notes, 2025-01-09. 「strict provenance」와 「exposed provenance」 API 묶음. ↩
  5. Arm Morello — CHERI 를 얹은 ARMv8-A 시험용 SoC 와 개발 보드. 2022년 1월 출하. 케임브리지 대학 CTSRD/CHERI 프로젝트와 Arm 의 공동 작업이다. ↩