Skip to content
Published on

커널 모듈 작성 — 가장 작은 모듈부터 파라미터와 GPL 심볼까지

공유하기
Authors

들어가며 — 처음으로 커널 안에서 실행되는 내 코드

4편까지는 읽기였습니다. 남이 쓴 코드를 따라가며 커널이 어떻게 생겼는지 봤습니다. 이 글부터는 씁니다.

모듈이 학습 수단으로 좋은 이유가 있습니다. 커널을 다시 빌드하지 않아도 되기 때문입니다. 3편에서 확인했듯 커널 전체 빌드는 시간이 걸리고, 고쳐서 확인하는 주기가 길면 학습이 느려집니다. 모듈은 몇 초 만에 빌드되고 즉시 로드됩니다. 고치고, 빌드하고, 올리고, dmesg를 보는 사이클이 30초입니다.

이 블로그의 eBPF 기초 글과의 관계를 분명히 해 두겠습니다. eBPF도 커널 안에서 코드를 돌리는 방법이고, 요즘은 오히려 더 자주 쓰입니다. 하지만 둘은 성격이 완전히 다릅니다. eBPF 프로그램은 검증기를 통과해야 하고, 그래서 할 수 있는 일이 제한되는 대신 안전합니다. 모듈은 검증기가 없습니다. 올라가는 순간 커널 코드와 동등한 권한을 갖고, 실수하면 시스템이 멈춥니다. 이 글의 후반부가 그 대가를 다룹니다.

이 글의 실습은 반드시 가상 머신 안에서 하시기 바랍니다. 3편 6절에서 만든 QEMU 환경이면 충분합니다.

1. 가장 작은 모듈

파일 하나면 됩니다.

// SPDX-License-Identifier: GPL-2.0
#include <linux/init.h>
#include <linux/module.h>

static int __init hello_init(void)
{
	pr_info("hello: 모듈이 올라왔습니다\n");
	return 0;
}

static void __exit hello_exit(void)
{
	pr_info("hello: 모듈이 내려갑니다\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("가장 작은 커널 모듈 예제");

한 줄씩 뜯어보겠습니다.

module_initmodule_exit. include/linux/module.h에 정의된 매크로입니다. 모듈이 로드될 때 부를 함수와 내려갈 때 부를 함수를 등록합니다. 이름은 자유이고, 매크로에 넘기기만 하면 됩니다.

초기화 함수의 반환값이 0이어야 합니다. 4편 7절에서 본 규약 그대로입니다. 0이면 성공, 음수면 에러입니다. 음수를 반환하면 모듈이 올라가지 않고 insmod가 그 값을 에러로 보여 줍니다.

__init__exit 표시. include/linux/init.h를 열어 보면 이 매크로들이 함수를 특별한 섹션에 배치한다는 것을 알 수 있습니다. __init.init.text, __exit.exit.text입니다. 초기화가 끝나면 __init 코드가 차지하던 메모리를 커널이 회수합니다. 한 번만 쓰는 코드를 계속 들고 있을 이유가 없기 때문입니다.

MODULE_LICENSE는 선택이 아닙니다. 빠뜨리면 빌드 단계에서 걸립니다. 커널의 모듈 후처리 도구인 scripts/mod/modpost.c가 이 경우 오류를 냅니다.

error: missing MODULE_LICENSE() in <모듈 이름>

MODULE_DESCRIPTION도 넣는 습관을 들이세요. 같은 도구가 이쪽은 경고로 처리합니다.

warning: missing MODULE_DESCRIPTION() in <모듈 이름>

pr_info. printk 계열 매크로입니다. 4절에서 자세히 다룹니다.

2. 트리 밖에서 빌드하기

모듈을 커널 소스 트리 안에 넣지 않고 별도 디렉터리에서 빌드하는 것이 표준적인 학습 방식입니다. 커널 공식 문서 Documentation/kbuild/modules.rst가 제시하는 Makefile 예제는 이렇습니다.

ifneq ($(KERNELRELEASE),)
# kbuild part of makefile
obj-m  := 8123.o
8123-y := 8123_if.o 8123_pci.o

else
# normal makefile
KDIR ?= /lib/modules/`uname -r`/build

default:
        $(MAKE) -C $(KDIR) M=$$PWD

endif

같은 문서가 KDIR에 대해 설명하기를, 커널 소스 디렉터리의 경로이거나 별도 빌드 디렉터리를 썼다면 그 출력 디렉터리의 경로라고 합니다. 표준 위치는 /lib/modules/ 아래의 실행 중인 커널 디렉터리에 있는 build 링크이며, 여기에 외부 모듈을 빌드하는 데 필요한 커널과 빌드 산출물이 들어 있습니다.

우리 예제에 맞춘 최소 형태는 이렇습니다. 파일 이름을 hello.c라고 하면 됩니다.

obj-m += hello.o

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

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

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

Makefile은 탭으로 들여써야 합니다. 공백을 쓰면 make가 거부합니다. 이 형태를 처음 복사해 쓸 때 가장 자주 나는 오류입니다.

빌드 명령의 구조를 이해해 두면 오류를 읽기 쉬워집니다. 문서가 제시하는 형태는 이렇습니다.

make -C /lib/modules/`uname -r`/build M=$PWD
make -C /lib/modules/`uname -r`/build M=$PWD modules_install

-C로 커널 빌드 디렉터리로 이동해 그쪽 Makefile을 실행하되, M= 변수로 "내 모듈은 여기 있다"고 알려 주는 방식입니다. 즉 빌드 규칙은 커널의 것을 쓰고 소스만 밖에서 가져오는 구조입니다.

빌드가 끝나면 여러 파일이 생기는데, 실제로 쓰는 것은 .ko 하나입니다. 나머지는 중간 산출물입니다.

빌드가 실패한다면 대개 커널 헤더가 없는 경우입니다. /lib/modules/ 아래의 실행 중인 커널 디렉터리에 build 링크가 살아 있는지 확인하세요. 배포판 커널을 쓴다면 헤더 패키지를 설치해야 합니다. Debian 계열은 linux-headers-$(uname -r), RHEL 계열은 kernel-devel입니다. 3편에서 직접 빌드한 커널이라면 그 소스 트리가 그대로 build 링크의 목적지입니다.

3. 올리고 내리고 확인하기

# 로드
sudo insmod ./hello.ko

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

# 모듈 정보 보기 — 라이선스, 설명, 파라미터가 나옵니다
modinfo ./hello.ko

# 로그 확인
sudo dmesg | tail -5

# 내리기
sudo rmmod hello

네 명령의 차이를 정리해 두겠습니다.

명령하는 일
insmod지정한 파일 하나를 그대로 로드합니다. 의존성을 해결하지 않습니다
modprobe모듈 이름으로 찾아서 의존하는 모듈까지 함께 로드합니다
rmmod모듈 하나를 내립니다
modinfo로드하지 않고 모듈 파일의 메타데이터만 읽습니다
lsmod지금 로드된 모듈 목록입니다. /proc/modules를 읽어 보여 줍니다

실습 단계에서는 insmodrmmod면 충분합니다. modprobe는 모듈이 시스템에 설치된 뒤에 의미가 있습니다.

rmmod가 거부하는 경우가 있습니다. 참조 횟수가 0이 아니면 내려가지 않습니다. lsmod 출력의 세 번째 열이 그 숫자입니다. 누가 이 모듈을 쓰고 있는지도 그 옆에 나옵니다. 초기화 함수에서 등록한 것을 종료 함수에서 해제하지 않으면 이 숫자가 남아 영원히 내려가지 않는 모듈이 되고, 그러면 재부팅 외에 방법이 없습니다.

모듈을 내리는 기능 자체가 없을 수도 있습니다. CONFIG_MODULE_UNLOAD가 꺼진 커널에서는 rmmod 자체가 동작하지 않습니다. 배포판 커널은 보통 켜 두지만, 3편에서 직접 만든 설정이라면 확인해 볼 만합니다.

4. printk와 로그 레벨

커널에는 printf가 없습니다. 대신 printk가 있고, 실제로는 그 위에 얹힌 pr_ 계열 매크로를 씁니다.

커널 문서 Documentation/core-api/printk-basics.rst가 정리한 대응 관계입니다.

로그 레벨 매크로문자열대응 매크로
KERN_EMERG"0"pr_emerg()
KERN_ALERT"1"pr_alert()
KERN_CRIT"2"pr_crit()
KERN_ERR"3"pr_err()
KERN_WARNING"4"pr_warn()
KERN_NOTICE"5"pr_notice()
KERN_INFO"6"pr_info()
KERN_DEBUG"7"pr_debug(), pr_devel()
KERN_CONT"c"pr_cont()

실무적으로 알아야 할 것은 셋입니다.

레벨이 콘솔 출력 여부를 결정합니다. 커널은 현재 콘솔 로그 레벨보다 급한 메시지만 콘솔에 직접 찍습니다. 나머지는 링 버퍼에만 들어가고 dmesg로 봐야 합니다. 그래서 "분명히 pr_debug를 넣었는데 화면에 안 나온다"는 상황이 생깁니다.

# 현재 설정 확인 — 네 숫자가 나옵니다
cat /proc/sys/kernel/printk

# 콘솔로 모든 레벨을 내보내기
sudo dmesg -n 8

pr_debug는 기본적으로 아무것도 출력하지 않습니다. 동적 디버그 기능이 꺼져 있으면 컴파일 시점에 사라집니다. 실습 중이라면 그냥 pr_info를 쓰는 편이 혼란이 적습니다.

pr_fmt으로 접두사를 붙이세요. 같은 문서가 설명하듯, 이 매크로는 한 파일의 모든 메시지 앞에 공통 문자열을 붙이는 데 씁니다. 헤더를 포함하기 전에 정의해야 합니다.

#define pr_fmt(fmt) "hello: " fmt

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

이렇게 해 두면 본문에서는 접두사를 신경 쓰지 않아도 됩니다. dmesg에서 자기 모듈의 로그만 골라내기가 훨씬 쉬워집니다.

로그를 실시간으로 보려면 이렇게 합니다.

sudo dmesg -wH

-w가 새 메시지를 계속 따라가고, -H가 사람이 읽기 좋은 형태로 시간을 표시합니다. 모듈 실습 중에는 이 창을 하나 띄워 두는 것이 편합니다.

5. 모듈 파라미터

로드할 때 값을 넘길 수 있습니다.

#include <linux/moduleparam.h>

static int count = 1;
module_param(count, int, 0644);
MODULE_PARM_DESC(count, "출력할 인사 횟수");

static char *name = "world";
module_param(name, charp, 0644);
MODULE_PARM_DESC(name, "인사할 대상 이름");

module_param의 세 번째 인자는 sysfs에서의 권한입니다. include/linux/moduleparam.h의 주석이 설명하듯 0이면 sysfs 항목이 아예 생기지 않고, 0444면 누구나 읽을 수 있고, 0644면 root가 쓸 수 있습니다.

같은 헤더가 나열하는 표준 타입은 이렇습니다.

byte, hexint, short, ushort, int, uint, long, ulong
charp: a character pointer
bool: a bool, values 0/1, y/n, Y/N.
invbool: the above, only sense-reversed (N = true).

쓰는 법은 간단합니다.

sudo insmod ./hello.ko count=3 name=kernel

# 권한을 0644로 줬다면 로드 후에도 읽고 쓸 수 있습니다
cat /sys/module/hello/parameters/count
echo 5 | sudo tee /sys/module/hello/parameters/count

쓰기 가능한 파라미터에는 함정이 하나 있습니다. 같은 헤더의 주석이 경고하듯, 쓰기 가능하게 만들었다면 접근 시 잠금이 필요할 수 있습니다. 특히 문자열 포인터 타입은 값이 바뀔 때 이전 문자열이 해제되기 때문에, 모듈 코드가 그 포인터를 들고 있다가 참조하면 이미 해제된 메모리를 읽게 됩니다. 학습 단계에서는 파라미터를 읽기 전용으로 두는 편이 안전합니다.

6. 커널 C의 규칙 — 짧은 예고

여기서부터가 이 글의 경고 부분입니다. 자세한 것은 7편이 다루지만, 모듈을 쓰기 시작하는 순간 부딪히는 것들을 먼저 적어 두겠습니다.

표준 C 라이브러리가 없습니다

커널 문서 Documentation/process/howto.rst가 이 점을 분명히 합니다. 커널은 프리스탠딩 C 환경이고 표준 C 라이브러리에 의존하지 않으므로 C 표준의 일부는 지원되지 않으며, 임의의 long long 나눗셈과 부동소수점은 허용되지 않는다고 명시합니다.

구체적으로는 이렇게 바뀝니다.

사용자 공간에서 쓰던 것커널에서 쓰는 것
printfpr_info 계열
malloc / freekmalloc / kfree
memcpy / strlen같은 이름의 커널 자체 구현
assertBUG_ON / WARN_ON
double, float쓸 수 없습니다

부동소수점을 쓸 수 없다는 것이 처음에는 이상하게 들립니다. 이유는 성능입니다. 커널이 부동소수점 레지스터를 쓰려면 문맥 전환마다 그 레지스터들을 저장하고 복원해야 하고, 그 비용을 모든 시스템 콜이 나눠 지게 됩니다. 그래서 아예 쓰지 않기로 한 것입니다. 나눗셈이 필요하면 정수 연산으로 바꾸거나, 커널이 제공하는 전용 헬퍼를 씁니다.

스택이 작습니다

x86_64에서 커널 스택 크기는 arch/x86/include/asm/page_64_types.h에 정의돼 있고, 기본 설정에서 페이지 크기의 네 배입니다. 4KB 페이지 기준으로 16KB입니다. 사용자 공간 스레드가 보통 8MB를 갖는 것과 비교하면 500분의 1입니다.

그래서 커널 코드에서는 큰 지역 배열을 잡으면 안 됩니다. 커널 문서 Documentation/process/submit-checklist.rstmake checkstack으로 스택을 많이 쓰는 함수를 찾아보라고 요구하며, 512바이트를 넘는 함수를 문제로 봅니다.

실패의 대가가 다릅니다

사용자 공간에서 널 포인터를 건드리면 그 프로세스만 죽습니다. 커널에서는 커널 oops가 나고, 운이 나쁘면 시스템 전체가 멈춥니다. 데이터가 디스크에 쓰이기 전이었다면 그대로 사라집니다.

이것이 이 글에서 실습을 가상 머신 안에서 하라고 반복해 말하는 이유입니다.

7. GPL 심볼과 오염 플래그

이 부분은 라이선스 이야기처럼 보이지만 실제로는 쓸 수 있는 API의 범위에 대한 이야기입니다.

커널은 모듈이 링크할 수 있는 심볼을 명시적으로 공개합니다. include/linux/export.h에 두 가지 형태가 있습니다.

#define EXPORT_SYMBOL(sym)		_EXPORT_SYMBOL(sym, "")
#define EXPORT_SYMBOL_GPL(sym)		_EXPORT_SYMBOL(sym, "GPL")

차이는 MODULE_LICENSE에 무엇을 적었느냐에 따라 갈립니다. include/linux/module.h의 주석이 그 효과를 직접 설명합니다. 라이선스 문자열의 유일한 목적은 독점 표시가 동작하게 만들고, 비자유 모듈이 로드됐을 때 EXPORT_SYMBOL_GPL로 익스포트된 심볼과의 결합을 거부하는 것이라고 되어 있습니다.

MODULE_LICENSE("Proprietary")로 표시한 모듈은 GPL 전용 심볼을 쓸 수 없습니다. 그리고 커널 내부 API의 상당수가 GPL 전용입니다.

같은 주석이 문자열의 의미도 정리해 줍니다. "GPL v2""GPL" 둘 다 GPL v2 라이선스임을 뜻할 뿐이며, only인지 or later인지를 구분하려던 역사적 시도가 실패한 결과라 지금은 구분이 무의미하다고 적혀 있습니다. "Dual MIT/GPL"이나 "Dual MPL/GPL" 같은 이중 라이선스 표기도 있습니다.

오염 플래그

모듈을 로드하면 커널에 흔적이 남습니다. Documentation/admin-guide/tainted-kernels.rst의 표에서 모듈과 관련된 항목만 뽑으면 이렇습니다.

비트문자
0P독점 라이선스 모듈이 로드됨
1F모듈이 강제 로드됨
10Cstaging 드라이버가 로드됨
12O트리 밖에서 빌드된 모듈이 로드됨
13E서명되지 않은 모듈이 로드됨

확인은 이렇게 합니다.

cat /proc/sys/kernel/tainted

우리가 만든 모듈을 올리면 이 값이 0이 아니게 됩니다. 최소한 트리 밖 모듈 비트가 켜집니다. 이건 잘못된 것이 아니라 정상입니다. 다만 이 상태에서 발생한 커널 버그를 메일링 리스트에 보고하면, 개발자들이 먼저 하는 질문이 "그 모듈 빼고도 재현됩니까"가 됩니다. 오염된 커널의 버그 리포트는 신뢰도가 낮게 취급되기 때문입니다.

서명

3편 7절에서 다룬 CONFIG_MODULE_SIG_FORCE가 켜져 있으면 서명되지 않은 모듈은 아예 로드되지 않습니다. insmod가 거부하면 이 설정을 먼저 확인해 보세요. 실습용 커널에서는 꺼 두는 편이 편합니다.

8. 다음 단계 — 트리 안의 예제 읽기

hello.ko가 올라갔다면 다음은 무엇을 할 것인가. 좋은 답이 커널 트리 안에 있습니다. samples/ 디렉터리입니다.

특히 samples/kobject/kobject-example.c를 권합니다. 이 예제는 sysfs에 디렉터리를 하나 만들고 그 안에 파일 셋을 만들어, 정수를 쓰면 저장하고 읽으면 돌려주는 모듈입니다. 주석이 그렇게 설명합니다.

이 예제가 좋은 이유는 커널이 사용자 공간에 인터페이스를 노출하는 표준 방식을 그대로 보여 주기 때문입니다. 우리가 /sys 아래에서 보는 파일들이 전부 이런 코드로 만들어집니다. 5절에서 다룬 모듈 파라미터도 결국 이 메커니즘 위에 얹혀 있습니다.

samples/ 아래에는 kprobes, ftrace, trace_events, seccomp, livepatch 예제도 있습니다. 6편에서 kprobes를 다룰 때 그쪽 예제를 다시 보게 됩니다.

흔한 함정

Makefile을 공백으로 들여썼습니다. make가 거부합니다. 탭이어야 합니다.

커널 헤더가 없어 빌드가 안 됩니다. 2절 마지막의 헤더 패키지를 확인하세요.

빌드한 모듈이 로드를 거부합니다. 커널 버전이 다른 경우가 가장 흔합니다. 모듈은 빌드한 커널 버전에서만 로드됩니다. modinfo로 모듈이 기대하는 버전을 확인하고 uname -r과 비교하세요.

MODULE_LICENSE를 빼먹었습니다. 빌드 자체가 오류로 멈춥니다.

초기화 함수에서 실패했는데 0을 반환합니다. 모듈은 올라간 것으로 처리되지만 실제로는 아무것도 준비되지 않은 상태가 됩니다. 실패하면 반드시 음수 errno를 반환하고, 그때까지 잡아 둔 자원을 역순으로 해제해야 합니다.

종료 함수에서 해제를 빠뜨렸습니다. 등록한 것을 해제하지 않으면 참조 횟수가 남아 모듈이 내려가지 않거나, 더 나쁘게는 내려간 뒤 커널이 사라진 코드를 호출해 시스템이 멈춥니다. 초기화에서 A, B, C를 준비했다면 종료에서는 C, B, A 순으로 정리하는 습관을 들이세요.

호스트 머신에서 실습합니다. 6절에서 말한 대로입니다. 널 포인터 하나로 작업 중이던 것이 전부 날아갈 수 있습니다.

마치며

모듈은 커널 학습에서 가장 만족스러운 단계입니다. 처음으로 자기가 쓴 코드가 커널 로그에 나타나는 순간이 있고, 그 순간부터 커널이 남의 물건이 아니게 됩니다.

동시에 이 단계는 처음으로 대가가 실재하는 단계이기도 합니다. 앞의 네 편에서는 최악의 경우가 "컴파일 실패"였지만, 여기서부터는 "시스템 정지"입니다. 그래서 이 글이 가상 머신 이야기를 여러 번 반복했습니다.

정리하면 이렇습니다. 모듈에는 초기화 함수와 종료 함수가 있고, 반환값 규약은 커널 전체와 같으며, 라이선스 표시는 필수이고 그 값이 쓸 수 있는 API의 범위를 결정합니다. 로그는 pr_ 계열로 남기고 dmesg로 봅니다. 초기화에서 잡은 것은 종료에서 역순으로 놓습니다.

다음 편은 관측과 디버깅입니다. 지금까지는 로그를 찍어 확인했지만, 커널에는 훨씬 강력한 도구들이 있습니다. 그리고 그 도구들을 다 소개한 다음에도 결국 printk가 가장 많이 쓰인다는 이야기도 함께 하겠습니다.

이 글의 매크로 정의와 문서 인용은 2026년 8월 19일에 메인라인 트리와 공식 문서에서 직접 확인했습니다.

직접 해보기

이전 편과 다음 편

참고 자료

모든 링크는 2026년 8월 19일에 확인했습니다.