일반적인 Visual C++ ARM 마이그레이션 문제

Microsoft C++ 컴파일러(MSVC)를 사용하는 경우 동일한 C++ 소스 코드가 x86 또는 x64 아키텍처에서 수행하는 것과 다른 결과를 ARM 아키텍처에 대해 산출할 수 있습니다.

마이그레이션 관련 문제의 원인

x86 또는 x64 아키텍처에서 ARM 아키텍처로 코드를 마이그레이션할 때 겪을 수 있는 많은 문제는 정의되지 않았거나, 구현에서 정의하거나, 지정되지 않은 동작을 호출할 수 있는 소스 코드 구문과 관련이 있습니다.

정의되지 않은 동작은 C++ 표준에서 정의하지 않는 동작입니다. 이러한 동작은 합당한 결과를 얻지 못하는 작업으로 인해 발생하는데, 부동 소수점 값을 부호 없는 정수로 변환하거나, 음수이거나 승격된 형식의 비트 수를 초과하는 위치의 수로 값을 이동하는 작업을 예로 들 수 있습니다.

구현 정의 동작은 컴파일러 공급업체가 정의하고 문서화하려 할 때 C++ 표준이 요구하는 동작입니다. 프로그램은 구현 정의 동작을 안전하게 사용할 수 있습니다(단, 이 작업을 이식하지 못할 수 있음). 구현 정의 동작의 예로 기본 제공 데이터 형식의 크기와 맞춤 요구 사항을 들 수 있습니다. 구현 정의 동작의 영향을 받을 수 있는 작업의 한 가지 예는 가변 인수 목록에 액세스하는 것입니다.

지정되지 않은 동작은 C++ 표준의 의도적으로 비결정적인 상태로 남겨 두는 동작입니다. 이 동작은 비결정적인 것으로 간주되지만 지정되지 않은 동작의 특정 호출은 컴파일러 구현으로 결정됩니다. 그러나 컴파일러 공급업체가 결과를 미리 결정하고 비교 가능한 호출 간에 일관된 동작을 보장해야 하는 것은 아니며, 설명서가 있어야 하는 것도 아닙니다. 지정되지 않은 동작의 한 가지 예는 함수 호출에 대한 인수를 포함하는 하위 식이 평가되는 순서입니다.

다른 마이그레이션 문제는 ARM과 C++ 표준과 다른 방식으로 상호 작용하는 x86 또는 x64 아키텍처 간의 하드웨어 차이에 기인한 것일 수 있습니다. 예를 들어 x86 및 x64 아키텍처의 강력한 메모리 모델에서는 과거에 특정한 종류의 스레드 간 통신을 촉진하는 데 사용되던 일부 부가적 속성을 volatile 한정 변수에 부여합니다. 그러나 ARM 아키텍처의 약한 메모리 모델에서는 이러한 사용을 지원하지 않고 C++ 표준에서도 이것을 요구하지 않습니다.

Important

volatile은 x86 및 x64에서 제한된 형태의 스레드 간 통신을 구현하는 데 사용할 수 있는 일부 속성을 얻을 수 있지만 이러한 부가적 속성은 일반적으로 스레드 간 통신을 구현하기에 충분하지 않습니다. C++ 표준에서는 적절한 동기화 기본 형식을 대신 사용하여 이러한 통신을 구현하는 것이 좋습니다.

여러 플랫폼에서 이러한 종류의 동작을 다르게 표현할 수 있으므로 플랫폼 간에 소프트웨어를 이식하는 것은 특정 플랫폼의 동작에 따라 달라지는 경우 어려울 수 있으며 버그가 발생할 수 있습니다. 이러한 종류의 동작 중 다수가 관찰될 수 있고 안정적으로 표시될 수 있지만 이러한 동작에 의존하는 것은 최소한 이식할 수 없으며, 정의되지 않거나 지정되지 않은 동작의 경우에도 오류가 발생합니다. 이 문서에 인용된 동작에 의존해서는 안 되며, 이 동작은 이후 컴파일러 또는 CPU 구현에서 변경될 수 있습니다.

마이그레이션 문제의 예

이 문서의 나머지 부분에서는 이러한 C++ 언어 요소의 다양한 동작이 어떻게 다양한 플랫폼에서 다양한 결과를 산출할 수 있는지에 대해 설명합니다.

부동 소수점을 부호 없는 정수로 변환

ARM 아키텍처에서는 부동 소수점 값이 정수로 표현할 수 있는 범위 밖에 있는 경우 부동 소수점 값을 32비트 정수로 변환하면 정수로 표현할 수 있는 가장 가까운 값으로 범위가 제한됩니다. x86 및 x64 아키텍처에서는 정수에 부호가 없는 경우 이러한 변환이 래핑됩니다. 정수에 부호가 있는 경우에는 -2147483648로 설정됩니다. 이 아키텍처 중 어느 것도 부동 소수점 값을 더 작은 정수 형식으로 변환하는 것을 직접 지원하지 않습니다. 대신에 32비트로 변환이 수행되며 결과는 더 작은 크기로 잘립니다.

ARM 아키텍처의 경우 범위 제한과 잘림의 조합은 32비트 정수를 범위 제한할 때 부호 없는 형식으로의 변환이 더 작은 부호 없는 형식을 올바르게 범위 제한함을 뜻합니다. 하지만 작은 형식이 나타낼 수 있는 것보다 크지만 전체 32 비트 정수의 범위를 제한하기에는 너무 작은 값에 대해 잘린 결과를 산출합니다. 변환도 32비트 부호 있는 정수에 대해 올바르게 범위를 제한하지만 범위가 제한된 부호 있는 정수는 잘림으로 인해 양수로 범위가 제한된 값에 대해서는 -1, 음수로 범위가 제한된 값에 대해서는 0이 됩니다. 더 작은 부호 있는 정수로 변환하면 잘림의 결과로 예기치 않은 값이 나옵니다.

x86 및 x64 아키텍처의 경우 부호 없는 정수 변환에 대한 래핑 동작과 오버플로 시 부호 있는 정수 변환에 대한 명시적 평가를 잘림과 함께 조합하면 대부분의 이동 결과(너무 큰 경우)는 예기치 않은 것이 됩니다.

또한 이러한 플랫폼은 NaN(Not-a-Number)을 정수 형식으로 변환하는 작업을 처리하는 방식이 다릅니다. ARM에서 NaN은 0x00000000으로 변환되고, x86 및 x64에서는 0x80000000으로 변환됩니다.

부동 소수점 변환은 값이 변환되고 있는 정수 형식의 범위 내에 있음을 알고 계신 경우에만 사용할 수 있습니다.

Shift 연산자(<<>>) 동작

ARM 아키텍처에서는 패턴이 반복을 시작하기 전에 값을 왼쪽이나 오른쪽으로 최대 255비트까지 이동할 수 있습니다. x86 및 x64 아키텍처에서는 패턴의 소스가 64비트 변수가 아니면 32의 모든 배수로 패턴이 반복됩니다. 이 경우 패턴은 x64에서는 64의 모든 배수로, x86에서는 256의 모든 배수로 반복되며, 이때 소프트웨어 구현이 사용됩니다. 예를 들어 값이 1인 32비트 변수를 32 위치로 왼쪽으로 이동한 경우의 결과는 ARM에서는 0이고, x86에서는 1, x64에서는 1입니다. 그러나 값의 소스가 64비트 변수인 경우 결과는 세 플랫폼 모두에서 4294967296이고, 값은 x64에서 64위치, ARM 및 x86에서 256위치를 이동할 때까지 "래핑"하지 않습니다.

원본 형식의 비트 수를 초과하는 이동 연산의 결과는 정의되어 있지 않기 때문에 모든 상황에서 컴파일러에 일관된 동작이 있을 필요가 없습니다. 예를 들어 컴파일 시간에 이동의 두 피연산자가 모두 알려지는 경우 컴파일러는 내부 루틴을 사용하여 이동 결과를 미리 계산하고 이동 작업 대신에 결과를 대체하여 프로그램을 최적화할 수 있습니다. 이동량이 너무 크거나 음수인 경우 내부 루틴의 결과는 CPU가 실행한 이동 식의 결과와 다를 수 있습니다.

가변 인수(varargs) 동작

ARM 아키텍처에서는 스택에 전달되는 변수 인수 목록의 매개 변수에 맞춤이 적용됩니다. 예를 들어 64비트 매개 변수는 64비트 경계에 맞춰집니다. x86 및 x64에서는 스택에 전달되는 인수에 맞춤 및 팩이 엄격히 적용되지 않습니다. 이러한 차이로 인해 printf와 같은 가변 인자 함수는 변수 인수 목록의 예상 레이아웃이 정확히 일치하지 않는 경우에는 x86 또는 x64 아키텍처에서 일부 값의 하위 집합에 대해 작동한다 해도 ARM에서 안쪽 여백으로 사용하려고 의도했던 메모리 주소를 읽을 수 있습니다. 다음 예제를 고려해 보세요.

// notice that a 64-bit integer is passed to the function, but '%d' is used to read it.
// on x86 and x64 this may work for small values because %d will "parse" the low-32 bits of the argument.
// on ARM the calling convention will align the 64-bit value and the code will print a random value
printf("%d\n", 1LL);

이 경우 인수의 맞춤을 고려하도록 올바른 형식 지정을 사용하게 함으로써 버그를 수정할 수 있습니다. 다음 코드는 정확합니다.

// CORRECT: use %I64d for 64-bit integers
printf("%I64d\n", 1LL);

인수 평가 순서

ARM, x86 및 x64 프로세서는 서로 다르기 때문에 컴파일러 구현 요구 사항과 최적화 기회를 서로 다르게 제시할 수 있습니다. 이로 인해 호출 규칙 및 최적화 설정과 같은 다른 요소와 함께 컴파일러는 다른 아키텍처에서 또는 다른 요인이 변경될 때 함수 인수를 다른 순서로 평가할 수 있습니다. 이로 인해 특정 평가 순서에 의존하는 앱의 동작이 예기치 않게 변경될 수 있습니다.

이러한 종류의 오류는 함수에 대한 인수가 동일한 호출에서 함수에 대한 다른 인수에 영향을 주는 부작용이 있는 경우에 발생할 수 있습니다. 일반적으로 이러한 종류의 종속성은 쉽게 방지할 수 있지만, 파악하기 어려운 종속성이나 연산자 오버로드에 의해 가려질 수 있습니다. 다음 코드 예제를 고려하세요.

handle memory_handle;

memory_handle->acquire(*p);

이는 잘 정의된 것으로 보이지만 ->*가 오버로드된 연산자인 경우 이 코드는 다음과 유사한 것으로 변환됩니다.

Handle::acquire(operator->(memory_handle), operator*(p));

또한 operator->(memory_handle)operator*(p) 사이에 종속성이 있는 경우에는 원본 코드가 가능한 종속성이 없는 것처럼 보일 수 있지만 코드는 특정 평가 순서를 사용할 수 있습니다.

휘발성 키워드 기본 동작

MSVC 컴파일러는 컴파일러 스위치를 사용하여 지정할 수 있는 volatile 스토리지 한정자의 두 가지 해석을 지원합니다. /volatile:ms 스위치는 이러한 아키텍처의 강력한 메모리 모델로 인해 x86 및 x64에 대한 기존 사례에서 그러했듯이 강력한 순서 지정을 보장하는 Microsoft 확장 휘발성 의미 체계를 선택합니다. /volatile:iso 스위치는 강력한 순서 지정을 보장하지 않는 엄격한 C++ 표준 휘발성 의미 체계를 선택합니다.

ARM 아키텍처에서(ARM64EC 제외) 기본값은 /volatile:iso입니다. ARM 프로세서에는 약한 순서의 메모리 모델이 있고 ARM 소프트웨어는 /volatile:ms확장 의미 체계에 의존하는 레거시가 없기 때문에 일반적으로 소프트웨어와 인터페이스할 필요가 없기 때문입니다. 그러나 확장된 의미 체계를 사용하기 위해 ARM 프로그램을 컴파일하는 경우에도 여전히 편리하거나 필요한 경우가 때로 있습니다. 예를 들어 ISO C++ 의미 체계를 사용하기 위해 프로그램을 이식하는 데 비용이 너무 많이 들 수 있습니다. 또는 드라이버 소프트웨어가 기존 의미 체계를 준수하여 제대로 작동해야 할 수 있습니다. 이 경우 /volatile:ms 스위치를 사용할 수 있습니다. 그러나 ARM 대상에 기존 휘발성 의미 체계를 다시 만들기 위해 컴파일러는 volatile 변수의 각 읽기 또는 쓰기에 대한 메모리 장벽을 삽입하여 강력한 순서 지정을 적용해야 합니다. 이렇게 하면 성능에 부정적인 영향을 미칠 수 있습니다.

x86, x64 및 ARM64EC 아키텍처에서 기본값은 /volatile:ms입니다. MSVC를 사용하여 이러한 아키텍처에 대해 이미 만들어진 대부분의 소프트웨어가 해당 아키텍처에 의존하기 때문입니다. x86, x64 및 ARM64EC 프로그램을 컴파일할 때 /volatile:iso 스위치를 지정하여 기존의 휘발성 의미 체계에 대한 불필요한 의존을 방지하고 이식성을 높일 수 있습니다.

참고 항목

ARM 프로세서에 대한 Visual C++ 구성