안녕하세요.
이전 글에서는 유저공간에서 실행되는 프로세스를 봤습니다. 이제 커널 공간에서 실행하는 프로세스에 대해 알아보는 시간입니다.
1. 커널 스레드
커널 프로세스는 커널 공간에서만 실행되는 프로세스이며 대부분 커널 스레드 형태로 동작합니다. 리눅스 시스템 프로그래밍에서 데몬과 비슷한 처리를 하는데 데몬과 커널 스레드는 백그라운드 작업을 실행하면서 시스템 메모리, 전원 제어동작 수행합니다.
커널 스레드 특징
커널 공간에서만 실행함
유저 영역과 시스템 콜을 받지 않고 동작하는 것이 데몬과 차이
실행, 휴면 등 모든 동작을 커널에서 직접 제어 관리
대부분 커널 스레드는 시스템이 부팅할 때 생성하고 종료할 때까지 백그라운드로 실행
2. 커널 스레드 종류
1) 커널 스레드 항목 확인
ps 명령어로 현재 커널에서 동작하고 있는 커널 스레드를 확인하고 그중에 대표적인 커널 스레드 몇 개를 알아보겠습니다.
#ps axjf
a: 터미널에 로그인한 모든 사용자의 프로세스를 표시합니다. 즉, 특정 사용자나 세션에 제한되지 않고 전체 시스템의 프로세스를 볼 수 있습니다.
x: 터미널에 연결되지 않은 백그라운드 프로세스도 포함하여 표시합니다.
j: 출력에 작업(job) 정보를 추가합니다. 이는 프로세스 그룹 ID, 세션 ID 등을 포함한 정보를 뜻합니다.
f: 포맷을 트리(tree) 형식으로 표시합니다. 부모 프로세스와 자식 프로세스 간의 관계를 계층적으로 보여줍니다.

(1) kthreadd
kthreadd 프로세스는 모든 커널 스레드의 부로 프로세스이고 스레드 핸들러 함수는 kthreadd()이고 커널 스레드 생성을 담당합니다.
일반 프로세스와 달리, 프로세스가 실행하고 휴면 상태에 진입하는 동작을 커널 함수를 사용해서 구현해야 하고, 커널 스레드 생성 시 호출하는 kthread_create() 함수의 1번째 인자로 커널 스레드 핸들러 함수를 지정해야 합니다.
kthread_create() 함수의 선언부를 보면 커널 스레드 핸들러 함수를 지정하는 threadfn 인자를 볼 수 있습니다. 그렇기 때문에 커널 스레드 세부 동작은 핸들러 함수에 구현되어 있기 때문에 핸들러 함수 분석이 필요합니다.
/rpi_kernel_src# vim linux/include/linux/kthread.h

(2) worker 스레드
느린 작업을 백그라운드에서 실행하여 시스템 응답성을 높이며, workqueue에서 작업을 가져와 실행하며, 작업이 완료되면 Worker Thread는 새로운 작업을 기다리거나 대기 상태로 전환합니다.
리눅스에서 실행 중인 worker thread는 다음과 같은 패턴을 가집니다.
kworker/[CPU ID]:[worker ID]

<실행 흐름>
worker_thread() → run_workqueue() → process_one_work()
(메인 루프) → (작업 대기열 확인) → (작업 실행)
worker_thread()는 worker thread의 메인 루프이고 process_one_work()는 실제 작업을 실행하는 함수입니다. worker_thread()가 process_one_work()를 호출하는 구조가 됩니다. worker thread는 여러 작업을 연속적으로 처리하면서, workqueue 기반으로 동작합니다.
(3) ksoftirqd 스레드
SoftIRQ(소프트 인터럽트)는 하드웨어 인터럽트와 일반 프로세스 실행 사이에서 비동기적으로 실행되는 특별한 커널 작업입니다. 네트워크 패킷 수신, 타이머 이벤트, 블록 I/O 요청 처리 등의 작업을 담당하며 하드웨어 인터럽트 처리 후, 긴 작업을 SoftIRQ로 넘겨서 실행 성능을 높입니다.
일반적으로 SoftIRQ는 인터럽트 컨텍스트에서 즉시 실행되지만, 만약 SoftIRQ가 너무 많아져서 CPU 사용량이 급증하면 ksoftirqd 스레드가 실행됩니다.
간략하게 보자면 SoftIRQ 발생 → ksoftirqd 실행 과정은 아래와 같습니다.
1. 하드웨어 인터럽트 발생
2. SoftIRQ가 등록됨
3. SoftIRQ가 적으면 → 즉시 실행 (__do_softirq())
4. SoftIRQ가 많아지면 → ksoftirqd가 실행됨 (run_ksoftirqd())
5. run_ksoftirqd()가 대기 중인 SoftIRQ를 실행
6. SoftIRQ 처리가 끝나면 → 다시 대기 상태 (schedule())
여기서 ksoftirqd 스레드의 핸들러는 run_ksoftirqd()이고 메인 루프 역할을 하는 함수입니다.
(4) irq/86-mmc1 스레드
IRQ 스레드라고 하며, 인터럽트 후반부 처리를 위해 사용되는 프로세스입니다. 프로세스 이름으로 어떤 기능을 하는지 확인 가능해서, 확인하고자 한, irq/86-mmc1는 MMC/SD 카드 장치가 발생시키는 인터럽트(IRQ) 86번을 처리하는 커널 스레드라고 생각할 수 있습니다.
조금 더 내용을 설명하면,
SD 카드에서 데이터 읽기/쓰기 시 하드웨어 인터럽트(IRQ 86번)가 발생하면, 하드 IRQ 핸들러(Top Half)가 즉시 실행되어, 기본적인 인터럽트 처리를 수행합니다. 이후, 복잡한 처리가 필요하면 SoftIRQ 또는 irq/86-mmc1 같은 IRQ 스레드로 넘겨서 실행합니다. 결과적으로 MMC/SD 카드의 데이터 전송이 원활하게 이루어지도록 합니다.
3. 커널 스레드 생성
커널 스레드 생성하는 과정을 2단계로 확인합니다. 큰 흐름은 이전에 본 그림과 같습니다.

1) 1단계 : kthreadd프로세스에게 커널 스레드 생성요청
커널 스레드를 생성하려면 kthread_create() 함수 호출해야 합니다.
kthread_create() 함수의 인자를 먼저 보면
int (*threadfn)(void *data)
스레드 핸들러 함수 주소를 저장하는 필드이고, 새로 생성될 스레드가 실행할(구현된) 함수입니다. 이 함수는 반환값이 0이면 성공적으로 종료됨을 의미합니다.
함수의 매개변수 data는 호출자가 전달하는 사용자 정의 데이터입니다.
void *data
threadfn(스레드 핸들러) 함수로 전달되는 매개변수입니다. 주로 주소를 전달하며, 스레드를 식별하는 구조체의 주소를 전달합니다. 사용자 정의 데이터를 포함할 수 있으며, 스레드 함수가 이 데이터를 활용하여 작업을 수행할 수 있습니다.
const char namefmt[]
새로 생성되는 커널 스레드의 이름을 지정합니다. 문자열 포맷 형식으로 사용할 수 있습니다. (예: "%s-thread")
실제 kthread_create() 함수로 생성하는 코드를 확인합니다. 525번째 줄에 kthread_create() 함수 첫 번째 인자로 vhost_worker로 지정되어 있고, dev 변수는 아래의 vhost_worker 함수를 보면 알 수 있습니다.
rpi_kernel_src# vim linux/drivers/vhost/vhost.c

동일한 vhost.c 파일 내에 함수 내에 vhost_worker() 함수가 있으며, vhost_dev의 구조체의 주소를 kthread_create() 함수 2번째 인자로 전달했습니다.
335번째 줄을 보면 void 타입의 data 포인터를 vhost_dev구조체로 형변환해서 사용합니다. 커널에서 이 같은 방식으로 스레드를 관리하는 구조체를 매개변수(디스크립터)로 전달합니다.

kthread_create() 함수는 선언부와 같이 매크로 타입으로 되어 있습니다. 그러나 커널 컴파일 과정에서 전처리기는 kthread_create_on_node() 함수로 바꿉니다. 즉, kthread_create() 함수를 커널이나 드라이버 코드에서 호출하면 kthread_create_on_node() 함수가 동작합니다.

그렇다면 kthread_create_on_node() 함수를 확인하는 것이 필요할 것입니다. 그런데 구현부를 보면 특별한 코드가 있지 않고 가변인자를 통해 __kthread_create_on_node() 함수를 호출합니다.
rpi_kernel_src# vim linux/kernel/kthread.c

291번째 줄을 보면 kmalloc() 함수로 kthread_create_info 구조체만큼 동적메모리를 할당합니다. 296번~298번 줄은 커널 스래드 함수와 매개변수 및 노드를 생성한 구조체 필드에 저장합니다.
302번째 줄에서 커널 스레드 생성 요청을 관리하는 kthread_create_list 연결 리스트에 &create_list를 추가합니다.
kthreadd 프로세스는 kthread_create_list를 확인해서 커널 스레드 요청이 있었는지 확인하며, 비어 있지 않다면 커널 스레드를 생성을 시작합니다.
305번째 wake_up_process() 함수는 kthread 프로세스의 태스크 디스크립터(매개변수)인 kthread_task를 인자를 넣어 kthreadd프로세스를 깨웁니다.

2) 2단계 : kthreadd가 커널 프로세스 생성
wake_up_process() 함수를 통해 kthreadd 프로세스가 깨어난 것을 확인했습니다. 깨어나면 kthreadd 프로세스의 스레드 핸들러인 kthreadd() 함수가 호출되어 프로세스를 생성합니다.
kthreadd() 함수에서 작동하는 코드를 확인해 보겠습니다. static int kthread() 함수도 같이 있기 때문에 한번 더 함수 이름 확인 후에 코드를 보시기 바랍니다.
for(;;) 문이기 때문에 무한 루프를 돌게 되는데, 책에서 kthreadd 프로세스를 깨우면 함수 어느 부분에서 실행하게 되는지와 생성할 프로세스가 없을 때 어느 코드가 실행할까요?라고 질문하고 있습니다.
책 내용을 보면 알겠지만, 574, 575번째 줄을 보면 리스트가 없으면 휴면상태로 들어가게 되고, 깨우면 576줄이 실행되는데, 이는 휴면상태 후 바로 다음 코드이기 때문입니다.
579번째 줄은 kthread_create_list 연결 리스트가 비어 있지 않을 조건을 확인하고 580~589번째 줄까지 해서 커널 스레드를 생성합니다.
kthreadd() 함수 핵심 기능은 kthread_create_info 연결 리스트를 확인해서 프로세스 생성 요청을 확인하고, create_kthread() 함수를 호출해 프로세스를 생성하는 것입니다.
582,583번째 줄을 보면 kthread_create_list.next 필드를 통해 kthread_create_info 구조체의 주소를 읽습니다.

kthread_create_info 구조체 내용을 먼저 확인합니다. 구조체 마지막 필드는 list이며 struct list_head 타입입니다.

아래 kthread_create_info 구조체 관계에서 보면, kthread_create_list 전역변수의 next 필드가 kthread_create_info 구조체의 list 필드 주소를 가리키고 있습니다.
이렇게 하면 kthread_create_info 구조체에서 list 필드의 오프셋을 계산해 kthread_create_info 구조체의 시작 주소를 알 수 있습니다.
(왜 이렇게 작업하는 걸까 궁금해서 마지막(참고 TIP)에 조금 더 내용을 추가해 보았습니다.)

587번째 줄에서 create_kthread() 함수를 호출해서 커널 스레드를 생성합니다.
그러면 create_kthread() 함수를 확인합니다. 함수는 kthread.c 동일 파일에 구현되어 있습니다.
269번째 줄에 CLONE_FS | CLONE_FILES | SIGCHLD 매크로를 추가해서 kernel_thread() 함수를 호출합니다.

호출한 kernel_thread() 함수를 확인해 봅니다. kernel_create() 함수는 fork.c 파일에 구현되어 있습니다.
최종적으로 _do_fork() 함수를 호출해서 프로세스를 생성합니다. 위에서 thread로 따라왔지만, 최종적으로 커널 스레드도 프로세스의 한 종류인 것을 알 수 있습니다.

함수를 따라가는 것이 쉽지는 않은 듯합니다. 정리한다면 처음에 봤던 그림이 되지 않을까 합니다. 내부적으로 어떻게 구성이 이렇게 이어져 커널 스레드가 만들어지는구나 알 수 있는 시간이었습니다.

참고 TIP)
kthread_create_list 전역변수의 next 필드가 kthread_create_info 구조체의 list 필드 주소를 가리키고 있을까 의문이 들었습니다. 기본 자료구조를 배웠다면 kthread_create_info 구조체 주소를 넣어서 Double Linked List를 만들면 되는 것 아닌가 생각할 수 있을 것입니다. (아직 리눅스 커널에 대한 초보라...)
이는 리눅스 커널에서 흔히 사용하는 container_of 패턴 때문이라고 합니다. 리눅스 커널에서는 일반적으로 구조체의 일부 필드(예: list 필드)의 주소를 링크드 리스트에 저장하고, 이를 통해 전체 구조체의 시작 주소를 얻는 방식을 사용합니다.
리눅스 커널에서 container_of 매크로를 이용하면, 특정 필드의 주소를 가지고 해당 필드가 포함된 전체 구조체의 시작 주소를 계산할 수가 있다고 합니다. 이 매크로는 ptr이 구조체 type 내의 member 필드 주소일 때, type 구조체의 시작 주소를 반환합니다.
#define container_of(ptr, type, member) \
(type *)((char *)(ptr) - (unsigned long)(&((type *)0)->member))
이해가 되지 않아서, GPT의 도움을 받아 사용 예제를 확인해 보았습니다.
#include <stdio.h>
#define container_of(ptr, type, member) \
(type *)((char *)(ptr) - (unsigned long)(&((type *)0)->member))
// 예제 구조체 정의
struct my_struct {
int a;
double b;
char c;
};
int main() {
struct my_struct obj = {10, 3.14, 'X'};
// 멤버 `b`의 주소를 가져옴
double *b_ptr = &obj.b;
// `b_ptr`을 사용하여 `obj`의 전체 구조체 주소를 얻음
struct my_struct *struct_ptr = container_of(b_ptr, struct my_struct, b);
// 원래 구조체의 주소와 비교
printf("Original struct address: %p\n", (void *)&obj);
printf("Retrieved struct address: %p\n", (void *)struct_ptr);
return 0;
}
그렇다면 왜 이렇게 설계했을까?입니다.
(1) 일반적인 list_head 기반 링크드 리스트를 사용할 수 있음
리눅스 커널에서는 list_head를 이용한 링크드 리스트가 많고 모두 동일한 방식으로 관리할 수 있도록 일관된 설계를 유지하기 위해서입니다.
struct task_struct
struct file_operations
struct inode
struct kthread_create_info
(2) container_of를 이용한 유연성
구조체 내부의 특정 필드를 이용해 전체 구조체의 주소를 얻을 수 있기 때문에
필드 위치가 바뀌어도 코드 수정이 최소화합니다.
(3) 확장성
만약 kthread_create_info의 구조가 바뀌어도 list_head를 유지하면 링크드 리스트 구조는 그대로 유지 가능 합니다. 즉, 다른 리스트 구조체와 호환이 쉬워집니다.
감사합니다.
<참고 자료>
1. [도서] 디버깅을 통해 배우는 리눅스 커널의 구조와 원리 p170~183, wikibook
'IT > Linux Kernel' 카테고리의 다른 글
디버깅을 통해 배우는 리눅스 커널의 구조와 원리 1, 도서 공부하기 12 - 4_4_user_process생성과 종료 과정 분석 (2) | 2025.03.23 |
---|---|
디버깅을 통해 배우는 리눅스 커널의 구조와 원리 1, 도서 공부하기 11 - _do_fork() 흐름 파악과 ftrace 메시지 추출 (0) | 2025.03.20 |
디버깅을 통해 배우는 리눅스 커널의 구조와 원리 1, 도서 공부하기 10 - 프로세스 생성 (0) | 2025.03.17 |
디버깅을 통해 배우는 리눅스 커널의 구조와 원리 1, 도서 공부하기 9 - 프로세서란 (4) | 2025.03.12 |
디버깅을 통해 배우는 리눅스 커널의 구조와 원리 1, 도서 공부하기 8 - debugsfs 드라이버 코드 (2) | 2025.03.09 |