반응형

제목: 잠복성 임베디드 소프트웨어 버그의 상위 10가지 원인(Top 10 causes of nasty embedded software bugs)

저자: Michael Barr

문서유형: 웹사이트 자료(http://www.embedded.com/), 2010

 

임베디드 소프트웨어에서 쉽게 재현이 어려운 버그 또는 간혹 나타났다가 다시 사라져서 소스 코드에 사는 귀신이라 불리는 버그의 가장 빈번한 원인 상위 10개를 설명한 자료




버그 1: 경합상황(Race condition)

  • 인터리빙 가능한 인스트럭션들이 프로세서에서 실행된 정확한 순서에 따라 두 개 (또는 그 이상의) 실행 쓰레드의 종합적 결과가 달라지는 상황
  • 예를 들면, 두 개 실행 쓰레드 중 하나는 정기적으로 글로벌 변수를 증가시키고 (g_counter += 1;), 다른 하나는 가끔 이 변수를 0으로 만듬(g_counter = 0;). 여기서 증가가 항상 원자 단위로(, 단일 인스트럭션 사이클에서) 실행되지 못하면 경합상황이 발생. 이 카운터 변수에 대한 두 개의 업데이트 간의 충돌이 발생하지 않을 수도 또는 아주 드물게 발생할 수도 있다.
  • 공유 데이터와 프리엠션(선점)의 랜덤 타이밍이 경합상황을 야기하는 주범이지만, 에러가 항상 발생하는 것이 아니라서 관측된 증상에서부터 근본 원인까지 경합상황을 추적하는 것이 매우 어려움. 따라서 모든 공유 오브젝트를 부지런히 보호하는 노력이 필요


예방 조치(Best practice)

  • 반드시 원자 단위로 실행되어야 하는 코드의 임계영역(critical section)을 적절한 프리엠션으로 둘러쌈으로써 경합상황을 예방 할 수 있음. ISR(인터럽트 서비스 루틴) 관련 경합상황을 예방하기 위해서는 다른 코드의 임계영역 동안에 적어도 하나의 인터럽트 신호가 반드시 비활성화되어야 함. RTOS 태스크 간의 경합에서는 각 태스크가 임계영역에 진입하기 전에 반드시 확보해야 하는 공유 오브젝트의 뮤텍스(mutex)를 생성하는 것이 바람직함
  • 모든 잠재적인 공유 오브젝트(, 글로벌 변수, 힙 오브젝트, 동일한 것을 가리키는 주변장치 레지스터와 포인터)를 명명하여 향후 코드를 읽는 모든 이들에게 리스크를 명확하게 보인다. 예를 들면, Netrino 임베디드 C 코딩 표준은 이런 목적으로 접두어 "g_" 사용을 권장함. 모든 잠재적인 공유 오브젝트의 위치를 파악하는 일은 경합상항을 위한 코드 감사(code audit)에서 첫 번째로 할 일이다.



버그 2: 비재진입 함수(Non-reentrant function)

  • 기술적으로 비재진입 함수 문제는 경합상황 문제의 특별한 경우이며, 이런 이유로 비재진입 함수에 의해 야기되는 런타임 에러를 재현하기 어려움. 또한 비재진입 함수는 다른 타입의 경합상황보다 코드 리뷰에서 찾아내기가 더 어렵다.
  • 아래는 전형적인 시나리오를 보여줌. 프리엠션 대상인 소프트웨어 엔터티가 RTOS 태스크이고, 공유 오브젝트를 직접 조작하는 대신에 간접적 함수 호출(function call)을 통함. 예를 들면, 태스크 A가 소켓층 프로토콜 함수를 호출하고, 이것이 TCP층 프로토콜 함수를 호출하고, 이는 다시 IP층 프로토콜 함수를 호출하며, 이것이 이더넷 드라이버를 호출. 시스템이 믿을 수 있게 동작하려면 이 모든 함수들이 재진입 가능해야 함

[라이브러리와 디바이스 드라이버가 비재진입 함수를 포함하고 있을 수 있음]


  • 이더넷 드라이버의 모든 함수는 동일한 글로벌 오브젝트를 이더넷 콘트롤러 칩의 레지스터 형태로 조작함. 이런 레지스터 조작 동안에 프리엠션이 허용된다면 패킷 A가 큐에 들어갔지만 아직 전송이 시작되기 전에 태스크 B가 태스크 A를 누르고 선점할 수도 있음. , 태스크 B가 소켓층 함수를 호출하고, 이어서 TCP층 함수, IP층 함수, 이더넷 드라이버가 호출되고, 패킷 B를 큐에 넣어 전송함. CPU의 콘트롤이 태스크 A로 반환되면 태스크 A가 전송을 요청하는데, 이 때 패킷 B가 재전송되거나 또는 에러가 생성됨(이더넷 콘트롤러 칩의 설계에 따라 다름). 패킷 A는 손실되고 네트워크 상으로 나가지 못한다.

  • 이 이더넷 드라이버의 함수들이 다수의 RTOS 태스크로부터 거의 동시에 불려질 수 있으려면 반드시 재진입 가능해야 함. 하지만 주의 깊게 설계하지 않는 한 드라이버와 일부 함수가 비재진입성을 가진다.


예방 조치(Best practice)

  • 본질적으로 재진입(reentrant)이 아닌 각 라이브러리 또는 드라이버 모듈 내에 뮤텍스를 생성하고 숨긴다. 모듈 내에서 사용되는 영구 데이터나 공유 레지스터 조작을 위해서는 이 뮤텍스를 얻어야 함을 사전조건으로 함. 예를 들어, 이더넷 컨트롤러 레지스터와 글로벌(또는 정적 로컬) 패킷 카운터와 관련된 경합상황을 방지하기 위해 동일한 뮤텍스가 사용될 수도 있음. 이 데이터를 액세스하는 모듈 내의 모든 함수는 공유 오브젝트의 조작 전에 반드시 뮤텍스를 획득하는 프로토콜을 준수해야 한다.
  • 비재진입 함수가 제 3자의 미들웨어/레가시 코드/디바이스 드라이버의 한 부분으로서 코드 베이스에 유입될 수도 있음을 유의해야 함. 심지어 비재진입 함수가 컴파일러에서 제공되는 표준 C/C++ 라이브러리의 일부일 수도 있음. RTOS 기반 애플리케이션을 구축하기 위해 GNU 컴파일러를 사용한다면 디폴트 보다는 재진입 가능한 "newlib" 표준 C 라이브러리를 사용해야 함에 주의



버그 3: volatile 키워드 누락(Missing volatile keyword)

  • 특정 타입의 변수에 Cvolatile 키워드를 붙이지 않은 것이 컴파일러 최적화기(optimizer)가 낮은 레벨로 설정되거나 비활성화일 때만 제대로 동작하는 시스템에서 여러 예상치 못한 동작을 야기할 수 있음
  • 한정사 volatile은 변수 선언에서 사용됨(해당 변수의 읽기 및 쓰기 최적화를 방지하기 위한 목적). 예를 들어, 아래처럼 작성된 코드에서 최적화기가 첫 라인을 제거하여 프로그램을 더 빠르고 작게 만드는 시도를 할 수 있음(이는 환자 건강에 해를 줌). 하지만 g_alarmvolatile로 선언되면 이런 최적화가 허용되지 않는다.


예방 조치(Best practice)

  • 아래를 선언하는데 volatile 키워드가 사용되어야 함
    - ISR
    과 코드의 기타 다른 부분에 의해 액세스되는 글로벌 변수
    -
    두 개 또는 그 이상의 RTOS 태스크에 의해 액세스되는 글로벌 변수
    -
    메모리 매핑된 주변장치 레지스터로의 포인터
    -
    지연 루프 카운터(Delay loop counter)
  • volatile의 사용은 주어진 변수를 위한 모든 읽기와 쓰기가 수행됨을 보장하는 외에도 추가적인 "시퀀스 포인트를 더함으로써 컴파일러를 제한한다.



버그 4: 스택 오버플로우(Stack overflow)

  • 스택 오버플로우가 주는 피해의 성격과 오동작의 타이밍은 어떤 데이터(또는 인스트럭션)이 타격을 받았고 이들이 어떻게 사용되는지에 따라 달라짐. 스택 오버플로우 발생과 그로 인한 부정적 영향이 시스템에 나타나기까지의 시간은 타격을 받은 비트가 얼마나 빨리(혹은 늦게) 사용되는지에 달려 있음
  • 여러 이유로 스택 오버플로우가 데스크탑보다 임베디드 시스템에서 훨씬 자주 문제가 됨
    -
    임베디드 시스템은 대개 작은 RAM 상에서 작동
    -
    의지할 가상 메모리가 없음(디스크가 없으므로)
    - RTOS
    태스크 기반의 펌웨어 설계가 다수의 스택을 활용하며(태스크 당 하나씩), 이들 각각이 최고 스택 깊이(worst-case stack depth)를 보장하기에 충분한 크기여야 함
    -
    이 동일한 스택을 인터럽트 핸들러가 사용하려 할 수도 있음
  • 아무리 많은 테스팅을 해도 특정 스택이 충분히 큰지를 보장할 수 없음. 스택 오버플로우가 아주 드물게 발생하므로 테스트 동안에 이 문제가 목격되지 않을 수 있다.
  • 특정 알고리즘적 제약하에서 코드 통제 흐름의 하향식 분석(top-down analysis)을 통해 스택 오버플로우가 절대 발생하지 않음을 증명할 수는 있지만, 매번 코드가 변경될 때마다 이 하향식 분석이 재수행 되어야만 함


예방 조치(Best practice)

  • 시스템 시동(startup) 시 있을 법하지 않은 메모리 패턴을 스택 전반에 페인트칠함. , hex 23 3D 3D 23을 사용하면 ASCII 메모리 덤프에서 울타리('#==#') 처럼 보임
  • 런타임에서 감독 태스크(a supervisor task)가 사전 설정된 최고 수위선(high water mark)을 나타낸 페인트가 어느 것도 변경되지 않았음을 주기적으로 체크함
  • 스택의 뭔가가 잘못된 것으로 발견된다면 구체적인 에러(, 어떤 스택이 문제인지, 얼마나 높게 넘쳤는지)를 비휘발성 메모리에 기록하고, 실제 오버플로우가 발생하기 전에 사용자를 위한 안전 조치(, 통제된 셧다운이나 리셋)를 취함. 워치독 태스크(watchdog task)에 이런 안전 기능을 추가하면 좋음



버그 5: 힙 단편화(Heap fragmentation)

  • 동적 메모리 할당은 임베디드 소프트웨어 개발자에 의해 널리 사용되지 않는데, 그 이유 중 하나가 힙의 단편화 문제임
  • 힙은 RAM의 특정 영역(사전 결정된 최대 크기를 가짐). Cmalloc() 표준 라이브러리 루틴이나 C++new 키워드를 통해 생성된 모든 데이터 구조가 힙 상에 올라감
  • 특정 시스템에서 힙이 주소 0x20200000에서부터 시작하여 10KB에 걸쳐 있을 때, 여기에 4KB의 데이터 구조 한 쌍을 할당하면 2KB의 자유 공간이 남게 됨. 더 이상 필요하지 않게 된 데이터 구조를 위한 저장소는 free()를 호출하거나 또는 delete 키워드를 사용하여 힙으로 반환될 수 있음. 이론상으로는 이 반환된 저장소가 차후 할당에서 재사용 가능해야 하지만 할당(allocations)과 삭제(deletions)의 순서가 랜덤에 가까워서 힙이 작은 파편들로 엉망이 되는 결과를 낳음
  • 단편화가 왜 문제인지는 위 예의 첫 번째 4KB 데이터 구조가 자유로워지면 무슨 일이 생기는지로 알 수 있음. 이제 힙이 4KB의 자유 공간과 또 다른 2KB의 자유 공간으로 구성되며, 이 둘은 인접하지 않아서 합쳐질 수 없음(, 힙이 단편화됨). 따라서 총 6KB의 자유 공간이 있음에도 불구하고 4KB 이상의 할당은 실패함
  • 단편화는 엔트로피와 유사함(둘 다 시간 경과에 따라 증가). 장기간 작동되는 시스템(대부분의 임베디드 시스템이 여기에 해당)에서 힙 단편화가 결국은 일부 할당 요청을 실패하게 만들 수 있다.


예방 조치(Best practice)

  • 힙 사용을 아예 피하는 것이 이 버그를 막는 확실한 방법이지만, 동적 메모리 할당이 시스템에서 필수적이거나 편리한 경우라면 단편화를 방지하도록 힙을 구조화 함
    , 아래 그림처럼 특정한 크기의 할당 요청을 가진 다수의 힙을 메모리 풀로 구현
  • 많은 실시간 운영체제가 고정 크기 메모리 풀 API를 포함하므로, 여기에 접근할 수 있다면 malloc()free() 대신에 이런 API를 사용한다. 아니면 직접 고정 크기 메모리 풀 API를 작성할 수도 있음

[고정 크기 메모리 풀은 단편화 문제를 겪지 않음]





버그 6: 메모리 누수(Memory leak)

  • 아주 작은 양이라도 메모리 누수가 있는 시스템은 결국 여유 공간을 소진하고 실패하게 됨(종종 합법적인 메모리 영역이 덮어쓰기 되고 한참 후까지 이를 깨닫지 못함). 예를 들어, malloc() 호출 실패에 의해 NULL 포인터가 반환되었지만 호출자가 이를 모른 채 물리 주소 0x00000000부터 시작하는 인터럽트 벡터 테이블(또는 뭔가 다른 중요한 코드나 데이터)을 덮어쓰기 하면 이런 일이 발생함
  • 메모리 누수는 동적 메모리 할당을 사용하는 시스템에서 주로 문제가 됨. 임베디드 시스템에서나 PC 프로그램에서나 메모리 누수는 마찬가지이지만, 장시간 운행되는 임베디드 시스템 성격과 일부 고안전 시스템(safety-critical systems)은 실패가 치명적이기 때문에 펌웨어에서 꼭 피해야 할 버그로 손꼽힘


예방 조치(Best practice)

  • 메모리 누수를 피하는 간단한 방법은 소유권 패턴(ownership pattern) 또는 힙에 할당된 각 타입의 오브젝트의 생명주기(lifetime)를 명확히 정의하는 것임. 예를 들어, 아래는 버퍼 관련 하나의 공통된 소유권 패턴을 보여줌. 버퍼가 생산자 태스크(P)에 의해 할당되고, 메시지 큐를 통해 전송되고, 후에 소비자 태스크(C)에 의해 파괴됨

  • 힙을 사용하는 실시간 시스템에서 이것과 다른 안전한 설계 패턴이 가능한 최대로 준수되어야 한다.


[명확한 오너십을 가진 설계 패턴]


버그 7: 교착상태(Deadlock)

  • 데드락은 두 개(또는 그 이상의) 태스크 간의 원형의 의존성이다. 예를 들어, 태스크 1이 이미 A를 획득하고 B를 기다리는 상태이고 태스크 2도 이미 B를 획득하고 A를 기다리는 상태라면 둘 중 어느 것도 진전되지 못함
  • 원형의 의존성(Circular dependencies)은 멀티쓰레드 시스템 아키텍쳐의 여러 레벨에서 발생할 수 있지만(, 각 태스크가 오로지 상대방만이 전송할 수 있는 이벤트를 기다림) 여기서는 뮤텍스 관련 리소스 데드락 문제만 고려함


예방 조치(Best practice)

  • 두 가지 간단한 프로그래밍 관행으로 임베디드 시스템에서 리소스 데드락을 방지할 수 있음. 첫 번째는 두 개(또는 그 이상의) 뮤텍스를 절대 동시에 획득하고자 하지 않는다. 하나의 뮤텍스를 쥐고서 또 다른 뮤텍스를 기다리고 있는 것은 교착상태의 필수 조건임
  • 두 번째는 시스템의 모든 뮤텍스에 순서를 할당하고 항상 이 순서로 다수의 뮤텍스를 획득한다. 이 방법은 리소스 데드락을 제거하지만 실행 시간을 그 대가로 치른다.



버그 8: 우선순위 역전(Priority inversion)

  • 상용 실시간 운영체제(OS)에서 흔히 볼 수 있는 우선순위 기반 프리엠션과 리소스 공유의 결합이 재현 및 디버그가 어려운 우선순위 역전을 초래할 수 있음
  • 우선순위 역전이 발생하려면 적어도 세 개 태스크가 필요: 가장 높은 우선순위와 가장 낮은 우선순위의 태스크 쌍이 리소스를 공유해야 하며(예를 들면 뮤텍스를 통해서), 세 번째 태스크는 그 우선순위가 다른 두 태스크의 사이여야 함
  • 우선순위 역전 시나리오는 아래와 같음. 먼저 낮은 우선순위 태스크가 공유 리소스를 획득하고(t1), 가장 높은 우선순위 태스크가 낮은 우선순위 태스크를 프리엠션한 후에 공유 리소스를 얻으려 시도하지만 실패함(t2). 높은 우선순위 태스크가 블록 상태이므로 CPU 콘트롤이 낮은 우선순위 태스크에게 돌아가고, 중간 우선순위 태스크가 낮은 우선순위 태스크를 프리엠션함(t3). 이 시점에 우선순위가 역전된다. , 높은 우선순위 태스크는 낮은 우선순위 태스크를 기다리는 반면 중간 우선순위 태스크는 원하는 동안 계속 CPU를 사용함(이런 중간 우선순위 태스크가 여러 개 존재할 수도 있음)

[우선순위 역전 시나리오]


  • 우선순위 역전은 가장 높은 우선순위 태스크가 실시간 데드라인을 맞추는 것을 방해할 수 있고 제품에 따라 그 결과가 사용자에게 치명적일 수 있음
  • 우선순위 역전의 어려움 중 하나는 재현이 불가능한 문제라는 점이다. 위의 세 개 단계가 순서대로 발생할 필요가 있고 더불어 높은 우선순위 태스크가 실제 데드라인을 놓칠 필요가 있음(하지만 실제 이런 일이 드물고 이벤트를 재현하기가 어려움). 아무리 많은 테스팅을 해도 이런 일이 현장에서 절대 발생하지 않는다고 보장할 수가 없음


예방 조치(Best practice)

아래와 같은 3-단계 수리(fix)로 시스템에서 우선순위 역전을 제거할 수 있음. 물론 데드라인을 놓쳐도 문제가 되는 태스크가 전혀 없는 경우라면 우선순위 역전의 가능성을 무시해도 안전함

  • 뮤텍스 API에 우선순위 역전 워크어라운드(, 우선순위 상속 프로토콜, 우선순위 상한 에뮬레이션 등)를 포함하는 RTOS를 선택한다.
  • 실시간 소프트웨어 내에 공유 리소스를 보호하기 위해서 뮤텍스 API만을 사용한다(이런 워크어라운드가 결여된 세마포어 API는 안됨).
  • 모든 데드라인이 항상 충족됨을 증명하기 위한 분석을 수행할 때 이런 워크어라운드의 추가적인 실행 시간도 고려한다.



버그 9: 부정확한 우선순위 할당(Incorrect priority assignment)

  • 실시간 시스템이 종종 적절한 계획 없이 애드혹 우선순위(ad hoc priorities)로 설계되는 경향이 있는데, 실시간 시스템 태스크와 인터럽트 서비스 루틴(ISR)의 상대적 우선순위를 제대로 할당하지 않으면 데드라인을 맞추지 못하고 문제가 생길 수 있음
  • 하지만 우선순위가 잘못 부여된 시스템도 그 문제가 잘 드러나지 않는 경우가 많음. 테스팅에서 중요한 데드라인을 놓치는 경우가 나타나지 않은 채 제대로 동작하는 것처럼 보이거나, 필드에서도 최악의 작업 부하 상황이 아직 한번도 일어나지 않았거나 또는 우연하게 충분한 CPU가 있어서 작업이 성공적으로 수행됨
  • 이런 상황은 임베디드 소프트웨어 개발자가 이 문제를 인지하고 적절한 해결 기법을 습득하기 어렵게 함. 현장에서 데드라인을 놓치는 문제(재현이 어려움)가 생겨도 사망 사고나 소송으로 강제 조사가 요구되지 않는 한 애초의 설계 팀으로 피드백이 가는 일이 거의 없음


예방 조치(Best practice)

  • 사실에 기반하여 태스크 우선순위를 할당하는 정형화된 방법을 제공하는 "Rate Monotonic 알고리즘"을 활용한다.
  • 비율 단조 분석(Rate Monotonic Analysis)은 올바르게 우선순위화된 태스크와 ISR이 극도의 업무 부하 동안에도 충분히 가용한 CPU를 얻을 수 있는지 증명할 수 있게 해줌



버그 10: 지터(Jitter)

  • 일부 실시간 시스템은 데드라인이 항상 충족되는 것뿐만 아니라 추가적인 타이밍 제약이 프로세스에서 관측될 것을 요구함(, 지터 관리)
  • 아래 그림은 지터의 예를 보여줌. 다양한 작업량(파란 상자)의 태스크가 매 10ms 데드라인 전에 반드시 완성되어야 함. 그림에서 보다시피 데드라인이 모두 충족되지만 잡(job) 실행 타이밍은 상당히 제 각각임. 이런 지터가 일부 시스템에서는 수용되지 않으므로 10ms 실행을 더 정확하게 시작하던지 끝내던지 해야 함

[10-ms 태스크의 타이밍에서 지터 예]


예방 조치(Best practice)

  • 지터의 양에 영향을 주는 가장 중요한 요인은 되풀이되는 동작을 구현하는 태스크나 ISR의 상대적 우선순위이다(우선순위가 더 높을수록 지터가 더 낮음). 따라서 인코더 펄스 카운트를 주기적으로 읽는 작업은 RTOS 태스크 보다는 timer tick ISR이어야 함
  • 아래 그림은 10ms의 반복적 샘플들의 간격이 우선순위에 의해 어떻게 영향을 받는지를 보여줌. 가장 높은 우선순위는 정확하게 10ms 간격상에서 실행되는 timer tick ISR이고, 그 아래는 높은 우선순위 태스크(TH)로 여전히 반복되는 10ms 시작 시간을 정확하게 맞출 수 있으며, 가장 아래는 낮은 우선순위 태스크(TL)로 더 높은 우선순위 레벨에서 무슨 일이 일어나는지에 의해 그 타이밍이 크게 영향을 받는다. 낮은 우선순위 태스크의 간격은 대략 10ms +/- 5ms

[지터가 상대적인 우선순위에 의해 영향을 받음]



최고의 예방 조치는 코드 리뷰

  • 위에 열거된 버그들 중 어떤 것도 애초에 시스템에 존재하지 않음을 확실하게 하면 디버깅으로 골머리를 앓는 일을 피할 수 있음(모두가 재현이 어려운 버그이므로 시스템에 이런 버그가 아예 들어오지 못하게 하는데 집중해야 한다).
  • 그러기 위해 가장 좋은 방법은 회사 내부 또는 외부의 누군가가 꼼꼼한 코드 리뷰를 수행하도록 하는 것이다. 또한 위에서 기술된 각 버그의 예방 조치(best practice) 사용을 강제화하는 코딩 표준 규칙을 적용하는 것도 도움이 된다.
  • 위에 기술된 것 같은 버그가 기존 코드에 존재하는 것으로 의심되는 경우, 관측된 이상 징후로부터 근본 원인으로 역추적 해 나가는 것보다 코드 리뷰를 수행하는 것이 의심을 더 빠르게 증명할 수도 있다.


반응형

+ Recent posts