기본으로 돌아간다 – 효율성 증대를 위한 추적 및 디버깅

 

임베디드 소프트웨어의 디버깅은 경우에 따라서는 시간이 많이 걸리는 작업입니다. 이는 프로젝트 활동 전반에서도 그렇지만, 특히 개별 버그를 추적해 내는 것이 어렵습니다. 버그 추적은 그야말로 컴퓨터 세상 속의 절망감과 압박감, 그리고 기발한 사고가 이루어지는 활동이라고 할 수 있습니다.

여기서 알려드리는 기법으로 이러한 어려움이 모두 사라지지는 않을 것입니다. 하지만 디버깅에 있어 기발한 발상에 의존하는 비중을 줄여 드릴 수는 있습니다. 임베디드 소프트웨어 분야가 비교적 생소한 분들께서는 특히 도움이 되는 내용을 찾아 보실 수 있을 것입니다. 숙련된 프로시라면 이러한 기법을 이미 들어서 알고 계시겠지만, 그동안 잊고 활용하지 않았던, 유용한 기법들을 다시 한 번 떠올려 볼 수 있는 계기가 되기를 바랍니다

기본 바탕으로서의 코드 품질 

방금 작성한 코드는 반드시 버그가 있습니다. 이는 만고 불변의 진리입니다. 그렇지만 코드 속에 우리가 해결해야 하는 문제의 수를 줄여주는 방법은 분명히 존재합니다. 즉, 처리해야 하는 버그가 그 만큼 적어지는 것입니다. 이러한 활동의 가장 기본은 뭐니뭐니 해도 코드 위생(code hygiene)을 철저히 관리하는 것입니다. 여기서 기억 하셔야 하는 규칙을 간단하게 살펴 보면 다음과 같습니다:

  • 코딩 표준을 준수할 것. MISRA, 그리고 CERT C는 좋은 출발점이 될 수 있습니다. MISRA 표준을 준수하는 경우, C/C++에 태생적으로 존재하는 여러가지 문제점들을 피해갈 수 있습니다. CERT C의 경우는 또한 이와 같은 버그 방지 과정에서 보안에 관한 측면을 보완해 줍니다. 가장 먼저 떠올릴 수 있는 첫 번째 원칙은 컴파일러의 경고 메시지에 귀를 기울이는 것입니다. 두 번째 원칙은 자동화 정적 체커(checker)를 통해 관련 표준의 준수 여부를 확인하는 것입니다.
  • 자기 자신이 직접 작성했거나, 타인이 작성한 하드웨어 추상화 레이어를 사용하십시오. 하드웨어를 제어하는 인라인 코드(inline code)를 코드 내에 삽입하지 마십시오. 예를 들어, 타이머를 시작할 필요가 있다고 할 때 타이머 레지스터를 직접 조작하는 것 보다는 HAL 함수를 호출하여 타이머를 설정하고, 작동을 시작하는 것이 좋습니다. 이와 같은 원칙을 따르는 경우 누릴 수 있는 장점이 몇 가지 있습니다. 그 중 하나가 서로 다른 코드에 걸쳐 다른 방식으로 타이머를 설정하는 과정에서 바로 에러가 있는 코드를 복사 후 붙여 넣기하는 실수나, 오탈자를 방지할 수 있다는 것입니다. 뿐만 아니라 컴파일러가 자체적으로 최적화를 수행해, 코드를 인라인으로 삽입할 수도 있습니다. 이 경우 코드의 양이 줄어 들고 성능에 있어서도 실제로 이점을 누릴 수가 있는 것입니다. 여기서 지켜야 하는 첫 번째 원칙은 각각의 HAL 함수를 가급적 간결하게 가져야 한다는 것입니다. 다양한 기능을 지니는, 덩치 큰 하나의 만능 함수를 만드는 방식은 지양하십시오. 단일한 목적을 지니는 소규모 함수들은 이해하기가 상대적으로 쉬울 뿐 아니라 컴파일러 차원에서 최적화하기도 더 쉬운 경우가 많습니다. 이 부분은 직관적으로 이해하기에 어려움이 있을 수도 있습니다. 
  • 메모리 사용에 신중하세요. 예를 들어, 정말 동적 메모리 관리가 필요한 걸까요? 복잡한 데이터 구조를 스택에 저장하는 게 정말 좋은 생각일까요? 기능적 안전성에 대한 표준 및 고도 직접 소프트웨어에서는 동적 메모리 관리, 그리고 복잡한 데이터나 대용량의 데이터를 스택에 저장하는 것을 가급적 지양하도록 권하고 있습니다. 여기에는 다 나름의 이유가 있습니다. 
  • 만일 사용하시는 툴 체인이 최악의 시나리오에 해당하는 스택 심도 분석을 지원하는 경우, 이를 읽어 들여 해당 기능을 사용하기 위해 이루어지는 투자는 빠르게 그 대가를 얻을 수 있습니다.

printf냐, 또는 printf를 쓰지 않을 것이냐는 중요하지 않습니다. 

가장 먼저 깨달아야 할 것, 그리고 기억해야 할 것은 바로 지금 개발하고 있는 것이 임베디드 소프트웨어인 경우, 대상에 대한 코드를 실행하는 것은 디버거를 통하여 이루어질 가능성이 높습니다. 예를 들어, 만일 IDE를 사용하여 개발을 진행하는 경우, 프로그램을 실행하는 가장 쉬운 방법은 디버거를 띄우는 것입니다. 이는 조금만 생각해 보면 금방 알 수 있는 이치입니다. 하지만 여기서 한 가지 주목해야 하는 사실은 사용자가 깨닫기도 전에, 디버거의 모든 기능이 당신의 손 끝에 있다는 것입니다. 

좀 더 자세히 살펴 보기 위해, 브레이크포인트가 지니는 위력에 대해 한 번 알아보겠습니다. 그렇지만 먼저, 오래 전부터 디버깅에 이용되어 온 printf는 왜 사용하면 안 되는지에 대해 간략하게 살펴 보겠습니다. printf를 쓰면 안 되는 가장 중요한 이유는 printf문을 코드에 추가할 경우, 코드의 컴파일에 상당한 영향을 미칠 수 있다는 점입니다.  printf는 그 자체가 함수 호출일 뿐 아니라, 거기에 투입되는 인자 역시 고려하지 않으면 안 되기 때문입니다. 이는 다시 스택과 레지스터의 이용도 크게 차이가 난다는 것을 의미하며, 선언문이 타이트한 루프 내에서 발생할 시 컴파일러 차원에서 이루어지는 최적화의 상당수를 건너 뛴다는 것을 의미합니다. 이는 코드가 복잡하거나, C/C++의 구축 과정에서 정의되는 습성에 의존하는 경우, 또는 C/C++ 표준에서 정하고 있지 않은 내용인 경우 예기치 못한 결과로 이어질 수도 있습니다. 그렇게 되면, 코드에 printf를 두었을 시에는 잘 작동하던 코드가 printf를 제거할 시 제대로 작동하지 않을 수가 있으며, 그 반대의 경우도 발생할 수 있습니다. 이는 또한 MISRA 표준을 준수하기 위해 힘써야 하는 또 하나의 좋은 이유가 되기도 합니다. 또 printf의 경우는 데이터를 표시만 할 수 있기 때문에 그다지 좋은 디버깅 툴이 되지 못하기도 합니다. 세 번째 이유는 출력물의 행태에 변경을 가하거나, 출력 선언문을 추가하는 경우 어플리케이션의 빌드를 다시 해야 하며, 따라서 대상으로 다운로드 받는 과정도 반복되어야 하기 때문입니다. 마지막으로 어느 시점에서는 코드 베이스를 검토하면서 개발자가 추가한 모든 선언물을 제거해야 합니다. 이들이 모두 #ifdefs에 의해 관리되고 있다고 할 지라도 말입니다. 

브레이크포인트의 위력 

그러면 잠시 설교를 멈추고, 우리가 개발 과정에서 사용할 수 있는 다양한 종류의 브레이크포인트에 대해 알아보도록 하겠습니다. 브레이크포인트는 가장 단순하게 말하면 소스 선언문 속에 심어 둔 정지 표지판이라고 볼 수 있습니다. 따라서, 코드가 실행되다가 무조건 해당 지점에서는 중지되는 것입니다. 제대로 된 디버거라면 변수의 내용, 레지스터, 그리고 콜 스택의 내용은 물론 메모리 전반을 점검할 수 있도록 해 줄 것입니다.  이러한 코드 브레이크포인트는 그 자체로도 매우 유용하지만 특정 코드가 참인 경우, 또는 그렇지 않은 경우에 따라 실행 중지 여부가 결정되는 코드를 함께 사용할 시 그 위력이 배가 됩니다. 

이러한 코드를 사용하게 되면 코드 실행 위치가 브레이크포인트를 지날 때마다 대상 변수를 점검하는 대신, 대상 케이스에 더 집중할 수가 있게 됩니다. 예를 들어 만일 로프 인덱스 변수 내에서 특정한 값의 범위에 대해 좀 더 구체적으로 살펴보고자 하는 경우, 해당 위치에서 무조건 실행이 중지되는 것이 아니라 해당 인덱스가 대상 범위 내에 위치할 때에만 실행이 중지되도록 코드를 작성할 수가 있습니다. 물론, 범위 내에 속하는 임의의 변수를 활용하여 좀 더 복잡한 중지 코드를 작성할 수도 있습니다. 

때에 따라서는 하나 이상의 코드에서 값을 확인해야 할 경우가 있습니다. 이 때에는 로그 브레이크포인트(log breakpoint)를 사용하면 편리합니다. 로그 브레이크포인트는 디버그 로그 창에 메시지를 출력시키는 기능 만을 지니는 브레이크포인트입니다. 코드는 중지되지 않고 계속 실행됩니다. 이는 기본적으로 디버거에서 제공하는 printf라고 할 수 있습니다. 이 기능은 불리언 선언문과 함께 사용하여 특정 메시지를 출력할 지의 여부를 결정할 수 있는 기능을 지니고 있습니다. 

AH breakpoint

데이터 브레이크포인트(data breakpoint) 역시 매우 강력한 브레이크포인트입니다. 데이터 브레이크포인트는 특정한 변수나 메모리 위치에 접근하는 경우 발동합니다. 이는 특정한 위치에 와야 하는 데이터 대신 엉뚱한 데이터가 오는 이유를 알아 낼 때에 유용합니다. 왜 그런 걸 알아야 하냐구요? 이러한 상황이 발생할 수 있는 이유는 다양합니다. 하지만 그 원인은 바로 포인터에 있습니다. 개발 과정에서 포인터를 사용(또는 남용)하는 경우 결국은 포인트 계산을 실수하는 사태가 벌어지고 맙니다. 그리고 잘못된 주소에 데이터를 쓰거나, 엉뚱한 주소에서 데이터를 읽어 들인다고 하여 프로그램이 완전히 멈춰버리지는 않지만, 괴상한 결과가 도출되는 경우가 있을 수 있습니다. 이러한 문제는 특히 디버그 과정에서 해결하기 힘든 부분입니다. 실제 버그가 존재하는 위치와 그로 인한 결과가 발생하는 위치가 서로 다른 경우가 종종 발생하기 때문입니다. 

데이터 브레이크포인트(또는, 이에 관련되는 다른 종류의 브레이크포인트)를 콜 스택 윈도우(call stack window)와 함께 사용하는 경우 많은 것을 알 수 있습니다. 콜 스택 윈도우에서는 어디서 지금 현 재의 위치로 흘러 들어왔는지를 알 수가 있으며, 그 내용을 보면 예상한 것과 전혀 다른 양상을 보일 때가 가끔 있습니다. 이를 통해 콜 체인을 오르내리며 파라미터 수치를 점검해 볼 수가 있게 되는 것입니다. 

이러한 브레이크포인트 형식 중 일부는 사용이 어려운 경우도 있을 수 있습니다. 이는 프로그램을 실행하는 기기 및/또는 사용되는 디버그 프로브가 구체적으로 무엇인지에 따라 정해집니다. 

일부 타겟에서는 메모리를 실시간으로 읽어 올 수도 있습니다. 따라서 디버거는 표준 디버그 프로브를 사용하여 실행되는 과정에서 지속적으로 변수의 값 및 기타 다른 정보를 표시할 수가 있습니다.  

깨달음의 길  

복잡한 이야기를 조금만 더 참아 보실 의향이 있으신 사용자들을 위해, 정말 놀라운 디버깅 툴에 대해서 한 번 이야기를 해 볼까 합니다. 추적(Trace)은 실행의 기록, 그리고 디바이스 상에서 일어나는 다른 종류의 데이터 흐름을 기록하는 방식으로, 실행 중단(interrupt) 정보나 기타 하드웨어에서 발생하는 이벤트를 기록합니다. 예를 들어, 하나의 타임라인 내에서 혼합되어 있는 이벤트를 조회하면 시스템의 작동 상태에 대해 많은 정보를 얻을 수 있습니다. 실행 중단 코드가 적절한 시점에 작동을 하는지, 그리고 다른 활동과 어떻게 연계되고 있는지 등을 알 수 있는 것입니다. 

데이터 브레이크포인트(data breakpoint) 역시 매우 강력한 브레이크포인트입니다. 데이터 브레이크포인트는 특정한 변수나 메모리 위치에 접근하는 경우 발동합니다. 이는 특정한 위치에 와야 하는 데이터 대신 엉뚱한 데이터가 오는 이유를 알아 낼 때에 유용합니다. 왜 그런 걸 알아야 하냐구요? 이러한 상황이 발생할 수 있는 이유는 다양합니다. 하지만 그 원인은 바로 포인터에 있습니다. 개발 과정에서 포인터를 사용(또는 남용)하는 경우 결국은 포인트 계산을 실수하는 사태가 벌어지고 맙니다. 그리고 잘못된 주소에 데이터를 쓰거나, 엉뚱한 주소에서 데이터를 읽어 들인다고 하여 프로그램이 완전히 멈춰버리지는 않지만, 괴상한 결과가 도출되는 경우가 있을 수 있습니다. 이러한 문제는 특히 디버그 과정에서 해결하기 힘든 부분입니다. 실제 버그가 존재하는 위치와 그로 인한 결과가 발생하는 위치가 서로 다른 경우가 종종 발생하기 때문입니다. 

데이터 브레이크포인트(또는, 이에 관련되는 다른 종류의 브레이크포인트)를 콜 스택 윈도우(call stack window)와 함께 사용하는 경우 많은 것을 알 수 있습니다. 콜 스택 윈도우에서는 어디서 지금 현 재의 위치로 흘러 들어왔는지를 알 수가 있으며, 그 내용을 보면 예상한 것과 전혀 다른 양상을 보일 때가 가끔 있습니다. 이를 통해 콜 체인을 오르내리며 파라미터 수치를 점검해 볼 수가 있게 되는 것입니다. 

이러한 브레이크포인트 형식 중 일부는 사용이 어려운 경우도 있을 수 있습니다. 이는 프로그램을 실행하는 기기 및/또는 사용되는 디버그 프로브가 구체적으로 무엇인지에 따라 정해집니다. 

일부 타겟에서는 메모리를 실시간으로 읽어 올 수도 있습니다. 따라서 디버거는 표준 디버그 프로브를 사용하여 실행되는 과정에서 지속적으로 변수의 값 및 기타 다른 정보를 표시할 수가 있습니다.  

깨달음의 길  

복잡한 이야기를 조금만 더 참아 보실 의향이 있으신 사용자들을 위해, 정말 놀라운 디버깅 툴에 대해서 한 번 이야기를 해 볼까 합니다. 추적(Trace)은 실행의 기록, 그리고 디바이스 상에서 일어나는 다른 종류의 데이터 흐름을 기록하는 방식으로, 실행 중단(interrupt) 정보나 기타 하드웨어에서 발생하는 이벤트를 기록합니다. 예를 들어, 하나의 타임라인 내에서 혼합되어 있는 이벤트를 조회하면 시스템의 작동 상태에 대해 많은 정보를 얻을 수 있습니다. 실행 중단 코드가 적절한 시점에 작동을 하는지, 그리고 다른 활동과 어떻게 연계되고 있는지 등을 알 수 있는 것입니다. 

AH trace

추적 작업이 일반적인 디버그보다 약간 더 복잡한 이유는 추적 기법이 그만큼 다양하기 때문입니다. 추적 데이터에 접근하는 방식 역시 다양합니다. 여기에, 추적 기능을 갖추고 있는 프로브도 필요할 수 있습니다. 그러므로 개발자의 필요에 따라 추적 기능이 지니고 있는 힘을 최대한 활용하기 위해서는 프로젝트 초기부터 추적 기능을 활용하기 위해 어떻게 해야 하는지를 미리 생각해 두는 것이 좋습니다. 

  • 그 중 하나가 기기의 선택입니다. 해당 기기가 추적 기능을 지니고 있는지? 있다면 어떠한 종류의 기능인지? 
  • 기기의 버전이 추적 기능이 있는 버전과 없는 버전으로 나뉘어 제공되는지? 만일 그렇다면, 추적 기능을 포함해 보드의 개발 버전을 빌드한 뒤, 실제 양산 과정에서는 비용 절감을 위해 추적 기능이 없는 것을 사용할 수 있습니다. 
  • 추적은 또한 프로파일링 및 코드 커버리지 데이터에도 많은 도움이 될 수 있습니다. 그러므로 이 부분과 관련해 어떠한 내용을 필요로 하게 될 지를 미리 생각해 두는 것이 좋을 것입니다.

추적 과정의 복잡성을 줄이고, 주어진 추적 정보를 활용하기 위해 여러 우수한 추적 도구가 개발되어 있습니다. 하지만 하드웨어 측면에서 어떠한 부분을 필요로 하고 있는지를 개발자가 생각해 두지 않으면 안 됩니다. 그렇지만 추적을 향후 디버그 및 코드 품질 관리 도구로 활용하기 위해 미리 시간과 자원을 투자해 놓는다면, 처음 곤란한 문제에 직면하는 그 순간부터 그 진가를 발휘하게 될 것입니다. 

효율성 증대 방법 

이 글에서 설명하고 있는 내용 중 일부는 사소하다면 사소하다고도 할 수 있는 내용들입니다. 하지만 난관에 부딪혔을 때 해결책을 찾을 수 있는 곳도 바로 이런 사소한 부분들입니다. 소프트웨어 문제의 근본 원인을 찾아 내는 데에는 며칠, 또는 몇 주가 걸릴 수도 있고, 금방 문제가 발견되는 경우도 있습니다. 문제를 금방 찾아낼 확률을 높이는 방법은 printf 선언문에 의존하지 말고, 코드베이스에 대해 개발자가 보유하고 있는 지식, 그리고 디버거 및 추적 툴이 지니고 있는 기능을 어떻게 조합해 사용할 것인지에 대해 잠시 생각하는 시간을 가져 보는 것입니다. 시간이 흐름에 따라, 이러한 작업 방식을 도입하는 것이 정신 건강에 이로울 뿐 아니라 생산성과 효율성 측면에서 큰 도움이 된다는 점을 알 수 있을 것입니다. 

 

작성: Anders Holmberg, IAR 시스템스 임베디드 개발 툴 총괄 관리자