/

읽는 방법

새 용어는 바로 옆에서 쉬운 말로 설명하고, 그 다음 실제 커널 구조와 임베디드 예제로 내려갑니다. 세부적인 Device Tree와 플랫폼 드라이버 작성 코드는 별도 글에서 이어집니다.

이 글은 Linux 커널을 처음 공부하는 분이 "애플리케이션의 한 줄이 커널과 하드웨어까지 어떻게 이어지는가?"를 따라갈 수 있도록 만든 학습 기록입니다. System Call, Process/Thread, Scheduler, Sleep/Wake-up, Interrupt, Lock, Atomic, Memory Ordering을 하나의 흐름으로 연결합니다.

1. 애플리케이션 한 줄은 어디로 가는가?

일상적인 비유로 시작해 보겠습니다. 식당에서 주문서를 내면 손님이 주방 기계를 직접 조작하지 않습니다. 주문 창구가 요청을 받고, 주방이 적절한 조리 도구를 선택한 뒤, 음식이 다시 손님에게 전달됩니다. 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는 왜 필요한가?

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입니다.

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는 지금 실행 가능한 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 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

데이터가 올 때까지 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을 부르면 언젠가 된다"고 생각하면 안 됩니다.

상태쉬운 의미주의점
TASK_RUNNING 실행 중이거나 실행 가능한 상태 run queue의 후보가 될 수 있음
TASK_INTERRUPTIBLE 신호로 깨어날 수 있는 대기 반환 시 신호 오류를 처리해야 할 수 있음
TASK_UNINTERRUPTIBLE 일반 신호에 바로 반응하지 않는 대기 오래 지속되면 원인과 I/O 경로를 조사

스스로 블록한 뒤 전환하는 것을 voluntary context switch라고 부르고, 타이머나 우선순위 정책 때문에 강제로 교체되는 경우를 involuntary context switch라고 부릅니다. 실행 중인 프로세스에서 /proc/self/statusvoluntary_ctxt_switchesnonvoluntary_ctxt_switches를 관찰해 볼 수 있습니다.

5. Interrupt: Top Half와 Bottom Half

인터럽트는 하드웨어가 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

공유 queue를 Process context와 IRQ context가 함께 수정한다고 가정해 보겠습니다. 두 실행 흐름이 동시에 읽고 쓰면 race condition이 생겨 데이터가 사라지거나 리스트가 깨질 수 있습니다. 락의 목적은 "데드락을 자동으로 없애는 것"이 아니라 공유 상태를 한 번에 한 규칙으로 접근하게 만드는 것입니다.

도구언제핵심 동작
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을 잡은 채 잠들지 않기

CPU0이 spinlock을 잡고 sleep하면 CPU1은 lock이 풀릴 때까지 계속 회전합니다. 그런데 lock을 풀 주체인 CPU0이 잠들어 있으므로 CPU 낭비를 넘어 교착 상태가 됩니다. sleep 가능한 작업은 lock을 풀고 mutex 또는 workqueue 영역으로 옮깁니다.

Atomic operationcount++처럼 하나의 값에 대한 단순 연산을 원자적으로 처리합니다. 하지만 atomic counter가 다른 구조체 필드의 순서까지 보장하는 것은 아닙니다. 객체의 수명 참조에는 일반 카운터보다 refcount_t가 더 적절한 경우가 있고, 실제 선택은 커널 API와 자료구조의 의미를 기준으로 해야 합니다.

7. CPU Cache와 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

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. 이 개념들이 드라이버에서 만나는 한 장면

앞의 개념을 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. 직접 확인해 보는 명령과 읽을 코드

  1. strace -e trace=%file,%desc ./app로 파일·device file 접근과 시스템 콜을 관찰합니다.
  2. cat /proc/self/status에서 context-switch counter를 확인합니다.
  3. cat /proc/interrupts에서 UART, CAN, USB, MMC 인터럽트 카운터가 증가하는지 봅니다.
  4. dmesg에서 드라이버의 probe, IRQ, defer 로그를 시간 순서로 읽습니다.
  5. 커널 소스에서 struct task_struct, schedule(), wait_event_interruptible(), request_threaded_irq(), spin_lock_irqsave()를 찾아 선언과 호출자를 함께 봅니다.

실습 질문

  1. read()가 데이터가 없을 때 busy waiting 대신 sleep하는 이유는 무엇인가?
  2. IRQ handler와 process context가 같은 queue를 만질 때 mutex 대신 어떤 조합을 검토해야 하는가?
  3. atomic_inc()가 안전해도 왜 별도의 memory ordering이 필요할 수 있는가?
  4. DMA descriptor를 작성한 뒤 doorbell을 울릴 때 어떤 주체가 어떤 순서를 관찰해야 하는가?

핵심 정리

  1. System Call은 사용자 공간과 커널 사이의 통제된 진입점이고, VFS는 파일과 device file을 공통 모델로 연결합니다.
  2. Linux는 Process와 Thread를 task_struct 기반의 실행 단위로 관리하며, Scheduler는 runnable task를 선택합니다.
  3. Run queue와 context switch를 이해하면 CPU 공유, sleep/wakeup, scheduler 로그를 하나의 그림으로 볼 수 있습니다.
  4. Hard IRQ는 짧게 끝내고, sleep 가능한 처리는 workqueue나 threaded IRQ로 미룹니다.
  5. Spinlock, Mutex, Atomic, Barrier는 서로 다른 문제를 해결합니다. 하나를 다른 하나의 대체품처럼 외우면 안 됩니다.
  6. MMIO와 DMA ordering은 CPU cache coherency와 별개의 문제이며, accessor·DMA API·하드웨어 프로토콜을 함께 읽어야 합니다.

공식 참고 자료

#LinuxKernel #SystemCall #Process #Thread #Scheduler #Interrupt #Spinlock #MemoryBarrier #BSP

How to read this article

Each new term is introduced in plain language before connecting it to kernel internals and embedded examples. Detailed Device Tree and platform-driver code is covered in a separate article.

This study note follows a single question: how does one application call travel through the Linux kernel all the way to real hardware? We connect system calls, tasks, scheduling, sleep and wakeup, interrupts, locks, atomics, and memory ordering into one coherent picture.

1. Tracing an Application Call to the Linux Kernel

Consider a restaurant analogy: a customer submits an order slip, the front desk routes it to the kitchen, the kitchen selects the right equipment, and the result travels back to the customer. Linux applications work the same way—they ask the kernel for services instead of touching hardware registers directly.

Application
    │  open() / read() / write() / ioctl()
    ▼
libc wrapper or direct syscall
    ▼
System-call entry (user → kernel privilege transition)
    ▼
VFS (Virtual File System)
    ▼
Filesystem or device driver
    ▼
MMIO / DMA / storage / network hardware

A system call is the controlled entry point from user space into the kernel. The read() function that user code calls is commonly a libc wrapper that executes the architecture's system-call instruction. The kernel validates the syscall number and arguments, then dispatches the request to the appropriate kernel path. A program can bypass libc and call syscall(2) directly, but normal applications use libc APIs.

CPUs enforce privilege boundaries between application code and kernel code. On x86 this is often explained as Ring 3 versus Ring 0; other architectures use different terminology and rules. The essential idea is that applications cannot freely execute privileged instructions or directly manipulate device state.

The Role of the Virtual File System (VFS)

VFS (Virtual File System) is the common kernel layer that presents different filesystems and devices through a uniform, file-like interface. An application can use read(fd, ...) to read from an ext4 file and from a UART device file using exactly the same call. The file object's operations table routes the call to the appropriate filesystem or driver underneath.

# Useful observation commands
strace -e trace=openat,read,write,ioctl ./app
cat /proc/self/status

strace shows the system calls issued by an application, while /proc exposes kernel-maintained process information. Both are valuable starting points when tracing a driver path from the user API down to kernel code.

2. Program, Process, Thread, and task_struct Explained

An executable file on disk is a program. Once launched, it becomes a process: a living object with an address space, open file descriptors, credentials, and execution state. A thread is an execution flow that shares those resources with other threads in the same process while maintaining its own execution position.

Shared by the process
  code / global data / heap
  address space / file descriptors

Private to each thread
  stack / CPU registers / program counter

Linux kernel
  Manages both processes and threads via task_struct

Linux does not have a separate core structure reserved for threads. The scheduler manages every execution unit through task_struct. fork() creates a new process-like task, while clone() allows fine-grained control over which resources—address space, files, signal handlers, and so on—are shared. User-space thread libraries build on these kernel primitives.

PCB (Process Control Block) is the general operating-systems term for the block of state needed to manage a process. In Linux, task_struct is the central structure that fills that role. Its fields and internal layout change across kernel versions and do not form a stable user ABI, so memorizing a specific field list as if it were permanent is not advisable.

3. Linux Scheduler and Context Switches

The scheduler is the kernel subsystem that chooses which runnable task gets the CPU next. A single logical CPU executes one instruction stream at a time, so rapid switching between tasks creates the illusion of concurrency. On a multicore system, multiple logical CPUs execute truly in parallel.

Task A running
   ↓ timer tick or block/unblock
Save current registers and execution position
   ↓
Restore Task B's saved context
   ↓
Task B running

A context switch saves the CPU registers, stack pointer, program counter, and other execution state of the outgoing task, then restores the state of the incoming task. Beyond the direct save-and-restore cost, cache and TLB locality can break, slowing subsequent code. It is therefore wrong to assert that "thread switches are always free and cheap" or "process switches are always very expensive." The real cost depends on address-space sharing, architecture, cache state, and workload characteristics.

Run Queues, CFS, and EEVDF

A run queue holds the tasks that are currently runnable. Tasks that are sleeping or blocked on I/O are not candidates for immediate execution and do not stay on the run queue. Per-CPU run queues reduce contention compared to a single global queue shared by all CPUs; load balancing migrates tasks when one CPU becomes overloaded.

CFS (Completely Fair Scheduler) is the fairness model that tracks each task's weighted CPU usage as vruntime, giving priority to tasks that have run less. Modern Linux fair scheduling has evolved toward EEVDF (Earliest Eligible Virtual Deadline First). "Always pick the task with the smallest vruntime" remains a useful historical intuition, but it is not the complete selection rule in a current kernel.

A task's nice value is one input that influences its relative CPU share in the normal scheduling class. Real-time and fair scheduling classes use different policies, so trying to describe every task with a single CFS formula leads to confusion.

4. Sleep, Wake-Up, and Wait Queues in Linux

Repeatedly checking while (data_not_ready) {} is busy waiting, which consumes CPU cycles without doing useful work. Linux prefers to put the waiting task to sleep and wake it when the condition becomes true. A sleeping task is not runnable, so it does not consume CPU time.

Application read()
      ↓ no data available
wait_event_interruptible(queue, data_ready)
      ↓
TASK_INTERRUPTIBLE / removed from run queue
      ↓ UART or CAN IRQ fires
driver stores data in buffer
      ↓ wake_up_interruptible(&queue)
Runnable → scheduler selects task → read() resumes

A wait queue is a kernel data structure that links tasks waiting for a particular condition. wait_event_interruptible() evaluates the condition and puts the current task to sleep when it is false. An interrupt handler or another execution context stores the data and calls wake_up_interruptible(). The condition check and shared-data protection must be designed together; assuming that "calling wake_up eventually gets things right" is insufficient.

StatePlain meaningNote
TASK_RUNNING Running or runnable Eligible to be placed on the run queue
TASK_INTERRUPTIBLE Interruptible sleep; can be woken by a signal Must handle signal errors on return
TASK_UNINTERRUPTIBLE Uninterruptible sleep; does not respond to normal signals Investigate cause and I/O path if persistent

A task that blocks itself and yields the CPU performs a voluntary context switch; a task preempted by a timer or scheduling policy experiences an involuntary context switch. The voluntary_ctxt_switches and nonvoluntary_ctxt_switches counters in /proc/self/status provide a simple observation point for a running process.

5. Interrupt Handling: Top Half vs. Deferred Work

An interrupt is a signal from hardware telling the CPU that an event needs attention—a UART byte received, a CAN frame arrived, a network packet landed, or a DMA transfer completed. If the interrupt handler runs too long, it can delay subsequent events. Linux therefore separates urgent work from deferred work.

Hardware event
    ↓
Hard IRQ handler (minimal: read status, ack, move data)
    ↓
Softirq / workqueue / threaded IRQ
    ↓
Packet parsing, sleepable work, user-space wake-up

Hard IRQ and SoftIRQ/tasklet handlers run in atomic execution contexts and generally cannot sleep. A workqueue runs on a kernel worker thread, so sleeping operations and mutexes are permitted there. A threaded IRQ combines a minimal hard handler with a dedicated IRQ thread for longer processing. Tasklets are a historical mechanism; for new designs, evaluate workqueues or threaded IRQs first as they are the currently preferred patterns.

A CAN driver, for example, may read the receive status register and drain the FIFO in the hard IRQ handler, then schedule work to the networking path for further processing. A UART character driver places received bytes in a ring buffer and wakes any readers waiting on a wait queue. Applications never handle the hardware IRQ directly.

6. Kernel Concurrency: Spinlocks, Mutexes, and Atomics

Suppose process context and an IRQ handler both update a shared queue. Without coordination, a race condition can silently drop data or corrupt the list structure. A lock's primary role is to serialize access to shared state so that only one execution context modifies it at a time. Locks do not automatically prevent deadlocks.

ToolWhen to useKey behavior
Spinlock Short critical sections that must not sleep Busy-waits (spins) on the CPU until the lock is released
Mutex Process context; long or sleepable critical sections Waiting task can sleep
atomic_t Simple operations such as counter increment/decrement Makes the operation indivisible
Memory barrier When observation order across CPUs or devices matters Expresses visibility and ordering constraints
/* Example: IRQ and process context share the same queue */
spin_lock_irqsave(&queue_lock, flags);
/* very short queue modification */
spin_unlock_irqrestore(&queue_lock, flags);

spin_lock_irqsave() saves the current CPU's interrupt state, disables local IRQs as needed, and then acquires the spinlock. This prevents an IRQ on the same CPU from re-entering code that already holds the lock. The spinlock itself protects against other CPUs; local IRQ control solves the separate re-entry problem on the same CPU.

Never sleep while holding a spinlock

If CPU0 sleeps while holding a spinlock, CPU1 will spin indefinitely waiting for the lock to be released—but the only entity that can release it is CPU0, which is asleep. This wastes a CPU and can deadlock the system. Any work that might sleep must be moved outside the spinlock; use a mutex or a workqueue where appropriate.

An atomic operation makes a simple update such as count++ indivisible, preventing partial reads or writes. However, an atomic counter does not automatically impose ordering on other unrelated struct fields. For managing object lifetimes, refcount_t may be more appropriate than a generic atomic counter; the right choice depends on the data structure's semantics and the kernel APIs involved.

7. CPU Caches and Memory Ordering

On a multicore system, the fact that your source code is written top-to-bottom does not guarantee that another CPU or a hardware device observes the same order. Compiler transformations, out-of-order CPU execution, store buffers, and per-core caches all intervene. Cache coherency is about ensuring agreement on the current value at a memory address. Memory ordering is about constraining the order in which multiple accesses become observable to different observers. These are distinct problems.

/* producer */
data = 100;
smp_store_release(&ready, 1);

/* consumer */
if (smp_load_acquire(&ready) == 1)
    use(data);

Release ensures that all prior writes are visible before the flag is published. Acquire ensures that the flag is observed before the protected data is read. A happens-before relationship is established when the acquire observes the value published by the release. Simply marking two variables as atomic does not define a complete synchronization protocol.

MMIO Ordering and DMA Doorbells

An NPU or network device driver commonly writes a descriptor into normal RAM and then rings an MMIO doorbell to notify the device. The device must not observe the doorbell before the descriptor is visible, so the ordering required by both the device protocol and the DMA API must be made explicit in the driver code.

desc->addr = dma_addr;
desc->len  = length;

/* Use the barrier that matches the DMA API and hardware protocol */
dma_wmb();
writel(QUEUE_START, regs + DOORBELL);

readl() and writel() are architecture-aware MMIO accessors, not ordinary pointer dereferences. A relaxed accessor such as writel_relaxed() intentionally provides weaker ordering and must not be substituted merely for a perceived performance benefit. MMIO ordering and DMA buffer visibility must be verified against the device manual, the Linux DMA API documentation, and the architecture-specific accessor rules—all together.

8. Putting It All Together: Inside a Linux Device Driver

Overlay all the concepts above onto a single UART or CAN device to see how they connect end to end.

Device Tree resource
      ↓ compatible matching
platform_driver.probe()
      ↓ MMIO mapping / clock / regulator / IRQ
Register file_operations or existing subsystem
      ↓
Application read()
      ↓ system call → VFS → driver.read()
No data → sleep on wait queue
      ↓
Hardware IRQ → short handler → store data in buffer
      ↓ spinlock protects the queue
wake_up() → task becomes runnable → scheduler
      ↓
read() returns data to user space

In this flow, the Device Tree and platform driver handle device discovery and resource binding. System calls and VFS connect user-space APIs to driver operations. IRQs, wait queues, locks, and barriers preserve correctness and performance when data arrives asynchronously and multiple execution contexts share state.

Step-by-step DTS, probe(), reg parsing, and clock/regulator/IRQ resource acquisition are covered in the Device Tree and Linux Platform Driver Introduction article. U-Boot eMMC commands and waveform measurements are covered separately in the UUU, U-Boot, and eMMC Waveform article.

9. Essential Commands and Source-Reading Exercises

  1. Use strace -e trace=%file,%desc ./app to observe file and device-file access and the system calls involved.
  2. Read the context-switch counters with cat /proc/self/status.
  3. Watch UART, CAN, USB, and MMC interrupt counters increment with cat /proc/interrupts.
  4. Read dmesg in chronological order to trace driver probe, IRQ registration, and deferred-probe messages.
  5. In the kernel source tree, find declarations and callers for struct task_struct, schedule(), wait_event_interruptible(), request_threaded_irq(), and spin_lock_irqsave().

Practice questions

  1. Why does read() sleep instead of busy-waiting when no data is available?
  2. When an IRQ handler and process context share a queue, which lock and IRQ-control combination should you use instead of a plain mutex?
  3. If atomic_inc() is already safe, why might separate memory ordering still be necessary?
  4. When writing a DMA descriptor and then ringing a doorbell, which observer must see the descriptor first, and how do you enforce that order?

Key Takeaways

  1. System calls are the controlled entry point between user space and the kernel; VFS presents files and device files through a unified model.
  2. Linux manages processes and threads as task_struct-based execution units; the scheduler selects which runnable task runs next.
  3. Understanding run queues and context switches lets you read CPU sharing, sleep/wakeup, and scheduler logs as a single coherent picture.
  4. Hard IRQ handlers must finish quickly; sleepable work belongs in a workqueue or threaded IRQ.
  5. Spinlocks, mutexes, atomics, and barriers solve different problems and are not interchangeable substitutes for one another.
  6. MMIO and DMA ordering are distinct from CPU cache coherency; consult the MMIO accessor rules, DMA API, and hardware protocol together.

Official References

#LinuxKernel #SystemCall #Process #Thread #Scheduler #Interrupt #Spinlock #MemoryBarrier #BSP

+ Recent posts