IP 프로토콜 (IPv4/IPv6)
Linux 커널 IPv4/IPv6 프로토콜의 핵심 처리 경로를 심층 분석합니다. 라우팅/FIB 조회, ARP/NDP 기반 이웃 해석, 단편화/재조립, 체크섬 검증, TTL/Hop Limit 및 ICMP 오류 처리, 듀얼스택 환경의 상호작용을 포함해 커널 내부 자료구조와 함수 경로를 연결해 설명하며, MTU/PMTUD·경로 불일치·드롭 문제 디버깅 포인트까지 다룹니다.
핵심 요약
- 패킷 수명주기 — ingress, 처리, egress 경로를 연결합니다.
- 큐/버퍼 모델 — sk_buff와 큐 지점의 역할을 분리합니다.
- 정책/데이터 분리 — 제어 평면과 데이터 평면을 구분합니다.
- 성능 지표 — PPS, 지연, 드롭 원인을 함께 분석합니다.
- 오프로딩 경계 — NIC/XDP/DPDK 경계를 명확히 유지합니다.
단계별 이해
- 경로 고정
문제가 발생한 ingress/egress 지점을 먼저 특정합니다. - 큐 관찰
백로그와 드롭 위치를 계측합니다. - 정책 반영 확인
라우팅/필터 변경이 데이터 경로에 반영됐는지 봅니다. - 부하 검증
실제 트래픽 패턴에서 재현성을 확인합니다.
IPv4 프로토콜
IPv4 심화
IPv4 헤더 구조와 커널 처리
/* include/uapi/linux/ip.h */
struct iphdr {
#if defined(__LITTLE_ENDIAN_BITFIELD)
__u8 ihl:4, /* Internet Header Length (단위: 4바이트) */
version:4; /* IP 버전 (항상 4) */
#elif defined(__BIG_ENDIAN_BITFIELD)
__u8 version:4,
ihl:4;
#endif
__u8 tos; /* Type of Service / DSCP + ECN */
__be16 tot_len; /* 패킷 전체 길이 (헤더 + 페이로드) */
__be16 id; /* 식별자 (단편화용) */
__be16 frag_off; /* 플래그(3bit) + Fragment Offset(13bit) */
__u8 ttl; /* Time To Live */
__u8 protocol; /* 상위 프로토콜 (6=TCP, 17=UDP, 132=SCTP) */
__sum16 check; /* 헤더 체크섬 */
union {
struct {
__be32 saddr; /* 소스 IP 주소 */
__be32 daddr; /* 목적지 IP 주소 */
};
__be32 addrs[2];
};
/* IP 옵션 (ihl > 5일 때, 최대 40바이트) */
};
tos 필드의 상위 6비트는 DSCP(Differentiated Services Code Point)이고, 하위 2비트는 ECN(Explicit Congestion Notification)입니다. ECN은 라우터가 혼잡을 감지하면 패킷을 드롭하는 대신 ECN 비트를 설정하여 송신자에게 알리는 메커니즘으로, TCP 혼잡 제어와 긴밀하게 연동됩니다.
IPv4 수신 경로 (ip_rcv)
/* net/ipv4/ip_input.c — IPv4 패킷 수신 진입점 */
int ip_rcv(struct sk_buff *skb, struct net_device *dev,
struct packet_type *pt, struct net_device *orig_dev)
{
struct iphdr *iph;
/* 1. 기본 검증 */
if (!pskb_may_pull(skb, sizeof(struct iphdr)))
goto drop;
iph = ip_hdr(skb);
/* 2. 버전, IHL, 길이 검증 */
if (iph->ihl < 5 || iph->version != 4)
goto inhdr_error;
if (ntohs(iph->tot_len) < (iph->ihl * 4))
goto inhdr_error;
/* 3. 헤더 체크섬 검증 */
if (ip_fast_csum((u8 *)iph, iph->ihl))
goto csum_error;
/* 4. Netfilter PREROUTING 훅 */
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING,
dev_net(dev), NULL, skb, dev, NULL,
ip_rcv_finish);
/* → ip_rcv_finish → 라우팅 결정 → ip_local_deliver 또는 ip_forward */
}
/* ip_rcv_finish → ip_route_input_noref → 라우팅 결정 */
/* 목적지가 로컬: ip_local_deliver → ip_local_deliver_finish */
/* 목적지가 외부: ip_forward → ip_forward_finish */
/* 브로드캐스트: ip_local_deliver (+ 포워딩 가능) */
/* 멀티캐스트: ip_mr_input (멀티캐스트 라우팅) */
IP 체크섬 (Checksum) 심화
IP 체크섬은 RFC 1071에 정의된 1의 보수(one's complement) 합 알고리즘을 사용합니다. IPv4 헤더의 무결성을 보장하는 핵심 메커니즘으로, 매 홉(hop)마다 TTL이 감소하므로 라우터는 체크섬을 매번 재계산해야 합니다.
체크섬 알고리즘 원리
IP 체크섬은 헤더를 16비트 워드 단위로 나누어 1의 보수 덧셈을 수행한 뒤, 결과의 1의 보수를 취합니다:
/*
* RFC 1071 — Internet Checksum 알고리즘
*
* 1. 체크섬 필드를 0으로 설정
* 2. 헤더를 16비트 워드 단위로 분할
* 3. 모든 워드를 1의 보수 합산 (캐리 발생 시 하위에 더함)
* 4. 최종 합의 1의 보수(비트 반전)가 체크섬 값
*
* 검증: 체크섬 포함하여 전체 합산 → 결과가 0xFFFF이면 유효
*/
/* 단순 구현 (이해용 — 실제 커널은 최적화 버전 사용) */
static __sum16 simple_ip_checksum(const void *data, int len)
{
const __be16 *ptr = data;
u32 sum = 0;
int nwords = len / 2;
while (nwords-- > 0)
sum += *ptr++;
/* 홀수 바이트 처리 (IP 헤더에서는 발생하지 않지만 범용 구현) */
if (len & 1)
sum += *(u8 *)ptr;
/* 캐리 폴딩: 상위 16비트를 하위 16비트에 반복 합산 */
while (sum >> 16)
sum = (sum & 0xFFFF) + (sum >> 16);
return (__sum16)~sum;
}
1의 보수 연산 특성: 일반 2의 보수 덧셈과 달리, 1의 보수 합에서는 캐리(carry)가 발생하면 결과에 1을 더합니다(end-around carry). 이 특성 덕분에 바이트 순서(endianness)에 독립적이며, 합산 순서를 바꿔도 결과가 동일합니다. 또한 체크섬 계산에 체크섬 필드 자체를 포함해도 최종 결과가 0xFFFF(또는 ~0)이 되어 검증이 단순합니다.
커널 체크섬 구현
리눅스 커널은 성능을 위해 아키텍처별 최적화된 체크섬 함수를 제공합니다:
/* arch/x86/include/asm/checksum.h — x86 최적화 */
static inline __sum16 ip_fast_csum(const void *iph, unsigned int ihl)
{
unsigned int sum;
asm(
" movl (%1), %0\\n" /* 첫 번째 32비트 워드 로드 */
" subl $4, %2\\n" /* ihl -= 4 (최소 5이므로 1부터 루프) */
" jbe 2f\\n"
" addl 4(%1), %0\\n" /* 두 번째 워드 가산 */
" adcl 8(%1), %0\\n" /* ADC: 캐리 포함 가산 (1의 보수 합) */
" adcl 12(%1), %0\\n" /* 네 번째 워드 */
"1: adcl 16(%1), %0\\n" /* 루프: IP 옵션 영역 */
" lea 4(%1), %1\\n"
" decl %2\\n"
" jne 1b\\n"
" adcl $0, %0\\n" /* 마지막 캐리 추가 */
" movl %0, %2\\n"
" shrl $16, %0\\n" /* 상위 16비트 */
" addw %w2, %w0\\n" /* 16비트 폴딩 */
" adcl $0, %0\\n"
" notl %0\\n" /* 비트 반전 (1의 보수) */
"2:"
: "=r"(sum), "=r"(iph), "=r"(ihl)
: "1"(iph), "2"(ihl)
: "memory"
);
return (__sum16)sum;
}
/*
* ip_fast_csum 동작 요약:
* - 32비트 단위로 ADC(Add with Carry) 명령어 사용
* - 처음 4워드(20바이트 기본 헤더)는 언롤링으로 분기 없이 처리
* - IP 옵션이 있으면(ihl > 5) 루프로 추가 워드 처리
* - 최종 32비트 → 16비트 폴딩 + NOT
* - 반환값 0 = 체크섬 유효, 비-0 = 오류
*/
/* include/net/checksum.h — 범용 체크섬 유틸리티 */
/* 부분 체크섬 계산 (임의 길이 데이터) */
__wsum csum_partial(const void *buff, int len, __wsum wsum);
/*
* 데이터 버퍼의 부분 체크섬을 계산하여 기존 wsum에 누적.
* TCP/UDP 페이로드 체크섬 계산의 핵심 함수.
* 아키텍처별 어셈블리 최적화 제공 (x86, ARM, MIPS 등).
*
* x86_64: ADCQ 명령어로 64비트 단위 처리 → 대용량 데이터에서 2배 빠름
* ARM: LDMIA + ADC 조합으로 레지스터 파이프라인 최적화
*/
/* 32비트 합을 16비트 체크섬으로 최종 폴딩 */
static inline __sum16 csum_fold(__wsum csum)
{
u32 sum = (__force u32)csum;
sum = (sum & 0xFFFF) + (sum >> 16); /* 첫 번째 폴딩 */
sum = (sum & 0xFFFF) + (sum >> 16); /* 두 번째 폴딩 (캐리 전파) */
return (__sum16)~sum;
}
/* 체크섬 검증: 전체 합이 0이면 유효 */
static inline __sum16 csum_verify(__wsum csum)
{
return csum_fold(csum); /* 결과가 0이면 유효 */
}
/* IP 헤더에 체크섬 기록 (송신 경로) */
static inline void ip_send_check(struct iphdr *iph)
{
iph->check = 0;
iph->check = ip_fast_csum((unsigned char *)iph, iph->ihl);
/*
* 송신 경로에서 IP 헤더 완성 후 호출.
* ip_output() → ip_finish_output() 직전에 실행.
*
* 주요 호출 위치:
* - ip_build_and_send_pkt(): SYN+ACK 응답
* - ip_queue_xmit(): 일반 TCP 송신
* - ip_push_pending_frames(): UDP 등 지연 송신
* - ip_forward_finish(): 포워딩 (증분 갱신 후 호출하지 않음 — ip_decrease_ttl 사용)
*/
}
/* ip_do_csum — 아키텍처 독립적 IP 체크섬 (fallback) */
unsigned short ip_compute_csum(const void *buff, int len);
/*
* 임의 길이 데이터의 체크섬 계산 (csum_fold + csum_partial 조합)
* ICMP 등 IP가 아닌 프로토콜의 체크섬 계산에 사용.
* 내부적으로: return csum_fold(csum_partial(buff, len, 0));
*/
체크섬 산술 헬퍼 함수
커널은 체크섬 값을 조합하거나 변환하기 위한 다양한 산술 헬퍼를 제공합니다. 이 함수들은 include/net/checksum.h에 정의되어 있으며, 체크섬을 부분적으로 계산한 뒤 결합하거나, 프로토콜 스택의 각 계층에서 누적 합산할 때 핵심적으로 사용됩니다:
/* include/net/checksum.h — 체크섬 산술 연산 */
/* 두 부분 체크섬을 1의 보수 합으로 결합 */
static inline __wsum csum_add(__wsum csum, __wsum addend)
{
u32 res = (__force u32)csum;
res += (__force u32)addend;
return (__force __wsum)(res + (res < (__force u32)addend));
/*
* 핵심: res < addend 이면 캐리 발생 → +1 (end-around carry)
*
* 사용 예:
* __wsum total = csum_add(csum_partial(hdr, hdr_len, 0),
* csum_partial(payload, pay_len, 0));
* → 헤더와 페이로드의 부분 체크섬을 결합
*
* 수신 경로에서:
* csum_add(skb->csum, pseudo_header_csum)
* → HW 체크섬(CHECKSUM_COMPLETE) + 의사 헤더 합산
*/
}
/* 체크섬에서 특정 값을 빼기 (1의 보수 감산) */
static inline __wsum csum_sub(__wsum csum, __wsum addend)
{
return csum_add(csum, ~addend);
/*
* 1의 보수 체계에서 빼기 = 비트 반전 후 더하기
* ~addend는 addend의 1의 보수 (비트 반전)
*
* 사용 예: skb_pull() 후 제거된 헤더의 체크섬 보정
* skb->csum = csum_sub(skb->csum, csum_partial(removed_hdr, len, 0));
*
* NAT에서도 사용:
* old 값 빼고 new 값 더하기 = 증분 갱신의 기초
*/
}
/* __sum16(16비트) → __wsum(32비트) 확장 (폴딩의 역연산) */
static inline __wsum csum_unfold(__sum16 n)
{
return (__force __wsum)n;
/*
* 16비트 체크섬을 32비트 wsum으로 제로 확장.
* csum_fold()의 역연산.
*
* 증분 갱신에서 기존 체크섬 필드를 wsum으로 변환할 때 사용:
* __wsum old = csum_unfold(*checksum_field);
* __wsum new = csum_add(csum_sub(old, old_val), new_val);
* *checksum_field = csum_fold(new);
*/
}
/* 블록 단위 부분 체크섬을 오프셋 고려하여 합산 */
static inline __wsum csum_block_add(__wsum csum, __wsum csum2, int offset)
{
u32 sum = (__force u32)csum2;
/* 홀수 오프셋이면 바이트 스왑 — 바이트 정렬 보정 */
if (offset & 1)
sum = ((sum & 0xFF00FF) << 8) + ((sum >> 8) & 0xFF00FF);
return csum_add(csum, (__force __wsum)sum);
/*
* 비연속 메모리 블록의 체크섬을 합산할 때 사용.
* 예: scatter-gather I/O에서 각 fragment의 체크섬을 합산
*
* offset이 홀수이면 16비트 워드 경계가 어긋나므로
* 바이트 스왑으로 보정해야 정확한 1의 보수 합이 됨.
*
* skb_checksum()에서 paged data 처리 시:
* for (각 frag) {
* __wsum frag_csum = csum_partial(frag_data, frag_len, 0);
* csum = csum_block_add(csum, frag_csum, offset);
* offset += frag_len;
* }
*/
}
/* csum_block_add의 역연산 (블록 체크섬 제거) */
static inline __wsum csum_block_sub(__wsum csum, __wsum csum2, int offset)
{
return csum_block_add(csum, ~csum2, offset);
/* skb에서 특정 블록을 제거할 때 체크섬 보정에 사용 */
}
/* csum_block_add의 최적화 변형 — 이미 정렬된 경우 */
static inline __wsum csum_block_add_ext(__wsum csum, __wsum csum2,
int offset, int len)
{
return csum_block_add(csum, csum2, offset);
/* len 매개변수는 향후 최적화를 위해 예약 (현재 미사용) */
}
csum_block_add의 바이트 스왑 이유: 1의 보수 체크섬은 16비트 워드 단위로 합산합니다. 데이터가 홀수 오프셋에서 시작하면 바이트 정렬이 이전 블록과 달라집니다. 예를 들어 바이트열 [AA BB CC DD]에서 오프셋 1 시작이면 [BB CC] [DD ??]로 워드가 분할되어, 오프셋 0의 [AA BB] [CC DD]와 다른 합이 됩니다. csum_block_add는 이 차이를 바이트 스왑으로 보정하여 마치 연속 메모리처럼 정확한 체크섬을 계산합니다.
데이터 복사 + 체크섬 동시 계산
커널은 메모리 복사와 체크섬 계산을 하나의 패스(single pass)로 수행하는 함수들을 제공합니다. 데이터를 한 번만 읽으므로 캐시 효율이 높고, 대용량 페이로드에서 성능 이점이 큽니다:
/* arch/x86/lib/csum-copy_64.S 등 — 아키텍처별 최적화 */
/* 커널 공간 → 커널 공간 복사 + 체크섬 */
__wsum csum_partial_copy_nocheck(const void *src, void *dst,
int len, __wsum sum);
/*
* src → dst로 len 바이트를 복사하면서 동시에 체크섬 누적.
* "nocheck"는 페이지 폴트 검사를 하지 않는다는 의미 (커널 주소만 사용).
*
* 사용 예 — TCP 재전송 큐 복사:
* __wsum csum = 0;
* csum = csum_partial_copy_nocheck(orig_data, new_data, len, csum);
* // csum에는 복사된 데이터의 체크섬이 누적됨
*
* x86_64 구현: REP MOVSB + ADC 조합으로 복사와 합산을 동시에 수행.
* 별도로 csum_partial()을 호출하는 것보다 캐시 미스가 절반으로 줄어듦.
*/
/* 사용자 공간 → 커널 공간 복사 + 체크섬 (수신 경로) */
__wsum csum_partial_copy_from_user(const void __user *src,
void *dst, int len,
__wsum sum, int *err_ptr);
/*
* 사용자 공간에서 커널로 복사하면서 체크섬 계산.
* 페이지 폴트 발생 가능 → *err_ptr에 오류 코드 설정.
*
* 사용 예 — sendmsg() 경로:
* 사용자 버퍼 → skb 데이터 영역 복사 시 체크섬 동시 계산
* → CHECKSUM_PARTIAL이 아닌 경우 SW 체크섬 계산에 활용
*/
/* 커널 공간 → 사용자 공간 복사 + 체크섬 (송신 경로) */
__wsum csum_and_copy_to_user(const void *src,
void __user *dst, int len,
__wsum sum, int *err_ptr);
/*
* 커널에서 사용자 공간으로 복사하면서 체크섬 계산.
*
* 사용 예 — recvmsg() 경로:
* skb 데이터 → 사용자 버퍼 복사 시 체크섬 동시 검증
* tcp_recvmsg() → skb_copy_datagram_msg() 경로에서 활용
*/
/* 사용자 공간 → 커널 공간 복사 + 체크섬 (sendmsg 최적화) */
__wsum csum_and_copy_from_user(const void __user *src,
void *dst, int len,
__wsum sum, int *err_ptr);
/*
* csum_partial_copy_from_user()와 유사하지만,
* 최신 커널에서는 이 함수로 통합되는 추세.
*
* UDP sendmsg() → ip_make_skb() → __ip_append_data() 경로:
* 사용자 버퍼에서 skb 페이지로 복사하면서 체크섬을 누적 계산
* → 나중에 csum_fold()로 최종 체크섬 생성
*/
복사+체크섬 결합의 성능: 대형 패킷(예: 64KB GSO 세그먼트)에서 데이터를 두 번 순회(복사 1회 + 체크섬 1회)하면 캐시 라인을 두 번 로드해야 합니다. csum_partial_copy_nocheck()는 한 번의 순회로 양쪽을 처리하여 L1/L2 캐시 히트율을 크게 향상시킵니다. x86_64에서 64바이트 캐시 라인 기준, 64KB 데이터의 경우 약 1024회의 불필요한 캐시 라인 로드를 절약합니다.
증분 체크섬 갱신 (Incremental Update)
라우터가 패킷을 포워딩할 때 TTL을 감소시키면 체크섬도 갱신해야 합니다. RFC 1624에 따라 전체를 재계산하지 않고 변경된 필드만으로 증분 갱신하여 성능을 최적화합니다:
/* include/net/ip.h — TTL 감소 + 체크섬 증분 갱신 */
static inline int ip_decrease_ttl(struct iphdr *iph)
{
u32 check = (__force u32)iph->check;
check += (__force u32)htons(0x0100);
/*
* TTL은 8번째 바이트 (offset 8).
* TTL이 1 감소하면 16비트 워드 관점에서 상위 바이트가 1 감소.
* 1의 보수 합에서 필드 감소 = 체크섬에 해당 차이를 더함.
* htons(0x0100) = 빅엔디안에서 TTL 바이트 위치의 +1.
*
* 1의 보수 산술: ~(C + (-m) + m') = ~C' (RFC 1624)
* C = 기존 체크섬의 1의 보수
* m = 변경 전 값
* m' = 변경 후 값
* C' = 새 체크섬의 1의 보수
*/
iph->check = (__force __sum16)(check + (check >= 0xFFFF));
/* check >= 0xFFFF: 캐리 발생 시 +1 (end-around carry) */
return --iph->ttl;
}
/* net/core/utils.c — 범용 증분 체크섬 갱신 */
void inet_proto_csum_replace4(__sum16 *sum, struct sk_buff *skb,
__be32 from, __be32 to, bool pseudohdr)
{
/*
* NAT에서 IP 주소 변경 시 사용.
* from: 변경 전 주소, to: 변경 후 주소
* pseudohdr: true면 TCP/UDP 의사 헤더 체크섬도 갱신
*
* 내부적으로 csum_replace4() 호출:
* ~csum_partial(&to, 4, csum_partial(&from_complement, 4, ~old_csum))
*
* 핵심: 전체 패킷을 다시 순회하지 않고 O(1)에 체크섬 갱신
*/
if (skb->ip_summed != CHECKSUM_PARTIAL) {
*sum = csum_fold(
csum_partial((u8 *)&to, 4,
csum_partial((u8 *)&from, 4,
~csum_unfold(*sum))));
} else if (pseudohdr) {
*sum = ~csum_fold(
csum_partial((u8 *)&to, 4,
csum_partial((u8 *)&from, 4,
csum_unfold(*sum))));
}
}
/* 2바이트 필드 변경 (포트 번호 등) */
void inet_proto_csum_replace2(__sum16 *sum, struct sk_buff *skb,
__be16 from, __be16 to, bool pseudohdr);
/* NAT에서 포트 변환(NAPT) 시 TCP/UDP 체크섬 증분 갱신에 사용 */
/* 저수준 증분 갱신 함수 (inet_proto_csum_replace*의 내부) */
static inline void csum_replace4(__sum16 *sum,
__be32 from, __be32 to)
{
*sum = csum_fold(
csum_partial((u8 *)&to, 4,
csum_partial((u8 *)&from, 4,
~csum_unfold(*sum))));
/*
* inet_proto_csum_replace4()의 핵심 로직.
* skb->ip_summed 상태를 고려하지 않는 단순 버전.
*
* 알고리즘: ~(~old_csum + ~old_val + new_val)
* = old_csum - old_val + new_val (1의 보수 산술)
*
* IP 헤더 체크섬 직접 갱신 시 사용:
* csum_replace4(&iph->check, old_saddr, new_saddr);
*/
}
static inline void csum_replace2(__sum16 *sum,
__be16 old, __be16 new)
{
*sum = ~csum16_add(csum16_sub(~(*sum), old), new);
/*
* 2바이트 필드 변경 시 체크섬 증분 갱신.
* TCP/UDP 포트 번호 변경에 사용.
*
* 사용 예 — NAPT 포트 변환:
* csum_replace2(&tcph->check, old_port, new_port);
*/
}
/* IPv6 128비트 주소 변경 시 체크섬 증분 갱신 */
void inet_proto_csum_replace16(__sum16 *sum, struct sk_buff *skb,
const __be32 *from, const __be32 *to,
bool pseudohdr);
/*
* IPv6 NAT에서 128비트 주소 변경 시 사용.
* 내부적으로 4바이트 단위로 4회 반복하여 증분 갱신:
* for (i = 0; i < 4; i++)
* inet_proto_csum_replace4(sum, skb, from[i], to[i], pseudohdr);
*
* IPv6에는 IP 헤더 체크섬이 없으므로 TCP/UDP 의사 헤더 체크섬만 갱신.
* NFT_NAT, ip6tables MASQUERADE 등에서 사용.
*/
/* diff 기반 체크섬 갱신 (임의 길이 변경) */
void inet_proto_csum_replace_by_diff(__sum16 *sum,
struct sk_buff *skb,
__wsum diff, bool pseudohdr);
/*
* 미리 계산된 체크섬 차이(diff)를 적용.
* 가변 길이 데이터 변경 시 유용.
*
* 사용 예 — BPF 프로그램에서:
* __wsum diff = csum_sub(csum_partial(new_data, new_len, 0),
* csum_partial(old_data, old_len, 0));
* inet_proto_csum_replace_by_diff(&csum, skb, diff, true);
*
* bpf_l4_csum_replace() 헬퍼의 내부 구현에서도 사용.
*/
증분 갱신의 성능 이점: 표준 IP 헤더(20바이트)의 체크섬 재계산은 10회의 16비트 덧셈이 필요하지만, 증분 갱신은 단 1~2회의 32비트 연산으로 완료됩니다. 고속 라우팅(포워딩 경로)에서 매 패킷마다 수행되므로 이 최적화는 매우 중요합니다. Netfilter NAT에서 IP 주소와 포트를 동시에 변경할 때도 inet_proto_csum_replace4와 inet_proto_csum_replace2를 순차적으로 호출하여 O(1)에 처리합니다.
NAT 체크섬 갱신 순서: SNAT/DNAT에서 IP 주소와 포트를 동시에 변경할 때, 체크섬 갱신 순서가 중요합니다. 일반적인 순서는: (1) inet_proto_csum_replace4()로 IP 주소 변경분을 L4 체크섬(TCP/UDP check 필드)에 반영, (2) inet_proto_csum_replace2()로 포트 변경분을 L4 체크섬에 반영, (3) csum_replace4()로 IP 주소 변경분을 L3 체크섬(IP header check 필드)에 반영. L3와 L4는 독립적이므로 순서를 바꿔도 결과는 동일하지만, 커널 코드에서는 L4 → L3 순서가 일반적입니다.
TCP/UDP 의사 헤더 체크섬
TCP와 UDP는 IP 계층의 주소 정보를 체크섬에 포함하기 위해 의사 헤더(pseudo-header)를 사용합니다. 이 설계는 잘못된 호스트로 배달된 패킷을 상위 계층에서 탐지할 수 있게 합니다:
| 의사 헤더 필드 | 크기 | 설명 |
|---|---|---|
| Source Address | 4 bytes (IPv4) | 송신 IP 주소 |
| Destination Address | 4 bytes (IPv4) | 수신 IP 주소 |
| Zero | 1 byte | 항상 0 (패딩) |
| Protocol | 1 byte | IPPROTO_TCP 또는 IPPROTO_UDP |
| TCP/UDP Length | 2 bytes | L4 헤더 + payload 길이 |
의사 헤더는 실제로 전송되지 않으며, 체크섬 계산 시에만 포함됩니다.
/* include/net/ip.h — 의사 헤더 체크섬 계산 */
static inline __wsum csum_tcpudp_nofold(
__be32 saddr, __be32 daddr,
__u32 len, __u8 proto, __wsum sum)
{
/* x86 최적화 구현 (인라인 어셈블리) */
asm(
" addl %1, %0\\n" /* sum += saddr */
" adcl %2, %0\\n" /* sum += daddr + carry */
" adcl %3, %0\\n" /* sum += (proto << 8) + len + carry */
" adcl $0, %0\\n" /* 마지막 캐리 추가 */
: "=r"(sum)
: "g"(daddr), "g"(saddr),
"g"((u32)((u32)len + ((u32)proto << 8))),
"0"(sum)
);
return sum;
}
/* 최종 TCP/UDP 체크섬 = csum_fold(의사 헤더 + 헤더 + 페이로드) */
static inline __sum16 csum_tcpudp_magic(
__be32 saddr, __be32 daddr,
__u32 len, __u8 proto, __wsum sum)
{
return csum_fold(csum_tcpudp_nofold(saddr, daddr, len, proto, sum));
}
/* 전송 경로에서 TCP 체크섬 계산 예시 */
/* tcp_v4_send_check() → tcp_v4_check() */
static inline __sum16 tcp_v4_check(int len, __be32 saddr,
__be32 daddr, __wsum base)
{
return csum_tcpudp_magic(saddr, daddr, len, IPPROTO_TCP, base);
/*
* base = csum_partial(TCP 헤더 + 페이로드)
* 의사 헤더(src_ip, dst_ip, 프로토콜, 길이) + 실제 데이터를
* 하나의 1의 보수 합으로 통합
*
* HW offload 시 (CHECKSUM_PARTIAL):
* 의사 헤더 체크섬만 미리 계산하여 TCP 헤더의 check 필드에 기록
* NIC가 나머지(TCP 헤더 + 페이로드) 합산을 HW로 처리
*/
}
프로토콜별 체크섬 함수
각 프로토콜(TCP, UDP, ICMP 등)은 고유한 체크섬 처리 흐름을 가집니다. 커널은 이를 위한 전용 함수들을 제공합니다:
/* ======== TCP 체크섬 함수 ======== */
/* TCP 체크섬 완전성 검증 (수신 경로) */
static inline bool tcp_checksum_complete(struct sk_buff *skb)
{
return !skb_csum_unnecessary(skb) &&
__skb_checksum_complete(skb);
/*
* TCP 수신 경로(tcp_v4_rcv)에서 호출.
*
* 1) CHECKSUM_UNNECESSARY → 즉시 통과 (HW가 검증 완료)
* 2) CHECKSUM_COMPLETE → skb->csum + 의사 헤더 합산하여 검증
* 3) CHECKSUM_NONE → SW로 전체 체크섬 재계산
*
* 내부적으로 __skb_checksum_complete()는:
* __sum16 csum = csum_fold(skb_checksum(skb, 0, skb->len, skb->csum));
* → 0이면 유효, 비-0이면 손상
*
* 실패 시 TCP MIB 카운터 InCsumErrors 증가:
* TCP_MIB_CSUMERRORS
*/
}
/* TCP 체크섬 계산 및 기록 (송신 경로) */
void tcp_v4_send_check(struct sock *sk, struct sk_buff *skb)
{
struct inet_sock *inet = inet_sk(sk);
struct tcphdr *th = tcp_hdr(skb);
__tcp_v4_send_check(skb, inet->inet_saddr, inet->inet_daddr);
/*
* __tcp_v4_send_check() 내부:
* 1) skb->ip_summed == CHECKSUM_PARTIAL이면:
* th->check = ~tcp_v4_check(len, saddr, daddr, 0);
* → 의사 헤더 체크섬의 보수만 기록 (NIC가 나머지 계산)
* → skb->csum_start / skb->csum_offset 설정
*
* 2) 그 외:
* th->check = tcp_v4_check(len, saddr, daddr,
* csum_partial(th, th->doff * 4, skb->csum));
* → SW로 전체 체크섬 계산
*
* 호출 위치:
* tcp_transmit_skb() → __tcp_transmit_skb() → tcp_v4_send_check()
* 모든 TCP 세그먼트 송신 시 호출됨 (SYN, ACK, DATA, FIN 등)
*/
}
/* TCP 체크섬 초기화 (수신 경로 진입점) */
static inline int tcp_v4_checksum_init(struct sk_buff *skb)
{
struct iphdr *iph = ip_hdr(skb);
struct tcphdr *th = tcp_hdr(skb);
if (skb->ip_summed == CHECKSUM_COMPLETE) {
/* HW가 제공한 csum에 의사 헤더 합산 → 0이면 유효 */
if (!tcp_v4_check(skb->len, iph->saddr, iph->daddr, skb->csum)) {
skb->ip_summed = CHECKSUM_UNNECESSARY;
return 0;
}
}
/* SW 검증 준비: 의사 헤더 체크섬을 skb->csum에 미리 저장 */
skb->csum = csum_tcpudp_nofold(iph->saddr, iph->daddr,
skb->len, IPPROTO_TCP, 0);
return 0;
/*
* tcp_v4_rcv() 초반에 호출.
* 이후 tcp_checksum_complete()에서 skb->csum에 누적된
* 의사 헤더 + 데이터 합산으로 최종 검증.
*/
}
/* ======== UDP 체크섬 함수 ======== */
/* UDP 체크섬 초기화 (수신 경로) */
static inline int udp4_csum_init(struct sk_buff *skb,
struct udphdr *uh, int proto)
{
struct iphdr *iph = ip_hdr(skb);
if (uh->check == 0) {
/* UDP 체크섬 0 = 비사용 (IPv4만 허용, IPv6에서는 불법) */
skb->ip_summed = CHECKSUM_UNNECESSARY;
return 0;
}
if (skb->ip_summed == CHECKSUM_COMPLETE) {
/* HW csum + 의사 헤더 합산 → 유효 여부 확인 */
if (!csum_tcpudp_magic(iph->saddr, iph->daddr,
skb->len, proto, skb->csum))
skb->ip_summed = CHECKSUM_UNNECESSARY;
}
if (skb->ip_summed != CHECKSUM_UNNECESSARY)
skb->csum = csum_tcpudp_nofold(iph->saddr, iph->daddr,
skb->len, proto, 0);
return 0;
/*
* __udp4_lib_rcv()에서 호출.
*
* IPv4 UDP 특수 사항:
* - check == 0: 체크섬 미사용 → 즉시 UNNECESSARY
* - RFC 768: "If the computed checksum is zero,
* it is transmitted as all ones"
* - 따라서 수신 시 check == 0xFFFF도 유효
*
* IPv6 UDP는 check == 0이 허용되지 않음:
* udp6_csum_init()에서 check == 0이면 패킷 드롭
*/
}
/* UDP 체크섬 계산 (송신 경로 — SW fallback) */
static inline __wsum udp_csum(struct sk_buff *skb)
{
__wsum csum = csum_partial(udp_hdr(skb),
sizeof(struct udphdr), skb->csum);
/*
* skb->csum에는 페이로드의 부분 체크섬이 이미 누적되어 있음
* (__ip_append_data()에서 csum_and_copy_from_user()로 계산)
*
* UDP 헤더(8바이트)의 체크섬을 추가하여 반환.
* 이후 csum_tcpudp_magic()으로 의사 헤더를 합산하면 최종 체크섬.
*/
return csum;
}
void udp4_hwcsum(struct sk_buff *skb, __be32 src, __be32 dst);
/*
* HW 체크섬 오프로드를 위한 UDP 설정.
* ip_summed = CHECKSUM_PARTIAL일 때 호출.
*
* 의사 헤더 체크섬을 미리 계산하여 uh->check에 기록:
* uh->check = ~csum_tcpudp_magic(src, dst, len, IPPROTO_UDP, 0);
*
* scatter-gather 비활성 시 (단일 선형 버퍼):
* skb->ip_summed = CHECKSUM_NONE;
* uh->check = csum_tcpudp_magic(src, dst, len, IPPROTO_UDP,
* csum_partial(uh, len, 0));
* → 작은 패킷은 SW 계산이 더 효율적
*/
/* UDP-Lite: 부분 체크섬 지원 */
static inline __wsum udplite_csum(struct sk_buff *skb)
{
int cscov = min_t(int, ntohs(udp_hdr(skb)->len), skb->len);
return skb_checksum(skb, 0, cscov, 0);
/*
* UDP-Lite (RFC 3828): 체크섬 적용 범위를 선택 가능.
* uh->len 필드가 체크섬 커버리지 길이를 지정.
* 실시간 오디오/비디오에서 일부 손상 허용 시 유용.
*
* 체크섬 커버리지:
* 0 → 전체 패킷 (표준 UDP와 동일)
* 8 → 헤더만 (최소)
* n → 처음 n 바이트만 보호
*/
}
/* ======== ICMP 체크섬 함수 ======== */
/* ICMP 체크섬 검증 (수신 경로) */
static inline bool icmp_checksum_validate(struct sk_buff *skb)
{
return skb_checksum_simple_validate(skb);
/*
* ICMP는 의사 헤더를 사용하지 않음 (TCP/UDP와 다름).
* ICMP 헤더 + 데이터 전체에 대해 단순 체크섬만 검증.
*
* skb_checksum_simple_validate() 내부:
* if (ip_summed == CHECKSUM_COMPLETE)
* → csum_fold(skb->csum) == 0이면 유효
* else
* → skb_checksum(skb, 0, skb->len, 0) → fold → 0이면 유효
*
* icmp_rcv()에서 호출. 실패 시 ICMP_MIB_CSUMERRORS 증가.
*/
}
/* ICMP 체크섬 계산 (송신 경로) */
/* icmp_push_reply()에서: */
struct icmphdr *icmph;
icmph->checksum = 0;
icmph->checksum = csum_fold(
csum_partial(icmph, skb->len - skb_transport_offset(skb),
skb->csum));
/*
* ICMP 송신 시 체크섬 계산 흐름:
* 1. icmph->checksum = 0으로 초기화
* 2. ICMP 헤더 + 페이로드의 csum_partial() 계산
* 3. skb->csum에 이미 누적된 페이로드 부분합과 합산
* 4. csum_fold()로 최종 16비트 체크섬 생성
*
* ICMP Echo Reply의 최적화:
* 요청 패킷의 체크섬으로부터 증분 갱신 가능
* (type 필드만 변경: 8→0, code는 동일)
*/
/* ICMPv6 체크섬 (의사 헤더 사용!) */
/* ICMPv6는 ICMP와 달리 TCP/UDP처럼 의사 헤더를 포함 */
static inline __sum16 csum_ipv6_magic_icmpv6(
const struct in6_addr *saddr,
const struct in6_addr *daddr,
__u32 len, __wsum csum)
{
return csum_ipv6_magic(saddr, daddr, len, IPPROTO_ICMPV6, csum);
/*
* ICMPv6 (RFC 4443)는 IPv6 의사 헤더를 체크섬에 포함.
* IPv4 ICMP와의 핵심 차이점:
* - IPv4 ICMP: 체크섬 = ICMP 헤더 + 데이터만
* - ICMPv6: 체크섬 = 의사 헤더 + ICMPv6 헤더 + 데이터
*
* 이유: IPv6에는 IP 헤더 체크섬이 없으므로,
* 잘못된 주소로 배달된 패킷을 탐지하기 위해 의사 헤더 필요.
*
* 영향받는 프로토콜:
* - Neighbor Discovery (NDP): NS, NA, RS, RA
* - MLD (Multicast Listener Discovery)
* - ICMPv6 Echo Request/Reply
*/
}
/* ======== Netfilter 체크섬 함수 ======== */
int nf_ip_checksum(struct sk_buff *skb, unsigned int hook,
unsigned int dataoff, u_int8_t protocol);
/*
* Netfilter 훅에서 패킷 체크섬을 검증하는 범용 함수.
* conntrack, NAT, iptables 규칙 평가 전에 호출.
*
* 동작:
* 1) CHECKSUM_UNNECESSARY → 즉시 통과
* 2) protocol == 0 (IP 헤더만): ip_fast_csum()으로 검증
* 3) protocol != 0 (TCP/UDP): 의사 헤더 + 페이로드 체크섬 검증
*
* hook 매개변수에 따른 최적화:
* NF_INET_PRE_ROUTING: ip_rcv()에서 IP 헤더 검증 완료 → L4만 검증
* NF_INET_LOCAL_IN: 이미 검증된 경우 스킵 가능
*
* Conntrack에서 사용 예:
* nf_conntrack_tcp_packet()에서 TCP 체크섬 검증
* → 유효하지 않으면 conntrack 추적하지 않음
*/
int nf_checksum(struct sk_buff *skb, unsigned int hook,
unsigned int dataoff, u_int8_t protocol,
unsigned short family);
/*
* IPv4/IPv6 통합 체크섬 검증 함수.
* family에 따라 nf_ip_checksum() 또는 nf_ip6_checksum() 호출.
* nf_tables (nft) 프레임워크에서 주로 사용.
*/
/* ======== BPF 체크섬 헬퍼 ======== */
/* BPF 프로그램에서 사용 가능한 체크섬 헬퍼 함수들 */
s64 bpf_csum_diff(__be32 *from, u32 from_size,
__be32 *to, u32 to_size,
__wsum seed);
/*
* BPF_FUNC_csum_diff — BPF 프로그램에서 체크섬 차이 계산.
* from 데이터와 to 데이터의 체크섬 차이를 반환.
*
* XDP/TC BPF에서 패킷 수정 시:
* diff = bpf_csum_diff(old_data, 4, new_data, 4, 0);
* bpf_l3_csum_replace(skb, IP_CSUM_OFFSET, 0, diff, 0);
* bpf_l4_csum_replace(skb, TCP_CSUM_OFFSET, 0, diff, BPF_F_PSEUDO_HDR);
*/
int bpf_l3_csum_replace(struct sk_buff *skb, u32 offset,
u64 from, u64 to, u64 flags);
/*
* BPF_FUNC_l3_csum_replace — L3 (IP) 체크섬 증분 갱신.
* offset: skb 내 체크섬 필드의 오프셋
* flags: 2 또는 4 (변경 크기 바이트)
*
* 내부적으로 inet_proto_csum_replace{2,4}() 호출.
*/
int bpf_l4_csum_replace(struct sk_buff *skb, u32 offset,
u64 from, u64 to, u64 flags);
/*
* BPF_FUNC_l4_csum_replace — L4 (TCP/UDP) 체크섬 증분 갱신.
* BPF_F_PSEUDO_HDR 플래그: 의사 헤더 변경을 고려한 갱신.
*
* XDP 프로그램의 DNAT 구현 예:
* // IP 주소 변경
* bpf_l3_csum_replace(skb, IP_CSUM_OFF, old_ip, new_ip, 4);
* bpf_l4_csum_replace(skb, TCP_CSUM_OFF, old_ip, new_ip,
* 4 | BPF_F_PSEUDO_HDR);
* // 포트 변경
* bpf_l4_csum_replace(skb, TCP_CSUM_OFF, old_port, new_port, 2);
*/
프로토콜별 체크섬 차이 요약: (1) IP 헤더: 헤더만 보호, 매 홉 재계산, 의사 헤더 없음. (2) TCP/UDP: 헤더+페이로드+의사 헤더, 종단 간 보호. (3) ICMP: 헤더+데이터, 의사 헤더 없음 (IPv4). (4) ICMPv6: 헤더+데이터+의사 헤더 (IPv4 ICMP와 다름!). (5) UDP-Lite: 체크섬 커버리지 길이 선택 가능. 이 차이들을 정확히 이해해야 패킷 조작(NAT, BPF, Netfilter) 시 올바른 체크섬 갱신이 가능합니다.
하드웨어 체크섬 오프로드 상세
최신 NIC는 체크섬 계산을 하드웨어로 수행하여 CPU 부담을 크게 줄입니다. sk_buff의 ip_summed 필드와 관련 필드들이 이를 제어합니다:
/* sk_buff 체크섬 관련 필드 */
struct sk_buff {
/* ... */
__u8 ip_summed:2; /* CHECKSUM_NONE/UNNECESSARY/COMPLETE/PARTIAL */
union {
__wsum csum; /* 수신: HW가 계산한 raw 체크섬 (COMPLETE) */
struct {
__u16 csum_start; /* 송신: 체크섬 계산 시작 오프셋 (skb->head 기준) */
__u16 csum_offset; /* 송신: 체크섬 저장 위치 (csum_start 기준) */
};
};
/* ... */
};
/* ======== 수신 경로 (RX) ======== */
/* 1) CHECKSUM_COMPLETE: NIC가 L4 전체 패킷 합을 계산하여 제공 */
/* 드라이버: skb->csum에 HW 체크섬 저장 */
/* 스택: skb_checksum_validate()에서 의사 헤더만 추가로 합산하여 검증 */
static inline bool __skb_checksum_validate_needed(
struct sk_buff *skb, bool zero_okay, __wsum pseudohdr)
{
if (skb->ip_summed == CHECKSUM_COMPLETE) {
/* HW csum + 의사 헤더 합산 → 폴딩 → 0이면 유효 */
if (!csum_fold(csum_add(skb->csum, pseudohdr)))
return false; /* 유효 → SW 재검증 불필요 */
}
return true; /* SW 검증 필요 */
}
/* 2) CHECKSUM_UNNECESSARY: NIC가 체크섬 검증까지 완료 */
/* 가장 빠름. loopback, 일부 고급 NIC */
/* 3) CHECKSUM_NONE: HW 지원 없음 → SW 전체 검증 */
/* ======== 송신 경로 (TX) ======== */
/* CHECKSUM_PARTIAL: 프로토콜 스택이 의사 헤더 체크섬만 계산 */
/* NIC가 csum_start부터 패킷 끝까지의 체크섬을 계산하여 */
/* csum_start + csum_offset 위치에 기록 */
static inline void skb_set_transport_header_csum(
struct sk_buff *skb, int offset)
{
skb->ip_summed = CHECKSUM_PARTIAL;
skb->csum_start = skb_headroom(skb) + offset;
skb->csum_offset = offsetof(struct tcphdr, check);
/* TCP: csum_offset = 16 (check 필드의 오프셋) */
/* UDP: csum_offset = 6 (check 필드의 오프셋) */
}
/* NIC가 HW offload를 지원하지 않을 때의 SW fallback */
int skb_checksum_help(struct sk_buff *skb)
{
/*
* CHECKSUM_PARTIAL → CHECKSUM_NONE으로 변환
* SW로 체크섬을 직접 계산하여 패킷에 기록
*
* 호출 시점:
* - NIC가 NETIF_F_HW_CSUM / NETIF_F_IP_CSUM 미지원
* - Netfilter가 패킷을 변조하여 HW offload 불가능
* - veth, bridge 등 가상 디바이스 경유
*/
__wsum csum;
int offset = skb->csum_start - skb_headroom(skb);
csum = skb_checksum(skb, offset, skb->len - offset, 0);
*(__sum16 *)(skb->data + offset + skb->csum_offset) = csum_fold(csum);
skb->ip_summed = CHECKSUM_NONE;
return 0;
}
skb 체크섬 조작 함수 상세
네트워크 스택은 sk_buff의 체크섬 상태를 조작하는 다양한 헬퍼 함수를 제공합니다. 패킷 수신/송신/변조의 각 단계에서 체크섬 일관성을 유지하는 데 핵심적입니다:
/* ======== skb 체크섬 계산 ======== */
/* skb 전체(선형 + paged) 데이터의 체크섬 계산 */
__wsum skb_checksum(const struct sk_buff *skb,
int offset, int len, __wsum csum);
/*
* skb의 offset부터 len 바이트의 체크섬을 계산하여 csum에 누적.
*
* 핵심: skb는 선형 영역(skb->data)과 paged 영역(skb_shinfo(skb)->frags)으로
* 구성되므로 두 영역을 모두 순회해야 합니다.
*
* 내부 동작:
* 1. 선형 영역: csum_partial(skb->data + offset, copy, csum)
* 2. Paged frags: 각 frag를 kmap_atomic()으로 매핑 후
* csum_partial() + csum_block_add()로 오프셋 보정하며 누적
* 3. Frag list: skb_shinfo(skb)->frag_list의 sub-skb 재귀 처리
*
* 사용 위치:
* - skb_checksum_help(): PARTIAL → NONE 변환 시
* - __skb_checksum_complete(): 수신 경로 SW 검증
* - tcp_checksum_complete(): TCP 체크섬 검증
*
* 주의: paged data가 highmem에 있을 수 있으므로
* kmap_atomic()/kunmap_atomic() 사용.
* 이 함수는 sleep 불가능한 컨텍스트에서도 안전.
*/
/* ======== skb 체크섬 검증 헬퍼 ======== */
/* 범용 체크섬 검증 (TCP/UDP/SCTP 공통) */
static inline bool skb_csum_unnecessary(const struct sk_buff *skb)
{
return ((skb->ip_summed == CHECKSUM_UNNECESSARY) ||
skb->csum_valid);
/*
* HW가 체크섬 검증을 완료했는지 확인.
* CHECKSUM_UNNECESSARY: NIC가 직접 검증 완료
* csum_valid: 이전 단계에서 SW 검증 통과
*
* 이 함수가 true 반환 → SW 재검증 생략 가능.
* tcp_checksum_complete(), __udp_lib_checksum_complete() 등에서 사용.
*/
}
/* L4 프로토콜 체크섬 완전 검증 (SW fallback) */
__sum16 __skb_checksum_complete(struct sk_buff *skb);
/*
* SW로 skb 전체 체크섬을 계산하여 검증.
* 반환값 0 = 유효, 비-0 = 손상.
*
* 내부: csum_fold(skb_checksum(skb, 0, skb->len, skb->csum))
*
* 주요 호출 경로:
* tcp_checksum_complete() → __skb_checksum_complete()
* udp_lib_checksum_complete() → __skb_checksum_complete()
*
* skb->csum에 이미 의사 헤더 합이 저장되어 있어야 정확한 검증 가능.
* (tcp_v4_checksum_init, udp4_csum_init 등에서 사전 설정)
*
* 성공 시 skb->csum_valid = 1로 설정 → 이후 재검증 생략.
*/
/* 단순 체크섬 검증 (의사 헤더 없는 프로토콜용) */
static inline __sum16 skb_checksum_simple_validate(struct sk_buff *skb)
{
return skb_checksum_validate(skb, 0, true, false, 0,
null_compute_pseudo);
/*
* ICMP, IGMP 등 의사 헤더가 없는 프로토콜의 체크섬 검증.
* 의사 헤더 계산 함수로 null_compute_pseudo(항상 0)를 전달.
*
* 사용 위치:
* icmp_rcv() → skb_checksum_simple_validate()
* igmp_rcv() → skb_checksum_simple_validate()
*/
}
/* 체크섬 초기화 (수신 경로 진입점) */
static inline int skb_checksum_init(struct sk_buff *skb,
__u16 proto,
inet_compute_pseudo_fn compute_pseudo)
{
return skb_checksum_init_zero_check(skb, proto, 0, compute_pseudo);
/*
* L4 체크섬 검증을 위한 초기 설정.
*
* 동작:
* CHECKSUM_COMPLETE인 경우:
* 의사 헤더 + skb->csum 합산 → 유효하면 UNNECESSARY로 승격
* 그 외:
* 의사 헤더 체크섬을 skb->csum에 저장 (나중에 SW 검증용)
*
* compute_pseudo: 프로토콜별 의사 헤더 계산 함수
* - tcp4: tcp_v4_check()
* - udp4: udp_v4_check()
* - tcp6: tcp_v6_check()
* - udp6: udp_v6_check()
*/
}
/* ======== skb 조작 시 체크섬 보정 ======== */
/* skb_pull() 후 제거된 헤더의 체크섬 보정 */
static inline void skb_postpull_rcsum(struct sk_buff *skb,
const void *start,
unsigned int len)
{
if (skb->ip_summed == CHECKSUM_COMPLETE)
skb->csum = csum_sub(skb->csum,
csum_partial(start, len, 0));
/*
* skb_pull()로 헤더를 제거하면 skb->data 이동 → csum 범위 변경.
* CHECKSUM_COMPLETE의 csum은 L4 전체 체크섬이므로,
* 제거된 헤더의 체크섬을 빼야 정확한 L4 체크섬 유지.
*
* 사용 예 — VLAN 헤더 제거:
* __vlan_hwaccel_pull_tag()에서:
* skb_postpull_rcsum(skb, vhdr, VLAN_HLEN);
*
* GRE 디캡슐레이션:
* gre_rcv()에서 GRE 헤더 strip 후 호출
*
* 중요: CHECKSUM_PARTIAL/NONE/UNNECESSARY에는 영향 없음.
*/
}
/* skb_push() 후 추가된 헤더의 체크섬 보정 */
static inline void skb_postpush_rcsum(struct sk_buff *skb,
const void *start,
unsigned int len)
{
if (skb->ip_summed == CHECKSUM_COMPLETE)
skb->csum = csum_add(skb->csum,
csum_partial(start, len, 0));
/*
* skb_push()로 헤더를 추가하면 csum에 새 헤더의 체크섬을 더함.
*
* 사용 예 — VLAN 태그 삽입:
* vlan_insert_tag()에서 호출
*
* 터널 캡슐화:
* ip_tunnel_xmit() → iptunnel_handle_offloads()에서
* 외부 IP/GRE 헤더 추가 시 호출
*/
}
/* skb 데이터 변경 후 체크섬 무효화 */
static inline void skb_checksum_none_assert(const struct sk_buff *skb)
{
BUG_ON(skb->ip_summed != CHECKSUM_NONE);
/* 디버깅용: ip_summed가 NONE이 아니면 BUG 트리거.
* 수신 드라이버가 HW 체크섬을 설정하지 않을 때 사용. */
}
/* 수신 패킷 체크섬 완전 검증 후 user space 복사 */
int skb_copy_and_csum_datagram_msg(struct sk_buff *skb,
int hlen,
struct msghdr *msg);
/*
* 수신 패킷의 데이터를 사용자 공간으로 복사하면서 체크섬 검증.
* recvmsg() 시스템 콜 경로에서 사용.
*
* 동작:
* 1. CHECKSUM_UNNECESSARY → 단순 복사 (검증 생략)
* 2. CHECKSUM_COMPLETE → 복사하면서 체크섬 검증
* → csum_and_copy_to_user() 활용
* 3. 복사 완료 후 csum_fold() → 0이면 유효
*
* UDP recvmsg() 경로:
* udp_recvmsg() → skb_copy_and_csum_datagram_msg()
* → 복사와 검증을 한 패스로 수행 → 캐시 효율 극대화
*
* TCP에서는 이미 tcp_v4_rcv()에서 검증 완료이므로
* 이 함수 대신 단순 skb_copy_datagram_msg() 사용.
*/
/* 송신 경로: skb를 NIC로 전달하기 전 체크섬 처리 */
int skb_csum_hwoffload_help(struct sk_buff *skb,
const netdev_features_t features);
/*
* dev_queue_xmit() → validate_xmit_skb() 경로에서 호출.
* NIC의 체크섬 오프로드 능력에 따라 적절한 처리를 결정:
*
* 1) NIC가 해당 프로토콜의 HW 체크섬 지원:
* → 아무것도 하지 않음 (CHECKSUM_PARTIAL 유지)
*
* 2) NIC가 미지원:
* → skb_checksum_help() 호출하여 SW로 체크섬 계산
* → CHECKSUM_PARTIAL → CHECKSUM_NONE 전환
*
* features: NIC의 netdev_features (NETIF_F_*_CSUM 등)
* 반환값: 0 (성공), -EINVAL (실패 — 패킷 드롭)
*/
skb_postpull_rcsum/skb_postpush_rcsum 사용 시 주의: 이 함수들은 CHECKSUM_COMPLETE일 때만 동작합니다. 커널 모듈에서 패킷을 수정할 때 흔한 실수는 ip_summed 상태를 확인하지 않고 skb->csum을 직접 변경하는 것입니다. 항상 skb_postpull_rcsum()/skb_postpush_rcsum()을 사용하거나, 패킷 수정 후 skb->ip_summed = CHECKSUM_NONE으로 설정하여 SW 재검증을 강제하세요.
체크섬 오프로드와 패킷 캡처: tcpdump/Wireshark에서 송신 패킷의 체크섬이 잘못된 것으로 표시되는 경우가 많습니다. 이는 CHECKSUM_PARTIAL 상태에서 캡처 시점에 아직 NIC가 체크섬을 계산하지 않았기 때문입니다. 수신 측에서는 정상 체크섬이 확인됩니다. ethtool -K eth0 tx-checksum-ipv4 off로 오프로드를 비활성화하면 SW가 체크섬을 계산하여 캡처에서도 올바른 값을 볼 수 있습니다.
NIC 체크섬 피처 플래그
| 피처 플래그 | 설명 | 적용 범위 |
|---|---|---|
NETIF_F_IP_CSUM |
IPv4 TCP/UDP HW 체크섬 (L4) | IPv4 + TCP/UDP만. 의사 헤더 포함 HW 계산 |
NETIF_F_IPV6_CSUM |
IPv6 TCP/UDP HW 체크섬 (L4) | IPv6 + TCP/UDP만 |
NETIF_F_HW_CSUM |
범용 HW 체크섬 | 임의 프로토콜. csum_start/csum_offset 기반으로 NIC가 계산 |
NETIF_F_RXCSUM |
수신 HW 체크섬 검증 | NIC가 수신 패킷 체크섬을 검증하여 CHECKSUM_COMPLETE 또는 CHECKSUM_UNNECESSARY 설정 |
# 현재 NIC의 체크섬 오프로드 상태 확인
ethtool -k eth0 | grep checksum
# rx-checksumming: on
# tx-checksumming: on
# tx-checksum-ipv4: on
# tx-checksum-ipv6: on
# tx-checksum-ip-generic: off [not requested]
# 개별 제어
ethtool -K eth0 rx off # 수신 체크섬 오프로드 비활성화
ethtool -K eth0 tx-checksum-ipv4 off # 송신 IPv4 체크섬 오프로드 비활성화
중첩 체크섬 오프로드 (csum_level)
터널 캡슐화(VXLAN, GRE, Geneve 등)에서는 외부/내부 헤더 각각에 체크섬이 존재합니다. skb->csum_level 필드는 NIC가 몇 단계 깊이까지 체크섬을 검증했는지를 나타냅니다:
/* include/linux/skbuff.h — csum_level 필드 */
struct sk_buff {
/* ... */
__u8 csum_level:2; /* 중첩 체크섬 검증 깊이 (0~3) */
/*
* CHECKSUM_UNNECESSARY와 함께 사용.
*
* csum_level = 0: 가장 바깥쪽 L4 체크섬만 검증됨 (기본)
* csum_level = 1: 한 단계 캡슐화된 내부 L4도 검증됨
* csum_level = 2: 두 단계 중첩 캡슐화의 내부까지 검증됨
* csum_level = 3: 세 단계 중첩까지 (이론적 최대)
*
* 예시 — VXLAN 패킷:
* [외부 Ethernet] [외부 IP] [외부 UDP] [VXLAN] [내부 Ethernet] [내부 IP] [내부 TCP]
*
* NIC가 NETIF_F_RXCSUM만 지원:
* csum_level = 0 → 외부 UDP 체크섬만 검증
* → 내부 TCP는 SW 재검증 필요
*
* NIC가 중첩 오프로드 지원 (mlx5, i40e 등):
* csum_level = 1 → 내부 TCP 체크섬도 HW 검증 완료
* → 내부 수신 경로에서 체크섬 검증 생략 가능
*/
/* ... */
};
/* csum_level 감소 (캡슐 해제 시) */
static inline void skb_decr_csum_level(struct sk_buff *skb)
{
if (skb->ip_summed == CHECKSUM_UNNECESSARY && skb->csum_level)
skb->csum_level--;
}
/*
* 터널 디캡슐레이션 시 호출.
* 외부 헤더를 벗기면 csum_level을 1 감소.
*
* 예시 흐름 (VXLAN 디캡슐레이션):
* NIC 수신: csum_level = 1, ip_summed = UNNECESSARY
* → vxlan_rcv()에서 외부 UDP 헤더 제거
* → skb_decr_csum_level(skb) → csum_level = 0
* → 내부 패킷이 ip_summed = UNNECESSARY, csum_level = 0
* → 내부 TCP/UDP 체크섬 검증 생략 가능
*
* NIC가 중첩 오프로드를 미지원하는 경우:
* csum_level = 0 → 디캡슐레이션 후 csum_level은 여전히 0
* → 내부 패킷은 ip_summed = CHECKSUM_NONE으로 강제 → SW 재검증
*/
/* csum_level 증가 (캡슐화 시 — 거의 사용되지 않음) */
static inline void skb_incr_csum_level(struct sk_buff *skb)
{
if (skb->ip_summed == CHECKSUM_UNNECESSARY &&
skb->csum_level < SKB_MAX_CSUM_LEVEL)
skb->csum_level++;
}
/* NIC 드라이버에서 csum_level 설정 예시 (mlx5) */
static inline void mlx5e_handle_csum(struct sk_buff *skb, u32 cqe_flags)
{
if (cqe_flags & MLX5_CQE_L4_OK) {
skb->ip_summed = CHECKSUM_UNNECESSARY;
if (cqe_flags & MLX5_CQE_INNER_L4_OK) {
/* NIC가 내부 L4 체크섬도 검증 */
skb->csum_level = 1;
}
} else if (cqe_flags & MLX5_CQE_L4_CSUM_OK) {
skb->ip_summed = CHECKSUM_COMPLETE;
skb->csum = cqe_get_csum(cqe);
} else {
skb->ip_summed = CHECKSUM_NONE;
}
}
csum_level의 실무 영향: VXLAN/Geneve 기반 오버레이 네트워크(Kubernetes, OpenStack 등)에서 csum_level을 올바르게 설정하는 NIC 드라이버를 사용하면 CPU 사용률이 5~15% 감소합니다. 특히 Mellanox ConnectX-5+, Intel X710/E810 등 최신 NIC는 중첩 오프로드를 지원합니다. ethtool -k eth0 | grep inner로 확인 가능합니다. 미지원 시 내부 패킷마다 SW 체크섬 검증이 필요하여 pps(초당 패킷 수)가 크게 저하됩니다.
IPv4 vs IPv6 체크섬 차이
| 항목 | IPv4 | IPv6 |
|---|---|---|
| IP 헤더 체크섬 | 있음 (iphdr->check). 매 홉마다 재계산 |
없음. L2(이더넷 CRC) + L4(TCP/UDP) 체크섬으로 대체 |
| 설계 이유 | 1980년대: L2 CRC가 불충분한 환경 고려 | 라우터 포워딩 성능 향상. L2/L4 체크섬이 충분히 강력 |
| UDP 체크섬 | 선택 (0이면 미사용) | 필수. IP 헤더 체크섬이 없으므로 의사 헤더로 보완 |
| 의사 헤더 크기 | 12바이트 (src4 + dst4 + zero + proto + len) | 40바이트 (src16 + dst16 + len4 + zero3 + next_header1) |
| 커널 함수 | ip_fast_csum(), csum_tcpudp_magic() |
csum_ipv6_magic() (의사 헤더만, IP 헤더 체크섬 없음) |
/* include/net/ip6_checksum.h — IPv6 의사 헤더 체크섬 */
__sum16 csum_ipv6_magic(
const struct in6_addr *saddr, /* 128비트 소스 주소 */
const struct in6_addr *daddr, /* 128비트 목적지 주소 */
__u32 len, /* Upper-Layer Packet Length */
__u8 proto, /* Next Header (6=TCP, 17=UDP) */
__wsum csum /* 기존 부분 합 */
);
/*
* IPv6 의사 헤더 (RFC 8200, Section 8.1):
* Source Address (16) + Dest Address (16) +
* Upper-Layer Packet Length (4) + zero (3) + Next Header (1)
* = 40바이트
*
* IPv4보다 의사 헤더가 크므로 csum 연산이 조금 더 비싸지만,
* IP 헤더 체크섬 자체가 없어 라우터 포워딩은 더 빠름
*/
체크섬의 한계: 1의 보수 체크섬은 단일 비트 오류는 항상 검출하지만, 16비트 워드 경계의 동일 위치에서 두 비트가 동시에 반전되는 경우 검출 실패할 수 있습니다. CRC-32(이더넷)는 최대 32비트 버스트 오류까지 검출하므로, L2 CRC와 L4 체크섬의 이중 보호가 중요합니다. 추가로 TCP는 선택적으로 TCP-AO(Authentication Option) 또는 레거시 TCP-MD5를 사용하여 암호학적 무결성을 보장할 수 있습니다.
실전 체크섬 처리 시나리오
지금까지 설명한 체크섬 함수들이 실제 패킷 처리 경로에서 어떻게 조합되는지, 4가지 대표적인 시나리오로 살펴봅니다:
시나리오 1: 패킷 수신 → 로컬 TCP 소켓 전달
/*
* NIC → 드라이버 → IP 스택 → TCP 스택 → 소켓 수신 버퍼
*
* [1] NIC 드라이버 (e.g., e1000e, ixgbe)
* ─ HW 체크섬 결과를 skb에 설정
*/
static void driver_rx_handler(struct sk_buff *skb,
u32 hw_status)
{
if (hw_status & HW_RX_CSUM_VALID) {
skb->ip_summed = CHECKSUM_UNNECESSARY;
/* 가장 빠른 경로: NIC가 L4 체크섬 검증 완료 */
} else if (hw_status & HW_RX_CSUM_CALCULATED) {
skb->ip_summed = CHECKSUM_COMPLETE;
skb->csum = hw_status & HW_CSUM_MASK;
/* NIC가 raw 체크섬 제공 → 의사 헤더 합산 필요 */
} else {
skb->ip_summed = CHECKSUM_NONE;
/* HW 미지원 → 전체 SW 검증 필요 */
}
}
/*
* [2] ip_rcv() → ip_rcv_core()
* ─ IP 헤더 체크섬 검증 (L3)
*/
if (ip_fast_csum((u8 *)iph, iph->ihl))
goto csum_error; /* IP 헤더 손상 → 패킷 드롭 */
/* 참고: CHECKSUM_UNNECESSARY여도 IP 헤더 검증은 항상 수행
* (HW가 L4만 검증하는 NIC가 많으므로) */
/*
* [3] tcp_v4_rcv() → TCP 체크섬 초기화
*/
tcp_v4_checksum_init(skb);
/* CHECKSUM_COMPLETE → 의사 헤더 합산 → 유효하면 UNNECESSARY 승격
* CHECKSUM_NONE → 의사 헤더를 skb->csum에 미리 저장 */
/*
* [4] tcp_v4_do_rcv() → tcp_checksum_complete()
* ─ 최종 체크섬 검증
*/
if (tcp_checksum_complete(skb)) {
TCP_INC_STATS(net, TCP_MIB_CSUMERRORS);
goto bad_packet;
}
/*
* UNNECESSARY → 즉시 통과 (검증 생략)
* COMPLETE/NONE → __skb_checksum_complete()로 SW 검증
*
* 전체 흐름 요약:
* UNNECESSARY: 드라이버 → ip_rcv(L3만) → tcp_rcv → 즉시 통과
* COMPLETE: 드라이버 → ip_rcv(L3) → init(+pseudo) → complete(fold→0?)
* NONE: 드라이버 → ip_rcv(L3) → init(pseudo저장) → complete(전체 SW 합산)
*/
시나리오 2: NAT 처리 (SNAT — 소스 IP + 포트 변경)
/*
* Netfilter SNAT 처리 시 체크섬 갱신 순서:
*
* 패킷: [IP: src=10.0.0.1, dst=8.8.8.8] [TCP: sport=12345, dport=80]
* 변환: src → 203.0.113.1, sport → 45678
*/
/* [1] L4 체크섬에 IP 주소 변경 반영 */
inet_proto_csum_replace4(&tcph->check, skb,
iph->saddr, /* from: 10.0.0.1 */
new_saddr, /* to: 203.0.113.1 */
true); /* pseudohdr = true */
/*
* pseudohdr = true:
* CHECKSUM_PARTIAL → ~fold(partial(&to, partial(&from, unfold(*sum))))
* 그 외 → fold(partial(&to, partial(&from, ~unfold(*sum))))
*
* TCP 의사 헤더에 포함된 src IP가 변경되므로
* TCP 체크섬을 증분 갱신해야 함
*/
/* [2] L4 체크섬에 포트 변경 반영 */
inet_proto_csum_replace2(&tcph->check, skb,
tcph->source, /* from: 12345 */
new_sport, /* to: 45678 */
false); /* pseudohdr = false */
/*
* pseudohdr = false:
* 포트는 TCP 헤더의 실제 필드이므로 의사 헤더와 무관.
* TCP 체크섬에 직접적으로 영향.
*/
/* [3] IP 주소 실제 변경 */
iph->saddr = new_saddr;
tcph->source = new_sport;
/* [4] L3 (IP 헤더) 체크섬 갱신 */
csum_replace4(&iph->check, old_saddr, new_saddr);
/*
* IP 헤더 체크섬은 IP 헤더만 포함하므로
* 포트 변경은 영향 없음.
*
* 또는 더 안전하게:
* ip_send_check(iph); // 전체 재계산 (IP 옵션 변경 시 필수)
*
* 실제 커널 코드 (nf_nat_ipv4_manip_pkt):
* 1. nf_nat_l4proto_manip_pkt() → L4 체크섬 갱신
* 2. csum_replace4(&iph->check, old, new) → L3 체크섬 갱신
* 3. iph->saddr = new_saddr → IP 주소 실제 변경
*/
시나리오 3: 터널 캡슐화 (VXLAN 송신)
/*
* 내부 패킷을 VXLAN 터널로 캡슐화하여 송신하는 흐름.
* 체크섬 처리가 여러 계층에서 발생합니다.
*
* 원본: [내부 Eth] [내부 IP] [내부 TCP (csum_partial)]
* 결과: [외부 Eth] [외부 IP] [외부 UDP] [VXLAN] [내부 Eth] [내부 IP] [내부 TCP]
*/
/* [1] 내부 패킷의 체크섬 상태 */
/* skb->ip_summed = CHECKSUM_PARTIAL (의사 헤더만 계산됨) */
/* skb->csum_start → 내부 TCP 헤더 시작 */
/* skb->csum_offset → TCP check 필드 오프셋 (16) */
/* [2] VXLAN 캡슐화 (vxlan_xmit_one) */
/* 내부 패킷에 외부 UDP/IP 헤더 추가 */
udp_tunnel_xmit_skb(rt, sk, skb, src, dst,
tos, ttl, df, sport, dport,
xnet, !udp_sum);
/* [3] 외부 UDP 체크섬 처리 */
if (udp_sum) {
/* 외부 UDP 체크섬 활성화 */
udp_set_csum(nocheck, skb, src, dst, skb->len);
/*
* NIC가 NETIF_F_GSO_UDP_TUNNEL_CSUM 지원 시:
* → 내부 TCP + 외부 UDP 모두 HW에 위임
* → skb->inner_transport_header 설정으로 NIC에 알림
*
* NIC 미지원 시:
* → skb_checksum_help()로 외부 UDP 체크섬 SW 계산
* → 내부 TCP는 여전히 CHECKSUM_PARTIAL 유지 가능
* (NIC가 NETIF_F_HW_CSUM 지원 시)
*/
} else {
/* 외부 UDP 체크섬 비활성화 (uh->check = 0) */
/* RFC 7348: VXLAN은 외부 UDP 체크섬 0 허용 */
/* 성능 상 이점: 외부 체크섬 계산 생략 */
}
/* [4] 외부 IP 헤더 체크섬 */
ip_send_check(iph);
/* 항상 SW로 계산 (IP 헤더는 20바이트로 작음) */
/* [5] validate_xmit_skb() — NIC 전달 전 최종 체크 */
skb_csum_hwoffload_help(skb, features);
/*
* NIC features 확인 후:
* 지원 → CHECKSUM_PARTIAL 유지 (NIC가 계산)
* 미지원 → skb_checksum_help()로 SW 계산 → CHECKSUM_NONE
*/
시나리오 4: 커널 모듈에서 raw 패킷 생성
/*
* 커널 모듈(예: IPVS, custom netfilter)에서 새 패킷을 생성할 때
* 체크섬을 올바르게 설정하는 전체 예시.
*/
struct sk_buff *build_tcp_rst(__be32 saddr, __be32 daddr,
__be16 sport, __be16 dport,
__be32 seq)
{
struct sk_buff *skb;
struct iphdr *iph;
struct tcphdr *tcph;
int tot_len = sizeof(struct iphdr) + sizeof(struct tcphdr);
skb = alloc_skb(LL_MAX_HEADER + tot_len, GFP_ATOMIC);
if (!skb) return NULL;
skb_reserve(skb, LL_MAX_HEADER);
skb_reset_network_header(skb);
/* IP 헤더 구성 */
iph = skb_put(skb, sizeof(*iph));
iph->version = 4;
iph->ihl = 5;
iph->tos = 0;
iph->tot_len = htons(tot_len);
iph->id = 0;
iph->frag_off = htons(IP_DF);
iph->ttl = 64;
iph->protocol = IPPROTO_TCP;
iph->saddr = saddr;
iph->daddr = daddr;
iph->check = 0;
iph->check = ip_fast_csum((u8 *)iph, iph->ihl);
/* ↑ L3 체크섬: ip_fast_csum()으로 IP 헤더 체크섬 계산 */
/* TCP 헤더 구성 */
skb_set_transport_header(skb, sizeof(*iph));
tcph = skb_put(skb, sizeof(*tcph));
memset(tcph, 0, sizeof(*tcph));
tcph->source = sport;
tcph->dest = dport;
tcph->seq = seq;
tcph->doff = sizeof(*tcph) / 4;
tcph->rst = 1;
tcph->window = 0;
/* ↓ L4 체크섬: 두 가지 방법 */
/* 방법 A: SW 체크섬 (간단하지만 CPU 사용) */
tcph->check = 0;
tcph->check = tcp_v4_check(
sizeof(*tcph), saddr, daddr,
csum_partial(tcph, sizeof(*tcph), 0));
skb->ip_summed = CHECKSUM_NONE;
/*
* tcp_v4_check() = csum_fold(csum_tcpudp_nofold(
* saddr, daddr, len, IPPROTO_TCP, base))
*
* base = csum_partial(tcph, sizeof(*tcph), 0) → TCP 헤더의 부분 합
* csum_tcpudp_nofold() → 의사 헤더 합산
* csum_fold() → 16비트 폴딩 + 반전
*/
/* 방법 B: HW 오프로드 (성능 최적화) */
/*
* tcph->check = ~csum_tcpudp_magic(
* saddr, daddr, sizeof(*tcph), IPPROTO_TCP, 0);
* skb->ip_summed = CHECKSUM_PARTIAL;
* skb->csum_start = skb_transport_header(skb) - skb->head;
* skb->csum_offset = offsetof(struct tcphdr, check);
*
* → 의사 헤더의 보수만 기록, NIC가 최종 체크섬 계산
* → NIC가 NETIF_F_IP_CSUM을 지원해야 동작
* → 미지원 시 validate_xmit_skb()에서 자동 SW fallback
*/
return skb;
}
체크섬 함수 호출 관계도
체크섬 디버깅 팁: (1) dropwatch -l kas로 체크섬 오류로 인한 패킷 드롭 위치를 확인할 수 있습니다. (2) /proc/net/snmp의 InCsumErrors 카운터로 TCP/UDP 체크섬 오류 횟수를 모니터링합니다. (3) Wireshark에서 tcp.checksum.status == "Bad" 필터로 체크섬 오류 패킷만 필터링 가능합니다 (단, TX offload 패킷은 오탐에 주의). (4) perf probe로 __skb_checksum_complete에 동적 트레이스포인트를 설정하여 SW fallback 빈도를 측정할 수 있습니다: perf probe --add __skb_checksum_complete && perf stat -e probe:__skb_checksum_complete -a sleep 10.
IP 단편화와 재조합
IPv4 패킷이 MTU를 초과하면 단편화(fragmentation)가 발생합니다. 커널은 수신 시 ip_defrag()로 단편을 재조합합니다:
/* frag_off 필드 해석 */
#define IP_DF 0x4000 /* Don't Fragment 플래그 */
#define IP_MF 0x2000 /* More Fragments 플래그 */
#define IP_OFFSET 0x1FFF /* Fragment Offset 마스크 (단위: 8바이트) */
/* 단편화 여부 확인 */
if (iph->frag_off & htons(IP_MF | IP_OFFSET))
/* 이 패킷은 단편이다 → 재조합 필요 */
return ip_defrag(net, skb, IP_DEFRAG_LOCAL_DELIVER);
/* net/ipv4/ip_fragment.c — 재조합 핵심 */
/* ip_defrag()는 해시 테이블(inet_frag_queue)에 단편을 수집
* 키: (src_ip, dst_ip, id, protocol)
* 모든 단편 도착 시 → ip_frag_reasm()으로 하나의 skb로 합침
*
* 타임아웃: ipfrag_time (기본 30초)
* → 시간 내 모든 단편 미도착 시 재조합 포기, skb 해제
*
* 메모리 제한: ipfrag_high_thresh / ipfrag_low_thresh
* → 재조합 대기 메모리가 high_thresh 초과 시 오래된 큐 강제 해제
*/
| sysctl 매개변수 | 기본값 | 설명 |
|---|---|---|
net.ipv4.ipfrag_time |
30 (초) | 재조합 타임아웃. 초과 시 모든 단편 폐기 |
net.ipv4.ipfrag_high_thresh |
4194304 (4MB) | 재조합 대기 메모리 상한. 초과 시 오래된 큐 제거 |
net.ipv4.ipfrag_low_thresh |
3145728 (3MB) | 메모리 회수 후 목표 수준 |
net.ipv4.ipfrag_max_dist |
64 | 동일 소스의 최대 비순차 단편 수 (공격 방어) |
net.ipv4.ip_no_pmtu_disc |
0 | PMTU Discovery 비활성화 (1로 설정 시 항상 DF=0) |
Path MTU Discovery (PMTUD) 심화
Path MTU Discovery는 송신자와 수신자 사이 경로에서 단편화 없이 전송 가능한 최대 패킷 크기를 동적으로 탐지하는 메커니즘입니다. IPv4에서는 RFC 1191, IPv6에서는 RFC 8201로 정의되며, 리눅스 커널은 기본적으로 PMTUD를 활성화합니다.
PMTUD 동작 원리
PMTUD의 핵심은 IP 헤더의 DF(Don't Fragment) 비트와 ICMP 메시지의 상호작용입니다:
커널 PMTUD 구현
리눅스 커널의 PMTUD는 라우팅 서브시스템과 전송 계층이 긴밀하게 협력하여 동작합니다. PMTU 값은 dst_entry 경로 정보에 저장되며, fib_nh_exception 구조체를 통해 per-destination으로 관리됩니다.
/* include/net/dst.h — 경로의 PMTU 조회 */
static inline u32 dst_mtu(const struct dst_entry *dst)
{
/* dst_ops에 정의된 mtu 콜백 호출
* IPv4: ipv4_mtu() → PMTU 캐시 또는 인터페이스 MTU 반환
* IPv6: ip6_mtu() → PMTU 캐시 또는 인터페이스 MTU 반환 */
return dst->ops->mtu(dst);
}
/* net/ipv4/route.c — IPv4 PMTU 조회 */
static unsigned int ipv4_mtu(const struct dst_entry *dst)
{
unsigned int mtu = dst_metric_raw(dst, RTAX_MTU);
struct net *net = dev_net(dst->dev);
if (mtu)
return mtu; /* 명시적으로 설정된 route MTU */
mtu = READ_ONCE(dst->dev->mtu); /* 인터페이스 MTU */
if (unlikely(ip_mtu_locked(dst)))
return mtu; /* 관리자가 lock한 MTU (변경 불가) */
/* fib_nh_exception에 캐싱된 PMTU가 있으면 그 값 사용 */
if (mtu > IPV4_MIN_MTU) /* 68바이트 (RFC 791 최소) */
return mtu;
return IPV4_MIN_MTU;
}
ICMP "Fragmentation Needed" 수신 처리
ICMP Type 3, Code 4 메시지를 수신하면 커널은 해당 목적지에 대한 PMTU를 갱신합니다:
/* net/ipv4/route.c — PMTU 갱신 핵심 함수 */
static void __ip_rt_update_pmtu(struct rtable *rt,
struct flowi4 *fl4, u32 mtu)
{
struct dst_entry *dst = &rt->dst;
struct net *net = dev_net(dst->dev);
struct fib_result res;
bool lock = false;
/* RFC 1191: PMTU는 최소 68바이트 (IPv4 최소 MTU) */
if (mtu < IPV4_MIN_MTU) {
lock = true;
mtu = IPV4_MIN_MTU;
}
/* ip_no_pmtu_disc 설정 시 PMTUD 무시 */
if (mtu < ip_rt_min_pmtu(net))
mtu = ip_rt_min_pmtu(net);
/* FIB nexthop exception에 PMTU 캐싱 */
rcu_read_lock();
if (fib_lookup(net, fl4, &res, 0) == 0) {
struct fib_nh_common *nhc = FIB_RES_NHC(res);
update_or_create_fnhe(nhc, fl4->daddr, 0, mtu, lock,
jiffies + ip_rt_mtu_expires(net));
}
rcu_read_unlock();
}
/* net/ipv4/icmp.c — ICMP "Fragmentation Needed" 수신 경로 */
/*
* icmp_rcv()
* → icmp_unreach() (Type 3 처리)
* → icmp_unreach_handler()
* → ICMP_MIB_INMSGS++
* → 내부 IP 헤더에서 원본 (src, dst, proto) 추출
* → 상위 프로토콜의 err_handler 호출:
* TCP: tcp_v4_err() → tcp_v4_mtu_reduced()
* UDP: udp_err() → ip_icmp_error()
* → ip_rt_update_pmtu() 호출하여 라우팅 캐시 갱신
*/
TCP와 PMTUD 연동
TCP는 PMTU 변경에 가장 적극적으로 대응하는 프로토콜입니다. ICMP 에러 수신 시 MSS를 조정하고 필요하면 세그먼트를 재전송합니다:
/* net/ipv4/tcp_ipv4.c — PMTU 감소 시 TCP 처리 */
static void tcp_v4_mtu_reduced(struct sock *sk)
{
struct inet_sock *inet = inet_sk(sk);
struct dst_entry *dst;
u32 mtu;
/* LISTEN 상태에서는 무시 */
if ((1 << sk->sk_state) & (TCPF_LISTEN | TCPF_CLOSE))
return;
dst = inet_csk_update_pmtu(sk, tcp_sk(sk)->mtu_info);
if (!dst)
return;
mtu = dst_mtu(dst);
/* 새 PMTU에 맞춰 MSS 재계산 */
if (inet_csk(sk)->icsk_pmtu_cookie > mtu) {
/* TCP MSS = PMTU - IP 헤더(20) - TCP 헤더(20+옵션)
* 예: PMTU=1400 → MSS = 1400 - 20 - 32 = 1348 */
tcp_sync_mss(sk, mtu);
/* 이미 전송된 세그먼트가 새 PMTU 초과 시
* 재전송 큐의 세그먼트를 분할하여 재전송 */
tcp_simple_retransmit(sk);
}
}
/* TCP MSS 클램핑 관련 sysctl */
/* net.ipv4.tcp_mtu_probing:
* 0 = 비활성 (기본)
* 1 = PMTUD 블랙홀 감지 시에만 probing 활성화
* 2 = 항상 probing 활성화
*
* net.ipv4.tcp_base_mss:
* MTU probing 시작 MSS (기본: 1024)
* → PMTUD 블랙홀 감지 시 이 값부터 시작하여 점진적으로 증가
*
* net.ipv4.tcp_mtu_probe_floor:
* probing 최소 MSS (기본: 48)
*/
TCP MSS와 PMTU 관계: TCP SYN 패킷의 MSS 옵션은 인터페이스 MTU에서 IP+TCP 헤더를 뺀 값입니다. 예를 들어 Ethernet MTU 1500이면 MSS=1460 (IPv4) 또는 1440 (IPv6)입니다. PMTUD가 경로상 더 작은 MTU를 발견하면 MSS를 동적으로 줄여 단편화를 방지합니다.
PMTUD 블랙홀 문제
PMTUD의 가장 심각한 문제는 블랙홀(Black Hole)입니다. 중간 경로의 방화벽이나 라우터가 ICMP "Fragmentation Needed" 메시지를 차단하면, 송신자는 PMTU를 알 수 없어 큰 패킷이 사라지는 현상이 발생합니다:
# PMTUD 블랙홀 증상 진단
# 1. 작은 패킷(ping)은 정상, 큰 패킷(SSH, HTTP)이 멈춤
ping -c 3 -M do -s 1472 목적지 # DF=1, 1500B (20+8+1472) → 통과 확인
ping -c 3 -M do -s 1400 목적지 # 점진적으로 줄여 병목 MTU 탐지
# 2. tracepath로 PMTU 탐색 (ICMP 기반)
tracepath -n 목적지
# 출력 예:
# 1?: [LOCALHOST] pmtu 1500
# 1: 192.168.1.1 0.345ms
# 2: 10.0.0.1 1.234ms pmtu 1400 ← 여기서 MTU 감소
# 3: 172.16.0.1 2.567ms reached
# Resume: pmtu 1400
# 3. 커널 PMTU 캐시 확인
ip route get 목적지
# 출력 예:
# 목적지 via 192.168.1.1 dev eth0 src 192.168.1.100
# cache expires 542sec mtu 1400
# 4. PMTU 캐시 강제 삭제 (재탐색 유도)
ip route flush cache
블랙홀 해결 방법
| 해결 방법 | 적용 위치 | 설명 | 설정 예시 |
|---|---|---|---|
| MSS Clamping | Netfilter (라우터) | TCP SYN의 MSS 옵션을 강제로 줄여 PMTUD 없이도 단편화 방지 | iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu |
| TCP MTU Probing | 송신 호스트 | ICMP 없이 TCP 계층에서 직접 적정 MSS를 탐색 (PLPMTUD 유사) | sysctl net.ipv4.tcp_mtu_probing=1 |
| 인터페이스 MTU 축소 | 송신 호스트/터널 | 터널 오버헤드를 고려한 보수적 MTU 설정 | ip link set dev tun0 mtu 1400 |
| PMTUD 비활성화 | 송신 호스트 | DF=0으로 설정하여 중간 라우터가 단편화 (성능 저하) | sysctl net.ipv4.ip_no_pmtu_disc=1 |
TCP MTU Probing (PLPMTUD) 상세
RFC 4821/8899에 정의된 Packetization Layer PMTUD (PLPMTUD)는 ICMP에 의존하지 않고 전송 계층에서 직접 적정 패킷 크기를 탐색합니다. 리눅스의 tcp_mtu_probing이 이 메커니즘을 구현합니다:
/* net/ipv4/tcp_timer.c — TCP MTU Probing 구현 */
static void tcp_mtup_probe(struct sock *sk)
{
struct tcp_sock *tp = tcp_sk(sk);
struct inet_connection_sock *icsk = inet_csk(sk);
struct sk_buff *skb;
int probe_size;
/* 이진 탐색으로 최적 MSS 탐색
* search_low = 현재 동작하는 MSS (확인된 하한)
* search_high = 시도할 MSS 상한
* probe_size = (search_low + search_high) / 2 */
probe_size = (icsk->icsk_mtup.search_low +
icsk->icsk_mtup.search_high) / 2;
/* probe 패킷 전송:
* - 일반 데이터를 probe_size 크기로 전송
* - ACK 수신 → search_low = probe_size (성공, 상향 탐색)
* - RTO 타임아웃 → search_high = probe_size (실패, 하향 탐색)
* - search_high - search_low < 8 이면 탐색 종료 */
}
/* tcp_mtu_probing 동작 모드:
*
* 모드 0 (기본): MTU probing 비활성. 전통적 PMTUD만 사용.
*
* 모드 1 (블랙홀 감지):
* - 정상 시에는 전통적 PMTUD 사용
* - TCP 재전송 타임아웃(RTO) 반복 시 블랙홀로 판단
* - 블랙홀 감지 → tcp_base_mss부터 이진 탐색 시작
* - 장점: 오버헤드 최소, 블랙홀 자동 복구
*
* 모드 2 (항상 활성):
* - 연결 시작부터 MTU probing 수행
* - ICMP가 전혀 동작하지 않는 환경에 적합
* - 오버헤드가 있으므로 일반적으로 모드 1 권장
*/
IPv6 Path MTU Discovery
IPv6는 중간 라우터의 단편화를 금지하므로 PMTUD가 더욱 중요합니다. 최소 MTU는 1280바이트이며, ICMPv6 "Packet Too Big" (Type 2)를 사용합니다:
/* net/ipv6/route.c — IPv6 PMTU 갱신 */
static void ip6_rt_update_pmtu(struct dst_entry *dst, struct sock *sk,
struct sk_buff *skb, u32 mtu,
bool confirm_neigh)
{
struct rt6_info *rt6 = (struct rt6_info *)dst;
/* IPv6 최소 MTU = 1280 (RFC 8200) */
if (mtu < IPV6_MIN_MTU)
mtu = IPV6_MIN_MTU;
/* rt6_exception에 PMTU 캐싱 (IPv4의 fib_nh_exception과 유사) */
rt6_do_update_pmtu(rt6, mtu);
rt6_update_exception_stamp_rt(rt6);
}
/* IPv4 vs IPv6 PMTUD 차이점:
*
* 항목 IPv4 (RFC 1191) IPv6 (RFC 8201)
* ─────────────────────────────────────────────────────────────
* ICMP 메시지 Type 3 Code 4 ICMPv6 Type 2
* "Frag Needed" "Packet Too Big"
* 최소 MTU 68 바이트 1280 바이트
* 중간 라우터 단편화 허용 (DF=0일 때) 금지 (항상)
* 송신측 단편화 선택적 Fragment Extension Header 사용
* DF 비트 명시적 설정 필요 암묵적 (항상 DF=1 동작)
* PMTU 만료 ip_rt_mtu_expires rt6_mtu_expires
* 최소 PMTU sysctl ip_rt_min_pmtu (없음, 항상 1280)
*/
PMTUD 관련 sysctl 매개변수 종합
| sysctl 매개변수 | 기본값 | 설명 |
|---|---|---|
net.ipv4.ip_no_pmtu_disc |
0 | 전역 PMTUD 비활성화. 1=항상 DF=0, 2=DF 설정하되 PMTUD 하지 않음, 3=PMTU 정보 유지하되 사용 안 함 |
net.ipv4.route.min_pmtu |
552 | PMTU 최솟값. 이보다 작은 ICMP 응답은 이 값으로 클램핑 (RFC 1191의 68보다 높게 설정) |
net.ipv4.route.mtu_expires |
600 (초) | 캐싱된 PMTU의 만료 시간. 만료 후 인터페이스 MTU로 복원 (경로 변경 대응) |
net.ipv4.tcp_mtu_probing |
0 | TCP MTU probing 모드. 0=비활성, 1=블랙홀 시 활성, 2=항상 활성 |
net.ipv4.tcp_base_mss |
1024 | MTU probing 시작 MSS. 블랙홀 감지 시 이 크기부터 이진 탐색 |
net.ipv4.tcp_mtu_probe_floor |
48 | MTU probing 최소 MSS 하한. 이보다 낮은 MSS는 시도하지 않음 |
net.ipv4.ip_forward_use_pmtu |
0 | 포워딩 시 PMTU 사용 여부. 1=PMTU 적용 (라우터에서 단편화 감소), 0=인터페이스 MTU 사용 |
net.ipv6.conf.*.mtu |
(인터페이스별) | 인터페이스별 IPv6 MTU. PMTUD 상한값으로 사용 |
터널과 PMTUD
VPN, GRE, VXLAN 등 터널 환경에서는 캡슐화 오버헤드로 인해 PMTUD가 특히 중요합니다:
# 터널별 일반적인 오버헤드와 권장 내부 MTU (외부 MTU=1500 기준)
#
# 터널 유형 오버헤드 내부 MTU 비고
# ──────────────────────────────────────────────────────────────
# GRE 24B (IP+GRE) 1476 키/시퀀스 시 +4~8B
# GRE + IPsec ESP ≈80~120B ≈1380 암호화 알고리즘에 따라 변동
# VXLAN 50B (UDP+VXLAN) 1450 Jumbo Frame 사용 시 완화
# WireGuard 60B (UDP+WG) 1420 IPv6 외부: 1400
# IPsec ESP(전송) 36~73B ≈1430 암호화 + 인증
# IPsec ESP(터널) 56~93B ≈1400 + 외부 IP 헤더 20B
# GENEVE 50~258B ≈1450 가변 옵션 길이
# IP-in-IP (IPIP) 20B 1480 최소 오버헤드
# 터널 PMTUD 설정 예시
ip tunnel add gre1 mode gre remote 10.0.0.2 local 10.0.0.1 \
pmtudisc # DF 비트 설정 (PMTUD 활성, 기본값)
ip link set gre1 mtu 1476 # 내부 MTU 수동 설정
# 터널 인터페이스에 MSS clamping 적용
iptables -t mangle -A FORWARD -o gre1 -p tcp \
--tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
# 커널 내부: 터널 xmit 시 PMTU 처리
# ip_tunnel_xmit() → 내부 패킷 크기 > 터널 PMTU?
# → DF=1인 내부 패킷: 원래 송신자에게 ICMP "Frag Needed" 전송
# → DF=0인 내부 패킷: 내부 패킷을 단편화 후 각각 캡슐화
이중 단편화(Double Fragmentation) 주의: 터널에서 nopmtudisc를 설정하면 외부 패킷이 단편화됩니다. 수신 측에서 외부 IP 재조합 → 디캡슐화 → 내부 IP 재조합으로 이중 재조합이 필요할 수 있어 성능이 크게 저하됩니다. 가능하면 pmtudisc(기본값)를 유지하고 내부 MTU를 적절히 설정하세요.
PMTUD 디버깅
# 1. 현재 경로의 PMTU 확인
ip route get 203.0.113.50
# 203.0.113.50 via 192.168.1.1 dev eth0 src 192.168.1.100 uid 0
# cache expires 542sec mtu 1400 ← PMTU가 캐싱됨
# 2. 모든 PMTU 예외 확인 (fib_nh_exception)
ip route show cache
# 203.0.113.50 via 192.168.1.1 dev eth0
# cache expires 542sec mtu 1400
# 198.51.100.25 via 192.168.1.1 dev eth0
# cache expires 310sec mtu 1380
# 3. ICMP "Fragmentation Needed" 수신 모니터링
tcpdump -i eth0 'icmp[icmptype] == 3 and icmp[icmpcode] == 4' -nn -v
# → IP 10.0.0.1 > 192.168.1.100: ICMP 203.0.113.50 unreachable -
# need to frag (mtu 1400), length 556
# 4. ICMPv6 "Packet Too Big" 모니터링
tcpdump -i eth0 'icmp6 and ip6[40] == 2' -nn -v
# 5. nstat으로 PMTU 관련 통계 확인
nstat -az | grep -i -E 'Pmtu|Frag|Mtu'
# IpFragOKs 0 # 성공적으로 단편화한 패킷 수
# IpFragFails 5 # DF=1로 단편화 실패 (ICMP 전송됨)
# IpFragCreates 0 # 생성된 단편 수
# IpReasmReqds 12 # 재조합 요청 수
# IpReasmOKs 12 # 재조합 성공 수
# IpReasmFails 0 # 재조합 실패 수
# 6. ftrace로 PMTU 갱신 추적
echo 1 > /sys/kernel/debug/tracing/events/fib/fib_table_lookup/enable
echo 'nexthop_exceptions != 0' > /sys/kernel/debug/tracing/events/fib/fib_table_lookup/filter
cat /sys/kernel/debug/tracing/trace_pipe
# 7. 수동 PMTU 테스트 (이진 탐색)
# MTU 1500에서 시작하여 DF 비트로 테스트
for size in 1472 1400 1300 1200; do
ping -c 1 -M do -s $size -W 2 목적지 &> /dev/null \
&& echo "MTU >= $((size + 28)): OK" \
|| echo "MTU < $((size + 28)): FAIL"
done
운영 권장 사항: (1) tcp_mtu_probing=1을 서버에 기본 설정하여 PMTUD 블랙홀을 자동 복구, (2) 터널/VPN 라우터에는 TCPMSS --clamp-mss-to-pmtu 적용을 우선 검토, (3) route.mtu_expires를 환경에 맞게 조정 (동적 경로가 많으면 짧게, 안정적이면 길게), (4) IpFragFails 카운터를 모니터링하여 PMTUD 블랙홀 조기 감지.
IPv4 전송 경로
/* net/ipv4/ip_output.c — IPv4 전송 */
int ip_queue_xmit(struct sock *sk, struct sk_buff *skb, struct flowi *fl)
{
/* 1. 라우팅 조회 (캐시된 dst 또는 새로 lookup) */
rt = ip_route_output_flow(net, fl4, sk);
/* 2. IP 헤더 구성 */
iph = ip_hdr(skb);
iph->version = 4;
iph->ihl = 5;
iph->tos = inet->tos; /* IP_TOS 소켓 옵션 */
iph->tot_len = htons(skb->len);
iph->id = ip_select_ident(...); /* per-destination 카운터 */
iph->frag_off = htons(IP_DF); /* PMTUD 활성 시 DF 설정 */
iph->ttl = ip_select_ttl(inet, &rt->dst);
iph->protocol = sk->sk_protocol;
iph->saddr = fl4->saddr;
iph->daddr = fl4->daddr;
/* 3. Netfilter LOCAL_OUT 훅 → ip_output */
return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_OUT,
net, sk, skb, NULL, rt->dst.dev,
dst_output);
}
/* ip_output → ip_finish_output → ip_fragment (필요 시) → dev_queue_xmit */
/* ip_fragment: skb->len > mtu && DF 미설정 시 단편화 수행
* → ICMP "Fragmentation Needed" (DF 설정 시) 또는 실제 단편화
* → GSO skb는 skb_gso_segment()으로 분할 후 각각 전송
*/
IP 옵션 처리
| 옵션 | 타입 번호 | 커널 처리 | 보안 영향 |
|---|---|---|---|
| Record Route | 7 | ip_options_compile()에서 파싱, 포워딩 시 자기 IP 기록 |
네트워크 토폴로지 노출 위험 |
| Timestamp | 68 | 타임스탬프 기록/검증 | 시스템 시간 노출 |
| Loose Source Route | 131 | 지정 경로 경유 (기본 비활성: accept_source_route=0) |
IP 스푸핑 악용 가능 → 서버에서 반드시 비활성화 |
| Strict Source Route | 137 | 정확한 경로 강제 | Loose와 동일한 보안 위험 |
| Router Alert | 148 | 라우터가 패킷을 로컬 처리 (IGMP, RSVP) | DoS 가능성 (라우터 CPU 소비) |
IP Source Routing 보안: net.ipv4.accept_source_route=0 (기본값)은 source route 옵션이 포함된 패킷을 폐기합니다. 이 옵션을 절대 활성화하지 마세요. 공격자가 패킷 경로를 조작하여 방화벽을 우회하거나 IP 스푸핑에 악용할 수 있습니다.
LLC/SNAP (Logical Link Control)
LLC(IEEE 802.2)는 OSI 모델의 데이터 링크 계층 상위에 위치하며, SNAP(Sub-Network Access Protocol)과 함께 L2 프레임의 프로토콜 식별을 담당합니다.
IEEE 802.2 LLC 프레임 구조
/* Ethernet II vs IEEE 802.3 + LLC/SNAP */
/*
* Ethernet II (DIX):
* [Dst MAC 6B][Src MAC 6B][EtherType 2B][Payload][FCS]
* EtherType ≥ 0x0600 → 프로토콜 식별 (0x0800=IPv4, 0x86DD=IPv6)
*
* IEEE 802.3 + LLC:
* [Dst MAC 6B][Src MAC 6B][Length 2B][LLC Header 3B][Payload][FCS]
* Length < 0x0600 → IEEE 802.3 프레임
* LLC Header: [DSAP 1B][SSAP 1B][Control 1~2B]
*
* IEEE 802.3 + LLC/SNAP:
* [Dst MAC][Src MAC][Length][LLC(AA:AA:03)][SNAP(OUI 3B + Type 2B)][Payload]
* DSAP=0xAA, SSAP=0xAA, Ctrl=0x03 → SNAP 식별
* OUI=00:00:00 + Type → EtherType와 동일한 프로토콜 식별
*/
/* include/linux/llc.h */
struct llc_snap_hdr {
__u8 dsap; /* Destination SAP (0xAA for SNAP) */
__u8 ssap; /* Source SAP (0xAA for SNAP) */
__u8 ctrl; /* Control (0x03 = UI frame) */
__u8 oui[3]; /* Organization Unique Identifier */
__be16 ethertype; /* SNAP 프로토콜 타입 */
};
커널 LLC 구현
/* net/llc/ — LLC 서브시스템 */
/* llc_rcv(): LLC 프레임 수신 진입점
* → eth_type_trans()에서 skb->protocol = htons(ETH_P_802_2)로 설정
* → llc_rcv()에서 DSAP/SSAP에 따라 적절한 SAP으로 전달
*/
/* SAP (Service Access Point) 등록 */
struct llc_sap *sap = llc_sap_open(0xAA, NULL);
/* → DSAP 0xAA로 오는 프레임을 이 SAP에서 수신 */
/* LLC 사용 프로토콜:
* DSAP 0xFE: ISO CLNP (IS-IS 라우팅)
* DSAP 0x42: STP (Spanning Tree Protocol)
* DSAP 0xAA: SNAP (대부분의 상위 프로토콜)
* DSAP 0x06: IP over IEEE 802.2 (거의 미사용)
*/
/* STP (Spanning Tree Protocol) — 브리지 루프 방지 */
/* net/bridge/br_stp_bpdu.c */
/* BPDU 수신: LLC DSAP=0x42 → br_stp_rcv()
* → STP 상태 머신 업데이트 (BLOCKING, LISTENING, LEARNING, FORWARDING)
*/
| 프레임 유형 | EtherType/Length | LLC 헤더 | 사용 사례 |
|---|---|---|---|
| Ethernet II | ≥ 0x0600 (EtherType) | 없음 | IPv4/IPv6, ARP 등 대부분의 현대 프로토콜 |
| 802.3 + LLC | < 0x0600 (Length) | DSAP/SSAP/Ctrl | STP, NetBEUI, IPX (레거시) |
| 802.3 + LLC/SNAP | < 0x0600 (Length) | AA:AA:03 + OUI + Type | 802.11 (WiFi), AppleTalk, 일부 VLAN |
/* net/ethernet/eth.c — 프레임 타입 판별 */
__be16 eth_type_trans(struct sk_buff *skb, struct net_device *dev)
{
struct ethhdr *eth = (struct ethhdr *) skb->data;
__be16 proto = eth->h_proto;
if (ntohs(proto) >= ETH_P_802_3_MIN) {
/* Ethernet II: EtherType ≥ 1536 */
return proto; /* 0x0800(IPv4), 0x86DD(IPv6), 0x0806(ARP) 등 */
}
/* IEEE 802.3: length 필드 → LLC/SNAP 검사 */
if (skb_at_tc_ingress(skb))
skb->protocol = eth->h_proto;
else if (*(__be16 *)(skb->data) == htons(0xAAAA))
return htons(ETH_P_SNAP); /* LLC/SNAP */
else
return htons(ETH_P_802_2); /* 순수 LLC */
}
IPv6 프로토콜
IPv6 심화
IPv6 헤더 구조
IPv6 헤더는 고정 40바이트로 IPv4보다 단순하지만, 확장 헤더 체인으로 유연성을 제공합니다:
/* include/uapi/linux/ipv6.h */
struct ipv6hdr {
#if defined(__LITTLE_ENDIAN_BITFIELD)
__u8 priority:4, /* Traffic Class 상위 4비트 */
version:4; /* IP 버전 (항상 6) */
#elif defined(__BIG_ENDIAN_BITFIELD)
__u8 version:4,
priority:4;
#endif
__u8 flow_lbl[3]; /* Traffic Class 하위 4비트 + Flow Label 20비트 */
__be16 payload_len; /* 페이로드 길이 (확장 헤더 포함, 기본 헤더 제외) */
__u8 nexthdr; /* 다음 헤더 타입 (6=TCP, 17=UDP, 43=Routing 등) */
__u8 hop_limit; /* IPv4의 TTL에 해당 */
struct in6_addr saddr; /* 소스 주소 (128비트) */
struct in6_addr daddr; /* 목적지 주소 (128비트) */
};
/* 크기: 정확히 40바이트 (IPv4와 달리 가변 길이 아님) */
/* 커널에서 IPv6 헤더 접근 */
struct ipv6hdr *hdr = ipv6_hdr(skb);
pr_info("src=%pI6c dst=%pI6c nexthdr=%u\\n",
&hdr->saddr, &hdr->daddr, hdr->nexthdr);
확장 헤더 체인
IPv6는 옵션을 확장 헤더로 체인 연결합니다. nexthdr 필드가 다음 헤더 타입을 지정합니다:
| 확장 헤더 | Next Header 값 | 용도 | 커널 소스 |
|---|---|---|---|
| Hop-by-Hop Options | 0 | 모든 중간 라우터가 검사 (Router Alert, Jumbogram) | net/ipv6/exthdrs.c |
| Routing | 43 | 소스 라우팅 (Type 0은 보안상 비활성, SRv6는 Type 4) | net/ipv6/exthdrs.c |
| Fragment | 44 | 송신측 단편화 (IPv6는 중간 라우터 단편화 금지) | net/ipv6/reassembly.c |
| Destination Options | 60 | 목적지 노드만 검사하는 옵션 | net/ipv6/exthdrs.c |
| Authentication Header (AH) | 51 | IPSec 인증 | net/ipv6/ah6.c |
| ESP | 50 | IPSec 암호화 | net/ipv6/esp6.c |
| Segment Routing (SRH) | 43 (Type 4) | SRv6 세그먼트 리스트 | net/ipv6/seg6.c |
/* 확장 헤더 순회: 커널 내부 패턴 */
int ipv6_find_tlv(struct sk_buff *skb, int offset, int type)
{
/* nexthdr 체인을 따라가며 특정 TLV 옵션 검색 */
/* 각 확장 헤더: nexthdr(1) + hdrlen(1) + data(가변) */
/* hdrlen 단위: 8바이트 (실제 길이 = (hdrlen+1)*8) */
}
/* ipv6_skip_exthdr: 확장 헤더를 건너뛰고 실제 상위 프로토콜 위치 찾기 */
int nexthdr = ipv6_hdr(skb)->nexthdr;
int offset = sizeof(struct ipv6hdr);
__be16 frag_off;
offset = ipv6_skip_exthdr(skb, offset, &nexthdr, &frag_off);
/* 반환: nexthdr = 실제 프로토콜 (TCP/UDP 등), offset = 페이로드 시작 위치 */
/* Fragment 헤더 구조 */
struct frag_hdr {
__u8 nexthdr; /* 단편화된 원본의 상위 프로토콜 */
__u8 reserved;
__be16 frag_off; /* Offset(13bit) + Res(2bit) + MF(1bit) */
__be32 identification; /* 단편 식별자 (per-destination) */
};
NDP (Neighbor Discovery Protocol)
IPv6에서 ARP를 대체하는 NDP는 ICMPv6 기반으로 주소 해석, 라우터 발견, 주소 자동 설정을 수행합니다:
| ICMPv6 타입 | 메시지 | 용도 | 커널 함수 |
|---|---|---|---|
| 133 | Router Solicitation (RS) | 호스트가 라우터 탐색 | ndisc_send_rs() |
| 134 | Router Advertisement (RA) | 라우터가 프리픽스, MTU, DNS 광고 | ndisc_router_discovery() |
| 135 | Neighbor Solicitation (NS) | 주소 해석 (ARP Request 역할) + DAD | ndisc_send_ns() |
| 136 | Neighbor Advertisement (NA) | 주소 응답 (ARP Reply 역할) | ndisc_send_na() |
| 137 | Redirect | 더 나은 next-hop 알림 | ndisc_redirect_rcv() |
/* net/ipv6/ndisc.c — NDP 핵심 */
/* Neighbor Solicitation 수신 처리 */
static void ndisc_recv_ns(struct sk_buff *skb)
{
struct nd_msg *msg = (struct nd_msg *)skb_transport_header(skb);
struct in6_addr *target = &msg->target;
/* DAD (Duplicate Address Detection) 확인 */
if (ipv6_addr_any(&ipv6_hdr(skb)->saddr)) {
/* 소스 = :: → DAD 요청 (주소 중복 검사) */
/* 같은 주소를 가진 인터페이스가 있으면 DAD 실패 */
}
/* 타겟 주소가 로컬이면 NA(Neighbor Advertisement) 응답 */
if (ipv6_chk_addr(net, target, dev, 0))
ndisc_send_na(dev, &saddr, target, ...);
}
/* SLAAC (Stateless Address AutoConfiguration) */
/* RA에서 prefix 정보를 받아 자동으로 IPv6 주소 생성:
* 주소 = prefix (64bit) + interface ID (64bit, EUI-64 또는 랜덤)
* net.ipv6.conf.*.use_tempaddr = 2 → RFC 4941 Privacy Extension
* → 임시 주소 자동 생성 (기본 24시간 유효)
*/
Flow Label과 ECMP
/* Flow Label: 20비트 (IPv6 헤더 내) */
/* 동일 flow의 패킷을 동일 경로로 라우팅 (ECMP 해시 입력) */
/* 커널 자동 설정: net.ipv6.flowlabel_state_ranges */
/* TCP: 연결별 고유 flow label 자동 할당 (auto_flowlabels) */
/* sysctl: net.ipv6.auto_flowlabels = 1 (기본 활성) */
/* 소켓에서 flow label 명시적 설정 */
struct in6_flowlabel_req freq = {
.flr_label = htonl(0x12345),
.flr_action = IPV6_FL_A_GET,
.flr_share = IPV6_FL_S_EXCL,
};
setsockopt(fd, SOL_IPV6, IPV6_FLOWLABEL_MGR, &freq, sizeof(freq));
/* ECMP에서의 활용:
* 라우터가 5-tuple 대신 (src, dst, flow_label)로 해시
* → UDP 멀티플렉싱 환경에서도 안정적 경로 고정
* → 특히 QUIC처럼 하나의 UDP 포트에 다수 연결 시 효과적
*/
IPv4/IPv6 비교 및 듀얼 스택
헤더 구조 비교
| 구분 | IPv4 | IPv6 |
|---|---|---|
| 헤더 크기 | 20바이트 (옵션 제외) | 40바이트 (고정) |
| 주소 길이 | 32비트 | 128비트 |
| 체크섬 | 헤더 체크섬 포함 | 헤더 체크섬 없음 (L2/L4에 위임) |
| 단편화 | 송신·중간 라우터 모두 가능 | 송신측만 가능 (중간 라우터 불가) |
| 옵션 | 헤더 내 가변 길이 | 확장 헤더 체인 |
| TTL/Hop Limit | TTL (8비트) | Hop Limit (8비트) |
| QoS | ToS/DSCP | Traffic Class + Flow Label |
주소 체계 비교
| 구분 | IPv4 | IPv6 |
|---|---|---|
| 주소 공간 | 약 43억 개 | 약 3.4×1038 개 |
| 표기법 | 점-십진 (192.168.1.1) | 콜론-16진 (2001:db8::1) |
| 브로드캐스트 | 있음 (255.255.255.255) | 없음 (멀티캐스트 사용) |
| 주소 자동 설정 | DHCP | SLAAC (+ DHCPv6) |
| 로컬 링크 | 169.254.0.0/16 (APIPA) | fe80::/10 (Link-Local) |
| 루프백 | 127.0.0.1/8 | ::1/128 |
프로토콜 특징 비교
| 구분 | IPv4 | IPv6 |
|---|---|---|
| 주소 해석 | ARP (브로드캐스트) | NDP (멀티캐스트, ICMPv6) |
| 라우터 발견 | ICMP Router Discovery | NDP Router Advertisement |
| 경로 최적화 | ICMP Redirect | NDP Redirect |
| 주소 중복 검사 | 없음 (ARP probe 비표준) | DAD (Duplicate Address Detection) |
| IPsec | 선택 사항 | 규격에서는 필수로 정의된 시기가 있었으나, 실제 구현에서는 선택적으로 제공되는 경우가 많음 |
| 모바일 IP | MIPv4 | MIPv6 (더 효율적) |
듀얼 스택 운영
| sysctl | 기본값 | 설명 |
|---|---|---|
net.ipv6.conf.all.disable_ipv6 |
0 | IPv6 전체 비활성화 (1로 설정 시) |
net.ipv6.bindv6only |
0 | 0: IPv6 소켓이 IPv4도 수신 (mapped address). 1: IPv6 전용 |
net.ipv6.conf.*.accept_ra |
1 | Router Advertisement 수락 (2: forwarding 활성 시에도 수락) |
net.ipv6.conf.*.autoconf |
1 | SLAAC 주소 자동 설정 |
net.ipv6.conf.*.use_tempaddr |
0 | Privacy Extension (2: 임시 주소 선호) |
net.ipv6.conf.*.dad_transmits |
1 | DAD NS 전송 횟수 (0: DAD 비활성) |
/* IPv4-mapped IPv6 주소: ::ffff:a.b.c.d */
/* 듀얼 스택 소켓이 IPv4 패킷을 수신하면:
* 커널이 IPv4 주소를 mapped 형태로 변환하여 IPv6 소켓에 전달
* → 애플리케이션은 하나의 IPv6 소켓으로 양쪽 모두 처리 가능
*
* IPV6_V6ONLY 소켓 옵션:
* setsockopt(fd, SOL_IPV6, IPV6_V6ONLY, &on, sizeof(on));
* → IPv6 전용으로 제한 (mapped address 거부)
*/
/* 커널 내부: IPv4/IPv6 프로토콜 핸들러 등록 */
static struct inet_protosw tcpv6_protosw = {
.type = SOCK_STREAM,
.protocol = IPPROTO_TCP,
.prot = &tcpv6_prot, /* IPv6 TCP 핸들러 */
.ops = &inet6_stream_ops,
};
/* tcp_v6_rcv()에서 IPv4-mapped 주소 패킷도 처리:
* ipv6_addr_v4mapped(&hdr->daddr) → tcp_v4_rcv()로 fallback
*/
관련 문서
IP 프로토콜과 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.