프로그래밍 언어/C++

C ++ 표준은 초기화되지 않은 bool이 프로그램을 충돌시키는 것을 허용할까?

Rateye 2021. 6. 27. 14:23
728x90
반응형
질문 : C ++ 표준은 초기화되지 않은 bool이 프로그램을 충돌시키는 것을 허용합니까?

C ++의 "정의되지 않은 동작" 은 컴파일러가 원하는 모든 작업을 수행 할 수 있도록 허용합니다. 그러나 코드가 충분히 안전하다고 생각했기 때문에 놀랐던 충돌이 발생했습니다.

이 경우 실제 문제는 특정 컴파일러를 사용하는 특정 플랫폼에서만 발생했으며 최적화가 활성화 된 경우에만 발생했습니다.

문제를 재현하고 최대한 단순화하기 위해 여러 가지를 시도했습니다. 다음은 Bool 매개 변수를 취하고 문자열 true 또는 false 를 기존 대상 버퍼에 Serialize 라는 함수의 추출입니다.

이 함수가 코드 검토에 포함되어 있습니까? 실제로 bool 매개 변수가 초기화되지 않은 값인 경우 충돌이 발생할 수 있다는 것을 알 수있는 방법이 없습니까?

// Zero-filled global buffer of 16 characters
char destBuffer[16];

void Serialize(bool boolValue) {
    // Determine which string to print based on boolValue
    const char* whichString = boolValue ? "true" : "false";

    // Compute the length of the string we selected
    const size_t len = strlen(whichString);

    // Copy string into destination buffer, which is zero-filled (thus already null-terminated)
    memcpy(destBuffer, whichString, len);
}

이 코드가 clang 5.0.0 + 최적화로 실행되면 충돌이 발생할 수 있습니다.

예상되는 삼항 연산자 boolValue ? "true" : "false" 가 충분히 안전 해 보였습니다 boolValue 가비지 값이 무엇이든 상관 없습니다. 어쨌든 true 또는 false로 평가되기 때문입니다."

디스 어셈블리의 문제를 보여주는 컴파일러 탐색기 예제 를 설정했습니다. 여기에 전체 예제가 있습니다. 참고 : 문제를 재현하기 위해 내가 찾은 조합은 -O2 최적화와 함께 Clang 5.0.0을 사용하는 것입니다.

#include <iostream>
#include <cstring>

// Simple struct, with an empty constructor that doesn't initialize anything
struct FStruct {
    bool uninitializedBool;

   __attribute__ ((noinline))  // Note: the constructor must be declared noinline to trigger the problem
   FStruct() {};
};

char destBuffer[16];

// Small utility function that allocates and returns a string "true" or "false" depending on the value of the parameter
void Serialize(bool boolValue) {
    // Determine which string to print depending if 'boolValue' is evaluated as true or false
    const char* whichString = boolValue ? "true" : "false";

    // Compute the length of the string we selected
    size_t len = strlen(whichString);

    memcpy(destBuffer, whichString, len);
}

int main()
{
    // Locally construct an instance of our struct here on the stack. The bool member uninitializedBool is uninitialized.
    FStruct structInstance;

    // Output "true" or "false" to stdout
    Serialize(structInstance.uninitializedBool);
    return 0;
}

문제는 옵티 마이저로 인해 발생합니다. 문자열 "true"및 "false"는 길이가 1만큼만 차이가 있다는 것을 추론 할 수있을만큼 영리했습니다. 따라서 실제로 길이를 계산하는 대신 bool 자체의 값을 사용 합니다. 기술적으로 0 또는 1이며 다음과 같습니다.

const size_t len = strlen(whichString); // original code
const size_t len = 5 - boolValue;       // clang clever optimization

이것이 "영리"하지만 말하자면, 내 질문은 C ++ 표준이 컴파일러가 bool이 '0'또는 '1'의 내부 숫자 표현 만 가질 수 있다고 가정하고 그런 방식으로 사용할 수 있도록 허용합니까?

아니면 구현 정의의 경우입니까?이 경우 구현은 모든 bool이 0 또는 1 만 포함하고 다른 값은 정의되지 않은 동작 영역이라고 가정합니다.

답변

그러나 ISO C ++를 사용하면 프로그램이 UB를 발견 할 경우 (예 : 오류를 찾는 데 도움이되는) 의도적으로 충돌하는 코드를 컴파일러가 내보낼 수 있습니다. (또는 DeathStation 9000이기 때문입니다. 엄격하게 준수하는 것만으로는 C ++ 구현이 실제 목적에 유용하지 않습니다.) uint32_t 를 읽는 유사한 코드에서도 충돌 (완전히 다른 이유로) 된 asm을 만들 수 있도록합니다. 트랩 표현이없는 고정 레이아웃 유형이어야하지만.

실제 구현이 어떻게 작동하는지에 대한 흥미로운 질문이지만 대답이 다르더라도 최신 C ++는 어셈블리 언어의 이식 가능한 버전이 아니기 때문에 코드가 여전히 안전하지 않다는 것을 기억하십시오.

사용자는 대해 컴파일하는 - 64 V 시스템 ABI 하도록 지정 bool 레지스터의 함수는 인수로서 비트 패턴으로 표현된다 false=0true=1 로우의 레지스터 (1)의 8 비트. 메모리에서 bool 은 다시 정수 값 0 또는 1을 가져야하는 1 바이트 유형입니다.

(ABI는 유형 크기, 구조체 레이아웃 규칙 및 호출 규칙을 포함하여 서로의 함수를 호출하는 코드를 만들 수 있도록 동일한 플랫폼의 컴파일러가 동의하는 구현 선택의 집합입니다.)

ISO C ++는이를 지정하지 않지만 bool-> int 변환을 저렴하게 만들기 때문에이 ABI 결정이 널리 퍼져 있습니다 (단지 제로 확장) . 컴파일러가 모든 아키텍처 (x86뿐만 아니라)에 bool 에 대해 0 또는 1을 가정하도록 허용하지 않는 ABI를 알지 못합니다. 이것은 최적화 같은 허용 !myboolxor eax,1 하위 비트 플립 : 단일 CPU 인스트럭션에 0과 1 사이의 비트 / 정수 / BOOL 플립 수있는 모든 가능한 코드 . 또는 bool 유형의 a&&b 를 비트 AND로 컴파일합니다. 일부 컴파일러는 실제로 컴파일러에서 부울 값을 8 비트로 활용합니다. 그들에 대한 작업이 비효율적입니까? .

일반적으로 as-if 규칙을 사용하면 최종 결과가 C ++ 소스와 동일한 외부에서 볼 수있는 동작을 구현하는 실행 가능한 코드가되기 때문에 컴파일러가에서 컴파일되는 대상 플랫폼에서 참인 것들을 활용할 수 있습니다. (Undefined Behavior가 실제로 "외부에서 볼 수있는"것에 적용하는 모든 제한 사항 : 디버거가 아니라 올바른 형식의 합법적 인 C ++ 프로그램의 다른 스레드에서)

컴파일러는 코드 생성에서 ABI 보장을 최대한 활용하고 strlen(whichString) 을 최적화하는 코드를 만들 수 있습니다.
5U - boolValue .
(BTW,이 최적화의 종류 영리의,하지만 어쩌면 근시안적인 대는 분기 및 인라인 memcpy 즉시 데이터 2의 저장한다.)

또는 컴파일러는 포인터 테이블을 만들고 bool 의 정수 값으로 인덱스화할 수 있으며 다시 0 또는 1이라고 가정합니다 ( 이 가능성은 @Barmar의 답변이 제안한 것 입니다.)

__attribute((noinline)) 생성자는 clang이 스택에서 바이트를로드하여 uninitializedBool 로 사용하도록했습니다. push rax (이는 더 작고 다양한 이유로 sub rsp, 8 만큼 효율적 임)를 사용 main 의 객체를위한 공간을 만들었 main 에 진입 할 때 AL에 있던 가비지가 uninitializedBool 사용 된 값입니다. 0 이 아닌 값을 얻은 이유입니다.

5U - random garbage 가 큰 부호없는 값으로 쉽게 래핑되어 memcpy가 매핑되지 않은 메모리로 이동합니다. 대상은 스택이 아닌 정적 저장소에 있으므로 반환 주소 등을 덮어 쓰지 않습니다.

다른 구현에서는 예를 들어 false=0true=any non-zero value 과 같이 다른 선택을 할 수 있습니다. 그러면 clang은이 특정 UB 인스턴스에 대해 충돌하는 코드를 만들지 않을 것입니다. (하지만 원한다면 여전히 허용 될 것입니다.) bool 에 대해 수행하는 다른 작업을 선택하는 구현에 대해서는 알지 못합니다. 그러나 C ++ 표준은 아무도하지 않거나 심지어하고 싶지 않은 많은 것을 허용합니다. 현재 CPU와 같은 하드웨어에서.

bool 의 객체 표현을 검사하거나 수정할 때 찾을 수있는 내용을 지정하지 않습니다. (예를 들어 boolunsigned char memcpy char* 가 모든 별칭을 사용할 수 있기 때문에 unsigned char 에는 패딩 비트가 없음이 보장되므로 C ++ 표준은 공식적으로 UB없이 hexdump 객체 표현을 허용합니다. 객체 표현을 복사하기위한 포인터 캐스팅 char foo = my_bool 을 할당하는 것과 다르므로 0 또는 1에 대한 부울 화는 발생하지 않으며 원시 객체 표현을 얻을 수 있습니다.)

noinline 을 사용하여 컴파일러에서이 실행 경로의 UB를 부분적으로 "숨겼습니다". 인라인되지 않더라도 절차 간 최적화는 다른 함수의 정의에 의존하는 함수 버전을 만들 수 있습니다. (첫째, clang은 기호 삽입이 발생할 수있는 Unix 공유 라이브러리가 아닌 실행 파일을 만듭니다. 둘째, class{} 정의 내부의 정의이므로 모든 번역 단위가 동일한 정의를 가져야합니다. inline 키워드와 마찬가지로)

컴파일러는 다만 방출 수 있도록 ret 또는 ud2 대한 정의로서 (잘못된 명령) main 실행 경로의 상단에서 시작하기 때문에, main 불가피 만남 정의되지 않은 동작. (컴파일러는 인라인이 아닌 생성자를 통해 경로를 따르기로 결정한 경우 컴파일 타임에 볼 수 있습니다.)

UB를 만나는 모든 프로그램은 전체 존재에 대해 완전히 정의되지 않았습니다. if() 분기 내부의 UB는 나머지 프로그램을 손상시키지 않습니다. 실제로 이는 컴파일러가 컴파일 타임에 UB를 포함하거나 이로 이어질 수 있음을 입증 할 수있는 전체 기본 블록에 ret 방출하거나 아무것도 방출하지 않고 다음 블록 / 함수에 속하도록 결정할 수 있음을 의미합니다.

GCC와 실제로 연타 실제로 때때로 방출 ud2 대신도 아무 의미가없는 실행 경로에 대한 코드를 생성하기 위해 노력하는, UB에. void 가 아닌 함수의 끝에서 떨어지는 것과 같은 경우에 gcc는 때때로 ret 명령을 생략합니다. "내 함수는 RAX에있는 쓰레기와 함께 그냥 반환 될 것입니다"라고 생각했다면 정말 착각입니다. 최신 C ++ 컴파일러는 더 이상 이식 가능한 어셈블리 언어처럼 언어를 처리하지 않습니다. 프로그램의 독립 실행 형 비 인라인 버전이 asm에서 어떻게 보이는지에 대한 가정없이 실제로 유효한 C ++이어야합니다.

또 다른 재미있는 예는 왜 AMD64에서 mmap'ed 메모리에 대한 정렬되지 않은 액세스가 때때로 segfault입니까? . x86은 정렬되지 않은 정수에 결함이 없습니다. 그렇다면 왜 잘못 정렬 된 uint16_t* 가 문제일까요? alignof(uint16_t) == 2 가정을 위반하면 SSE2로 자동 벡터화 할 때 segfault가 발생했습니다.

참조 무엇 모든 C 프로그래머한다 알고 정의되지 않은 행동 # 1 / 3의 소개 하는 그 소리 개발자의 글.

프로그래머의 많은 실수, 특히 현대 컴파일러가 경고하는 것들에 대한 완전한 적대감을 기대하십시오. -Wall 을 사용하고 경고를 수정해야하는 이유입니다. C ++는 사용자에게 친숙한 언어가 아니며 C ++의 무언가는 컴파일하려는 대상의 asm에서 안전하더라도 안전하지 않을 수 있습니다. clang/gcc -fwrapv 를 사용하지 않는 한 2의 보수 x86을 위해 컴파일 할 때도 발생하지 않는다고 가정합니다.)

컴파일 타임에 보이는 UB는 항상 위험하며, (링크 타임 최적화를 통해) 컴파일러에서 UB를 숨겼는지 확인하기가 정말 어렵고 따라서 어떤 종류의 asm을 생성할지 추론 할 수 있습니다.

지나치게 극적이어서는 안됩니다. 종종 컴파일러는 UB 일 때도 기대하는 것처럼 코드를 내 보냅니다. 그러나 컴파일러 개발자가 값 범위에 대한 더 많은 정보를 얻는 최적화를 구현하면 미래에 문제가 될 수 있습니다 (예 : 변수가 음수가 아닌 경우 x86에서 제로 확장을 해제하기 위해 부호 확장을 최적화 할 수 있음). 64). 예를 들어 현재 gcc 및 clang에서 tmp = a+INT_MIN a<0 이 always-false로 최적화되지 않고 tmp 만 항상 음수입니다. ( INT_MIN + a=INT_MAX 는이 2의 보수 대상에서 음수이고 a 는 그보다 더 높을 수 없기 때문입니다.)

따라서 gcc / clang은 현재 계산 입력에 대한 범위 정보를 유도하기 위해 역 추적하지 않으며, 서명 된 오버플로가 없다는 가정에 기반한 결과 만 기반으로합니다 : example on Godbolt . 이것이 최적화가 사용자 친 화성이라는 이름에서 의도적으로 "누락"되었는지 또는 무엇인지 모르겠습니다.

또한 구현 (컴파일러라고도 함)은 ISO C ++가 undefined를 남겨 두는 동작을 정의 할 수 있습니다 . 예를 들어 Intel 내장 함수 (예 _mm_add_ps(__m128, __m128) 를 지원하는 모든 컴파일러는 잘못 정렬 된 포인터를 형성하도록 허용해야합니다. 이는 참조를 역 참조 하지 않더라도 C ++에서 UB입니다. __m128i _mm_loadu_si128(const __m128i *) void* 또는 char* 아닌 __m128i* 인수를 사용하여 정렬되지 않은로드를 수행합니다. 하드웨어 벡터 포인터와 해당 유형 간의 '재 해석 _ 캐스트'가 정의되지 않은 동작입니까?

GNU C / C ++는 또한 일반 부호있는 오버플로 UB 규칙과는 별도로 -fwrapv )를 왼쪽으로 이동하는 동작을 정의합니다. ( 이것은 ISO C ++의 UB이고 , 부호있는 숫자의 오른쪽 시프트는 구현에 따라 정의됩니다 (논리적 대 산술). 좋은 품질의 구현은 산술 오른쪽 시프트가있는 HW에서 산술을 선택하지만 ISO C ++는 지정하지 않습니다). 이것은 GCC 매뉴얼의 Integer 섹션에 문서화되어 있으며 C 표준에서 구현이 어떤 방식 으로든 정의해야하는 구현 정의 동작을 정의합니다.

컴파일러 개발자가 신경 쓰는 구현 품질 문제가 있습니다. 그들은 일반적으로 의도적으로 적대적인 컴파일러를 만들려고 하지 않지만, 더 나은 최적화를 위해 C ++의 모든 UB 움푹 들어간 곳 (정의하기로 선택한 것 제외)을 활용하는 것은 때때로 거의 구별 할 수 없습니다.

각주 1 : 상위 56 비트는 레지스터보다 좁은 유형의 경우 일반적으로 호출 수신자가 무시해야하는 가비지가 될 수 있습니다.

( 다른 ABI 여기에서 다른 선택을합니다 . 일부는 MIPS64 및 PowerPC64와 같은 함수로 전달되거나 함수에서 반환 될 때 레지스터를 채우기 위해 0 또는 부호 확장이 필요한 좁은 정수 유형이 필요합니다. 이 x86-64 답변의 마지막 섹션을 참조하십시오.) 이전 ISA와 비교합니다 .)

예를 들어, 호출자는 bool_func(a&1) 호출하기 전에 RDI에서 a & 0x01010101 을 계산하여 다른 용도로 사용할 수 있습니다. &1 최적화 할 수 있습니다. 왜냐하면 이미 하위 바이트에 and edi, 0x01010101 일부로이를 수행했고 호출 수신자가 상위 바이트를 무시해야한다는 것을 알고 있기 때문입니다.

또는 부울이 세 번째 인수로 전달되면 코드 크기를 최적화하는 호출자 movzx edx, [mem] mov dl, [mem] 사용하여로드하여 이전에 대한 잘못된 종속성을 사용하여 1 바이트를 절약 할 수 있습니다. RDX 값 (또는 CPU 모델에 따라 다른 부분 레지스터 효과). 또는 첫 번째 인수 인 mov dil, byte [r10] movzx edi 대신 movzx edi, byte [r10] , byte [r10]. 둘 다 어쨌든 REX 접두사가 필요하기 때문입니다.

이것이 clang이 Serialize 에서 sub eax, edi movzx eax, dil 을 방출하는 이유입니다. (정수 인수의 경우 clang은 gcc 및 clang의 문서화되지 않은 동작에 따라 좁은 정수를 32 비트로 0 또는 부호 확장하는 대신이 ABI 규칙을 위반합니다. 32 비트 오프셋을 다음에 대한 포인터에 추가 할 때 부호 또는 0 확장이 필요합니까? x86-64 ABI? bool 대해 동일한 작업을 수행하지 않는지보고 싶었습니다.)

각주 2 : 분기 후에는 4 바이트 mov -immediate 또는 4 바이트 + 1 바이트 저장소가 있습니다. 길이는 상점 너비 + 오프셋에 내재되어 있습니다.

OTOH, glibc memcpy는 길이에 따라 중복되는 두 개의 4 바이트로드 / 스토어를 수행하므로 실제로 부울에 대한 조건부 분기가없는 상태로 전체를 만듭니다. glibc의 memcpy / memmove에서 L(between_4_7): 블록 을 참조하십시오. 또는 최소한 memcpy 분기의 부울에 대해 동일한 방식으로 청크 크기를 선택하십시오.

인라인의 경우 2x mov -immediate + cmov 및 조건부 오프셋을 사용하거나 문자열 데이터를 메모리에 남겨 둘 수 있습니다.

또는 Intel Ice Lake ( Fast Short REP MOV 기능 사용 )를 rep movsb 가 최적 일 수 있습니다. glibc memcpy 는 해당 기능이있는 CPU에서 작은 크기에 rep movsb 를 사용하여 많은 분기를 절약 할 수 있습니다.

gcc 및 clang에서 -fsanitize=undefined 로 컴파일하여 런타임시 발생하는 UB에서 경고하거나 오류를 발생시키는 런타임 계측을 추가 할 수 있습니다. 하지만 단위 화 된 변수는 포착하지 못합니다. ( "초기화되지 않은"비트를위한 공간을 만들기 위해 유형 크기를 늘리지 않기 때문입니다).

https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/ 참조

초기화되지 않은 데이터의 사용량을 찾으려면 clang / LLVM에 Address Sanitizer 및 Memory Sanitizer가 있습니다. https://github.com/google/sanitizers/wiki/MemorySanitizer clang -fsanitize=memory -fPIE -pie 초기화되지 않은 메모리 읽기를 감지하는 예를 보여줍니다. 최적화없이 컴파일하면 가장 잘 작동 할 수 있으므로 모든 변수 읽기는 실제로 asm의 메모리에서로드됩니다. 부하가 최적화되지 않는 경우 -O2 에서 사용됨을 보여줍니다. 나는 그것을 직접 시도하지 않았습니다. (예를 들어 배열을 합산하기 전에 누산기를 초기화하지 않는 경우와 같이 clang -O3는 초기화되지 않은 벡터 레지스터로 합산되는 코드를 내 보냅니다. 따라서 최적화를 통해 UB와 관련된 메모리 읽기가없는 경우가 발생할 수 있습니다. . 그러나 -fsanitize=memory 는 생성 된 asm을 변경하고 이에 대한 검사 결과를 초래할 수 있습니다.)

초기화되지 않은 메모리의 복사와 간단한 논리 및 산술 연산도 허용합니다. 일반적으로 MemorySanitizer는 메모리에서 초기화되지 않은 데이터의 확산을 자동으로 추적하고 초기화되지 않은 값에 따라 코드 분기가 사용되거나 사용되지 않을 때 경고를보고합니다.

MemorySanitizer는 Valgrind (Memcheck 도구)에있는 기능의 하위 집합을 구현합니다.

초기화되지 않은 메모리에서 계산 된 length memcpylength 기준으로 분기가 생성되기 때문에이 경우에 작동합니다. cmov , 인덱싱 및 두 개의 저장소를 사용하는 완전 분기없는 버전을 인라인했다면 작동하지 않았을 수 있습니다.

Valgrind의 memcheck 는 또한 이러한 종류의 문제를 찾을 것이며, 프로그램이 단순히 초기화되지 않은 데이터를 복사하는 경우에도 불평하지 않습니다. 그러나 "조건부 점프 또는 이동이 초기화되지 않은 값에 의존"하는 경우를 감지하여 초기화되지 않은 데이터에 의존하는 외부에서 볼 수있는 행동을 포착하려고 시도합니다.

로드에만 플래그를 지정하지 않는이면의 아이디어는 구조체가 패딩을 가질 수 있으며, 개별 멤버가 한 번에 하나씩 만 작성 되었더라도 전체 구조체 (패딩 포함)를 넓은 벡터로드 / 저장으로 복사하는 것은 오류가 아니라는 것입니다. asm 수준에서 패딩이 무엇이고 실제로 값의 일부가 무엇인지에 대한 정보가 손실되었습니다.

출처 : https://stackoverflow.com/questions/54120862/does-the-c-standard-allow-for-an-uninitialized-bool-to-crash-a-program
728x90
반응형