Kori 4월 1일, 2025에 포스트됨 4월 1일, 2025에 포스트됨 David Millington의 "Safety with C++Builder 12.3: Introducing Sanitizers" 를 번역했습니다. (원문 작성: 2025년 3월, 최종 번역: 2025년 3월) 강화된 Clang 컴파일러를 사용하기 시작한 후, 우리는 지속적인 요청을 받아왔다: 런타임 검사를 추가해 달라는 요청이었다. 많은 기존 고객들은 지금까지 런타임 검사를 해왔었기 때문이다. 예전의 '클래식' 컴파일러에는 CodeGuard라는 기능이 있었다. 그래서, 에러를 잡는데 도움이 되었었다. 그러니, 새 도구체인으로 마이그레이션하게 되면, 그와 유사한 기능이 갖춰지기를 요구하는 것은 이치에 맞는 바램이다. 다른 많은 고객들, 특히 Clang을 알고 있던 고객들은, Clang이 여러 가지 런타임 검사 기능들, 즉 새니타이저(Sanitizer)들과 함께 온다는 것을 이미 알고 있다. 그러니 그것들을 넣어 달라고 요구했다. 우리의 예전 Clang 도구체인은 이러한 기능을 지원하지 못했다. 하지만 우리의 새 C++ Modern 도구체인 즉, 12.2에서 새로 들어 갔고, 12.3에서 품질과 성능이 향상된 도구체인이 이제 있다. 우리가 '미래를 위한 기반'이라고 강조하고 있는 도구체인이다. 즉, '우리가 이전에 할 수 없었던 것들을 구축할 수 있도록 하는 것'이고, '이것을 갖추게 되면 우리가 여러분에게 진짜 가치를 제공할 수 있다'고 말하고 있는 것이다. 여러분은 이제 이것이 어디로 가고 있는지 볼 수 있다. 우리는 그 약속을 지키고 있다. 지난 버전 출시에서 이것이 보여준 것은, CMake와 놀라운 컴파일러 성능이었다 (참고로, 이번 버전에서는 지난 버전보다도 성능이 20%까지 더 향상되었다). 이번 버전은, 예전에는 제공할 수 없었던 여러 가지 새 기능들을 제공한다. 그 중 하나가 런타임 검사다. 새니타이저(Sanitizer)들을 이용한다. 이제 C++빌더와 RAD 스튜디오 12.3에서 그것들을 사용할 수 있다. 이번에 제공되는 것은 두 가지다: 주소(메모리) 새니타이저와 정의되지 않은 동작 새니타이저다. 둘 다 살펴보자. 목차 주소 새니타이저 (Address Sanitizer) 정의되지 않은 동작 새니타이저 (Undefined Behaviour Sanitizer) 둘 다 사용하기 새니타이저를 언제 사용할까? 주소 새니타이저 (Address Sanitizer) 이 새니타이저는 'asan'이라고 알려져 있다(옮긴이: Address SANitizer). 이것은 일반적인 메모리 접근 문제를 검사한다: 범위 이탈(out of bounds), nil 역참조(nil dereference), 이중 해제(double free) 등등을 찾아낸다. 힙(heap)과 스택(stack) 둘 다를 추적한다. 여러 기술적인 이유로 인해, Asan을 사용할 때는, 여러분의 앱을 명령줄에서 실행해야 한다. Asan은 stderr에 기록한다. 그런데, 대부분의 UI 앱에는 이것이 없기 때문이다. 그러니, 다음과 같이 실행하는 것이 좋다: myapp 2> asanlog.txt '2'는 stderr을 생성한 다음 asanlog.txt 파일로 리디렉션한다. 실행 중에, 새니타이저가 오류를 발견하면, 그것을 기록하고 나서 프로세스를 종료한다. 한번 해보자. 다음 코드에는 미묘하고 쉽게 발생할 수 있는 버그가 있다: void __fastcall TForm1::FormCreate(TObject *Sender) { std::vector<int> numbers = {1, 2, 3, 4, 5}; auto it = numbers.begin(); while (it != numbers.end()) { std::cout << "Processing: " << *it << std::endl; ++it; } // 이것은 버그다. 하지만, 명백한가? 내 생각에, 놓치기 쉬운 버그다... // 반복자를 역참조하는데, 그 반복자는 벡터의 끝을 넘은 지점을 가리킨다 std::cout << "Accidental access: " << *it << std::endl; } 이 코드는 접근해서는 안 되는 메모리에 쓰기를 한다. 하지만, 여러분의 앱이 실행 중에 충돌하지 않을 가능성이 꽤 있다. 따라서 충돌하지 않는 한, 여러분은 이 문제를 알지 못할 수 있다. 주소 새니타이저를 켜고(Project Options > Building > C++ Compiler > Safety), 빌드를 하고, 실행한다 (위의 구문에 따라 명령줄에서 실행). 그러면, 앱이 종료된다. 그리고 여러분은 로그 파일에 출력된 오류를 얻을 수 있다. 이것은 매우 많은 정보를 출력한다. 따라서, 전체 출력 중 몇 가지만 골라서 인용하겠다. ERROR: AddressSanitizer: heap-buffer-overflow on address 0x11e8153a73a4 좋다: 오류 하나가 발생했다. Asan이 관여하고 있는 것인 버퍼 오버플로우다(할당된 메모리의 끝을 넘어감). 0x11e8153a73a4는 오버플로우가 탐지되었을 때 접근했던 주소다. 프로그램 상태에 대한 더 많은 정보들은 건너뛰고, 무슨 일이 일어났는지 기록된 곳으로 가보자: READ of size 4 at 0x11e8153a73a4 thread T0 접근해서는 안 되는 4바이트를 첫 번째 쓰레드가 읽었다. 그런 다음 이런 호출 스택이 있다: #0 0x7ff766033ed4 in TForm1::FormCreate(System::TObject*) C:sanitizersUnit1.cpp:29 #1 0x7ffc79253b69 in Vcl::Forms::TCustomForm::DoCreate() (I:srcworkdelphi.12xbin64vcl290.bpl+0x180283b69) #2 0x7ffc792532ac in Vcl::Forms::TCustomForm::AfterConstruction() (I:srcworkdelphi.12xbin64vcl290.bpl+0x1802832ac) #3 0x7ff76603fc37 in __AfterConstruction (C:sanitizersWin64xDebugasan_vcl_demo.exe+0x14000fc37) 이 잘못된 4바이트 읽기는 Unit1.cpp의 29줄에서 발생했다. 29줄에 있는 코드는 std::cout 다. 이후에 더 많은 정보가 출력된다: 0x11e8153a73a4 is located 0 bytes to the right of 20-byte region [0x11e8153a7390,0x11e8153a73a4) allocated by thread T0 here: 이것이 알려주는 정보는 다음과 같다. 버퍼(우리가 알고 있는 이 벡터의 할당)의 유효한 범위, 20 바이트, 그 끝에서 '오른쪽'으로 0 바이트(이상한 표현이지만, 그 바로 뒤라는 의미임)에 접근했음, 즉 맨 끝의 바로 다음을 접근하려고 했다고 알려준다. 참고로, 벡터의 end() 반복자는 마지막 요소를 가리키지는 것이 아니라, 마지막 요소 뒤에 있는 다음 요소를 가리킨다 - 그리고 그것이 바로 이 버그의 원인이다, 즉, 이 코드를 작성한 개발자가 잘못 이해하고 있어서 생긴 오류다. 그 다음에는, 호출 스택이 있다(간략하게 보여주기 위해, 대부분을 생략했다). 이것은 메모리가 할당되던 당시의 호출 스택이다. #8 0x7ff766034142 in std::__1::vector<int, std::__1::allocator<int>>::vector[abi:v15007](std::initializer_list<int>) i:srcworkdelphi.12xincludex86_64-w64-mingw32c++v1vector:1286 #9 0x7ff766033c4e in TForm1::FormCreate(System::TObject*) C:sanitizersUnit1.cpp:19 ...그리고 호출 스택 항목 #9는 우리 코드에 들어가는 곳이다. 여기에서 여러분은 파일과 줄 번호를 볼 수 있다. 19줄은 벡터가 생성되고 초기화된 곳이다. 이제 우리는 무슨 일이 일어났는지 전반적으로 알 수 있다. 해당 호출 스택, 무슨 일이 일어났는지, 그리고 그 설정을 통해 모든 것이 어디서 할당되었는지 알 수 있다. 그 다음에는, 요약과 메모리 덤프가 있다. 이는 '섀도우 바이트(shadow byte)들'을 보여준다. 이것은 할당된 메모리에 대한 데이터를 Asan이 따로 저장하는 별도의 맵(map)이다. 즉, 우리가 아래에서 보고 있는 것은 결함이 있는 그 메모리 자체가 아니라, 그 메모리에 대한 정보다. =>0x041f17df4e70: fa fa 00 00[04]fa fa 다양한 플래그들과 관련해, 우리는 여기에서 매우 많은 것들을 더 자세히 살펴볼 수 있다. 그리고 Asan 이 추적하는 메모리 상태는 스택, 해제된 영역 등등 많다. 이것은 해당 메모리 영역과 매핑된다. 여기에 찍힌 각 바이트는 실제 메모리의 8 바이트를 나타낸다. 위를 보면, 'fa'(메모리 할당 바깥을 의미하는 플래그), 몇몇 00들 (유효한 힙 메모리를 의미)이 있다. [04]는 8바이트 중 일부인 4바이트를 가리킨다. 따라서 우리는 8바이트 영역 두 개 반, 즉 20바이트를 볼 수 있다. 이는 4바이트씩 5개의 묶음과 같다. 우리의 코드에 있는 4바이트 정수 5개로 구성된 벡터와 정확히 일치한다. 이 모든 정보를 통해, 우리는 무슨 일이 일어났는지 쉽게 알 수 있다: 우리는 버퍼 바로 뒤를 접근했고, 호출 스택과 줄을 통해 그것이 발생한 코드의 위치를 알 수 있다. 그런 다음, 그 메모리가 할당된 곳이 어디인지도 알 수 있다. 그리고 나서, 그 메모리가 어떻게 추적되고 있는지도 보여준다. 이 모든 것을 종합하면: 우리는 벡터의 맨끝을 지나쳐서 쓰기를 했다는 것을 알 수 있다. 그리고 그 문제를 일으킨 줄에 가보면, 우리가 end()를 역참조하고 있다. 주소 새니타이저는 이러한 종류의 문제를 여러분을 위해 포착한다. 훌륭한 도구이며 매우 유용하다. 정의되지 않은 동작 새니타이저 (Undefined Behaviour Sanitizer) C++에는 정의되지 않은 동작(undefined behaviour)인 것들이 많다, 그런 것들에 대해서 컴파일러는 자신이 원하는 대로 할 수 있다. 유효한 코드가 아니기 때문이다. 종종 최적화를 활성화하기 위해, 정의되지 않은 동작에 대해 컴파일러가 심지어 '트랩'을 삽입해(충돌!)을 삽입해서, 특정 상태가 존재하는지 보장하려고 하기도 한다. 그런 식으로 최적화를 할 수 있다. 나는 트랩이 발생하는 특정 상황에 대한 기고를 쓴 적이 있다. 그것이 놀라울 수 있다. 하지만, 그렇지 않더라도, 정의되지 않은 동작이 여러분의 코드 안에서 실제로 어떻게 작동할 지에 대한 보장은 전혀 없다. 이를 미리 감지하기 위한 컴파일러 경고에 대한 기고도 작성한 적이 있다. 하지만 실제 테스트는 코드가 실행될 때다. 여러분의 코드 안에서 정의되지 않은 동작을 어떻게 실제로 포착할 수 있을까? 정의되지 않은 동작 새니타이저를 가지고 할 수 있다. 정의되지 않은 동작의 몇 가지 일반적인 예는 null 포인터 역참조(읽기나 쓰기는 Asan을 발동해야 한다. 하지만, 처음에 역참조하는 행위는 정의되지 않은 동작이다. 이것은 UBSan-옮긴이: Undefined Behaviour SANtizer-을 발동하게 된다), 정렬되지 않은 메모리 접근(예: 여러분의 포인터 연산이 잘못된 경우), 잘못된 캐스팅 등이 여기에 해당한다. 잘못된 캐스팅을 예를 들어 보자: struct Base { virtual ~Base() = default; }; struct Derived : Base { void hello() { std::cout << "Hello!n"; } }; void __fastcall TForm2::FormCreate(TObject *Sender) { Base b; Derived* d = dynamic_cast<Derived*>(&b); // UB: 실제로 Derived가 아님 d->hello(); } (여기서, 기반(Base)를 파생(Derived)으로 캐스팅하는 동작이 바로 정의되지 않은 동작에 해당된다. 실제로 Derived가 아니기 때문에 문제가 있다. hello() 호출은 이것이 최적화되지 않도록 하기 위한 것이다. 하지만, 그것 자체도 정의되지 않은 동작이다. hello() 메서드가 없는 타입에서 호출되기 때문이다.) 이 코드를 IDE 안에서 실행할 수 있다. 이벤트 창을 잘 보자, 이 경우에는 예외(exception)를 발생시키지 않는다. 하지만, 무슨 일이 발생했는지는 기록된다. 여러분은 이벤트 창을 잘 지켜봐야 볼 수 있다. 여러분의 데스크탑(Desktop) 레이아웃 안에서 이벤트 창을 열어두도록 하는 것을 습관으로 만들자. 심지어 기본(Default) 레이아웃 안에 그렇게 해두는 것도 좋다. 그러면, 앱이 실행되는 동안 또는 실행 후에, 이벤트 창이 화면에 계속 보여진다. 따라서, 여러분은 무언가 기록될 때 창이 스크롤되는 것을 볼 수 있다. 다음과 같은 것을 보게 된다: 위 창에는 정보가 많지 않다. 하지만, 이것들 둘러싼 다른 메시지들이 더 있다. 복잡해지지 않도록, 지금 언급하지는 않겠다. 여기서, UBSan은 vtable에 대한 잠재적 오류를 표시하고 있다. 우리가 캐스팅을 올바르게 하지 않고 있기 때문이다. 우리가 그 메서드 자체를 호출하면, 정의되지 않은 동작이 추가로 발생한다. 그리고, 그것도 역시 표시된다 - 여러분은 이 메시지 아래에 메시지가 더 추가되어 있는 것을 볼 수 있을 것이다. 매우 유용하다! 둘 다 사용하기 우리는 이 새 새니타이저들을 서로 배타적으로 활성화할 것을 권장한다. 이는 일반적인 Clang 새니타이저 규칙이다. 일부 온라인 자료에서는 Asan과 UBSan을 동시에 켤 수 있다고 말한다. 하지만, 우리는 그런 상황을 테스트하지 않았다. 새니타이저를 켜거나 끌 때는, 전체를 다시 빌드해야 한다. 새 도구체인에서 PDB 디버그 정보를 사용하는 경우, 여러가지 기타 서드파티 도구들이 작동한다는 리포트를 MVP들이 해 주었다. 이는 새 도구체인이 새로운 가능성을 제공하는 여러 가지 좋은 예 중 하나다. 새니타이저를 언제 사용할까? 오직 디버깅 시에만 사용하자. 이것들은 런타임 검사다. 따라서, 여러분의 앱 안에 들어간다. 이것들은 앱이 즉시 종료시킬 수도 있다. 성능에 영향을 미칠 수도 있다. 예측하지 못한 부작용이 있을 수도 있다. 네트워크에서 격리되고 실제 운영 인스턴스가 아닌 경우에 실행하자. 왜냐하면, 여러분이 공격받을 수 있는 표면을 증가시킬 수 있다는 리포트가 있기 때문이다. '릴리스' 빌드에는 반영하지 말자. 즉, 사용자가 사용하게 될 앱에는 새니타이저를 켜지 말자. 이것은 여러분이 앱을 실행하고 오류를 찾는데 훌륭하다. 위와 같은 상황만 빼고 – 안전한 개발/디버그 환경에서는 항상 사용하자. 디버그 빌드에서는 정기적으로 이것을 켜 두자. 유닛 테스트나 통합 테스트를 실행할 때 함께 사용하자. 새 기능을 실험할 때 이것을 함께 사용하자. 이것들을 켜 놓은 상태로 CI 빌드를 하자. 이 새니타이저들은 문제를 포착하려고 한다. 그리고, 여러분은 앱의 견고성, 안전성, 품질을 향상시키게 될 것이다. 인용하기
Recommended Posts
이 토의에 참여하세요
지금 바로 의견을 남길 수 있습니다. 그리고 나서 가입해도 됩니다. 이미 회원이라면, 지금 로그인하고 본인 계정으로 의견을 남기세요.