요즘 각광을 받고 있는 임베디드 시스템이란 특정한 목적만을 수행하기 위해 따로 설계된 시스템이라고 간단하게 말할 수 있다. 여기에 임베디드 소프트웨어란 이러한 임베디드 시스템에 내장되어 있는 소프트웨어를 말한다. 데스크톱에서의 개발에 익숙해있는 일반 개발자들에게는 약간 낯설 수 있으나, 실제로 임베디드 시스템은 TV, MP3 플레이어, 디지털 카메라, 핸드폰, 자동차 제어(ECU), 의료기기 등과 같은 우리 일상생활과 매우 밀접한 관계를 가지고 있다.
임베디드 소프트웨어 가운데 임베디드 운영체제 기술 변화의 최근 몇 년간 큰 흐름은 기존의 정보가전 시장과 여타의 임베디드 산업을 주도하던 임베디드 RTOS들인 VxWorks, pSOS, QNX, VRTX 등에 비해서 임베디드 리눅스의 시장 점유율이 조금씩 증가하고 있다는 점이다. 그 이유는 각종 임베디드 시스템에 사용되는 하드웨어의 양적, 질적 발전으로 인해 사용할 수 있는 하드웨어 리소스가 늘어나게 되었고 임베디드 리눅스는 공개 소스를 이용하기 때문에 개발 속도가 빠르고 개발 시에 기존의 RTOS를 사용하는 것보다 제품 당 단가가 적게 드는 것을 들 수 있을 것이다. 이처럼 최근 임베디드 리눅스가 임베디드 시장에서 두각을 나타내고 있다.
임베디드 시스템의 실시간 지원
최근 몇 년간은 OS 춘추 전국시대라고 해도 무방할 만큼 임베디드 산업뿐만 아니라 데스크톱 영역까지도 다양한 OS가 개발되어 나오고 있는 상황에서 관심이 집중되고 있는 임베디드 운영체제는 그 만큼 많은 발전과 더불어 많은 변화를 거치고 있는 중이다. 그 중에서도 실시간 지원 기능은 임베디드 운영체제 기술 특성상 가장 중요한 몇 가지 기능 중 하나일 것이 분명하다. 실시간 지원 기능의 차원에서 접근하여 본 임베디드 운영체제의 대략적인 사용 현황을 살펴보면, 경성 실시간성이 반드시 요구되는 의료 기기, 공장 제어, 항공 제어, 군 장비, 원자력 발전소 등의 응용에서는 기존 RTOS인 VxWorks, pSOS, QNX, VRTX 등이 쓰이고, 연성 실시간성이 요구되는 TV 셋톱박스, 라우터, 디지털 홈네트워킹, 각종 정보가전 분야 등에는 다양한 기능을 지원하는 임베디드 리눅스가 사용되고 있다. 여기에는 VxWorks나 pSOS 등 기존 RTOS를 사용하는 것보다 임베디드 리눅스를 사용하는 것이 비용적인 측면과 개발의 편리성 측면에서 유리하다는 이유가 있다. 따라서, 최근 흐름에 발맞추어 임베디드 리눅스의 실시간성 지원 중심으로 살펴보겠다.
4
임베디드 리눅스의 실시간 지원
리눅스는 일반적인 목적으로 설계된 운영체제이기 때문에 RTOS의 입장에서 보면 많은 문제점을 지니고 있다. 우선 리눅스는 다른 UNIX 시스템과 마찬가지로 time-sharing scheduling을 한다. 이 말은 하나의 프로세스를 실행하고 있는 중에, 정해진 타임 슬라이스를 모두 채우거나, 임의로 블록킹이 된 경우, 시스템 콜을 호출하는 경우, 페이징 메커니즘을 통해서 페이지를 하드 디스크에서 읽어 와야 하는 경우 등을 제외하고는 현재의 프로세스를 중단하지 않는다(커널 2.6에서는 선점기능이 추가되었다, 커널 컴파일 옵션에서 선택 가능)는 것이다. 기존 리눅스의 프로세스 관리에 보면 프로세스를 일반 프로세스와 실시간 프로세스로 분류하여 실시간 프로세스에게 더 높은 우선순위를 주고 높은 권한(타임 슬라이스의 제한이 없어서 CPU의 독점이 가능)을 부여하여 스케줄링을 할 때 실시간 프로세스가 다른 프로세스보다 먼저 수행되게 하고 있지만, 일반적인 실시간 스케줄링 요건을 완전히 충족하지 못한다. 또한 리눅스는 가상 메모리를 사용한다. 리눅스는 요구 페이징(demand paging)기법을 사용하여 페이지 교체를 하기 때문에, 프로세스 수행 도중 페이지를 디스크에서 메모리로 읽어 들이는 일이 발생하여 프로세스의 수행 시간을 예상할 수 없게 된다. 가상 메모리는 프로세스가 관리해야 하는 자료구조가 많아지게 하기 때문에 태스크 전환이 일어나는 경우 처리해야 하는 일도 많아지게 되어 성능 저하를 가져오게 된다. 이러한 문제점을 해결하고 임베디드 리눅스의 실시간 지원을 위한 두 가지 측면의 알려진 방식이 있다. 첫 번째 방법은 RTLinux와 같이 임베디드 리눅스 커널 아래에 새로운 실시간 커널을 두어 실시간 태스크에 대한 지원을 하는 방법이고, 두번째 방법은 임베디드 리눅스 커널을 직접 수정을 가하여 실시간성을 지원하는 방법이다.
RTLinux의 Dual-Kernel을 이용한 실시간 지원
첫 번째 실시간 지원 접근 방법인 RTLinux는 기존의 Linux 커널 자체를 하나의 real-time 태스크로 보고, RTLinux내에서 다른 real-time 태스크가 없을 경우에만 기존의 Linux를 수행하도록 만드는 방법을 사용하고 있다. 이것은 하나의 시스템에 두 개의 커널을 동시에 동작시키는 방법으로서 Linux 커널을 수정해야하는 수고는 덜고, 동시에 hard real-time을 구현할 수 있는 방법을 만든 것이다.
위의 그림에 나타나 있듯이 Interrupt를 발생시키는 Hardware에서 생성된 신호가 RTLinux의 커널로 먼저 전달되고 만약 real-time 태스크라면 RT FIFO에 저장하고, 만약 유저 프로세스가 real-time 태스크의 접근을 요청한다면 Linux 커널을 스케줄링하여 읽어갈 수 있도록 하고 있다. 여기서 사용되는 RT FIFO란 중요한 hard real time 태스크와 그 외의 나머지 복잡한 처리와 시간을 요구하는 데이터는 유저 태스크로 전달시켜 두 개의 태스크 사이의 상호작용을 위한 인터페이스 역할을 하는 것을 말한다. RT FIFO는 hard real-time 태스크가 유저 태스크와 통신할 수 있는 유일한 방법이며, 또한 hard real-time 태스크가 데이터를 읽거나 쓸 때 생길 수 있는 블록킹 문제를 해결하는데 사용되고 있다.
RTLinux는 기존의 Linux와는 다르게 hard real-time 이벤트에 한해 더 빠르게 반응할 수 있다. 또한 RTLinux만의 독창적인 pthread API 제공(http://www.rtlinux-gpl.org/cgi-bin/viewcvs.cgi/rtldoc-3.2-pre1/doc/html/MAN/)을 통해 real-time 태스크를 구현할 수 있다. 하지만 RTLinux는 모든 real-time 태스크가 커널 레벨에서 동작한다는 단점이 있다. Linux 커널뿐만 아니라 RTLinux의 태스크가 동작함으로써 커널 레벨 자체 오버헤드가 커지게 된다. 또한 위에서 설명한 RT FIFO의 특징인 hard real-time 태스크와 유저 태스크 간의 유일한 인터페이스라는 점도 반대로 생각해보면 단점이 될 수 있다. 바로 RT FIFO 자체에 오버로드가 발생하게 되면 전체적인 리얼타임 시스템의 성능이 저하될 수 있기 때문이다. 가장 큰 문제는 프로그래머가 구현한 real-time 태스크의 실수가 커널에 영향을 미쳐 커널을 멈추게 해버릴 수도 있다는 점이다. RTLinux는 현재 항공기 제어 시스템, 군사 기기, 산업용 로봇, 의료 기기 등의 분야에 적용되고 있다. 하지만 RTLinux에 대한 평가는 일단 복잡하고 많은 것을 수행할 필요가 있는 프로젝트에는 적합하지 않으며, 비교적 단순한 실시간성을 요하는 프로젝트에 적용 가능하다는 것으로 알려져 있다. 그럼에도 불구하고 RTLinux는 기존의 Linux 시스템에서 해결하지 못했던 hard real-time 특성을 dual-kernel이라는 구조를 사용해서 해결했다는 것에 대해서는 상당히 의미가 깊다. 하지만 역시 아직 대규모의 project에 사용된 예가 적으며, dual-kernel이라는 구조가 특허에 등록되어 있으며, 완전한 RTLinux를 사용하기 위해서는 대가를 지급해야 한다는 점에서 제약사항이 발생한다.
RTLinux와 많이 비교되는 또 다른 Linux 버전으로는 밀라노 대학의 RTAI가 있다. 두 시스템의 기본적인 구조는 거의 동일하다. 차이점은 RTLinux는 커널의 어셈코드를 직접 수정하는 것이고 RTAI는 리눅스의 인터럽트 벡터테이블에 포인터로 연결되는 HAL(Hardware Abstraction Layer)을 추가하는 것이다. 따라서 RTLinux는 RTAI에 비해 상대적으로 많은 어셈코드를 수정해야 하는 어려움이 있다.
RTLinux의 설치 및 간단한 사용법에 대해서는 http://tldp.org/HOWTO/RTLinux-HOWTO.html에 자세히 설명되어 있으니 관심이 있는 사람은 참고하기 바란다.
리눅스 커널의 수정을 통한 실시간 지원
두 번째 실시간 지원 방법은 일반적인 임베디드 리눅스 개발업체와 일반 온라인 커뮤니티에서 많이 사용하는 방법으로 일반적인 리눅스 커널에 실시간적 요소를 직접 추가하는 방법이다. 이 방법은 연성 실시간성을 지원하지만, POSIX와의 호환성 때문에 개발자들에게 응용 프로그램 개발에 대한 편의성 및 개발기간 단축을 제공할 수 있다는 것이 가장 큰 장점이다. 이에 대한 실시간 지원 방법은 크게 선점형 커널, 락 브레이킹 기법, 실시간 스케줄러의 세 가지로 나눌 수 있다.
선점형 커널 지원
커널 2.4버전까지의 리눅스 특징 중 하나가 커널이 비선점형(non-preemptive)라는 것이다. 선점이란 프로세스가 실행 중일 때 현재의 프로세스를 멈추고 다른 프로세스를 실행할 수 있는 것을 말한다. 프로세스가 실행 중일 때 아무 때나 이를 선점하여 스케줄링 할 수 있지만, 예외 상황이 몇 가지 있다. 그 것은 프로세스가 시스템 콜을 호출하거나 인터럽트 예외가 발생하여 커널 모드에 진입한 경우 커널 모드에서 빠져나오거나 프로세스가 자발적으로 CPU를 반납하지 않는 이상 커널 모드에 있는 동안은 프로세스가 선점되지 않는다는 점이다. 예를 들어보면 비선점형 커널의 경우 개발자가 잘못 작성한 디바이스 드라이버가 커널 모드에서 무한루프를 돌게 되면 더 이상 스케줄러가 호출되지 못하여 시스템의 동작이 멈추게 된다. 스케줄러도 하나의 프로세스라고 할 수 있기 때문에 현재 실행중인 프로세스를 선점을 할 수 없기 때문이다.
이렇듯 비선점형 커널의 가장 큰 단점으로 꼽히는 것이 RTOS에서 가장 중요한 부분인 시스템의 반응성을 떨어뜨린다는 것이다. 현재 프로세스가 커널 모드에 있는 동안은 인터럽트가 발생하여 이를 처리한 결과 실시간 프로세스나 현재 프로세스보다 우선순위가 더 높은 프로세스가 실행 가능해진 경우라도 스케줄링은 커널 모드에서 사용자 모드로 돌아가는 시점에만 일어나므로 프로세스가 커널 모드에서 작업을 완료할 때까지 기다려야 한다. 이는 우선순위가 높은 프로세스를 실행해야 하고, 이벤트가 발생하여 이를 처리할 때까지 걸리는 Latency를 일정한 시간 이내로 유지해야 하는 RTOS의 조건에는 맞지 않다.
이러한 문제점에도 불구하고 최초에 비선점형 커널로 설계한 이유는 먼저 커널의 구조가 간단해지기 때문이다. 커널 코드를 선점형으로 만들려면 커널 코드가 재진입 가능하게 해야 하고 재진입 가능한 코드 자료구조의 접근에 있어서 임계영역을 설정하여 보호해야 한다. 이러한 크리티컬 섹션을 설정할 때는 스핀락을 사용한다. 크리티컬 섹션 내에서는 선점이 일어나지 못하게 하고, 크리티컬 섹션을 빠져나올 때 선점이 되어야 하는지 여부를 판단하여 선점이 필요한 경우 선점이 일어나도록 하고 있다. 이 기능을 사용하게 되면 커널 내부라 할지라도 스핀락이 걸려 있는 구간을 제외한 타 지역에서는 선점이 가능한 선점형 커널을 구성할 수 있게 된다. 하지만 이 방법 역시 RTOS에서는 문제점을 지니게 된다.
위의 그림처럼 크리티컬 섹션이 길어지는 곳이 나타나게 됨에 따라 동시에 선점할 수 없는 시간이 길어지는 현상으로 역시 시스템의 반응성을 떨어뜨리게 되는 것이다. 이 점에 관해서는 이어지는 Lock-breaking 기법에서 알아보기로 한다.
락 브레이킹 기법
흔히 락 브레이킹이라고 하면 안티 락 브레이킹이라고 부르는 자동차 장치인 ABS가 떠오르기 마련이다. 자동차가 달릴 때는 4개 바퀴에 똑같은 무게가 실리지 않는다. 이런 상태에서 급제동을 하면 일부 바퀴에 로크업(lock-up)현상, 즉 바퀴가 잠기는 현상이 발생한다. 이것은 차량은 여전히 진행하고 있는데도 바퀴는 완전히 멈춰선 상태를 말하는데, 이때 차량 무게 중심이 이동하여 차량이 미끄러지거나 옆으로 밀려 운전자가 차의 방향을 제대로 제어할 수 없게 된다. 이러한 문제를 방지하려면 바퀴가 완전히 잠기지 않도록 브레이크를 밟았다 놓았다 하는 작업을 해주어야 한다. 이 작업을 ECU(전자제어장치)나 기계적인 장치를 이용하여 1초에 10회 이상 반복되면서 제동이 이루어지도록 한 것이 그 원리이다(ECU에도 RTOS가 들어간다. 1초에 10번씩 지정된 시간에 반드시 작동을 해야 하기 때문이다). 이와 마찬가지로 리눅스 커널 내부에 사용되는 락 브레이킹 기법도 비슷한 원리를 가진다. 커널 내부의 긴 브레이킹 구간(크리티컬 섹션)을 중간 중간 나눠서 응답성을 높이는 방법이다. 리눅스 커널은 공개 소스 기반으로 다수의 개발자들이 참여하여 개발되었기 때문에 응답성을 중시하기 보다는 안정성과 처리량을 중시한다. 이에 따라 개발자들의 커널 코드는 안정성을 높이기 위한 락 구간이 많고 긴 편이다. 이러한 락 구간은 리눅스 커널이 선점형 커널이라도 락 구간 내의 태스크들은 선점할 수가 없고 시스템의 응답성이 현저히 길어질 수밖에 없다. 이러한 코드 구간을 실험을 통하여 찾아내고 짧은 응답시간을 갖도록 락 구조를 변경해야 한다. 이 기법은 리눅스 커널의 응답성을 높이는데 유효한 방법이지만, 긴 락 구간을 찾아내기가 어렵고 게다가 찾아낸다 하더라도 때에 따라서는 락 구조를 변경할 수 없는 경우가 있어 쉽지 않은 방법이기도 하다. 일반적으로 긴 락 구간을 찾아보면 가상 파일 시스템 관련 코드와 메모리 관련 코드가 많은 편이다. 다음은 락 브레이킹 패치 된 커널 코드의 한 예이다.
void si_swapinfo(struct sysinfo *val)
{
...
swap_list_lock();
for (i = 0; i < nr_swapfiles; i++) {
unsigned int j;
if (swap_info[i].flags != SWP_USED)
continue;
for (j = 0; j < swap_info[i].max; ++j) {
if (conditional_schedule_needed()) {
debug_lock_break(551);
swap_list_unlock();
debug_lock_break(551);
unconditional_schedule();
swap_list_lock();
}
switch (swap_info[i].swap_map[j]) {
case 0:
case SWAP_MAP_BAD:
continue;
default:
nr_to_be_unused++;
}
}
}
...
swap_list_unlock();
}
위의 코드를 보면 이중 반복문 사이에서 상당히 긴 시간을 소비하는 크리티컬 섹션구간이라는 것을 알 수 있다. 이러한 코드 중간에 선점될 필요가 있는지를 검사하여 필요한 경우 선점이 이뤄질 수 있도록 하고 있다. 이렇게 커널 코드를 직접 수정함으로써 빠른 응답성을 가지는 효과를 가져 올 수 있다.
실시간 스케줄러
스케줄러(scheduler)란 다음에 실행될 프로세스를 선택하는 커널 컴포넌트이다. 스케줄러는 시스템에 있는 실행 가능한 프로세스들에게 한정된 CPU 사용시간을 분배해주는 커널의 핵심 컴포넌트이다. 스케줄러는 리눅스 같은 멀티태스킹 시스템의 기본 요소이며, 다음에 실행할 프로세스를 결정해주고 시스템을 최대한 활용할 수 있도록 해주는 역할을 지닌다. 이 역할을 수행하기 위해 항상 CPU에서 프로세스가 작동하고 있도록 해주고 CPU수 보다 프로세스 수가 많을 경우 현재 CPU 자원을 사용하지 못하고 있는 프로세스에게도 공평한 CPU 사용시간을 가지도록 해준다. 여러 프로세스 중에서 다음에 실행할 프로세스를 선택하는 것은 스케줄러가 가진 역할 중에 가장 중요한 것이라고 할 수 있겠다.
일반적으로 리눅스 프로세스는 여러 가지 상태중의 하나를 가지는데, 커널은 그 중에서 실행가능한 상태의 프로세스를 실행 큐라는 자료구조에 삽입하여 관리한다. 스케줄러가 호출되면 실행 큐에 있는 모든 프로세스 중 스케줄링 알고리즘에 의해 가장 높은 우선순위를 가진 프로세스를 다음 프로세스로 선택하여 실행한다. 스케줄러는 다음과 같은 경우 호출된다.
-현재 프로세스가 자신에게 부여된 타임 슬라이스를 다 사용하였을 때
-현재 프로세스가 어떤 이벤트를 기다리며 블록 되었을 때
-현재 프로세스가 스케줄러를 호출하여 스스로 CPU를 반납했을 때
-현재 프로세스보다 더 높은 우선순위를 가진 프로세스가 실행가능한 상태가 되었을 때
커널 2.4의 스케줄러는 실행 가능한 프로세스를 번갈아가며 실행하다가 실행큐 안에 있는 모든 프로세스가 타임 슬라이스를 다 소모하면 다음에 CPU자원을 사용할 수 있도록 프로세스들에게 다시 기본 타임 슬라이스를 할당해준다. 스케줄러는 CPU 위주의 프로세스보다 IO 위주의 프로세스에 높은 우선순위를 주고 모든 프로세스가 공정하게 스케줄링 되도록 해당 스케줄링 알고리즘에 맞게 각각의 타임 슬라이스를 할당한다. 커널 2.4의 스케줄링은 O(n)의 성능을 나타낸다. 전체 프로세스는 이중 연결리스트로 연결되어 있으며, 스케줄러는 매 스케줄링마다 각 프로세스의 연결 리스트를 따라가면서 다음에 실행할 최적의 프로세스를 선택하기 때문에 프로세스의 개수가 많으면 많을수록 계산량이 많아지는 구조로 되어있다. 즉, 시스템에 사용 중인 프로세스 개수가 많을수록 스케줄링에 많은 시간을 투자하기 때문에 시스템 전체가 느려지며, 효율이 떨어진다. 따라서 이런 구조로는 태스크 수에 상관없이 실시간 태스크들에 대한 고정적인 스케줄링 시간을 갖는 실시간 스케줄러를 기대할 수 없으므로, 실시간 지원적 성격의 하나인 예측가능성이 낮아질 수밖에 없다. 이러한 문제점을 해결하기 위해 나온 것이 프로세스의 개수에 관계없이 항상 같은 실행시간을 갖는 커널 2.6의 O(1) 스케줄러이다.
O(1) 스케줄러의 가장 큰 특징은 실행 큐가 동작 중인(active) 프로세스와 만료된(expired) 프로세스의 2개의 우선순위 배열로 이뤄진다는 점이다. active 큐에는 타임 슬라이스가 남아있는 모든 실행 가능한 프로세스가 expired 큐에는 타임 슬라이스를 모두 사용한 실행 가능한 프로세스가 들어간다. 각 배열에는 MAX_PRIO 개의 큐가 있으며, 프로세스는 우선순위에 따라 해당하는 큐의 위치에 들어간다. 실행 가능한 프로세스는 타음 슬라이스를 부여받은 채로 active 큐에 들어가고, 타임 슬라이스를 모두 사용하면 새로운 우선순위와 타임 슬라이스를 할당받은 채로 expired 큐에 들어간다. 나중에 active 큐에 어떠한 프로세스도 남아있지 않게 되면 아래와 같이 active 큐와 expired 큐가 교체되어 스케줄링을 계속하게 된다.
asmlinkage void __sched schedule(void)
{
...
array = rq->active;
if (unlikely(!array->nr_active)) {
schedstat_inc(rq, sched_switch);
rq->active = rq->expired;
rq->expired = array;
array = rq->active;
rq->expired_timestamp = 0;
rq->best_expired_prio = MAX_PRIO;
}
...
}
이렇게 되면 스케줄러는 다음에 실행할 프로세스를 선택할 때 새로 교체된 active 큐의 가장 상단에 있는 우선순위가 높은 프로세스를 고르게 된다. 따라서 스케줄링에 드는 시간은 프로세스의 수와 상관없이 항상 일정하므로, 이 스케줄링 알고리즘의 성능은 O(1)이 된다. 커널 2.6의 스케줄러는 이와 같이 프로세스의 개수가 늘어나서 스케줄링의 횟수가 증가한다고 해도 커널 2.4처럼 성능이 저하되지 않는다. 이러한 점은 제한된 시간 내의 스케줄링 응답시간을 가지게 되어 실시간 운영체제의 특성에 부합하게 된다.
Livnux
위에서 살펴본 리눅스의 실시간 프로젝트를 요약해보면 크게 두 가지로 나눌 수 있다. 리눅스 커널과 개별적으로 동작하는 새로운 커널을 개발하는 방법과 리눅스 커널 자체의 수정이 바로 그것이다. 우리 프로젝트는 두 가지 방법 중 후자인 리눅스 커널을 직접 수정하는 것이다. 먼저 개발환경으로 보면 FALinux사의 EZ-X5보드에 수정된 커널을 포팅하여 동작시킬 것이고 검증은 열차 게이트 관리시스템과 삼각파 발생기를 실시간 어플리케이션으로 작성하여 확인할 것이다.
<프로젝트에 사용된 FALinux의 EZ-X5>
프로젝트의 이름은 실시간을 의미하는 Live와 리눅스의 영문명인 Linux를 합친 Livnux로 결정하였다. 이하 본문에서 우리 프로젝트를 Livnux라고 부르겠다. Livnux는 Non-Preemption Section 처리, High-Resolution Timer 구현, Memory 관리 이렇게 세 부분으로 나눌 수 있다.
Non-Preemption Section
Livnux의 RT-Task는 커널 모드와 유저 모드 두 군데서 동작하게 된다. 하나의 리얼타임 어플리케이션에서도 반응성이 중요한 부분인 하드 리얼타임이 필요한 부분과 반응성이 덜 중요한 부분인 소프트 리얼타임인 부분으로 나눠지게 마련이다. 이러한 점의 구분은 전적으로 개발자에게 달려있으며 하드 리얼타임이 필요한 부분은 커널 모드에서 동작하도록 개발하면 된다. 유저모드에 있는 부분과의 통신은 실시간 시그널로 이뤄지도록 되어있다. RT Task중에 커널 모드에서 동작하는 부분을 커널에 인식시키기 위해서는 인터럽트를 사용해야 한다. RT Task용 인터럽트를 커널에서 제대로 받아들이기 위해서는 몇 가지 처리해야 할 부분이 있는데 그 중에 한 부분이 Non-Preemption Section의 처리이다. 커널은 크게 Preemption Section과 Non-Preemption Section으로 나눌 수 있는데 Non-Preemption Section에서는 인터럽트가 허용이 되지 않기 때문에 범용OS 및 RTOS에서 이 Non-Preemption Section에 대한 처리가 상당히 중요하다. Non-Preemption Section의 처리에 의해 RTOS의 응답성이 결정되기 때문이다. 만약 일반 태스크에 의해 Non-Preemption Section에 들어가게 되는 동시에 우선순위가 더 높은 RT-Task의 동작이 요구될 경우 RT-Task의 우선순위가 더 높은데도 불구하고 바로 실행되지 못하고 일반 태스크가 Non-Preemption Section에서 나오고 난 뒤에 RT-Task가 처리되기 때문에 응답성에 상당히 문제가 생긴다. 만약에 Non-Preemption Section이 10초가 되어버리면 RT-Task의 최대 응답성이 10초가 되어버리는 상황이 발생하게 된다. 이런 사태가 발생하게 되면 해당 RTOS는 가치가 거의 없는 것이 된다. 지금까지 개발된 다양한 RTOS에서 Non-Preemption Section에 대해 적절한 처리가 되어있지만 이번 프로젝트에서는 Linux를 직접 수정하여 Non-Preemption Section을 처리해야 하기 때문에 기존의 RTOS와는 다른 방식의 접근이 필요하다. 기존의 RTOS는 Non-Preemption Section의 처리에 맞게 처음부터 OS 설계가 되어 있지만 우리는 범용OS인 Linux를 사용하여 개발하기 때문에 이 부분의 처리에 있어서 새로운 방식을 고안해낼 필요가 있다. 그리고 인터럽트를 사용하여 해당 RT-Task를 실행시킬 예정이므로 Non-Preemption Section에서 인터럽트를 받아들이도록 하는 것이 중요하다. 물론 항상 모든 인터럽트를 받아들이면 Non-Preemption Section의 가장 기본적인 존재 자체가 불분명해지므로 기존의 OS개념은 그대로 이어가면서 RTOS라는 새로운 개념과도 잘 조합될 수 있는 방식의 구현이 필요하다. Linux에서 Non-Preemption Section에 진입하는 경우의 예를 살펴보면 아래와 같다.
void fastcall __wake_up(wait_queue_head_t *q, ...)
{
...
spin_lock_irqsave(&q->lock, flags);
...
spin_unlock_irqrestore(&q->lock, flags);
}
위에서 제시한 함수는 kernel/sched.c의 __wake_up 함수이다. 대기 큐에 들어있는 프로세스를 깨워주는 역할을 하는 함수로써 spin_lock_irqsave()라는 API로 인터럽트를 방지하고 난 뒤 프로세스를 깨우는 중요작업을 하고 spin_lock_irqrestore()로 인터럽트 방지를 해제하고 작업을 마무리한다. 이렇게 커널에 락을 걸어 어떠한 다른 Task도 선점할 수 없도록 방지하여 중요 자료구조의 보호뿐만 아니라 인터럽트조차도 받아들일 수 없도록 하고 있다. 물론 위와 같은 작업은 Linux의 올바르고 신속한 처리를 위해서 매우 짧은 순간(실험결과 최대 27us) 실행이 되고 Non-Preemption Section에서 탈출을 하겠지만 여기서의 처리시간은 일반 범용OS에게는 적용을 할 수 있겠지만 빠른 응답성을 요구하는 RTOS에는 맞지 않다. 그리고 위에서 제시한 것 외에도 수많은 Non-Preemption Section이 커널 내부에 존재한다.
Non-Preemption Section 처리
위에서 Linux의 Non-Preemption Section의 한 예를 제시하였다. 하지만 Linux의 모든 Non-Preemption Section을 처리하는데 무리가 있다. 위에서 락 브레이킹 기법에서 설명했듯이 모든 Non-Preemption Section을 찾아내기도 힘들고 일일이 처리해준다는데 어려움이 발생하게 된다. 따라서 이 모든 Non-Preemption section이 공통적으로 처리되는 부분에서 무언가 새로운 기능을 처리해주는 기능을 구현한다면 해결이 될 것이라고 생각한다. 이 부분에 대해서는 인터럽트 발생 방식에 의해 크게 두 가지 해결 방법을 생각할 수 있는데 첫 번째로 예측 가능한 인터럽트를 처리하는 방법과 예측 불가능한 인터럽트를 처리하는 방법을 생각할 수 있다. 예측 가능한 인터럽트의 경우를 예로 들어보면 기존의 타이머 인터럽트를 예로 들 수 있다. RT-Task가 주기적으로 실행해야 할 때 타이머 리스트에 등록시키기 때문에 타이머 리스트에서 특정 값을 받아오면 RT-Task의 실행 시간을 예측할 수 있을 것이다. 현재 커널의 시간 기준 값과 해당 Task의 실행 시작시간을 측정하여 RT-Task 실행 예정시간을 측정할 수 있기 때문에 언제 인터럽트가 발생할지 알 수 있다. 그리고 후자의 경우인 예측 불가능한 인터럽트의 경우는 외부에서 받아들이는 인터럽트를 생각할 수 있을 것이다. 일반적인 임베디드 시스템 환경에서 외부 인터럽트의 처리는 필수적이다. 중요한 비상버튼 같은 경우를 처리할 때를 예로 들 수 있는데 이 때는 언제 버튼이 눌러질지 커널이 예측할 수 없는 상황이기 때문에 혹시라도 커널에서 Non-Preemption section에 들어갔을 때 이 부분을 처리하는 방법을 고안해내야 한다. 이처럼 Non-Preemption section에 대해서 예측 가능한 인터럽트와 예측 불가능한 인터럽트를 처리하는 방식은 다르게 처리되어야 할 것이다.
예측 가능한 인터럽트의 처리
Non-Preemption section의 처리에 있어서 예측 가능한 인터럽트를 처리하는 경우는 인터럽트 발생 예정시간과 Non-Preemption section에 진입하는 시간을 비교하여 처리한다. 인터럽트가 발생할 시간은 위에서 제시한 방식을 적용하면 예측할 수 있지만 Non-Preemption section에 진입해있는 시간은 예측하기 까다로운 부분이다. j이 부분을 설명하기 앞서 올바른 이해를 위해 먼저 예측 가능한 인터럽트의 처리개념을 설명하자면 RT Task용 인터럽트 발생시간과 다른 태스크의 Non-Preemption section 탈출시간을 비교하여 Non-Preemption section 탈출 후에 RT Task용 인터럽트가 발생한다고 예측된다면 현재 태스크를 그대로 진행시켜 실행 후에 RT Task용 인터럽트가 발생하도록 해준다. 하지만 반대의 경우 Non-Preemption section 진행 중에 RT Task용 인터럽트가 발생하는 경우로 예측된다면 Non-Preemption section안에서는 인터럽트의 처리가 될 수 없는 상황이 발생하므로 이 경우 따로 대기 큐를 만들어 현재 Task를 넣어두고 후에 RT Task용 인터럽트가 정상적으로 실행될 수 있도록 한다.
Non-Preemption section인 크리티컬 섹션에 진입하려고 하면 먼저 크리티컬 섹션 탈출 시간과 RT Task의 실행 예정시간을 비교하여 Locking을 방지할지 그대로 허용할지 결정한다. Locking방지에 대한 설명을 그림으로 나타내면 아래와 같다.
<Locking을 방지한 경우>
Locking을 방지하겠다고 결정한 경우는 Preemption Section에서 Non-Preemption section으로 진입하기 전 현재 태스크를 대기 큐에 삽입하여 Non-Preemption section에 진입하는 것을 막는다. 위 그림에서 보이는 NP영역은 원래 P영역 뒤에 바로 실행되어야 하지만 대기 큐에 들어가게 되면서 RT Task 후에 진행되도록 순서가 미뤄졌다. 아래는 Locking을 허용한 경우의 그림으로서 일반적인 경우를 나타내었다.
<Locking을 허용한 경우>
Locking을 허용하는 부분은 그림에서도 나타나 있듯이 Non-Preemption section이 종료한 뒤 약간의 시간이 지난 후이거나 바로 RT-Task가 실행되는 경우로 이 경우는 별다른 작업을 해주지 않아도 그대로 진행하면 제대로 작동하게 된다. 지금까지 설명한 부분 중 Locking 방지결정 여부를 코드로 나타낸 부분은 아래와 같다.
void lvn_time_check(int number)
{
....
struct timespec holdtime_tspec;
ktime_t holdtime_ktime;
int index;
int holdtime_table[] = {
118,/* [0] driver/serial/serial_core.c : __uart_start() */
1246,/* [1] fs/dnotify.c : dnotify_parent() */
65,/* [2] kernel/timer.c : lock_timer_base() */
27,/* [3] drivers/serial/serial_core.c : uart_write() */
34,/* [4] kernel/signal.c : sigprocmask() */
46,/* [5] mm/memory.c : handle_pte_fault()*/
223,/* [6] kernel/sched.c : schedule()*/
27,/* [7] kernel/sched.c : __wake_up()*/
21,/* [8] kernel/workqueue.c : __queue_work()*/
78,/* [9] kerenel/timer.c : __run_timers()*/
...
};
holdtime_tspec.tv_sec = 0;
holdtime_tspec.tv_nsec = holdtime_table[number] * NSEC_PER_USEC;
holdtime_ktime = timespec_to_ktime(holdtime_tspec);
hrtimer_get_softirq_time(cpu_base);
for (index = 0; index < HRTIMER_MAX_CLOCK_BASES; index++) {
base = &cpu_base->clock_base[index];
if (!base->first)
continue;
if (base->get_softirq_time)
base->softirq_time = base->get_softirq_time();
node = base->first;
/* getting the hrtimer list data */
timer = rb_entry(node, struct hrtimer, node);
holdtime_ktime =
ktime_add(base->softirq_time, holdtime_ktime);
if (holdtime_ktime.tv64 > timer->expires.tv64) {
lvn_insert_wait_queue(holdtime_table[index]);
}
}
}
배열로 이뤄진 holdtime_table은 커널 내부의 각각 Non-Preemption section의 시간을 나타낸다. Non-Preemption section에 들어가기 전과 나온 후의 시간차를 수백 번의 실험으로 알아낸 후 시간의 최대 값을 나타내었다. 이러한 값들의 집합이 바로 holdtime_table이다. 이 값과 타이머 리스트의 가장 상단에 있는 프로세스의 시작 예정시간을 비교해서( if (holdtime_ktime.tv64 > timer->expires.tv64) ) 조건이 충족되면 대기 큐에 삽입( lvn_insert_wait_queue(holdtime_table[index] )한다. 대기 큐에 들어가 있는 시간도 holdtime_table에서 받아온다. 참고로 API앞에 “lvn_”이라고 붙은 접두어는 Livnux의 약자로 Livnux에 맞게 구현된 API이다. 이 lvn_time_check의 사용법을 예로 들면 아래와 같다. 코드는 앞서 설명한 __wake_up 함수의 수정된 버전이다.
void fastcall __wake_up(wait_queue_head_t *q, ...)
{
...
lvn_spin_lock_irqsave(&q->lock, flags, 7);
...
lvn_spin_unlock_irqrestore(&q->lock, flags);
}
기존에는 일반적인 spin_lock_irqsave를 사용하였지만 수정된 부분은 lvn_spin_lock_irqsave라는 이름과 3번째 인자인 7이다. 7이라는 값이 바로 전에 설명한 lvn_time_check의 인자로 들어오면서 holdtime_table에서 해당 __wake_up함수의 Non-Preemption section의 대기시간이 27us라는 것을 알아내고 이 값을 바탕으로 대기 큐로 옮길지 그대로 진행할지 판단하게 된다. lvn_spin_lock_irqsave()를 자세히 살펴보면 아래와 같다.
#define __LVN_LOCK_IRQSAVE(lock, flags, number) \
do { lvn_time_check(number); \
lvn_cpsr_save(flags); lvn_disable_irq(); __LOCK(lock); \
} while (0)
세 번째 인자와 함께 대기 큐에 넣을지 진행할지 체크를 하는 lvn_time_check()를 진행하고 두 번째 인자로 CPSR을 저장한다. spin_lcok_irqsave라는 API가 현재 상태 레지스터를 저장하고 인터럽트를 금지 시키는 작업을 하므로 ARM 프로세서의 상태 레지스터인 CPSR의 값을 저장한다. 그리고 lvn_disable_irq()를 통해 인터럽트를 금지한다. 원래 이 부분은 local_irq_save()라는 어셈코드로 구성된 하나의 통합된 부분이지만 lvn_disable_irq() 부분이 따로 필요하기 때문에 두 부분으로 분리되었다. lvn_disable_irq()에 대해서는 예측 불가능한 인터럽트에서 설명하도록 하고 lvn_cpsr_save()를 살펴보면 아래와 같다.
#define lvn_raw_cpsr_save(x)\
({\
__asm__ __volatile__ (\
"mrs%0, cpsr@ cpsr_save\n"\
: "=r" (x)\
:\
: "memory", "cc");\
})
psr 레지스터 전용 mov 명령어인 mrs로 cpsr에서 데이터를 가져와서 인자로 넘어온 x에 해당 데이터를 저장한다. livnux에서 새로 추가된 lock API 리스트는 아래와 같다.
lvn_spin_lock(lock, number)
lvn_spin_unlock(lock)
lvn_spin_lock_irq(lock, number)
lvn_spin_unlock_irq(lock)
lvn_spin_lock_irqsave(lock, flags, number)
lvn_spin_unlock_irqrestore(lock, flags)
커널 내부에서는 위에 제시된 lock API외에도 정말 많은 수의 다양한 lock API가 존재한다. 하지만 해당 lock API의 속으로 따라가다 보면 ARM 프로세서에 한해서는 위에 나열된 lock API의 원본 lock API에서 모두 파생되어 나타낸 함수라는 것을 알 수 있다. 따라서 위에서 제시한 6개의 lock API가 있으면 나머지 수많은 lock API도 제어할 수 있게 된다.
예측 불가능한 인터럽트의 처리
예측 불가능한 인터럽트를 처리하는 경우는 일반적으로 외부 인터럽트의 처리 경우를 들 수 있다. Linux에서는 다른OS와는 다르게 인터럽트를 크게 두 가지로 나눌 수 있다. 인터럽트는 두 가지 목표, 즉 인터럽트 핸들러를 최대한 빨리 수행해야 하지만 한편으로는 많은 추가적인 일을 해야 하는 두 가지 상충된 목표를 가지고 있다. 따라서 인터럽트의 처리를 두 단계로 분리하여 처리한다. 이 두 단계 중 인터럽트 핸들러는 탑하프(top half, 하드웨어 인터럽트)에 해당한다. 즉 인터럽트가 발생하는 즉시 실행되어 타임 크리티컬(time critical)한 작업들을 수행한다. 나중에 해도 무관한 작업들은 보톰하프(bottom half, 소프트 인터럽트, 소프트웨어 인터럽트와는 다른 개념)가 실행될 때까지 지연된다. 보톰하프는 좀 더 편한 시간에 모든 인터럽트를 활성화 시킨 상태에서 실행된다(일반적으로 인터럽트 핸들러가 작업을 끝내면 실행된다). 네트워크 랜 카드를 예로 들어보면 랜 카드가 패킷을 받게 되면 커널에 그 사실을 알려야 한다. 이 과정은 즉시 처리되어야 할 중요한 부분이므로 바로 인터럽트를 발생시킨다. 인터럽트 핸들러는 연관된 다른 하드웨어에 자신의 실행을 알린 후, 도착한 패킷들을 메모리로 복사하는 작업 등을 한다. 그리고 패킷에 대한 남은 처리 과정은 보톰하프에서 수행된다. 인터럽트에 대한 간단한 설명은 여기까지 하고 다시 본론으로 돌아가 보면 Livnux에서는 이 인터럽트 중 탑하프 즉 하드웨어 인터럽트를 이용하여 외부 인터럽트를 처리한다. 이번 프로젝트에서 사용할 보드가 FALinux사의 EZ-X5이므로 해당 보드의 GPIO포트를 사용할 수 있다. GPIO는 인터럽트용으로도 사용할 수 있으므로 이를 이용한다. 결론부터 말하면 예측 불가능한 인터럽트의 Non-Preemption section 처리는 또 다른 레벨의 인터럽트를 만들어서 Non-Preemption section에 들어가더라도 새롭게 만든 인터럽트만은 받아들일 수 있도록 하는 개념이다. 앞의 예측 가능한 인터럽트의 처리 마지막 부분에 lvn_disable_irq()가 바로 핵심인데 지금부터 설명하도록 하겠다. 먼저 기존의 local_irq_disable()을 본다.
#define raw_local_irq_disable() \
({\
unsigned long temp;\
__asm__ __volatile__(\
"mrs%0, cpsr@ local_irq_disable\n" \
"orr%0, %0, #128\n"\
"msrcpsr_c, %0"\
: "=r" (temp)\
:\
: "memory", "cc");\
})
mrs로 현재 cpsr 값을 0번 레지스터로 받아온다. 0번 레지스터에 128을 OR 논리연산을 해주면 원래의 cpsr의 8번째 비트가 1로 지정된다. cpsr에 대해서는 아래의 그림을 보자
32비트로 된 cpsr 레지스터는 현재 프로세서의 상태를 기록하고 있는 상태 레지스터이다. 마지막 부분의 N은 Negative 즉, 음수 플래그를 나타내고 Z는 Zero, C는 Carry, V는 Overflow 플래그를 나타내고 우리에게 가장 중요한 부분인 7번 비트가 인터럽트 활성화, 비활성화 여부를 결정해준다. 7번 비트가 1로 설정되어 있으면 인터럽트를 비활성화 시킨다. F는 인터럽트와 유사한 Fast Interrupt를 나타낸다. 일반적인 인터럽트의 경우 인터럽트 모드로 변경할 때 현재 모드에서 사용하고 있는 레지스터 값을 스택에 저장하기 위해서 메모리 접근을 해야 한다. 따라서 메모리 접근으로 인한 오버헤드가 생기기 마련이다. 하지만 Fast Interrupt의 경우 모드 전환을 위한 레지스터 8개가 따로 마련되어 있다. 이 8개의 레지스터를 이용하면 메모리 접근 수가 상대적으로 적어짐으로 인해 속도가 빨라지기 때문에 Fast Interrupt가 된다. T는 16비트 명령인 Thumb 명령의 사용을 의미하고, mode는 현재 모드를 나타내주는 비트이다. 다시 본론으로 돌아와서 8번째 비트인 I비트를 1로 해줘서 인터럽트를 비활성화 시키도록 만들고 이 값을 다시 cpsr에 저장해주면 그 때부터 인터럽트가 비활성화 된다. 이렇게 인터럽트를 활성화, 비활성화 할 때 하드웨어적으로 레지스터를 직접 조작하여 명령을 내리게 되는데 Livnux는 인터럽트의 상태를 소프트웨어적으로 조작하여 인터럽트를 비활성화 한다. 이렇게 되면 개별 인터럽트를 따로 조작할 수 있어서 기존의 인터럽트 제어 함수처럼 한꺼번에 인터럽트를 비활성화 시키지 않으면서 인터럽트를 따로 제어할 수 있게 되므로 커널에서 사용하는 인터럽트만 따로 비활성화 시켜 개발자가 원하는 인터럽트는 막지 않을 수 있게 된다. 새롭게 구현된 인터럽트 비활성화 모듈을 보자.
void lvn_disable_irq(void)
{
struct irq_desc *desc = irq_desc;
unsigned long flags;
int irq;
int irqnum_table[] = {10, 11, 13, 18, 19, 32, 31, 30, 29, 44};
for(irq = 0; irq < IRQTABLE_END ; irq++) {
if (irqnum_table[irq] >= NR_IRQS)
return;
desc = irq_desc + irqnum_table[irq];
spin_lock_irqsave(&desc->lock, flags);
if (!desc->depth++) {
desc->status |= IRQ_DISABLED;
desc->chip->disable(irqnum_table[irq]);
}
spin_unlock_irqrestore(&desc->lock, flags);
if (desc->action)
while (desc->status & IRQ_INPROGRESS)
cpu_relax();
desc = irq_desc;
}
}
irqnum_table은 제어할 인터럽트의 번호를 가진 테이블이다. 10, 11, 13, 18, 19, 44번은 기존에 커널에서 사용하는 인터럽트들이고 29번부터 32번까지가 프로젝트 검증 어플리케이션에서 사용하기 위해 선택된 인터럽트들이다. 먼저 인터럽트 디스크립터 테이블의 포인터를 받아와서( desc = irq_desc; ) irqnum_table에서 해당 인터럽트 번호를 받아오고 적합한 위치로 포인터를 이동( desc = irq_desc + irqnum_table[irq]; )시킨다. 그리고 현재 인터럽트의 상태를 비활성화 상태로 변경해주면( desc->status |= IRQ_DISABLED; ) 소프트웨어적으로 인터럽트의 비활성화가 된다. 이렇게 지정된 모든 인터럽트를 비활성화 해주면 livnux만을 위한 인터럽트 비활성화 함수가 완성된다. 이렇게 하는 이유는 바로 다음에 설명할 인터럽트의 우선순위를 위해서이다. 기존 linux는 앞에서 설명했듯이 인터럽트의 우선순위가 없다. 과거 UNIX에서는 응답성을 확보하기 위해 인터럽트 레벨 개념을 가지고 있었다. 이것은 높은 응답성을 필요로 하는 인터럽트에게 높은 우선순위를 주는 방식이다. 낮은 우선순위의 인터럽트 핸들러가 실행하고 있는 중에 우선순위가 높은 인터럽트가 발생하면, 우선순위가 낮은 인터럽트 핸들러를 중단하고 우선순위가 높은 인터럽트 핸들러를 실행한다. 그와 반대로 우선순위가 높은 인터럽트 핸들러가 실행이 종료될 때까지 우선순위가 낮은 인터럽트 요청은 보류 상태가 된다. Livnux도 이렇게 인터럽트가 RT Task에 직접 연관된다는 점과 응답성이 필요하다는 점 때문에 과거 UNIX가 가지고 있었던 인터럽트 레벨 개념을 도입했다. 과거 UNIX에서는 어떻게 구현했는지 알 수 없지만 비슷하지 않을까 생각한다. 다음은 인터럽트에 우선순위를 정해주는 모듈인 lvn_raw_request_irq_prio를 살펴보겠다. 인터럽트를 비활성화 해주는 함수와 유사해서 이해하기 쉬울 것이다.
void lvn_raw_request_irq_prio(int prio)
{
struct irq_desc *desc = irq_desc;
unsigned long flags;
int i, irq;
int irqnum_table[] = {10, 11, 13, 18, 19, 32, 31, 30, 29, 44};
if ( prio > LVN_MAX_IRQ_PRIO )
return;
for (i = 1; i < prio; i++)
irqnum_table[IRQTABLE_END - i] =
LVN_DEFAULT_IRQ_NUM;
for(irq = 0; irq < IRQTABLE_END ; irq++) {
if (irqnum_table[irq] >= NR_IRQS)
return;
desc = irq_desc + irqnum_table[irq];
spin_lock_irqsave(&desc->lock, flags);
if (!desc->depth++) {
desc->status |= IRQ_DISABLED;
desc->chip->disable(irqnum_table[irq]);
}
spin_unlock_irqrestore(&desc->lock, flags);
if (desc->action)
while (desc->status & IRQ_INPROGRESS)
cpu_relax();
desc = irq_desc;
}
}
변경된 부분은 굵은 글씨로 표시된 부분이다. 기본적인 개념은 다음과 같다. 예를 들어 29번 인터럽트가 우선순위가 가장 높을 때는 29번 인터럽트가 동작할 때는 29번 인터럽트 외 다른 인터럽트를 모두 비활성화 시키는 것이다. 30번 인터럽트를 예로 들어보면 30번 인터럽트가 우선순위가 2번째일 때는 29번과 30번 인터럽트를 외 다른 인터럽트를 비활성화 시킨다. 이런 개념으로 인터럽트에 레벨을 주었다. 코드 설명은 간단하니 생략하도록 하겠다. 실제 활용하는 예를 보면 아래와 같다
irqreturn_t interrupt_1(int irq, void* data)
{
lvn_request_irq_prio(1);
... 리얼타임 작업 ...
lvn_release_irq_prio(1);
}
RT Task의 시작 부분에 lvn_request_irq_prio()로 우선순위를 주고 마지막 부분에 lvn_release_irq_prio()로 우선순위 해제를 해준다. 이렇게 하면 간단한 작업으로 인터럽트에 우선순위를 줄 수 있게 된다.
High-Resolution Timer
리눅스 타이머는 2가지 중요한 역할이 있다. 첫 번째가 시각을 정확하게 카운트하는 것이고, 두 번째가 일정 시간이 지나면 지정한 처리를 수행하는, 즉 제한 시간을 관리하는 것이다. 리눅스 커널은 주기적으로 발생하는 타이머 인터럽트를 이용하여 이러한 처리를 수행할 타이머 기능을 동작시킨다. 타이머 관련 변수로 jiffies 라는 것이 있다. 리눅스 커널이 부팅된 뒤에 발생한 타이머 인터럽트 횟수를 세고 있으며, 리눅스 내부의 제한 시간을 관리하는 처리 기준으로 이용된다. ARM 프로세서에서는 타이머 인터럽트가 1,000HZ마다 발생하게 되는데 다른 말로는 1000분의 1초마다 인터럽트가 발생하게 된다는 뜻이다. 먼저 타이머 인터럽트가 발생하는 원리를 살펴보겠다. EZ-X5가 사용하는 프로세서인 PXA255는 OSCR, OSMR, OIRE, OSSR등 네 개의 레지스터로 OS타이머를 설정할 수 있다. 먼저 OSCR부터 살펴보면 PXA255는 3.6864MHz의 오실레이터 클록 입력을 받는다. 따라서 1초 후에 3686400개의 클록이 생기게 되는데 이 횟수를 저장하고 있는 것이 OSCR로서 OS timer Counter Register로 불린다. 다음으로 OSMR을 살펴보면 OS timer Match Register로서 OSMR0~3까지 모두 네 개가 존재한다. OSMR0는 리눅스에서 이미 사용하고 있으므로 OSMR1을 사용하도록 한다. OSMR의 역할을 이름에서도 알 수 있듯이 어떤 값과 같은지 비교할 때 사용하는 값이다. 계속 증가하는 OSCR값과 사용자가 지정할 수 있는 OSMR값과 일치할 때 인터럽트가 발생하는 것이다. 이 때 인터럽트가 발생하도록 허락할지 결정을 해주는 것이 OIER로서 OS timer Interrupt Enable Register로 불린다. OIER의 하위 네 개 비트가 4개의 OS Timer를 제어하는 비트이므로 2번째 비트를 제어해주면 OS Timer1 인터럽트가 발생되도록 제어할 수 있다. OSSR 레지스터는 OS timer Status Register로 불리며 인터럽트가 발생하면 기록되는 레지스터이다. 여기까지가 OS Timer에 대한 기본 지식이고 이것을 바탕으로 초기화 해주는 코드를 작성해보면 아래와 같다.
static void __init lvn_pxa_hrtimer_init(void)
{
...
OIER = 0;
OSSR = 0xf;
setup_irq(IRQ_OST1, &lvn_pxa_hrtimer_irq);
local_irq_save(flags);
OIER = OIER_E1;
OSMR1 = OSCR + LATCH_HPET;
local_irq_restore(flags);
}
OIER과 OSSR을 먼저 초기화 시켜주고 OS Timer1(2번째 타이머, 타이머 0번부터 시작한다)을 등록한다. 그리고 OIER의 2번째 레지스터를 활성화 시켜서 OS Timer1의 인터럽트가 허용되도록 설정한다. 그리고 OSMR1의 초기값은 현재 OSCR값을 기본으로 LATCH_HPET라는 값을 더하여 넣어주면 다음 인터럽트가 발생할 타이밍을 설정할 수 있다. LATCH_HPET에는 보통 발생하는 타이머 인터럽트보다 100배 작은 값이 들어가 있어서 기존의 1,000분의 1초마다 발생하던 인터럽트를 100,000분의 1초마다 발생하도록 설정할 수 있다. 이 뜻은 10us의 정밀도를 가진 인터럽트가 생기는 것을 의미한다. 기존의 리눅스는 1ms의 정밀도를 가지는 타이머 인터럽트를 사용한다. 리눅스도 이렇게 정밀도 높은 타이머를 가지면 왜 안될까? 이유는 타이머 인터럽트가 너무 자주 발생하게 되면 이 것을 처리하느라 다른 일을 하지 못하게 되기 때문이다. 리눅스는 타이머 인터럽트가 발생할 때마다 처리해야 할 일이 많기 때문에 오버헤드가 가장 적당한 시간으로 1ms의 타이머 인터럽트 간격을 설정해놓았다. 하지만 Livnux의 두 번째 타이머는 인터럽트가 발생할 때마다 새로 생성한 타이머 리스트만 확인하는 간단한 작업만 필요하기 때문에 상대적으로 부담이 적게 된다. 다시 본론으로 돌아가서 인터럽트가 발생할 때마다 호출해주는 핸들러를 살펴보면 아래와 같다.
static irqreturn_t
lvn_pxa_hrtimer_interrupt(int irq, void *dev_id)
{
...
do {
lvn_hrtimer_run_queues();
OSSR = OSSR_M1;
next_match = (OSMR1 += LATCH_HPET);
} while( (signed long)(next_match - OSCR) <= 8 );
...
}
인터럽트가 발생할 때마다 새로운 타이머 리스트를 확인해주는 함수인 lvn_hrtimer_run_queues()을 불러주고 다음 인터럽트 발생 값을 새로 설정해주고( OSMR1 += LATCH_HPET ) 마무리하게 된다. 여기서 말한 새롭게 만든 타이머 리스트는 기존의 타이머 리스트와 유사한 구조를 가지지만 RT-Task를 사용할 때만 이용할 수 있는 타이머 리스트이다.
Memory Management - Swap
Linux에서는 각각의 메모리의 공간을 페이지 단위로 관리를 한다. 하지만 모든 시스템에 가상메모리가 제공하는 물리적인 메모리를 제공하지 않기 때문에, 보조 기억장치를 이용하여, 사용하지 않는 메모리의 페이지를 보조기억장치에 Swap-out함으로 써, 모든 프로세서에서 필요한 메모리를 확보한다. 스왑의 대상이 되는 페이지들은 아래와 같다.
사용자 모드의 스택이나 힙
비공개 메모리 매핑에 속하는 더티 페이지
IPC 공유 메모리 구역에 속하는 페이지
Swap Cache
page-out된 모든 메모리가 바로 디스크에 저장되는 것은 아니며, 페이지 캐시와 동일한 구조를 사용하는 스왑 캐시에 보관되며, 스왑 캐시에서 오래된 페이지만이 디스크에 쓰여지게 된다. 이 스왑 캐시는 2개의 프로세서가 공유하는 페이지가 스왑 됬을 경우, 하나의 프로세서가 페이지영역을 참조하여 swap-in 작업중에 다른 프로세서가 스왑된 페이지영역에 접근하여 동시에 같은 페이지가 swap-in되지 않도록 예방하는 기능도 가지고 있다.
Swap-out
Linux에서는 물리적인 메모리가 부족할 경우 PFRA(Page Frame Reclaiming Algorithm) 알고리즘에 의해 사용되지 않는 오래된 페이지(LRU List 활용)를 Swap-out 한다. Swap의 대상 페이지가 정해지면 지정된 디스크 스왑 영역에, 물리메모리의 페이지를 디스크에 복사한다(실질적으론 스왑캐시에 보관된후 디스크에 저장된다). 복사된 각각의 page struct의 flags의 PG_mappedtodisk을 체크하여 해당 페이지가 swap이 된 것을 표시한다.
스왑의 시작은 메모리 부족에서 시작하며, PFRA알고리즘 내부에서 호출한 shrink_page_list함수에서 스왑할 대상의 페이지를 검사하여 익명의 페이지이며, 스왑 캐시에 대상 페이지가 없다면 add_to_swap()함수를 호출한다. add_to_swap()함수는 새로운 페이지 슬롯을 할당하고, 해당 페이지를 페이지 스왑 캐시에 추가한다. add_to_swap은 최종적으로 자신을 호출한 shrink_page_list()함수가 페이지를 디스크에 반드시 쓰도록 페이지 디스크립터에 PG_uptodate와 PG_dirty 플래그를 설정하고 리턴한다.
이후 shrink_page_list()함수에서 페이지의 PG_dirty를 확인하여 pageout()함수를 호출하여 최종적으로 디스크에 페이지를 쓰게 된다.
Swap-in
프로세스가 메모리 영역에 접근했을 때, 페이지 폴트가 발생했을 경우, 다음 조건을 만족 했을 경우 스왑 인 연산을 수행한다.
예외를 발생 시킨 주소가, 현재 프로세스의 메모리 구역이다.
페이지가 메모리에 없다.
페이지에 대응하는 페이지 테이블 엔트리가 NULL은 아니지만 Dirty 비트가 지워진 경우, 즉 스왑 아웃된 경우이다.
페이지 폴트가 발생하고, 위의 조건이 맞는 경우 커널은 do_swap_page 함수를 호출한다. do_swap_page함수는 페이지가 이미 스왑캐시에 있다면 1을 반환하고, 페이지를 이미 스왑 영역에서 읽었으면 2를 반환한다.
프로세스의 Swap 방지
RTOS에서는 프로세서의 반응성이 중요하다. 속도가 빠른 메모리내에서 RTOS에 필요한 데이터를 읽는 속도가 보장되지만, 만약 지정된 데이터가 스왑아웃되어, 스와핑작업이 일어난다면 IO가 일어나게되고 응답성은 늦어지게 된다. 그렇기 때문에 특정 프로세스(RT_Task)는 스왑이 발생되지 않도록 해야한다. 대부분의 RTOS에서는 스왑을 지원하지 않으며, 그 이유는 특정한 시스템에 종속적인 RTOS이기 때문에 메모리크기 한도내에서 프로그램을 제작하기 때문에, 지정된 메모리가 부족한 현상이 잘 일어나지 않기 때문이다.
일반적인 호환성을 지원하기 위해 Linvux에서는 특정테스크가 스왑아웃 되지 않도록 하기 위해 mlock기법을 활용하여 RT_Task를 생성한다.
mlock, mlockall
리눅스에서는 프로세스의 메모리가 페이징에 걸리지 않기 위해 mlock과 mlockall이라는 시스템콜을 지원한다. mlock을 지정된 크기의 메모리를 lock하며, mlockall을 인자를 통해, 프로세스에 의해 생성된 모든 메모리영역, 또는 이후에 생성되는 모든 영역을 lock할 수 있다. 원형은 다음과 같다.
int mlock(const void *addr, size_t len); int munlock(const void *addr, size_t len); int mlockall(int flags); int munlockall(void); |
lock이외에도 다시 해제를 위한 munlock과 munlockall을 제공한다.
mlock의 경우 모든 지정된 크기의 영역의 메모리를 VM_LOCKED함으로써 swap을 방지하고 mlockall은 2가지의 flag가 존재하는데 mlockall을 실행한 부분의 이전의 모든 메모리를 lock하는 부분과 이후에 생성되는 모든 메모리를 lock하는 부분으로 나뉜다. 2가지의 flag모두 실행 할 수 있다.
리눅스 커널의 mm/mlock.c 의 구조이다.
mlock, mlockall 모두 최종적으로 mlock_fixup을 호출한다.
static int do_mlock(unsigned long start, size_t len, int on); static int do_mlockall(int flags) |
do_mlock은 on인자가 1일 경우 할당이며, 0이면 해제이다. do_mlockall은 2개의 기본인자(MCL_CURRENT, MCL_FUTURE)가 있으며, 0이면 해제이다.
MCL_CURRNET는 명령어가 실행한 이전 시점의 모든 메모리를 lock하며, MCL_FUTURE는 실행한 시점 이후의 모든 메모리를 할당한다.
메모리 LOCK의 구조는 현재의 task에 할당된 vm_area_struct의 vm_flags를 VM_LOCKED으로 설정하는것으로 이루어지며 mlock_fixup에서 만약 lock하고자하는 vm_area가 swap_in 되어 있다면 swap_off한 뒤, vm_flags를 VM_LOCKED한다. 설정하는 구조로보아 vm_area에 할당된 메모리의 크기 별로 lock이 이루어지며, 이보다 작은 크기를 lock 할 경우 그 메모리가 포함된 모든 영역을 lock한다.
mlockall의 MCL_FUTERE는 현재 task의 task_struct->mm->def_flags 를 VM_LOCKED로 설정하는 것으로 끝이 나며, 이는 메모리의 할당할 때 이 값을 참조하여 VM_LOCK하는 것으로 보인다.
해제는 모든 영역의 VM_LOCK flag를 0으로 하는 것으로 설정된다.
Livnux 검증
Livnux를 검증하는 방법은 크게 두 가지가 있다. 첫째 RT Task의 응답성을 확인하여 운영체제의 성능을 측정하는 방법과 실제로 RT Task를 만들어서 제대로 돌아가는지 눈으로 확인하는 방법이 있다. 먼저 응답성을 측정하는 대표적으로 알려진 방법을 살펴보면 sleep 함수가 필요하다.
(1) 현재 시간을 측정한다 : t1
(2) X 시간동안 Sleep을 한다.
(3) 현재 시간을 측정한다 : t2
가장 이상적인 상황은 t2값과 t1 + X값이 같은 것이다. 이 말은 운영체제의 응답성이 0이라는 뜻이다. 하지만 이런 이상적인 상황은 나올 수 없는 경우가 대부분이므로 먼저 시중에 나와 있는 다른 리얼타임 리눅스들의 응답성을 비교해보겠다.
구분 |
최대 |
최소 |
평균 |
RTLinux |
42.95 |
6.52 |
7.86 |
레모닉스 |
36.83 |
5.58 |
8.16 |
Linux |
7,201.25 |
2.81 |
29.89 |
Livnux도 위에서 말한 sleep 함수를 이용하여 응답성을 측정해보았다. 10,000회 정도 측정해본 결과이다.
결과 값이 최소 4us, 평균 8us, 최대 61us 정도가 나왔다. 응답성이라는 수치로는 괜찮은 수준이다. 물론 Livnux가 단지 응답성이라는 수치 하나만으로 RTLinux, Lemonix와 어깨를 나란히 한다고 할 수는 없다. 하지만 이 정도면 개인적으로는 상당히 만족하는 결과라고 생각된다. 물론 Livnux의 올바른 성능 측정을 위해 안정성 측면이나 기능성 같은 측면은 좀 더 많은 스트레스 실험을 통해 검증해보아야 한다.
다음은 직접 RT Task를 만들어서 검증해보는 방법이다. 두 가지의 RT Task로 검증을 할 것이다. 삼각파 발생 프로그램을 제작하여 오실로스코프로 확인하는 것이 첫 번째 검증 방법이다. 일반적인 방법으로는 마이크로 세컨드 단위로 작동하는 RTOS의 검증을 사람의 눈으로 확인 할 수 없기 때문에 이렇게 오실로스코프로 확인하는 방법을 채택하였다. 보드에서 매우 짧은 주기로 신호를 발생(10us)시키는 RT-Task를 만들고 이 데이터를 받아서 삼각파로 변환하여 출력해주는 모듈을 달아서 삼각파의 모양의 변화로 RT-Task를 검증한다. 아래 사진은 따로 제작한 간단한 삼각파 발생 모듈이다.
EZ-X5보드에서 RT-Task로 10us마다 신호를 위의 모듈로 보내고 삼각파를 발생시키게 되면 Livnux는 항상 고른 모양의 삼각파를 발생시킬 것이다. RT-Task를 진행하면서 신호를 보내고 다음 신호를 보내기 전에 커널 내부 작업으로 인해 Non-Preemption Section에 들어가려고 해도 Livnux의 관리모듈이 이를 저지시켜서 신호가 지정된 시간에 나갈 수 있도록 하기 때문이다. 이를 직접 사진으로 확인해 보면 아래와 같다.
<livnux에서 삼각파를 발생시킨 경우>
하지만 일반 리눅스에서 같은 Task를 돌릴 경우 대부분 신호가 잘 나오다가도 가끔씩 커널이 Non-Preemption Section에 진입해 있는 시간동안 신호를 내보내야 될 시간이 겹치게 되어 지정된 시간에 신호를 보낼 수 없어서 한 번씩 찌그러진 삼각파가 나올 것이다. 이를 역시 사진으로 확인해보면 아래와 같다.
<일반 linux에서 삼각파를 발생시킨 경우>
Train Management System
Livnux의 응답성 및 인터럽트 우선순위에 따른 선점을 확인하기 위해 열차 관리 시스템을 구성하였다. 이 시스템의 목적은 인터럽트가 발생했을 경우, 인터럽트 우선순위에 의해 우선처리를 함으로써, 기존 리눅스에서 발생하는 우선순위 방식과 차이를 둠으로써 실시간으로 되어야 하는 작업을 안정적으로 실행하는지에 대한 테스트를 하기 위함이다. TMS(Train Management System)은 기차 감지과 게이트 구성을 위한 하드웨어와 인터럽트 핸들러를 위한 디바이스 드라이버, 사용자 어플리케이션으로 나뉜다. 거리감지용 초음파 센서에서 기차가 감지되거나, 감지된 상태에서 감지가 사라질 경우, 디바이스 드라이버에서 인터럽트를 발생시킨다. 인터럽트 핸들러에서는 현재 초음파센서의 상태를 확인하여 기차가 감지된 상태이면 게이트를 열고, 감지가 중단된 상태라면 게이트를 닫는다. 인터럽트가 발생되면 사용자 프로그램에 시그널을 발생시켜 현재 게이트의 상태를 표출한다.
하드웨어 구성
- EZ-X5
- Hitec HS-5645MG Digtal Servo Moter
- SHARP GP2Y0A21YK Sensor
- OPAMP
- 가변저항
Linux에서 서보모터를 제어하기 위해 연속적인 파형생성이 필요한 아날로그 서보모터보다 한 번의 파형으로 지정된 각도로 움직이는 디지털 서보 모터를 활용하였다. 기차가 지정된 위치에 온 것을 감지하기 위해 초음파 거리감지 센서를 활용하였으며, 거리 값을 조절하기 위해 OPAMP와 가변 저항을 사용하였다. 서보모터 제어와 거리 감지센서 모두 EZ-X5에서 사용하는 PXA255의 GPIO를 이용하였다.
PXA255에서 GPIO Port의 제어
Linux에서 GPIO Port를 제어하기 위해서는 디바이스 드라이버 단에서 PXA255의 레지스터 값을 수정함으로써 활용이 가능하다. EZ-X5(PXA255)에서는 85개의 GPIO Port가 존재하며, 하나의 GPIO Port마다 하나의 Pin이 존재한다. 모든 GPIO Port는 input/output으로 프로그램되거나, 인터럽트 source로 활용할 수 있다.
PXA255에서 활용하는 GPIO 레지스터는 다음과 같다.
핀 특성 부여용 ■ GPDR - GPIO 입출력 방향 설정 레지스터 ■ GAFR - GPIO 이외의 기능으로 사용 허가 설정 레지스터 ■ GRER - GPIO 입력 상승 엣지 검출 허가 레지스터 ■ GFER - GPIO 입력 하강 엣지 검출 허가 레지스터 데이터 출력 및 검출용 레지스터 ■ GPSR - GPIO 출력 셋 레지스터 ■ GPCR - GPIO 출력 클리어 레지스터 ■ GPLR - GPIO 입력 레벨 검출 레지스터 ■ GEDR - GPIO 입력 엣지 검출 레지스터 |
각각의 레지스터는 1byte로 32개의 bit를 셋할 수 있다. PXA255에서는 85개의 GPIO가 존재하므로, 동일한 이름의 레지스터가 0~2까지 3개가 존재하며, GPDR0, GPDR1, GPDR2와 같은 이름으로 지정되어 있으며 하나의 레지스터는 32개의 GPIO를 제어 할 수 있다.
거리감지 센서 / 서보모터 제어
GPIO 활용을 위해서 우선 GPDR레지스터를 각각의 용도에 맞게 설정한다. GPDR레지스터를 0으로 초기화할 경우 입력용으로 사용하며, 1로 설정할 경우 출력용으로 사용할 수 있다. GAFR레지스터는 총 4개의 부가기능을 설정하기 위해 GPIO Port 하나당 2개의 bit를 활용하는데 이를 위해서 GAFR레지스터는 GAFR_L와 GAFR_U로 구성되어 있으며, GAFR_L은 32개의 GPIO_Port중 앞의 16개, GAFR_U는 뒤의 16개가 지정되어 있으며, 각각 2개의 bit가 붙어 있어 이를 설정함으로 각각의 Port에 부가기능을 줄수 있다. TMS에서는 순수하게 출력과 입력용으로 사용하기 때문에 부가기능 레지스터인 GAFR레지스터들은 0으로 설정한다.
#define lvn_set_bit(data, loc)((data) |= (0x01 << (loc)))
#define lvn_set_2bit(data, loc)((data) |= (0x03 << (loc)))
#define lvn_clear_bit(data, loc)((data) &= ~(0x01 << (loc)))
#define lvn_clear_2bit(data, loc)((data) &= ~(0x03 << (loc)))
#define lvn_check_bit(data, loc)((data) & (0x01 << (loc)))
void gpio_init(void)
{
// Sensor GPIO Init
lvn_clear_bit(GPDR0, SENSOR_NUM1);
lvn_clear_bit(GPDR0, SENSOR_NUM2);
lvn_clear_bit(GPDR0, SENSOR_NUM3);
// Sensor GPIO Alternative Funcion Disable
lvn_clear_2bit(GAFR0_L, check_gpio_num(SENSOR_NUM1));
lvn_clear_2bit(GAFR0_L, check_gpio_num(SENSOR_NUM2));
lvn_clear_2bit(GAFR0_L, check_gpio_num(SENSOR_NUM3));
// Servo GPIO Init
lvn_set_bit(GPDR0, SERVO_NUM1);
lvn_set_bit(GPDR0, SERVO_NUM2);
lvn_set_bit(GPDR0, SERVO_NUM3);
// Servo GPIO Alternative Funcion Disable
lvn_clear_2bit(GAFR0_U, check_gpio_num(SERVO_NUM1));
lvn_clear_2bit(GAFR0_U, check_gpio_num(SERVO_NUM2));
lvn_clear_2bit(GAFR0_U, check_gpio_num(SERVO_NUM3));
}
거리감지 센서 값의 측정을 위해서는 해당 GPLR레지스터를 0으로 설정하여 입력전용으로 만든 뒤, Linux 디바이스 드라이버에서 해당 GPIO IRQ 인터럽트 설정을 RSING과 FALLING 설정을 동시에 해준다. 이는 GPIO포트 값이 0에서 1이 됐을 때와, 1에서 0이 됐을 때 모두 인터럽트를 발생시키기 위함이다. 인터럽트가 발생했을 때, 인터럽트 함수 루틴 내에서 현재 GPIO값을 비교함으로써 기차가 진입 했을지인지, 통과한 상태인지를 판단하여 게이트용 서보모터를 제어한다.
서보모터제어를 위해서는 GPDR레지스터를 출력용으로 설정한 뒤, 파형 생성을 위하 GPSR레지스터를 1로 셋하여 GPIO Port에 High 값을 출력한 뒤, 지정된 시간만큼 udelay()함수로 대기한 뒤, GPCR레지스터를 1로 셋하여 GPIO Port에 Low값을 출력함으로써, 서보모터 제어를 위한 파형 생성이 가능하다. 디지털 서보 모터는 20ms안에 0.7~2.0의 크기의 파형을 줌으로써 각도 조절을 할 수 있는데, 게이트 제어를 위해서는 0~90도의 각만 필요하므로 0.7ms과 1.5ms의 파형을 생산한다. 또한 인터럽트 루틴 내에 스핀락을 걸어줌으로써 동일한 인터럽트 문이 중복 발생했을 경우, 중복된 인터럽트를 방지함으로써 올바른 파형생산가능하다.
irqreturn_t sensor_interrupt(int irq, void *data)
{
int servo_num = 0;// 제어를 위한 서버번호
int sensor_num = 0;// 제어를 위한 센서번호
int offset = 0;// Gate 상태를 저장하기 위한 Offset
int i ,slock;
spin_lock(&slock);
switch(irq){ // IRQ가 발생한 번호 확인
case IRQ_GPIO(SENSOR_NUM1) :
servo_num = SERVO_NUM1;
sensor_num = SENSOR_NUM1;
offset = 1;
break;
case IRQ_GPIO(SENSOR_NUM2) :
servo_num = SERVO_NUM2;
sensor_num = SENSOR_NUM2;
offset = 2;
break;
case IRQ_GPIO(SENSOR_NUM3) :
servo_num = SERVO_NUM3;
sensor_num = SENSOR_NUM3;
offset = 3;
break;
}
if(lvn_check_bit(GPLR0, sensor_num))
// 센서의 GPIO가 On일 경우, Gate Open
{
lvn_set_bit(GPSR0, servo_num);
udelay(1700);
lvn_set_bit(GPCR0, servo_num);
lvn_set_bit(gate_state, offset);
kill_fasync(&train_async_queue, SIGIO, POLL_IN);// App에 시그널 전송
}
else
// 센서의 GPIO가 off일 경우, Gate Close
{
for(i = 0; i < 1000; i++)
udelay(1000);
// Gate충돌을 대비하기 위한 1초 지연
lvn_set_bit(GPSR0, servo_num);
udelay(750);
lvn_set_bit(GPCR0, servo_num);
lvn_clear_bit(gate_state, offset);
kill_fasync(&train_async_queue, SIGIO, POLL_IN);// App에 시그널 전송
}
spin_unlock(&slock);
return IRQ_HANDLED;
}