Proven C BookEnglish GitHub

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

먼저 알아야 할 것

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

돌아보기

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

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

이 장의 필요성과 맥락

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

이 장이 끝나면

포인터에 걸린 두 가지 깊은 규칙을 배운다 — 6장의 정렬이 포인터 캐스트에 거는 제약(그리고 char*만의 특권), 14장에서 예고한 프로버넌스(출처 딱지)의 실무 감각까지. 짧지만, 이 부의 안전 수칙 중 가장 “계약”다운 장이다.

이 장에서 답할 질문

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

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

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

37.1.1 두 특권을 나란히 놓으면

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

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

표 37.1

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

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

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

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

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

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

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

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

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

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