리눅스 커널에 대해서 공부하면서, 이론과 더불어 함께 진행해볼 수 있는 실습들을 직접 시행해보고 있다. 책으로만 공부하면 금세 잊어버리기 때문에 실습을 겸하여 공부하고 있으며, 또한 실습한 내용을 보다 효과적으로 기억하기 위해 블로그에 정리하고 있다. 오늘은 이전에 진행했던 모듈 프로그래밍에 연장선인 문자 디바이스 드라이버를 제작해봤다. 가장 간단한 형태의 캐릭터 디바이스 드라이버지만 기본적인 골격을 확인하기에는 충분했다. 개발 환경은 이전에 모듈 프로그래밍 실습에서의 환경과 동일하게 버추얼박스 버전 5.0, 우분투 버전 14.04.3 LTS, 그리고 커널 버전 3.19.0-25-generic를 활용했다.


0. 준비


실습에 앞서 디바이스 드라이버에 대해 공부한 내용을 간략하게 정리해봤다. 가장 먼저 알아야 할 것은 '디바이스 드라이버'라는 용어다. 이를 명확하게 표현한 글이 있어서 인용했다. 기본적으로 디바이스는 하드웨어를 의미하고, 드라이버는 그 하드웨어(디바이스)를 컨트롤하는 소프트웨어를 의미한다. 예를 들어 UART 칩이 16개 달려있다고 했을 때 이를 제어하는 드라이버(소프트웨어)는 하나이다. 하지만 UART 칩은 16개이므로 디바이스는 16개다.

Device(Hardware)를 조종하는 Driver(Software) = Device Driver


                                   


흔히 디바이스 드라이버를 설명할 때 좌측 그림이 자주 인용된다. 응용프로그램이 하드웨어를 직접 컨트롤 하는 것이 아니라 디바이스 드라이버를 통해서 하드웨어를 조종한다. 하지만 장치별로 각각 제공하는 디바이스 드라이버가 다른 점이 문제다. 하드웨어 제작사에서 디바이스 드라이버를 제공하는 것은 기분 좋은 일이지만 사용 방법이 회사별로 다르다면 응용 프로그래머들이 모든 하드웨어를 다루기엔 부담이 너무 크다. 하지만 다행히 리눅스 시스템은 디바이스를 /dev 디렉토리에 하나의 파일로써 취급한다. 디바이스 드라이버도 마찬가지로 파일처럼 취급하는데, 이는 리눅스가 VFS(Virtual File System)을 제공하기 때문이다. 이 때 /dev 디렉토리에 있는 디바이스 파일은 사용자가 액세스 할 수 있는 드라이버의 인터페이스 부분이다. 디바이스 드라이버가 파일로써 취급되기 때문에 open, close, read, write 등의 연산을 통해 액세스 할 수 있다. 주목할 점은 각각의 디바이스 파일들은 고유한 번호와 이름을 할당받는 점이다. 때문에 디바이스 드라이버를 제작하고 등록하기 위해서는 번호 및 이름을 지정해줘야만 한다.  


디바이스 드라이버의 종류는 캐릭터(문자) 디바이스 드라이버/ 블록 디바이스 드라이버/ 네트워크 디바이스 드라이버로 구분된다. 대게 일반적으로 캐릭터 디바이스 드라이버 작성을 주로하고, 블록이나 네트워크 드라이버는 보다 전문적인 곳에서 작성된다고 한다. 먼저 터미널에서 해당 드라이버가 위치한 /dev 디렉토리를 확인해보자. 

$ls -al /dev



파일들이 꽤 많은 것을 알 수 있는데, 확인해 볼 것은 맨 좌측의 문자다. 네, 다섯개의 문자를 확인할 수 있는데 문자 c는 캐릭터 디바이스 드라이버 파일을 의미하고 문자 b는 블록 디바이스 드라이버 파일을 의미한다. 문자 'n'이 눈에 띄지 않는 것은 네트워크 디바이스 드라이버는 캐릭터 혹은 블록 디바이스 드라이버와는 다르게 취급되기 때문인데, 네트워크 디바이스 드라이버에 대해서는 추후에 알아보도록 하고, 지금은 문자 디바이스 드라이버에 집중해보자.


좌측 문자에 이어서 가운데 위치한 두 개의 숫자는 Major Number와 Minor Number를 의미하는데 디바이스 드라이버 제작 및 등록을 위해서는 Major Number 및 디바이스 네임 설정이 필수적이라는 것만 기억하자.


1. 캐릭터(문자) 디바이스 드라이버

위에서 본 것처럼 캐릭터 디바이스 드라이버는 VFS에서 하나의 노드 형태로 존재한다. 때문에 추후에 실습할 때 mknod 명령어를 통해 노드를 생성해줘야 한다. 자료의 순차성을 가지고 있는 하드웨어를 다룰 때 사용하며, 데이터를 문자 단위(또는 연속적인 바이트 스트림)로 전달하고 읽어들인다. 대표적인 하드웨어로는 터미널, 콘솔, 키보드, 사운드카드, 스캐너, 프린터, 직렬/병렬 포트, 마우스, 조이스틱 등이 있다. 하드 드라이브나 플래시 메모리와 같은 저장자치가 포함되지 않는 것에 유의하자.


2. 문자 디바이스 드라이버 제작

문자 디바이스 드라이버를 제작하고 등록하는 과정은 모듈 프로그래밍의 연장선으로 볼 수 있다. 모듈 프로그래밍과 공통된 부분은 생략하니 만약 생략된 부분에 대한 상세한 설명을 원한다면 클릭하여 확인하도록 하자. 



먼저 소스 코드 작성에 대해서 설명하자면, 기본적으로 모듈 프로그래밍 과정을 통해 디바이스 드라이버를 등록하기 때문에 모듈 초기화 루틴(module_init) 매크로를 통해 virtual_device_init 함수를 구동하고, 이 함수 내에서 register_chrdev 함수를 통해 드라이버를 등록한다. 이 때 파라미터에 대해서 알아볼 필요가 있는데, 필자가 작성한 코드는 다음과 같다.

register_chrdev(250, "virtual_device", &vd_fops);


여기서 첫 번째 인수 250은 위에서 언급했던 Major Number를 의미하고, 두 번째 인수 virtual_device는 등록할 디바이스의 이름을 의미한다. 마지막 인수는 디바이스 드라이버의 오퍼레이션들의 집합인데 그 구성에 대해서 알아보면 다음과 같다.

static struct file_operations vd_fops = {

.read = virtual_device_read,

.write = virtual_device_write,

.open = virtual_device_open,

.release = virtual_device_release

};


동작들(R,W,O,R)은 각각 virtual_device_*들과 맵핑되어 있고, virtual_device_* 들은 함수로 구현되어 있다. 이들이 어떻게 동작하는지 아래의 그림을 통해서 조금 더 자세히 알아보도록하자.


응용프로그램에서 open, read, write, close와 같은 연산을 수행한다면 이는 커널 영역에서 시스템 콜을 호출한다. 각각의 시스템 콜은 VFS에 등록된 연산(vd_fops의 좌)들을 호출하고, 이들은 맵핑된 디바이스 드라이버의 함수(vd_fops의 우)들을 호출하는 매커니즘이다. 지금까지 이해한 정보를 토대로 실제 디바이스 드라이버를 제작해보자.


[virtual_device.c]

#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <asm/uaccess.h>
#include <linux/slab.h>

static char *buffer = NULL;

int virtual_device_open(struct inode *inode, struct file *filp) {
	printk(KERN_ALERT "virtual_device open function called\n");
	return 0;
}

int virtual_device_release(struct inode *inode, struct file *filp) {
	printk(KERN_ALERT "virtual device release function called\n");
	return 0;
}

ssize_t virtual_device_write(struct file *filp, const char *buf, size_t count, loff_t *f_pos) {
	printk(KERN_ALERT "virtual_device write function called\n");
	strcpy(buffer, buf);
	return count;
}

ssize_t virtual_device_read(struct file *filp, char *buf, size_t count, loff_t *f_pos) {
	printk(KERN_ALERT "virtual_device read function called\n");
	copy_to_user(buf, buffer, 1024);
	return count;
}

static struct file_operations vd_fops = {
	.read = virtual_device_read,
	.write = virtual_device_write,
	.open = virtual_device_open,
	.release = virtual_device_release
};

int __init virtual_device_init(void) {
	if(register_chrdev(250, "virtual_device", &vd_fops) < 0 )
		printk(KERN_ALERT "driver init failed\n");
	else 
		printk(KERN_ALERT "driver init successful\n");
	buffer = (char*)kmalloc(1024, GFP_KERNEL);
	if(buffer != NULL) 
		memset(buffer, 0, 1024);
	return 0;
}

void __exit virtual_device_exit(void) {
	unregister_chrdev(250, "virtual_device");
	printk(KERN_ALERT "driver cleanup successful\n");
	kfree(buffer);
}

module_init(virtual_device_init);
module_exit(virtual_device_exit);
MODULE_LICENSE("GPL");

이전에 언급했던 모듈 프로그래밍과 위에서 주목하여 봤던 함수들만 주의깊게 본다면 코드를 이해하는 것은 크게 어렵지 않을 것이다.


3. 모듈 컴파일

제작한 코드를 모듈때와 마찬가지로 Makefile을 통해 컴파일 해주도록하자.


[Makefile]

KERNELDIR = /lib/modules/$(shell uname -r)/build

obj-m = virtual_device.o

KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

default:
	$(MAKE) -C $(KDIR) SUBDIRS=$(PWD) modules

clean:
	rm -rf *.ko
	rm -rf *.mod.*
	rm -rf .*.cmd
	rm -rf *.o



4. 모듈 등록 및 등록 확인

정상적으로 컴파일이 완료되었다면 폴더에 virtual_device.ko라는 파일이 생성되었을 것이다. 해당 파일을 커널에 등록하기 위해 아래의 명령어를 이용하자.

$sudo insmod virtual_device.ko


등록을 마쳤다면, 정상적으로 모듈이 등록되었는지 확인 한 후 다음 단계를 진행해보자. 아래의 명령어를 이용하면 등록 여부를 확인할 수 있다. 아래의 화면처럼 출력된다면 등록이 정상적으로 진행된 것이다.

$sudo lsmod | grep virtual_device



5. 노드 추가

위에서 언급한 것처럼 문자 디바이스 드라이버는 /dev 디렉토리 내부에 노드로써 존재한다. 따라서 더미 디바이스를 mknod 명령어를 통해 등록해줘야 한다.

$sudo mknod /dev/virtual_device c 250 0


파라미터에 대해서 간단히 알아보면 좌측에서부터 순서대로 디바이스 네임, 디바이스 타입(문자 or 블록), Major Number, Minor Number다. 노드 추가가 정상적으로 진행됐는지 아래의 명령어로 확인해보자.

$sudo ls -al /dev | grep virtual_device



위의 화면을 보면, 타입은 'c'로 등록되었고, Major Number와 Minor Number가 각각 250, 0으로 할당됐으며, 노드의 이름은 virtual_device임을 확인할 수 있다. 내용이 길어져 진행사항을 확인해 볼 필요가 있다. 아래의 그림을 통해 지금까지의 과정과 앞으로 남은 과정에 대해서 짚고 넘어가자. 현재까지 진행된 것은 디바이스 드라이버를 작성하고, 디바이스 파일(노드)를 생성하고, insmod 명령어를 통해 디바이스 드라이버를 커널에 적재까지 완료했다. 즉 아래의 네 개의 박스 중에 좌측 세 개의 과정이 일단락되었고, 우리가 제작한 문자 디바이스 드라이버를 활용한 테스트 응용프로그램이 남았다.



6. 응용프로그램 제작 및 테스트

그럼 커널에 적재한 디바이스 드라이버가 정상적으로 동작하는지 확인하기 위해 간단한 테스트용 애플리케이션을 제작해보자.


[test.c]

#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char **argv) {
	int dev;
	char buff[1024];

	printf("Device driver test.\n");

	dev = open("/dev/virtual_device", O_RDWR);
	printf("dev = %d\n", dev);

	write(dev, "1234", 4);
	read(dev, buff, 4);
	printf("read from device: %s\n", buff);
	close(dev);

	exit(EXIT_SUCCESS);
}

	

코드 내용에 대해서 주목할만한 부분을 추려보면 아래와 같다. 마치 일반 파일을 액세스할 때와 같은 연산(open, read ...)들을 통해 디바이스를 조작하고 있다. 

dev = open("/dev/virtual_device", O_RDWR);

write(dev, "1234", 4);

read(dev, buff, 4);

close(dev);


소스 코드를 모두 작성했다면 아래의 순서대로 명령어를 입력하여 컴파일 후 실행시켜보자. 맨 아래에 화면처럼 나온다면 정상적으로 동작하는 것이다.

$gcc -o test test.c


$./test




7. 마치며...

지금까지 모듈 프로그래밍 안에서 문자 디바이스 드라이버를 제작하고, 등록했으며 이를 활용한 응용프로그램을 제작하여 테스트해봤다. 테스트 결과 정상적으로 동작하는 것을 확인했으며 커널이 제공하는 VFS 서비스를 통해서 디바이스를 마치 파일처럼 접근할 수 있다는 것을 알 수 있었다. 모듈 프로그래밍 보다 아주 조금 더 내용이 깊어진 기분이다. 하지만 이론을 익히고 실습을 통해서 이해하는데 큰 어려움은 없었다. 지금까지의 과정을 한 눈에 보기쉽게 설명된 그림을 찾아, 공유하고자 첨부한다.




그림을 자세히 들여다보며 정리하면 각 번호는 다음의 과정을 의미한다.

① 디바이스 드라이버가 포함된 모듈을 컴파일하고 insmod 명령어를 통해 커널에 등록

② 모듈 초기화 루틴에 의해 module_init() 매크로가 실행

③ module_init() 내부의 register_chrdev 함수에 의해 디바이스가 등록

④ 디바이스 연산이 매핑되는 과정


위 그림의 과정을 이해할 수 있다면 가장 기본적인 문자 디바이스 드라이버의 동작에 대해서 이해했다고 볼 수 있다. 실습해 볼 당시에는 시간이 얼마 안 걸렸는데 오히려 설명과 함께 실습한 내용을 전달하려다보니 부쩍 시간이 오래걸린다. 그래도 실습한 과정을 토대로 포스팅을 작성해보니 복습하면서 보다 오래 기억에 남을 것 같다. 차츰차츰 쌓이는 포스팅만큼 아는 것이 많아지고, 알고 있는 내용을 많은 사람들과 공유할 수 있기를 희망한다.