Chapter 7 리눅스 모듈 프로그래밍

 

마이크로 커널

리눅스는 커널 구조상 모노리틱(monolithic) 커널이다. 모노리틱 커널이란 커널이 제공해야 할 모든 기능 즉 태스크 관리, 메모리 관리, 파일시스템, 디바이스 드라이버, 통신 프로토콜 등의 기능이 단일한 커널 공간에 구현된 구조이다.

 

모노리틱 커널과 구조상으로 반대되는 개념이 마이크로 커널이다. 마이크로 커널은 커널 공간에 반드시 필요한 기능들만을 구현한다. 일반적으로 문맥 교환이나 주소 변환, 시스템 호출 처리, 그리고 디바이스 드라이버의 일부 등 주로 하드웨어와 밀접하게 관련된 기능들을 커널 공간에 구현한다. 그 외의 다른 커널 기능들은 사용자 공간에 구현

 

마이크로 커널의 장점

커널의 크기를 작게 할 수 있다. 커널 소스도 작고 깨끗, 관리, 개선, 유지등이 쉬워진다.

커널 크기가 작기 때문에 PDA나 휴대폰, 노트북 등 휴대용 시스템의 운영체제로 사용될 수 있다.

많은 기능이 사용자 공간에서 서버 형태로 구현되기 때문에 분산 환경 특히 클라이언트-서버 모델에 잘 적요돌 수 있다.

이러한 장점들 때문에 최근 개발되는 운영체제들은 대부분 마이크로 커널 구조를 갖는다. (Mach, L4, VxWorks Windows NT 등)

 

모노리틱 구조인 리눅스 커널은 모듈을 지원함으로써 마이크로 커널의 장점을 제공한다. 커널을 작게 만드는 것이 가능하며, 많은 기능들을 필요로 할 때 적재하여 사용할 수 있도록 해주는 것.(사용자 공간 적재x, 모듈은 커널 공간에 적재). 모듈을 사용하면 커널에 새로운 기능을 추가할 때 커널 소스를 직접 컴파일할 필요가 없다.

 

모듈 프로그래밍 무작정 따라 하기

 

hello_module.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <linux/kernel.h>
#include <linux/module.h>
 
int hello_module_init(void)
{
    printk(KERN_EMERG "Hello Module~! I'm in kernel \n");
    return 0;
}
 
void hello_module_cleanup(void)
{
    printk("<0>Bye Module~!\n");
}
 
module_init(hello_module_init);
module_exit(hello_module_cleanup);
 
MODULE_LICENSE("GPL");
 
http://colorscripter.com/info#e" target="_blank" style="color:#4f4f4f; text-decoration:none">Colored by Color Scripter
http://colorscripter.com/info#e" target="_blank" style="text-decoration:none; color:white">cs

모듈은 커널 공간에서 동작하며 커널 내부에 정의된 변수나 함수들을 사용한다. 따라서 커널이 사용하는 kernel.h, module.h 헤더 파일을 삽입. 또한 모듈은 커널에 적재될 때 커널에 의해 자동으로 호출될 함수를 작성해줘야 하는데 이 함수의 이름이 리눅스커널 2.4에선 init_module(), cleanup_module()이였다. 이후 이 매크로들은 이러한 함수의 이름을 임의로 지정할 수 있도록 해주고 있다.

결국 모듈이 시작될 때 hello_module_init()이 수행되고, 종료될 때 hello_module_exit()가 수행 된다.

 

이 두 함수는 단지 printk()라는 커널 내부 함수를 이용해 문자열을 출력하는 일을 한다. 커널 공간에서 문자열을 출력하는 것이므로 printf()가 아닌 printk()가 사용.

printk()는 인자로 넘어온 문자열과 출력지정자, 그리고 인자를 하나의 문자열로 조합한 뒤 이를 커널 내부에 유지되고 있는 원형 큐에 넣어주는 일을 담당. 원형 큐에 삽입된 문자열은 원형 큐를 관리하고 있으며, 원형 큐 내의 데이터를 터미널에 출력해주는 콘솔 디바이스가 활성화 될 때까지 보류되었다가 출력된다. 따라서 printk()를 호출하는 시점과 실제 화면에 출력되는 시점이 정확히 동기화 되지는 않는다. 또한 콘솔 디바이스가 활성화되기 이전에 원형 큐의 용량을 다 채우게 되면 printk()로 출력했다고 믿었던 내용이 출력되지 않고 사라지는 경우가 발생.

 

리눅스 커널은 출력하는 수많은 메시지들의 등급을 나누어 관리하는데, 이때 등급을 지정하는 방법은 KERN_EMERG 등의 매크로를 사용하거나 <0>처럼 직접 숫자로 지정해 주어도 된다.

1
2
3
4
5
6
7
8
9
obj-m := hello_module.o
 
KERNEL_DIR := /lib/modules/$(shell uname -r)build
PWD := $(shell pwd)
 
default :
    $(MAKE) -C $(KERNEL_DIR) SUBDIRS=$(PWD) modules
clean :
    $(MAKE) -C $(KERNEL_DIR) SUBDIRS=$(PWD) clean
http://colorscripter.com/info#e" target="_blank" style="text-decoration:none; color:white">cs

make 파일

----커널버전이 달라서 동작이 안됨. 책은 2.4(?) 버전---

 

 

 

Chapter 8 디바이스 드라이버

 

디바이스 드라이버 일반

유닉스 계열 시스템에서 모든 것은 파일로 취급, 모니터,키보드,마우스 디바이스도 모두 파일.

디스크에 저장되어있는 파일은 정규파일(regular file)이라고 부름.

사용자 태스크는 현재 접근하려는 파일이 모니터,키보드,마우스 인지 정규파일인지 신경쓰지않고 그저 파일만 접근하면 된다. 다시 말해 open(), read(), write(), close() 등의 일관된 함수 인터페이스를 통해서 모니터,키보드,마우스,정규파일 등의 파일에 접근하는 것이 가능. 이때 장치를 가리키는 파일을 정규파일과 구분하여 장치파일(device file)이라 부른다.

 

가. 사용자 입장에서 디바이스 드라이버

디바이스 드라이버는 두 가지 관점에서 생각할 수 있다.

첫번째는 사용자 태스크 관점에서의 디바이스 드라이버. 사용자 태스크가 접근하는 장치 파일이라는 개념은 VFS가 제공하는 파일 객체를 의미한다. 파일 객체에 사용자 태스크가 행할 수 있는 연산은 'linux/include/linux/fs.h' 파일 내에 strcut file_operations라는 이름으로 정의되어 있다.

 

사용자 태스크는 open() read() write() release() (사용자 태스크가 호출하는 close()함수에 대응) 등의 함수를 이용하여 파일 객체를 접근할 수 있다 

 

사용자 태스크가 장치파일에 접근할 때는 어떨까? 키보드의 입력 값을 얻기 위해 '키보드' 장치파일에 read()를 호출했다면, 키보드에서 입력 값을 확인하고 이 값을 사용자 태스크에게 넘겨주는 함수를 호출해야 할 것이다. 

 

사용자 태스크가 file_operations 구조체에 정의되어 있는 함수를 통해 장치파일에 접근할 때 호출할 함수를 정의하고 구현해 주는 것이 바로 디바이스 드라이버이다. 사용자 태스크는 디바이스 드라이버 개발자가 작성한 여러 가지 함수들을 일일이 알 필요 없이 파일 객체에 정의되어 있는 함수를 호출함으로써 장치에 접근할 수 있다는 장점이 있다.

 

리눅스에서 사용하는 장치는 한두 가지가 아니다. 리눅스는 시스템에 존재하는 여러 개의 디바이스 드라이버를 구분하기 위해 각 디바이스 드라이버마다 고유한 번호를 정해준다. 이때 각 디바이스 드라이버에게 정해준 고유한 번호룰 주 번호(Major number)라고 부른다. 리눅스는 4096개의 주 번호를 지원

 

리눅스에서 각 장치는 자신을 나타내는 장치 파일을 가지며, 이 장치 파일을 관리하는 아이노드 객체에 주 번호가 기록되어 있다. 구체적으로 아이노드 객체에는 i_rdev 필드가 존재하는데 바로 이 i_rdev 필드에 주 번호와 부 번호를 저장한다.

주 번호를 위해서는 12bit, 부 번호를 위해 20bit가 사용. 사용자 태스크가 특정 장치파일에 접근하면, 이 장치파일에 적절한 디바이스 드라이버의 주 번호를 알게 되는 것이다. 그리고 주 번호를 알게되면 장치파일에 등록된 디바이스 들아ㅣ버 내부 함수를 호출할 수 있게 된다.

 

부 번호의 필요성

만약 시스템에 네 개의 모니터가 장착되어있다면? 물리적으로 동일한 특성을 가지기 때문에 같은 디바이스 드라이버를 사용해도 될 것이다. 동일한 주 번호를 공유 해도 됨. 그런데 어떤 문자열의 출력을 요청했을 때 디바이스 드라이버는 4개의 모니터중 특정한 하나의 모니터에 그 문자열을 출력해야 할 것이다. 바로 이때 부 번호가 사용된다.

 

리눅스에서 장치파일은 일반적으로 /dev 디렉터리 밑에 존재. 모니터 즉, 터미널을 나타내는 장치 파일의 이름은 전통적으로 tty이란 이름으로 존재하며 복수개의 터미널이 존재하는 경우 tty0 tty1 tty2와 같이 뒤쪽에 번호를 붙여서 구분한다. tty0~tty3 장치파일은 4라는 공통된 주 번호를 가지고, 주 번호가 같다는 것은 4개의 터미널이 같은 디바이스 드라이버에 의해 구동될 수 있다는 의미가 된다. 

 

결국 부 번호는 같은 디바이스 드라이버를 사용하는 장치가 복수 개 있을 때 이들을 서로 구분하기 위해 사용된다. 사실 장치파일의 이름은 사용자 태스크에게 보여주기 위한 것일 뿐 커널 내부적으로는 아무 의미가 없다. 장치파일은 사용자 태스크에게 디바이스 드라이버 내부의 함수를 호출할 수 있는 진입점(entry point)을 제공한느 것이며, 이때 디바이스 드라이버를 선택하는 것은 주 번호를 통해서만 이뤄지기 때문.

 

사용자 태스크가 디바이스 드라이버의 함수를 호출할 수 있게 해주는 진입점인 장치 파일은 어떻게 만들까?

최근 커널에서는 udev를 통한 동적인 장치파일 생성/제거를 지원한다.

mknod /dev/mydrv [c | b] 주번호 부번호

mknod라는 명령어를 사용, 장치파일 생성 가능. 인자로는 장치파일의 이름과 유형, 그리고 이 파일을 접근할 때 어떤 디바이스 드라이버를 사용할지를 나타내는 주 번호와 같은 디바이스 드라이버를 사용하는 장치를 유일하게 구분하기 위한 부번호를 써준다. 

그러며 리눅스는 /dev디렉터리 밑에 mydrv 라는 이림의 파일을 생성하며, i_mode엔 장치 파일 유형에 따라 c인 경우 S_IFCHR b일 경우 S_IFBLK라는 값을 넣어준다.

 

장치파일 유형이란 ? 리눅스는 디바이스 드라이버를 크게 문자 디바이스 드라이버, 블록 디바이스 드라이버, 그리고 네트워크 디바이스 드라이버 세 가지로 구분.

문자 디바이스 드라이버: 순차 접근이 가능하고 임의의 크기로 데이터 전송이 가능한 드라이버 예) 터미널 드라이버

블록 디바이스 드라이버: 임의 접근이 가능하고 고정된 크기의 블록 단위로 데이터를 전송하는 드라이버 예)디스크 드라이버

현재는 단지 커널의 '페이지캐시'와 큐를 통해 데이터를 주고받는가의 여부를 통해 문자와 문자 블록디바이스 드라이이버를 구분한다. 사용자의 read() wrtie()함수와 1:1로 매칭 되어 디바이스 드라이버의 함수가 호출된다면 이를 문자 디바이스 드라이버라 한다. 한편 블록 디바이스 드라이버는 사용자의 read() write()와 같은 함수에 대응되는 함수가 존재하지 않으며, 큐를 통해 '페이지캐시'와 통신한다. 따라서 사용자 태스크가 블록 디바이스 드라이버에서 호출할 수 있는 함수는 struct block_device_operations에 정의 되어 있다.

 

 

나. 개발자 입장에서 디바이스 드라이버

디바이스 드라이버라는 말 자체는 하드웨어로써 존재하는 디바이스를 구동시키기 위한 소프트웨어를 부르는 말이다. 디바이스라는 하드웨어는 어느 운영체제이건 혹은 운영체제가 없어도 동작가능하도록 만들어야하는데, 운영체제 마다 디바이스 드라이버를 관리하는 방식이 다르기 때문에 모두 동작되는 디바이스 드라이버를 만드는 것은 쉬운 일이 아니다.

따라서 디바이스 드라이버를 작성할 때는 디바이스를 구도시키기 위해 필요한 '하드웨어와 밀접한 코드'와 '운영체제와 관련된 코드'를 분리하는 것이 바람직하다.

 

리눅스의 디바이스 드라이버는 특정 하드웨어를 위한 디바이스 드라이버 코어와, 코어를 리눅스에서 사용가능한 형태로 만들어 주기 위한 일종의 래퍼로 구성. 디바이스 드라이버 코어는 하드웨어 매뉴얼을 참조하여 해당 하드웨어 특성에 맞도록 작성.

 

작성된 코어를 리눅스 커널이 알 수 있도록 커널에 등록, 사용자 태스크가 장치 파일을 통해 접근할 수 있게 ㅐ주려면 사용자 태스크가 호출한 함수들과 코어의 함수를 연결시켜줘야 한다. 이것이 바로 래퍼(wrapper)의 역할이다.

 

리눅스 커널은 드라이버의 래퍼가 제공해야 할 함수들을 미리 정의해놓았다. 디바이스 드라이버 개발자는 어떤 함수를 사용자 태스크에게 제공해야 할지 고민할 필요 없이, 파일 오퍼레이션 자료 구조에 이미 정의되어있는 함수들에 대해서만 제공하면 된다.

 

사용자 태스크는 시스템 호출을 통해 장치 파일에 접근, 이때 호출 가능한 함수는 파일 오퍼레이션 구조체에 정의 되어 있다. 따라서 사용자 태스크는 일관된 구조를 갖출 수 있게 된다.

거꾸로 디바이스 드라이버 개발자는 DDI, 즉 파일 오퍼레이션 구조체에 정의되어 있는 함수를 디바이스 드라이버 내에 구현해 줌으로써 간단히 개발을 완료 할 수 있다.

 

여러 디바이스 드라이버를 커널이 실제로 어떻게 관리하고 있는가?

커널 내부에는 장치파일의 유형과 주 번호를 이용해 디바이스 드라이버(적절한 파일 오퍼레이션 구조체를)찾아 올 수 있는 자료구조가 유지 되고 있어야 할 것이다.

 

리눅스 커널은 문자형 디바이스 드라이버와 블록형 디바이스 드라이버를 위해 각각 cdev_map과 bdev_map이라는 이름의 자료구조를 유지한다. 이들 자료구조는 문자형 디바이스 드라이버인 경우 cdev 구조체를, 블록형 디바이스 드라이버인 경우 gendisk 구조체를 각각 255개씩 저장할 수 있는 배열형태로 구현되어 있다.

 

각 배열은 디바이스 드라이버의 주 번호를 255로 나누었을 때의 나머지 값(=해쉬값을 인덱스로 하여 접근되며, 동일한 해쉬 값을 가지는 드라이버들은 *next 포인터를 이용해 연결.

이를 통해 문자형과 블록형 디바이스 드라이버 각각 212=4096개씩, 최대 8192개의 디바이스 드라이버가 이들 자료구조를 통해 관리되는 것이다.

 

이때 cdev 자료구조의 ops 필드에는 문자형 디바이스 드라이버가 제공하는 file_operations 구조체가 저장되어 있으며, gendisk 자료구조의 fops 필드에는 블록형 디바이스 드라이버가 제공하는 block_device_operations 구조체가 저장되어 있다. 결국 사용자 태스크가 장치파일에 접근하는 경우, 장치 파일의 inode 구조체에 저장되어 있는 i_mode 필드와 i_rdev필드의 값을 이용하여 적절한 파일 오퍼레이션 구조체를 찾아서 호출함으로써 디바이스 드라이버가 제공하는 함수를 사용할 수 있게 되는 것이다.

 

디아비스 드라이버를 등록하는 방법

문자형 디바이스 드라이버인 경우 cdev_init() 함수를 호출하여, cdev 구조체를 할당받고, 구조체의 필드를 적절히 초기화 한다. 그런 뒤, cdev_add() 함수를 호출함으로써 cdev 구조체를 cdev_map에 등록. 그런데 cdev_map 배열은 주번호를 이용해 접근된다고 했으므로 디바이스 드라이버를 위한 주번호가 있어야 이 배열에 등록하는 것이 가능할 것이다.

주 번호 할당/관리를 위해 커널은 chrdevs라는 이름의 자료구조를 사용한다. 미리 정해진 주 번호를 사용하는 경우에는 register_chrdev_region() 함수를 호출하여 디바이스 드라이버의 주 번호를 chrdevs배열에 등록 할 수 있다.

 

디바이스 드라이버 개발자는 어떤 주 버호를 사용할 수 있는가?

리눅스 커널 소스에는 많은 디바이스에 대해 주 번호를 미리 지정해 놓았다.

Documentation/devices.txt 파일을 통해 확인 가능

현재 시스템에서 실제 사용 중인 주 번호는 "cat /proc/devices" 명령으로 확인 가능 디바이스 드라이버 개발자는 비사용 중인 주 번호를 할당하여 사용하면 된다. alloc_chrdev_region()함수도 이용가능, 이 함수는 chrdevs 자료구조에서 아직 비사용 중인 주 번호를 리턴해 준다.

 

블록 디바이스 드라이버인 경우 주 번호는 major_names 자료구조를 통해 관리

register_blkdev()함수를 통해 특정 주 번호 등록가능. 동적으로 주 번호를 할당받으려면 이 함수의 첫 번째 인자로 0을 넘기면 사용가능한 주번호를 할당받아 리턴해준다. 그런 뒤 alloc_disk()함수를 통해 gendisk 구조체를 할당받고 add_disk() 함수를 통해 bdev_map에 등록.

 

블록 디바이스 드라이버는 '페이지캐시'와 큐를 통해 데이터를 주고받기때문에 커널과 데이터를 주고 받을 큐를 생성해야 하며, 커널이 큐에 데이터 읽기/쓰기 요청을 넣고 호출할 함수를 지정해 주어야 한다. 이는 blk_alloc_queue()와 blk_queue_make_request() 함수를 통해 이루어진다.

 

새로운 디바이스를 리눅스에 추가하는 과정은 기본적으로 다음과 같은 4단계로 이루어짐

  1. 디바이스 드라이버 코어 함수를 구현. 이 함수들은 하드웨어 매뉴얼을 통해 작성된 코드들
  2. 작성한 코어 함수를 리눅스에 등록시키기 위해 래퍼 작성. 리눅스의 디바이스 드라이버는 파일 오퍼레이션 구조체를 통해 파일로서 접근된다.
  3. 앞서 설명한 일련의 함수를 이용, 디바이스 드라이버를 커널에 등록
  4. 디바이스 드라이버를 호출하기 위한 진입점 (entry point)에 해당하는 장치 파일을 생성

 

 

문자 디바이스 드라이버 구조

커널 버전이 달라서 안될것같아서 실습하지 않았음. 책참조

+ Recent posts