Skip to content

필사 모드: 시스템 콜 하나를 끝까지 따라가기 — read가 커널 안에서 지나는 길

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며 — 이 시리즈의 척추

2편에서 read 시스템 콜의 구현 파일을 찾아내는 데까지 갔습니다. 이 글은 그 파일을 열어서 끝까지 읽습니다.

이 블로그에는 시스템 콜을 다루는 글이 이미 하나 있습니다. 느린 건 CPU가 아니라 시스템 콜 경로였다는 시스템 콜 경로의 비용을 다룹니다. 호환 계층을 거칠 때 그 경로가 얼마나 비싸지는지, 그래서 실행 경로를 바꾸는 것이 왜 코드를 고치는 것보다 효과적인지에 대한 이야기입니다. 이 글은 같은 경로를 성능이 아니라 코드로 봅니다. 그 경로 위에 어떤 함수들이 실제로 놓여 있는지를 파일 이름과 줄 단위로 확인합니다.

목표는 하나입니다. 이 글을 다 읽고 나면 "커널 코드는 읽을 수 있는 것"이라는 감각이 생기는 것입니다. 어렵기는 하지만 마법은 아니고, 대부분은 그냥 C입니다.

기준 버전: 아래의 모든 코드와 경로는 2026년 8월 19일에 메인라인 트리에서 직접 확인했습니다. 그 시점의 최상위 Makefile은 버전을 7.2로 표시하고 있었습니다. 커널 버전이 다르면 줄 번호와 세부 구현이 달라질 수 있으므로, 파일 이름과 함수 이름을 기준으로 삼고 2편의 방법으로 다시 찾으시기 바랍니다. 실제로 이 경로는 최근 몇 년 사이에도 여러 번 이동했습니다.

1. 왜 read를 고르는가

시스템 콜 300여 개 중 read를 고른 이유는 셋입니다.

첫째, 모든 사람이 이미 써 봤습니다. 무엇을 하는 함수인지 설명할 필요가 없으니 순수하게 경로만 볼 수 있습니다.

둘째, 적당히 간단합니다. 인자가 셋뿐이고 반환값이 하나입니다. 그런데도 사용자 포인터 처리, 파일 디스크립터 조회, 함수 포인터 디스패치 같은 커널의 핵심 관용구가 전부 나옵니다.

셋째, 갈라지는 지점이 명확합니다. 초반은 파일시스템과 무관한 공통 경로이고, 어느 지점부터 ext4나 소켓이나 파이프로 갈라집니다. 그 분기점을 눈으로 보는 것 자체가 VFS라는 개념을 이해하는 가장 빠른 길입니다.

2. 사용자 공간에서 커널로

C 코드의 read는 시스템 콜이 아닙니다

프로그램에서 read(fd, buf, n)을 호출하면 먼저 실행되는 것은 커널 코드가 아니라 C 라이브러리의 래퍼 함수입니다. 이 래퍼가 하는 일은 인자를 규약대로 레지스터에 넣고 CPU 명령 하나를 실행하는 것입니다.

x86_64에서 그 규약은 arch/x86/entry/entry_64.S의 주석에 그대로 적혀 있습니다.

 * Registers on entry:
 * rax  system call number
 * rcx  return address
 * r11  saved rflags (note: r11 is callee-clobbered register in C ABI)
 * rdi  arg0
 * rsi  arg1
 * rdx  arg2
 * r10  arg3 (needs to be moved to rcx to conform to C ABI)
 * r8   arg4
 * r9   arg5

여기서 눈여겨볼 것이 하나 있습니다. 네 번째 인자가 일반적인 C 호출 규약의 rcx가 아니라 r10입니다. 주석이 그 이유를 설명합니다. rcx는 시스템 콜 명령이 복귀 주소를 저장하는 데 써 버리기 때문입니다. 그래서 커널 진입 코드는 r10rcx로 옮겨 C 규약에 맞춰 주는 작업을 합니다.

read의 시스템 콜 번호는 0입니다. arch/x86/entry/syscalls/syscall_64.tbl의 첫 항목이 이렇게 되어 있습니다.

0	common	read			sys_read
1	common	write			sys_write
2	common	open			sys_open
3	common	close			sys_close

커널 쪽 첫 C 함수

CPU가 특권 수준을 바꾸고 커널 진입점으로 점프하면, 잠깐의 어셈블리 구간을 지나 첫 C 함수에 도착합니다. 그 함수가 do_syscall_64이고, 제가 확인한 트리에서 위치는 arch/x86/entry/syscall_64.c였습니다.

여기서 1편에서 예고한 버전 문제가 실제로 나옵니다. 이 함수는 오랫동안 arch/x86/entry/common.c에 있었고, 그렇게 설명하는 자료가 아직도 많습니다. 자료가 틀린 것이 아니라 그 뒤에 파일이 갈라진 것입니다. 함수 이름으로 찾으면 어느 버전에서든 나옵니다.

함수의 앞부분은 이렇습니다.

/* Returns true to return using SYSRET, or false to use IRET */
__visible noinstr bool do_syscall_64(struct pt_regs *regs, int nr)
{
	nr = syscall_enter_from_user_mode(regs, nr);

	instrumentation_begin();
	add_random_kstack_offset();

	if (!do_syscall_x64(regs, nr) && !do_syscall_x32(regs, nr) && nr != -1) {
		/* Invalid system call, but still a system call. */
		regs->ax = __x64_sys_ni_syscall(regs);
	}

	instrumentation_end();
	syscall_exit_to_user_mode(regs);

네 가지를 짚겠습니다.

  • struct pt_regs *regs — 진입 시점에 저장해 둔 레지스터 전부입니다. 시스템 콜 인자도 여기 들어 있습니다.
  • syscall_enter_from_user_modesyscall_exit_to_user_mode — 커널 진입과 복귀에 딸린 부수 작업을 담당합니다. 추적점, seccomp 필터, 시그널 처리, 스케줄링 기회 같은 것들이 여기서 처리됩니다. 컨테이너의 seccomp 프로파일이 시스템 콜을 막는 지점도 이 구간입니다. 컨테이너는 거짓말이다에서 다룬 seccomp가 실제로 개입하는 곳이 여기입니다.
  • add_random_kstack_offset — 커널 스택 오프셋을 무작위화하는 보안 기능입니다.
  • 주석의 SYSRET와 IRET — 커널에서 사용자 공간으로 돌아가는 방법이 둘이고, 어느 쪽을 쓸지 이 함수가 판정합니다. 함수 뒷부분은 대부분 그 판정 로직인데, 흥미롭게도 그 조건 중 상당수가 CPU 버그 회피입니다.

번호를 함수로 바꾸기

같은 파일 안의 do_syscall_x64가 실제 디스패치를 합니다.

	unsigned int unr = nr;

	if (likely(unr < NR_syscalls)) {
		unr = array_index_nospec(unr, NR_syscalls);
		regs->ax = x64_sys_call(regs, unr);
		return true;
	}
	return false;

여기서 두 가지가 눈에 띕니다.

array_index_nospec. 범위 검사를 이미 했는데 왜 또 필요한가 싶지만, 이건 Spectre 계열 투기 실행 공격에 대한 방어입니다. CPU가 범위 검사를 투기적으로 건너뛰고 배열에 접근할 수 있기 때문에, 하드웨어 수준에서 인덱스를 강제로 범위 안에 가두는 장치가 따로 필요합니다.

x64_sys_call이 배열 조회가 아니라 switch 문입니다. 같은 파일에 이렇게 정의돼 있습니다.

#define __SYSCALL(nr, sym) case nr: return __x64_##sym(regs);
long x64_sys_call(const struct pt_regs *regs, unsigned int nr)
{
	switch (nr) {
	#include <asm/syscalls_64.h>
	default: return __x64_sys_ni_syscall(regs);
	}
}

헤더를 매크로 정의와 함께 포함해서 수백 개의 case 문을 생성하는 방식입니다. 커널에서 자주 보게 되는 관용구이고, 처음 보면 당황스럽습니다. 같은 헤더를 다른 매크로 정의로 다시 포함하면 이번에는 함수 포인터 배열 sys_call_table이 만들어집니다. 하나의 목록에서 여러 형태를 뽑아내는 것입니다.

3. SYSCALL_DEFINE 매크로가 실제로 만드는 것

여기가 이 글에서 가장 중요한 절입니다. 커널을 처음 읽는 사람의 절반은 여기서 막힙니다.

앞 절에서 진입점 이름이 __x64_sys_read라는 것을 봤습니다. 그런데 소스 어디에도 그 이름의 함수 정의가 없습니다. 실제 소스에 있는 것은 fs/read_write.c의 이 세 줄뿐입니다.

SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
	return ksys_read(fd, buf, count);
}

이 매크로가 무엇을 만드는지 따라가 봅시다. 공통 정의는 include/linux/syscalls.h에 있습니다.

#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)

#define SYSCALL_DEFINEx(x, sname, ...)				\
	SYSCALL_METADATA(sname, x, __VA_ARGS__)			\
	__SYSCALL_DEFINEx(x, sname, __VA_ARGS__)

같은 파일의 주석이 핵심을 요약해 줍니다.

/*
 * The asmlinkage stub is aliased to a function named __se_sys_*() which
 * sign-extends 32-bit ints to longs whenever needed. The actual work is
 * done within __do_sys_*().
 */

즉 함수가 세 겹으로 갈립니다.

  1. 바깥 스텁 — 진입 경로가 호출하는 함수
  2. __se_sys_* — 인자를 부호 확장하고 검증하는 중간 래퍼
  3. __do_sys_* — 실제 본문. 우리가 소스에 쓴 그 코드입니다.

x86에서는 여기에 한 겹이 더 붙습니다. arch/x86/include/asm/syscall_wrapper.h가 공통 매크로를 덮어씁니다.

#define __SYSCALL_DEFINEx(x, name, ...)					\
	static long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__));	\
	static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__));\
	__X64_SYS_STUBx(x, name, __VA_ARGS__)				\
	__IA32_SYS_STUBx(x, name, __VA_ARGS__)				\
	...

그리고 그 스텁의 정체는 이렇습니다.

#define __SYS_STUBx(abi, name, ...)					\
	long __##abi##_##name(const struct pt_regs *regs);		\
	ALLOW_ERROR_INJECTION(__##abi##_##name, ERRNO);			\
	long __##abi##_##name(const struct pt_regs *regs)		\
	{								\
		return __se_##name(__VA_ARGS__);			\
	}

인자로 넘어가는 것이 SC_X86_64_REGS_TO_ARGS인데, 이 매크로가 하는 일은 이겁니다.

#define SC_X86_64_REGS_TO_ARGS(x, ...)					\
	__MAP(x,__SC_ARGS						\
		,,regs->di,,regs->si,,regs->dx				\
		,,regs->r10,,regs->r8,,regs->r9)			\

2절에서 본 레지스터 규약이 여기서 코드가 됩니다. regs->di가 첫 번째 인자, regs->si가 두 번째, 하는 식입니다.

정리하면, 우리가 쓴 세 줄에서 이런 이름들이 생성됩니다.

생성되는 이름역할
__x64_sys_readx86_64 진입 스텁. pt_regs 하나만 받습니다
__ia32_sys_read32비트 호환 진입 스텁
__se_sys_read인자 부호 확장 래퍼
__do_sys_read실제 본문. 우리가 쓴 코드

그러니 sys_read를 grep해서 안 나오는 것은 당연합니다. 이 이름들은 전부 전처리기가 토큰을 이어 붙여 만들어 낸 것이고, 소스 텍스트에는 존재하지 않습니다.

왜 이렇게 복잡한가. 이유는 보안입니다. 예전에는 진입 코드가 레지스터를 그대로 C 함수 인자로 넘겼는데, 이 경우 사용하지 않는 상위 비트에 사용자가 넣어 둔 값이 커널 안으로 그대로 흘러들어 갈 수 있었습니다. pt_regs 하나만 받는 스텁을 두고 거기서 필요한 인자만 꺼내 쓰는 지금 방식은 그 통로를 막습니다.

4. ksys_read — 파일 디스크립터를 파일로

이제 실제 본문입니다. fs/read_write.c에 있습니다.

ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count)
{
	CLASS(fd_pos, f)(fd);
	ssize_t ret = -EBADF;

	if (!fd_empty(f)) {
		loff_t pos, *ppos = file_ppos(fd_file(f));
		if (ppos) {
			pos = *ppos;
			ppos = &pos;
		}
		ret = vfs_read(fd_file(f), buf, count, ppos);
		if (ret >= 0 && ppos)
			fd_file(f)->f_pos = pos;
	}
	return ret;
}

열 줄 남짓인데 커널의 관용구가 여럿 들어 있습니다.

CLASS(fd_pos, f)(fd) — 파일 디스크립터 번호로 실제 파일 객체를 얻어 오면서, 동시에 이 스코프를 벗어날 때 자동으로 참조를 놓아 주는 장치입니다. C에는 소멸자가 없으니 커널이 컴파일러 확장으로 비슷한 것을 만들어 쓰는 것입니다. 오래된 커널 코드를 보면 같은 일을 명시적인 획득과 해제 쌍으로 처리하는 형태가 나오는데, 그 방식은 중간에 return이 하나만 늘어도 누수가 나기 때문에 이렇게 바뀌었습니다. 이름에 pos가 붙은 이유는 파일 오프셋에 대한 잠금까지 함께 처리하기 때문입니다.

ret = -EBADF로 시작합니다 — 실패를 기본값으로 두고, 성공 경로에서만 덮어씁니다. 커널에서 아주 흔한 패턴입니다.

file_ppos — 같은 파일에 정의돼 있고 세 줄뿐입니다.

/* file_ppos returns &file->f_pos or NULL if file is stream */
static inline loff_t *file_ppos(struct file *file)
{
	return file->f_mode & FMODE_STREAM ? NULL : &file->f_pos;
}

파일에 "현재 위치"라는 개념이 있느냐를 묻는 것입니다. 일반 파일에는 있지만 파이프나 소켓에는 없습니다. 없으면 NULL을 돌려주고, 위쪽 코드는 그 경우 오프셋 갱신을 건너뜁니다.

오프셋을 지역 변수에 복사했다가 되돌려 놓는 부분pos = *ppos로 값을 떠서 그 주소를 넘기고, 성공하면 결과를 f_pos에 다시 씁니다. 아래 계층이 오프셋을 마음대로 건드려도 실패한 경우에는 원래 값이 유지되도록 하는 구조입니다.

5. vfs_read — 검사와 분기

한 겹 더 내려갑니다. 같은 파일에 있습니다.

ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
	ssize_t ret;

	if (!(file->f_mode & FMODE_READ))
		return -EBADF;
	if (!(file->f_mode & FMODE_CAN_READ))
		return -EINVAL;
	if (unlikely(!access_ok(buf, count)))
		return -EFAULT;

	ret = rw_verify_area(READ, file, pos, count);
	if (ret)
		return ret;
	if (count > MAX_RW_COUNT)
		count =  MAX_RW_COUNT;

	if (file->f_op->read)
		ret = file->f_op->read(file, buf, count, pos);
	else if (file->f_op->read_iter)
		ret = new_sync_read(file, buf, count, pos);
	else
		ret = -EINVAL;
	if (ret > 0) {
		fsnotify_access(file);
		add_rchar(current, ret);
	}
	inc_syscr(current);
	return ret;
}

이 함수는 세 부분으로 읽으면 깔끔합니다.

첫째, 검사 구간

  • FMODE_READ — 이 파일이 읽기용으로 열렸는가. 쓰기 전용으로 연 파일을 읽으려 하면 여기서 EBADF입니다.
  • FMODE_CAN_READ — 열기 모드와 별개로, 이 파일 종류가 애초에 읽기를 지원하는가.
  • access_ok(buf, count) — 사용자가 준 버퍼 주소가 사용자 공간 범위 안에 있는가. 다음 절에서 자세히 다룹니다.
  • rw_verify_area — 오프셋과 길이가 타당한지, 파일 잠금에 걸리지 않는지 검사합니다.
  • MAX_RW_COUNT — 한 번에 읽을 수 있는 양의 상한입니다. 여기서 조용히 잘라 냅니다. read가 요청한 것보다 적게 읽어 올 수 있다는 사실의 근거 중 하나가 이 두 줄입니다.

둘째, 분기 구간

VFS의 본질이 이 세 줄에 있습니다.

	if (file->f_op->read)
		ret = file->f_op->read(file, buf, count, pos);
	else if (file->f_op->read_iter)
		ret = new_sync_read(file, buf, count, pos);

f_opstruct file_operations 포인터이고, 그 안에는 함수 포인터들이 들어 있습니다. 파일이 열릴 때 파일시스템이 자기 구현을 여기에 채워 넣습니다. ext4로 연 파일과 소켓과 파이프는 여기서 서로 다른 함수로 갑니다.

이 한 줄이 커널의 다형성 전부라고 말해도 과하지 않습니다. C에는 가상 함수가 없으니 구조체 안의 함수 포인터로 같은 일을 합니다. 커널을 읽다가 something->ops->method(...) 형태를 만나면, 그건 "여기서 구현이 갈라진다"는 표시입니다.

두 갈래가 있는 이유는 역사적입니다. read는 오래된 단순 인터페이스이고 read_iter는 나중에 도입된 벡터 기반 인터페이스입니다. 요즘 파일시스템은 대부분 후자를 구현하고, 그래서 실제 읽기는 new_sync_read를 거쳐 반복자 형태로 내려갑니다.

셋째, 사후 처리

  • fsnotify_access — 파일 접근 알림입니다. inotify로 파일 읽기를 감시할 수 있는 근거가 여기입니다.
  • add_rcharinc_syscr — 프로세스별 I/O 통계입니다. 나중에 /proc/PID/io에서 보게 되는 숫자가 여기서 늘어납니다.

current라는 이름을 처음 봤다면 기억해 두세요. 지금 이 CPU에서 실행 중인 태스크를 가리키는 커널의 매크로입니다. 커널 코드 어디서든 쓸 수 있고, 아주 자주 나옵니다.

6. 사용자 포인터는 왜 특별한가

vfs_read의 시그니처에 char __user *buf가 있었습니다. 이 표기를 짚고 넘어가야 합니다.

__user는 컴파일에는 아무 영향이 없습니다. 이 포인터가 사용자 공간 주소라는 표시일 뿐입니다. 의미가 생기는 것은 정적 분석 도구를 돌릴 때입니다. 커널 공식 문서 Documentation/process/submit-checklist.rst가 패치 제출 전에 sparse로 깨끗하게 검사되어야 한다고 요구하는데, 이 검사가 잡아내는 대표적인 실수가 사용자 포인터를 커널 포인터처럼 다루는 것입니다.

# sparse 검사를 켜고 빌드하기
make C=1 fs/read_write.o

왜 그냥 역참조하면 안 되는가. 이유가 여럿입니다.

  • 그 주소가 매핑되어 있지 않을 수 있습니다. 커널이 무방비로 접근하면 커널 모드에서 페이지 폴트가 나고, 처리가 잘못되면 시스템이 멈춥니다.
  • 사용자가 일부러 커널 주소를 넘길 수 있습니다. 검사 없이 읽으면 커널 메모리가 그대로 유출됩니다.
  • 다른 스레드가 동시에 그 매핑을 바꿀 수 있습니다.

그래서 커널은 사용자 메모리에 접근할 때 전용 함수만 씁니다.

함수방향
copy_from_user사용자 공간에서 커널로
copy_to_user커널에서 사용자 공간으로
get_user / put_user작은 스칼라 값 하나
access_ok주소 범위가 사용자 공간인지 검사

include/linux/uaccess.h의 구현을 보면 이 함수들의 앞부분에 might_fault()가 있습니다. 이름 그대로 "여기서 페이지 폴트가 날 수 있다"는 선언이고, 이는 곧 이 함수는 잠들 수 있다는 뜻입니다. 잠들 수 있다는 것이 왜 중대한 제약인지는 7편의 주제입니다.

여기서 정직하게 짚을 것이 하나 있습니다. read 경로를 위에서부터 따라오면서 copy_to_user 호출을 아직 보지 못했습니다. vfs_read는 사용자 버퍼 포인터를 복사하지 않고 그대로 아래로 넘깁니다. 실제 복사는 훨씬 아래, 파일시스템 구현이 페이지 캐시에서 데이터를 꺼낼 때 일어납니다. 데이터가 어디 있는지 아는 계층에서 한 번만 복사하는 것이 효율적이기 때문입니다. 상위 계층이 하는 일은 그 포인터가 정당한지 미리 확인해 두는 것뿐이고, 그게 access_ok의 역할입니다.

copy_to_user가 명시적으로 나오는 더 단순한 예를 보고 싶다면 kernel/sys.cuname 구현이 좋습니다. 구조체 하나를 통째로 복사하고, 실패하면 EFAULT를 돌려주는 교과서적인 형태입니다.

7. 돌아오는 길

읽기가 끝나면 값이 위로 올라갑니다. vfs_read가 반환하고, ksys_read가 오프셋을 갱신하고, __do_sys_read가 반환하고, 스텁이 그 값을 regs->ax에 넣고, do_syscall_64가 SYSRET이냐 IRET이냐를 판정해 사용자 공간으로 돌아갑니다.

여기서 커널의 오래된 규약 하나를 짚어야 합니다. 커널 내부에서 에러는 음수 errno 값으로 표현됩니다. 앞의 코드에서 본 -EBADF, -EINVAL, -EFAULT가 전부 그것입니다. 성공하면 0 이상, 실패하면 음수입니다.

errno 변수와 -1 반환은 커널이 아니라 C 라이브러리가 만들어 내는 것입니다. 라이브러리 래퍼가 음수를 보면 부호를 뒤집어 errno에 넣고 -1을 반환합니다. 그래서 커널 코드에는 errno라는 이름이 나오지 않습니다.

이 규약을 알면 커널 코드가 훨씬 쉽게 읽힙니다. 함수 반환값을 이런 식으로 다루는 코드가 온 천지에 나오기 때문입니다.

	ret = some_kernel_function();
	if (ret)			/* 0이 아니면 에러 */
		return ret;		/* 그대로 위로 전달 */

포인터를 반환하는 함수는 조금 다릅니다. 에러를 포인터 안에 인코딩해 넣고 IS_ERRPTR_ERR로 꺼내 쓰는 방식을 씁니다. 이것도 아주 자주 나오니 이름만 기억해 두면 됩니다.

8. 이 경로를 실행 중에 확인하기

지금까지 읽은 경로가 실제로 그렇게 흘러가는지 눈으로 확인할 수 있습니다. 자세한 것은 6편에서 다루지만, 맛보기로 하나만 보여 드리겠습니다.

# 루트 권한, 그리고 앞 절의 QEMU 가상 머신 안에서 하는 것을 권합니다
cd /sys/kernel/tracing
echo function_graph > current_tracer
echo vfs_read > set_graph_function
echo 1 > tracing_on
cat /etc/hostname > /dev/null      # read를 유발
echo 0 > tracing_on
head -40 trace

출력에는 vfs_read에서 시작해 그 아래로 어떤 함수들이 호출됐는지가 들여쓰기로 나타납니다. 5절에서 본 함수 포인터 분기가 어느 구현으로 갔는지가 여기서 드러납니다. ext4 파일을 읽었는지 파이프를 읽었는지에 따라 결과가 완전히 달라집니다. 직접 두 경우를 비교해 보시길 권합니다.

흔한 함정

sys_read를 grep해서 못 찾고 "커널에 없다"고 결론 내립니다. 3절이 이 함정만을 위해 쓰인 절입니다.

오래된 자료의 파일 경로를 그대로 믿습니다. do_syscall_64의 위치가 대표적입니다. 파일 경로는 참고만 하고 함수 이름으로 찾으세요.

32비트와 64비트 경로를 섞어 봅니다. 같은 시스템 콜이라도 32비트 호환 경로는 별도의 스텁을 거칩니다. 추적 결과에 __ia32_compat_이 붙은 이름이 보인다면 그쪽입니다.

함수 포인터 분기를 정적으로 따라가려 합니다. file->f_op->read_iter가 어느 함수인지는 소스를 읽어서는 확정할 수 없습니다. 파일이 어떻게 열렸느냐에 달려 있기 때문입니다. 이럴 때는 읽기를 멈추고 8절처럼 실행 중인 커널에 물어보는 것이 정답입니다.

모든 시스템 콜이 이만큼 단순하다고 생각합니다. read는 일부러 고른 쉬운 예입니다. ioctl이나 mmap은 분기가 훨씬 많고, 네트워크 계열은 상태 기계가 얽혀 있습니다. 다만 구조는 같습니다. 테이블에서 번호를 찾고, 매크로를 뚫고, 검사를 지나, 함수 포인터로 갈라집니다.

마치며

이 글에서 하고 싶었던 말은 단순합니다. 커널은 읽을 수 있는 코드입니다.

우리가 따라온 경로를 다시 정리하면 이렇습니다. 라이브러리 래퍼가 레지스터에 인자를 싣고 명령을 실행합니다. 진입 코드가 do_syscall_64를 부릅니다. 번호가 switch를 거쳐 __x64_sys_read로 갑니다. 그 스텁이 pt_regs에서 인자를 꺼내 __do_sys_read를 부르고, 그게 우리가 소스에서 본 세 줄입니다. ksys_read가 파일 디스크립터를 파일 객체로 바꾸고 오프셋을 준비합니다. vfs_read가 검사를 마친 뒤 함수 포인터로 파일시스템 구현에 넘깁니다. 결과가 그대로 위로 올라오고, 음수면 에러입니다.

어려운 부분이 없지는 않았습니다. 매크로 전개, 함수 포인터 디스패치, 사용자 포인터 규칙, 그리고 array_index_nospec 같은 하드웨어 방어. 하지만 전부 이유가 있고, 그 이유는 대부분 주석에 적혀 있습니다. 커널은 낯설 뿐 불친절하지는 않습니다.

다음 편에서는 처음으로 커널 안에서 실행되는 코드를 직접 씁니다. 가장 작은 모듈부터 시작합니다.

직접 해보기

이전 편과 다음 편

참고 자료

모든 링크는 2026년 8월 19일에 확인했습니다. 코드 인용은 같은 날 메인라인 트리에서 직접 확인한 것입니다.

현재 단락 (1/237)

[2편](/blog/linux/2026-08-19-kernel-getting-and-navigating-the-source)에서 `read` 시스템 콜의 구현 파일을 찾아내는 데까...

작성 글자: 0원문 글자: 12,284작성 단락: 0/237