본문 바로가기

Hacked Brain/embeddedland.net

일반적 임베디드 프로그램 통한 실시간성 구현의 한계

일반적 임베디드 프로그램 통한 실시간성 구현의 한계 | 임베디드
전체공개 2005.02.10 12:07

최근 임베디드 시스템의 중요성에 대한 인식이 점차 확대되면서 핵심 소프트웨어인 실시간 운영체제(RTOS : Real Time Operating Systems) 역시 많은 관심을 끌고 있다. 그러나 실제 왜 RTOS가 필요한지, OS가 없는 환경에서의 한계점은 무엇인지, RTOS를 사용해서 기존의 문제를 어떻게 해결할 수 있는지에 대한 정확한 개념을 숙지한 프로그래머는 많지 않은 듯하다. 이에 여기에서는 2회에 걸쳐 기존 임베디드 시스템 프로그래밍 방식의 한계점에 대해 알아보고, RTOS의 한 종류인 MicroC/OS-II를 사용해서 그 문제를 해결하는 방법을 제시해 보고자 한다.

<성 원 호/ (주)디오이즈 부장(whsung@dioiz.com)>

MicroC/OS-II는 Jean J. La brosse가 1998년에 개발, 발표한 RT OS다. 이 운영체제의 기원은 1992년에 발표한 MicroC/OS로 당시 버전은 아주 초보적인 수준이었다. MicroC/OS-II는 여러 가지 커널 서비스와 기능을 지원하면서 1998년에 발표된 이후 많은 제품에 적용돼 그 안정성을 인정받은 OS로 32bit 시스템은 물론 16bit와 8bit 시스템에서도 동작할 수 있다. 반면에 MicroC/OS-II는 개발환경과 OS 주변 소프트웨어인 파일 시스템, 그래픽 인터페이스, 네트워크 스택 등에 대한 지원이 미약하다는 단점이 있다.

그러나 MicroC/OS-II가 로우-엔드 시스템에 목적을 둔 OS라는 관점에서 생각하면 이것을 큰 단점으로 생각할 수는 없다. MicroC/OS-II는 ROM화가 가능하고, 용도에 따라 커널 크기를 다양하게 조절할 수 있으며, 리얼타임을 지원하는 선점형 멀티태스킹 커널이다. 또, 일관성 있게 프로그램한 소스 코드가 공개돼 있으므로 커널 내부와 동작원리를 쉽고 확실히 이해할 수 있다.

일단 MicroC/OS-II를 이용해서 리얼타임 커널의 구조를 이해하고 나면 다른 상용 OS를 사용해서 응용 프로그램을 작성할 때도 많은 도움이 된다.

RTOS의 동작원리를 모르는 상태에서도 멀티태스킹 응용 프로그램이나 RTOS용 디바이스 드라이버를 만들 수는 있다. 그러나 어느 정도의 수준에 도달하려면 수많은 시행착오를 거쳐야 한다. 일단 동작원리를 이해하고 나면 많은 시행착오를 피할 수 있다. RTOS의 동작을 이해하고 나면 시스템을 운영하기 위해 태스크를 몇 개로 나누어야 할 지, 디바이스 드라이버의 API는 어떻게 정해야 할지 등이 머리 속에 그려진다. 여기에서는 지면관계상 동작원리를 다루지는 않는다. 동작원리를 알고 싶다면 참고서적 MicroC/OS-II, The Real Time Kernel을 참고하기 바란다.

== RTOS 사용의 여러 가지 효용성

만약 RTOS를 사용하지 않고 임베디드 시스템 프로그램을 개발 중인데, 소프트웨어가 점점 복잡해지고 시스템의 응답성 때문에 소프트웨어 확장이 어려운 단계라면 RTOS 사용을 검토해 볼 것을 권한다. 멀티태스킹 환경에서 응용 프로그램을 작성하면 소프트웨어 모듈화와 유지 관리가 상당히 편리하다. 또, RTOS를 사용하면 응용 프로그래밍 테크닉을 몇 단계 높여준다.

실제로 RTOS를 사용하는 이유는 여러 가지가 있다. 첫째, 임의의 시각에 외부로부터의 입력이 있을 때, 시스템이 정해진 시간 안에 그 입력에 응답해야 하는 실시간성이다. 이것은 RTOS가 제공해야 하는 가장 중요한 기능이다. 둘째, 응용 프로그램의 주요 기능을 태스크 별로 모듈화해서 응용 프로그램의 개발과 유지보수를 쉽게 해준다는 점이다. 그러나 요즘은 이런 주목적 외에 상용 RTOS가 기본적으로 제공하는 소프트웨어 모듈을 사용하고자 하는 것이 RTOS를 사용하는 이유 중 큰 비중을 차지한다. 이들 소프트웨어로는 그래픽 인터페이스, 파일 시스템, 네트워크 스택, 웹 브라우저 등을 들 수 있다.

인터넷의 발전과 확산으로 인해 데스크톱 환경이 아닌 모바일 환경에서 인터넷을 이용하고자 하는 요구는 계속 증가하고 있으며, 이에 따라 임베디드 시스템에서도 이들 소프트웨어 모듈이 필요해진 것이다. 그러나 이런 소프트웨어는 엄청나게 복잡한 구조를 가지고 있으므로 개발에 많은 시간과 비용이 들어간다. 따라서 로열티 부담에도 불구하고 상용 RTOS가 제공하는 소프트웨어 모듈을 사용하는 것이 경제적, 시간적 관점에서 더 경쟁력이 있다는 것이 일반적인 견해다. 이런 소프트웨어를 개발하는 동안 경쟁사에서 먼저 제품을 출시한다면 그 개발이 무슨 소용이 있겠는가. 그 외에 염두에 둘 점은 제품을 출시한 뒤 바로 다음 제품을 준비해야 한다는 것이다. 그만큼 소비자의 요구가 다양해지고 타임 투 마켓이 제품원가 만큼 중요한 시대가 됐다. 요즘은 로열티 부담이 없는 임베디드 리눅스를 사용하는 예도 많이 볼 수 있다. 그러나 리눅스를 사용해서 처음 제품을 개발하는데 걸리는 시간 또한 무시할 만한 것은 아니다.

결론적으로 가장 중요한 실시간성보다는 이런 소프트웨어 모듈을 별다른 수정 없이 사용할 수 있다는 장점 때문에 RTOS를 사용하는 예도 많다는 것이다.

== 전경/배경 시스템의 개요

RTOS에서 가장 중요한 특징은 안정성과 실시간성이다. 여기에서는 안정성에 대해서는 논의하지 않겠다. 객관적으로 안정성을 증명하는 것이 간단한 일은 아니기 때문이다. 사용자가 실제 여러 환경에서 OS를 사용해보고 판단을 내리는 게 가장 적절하다고 할 수 있다. 그렇다면 ‘RTOS를 사용하지 않고 실시간성을 보장받을 수는 없는 것인가’라는 의문이 생길 것이다. 이 문제를 구체적으로 살펴보도록 하자.

간단한 임베디드 시스템 소프트웨어는 전경/배경 프로세스(Foregro und/Background Process)로 구성하는 것이 일반적이다. 전경/배경이라는 용어가 생소하게 들릴 수도 있지만 마이크로프로세서를 사용해서 프로그램을 작성해본 경험이 있다면 이미 이 용어의 의미를 알고 있는 것이라 할 수 있다. 여기서 전경 프로세스는 인터럽트를 처리하는 인터럽트 서비스 루틴(ISR : Interrupt Service Routine)을 의미한다. 반면 배경 프로세스는 ISR을 실행하지 않는 평상시 즉, 순차적인 무한 루프를 반복하는 메인 프로그램을 말한다. 이런 방식으로 프로그램한 임베디드 시스템을 전경/배경 시스템이라 한다. 이때, ISR은 인터럽트 레벨에서 실행되고, 메인 루틴은 태스크 레벨에서 실행된다고 한다. 다음과 같은 간단한 전경/배경 시스템 프로그램을 살펴보자.

위의 의사코드(Pseudo Code)는 전형적인 예일 뿐, 모든 전경/배경 프로세스를 이런 식으로 처리하는 것은 아니다. 그러나 이런 코드를 기본으로 해서 예외적인 루틴을 추가하는 식으로 프로그램하는 것이 일반적이다. 이 코드에서 #pragma 키워드로 선언한 함수 4개(ExtInt())는 ISR(인터럽트 서비스 루틴)을 의미한다. 이것은 C 소스 파일에서 ISR을 선언하는 하나의 예를 보여주고 있다. 최근 임베디드 프로세서용 컴파일러의 대부분은 이와 같이 C 파일 내에서 ISR을 정의할 수 있다. 다른 방법으로 ISR을 정의할 수도 있는데, 어셈블리 코드에서 직접 ISR을 정의하는 것이다. 어디에 ISR을 정의해도 상관은 없다. 어셈블리 코드에서 ISR을 정의하는 경우는 레지스터 저장과 복구를 프로그래머가 해줘야 한다. 반면, C에서 ISR을 정의하면 레지스터 저장과 복구는 컴파일러가 자동으로 해준다.

이제 이 시스템이 어떻게 동작할지 대충 짐작이 갈 것이다. 이 시스템은 평상시 인터럽트가 발생한 것을 알려주는 프래그를 모니터링하면서 무한 루프를 반복한다. 그리고 루프를 한 번 돌 때마다 기본 배경처리 함수를 호출한다. 이렇게 무한 루프를 반복하는 도중 인터럽트가 발생하면, ISR에서 프래그를 세트하므로 배경 프로세스로 돌아온 뒤 해당 처리 함수를 호출한다. 여기서 기본 배경처리 함수인 Default_background_process()는 설명을 쉽게 하기 위해 없는 것으로 간주하겠다.

인터럽트가 발생하는 원인은 다음과 같이 여러 가지가 있다.

→ 시리얼 포트로 데이터를 수신한 경우

→ 시리얼 출력 버퍼에 써넣은 데이터가 실제로 전송 완료된 경우

→ 외부 디지털 입력 신호의 상태가 변한 경우

→ AD 컨버터가 AD 변환을 완료한 경우

→ 이더넷 칩에 프레임이 수신된 경우

→ 타이머의 시간이 경과한 경우

→ 기타 여러 가지

이와 같은 여러 가지 인터럽트 원인 중 데이터 수신과 관련된 인터럽트 처리 루틴은 하드웨어로 수신된 데이터를 버퍼(메모리)에 저장하는 일만 한 뒤 나머지 처리는 배경 프로세스로 넘겨주는 것이 일반적이다. 이 경우, 배경 프로세스에서는 버퍼에 있는 데이터를 읽어서 분석하고, 그에 따른 처리를 수행한다. 이때, 인터럽트와 배경 프로세스는 전역변수를 이용해서 일종의 통신을 한다. 그러나 그 외의 인터럽트라도 배경 프로세스와 어떻게든 통신할 방법이 필요하다. 다른 말로 인터럽트 처리 루틴에서 모든 일을 독립적으로 처리하는 경우는 거의 없다는 것이다. 그렇다면 언제 배경 프로세스와 통신을 할 것인가→ 여기서 태스크 레벨의 응답성에 대한 문제가 제시된다. 이 문제는 뒤에서 자세히 설명한다.

이 시스템은 외부 인터럽트를 4개 사용한다고 가정한다. 그리고 외부 인터럽트가 발생하지 않는 한 시스템은 아무 일도 하지 않는다(앞에서 기본 배경처리 함수는 생략한다고 했다). 즉, 메인 프로그램은 해당 인터럽트가 발생했는지 알려주는 프래그를 검사하면서 무한 루프를 반복하다가 해당 프래그가 세트될 경우만 나머지 처리 함수를 호출하므로 평상시는 아무 일도 하지 않는다.

== 일반적 전경/배경 시스템 프로그래밍 방법

전경 프로세스(ISR)와 배경 프로세스(메인 루프)의 통신은 일반적으로 간단한 프래그 변수로 처리한다. 위의 코드에서 메인 루프는 지속적으로 각 프래그(intFlag)를 감시한다. 프래그 변수의 값은 일반적으로 0 또는 1이지만 여기서는 이벤트 발생 회수를 누적할 수 있도록 카운터로 사용한다. 프래그에 변화가 생기면 구체적인 내용을 알아내기 위해 전역변수를 액세스 해서 배경처리를 진행한다. 위의 코드에서 모든 프래그를 0으로 초기화했으므로 인터럽트가 발생하지 않는 한 메인 루프는 4개의 process() 함수 중 아무 것도 호출하지 않을 것이다. 이때, 임의의 인터럽트가 발생하면 해당 인터럽트 서비스 루틴 ExtInt()이 실행된다. ISR은 자신이 처리할 일을 수행한 뒤 인터럽트가 발생한 것을 알려주기 위해 프래그를 세트하고, 배경 프로세스(메인 루프)로 복귀한다. 배경 프로세스는 계속해서 프래그를 점검하고 있으므로 해당 인터럽트가 발생한 사실을 감지한 뒤 나머지 처리를 위해서 process()를 호출한다. 이 과정을 좀 더 구체적으로 살펴보자.

→ 임의의 시각에 배경 프로세스가 intFlag4를 점검하는 부분을 수행한다.

→ 외부 인터럽트1이 발생한다.

→ 프로세서의 실행은 ExtInt1()로 옮겨진다.

→ ExtInt1()에서 처리할 일을 한 뒤 배경 프로세스로 복귀한다. 이때, intFlag1은 0이 아닌 값을 갖고 있는 상태다.

→ 배경 프로세스에서는 intFlag4가 OFF이므로 바로 무한 루프의 처음으로 돌아와 프래그 intFlag1을 검사한다. 이때, intFlag1에 값이 설정돼 있는 것을 확인한다.

→ 나머지 처리를 수행한다는 것을 알리기 위해 intFlag1의 값은 1 감소한다.

→ 나머지 처리를 위해 process1()을 호출한다.

이런 방법으로 인터럽트 처리를 수행하는 것이 일반적인 전경/배경 시스템 프로그래밍 방법이다.

== 우선순위에 따른 프로세스 처리의 한계

그러나 여기서 짚고 넘어가야 할 중요한 점이 있다. 이런 형태의 구조로는 배경 프로세스에서 인터럽트의 중요도 즉, 우선순위에 따른 처리가 불가능하다는 점이다. 예를 들어, 인터럽트 1을 시작으로 우선순위가 점차 낮아진다고 가정하자. 다시 말하면, 인터럽트 1의 우선순위가 가장 높고, 인터럽트 4의 우선순위가 가장 낮다고 가정하는 것이다. 그리고 배경 프로세스에서의 나머지 처리 함수(pro- cess())의 실행시간을 모두 T라고 가정한다. 마이크로프로세서가 처리할 수 있는 일에는 한계가 있다. 여러 개의 이벤트가 발생한다고 해서 모든 일을 동시에 처리할 수는 없는 것이다. 이 문제는 우선순위라는 용어와 밀접한 관계가 있다. 마이크로프로세서는 여러 요청을 한꺼번에 처리할 수 없기 때문에 각 이벤트의 중요도에 따라 처리 순서를 정해서 차례대로 처리해야 한다. 다시 예제로 돌아와서 다음과 같은 시나리오를 생각해보자.

→ 메인 루프에서 4개의 프래그를 점검하면서 무한 루프를 돌고 있을 때, 이벤트 2가 발생한다.

→ 인터럽트 2의 ISR을 실행한다.

→ ISR에서 해당 이벤트에 대한 처리를 완료한 뒤 intFlag2를 1 증가한다.

→ ISR 실행을 완료했으므로 프로세서의 실행은 배경 프로세스로 옮겨진다.

→ 이때, intFlag2가 설정된 상태이므로 프래그 점검 루프에서 intFlag2를 점검할 때, intFlag2의 값을 감소하고 process2()를 실행한다.

→ process2()를 실행하는 동안 이벤트 1, 이벤트 3, 이벤트 4가 차袈?발생한다. 이들 이벤트가 발생할 때마다 process2()의 실행은 잠시 중단되고 해당 ISR이 실행된다. 이때의 실행순서를 그림 1에서 확인할 수 있다. 3개의 ISR이 모두 완료하면 해당 프래그 변수 3개의 값도 1씩 증가한 상태다.

→ process2()의 실행이 끝나면 다음 프래그 점검 순서는 intFlag3이다.

→ intFlag3이 설정되어 있는 상태이므로 이 값을 1 감소한 뒤 pro-cess3()을 실행한다.

→ process3()의 실행이 끝나면 다음 프래그 점검 순서는 intFlag4이다.

→ intFlag4가 설정되어 있는 상태이므로 이 값을 1 감소한 뒤 pro-cess4()를 실행한다.

→ process4()의 실행이 끝나면 다음 프래그 점검 순서는 intFlag1이다.

→ intFlag1이 설정되어 있는 상태이므로 이 값을 1 감소한 뒤 pro-cess1()을 실행한다.

이 경우 인터럽트 1이 발생한 뒤 process1()을 실행하기까지 최소 T*2에서 최대 T*3의 시간이 걸린다. 이 시간은 process2()의 일부와 pro-cess3(), process4()를 실행하는데 걸리는 시간이다. 여기서 중요한 것은 T인데, 가령 T가 1초라면 어떻게 될 것인가

→ 최상위 우선순위의 인터럽트가 발생한 뒤 2초 이상의 시간이 지난 후에 시스템이 응답을 시작한 것이다. 그러나 T가 10m/sec라면 최대 30m/sec 안에는 응답을 할 수 있을 것이다. 이 정도의 시간은 대체로 허용할 수 있는 시간이다. 그러나 어떤 시스템은 이 정도의 시간도 허용할 수 없는 경우도 있다. 더욱이 처리해야 할 외부 인터럽트의 수가 10개이고, 지금 시나리오와 비슷한 상황으로 이들이 모두 한꺼번에 발생한다면 어떻게 되겠는가

→ 이 문제를 가지고 여러 가지 상황을 가정해볼 수 있을 것이다. 더욱이 주어진 시간제약에 따라 프로그램을 겨우 작성했다고 하자. 그 시스템에 외부 이벤트를 몇 개 더 추가해야 할 경우는 어떻게 할 것인가

→ 그때부터 프로그래머는 밤잠을 설치게 될 것이다. 그렇다고 시원한 해결방법이 나오지는 않을 것이다. 어쨌든 이를 해결하기 위한 방법을 생각해 보자.

→ 각 배경 처리 함수 내에서 자신보다 우선순위가 높은 프래그를 수시로 모니터링 하면서 자신의 일을 처리한다. 이때, 상위 우선순위의 프래그가 임의의 시각에 세트되면 해당 함수를 호출한다.

→ 인터럽트 중첩을 허용하도록 한 뒤 ISR 내에서 모든 일을 처리한다.

첫 번째 경우는 하위 우선순위 처리함수(예를 들면, process3()) 내에서 지속적으로 상위 우선순위 처리함수(예를 들면, process1())의 프래그를 모니터링 하기위해 귀중한 CPU 시간을 소모해야 한다. 즉, 순수하게 해당 프로세스를 실행하는데 걸리는 시간보다 전체 수행시간이 길어진다는 단점이 있다. 또, 상위 우선순위 처리함수의 개수가 많을수록 검사해야 하는 프래그 수가 많아지므로 CPU 낭비는 심화되고, 시스템에 외부 이벤트를 추가하거나 우선순위를 변경하고자 할 때마다 전체 소프트웨어를 수시로 변경해야 한다. 이것은 소프트웨어 업그레이드와 유지관리를 힘들게 하는 주원인이 된다. 이 방법에 따른 배경 처리 함수 코드는 다음과 같을 것이다.

위의 코드와 같이 함수 내에서 시간이 걸리는 부분을 수행할 때는 응답성 때문에 수시로 상위 우선순위의 인터럽트 프래그를 점검해야 할 것이다. 어쨌든 이런 식으로 시스템이 요구하는 응답성을 만족하도록 소프트웨어를 만들었다고 하자. 그 상태에서 이 함수가 담당하던 인터럽트의 우선순위를 변경한다면 어떻게 되겠는가→ 이 함수보다 우선순위가 높거나 낮은 함수를 모두 점검해서 코드 곳곳에 위의 점검 부분을 추가/삭제해야 한다. 그렇다고 이 함수만 변경해서 될 일도 아니다. 다른 모든 함수에 유사한 작업을 해줘야 한다. 점검 부분 추가/삭제를 하나라도 실수하면 그것은 시스템의 응답성 문제로 바로 연결된다. 외부 이벤트가 10개 있는 시스템에서 이 작업을 한다고 생각해 보면 얼마나 많은 단순작업을 되풀이 해야할지 눈에 선하다.

두 번째 해결책 역시 완벽한 해답은 아니다. 몇몇 CPU는 현재 실행하고 있는 인터럽트보다 우선순위가 높은 인터럽트만 인터럽트 중첩을 허용한다. 따라서 우선순위가 가장 높은 인터럽트는 문제가 없지만 우선순위가 낮은 인터럽트는 상위 인터럽트가 실행하고 있는 동안 활성화하지 못하므로 T만큼의 시간동안 비활성화 상태로 남게된다. 앞에서 언급했듯이 T라는 시간은 경우에 따라 긴 시간일 수도 있다. 따라서 하위 인터럽트가 외부로부터의 데이터를 수신하는 시리얼 인터럽트라면 이 기간 동안 수신되는 데이터를 모두 잊어버리게 될 것이다. 따라서 ISR에서 모든 일을 처리하는 것은 아무래도 무리인 듯하다. 그리고 인터럽트에서 모든 일을 처리하는 방식은 언젠가는 문제를 유발한다. ISR에서 최소한의 처리만 하도록 프로그램하는 것은 프로그래밍의 정석이라 할 수 있다