커널 스터디(iamroot 18기) 6주차 내용 정리 #2, 디바이스 드라이버와 네트워크

문연수·2021년 8월 29일
0

iamroot (Linux Internal)

목록 보기
13/24

6. 디바이스 드라이버

유닉스 계열 시스템에서 일반적인 장치 파일(regular file) 을 포함해 모니터, 키보드, 마우스 디바이스 등도 모두 파일로 취급한다.
따라서 리눅스는 사용자 태스크가 접근하려는 파일의 종류와 관계없이 일관된 인터페이스(close(), open(), read(),, write()) 를 제공할 수 있다.
이때 장치를 가르키는 파일을 정규 파일과 구분하여 장치 파일(device file) 이라 부른다.

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

사용자 태스크가 접근하는 장치 파일이라는 개념은 VFS 가 제공하는 파일 객체를 의미한다. 파일 객체에 사용자 태스크가 행할 수 있는 연산은 include/linux/fs.h 에 저장되어 있다.
사용자 태스크가 file_operations 구조체에 정의되어 있는 함수를 통해 장치 파일에 접근할 때, 호출할 함수를 정의하고 구현해 주는 것이 디바이스 드라이버이다.

장치 파일의 접근

리눅스는 시스템에 존재하는 여러 개의 디바이스 드라이버를 구현하기 위해 각 디바이스 드라이버마다 고유한 번호를 정해준다. 이때 각 디바이스 드라이버에게 정해준 고유한 번호를 주 번호(Major number) 라 부른다.
부 번호(Minor number) 는 같은 디바이스 드라이버를 사용하는 장치가 복수개 있을 때 이들을 서로 구분하기 위해 사용된다.
장치 파일은 사용자 태스크에게 디바이스 드라이버 내부 함수를 호출할 수 있는 진입점 (entry point) 를 제공한다. 장치 파일은 이름유형 그리고 이 파일을 접근할 때 어떤 디바이스 드라이버를 사용할 지를 나타내는 주번호부번호 로 구성된다.
리눅스는 디바이스 드라이버를 크게 문자 디바이스 드라이버, 블록 디바이스 드라이버, 그리고 네트워크 디바이스 드라이버 세 가지로 구분한다.

  • 문자 디바이스 드라이버: 순차 접근이 가능하고 임의의 크기로 데이터 전송이 가능한 드라이버.
  • 블록 디바이스 드라이버: 임의 접근이 가능하고 고정된 크기의 블록 단위로 데이터를 전송하는 드라이버.

+ 추가

일반적으로 위 세 가지 분류에 들어가는 디바이스는 /dev 디렉터리를 통해 접근이 가능하나, 세 가지 분류에 들어가지 않는 디바이스들은 모두 /sys/dev 에서 계층적으로 관리된다.

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

디바이스 드라이버라는 말 자체는 하드웨어로써 존재하는 디바이스를 구동 시키기 위한 소프트웨어를 부르는 말이다. 디바이스라는 하드웨어는 어느 운영체제건 심지어 운영체제가 없는 환경에서도 사용될 수 있기 때문에, 당연히 디바이스 드라이버라는 소프트웨어도 독립적으로 제작해야 한다.
리눅스의 디바이스 드라이버는 특정 하드웨어를 위한 디바이스 드라이버 코어와 코어를 리눅스에서 사용 가능한 형태로 만들어주기 위한 래퍼로 구성된다.
장치에 대해서 반드시 하나의 속성만을 가질 필요가 없이 다양한 디바이스 드라이버 형태를 가질 수 있다 <= 디바이스 드라이버 모델 이라 부른다.

7. 디바이스 드라이버의 관리

리눅스 커늘은 문자형 디바이스 드라이버와 블록형 디바이스 드라이버를 위해 각각 cdev_mapbdev_map 이라는 255 개의 bucket 을 가지는 hash 자료구조를 제공한다. 이를 통해 문자형과 블록형 디바이스 드라이버 각각 212=40962^{12} = 4096 개씩, 최대 8192 개의 디바이스 드라이버가 위 자료구조를 통해 관리된다.

cdev 자료구조의 ops 필드에는 문자형 디바이스 드라이버가 제공하는 file_operations 구조체가 저장되어 있으며, gendisk 자료구조의 fops 필드에는 블록형 디바이스 드라이버가 제공하는 block_device_operations 구조체가 저장되어 있다.

리눅스 커널 소스에는 많은 디바이스에 대해 주 번호를 미리 지정해놓았다. 사용 중인 주번호의 내역은 Documentation/devices.txt 파일을 통해 확인할 수 있다.

리눅스에서 새로운 디바이스 드라이버를 추가하는 과정은 아래의 4 단계로 이뤄진다:

  1. 디바이스 드라이버 코어 함수를 구현한다.
  2. 작성한 코어 함수를 디바이스 드라이버는 파일 오퍼레이션 구조체를 통해 파일로써 접근된다. 따라서 이러한 인터페이스를 위한 함수를 구현해주어야 한다.
  3. 디바이스 드라이버를 커널에 등록한다.
  4. 디바이스 드라이버를 호출하기 위한 진입점에 해당하는 장치 파일을 생성한다.

8. 문자 디바이스 드라이버 구현

문자 디바이스 드라이버는 아래의 순서로 구현된다:

  1. 디바이스 드라이버의 이름과 주번호를 결정
  2. 디바이스 드라이버가 제공할 인터페이스를 위한 함수 구현
  3. 새로운 디바이스 드라이버를 커널에 등록
  4. /dev 디렉터리를 통해 접근할 수 있도록 장치 파일 생성
 #include <linux/kernel.h>
 #include <linux/module.h>
 #include <linux/slab.h>
 #include <linux/fs.h>
 #include <linux/cdev.h>
 #include <linux/device.h>
 #include <asm/uaccess.h>

#define DEVICE_NAME "mydrv"
#define MYDRV_MAX_LENGTH 4096
#define MIN(a, b) (((a) < (b)) ? (a) : (b))

struct class *myclass;
struct cdev *mycdev;
struct device *mydevice;
dev_t mydev;

static char *mydrv_data;
static int mydrv_read_offset, mydrv_write_offset;

static int mydrv_open(struct inode *inode, struct file *file)
{
	printk("%s\n", __FUNCTION__);
	return 0;
}

static int mydrv_release(struct inode *inode, struct file *file)
{
	printk("%s\n", __FUNCTION__);
	return 0;
}

static ssize_t mydrv_read(struct file *file, char *buf, size_t count, loff_t *ppos)
{
	if ((buf == NULL) || (count < 0))
		return -EINVAL;
	
	if ((mydrv_write_offset - mydrv_read_offset) <= 0)
		return 0;
        
	count = MIN(mydrv_write_offset - mydrv_read_offset), count);
	if (copy_to_user(buf, mydrv_data + mydrv_read_offset, count))
		return -EFAULT;
        
	mydrv_read_offset += count;
    
	return count;
}

static ssize_t mydrv_write(struct file *file, const char *buf, size_t count, loff_t *ppos)
{
	if ((buf == NULL) || (count < 0))
		return -EINVAL;
        
	if (count + mydrv_write_offset >= MYDRV_MAX_LENGTH) {
		/* driver space is too small */
		return 0;
	}
    
	if (copy_from_user(mydrv_data + mydrv_write_offset, buf, count))
		return -EFAULT;
        
	mydrv_write_offset += count;
    
	return count;
}

struct file_operations mydrv_fops = {
	.owner = THIS_MODULE,
	.read = mydrv_read,
	.write = mydrv_write,
	.open = mydrv_open,
	.release = mydrv_release,
};

int mydrv_init(void)
{
	if (alloc_chrdev_region(&mydrv, 0, 1, DEVICE_NAME) < 0)
		return -EBUSY;
        
	myclass = class_create(THIS_MODULE, "mycharclasss");
	if (IS_ERR(myclass)) {
		unregister_chrdev_region(mydev, 1);
		return PTR_ERR(myclass);
	}
    
	mydevice = device_create(myclass, NULL, mydev, NULL, "mydevicefile");
	if (IS_ERR(mydevice)) {
		class_destroy(myclass);
		unregister_chrdev_region(mydev, 1);
		return PTR_ERR(mydevice);
	}
    
	mycdev = cdev_alloc();
	mycdev->ops = &mydrv_fops;
	mycdev->owner = THIS_MODULE;
	if (cdev_add(mycdev, mydrv, 1) < 0) {
		device_destroy(myclass, mydev);
		class_destroy(myclass);
		unregister_chrdev_region(mydev, 1);
		return -EBUSY;
	}
    
	mydrv_data = (char *) kmalloc(MYDRV_MAX_LENGTH * sizeof(char), GFP_KERNEL);
	mydrv_read_offset = mydrv_write_offset = 0;
    
	return 0;
}

void mydrv_cleanup(void)
{
	kfree(mydrv_data);
	cdev_del(mycdev);
	device_destroy(myclass, mydev);
	class_destroy(myclass);
	unregister_chrdev_region(mydev, 1);
}

module_init(mydrv_init);
module_exit(mydrv_cleanup);
MODULE_LICENSE("GPL");

mydrv_init()

  1. alloc_chrdev_region()
    주 번호 동적할당
  2. class_create()
    device_create() 를 위한 class 구조체 생성.
  3. device_create()
    udev 를 통해 장치파일을 등록 & 생성.
    udev: 리눅스 커널을 위한 장치 관리자로 /dev 디렉터리의 장치 노드를 관리한다.
  4. cdev_alloc()
    cdev 구조체 할당
  5. kmalloc()
    드라이버가 사용할 공간을 동적 할당

mydrv_open(), mydrv_release()

장치 주번호 확인. 실제 물리적인 드라이버라면 해당 하드웨어로 초기화하는 작업이 들어가야 한다.

mydrv_write(), mydrv_read()

조건을 검사하고, 커널 공간에 데이터를 읽고 쓰는 작업을 수행

9. 블록 디바이스 드라이버 개요

블록 디바이스 드라이버는 파일 시스템(조금 더 구체적으로 페이지 캐시) 에서 논리적인 블록에 대한 읽기/쓰기 요청이 발생했을 때 이 논리적인 블록을 물리적인 주소(헤더, 트랙, 섹터, 섹터의 수)로 변환하는 것이다. 변환된 물리적인 주소에서 실제 데이터를 주 메모리(커널의 페이지 캐시 공간)로 읽어온다. 또한 디스크에서 사건의 발생을 알렸을 때(인터럽트) 그 사건을 처리하는 일을 수행한다.

페이지 캐시

블록 디바이스 드라이버는 사용자의 read(), write() 함수와 1:1 로 연관되지 않고 페이지 캐시와 통신한다. 이유는 아래의 두 가지이다:

  1. 블록 디바이스는 블록 단위(디스크의 경우 4KiB) 로 입출력하기 때문에 100 byte 만 읽고 쓰는 것을 불가능하다.
  2. 디스크는 램보다 매우 느리기 때문에, 다시 사용될 데이터를 페이지 캐시에 두는 것이 성능상의 이점이 된다.

교체 정책

일반적으로 램의 크기가 디스크보다 작기 때문에 결국 페이지 캐시 내의 데이터는 언젠가는 페이지 교체 정책에 의해 디스크에 기록(flush) 된다.

I/O 스케쥴링

일반적으로 디스크 I/O 는 데이터 전송을 위한 지연시간이 존재한다. 따라서 대부분의 운영체제는 발생된 I/O 요청 (request) 을 그대로 블록 디바이스 드라이버로 보내는 대신, 성능 향상을 위해 I/O 요청 의 순서를 바꾸거나 병합하는 I/O 스케쥴링 기법을 사용한다.

1. CFQ (Completely Fair Queue)

기본적으로 64 개의 큐로 유지하며, 태스크의 PID 해쉬값을 인덱스로 하여 I/O 요청을 각 큐에 나누어 저장하고, 각 큐에서 공평하게 I/O 요청을 꺼내어 디바이스 드라이버 큐에 넣는다.

2. Deadline

블록 번호로 정렬되어 있는 R/W sorted 큐와, deadline 으로 정렬된 R/W deadline 을 유지한다. deadline 큐에는 읽기 요청과 쓰기 요청이 완료되어야 하는 시간을 지정함으로써 I/O 요청 이나 병합이나, 순서 변경 등의 이유로 장시간 대기하는 것을 방지한다.

3. Anticipatory

deadline 정책과 유사하나 성능 향상을 위해 두 가지 기법을 추가

  1. 다음 I/O 요청seektime 이 현재 I/O 요청seek time 대비 반절 이하라면 순서를 변경
  2. 각 태스크의 I/O 통계 정보 를 바탕으로 I/O 요청 선택

4. Noop

말 그대로 아무 일도 하지 않는다. I/O 스케쥴링으로 인한 성능 향상이 적은 SSD 등의 저장장치에서 사용됨

블록 디바이스 드라이버 큐

스케쥴링을 통해 결정된 I/O 요청 은 블록 디바이스 드라이버의 큐에 담기게 된다. 요청은 크게 읽기와 쓰기로 나뉠 수 있으며 일반적으로 읽기/쓰기 요청을 각각 최대 128 개씩 담을 수 있다.
blk_queue_make_register() 에서 지정된 디바이스 드라이버의 함수를 호출함으로써 드라이버가 I/O 작업 을 수행하게 한다.

10. 블록 디바이스 드라이버 구조

#include <linux/string.h>
#include <linux/slab.h>
#include <asm/atomic.h>
#include <linux/bio.h>
#include <linux/module.h>
#include <linux/moduleparam.h>
#include <linux/init.h>
#include <linux/pagemap.h>
#include <linux/blkdev.h>
#include <linux/genhd.h>
#include <linux/buffer_head.h>
#include <linux/backing-dev.h>
#include <linux/blkpg.h>
#include <linux/writeback.h>

#include <asm/uaccess.h>

#define DEVICE_NAME "mydrv"
#define MYDRV_MAX_LENGHTH (8 * 1024 * 1024) // 8 MiB
#define MYDRV_BLK_SIZE 512
#define MYDRV_TOTAL_BLK (MYDRV_MAX_LENGTH >> 9)

static int MYDRV_MAJOR = 0;
static char *mydrv_data;
struct request_queue *mydrv_queue;
struct gendisk *mydrv_disk;

static void mydrv_make_request(struct request_queue *q, struct bio *bio)
{
	struct block_device *bdev = bio->bi_bdev;
	int rw;
	struct bio_vec bvec;
	sector_t sector;
	struct bvec_iter iter;
	int err = -EIO;
	void *mem;
	char *dev;
    
	sector = bio->bi_iter.bi_sector;
	if (bio_end_sector(bio) > get_capacity(bdev->bd_disk))
		goto out;
        
	if (unlikely(bio->bi_rw & REQ_DISCARD))) {
		err = 0;
		goto out;
	}
    
	rw = bio_rw(bio);
	if (rw == READA)
		rw = READ;
        
	bio_for_each_segment(bvec, bio, iter) {
		unsigned int len = bvec.bv_len;
		data = mydrv_data + (sector * MYDRV_BLK_SIZE);
		mem = kmap_atomic(bvec.bv_page) + bvec.bv_offset;
		
		if (rw == READ)
			memcpy(mem + bvec.bv_offset, data, len);
			flush_dcache_page(page);
		} else {
			flush_dcache_page(page);
			memcpy(data, mem + bvec.bv_offset, len);
		}
        
		kunmap_atomic(mem);
		data += len;
		sector += len >> 9;
		err = 0;
	}

out:
	bio_endio(bio, err);
}

int mydrv_open(struct block_device *dev, fmode_t mode)
{
	return 0;
}

void mydrv_release(struct gendisk *gd, fmode_t mode)
{
	return ;
}

int mydrv_ioctl(struct block_device *bdev, fmode_t mode, unsigned int cmd, unsigned long arg)
{
	int err;
	if (cmd != BLKFLSBUF)
		return -ENOTTY;
        
	err = -EBUSY;
	if (bdev->bd_openers <= 1) {
		kill_bdev(bdev);
		err = 0;
	}
    
	return err;
}

static struct block_device_operations mydrv_fops = {
	.owner = THIS_MODULE,
	.open = mydrv_open,
	.release = mydrv_release,
	.ioctl = mydrv_ioctl
};

int mydrv_init(void)
{
	if ((MYDRV_MAJOR = register_blkdev(MYDRV_MAJOR, DEVICE_NAME)) < 0) {
		printk("<0> can't be registered \n");
		return -EIO;
	}
    
	printk("<0> major NO = %d\n", MYDRV_MAJOR);

	if (mydrv_data = vmalloc(MYDRV_MAX_LENGTH)) == NULL) {
		unregister_blkdev(MYDRV_MAJOR, DEVICE_NAME);
		printk("<0> vmalloc failed\n");
		return -ENOMEM;
	}
    
	if ((mydrv_disk  = alloc_disk(1)) == NULL) {
		printk("<0> alloc_disk failed\n");
		unregister_chrdev(MYDRV_MJAOR, DEVICE_NAME);
		vfree(mydrv_data);
		return -EIO;
	}
    
	if ((mydrv_queue = blk_alloc_queue(GFP_KERNEL)) == NULL) {
		printk("<0> blk_alloc_queue failed \n");
		put_disk(mydrv_disk);
		vfree(mydrv_data);
		unregister_chrdev(MYDRV_MAJOR, DEVICE_NAME);
		return -EIO;
	}

	blk_queue_make_request(mydrv_queue, &mydrv_make_request);
	blk_queue_max_hw_sectors(mydrv_queue, 512);
	mydrv_disk->major = MYDRV_MAJOR;
	mydrv_disk->first_minor = 0;
	mydrv_disk->fops = &mydrv_fops;
	mydrv_disk->queue = mydrv_queue;
	sprintf(mydrv_disk->disk_name, "mydrv");
	set_capacity(mydrv_disk, MYDRV_TOTAL_BLK);
	add_disk(mydrv_disk);
    
	return 0;
}

void mydrv_exit(void)
{
	del_gendisk(mydrv_disk);
	put_disk(mydrv_disk);
	blk_cleanup_queue(mydrv_queue);
	vfree(mydrv_data);
    
	blk_unregister_region(MKDEV(MYDRV_MAJOR, 0), 1);
	unregister_blkdev(MYDRV_MAJOR, DEVICE_NAME);
}

module_init(mydrv_init);
module_exit(mydrv_exit);
MODULE_LICENSE("GPL");

mydrv_init()

  1. register_blkdev()
    블록 디바이스 드라이버를 등록한다.
  2. vmalloc()
    가상 디스크 공간 할당
  3. alloc_disk()
    리눅스에 논리적인 디스크를 등록 시키기 위한 자료구조(gendisk) 를 할당
  4. blk_alloc_queue()
    커널과 통신하기 위한 블록 디바이스 큐를 생성
  5. blk_queue_make_request()
    I/O 작업 을 수행할 함수 등록

mydrv_make_request()

  1. bio 구조체를 통해 현재 요청의 유형을 파악.
  2. 작업 수행 가능 여부 확인
  3. 할당된 공간에 복사 작업을 수행하여 요청을 처리

11. 네트워킹

통신 프로토콜은 약속이다. 통신하려는 사람들끼리 지켜야 할 약속을 정의해놓은 것으로, 이 약속만 잘 지킨다면 네트워크에 연결되어 있는 누구와도 통신을 할 수 있다.
통신하려는 양측단은 서로 IP 주소 와 포트 번호를 알고 있어야 하며 미리 정해진 약속, 즉 프로토콜을 지키는 경우에 통신이 가능해진다.

프로토콜 계층 구조

통신 프로토콜은 층 구조(layered architecture) 를 갖는다. 표준 프로토콜인 OSI 프로토콜 은 7 층을 가지며, 각 층마다 명세와 인터페이스가 명확히 정의되어 있다.

BSD Socket

하위 계층의 모든 작업을 추상화시켜 사용자 태스크에게 소켓이라는 객체를 제공. BSD 소켓 층에서 사용자는 프로토콜 패밀리를 선택할 수 있고, 리눅스에서 지원되는 대표적인 패밀리로 INET, UNIX, IPX, APPLETALK 등이 있다.

INET socket

사용자가 INET 패밀리를 선택하면 INET 층으로 내려가게 된다. 여기에서 사용자는 다시 소켓의 유형(type) 을 선택할 수 있다. 일반적으로 사용되는 유형은 스트림과 데이터그램이다.

TCP, UDP

  • UDP (User Datagram Protocol) 비연결 지향형 프로토콜로 UDP 는 패킷 전송 시 그 패킷이 목적지에 안전하게 도착하였는지 알 수도 없고 신경 쓰지도 않는다.
  • TCP (Transmission Control Protocol)
    연결 지향 방식의 신뢰성 있는 프로토콜이다. TCP 는 일련의 패킷들에게 번호(Sequence numberAcknowledge number) 를 설정하고 전송에 따라 상태를 유지하며 TCP 통로 양 끝(종점 호스트) 간에 전송 데이터가 정확하게 수신되었는지를 확인한다.

IP (Internet Protocol)

IP 층은 인터넷 프로토콜을 구현한 층이다. 이 층에서는 IP 주소를 사용해 통신한다. IP 주소는 각 NIC (Network Interface Card) 마다 고유하다. IP 층은 자신의 IP 주소와 목적지 IP 주소를 이용해 패킷을 만든다. 또한 에러 처리를 위한 체크섬(Checksum), 데이터가 너무 클 경우 단편화(fragmentation), 다른 호스트로 재전송(forwarding) 등을 수행한다.

데이터 링크 층에는 실제 네트워크 디바이스가 존재하게 되며, 각 디바이스는 net_device 라는 자료구조에 자신의 정보 및 저장 기능을 저장하여 IP 층에게 제공하게 된다.

ARP (Address Resoltuion Protocol)

다중 접속 프로토콜을 통해 IP 패킷을 보내기 위해서는 IP 계층이 IP 호스트의 이더넷 주소를 알아야만 한다. IP 주소는 단지 개념적인 주소일 뿐이고, 고유한 물리적인 주소를 가지는 것은 이더넷 디바이스이기 때문이다. 리눅스는 IP 주소를 이더넷 주소(MAC 주소) 로 변환하기 위해 ARP (Address Resolution Protocol) 를 사용한다. 특정한 IP 주소가 담긴 ARP 요청 패킷은 멀티 캐스트 주소에 보내 모든 노드에 전달한다. 그 IP 주소를 가지고 있는 호스트는 자신의 이더넷 주소가 담긴 ARP 응답을 돌려준다. 그 반대는 RARP (Reverse ARP) 라 부른다.

데이터 Encapsulation

사용자가 응용 프로그램에게 전송할 데이터를 보내면, 응용은 자신의 제어에 필요한 데이터를 헤더에 추가한다 (ex. telnet, ftp, http)
응용 데이터가 TCP 층으로 전달되면, TCP 층에서는 자신의 헤더(소스 포트, 목적지 포트 등) 를 이 데이터에 추가하여 메세지를 만든다.
메세지가 IP 층으로 전달되면 IP 층에서도 자신의 헤더(IP 버전, TTL 등) 를 이 메세지에 추가하여 패킷을 만든다.

이하동문

12. 리눅스 커널의 네트워크 구현

리눅스는 제어 흐름이 다양한 곳으로 분기할 수 있는 상황을 효과적으로 지원하기 위해, 층 사이에 제어가 전달될 때 자료구조를 이용한 간접 호출 방법으로 통신 프로토콜을 구현하였다. 즉 하위 층에서 제공하는 함수를 상위 층에서 직접(direct) 이 함수를 호출하는 것이 아니라 하위 층이 자신이 제공하는 함수의 시작 주소를 특정 자료구조에 (테이블) 에 등록하고, 상위 층에서는 단지 자료구조에 등록된 함수를 호출하는 방식으로 하위 층의 함수르 간접(indirect) 호출하는 방식으로 구현한다.

장점

상위 층과 하위 층간에 종속 관계가 없어지며, 일부 층의 내용을 수정하거나, 새로운 층을 추가할 때 커널의 변경이 간단해진다.

단점

자료구조를 이용한 간접 호출 방법으로 통신 프로토콜을 구현하면 소스코드가 복잡해지고 분석하기도 어려워진다. 그리고 수행 시 성능에 문제가 될 수도 있다.

출처

[책] 리눅스 커널: 내부구조 (백승제, 최종무 저)
[사이트] https://testkernel.tistory.com/91
[사이트] https://ko.wikipedia.org/wiki/Udev
[사이트] https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=si1254&logNo=70183582125
[사이트] http://egloos.zum.com/rousalome/v/10002618
[이미지] https://www.real-sec.com/2020/05/osi-7-layers-explained-the-easy-way/

profile
2000.11.30

0개의 댓글