들어가며
이전 글에서는 NAPI(New API)를 통해 네트워크 인터페이스 카드(NIC)에서 수신한 패킷이 커널의 네트워크 스택으로 본격적으로 진입하기 직전까지의 과정을 살펴보았습니다. 이번 글에서는, 패킷이 커널 네트워크 스택의 상위 계층으로 전달되어 최종적으로 TCP 애플리케이션 레벨 소켓의 수신 버퍼(receive buffer)에 안착하기까지의 여정을 상세히 추적해 보겠습니다.
리눅스 커널의 실제 소스 코드를 기반으로 설명하지만, 방대한 코드를 모두 담을 수는 없어 많은 부분을 생략하고 개념적으로 추상화했습니다. 따라서 각 함수나 로직의 더욱 깊은 내부 동작이 궁금하시다면, 본문에 링크된 소스 코드를 직접 보시는 것을 강력히 추천합니다.
환경
- 커널: 리눅스 5.15
- 프로토콜: TCP/IPv4
- 상황: 이미 연결이 확립된(Established) 상태에서 정상적인 단일 데이터 패킷 수신
TL;DR
✅ L2/L3 처리: NAPI 이후 패킷은 __netif_receive_skb_list_core에서 프로토콜별로 묶여 ip_rcv로 전달, Netfilter 훅과 라우팅을 거쳐 ip_local_deliver를 통해 로컬 시스템으로 향합니다.
✅ L4(TCP) 처리: tcp_v4_rcv에서 TCP 헤더를 분석하고 관련 소켓을 찾은 뒤, tcp_rcv_established에서 연결 상태에 맞는 로직(주로 fast path)으로 데이터를 처리합니다.
✅ 수신 버퍼 도달 및 알림: 최종적으로 tcp_queue_rcv 함수가 skb를 소켓의 sk_receive_queue(수신 버퍼)에 추가하고, tcp_data_ready가 recv/epoll 등으로 대기 중인 애플리케이션을 깨웁니다.
✂️ __netif_receive_skb_list_core
이전 글에서 gro_normal_one
함수가 gro_normal_list
를 호출하며 패킷 리스트를 네트워크 스택으로 전달하는 지점까지 살펴보았습니다. 이후 몇 단계를 더 거치면, 드디어 __netif_receive_skb_list_core
함수에 도달하게 됩니다.
이 함수는 네트워크 스택으로 들어온 패킷들을 본격적으로 처리하는 관문 중 하나입니다.
static void __netif_receive_skb_list_core(struct list_head *head, bool pfmemalloc)
{
struct packet_type *pt_curr = NULL;
struct net_device *od_curr = NULL;
struct list_head sublist;
struct sk_buff *skb, *next;
list_for_each_entry_safe(skb, next, head, list) {
struct net_device *orig_dev = skb->dev;
struct packet_type *pt_prev = NULL;
// skb의 프로토콜 타입을 분석하고 해당 프로토콜을 처리할 핸들러(pt_prev)를 찾아옵니다.
__netif_receive_skb_core(&skb, pfmemalloc, &pt_prev);
if (!pt_prev)
continue;
if (pt_curr != pt_prev || od_curr != orig_dev) {
// 프로토콜 타입이나 원본 장치가 달라졌다면, 지금까지 모은 sublist를 처리합니다.
__netif_receive_skb_list_ptype(&sublist, pt_curr, od_curr);
pt_curr = pt_prev;
od_curr = orig_dev;
}
// 동일 프로토콜, 동일 원본 장치의 skb들은 sublist에 계속 쌓입니다.
list_add_tail(&skb->list, &sublist);
}
// 루프 종료 후, 마지막으로 묶인 sublist를 처리합니다.
__netif_receive_skb_list_ptype(&sublist, pt_curr, od_curr);
}
이 함수는 수신된 패킷들(head
리스트)을 순회하면서, 동일한 프로토콜 타입 (예: IPv4, ARP)과 동일한 원본 네트워크 장치 (orig_dev
)를 가진 패킷들을 sublist
라는 임시 리스트로 묶습니다. 그리고 프로토콜 타입이나 장치가 달라지는 시점에, 그때까지 sublist
에 모인 패킷들을 한꺼번에 해당 프로토콜 핸들러(pt_curr
)에게 전달합니다(__netif_receive_skb_list_ptype
호출).
이는 일종의 배치(batch) 처리 최적화로, 동일한 컨텍스트를 공유하는 패킷들을 모아서 처리함으로써 효율을 높이려는 시도입니다. 예를 들어, 연속으로 IP 패킷들이 들어오다가 ARP 패킷이 들어오면, 그 직전에 쌓여있던 IP 패킷들을 한 묶음으로 IP 핸들러에게 넘기는 방식입니다.
[ skb1(IP) ] → sublist(IP)에 추가
[ skb2(IP) ] → sublist(IP)에 추가 -- IP 패킷 2개가 sublist에 모임. 다음 패킷이 ARP이므로 여기서 dispatch! → IP 핸들러
[ skb3(ARP) ] → (새로운) sublist(ARP)에 추가
[ skb4(ARP) ] → sublist(ARP)에 추가 -- dispatch! → ARP 핸들러
[ skb5(IP) ] → (새로운) sublist(IP)에 추가 -- dispatch! (루프 종료 후 남은 리스트 처리) → IP 핸들러
🍭 __netif_receive_skb_list_ptype
static inline void __netif_receive_skb_list_ptype(struct list_head *head,
struct packet_type *pt_prev,
struct net_device *orig_dev)
{
struct sk_buff *skb, *next;
if (pt_prev->list_func != NULL)
INDIRECT_CALL_INET(pt_prev->list_func, ipv6_list_rcv, ip_list_rcv, head, pt_prev, orig_dev);
else
// 리스트 내의 각 skb를 개별적으로 func를 통해 처리합니다.
list_for_each_entry_safe(skb, next, head, list) {
skb_list_del_init(skb); // 리스트에서 skb를 제거하고 초기화
pt_prev->func(skb, skb->dev, pt_prev, orig_dev); // 개별 skb 처리 함수 호출
}
}
이 함수는 앞서 __netif_receive_skb_list_core
에서 만들어진 sublist
(여기서는 head
파라미터)와 해당 패킷들의 프로토콜 핸들러 정보(pt_prev
)를 받습니다.
만약 pt_prev
(즉, struct packet_type
)에 list_func
라는 함수 포인터가 등록되어 있다면, 이는 패킷 리스트 전체를 한 번에 효율적으로 처리할 수 있는 전용 함수가 존재한다는 의미입니다. 이 경우 INDIRECT_CALL_INET
매크로를 통해 해당 list_func
(IPv4라면 ip_list_rcv
, IPv6라면 ipv6_list_rcv
)가 호출되어 리스트 전체를 넘겨줍니다.
하지만 list_func
가 등록되어 있지 않다면 (NULL이라면), 리스트(head
)에 담긴 각 sk_buff
를 하나씩 꺼내어 pt_prev->func
라는 개별 패킷 처리 함수를 호출합니다.
우리의 시나리오는 단일 패킷의 흐름을 추적하고 있으므로, 궁극적으로는 pt_prev->func
가 호출되는 경로에 집중하게 됩니다. (비록 list_func
가 호출되더라도 그 내부에서 결국 개별 패킷 처리 로직으로 이어지거나, 단일 패킷만 있는 리스트의 경우 유사한 경로를 탈 것입니다.)
packet_type
여기서 등장하는 pt_prev
는 struct packet_type
타입의 포인터입니다. 이 구조체는 커널이 다양한 네트워크 프로토콜을 식별하고 각 프로토콜에 맞는 처리 함수를 연결해주는 핵심적인 역할을 합니다.
struct packet_type {
__be16 type;
int (*func) (struct sk_buff *,struct net_device *,struct packet_type *,struct net_device *);
void (*list_func) (struct list_head *, struct packet_type *, struct net_device *);
// ... (다른 멤버들 생략)
};
type
: 이더넷 프레임의 EtherType 필드 값 (예: IPv4는 ETH_P_IP
, ARP는 ETH_P_ARP
). 네트워크 바이트 오더(__be16
)로 저장됩니다.func
: 단일 sk_buff
를 처리하기 위한 함수 포인터입니다.
각 프로토콜(L2.5 이상)은 자신의 packet_type
구조체를 커널에 등록합니다. 예를 들어, IPv4의 경우 다음과 같이 정의되어 있습니다.
static struct packet_type ip_packet_type __read_mostly = {
.type = cpu_to_be16(ETH_P_IP),
.func = ip_rcv,
.list_func = ip_list_rcv,
};
따라서 우리의 TCP/IPv4 패킷은 EtherType이 ETH_P_IP
이므로, pt_prev->func
는 바로 ip_rcv
함수를 가리키게 됩니다.
🎍 ip_rcv
드디어 L3 처리의 시작점, ip_rcv
함수에 도달했습니다. 이 함수는 IPv4 패킷 수신의 주 진입로입니다.
소스코드
int ip_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt,
struct net_device *orig_dev)
{
// 패킷이 수신된 네트워크 장치로부터 네트워크 네임스페이스 정보를 가져옵니다.
struct net *net = dev_net(dev);
// ip_rcv_core: IP 패킷의 기본적인 유효성 검사(길이, 체크섬, 버전 등)
skb = ip_rcv_core(skb, net);
if (skb == NULL)
return NET_RX_DROP;
// Netfilter 훅 실행: NF_INET_PRE_ROUTING
// iptables의 PREROUTING 체인에 등록된 규칙들이 여기서 적용됩니다. (예: DNAT)
// 모든 훅을 통과하면(NF_ACCEPT), okfn인 ip_rcv_finish가 호출됩니다.
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING,
net, NULL, skb, dev, NULL,
ip_rcv_finish);
}
ip_rcv
함수는 먼저 ip_rcv_core
를 호출하여 패킷에 대한 기본적인 검증과 전처리를 수행합니다. 여기서 IP 헤더의 길이, 버전, 체크섬 등을 확인하고, skb
내부의 헤더 포인터들(예: network_header
)을 올바르게 설정합니다. 만약 이 과정에서 패킷에 문제가 발견되면 skb
는 NULL
이 되어 반환되고, 해당 패킷은 NET_RX_DROP
처리되어 폐기됩니다.
정상적인 패킷이라면, 그 다음으로 매우 중요한 NF_HOOK
매크로를 만나게 됩니다.
NF_HOOK
리눅스 커널의 강력한 패킷 필터링 및 조작 프레임워크인 Netfilter의 훅(hook)을 실행하는 매크로입니다.
static inline int
NF_HOOK(uint8_t pf, unsigned int hook, struct net *net, struct sock *sk, struct sk_buff *skb,
struct net_device *in, struct net_device *out,
int (*okfn)(struct net *, struct sock *, struct sk_buff *))
{
// nf_hook 함수는 지정된 프로토콜 패밀리(pf)와 훅 포인트(hook)에 등록된 모든 넷필터 모듈의 함수들을 순차적으로 호출합니다.
int ret = nf_hook(pf, hook, net, sk, skb, in, out, okfn);
// 모든 훅 함수들이 패킷을 수락(NF_ACCEPT, 값 1)하면, okfn (여기서는 ip_rcv_finish)을 직접 호출합니다.
if (ret == 1)
ret = okfn(net, sk, skb);
return ret;
}
NF_HOOK
매크로는 다음과 같은 작업을 수행합니다:
- 첫 번째 인자
pf
(Protocol Family, 여기서는NFPROTO_IPV4
)와 두 번째 인자hook
(Hook Point, 여기서는NF_INET_PRE_ROUTING
)에 등록된 모든 Netfilter 모듈(예:iptables
규칙에 해당하는 커널 모듈)들의 콜백 함수들을 실행합니다. - 이 콜백 함수들은 패킷을 검사하고, 변경하거나, 폐기(drop), 혹은 훔쳐갈(steal) 수 있습니다.
- 만약 모든 콜백 함수가 패킷을 통과시키기로 결정하면 (
NF_ACCEPT
를 반환, 이는 정수 값 1에 해당),NF_HOOK
매크로는 마지막 인자로 전달된okfn
(okay function, 여기서는ip_rcv_finish
) 함수를 호출합니다.
즉, ip_rcv
에서는 NF_INET_PRE_ROUTING
훅에 등록된 규칙들 (예: iptables
의 PREROUTING
체인에 설정된 DNAT 규칙 등)을 먼저 적용하고, 만약 패킷이 이 과정을 무사히 통과하면 ip_rcv_finish
함수가 호출되어 다음 단계로 진행됩니다.
🛍 ip_rcv_finish
Netfilter의 PRE_ROUTING
훅을 통과한 패킷은 ip_rcv_finish
함수로 전달됩니다.
소스코드
static int ip_rcv_finish(struct net *net, struct sock *sk, struct sk_buff *skb)
{
struct net_device *dev = skb->dev;
int ret;
// 추가적인 IP 헤더 유효성 검사 및 라우팅 결정에 필요한 정보 확인 등을 수행할 수 있습니다.
ret = ip_rcv_finish_core(net, sk, skb, dev, NULL);
if (ret != NET_RX_DROP)
// 라우팅 테이블을 참조하여 패킷의 목적지(dst_entry)를 결정하고, L4로 올리는 준비를 합니다.
ret = dst_input(skb);
return ret;
}
이 함수에서는 ip_rcv_finish_core
를 호출하여 추가적인 검사나 처리를 수행할 수 있습니다.
핵심적인 부분은 dst_input(skb)
호출입니다. dst
는 "destination entry"의 약자로, 라우팅 시스템에 의해 결정된 패킷의 목적지 정보를 담고 있는 struct dst_entry
구조체를 가리킵니다. dst_input
함수는 이 dst_entry
에 저장된 정보(특히 input
함수 포인터)를 사용하여 패킷을 다음 단계로 전달합니다.
👳 dst_input
dst_input
함수는 skb
에 연결된 dst_entry
의 input
함수 포인터를 호출하여 패킷 처리를 위임합니다.
INDIRECT_CALLABLE_DECLARE(int ip6_input(struct sk_buff *));
INDIRECT_CALLABLE_DECLARE(int ip_local_deliver(struct sk_buff *));
static inline int dst_input(struct sk_buff *skb)
{
return INDIRECT_CALL_INET(skb_dst(skb)->input, ip6_input, ip_local_deliver, skb);
}
INDIRECT_CALL_INET()
매크로는 input
함수 포인터가 실제로 가리키는 함수를 호출합니다.
우리가 다루는 IPv4 패킷이 로컬 시스템으로 전달되어야 하는 경우, 라우팅 과정에서 dst_entry->input
은 ip_local_deliver
함수를 가리키도록 설정됩니다. (IPv6의 경우는 ip6_input
이 됩니다.)
🍽 ip_local_deliver
이제 패킷은 로컬 시스템의 IP 스택으로 전달되어 ip_local_deliver
함수에서 처리됩니다.
int ip_local_deliver(struct sk_buff *skb)
{
//네트워크 네임스페이스를 가져온다
struct net *net = dev_net(skb->dev);
//패킷이 조각났다면 재조립을 시도한다
if (ip_is_fragment(ip_hdr(skb))) {
if (ip_defrag(net, skb, IP_DEFRAG_LOCAL_DELIVER))
return 0;
}
// iptables의 INPUT 체인에 등록된 규칙들이 여기서 적용됩니다. (예: 방화벽 규칙)
// 모든 훅을 통과하면 okfn인 ip_local_deliver_finish가 호출됩니다.
return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN,
net, NULL, skb, skb->dev, NULL,
ip_local_deliver_finish);
}
이 함수에서는 두 가지 중요한 작업이 이루어집니다:
- IP 단편화(Fragmentation) 처리:
ip_is_fragment
를 통해 패킷이 IP 계층에서 단편화되었는지 확인합니다. 만약 그렇다면,ip_defrag
함수를 호출하여 단편들을 모아 원래의 패킷으로 재조립을 시도합니다. 재조립이 아직 진행 중이거나 성공적으로 시작되었다면, 함수는 여기서 반환되고 재조립된 완전한 패킷이 나중에 다시 이ip_local_deliver
경로로 들어오게 됩니다. - Netfilter
NF_INET_LOCAL_IN
훅 실행: 다시 한번 Netfilter의 검문을 받습니다. 이번에는NF_INET_LOCAL_IN
훅 포인트입니다.iptables
의INPUT
체인에 정의된 규칙들(예: 로컬 프로세스로 들어오는 패킷에 대한 방화벽 규칙)이 이 시점에서 적용됩니다.
모든 INPUT
체인 규칙을 통과한 패킷은 okfn
으로 지정된 ip_local_deliver_finish
함수로 넘겨집니다.
🐋 ip_local_deliver_finish
Netfilter의 LOCAL_IN
훅까지 무사히 통과한 패킷은 드디어 L3 처리를 마무리하고 L4로 올라갈 준비를 합니다.
static int ip_local_deliver_finish(struct net *net, struct sock *sk, struct sk_buff *skb)
{
//IP 헤더를 벗겨냅니다
__skb_pull(skb, skb_network_header_len(skb));
//rcu 락을 걸고 L4레벨로 패킷을 전달한다.
rcu_read_lock();
ip_protocol_deliver_rcu(net, skb, ip_hdr(skb)->protocol);
rcu_read_unlock();
return 0;
}
여기서는 다음 작업이 수행됩니다:
- __skb_pull:
skb
의 데이터 시작 포인터(skb->data
)를 IP 헤더의 길이만큼 뒤로 이동시킵니다. 이렇게 함으로써skb->data
는 이제 L4 헤더(우리의 경우 TCP 헤더)의 시작을 가리키게 됩니다. 사실상 IP 헤더를 "벗겨내는" 효과입니다. - ip_protocol_deliver_rcu: IP 헤더 내의
protocol
필드 값 (TCP의 경우IPPROTO_TCP
)을 참조하여, 이 패킷을 처리할 적절한 L4 프로토콜 핸들러에게skb
를 전달합니다.
RCU 락은 왜 여기서 사용될까요?
ip_protocol_deliver_rcu
함수 내부에서는 inet_protos
(또는 유사한 이름의) 전역 배열/리스트를 참조하여 protocol
번호에 해당하는 L4 프로토콜 핸들러(struct net_protocol
)를 찾습니다. 이 핸들러 테이블은 커널 모듈(LKM)에 의해 동적으로 등록되거나 해제될 수 있습니다.
RCU (Read-Copy-Update)는 이러한 동적 변경이 일어나는 와중에도, 읽기 작업을 수행하는 쪽이 안전하게, 잠금 경합 없이 기존 데이터를 참조할 수 있도록 보장하는 동기화 메커니즘입니다.
😊 ip_protocol_deliver_rcu
이 함수는 IP 헤더의 protocol
필드를 기반으로 적절한 L4 프로토콜 핸들러를 찾아 실행합니다.
INDIRECT_CALLABLE_DECLARE(int udp_rcv(struct sk_buff *));
INDIRECT_CALLABLE_DECLARE(int tcp_v4_rcv(struct sk_buff *));
void ip_protocol_deliver_rcu(struct net *net, struct sk_buff *skb, int protocol)
{
const struct net_protocol *ipprot;
int raw, ret;
// RCU 안전 방식으로 'protocol' 번호에 해당하는 L4 프로토콜 핸들러 정보를 가져옵니다.
ipprot = rcu_dereference(inet_protos[protocol]);
if (ipprot) {
// TCP의 경우 tcp_v4_rcv, UDP의 경우 udp_rcv가 호출됩니다.
ret = INDIRECT_CALL_2(ipprot->handler, tcp_v4_rcv, udp_rcv, skb);
// (ret 값에 따른 추가 처리 로직이 있을 수 있으나 생략)
}
}
rcu_dereference(inet_protos[protocol])
을 통해 protocol
번호 (TCP의 경우 6)에 해당하는 struct net_protocol
구조체 포인터(ipprot
)를 RCU의 보호 하에 안전하게 가져옵니다.
우리의 TCP/IPv4 패킷의 경우, 이 구조체내의 handler
는 바로 tcp_v4_rcv
함수를 가리키게 됩니다.
🔩 tcp_v4_rcv
패킷이 L4, 즉 TCP 계층에 도달했습니다. tcp_v4_rcv
함수는 IPv4를 통해 수신된 TCP 세그먼트 처리의 시작점입니다.
int tcp_v4_rcv(struct sk_buff *skb)
{
// TCP 헤더 길이 유효성 검사, 체크섬 검사 등을 수행합니다.
lookup:
// 이 세그먼트가 속한 TCP 소켓(sk)을 tcp_hashinfo 해시 테이블에서 찾습니다.
sk = __inet_lookup_skb(&tcp_hashinfo, skb, __tcp_hdrlen(th), th->source,
th->dest, sdif, &refcounted);
ret = 0;
if (!sock_owned_by_user(sk)) {
// 현재 소켓이 사용자 공간 코드에 의해 잠겨있지 않다면 즉시 TCP 처리를 진행합니다.
ret = tcp_v4_do_rcv(sk, skb);
} else {
// 패킷을 소켓의 백로그(backlog) 큐에 추가하고 나중에 처리합니다.
if (tcp_add_backlog(sk, skb))
goto discard_and_relse; // 백로그 추가 실패 시 폐기
}
return ret;
// ... (오류 처리 및 레이블 정리 부분 생략)
}
이 함수에서 일어나는 주요 과정은 다음과 같습니다:
- 기본 검증: TCP 헤더의 유효성(길이 등)과 체크섬을 검사합니다. 여기서 오류가 발견되면 패킷은 폐기됩니다.
- 소켓 탐색 (
__inet_lookup_skb
):skb
에 담긴 출발지/목적지 IP 주소와 포트 번호, 그리고 네트워크 인터페이스 정보 등을 사용하여 이 TCP 세그먼트가 속한struct sock
(소켓 구조체) 객체를 커널의 TCP 소켓 해시 테이블 (tcp_hashinfo
)에서 찾아냅니다. ESTABLISHED 상태의 연결이라면 반드시 해당 소켓이 존재해야 합니다. - 소유권 확인 (
sock_owned_by_user
): 찾아낸 소켓(sk
)이 현재 사용자 공간 프로세스에 의해 "소유" (잠금 상태)되어 있는지 확인합니다.
if (!sock_owned_by_user(sk)) {
ret = tcp_v4_do_rcv(sk, skb);
} else {
if (tcp_add_backlog(sk, skb))
goto discard_and_relse;
}
유저가 소켓을 점유하지 않은 순간 tcp_v4_do_rcv(sk, skb) 가 실행됩니다.
유저가 소켓을 점유한 순간 이란?
사용자가 recv()
, sendmsg()
, close()
등 소켓 관련 시스템 콜을 호출하면, 커널은 해당 시스템 콜을 처리하는 동안 해당 소켓 객체에 대한 잠금(lock)을 획득합니다. 간단한 recv 함수를 통해 예시를 들어보겠습니다.
while ((recv_len = recv(sockfd, buffer, 256, 0)) == -1) {
if (errno == EINTR) {
continue;
} else {
fprintf(stderr, "Recv Error: %s\n", strerror(errno));
return -1;
}
}
recv(sockfd, buffer, 256, 0)) 이걸 커널 내부에서 실행하는 아주 짧은 순간을 말합니다.
fprintf(stderr, "Recv Error: %s\n", strerror(errno)) 를 실행하는 순간 - 이미 잠금이 해제된 상황입니다.
📔 tcp_v4_do_rcv
이 함수는 소켓이 사용자에게 잠겨있지 않을 때, 실제 TCP 상태에 따른 처리를 수행하는 함수입니다. 우리는 TCP_ESTABLISHED
상태에 집중합니다.
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
struct sock *rsk;
// ... (TCP_LISTEN 상태 등 다른 상태 처리 로직 생략)
if (sk->sk_state == TCP_ESTABLISHED) { // 연결이 확립된 상태라면
//소켓의 수신 경로 캐시를 가져옵니다. (라우팅 관련 최적화)
struct dst_entry *dst = sk->sk_rx_dst;
//RPS 처리를 위한 RX 해시값을 소켓에 저장합니다.
sock_rps_save_rxhash(sk, skb);
//NAPI에서 어떤 코어로부터 이 소켓을 받았는지 기록
sk_mark_napi_id(sk, skb);
//목적지 캐시 유효성 검사 (라우팅 경로가 여전히 유효한지 등)
if (dst) {
if (inet_sk(sk)->rx_dst_ifindex != skb->skb_iif ||
!INDIRECT_CALL_1(dst->ops->check, ipv4_dst_check,
dst, 0)) {
// 캐시된 경로 정보가 유효하지 않으면 해제.
dst_release(dst);
sk->sk_rx_dst = NULL;
}
}
//실제 데이터 처리: ESTABLISHED 상태의 TCP 세그먼트 처리 함수 호출
tcp_rcv_established(sk, skb);
return 0;
}
}
TCP_ESTABLISHED
상태의 소켓에 대해, 이 함수는 몇 가지 준비 작업을 수행합니다:
sock_rps_save_rxhash(sk, skb)
: Receive Packet Steering (RPS)은 소프트웨어적인 방식으로 수신 패킷 처리를 여러 CPU 코어로 분산시키는 기술입니다. 이 함수는 패킷의 해시값을 계산하여 소켓에 저장해두면, 동일한 TCP 흐름에 속하는 후속 패킷들이 일관되게 특정 CPU에서 처리될 가능성을 높여 캐시 효율성을 향상시킵니다.sk_mark_napi_id(sk, skb)
: NAPI 컨텍스트에서 이 패킷을 처리한 CPU의 ID를 소켓에 기록합니다. 애플리케이션은getsockopt
으로SO_INCOMING_CPU
옵션을 사용하여 이 정보를 얻을 수 있으며, 이를 통해 데이터 지역성(data locality)을 높이기 위해 스레드를 해당 CPU에 바인딩하는 등의 최적화를 고려할 수 있습니다.- 캐시된 수신 경로(
sk->sk_rx_dst
)의 유효성을 검사하고, 필요시 갱신합니다.
이러한 준비가 끝나면, 드디어 tcp_rcv_established(sk, skb)
함수를 호출하여 ESTABLISHED 상태의 TCP 세그먼트에 대한 본격적인 처리를 시작합니다.
💣 tcp_rcv_established
이 함수는 TCP_ESTABLISHED
상태의 소켓에 도착한 TCP 세그먼트를 처리하는 핵심 로직을 담고 있습니다. 성능이 매우 중요하기 때문에 "fast path"와 "slow path"로 나뉘어 구현되어 있습니다.
void tcp_rcv_established(struct sock *sk, struct sk_buff *skb)
{
const struct tcphdr *th = (const struct tcphdr *)skb->data;
struct tcp_sock *tp = tcp_sk(sk);
unsigned int len = skb->len;
if ((tcp_flag_word(th) & TCP_HP_BITS) == tp->pred_flags &&
TCP_SKB_CB(skb)->seq == tp->rcv_nxt &&
!after(TCP_SKB_CB(skb)->ack_seq, tp->snd_nxt)) {
// --- Fast Path 시작 ---
int tcp_header_len = tp->tcp_header_len;
if (len <= tcp_header_len) {
if (len == tcp_header_len) {
//ACK 만 있는 패킷처리 (예: ACK 처리, 윈도우 업데이트 등)
return;
}
} else {
// 페이로드가 있는 데이터 세그먼트
int eaten = 0;
bool fragstolen = false;
//패킷의 Timestamp 값 저장 (타임스탬프 옵션이 있고, 특정 조건 만족 시)
if (tcp_header_len ==
(sizeof(struct tcphdr) + TCPOLEN_TSTAMP_ALIGNED) &&
tp->rcv_nxt == tp->rcv_wup)
tcp_store_ts_recent(tp);
//rtt 측정 (타임스탬프 옵션이 있다면)
tcp_rcv_rtt_measure_ts(sk, skb);
//TCP 헤더 제거: skb->data 포인터를 페이로드 시작 지점으로 이동
__skb_pull(skb, tcp_header_len);
//내부 receive buffer에 데이터 적재
eaten = tcp_queue_rcv(sk, skb, &fragstolen);
// 데이터가 수신되었음을 알립니다 (예: select/poll/epoll 대기 중인 프로세스 깨우기).
tcp_data_ready(sk);
return;
}
}
slow_path:
// slow path 처리
}
Fast Path란?
TCP는 성능에 매우 민감한 프로토콜입니다. 따라서 커널은 일반적이고 정상적인 상황(예: 데이터가 순서대로 잘 도착하고, ACK도 문제없는 경우)에 대해서는 최소한의 검증만을 거쳐 매우 빠르게 패킷을 처리하는 "fast path"를 마련해 두었습니다.
- 헤더 예측 (
tp->pred_flags
): 직전에 수신했던 패킷의 플래그를 기반으로 이번에 올 패킷의 플래그를 예측하여, 일치하면 더 빠른 처리를 시도합니다. - 순차적인 데이터 (
TCP_SKB_CB(skb)->seq == tp->rcv_nxt
): 패킷의 시퀀스 번호가 소켓이 다음에 수신할 것으로 예상하는 시퀀스 번호와 정확히 일치해야 합니다. - 정상적인 ACK (
!after(TCP_SKB_CB(skb)->ack_seq, tp->snd_nxt)
): 상대방이 보내온 ACK 번호가 우리의 송신 윈도우 범위 내에 있어야 합니다.
이 fast path 조건에 부합하면, 다음과 같은 중요한 작업들이 신속하게 이루어집니다:
- 페이로드 유무 판단, 타임스탬프 처리
- TCP 헤더 제거 (
__skb_pull
): IP 헤더와 마찬가지로 TCP 헤더도skb
에서 "벗겨내어"skb->data
가 실제 페이로드를 가리키도록 합니다. - 수신 버퍼에 데이터 추가 (
tcp_queue_rcv
): 드디어, 애플리케이션이recv()
등으로 읽어갈 데이터를 소켓의 수신 버퍼 (sk->sk_receive_queue
)에 추가합니다! - 데이터 준비 알림 (
tcp_data_ready
): 수신 버퍼에 새로운 데이터가 도착했음을 커널의 다른 부분이나 대기 중인 사용자 애플리케이션에게 알립니다. 이 호출을 통해recv()
나epoll_wait()
등으로 잠들어 있던 스레드가 깨어날 수 있습니다.
만약 fast path 조건을 만족하지 못하면 (예: 순서가 뒤섞인 패킷, URG 플래그, 예상치 못한 ACK 등), 패킷은 slow_path:
레이블 아래의 복잡한 로직으로 넘어가 처리됩니다. Slow path는 다양한 예외 상황과 TCP의 정교한 메커니즘(재전송 처리, 혼잡 제어 반응 등)을 다루기 때문에 훨씬 더 많은 검사와 계산을 수행합니다.
우리는 정상적인 단일 데이터 패킷을 따라가고 있으므로, fast path에서 tcp_queue_rcv
와 tcp_data_ready
가 호출되는 과정을 살펴보겠습니다.
📧 tcp_queue_rcv
이 함수는 skb
에 담긴 수신 데이터를 소켓의 실제 수신 큐(sk->sk_receive_queue
)에 추가하는 역할을 합니다.
static int __must_check tcp_queue_rcv(struct sock *sk, struct sk_buff *skb,
bool *fragstolen)
{
int eaten;
struct sk_buff *tail = skb_peek_tail(&sk->sk_receive_queue);
// tcp_try_coalesce: 만약 현재 skb의 데이터를 이전 skb(tail)에 합칠 수 있다면 합칩니다.
eaten = (tail &&
tcp_try_coalesce(sk, tail,
skb, fragstolen)) ? 1 : 0;
if (!eaten) {
// 현재 skb를 소켓의 수신 큐(sk_receive_queue)의 꼬리에 추가합니다.
__skb_queue_tail(&sk->sk_receive_queue, skb);
skb_set_owner_r(skb, sk);
}
return eaten;
}
sk->sk_receive_queue
가 바로 우리가 흔히 말하는 소켓의 수신 버퍼입니다. 이 큐는 sk_buff
구조체들의 연결 리스트(linked list) 형태로 구현되어 있습니다.
- 데이터 병합 시도 (
tcp_try_coalesce
): 먼저, 현재 도착한skb
의 데이터를 수신 큐의 맨 마지막skb
(tail
)와 합칠 수 있는지 시도합니다. 이는 작은 TCP 세그먼트가 여러 개 도착했을 때 이를 하나의 큰skb
로 만들어 처리 효율을 높이는 중요한 최적화입니다. - 큐에 추가 (
__skb_queue_tail
): 현재skb
를sk->sk_receive_queue
의 꼬리에 직접 추가합니다.
중요한 점: 이 단계에서 skb
를 큐에 추가할 때, skb
에 담긴 데이터 페이로드를 소켓 버퍼 영역으로 또다시 복사하는 것이 아닙니다! skb
자체 (더 정확히는 skb
구조체 포인터)가 큐에 연결되는 것입니다. skb->data
가 가리키는 메모리 영역은 Ring Buffer 로 유지되고 있습니다. 실제 데이터 복사는 추후 애플리케이션이 recv()
계열 함수를 호출하여 사용자 공간 버퍼로 데이터를 읽어갈 때 발생합니다.
🙌 tcp_data_ready
수신 버퍼에 데이터가 성공적으로 추가되었다면, tcp_data_ready
함수가 호출되어 이 사실을 알려야 합니다.
void tcp_data_ready(struct sock *sk)
{
if (tcp_epollin_ready(sk, sk->sk_rcvlowat) || sock_flag(sk, SOCK_DONE))
sk->sk_data_ready(sk);
}
이 함수는 tcp_epollin_ready
를 통해 소켓의 수신 버퍼에 애플리케이션이 읽어갈 만한 데이터가 있는지 등을 확인합니다.
이 조건이 참이면, sk->sk_data_ready(sk)
함수 포인터를 호출합니다. 이 함수 포인터가 바로 사용자 공간에서 recv()
, select()
, poll()
, epoll_wait()
등으로 데이터 수신을 기다리고 있는 프로세스/스레드를 깨우는 역할을 수행합니다.
🌂 sk_data_ready 란?
sk->sk_data_ready
는 struct sock
구조체 내에 정의된 함수 포인터입니다.
struct sock
struct sock {
// ... (수많은 다른 멤버들)
void (*sk_data_ready)(struct sock *sk);
};
이 함수 포인터는 소켓이 초기화될 때 특정 함수로 설정됩니다. TCP/IP 소켓의 경우, 일반적으로 sock_def_readable
함수로 초기화됩니다.
sock_def_readable
sock_init_data
함수 내부에서 소켓의 기본 콜백들이 설정될 때 sk->sk_data_ready
는 sock_def_readable
을 가리키게 됩니다.
void sock_init_data(struct socket *sock, struct sock *sk)
{
// ...
sk->sk_data_ready = sock_def_readable; // sk_data_ready에 sock_def_readable 할당
}
따라서 sk->sk_data_ready(sk)
를 호출하면 실제로는 sock_def_readable(sk)
함수가 실행됩니다.
void sock_def_readable(struct sock *sk)
{
struct socket_wq *wq;
rcu_read_lock();
wq = rcu_dereference(sk->sk_wq);
if (skwq_has_sleeper(wq)) //
// wait queue에 잠들어 있는 태스크가 있다면 해당 태스크들을 깨우고, EPOLLIN 등의 이벤트 플래그를 전달합니다.
wake_up_interruptible_sync_poll(&wq->wait, EPOLLIN | EPOLLPRI | EPOLLRDNORM | EPOLLRDBAND);
// 비동기 I/O (SIGIO) 알림을 요청한 프로세스에게 시그널을 보냅니다.
sk_wake_async(sk, SOCK_WAKE_WAITD, POLL_IN);
rcu_read_unlock();
}
sock_def_readable
함수의 역할은 다음과 같습니다:
rcu_dereference(sk->sk_wq)
: 소켓의 대기 큐 헤드(struct socket_wq
)를 RCU 보호 하에 가져옵니다. 이 대기 큐에는recv()
,select()
,poll()
,epoll_wait()
등과 같은 블로킹 시스템 콜을 호출하여 데이터 수신을 기다리며 잠들어 있는(sleep) 태스크(프로세스/스레드)들이 등록되어 있습니다.skwq_has_sleeper(wq)
: 대기 큐에 잠든 태스크가 있는지 확인합니다.wake_up_interruptible_sync_poll(&wq->wait, ...)
: 만약 잠든 태스크가 있다면, 이 함수를 호출하여 해당 태스크들을 깨웁니다 (TASK_INTERRUPTIBLE 상태에서 TASK_RUNNING 상태로 변경). 이때, 두 번째 인자로 전달된 이벤트 마스크 (EPOLLIN | EPOLLPRI | ...
)는poll
이나epoll
시스템 콜이 어떤 종류의 이벤트가 발생했는지 알 수 있도록 해줍니다.EPOLLIN
은 "읽을 데이터가 있음"을 의미합니다.sk_wake_async(sk, SOCK_WAKE_WAITD, POLL_IN)
: 비동기 I/O 알림을 설정한 프로세스(예:fcntl
로F_SETOWN
,F_SETFL
을 통해O_ASYNC
플래그를 설정한 경우)에게SIGIO
시그널을 보내 데이터가 준비되었음을 알립니다.POLL_IN
은 입력 가능 이벤트를 나타냅니다. (현대의 고성능 서버에서는epoll
과 같은 동기 이벤트 다중화 방식이 더 널리 쓰입니다.)
이로써, 커널은 수신 버퍼에 도착한 데이터를 사용자 공간의 애플리케이션이 인지하고 읽어갈 수 있도록 모든 준비를 마치게 됩니다. 패킷의 기나긴 여정이 드디어 애플리케이션 코앞까지 도달한 것입니다!
마치며
NAPI를 통해 하드웨어 인터럽트의 부담을 줄이며 커널로 효율적으로 전달된 패킷은, 이더넷 프레임에서 IP 패킷으로, 다시 TCP 세그먼트로 단계별로 처리되었습니다. 각 계층에서는 Netfilter 훅을 통해 방화벽 규칙 적용이나 패킷 변형과 같은 유연한 처리가 가능했고, 최종적으로 sk_receive_queue
라는 소켓의 수신 버퍼에 sk_buff
형태로 저장되었습니다.
그리고 tcp_data_ready
와 sock_def_readable
을 통해, 데이터 수신을 간절히 기다리고 있던 애플리케이션의 recv()
호출이나 epoll_wait()
루프가 깨어나게 됩니다. 이 시점까지 데이터는 여전히 커널이 관리하는 sk_buff
메모리 영역에 존재하며, 애플리케이션이 recv()
계열 함수를 호출하여 사용자 공간 버퍼로 명시적으로 읽어갈 때 비로소 데이터 복사가 일어납니다.
지금까지 리눅스 커널 네트워크 스택 내부의 복잡하면서도 정교한 패킷 수신 경로를 함께 탐험해 보았습니다. 이 시리즈를 통해 전체적인 줄기의 흐름을 알 수 있었습니다. 세부적인 동기화 방식이나 프로토콜 처리 내역등은 더 깊은 공부가 필요할 것 같습니다. 이 글이 여러분의 네트워크 시스템 이해에 도움이 되었기를 바랍니다.
Reference
https://github.com/torvalds/linux/blob/v5.15/net/core/dev.c
https://github.com/torvalds/linux/blob/v5.15/include/linux/netdevice.h
https://github.com/torvalds/linux/blob/v5.15/net/ipv4/af_inet.c
https://github.com/torvalds/linux/blob/v5.15/net/ipv4/ip_input.c
https://github.com/torvalds/linux/blob/v5.15/include/linux/netfilter.h
https://github.com/torvalds/linux/blob/v5.15/include/net/dst.h
https://github.com/torvalds/linux/blob/v5.15/net/ipv4/tcp_ipv4.c
https://github.com/torvalds/linux/blob/v5.15/net/ipv4/tcp_input.c
https://github.com/torvalds/linux/blob/v5.15/include/net/sock.h
https://github.com/torvalds/linux/blob/v5.15/net/core/sock.c
'CS > Linux' 카테고리의 다른 글
[Linux] epoll의 내부 동작 방식 (1) | 2025.06.01 |
---|---|
[Linux] recv()는 어떻게 잠들고 깨어날까? (0) | 2025.05.23 |
[Linux] Ring Buffer 에서 NAPI: 리눅스 커널 네트워크 패킷 수신 여정 #2 (0) | 2025.05.21 |
[Linux] NIC에서 링 버퍼까지: 리눅스 커널 네트워크 패킷 수신 여정 #1 (0) | 2025.05.20 |
[Linux] 페이지 테이블 구조 (0) | 2025.05.15 |