sk_buff 자료구조
Linux 커널 네트워크 스택(Network Stack)의 핵심 자료구조 sk_buff: 메모리 레이아웃, 데이터 조작 함수, clone/copy, 소켓(struct sock) 연동, 소켓 옵션, raw socket, 체크섬(Checksum) 오프로드, GSO/GRO, zero-copy 전송까지 종합 분석합니다. 커널 내부 데이터 경로, 핵심 자료구조/API, 운영 환경 튜닝 포인트와 장애 디버깅(Debugging) 절차까지 실무 관점으로 다룹니다.
핵심 요약
- 메모리 레이아웃 —
head,data,tail,end4개 포인터로 버퍼(Buffer) 관리.skb_push/pull/put로 데이터 영역 조작. - 참조 모델 —
clone은 메타데이터만 복사하고 버퍼 공유,copy는 완전 복사. 참조 카운트(Reference Count) 관리가 핵심. - 소켓 메모리 —
sk_rmem_alloc/sk_wmem_alloc이truesize기반 소켓 버퍼 제한 구현. - 헤더 포인터 —
mac_header,network_header,transport_header로 L2/L3/L4 헤더 오프셋(Offset) 추적. - 수명주기 — 할당 → 프로토콜 처리 → 소켓 전달 → 사용자 복사 → 해제. 각 단계에서 다른 함수와 상태 변화.
- skb 확장 —
skb_ext로 conntrack, IPsec secpath, bridge NF 등 가변 메타데이터를 skb에 동적 연결. 5.x+에서 메모리 효율 향상. - page_pool — 최신 고성능 드라이버(6.x+)는 page_pool로 DMA 매핑(Mapping) 캐시(Cache)와 페이지(Page) 재활용(Recycling)을 구현해 할당/해제 비용 최소화.
- XDP 인터페이스 —
xdp_buff는 skb 할당 이전 단계로 동작. XDP_PASS 시build_skb()를 통해 sk_buff로 변환되어 일반 스택 진입.
단계별 이해
- 구조체(Struct) 이해
4개 포인터(head, data, tail, end)와 3개 헤더 포인터(mac, network, transport)의 관계를 먼저 익힙시다. 이게 sk_buff의 핵심입니다. - 데이터 조작 함수
skb_push(헤더 추가),skb_pull(헤더 제거),skb_put(데이터 추가)의 동작을 코드로 직접 연습합니다. - 할당 함수 선택
alloc_skb(일반),netdev_alloc_skb(드라이버 수신),napi_alloc_skb(NAPI),page_pool_alloc_pages(6.x 고성능) 차이점을 파악합니다. - 수명주기 추적
수신 경로(NIC → 드라이버 → L2 → L3 → L4 → 소켓)와 전송 경로의 함수 호출 체인을 따라가며 이해합니다. - 확장 시스템 학습
skb_ext,page_pool, XDP ↔ sk_buff 변환 등 현대 커널의 최적화 메커니즘을 파악해 고성능 드라이버 코드를 읽는 눈을 키웁니다. - 실전 디버깅 연습
perf trace -e skb:kfree_skb로 드롭 원인을 추적하고,/proc/net/softnet_stat으로 CPU별 처리량을 분석하며,dropwatch로 병목(Bottleneck) 지점을 찾아봅니다.
개요
struct sk_buff(흔히 skb라 부름)는 Linux 네트워크 스택에서 모든 패킷을 표현하는 자료구조입니다. 수신 패킷이든 전송 패킷이든, L2 프레임이든 L4 세그먼트든, 커널 내에서 네트워크 데이터가 이동할 때는 항상 sk_buff가 사용됩니다.
O'Reilly의 "Understanding Linux Network Internals"에서 Christian Benvenuti는 sk_buff를 "네트워크 스택의 왕(king of networking)"이라 표현했습니다. 이 자료구조를 이해하지 않고는 Linux 네트워크 코드를 읽을 수 없습니다.
- 헤더 파일:
<linux/skbuff.h> - 주요 소스:
net/core/skbuff.c - 구조체 크기: x86_64에서 약 232바이트 (커널 6.x 기준)
struct sk_buff 주요 필드
/* include/linux/skbuff.h (주요 필드만 발췌) */
struct sk_buff {
union {
struct {
struct sk_buff *next; /* 리스트 내 다음 skb */
struct sk_buff *prev; /* 리스트 내 이전 skb */
};
struct rb_node rbnode; /* TCP retransmit queue용 */
};
struct sock *sk; /* 소속 소켓 */
struct net_device *dev; /* 수신/전송 네트워크 디바이스 */
unsigned int len; /* 전체 데이터 길이 (linear + frags) */
unsigned int data_len; /* 비선형(paged) 데이터 길이 */
__u16 mac_len; /* MAC 헤더 길이 */
__u16 hdr_len; /* 클론 시 writable 헤더 길이 */
__be16 protocol; /* 패킷 프로토콜 (ETH_P_IP 등) */
__u32 priority; /* QoS 우선순위 */
sk_buff_data_t transport_header; /* L4 헤더 오프셋 */
sk_buff_data_t network_header; /* L3 헤더 오프셋 */
sk_buff_data_t mac_header; /* L2 헤더 오프셋 */
sk_buff_data_t tail; /* 데이터 끝 */
sk_buff_data_t end; /* 할당된 버퍼 끝 */
unsigned char *head; /* 할당된 버퍼 시작 */
unsigned char *data; /* 실제 데이터 시작 */
unsigned int truesize; /* 실제 메모리 사용량 */
refcount_t users; /* 참조 카운트 */
};
코드 설명
- next/prev, rbnode
include/linux/skbuff.h에서 정의된 union으로, 일반 큐(sk_buff_head)에서는next/prev이중 연결 리스트를, TCP 재전송 큐에서는rb_node를 사용합니다. 메모리를 절약하면서 두 가지 큐 방식을 지원합니다. - sk, dev
sk는 이 패킷이 속한 소켓을 가리키며 메모리 과금(truesize기반)에 사용됩니다.dev는 수신 시 입력 인터페이스, 전송 시 출력 인터페이스를 가리킵니다. 라우팅 결정 후dev가 변경될 수 있습니다. - len, data_len
len은 linear + paged 전체 데이터 길이이고,data_len은 비선형(frags[] + frag_list) 부분만의 길이입니다. linear 영역 크기는skb_headlen(skb) = len - data_len으로 계산합니다. - transport/network/mac_headerL4/L3/L2 헤더의
head기준 오프셋입니다.skb_reset_transport_header()등으로 현재data위치를 기록하며,tcp_hdr(skb),ip_hdr(skb),eth_hdr(skb)매크로가 이 오프셋을 사용해 헤더 포인터를 반환합니다. - head, data, tail, end4개 포인터가 버퍼 레이아웃의 핵심입니다.
head~data가 headroom,data~tail이 실제 데이터,tail~end가 tailroom입니다.end직후에skb_shared_info가 배치됩니다. - truesize, users
truesize는 소켓 메모리 추적에 사용되는 실제 메모리 소비량(sk_buff 구조체 + 데이터 버퍼)입니다.users는 sk_buff 자체의 참조 카운트로, 0이 되면__kfree_skb()가 호출됩니다.
자주 사용되는 추가 필드
위의 핵심 필드 외에도, sk_buff에는 프로토콜 처리와 성능 최적화에 쓰이는 중요한 필드들이 있습니다:
struct sk_buff {
/* ... 위의 핵심 필드들 ... */
char cb[48]; /* 프로토콜별 제어 블록 (Control Buffer) */
__u32 hash; /* 패킷 해시 (RSS, flow steering) */
__u8 pkt_type:3; /* PACKET_HOST, PACKET_BROADCAST 등 */
__u8 ip_summed:2; /* 체크섬 오프로드 상태 */
__u32 mark; /* netfilter/tc 마킹 (iptables -j MARK) */
__u16 queue_mapping; /* 멀티큐 NIC 큐 인덱스 */
unsigned int napi_id; /* NAPI 구조체 ID (busy polling) */
union {
__u32 tstamp; /* 수신 타임스탬프 */
u64 skb_mstamp_ns; /* 고해상도 타임스탬프 */
};
__u8 cloned:1; /* clone 여부 */
__u8 nohdr:1; /* 페이로드 참조만 (헤더 없음) */
__u8 peeked:1; /* MSG_PEEK으로 이미 확인됨 */
};
| 필드 | 크기 | 용도 | 접근 방법 |
|---|---|---|---|
cb[48] | 48바이트 | 프로토콜 레이어가 임시 데이터 저장 (TCP: tcp_skb_cb) | TCP_SKB_CB(skb), IPCB(skb) |
hash | 32비트 | 수신 패킷의 flow hash (RSS, RPS에 활용) | skb_get_hash(skb) |
pkt_type | 3비트 | 패킷 수신 유형: HOST, BROADCAST, MULTICAST, OTHERHOST | 직접 접근 |
ip_summed | 2비트 | 체크섬 오프로드 모드 (NONE/UNNECESSARY/COMPLETE/PARTIAL) | 직접 접근 |
mark | 32비트 | netfilter, tc, 라우팅(Routing) 결정에 사용되는 패킷 마크 | 직접 접근 |
queue_mapping | 16비트 | 멀티큐 NIC에서 TX/RX 큐 선택 | skb_get_queue_mapping(skb) |
napi_id | 32비트 | NAPI 인스턴스 식별 (SO_BUSY_POLL 연동) | 직접 접근 |
cb[48]은 각 프로토콜 레이어가 자신만의 구조체로 캐스팅하여 사용합니다. 예를 들어 TCP는 TCP_SKB_CB(skb)로 struct tcp_skb_cb에 시퀀스 번호, 타임스탬프 등을 저장합니다. 한 레이어가 cb를 사용하면 다음 레이어에서 덮어쓰므로, 레이어 간 데이터 전달에는 사용하지 마세요.
sk_buff 비트필드 및 플래그 완전 맵
sk_buff에는 수십 개의 1비트 플래그가 내장되어 있습니다. 이 플래그들은
네트워킹 서브시스템의 다양한 계층이 설정하며, 패킷의 현재 상태와 처리 방향을 결정합니다.
아래는 카테고리별로 분류한 완전한 목록입니다.
플래그 카테고리별 SVG 비트맵
네트워킹 플래그 상세
| 플래그 | 비트 수 | 설정 주체 | 의미 및 사용 시점 |
|---|---|---|---|
cloned | 1 | skb_clone() |
데이터 버퍼를 다른 sk_buff와 공유 중임을 표시합니다. 설정 시 쓰기 전에 skb_copy()가 필요합니다. |
peeked | 1 | 소켓 수신 경로 | MSG_PEEK 플래그로 소켓 버퍼를 들여다볼 때 설정됩니다. 실제 수신 시 참조 카운트 이중 감소를 방지합니다. |
nohdr | 1 | skb_header_release() |
클론된 skb의 헤더 영역이 더 이상 수정되지 않음을 표시합니다. pskb_expand_head() 생략을 허용합니다. |
fclone | 2 | skb_fclone_busy() |
빠른 클론(fast clone) 풀에서 할당된 skb임을 표시합니다. SKB_FCLONE_ORIG, SKB_FCLONE_CLONE, SKB_FCLONE_UNAVAILABLE 값을 가집니다. |
pkt_type | 3 | 드라이버, eth_type_trans() |
패킷 수신 유형입니다. PACKET_HOST(자신), PACKET_BROADCAST, PACKET_MULTICAST, PACKET_OTHERHOST(도청) 등을 구분합니다. |
ooo_okay | 1 | TCP 송신 경로 | 이 패킷이 순서 외(Out-Of-Order) 전송을 허용함을 표시합니다. MPTCP, SCTP 멀티스트리밍에서 활용됩니다. |
l4_hash | 1 | skb_set_hash() |
L4 5-튜플 기반 해시가 hash 필드에 유효함을 표시합니다. RSS(Receive Side Scaling) 큐 선택에 사용됩니다. |
sw_hash | 1 | 소프트웨어 해시 계산 경로 | 하드웨어가 아닌 소프트웨어가 계산한 해시임을 표시합니다. 하드웨어 RSS와 구분하기 위해 사용합니다. |
오프로드 플래그 상세
| 플래그 | 비트 수 | 설정 주체 | 의미 |
|---|---|---|---|
ip_summed | 2 | 드라이버, IP 스택 | CHECKSUM_NONE: 체크섬 미검증. CHECKSUM_UNNECESSARY: HW 검증 완료. CHECKSUM_COMPLETE: HW가 전체 합산 제공. CHECKSUM_PARTIAL: 송신 시 HW에 위임. |
csum_valid | 1 | IP/TCP 수신 경로 | 체크섬이 소프트웨어에 의해 검증되었음을 표시합니다. GRO(Generic Receive Offload) 병합 시 중요합니다. |
csum_complete_sw | 1 | 소프트웨어 체크섬 경로 | 하드웨어 없이 소프트웨어가 CHECKSUM_COMPLETE 수준의 체크섬을 계산하였음을 표시합니다. |
csum_not_inet | 1 | SCTP, FCOE 등 | 인터넷 표준(RFC 1071) 체크섬이 아님을 표시합니다. SCTP CRC-32c, FCoE CRC32 등 별도 알고리즘 사용 시 설정합니다. |
remcsum_offload | 1 | UDP 터널 오프로드 | 원격 체크섬 오프로드(Remote Checksum Offload) 동작 중임을 표시합니다. VXLAN, Geneve 등 UDP 터널에서 사용합니다. |
offload_fwd_mark | 1 | 하드웨어 스위치 드라이버 | 하드웨어가 이미 패킷을 포워딩하였음을 표시합니다. 소프트웨어 브리지가 재처리하지 않도록 합니다. |
slow_gro | 1 | GRO 수신 경로 | 이 패킷이 GRO 병합에 적합하지 않아 슬로우패스로 처리됨을 표시합니다. |
wifi_acked | 1 | mac80211 | WiFi 레이어에서 ACK를 수신하였음을 표시합니다. TCP 소켓의 SO_WIFI_STATUS 소켓 옵션 응답에 사용됩니다. |
터널·캡슐화 플래그 상세
| 플래그 | 의미 |
|---|---|
encapsulation |
이 skb가 터널 캡슐화 패킷임을 표시합니다. 내부 패킷의 헤더 오프셋(inner_*_header)이 유효합니다. |
encap_hdr_csum |
내부 헤더의 체크섬이 계산되어 있음을 표시합니다. 캡슐화 해제 시 재계산을 생략할 수 있습니다. |
inner_protocol_type |
내부 프로토콜 유형 식별자입니다. SKB_INNER_PROTOCOL_TYPE_IANA_L3 또는 SKB_INNER_PROTOCOL_TYPE_ETH_TYPE 값을 가집니다. |
dst_pending_confirm |
라우팅 캐시 항목(dst_entry)의 유효성 확인이 필요함을 표시합니다. ARP/NDP 갱신이 필요한 경로에서 설정됩니다. |
ndisc_nodetype |
IPv6 이웃 발견(NDP) 노드 유형을 구분합니다. NDISC_NODETYPE_UNSPEC, NDISC_NODETYPE_HOST, NDISC_NODETYPE_NODEFAULT, NDISC_NODETYPE_DEFAULT. |
ipvs_property |
IPVS(IP Virtual Server)가 처리 중인 패킷임을 표시합니다. Netfilter 훅에서 IPVS 내부 패킷을 구분하기 위해 사용합니다. |
no_fcs |
이더넷 FCS(Frame Check Sequence)가 포함되지 않음을 표시합니다. 일부 가상 드라이버나 내부 인터페이스에서 설정합니다. |
TC/QoS, 보안, 타임스탬프 플래그 상세
| 카테고리 | 플래그 | 의미 |
|---|---|---|
| TC/QoS | tc_skip_classify |
TC 분류기를 건너뛰도록 요청합니다. 루프백이나 이미 분류된 패킷에 설정합니다. |
tc_at_ingress |
인그레스(수신) 방향의 TC 훅에서 처리 중임을 표시합니다. sch_handle_ingress()에서 설정합니다. |
|
redirected |
TC BPF 프로그램이 패킷을 다른 인터페이스로 리다이렉트하였음을 표시합니다. | |
from_ingress |
리다이렉트된 패킷이 인그레스 경로에서 왔음을 표시합니다. skb_redirect_from_ingress()에서 참조합니다. |
|
| 보안 | decrypted |
TLS 오프로드 또는 IPsec에 의해 복호화가 완료되었음을 표시합니다. 상위 레이어가 중복 복호화를 시도하지 않도록 합니다. |
nf_skip_egress |
Netfilter 이그레스 훅을 건너뛰도록 요청합니다. 브리지-라우터 혼합 경로에서 중복 처리를 방지합니다. | |
| 타임스탬프 | mono_delivery_time |
tstamp 필드가 CLOCK_REALTIME이 아닌 단조 시계(CLOCK_MONOTONIC) 기반 전달 예정 시각을 담고 있음을 표시합니다. EDT(Earliest Departure Time) 스케줄링에 사용합니다. |
tstamp_type |
타임스탬프 유형을 구분합니다 (수신 시각 vs 예약 전송 시각). 커널 6.x에서 추가되었습니다. | |
| 기타 | pfmemalloc |
메모리 압박 상황(PFMEMALLOC)에서 할당된 skb임을 표시합니다. 일반 소켓의 수신 큐 입큐를 거부하여 메모리 고갈을 방지합니다. |
pp_recycle |
페이지 풀(Page Pool)에서 할당된 데이터 페이지를 재활용할 수 있음을 표시합니다. XDP/AF_XDP 제로-카피 경로에서 사용합니다. | |
scm_io_uring |
io_uring의 SCM(Socket Control Message) 경로를 통해 전달됨을 표시합니다. |
ip_summed 플래그 실제 사용 예시 (net/ipv4/tcp_input.c)
/* 수신 체크섬 상태에 따른 분기 처리 */
static inline int tcp_checksum_complete(struct sk_buff *skb)
{
return !skb_csum_unnecessary(skb) &&
__tcp_checksum_complete(skb);
}
/* ip_summed 값에 따른 체크섬 불필요 여부 판단 */
static inline bool skb_csum_unnecessary(const struct sk_buff *skb)
{
/* CHECKSUM_UNNECESSARY: HW 검증 완료
* CHECKSUM_COMPLETE: 합산값으로 검증 가능 (csum_valid 추가 확인)
* CHECKSUM_PARTIAL: 송신 시 HW 위임, 수신에서는 미사용 */
return ((skb->ip_summed == CHECKSUM_UNNECESSARY) ||
(skb->ip_summed == CHECKSUM_COMPLETE && skb->csum_valid));
}
/* 드라이버에서 HW 체크섬 결과 설정 */
static void my_driver_rx_checksum(struct sk_buff *skb,
struct hw_desc *desc)
{
if (desc->csum_ok) {
skb->ip_summed = CHECKSUM_UNNECESSARY;
} else if (desc->csum_provided) {
skb->ip_summed = CHECKSUM_COMPLETE;
skb->csum = csum_unfold((__force __sum16)desc->csum_value);
} else {
skb->ip_summed = CHECKSUM_NONE;
}
}
체크섬 오프로드와 ip_summed
ip_summed 필드는 NIC 하드웨어의 체크섬 오프로드 상태를 나타내는 핵심 플래그입니다:
| 값 | 의미 (RX) | 의미 (TX) |
|---|---|---|
CHECKSUM_NONE | HW 미지원, SW 검증 필요 | SW가 체크섬 계산 완료 |
CHECKSUM_UNNECESSARY | HW 검증 완료, 유효함 | 체크섬 불필요 (loopback 등) |
CHECKSUM_COMPLETE | HW가 전체 체크섬 제공 | 사용 안 함 |
CHECKSUM_PARTIAL | 사용 안 함 | HW에 체크섬 계산 위임 |
/* 수신: 드라이버에서 체크섬 상태 설정 */
void my_driver_rx(struct sk_buff *skb, bool csum_ok)
{
if (csum_ok) {
skb->ip_summed = CHECKSUM_UNNECESSARY; /* SW 검증 생략 */
} else {
skb->ip_summed = CHECKSUM_NONE; /* SW 검증 필요 */
}
}
/* 전송: CHECKSUM_PARTIAL 설정 예 */
skb->ip_summed = CHECKSUM_PARTIAL;
skb->csum_start = skb_transport_header(skb) - skb->head;
skb->csum_offset = offsetof(struct udphdr, check);
CHECKSUM_UNNECESSARY를 설정하면 TCP/UDP 프로토콜 스택에서 __skb_checksum_validate_needed()를 건너뛰어 CPU 사이클을 크게 절약합니다. 하지만 일부 buggy NIC에서는 가짜 양수(false positive)가 발생할 수 있어, 문제 발생 시 ethtool -K eth0 rx-checksumming off로 비활성화하고 테스트하세요.
skb 할당과 해제
sk_buff 할당 함수는 사용 상황에 따라 여러 변형이 있습니다:
| 함수 | 컨텍스트 | 특징 |
|---|---|---|
alloc_skb(size, gfp) | 일반 (프로세스(Process)/softirq) | 기본 할당 함수. kmalloc으로 linear 버퍼 할당 |
netdev_alloc_skb(dev, len) | NAPI/irq 수신 경로 | NET_SKB_PAD headroom 자동 확보, per-CPU 캐시 활용 |
napi_alloc_skb(napi, len) | NAPI poll 내부 | NAPI 전용 per-CPU 페이지 캐시(Page Cache), 최적 성능 |
build_skb(data, frag_size) | 사전 할당 버퍼 | 이미 할당된 버퍼에 skb 메타데이터만 생성 |
__alloc_skb(size, gfp, flags) | 내부 API | SKB_ALLOC_FCLONE, SKB_ALLOC_RX 등 플래그 지정 |
/* 일반적인 전송 경로 할당 */
struct sk_buff *skb = alloc_skb(MAX_HEADER + payload_len, GFP_KERNEL);
if (!skb)
return -ENOMEM;
skb_reserve(skb, MAX_HEADER); /* L2/L3/L4 헤더용 headroom */
/* NAPI 수신 경로 할당 (드라이버 내) */
struct sk_buff *skb = napi_alloc_skb(napi, 256); /* 헤더만 linear */
/* 페이로드는 page fragment로 추가 */
skb_add_rx_frag(skb, 0, page, offset, size, truesize);
/* build_skb: XDP, 고성능 드라이버에서 사용 */
void *buf = page_address(page);
struct sk_buff *skb = build_skb(buf, PAGE_SIZE);
if (!skb) {
put_page(page);
return;
}
skb_reserve(skb, headroom);
해제 함수도 상황에 따라 구분됩니다:
| 함수 | 용도 | tracepoint |
|---|---|---|
kfree_skb(skb) | 패킷 드롭 (에러/필터링) | skb:kfree_skb 발생 (원인 추적 가능) |
consume_skb(skb) | 정상적 소비 완료 | skb:consume_skb 발생 |
dev_kfree_skb_any(skb) | 드라이버 (irq/process 모두) | 컨텍스트에 따라 지연 해제 가능 |
dev_consume_skb_any(skb) | 드라이버 정상 소비 | irq-safe한 consume_skb |
kfree_skb_reason(skb, reason) | 드롭 원인 명시 (6.x+) | 드롭 원인을 enum으로 기록 |
kfree_skb()와 consume_skb()의 차이는 기능적으로 동일하지만, tracepoint가 다릅니다. 정상 경로에서 kfree_skb()를 사용하면 드롭 모니터링 도구가 오탐을 보고합니다. 반드시 의미에 맞는 함수를 사용하세요.
__kfree_skb() 내부 해제 경로 소스 분석
sk_buff의 해제는 단순한 메모리 반환이 아닙니다. 참조 카운트 관리, 소멸자
콜백, 프래그먼트 정리, 클론 처리까지 여러 단계를 거칩니다. 커널 6.x에는 해제 이유를
추적하는 kfree_skb_reason() API도 추가되었습니다.
해제 경로 전체 흐름
skb_release_head_state() 상세 분석
skb_release_head_state()는 sk_buff가 보유한 외부 참조를 모두
해제합니다. 실제 메모리 반환보다 앞서 수행되어야 하는 정리 작업들을 담당합니다.
skb_release_head_state() 소스 전체 분석 (net/core/skbuff.c)
static void skb_release_head_state(struct sk_buff *skb)
{
/* ① 라우팅 캐시 참조 해제
* skb_dst(skb)는 dst 포인터와 noref 비트를 분리해서 반환.
* noref가 설정되지 않은 경우만 dst_release() 호출. */
skb_dst_drop(skb);
#ifdef CONFIG_XFRM
/* ② IPsec 보안 경로(secpath) 참조 해제 */
if (skb_sec_path(skb))
secpath_put(skb_sec_path(skb));
#endif
/* ③ 소멸자 콜백 실행
* sock_wfree: 소켓 송신 큐 버퍼 카운터 감소 + 대기 프로세스 깨움
* sock_rfree: 소켓 수신 큐 버퍼 카운터 감소
* tcp_wfree: TCP 송신 + TSQ(TCP Small Queues) 처리
* skb_kfree_head: skb_orphan 이후 간단 해제
*/
if (skb->destructor) {
WARN_ON(in_hardirq());
skb->destructor(skb);
}
#if IS_ENABLED(CONFIG_NF_CONNTRACK)
/* ④ Netfilter 연결 추적(conntrack) 참조 해제 */
nf_conntrack_put(skb_nfct(skb));
#endif
skb_ext_put(skb); /* ⑤ skb 확장 블록(tc_skb_ext 등) 해제 */
}
skb_release_data() 상세 분석
skb_release_data()는 선형 데이터 버퍼와 비선형 영역(페이지 프래그먼트,
frag_list)을 정리합니다. 데이터가 여러 sk_buff에 의해 공유되는
경우 dataref 참조 카운트를 확인한 후 실제 해제 여부를 결정합니다.
skb_release_data() 소스 전체 분석 (net/core/skbuff.c)
static void skb_release_data(struct sk_buff *skb,
enum skb_drop_reason reason,
bool zerocopy_clean)
{
struct skb_shared_info *shinfo = skb_shinfo(skb);
int i;
/* ① 제로-카피 완료 콜백 처리
* ubuf_info가 있으면 MSG_ZEROCOPY 전송 완료 통지 */
if (skb_zcopy(skb)) {
bool skip_unref = shinfo->flags & SKBFL_DONT_ORPHAN;
skb_zcopy_clear(skb, zerocopy_clean);
if (skip_unref)
return;
}
/* ② 공유 데이터 참조 카운트 확인
* dataref가 0이 되지 않으면 다른 sk_buff가 아직 사용 중 */
if (!skb_data_unref(skb, shinfo))
return; /* 다른 클론이 있으면 중단 */
/* ③ skb_shared_info의 hw_timestamps 등 정리 */
skb_zcopy_clear(skb, true);
/* ④ 페이지 프래그먼트(frags[]) 참조 해제
* nr_frags개의 각 페이지에 put_page() 수행 */
for (i = 0; i < shinfo->nr_frags; i++)
skb_frag_unref(skb, i);
/* ⑤ frag_list 연결 skb들 재귀 해제
* 각 frag skb에 대해 kfree_skb() 재귀 호출 */
if (skb_has_frag_list(skb)) {
struct sk_buff *frag, *tmp;
skb_walk_frags(skb, frag) {
tmp = frag;
frag = frag->next;
kfree_skb_reason(tmp, reason);
}
}
/* ⑥ 선형 데이터 버퍼(head) 해제
* 할당 크기에 따라 kfree() 또는 vfree() 선택 */
skb_free_head(skb);
}
static void skb_free_head(struct sk_buff *skb)
{
unsigned char *head = skb->head;
if (skb->head_frag) {
/* 페이지 기반 head 버퍼: put_page() */
if (skb_pp_recycle(skb, head))
return; /* 페이지 풀 재사용 */
skb_free_frag(head);
} else {
kfree(head); /* kmalloc 기반 head 버퍼 */
}
}
fclone skb 해제 경로
fclone(fast clone)은 TCP 송신 경로에서 자주 발생하는 skb_clone()을
최적화하기 위해, 원본 sk_buff와 클론 슬롯을 하나의 슬랩 객체에 같이 할당하는
방식입니다. skb_fclone_busy(sk, skb)로 클론 중인지 확인하며, 둘 다 해제되어야
슬랩 객체가 반환됩니다.
fclone 해제 경로 분석 (net/core/skbuff.c)
static void __kfree_skb(struct sk_buff *skb)
{
skb_release_all(skb);
kfree_skbmem(skb);
}
static void kfree_skbmem(struct sk_buff *skb)
{
struct sk_buff_fclones *fclones;
switch (skb->fclone) {
case SKB_FCLONE_UNAVAILABLE:
/* 일반 skb: skbuff_cache 슬랩에 반환 */
kmem_cache_free(skbuff_cache, skb);
return;
case SKB_FCLONE_ORIG:
/* 원본 fclone skb: 클론 슬롯 확인 */
fclones = container_of(skb, struct sk_buff_fclones, skb1);
/* 클론이 아직 살아있으면 원본만 DEAD 표시 후 종료.
* 클론이 나중에 해제될 때 슬랩 객체 반환. */
if (skb_fclone_busy(skb->sk, skb))
break;
fclones->skb1.fclone = SKB_FCLONE_UNAVAILABLE;
goto fastpath;
case SKB_FCLONE_CLONE:
/* 클론 skb: 원본이 DEAD이면 함께 슬랩 반환 */
fclones = container_of(skb, struct sk_buff_fclones, skb2);
/* 원본 refcount 확인: 원본이 아직 살아있으면 클론만 해제 */
if (!refcount_dec_and_test(&fclones->fclone_ref))
return;
break;
}
fastpath:
/* fclone 쌍 전체를 skbuff_fclone_cache에 반환 */
kmem_cache_free(skbuff_fclone_cache, fclones);
}
kfree_skb_reason() — 6.x 해제 이유 추적
리눅스 커널 5.17부터 도입된 kfree_skb_reason()은 패킷이 드롭될 때
그 이유를 enum skb_drop_reason으로 기록합니다. 이 정보는
trace_kfree_skb 트레이스포인트를 통해 perf, bpftrace,
ftrace로 관측할 수 있으며, 네트워크 패킷 드롭 디버깅에 매우 유용합니다.
| 열거값 | 값 | 드롭 원인 | 발생 위치 |
|---|---|---|---|
SKB_NOT_DROPPED_YET | 0 | 아직 드롭되지 않음 (정상 경로) | 기본값 |
SKB_DROP_REASON_NOT_SPECIFIED | 1 | 이유 미지정 (구형 코드 경로) | 레거시 kfree_skb() |
SKB_DROP_REASON_NO_SOCKET | 2 | 대상 소켓 없음 | UDP/TCP 수신 경로 |
SKB_DROP_REASON_PKT_TOO_SMALL | 3 | 패킷 크기 부족 | 각 프로토콜 헤더 검증 |
SKB_DROP_REASON_TCP_CSUM | 4 | TCP 체크섬 오류 | tcp_v4_rcv() |
SKB_DROP_REASON_SOCKET_FILTER | 5 | 소켓 필터(BPF) 드롭 | sk_filter_trim_cap() |
SKB_DROP_REASON_UDP_CSUM | 6 | UDP 체크섬 오류 | udp_lib_checksum_complete() |
SKB_DROP_REASON_NETFILTER_DROP | 7 | Netfilter 훅 드롭 | NF_DROP 반환 시 |
SKB_DROP_REASON_OTHERHOST | 8 | 목적지 MAC 불일치 (PACKET_OTHERHOST) | ip_rcv() |
SKB_DROP_REASON_IP_CSUM | 9 | IP 헤더 체크섬 오류 | ip_rcv() |
SKB_DROP_REASON_IP_INHDR | 10 | IP 헤더 길이 오류 | ip_rcv() |
SKB_DROP_REASON_IP_RPFILTER | 11 | 역방향 경로 필터링(rp_filter) | fib_validate_source() |
SKB_DROP_REASON_UNICAST_IN_L2_MULTICAST | 12 | L2 멀티캐스트 프레임에 유니캐스트 IP | 브리지 수신 경로 |
SKB_DROP_REASON_XFRM_POLICY | 13 | IPsec 정책 위반 | xfrm_policy_check() |
SKB_DROP_REASON_IP_NOPROTO | 14 | 지원하지 않는 IP 프로토콜 번호 | ip_local_deliver_finish() |
SKB_DROP_REASON_SOCKET_RCVBUFF | 15 | 소켓 수신 버퍼 가득 참 | udp_queue_rcv_skb() |
SKB_DROP_REASON_PROTO_MEM | 16 | 프로토콜 메모리 한도 초과 | sk_rmem_schedule() |
SKB_DROP_REASON_TCP_AUTH_HDR | 17 | TCP 인증 헤더 오류 | TCP-AO 처리 경로 |
SKB_DROP_REASON_MAX | N | 열거 범위 끝 | 경계 검사용 |
kfree_skb_reason() 실제 사용 및 tracing 예시
/* 커널 코드: 드롭 원인 지정 해제 */
static int udp_rcv(struct sk_buff *skb)
{
return __udp4_lib_rcv(skb, &udp_table, IPPROTO_UDP);
}
static int __udp4_lib_rcv(struct sk_buff *skb, ...)
{
/* ... */
if (udp4_csum_init(skb, uh, proto))
goto csum_error;
/* ... */
csum_error:
kfree_skb_reason(skb, SKB_DROP_REASON_UDP_CSUM);
return 0;
}
/* --- bpftrace로 드롭 이유 실시간 추적 --- */
/*
* sudo bpftrace -e '
* tracepoint:skb:kfree_skb {
* @reason[args->reason] = count();
* }
* interval:s:5 { print(@reason); clear(@reason); }
* '
*/
/* --- perf로 kfree_skb 이벤트 기록 --- */
/*
* perf record -e skb:kfree_skb -ag -- sleep 10
* perf script | awk '{print $NF}' | sort | uniq -c | sort -rn
*/
/* trace_kfree_skb 트레이스포인트 정의 (include/trace/events/skb.h) */
TRACE_EVENT(kfree_skb,
TP_PROTO(struct sk_buff *skb, void *location,
enum skb_drop_reason reason),
TP_ARGS(skb, location, reason),
TP_STRUCT__entry(
__field(void *, skbaddr)
__field(void *, location)
__field(unsigned short, protocol)
__field(enum skb_drop_reason, reason)
),
TP_fast_assign(
__entry->skbaddr = skb;
__entry->location = location;
__entry->protocol = ntohs(skb->protocol);
__entry->reason = reason;
),
TP_printk("skbaddr=%p protocol=%u location=%p reason: %s",
__entry->skbaddr, __entry->protocol,
__entry->location,
__print_symbolic(__entry->reason,
DEFINE_DROP_REASON(...)
))
);
skb_release_all()의 호출 순서가 중요한 이유
skb_release_head_state()가 skb_release_data()보다 먼저
호출되어야 합니다. 소멸자 콜백(destructor)인 sock_wfree()나
tcp_wfree()는 소켓의 송신 버퍼 카운터를 갱신하고 대기 중인 프로세스를
깨울 수 있습니다. 이때 소켓이 여전히 유효한 상태여야 하므로, 데이터 메모리 해제보다
소켓 참조 정리가 선행되어야 합니다.
마찬가지로 nf_conntrack_put()은 연결 추적 항목의 참조를 감소시켜 타임아웃
타이머가 돌아가도록 합니다. 이 작업 역시 데이터 버퍼와는 독립적으로 먼저 수행해야
레이스 컨디션이 발생하지 않습니다.
메모리 레이아웃
sk_buff의 데이터 영역은 4개의 핵심 포인터로 관리됩니다:
아래 다이어그램은 두 부분으로 구성됩니다. 상단은 struct sk_buff의 포인터 필드가 할당된 버퍼의 어느 영역을 가리키는지 보여주고, 하단은 skb_reserve/skb_put/skb_push/skb_pull 호출 시 포인터가 어떻게 이동하는지 단계별로 나타냅니다:
이 레이아웃은 불변식 head ≤ data ≤ tail ≤ end를 항상 유지합니다.
이 조건이 깨지면 커널 패닉(Kernel Panic) 또는 메모리 손상으로 이어집니다. skb_push()/skb_pull()/skb_put() 계열 함수는 호출 전에 이 불변식을 검증하며, 위반 시 skb_over_panic / skb_under_panic을 트리거합니다.
메모리 관련 주요 매크로(Macro)
sk_buff 버퍼 크기를 계산할 때 자주 사용되는 매크로입니다:
| 매크로 | 정의 (개념) | 용도 |
|---|---|---|
SKB_DATA_ALIGN(X) |
ALIGN(X, SMP_CACHE_BYTES) |
SMP 캐시 라인(Cache Line) 단위(보통 64B)로 올림 정렬. end 포인터 계산에 사용. |
SKB_WITH_OVERHEAD(X) |
X - SKB_DATA_ALIGN(sizeof(struct skb_shared_info)) |
할당 크기 X에서 skb_shared_info를 뺀 실제 사용 가능한 linear 데이터 크기. |
SKB_TRUESIZE(X) |
SKB_DATA_ALIGN(X + sizeof(struct sk_buff)) + SKB_DATA_ALIGN(sizeof(struct skb_shared_info)) |
X바이트 데이터를 담는 sk_buff를 할당할 때 실제로 필요한 총 메모리 크기. skb->truesize 초기값으로 사용. |
SKB_MAX_HEAD(X) |
SKB_WITH_OVERHEAD(PAGE_SIZE - X) |
헤더용 headroom X를 예약한 뒤 한 페이지 내에서 사용할 수 있는 linear 데이터 최대 크기. |
alloc_skb(size, gfp)는 내부적으로 SKB_TRUESIZE(size)로 skb->truesize를 초기화합니다. 소켓의 수신 버퍼 제한(sk_rmem_alloc)은 이 값을 누적해 추적하므로, 페이지 프래그먼트를 직접 추가할 때는 skb_add_rx_frag()의 truesize 인자를 실제 할당 크기(예: PAGE_SIZE)로 정확히 넘겨야 합니다.
데이터 조작 함수
sk_buff의 데이터 영역은 head, data, tail, end 4개 포인터로 관리됩니다. 이 포인터들을 조작하는 함수들은 Linux 네트워크 코드에서 가장 빈번하게 사용되는 핵심 API입니다. 할당 직후의 빈 skb에서 프로토콜 헤더와 페이로드(Payload)를 채워가는 과정, 수신 패킷에서 헤더를 벗겨가는 과정 모두 이 함수들의 조합으로 이루어집니다.
skb_reserve — headroom 확보
/* include/linux/skbuff.h */
static inline void skb_reserve(struct sk_buff *skb, int len);
skb_reserve()는 data와 tail 포인터를 동시에 len만큼 뒤로 이동시켜 headroom(헤더 추가 공간)을 확보합니다. 반드시 할당 직후, 데이터를 넣기 전에만 호출해야 합니다.
/* 사용 예: 전송 경로에서 L2/L3/L4 헤더를 위한 headroom 확보 */
struct sk_buff *skb = alloc_skb(MAX_HEADER + payload_len, GFP_KERNEL);
if (!skb)
return -ENOMEM;
skb_reserve(skb, MAX_HEADER); /* data=tail=head+MAX_HEADER */
/* 이후 skb_put()으로 payload, skb_push()로 헤더 추가 */
/* 사용 예: 드라이버 수신 경로에서 NET_IP_ALIGN 정렬 */
struct sk_buff *skb = netdev_alloc_skb(dev, len + NET_IP_ALIGN);
skb_reserve(skb, NET_IP_ALIGN); /* IP 헤더 4바이트 정렬 (x86 외) */
주의: skb_reserve()는 이미 데이터가 들어 있는 skb에 호출하면 data 영역이 페이로드를 덮어쓰게 됩니다. skb->len == 0인 상태에서만 호출하세요. 할당 직후가 유일한 안전한 타이밍입니다.
skb_put 계열 — 데이터 끝에 추가
skb_put() 계열 함수는 tail 포인터를 뒤로 이동하여 데이터 영역을 확장합니다. 주로 전송 경로에서 페이로드를 추가하거나, 수신 드라이버에서 수신된 데이터 크기를 설정할 때 사용됩니다.
| 함수 | 프로토타입 | 설명 |
|---|---|---|
skb_put | void *skb_put(skb, len) | tail += len, 이전 tail 반환. 경계 검사 수행 — tailroom 부족 시 skb_over_panic |
__skb_put | void *__skb_put(skb, len) | 경계 검사 없는 고속 버전. tailroom이 충분함을 확신할 때만 사용 (드라이버 fast path) |
skb_put_data | void *skb_put_data(skb, data, len) | skb_put() + memcpy(). 한 번에 데이터 복사까지 수행 (4.13+) |
skb_put_zero | void *skb_put_zero(skb, len) | skb_put() + memset(0). 패딩이나 초기화된 헤더 추가 시 (4.13+) |
skb_put_u8 | u8 *skb_put_u8(skb, val) | 1바이트 추가. 프로토콜 필드를 하나씩 조립할 때 (4.18+) |
/* 기본 사용: 페이로드 추가 */
unsigned char *p = skb_put(skb, payload_len);
memcpy(p, payload_data, payload_len);
/* skb_put_data: 위 두 줄을 한 줄로 (4.13+, 권장) */
skb_put_data(skb, payload_data, payload_len);
/* skb_put_zero: 패딩 영역을 0으로 채우기 */
skb_put_zero(skb, 4); /* 4바이트 zero 패딩 추가 */
/* __skb_put: 드라이버에서 DMA 수신 버퍼 크기 설정 시 */
__skb_put(skb, rx_size); /* 이미 할당 시 충분한 공간 확보됨 */
/* skb_put_u8: 1바이트 필드 추가 */
skb_put_u8(skb, MY_PROTO_VERSION); /* version 필드 */
skb_put_u8(skb, MY_MSG_TYPE); /* type 필드 */
/* 구조체 단위로 헤더 추가하는 패턴 */
struct my_hdr *hdr = skb_put_zero(skb, sizeof(*hdr));
hdr->version = 1;
hdr->payload_len = htons(data_len);
코드 설명
- skb_put()
include/linux/skbuff.h에서tail포인터를len만큼 뒤로 이동하고 이전tail위치를 반환합니다. 반환 포인터에memcpy()로 데이터를 채우는 것이 기본 패턴입니다. 내부에서SKB_LINEAR_ASSERT(skb)와 tailroom 검사를 수행합니다. - skb_put_data()4.13+에서 추가된 편의 함수로,
skb_put()+memcpy()를 한 번에 수행합니다. 호출 체인:skb_put_data()→skb_put()→skb_tail_pointer()갱신 →memcpy(). 새 코드에서는 이 함수를 사용하는 것이 권장됩니다. - skb_put_zero()
skb_put()+memset(0)조합입니다. Ethernet 최소 프레임 크기 충족을 위한 패딩이나, 보안상 초기화가 필요한 헤더 필드에 사용됩니다. 구조체 포인터를 반환하므로 이후 필드별 설정이 가능합니다. - __skb_put()경계 검사를 생략하는 고속 버전입니다. 드라이버에서
alloc_skb()직후처럼 할당 크기가 보장된 상황에서만 사용합니다. 잘못 사용하면skb_shared_info영역을 덮어써 커널 크래시를 유발합니다. - 구조체 헤더 패턴
skb_put_zero()의 반환값을 구조체 포인터로 캐스팅해 필드를 개별 설정하는 관용 패턴입니다.skb_put_zero()가 먼저 0으로 초기화하므로, 설정하지 않는 필드는 자동으로 0이 됩니다.
주의: skb_put()은 skb_tailroom(skb) < len이면 skb_over_panic()을 호출하여 커널 패닉(Kernel Panic)을 발생시킵니다. 반드시 skb_tailroom(skb)으로 남은 공간을 확인하거나, 할당 시 충분한 크기를 지정하세요. __skb_put()은 검사를 하지 않으므로 더 위험합니다.
skb_push 계열 — 헤더 추가 (앞쪽 확장)
skb_push() 계열 함수는 data 포인터를 앞으로 이동하여 headroom에서 공간을 가져옵니다. 전송 경로에서 프로토콜 헤더를 추가할 때 사용됩니다 (L4 → L3 → L2 순서).
| 함수 | 프로토타입 | 설명 |
|---|---|---|
skb_push | void *skb_push(skb, len) | data -= len, 새 data 포인터 반환. 경계 검사 수행 — headroom 부족 시 skb_under_panic |
__skb_push | void *__skb_push(skb, len) | 경계 검사 없는 고속 버전. headroom이 보장될 때만 사용 |
/* 전형적인 전송 경로: L4 → L3 → L2 순서로 헤더 추가 */
/* 1. UDP 헤더 추가 */
struct udphdr *uh = skb_push(skb, sizeof(*uh));
skb_reset_transport_header(skb);
uh->source = sport;
uh->dest = dport;
uh->len = htons(skb->len);
uh->check = 0;
/* 2. IP 헤더 추가 */
struct iphdr *iph = skb_push(skb, sizeof(*iph));
skb_reset_network_header(skb);
iph->version = 4;
iph->ihl = 5;
iph->tot_len = htons(skb->len);
/* ... 나머지 IP 필드 설정 ... */
/* 3. Ethernet 헤더 추가 */
struct ethhdr *eth = skb_push(skb, ETH_HLEN);
skb_reset_mac_header(skb);
ether_addr_copy(eth->h_dest, dst_mac);
ether_addr_copy(eth->h_source, src_mac);
eth->h_proto = htons(ETH_P_IP);
/* __skb_push: 터널 드라이버에서 outer 헤더 추가 시 */
/* headroom은 skb_cow_head()로 이미 확보된 상태 */
struct vxlanhdr *vxh = (struct vxlanhdr *)__skb_push(skb, sizeof(*vxh));
코드 설명
- UDP 헤더 (L4)
skb_push(skb, sizeof(*uh))로data포인터를 UDP 헤더 크기만큼 앞으로 이동합니다.skb_reset_transport_header(skb)는 현재data위치를transport_header에 기록하여 이후udp_hdr(skb)가 올바른 위치를 반환하게 합니다. - IP 헤더 (L3)L4 위에
skb_push()로 IP 헤더를 쌓습니다.skb_reset_network_header(skb)로network_header를 설정하면ip_hdr(skb)가 동작합니다.iph->tot_len에skb->len을 넣으면 UDP 헤더 + 페이로드를 포함한 전체 IP 패킷 길이가 됩니다. - Ethernet 헤더 (L2)마지막으로
ETH_HLEN(14바이트) 크기의 Ethernet 헤더를 push합니다.skb_reset_mac_header(skb)로mac_header를 설정합니다. 이 시점에서data는 L2 프레임 시작을 가리키고,skb->len은 전체 프레임 크기입니다. - __skb_push (VXLAN)터널 드라이버에서 outer 헤더를 추가할 때 사용됩니다.
skb_cow_head()로 headroom과 unclone을 먼저 확보했으므로 경계 검사 없는__skb_push()가 안전합니다. VXLAN 드라이버(drivers/net/vxlan/vxlan_core.c)의 실제 패턴입니다.
주의: skb_push()는 headroom이 부족하면 skb_under_panic()으로 커널 패닉을 발생시킵니다. 터널 캡슐화처럼 추가 헤더가 필요한 경우, 반드시 skb_cow_head()나 pskb_expand_head()로 headroom을 먼저 확보하세요.
skb_pull 계열 — 헤더 제거 (앞쪽 축소)
skb_pull() 계열 함수는 data 포인터를 뒤로 이동하여 이미 처리된 헤더를 건너뜁니다. 수신 경로에서 프로토콜 레이어를 올라갈 때 사용됩니다 (L2 → L3 → L4 순서).
| 함수 | 프로토타입 | 설명 |
|---|---|---|
skb_pull | void *skb_pull(skb, len) | data += len. len > skb->len이면 NULL 반환 (BUG_ON 대신 안전 실패) |
__skb_pull | void *__skb_pull(skb, len) | 경계 검사 없는 고속 버전. len이 유효함을 보장할 때만 사용 |
pskb_pull | void *pskb_pull(skb, len) | 비선형(paged) 데이터까지 처리. linear 영역이 부족하면 frags에서 linear로 당겨옴 |
skb_pull_data | void *skb_pull_data(skb, len) | pskb_may_pull + skb_pull 결합. 실패 시 NULL 반환 (5.14+) |
/* 수신 경로: eth_type_trans()가 L2 헤더를 처리 */
skb->protocol = eth_type_trans(skb, dev);
/* 내부적으로 skb_pull(skb, ETH_HLEN) 수행 → data가 L3 시작을 가리킴 */
/* IP 프로토콜 핸들러에서: IP 헤더 건너뛰기 */
skb_pull(skb, ip_hdrlen(skb));
skb_reset_transport_header(skb);
/* 이제 data가 L4 (TCP/UDP) 헤더를 가리킴 */
/* pskb_pull: 비선형 skb에서 안전하게 헤더 제거 */
/* GRO로 병합된 패킷은 페이로드가 frags에 있을 수 있음 */
if (!pskb_pull(skb, header_len))
goto drop;
/* skb_pull_data: 검증 + 당기기를 한 번에 (5.14+, 권장) */
struct my_hdr *hdr;
if (!skb_pull_data(skb, sizeof(*hdr)))
goto drop; /* linear + paged 모두 검사 후 pull */
skb_pull()과 pskb_pull()의 핵심 차이: skb_pull()은 linear 데이터만 건너뛰며 len > skb->len이면 NULL을 반환합니다. pskb_pull()은 필요 시 paged fragments에서 데이터를 linear 영역으로 복사해 옵니다. GRO/GSO로 인한 비선형 패킷이 일반적인 현대 커널에서는 pskb_pull()이나 skb_pull_data() 사용을 권장합니다.
길이/공간 조회 함수
skb의 현재 데이터 크기와 남은 공간을 조회하는 인라인 함수들입니다. 데이터 추가 전 공간 확인, 비선형 여부 판단 등에 필수적으로 사용됩니다.
| 함수 | 반환값 | 설명 |
|---|---|---|
skb_headroom(skb) | skb->data - skb->head | data 앞의 빈 공간 (헤더 추가 가능 공간). skb_push() 전에 확인 |
skb_tailroom(skb) | skb->end - skb->tail | tail 뒤의 빈 공간 (데이터 추가 가능 공간). skb_put() 전에 확인 |
skb_headlen(skb) | skb->len - skb->data_len | linear 영역의 데이터 크기 (head~tail 사이 실제 데이터) |
skb_is_nonlinear(skb) | skb->data_len != 0 | paged fragments가 있는지 여부. true면 linear 데이터 외에 frags 존재 |
skb_pagelen(skb) | headlen + frags 합 | frag_list 제외한 데이터 크기 (linear + frags[]만) |
skb->len | 전체 데이터 길이 | linear + data_len (모든 paged data + frag_list 포함) |
skb->data_len | 비선형 데이터 크기 | frags[] + frag_list에 있는 데이터 합계 |
skb_availroom(skb) | 즉시 사용 가능한 tailroom | cloned skb는 tailroom이 있어도 0 반환 (쓸 수 없으므로) |
/* 공간 확인 후 안전하게 데이터 추가 */
if (skb_tailroom(skb) < extra_len) {
/* tailroom 부족 → 확장 필요 */
if (pskb_expand_head(skb, 0, extra_len, GFP_ATOMIC))
goto drop;
}
skb_put_data(skb, extra_data, extra_len);
/* 비선형 여부에 따른 분기 처리 */
if (skb_is_nonlinear(skb)) {
/* paged fragments가 있음 → skb_copy_bits() 등으로 접근 */
skb_copy_bits(skb, offset, buf, len);
} else {
/* 모든 데이터가 linear → 직접 포인터 접근 가능 */
memcpy(buf, skb->data + offset, len);
}
/* headroom 확인: 터널 캡슐화 전 */
int needed_headroom = sizeof(struct iphdr) + sizeof(struct udphdr) + VXLAN_HLEN;
if (skb_headroom(skb) < needed_headroom) {
/* skb_cow_head()가 내부적으로 이 검사 + expand를 수행 */
if (skb_cow_head(skb, needed_headroom))
goto tx_error;
}
skb_trim 계열 — 끝에서 자르기
skb_trim() 계열 함수는 skb의 전체 길이를 줄입니다. 패킷 끝의 불필요한 데이터 제거, 패딩 제거, IP total length에 맞게 자르기 등에 사용됩니다.
| 함수 | 프로토타입 | 설명 |
|---|---|---|
skb_trim | void skb_trim(skb, len) | 전체 길이를 len으로 줄임. linear 전용 — 비선형 skb에서는 BUG 발생 |
__skb_trim | void __skb_trim(skb, len) | 검사 없는 내부 버전. len < skb->len 보장 시 사용 |
pskb_trim | int pskb_trim(skb, len) | 비선형(paged) skb도 안전하게 trim. 실패 시 -ENOMEM 반환 |
pskb_trim_rcsum | int pskb_trim_rcsum(skb, len) | trim + 체크섬 무효화. 수신 경로에서 IP 패킷 길이에 맞게 자를 때 (체크섬 재검증 필요) |
skb_trim_rcsum_slow | int skb_trim_rcsum_slow(skb, len) | pskb_trim_rcsum의 slow path. 체크섬 갱신이 필요한 경우 |
/* IP 수신 경로: 패킷을 IP total length에 맞게 trim */
unsigned int pkt_len = ntohs(iph->tot_len);
if (pkt_len < skb->len) {
/* Ethernet padding 등으로 실제보다 길 수 있음 → 잘라내기 */
if (pskb_trim_rcsum(skb, pkt_len))
goto drop;
}
/* linear skb에서 단순 trim */
if (!skb_is_nonlinear(skb))
skb_trim(skb, new_len); /* tail 포인터 조정 */
else
pskb_trim(skb, new_len); /* fragments도 처리 */
/* 커스텀 프로토콜에서 트레일러 제거 */
int trailer_len = my_trailer_size(skb);
pskb_trim(skb, skb->len - trailer_len);
주의: skb_trim()은 비선형 skb에서 호출하면 BUG를 발생시킵니다. 안전하게 사용하려면 pskb_trim()을 사용하세요. 또한 CHECKSUM_COMPLETE 상태의 skb를 trim할 때는 반드시 pskb_trim_rcsum()을 사용해야 체크섬이 올바르게 갱신됩니다.
skb_pad / skb_padto 계열 — 최소 길이 패딩
Ethernet은 최소 프레임 크기 60바이트(FCS 제외)를 요구합니다. 짧은 패킷을 전송할 때 최소 길이를 맞추기 위해 skb_pad()를 사용합니다.
| 함수 | 프로토타입 | 설명 |
|---|---|---|
skb_pad | int skb_pad(skb, pad) | skb 끝에 pad바이트 0 추가. 실패 시 skb가 해제됨(freed) — 반환값 확인 필수 |
skb_padto | int skb_padto(skb, len) | 전체 길이가 len이 되도록 패딩. 실패 시 skb 해제됨 |
__skb_pad | int __skb_pad(skb, pad, free_on_error) | 내부 함수. free_on_error로 에러 시 skb 해제 여부 제어 |
__skb_put_padto | int __skb_put_padto(skb, len, free_on_error) | 패딩 + skb->len 갱신까지 수행. eth_skb_pad()가 내부적으로 호출 |
eth_skb_pad | int eth_skb_pad(skb) | ETH_ZLEN(60바이트)으로 패딩. 드라이버 xmit에서 가장 흔히 사용 |
/* 네트워크 드라이버 xmit: Ethernet 최소 프레임 크기 보장 */
static netdev_tx_t my_start_xmit(struct sk_buff *skb, struct net_device *dev)
{
/* ETH_ZLEN = 60. 짧은 패킷을 60바이트까지 0으로 패딩 */
if (eth_skb_pad(skb))
return NETDEV_TX_OK; /* 실패 시 skb는 이미 해제됨 */
/* ... DMA 매핑 및 전송 ... */
}
/* 특정 길이로 패딩 (예: 46바이트 최소 페이로드) */
if (skb->len < min_len) {
if (skb_padto(skb, min_len))
return -ENOMEM; /* skb는 이미 해제됨 — 이중 해제 금지! */
skb->len = min_len; /* __skb_put_padto 사용 시 자동 갱신 */
}
주의: skb_pad(), skb_padto()는 실패 시 skb를 자동으로 해제합니다. 에러 경로에서 kfree_skb(skb)를 다시 호출하면 이중 해제(double-free) 버그가 됩니다. 반환값이 에러이면 skb가 이미 사라진 것으로 간주하세요.
전형적인 전송/수신 경로 조작 순서
/* ══════════════════════════════════════════════
* 전송(TX) 경로: reserve → put → push × N
* ══════════════════════════════════════════════ */
struct sk_buff *skb = alloc_skb(1500 + headroom, GFP_KERNEL);
/* 1. headroom 확보 */
skb_reserve(skb, headroom); /* data=tail → head+headroom */
/* 2. 페이로드 추가 */
skb_put_data(skb, payload_data, payload_len); /* tail ↓ */
/* 3. TCP 헤더 추가 */
struct tcphdr *th = skb_push(skb, sizeof(*th)); /* data ↑ */
skb_reset_transport_header(skb);
/* 4. IP 헤더 추가 */
struct iphdr *iph = skb_push(skb, sizeof(*iph)); /* data ↑↑ */
skb_reset_network_header(skb);
/* 5. Ethernet 헤더 추가 */
struct ethhdr *eh = skb_push(skb, ETH_HLEN); /* data ↑↑↑ */
skb_reset_mac_header(skb);
/* ══════════════════════════════════════════════
* 수신(RX) 경로: pull × N (위와 반대 순서)
* ══════════════════════════════════════════════ */
/* 드라이버가 skb를 할당하고 DMA로 데이터 수신 */
skb_put(skb, rx_bytes); /* 수신된 바이트만큼 len 설정 */
/* L2: eth_type_trans()가 Ethernet 헤더를 파싱하고 pull */
skb->protocol = eth_type_trans(skb, dev); /* 내부적으로 skb_pull(ETH_HLEN) */
/* L3: IP 수신 핸들러 */
if (!pskb_may_pull(skb, sizeof(struct iphdr)))
goto drop;
struct iphdr *iph = ip_hdr(skb);
skb_pull(skb, iph->ihl * 4); /* IP 헤더 건너뛰기 */
skb_reset_transport_header(skb);
/* L4: TCP/UDP 핸들러에서 transport 헤더 접근 */
struct tcphdr *th = tcp_hdr(skb);
/* data는 여전히 TCP 헤더를 가리킴 (TCP가 직접 pull하지 않고 offset 사용) */
Clone/Copy 메커니즘
여러 곳에서 같은 패킷을 참조해야 할 때 (예: 패킷 미러링, tcpdump 캡처, netfilter bridge), 세 가지 복사 전략을 선택할 수 있습니다:
/* clone: sk_buff 메타데이터만 복사, 데이터 버퍼는 공유 (refcount 증가) */
struct sk_buff *clone = skb_clone(skb, GFP_ATOMIC);
/* clone->data == skb->data (같은 버퍼 참조) */
/* skb_shared_info.dataref 증가됨 */
/* pskb_copy: linear 헤더만 복사, paged data는 page refcount 증가 */
struct sk_buff *pcopy = pskb_copy(skb, GFP_ATOMIC);
/* 헤더를 수정해야 하지만 페이로드는 그대로인 경우 최적 */
/* copy: 메타데이터 + linear + paged 데이터 모두 완전 복사 */
struct sk_buff *copy = skb_copy(skb, GFP_ATOMIC);
/* copy->data != skb->data (독립적 버퍼) */
/* skb_share_check: 공유 여부 확인 후 필요 시 clone */
skb = skb_share_check(skb, GFP_ATOMIC);
if (!skb)
return NET_RX_DROP;
/* 이제 skb를 독점적으로 소유 — 안전하게 메타데이터 수정 가능 */
코드 설명
- skb_clone()
net/core/skbuff.c의skb_clone()은 새sk_buff메타데이터만 할당하고 데이터 버퍼는 원본과 공유합니다.skb_shinfo(skb)->dataref를 증가시켜 공유를 추적하며, clone된 skb의 데이터를 수정하려면 먼저pskb_copy()나skb_copy()로 분리해야 합니다. - pskb_copy()linear 헤더 영역(
head~end)만 새로 할당·복사하고, paged fragments는 page refcount 증가로 공유합니다. netfilter NAT처럼 L3/L4 헤더만 수정하고 페이로드는 그대로인 경우 최적의 선택입니다. - skb_copy()
skb_copy()는__alloc_skb()로 새 버퍼를 할당한 뒤skb_copy_bits()로 linear + paged 데이터를 모두 복사합니다. 결과물은 완전히 독립적이므로 자유롭게 수정 가능하지만, 비용이 가장 큽니다. - skb_share_check()
skb->users > 1이면(다른 경로에서도 참조 중이면)skb_clone()을 수행하고 원본의 refcount를 감소시킵니다. 프로토콜 핸들러 진입 시 메타데이터 독점 소유를 확보하는 관용 패턴입니다. 실패 시 NULL을 반환하므로 반드시 검사해야 합니다.
선택 기준: 패킷을 읽기만 한다면 skb_clone(), 헤더만 수정해야 한다면 pskb_copy(), 페이로드(Payload)까지 수정해야 한다면 skb_copy()를 사용하세요. Netfilter NAT는 pskb_copy()를 주로 사용합니다.
프래그먼트와 scatter-gather
대용량 패킷은 linear 데이터 외에 paged fragments를 사용합니다. skb_shared_info는 end 포인터 바로 뒤에 위치하며, fragment 배열과 GSO 메타데이터를 관리합니다.
/* skb_shared_info: end 포인터 바로 뒤에 위치 */
struct skb_shared_info {
__u8 nr_frags; /* fragment 수 */
__u8 tx_flags;
unsigned short gso_size; /* GSO 세그먼트 크기 */
unsigned short gso_segs; /* GSO 세그먼트 수 */
unsigned short gso_type; /* GSO 타입 */
struct sk_buff *frag_list; /* 연결된 skb 리스트 */
skb_frag_t frags[MAX_SKB_FRAGS]; /* page fragment 배열 */
atomic_t dataref; /* 데이터 공유 참조 카운트 */
};
코드 설명
- nr_frags현재 사용 중인 paged fragment 수입니다.
frags[]배열의 유효 범위는0~nr_frags-1이며, 최대값은MAX_SKB_FRAGS(PAGE_SIZE=4096 기준 17)입니다. - gso_size, gso_segs, gso_typeGSO(Generic Segmentation Offload) 메타데이터입니다.
gso_size는 MSS(Maximum Segment Size),gso_segs는 세그먼트 수,gso_type은 프로토콜 유형(SKB_GSO_TCPV4등)입니다.gso_size > 0이면 이 skb는 아직 분할되지 않은 super-packet입니다. - frag_list연결된 skb 리스트로, GRO 병합이나 IP 재조립(Reassembly) 결과물에서 사용됩니다.
frags[]가 page fragment를 참조하는 반면,frag_list는 완전한sk_buff체인입니다.skb_walk_frags()매크로로 순회합니다. - frags[MAX_SKB_FRAGS]
skb_frag_t(=struct bio_vec) 배열로, 각 원소가{page, offset, size}튜플입니다. scatter-gather DMA를 위해 물리적으로 분산된 페이지들을 하나의 skb에서 참조할 수 있게 합니다. - dataref데이터 버퍼의 공유 참조 카운트입니다.
skb_clone()시 증가하며,skb_shared()가dataref > 1인지 확인합니다. 데이터를 수정하려면dataref == 1이어야 하고, 그렇지 않으면pskb_copy()로 분리해야 합니다.
프래그먼트 접근 함수
| 함수 | 프로토타입 | 설명 |
|---|---|---|
skb_shinfo | struct skb_shared_info *skb_shinfo(skb) | skb_shared_info 접근. (struct skb_shared_info *)(skb_end_pointer(skb)) |
skb_frag_page | struct page *skb_frag_page(frag) | fragment의 페이지(Page) 반환 |
skb_frag_off | unsigned int skb_frag_off(frag) | 페이지 내 데이터 시작 오프셋 |
skb_frag_size | unsigned int skb_frag_size(frag) | fragment의 데이터 크기 |
skb_frag_address | void *skb_frag_address(frag) | fragment 데이터의 가상 주소. page_address(page) + offset. highmem 페이지에서는 사용 불가 |
skb_frag_address_safe | void *skb_frag_address_safe(frag) | highmem이면 NULL 반환. 안전한 접근 시도 |
skb_frag_set_page | void skb_frag_set_page(skb, i, page) | fragment i의 페이지 설정 |
skb_frag_off_add | void skb_frag_off_add(frag, delta) | fragment 오프셋 증가 (partial consume 시) |
skb_frag_size_sub | void skb_frag_size_sub(frag, delta) | fragment 크기 감소 |
skb_frag_size_set | void skb_frag_size_set(frag, size) | fragment 크기 직접 설정 |
skb_has_frag_list | bool skb_has_frag_list(skb) | shinfo->frag_list != NULL. GRO/IP fragmentation 결과 |
skb_walk_frags | skb_walk_frags(skb, iter) | frag_list 순회 매크로 |
프래그먼트 추가 함수
| 함수 | 프로토타입 | 설명 |
|---|---|---|
skb_add_rx_frag | void skb_add_rx_frag(skb, i, page, off, size, truesize) | 수신 경로: fragment 추가 + len, data_len, truesize 갱신. 드라이버에서 가장 흔히 사용 |
skb_fill_page_desc | void skb_fill_page_desc(skb, i, page, off, size) | fragment 슬롯 설정 + nr_frags 갱신. len/truesize는 호출자가 직접 갱신 |
__skb_fill_page_desc | void __skb_fill_page_desc(skb, i, page, off, size) | nr_frags 미갱신. 여러 fragment를 일괄 추가 시 마지막에만 nr_frags 설정 |
skb_coalesce_rx_frag | void skb_coalesce_rx_frag(skb, i, size, truesize) | 마지막 fragment 확장 (연속된 페이지). 새 fragment 추가 대신 기존 것 크기 증가 |
/* fragment 순회 */
struct skb_shared_info *si = skb_shinfo(skb);
for (int i = 0; i < si->nr_frags; i++) {
skb_frag_t *frag = &si->frags[i];
struct page *page = skb_frag_page(frag);
unsigned int offset = skb_frag_off(frag);
unsigned int size = skb_frag_size(frag);
/* page + offset에서 size 바이트 데이터 */
}
/* 드라이버 수신: DMA로 받은 페이지를 fragment로 추가 */
struct sk_buff *skb = napi_alloc_skb(napi, 256); /* 헤더만 linear */
skb_put(skb, header_len);
memcpy(skb->data, dma_buf, header_len);
/* 페이로드는 DMA 페이지를 fragment로 직접 참조 (zero-copy) */
skb_add_rx_frag(skb, 0, rx_page, rx_offset, payload_size, PAGE_SIZE);
/* 내부적으로: skb->len += payload_size,
* skb->data_len += payload_size,
* skb->truesize += PAGE_SIZE */
/* zero-copy 전송: sendfile에서 파일 페이지를 fragment로 */
skb_fill_page_desc(skb, frag_idx, file_page, page_offset, chunk_size);
skb->len += chunk_size;
skb->data_len += chunk_size;
skb->truesize += chunk_size; /* 호출자가 직접 갱신 */
/* frag_list 순회 (GRO 병합, IP 재조립 결과) */
struct sk_buff *frag_iter;
skb_walk_frags(skb, frag_iter) {
/* frag_iter는 frag_list의 각 skb */
pr_debug("frag_list skb len=%u
", frag_iter->len);
}
코드 설명
- frags[] 순회
skb_shinfo(skb)로skb_shared_info에 접근한 뒤,nr_frags만큼 루프를 돌며 각skb_frag_t의 page/offset/size를 읽습니다. 실제 데이터 접근 시kmap_local_page(page)로 가상 주소를 얻어야 합니다(highmem 페이지 대응). - 드라이버 수신 패턴
napi_alloc_skb(napi, 256)으로 헤더만 담을 작은 linear 버퍼를 할당하고, DMA로 받은 페이지는skb_add_rx_frag()로 fragment에 직접 연결합니다. 이것이 현대 드라이버의 header-split zero-copy 수신 패턴입니다. - skb_add_rx_frag()
net/core/skbuff.c에서skb_fill_page_desc()+len/data_len/truesize갱신을 한 번에 수행합니다. truesize 인자는 데이터 크기가 아닌 실제 할당된 페이지 크기(PAGE_SIZE)를 전달해야 소켓 메모리 추적이 정확합니다. - skb_fill_page_desc()fragment 슬롯에 page/offset/size를 설정하고
nr_frags를 증가시키지만,len/data_len/truesize는 갱신하지 않습니다. sendfile의 zero-copy 전송처럼 호출자가 직접 이 값들을 관리해야 하는 경우에 사용합니다. - skb_walk_frags()
skb_shinfo(skb)->frag_list를 순회하는 매크로입니다. GRO 병합이나 IP 재조립 결과, frag_list에는 각 원본 패킷이 완전한sk_buff로 연결되어 있습니다.frags[]의 page fragment와는 완전히 다른 구조이므로 별도 처리가 필요합니다.
주의: skb_add_rx_frag()의 마지막 인자 truesize는 실제 할당된 메모리 크기여야 합니다 (보통 PAGE_SIZE). 데이터 크기(size)를 넣으면 소켓 메모리 추적(sk_rmem_alloc)이 부정확해져 OOM이나 조기 드롭이 발생합니다. skb_frag_address()는 CONFIG_HIGHMEM 환경에서 highmem 페이지에 사용하면 잘못된 주소를 반환합니다 — kmap이 필요합니다.
고급 데이터 조작
패킷 처리 시 데이터 레이아웃을 변경해야 하는 상황이 자주 발생합니다. 비선형(paged) 데이터 처리, headroom 확장, clone된 skb의 쓰기 권한 확보 등 고급 시나리오를 다루는 함수들입니다.
pskb_may_pull — linear 영역 확보 (★★★ 최중요)
/* include/linux/skbuff.h */
static inline int pskb_may_pull(struct sk_buff *skb, unsigned int len);
pskb_may_pull()은 프로토콜 헤더 파싱 전에 반드시 호출해야 하는 필수 함수입니다. linear 영역에 최소 len 바이트가 있는지 확인하고, 부족하면 paged fragments에서 linear 영역으로 데이터를 당겨옵니다.
/* 기본 패턴: 프로토콜 헤더 파싱 전 linear 확보 */
static int my_protocol_rcv(struct sk_buff *skb)
{
struct my_hdr *hdr;
/* 1단계: 고정 크기 헤더만큼 확보 */
if (!pskb_may_pull(skb, sizeof(*hdr)))
goto drop;
hdr = (struct my_hdr *)skb_transport_header(skb);
/* 이제 hdr-> 필드에 안전하게 접근 가능 */
/* 2단계: 가변 길이 헤더 (옵션 등) */
if (!pskb_may_pull(skb, hdr->hdr_len))
goto drop;
hdr = (struct my_hdr *)skb_transport_header(skb); /* ★ 포인터 재취득! */
/* ... 처리 ... */
}
/* 실제 커널 코드 예: IPv6 옵션 헤더 파싱 */
/* net/ipv6/exthdrs.c */
if (!pskb_may_pull(skb, skb_transport_offset(skb) + 8))
goto fail;
struct ipv6_opt_hdr *opth = (struct ipv6_opt_hdr *)skb_transport_header(skb);
int optlen = ipv6_optlen(opth);
if (!pskb_may_pull(skb, skb_transport_offset(skb) + optlen))
goto fail;
opth = (struct ipv6_opt_hdr *)skb_transport_header(skb); /* ★ 재취득 */
★ 가장 흔한 커널 네트워크 버그: pskb_may_pull() 호출 후 반드시 헤더 포인터를 재취득해야 합니다. 내부적으로 __pskb_pull_tail()이 새 버퍼를 할당하고 데이터를 복사할 수 있어, 이전 포인터가 해제된 메모리를 가리키게 됩니다. 커널 소스에서 이 패턴의 버그가 수백 건 패치되었습니다.
skb_ensure_writable — 쓰기 가능 영역 확보 (6.x+ 권장)
int skb_ensure_writable(struct sk_buff *skb, unsigned int write_len);
skb_ensure_writable()는 패킷 데이터를 수정하기 전에 호출하는 통합 함수입니다. 내부적으로 clone 상태 해제(skb_unclone) + linear 영역 확보(pskb_may_pull)를 모두 수행합니다. skb_make_writable()의 개선 버전으로, netfilter/tc 등에서 패킷 수정 전 표준 패턴입니다.
/* netfilter/tc에서 패킷 내용 수정 전 */
if (skb_ensure_writable(skb, skb_network_header_len(skb) + sizeof(struct tcphdr)))
goto drop;
/* 이제 IP + TCP 헤더 영역을 안전하게 수정 가능 */
struct iphdr *iph = ip_hdr(skb); /* ★ 포인터 재취득 */
iph->ttl = 64;
struct tcphdr *th = tcp_hdr(skb); /* ★ 포인터 재취득 */
th->window = htons(new_window);
/* NAT 구현: 소스 IP 변경 */
if (skb_ensure_writable(skb, skb_network_offset(skb) + sizeof(struct iphdr)))
return NF_DROP;
iph = ip_hdr(skb);
csum_replace4(&iph->check, iph->saddr, new_saddr);
iph->saddr = new_saddr;
권장: skb_make_writable() 대신 skb_ensure_writable()를 사용하세요. skb_make_writable()는 write_len에 대해 skb->len 검사를 추가로 수행하는데, 대부분의 경우 불필요합니다. 최신 커널 코드는 skb_ensure_writable()로 통일되는 추세입니다.
skb_linearize — 전체 linearize
static inline int skb_linearize(struct sk_buff *skb);
모든 paged fragments와 frag_list의 데이터를 하나의 연속 linear 버퍼에 합칩니다. 내부적으로 __pskb_pull_tail(skb, skb->data_len)을 호출합니다.
/* scatter-gather를 지원하지 않는 레거시 장치 */
if (skb_is_nonlinear(skb)) {
if (skb_linearize(skb))
goto drop; /* -ENOMEM: 새 버퍼 할당 실패 */
/* 이제 모든 데이터가 head~tail 사이에 연속 존재 */
}
/* 디버깅/검증: 전체 패킷 덤프 전 */
if (skb_linearize(skb) == 0)
print_hex_dump(KERN_DEBUG, "pkt: ", DUMP_PREFIX_OFFSET,
16, 1, skb->data, skb->len, true);
코드 설명
- skb_is_nonlinear()
skb->data_len != 0을 확인하는 인라인 함수입니다. true이면 데이터가 paged fragments나 frag_list에 분산되어 있으므로,skb->data만으로는 전체 패킷에 접근할 수 없습니다. - skb_linearize()
include/linux/skbuff.h에서__pskb_pull_tail(skb, skb->data_len)을 호출하는 인라인 래퍼입니다.net/core/skbuff.c의__pskb_pull_tail()이 새 연속 버퍼를 할당하고 frags[] + frag_list 데이터를 모두 복사한 뒤, 비선형 영역을 해제합니다. - 레거시 장치 패턴scatter-gather DMA를 지원하지 않는 NIC 드라이버의
ndo_start_xmit()에서 linearize가 필요합니다. 성공하면data_len == 0이 되어head~tail사이에 모든 데이터가 연속으로 존재합니다. - 디버깅 패턴
print_hex_dump()로 전체 패킷을 덤프하려면 연속 버퍼가 필요하므로 linearize를 먼저 수행합니다. 프로덕션 코드에서는skb_copy_bits()나skb_header_pointer()로 필요한 부분만 읽는 것이 권장됩니다.
주의: skb_linearize()는 성능 킬러입니다. 64KB GSO 패킷을 linearize하면 거대한 메모리 할당 + 전체 복사가 발생합니다. 가능하면 pskb_may_pull()로 필요한 부분만 당기거나, skb_header_pointer()나 skb_copy_bits()로 비선형 데이터에 접근하세요. skb_linearize()는 최후의 수단으로만 사용합니다.
pskb_expand_head — headroom/tailroom 확장
int pskb_expand_head(struct sk_buff *skb, int nhead, int ntail, gfp_t gfp_mask);
skb의 headroom을 nhead바이트, tailroom을 ntail바이트 추가로 확장합니다. 필요 시 새 버퍼를 할당하고 linear 데이터를 복사합니다. clone 상태의 skb는 자동으로 unclone됩니다.
| 함수 | 프로토타입 | 설명 |
|---|---|---|
pskb_expand_head | int pskb_expand_head(skb, nhead, ntail, gfp) | headroom/tailroom 확장. 기존 공간이 충분해도 cloned면 재할당 |
skb_expand_head | int skb_expand_head(skb, nhead) | headroom만 확장하는 간단 버전 (5.12+). 실패 시 skb 자동 해제 |
skb_realloc_headroom | struct sk_buff *skb_realloc_headroom(skb, headroom) | 새 skb를 반환 (원본 skb는 변경 없음). 원본이 필요한 경우에 적합 |
skb_copy_expand | struct sk_buff *skb_copy_expand(skb, nhead, ntail, gfp) | 전체 복사 + head/tailroom 확장을 동시에. 완전한 독립 복사본 생성 |
/* 터널 캡슐화 전: headroom 확장 */
int max_headroom = LL_RESERVED_SPACE(tun_dev) + sizeof(struct iphdr)
+ sizeof(struct udphdr) + GENEVE_HLEN;
if (skb_headroom(skb) < max_headroom || skb_header_cloned(skb)) {
if (pskb_expand_head(skb, max_headroom, 0, GFP_ATOMIC))
goto tx_error;
}
/* skb_expand_head: 더 간단한 API (5.12+) */
if (skb_expand_head(skb, needed_headroom))
return NETDEV_TX_OK; /* 실패 시 skb 이미 해제됨! */
/* skb_realloc_headroom: 원본을 보존하며 새 skb 생성 */
struct sk_buff *new_skb = skb_realloc_headroom(skb, needed);
if (!new_skb)
goto drop;
consume_skb(skb); /* 원본 해제 */
skb = new_skb;
/* skb_copy_expand: 완전 복사 + 확장 */
struct sk_buff *expanded = skb_copy_expand(skb, extra_head, extra_tail, GFP_ATOMIC);
if (!expanded)
goto drop;
/* expanded는 완전히 독립된 복사본 + 확장된 headroom/tailroom */
주의: pskb_expand_head() 후에는 모든 포인터(data, head, tail, end와 헤더 포인터)가 무효화됩니다. skb_expand_head()는 실패 시 skb를 자동 해제하므로, 에러 경로에서 kfree_skb()를 호출하지 마세요.
skb_cow 계열 — Copy-On-Write
clone된(공유 버퍼를 가진) skb의 데이터를 수정하려면, 먼저 독점적 쓰기 권한을 확보해야 합니다. skb_cow 계열 함수가 이를 처리합니다.
| 함수 | 프로토타입 | 설명 |
|---|---|---|
skb_cow_head | int skb_cow_head(skb, headroom) | headroom 확보 + clone 해제를 한 번에. 터널/VLAN 캡슐화의 표준 패턴 |
skb_cow | int skb_cow(skb, headroom) | skb_cow_head와 유사하지만 linear 데이터 전체를 COW. 페이로드까지 수정 시 |
skb_cow_data | int skb_cow_data(skb, tailbits, trailer) | paged data를 포함한 전체 데이터를 COW. IPSec 암호화 등 전체 패킷 수정 시 |
/* skb_cow_head: 터널 encapsulation 전 (★가장 흔한 패턴) */
static int my_tunnel_xmit(struct sk_buff *skb, struct net_device *dev)
{
int hdr_len = sizeof(struct iphdr) + sizeof(struct gre_hdr);
/* 클론 상태 해제 + headroom 확보를 동시에 수행 */
if (skb_cow_head(skb, hdr_len + LL_RESERVED_SPACE(dev))) {
kfree_skb(skb);
return NETDEV_TX_OK;
}
/* 이제 안전하게 헤더 추가 가능 (독점 소유) */
skb_push(skb, hdr_len);
/* ... GRE + IP 헤더 설정 ... */
}
/* skb_cow: 포워딩 경로에서 TTL 수정 */
if (skb_cow(skb, LL_RESERVED_SPACE(rt->dst.dev)))
goto drop;
ip_hdr(skb)->ttl--; /* linear 데이터 수정 안전 */
ip_send_check(ip_hdr(skb));
/* skb_cow_data: IPSec ESP 암호화 전 */
struct sk_buff *trailer;
int nfrags = skb_cow_data(skb, esp_trailer_len, &trailer);
if (nfrags < 0)
goto error;
/* 이제 paged data를 포함한 전체 패킷을 수정 가능 */
/* trailer는 마지막 fragment의 skb를 가리킴 (ESP padding 추가용) */
공유 상태 확인 및 해제 함수
skb가 clone되었거나 여러 곳에서 참조되는지 확인하고, 필요 시 독점 소유를 확보하는 함수들입니다.
| 함수 | 프로토타입 | 설명 |
|---|---|---|
skb_cloned | bool skb_cloned(skb) | 데이터 버퍼가 다른 skb와 공유되는지 (dataref > 1). clone 또는 원본 중 하나 |
skb_header_cloned | bool skb_header_cloned(skb) | 헤더(linear) 영역만 공유 여부. skb->cloned && dataref != 1 |
skb_shared | bool skb_shared(skb) | sk_buff 메타데이터가 공유되는지 (users > 1). skb_get() 호출 여부 |
skb_share_check | struct sk_buff *skb_share_check(skb, gfp) | skb_shared()이면 skb_clone(), 아니면 원본 반환. 실패 시 NULL |
skb_unshare | struct sk_buff *skb_unshare(skb, gfp) | 공유 상태이면 완전 복사(skb_copy)하여 독립 skb 반환 |
skb_unclone | int skb_unclone(skb, gfp) | clone 상태면 linear 데이터를 독립 복사. pskb_expand_head(skb, 0, 0, gfp)와 동등 |
/* 수신 핸들러: 패킷 수정 전 독점 소유 확보 */
static int my_netfilter_hook(struct sk_buff *skb)
{
/* 1. 메타데이터 공유 해제 (여러 수신자가 같은 skb를 참조할 수 있음) */
skb = skb_share_check(skb, GFP_ATOMIC);
if (!skb)
return NET_RX_DROP;
/* 2. 데이터 쓰기 권한 확보 (clone된 데이터 버퍼 독립화) */
if (skb_ensure_writable(skb, skb_network_header_len(skb)))
goto drop;
/* 이제 안전하게 패킷 수정 가능 */
ip_hdr(skb)->tos = new_tos;
return NET_RX_SUCCESS;
}
/* 브리지에서 패킷을 여러 포트로 복제 전송 */
if (skb_shared(skb)) {
/* 이미 다른 곳에서 참조 중 → clone으로 분리 */
struct sk_buff *new_skb = skb_clone(skb, GFP_ATOMIC);
if (!new_skb)
return;
/* new_skb를 해당 포트로 전송 */
}
/* clone 상태 해제 (linear 데이터 독립화) */
if (skb_cloned(skb)) {
if (skb_unclone(skb, GFP_ATOMIC))
goto drop;
/* 이제 linear 데이터를 수정해도 원본에 영향 없음 */
}
shared vs cloned: skb_shared()는 sk_buff 구조체 자체가 여러 참조자를 가지는지 (users > 1), skb_cloned()는 데이터 버퍼가 여러 sk_buff에 의해 공유되는지 (dataref > 1)를 확인합니다. skb_clone()은 새 sk_buff를 만들지만 버퍼를 공유하므로, clone 후에는 원본과 clone 모두 skb_cloned() == true가 됩니다.
skb_make_writable — 레거시 호환
int skb_make_writable(struct sk_buff *skb, unsigned int writable_len);
writable_len 바이트까지 쓰기 가능하게 만듭니다. skb_ensure_writable()와 거의 동일하지만, writable_len > skb->len이면 실패를 반환하는 추가 검사가 있습니다. 새 코드에서는 skb_ensure_writable()를 사용하세요.
헤더 포인터 조작 함수
sk_buff는 L2/L3/L4 각 프로토콜 레이어의 헤더 위치를 mac_header, network_header, transport_header 세 개의 오프셋(Offset)으로 추적합니다. 이 오프셋을 설정/조회하는 함수들은 패킷 처리의 기반입니다.
헤더 포인터 접근 함수
| 함수 | 반환형 | 설명 |
|---|---|---|
skb_mac_header(skb) | unsigned char * | L2(MAC) 헤더 시작 주소. eth_hdr(skb)가 내부적으로 호출 |
skb_network_header(skb) | unsigned char * | L3(IP) 헤더 시작 주소. ip_hdr(skb), ipv6_hdr(skb)가 내부적으로 호출 |
skb_transport_header(skb) | unsigned char * | L4(TCP/UDP) 헤더 시작 주소. tcp_hdr(skb), udp_hdr(skb)가 내부적으로 호출 |
skb_inner_network_header(skb) | unsigned char * | 터널 캡슐화 시 내부(inner) L3 헤더. GRE/VXLAN/Geneve 등 |
skb_inner_transport_header(skb) | unsigned char * | 터널 캡슐화 시 내부(inner) L4 헤더 |
skb_inner_mac_header(skb) | unsigned char * | 터널 캡슐화 시 내부(inner) L2 헤더 |
헤더 포인터 설정/리셋 함수
| 함수 | 동작 | 사용 시점 |
|---|---|---|
skb_reset_mac_header(skb) | mac_header = data - head | L2 헤더 위치를 현재 data로 설정. 드라이버 수신, 캡슐화 후 |
skb_reset_network_header(skb) | network_header = data - head | L3 헤더 위치를 현재 data로 설정. eth_type_trans() 이후, IP push 후 |
skb_reset_transport_header(skb) | transport_header = data - head | L4 헤더 위치를 현재 data로 설정. IP pull 이후, TCP/UDP push 후 |
skb_set_mac_header(skb, offset) | mac_header = data + offset - head | data 기준 offset 위치에 L2 헤더 설정 |
skb_set_network_header(skb, offset) | network_header = data + offset - head | data 기준 offset 위치에 L3 헤더 설정 |
skb_set_transport_header(skb, offset) | transport_header = data + offset - head | data 기준 offset 위치에 L4 헤더 설정 |
skb_set_inner_network_header(skb, offset) | inner L3 오프셋 설정 | 터널 캡슐화 시 inner 헤더 위치 기록 |
skb_set_inner_transport_header(skb, offset) | inner L4 오프셋 설정 | 터널 캡슐화 시 inner 헤더 위치 기록 |
헤더 오프셋/길이 조회 함수
| 함수 | 반환값 | 설명 |
|---|---|---|
skb_transport_offset(skb) | transport_header - data | data부터 L4 헤더까지의 오프셋. IP 옵션 포함 L3 + L2 길이 |
skb_network_offset(skb) | network_header - data | data부터 L3 헤더까지의 오프셋. L2 pull 전에는 0 |
skb_network_header_len(skb) | transport_header - network_header | L3 헤더 길이 (IP 옵션 포함). netfilter에서 자주 사용 |
skb_inner_network_offset(skb) | inner L3 오프셋 | 터널 내부 L3 헤더까지의 오프셋 |
skb_mac_header_was_set(skb) | bool | mac_header가 설정된 적 있는지. 포워딩/캡슐화 후 유효성 확인용 |
skb_mac_header_rebuild(skb) | void | mac_header를 network_header 바로 앞으로 재정렬. 포워딩 후 L2 재구성 |
/* 프로토콜별 편의 매크로 (헤더 포인터 + 캐스팅) */
struct ethhdr *eth = eth_hdr(skb); /* (struct ethhdr *)skb_mac_header(skb) */
struct iphdr *iph = ip_hdr(skb); /* (struct iphdr *)skb_network_header(skb) */
struct ipv6hdr *ip6h = ipv6_hdr(skb); /* (struct ipv6hdr *)skb_network_header(skb) */
struct tcphdr *th = tcp_hdr(skb); /* (struct tcphdr *)skb_transport_header(skb) */
struct udphdr *uh = udp_hdr(skb); /* (struct udphdr *)skb_transport_header(skb) */
struct icmphdr *icmph = icmp_hdr(skb); /* (struct icmphdr *)skb_transport_header(skb) */
/* 터널 캡슐화 시 inner/outer 헤더 구분 */
struct iphdr *outer_iph = ip_hdr(skb);
skb_set_inner_network_header(skb, skb_network_offset(skb));
skb_set_inner_transport_header(skb, skb_transport_offset(skb));
/* outer 헤더 추가 후 */
skb_push(skb, outer_hdr_len);
skb_reset_network_header(skb);
/* 이제 ip_hdr(skb)는 outer, skb_inner_network_header(skb)는 inner */
/* 포워딩 후 L2 헤더 재구성 */
if (skb_mac_header_was_set(skb))
skb_mac_header_rebuild(skb); /* mac_header를 network_header 앞으로 이동 */
주의: skb_push()/skb_pull() 후에는 반드시 skb_reset_*_header()로 해당 레이어의 헤더 포인터를 갱신하세요. 갱신하지 않으면 ip_hdr(skb), tcp_hdr(skb) 등이 잘못된 주소를 반환합니다. 특히 터널 캡슐화/역캡슐화 후에는 inner와 outer 헤더 포인터를 모두 올바르게 설정해야 합니다.
데이터 접근 및 복사 함수
비선형(paged) skb에서 데이터를 읽거나 쓰려면, linear 영역 너머의 fragments까지 처리하는 전용 함수가 필요합니다. 직접 포인터 접근(skb->data + offset)은 linear 영역만 가능합니다.
skb_header_pointer — 비선형 안전 헤더 접근 (★★ 매우 중요)
static inline void *skb_header_pointer(
const struct sk_buff *skb,
int offset, int len,
void *buffer);
skb->data + offset에서 len 바이트를 안전하게 접근합니다. 데이터가 linear 영역에 있으면 직접 포인터를 반환하고, paged 영역에 걸쳐 있으면 buffer에 복사 후 buffer 포인터를 반환합니다. 실패 시 NULL을 반환합니다.
/* 전형적 사용법: netfilter에서 TCP 포트 확인 */
struct tcphdr _tcph; /* 스택에 임시 버퍼 */
const struct tcphdr *th;
th = skb_header_pointer(skb,
skb_network_header_len(skb), /* IP 헤더 뒤 */
sizeof(_tcph), /* TCP 헤더 크기 */
&_tcph); /* 임시 복사 버퍼 */
if (!th)
return NF_DROP; /* 데이터 부족 */
/* th가 linear 데이터를 직접 가리키거나, _tcph의 복사본을 가리킴 */
pr_info("src port: %u
", ntohs(th->source));
/* 읽기 전용 접근에 최적: 복사 불필요 시 zero-copy */
/* 쓰기가 필요하면 skb_ensure_writable() + 직접 접근이 더 적합 */
/* 중첩된 프로토콜 헤더 접근 */
struct gre_base_hdr _greh;
const struct gre_base_hdr *greh;
int gre_offset = skb_transport_offset(skb) + sizeof(struct udphdr);
greh = skb_header_pointer(skb, gre_offset, sizeof(_greh), &_greh);
skb_header_pointer vs pskb_may_pull: 읽기 전용이면 skb_header_pointer()가 더 효율적입니다 — skb를 수정하지 않고 필요 시 스택 버퍼에만 복사합니다. 데이터 수정이 필요하면 pskb_may_pull()이나 skb_ensure_writable()를 사용하세요.
skb_copy_bits / skb_store_bits — 비선형 데이터 읽기/쓰기
| 함수 | 프로토타입 | 설명 |
|---|---|---|
skb_copy_bits | int skb_copy_bits(skb, offset, to, len) | skb의 offset부터 len바이트를 to 버퍼로 복사. 비선형 안전 (frags + frag_list 순회) |
skb_store_bits | int skb_store_bits(skb, offset, from, len) | from 버퍼에서 skb의 offset 위치에 len바이트 쓰기. 비선형 안전 |
skb_copy_from_linear_data | void skb_copy_from_linear_data(skb, to, len) | memcpy(to, skb->data, len). linear 전용 — 비선형 미지원 |
skb_copy_from_linear_data_offset | void ..._offset(skb, off, to, len) | memcpy(to, skb->data + off, len). linear 전용 |
skb_copy_datagram_iter | int skb_copy_datagram_iter(skb, off, to, len) | iov_iter로 데이터 복사. 소켓 → 사용자 공간 경로 (recvmsg 등) |
skb_copy_datagram_msg | int skb_copy_datagram_msg(skb, off, msg, len) | msghdr로 데이터 복사. skb_copy_datagram_iter 래퍼 |
/* 비선형 skb에서 특정 오프셋의 데이터 읽기 */
u8 buf[64];
if (skb_copy_bits(skb, offset, buf, sizeof(buf)) < 0)
goto drop; /* offset + len > skb->len */
/* 비선형 skb의 특정 위치에 데이터 쓰기 */
__be16 new_port = htons(8080);
skb_store_bits(skb, transport_offset + offsetof(struct udphdr, dest),
&new_port, sizeof(new_port));
/* 소켓 수신 경로: 사용자 공간으로 복사 */
int my_recvmsg(struct sock *sk, struct msghdr *msg, size_t len, int flags)
{
struct sk_buff *skb = skb_recv_datagram(sk, flags, &err);
if (!skb)
return err;
/* 비선형 skb에서도 안전하게 사용자 공간에 복사 */
err = skb_copy_datagram_msg(skb, header_len, msg, copy_len);
skb_free_datagram(sk, skb);
return err ? err : copy_len;
}
주의: skb_copy_from_linear_data()는 linear 영역만 접근합니다. 비선형 skb에서 이 함수를 사용하면 paged 데이터를 읽지 못합니다. 비선형 가능성이 있으면 반드시 skb_copy_bits()를 사용하세요. skb_store_bits()는 clone된 skb에서도 동작하지만, 원본의 데이터가 변경될 수 있으므로 skb_ensure_writable()를 먼저 호출하세요.
체크섬 조작 함수
패킷의 체크섬(Checksum)은 데이터 무결성 확인의 핵심입니다. skb 데이터를 수정할 때마다 관련 체크섬을 갱신해야 하며, 하드웨어 오프로드 상태도 고려해야 합니다.
| 함수 | 프로토타입 | 설명 |
|---|---|---|
skb_checksum | __wsum skb_checksum(skb, offset, len, csum) | 비선형 안전 체크섬 계산. frags + frag_list 순회. csum은 초기값 |
skb_checksum_complete | __sum16 skb_checksum_complete(skb) | 수신 패킷 체크섬 검증 완료. 0이면 유효, 비0이면 손상 |
skb_checksum_help | int skb_checksum_help(skb) | HW 오프로드(CHECKSUM_PARTIAL)를 SW 체크섬으로 변환. GSO 전, netfilter NAT 후 |
skb_postpull_rcsum | void skb_postpull_rcsum(skb, start, len) | skb_pull() 후 체크섬 갱신. 제거된 데이터의 체크섬을 보정 |
skb_postpush_rcsum | void skb_postpush_rcsum(skb, start, len) | skb_push() 후 체크섬 갱신. 추가된 헤더의 체크섬을 반영 |
skb_checksum_init | __sum16 skb_checksum_init(skb, proto, ...) | pseudo-header 체크섬으로 초기화. UDP/TCP 수신 시 |
csum_replace2 | void csum_replace2(sum, old, new) | 2바이트 필드 변경 시 체크섬 증분 갱신 (포트 변경 등) |
csum_replace4 | void csum_replace4(sum, old, new) | 4바이트 필드 변경 시 체크섬 증분 갱신 (IP 주소 변경) |
inet_proto_csum_replace4 | void inet_proto_csum_replace4(sum, skb, from, to, pseudohdr) | L4 체크섬 증분 갱신 + HW 오프로드 상태 고려. NAT 표준 패턴 |
/* NAT: 소스 IP + 포트 변경 시 체크섬 갱신 */
static void nat_saddr(struct sk_buff *skb, __be32 new_saddr, __be16 new_sport)
{
struct iphdr *iph = ip_hdr(skb);
struct tcphdr *th = tcp_hdr(skb);
/* IP 체크섬: 소스 IP 변경 반영 */
csum_replace4(&iph->check, iph->saddr, new_saddr);
iph->saddr = new_saddr;
/* TCP 체크섬: pseudo-header의 소스 IP 변경 + 소스 포트 변경 */
inet_proto_csum_replace4(&th->check, skb,
iph->saddr, new_saddr, true); /* pseudohdr=true */
inet_proto_csum_replace2(&th->check, skb,
th->source, new_sport, false);
th->source = new_sport;
}
/* 수신 경로: 체크섬 검증 */
if (skb->ip_summed == CHECKSUM_COMPLETE) {
/* HW가 전체 체크섬 제공 → 빠른 검증 */
if (skb_checksum_complete(skb))
goto csum_error;
} else if (skb->ip_summed != CHECKSUM_UNNECESSARY) {
/* SW 체크섬 계산 필요 */
skb_checksum_init(skb, IPPROTO_TCP, inet_compute_pseudo);
if (skb_checksum_complete(skb))
goto csum_error;
}
/* CHECKSUM_PARTIAL → SW 체크섬으로 강제 변환 */
/* (e.g., netfilter REDIRECT 후 loopback으로 재진입 시) */
if (skb_checksum_help(skb))
goto drop;
주의: NAT 등에서 IP 주소나 포트를 변경할 때, inet_proto_csum_replace4()를 사용하면 CHECKSUM_PARTIAL 상태도 올바르게 처리됩니다. csum_replace4()를 직접 사용하면 HW 오프로드 상태를 고려하지 않아 잘못된 체크섬이 전송될 수 있습니다.
skb 분할/병합/변형 함수
GSO/TSO, TCP 재전송, 패킷 포워딩 등에서 skb를 분할하거나 변형해야 하는 경우에 사용되는 함수들입니다.
| 함수 | 프로토타입 | 설명 |
|---|---|---|
skb_split | void skb_split(skb, skb1, len) | skb를 len 위치에서 둘로 분할. 원본은 앞부분, skb1에 뒷부분. TCP 세그먼트 분할 |
skb_segment | struct sk_buff *skb_segment(skb, features) | GSO skb를 MSS 크기의 개별 세그먼트 리스트로 분할. SW GSO 경로의 핵심 |
skb_gso_segment | struct sk_buff *skb_gso_segment(skb, features) | skb_segment의 wrapper. 프로토콜별 GSO 콜백 호출 |
skb_morph | struct sk_buff *skb_morph(dst, src) | dst skb를 src의 복제로 변형 (dst 내용물 교체). TCP fastopen, fclone 최적화 |
skb_condense | void skb_condense(skb) | linear 영역의 slack(유휴) 공간 축소. 메모리 절약 (소켓 큐에 장기 보관 시) |
skb_scrub_packet | void skb_scrub_packet(skb, xnet) | 포워딩/라우팅 전 민감 메타데이터 제거. mark, priority, 분류 결과 초기화 |
skb_orphan | void skb_orphan(skb) | skb↔소켓 연결 해제. destructor 호출 후 sk=NULL. TC/qdisc에서 소켓 메모리 즉시 반환 |
skb_shift | int skb_shift(to, from, shiftlen) | from의 paged data를 to로 이동. TCP coalescing (연속 세그먼트 병합) |
/* skb_split: TCP 세그먼트 분할 (재전송 시) */
struct sk_buff *buff = alloc_skb(nsize, GFP_ATOMIC);
if (!buff)
return -ENOMEM;
skb_split(skb, buff, len);
/* skb: 0 ~ len-1 바이트 (재전송할 부분) */
/* buff: len ~ 끝 (나중에 전송할 부분) */
skb_queue_after(&sk->sk_write_queue, skb, buff);
/* skb_segment: GSO → 개별 MSS 세그먼트로 분할 */
/* NIC가 TSO를 지원하지 않는 경로에서 SW GSO 수행 */
struct sk_buff *segs = skb_gso_segment(skb, dev->features);
if (IS_ERR(segs)) {
kfree_skb(skb);
return PTR_ERR(segs);
}
/* segs는 연결된 skb 리스트 (각각 MSS 크기) */
consume_skb(skb);
skb_list_walk_safe(segs, seg, next) {
skb_mark_not_on_list(seg);
dev_queue_xmit(seg);
}
/* skb_orphan: qdisc에서 소켓 메모리 즉시 해제 */
/* fq_codel 등에서 큐잉 시간이 길어질 수 있으므로 */
skb_orphan(skb);
/* skb->sk = NULL, destructor가 호출되어 sk_wmem_alloc 감소 */
/* → 소켓이 새 데이터를 전송할 수 있게 됨 */
/* skb_scrub_packet: 네임스페이스 간 포워딩 시 */
skb_scrub_packet(skb, true); /* xnet=true: 네트워크 네임스페이스 경계 */
/* mark, priority, pkt_type, hash 등 초기화됨 */
/* skb_condense: 소켓 수신 큐 메모리 최적화 */
/* GRO 후 linear 영역에 slack이 클 수 있음 */
skb_condense(skb);
/* data_len이 0이 아니고 truesize > SKB_TRUESIZE(skb_end_offset(skb))이면 */
/* paged data를 가능한 한 linear으로 옮기고 slack 해제 */
주의: skb_split()은 원본 skb와 새 skb가 paged fragments를 공유할 수 있습니다. 분할 후 한쪽의 fragment를 수정하면 다른 쪽에 영향을 줄 수 있습니다. skb_segment()에서 반환된 리스트의 각 세그먼트는 next 포인터로 연결되며, 반드시 모든 세그먼트를 전송하거나 해제해야 합니다.
참조 카운트 및 소유권 함수
skb의 수명주기를 관리하는 참조 카운트와 소유권 관련 함수들입니다.
| 함수 | 프로토타입 | 설명 |
|---|---|---|
skb_get | struct sk_buff *skb_get(skb) | users 참조 카운트 증가. 여러 곳에서 같은 skb 메타데이터를 참조할 때 |
skb_unref | bool skb_unref(skb) | users 감소. 0이 되면 true 반환 (호출자가 해제 책임). kfree_skb 내부에서 사용 |
skb_orphan | void skb_orphan(skb) | 소켓 연결 해제. destructor 호출 → sk = NULL. 소켓 메모리 즉시 반환 |
skb_dst | struct dst_entry *skb_dst(skb) | skb에 연결된 라우팅 결정(dst_entry) 조회 |
skb_dst_set | void skb_dst_set(skb, dst) | 라우팅 결정을 skb에 연결. dst의 참조 카운트는 호출자가 이미 획득 |
skb_dst_drop | void skb_dst_drop(skb) | dst 참조 해제 + skb->_skb_refdst = 0 |
skb_dst_force | void skb_dst_force(skb) | noref dst를 ref dst로 승격 (장기 보관 전). RCU 보호 해제 시 필수 |
skb_set_owner_r | void skb_set_owner_r(skb, sk) | 수신 방향 소켓 소유권 설정. sk_rmem_alloc += truesize |
skb_set_owner_w | void skb_set_owner_w(skb, sk) | 전송 방향 소켓 소유권 설정. sk_wmem_alloc += truesize |
/* skb_get: 패킷 미러링 — 원본을 유지하면서 다른 경로로도 전달 */
struct sk_buff *mirror = skb_get(skb);
/* mirror와 skb는 동일한 sk_buff 구조체 (users == 2) */
/* 주의: 데이터 수정 시 skb_clone() + skb_ensure_writable() 사용 */
netif_rx(mirror); /* 미러 포트로 전달 */
/* skb_dst_force: qdisc 큐잉 전 dst 참조 고정 */
/* RCU read-side에서 noref로 설정된 dst는 RCU 밖에서 무효화될 수 있음 */
skb_dst_force(skb);
__skb_queue_tail(&qdisc->q, skb);
/* skb_orphan: 소켓 메모리 즉시 반환이 필요한 경우 */
/* 예: TCP 전송 경로에서 qdisc 큐에 오래 대기하면 */
/* sk_wmem_alloc이 sk_sndbuf에 도달하여 전송 차단 */
skb_orphan(skb);
/* → destructor(sock_wfree) 호출 → sk_wmem_alloc 감소 */
/* → 소켓이 새 데이터를 write 할 수 있게 됨 */
주의: skb_get()은 skb_clone()과 다릅니다. skb_get()은 같은 sk_buff 구조체의 참조 카운트만 올리므로, 메타데이터(mark, priority 등)를 변경하면 양쪽 모두 영향받습니다. 독립적인 메타데이터가 필요하면 skb_clone()을 사용하세요. 또한 skb_dst_set() 후에는 반드시 대응하는 skb_dst_drop()이 호출되어야 dst 참조 누수가 발생하지 않습니다.
해시/메타데이터 조작 함수
skb에 연결된 패킷 해시, 큐 매핑, 리다이렉트 플래그 등 메타데이터를 조작하는 함수들입니다. RSS/RPS 스티어링, TC 분류, 멀티큐 NIC 큐 선택 등에 사용됩니다.
| 함수 | 프로토타입 | 설명 |
|---|---|---|
skb_get_hash | u32 skb_get_hash(skb) | 패킷의 flow hash 반환. 캐시된 값이 있으면 재사용, 없으면 flow dissector로 계산 |
skb_set_hash | void skb_set_hash(skb, hash, type) | 해시 값과 타입(PKT_HASH_TYPE_L3, PKT_HASH_TYPE_L4) 설정. 드라이버에서 RSS 해시 전달 |
skb_clear_hash | void skb_clear_hash(skb) | 캐시된 해시 무효화. 패킷 내용이 변경되어 재계산이 필요할 때 |
skb_get_queue_mapping | u16 skb_get_queue_mapping(skb) | 멀티큐 NIC의 TX 큐 인덱스 조회 |
skb_set_queue_mapping | void skb_set_queue_mapping(skb, mapping) | TX 큐 인덱스 설정. TC mqprio/XPS에서 사용 |
skb_record_rx_queue | void skb_record_rx_queue(skb, rx_queue) | 수신 큐 인덱스 기록. RPS 등에서 활용 |
skb_set_redirected | void skb_set_redirected(skb, from_ingress) | TC/XDP 리다이렉트 표시. 패킷이 리다이렉트된 것임을 마킹 |
skb_clear_redirected | void skb_clear_redirected(skb) | 리다이렉트 표시 해제 |
/* 드라이버에서 HW RSS 해시 설정 */
static void my_rx_handler(struct sk_buff *skb, u32 hw_hash)
{
skb_set_hash(skb, hw_hash, PKT_HASH_TYPE_L4);
skb_record_rx_queue(skb, rx_ring->queue_idx);
napi_gro_receive(&rx_ring->napi, skb);
}
/* NAT 후 해시 무효화 (IP/포트가 변경됨) */
skb_clear_hash(skb);
/* 다음 skb_get_hash() 호출 시 flow dissector가 재계산 */
/* TC에서 특정 TX 큐로 스티어링 */
skb_set_queue_mapping(skb, target_queue);
/* XDP 리다이렉트 후 마킹 */
skb_set_redirected(skb, true); /* from_ingress = true */
sk_buff 리스트/큐 관리
struct sk_buff_head는 spinlock을 내장한 이중 연결 리스트로, 소켓 수신/전송 큐, qdisc 큐, TCP 재전송 큐 등에서 skb를 관리하는 데 사용됩니다. lock이 필요한 함수(spinlock 기반)와 lock 없는 함수(__ 접두사)가 쌍으로 존재합니다.
/* sk_buff_head: 이중 연결 리스트의 head (sentinel) */
struct sk_buff_head {
struct sk_buff *next;
struct sk_buff *prev;
__u32 qlen; /* 큐 내 skb 수 */
spinlock_t lock; /* 동시성 보호 */
};
초기화 및 추가/제거
| 함수 | lock | 설명 |
|---|---|---|
skb_queue_head_init(list) | — | 큐 초기화 (next=prev=head, qlen=0, spinlock 초기화) |
skb_queue_head(list, skb) | O | 큐 앞에 추가 (LIFO). lock 획득/해제 포함 |
__skb_queue_head(list, skb) | X | 큐 앞에 추가. 호출자가 lock을 이미 보유해야 함 |
skb_queue_tail(list, skb) | O | 큐 끝에 추가 (FIFO). 가장 일반적 패턴 |
__skb_queue_tail(list, skb) | X | 큐 끝에 추가. lock 없이 |
skb_dequeue(list) | O | 큐 앞에서 skb 제거 및 반환. 빈 큐면 NULL |
__skb_dequeue(list) | X | lock 없이 큐 앞에서 제거 |
skb_dequeue_tail(list) | O | 큐 뒤에서 skb 제거 및 반환 |
__skb_dequeue_tail(list) | X | lock 없이 큐 뒤에서 제거 |
skb_append(old, new, list) | O | old 뒤에 new 삽입 |
skb_insert(new, old, list) | O | old 앞에 new 삽입 |
skb_unlink(skb, list) | O | 큐에서 특정 skb 제거 (위치 무관) |
__skb_unlink(skb, list) | X | lock 없이 큐에서 제거 |
조회 및 순회
| 함수/매크로 | 설명 |
|---|---|
skb_queue_empty(list) | 큐가 비어있는지. list->next == (struct sk_buff *)list |
skb_queue_len(list) | 큐의 skb 수 (list->qlen) |
skb_peek(list) | 큐 첫 번째 skb 반환 (제거 안 함). NULL이면 빈 큐 |
skb_peek_tail(list) | 큐 마지막 skb 반환 (제거 안 함) |
skb_queue_is_last(list, skb) | skb가 큐의 마지막인지 |
skb_queue_is_first(list, skb) | skb가 큐의 첫 번째인지 |
skb_queue_next(list, skb) | 다음 skb. is_last이면 BUG |
skb_queue_prev(list, skb) | 이전 skb. is_first이면 BUG |
skb_queue_walk(list, skb) | 큐 전체 순회 매크로. 순회 중 제거 불가 |
skb_queue_walk_safe(list, skb, tmp) | 큐 순회 + 안전한 제거 가능 (tmp에 next 저장) |
skb_queue_walk_from(list, skb) | 특정 skb부터 끝까지 순회 |
skb_queue_walk_from_safe(list, skb, tmp) | 특정 위치부터 안전한 순회 |
일괄 처리 (splice/purge)
| 함수 | lock | 설명 |
|---|---|---|
skb_queue_purge(list) | O | 큐의 모든 skb를 kfree_skb()로 해제. 큐 비우기 |
__skb_queue_purge(list) | X | lock 없이 큐 비우기 |
skb_queue_splice(from, to) | X | from 큐의 모든 skb를 to 큐 앞에 합침. from은 비워짐 |
skb_queue_splice_tail(from, to) | X | from 큐를 to 큐 뒤에 합침 |
skb_queue_splice_init(from, to) | X | splice + from 큐 재초기화 |
skb_queue_splice_tail_init(from, to) | X | splice_tail + from 큐 재초기화 |
/* 초기화 */
struct sk_buff_head my_queue;
skb_queue_head_init(&my_queue);
/* 기본 enqueue/dequeue (FIFO) */
skb_queue_tail(&my_queue, skb);
struct sk_buff *s = skb_dequeue(&my_queue);
/* 큐 전체 순회 + 조건부 제거 */
struct sk_buff *skb, *tmp;
spin_lock(&my_queue.lock);
skb_queue_walk_safe(&my_queue, skb, tmp) {
if (should_drop(skb)) {
__skb_unlink(skb, &my_queue);
kfree_skb(skb);
}
}
spin_unlock(&my_queue.lock);
/* splice: NAPI poll에서 per-CPU 큐를 소켓 큐로 이동 */
struct sk_buff_head process_queue;
skb_queue_head_init(&process_queue);
spin_lock(&napi_queue.lock);
skb_queue_splice_tail_init(&napi_queue, &process_queue);
spin_unlock(&napi_queue.lock);
/* 이제 lock 없이 process_queue를 처리 */
while ((skb = __skb_dequeue(&process_queue)) != NULL)
netif_receive_skb(skb);
/* 큐 비우기 (모듈 언로드, 소켓 닫기 시) */
skb_queue_purge(&my_queue);
주의: __ 접두사 함수(lockless)는 호출자가 이미 lock을 보유하거나, 동시성이 보장되는 컨텍스트(예: NAPI poll 단독 실행)에서만 사용하세요. skb_queue_walk()로 순회 중에 skb를 제거하면 리스트가 손상됩니다 — 반드시 skb_queue_walk_safe()를 사용하세요. skb_queue_purge()는 모든 skb에 kfree_skb()를 호출하므로 드롭 tracepoint가 발생합니다. 정상 정리에서는 개별 consume_skb()를 고려하세요.
소켓과 sk_buff의 관계
sk_buff의 sk 필드는 해당 패킷이 소속된 소켓(struct sock)을 가리킵니다. 이 연결은 소켓 메모리 계산, 소켓 옵션 적용, 프로토콜별 처리의 기반이 됩니다.
struct sock 계층 구조
커널 소켓은 3단계 계층으로 구성됩니다:
/* 소켓 구조체 계층 (간략) */
struct socket { /* BSD 소켓 (사용자 공간 인터페이스) */
socket_state state; /* SS_UNCONNECTED, SS_CONNECTED 등 */
struct file *file; /* VFS file (fd와 연결) */
struct sock *sk; /* 네트워크 레이어 소켓 */
const struct proto_ops *ops; /* connect, sendmsg 등 */
};
struct sock { /* 프로토콜 무관 공통 소켓 */
struct sk_buff_head sk_receive_queue; /* 수신 skb 큐 */
struct sk_buff_head sk_write_queue; /* 전송 skb 큐 */
struct sk_buff_head sk_error_queue; /* 에러 큐 (ICMP 등) */
struct {
struct sk_buff *head, *tail;
} sk_backlog; /* backlog 큐 (lock 중 수신) */
atomic_t sk_rmem_alloc; /* 수신 큐 메모리 사용량 */
atomic_t sk_wmem_alloc; /* 전송 큐 메모리 사용량 */
int sk_rcvbuf; /* SO_RCVBUF 값 */
int sk_sndbuf; /* SO_SNDBUF 값 */
unsigned long sk_flags; /* 소켓 플래그 */
struct proto *sk_prot; /* 프로토콜 핸들러 */
void (*sk_data_ready)(struct sock *sk); /* 수신 알림 */
/* ... */
};
/* 프로토콜별 확장 (임베디드 패턴) */
struct inet_sock { /* IPv4/IPv6 공통 */
struct sock sk; /* 공통 sock 내장 */
__be32 inet_saddr; /* 소스 IP */
__be32 inet_daddr; /* 목적지 IP */
__be16 inet_sport; /* 소스 포트 */
__be16 inet_dport; /* 목적지 포트 */
__u8 tos; /* IP_TOS 옵션 */
__u8 min_ttl; /* IP_MINTTL 옵션 */
__s16 uc_ttl; /* IP_TTL 옵션 (-1 = 기본값) */
struct ip_options_rcu *inet_opt; /* IP 옵션 */
/* ... */
};
struct tcp_sock {
struct inet_connection_sock inet_conn; /* inet_sock ⊃ sock 내장 */
u32 snd_una; /* 가장 오래된 미확인 시퀀스 */
u32 snd_nxt; /* 다음 전송 시퀀스 */
u32 rcv_nxt; /* 다음 수신 기대 시퀀스 */
u32 mss_cache; /* MSS (최대 세그먼트 크기) */
/* ... 수십 개 TCP 전용 필드 ... */
};
struct udp_sock {
struct inet_sock inet;
int pending; /* cork 상태 */
__u8 encap_type; /* UDP encap (VXLAN 등) */
/* ... */
};
/* 캐스팅 매크로 */
#define inet_sk(sk) ((struct inet_sock *)(sk))
#define tcp_sk(sk) ((struct tcp_sock *)(sk))
#define udp_sk(sk) ((struct udp_sock *)(sk))
skb↔sk 바인딩과 소켓 메모리 관리(Memory Management)
skb->sk가 설정되면 해당 skb의 메모리 사용량이 소켓에 과금됩니다. 이 메커니즘이 SO_RCVBUF/SO_SNDBUF 제한을 실현합니다:
/* skb를 소켓에 연결 — 메모리 과금 시작 */
static inline void skb_set_owner_r(struct sk_buff *skb, struct sock *sk)
{
skb->sk = sk;
skb->destructor = sock_rfree; /* 해제 시 콜백 */
atomic_add(skb->truesize, &sk->sk_rmem_alloc);
/* → 수신 큐 메모리 사용량에 truesize만큼 추가 */
}
static inline void skb_set_owner_w(struct sk_buff *skb, struct sock *sk)
{
skb->sk = sk;
skb->destructor = sock_wfree; /* 해제 시 콜백 */
refcount_add(skb->truesize, &sk->sk_wmem_alloc);
/* → 전송 큐 메모리 사용량에 truesize만큼 추가 */
}
/* skb 해제 시: destructor 콜백이 메모리 차감 */
void sock_rfree(struct sk_buff *skb)
{
struct sock *sk = skb->sk;
atomic_sub(skb->truesize, &sk->sk_rmem_alloc);
/* → 수신 큐 메모리 사용량에서 차감 */
}
/* 수신 큐 과부하 확인 — 소켓 버퍼 제한 */
static inline bool sk_rmem_schedule(struct sock *sk, struct sk_buff *skb, int size)
{
/* sk_rmem_alloc + size > sk_rcvbuf 이면 false → 패킷 드롭 */
return __sk_mem_schedule(sk, size, SK_MEM_RECV);
}
| 필드/콜백(Callback) | 방향 | 역할 |
|---|---|---|
sk_rmem_alloc | RX | 수신 큐에 쌓인 skb의 총 truesize (SO_RCVBUF와 비교) |
sk_wmem_alloc | TX | 전송 중인 skb의 총 truesize (SO_SNDBUF와 비교) |
sock_rfree | RX | skb 해제 시 sk_rmem_alloc 차감 |
sock_wfree | TX | skb 해제 시 sk_wmem_alloc 차감, 전송 대기 프로세스 wakeup |
skb_orphan(skb) | 양방향 | skb↔sk 연결 해제 (destructor 호출 후 sk=NULL) |
skb->truesize는 skb 구조체 크기 + 할당된 데이터 버퍼 크기를 합산한 값입니다. 소켓의 메모리 추적은 이 값 기반이므로, truesize가 실제와 어긋나면 SO_RCVBUF 제한이 오작동합니다. 너무 작으면 메모리 과다 사용, 너무 크면 조기 패킷 드롭이 발생합니다.
sk 참조 카운트(Reference Count)와 수명주기
skb->sk가 가리키는 소켓이 skb보다 먼저 해제되면 Use-After-Free가 발생합니다. 이를 방지하기 위해 커널은 참조 카운트 기반 수명주기 관리를 합니다. struct sock의 sk_refcnt가 0이 되어야 소켓이 실제로 해제됩니다.
/* include/net/sock.h — sk 참조 카운트 API */
/* 소켓 할당: sk_refcnt = 1로 시작 */
struct sock *sk_alloc(struct net *net, int family,
gfp_t priority, struct proto *prot, int kern)
{
struct sock *sk = sk_prot_alloc(prot, priority, family);
if (sk) {
refcount_set(&sk->sk_refcnt, 1);
/* net 참조, 프로토콜 핸들러 설정 등 */
sock_net_set(sk, get_net(net));
sk->sk_prot = prot;
}
return sk;
}
/* 참조 카운트 증가 — sk를 장기 보유할 때 호출 */
static inline void sock_hold(struct sock *sk)
{
refcount_inc(&sk->sk_refcnt);
}
/* 참조 카운트 감소 — 0이면 sk_free() 호출 */
static inline void sock_put(struct sock *sk)
{
if (refcount_dec_and_test(&sk->sk_refcnt))
sk_free(sk);
}
/* 소켓 해제 체인 */
void sk_free(struct sock *sk)
{
/* 1. sk->sk_destruct 콜백 호출 (프로토콜별 정리) */
if (sk->sk_destruct)
sk->sk_destruct(sk);
/* 2. 필터(BPF) 해제 */
sk_filter_uncharge(sk, sk->sk_filter);
/* 3. net namespace 참조 해제 */
put_net(sock_net(sk));
/* 4. 프로토콜별 slab에서 메모리 반환 */
sk_prot_free(sk->sk_prot, sk);
}
/* inet_sock_destruct: AF_INET 소켓의 sk_destruct 콜백 */
void inet_sock_destruct(struct sock *sk)
{
/* 수신/전송/에러 큐의 잔여 skb 전부 해제 */
__skb_queue_purge(&sk->sk_receive_queue);
__skb_queue_purge(&sk->sk_write_queue);
__skb_queue_purge(&sk->sk_error_queue);
/* 메모리 추적 검증: 모든 skb가 해제된 후 0이어야 함 */
WARN_ON(atomic_read(&sk->sk_rmem_alloc));
WARN_ON(refcount_read(&sk->sk_wmem_alloc));
WARN_ON(sk->sk_forward_alloc);
/* IP 옵션, cork 등 해제 */
kfree(rcu_dereference_protected(
inet_sk(sk)->inet_opt, 1));
/* dst_cache 해제 */
dst_release(rcu_dereference_protected(
sk->sk_dst_cache, 1));
}
close(fd) ≠ sk_free: 사용자 공간에서 close(fd)를 호출해도 소켓이 즉시 해제되지 않습니다. TCP의 경우 TIME_WAIT 상태에서 수십 초간 유지되고, 전송 큐에 미전송 skb가 남아있으면 그 skb들의 destructor가 호출되어 sock_put()을 수행할 때까지 소켓이 살아있습니다. /proc/net/sockstat의 "orphan" 카운트가 이런 소켓을 보여줍니다.
skb_orphan과 소유권(Ownership) 전이
skb_orphan()은 skb와 소켓의 연결을 끊어 소유권을 해제합니다. 이 함수는 네트워크 스택의 여러 전환점에서 호출되며, 소켓 메모리 회계와 소켓 수명주기에 직접 영향을 미칩니다.
/* include/linux/skbuff.h — skb_orphan */
static inline void skb_orphan(struct sk_buff *skb)
{
/* destructor가 있으면 호출 → 소켓 메모리 회계 정리 */
if (skb->destructor) {
skb->destructor(skb); /* sock_wfree/sock_rfree 등 */
skb->destructor = NULL;
}
skb->sk = NULL; /* 소켓 참조 해제 */
}
/* skb_orphan_partial: TX 경로에서 wmem만 반환, sk 참조 유지 */
void skb_orphan_partial(struct sk_buff *skb)
{
if (skb_is_tcp_pure_ack(skb))
return; /* pure ACK은 건드리지 않음 */
if (skb->destructor) {
/* 현재 destructor 호출 → wmem_alloc 차감 */
skb->destructor(skb);
/* sock_efree로 교체 → sk만 유지 (wmem 0) */
skb->destructor = sock_efree;
sock_hold(skb->sk); /* sk 참조 유지 (refcnt++) */
}
}
/* sock_efree: sk 참조만 해제 (메모리 회계 없음) */
void sock_efree(struct sk_buff *skb)
{
sock_put(skb->sk); /* refcnt-- → 0이면 sk_free */
}
/* 실제 사용 예: qdisc enqueue 시 orphan */
static inline int __dev_xmit_skb(struct sk_buff *skb,
struct Qdisc *q, ...)
{
/* sysctl net.core.skb_orphan_first = 1일 때 */
if (READ_ONCE(netdev_budget_usecs))
skb_orphan_partial(skb);
/* → qdisc에 진입하면서 소켓 메모리 예산 즉시 반환
* 이후 NIC 전송 완료까지 skb는 sk 없이(또는 efree 참조만으로) 존재 */
return q->enqueue(skb, q, ...);
}
/* IP 포워딩 시 orphan */
int ip_forward(struct sk_buff *skb)
{
/* 포워딩 패킷은 로컬 소켓과 무관 */
skb_orphan(skb); /* 완전 orphan */
/* ... TTL 감소, checksum 재계산, 라우팅 ... */
return ip_forward_finish(net, sk, skb);
}
성능 영향: skb_orphan_partial()이 qdisc 진입 시점에 호출되면, 소켓의 sk_wmem_alloc이 조기에 감소하여 sendmsg()가 -EAGAIN 없이 더 많은 데이터를 보낼 수 있습니다. 대역폭이 높은 전송 시 처리량이 개선되지만, qdisc 큐가 비정상적으로 커지면 메모리 사용량이 늘 수 있습니다. tc qdisc show로 큐 길이를 모니터링하세요.
skb->destructor 콜백(Callback) 상세
skb->destructor는 skb가 해제될 때 호출되는 콜백 함수 포인터로, 소켓 메모리 회계의 핵심입니다. 커널은 용도별로 다양한 destructor를 사용합니다:
/* net/core/sock.c — sock_wfree (전송 경로 destructor) */
void sock_wfree(struct sk_buff *skb)
{
struct sock *sk = skb->sk;
/* wmem_alloc에서 truesize 차감 */
if (refcount_sub_and_test(skb->truesize, &sk->sk_wmem_alloc)) {
/* wmem_alloc이 0이 되면 → 마지막 전송 skb 해제
* → 대기 중인 송신자 wakeup 가능 */
}
/* 쓰기 공간이 확보되었음을 알림 */
if (sock_flag(sk, SOCK_USE_WRITE_QUEUE)) {
/* TCP: sk_write_space → tcp_write_space → epollout 트리거 */
} else {
sk->sk_write_space(sk);
}
sock_put(sk); /* sk 참조 카운트 감소 */
}
/* net/ipv4/tcp_output.c — tcp_wfree (TCP 전용 destructor) */
void tcp_wfree(struct sk_buff *skb)
{
struct sock *sk = skb->sk;
struct tcp_sock *tp = tcp_sk(sk);
unsigned long flags, nval, oval;
/* TSQ (TCP Small Queue) 제어:
* NIC 전송 완료 → tcp_wfree → 추가 세그먼트 전송 허용
* → 지연시간 감소 (qdisc 큐에 과도한 패킷 축적 방지) */
tcp_tsq_write(sk);
/* wmem_alloc 차감 */
refcount_sub(skb->truesize - 1, &sk->sk_wmem_alloc);
sk->sk_tsq_flags |= TSQF_THROTTLED;
sock_put(sk);
}
/* kfree_skb 호출 체인에서 destructor가 호출되는 위치 */
void __kfree_skb(struct sk_buff *skb)
{
skb_release_all(skb);
/* → skb_release_head_state(skb)
* → if (skb->destructor)
* skb->destructor(skb); ← 여기서 호출!
* → skb_release_data(skb)
* → kfree_skbmem(skb) */
}
| destructor | 설정 함수 | 회계 동작 | 추가 동작 |
|---|---|---|---|
sock_rfree | skb_set_owner_r() | sk_rmem_alloc -= truesize | 없음 |
sock_wfree | skb_set_owner_w() | sk_wmem_alloc -= truesize | sk_write_space() wakeup |
tcp_wfree | tcp_skb_set_owner_w() | sk_wmem_alloc -= truesize | TSQ tasklet → 추가 세그먼트 전송 |
sock_efree | skb_orphan_partial() | 없음 (회계 제외) | sock_put(sk) — refcnt만 관리 |
sock_edemux | Early Demux 경로 | 없음 | sock_put(sk) — GRO/early demux 최적화 |
sock_rmem_free | 에러 큐 등 | sk_rmem_alloc -= truesize | sock_put(sk) |
unix_destruct_scm | Unix SCM_RIGHTS | sk_wmem_alloc -= truesize | fput() — fd 참조 해제 |
sctp_wfree | SCTP 전송 | asoc→sndbuf_used 차감 | SCTP 전송 wakeup |
sk_forward_alloc과 메모리 예산(Memory Budget)
매 skb마다 __sk_mem_schedule()를 호출하면 atomic 연산 비용이 높습니다. 커널은 sk_forward_alloc이라는 선급 예산을 두어 소켓별로 한 번에 큰 단위로 메모리를 할당받고, skb 단위에서는 이 예산에서 차감하는 방식으로 최적화합니다.
/* net/core/sock.c — __sk_mem_schedule */
int __sk_mem_schedule(struct sock *sk, int size, int kind)
{
int amt = sk_mem_pages(size);
/* size를 페이지 단위로 올림:
* sk_mem_pages(size) = (size + PAGE_SIZE - 1) >> PAGE_SHIFT */
sk->sk_forward_alloc += amt * SK_MEM_QUANTUM;
/* SK_MEM_QUANTUM = PAGE_SIZE = 4096 (일반적)
* → 한 번 충전에 최소 4KB 예산 확보 */
/* 전역 프로토콜 메모리에서 차감 */
atomic_long_add(amt, sk->sk_prot->memory_allocated);
/* 메모리 압력 상태 확인 */
if (mem_cgroup_sockets_enabled && sk->sk_memcg) {
/* cgroup v2: cgroup 단위 메모리 제한도 적용 */
if (!mem_cgroup_charge_skmem(sk->sk_memcg, amt,
sk->sk_prot->memory_pressure))
goto suppress;
}
/* tcp_mem[0] (low), tcp_mem[1] (pressure), tcp_mem[2] (high) */
if (atomic_long_read(sk->sk_prot->memory_allocated) >
sk->sk_prot->sysctl_mem[2]) {
/* high 초과 → 거부 (새 skb 할당 실패) */
sk->sk_prot->enter_memory_pressure(sk);
goto suppress;
}
return 1; /* 성공 */
suppress:
/* 예산 롤백 */
sk->sk_forward_alloc -= amt * SK_MEM_QUANTUM;
atomic_long_sub(amt, sk->sk_prot->memory_allocated);
return 0; /* 실패 → 호출자가 패킷 드롭 */
}
/* sk_mem_reclaim: 잔여 예산을 전역 풀에 반환 */
void sk_mem_reclaim(struct sock *sk)
{
if (sk->sk_forward_alloc >= SK_MEM_QUANTUM) {
/* 사용하지 않는 예산을 전역에 반환 → 다른 소켓이 사용 */
__sk_mem_reclaim(sk, sk->sk_forward_alloc - 1);
}
}
/* 수신 경로에서의 예산 소비 예시 */
static int tcp_try_rmem_schedule(struct sock *sk, struct sk_buff *skb,
unsigned int size)
{
if (atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf) {
/* SO_RCVBUF 초과 → OOM 경로 */
tcp_try_coalesce(sk, skb, ...); /* 병합 시도 */
if (...)
goto drop;
}
if (!sk_rmem_schedule(sk, skb, size)) {
/* sk_forward_alloc 부족 + __sk_mem_schedule 실패
* → 메모리 압력 상태: pruning (OOO 큐 정리) 시도 */
tcp_prune_ofo_queue(sk);
if (!sk_rmem_schedule(sk, skb, size))
goto drop;
}
return 0;
drop:
/* → TCPRcvQDrop 카운터 증가 → /proc/net/snmp */
return -1;
}
sk_forward_alloc은 inet_sock_destruct()에서 0인지 검증됩니다. 0이 아니면 WARN_ON이 발생하며, 이는 메모리 예산 계산 버그를 의미합니다. 커스텀 프로토콜 핸들러를 작성할 때 sk_mem_charge()/sk_mem_uncharge() 호출 쌍이 정확히 일치해야 합니다.
소켓 메모리 압력(Memory Pressure) 시스템
커널은 프로토콜별로 전역 메모리 압력 상태를 관리합니다. TCP, UDP 등 각 프로토콜은 memory_allocated와 sysctl_mem[] 임계값으로 시스템 전체의 소켓 메모리를 제어합니다.
/* include/net/sock.h — 프로토콜별 메모리 관리 구조체 */
struct proto {
/* ... */
atomic_long_t *memory_allocated; /* 프로토콜 전체 할당 페이지 수 */
int *memory_pressure; /* 압력 상태 플래그 (0 or 1) */
long *sysctl_mem; /* [low, pressure, high] 배열 */
int *sysctl_rmem; /* [min, default, max] 수신 */
int *sysctl_wmem; /* [min, default, max] 전송 */
/* ... */
};
/* TCP 프로토콜의 메모리 관련 전역 변수 */
atomic_long_t tcp_memory_allocated; /* TCP 전체 메모리 (페이지 단위) */
int tcp_memory_pressure; /* TCP 압력 플래그 */
long sysctl_tcp_mem[3]; /* 부팅 시 자동 계산 */
/* tcp_init_mem: tcp_mem 자동 계산 (부팅 시) */
void tcp_init_mem(void)
{
unsigned long limit = nr_free_buffer_pages() / 8;
limit = max(limit, 128UL);
sysctl_tcp_mem[0] = limit * 3 / 4; /* low: 전체의 3/32 */
sysctl_tcp_mem[1] = limit; /* pressure: 전체의 1/8 */
sysctl_tcp_mem[2] = limit * 3 / 2; /* high: 전체의 3/16 */
}
/* sk_under_memory_pressure: 프로토콜 압력 확인 */
static inline bool sk_under_memory_pressure(const struct sock *sk)
{
/* cgroup 메모리 제한도 고려 */
if (mem_cgroup_sockets_enabled && sk->sk_memcg &&
mem_cgroup_under_socket_pressure(sk->sk_memcg))
return true;
return !!*sk->sk_prot->memory_pressure;
}
/* tcp_enter_memory_pressure: 압력 상태 진입 */
void tcp_enter_memory_pressure(struct sock *sk)
{
unsigned long val;
if (READ_ONCE(tcp_memory_pressure))
return;
WRITE_ONCE(tcp_memory_pressure, 1);
/* → /proc/net/sockstat의 "TCP: ... pressure 1"로 확인 가능 */
}
/* tcp_leave_memory_pressure: 압력 상태 해제 */
void tcp_leave_memory_pressure(struct sock *sk)
{
unsigned long val;
if (!READ_ONCE(tcp_memory_pressure))
return;
if (atomic_long_read(&tcp_memory_allocated) <
sysctl_tcp_mem[0])
WRITE_ONCE(tcp_memory_pressure, 0);
}
# 소켓 메모리 압력 상태 모니터링
# TCP 메모리 사용량과 압력 상태 확인
cat /proc/net/sockstat
# sockets: used 1024
# TCP: inuse 80 orphan 2 tw 15 alloc 85 mem 1520 ← mem = 페이지
# UDP: inuse 12 mem 3
# → mem * PAGE_SIZE = 실제 바이트 (1520 * 4096 ≈ 6.2MB)
# TCP 메모리 임계값 확인/조정 (페이지 단위)
cat /proc/sys/net/ipv4/tcp_mem
# 191232 254976 382464 ← low pressure high
# → 750MB 1000MB 1500MB (4KB 페이지 기준)
# UDP 메모리 임계값
cat /proc/sys/net/ipv4/udp_mem
# 191232 254976 382464
# 소켓별 메모리 사용량 확인 (ss)
ss -tm
# ESTAB 0 0 192.168.1.1:80 10.0.0.1:12345
# skmem:(r0,rb1048576,t0,tb2626560,f0,w0,o0,bl0,d0)
# r=rmem_alloc rb=rcvbuf t=wmem_alloc tb=sndbuf
# f=forward_alloc w=wmem_queued o=opt_mem bl=backlog d=drop
# TCP 압력 상태 실시간 추적
watch -n1 'grep "TCP:" /proc/net/sockstat'
cgroup v2 메모리 제한: cgroup v2의 memory.max가 소켓 메모리에도 적용됩니다(CONFIG_MEMCG). sk->sk_memcg가 설정되면 __sk_mem_schedule()이 mem_cgroup_charge_skmem()을 호출하여 cgroup 단위 제한도 검사합니다. 컨테이너 환경에서 tcp_mem이 충분해도 cgroup 제한으로 패킷이 드롭될 수 있습니다. memory.stat의 sock 항목으로 확인하세요.
소켓 잠금(Lock)과 backlog 처리
소켓의 수신 큐에 skb를 넣으려면 소켓 잠금이 필요합니다. 사용자 프로세스가 recvmsg() 중이라 소켓을 잠그고 있을 때, softirq에서 도착한 패킷은 backlog 큐에 임시 저장됩니다. release_sock() 시점에 backlog이 한꺼번에 처리됩니다.
/* include/net/sock.h — 소켓 잠금 관련 구조와 API */
struct sock {
/* socket_lock_t sk_lock: 소켓 잠금 */
struct {
spinlock_t slock; /* softirq 보호용 스핀록 */
int owned; /* 1 = 프로세스가 소유 중 */
wait_queue_head_t wq; /* lock 대기 큐 */
} sk_lock;
/* backlog 큐 */
struct {
atomic_t rmem_alloc; /* backlog skb 메모리 */
int len; /* backlog 총 길이 */
struct sk_buff *head, *tail; /* backlog 리스트 */
} sk_backlog;
/* backlog 처리 콜백 (프로토콜별) */
int (*sk_backlog_rcv)(struct sock *sk, struct sk_buff *skb);
/* TCP: tcp_v4_do_rcv
* UDP: __udp_queue_rcv_skb */
};
/* lock_sock: 프로세스 컨텍스트에서 소켓 잠금 */
void lock_sock(struct sock *sk)
{
lock_sock_nested(sk, 0);
/* 1. spin_lock_bh(&sk->sk_lock.slock) — BH 비활성화
* 2. sk->sk_lock.owned = 1 — 소유 표시
* 3. spin_unlock_bh(&sk->sk_lock.slock)
* → 이후 softirq는 owned=1을 보고 backlog 사용 */
}
/* release_sock: 소켓 잠금 해제 + backlog 처리 */
void release_sock(struct sock *sk)
{
spin_lock_bh(&sk->sk_lock.slock);
if (sk->sk_backlog.tail) {
__release_sock(sk); /* backlog 소진 */
}
sk->sk_lock.owned = 0;
if (waitqueue_active(&sk->sk_lock.wq))
wake_up(&sk->sk_lock.wq);
spin_unlock_bh(&sk->sk_lock.slock);
}
/* __release_sock: backlog 큐의 모든 skb 처리 */
static void __release_sock(struct sock *sk)
{
struct sk_buff *skb, *next;
while ((skb = sk->sk_backlog.head) != NULL) {
sk->sk_backlog.head = sk->sk_backlog.tail = NULL;
spin_unlock_bh(&sk->sk_lock.slock);
do {
next = skb->next;
skb->next = NULL;
/* 프로토콜별 수신 처리 */
sk->sk_backlog_rcv(sk, skb);
/* TCP: tcp_v4_do_rcv() → ACK 처리, 데이터 큐잉 */
skb = next;
} while (skb != NULL);
spin_lock_bh(&sk->sk_lock.slock);
/* 처리 중 새 backlog이 쌓였을 수 있음 → while 반복 */
}
}
/* __sk_add_backlog: softirq에서 backlog에 skb 추가 */
static inline int __sk_add_backlog(struct sock *sk, struct sk_buff *skb)
{
/* backlog 크기 제한 확인 */
if (sk->sk_backlog.len >= sk_rcvqueues_full(sk))
return -ENOBUFS; /* → 패킷 드롭 */
/* 리스트에 추가 */
if (!sk->sk_backlog.tail)
sk->sk_backlog.head = skb;
else
sk->sk_backlog.tail->next = skb;
sk->sk_backlog.tail = skb;
sk->sk_backlog.len += skb->truesize;
return 0;
}
bh_lock_sock vs lock_sock: lock_sock()은 프로세스 컨텍스트 전용이고 sleep 가능합니다. bh_lock_sock(sk)은 softirq에서 사용하며 spin_lock(&sk->sk_lock.slock)만 잡고, owned를 확인해 backlog으로 분기합니다. 두 잠금이 다른 것은 프로세스가 오래 소켓을 잡고 있어도 softirq가 블록되지 않게 하기 위한 설계입니다.
TCP 소켓 버퍼 자동 튜닝(Autotuning)
사용자가 SO_RCVBUF를 명시적으로 설정하지 않으면, TCP는 네트워크 조건에 따라 수신 버퍼를 자동으로 조절합니다. 이를 autotuning이라 하며 tcp_moderate_rcvbuf sysctl로 제어합니다.
/* net/ipv4/tcp_input.c — tcp_rcv_space_adjust */
void tcp_rcv_space_adjust(struct sock *sk)
{
struct tcp_sock *tp = tcp_sk(sk);
u32 time, space;
/* SOCK_RCVBUF_LOCK: 사용자가 SO_RCVBUF 설정 → autotuning 중단 */
if (sock_flag(sk, SOCK_RCVBUF_LOCK))
return;
/* 최근 수신 처리량 측정 (bytes/time) */
time = tcp_jiffies32 - tp->rcvq_space.time;
if (time < (tp->rcv_rtt_est.rtt_us >> 3) || tp->rcv_rtt_est.rtt_us == 0)
return;
/* 수신 공간 필요량 계산:
* space = 수신 처리량 × 2 (BDP의 2배 여유) */
space = tp->rcvq_space.space * 2;
/* sk_rcvbuf 상한: tcp_rmem[2] (기본 6MB) */
space = min(space, tcp_space_from_win(tp,
READ_ONCE(sock_net(sk)->ipv4.sysctl_tcp_rmem[2])));
if (space > sk->sk_rcvbuf) {
/* 수신 버퍼 확장 */
WRITE_ONCE(sk->sk_rcvbuf, space);
/* → 더 큰 수신 윈도우를 상대에게 광고
* → throughput 향상 (특히 고지연 네트워크) */
}
tp->rcvq_space.space = tp->copied_seq - tp->rcvq_space.seq;
tp->rcvq_space.seq = tp->copied_seq;
tp->rcvq_space.time = tcp_jiffies32;
}
/* 전송 측 autotuning: sk_sndbuf도 유사하게 동적 조절 */
/* tcp_sndbuf_expand() — TCP pacing과 연동 */
void tcp_sndbuf_expand(struct sock *sk)
{
if (sock_flag(sk, SOCK_SNDBUF_LOCK))
return; /* SO_SNDBUF 설정 시 중단 */
/* sk_pacing_rate 기반 전송 버퍼 크기 계산 */
int sndmem = SKB_TRUESIZE(tp->mss_cache) *
max_t(int, tp->snd_cwnd, tp->reordering + 1);
sndmem *= 2; /* 여유 계수 */
if (sndmem > sk->sk_sndbuf)
WRITE_ONCE(sk->sk_sndbuf,
min(sndmem,
READ_ONCE(sock_net(sk)->ipv4.sysctl_tcp_wmem[2])));
}
# TCP 자동 튜닝 관련 sysctl
# 자동 튜닝 활성화/비활성화 (기본값: 1)
sysctl net.ipv4.tcp_moderate_rcvbuf
# 1 = 자동 튜닝 활성 (권장)
# 0 = 비활성 → tcp_rmem[1](default)로 고정
# TCP 수신 버퍼 범위 (바이트 단위)
sysctl net.ipv4.tcp_rmem
# 4096 131072 6291456
# ↑min ↑default ↑max (autotuning 상한)
# min: 메모리 압력 시 최소 보장
# default: 새 소켓 초기값
# max: autotuning이 확장 가능한 최대값
# TCP 전송 버퍼 범위 (바이트 단위)
sysctl net.ipv4.tcp_wmem
# 4096 16384 4194304
# 특정 소켓의 autotuning 상태 확인
ss -tmi dst 10.0.0.1
# skmem:(r4096,rb1048576,...,f262144,...)
# rb = sk_rcvbuf (autotuning 결과 값)
# f = sk_forward_alloc (잔여 예산)
# 고대역폭 장거리 네트워크 (10Gbps, 50ms RTT)에서 최적 설정
# BDP = 10Gbps × 50ms = 62.5MB → tcp_rmem max를 이 이상으로
sysctl -w net.ipv4.tcp_rmem="4096 131072 67108864" # max 64MB
sysctl -w net.ipv4.tcp_wmem="4096 16384 67108864" # max 64MB
SO_RCVBUF 함정: 애플리케이션이 setsockopt(SO_RCVBUF, ...)을 호출하면 SOCK_RCVBUF_LOCK 플래그가 설정되어 autotuning이 영구 비활성화됩니다. 많은 성능 가이드가 SO_RCVBUF를 크게 설정하라고 권장하지만, 실제로는 커널 autotuning을 방해하여 오히려 성능이 저하될 수 있습니다. tcp_rmem의 max 값을 높이고 SO_RCVBUF는 설정하지 않는 것이 일반적으로 최선입니다.
sk_clone_lock — TCP accept 시 소켓 복제(Clone)
TCP 서버가 accept()를 호출하면, 리스닝(listening) 소켓의 설정을 상속받은 새 소켓이 생성됩니다. 커널은 sk_clone_lock()으로 리스닝 소켓을 복제하여 연결별 소켓을 만듭니다.
/* net/core/sock.c — sk_clone_lock */
struct sock *sk_clone_lock(const struct sock *sk, const gfp_t priority)
{
struct proto *prot = READ_ONCE(sk->sk_prot);
struct sock *newsk;
/* 프로토콜 slab에서 새 소켓 할당 */
newsk = sk_prot_alloc(prot, priority, sk->sk_family);
if (!newsk)
return NULL;
/* 원본 소켓 전체를 메모리 복사 (shallow copy) */
sock_copy(newsk, sk);
/* → sk_rcvbuf, sk_sndbuf, sk_priority, sk_mark 등 상속 */
/* 복제된 소켓의 고유 필드 초기화 */
refcount_set(&newsk->sk_refcnt, 2);
/* refcnt=2: 1(소켓 자체) + 1(caller 참조) */
/* 큐 초기화 (원본의 큐를 공유하면 안 됨) */
skb_queue_head_init(&newsk->sk_receive_queue);
skb_queue_head_init(&newsk->sk_write_queue);
skb_queue_head_init(&newsk->sk_error_queue);
/* 메모리 회계 초기화 */
atomic_set(&newsk->sk_rmem_alloc, 0);
refcount_set(&newsk->sk_wmem_alloc, 1);
/* wmem_alloc=1: "sentinel" — 0이 되기 전까지 해제 방지
* → sock_put() 호출 시 __sk_free 대신 대기 */
newsk->sk_forward_alloc = 0;
newsk->sk_backlog.head = newsk->sk_backlog.tail = NULL;
newsk->sk_backlog.len = 0;
/* BPF 필터 상속 */
if (sk->sk_filter) {
sk_filter_charge(newsk, sk->sk_filter);
/* BPF 프로그램 참조 카운트 증가 */
}
/* net namespace 참조 */
get_net(sock_net(newsk));
/* dst_cache (라우팅 캐시) 초기화 */
sk_dst_reset(newsk);
/* 잠금은 caller가 해제 */
bh_lock_sock(newsk);
return newsk;
}
/* TCP accept 경로 요약:
* inet_csk_accept()
* → reqsk_queue_remove() — SYN 큐에서 완성된 연결 가져옴
* → newsk = listen_sk→clone(listen_sk)
* → inet_csk_clone_lock()
* → sk_clone_lock()
* → inet_sk_set_state(newsk, TCP_SYN_RECV → TCP_ESTABLISHED)
* → 새 소켓의 fd 생성 → 사용자 공간 반환 */
/* TCP 3-way handshake에서 소켓 복제 시점 */
/*
* [Client] [Server listen_sk]
* SYN → → tcp_conn_request()
* → request_sock 할당 (경량)
* ← SYN+ACK ← tcp_v4_send_synack()
* ACK → → tcp_check_req()
* → tcp_v4_syn_recv_sock()
* → sk_clone_lock(listen_sk) ← 여기서 복제!
* → inet_csk_clone_lock()
* → tcp_create_openreq_child()
* → 새 소켓 완성
* → inet_csk_complete_hashdance()
* accept() → → inet_csk_accept()
* → 완성된 소켓 반환
*/
| 필드 | listen_sk 값 | newsk (복제 후) | 설명 |
|---|---|---|---|
sk_rcvbuf | 원본 값 | 상속 | SO_RCVBUF 설정 또는 autotuning 초기값 |
sk_sndbuf | 원본 값 | 상속 | SO_SNDBUF 설정 또는 autotuning 초기값 |
sk_priority | SO_PRIORITY | 상속 | skb→priority에 복사됨 |
sk_mark | SO_MARK | 상속 | skb→mark에 복사됨 (netfilter/tc) |
sk_filter | BPF 프로그램 | 참조 공유 | SO_ATTACH_FILTER/BPF → 동일 필터 적용 |
sk_bound_dev_if | SO_BINDTODEVICE | 상속 | 특정 인터페이스 바인딩 |
sk_receive_queue | 리스닝 큐 | 빈 큐 (초기화) | 연결별 독립 수신 큐 |
sk_rmem_alloc | 원본 값 | 0 (초기화) | 새 연결은 메모리 사용량 0에서 시작 |
sk_wmem_alloc | 원본 값 | 1 (sentinel) | sentinel 값으로 조기 해제 방지 |
sk_forward_alloc | 원본 값 | 0 (초기화) | 새 연결은 예산 없이 시작 |
sk_refcnt | 원본 값 | 2 | 소켓 + caller 참조 |
wmem_alloc=1 sentinel: 복제된 소켓의 sk_wmem_alloc이 1로 설정되는 이유는, 전송 skb의 destructor가 sock_wfree()에서 refcount_sub_and_test(truesize, &sk_wmem_alloc)을 수행할 때 0에 도달하는 것을 방지하기 위함입니다. 실제 해제는 sk_free()에서 refcount_sub(1, &sk_wmem_alloc)으로 sentinel을 제거한 후 진행됩니다.
소켓 BPF 필터와 skb
사용자 공간에서 SO_ATTACH_FILTER 또는 SO_ATTACH_BPF로 부착한 BPF 프로그램은 skb가 소켓 수신 큐에 들어가기 전에 실행됩니다. 이 필터는 sk->sk_filter에 저장되며, sk_filter_trim_cap()에서 호출됩니다:
/* include/linux/filter.h — sk_filter 구조 */
struct sk_filter {
refcount_t refcnt; /* 참조 카운트 (clone 시 공유) */
struct rcu_head rcu;
struct bpf_prog *prog; /* JIT 컴파일된 BPF 프로그램 */
};
/* net/core/filter.c — sk_filter_trim_cap */
int sk_filter_trim_cap(struct sock *sk, struct sk_buff *skb,
unsigned int cap)
{
struct sk_filter *filter;
int err;
/* security_sock_rcv_skb: LSM (SELinux 등) 검사 */
err = security_sock_rcv_skb(sk, skb);
if (err)
return err;
/* BPF 필터 실행 */
filter = rcu_dereference(sk->sk_filter);
if (filter) {
struct sock *save_sk = skb->sk;
unsigned int pkt_len;
skb->sk = sk; /* BPF가 소켓 정보 접근할 수 있게 */
pkt_len = bpf_prog_run_save_cb(filter->prog, skb);
skb->sk = save_sk;
/* pkt_len=0 → 패킷 드롭
* pkt_len>0 → skb->len을 pkt_len으로 트림(trim) */
err = pkt_len ? pskb_trim(skb, max(cap, pkt_len)) : -EPERM;
}
return err;
}
/* 호출 위치 예시 */
/* TCP: tcp_v4_do_rcv() → tcp_filter() → sk_filter_trim_cap()
* UDP: __udp_queue_rcv_skb() → sk_filter_trim_cap()
* Raw: raw_rcv() → raw_rcv_skb() → sk_filter_trim_cap() */
/* 사용자 공간에서 필터 부착 */
struct sock_fprog bpf = {
.len = ARRAY_SIZE(code),
.filter = code,
};
setsockopt(fd, SOL_SOCKET, SO_ATTACH_FILTER, &bpf, sizeof(bpf));
/* → sock_attach_prog() → sk->sk_filter 설정
* → 이후 모든 수신 skb가 이 필터를 통과해야 소켓 큐 진입 */
소켓 옵션(setsockopt)과 sk_buff
사용자 공간(User Space)의 setsockopt() 호출은 struct sock 필드를 변경하고, 이것이 skb 생성·처리에 직접 반영됩니다:
| 소켓 옵션 | 레벨 | sock/skb 영향 |
|---|---|---|
SO_RCVBUF | SOL_SOCKET | sk->sk_rcvbuf 설정 → 수신 큐 skb 총량 제한 |
SO_SNDBUF | SOL_SOCKET | sk->sk_sndbuf 설정 → 전송 큐 skb 총량 제한 |
SO_MARK | SOL_SOCKET | sk->sk_mark → skb->mark로 복사 (netfilter/tc/라우팅) |
SO_PRIORITY | SOL_SOCKET | sk->sk_priority → skb->priority로 복사 (QoS) |
SO_BINDTODEVICE | SOL_SOCKET | sk->sk_bound_dev_if → skb의 dev 제한 |
SO_TIMESTAMP | SOL_SOCKET | 수신 skb에 타임스탬프 기록, recvmsg() cmsg로 전달 |
SO_BUSY_POLL | SOL_SOCKET | sk->sk_napi_id + skb->napi_id로 busy polling |
IP_TOS | SOL_IP | inet->tos → 전송 skb IP 헤더 TOS 필드 |
IP_TTL | SOL_IP | inet->uc_ttl → 전송 skb IP 헤더 TTL 필드 |
IP_HDRINCL | SOL_IP | raw socket: 사용자가 IP 헤더를 직접 제공 |
TCP_NODELAY | SOL_TCP | Nagle 알고리즘 해제 → 소량 데이터 즉시 skb 전송 |
TCP_CORK | SOL_TCP | skb 전송 지연 (cork), uncork 시 한번에 전송 |
UDP_CORK | SOL_UDP | 여러 sendmsg를 하나의 skb로 합쳐 전송 |
UDP_GRO | SOL_UDP | 수신 UDP GRO 활성화 → 여러 패킷이 하나의 큰 skb로 |
/* 전송 경로에서 sock 옵션 → skb 필드 복사 과정 */
static void ip_copy_addrs(struct iphdr *iph, const struct flowi4 *fl4)
{
/* flowi4는 routing lookup 입력: sock의 IP/포트에서 구성 */
iph->saddr = fl4->saddr;
iph->daddr = fl4->daddr;
}
/* ip_queue_xmit: TCP 전송 시 sock 옵션 적용 */
int __ip_queue_xmit(struct sock *sk, struct sk_buff *skb, ...)
{
struct inet_sock *inet = inet_sk(sk);
/* SK 옵션 → skb 필드 전파 */
skb->priority = sk->sk_priority; /* SO_PRIORITY */
skb->mark = sk->sk_mark; /* SO_MARK */
/* IP 헤더 필드: inet_sock에서 가져옴 */
iph->tos = inet->tos; /* IP_TOS */
iph->ttl = ip_select_ttl(inet, ...); /* IP_TTL 또는 기본값 */
/* ... */
}
/* SO_RCVBUF 설정과 수신 큐 제한의 관계 */
/* 사용자 공간 */
int bufsize = 262144; /* 256KB */
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize));
/* 커널: sk->sk_rcvbuf = min(bufsize * 2, sysctl_rmem_max)
* → 실제 커널 값은 요청값의 2배 (overhead 고려)
*
* 수신 시: atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf 이면
* → 새 패킷 드롭 (ENOMEM) */
SO_RCVBUF/SO_SNDBUF에 설정한 값은 커널 내에서 2배로 증폭됩니다 (sock_setsockopt() 내부). 이는 skb 구조체와 메타데이터 오버헤드(Overhead)를 고려한 것입니다. getsockopt()으로 읽으면 2배된 값이 반환됩니다. 시스템 전역 상한은 /proc/sys/net/core/rmem_max, wmem_max입니다.
Raw Socket과 sk_buff
Raw socket(SOCK_RAW)은 프로토콜 스택의 일부를 우회하여 직접 패킷을 구성하거나 수신합니다. 일반 SOCK_STREAM/SOCK_DGRAM과 달리 커널의 L4 프로토콜 처리를 거치지 않고 skb를 직접 다루므로, 네트워크 도구(ping, traceroute, tcpdump, nmap 등)와 프로토콜 구현의 핵심입니다.
Raw Socket 타입 비교
| 타입 | 생성 | 접근 계층 | skb 관계 |
|---|---|---|---|
| IP raw socket | socket(AF_INET, SOCK_RAW, IPPROTO_XXX) |
L3 (IP) | IP_HDRINCL 없으면 커널이 IP 헤더 생성. 수신 시 IP 헤더 포함 |
| IP raw + IP_HDRINCL | setsockopt(IP_HDRINCL, 1) |
L3 (IP) | 사용자가 IP 헤더까지 직접 작성. skb->data가 IP 헤더부터 시작 |
| Packet socket (L2 raw) | socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)) |
L2 (Ethernet) | Ethernet 헤더 포함 전체 프레임 접근. skb를 MAC 헤더부터 수신/전송 |
| Packet socket (L2 cooked) | socket(AF_PACKET, SOCK_DGRAM, htons(ETH_P_ALL)) |
L3 (IP) | L2 헤더 제거 후 전달. 송신 시 커널이 L2 헤더 생성 |
| AF_PACKET + TPACKET | setsockopt(PACKET_VERSION, TPACKET_V3) |
L2 (Ethernet) | mmap 기반 ring buffer → skb 없이 직접 DMA 버퍼 접근 (고성능) |
| Ping socket | socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP) |
L4 (ICMP) | CAP_NET_RAW 불필요. 커널이 ICMP 헤더 id/checksum 관리 |
Raw Socket 권한 모델
Raw socket 생성에는 CAP_NET_RAW capability가 필요합니다. 커널은 sock_create() → inet_create() 경로에서 capability를 검사합니다:
/* net/ipv4/af_inet.c — inet_create() */
static int inet_create(struct net *net, struct socket *sock,
int protocol, int kern)
{
struct inet_protosw *answer;
struct sock *sk;
/* SOCK_RAW 사용 시 CAP_NET_RAW 검사 */
if (sock->type == SOCK_RAW && !kern &&
!ns_capable(net->user_ns, CAP_NET_RAW))
return -EPERM;
/* protocol 번호로 inetsw[] 해시 테이블에서 프로토콜 핸들러 검색 */
answer = inet_protosw_lookup(sock->type, protocol);
/* SOCK_RAW → raw_prot (net/ipv4/raw.c)
* SOCK_STREAM → tcp_prot
* SOCK_DGRAM → udp_prot */
sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer->prot, kern);
/* ... */
}
/* net/packet/af_packet.c — AF_PACKET도 CAP_NET_RAW 필요 */
static int packet_create(struct net *net, struct socket *sock,
int protocol, int kern)
{
if (!kern && !ns_capable(net->user_ns, CAP_NET_RAW))
return -EPERM;
/* ... */
}
Ping socket 예외: Linux 3.0+에서 도입된 ping socket(socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP))은 CAP_NET_RAW 없이도 ICMP Echo Request를 보낼 수 있습니다. /proc/sys/net/ipv4/ping_group_range로 허용 GID 범위를 설정합니다. setcap cap_net_raw+ep /usr/bin/ping 대신 이 메커니즘을 사용합니다.
커널 내부 자료구조 — struct raw_sock
/* include/net/raw.h */
struct raw_sock {
struct inet_sock inet; /* inet_sock 상속 (→ sock → sock_common) */
struct icmp_filter filter; /* ICMP 타입별 필터 비트맵 */
u32 ipmr_table; /* 멀티캐스트 라우팅 테이블 ID */
};
/* raw socket 프로토콜 해시 테이블
* protocol 번호로 해싱하여 수신 시 O(1) 조회 */
struct raw_hashinfo {
spinlock_t lock;
struct hlist_head ht[RAW_HTABLE_SIZE]; /* 256 버킷 */
};
/* 전역 raw 해시 테이블 — 모든 AF_INET SOCK_RAW 소켓 관리 */
struct raw_hashinfo raw_v4_hashinfo; /* IPv4 */
struct raw_hashinfo raw_v6_hashinfo; /* IPv6 */
/* 해시 함수: protocol 번호를 버킷 인덱스로 변환 */
static inline u32 raw_hashfunc(const struct net *net, u32 proto)
{
return proto & (RAW_HTABLE_SIZE - 1); /* 0~255 */
}
/* raw socket의 프로토콜 연산 테이블 */
struct proto raw_prot = {
.name = "RAW",
.owner = THIS_MODULE,
.close = raw_close,
.connect = ip4_datagram_connect,
.sendmsg = raw_sendmsg,
.recvmsg = raw_recvmsg,
.bind = raw_bind,
.hash = raw_hash_sk,
.unhash = raw_unhash_sk,
.obj_size = sizeof(struct raw_sock),
};
AF_INET SOCK_RAW 수신 경로
IP 계층에서 패킷이 로컬로 배달될 때, TCP/UDP 디먹싱 이전에 raw socket으로의 복제가 먼저 수행됩니다. 즉, raw socket은 패킷의 사본을 받으며, 원본 skb는 정상 프로토콜 스택으로 계속 진행합니다:
/* net/ipv4/ip_input.c — ip_local_deliver_finish()
* 패킷이 로컬 배달될 때 raw socket에 먼저 전달 */
static int ip_local_deliver_finish(struct net *net, struct sock *sk,
struct sk_buff *skb)
{
__skb_pull(skb, skb_network_header_len(skb));
int protocol = ip_hdr(skb)->protocol;
const struct net_protocol *ipprot;
/* ① raw socket이 있으면 먼저 skb 사본 전달 */
raw_local_deliver(skb, protocol);
/* ② 등록된 프로토콜 핸들러 호출 (tcp_v4_rcv, udp_rcv 등) */
ipprot = rcu_dereference(inet_protos[protocol]);
if (ipprot) {
ret = INDIRECT_CALL_2(ipprot->handler, tcp_v4_rcv, udp_rcv,
skb);
}
}
/* net/ipv4/raw.c — raw_local_deliver()
* 해당 프로토콜의 raw socket들을 해시 테이블에서 찾아 전달 */
int raw_local_deliver(struct sk_buff *skb, int protocol)
{
struct raw_hashinfo *h = &raw_v4_hashinfo;
struct hlist_head *head;
int hash;
hash = raw_hashfunc(dev_net(skb_dst(skb)->dev), protocol);
head = &h->ht[hash];
if (!hlist_empty(head)) {
/* 매칭되는 모든 raw socket에 skb 복제본 전달 */
raw_v4_input(skb, ip_hdr(skb), hash);
}
return 0;
}
/* net/ipv4/raw.c — raw_v4_input()
* 프로토콜 번호와 목적지 주소가 매칭되는 모든 raw socket에 전달 */
static int raw_v4_input(struct sk_buff *skb, const struct iphdr *iph,
int hash)
{
struct sock *sk;
struct hlist_head *head = &raw_v4_hashinfo.ht[hash];
int delivered = 0;
rcu_read_lock();
sk_for_each_rcu(sk, head) {
/* 프로토콜 번호, 목적지 IP, 소스 IP, 네트워크 네임스페이스 매칭 */
if (raw_v4_match(net, sk, iph->protocol,
iph->saddr, iph->daddr,
skb->dev->ifindex, sdif)) {
/* skb를 clone하여 해당 raw socket에 전달 */
raw_rcv(sk, skb);
delivered++;
}
}
rcu_read_unlock();
return delivered;
}
/* net/ipv4/raw.c — raw_rcv()
* skb clone → IP 헤더 포함한 상태로 수신 큐에 추가 */
int raw_rcv(struct sock *sk, struct sk_buff *skb)
{
struct raw_sock *rp = raw_sk(sk);
/* ICMP 필터 적용: 관심 없는 ICMP 타입은 드롭 */
if (sk->sk_protocol == IPPROTO_ICMP) {
struct icmphdr *icmph = icmp_hdr(skb);
if (raw_icmp_type_filtered(rp, icmph->type))
return 0; /* 필터에 의해 드롭 */
}
/* skb 복제: 원본은 프로토콜 스택이 계속 사용 */
struct sk_buff *clone = skb_clone(skb, GFP_ATOMIC);
if (!clone)
return 0;
/* 핵심: data 포인터를 network_header (IP 헤더) 위치로 복원
* ip_local_deliver_finish()에서 __skb_pull로 L4까지 당겼으므로
* raw socket은 IP 헤더부터 보여줘야 함 */
skb_push(clone, clone->data - skb_network_header(clone));
/* 수신 큐에 추가 → recvmsg()로 사용자에게 전달 */
if (sock_queue_rcv_skb(sk, clone) < 0)
kfree_skb(clone);
return 0;
}
핵심 포인트: 동일 프로토콜 번호를 사용하는 여러 raw socket이 열려 있으면, 하나의 수신 패킷이 모든 매칭 소켓에 clone되어 전달됩니다. 예: 두 프로세스가 각각 IPPROTO_ICMP raw socket을 열면, ICMP 패킷 수신 시 두 프로세스 모두 사본을 받습니다. 이는 raw_v4_input()의 sk_for_each_rcu() 루프가 해시(Hash) 버킷의 모든 소켓을 순회하기 때문입니다.
AF_INET SOCK_RAW 전송 경로
Raw socket의 전송 경로는 IP_HDRINCL 옵션에 따라 두 가지로 분기됩니다:
/* net/ipv4/raw.c — raw_sendmsg()
* 사용자 공간의 sendto()/sendmsg() → raw_sendmsg() */
static int raw_sendmsg(struct sock *sk, struct msghdr *msg, size_t len)
{
struct inet_sock *inet = inet_sk(sk);
struct flowi4 fl4;
struct rtable *rt;
int err;
/* 목적지 주소 결정 */
struct sockaddr_in *usin = (struct sockaddr_in *)msg->msg_name;
__be32 daddr;
if (usin) {
daddr = usin->sin_addr.s_addr;
} else {
/* connect()로 미리 바인딩된 주소 사용 */
daddr = inet->inet_daddr;
if (!daddr)
return -EDESTADDRREQ;
}
/* 라우팅 테이블 조회 */
flowi4_init_output(&fl4, ...);
rt = ip_route_output_flow(net, &fl4, sk);
if (inet->hdrincl) {
/* ── IP_HDRINCL 모드 ──
* 사용자가 IP 헤더를 직접 작성
* 커널은 최소한의 필드만 보정 */
err = raw_send_hdrinc(sk, &fl4, msg, len, &rt, msg->msg_flags);
} else {
/* ── 일반 raw 모드 ──
* 커널이 IP 헤더를 자동 생성
* 사용자 데이터는 L4 페이로드로 취급 */
err = ip_append_data(sk, &fl4, raw_getfrag,
msg, len, 0, &ipc, &rt, msg->msg_flags);
if (!err) {
err = ip_push_pending_frames(sk, &fl4);
/* → ip_output() → dev_queue_xmit() */
}
}
ip_rt_put(rt);
return err;
}
IP_HDRINCL 상세 — 커널의 보정 동작
IP_HDRINCL을 설정하면 사용자가 IP 헤더를 직접 작성하지만, 커널은 안전성과 정확성을 위해 일부 필드를 자동 보정합니다:
/* net/ipv4/raw.c — raw_send_hdrinc()
* IP_HDRINCL 모드의 실제 전송 처리 */
static int raw_send_hdrinc(struct sock *sk, struct flowi4 *fl4,
struct msghdr *msg, unsigned int len,
struct rtable **rtp, unsigned int flags)
{
struct iphdr *iph;
struct sk_buff *skb;
unsigned int iphlen;
/* skb 할당: IP 헤더 + 페이로드 크기 */
skb = sock_alloc_send_skb(sk,
len + LL_ALLOCATED_SPACE(rt->dst.dev), /* L2 headroom 확보 */
flags & MSG_DONTWAIT, &err);
if (!skb)
return err;
/* L2 헤더 공간 예약 */
skb_reserve(skb, LL_RESERVED_SPACE(rt->dst.dev));
skb->protocol = htons(ETH_P_IP);
/* 사용자 데이터를 skb에 복사 (IP 헤더 포함) */
skb_put(skb, len);
skb->network_header = skb->data;
skb_copy_datagram_from_iter(skb, 0, &msg->msg_iter, len);
iph = ip_hdr(skb);
/* ── 커널이 자동 보정하는 필드 ── */
/* (1) tot_len: 0이면 커널이 skb->len으로 설정 */
if (!iph->tot_len)
iph->tot_len = htons(len);
/* (2) saddr: 0이면 라우팅 결과의 소스 IP로 채움 */
if (!iph->saddr)
iph->saddr = fl4->saddr;
/* (3) id: 0이면 커널이 고유 ID 할당 */
if (!iph->id)
ip_select_ident(net, skb, NULL);
/* (4) check: 항상 커널이 재계산 (사용자 값 무시) */
iph->check = 0;
iph->check = ip_fast_csum((unsigned char *)iph, iph->ihl);
/* Netfilter OUTPUT 체인 통과 후 전송 */
err = NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_OUT,
net, sk, skb, NULL, rt->dst.dev,
dst_output);
return err;
}
| IP 헤더 필드 | 사용자 제공 시 | 0 또는 미설정 시 |
|---|---|---|
version | 사용자 값 사용 | 사용자가 반드시 4로 설정해야 함 |
ihl | 사용자 값 사용 | 사용자가 설정 (보통 5) |
tos | 사용자 값 사용 | 0 (기본 서비스) |
tot_len | 사용자 값 사용 | 커널이 skb->len으로 설정 |
id | 사용자 값 사용 | 커널이 ip_select_ident()로 할당 |
frag_off | 사용자 값 사용 | 0 (단편화(Fragmentation) 없음) |
ttl | 사용자 값 사용 | 사용자가 반드시 설정해야 함 |
protocol | 사용자 값 사용 | 사용자가 반드시 설정해야 함 |
saddr | 사용자 값 사용 (스푸핑 가능) | 커널이 라우팅 테이블(Routing Table)에서 결정 |
daddr | 사용자 값 사용 | 사용자가 반드시 설정해야 함 |
check | 무시 — 커널이 항상 재계산 | 커널이 ip_fast_csum()으로 계산 |
IP_HDRINCL과 IP Spoofing: IP_HDRINCL을 사용하면 saddr(소스 IP)를 임의로 설정할 수 있어 IP 스푸핑이 가능합니다. 이것이 CAP_NET_RAW가 필요한 주요 보안 이유 중 하나입니다. 네트워크 장비의 BCP 38 (uRPF) 필터링이나 커널의 rp_filter 설정으로 발신 스푸핑 패킷을 차단할 수 있습니다.
raw_recvmsg() — 사용자 공간으로 전달
/* net/ipv4/raw.c — raw_recvmsg()
* 사용자의 recvfrom()/recvmsg() 처리 */
static int raw_recvmsg(struct sock *sk, struct msghdr *msg,
size_t len, int flags, int *addr_len)
{
struct sk_buff *skb;
struct sockaddr_in *sin = (struct sockaddr_in *)msg->msg_name;
int err, copied;
/* 수신 큐에서 skb 꺼내기 (대기 가능) */
skb = skb_recv_datagram(sk, flags, &err);
if (!skb)
return err;
/* skb에서 사용자 버퍼로 데이터 복사
* → IP 헤더부터 전체 패킷이 사용자에게 전달됨 */
copied = skb->len;
if (len < copied) {
msg->msg_flags |= MSG_TRUNC; /* 잘림 알림 */
copied = len;
}
skb_copy_datagram_msg(skb, 0, msg, copied);
/* 소스 주소 정보 채우기 */
if (sin) {
sin->sin_family = AF_INET;
sin->sin_addr.s_addr = ip_hdr(skb)->saddr;
sin->sin_port = 0; /* raw socket은 포트 개념 없음 */
}
/* IP_PKTINFO, IP_TTL 등 ancillary data (cmsg) 전달 */
if (inet_cmsg_flags(inet))
ip_cmsg_recv(msg, skb);
skb_free_datagram(sk, skb);
return copied;
}
ICMP 필터 (ICMP_FILTER)
IPPROTO_ICMP raw socket에서 관심 있는 ICMP 타입만 수신하도록 비트맵(Bitmap) 필터를 설정할 수 있습니다:
/* include/uapi/linux/icmp.h */
struct icmp_filter {
__u32 data; /* 비트맵: bit N이 1이면 ICMP type N을 필터링(드롭) */
};
/* 사용자 공간 예: Echo Reply(type 0)만 수신, 나머지 필터링 */
struct icmp_filter filt;
filt.data = ~(1 << ICMP_ECHOREPLY); /* type 0만 통과 */
setsockopt(fd, SOL_RAW, ICMP_FILTER, &filt, sizeof(filt));
/* 커널 내부: raw_rcv()에서 필터 검사 */
static inline bool raw_icmp_type_filtered(const struct raw_sock *rp,
u8 type)
{
/* type에 해당하는 비트가 1이면 필터링(드롭) */
return (rp->filter.data >> type) & 1;
}
IPv6 Raw Socket (AF_INET6 SOCK_RAW)
IPv6 raw socket은 IPv4와 유사하지만 중요한 차이점이 있습니다:
| 특성 | IPv4 (AF_INET) | IPv6 (AF_INET6) |
|---|---|---|
| IP 헤더 접근 | IP_HDRINCL로 IP 헤더 포함 가능 |
IPv6 헤더는 항상 커널이 생성 (IPV6_HDRINCL 미지원) |
| 확장 헤더 | IP 옵션을 IP_OPTIONS로 설정 |
IPV6_RTHDR, IPV6_HOPOPTS 등 ancillary data(cmsg)로 설정 |
| ICMPv6 체크섬 | 사용자가 직접 계산 | 커널이 자동 계산 (RFC 3542 요구사항) |
| 체크섬 오프셋 | 해당 없음 | IPV6_CHECKSUM — 페이로드 내 체크섬 위치 지정, 커널이 계산 |
| 필터 | ICMP_FILTER |
ICMPV6_FILTER — 256비트 비트맵 (struct icmp6_filter) |
| 커널 소스 | net/ipv4/raw.c |
net/ipv6/raw.c |
/* IPv6 raw socket에서 ICMPv6 체크섬은 커널이 자동 계산 */
/* net/ipv6/raw.c — rawv6_send_hdrinc() 내부 */
/* IPV6_CHECKSUM 소켓 옵션: 체크섬 계산 위치 지정 */
int offset = 2; /* 페이로드 시작부터 체크섬 필드의 바이트 오프셋 */
setsockopt(fd, IPPROTO_IPV6, IPV6_CHECKSUM, &offset, sizeof(offset));
/* 커널이 IPv6 pseudo-header 포함 체크섬을 해당 오프셋에 기록 */
/* ICMPv6 raw socket (protocol = IPPROTO_ICMPV6)은
* IPV6_CHECKSUM이 자동으로 offset=2에 설정됨
* → ICMPv6 체크섬 필드 위치가 헤더 시작+2바이트 */
/* ICMPv6 필터 예: Neighbor Solicitation만 수신 */
struct icmp6_filter filt;
ICMP6_FILTER_SETBLOCKALL(&filt);
ICMP6_FILTER_SETPASS(ND_NEIGHBOR_SOLICIT, &filt);
setsockopt(fd, IPPROTO_ICMPV6, ICMPV6_FILTER, &filt, sizeof(filt));
AF_PACKET — L2 프레임 접근
AF_PACKET 소켓은 Ethernet 프레임 수준에서 패킷을 캡처/전송합니다. tcpdump, wireshark, dhclient, arping 등이 사용합니다.
| 소켓 타입 | 수신 시 포함 헤더 | 전송 시 필요 헤더 | 사용 사례 |
|---|---|---|---|
AF_PACKET, SOCK_RAW |
Ethernet + IP + L4 + 페이로드 | 사용자가 Ethernet 헤더 포함 전체 작성 | tcpdump, 패킷 injection |
AF_PACKET, SOCK_DGRAM |
IP + L4 + 페이로드 (Ethernet 제거) | 커널이 Ethernet 헤더 생성 | dhclient, 프로토콜 분석 |
/* net/packet/af_packet.c — packet_type 등록
* AF_PACKET 소켓 생성 시 packet_type을 등록하여
* NIC 드라이버의 수신 경로에 후킹 */
static int packet_create(struct net *net, struct socket *sock,
int protocol, int kern)
{
struct packet_sock *po;
struct sock *sk;
sk = sk_alloc(net, PF_PACKET, GFP_KERNEL, &packet_proto, kern);
po = pkt_sk(sk);
/* packet_type 구조체 설정 */
po->prot_hook.func = packet_rcv; /* 수신 콜백 */
po->prot_hook.af_packet_priv = sk; /* 소켓 포인터 */
if (protocol) {
po->prot_hook.type = protocol; /* ETH_P_ALL, ETH_P_IP 등 */
__register_prot_hook(sk);
/* → dev_add_pack() → ptype_all 또는 ptype_base[] 리스트에 등록
* → netif_receive_skb() 경로에서 모든 수신 패킷에 대해 콜백 */
}
}
/* 수신 콜백: netif_receive_skb() → deliver_skb() → packet_rcv() */
static int packet_rcv(struct sk_buff *skb, struct net_device *dev,
struct packet_type *pt, struct net_device *orig_dev)
{
struct sock *sk = pt->af_packet_priv;
struct sk_buff *copy;
unsigned int snaplen, res;
/* BPF 필터 적용 (setsockopt SO_ATTACH_FILTER) */
res = run_filter(skb, sk, snaplen);
if (!res)
goto drop; /* BPF 필터에 의해 드롭 */
/* ETH_P_ALL인 경우 모든 패킷에 대해 호출됨 */
copy = skb_clone(skb, GFP_ATOMIC);
if (!copy)
goto drop;
/* SOCK_RAW: MAC 헤더부터 전체 프레임 노출 */
if (sk->sk_type == SOCK_RAW)
skb_push(copy, skb_mac_header_len(skb));
/* sockaddr_ll에 수신 메타데이터 기록 */
struct sockaddr_ll *sll = &PACKET_SKB_CB(copy)->sa.ll;
sll->sll_ifindex = orig_dev->ifindex;
sll->sll_hatype = dev->type;
sll->sll_pkttype = skb->pkt_type; /* PACKET_HOST, PACKET_BROADCAST 등 */
sock_queue_rcv_skb(sk, copy);
return 0;
drop:
kfree_skb(skb);
return 0;
}
/* AF_PACKET 전송: 사용자 → dev_queue_xmit() 직접 전달 */
static int packet_sendmsg(struct socket *sock, struct msghdr *msg,
size_t len)
{
/* SOCK_RAW: 사용자가 Ethernet 헤더 포함 전체 프레임 작성 */
/* SOCK_DGRAM: sockaddr_ll에서 목적지 MAC, 커널이 Ethernet 헤더 생성 */
struct sk_buff *skb = packet_alloc_skb(sk, ...);
/* 사용자 데이터 복사 */
skb_copy_datagram_from_iter(skb, 0, &msg->msg_iter, len);
/* IP 스택을 완전히 우회하여 직접 디바이스 큐로 전송 */
err = dev_queue_xmit(skb);
/* → qdisc → NIC 드라이버 → 물리 전송 */
}
TPACKET — mmap 기반 고성능 캡처
TPACKET(PACKET_MMAP)은 커널-사용자 간 mmap된 공유 ring buffer를 사용하여 recvmsg()/sendmsg() 시스템콜 오버헤드 없이 패킷을 교환합니다. tcpdump, libpcap, suricata 등 고성능 캡처 도구의 핵심입니다.
| 버전 | 커널 | 특징 | 제한/이슈 |
|---|---|---|---|
TPACKET_V1 |
2.4+ | 기본 ring buffer, 고정 크기 프레임 | 32비트 타임스탬프, 큰 패킷 지원 불가 |
TPACKET_V2 |
2.6.27+ | VLAN 태그 보존, 64비트 타임스탬프 | 여전히 고정 크기 프레임 |
TPACKET_V3 |
3.2+ | 가변 크기 블록, 타임아웃 기반 블록 해제, 배치 처리 | TX ring 미지원 (V2 사용), 구현 복잡 |
/* TPACKET_V3 ring buffer 설정 예 (사용자 공간) */
struct tpacket_req3 req = {
.tp_block_size = 1 << 22, /* 4MB 블록 */
.tp_block_nr = 64, /* 64개 블록 = 256MB */
.tp_frame_size = TPACKET_ALIGNMENT << 7, /* 프레임 정렬 */
.tp_frame_nr = (1 << 22) * 64 / (TPACKET_ALIGNMENT << 7),
.tp_retire_blk_tov = 60, /* 블록 타임아웃 60ms */
.tp_feature_req_word = TP_FT_REQ_FILL_RXHASH,
};
/* TPACKET 버전 설정 */
int ver = TPACKET_V3;
setsockopt(fd, SOL_PACKET, PACKET_VERSION, &ver, sizeof(ver));
/* RX ring buffer 설정 */
setsockopt(fd, SOL_PACKET, PACKET_RX_RING, &req, sizeof(req));
/* 커널-사용자 공유 메모리 매핑 */
void *ring = mmap(NULL, req.tp_block_size * req.tp_block_nr,
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_LOCKED,
fd, 0);
/* 패킷 수신 루프 (V3 블록 기반) */
while (1) {
struct tpacket_block_desc *pbd = block_descs[current_block];
/* 블록이 준비될 때까지 대기 */
while (!(pbd->hdr.bh1.block_status & TP_STATUS_USER))
poll(&pfd, 1, -1);
/* 블록 내 모든 패킷 순회 */
int num_pkts = pbd->hdr.bh1.num_pkts;
struct tpacket3_hdr *ppd = (struct tpacket3_hdr *)
((uint8_t *)pbd + pbd->hdr.bh1.offset_to_first_pkt);
for (int i = 0; i < num_pkts; i++) {
uint8_t *pkt_data = (uint8_t *)ppd + ppd->tp_mac;
uint32_t pkt_len = ppd->tp_snaplen;
process_packet(pkt_data, pkt_len); /* 패킷 처리 */
ppd = (struct tpacket3_hdr *)
((uint8_t *)ppd + ppd->tp_next_offset);
}
/* 블록을 커널에 반환 */
pbd->hdr.bh1.block_status = TP_STATUS_KERNEL;
current_block = (current_block + 1) % req.tp_block_nr;
}
/* 커널 내부: TPACKET_V3 수신 처리
* net/packet/af_packet.c — tpacket_rcv() */
static int tpacket_rcv(struct sk_buff *skb, struct net_device *dev,
struct packet_type *pt,
struct net_device *orig_dev)
{
/* V3: 현재 블록에 패킷 추가 (가변 크기)
* → skb 데이터를 mmap 버퍼에 직접 복사
* → 블록이 가득 차거나 타임아웃 시 TP_STATUS_USER로 전환
* → 사용자는 poll()로 알림 받고 mmap 메모리에서 직접 읽음
* → recvmsg() 시스콜 불필요 = zero-copy 수신 */
/* BPF 필터 먼저 실행 */
res = run_filter(skb, sk, snaplen);
if (!res)
goto drop;
/* ring buffer의 현재 블록에 패킷 데이터 복사 */
h.raw = packet_current_rx_frame(po, skb, ...);
skb_copy_bits(skb, 0, h.raw + macoff, snaplen);
/* 패킷 메타데이터 기록: 타임스탬프, 길이, VLAN 등 */
h.h3->tp_sec = ts.tv_sec;
h.h3->tp_nsec = ts.tv_nsec;
h.h3->tp_snaplen = snaplen;
h.h3->tp_len = skb->len;
}
PACKET_FANOUT — 멀티코어 패킷 분산
여러 AF_PACKET 소켓이 동일 인터페이스에서 패킷을 분산 처리할 수 있습니다. suricata, PF_RING 대안으로 사용됩니다:
/* PACKET_FANOUT 모드 */
#define PACKET_FANOUT_HASH 0 /* 흐름 해시 기반 분배 (기본) */
#define PACKET_FANOUT_LB 1 /* 라운드 로빈 */
#define PACKET_FANOUT_CPU 2 /* CPU ID 기반 (RSS 활용) */
#define PACKET_FANOUT_ROLLOVER 3 /* 큐 가득 차면 다음 소켓으로 */
#define PACKET_FANOUT_RND 4 /* 랜덤 분배 */
#define PACKET_FANOUT_QM 5 /* skb 큐 매핑 기반 */
#define PACKET_FANOUT_CBPF 6 /* cBPF 프로그램으로 분배 결정 */
#define PACKET_FANOUT_EBPF 7 /* eBPF 프로그램으로 분배 결정 */
/* 사용 예: 4개 워커 스레드가 흐름 해시 기반으로 패킷 분산 */
int fanout_arg = (PACKET_FANOUT_HASH | PACKET_FANOUT_FLAG_DEFRAG)
| (group_id << 16);
setsockopt(fd, SOL_PACKET, PACKET_FANOUT, &fanout_arg,
sizeof(fanout_arg));
/* 커널 내부: fanout_demux() — 소켓 선택 */
static struct sock *fanout_demux_hash(
struct packet_fanout *f, struct sk_buff *skb, unsigned int num)
{
/* skb 흐름 해시를 소켓 수로 나눠 분배 */
return f->arr[reciprocal_scale(
__skb_get_hash_symmetric(skb), num)];
}
/* PACKET_FANOUT_FLAG 옵션 */
#define PACKET_FANOUT_FLAG_ROLLOVER 0x1000 /* 소켓 백로그 시 롤오버 */
#define PACKET_FANOUT_FLAG_UNIQUEID 0x2000 /* 고유 그룹 ID 자동 할당 */
#define PACKET_FANOUT_FLAG_DEFRAG 0x8000 /* IP 단편화 재조합 후 분배 */
Raw Socket과 Netfilter 관계
Raw socket으로 전송하는 패킷도 Netfilter 체인을 통과합니다. 수신은 프로토콜 핸들러(Handler) 이전(raw_local_deliver)에 처리되므로 INPUT 체인보다 먼저 clone이 발생합니다:
| 방향 | 소켓 타입 | Netfilter 통과 여부 | 설명 |
|---|---|---|---|
| TX | AF_INET SOCK_RAW | OUTPUT 체인 통과 | raw_send_hdrinc() → NF_HOOK(NF_INET_LOCAL_OUT) |
| TX | AF_PACKET SOCK_RAW | Netfilter 우회 | dev_queue_xmit() 직접 호출 (L3 스택 미통과) |
| RX | AF_INET SOCK_RAW | PREROUTING 이후, INPUT 이전 | NF_INET_PRE_ROUTING 통과 후 raw_local_deliver() |
| RX | AF_PACKET SOCK_RAW | Netfilter 이전에 수신 | netif_receive_skb()에서 ptype 콜백 (L3 이전) |
tcpdump가 DROP된 패킷도 보이는 이유: AF_PACKET 소켓은 netif_receive_skb()의 ptype_all 리스트에 등록되어 Netfilter 이전에 skb 사본을 받습니다. 따라서 iptables/nftables에서 DROP된 패킷도 tcpdump에서 관찰됩니다. 전송 방향도 마찬가지로, AF_PACKET TX는 dev_queue_xmit()을 직접 호출하여 Netfilter OUTPUT 체인을 우회합니다.
Raw Socket의 bind()와 connect()
/* AF_INET SOCK_RAW에서 bind()와 connect()의 역할 */
/* bind() — 수신 필터링: 특정 로컬 IP로의 패킷만 수신 */
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_addr.s_addr = inet_addr("192.168.1.10"),
};
bind(fd, (struct sockaddr *)&addr, sizeof(addr));
/* → raw_v4_match()에서 daddr 매칭에 사용
* → 해당 IP가 목적지인 패킷만 수신 큐에 전달 */
/* connect() — 기본 목적지 설정 + 수신 필터링 */
struct sockaddr_in dest = {
.sin_family = AF_INET,
.sin_addr.s_addr = inet_addr("10.0.0.1"),
};
connect(fd, (struct sockaddr *)&dest, sizeof(dest));
/* → send()에서 목적지 주소 생략 가능 (sendto 대신 send 사용)
* → 수신 시 해당 소스 IP에서 온 패킷만 수신 (소스 필터) */
/* AF_PACKET에서 bind() — 특정 인터페이스에 바인딩 */
struct sockaddr_ll sll = {
.sll_family = AF_PACKET,
.sll_protocol = htons(ETH_P_ALL),
.sll_ifindex = if_nametoindex("eth0"),
};
bind(fd, (struct sockaddr *)&sll, sizeof(sll));
/* → 해당 인터페이스의 패킷만 수신
* → 바인딩 없으면 모든 인터페이스의 패킷 수신 */
실용 예제
/* 예제 1: ICMP Echo Request 전송 (ping 구현) */
int fd = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
/* CAP_NET_RAW 필요 */
struct {
struct icmphdr hdr;
char data[56]; /* 페이로드 (타임스탬프 등) */
} pkt;
pkt.hdr.type = ICMP_ECHO;
pkt.hdr.code = 0;
pkt.hdr.un.echo.id = htons(getpid());
pkt.hdr.un.echo.sequence = htons(seq++);
pkt.hdr.checksum = 0;
pkt.hdr.checksum = icmp_checksum(&pkt, sizeof(pkt));
struct sockaddr_in dest = { .sin_family = AF_INET,
.sin_addr.s_addr = inet_addr("8.8.8.8") };
sendto(fd, &pkt, sizeof(pkt), 0,
(struct sockaddr *)&dest, sizeof(dest));
/* 커널이 IP 헤더를 자동 생성 (IP_HDRINCL 미설정이므로)
* → skb 할당 → ICMP 페이로드 복사 → IP 헤더 추가
* → raw_sendmsg() → ip_append_data() → ip_push_pending_frames()
* → Netfilter OUTPUT → ip_output() → dev_queue_xmit() */
/* 수신: raw socket은 모든 ICMP 패킷을 받으므로 필터 설정 */
struct icmp_filter filt;
filt.data = ~(1 << ICMP_ECHOREPLY);
setsockopt(fd, SOL_RAW, ICMP_FILTER, &filt, sizeof(filt));
char buf[1500];
struct sockaddr_in from;
socklen_t fromlen = sizeof(from);
int n = recvfrom(fd, buf, sizeof(buf), 0,
(struct sockaddr *)&from, &fromlen);
/* buf[0..19] = IP 헤더 (raw socket은 항상 IP 헤더 포함 수신)
* buf[20..] = ICMP 헤더 + 페이로드 */
/* 예제 2: ARP Request 전송 (AF_PACKET SOCK_RAW) */
int fd = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ARP));
/* 전체 Ethernet 프레임을 직접 구성 */
struct {
struct ethhdr eth; /* Ethernet 헤더 (14 bytes) */
struct arphdr arp; /* ARP 헤더 */
uint8_t ar_sha[6]; /* 송신자 MAC */
uint8_t ar_sip[4]; /* 송신자 IP */
uint8_t ar_tha[6]; /* 대상 MAC (ARP Request에서는 0) */
uint8_t ar_tip[4]; /* 대상 IP */
} frame;
/* Ethernet 헤더: 브로드캐스트 */
memset(frame.eth.h_dest, 0xff, ETH_ALEN); /* FF:FF:FF:FF:FF:FF */
memcpy(frame.eth.h_source, my_mac, ETH_ALEN);
frame.eth.h_proto = htons(ETH_P_ARP);
/* ARP 헤더: ARP Request */
frame.arp.ar_hrd = htons(ARPHRD_ETHER);
frame.arp.ar_pro = htons(ETH_P_IP);
frame.arp.ar_hln = 6;
frame.arp.ar_pln = 4;
frame.arp.ar_op = htons(ARPOP_REQUEST);
/* sockaddr_ll로 출력 인터페이스 지정 */
struct sockaddr_ll sll = {
.sll_ifindex = if_nametoindex("eth0"),
.sll_halen = ETH_ALEN,
};
memset(sll.sll_addr, 0xff, ETH_ALEN);
sendto(fd, &frame, sizeof(frame), 0,
(struct sockaddr *)&sll, sizeof(sll));
/* → packet_sendmsg() → dev_queue_xmit()
* → IP 스택, Netfilter 완전 우회
* → 직접 NIC 드라이버로 전달 */
/* 예제 3: IP_HDRINCL로 커스텀 IP 패킷 전송 */
int fd = socket(AF_INET, SOCK_RAW, IPPROTO_RAW);
/* IPPROTO_RAW (255)는 자동으로 IP_HDRINCL 활성화 */
struct {
struct iphdr ip;
struct udphdr udp;
char payload[64];
} pkt;
/* IP 헤더 구성 */
pkt.ip.version = 4;
pkt.ip.ihl = 5;
pkt.ip.tos = 0;
pkt.ip.tot_len = htons(sizeof(pkt));
pkt.ip.id = 0; /* 커널이 자동 할당 */
pkt.ip.frag_off = htons(IP_DF);
pkt.ip.ttl = 64;
pkt.ip.protocol = IPPROTO_UDP;
pkt.ip.check = 0; /* 커널이 자동 계산 */
pkt.ip.saddr = inet_addr("10.0.0.1");
pkt.ip.daddr = inet_addr("10.0.0.2");
/* UDP 헤더 구성 */
pkt.udp.source = htons(12345);
pkt.udp.dest = htons(53);
pkt.udp.len = htons(sizeof(pkt.udp) + sizeof(pkt.payload));
pkt.udp.check = 0; /* UDP 체크섬은 사용자가 계산해야 함 */
sendto(fd, &pkt, sizeof(pkt), 0, ...);
/* → raw_sendmsg() → inet->hdrincl=1이므로 raw_send_hdrinc()
* → 커널은 check, tot_len(0이면), id(0이면), saddr(0이면)만 보정
* → NF_HOOK(NF_INET_LOCAL_OUT) → dst_output() → dev_queue_xmit() */
보안 고려사항
| 위협 | 관련 소켓 타입 | 방어 메커니즘 |
|---|---|---|
| IP 스푸핑 | AF_INET + IP_HDRINCL | rp_filter (Reverse Path Filtering), BCP 38 (uRPF) |
| ARP 스푸핑 | AF_PACKET SOCK_RAW | DAI (Dynamic ARP Inspection), 정적 ARP 엔트리 |
| 패킷 스니핑 | AF_PACKET (ETH_P_ALL) | CAP_NET_RAW 제한, 네트워크 네임스페이스(Namespace) 격리(Isolation) |
| 프로토콜 스택 DoS | SOCK_RAW 대량 전송 | net.core.rmem_max, sk->sk_sndbuf 제한 |
| 컨테이너(Container) 탈출 | AF_PACKET TPACKET | CAP_NET_RAW 제거, seccomp 필터 |
# CAP_NET_RAW 관련 보안 설정
# 특정 바이너리에만 CAP_NET_RAW 부여 (setuid 대체)
setcap cap_net_raw+ep /usr/bin/ping
# ping socket 허용 범위 설정 (CAP_NET_RAW 불필요)
# GID 0~2147483647 범위의 사용자가 ICMP ping 가능
sysctl -w net.ipv4.ping_group_range="0 2147483647"
# Reverse Path Filtering (IP 스푸핑 방지)
sysctl -w net.ipv4.conf.all.rp_filter=1 # strict mode
sysctl -w net.ipv4.conf.all.rp_filter=2 # loose mode
# 컨테이너에서 CAP_NET_RAW 제거 (Docker)
docker run --cap-drop=NET_RAW ...
# seccomp으로 raw socket 시스콜 차단
# socket(AF_PACKET, ...) 또는 socket(AF_INET, SOCK_RAW, ...) 블록
# 열린 raw socket 확인
ss -w -a # RAW 소켓 목록
cat /proc/net/raw # IPv4 raw socket 상세 정보
cat /proc/net/raw6 # IPv6 raw socket 상세 정보
cat /proc/net/packet # AF_PACKET 소켓 목록
IPPROTO_RAW (255) 특수 동작: socket(AF_INET, SOCK_RAW, IPPROTO_RAW)는 전송 전용 raw socket을 생성합니다. IP_HDRINCL이 자동 활성화되며, 이 소켓으로는 수신이 불가합니다 (recvmsg()가 영원히 블록). 수신하려면 IPPROTO_RAW 대신 구체적인 프로토콜 번호(예: IPPROTO_UDP)를 지정하거나 별도의 수신용 raw socket을 생성해야 합니다.
소켓 디먹싱과 skb 전달
수신된 skb가 올바른 소켓을 찾아가는 과정 (디먹싱):
/* TCP 수신 디먹싱: 4-tuple 해시 → 소켓 lookup */
/* tcp_v4_rcv() 내부 */
struct sock *sk = __inet_lookup_skb(
&tcp_hashinfo, /* TCP 소켓 해시 테이블 */
skb,
__tcp_hdrlen(th),
th->source, /* 소스 포트 */
th->dest, /* 목적지 포트 */
iph->saddr, /* 소스 IP */
iph->daddr, /* 목적지 IP */
sdif);
/* 반환: established 소켓 또는 listen 소켓 */
/* UDP 수신 디먹싱 */
struct sock *sk = __udp4_lib_lookup_skb(skb, uh->source, uh->dest,
udptable);
/* 소켓을 찾은 후 skb를 수신 큐에 전달 */
if (!sock_owned_by_user(sk)) {
/* 소켓이 lock 상태가 아니면 직접 수신 큐에 추가 */
__skb_queue_tail(&sk->sk_receive_queue, skb);
sk->sk_data_ready(sk); /* epoll/poll/select wakeup */
} else {
/* 소켓이 lock 중이면 backlog에 임시 저장 */
__sk_add_backlog(sk, skb);
/* → release_sock() 시 backlog 처리 */
}
성능 팁: SO_REUSEPORT와 BPF_PROG_TYPE_SK_REUSEPORT를 조합하면, 동일 포트를 여러 소켓이 공유하면서 BPF 프로그램으로 skb를 특정 소켓에 스티어링할 수 있습니다. 이는 nginx, envoy 등의 고성능 프록시에서 활용됩니다.
Zero-copy 전송
대용량 데이터를 전송할 때, 사용자 공간 버퍼를 커널로 복사하지 않고 직접 NIC에 전달하여 CPU 사용량과 지연을 줄일 수 있습니다:
| 메커니즘 | 시스템콜 | 동작 방식 |
|---|---|---|
sendfile() | sendfile(out_fd, in_fd, ...) | 파일 → 소켓 직접 전송 (페이지 캐시 → skb frag) |
splice() | splice(fd_in, ..., fd_out, ...) | 파이프 기반 zero-copy, 파일 ↔ 소켓 모두 가능 |
MSG_ZEROCOPY | send(fd, buf, len, MSG_ZEROCOPY) | 사용자 버퍼 → skb frag (완료 통지 필요, 4.14+) |
/* 커널 내부: sendfile의 skb 구성 */
/* 파일 페이지를 skb fragment로 직접 참조 */
skb_fill_page_desc(skb, frag_idx, page, offset, size);
/* page refcount 증가, 복사 없음 */
/* MSG_ZEROCOPY: 사용자 버퍼 페이지를 pin */
/* skb->destructor = sock_zerocopy_callback;
* 전송 완료 시 사용자 공간에 completion notification 전달
* (errqueue에서 SO_EE_ORIGIN_ZEROCOPY 메시지 수신) */
/* 드라이버: skb_page_frag_refill로 페이지 풀 활용 */
struct page_frag_cache *nc = &this_cpu_ptr(&nf_skb_cache)->pf_cache;
if (!skb_page_frag_refill(size, nc, GFP_ATOMIC))
return -ENOMEM;
/* nc->va + nc->offset 에서 size 바이트 사용 가능 */
MSG_ZEROCOPY는 10Gbps 이상 고속 네트워크에서 효과적입니다. 그러나 작은 패킷(~수KB 이하)에서는 페이지 pinning과 completion 통지 오버헤드가 복사 비용보다 클 수 있습니다. Google의 벤치마크에 따르면 5~8% CPU 절감이 일반적입니다.
수신/전송 경로에서의 skb 변형
sk_buff 생애주기 (Lifecycle)
sk_buff는 네트워크 패킷의 전체 생명주기 동안 커널 메모리를 차지하며, 할당부터 해제까지 다양한 변형을 거칩니다. 다음 다이어그램은 수신(RX)과 송신(TX) 경로에서 sk_buff의 생애주기를 보여줍니다.
참조 카운트와 메모리 관리
/* sk_buff의 참조 카운트는 users 필드로 관리 */
struct sk_buff {
atomic_t users; /* 참조 카운트 (skb_get/skb_put으로 증감) */
/* ... */
};
/* 참조 카운트 증가 — 소유권 공유 */
static inline struct sk_buff *skb_get(struct sk_buff *skb)
{
refcount_inc(&skb->users);
return skb;
}
/* 참조 카운트 감소 — 0이 되면 해제 */
static inline void kfree_skb(struct sk_buff *skb)
{
if (!skb) return;
if (refcount_dec_and_test(&skb->users))
__kfree_skb(skb); /* 실제 해제 */
}
/* 정상 소비 (드롭 아님) — 통계 구분 */
static inline void consume_skb(struct sk_buff *skb)
{
if (!skb) return;
if (refcount_dec_and_test(&skb->users))
__kfree_skb(skb);
}
/* 사용 예: 소켓 큐에서 꺼낸 후 해제 */
struct sk_buff *skb = skb_dequeue(&sk->sk_receive_queue);
if (skb) {
process_packet(skb);
consume_skb(skb); /* 정상 소비 */
}
/* 드롭 시 kfree_skb 사용 (디버깅 추적 가능) */
if (!pskb_may_pull(skb, sizeof(struct iphdr))) {
kfree_skb(skb); /* 드롭: perf/dropwatch로 추적됨 */
return -EINVAL;
}
수신/송신 경로에서의 데이터 영역 변화
수신 경로: 각 계층에서 헤더를 제거하며 data 포인터가 앞으로 이동합니다.
| 단계 | 대표 함수 | 버퍼 레이아웃 | data 포인터 위치 |
|---|---|---|---|
| 1. DMA 복사 직후 | NIC RX | headroom | ETH | IP | TCP | DATA | tail |
ETH 시작점 |
| 2. L2 처리 | eth_type_trans() |
headroom | ETH | IP | TCP | DATA | tail |
skb_pull(ETH_HLEN) 후 IP 시작점 |
| 3. L3 처리 | ip_rcv() |
headroom | ETH | IP | TCP | DATA | tail |
skb_pull(ip_hdr_len) 후 TCP 시작점 |
| 4. L4 처리 | tcp_rcv() |
headroom | ETH | IP | TCP | DATA | tail |
payload 시작점 (헤더 제거 완료) |
송신 경로: 각 계층에서 헤더를 추가하며 data 포인터가 뒤로 이동합니다.
| 단계 | 대표 함수 | 버퍼 레이아웃 | data 포인터 이동 |
|---|---|---|---|
| 1. payload 준비 | 소켓 송신 준비 | headroom | PAYLOAD | tail |
payload 시작점 |
| 2. L4 헤더 추가 | tcp_transmit_skb() |
headroom | TCP | PAYLOAD | tail |
skb_push(tcp_hdr_len) |
| 3. L3 헤더 추가 | ip_queue_xmit() |
headroom | IP | TCP | PAYLOAD |
skb_push(ip_hdr_len) |
| 4. L2 헤더 추가 | dev_hard_start_xmit() |
headroom | ETH | IP | TCP | PAYLOAD |
skb_push(ETH_HLEN) 후 NIC DMA 전송 |
skb_realloc_headroom()이 호출되어 새로운 버퍼를 할당합니다. 이는 성능 저하를 유발하므로, 초기 할당 시 충분한 headroom을 확보하는 것이 중요합니다 (NET_SKB_PAD + NET_IP_ALIGN + 예상 헤더 크기).
eth_type_trans() 호출 전후 skb 필드 변화
NIC 드라이버가 eth_type_trans(skb, dev)를 호출하면 다음 필드들이 갱신됩니다:
| 필드 | 호출 전 (DMA 직후) | 호출 후 |
|---|---|---|
skb->mac_header |
미설정 | L2(Ethernet) 헤더 시작 오프셋으로 설정 (skb_reset_mac_header() 수행) |
skb->protocol |
미설정 | EtherType 값 (예: ETH_P_IP, ETH_P_IPV6) — ntohs() 변환 포함 |
skb->data |
L2(ETH) 헤더 시작 | L3 헤더 시작 (skb_pull(ETH_HLEN) 또는 VLAN 포함 크기만큼 당겨짐) |
skb->len |
L2 프레임 전체 크기 | ETH 헤더 제거 후 L3 이상 크기 |
skb->network_header |
미설정 | 미설정 — ip_rcv() 진입 후 skb_reset_network_header()로 설정됨 |
skb->mac_len |
미설정 | 미설정 — L3 처리 진입 시 skb->network_header - skb->mac_header로 초기화됨 |
mac_header가 설정된 뒤에는 eth_hdr(skb)로 Ethernet 헤더 포인터를 얻을 수 있고, skb_mac_header(skb)로 skb->head + skb->mac_header에 해당하는 포인터를 얻습니다. skb->data는 이미 L3 시작으로 옮겨졌으므로 혼동하지 않도록 주의하십시오.
수신/전송 경로 요약
수신 경로 (NIC → 앱):
- NIC 드라이버:
netdev_alloc_skb()로 skb 할당, DMA 데이터 복사 - L2 처리:
skb_pull(ETH_HLEN)으로 Ethernet 헤더 제거 - L3 처리:
skb_pull(ip_hdr_len)으로 IP 헤더 제거, transport_header 설정 - L4 처리: TCP/UDP 디먹싱, 소켓 수신 큐에
skb_queue_tail() - 앱:
recvmsg()에서 데이터를 사용자 공간에 복사
전송 경로 (앱 → NIC):
- 앱:
sendmsg()에서 사용자 데이터를 skb에 복사 - L4:
skb_push()로 TCP/UDP 헤더 추가 - L3:
skb_push()로 IP 헤더 추가, 라우팅 - L2:
skb_push()로 Ethernet 헤더 추가 - NIC 드라이버:
dev_queue_xmit()→ DMA 전송
커널 내 실제 사용 사례
sk_buff는 네트워크 스택의 모든 서브시스템에서 핵심적으로 사용됩니다:
| 서브시스템 | 주요 skb 활용 | 핵심 함수/패턴 |
|---|---|---|
| TCP | 전송 큐, 재전송(Retransmission) 큐, OOO 큐에 skb 관리 | tcp_write_xmit(), tcp_retransmit_skb(), TCP_SKB_CB()로 cb[] 활용 |
| UDP | 소켓 수신 큐에 skb 대기열 | udp_rcv(), skb_consume_udp(), MSG_PEEK 처리 |
| Netfilter | 패킷 필터링/수정/NAT | skb_make_writable() 후 헤더 수정, nf_ct_get(skb)로 conntrack |
| Bridge | L2 포워딩, VLAN 처리 | skb_clone()으로 멀티캐스트 복제, skb_vlan_push/pop() |
| Tunnel (GRE, VXLAN) | encapsulation/decapsulation | skb_cow_head()로 headroom 확보, skb_push()로 외부 헤더 추가 |
| TC (Traffic Control) | QoS, 큐잉, 셰이핑 | skb->priority, skb->mark, skb_get_queue_mapping() |
| BPF/XDP | 프로그래밍 가능 패킷 처리 | TC-BPF: __skb_buff 컨텍스트, XDP: skb 이전 단계 (xdp_buff → build_skb) |
| SCTP | 멀티스트리밍, 멀티호밍 | skb_queue_head_init()으로 청크별 큐 관리 |
/* TCP: cb[]를 tcp_skb_cb로 활용하는 패턴 */
struct tcp_skb_cb {
__u32 seq; /* 시작 시퀀스 번호 */
__u32 end_seq; /* 끝 시퀀스 번호 */
__u32 ack_seq; /* ACK 번호 */
__u8 tcp_flags; /* TCP 플래그 */
__u8 sacked; /* SACK 상태 */
/* ... */
};
#define TCP_SKB_CB(__skb) \
((struct tcp_skb_cb *)&((__skb)->cb[0]))
/* 사용 예: TCP 재전송 판단 */
if (after(TCP_SKB_CB(skb)->end_seq, tp->snd_una))
/* 아직 ACK되지 않은 데이터 — 재전송 대상 */
/* Netfilter: 패킷 수정 전 쓰기 가능 확보 */
static unsigned int my_nf_hook(void *priv,
struct sk_buff *skb,
const struct nf_hook_state *state)
{
struct iphdr *iph;
if (skb_ensure_writable(skb, sizeof(*iph)))
return NF_DROP;
iph = ip_hdr(skb);
iph->ttl--; /* 안전하게 수정 가능 */
ip_send_check(iph);
return NF_ACCEPT;
}
성능 튜닝 경험적 팁
커널 네트워크 스택과 NIC 드라이버를 다루면서 얻는 경험적 성능 최적화 팁들입니다:
할당 최적화
- NAPI 컨텍스트에서는
napi_alloc_skb사용: 일반alloc_skb보다 per-CPU 캐시를 활용해Cache hit율 높임. IRQ 컨텍스트에서는 atomic GFP(Get Free Pages) 플래그 필수. - 페이지 프래그먼트 활용: 수신 시 데이터가 크면 linear 버퍼 대신
skb_add_rx_frag로 페이지을 DMA 버퍼에 직접 추가. memcpy를 피하면 대역폭(Bandwidth) 활용이 크게 향상됨. - headroom 충분하게 확보: 초기 할당 시
NET_SKB_PAD(보통 32바이트) +NET_IP_ALIGN(2 또는 0) + 최대 헤더 크기(예: 100바이트) 확보. 나중에skb_realloc_headroom호출은 심각한 성능 저하 유발.
Clone vs Copy 선택
- 읽기 전용(Read-Only) 경로:
skb_clone사용. 데이터 버퍼 공유하므로 memcpy 1회 절약. Netfilter의 MIRROR 타겟, tcpdump가 이 패턴. - 헤더만 수정:
pskb_copy사용. Linear 영역만 복사하고 프래그먼트는 refcount 공유. NAT, 라우팅 변경에서 주로 사용. - Payload 수정 필요:
skb_copy사용. 완전 복사이므로 가장 느리지만 안전. - 경험적 판단: "이 패킷을 두 곳에서 동시에 수정하는가?" → 아니면 clone, 그 외면 copy.
큐 및 스케줄링
- RSS(Receive Side Scaling) 활용: 멀티큐 NIC에서
skb->queue_mapping이 수신 큐 인덱스 저장.irqbalance또는 수동 IRQ affinity 설정으로 각 큐를 다른 CPU에 분산. - softirq 튜닝:
/proc/net/softnet_stat에서 각 CPU의 처리량 확인.net.core.netdev_budget(기본 300)으로 softirq time slice 조절. - NAPI 폴링(Polling) 시간:
netif_napi_add시napi->weight(기본 64) 값을 조절. 높은 대역폭 지연이 허용되면 값을 크게, 저지연이 중요하면 작게 설정.
Zero-Copy 경로
- sendfile(): 파일 전송 시 가장 효율적. 페이지 캐시 → NIC 직접 경로로 복사 최소화. HTTP 서버 정적 파일 전송에 적합.
- MSG_ZEROCOPY: 대용량 UDP 전송에서 효과적. 10Gbps 이상에서 CPU 절약이 크게 향상됨. 단, TX 완료 대기로 추가 지연이 발생함.
- TPACKET (mbuf): 고성능 캡처에서 필수. mmap으로 커널-사용자 공간 복사를 완전히 제거함.
suricata,tcpdump -i any참조.
하드웨어 offload 활용
- 체크섬 offload 활성화:
ethtool -K eth0 rx-checksumming on tx-checksumming on. 대부분의 modern NIC에서 기본값. - TSO/GSO 활성화:
ethtool -K eth0 tso on gso on. 대용량 TCP 전송 시 극적인 성능 향상. 64KB super-packet이 NIC에서 자동 분할. - GRO 활성화:
ethtool -K eth0 gro on. 수신측 병합으로 수천 PPS에서 CPU 사용량 크게 감소.
# NIC 오프로드 상태 확인
$ ethtool -k eth0 | grep -E "checksum|gso|gro|rss"
tcp-segmentation-offload: on
udp-fragmentation-offload: [fixed]
generic-segmentation-offload: on
generic-receive-offload: on
tcp6-segmentation-offload: on
rx-checksumming: on
tx-checksumming: on
# RSS 설정 확인 및 변경
$ ethtool -l eth0 # 큐 개수 확인
$ ethtool -L eth0 combined 4 # 4개 combined 큐로 설정
# interrupt coalescing 조절 (지연 vs 처리량)
$ ethtool -C eth0 rx-usecs 100 tx-usecs 100 # moderate coalescing
$ ethtool -C eth0 rx-usecs 0 tx-usecs 0 # 낮은 지연 (latency)
GRO off → GRO on으로 변경 시 CPU 사용량이 약 40% 감소했습니다. 하지만 특정 레거시 애플리케이션에서는 GRO로 인한 packet reordering이 문제를 일으킬 수 있어, 프로덕션 변경 전 반드시 테스트 환경에서 검증하세요.
주의사항과 함정 (Common Mistakes)
1. skb leak (메모리 누수)
/* 잘못된 코드: 에러 경로에서 skb 해제 누락 */
static int my_rx(struct sk_buff *skb)
{
struct my_hdr *hdr = (struct my_hdr *)skb->data;
if (hdr->version != MY_VERSION)
return -EINVAL; /* BUG! skb가 해제되지 않음 */
/* ... */
}
/* 올바른 코드 */
static int my_rx(struct sk_buff *skb)
{
struct my_hdr *hdr = (struct my_hdr *)skb->data;
if (hdr->version != MY_VERSION) {
kfree_skb(skb); /* 에러 경로 → kfree_skb (드롭) */
return -EINVAL;
}
/* ... 정상 처리 후 ... */
consume_skb(skb); /* 정상 경로 → consume_skb */
return 0;
}
2. pskb_may_pull 후 포인터 미갱신
/* 잘못된 코드: pull 후 이전 포인터 사용 */
struct iphdr *iph = ip_hdr(skb);
if (!pskb_may_pull(skb, iph->ihl * 4))
goto drop;
/* BUG! pskb_may_pull이 버퍼를 재할당했을 수 있음 → iph는 dangling pointer */
pr_info("saddr: %pI4\
", &iph->saddr);
/* 올바른 코드: 포인터 재취득 */
if (!pskb_may_pull(skb, sizeof(struct iphdr)))
goto drop;
struct iphdr *iph = ip_hdr(skb); /* pull 후 재취득 */
if (!pskb_may_pull(skb, iph->ihl * 4))
goto drop;
iph = ip_hdr(skb); /* 두 번째 pull 후에도 재취득! */
3. 공유 skb 데이터 수정
/* 잘못된 코드: clone된 skb의 데이터를 바로 수정 */
struct iphdr *iph = ip_hdr(skb);
iph->ttl = 64; /* BUG! skb가 clone 상태면 원본도 수정됨 */
/* 올바른 코드: 쓰기 전 독점 소유 확보 */
if (skb_ensure_writable(skb, skb_network_header_len(skb)))
goto drop;
struct iphdr *iph = ip_hdr(skb); /* 포인터 재취득 */
iph->ttl = 64; /* 이제 안전 */
4. truesize 불일치
/* 잘못된 코드: skb에 페이지를 추가하면서 truesize 미갱신 */
skb_add_rx_frag(skb, idx, page, offset, size, size);
/* 마지막 인자(truesize)가 실제 할당 크기보다 작으면
* → 소켓 메모리 추적(sk_rmem_alloc)이 실제보다 작게 계산됨
* → 소켓이 제한 없이 메모리를 소비 → OOM 가능
*/
/* 올바른 코드: truesize는 실제 할당된 메모리 크기 */
skb_add_rx_frag(skb, idx, page, offset, size, PAGE_SIZE);
/* PAGE_SIZE = 실제 할당 단위 (page order 0 기준) */
5. refcount 이중 해제(Double Free)
/* 잘못된 코드: netif_rx 후 skb를 다시 해제 */
netif_rx(skb); /* 네트워크 스택에 소유권 이전 */
kfree_skb(skb); /* BUG! 이중 해제 → use-after-free */
/* 올바른 패턴: 전달 후 skb를 사용하지 않음 */
netif_rx(skb); /* 소유권 이전, skb는 더 이상 사용하지 않음 */
/* netif_rx(), netif_receive_skb(), napi_gro_receive() 등은
* skb의 소유권을 가져감 — 이후 skb 접근 금지 */
6. headroom 부족으로 인한 skb_under_panic
/* 잘못된 코드: headroom 확인 없이 헤더 추가 */
skb_push(skb, sizeof(struct my_encap_hdr));
/* headroom이 부족하면 skb_under_panic → 커널 panic */
/* 올바른 코드: headroom 확보 후 추가 */
int needed = sizeof(struct my_encap_hdr) + LL_RESERVED_SPACE(dev);
if (skb_cow_head(skb, needed)) {
kfree_skb(skb);
return NETDEV_TX_OK;
}
skb_push(skb, sizeof(struct my_encap_hdr)); /* 이제 안전 */
7. 레이어 경계 처리 실수
RX → 포워딩 → TX 경로를 거치는 패킷은 mac_header, network_header 포인터의 유효성이 경계마다 달라집니다. 이를 무시하면 잘못된 헤더 접근이나 커널 BUG를 유발합니다.
RX → 포워딩: mac_header 갭 문제
/* 수신 후 포워딩 경로에서 mac_header와 network_header 사이에
* "갭(hole)"이 생길 수 있음:
* eth_type_trans() → data를 L3 시작으로 당김
* ip_rcv() → network_header = data 위치로 재설정
* → mac_header는 그대로이므로 mac_header < network_header
* (갭 = Ethernet 헤더 크기)
*
* 포워딩 후 재전송 시 L2 헤더 재구성이 필요하면:
*/
skb_mac_header_rebuild(skb); /* mac_header를 network_header 바로 앞으로 재정렬 */
포워딩 → TX: mac_header 무효화
/* IPSec/GRE 등 캡슐화 후 원래 mac_header가 무효화될 수 있음.
* 헤더 재구성 전에 반드시 유효성 확인: */
if (skb_mac_header_was_set(skb)) {
/* mac_header가 유효할 때만 eth_hdr(skb) 접근 */
struct ethhdr *eth = eth_hdr(skb);
/* ... */
}
/* 캡슐화로 mac_header가 갱신되지 않은 경우 직접 재설정 */
skb_reset_mac_header(skb); /* mac_header = data - head */
IPSec: headroom 확보 및 network_header 재설정
/* IPSec 암호화(outbound) 전: ESP/AH 헤더를 위한 headroom 확보 */
int head_delta = skb_cow_head(skb, esp_hdr_len + LL_RESERVED_SPACE(dst->dev));
if (head_delta)
goto error;
skb_push(skb, esp_hdr_len); /* ESP 헤더 공간 확보 */
skb_reset_network_header(skb); /* IP 헤더 위치 재설정 */
/* IPSec 복호화(inbound) 후: network_header가 바뀌었으므로 재설정 필수 */
skb_pull(skb, esp_hdr_len); /* ESP 헤더 제거 */
skb_reset_network_header(skb); /* 복호화된 IP 헤더로 포인터 재설정 */
network_header, transport_header, mac_header를 반드시 재설정하십시오. 포인터를 재설정하지 않으면 ip_hdr(skb), tcp_hdr(skb) 등이 잘못된 주소를 반환하여 조용한 메모리 손상이 발생합니다.
실제 트러블슈팅 사례
네트워크 문제를 분석하면서 자주 마주하는 skb 관련 실제 케이스들입니다:
사례 1: 메모리 누수가 의심될 때
증상: ss -s나 /proc/net/sockstat에서 사용 중인 소켓 수가 비정상적으로 많거나, 시스템 메모리가 점진적으로 감소합니다.
# 현재 소켓 상태 확인
$ ss -s
$ cat /proc/net/sockstat
# orphan(소멸된) 소켓 수 — TIME_WAIT 소켓이 정리되지 않으면 증가
$ cat /proc/net/sockstat | grep TCP
# 드롭된 패킷 수 확인
$ nstat -az TcpExt.ListenOverflows
$ cat /proc/net/netstat | grep -E "Tcp|Ext" | column -t
원인: 에러 경로에서 kfree_skb() 호출 누락, 또는 consume_skb() 대신 kfree_skb()를 사용해서 메모리 참조가 해제되지 않음.
사례 2: 체크섬 검증 실패
증상: 특정 NIC에서만 TCP/UDP 체크섬 오류가 발생하거나, 애플리케이션에서 "bad checksum" 로그가 반복됩니다.
# NIC 드라이버와 체크섬 오프로드 상태 확인
$ ethtool -k eth0 | grep checksum
rx-checksumming: on
tx-checksumming: on
# 드라이버 메시지 확인 (dmesg)
$ dmesg | grep -i "eth0\|ixgbe\|mlx5"
[12345.678] ixgbe 0000:01:00.0: ixgbe_check_bad_counter: Detected bad TCP checksum,
but feature turned on — actual problem may exist
# 테스트: 체크섬 offload 비활성화
$ ethtool -K eth0 rx-checksumming off tx-checksumming off
원인: 일부 저가형 또는 legacy NIC에서 HW 체크섬 계산이 부정확한 경우 (false positive). 커널 버그로 인해 특정 드라이버에서만 발생.
사례 3: GRO로 인한 TCP 재전송 증가
증상: GRO 활성화 후 TCP 재전송이 증가하거나, 특정 애플리케이션에서 패킷 순서 오류 발생.
# GRO 상태 확인
$ ethtool -k eth0 | grep generic-receive-offload
# TCP 재전송 통계 확인
$ nstat -az TcpRetransSegs
$ cat /proc/net/snmp | grep -E "Retrans|OutSegs"
# 문제 구간 확인 — 서버-클라이언트 양쪽에서 GRO 상태 맞춰야 함
$ ethtool -K eth0 gro off # 테스트를 위해 off
$ iperf3 -c 10.0.0.1 -P 4 # 대역폭 재테스트
원인: GRO가 다른 흐름의 패킷을 잘못 병합하거나, NIC HW GRO의 구현 버그. 특히 가상화(Virtualization) 환경(virtio, VM에서) 자주 발생.
사례 4: 프래그먼트된 대용량 패킷 처리 지연
증상: 대용량 파일 전송 시 예상보다 낮은 throughput, 또는 특정 크기(예: 64KB 근처)에서 throughput 급격 감소.
# skb_linearize 빈도 확인 — linearization은 비용이 큼
$ cat /proc/net/netstat | grep SkbConcatenate
TcpSmbConcatenate: 12345
# GSO/TSO 상태 확인
$ ethtool -k eth0 | grep -E "segmentation|offload"
# 수신측 gro_flush_timeout 확인 (지연 병합)
$ sysctl net.core.gro_flush_timeout
net.core.gro_flush_timeout = 2000
원인: NIC이 TSO를 지원하지 않으면 커널에서 software GSO가 linearize를 유발하거나, 수신 측 GRO가 타임아웃까지 대기를 위해 지연 발생.
사례 5: NAPI 기아(Starvation) 상태 (starvation)
증상: 고대역폭 트래픽에서 일부 CPU만 max softirq time에 도달하고, 다른 CPU는 유휴 상태(Idle State). 드롭이 특정 CPU에서 집중됨.
# CPU별 softirq 처리량 확인
$ cat /proc/net/softnet_stat | awk '{print $1, $2, $3, $4}' | head -20
# 컬럼: cpu_id, processed, dropped, time_squeeze
# IRQ affinity 확인
$ cat /proc/interrupts | grep eth0
$ cat /proc/irq/<irq_num>/smp_affinity
# NAPI 가중치 확인
$ ls /sys/class/net/eth0/napi
$ cat /sys/class/net/eth0/napi/<napi_id>/poll_time
원인: IRQ가 단일 CPU에 집중되거나, NAPI weight가 너무 작아서 time slice 내에 처리를 못 함. RSS 설정과 IRQ balancing 문제.
디버깅 기법
tracepoint 활용
# skb 드롭 추적 (kfree_skb 호출 위치와 원인)
$ perf trace -e skb:kfree_skb --call-graph dwarf -a sleep 5
# skb 드롭 실시간 모니터링
$ cat /sys/kernel/debug/tracing/events/skb/kfree_skb/format
$ echo 1 > /sys/kernel/debug/tracing/events/skb/kfree_skb/enable
$ cat /sys/kernel/debug/tracing/trace_pipe
# dropwatch (커널 6.x+: kfree_skb_reason으로 드롭 원인 표시)
$ dropwatch -l kas
> start
perf probe로 동적 추적
# 특정 함수에서 skb->len 값 추적
$ perf probe --add 'tcp_v4_rcv skb->len skb->data_len'
$ perf record -e probe:tcp_v4_rcv -a sleep 10
$ perf script
# skb 할당 빈도 측정
$ perf stat -e 'skb:*' -a sleep 10
/proc/net 진단
# 소켓 메모리 사용량 확인 (skb leak 의심 시)
$ cat /proc/net/sockstat
sockets: used 1234
TCP: inuse 56 orphan 2 tw 128 alloc 60 mem 1024
UDP: inuse 12 mem 256
# mem: 페이지 단위 (mem * PAGE_SIZE = 실제 바이트)
# orphan: 소속 프로세스 없는 TCP 소켓 (skb leak 원인 가능)
# tw: TIME_WAIT 상태 (정상적이지만 과다하면 문제)
# 네트워크 스택 통계 (드롭/에러 확인)
$ nstat -az | grep -i drop
$ cat /proc/net/softnet_stat
디버깅 커널 옵션
| 옵션 | 기능 |
|---|---|
CONFIG_DEBUG_KMEMLEAK | skb를 포함한 커널 메모리 누수 탐지 |
CONFIG_KASAN | use-after-free, out-of-bounds 접근 탐지 |
CONFIG_NET_DROP_MONITOR | 네트워크 패킷 드롭 위치 추적 |
CONFIG_DEBUG_NET | 네트워크 스택 디버깅 assertion 활성화 |
CONFIG_SKB_EXTENSIONS | skb extension (conntrack, bridge 등) 디버깅 |
커널 버전별 sk_buff 변경 이력
sk_buff는 Linux 커널 네트워크 스택의 핵심 자료구조로서 수십 년에 걸쳐 지속적으로 변화해 왔습니다. 아래 이력은 각 커널 시리즈에서 sk_buff에 영향을 준 주요 변경사항을 정리합니다.
2.6.x 시대: 구조 간소화와 헤더 분리
Linux 2.6 시리즈는 sk_buff의 내부 구조를 대대적으로 간소화한 시기입니다. 2.6.14 이전에는 sk_buff 구조체 자체에 callback 함수 포인터 배열(destructor_list)이 포함되어 있었으나, 이를 소켓 수준으로 이동시켜 sk_buff 크기를 줄였습니다.
2.6.22에서는 skb_shared_info를 sk_buff 끝 부분(end 포인터 위치)에 배치하는 방식으로 고정하여 캐시 지역성(Cache Locality)을 개선하였습니다. 기존에는 별도 할당으로 포인터를 통해 접근했으나, 같은 캐시 라인에 위치하도록 변경되어 GSO/TSO 처리 성능이 향상되었습니다.
2.6.24에서는 mac_header, network_header, transport_header 필드가 절대 포인터에서 head 기준 16비트 오프셋(offset)으로 변경되었습니다. 이 변경으로 sk_buff 구조체 크기가 줄고, pskb_expand_head()로 버퍼를 재할당할 때 헤더 포인터를 일일이 갱신하지 않아도 되게 되었습니다.
2.6.31에서는 skb_dst_set() / skb_dst() 접근자 함수가 도입되어 dst 포인터에 참조 카운트 정보를 태그 비트(tagged pointer)로 인코딩하는 방식으로 최적화되었습니다. 이를 통해 빠른 경로(fast path)에서 참조 카운트 연산을 생략할 수 있게 되었습니다.
| 버전 | 변경 내용 | 영향 |
|---|---|---|
| 2.6.14 | destructor_list 제거, 소켓 레벨 destructor로 통합 | sk_buff 크기 감소, 메모리 효율 향상 |
| 2.6.18 | ip_summed CHECKSUM_HW → CHECKSUM_PARTIAL / CHECKSUM_COMPLETE 분리 | 체크섬 오프로드 API 명확화 |
| 2.6.22 | skb_shared_info를 skb 말단(end)에 인접 배치 | GSO/TSO 처리 캐시 지역성 향상 |
| 2.6.24 | mac/network/transport_header를 절대 포인터 → 16비트 오프셋으로 변경 | sk_buff 구조체 축소, pskb_expand_head 후 포인터 재획득 불필요 |
| 2.6.26 | skb_clone_writable() 도입, 클론 쓰기 가능 여부 판별 | Netfilter COW 패턴 안정화 |
| 2.6.29 | GRO (Generic Receive Offload) 도입 (Ben Hutchings) | 소프트웨어 LRO 대체, 프로토콜 중립적 병합 |
| 2.6.31 | skb_dst 태그드 포인터 최적화 | fast path에서 dst 참조 카운트 비용 절감 |
| 2.6.39 | skb_frag_t의 page+offset 구조 도입 (bio_vec 정렬 준비) | 비선형 단편 일관성 향상 |
3.x: bio_vec 정렬과 BUILD_BUG_ON 안전성 강화
3.x 시리즈에서는 skb_frag_t를 struct bio_vec와 동일한 레이아웃으로 통일하려는 시도가 이루어졌습니다. 3.2 이전에는 skb_frag_t가 struct page *page + __u32 page_offset + __u32 size였으나, 3.2 이후 struct page *bv_page + unsigned int bv_offset + unsigned int bv_len으로 bio_vec와 호환되도록 정렬되었습니다.
3.13에서는 sk_buff 구조체 레이아웃 변경에 대한 안전망으로 BUILD_BUG_ON 검사가 강화되었습니다. 컴파일 타임에 중요 오프셋과 크기를 검증해 아키텍처별 패딩 차이로 인한 잠재적 버그를 사전에 차단합니다.
| 버전 | 변경 내용 | 영향 |
|---|---|---|
| 3.2 | skb_frag_t → bio_vec 정렬, skb_frag_page/offset/size 접근자 추가 | 블록 레이어와 네트워크 레이어 단편 표현 통일 |
| 3.3 | sk_buff에서 ip_summed/remcsum_offload 비트 필드 도입 | 체크섬 상태 정밀 표현 |
| 3.10 | skb_ext 사전 연구: conntrack 슬롯 구조 재편 | 5.x skb_ext 기반 작업 |
| 3.13 | BUILD_BUG_ON으로 sk_buff 크기·오프셋 컴파일 타임 검증 | 아키텍처별 레이아웃 안전성 강화 |
| 3.15 | skb_zerocopy() 도입 (페이지 단편 참조 이전) | tun, vhost zero-copy 포워딩 가능 |
| 3.16 | GSO_BY_FRAGS 도입 (단편 수 기반 세그먼트 크기 결정) | UDP cork 패킷 처리 효율화 |
| 3.18 | XDP 기초 연구 시작, skb ← → xdp_buff 변환 개념 정립 | 4.x XDP 도입 기반 |
4.x: MSG_ZEROCOPY, UDP GSO, skb_put_data/zero
4.x 시리즈는 성능 최적화에 초점을 맞춘 변경이 많습니다. 가장 중요한 변화는 4.14에서 도입된 MSG_ZEROCOPY로, send() 시스템 콜에서 사용자 공간 메모리를 커널로 복사하지 않고 페이지 참조만으로 전송하는 기능입니다. 이를 위해 sk_buff에 skb_zcopy 비트와 ubuf_info 포인터가 추가되었습니다.
4.18에서는 UDP GSO가 도입되었습니다. 기존 TSO(TCP Segmentation Offload)와 달리 UDP GSO는 단일 sendmsg() 호출로 전달한 대형 UDP 버퍼를 NIC가 분할하도록 하여 UDP 스트리밍 성능이 크게 향상되었습니다.
4.20에서는 skb_put_data()와 skb_put_zero()가 공식 API로 추가되어 기존 skb_put() 후 memcpy()/memset() 패턴을 단일 함수로 표현할 수 있게 되었습니다.
| 버전 | 변경 내용 | 영향 |
|---|---|---|
| 4.8 | XDP(eXpress Data Path) 도입, xdp_buff ↔ sk_buff 변환 경로 확립 | 드라이버에서 skb 할당 이전 패킷 처리 가능 |
| 4.9 | sk_buff에 tc_index, tc_verd 통합 (TC 메타데이터) | tc(1) BPF 프로그램과 sk_buff 연동 강화 |
| 4.14 | MSG_ZEROCOPY 도입: ubuf_info, skb_zcopy 비트 추가 | TCP 전송 경로에서 사용자 메모리 복사 제거 |
| 4.15 | skb_ext 프로토타입: 가변 크기 확장 메타데이터 개념 연구 | 5.x 정식 도입 기반 |
| 4.17 | skb_condense() 도입: 단편이 없을 때 헤드 버퍼 축소 | 소켓 버퍼 메모리 효율 향상 |
| 4.18 | UDP GSO 도입 (Willem de Bruijn): NETIF_F_GSO_UDP_L4 | UDP 스트리밍 처리량 대폭 향상 |
| 4.20 | skb_put_data(), skb_put_zero() 공식 API 추가 | 패킷 생성 코드 간소화 |
5.x: skb_ext 정식 도입과 skb_ensure_writable
5.x는 sk_buff 확장성(Extensibility)과 안전성 측면에서 큰 발전이 있었습니다. 5.1에서 skb_ext 시스템이 정식 도입되어 conntrack, IPsec secpath, bridge NF, MPTCP 서브플로 등 다양한 프로토콜이 sk_buff에 가변 크기 메타데이터를 동적으로 연결할 수 있게 되었습니다. 기존에는 각 기능이 sk_buff 구조체에 직접 포인터 필드를 추가했으나, skb_ext 덕분에 sk_buff 구조체 자체의 비대화를 막을 수 있게 되었습니다.
5.3에서는 skb_ensure_writable()이 도입되어 기존에 혼용되던 skb_make_writable()과 skb_cow_head()를 단일 인터페이스로 통합하였습니다. BPF TC 프로그램이 sk_buff 데이터를 수정할 때도 이 함수를 통해 안전한 COW를 보장받습니다.
| 버전 | 변경 내용 | 영향 |
|---|---|---|
| 5.1 | skb_ext 시스템 정식 도입 (Florian Westphal): SKB_EXT_SEC_PATH, SKB_EXT_BRIDGE_NF 등 | sk_buff 구조체 비대화 방지, 확장 메타데이터 COW 지원 |
| 5.2 | page_pool API 안정화: page_pool_alloc_pages, page_pool_recycle 표준화 | 드라이버 RX 경로 DMA 캐시 재활용 표준화 |
| 5.3 | skb_ensure_writable() 도입 (Netfilter, BPF 공용) | skb 쓰기 보장 API 통합, 기존 skb_make_writable 대체 |
| 5.4 | BPF skb 접근 개선: bpf_skb_load_bytes_relative 헬퍼 추가 | BPF 프로그램에서 mac/net 헤더 상대 오프셋 안전 접근 |
| 5.7 | sk_buff 내 tc_at_ingress 플래그 추가 (TC ingress/egress 경로 구분) | TC 훅 방향 판별 명확화 |
| 5.10 | MPTCP skb_ext 슬롯(SKB_EXT_MPTCP) 추가 | MPTCP 서브플로 메타데이터를 skb_ext로 관리 |
| 5.13 | skb_csum_hwoffload_help() 도입 (드라이버 TX 체크섬 오프로드 헬퍼) | 드라이버 TX 체크섬 처리 코드 중복 제거 |
| 5.17 | skb_orphan_frags_rx() 추가 (RX 경로 단편 orphan) | 소켓 버퍼 해제 경로 안전성 강화 |
6.x: kfree_skb_reason, page_pool 심화, netmem, devmem TCP
6.x 시리즈는 관찰 가능성(Observability)과 zero-copy 수신의 혁신이 핵심입니다. 6.0에서 도입된 kfree_skb_reason()은 패킷 드롭의 원인을 SKB_DROP_REASON_* 열거형으로 기록하여 perf, bpftrace에서 드롭 원인을 정확히 추적할 수 있게 되었습니다. 기존 kfree_skb()는 드롭 원인을 알 수 없었으나, 이 변경으로 NOT_SPECIFIED부터 TCP_INVALID_SEQUENCE, NETFILTER_DROP 등 수십 가지 세분화된 이유를 기록합니다.
6.1에서는 page_pool이 sk_buff 수명주기와 더욱 깊이 통합되었습니다. page_pool_return_skb_page()를 통해 RX 경로에서 사용한 페이지를 DMA 매핑을 유지한 채 page_pool로 즉시 반환할 수 있어, 고성능 드라이버의 할당/해제 오버헤드가 크게 줄었습니다.
6.6에서는 netmem이라는 새로운 추상 메모리 타입이 도입되었습니다. netmem은 struct page, hugetlb, dma-buf 등 다양한 메모리 유형을 통일된 인터페이스로 다루기 위한 것으로, 향후 devmem TCP(Device Memory TCP)의 기반이 됩니다.
6.9에서는 devmem TCP가 도입되어 NIC에서 수신한 데이터를 CPU 메모리를 거치지 않고 GPU/FPGA 등의 디바이스 메모리에 직접 전달하는 zero-copy 수신이 가능해졌습니다. 이는 SO_DEVMEM_DONTNEED 소켓 옵션과 새로운 recvmsg 메시지 헤더를 통해 사용자 공간에 노출됩니다.
| 버전 | 변경 내용 | 영향 |
|---|---|---|
| 6.0 | kfree_skb_reason() 도입: SKB_DROP_REASON_* 열거형 40여 종 | perf/bpftrace 드롭 원인 정밀 추적 가능 |
| 6.1 | page_pool과 sk_buff 통합 심화: page_pool_return_skb_page() | RX 페이지 재활용 효율 극대화, DMA 매핑 캐시 유지 |
| 6.2 | skb_drop_reason 확장: TCP 시퀀스, 윈도우 오류 등 세분화 | TCP 수신 경로 드롭 디버깅 상세화 |
| 6.3 | AF_XDP 경로 sk_buff 제로 할당 최적화: xsk_buff_alloc 통합 | AF_XDP zero-copy 모드 지연 시간 감소 |
| 6.4 | skb_frag_t 내 netmem 지원 준비 (bio_vec 하위 호환 유지) | 다중 메모리 유형 단편 지원 기반 |
| 6.6 | netmem 추상 메모리 타입 도입 (Mina Almasry): net_iov 지원 | GPU/디바이스 메모리 직접 수신 기반 마련 |
| 6.7 | kfree_skb_reason 이벤트 tracepoint 강화: 드롭 이유 → bpf_map 집계 | 운영 환경 드롭 분석 자동화 |
| 6.8 | page_pool 메모리 제공자(Memory Provider) 인터페이스 도입 | devmem TCP를 위한 NIC ↔ 디바이스 메모리 DMA 추상화 |
| 6.9 | devmem TCP 도입 (SO_DEVMEM_DONTNEED, MSG_SOCK_DEVMEM): NIC → GPU 직접 전달 | AI/ML 워크로드에서 CPU 메모리 복사 완전 제거 |
| 6.10 | sk_buff napi_id 활용 강화: busy polling 정밀도 향상 | 저지연 네트워킹(DPDK 대체) 성능 개선 |
| 6.11 | skb_drop_reason 열거형 서브시스템 분리 (SUBSYS 비트 필드) | 드라이버별 커스텀 드롭 이유 정의 가능 |
| 6.12 | netmem/net_iov 기반 zero-copy TX 실험적 지원 | TX 경로도 디바이스 메모리 직접 전송 방향 진행 중 |
kfree_skb_reason() 도입 이전(6.0 이전)과 이후의 접근 방식이 크게 다르며, 체크섬 오프로드 문제는 2.6.18 이전 CHECKSUM_HW 시대 드라이버를 다룰 때 주의가 필요합니다.
skb 확장 (skb_ext)
커널 5.x부터 sk_buff에 가변 메타데이터를 동적으로 연결하는 skb_ext 메커니즘이 도입되었습니다. 이전에는 conntrack 포인터, IPsec secpath 등이 skb 내부에 직접 포함되어 항상 메모리를 차지했으나, skb_ext를 통해 필요할 때만 할당되어 메모리 효율이 크게 개선되었습니다.
/* include/linux/skbuff.h — skb_ext 구조체 */
struct skb_ext {
refcount_t refcnt; /* 참조 카운트 (clone 시 공유) */
u8 offset[SKB_EXT_NUM]; /* 각 확장의 data[] 내 오프셋 */
u8 chunks; /* 할당된 청크 수 (64B 단위) */
char data[]; /* 가변 길이 확장 데이터 */
};
/* 확장 타입 열거형 */
enum skb_ext_id {
SKB_EXT_BRIDGE_NF, /* br_netfilter 상태 (struct nf_bridge_info) */
SKB_EXT_SEC_PATH, /* IPsec 보안 경로 (struct sec_path) */
SKB_EXT_MPTCP, /* MPTCP 옵션 (struct mptcp_ext) */
TC_SKB_EXT, /* TC cls_act 메타데이터 (struct tc_skb_ext) */
SKB_EXT_NUM /* 총 확장 타입 수 */
};
/* skb_ext 추가 — 해당 타입의 확장 공간을 할당하고 포인터 반환 */
void *skb_ext_add(struct sk_buff *skb, enum skb_ext_id id)
{
struct skb_ext *new;
/* active_extensions 비트맵에 id 설정 */
skb->active_extensions |= 1 << id;
/* 확장 공간 할당 또는 기존 공간에서 오프셋 반환 */
return skb->extensions->data + skb->extensions->offset[id];
}
/* skb_ext 조회 — 해당 확장이 있으면 포인터, 없으면 NULL */
static inline void *skb_ext_find(const struct sk_buff *skb,
enum skb_ext_id id)
{
if (skb->active_extensions & (1 << id))
return skb->extensions->data + skb->extensions->offset[id];
return NULL;
}
/* Netfilter conntrack 연결: nf_ct_get()으로 conntrack 참조 */
static inline struct nf_conn *nf_ct_get(
const struct sk_buff *skb,
enum ip_conntrack_info *ctinfo)
{
/* skb->_nfct에서 conntrack 포인터와 상태 정보 추출 */
unsigned long nfct = skb->_nfct;
*ctinfo = nfct & NFCT_INFOMASK;
return (struct nf_conn *)(nfct & NFCT_PTRMASK);
}
/* TC 확장: cls_act에서 skb에 메타데이터 연결 */
struct tc_skb_ext {
__u32 chain; /* TC 체인 번호 */
__u16 mru; /* TC Maximum Receive Unit */
__u16 zone; /* conntrack zone */
u8 post_ct:1; /* CT action 이후 여부 */
u8 post_ct_snat:1;
u8 post_ct_dnat:1;
};
/* TC에서 skb_ext 사용 예 */
struct tc_skb_ext *ext = skb_ext_add(skb, TC_SKB_EXT);
if (ext)
ext->chain = chain_index;
| 확장 타입 | 구조체 | 크기 (약) | 사용 서브시스템 |
|---|---|---|---|
SKB_EXT_SEC_PATH | sec_path | ~40B | IPsec/xfrm — SA 참조 배열 |
SKB_EXT_BRIDGE_NF | nf_bridge_info | ~48B | br_netfilter — 원본 포트/MAC 보존 |
TC_SKB_EXT | tc_skb_ext | ~12B | TC cls_act — 체인/zone/CT 메타 |
SKB_EXT_MPTCP | mptcp_ext | ~24B | MPTCP — DSS/DSN 매핑 |
성능 영향: skb_ext 도입 전, struct sec_path와 struct nf_bridge_info는 skb 내에 항상 포인터를 차지했습니다 (각 8바이트). IPsec이나 bridge를 사용하지 않는 대다수 패킷에서 이 공간이 낭비되었습니다. skb_ext 전환 후 sizeof(struct sk_buff)가 약 16바이트 줄어들었고, 이는 수백만 동시 패킷을 처리하는 환경에서 상당한 메모리 절감입니다.
page_pool 기반 고성능 할당
커널 5.17+에서 도입된 page_pool은 네트워크 드라이버의 skb 데이터 버퍼 할당을 혁신적으로 개선합니다. DMA 매핑을 캐시하고, 해제된 페이지를 재활용하며, bulk 할당으로 lock contention을 최소화합니다. mlx5, ice, i40e, bnxt 등 주요 고성능 드라이버가 page_pool을 사용합니다.
/* include/net/page_pool/types.h — page_pool 생성 파라미터 */
struct page_pool_params {
unsigned int flags; /* PP_FLAG_DMA_MAP | PP_FLAG_DMA_SYNC_DEV */
unsigned int order; /* page order (0 = 4KB, 1 = 8KB) */
unsigned int pool_size; /* ring 크기 (기본 1024) */
int nid; /* NUMA 노드 (-1 = 현재 노드) */
struct device *dev; /* DMA 매핑 대상 디바이스 */
enum dma_data_direction dma_dir; /* DMA_FROM_DEVICE (RX) */
unsigned int max_len; /* 최대 데이터 길이 */
unsigned int offset; /* 데이터 시작 오프셋 */
};
/* 드라이버 초기화: page_pool 생성 */
struct page_pool_params pp_params = {
.flags = PP_FLAG_DMA_MAP | PP_FLAG_DMA_SYNC_DEV,
.order = 0, /* 4KB 페이지 */
.pool_size = 1024,
.nid = dev_to_node(dev),
.dev = &pdev->dev,
.dma_dir = DMA_FROM_DEVICE,
.max_len = PAGE_SIZE,
.offset = XDP_PACKET_HEADROOM,
};
struct page_pool *pool = page_pool_create(&pp_params);
/* 수신 경로: page_pool에서 페이지 할당 */
struct page *page = page_pool_dev_alloc_pages(pool);
/* → alloc.cache[]에서 O(1) 반환 (캐시 히트)
* → 캐시 비면 ring.queue에서 bulk refill
* → ring도 비면 buddy allocator + DMA 매핑 */
dma_addr_t dma = page_pool_get_dma_addr(page);
/* DMA 주소가 이미 캐시됨 — dma_map_page() 호출 불필요! */
/* skb 생성 후 page_pool 페이지를 frag로 추가 */
struct sk_buff *skb = napi_alloc_skb(napi, 256);
skb_add_rx_frag(skb, 0, page, offset, len, PAGE_SIZE);
skb_mark_for_recycle(skb); /* 해제 시 page_pool로 반환 */
/* skb 해제 시: page_pool_put_page()로 자동 재활용 */
/* consume_skb(skb) → skb_free_frag() → page_pool_put_page()
* → alloc.cache[]에 반환 (fast path)
* → 또는 ring.queue에 반환 (다른 CPU에서 해제 시) */
코드 설명
- page_pool_params
include/net/page_pool/types.h에 정의된 생성 파라미터입니다.PP_FLAG_DMA_MAP은 할당 시 자동 DMA 매핑을,PP_FLAG_DMA_SYNC_DEV는 재활용 시 DMA sync를 수행합니다.offset에XDP_PACKET_HEADROOM(256바이트)을 설정하면 XDP 프로그램이 headroom에서 헤더를 조작할 수 있습니다. - page_pool_create()
net/core/page_pool.c에서 per-CPU alloc cache(배열)와 ptr_ring 기반 반환 큐를 초기화합니다. 드라이버probe()에서 RX 큐당 하나의 pool을 생성하는 것이 일반적입니다. - page_pool_dev_alloc_pages()할당 경로는 3단계 폴백입니다: (1) per-CPU
alloc.cache[]에서 O(1) 반환 → (2) 캐시가 비면ring.queue에서 bulk refill → (3) ring도 비면 buddy allocator +dma_map_page(). 대부분의 경우 단계 1에서 완료되어 할당 비용이 극히 낮습니다. - page_pool_get_dma_addr()page의
struct page메타데이터에 캐시된 DMA 주소를 반환합니다. 기존 방식은 매 패킷마다dma_map_page()를 호출했지만, page_pool에서는 페이지 재활용 시 DMA 매핑이 유지되므로 IOMMU 변환 비용을 완전히 제거합니다. - skb_mark_for_recycle()skb 해제 시 page를
put_page()대신page_pool_put_page()로 반환하도록 표시합니다.consume_skb()→skb_release_data()→napi_pp_put_page()경로로 page_pool의 alloc cache나 ring에 반환되어 재활용됩니다.
| 비교 항목 | 기존 (alloc_page + dma_map) | page_pool |
|---|---|---|
| 페이지 할당 | 매번 buddy allocator 호출 | per-CPU 캐시에서 O(1) |
| DMA 매핑 | 매번 dma_map_page() | 캐시된 DMA 주소 재사용 |
| 해제 | dma_unmap + put_page() | 캐시에 반환 (unmap 생략) |
| NUMA 인식 | 수동 관리 필요 | nid 파라미터로 자동 |
| XDP 호환 | 직접 구현 필요 | 내장 XDP headroom 지원 |
| bulk 할당 | 지원 안 함 | page_pool_alloc_pages_batch() |
page_pool 통계 확인: /sys/kernel/debug/page_pool/에서 각 풀의 할당/재활용/실패 통계를 확인할 수 있습니다. ethtool -S eth0 | grep page_pool로 드라이버별 통계도 확인 가능합니다. 재활용율이 90% 이하면 ring 크기 증가 또는 NUMA 문제를 점검하세요.
XDP와 sk_buff 인터페이스
XDP(eXpress Data Path)는 sk_buff 할당 이전 단계에서 패킷을 처리하는 고성능 프레임워크입니다. NIC 드라이버 내부에서 xdp_buff라는 경량 구조체로 패킷을 표현하며, XDP 프로그램의 판정(verdict)에 따라 패킷을 드롭/포워딩하거나 일반 네트워크 스택으로 전달합니다.
/* include/net/xdp.h — xdp_buff 구조체 (sk_buff보다 훨씬 경량) */
struct xdp_buff {
void *data; /* 패킷 데이터 시작 (L2) */
void *data_end; /* 패킷 데이터 끝 */
void *data_meta; /* 메타데이터 시작 (data 앞) */
void *data_hard_start;/* 버퍼 절대 시작 */
struct xdp_rxq_info *rxq; /* RX 큐 정보 */
struct xdp_txq_info *txq; /* TX 큐 정보 */
u32 frame_sz; /* 전체 프레임 크기 */
u32 flags; /* XDP_FLAGS_* */
};
/* xdp_buff → sk_buff 변환 (XDP_PASS 시) */
struct sk_buff *xdp_build_skb_from_buff(struct xdp_buff *xdp)
{
unsigned int headroom = xdp->data - xdp->data_hard_start;
unsigned int data_len = xdp->data_end - xdp->data;
struct sk_buff *skb;
/* build_skb: 기존 버퍼에 sk_buff 메타데이터만 생성 */
skb = build_skb(xdp->data_hard_start, xdp->frame_sz);
if (!skb)
return NULL;
skb_reserve(skb, headroom);
__skb_put(skb, data_len);
/* XDP 메타데이터가 있으면 skb에 전달 */
if (xdp->data_meta != xdp->data) {
int metasize = xdp->data - xdp->data_meta;
skb_metadata_set(skb, metasize);
}
return skb;
}
/* XDP BPF 프로그램 예: 특정 포트 패킷만 PASS, 나머지 DROP */
SEC("xdp")
int xdp_filter(struct xdp_md *ctx)
{
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
if (data + sizeof(*eth) > data_end)
return XDP_DROP;
if (eth->h_proto != bpf_htons(ETH_P_IP))
return XDP_PASS; /* IP 아니면 일반 스택으로 */
struct iphdr *ip = data + sizeof(*eth);
if ((void *)(ip + 1) > data_end)
return XDP_DROP;
if (ip->protocol == IPPROTO_TCP) {
struct tcphdr *tcp = (void *)ip + (ip->ihl * 4);
if ((void *)(tcp + 1) > data_end)
return XDP_DROP;
if (tcp->dest == bpf_htons(80))
return XDP_PASS; /* HTTP → sk_buff 생성 → 스택 진입 */
}
return XDP_DROP; /* sk_buff 할당 없이 즉시 드롭 */
}
/* XDP 메타데이터: xdp_buff → sk_buff 전달
* BPF 프로그램이 data_meta 영역에 커스텀 메타데이터 기록 가능 */
SEC("xdp")
int xdp_with_meta(struct xdp_md *ctx)
{
/* 메타데이터 영역 확보 (data 앞으로 4바이트) */
if (bpf_xdp_adjust_meta(ctx, -4))
return XDP_PASS;
__u32 *meta = (void *)(long)ctx->data_meta;
if ((void *)(meta + 1) > (void *)(long)ctx->data)
return XDP_PASS;
*meta = 0xCAFE; /* 커스텀 마크 */
return XDP_PASS;
/* → sk_buff 변환 후 skb_metadata_len(skb) == 4
* → TC BPF에서 __sk_buff->data_meta로 접근 가능 */
}
| XDP 액션 | sk_buff 할당 | 동작 | 성능 |
|---|---|---|---|
XDP_DROP | 안 함 | 패킷 즉시 드롭, 페이지 반환 | ~24Mpps (64B) |
XDP_TX | 안 함 | 같은 NIC으로 즉시 재전송 | ~14Mpps |
XDP_REDIRECT | 안 함 | 다른 NIC/CPU/AF_XDP로 전달 | ~12Mpps |
XDP_PASS | 생성 | build_skb() → 일반 스택 | 일반 스택 수준 |
XDP_ABORTED | 안 함 | 에러 발생, tracepoint 기록 | — |
XDP Generic vs Native: XDP_FLAGS_SKB_MODE(Generic)는 sk_buff가 이미 할당된 후 XDP 프로그램을 실행합니다. 따라서 XDP_DROP해도 skb 할당 비용이 발생하며, 성능 이점이 크게 감소합니다. 진정한 고성능을 위해서는 드라이버가 네이티브 XDP를 지원해야 합니다 (XDP_FLAGS_DRV_MODE). ethtool -i eth0으로 드라이버를 확인하고, ip link set dev eth0 xdp obj prog.o으로 로드합니다.
패킷 타임스탬핑 (SO_TIMESTAMPING)
정밀한 네트워크 지연 측정, PTP(Precision Time Protocol) 동기화, 금융 거래 시스템 등에서 패킷의 정확한 송수신 시각이 필요합니다. Linux는 SO_TIMESTAMPING 소켓 옵션으로 하드웨어 타임스탬프(NIC PHY 수준)부터 소프트웨어 타임스탬프(커널 네트워크 스택)까지 다양한 수준의 타임스탬핑을 지원하며, 이 정보는 sk_buff를 통해 전달됩니다.
/* include/linux/skbuff.h — 타임스탬프 관련 구조체 */
struct skb_shared_hwtstamps {
union {
ktime_t hwtstamp; /* HW 타임스탬프 (NIC PHY) */
void *netdev_data; /* 드라이버별 데이터 */
};
};
/* skb에서 HW 타임스탬프 접근 */
static inline struct skb_shared_hwtstamps *skb_hwtstamps(
struct sk_buff *skb)
{
return &skb_shinfo(skb)->hwtstamps;
}
/* NIC 드라이버: 수신 시 HW 타임스탬프 기록 */
static void my_nic_rx_hwtstamp(struct sk_buff *skb,
u64 hw_ns)
{
struct skb_shared_hwtstamps *hwts = skb_hwtstamps(skb);
hwts->hwtstamp = ns_to_ktime(hw_ns);
/* → recvmsg()에서 SOF_TIMESTAMPING_RAW_HARDWARE cmsg로 전달 */
}
/* 사용자 공간: SO_TIMESTAMPING 설정 */
int flags = SOF_TIMESTAMPING_RX_HARDWARE /* 수신 HW 타임스탬프 */
| SOF_TIMESTAMPING_TX_HARDWARE /* 전송 HW 타임스탬프 */
| SOF_TIMESTAMPING_RAW_HARDWARE /* 원시 HW 시각 (PTP 클럭) */
| SOF_TIMESTAMPING_SOFTWARE /* SW 타임스탬프 */
| SOF_TIMESTAMPING_OPT_TSONLY; /* 타임스탬프만 (페이로드 생략) */
setsockopt(fd, SOL_SOCKET, SO_TIMESTAMPING, &flags, sizeof(flags));
/* recvmsg()로 타임스탬프 수신 */
struct msghdr msg = { .msg_control = cbuf, .msg_controllen = sizeof(cbuf) };
recvmsg(fd, &msg, 0);
/* cmsg에서 타임스탬프 추출 */
struct cmsghdr *cm;
for (cm = CMSG_FIRSTHDR(&msg); cm; cm = CMSG_NXTHDR(&msg, cm)) {
if (cm->cmsg_level == SOL_SOCKET &&
cm->cmsg_type == SO_TIMESTAMPING) {
struct timespec *ts = (struct timespec *)CMSG_DATA(cm);
/* ts[0] = SW 타임스탬프 (SOF_TIMESTAMPING_SOFTWARE)
* ts[1] = 예약 (사용 안 함)
* ts[2] = HW 타임스탬프 (SOF_TIMESTAMPING_RAW_HARDWARE) */
printf("HW: %ld.%09ld
", ts[2].tv_sec, ts[2].tv_nsec);
}
}
/* TX 타임스탬프: 전송 완료 시 errqueue에서 수신 */
recvmsg(fd, &msg, MSG_ERRQUEUE);
/* → SOF_TIMESTAMPING_TX_HARDWARE cmsg에 전송 시각 포함
* → NIC 드라이버가 TX 완료 인터럽트에서 HW 타임스탬프 기록 */
| 타임스탬프 종류 | 정밀도 | 지연 소스 | 용도 |
|---|---|---|---|
| HW 타임스탬프 (PHY) | ~1ns | NIC PTP 클럭 | PTP 동기화, 금융 트레이딩 |
| SW 타임스탬프 (커널) | ~1μs | ktime_get_real() (softirq) | 일반 지연 측정, tcpdump |
| TX SCHED | ~1μs | qdisc 진입 시점 | 큐잉 지연 측정 |
| TX ACK (TCP) | ~1μs | ACK 수신 시점 | RTT 측정 |
PTP 하드웨어 지원 확인: ethtool -T eth0으로 NIC의 HW 타임스탬핑 지원 여부를 확인합니다. hardware-transmit/hardware-receive/hardware-raw-clock이 표시되면 HW 타임스탬프를 사용할 수 있습니다. Intel i210/i225, Mellanox ConnectX-4+, Broadcom BCM57416 등이 대표적인 PTP 지원 NIC입니다.
NAPI와 GRO 상세 흐름
NAPI(New API)는 인터럽트(Interrupt)와 폴링을 결합하여 고속 패킷 수신을 효율적으로 처리합니다. GRO(Generic Receive Offload)는 NAPI poll 내부에서 동일 플로우의 패킷들을 하나의 큰 sk_buff로 병합하여 프로토콜 스택 처리 오버헤드를 줄입니다. 이 두 메커니즘은 현대 Linux 네트워크 성능의 핵심 축입니다.
/* include/linux/netdevice.h — NAPI 구조체 */
struct napi_struct {
struct list_head poll_list; /* softirq 폴링 리스트 */
unsigned long state; /* NAPI_STATE_SCHED 등 */
int weight; /* 한 번 poll에서 처리할 최대 패킷 수 (기본 64) */
int defer_hard_irqs_count;
unsigned long gro_bitmask; /* GRO 활성 프로토콜 비트맵 */
int (*poll)(struct napi_struct *, int); /* 드라이버 poll 함수 */
struct list_head rx_list; /* GRO 병합 완료 skb 리스트 */
int rx_count; /* rx_list 내 skb 수 */
struct gro_list gro_hash[GRO_HASH_BUCKETS]; /* GRO 해시 테이블 */
};
/* 드라이버 poll 함수 패턴 */
static int my_driver_poll(struct napi_struct *napi, int budget)
{
int work_done = 0;
while (work_done < budget) {
struct sk_buff *skb = my_rx_one(napi);
if (!skb)
break;
/* GRO에 전달: 동일 플로우 병합 시도 */
napi_gro_receive(napi, skb);
work_done++;
}
if (work_done < budget) {
/* budget을 다 쓰지 않음 → 패킷이 없음 → IRQ 재활성화 */
napi_complete_done(napi, work_done);
/* → gro_list flush → IRQ unmask */
}
/* budget 소진 → softirq가 다시 poll 호출 예정 */
return work_done;
}
/* GRO 내부: 병합 판단 로직 (net/core/gro.c) */
static enum gro_result dev_gro_receive(struct napi_struct *napi,
struct sk_buff *skb)
{
/* 1. 프로토콜별 GRO 콜백 호출 (inet_gro_receive 등) */
/* 2. gro_hash[]에서 동일 플로우 검색 (5-tuple 매칭) */
/* 3. 매칭 결과에 따라: */
if (same_flow && !flush) {
/* GRO_MERGED: 기존 skb에 새 패킷을 frag로 추가
* → skb_shinfo(p)->frag_list에 연결
* → p->len += skb->len (super-packet 크기 증가)
* → NAPI_GRO_CB(p)->count++ */
return GRO_MERGED;
}
if (same_flow && flush) {
/* 플로우는 같지만 병합 불가 (PSH 플래그, 순서 불일치 등)
* → 기존 skb를 flush하고 새 skb를 gro_hash에 등록 */
napi_gro_complete(napi, pp);
}
/* GRO_HELD: 새 플로우 → gro_hash에 등록하고 대기
* → 같은 플로우의 후속 패킷이 올 때까지 보류 */
list_add(&skb->list, &napi->gro_hash[hash].list);
return GRO_HELD;
}
/* GRO 병합 완료 → 일반 스택으로 전달 */
static void napi_gro_complete(struct napi_struct *napi,
struct sk_buff *skb)
{
/* 프로토콜별 GRO complete 콜백 */
/* → TCP: tcp_gro_complete() — 헤더 보정, 체크섬 설정
* → skb->ip_summed = CHECKSUM_UNNECESSARY (병합된 패킷) */
/* gro_normal_one(): rx_list에 추가 */
gro_normal_one(napi, skb, NAPI_GRO_CB(skb)->count);
/* → rx_count >= gro_normal_batch(8) 이면 배치 전달:
* gro_normal_list() → netif_receive_skb_list()
* → 한 번의 함수 호출로 여러 skb를 스택에 전달 */
}
코드 설명
- napi_struct
include/linux/netdevice.h에 정의된 NAPI 구조체입니다.weight(기본 64)는 한 번의 poll에서 처리할 최대 패킷 수이며,gro_hash[]는 GRO 병합 대기 중인 skb를 플로우별로 관리하는 해시 테이블입니다. - 드라이버 poll 루프budget만큼 반복하면서
napi_gro_receive()로 각 skb를 GRO에 전달합니다. budget을 소진하지 않으면(work_done < budget) 패킷이 더 없다는 뜻이므로napi_complete_done()으로 인터럽트를 재활성화합니다. - dev_gro_receive()
net/core/gro.c에서 GRO 병합을 결정하는 핵심 함수입니다. 프로토콜별 콜백(inet_gro_receive()→tcp4_gro_receive())이 5-tuple 매칭과 병합 가능 여부를 판단합니다. - GRO_MERGED같은 플로우의 기존 skb에 새 패킷을
frag_list로 연결하고len을 합산합니다. 최대 64KB까지 병합하여 프로토콜 스택 진입 횟수를 대폭 줄입니다.NAPI_GRO_CB(p)->count로 병합된 패킷 수를 추적합니다. - napi_gro_complete()병합 완료된 super-skb를 일반 네트워크 스택으로 전달합니다.
gro_normal_one()이rx_list에 추가하고,gro_normal_batch(기본 8) 이상 누적되면netif_receive_skb_list()로 배치 전달하여 함수 호출 오버헤드를 최소화합니다.
| GRO 파라미터 | 기본값 | 조절 방법 | 영향 |
|---|---|---|---|
| NAPI weight | 64 | netif_napi_add(dev, napi, poll, weight) | poll당 처리 패킷 수. 높으면 throughput↑, latency↑ |
| gro_flush_timeout | 0 (즉시) | sysctl net.core.gro_flush_timeout | 0이 아니면 타이머(Timer)로 flush → 병합 기회 증가 |
| gro_normal_batch | 8 | sysctl net.core.gro_normal_batch | 배치 전달 크기. 높으면 처리량↑, 지연↑ |
| netdev_budget | 300 | sysctl net.core.netdev_budget | softirq당 전체 NAPI 처리 패킷 상한 |
| busy_poll | 0 (off) | sysctl net.core.busy_poll | 소켓별 busy polling 시간 (μs) |
# GRO 병합 효과 확인
$ ethtool -S eth0 | grep gro
rx_gro_packets: 1234567 # GRO 처리된 패킷 수
rx_gro_bytes: 987654321 # GRO 처리된 바이트
# NAPI 통계 확인
$ cat /proc/net/softnet_stat
# 컬럼: processed, dropped, time_squeeze, ..., cpu_collision, received_rps, flow_limit_count
# time_squeeze > 0: budget/time 부족으로 처리 중단 → netdev_budget 증가 고려
# Busy polling 활성화 (저지연 용도)
$ sysctl -w net.core.busy_poll=50 # 50μs 폴링
$ sysctl -w net.core.busy_read=50 # 읽기 시 50μs 폴링
# GRO flush 타임아웃 설정 (병합 기회 증가)
$ sysctl -w net.core.gro_flush_timeout=20000 # 20μs
# per-NAPI 설정 (커널 6.x+)
$ echo 100 > /sys/class/net/eth0/napi/0/gro_flush_timeout
$ echo 16 > /sys/class/net/eth0/napi/0/defer_hard_irqs
GRO와 Netfilter 상호작용: GRO로 병합된 super-packet은 skb->len이 64KB에 달할 수 있습니다. 이 상태로 Netfilter를 통과하면 conntrack 등이 정상 동작하지만, iptables -m length 같은 패킷 길이 기반 규칙은 예상과 다르게 동작할 수 있습니다. 필요시 ethtool -K eth0 gro off로 비활성화하거나, nftables의 @th 표현식으로 개별 세그먼트 길이를 확인하세요.
GSO/TSO 분할 메커니즘 상세
GSO(Generic Segmentation Offload)는 커널이 대용량 sk_buff를 NIC의 MTU에 맞는 작은 세그먼트로 분할하는 메커니즘입니다. NIC이 TSO(TCP Segmentation Offload)를 지원하면 하드웨어가 분할하고, 미지원 시 커널의 skb_segment()가 소프트웨어로 처리합니다. 이 과정에서 skb_shared_info의 GSO 필드가 핵심 역할을 합니다.
/* skb_shared_info의 GSO 관련 필드 */
struct skb_shared_info {
unsigned short gso_size; /* 세그먼트 크기 (MSS) */
unsigned short gso_segs; /* 세그먼트 수 */
unsigned short gso_type; /* GSO 타입 비트맵 */
/* ... */
};
/* GSO 타입 상수 */
#define SKB_GSO_TCPV4 (1 << 0) /* TCP/IPv4 분할 */
#define SKB_GSO_TCPV6 (1 << 4) /* TCP/IPv6 분할 */
#define SKB_GSO_UDP_L4 (1 << 17) /* UDP L4 분할 (4.18+) */
#define SKB_GSO_GRE (1 << 6) /* GRE 터널 내부 분할 */
#define SKB_GSO_GRE_CSUM (1 << 7) /* GRE+체크섬 */
#define SKB_GSO_UDP_TUNNEL (1 << 9) /* VXLAN/Geneve */
#define SKB_GSO_UDP_TUNNEL_CSUM (1 << 10) /* 터널+체크섬 */
#define SKB_GSO_PARTIAL (1 << 13) /* 부분 GSO (외부 헤더만 HW) */
/* skb_segment(): SW GSO 분할 핵심 함수 (net/core/skbuff.c) */
struct sk_buff *skb_segment(struct sk_buff *head_skb,
netdev_features_t features)
{
struct sk_buff *segs = NULL;
unsigned int mss = skb_shinfo(head_skb)->gso_size;
unsigned int doffset = head_skb->data - skb_mac_header(head_skb);
unsigned int offset = doffset;
unsigned int tnl_hlen, headroom;
unsigned int len, nfrags;
/* 각 세그먼트에 대해: */
do {
struct sk_buff *nskb;
int hsize, size;
/* 1. 새 skb 할당 */
nskb = alloc_skb(hsize + doffset + headroom, GFP_ATOMIC);
/* 2. L2+L3+L4 헤더 복사 (공통 헤더) */
skb_copy_from_linear_data(head_skb, skb_put(nskb, doffset),
doffset);
/* 3. 페이로드를 mss 크기만큼 복사/참조 */
if (!sg && !nskb->remcsum_offload) {
/* linear 복사 */
skb_copy_from_linear_data_offset(head_skb, offset,
skb_put(nskb, size), size);
} else {
/* SG: page fragment 참조 (zero-copy) */
skb_fill_page_desc(nskb, i, frag->bv_page,
frag->bv_offset, frag_size);
}
/* 4. 각 세그먼트 고유 필드 설정 */
skb_shinfo(nskb)->gso_size = 0; /* 더 이상 GSO 아님 */
skb_shinfo(nskb)->gso_segs = 0;
skb_shinfo(nskb)->gso_type = 0;
/* 5. IP 헤더: tot_len 갱신, id 순차 증가 */
/* 6. TCP 헤더: seq 순차 증가, 마지막 seg에만 PSH */
offset += size;
} while (offset < head_skb->len);
return segs; /* 분할된 skb 체인 (next 포인터 연결) */
}
/* GSO 분할 트리거 지점: dev_queue_xmit() → validate_xmit_skb() */
static struct sk_buff *validate_xmit_skb(struct sk_buff *skb,
struct net_device *dev, bool *again)
{
netdev_features_t features = dev->features;
if (skb_is_gso(skb)) {
/* NIC이 해당 GSO 타입을 지원하는지 확인 */
if (skb_gso_ok(skb, features))
return skb; /* HW TSO: 그대로 전달 */
/* SW GSO: 커널에서 분할 */
struct sk_buff *segs = skb_gso_segment(skb, features);
consume_skb(skb); /* 원본 super-packet 해제 */
return segs;
}
return skb;
}
/* GSO 관련 유틸리티 함수 */
static inline bool skb_is_gso(const struct sk_buff *skb) {
return skb_shinfo(skb)->gso_size;
}
static inline unsigned int skb_gso_network_seglen(const struct sk_buff *skb) {
/* gso_size + L4 헤더 + L3 헤더 = 실제 세그먼트의 IP 총 길이 */
return skb_shinfo(skb)->gso_size +
skb_network_header_len(skb) + skb_transport_header_len(skb);
}
| GSO 타입 | 프로토콜 | 커널 버전 | 특이사항 |
|---|---|---|---|
SKB_GSO_TCPV4 | TCP/IPv4 | 2.6+ | 가장 기본적인 TSO. 대부분 NIC이 HW 지원 |
SKB_GSO_TCPV6 | TCP/IPv6 | 2.6+ | IPv6 확장 헤더가 있으면 SW fallback 가능 |
SKB_GSO_UDP_L4 | UDP | 4.18+ | UDP GSO (USO). QUIC 등에서 활용. HW 지원 희소 |
SKB_GSO_GRE | GRE 터널(Tunnel) | 3.10+ | 외부 GRE 헤더 + 내부 TCP 분할 |
SKB_GSO_UDP_TUNNEL | VXLAN/Geneve | 3.12+ | 외부 UDP + 내부 TCP 분할 |
SKB_GSO_PARTIAL | 다양 | 4.7+ | 외부 헤더만 HW, 내부는 SW. tunnel GSO 최적화 |
SKB_GSO_SCTP | SCTP | 4.15+ | SCTP 청크 기반 분할 |
GSO vs TSO 성능 비교: HW TSO는 CPU 비용이 0에 가깝습니다(DMA 한 번으로 64KB 전송). SW GSO는 skb_segment()에서 세그먼트 수만큼 메모리 할당+헤더 복사가 필요하지만, 그래도 사용자 공간에서 sendmsg()를 45번 호출하는 것보다 훨씬 효율적입니다. 시스콜 오버헤드를 한 번으로 줄이는 것이 GSO의 핵심 이점입니다. ethtool -k eth0 | grep segmentation으로 HW 지원 여부를 확인하세요.
skb_shared_info: frags[] vs frag_list 상세
sk_buff의 비선형 데이터는 두 가지 방식으로 표현됩니다: frags[](page fragment 배열)과 frag_list(skb 체인). 이 두 구조는 목적과 사용 상황이 완전히 다르며, 혼동하면 심각한 버그가 발생합니다.
| 특성 | frags[] (page fragments) | frag_list (skb chain) |
|---|---|---|
| 저장 형태 | skb_frag_t 배열 (page+offset+size) | struct sk_buff 연결 리스트(Linked List) |
| 최대 개수 | MAX_SKB_FRAGS (보통 17) | 제한 없음 |
| DMA SG | 직접 SG 매핑 가능 | 불가 — linearize 또는 변환 필요 |
| 오버헤드 | frag당 16바이트 (page+offset+size) | skb당 ~240바이트 (전체 sk_buff) |
| 주요 사용처 | NIC RX (skb_add_rx_frag), sendfile, splice | GRO 병합, IP defrag, GSO 분할 결과 |
| 데이터 접근 | skb_frag_page(), skb_frag_off() | skb_walk_frags(skb, frag_skb) |
| len/data_len | data_len = frags 총합 | data_len = frag_list skb들의 len 총합 |
/* frags[] 접근 패턴 */
struct skb_shared_info *si = skb_shinfo(skb);
for (int i = 0; i < si->nr_frags; i++) {
skb_frag_t *f = &si->frags[i];
struct page *page = skb_frag_page(f);
unsigned int off = skb_frag_off(f);
unsigned int sz = skb_frag_size(f);
/* kmap_local_page(page) + off 로 데이터 접근 */
}
/* frag_list 순회 패턴 */
struct sk_buff *frag_iter;
skb_walk_frags(skb, frag_iter) {
/* frag_iter는 frag_list의 각 skb */
process_fragment(frag_iter->data, frag_iter->len);
}
/* 전체 skb 데이터를 순차 복사하는 범용 함수 */
/* skb_copy_bits(): linear + frags[] + frag_list 모두 처리 */
int skb_copy_bits(const struct sk_buff *skb, int offset,
void *to, int len)
{
/* 1. linear 영역에서 복사 */
/* 2. frags[]에서 복사 */
/* 3. frag_list의 각 skb에서 재귀적으로 복사 */
}
/* MAX_SKB_FRAGS 계산 */
#define MAX_SKB_FRAGS (65536 / PAGE_SIZE + 1)
/* PAGE_SIZE=4096 → MAX_SKB_FRAGS=17
* 64KB 데이터를 frags로 표현하는 데 필요한 최대 페이지 수
* +1은 페이지 경계 걸침 고려 */
frags[]와 frag_list 혼용 주의: 하나의 skb에 frags[]와 frag_list가 동시에 존재할 수 있습니다. skb->data_len은 두 영역의 합산입니다. skb_linearize()는 모든 비선형 데이터(frags[] + frag_list)를 linear 영역으로 합치므로, 대용량 패킷에서 호출하면 거대한 연속 메모리 할당이 필요해 실패할 수 있습니다. GRO로 병합된 64KB super-packet에 skb_linearize()를 호출하는 것은 안티패턴입니다.
VLAN 태그 처리와 sk_buff
Linux 커널은 VLAN 태그를 두 가지 방식으로 처리합니다: 하드웨어 가속(HW VLAN acceleration)과 소프트웨어 처리. NIC이 VLAN 태그를 추출/삽입하는 HW 가속 방식이 더 효율적이며, 대부분의 현대 NIC이 지원합니다.
/* sk_buff의 VLAN 필드 */
struct sk_buff {
__be16 vlan_proto; /* VLAN 프로토콜 (0x8100 또는 0x88a8) */
__u16 vlan_tci; /* TCI: PCP(3) + DEI(1) + VID(12) */
/* vlan_present는 6.x에서 vlan_all로 통합 */
};
/* NIC 드라이버: HW VLAN 가속 — 수신 시 */
static void my_nic_rx(struct napi_struct *napi, u16 rx_vlan)
{
struct sk_buff *skb = napi_alloc_skb(napi, 256);
/* ... DMA 데이터 복사 (VLAN 태그 없는 프레임) ... */
if (rx_vlan) {
/* NIC이 추출한 VLAN 태그를 skb 메타에 저장 */
__vlan_hwaccel_put_tag(skb, htons(ETH_P_8021Q), rx_vlan);
/* → skb->vlan_proto = ETH_P_8021Q
* → skb->vlan_tci = rx_vlan
* → 패킷 데이터에는 VLAN 태그 없음 */
}
napi_gro_receive(napi, skb);
}
/* VLAN 태그 확인/추출 */
if (skb_vlan_tag_present(skb)) {
u16 vid = skb_vlan_tag_get_id(skb); /* VID (0~4095) */
u16 prio = skb_vlan_tag_get_prio(skb); /* PCP (0~7) */
}
/* VLAN 태그 추가/제거 (소프트웨어) */
skb_vlan_push(skb, htons(ETH_P_8021Q), vid | (prio << 13));
/* → 패킷 데이터에 4B VLAN 태그 삽입, headroom 필요 */
skb_vlan_pop(skb);
/* → 패킷에서 VLAN 태그 제거, skb 메타로 이동 */
/* QinQ (802.1ad): 이중 VLAN 태그 */
/* 외부 VLAN: skb->vlan_proto = ETH_P_8021AD, skb->vlan_tci = outer */
/* 내부 VLAN: 패킷 데이터 내 ETH_P_8021Q 태그로 존재 */
skb_vlan_push(skb, htons(ETH_P_8021AD), outer_vid);
/* → 외부 S-tag + 내부 C-tag 이중 태그 구성 */
TCP의 sk_buff 분할/병합/재전송
TCP는 sk_buff를 가장 정교하게 활용하는 프로토콜입니다. 전송 큐의 skb를 MSS 단위로 분할하고, 수신 경로에서 인접 세그먼트를 병합하며, 재전송 시 skb를 재활용합니다. 이 과정에서 TCP_SKB_CB()를 통한 cb[] 활용이 핵심입니다.
/* tcp_fragment(): 하나의 skb를 두 개로 분할
* 용도: MSS 변경, SACK 기반 부분 재전송, cwnd 축소 시
* net/ipv4/tcp_output.c */
int tcp_fragment(struct sock *sk, enum tcp_queue tcp_queue,
struct sk_buff *skb, u32 len, unsigned int mss_now,
gfp_t gfp)
{
struct sk_buff *buff;
int old_factor;
/* 새 skb 할당 (뒷부분 데이터용) */
buff = sk_stream_alloc_skb(sk, 0, gfp, 0);
/* 페이로드 분할: skb의 len 이후 데이터를 buff로 이동 */
skb_split(skb, buff, len);
/* TCP_SKB_CB 갱신: 시퀀스 번호 분할 */
TCP_SKB_CB(buff)->seq = TCP_SKB_CB(skb)->seq + len;
TCP_SKB_CB(buff)->end_seq = TCP_SKB_CB(skb)->end_seq;
TCP_SKB_CB(skb)->end_seq = TCP_SKB_CB(buff)->seq;
/* GSO 세그먼트 수 재계산 */
tcp_set_skb_tso_segs(skb, mss_now);
tcp_set_skb_tso_segs(buff, mss_now);
/* 전송 큐에서 skb 뒤에 buff 삽입 */
skb_append(skb, buff, &sk->sk_write_queue);
return 0;
}
/* tcp_try_coalesce(): 인접 수신 skb를 하나로 병합
* 용도: RX 경로에서 연속 세그먼트 병합 → 소켓 큐 skb 수 감소
* net/ipv4/tcp_input.c */
static bool tcp_try_coalesce(struct sock *sk,
struct sk_buff *to,
struct sk_buff *from,
bool *fragstolen)
{
/* from의 데이터를 to에 병합 가능한지 확인 */
if (TCP_SKB_CB(from)->seq != TCP_SKB_CB(to)->end_seq)
return false; /* 연속이 아님 */
if (!skb_try_coalesce(to, from, fragstolen, &delta))
return false; /* 메모리/frag 제한 초과 */
/* skb_try_coalesce: from의 frags를 to의 frags[]에 추가
* → from은 해제 가능, to->len 증가 */
TCP_SKB_CB(to)->end_seq = TCP_SKB_CB(from)->end_seq;
TCP_SKB_CB(to)->ack_seq = TCP_SKB_CB(from)->ack_seq;
return true;
}
/* tcp_collapse(): OOO(Out-of-Order) 큐에서 중복/겹침 제거
* 용도: OOO 큐의 skb가 과도하게 쌓일 때 메모리 절약
* net/ipv4/tcp_input.c */
static void tcp_collapse(struct sock *sk,
struct sk_buff_head *list,
struct rb_root *root,
struct sk_buff *head,
struct sk_buff *tail,
u32 start, u32 end)
{
/* start~end 범위의 skb들을 하나의 skb로 합침
* → 겹치는 시퀀스 번호는 제거
* → OOO 큐의 메모리 사용량 감소
* → tcp_prune_ofo_queue()에서 메모리 압박 시 호출 */
}
/* TCP 재전송: 기존 skb 재활용 */
int __tcp_retransmit_skb(struct sock *sk, struct sk_buff *skb, int segs)
{
/* 1. skb가 clone 상태면 pskb_copy()로 헤더 독립화 */
if (skb_cloned(skb)) {
struct sk_buff *nskb = pskb_copy(skb, GFP_ATOMIC);
/* 원본을 큐에서 교체 */
}
/* 2. TCP 헤더 재구성 (seq, ack, window, timestamp) */
tcp_transmit_skb(sk, skb, 1, GFP_ATOMIC);
/* clone_it=1: 재전송 큐에 남기면서 clone 전송 */
}
| TCP skb 연산 | 함수 | 트리거 조건 | skb 변화 |
|---|---|---|---|
| 분할 | tcp_fragment() | MSS 축소, SACK partial retx | 1개 skb → 2개 (seq 분할) |
| 수신 병합 | tcp_try_coalesce() | 연속 세그먼트 수신 | 2개 skb → 1개 (frags 합체) |
| OOO 압축 | tcp_collapse() | OOO 큐 메모리 압박 | N개 skb → 1개 (데이터 병합) |
| 재전송 | __tcp_retransmit_skb() | RTO, SACK, TLP | 기존 skb clone 후 재전송 |
| GSO 생성 | tcp_write_xmit() | cwnd 허용, TSQ 미달 | 여러 MSS를 하나의 GSO skb로 |
BPF/TC의 __sk_buff 컨텍스트
eBPF 프로그램(TC classifier, socket filter)은 커널의 struct sk_buff에 직접 접근하지 않고, 안전한 래퍼인 struct __sk_buff를 통해 접근합니다. BPF 검증기(verifier)가 이 구조체의 필드 접근을 커널 내부 sk_buff 필드로 변환합니다.
/* include/uapi/linux/bpf.h — BPF 프로그램이 보는 skb 뷰 */
struct __sk_buff {
__u32 len; /* skb->len */
__u32 pkt_type; /* skb->pkt_type */
__u32 mark; /* skb->mark (읽기/쓰기) */
__u32 queue_mapping; /* skb->queue_mapping */
__u32 protocol; /* skb->protocol */
__u32 vlan_present; /* skb_vlan_tag_present(skb) */
__u32 vlan_tci; /* skb->vlan_tci */
__u32 vlan_proto; /* skb->vlan_proto */
__u32 priority; /* skb->priority (읽기/쓰기) */
__u32 ingress_ifindex; /* skb->skb_iif */
__u32 ifindex; /* skb->dev->ifindex */
__u32 tc_index; /* skb->tc_index */
__u32 cb[5]; /* skb->cb[] (TC에서 사용) */
__u32 hash; /* skb->hash */
__u32 tc_classid; /* skb->tc_classid (쓰기) */
__u32 data; /* skb->data 포인터 (패킷 시작) */
__u32 data_end; /* skb->data + skb_headlen(skb) */
__u32 napi_id; /* skb->napi_id */
__u32 family; /* sk->sk_family */
__u32 data_meta; /* skb->data - skb_metadata_len */
__u32 flow_keys; /* flow dissector 결과 */
__u64 tstamp; /* skb->tstamp (읽기/쓰기) */
__u32 wire_len; /* 원래 와이어 길이 (GSO 이전) */
__u32 gso_segs; /* skb_shinfo(skb)->gso_segs */
__u64 hwtstamp; /* skb_hwtstamps(skb)->hwtstamp */
};
/* BPF 검증기: __sk_buff 필드 접근 → 실제 skb 오프셋 변환
* net/core/filter.c — bpf_convert_ctx_access() */
static u32 bpf_convert_ctx_access(...)
{
switch (si->off) {
case offsetof(struct __sk_buff, len):
/* __sk_buff.len → skb->len 직접 매핑 */
*insn++ = BPF_LDX_MEM(BPF_W, si->dst_reg, si->src_reg,
offsetof(struct sk_buff, len));
break;
case offsetof(struct __sk_buff, data):
/* __sk_buff.data → skb->data 포인터 로드 */
*insn++ = BPF_LDX_MEM(BPF_FIELD_SIZEOF(struct sk_buff, data),
si->dst_reg, si->src_reg,
offsetof(struct sk_buff, data));
break;
}
}
/* TC-BPF 프로그램에서 skb 패킷 데이터 직접 접근 */
SEC("tc")
int tc_filter(struct __sk_buff *skb)
{
void *data = (void *)(long)skb->data;
void *data_end = (void *)(long)skb->data_end;
/* 패킷 데이터 직접 접근 (bounds check 필수!) */
struct ethhdr *eth = data;
if (data + sizeof(*eth) > data_end)
return TC_ACT_OK;
/* skb 필드 수정 */
skb->mark = 42; /* → skb->mark = 42 (netfilter/tc 마킹) */
skb->priority = 7; /* → skb->priority = 7 (QoS) */
/* 패킷 데이터 수정: bpf_skb_store_bytes() 헬퍼 사용 */
__u8 new_ttl = 64;
bpf_skb_store_bytes(skb, ETH_HLEN + offsetof(struct iphdr, ttl),
&new_ttl, sizeof(new_ttl), 0);
/* 헤더 축소/확장: encap/decap */
bpf_skb_adjust_room(skb, -14, BPF_ADJ_ROOM_MAC, 0);
/* → skb_pull(14) 효과: L2 헤더 제거 */
return TC_ACT_OK;
}
/* 주요 BPF skb 헬퍼 함수 */
/* bpf_skb_load_bytes() — 오프셋에서 N바이트 로드 (비선형 안전) */
/* bpf_skb_store_bytes() — 오프셋에 N바이트 저장 */
/* bpf_skb_pull_data() — pskb_may_pull() 래퍼 */
/* bpf_skb_change_head() — headroom 변경 (encap) */
/* bpf_skb_change_tail() — tailroom 변경 */
/* bpf_skb_adjust_room() — MAC/NET 레벨 크기 조정 */
/* bpf_skb_vlan_push/pop() — VLAN 태그 추가/제거 */
/* bpf_skb_change_proto() — L3 프로토콜 변경 (IPv4↔IPv6) */
/* bpf_skb_cgroup_id() — cgroup ID 조회 */
/* bpf_skb_get_tunnel_key() — 터널 메타데이터 조회 */
/* bpf_redirect() — 다른 인터페이스로 리다이렉트 */
/* bpf_clone_redirect() — clone 후 리다이렉트 */
direct packet access vs 헬퍼: data/data_end를 통한 직접 접근은 linear 영역만 가능합니다. 비선형 데이터(frags/frag_list)에 접근하려면 bpf_skb_pull_data(skb, offset)로 먼저 linearize하거나, bpf_skb_load_bytes() 헬퍼를 사용해야 합니다. 직접 접근이 더 빠르지만, GRO로 병합된 대용량 패킷은 헤더만 linear이므로 페이로드 파싱 시 헬퍼가 필요합니다.
Flow Dissector와 RSS/RPS 해시
Flow dissector는 skb에서 프로토콜 헤더를 파싱하여 플로우 키(5-tuple 등)를 추출하는 커널 프레임워크입니다. 추출된 키는 skb->hash에 저장되어 RSS(Receive Side Scaling), RPS(Receive Packet Steering), GRO 병합, 소켓 lookup 등 다양한 곳에서 플로우 기반 분배에 사용됩니다.
/* include/net/flow_dissector.h — 플로우 키 구조체 */
struct flow_keys {
struct flow_dissector_key_control control;
struct flow_dissector_key_basic basic; /* n_proto, ip_proto */
struct flow_dissector_key_addrs addrs; /* saddr, daddr */
struct flow_dissector_key_ports ports; /* sport, dport */
/* VLAN, GRE, MPLS 키도 포함 가능 */
};
/* skb->hash 계산 (lazy — 처음 접근 시 계산) */
static inline __u32 skb_get_hash(struct sk_buff *skb)
{
if (!skb->l4_hash && !skb->sw_hash)
__skb_get_hash(skb); /* flow dissector 실행 */
return skb->hash;
}
/* RPS: SW 기반 CPU 분배 (net/core/dev.c) */
static int get_rps_cpu(struct net_device *dev,
struct sk_buff *skb,
struct rps_dev_flow **rflowp)
{
u32 hash = skb_get_hash(skb);
/* hash를 CPU 수로 나눠 대상 CPU 결정 */
u32 cpu = reciprocal_scale(hash, cpumask_weight(rps_mask));
return cpu;
}
| 해시 소스 | 설정 방법 | 성능 | 커스터마이즈 |
|---|---|---|---|
| NIC RSS (HW) | ethtool -X eth0 hkey/hfunc | 최고 (HW 처리) | 해시 키, 해시 함수, indirection table |
| RPS (SW) | /sys/class/net/eth0/queues/rx-0/rps_cpus | 양호 (softirq) | CPU 비트맵 |
| RFS (Flow Steering) | /proc/sys/net/core/rps_sock_flow_entries | 양호 | 앱이 실행 중인 CPU로 스티어링 |
| XPS (TX) | /sys/class/net/eth0/queues/tx-0/xps_cpus | TX 큐 선택 | CPU→TX 큐 매핑 |
Encapsulation/Tunnel과 sk_buff
터널 프로토콜(GRE, VXLAN, Geneve, WireGuard)은 sk_buff에 외부 헤더를 추가(encap)하거나 제거(decap)합니다. 이 과정에서 headroom 관리, 헤더 포인터 재설정, inner/outer 프로토콜 구분이 핵심입니다.
/* VXLAN encapsulation 흐름 (drivers/net/vxlan/vxlan_core.c) */
static void vxlan_xmit_one(struct sk_buff *skb, ...)
{
int headroom = sizeof(struct iphdr) /* 20B outer IP */
+ sizeof(struct udphdr) /* 8B outer UDP */
+ sizeof(struct vxlanhdr) /* 8B VXLAN */
+ LL_RESERVED_SPACE(dst->dev); /* outer L2 */
/* 1. headroom 확보 (clone이면 독립화) */
if (skb_cow_head(skb, headroom)) {
kfree_skb(skb);
return;
}
/* 2. skb->inner_* 헤더 포인터 저장 (decap 시 복원용) */
skb_set_inner_protocol(skb, skb->protocol);
skb_set_inner_network_header(skb, skb_network_offset(skb));
skb_set_inner_transport_header(skb, skb_transport_offset(skb));
/* 3. encapsulation 플래그 설정 */
skb->encapsulation = 1;
/* 4. VXLAN 헤더 추가 */
struct vxlanhdr *vxh = (struct vxlanhdr *)__skb_push(skb, sizeof(*vxh));
vxh->vx_flags = htonl(VXLAN_HF_VNI);
vxh->vx_vni = vxlan_vni_field(vni);
/* 5. 외부 UDP 헤더 */
udp_set_csum(skb, ...);
/* 6. 외부 IP 헤더 → ip_tunnel_xmit() */
iptunnel_xmit(..., skb, ...);
/* → skb_push(IP 헤더) → skb_reset_network_header()
* → ip_local_out() → Netfilter OUTPUT → dev_queue_xmit() */
}
/* Decapsulation: 외부 헤더 제거 후 inner 헤더 복원 */
static int vxlan_rcv(struct sock *sk, struct sk_buff *skb)
{
/* 1. VXLAN 헤더 파싱 및 VNI 추출 */
struct vxlanhdr *vxh = vxlan_hdr(skb);
/* 2. 외부 헤더 제거 */
__skb_pull(skb, sizeof(struct vxlanhdr));
skb_reset_network_header(skb); /* inner IP로 재설정 */
/* 3. inner 패킷으로 프로토콜 재설정 */
skb->protocol = eth_type_trans(skb, vxlan->dev);
skb->encapsulation = 0;
/* 4. 일반 스택으로 재진입 */
netif_rx(skb);
}
/* sk_buff의 inner 헤더 포인터 */
struct sk_buff {
sk_buff_data_t inner_transport_header; /* 내부 L4 */
sk_buff_data_t inner_network_header; /* 내부 L3 */
sk_buff_data_t inner_mac_header; /* 내부 L2 */
__be16 inner_protocol; /* 내부 프로토콜 */
__u8 encapsulation:1; /* 캡슐화 여부 */
};
터널과 GSO 상호작용: skb->encapsulation = 1이면 GSO/체크섬 오프로드가 inner 패킷 기준으로 동작합니다. NIC이 NETIF_F_GSO_UDP_TUNNEL을 지원하면 HW가 외부 UDP + 내부 TCP를 한 번에 분할합니다. 미지원 NIC에서는 SKB_GSO_PARTIAL을 사용하여 외부 헤더만 SW로, 내부 분할은 HW로 처리하는 하이브리드 방식이 가능합니다(4.7+).
sk_buff 할당 내부 (kmem_cache)
sk_buff의 메모리 할당은 일반 kmalloc()이 아닌 전용 SLAB 캐시(skbuff_head_cache)를 사용합니다. 이는 빈번한 할당/해제에 최적화되어 있으며, fclone(fast clone) 메커니즘으로 clone 비용을 더 줄입니다.
/* net/core/skbuff.c — sk_buff SLAB 캐시 초기화 */
static struct kmem_cache *skbuff_head_cache;
static struct kmem_cache *skbuff_fclone_cache;
void __init skb_init(void)
{
/* 일반 sk_buff 캐시 */
skbuff_head_cache = kmem_cache_create(
"skbuff_head_cache",
sizeof(struct sk_buff), /* ~240바이트 */
0, /* 정렬 */
SLAB_HWCACHE_ALIGN | SLAB_PANIC,
NULL);
/* fclone 캐시: sk_buff 2개 + fclone_ref를 하나의 슬랩 객체로 */
skbuff_fclone_cache = kmem_cache_create(
"skbuff_fclone_cache",
sizeof(struct sk_buff_fclones), /* sk_buff*2 + ref */
0,
SLAB_HWCACHE_ALIGN | SLAB_PANIC,
NULL);
}
/* fclone 구조체: clone 전용 최적화 */
struct sk_buff_fclones {
struct sk_buff skb1; /* 원본 sk_buff */
struct sk_buff skb2; /* 사전 할당된 clone sk_buff */
refcount_t fclone_ref; /* 공유 참조 카운트 */
};
/* __alloc_skb: 내부 할당 로직 */
struct sk_buff *__alloc_skb(unsigned int size, gfp_t gfp_mask,
int flags, int node)
{
struct sk_buff *skb;
u8 *data;
if (flags & SKB_ALLOC_FCLONE) {
/* fclone 모드: 2개의 sk_buff를 한 번에 할당
* TCP 전송 경로에서 사용: 재전송 시 clone 필요 예상 */
struct sk_buff_fclones *fclones;
fclones = kmem_cache_alloc_node(skbuff_fclone_cache,
gfp_mask, node);
skb = &fclones->skb1;
skb->fclone = SKB_FCLONE_ORIG; /* 원본 표시 */
fclones->skb2.fclone = SKB_FCLONE_CLONE; /* clone 슬롯 */
} else {
/* 일반 모드: sk_buff 1개만 할당 */
skb = kmem_cache_alloc_node(skbuff_head_cache,
gfp_mask, node);
skb->fclone = SKB_FCLONE_UNAVAILABLE;
}
/* 데이터 버퍼 할당 (별도) */
size = SKB_DATA_ALIGN(size);
data = kmalloc_reserve(size + sizeof(struct skb_shared_info),
gfp_mask, node, &pfmemalloc);
skb->head = data;
skb->data = data;
skb->truesize = SKB_TRUESIZE(size);
refcount_set(&skb->users, 1);
return skb;
}
/* fclone으로 빠른 clone (별도 할당 불필요) */
struct sk_buff *skb_clone(struct sk_buff *skb, gfp_t gfp_mask)
{
struct sk_buff *n;
if (skb->fclone == SKB_FCLONE_ORIG) {
/* fclone 슬롯이 사용 가능하면 할당 없이 즉시 clone */
struct sk_buff_fclones *fclones =
container_of(skb, struct sk_buff_fclones, skb1);
n = &fclones->skb2;
if (refcount_inc_not_zero(&fclones->fclone_ref)) {
/* 할당 없이 clone 완료! → kmem_cache_alloc 비용 절약 */
goto do_clone;
}
}
/* fclone 불가: 일반 할당 */
n = kmem_cache_alloc(skbuff_head_cache, gfp_mask);
do_clone:
/* sk_buff 메타데이터 복사 (데이터 버퍼 공유) */
__copy_skb_header(n, skb);
n->cloned = 1;
skb->cloned = 1;
atomic_inc(&skb_shinfo(skb)->dataref);
return n;
}
/* NAPI per-CPU 캐시 (napi_alloc_skb 최적화) */
/* NAPI 수신 경로에서는 skbuff_head_cache 대신
* per-CPU page fragment cache를 사용하여 allocation lock 경합을 회피
* → napi_alloc_cache (struct page_frag_cache)
* → 같은 page에서 연속 skb의 data 버퍼를 할당
* → TLB miss, cache miss 최소화 */
| 할당 방식 | 캐시 | 크기 | 사용처 |
|---|---|---|---|
| 일반 skb | skbuff_head_cache | ~240B | 대부분의 skb 할당 |
| fclone skb | skbuff_fclone_cache | ~490B | TCP TX (clone 예상 시) |
| 데이터 버퍼 | kmalloc slab | 가변 | linear 데이터 영역 |
| NAPI 수신 | per-CPU page frag | PAGE_SIZE | NAPI poll 내 고속 할당 |
| page_pool | per-pool 캐시 | PAGE_SIZE | 고성능 NIC 드라이버 (6.x+) |
fclone 효과: TCP 전송 경로에서 sk_stream_alloc_skb()는 SKB_ALLOC_FCLONE 플래그로 skb를 할당합니다. 이는 재전송 시 skb_clone()이 별도 메모리 할당 없이 사전 할당된 슬롯을 사용하게 합니다. 고부하 TCP 서버에서 재전송율이 높을 때 kmem_cache_alloc() 호출 수를 크게 줄여 성능을 개선합니다. slabinfo -s | grep skbuff로 캐시 사용 통계를 확인할 수 있습니다.
커널 소스 분석: __alloc_skb() 호출 체인
__alloc_skb()는 sk_buff 할당의 핵심 경로입니다. kmem_cache_alloc()로 sk_buff 헤더를 가져오고, __netdev_alloc_frag() 또는 kmalloc_reserve()로 데이터 버퍼를 확보합니다. 아래 다이어그램은 경로별 분기와 내부 호출 관계를 보여줍니다.
__alloc_skb() 소스 코드 상세
/* net/core/skbuff.c */
struct sk_buff *__alloc_skb(unsigned int size, gfp_t gfp_mask,
int flags, int node)
{
struct kmem_cache *cache;
struct sk_buff *skb;
u8 *data;
bool pfmemalloc;
cache = (flags & SKB_ALLOC_FCLONE)
? skbuff_fclone_cache : skbuff_head_cache;
skb = kmem_cache_alloc_node(cache, gfp_mask & ~__GFP_DMA, node);
if (unlikely(!skb))
return NULL;
prefetchw(skb);
/* skb_shared_info를 포함할 크기로 정렬 */
size = SKB_DATA_ALIGN(size);
size += SKB_DATA_ALIGN(sizeof(struct skb_shared_info));
data = kmalloc_reserve(size, gfp_mask, node, &pfmemalloc);
if (unlikely(!data))
goto nodata;
/* skb 포인터 초기화 */
memset(skb, 0, offsetof(struct sk_buff, tail));
skb->truesize = SKB_TRUESIZE(size);
skb->pfmemalloc = pfmemalloc;
refcount_set(&skb->users, 1);
skb->head = data;
skb->data = data;
skb_reset_tail_pointer(skb);
skb->end = skb->tail + size - SKB_DATA_ALIGN(sizeof(struct skb_shared_info));
/* skb_shared_info 초기화: end 포인터 직후에 위치 */
memset(skb_shinfo(skb), 0, sizeof(struct skb_shared_info));
atomic_set(&skb_shinfo(skb)->dataref, 1);
if (flags & SKB_ALLOC_FCLONE) {
struct sk_buff_fclones *fclones;
fclones = container_of(skb, struct sk_buff_fclones, skb1);
skb->fclone = SKB_FCLONE_ORIG;
refcount_set(&fclones->fclone_ref, 1);
fclones->skb2.fclone = SKB_FCLONE_CLONE;
}
return skb;
nodata:
kmem_cache_free(cache, skb);
return NULL;
}
코드 설명
- 3행size, gfp_mask, flags, node 4개 매개변수 수신. node는 NUMA 노드 번호로 로컬 메모리 할당에 사용됩니다.
- 9-10행SKB_ALLOC_FCLONE 플래그에 따라 fclone 전용 캐시 또는 일반 캐시 선택. TCP TX 경로는 fclone 캐시를 사용해 재전송 시 별도 할당을 피합니다.
- 12-14행kmem_cache_alloc_node()로 sk_buff 헤더 객체만 먼저 할당. __GFP_DMA 플래그는 제거해 DMA 전용 영역 낭비를 막습니다.
- 18-19행SKB_DATA_ALIGN()로 size를 캐시라인 경계에 정렬. 그 뒤에 skb_shared_info 크기도 추가하여 한 번의 kmalloc으로 데이터 버퍼와 shinfo를 연속 배치합니다.
- 20-23행kmalloc_reserve()는 pfmemalloc(메모리 압박 상황용 예약 풀) 여부를 out-param으로 반환합니다. 할당 실패 시 nodata 레이블로 점프해 이미 할당된 sk_buff 헤더를 반환합니다.
- 26-31행tail까지의 범위만 memset 0으로 초기화해 불필요한 쓰기를 줄입니다. truesize에는 SKB_TRUESIZE(size)를 설정해 소켓 버퍼 카운팅에 반영합니다.
- 32-35행head/data는 데이터 버퍼 시작점, tail은 head+sizeof(head)로 리셋, end는 tail + 정렬 크기 - shinfo 크기로 설정됩니다. 이 4개 포인터가 sk_buff 메모리 레이아웃의 핵심입니다.
- 38-39행skb_shinfo(skb)는 end 포인터가 가리키는 위치로, 데이터 버퍼 끝에 연속 배치된 skb_shared_info를 초기화합니다. dataref=1로 설정해 현재 참조 수 1개임을 표시합니다.
- 41-47행fclone 모드면 container_of로 sk_buff_fclones 전체 구조체 포인터를 구하고, skb2의 fclone 타입을 SKB_FCLONE_CLONE으로 설정해 빠른 clone 슬롯으로 준비합니다.
__netdev_alloc_frag() — per-CPU 페이지 캐시
NAPI 수신 경로(napi_alloc_skb())는 데이터 버퍼를 kmalloc 대신 per-CPU 페이지 프래그먼트(Fragment) 캐시에서 가져옵니다. 이를 통해 SMP 환경에서의 SLAB 잠금(Lock) 경합을 완전히 제거합니다.
/* net/core/skbuff.c — per-CPU 페이지 캐시에서 데이터 버퍼 할당 */
static void *__netdev_alloc_frag_align(unsigned int fragsz,
unsigned int align_mask)
{
struct page_frag_cache *nc;
void *data;
/* per-CPU 캐시: CPU별로 독립적 → lock-free */
nc = this_cpu_ptr(&netdev_alloc_cache);
data = page_frag_alloc_align(nc, fragsz, GFP_ATOMIC, align_mask);
local_bh_enable();
return data;
}
/* page_frag_cache: 하나의 물리 페이지에서 연속 할당 */
struct page_frag_cache {
void *va; /* 현재 페이지의 가상 주소 */
__u16 offset; /* 다음 할당 위치 오프셋 */
__u16 size; /* 현재 페이지 크기 (PAGE_SIZE 또는 2×PAGE_SIZE) */
unsigned int pagecnt_bias; /* page refcount 편향값 */
bool pfmemalloc; /* 예약 메모리 풀 사용 여부 */
};
코드 설명
- 8-9행this_cpu_ptr()로 현재 CPU의 netdev_alloc_cache를 가져옵니다. 각 CPU가 독립된 캐시를 사용하므로 스핀락 없이 동작합니다. 이는 고속 패킷 수신 경로에서 메모리 할당 오버헤드를 획기적으로 줄입니다.
- 10행page_frag_alloc_align()은 현재 페이지 캐시에 공간이 남아 있으면 offset만 증가시켜 반환합니다. 페이지가 소진되면 새 물리 페이지를 할당합니다. 연속된 skb 데이터가 같은 페이지에 배치되어 TLB/캐시 효율이 높아집니다.
- 16행va 필드는 현재 사용 중인 페이지의 가상 주소. 할당은 va+offset 위치부터 시작합니다.
- 17행offset은 현재 페이지 내 다음 할당 시작 위치. 새 데이터를 할당할 때마다 fragsz만큼 증가합니다.
- 19행pagecnt_bias는 page_ref_count의 편향값으로, page를 개별 put_page() 없이 한 번에 해제할 수 있게 해주는 최적화 기법입니다.
핵심 구조체 필드별 주석: sk_buff와 skb_shared_info
커널 소스 기준 주요 필드 전체를 한국어 주석과 함께 제시합니다. 실제 커널(include/linux/skbuff.h)의 배치 순서에 맞춰 그룹별로 정리했습니다.
/* include/linux/skbuff.h — sk_buff 전체 구조 (6.x 기준, 주요 필드) */
struct sk_buff {
/* === 큐 연결 (Queue Linkage) === */
union {
struct {
struct sk_buff *next; /* 이중 연결 리스트: 다음 skb */
struct sk_buff *prev; /* 이중 연결 리스트: 이전 skb */
};
struct rb_node rbnode; /* TCP OFO/retransmit 큐용 레드블랙 트리 노드 */
struct list_head list; /* generic list_head 사용 시 (RX 배치 처리) */
struct llist_node ll_node; /* lock-free 단방향 리스트 (GRO flush 경로) */
};
/* === 소유권 (Ownership) === */
struct sock *sk; /* 이 skb를 소유한 소켓. RX/TX 메모리 할당량 추적 */
union {
struct net_device *dev; /* 수신/전송에 사용된 네트워크 디바이스 */
unsigned long dev_scratch;/* dev 필드를 임시 정수로 재사용 (Tx 완료 콜백) */
};
/* === 라우팅/전달 메타데이터 === */
struct dst_entry *_skb_refdst; /* 라우팅 결정 결과 (dst_hold/dst_release 관리) */
void (*destructor)(struct sk_buff *skb);
/* skb 해제 시 호출되는 소멸자. sock_rfree 등 등록 */
/* === 데이터 길이 === */
unsigned int len; /* linear + paged 전체 데이터 길이 */
unsigned int data_len; /* paged(비선형) 부분의 길이. 0이면 순수 linear */
__u16 mac_len; /* MAC(L2) 헤더 길이 */
__u16 hdr_len; /* clone 시 writable 복사 헤더 길이 (pskb_copy) */
__u16 queue_mapping; /* 멀티큐 NIC의 TX 큐 인덱스 선택 */
/* === 프로토콜 식별 === */
__be16 protocol; /* EtherType: ETH_P_IP(0x0800), ETH_P_IPV6(0x86DD) 등 */
/* === 비트필드 플래그들 === */
__u8 cloned:1; /* 이 skb가 clone인지 여부 (데이터 버퍼 공유 중) */
__u8 nohdr:1; /* 페이로드 참조 전용, 헤더 영역 없음 */
__u8 peeked:1; /* MSG_PEEK으로 이미 확인된 skb */
__u8 ip_summed:2; /* 체크섬 오프로드 상태 (NONE/UNNECESSARY/COMPLETE/PARTIAL) */
__u8 ooo_okay:1; /* TCP Out-Of-Order 허용 여부 */
/* === 체크섬 오프로드 === */
__wsum csum; /* 체크섬 값 (CHECKSUM_COMPLETE 시 HW가 채움) */
__u16 csum_start; /* CHECKSUM_PARTIAL: 체크섬 계산 시작 오프셋 (head 기준) */
__u16 csum_offset; /* CHECKSUM_PARTIAL: 체크섬 필드 위치 (csum_start 기준) */
/* === 헤더 오프셋 (sk_buff_data_t는 u16 또는 포인터) === */
sk_buff_data_t transport_header; /* L4(TCP/UDP/ICMP) 헤더 시작 오프셋 */
sk_buff_data_t network_header; /* L3(IP) 헤더 시작 오프셋 */
sk_buff_data_t mac_header; /* L2(Ethernet) 헤더 시작 오프셋 */
sk_buff_data_t inner_transport_header; /* 터널 내부 L4 오프셋 */
sk_buff_data_t inner_network_header; /* 터널 내부 L3 오프셋 */
sk_buff_data_t inner_mac_header; /* 터널 내부 L2 오프셋 */
/* === 버퍼 포인터 (메모리 레이아웃의 핵심) === */
sk_buff_data_t tail; /* 데이터 유효 영역 끝 (skb_put으로 증가) */
sk_buff_data_t end; /* 할당된 버퍼의 절대 끝 (skb_shared_info 직전) */
unsigned char *head; /* 할당된 버퍼의 절대 시작 */
unsigned char *data; /* 현재 데이터 유효 영역 시작 (skb_push/pull로 변경) */
/* === 메모리 회계 === */
unsigned int truesize; /* 실제 점유 메모리 (소켓 버퍼 한계 sk_wmem_queued 등에 반영) */
refcount_t users; /* 참조 카운트. 0이 되면 해제 */
/* === 프로토콜 제어 블록 === */
char cb[48] __aligned(8);
/* 프로토콜 전용 임시 저장소. TCP: tcp_skb_cb, IP: inet_skb_parm */
__u32 hash; /* 패킷 플로우 해시 (RSS 큐 선택, RPS, SO_INCOMING_CPU) */
__u32 priority; /* QoS 우선순위 (SO_PRIORITY, IP DSCP 반영) */
__u32 mark; /* netfilter/tc 마크 (iptables -j MARK, tc filter) */
__u32 napi_id; /* SO_BUSY_POLL: 어느 NAPI 인스턴스가 수신했는지 */
union {
ktime_t tstamp; /* 패킷 수신/전송 타임스탬프 (SO_TIMESTAMPING) */
u64 skb_mstamp_ns; /* TCP RTT 측정용 고해상도 타임스탬프 (CLOCK_TAI) */
};
};
코드 설명
- 큐 연결 unionnext/prev는 일반 sk_buff_head 큐에 사용되고, rbnode는 TCP 재전송/OFO 큐처럼 정렬이 필요한 경우 레드블랙 트리로 사용됩니다. 같은 메모리를 용도에 따라 재사용해 구조체 크기를 최소화합니다.
- sk/devsk는 소속 소켓 포인터로, skb의 메모리가 소켓의 sk_rmem_alloc/sk_wmem_queued 카운터에 반영됩니다. dev는 수신/전송 디바이스로 네트워크 네임스페이스 결정에도 사용됩니다.
- destructorkfree_skb() 호출 시 destructor가 설정돼 있으면 해제 전에 호출합니다. sock_rfree()가 등록되면 소켓의 수신 메모리 카운터를 자동으로 감소시킵니다.
- len/data_lenlen은 선형+페이지 전체 길이, data_len은 페이지 부분만의 길입니다. len - data_len = linear 크기. skb_headlen(skb)은 이 값을 반환합니다.
- ip_summedCHECKSUM_UNNECESSARY면 프로토콜 스택이 체크섬 검증을 생략합니다. 고성능 NIC은 수신 시 이 값을 설정해 CPU 절약에 기여합니다.
- 헤더 오프셋 6개일반 패킷에는 transport/network/mac_header 3개, 터널 패킷에는 inner_ 접두사 3개가 추가됩니다. skb_transport_header(skb) 등 인라인 함수로 head + 오프셋 주소를 구합니다.
- head/data/tail/end할당 직후: head == data == tail, end는 버퍼 끝. skb_put(n)으로 tail이 n 증가, skb_push(n)으로 data가 n 감소, skb_pull(n)으로 data가 n 증가합니다.
- truesizesizeof(sk_buff) + 실제 데이터 버퍼 크기. 소켓 버퍼 한계(sk->sk_rcvbuf, sk->sk_sndbuf)와 비교되며, 초과 시 패킷이 드롭되거나 전송이 블록됩니다.
- cb[48]각 프로토콜 계층이 자신만의 구조체로 캐스팅해 임시 데이터를 저장합니다. 다음 계층에 전달하면 덮어쓰이므로 계층 간 데이터 공유 용도로는 사용하면 안 됩니다.
skb_shared_info 필드별 주석
/* include/linux/skbuff.h — skb_shared_info (end 포인터 직후에 연속 배치) */
struct skb_shared_info {
/* === 참조 카운트 === */
__u8 flags; /* 내부 플래그 (TX_SHARED_SKB 등) */
__u8 meta_len; /* XDP 메타데이터 길이 (XDP redirect 경로) */
__u8 nr_frags; /* frags[] 배열에 등록된 페이지 프래그먼트 수 (0~MAX_SKB_FRAGS) */
__u8 tx_flags; /* TX 완료 알림 플래그 (SKBTX_HW_TSTAMP 등) */
atomic_t dataref; /* 데이터 버퍼 참조 카운트. clone 시 증가, 해제 시 감소 */
/* === GSO 메타데이터 === */
unsigned short gso_size; /* GSO 세그먼트 크기(MSS). 0이면 GSO 아님 */
unsigned short gso_segs; /* 예상 세그먼트 수 (IP len / gso_size 기반) */
unsigned int gso_type; /* GSO 타입 비트맵: SKB_GSO_TCPV4, SKB_GSO_UDP_L4 등 */
__u32 tskey; /* SO_TIMESTAMPING: TX 타임스탬프 식별 키 */
/* === Scatter-Gather 프래그먼트 배열 === */
skb_frag_t frags[MAX_SKB_FRAGS]; /* 물리 페이지 참조 배열. NIC SG DMA에 직접 전달 가능 */
/* === frag_list: skb 체인 (GRO/UDP 단편화 등) === */
struct sk_buff *frag_list; /* 하위 skb 체인 포인터. GRO 병합, UDP 재조립에 사용 */
/* === Ubuf/Zero-copy 메타데이터 === */
struct ubuf_info *uarg; /* MSG_ZEROCOPY: 사용자 공간 버퍼 참조 추적용 */
};
코드 설명
- dataref데이터 버퍼를 참조하는 sk_buff 개수. __alloc_skb()에서 1로 초기화, skb_clone() 시 atomic_inc()로 증가, 마지막 참조자가 해제될 때 kfree_skb_partial()이 실제 데이터 버퍼를 해제합니다.
- nr_fragsfrags[] 배열의 사용 개수. 0이면 순수 linear skb. skb_add_rx_frag()나 skb_fill_page_desc()로 증가. MAX_SKB_FRAGS(보통 17)를 초과하면 skb_linearize() 필요.
- gso_size/gso_segs/gso_typeTCP가 tcp_sendmsg()에서 super-packet을 만들 때 설정합니다. NIC이 TSO를 지원하면 그대로 DMA되고, 아니면 validate_xmit_skb()에서 skb_segment()가 소프트웨어로 분할합니다.
- frags[]각 원소는 skb_frag_t로 page 포인터, offset, length를 담습니다. skb_frag_page(), skb_frag_off(), skb_frag_size() 인라인 함수로 접근. NIC 드라이버는 이 배열을 DMA descriptor에 직접 매핑해 zero-copy 전송합니다.
- frag_listfrags[]와 달리 sk_buff 포인터 체인입니다. GRO(Generic Receive Offload)에서 병합된 패킷들, UDP 재조립 과정에서 사용됩니다. skb_walk_frags() 매크로로 순회합니다.
- uargMSG_ZEROCOPY(sendmsg 플래그)로 전송 시, 사용자 버퍼가 NIC으로 DMA되는 동안 해제되지 않도록 참조를 유지합니다. 전송 완료 시 sock_zerocopy_callback()이 호출돼 사용자 공간에 완료를 알립니다.
커널 소스 분석: skb_clone() vs skb_copy()
두 함수는 목적이 완전히 다릅니다. skb_clone()은 데이터 버퍼를 공유하므로 매우 빠르지만 데이터를 수정할 수 없고, skb_copy()는 독립 복사본을 만들어 자유로운 수정이 가능합니다.
/* net/core/skbuff.c — skb_clone(): 메타데이터만 복사, 버퍼 공유 */
struct sk_buff *skb_clone(struct sk_buff *skb, gfp_t gfp_mask)
{
struct sk_buff_fclones *fclones;
struct sk_buff *n;
fclones = container_of(skb, struct sk_buff_fclones, skb1);
/* 1단계: fclone 슬롯 재활용 시도 (추가 할당 없음) */
if (skb->fclone == SKB_FCLONE_ORIG &&
refcount_read(&fclones->fclone_ref) == 1) {
n = &fclones->skb2;
refcount_set(&fclones->fclone_ref, 2);
n->fclone = SKB_FCLONE_CLONE;
goto do_clone;
}
/* 2단계: fclone 불가 → 일반 kmem_cache 할당 */
n = kmem_cache_alloc(skbuff_head_cache, gfp_mask);
if (!n)
return NULL;
n->fclone = SKB_FCLONE_UNAVAILABLE;
do_clone:
/* 3단계: sk_buff 헤더 복사 (데이터 버퍼 포인터 포함) */
__copy_skb_header(n, skb);
/* 4단계: clone 플래그 설정 및 dataref 증가 */
n->cloned = 1;
skb->cloned = 1;
n->nohdr = 0;
atomic_inc(&skb_shinfo(skb)->dataref);
/* 5단계: page fragment refcount 증가 */
skb_clone_fraglist(n);
skb_clone_sk(n); /* sk가 있으면 소켓 메모리 카운터 참조 */
return n;
}
/* net/core/skbuff.c — skb_copy(): 완전한 독립 복사본 생성 */
struct sk_buff *skb_copy(const struct sk_buff *skb, gfp_t gfp_mask)
{
int headerlen = skb_headroom(skb);
unsigned int size;
struct sk_buff *n;
/* linear 크기 + headroom + skb_shared_info */
size = skb_end_offset(skb) + skb->data_len;
n = __alloc_skb(size, gfp_mask, SKB_ALLOC_RX, NUMA_NO_NODE);
if (!n)
return NULL;
/* headroom 확보 */
skb_reserve(n, headerlen);
/* linear 데이터 전체 복사 */
if (skb_copy_bits(skb, -headerlen, n->head, headerlen + skb->len))
BUG();
skb_put(n, skb->len);
/* 메타데이터/헤더 오프셋 복사 */
__copy_skb_header(n, skb);
skb_headers_offset_update(n, nhead);
return n;
}
코드 설명
- skb_clone 7행container_of로 skb1을 포함한 sk_buff_fclones 구조체 전체 포인터를 구합니다. 이를 통해 skb2(clone 슬롯)에 직접 접근할 수 있습니다.
- skb_clone 10-14행fclone_ref == 1이면 skb2 슬롯이 현재 미사용 중임을 의미합니다. refcount를 2로 설정하고 skb2를 clone으로 사용하면 kmem_cache_alloc() 호출을 완전히 생략할 수 있습니다. TCP 재전송이 빈번한 환경에서 큰 성능 이득을 줍니다.
- skb_clone 18-21행fclone 슬롯을 쓸 수 없을 때 일반 skbuff_head_cache에서 새 sk_buff를 할당합니다. 이 경우 추가 메모리 할당이 발생합니다.
- skb_clone 25-30행__copy_skb_header()는 cb[]를 제외한 헤더 포인터(transport/network/mac), 프로토콜, 플래그 등을 복사합니다. head/data/tail/end 포인터도 그대로 복사되므로 두 sk_buff가 같은 데이터 버퍼를 참조하게 됩니다.
- skb_clone 32행atomic_inc로 dataref를 증가시킵니다. kfree_skb()는 dataref가 1 이하일 때만 실제 데이터 버퍼를 해제합니다. 이 카운터가 clone 수명 관리의 핵심입니다.
- skb_copy 46-48행__alloc_skb()를 SKB_ALLOC_RX 플래그로 호출해 완전히 새로운 skb와 데이터 버퍼를 할당합니다. size에 data_len을 더해 paged 데이터까지 선형화할 공간을 확보합니다.
- skb_copy 53-55행skb_copy_bits()는 선형 및 paged 데이터를 모두 선형 버퍼로 복사합니다. 헤더 영역(-headerlen 오프셋)부터 전체 len을 복사하므로 완전한 독립 복사본이 됩니다.
커널 소스 분석: skb_segment() 상세
skb_segment()는 GSO super-packet을 MTU 크기 세그먼트로 분할하는 소프트웨어 GSO 엔진입니다. 헤더 복사, 페이로드 참조(또는 복사), IP/TCP 헤더 필드 갱신을 세그먼트마다 수행합니다.
/* net/core/skbuff.c — skb_segment() 핵심 루프 (단순화) */
struct sk_buff *skb_segment(struct sk_buff *head_skb,
netdev_features_t features)
{
struct sk_buff *segs = NULL, **tail = &segs;
unsigned int mss = skb_shinfo(head_skb)->gso_size;
unsigned int doffset = head_skb->data - skb_mac_header(head_skb);
bool sg = !!(features & NETIF_F_SG);
unsigned int offset = doffset; /* 현재 페이로드 처리 위치 */
int err = -ENOMEM;
do {
struct sk_buff *nskb;
int hsize = min_t(int, doffset + mss, head_skb->len) - doffset;
/* ① 새 세그먼트용 sk_buff 할당 */
nskb = alloc_skb(hsize + doffset + headroom, GFP_ATOMIC);
if (unlikely(!nskb)) {
err = -ENOMEM;
goto err;
}
/* ② 세그먼트 체인에 연결 */
*tail = nskb;
tail = &nskb->next;
/* ③ headroom 확보 후 L2/L3/L4 헤더 복사 */
skb_reserve(nskb, headroom);
skb_put(nskb, doffset);
skb_copy_from_linear_data(head_skb, nskb->data, doffset);
/* ④ 페이로드: SG 지원 시 page frag 참조, 아니면 memcpy */
if (sg) {
skb_fill_page_desc(nskb, 0,
frag->bv_page, frag->bv_offset + offset, hsize);
} else {
skb_copy_from_linear_data_offset(head_skb, offset,
skb_put(nskb, hsize), hsize);
}
/* ⑤ 세그먼트 GSO 필드 초기화 (더 이상 GSO 아님) */
skb_shinfo(nskb)->gso_size = 0;
skb_shinfo(nskb)->gso_segs = 0;
skb_shinfo(nskb)->gso_type = 0;
offset += hsize;
} while (offset < head_skb->len - doffset);
return segs;
err:
kfree_skb_list(segs);
return ERR_PTR(err);
}
코드 설명
- 4행tail 이중 포인터로 segs 체인을 효율적으로 구성합니다. 첫 세그먼트는 segs에 연결하고, 이후 세그먼트는 이전 nskb->next에 연결됩니다.
- 5행gso_size가 MSS(Maximum Segment Size). TCP에서는 협상된 MSS에서 TCP/IP 헤더 크기를 뺀 값입니다.
- 6행doffset은 MAC 헤더부터 데이터 시작까지의 바이트 수 즉 L2+L3+L4 헤더 전체 길입니다. 각 세그먼트에 이 헤더를 복사합니다.
- 7행NETIF_F_SG 비트로 NIC의 Scatter-Gather DMA 지원 여부를 확인합니다. SG를 지원하면 페이로드를 zero-copy로 참조할 수 있습니다.
- 15행GFP_ATOMIC으로 할당하는 이유는 이 함수가 softirq 컨텍스트(dev_queue_xmit())에서 호출될 수 있기 때문입니다. 슬립이 허용되지 않습니다.
- 29행skb_copy_from_linear_data()는 원본 skb의 MAC 헤더부터 doffset 바이트를 새 세그먼트에 복사합니다. IP.tot_len, TCP.seq 등은 이후 별도 갱신됩니다.
- 32-38행SG가 지원되면 skb_fill_page_desc()로 원본 페이지를 참조만 하므로 메모리 복사가 없습니다. 미지원 시 skb_copy_from_linear_data_offset()으로 데이터를 실제로 복사합니다.
- 41-43행세그먼트의 gso_size를 0으로 설정해 이 세그먼트가 더 이상 GSO 처리가 필요하지 않음을 표시합니다. NIC 드라이버는 이 값이 0인 일반 패킷으로 취급해 DMA 처리합니다.
- 48-49행실패 시 kfree_skb_list()로 지금까지 생성된 모든 세그먼트를 한 번에 해제합니다. ERR_PTR()로 오류 코드를 포인터에 인코딩해 반환합니다.
네트워크 네임스페이스와 sk_buff
Linux 네트워크 네임스페이스는 독립된 네트워크 스택(인터페이스, 라우팅, iptables, 소켓)을 제공합니다. sk_buff는 skb->dev를 통해 네임스페이스에 소속되며, veth, bridge 등을 통해 네임스페이스를 넘나들 때 sk_buff의 처리가 변화합니다.
/* sk_buff가 속한 네트워크 네임스페이스 확인 */
static inline struct net *dev_net(const struct net_device *dev)
{
return read_pnet(&dev->nd_net);
}
/* skb->dev를 통해 네임스페이스 참조 */
struct net *net = dev_net(skb->dev);
/* → net->ipv4.ip_forward (포워딩 설정)
* → net->ct.nf_conntrack_hash (conntrack 해시)
* → net->loopback_dev (lo 인터페이스)
* → 모두 네임스페이스별 독립 */
/* veth: 네임스페이스 간 패킷 전달 */
/* drivers/net/veth.c — veth_xmit() */
static netdev_tx_t veth_xmit(struct sk_buff *skb,
struct net_device *dev)
{
struct veth_priv *priv = netdev_priv(dev);
struct net_device *rcv = rcu_dereference(priv->peer);
/* rcv는 다른 네임스페이스의 veth peer 디바이스 */
/* skb->dev를 peer 디바이스로 교체 → 네임스페이스 전환 */
skb->dev = rcv;
/* L2 헤더 재처리 */
skb->protocol = eth_type_trans(skb, rcv);
/* → 이제 skb는 rcv가 속한 네임스페이스에서 처리됨
* → rcv 네임스페이스의 Netfilter, 라우팅, 소켓 lookup 적용 */
if (likely(veth_forward_skb(rcv, skb, priv, rq, rcv_xdp) == NET_RX_SUCCESS))
return NETDEV_TX_OK;
/* veth_forward_skb → netif_rx() 또는 napi_gro_receive()
* → rcv 네임스페이스의 네트워크 스택에 진입 */
}
/* 네임스페이스 경계에서 주의할 skb 처리 */
/* 1. conntrack: 네임스페이스별 독립 → skb->_nfct 초기화 필요할 수 있음 */
/* 2. skb->mark: 네임스페이스 간 보존됨 → 의도하지 않은 정책 적용 주의 */
/* 3. skb->sk: NULL이 아니면 소켓 네임스페이스와 dev 네임스페이스 불일치 가능 */
컨테이너 네트워킹과 skb: Docker/Kubernetes의 Pod 네트워킹은 veth 쌍을 통해 구현됩니다. 호스트 네임스페이스의 veth에서 dev_queue_xmit(skb)를 호출하면 skb->dev가 컨테이너 네임스페이스의 peer veth로 교체되어 netif_rx()로 재진입합니다. 이 과정에서 XDP는 veth 드라이버에서 실행되어 컨테이너로 진입하기 전에 패킷을 필터링/리다이렉트할 수 있습니다 (Cilium의 veth XDP 모드).
IP 프래그먼테이션(Fragmentation)과 skb 재조립
IP 프래그먼테이션(IP Fragmentation)은 MTU(Maximum Transmission Unit)를 초과하는 패킷을 여러 조각으로 분할하고, 수신 측에서 재조립(Reassembly)하는 메커니즘입니다. Linux 커널은 이 과정을 모두 sk_buff 체인으로 처리하며, 메모리 제한·보안·성능 측면에서 정교한 관리가 필요합니다.
ip_fragment() / ip_do_fragment() 분할 경로
전송 경로에서 패킷이 경로 MTU를 초과하면 ip_fragment()가 호출됩니다. 이 함수는 원본 sk_buff를 MTU 크기에 맞는 여러 sk_buff로 분할합니다.
/* net/ipv4/ip_output.c */
int ip_fragment(struct net *net, struct sock *sk,
struct sk_buff *skb, unsigned int mtu,
int (*output)(struct net *, struct sock *, struct sk_buff *))
{
struct iphdr *iph = ip_hdr(skb);
/* DF 비트가 설정되어 있으면 ICMP Fragmentation Needed 전송 후 폐기 */
if (unlikely((iph->frag_off & htons(IP_DF)) &&
!(skb->ignore_df))) {
IP_INC_STATS(net, IPSTATS_MIB_FRAGFAILS);
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_FRAG_NEEDED,
htonl(mtu));
kfree_skb(skb);
return -EMSGSIZE;
}
return ip_do_fragment(net, sk, skb, output);
}
int ip_do_fragment(struct net *net, struct sock *sk,
struct sk_buff *skb,
int (*output)(struct net *, struct sock *, struct sk_buff *))
{
struct iphdr *iph;
int ptr;
struct sk_buff *skb2;
unsigned int mtu, hlen, left, len, ll_rs;
int offset;
iph = ip_hdr(skb);
mtu = ip_skb_dst_mtu(sk, skb);
/* 이미 프래그먼트된 패킷 (frag_list 보유) 처리 — 빠른 경로 */
if (skb_has_frag_list(skb)) {
return ip_fragment_fast(net, sk, skb, output, mtu);
}
hlen = iph->ihl * 4;
mtu = mtu - hlen; /* 페이로드(Payload) 기준 MTU */
/* 8바이트 정렬 필수 (fragment offset 필드는 8바이트 단위) */
mtu &= ~7;
left = skb->len - hlen; /* 분할 대상 페이로드 길이 */
ptr = hlen; /* 현재 읽기 오프셋 */
offset = (ntohs(iph->frag_off) & IP_OFFSET) << 3;
while (left > 0) {
len = left;
if (len > mtu)
len = mtu;
/* 마지막 조각이 아닌 경우 8바이트 정렬 */
if (left > len)
len &= ~7;
/* 새 sk_buff 할당: IP 헤더 + 페이로드 len바이트 */
skb2 = alloc_skb(len + hlen + ll_rs, GFP_ATOMIC);
if (!skb2) {
IP_INC_STATS(net, IPSTATS_MIB_FRAGFAILS);
return -ENOMEM;
}
/* 메타데이터 복사 (priority, mark, dst 등) */
ip_copy_metadata(skb2, skb);
skb_reserve(skb2, ll_rs);
skb_put(skb2, len + hlen);
memcpy(skb2->data, skb->data, hlen); /* IP 헤더 복사 */
/* 페이로드 복사 (비선형 skb도 처리) */
if (skb_copy_bits(skb, ptr, skb2->data + hlen, len))
BUG();
/* fragment offset 필드 설정 (8바이트 단위) */
iph = (struct iphdr *)skb2->data;
iph->frag_off = htons((offset >> 3) |
(left > len ? IP_MF : 0) |
(ntohs(ip_hdr(skb)->frag_off) & IP_DF));
iph->tot_len = htons(len + hlen);
ip_send_check(iph); /* IP 체크섬 재계산 */
err = output(net, sk, skb2);
if (err)
goto fail;
offset += len;
left -= len;
ptr += len;
IP_INC_STATS(net, IPSTATS_MIB_FRAGCREATES);
}
consume_skb(skb);
IP_INC_STATS(net, IPSTATS_MIB_FRAGOKS);
return 0;
}
ip_do_fragment() 핵심 포인트
- 8바이트 정렬 필수: IPv4 fragment offset 필드는 8바이트 단위 값을 저장합니다.
mtu &= ~7과len &= ~7로 정렬을 보장합니다. - GFP_ATOMIC 할당: 전송 경로는 소프트 인터럽트 컨텍스트일 수 있으므로 절대 블로킹 불가.
GFP_ATOMIC이 필수입니다. - MF 비트: 마지막 조각을 제외한 모든 조각에
IP_MF(More Fragments) 플래그를 설정합니다. 수신 측은 이 비트로 마지막 조각을 인식합니다. - ip_copy_metadata(): priority, mark, dst(routing table 항목), skb_dst()를 복사하여 모든 조각이 동일한 라우팅 컨텍스트를 갖게 합니다.
- IPSTATS 카운터:
FRAGCREATES(생성 조각 수),FRAGOKS(성공),FRAGFAILS(실패)로/proc/net/snmp에서 모니터링할 수 있습니다.
ip_defrag()와 frag_list 재조립
수신 측에서는 ip_defrag()가 분산된 조각들을 모아 원본 패킷을 재조립합니다. 재조립된 패킷은 첫 번째 sk_buff의 skb_shinfo()->frag_list에 나머지 조각을 연결한 형태로 표현됩니다.
/* net/ipv4/ip_fragment.c */
/* inet_frag_queue: 동일 (src, dst, id, proto) 4-tuple의 조각 집합 */
struct inet_frag_queue {
struct rhash_head node; /* rhashtable 해시 노드 */
union {
struct frag_v4_compare_key v4; /* IPv4 키: src,dst,id,proto */
struct frag_v6_compare_key v6; /* IPv6 키: src,dst,id */
} key;
struct timer_list timer; /* 만료 타이머 (ipfrag_time) */
spinlock_t lock;
refcount_t refcnt;
struct rb_root rb_fragments; /* 오프셋 순 red-black tree */
struct sk_buff *fragments_tail;
ktime_t stamp;
int len; /* 현재까지 수신된 총 바이트 */
int meat; /* 중복/갭 제외 유효 바이트 */
u8 flags;
u16 max_size; /* 가장 큰 조각 크기 */
struct netns_frags *net;
struct rcu_head rcu;
};
int ip_defrag(struct net *net, struct sk_buff *skb, u32 user)
{
struct net_device *dev = skb->dev;
struct ipq *qp;
int ret;
__IP_INC_STATS(net, IPSTATS_MIB_REASMREQDS);
skb_orphan(skb); /* 소켓 참조 제거 */
/* 메모리 임계값 초과 시 오래된 큐 축출(evict) */
ip_evictor(net);
/* 4-tuple로 기존 조각 큐 탐색 또는 신규 생성 */
qp = ip_find(net, ip_hdr(skb), user, skb->dev->ifindex);
if (qp) {
ret = ip_frag_queue(qp, skb); /* 큐에 조각 삽입 */
if (ret == 1) {
/* 모든 조각 수신 완료 → 재조립 */
skb = ip_frag_reasm(qp, &dev);
ipq_put(qp);
return net_xmit_eval(ip_local_deliver(skb));
}
ipq_put(qp);
return -EINPROGRESS; /* 아직 더 기다려야 함 */
}
return -ENOMEM;
}
/* ip_frag_reasm(): frag_list 구성 */
static struct sk_buff *ip_frag_reasm(struct ipq *qp,
struct net_device **devp)
{
struct sk_buff *fp, *head = qp->q.fragments;
struct skb_shared_info *shinfo;
/* head = 첫 번째 조각 (offset==0) */
shinfo = skb_shinfo(head);
shinfo->frag_list = head->next;
head->next = NULL;
head->len = head->data_len = qp->q.len - (head->len - head->data_len);
struct iphdr *iph = ip_hdr(head);
iph->frag_off = 0;
iph->tot_len = htons(qp->q.len);
__IP_INC_STATS(qp->q.net, IPSTATS_MIB_REASMOKS);
qp->q.fragments = NULL;
return head;
}
재조립 메커니즘 상세 설명
- rb_fragments red-black tree: 조각들을 fragment offset 기준으로 정렬 유지합니다. O(log n) 삽입과 겹침(overlap) 탐지가 가능합니다.
- frag_list 구성:
ip_frag_reasm()은 head sk_buff의skb_shinfo()->frag_list에 나머지 조각들을 연결합니다. 상위 레이어는 이 비선형 skb를 단일 패킷으로 처리합니다. - -EINPROGRESS 반환: 아직 모든 조각이 도착하지 않은 경우 반환하며, skb는 큐에 보관됩니다. NF_STOLEN과 유사하게 호출자는 해당 skb를 더 이상 건드리면 안 됩니다.
- skb_orphan(): 수신된 조각을 큐에 넣기 전에 소켓 참조를 제거합니다. 조각들은 소켓이 아닌 inet_frag_queue가 소유합니다.
- ip_evictor(): 메모리 사용량이 high_thresh를 초과하면 가장 오래된 큐를 삭제합니다. ICMP Time Exceeded를 보내지 않으므로 조용히 드롭됩니다.
메모리 제한: ipfrag_high_thresh / ipfrag_low_thresh
재조립 대기 중인 조각들은 시스템 메모리를 사용합니다. Linux는 두 임계값으로 이를 제한합니다.
/* /proc/sys/net/ipv4/ipfrag_high_thresh (기본 4MB) */
/* /proc/sys/net/ipv4/ipfrag_low_thresh (기본 3MB) */
/* /proc/sys/net/ipv4/ipfrag_time (기본 30초) */
struct netns_frags {
long high_thresh; /* 축출 시작 임계값 */
long low_thresh; /* 축출 중단 임계값 */
int timeout; /* 큐 만료 시간(초) */
struct rhashtable rhashtable; /* 조각 큐 해시 테이블 */
atomic_long_t mem; /* 현재 사용 중인 메모리 */
};
/* 현재 메모리 사용량 확인: $ cat /proc/net/stat/ip_frag */
/* sysctl 튜닝 예시 — 고성능 서버: 256MB로 확장 */
/* $ sysctl -w net.ipv4.ipfrag_high_thresh=268435456 */
/* $ sysctl -w net.ipv4.ipfrag_low_thresh=201326592 */
/* $ sysctl -w net.ipv4.ipfrag_time=10 */
메모리 제한 초과 시 동작: ipfrag_high_thresh를 초과하면 ip_evictor()가 가장 오래된 조각 큐를 삭제합니다. 삭제된 큐의 첫 번째 조각에 대해 ICMP Time Exceeded를 전송하고 나머지는 조용히 드롭됩니다. 이로 인해 TCP 재전송이 발생할 수 있으므로 DDoS 공격 환경에서는 임계값을 적절히 낮게 설정하는 것이 중요합니다.
프래그먼트 공격 방어
프래그먼트 공격(Fragment Attack)은 고의적으로 비정상적인 조각을 전송해 재조립 버퍼를 소진하거나, 방화벽을 우회하는 기법입니다.
/* 겹침 조각(Overlapping Fragment) 처리 */
static int ip_frag_queue(struct ipq *qp, struct sk_buff *skb)
{
int ihl, end, flags;
struct sk_buff *prev_tail;
/* 이미 타임아웃된 큐에 조각 도착 시 즉시 드롭 */
if (qp->q.flags & INET_FRAG_COMPLETE)
goto err;
ihl = ip_hdrlen(skb);
end = offset + skb->len - ihl;
flags = ntohs(ip_hdr(skb)->frag_off);
/* 최소 8바이트: 마지막 조각 아닌데 MF=0이면 공격으로 간주 */
if ((flags & IP_MF) == 0 && end < qp->q.len) {
err = -EINVAL;
goto err;
}
/* 겹침 감지 */
prev_tail = qp->q.fragments_tail;
if (prev_tail &&
offset < prev_tail->ip_defrag_offset + prev_tail->len - ihl) {
/* 겹침 발생 → 큐 폐기 */
__IP_INC_STATS(qp->q.net, IPSTATS_MIB_REASMFAILS);
goto err;
}
/* 총 크기가 65535 초과 불가 (IP 최대 패킷 크기) */
if (end > 65535) {
__IP_INC_STATS(qp->q.net, IPSTATS_MIB_REASMFAILS);
goto err;
}
return 0;
}
RFC 5722 vs IPv4 겹침 처리 차이: IPv6는 RFC 5722에 따라 겹치는 조각이 하나라도 있으면 전체 재조립 큐를 폐기하고 ICMP Parameter Problem을 전송합니다. IPv4는 역사적으로 겹침을 허용적으로 처리했으나 Linux 4.x 이후 겹침 조각 큐를 즉시 폐기하는 방향으로 강화되었습니다.
프래그먼테이션 → 재조립 SVG 다이어그램
IPv6 프래그먼테이션 차이
IPv6는 IPv4와 달리 중간 라우터가 분할을 수행하지 않습니다. 오직 송신 단말만 분할할 수 있으며, Fragment Extension Header를 사용합니다.
/* net/ipv6/ip6_output.c */
/* IPv6 Fragment Extension Header (8바이트) 삽입 */
/* next_header | reserved | fragment_offset | M | identification */
fh->nexthdr = nexthdr;
fh->reserved = 0;
fh->frag_off = htons(offset); /* 하위 1비트: M 플래그 */
fh->identification = frag_id; /* 32비트 (IPv4보다 큼) */
/* IPv6 최소 MTU: 1280 바이트 (RFC 8200) */
/* 라우터가 분할 불가하므로 ICMP Packet Too Big 반환 */
/* net/ipv6/reassembly.c: 겹침 즉시 전체 큐 폐기 (RFC 5722) */
if (ip6_frag_too_far(fq) || ip6_frag_overlap(fq, skb)) {
inet_frag_kill(&fq->q);
icmpv6_send(skb, ICMPV6_PARAMPROB, ICMPV6_HDR_FIELD, 0);
goto discard;
}
IPv4 vs IPv6 프래그먼테이션 비교
| 항목 | IPv4 | IPv6 |
|---|---|---|
| 중간 라우터 분할 | 허용 | 금지 (RFC 8200) |
| Fragment ID | 16비트 | 32비트 |
| 최소 MTU | 68바이트 | 1280바이트 |
| 겹침 처리 | 허용적 (잘라냄) | 즉시 큐 폐기 (RFC 5722) |
| 분할 헤더 | IP 헤더 필드 | Fragment Extension Header |
| 재조립 코드 | ip_defrag() | ipv6_frag_rcv() |
| sysctl 네임스페이스 | net.ipv4.ipfrag_* | net.ipv6.ip6frag_* |
성능 영향과 튜닝
/* 프래그먼테이션 통계 확인 */
/* $ cat /proc/net/snmp | grep -i frag */
/* Ip: ReasmReqds ReasmOKs ReasmFails FragOKs FragFails FragCreates */
/* PMTU Discovery 활성화로 프래그먼테이션 회피 */
/* $ sysctl -w net.ipv4.ip_no_pmtu_disc=0 # 기본값 */
/* 재조립 메모리 모니터링: $ cat /proc/net/stat/ip_frag */
/* 열: timeout queue mem(KB) hash backlog oom */
/* GRO는 IP 프래그먼트를 병합하지 않음 (오직 TCP/UDP 세그먼트만) */
/* → IP 프래그먼트는 항상 소프트웨어 재조립 경로 통과 */
/* BIG TCP (Linux 5.19+): jumbo frame 없이 64KB 초과 세그먼트 */
/* $ ip link set eth0 gso_max_size 65536 */
실전 네트워크 드라이버의 skb 사용 패턴
네트워크 드라이버는 sk_buff를 할당·구성·전달·해제하는 주체입니다. 드라이버의 skb 처리 방식은 성능에 직접적인 영향을 미치며, 커널 버전과 하드웨어 기능에 따라 다양한 패턴이 사용됩니다.
패턴 1: 기본 수신 — 전통적 copybreak
copybreak 패턴은 소형 패킷은 복사(copy)하고, 대형 패킷은 페이지를 직접 참조합니다. 이를 통해 DMA 버퍼 재활용과 메모리 낭비 감소를 동시에 달성합니다.
/* 전통적 copybreak 수신 패턴 (e.g., igb, e1000e 계열) */
#define COPYBREAK_DEFAULT 256 /* 256바이트 이하: 복사 */
static void driver_clean_rx_irq(struct driver_ring *rx_ring, int budget)
{
struct sk_buff *skb;
unsigned int size;
while (budget--) {
size = le16_to_cpu(rx_desc->wb.upper.length);
if (size <= rx_ring->rx_copybreak) {
/* 소형 패킷: 새 skb 할당 후 복사 */
skb = napi_alloc_skb(&rx_ring->napi, size);
if (unlikely(!skb))
goto drop;
dma_sync_single_for_cpu(rx_ring->dev,
rx_buffer->dma, size, DMA_FROM_DEVICE);
skb_put_data(skb,
page_address(rx_buffer->page) + rx_buffer->page_offset,
size);
/* DMA 버퍼는 그대로 재활용 가능 */
} else {
/* 대형 패킷: build_skb로 DMA 버퍼를 skb에 직접 연결 */
dma_unmap_single(rx_ring->dev, rx_buffer->dma,
rx_ring->rx_buf_len, DMA_FROM_DEVICE);
skb = build_skb(rx_buffer->page_address,
rx_ring->rx_buf_len + SKB_DATA_ALIGN(
sizeof(struct skb_shared_info)));
if (unlikely(!skb))
goto drop;
skb_put(skb, size);
rx_buffer->page = NULL; /* 페이지 소유권 이전 */
}
napi_gro_receive(&rx_ring->napi, skb);
}
}
copybreak 패턴 설명
- copybreak 임계값: 일반적으로 256~1024바이트로 설정합니다. 소형 패킷은 복사 비용이 작고, DMA 버퍼를 계속 재사용할 수 있어 이득입니다.
- napi_alloc_skb(): NAPI 컨텍스트 전용 할당자.
napi_alloc_cacheper-CPU 캐시를 사용해 일반alloc_skb()보다 빠릅니다. - build_skb(): 이미 할당된 메모리(DMA 버퍼)를 sk_buff의 데이터 영역으로 래핑합니다. 추가 메모리 복사 없이 zero-copy 수신이 가능합니다.
- dma_sync_single_for_cpu(): 복사하는 경우에만 CPU 가시성을 보장합니다. build_skb 경로에서는 dma_unmap이 필요합니다.
패턴 2: Header Split — 헤더/페이로드 분리 수신
최신 NIC는 헤더를 선형 영역에, 페이로드를 page fragment에 별도 저장하는 Header Split을 지원합니다. 이를 통해 헤더 파싱 성능과 페이로드 zero-copy를 동시에 달성합니다.
/* Header Split 패턴 (mlx5, ice, bnxt 계열) */
static struct sk_buff *driver_build_skb_header_split(
struct driver_rx_ring *ring,
struct driver_rx_desc *desc)
{
struct sk_buff *skb;
void *hdr_buf = ring->hdr_buf[desc->hdr_buf_idx];
u16 hdr_len = desc->hdr_len; /* NIC가 파싱한 헤더 길이 */
u16 data_len = desc->data_len; /* 페이로드 길이 */
/* 헤더: 선형(linear) 영역 — napi_alloc_skb */
skb = napi_alloc_skb(&ring->napi, hdr_len);
if (unlikely(!skb))
return NULL;
skb_put_data(skb, hdr_buf, hdr_len);
if (data_len) {
/* 페이로드: page fragment로 추가 — 복사 없음 */
struct page *page = ring->rx_pages[desc->data_buf_idx];
skb_add_rx_frag(skb, skb_shinfo(skb)->nr_frags,
page, desc->data_buf_offset, data_len,
ring->truesize);
get_page(page);
}
/* skb->data_len > 0 → 비선형 패킷 */
/* 상위 스택은 pskb_may_pull()로 필요 시 선형화 */
return skb;
}
/* skb_add_rx_frag() 내부 */
static inline void skb_add_rx_frag(struct sk_buff *skb, int i,
struct page *page, int off,
int size, unsigned int truesize)
{
skb_fill_page_desc(skb, i, page, off, size); /* frags[i] 설정 */
skb->len += size;
skb->data_len += size;
skb->truesize += truesize; /* 소켓 버퍼 메모리 추적 */
}
Header Split 패턴의 장단점
- 장점: TCP payload가 page fragment에 위치하므로 sendfile/splice로 zero-copy 전달 가능. NIC가 헤더를 별도 버퍼에 DMA하므로 CPU 캐시 친화적입니다.
- 단점: 비선형 skb이므로 단순 포인터 접근 불가.
pskb_may_pull(),skb_copy_bits()사용이 필요합니다. - truesize 중요성: page fragment의 truesize는 실제 페이지 크기(보통 4096B)로 설정해야 소켓 메모리 accounting이 정확합니다.
패턴 3: page_pool 기반 수신 (Linux 6.x 현대 드라이버)
page_pool(페이지 풀)은 DMA 매핑 캐싱과 페이지 재활용을 통합하는 Linux 5.x+ 표준 API입니다. 현대 고성능 드라이버의 핵심 할당 메커니즘입니다.
/* page_pool 초기화 */
struct page_pool_params pp_params = {
.order = 0, /* 4KB 단일 페이지 */
.pool_size = 512, /* 풀 크기 */
.nid = dev_to_node(dev), /* NUMA 노드 */
.dev = ring->dev, /* DMA 디바이스 */
.dma_dir = DMA_FROM_DEVICE,
.offset = NET_SKB_PAD, /* headroom */
.max_len = PAGE_SIZE - NET_SKB_PAD,
.flags = PP_FLAG_DMA_MAP | PP_FLAG_DMA_SYNC_DEV,
};
ring->page_pool = page_pool_create(&pp_params);
/* 수신 처리: napi_build_skb + page_pool 반환 */
static struct sk_buff *driver_rx_page_pool(
struct driver_ring *ring, struct page *page, u32 size)
{
struct sk_buff *skb;
/* DMA sync: CPU가 데이터를 읽기 전 캐시 일관성 보장 */
page_pool_dma_sync_for_cpu(ring->page_pool, page,
ring->pp_params.offset, size);
/* napi_build_skb: page_pool 인식 — 해제 시 자동 pool 반환 */
skb = napi_build_skb(page_address(page), PAGE_SIZE);
if (unlikely(!skb)) {
page_pool_recycle_direct(ring->page_pool, page);
return NULL;
}
skb_reserve(skb, ring->pp_params.offset);
skb_put(skb, size);
/* skb가 해제될 때 page_pool에 자동 반환됨 */
return skb;
}
page_pool의 핵심 장점
- DMA 매핑 캐시: page_pool은 페이지를 해제하지 않고 풀로 반환합니다. DMA 매핑이 유지되므로 다음 수신 시
dma_map_single()비용이 없습니다. - napi_build_skb():
build_skb()와 달리 page_pool 메타데이터를 skb에 기록합니다. skb 해제 시 자동으로page_pool_recycle_direct()가 호출됩니다. - PP_FLAG_DMA_SYNC_DEV: 이 플래그를 설정하면 page_pool이 자동으로
dma_sync_for_device()를 호출하므로 드라이버에서 직접 호출할 필요가 없습니다. - NUMA 친화적:
nid파라미터로 NIC와 같은 NUMA 노드의 메모리를 사용해 PCIe DMA 지연을 최소화합니다.
패턴 4: XDP → build_skb 변환
XDP_PASS를 반환한 패킷은 xdp_buff에서 sk_buff로 변환되어 일반 네트워크 스택에 진입합니다.
/* XDP → skb 변환 경로 */
static struct sk_buff *driver_xdp_build_skb(
struct driver_ring *ring, struct xdp_buff *xdp)
{
struct sk_buff *skb;
unsigned int metasize = xdp->data - xdp->data_meta;
unsigned int headroom = xdp->data - xdp->data_hard_start;
unsigned int datasize = xdp->data_end - xdp->data;
/* xdp_buff 메모리를 그대로 재사용: 추가 할당 없음 */
skb = build_skb(xdp->data_hard_start,
xdp_data_hard_end(xdp) - xdp->data_hard_start +
SKB_DATA_ALIGN(sizeof(struct skb_shared_info)));
if (unlikely(!skb))
return NULL;
skb_reserve(skb, headroom);
__skb_put(skb, datasize);
if (metasize)
skb_metadata_set(skb, metasize); /* XDP metadata → skb cb */
/* XDP multi-buffer 처리 (jumbo frame, Linux 5.18+) */
if (xdp_buff_has_frags(xdp)) {
struct skb_shared_info *sinfo = xdp_get_shared_info_from_buff(xdp);
int i;
skb_shinfo(skb)->nr_frags = sinfo->nr_frags;
for (i = 0; i < sinfo->nr_frags; i++) {
skb_shinfo(skb)->frags[i] = sinfo->frags[i];
skb_frag_ref(skb, i);
}
skb->data_len = sinfo->xdp_frags_size;
skb->len += skb->data_len;
}
return skb;
}
패턴 5: TX 완료 처리와 BQL
전송 완료(TX completion) 처리는 DMA 매핑 해제와 sk_buff 해제를 수행합니다. BQL(Byte Queue Limits)은 전송 큐 깊이를 동적으로 조정해 지연을 최소화합니다.
/* TX 완료 처리 (NAPI poll 내에서 호출) */
static void driver_clean_tx(struct driver_tx_ring *tx_ring)
{
struct sk_buff *skb;
unsigned int total_bytes = 0, total_pkts = 0;
while (driver_tx_desc_done(tx_ring)) {
skb = tx_ring->tx_buffer[tx_ring->next_to_clean].skb;
if (!skb)
goto next_desc;
/* DMA 매핑 해제 */
dma_unmap_single(tx_ring->dev,
dma_unmap_addr(&tx_ring->tx_buffer[tx_ring->next_to_clean], dma),
dma_unmap_len(&tx_ring->tx_buffer[tx_ring->next_to_clean], len),
DMA_TO_DEVICE);
total_bytes += skb->len;
total_pkts++;
napi_consume_skb(skb, budget); /* consume_skb + NAPI 힌트 */
tx_ring->tx_buffer[tx_ring->next_to_clean].skb = NULL;
next_desc:
tx_ring->next_to_clean = driver_ring_next(tx_ring->next_to_clean,
tx_ring->count);
}
/* BQL 완료 보고: 큐 깊이 동적 조정 */
netdev_tx_completed_queue(txring_txq(tx_ring), total_pkts, total_bytes);
if (netif_tx_queue_stopped(txring_txq(tx_ring)) &&
driver_tx_has_space(tx_ring))
netif_tx_wake_queue(txring_txq(tx_ring));
}
/* BQL 전송 시작 시 보고 */
static netdev_tx_t driver_xmit_frame(struct sk_buff *skb,
struct net_device *netdev)
{
if (driver_tx_ring_full(tx_ring)) {
netif_tx_stop_queue(txring_txq(tx_ring));
return NETDEV_TX_BUSY;
}
driver_tx_map_and_queue(tx_ring, skb);
netdev_tx_sent_queue(txring_txq(tx_ring), skb->len); /* BQL 보고 */
driver_tx_kick(tx_ring);
return NETDEV_TX_OK;
}
BQL(Byte Queue Limits) 메커니즘
- 목적: BQL은 네트워크 인터페이스 TX 큐에서 버퍼링되는 바이트 수를 동적으로 제한합니다. 큐가 너무 깊으면 latency가 증가하고, 너무 얕으면 throughput이 감소합니다.
- netdev_tx_sent_queue(): 전송을 시작하기 전에 호출합니다. 큐에 들어간 바이트를 추적합니다.
- netdev_tx_completed_queue(): TX 완료 인터럽트에서 호출합니다. 완료된 바이트를 반영하고 큐 제한을 동적으로 조정합니다.
- napi_consume_skb():
consume_skb()에 NAPI poll 컨텍스트 힌트를 추가합니다. NAPI가 활성화된 경우 defer-free를 활용해 성능을 향상시킵니다.
패턴 6: Scatter-Gather TX와 DMA 매핑
/* Scatter-Gather TX: 비선형 skb의 모든 세그먼트 DMA 매핑 */
static int driver_tx_map_skb(struct driver_tx_ring *tx_ring,
struct sk_buff *skb)
{
dma_addr_t dma;
u16 i, count = 0;
/* 1. 선형(head) 데이터 매핑 */
dma = dma_map_single(tx_ring->dev, skb->data,
skb_headlen(skb), DMA_TO_DEVICE);
if (dma_mapping_error(tx_ring->dev, dma))
goto dma_error;
driver_set_tx_desc(tx_ring, dma, skb_headlen(skb), TX_DESC_FIRST);
count++;
/* 2. frags[] 각 페이지 매핑 */
for (i = 0; i < skb_shinfo(skb)->nr_frags; i++) {
const struct skb_frag_struct *frag = &skb_shinfo(skb)->frags[i];
dma = skb_frag_dma_map(tx_ring->dev, frag, 0,
skb_frag_size(frag), DMA_TO_DEVICE);
if (dma_mapping_error(tx_ring->dev, dma))
goto dma_error;
driver_set_tx_desc(tx_ring, dma, skb_frag_size(frag),
i + 1 == skb_shinfo(skb)->nr_frags ?
TX_DESC_LAST : TX_DESC_MIDDLE);
count++;
}
/* 3. frag_list 처리 */
if (skb_has_frag_list(skb)) {
struct sk_buff *fskb;
skb_walk_frags(skb, fskb)
count += driver_tx_map_skb_frag(tx_ring, fskb);
}
return count;
}
패턴 7: NAPI poll 내부 skb 배치 처리
/* NAPI poll: 배치로 skb 처리하여 인터럽트 오버헤드 감소 */
static int driver_napi_poll(struct napi_struct *napi, int budget)
{
struct driver_ring *rx_ring =
container_of(napi, struct driver_ring, napi);
int cleaned_count = 0;
/* TX 완료 처리: RX보다 먼저 수행하여 메모리 조기 해제 */
driver_clean_tx(driver_tx_ring(rx_ring));
while (cleaned_count < budget) {
struct sk_buff *skb = driver_fetch_rx_skb(rx_ring);
if (!skb)
break;
/* GRO 처리: 동일 5-tuple 패킷 병합 */
if (napi_gro_receive(napi, skb) == GRO_DROP)
rx_ring->rx_stats.drop++;
cleaned_count++;
/* 주기적으로 RX 링 버퍼 보충 */
if (cleaned_count % 16 == 0)
driver_alloc_rx_buffers(rx_ring);
}
/* budget 미달: 모두 처리 완료 → 인터럽트 재활성화 */
if (cleaned_count < budget) {
napi_complete_done(napi, cleaned_count);
driver_enable_irq(rx_ring);
}
driver_alloc_rx_buffers(rx_ring);
return cleaned_count;
}
링 버퍼 ↔ skb 관계 SVG 다이어그램
드라이버 일반 버그와 예방
/* 버그 1: DMA 언매핑 경로 누락 → IOMMU 리소스 고갈 */
/* 잘못된 코드 */
if (driver_build_skb(ring, page, size) == NULL) {
page_pool_recycle_direct(ring->pool, page); /* DMA 매핑 해제 없음! */
return;
}
/* 올바른 코드 */
if (driver_build_skb(ring, page, size) == NULL) {
page_pool_put_full_page(ring->pool, page, false); /* DMA 포함 반환 */
return;
}
/* 버그 2: NAPI context에서 GFP_KERNEL 사용 */
skb = alloc_skb(size, GFP_KERNEL); /* NAPI poll = 소프트 인터럽트 → 금지! */
skb = napi_alloc_skb(&ring->napi, size); /* 올바른 코드 */
/* 버그 3: budget 초과 처리 → softirq 독점 */
static int good_napi_poll(struct napi_struct *napi, int budget)
{
int work = 0;
while (work < budget && driver_rx_pending()) { /* budget 준수 필수 */
process_skb();
work++;
}
if (work < budget)
napi_complete_done(napi, work);
return work;
}
/* 버그 4: skb headroom 부족 → L3/L4 헤더 추가 실패 */
if (skb_cow_head(skb, needed_headroom)) {
kfree_skb(skb); /* cow_head 실패 = ENOMEM → 드롭 */
return -ENOMEM;
}
/* napi_alloc_skb(napi, len) 사용 시 자동으로 NET_SKB_PAD 확보 */
고급 디버깅: bpftrace와 perf를 활용한 skb 분석
sk_buff 관련 문제는 패킷 드롭, 메모리 누수, 성능 병목 등 다양한 형태로 나타납니다. Linux 커널은 bpftrace, perf, ftrace, kmemleak 등 강력한 도구를 제공하며, 이를 조합해 운영 환경에서도 안전하게 문제를 진단할 수 있습니다.
bpftrace one-liner 모음
### 1. kfree_skb 드롭 원인 히스토그램 (Linux 5.17+ reason 파라미터)
bpftrace -e 'tracepoint:skb:kfree_skb {
@reason[args->reason] = count();
}
interval:s:5 { print(@reason); clear(@reason); }'
### 2. skb 수명 측정 (alloc → free 지연)
bpftrace -e 'kretprobe:__alloc_skb { @birth[retval] = nsecs; }
kprobe:kfree_skb / @birth[arg0] / {
@latency_us = hist((nsecs - @birth[arg0]) / 1000);
delete(@birth[arg0]);
}'
### 3. 드롭 핫스팟 (커널 스택 기준)
bpftrace -e 'tracepoint:skb:kfree_skb { @[kstack(5)] = count(); }
END { print(@); }'
### 4. 특정 포트 드롭 모니터링 (TCP 443만)
bpftrace -e 'tracepoint:skb:kfree_skb {
$skb = (struct sk_buff *)args->skbaddr;
$th = (struct tcphdr *)($skb->head + $skb->transport_header);
if ($th->dest == 443 || $th->dest == bswap((uint16)443))
@drops_port443[kstack(3)] = count();
}'
### 5. skb clone/copy 비율 (zero-copy 효율 측정)
bpftrace -e 'kprobe:skb_clone { @clone = count(); }
kprobe:skb_copy { @copy = count(); }
interval:s:5 { printf("clone=%d copy=%d
", @clone, @copy);
clear(@clone); clear(@copy); }'
### 6. GRO 병합 효율 측정
bpftrace -e 'kretprobe:napi_gro_receive {
@gro_result[retval] = count();
/* GRO_MERGED=1, GRO_HELD=3, GRO_NORMAL=4, GRO_DROP=5 */
}
interval:s:5 { print(@gro_result); clear(@gro_result); }'
### 7. TCP 소켓 버퍼 초과 드롭 추적
bpftrace -e 'kprobe:tcp_add_backlog { @backlog_drops[comm] = count(); }'
### 8. skb->truesize 분포 (메모리 사용 패턴)
bpftrace -e 'kprobe:__alloc_skb { @truesize = hist(arg0); }
END { print(@truesize); }'
### 9. TX 큐 중단(queue stop) 빈도
bpftrace -e 'kprobe:netif_tx_stop_queue {
$q = (struct netdev_queue *)arg0;
@[str($q->dev->name)] = count();
}
interval:s:1 { print(@); clear(@); }'
### 10. page_pool recycle 히트율
bpftrace -e 'kprobe:page_pool_alloc_pages { @alloc = count(); }
kprobe:page_pool_return_skb_page { @recycle = count(); }
interval:s:5 {
printf("alloc=%d recycle=%d hit=%d%%
",
@alloc, @recycle,
@alloc > 0 ? @recycle * 100 / @alloc : 0);
clear(@alloc); clear(@recycle); }'
### 11. skb_ext 사용 패턴 (conntrack, ipsec 등)
bpftrace -e 'kprobe:skb_ext_add { @ext_type[arg1] = count(); }
END { print(@ext_type); }'
### 12. 수신 경로 함수 지연 분포
bpftrace -e 'kprobe:netif_receive_skb { @start[tid] = nsecs; }
kretprobe:netif_receive_skb {
@rx_lat_us = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}
END { print(@rx_lat_us); }'
perf probe를 활용한 skb 추적
# kfree_skb 발생 위치와 호출 스택 분석
sudo perf probe -a 'kfree_skb skb:x64 location:x64'
sudo perf record -e probe:kfree_skb -g --call-graph dwarf -- sleep 10
sudo perf report --call-graph --stdio | head -100
# 특정 함수의 인수 추출
sudo perf probe -a 'ip_drop_skb_reason skb reason'
sudo perf record -e probe:ip_drop_skb_reason -a -- sleep 5
sudo perf script | awk '{print $NF}' | sort | uniq -c | sort -rn
# TCP 수신 경로 성능 분석
sudo perf record -e cycles:pp -g -a --call-graph fp -- sleep 10
sudo perf report --sort=symbol --no-children | grep -E 'tcp|skb|napi'
# skb 처리 CPU별 분포
sudo perf stat -e skb:consume_skb,skb:kfree_skb -a --per-cpu -- sleep 5
# netif_receive_skb 지점 동적 프로브
sudo perf probe --add='netif_receive_skb skb->len:u32'
sudo perf record -e probe:netif_receive_skb -a -- sleep 5
sudo perf script | awk '{print $NF}' | \
awk -F= '{sum+=$2; cnt++} END {print "avg_skb_len:", sum/cnt}'
ftrace function_graph로 skb 호출 체인 시각화
# TCP 수신 경로 호출 체인 추적
echo function_graph > /sys/kernel/debug/tracing/current_tracer
echo 'tcp_v4_rcv' > /sys/kernel/debug/tracing/set_graph_function
echo 1 > /sys/kernel/debug/tracing/tracing_on
sleep 1
echo 0 > /sys/kernel/debug/tracing/tracing_on
cat /sys/kernel/debug/tracing/trace | head -80
# 출력 예시:
# 0) | tcp_v4_rcv() {
# 0) 0.312 us | __inet_lookup_skb();
# 0) | tcp_rcv_established() {
# 0) 0.128 us | tcp_queue_rcv();
# 0) }
# kfree_skb 호출 체인 추적
echo function > /sys/kernel/debug/tracing/current_tracer
echo kfree_skb > /sys/kernel/debug/tracing/set_ftrace_filter
echo 1 > /sys/kernel/debug/tracing/options/func_stack_trace
echo 1 > /sys/kernel/debug/tracing/tracing_on
sleep 2
echo 0 > /sys/kernel/debug/tracing/tracing_on
grep -A 20 'kfree_skb' /sys/kernel/debug/tracing/trace | head -60
kfree_skb_reason 히스토그램 분석
/* Linux 5.17+: kfree_skb에 reason 파라미터 추가 */
/* include/net/dropreason-core.h */
enum skb_drop_reason {
SKB_DROP_REASON_NOT_SPECIFIED = 1,
SKB_DROP_REASON_NO_SOCKET, /* 소켓 없음 */
SKB_DROP_REASON_PKT_TOO_SMALL, /* 패킷 크기 부족 */
SKB_DROP_REASON_TCP_CSUM, /* TCP 체크섬 오류 */
SKB_DROP_REASON_SOCKET_FILTER, /* BPF 소켓 필터 드롭 */
SKB_DROP_REASON_UDP_CSUM, /* UDP 체크섬 오류 */
SKB_DROP_REASON_NETFILTER_DROP, /* netfilter 규칙 드롭 */
SKB_DROP_REASON_IP_INHDR, /* IP 헤더 오류 */
SKB_DROP_REASON_IP_RPFILTER, /* Reverse path filter */
SKB_DROP_REASON_FLOW_LIMIT, /* RPS flow limit 초과 */
SKB_DROP_REASON_MAX,
};
# dropwatch로 실시간 드롭 위치 확인
# sudo dropwatch -l kas
# 출력: Drops at: ip_rcv_finish+0x4c ... 등 심볼 기반 위치
# /proc/net/stat/nf_conntrack 에서 conntrack 관련 드롭 확인
# cat /proc/net/stat/nf_conntrack | awk '{print "drop:", $3, "insert_failed:", $4}'
주요 drop reason 해석 가이드
| reason | 원인 | 확인 방법 |
|---|---|---|
| NO_SOCKET | 소켓이 없음 (UDP 미수신 포트) | ss -u로 포트 확인 |
| TCP_CSUM | TCP 체크섬 오류 | NIC 오프로드 설정 확인 |
| NETFILTER_DROP | iptables/nftables 규칙 | iptables -L -v -n |
| IP_RPFILTER | 역방향 경로 필터 | sysctl net.ipv4.conf.*.rp_filter |
| SOCKET_FILTER | BPF 소켓 필터 드롭 | tcpdump 중단 후 재확인 |
| FLOW_LIMIT | RPS 플로우 제한 | sysctl net.core.flow_limit_table_len |
| NOT_SPECIFIED | 구버전 코드 경로 | 커널 업그레이드 또는 소스 추적 |
skb 수명 추적: alloc → free 지연 측정
### skb 수명 전체 추적
bpftrace -e 'kretprobe:__alloc_skb / retval != 0 / { @birth[retval] = nsecs; }
kprobe:kfree_skb / @birth[arg0] / {
$us = (nsecs - @birth[arg0]) / 1000;
@skb_lifetime_us = hist($us);
if ($us > 1000000)
printf("WARN: long-lived skb %p: %d us
", arg0, $us);
delete(@birth[arg0]);
}
END { print(@skb_lifetime_us); }'
### 클론 vs 원본 skb 수명 비교
bpftrace -e 'kretprobe:skb_clone / retval != 0 / { @is_clone[retval] = 1; @birth[retval] = nsecs; }
kretprobe:__alloc_skb / retval != 0 / { @birth[retval] = nsecs; }
kprobe:kfree_skb / @birth[arg0] / {
$us = (nsecs - @birth[arg0]) / 1000;
if (@is_clone[arg0]) { @clone_life = hist($us); }
else { @orig_life = hist($us); }
delete(@birth[arg0]); delete(@is_clone[arg0]);
}
END { print(@orig_life); print(@clone_life); }'
kmemleak을 활용한 skb 메모리 누수 탐지
# kmemleak 활성화 (CONFIG_DEBUG_KMEMLEAK=y 필요)
echo scan > /sys/kernel/debug/kmemleak
cat /sys/kernel/debug/kmemleak | grep -A 5 'sk_buff\|alloc_skb'
# 드라이버 모듈 특정 누수 확인
echo clear > /sys/kernel/debug/kmemleak
modprobe my_driver
iperf3 -c TARGET -t 60 -P 8 # 부하 유발
echo scan > /sys/kernel/debug/kmemleak
cat /sys/kernel/debug/kmemleak
/* 드라이버 코드 누수 패턴: 에러 경로에서 skb 해제 누락 */
static netdev_tx_t good_start_xmit(struct sk_buff *skb, ...)
{
if (driver_hw_error()) {
dev_kfree_skb_any(skb); /* 에러 → skb 해제 필수 */
return NETDEV_TX_OK;
}
if (driver_tx_full())
return NETDEV_TX_BUSY; /* BUSY: 상위가 재시도 → skb 반환 불필요 */
driver_queue_skb(skb);
return NETDEV_TX_OK;
}
crash tool을 활용한 사후 분석
# vmcore에서 sk_buff 체인 분석
# crash /usr/lib/debug/lib/modules/$(uname -r)/vmlinux vmcore
# crash 세션 내 sk_buff 구조체 출력
# crash> struct sk_buff 0xffff888012345000
# crash> list sk_buff.next -s sk_buff.len,data_len \
# -H ((struct softnet_data *)&softnet_data)->input_queue_head
# TCP 소켓 수신 큐 skb 목록
# crash> list sk_buff.next -s sk_buff.len \
# -H 0xffff888056789000+0x280 # sk_receive_queue 오프셋
# skb_shinfo 접근
# crash> struct skb_shared_info \
# (skb->end - sizeof(struct skb_shared_info))
eBPF TC 프로그램으로 skb 검사
/* TC BPF 프로그램: skb 내용 검사 및 통계 수집 */
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <linux/pkt_cls.h>
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, __u32); /* src IP */
__type(value, __u64); /* 패킷 수 */
} pkt_count SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 20);
} events SEC(".maps");
SEC("tc")
int tc_skb_inspector(struct __sk_buff *skb)
{
void *data = (void *)(long)skb->data;
void *data_end = (void *)(long)skb->data_end;
struct iphdr *iph = data + 14; /* ETH_HLEN */
struct tcphdr *tcph;
if ((void *)(iph + 1) > data_end || iph->protocol != IPPROTO_TCP)
return TC_ACT_OK;
tcph = (void *)iph + iph->ihl * 4;
if ((void *)(tcph + 1) > data_end)
return TC_ACT_OK;
__u32 src = iph->saddr;
__u64 *cnt = bpf_map_lookup_elem(&pkt_count, &src);
if (cnt) __sync_fetch_and_add(cnt, 1);
else { __u64 one = 1; bpf_map_update_elem(&pkt_count, &src, &one, BPF_ANY); }
return TC_ACT_OK;
}
char LICENSE[] SEC("license") = "GPL";
# TC BPF 프로그램 로드 및 연결
clang -O2 -target bpf -c tc_skb_inspector.c -o tc_skb.o
tc qdisc add dev eth0 clsact
tc filter add dev eth0 ingress bpf direct-action obj tc_skb.o sec tc
bpftool map dump id 5 # pkt_count 맵 내용 확인
# 정리
tc filter del dev eth0 ingress
tc qdisc del dev eth0 clsact
/proc/net/softnet_stat 상세 해석
# /proc/net/softnet_stat: 각 행 = CPU N의 통계 (공백 구분 16진수)
cat /proc/net/softnet_stat
/* 열 의미 (커널 5.15+ 기준) */
/* 열 00: total — 처리된 총 패킷 수 */
/* 열 01: dropped — backlog 초과 드롭 */
/* 열 02: time_squeeze — budget/time 소진 횟수 */
/* 열 09: received_rps — RPS로 넘어온 패킷 수 */
/* 열 10: flow_limit_count — flow limit 드롭 수 */
# 파싱 스크립트
awk 'NR==1 { printf "%-4s %12s %10s %10s
", "CPU","total","dropped","squeeze" }
{ printf "%-4s %12d %10d %10d
", NR-1,
strtonum("0x"$1), strtonum("0x"$2), strtonum("0x"$3) }' \
/proc/net/softnet_stat
# time_squeeze 높은 경우: netdev_budget 증가
sysctl -w net.core.netdev_budget=600
sysctl -w net.core.netdev_budget_usecs=8000
# dropped 높은 경우: backlog 큐 크기 증가
sysctl -w net.core.netdev_max_backlog=10000
nstat / ss 출력 해석
# nstat: 커널 MIB 카운터 변화량 모니터링
nstat -z | grep -E 'IpReasmFails|IpFragFails|TcpRetrans|UdpRcvbufErrors'
# IpReasmFails: IP 재조립 실패 (조각 타임아웃, 메모리 부족)
# IpFragFails: DF 설정 패킷의 MTU 초과 전송 실패
# UdpRcvbufErrors: UDP 소켓 수신 버퍼 초과 드롭
# ss: 소켓 상태와 skb 버퍼 정보
ss -tnip # Recv-Q/Send-Q 확인
# Recv-Q: 소켓 수신 버퍼에 대기 중인 바이트
# Send-Q: 전송 버퍼에서 ACK 미수신 바이트
ss -tnp | awk '$3 > 10000 {print "HIGH_RECV_Q:", $0}'
# 전체 시스템 TCP 수신 드롭 원인
nstat | grep -E 'TcpExtTCPBacklogDrop|TcpExtListenDrops|TcpExtTCPMinTTLDrop'
# TCPBacklogDrop: accept 큐 초과 드롭
# ListenDrops: SYN 큐(반연결 큐) 포화 드롭
실전 디버깅 세션 예시
다음은 "특정 서버에서 패킷 드롭이 급증한다"는 신고를 받았을 때의 실전 디버깅 절차입니다.
### Step 1: 드롭 규모 파악
watch -n 1 'nstat | grep -E "IpInDiscards|UdpInErrors|TcpExtTCPBacklogDrop"'
### Step 2: softnet_stat으로 CPU별 드롭 확인
awk '{printf "CPU%d: dropped=%d squeeze=%d
", NR-1,
strtonum("0x"$2), strtonum("0x"$3)}' /proc/net/softnet_stat
# → CPU 3의 dropped 값이 급증 → RPS 불균형 또는 backlog 포화 의심
### Step 3: kfree_skb reason으로 드롭 원인 식별
bpftrace -e 'tracepoint:skb:kfree_skb { @[args->reason, kstack(3)] = count(); }
interval:s:10 { print(@); exit(); }'
# → SKB_DROP_REASON_TCP_CSUM → NIC 오프로드 비활성화 또는 중간 장비 문제
### Step 4: NIC 오프로드 상태 확인
ethtool -k eth0 | grep -E 'checksum|scatter|gso|gro'
# tx-checksumming: off ← 오프로드 꺼짐 → 체크섬 오류 발생 가능
### Step 5: 체크섬 오프로드 재활성화
ethtool -K eth0 tx-checksumming on rx-checksumming on
### Step 6: 효과 확인
nstat -az | grep TcpExtTCPAbortOnTimeout
# 카운터 증가 멈춤 확인 → 문제 해결
### 대안 시나리오: conntrack 테이블 포화
sysctl net.netfilter.nf_conntrack_count # 현재 수
sysctl net.netfilter.nf_conntrack_max # 최대값
# 90% 이상이면 위험 → 증가 필요
sysctl -w net.netfilter.nf_conntrack_max=1048576
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=300
디버깅 도구 요약 비교
| 도구 | 용도 | 오버헤드 | 운영 환경 적합도 |
|---|---|---|---|
| bpftrace | 동적 추적, 히스토그램, 스택 | 낮음~중간 | 적합 (복잡한 스크립트 주의) |
| perf probe | 정적/동적 프로브, 성능 분석 | 낮음 | 적합 |
| ftrace | 함수 호출 체인 시각화 | 중간 | 단시간 사용 권장 |
| dropwatch | 드롭 위치 심볼화 | 낮음 | 적합 |
| nstat | MIB 카운터 변화량 | 거의 없음 | 항상 적합 |
| ss/netstat | 소켓 상태, 큐 크기 | 거의 없음 | 항상 적합 |
| kmemleak | 메모리 누수 탐지 | 높음 | 개발/스테이징 전용 |
| crash | vmcore 사후 분석 | 없음 (오프라인) | 장애 후 분석 전용 |
운영 환경 디버깅 원칙: bpftrace와 perf는 런타임 오버헤드가 낮아 운영 환경에서도 사용 가능하지만, 복잡한 kprobe 스크립트는 특정 커널 경로에서 성능 영향을 줄 수 있습니다. ftrace의 function_graph는 추적 대상 함수 수에 비례해 오버헤드가 증가하므로 운영 환경에서는 필요한 함수만 최소한으로 지정하는 것이 중요합니다. kmemleak은 메모리 할당마다 추적 데이터를 유지하므로 운영 환경에는 권장하지 않습니다.
실습 커널 모듈: sk_buff 조작
이 절에서는 sk_buff를 직접 다루는 세 가지 완전한 커널 모듈 예제를 제공합니다. 각 예제는 실제로 빌드·적재·테스트가 가능하며, 핵심 패턴에 대한 상세한 설명을 포함합니다.
예제 1: Netfilter 패킷 인스펙터 (pkt_inspector.c)
목적: NF_INET_PRE_ROUTING 훅에서 인입 패킷의 L2/L3/L4 헤더를 안전하게 파싱하고 로그로 출력합니다. pskb_may_pull()을 사용해 비선형(Non-linear) sk_buff에서도 안전하게 헤더를 읽는 패턴을 보여줍니다.
/* pkt_inspector.c — Netfilter 기반 패킷 인스펙터 */
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/netfilter.h>
#include <linux/netfilter_ipv4.h>
#include <linux/ip.h>
#include <linux/tcp.h>
#include <linux/udp.h>
#include <linux/skbuff.h>
#include <linux/netdevice.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("skb-lab");
MODULE_DESCRIPTION("Netfilter packet inspector");
static unsigned int pkt_inspect_hook(void *priv,
struct sk_buff *skb,
const struct nf_hook_state *state)
{
const struct iphdr *iph;
const struct tcphdr *tcph;
const struct udphdr *udph;
u32 saddr, daddr;
/* 1단계: skb가 NULL이거나 IP 패킷이 아니면 통과 */
if (!skb)
return NF_ACCEPT;
/* 2단계: pskb_may_pull — IPv4 헤더 최소 크기만큼 선형 영역 확보
* 비선형 skb(jumbo frame, 분산 DMA 등)에서 헤더가 frag에 있을 수 있음.
* pskb_may_pull 이후 skb->data, head 등이 재배치될 수 있으므로
* 반드시 포인터를 재획득해야 합니다. */
if (!pskb_may_pull(skb, sizeof(struct iphdr)))
return NF_ACCEPT;
/* 3단계: 포인터 재획득 (pskb_may_pull 후 필수) */
iph = (struct iphdr *)skb_network_header(skb);
/* IPv4만 처리, 헤더 길이 검증 */
if (iph->version != 4)
return NF_ACCEPT;
saddr = ntohl(iph->saddr);
daddr = ntohl(iph->daddr);
/* 4단계: 전송 계층 헤더까지 pull
* iph->ihl은 4바이트 단위이므로 *4 필요 */
if (!pskb_may_pull(skb, iph->ihl * 4 + sizeof(struct tcphdr)))
goto log_l3;
/* pull 후 iph 포인터 재획득 */
iph = (struct iphdr *)skb_network_header(skb);
if (iph->protocol == IPPROTO_TCP) {
tcph = (struct tcphdr *)skb_transport_header(skb);
pr_info("[pkt_inspector] TCP %pI4:%u -> %pI4:%u flags=%c%c%c
",
&iph->saddr, ntohs(tcph->source),
&iph->daddr, ntohs(tcph->dest),
tcph->syn ? 'S' : '-',
tcph->ack ? 'A' : '-',
tcph->fin ? 'F' : '-');
} else if (iph->protocol == IPPROTO_UDP) {
udph = (struct udphdr *)skb_transport_header(skb);
pr_info("[pkt_inspector] UDP %pI4:%u -> %pI4:%u len=%u
",
&iph->saddr, ntohs(udph->source),
&iph->daddr, ntohs(udph->dest),
ntohs(udph->len));
}
return NF_ACCEPT;
log_l3:
pr_info("[pkt_inspector] IP %pI4 -> %pI4 proto=%u
",
&iph->saddr, &iph->daddr, iph->protocol);
return NF_ACCEPT;
}
static struct nf_hook_ops pkt_inspect_ops = {
.hook = pkt_inspect_hook,
.pf = NFPROTO_IPV4,
.hooknum = NF_INET_PRE_ROUTING,
.priority = NF_IP_PRI_FIRST,
};
static int __init pkt_inspector_init(void)
{
return nf_register_net_hook(&init_net, &pkt_inspect_ops);
}
static void __exit pkt_inspector_exit(void)
{
nf_unregister_net_hook(&init_net, &pkt_inspect_ops);
}
module_init(pkt_inspector_init);
module_exit(pkt_inspector_exit);
코드 설명: pskb_may_pull() 패턴의 핵심
- pskb_may_pull(skb, len): skb의 선형 영역(linear area,
skb->data~skb->tail)에 최소len바이트가 있음을 보장합니다. 데이터가 비선형(frag/frag_list)에 있으면 선형 영역으로 당겨오며, 이 과정에서skb->head가 재할당(pskb_expand_head)될 수 있습니다. - 포인터 재획득 필수:
pskb_may_pull()호출 후에는 이전에 저장한 헤더 포인터(iph,tcph등)가 무효화될 수 있으므로 반드시skb_network_header(),skb_transport_header()등으로 재획득해야 합니다. 이는 커널 내 모든 네트워크 코드에서 공통으로 지켜야 하는 규칙입니다. - skb_network_header() vs (struct iphdr *)skb->data:
skb_network_header()는skb->head + skb->network_header를 반환합니다. L2 헤더가 있는 경우skb->data는 이더넷 헤더를 가리키므로, IP 헤더 접근에는 반드시skb_network_header()를 사용해야 합니다. - NF_ACCEPT vs NF_DROP: 인스펙터는 패킷을 수정하지 않으므로 항상
NF_ACCEPT를 반환합니다. pskb_may_pull() 실패 시 패킷을 드롭하지 않고 통과시키는 이유는, 메모리 부족 등의 일시적 오류로 정상 패킷을 차단하지 않기 위함입니다.
Makefile (pkt_inspector)
obj-m += pkt_inspector.o
KDIR ?= /lib/modules/$(shell uname -r)/build
all:
make -C $(KDIR) M=$(PWD) modules
clean:
make -C $(KDIR) M=$(PWD) clean
사용법 및 예상 출력
# 빌드 및 적재
make
sudo insmod pkt_inspector.ko
# 패킷 발생 (다른 터미널에서)
curl http://1.1.1.1 &>/dev/null
ping -c 3 8.8.8.8
# 커널 로그 확인
sudo dmesg | grep pkt_inspector
| 예상 로그 예시 |
|---|
[pkt_inspector] TCP 192.168.1.100:52341 -> 1.1.1.1:80 flags=S-- |
[pkt_inspector] TCP 1.1.1.1:80 -> 192.168.1.100:52341 flags=-A- |
[pkt_inspector] UDP 192.168.1.100:54321 -> 8.8.8.8:53 len=45 |
# 모듈 제거
sudo rmmod pkt_inspector
예제 2: TTL 수정 모듈 (ttl_modifier.c)
목적: NF_INET_LOCAL_OUT 훅에서 발신 패킷의 IP TTL을 수정합니다. 클론된(shared) sk_buff를 안전하게 수정하는 skb_ensure_writable() COW(Copy-On-Write) 패턴을 보여줍니다.
/* ttl_modifier.c — Netfilter TTL 수정 모듈 */
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/netfilter.h>
#include <linux/netfilter_ipv4.h>
#include <linux/ip.h>
#include <linux/skbuff.h>
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("TTL modifier via Netfilter");
static int target_ttl = 64;
module_param(target_ttl, int, 0644);
MODULE_PARM_DESC(target_ttl, "Target TTL value (default: 64)");
static unsigned int ttl_mod_hook(void *priv,
struct sk_buff *skb,
const struct nf_hook_state *state)
{
struct iphdr *iph;
if (!skb)
return NF_ACCEPT;
/* 1단계: 선형 영역에 IP 헤더가 있음을 보장 */
if (!pskb_may_pull(skb, sizeof(struct iphdr)))
return NF_ACCEPT;
iph = (struct iphdr *)skb_network_header(skb);
if (iph->version != 4)
return NF_ACCEPT;
/* 2단계: 쓰기 가능 여부 확인 및 COW 수행
* skb가 다른 곳에서 공유(clone/get)된 경우, 헤더 부분만 복사해
* 독립적인 쓰기 가능 영역을 확보합니다.
* 커널 5.x+ 권장 API: skb_ensure_writable()
* 구 API: skb_make_writable() (레거시, 사용 자제) */
if (skb_ensure_writable(skb, sizeof(struct iphdr)))
return NF_DROP; /* COW 실패 시 드롭 */
/* 3단계: ensure_writable 후 포인터 재획득 */
iph = (struct iphdr *)skb_network_header(skb);
/* 4단계: TTL 수정 */
iph->ttl = (u8)target_ttl;
/* 5단계: IP 헤더 체크섬 재계산
* TTL 변경은 IP 헤더를 수정하므로 체크섬을 갱신해야 합니다.
* ip_send_check()는 iph->check를 0으로 초기화하고
* ip_fast_csum()으로 재계산합니다. */
ip_send_check(iph);
return NF_ACCEPT;
}
static struct nf_hook_ops ttl_mod_ops = {
.hook = ttl_mod_hook,
.pf = NFPROTO_IPV4,
.hooknum = NF_INET_LOCAL_OUT,
.priority = NF_IP_PRI_MANGLE,
};
static int __init ttl_modifier_init(void)
{
pr_info("ttl_modifier: loaded, target_ttl=%d
", target_ttl);
return nf_register_net_hook(&init_net, &ttl_mod_ops);
}
static void __exit ttl_modifier_exit(void)
{
nf_unregister_net_hook(&init_net, &ttl_mod_ops);
pr_info("ttl_modifier: unloaded
");
}
module_init(ttl_modifier_init);
module_exit(ttl_modifier_exit);
코드 설명: skb_ensure_writable() COW 패턴
- 왜 COW가 필요한가?
skb_clone()으로 생성된 sk_buff는 원본과 데이터 영역을 공유합니다(skb_shared()또는skb_cloned()). 공유 상태에서 헤더를 직접 수정하면 원본 sk_buff를 사용하는 다른 코드(예: TCP 재전송 큐)가 의도치 않게 수정된 데이터를 보게 됩니다. - skb_ensure_writable(skb, len):
len바이트 범위가 쓰기 가능(비공유, 선형)한지 확인하고, 필요하면 해당 부분만 복사(COW)합니다. 반환값이 0이 아니면 실패이므로 드롭합니다. 5.x 이전에는skb_make_writable()을 사용했으나 현재는 deprecated 추세입니다. - ip_send_check(iph): 내부적으로
iph->check = 0; iph->check = ip_fast_csum((unsigned char *)iph, iph->ihl);를 수행합니다. TTL뿐 아니라 IP 헤더의 어떤 필드를 수정해도 이 함수를 반드시 호출해야 합니다. - 포인터 재획득 패턴의 일반화:
pskb_may_pull()과skb_ensure_writable()모두 내부에서pskb_expand_head()를 호출할 수 있으며, 이 경우skb->head가 바뀝니다. 따라서 두 함수 호출 후에는 항상 헤더 포인터를 재획득해야 합니다. 이를 잊으면 UAF(Use-After-Free) 버그로 이어집니다.
사용법 (ttl_modifier)
# 기본 TTL(64)로 적재
sudo insmod ttl_modifier.ko
# TTL 128로 적재
sudo insmod ttl_modifier.ko target_ttl=128
# 확인: 발신 패킷의 TTL 변경 여부
sudo tcpdump -i any -n "ip" -c 10
# 런타임 파라미터 변경 (module_param 0644 권한)
echo 32 | sudo tee /sys/module/ttl_modifier/parameters/target_ttl
예제 3: 커스텀 패킷 생성 모듈 (pkt_gen.c)
목적: alloc_skb() → skb_reserve() → skb_put() → skb_push() → dev_queue_xmit() 흐름을 완전히 구현하는 UDP 패킷 생성 모듈입니다. 커스텀 프로토콜 구현이나 패킷 생성기 개발 시 필요한 모든 단계를 포함합니다.
/* pkt_gen.c — 커스텀 UDP 패킷 생성 모듈 */
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/skbuff.h>
#include <linux/netdevice.h>
#include <linux/ip.h>
#include <linux/udp.h>
#include <linux/etherdevice.h>
#include <linux/if_ether.h>
#include <linux/in.h>
#include <linux/timer.h>
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Custom packet generator using sk_buff APIs");
#define PAYLOAD_DATA "Hello from kernel pkt_gen!"
#define PAYLOAD_LEN 26
#define DST_IP htonl(0xC0A80101) /* 192.168.1.1 */
#define SRC_IP htonl(0xC0A801FE) /* 192.168.1.254 */
#define DST_PORT htons(9999)
#define SRC_PORT htons(12345)
static char *ifname = "eth0";
module_param(ifname, charp, 0444);
MODULE_PARM_DESC(ifname, "Output network interface (default: eth0)");
static struct timer_list gen_timer;
static int send_custom_packet(void)
{
struct net_device *dev;
struct sk_buff *skb;
struct ethhdr *eth;
struct iphdr *iph;
struct udphdr *udph;
unsigned char *payload;
int total_len;
int ret;
/* 1단계: 출력 인터페이스 조회 */
dev = dev_get_by_name(&init_net, ifname);
if (!dev) {
pr_err("pkt_gen: interface %s not found
", ifname);
return -ENODEV;
}
/* 2단계: 전체 패킷 크기 계산
* LL_RESERVED_SPACE = dev->hard_header_len + dev->needed_headroom
* 이더넷: 14바이트 헤더 + 드라이버 headroom */
total_len = ETH_HLEN + sizeof(struct iphdr) +
sizeof(struct udphdr) + PAYLOAD_LEN;
/* 3단계: sk_buff 할당
* alloc_skb(size, gfp): headroom+data+tailroom 포함 버퍼 할당
* GFP_ATOMIC: softirq/tasklet 컨텍스트에서 sleep 불가 상황
* LL_RESERVED_SPACE: L2 헤더를 위한 추가 headroom 공간 예약분 */
skb = alloc_skb(total_len + LL_RESERVED_SPACE(dev), GFP_ATOMIC);
if (!skb) {
dev_put(dev);
return -ENOMEM;
}
/* 4단계: skb_reserve — headroom 확보
* data 포인터를 앞으로 당겨 headroom을 확보합니다.
* 이후 skb_push()로 헤더를 앞에 추가할 공간을 만듭니다.
* 이 시점: head == data == tail, headroom=LL_RESERVED_SPACE */
skb_reserve(skb, LL_RESERVED_SPACE(dev));
/* 5단계: skb_put — 페이로드 공간 확보 (tail 확장)
* tail 포인터를 뒤로 밀어 데이터 공간을 만들고 포인터 반환
* 패킷을 뒤에서 앞으로(payload → UDP → IP → ETH 순) 구성 */
payload = (unsigned char *)skb_put(skb, PAYLOAD_LEN);
memcpy(payload, PAYLOAD_DATA, PAYLOAD_LEN);
/* 6단계: skb_push — UDP 헤더 추가 (data 포인터를 앞으로)
* 반환값: 새로 추가된 헤더의 시작 포인터 */
udph = (struct udphdr *)skb_push(skb, sizeof(struct udphdr));
udph->source = SRC_PORT;
udph->dest = DST_PORT;
udph->len = htons(sizeof(struct udphdr) + PAYLOAD_LEN);
udph->check = 0; /* UDP 체크섬 선택 사항, 0=미사용 */
/* 7단계: skb_push — IP 헤더 추가 */
iph = (struct iphdr *)skb_push(skb, sizeof(struct iphdr));
iph->version = 4;
iph->ihl = sizeof(struct iphdr) / 4;
iph->tos = 0;
iph->tot_len = htons(sizeof(struct iphdr) +
sizeof(struct udphdr) + PAYLOAD_LEN);
iph->id = htons(0x1234);
iph->frag_off = 0;
iph->ttl = 64;
iph->protocol = IPPROTO_UDP;
iph->check = 0;
iph->saddr = SRC_IP;
iph->daddr = DST_IP;
ip_send_check(iph);
/* 8단계: skb_push — 이더넷 헤더 추가 */
eth = (struct ethhdr *)skb_push(skb, ETH_HLEN);
memset(eth->h_dest, 0xff, ETH_ALEN); /* 브로드캐스트 */
memcpy(eth->h_source, dev->dev_addr, ETH_ALEN);
eth->h_proto = htons(ETH_P_IP);
/* 9단계: 헤더 오프셋 설정
* skb_set_*_header는 head 기준 오프셋을 저장합니다. */
skb_set_mac_header(skb, 0);
skb_set_network_header(skb, ETH_HLEN);
skb_set_transport_header(skb, ETH_HLEN + sizeof(struct iphdr));
/* 10단계: skb 메타데이터 설정 */
skb->dev = dev;
skb->protocol = htons(ETH_P_IP);
skb->priority = 0;
/* 11단계: dev_queue_xmit — 전송 큐에 제출
* 이 시점부터 skb의 소유권이 네트워크 스택으로 이전됩니다.
* dev_queue_xmit() 호출 후에는 절대로 skb에 접근하면 안 됩니다.
* (성공·실패 모두 마찬가지) */
ret = dev_queue_xmit(skb);
if (ret != NET_XMIT_SUCCESS)
pr_warn("pkt_gen: xmit returned %d
", ret);
/* dev 참조 카운트 해제 */
dev_put(dev);
return 0;
}
static void gen_timer_cb(struct timer_list *t)
{
send_custom_packet();
mod_timer(&gen_timer, jiffies + msecs_to_jiffies(1000));
}
static int __init pkt_gen_init(void)
{
pr_info("pkt_gen: loaded, iface=%s
", ifname);
timer_setup(&gen_timer, gen_timer_cb, 0);
mod_timer(&gen_timer, jiffies + msecs_to_jiffies(1000));
return 0;
}
static void __exit pkt_gen_exit(void)
{
del_timer_sync(&gen_timer);
pr_info("pkt_gen: unloaded
");
}
module_init(pkt_gen_init);
module_exit(pkt_gen_exit);
코드 설명: alloc_skb / skb_reserve / skb_put / skb_push 패턴
- 패킷 구성 순서 (역순): 헤더를 앞에 추가하는 방식 특성상, 실제 데이터를 먼저
skb_put()으로 뒤에 채운 뒤skb_push()로 헤더를 차례로 앞에 붙입니다. 즉 페이로드 → L4 → L3 → L2 순서입니다. - skb_reserve(skb, len):
data와tail모두len만큼 전진시켜 headroom을 확보합니다. 아직 데이터가 없는 초기 상태에서만 호출해야 합니다.LL_RESERVED_SPACE(dev)는 드라이버가 요구하는 headroom + L2 헤더 크기의 합계입니다. - skb_put(skb, len):
tail을len만큼 뒤로 밀고 이전tail을 반환합니다. 선형 데이터 영역의 끝에 공간을 추가합니다.len > skb_tailroom(skb)이면 BUG()가 발생하므로, alloc_skb 시 충분한 크기를 지정해야 합니다. - skb_push(skb, len):
data를len만큼 앞으로 당기고 새data를 반환합니다. headroom이 부족하면 BUG()가 발생합니다. skb_reserve로 미리 확보한 headroom에서 소비됩니다. - dev_queue_xmit() 후 skb 접근 금지: dev_queue_xmit()는 skb 소유권을 커널 네트워크 스택에 넘깁니다. 성공·실패와 무관하게 이 함수 호출 후 skb 포인터로 접근하면 UAF 버그가 발생합니다. dev_put() 등 별도로 처리해야 할 것들은 skb와 분리하여 관리하십시오.
- skb_set_*_header() 호출 이유: L2/L3/L4 헤더의 위치(head 기준 오프셋)를 skb 구조체에 기록합니다. 이 값이 없으면 이후 단계에서
skb_network_header()등이 잘못된 위치를 반환합니다.
사용법 (pkt_gen)
# eth0으로 브로드캐스트 UDP 패킷 1초 간격 전송
sudo insmod pkt_gen.ko
# 다른 인터페이스 사용
sudo insmod pkt_gen.ko ifname=ens3
# 수신 측에서 확인
sudo tcpdump -i any udp port 9999 -A
# 모듈 언로드 (타이머 자동 정지)
sudo rmmod pkt_gen
실습 주의사항: 위 예제 모듈은 학습 목적으로 단순화되었습니다. 실제 드라이버나 넷필터 모듈에서는 네임스페이스 처리, 멀티큐 NIC 지원, NAPI 버짓 관리, TSO/GSO 오프로드 여부 확인 등을 추가로 고려해야 합니다. 또한 커널 버전별 API 변화(특히 nf_register_net_hook vs nf_register_hooks)에 주의하십시오.
sk_buff 전체 API 퀵 레퍼런스
아래 표는 sk_buff 관련 핵심 함수를 카테고리별로 정리한 퀵 레퍼런스입니다. 각 함수의 시그니처(Signature), 반환값, 주요 용도를 한눈에 파악할 수 있도록 구성하였습니다.
1. 할당 및 해제
| 함수 | 시그니처 (핵심 인자) | 반환값 | 설명 |
|---|---|---|---|
alloc_skb | (size, gfp) | struct sk_buff * | 기본 sk_buff 할당. head~end 연속 버퍼. |
alloc_skb_with_frags | (header_len, data_len, max_page_order, errcode, gfp) | struct sk_buff * | 헤더+페이지 분리 할당. 대용량 패킷. |
netdev_alloc_skb | (dev, length) | struct sk_buff * | NIC 수신용 할당. NET_SKB_PAD headroom 자동. |
napi_alloc_skb | (napi, length) | struct sk_buff * | NAPI 컨텍스트 최적화 할당. 현재 CPU 캐시 활용. |
build_skb | (data, frag_size) | struct sk_buff * | 기존 버퍼로 skb 구성. XDP/zero-copy 수신. |
build_skb_around | (skb, data, frag_size) | struct sk_buff * | 기존 skb 구조체를 재사용해 data 연결. |
__alloc_skb | (size, gfp, flags, node) | struct sk_buff * | 내부 저수준 할당. fclone/node 제어. |
kfree_skb | (skb) | void | 참조 카운트 감소. 0이 되면 실제 해제. |
kfree_skb_reason | (skb, reason) | void | 드롭 이유 추적 포함 해제 (6.0+). |
consume_skb | (skb) | void | 정상 소비(드롭 아님) 시 사용. tracepoint 구분. |
dev_kfree_skb | (skb) | void | 드라이버 TX 완료 시 해제. consume_skb 래퍼. |
dev_kfree_skb_irq | (skb) | void | IRQ 컨텍스트에서 TX 완료 해제. defer 큐 사용. |
dev_kfree_skb_any | (skb) | void | IRQ/비IRQ 모두 안전한 TX 해제. |
__kfree_skb | (skb) | void | 참조 카운트 확인 없이 즉시 해제 (내부용). |
skb_free_datagram | (sk, skb) | void | UDP/raw 소켓 수신 큐에서 데이터그램 해제. |
2. 데이터 조작
| 함수 | 시그니처 (핵심 인자) | 반환값 | 설명 |
|---|---|---|---|
skb_reserve | (skb, len) | void | headroom 확보. 빈 skb에서만 사용. |
skb_put | (skb, len) | void * | tail 확장. 선형 데이터 끝에 공간 추가. |
skb_put_zero | (skb, len) | void * | skb_put + memset(0). |
skb_put_data | (skb, data, len) | void * | skb_put + memcpy. |
skb_push | (skb, len) | void * | data 앞으로 이동. 헤더 추가. |
skb_pull | (skb, len) | void * | data 뒤로 이동. 헤더 제거. NULL=실패. |
skb_pull_inline | (skb, len) | void * | skb_pull 인라인 버전. 빠른 경로 최적화. |
__skb_pull | (skb, len) | void * | skb_pull 무조건 버전 (길이 검사 없음). |
skb_trim | (skb, len) | void | 패킷 끝부터 자르기. 선형 전용. |
__skb_trim | (skb, len) | void | skb_trim 저수준 버전. |
skb_pad | (skb, pad) | int | 최소 길이 패딩. 패킷을 복사할 수 있음. |
skb_padto | (skb, len) | int | 패킷을 len 바이트로 패딩. |
pskb_expand_head | (skb, nhead, ntail, gfp) | int | head 영역 재할당. headroom/tailroom 확장. |
skb_ensure_writable | (skb, write_len) | int | COW 수행, 쓰기 가능 보장 (5.x+). |
skb_linearize | (skb) | int | frag_list/frags를 선형 영역으로 통합. |
__pskb_pull_tail | (skb, delta) | unsigned char * | frag에서 선형 영역으로 delta 바이트 당겨오기. |
pskb_may_pull | (skb, len) | bool | 선형 영역 확보 보장. 비선형 skb 안전 접근. |
skb_cow_data | (skb, tailbits, trailer) | int | IPsec 등에서 데이터 COW + tailroom 확보. |
skb_prepare_seq_read | (skb, from, to, st) | void | 비선형 skb 순차 읽기 상태 초기화. |
skb_seq_read | (consumed, data, datalen, st) | unsigned int | 비선형 skb 순차 읽기. |
3. 헤더 포인터 조작
| 함수/매크로 | 반환값 | 설명 |
|---|---|---|
skb_mac_header(skb) | unsigned char * | L2 헤더 포인터 (head + mac_header). |
skb_network_header(skb) | unsigned char * | L3 헤더 포인터 (head + network_header). |
skb_transport_header(skb) | unsigned char * | L4 헤더 포인터 (head + transport_header). |
skb_inner_mac_header(skb) | unsigned char * | 터널 내부 L2 헤더 포인터. |
skb_inner_network_header(skb) | unsigned char * | 터널 내부 L3 헤더 포인터. |
skb_inner_transport_header(skb) | unsigned char * | 터널 내부 L4 헤더 포인터. |
skb_set_mac_header(skb, offset) | void | mac_header = data + offset. |
skb_set_network_header(skb, offset) | void | network_header = data + offset. |
skb_set_transport_header(skb, offset) | void | transport_header = data + offset. |
skb_reset_mac_header(skb) | void | mac_header = data (현재 위치로 리셋). |
skb_reset_network_header(skb) | void | network_header = data. |
skb_reset_transport_header(skb) | void | transport_header = data. |
skb_mac_header_len(skb) | int | network_header - mac_header. |
skb_network_header_len(skb) | int | transport_header - network_header. |
skb_network_offset(skb) | int | network_header - (data - head). |
skb_transport_offset(skb) | int | transport_header - (data - head). |
skb_header_pointer(skb, offset, len, buf) | void * | 비선형 안전 헤더 접근. frag 경계 처리. |
4. 비선형 데이터 (frags/frag_list)
| 함수/매크로 | 설명 |
|---|---|
skb_shinfo(skb) | skb_shared_info 포인터 반환 (end 위치). |
skb_shinfo(skb)->nr_frags | frags[] 배열의 유효 항목 수. |
skb_shinfo(skb)->frags[i] | i번째 페이지 frag (skb_frag_t). |
skb_frag_page(&frag) | frag의 struct page * 반환. |
skb_frag_address(&frag) | frag의 가상 주소 반환 (kmap 필요 시 불가). |
skb_frag_size(&frag) | frag의 데이터 크기 반환. |
skb_frag_off(&frag) | frag 내 데이터 시작 오프셋. |
skb_add_rx_frag(skb, i, page, off, size, truesize) | 수신 frag 추가. nr_frags++, len/truesize 갱신. |
skb_fill_page_desc(skb, i, page, off, size) | frags[i]에 페이지 정보 채우기. truesize 미갱신. |
skb_coalesce_rx_frag | 마지막 frag가 같은 페이지면 병합, 아니면 추가. |
skb_copy_bits(skb, offset, to, len) | 비선형 skb에서 연속 버퍼로 복사. |
skb_store_bits(skb, offset, from, len) | 연속 버퍼에서 비선형 skb로 쓰기. |
skb_copy_datagram_iter(skb, offset, iter, len) | iov_iter로 데이터 복사 (user-space 포함). |
skb_walk_frags(skb, iter_skb) | frag_list 순회 매크로. |
skb_has_frag_list(skb) | skb_shinfo(skb)->frag_list != NULL 여부. |
skb_frag_ref(skb, f) | frags[f] 페이지 참조 카운트 증가. |
skb_frag_unref(skb, f) | frags[f] 페이지 참조 카운트 감소. |
5. Clone / Copy
| 함수 | 시그니처 | 설명 |
|---|---|---|
skb_clone | (skb, gfp) | 헤더 복사, 데이터 공유. 빠름. 읽기 전용 데이터 공유 시. |
skb_copy | (skb, gfp) | 헤더+데이터 모두 복사. 독립적 수정 필요 시. |
pskb_copy | (skb, headroom, gfp) | 선형 영역만 복사, frag 공유. 선택적 headroom 지정. |
pskb_copy_for_clone | (skb, headroom, gfp) | pskb_copy + 클론 표시. |
skb_copy_expand | (skb, newheadroom, newtailroom, gfp) | headroom/tailroom 조정하며 전체 복사. |
skb_get | (skb) | 참조 카운트 증가. 다중 큐 보관 시. |
skb_clone_sk | (skb) | 소켓 연결 유지 클론. |
skb_orphan | (skb) | 소켓과 연결 끊기. destructor 호출. |
skb_unshare | (skb, gfp) | 공유 상태면 복사해 독립 skb 반환. |
skb_cow | (skb, headroom) | 공유 또는 headroom 부족 시 복사. |
6. 큐 관리
| 함수 | 설명 |
|---|---|
skb_queue_head_init(list) | sk_buff_head 초기화 (next=prev=self, lock 초기화). |
skb_queue_head(list, skb) | 큐 앞에 skb 삽입 (락 획득). |
skb_queue_tail(list, skb) | 큐 끝에 skb 삽입 (락 획득). |
skb_dequeue(list) | 큐 앞에서 skb 제거·반환 (락 획득). |
skb_dequeue_tail(list) | 큐 끝에서 skb 제거·반환. |
__skb_queue_head(list, skb) | 락 없이 큐 앞 삽입 (이미 락 보유 시). |
__skb_queue_tail(list, skb) | 락 없이 큐 끝 삽입. |
__skb_dequeue(list) | 락 없이 큐 앞 제거. |
skb_queue_len(list) | 큐 길이 반환 (qlen 필드). |
skb_queue_empty(list) | 큐가 비어있으면 true. |
skb_queue_empty_lockless(list) | 락 없이 큐 비어있음 확인 (READ_ONCE 사용). |
skb_peek(list) | 첫 번째 skb 반환 (제거 안 함). |
skb_peek_tail(list) | 마지막 skb 반환 (제거 안 함). |
skb_queue_purge(list) | 큐의 모든 skb를 kfree_skb로 제거. |
skb_queue_splice(list, head) | list를 head 앞에 이어 붙임. |
skb_queue_splice_tail(list, head) | list를 head 끝에 이어 붙임. |
skb_queue_walk(list, skb) | 큐 순회 매크로 (제거 시 _safe 버전 사용). |
skb_queue_walk_safe(list, skb, tmp) | 순회 중 제거 안전 버전. |
7. 체크섬
| 함수 | 설명 |
|---|---|
ip_send_check(iph) | IPv4 헤더 체크섬 재계산 (iph->check 갱신). |
skb_checksum(skb, offset, len, csum) | 비선형 skb의 체크섬 계산. |
skb_checksum_none_assert(skb) | ip_summed=CHECKSUM_NONE 단언 (디버그). |
skb_csum_unnecessary(skb) | 하드웨어가 체크섬 검증 완료 여부. |
skb_postpull_rcsum(skb, start, len) | skb_pull 후 csum 갱신. |
skb_pull_rcsum(skb, len) | skb_pull + rcsum 자동 갱신. |
csum_block_add(csum, csum2, offset) | 부분 체크섬 누적. |
inet_proto_csum_replace4(sum, skb, from, to, pseudohdr) | L4 체크섬에서 4바이트 필드 교체 갱신. |
inet_proto_csum_replace2(sum, skb, from, to, pseudohdr) | L4 체크섬에서 2바이트 필드 교체 갱신. |
skb_checksum_setup(skb, recalculate) | 가상화 환경에서 체크섬 설정. |
8. GSO / GRO
| 함수/필드 | 설명 |
|---|---|
skb_shinfo(skb)->gso_size | GSO 세그먼트 크기 (MSS). |
skb_shinfo(skb)->gso_segs | GSO 세그먼트 수. |
skb_shinfo(skb)->gso_type | GSO 유형 (SKB_GSO_TCPV4 등). |
skb_gso_segment(skb, features) | GSO skb를 세그먼트 리스트로 분할. |
skb_gso_validate_network_len(skb, mtu) | GSO skb가 MTU를 초과하지 않는지 확인. |
skb_gro_header(skb, hlen, off) | GRO 헤더 접근 헬퍼. |
skb_gro_offset(skb) | GRO 처리 시작 오프셋. |
skb_gro_len(skb) | GRO 처리할 데이터 길이. |
napi_gro_receive(napi, skb) | GRO로 패킷 수신 처리. |
napi_gro_flush(napi, flush_old) | GRO로 누적된 패킷 즉시 전달. |
dev_gro_receive(napi, skb) | GRO 프로토콜 훅 호출 (프로토콜별 병합). |
9. 유틸리티 / 기타
| 함수/매크로 | 설명 |
|---|---|
skb_is_nonlinear(skb) | nr_frags > 0 또는 frag_list 있으면 true. |
skb_headlen(skb) | 선형 데이터 길이 (len - data_len). |
skb_tailroom(skb) | tail ~ end 여유 공간. |
skb_headroom(skb) | head ~ data 여유 공간. |
skb_shared(skb) | users > 1 이면 true (참조 공유). |
skb_cloned(skb) | 클론된 sk_buff 여부 (dataref & SKB_DATAREF_MASK > 1). |
skb_pfmemalloc(skb) | PFMEMALLOC(메모리 압박) 플래그 확인. |
skb_get_hash(skb) | L3/L4 기반 플로우 해시 (캐시됨). |
skb_set_hash(skb, hash, type) | 하드웨어 제공 해시 값 설정. |
skb_record_rx_queue(skb, rx_queue) | 수신 큐 번호 기록 (다중 큐 NIC). |
skb_get_rx_queue(skb) | 기록된 수신 큐 번호 반환. |
skb_mark_not_on_list(skb) | next 포인터 NULL 설정 (큐에서 제거 후). |
skb_list_del_init(skb) | list_head 기반 skb 리스트에서 제거 및 초기화. |
skb_metadata_len(skb) | 메타데이터 영역 크기 (XDP metadata 등). |
skb_ext_add(skb, id) | skb extension 추가 (5.1+). |
skb_ext_find(skb, id) | skb extension 조회. |
skb_dump(pr_level, skb, full_pkt) | skb 전체 구조 덤프 출력 (디버그). |
커널 버전별 sk_buff 변경 이력 확장
sk_buff는 Linux 커널 네트워크 스택의 핵심 자료구조로서 30년 이상에 걸쳐 지속적으로 발전해 왔습니다. 이 절에서는 각 커널 시리즈에서 sk_buff 구조와 API에 영향을 준 주요 변경사항을 연대기 순으로 정리합니다.
2.6.x 시대 (2003–2011): 구조 간소화와 오프로드 기반 확립
Linux 2.6 시리즈는 sk_buff의 내부 구조를 대대적으로 정비한 시기입니다. skb 크기 최소화, 헤더 분리, 오프로드 API 명확화가 이 시기의 핵심 주제였습니다.
| 버전 | 변경 내용 | 영향 |
|---|---|---|
| 2.6.9 | skb_shared_info에 GSO 필드 추가 (gso_size, gso_segs) | 소프트웨어 GSO 기반 확립 |
| 2.6.14 | destructor_list 제거, 소켓 레벨 destructor로 통합 | sk_buff 크기 감소, 메모리 효율 향상 |
| 2.6.18 | ip_summed: CHECKSUM_HW → CHECKSUM_PARTIAL/CHECKSUM_COMPLETE 분리 | 체크섬 오프로드 API 명확화, 드라이버 인터페이스 안정화 |
| 2.6.19 | skb_shared_info에 tx_flags 추가 (TX 타임스탬핑 기반) | 하드웨어 TX 타임스탬프 지원 시작 |
| 2.6.22 | skb_shared_info를 skb 말단(end)에 인접 배치 | GSO/TSO 처리 캐시 지역성 향상 |
| 2.6.24 | mac/network/transport_header: 절대 포인터 → 16비트 오프셋 | sk_buff 구조체 축소, pskb_expand_head 후 포인터 갱신 불필요 |
| 2.6.24 | net_device 분리: 인터페이스별 통계를 percpu로 이동 | skb->dev 접근 패턴 안정화 |
| 2.6.26 | skb_clone_writable() 도입 | Netfilter COW 패턴 안정화 |
| 2.6.29 | GRO(Generic Receive Offload) 도입 (Ben Hutchings) | 소프트웨어 LRO 대체, 프로토콜 중립적 병합 |
| 2.6.31 | skb_dst 태그드 포인터(tagged pointer) 최적화 | fast path에서 참조 카운트 연산 절감 |
| 2.6.32 | RPS(Receive Packet Steering): skb->rxhash 도입 | 멀티코어 수신 부하 분산 기반 |
| 2.6.36 | skb_frag_t: page + offset + size 구조 확정 | DMA frag API 안정화 |
| 2.6.37 | skb_tx_hash() → skb_get_tx_queue() 분리 | 멀티큐 TX 선택 로직 명확화 |
| 2.6.39 | sk_buff에 vlan_tci 필드 추가 (하드웨어 VLAN 오프로드) | VLAN 태그 처리 일원화 |
3.x 시대 (2011–2015): XPS/RFS와 네임스페이스 지원
3.x 시리즈에서는 멀티큐 NIC의 보편화에 맞춰 TX 스케줄링 및 수신 흐름 제어 기능이 sk_buff에 반영되었으며, 네트워크 네임스페이스와의 통합이 강화되었습니다.
| 버전 | 변경 내용 | 영향 |
|---|---|---|
| 3.0 | skb_frag_t: struct skb_frag_struct → 정식 typedef, API 함수화 | frag 접근 코드 일관성 향상 |
| 3.1 | sk_buff에 rxhash를 hash로 통합, l4_rxhash 비트 추가 | L4 해시 여부 구분 가능 |
| 3.3 | skb_shared_info에 tx_flags 확장 (SKBTX_* 비트플래그) | TX 타임스탬핑 옵션 세분화 |
| 3.6 | skb_shared_info에 hwtstamps(hw_timestamps) 필드 추가 | 하드웨어 타임스탬프 직접 저장 |
| 3.7 | BQL(Byte Queue Limits) 통합: netdev_tx_sent_queue()가 skb->len 기반으로 동작 | TX 큐 지연 최소화, 버퍼 팽창(bufferbloat) 개선 |
| 3.10 | skb->rxhash 필드 제거, skb->hash로 완전 통합 | 해시 필드 단일화, RPS/RFS 인터페이스 정리 |
| 3.11 | TCP Fast Open: skb에 TFO 쿠키 처리 경로 추가 | 연결 설정 지연 감소 |
| 3.14 | skb_shared_info: dataref를 atomic_t → refcount_t 전환 시작 | 참조 카운트 오버플로 감지 향상 |
| 3.18 | eBPF 소켓 필터: sk_buff를 BPF 프로그램에 읽기 전용 노출 | BPF 기반 패킷 필터링 확장성 확보 |
| 3.19 | XDP 프로토타입: netmap-like raw 버퍼 접근 논의 시작 | 4.x XDP 도입의 전초 |
4.x 시대 (2015–2019): XDP, 터널링, skb Extension 기반
4.x 시리즈는 XDP의 도입, 터널 프로토콜 지원 강화, 그리고 sk_buff 확장 메커니즘의 기반이 마련된 시기입니다.
| 버전 | 변경 내용 | 영향 |
|---|---|---|
| 4.1 | inner_*_header 오프셋 추가 (터널 내부 헤더 지원) | VXLAN/GRE/GENEVE 등 터널 처리 표준화 |
| 4.2 | sock_efastpath: 소켓 직접 전송 경로 최적화 | UDP sendmsg 지연 감소 |
| 4.4 | skb_vlan_push/pop 표준화: __vlan_hwaccel_put_tag 대체 | VLAN 조작 API 정리 |
| 4.8 | XDP(eXpress Data Path) 드라이버 레벨 훅 도입 (mlx4/ixgbe) | sk_buff 없이 패킷 처리 가능, 극저지연 경로 |
| 4.10 | skb_shared_info: gso_type에 SKB_GSO_UDP_TUNNEL_CSUM 추가 | 터널 UDP 오프로드 개선 |
| 4.13 | skb->tstamp: ktime_t에서 u64 nanoseconds로 타입 변경 | 타임스탬프 처리 통일, SO_TXTIME 기반 |
| 4.15 | skb_ext 시스템 초안 (Eric Dumazet): 선택적 확장 필드 관리 | sk_buff 크기 증가 없이 기능 추가 가능 |
| 4.17 | skb->slow_gro 비트 추가 (GRO 슬로우 경로 표시) | GRO 프로토콜 훅 선택적 건너뛰기 |
| 4.18 | UDP GSO 지원: SKB_GSO_UDP_L4 타입 추가 | UDP 대용량 전송 오프로드 (sendmsg + GSO) |
| 4.20 | skb_shared_info에 xdp_frags 필드 추가 (멀티버퍼 XDP 기반) | XDP 멀티버퍼 지원 준비 |
5.x 시대 (2019–2022): skb_ext 정식화, page_pool, io_uring 통합
5.x 시리즈는 skb_ext 시스템의 정식 도입, page_pool을 통한 NIC 버퍼 관리 개선, 그리고 io_uring 및 zero-copy 전송 지원의 성숙 단계입니다.
| 버전 | 변경 내용 | 영향 |
|---|---|---|
| 5.1 | skb_ext 시스템 정식 도입: skb_ext_add/find/del, SKB_EXT_* ID | 보안(SEC_PATH), 서비스 품질 등 옵션 필드 동적 관리 |
| 5.1 | page_pool API 안정화: pp_recycle 비트가 skb에 추가됨 | NIC별 페이지 재활용 표준화, 드라이버 메모리 관리 개선 |
| 5.2 | skb_ensure_writable() 도입 (skb_make_writable 대체) | COW API 명확화, 성능 개선 |
| 5.4 | MSG_ZEROCOPY: sock_zerocopy_put() 경로 성숙, uarg 레퍼런스 정리 | zero-copy 전송 안정화 |
| 5.7 | XDP 멀티버퍼 프레임워크: skb_shared_info->xdp_frags 활용 본격화 | 점보 프레임 XDP 처리 가능 |
| 5.9 | skb->active_extensions 비트마스크로 skb_ext 관리 최적화 | 확장 필드 조회 오버헤드 감소 |
| 5.10 | kfree_skb_reason() 도입: SKB_DROP_REASON_* 열거형 | 패킷 드롭 원인 추적, 디버깅 개선 |
| 5.11 | skb_mark_for_recycle(): page_pool 재활용 마킹 단순화 | 드라이버 코드 간소화 |
| 5.14 | XDP 멀티버퍼 플래그: skb_shared_info에 nr_frags 기반 멀티버퍼 공식 지원 | XDP_FLAGS_HW_MODE에서 점보 패킷 처리 |
| 5.18 | skb_frag_t에 netmem 필드 추가 예비 작업 (devmem TCP 전구체) | 비메모리(장치 메모리) frag 표현 기반 |
| 5.19 | GRO list 기반 처리 개선: napi_gro_receive에서 list_head 사용 | GRO 경로 잠금 경합 감소 |
6.x 시대 (2022–현재): netmem, devmem TCP, page_pool 완성
6.x 시리즈는 sk_buff 아키텍처의 근본적인 변화가 진행 중인 시기입니다. netmem 추상화와 devmem TCP로 대표되는 장치 메모리 직접 통합이 핵심 주제입니다.
| 버전 | 변경 내용 | 영향 |
|---|---|---|
| 6.0 | kfree_skb_reason() 전면 채택: net/core 전체에 drop reason 추가 | 패킷 드롭 원인 가시성 대폭 향상 |
| 6.1 | page_pool: 통계 인프라 추가 (page_pool_get_stats) | NIC 버퍼 사용량 모니터링 가능 |
| 6.2 | skb_frag_t: union {struct page *, netmem_ref} 구조로 변환 시작 | non-page 메모리(DMABUF 등) frag 표현 준비 |
| 6.4 | af_packet: skb_segment() 경로 개선, GSO_BY_FRAGS 처리 최적화 | AF_PACKET 대용량 전송 성능 향상 |
| 6.6 | netmem 추상화 정식 도입: netmem_ref 타입, net_iov 구조체 | 장치 메모리(DMABUF/P2PDMA)를 skb frag에 직접 연결 가능 |
| 6.6 | page_pool: netmem_ref 기반 할당 지원 | page_pool이 DMABUF 백엔드 지원 |
| 6.7 | skb_frag_t: skb_frag_netmem() API 추가 | netmem frag 접근 표준화 |
| 6.8 | MSG_ZEROCOPY: io_uring 통합 개선 | io_uring 기반 zero-copy 전송 지연 감소 |
| 6.9 | devmem TCP 초기 구현: TCP 수신 경로에서 net_iov frag 직접 user-space 매핑 | NIC DMA 버퍼를 애플리케이션에 직접 노출 (copy 없는 수신) |
| 6.10 | devmem TCP: sendmsg 경로에서 DMABUF 직접 전송 지원 | 완전한 zero-copy TX/RX 경로 구현 |
| 6.12 | skb_frag_t API 정리: 하위 호환 유지하며 netmem_ref 기반으로 전환 완료 | 드라이버 maigration 경로 확립 |
버전별 변화 타임라인
버전 간 API 마이그레이션 가이드
커널 버전 업그레이드 시 sk_buff 관련 코드에서 자주 필요한 마이그레이션 사항을 정리합니다.
| 구 API (제거/deprecated) | 신 API | 전환 시점 | 비고 |
|---|---|---|---|
skb_make_writable() | skb_ensure_writable() | 5.2+ | Netfilter 수정 패턴 |
CHECKSUM_HW | CHECKSUM_PARTIAL / CHECKSUM_COMPLETE | 2.6.18+ | 체크섬 오프로드 타입 |
skb->rxhash | skb->hash | 3.10+ | L4 해시 통합 |
nf_register_hooks() | nf_register_net_hook() | 4.13+ | 네임스페이스 지원 |
kfree_skb() (드롭 시) | kfree_skb_reason() | 5.10+ | 드롭 이유 추적 권장 |
skb_frag_page() 직접 접근 | skb_frag_netmem() + netmem API | 6.7+ | DMABUF 백엔드 지원 시 |
skb->tstamp (ktime_t) | skb->tstamp (u64 ns) | 4.13+ | SO_TXTIME/ETF 스케줄러 |
netif_rx() (대부분) | napi_gro_receive() | 2.6.29+ | GRO 이점 활용 |
관련 문서
sk_buff와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.