Linux Kernel 핵심 흐름: System Call부터 동기화까지
이 글은 Linux 커널을 처음 공부하는 사람이 “애플리케이션의 한 줄이 커널과 하드웨어까지 어떻게 이어지는가?”를 따라가도록 만든 학습 기록이다. System Call, Process/Thread, Scheduler, Sleep/Wake-up, Interrupt, Lock, Atomic, Memory Ordering을 하나의 흐름으로 연결한다.
읽는 방법 / How to read
새 용어는 바로 옆에서 쉬운 말로 설명하고, 그 다음 실제 커널 구조와 임베디드 예제로 내려간다. 세부적인 Device Tree와 플랫폼 드라이버 작성 코드는 별도 글에서 이어진다.
1. 애플리케이션 한 줄은 어디로 가는가? / Where does one application call go?
일상적인 비유로 시작해 보자. 식당에서 주문서를 내면 손님이 주방 기계를 직접 조작하지 않는다. 주문 창구가 요청을 받고, 주방이 적절한 조리 도구를 선택한 뒤, 음식이 다시 손님에게 전달된다. Linux에서도 애플리케이션은 하드웨어 레지스터를 직접 만지지 않고 커널에 서비스를 요청한다.
Application
│ open() / read() / write() / ioctl()
▼
libc wrapper 또는 직접 syscall
▼
System-call entry (user → kernel privilege transition)
▼
VFS (Virtual File System)
▼
파일시스템 또는 device driver
▼
MMIO / DMA / storage / network hardware
System Call은 사용자 공간 프로그램이 커널 기능을 요청하는 공식 진입점이다. read()를 호출할 때 우리가 보는 함수는 보통 libc가 제공하는 래퍼(wrapper)다. 래퍼는 CPU의 시스템 콜 진입 명령을 실행하고, 커널은 요청 번호와 인자를 확인한 뒤 적절한 커널 경로로 보낸다. libc를 거치지 않고 syscall(2)을 사용할 수도 있지만, 일반 애플리케이션에서는 libc API를 사용하는 것이 보통이다.
CPU에는 사용자 코드와 커널 코드를 구분하는 권한 수준이 있다. x86에서는 흔히 Ring 3와 Ring 0으로 설명하고, 다른 아키텍처에서는 다른 이름과 세부 규칙을 사용한다. 중요한 점은 애플리케이션이 임의 주소와 특권 명령을 실행하지 못하도록 CPU가 경계를 제공한다는 것이다.
VFS는 왜 필요한가? / Why VFS?
**VFS(Virtual File System)**는 서로 다른 파일시스템과 장치 드라이버를 “파일처럼” 보이게 하는 커널 공통 계층이다. 그래서 애플리케이션은 ext4 파일을 읽을 때와 UART device file을 읽을 때 모두 read(fd, ...)라는 형태를 사용할 수 있다. 실제 목적지는 파일 객체의 연산 테이블과 연결된 파일시스템 또는 드라이버가 결정한다.
# 관찰에 유용한 예
strace -e trace=openat,read,write,ioctl ./app
cat /proc/self/status
strace는 애플리케이션이 어떤 시스템 콜을 요청하는지 보여 주고, /proc은 커널이 제공하는 프로세스 관찰 창이다. 드라이버를 공부할 때 사용자 API에서 커널 내부로 내려가는 첫 번째 실마리가 된다.
2. Program, Process, Thread, 그리고 task_struct / Program, process, thread, and task_struct
디스크에 있는 실행 파일은 Program이다. 프로그램을 실행하면 주소 공간, 열린 파일, 보안 정보, 실행 상태를 가진 살아 있는 객체인 Process가 된다. 같은 프로세스 안에서 메모리와 파일을 공유하면서 각자 실행 위치를 갖는 흐름이 Thread다.
Process가 공유하는 것
code / global data / heap
address space / file descriptors
각 Thread가 따로 가지는 것
stack / CPU registers / program counter
Linux kernel
Process와 Thread를 모두 task_struct로 관리
Linux에는 “스레드 전용 핵심 구조체”가 따로 있는 것이 아니라, 스케줄러가 실행 단위를 task_struct로 관리한다. fork()는 새로운 프로세스 생성에 가깝고, clone()은 주소 공간·파일·시그널 핸들러 등을 어떤 범위로 공유할지 선택할 수 있게 한다. 사용자 공간의 스레드 라이브러리는 결국 이 커널 기능을 조합한다.
**PCB(Process Control Block)**라는 용어는 프로세스 관리에 필요한 정보를 모아 둔 블록이라는 운영체제 개념이다. Linux에서 그 역할의 중심에 있는 구조체가 task_struct이며, 실제 커널 버전의 필드와 내부 배치는 계속 바뀔 수 있으므로 특정 필드 목록을 영구적인 ABI처럼 외우면 안 된다.
3. Scheduler와 Context Switch / Scheduler and context switches
Scheduler는 지금 실행 가능한 task 중 다음에 CPU를 사용할 task를 고르는 커널 서브시스템이다. 논리 CPU 하나는 한 순간에 하나의 instruction stream을 실행하므로, 여러 task를 짧은 단위로 교체해 동시에 실행되는 것처럼 보이게 한다. 실제 멀티코어에서는 여러 논리 CPU가 병렬로 실행한다.
Task A 실행
↓ timer tick 또는 block/unblock
현재 레지스터·실행 위치 저장
↓
Task B의 저장된 context 복원
↓
Task B 실행
Context switch는 CPU 레지스터, 스택 포인터, 프로그램 카운터 등 실행에 필요한 상태를 저장하고 다른 task의 상태를 복원하는 과정이다. 전환 자체의 저장·복원 비용뿐 아니라 캐시와 TLB의 지역성이 깨져 다음 코드가 느려질 수도 있다. 그렇기 때문에 “스레드 전환은 무조건 공짜로 빠르다”거나 “프로세스 전환은 항상 매우 느리다”처럼 단정하면 안 된다. 실제 비용은 공유 주소 공간, 아키텍처, 캐시 상태, 작업량에 따라 달라진다.
Run queue, CFS, EEVDF / Run queues, CFS, and EEVDF
Run queue는 지금 CPU에서 실행될 수 있는(runnable) task가 대기하는 자료구조다. 잠든 task나 I/O를 기다리는 task는 당장 실행할 수 없으므로 run queue에 계속 남아 있지 않는다. CPU마다 run queue를 두면 하나의 전역 큐를 모든 CPU가 잠그고 경쟁하는 비용을 줄일 수 있고, 한 CPU에 일이 몰리면 load balancing이 task를 옮긴다.
예전 설명에서 자주 등장한 **CFS(Completely Fair Scheduler)**는 task가 사용한 CPU 시간을 vruntime으로 추적해 덜 실행된 task에 기회를 주는 공정성 모델이다. 최근 일반 스케줄링을 읽을 때는 이 개념이 EEVDF(Earliest Eligible Virtual Deadline First) 방식으로 발전했다는 점을 함께 봐야 한다. 따라서 “항상 가장 작은 vruntime만 고른다”는 설명은 역사적 직관으로는 유용하지만, 최신 커널의 전체 선택 규칙을 모두 표현하지는 않는다.
nice 값은 일반 task의 상대적인 CPU 비중에 영향을 주는 입력 중 하나다. 실시간 스케줄링 클래스와 일반 fair 클래스는 선택 규칙이 다르므로, 모든 task를 CFS 한 가지 공식으로 설명하려고 하면 혼란이 생긴다.
4. Sleep/Wake-up과 Wait Queue / Sleep, wake-up, and wait queues
데이터가 올 때까지 while (data_not_ready) {}를 반복하면 CPU를 계속 쓰는 busy waiting이 된다. Linux는 기다리는 task를 잠재우고, 조건이 만족되면 깨우는 방식을 선호한다. 잠든 task는 runnable이 아니므로 CPU를 양보한다.
Application read()
↓ 데이터 없음
wait_event_interruptible(queue, data_ready)
↓
TASK_INTERRUPTIBLE / run queue에서 제외
↓ UART 또는 CAN IRQ
driver가 buffer 저장
↓ wake_up_interruptible(&queue)
Runnable → scheduler 선택 → read() 재개
Wait queue는 특정 조건이 만족되기를 기다리는 task를 연결해 두는 커널 자료구조다. wait_event_interruptible()는 조건을 확인하고, 만족하지 않으면 현재 task를 잠재운다. 인터럽트나 다른 실행 흐름은 데이터를 준비한 뒤 wake_up_interruptible()을 호출한다. 실제 코드는 잠자기 전후의 조건 확인과 공유 데이터 보호를 함께 설계해야 하며, 단순히 “wake_up을 부르면 언젠가 된다”고 생각하면 안 된다.
| 상태 / State | 쉬운 의미 / Plain meaning | 주의점 / Note |
|---|---|---|
TASK_RUNNING | 실행 중이거나 실행 가능한 상태 | run queue의 후보가 될 수 있음 |
TASK_INTERRUPTIBLE | 신호로 깨어날 수 있는 대기 | 반환 시 신호 오류를 처리해야 할 수 있음 |
TASK_UNINTERRUPTIBLE | 일반 신호에 바로 반응하지 않는 대기 | 오래 지속되면 원인과 I/O 경로를 조사 |
스스로 블록한 뒤 전환하는 것을 voluntary context switch라고 부르고, 타이머나 우선순위 정책 때문에 강제로 교체되는 경우를 involuntary context switch라고 부른다. 실행 중인 프로세스에서 /proc/self/status의 voluntary_ctxt_switches와 nonvoluntary_ctxt_switches를 관찰해 볼 수 있다.
5. Interrupt: Top Half와 Bottom Half / Interrupts: top half and deferred work
인터럽트는 하드웨어가 CPU에 “지금 처리할 사건이 있다”고 알리는 신호다. UART RX, CAN frame, 네트워크 packet, DMA completion 같은 사건이 대표적이다. 인터럽트 핸들러가 너무 오래 머무르면 다음 이벤트를 놓칠 수 있으므로, 빠른 처리와 늦춰도 되는 처리를 나눈다.
Hardware event
↓
Hard IRQ handler (짧게: 상태 확인·ack·buffer 이동)
↓
Softirq / workqueue / threaded IRQ
↓
패킷 파싱·sleep 가능한 처리·사용자 공간 wake-up
Hard IRQ와 SoftIRQ/tasklet은 원자적 실행 맥락이므로 일반적으로 sleep할 수 없다. Workqueue는 커널 worker thread에서 실행되므로 sleep 가능한 작업과 mutex를 사용할 수 있다. Threaded IRQ는 아주 짧은 hard handler와 별도의 IRQ thread를 조합해 긴 처리를 thread context로 옮긴다. Tasklet은 역사적으로 쓰였지만 새 설계에서는 workqueue나 threaded IRQ 등 현재 커널의 권장 패턴을 먼저 검토한다.
예를 들어 CAN 드라이버는 hard IRQ에서 수신 상태를 확인하고 FIFO를 비운 뒤, 네트워크 계층에서 처리할 work를 예약한다. UART character driver는 수신 데이터를 ring buffer에 넣고 wait queue의 reader를 깨운다. 이 흐름을 알면 “애플리케이션이 직접 인터럽트를 처리한다”는 오해를 피할 수 있다.
6. 동시성: Spinlock, Mutex, Atomic / Concurrency: spinlocks, mutexes, and atomics
공유 queue를 Process context와 IRQ context가 함께 수정한다고 하자. 두 실행 흐름이 동시에 읽고 쓰면 race condition이 생겨 데이터가 사라지거나 리스트가 깨질 수 있다. 락의 목적은 “데드락을 자동으로 없애는 것”이 아니라 공유 상태를 한 번에 한 규칙으로 접근하게 만드는 것이다.
| 도구 / Tool | 언제 / When | 핵심 동작 / Behavior |
|---|---|---|
| Spinlock | 짧고 sleep하지 않는 critical section | lock이 풀릴 때까지 CPU에서 바쁜 대기 |
| Mutex | Process context에서 오래 걸리거나 sleep 가능 | 기다리는 task가 잠들 수 있음 |
atomic_t | 카운터 증가·감소 같은 단순 연산 | 연산 자체를 쪼개지지 않게 처리 |
| Memory barrier | 서로 다른 CPU·장치가 보는 순서가 중요 | 가시성과 순서 제약을 표현 |
/* IRQ와 일반 코드가 같은 queue를 만지는 경우의 예 */
spin_lock_irqsave(&queue_lock, flags);
/* 아주 짧은 queue 수정 */
spin_unlock_irqrestore(&queue_lock, flags);
spin_lock_irqsave()는 현재 CPU의 인터럽트 상태를 저장하고, 필요한 경우 local IRQ를 막은 뒤 spinlock을 획득한다. 이렇게 해야 같은 CPU에서 IRQ가 들어와 이미 잡은 lock을 다시 기다리는 상황을 피할 수 있다. 다른 CPU의 접근을 막는 역할은 spinlock이 담당하고, local IRQ 제어는 별도의 문제를 해결한다.
Spinlock을 잡은 채 잠들지 않기 / Never sleep while holding a spinlock
CPU0이 spinlock을 잡고 sleep하면 CPU1은 lock이 풀릴 때까지 계속 회전한다. 그런데 lock을 풀 주체인 CPU0이 잠들어 있으므로 CPU 낭비를 넘어 교착 상태가 된다. sleep 가능한 작업은 lock을 풀고 mutex 또는 workqueue 영역으로 옮긴다.
Atomic operation은 count++처럼 하나의 값에 대한 단순 연산을 원자적으로 처리한다. 하지만 atomic counter가 다른 구조체 필드의 순서까지 보장하는 것은 아니다. 객체의 수명 참조에는 일반 카운터보다 refcount_t가 더 적절한 경우가 있고, 실제 선택은 커널 API와 자료구조의 의미를 기준으로 해야 한다.
7. CPU Cache와 Memory Ordering / CPU caches and memory ordering
멀티코어에서 “내 코드가 위에서 아래로 쓰였다”는 사실만으로 다른 CPU나 장치가 같은 순서로 본다고 가정하면 안 된다. 컴파일러 재배치, CPU의 out-of-order 실행, store buffer, 각 코어의 cache가 있기 때문이다. Cache coherency는 같은 주소의 최신 값을 맞추는 문제이고, memory ordering은 여러 접근이 관찰되는 순서를 정하는 문제라서 서로 같은 말이 아니다.
/* producer */
data = 100;
smp_store_release(&ready, 1);
/* consumer */
if (smp_load_acquire(&ready) == 1)
use(data);
Release는 앞에서 준비한 데이터를 공개하기 전에 정리한다는 의미이고, Acquire는 준비 완료 표시를 관찰한 뒤 데이터를 읽는다는 의미다. Acquire가 Release가 공개한 값을 실제로 읽는 동기화 경로가 있어야 두 작업 사이에 happens-before 관계가 만들어진다. 단순히 변수 두 개에 각각 atomic을 붙이는 것만으로는 프로토콜이 완성되지 않는다.
MMIO와 DMA doorbell / MMIO and a DMA doorbell
NPU나 네트워크 장치의 드라이버는 일반 RAM에 descriptor를 작성한 뒤 MMIO doorbell을 써서 장치에 알리는 경우가 많다. 장치가 doorbell을 먼저 보고 descriptor를 읽으면 안 되므로, 장치 프로토콜과 DMA API에 맞는 ordering을 명시한다.
desc->addr = dma_addr;
desc->len = length;
/* 정확한 barrier는 DMA API와 하드웨어 프로토콜에 맞춘다 */
dma_wmb();
writel(QUEUE_START, regs + DOORBELL);
readl()/writel()은 일반 RAM 포인터 역참조를 대신하는 architecture-aware MMIO accessor다. writel_relaxed() 같은 relaxed accessor는 더 약한 ordering을 의도적으로 사용하므로, “더 빠르다”는 이유만으로 바꾸면 안 된다. MMIO ordering과 DMA buffer visibility는 장치 매뉴얼, Linux DMA API, 해당 아키텍처의 accessor 규칙을 함께 확인해야 한다.
8. 이 개념들이 드라이버에서 만나는 한 장면 / How the pieces meet in a driver
앞의 개념을 UART나 CAN 같은 장치 하나에 겹쳐 보자.
Device Tree의 resource
↓ compatible matching
platform_driver.probe()
↓ MMIO mapping / clock / regulator / IRQ
file_operations 또는 기존 subsystem 등록
↓
Application read()
↓ system call → VFS → driver.read()
데이터 없음 → wait queue에서 sleep
↓
Hardware IRQ → 짧은 handler → buffer 저장
↓ spinlock으로 queue 보호
wake_up() → task runnable → scheduler
↓
read()가 사용자 공간으로 데이터 반환
이 흐름에서 Device Tree와 플랫폼 드라이버는 장치를 발견하고 자원을 연결하는 앞부분을 담당한다. System Call/VFS는 사용자 API를 드라이버 연산으로 연결한다. IRQ·wait queue·lock·barrier는 데이터가 비동기적으로 도착하고 여러 실행 흐름이 공유할 때 정확성과 성능을 지킨다.
실제 DTS와 probe() 코드, reg 해석, clock/regulator/IRQ 자원 획득은 Device Tree와 Linux 플랫폼 드라이버 작성 입문에서 단계별로 볼 수 있다. eMMC U-Boot 명령과 실제 파형 관찰은 UUU·U-Boot·eMMC 파형 실습 글로 분리했다.
9. 직접 확인해 보는 명령과 읽을 코드 / Commands and source-reading exercises
strace -e trace=%file,%desc ./app로 파일·device file 접근과 시스템 콜을 관찰한다.cat /proc/self/status에서 context-switch counter를 확인한다.cat /proc/interrupts에서 UART, CAN, USB, MMC 인터럽트 카운터가 증가하는지 본다.dmesg에서 드라이버의 probe, IRQ, defer 로그를 시간 순서로 읽는다.- 커널 소스에서
struct task_struct,schedule(),wait_event_interruptible(),request_threaded_irq(),spin_lock_irqsave()를 찾아 선언과 호출자를 함께 본다.
실습 질문 / Practice questions
read()가 데이터가 없을 때 busy waiting 대신 sleep하는 이유는 무엇인가?- IRQ handler와 process context가 같은 queue를 만질 때 mutex 대신 어떤 조합을 검토해야 하는가?
atomic_inc()가 안전해도 왜 별도의 memory ordering이 필요할 수 있는가?- DMA descriptor를 작성한 뒤 doorbell을 울릴 때 어떤 주체가 어떤 순서를 관찰해야 하는가?
핵심 정리 / Key takeaways
- System Call은 사용자 공간과 커널 사이의 통제된 진입점이고, VFS는 파일과 device file을 공통 모델로 연결한다.
- Linux는 Process와 Thread를
task_struct기반의 실행 단위로 관리하며, Scheduler는 runnable task를 선택한다. - Run queue와 context switch를 이해하면 CPU 공유, sleep/wakeup, scheduler 로그를 하나의 그림으로 볼 수 있다.
- Hard IRQ는 짧게 끝내고, sleep 가능한 처리는 workqueue나 threaded IRQ로 미룬다.
- Spinlock, Mutex, Atomic, Barrier는 서로 다른 문제를 해결한다. 하나를 다른 하나의 대체품처럼 외우면 안 된다.
- MMIO와 DMA ordering은 CPU cache coherency와 별개의 문제이며, accessor·DMA API·하드웨어 프로토콜을 함께 읽어야 한다.
공식 참고 자료 / Official references
- Linux kernel API documentation
- Kernel locking documentation
- Linux kernel memory barriers
- Dynamic DMA mapping Guide
- Driver basics and wait queues
- Linux platform devices and drivers
#LinuxKernel
#SystemCall
#Process
#Thread
#Scheduler
#Interrupt
#Spinlock
#MemoryBarrier
#BSP