C ++의 서명 된 오버플로 및 정의되지 않은 동작 (UB)


56

다음과 같은 코드 사용에 대해 궁금합니다.

int result = 0;
int factor = 1;
for (...) {
    result = ...
    factor *= 10;
}
return result;

루프가 n시간 이 지남에 따라 반복되면 정확히 시간을 factor곱합니다 . 그러나 총 횟수를 곱한 후에 만 ​​사용됩니다 . 루프의 마지막 반복을 제외하고 오버플로 가 발생 하지 않지만 루프의 마지막 반복에서 오버플로가 발생할 수 있다고 가정 하면 그러한 코드를 수용 할 수 있습니까? 이 경우 오버플로가 발생한 후에는 값 이 사용되지 않을 것입니다.10nfactor10n-1factorfactor

이와 같은 코드를 수락해야하는지에 대한 토론이 있습니다. 곱셈을 if 문 안에 넣을 수 있고 오버플로가 발생할 수있을 때 루프의 마지막 반복에서 곱셈을 수행하지 않을 수 있습니다. 단점은 코드를 복잡하게 만들고 불필요한 모든 분기를 추가하여 이전의 모든 루프 반복을 확인해야한다는 것입니다. 루프를 한 번 덜 반복하고 루프 후에 루프 본문을 한 번 복제하면 코드가 복잡해집니다.

실제 코드는 실시간 그래픽 애플리케이션에서 전체 CPU 시간의 큰 청크를 소비하는 단단한 내부 루프에 사용됩니다.


5
이 질문은 codereview.stackexchange.com이 아니 어야하기 때문에이 질문을 주 제외로 닫으려고 합니다.
케빈 앤더슨

31
@KevinAnderson, 예제 코드는 단순히 개선 된 것이 아니라 수정되어야하므로 유효하지 않습니다.
Bathsheba

1
@ harold 그들은 가까이에 매달려 있습니다.
궤도에서 가벼움 경주

1
@LightnessRaceswithMonica : 표준의 저자는 다양한 플랫폼과 목적을위한 구현이 표준이 요구하는 여부에 관계없이 다양한 플랫폼과 목적에 유용한 방식으로 해당 플랫폼과 목적에 유용한 방식으로 다양한 동작을 의미있게 처리함으로써 프로그래머가 사용할 수있는 의미를 확장 할 것으로 예상하고, 또한 이식 불가능한 코드를 무시하고 싶지 않다고 진술했습니다. 따라서 질문 간의 유사성은 어떤 구현이 지원해야하는지에 달려 있습니다.
supercat

2
@supercat 구현 정의 된 동작을 위해서는 툴체인에 확장 기능이 있다는 것을 알고 있다면 사용할 수 있고 이식성에 신경 쓰지 않아도됩니다. UB의 경우? 못 미더운.
궤도에서 가벼움 레이스

답변:


51

컴파일러는 유효한 C ++ 프로그램에 UB가 포함되어 있지 않다고 가정합니다. 예를 들면 다음과 같습니다.

if (x == nullptr) {
    *x = 3;
} else {
    *x = 5;
}

그런 x == nullptr다음이를 참조 해제하고 값을 지정하는 것은 UB입니다. 따라서 이것이 유효한 프로그램에서 끝날 수있는 유일한 방법 x == nullptr은 절대로 true를 산출하지 않고 컴파일러가 as 규칙에 따라 가정 할 수있는 경우입니다. 위의 내용은 다음과 같습니다.

*x = 5;

이제 코드에서

int result = 0;
int factor = 1;
for (...) {      // Loop until factor overflows but not more
   result = ...
   factor *= 10;
}
return result;

factor유효한 프로그램에서 마지막 곱셈을 수행 할 수 없습니다 (서명 된 오버 플로우는 정의되지 않음). 따라서 과제도 result발생할 수 없습니다. 마지막 반복 전에 분기 할 방법이 없으므로 이전 반복이 발생할 수 없습니다. 결국 올바른 코드 부분 (즉, 정의되지 않은 동작은 발생하지 않음)은 다음과 같습니다.

// nothing :(

6
"정의되지 않은 행동"은 프로그램 전체에 영향을 줄 수있는 방법을 명확하게 설명하지 않고 SO 답변에서 많이 듣는 표현입니다. 이 답변은 상황을 훨씬 명확하게 만듭니다.
Gilles-Philippe Paillé

1
그리고 함수 가 더 작은 INT_MAX >= 10000000000경우에 호출 된 다른 함수와 함께로만 대상에서 호출되는 경우 "유용한 최적화"가 될 수도 있습니다 INT_MAX.
R .. GitHub 중지 도움 얼음

2
@ Gilles-PhilippePaillé 우리가 그것에 대한 게시물을 붙일 수 있기를 바랍니다. Benign Data Races 는 내가 얼마나 불쾌한지를 포착하는 데 가장 좋아하는 것 중 하나입니다. MySQL에는 실수로 UB를 호출 한 버퍼 오버 플로우 검사를 다시 찾을 수없는 훌륭한 버그 보고서가 있습니다. 특정 컴파일러의 특정 버전은 단순히 UB가 발생하지 않는다고 가정하고 전체 오버플로 검사를 최적화했습니다.
Cort Ammon

1
@SolomonSlow : UB가 논란의 여지가있는 주요 상황은 표준의 일부와 구현 문서에서 일부 동작의 동작을 설명하지만 표준의 일부는이를 UB로 특성화하는 상황입니다. 표준이 작성되기 전의 일반적인 관행은 컴파일러 작성자가 고객이 다른 작업을 통해 혜택을 볼 수있는 경우를 제외하고는 이러한 조치를 의미있게 처리하는 것이 었으며, 표준 작성자는 컴파일러 작성자가 의도적으로 다른 작업을 수행 할 것이라고 생각하지 않았습니다. .
supercat

2
@ Gilles-PhilippePaillé : 모든 C 프로그래머 가 LLVM 블로그에서 정의되지 않은 동작에 대해 알아야 할 사항 도 좋습니다. 예를 들어 부호있는 정수 오버플로 UB를 사용하여 i <= n루프가 i<n루프 와 같이 루프 가 항상 무한대 임을 증명할 수있는 방법을 설명합니다 . 그리고 int i첫 번째 4G 배열 요소에 랩핑 가능한 배열 인덱싱에 다시 서명하는 대신 루프에서 포인터 너비로 올리십시오.
Peter Cordes

34

int오버 플로우 의 동작 은 정의되어 있지 않습니다.

factor루프 본문 외부 를 읽는 것이 중요하지 않습니다 . 그것이 오버 플로우 된 경우 코드의 동작은, 이후, 다소 역설적으로 나타납니다.오버플로가 발생하면 오버플로가 정의되지 전에 입니다.

이 코드를 유지할 때 발생할 수있는 한 가지 문제는 최적화와 관련하여 컴파일러가 점점 더 공격적으로 가고 있다는 것입니다. 특히 그들은 정의되지 않은 행동이 결코 일어나지 않는다고 가정하는 습관을 개발하고 있습니다. 이 경우 for루프를 완전히 제거 할 수 있습니다 .

당신은 사용할 수 없습니다 unsigned에 대한 유형을 factor다음의 원치 않는 변환에 대해 걱정할 필요가 거라고하지만 intunsigned모두 포함 된 표현식에서?


12
@nicomp; 왜 안돼?
Bathsheba

12
@ Gilles-PhilippePaillé : 내 대답에 문제가 있다고 말하지 않습니까? 나의 첫 문장은 반드시 OP를위한 것이 아니라 더 넓은 공동체 factor이며, 그 자체로 다시 할당 될 때 "사용"됩니다.
Bathsheba

8
@ Gilles-PhilippePaillé와이 답변은 왜 문제가 있는지 설명합니다
idclev 463035818

1
@Bathsheba 당신 말이 맞아, 나는 당신의 대답을 오해했습니다.
Gilles-Philippe Paillé

4
정의되지 않은 동작의 예로, 런타임 검사가 활성화 된 상태에서 해당 코드를 컴파일하면 결과를 반환하는 대신 종료됩니다. 작동하기 위해 진단 기능을 해제해야하는 코드가 손상되었습니다.
Simon Richter

23

실제 옵티 마이저를 고려하는 것이 통찰력이있을 수 있습니다. 루프 언 롤링은 알려진 기술입니다. 기본 아이디어 op 루프 언 롤링은

for (int i = 0; i != 3; ++i)
    foo()

장면 뒤에서 더 잘 구현 될 수 있습니다.

 foo()
 foo()
 foo()

고정 된 경계를 가진 쉬운 경우입니다. 그러나 최신 컴파일러는 변수 범위에 대해서도이를 수행 할 수 있습니다.

for (int i = 0; i != N; ++i)
   foo();

된다

__RELATIVE_JUMP(3-N)
foo();
foo();
foo();

컴파일러가 N <= 3을 알고있는 경우에만 작동합니다. 그리고 그것이 우리가 원래의 질문으로 돌아가는 곳입니다. 컴파일러는 부호있는 오버플로가 발생하지 않는다는 것을 알고 있기 때문에 루프는 32 비트 아키텍처에서 최대 9 번 실행될 수 있다는 것을 알고 있습니다. 10^10 > 2^32. 따라서 9 반복 루프 언롤을 수행 할 수 있습니다. 그러나 의도 된 최대 값은 10 회입니다! .

발생할 수있는 일은 N = 10 인 어셈블리 명령어 (9-N) 로의 상대적 점프를 얻으므로 오프셋은 -1이며 이는 점프 명령어 자체입니다. 죄송합니다. 이것은 잘 정의 된 C ++에 대해 완벽하게 유효한 루프 최적화이지만 주어진 예제는 꽉 찬 무한 루프로 바뀝니다.


9

부호있는 정수 오버플로는 오버플로 된 값의 읽기 여부에 관계없이 정의되지 않은 동작을 발생시킵니다.

어쩌면 유스 케이스에서 루프에서 첫 번째 반복을 들어 올려 이것을 돌릴 수 있습니다.

int result = 0;
int factor = 1;
for (int n = 0; n < 10; ++n) {
    result += n + factor;
    factor *= 10;
}
// factor "is" 10^10 > INT_MAX, UB

이것으로

int factor = 1;
int result = 0 + factor; // first iteration
for (int n = 1; n < 10; ++n) {
    factor *= 10;
    result += n + factor;
}
// factor is 10^9 < INT_MAX

최적화가 활성화되면 컴파일러는 위의 두 번째 루프를 하나의 조건부 점프로 풀 수 있습니다.


6
이것은 약간 지나치게 기술적 인 것이지만 "서명 된 오버플로는 정의되지 않은 동작"입니다. 형식적으로 오버플로가 서명 된 프로그램의 동작은 정의되어 있지 않습니다. 즉, 표준은 해당 프로그램의 기능을 알려주지 않습니다. 오버플로 된 결과에 문제가있는 것은 아닙니다. 전체 프로그램에 문제가 있습니다.
피트 베커

공정한 관찰, 나는 내 대답을 수정했습니다.
elbrunovsky

이상 간단하게 껍질을 마지막으로 죽은 반복을 제거factor *= 10;
피터 코르

9

이것은 UB입니다. ISO C ++ 용어에서 전체 프로그램의 전체 동작은 실행에 대해 완전히 지정되지 않습니다. 결국 UB에 도달 않습니다. 고전적인 예는 C ++ 표준이 관리하는 한 악마가 코에서 날아갈 수 있도록합니다. (비강 악마가 실제로 가능한 구현을 사용하지 않는 것이 좋습니다). 자세한 내용은 다른 답변을 참조하십시오.

컴파일러는 실행 경로에 대해 컴파일 타임에 "문제를 일으켜"컴파일 타임에 보이는 UB로 연결되는 것을 볼 수 있습니다.

모든 C 프로그래머가 정의되지 않은 동작에 대해 알아야 할 사항 (LLVM 블로그) 도 참조하십시오 . 거기에 설명 된 것처럼 부호가있는 UB는 컴파일러가 for(... i <= n ...)루프가 무한 루프가 아님을 증명할 수있게합니다.n 있습니다. 또한 부호 확장을 다시 실행하는 대신 int 루프 카운터를 포인터 너비로 "승격"할 수 있습니다. (따라서 UB의 결과는 서명 된 줄 바꿈을 기대하는 경우 배열의 낮은 64k 또는 4G 요소 외부에 액세스 할 수 있습니다i 는 값 범위로 .)

경우에 따라 컴파일러는 x86과 같은 잘못된 명령을 ud2실행하여 UB를 발생시키는 블록에 대해 명령 을 내릴 수 있습니다. (함수가 호출 되지 않을 수도 있으므로 컴파일러는 일반적으로 맹렬한 영향을 받아 다른 기능을 중단하거나 UB에 도달하지 않는 함수를 통해 경로를 만들 수 없습니다. 즉, 컴파일하는 기계어 코드는 여전히 작동해야합니다 UB로 연결되지 않는 모든 입력)


아마도 가장 효율적인 해결책은 불필요한 반복을 수동으로 제거하여 불필요한 factor*=10것을 피하는 것입니다.

int result = 0;
int factor = 1;
for (... i < n-1) {   // stop 1 iteration early
    result = ...
    factor *= 10;
}
 result = ...      // another copy of the loop body, using the last factor
 //   factor *= 10;    // and optimize away this dead operation.
return result;

또는 루프 본문이 큰 경우에 대해 부호없는 유형을 사용하는 것을 고려하십시오 factor. 그런 다음 부호없는 곱하기 오버플로 할 수 있으며 2의 거듭 제곱 (부호없는 유형의 값 비트 수)으로 잘 정의 된 줄 바꿈을 수행합니다.

이것은 당신이 그것을 사용하는 경우에도 괜찮 와 함께 당신의 unsigned-> 서명 변환이 넘치는 결코 특히, 서명 유형.

부호없는 부호와 2의 보수 부호 사이의 변환은 자유입니다 (모든 값에 대해 동일한 비트 패턴). C ++ 표준에 의해 지정된 int-> unsigned에 대한 모듈로 랩핑은 보수 또는 부호 / 크기와 달리 동일한 비트 패턴을 사용하여 단순화합니다.

unsigned-> signed는 비슷하지만 사소한 것이지만보다 큰 값에 대해서는 구현 정의되어 있습니다 INT_MAX. 마지막 반복에서 서명되지 않은 거대한 결과를 사용 하지 않으면 걱정할 것이 없습니다. 그러나 그렇다면 서명되지 않은 상태에서 서명되지 않은 상태로의 변환이 있습니까?를 참조하십시오 . . 값-나던 맞는 경우는 구현 정의 , 구현이 선택해야한다는 것을 의미 일부 동작; 제정신은 부호없는 비트 패턴을 자르고 (필요한 경우) 부호없는 것으로 사용합니다. 추가 작업없이 범위 내 값에 대해 같은 방식으로 작동하기 때문입니다. 그리고 그것은 확실히 UB가 아닙니다. 따라서 부호없는 큰 값은 부호없는 정수가 될 수 있습니다. 예를 들어, 후int x = u; gcc와 clang이 최적화되지 않은x>=0-fwrapv그들이 행동을 정의했기 때문에 항상없이 진실한 것처럼 .


2
여기서 공감대를 이해하지 못합니다. 나는 대부분 마지막 반복을 벗기는 것에 대해 게시하고 싶었습니다. 그러나 여전히 그 질문에 대답하기 위해, 나는 UB를 구부리는 방법에 대한 몇 가지 요점을 함께 던졌습니다. 자세한 내용은 다른 답변을 참조하십시오.
Peter Cordes

5

루프에서 몇 가지 추가 조립 지침을 허용 할 수있는 경우

int factor = 1;
for (int j = 0; j < n; ++j) {
    ...
    factor *= 10;
}

당신은 쓸 수 있습니다:

int factor = 0;
for (...) {
    factor = 10 * factor + !factor;
    ...
}

마지막 곱셈을 피하기 위해. !factor지점을 소개하지 않습니다 :

    xor     ebx, ebx
L1:                       
    xor     eax, eax              
    test    ebx, ebx              
    lea     edx, [rbx+rbx*4]      
    sete    al    
    add     ebp, 1                
    lea     ebx, [rax+rdx*2]      
    mov     edi, ebx              
    call    consume(int)          
    cmp     r12d, ebp             
    jne     .L1                   

이 코드

int factor = 0;
for (...) {
    factor = factor ? 10 * factor : 1;
    ...
}

또한 최적화 후 분기없는 어셈블리가 발생합니다.

    mov     ebx, 1
    jmp     .L1                   
.L2:                               
    lea     ebx, [rbx+rbx*4]       
    add     ebx, ebx
.L1:
    mov     edi, ebx
    add     ebp, 1
    call    consume(int)
    cmp     r12d, ebp
    jne     .L2

(GCC 8.3.0으로 컴파일 -O3)


1
루프 바디가 크지 않은 한 마지막 반복을 벗겨내는 것이 더 간단합니다. 이것은 영리한 해킹이지만 루프 전달 종속성 체인의 대기 시간을 factor약간 연장합니다 . 여부 :이 배 LEA에 컴파일 할 때 그냥 할 LEA + ADD 효율적으로 관하여 f *= 10f*5*2으로, test처음으로 숨겨져 대기 시간 LEA. 그러나 루프 내부에 추가 UOP가 필요하므로 처리량 단점이있을 수 있습니다 (또는 적어도 하이퍼 스레딩 친 화성 문제)
Peter Cordes

4

설명의 괄호 안에 무엇이 있는지 표시하지 않았지만 for다음과 같이 가정합니다.

for (int n = 0; n < 10; ++n) {
    result = ...
    factor *= 10;
}

카운터 증가 및 루프 종료 검사를 본문으로 간단히 이동할 수 있습니다.

for (int n = 0; ; ) {
    result = ...
    if (++n >= 10) break;
    factor *= 10;
}

루프의 조립 명령 수는 동일하게 유지됩니다.

Andrei Alexandrescu의 프레젠테이션 "속도는 사람들의 마음에 있습니다"에서 영감을 얻었습니다.


2

기능을 고려하십시오.

unsigned mul_mod_65536(unsigned short a, unsigned short b)
{
  return (a*b) & 0xFFFFu;
}

게시 된 이유에 의하면, 표준의 저자는이 기능 (예를 들어) 0xC000으로하고 0xC000으로의 인수를 가진 평범한 32 비트 컴퓨터에서 호출 된 경우의 피연산자를 홍보 것으로 예상했을 *하려면 signed int계산이 -0x10000000를 생성하는 원인이 로 변환 할 때unsigned 수익을 0x90000000u올리는 것과 같은 대답을 얻을 unsigned shortunsigned있습니다. 그럼에도 불구하고 gcc는 때때로 오버플로가 발생하면 무의미하게 작동하는 방식으로 해당 기능을 최적화합니다. 일부 입력 조합이 오버플로를 일으킬 수있는 모든 코드 -fwrapv는 의도적으로 변형 된 입력 작성자가 원하는 임의의 코드를 실행할 수없는 경우 옵션 으로 처리해야합니다 .


1

왜 그렇지 않습니까?

int result = 0;
int factor = 10;
for (...) {
    factor *= 10;
    result = ...
}
return result;

그것은 ...루프 바디를 factor = 1or factor = 10에 대해 실행하지 않으며 100 이상입니다. 첫 번째 반복을 벗겨 내고factor = 1 이것이 작동하려면 여전히 시작 해야합니다.
Peter Cordes

1

정의되지 않은 동작에는 여러 가지 다른면이 있으며 허용되는 것은 사용법에 따라 다릅니다.

실시간 그래픽 애플리케이션에서 전체 CPU 시간의 큰 청크를 소비하는 단단한 내부 루프

그것은 그 자체로는 조금 특이한 일이지만, 아마도 ... 그렇다면 실제로 UB는 아마도 영역 안에 있습니다. "허용 가능한, 수용 가능한" . 그래픽 프로그래밍은 해킹과 추악한 것들로 유명합니다. "작동"하고 프레임을 생성하는 데 16.6ms 이상 걸리지 않는 한, 일반적으로 아무도 신경 쓰지 않습니다. 그러나 여전히 UB를 호출하는 것이 무엇을 의미하는지 알고 있어야합니다.

첫째, 표준이 있습니다. 그 관점에서, 논의 할 것이 없으며 정당화 할 방법이 없습니다. 코드는 단순히 유효하지 않습니다. ifs 또는 whens는 없으며 유효한 코드가 아닙니다. 당신은 또한 당신의 관점에서 볼 때 중간 손가락이며, 95-99 %는 어쨌든 갈 것입니다.

다음으로 하드웨어 측면이 있습니다. 몇 가지가 있습니다 드문, 이상한 이것이 문제 아키텍처. 나는 말하고 "드문, 이상한" 때문에에 하나 (또는 모든 컴퓨터의 80 %를 구성하는 아키텍처 함께 모든 컴퓨터의 95 %를 구성하는 아키텍처를) 오버 플로우가있다 "그래, 뭐든 상관 없어" 하드웨어 수준의 것. 가비지 (아직 예측 가능하지만) 결과는 얻을 수 있지만 악의적 인 일은 발생하지 않습니다.
그건 아니야모든 아키텍처의 경우 오버플로에 대한 함정이 생길 수 있습니다 (그래픽 응용 프로그램에 대해 어떻게 말하는지 알지만 이러한 이상한 아키텍처에있을 가능성은 적습니다). 이식성이 문제입니까? 그렇다면, 기권 할 수도 있습니다.

마지막으로 컴파일러 / 최적화 측이 있습니다. 오버플로가 정의되지 않은 이유 중 하나는 하드웨어를 한 번에 한 번만 처리하는 것이 가장 쉬웠 기 때문입니다. 그러나 또 다른 이유는 예를 들어이있다 x+1한다 보장 항상보다 크게 x, 그리고 컴파일러 / 최적화 프로그램은이 지식을 악용 할 수 있습니다. 이전에 언급 한 사례의 경우 실제로 컴파일러는 이러한 방식으로 작동하고 단순히 전체 블록을 제거하는 것으로 알려져 있습니다 (몇 년 전 Linux 익스플로잇이 있었기 때문에 정확한 검증 코드로 인해 유효성 검사 코드가 손상되었습니다).
귀하의 경우에는 컴파일러가 특수하고 이상한 최적화를 수행한다는 것을 심각하게 의심합니다. 그러나 당신은 무엇을 알고 있습니다. 의심스러운 경우 사용해보십시오.. 그것이 효과가 있다면, 당신은 갈 것입니다.

(그리고 마지막으로, 물론 코드 감사가 있습니다. 운이 좋지 않으면 감사 자와 논의하는 데 시간을 낭비해야 할 수도 있습니다.)

당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.