Skip to content
irmusy edited this page Sep 30, 2013 · 2 revisions

개요

Assert는 프로그램의 런타임(실생중) 오류를 체크하고 적절한 디버그 정보를 생성하기 위한 수단으로 사용되는 디버깅 방법입니다.

코딩을 하다 보면 여러 가지 디버깅 방법을 사용하게 됩니다. 가장 원시적이면서도 직관적인 printf() 마구 집어넣는 방법부터 JTAG을 물려 한줄씩 진행하면서 메모리 변동상황을 살펴보는 방법까지 많은 방법이 있고, 자신의 상황에 맞는 방법을 선택해서 사용하게 될 것입니다. Assert는 printf()와 비슷한듯 하면서도 상당히 강력한 디버깅 수단입니다.

여기서 잠깐, 첫번째 문장에서 런타임 오류를 체크한다고 했었는데, 이것은 컴파일 타임과 대비되는 개념입니다. 컴파일 타임 오류는 구문 오류와 같이 정적인 오류이며, 컴파일러가 잘 잡아내 줍니다. 반면 런타임 오류는 프로그램이 실행되는 도중에 특정 데이터 조합에 의해서만 발생하는 오류입니다. 예를 들어 x = y / z 라는 구문이 있을 때, 컴파일 오류는 발생하지 않습니다. 일반적인 경우라면 런타임 오류도 발생하지 않을 것입니다. 하지만 z = 0 이라는 특수한 경우에 대해서는 런타임 오류가 발생합니다. 이처럼 특정 데이터에 의해서만 발생할 수 있는 오류를 런타임 오류라 합니다.

printf() 디버깅

Assert가 printf()와 비슷하다고 했으니 먼저 printf()를 디버깅 수단으로 사용하는 경우 부터 살펴보도록 하겠습니다. printf()를 사용한 디버깅은 궁금한 포인트에 printf() 함수 호출을 넣어 원하는 메시지나 데이터를 출력해서 지켜보는 것으로 디버깅 하는 것을 말합니다.

PC에서 프로그래밍 할 때에는 printf()를 사용하는 디버깅이 꽤 유용한 방법입니다. 하지만 임베디드 환경에서는 좀 다른 이야기입니다. printf() 함수는 쓰기 편하고 기본 제공되는 강력한 출력 함수이긴 하지만 상당히 덩치 크고 무거운 함수입니다. printf() 호출 한번에 들어가는 CPU 연산과 사용되는 메모리 양이 아주 크다는 뜻입니다. 디버깅의 기본은 원래 수행되어야 하는 로직의 흐름을 방해하지 않으면서 개발자에게 필요한 정보를 제공하는 것인데, printf()를 넣어 버리면 이 기본 흐름이 방해받게 됩니다. 예를 들어 1KHz 주기로 동작하는 PID 컨트롤러를 만들고 있다고 가정해 보겠습니다. 1KHz 주기면 한 루프의 연산은 최대 1ms 내에 끝나야 합니다. 만일 printf() 함수 한번 호출에 걸리는 시간이 1ms를 넘는다면 어떻게 될까요? 1ms까지는 안되더라도 100us 정도 된다면 어떻게 될까요? 디버깅용으로 printf()를 몇개 넣어 버리면 루프 수행에 걸리는 시간 자체가 큰 폭으로 변하게 됩니다. 1KHz라는 컨트롤 주기를 지킬 수 없어져 버리는 거죠. 디버깅 메시지를 넣어서 열심히 버그 잡고 "디버깅 끝~" 하며 printf() 문구를 제거해 놨더니 또다른 버그가 나타나더라 같은 일이 벌어지게 되는 원인입니다.

printf()가 오래 걸리는 이유는 그 로직이 복잡해서이기도 하지만, 실제 문자열이 출력되는 방법에도 문제가 있습니다. PC에서는 화면에 출력해 버리면 그만입니다. 임베디드에는 화면이라는 출력 수단이 없죠. 그래서 보통 printf()로 출력되는 문자열은 UART를 통해 시리얼 포트로 출력합니다. 잘 아시다시피 UART는 굉장히 느린 인터페이스 수단입니다. 115200bps의 UART를 이용한다 하더라도 한 글자를 출력하는데 대략 10us 정도 걸립니다. 스무 글자 짜리 디버깅 메시지 3개만 출력한다하더라도 600us가 걸립니다. 물론 이건 UART 출력 로직이 최적화 되어 오버헤드 없이 동작하는 것을 가정했을 때 이야기이며, 실제로는 이보다 더 많은 시간이 걸릴 것입니다.

printf()의 수행 시간이 오래 걸리는 것도 문제이며, printf()의 바이너리 사이즈가 크다는 것도 문제입니다. 작은 MCU에는 수KByte의 ROM만 있는 경우도 있습니다. 반면 일반적인 printf()라면 10KB 이상의 ROM을 요구합니다. 디버깅하려고 더 큰 사이즈의 MCU로 바꿔야 한다고 말한다면 아무도 수긍하지 않을 것입니다.

임베디드에서의 디버깅

위에서 살펴본 바와 같이 임베디드 환경에서는 printf()를 사용해 디버깅 하는 것에 어느정도 한계가 있습니다. 복잡한 로직에서는 printf()를 사용하는 것 자체가 아주 위험한 경우가 많습니다. 그래서 어지간해서는 printf()를 쓰지 않고 JTAG을 이용한 디버깅을 더 선호하게 됩니다.

하지만 JTAG에도 한계가 있습니다. 바로 항상 JTAG 디버깅 모드로 동작시킬 수 없다는 것입니다. JTAG으로 디버깅을 하려면 PC와 연결하여 JTAG 디버깅 모드로 동작시키고 있어야만 합니다. 일반 동작 모드로잘 돌고 있는 시스템을 JTAG 모드로 전환하는 것은 불가능합니다. 즉 처음부터 디버깅을 염두에 두고 돌려야만 하는 것이죠.

항상 발생하는 로직상의 오류는 JTAG을 이용한 디버깅으로 쉽게 해결할 수 있습니다. 반면 사용자 입력이나 센서 입력 등의 동적 데이터 때문에 발생하는 진성 런타임 오류는 JTAG으로 해결하기 어렵습니다. 오류가 발생하는 시점이 일정하지 않다면 더더욱 어렵습니다.

앞에서 살펴본 것과 같이 x = y / z 라는 문구가 있을 때 z의 값이 0이냐 아니냐에 따라 오류가 생기거나 안생기거나 할 수 있습니다. Assert는 바로 이런 경우에 적합한 디버깅 수단입니다.

Assert 구문

Assert는 하나의 함수라고 생각하면 편합니다. 보통 아래 처럼 사용합니다.

ASSERT(조건)

이 문구는 "조건"이 참이라면 아무 일도 하지 않고 지나가고, 거짓이라면 특정한 디버깅 작업을 수행하라 입니다. 보통 assert는 메크로 함수로 많이 만드는데, 아래와 같이 만들어 지게 됩니다.

#define ASSERT(x)       if(!(x)) __error__(__FILE__, __LINE__)

조건 "x"가 false라면 __error__() 함수를 호출하는 것입니다. 인자로 넘어가는 __FILE__과 __LINE__은 컴파일러에서 제공하는 기능입니다. 이 문구는 컴파일 시점에 소스 파일의 이름과 라인 번호로 자동으로 치환됩니다. 즉 런타임 에러가 발생하면 해당 지점의 파일 이름과 라인 번호를 알 수 있게 됩니다.

__error__()함수는 사용자가 직접 작성하는 함수입니다. 인자로 파일 이름과 라인 번호를 받아 이를 디버깅 메시지로 출력하게 만들면 됩니다.

ASSERT()가 printf()와 다른 점이라면 조건 검사 기능과 그에 따른 정형화된 에러 메시지 출력 기능입니다. printf()는 무조건 메시지를 출력하게 되지만 ASSERT는 조건이 참이라면 아무 일도 하지 않습니다. 또한 조건이 거짓일 때에는 ASSERT()문구가 있는 소스 파일 이름과 라인 번호를 포함한 에러 메시지를 출력하게 될 것입니다. 이 에러 메시지는 __error__()함수에서 출력하는 것이며, 사용자가 원하는 형태의 메시지를 원하는 인터페이스(UART, LED, flash memory 등)로 출력하게 됩니다.

일반적으로 __error__()함수 구현에는 파일명과 라인번호를 포함한 에러 메시지를 UART 인터페이스로 출력하고, LED를 깜박이는 동작을 무한 반복하게 합니다. 무한 반복하는 이유는 런타임 에러가 발생했으므로 더이상의 진행이 무의미하고, 오히러 디버깅을 위한 오류 정보를 전달하는 것이 더 중요하기 때문입니다. 임베디드 시스템이 동작 도중에 먹통이 되면서 LED만 깜박이고 있다면 ASSERT() 런타임 오류가 발생한 것이므로 이때 UART 인터페이스를 연결해서 어느 파일의 어느 곳에서 에러가 발생했는지 확인할 수 있습니다.

앞에서 디버깅을 위한 코드가 원래의 로직 수행을 방해하거나 시간 지연을 일으켜서는 곤란하다고 했었습니다. 따라서 디버깅이 끝나고 코드 릴리즈를 할 때에는 디버깅을 위한 코드들을 다 제거하고 나가야 합니다. 판매용 제품이 어떤 이유로 assert 메시지를 출력하고 있어서는 안됩니다. 판매용 제품은 어떠한 경우에도 정상 동작해야 하니깐요.

이처럼 ASSERT 기능을 일괄적으로 꺼버리기 위해서는 ASSERT 정의를 다르게 내리면 됩니다. 앞에서 ASSERT()를 메크로 함수로 정의했었는데요, 아래와 같이 간단하게 만들 수 있습니다.

#ifdef DEBUG
    #define ASSERT(x)       if(!(x)) __error__(__FILE__, __LINE__)
#else
    #define ASSERT(x)
#endif

이렇게 정의하면 DEBUG 기능이 켜져 있는 동안에는 ASSERT가 기능하고, 릴리즈 할 때에는 ASSERT() 문구 자체가 사라지게 됩니다.

StellarisWare의 driverlib/debug.h 파일에 관련 정의가 있으므로 참고하시기 바랍니다.

Assert의 사용

StellarisWare에 포함된 Driver Library에도 assert가 많이 쓰이고 있습니다. 대표적으로 함수 초입에 인자로 전달된 값들이 정상 범위에 있는지 확인하는 용도로 쓰입니다. timer 예제에서 사용된 DriverLib 함수 중 TimerConfigure() 함수의 소스코드는 아래와 같습니다.

void
TimerConfigure(unsigned long ulBase, unsigned long ulConfig)
{
    //
    // Check the arguments.
    //
    ASSERT(TimerBaseValid(ulBase));
    ASSERT((ulConfig == TIMER_CFG_ONE_SHOT) ||
           (ulConfig == TIMER_CFG_ONE_SHOT_UP) ||
           (ulConfig == TIMER_CFG_PERIODIC) ||
           (ulConfig == TIMER_CFG_PERIODIC_UP) ||
           (ulConfig == TIMER_CFG_RTC) ||
           ((ulConfig & 0xff000000) == TIMER_CFG_SPLIT_PAIR));
    ASSERT(((ulConfig & 0xff000000) != TIMER_CFG_SPLIT_PAIR) ||
           ((((ulConfig & 0x000000ff) == TIMER_CFG_A_ONE_SHOT) ||
             ((ulConfig & 0x000000ff) == TIMER_CFG_A_ONE_SHOT_UP) ||
             ((ulConfig & 0x000000ff) == TIMER_CFG_A_PERIODIC) ||
             ((ulConfig & 0x000000ff) == TIMER_CFG_A_PERIODIC_UP) ||
             ((ulConfig & 0x000000ff) == TIMER_CFG_A_CAP_COUNT) ||
             ((ulConfig & 0x000000ff) == TIMER_CFG_A_CAP_TIME) ||
             ((ulConfig & 0x000000ff) == TIMER_CFG_A_PWM)) &&
            (((ulConfig & 0x0000ff00) == TIMER_CFG_B_ONE_SHOT) ||
             ((ulConfig & 0x0000ff00) == TIMER_CFG_B_ONE_SHOT_UP) ||
             ((ulConfig & 0x0000ff00) == TIMER_CFG_B_PERIODIC) ||
             ((ulConfig & 0x0000ff00) == TIMER_CFG_B_PERIODIC_UP) ||
             ((ulConfig & 0x0000ff00) == TIMER_CFG_B_CAP_COUNT) ||
             ((ulConfig & 0x0000ff00) == TIMER_CFG_B_CAP_COUNT_UP) ||
             ((ulConfig & 0x0000ff00) == TIMER_CFG_B_CAP_TIME) ||
             ((ulConfig & 0x0000ff00) == TIMER_CFG_B_CAP_TIME_UP) ||
             ((ulConfig & 0x0000ff00) == TIMER_CFG_B_PWM))));

    //
    // Disable the timers.
    //
    HWREG(ulBase + TIMER_O_CTL) &= ~(TIMER_CTL_TAEN | TIMER_CTL_TBEN);

    //
    // Set the global timer configuration.
    //
    HWREG(ulBase + TIMER_O_CFG) = ulConfig >> 24;

    //
    // Set the configuration of the A and B timers.  Note that the B timer
    // configuration is ignored by the hardware in 32-bit modes.
    //
    HWREG(ulBase + TIMER_O_TAMR) = (ulConfig & 255) | TIMER_TAMR_TAPWMIE;
    HWREG(ulBase + TIMER_O_TBMR) =
        ((ulConfig >> 8) & 255) | TIMER_TBMR_TBPWMIE;
}

보시다시피 함수 시작 직후에 ASSERT()를 세 번 호출해서 인자로 받은 값들이 정상 범위에 있는 지 확인한 이후에야 하고자 했던 기능 구현에 들어가게 됩니다.

DriverLib에서 사용하는 것과 마찬가지로 동일한 ASSERT() 함수를 우리들도 사용할 수 있습니다. 사용 방법은 각자의 상황에 맞게 다양하게 가져갈 수 있을 것입니다. 사용자 코드에서 ASSERT()를 사용하고 __error__()함수를 구현하는 예를 간단하게 볼 수 있도록 [assert 예제](예제 assert)를 제공하고 있습니다. 참고하시기 바랍니다.