여러분이 쓰는 C는 표준 C가 아닙니다

Your C is not standard C

sebsite.pw ▲ 125 댓글 100 dverlaeckt80

요약

GCC·Clang과 glibc·musl로 쓰는 실제 C는 ISO 표준 C와 여러 곳에서 어긋납니다.

sebsite.pw의 글쓴이가 표준대로 짠 코드가 흔한 도구의 기본 설정에서 컴파일되지 않거나 다르게 동작하는 사례를 모았습니다. 글쓴이는 이 목록이 완전하지 않다고 직접 밝혔습니다.

왜 중요한가

  • 표준 문서만 보고 짠 코드라도 널리 쓰는 컴파일러와 C 라이브러리에서 그대로 통한다는 보장이 없습니다.
  • 어긋남이 C 라이브러리에도 있어서, 컴파일러가 새 표준을 지원해도 쓸 수 없는 기능이 생깁니다.
  • 글쓴이는 -std 옵션이 있어도 기본값이 표준과 다르면 문제라고 봤습니다.

핵심 내용

  • GCC는 C99와 C23이 정한 부동소수점 pragma(컴파일러에 주는 지시문)를 하나도 지원하지 않습니다.
  • Clang은 C99 pragma는 대부분 지원하지만 C23에서 새로 생긴 것은 지원하지 않습니다.
  • GCC는 실행 중에 반올림 방식을 바꿔도 FLT_ROUNDS 값을 1(가장 가까운 값으로 반올림)로 고정해 둡니다.
  • 표준은 asm을 일반 이름으로 쓰게 허용하지만, GCC와 Clang은 기본 설정에서 이를 키워드로 취급합니다.
  • musl은 자체 stdarg.h가 컴파일러 헤더를 덮어써서 C23이 허용한 인자 하나짜리 va_start를 쓸 수 없습니다.

HN 반응

  • char를 unsigned로 못 박자는 주장에, x86-64 주요 컴파일러는 signed라서 오히려 코드가 더 깨진다는 반론이 나왔습니다.
  • 음수 왼쪽 시프트를 정의되지 않은 동작(표준이 결과를 보장하지 않음)으로 둔 이유를 두고, 트랩 대비라는 추측과 혼란만 키웠다는 비판이 맞섰습니다.

댓글

30개 표시 · 전체 100개
  1. omoikane HN

    GCC가 표준 pragma를 아직도 구현하지 않는다니, 옛날 GCC pragma 이스터에그가 떠오르네요. gcc 0.9의 cccp.c에 있던 코드입니다.

        /*
         * the behavior of the #pragma directive is implementation defined.
         * this implementation defines it as follows.
         */
        do_pragma ()
        {
          close (0);
          if (open ("/dev/tty", O_RDONLY) != 0)
            goto nope;
          close (1);
          if (open ("/dev/tty", O_WRONLY) != 1)
            goto nope;
          execl ("/usr/games/hack", "#pragma", 0);
          execl ("/usr/games/rogue", "#pragma", 0);
          execl ("/usr/new/emacs", "-f", "hanoi", "9", "-kill", 0);
          execl ("/usr/local/emacs", "-f", "hanoi", "9", "-kill", 0);
        nope:
          fatal ("You are in a maze of twisty compiler features, all different");
        }
    

    gcc 1.21에서는 사라졌고요.

        #if 0
        /* This was a fun hack, but #pragma seems to start to be useful.
           By failing to recognize it, we pass it through unchanged to cc1.  */
        ...
        #endif
    
  2. im3w1l HN

    그 게임들에 명령줄 인자로 #pragma를 넘기는 게 무슨 의미인가요?

  3. mitxela HN

    프로세스 이름이에요.

  4. im3w1l HN

    그래도 그러면 더 수수께끼 같은데요. 왜 굳이 #pragma로 정하는 거죠? 프로그램 동작에 무슨 영향이 있나요? 그리고 emacs에는 왜 -f가 첫 번째 인자가 아니라 프로세스 이름 자리로 들어가는 건가요?

  5. sethev HN

    농담이었고, 코드를 쓴 사람이 그게 웃기다고 생각했던 모양입니다. execl은 현재 실행 중인 프로세스(컴파일러)를 새 프로세스로 아예 바꿔치기하기 때문에, 이 코드는 쓸모 있는 일을 전혀 하지 않습니다.

  6. AnimalMuppet HN

    제가 알기로는 동작은 전혀 바뀌지 않습니다. "ps"를 실행하면 "hack"이나 "rogue" 대신 "#pragma"로 보인다는 뜻인 것 같습니다.

  7. MisterTea HN

    Plan 9 C를 쓰는 걸 좋아하고 즐기는 사람으로서 말하자면, 요즘 말하는 "표준 C"가 뭘 뜻하든 간에 그건 엉망진창이에요. 꼽자면 끝이 없습니다. 오픈소스 컴파일러가 둘뿐인데 둘 다 비대한 괴물이고, C99 이후에 추가된 건 하나같이 쓸데없고, 인라인 어셈블리는 이식성을 망치고, POSIX는 불에 타 죽어버렸으면 좋겠어요(타자기 터미널도 같이요).

    제 친구가 지금 C 위원회에 있는데, 이미 구현됐거나 앞으로 구현될 나쁜 아이디어가 너무 많아서 아마 그만둘 거래요.

    • 아니, 소스 파일 곳곳에 못생긴 #ifdef를 수백만 개 뿌려 놓는 건 보기 흉하고 읽기도 힘들다고요. 제발 코드를 좀 나누세요.
  8. WalterBright HN

    C 표준에서 char는 부호 없는 타입이라고 정해 주면 좋겠어요. 부호 여부를 구현 정의로 둬서 생긴 피해는 헤아릴 수도 없습니다.

    (D는 char가 부호 없는 타입이라고 못 박았어요. 헛소리는 이제 그만!)

    C에는 제대로 된 배열도 필요해요. D는 그걸 갖췄는데, 그 기능 하나만으로도 금덩이만큼 값어치가 있습니다.

    https://www.digitalmars.com/articles/C-biggest-mistake.html

    배열 오버플로가 더는 없다는 게 정말 너무 좋아요.

  9. avadodin HN

    전통적인 C에서는 모든 게 int이고 다른 기본 타입도 unsigned라고 명시하지 않으면 전부 부호 있는 타입이니, 부호 있는 타입으로 정하는 게 아마 옳은 선택일 겁니다.

    기본 타입을 기본적으로 부호 없게 만드는 것에 반대하는 건 아닙니다. 다만 C에서는 아니라는 거죠.

  10. WalterBright HN

    문자 타입이 부호가 있다는 건 말이 안 돼요. 그래서 D에는 이런 타입이 있습니다.

        char - unsigned 8 bits
        byte - signed 8 bits
        ubyte - unsigned 8 bits
    

    char는 utf-8용이고, byte와 ubyte는 부호 있는/없는 작은 정수예요. 사소해 보여도 이 덕분에 D 코드가 C보다 훨씬 읽기 쉬워집니다.

  11. JdeBP HN

    그건 일관성 있는 선택이겠죠. 하지만 그게 옳은 선택인지는 따져 볼 만합니다. <ctype.h> 함수 같은 것들은 부호 있는 char를 잘못 넘기면 우연이든 의도든 가끔 제대로 동작하지 않고, 부호 확장 때문에 우연히 EOF 값이 나오는 경우도 있으니까요.

  12. WalterBright HN

    기본값이 signed char인 C 컴파일러는 정말 오랫동안 못 봤어요. 이제 signed char는 버릴 때가 됐습니다. 그래야 사람들이 기본적으로 이식성 있는 코드를 쓸 수 있어요. 필요할 때만 signed char를 쓰면 되죠.

    컴파일러 옵션으로 char를 부호 있는 타입으로 바꾸면 기존 C 코드가 많이 깨질 거라고 장담합니다.

    C 코드를 리뷰하다가 그냥 char가 보이면, 구현 정의인 부호 여부를 코드가 제대로 처리하는지 따져 보느라 시간을 낭비해야 하잖아요.

    그리고 stdint.h가 있다는 것도 알아요. 이 문제를 해결해 보려던 서툰 시도죠.

  13. dzaima HN

    ...그럼 x86-64를 겨냥하는 C 컴파일러는 안 쓰시나 보네요? 제가 테스트해 본 주요 환경(gcc+리눅스, clang+리눅스, clang+윈도, msvc+윈도)은 전부 x86-64에서 signed char를 씁니다. 그러니 unsigned char를 강제하면 signed char를 강제할 때보다 깨지는 프로그램이 더 많을 가능성이 커요.

  14. pjmlp HN

    아, 그럼 Java가 부호 있는 바이트를 택한 건 옳은 결정이었네요! /s

  15. peterfirefly HN

    그런데 VAX는 부호 있는 타입이길 정말, 정말 원하거든요.

  16. WalterBright HN

    VAX는 유물이에요.

  17. glouwbug HN

    Plan 9의 상속은 정말 좋습니다. 지금도 gcc -fplan9-extensions로 쓸 수 있어요. 태그가 붙은 enum과 void 포인터의 배열로 꽤 괜찮은 다형성 작업 방식을 만들 수 있습니다.

  18. jcranmer HN

    C99 이후 기능 가운데 가치가 의심스러운 게 많긴 하지만(_Generic은 전형적인 "집에도 템플릿 있는데" 같은 기능이죠), 있어서 정말 반가운 것도 있습니다.

    • C11의 thread_local과 _Atomic 지원

    • C11의 fopen("foo", "x")

    • C11의 aligned_alloc

    • C11의 CMPLX 매크로

    • C++ 어트리뷰트 문법 (제게는 [[foo]]가 attribute((foo))보다 훨씬 매끄럽게 읽혀요)

    • C23에서 마침내 _Who _The _Hell _Thinks _Users _Like _This 식의 _Keywords가 사라졌다는 것

    • C2y의 다중 수준 break/continue

    • if 조건 안에서의 변수 선언

    • ckd_add나 stdc_rotate_left 같은 새 연산 몇 가지. 이름은 마음에 안 들지만, 뭐 어때요. 적어도 C가 쓸 만한 기본 연산 집합을 갖추기 시작했다는 거니까요.

  19. Joker_vD HN

    또 C23(과 C2y)은 의미론을 제대로 정의하는 쪽으로 점점 다가가고 있습니다. 이를테면 "코드의 어느 분기에서, 그것도 특정 입력에 대해 배열 범위 밖의 값을 읽었다고 해서, 프로그램 전체의 실행이 갑자기 처음으로 되감기고(I/O까지 포함해서) NOP이나 무한 루프로 빠지거나 main() 뒤의 임의 바이트를 실행해 버리는" 일이 없어지는 거죠.

    저는 개인적으로 "왼쪽 시프트" 연산자에 대한 표준의 서술이 더 깔끔해지길 아직도 기다리고 있습니다.

        If E1 has an unsigned type, the value of the result is E1 × 2**E2, wrapped
        around. If E1 has a signed type and nonnegative value, and E1 × 2**E2 is
        representable in the result type, then that is the resulting value; otherwise,
        the behavior is undefined.
    

    여기서 "otherwise"가 무슨 뜻일까요? "E1이 unsigned 타입이거나, 음수이거나, E1 × 2E2를 표현할 수 없으면 동작은 미정의"라는 뜻일 수는 없습니다. "E1이 unsigned 타입이면"은 앞 절에서 이미 다뤘으니까요. 그럼 "E1이 음수이거나 E1 × 2E2를 표현할 수 없으면 동작은 미정의"라는 뜻일까요? 그건 터무니없는 규정입니다. (-1) << 1이 UB라니요?!

  20. jcranmer HN

    "E1이 음수이거나 E1 × 2*E2를 표현할 수 없으면 동작은 미정의"라는 뜻일까요? 그건 터무니없는 규정입니다.

    정확히 그런 뜻입니다. https://www.open-std.org/jtc1/sc22/wg14/www/docs/n723.htm 에 언급된 변경 내용을 보세요. (이전 C89에서는 부호 있는 정수의 왼쪽 시프트가 완전히 미정의였고, 정의된 왼쪽 시프트 연산이 있는 건 부호 없는 정수뿐이었습니다.) 제 짐작에는 부호-크기(signed-magnitude) 표현 때문에 음수의 왼쪽 시프트가 완전히 엉망이 되는 것과 관련이 있는 것 같아요. 표준에서 미정의 동작이라고 해서 구현이 그 동작을 정의하는 게 막히는 건 아니라는 점도 기억해야 합니다. C89와 C99에서 도입된 미정의 동작 가운데 상당수가 원래 꽤 자유분방했던 만큼 특히 그렇고요.

    또 C23(과 C2y)은 의미론을 제대로 정의하는 쪽으로 점점 다가가고 있습니다.

    글쎄요, 소시지가 만들어지는 과정을 좀 지켜본 입장에서 말하자면, 표준에서 미정의 동작을 없애려는 의욕이 지나쳐서 오히려 품질이 나빠진 경우가 분명히 있었습니다.

  21. derf_ HN

    > 이전 C89에서는 부호 있는 정수의 왼쪽 시프트가 완전히 미정의였습니다...

    아니요, 그건 구현 정의였고, 둘은 아주 다릅니다. 여러분이 만났을 법한 구현은 전부 그걸 합리적으로 정의했어요. 지금은 일부를 미정의로 바꿔 놓은 탓에, 원하는 동작을 얻으려고 unsigned로 이리저리 캐스팅하는 매크로를 쓰는 코드가 잔뜩 생겼습니다. 결과를 표현할 수 있다는 게 증명되는 경우에는 왼쪽 시프트를 곱셈으로 바꾸고 최적화 컴파일러가 알아서 되돌려 주기를 바라기도 하고요. 이제 컴파일러가 음수의 왼쪽 시프트에는 무조건 경고를 띄우기 때문이에요. 그 의미가 대응하는 곱셈과 같아야 마땅한데도 말이죠.

    한마디로 별다른 이유도 없이 난장판만 만든 셈입니다.

  22. jcranmer HN

    C89에서 왼쪽 시프트 연산자의 동작에 대한 정의는 이게 전부입니다.

    E1 << E2의 결과는 E1을 왼쪽으로 E2비트 자리만큼 시프트한 값이며, 비워진 비트는 0으로 채워진다. E1이 부호 없는 타입이면 결과값은 E1에 2의 E2제곱을 곱한 값이고, E1이 unsigned long 타입이면 ULONG_MAX+1로, 그 밖의 경우에는 UINT_MAX+1로 나눈 나머지로 줄인다. (상수 ULONG_MAX와 UINT_MAX는 헤더 <limits.h>에 정의되어 있다.)

    빠진 게 뭔지 보이시나요? E1이 부호 있는 타입이면 어떻게 되는지입니다. 표준은 아무 말도 하지 않아요. 그리고 표준이 아무 말도 하지 않으면 그건 미정의 동작입니다.

  23. derf_ HN

    방금 인용하신 3.3.7절보다 몇 쪽 앞, 3.3절 첫머리에 이런 말이 있습니다.

          Some operators (the unary operator ~ , and the binary operators << ,
      >> , & , ^ , and | , collectively described as bitwise operators)
      shall have operands that have integral type.  These operators return
      values that depend on the internal representations of integers, and
      thus have implementation-defined aspects for signed types.
    
  24. MobiusHorizons HN

    그 발췌문만 보면 비트 수준의 동작은 아주 잘 정의되어 있는 것 같은데요. 결과의 숫자 값이 뭔지는 부호 있는 정수가 어떻게 정의되어 있는지 알아야 하겠지만요. 구현에 따라 달라질 수는 있어도 완전히 예측 가능한 동작일 텐데, 제가 뭔가 놓치고 있나요?

  25. MobiusHorizons HN

    그런 시프트가 구현별 동작이 아니라 UB인 이유가 정말 궁금합니다.

    부호 있는 시프트가 하드웨어가 부호 있는 수를 비트 수준에서 표현하는 방식과 부딪힌다는 건 이해가 가요. 그래도 어떤 플랫폼이든 어셈블리 쪽에서 동작이 불분명한 건 아니잖아요.

  26. Joker_vD HN

    옛날 기계 중에는 부호 있는 오버플로에서 트랩이 걸리는 것들이 있었습니다. 논리 왼쪽 시프트가 아니라 산술 왼쪽 시프트가 일으키는 오버플로도 포함해서요. 그리고 구현 정의 동작을 "아, 이 경우엔 트랩이 걸려요"라고 정의할 수는 없는 모양입니다. 마지막 부분은 여전히 믿기지 않지만요.

    하지만 그게 사실이라면 (어느 정도는) 말이 되긴 하겠네요. "어느 정도"라고 한 건, 이 경우 구현이 int든 unsigned int든 시프트 연산에 그냥 "논리 왼쪽 시프트" 명령어를 내보내면 되기 때문입니다. 레지스터의 부호 여부를 실제로 추적한 기계가 있었다고는 생각하지 않아요.

  27. Joker_vD HN

    제 짐작에는 부호-크기(signed-magnitude) 표현 때문에 음수의 왼쪽 시프트가 완전히 엉망이 되는 것과 관련이 있는 것 같아요.

    우선, 왜 구현 정의로 하지 않았을까요? 그게 프로그래머한테는 훨씬 쓸모 있어 보이는데요. 둘째로, 제가 아는 부호-크기 표현 기계는 IBM 7090 하나뿐인데, 그 기계의 ALS(Accumulator Left Shift) 명령어는 부호 비트를 고정으로 취급했습니다. 재미있게도 LGL(Logical Shift Left)도 마찬가지여서, 부호 없는 시프트를 하려면 시프트할 값을 누산기가 아니라 MQ 레지스터에 넣어야 했어요. 그러니... 아니, 그게 이유일 리는 없지 않을까요?

  28. jcranmer HN

    까다로운 점은, 미정의 동작은 보통 "폭발 반경"이 무제한이라고 이해되고 구현 정의 동작은 그런 "폭발 반경"을 허용하지 않는다고 이해된다는 거예요. 그래서 그렇게 무제한의 "폭발 반경"이 필요한 확장 지점은 미정의 동작이어야 했습니다. (요즘은 이게 어느 정도 바뀌었지만 지금도 조금은 남아 있어요. 가장 최근 회의에서 있었던 N3863 논의를 참고하시라고 하고 싶은데, 그 논의를 제대로 담았다고 생각하는 회의록은 없네요.)

    그런 "폭발 반경" 중 하나가 트랩인데, 부호 있는 정수 오버플로를 미정의 동작으로 만든 애초의 동기가 바로 오버플로에서 트랩을 걸 수 있는 능력을 지키려는 것이었다고 저는 짐작합니다. 그건 구현 정의일 수 없었을 테니까요. 다만 C89와 C99로 이어지는 논의 기록이 너무 단편적이라 확신하지는 못하겠습니다.

  29. userbinator HN

    UB의 문제는 오랜 세월 증명되었듯이 컴파일러 작성자들을 현실에서 너무 멀리 벗어나게 만든다는 겁니다. 표준 자체가 (더 나은) 선택지 가운데 하나로 "환경의 특성에 맞는 문서화된 방식으로 [...] 동작"하는 것을 제시하고 있는데도 말이죠.

  30. Joker_vD HN

    그런 "폭발 반경" 중 하나가 트랩인데,

    잠깐만요. 구현 정의 동작은 트랩이나 실행 종료가 되면 안 된다는 건가요? 그럼 그냥 unspecified로 하죠. 트랩을 걸거나, (불확정이 아닌) 어떤 지정되지 않은 정수 값을 계산하는 두 선택지를 두고요.

    N3863

    음?

        Because implementation-defined behavior relies on the definition of unspecified behavior, it
        means that implementation-defined behavior requires two or more possibilities to be defined in
        the standard. However, a brief look at its uses in the standard clearly shows that there isn’t
        always a list of potential behaviors to pick from.
    

    글쎄요, 저는 그런 목록이 없는 건 그냥 가능성이 암묵적으로 무한히 많다는 뜻("둘 이상"이잖아요)이고, 그 목록을 만드는 법은 자명하다고 주장하고 싶네요.

        N3783 5.2.1.2p1.1: Physical source file multibyte characters are mapped, in an implementation-
        defined manner, to the source character set (introducing new-line characters for end-of-line
        indicators) if necessary.
    

    멀티바이트 문자를 소스 문자 집합에 대응시키는 방법은 무한히 많고, 구현은 그중 하나를 골라 문서화해야 합니다.

        N3783 5.2.1.3p1: A conforming implementation shall produce at least one diagnostic message
        (identified in an implementation-defined manner)
    

    진단 메시지로 가능한 것도 무한히 많고요...

        N3783 5.2.2.2p1-2: In a freestanding environment (in which C program execution can take
        place without any benefit of an operating system), the name and type of the function called at
        program startup are implementation-defined.
    

    구현이 프로그램 시작 시 호출할 함수의 이름으로 고를 수 있는 C 식별자도 무한히 많습니다.

Hacker News에서 보기 ↗