1P by GN⁺ | ★ favorite | 댓글 1개
  • float의 소수부를 버린 값이 대상 정수형 범위를 벗어나면 정의되지 않은 동작(UB) 이 발생하며, 암시적 변환·함수형 캐스트·static_cast 모두 영향받음
  • -Wall-Wextra는 이를 경고하지 않고, -Wconversion암시적 변환만 탐지해 놓치기 쉬움
  • Microsoft GSL의 안전한 축소 변환 함수 gsl::narrow도 일부 부동소수점→정수 입력에서 UB를 일으켜, 표현할 수 없는 값에 예외를 던진다는 문서의 동작을 지키지 못함
  • x86의 CVTTSS2SI는 표현 불가능한 값을 INT_MIN으로 처리하지만, AArch64의 FCVTZS는 포화 변환하고 NaN을 0으로 바꾸므로 하드웨어별 결과가 달라질 수 있음
  • 안전하게 변환하려면 캐스트 전에 범위를 검사해야 하며, Clang·GCC의 UBSan 옵션 -fsanitize=float-cast-overflow로 문제를 탐지할 수 있음

변환 규칙과 탐지의 한계

  • C++ 부동소수점-정수 변환 규칙에 따르면, 소수부를 버린 뒤 값이 대상 정수형에 들어가지 않으면 정의되지 않은 동작이 됨
    • 대상이 unsigned여도 모듈러 산술은 적용되지 않음
    • int i0 = f, int(f), static_cast<int>(f) 모두 일부 입력에서 UB를 일으킴
  • 일반적인 컴파일러 경고만으로는 문제를 모두 찾기 어려움
    • -Wall-Wextra는 세 변환 모두 경고하지 않음
    • -Wconversion은 암시적 변환만 경고함
  • 현재 프로세서와 컴파일러에서 프로그램이 계속 실행되더라도 결과는 플랫폼마다 달라질 수 있음
    • x86의 CVTTSS2SI는 표현할 수 없는 입력을 INT_MIN으로 매핑함
    • AArch64의 FCVTZS는 포화 처리하고 NaN을 0으로 매핑함
    • 실행된 UB는 컴파일러가 다른 변환을 적용할 때 코드가 갑자기 오작동하는 원인이 될 수 있음

GSL 사례와 안전한 대응

  • Microsoft Guidelines Support Librarygsl::narrow는 대상 형식으로 표현할 수 없는 값에 예외를 던지는 안전한 축소 변환을 표방함
    • 실제 부동소수점→정수 변환은 일부 입력에서 먼저 UB를 실행하므로 문서와 일치하지 않음
    • GSL 측은 대상 플랫폼에서 하드웨어 트랩 표현을 건드리지 않아 내부 UB가 무해하다고 판단했으며, 이 논리가 코드에 반영된 채 문제는 수정되지 않음
  • 올바른 해결책은 캐스트 전에 범위를 검사하는 것임
  • Clang과 GCC의 Undefined Behavior Sanitizer에서 -fsanitize=float-cast-overflow를 사용하면 해당 UB를 탐지할 수 있음
    • 모든 C++ 코드를 UBSan으로 테스트하는 방식을 권장함

댓글과 토론

Lobste.rs 의견들
  • C와 C++의 미묘한 미정의 동작을 많이 알고 있었는데도 이 사례는 놀라웠음
    Rust도 LLVM IR의 같은 부동소수점→정수 변환 규칙을 물려받아 한동안 미정의 동작이 있었고, 2020년에야 더 복잡한 IR을 생성하도록 수정함. 성능 차이가 커서 값이 유한하고 대상 타입 범위에 들어간다는 걸 아는 고성능 반복문용으로 검사 없는 부동소수점→정수 변환도 제공함
    C++ Core Guidelines Library가 이를 대수롭지 않게 여기는 건 터무니없음. LLVM은 변환에서 얻은 정보를 이용해 경계 검사를 제거했고, 그 결과 경계 검사가 있었는데도 배열 범위를 벗어난 사례가 있음. 메모리 안전성과 미정의 동작 회피를 진지하게 여긴다면 이를 무시할 수 없으며, Herb Sutter가 “양성 미정의 동작”을 말한 것도 실망스러움

  • C++에 미정의 동작이 많다는 건 알았지만 이것은 특히 놀라움. 변환을 최대한 빠르게 만들려 했고, 아키텍처마다 이 극단값을 다르게 처리하는 명령어가 있어서 미정의 동작으로 둔 것인지 궁금함. 부호 있는 정수 오버플로와 비슷하게 이런 규칙이 생기는 주된 이유로 보임

    • 그 이유라면 미정의 동작이 아니라 구현 정의 동작이어야 함. 0으로 나누기는 일부 아키텍처에서 트랩되므로 미정의 동작인 것이 이해됨
      미정의 동작은 해제 후 사용처럼 C 추상 기계 밖의 효과 때문에 같은 플랫폼에서도 일관된 결과를 보장할 수 없거나, 일부 대상에서 트랩될 수 있는 경우로 제한해야 함
      표준 준수 구현은 미정의 동작을 자체적으로 정의할 수 있고 GCC도 일부 항목에서 그렇게 함. 안정적인 의미를 성능 비용 없이 보장할 수 있다면 대상별 부동소수점→정수 명령어의 동작을 그대로 정의해도 되지만, 다른 구현에서는 여전히 미정의 동작이 됨
    • 아마 그럴 것임. PowerPC의 fctiw 계열은 포화 변환하며 NaN을 INT_MIN으로 바꾸고 FPSCR 플래그도 설정함. 64비트 Power ISA의 fctid도 더 큰 정수에 같은 처리를 하지만, 어느 쪽도 AArch64나 x86의 동작과 일치하지 않음
  • 이런 경우에는 구현 정의 동작이나 지정되지 않은 값으로 처리하는 편이 맞음. 무한대를 int로 변환했다는 이유만으로 프로그램 전체가 C++ 표준의 통제를 벗어나는 건 말이 안 됨
    C++26은 몇 가지 불합리한 미정의 동작을 제거했으며, 이것도 명백한 제거 후보임. 실험상 GCC와 Clang이 이 미정의 동작을 최적화에 활용하지 않는 듯해 실제 영향은 제한적으로 보임

  • 명세가 이 연산을 미정의 동작으로 규정할 이유가 없는 또 하나의 불쾌한 사례임

  • IEEE 754를 싫어할 이유가 하나 더 생김. 다른 라이브러리나 성능 때문에 꼭 필요하지 않다면 부동소수점 대신 순수 정수, 큰 정수 분자·분모를 쓰는 유리수, 고정소수점 십진수를 최대한 사용함

    • 이번 사태는 IEEE 754와 무관하며 전적으로 C++의 잘못임
  • C나 C++를 사용했다면 float32int32, 심지어 int64의 차이도 놀라운 일이 아니어야 함. 큰 지수를 쓰는 float32int64보다 훨씬 큰 정숫값을 나타낼 수 있음
    서로의 상위 집합이 아니고 표현 능력이 다른 형식 사이에서, 언어 문법과 무관하게 부동소수점→정수 변환이 안전하다고 가정할 이유가 없음

    • 핵심을 놓치고 있음. 부동소수점의 모든 값을 보존할 수 없다는 것과 미정의 동작은 다름
      uint32_tuint64_t의 모든 값을 표현할 수 없지만 변환 의미는 잘라내기로 정의되어 있음. 여기서는 특정 입력에서 컴파일러가 무엇이든 할 수 있다는 게 본질적으로 다른 문제임
    • 표현 범위가 다르다고 안전한 변환을 정의할 수 없는 것은 아님. Rust는 부동소수점→정수 변환을 명시적으로 정의하고, Java 26 언어 명세는 5.1.3절에서 변환 절차를 상세히 규정함
      저수준 언어라면 CVTTSS2SIFCVTZS 같은 어셈블리 명령어에 대응시키는 방식도 가능함. 저수준 언어가 어셈블리와 밀접하고 다른 “양성” 미정의 동작도 논란이 큰 만큼, 이 변환이 미정의 동작을 허용한다는 사실이 오히려 더 놀라움
    • 하드웨어에 따라 구현별 쓰레기 값을 내거나, 하드웨어·검사 도구가 트랩해 프로그램을 중단하는 것은 크게 놀랍지 않음
      하지만 무관한 코드를 이상하게 오컴파일하거나, 하드 드라이브를 포맷하거나, “코에서 악마가 날아 나오게” 하는 것까지 허용된다는 건 놀라움
      이중 해제나 배열 범위 밖 쓰기에는 무엇이든 일어날 수 있다는 미정의 동작 개념이 타당하지만, C와 C++는 구현 정의 쓰레기 값이나 프로그램 중단처럼 더 엄격한 규칙을 둘 수 있는 곳에도 이를 남발함. Rust는 일부 정수 오버플로를 디버그 모드에서 트랩하고 릴리스 모드에서 지정되지 않은 값을 반환하지만, 엉뚱한 코드까지 망가뜨리지는 못하게 함
      C++가 Java나 Rust가 될 필요는 없지만, 불필요한 미정의 동작을 줄이면 분명 더 나은 언어가 됨
    • 나에게는 새로운 사실이었음. 표현할 수 없는 부동소수점을 정수로 변환하면 결과 정수에 엉터리 값이 들어가는 정도로 생각했지, 프로그램 전체가 영원히 오염되어 C++ 표준의 통제 밖으로 나간다고는 몰랐음