첫 번째 커널 모듈 만들기

Hello World 모듈부터 캐릭터 디바이스 구현까지 단계별로 따라하며 커널 모듈 개발의 기초를 확립합니다.

전제 조건: 빌드 시스템커널 모듈 문서를 먼저 읽으세요. 튜토리얼은 실습 중심이므로, 개발 환경 구성과 빌드 흐름을 먼저 익혀야 오류 해결이 빠릅니다.
일상 비유: 이 튜토리얼은 요리 레시피 따라하기와 비슷합니다. 재료를 준비하고(헤더 파일), 순서대로 조리하고(빌드), 맛을 보는(테스트) 과정을 단계별로 경험하면서 감각을 익힙니다.

핵심 요약

  • module_init / module_exit — 모듈 로드/언로드 시 실행될 함수를 등록하는 매크로입니다.
  • MODULE_LICENSE — 라이선스를 명시하는 매크로. GPL 라이선스는 모든 커널 심볼 접근을 허용합니다.
  • insmod / rmmod — 모듈을 커널에 로드/언로드하는 명령어입니다.
  • module_param — 모듈 로드 시 파라미터를 전달받을 수 있게 합니다.
  • file_operations — 캐릭터 디바이스의 read, write, ioctl 등의 동작을 정의하는 구조체입니다.

단계별 이해

  1. Hello World 모듈
    가장 간단한 형태의 모듈을 작성하여 빌드부터 로드까지의 전체 흐름을 익힙니다.
  2. 파라미터 추가
    모듈에 런타임 설정을 전달하는 방법을 배웁니다.
  3. sysfs 속성 추가
    유저 공간에서 모듈 상태를 조회하고 제어하는 인터페이스를 만듭니다.
  4. 캐릭터 디바이스 구현
    실제 동작하는 디바이스 드라이버의 뼈대를 완성합니다.
실습 환경: Ubuntu 22.04 LTS, Linux 6.x 커널, gcc 11.x 기준으로 작성되었습니다. 다른 배포판에서도 대부분 동일하게 동작하지만, 헤더 파일 패키지명이나 경로는 다를 수 있습니다.

1단계: Hello World 모듈

환경 준비

커널 모듈을 빌드하려면 커널 헤더 파일이 필요합니다. 현재 실행 중인 커널 버전에 맞는 헤더를 설치하세요.

# Ubuntu/Debian
sudo apt update
sudo apt install -y build-essential linux-headers-$(uname -r)

# Fedora/RHEL
sudo dnf install -y gcc make kernel-devel kernel-headers

# 설치 확인
ls /lib/modules/$(uname -r)/build
권장: 실습은 가상 머신(VirtualBox, QEMU)에서 수행하는 것이 안전합니다. 잘못된 모듈 코드가 시스템을 패닉 상태로 만들 수 있습니다.

소스 코드 작성

작업 디렉토리를 만들고 hello.c 파일을 생성합니다.

mkdir -p ~/kernel_tutorial/hello
cd ~/kernel_tutorial/hello
vim hello.c  # 또는 nano, gedit 등 선호하는 에디터 사용

다음은 가장 기본적인 커널 모듈 코드입니다.

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

static int __init hello_init(void)
{
    pr_info("Hello World! 모듈이 로드되었습니다.\\n");
    return 0;
}

static void __exit hello_exit(void)
{
    pr_info("Goodbye! 모듈이 언로드되었습니다.\\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("간단한 Hello World 커널 모듈");
MODULE_VERSION("1.0");
코드 설명
  • 1-3행 커널 모듈 개발에 필요한 헤더 파일 포함. module.h는 모듈 매크로, kernel.h는 커널 로그 함수, init.h는 초기화/종료 섹션 매크로를 제공합니다.
  • 5-9행 __init 매크로는 이 함수가 초기화 후 메모리에서 해제될 수 있음을 표시합니다. module_init()이 이 함수를 모듈 로드 시 호출할 진입점으로 등록합니다. 성공 시 0, 실패 시 음수(errno)를 반환해야 합니다.
  • 11-14행 __exit 매크로는 이 함수가 모듈이 커널에 직접 빌트인될 경우 사용되지 않음을 표시합니다. module_exit()이 이 함수를 언로드 시 호출할 함수로 등록합니다.
  • 16-17행 모듈의 초기화 및 종료 함수를 커널에 등록하는 매크로입니다. 이 두 매크로는 반드시 존재해야 합니다.
  • 19-22행 모듈 메타데이터를 정의합니다. MODULE_LICENSE("GPL")은 필수이며, 이를 누락하면 "tainted" 상태가 되어 일부 커널 심볼에 접근할 수 없습니다.

Makefile 작성

같은 디렉토리에 Makefile을 생성합니다.

# Makefile
obj-m += hello.o

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

all:
	$(MAKE) -C $(KDIR) M=$(PWD) modules

clean:
	$(MAKE) -C $(KDIR) M=$(PWD) clean
코드 설명
  • 2행 obj-m은 모듈로 빌드할 대상을 지정합니다. hello.ohello.c로부터 생성되며, 최종적으로 hello.ko 파일이 만들어집니다.
  • 4행 현재 실행 중인 커널의 빌드 시스템 경로를 가리킵니다. 이 디렉토리에는 Kbuild 파일들과 헤더 파일들이 있습니다.
  • 8행 -C $(KDIR)는 커널 빌드 시스템으로 이동하고, M=$(PWD)는 외부 모듈 소스 위치를 알려줍니다. modules 타겟은 모듈을 빌드합니다.

빌드 및 확인

이제 모듈을 빌드합니다.

make

빌드가 성공하면 다음 파일들이 생성됩니다.

ls -lh
# hello.c
# hello.ko      ← 로드할 커널 모듈
# hello.mod.c   ← 자동 생성된 모듈 메타데이터
# hello.mod.o
# hello.o
# modules.order
# Module.symvers
핵심 파일: hello.ko가 최종 결과물입니다. 이 파일을 커널에 로드하게 됩니다.

모듈 로드 및 테스트

모듈을 커널에 로드하고 커널 로그를 확인합니다.

# 모듈 로드 (root 권한 필요)
sudo insmod hello.ko

# 커널 로그 확인 (마지막 10줄)
dmesg | tail -10

# 로드된 모듈 목록 확인
lsmod | grep hello

# 모듈 정보 확인
modinfo hello.ko

예상 출력:

$ dmesg | tail -10
[  123.456789] Hello World! 모듈이 로드되었습니다.

$ lsmod | grep hello
hello                  16384  0

$ modinfo hello.ko
filename:       /home/user/kernel_tutorial/hello/hello.ko
version:        1.0
description:    간단한 Hello World 커널 모듈
author:         Your Name
license:        GPL
srcversion:     ABCDEF123456
depends:
retpoline:      Y
name:           hello
vermagic:       6.1.0-18-amd64 SMP preempt mod_unload modversions

모듈 언로드

모듈을 제거하고 확인합니다.

# 모듈 언로드
sudo rmmod hello

# 커널 로그 확인
dmesg | tail -5

예상 출력:

$ dmesg | tail -5
[  123.456789] Hello World! 모듈이 로드되었습니다.
[  456.789012] Goodbye! 모듈이 언로드되었습니다.
축하합니다! 첫 번째 커널 모듈을 성공적으로 작성하고 실행했습니다. 이제 더 복잡한 기능들을 추가해보겠습니다.

모듈 생명주기 이해

커널 모듈은 소스 코드 작성부터 커널에 적재되어 실행되기까지 명확한 생명주기를 거칩니다. 아래 다이어그램은 이 전체 흐름을 한눈에 보여줍니다.

커널 모듈 생명주기 (Lifecycle) 소스 작성 → 빌드 → 적재 → 실행 → 제거 → 언로드 소스 코드 (.c) make 컴파일 모듈 (.ko) insmod 적재 module_init() 초기화 실행 실행 중 Running rmmod 제거 module_exit() 정리 실행 언로드됨 Unloaded

그림 1. 커널 모듈의 전체 생명주기 흐름

빌드 과정 상세

make 명령을 실행하면 Kbuild 시스템이 내부적으로 여러 단계를 거쳐 최종 .ko 파일을 생성합니다.

커널 모듈 빌드 과정 (Kbuild) hello.c 모듈 소스 코드 전처리 (Preprocessing) 매크로 확장 → hello.i Kbuild 시스템 CFLAGS, EXTRA_CFLAGS 적용 컴파일 (Compilation) gcc → hello.o (오브젝트 파일) modpost 심볼 검증 & 버전 체크 → hello.mod.c 생성 Module.symvers 내보낸 심볼 CRC 목록 hello.mod.c 컴파일 gcc → hello.mod.o 링킹 (Linking) hello.o + hello.mod.o → ld 입력 오브젝트 hello.o + hello.mod.o hello.ko ✓ 최종 모듈 파일

그림 2. Kbuild 시스템의 모듈 빌드 세부 과정

모듈 ELF 섹션 구조

.ko 파일은 표준 ELF 형식이지만, 커널 모듈 전용 섹션들을 포함합니다. readelf -S hello.ko 명령으로 직접 확인할 수 있습니다.

커널 모듈 (.ko) ELF 섹션 레이아웃 ELF Header 매직: 7f 45 4c 46 .text 모듈 주요 코드 (함수 본문) 메모리 상주 .init.text __init 함수 (module_init) 초기화 후 해제됨 .exit.text __exit 함수 (module_exit) 빌트인 시 생략 (CONFIG_…=y) .modinfo license, author, description, alias modinfo 명령으로 조회 __versions CRC 심볼 버전 (CONFIG_MODVERSIONS) 커널 ABI 호환성 검증 .symtab / .strtab 심볼 테이블 및 문자열 테이블 nm, objdump으로 조회 .gnu.linkonce.this_module struct module 인스턴스 모듈 식별 핵심 구조체

그림 3. .ko 파일의 ELF 섹션 레이아웃

__init 섹션: __init 매크로가 붙은 함수와 데이터는 ELF의 .init.text / .init.data 섹션에 배치됩니다. 모듈이 성공적으로 초기화된 뒤 커널은 이 섹션의 메모리를 자동으로 해제합니다. 한 번만 실행되는 초기화 코드가 상주 메모리를 낭비하지 않도록 하는 최적화입니다.
__exit 섹션: __exit 매크로가 붙은 함수는 ELF의 .exit.text 섹션에 배치됩니다. 만약 모듈이 obj-y로 커널에 빌트인(내장)되는 경우, __exit 함수는 아예 최종 바이너리에 포함되지 않습니다. 빌트인 코드는 언로드될 수 없으므로 종료 함수 자체가 불필요하기 때문입니다.
.ko 파일 직접 확인:
# ELF 섹션 목록 확인
readelf -S hello.ko

# 모듈 정보(modinfo 섹션) 확인
readelf -p .modinfo hello.ko

# 심볼 테이블 확인
nm hello.ko

# __init 함수가 .init.text에 있는지 확인
objdump -t hello.ko | grep init

2단계: 모듈 파라미터 추가

모듈 로드 시 파라미터를 전달하면 코드 수정 없이 동작을 변경할 수 있습니다. 이는 디버깅이나 설정 조정에 매우 유용합니다.

소스 코드 (hello_param.c)

새로운 파일 hello_param.c를 생성합니다.

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/moduleparam.h>

static char *name = "World";
static int count = 1;
static bool debug = false;

module_param(name, charp, 0644);
MODULE_PARM_DESC(name, "인사할 대상 이름 (기본값: World)");

module_param(count, int, 0644);
MODULE_PARM_DESC(count, "메시지 반복 횟수 (기본값: 1)");

module_param(debug, bool, 0644);
MODULE_PARM_DESC(debug, "디버그 모드 활성화 (기본값: false)");

static int __init hello_init(void)
{
    int i;

    if (debug)
        pr_debug("디버그 모드로 모듈 로드 시작\\n");

    for (i = 0; i < count; i++) {
        pr_info("[%d/%d] Hello, %s!\\n", i + 1, count, name);
    }

    return 0;
}

static void __exit hello_exit(void)
{
    pr_info("Goodbye, %s!\\n", name);
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("파라미터를 받는 커널 모듈");
MODULE_VERSION("1.1");
코드 설명
  • 6-8행 모듈 파라미터로 사용할 전역 변수를 선언합니다. 기본값을 설정할 수 있습니다.
  • 10-11행 module_param(변수명, 타입, 권한) 매크로로 파라미터를 등록합니다. 세 번째 인자는 /sys/module/모듈명/parameters/ 아래에 생성될 파일의 권한입니다. 0644는 읽기 전용입니다.
  • 13-17행 MODULE_PARM_DESC는 파라미터 설명을 추가합니다. modinfo 명령으로 확인할 수 있습니다.
  • 23-24행 디버그 파라미터가 true일 때만 디버그 메시지를 출력합니다.
  • 26-28행 count 파라미터 값만큼 반복하여 메시지를 출력합니다.

Makefile 수정

="macro">obj-m += hello_param.o

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

all:
	="fn">$(MAKE) -C ="fn">$(KDIR) M=="fn">$(PWD) modules

clean:
	="fn">$(MAKE) -C ="fn">$(KDIR) M=="fn">$(PWD) clean

빌드 및 테스트

# 빌드
make

# 파라미터 정보 확인
modinfo hello_param.ko | grep parm

# 기본값으로 로드
sudo insmod hello_param.ko
dmesg | tail -5

# 파라미터를 전달하여 로드
sudo rmmod hello_param
sudo insmod hello_param.ko name="Linux" count=3 debug=1
dmesg | tail -10

# sysfs를 통해 파라미터 확인
cat /sys/module/hello_param/parameters/name
cat /sys/module/hello_param/parameters/count
cat /sys/module/hello_param/parameters/debug

# 정리
sudo rmmod hello_param

예상 출력:

$ modinfo hello_param.ko | grep parm
parm:           debug:디버그 모드 활성화 (기본값: false) (bool)
parm:           count:메시지 반복 횟수 (기본값: 1) (int)
parm:           name:인사할 대상 이름 (기본값: World) (charp)

$ dmesg | tail -5
[  789.012345] [1/3] Hello, Linux!
[  789.012347] [2/3] Hello, Linux!
[  789.012348] [3/3] Hello, Linux!
권한 주의: 파라미터 권한을 0666 등으로 설정하면 모든 사용자가 쓰기 가능해집니다. 보안 위험이 있으므로 신중하게 설정하세요. 대부분의 경우 0444(읽기 전용) 또는 0644(root만 쓰기)를 사용합니다.

파라미터 처리 흐름

아래 다이어그램은 module_param() 매크로가 내부적으로 어떻게 동작하고, 파라미터 값이 어디에 반영되는지 보여줍니다.

module_param() 매개변수 처리 흐름 사용자 명령 insmod hello.ko \ name="Linux" count=3 (커맨드라인 인자 전달) 커널 파라미터 처리 module_param(name, charp, 0644); module_param(count, 매크로 내부 동작 1. __module_param_call() → 파라미터 메타데이터 등록 2. struct kernel_param 생성 → .name, .ops, .arg 설정 3. __section(".modinfo") 에 파라미터 정보 기록 sysfs 노출 (읽기/쓰기) /sys/module/hello/ parameters/ ├── name → "Linux" └── count → 3 모듈 정적 변수 갱신 static char *name; → "Linux" 저장 static int count; → 3 저장 perm=0644 값 파싱 perm!=0 이면 런타임에 sysfs로 값 변경 가능 sysfs 쓰기 시 변수에 반영

그림 4. module_param() 매크로의 파라미터 처리 흐름

지원하는 파라미터 타입

타입 이름 C 타입 설명 예시
bool bool 참/거짓 (0, 1, Y, N) insmod m.ko debug=1
int int 정수 insmod m.ko count=42
uint unsigned int 부호 없는 정수 insmod m.ko size=1024
long long 긴 정수 insmod m.ko addr=0xFF
charp char * 문자열 포인터 insmod m.ko name="hello"
short short 짧은 정수 insmod m.ko port=80

배열 파라미터

module_param_array() 매크로를 사용하면 배열 형태의 파라미터를 받을 수 있습니다.

static int ports[4] = { 0 };
static int port_count = 0;

/* 네 번째 인자: 실제 전달된 원소 수를 저장할 변수 */
module_param_array(ports, int, &port_count, 0444);
MODULE_PARM_DESC(ports, "포트 번호 배열 (최대 4개)");
# 배열 파라미터 전달 (쉼표로 구분)
sudo insmod mymod.ko ports=80,443,8080,8443

# 확인
cat /sys/module/mymod/parameters/ports

3단계: sysfs 속성 추가

sysfs는 커널 객체를 파일시스템으로 노출하는 메커니즘입니다. 모듈에 sysfs 속성을 추가하면 유저 공간에서 실시간으로 상태를 조회하거나 설정을 변경할 수 있습니다.

소스 코드 (hello_sysfs.c)

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/kobject.h>
#include <linux/sysfs.h>
#include <linux/string.h>

static int counter = 0;
static char message[128] = "Initial message";

/* counter 속성의 show 함수 */
static ssize_t counter_show(struct kobject *kobj,
                              struct kobj_attribute *attr,
                              char *buf)
{
    return sprintf(buf, "%d\\n", counter);
}

/* counter 속성의 store 함수 */
static ssize_t counter_store(struct kobject *kobj,
                               struct kobj_attribute *attr,
                               const char *buf, size_t count)
{
    int ret;

    ret = kstrtoint(buf, 10, &counter);
    if (ret < 0)
        return ret;

    pr_info("Counter updated to: %d\\n", counter);
    return count;
}

/* message 속성의 show 함수 */
static ssize_t message_show(struct kobject *kobj,
                              struct kobj_attribute *attr,
                              char *buf)
{
    return sprintf(buf, "%s\\n", message);
}

/* message 속성의 store 함수 */
static ssize_t message_store(struct kobject *kobj,
                               struct kobj_attribute *attr,
                               const char *buf, size_t count)
{
    snprintf(message, sizeof(message), "%s", buf);
    /* 줄바꿈 제거 */
    if (message[count - 1] == '\\n')
        message[count - 1] = '\\0';

    pr_info("Message updated to: %s\\n", message);
    return count;
}

/* 속성 정의 */
static struct kobj_attribute counter_attribute =
    __ATTR(counter, 0664, counter_show, counter_store);

static struct kobj_attribute message_attribute =
    __ATTR(message, 0664, message_show, message_store);

/* 속성 배열 */
static struct attribute *attrs[] = {
    &counter_attribute.attr,
    &message_attribute.attr,
    NULL,
};

/* 속성 그룹 */
static struct attribute_group attr_group = {
    .attrs = attrs,
};

static struct kobject *hello_kobj;

static int __init hello_init(void)
{
    int ret;

    /* kobject 생성 */
    hello_kobj = kobject_create_and_add("hello_sysfs", kernel_kobj);
    if (!hello_kobj) {
        pr_err("Failed to create kobject\\n");
        return -ENOMEM;
    }

    /* 속성 그룹 생성 */
    ret = sysfs_create_group(hello_kobj, &attr_group);
    if (ret) {
        pr_err("Failed to create sysfs group\\n");
        kobject_put(hello_kobj);
        return ret;
    }

    pr_info("sysfs 모듈 로드됨. /sys/kernel/hello_sysfs/ 확인\\n");
    return 0;
}

static void __exit hello_exit(void)
{
    sysfs_remove_group(hello_kobj, &attr_group);
    kobject_put(hello_kobj);
    pr_info("sysfs 모듈 언로드됨\\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("sysfs 속성을 가진 커널 모듈");
MODULE_VERSION("1.2");
코드 설명
  • 12-17행 show 함수는 파일을 읽을 때 호출됩니다. sprintf로 버퍼에 값을 쓰고 길이를 반환합니다.
  • 20-32행 store 함수는 파일에 쓸 때 호출됩니다. kstrtoint로 문자열을 정수로 변환합니다. 성공 시 쓴 바이트 수를 반환해야 합니다.
  • 58-59행 __ATTR 매크로로 속성을 정의합니다. 인자는 (이름, 권한, show 함수, store 함수)입니다.
  • 65-69행 속성들을 배열로 묶어 그룹을 만듭니다. 마지막은 반드시 NULL이어야 합니다.
  • 85-88행 kobject_create_and_add/sys/kernel/ 아래에 새로운 디렉토리를 만듭니다. kernel_kobj/sys/kernel을 가리키는 전역 변수입니다.
  • 91-97행 sysfs_create_group으로 속성 파일들을 한 번에 생성합니다.

빌드 및 테스트

# Makefile 수정
# obj-m += hello_sysfs.o

# 빌드 및 로드
make
sudo insmod hello_sysfs.ko

# sysfs 파일 확인
ls -l /sys/kernel/hello_sysfs/

# counter 읽기
cat /sys/kernel/hello_sysfs/counter

# counter 쓰기
echo 42 | sudo tee /sys/kernel/hello_sysfs/counter
cat /sys/kernel/hello_sysfs/counter

# message 읽기/쓰기
cat /sys/kernel/hello_sysfs/message
echo "Hello from userspace" | sudo tee /sys/kernel/hello_sysfs/message
cat /sys/kernel/hello_sysfs/message

# 커널 로그 확인
dmesg | tail -10

# 정리
sudo rmmod hello_sysfs

예상 출력:

$ ls -l /sys/kernel/hello_sysfs/
total 0
-rw-rw-r-- 1 root root 4096 Feb 16 10:00 counter
-rw-rw-r-- 1 root root 4096 Feb 16 10:00 message

$ cat /sys/kernel/hello_sysfs/counter
0

$ echo 42 | sudo tee /sys/kernel/hello_sysfs/counter
42

$ dmesg | tail -3
[  901.234567] sysfs 모듈 로드됨. /sys/kernel/hello_sysfs/ 확인
[  910.345678] Counter updated to: 42
[  920.456789] Message updated to: Hello from userspace
활용: sysfs는 디바이스 드라이버의 런타임 설정, 통계 수집, 디버깅 인터페이스로 널리 사용됩니다. /sys/class/, /sys/bus/, /sys/devices/ 아래를 탐색해보면 다양한 예제를 확인할 수 있습니다.

sysfs 계층 구조

지금까지 만든 세 모듈이 sysfs에 어떻게 배치되는지 전체 계층을 살펴봅니다. sysfs는 커널 객체 모델(kobject)을 기반으로 하며, 각 모듈이 다른 방식으로 sysfs에 엔트리를 생성합니다.

sysfs 계층 구조 /sys/ kernel/ hello_sysfs/ ← kobject_create_and_add() counter ← __ATTR() message ← __ATTR() show/store 콜백으로 사용자 공간에서 읽기/쓰기 가능 module/ hello_param/ parameters/ name ← module_param() count debug insmod 시 커맨드라인으로 전달 perm 설정 시 런타임 변경 가능 class/ hello/ hello_char ← device_create() udev가 감지하여 /dev/hello_char 생성 범례 kobject 속성 (직접 생성) 모듈 파라미터 (자동 노출) 디바이스 클래스 (udev 연동)

그림 5. 튜토리얼 모듈들의 sysfs 계층 구조

4단계: 캐릭터 디바이스 구현

캐릭터 디바이스는 바이트 스트림으로 데이터를 읽고 쓸 수 있는 디바이스입니다. 이 단계에서는 /dev/hello 노드를 통해 유저 공간과 통신하는 간단한 캐릭터 디바이스를 만들어봅니다.

소스 코드 (hello_chardev.c)

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>

#define DEVICE_NAME "hello_char"
#define CLASS_NAME  "hello"
#define BUFFER_SIZE 1024

static int major_number;
static struct class *hello_class = NULL;
static struct device *hello_device = NULL;
static struct cdev hello_cdev;

static char device_buffer[BUFFER_SIZE];
static int buffer_len = 0;

/* open 핸들러 */
static int hello_open(struct inode *inode, struct file *file)
{
    pr_info("hello_char: Device opened\\n");
    return 0;
}

/* release 핸들러 */
static int hello_release(struct inode *inode, struct file *file)
{
    pr_info("hello_char: Device closed\\n");
    return 0;
}

/* read 핸들러 */
static ssize_t hello_read(struct file *file, char __user *user_buf,
                            size_t count, loff_t *offset)
{
    size_t to_read;

    if (*offset >= buffer_len)
        return 0;  /* EOF */

    to_read = min(count, (size_t)(buffer_len - *offset));

    if (copy_to_user(user_buf, device_buffer + *offset, to_read))
        return -EFAULT;

    *offset += to_read;
    pr_info("hello_char: Read %zu bytes\\n", to_read);

    return to_read;
}

/* write 핸들러 */
static ssize_t hello_write(struct file *file, const char __user *user_buf,
                             size_t count, loff_t *offset)
{
    size_t to_write;

    to_write = min(count, (size_t)(BUFFER_SIZE - buffer_len));

    if (to_write == 0)
        return -ENOSPC;  /* 버퍼 가득 참 */

    if (copy_from_user(device_buffer + buffer_len, user_buf, to_write))
        return -EFAULT;

    buffer_len += to_write;
    pr_info("hello_char: Written %zu bytes\\n", to_write);

    return to_write;
}

/* file_operations 구조체 */
static struct file_operations fops = {
    .owner = THIS_MODULE,
    .open = hello_open,
    .release = hello_release,
    .read = hello_read,
    .write = hello_write,
};

static int __init hello_init(void)
{
    int ret;
    dev_t dev;

    /* 1. 메이저 번호 할당 */
    ret = alloc_chrdev_region(&dev, 0, 1, DEVICE_NAME);
    if (ret < 0) {
        pr_err("Failed to allocate major number\\n");
        return ret;
    }
    major_number = MAJOR(dev);
    pr_info("Registered with major number %d\\n", major_number);

    /* 2. 디바이스 클래스 생성 */
    hello_class = class_create(CLASS_NAME);
    if (IS_ERR(hello_class)) {
        unregister_chrdev_region(dev, 1);
        pr_err("Failed to create device class\\n");
        return PTR_ERR(hello_class);
    }

    /* 3. 디바이스 파일 생성 (/dev/hello_char) */
    hello_device = device_create(hello_class, NULL, dev, NULL, DEVICE_NAME);
    if (IS_ERR(hello_device)) {
        class_destroy(hello_class);
        unregister_chrdev_region(dev, 1);
        pr_err("Failed to create device\\n");
        return PTR_ERR(hello_device);
    }

    /* 4. cdev 초기화 및 추가 */
    cdev_init(&hello_cdev, &fops);
    hello_cdev.owner = THIS_MODULE;
    ret = cdev_add(&hello_cdev, dev, 1);
    if (ret < 0) {
        device_destroy(hello_class, dev);
        class_destroy(hello_class);
        unregister_chrdev_region(dev, 1);
        pr_err("Failed to add cdev\\n");
        return ret;
    }

    pr_info("Device /dev/%s created\\n", DEVICE_NAME);
    return 0;
}

static void __exit hello_exit(void)
{
    dev_t dev = MKDEV(major_number, 0);

    cdev_del(&hello_cdev);
    device_destroy(hello_class, dev);
    class_destroy(hello_class);
    unregister_chrdev_region(dev, 1);

    pr_info("Device /dev/%s removed\\n", DEVICE_NAME);
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("간단한 캐릭터 디바이스 드라이버");
MODULE_VERSION("1.3");
코드 설명
  • 22-26행 open 핸들러는 디바이스 파일이 열릴 때 호출됩니다. 여기서는 로그만 남깁니다.
  • 36-53행 read 핸들러는 유저 공간으로 데이터를 전송합니다. copy_to_user를 사용해야 하며, offset을 업데이트해 연속 읽기를 지원합니다.
  • 56-71행 write 핸들러는 유저 공간에서 데이터를 받습니다. copy_from_user를 사용하며, 버퍼가 가득 차면 -ENOSPC를 반환합니다.
  • 74-80행 file_operations 구조체에 핸들러 함수들을 등록합니다. 지원하지 않는 연산은 NULL로 두면 커널이 기본 동작을 제공합니다.
  • 88-93행 alloc_chrdev_region으로 동적으로 메이저 번호를 할당받습니다. 정적 할당보다 안전합니다.
  • 96-102행 class_create로 디바이스 클래스를 생성합니다. 이는 udev가 /dev에 노드를 자동으로 만들 수 있게 합니다.
  • 105-112행 device_create로 디바이스 파일을 생성합니다. udev가 이를 감지하여 /dev/hello_char를 만듭니다.
  • 115-125행 cdev_init로 cdev 구조체를 초기화하고, cdev_add로 커널에 등록합니다.

빌드 및 테스트

# Makefile 수정
# obj-m += hello_chardev.o

# 빌드 및 로드
make
sudo insmod hello_chardev.ko

# 디바이스 파일 확인
ls -l /dev/hello_char

# 메이저/마이너 번호 확인
cat /proc/devices | grep hello

# 데이터 쓰기
echo "Hello from userspace!" | sudo tee /dev/hello_char

# 데이터 읽기
sudo cat /dev/hello_char

# 추가 데이터 쓰기
echo "Second line" | sudo tee -a /dev/hello_char

# 다시 읽기
sudo cat /dev/hello_char

# 커널 로그 확인
dmesg | tail -15

# 정리
sudo rmmod hello_chardev

예상 출력:

$ ls -l /dev/hello_char
crw------- 1 root root 240, 0 Feb 16 11:00 /dev/hello_char

$ sudo cat /dev/hello_char
Hello from userspace!

$ dmesg | tail -10
[ 1001.123456] Registered with major number 240
[ 1001.123458] Device /dev/hello_char created
[ 1010.234567] hello_char: Device opened
[ 1010.234569] hello_char: Written 22 bytes
[ 1010.234570] hello_char: Device closed
[ 1020.345678] hello_char: Device opened
[ 1020.345680] hello_char: Read 22 bytes
[ 1020.345681] hello_char: Device closed

테스트 프로그램 작성

C 프로그램으로도 테스트할 수 있습니다. test_chardev.c를 작성합니다.

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

int main()
{
    int fd;
    char write_buf[] = "Test from C program\\n";
    char read_buf[100];
    ssize_t ret;

    /* 디바이스 열기 */
    fd = open("/dev/hello_char", O_RDWR);
    if (fd < 0) {
        perror("Failed to open device");
        return 1;
    }

    /* 쓰기 */
    ret = write(fd, write_buf, strlen(write_buf));
    printf("Written %zd bytes\\n", ret);

    /* 파일 포인터를 처음으로 */
    lseek(fd, 0, SEEK_SET);

    /* 읽기 */
    ret = read(fd, read_buf, sizeof(read_buf) - 1);
    if (ret >= 0) {
        read_buf[ret] = '\\0';
        printf("Read %zd bytes: %s", ret, read_buf);
    }

    /* 닫기 */
    close(fd);
    return 0;
}
# 컴파일 및 실행
gcc -o test_chardev test_chardev.c
sudo ./test_chardev

예상 출력:

Written 21 bytes
Read 21 bytes: Test from C program
축하합니다! 실제로 동작하는 캐릭터 디바이스 드라이버를 완성했습니다. 이제 이를 기반으로 더 복잡한 드라이버를 개발할 수 있습니다.

캐릭터 디바이스 전체 아키텍처

아래 다이어그램은 유저 공간의 응용 프로그램이 /dev/hello_char를 통해 커널 모듈과 통신하는 전체 흐름을 보여줍니다.

캐릭터 디바이스 전체 아키텍처 사용자 공간 (User Space) 응용 프로그램 fd = open("/dev/ hello_char",..) 시스템 콜 open() read() write() ioctl() close() /dev/hello_char major: 동적 할당 minor: 0 커널/사용자 경계 VFS (Virtual File System) inode 조회 major/minor 번호로 cdev 구조체 검색 cdev_map → cdev file_operations 디스패치 .open = dev_open .read = dev_read .write = dev_write .release = dev_release .unlocked_ioctl = dev_ioctl .llseek = dev_llseek 커널 모듈 (hello_char) 핸들러 함수 dev_open() → 열기 dev_read() → 버퍼→유저 dev_write() → 유저→버퍼 dev_ioctl() → 제어 dev_release()→ 닫기 dev_llseek() → 탐색 struct cdev cdev_init(&cdev, &fops) cdev_add(&cdev, dev, 1) device_buffer char buf[1024] fops 디바이스 등록 흐름 alloc_chrdev_region(&dev,..) class_create("hello") device_create(cls,..,"hello_char") udev 자동 노드 생성 과정 device_create() 커널에서 uevent 발생 kobject_uevent() netlink 소켓으로 이벤트 전송 udevd 데몬 규칙 매칭 후 mknod 호출 /dev/hello_char 디바이스 노드 생성 crw-rw---- 240,0

그림 6. 캐릭터 디바이스 전체 아키텍처 — 유저 공간 → VFS → 모듈 → udev

5단계: procfs 인터페이스 구현

procfs(/proc)는 커널이 유저 공간에 정보를 노출하는 가상 파일시스템입니다. 프로세스 정보(/proc/[pid])뿐 아니라 커널 모듈도 자체 엔트리를 만들어 상태를 보고하거나 설정을 받을 수 있습니다. Linux 5.6부터 proc_ops 구조체가 도입되어 기존 file_operations를 대체했습니다.

User Space cat /proc/hello → read() echo "data" > → write() VFS (Virtual File System) procfs proc_create() seq_show() write_handler() proc_ops 커널 모듈 내부 상태 / 버퍼 읽기 쓰기

그림 7. procfs 데이터 흐름 — 읽기(seq_file)와 쓰기 경로

procfs vs sysfs: sysfs는 하나의 속성에 하나의 값을 노출하는 데 적합하고, procfs는 여러 줄의 정보를 한 파일에 출력하거나 복잡한 읽기/쓰기 로직이 필요할 때 적합합니다. 새로운 드라이버에서는 sysfs를 권장하지만, 디버깅이나 통계 정보 출력에는 procfs가 여전히 널리 사용됩니다.

소스 코드 (hello_proc.c)

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/proc_fs.h>
#include <linux/seq_file.h>
#include <linux/uaccess.h>
#include <linux/version.h>

#define PROC_NAME    "hello_proc"
#define BUFFER_SIZE  256

static char proc_buffer[BUFFER_SIZE];
static int proc_buffer_len = 0;
static int read_count = 0;

/* seq_file show 콜백 — 읽기 시 호출 */
static int hello_proc_show(struct seq_file *m, void *v)
{
    read_count++;
    seq_printf(m, "Hello from /proc/%s!\\n", PROC_NAME);
    seq_printf(m, "읽기 횟수: %d\\n", read_count);

    if (proc_buffer_len > 0)
        seq_printf(m, "저장된 메시지: %s\\n", proc_buffer);
    else
        seq_printf(m, "저장된 메시지: (없음)\\n");

    return 0;
}

/* open 핸들러 — single_open으로 seq_file과 연결 */
static int hello_proc_open(struct inode *inode, struct file *file)
{
    return single_open(file, hello_proc_show, NULL);
}

/* write 핸들러 — 유저 공간에서 데이터를 받음 */
static ssize_t hello_proc_write(struct file *file,
                                 const char __user *user_buf,
                                 size_t count, loff_t *offset)
{
    size_t len = min(count, (size_t)(BUFFER_SIZE - 1));

    if (copy_from_user(proc_buffer, user_buf, len))
        return -EFAULT;

    proc_buffer[len] = '\\0';
    proc_buffer_len = len;

    if (len > 0 && proc_buffer[len - 1] == '\\n') {
        proc_buffer[len - 1] = '\\0';
        proc_buffer_len--;
    }

    pr_info("hello_proc: 메시지 수신 (%zu bytes): %s\\n", len, proc_buffer);
    return len;
}

/* Linux 5.6+ 에서는 proc_ops, 이전 버전에서는 file_operations */
#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 6, 0)
static const struct proc_ops hello_proc_ops = {
    .proc_open    = hello_proc_open,
    .proc_read    = seq_read,
    .proc_write   = hello_proc_write,
    .proc_lseek   = seq_lseek,
    .proc_release = single_release,
};
#else
static const struct file_operations hello_proc_ops = {
    .owner   = THIS_MODULE,
    .open    = hello_proc_open,
    .read    = seq_read,
    .write   = hello_proc_write,
    .llseek  = seq_lseek,
    .release = single_release,
};
#endif

static struct proc_dir_entry *proc_entry;

static int __init hello_init(void)
{
    proc_entry = proc_create(PROC_NAME, 0666, NULL, &hello_proc_ops);
    if (!proc_entry) {
        pr_err("Failed to create /proc/%s\\n", PROC_NAME);
        return -ENOMEM;
    }
    pr_info("hello_proc: /proc/%s 생성 완료\\n", PROC_NAME);
    return 0;
}

static void __exit hello_exit(void)
{
    proc_remove(proc_entry);
    pr_info("hello_proc: /proc/%s 제거 완료\\n", PROC_NAME);
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("procfs 인터페이스를 가진 커널 모듈");
MODULE_VERSION("1.4");
코드 설명
  • 4-6행 proc_fs.h는 procfs API를, seq_file.h는 순차 파일 인터페이스를 제공합니다. version.h는 커널 버전 감지에 사용됩니다.
  • 17-28행 seq_file의 show 콜백입니다. seq_printf는 내부적으로 버퍼 관리를 자동 수행하므로 sprintf보다 안전합니다. 데이터가 버퍼 크기를 초과하면 자동으로 버퍼를 확장합니다.
  • 31-34행 single_open은 단일 show 함수만 필요한 간단한 경우에 사용합니다. 복잡한 순차 데이터는 seq_openstart/next/stop/show 4개 콜백을 사용합니다.
  • 57-75행 LINUX_VERSION_CODE 매크로로 커널 버전을 감지합니다. 5.6 이상에서는 proc_ops를 사용하고, 이전 버전에서는 file_operations를 사용합니다. proc_ops.owner 필드가 없고, 함수 이름에 proc_ 접두사가 붙습니다.
  • 80행 proc_create의 세 번째 인자 NULL/proc 루트 아래에 직접 만듭니다. 서브디렉토리를 만들려면 proc_mkdir로 먼저 디렉토리를 생성합니다.

빌드 및 테스트

# 빌드 및 로드 (Makefile: obj-m += hello_proc.o)
make
sudo insmod hello_proc.ko

# 읽기
cat /proc/hello_proc

# 데이터 쓰기
echo "커널에게 보내는 메시지" | sudo tee /proc/hello_proc

# 다시 읽기 (저장된 메시지 확인)
cat /proc/hello_proc

# 정리
sudo rmmod hello_proc

예상 출력:

$ cat /proc/hello_proc
Hello from /proc/hello_proc!
읽기 횟수: 1
저장된 메시지: (없음)

$ echo "커널에게 보내는 메시지" | sudo tee /proc/hello_proc
$ cat /proc/hello_proc
Hello from /proc/hello_proc!
읽기 횟수: 2
저장된 메시지: 커널에게 보내는 메시지
procfs vs sysfs vs debugfs 선택 기준:
  • sysfs — 단일 값 (정수, 문자열 등), 디바이스 속성, 하드웨어 설정
  • procfs — 여러 줄의 통계/상태 정보, 디버깅용 데이터 덤프, 테이블 형식 출력
  • debugfs — 개발/디버깅 전용 인터페이스 (프로덕션에서는 비활성화 가능)

6단계: ioctl 구현

ioctl(Input/Output Control)은 read/write로 처리하기 어려운 디바이스 제어 명령을 구현하는 메커니즘입니다. 디바이스 상태 조회, 설정 변경, 하드웨어 리셋 같은 명령을 정의하며, 구조체를 통해 복잡한 데이터를 주고받을 수 있습니다.

ioctl 명령 번호 인코딩 (32비트) direction (2) bits 31-30 size (14) bits 29-16 type (8) bits 15-8 nr (8) bits 7-0 User Space int fd = open("/dev/hello") ioctl(fd, HELLO_GET, &val) ioctl(fd, HELLO_SET, &val) 시스템 콜 인터페이스 cmd 번호 디코딩 _IOC_DIR(cmd) _IOC_SIZE(cmd) _IOC_TYPE(cmd) _IOC_NR(cmd) 커널 모듈 unlocked_ioctl() switch (cmd) { case HELLO_GET_COUNT: copy_to_user() case HELLO_SET_MSG: copy_from_user() 반환값 (0 또는 -errno) _IOR → copy_to_user() | _IOW → copy_from_user() | _IOWR → 양방향

그림 8. ioctl 명령 인코딩과 처리 흐름

ioctl 매크로: 커널은 ioctl 명령 번호 충돌을 방지하기 위해 매직 넘버 기반 매크로를 제공합니다.
  • _IO(type, nr) — 데이터 전달 없는 명령
  • _IOW(type, nr, datatype) — 유저 → 커널 데이터 전달 (Write)
  • _IOR(type, nr, datatype) — 커널 → 유저 데이터 전달 (Read)
  • _IOWR(type, nr, datatype) — 양방향 데이터 전달

헤더 파일 (hello_ioctl.h)

#ifndef HELLO_IOCTL_H
#define HELLO_IOCTL_H

#include <linux/ioctl.h>

#define HELLO_MAGIC  'H'

struct hello_info {
    int  version_major;
    int  version_minor;
    int  current_count;
    char name[32];
};

#define HELLO_GET_COUNT   _IOR(HELLO_MAGIC, 1, int)
#define HELLO_SET_COUNT   _IOW(HELLO_MAGIC, 2, int)
#define HELLO_RESET       _IO(HELLO_MAGIC, 3)
#define HELLO_GET_INFO    _IOR(HELLO_MAGIC, 4, struct hello_info)

#endif

커널 모듈의 ioctl 핸들러 핵심

static long hello_ioctl(struct file *file,
                        unsigned int cmd, unsigned long arg)
{
    int tmp;
    struct hello_info info;

    switch (cmd) {
    case HELLO_GET_COUNT:
        if (copy_to_user((int __user *)arg, &access_count,
                         sizeof(access_count)))
            return -EFAULT;
        break;

    case HELLO_SET_COUNT:
        if (copy_from_user(&tmp, (int __user *)arg, sizeof(tmp)))
            return -EFAULT;
        if (tmp < 0)
            return -EINVAL;
        access_count = tmp;
        break;

    case HELLO_RESET:
        access_count = 0;
        break;

    case HELLO_GET_INFO:
        info.version_major = 1;
        info.version_minor = 5;
        info.current_count = access_count;
        strscpy(info.name, "hello_ioctl", sizeof(info.name));
        if (copy_to_user((struct hello_info __user *)arg,
                         &info, sizeof(info)))
            return -EFAULT;
        break;

    default:
        return -ENOTTY;  /* 지원하지 않는 ioctl */
    }
    return 0;
}

/* file_operations에 등록 */
static struct file_operations fops = {
    .owner          = THIS_MODULE,
    .open           = hello_open,
    .release        = hello_release,
    .unlocked_ioctl = hello_ioctl,  /* BKL 없는 ioctl (2.6.36+) */
};
코드 설명
  • HELLO_GET_COUNT _IOR로 정의되어 커널 → 유저 방향. copy_to_user로 커널 변수 값을 유저 공간에 복사합니다.
  • HELLO_SET_COUNT _IOW로 정의되어 유저 → 커널 방향. 입력값 검증(음수 거부)을 반드시 수행해야 합니다.
  • HELLO_RESET _IO로 정의되어 데이터 전달 없이 명령만 수행합니다.
  • HELLO_GET_INFO 구조체 전체를 유저 공간으로 전달. strscpystrncpy보다 안전한 문자열 복사 함수로, 항상 NULL 종료를 보장합니다.
  • default 정의되지 않은 명령에 -ENOTTY를 반환합니다. 이는 "디바이스에서 지원하지 않는 ioctl"이라는 표준 에러 코드입니다.
보안 주의: ioctl 핸들러에서는 반드시 copy_from_user/copy_to_user를 사용하세요. 유저 공간 포인터를 직접 역참조하면 커널 패닉이 발생할 수 있습니다.

7단계: 타이머와 워크큐 활용

커널 타이머는 지정된 시간 후 콜백을 실행하는 메커니즘이고, 워크큐는 프로세스 컨텍스트에서 지연 작업을 수행하는 메커니즘입니다. 타이머 콜백은 softirq 컨텍스트에서 실행되므로 슬립할 수 없지만, 워크큐는 프로세스 컨텍스트이므로 블로킹 작업이 가능합니다.

컨텍스트 구분:
  • 타이머 콜백 (softirq)GFP_ATOMIC만 사용, mutex 사용 불가, 최대한 짧게 실행
  • 워크큐 (process)GFP_KERNEL 사용 가능, mutex 사용 가능, 슬립 가능

핵심 코드 (hello_timer.c)

#include <linux/module.h>
#include <linux/timer.h>
#include <linux/jiffies.h>
#include <linux/workqueue.h>
#include <linux/atomic.h>

#define TIMER_INTERVAL_SEC  1
#define WORK_THRESHOLD      5

static struct timer_list hello_timer;
static atomic_t tick_count = ATOMIC_INIT(0);

/* 워크큐 — 프로세스 컨텍스트에서 실행 */
static void hello_work_handler(struct work_struct *work);
static DECLARE_WORK(hello_work, hello_work_handler);

static void hello_work_handler(struct work_struct *work)
{
    pr_info("hello_timer: [워크큐] 틱=%d (process context, pid=%d)\\n",
            atomic_read(&tick_count), current->pid);
    /* 프로세스 컨텍스트: kmalloc(GFP_KERNEL), mutex_lock 가능 */
}

/* 타이머 콜백 — softirq 컨텍스트 */
static void hello_timer_callback(struct timer_list *t)
{
    int ticks = atomic_inc_return(&tick_count);

    pr_info("hello_timer: [타이머] tick #%d (jiffies=%lu)\\n", ticks, jiffies);

    if (ticks % WORK_THRESHOLD == 0)
        schedule_work(&hello_work);  /* 5틱마다 워크큐 예약 */

    mod_timer(&hello_timer, jiffies + TIMER_INTERVAL_SEC * HZ);  /* 재등록 */
}

static int __init hello_init(void)
{
    timer_setup(&hello_timer, hello_timer_callback, 0);
    mod_timer(&hello_timer, jiffies + TIMER_INTERVAL_SEC * HZ);
    pr_info("hello_timer: 로드 (타이머 간격: %d초)\\n", TIMER_INTERVAL_SEC);
    return 0;
}

static void __exit hello_exit(void)
{
    del_timer_sync(&hello_timer);   /* 타이머 정지 (콜백 완료 대기) */
    cancel_work_sync(&hello_work);  /* 워크 완료 대기 */
    pr_info("hello_timer: 언로드 (총 %d틱)\\n", atomic_read(&tick_count));
}

module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
코드 설명
  • timer_setup Linux 4.15에서 도입된 타이머 초기화 API. 이전의 setup_timer/init_timer를 대체합니다.
  • atomic_inc_return softirq 컨텍스트에서 안전한 원자적 증가. 일반 int 대신 atomic_t를 사용합니다.
  • schedule_work 디폴트 워크큐에 작업을 예약합니다. 타이머 콜백에서 직접 할 수 없는 슬립 가능 작업을 위임합니다.
  • del_timer_sync / cancel_work_sync 모듈 언로드 시 반드시 호출해야 합니다. 실행 중인 콜백이 있다면 완료될 때까지 대기하여 use-after-free를 방지합니다.
타이머 콜백 제약사항:
  • mutex_lock(), kmalloc(GFP_KERNEL), msleep() 등 슬립 가능한 함수 호출 금지
  • 긴 작업은 schedule_work()로 워크큐에 넘기고, 타이머 콜백은 빠르게 반환
  • 고해상도가 필요하면 hrtimer 사용 (나노초 단위 제어 가능)

8단계: 동시성 보호

커널 모듈은 여러 프로세스나 인터럽트 핸들러가 동시에 접근할 수 있으므로 공유 데이터를 반드시 보호해야 합니다.

동시성 보호: mutex 사용 전후 비교 프로세스 A 프로세스 B /dev/hello 동시 접근 ✗ 락 없음 — 경쟁 조건 (Race Condition) 시간 → A: read buf[0..3] A: write buf[4..7] B: write buf[2..5] 버퍼 손상! 데이터 뒤섞임 ✓ mutex 사용 — 직렬화된 접근 시간 → lock A: read+write buf[0..7] unlock B: 대기 (mutex_lock 블록) lock B: read+write buf[0..7] unlock 데이터 무결성 보장 ✓

그림 9. mutex를 사용한 동시성 보호 전후 비교

동기화 기법 비교

기법슬립 가능인터럽트 컨텍스트주요 용도
mutex예 (대기 시 슬립)사용 불가프로세스 컨텍스트의 긴 크리티컬 섹션
spinlock아니오 (busy-wait)사용 가능인터럽트 핸들러, 짧은 크리티컬 섹션
semaphore사용 불가카운팅 세마포어 (동시 N개 접근)
RCU읽기: 예읽기: 사용 가능읽기 빈도 높고 쓰기 드문 데이터
atomic_t해당 없음사용 가능단일 정수의 원자적 조작

핵심 코드 (hello_safe.c)

/* === 동기화 객체 선언 === */
static DEFINE_MUTEX(buffer_mutex);       /* 버퍼 보호 */
static atomic_t open_count = ATOMIC_INIT(0);  /* open 카운터 */
static DEFINE_SPINLOCK(stats_lock);     /* 통계 보호 */
static unsigned long total_bytes_read = 0;

/* read — mutex로 버퍼 보호 */
static ssize_t hello_read(struct file *file, char __user *user_buf,
                            size_t count, loff_t *offset)
{
    ssize_t to_read;
    unsigned long flags;

    /* mutex 획득 (시그널로 중단 가능) */
    if (mutex_lock_interruptible(&buffer_mutex))
        return -ERESTARTSYS;

    if (*offset >= buffer_len) {
        mutex_unlock(&buffer_mutex);
        return 0;
    }

    to_read = min(count, (size_t)(buffer_len - *offset));

    if (copy_to_user(user_buf, device_buffer + *offset, to_read)) {
        mutex_unlock(&buffer_mutex);
        return -EFAULT;
    }

    *offset += to_read;
    mutex_unlock(&buffer_mutex);

    /* 통계 업데이트 — spinlock (인터럽트 안전) */
    spin_lock_irqsave(&stats_lock, flags);
    total_bytes_read += to_read;
    spin_unlock_irqrestore(&stats_lock, flags);

    return to_read;
}
코드 설명
  • DEFINE_MUTEX 정적 mutex 선언. 버퍼와 buffer_len에 대한 모든 접근을 보호합니다.
  • mutex_lock_interruptible 시그널에 의해 중단 가능한 락 획득. Ctrl+C 시 -ERESTARTSYS 반환. mutex_lock은 무조건 대기합니다.
  • spin_lock_irqsave 로컬 인터럽트를 비활성화하면서 spinlock 획득. flags에 이전 인터럽트 상태 저장 후 spin_unlock_irqrestore에서 복원합니다.
절대 하면 안 되는 것들:
  • spinlock 내에서 슬립spin_lock() 상태에서 kmalloc(GFP_KERNEL), copy_from_user() 등 호출 시 데드락. CONFIG_DEBUG_ATOMIC_SLEEP=y로 감지
  • 같은 mutex 재귀 획득 — 같은 스레드에서 mutex_lock() 두 번 호출 시 데드락
  • 락 순서 불일치 — A가 lock1→lock2, B가 lock2→lock1 순서면 데드락. 일관된 순서 유지
  • 에러 경로에서 unlock 누락goto 패턴으로 해제 경로 통합

9단계: 다중 소스 파일과 심볼 내보내기

실제 프로젝트에서는 하나의 모듈을 여러 소스 파일로 분리하거나, 여러 모듈 간에 함수를 공유합니다.

Module A (module_a.ko) hello_shared_func() hello_shared_data EXPORT_SYMBOL_GPL() ① insmod module_a.ko Module B (module_b.ko) extern hello_shared_func() 호출: hello_shared_func() MODULE_INFO(depends,...) ② insmod module_b.ko 커널 심볼 테이블 hello_shared_func → 0xffff... hello_shared_data → 0xffff... (GPL 전용) 등록 참조 Module.symvers (빌드 시 A가 생성) Module B 빌드 시 참조 KBUILD_EXTRA_SYMBOLS 빌드 타임 심볼 전달 로드 순서: insmod module_a.ko → insmod module_b.ko (의존성 순서 필수)

그림 10. 모듈 간 심볼 내보내기와 의존성

다중 파일로 하나의 모듈 빌드

# 하나의 모듈을 여러 .c 파일로 빌드
obj-m += mymod.o
mymod-objs := mymod_main.o mymod_ops.o

# 주의: mymod.c 파일이 있으면 안 됨 (이름 충돌)

EXPORT_SYMBOL_GPL 사용

/* module_a.c — 함수를 내보내는 모듈 */
int hello_shared_func(int increment)
{
    shared_counter += increment;
    pr_info("module_a: counter = %d\\n", shared_counter);
    return shared_counter;
}
EXPORT_SYMBOL_GPL(hello_shared_func);

/* module_b.c — 모듈 A의 함수를 사용하는 모듈 */
extern int hello_shared_func(int increment);

static int __init module_b_init(void)
{
    int val = hello_shared_func(10);
    pr_info("module_b: result = %d\\n", val);
    return 0;
}
# 로드 순서: A 먼저 (B가 A에 의존)
sudo insmod module_a.ko
sudo insmod module_b.ko

# 의존성 확인
lsmod | grep module
# module_b  16384  0
# module_a  16384  1 module_b

# 언로드 순서: B 먼저 (A를 먼저 제거하면 실패)
sudo rmmod module_b
sudo rmmod module_a
로드/언로드 순서가 핵심:
  • 로드: 의존 대상(A)을 먼저, 사용하는 쪽(B)을 나중에. 바뀌면 Unknown symbol 에러
  • 언로드: 사용하는 쪽(B)을 먼저, 의존 대상(A)을 나중에. modprobe 사용 시 자동 해결
EXPORT_SYMBOL vs EXPORT_SYMBOL_GPL:
  • EXPORT_SYMBOL(sym) — 모든 라이선스의 모듈에서 사용 가능
  • EXPORT_SYMBOL_GPL(sym) — GPL 라이선스 모듈만 사용 가능 (커널 커뮤니티 권장)

일반적인 에러와 해결 방법

컴파일 에러

에러 메시지 원인 해결 방법
fatal error: linux/module.h: No such file or directory 커널 헤더 미설치 sudo apt install linux-headers-$(uname -r)
Makefile:X: *** missing separator 탭 대신 스페이스 사용 들여쓰기를 탭 문자로 변경
ERROR: modpost: "symbol" undefined! 필요한 심볼을 export하지 않음 MODULE_LICENSE("GPL") 확인, 의존 모듈 확인
implicit declaration of function 'xxx' 헤더 파일 누락 함수가 정의된 헤더 파일 include

로드 에러

에러 메시지 원인 해결 방법
insmod: ERROR: could not insert module 다양한 원인 가능 dmesg로 정확한 에러 확인
Invalid module format 커널 버전 불일치 현재 커널에 맞는 헤더로 재빌드
Required key not available Secure Boot 활성화 모듈 서명 또는 Secure Boot 비활성화
Unknown symbol in module 의존 모듈 미로드 modprobe 사용 또는 의존 모듈 먼저 로드
Module already in use 모듈 사용 중 사용 중인 프로세스 종료 후 재시도

런타임 에러

증상 원인 해결 방법
커널 패닉 NULL 포인터 접근, 메모리 침범 VM에서 재현, CONFIG_DEBUG_KERNEL 활성화
메모리 누수 kmallockfree 누락 kmemleak 활성화, 코드 리뷰
데드락 잘못된 락 순서 CONFIG_PROVE_LOCKING 활성화
Oops 메시지 잘못된 메모리 접근 dmesg의 콜 스택 분석, addr2line 사용
중요: 커널 모듈 개발 중 시스템이 불안정해지면 즉시 재부팅하세요. 패닉 상태에서는 파일시스템이 손상될 수 있으므로, 실습은 항상 가상 머신이나 테스트 시스템에서 수행하세요.

디버깅 팁

printk 활용

커널 모듈 디버깅의 기본은 printk입니다. 로그 레벨을 적절히 사용하세요.

/* 로그 레벨별 사용 */
pr_emerg("시스템 사용 불가");   /* 거의 사용 안 함 */
pr_alert("즉각 조치 필요");
pr_crit("치명적 조건");
pr_err("오류 발생: %d", errno);     /* 에러 */
pr_warn("경고: 권장하지 않는 동작"); /* 경고 */
pr_notice("정상이지만 주목");
pr_info("정보성 메시지");           /* 일반 정보 */
pr_debug("디버그 정보");            /* 디버그용 */

/* pr_fmt 매크로로 접두사 자동 추가 */
#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt

/* 동적 디버그 (CONFIG_DYNAMIC_DEBUG 필요) */
pr_debug("이 메시지는 runtime에 켜고 끌 수 있음\\n");

/* 한 번만 출력 */
pr_info_once("첫 번째 호출 시에만 출력\\n");

/* 속도 제한 (rate limiting) */
printk_ratelimited(KERN_WARNING "반복 메시지 억제\\n");

커널 디버그 옵션

개발 중에는 다음 커널 설정 옵션들을 활성화하는 것이 좋습니다.

# .config 파일 또는 menuconfig에서 설정
CONFIG_DEBUG_KERNEL=y
CONFIG_DEBUG_INFO=y           # 디버그 심볼 포함
CONFIG_DEBUG_INFO_DWARF4=y    # DWARF4 포맷 사용
CONFIG_KALLSYMS=y             # 커널 심볼 테이블
CONFIG_KALLSYMS_ALL=y
CONFIG_DEBUG_SLAB=y           # 메모리 디버깅
CONFIG_DEBUG_KMEMLEAK=y       # 메모리 누수 감지
CONFIG_LOCKDEP=y              # 데드락 감지
CONFIG_PROVE_LOCKING=y
CONFIG_DEBUG_ATOMIC_SLEEP=y   # atomic 컨텍스트에서 sleep 감지
CONFIG_KASAN=y                # AddressSanitizer (메모리 오류 감지)

커널 크래시 분석

모듈이 커널 패닉을 일으킨 경우 다음 단계로 분석합니다.

# 1. dmesg에서 오류 메시지 확인
dmesg | tail -50

# 2. Oops 메시지의 Instruction Pointer(IP) 확인
# 예: RIP: 0010:[<ffffffffc0123456>] my_function+0x23/0x50 [my_module]

# 3. addr2line으로 소스 코드 위치 찾기
addr2line -e my_module.ko 0x123456

# 4. objdump로 어셈블리 확인
objdump -dS my_module.ko | less

# 5. gdb로 디버깅
gdb ./vmlinux
(gdb) list *(my_function+0x23)

KGDB 사용 (선택)

두 시스템 간 시리얼 연결 또는 가상 머신에서 KGDB를 사용할 수 있습니다.

# 타겟 시스템 (디버깅 대상) 커널 파라미터
kgdboc=ttyS0,115200 kgdbwait

# 호스트 시스템에서 gdb 실행
gdb ./vmlinux
(gdb) target remote /dev/ttyS0
(gdb) b my_function
(gdb) c
추천 도구:
  • trace-cmd / kernelshark — ftrace GUI 도구
  • perf — 성능 프로파일링
  • strace — 시스템 콜 추적 (유저 공간)
  • crash — kdump 덤프 분석
자세한 내용은 디버깅 문서ftrace 문서를 참고하세요.

모범 사례

코딩 스타일

# 코딩 스타일 검사
scripts/checkpatch.pl --no-tree -f my_module.c

에러 처리

커널 코드에서는 철저한 에러 처리가 필수입니다.

static int __init my_init(void)
{
    int ret;
    struct resource *res;

    res = kmalloc(sizeof(*res), GFP_KERNEL);
    if (!res) {
        pr_err("Failed to allocate memory\\n");
        return -ENOMEM;
    }

    ret = register_something(res);
    if (ret < 0) {
        pr_err("Failed to register: %d\\n", ret);
        goto err_free;
    }

    ret = init_hardware();
    if (ret < 0) {
        pr_err("Failed to init hardware: %d\\n", ret);
        goto err_unregister;
    }

    return 0;

err_unregister:
    unregister_something(res);
err_free:
    kfree(res);
    return ret;
}

메모리 관리

동기화

보안

/* 나쁜 예 */
copy_from_user(buf, user_buf, count);  /* count를 믿으면 안 됨! */

/* 좋은 예 */
if (count > MAX_SIZE)
    return -EINVAL;
if (copy_from_user(buf, user_buf, count))
    return -EFAULT;

다음 단계

이제 기본적인 커널 모듈 개발을 익혔으니, 다음 주제들을 학습하세요.

추천 학습 주제

실습 과제

다음 과제들을 직접 구현해보면서 실력을 향상시키세요.

  1. 간단한 procfs 인터페이스/proc/my_module에 읽기/쓰기 가능한 파일 생성
  2. 타이머 기반 모듈 — 1초마다 카운터를 증가시키고 로그를 남기는 모듈
  3. 링 버퍼 구현 — 고정 크기 순환 버퍼를 사용하는 캐릭터 디바이스
  4. ioctl 추가 — 기존 캐릭터 디바이스에 ioctl 명령 추가
  5. 다중 디바이스 지원 — 하나의 모듈이 여러 개의 디바이스 노드를 생성
다음 학습:

참고자료

공식 문서

추천 서적

온라인 리소스

도구 및 유틸리티

튜토리얼 이후 확장 실습 로드맵

첫 모듈 튜토리얼을 끝낸 뒤에는 "기능 추가"보다 "안전한 구조화"에 집중하는 것이 중요합니다. 아래 단계는 입문 코드를 실무형 모듈로 바꾸는 최소 경로입니다.

단계 목표 완료 기준
1단계 에러 경로 정리 모든 실패 분기에서 누수 없이 복귀
2단계 동시성 보호 sysfs/ioctl/read-write 경로에 락 정책 적용
3단계 디버깅 가능성 강화 로그 레벨 분리, 동적 디버그 지원
4단계 테스트 자동화 로드/언로드, 파라미터, 인터페이스 테스트 스크립트화

권장 셀프 테스트 루틴

# 1) 빌드
make -j$(nproc)

# 2) 로드/파라미터 테스트
sudo insmod hello_param.ko whom="kernel" repeat=3
dmesg | tail -n 50
sudo rmmod hello_param

# 3) 반복 안정성 테스트
for i in $(seq 1 50); do sudo insmod hello.ko; sudo rmmod hello; done

# 4) 정적 점검
./scripts/checkpatch.pl --file hello_param.c
학습 전략: 새 기능을 추가할 때마다 "정상 경로 1개 + 실패 경로 1개 + 정리 경로 1개"를 같이 검증하세요. 이 루틴이 잡히면 고급 드라이버 코드로 넘어갈 때 시행착오가 크게 줄어듭니다.
이전 단계:
다음 단계:
심화 학습: