Linux 커널 심층 학습 자료: 이원 구조에서 네트워킹까지¶
목차¶
- User Space vs Kernel Space: 이원 구조의 이해
- 커널 모듈(LKM)의 생애주기
- 문자 디바이스 드라이버와 VFS
- 네트워크 패킷 가로채기: Netfilter와 sk_buff
챕터 1: User Space vs Kernel Space - 이원 구조의 이해¶
1.1 CPU 권한 레벨(Ring) 아키텍처¶
Linux 커널의 가장 기초적인 보안 메커니즘은 CPU 보호 링(Protection Ring) 아키텍처에 기반합니다. x86-64 프로세서는 4개의 권한 레벨(Ring 0~3)을 제공하지만, Linux는 오직 2개만 사용합니다:
- Ring 0 (Kernel Mode): 커널 코드가 실행되는 최고 권한 영역. 모든 CPU 명령어, 메모리 접근, 하드웨어 제어 가능
- Ring 3 (User Mode): 사용자 애플리케이션이 실행되는 제한된 권역. 보호된 메모리 영역 접근 불가, 특정 CPU 명령어 실행 불가
이러한 분리는 하드웨어 수준에서 강제되므로, 사용자 프로세스가 임의로 Ring 0으로 전환할 수 없습니다. CPU는 세그먼트 레지스터(CS, DS 등)의 권한 비트를 확인하여 현재 실행 모드를 결정합니다. 사용자 모드 코드에서 권한 명령어(예: lgdt, lidt, 페이지 테이블 수정)를 실행하려 하면, CPU는 즉시 **일반 보호 예외(General Protection Fault)**를 발생시킵니다.
1.2 메모리 주소 공간 분리¶
각 프로세스는 자신의 **가상 메모리 주소 공간**을 갖습니다(x86-64에서는 64비트 주소 공간). 이 공간은 논리적으로 두 부분으로 나뉩니다:
┌─────────────────────────────────────────┐
│ 0xFFFFFFFF FFFFFFFF (2^64 - 1) │
├─────────────────────────────────────────┤
│ │
│ 커널 공간 (약 128TB) │
│ - 커널 코드, 페이지 테이블, 스택 │
│ - 모든 프로세스가 공유 │
├─────────────────────────────────────────┤
│ │
│ 사용자 공간 (약 128TB) │
│ - 애플리케이션 코드, 데이터, 스택 │
│ - 프로세스별로 격리 │
│ │
├─────────────────────────────────────────┤
│ 0x0000000000000000 │
└─────────────────────────────────────────┘
MMU(Memory Management Unit)**는 가상 주소를 물리 주소로 변환할 때 **권한 비트(Permission Bits)**를 확인합니다. 커널 메모리 페이지는 USER 비트가 0으로 설정되어 있어, Ring 3에서 접근 시도 시 **페이지 폴트(Page Fault) 예외를 발생시킵니다. 이 폴트는 커널의 페이지 폴트 핸들러로 진입하게 하며, 일반적으로 해당 프로세스를 종료합니다.
1.3 System Call 메커니즘: User ↔ Kernel 전환¶
사용자 프로세스가 하드웨어나 커널 자원에 접근해야 할 때, **시스템 콜(System Call)**을 통해 안전하게 커널 모드로 전환합니다.
x86-64 System Call 흐름:
1. 사용자 애플리케이션에서:
- 시스템 콜 번호를 rax 레지스터에 저장
- 인자들을 rdi, rsi, rdx, r10, r8, r9 레지스터에 저장
- syscall 명령어 실행
2. CPU 하드웨어 (syscall 명령어):
- CPU 모드를 Ring 3 → Ring 0으로 전환
- 현재 RIP를 RCX에 저장 (복귀 주소)
- 현재 RFLAGS를 R11에 저장 (상태 플래그)
- IA32_LSTAR MSR(Model-Specific Register)에 저장된
커널 엔트리 포인트로 점프
- 페이지 테이블을 커널 페이지 테이블로 전환
3. 커널 진입점 (entry_64.S):
- 커널 스택으로 전환
- 사용자 컨텍스트를 커널 스택에 저장
- 적절한 syscall 핸들러 호출
4. 시스템 콜 처리:
- 커널 함수(예: sys_open, sys_read)에서 실제 작업 수행
- 반환값을 rax 레지스터에 저장
5. CPU 복귀 (sysret 명령어):
- CPU 모드를 Ring 0 → Ring 3으로 전환
- RCX에 저장된 주소로 점프 (사용자 코드로 복귀)
- R11의 플래그 값 복원
핵심 특징:
- syscall은 순수 레지스터 연산: 메모리 접근이 없어 x86의 sysenter보다 빠름
- 자동 권한 전환: CPU가 하드웨어 수준에서 보장하므로, 중간에 특정 메모리 페이지에만 접근 가능한 상태가 될 수 없음
- 원자적(Atomic) 전환: 전환 중 인터럽트 불가능, 일관성 보장
1.4 Context Switching의 하드웨어 비용¶
프로세스 컨텍스트 스위칭은 OS의 가장 비싼 연산 중 하나입니다. 다음 작업들이 연달아 발생합니다:
1. CPU 레지스터 상태 저장 및 복원
// arch/x86/entry/entry_64.S의 개념화된 코드
// 프로세스 A → 프로세스 B로 전환
// Step 1: 현재 프로세스 A의 모든 레지스터를 kernel stack에 저장
SAVE_REGS(rax, rbx, rcx, rdx, rsi, rdi, r8-r15) // 120+ bytes
SAVE_EXTENDED_REGS(xmm0-xmm15) // SSE/AVX 상태
// Step 2: task_struct에서 프로세스 B의 레지스터 복원
LOAD_REGS(rax, rbx, rcx, rdx, rsi, rdi, r8-r15)
LOAD_EXTENDED_REGS(xmm0-xmm15)
// Step 3: 다음 명령어로 점프
jmp [rcx] // RIP 복원
저장/복원되는 항목: - 범용 레지스터 16개: rax, rbx, rcx, rdx, rsi, rdi, r8-r15 (각 8바이트) - FPU/SSE 상태: xmm0-xmm15 (16개 × 16바이트 = 256바이트) - 제어 레지스터: CR0, CR3 (페이지 테이블 기본 주소), CR4 - 세그먼트 레지스터: CS, DS, ES, SS, FS, GS - RFLAGS: CPU 플래그 레지스터
2. 페이지 테이블 전환 (주요 병목)
// 새 프로세스의 페이지 테이블 로드
// mm_struct는 각 프로세스의 메모리 관리 정보를 포함
struct mm_struct *new_mm = next_task->mm;
unsigned long pgd_phys = __pa(new_mm->pgd);
// CR3 레지스터에 새 PGD(Page Global Directory) 물리 주소 로드
// 이 순간 TLB(Translation Lookaside Buffer) 플러시 발생
load_cr3(pgd_phys);
// 이 명령 직후 모든 메모리 접근은 새 주소 공간을 참조
페이지 테이블 전환의 비용: - CR3 로드 직후 TLB 전체 플러시 (ASID 없는 경우): 수 μs 소비 - 새 프로세스의 첫 메모리 접근 시 TLB 미스: 수십 ns → 수백 ns로 증가 - L1/L2/L3 캐시 오염: 이전 프로세스의 캐시 데이터가 쓸모없어짐
3. 커널 스택 전환
// 각 프로세스는 커널 모드에서만 사용하는 별도의 커널 스택을 가짐
// thread_info 구조체는 커널 스택의 맨 위에 배치됨
struct thread_info {
unsigned long flags;
__u32 status;
mm_segment_t addr_limit;
struct task_struct *task;
// ... 다른 필드들
};
// 커널은 스택을 전환하기 위해 RSP(스택 포인터) 변경
// 이전: RSP → [프로세스 A의 커널 스택]
// 이후: RSP → [프로세스 B의 커널 스택]
컨텍스트 스위칭의 실제 비용 측정:
Linux의 최신 시스템(Skylake 이상, 마이크로초 단위): - 최소 비용: ~2-3 μs (메모리 캐시에 있을 때) - 전형적인 경우: ~5-10 μs - 최악의 경우: ~20-50 μs (캐시 미스, TLB 재구성)
이는 산술 연산 1000만 회가 약 1 μs이므로, **컨텍스트 스위칭 1회가 산술 연산 1억 회와 동등**합니다.
1.5 System Call 실행 흐름: open() 예제¶
사용자 코드에서 open("/etc/passwd", O_RDONLY) 호출 시의 상세 동작:
사용자 공간 (User Space):
glibc의 open() 함수는 내부적으로 다음을 수행:
// glibc 내부 (sysdeps/unix/syscall-template.S)
// 인자 설정
mov $<syscall_number_open>, %eax // rax = 2 (x86-64 syscall #2는 open)
mov $filename, %rdi // rdi = "/etc/passwd"
mov $flags, %rsi // rsi = O_RDONLY
mov $mode, %rdx // rdx = 0 (flags가 O_RDONLY이므로 무시)
// 시스템 콜 진입
syscall // CPU가 kernel mode로 전환
커널 공간 (Kernel Space):
// arch/x86/entry/entry_64.S의 syscall entry point
ENTRY(entry_SYSCALL_64)
// 1. 레지스터 저장 (push를 사용하지 않고 메모리에 직접 저장)
mov %rsp, %rax // 임시로 사용자 RSP 저장
mov %gs:cpu_current_stack, %rsp // 커널 스택으로 전환
// 2. pt_regs 구조체에 사용자 컨텍스트 저장
SAVE_REGS
// 3. 시스템 콜 번호로 핸들러 찾기
cmp $nr_syscalls, %eax // 유효성 검사
jae 1f
// 4. 시스템 콜 테이블(sys_call_table)에서 함수 포인터 조회
lea sys_call_table(%rip), %r10
mov (%r10, %rax, 8), %r10 // r10 = sys_open 주소
// 5. 시스템 콜 함수 호출
call *%r10 // sys_open() 실행
// 6. 반환값을 RAX에 저장 (이미 이 상태)
// 반환값은 open()의 반환 값 (file descriptor 또는 -errno)
sys_open() 커널 함수 (fs/open.c):
SYSCALL_DEFINE3(open, const char __user *filename, int flags, umode_t mode)
{
// 1. 사용자 공간 포인터 유효성 확인
if (unlikely(!filename)) {
return -EFAULT; // 잘못된 주소
}
// 2. 사용자 공간에서 커널 공간으로 경로 복사
char *buf = kmalloc(PATH_MAX, GFP_KERNEL);
// copy_from_user는 매우 중요한 함수
// 보안 검증, 페이지 폴트 핸들링, MMU 권한 확인 수행
if (copy_from_user(buf, filename, strlen_user(filename))) {
return -EFAULT; // 사용자 공간 메모리 접근 실패
}
// 3. 파일 시스템 VFS를 통해 inode 조회
struct inode *inode = vfs_open_inode(buf);
// 4. 파일 객체(struct file) 생성
struct file *filp = alloc_file();
filp->f_inode = inode;
filp->f_pos = 0;
// 5. 프로세스의 파일 디스크립터 테이블에 등록
int fd = get_unused_fd_flags(flags);
fd_install(fd, filp);
// 6. 반환 (RAX = fd)
return fd;
}
copy_from_user() 함수의 구현:
이 함수는 매우 흥미로운 아키텍처 특성을 보여줍니다:
// include/linux/uaccess.h
unsigned long copy_from_user(void *to, const void __user *from, unsigned long n)
{
// 1. 사용자 공간 주소 범위 확인
// access_ok는 현재 프로세스의 mm_struct을 확인하여
// from이 정말 사용자 주소 범위에 있는지 검증
if (!access_ok(from, n)) {
return n; // 모두 실패
}
// 2. 아키텍처 특화 복사 루틴 호출
// x86-64의 경우: __arch_copy_from_user
return __arch_copy_from_user(to, from, n);
}
// arch/x86/lib/copy_user_64.S
// 매우 저수준 어셈블리로 구현됨
// 이유: 페이지 폴트 시 특수한 처리 필요
__arch_copy_from_user:
// rep movsb 또는 rep movsq로 바이트/워드 복사
// 이 명령어 실행 중 페이지 폴트 발생 시:
// 1. CPU가 예외를 발생시킴
// 2. 커널의 page fault handler가 호출됨
// 3. Handler는 필요한 페이지를 메모리에 로드
// 4. 복사 명령어 재개
// 5. 성공하면 0 반환, 실패하면 남은 바이트 수 반환
rep movsb
ret
복귀 (Return to User Space):
// arch/x86/entry/entry_64.S
ENTRY(entry_SYSCALL_64_after_hwframe)
// 1. 반환값은 이미 RAX에 있음 (fd 번호)
// 2. 사용자 컨텍스트 복원
RESTORE_REGS
// 3. 사용자 모드로 전환하고 사용자 코드로 점프
sysret // CPU: Ring 0 → Ring 3, RIP = RCX
사용자 공간 복귀:
// 이제 사용자 프로세스의 다음 명령어 실행
// glibc의 syscall 래퍼는 RAX의 값을 처리
// RAX = fd (양수) 또는 -errno (음수)
int fd = open("/etc/passwd", O_RDONLY);
// fd = 3 (일반적으로 0=stdin, 1=stdout, 2=stderr 다음)
1.6 Docker 컨테이너와 Kernel Space의 관계¶
Docker 컨테이너가 "경량 가상 머신"처럼 보이지만, 실제로는 모두 동일한 커널을 공유**합니다. 컨테이너 격리는 커널의 **namespace와 cgroup 기능으로 구현됩니다.
Namespace: 시스템 리소스의 논리적 분리
| Namespace | 격리 대상 | 설명 |
|---|---|---|
| PID Namespace | 프로세스 ID | 컨테이너 내부에서 PID 1부터 시작 |
| Network Namespace | 네트워크 스택 | 독립적인 IP, 라우팅, 방화벽 규칙 |
| Mount Namespace | 파일시스템 마운트 | 컨테이너만의 루트 파일시스템 (/dev/myapp) |
| IPC Namespace | 프로세스 간 통신 | 공유 메모리, 세마포어, 메시지 큐 격리 |
| User Namespace | UID/GID 매핑 | 컨테이너 내부의 UID 0이 호스트의 일반 사용자 |
| UTS Namespace | 호스트명, 도메인명 | 컨테이너의 독립적인 호스트명 |
실제 메커니즘:
호스트 OS:
┌─────────────────────────────────────────┐
│ Linux Kernel (Ring 0) │
│ - 단 1개의 커널 인스턴스 │
│ - 모든 컨테이너가 공유 │
└─────────────────────────────────────────┘
↓ Namespace로 분리
┌──────┬──────┬──────┐
│ │ │ │
┌─┴──┐┌─┴──┐┌─┴──┐
│동일 ││동일 ││동일 │ → sys_open()을 호출할 때
│sys_ ││sys_ ││sys__│ 어느 컨테이너의 파일인지
│open ││read ││write│ namespace에서 판단
│() ││() ││() │
└────┘└────┘└────┘
Container A Container B Container C
(PID 1 ~ 100) (PID 101 ~ 200) (PID 201 ~ 300)
Namespace 격리
호스트 관점: PID 5432 │ 호스트 관점: PID 6789 │ 호스트 관점: PID 7890
컨테이너 관점: PID 1 │ 컨테이너 관점: PID 1 │ 컨테이너 관점: PID 1
Cgroup: 자원 제한
// cgroup에서 컨테이너 A의 메모리를 512MB로 제한
// 이는 kernel 수준의 메모리 할당자(kmalloc, page allocator)에서 체크됨
// 시스템 콜 내에서 메모리 할당 시도
void *buf = kmalloc(1024 * 1024, GFP_KERNEL);
// 커널: "이 프로세스가 속한 cgroup의 메모리 한계는?"
// → Container A: 512MB 한계
// → 현재 사용: 510MB
// → 요청: 1MB
// → 결과: 할당 실패 (-ENOMEM)
// 컨테이너 내부 애플리케이션 관점:
// "malloc()이 NULL을 반환했다" = 메모리 부족
Container vs Kernel Space 명확히:
- Container: Namespace + Cgroup을 통한 사용자 공간의 격리
- Kernel Space: **모든 컨테이너가 공유**하는 단일 커널
/proc/kallsyms(커널 심볼)는 모든 컨테이너에서 동일- 시스템 콜 번호 테이블도 동일
- 드라이버도 공유
따라서 Docker 컨테이너는 가벼우면서도 진정한 격리를 제공하는 아키텍처입니다.
1.7 Key Takeaways¶
- CPU Ring 레벨: 하드웨어 수준의 권한 분리, 소프트웨어로 우회 불가
- Virtual Memory: 각 프로세스의 가상 주소 공간은 독립적, MMU가 권한 검증
- System Call: 유일한 안전한 커널 진입점, syscall 명령어는 원자적 모드 전환 보장
- Context Switching: 매우 비싼 연산 (μs 단위), TLB/캐시 재구성 비용 포함
- Docker Container: Namespace로 UI 격리, 하지만 커널 공유 → 가벼움
챕터 2: 커널 모듈(LKM)의 생애주기¶
2.1 LKM의 개념과 목적¶
**Loadable Kernel Module (LKM)**은 실행 중인 커널을 재컴파일/재부팅하지 않고도 런타임에 커널에 코드를 동적으로 로드/언로드할 수 있는 메커니즘입니다.
전통적인 커널 기능 추가 방법: 1. 커널 소스에 코드 추가 2. 전체 커널 재컴파일 (30분 ~ 수시간) 3. 부팅 로더 업데이트 4. 재부팅 5. 새 커널 실행
LKM의 이점: - 빠른 개발 사이클 - 드라이버 격리 (버그가 다른 드라이버를 멈추지 않음) - 선택적 로드/언로드 (필요 시에만 메모리 점유)
2.2 객체 지향 프로그래밍과의 비교¶
LKM의 구조는 **객체 지향 프로그래밍의 클래스 생명주기**와 매우 유사합니다.
C++ 클래스 생명주기:
class Device {
public:
// 생성자: 객체 초기화 시점
Device() {
this->fd = -1;
this->buffer = malloc(4096);
this->state = IDLE;
}
// 객체가 사용 중...
void operate() {
// 기능 수행
}
// 소멸자: 객체 정리 시점
~Device() {
if (this->buffer) free(this->buffer);
this->fd = -1;
}
};
// 사용
Device *dev = new Device(); // 생성자 호출
dev->operate(); // 사용
delete dev; // 소멸자 호출
LKM 생명주기:
#include <linux/module.h>
#include <linux/kernel.h>
// 초기화: 모듈 로드 시점
static int __init device_init(void) {
// 메모리 할당
device_data = kmalloc(4096, GFP_KERNEL);
// 하드웨어 리소스 등록
register_chrdev(MAJOR_NUM, "device", &fops);
// 인터럽트 핸들러 등록
request_irq(IRQ_NUM, device_irq_handler, 0, "device", NULL);
printk(KERN_INFO "Device module initialized\n");
return 0; // 성공
}
// 정리: 모듈 언로드 시점 (소멸자 역할)
static void __exit device_exit(void) {
// 역순으로 정리
free_irq(IRQ_NUM, NULL);
unregister_chrdev(MAJOR_NUM, "device");
kfree(device_data);
printk(KERN_INFO "Device module unloaded\n");
}
// 매크로로 진입점 등록
module_init(device_init);
module_exit(device_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Kernel Developer");
MODULE_DESCRIPTION("Example Character Device Driver");
유사점:
| 측면 | C++ 클래스 | LKM |
|---|---|---|
| 초기화 | 생성자 Device() |
module_init(device_init) |
| 메모리 할당 | malloc() |
kmalloc() |
| 리소스 등록 | 멤버 변수 초기화 | 드라이버 등록, IRQ 요청 |
| 활용 단계 | dev->operate() |
사용자 공간 애플리케이션이 호출 |
| 정리 | 소멸자 ~Device() |
module_exit(device_exit) |
| 메모리 해제 | free() |
kfree() |
| 리소스 해제 | - | 드라이버 언등록, IRQ 해제 |
중요한 차이점:
- C++ 객체는 프로그래머가 명시적으로 new/delete 호출
- LKM은 커널이 자동으로 관리 (insmod/rmmod)
2.3 Kernel Symbol Table과 EXPORT_SYMBOL¶
LKM이 커널 함수를 호출하려면, 그 함수의 메모리 주소를 알아야 합니다. 이것이 커널 심볼 테이블의 역할입니다.
동적 링킹(Dynamic Linking) 프로세스:
┌─────────────────────────────────────────────┐
│ 커널 이미지 (vmlinuz) │
│ │
│ kmalloc() @ 0xffffffff812a4030 │
│ printk() @ 0xffffffff812b5020 │
│ ...더 많은 함수들... │
│ │
│ __ksymtab 섹션 (심볼 테이블) │
│ ┌─────────────────────────────────────┐ │
│ │ Symbol: "kmalloc" │ │
│ │ Address: 0xffffffff812a4030 │ │
│ │ License: GPL │ │
│ ├─────────────────────────────────────┤ │
│ │ Symbol: "printk" │ │
│ │ Address: 0xffffffff812b5020 │ │
│ │ License: GPL │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
EXPORT_SYMBOL 메커니즘:
// 커널 소스 (kernel/sched/core.c)
void schedule(void) {
// ... 스케줄링 로직
}
// 이 심볼을 모든 모듈에 공개
EXPORT_SYMBOL(schedule);
// 또는 GPL 라이선스의 모듈만 접근 가능
EXPORT_SYMBOL_GPL(schedule);
컴파일 시 EXPORT_SYMBOL 매크로는 **__ksymtab 섹션에 항목을 추가**합니다:
// include/linux/export.h의 EXPORT_SYMBOL 구현
#define EXPORT_SYMBOL(sym) \
extern typeof(sym) sym; \
__CRC_SYMBOL(sym, "_gpl", ""); \
static const struct kernel_symbol __ksymtab_##sym \
__attribute__((section("__ksymtab"), used)) = { \
.value = (unsigned long)&sym, \
.name = __stringify(sym), \
}
모듈 로드 시 심볼 해석:
insmod my_module.ko 실행 시:
- 커널의 모듈 로더가
my_module.ko읽음 -
모듈의 미해석 심볼(Unresolved Symbols) 목록 추출
-
전역 심볼 테이블 조회
-
재배치(Relocation) 수행
-
모듈을 커널 메모리에 로드하고 초기화 함수 실행
EXPORT_SYMBOL 없을 시의 문제:
// kernel/some_core.c
static void internal_function(void) { // static = 심볼 비공개
// ...
}
// my_module.c
extern void internal_function(void); // 선언
static int __init my_init(void) {
internal_function(); // 호출 시도
return 0;
}
module_init(my_init);
insmod my_module.ko 시도:
GPL Symbol과 Non-GPL의 차이:
// 커널 코드
int sensitive_kernel_function(void) {
// ...
}
// 논쟁이 되는 심볼: 많은 커널 개발자들이 GPL 준수를 원함
EXPORT_SYMBOL_GPL(sensitive_kernel_function);
// 독점 드라이버 (GPL 미동의)
#include <linux/module.h>
static int __init proprietary_init(void) {
sensitive_kernel_function(); // 호출 시도
return 0;
}
// MODULE_LICENSE("Proprietary");
module_init(proprietary_init);
MODULE_LICENSE("Proprietary");
로드 시 실패:
2.4 module_init() / module_exit() 매크로 해부¶
이 매크로들은 **컴파일 시점에 특수한 섹션(Section)에 배치**되어, 커널의 모듈 로더가 자동으로 발견하고 실행할 수 있도록 합니다.
매크로 확장:
// 원본 코드
static int __init device_init(void) {
printk("Init called\n");
return 0;
}
module_init(device_init);
// 컴파일러가 보는 실제 확장 코드
// (include/linux/module.h)
static int __init device_init(void) {
printk("Init called\n");
return 0;
}
// 특수 섹션에 진입점 등록
static struct kernel_symbol __initcall_device_init \
__attribute__((section(".initcall.init"))) = {
.init_fn = device_init,
.level = INITCALL_ROOTFS,
};
// module_exit() 매크로
static void __exit device_exit(void) {
printk("Exit called\n");
}
module_exit(device_exit);
// 확장됨:
static struct kernel_symbol __exitcall_device_exit \
__attribute__((section(".exitcall.exit"))) = {
.exit_fn = device_exit,
};
ELF 섹션과 로더:
컴파일된 모듈 파일 (module.ko)의 구조:
┌──────────────────────────────────────────┐
│ ELF 헤더 │
├──────────────────────────────────────────┤
│ 섹션 1: .text (코드) │
│ device_init()의 바이너리 │
│ device_exit()의 바이너리 │
├──────────────────────────────────────────┤
│ 섹션 2: .data (초기화된 데이터) │
├──────────────────────────────────────────┤
│ 섹션 3: .init.text (초기화 코드) │
│ __init로 마킹된 함수들 │
├──────────────────────────────────────────┤
│ 섹션 4: .init.data (초기화 데이터) │
│ ┌────────────────────────────────────┐ │
│ │ module_init 진입점 레코드: │ │
│ │ { │ │
│ │ .init_fn = device_init 주소, │ │
│ │ } │ │
│ └────────────────────────────────────┘ │
├──────────────────────────────────────────┤
│ 섹션 5: .exit.data (종료 데이터) │
│ ┌────────────────────────────────────┐ │
│ │ module_exit 진입점 레코드: │ │
│ │ { │ │
│ │ .exit_fn = device_exit 주소, │ │
│ │ } │ │
│ └────────────────────────────────────┘ │
├──────────────────────────────────────────┤
│ 섹션 6: .symtab (심볼 테이블) │
│ 이 모듈에서 정의한 심볼들 │
├──────────────────────────────────────────┤
│ 섹션 7: .strtab (문자열 테이블) │
│ 심볼명, 섹션명 등의 문자열 │
└──────────────────────────────────────────┘
커널 모듈 로더의 동작:
// kernel/module.c의 개념화된 로드 함수
int load_module(const char *umod, struct load_info *info) {
// 1. 모듈 검증 및 기본 설정
struct module *mod = layout_and_allocate(info);
// 2. 모듈 코드를 커널 메모리에 복사
memcpy(mod->core_layout.base, load_ptr, info->core_size);
// 3. 미해석 심볼 해석 (모듈이 사용하는 커널 함수 주소 결정)
apply_relocations(mod);
// 4. 초기화 섹션(.init) 실행
ret = call_init_module(mod); // module_init() 호출
if (ret < 0) {
return ret; // 초기화 실패 시 모듈 언로드
}
// 5. 초기화 섹션 메모리 해제 (.init 섹션은 이제 불필요)
// __init 함수는 로드 후 버려져도 됨 (부팅 시 사용, 모듈은 런타임 로드)
free_init_section(mod);
// 6. 모듈 리스트에 추가
list_add_rcu(&mod->list, &modules);
return 0;
}
// 초기화 함수 호출 부분
static int call_init_module(struct module *mod) {
// .init.data 섹션에서 진입점 레코드 추출
struct kernel_init_record *init_rec = &mod->init_section;
// init 함수 포인터 호출
// 이 함수는 커널 권한(Ring 0)에서 실행됨
int ret = (*init_rec->init_fn)();
return ret;
}
2.5 Hello World 모듈: 상세 분석¶
#include <linux/module.h> // 모듈 관련 매크로 정의
#include <linux/kernel.h> // printk() 매크로
#include <linux/init.h> // __init, __exit 속성
MODULE_LICENSE("GPL"); // 라이선스 선언 (required)
MODULE_AUTHOR("Developer"); // 저자 정보
MODULE_DESCRIPTION("Hello World Module"); // 설명
// 초기화 함수: 모듈 로드 시 호출됨
static int __init hello_init(void) {
// printk는 커널 로깅 함수
// 출력은 /var/log/kern.log 또는 dmesg로 확인
printk(KERN_INFO "Hello World!\n");
return 0; // 0 = 성공, 음수 = 실패 (모듈 로드 거부)
}
// 정리 함수: 모듈 언로드 시 호출됨
static void __exit hello_exit(void) {
printk(KERN_INFO "Goodbye World!\n");
}
// 컴파일 시점에 확장되는 매크로들
module_init(hello_init); // 진입점 등록
module_exit(hello_exit); // 종료점 등록
각 구성 요소의 역할:
- #include
module_init,module_exit,EXPORT_SYMBOL등 매크로 정의struct module구조체 정의-
모듈 관련 함수 선언
-
#include
printk()매크로 정의-
KERN_INFO,KERN_ERR등 로그 레벨 상수 -
#include
__init속성: "이 함수는 초기화 시점에만 필요" → 로드 후 메모리 해제 가능-
__exit속성: "이 함수는 모듈이 로드된 경우에만 필요" → 정적 빌드 시 생략 가능 -
MODULE_LICENSE("GPL")
- 모듈의 라이선스 선언
- GPL이 아니면 일부 커널 심볼 접근 불가
-
모듈 서명 검증 시에도 사용
-
static int __init hello_init(void)
static: 심볼을 파일 내에 한정 (다른 모듈에서 접근 불가)__init: 로드 후 메모리 해제 가능-
반환값: 0 (성공) 또는 음수 에러 코드
-
module_init(hello_init)
- 매크로 확장:
__initcall_hello_init섹션에hello_init주소 기록 - 커널이 이를 읽어 모듈 로드 시 호출
실행 흐름 (insmod hello.ko):
1. 커널 모듈 로더 (kernel/module.c)
│
├─> hello.ko 파일 읽음 (ELF 형식)
│
├─> 섹션 파싱
│ ├─ .text: hello_init, hello_exit 바이너리
│ ├─ .init.data: module_init 레코드 ({.init_fn = hello_init})
│ └─ .exit.data: module_exit 레코드 ({.exit_fn = hello_exit})
│
├─> 커널 메모리에 모듈 로드
│ malloc을 사용하지 않음 (Ring 0에서 운영 중)
│ vmalloc 또는 module_alloc으로 메모리 할당
│
├─> __init 함수 호출: hello_init()
│ └─> printk(KERN_INFO "Hello World!\n");
│ [커널 로그 버퍼에 기록]
│
├─> __init 섹션 메모리 해제
│ [hello_init 함수의 메모리 회수]
│
└─> 모듈 로드 완료
/proc/modules에 나타남
/proc/kallsyms에 심볼 추가
2. 사용자가 확인
$ dmesg | tail
[ 234.567890] Hello World!
3. rmmod hello 명령
│
├─> .exit.data 섹션에서 exit 함수 포인터 추출
│
├─> hello_exit() 호출
│ └─> printk(KERN_INFO "Goodbye World!\n");
│
├─> 모듈 메모리 해제
│
└─> /proc/modules에서 제거
4. 사용자가 확인
$ dmesg | tail
[ 234.890123] Goodbye World!
2.6 insmod, rmmod, lsmod 내부 동작¶
insmod (Insert Module):
// insmod의 내부 동작 (user-space util-linux)
int main(int argc, char *argv[]) {
// 1. 모듈 파일 읽기
FILE *fp = fopen("my_module.ko", "rb");
char *module_data = read_entire_file(fp);
// 2. 커널에 모듈 로드 요청 (시스템 콜)
// init_module(module_data, module_size, "param1=value1");
syscall(SYS_init_module, module_data, module_size, "param1=value1");
// 3. 커널이 처리
// → kernel/module.c: load_module() 함수 실행
return 0;
}
커널 측 sys_init_module():
// kernel/module.c
SYSCALL_DEFINE3(init_module, void __user *, umod, unsigned long, len,
const char __user *, uargs)
{
struct load_info info = {};
struct module *mod;
// 1. 사용자 공간 데이터를 커널 버퍼로 복사
char *buf = vmalloc(len);
copy_from_user(buf, umod, len); // 매우 중요!
// 2. 모듈 정보 파싱
mod = load_module(buf, &info);
if (IS_ERR(mod)) {
vfree(buf);
return PTR_ERR(mod);
}
// 3. 모듈의 __init 함수 호출
// 이 과정에서 printk()가 실행되고 커널 로그 버퍼에 저장됨
// 4. /proc/modules에 추가 (사용자가 lsmod로 확인 가능)
list_add_rcu(&mod->list, &modules);
vfree(buf);
return 0;
}
dmesg (커널 로그 확인):
내부 동작:
- 커널 로그는 ring buffer(순환 버퍼)에 저장됨
- dmesg는 시스템 콜 syslog()를 사용하여 버퍼 내용 읽음
- 로그가 너무 많으면 오래된 항목이 삭제됨
rmmod (Remove Module):
// rmmod의 내부 동작
int main(int argc, char *argv[]) {
// 1. 모듈 이름으로 모듈 제거 요청
// delete_module("my_module", O_NONBLOCK);
syscall(SYS_delete_module, "my_module", O_NONBLOCK);
return 0;
}
커널 측 sys_delete_module():
// kernel/module.c
SYSCALL_DEFINE2(delete_module, const char __user *, name_user,
unsigned int, flags)
{
struct module *mod;
// 1. 모듈 리스트에서 찾기
mod = find_module(name_user);
if (!mod) {
return -ENOENT; // 없음
}
// 2. 모듈 사용 중인지 확인 (refcount 체크)
if (mod->state == MODULE_STATE_LIVE &&
!try_stop_module(mod)) {
return -EWOULDBLOCK; // 다른 모듈에서 사용 중
}
// 3. 모듈의 __exit 함수 호출
mod->exit(); // my_module의 hello_exit() 실행
// 4. 모듈 메모리 해제
free_module(mod);
// 5. /proc/modules에서 제거
list_del_rcu(&mod->list);
return 0;
}
lsmod (List Modules):
내부:
// /proc/modules는 커널이 제공하는 가상 파일
// 읽을 때마다 kernel/module.c의 m_show() 함수가 호출됨
static int m_show(struct seq_file *m, void *p) {
struct module *mod = list_entry(p, struct module, list);
seq_printf(m, "%s %u 0\n",
mod->name,
mod->core_layout.size); // 모듈 크기
// Used by: 이 모듈을 참조하는 다른 모듈들 표시
print_modules_used_by(m, mod);
return 0;
}
2.7 모듈 매개변수 (Module Parameters)¶
#include <linux/module.h>
#include <linux/moduleparam.h>
// 모듈 매개변수 선언
static int debug_level = 0;
module_param(debug_level, int, S_IRUGO | S_IWUSR);
MODULE_PARM_DESC(debug_level, "Debug level (0-3)");
static char *name = "default";
module_param(name, charp, S_IRUGO);
MODULE_PARM_DESC(name, "Device name");
static int __init my_init(void) {
printk(KERN_INFO "Debug: %d, Name: %s\n", debug_level, name);
return 0;
}
module_init(my_init);
사용:
$ insmod my_module.ko debug_level=3 name=mydev
$ dmesg | tail
[ 234.567890] Debug: 3, Name: mydev
$ cat /sys/module/my_module/parameters/debug_level
3
$ echo 2 > /sys/module/my_module/parameters/debug_level # 런타임 변경
2.8 Key Takeaways¶
- LKM: 커널 재컴파일 없이 런타임에 기능 추가/제거
- 객체 지향과의 유사성:
__init= 생성자,__exit= 소멸자 - EXPORT_SYMBOL: 커널 함수를 모듈에 공개하는 메커니즘
- module_init/exit: 컴파일 시 특수 섹션(.init.data, .exit.data)에 배치
- insmod/rmmod: 사용자 공간의 시스템 콜 래퍼
- 매개변수: /sys/module/을 통해 런타임 조정 가능
챕터 3: 문자 디바이스 드라이버와 VFS (Virtual File System)¶
3.1 "Everything is a File" 철학¶
Linux에서는 거의 모든 것을 **파일로 추상화**합니다:
| 대상 | 파일 경로 | 타입 | 드라이버 |
|---|---|---|---|
| 터미널 | /dev/tty0 | 문자 디바이스 | tty 드라이버 |
| 웹캠 | /dev/video0 | 문자 디바이스 | V4L2 드라이버 |
| USB 포트 | /dev/usb0 | 문자 디바이스 | USB 드라이버 |
| 하드 디스크 | /dev/sda | 블록 디바이스 | SATA 드라이버 |
| 네트워크 | /proc/net/tcp | 일반 파일 | 네트워크 스택 |
| 메모리 | /dev/mem | 문자 디바이스 | 메모리 드라이버 |
문자 디바이스(Character Device)의 특징: - 스트림 기반: 데이터가 순차적으로 처리**됨 - **버퍼링 없음: 읽기/쓰기가 직접 하드웨어로 전달됨 - 예: 터미널, 시리얼 포트, 마우스
블록 디바이스(Block Device)의 특징: - 랜덤 접근: 임의의 위치에서 읽기/쓰기 가능 - 버퍼링: 커널이 블록 캐시 관리 - 예: 하드 디스크, SSD
3.2 file_operations 구조체: 인터페이스의 심장¶
file_operations는 사용자 공간의 system call(open, read, write)과 드라이버 함수를 **매핑하는 인터페이스**입니다.
// include/linux/fs.h
struct file_operations {
struct module *owner;
// 파일 위치 이동
loff_t (*llseek) (struct file *, loff_t, int);
// 파일 읽기
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
// 파일 쓰기
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
// 읽기 + 쓰기 동시 (정렬된 I/O)
ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
// 디바이스 열기
int (*open) (struct inode *, struct file *);
// 디바이스 닫기
int (*release) (struct inode *, struct file *);
// 제어 명령어 (ioctl)
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
// 메모리 매핑
int (*mmap) (struct file *, struct vm_area_struct *);
// 폴링 (select/poll)
__poll_t (*poll) (struct file *, struct poll_table_struct *);
// 기타 다양한 함수들...
};
3.3 간단한 문자 디바이스 드라이버¶
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#include <linux/device.h>
#define DEVICE_NAME "mychdev"
#define CLASS_NAME "myclass"
// 주요 번호 (major number) - 0은 커널이 자동 할당
static int majorNumber;
static struct class *charClass = NULL;
static struct device *charDevice = NULL;
// 디바이스의 메모리 버퍼
#define BUFFER_SIZE 1024
static char deviceBuffer[BUFFER_SIZE] = {};
// open: 사용자가 /dev/mychdev를 열 때 호출
static int device_open(struct inode *inodep, struct file *filep) {
printk(KERN_INFO "Device opened\n");
return 0; // 성공
}
// release: 사용자가 파일을 닫을 때 호출
static int device_release(struct inode *inodep, struct file *filep) {
printk(KERN_INFO "Device closed\n");
return 0;
}
// read: 사용자가 디바이스로부터 읽을 때 호출
// cat /dev/mychdev 또는 read() 시스템 콜
static ssize_t device_read(struct file *filep, char __user *buffer,
size_t len, loff_t *offset) {
// 버퍼 크기 제한
int bytes_to_read = (len > BUFFER_SIZE) ? BUFFER_SIZE : len;
// 커널 메모리(deviceBuffer)에서 사용자 메모리(buffer)로 복사
// copy_to_user는 매우 중요한 함수
int error_count = copy_to_user(buffer, deviceBuffer, bytes_to_read);
if (error_count != 0) {
// copy_to_user는 실패한 바이트 수 반환
// 0이 아니면 일부 복사 실패
return -EFAULT;
}
printk(KERN_INFO "Device read %d bytes\n", bytes_to_read);
return bytes_to_read;
}
// write: 사용자가 디바이스에 쓸 때 호출
// echo "hello" > /dev/mychdev 또는 write() 시스템 콜
static ssize_t device_write(struct file *filep, const char __user *buffer,
size_t len, loff_t *offset) {
int bytes_to_write = (len > BUFFER_SIZE) ? BUFFER_SIZE : len;
// 사용자 메모리(buffer)에서 커널 메모리(deviceBuffer)로 복사
int error_count = copy_from_user(deviceBuffer, buffer, bytes_to_write);
if (error_count != 0) {
return -EFAULT;
}
printk(KERN_INFO "Device wrote %d bytes\n", bytes_to_write);
return bytes_to_write;
}
// file_operations 구조체 초기화
static struct file_operations fops = {
.owner = THIS_MODULE,
.open = device_open,
.release = device_release,
.read = device_read,
.write = device_write,
};
// 모듈 초기화
static int __init chardev_init(void) {
// 1. 문자 디바이스 등록
majorNumber = register_chrdev(0, DEVICE_NAME, &fops);
if (majorNumber < 0) {
printk(KERN_ALERT "Failed to register device\n");
return majorNumber;
}
printk(KERN_INFO "Device registered with major number %d\n", majorNumber);
// 2. 장치 클래스 생성 (udev가 /dev/ 노드 자동 생성)
charClass = class_create(THIS_MODULE, CLASS_NAME);
if (IS_ERR(charClass)) {
unregister_chrdev(majorNumber, DEVICE_NAME);
return PTR_ERR(charClass);
}
// 3. 장치 생성
charDevice = device_create(charClass, NULL, MKDEV(majorNumber, 0),
NULL, DEVICE_NAME);
if (IS_ERR(charDevice)) {
class_destroy(charClass);
unregister_chrdev(majorNumber, DEVICE_NAME);
return PTR_ERR(charDevice);
}
printk(KERN_INFO "Device created successfully\n");
return 0;
}
// 모듈 정리
static void __exit chardev_exit(void) {
device_destroy(charClass, MKDEV(majorNumber, 0));
class_destroy(charClass);
unregister_chrdev(majorNumber, DEVICE_NAME);
printk(KERN_INFO "Device unregistered\n");
}
module_init(chardev_init);
module_exit(chardev_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Developer");
MODULE_DESCRIPTION("Simple Character Device Driver");
3.4 VFS (Virtual File System) 계층 구조¶
사용자가 /dev/mychdev에 접근할 때의 VFS 계층:
┌────────────────────────────────────────┐
│ 사용자 애플리케이션 │
│ read("/dev/mychdev", buf, 1024) │
└────────────────┬───────────────────────┘
│ (시스템 콜)
↓
┌────────────────────────────────────────┐
│ VFS - 파일 시스템 인터페이스 │
│ fs/read_write.c: vfs_read() │
│ - 파일 객체 검증 │
│ - 권한 확인 │
│ - 적절한 fops 찾기 │
└────────────────┬───────────────────────┘
│
↓
┌────────────────────────────────────────┐
│ devtmpfs - 디바이스 파일 시스템 │
│ - 주요 번호(major)와 부가 번호(minor) │
│ 로부터 드라이버 찾기 │
└────────────────┬───────────────────────┘
│
↓
┌────────────────────────────────────────┐
│ 문자 디바이스 드라이버 │
│ file_operations.read() │
│ → device_read() 호출 │
└────────────────┬───────────────────────┘
│
↓
┌─────────────────────┐
│ 하드웨어 또는 │
│ 메모리 버퍼 │
└─────────────────────┘
VFS 주요 구조체:
// 파일 시스템의 메타정보
struct inode {
unsigned long i_ino; // inode 번호
struct timespec64 i_atime; // 마지막 접근 시간
struct timespec64 i_mtime; // 마지막 수정 시간
struct file_operations *i_fop; // 파일 연산 함수 포인터
// 문자 디바이스의 경우
struct cdev *i_cdev; // 문자 디바이스 정보
union {
blkcnt_t i_blocks; // 블록 개수
struct {
dev_t i_rdev; // 주요/부가 번호
};
};
};
// 열린 파일의 상태 (프로세스마다 생성)
struct file {
struct inode *f_inode; // inode 참조
struct file_operations *f_op; // 파일 연산 함수 포인터
loff_t f_pos; // 현재 위치
unsigned int f_flags; // O_RDONLY, O_WRONLY 등
void *private_data; // 드라이버가 자유롭게 사용
};
// 문자 디바이스 정보
struct cdev {
struct kobject kobj;
struct module *owner;
const struct file_operations *ops; // 우리의 fops!
struct list_head list;
dev_t dev; // 주요 번호
unsigned int count;
};
3.5 copy_to_user() 상세 분석¶
이 함수 없이 직접 메모리를 접근하면 **여러 보안 취약점**이 발생합니다:
취약한 코드 (위험!):
static ssize_t bad_device_read(struct file *filep, char __user *buffer,
size_t len, loff_t *offset) {
// ❌ 위험: 사용자 포인터를 직접 해석
// 문제 1: buffer가 NULL일 수 있음 → 커널 크래시
// 문제 2: buffer가 커널 메모리를 가리킬 수 있음 → 데이터 유출
// 문제 3: buffer가 다른 프로세스의 메모리를 가리킬 수 있음
memcpy(buffer, deviceBuffer, len); // 💣 매우 위험
return len;
}
copy_to_user의 역할:
static ssize_t safe_device_read(struct file *filep, char __user *buffer,
size_t len, loff_t *offset) {
// ✅ 안전: 사용자 포인터 검증
unsigned long result = copy_to_user(buffer, deviceBuffer, len);
if (result != 0) {
// result = 복사 실패한 바이트 수
// 일부 복사 실패 시 처리
printk(KERN_ERR "%lu bytes could not be copied\n", result);
return -EFAULT;
}
return len;
}
copy_to_user의 내부 동작:
// arch/x86/lib/usercopy_64.c
unsigned long copy_to_user(void __user *to, const void *from,
unsigned long n)
{
// 1. 주소 범위 검증
// MMU가 user/kernel 경계를 이미 페이지 테이블로 강제했지만,
// 추가 소프트웨어 검증도 수행
if (!access_ok(to, n)) {
// 이 주소는 사용자 공간이 아님
return n; // 모두 실패
}
// 2. 잠재적 페이지 폴트 처리
might_fault(); // 커널 스케줄러에 알림: 이 함수는 휴면할 수 있음
// 3. 아키텍처 특화 복사
// x86-64: REP MOVSB 명령어 사용 (바이트 단위 복사)
// ARM: memcpy의 특수 버전
return __copy_to_user(to, from, n);
}
// arch/x86/lib/copy_user_64.S - 매우 저수준
__copy_to_user:
// MOV 또는 REP MOVSB로 바이트 복사
// 이 중에 페이지 폴트 발생 가능
copy_bytes:
mov (%rsi), %al // 커널 메모리에서 읽음
mov %al, (%rdi) // 사용자 메모리에 쓰기 (여기서 faulting!)
inc %rsi
inc %rdi
dec %rcx
jnz copy_bytes
ret
페이지 폴트 처리의 예:
사용자 버퍼가 스왑 메모리에 있을 수 있습니다:
1. copy_to_user(user_buffer, kernel_data, 4096) 호출
2. 처음 4KB는 메모리에 있음 → 즉시 복사
3. 다음 4KB는 디스크에 스왑됨 → 페이지 폴트 발생
4. CPU가 page fault exception 발생
5. 커널의 page fault handler 호출
do_page_fault(regs, error_code)
6. 핸들러: 현재 주소가 copy_to_user/copy_from_user 내부인지 확인
if (in_exception_table(regs->ip)) {
// 이것은 예상된 폴트
// 스왑된 페이지를 메모리로 로드
do_swap_page(...)
// 복사 명령어 재개
}
7. 복사 계속 진행
3.6 copy_from_user() vs Pipe/Socket¶
커널이 사용자 데이터를 받을 때 몇 가지 방법이 있습니다:
1. copy_from_user() - 직접 복사
// 디바이스 드라이버
static ssize_t device_write(struct file *filep, const char __user *buffer,
size_t len, loff_t *offset) {
char kernel_buf[1024];
// 사용자 → 커널 메모리로 직접 복사
if (copy_from_user(kernel_buf, buffer, len)) {
return -EFAULT;
}
// kernel_buf에서 처리
process_data(kernel_buf, len);
return len;
}
2. Pipe - 버퍼 중간 저장
// write(pipe_fd, "hello", 5)
ssize_t pipe_write(struct kiocb *iocb, struct iov_iter *from) {
struct pipe_inode_info *pipe = iocb->ki_filp->private_data;
// 파이프의 내부 버퍼에 데이터 복사
copy_from_iter(pipe->bufs[pipe->curbuf].page, from);
// 읽는 쪽이 있으면 깨우기
wake_up_interruptible(&pipe->rd_wait);
return len;
}
// read(pipe_fd, buf, 5)
ssize_t pipe_read(struct kiocb *iocb, struct iov_iter *to) {
struct pipe_inode_info *pipe = iocb->ki_filp->private_data;
// 파이프 버퍼 → 사용자 메모리
copy_to_iter(pipe->bufs[pipe->curbuf].page, to);
return len;
}
3. Socket - 네트워크 버퍼
// send(sock, buf, len, 0)
static int sock_sendmsg(struct socket *sock, struct msghdr *msg) {
// 소켓의 송신 버퍼에 복사
// sk->sk_write_queue 큐에 sk_buff 추가
struct sk_buff *skb = alloc_skb(len, GFP_KERNEL);
skb_copy_from_user(skb, msg->msg_iov, len);
// 네트워크 스택으로 전달
sk->sk_write_queue.push(skb);
return len;
}
비교:
| 측면 | copy_from_user | Pipe | Socket |
|---|---|---|---|
| 데이터 저장소 | 드라이버 버퍼 | 파이프 버퍼 | sk_buff (순환 버퍼) |
| 싱크 | 1:1 | 1:1 | 1:많음 (네트워크) |
| 처리 시점 | 동기적 | 비동기적 | 비동기적 |
| 오버헤드 | 낮음 | 중간 (버퍼 관리) | 높음 (네트워크 프로토콜) |
3.7 메모리 보안: Ramdisk 예제¶
실제 하드웨어 없이 메모리를 디바이스처럼 다루는 예제:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#include <linux/vmalloc.h>
#define DEVICE_NAME "ramdisk"
#define DEVICE_SIZE (1024 * 1024) // 1MB 가상 디스크
static int majorNumber;
static void *ramdisk_buffer = NULL;
// 읽기: 메모리의 특정 위치에서 데이터 반환
static ssize_t ramdisk_read(struct file *filep, char __user *buffer,
size_t len, loff_t *offset) {
// offset이 범위 벗어나면 0 반환 (EOF)
if (*offset >= DEVICE_SIZE) {
return 0;
}
// 읽을 수 있는 최대 크기 계산
if (*offset + len > DEVICE_SIZE) {
len = DEVICE_SIZE - *offset;
}
// ramdisk_buffer의 offset 위치에서 len만큼 읽기
if (copy_to_user(buffer, (char *)ramdisk_buffer + *offset, len)) {
return -EFAULT;
}
*offset += len; // 파일 위치 이동
return len;
}
// 쓰기: 메모리의 특정 위치에 데이터 저장
static ssize_t ramdisk_write(struct file *filep, const char __user *buffer,
size_t len, loff_t *offset) {
if (*offset >= DEVICE_SIZE) {
return -ENOSPC; // 공간 부족
}
if (*offset + len > DEVICE_SIZE) {
len = DEVICE_SIZE - *offset;
}
// 사용자 메모리에서 ramdisk_buffer로 복사
if (copy_from_user((char *)ramdisk_buffer + *offset, buffer, len)) {
return -EFAULT;
}
*offset += len;
return len;
}
// seek: 파일 위치 변경
static loff_t ramdisk_llseek(struct file *filep, loff_t offset, int origin) {
loff_t new_pos;
switch (origin) {
case SEEK_SET:
new_pos = offset;
break;
case SEEK_CUR:
new_pos = filep->f_pos + offset;
break;
case SEEK_END:
new_pos = DEVICE_SIZE + offset;
break;
default:
return -EINVAL;
}
if (new_pos < 0 || new_pos >= DEVICE_SIZE) {
return -EINVAL;
}
filep->f_pos = new_pos;
return new_pos;
}
static struct file_operations fops = {
.read = ramdisk_read,
.write = ramdisk_write,
.llseek = ramdisk_llseek,
};
static int __init ramdisk_init(void) {
// 1MB 메모리 할당
ramdisk_buffer = vmalloc(DEVICE_SIZE);
if (!ramdisk_buffer) {
return -ENOMEM;
}
// 버퍼 초기화
memset(ramdisk_buffer, 0, DEVICE_SIZE);
// 디바이스 등록
majorNumber = register_chrdev(0, DEVICE_NAME, &fops);
if (majorNumber < 0) {
vfree(ramdisk_buffer);
return majorNumber;
}
printk(KERN_INFO "Ramdisk initialized\n");
return 0;
}
static void __exit ramdisk_exit(void) {
unregister_chrdev(majorNumber, DEVICE_NAME);
vfree(ramdisk_buffer);
printk(KERN_INFO "Ramdisk unloaded\n");
}
module_init(ramdisk_init);
module_exit(ramdisk_exit);
MODULE_LICENSE("GPL");
사용 예:
$ sudo mknod /dev/ramdisk c 250 0
$ sudo chmod 666 /dev/ramdisk
# 쓰기
$ echo "Hello Ramdisk!" > /dev/ramdisk
# 읽기
$ cat /dev/ramdisk
Hello Ramdisk!
# seek 테스트
$ dd if=/dev/ramdisk skip=5 bs=1
Ramdisk!
3.8 Key Takeaways¶
- VFS: 모든 파일 연산을 통합 인터페이스(
file_operations)로 제공 - file_operations: 사용자 시스템 콜과 드라이버 함수의 매핑 구조
- copy_to/from_user(): 필수적인 보안 함수, 주소 검증과 페이지 폴트 처리
- 메모리 격리: MMU + 소프트웨어 검증으로 커널 메모리 보호
- 추상화: "모든 것은 파일"로 일관된 인터페이스 제공
챕터 4: 네트워크 패킷 가로채기 - Netfilter와 sk_buff¶
4.1 패킷의 여행 지도: Netfilter Hook¶
네트워크 패킷이 하드웨어에서 애플리케이션까지 도달하는 과정에서 **5개의 Hook 지점**이 있습니다:
┌──────────────────────────────────────────────────────────────────────┐
│ NIC (Network Interface Card) │
│ (Ring Buffer에 패킷 도착) │
└─────────────────────────────┬──────────────────────────────────────────┘
│ (interrupt handler)
↓
┌─────────────────────────────────────────────────┐
│ sk_buff 구조체 생성 및 초기화 │
│ (패킷 메타데이터 저장) │
└─────────────────────┬───────────────────────────┘
│
╔═════════════════════════════════════════════════╗
║ HOOK #1: NF_INET_PRE_ROUTING ║
║ (패킷이 라우팅 결정되기 전) ║
║ - 목적지 주소 변환 (DNAT) ║
║ - 패킷 필터링 ║
╚═════════════════════╤═════════════════════════════╝
│
↓
라우팅 결정 (Route Lookup)
목적지: 로컬인가? 포워드인가?
│
┌─────────────┴────────────┐
│ │
↓ (로컬) ↓ (포워드)
┌──────────────────┐ ╔══════════════════════════╗
│ 로컬 수신 (Input)│ ║ HOOK #2: NF_INET_FORWARD║
│ │ ║ (포워딩 결정 전) ║
└────────┬────────┘ ║ - 포워딩 방화벽 ║
│ ║ - QoS 설정 ║
╔════════════════════╗ ╚══════════════════════════╝
║ HOOK #3: INPUT ║ │
║ (로컬 수신 직전) ║ ↓
║ - 인바운드 방화벽 ║ ╔══════════════════════════╗
╚════════════╤═══════╝ ║ HOOK #4: POST_ROUTING ║
│ ║ (경로 결정 후) ║
↓ ║ - 출발지 주소 변환 (SNAT) ║
┌──────────────────┐ ║ - IP 변조 ║
│ 애플리케이션 처리 │ ╚══════════════════════════╝
│ (소켓 수신) │ │
│ │ ↓
└────────┬────────┘ ╔═════════════════════════╗
│ ║ HOOK #5: OUTPUT ║
│ ║ (로컬 송신 직후) ║
│ ║ - 로컬 패킷 필터링 ║
│ ║ - DNAT (로컬 출발) ║
│ ╚════════════╤═════════════╝
│ │
│ ┌───────────────────┘
│ │
└────┬────┘
↓
┌──────────────────┐
│ NIC 송신 (TX) │
│ (Ring Buffer) │
└──────────────────┘
각 Hook에서 패킷에 대해 취할 수 있는 3가지 결정:
- NF_ACCEPT: 계속 진행
- NF_DROP: 패킷 폐기
- NF_QUEUE: 사용자 공간 애플리케이션으로 전달
4.2 sk_buff 구조체: 패킷의 메타데이터¶
// include/linux/skbuff.h
struct sk_buff {
// -------- 링크 필드 --------
struct sk_buff *next;
struct sk_buff *prev;
// -------- 메모리 레이아웃 (포인터 기반) --------
// 패킷 메모리의 구조:
// [head] ← data ← network_header ← transport_header ← tail ← [end]
// ↑ ↑
// └─ 할당된 메모리 범위 ─────────────────────────────────────┘
unsigned char *head; // 할당된 메모리의 시작
unsigned char *data; // 현재 데이터 시작 (이더넷 헤더)
unsigned char *tail; // 현재 데이터의 끝
unsigned char *end; // 할당된 메모리의 끝
// -------- 데이터 길이 --------
unsigned int len; // data~tail의 바이트 수
unsigned int data_len; // 프래그먼트 데이터 길이
// -------- 프로토콜 스택 추적 --------
// 각 프로토콜 층의 헤더 위치를 추적 (레지스터처럼 동작)
union {
unsigned int transport_header;
unsigned int th;
};
union {
unsigned int network_header;
unsigned int nh;
};
union {
unsigned int mac_header;
unsigned int mac;
};
// -------- 제어 정보 --------
__u16 protocol; // 이더넷 타입 (0x0800 = IPv4)
__u16 vlan_tci; // VLAN 태그
// -------- 소켓 정보 --------
struct sock *sk; // 소속 소켓
struct net_device *dev; // 입력/출력 장치
// -------- 타이밍 정보 --------
ktime_t tstamp; // 패킷 타임스탐프
// -------- 마킹 --------
__u32 mark; // iptables mark (QoS 용)
// -------- 기타 --------
void *destructor_arg; // 메모리 해제 시 호출
struct sec_path *sp; // IPsec 정보
};
메모리 레이아웃 시각화:
sk_buff 수신 초기 상태:
┌─────────────────────────────────────────────────────────┐
│ NIC로부터 수신한 프레임 (1500 bytes) │
│ [이더넷][IP][TCP][페이로드] │
└─────────────────────────────────────────────────────────┘
↑ ↑ ↑ ↑ ↑
│ │ │ │ │
head data nh th end
(=ethernet (=IP (=TCP
header) header) header)
offset offset offset
추적 추적 추적
Zero-Copy 헤더 추가 (예: 터널링):
before:
[IP][TCP][페이로드]
↑ ↑
data tail
after:
[터널IP][IP][TCP][페이로드]
↑ ↑
data tail
(data 포인터만 역방향 이동, 실제 메모리 복사 없음!)
4.3 Zero-Copy 효율성¶
sk_buff의 혁신적인 설계:
전통적인 방식 (데이터 복사):
void old_way_tunnel_packet(struct packet *pkt, char *tunnel_ip) {
// 1. 터널 IP 헤더를 위한 새 버퍼 할당
char *new_buffer = malloc(pkt->len + 20); // 20 = IPv4 헤더
// 2. 터널 IP 헤더 복사
memcpy(new_buffer, tunnel_ip, 20);
// 3. 기존 패킷 복사
memcpy(new_buffer + 20, pkt->data, pkt->len); // 💣 비싼 연산!
// 4. 전송
send_packet(new_buffer, pkt->len + 20);
// 5. 메모리 해제
free(pkt->data);
free(new_buffer);
}
sk_buff의 Zero-Copy 방식:
void sk_buff_tunnel_packet(struct sk_buff *skb, char *tunnel_ip) {
// 1. data 포인터를 역방향으로 이동 (IP 헤더를 위한 공간 확보)
// 실제 메모리 복사 없음!
if (skb_push(skb, 20) == NULL) {
return -ENOMEM; // 사전에 예약된 headroom 부족
}
// 2. 터널 IP 헤더만 쓰기 (빠른 memcpy)
struct iphdr *new_iph = (struct iphdr *)skb->data;
memcpy(new_iph, tunnel_ip, 20); // 20바이트만 (원래 패이로드 건들지 않음)
// 3. 전송 (skb 구조체만 전달)
send_skb_to_nic(skb);
// skb_buffer_free() 호출 시에만 메모리 해제
}
성능 차이:
패킷 크기: 1500 bytes (전형적인 이더넷 프레임)
전통적 방식:
- 메모리 할당: 1520 bytes → ~100 CPU 사이클
- memcpy(1500 bytes) → ~15,000 CPU 사이클 (L3 캐시에서)
- 메모리 해제: ~50 사이클
- 합계: ~15,150 사이클
Zero-Copy 방식:
- 포인터 감소: ~1 사이클
- memcpy(20 bytes) → ~200 사이클
- 합계: ~201 사이클
성능 향상: 약 75배!
4.4 Netfilter Hook 모듈: IP 기반 필터링¶
특정 IP의 패킷을 DROP하는 방화벽:
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/netfilter.h>
#include <linux/netfilter_ipv4.h>
#include <linux/skbuff.h>
#include <linux/ip.h>
#include <linux/inet.h> // inet_ntoa 등
// 차단할 IP 주소
static char *blocked_ip = "192.168.1.100";
module_param(blocked_ip, charp, S_IRUGO);
static __be32 blocked_ip_addr = 0;
// Hook 함수: PRE_ROUTING에서 호출됨
static unsigned int hook_func(void *priv,
struct sk_buff *skb,
const struct nf_hook_state *state) {
// 1. 패킷 유효성 확인
if (!skb) {
return NF_ACCEPT;
}
// 2. IP 헤더 추출
// skb->network_header는 이전에 설정됨 (L3 처리 시)
struct iphdr *iph = ip_hdr(skb);
if (!iph) {
return NF_ACCEPT; // IP 헤더 없음 (IPv6 또는 기타)
}
// 3. 목적지 IP 주소 확인
if (iph->daddr == blocked_ip_addr) {
// 차단할 IP에서 수신한 패킷
printk(KERN_WARNING "Dropping packet from %pI4\n", &iph->saddr);
// 4. 패킷 폐기
// 절대 kfree_skb(skb)를 호출하지 말 것!
// Netfilter가 NF_DROP 후 자동으로 처리
return NF_DROP;
}
// 5. 다른 패킷은 계속 진행
return NF_ACCEPT;
}
// Hook 등록
static struct nf_hook_ops nfho = {
.hook = hook_func,
.hooknum = NF_INET_PRE_ROUTING, // PRE_ROUTING 지점
.pf = NFPROTO_IPV4, // IPv4만
.priority = NF_IP_PRI_FIRST, // 가장 먼저 실행
};
static int __init firewall_init(void) {
// 1. 차단 IP 주소를 네트워크 바이트 순서로 변환
in4_pton(blocked_ip, -1, (u8 *)&blocked_ip_addr, '\0', NULL);
printk(KERN_INFO "Firewall: Blocking IP %pI4\n", &blocked_ip_addr);
// 2. Hook 등록
nf_register_net_hook(&init_net, &nfho);
return 0;
}
static void __exit firewall_exit(void) {
nf_unregister_net_hook(&init_net, &nfho);
printk(KERN_INFO "Firewall: Unloaded\n");
}
module_init(firewall_init);
module_exit(firewall_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Network Developer");
MODULE_DESCRIPTION("Simple IP-based Firewall");
사용:
$ sudo insmod firewall.ko blocked_ip="10.0.0.1"
$ sudo dmesg | tail
[ 1234.567890] Firewall: Blocking IP 10.0.0.1
# 10.0.0.1에서 ping
$ ping 10.0.0.1
# 응답 없음 (차단됨)
$ sudo rmmod firewall
$ sudo dmesg | tail
[ 1235.123456] Firewall: Unloaded
4.5 Kernel-Space vs User-Space 처리¶
User-Space 프록시 (전통적 방식):
// Nginx 같은 리버스 프록시
while (1) {
// 1. 클라이언트 요청 대기
accept_connection(server_socket, &client);
// 2. 요청 데이터 읽기
recv(client, request_buf, 4096);
// 3. 요청 분석 및 수정
if (is_malicious(request_buf)) {
send_error(client);
}
// 4. 백엔드로 전달
send(backend_socket, request_buf, len);
// 5. 응답 수신
recv(backend_socket, response_buf, 4096);
// 6. 클라이언트로 전송
send(client, response_buf, len);
close(client);
}
성능 문제:
클라이언트 → 프록시 → 백엔드 → 프록시 → 클라이언트
1회 왕복 비용:
- 클라이언트: user-mode recv syscall → ~5 μs
- 프로세스 스케줄링 (전락 발생 가능) → ~2-10 μs
- 프록시: 애플리케이션 처리 → ~50-100 μs
- 백엔드 connect/recv: 네트워크 왕복 → ~100-1000 μs
- 총: ~150-1100 μs per request
초당 처리: ~1000-6500 req/s (고급 최적화 후)
Kernel-Space 필터링 (Netfilter):
// Linux Netfilter Hook
static unsigned int hook_func(void *priv, struct sk_buff *skb,
const struct nf_hook_state *state) {
struct iphdr *iph = ip_hdr(skb);
struct tcphdr *tcph = tcp_hdr(skb);
// 1. TCP 포트 확인 (메모리 접근만 함)
if (tcph->dest == htons(80)) {
// 2. Payload 검사 (여전히 커널 메모리에서)
unsigned char *payload = (unsigned char *)tcph + (tcph->doff * 4);
// 3. 패턴 매칭 (매우 빠름, 캐시 친화적)
if (malware_pattern_found(payload, skb->len)) {
return NF_DROP; // 즉시 폐기
}
}
return NF_ACCEPT;
}
성능 비교:
Kernel-Space 필터링 비용:
- IP/TCP 헤더 접근: ~50 ns (L1 캐시)
- 패턴 매칭: ~200 ns
- NF_DROP 결정: ~10 ns
- 합계: ~260 ns per packet
User-Space 프록시:
- 동일 패킷 처리: ~10,000-100,000 ns
성능 향상: 약 40-400배!
4.6 Context Switch 최소화의 이점¶
상황: 방화벽이 초당 100,000개 패킷 처리
User-Space 접근 (프로세스마다 처리):
100,000 packets/s
─────────────────────── = 100 프로세스 필요 (각 1000 packets/s 처리)
1,000 packets/s/process
Context Switch 비용:
- 100개 프로세스 간 전환: 99 switches/s (매우 낮음)
- 하지만 패킷당 평균 5-10 syscall 필요
- 500,000-1,000,000 context switches/s!
각 전환: ~5 μs
총 비용: 500,000 * 5 μs = 2.5초!
(1초에 처리할 수 있는 시간이 2.5배 소모)
Kernel-Space 필터링:
100,000 packets/s
- Context Switch 0회! (Ring 0에서 처리)
- 모든 처리가 interrupt handler 또는 softirq context에서
- CPU 사이클만 소비: 100,000 * 260 ns = 26 ms
1초 = 1,000,000,000 ns
네트워크 처리 시간: 26 ms
남은 시간: 974 ms (97.4% CPU 여유)
→ 다른 작업 충분히 처리 가능!
4.7 sk_buff 수정과 포워딩¶
실제로 패킷을 수정하는 경우:
static unsigned int modify_hook(void *priv, struct sk_buff *skb,
const struct nf_hook_state *state) {
struct iphdr *iph = ip_hdr(skb);
struct tcphdr *tcph = tcp_hdr(skb);
// 1. 쓰기 가능한지 확인 (다른 skb에서 공유 중이면 복사)
if (skb_cloned(skb)) {
// 데이터 공유 → 복사 필요
struct sk_buff *new_skb = skb_copy(skb, GFP_ATOMIC);
if (!new_skb) {
return NF_DROP;
}
consume_skb(skb);
skb = new_skb;
}
// 2. 출발지 IP 변경 (SNAT)
if (iph->saddr == htonl(0xc0a80101)) { // 192.168.1.1
// 새 주소로 변경
iph->saddr = htonl(0xc0a80102); // 192.168.1.2
// 3. 체크섬 재계산 (중요!)
ip_send_check(iph);
// TCP 체크섬도 다시 계산 필요
tcph->check = 0;
tcph->check = tcp_v4_check(skb->len - (iph->ihl * 4),
iph->saddr, iph->daddr,
csum_partial(tcph, skb->len, 0));
}
// 4. 수정된 패킷 전달
return NF_ACCEPT;
}
4.8 Key Takeaways¶
- Netfilter Hooks: 5개 지점에서 패킷을 가로채고 처리
- sk_buff: 패킷을 메타데이터와 함께 관리, Zero-Copy 헤더 조작 지원
- Kernel-Space 필터링: User-Space 프로세스 대비 40-400배 빠름 (Context Switch 제거)
- Zero-Copy: 포인터 조작으로 데이터 복사 최소화
- 성능 관점: 네트워크 대역폭 다항 처리 시 커널 레벨 처리 필수
전체 요약 및 학습 로드맵¶
학습 체계화¶
Level 1: 아키텍처 이해 (이 문서 - 챕터 1) - ✅ CPU Ring과 메모리 격리의 필요성 이해 - ✅ System Call의 하드웨어 메커니즘 파악 - ✅ Context Switching의 성능 영향 인식 - ✅ Docker Container와 커널의 관계 구분
Level 2: 동적 커널 확장 (챕터 2) - ✅ LKM의 생명주기와 module_init/exit 이해 - ✅ EXPORT_SYMBOL을 통한 모듈 간 통신 - ✅ insmod/rmmod의 내부 동작 파악 - ✅ 실제 드라이버 개발의 기초
Level 3: I/O 추상화 (챕터 3) - ✅ VFS를 통한 파일 인터페이스의 힘 이해 - ✅ copy_to/from_user의 필요성과 원리 - ✅ 문자 디바이스 드라이버 개발 능력 - ✅ 메모리 보안과 사용자-커널 데이터 이동
Level 4: 고성능 네트워킹 (챕터 4) - ✅ Netfilter 아키텍처의 5개 Hook 이해 - ✅ sk_buff의 Zero-Copy 설계 원리 - ✅ Kernel vs User-Space 처리의 성능 차이 정량화 - ✅ 실제 방화벽/프록시 구현 기초
실전 응용 방안¶
- 커널 모니터링: eBPF + Netfilter를 조합한 실시간 패킷 감시
- 고성능 프록시: User-Space 네트워킹 (DPDK, AF_XDP)과 커널 통합
- 보안 강화: LSM (Linux Security Module) 개발
- 성능 최적화: 시스템 콜 감소, 페이지 폴트 최소화, 캐시 친화성 향상
- 가상화 개선: Namespace/Cgroup 확장, KVM 최적화
부록: 자주 묻는 질문¶
Q1: Docker Container가 진짜 격리되어 있나요? A: Namespace로는 격리되지만, **커널을 공유**합니다. 따라서: - 커널 취약점은 모든 컨테이너에 영향 - Kernel bypass (seccomp)로 추가 보안 가능 - 완전한 보안을 원하면 VM 사용 필요
Q2: System Call이 왜 그렇게 비싼가요? A: Context Switch 때문: - TLB flush (Translation Lookaside Buffer) - CPU 캐시 재구성 - 레지스터 저장/복원 - 페이지 테이블 전환
Q3: Kernel Module vs built-in code의 차이는? A: 성능/기능상 **동일**하지만, LKM은: - 런타임 로드/언로드 가능 - 커널 재부팅 불필요 - 개발 사이클 단축 - 메모리 절약 (필요할 때만 로드)
Q4: Netfilter가 진짜 빠른가요? A: 측정 기반: - L2 캐시 타중률: ~95% (패킷 헤더는 작음) - 패킷당 ~260 ns (확인됨) - Context Switch 없음 (0 비용) - 초당 ~380만 packets (현대 CPU 기준)
추천 실습¶
-
LKM 개발
-
문자 디바이스 드라이버
-
Netfilter 방화벽
-
성능 벤치마크
문서 작성자 노트: 이 자료는 리눅스 커널의 핵심 개념을 **실무 관점**에서 설명하도록 작성되었습니다. 이론만이 아닌 "왜 이렇게 설계되었고, 실제로 어떻게 작동하며, 어떤 성능 영향이 있는지"에 초점을 맞췄습니다. 각 챕터의 코드 예제는 실제 커널 소스에서 단순화되었으므로, 깊이 있는 학습을 원한다면 다음을 참고하세요:
- 커널 소스: https://github.com/torvalds/linux
- Linux Kernel Labs: linux-kernel-labs.github.io
- LWN.net: 커널 개발 뉴스 및 심층 분석
- Linux Foundation Documentation: kernel.org/doc
마지막 조언¶
리눅스 커널 개발은 **시스템의 모든 계층을 이해**하도록 강제합니다. 하드웨어, CPU 아키텍처, 메모리 관리, I/O 처리, 네트워킹까지—모두 연결되어 있습니다. 이 지식은:
- 일반 애플리케이션 개발을 더 깊이 있게 합니다
- 성능 병목을 직관적으로 파악하게 합니다
- **시스템 설계의 trade-off**를 이해하게 합니다
- 보안 취약점을 사전에 예방하게 합니다
꾸준한 학습과 실험을 통해, 당신도 **리눅스 시스템의 마스터**가 될 수 있습니다. 화이팅!