지난 글에서 Linux NVMe 드라이버의 아키텍처와 nvme_core_init() 관련 내용에 대해 알아보았는데, 이번 글에서는 주로 Linux NVMe 드라이버의 초기화 과정에서 어떤 일들이 일어나는지 알아봅니다.

Linux NVMe 드라이버 초기화 과정

drivers/nvme/host/pci.c 파일을 열어 module_init(nvme_init)을 찾습니다. 바로 위에 위치한 nvme_init()함수가 insmod로 드라이버가 Linux Kernel로 Load될 때 호출되는 함수로 코드 구조가 단순합니다.

static int __init nvme_init(void)
{
	int result;

	//1, Global Workqueue 생성
	nvme_workq = alloc_workqueue("nvme", WQ_UNBOUND | WQ_MEM_RECLAIM, 0);
	if (!nvme_workq)
		return -ENOMEM;

	//2, NVMe 드라이버 등록
	result = pci_register_driver(&nvme_driver); 
	if (result)
		destroy_workqueue(nvme_workq);

	return result;
}

MODULE_AUTHOR("Matthew Wilcox <willy@linux.intel.com>");
MODULE_LICENSE("GPL");
MODULE_VERSION("1.0");
module_init(nvme_init);
module_exit(nvme_exit);

1단계: Global Workqueue 만들기

다른 Linux 드라이버와 같이 제일 처음하는 작업은 Workqueue를 생성하는 것입니다.

Workqueue를 생성할 때 실제로 호출되는 함수는 __alloc_workqueue_key()인데, 이 부분은 이미 Linux Kenel Fundmantal 영역이라 자세히 살펴보지 않고 nvme 관련 내용에 대한 구체적인 분석을 위주로 하겠습니다.

/* include/linux/workqueue.h */

#define alloc_workqueue(fmt, flags, max_active, args...)		\
	__alloc_workqueue_key((fmt), (flags), (max_active),		\
			      NULL, NULL, ##args)

다음은 커널의 두 스레드에 대해 알아보기 위한 __alloc_workqueue_key() 함수의 첫 번째 코드 조합입니다.

/* kenel/workqueue.c */

if ((flags & WQ_POWER_EFFICIENT) && wq_power_efficient)
		flags |= WQ_UNBOUND;

커널에는 두 종류의 쓰레드 풀이 있습니다. 하나는 특정 CPU [0~N-1]에서만 실행되는 percpu타입의(__percpu) 쓰레드 풀입니다. 다른 하나는 특정 CPU에 바인딩되지 않은 쓰레드 풀이며, 이 쓰레드 풀에서 생성된 작업자 쓰레드는 모든 CPU에 디스패치될 수 있습니다. 캐시의 지역성으로 인해 CPU 바인딩 쓰레드 풀의 성능이 더 좋지지만, Power 소모 측면에서는 약간의 영향을 미칩니다. 흔히 그러하듯이, 설계시 Workqueue는 성능과 절전 사이의 Trade-off가 존재합니다. 더 나은 성능을 원한다면 CPU 바인딩 작업자 쓰레드가 작업을 처리하도록 하는 것이 가장 좋습니다. 그러나 전원 관리의 관점에서 볼 때 가장 좋은 전략은 유휴, 작업 및 다시 유휴를 반복하는 대신 CPU를 가능한 유휴 상태로 유지하는 것입니다.

위 내용의 이해를 돕기 위해 예를 들어보겠습니다. 시간 t1에 작업이 CPU A에서 실행되도록 예약되어 있고, 시간 t2에 작업이 완료되고 CPU A가 유휴 모드로 들어가고 t3에서 처리해야 할 새로운 작업이 있습니다. 이때, 어떤 CPU에 작업을 스케줄하는 것이 좋을까요? 작동 상태에 있는 CPU B입니까 아니면 유휴 상태에 있는 CPU A입니까? CPU A에서 실행되도록 예약된 경우 작업이 이전에 처리되었으므로 캐시 콘텐츠가 신선하고 작업이 편리하고 빠릅니다. 그러나 이렇게 하려면 CPU A를 유휴 상태에서 깨워야 합니다. CPU B를 선택하면 절전의 이점을 얻기 위해 CPU A를 유휴 상태에서 깨울 필요가 없습니다.

다음은 CPU 바인딩 쓰레드 풀과 바인딩되지 않은 쓰레드 풀에 대해 살펴보겠습니다. Workqueue가 처리할 작업을 받았을 때, Workqueue가 unbound type이면 unbound thread pool에서 작업을 처리하고 실행할 작업을 예약할 CPU 정책을(어떤 CPU에 실행할 작업을 예약할 것인지) 시스템 스케줄러 모듈로 전달됩니다. 스케줄러는 CPU 코어의 유휴 상태를 고려하여 CPU를 가능한 한 유휴 상태로 유지하여 전력 소비를 절약합니다. 따라서, Workqueue에 WQ_UNBOUND와 같은 플래그가 있으면 Workqueue에서 작업을 처리할 때 절전을 고려한다는 의미입니다. Workqueue에 WQ_UNBOUND 플래그가 없으면 Workqueue가 CPU 바인딩을 의미하는데, 이때 어떤 CPU 코어가 작업자 스레드를 실행하여 작업을 처리하도록 스케줄링될지는 더 이상 시스템 스케줄러의 제어를 받지 않으므로 간접적으로 전력 소비에 영향을 미칩니다.

관련된 자세한 내용은 리눅스 관련 서적을 읽어보시면 됩니다.

2단계: NVME 드라이버 등록

초기화하는 동안 Linux Kenel에서 제공하는 pci_register_driver() 함수를 호출하여 nvme_driver를 PCI 버스에 등록합니다. 여기서 PCI 버스가 nvme 드라이버를 해당 NVMe 장치에 어떻게 일치시키는지에 대한 의문점이 생깁니다.

시스템이 시작되면 BIOS는 전체 PCI 버스를 열거하고 스캔한 장치에 대한 정보를 담고 있는 ACPI 테이블을 운영 체제로 전달합니다. 운영 체제가 로드되면 PCI 버스 드라이버는 이 정보를 기반으로 각 PCI 장치의 Header Config 공간을 읽고 클래스 코드 레지스터에서 특성 값을 얻습니다. 이 클래스 코드는 장치를 로드할 드라이버를 선택하는 PCI 버스의 유일한 정보/기준입니다. NVMe Spec.은 아래 그림과 같이 NVMe 장치의 클래스 코드=0x010802h를 정의합니다.

img

코드에 따르면 nvme 드라이버는 nvme_id_table에 클래스 코드를 작성합니다.

/* drivers/nvme/pci.c */

static struct pci_driver nvme_driver = {
	.name		= "nvme",
	.id_table	= nvme_id_table,
	.probe		= nvme_probe,
	.remove		= nvme_remove,
	.shutdown	= nvme_shutdown,
	.driver		= {
		.pm	= &nvme_dev_pm_ops,
	},
	.sriov_configure = nvme_pci_sriov_configure,
	.err_handler	= &nvme_err_handler,
};

nvme_id_table의 내용은 다음과 같습니다.

static const struct pci_device_id nvme_id_table[] = {
	{ PCI_VDEVICE(INTEL, 0x0953),
		.driver_data = NVME_QUIRK_STRIPE_SIZE |
				NVME_QUIRK_DISCARD_ZEROES, },
	{ PCI_VDEVICE(INTEL, 0x0a53),
		.driver_data = NVME_QUIRK_STRIPE_SIZE |
				NVME_QUIRK_DISCARD_ZEROES, },
	{ PCI_VDEVICE(INTEL, 0x0a54),
		.driver_data = NVME_QUIRK_STRIPE_SIZE |
				NVME_QUIRK_DISCARD_ZEROES, },
	{ PCI_VDEVICE(INTEL, 0x5845),	/* Qemu emulated controller */
		.driver_data = NVME_QUIRK_IDENTIFY_CNS, },
	{ PCI_DEVICE(0x1c58, 0x0003),	/* HGST adapter */
		.driver_data = NVME_QUIRK_DELAY_BEFORE_CHK_RDY, },
	{ PCI_DEVICE(0x1c5f, 0x0540),	/* Memblaze Pblaze4 adapter */
		.driver_data = NVME_QUIRK_DELAY_BEFORE_CHK_RDY, },
	{ PCI_DEVICE_CLASS(PCI_CLASS_STORAGE_EXPRESS, 0xffffff) },
	{ PCI_DEVICE(PCI_VENDOR_ID_APPLE, 0x2001) },
	{ 0, }
};

위의 nvme_id_table에서 0x010802h가 보이시나요? Linux Kenel은 PCI_CLASS_STORAGE_EXPRESS를 pci_ids.h에서 정의하고 있습니다.

#define PCI_CLASS_STORAGE_EXPRESS	0x010802

pci_register_driver() 함수가 nvme_driver를 PCI 버스에 등록한 후, PCI 버스는 이 드라이버가 NVMe 장치용임을 알게됩니다(클래스 코드=0x010802h).

driver_register() 함수 → nvme_probe() 함수 호출

이 시점에서 PCI 버스의 드라이버와 NVMe 장치 간의 대응 관계는 __pci_register_driver() 함수내에서 호출되는 driver_register() 함수에 의해 결정됩니다. nvme_probe() 함수를 호출하여 발견한 nvme 장치에 해당하는 nvme 드라이버를 PCI 버스에 등록합니다.

/**
 * driver_register - register driver with bus
 * @drv: driver to register
 *
 * We pass off most of the work to the bus_add_driver() call,
 * since most of the things we have to do deal with the bus
 * structures.
 */
int driver_register(struct device_driver *drv)
{
	int ret;
	struct device_driver *other;

	BUG_ON(!drv->bus->p);

	if ((drv->bus->probe && drv->probe) ||
	    (drv->bus->remove && drv->remove) ||
	    (drv->bus->shutdown && drv->shutdown))
		printk(KERN_WARNING "Driver '%s' needs updating - please use "
			"bus_type methods\n", drv->name);

	other = driver_find(drv->name, drv->bus);
	if (other) {
		printk(KERN_ERR "Error: Driver '%s' is already registered, "
			"aborting...\n", drv->name);
		return -EBUSY;
	}

	ret = bus_add_driver(drv);
	if (ret)
		return ret;
	ret = driver_add_groups(drv, drv->groups);
	if (ret) {
		bus_remove_driver(drv);
		return ret;
	}
	kobject_uevent(&drv->p->kobj, KOBJ_ADD);

	return ret;
}

추가 정보