동적 커널: 모듈화된 디바이스 드라이버

저자: Alessandro Rubini

번역자: Alphard

커널 모듈은 최근 리눅스 커널의 멋진 특징이다. 대부분 사용자들에게 모듈이란 단지 플로피 드라이버를 보통 때엔 커널에서 제외시켜 두어서 메모리를 조금 더 남겨 둘 수 있는 방법 정도로 보일 수도 있지만, 모듈을 썼을 때의 진짜 이점은 커널 소스를 패치하지 않고도 새로운 드라이버를 쓸 수 있다는 것이다. 앞으로 커널 코너의 몇 장에 걸쳐서 Georg Zezschwitz와 필자는 흔한 디자인 실수 없이 강력한 모듈을 만드는 방법이라는 "예술"에 대해 소개하려고 한다.

디바이스란?

하드웨어 디바이스의 특징과 직접적으로 관련되는 만큼, 디바이스 드라이버는 컴퓨터에서 돌아가는 소프트웨어 중 가장 저수준의 프로그램이다.

디바이스 드라이버의 개념은 사실 아주 추상적인 것이다. 심지어 커널 자체도 컴퓨터라는 디바이스용의 커다란 디바이스 드라이버로 취급할 수 있다. 하지만 컴퓨터는 통합적인 한 개체로 보기보다는 주변장치가 장착된 CPU로 보는 것이 일반적이므로, 커널은 디바이스 드라이버들 위에서 돌아가는 응용 프로그램으로 볼 수 있다. 각각의 드라이버들은 컴퓨터의 각 부분을 담당하며, 커널 자체는 사용할 수 있는 디바이스들 상에서 작업 스케줄링과 파일시스템 접근을 담당한다.

프로세서 드라이버나 메모리 드라이버 같은 몇 개의 필수적인 드라이버들은 커널에 내장되어 있고, 컴퓨터는 다른 드라이버들이 있건 없건 – 일반 사용자들에게 콘솔 드라이버도 네트웍 드라이버도 없는 커널이란 무의미하긴 하지만 – 동작한다.

위의 설명은 상당히 단순화된 것이고, 심지어 조금은 철학적이기마저도 한 것이다. 실제 드라이버의 동작은 그리 간단치 않고, 가끔은 드라이버와 커널 자체를 확실히 구분하기 힘든 경우도 있다.

유닉스의 세계에서는 네트웍 드라이버나 그 외의 몇 가지 복잡한 드라이버들은 커널에 속하고, 디바이스 드라이버라는 이름은 다음 셋 중 어딘가에 속하는 디바이스에 대한 저수준 소프트웨어 인터페이스를 지칭하는 데에만 쓰인다.

캐릭터 디바이스

파일로 볼 수 있는, 즉 읽어들이거나 써넣을 수 있는 것들. 콘솔(그러니까 모니터와 키보드) 그리고 직렬/병렬 포트들은 캐릭터 디바이스들이다. /dev/tty0, dev/cua0 파일 등을 통해 캐릭터 디바이스에 접근할 수 있다. 캐릭터 디바이스는 순차적으로만 접근할 수 있는 것이 보통이다.

블록 디바이스

역사적으로는 블록 사이즈(보통은 512 또는 1024바이트)의 배수 단위로만 읽고 쓸 수 있는 디바이스를 말한다. 여기에는 디스크처럼 파일시스템을 마운트할 수 있다. /dev/hda1 파일 등을 통해 이런 디바이스에 접근할 수 있다. 블록 디바이스의 각 블록들은 버퍼 캐시라는 곳에 캐시된다. 유닉스는 블록 디바이스에 상응하는 캐시되지 않는 캐릭터 디바이스도 제공하지만, 리눅스에서는 제공되지 않는다.

네트워크 인터페이스

네트워크 인터페이스는 디바이스-파일 추상화에 속하지 않는다. 네트워크 인터페이스는 이름(eth0 나 plip1 같은)으로 식별되지만 파일시스템에는 상응하지 않는다. 이론상으로는 그럴 수도 있겠지만, 프로그래밍과 성능의 관점에서 보자면 별로 실용적이지 못한 방법이다. 네트워크 인터페이스는 단지 패킷만을 전달하고, 파일 추상화는 패킷 구조의 자료들을 효율적으로 다룰 수 없다.

위의 설명은 상당히 단순화된 것이고, 여러 유닉스 변종들은 블록 디바이스의 기준에 대해 세부적으로는 조금씩 다르다. 그러나 실제로는 커널 내부에서나 차이가 나는 것인 데다가 여기서는 블록 디바이스에 대해 자세히 다루지도 않을 것이므로 크게 신경 쓰지 않아도 된다.

앞 설명에는 커널 자체가 디바이스 드라이버에게 라이브러리와 같은 역할을 한다는 내용이 빠져 있다. 드라이버는 커널에 서비스를 요청하게 된다. 모듈은 메모리 배치나 파일시스템 접근 등등의 기능들을 요청할 수 있어야 한다.

적재 가능한 모듈들에 한해서라면, 이 세 종류의 드라이버들 중 어떤 것도 모듈로 만들 수 있다. 심지어 파일시스템조차 모듈로 구현할 수도 있지만, 이것은 우리가 설명하고자 하는 범위 밖이다.

이 칼럼은 캐릭터 디바이스 드라이버에 대해 집중적으로 설명한다. 왜냐하면 특수한(또는 직접 만든) 하드웨어들은 대부분의 경우 캐릭터 디바이스 드라이버 추상화에 잘 들어맞기 때문이다. 이 세 종류 사이에는 차이가 별로 없기 때문에 혼동을 피하기 위해 제일 일반적인 종류에 대해서만 설명하기로 한다.

블록 디바이스에 대한 소개는 리눅스 저널의 9,10,11번 이슈나 KHG에서 찾아볼 수 있을 것이다. 양쪽 다 약간 오래되긴 했지만, 이 칼럼과 같이 본다면 시작하기에 충분한 정보를 얻을 수 있을 것이다.

모듈이란 무엇인가?

모듈은 커널에 디바이스 드라이버로 등록되어, 커널이 디바이스와 교신해야 할 때 호출하면 다른 커널 함수들을 호출하여 그 일을 해 주는 코드 부분이다. 모듈은 커널 자체와 디바이스 사이의 깔끔한 인터페이스를 사용하기 때문에, 작성하기도 쉽고 커널 소스가 어지럽혀지는 것을 방지해줄 수도 있다.

모듈은 목적 코드(링크시켜서는 안된다. 컴파일된 코드를 .o 파일로 남겨두라)로 컴파일되어야 하며, 실행중인 커널에는 insmod에 의해 적재된다. insmod 프로그램은 실행시간 링커로서, 실행중인 커널의 심벌 테이블을 사용해 모듈 내의 미정의 심벌들을 적당한 주소에 배당한다.

이것은 일반적인 C언어 프로그램처럼 모듈을 작성할 수 있다는 것을 의미하며, 보통 때 응용프로그램에서 printf(), fopen()을 호출하는 것처럼 정의하지 않은 함수를 호출할 수 있다는 것이다. 하지만 커널이 공용 함수로 제공하는 최소한의 외부 함수 집합만을 사용할 수 있다. insmod는 모듈이 커널 함수를 호출하는 곳에 커널 스페이스 내의 적당한 주소를 집어넣고 나서 모듈을 실행중인 리눅스 커널에 삽입한다.

만약 어떤 커널 함수가 공용인지 아닌지 모르겠다면, 그 이름을 커널의 소스 파일인 /usr/src/linux/kernel/ksyms.c 또는 런타임 테이블인 /proc/ksyms에서 찾아보면 된다.

모듈을 컴파일하기 위해서 make를 사용하고 싶다면 다음과 같은 간단한 makefile이 필요할 것이다.

TARGET = myname
ifdef DEBUG
# "extern inlines" 때문에 -O 플래그가 필요하다.
# gdb가 패치된 것이고 -g 플래그를 사용할 수 있다면 -g를 추가하라.
CFLAGS = -O -DDEBUG_$(TARGET) -D__KERNEL__ -Wall
else
CFLAGS = -O3 -D__KERNEL__ -fomit-frame-pointer
endif
all: $(TARGET).o

보다시피, 모듈을 만들기 위해서는 특별한 규칙이 필요한 게 아니라 단지 CFLAGS 에 옳은 값만 넣어주면 된다. 패치 없이는 gdb가 -g 플래그에 의해 제공되는 심벌 정보를 이용할 수 없기 때문에 디버그 지원 코드를 포함시키는 것이 좋다.

디버그 지원은 종종 메시지를 출력하는 여분의 코드를 드라이버 내에 추가하는 것을 의미한다. 디버깅을 위해 printk()를 사용하는 것도 강력한 방법이며, 디버거를 커널에 사용하는 것이라든가 /dev/mem을 살펴보는 것, 그리고 대단히 저수준인 다른 방법들도 대안이 될 수 있다. 인터넷에는 이런 방법들을 이용할 수 있게 도와주는 몇몇 도구들이 있지만, 이런 도구들을 사용하기 위해서는 gdb를 확실히 익혀두고 실제 커널 코드를 읽어두었어야 한다. 현재 모듈 제작에 사용되는 가장 흥미로운 도구는 실행중인 커널에 대해 gdb를 실행시키고 커널의 자료 구조 (이미 커널에 적재된 것들도 포함해서)를 살펴보거나 심지어는 바꿀 수도 있게 해 주는 kdebug-1.1이다. Kdebug는 sunsute.unc.edu와 그 미러 사이트들의 /pub/Linux/kernel에서 ftp로 얻을 수 있다.

조금 어려운 점은 표준 printf()함수에 해당하는 커널 함수인 printk()가 printf()와 정확히 똑같이 동작하지는 않는다는 것이다. 1.3.37 이전에는 printk()가 출력한 라인들은 보통 /var/adm/messages 에 들어갔지만, 그 후의 커널들은 직접 콘솔에 출력해버린다. 말끔한 로그인(출력이 syslogd를 통해 메시지 파일에만 저장되는)을 원한다면 KERN_DEBUG 심벌을 형식 문자열에 포함시켜야 한다. KERN_DEBUG와 그 외의 유사한 심벌들은 컴파일러에 의해 원래의 형식 문자열에 연결되는 단순한 문자열이다. 그러므로 KERN_DEBUG와 형식 문자열 사이에는 콤마를 넣어서는 안 된다. 이 심벌들을 찾아보면 설명도 함께 읽어볼 수 있을 것이다. 또한, printk()는 부동 소수점 형식을 지원하지 않는다.

syslog는 시스템 크래시를 대비해 메시지를 최대한 빨리 파일에 써넣으려 할 것이라는 것을 기억해 두기 바란다. 이것은 printk()를 과다하게 사용한 모듈은 로그인 속도를 눈에 띄게 저하시킬 것이며 디스크를 짧은 시간 안에 꽉 채워 버릴 수도 있다는 것이다.

모듈이 잘못 동작하는 경우 십중팔구는 커널의 Oops 메시지를 보게 될 것이다. Oops란 커널이 커널 코드에서 예외를 받았을 때 생기는 것이다. 다시 말해, Oops는 core 파일이 생기지 않는다는 점을 제외하면 사용자 공간에서의 segmentation fault 와 같은 것이다. 보통 이는 그 원인이 된 프로세스가 갑자기 파괴되고 메시지 파일에 저수준의 정보로 된 몇 줄이 남는 결과를 가져온다. 대부분 Oops 메시지는 널 포인터를 역참조하는 데서 일어난다.

재해를 이렇게 처리하는 것은 친절한 방법인 셈이고, 독자의 코드가 잘못되었을 때에도 독자는 안심할 수 있다. 대부분의 다른 유닉스들은 그 대신 커널 패닉을 일으킨다. 이것은 리눅스가 절대 패닉을 일으키지 않는다는 것은 아니다. 프로세스 밖에서, 그러니까 인터럽트 핸들러라든가 타이머 callbacks 와 같은 것들 내에서 동작하는 함수들을 작성할 때에는 패닉이 일어날 것에 대비해야만 한다.

Oops 메시지와 함께 주어지는 적은 양의 난해한 정보는 이 코드가 잘못 동작한 순간의 프로세서 상태를 나타내며, 이 에러가 어디서 일어났는지를 이해하는 데 사용될 수 있다. Oops의 정보를 좀 더 읽기 쉽게 출력해 주는 ksymoops라고 하는 도구도 있다. 여기에는 커널 맵이 필요할 것이고, 이것은 커널 컴파일 이후에 /usr/src/linux.System.map 이라는 파일로 저장된다. Ksymoops는 util-linux-2.4에 든 형태로 배포되었지만, 리눅스 1.3 개발과정에서 커널에 포함된 관계로 2.5부터는 제외되었다.

만약 Oops 메시지를 정말로 이해할 수 있다면, gdb를 오프라인으로 사용해서 원인이 된 함수를 역어셈블 하는 등 마음대로 사용할 수도 있다. 만약 Oops의 출력도 ksymoops의 출력도 이해할 수 없다면, printk()를 좀 더 집어넣고, 다시 컴파일한 뒤 버그를 재현해보는 것이 나을 것이다.

다음의 코드를 쓰면 디버깅 메시지를 좀 더 쉽게 다룰 수 있을 것이다. 이 코드는 반드시 모듈의 공통 헤더 파일에 들어가야 하고, 커널 코드(모듈)와 사용자 코드(응용프로그램) 양쪽에 다 쓸 수 있다. 하지만 이 코드는 gcc 전용이라는 점을 알아두기 바란다. 어차피 gcc에 의존적인 커널 모듈에 대해서는 그리 큰 문제가 되진 않을 것이다. 이 코드는 필자가 만들었던 ANSI 호환의 코드를 향상시키는 방법으로서 Linus Torvalds가 제안한 것이다.

#ifndef PDEBUG
# ifdef DEBUG_modulename
# ifdef __KERNEL__
# define PDEBUG(fmt, args…) printk (KERN_DEBUG fmt , ## args)
# else
# define PDEBUG(fmt, args…) fprintf (stderr, fmt , ## args)
# endif
# else
# define PDEBUG(fmt, args…)
# endif
#endif
#ifndef PDEBUGG
# define PDEBUGG(fmt, args…)
#endif

모듈을 -DDEBUG_modulename 옵션으로 컴파일했을 경우에, 이 코드 이후의 모든 PDEBUG("any %i or %s…\n", i, s); 명령은 메시지를 출력하겠지만 같은 인수를 갖는 PDEBUGG()는 아무 것도 하지 않을 것이다. 사용자 모드의 응용 프로그램들에서는 단지 메시지가 메시지 파일 대신에 stderr로 출력된다는 것만 제외하면 같은 결과를 보일 것이다.

이 코드를 사용하면 단지 G 한 개를 덧붙이거나 지우는 것으로 메시지 출력을 조절할 수 있을 것이다.

코드 만들기

이제 모듈에 어떤 종류의 코드가 들어가야 하는지 살펴보자. 간단한 답은 "필요한 건 뭐든지 상관없다" 이지만, 실제로는 모듈이 커널 코드이기 때문에 리눅스의 다른 부분과 잘 정의된 인터페이스를 가져야 한다는 것을 기억해 두어야 한다.

보통은 헤더를 포함시키는 데에서부터 시작한다. 제한은 여기서부터 시작된다. makefile에 정의되어 있지 않은 한은 헤더를 포함시키기 전에 반드시 __KERNEL__ 심벌을 정의해야 하고, … 체제에 속하는 헤더만을 포함시켜야 한다. 물론 당연히 그 모듈 전용 헤더를 포함시키는 것은 가능하지만, 절대로 printf()나 malloc()과 같은 라이브러리용의 헤더를 포함시켜서는 안 된다.

리스트 1의 코드는 일반적인 캐릭터 드라이버의 소스 첫 부분의 일례이다. 만일 모듈을 만들 생각이라면, 직접 이 부분을 베껴 쓰는 것보다는 갖고 있는 소스에서 이 부분을 잘라 붙이는 것이 더 쉬울 것이다.

#define __KERNEL__ /* 커널 코드임 */
#define MODULE /* 항상 모듈로서 동작함 */
– 중략 –
#include "modulename.h" /* your own material */

(헤더 이름이 전부 빠져 있는 관계로 대부분 생략합니다. 필요한 경우 실제 소스에서 찾아보시길. – 역주)

헤더들을 포함시킨 후에는 실제 코드 차례이다. 코드의 대부분을 차지하는 구체적인 드라이버 기능에 대해 설명하기 전에, 모듈이 적재되려면 두 개의 모듈용 함수가 반드시 필요하다는 사실을 짚어두는 것이 좋을 것이다.

int init_module(void);

void cleanup_module(void);

첫 번째는 모듈 초기화(관련 하드웨어를 찾아내고 적당한 커널 테이블에 드라이버를 등록하는 것)를 맡으며, 두 번째는 모듈에 할당되었던 자원을 돌려주고 커널 테이블에서 드라이버의 등록을 삭제하는 일을 맡는다.

만일 이 함수들이 없다면, insmod는 모듈을 적재할 수 없을 것이다.

init_module() 함수는 성공하면 0을, 실패하면 음수를 리턴한다. cleanup_module() 함수는 모듈을 제거할 수 있을 때에만 호출되기 때문에 리턴값은 void이다. 커널 모듈은 usage count(이에 대해서는 차후에 설명한다)를 갖고 있고, cleanup_module() 함수는 이 값이 0일 경우에만 호출된다.

이 두 함수의 개략적인 코드는 다음 회에 연재될 것이다. 제대로 모듈을 적재하고 제거하려면 기본적으로 이것들을 디자인해야만 하고, 덧붙여 몇 가지 세부사항도 반드시 다루어져야만 한다. 그러므로 여기서는 다음 달에 시시콜콜 설명하지 않고도 그 구조를 제시할 수 있도록 하기 위해 각 세부사항에 대해 소개하도록 한다.

메이저 번호 받기

캐릭터 드라이버와 블록 드라이버 어느 쪽이건 커널의 배열에 등록되어야만 한다. 이 단계는 드라이버를 사용하기 위한 기본 단계이다. init_module()이 종료된 후에는 드라이버의 코드 부분은 커널의 일부가 되므로, 미리 기능을 등록해 두지 않았다면 절대 호출할 수 없다. 리눅스는 대다수의 유닉스 변종들처럼 디바이스 드라이버들의 배열을 갖고 있어서, 각 드라이버들은 그 배열에서의 인덱스인 메이저 번호에 의해서 구별되게 된다.

디바이스의 메이저 번호는 그 디바이스 파일에 대한 ls -l 출력에서 처음으로 나타나는 숫자이다. 다른 하나는 (짐작했겠지만)마이너 번호이다. 같은 메이저 번호를 갖는 모든 디바이스(파일 노드)들은 같은 드라이버 코드에 의해 다루어진다.

직접 모듈화된 드라이버를 만들 때 그 자체의 메이저 번호가 필요하다는 것은 명확하다. 문제는 현재로선 커널이 드라이버 정보를 보관하기 위해서 정적 배열을 사용하고 있고, 그 배열이 겨우 64개(그전에는 32개였다가 1.2 커널 개발 과정에서 메이저 번호가 부족했기 때문에 늘어난 것이다)의 자료밖에 저장할 수 없다는 것이다.

다행히도, 커널은 동적인 메이저 번호 배당을 허용하고 있다. 다음 함수

int register_chrdev(unsigned int major, const char *name, struct file_operations *fops);

를 호출하면 그 캐릭터 드라이버는 커널에 등록된다. 첫번째 인수는 배당받기를 원하는 번호, 또는 동적인 배당을 원한다면 0이 될 것이다. 이 함수는 에러가 생기면 음수를, 성공하면 0이나 양수를 리턴한다. 만일 메이저 번호를 동적으로 배당받도록 신청했다면 돌려준 양수는 드라이버에 배당된 메이저 번호가 된다. name 인수는 드라이버의 이름이고, 이것이 /proc/devices 파일에 나타나게 된다. 마지막으로, fops 인수는 드라이버의 다른 모든 함수들을 부르는 데 쓰이는 구조체이고 나중에 설명할 것이다.

커스텀 디바이스에 대해서는 메이저 번호를 동적으로 배당받는 것이 좋다. 다른 디바이스의 메이저 번호와 충돌할 리도 없고, 디바이스가 너무 많이 로드되어서 디바이스 번호가 부족한 경우 – 거의 일어나기 힘든 상황이 되겠지만 – 만 제외한다면 register_chrdev()함수가 성공할 것이라고 확신해도 좋다.

모듈 적재하기와 등록 해제하기

메이저 번호들은 응용 프로그램이 디바이스에 접근할 때 사용하는 파일시스템 노드 안에 기록되기 때문에, 메이저 번호를 동적으로 배당한다는 것은 처음 만들어진 노드를 /dev 에 계속 두고 쓸 수 없다는 것을 의미한다. 모듈을 적재할 때마다 매번 노드를 새로 만들어야 하는 것이다.

이 페이지에 있는 스크립트는 필자가 필자의 모듈을 로드하고 언로드하는 데 사용하는 것들이다. 약간만 수정한다면 독자의 모듈에도 마찬가지로 쓸 수 있을 것이다. 모듈 이름하고 디바이스 이름만 바꾸면 된다.

mknod 명령은 주어진 메이저 번호와 마이너 번호를 갖는 디바이스 노드를 만들고(마이너 번호는 다음 회에 설명할 예정이다), chmod는 새 디바이스에 원하는 퍼미션을 준다.

독자들 중 일부는 시스템 기동시에 무언가를 만들고 퍼미션을 바꾸는 것을 싫어할 수도 있지만, 이 스크립트는 잘못된 것이 아니다. root 권한이 필요하다는 것에 대해 신경이 쓰인다면, insmod 자체가 root 권한으로만 실행된다는 것을 기억하라.

로딩 스크립트의 이름은 드라이버를 구별하기 위해 쓰는 이름의 앞부분, 그러니까 register_chrdrv()에 name 인수로 들어가는 것을 drvname이라고 하면 drvname_load라고 짓는 게 편하다. 스크립트는 드라이버를 만들 때에는 직접 실행시켜도 되고, 모듈을 설치한 뒤에는 rc.local에서 실행시켜도 된다. insmod는 설치될 모듈을 현재 디렉토리와 설치 디렉토리(/lib/modules 아래 어딘가가 될 것이다) 양쪽에서 모두 찾아본다는 것을 기억하라.

만일 독자의 모듈이 다른 모듈에 의존하거나 또는 독자의 시스템이 좀 특이한 경우라면, insmod 대신에 modprobe를 쓸 수도 있다. modprobe 유틸리티는 insmode를 개량한 버전으로서, 모듈간의 의존성 검사와 조건적인 로딩을 수행한다. 이 도구는 매우 강력하고 설명도 잘 되어 있다. 만일 드라이버가 exotic handling을 필요로 한다면, manpage를 읽어보는 것이 좋을 것이다.

하지만, 코드를 쓰고 있는 단계에서는 자동적인 메이저 번호 배당에 따라 디바이스 노드를 만들어내는 것을 제대로 다루는 표준적인 도구란 존재하지 않고, 필자로서는 그런 도구들이 드라이버의 이름과 마이너 번호를 알 수 있기나 할지도 의문이다. 그러니까 어쨌건 커스텀 스크립트가 필요하다는 이야기다.

이 아래는 drvname_load이다.

#!/bin/sh
# drvname 드라이버를 설치하고
# 디바이스 노드를 만든다.
# FILE과 DEV 는 같아도 된다.
# FILE은 로드할 목적 파일(*.o)이고
# DEV는 커널 내에서의 공식 이름이다.
FILE="drvname"
DEV="devicename"
/sbin/insmod -f $FILE $* || \
{echo "$DEV not inserted" ; exit 1}
# 방금 할당된 메이저 번호를 받아온다.
major=`grep $DEV /proc/devices | \
awk "{print \$1}"`
# 디바이스 노드를 만든다.
cd /dev
rm -f mynode0 mynode1
mknod mynode0 c $major 0
mknod mynode1 c $major 1
# 필요에 따라 이 라인을 수정하라.
chmod go+rw mynode0 mynode1

그리고 이 아래는 drvname_unload 이다.

#!/bin/sh
# drvname 드라이버를 언로드한다.
FILE="drvname"
DEV="devicename"
/sbin/rmmod $FILE $* || \
{echo "$DEV not removed" ; exit 1}
# 디바이스 노드를 없앤다.
cd /dev
rm -f mynode0 mynode1

자원 배당하기

init_module()에서 처리할 다음 일은 드라이버가 제대로 동작하는 데 필요한 모든 자원을 배당하는 것이다. 자원이란 그게 무엇이든 간에 컴퓨터의 작은 '조각'을 지칭하는 것이며, 여기에서 '조각'이란 컴퓨터의 물리적인 한 부분에 대한 논리적(또는 소프트웨어적)인 표현이다. 보통 드라이버는 메모리와 입출력 포트, 그리고 IRQ선을 필요로 한다.

C 프로그래밍을 해 보았다면 메모리를 요청하는 데 익숙할 것이다. kmalloc() 함수가 그 역할을 하며, 마치 malloc()을 쓰는 것과 똑같이 사용하면 된다. 반면에 입출력 포트를 요청하는 것은 그렇게 일반적인 것이 아니다. 포트는 그냥 있는 거고, 맘대로 써도 된다. "segmentation fault"에 해당할 만한 "I/O port fault" 같은 것은 존재하지 않는다. 하지만 다른 드라이버에 속한 입출력 포트에 써넣는다면 역시 시스템 크래시를 일으킬 수 있다.

리눅스는 근본적으로 입출력 포트에 대해 메모리에 사용되는 것과 근본적으로 같은 방침을 적용한다. 실제로 다른 점은 CPU에 있을 뿐인데, 요청하지 않은 포트 주소에 써넣어도 예외가 발생하지 않는다는 것이다. 포트 등록은 메모리 등록과 마찬가지로 커널이 깨끗하게 정돈하는 걸 돕는 데 유용하다.

만일 새 보드에 주었던 포트 주소를 까먹었다 해도 별로 상관없다. cat /proc/ioports와 cat /proc/interrupts 명령으로 독자의 하드웨어의 비밀은 금새 풀릴 것이다.

디바이스가 있는 곳을 찾아내야 할 경우가 자주 생기기 때문에, 사용할 입출력 포트를 등록하는 것은 메모리를 등록하는 것보다 조금 더 복잡하다. 다른 디바이스들이 이미 등록한 포트를 찾아내어 그것들을 피해 가기 위해서는, check_region()을 호출해서 고려중인 영역이 이미 요청된 것인지 살펴보면 된다. 찾아내는 과정에서 이것을 각 영역에 대해 반복하라. 일단 디바이스를 찾아내면, request_region() 함수를 호출해서 그 영역을 예약하라. 디바이스가 제거될 때에는 release_region()을 호출해서 할당받은 포트를 돌려주어야 한다. 아래에 이 함수들에 대한 선언이 있다.

int check_region(unsigned int from, unsigned int extent);

void request_region(unsigned int from, unsigned int extent, const char *name);

void release_region(unsigned int from, unsigned int extent);

from 인수는 입출력 포트에 해당하는 연속적인 영역 또는 범위의 시작점이고, extent는 그 영역 내에 존재하는 포트의 개수이며, name은 그 드라이버의 이름이다.

입출력 포트를 등록하는 것을 잊었다 하더라도, 그런 식으로 잘못 동작하는 드라이버가 둘 이상이거나 독자의 컴퓨터에 새 보드를 설치하기 위해 정보를 얻으려 할 때가 아니라면 아무 것도 문제되지 않는다. 만일 언로드할 때에 포트를 해제하는 것을 잊었다면 그 다음부터 /proc/ioports 파일에 접근하는 모든 프로그램들은 "Oops"를 낼 것이다. 이것은 드라이버 이름이 매핑되지 않은 메모리를 참조하기 때문이다. 게다가 그 포트가 더 이상 쓸 수 없는 상태이기 때문에 그 드라이버는 다시 적재할 수 없을 것이다. 그러므로 포트를 되돌려주는 것을 잊지 말라.

비슷한 배당 방침이 IRQ선에도 적용된다. (보다시피)

int request_irq(uint irq, void (*handler)(int, struct pt_regs *), ulong flags, const char *name);

void free_irq(uint irq);

name이 /proc/ 의 파일들에 보이는 그 이름이고, mydrv이기보다는 myhardware여야 한다는 것을 유의하라.

만일 IRQ선을 등록하는 것을 잊었다면 독자의 인터럽트 핸들러는 호출되지 않을 것이고, IRQ를 되돌려 주는 것을 잊었다면 /proc/interrupts 파일을 읽을 수 없을 것이다. 게다가, 만일 보드가 독자의 인터럽트 핸들러가 언로드된 이후에도 계속 인터럽트를 보낸다면, 뭔가 이상한 일이 벌어질 수도 있다. (필자에게는 한 번도 일어난 적 없는 일이라서 정확히 말할 수는 없지만, 그렇다고 그 상황에 대해 쓰기 위해 시도해 보지는 않을 것이다.) [아마도 커널 패닉이 일어날 것이라고 생각하지만, 본인도 그런 일을 경험해 본 (또는 알아보려 해 본) 일은 전혀 없다. — 편집자 주]

여기서 내가 언급할 마지막 부분은 Linus의 논평에 소개되어 있다.

"하드웨어를 찾아내야만 한다. 만일 쓸만한 드라이버들을 만들려면, 반드시 디바이스를 자동검색할 수 있어야 한다. 자동검색은 드라이버를 일반인들에게 배포하기 위해서는 필수적인 부분이다. 하지만 "Plug and Play" 는 상표이므로 그렇게 불러서는 안 된다."

하드웨어는 입출력 포트와 IRQ 번호를 모두 검색해야 한다. 만일 보드가 어느 IRQ 선을 쓸 것인지 알 수 없다면, 시행착오를 거쳐 보면 된다. 주의 깊게만 한다면 멋지게 작동할 것이다. 이 방법은 나중의 연재에서 다룰 것이다.

디바이스의 IRQ 번호를 알았다면, module_init()에서 리턴하기 전에 freeirq()를 사용해서 그 IRQ를 해제해야 한다. 나중에 실제로 디바이스를 열 때 다시 요청할 수 있다. 만일 그 인터럽트를 그냥 차지한다면 다중 하드웨어를 사용할 수 없을 것이다. (게다가 i386에는 IRQ선이 너무 적어서 쓸데없이 낭비할 수 없다.) 이 방법으로 필자는 어떤 모듈도 언로드하지 않고서 plip와 frame grabber에 같은 인터럽트를 주고 실행시킨다. 한 번에 그 중 한 개만 열리는 것이다.

불행히도, 드물지만 가끔씩은 자동검색이 불가능할 때가 있기 때문에, 드라이버에 직접 포트와 IRQ에 대한 정보를 넘겨줄 수도 있어야 한다. 검색 실패는 보통 시스템 부트 때에 첫 번째 드라이버가 여러 미등록 디바이스에 대한 접근권을 가지고서 다른 디바이스를 자신이 찾는 것으로 오인하는 경우 일어나곤 한다. 때로는 한 디바이스를 검색하는 것이 다른 디바이스에 대해 "파괴적인", 즉 더 이상의 초기화를 불가능하게 하는 경우도 있을 수 있다. 어느 쪽 경우도 모듈에 대해서는 일어날 수 없는데, 그것은 모듈이 마지막 순서이기 때문에 다른 디바이스에 속한 포트를 요청할 수 없기 때문이다. 그럼에도 불구하고, 자동검색을 비활성화 시키고 드라이버에 강제로 값을 주는 방법을 마련하는 것은 구현해 둘 만한 중요한 사항 중 하나이다. 최소한 이 쪽이 자동검색보다 쉽고, 이렇게 해 두면 자동검색이 되기 이전에도 로드할 수 있다.

init_module()과 cleanup_module()의 완전한 소스가 공개되는 다음 연재의 첫 번째 주제는 적재시 구성이 될 것이다.

동적 커널: 모듈화된 디바이스 드라이버

댓글 남기기

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다