2009년 9월 6일 일요일

Visual Studio 계열 쉬프트(>>) 연산 버그의 원인

김우승님의 댓글을 보고 ISO/IEC 9899:201x를 찾아 읽어본 결과, 아래 내용은 버그가 아님.
C/C++는 전체 비트수(즉, long int는 32비트이니 32)보다 큰 숫자의 쉬프트는 정의하지 않음!
그냥 '이런 뻘짓을 했는데, 다 헛소리구나'하는 참고용으로만 읽을 것.

visual studio의 쉬프트연산 버그란 글을 보곤 원인이 궁금해졌다.


위 글의 요지는 아래와 같은 연산 결과가 Visual Studio 2005에선 0이 아니라 1이란 거다.

i=0;
i|=1>>(32-i);

확인해보니, 이 버그는 아래와 같은 특성을 보인다.

1. VC++6.0, VS.Net2003, VS2005에서 똑같이 발생함
2. 오직 디버그 모드에서만 나타남. 릴리즈 모드에선 정상적인 결과인 0을 출력함

상식적으로야 1>>(32-0)은 당근빠따 0인데, 왜 1이라고 구라를 치는가 궁금해져서 컴파일 된 어셈블리 코드를 뽑아봤다.


1. 디버그 모드

; 35   :     int i;
; 36   :     i=0;

    mov    DWORD PTR _i$[ebp], 0

; 37   :     i|=1>>(32-i);

    mov    ecx, 32                    ; 00000020H
    sub    ecx, DWORD PTR _i$[ebp]
    mov    eax, 1
    sar    eax, cl
    or    eax, DWORD PTR _i$[ebp]
    mov    DWORD PTR _i$[ebp], eax

그렇다! 바로 저것이 원인인 것이다. (참 쉽죠잉?)

쉬프트 로테이트 연산엔 총 4가지가 있는데, >>는 이 중에서 보통 sar로 컴파일된다.
그리고, VC에선 속도를 향상시키기 위해 쉬프트 횟수 인자를 cl로 넘긴다.

이게 왜 문제가 되냐면 x86 CPU에선 sar에서 cl을 사용할 때 하위 5비트만 사용하기 때문이다.
인텔에서야 컴파일러 만드는 사람이나 어셈블리 프로그래머가 알아서 하길 바랬지만, 컴파일러 만드는 쪽에서 직무유기를 하면 이런 결과가 나오는 것이다.

참고로, x86 어셈블리에선 32비트 ecx 레지스터의 하위 16비트를 cx라고 한다.
그리고, 다시 이 cx 레지스터를 상위/하위 8비트로 나눠 각각 ch, cl이라고 한다.

그럼 다시 코드로 돌아가보자.
ecx의 값이 32-0 == 32이고, eax의 값이 1인데 sar eax, cl을 했다.
이 때 cl의 값은 물론 32이지만, sar 연산을 할 땐 이 중 하위 5비트만 사용되므로 sar eax, 0이 되는 것이다.

따라서 결과는 1 >> 0 == 1.


2. 릴리즈 모드

앞에서도 얘기했듯이, 이 버그는 오직 디버그 모드에서만 나타난다.
릴리즈 모드에선 얼마나 대단한 수를 써서 안 나오나 쳐다봤다.

; 35   :     int i;
; 36   :     i=0;
; 37   :     i|=1>>(32-i);
; 38   :     printf("%d\n",i);

    push    0
    push    OFFSET FLAT:??_C@_03PMGGPEJJ@?$CFd?6?$AA@
    call    _printf

이런! 컴파일 타임에 계산해주고 끝냈기 때문에 문제가 발생하지 않은 것 뿐이다.

더 확인해보니, 인자를 좀 복잡하게 넘기면 릴리즈 모드에서도 똑같은 문제가 발생한다.
다시 말해 릴리즈 모드가 결코 안전하지 않다는 것이다!


컴파일러 만드는 것이 결코 쉬운 일도 아니고, Visual Studio의 C++ 컴파일러가 결코 듣보잡 수준은 절대로 절대로 아니지만, 이런 기본적인 버그는 아무리 생각해도 짜증난다.
C++ 프로그래머가 C++ 세상에서만 고민하면 되지, 어셈블리 세상까지 와서 고민해야 되겠는가!!

댓글 8개:

  1. 역시 VS를 쓰는 건 스스로 목을 매다는 일인 것 같습니다 ㅋㅋㅋ 도대체 이건 뭐...

    답글삭제
  2. @Un-i-que - 2009/09/06 23:18
    그런다고 버릴 수도 없고 말이죠... ㅠ.ㅠ

    답글삭제
  3. 글쎄요... gcc라던가 다른 compiler를 잘 쓰시나요?

    제 기억으로 gcc는 위의 source에 대해서 경고를 낼 겁니다.



    C 표준의 입장에서 이야기를 해보겠습니다. C 표준은 진단 message는 compiler의 품질의 문제이지 표준준수사항은 아니라고 하고 있습니다. 즉, 잘못된 source에 대한 결과에 대해서 어떤 동작을 하건 표준준사사항은 아니고, 실제로 표준에서 잘못된 source에 대해서 어떻게 동작하라고 말하지도 않죠. 단지 정의하지 않는다(undefined)라고 합니다.

    그래서 C의 정신은 흥미롭게도 '프로그래머를 믿는다.(Trust Programmers)' 입니다. 달리 말하면 책임은 programmer에게 있다입니다. 아이러니하게 지난번에 올린 답글의 link에서 그건 구식이라다고 MS 사람이 말을 해버리고 있지만 말이죠.



    무슨 말이 하고 싶은 거냐면 위에 작성하신 source는 표준입장에서 잘못된 것이라는 겁니다.

    즉 source 자체가 잘못되었으니 compiler의 문제이전에 BluenLive님의 잘못이 선행된다는 거죠.



    표준에는 다음과 같이 적혀 있습니다.



    If the value of the right operand is negative or is greater than or equal to the width of the promoted left operand, the behavior is undefined.



    오른쪽 피연산자의 값이 음수이거나 왼쪽 피연산자의 자동형진급 후의 폭보다 같거나 크다면, 그 행동은 정의되지 않는다.



    int 는 자동형진급을 해서 그대로 int이고 환경에 의해서 int는 32bit일테고, 따라서 우측 피연산자는 32보다 같거나 커서는 안 됩니다.



    표준에서 이 항목이 Intel CPU에 대한 배려로 들어간 것인지는 모르겠습니다만 다양한 환경에서 이식성을 고려하는 C 언어 입장에서 받아들여야 할만한 항목이라고 생각합니다.



    만일 C 언어를 Assembly 수준에서 해석하지 않고 고급언어 입장에서 다루고 싶다면 C 표준을 항상 참고하시길 바랍니다. 말씀드린대로 표준에서는 programmer에게 우선적으로 책임을 지우고 있고, 그것이 C, C++ native를 다루는 입장에서는 합리적인 방식이라고 생각합니다.

    답글삭제
  4. @김우승 - 2009/09/07 00:06
    아... 그런 점이 있군요. 표준이나 Semantic 같은 거 찾아본 지가 너무 오래 지나서 이런 개념이 있는지 생각도 못해봤습니다.



    많은 공부가 되고 있습니다. 좋은 답글 감사드립니다.



    덧. 그래도 디버그 모드랑 릴리즈 모드의 동작방식은 같아야 될 것 같습니다. 표준에 정의되지 않는다고 했다고 구현할 때 여기 따로 저기 따로는 좀 심한 것 같습니다.

    답글삭제
  5. 디버그 모드 컴파일 결과 =! 릴리즈 모드 컴파일 결과



    그래서 디버그 모드에서도 릴리즈 모드와 동일한 결과가 나오도록 프로그램을 하는 버릇이 생겼다는...

    답글삭제
  6. @JAFO - 2009/09/08 21:22
    저도 그렇게 하고 싶은데,

    그게 생각보다 많이 많이 많이 어렵더군요. OTL



    덧. 오타지적: =! → !=

    (이딴 거나 지적하는 난 뭐냐!)

    답글삭제
  7. @BLUEnLIVE - 2009/09/08 22:29
    다음부터 VHDL문법을 써야 겠다는....

    ㅠㅠ

    답글삭제
  8. @JAFO - 2009/09/08 21:22
    저는 보통 release 로 compile 해서 개발을 진행하고, 필요할 때만 debug mode 로 compile 합니다. 어떻게 생각하세요?

    아직까지 여기에 대해 이야기해본 적은 없는데 다른 사람들은 어떻게 생각하는지 궁금하네요.

    답글삭제