C 언어를 통한 안전한 개발 C 언어 자체에 내제된 일부 위험을 해결하는 방법

 

이번 시간에는 안전 핵심 기능을 보유한 시스템을 개발하는 데에 있어 C를 사용할 시 발생하는 문제점을 살펴 보도록 하겠습니다.

C 언어는 반응 양식이 확실하게 정의되어 있지 않은 부분이 많습니다. 그 외에도 하드웨어 의존성 문제를 비롯해 여러가지 문제가 산재해 있습니다. 그렇지만 안전 핵심 개발 분야에서 가장 널리 이용되며, 가장 인기 있는 언어 역시 C 입니다. 하지만 미리 고민해 계획만 잘 세워 둔다면, 이러한 문제를 전화 위복의 계기로 만들 수도 있습니다. 

내 발등은 찍지 말자 

지난 1991년, Developer’s Insight라는 잡지에서 “내 발등을 찍는 방법”(How to Shoot Yourself in the Foot)이라는 제목의 기사를 실은 적이 있습니다. 기사에 따르면, “오늘날, 현대적인 프로그래밍 언어가 널리 확산되고 있다(이들 언어는 저마나 서로 수 많은 기능을 베껴 달고 있다). 그렇다 보니, 자신이 사용하는 언어가 어떤 것인지 기억하기 힘든 경우마저 생겨난다. 이번 기사는 공익적 차원에서, 바로 이러한 딜레마에 직면한 개발자들을 돕기 위해 기획되었다.” 

당시 기사에서는 C 언어를 필두로 하여 여러 언어의 목록을 제시하고 있는데, C 언어에 관련해서는 다음과 같이 적고 있습니다. 

  • C- 내 발등을 찍어보자 

일견 너무 잔인한 듯 들리겠지만, 그 타당성을 부인하기는 어려운 말이기도 합니다.  그렇지만, 이보다 문제가 적은 다른 언어의 경우, 타입 안전성 문제라던가, 미정의 반응 등에 있어서는 상대적으로 안전하겠지만, 좀 더 하드웨어에 근접한 수준의 기능을 갖추지 못하고 있는 경우가 많습니다. 우리가 계속해서 C 언어를 사용하고자 한다면, 중용의 길을 찾아야 합니다. 즉, C 언어가 지닌 명백한 문제점, 그리고 상대적으로 숨겨져 있는 문제점에 빠지지 않도록 하면서, 그 기능을 최대한 활용하는 것입니다. 안전 핵심 기능 개발에서, C 언어를 두 가지 시각에서 조명해 볼 수가 있습니다. 

  • 프로그래밍 언어와 관련하여 안전 핵심 프로젝트에 적용되는 외부적 요구 조건에는 어떤 것이 있는가? 
  • 또한 레거시 코드를 사용해야 할 때, C에서 보다 많이 문제가 되고 있는 부분을 해소하기 위해 할 수 있는 조치는 무엇인가? 

모범 답안 

만일 개발하시는 제품이 자동차, 산업 제어, 의료 기기, 철도 분야에서 이용되는 경우, 정식의 기능 안전 요구 사항이 적용되고 있을 가능성이 높습니다. 이와 같은 요구 사항은 결국 특정한 제품의 허용 가능한 실패율에 대한 매우 구체적인 요구사항이나, 제품 내 특정 기능에서 허용되는 최대 실패율로 귀결됩니다. 뿐만 아니라  IEC61508(전기 및 전자 프로그램식 기기), ISO26262(자동차), 또는 EN 50126x(철도)와 같이 특정한 기능 안전 표준에 따라 개발되고 있는 제품에 대한 일반적인 요구사항도 해당될 수 있습니다. 최소한 10년 전부터 안전 기능 안전 기능을 구현하는 데에 있어 순수 메카닉 또는 PLC 제어 자동화를 지향하고, 마이크로컨트롤러의 세계로 넘어오는 명확한 트렌드가 나타나기 시작했습니다. 따라서, 관련되는 요구 사항들이 소프트웨어 분야에서도 적용되기 시작했습니다. 

여러가지 표준에서 제시하고 있는 소프트웨어 요구 사항은 근본적으로 비슷한 의도에서 출발합니다. IEC61508을 예로 들어 보겠습니다. 이 표준은 분야별로 적용되고 있는 여러가지 표준의 근간을 제공한 것으로, 예를 들어 IEC61508에서 유효한 요구 사항들은 대부분 ISO26262에도 통용됩니다. 

이러한 표준은 요구 사항 취합에서부터 고객 현장에 제품을 설치 및 철거하는 모든 과정에 걸쳐 여러분이 업무를 수행하고, 이를 문서화 하는 과정에 상당한 영향을 미치게 됩니다.  여러분이 주어진 표준 상에서 소기의 목적을 달성하는 데에 성공하였는지를 결정하는 것은 단지 여러분과 여러분의 프로젝트 관련자에 그치지 않습니다. 공인 외부 기관에서 나온 외부 평가자, 또는 여러분이 속한 조직 내에서 이러한 역할을 수행하는 사람에게도 확신을 주어야 하는 것입니다. 

이러한 표준의 대부분은 안전 무결성 수준 개념을 수정하여 사용하고 있습니다. 그러므로, 제품의 분류에 따라 적절한 표준을 어떻게 여러분이 적용할 것인지에 대해 어느 정도의 변동이 생기게 되는 것입니다.  

표준은 적용되는가? 

여기서 한 가지 흥미로운 질문을 던져 보겠습니다. 과연 이 모든 것이 어떤 프로그래밍 언어를 선택할 지와 무슨 상관이 있는 것일까요? 사실은 상당한 관련이 있습니다. 아래의 표를 보시면 어플리케이션이나 안전 기능에서 요구되는 안전 무결성 수준에 따라 적절한 프로그래밍 언어를 선택할 수가 있습니다. 

HR은 Highly recommended, 즉 적극 추천의 줄임말로, 실무에서 이것은 해당 권고 사항을 따르는 것이 좋다는 매우 강력한 신호입니다. 만일 이를 따르지 않는다고 할 경우, 그러한 결정을 100% 뒷받침 할 수 있는 강력한 근거가 필요합니다. 

Table_from_IEC61508

표 1: IEC61508 제3부 부록 A 별표 

뭐라고요? 

표에서 보시듯, 적절한 프로그래밍 언어를 사용하는 것이 적극 추천(highly recommended) 됩니다. 그렇지만 추천이라는 의미에서 생각해 보면 그다지 말이 되지 않는 것처럼 보이기도 합니다. 그렇지요? 그러나 표에서 언급하고 있는 C 부록을 보시면, 적절한 프로그래밍 언어를 다음과 같이 정의하고 있습니다. 

완전하고 명확하게 정의되어 있는 언어. 프로세서/플랫폼 머신이 아닌 사용자 또는 문제 지향적인 언어일 것. 특정 목적 전용의 언어보다는 널리 사용되고 있는 언어, 또는 그 서브셋 사용을 지향. 소규모로 관리가 용이한 모듈형의 구조를 지니는 언어로서, 특정 소프트웨어 모듈 내의 데이터에 대한 접근을 제한하고, 가변 하위 범위의 정의가 가능해야 하며, 그 외 오류의 발생을 억제하는 구조를 채용한 언어일 것. 

그러면 상기 정의 부분의 여러 내용을 살펴 보면서 C가 어떻게 여기에 부합하는지 살펴 보도록 하겠습니다. 

  • 완전하고 명확하게 정의되어 있는 언어: 음… 어떻게 세는가에 따라 다르지만, C99의 경우 정의되지 않은 반응이 최소 190개 이상 존재합니다. 
  • 프로세서/플랫폼 머신이 아닌 사용자 또는 문제 지향적인 언어일 것: 흠, C는 원래 PDP-11 아키텍쳐를 위한 시스템 개발 언어였다는 점, 그리고 특정 대상을 위한 C 구현은 다른 대상을 위해 구현된 C와는 다를 수 밖에 없는 점, 그리고 같은 대상이라고 할 지라도 구현 방식에 따라서 달라질 수가 있는 점을 고려해 보면, 수가 실제로 이러한 정의에 부합하는 언어라고 주장하기는 어렵습니다. 
  • 특정 목적 전용의 언어보다는 널리 사용되고 있는 언어, 또는 그 서브셋 사용을 지향: 이제야 C에 해당되는 이야기가 나왔습니다! C, 또는 C++과 같은 파생 언어를 사용하고 있는 개발자들은 정말 많지요. 
  • 소규모로 관리가 용이한 모듈형의 구조를 지니는 언어로서, 특정 소프트웨어 모듈 내의 데이터에 대한 접근을 제한하고, 가변 하위 범위의 정의가 가능해야 하며, 그 외 오류의 발생을 억제하는 구조를 채용한 언어일 것: 비록 C는 이러한 개념을 지지하는 추상화의 생성을 명시적으로 금지하고 있지는 않지만, 언어 자체로서는 이를 전혀 지원하지 않고 있다고 해도 무방할 것입니다. 사실, 실상은 오히려 그 정반대라고도 할 수 있습니다. 

C는 표준에서 정하고 있는 내용과는 꽤 거리가 있는 언어였습니다. 이와 관련해서 우리가 할 수 있는 일에는 무엇이 있을까요? 

사실 그 답은 매우 간단합니다. 적어도 표준 만을 읽어 보면 말입니다. 그렇지만 더 읽어 보면, 개별 언어에 대해 판정을 내리고 있는 표를 찾아 볼 수 있는데, 거기서는 C에 대해 다음과 같이 말하고 있습니다:

Table_from_IEC61508

표 2: IEC61508 제7부 부록 C 별표 

비록 C는 권장 언어는 아니지만 적절한 서브셋을 갖춘 C는 적절한 코딩 표준 및 정적 분석툴과 함께 사용되는 경우 적극 추천의 대상이 될 수도 있습니다.  하지만 여기서 말하는 서브셋과 코딩 표준은 과연 어떤 의미일까요? 

표준 

이러한 맥락에서 언어 서브셋의 목적은 프로그래밍 에러의 가능성을 줄이고, 그럼에도 불구하고 코드 베이스 내에 숨어 있는 에러를 좀 더 찾기 쉽게 하는 것입니다. C에 있어 이것은 정의되지 않았거나, 구현 시 정의되는 반응의 사용을 최대한 줄이는 것을 의미합니다. 이러한 언어 서브셋은 여러가지 종류가 있지만, 가장 널리 알려져 있는 것이 바로 MISRA-C입니다. MISRA-C는 영국 Motor Software Reliability Association에서 시작한 사업으로, 오직 자동차용 소프트웨어 개발을 목적으로 만든 것이었습니다. MISRA-C 규칙은 여러 해가 지나면서 전 세계로 확산되었고, 다른 산업 부문에서도 사용되기 시작했습니다. 그리고 그 규칙은 현재 임베디드 업계에서 가장 널리 사용하고 있는 C 서브셋이 되었습니다. 

IEC61508에서도 코딩 표준과 관련해 상당히 많은 내용을 담고 있습니다. 다음은 MISRA-C 규칙에 더해 함께 검토해야 하는 주제의 예시입니다. 

  • 글로벌 변수 등 공유 자원에 대한 접근을 어떻게 방어할 것인가? 
  • 객체 할당을 위한 스택 및 힙 메모리의 사용. 
  • 순환(recursion)을 허용할 것인가 말 것인가? 
  • 함수의 cyclomatic complexity 한도 등 복잡성 한도. 
  • 특정 맥락에서 적용되지 않는 규칙(예: MISRA-C) 적용을 어떻게 면제할 것인가? 
  • 내재 함수나 언어 확장팩과 같은 컴파일러 고유 기능을 어떻게 사용할 것인가? 
  • 에러 식별을 위해 범위 점검, assertion, pre/post-conditions, 기타 유사한 구조체를 어떻게 활용할 것인가? 
  • 인터페이스의 체계화 및 모듈간 접근성 
  • 문서화 요구 사항 

결국, 코딩 표준은 코드의 품질 및 무결성에 영향을 미치는 요소로서, 언어나 서브셋에서 명시적으로 다루지 않는 부분에 어떻게 대등할 것인지에 대한 내용인 것입니다.  

성공의 비결은 연습 

여기서는 이전 항에서 제기했던 일부 문제들을 본격적으로 다루어 보고, 프로젝트 시 여기에 대해 어떻게 접근하면 좋은지를 생각해 보겠습니다. 

MISRA-C 

만일 MISRA-C의 사용을 생각하고 있는 경우, 이러한 접근법은 완전히 백지상태에서 프로젝트를 시작하는지, 아니면 레거시 코드를 사용하는지에 따라 조금씩 달라질 수 있습니다. 완전히 새롭게 개발되는 코드의 경우, 다음의 내용을 염두에 두시기 바랍니다. 

  • 모든 규칙을 맹목적으로 따르려 하지 마십시오. 코드를 작성하다 보면 규칙 하나둘쯤은 지킬 수가 없는 부분도 생겨나기 마련입니다. 특히 하드웨어와 인터페이스를 하는 코드인 경우 더더욱 그러합니다. 대신, 합리적인 정보를 바탕으로 규칙을 무시할 지의 여부를 결정하고 해당 결정 내용은 문서로 기록해 두십시오. 모든 규칙을 준수하고자 하는 경우, 또는 프로젝트 내에서 무시한 규칙이 있는 경우 프로젝트 관련 당사자 및 외부 평가 담당자와 사전에 협의를 진행해 두십시오. 
  • 항상 기본형과 관련되는 규칙은 준수하도록 노력하고, 이러한 타입의 산술 연산이나 변환(conversion)과 같은 규칙도 따르는 것이 좋습니다! 이 부분은 여러가지 함정이 도사리고 있는 부분이며, 특정 플랫폼에서는 코드가 완벽하게 작동하는 것 같다가도 다른 플랫폼에서는 문제를 일으키기도 합니다. 
  • 만일 유사한 내용의 코드에서 동일한 규칙에 대해 여러차례 반복적으로 이를 무시하고 있는 경우, 스스로 이를 하나의 경고 신호로 받아 들여야 합니다.  
  • 여러분이 규칙의 내용을 제대로 이해하고 있는 것일까요? 
  • 해당 코드 패턴이 정말로 필요로 한 것일까요? 만일 그렇다면, 문제가 되는 코드를 따로 분리(factoring out)해 하나 혹은 여러 함수의 형태로 격리하는 방법을 생각해 보십시오. 
  • 개발 과정 중 규칙 준수 여부를 상호작용식으로 확인해 줄 수 있는 정적 체커(checker)를 사용해 보십시오. 

 만일 일련의 MISRA-C 규칙을 레거시 코드에 적용하는 경우, 다음과 같은 조치가 권장됩니다. 

  • 한 번에 하나의 규칙을 처리하십시오. 
  • 먼저 쉬운 규칙부터 선택합니다. 예를 들어, 조건문 구조체의 본문을 구성하는 하나의 선언문을 {}으로 둘러싸야 한다는 규칙부터 시작하는 것이 좋습니다. (MISRA-C:2004 rule 14.8) 
  • 단순한 타입인 short, int, chart 등을 사용해서는 안 된다는 규칙을 자세히 읽어 보십시오. 그리고 uint16_t와 같이 명시적으로 용량이 지정되어 있는 타입을 사용하는 경우, 한 번에 하나씩 모듈을 변경하는 방식을 생각해 보는 것이 좋습니다. 
  • 일부 쉬운 모듈을 대상으로 먼저 연습을 해 본 다음, 버그가 많거나 관리가 어려운 것으로 생각되는 모듈로 진행합니다. 

동기화가 필수! 

이제는 C 언어에서 많이들 오해하시는 부분에 대해 알아보겠습니다. 바로 ‘휘발성’(volatile) 키워드가 그것이지요. 이러한 키워드를 잘못 사용하는 것은 임베디드 시스템이 장애를 일으켜 붕괴되는 가장 주된 원인 중의 하나로 널리 알려져 있습니다. 

특정 객체를 volatile로 선언하는 가장 주된 이유는 컴파일러에게 해당 객체의 값은 컴파일러가 알 수 없는 방식으로 변경될 수 있으며, 따라서 해당 객체에 대한 모든 접근을 보존해야 한다고 알려주기 위한 것입니다. volatile 객체를 필요로 하게 되는 상황은 크게 3 가지로 나누어 볼 수 있습니다. 

  • 접근 공유. 객체가 멀티 테스팅 환경 내에서 여러 개의 과업 사이에 공유되어 있는 상태이거나, 단일 실행 스레드로부터 접근이 이루어지는 경우, 그리고 하나 이상의 간섭 서비스 루틴에 의해 접근이 이루어지는 경우를 말합니다. 
  • 트리거 액세스. 메모리 매핑이 되어 있는 하드웨어로서, 접근이 이루어진다는 사실 자체가 기기에 영향을 미치는 경우. 
  • 수정 접근. 객체의 내용물이 컴파일러에 알려져 있지 않은 방식으로 변경될 수 있는 경우. 

그렇다면, 객체 선언 시 volatile 키워드를 사용하게 되면 컴파일러로부터 무엇을 보장받을 수 있는 것일까요? 기본적으로 다음과 같은 내용이 보장됩니다. 모든 읽기 및 쓰기 액세스를 보존. 이게 전부입니다! 

대상 아키텍처에 따라서는 추상 머신(해당 시 atomic)에서 부여하는 순서에 따라 실행된 모든 접근에 대한 내용을 받아 볼 수도 있습니다. 

Compilation_of_volatile

그림 1: ARM/THUMB 대상을 위한 volatile의 컴필레이션 

volatile 객체는 서로 다른 실행 맥락에서 접근이 가능합니다. 이러한 점에 비추어 볼 때, 그림 1 상의 코드는 스레드와 간섭으로부터 안전한 것일까요?  vol의 값을 메모리로부터 불러오는 것, 그리고 이를 메모리에 저장하는 것은 모두 atomic에 해당합니다. 왜냐하면, 이것은 32 비트 불러오기/저장 아키텍쳐를 위한 것이기 때문입니다. 그렇지만 소스 선언문은 atomic이 아닙니다! 하지만 아직도 여기는 vol++ 선언문을 구성하는 3가지 명령어 사이 어딘가에서 맥락 스위치, 또는 간섭의 영향을 받을 수가 있습니다. 

대처 방법 

  • 절대로 volatile가 atomic를 의미한다고 짐작해서는 안 됩니다. 단, 특정한 메모리에 접근할 때에는 예외입니다. 
  • 코드가 volatile 객체를 대상으로 atomic 읽기나 쓰기를 수행하는 것 이상의 작업을 하는 코드는 반드시 serialization primitives(mutex 등)를 통해 보호를 받을 수 있도록 하십시오. 또는 간섭 기능을 비활성화 시키는 방법도 사용할 수 있습니다. 이는 해당 객체가 서로 다른 실행 맥락으로부터 접근을 받을 때에 사용이 가능한 방법입니다. 
  • Volatile 키워드의 적절한 사용, 그리고 serialization primitives에 대한 내용을 사용자의 코딩 표준 내에 포함시키도록 하십시오. 
  • 기존의 코드 내에서 파일 범위 정적 객체 등 모든 글로벌 객체를 검토하는 방법을 고려해 보십시오.  

스택 업? 

스택이 원하는 대로 작거나 크게 나왔습니까? 이것은 모든 개발자를 속 썩이는 영원한 난제라고 할 수 있습니다. 만일 스택이 불필요하게 큰 사이즈라고 한다면, 보드 상의 RAM을 그 만큼 더 잡아먹어야 한다는 것을 의미합니다. 이것은 불필요한 비용을 유발합니다. 또 스택이 지나치게 작은 경우, 시스템 전체가 먹통이 되어버리는 사태도 발생할 수가 있습니다. 보안 핵심 요구 사항에 따라 개발되는 제품에 있어, 이것은 적어도 매우 좋지 않은 상태에 해당한다고 할 수 있습니다. 적절한 스택의 크기를 결정할 때에 고려해 봐야 하는 조치로는 다음을 들 수 있습니다. 

  • 해당 시, 디버거 내의 런타임 스택 체크 기능을 사용합니다. 
  • 스택 상단 및/또는 하단의 메모리를 매직 패턴(magic pattern)으로 채웁니다. 이러한 매직 패턴은 런타임에서 별도의 체크 루틴을 통해 정기적으로 검사할 수 있습니다. 가전 기기에 대한 IEC 60730 표준의 요구 사항에 따라, 사용자의 MCU 공급자는 실제로 이미 이러한 기능을 갖추고 있을 수도 있습니다. 또한 특수 목적 라이브러리의 경우 다른 MCU 자가 진단 기능을 보유하고 있습니다. 
  • 최악의 상황에 해당하는 스택 뎁스(stack depth)를 결정하기 위해 콜 트리 분석(call tree analysis)를 실시합니다. 이때, 간섭 핸들러(interrupt handlers)에서 사용하고 있는 스택도 포함 시키십시오. 만일 수동으로 코드 검토 및 링커 맵 파일을 검사하는 경우, 최적화에 따라 스택의 사용에 반드시 영향이 발생하니 주의하십시오. 또한, 지원 툴을 구매하거나 직접 개발할 수도 있고, 콜 트리 분석 및 스택 뎁스 분석 지원 기능을 보유한 빌드 툴체인을 사용하는 것도 방법입니다. 

가져가세요!  

위의 내용을 몇 가지 중요 포인트 위주로 요약하자면 다음과 같습니다. 

  • 시작 전, 먼저 소프트웨어 개발 요구 조건을 확실히 파악해 두기 바랍니다. 
  • MISRA-C를 코딩 표준의 기본으로 사용하십시오. 
  • 현재 자신이 volatile를 어떻게 쓰고 있는지를 잘 살펴 보시기 바랍니다 
  • 스택 배포에 대한 테스트 및 분석 전략을 구축하십시오.