# C의 모든 것은 정의되지 않은 동작이다

> Clean Markdown view of GeekNews topic #29737. Use the original source for factual precision when an external source URL is present.

## Metadata

- GeekNews HTML: [https://news.hada.io/topic?id=29737](https://news.hada.io/topic?id=29737)
- GeekNews Markdown: [https://news.hada.io/topic/29737.md](https://news.hada.io/topic/29737.md)
- Type: GN+
- Author: [neo](https://news.hada.io/@neo)
- Published: 2026-05-22T01:38:00+09:00
- Updated: 2026-05-22T01:38:00+09:00
- Original source: [blog.habets.se](https://blog.habets.se/2026/05/Everything-in-C-is-undefined-behavior.html)
- Points: 1
- Comments: 1

## Topic Body

- **정의되지 않은 동작(UB)** 은 컴파일러의 악의적 최적화가 아니라, 코드가 유효하다는 전제에서 불가능한 실행 경로를 처리하지 않아도 되는 규칙임
- 사소하지 않은 **C/C++ 코드**에는 double-free나 경계 밖 접근뿐 아니라 정렬, 캐스팅, 초기화, 타입 불일치 같은 미묘한 UB가 널리 숨어 있음
- 정렬되지 않은 `int*`나 `std::atomic&lt;int&gt;*` 접근은 플랫폼별로 SIGBUS, 커널 보정, 정상 동작처럼 보이는 결과가 갈리지만 표준상 이미 UB임
- `isxdigit()`에 signed `char`를 넘기거나 `float`를 `int`로 바꾸거나 `NULL`과 가변 인자를 잘못 쓰는 흔한 코드도 쉽게 표준 밖으로 벗어남
- 기존 코드베이스를 버릴 수는 없지만, **LLM 기반 UB 탐지**와 전문가 검증을 결합해 대규모로 고쳐야 하며 주니어에게 맡기기엔 너무 미묘함

---

### C/C++의 정의되지 않은 동작은 최적화 문제가 아님
- **정의되지 않은 동작(UB)** 은 컴파일러가 개발자의 실수를 “악용”한다는 뜻이 아니라, 프로그램이 표준상 유효하다고 가정할 수 있다는 뜻임
- 사람이 보기에는 의도가 분명해도, 컴파일러 단계나 모듈 사이에서 그 의도를 표현하기 어려울 수 있음
- 컴파일러는 “일어날 수 없는” 특수 사례를 코드 생성에서 처리할 의무가 없고, 하드웨어까지 포함한 실행 경로에서 의도와 다른 결과가 나올 수 있음
- 최적화를 꺼도 UB가 안전해지지는 않으며, 현재나 미래의 컴파일러·아키텍처에서 같은 동작이 유지된다는 보장도 없음

### UB는 비정상적인 코드에만 있지 않음
- **double-free**, use-after-free, 객체 경계 밖 접근, 초기화되지 않은 메모리 접근은 잘 알려진 UB지만, 산업 전반에서 계속 반복됨
- 더 미묘하고 비직관적인 UB도 많아, 평범해 보이는 C/C++ 코드가 쉽게 표준 밖으로 벗어남
- C23 표준에는 “undefined”라는 단어가 283번 나오며, 명시되지 않아 정의되지 않는 경우까지 포함하면 범위는 더 넓어짐
- 사소하지 않은 C/C++ 코드에는 UB가 곳곳에 있으며, 이를 개별 프로그래머의 부주의만으로 돌리기 어려움

### 정렬되지 않은 객체 접근
- 다음처럼 `int*`를 역참조하는 함수는 포인터가 올바르게 정렬되지 않았을 때 UB가 됨
  ```c
  int foo(const int* p) {
     return *p;
  }
  ```
- **정렬(alignment)** 은 보통 `sizeof(int)`의 배수 주소를 뜻할 수 있지만, 실제 요구사항은 플랫폼과 구현에 따라 달라질 수 있음
- Linux Alpha에서는 일부 경우 커널이 트랩을 받아 소프트웨어로 의도한 접근을 흉내낼 수 있었지만, 다른 경우에는 `SIGBUS`로 프로그램이 죽을 수 있음
- SPARC에서는 `SIGBUS`가 발생하고, x86/amd64에서는 대체로 문제없이 동작하거나 원자적 읽기처럼 보일 수도 있음
- ARM, RISC-V, 미래 아키텍처에서는 결과를 일반화할 수 없고, 미래 아키텍처가 `int*`의 하위 비트를 사용하지 않는 특수 레지스터를 둘 수도 있음
- 컴파일러가 다른 load 명령을 사용하면, 이전에는 커널이 보정해주던 접근이 더 이상 보정되지 않을 수 있음
- 컴파일러는 정렬되지 않은 포인터에서도 동작하는 어셈블리를 생성할 의무가 없으며, 해당 접근 자체가 UB임

### 원자 타입도 정렬이 틀리면 이미 UB
- 다음처럼 `std::atomic&lt;int&gt;*`에 대해 `store()`나 `load()`를 호출해도, 객체가 올바르게 정렬되지 않았으면 동작은 UB임
  ```cpp
  void set_it(std::atomic&lt;int&gt;* p) {
          p->store(123);
  }
  int get_it(std::atomic&lt;int&gt;* p) {
          return p->load();
  }
  ```
- “정렬되지 않은 객체에서도 이 연산이 원자적인가”라는 질문은 표준 관점에서 성립하지 않음
- 실제 하드웨어에서는 원자성 문제가 될 수 있지만, 표준상으로는 그 전에 이미 UB임
- 원자적으로 읽는다고 생각한 객체가 [페이지](https://en.wikipedia.org/wiki/Memory_paging)를 걸쳐 있을 때 문제는 더 복잡해지지만, 결론은 “괜찮다”가 아니라 UB임

### 포인터를 만드는 행위만으로도 문제가 될 수 있음
- 정렬되지 않은 포인터는 역참조 전이라도 특정 타입의 포인터로 **캐스팅**하는 것만으로 문제가 될 수 있음
  ```c
  bool parse_packet(const uint8_t* bytes) {
          const int* magic_intp = (const int*)bytes;   // UB!
          int magic_raw = foo(magic_intp);  // Probably crashes on SPARC.
          int magic = ntohl(magic_raw); // this is fine, at least.
          […]
  }
  ```
- 여기서 문제는 `foo()` 호출이 아니라 `(const int*)bytes` 캐스팅임
- 컴파일러가 `int*`의 하위 비트에 가비지 컬렉션이나 보안 태그 비트 같은 의미를 부여하는 것도 표준상 가능함

### `isxdigit()`에 `char`를 넘기는 문제
- 다음 코드는 단순해 보이지만, `char`가 signed인 아키텍처에서 입력값이 0–127 범위를 벗어나면 UB가 될 수 있음
  ```c
  bool bar(char ch) {
          return isxdigit(ch);
  }
  ```
- `isxdigit()`는 16진수 문자인지 확인하는 함수이며, `EOF`도 인자로 받을 수 있음
- [C23](https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3088.pdf) 7.4p1에 따르면 `EOF`는 `int`이고, `unsigned char`로 표현될 수 없는 값으로 추론할 수 있음
- `isxdigit()`는 `char`가 아니라 `int`를 받으며, `char`에서 `int`로 변환은 가능해도 signed `char`의 음수 값이 문제가 됨
- [C23](https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3088.pdf) 6.2.5 문단 20에 따르면 `char`가 signed인지 여부는 구현 정의임
- 다음처럼 구현된 `isxdigit()`는 음수 인덱스로 알 수 없는 메모리를 읽을 수 있음
  ```c
  int isxdigit(int c) {
          if (c == EOF) {
                  return false;
          }
          return some_array[c];
  }
  ```
- 그 메모리가 I/O 매핑 영역이면 임의 값이나 크래시를 넘어 하드웨어 동작을 유발할 수도 있음
- 데스크톱 운영체제의 애플리케이션보다 임베디드 시스템에서 더 가능성이 높지만, 사용자 공간 네트워크 드라이버처럼 사용자 공간만으로도 보호가 충분하지 않은 경우가 있음

### `float`에서 `int`로 캐스팅하는 문제
- 다음처럼 초 단위 `float` 값을 밀리초 `int`로 변환하는 코드는 흔하지만 UB를 포함함
  ```c
  int milliseconds(float seconds) {
          int tmp = (int)(seconds * 1000.0); /* WRONG */
          return tmp + 1; /* WRONG separately (signed overflow is UB) */
  }
  ```
- C23 6.3.1.4는 유한한 실수 부동소수점 값을 정수 타입으로 변환할 때, 정수부가 해당 정수 타입으로 표현될 수 없으면 동작이 정의되지 않는다고 규정함
- 비유한 값에 대해서도 명시가 없어 UB가 됨
- `float`를 `INT_MAX`와 비교하는 일도 단순하지 않음
  - `float`를 `int`로 캐스팅하면 피하려던 UB가 발생할 수 있음
  - `INT_MAX`를 `float`로 캐스팅하면 정확히 표현되는지 알 수 없음
  - `INT_MAX`가 `float`로 반올림되어 `int`로 표현할 수 없는 값이 되면 비교가 대표성을 잃을 수 있음
- 안전하게 만들려면 `isfinite()` 검사, `INT_MIN + 1000`, `INT_MAX - 1000` 같은 여유 범위 비교, 변환 후 덧셈 전 추가 검사가 필요함
  ```c
  int milliseconds(float seconds) {
          const float ftmp = seconds * 1000.0f;
          if (!isfinite(ftmp)) {
                  return 0;
          }
          if ((float)(INT_MIN + 1000) > ftmp) {
                  return 0;
          }
          if ((float)(INT_MAX - 1000) < ftmp) {
                  return 0;
          }
          const int tmp = (int)ftmp;
          if (INT_MAX == tmp) {
                  return 0;
          }
          return tmp + 1;
  }
  ```
- 단순히 `float`를 `int`로 바꾸고 싶을 뿐인데, 안전한 코드는 훨씬 길어짐

### 주소 0의 객체와 null pointer
- OS 커널이나 임베디드 코드에서는 주소 0에 객체를 두려는 상황이 생길 수 있음
- C 표준에 맞게 실제로 주소 0에 객체를 두는 실용적 방법은 없다고 볼 수 있음
- C 6.3.2.3에서 포인터로 변환 가능한 정수 상수 0과 `nullptr`은 “null pointer constant”이며, 여기서는 `NULL`로 부를 수 있음
- C는 [실제 `NULL` 포인터가 기계 주소 0을 가리킨다고 지정하지 않음](https://c-faq.com/null/confusion4.html)
- C 표준은 하드웨어가 아니라 C 추상 기계를 다루며, `NULL`과 0을 비교하면 같다는 것만 보장함
- 그 같음은 정수 0이 해당 플랫폼의 native `NULL` 값으로 변환되기 때문일 수 있고, 그 값이 `0xffff`일 수도 있음
- null pointer를 역참조하는 것은 값이 무엇이든 UB이며, C 3.4.3의 대표 예시임
- 따라서 `memset(&ptr, 0, sizeof(ptr));`가 `NULL` 포인터를 만든다고 가정할 수 없음
- 구조체를 0으로 초기화하고 멤버 포인터가 `NULL`이라고 가정하는 방식은 대부분의 프로그래머에게도 실제 문제가 됨
- [역사적으로 0이 아닌 NULL 포인터를 사용한 기계](https://c-faq.com/null/machexamp.html)도 존재했음

### 주소 0에 함수가 있다고 가정하는 문제
- 현대 기계에서 `NULL`이 주소 0을 가리키고 실제로 그 주소에 객체나 함수가 있다고 해도, C 6.3.2.3은 `NULL`이 어떤 객체나 함수와도 같지 않다고 규정함
- 따라서 다음 코드는 UB임
  ```c
  void (*func_ptr)() = NULL;
  func_ptr();
  ```
- C 관점에서는 “거기에 함수가 없다”는 의미가 되며, 컴파일러 내부에 이런 의도를 표현할 방법이 없을 수 있음
- 단순히 모든 비트가 0인 주소로 call 명령을 내보낼 것이라고 가정할 수 없음
- 16비트 x86에서는 “모든 0”이 `0000:0000`인지 `CS:0000`인지도 명확하지 않음

### 가변 인자와 타입 불일치
- `execl()`의 마지막 인자는 포인터여야 하므로, `NULL` 매크로나 정수 0을 그대로 넘기면 UB가 될 수 있음
  ```c
  execl("/bin/sh", "sh", "-c", "date", NULL);  /* WRONG */
  execl("/bin/sh", "sh", "-c", "date", 0);     /* WRONG */
  ```
- 올바른 형태는 명시적으로 포인터 타입으로 캐스팅하는 것임
  ```c
  execl("/bin/sh", "sh", "-c", "date", (char*)NULL);
  ```
- `NULL` 매크로는 정수 0으로 해석될 수 있고, 가변 인자에서는 필요한 타입 정보가 전달되지 않음
- `printf()`에서도 포맷 지정자와 실제 인자 타입이 맞지 않으면 UB임
  ```c
  uint64_t blah = 123;
  printf("%ld\n", blah);  /* WRONG */
  ```
- `uint64_t` 출력에는 `PRIu64`를 써야 함
  ```c
  uint64_t blah = 123;
  printf("%"PRIu64"\n", blah);
  ```
- `uid_t`를 출력하려면 `uintmax_t`로 캐스팅하고 `PRIuMAX`를 쓰는 방법이 있을 수 있지만, `uid_t`가 unsigned인지도 확실하지 않음
- 최악의 경우 `-1` 대신 무의미한 값이 출력될 수 있음

### 0으로 나누기와 보안 문제
- **0으로 나누기**가 UB라는 사실은 널리 알려져 있지만, 분모가 신뢰할 수 없는 입력에서 오면 보안 문제가 됨
- 단순한 런타임 오류가 아니라, 입력 검증 경계에서 UB가 발생할 수 있다는 점이 중요함

### UB는 아니지만 정수 승격도 위험함
- 정수 승격 규칙은 코드를 훑는 속도로 적용하기 어렵고, 직관과 다른 결과를 만들 수 있음
- 다음 코드에서 `overflowed`는 1이 아니라 0이 됨
  ```c
  unsigned char a = 0xff;
  unsigned char b = 1;
  unsigned char zero = 0;
  bool overflowed = (a + b) == zero;
  // overflowed is set to zero, not one.
  ```
- 다음 코드에서는 모든 변수가 unsigned처럼 보여도 결과가 `2147483648 (0x80000000)`가 아니라 `18446744071562067968 (ffffffff80000000)`가 됨
  ```c
  unsigned char a = 0x80;
  uint64_t b = a << 24;     // Bonus UB(?)
  ```
- UB가 아니더라도 C/C++의 정수 규칙은 직관적이지 않아 결함을 만들기 쉬움

### LLM을 이용한 UB 탐지
- 최신 LLM은 임의의 C 코드에서 UB를 찾도록 요청하면 거의 항상 문제를 찾아내며, 대체로 맞는 결과를 냄
- 개인 코드에서 UB를 찾은 뒤, 성숙하고 엄격하게 작성된 OpenBSD 코드에도 같은 방식이 적용됐음
- 처음 떠올린 도구인 `find`를 대상으로 여러 문제가 발견됨
- OpenBSD에는 [범위 밖 쓰기](https://marc.info/?t=177910871100018&r=1&w=2)에 대한 패치와 [UB가 아닌 논리 버그](https://marc.info/?t=177910871100010&r=1&w=2)에 대한 패치가 보내짐
- 남아 있던 많은 UB에 대해서는 패치가 보내지지 않았음
  - OpenBSD 프로젝트가 과거 버그 리포트에 매우 수용적이지 않았던 경험이 있었음
  - 실제로는 괜찮을 수 있다는 판단이 있었음
  - OpenBSD가 코드베이스에서 UB를 제거하려면, LLM과 프로젝트 사이에서 개별 패치를 전달하는 방식보다 더 큰 프로젝트가 필요함

### C/C++ 코드베이스를 다루는 현실적 방향
- 기존 C/C++ 코드베이스를 버릴 수는 없지만, 본질적으로 깨진 상태로 두는 것도 선택지가 아님
- AI가 만든 저품질 변경을 커밋하지 않으면서도, 인간 리뷰어를 압도하지 않는 방식으로 UB를 대규모로 고쳐야 함
- 2026년에 LLM의 UB 감독 없이 C나 C++를 작성하는 것은 [SOX](https://en.wikipedia.org/wiki/Sarbanes%E2%80%93Oxley_Act) 위반처럼 여겨질 수 있고, 무책임한 일로 볼 수 있음
- OpenBSD 개발자들도 30년 넘게 이런 문제를 모두 찾지 못했다면, 다른 프로젝트의 가능성은 더 낮아짐
- 개인 프로젝트에서는 LLM에 UB를 찾고, 필요하면 설명하고, 수정하게 한 뒤 사람이 결과를 확인하는 방식이 가능함
- 다만 결과를 검증하려면 전문가가 필요하고, 전문가는 보통 다른 일로 바쁨
- 이 작업은 청소 작업처럼 보이지만, 전통적으로 그런 일을 맡아온 주니어 프로그래머에게 맡기기에는 너무 미묘함

### 관련 자료
- [No way to parse integers in C](https://blog.habets.se/2022/10/No-way-to-parse-integers-in-C.html)
- [Integer handling is broken](https://blog.habets.se/2022/11/Integer-handling-is-broken.html)
- [UB in the Linux kernel](https://pooladkhay.com/posts/first-kernel-patch/)
- [Integer promotion](https://www.123microcontroller.com/en/cpp-integer-promotion/)

## Comments



### Comment 58025

- Author: neo
- Created: 2026-05-22T01:38:01+09:00
- Points: 1

###### [Hacker News 의견들](https://news.ycombinator.com/item?id=48203698) 
- C에는 놀랍고 이상한 **정의되지 않은 동작**이 많지만, 이 글은 그걸 잘 보여주지는 못하고 표면만 살짝 긁은 수준임  
  더 이상한 예로 `volatile int x = 5; printf("%d in hex is 0x%x.\n", x, x);`가 있음. `x`가 그냥 `int`면 괜찮지만 `volatile`이면 정의되지 않은 동작이 됨. C 표준상 `volatile` 접근은 읽기만 해도 부작용이고, 같은 스칼라 객체에 대한 순서 없는 부작용은 정의되지 않은 동작이며, 함수 인자 평가는 서로 순서가 불확정이기 때문임  
  흔히 **데이터 경쟁**은 서로 다른 스레드가 같은 객체에 동시에 접근하고 그중 하나 이상이 쓰기일 때를 뜻하지만, C에서는 단일 스레드에서 쓰기 없이도 데이터 경쟁 비슷한 상황이 생길 수 있음
  - 글쓴이로서 동의함. 이 글의 목적은 표준에서 `undefined`라는 단어가 나오는 283곳이나, 생략 때문에 정의되지 않은 모든 경우를 열거하는 게 아님  
    요지는 **피할 수 없다는 것**임. 적어도 1972년 C가 나온 뒤 인간이 그걸 완전히 피한 적은 없다는 뜻임  
    54년 동안 성공하지 못했다면 “더 열심히 해라”나 “실수하지 마라”는 해법이 아님. Mythos가 OpenBSD에서 발견한 악용 가능한 결함 하나는 OpenBSD 개발자들에게 꽤 좋은 평가였지만, 가장 단순한 코드에 도구를 돌려도 정의되지 않은 동작이 잔뜩 나왔음  
    예를 들어 `find`가 `waitpid(&status)` 뒤에 `waitpid()` 오류 여부를 확인하기 전에 초기화되지 않은 자동 변수 `status`를 읽는 것도 정의되지 않은 동작인데, 이게 악용 가능할 아키텍처나 컴파일러는 상상하기 어렵긴 함  
    글에도 썼듯, 세상의 모든 정의되지 않은 동작을 열거하려는 게 아니라 **모든 비사소한 C/C++ 코드에는 정의되지 않은 동작이 있다**는 점을 말하려는 것임
  - `volatile`은 **타입 시스템 해킹**임. 더 원칙적인 해결을 했어야 했고, 현대 언어가 “C가 그렇게 했으니 좋은 생각”처럼 따라 해서는 안 됨  
    초기 C 컴파일러는 늘 값을 메모리에 내보냈기 때문에, 포인터를 메모리 매핑 입출력 하드웨어에 맞춰 두면 `x`를 바꿀 때마다 CPU 명령이 실제 메모리 쓰기를 수행해서 드라이버 코드가 동작했음  
    그런데 최적화가 들어오자 컴파일러는 `x`를 계속 수정할 뿐이라고 보고 레지스터에만 두게 되었고, 드라이버가 깨졌음. C의 `volatile`은 “그 최적화는 하지 마”라고 컴파일러에 말하는 해킹이고, 반면 올바른 해결인 라이브러리 차원의 **메모리 매핑 입출력 내장 함수** 제공은 훨씬 큰 작업이었을 것임  
    내장 함수가 필요한 이유는 가능한 동작과 불가능한 동작을 정확히 표현할 수 있기 때문임. 어떤 대상에서는 1바이트, 2바이트, 4바이트 쓰기가 각각 다른 동작이고 하드웨어도 이를 구분함. 어떤 장치는 4바이트 RGBA 쓰기를 기대하는데 1바이트 쓰기 네 번을 내보내면 혼란스럽거나 동작하지 않을 수 있음. 어떤 대상은 비트 단위 쓰기도 지원함. `volatile`만으로는 무슨 일이 일어나는지, 그 의미가 무엇인지 알 방법이 없음
  - 정의되지 않은 동작과 경쟁을 구분해야 함. 이런 구분이 정의되지 않은 동작 논의에서 자주 빠짐  
    C 프로그램을 컴파일한 뒤 역어셈블하면, 정의되지 않은 동작이 없는 어셈블리 프로그램이 됨. 어셈블리에는 정의되지 않은 동작이라는 개념이 없기 때문임  
    **정의되지 않은 동작은 소스 프로그램의 속성**이지 실행 파일의 속성이 아님. 소스가 쓰인 언어 명세가 그 프로그램에 의미를 부여하지 않는다는 뜻임. 반면 컴파일 결과인 실행 파일은 기계 명세가 의미를 부여함  
    경쟁은 프로그램 동작의 속성임. 따라서 C 프로그램에는 정의되지 않은 동작이 있다고 말할 수 있지만, 실행 파일에 실제 경쟁이 생긴다고는 할 수 없음. 물론 컴파일러가 정의되지 않은 동작이 있는 프로그램을 마음대로 컴파일할 수 있으니 경쟁을 도입할 수도 있지만, 새 스레드를 만들지 않는 방식으로 컴파일하면 경쟁은 없음
  - `volatile`의 의미가 바로 값이 **다른 무언가에 의해 바뀔 수 있다**는 것임. 전역 변수라면 그 무언가는 다른 스레드뿐 아니라 인터럽트나 시그널 핸들러일 수 있음. 특정 주소를 읽는 포인터라면 값이 바뀌는 하드웨어 장치 레지스터일 수도 있음  
    `volatile` 변수 개념 자체가 문제는 아님. 인터럽트 루틴과 메모리 매핑 입출력을 지원하려는 언어라면, 같은 하드웨어 레지스터를 두 번 읽는 것이 같은 메모리 위치를 두 번 읽는 것과 다르다는 사실을 컴파일러에 알려줄 방법이 필요함  
    진짜 문제는 언어 기능과 제약의 상호작용이 충분히 정리되지 않았다는 데 있음. “이 값은 언제든 바뀔 수 있다”고 명시했는데, 바로 그 이유 때문에 어떤 사용을 정의되지 않은 동작으로 보는 건 어리석음. `volatile` 변수에 대해서는 “순서 없는 부작용” 정의에서 예외가 있었어야 함
  - 글의 핵심은 정의되지 않은 동작을 만나기 위해 이상한 코드를 쓸 필요조차 없다는 데 있음  
    많은 사람이 C와 C++가 “원하는 걸 하게 해줘서 정말 유연하다”고 착각함. 실제로는 강력하고 멋져 보이는 거의 모든 기법이 **정의되지 않은 동작 지뢰밭**임

- **정렬되지 않은 포인터**의 정의되지 않은 동작은 더 나쁨. 정렬되지 않은 포인터는 접근할 때뿐 아니라 포인터 자체만으로도 정의되지 않은 동작임  
  그래서 `void* v`를 `int* i`로 암묵 캐스팅하는 것, 예컨대 C의 `i=v`나 `int*`를 받는 `f(v)`도 결과 포인터가 `int` 정렬 조건을 만족하지 않으면 정의되지 않은 동작임  
  이건 C 수준의 문제라는 점이 중요함. C 프로그램에 정의되지 않은 동작이 있으면 그 C 프로그램은 형식적으로 유효하지 않고 잘못된 프로그램임. 하드웨어 문제가 아니며 충돌이나 결함과도 무관함  
  `void*`에서 `int*`로의 캐스팅은 하드웨어 코드로는 보통 아무것도 없고, 타입은 C에만 있으므로 하드웨어가 그 캐스팅에서 충돌하지도 않음. 레지스터 안 정수값이면 괜찮다고 생각할 수 있지만, 핵심은 하드웨어에서 포인터가 실제 정수냐가 아니라 정렬되지 않은 포인터로 캐스팅한 순간 C 프로그램이 정의상 깨진다는 것임
  - 글쓴이로서 맞음. 글의 “Actually, it was UB even before that” 절에서 다룬 내용임  
    정의되지 않은 동작은 하드웨어에 있는 게 아니고 충돌이나 결함과도 무관하다는 점도 전달하려 했음. 동시에 “보면 잘 동작하잖아”라고 말하는 사람들에게 예시를 보여주려 했고, 실제로는 그렇지 않음
  - 괜찮고 예상 가능한 일임. 좋은 프로그래머라면 **포인터 캐스팅**은 명백히 용이 있는 영역이라는 걸 앎
  - 정렬되지 않은 포인터 자체가 정의되지 않은 동작이라는 내용이 표준 어디에 있는지 알려줄 수 있나?
  - `#pragma pack(push, 1)`로 구조체를 만들면, 우연히 정렬된 멤버가 아닌 한 **멤버 포인터**를 사용할 수 없다는 뜻인가?
  - C의 정의되지 않은 동작 개념은 원래 기계어 명령이 아키텍처마다 조금씩 다르더라도 컴파일러가 코드를 하드웨어에 매핑할 자유를 준다는 뜻이었음. 같은 C 프로그램이 실행되는 아키텍처에 따라 다른 동작을 표현할 수 있었던 것임  
    이런 종류의 정의되지 않은 동작은 괜찮고, 하드웨어 차이 때문에 버그가 나는 것 자체를 크게 문제 삼는 사람은 거의 없음  
    하지만 시간이 지나면서 공격적인 해석이 C를 암묵적 **계약에 의한 설계** 언어처럼 바꿔 놓았고, 제약 조건은 보이지 않게 되었음. 이는 RAII에서 암묵적 소멸자 호출이 보이지 않는 것과 비슷한 문제를 만듦  
    C에서 포인터를 역참조하면 컴파일러는 함수 시그니처에 암묵적인 널 불가 제약을 추가함. 널일 수 있는 포인터를 함수에 넘겨도 검사나 단언이 없다는 오류 대신, 컴파일러가 그 널 불가 제약을 포인터에 조용히 전파함. 그 제약이 거짓임을 증명하면 함수를 도달 불가능으로 표시하고, 도달 불가능 함수 호출은 호출하는 함수까지 도달 불가능하게 만듦

- C에서 정의되지 않은 동작을 배우는 5단계  
  부정: “내 기계에서 부호 있는 오버플로가 어떻게 되는지 알아”  
  분노: “이 컴파일러 쓰레기네! 왜 내가 시킨 대로 안 해?”  
  타협: “C를 고치려고 wg14에 이 제안을 낼 거야…”  
  우울: “C 코드를 믿을 수 있는 게 있나?”  
  수용: “그냥 **정의되지 않은 동작**을 쓰지 마”
  - “컴파일러가 정의되지 않은 걸 정의하게 만들면 된다”는 단계는 어디에 들어가나?  
    정렬되지 않은 접근은 **패킹 구조체**를 쓰면 됨. 컴파일러가 마법처럼 올바른 코드를 생성함. 사실 컴파일러는 늘 올바르게 할 줄 알았지만 하지 않았을 뿐임  
    엄격한 별칭 규칙은 유니언 타입 변환을 쓰면 됨. 중요한 컴파일러라면 표준이 말하지 않아도 동작한다고 문서화되어 있음. 아니면 `-fno-strict-aliasing`으로 꺼버리면 됨. 메모리를 원하는 대로 재해석하면 되고, 날카로운 모서리는 있겠지만 적어도 컴파일러에서 오는 건 아닐 것임  
    오버플로는 `-fwrapv`로 정의하면 됨. `+`, `-`, `*`를 `__builtin_*_overflow`로 바꾸면 명시적 오류 검사도 공짜로 얻음. 함수형 인터페이스도 좋고 효율적인 코드도 생성됨  
    진짜 수용은 “정상적인 사람은 C 표준을 신경 쓰지 않는다”에 가까움. 표준은 형편없고 중요한 건 컴파일러임. 컴파일러에는 이런 문제 대부분을 우회할 수 있는 매우 유용한 기능이 많음. 사람들이 쓰지 않는 이유는 “이식 가능한” “표준” C를 쓰고 싶어 하기 때문이고, 그 사고방식에서 벗어나는 게 진짜 수용임  
    이런 논리로 독립 실행 환경 C에서 Lisp 인터프리터를 만들었고 UBSan도 통과했음. 처음엔 터질 줄 알았는데 그렇지 않았고, 내가 할 수 있으면 누구나 할 수 있음
  - 글쓴이로서 “그냥 정의되지 않은 동작을 쓰지 마”가 불가능하다는 게 글의 요지임  
    인간이 코드를 쓰는 한 그게 최종 상태가 될 수 없음. 어떤 인간도 C/C++에서 정의되지 않은 동작을 완전히 피할 수 없음
  - “그냥 정의되지 않은 동작을 쓰지 마”는 잘해야 아직 **타협 단계**처럼 들림
  - 나처럼 임베디드 장치에서 일하면 됨. 특정 CPU를 대상으로 소프트웨어를 쓰는 건 정말 편함
  - C에서 수용은 “나는 **정의되지 않은 동작**을 쓸 것이고, 언젠가 나쁜 일이 생길 것이다”에 가까움

- 예시들은 실제 정의되지 않은 동작이라기보다 입력이나 상황에 따라 정의되지 않은 동작이 될 수 있는 사례에 가까움  
  그렇게 넓게 잡으면 모든 함수 호출도 스택 공간을 초과할 수 있으니 정의되지 않은 동작임. 사실 어떤 언어에서도 비슷한 의미로는 그렇다고 볼 수 있음  
  C에는 주목할 만한 실제 거친 부분이 충분히 많은데, 이런 식의 선정성은 특히 초보자의 주의를 흐리고 오히려 해가 될 수 있음
  - **Ada 83**은 호출 스택 오버플로를 정의되지 않은 동작으로 두지 않음. 참조 매뉴얼에 `STORAGE_ERROR` 예외가 정의되어 있음  
    [http://archive.adaic.com/standards/83lrm/html/lrm-11-01.html](<http://archive.adaic.com/standards/83lrm/html/lrm-11-01.html>)  
    “서브프로그램 호출 실행 중 저장 공간이 충분하지 않은 경우”에도 이 예외가 발생한다고 되어 있음
  - 전혀 맞지 않음  
    우선 스택 공간을 초과했을 때 어떤 일이 일어나는지 정의할 수 있음. 또한 모든 프로그램이 임의 크기의 스택을 필요로 하지는 않고, 어떤 프로그램은 사전에 계산 가능한 상수 크기만 필요함. 어떤 언어 구현은 스택을 아예 쓰지 않기도 함  
    언어가 남은 스택 공간을 확인하는 도구를 제공하고 그에 따른 보장을 할 수도 있음. 또는 스택 공간이 바닥났을 때 실행할 처리기를 설치하게 할 수도 있음
  - 입력에 따라 발생하는 정의되지 않은 동작도 **악용 경로**가 될 수 있음
  - 예시들은 명백히 정의되지 않은 동작임. 끝임  
    올바른 사고방식은, 정의되지 않은 동작이 생긴 순간 더 이상 언어 표준의 보호 아래 있지 않다는 것임. 한동안, 어쩌면 영원히 잘 동작할 수도 있음. 하지만 실제로는 자신도 모르게 도구체인, 컴파일러 교체나 업그레이드, 아키텍처, 런타임, libc 버전 차이의 변덕에 종속됨  
    결국 **모래 위에 기초**를 세우게 되고, 그게 정의되지 않은 동작의 위험임
  - 이 글은 거의 **FUD**의 정의에 가까움

- 정의되지 않은 동작의 문제는 어떤 아키텍처에서 충돌할 수 있다는 게 아님  
  진짜 문제는 컴파일러가 그런 코드가 절대 발생하지 않는다고 기대한다는 것임. 그래도 정의되지 않은 동작 코드를 쓰면 컴파일러, 특히 최적화기는 정상 경로에 편한 어떤 형태로든 번역할 수 있음. 그 “어떤 것”이 때로는 큰 코드 덩어리 삭제처럼 매우 예상 밖일 수 있음
  - 이와 관련된 예로 모든 함수는 종료하거나 부작용을 가져야 한다는 조건이 있음. 아직 직접 당해 본 적은 없지만, 실수로 무한 루프나 재귀를 작성했더니 함수가 삭제되는 상황은 충분히 상상 가능함  
    꼬리 재귀까지 얹히면 디버그 빌드에서는 무한 루프에 도달하지 않다가 최적화 수준을 올렸을 때만 버그가 드러날 수도 있음
  - 충돌은 정의되지 않은 동작 중 가장 온건한 편임. 적어도 눈에 잘 보이기 때문임  
    더 나쁜 경우에는 프로그램이 조용히 쓰레기값으로 계속 실행되거나, 하드디스크를 포맷하거나, 공격자에게 왕국의 열쇠를 넘겨줄 수 있음
  - 맞지만, 이것이 정의되지 않은 동작의 가장 유용한 기능이자 존재 이유이기도 함  
    그냥 정의하거나 미지정 동작으로 만들자고 하는 사람들은 컴파일러가 프로그램의 큰 부분을 제거할 수 있다는 점이 핵심이라는 걸 놓침  
    특정 입력에 대해 정의되지 않은 동작이 되는 코드를 쓴다면, 그 입력에 대해서는 프로그램이 어떤 동작도 갖지 않기를 의도한 것임. 컴파일러가 그 경로를 최적화로 없애거나, 정의된 다른 경우의 동작에 도움이 되는 어떤 처리를 해주길 바람  
    정의되지 않은 동작을 통해서만 도달 가능한 로그 문자열을 넣고, 바이너리에 그 문자열이 남지 않는 걸 보면 꽤 만족스러움
  - 글에서 **최적화의 문제가 아니다**라고 한 부분이 특히 눈에 들어왔음  
    예전에 변환 파이프라인의 마지막에 실행된다는 가정하에 분석 패스를 작성했고, 그 가정이 정확성에 필요했음. 더 이상 최적화가 일어나지 않으니 안전하다고 봤는데, 이제는 확신이 없음
  - 그건 문제가 아니라 기능임

- 20년 동안 C를 써 왔지만 최근 6개월 Hacker News에서 본 것만큼 **정의되지 않은 동작** 이야기를 많이 들은 적이 없음  
  실제 대화에서는 거의 나오지 않았음. 코드를 쓰고, 안 되면 디버그해서 수정하거나 우회하면 됨. 왜 C의 정의되지 않은 동작이라는 주제가 이렇게 꾸준히 1면에 올라오는지 모르겠음
  - Hacker News는 여전히 실제 프로그래밍보다 **프로그래밍 언어**에 관심 있는 쪽으로 기울어 있음. Y Combinator의 Lisp 유산 같은 것도 있을 듯함  
    새 프로그래밍 언어를 개발하거나 사용하는 것이 세상에서 제일 흥미롭다고 여기는 컴퓨터과학 전공자 소수도 꾸준히 있고, 그중 일부는 계속 그렇게 생각함  
    그런 사람들이 언어 설계 측면에 관심을 갖는 건 자연스럽고, C의 정의되지 않은 동작은 그 영역에 속함. 다만 원래 많은 부분은 오래된 CPU 아키텍처를 성능 손실 없이 수용하려던 것이었고, 바퀴가 둥근 것만큼이나 “설계 선택”이라고 부르기 애매한 면도 있음
  - 무슨 소리인가? 20년 전에도 C와 C++를 썼고, 그때도 정의되지 않은 동작은 대화와 교육과정에서 큰 비중을 차지했음  
    GCC 3.2 전후로 컴파일러가 최적화에서 정의되지 않은 동작을 훨씬 공격적으로 활용하기 시작하면서 꽤 유명한 “스캔들”들이 있었고, 그 때문에 많은 사람이 GCC 2.95에 오래 머물렀음. GCC 3.2는 2002년에 나왔음
  - 예전 컴퓨터는 멋졌고, 지금 컴퓨터는 위험해졌음  
    모든 회사가 안전과 노출, 즉 뉴스에 오르는 일을 계속 강조하니 “안전하지 않음”에 반대하는 서사가 과도하게 커졌음  
    새 세상은 날것의 자연을 본 적 없는 도시 사람들이 잔디깎이를 보고 겁먹는 것과 비슷함. 칼날이 돈다고? 말도 안 돼!
  - 운영 환경이 완전히 다른 아키텍처일 수 있으니 이런 세부사항은 매우 중요함  
    실제 목표가 외딴곳 통신탑 위의 작은 임베디드 시스템이라면 “내 기계에서는 동작함”은 쓸모가 없음. 물론 대부분은 그런 일을 하지 않고, 여기 개발자의 대다수도 웹 개발자일 가능성이 크지만, 직접 겪지 않았더라도 흥미로운 논의임. 오히려 그런 경우 더 그럴 수도 있음
  - 정확히는 상상 속 명세가 아니라 **목표 대상**에 맞춰 작성하는 것임. 명세는 목표 대상이 대략 무엇을 하는지 예측하는 데 유용할 뿐, 규범은 아님  
    컴파일러에는 명세상 동작해야 하는데 그렇지 않은 버그가 있을 수 있고, 표준 대응물이 없는 확장도 많으며, 표준에서는 정의되지 않은 것이라도 구현별로 의미 있는 결과가 할당된 동작도 있음

- 도입부에는 대체로 동의하지만, 예시가 좋지 않고 글 전체가 **LLM 코딩**을 밀기 위한 포장처럼 보임
  - 맞음. 예시들은 하나하나가 이식 가능한 코드를 쓸 때 피하는 표준적인 것들이거나, 주소 0의 객체 접근처럼 필요 없는 것들임  
    원하는 대로 아무 코드나 쓰고 모든 환경에서 똑같이 동작하길 바라는 사람처럼 보임. 그런 언어로 만들면, 원할 때 플랫폼에 맞춰 작성할 수 있다는 장점이 사라짐
  - 어떻게 좋지 않다는 건가? 참이라면 그건 꽤 심각함

- 이 글의 C++ 코드는 일부가 10년 넘게 관용적이지 않았고, 오늘날에는 **코드 냄새**로 여겨질 만함  
  언어는 처음 만들어졌을 때와 상당히 다른 언어로 진화했음. 원시 포인터와 직접 포인터 접근이 잔뜩 보이는 순간, 글의 일부는 어느 정도 걸러 들어야 한다는 게 분명했음  
  또 다른 명백한 문제는 C와 C++를 거의 같은 언어인 것처럼 한데 묶는 관점임. 요즘 두 언어는 실제로 꽤 멀리 떨어져 있음
  - 코드가 C++가 아니라 C라고 지적하려다가 다시 확인해 보니, 실제로 `atomic_int`가 아니라 `std::atomic`이라고 되어 있었음

- C의 정의되지 않은 동작을 이렇게 이해하면 맞나?  
  프로그램 P에는 정의되지 않은 동작을 유발하지 않는 입력 집합 A와, 유발하는 보완 집합 B가 있음  
  올바른 컴파일러는 P를 실행 파일 P'로 컴파일함. A의 모든 입력에 대해 P'는 P와 동일하게 동작해야 함  
  하지만 B의 어떤 입력에 대해서도 P'의 동작에는 **아무 요구사항도 없음**
  - 직관적으로는 맞음. 프로그램은 B 입력이 절대 전달되지 않는 것처럼 컴파일되고, 여기에는 B 입력을 감지하려는 코드 제거도 포함될 수 있음
  - 좋은 요약임

- 정렬되지 않은 포인터 때문에 생긴 정의되지 않은 동작의 구체적 예시: [https://pzemtsov.github.io/2016/11/06/bug-story-alignment-on...](<https://pzemtsov.github.io/2016/11/06/bug-story-alignment-on-x86.html>)
  - 특히 문제가 없을 거라고 흔히 가정하는 **x86**에서의 사례임
