관리 메뉴

미닉스의 작은 이야기들

리눅스 디바이스 드라이버 만들기 본문

기술과 인간/IT로 본 세상

리눅스 디바이스 드라이버 만들기

미닉스 김인성 2008. 9. 15. 03:33

1998년에 마이크로소프트웨어 잡지에 연재한 글입니다. 많은 시간이 지났고 리눅스 버전도 바뀌어 별로 유용성은 없는 글입니다. 다만 제 글을 모아 놓아야 겠다는 필요성 때문에 올려 놓습니다. 리눅스에 대해서 잘 모르시는 분들에게는 전혀 필요 없는 글이므로 읽지 않으셔도 됩니다.



리눅스 커널 디바이스 드라이버 만들기



1. 커널 이해와 디바이스 드라이버 등록

하루가 다르게 새로운 하드웨어가 나오고 있는 요즘, 이런 하드웨어를 사용하는 사람들은 마이크로소프트의 운영체제가 아닌 플랫폼에서 많은 어려움을 겪고 있다.

개발사들도 새로운 하드웨어를 개발하면 dos, windows, win95를 위한 드라이버만 지원을 하고 있다. 조금 신경을 쓰는 회사들도 windows/NT, OS/2 정도로 범위를 확대할 뿐 Unix 쪽은 거의 지원하지 않는다.


이제 마이크로소프트의 운영체제가 대세가 되고 있으므로 당연한 현상이라고 말할 수 있지만 사용자 입장에서는 큰 불만이 아닐 수 없다. 아무리 좋은 하드웨어라고 하더라도 사용하는 운영체제가 지원을 하지 않으면 전혀 쓸 수 없기 때문이다. 상용 운영체제는 사용자가 직접 만들어 쓰는 것도 쉽지 않다. 운영체제를 만드는 회사에 건의하거나 개발사에 적극적으로 요청하여 드라이버를 만들어 줄 때까지 기다려야 한다. 일반적으로 시장이 넓지 않은 분야에 대해 개발사들이 비용을 들여서 관심을 가질 이유도 여력도 없는 경우가 많다. 점점 지배적인 점유율을 가진 운영체제만이 살아 남게 되고 점유율이 낮은 운영체제는 하드웨어 디바이스 드라이버조차 변변찮게 되고 있는 것이다.

여기에 리눅스의 장점이 있다. 리눅스에서는 개발사가 지원하지 않아도 사용자가 필요한 하드웨어에 대한 명세를 구하여 스스로 만들 수 있다. 리눅스는 커널까지 완전히 개방되어 있기 때문이다. 그렇다고 새로운 하드웨어를 사용하기 위해서 직접 드라이버를 만들 필요는 없다. 리눅스의 개발자는 전세계의 모든 리눅스 사용자들이며, 새로운 하드웨어를 위한 디바이스 드라이버는 이미 누군가 먼저 개발해 놓고 누구나 사용할 수 있도록 인터넷에 올라와 있을 것이기 때문이다.


최근에는 하드웨어 개발자들도 마이크로소프트의 운영체제 뿐만 아니라 리눅스에서의 지원에 대해 최우선으로 관심을 가지는 경우가 늘고 있다. 소프트웨어는 이미 그런 현상이 뚜렷하다. 인터넷과 관련이 있는 소프트웨어는 윈도우계열 이외에 리눅스가 최우선으로 지원되고 있다. 요즘에는 인기 있는 게임 프로그램들도 리눅스용으로 포팅되고 있다.


공개 오에스인 리눅스의 성장은 경이적인 일이다. 무섭게 성장하는 리눅스 때문에 coherent 라는 작은 유닉스는 이미 사라졌고, 리눅스의 기초가 된 minix는 유료에서 무료로 돌아섰으며, 스코 유닉스는 일인 사용자에 한하여 무료 시디를 배포하고 있고, 선에서는 커널소스를 배포하기에 이르렀다. DEC에서는 윈도우NT와 함께 리눅스를 alpha cpu의 주요 플랫폼으로 키우기 위해 노력 중이다. 리눅스는 인텔, alpha, sparc, PowerPC, MIPS, ARM, SGI, 모토롤라 68계열 컴퓨터에서 돌아간다. 이미 하드웨어 개발사들에게 무시하지 못할 플랫폼이 되어 가고 있는 것이다.


이 글은 이런 추세에 맞추어 리눅스를 위한 하드웨어 커널 디바이스 드라이버를 제작하기를 원하는 제작사나 자신의 필요 때문에 직접 드라이버를 만들기 원하는 사용자를 위해서 리눅스 커널 디바이스 드라이버 작성법에 대해 쓰는 글이다.


리눅스 디바이스 드라이버를 완성하고 FreeBSD에 포팅하기 위해서 FreeBSD 커널 소스를 살펴 볼 때 막막한 느낌을 받았다. 코드 한 줄마다, 이름 모를 함수들이 생소하고 어떻게 코딩을 시작해야 할 지 알 수 없었다.


간단하더라도 줄기가 되는 드라이버 작성법 설명이 있었다면 그렇게 막막하지는 않았을 것이다. 줄기만 이해한다면 각 디바이스마다 달라지는 부분은 점차 이해할 수 있기 때문이다. 이 글도 마찬 가지이다. 여기서 디바이스 작성의 모든 것을 설명할 수 없다. 이 글의 목적은 리눅스 커널 디바이스 드라이버 작성을 위해서 최소한의 비빌 언덕을 제공하려는 것이다. 드라이버를 작성할 때에는 관련 프로그램을 모두 살펴야 하지만 이 글에서 언급한 부분이 코드의 구조를 이해하는 데 도움이 되기를 바란다.


이미 나와있는 디바이스 드라이버를 고찰하는 것 뿐만 아니라 가상의 하드웨어를 위한 디바이스 드라이버를 직접 작성해 보도록 하자. 리눅스로 도움을 받은 사용자라면 그 보답을 해야 하기 때문에, 리눅스에서 사용하지 못하는 새 하드웨어가 있다면 쓸 수 있게 해야 하기 때문에, 개발사라면 무시 못할 정도의 리눅스 사용자가 전 세계에 있기 때문이기도 하다. 새로운 하드웨어를 개발한 회사라면 이제 리눅스를 위한 드라이버 없이 하드웨어의 점유율을 높일 수 없다는 점을 명심해야 할 것이다.


리눅스 커널에 대한 이해

리눅스 커널은 리누스 토발즈가 미닉스에서 개발했다. 처음부터 멀티태스킹이 가능한 유닉스호환을 지향했고 현재는 POSIX에서 제안한 유닉스 표준을 지키고 있다. 초기에는 리누스가 모든 개발을 진행했지만 인터넷에 커널 소스를 개방시킨 결과 수많은 개발자들이 기여를 하여 기능 첨가, 버그 개선 등이 빠른 속도로 진행되었다. 이 과정에서 개발자들이 사용자가 되고 사용자가 개발자가 되는 상승효과로 리눅스 사용자 또한 빠르게 증가했다. 여기에 더해서 리눅스 상용 패키지 회사까지 경쟁적으로 나타나 유닉스에 경험이 전혀 없는 초보자도 몇 시간 안에 리눅스를 설치하고 설정까지 최적화 시킬 수 있을 정도로 발전했다.


개발자가 증가하면서 커널 소스도 방대해지고 멀티플랫폼, 코드최적화, 지원 디바이스 드라이버의 추가 등의 개발 방면도 다양해져서 이미 한 명의 개발자가 모든 부분을 전담하기에는 벅차게 되었다. 현재는 커널의 각 부분에 대한 관리자가 각각의 분야를 전담하고 리누스는 전체적인 부분의 유기적 연결과 중요한 개발 방향에 대한 정책적 결정을 하는 형태로 진행되고 있다.


알렌 콕스는 커널 모듈화, 네트워크 코드, SMP에 대해서 중요한 기여를 했고 개발관리 부분에 많은 일을 하고 있다. 리눅스는 이미 리누스의 의지로만 결정되지 않아서 개별적인 패치가 존재하기도 하고, 리누스가 찬성하지 않는 방향으로 개발을 진행하는 개발팀도 존재한다.


리눅스 커널 버전에 따라 안정 버전과 개발 버전으로 나뉜다. 2.0.x처럼 중간 번호가 짝수인 버전은 안정버전으로 일반 사용자가 안심하고 사용할 수 있을 정도의 안정성을 보장하는 버전이며, 2.1.x처럼 중간 번호가 홀수인 버전은 각종 패치와 최적화, 새로운 기능의 첨가가 진행되고 있는 개발 버전이다.


개발 버전은 안정 버전에는 없는 특수한 드라이버들이 필요한 사용자나 커널 개발에 관심이 있는 개발자들이 사용하며 버그보고나 기능 개선에 대한 조언을 할 수 있도록 공개된 버전이다. 개발 버전은 최신의 드라이버가 포함되고 새로운 알고리즘이 포함되어 있으므로 속도에 이점이 있고 더 많은 지원 드라이버가 있지만 안정성은 보장할 수 없으므로 일반 사용자는 주의해서 사용해야 한다.


만약 새로운 커널 디바이스 드라이버를 개발하는 중이라면 최신 안정버전을 이용해서 개발하고 개발이 끝나면 개발 버전을 따라가면서 변경사항을 그때 그때 적용해 나가는 것이 개발 시간을 줄일 수 있는 방법이다. 개발 버전은 결국 다음 안정 버전이 되기 때문이다.


리눅스 커널 소스의 구조

커널 2.2 버전을 중심으로 리눅스 커널 소스를 살펴보자. 받아온 커널을 풀면
/usr/src/linux 에 소스트리가 생성된다. /usr/src/linux/ 루트 트리에는 기본적인 설명서, 관리자들의 목록, 컴파일 방법 등에 대한 간단한 설명서가 있다.

리눅스 커널 프로그래밍을 위해서는 리눅스에 대한 이해와 여러 가지 프로그램의 사용법에 대해서 알고 있다고 가정을 하고 세세한 과정에 대한 설명은 생략한다. arch/ 디렉토리에는 지원하고 있는 CPU에 의존적인 코드가 있다. 부팅과정, 메모리 관리방법, 프로세스 처리 부분들이 모두 다르므로 가장 하드웨어와 밀접한 코드가 모여 있다. 여기서 CPU의 차이를 블랙박스로 만들어 주게 된다.


fs/ 디렉토리에서는 논리적인 파일 시스템을 처리하는 부분이다. 리눅스에서는 가상파일 시스템을 사용하여 동시에 여러 가지 파일 시스템을 지원할 수 있다.


mm/ 디렉토리에서는 메모리 처리 관련 프로그램이 net/ 에는 네트워크 관련 코드가 있다. 그 외 여러 디렉토리에는 잘 정리된 형태로 필요한 프로그램들이 나뉘어져 있다.


커널 디바이스 드라이버를 만들기 위해 가장 주의 깊게 보아야 할 디렉토리는 Documentation/ 디렉토리와 drivers/ 디렉토리이다. Doc../ 디렉토리에는 커널 프로그래밍에 필요한 다양한 설명서가 있으며 drivers/ 디렉토리에는 커널용 디바이스 드라이버가 모두 모여 있다. 가능하다면 Doc../ 디렉토리의 모든 문서를 프린트 해서 읽어 보기 바란다.


커널 프로그래밍을 위해서는 직접 파일들을 살펴보고 파일 안에 있는 설명을 읽고 소스트리에 있는 설명서를 모두 조사해야 하기 때문에 개략적으로 디렉토리 구조만을 말했다. 직접 프로그래밍을 시작하기 전에 우선 모든 디렉토리를 살펴 보기 바란다.


리눅스 커널 컴파일하기

새로운 디바이스 드라이버를 만들기 전에 먼저 이미 만들어진 것들을 조사해 보기로 하자. 가장 먼저 커널 컴파일을 해보는 것이 좋다. 루트에서


make clean
cd /usr/include
rm -rf asm linux scsi
ln -s /usr/src/linux/include/asm-i386 asm
ln -s /usr/src/linux/include/linux linux
ln -s /usr/src/linux/include/scsi scsi
make mrproper

를 실행한다. 다음에 엑스윈도우라면 make xconfig, 콘솔이라면 make menuconfig를 실행한다. 위 두 명령은 메뉴방식으로 커널을 설정할 수 있다. 뭔가 문제가 있다면 make config 명령을 쓸 수 있다. 이 명령은 모든 설정을 단계적으로 하게 되기 때문에 불편하다.




[make xconfig 를 실행한 화면]

각 버튼은 커널 설정의 큰 제목이고 해당 버튼을 누르면 다시 세분된 옵션을 선택할 수 있다. 옵션은 서로 연관되어 있어서 어떤 옵션이 선택되면 다른 옵션은 선택할 수 없는 상태로 된다. 큰 메뉴끼리의 연관성도 있다. 화면에서처럼 SCSI SUPPORT 메뉴 안에 있는 첫 번째 옵션인 SCSI SUPPORT 옵션을 "n"로 만들었을 때 다음 큰 메뉴인 SCSI low-level drivers 메뉴에 있는 모든 옵션이 꺼지는 것을 볼 수 있다. 옵션끼리의 연관은 한 메뉴 안에서 정해지거나 비슷한 설정 메뉴끼리 관련 되거나 베타버전의 드라이버를 포함하지 않겠다는 등의 정책적인 옵션에 영향받기도 한다. 모든 옵션을 살펴보고 연관성을 이해하기 바란다.


[scsi support 메뉴가 열린 모습]


[scsi low-level drivers 메뉴가 열린 모습]

그림에서 Adaptec AIC7xxx support 옵션을 살펴보자. 이 것은 Adaptec 2940 스커지 컨트롤러를 지원하기 위한 드라이버이다. 만약 SCSI SUPPORT 옵션이 "n"로 설정되어 있다면 선택을 할 수 없게 된다. "y"로 되어 있다면 y,m,n를 선택할 수 있고 SCSI SUPPORT 가 "m"으로 되어 있다면 m,n만을 선택할 수 있다. 다시 Adaptec AIC7xxx support 옵션을 y,m으로 만들어야 "Maxinum number of commands per LUN"옵션에 적절한 값을 써 넣을 수 있게 된다. 여기서 "y"는 드라이버를 커널에 직접 포함시키겠다는 것을, "m"은 모듈화 시켜서 필요할 때 직접 커널에 삽입 할 수 있게 하겠다는 것을 나타낸다. "n"는 이 드라이버가 지원하는 하드웨어를 사용하지 않겠다는 뜻이다. 이것은 어디에 있을까?

drivers/scsi/Config.in을 살펴보자.


dep_tristate 'Adaptec AIC7xxx support' CONFIG_SCSI_AIC7XXX $CONFIG_SCSI
if [ "$CONFIG_SCSI_AIC7XXX" != "n" ]; then
bool ' Enable tagged command queueing' CONFIG_AIC7XXX_TAGGED_QUEUEING Y
dep_tristate ' Override driver defaults for commands per LUN' CONFIG_OVERRIDE_CMDS N

if [ "$CONFIG_OVERRIDE_CMDS" != "n" ]; then
int ' Maximum number of commands per LUN' CONFIG_AIC7XXX_CMDS_PER_LUN 8
fi



CONFIG_SCSI 옵션이 설정되었다면 "Adaptec AIC7xxx support" 옵션이 세가지 상태 (dep_tristate)를 가질 수 있게 된다. 다른 옵션에 영향받지 않는다면 독자적인 세가지 상태(tristate)를 가질 수 있다. CONFIG_SCSI_AIC7XXX 옵션이 커널에 포함 되거나 모듈로 선택되었다면 바로 아래 줄의 옵션을 선택할 수 있다. bool이란 두 가지 상태를 선택할 수 있다는 뜻이며 줄 마지막의 "Y"는 기본값을 y로 한다는 뜻이다. 다음 줄의 CONFIG_OVERRIDE_CMDS를 N로 선택하지 않았으면 이제 비로소 CONFIG_AIC7XXX_CMDS_PER_LUN 값을 써넣을 수 있다. int라는 키워드는 여기에 써넣은 숫자를 취한다는 뜻이다. 기본값은 여기서 8로 되어 있다.


그렇다면 CONFIG_SCSI 옵션은 어디에서 선택하는 것일까? drivers/scsi 디렉토리의 상위 디렉토리인 drivers/ 의 Makefile에 있다.



ifeq ($(CONFIG_SCSI),y)
SUB_DIRS += scsi
MOD_SUB_DIRS += scsi
else
ifeq ($(CONFIG_SCSI),m)
MOD_SUB_DIRS += scsi
endif
endif



ifeq-endif 짝을 잘 살펴보면 별 무리 없이 이해할 수 있을 것이다. 필요한 설정값을 넣는 방법, 드라이버의 상호연관성에 대한 것, 세 가지 혹은 두 가지 설정 상태, 옵션이 있을 수 있는 파일 위치는 제작하려는 드라이버의 종류에 따라 다르지만 대략 위와 같다.


그림과 같이 세세한 옵션에 대해서 어떻게 설정해야 하는지 정확히 알 수 없을 때는 "help" 버튼을 눌러서 자세한 설명을 볼 수 있다. 이 버튼을 눌렀을 때 나오는 설명은
Documentation/Configure.help 파일에 있다. 그 형식은



Enable vendor-specific extensions (for SCSI CDROM)
CONFIG_BLK_DEV_SR_VENDOR
This enables the usage of vendor specific SCSI commands. This is
required to support multisession CD's on with old NEC/TOSHIBA
cdrom drives (and HP Writers). If you have such a drive and get
the first session only, try to say Y here; everybody else says N.



공백 한 줄, 간단한 설명 한 줄, 옵션 키워드, 필요한 설명, 공백 한 줄이다. 위치에 무관하게 설명을 누르면 키워드를 이용해 이 설명을 보여 주게 된다.




[scsi cdrom support 설명을 누른 모습]



이렇게 하드웨어에 맞는 옵션을 선택했다면 "make dep"라는 명령으로 선택한 옵션에 따라 파일들의 연관성이 올바로 되도록 만든다. 그 후에는 실제 컴파일을 수행하게 된다. 플로피에 커널을 보내려면 "make zdisk", 컴파일 수행과 동시에 lilo가 실행되게 하려면 "make zlilo", 커널의 크기가 너무 크면 "make bzlilo"를 사용하면 된다. 컴파일이 제대로 되고 커널이 만들어 졌으면 모듈을 만들기 위해 "make modules", "make modules_install"을 수행한다. 만들어진 모듈은 /lib/modules/
uname -r/ 아래에 인스톨 된다.


이제 새 하드웨어는 리눅스 커널에서 인식하고 사용할 수 있다. 요즘 나오는 리눅스 배포본의 추세는 대부분의 드라이버를 모듈화 시켜서 필요할 때 커널에 삽입해 쓰게 하기 위해서 데몬을 만들고 옵션을 따로 기록해 놓는 등의 여러 방법을 쓴다. 이런 것은 사용자 편의를 위한 것일 뿐 커널 프로그래밍과 크게 관련이 없기 때문에 언급하지 않는다.


MY_DEVICE

이제 각종 옵션과 필요한 파일의 위치, 고쳐야 하는 파일과 그 내용을 실제로 알아보기로 하자. 우선 가상의 하드웨어를 하나 정하기로 한다. 이 하드웨어의 성격은 다음과 같다.



문자 디바이스이다.

인터럽트 방식이다.

하드웨어에는 자체적인 CPU가 있다.

PC의 운영체제와는 shared memory 방식으로 교신한다.

인터럽트와 shared memory는 카드 설정에 따라 바뀔 수 있기 때문에

부팅할 때나 모듈 적재 시에 옵션으로 바꿀 수 있다.

동시에 같은 하드웨어를 다른 인터럽트를 주고 2개 이상 사용할 수 있다.



위와 비슷한 하드웨어 중에 커널에 포함된 것은 지능형 멀티 시리얼 포트 종류가 있다. 크게 말하면 사운드 드라이버도 포함될 수 있을 것이다. 이 정도의 하드웨어를 가정한다면 커널 프로그래밍에 필요한 대부분의 테크닉이 모두 동원되어야 할 것이다. 문자 디바이스 보다 블록 디바이스가 좀더 복잡하지만 버퍼 입출력만 제외한다면 크게 다르지 않다. 이 하드웨어를 my_device라고 명명하고 리눅스 커널에서 제대로 동작하게 하기 위해서 필요한 작업을 해보도록 한다.


장치 특수파일

프로그래밍을 할 때 어떤 장치를 열기 위해 open 함수를 호출한다고 하자. 표준 라이브러리에 있는 open이라는 함수를 사용하면 이 함수는 커널에 시스템 호출을 하고 커널은 이 호출이 요구하는 파일에 대한 요청 작업을 처리한다. 유닉스의 특성상 모든 디바이스의 입출력은 파일 입출력과 차이가 없다. 우리가 만들어야 하는 것은 추상화 되어 있는 파일 입출력이 최종적으로 호출하게 될 각 장치에 고유한 열기 방법에 관한 것이다. 어떤 장치에 접근하기 위해서 가장 먼저 해야 할 작업은 장치마다 다를 것이다. open이라는 호출이 오면 필요한 일을 하고 write/read 호출에 대비해서 준비를 하는 작업만 하면 된다.


이 작업은 프로그램 언어만 C를 사용할 뿐 C 라이브러리 함수를 하나도 사용할 수 없는 특수 작업이므로 응용 프로그래밍과 완전히 다른 작업이라고 할 수 있다. 파일 입출력의 상위 인터페이스는 리눅스에서도 다 완성되어 있기 때문에 우리가 신경을 쓸 필요는 없다. 장치 특수파일을 정의하고 표준 파일 입출력에 사용되는 시스템 호출이 사용할 적절한 함수를 만들어 내는 작업만으로 충분하다.


장치 특수파일을 위해서 파일유형, 주(major) 번호와 부(minor) 번호가 필요하다. 주번호는 장치유형을, 부번호는 그 유형의 단위기기를 나타낸다.


리눅스에서는 255번까지의 장치특수파일 주번호가 있다. 수많은 하드웨어 지원이 계속 추가되고 있어서 100번 이하는 거의 다 할당이 되었다. 특정한 하드웨어를 위한 디바이스 드라이버를 만들고 공식적으로 리눅스의 주번호를 받고 싶으면 Documentation/devices.txt 를 참고하여 리눅스 주번호 관리를 하는 사람에게 연락을 취하면 된다. 임의로 번호를 부여한다면 커널이 업그레이드되어 다른 디바이스 드라이버가 이 번호를 사용할 때 충돌이 있을 수 있다.


리눅스를 테스트하거나 실험적으로 디바이스 드라이버를 만드는 사람을 위해서 60-63, 120-127, 240-254번이 예약되어 있다. 이 번호 중에서 임시로 적당한 번호를 사용하여 테스트 하고 나중에 정식으로 번호를 할당받으면 된다. 우리가 만드는 장치를 위해서 125번을 선택하여 MY_DEVICE_MAJOR로 정하자.


이 번호를 커널에 등록하기 위해서는 include/linux/major.h에 이 값을 기록한다. 위치는 상관없다. 만약 계속 커널 업그레이드에 따라 갈 예정이고 아직 정식으로 커널 배포본에 등록이 안된 테스트 드라이버라면 커널 패치를 할 때 문제가 생기지 않도록 되도록 가장 뒷부분에 배치하는 것이 좋을 것이다. 우리가 삽입한 코드에 인접한 곳에서 커널 변경 사항이 생긴다면 패치할 때 문제가 생길 수 있다.



#define MY_DEVICE_MAJOR 125


그리고 이 번호로 된 장치 특별파일을 만든다.

mknod /dev/my_device0 c 125 0
mknod /dev/my_device1 c 125 1
mknod /dev/my_device2 c 125 2
mknod /dev/my_device3 c 125 3
chown root.root /dev/my_device?
chmod 660 /dev/my_device?



이제 my_device는 이 파일을 열고 쓰고 읽음으로써 조작할 수 있다. 참고로 c는 문자 특수파일임을 나타내고 125는 주번호, [0-3]은 부번호이다.


MY_DEVICE를 커널 컴파일 옵션에 삽입

앞에서 얘기한 make config 시에 MY_DEVICE 항목이 나오게 하기 위해서 필요한 작업을 하자. 디바이스 드라이버는 계층상 가장 하위에 위치하기 때문에 디렉토리 위치도 살펴야 한다. drivers/[block/,char/] 디렉토리 이외의 디렉토리는 편의상 분류한 것이다. drivers/scsi는 블록 디바이스지만 스커지만의 특수한 상위 함수와 다양한 스커지 컨트롤러 제품에 따른 파일을 모아서 디렉토리를 나누어 놓은 것이다. 만약에 이런 유형에 해당하는 하드웨어에 맞는 드라이버를 만든다면 거기에 맞는 디렉토리를 선택해야 할 것이다.


MY_DEVICE는 문자 특수파일이며 isdn등과 같이 특수한 분류에 들어가지 않기 때문에 drivers/char 디렉토리에서 작업을 하면 된다. 문자 드라이버는 상위의 tty_io 루틴과 연관이 있다. 시리얼 포트나 모뎀 등의 디바이스는 터미널 기능을 수행하게 되기 때문에 이 기능이 보장되어야 한다. 그러므로 각종 초기화나 옵션의 설정 등이 상위 루틴과 관련이 있는 파일에 있다.


make config를 했을 때 MY_DEVICE 항목이 나오게 하기 위해서는 drivers/char/Config.in 에 my_driver에 해당하는 조건을 명시해야 한다.



tristate 'insert my device driver in kernel' CONFIG_MY_DEVICE
if [ "$CONFIG_MY_DEVICE" = "y" -o "$CONFIG_MY_DEVICE" = "m" ]; then
int ' my device value' CONFIG_MY_DEVICE_VALUE 1
bool ' support my device something' CONFIG_MY_DEVICE_SUPPORT
fi



tristate는 커널에 직접 삽입되거나(y), 모듈로 만들거나(m), 컴파일하지 않는(n)다는 것을 정하는 것이며 bool은 (y,n) 두 가지 중에 선택하는 것이고 int는 필요한 수치값이 있으면 적어 주는 부분이다.


if-fi는 여러 계층을 둘 수 있다. 다른 if-fi 문장 사이만 아니라면 이 문장을 삽입하는 위치는 상관이 없다.


help 버튼을 눌렀을 때 설명이 나오게 하기 위해서 Documentation/Configure.help에 적절한 설명을 넣어 준다.



My device support
CONFIG_MY_DEVICE
This text is help message for my device driver



커널 부팅 옵션의 처리

커널이 부팅할 때 디바이스 드라이버들은 제어할 수 있는 하드웨어에 대한 설정값을 스스로 찾거나 고정된 값을 사용할 수 있다. 만약 사용자가 어떤 디바이스에 대한 인터럽트값이나 베이스 어드레스등을 바꾸었다면 커널에게 알려 주어야 한다. 설정값을 스스로 찾을 수 없는 디바이스 드라이버도 마찬가지로 사용자가 하드웨어 설정값을 알려 주어야 한다. 이를 위해 리눅스 커널은 부팅 옵션을 지원한다.


lilo를 사용해 부팅할 때 append 옵션을 사용하여 커널의 my_device에게 옵션을 전달하기 위해서는 init/main.c을 편집한다.



#ifdef CONFIG_MY_DEVICE
extern void my_device_setup(char *str, int *ints);
#endif
#ifdef CONFIG_MY_DEVICE
{ "my_device=", my_device_setup},
#endif



lilo.conf에 append="my_device=0x200,1" 이라고 적어 주면 이 값이 my_device_setup 함수에 전달된다. 이 함수는 앞으로 만들게 될 드라이버 함수 중에서 커널에서 넘어온 인자를 드라이버에 적절한 값으로 변환하여 전달하는 함수이다. 다른 ifdef-endif 속에 들어가지 않는다면 위치는 상관없다.


디바이스 드라이버를 위한 파일 drivers/char/my_device.c을 만들고 헤더파일 include/linux/my_device.h를 만든다. 컴파일 할 때 이 프로그램도 컴파일 시키기 위해서 drivers/char/Makefile의 적당한 곳에 이 파일을 적어준다.



ifeq ($(CONFIG_MY_DEVICE),y)
L_OBJS += my_device.o
else
ifeq ($(CONFIG_MY_DEVICE),m)
M_OBJS += my_device.o
endif
endif



커널에 직접 삽입(y)하라는 옵션과 모듈로 만들(m)라는 옵션일 때 각각에 대해서 다른 처리를 한다. 그 외에 Makefile 을 살펴보면 서브디렉토리를 포함하라는 옵션 등이 있다. 컴파일 속도를 위해서 리누스가 자체로 만든 mkdep 라는 프로그램으로 파일 간의 관계를 조사하기 때문에 메이크파일이 거의 암호화 수준이지만 잘 살펴보면 크게 어려운 점은 없을 것이다.


drivers/char/mem.c에서 문자 디바이스 드라이버를 초기화한다. 드라이버 초기화 함수는 대부분 *_init 형식이므로 my_driver_init라고 정하고 적당한 곳에 넣어 주면 된다. 비슷한 드라이버 초기화 루틴이 삽입되어 있는 위치에 다른 ifdef-endif와 상관없는 곳에 두면 된다.



#ifdef CONFIG_MY_DEVICE
my_device_init();
#endif



my_device_setup 함수는 부팅할 때 커널 인자로 넘어온 값을 처리하기 위해 한 번 호출되는 함수이고 my_device_init 함수는 my_device를 초기화 시킬 때 필요한 각종 작업을 할 때 한 번 실행되는 함수이다. my_device_init 함수에서 my_device_open, my_device_write 함수를 커널에 등록하게 된다.


my_device_init 함수는 block/char 디바이스 드라이버 초기화 루틴을 전체적으로 실행하는 부분에 알려 주어야 실행이 될 수 있다. 전체 디바이스 드라이버 초기화 루틴을 수행하는 함수 선언은 block device driver라면 include/linux/blk.h에, char device driver라면 include/linux/tty.h에 있다. my_device는 char device 이므로 .../tty.h에 적어 준다. 마찬가지로 위치는 크게 상관없다.



extern int my_device_init(void);




[ 그림 : make xconfig에서 my_device의 help 화면을 잡은 모습]

make dep 과정에서 Config.in을 참조하여 Makefile에 정의된 디바이스 드라이버 파일을 컴파일 하여 커널에 삽입할 것인지, 모듈로 만들 것인지 여부를 결정한다. 만약 커널에 삽입되었다면 부팅하면서 커널로 넘어온 인자(my_device=0x200,1) 중에서 init/main.c에 정의된 문자열(my_device=)과 맞는 것이 있으면 이 인자들을 파싱하여 이 드라이버의 인자 셋업함수(my_device_setup)에 인자(str="0x200,1", ints[0]=2, ints[1]=0x200, ints[2]=1)를 전달하고 셋업함수를 실행한다. 셋업함수는 간단히 넘어온 인자값을 조사하여 디바이스 드라이버의 번지값 등을 적절히 바꾸고 리턴한다.


나머지 부팅과정을 진행한 후에 커널은 디바이스 드라이버 초기화 루틴(drivers/char/mem.c)으로 뛰어 각 디바이스를 실제로 초기화(my_device_init)한다. 각각의 디바이스 초기화 함수는 하드웨어 디바이스가 컴퓨터에 존재하는지 검사하고, 하드웨어가 필요로 하는 초기화를 한 후에 system call 루틴을 위해서 read, write, release, ioctl 등의 함수가 정의된 file_operation 함수배열을 등록한다.


모듈로 만들었을 때는 커널 부팅과정을 insmod가 해 준다. 인자 파싱도 마찬가지로 insmod 몫이다. 디바이스 드라이버는 인자 배열을 넘겨 받게 되고 init_module(my_device_init)에서 마찬가지로 하드웨어 검색, 초기화, 인자를 사용한 설정값 변경을 한다. 모듈로 했을 때는 드라이버 프로그램에서 약간의 부가 작업이 필요할 뿐 직접 삽입된 드라이버와 다르지 않다.



2. 디바이스 드라이버의 적재와 삭제



리눅스 커널 코딩 스타일


리눅스 커널 프로그래밍을 하기 전에 리누스가 제안한 코딩 스타일을 살펴볼 필요가 있다. 스타일을 알아야 커널 소스를 빨리 이해할 수 있고, 우리가 쓴 드라이버를 다른 프로그래머가 쉽게 파악 할 수 있기 때문이다.

리누스가 제안한 코딩 스타일은 일반적인 C 프로그래밍과 크게 다르지 않다. GNU 코딩스타일과도 크게 차이나지 않는다.

1. 들여쓰기는 8자 단위로 할 것


2자나 4자 단위로 하면 들여쓰기의 목적인 블록 구분이 불분명하게 된다. 8자 단위일 때 80칼럼을 넘는 줄이 많아지는 문제가 있지만 리누스는 소스를 고쳐서 들여쓰기를 3단계 이하로 고치라고 말한다.

2. 괄호 사용법


조건문은 다음과 같이 사용해야 한다.


if (x == y) {
..
} else if (x > y) {
...
} else {
....
}



함수는 다음과 같이 사용해야 한다.


int function(int x)
{
body of function
}

이렇게 하면 가독성을 높이면서도 빈 줄을 최소한으로 할 수 있다.


3. 명명 규칙


파스칼과 같이 대소문자를 섞어 ThisVariableIsA, TemporaryCounter처럼 쓰는 것은 좋지 않다. 전역변수나 함수는 그 기능을 충분히 설명할 수 있게 사용해야 한다. count_active_users();와 같이 써야 할 것을 cntusr();로 쓰면 안 된다. 함수형은 컴파일러가 검사하니까 함수형을 함수 이름에 집어 넣을 필요는 없다. 지역변수는 되도록 짧게 쓸 것.

4. 함수 크기


함수는 한 가지 일만 하게 하고 가능한 짧게 해야 이해하기 쉽고, 버그를 줄일 수 있다.

5. 주석문


소스에 주석문을 달아 주는 것이 좋지만 코드가 어떻게 동작하는지 일일이 설명하는 장황한 주석은 피해야 한다. 코드자체로 명확하게 동작방식을 보여 주어야 한다. 주석문은 왜 이 코드가 여기에 존재하는지 이 코드가 하는 일은 무엇인지를 알려주는 것으로 족하다.


이 외에 소스를 찾아 보면 알겠지만 리누스가 쓴 코드는 함수를 적절히 배치하여 함수 원형을 거의 선언하지 않고 있다. 다른 사람이 만든 코드는 대부분 함수 원형을 선언해 놓았다. 어떤 방법을 사용할지는 스스로 판단하기 바란다.


커널 부팅 옵션 처리 함수

MY_DEVICE가 사용하는 번지와 인터럽트를 바꾸기 위해서 lilo옵션으로 값을 주었을 때 커널에서 my_device_setup을 호출한다.



lilo 옵션


append="my_device=0x200,5,0x300,7,0x400,8,0x500,9


셋업으로 넘어온 인자


void my_device_setup(char *str, int *ints)

char *str="0x200,5,0x300,7,0x400,8,0x500,9";

int *ints={ 8, 0x200, 5, 0x300, 7, 0x400, 8, 0x500, 9};



옵션으로 준 문자열(*str)은 값으로 변환되어(*ints) 넘어온다. 커널에서 한 디바이스가 취할 수 있는 값의 개수는 9개로 제한되어 있다. 그 보다 많은 값이 필요하다면 *ints를 무시하고 *str을 다시 파싱해야 한다. *str에는 lilo옵션으로 넘겨준 문자열이 그대로 전송되기 때문이다. *ints 값 보다는 문자열이 필요한 경우에는 *str만을 사용할 수도 있다. 단순한 방법을 사용한 예는 char/lp.c, 문자열 만을 취하는 경우는 scsi/aic7xxx.c에 있고 그 외의 드라이버들은 문자열과 값을 적절히 사용하고 있다. 지면이 허락하지 않을 뿐만 아니라 완전히 공개되어 있는 커널 소스를 모두 보일 이유도 없으므로 앞으로 꼭 필요한 부분을 제외하고는 커널 코드는 생략하려고 하니까 여기서 거론된 부분은 반드시 직접 찾아 보기 바란다.


모듈로 적재할 때에는 약간 달라진다. 이때에는 my_device_setup이 호출되지 않는다. 곧바로 초기화 루틴인 my_device_init가 호출된다. 모듈 적재 함수 명은 모두 init_module로 통일되어 있으므로 드라이버 파일을 살펴보면 모두 아래와 같이 선언된 부분을 찾을 수 있다.



#define my_device_init init_module



초기화 루틴을 하나로 통일시키고 커널 삽입 방법과 모듈 적재 방법을 #ifdef MODULE - #else - #endif 짝으로 구분해서 실행하도록 해놓았다. 만약 인자를 lilo옵션과 동일하게 쓸 수 있도록 했다면 my_device_init의 #ifdef MODULE 안에서 문자열을 파싱해야 한다. 대부분의 드라이버들이 이런 작업을 피하기 위해서 아래처럼 모듈을 적재한다.



insmod my_device io=200,300,400 irq=5,7,8



이렇게 하면 insmod가 인자를 파싱하여 드라이버의 io와 irq 배열에 이 값을 초기화 시켜 주니까 드라이버를 작성할 때 io와 irq배열을 정의해 놓아야 한다. 배열명이 꼭 io,irq일 필요는 없다. 이때에는 개수의 제한이 io 배열의 크기와 같다. 모듈 적재와 커널 삽입 때의 호환성을 위해서는 크기를 커널 삽입시와 같게 해야 문제가 없을 것이다.


커널 2.2에서는 MODULE_PARM(my_device,"1-10i") 함수를 사용한다. 함수 인자를 살펴보면 어떤 방식인지 쉽게 알 수 있을 것이다. 일부 드라이버들은 my_device_setup과 my_device_init를 통합시켜서 모든 작업을 my_device_init에서 하게 한 드라이버들도 있다. 이런 드라이버는 my_device_setup 함수가 간단히 아래처럼 한 줄이다.



return(my_device_init());



옵션 값이 필요 없거나 스스로 디바이스를 찾을 수 있게 만든 드라이버들이 주로 이 방법을 사용한다. setup 함수는 디바이스 드라이버의 제일 뒤쪽에 주로 있고 함수 명은 *_setup이다. 드라이버마다 특별한 방법이 많기 때문에 가능하다면 많은 드라이버를 조사해 보기 바란다.


드라이버 초기화 함수

초기화 함수에서는 특정 주소로 값을 보내기, 바이오스 다운로드, 지원 디바이스 중에서 장착된 디바이스를 구별하는 작업들을 해야 한다. 커널 쪽으로는 인터럽트 등록, 드라이버 등록, 디바이스 개수와 번지를 지정하는 작업이 필요하다. 개발 초기에는 모듈적재 방법을 사용 해야 커널 재컴파일 시간을 줄일 수 있으므로 모듈 적재 방법을 중심으로 살펴보자.


my_device_init에서 인자를 파싱하는 드라이버는 코드 처음에 있다. setup 함수에서 말했으니까 설명하지 않는다. 초기화 함수에서 그 다음으로 할 일은 setup에서 지정한 번지, 인터럽트가 유효한 것인지 검사하는 일이다. 설정값을 잘못 지정했거나, 장착된 디바이스보다 더 많이 지정했거나, 디바이스가 작동하지 않는 경우 등을 모두 걸러내야 한다.


우선 my_device 드라이버를 등록하자. 등록 함수는 register_chrdev이다. 이 함수는 주 장치 번호, 디바이스 이름, 파일 조작 함수 인터페이스를 등록한다. 커널에서 이 디바이스를 사용하는 인터페이스는 다음과 같다.



static struct file_operations my_device_fops = {
my_device_lseek,
my_device_read,
my_device_write,
my_device_readdir,
my_device_select,
my_device_ioctl,
my_device_mmap,
my_device_open,
my_device_release
};



커널에서는 어떤 디바이스라도 파일과 같이 접근하므로 file_operations 구조체로 정의하고 read/write 루틴을 my_device에 고유한 방법으로 작성하면 된다. 문자 디바이스와 블록 디바이스에 따라 지원하는 함수는 차이가 있다. 예를 들어, my_device_readdir 함수는 문자 디바이스인 my_device에서는 전혀 의미가 없다. 이 함수는 커널 자체에서 전혀 사용하지 않으므로 NULL로 정의해도 아무 상관이 없다. 어떤 함수가 필요하고 어떤 함수가 필요 없는지는 크게 블록 디바이스와 문자 디바이스에 따라 차이가 난다. 만들려는 디바이스의 특성을 고려하여 연관이 있는 드라이버를 조사해 파악하기 바란다.

디바이스가 사용할 번지 주소가 유효한지는 검사하는 함수는 check_region이다. 주소가 유효하다면 request_region으로 이 주소를 점유한다. 해제는 release_region이다. 초기화 과정에서 드라이버나 디바이스가 문제가 있으면 반드시 이 함수로 영역을 반환하여야 한다. 함수 정의는 소스를 찾아 보기 바란다. 앞에서 커널 코드를 생략한다고 말했듯이 함수 설명도 가능한 한 함수 명만을 보이고 정확한 정의는 생략한다.


인터럽트를 사용한다면 이것을 사용하겠다고 요청해야 한다. request_irq/free_irq를 사용하면 된다. request_irq의 세 번째 인자는 SA_INTERRUPT를 사용한다. SA_INTERRUPT는 빠른 irq라고 정의되어 있다. 이 플래그는 신속한 인터럽트 서비스를 위해서 다른 인터럽트를 금지하고 가장 우선으로 인터럽트 처리 루틴을 수행한다. 문맥교환, 재인터럽트, 프로세스 블록킹이 일어나지 않는다. 여기에 0을 쓰면 느린 irq로 작동된다. 이 인터럽트는 인터럽트를 금지하지 않으므로 또 다시 인터럽트가 발생할 수 있다. SA_SHIRQ는 pci 디바이스가 인터럽트를 공유할 때 사용한다. 함수 원형과 자세한 옵션 플래그 설명은 include/asm/sched.h 에서 찾아 볼 수 있다.

request_region, request_irq를 수행했을 때 요청한 값을 사용할 수 있으면 디바이스가 실제로 장착되어 있는지 검사해야 한다. 디바이스의 정해진 주소에 장치 종류를 나타내는 문자열 같은 식별자가 있다면 그것을 읽어 오면 된다. 인터럽트를 사용한다면 테스트를 위해 카드가 인터럽트를 일으키도록 한 다음에 돌아오는 인터럽트를 검사하면 된다. 이 방법은 register_chardev(또는 register_blkdev,tty_register_driver)를 사용하여 미리 인터럽트 함수를 등록하고 사용해야 한다. 이런 방법을 쓸 수 없다면 지정한 번지에 디바이스가 있다고 가정하고 초기화를 해야 한다. lilo 옵션이 틀리지 않았다는 가정을 해야 하기 때문에 다른 장치와 충돌한다면 심각한 문제가 생길 수 있으므로 가능하다면 확실한 방법을 강구해야 한다. 지정한 장치가 실제로 있는지 검사하는 루틴은 모든 드라이버에 있으므로 잘 살펴보기 바란다.


드라이버가 사용할 메모리 영역이 필요하다면 kmalloc 함수로 일정 영역을 확보해 놓는다. kmalloc은 데이터를 저장할 메모리 영역을 확보하는데 사용하고 request_region 함수는 특정 주소의 메모리 영역을 확보하는 데 사용한다.


디바이스에 바이오스를 다운로드 하여 활성화 시켜야 한다면 상당한 고민을 해야 한다. 바이오스의 크기가 적다면 바이너리 파일을 바이트 배열로 만들어 정의해 두고 memcpy_toio 함수를 이용해서 다운로드 할 수 있다.(drivers/char/digi_bios.h) 몇 메가씩 되는 데이터를 다운로드 해야 한다면 이 방법을 사용할 수 없다. 바이트 배열은 정적 데이터가 되어 커널을 적재할 수도 없도록 크게 만들게 되기 때문이다. 가능한 방법은 일단 드라이버를 등록하고 ioctl 루틴을 이용해서 디바이스를 열어 다운로드 하고, 그 결과에 따라 드라이버를 활성화하거나 우회적으로 드라이버를 사용금지 또는 강제 삭제를 해야 한다.


드라이버 삭제 함수

모듈 방식으로 드라이버를 적재했으면 rmmod로 모듈을 삭제하는 함수를 작성해야 한다. cleanup_module로 함수 명이 통일되어 있고 모듈일 때만 필요하므로 #ifdef MODULE - #endif 안에서 정의해야 한다. 우선 kmalloc으로 할당 받은 메모리를 kfree 함수로 반납한다. request_region으로 확보한 주소는 release_region으로 해제하고 request_irq로 인터럽트를 사용했다면 free_irq로 반납한다. 마지막으로 장치 특수번호를 해제하여 커널이 디바이스를 사용하지 않도록 한다. 이 함수는 unregister_chrdev/tty_unregister_device 이다.


그외에 ioremap등의 함수를 사용했으면 이들과 짝이 되는 iounmap 등의 함수를 사용하여 모든 자원을 반납해야 한다. 자원을 제대로 반납하지 않고 모듈을 삭제하면 엄청난 양의 에러 메시지가 /var/log/messages에 쌓이게 된다. 좀 더 심각한 상황이 생겨서 파일 시스템 전체를 잃게 될 수도 있기 때문에 할당 받은 자원은 반드시 반납하는 함수를 철저히 확인하여 사용해야 한다. 

2.2 버전에서는 __initfunc(my_device_setup())으로 초기화 때만 필요한 함수를 정의해서 초기화가 끝난 후에 이 함수가 할당 받은 메모리를 회수하여 커널 메모리 사용량을 줄이고 있다.

드라이버를 적재하고 초기화하는 함수들은 몇 가지 공통사항만 정의되어 있을 뿐 함수 내부의 작성 규칙은 완벽하게 정해지지 않았다. 사용할 수 있는 함수도 수시로 없어지거나 인터페이스가 달라져서 프로그래머를 혼란스럽게 한다. 또한 사용 가능한 함수를 정리해 놓은 곳도 없고 설명도 충분하지 않다. 커널 프로그래밍을 위해서 만든 함수를 사용하지 않고 같은 기능을 수행하는 함수를 스스로 만들어서 사용한 드라이버도 많다. 아마 함수를 찾지 못했거나 있는 것을 모르고 드라이버를 작성해서 그럴 것이다. 필자도 필요한 기능을 하는 함수를 만들어 사용하다가 커널에 이미 그 함수가 있는 것을 알고 코드를 고친 경험이 있다. 비주얼 툴로 최상위 응용 프로그램을 만드는 일보다 쓸 함수도 변변찮은 로우레벨의 커널 디바이스 프로그래밍이 오히려 풍부한 상상력을 필요로 하는 작업인 이유가 여기에 있을 것이다.


헤더파일의 구조

헤더파일은 반드시 아래와 같이 시작하고 끝내야 한다.


#ifndef __LINUX_MY_DEVICE
#define __LINUX_MY_DEVICE
...
...
#endif


이렇게 해야 여러 파일에 헤더파일이 인클루드 되었어도 문제가 생기지 않는다. 커널에서 사용하는 변수이지만 사용자 프로그램에서 보여서는 안 되는 변수가 있다면 아래와 같이 막아 놓아야 한다.


#ifdef __KERNEL__
...
#endif


그리고 my_device_init는 외부에서 참조할 수 있도록 헤더파일에 꼭 선언해야 한다. init 함수는 커널에서만 사용하므로 __KERNEL__ 내부에 존재해도 상관은 없다.


초기화 할 때 많이 쓰는 함수들

outb/outw, inb/inw 함수는 물리 주소에 쓰기/읽기 함수이다. 이름이 보여 주듯이 바이트, 워드를 읽고 쓴다. readb/writeb 함수는 memory mapped I/O 장비에 읽고 쓰는 함수이다. memcpy_toio/memcpy_fromio 함수는 특정 주소에 데이터를 인자로 준 바이트만큼 쓴다. 각 플랫폼에 따라 커널이 보는 주소와 cpu가 보는 주소, 그리고 물리 주소의 차이를 없애는 역할을 한다. 물리 주소와 가상주소 시스템 버스와의 관계가 복잡하고 여러 플랫폼에 따라 주소 지정법이 다르다. x86 아키텍쳐에서는 물리주소와 memory mapped 주소가 동일하지만 다른 플랫폼에서는 x86과 다르기 때문에 호환성을 위해서 상당한 주의를 해야 한다. Documentation/IO-mapping.txt를 살펴보면 리누스가 메모리 접근함수 사용시 주의할 점에 대해서 설명해 놓았다. 디바이스가 범용 플랫폼에서 동작하기를 바란다면 꼭 읽어 보아야 한다.


cli() 함수를 사용하여 인터럽트를 금지 시키고 중요한 작업을 한 다음에 sti() 함수로 인터럽트를 가능하게 만든다. 드라이버 프로그램을 부르는 함수에서 부르기 전후에 플래그 상태가 변화한다면 문제가 발생할 수 있기 때문에 cil/sti 짝으로만 쓰지는 않고 save_flags(flags); cli(); sti(); restore_flags(flags); 형식으로 쓴다. 이렇게 하면 불리기 전의 상태가 보존되므로 드라이버 프로그램 안에서 안심하고 플래그를 조작할 수 있다. sti()는 드라이버 프로그램 안에서 인터럽트를 가능하게 할 필요가 있을 때 사용하면 된다. 인터럽트 가능 불가능에관계없이 드라이버가 불릴 때의 상태에서 동작해도 상관없다면 save_flags;cli;restore_flags를 사용하면 된다. 원 상태가 인터럽트 가능이라면 restore_flags가 sti 함수 역할도 하기 때문이다. 주의할 것은 드라이버 함수에서 여러 하위 루틴으로 뛰는 동안에 save_flags;cli;restore_flags 순서가 유지되어야 하는 것이다. 함수가 조건문 등으로 분기할 때 자칫 restore_flags 함수가 수행되지 않는 등의 오류가 있으면 시스템이 정지하게 된다.


이런 에러는 상당히 발견하기 어려운 것이다. 어디서 시스템이 정지했는지 정보를 얻기가 힘들다. printk 함수를 사용해서 소스의 위치를 추적하더라도 찾기 힘들다. printk 함수는 수행되는 즉시 문자열을 /var/log/messages나 콘솔 화면에 쓰지 않고 쓸 데이터가 많거나 시스템이 바쁘지 않은 동안에 flash를 하므로 printk 함수가 아직 완전히 수행되지 않은 상태에서 시스템이 정지하는 경우가 많기 때문이다. 그러므로 코딩 시에 철저히 save_flags;cli;restore_flags의 순서와 흐름을 따져서 사용해야 한다.


디바이스에 어떤 조작을 하고 일정 시간을 기다릴 때에 사용할 수 있는 다양한 함수가 존재한다. 디바이스를 조작하고 결과를 체크하는 일을 수 밀리 초의 간격 안에 모두 해야 하는 경우가 있다. MY_DEVICE에 차체 cpu가 들어 있고 이 cpu를 활성화 시키기 위해서는 MY_DEVICE의 base 주소에 0을 쓰고 400ms와 500ms 사이에 1을 써야 한다고 하자. 이때에는 절대적인 대기 함수를 사용해야 한다. 여러 문서에 대기에 대한 함수 설명이 있지만 필자의 경험으로는 __delay() 함수만 이런 기능을 정상적으로 수행했다. 인자는 __delay((loops_per_sec/100) * (원하는ms))를 사용하면 된다. 이 함수는 문맥교환이 일어나지 않기 때문에 실행되는 동안 시스템이 정지해 있게 된다. 드라이버의 일반 작업에 사용하면 효율이 엄청나게 떨어지므로 절대적인 시간이 필요한 초기화 작업 같은 경우를 제외하고는 이 함수를 사용해서는 안 된다.


일반 작업에서 대기 함수가 필요하면 다음과 같이 사용하면 된다.


current->state = TASK_INTERRUPTIBLE;
current->timeout = jiffies + HZ * 3;
schedule();


current란 현재 수행되고 있는 프로세스를 말한다. 이 프로세스는 일정한 시스템 시간을 할당 받아 현재 이 라인을 수행하고 있다. 인터럽트가 가능하게 만들고(TASK_INTERRUPTIBLE) 깨어나는 시간을 앞으로 3초 후로 정하고 (HZ*3) 잠들게(schedule) 한다. jiffies란 시스템의 현재 시간을 의미한다. HZ는 1초에 해당하는 값이고 schedule은 현재의 프로세스를 블록 시키는 함수이다. 커널은 이 프로세스가 잠들고 있는 동안 다른 작업을 할 수 있게 되기 때문에 효율을 높일 수 있다. timeout이 3초로 되어 있지만 반드시 이 시각에 깨어난다는 보장은 없다. 깨어날 수 있는 보장이 3초 후부터라는 것일 뿐이다. timeout 값이 너무 작으면 문맥교환이 자주 일어나서 효율이 떨어지고 너무 크면 드라이버 작업 수행 성능이 떨어지므로 대기 시간을 잘 조사해서 적당한 값을 설정해야 한다.


커널은 프로세스에게 각종 signal을 줄 수 있다. include/asm/signal.h에서 정의된 여러 시그널을 받은 프로세스는 가능한 한 신속하게 작업을 끝내기 위해서 블록되지 않으므로 schedule이 무시되기 때문에 코딩을 할 때 이 것을 염두에 두고 깨어났을 때 정상상태에서 깨어난 것인지 signal을 받았는지 구별해서 동작하게 해야 한다. signal을 받았는지는 깨어난 후에 다음과 같이 알아 낼 수 있다.


if(current->signal & ~current->blocked)
//signal
else
// no signal


시그널을 받은 프로세스는 더 이상 작업을 진행하는 것이 의미가 없기 때문에 디바이스를 연 프로세스의 개수가 기록된 변수의 처리 등 꼭 필요한 일만 하고 신속하게 끝내야 한다. 2.2 버전에서는 signal_pending(current)로 바뀌었다.


앞에서 말한 대기 함수는 대기하는 프로세스가 일정 시간 이후에 깨어나서 바뀐 조건을 검사하는 것들이다. 대기하고 있는 동안에 다른 루틴에서 조건을 변화시키고 대기 프로세스를 깨워 준다면 조건이 만족하기 전에 깨어났다가 다시 잠드는 오버헤드를 줄일 수 있을 것이다. 이를 위해서 sleep/wake_up 함수가 있다.


sleep_on/interruptible_sleep_on, wake_up/wake_up_interruptible 함수는 잠들고 깨우는 일을 한다. interruptible_sleep_on 함수는 signal이 오거나 wake_up_interruptible 함수가 깨워 주거나 current->timeout 값에 시스템 시간이 이르면 깨어난다. 이 함수를 실행하기 전에 timeout 값을 조정해 줄 수 있으므로 깨우는 함수가 제대로 실행되지 않았다고 판단할 만큼 충분한 시간을 주고 잠들게 하면 된다. 이 함수는 대기 큐가 필요하므로 사용하기 전에 전역변수로 큐를 정의하고 초기화 시켜 놓아야 한다. 함수 명은 init_waitqueue 이다.


interruptible_sleep_on/wake_up_interruptible 함수는 인터럽트를 사용하는 드라이버에서 디바이스에 인터럽트를 걸어 놓고 잠들면 디바이스가 처리를 끝내고 인터럽트를 걸어 깨워 주는 경우에 많이 사용한다. 이 함수도 schedule과 마찬가지로 signal을 받으면 무조건 깨어 나기 때문에 꼭 상태를 체크해야 한다.


sleep_on/wake_up 짝은 wake_up 함수가 반드시 수행된다는 확신이 있는 경우에 사용된다. sleep_on으로 잠들면 timeout 값이 지나거나 시그널이 와도 깨어 나지 않고 오로지 wake_up만이 깨울 수 있다. wake_up 함수가 수행되지 않는다면 시스템이 교착상태에 빠질 수 있기 때문에 100% 확신이 없으면 사용하지 않는 것이 좋다. drivers/char/lp.c에서 사용되었지만 2.0.33에서는 interruptible로 바뀌었다. drivers/block 의 일부 드라이버에 사용예가 있으므로 참고하기 바란다.


그 외에 커널 해커 가이드의 지원함수 부분에 나머지 대기 함수에 대한 설명이 있다. 함수 설명을 읽고 드라이버를 조사해서 사용 방법을 알아 두기 바란다. 드라이브가 shared memory 방식으로 디바이스와 교신하기 위해 메모리를 확보하고 이것을 사용한다고 하자. 이 메모리 영역을 메일박스라고 부른다. 이 메일박스를 위해서 일정한 양의 메모리가 필요하다. 메일박스를 만들 때 마다 메모리를 할당 받는다면 상당한 오버헤드가 생기므로 디바이스 초기화 때에 영역을 확보해 놓고 계속 사용하면 좋을 것이다. 이때 사용할 수 있는 함수는 kmalloc이다. kmalloc이 한 번에 할당할 수 있는 메모리 양은 16KB로 제한되어 있다.


할당 받은 메모리 영역을 초기화 할 때는 memset 함수를 쓸 수 있다. 인자로 주는 값으로 메모리 영역을 채운다. 메모리에서 메모리로 데이터를 복사할 때는 memcpy를 사용할 수 있다. 이 함수는 커널 메모리사이에서 복사할 때만 쓰는 함수이다. 절대로 유저 데이터 영역과 커널 메모리사이의 복사에서 사용해서는 안 된다.


개발 초기에는 insmod 인자가 정상적으로 전달되었는지 확인한다든지 어떤 디바이스가 인식되었는지 보여 주는 출력루틴을 가능한 많이 삽입하는 것이 좋다. C 라이브러리 루틴의 printf는 사용할 수 없지만 커널 프로그래밍을 위해서 printk가 있다. 몇 가지 제한이 있지만 printf와 거의 유사하다. 정확한 형식은 lib/vsprintf.c에서 볼 수 있다.


디바이스의 I/O 컨트롤

디바이스 드라이버가 제대로 적재되고 나서 설정값을 바꿀 필요가 있을 때 ioctl 함수를 부른다. C 라이브러리의 ioctl 함수를 이용해서 커널 드라이버의 ioctl 함수에 접근 할 수 있다. C 라이브러리의 ioctl 함수 원형은 다음과 같다.


int ioctl(int d, int request,...);


d는 파일기술자이며 /dev/my_device0에 해당한다. 우선 open 함수로 my_device0를 열어 보고 정상적으로 열리면 ioctl을 실행할 수 있다. ioctl의 두 번째 인자에 원하는 명령을 넣는다. 만약 my_device0의 어떤 값을 바꾸게 하고 싶으면 include/linux/my_device.h에 아래와 같이 정의하고 유저 프로그램에서 사용할 수 있도록 하면 된다.


#define MY_DEVICE_CHANGE_VALUE _IOWR(MY_DEVICE_MAJOR, 1, int[2])


_IOWR/_IOR 매크로는 include/asm/ioctl.h에 정의되어 있고 여러 가지 비슷한 매크로가 있다. 함수 명이 보여주는 대로 세 번째 인자에서 지정한 영역을 읽기만 가능/읽고 쓰기 가능한 형태로 커널에 전달하게 된다. 이 매크로는 첫 번째 인자와 두 번째 인자를 결합해서 커널에 전달한다. 두 번째 인자에 들어 갈 수 있는 값의 크기가 얼마나 되는가는 ioctl.h를 조사해 스스로 알아보기 바란다.


ioctl의 세 번째 인자는 커널에 전달하는 값이나 값의 배열 또는 커널로부터 받을 데이터 영역이다. 커널에 값(값배열)을 전달하고 같은 곳에 커널로부터 값을 받을 수도 있다.


커널의 ioctl은 보내온 명령에 따라 완전히 독립된 작업을 해야 하기 때문에 가장 지저분한 부분이다. 대부분의 드라이버들이 switch 문을 사용해서 명령에 따른 작업을 하는 코드를 작성해 놓았다. 해야 하는 작업에 따라 내용이 다르지만 커널 프로그래밍과 크게 차이가 나는 것은 아니다. 가장 중요한 것은 C 라이브러리 함수인 ioctl에서 보내온 데이터를 주고 받는 방법이다. 커널이 보는 메모리 영역과 사용자 프로그램이 보는 메모리 영역은 완전히 다르기 때문에 memcpy 등의 함수를 사용해서는 안 된다.


커널에서 유저 프로그램에서 데이터를 읽어 오거나 쓰기 위해서는 우선 읽어 올 수 있는지 확인해야 한다. verify_area(VERIFY_READ/VERIFY_WRITE ..) 함수를 사용해서 읽거나 쓰는 데 문제가 없으면 memcpy_fromfs/memcpy_tofs 함수를 사용할 수 있다. 이 함수 명은 사용자 데이터 세그먼트와 커널 데이터 세그먼트를 구별하는 인텔 CPU의 레지스터 명이 fs인 데서 유래했다. 2.2 이상에서는 여러 플랫폼에서 일반 명칭으로 사용하기 위해서 copy_from_user/copy_to_user로 바뀌었다. 2.2에서는 또한 verify_area를 할 필요가 없다.


ioctl 함수는 사용하기에 따라 수많은 일을 할 수 있다. ioctl을 사용하여 할당 받은 인터럽트를 바꿀 수도 있고 점유하고 있는 물리 주소도 변경할 수 있다. linux-ggi 프로젝트 그룹에서는 모든 VGA드라이버를 단일화 시키고 ioctl을 사용해서 각 VGA카드의 특성을 조정해서 사용하자고 제안하고 있다. 이 방법은 리눅스 VGA드라이버 작성을 위한 노력을 대폭 줄일 수 있는 획기적인 방법이다.



3. 디바이스 드라이버 입출력 함수


이제 디바이스 드라이버의 핵심인 입출력 루틴에 대해서 알아보자. 이 부분은 하드웨어와 가장 밀접한 부분으로 하드웨어의 기계적인 작동까지도 고려해야 하는 복잡하고 힘든 작업을 필요로 한다. 게다가 책에서만 보던 세마포, 교착상태 스케줄링 등 운영체제에서 가장 중요한 부분을 직접 구현해야 하는 일이기도 하다. 온갖 개발툴이 난무하고 마우스만으로 프로그래밍을 끝낼 수 있는 최상위 응용프로그램 개발 환경이 지배하는 요즈음, 이렇게 원론적이고 근본적인 작업을 할 수 있다는 것은 즐거움이기도 하다. 심각한 소프트웨어 위기가 닥치면 닥칠 수록 컴퓨터를 배우는 사람들, 컴퓨터를 사용하여 무엇인가를 이루어 보려는 사람들은 가장 근본적인 부분을 다시 들여다 보아야 할 필요가 있다. 특히 컴퓨터를 배우고 있는 학생이라면 반드시 리눅스 커널에 관심을 가져야 하는 이유가 바로 여기에 있는 것이다.


커널이 발전하면서 필요에 따라 변수명이나 함수 명들을 바꿀 필요가 생긴다. 유저프로그래밍에서는 라이브러리 함수 인터페이스가 바뀔 수도 있지만 write같은 가장 기본적인 인터페이스는 거의 바뀌지 않는다. 커널 프로그래밍에서는 이런 것을 기대할 수 없다. 커널의 외부인터페이스만 유지될 뿐 내부에서는 모든 것이 바뀔 수 있다고 생각해야 한다. 특히 리눅스는 개발버전과 안정버전의 두 가지 버전이 공존하기 때문에 제작한 드라이버가 새버전의 커널에서 문제 없이 컴파일 될 수 있는지 확인할 필요가 있다. 리눅스 커널 프로그래밍의 변경 내용을 공존시키기 위해서 만들어진 변수가 LINUX_VERSION_CODE 이다.


커널 소스의 Makefile 가장 앞부분에 다음과 같은 내용이 있다.


VERSION = 2
PATCHLEVEL = 2
SUBLEVEL = 34


이 리눅스 커널 소스는 보통 부르는 방식으로 2.2.34 버전이며 버전코드는 다음과 같이 코딩된다.


#define LINUX_VERSION_CODE 0x020234


2.0.x 버전과 2.2.x 버전에서 많은 것이 바뀌었지만 그 중에 가장 좋은 예는 파일조작함수의 드라이버 쓰기 함수인 my_device_write가 바뀐 것이다. 앞에서 얘기했듯이, 필자는 이 것이 C 라이브러리 표준 함수인 write와 동등한 것으로 생각하고 컴파일 할 때 에러가 나왔지만 원인을 찾을 수 없었다. 다른 드라이버를 뒤져보고 나서야 넘어오는 인자가 바뀌었다는 것을 알고는 당황한 경험이 있다. 커널 프로그래밍에서는 모든 것이 바뀔 수 있다. 이 점을 반드시 기억해야 커널 버전업에 유연하게 대처할 수 있다. 이 문제는 다음과 같이 해결할 수 있다.


#if (LINUX_VERSION_CODE >= 0x020158)
static ssize_t my_device_write(struct file *, const char *, size_t count, loff_t *);
#else
static int my_device__write(struct inode *, struct file *, const char *, int);
#endif


MY_DEVICE_OPEN 함수

라이브러리의 open("/dev/my_device0",...) 호출이 오면 커널이 최종적으로 부르는 루틴은 my_device_open 함수이다. 여기서는 디바이스 상태를 점검하고 열 수 있는지 확인한 다음 필요한 메모리 등을 준비하고 다른 루틴에서 사용하지 못하도록 busy 세팅을 하면 된다.


모듈 방식으로 적재했을 때를 생각해보자. 만약 open,read/write 루틴을 수행 중인 상태에서 rmmod를 사용해서 모듈을 삭제하면 디바이스 드라이버 루틴을 수행할 수도, 정상적으로 중단할 수도 없게 되어 커널이 정지하게 된다. 그러므로 디바이스 드라이버 루틴을 수행 중인 프로세스가 있으면 이를 알려주는 변수를 증가시켜서 모듈 삭제 함수가 참고하게 하면 된다. 이 변수 값을 증가/감소시키는 매크로는 MOD_INC_USE_COUNT/MOD_DEC_USE_COUNT이다. my_device_open 함수를 수행하면서 열기에 문제가 없다면 이 값을 증가시킨다. 만약 이 값을 증가시킨 후에 open 루틴을 수행하면서 문제가 생겨 에러로 리턴할 때에는 반드시 값을 감소시켜야 한다. 증가/감소 짝이 맞지 않으면 드라이버를 삭제할 수 없다. 정상적으로 작동된다면 release 루틴에서 감소시키면 된다. write/read 수행 도중에 에러로 리턴하면 커널이 release를 부르기 때문에 write 루틴에서는 신경 쓸 필요는 없다.


커널이 open을 호출 할 때에는 주장치번호를 보고 호출하므로 내부에서 부장치번호를 구해야 한다. 부장치번호는 MINOR(inode->i_rdev) 매크로로 구할 수 있다. 사용자가 open("/dev/my_device255"..)처럼 호출할 수도 있으므로 이 값이 유효한 값인지 그리고 실제로 사용 가능한 값인지 확인해야 한다. 전역변수로 MY_DEVICE_MAX를 선언하거나 my_device 구조체 배열을 선언하고 exist 필드를 정의한 다음 유효한 부장치 번호의 exist필드를 세팅하면 된다. drivers/char/lp.c를 보면 lp[0-3]까지를 이런 방식으로 사용하고 있다.


커널은 부장치가 다른 루틴에서 사용 중인지 검사하지 않는다. 열기 요청이 오면 무조건 open 루틴으로 제어가 이동하기 때문에 다른 루틴에서 사용하지 못하게 하거나 사용이 끝나는 시점이라는 것을 알려 주는 일은 드라이버의 몫이다. 마찬가지로 my_device 구조체 안에 busy 필드를 정의하고 이를 사용하면 된다. 간단히 my_device에 필요한 정보를 담는 구조체를 만들어보자.


my_device는 카드에 CPU가 있고 이 카드가 2개 이상의 부장치번호를 제어한다. 만일 멀티시리얼포트라면 8-64개까지의 시리얼포트를 한 카드가 제어하게 될 것이다. 그리고 각 포트는 포트별로 필요한 정보와 이 포트가 어느 카드에 있는 것인지 알려주는 정보가 있어야 할 것이다. 우선 카드 정의를 한다.



struct MY_DEVICE_CARD {
int number; /* 카드번호 */
u_int flag; /* 상태 플래그 */
int base; /* base 주소 */
u_int irq; /* 인터럽트 번호 */
u_int address; /* memory mapped I/O 주소 */
u_char *mailbox_kernel; /* 커널에서 보낼 메일박스 */
u_char *mailbox_card; /* 카드에서 보내온 메일박스 */
struct semaphore semaphore; /* 주장치의 세마포 */
struct wait_queue *queue; /* 주장치의 대기큐 */
};
struct MY_DEVICE_CARD my_device_card[MY_DEVICE_CARD_MAX];

이에 따른 포트의 정의는 다음과 같이 할 수 있다.

struct MY_DEVICE_PORT {
int number; /* 포트 번호 */
int card_number; /* 포트가 속한 카드 번호 */
u_int flag; /* 상태 플래그 */
u_char *buffer; /* 버퍼 포인터 */
};
struct MY_DEVICE_PORT my_device_port[MY_DEVICE_PORT_MAX];



위의 내용은 여태까지 설명했던 글에서 필요하다고 생각되는 데이터를 모아본 것이다. 우선 card->irq, card->base는 인자로 받을 수 있으므로 초기값을 0로 두어서 카드가 존재하는지 판단할 수 있게 한다. 이 값에 따라 포트가 사용 가능인지 정해지므로 초기화시에 카드에 딸린 포트를 검사해서 port->flag의 exist필드를 세팅할 수 있을 것이다. card->address는 메모리멥 방식 I/O에서 사용할 주소값이고 mailbox_* 는 카드와 드라이버가 교신하면서 사용할 메일박스 (Documentations/IO-mapping.txt참조)이다. 이것은 데이터를 주고 받는 것이 아니고 주장치의 상태를 점검하고 카드가 상태를 보고하는 용도로 사용되는 것이므로 부장치가 아닌 주장치에 있어야 한다. 대기큐도 한 주장치번호에 할당되어서 각 부장치에서 돌아가는 프로세스들이 공유하게 된다. 멀티시리얼포트나 같은 장치를 여러 개 달 수 있는 하드웨어는 부장치번호를 가지고 주장치번호를 구해 낼 수 있어야 하므로 port->card_number가 반드시 필요하다. 버퍼는 실제 데이터를 보내는 것이므로 부장치들이 하나씩 가지게 된다.

이런 형태가 멀티 시리얼이나 동일 하드웨어를 여러 개 붙일 경우에 일반적으로 사용되는 방법이다. 각 필드에 접근하는 방법은 다음처럼 하게 된다.



struct MY_DEVICE_CARD *card = my_device_card[my_device_port[minor].card_number];
u_char *mailbox = card->mailbox_kernel;



이런 방법이 코드를 복잡하게 만들기 때문에 일부 드라이버에서는 매크로 정의로 처리하고 있다.



#define MY_CARD(minor) my_device_card[my_device_port[(minor)].card_number]
...
struct MY_DEVICE_CARD *card = MY_CARD(minor);
...



C 프로그래밍의 기초적인 이야기를 여기서 하는 이유는 커널에서 코드의 가독성과 효율을 높이기 위해서 사용하는 매크로들이 오히려 가독성을 떨어뜨리고 마치 소스가 암호와 같은 모양을 띄게 하기 때문에, 이런 매크로들이 나오면 선언부를 찾아 보고 그 내용을 익히라는 말을 하기 위해서이다. 리눅스 커널에서 사용되는 대문자 변수들은 거의가 상수값이 아니라 복잡한 매크로인 경우가 대부분이기 때문이다.



my_device가 취할 수 있는 상태값은 여러 가지가 있을 수 있다. 간단하게 몇 가지만 생각해보자.



#define MY_DEVICE_PORT_IDLE 0x00001
#define MY_DEVICE_PORT_BUSY 0x00002
#define MY_DEVICE_PORT_ERROR 0x00003
#define MY_DEVICE_STATUS_EXIST 0x00010
#define MY_DEVICE_STATUS_BUSY 0x00020
#define MY_DEVICE_STATUS_ERROR 0x00040



위에서 사용한 두 가지 방법은 약간 다르다. 우선 첫 번째로 값을 10진수로 증가시키는 방법은 하드웨어가 상태를 보내올 때 사용할 수 있다. my_device 카드가 보내오는 값이 10진수 값이라는 말이다. 이때 상태를 판단하기 위해서는 다음과 같이 해야 할 것이다.



if(status==MY_DEVICE_PORT_IDLE || status==MY_DEVICE_PORT_BUSY)
...



이렇게 10진수를 사용하는 방법은 드라이버가 받는 정보가 10진수일 때를 제외하고는 사용하지 않는 것이 좋다. 왜냐하면 각 비트를 상태값으로 이용하는 것에 비해서 단점이 많기 때문이다. 우선 상태정보를 분류할 수 없다. 비트 이용법은 처음 4비트는 드라이버 자체 에러, 다음 4비트는 하드웨어에러 등으로 분류해서 쉽게 에러 종류를 구해낼 수 있다.



if((status & 0xf0) != 0) // hardware error
else if((status & 0x0f) != 0) // driver error



10진수 사용법은 상태정보의 추가/변경이 힘들다. idle(1)에서 busy(2)로 바꾸는 것은 문제가 없지만 card_error, card_wait 등의 드라이버에서 상태를 유지해야 하는 정보를 위해서는 복잡한 방법을 사용하든지 card_status 필드를 또 하나 추가해서 사용해야 한다. 리눅스에서 unsigned int는 32비트이므로 동시에 32개의 상태를 설정할 수 있다. 비트 이용법은 32개의 상태까지는 비트열 정의만으로 간단히 해결할 수 있다. 속도와 효율성 그리고 메모리 사용량의 최적화를 요구하는 커널 프로그래밍에서 필요할 때마다 자원을 추가해 나가는 것은 좋은 방법이 아니다.


10진수 사용법은 속도가 느리다. 여러 상태정보를 얻기 위해서 사용해야 할 연산이 많아지게 되므로 비트이용법보다 느리고 코드가 복잡해진다. 한 하드웨어가 이 이상의 상태정보가 필요한 경우는 별로 없으므로 my_device_card에서처럼 상태정보는 flag 필드의 비트위치로 빠르고 간단하게 판단할 수 있게 하는 것이 좋다.

드라이버에서 리턴하는 에러값은 include/asm/errno.h에 정의된 값에 (-)부호를 붙여서 돌려 주면 된다. 치명적인 에러에는 EIO, ENOMEM 등이 있을 수 있고 커널에서 처리를 하고 드라이버 열기를 재시도 할 수 있게 하는 EAGAIN 등이 있을 수 있다. 에러 종류와 리턴값은 여러 드라이버 소스를 보고 파악하기 바란다.


my_device_open에서 해야 하는 일을 요약하자면 다른 프로세스가 부장치번호를 사용 중인지 검사하고 이 번호가 유효한 것인지 체크한 다음 MOD_INC_USE_COUNT를 사용해서 모듈 삭제시에 참고하게 한 후에 필요한 자원을 할당 받고 다른 프로세스가 이 장치를 사용할 수 없도록 busy 세팅을 하면 된다.


MY_DEVICE_READ/WRITE 함수

열기 함수를 수행했다면 커널에서 읽기/쓰기 함수를 호출할 수 있다. 열기/쓰기 함수의 원형은 2.2.x에서 다음과 같다.



static ssize_t my_device_read(struct file * file, char * buf, size_t count, loff_t *ppos);
static ssize_t my_device_write(struct file * file, const char * buf, size_t  count, loff_t *ppos);


쓰기함수에서는 쓸 데이터가 있는 주소 buf가 전달되어 오고 읽기 함수에서는 읽어갈 데이터 영역인 buf 주소가 전달되어 온다. 쓰기함수의 데이터는 커널에서 처리가 되어 커널 메모리 영역으로 생각하고 사용하면 된다. ioclt 함수 사용법에서 말했듯이 memcpy 등을 사용할 수 없고 copy_to_user를 사용해야 한다. 참고로 한번에 buf로 넘어오는 데이터의 최대값은 4096바이트이다.

read/write에서 물론 부장치번호를 체크해야 한다. 사용할 수 없는 부장치에 접근 요구가 오면 에러값을 가지고 리턴해야 한다. inode가 넘어오지 않으므로 file 구조체에서 찾아야 한다. 어떻게 찾는지는 여러 소스를 찾아보기 바란다. count는 읽거나 쓸 데이터의 양을 나타내고 ppos는 그 지점의 주소값이다. ppos는 블록 디바이스에 주로 쓰게 되며 문자 디바이스에서는 거의 사용되지 않는다.


쓸 데이터가 있고 쓸려는 부장치가 유효한 것이라면 이제 하드웨어를 검사한 후에 데이터를 쓰면 된다. 하드웨어 검사는 드라이버에서 쓸 때마다 할 수도 있고 하드웨어가 상태변화를 보고할 수도 있다. 여러 가지 방법은 하드웨어 특성에 따라 다르고 각 디바이스가 사용하는 방법이 다르므로 만들려는 드라이버에 맞게 작성해야 한다. 상태가 정상이라면 문자디바이스일 때 한 번에 한 바이트씩을 쓰고 결과를 보면서 그 다음 바이트를 보내거나(lp.c), 하드웨어가 문자 디바이스를 다루지만 내부적으로 대량의 데이터를 받을 수 있는 장치라면 일정 단위의 데이터를 보내면 된다. 블록 디바이스라면 자체적으로 데이터를 보낼 수도 있고 block_write라는 블록 디바이스 공용 루틴을 사용할 수도 있다.

쓰거나 읽을 데이터 양을 모두 처리하는 동안 문제가 생기지 않았으면 쓰거나 읽은 데이터 양을 인수로 하여 리턴하면 된다. 시그널을 받았으면 -EINTR 값을 사용하고 그 외에 에러가 발생하였으나 커널 쪽에서 재시도를 하기를 원한다면 그 때까지 읽은/쓴 데이터 양을 인수로 하여 리턴한다. 각종 에러가 발생했고 커널 쪽에서 읽기/쓰기를 재시도 할 수 없는 에러이면 커널에서 my_device_release를 스스로 호출하게 되어 있으므로 open/read,write/release 전체에 걸친 변수값 등을 read/write 루틴에서 신경 쓸 필요는 없다. read/write 루틴 안에서 사용한 값과 이 루틴이 정상적으로 끝났을 때 변경되는 전역 변수에 대해서만 신경을 쓰면 된다. 읽기/쓰기에서 꼭 알아야 할 함수 사용에 대해서는 뒤에서 설명하겠다.


MY_DEVICE_RELEASE 함수

release 함수에서는 open에서 했던 일을 역으로 하면 된다. 우선 메모리영역을 할당 받아서 사용했다면 반납한다. 부장치에 대해서 busy 세팅을 했다면 이를 해제한다. 모듈을 사용했다면 MOD_DEC_USE_COUNT를 실행해서 모듈 사용값을 감소시킨다. 이 매크로는 자체적으로 모듈로 적재되었는지 커널에 같이 컴파일 되었는지 구별하기 때문에 #ifdef MODULE 을 사용하지 않아도 된다. 인터럽트를 open에서 할당받았다면 여기서 해제한다. 인터럽트를 공유하는 장치들은 이렇게 사용하고 있다.


2.0.x에서는 리턴값이 없었으나 2.2.x에서는 리턴값이 생겼다. 정상적으로 진행되었다면 0을 리턴하면 되고 그렇지 않다면 에러값을 가져야 한다. 어떤 값을 가질 수 있는지는 다른 드라이버를 참고하기 바란다. 이 루틴에서 가장 중요하게 고려해야 할 것은 모든 작업이 또 다른 open이 가능하게 만들어야 한다는 것이다. 새로운 open 함수가 실행되어서 사용할 변수나 값들이 최초로 open이 실행되는 경우와 동일하게 되어 있어야 한다. 그러므로 open,read,write 함수에서 사용된 변수들을 철저히 조사해서 반드시 제자리로 돌려 놓는 데 신경을 써야 할 것이다. 이 일을 제대로 하지 못하면 사용할 수 있는 디바이스인데도 불구하고 한 번만 사용하고 나면 더 이상 쓸 수 없는 상태로 된다.

이런 에러는 open을 실행 할 때 나타나기 때문에 release 루틴에서 잘못했다고 생각하지 않고 open 쪽을 먼저 생각하여 시간을 낭비하게 되므로 많은 디버깅 노력이 들어간다. MY_DEVICE_INTERRUPT 함수 인터럽트는 디바이스가 인터럽트를 사용할 수 있는 장치를 제어할 때 사용할 수 있다. 인터럽트를 사용하면 대기시간 동안 커널에서 다른 작업을 할 수 있으니까 효율이 높다. 그리고 디바이스가 작업을 끝내는 즉시 드라이버의 인터럽트 루틴을 수행해서 대기 프로세스를 활성화 해주기 때문에 대기시간을 최소화 할 수 있다. 드라이버에서 디바이스의 상태를 체크하는 폴링(polling)방식은 인터럽트 방식의 장점을 반대로 생각하면 된다. 대기 프로세스가 작업이 끝나지 않았는데도 깨어나서 디바이스를 체크해야 하는 오버헤드가 있으며 대기 중인 프로세스는 디바이스가 작업을 끝냈는데도 지정된 대기시간을 모두 소모해야 다음 작업을 진행할 수 있다. 즉 폴링방식은 효율이 나쁘고 대기시간이 길다. 폴링방식으로 한 바이트씩 데이터를 쓰는 프린트 드라이버 루틴이 실행 중일 때 엑스에서 마우스 움직임도 느려지는 것을 볼 수 있다.

이런 단점에도 불구하고 폴링방식은 코드가 이해하기 쉽고 간단하기 때문에 드라이버 제작자가 관심을 가진다. 반대로 인터럽트 방식은 절차적 프로그래밍에 익숙한 응용 프로그램을 만들던 개발자들이 가장 어려워하는 방식이다. 제어가 한 군데에서 이루어지지 않고 전역 변수가 동시에 여러 군데에서 참조가 되면서 개발자들을 혼란스럽게 만들기 때문이다. 폴링 방식은 간단히 이해할 수 있기 때문에 언급을 하지 않겠다. 커널해커가이드의 지원함수 부분을 보면 자세하게 나와있으니까 이를 참조하기 바란다.


인터럽트 방식을 사용하기 위해서 먼저 생각해야 할 것은 인터럽트 함수의 실행시간을 가능한 한 짧게 만들어야 한다는 것이다. 같은 작업을 가장 신속하게 끝낼 수 있는 방법을 생각해야 한다. "빠른"인터럽트는 자기보다 우선순위가 낮거나 같은 인터럽트를 금지하기 때문에 인터럽트 처리 루틴이 비효율적이고 시간이 많이 걸리면 시스템이 느려지고 추가 인터럽트가 사라지는 등 신뢰성도 나빠지기 때문이다. 여러 소스를 찾아 보다 보면 인터럽트 루틴의 실행시간을 줄이기 위해서 고민한 개발자들의 글을 찾아 볼 수 있다.


인터럽트 루틴에서는 공통루틴이 있다고 이들을 모아서 실행한 후에 다른 부분만 뒤에 나누어서 실행하게 하는 등의 "코드를 보기 좋게" 하는 작업을 해서는 안 된다. 코드가 지저분하게 되더라도 여러 조건에 따라 다른 작업이 있다면 이들 각각의 작업에 가장 효율적인 형태로 코딩을 해야 한다. 인터럽트 루틴은 아름다움보다는 효율이 우선시되기 때문이다. 일부 드라이버는 이를 위해서 goto 문장도 아무 거리낌 없이 사용하고 있다.


인터럽트루틴에서 우선 해야 할 것은 넘어온 인터럽트가 이 디바이스에서 처리할 것인가를 조사하는 것이다. my_device_card의 지정된 인터럽트와 이 인터럽트가 일치하는지 검사한다. 물론 이 인터럽트가 아닐 경우는 매우 희박하다. 인터럽트를 비교하는 중요한 목적은 my_device_card가 여러 디바이스를 동시에 지원할 때에 이 카드 중에서 어느 것으로부터 인터럽트가 전달된 것인지 확인하기 위해서이다.


인터럽트에 따라 주장치번호를 구했으면 주장치번호를 대상으로 작업을 처리해야 한다. 인터럽트 루틴에서는 부장치번호를 알아내기 어렵다. 주장치에 따른 부장치가 여러 개 있다면 이들 중에서 한 개 이상의 부장치가 디바이스에 대해서 작업을 하고 디바이스의 응답을 기다리고 있을 것이다. 주장치 구조체에 대기 중인 부장치의 순서를 정해 넣거나 디바이스가 스스로 인터럽트를 걸면서 부장치번호를 알려 줄 수도 있지만 이를 처리하는 것은 많은 시간이 걸린다. 주장치의 wait_queue에 대기 중인 부장치들은 자동적으로 순서를 부여 받으므로 모든 처리를 주장치의 리소스에 대해서 하고 wait_queue를 활성화 시켜서 깨어난 부장치의 루틴에서 이 리소스를 처리하게 하는 것이 인터럽트 루틴에서 소요되는 시간을 줄일 수 있는 방법이다.


인터럽트 루틴에서 메모리를 할당 받는 kmalloc등을 사용할 수 없다. kmalloc은 할당에 실패했을 때 스스로 필요한 시간 만큼 기다리고 재시도를 하기 때문에 시스템을 정지 시킬 가능성이 있기 때문이다. 마찬가지로 인터럽트 중에 schedule 같은 프로세스 블록 함수도 사용할 수 없다. 이 것은 인터럽트라는 특성상 블록이 가능하지 않음을 생각하면 당연한 것이다. 또한 save_flags-cli-sti-restore_flags 루틴도 사용할 수 없다. 인터럽트 루틴은 인터럽트 불가능 상태에서 수행되기 때문에 어떠한 경우에도 sti를 실행해서는 안 된다. 만약 인터럽트 수행 도중에 sti를 실행해서 인터럽트를 가능하게 만들었다면 같은 인터럽트가 또 다시 걸리거나 다른 인터럽트가 걸릴 수 있다. 인터럽트는 한 번에 수행되고 그 동안 방해 받아서는 안 되는 루틴이기 때문에 이 경우 많은 문제가 발생할 수 있다. 이것을 생각하고 있다고 하더라도 인터럽트 함수도 부를 수 있고 다른 루틴에서도 사용할 수 있는 작은 함수를 만들어 쓰다가 필요에 따라 이 함수를 고치는 경우가 있다. 이때 무심코 다른 루틴에서의 필요에 의해서 이 함수에 save_flags-sti-restore_flags를 쓰게 될 수 도 있다. 그러므로 코드 유지보수를 위해서 인터럽트 함수는 가능한 한 필요한 작업을 다른 함수를 부르지 않고 내부적으로 모든 일을 처리하는 것이 바람직하다. C의 함수 호출은 오버헤드가 많으므로 시간적으로도 장점이 있다.


인터럽트는 리턴값이 없다. 인터럽트는 필요한 일을 수행하고 리턴할 뿐이다. 그 결과는 인터럽트를 호출한 프로세스에 필요한 것이 아니라 인터럽트를 기다리던 루틴에서 필요한 것이다. 이 값은 인터럽트 수행과정에서 세팅되고 깨어난 프로세스가 그 값을 스스로 찾아서 이용해야 한다.


WRITE,READ/INTERRUPT 함수의 수행 과정

쓰기(읽기)루틴에서 데이터를 디바이스에 쓰거나 디바이스의 상태를 알아보기 위해서 인터럽트를 사용한다. 이때 주장치에 부속된 부장치가 여러 개라면 이들은 경쟁적으로 자원을 할당 받으려고 할 것이다. 같은 자원에 대해서 동시에 여러 프로세스가 접근한다면 충돌이 일어난다. 운영체제의 가장 중요한 주제인 상호배제 문제를 리눅스에서는 어떻게 해결했을까. 결론적으로 말하면 멀티프로세서 환경에서도 잘 작동하는 세마포 함수가 준비되어 있다. 세마포 함수는 독립적으로 개발자가 개발하여 커널에 포함시키고 있으며 테스트 프로그램도 구할 수 있다. include/asm/semaphore.h 참조). 여러 부장치가 경쟁적으로 한 자원을 접근하는 루틴이 있으면 이 루틴의 앞뒤로 세마포를 설정한다.

down(&card->semaphore);
중요루틴 실행
up(&card->semaphore);



물론 이렇게 사용하기 위해서는 my_device_card 구조체에 세마포 필드를 설정해야 한다. 그리고 세마포의 초기값을 반드시 MUTEX로 지정해 놓아야 한다.



card->semaphore=MUTEX;



인터럽트 함수와 마찬가지로 세마포가 적용되는 루틴은 가능한 한 빨리 끝날 수 있도록 설정해야 한다. 내부루틴이 많은 시간을 소요한다면 down 부분에 다른 프로세스가 계속 멈추어 있게 된다. 편리한 루틴이 있다고 내부를 들여다 볼 생각을 하지 않고 그냥 사용하는 데 만족하면 안 된다. 세마포 내부 처리 과정을 보면 프로세스대기, 태스크에 대한 처리, 시그널과의 관계, 여러 종류의 CPU에 대한 어셈블러 수준의 비교까지 알아볼 수 있다. 리눅스 세마포 처리 루틴은 유닉스 프로세스에 대한 개념과 그 실제 적용을 파악할 수 있는 좋은 교재라고 생각된다. 이 글을 보는 독자라면 arch/*/lib/semaphore.S, kernel/sched.c 등을 조사해 보기 바란다.


세마포가 보증하므로 이제 충돌 없이 인터럽트를 사용할 수 있다. 부장치마다 카드에 쓸 데이터를 위한 버퍼를 할당 받고 카드상태 등의 드라이버에 필요한 정보를 모아 놓는 것은 자원의 낭비일 뿐이다. 여러 부장치가 한 디바이스를 접근하고 세마포가 동시에 접근하는 것을 막아 주므로 인터럽트를 주고 받는 디바이스는 최소한의 데이터만을 유지하고 나머지 데이터는 주장치에 할당하는 것이 좋다.



my_device_card에 할당하는 리소스
struct MY_DEVICE_CARD {
...
u_int flag; /* 상태 플래그 */
u_char *mailbox_kernel; /* 커널에서 보낼 메일박스 */
u_char *mailbox_card; /* 카드에서 보내온 메일박스 */
struct semaphore semaphore; /* 주장치의 세마포 */
struct wait_queue *queue; /* 주장치의 대기큐 */
};



주장치인 my_device_card에 디바이스에 주고받을 데이터와 디바이스 상태정보를 보관할 필드를 정의했다. 인터럽트를 사용해서 데이터를 디바이스로 보내기 위해서 write루틴은 미리 할당된 mailbox_kernel에 데이터를 보내고 디바이스에 인터럽트를 건다. 돌아온 인터럽트와는 달리 디바이스에 인터럽트를 거는 것은 write루틴의 몫이다. my_device에 인터럽트를 거는 것이 다음과 같다고 하자



memcpy_toio(card->address + DATA_ADDR, card->mailbox_kernel, length);
outb(0x01, card->base+INTERRUPT_ADDR);
current->timeout = jiffies + HZ * 3;
interruptible_sleep_on(&card->queue);



base 주소에서 INTERRUPT_ADDR 바이트를 더한 주소에 1을 쓰는 것이 이 디바이스에 인터럽트를 거는 행위이다. 이 전에 활성화된 디바이스가 사용할 데이터를 전송한다. memcpy_toio가 그 있을 하게 된다. 보낼 주소, 보낼 데이터가 있는 주소, 길이에 따라 데이터가 전송된다. 마지막으로 디바이스가 활성화되어 보낸 데이터에 대한 처리를 끝내고 커널에 인터럽트를 걸어 이 루틴이 활성화될 때까지 기다리기 위해서 잠든다(interruptible_sleep_on). 이 루틴은 깨워줄 대상이 누구를 깨워야 하는지 알 수 있도록 card->queue에 자신을 등록한다.


인터럽트가 걸린 디바이스는 일반적으로 수십 밀리 초에서 수백 밀리 초 안에 처리를 끝내고 인터럽트를 돌려 주기 때문에 현 프로세스의 타임아웃값을 크게 잡을 필요는 없다. 물론 그 값은 하드웨어 디바이스의 특성을 파악한 후에 적절한 값을 정해주어야 한다. 인터럽트가 정상적으로 돌아오면 바로 이 프로세스가 활성화 되므로 대기값을 많이 잡는 다고 오버헤드가 증가하는 것은 아니다. 여기에서는 my_device에 대해서만 얘기했지만 하드웨어에 따라 다양한 인터럽트 걸기가 존재할 수 있다. 마찬가지로 여러 드라이버를 찾아보고 필요한 방법을 연구하기 바란다.


대기 후에 활성화된 프로세스는 프로세스가 깨어난 상태에 대한 점검이 필요하다. 시그널에 의해서 깨어난 프로세스는 작업을 중단하고 자신을 부른 상위 루틴으로 복귀해야 한다. 물론 세마포에 대한 정리작업 같은 중요한 작업은 반드시 수행해야 그 다음 프로세스가 영향을 받지 않는다. 만약 타임아웃값이 지나서 활성화되었고 시그널이 없다면 디바이스에 이상이 있는 것인지 타임아웃값이 충분하지 않았는지 확인해야 한다. 타임아웃값이 적은 것이라면 늘여 주면 되고 디바이스가 인터럽트를 돌려 주지 못한 것이라면 이에 따른 처리를 해야 할 것이다.


필자의 경험으로는 디바이스가 인터럽트를 돌려 주지 못하는 경우가 생긴 적이 있었다. 대상 디바이스가 비정상적인 작동을 할 때 그 문제를 하드웨어적인 문제라고 생각하고 해결 방법이 없다고 생각했지만 나중에 알아낸 결과 인터럽트를 주고 받을 때의 규약을 정확히 지키지 못했기 때문이었다. 다른 운영체제에서 정상적으로 사용하던 하드웨어는 그 규약만 정확히 지킨다면 리눅스에서 아무 문제 없이 사용할 수 있다. 커널 프로그래밍을 할 때 종종 운영체제가 달라서 어쩔 수 없다는 말을 듣고는 하는데, 이것은 리눅스의 한계를 말하는 것이 아니고 하드웨어 제작사가 정말 필요한 정보를 공개하지 않고 있음을 말하는 것이다. 제대로 만들어진 하드웨어 사양을 가지고 프로그래밍을 한다면 절대로 실패할 수 없으며 리눅스에서 훌륭히 동작시킬 수 있다.


프로세스가 활성화된 후에 디바이스가 인터럽트를 제대로 돌려 주어서 드라이버의 인터럽트 루틴이 수행된 결과로 활성화된 것이라면 디바이스에서 보낸 데이터를 처리해야 한다. 앞에서 말했듯이 인터럽트 루틴은 최소한의 작업만을 하기 때문에 모든 처리는 write에서 하게 해야 한다.

여기에 대응되는 인터럽트 루틴 쪽의 코드는 다음과 같다.



memcpy_fromio(card->mailbox_card, buf, buf_size);
card->flag |= MY_DEVICE_ANSWERED;
if (waitqueue_active (&card->queue))
wake_up_interruptible(&card->queue);
return;



인터럽트 루틴에서 하는 일은 정당한 인터럽트인지 확인한 후에 디바이스에 있는 데이터를 복사하고 디바이스가 정상적으로 응답했음을 알려 준 후에 대기 중인 프로세스가 있으면 wake_up_interruptible 함수가 이 프로세스를 깨운다.


2.0버전에서는 큐에 대기 중인 프로세스가 있는지 확인하는 함수가 없었지만 2.2에서는 waitqueue_active라는 확인 함수가 생겼다. card->flag를 세팅하는 이유는 대기 프로세스가 제대로 된 응답을 디바이스로부터 받았음을 확인 시키기 위한 것이다.


필자가 프로그램을 하면서 가장 어렵게 느낀 부분이 인터럽트 사용에 대한 것이었다. 제어가 여러 군데로 넘어가게 하고 이들을 모두 일치시키는 것이 매우 어렵게 느껴졌기 때문이다. 여기에 더해서 상호배제의 문제와 활성화되었을 때의 상태 체크까지 겹쳐서 코딩을 하면서 많은 고생을 했다. 하지만 읽기/쓰기 함수에서 인터럽트 사용에 대한 내용은 여기까지 읽어온 독자라면 어려움 없이 이해할 수 있을 것이다.

에러 코드에 대해서 드라이버를 작성하면서 여러 에러가 생길 수 있다. 각각의 에러에 대해서 리턴값을 가져야 한다. 드라이버 루틴에서 많이 쓰는 에러에 대해서 간단하게 설명을 하겠다. 여기서 보인 에러를 리턴할 때는 (-)값을 취한다. 

완전한 에러 값은 include/asm/errno.h에 있다.

EINTR: 프로세스 수행 도중에 커널로부터 시그널을 받았을 때
   사용자가 프로세스를 중단했거나 커널에서 강제로 중단시킬 경우에 사용된다.
  해당 프로세스는 신속하게 작업을 끝내야 한다.


ENOMEM: 커널 메모리 영역에 여유가 없을 때

kmalloc 함수를 호출한 후에 에러가 났을 때 리턴값으로 쓴다.


ENXIO: 잘못된 부장치를 호출했거나 사용할 수 없는 주소가 참조되었을 때


ENODEV: 주장치에 딸린 부장치가 범위를 넘었을 때


EAGAIN: 일시적으로 드라이버에서 디바이스를 사용할 수 없을 때
        다른 루틴에서 이 디바이스를 사용 중일 때 잠시 기다린 후에 다시 시도할 수
        있게 한다.


EBUSY: 이미 다른 루틴에서 디바이스를 사용 중일 때


ENOSYS: ioctl등의 루틴에서 없는 함수를 부를 때


EIO: 장치를 등록할 수 없거나 디바이스가 정상작동을 하지 않고 있을 때
     이 에러는 위의 경우에 해당하지 않는 포괄적인 상황에서 사용된다.



4. 프로그래밍시 주의점과 기타 정보


리눅스 디바이스 드라이버를 만들기 위해서 필요한 최소한의 참고 사항은 모두 설명했다. 물론 이것으로는 아무 것도 할 수 없을 것이다. 이 글에서 설명한 것을 참고로 반드시 만들고자 하는 디바이스와 관련된 드라이버 파일을 조사해야 한다. 여전히 드라이버 파일의 내용이 어렵게 느껴지겠지만 이 글에서 언급한 것들이 도움이 될 것이다. 이제 드라이버를 작성할 때 전체적으로 관심을 가져야 할 사항에 대한 설명을 하도록 하자. 그 외에 왕성하게 개발되고 있는 리눅스 커널 관련 프로젝트들에 대해서 언급하고 마지막으로 꼭 찾아 보아야 할 정보에 대해서 알아보자.


절차적 프로그래밍과 커널 프로그래밍

프로그래밍 언어 C는 코드에 따라 순차적으로 진행되는 프로그램을 만든다. 멀티스레드 개념도 동기화 등의 작업을 프로그래머가 제어하게 되므로 순차적이라는 개념을 벗어나지는 않는다. X-window 또는 win32에서 이벤트 중심의 프로그래밍을 하게 되는데 이것도 발생할 수 있는 사건에 대해 프로그래머가 모두 파악하고 처리를 하게 된다. 응용프로그램이 실행된 이후에 발생할 수 있는 외부 사건은 시스템에러 정도이다. 즉 프로그래머가 신경 쓸 부분은 응용프로그램의 내부 로직이며 외부 요인에 신경 쓸 필요 없이 시스템 전체를 응용프로그램이 제어하고 있다고 생각하면서 프로그램밍을 하면 된다.


커널 프로그래밍을 할 때는 프로그래머가 신경 쓸 부분이 크게 증가한다. 증가하는 부분은 주로 디바이스 드라이버의 외부 요인에 대한 것이다. 커널은 시작도 끝도 없는 제어구조로 되어있다. 커널에서 주로 하는 작업은 자원테이블의 유지와 갱신이다. 예를 들어 할당된 메모리와 자유메모리에 대한 자원테이블을 유지하기 위해 메모리를 회수하거나 할당했을 때 이 변경사항을 갱신하고 메모리 회수에 필요한 추가작업을 한다. 이런 자원에 대한 요구는 언제 어디서든지 일어날 수 있고 자원 해제 요구 또한 마찬가지다. 커널은 끊임없이 이런 요구를 처리하는 데 주로 시간을 보낸다. 동시에 일어나는 수많은 요구에 대한 조정은 매우 힘든 일이기 때문에 가능한 최소한의 작업만을 하고 그 외 대부분의 작업은 디바이스 드라이버가 처리하도록 했다. 한 디바이스의 부장치를 액세스하는 프로세스가 활동 중일 때에도 커널은 이를 조사하지 않고 같은 부장치에 대한 액세스 요청이 있을 때 제어를 넘겨 준다. 이를 허용하거나 막는 것은 디바이스 드라이버의 몫이다.


그 외에도 드라이버를 만들 때 신경을 써야 할 많은 부분이 있다. 커널의 멀티프로세스, 시분할 방식이라는 특성과 비절차적인 외부 요인이 겹쳐서 프로그래밍을 복잡하게 한다.


경쟁의 제거

디바이스를 open한 프로세스가 가장 먼저 해야 할 것은 동일한 부장치에 대해서 또 다른 프로세스가 접근하는 것을 막아야 하는 것이다. 이 것은 부장치의 플래그를 busy로 세팅하는 것으로 쉽게 해결할 수 있다. open 함수에서 여러 조건을 검사한 후에 busy 세팅을 하면 된다. 이 디바이스를 두 번째로 open한 프로세스가 busy 세팅이 되었는지 검사한 후에 busy라면 EBUSY 값을 가지고 리턴하게 된다. 플래그 값이 busy일 때 즉시 EBUSY 값으로 리턴하지 않고 앞서 이 디바이스를 연 프로세스의 상태에 따라(앞의 프로세스가 release 루틴을 수행 중이라면) 커널에서 다시 open을 시도하도록 EAGAIN 값으로 리턴하게 할 수도 있다. 시리얼 포트 제어 디바이스들이 이 방법을 사용하고 있다.


이 것은 한 개의 부장치에 대한 프로세스의 접근을 막기 위해 고려해야 할 점이다. 시스템 시간을 할당 받은 프로세스가 어떤 루틴을 수행 하는 도중에 커널로부터 간섭을 받아서는 안 되는 경우가 있다. 중요한 전역변수를 바꾸고 중단 없이 필요한 작업을 해야 하거나, 중요 루틴 수행 도중에 제어가 넘어가게 되어서 다른 프로세스가 이 프로세스가 변경한 자원을 또 다시 바꾸지 못하게 해야 할 때 등이다.


이렇게 커널 프로세스의 모든 경쟁을 막고 완전히 프로세스 수행을 독점해야 할 때에는 다음과 같이 해야 한다.



...
u_long flags;
save_flags(flags);
cli();
change_variable();
do_critical_job();
recover_variable();
restore_flags(flags);
...



cli가 수행되는 순간 restore_flags 함수의 수행이 끝날 때까지는 커널의 블럭명령이나 인터럽트가 무시되고 오로지 이 프로세스만이 시스템 시간을 사용하게 된다. 커널 프로세스는 제한된 시스템 시간을 받고 작업을 수행하기 때문에 항상 변수의 변경과 같은 부분에서는 연속된 작업이 다른 프로세스의 방해를 받아도 되는 것인지 확인해야 한다.

save_flags-cli-restore_flags의 사용은 프로세스 자원할당을 방해하여 커널의 성능을 저하시킨다. 위와 같은 불특정 다수 프로세스와의 경쟁이 아니고 일정 자원을 공유하는 프로세스의 간섭을 막기 위해서라면 세마포를 사용하는 것이 좋다. 주장치에 동일한 부장치가 여러 개 있을 때 이들 부장치를 접근하는 프로세스들은 주장치에 대해 서로 경쟁을 한다..



struct MY_DEVICE_CARD {
...
u_char *mailbox_kernel; /* 주장치의 메일박스*/
...
}
my_device_write(..) {
...
get_mailbox_and_write();
...
}



my_device에는 한 개의 주장치에 대해서 4개의 부장치가 있다. my_device_write 함수는 각 부장치(my_device[0-3])에 동시에 접근한 4개의 프로세스가 같이 실행하고 있다. get_mailbox_and_write 함수에 대한 접근과 경쟁이 존재하게 되는 것이다. 이때에는 경쟁하고 있는 프로세스가 명확하고 한 프로세스가 독점적으로 실행되어야 할 코드가 복잡하고 길기 때문에 save_flags-cli-restore_flags를 사용할 수 없다. 세마포를 적용하면 다음과 같게 된다.

struct MY_DEVICE_CARD {
...
u_char *mailbox_kernel; /* 주장치의 메일박스*/
struct semaphore semaphore; /* 주장치의 세마포 */
...
}
my_device_write(..) {
...
down(&card->semaphore);
get_mailbox_and_write();
up(&card->semaphore);
...
}



인터럽트 루틴은 가장 효율적으로 실행되어야 한다고 했다. my_device에서는 하드웨어가 전달할 4096바이트의 데이터 영역이 필요하다고 하자. 인터럽트가 실행될 때마다 이 영역을 할당 받기 위해서 다음과 같이 사용했다.



my_device_interrupt(...) {
u_char data[4096];
...
}



그런데 이렇게 사용한다면 실행시마다 4096바이트의 영역을 할당 받기 위해서 오버헤드가 있을 수 있다. 그래서 효율을 높이기 위해서 다음과 같이 변경하였다.



my_device_interrupt(...) {
static u_char data[4096];
...
}



이렇게 하면 데이터 배열이 컴파일 시에 지정이 되어서 속도가 개선될 수 있을 것이다.


static 을 사용할 수 있는 이유는 인터럽트를 주고 받는 루틴이 세마포에 의해서 보호 받기 때문이다. 즉 일정 시점에 인터럽트를 걸 수 있는 프로세스가 반드시 하나임이 보장되고 지금 처리되고 있는 인터럽트가 보내 주는 데이터는 대기 중인 프로세스가 요청한 데이터이다. 그리고 인터럽트를 거는 루틴이 여기저기에 있는 것이 아니고 세마포에 의해 보호 받는 단 한 개의 루틴에서만 존재한다. 여기에 전혀 논리적인 문제가 없다. 실제로 많은 디바이스들이 이렇게 사용하고 있다. 그런데 왜 my_device에서는 이렇게 사용하면 안 되는가? 왜 이 코드가 문제인가?


동일한 자원에 대한 경쟁은 이것 뿐만이 아니다. 커널 프로그랭밍에서 static 변수를 사용하는 것은 대단히 위험한 일이다. 드라이버를 요청하는 프로세스는 동시다발적으로 자원을 요구한다.



my_device_interrupt(...) {
static u_char data[4096];
...
memcp_fromio(card->data, data, 4096);
wake_up_interruptible(&card->queue);
...
}
my_device_write(..) {
...
down(&card->semaphore);
...
interrupt_to_my_device();
interruptible_sleep_on(&card->queue);
up(&card->semaphore);
...
}



my_device의 인터럽트를 주고 받는 루틴은 세마포에 의해서 보호 받고 있다. 일정 시점에 인터럽트를 걸 수 있는 프로세스가 반드시 하나임이 보장되고 지금 처리되고 있는 인터럽트가 보내 주는 데이터는 대기 중인 프로세스가 요청한 데이터이다. 그리고 인터럽트를 거는 루틴이 여기저기에 있는 것이 아니고 세마포에 의해 보호 받는 단 한 개의 루틴에서만 존재한다. 그렇지만 이렇게 사용하면 문제가 발생한다. 왜냐하면 my_device라는 여러 개의 주장치를 붙일 수 있기 때문이다. my_device[0-3]은 첫 번째 주장치에 my_device[4-7]은 두 번째 주장치에 연결되어 있다. 인터럽트를 거는 루틴이 세마포에 의해서 보호 받고 있지만 이 것은 첫 번째 주장치에만 해당되는 사항이다. 두 번째 주장치에 연결된 부장치들은 자기들끼리 서로 경쟁한다. 때문에 my_device_interrupt함수를 호출하게 되는 두개 이상의 프로세스가 있을 수 있다. 상황을 설명하면 다음과 같다.



a.process 인터럽트를 걸고 대기모드로 감

b.process 인터럽트를 걸고 대기모드로 감

a.interrupt가 활성화. a.process가 원하는 데이터를

첫번째 my_device에서 static data 영역에 복사하고 리턴.

b.interrupt가 활성화. b.process가 원하는 데이터를

두번째 my_device에서 static data 영역에 복사하고 리턴

a.process 활성화 된 후에 잘못된 데이터가 왔음을 알고 에러로 처리하면서 static data 영역의 내용을 제거.

b.process 활성화 된 후에 데이터가 없으므로 에러로 리턴.

커널에서 함수를 호출할 때 지역변수는 새로 생성하지만 static은 정적 데이터 영역에 컴파일 시에 만들어진 것을 이용하므로 문제가 발생한다. 부장치끼리의 경쟁을 제거한다고 해서 주장치끼리의 경쟁까지 제거해 주지는 않는다. 위와 같이 되었을 때 세마포를 주장치 단위로 설정하지 않고 주장치 전체에 한 개만 만들면 해결되겠지만 드라이버의 성능은 엄청나게 떨어질 것이다. 16개의 부장치가 있다고 한다면 성능이 1/4로 떨어진다. 동시에 4개의 인터럽트 요청을 할 수 있는 것을 한번에 1개씩만 해야 하기 때문이다. 커널 디바이스 드라이버는 같은 하드웨어일 때 주장치가 다르더라도 같은 드라이버를 사용하기 때문에 부장치간에, 그리고 주장치간에 서로 경쟁에 의해서 문제가 생기지 않도록 신경을 써야 한다. 이러한 인터럽트 루틴 뿐만 아니라 입출력 함수에서도 static 배열을 사용하여 수행시간을 줄이려고 할 때 문제가 생기지 않도록 조심해야 한다.


커널에서는 프로세스에게 필요하다면 언제든지 시그널을 보낸다. 사용자가 입출력을 강제로 중단하거나 커널 자체의 문제로 인해 보내온 시그널을 받은 프로세스는 가능한 빨리 작업을 중단하고 복귀해야 한다. 그렇다고 꼭 필요한 작업까지 무시해서는 안 된다. 다음에 같은 드라이버를 사용할 프로세스가 정상적으로 진입하여 자원을 사용할 수 있도록 보장을 해야 한다.


플래그세팅, 세마포처리 등을 제대로 복원하고 돌아가야 한다. 시그널을 받았는지는 대부분 대기 후 활성화되었을 때 수행하기 때문에 이때 어떤 자원을 복원해야 하는지 꼼꼼히 확인해야 한다.


드라이버를 호출하는 프로세스는 아무런 순서 없이 생성되며, 커널은 언제라도 시그널을 보내어 프로세스를 간섭하고, 함수들 간에 부장치 끼리 그리고 커널 전체를 통해서 프로세스의 자원 점유를 위해서 경쟁하기 때문에 코드 순서에 따라 동작할 것이라고 가정하고 코딩을 하게 되면 제대로 된 드라이버를 만들 수 없다.

드라이버를 작성할 때에는 모든 함수가 동시에 서로를 간섭할 수 있다는 것을 염두에 두고 동시다발적인 상황을 철저하게 따져가면서 코딩을 해야 할 것이다.


커널 드라이버 디버깅

디바이스 드라이버를 디버깅하는 것은 쉬운 일이 아니다. 드라이버 루틴은 커널모드에서 돌아가는 것이기 때문에 드라이버의 에러는 대부분 시스템 전체를 다운시켜 버리는 치명적 결과를 가져오게 된다. 에러가 생기면 리눅스 시스템 전체가 다운되고 최악의 상황에서는 파일시스템이 날아가 버려서 메시지도 확인할 수 없을 뿐 아니라 드라이버 소스까지 잃게 될 수 있다. 그러므로 커널 프로그래밍 시에 백업은 기본적으로 해야 되고 여유가 있다면 똑같은 하드이미지를 만들어 놓기를 바란다. 파일시스템이 날아가더라도 똑같은 하드이미지를 만들어 놓았으면 백업본을 뒤져서 다시 리눅스를 인스톨하는 수고를 하지 않아도 새 하드디스크를 바꿔 달고 갱신된 드라이버 소스만 다시 복사하면 되기 때문이다. 똑같은 하드이미지란 같은 모델의 하드디스크를 두개 준비하여 원본 하드디스크를 /dev/hda에 복사본 하드디스크를 /dev/hdc에 연결하고 플로피로 부팅한 다음 아래에 있는 두 가지 명령 중에서 하나를 실행하여 완전히 같은 하드디스크를 만드는 것이다.



dd if=/dev/hda of=/dev/hdc
cat /dev/hda >/dev/hdc



이때 두 하드디스크는 마운트하지 않는다. 리눅스는 모든 데이터를 바이트열로 보기 때문에 위 두 명령은 물리적인 하드디스크 섹터를 읽어서 타깃 하드디스크에 쓰는 동일한 명령이다. 실행이 끝나면 마스터부트레코드까지 같은 하드이미지가 만들어진다. 물론 두 하드디스크는 베드섹터가 없어야 한다.


드라이버에 에러가 나서 시스템이 정지했을 때 이상 없이 시스템을 재부팅할 수 있는 방법이 있다. 2.2.x 버전에서 컴파일 할 때 CONFIG_MAGIC_SYSRQ 옵션을 활성화하면 된다. 테스트 도중에 에러가 생기고 키보드 입력을 받아 들이지 않고 네트워크로 로그인도 되지 않는다면 아래와 같은 키를 사용할 수 있다.



alt+sysrq(print screen)+s : 마운트 되어 있는 파일 시스템에 아직 쓰지 않은 데이터가 있으면 모두 쓴다.

alt+sysrq+e : 모든 프로세스에게 SIGTERM 시그널을 보낸다.(init 제외)

alt+sysrq+i : 모든 프로세스에게 SIGKILL 시그널을 보낸다.(init 제외)

alt+sysrq+u : 마운트한 파일 시스템을 모두 읽기 전용으로 바꾸어 마운트한다.

alt+sysrq+b : 시스템을 재부팅한다.



여기에 있는 키를 차례로 누르면 파일 시스템을 잃을 염려 없이 정지한 시스템을 재부팅할 수 있다. 또 다른 키들도 정의되어 있는데 메모리 상태를 보거나 태스크들의 정보를 보거나 죽일 수도 있다. 자세한 내용은 Documentation/sysrq.txt에 있다. 위에 적은 키는 순서까지 외워 놓고 비상시에 사용하기 바란다.


에러 추적을 위해서 printk 함수를 문제가 있다고 의심되는 부분에 넣고 테스트 한다. 콘솔에 이 메시지가 뜨게 되고 /var/log/messages에 같은 내용을 쓴다. 초기에는 가능한 많은 상태 메시지를 넣는 것이 좋다. 에러 출력에 등급을 두고 출력되는 정보량을 조절하면서 드라이버가 안정화 되어갈 수록 꼭 필요한 메시지만 나오도록 하면 된다.


커널 소스 최상위 디렉토리의 README와 Documentation/oops-tracing.txt에서 커널모드의 디버거를 사용하는 법, 에러난 곳을 찾는 법에 대한 설명이 있다. 여기에 주로 설명한 것은 커널 전체에서 에러난 부분을 찾기 위한 방법과 개발자에게 에러 보고를 위해서 취해야 할 일에 대한 것이다. 우리가 하는 것은 직접 만든 드라이버를 디버깅하는 것이기 때문에 여기서 설명한 에러난 곳을 찾는 방법은 별로 도움이 되지 않는다. 잘 동작하는 커널에 직접 만든 드라이버 모듈을 삽입했을 때 에러가 났다면 당연히 우리의 드라이버 모듈에서 원인을 찾아야 하기 때문이다. 물론 이 문서를 읽는 것이 커널이 알려 주는 에러 메시지를 이해하는 데는 상당한 도움이 될 것이다. 직접 만든 드라이버의 디버깅은 에러난 지점에서 어떤 논리적 문제가 있는지 스스로 따져 나가는 방법이 거의 유일한 것이다.


에러가 나지만 어느 정도 안정화 되어서 시스템이 정지하지는 않고 제대로 동작하지 않을 때 에러가 난 시점에 드라이버의 변수값 등을 조사해 보고 싶으면 ioctl 함수를 이용하여 그 기능을 구현할 수 있다.


우선 include/linux/my_device.h에 그 기능에 대한 정의를 한다.


#define VIEW_VARIABLE _IOWR(MY_DEVICE_MAJOR, 2, int[2])


_IOWR의 두 번째 인자 2는 커널의 my_device_ioctl에서 view_variable에 해당하는 일을 하도록 임의로 정한 값이다. int[2]는 정수 두개를 주고 받을 수 있도록 마련한 영역이다. 변수값을 보기 위해서 만드는 사용자 프로그램의 중요 부분은 다음과 같을 것이다.



...
int value[2];
FILE *fd; // my_device를 가르키는 파일 포인터
ret=ioctl(fd, VIEW_VARIABLE, value);
printf("variable 1 = %d, variable 2 = %dn",value[0],value[1]);
...



VIEW_VARIABLE 의 세 번째 인자는 ioctl 함수의 세 번째 인자의 크기를 알려주는 역할을 한다. value는 정수 2개를 할당할 수 있는 영역임을 알 수 있다.

파일 포인터 fd를 이용해 정상적으로 열리고 리턴값 ret가 이상이 없으면 그 값을 볼 수 있다. 커널 내부의 ioctl 함수 중에서 view_variable에 해당하는 부분은 다음처럼 만들 수 있다.



...
copy_from_user(&which, (void *)arg, sizeof(int));
if(which == 1) {

data[0] = my_device->variable_one; date[1] = my_device->variable_two; } else {

data[0] = my_device->variable_three; date[1] = my_device->variable_four;

}

...
copy_to_user((void *)arg, data, sizeof(int) * 2);
...

arg는 ioctl을 부르는 함수의 value 배열을 가리키는 포인터이다. 사용자 영역에서 데이터를 읽어 온다. which라는 변수에 따라 다양한 커널 변수값을 선택할 수 있다. 요청한 데이터를 처리하고 이 데이터를 사용자 영역에 쓸 수 있는지 확인 한 다음 데이터를 보낸다. 지난 연재에서 말했듯이 커널 영역에서 사용자 프로세스의 데이터를 접근하기 위해서는 copy_to[from]_user 함수를 사용해야 한다. 또 ncurses의 텍스트 윈도우 함수와 wgetch 함수를 사용하면 커널 변수의 실시간 모니터링도 가능하다. ncurses 라이브러리의 wgetch 함수는 정한 시간 동안 사용자의 키보드 입력을 기다리고 키 입력이 없으면 다음 코드로 넘어가기 때문에 루프를 돌며 사용자가 끝내기 키를 누르기 전까지 수 밀리초 간격으로 커널의 변수 내용을 보게 할 수 있다. 필자는 이 기능을 이용하여 디바이스의 돌아오는 인터럽트 받기 위해 대기 중인 프로세스의 수를 확인하는 모니터링 프로그램을 제작했었다.


커널의 발전 모습

2.2 버전에서 많이 개선된 것은 우선 멀티 플랫폼을 들 수 있다. 2.0 버전보다 훨씬 많은 CPU가 추가되었고 성능이 개선되었고 SMP 지원이 튼튼해 졌다. 공식버전에는 없지만 개별 프로젝트로 진행된 커널 포팅이 2.3 버전에 추가로 들어가게 될 것이다.
이에 따라 여러가지 파일 시스템에 대한 지원도 늘었다. 멀티 플랫폼으로 확장되면서 이미 그 플랫폼에서 운영되는 운영체제가 사용하고 있던 파일시스템을 인식할 필요가 있기 때문이다.

여기에 더해서 리눅스가 아닌 운영체제의 라이브러리와 링크된 프로그램을 실행할 수 있는 능력도 추가되었다. 아직은 상용 프로그램들이 리눅스의 모든 플랫폼을 지원하지 않기 때문에 포팅된 플랫폼에서 쓰던 프로그램을 쓸 수 있게 하는 것도 중요하다.

다음으로 리눅스가 가장 중요하게 생각하는 것은 네트웍에 대한 지원이다. 현재 리눅스가 주로 쓰이는 분야는 소규모 네트웍서버 쪽이다. 메일, 웹, ftp서버 기능은 리눅스 배포본을 인스톨하면 간단한 설정만으로 훌 륭하게 그 기능을 해낸다. 여기에 samba, netatalk, nfs 등을 이용하여 파일서버와 프린터서버 기능까지 구현되고 있다. 또한 486급 PC로도 고가의 라우터, 방화벽 기능을 완벽하게 수행할 수 있다.

네트웍 프로토콜은 거의 구현되고 있으며 요즘 한국에서 관심이 있는 X.25도 개발 중이다. 네트웍 서버로서의 리눅스의 중요성을 인식한 네트웍 관련 하드웨어 개발사들은 적극적으로 리눅스 지원에 나서고 있다. 또 아직 표준화 되지도 않은 IPv6 프로토콜을 리눅스에서 가장 먼저 실험적으로 구현하고 있다.

리눅스가 본격적으로 네트웍 서버로 사용되면서 나타나는 문제점이 2.2버전에서 많이 해결되었다. 네트웍 관련 하드웨어에 대한 지원이 대폭 늘어났고 프로토콜 구현과 수행속도가 상당히 개선되었다. 64개(pty[p-s][0-9,a-f])까지 제한되어 있던 텔렛 접속 한계가 256(pty[p-z][0-9,a-f])개로 늘었으며 이 것도 부족하여 936개(pty[a-z][0-9,a-z])까지 확장하기 위해 이름을 예약해 놓고 있다. 최종적으로는 /dev/pts/0, /dev/pts/1,... 로 만들어 필요한 만큼의 텔렛 접속을 받아들이게 할 수 있다. 또한 관리자를 위해서 콘솔을 재지정하여 원거리에서도 관리자가 쉽게 서버를 운용할 수 있게 하고 있다. 뛰어난 성능과 적은 하드웨어 요구량 그리고 놀라운 안정성 때문에 외국뿐만 아니라 국내의 많은 사이트에서 조용히 리눅스 서버가 그 영역을 넓히고 있다.

오락을 위한 3D 장비나 음악카드등의 개인 사용자가 주로 사용하는 멀티미디어 장비에 대한 지원은 아직 미약하다. 아마 이 쪽은 하드웨어 개발사가 직접 지원하지 않는다면 앞으로도 크게 나아지지는 않을 것이다. 최근에 리눅스를 이용한 NetWinder라는 제품을 개발한 코렐사도 용도를 네트웍 서버 쪽에 한정시키고 있다.

그러나 리눅스의 안정성을 바탕으로 소위 killing software가 많이 나와서 개인 사용자가 늘어난다면 이 상황이 바뀔 수 있다. 리누스도 상용 프로그램 제작회사가 리눅스에 좀 더 많은 관심을 가져 주기를 바라고 있고 그렇게 되고 있다.

아직은 여러가지로 미흡한 부분이 많지만 리눅스의 미래는 밝다. 수많은 프로그래머가 기능의 개선과 확장에 자발적으로 참여하여 노력하고 있고 사용자가 빠르게 증가하고 있으며 리눅스에 대한 열기는 나날이 높아지고 있기 때문이다.

Comments