2015년 6월 16일

PVS-Studio 팀이 공개한 언리얼 엔진의 코드를 향상시킨 방법

저자: Svyatoslav Razmyslov Pavel Eremeev

이 포스트의 원문은 PVS-Studio team의 Svyatoslav RazmyslovPaul Eremeev에 의해 작성되었습니다.

저희 회사는 C/C++ 프로그래머를 위한 PVS-Studio static code analyzer 를 개발, 마케팅, 판매합니다. 하지만, 고객과의 협업은 제품 판매에만 한정되지 않았습니다. 예를 들어, 저희는 종종 프로젝트를 계약하기도 합니다. 비밀유지서약때문에 보통은 해당 건에 대한 세부 사항을 말씀드릴 수 없고, 그래서 여러분은 프로젝트 명이 낯설게 느껴질 것입니다. 어쨌든, 이번 최신 프로젝트는 여러분이 아주 흥미 있으실 거라고 생각합니다. 에픽게임즈와 함께, 저희는 언리얼 엔진 프로젝트를 함께 하고 있습니다. 이번 포스팅에서 말씀드리고자 하는 내용입니다.

PVS-Studio static code analyzer를 홍보하는 방법으로, 저희는 기사 내용으로 재미있는 형식을 생각해 보았습니다. 그것은, 오픈소스 프로젝트를 분석하고 찾아낸 버그에 대해서 작성하는 것이었습니다. 여기 저희가 확인하고 작성한 업데이트 가능한 리스트를 보시기 바랍니다. 이런 활동은 모두에게 이익을 가져다 줍니다. 타산지석을 삼는 것과 이러한 코딩 테크닉, 스타일을 통해 이를 피해가는 방법을 발견하는 것을 즐겨하는 독자들에게 말이죠. 저희에게 있어서, 이 것은 저희 제품을 사람들에게 알려주는 방법입니다. 원 프로젝트 진행자들 또한 이런 버그를 고치는 기회를 갖는 이익을 얻게 됩니다.

그러한 기사 중에는 "언리얼 엔진 4에 대한 기나긴 확인 작업" 또한 있었습니다. 언리얼 엔진의 소스 코드는 엄청나게 고품질이지만, 모든 소프트웨어는 흠이 있고, PVS-Studio는 아주 교묘한 버그 등을 훌륭하게 찾아 냅니다. 저희는 진단과 탐지 결과를 에픽에 보고하였습니다. 언리얼 엔진 팀은 코드 진단에 대해 감사를 표시 하였고, 저희가 보고한 버그에 대한 빠른 픽스를 하였습니다. 하지만 저희는 여기에서 멈추고 싶지 않았고, 에픽게임즈에 PVS-Studio에 대한 라이선스를 판매하고자 하였습니다.

에픽 게임즈는 엔진을 지속적으로 향상시키기 위해 PVS-Studio에 아주 관심을 가졌습니다. 그들은 저희가 언리얼 엔진의 소스 코드를 진단하고 수정하여 버그를 완전히 제거하고 결국 툴이 오탐 결과를 내지 않도록 하는 것을 제안하였습니다. 그 후에, 에픽은 PVS-Studio를 그들의 코드 베이스에 사용하여, 개발 과정에 통합시켜 이를 가능한 쉽고 유연하게 만들었습니다. 에픽게임즈는 PVS-Studio의 라이센스 계약 뿐만이 아니라, 저희 회사의 작업에 대해 비용을 지불하기로 약속하였습니다.

저희는 제안을 받아들였습니다. 작업은 완료되었습니다. 그리고 지금 여러분은 언리얼 엔진 소스 코드에 대해서 작업하며 얻은 다양한 흥미로운 것들에 대해서 알아두셔도 좋습니다.

Pavel Eremeev, Svyatoslav Razmyslov, 와 Anton Tokarev는 PVS-Studio 파트의 참가자였습니다. 에픽게임즈의 가장 활동적인 참가자는 Andy Bayle과 Dan O'Connor였으며, 그들의 도움이 없었다면 작업이 불가능했을 것이라, 이 분들에게 감사 인사를 드립니다.

언리얼 엔진 빌드 프로세스로의 PVS-Studio 통합 작업

빌드 프로세스를 다루기 위해, 언리얼 엔진은 언리얼 빌드 툴이라는 자체 빌드 시스템을 사용하고 있습니다. 거기에는 각기 다른 플랫폼과 컴파일러에 맞는 프로젝트 파일을 생성하는 스크립트 세트도 있습니다. PVS-Studio는 먼저 Microsoft Visual C++ compiler를 위해 디자인 되었기 때문에, 저희는 이에 따라 Microsoft Visual Studio IDE를 위해 프로젝트 파일(*.vcxproj) 을 제작하는 스크립트를 사용하였습니다.

PVS-Studio는 Visual Studio IDE에 통합할 수 있는 플러그인과 함께 배포되고, 원 클릭 진단이 가능합니다. 하지만 언리얼 엔진에 의해 만들어진 프로젝트는 "평범하게" Visual Studio에서 사용되는 MSBuild 프로젝트가 아닙니다.

언리얼 엔진을 Visual Studio로 부터 컴파일 할 때에는, IDE는 빌드 프로세스를 시작할 때 MSBuild를 호출하지만, MSBuild 자체는 언리얼 빌드 툴 프로그램일 실행하기 위한 "포장지"로 사용될 뿐입니다.

PVS-Studio의 소스 코드를 진단하기 위해, 툴은 모든 헤더가 포함되고 매크로가 사용된 전처리 출력값 *.i file을 필요로 합니다.

잠시 알려 드리자면, 여러분이 언리얼 엔진 같은 빌드 프로세스를 커스터마이징 하는 데 관심이 있어서 PVS-Studio로 여러분의 프로젝트에서 문제되는 부분을 찾아내고 싶은 경우, 이 섹션을 끝까지 읽어 보시기 바랍니다. 아마 여러분의 프로젝트에 도움이 되는 사례가 될 것입니다. 하지만 여러분이 일반적 비주얼 스튜디오 프로젝트 사용자이거나, 저희가 찾아낸 버그에 좀 더 관심이 있으시다면 이 부분은 지나치셔도 됩니다.

전처리부를 올바르게 띄우기 위해서, 툴은 컴필레이션 파라미터에 대한 정보를 가지고 있어야 합니다. "평범한" MSBuild 프로젝트에서는 이런 정보가 상속됩니다. PVS-Studio 플러그인은 이 정보를 볼 수 있고 자동으로 필요한 소스 파일들을 나중에 불러올 애널라이저를 위해 전처리합니다. 언리얼 엔진 프로젝트에서 상황은 다릅니다.

위에서 언급하였듯이, 언리얼의 프로젝트들은 컴파일러가 실제로 언리얼 빌드 툴에 의해 불려오기 전 까지는 단지 "포장지"일 뿐입니다. 이 것이 컴필레이션 파라미터가 이런 경우 비주얼 스튜디오용 PVS-Studio 플러그인이 사용 불가능한 이유입니다. 여러분은 전처럼 그냥 "원클릭" 한방으로 진단 결과를 받아볼 수가 없습니다.

PVS-Studio.exe 진단프로그램 자체는 커맨드라인 응용프로그램으로 c++컴파일러의 사용 용도와 닮아 있습니다. 컴파일러와 같이, 모든 소스 파일에 대해서 독립적으로 열려야 하고, 파일의 컴필레이션 파라미터를 커맨드 라인이나 리스폰스 파일을 통해서 전달 해 줍니다. 그리고 진단프로그램이 자동으로 선택을 하고 적당한 전처리기를 호출하고 진단을 실시하게 됩니다.

다른 방법 또한 존재합니다. 여러분은 진단 프로그램을 미리 준비한 전처리된 파일을 이용해서 실행할 수도 있습니다.

그러므로, PVS-Studio진단 프로그램을 빌드 프로세스에 연결하는 통합적인 방법은 예를 들어 언리얼 빌트 툴 처럼 빌드 시스템 내부와 같이 컴파일러가 호출된 곳과 동일한 위치의 exe파일을 호출하는 것 입니다. 이 방법은 저희의 경우에 지금은 별로 마음에 안 들지만, 빌드 시스템을 변경하는 것이 필요합니다. 이것 때문에, 이런 경우를 위해, 저희는 컴파일러를 모니터링하여 컴파일러 호출을 가로채는 시스템을 만들었습니다.

컴파일러 모니터링 시스템은 컴필레이션 프로세스가 실행하는 것을 가로챌 수 있습니다(비주얼 c++의 경우, 여기서는 cl.ele 프로세스입니다.) 성공적인 전처릴를 위해서 필요한 모든 파라미터를 수집하고, 계속 진행될 진단을 위한 컴필레이션 하의 파일들을 위해 전처리를 다시 실행하였습니다. 이렇게 하였죠.

Figure 1. A scheme of the analysis process for the Unreal Engine project

그림 1. 언리얼 엔진 프로젝트의 진단 과정 도표

언리얼 엔진 진단에 통합하는 과정이 호출까지 도달하였고, 빌드 프로세스 직전에, 모니터링 프로세스(CLMonitor.exe)가 전처리와 필요한 모든 단계를 만들어 내고 진단 프로그램을 빌드 프로세스의 끝까지 실행시킵니다. 모니터링 프로세스를 실행하기 위해 저희는 간단한 커맨드를 실행해야 했습니다.

CLMonitor.exe monitor

CLMonitor.exe는 스스로를 "추적 모드"로 호출하고 종료하게 됩니다. 동시에 새로운 CLMonitor.exe 프로세스가 백그라운드에 남아서 컴파일러 호출을 가로채게 됩니다. 빌드 프로세스가 종료하게 되면, 또 다른 새로운 커맨드를 실행해야 합니다. :

 

중요: PVS-Studio 5.26 이상 버전에서는 아래와 같이 작성해야 합니다.

CLMonitor.exe analyze –l "UE.plog"

CLMonitor.exe 가 방금 전에 수집한 소스 파일들의 진단을 실행할 것이고, IDE플러그인에서 손쉽게 다룰 수 있는 UE.plog에 결과를 저장할 것입니다.

저희는 연속적 통합 서버에서 진단한 언리얼 엔진의 결과에 따른 가장 흥미로운 세팅 중 야간 빌드 프로세스를 설정하였습니다. 이 설정은 저희에게 있어 저희가 수정한 것이 빌드를 고장내지 않고, 둘째로 아침에 언리얼 엔진의 진단 결과를 전날 수정한 것을 모두 포함하여 받을 수 있도록 하는 것을 의미하였습니다. 그러므로 저희가 수정한 것을 GitHub에 수락 요구를 보내기 전에, 저희는 서버에서 단순히 재빌드 하는 것으로 수락 요구 전송 전에 현재 버전이 안정적인지에 대해 확인할 수 있었습니다.

비선형 버그 픽스 속도

저희는 프로젝트 빌드 프로세스와 진단에 관한 문제를 해결하였습니다. 그럼, 저희가 해온 진단 프로그램에 의한 메시지 출력에 기반한 버그 픽스에 대해서 말해 보겠습니다.

딱 보기에, 진단 프로그램에 의한 경고의 숫자가 매일 줄어들어야 하는 것이 자연스러워 보일 것입니다. 비슷한 숫자의 경고 메시지가 PVS-Studio의 매커니즘에 따른 버그 픽스를 따라 줄어듭니다.

그것은, 이론적으로 여러분이 이런 그래프를 기대해야 한다는 것입니다.

Figure 2. A perfect graph. The number of bugs drops evenly from day to day.

그림 2. 완벽한 그래프. 매일 일정한 숫자의 버그가 줄어드는 모습.

하지만 현실에서는 버그 수정 기간의 전반부가 후반부보다 픽스 속도가 빠릅니다. 먼저, 전반부에서는 매크로에 의한 경고를 해결해 줌으로써 빠르게 전반적인 버그 숫자를 줄입니다. 그리고 나서 확실한 이슈들을 먼저 해결하고 복잡한 문제를 나중에 해결합니다. 이 부분을 설명하자면, 에픽 게임의 개발자들이 저희가 업무를 시작하고 진전이 있었음을 보여주고 싶었습니다. 복잡한 버그부터 고치려고 하다가 그 곳에서 시간을 허비하면 이상하지 않겠습니까?

언리얼 엔진 코드 전체 진단과 버그 픽스에는 17일의 영업일이 필요했습니다. 저희의 목표는 1, 2급의 중요도를 갖는 일반적 진단 메시지를 제거하는 것이었습니다. 여기에 업무 진척도가 있습니다.

Table 1. The number of warnings on different days.

표 1. 일별 경고 숫자

적색으로 표시된 숫자에 주목해 주십시오. 처음 2일간 저희는 프로젝트에 익숙해 지고 있었으며 매크로에 의한 경고를 제거하였으며, 거짓 양성반응을 일으킨 버그를 다수 제거하였습니다.

17일의 영업일은 꽤 긴 기간이고, 왜 이렇듯 오랜 시간이 필요했는지 설명을 드리자면, 먼저 프로젝트에 전체 인원이 달라붙은 것이 아니며, 단 2명이서 작업을 했습니다. 당연히, 이 기간동안에 다른 일을 하느라 바쁘기도 했습니다. 두 번째로, 언리얼 엔진의 코드는 친숙하지 않았기 때문이었습니다, 그래서 버그를 수정하는 것은 아주 어려운 일이었습니다. 저희는 특정 부분을 수정해야 하는 지 그 때 그 때 마다 알아내기 위해 작업을 중단해야 했습니다.

아래에는 이를 매끄러운 그래프로 도식화 한 것입니다.

Figure 3. A smoothed graph of the warning numbers.

그림 3. 시간에 따른 경고 추이

실전 경험에 의한 결론 - 저희가 기억하고 다른 이들에게 말해주기 위해서 작성합니다. 작업 몇 일 동안에 경고를 해결한 추이를 토대로 예정일을 정해서는 안 됩니다. 처음에는 엄청 치고 나가기 때문에 일 등 할 줄 알게 되는 것이죠.

하지만, 어느정도 예측은 할 수 있어야 합니다. 저는 이 예측에 적합한 함수식이 있다고 생각하는데, 그리고 희망적으로 저희는 이 수식을 발견하였고 나중에 발표할 것입니다. 하지만 지금은, 저희가 신뢰할 만한 것을 발표하기에는 통계적 데이터가 너무 부족합니다.

프로젝트에서 발견된 버그에 대해서

저희는 수많은 코드 절편을 수정하였습니다. 이런 수정은 이론적으로는 3가지의 카테고리로 나뉩니다.

  1. 진짜 버그, 몇가지 예를 들어서 보여드리겠습니다.
  2. 실제 에러는 아니지만, 진단 프로그램을 헛갈리게 만들어서 나중에 이 코드를 공부할 프로그래머를 헛갈리게 만들 코드. 다시 말하자면, "애매한" 코드로 수정이 필요한 부분입니다. 그래서 수정하였습니다.
  3. 진단 프로그램을 "기쁘게" 만들어 거짓 양성 결과물이 나오지 않도록 하기만을 위한 편집도 있었습니다. 저희는 잘못 나타난 경고를 감쇄하기 위해 특별히 분리된 파일에 보관하거나 가능할 때 진단 프로그램을 향상시키려고 노력하였습니다. 하지만, 여전히 특정한 부분에서 진단 프로그램이 진단을 실시하는 것을 돕기 위해 리팩토링(결과의 변경 없이 코드의 구조를 재조정함)을 해야 했습니다.

제가 약속드렸듯이, 버그 샘플이 아래에 있습니다. 저희는 가장 이해하기 쉬운 문제점들을 골라보았습니다.

PVS-Studio에 의한 첫 번째 흥미로운 메시지는 "V506 로컬 변수 'NewBitmap'에 대한 포인터가 이 변수의 범위 외에 저장되었습니다. 이런 포인터는 유효하지 않을 것입니다. fontcache.cpp 466" 입니다.

void GetRenderData(....)
{
 ....
 FT_Bitmap* Bitmap = nullptr;
 if( Slot->bitmap.pixel_mode == FT_PIXEL_MODE_MONO )
 {
 FT_Bitmap NewBitmap;
 ....
 Bitmap = &NewBitmap;
 }
 ....
 OutRenderData.RawPixels.AddUninitialized(
 Bitmap->rows * Bitmap->width );
 ....
}

NewBitmap 오브젝트의 주소가 Bitmap 포인터에 저장되었습니다. 문제는 NewBitmap 오브젝트의 지속 시간이후 오브젝트가 제거되면 문제가 발생합니다. 그러므로 Bitmap이 이미 제거된 오브젝트를 포인팅하는 것으로 나타나게 됩니다.

이미 제거된 오브젝트를 어드레싱하는 포인터를 사용하려고 하면, 정의되지 않은 반응이 나타납니다. 어떻게 될 지는 모릅니다. 여러분이 제거된 오브젝트에 새로운 오브젝트가 덧씌워지지 않을 정도로 운이 좋아서 프로그램이 몇 년간 잘 돌아갈 수는 있겠지만 말입니다.

이 코드를 올바르게 수정하는 것은 NewBitmap의 정의를 if 연산자 밖에 두는 것입니다.

void GetRenderData(....)
{
 ....
 FT_Bitmap* Bitmap = nullptr;

 FT_Bitmap NewBitmap;
 if( Slot->bitmap.pixel_mode == FT_PIXEL_MODE_MONO )
 {
 FT_Bitmap_New( &NewBitmap );
 // Convert the mono font to 8bbp from 1bpp
 FT_Bitmap_Convert( FTLibrary, &Slot->bitmap, &NewBitmap, 4 );

 Bitmap = &NewBitmap;
 }
 else
 {
 Bitmap = &Slot->bitmap;
 }
 ....
 OutRenderData.RawPixels.AddUninitialized(
 Bitmap->rows * Bitmap->width );
 ....
}

PVS-Studio에 의한 흥미로운 다음 경고는 "V522 널 포인터 'GEngine'의 디레퍼런싱이 일어날 수 있습니다. 논리적 조건을 확인하십시오. gameplaystatics.cpp 988"

void UGameplayStatics::DeactivateReverbEffect(....)
{
 if (GEngine || !GEngine->UseSound())
 {
 return;
 }
 UWorld* ThisWorld = GEngine->GetWorldFromContextObject(....);
 ....
}

만약 GEngine의 포인터가 널이 아니라면, 함수가 리턴하고 아무런 문제가 없습니다. 하지만 널이 아닌 경우에는 디레퍼런싱(포인터가 가리키는 번지에 수납된 데이터에 접근)이 일어납니다.

이 코드를 다음과 같이 수정하였습니다.

void UGameplayStatics::DeactivateReverbEffect(....)
{
 if (GEngine == nullptr || !GEngine->UseSound())
 {
 return;
 }

 UWorld* ThisWorld = GEngine->GetWorldFromContextObject(....);
 ....
}

다음 코드 조각에 흥미로운 오타나 나타납니다. 진단 프로그램이 의미없는 함수 호출을 감지하였는데, "V530 'Memcmp'의 반환값이 사용되지 않습니다. pathfollowingcomponent.cpp 715" 입니다.

int32 UPathFollowingComponent::OptimizeSegmentVisibility(
 int32 StartIndex)
{
 ....
 if (Path.IsValid())
 {
 Path->ShortcutNodeRefs.Reserve(....);
 Path->ShortcutNodeRefs.SetNumUninitialized(....);
 }
 FPlatformMemory::Memcmp(Path->ShortcutNodeRefs.GetData(),
 RaycastResult.CorridorPolys,
 RaycastResult.CorridorPolysCount *
 sizeof(NavNodeRef));
 ....
}

Memcmp의 반환값이 사용되지 않았습니다. 그리고 이것은 진단 프로그램이 좋아하지 않았네요.

프로그래머가 Memcpy()함수를 이용해서 실질적으로 의도한 것은 메모리의 일부 영역을 복사하려는 것이지만, 오타를 남겼습니다. 아래는 수정된 버전입니다.

int32 UPathFollowingComponent::OptimizeSegmentVisibility(
 int32 StartIndex)
{
 ....
 if (Path.IsValid())
 {
 Path->ShortcutNodeRefs.Reserve(....);
 Path->ShortcutNodeRefs.SetNumUninitialized(....);

 FPlatformMemory::Memcpy(Path->ShortcutNodeRefs.GetData(),
 RaycastResult.CorridorPolys,
 RaycastResult.CorridorPolysCount *
 sizeof(NavNodeRef));
 }
 ....
}

그럼 앞으로 거의 대부분의 프로젝트에서 보게 되실 진단 메시지에 대해서 이야기 해 보도록 하겠습니다. 너무 흔해서 버그로 치기도 합니다. V595 에러 메시지에 대해서 이야기 하는 것입니다. 저희의 버그 데이터베이스에서 발생 빈도에 있어서 1위를 차지하고 있습니다. (예제를 보시기 바랍니다.) 처음 봤을 때에는, V501진단 결과의 리스트가 이렇게 크지 않았습니다. 하지만 이렇게 된 원인은 V595 진단 결과가 읽기 다소 지루했고 저희는 매 프로젝트마다 일일이 표시하지 않기 때문입니다. 나머지 절반의 경우들은, 실제 에러입니다. 아래와 같이 나타납니다.

Figure 4. The dread of V595 diagnostic.

표 4. V595 진단 결과의 공포
Figure 4. The dread of V595 diagnostic.

진단 규칙 V595는 포인터가 널로 체크되기 전에 디레퍼런스 되는 코드 절편을 찾도록 디자인되었습니다. 저희는 항상 진단한 프로젝트들에서 일정 량의 이런 문제점을 찾아냈습니다. 포인터 체크와 디레퍼런싱 연산은 함수 내에서 위치가 멀리 떨어져 있을 수 있습니다. 10 혹은 100라인 이상이 떨어져서는 버그를 찾기가 힘들게 됩니다. 하지만 아래와 같이 매우 작고 대표적인 함수도 있습니다.

float SGammaUIPanel::OnGetGamma() const
{
 float DisplayGamma = GEngine->DisplayGamma;
 return GEngine ? DisplayGamma : 2.2f;
}

PVS-Studio의 진단 결과 메시지: V595 'GEngine' 포인터가 널 포인터가 아닌 것으로 확인되기 전에 사용되었습니다. 라인 47, 48 을 확인하세요 gammauipanel.cpp 47

이 코드를 아래와 같이 수정하였습니다.

float SGammaUIPanel::OnGetGamma() const
{
 return GEngine ? GEngine->DisplayGamma : 2.2f;
}

다음 절편으로 넘어갑니다.

V517 'if (A) {...} else if (A) {...}'패턴의 사용이 감지되었습니다. 논리적 오류의 가능성이 있습니다. 289, 299 라인을 확인하세요 automationreport.cpp 289

void FAutomationReport::ClustersUpdated(const int32 NumClusters)
{
 ...
 //Fixup Results array
 if( NumClusters > Results.Num() ) //<==
 {
 for( int32 ClusterIndex = Results.Num();
 ClusterIndex < NumClusters; ++ClusterIndex )
 {
 ....
 Results.Add( AutomationTestResult );
 }
 }
 else if( NumClusters > Results.Num() ) //<==
 {
 Results.RemoveAt(NumClusters, Results.Num() - NumClusters);
 }
 ....
}

현재 폼 속에서, 두 번째 조건이 참이 될 수가 없습니다. 이 실수가 내부에서 'result' 배열으로부터 필요없는 항목을 제거할 목적으로 사용된 부호를 처리해야 하는 것으로 추측하는 것이 논리적입니다.

void FAutomationReport::ClustersUpdated(const int32 NumClusters)
{
 ....
 //Fixup Results array
 if( NumClusters > Results.Num() )
 {
 for( int32 ClusterIndex = Results.Num();
 ClusterIndex < NumClusters; ++ClusterIndex )
 {
 ....
 Results.Add( AutomationTestResult );
 }
 }
 else if( NumClusters < Results.Num() )
 {
 Results.RemoveAt(NumClusters, Results.Num() - NumClusters);
 }
 ....
}

그리고 여러분의 친절함을 테스트 할 코드가 있습니다. 진단 프로그램의 경고입니다. V616 0으로 명명된 'DT_POLYTYPE_GROUND'가 비트에 관한 연산에 사용되었습니다. pimplrecastnavmesh.cpp 2006

/// Flags representing the type of a navigation mesh polygon.
enum dtPolyTypes
{
 DT_POLYTYPE_GROUND = 0,
 DT_POLYTYPE_OFFMESH_POINT = 1,
 DT_POLYTYPE_OFFMESH_SEGMENT = 2,
};

uint8 GetValidEnds(...., const dtPoly& Poly)
{
 ....
 if ((Poly.getType() & DT_POLYTYPE_GROUND) != 0)
 {
 return false;
 }
 ....
}

첫 눈에 보기에도 모든 것이 괜찮아 보입니다. 여러분은 아마 어떤 비트가 마스크에 의해 할당되고 그 값이 확인되었다고 생각할 것입니다. 하지만 실제로는 그냥 'dtPolyTypes' 열거형 내에 정의된 상수명일 뿐이고 어떤 비트 값도 할당되도록 의도된 것이 아닙니다.

이런 조건 내에서, DT_POLYTYPE_GROUND 상수는 0과 같고, 조건은 절대로 참이 될 수 없습니다.

수정된 코드:

uint8 GetValidEnds(...., const dtPoly& Poly)
{
 ....
 if (Poly.getType() == DT_POLYTYPE_GROUND)
 {
 return false;
 }
 ....
}

오타가 발견되었습니다: V501 동일한 표현이 '||' 연산자 양 측에 위치하고 있습니다. !bc.lclusters ||!bc.lclusters detourtilecache.cpp 687

dtStatus dtTileCache::buildNavMeshTile(....)
{
 ....
 bc.lcset = dtAllocTileCacheContourSet(m_talloc);
 bc.lclusters = dtAllocTileCacheClusterSet(m_talloc);
 if (!bc.lclusters || !bc.lclusters) //<==
 return status;
 status = dtBuildTileCacheContours(....);
 ....
}

변수를 복사-붙여넣기 하고 나서, 프로그래머가 'bc.lclusters'에서 'bc.lcset'로 이름을 바꾸는 것을 잊고 넘어갔습니다.

일반적인 진단 결과

위의 예제들은 지금까지 프로젝트에서 찾아진 버그들의 일부에 불과합니다. 저희는 정상급 코드들에서도 PVS-Studio가 찾아낼 수 있는 버그들을 보여드리기 위해 예를 들어 보았습니다.

그러나, 저희가 여러분께 상기시켜드리고 싶은 것은, 단일 코드 실행에 기반한 진단은 정적 진단프로그램을 사용하는 올바른 방법이 아니라는 것입니다. 진단은 정기적으로 이루어 져야 합니다. 그래야 다량의 버그와 오타를 테스트 단계나 유지보수 단계 말고 코딩 단계에서 찾아낼 수 있도록 해 주는 것입니다.

언리얼 엔진 프로젝트는 저희가 한 말을 증명하기에 아주 안성맞춤인 실제 사례가 되겠습니다.

처음에 저희는 신규 변경인지 기존 상태 유지인지 추적하지 않고 수정하였습니다. 초기에 수 많은 버그를 수정할 때에는 별로 흥미롭지 않았습니다. 하지만 저희는 PVS-Studio 진단 프로그램이 저희가 경고 발생을 모두 해결한 뒤에 새로 작성되거나 변경된 코드에서 버그를 어떻게 진단하였는지에 주목하였습니다.

사실, 이 코드를 해결하기 위해서 17일보다 조금 더 걸렸습니다. 저희가 수정을 중단하고 진단 프로그램으로부터 "경고: 0회" 메시지를 받았을 때로 부터, 저희는 언리얼 엔진 팀으로부터 저희의 최종 결과물 수락 요구를 통합하는 데 2일을 더 기다려야 했습니다. 이 기간 동안 저희는 지속적으로 코드 기반 에픽의 저장소를 업데이트하고 새로운 코드를 진단했습니다.

저희는 해당 2일간 진단 프로그램이 버그를 찾는 것을 확인할 수 있었습니다. 이 버그들 또한 수정하였습니다. 이 것은 정기적인 정적 진단 프로그램 확인이 얼마나 중요한지 알 수 있는 좋은 예입니다.

사실, "경고 숫자"그래프의 끄트머리는 지금 이렇게 생겼습니다.

Figure 5. A schematic graph representing the growth of the warning number after it was cut to 0.

그림 5. 경고가 0으로 줄어든 이후를 보여주는 도표

그러면, 이 2일간 저희가 프로젝트 코드의 업데이트를 진단하면서 어떤 것을 발견해왔는지 알아보겠습니다.

1일 차

첫 번째 메시지: V560 조건절 내의 조건이 항상 참입니다: FBasicToken::TOKEN_Guid. k2node_mathexpression.cpp 235

virtual FString ToString() const override
{
 if (Token.TokenType == FBasicToken::TOKEN_Identifier ||
 FBasicToken::TOKEN_Guid) //<==
 {
 ....
 }
 else if (Token.TokenType == FBasicToken::TOKEN_Const)
 {
 ....
}

프로그래머가 "Token.TokenType =="을 쓰는 것을 깜빡했습니다. 이렇게 하면 상수 'FBasicToken::TOKEN_Guid' 이 0이 아니기 때문에 조건이 항상 참이 됩니다.

메시지 2: V611 'new T[]' 연산자를 사용하는 메모리가 할당되었으나 'delete' 연산자를 사용하며 해제되었습니다. 이 코드를 조사해 보시기 바랍니다. 'delete [] CompressedDataRaw;'를 사용하는 것이 더 좋을 것입니다. crashupload.cpp 222

void FCrashUpload::CompressAndSendData()
{
 ....
 uint8* CompressedDataRaw = new uint8[BufferSize]; //<==

 int32 CompressedSize = BufferSize;
 int32 UncompressedSize = UncompressedData.Num();
 ....
 // Copy compressed data into the array.
 TArray<uint8> CompressedData;
 CompressedData.Append( CompressedDataRaw, CompressedSize );
 delete CompressedDataRaw; //<==
 CompressedDataRaw = nullptr;
 ....
}

이 버그는 저희가 char형의 배열의 할당을 다루다 보면 실제로 잘 나타나지 않습니다. 하지만 정의되지 않은 문제를 발생시키는 버그이고 반드시 고쳐야 합니다.

2일 차

첫 번째 메시지: V521 '.' 연산자를 사용하는 것은 위험합니다. 표현이 올바른지 확인하십시오. unrealaudiodevicewasapi.cpp 128

static void GetArrayOfSpeakers(....)
{
 Speakers.Reset();
 uint32 ChanCount = 0;
 // Build a flag field of the speaker outputs of this device
 for (uint32 SpeakerTypeIndex = 0;
 SpeakerTypeIndex < ESpeaker::SPEAKER_TYPE_COUNT, //<==
 ChanCount < NumChannels; ++SpeakerTypeIndex)
 {
 ....
 }

 check(ChanCount == NumChannels);
}

아주 멋지고 통통한 버그

콤마 연산자는 오른쪽 방향으로 두 표현을 모두 실행시키고 오른쪽 피연산자에 값을 저장하는 데에 사용됩니다.

그 결과, 반복이 종료되는 조건이 ChanCount < NumChannels을 확인하는 것으로 대표됩니다.

수정된 조건:

static void GetArrayOfSpeakers(....)
{
 Speakers.Reset();
 uint32 ChanCount = 0;
 // Build a flag field of the speaker outputs of this device
 for (uint32 SpeakerTypeIndex = 0;
 SpeakerTypeIndex < ESpeaker::SPEAKER_TYPE_COUNT &&
 ChanCount < NumChannels; ++SpeakerTypeIndex)
 {
 ....
 }
 check(ChanCount == NumChannels);
}

두 번째 메시지, V543 '-1'이 HRESULT 형의 변수 'Result'에 할당되는 것이 이상합니다. unrealaudiodevicewasapi.cpp 568

 
	#define S_OK ((HRESULT)0L)
	#define S_FALSE ((HRESULT)1L)
	
bool
FUnrealAudioWasapi::OpenDevice(uint32 DeviceIndex,
 EStreamType::Type StreamType)
{
 check(WasapiInfo.DeviceEnumerator);

 IMMDevice* Device = nullptr;
 IMMDeviceCollection* DeviceList = nullptr;
 WAVEFORMATEX* DeviceFormat = nullptr;
 FDeviceInfo DeviceInfo;
 HRESULT Result = S_OK; //<==
 ....
 if (!GetDeviceInfo(DataFlow, DeviceIndex, DeviceInfo))
 {
 Result = -1; //<==
 goto Cleanup;
 }
 ....
}

HRESULT는 32비트 값을 에러 등급 코드, 분류 코드, 에러 코드의 3개로 나눈 것입니다. HRESULT를 다루기 위해서, S_OK, E_FAIL, E_ABORT와 같이 특별한 상수와 기타 등등이 사용됩니다. 그리고 HRESULT 값을 확인하기 위해 SUCCEEDED와 FAILED 같은 매크로가 사용됩니다.

경고 V543 은 프로그래머가 참이나 거짓, -1 등을 HRESULT 형 변수에 쓰려고 할 때만 발생합니다.

"-1"값을 쓰는 것은 잘못되었습니다. 만약 당신이 알려지지 않은 에러를 보고하려면, 당신은 0x80004005L (Unspecified failure)을 사용해야 합니다. 이 값과 기타 다른 유사한 상수들은 "WinError.h"에 정의되어 있습니다."

와! 엄청난 작업량이군요!

몇몇 프로그래머와 매니저들이 그들의 프로젝트에 정적 진단을 적용하는 데 2주 이상이 걸린다는 것을 알게 되어 슬펐을 것입니다. 하지만 여러분은 꼭 이렇게 하지 않아도 됩니다. 여러분은 그저 에픽게임즈 개발자들이 아직은 간단하고 빠른 게 아닌, 이상적인 방법을 선택했다는 것을 이해하시면 됩니다.

그렇습니다. 이상적인 시나리오는 모든 버그를 즉시 없애고, 점진적으로 새로 씌여진 코드에 대한 버그로부터 발생한 메시지만을 표시하는 것입니다. 하지만 정적 진단으로 이전 코드를 수정하는 일 없이 장점만을 취할 수도 있습니다.

PVS-Studio는 이런 목적을 위해서 "메시지 마킹"이라는 특별한 메커니즘을 제공합니다. 아래는 이 기능에 대한 설명입니다.

진단 프로그램에 의한 모든 메시지 출력은 불활성화 된 특별한 데이터 베이스에 표시가 됩니다. 그 이후에, 사용자는 이 메시지들을 새로 씌여지거나 변경된 코드에 대해서만 볼 수 있게 됩니다. 그것은, 정적 진단을 바로 실시하여 그 장점을 취할 수 있다는 뜻이 됩니다. 그리고 나서 시간이 있을 때 점진적으로 이전 코드에 대해서 수정을 할 수 있습니다.

이 주제에 관한 더 자세한 사항은 아래 링크를 통해서 확인해 보시기 바랍니다.
문서, 정적 진단을 여러분의 프로젝트에 빠르게 적용시키는 방법.

"제작자에게 버그를 제보한 적이 있습니까?"

프로젝트를 점검해 보면서 새로운 기사를 써 내고 나면 사람들은 질문할 것입니다. "제작자에게 버그를 제보한 적이 있습니까?" 그리고 당연히 저희는 항상 그렇게 합니다! 하지만 이번에는, 저희는"버그를 제작자에게 제보"하였을 뿐만이 아니라 수정까지 하여 주었습니다. 언리얼 엔진에 대한 모두의 관심이 Github의 저장소에 언리얼 엔진의 향상을 불러오고 이에 따른 이익을 가져다 줄 것입니다. (에픽게임즈 계정과 GitHub 계정을 생성하고 에픽게임즈 계정 내에 등록을 해야 합니다.)

결론

저희는 언리얼 엔진을 사용하는 개발자들이 언리얼 엔진의 소스 코드를 향상시킨 PVS-Studio의 역할에 감사하여 주기를 바라고, 언리얼 엔진 기반의 멋진 새 프로젝트들을 기대하고 있습니다!

이하는 저희의 작업에 의해 얻은 결론입니다.

  1. 언리얼 엔진 프로젝트의 코드는 굉장히 고품질입니다. 초반에 나타난 다수의 경고는 신경 쓰지 마시기 바랍니다. 흔하게 일어나는 일입니다. 이런 류의 경고는 대부분 다양한 테크닉과 설정으로 제거됩니다. 커다란 프로젝트의 크기에 비해 실제 버그는 아주 적은 것입니다.
  2. 타인이 작성한 코드를 고치는 것은 친숙하지 않으며 아주 어렵습니다. 대부분의 프로그래머는 아마 본능적으로 이 것을 이해하실 것입니다. 옛 말이 틀리지 않습니다.
  3. 진단 프로그램의 경고를 "줄여 나가는" 속도는 비선형적입니다. 점진적으로 감소하고 이 점을 작업예상일을 추정함에 반영하여야 합니다.
  4. 정적 진단을 통한 최고의 결과물은 정기적으로 이를 실행할 때만 얻을 수 있습니다.

이 글을 읽어 주셔서 감사드립니다. 여러분의 코드에 버그가 없기를 바랍니다! PVS-Studio의 개발자로써 직접 인사 드립니다. 바로 지금 다운로드받으시고 여러분의 프로젝트에 적용해 보세요.