C-RUN으로 에러 검사와 런타임 분석을 편리하게
이번 편에서는 런타임 분석 및 동적 런타임 에러 검사의 기본 개념을 간단하게 알아보고, 임베디드 개발에서 이러한 기술을 사용하면 어떤 장점이 있는지를 살펴보겠습니다. C-RUN은 IAR 시스템스 제품 중의 하나로, 런타임 분석과 런타임 에러 검사를 기존제품보다 한 단계 더 편리하게 진행할 수 있도록 해 주는 제품입니다.
배경
소프트웨어에는 항상 에러가 있기 마련입니다. 또, 테스트, 특히 디버깅 단계에서 정말 위험성이 높은 문제를 해결하는 과정에서 너무 많은 시간이 소요된다는 사실도 저희는 알고 있습니다. 이러한 상황을 잘 나타내는 것이 80/20의 규칙입니다. 소프트웨어를 개발하는 데에 80%의 시간을 소요하고, 나머지 20%는 테스트와 디버그에 소요되는 것입니다. 이것은 어느 정도 농담이 섞여 있기는 하지만, 마지막 20%의 시간을 줄일수록, 우리는 좀 더 건실한 소프트웨어를 좀 더 빠른 속도로 개발할 수가 있을 것입니다.
정리가 생명
소프트웨어 에러와 결함에 대해 이야기 할 때에는 우리가 어떠한 종류의 결함에 대처하고자 하는지, 이를 분류할 수 있는 체계를 세워 두는 것이 도움이 됩니다. 여기서는 지정 에러에 대해서 이야기하는 것이 아니므로, 뭔가를 올바로 지정하고, 제대로 구축하였는지에 대해 생각하지는 않습니다. 이 자체도 충분히 관심을 둘 가치가 있는 결함의 한 종류이며, 코드 상의 복잡한 요구 사항을 어떻게 취합하고, 정리, 처리, 구현, 시험할 것인지에 대해서는 이미 많은 자료가 나와 있습니다. 하지만, 여기서는 데이터 조작 문제에 대해서 집중적으로 살펴보고자 합니다. 다시 말해, 이와 같은 고급 요구 사항에 대응하는 과정에서 나타날 수 있는, 하위 수준의 문제점을 살펴보고자 하는 것입니다.
먼저 바탕을 깐다는 느낌으로 커뮤니티 차원에서 이루어지고 있는, 소프트웨어 취약점 정보의 취합 및 분류 노력에 대해 알아보도록 하겠습니다. 이것을 ‘공통 취약점 목록’(Common Weakness Enumeration)이라고 하는데, SANS Institute 및 MITRE Corporation이 호스팅하고 있는 것으로 주소는 cwe.mitre.org 입니다. 이 데이터베이스에서는 대기업 서버사이드 시스템에서부터 클라이언트 측 소프트웨어, 그리고 최종적으로는 임베디드 영역에 이르기까지 소프트웨어 분야 전반에 걸쳐 취약점 정보를 수집합니다. 즉, 여기에 해당되는 분야 중 상당수는 우리의 일상생활과는 동떨어져 있는 것입니다. 그렇지만 가장 위험성이 높은 것으로 분류된, 25대 소프트웨어 에러 목록, 그리고 2011년도에 추가된 15대 위험 에러의 목록을 살펴보면 아주 재미있는 것을 알 수 있습니다:
이것을 보면 C언어에 대해 조금이라도 알고 있는 사람이라면 누구라도 뭔가가 이상하다는 느낌을 받을 것입니다. 우리 모두는 갑자기 난데없이 나타나는 메모리 커럽션이나 이상한 출력 값을 받아 들고 어떻게 된 영문인지 수많은 시간을 들여가면서 추적을 해 본 경험이 있습니다. 여기서 한 가지 C 언어와 관련하여 '흥미로운' 점은 위에서 언급한 문제(그리고 그 외 일부 문제)는 기본적으로 시스템 자체에 태생적으로 존재한다는 것입니다. 예를 들어, 서명되지 않은 (unsigned) 정수 변수를 오버플로우하거나 랩어라운드 하는 것은 전혀 문제될 것이 없습니다. 또한, 이것은 경우에 따라서는 원하는 바를 이루기 위한 가장 효율적이고도 편리한 방법이 되기도 합니다. 하지만 이러한 언어 특성으로 인해 곤란한 상황이 발생하는 경우도 종종 있습니다. 뿐만 아니라 비록 서명된(signed) 정수를 오버플로우 하는 것은 규칙에 어긋나는 것이지만, 그럼에도 불구하고 십중팔구는 컴파일러가 상당히 그럴듯한 오버플로우 코드를 내 놓기도 합니다. 적어도 최적화 수준을 낮추어 놓거나, 아예 최적화 기능을 꺼 놓은 상태라면 말입니다. 예를 들어서 좀 더 값이 큰, 서명된 형식을 상대적으로 작은 서명 형식으로 할당하는 방식에 의존할 수도 있습니다. 오버플로우 또는 손실(truncation)이 발생하지 않는다면 말입니다. 크기가 큰 형식은 재사용 가능한 API의 일부를 구성하며, 우리는 해당 값은 절대로, 상대적으로 작은 형식을 오버플로우 하지 않을 것이라고 가정해 버립니다. 하지만 나중에 몇 차례 API의 개정이 이루어지고 나면, 사정이 바뀔 수도 있습니다. 여기서 골치 아픈 부분은 우리가 사용하는, 오버플로우 또는 손상이 발생한 값은 우리의 코드 안에서는 아무런 문제가 없을 수도 있다는 것입니다. 따라서 이러한 에러가 테스트 과정에서는 드러나지 않으며, 코드 자체가 제대로 작동 하는 것처럼 '보이는' 것입니다.
또한, 포인터 역시 많은 개발자들의 정신 건강을 위협하는 요소가 됩니다. 여기서도 우리 스스로의 발등을 찍게 되는 참극이 많이 발생하며, 마찬가지로 언어 자체가 태생적으로 가지고 있는 문제에 원인이 있습니다. 포인터는 말 그대로 무엇이든 지시하도록 할 수 있어, 포인터 상에서 다소간 구속되지 않은(unbounded) 산술 연산이 이루어지도록 하며, 이는 기본적으로 개발자가 사용할 수 있는 유일한 접근 통제 방식이 됩니다. 하드웨어가 예외를 발생시키지 않는 경우에는 정확한 프로그램을 짜는 데에 있어 그다지 우리에게 도움을 주지 못합니다. 동적 힙 메모리 관리를 추가하면, 사태는 더욱 흥미진진해 질 것입니다.
말썽을 부리는 포인터를 찾아내는 데에는 말 그대로 영겁의 시간이 걸릴 수도 있습니다. 왜냐하면 잘못된 포인터를 통해 쓰기를 하는 경우, 쓴 내용을 실행하는 프로그램 논리와는 전혀 상관없는 데이터에 악영향이 미칠 수도 있기 때문입니다. 손실된 데이터를 읽어 들이는 경우와 마찬가지로, 잘못된 위치에서 데이터를 읽어 들이는 경우에는 시험 과정에서는 에러조차 발생시키지 않을 수도 있습니다. 해당 데이터가 유효한 데이터로 해석되거나, 읽어 들이는 것만으로는 하드웨어 예외를 발생시키지 않을 수도 있기 때문입니다.
동적으로 할당된 메모리를 취급할 때에도 유사한 상황이 벌어질 수가 있습니다. 예를 들어, 이미 힙으로 돌린 블록으로 쓰기를 하는 경우가 그렇습니다. 프로그램의 다른 부분에서 이미 동일한 블록 전체, 또는 그 일부를 할당하고, 거기에 뭔가를 쓰기를 한 다음, 나중에 읽어 들이려 할 수도 있습니다.
포인터와 힙 문제는 특히 발견하기 어렵습니다. 하나의 아웃오브바운드 쓰기만 발생해도 시스템의 안전이 허술해 지거나, 외부 공격에 노출 될 수가 있기 때문입니다. 예를 들어 프로그램 상 버퍼가 스택 상의 고정 버퍼 용량을 넘어 서도록 하는 경우, 이를 통해 스택 상에 악성 코드를 복사할 수 있으며, 그와 동시에 올바른 리턴 주소를 자신이 원하는 주소로 바꾸어 놓을 수도 있습니다. 위의 표에서 보이는 바와 같이 이러한 공격은 시스템을 무너뜨리기 위해 흔히 사용하는 기법의 하나이며, CWE-120은 실제로 SQL 인젝션이나 OS 명령어 주입 취약성 다음으로는 가장 높은 순위의 취약점에 해당합니다.
공통의 기반
앞서, 이와 같이 까다로운 종류의 에러는 단위 테스팅 및 통합 테스팅에서도 좀처럼 걸러지지 않는다는 점을 확인하였습니다. 이것은 이상한 일은 아닙니다. 코드의 악성 반응은 종종 외부 인터페이스에서 예기치 못한 동작이 일어났을 때에 유발되기 때문입니다. 또한 오류 상황이 발생이 발생할 때에 시스템 상에 뚜렷한 징후가 나타나지 않는 경우도 있습니다. 프로젝트 테스팅 중 상당수는 태생적으로 기능적 사양에 따라 이루어집니다. 따라서 여기서는 미리 기술된 용도, 그리고 관련 시나리오에 초점이 맞추어집니다. 더 나아가, 실행중인 시스템에서 눈에 띌 수 있는 에러를 일으키는 부정적인 테스트를 생성하는 작업은 종종 상당한 어려움이 따르기도 하며, 시간도 많이 잡아먹습니다. (사람에 따라서는 변칙적인 수법을 써야 한다고 하는 사람들도 있을 수 있습니다.) 실제로 이를 위해서는 기존의 상식에서 벗어나, 예상되는 입력에 대한 반응을 확인하는 대신 오직 시스템의 작동을 정지시키거나 시스템에 장애를 초래하는 하나의 목적만을 위한 테스트를 개발해야 할 수도 있습니다.
앞서 설명한 종류의 문제를 성공적으로 찾아내기 위해서는 어떠한 도움이 필요할까요? 위에서 살펴본 CWE 문제는 크게 산술적 문제, 바인드 문제, 그리고 힙 체킹의 3 가지 범주로 나눌 수 있습니다.
산술적 문제
이 범주에서는 오버플로우, 랩어라운드, 변환 에러, 0으로 나누는 에러 뿐 아니라, 이해하기는 힘들지만 스위치 구문에서 기본 라벨 누락 에러까지도 다룰 수 있습니다. 이와 같은 에러는 특정한 계측 코드를 잠재적으로 에러가 발생할 수 있는 모든 위치에서 삽입하는 방식을 통해서 탐지할 수 있습니다. 소스 단위의 계측에서는 if 문을 삽입하거나, 그에 상응하는 내용을 삽입하여 조건을 점검하고, stdout으로 뭔가를 출력시키거나, 문제를 기록하기 위해 포트로 특수한 값을 전송시키기도 합니다. 마찬가지로, 컴파일러는 조건을 확인하기 위한 지시 내용을 주입할 수가 있으며, 모종의 방법을 통해 해당 문제를 런타임으로 보고하는 것도 가능합니다. 이와 같은 검사는 소스 영역에서 실행되던, 아니면 컴파일러 영역에서 실행되던 관계없이 상대적으로 수행하기가 쉽고, 일반적으로는 RAM 요구 사항이나 스택의 심도에 영향을 미치지 않습니다. 코드 사이즈는 점검해야 하는 동작의 수에 따라 다소 선형적으로 증가합니다. 예를 들어 컴파일러의 지시로 0으로 나누는 에러의 개수를 점검하는 경우, 나누기 연산을 하기 전, 0으로 나누는 경우의 개수 이하의 값을 내 놓게 됩니다. 따라서 전체 점검은 기본적으로 비교를 위한 하나의 지식, 그리고 나머지 하나 혹은 두 개의 지시를 통해 모종의 보고 행위를 하는 코드로 넘어가는 구조를 보이게 됩니다.
바운즈 문제(Bounds issues)
바운즈 문제는 매우 광범위한 문제 개념으로, 행렬에서 지정된 경계를 넘어서 쓰거나 읽기를 수행하는, 통상적인 아웃오브바운드 문제를 포함합니다. 그러나 아웃오브바운드 개념은 그 종류나 크기를 불문하고 포인터로 접근할 수 있는 모든 것을 포괄하는 의미로 일반화 할 수도 있습니다. 이는 스택 상의 scalar 오브젝트에 대한 포인터 등을 포함합니다. 따라서 만일에 포인터를 변경하는 경우, 또는 누가 악의적으로 개발자를 대신해 포인터를 스택 상의 다른 대상으로 변경해 버리는 경우, 첨단 기술을 바탕으로 한 바운드 점검 프로그램을 통해 포인터의 새로운 수치가 유효한 오브젝트의 바운드 내에 속하는지를 점검할 수가 있습니다. 이것은 포인터의 변동 상황을 추적하는 것을 의미할 뿐 아니라, 포인터에 연결되어 있는 오브젝트의 유효한 값의 범위를 추적한다는 의미가 되기도 합니다.
포인터를 추적하는 것은 쉬운 일이 아닙니다. 이것은 소스 차원에서는 이루어질 수가 있지만 컴파일러에서 이를 수행한다고 해서 특별한 소득이 있는 것은 아닙니다. 앞서 언급한 바와 같이, 빠르고 신뢰할 수 있는 바운드 검가 프로그램을 만들기 위해서는 포인터를 추적해야 하며, 관련된 범위에 대한 추적도 병행해야 합니다. 그리고 이러한 정보를 포인터로 보이는 수단을 통해 읽기 또는 쓰기 작업이 이루어질 때마다 사용해야 합니다. (C 형식의 행렬은 결국 포인터에 그럴 듯한 문법을 추가한 것에 불과하다는 것을 기억하십시오!) 바운드 검사는 성능에 영향을 미칠 뿐 아니라 코드의 크기, 그리고 RAM 요구량에도 영향을 미칩니다. 따라서 가급적 크기를 줄이는 것이 중요합니다!
바운드 검사를 까다롭게 만드는 또 하나의 요인은 인터페이스를 거쳐 오직 오브젝트 형태, 또는 어셈블리 언어 형태로만 존재하는 라이브러리를 오가는 포인터를 어떻게 처리할 것인가 하는 것입니다. 이러한 상황은 사용자가 직접 처리하는 것 외에 다른 방법은 없습니다. 하지만 복잡도의 차이, 그리고 사용의 편의성은 툴에 따라 상당한 차이를 나타냅니다.
힙 검사
힙 검사는 힙이 그 무결성을 유지하고, 시간의 흐름에도 불구하고 할당된 블록을 누출시키지 않는지 확인하는 것입니다. 효율적인 힙 검사는 기본적으로 라이브러리를 구축(implementation) 하는 작업이라고 할 수 있습니다. 하지만 관련되는 컴파일러의 내부적인 구조를 파악하는 것은 일부 기능을 다른 컴파일러 내재 기능과 동일한 방식으로 취급이 가능할 시 도움이 될 수가 있습니다. 무결성 검사는 통상적으로 malloc, free, 그리고 그에 수반되는 다른 요소(friends)에 대한 호출이 이루어질 때마다 이루어집니다. 이는 C와 C++이 동일합니다. 하지만 좋은 힙 패키지는 이미 지정이 해제(freed) 된 메모리 블록에 대한 읽기 및 쓰기 동작, 또는 지정된 블록의 바운드를 넘어서는 쓰기 동작 등의 감지도 가능하게 해 줍니다. 뿐만 아니라, 특정 메모리 블록은 누출 탐지의 대상에서 제외하거나, 사용자가 정하는 바에 따라 임의의 실행 포인트에서 누출 또는 힙 무결성을 검사하기도 합니다. 힙 무결성 검사는 힙의 크기가 상당한 경우 성능에 상당히 부담을 줄 수도 있습니다. 왜냐하면, 검사를 위해서는 전체 힙을 모두 거쳐야 할 수도 있기 때문입니다. 그러므로 검사의 빈도를 정하는 방법은 일부 어플리케이션에서는 매우 중요한 의미를 지닐 수도 있습니다.
다시 기본으로
서문에서 C-RUN에 대해 언급했던 것을 제외하고는 지금까지 런타임 분석 및 에러 검사를 위한 C-RUN를 다루지 않았습니다. 여기까지 읽어 오시면서 C-RUN가 산술적 검사, 바운드 검사, 힙 검사도 포함하는지 궁금하셨을 터인데, 포함하는 것이 맞습니다. C-RUN는 별도의 제품으로, 기존의 IAR Embedded Workbench에 추가하실 수가 있으며, 라이선스를 업그레이드 하는 방식으로 C-RUN를 부가하는 것도 가능합니다. C-RUN는 IAR Embedded Workbench for ARM 및 RX 용으로 제공이 되고 있으나, 유효한 지원 및 업데이트 계약을 포함한 표준 버전을 보유하시는 고객의 경우, 용량 제한 모드로 쉽게 C-RUN를 평가해 보실 수가 있습니다.
그렇다면, C-RUN는 사용자에게 어떻게 표시되는 것일까요? 당사가 제공하는 컴파일러 및 디버거 기술의 매우 자연스러운 확장이라고 할 수 있습니다. 물론 제가 견해가 약간 치우쳐 있을 수도 있지만 말입니다. 통상적인 용도는 그저 원하는 옵션을 설정하고, 프로젝트를 다시 빌드한 다음, 디버거 내에서 실행하여 검사 시 실제로 문제가 탐지되는지의 여부를 확인하는 것 정도입니다. 디버거는 무슨 문제가 발생했는지에 대해 매우 상세하게 설명해 드릴 것이며 사용자의 현 위치에서 조회가 가능하도록 콜 체인(call chain)을 제공할 것입니다. 작동의 과정은 이렇게 단순합니다! 통합 문제에 발목을 잡힐 일도 없고, 툴 버전 호환 문제, 새로운 파서에서 특정 키워드를 무시하도록 하는 방법, 검사 결과에서 파일 및 기호를 포함하도록 하는 법 등을 배우기 위해 골머리를 썩일 필요도 없습니다. 그러나 디버거를 포함해 전체 통합 검사를 실시하는 것은 적절치 못할 수도 있습니다. 예를 들어, 전기적 분리 및 격리가 원인이 될 수도 있기 때문입니다. 하지만 걱정하실 필요는 없습니다. 기본 보고 방식은 당사의 디버거 내에서 사용할 수 있도록 최적화 되어 있지만, 사용자의 환경에 맞는 방식으로 보고를 하는 다른 메커니즘으로 이를 쉽게 대체할 수가 있습니다. 예를 들어 메모리 또는 파일에 로그를 기록할 수도 있고, printf로 출력하거나 지정된 포트로 쓰기 동작을 수행하도록 할 수도 있습니다.
C-RUN은 일상적인 개발 업무의 일부로 자연스럽게 통합이 가능하도록 설계되어 있으며, 기존의 편집기/빌드/디버그 순서로 작업을 하던, 단위 검사를 실시하거나 통합 검사를 실시하던 상관없이 활용이 가능합니다. 이러한 과정에 지정을 초래하거나, 별도로 특별한 작업을 수행해야 할 필요성이 없기 때문입니다:
- 하지만 컴파일러에 의한 계측과 소스 단계에서의 계측은 어떠한 차이가 있는 것일까요? 표면적으로는 거의 차이가 없는 것으로 보입니다. 이 툴에서는 관심의 대상이 되는 모든 위치, 즉 검사를 할 만한 가치가 있는 일이 벌어지는 위치에 계측 코드를 삽입해야 합니다. 이와 같은 할당이 이루어질 시에는 오버플로우가 일어날 수 있으며, 포인터를 통해서 읽기를 수행하는 경우에는 아웃오브바운드나, 다른 문제를 유발할 수도 있습니다. 실무에서는 다음과 같은 차이점이 존재합니다.
- 컴파일러는 어플리케이션 코드와 런타임 검사에서 필요로 하는 코드의 차이를 인지하고 있습니다. 특히 이것은 컴파일러에서 먼저 다양한 최적화 기능을 적용한 다음, 최적화된 계측 코드를 남아 있는 부분에 삽입하는 것을 의미합니다. 이 상황에서 원래의 소스와는 계속 연결을 유지합니다.
- 소스 단위에서 계측을 실시하는 경우, 계측 코드는 원래의 코드라면 가능했을 최적화를 상당 부분 무너뜨리게 됩니다. 그 원인은 기본적으로 위와 같습니다. 컴파일러가 자신이 검사하고 있는 코드가 특별한 코드인 사실을 전혀 알지 못하며, 따라서 특별한 취급이 대상이 되는 것을 인지하지 못하는 것입니다.
- 위의 두 항목은 모두 최적화에 초점을 두고 있습니다. 하지만 과연 그것이 어째서 중요한 것일까요? 결국 코드를 삽입할 수 있는 공간, 또는 필요로 하는 실시간 성능을 기준으로 생산 하드웨어 상에서 어떠한 테스트를 수행할 수 있는지, 그리고 얼마나 많은 계측 시험 빌드를 생성해 실행해야 하는지가 정해집니다. 여기서 한술 더 떠서, 계측으로부터의 오버헤드가 지나치게 증가하는 경우, 하드웨어 상에서는 아무런 검사 테스트를 실행하지 못할 수도 있습니다.
그렇지만 컴파일러가 코드를 최적화하는 방식에 따라 오류를 유발할 수 있는 연산이 일어나지 않을 가능성도 있습니다. 그리고 이것은 계측이 어떠한 방식으로 일어났는지에 관계없이 발생할 수 있습니다. 그러므로 시간과 공간이 주어진다면, 생산 빌드의 수준보다 낮은 최적화 수준으로 테스트를 실시하는 것도 좋을 것입니다.
- 빌드 환경과의 통합. 이것은 상당한 골칫덩어리가 될 수도 있습니다. 계측 툴은 기본적으로 사용자의 빌드 툴 체인과 동일한 코드 및 빌드 환경 정보를 지녀야 하기 때문입니다. 톨은 또한 빌드 툴과 동일한 언어를 지원할 필요도 있습니다. 이것은 일견 너무나 당연한 말처럼 들리겠지만, 언어 확장이나 다양한 언어 표준 해석에 비추어 볼 때, 당연하다고 생각할 수 있는 문제는 절대 아닙니다. 여기에 복잡한 빌드 의존성, 헤더 파일 투명성, 복합 모율, 위계 서열 구조까지 더해지면, 동시에 일상적인 개발 과정에서도 작동이 가능하도록 구축하는 것은 상당히 골치 아픈 일이 될 수가 있습니다. 이와 같은 툴 중 일부에서는 전체 빌드 및 테스트 절차를 원하기도 합니다. 이것은 실질적으로 툴박스 내에 또 다른 IDE를 추가하는 것과 같은 것입니다.
- 빌드 툴 체인내로 통합이 이루어진다는 것은 C-RUN 검사의 전체 또는 일부를 포함하는 하나 이상의 빌드 구성을 쉽게 설정하고, 이러한 설정과 원래의 빌드 설정 사이를 오가는 것이 용이함을 의미합니다.
여기서 한 가지 주의해야 하는 부분이 있습니다. 일부 최첨단 시판 툴은 소스 단계에서 작동하면서 정말 놀라운 기능을 보여 줄 수 있습니다. C-RUN은 결코 이와 같은 툴들을 대체하지는 못합니다. 그러나 이러한 툴들은 주로 지정된 유닛 테스트, 요구 사항의 검증 및 요건 충족 관리 시나리오에서 사용됩니다. 반면, 여기서 우리가 주로 다루고 있는 내용은 좀 더 하드웨어에 가까운, 낮은 수준에 해당하는 것입니다. 쉽게 사용할 수 있는 인터페이스를 통해 C-RUN은 시험을 위해 첫 번째 코드 반복 주기를 실행하기 직전부터 극도로 유용한 피드백을 제공해 줍니다. 그리고 IAR Embedded Workbench와의 밀접한 통합을 통해 C-RUN은 어떠한 개발자도 일상적인 개발 업무에 반영할 수가 있습니다.