SW 개발

[Linux Device Driver] kernel 2.4 리눅스 네트워크 드라이버

. . . 2010. 8. 10. 17:11
반응형
  • 출처 : 마소
  • 기타사항 : 마소의 꽤 오래전 자료입니다. 해당 기사를 백업할 목적으로 포스팅합니다. 그리고 보기좋게 약간 손봤습니다.혹시나 문제가된다면 자삭하겠습니다..

네트워크 서브 시스템은 리눅스가 지금처럼 널리 확산되는데 많은 공헌을 했으며, 리눅스의 최대 장점 중의 하나로 인식되고 있는 분야이다. 이처럼 중요한 위치를 차지하고 있음에도 지금껏 리눅스 커널의 네트워크 서브 시스템의 구조를 분석하고 이해하려는 시도가 많이 부족한 것이 사실이다.

이번 글에서는 리눅스의 최대 장점 중 하나로 꼽히는 네트워킹 부분에 대한 구현을 살펴보겠다. 네트워크 코드는 너무나 방대한 영역이기 때문에 한 번에 살펴보는 것이 불가능하므로 아주 단순한 소켓 프로그램을 예제로 하여 기본적인 소켓의 생성, 연결, 데이터 전송/수신 과정에 대해 살펴보기로 한다. 네트워크는 또한 보안에 민감한 영역이기 때문에 곳곳에 보안을 위한 코드들이 포함되어 있음을 확인할 수 있을 것이다(가장 최신 버전의 안정 커널인 2.6.10에 대해 살펴본다).

자료구조

소켓 버퍼 - sk_buff 구조체

소 켓 버퍼는 네트워크로 전송되는 패킷을 나타내는 자료 구조로서, 네트워크 서브 시스템 전반에서 사용되는 중요한 구조체이다. 소켓 버퍼를 정의한 sk_buff 구조체는 <include/linux/skbuff.h>에 정의되어 있다.

next, prev, list는 소켓 버퍼를 관리하기 위한 포인터이다. 소켓 버퍼를 저장하는 큐는 sk_buff_head 구조체의 형태로 각 소켓 버퍼를 이중 연결 리스트로 관리한다. sk는 소켓 버퍼가 속한 소켓을 나타내며, stamp는 패킷을 받은 시간을 저장한다. net_device 구조체의 dev, input_dev, real_dev 필드는 현재 패킷을 받거나 보내기 위한 네트워크 장치를 가리키는 변수이다. 다음으로 나오는 3개의 union 필드들은 각각 OSI 7 계층의 전송 계층(transport layer), 네트워크 계층(network layer), 데이터 링크 계층(data link layer)의 헤더 정보를 저장한다.

이들 헤더 정보들은 데이터 영역 내에 순서대로 저장되어 있으며, 각각의 계층을 지나면서 해당 계층의 프로토콜에 맞는 헤더 정보를 적절히 설정한다. dst 필드는 패킷을 전송하기 위한 정보를 저장하는 구조체이다. cb는 각 프로토콜에서 사용되는 제어 정보들을 저장하는 역할을 하는 버퍼이다(control buffer). truesize 필드는 sk_buff 구조체 자체의 크기에 데이터 영역의 크기를 더한 실제 소켓 버퍼 구조체의 크기를 나타낸다.

소켓 버퍼 내의 데이터에 접근하기 위한 필드로 head, data, tail, end가 있다. 이 중 head와 end는 처음에 할당한 데이터 영역의 시작과 끝을 가리키는 고정된 필드이다. data와 tail은 그 중에서 실제로 데이터가 저장된 영역의 시작과 끝을 가리키는 필드로 소켓 버퍼로 데이터가 추가될 때마다 변경된다. 소켓 버퍼의 내용은 실제 데이터 앞에 각 계층 별로 헤더 정보가 추가되는 형태이므로 데이터 영역의 처음부터 사용할 수 없기 때문에 이러한 필드를 이용하여 쉽게 접근할 수 있도록 한다.

그리고 실제 구조체에는 포함되어 있지 않지만 소켓 버퍼의 데이터를 관리하기 위해 데이터 영역의 뒷부분에 추가적으로 struct skb_shared_info 구조체가 사용된다. 이 구조체는 데이터 영역을 참조하고 있는 소켓 버퍼의 수, fragment를 이루는 소켓 버퍼 정보 등을 포함한다. 소켓 버퍼의 대략적인 형태는 <그림 1>과 같이 나타낼 수 있다.

<그림 1> 소켓 버퍼 struct sk_buff

소 켓 버퍼를 다루기 위한 여러 함수들이 존재한다. 먼저 소켓 버퍼를 할당하기 위해 alloc_skb 함수가 사용된다. 이 함수는 주어진 크기만큼의 데이터 영역을 가지는 소켓 버퍼를 생성한다. 또한 디바이스 드라이버에서 패킷을 수신했을 때 소켓 버퍼를 생성하기 위해 사용하는 dev_alloc_skb 함수가 있다. 이 함수는 헤더 정보를 포함하기 위해 주어진 크기보다 16바이트 만큼을 더하여 소켓 버퍼를 생성하고, skb_reserve 함수로 16바이트 만큼을 예약해 둔다. 생성된 소켓 버퍼는 kfree_skb 함수를 통해 해제된다.

또한 소켓 버퍼를 복사하기 위한 skb_copy(데이터 영역도 복사), skb_clone(데이터 영역 공유) 함수와 데이터 영역을 가리키는 포인터를 조작하기 위한 skb_put, skb_push, skb_pull 등의 함수가 있다. 그리고 소켓 버퍼를 큐에 넣거나 빼는 일을 수행하는 skb_queue_tail, skb_dequeue, skb_insert, skb_append, skb_unlink 등의 함수도 제공한다.

네트워크 장치 - net_device 구조체

net_device 구조체는 리눅스 커널 내에서 네트워크 장치를 표현하기 위해 사용하는 구조체이다. 네트워크 장치는 일반 블럭 장치나 문자 장치와는 달리 /dev 디렉토리 내에 특정한 장치 파일을 가지지 않으며, 단순한 read/write 연산만으로는 접근할 수 없으므로 일반 장치와는 달리 취급된다. net_device 구조체는 I/O 연산에 필요한 하드웨어 정보 뿐 아니라 이를 관리하기 위한 고수준의 자료 구조 및 함수에 대한 정보를 포함하는 거대한 구조체로 네트워크 서브 시스템 전반에 걸쳐 사용된다. net_device 구조체는 <include/linux/netdevice.h>에 정의되어 있다.

먼저 net_device 구조체의 앞쪽에 나오는 하드웨어 정보를 살펴보기로 한다. name은 네트워크 장치가 가질 이름을 저장한다. 이더넷 장치의 이름은 특별히 지정하지 않는 한 <net/ethernet/eth.c>에 정의된 alloc_etherdev() 함수에 의해 eth0부터 차례로 부여된다. 다음으로 장치가 사용할 공유 메모리 영역, I/O 기본 주소, 인터럽트 번호 등의 정보를 저장한 후 특정 하드웨어 요구하는 포트 번호와 DMA 채널 번호를 각각 저장한다.

state 필드는 장치의 상태를 나타내는 것으로 장치가 open되어 동작할 준비가 된 경우에는 __LINK_STATE_START 값이 설정되고, 장치의 버퍼가 가득차서 더 이상 패킷을 처리할 수 없는 경우 __LINK_STATE_XOFF 값으로 설정된다. <include/linux/net_device.h>에 정의된 netif_running()와 netif_queue_stopped() 함수는 각각 이 state 필드를 검사하여 적절한 값을 리턴한다. 가능한 모든 상태의 목록은 역시 <include/linux/net_device.h>에 enum netdev_state_t로 정의되어 있다.

ifindex 필드는 장치의 이름과 마찬가지로 해당 장치를 나타내는 역할을 하는 정수 값으로 dev_new_index() 함수에 의해 부여된다. 이후 dev_get_by_index(), dev_get_by_name() 등의 함수로 장치의 레퍼런스를 얻어오는 것이 가능하다. iflink 필드는 패킷을 전송할 네트워크 장치의 인덱스를 저장하는 변수로 기본적으로 ifindex 필드와 같은 값을 가지지만 터널링 장치와 같은 경우에는 실제로 패킷을 전송할 다른 장치의 인덱스 값을 가지게 된다. get_stats 필드는 장치의 통계 정보(struct net_device_stats)를 얻기 위한 함수의 포인터를 저장한다.

mtu 필드는 장치가 전송할 수 있는 최대 패킷 크기(MTU : Maximum Transfer Unit) 정보를 저장하며, type 필드는 하드웨어의 종류(Ethernet, APPLEtalk, ATM, IrDA 등)에 대한 정보를 저장한다. hard_header_len 필드는 데이터 링크 계층에서 필요한 헤더 정보의 길이를 나타내며, priv 필드는 하드웨어의 종류에 따라 특정한 정보를 저장할 목적으로 사용된다. <net/ethernet/eth.c>에 정의된 ether_setup() 함수에서 이더넷 하드웨어 장치에 대한 설정을 실행한다. 다음으로는 장치의 하드웨어 주소와 브로드 캐스트 용 주소, 하드웨어 주소의 길이 등을 저장한다.

// ...

/* Protocol specific pointers */ 

void    *atalk_ptr;    /* AppleTalk link */ 
void    *ip_ptr;    /* IPv4 specific data */ 
void    *dn_ptr;    /* DECnet specific data */ 
void    *ip6_ptr;    /* IPv6 specific data */ 
void    *ec_ptr;    /* Econet specific data */ 
void    *ax25_ptr;    /* AX.25 specific data */ 

struct list_head    poll_list;    /* Link to poll list    */ 
int    quota; 
int    weight; 

struct Qdisc    *qdisc; 
struct Qdisc    *qdisc_sleeping; 
struct Qdisc    *qdisc_ingress; 
struct list_head    qdisc_list; 
unsigned long    tx_queue_len; /* Max frames per queue allowed */ 

// ... 

/* Pointers to interface service routines.    */ 
int    (*open)(struct net_device *dev); 
int    (*stop)(struct net_device *dev); 
int    (*hard_start_xmit) (struct sk_buff *skb, 
                 struct net_device *dev); 
//... 
}

다음은 디바이스 드라이버 영역에서 사용될 고수준의 정보들이다. 먼저 상위의 프로토콜에 따른 정보들을 저장하기 위한 포인터 변수들을 각각 유지한다.

qdisc 필드는 장치에서 패킷 정보를 저장할 큐에 대한 정보를 나타낸다. 패킷을 전송하는 경우 장치가 큐를 지원한다면 장치의 qdisc가 가리키는 큐에 소켓 버퍼 데이터를 저장해 두었다가 나중에 처리하고 그렇지 않다면(루프백 장치나 IP 터널링 같은 소프트웨어적인 장치의 경우) 바로 전송한다. tx_queue_len 필드는 큐에 저장될 수 있는 최대 소켓 버퍼의 수를 나타낸다. 그리고 상위 계층에서 장치에 대한 연산을 수행하기 위해 호출되는 함수들의 포인터를 저장한다.

간단한 소켓 프로그래밍 예제

다음은 단순한 에코 클라이언트 프로그램으로 W. Richard Stevens의 『Unix Network Programming』이라는 책의 1장에 나오는 예제를 약간 수정한 것이다. 간략한 설명을 위해 대부분의 에러 처리 부분은 생략했고 write 부분을 추가했다. 이 프로그램을 실행시킨다면 서버에 "hello"라는 문자열을 전송한 뒤 똑같이 "hello"라는 문자열을 서버로부터 받게 될 것이다. 다음의 예제에서 주의 깊게 봐야 할 함수는 socket, connect, write, read의 네 가지이다. 이들 각각에 대해 커널 내부에서 어떤 일이 일어나는지 살펴보자.

#include "unp.h" 

int main(int argc, char **argv) 
{ 
  int sockfd; 
  char line[MAXLINE + 1]; 
  struct sockaddr_in servaddr; 

  if (argc != 2) 
    err_quit("usage: a.out <IPaddress>"); 

  sockfd = socket(AF_INET, SOCK_STREAM, 0); 

  bzero(&servaddr, sizeof(servaddr)); 
  servaddr.sin_family = AF_INET; 
  servaddr.sin_port = ntons(7); 
  inet_pton(AF_INET, argv[1], &servaddr,sin_addr); 

  connect(sockfd, (SA *) &servadr, sizeof(servaddr)); 

  write(sockfd, "hello", 5); 
  read(sockfd, recvline, MAXLINE); 
  recvline[n] = 0; /* null terminate */ 
  fputs(recvline, stdout); 

  exit(0); 
} 

소켓의 생성과 연결

socket() 시스템 콜

먼저 소켓의 생성을 위해 socket 시스템 콜을 호출하면 리눅스 시스템 콜의 처리 방식에 따라 대응하는 커널 처리 루틴인 sys_socket() 함수가 호출된다. 이 함수는 에 정의되어 있으며 sock_create() 함수를 이용하여 소켓 구조체를 생성하고 이를 sock_map_fd() 함수를 이용해 파일 디스크립터에 연결한 뒤 이 값을 리턴한다. sock_create() 함수는 바로 __sock_create() 함수를 호출하며 이 함수가 실제 소켓을 생성하는 일을 수행한다.

static int __sock_create(int family, int type, int protocol, struct socket **res, int kern) 
{ 
  int i; 
  int err; 
  struct socket *sock; 

  if (family < 0 || family >= NPROTO) 
    return -EAFNOSUPPORT; 
  if (type < 0 || type >= SOCK_MAX) 
    return -EINVAL; 

  if (family == PF_INET && type == SOCK_PACKET) { 
    static int warned; 
    if (!warned) { 
        warned = 1; 
        printk(KERN_INFO "%s uses obsolete (PF_INET,SOCK_PACKET)\n", current->comm); 
    } 
    family = PF_PACKET; 
  } 

  err = security_socket_create(family, type, protocol, kern); 
  if (err) 
    return err; 

#if defined(CONFIG_KMOD) 
  if (net_families[family]==NULL) 
  { 
    request_module("net-pf-%d",family); 
  } 
#endif 

  net_family_read_lock(); 
  if (net_families[family] == NULL) { 
    i = -EAFNOSUPPORT; 
    goto out; 
  } 

먼저 인자로 주어진 family와 type 변수가 올바른 값인지를 검사한다. 앞의 예제의 경우라면 PF_INET과 SOCK_STREAM이 넘어오게 된다. PF_INET의 PF는 ‘Protocol Family’를 의미하며 AF(Address Family)에 해당하는 값과 동일하다. 리눅스에서 지원하는 프토토콜의 목록은 에 정의되어 있다. 그리고 호환성을 위해 PF_INET에 대하여 SOCK_PACKET 타입을 명시한 경우 family 값을 PF_PACKET으로 수정한다. 그리고 security_socket_create() 함수를 먼저 호출하여 소켓을 생성하기 위한 보안 사항을 점검한다.

이 를 위해 security_operations 구조체의 socket_create 필드가 가리키는 함수가 호출된다. 별도의 security_operations 구조체가 등록되지 않았다면 이 함수의 기본 값은 dummy_socket_create() 함수로 단순히 0을 리턴한다. 이후에 net_families 변수가 저장하고 있는 등록된 프로토콜의 배열에서 주어진 family가 존재하는지 검사한다. 만약 커널 모듈을 지원하는 경우라면(존재하지 않는 경우) request_module() 함수를 이용하여 모듈을 요청하고 net_family 구조체를 읽기 위한 락을 획득한다.

if (!(sock = sock_alloc())) 
   { 
      printk(KERN_WARNING "socket: no more sockets\n"); 
      i = -ENFILE;   /* Not exactly a match, but its the 
         closest posix thing */ 
      goto out; 
   } 

    sock->type = type; 

   i = -EAFNOSUPPORT; 
   if (!try_module_get(net_families[family]->owner)) 
      goto out_release; 

   if ((i = net_families[family]->create(sock, protocol)) < 0) 
      goto out_module_put; 

   if (!try_module_get(sock->ops->owner)) { 
      sock->ops = NULL; 
      goto out_module_put; 
   } 

   module_put(net_families[family]->owner); 
   *res = sock; 
   security_socket_post_create(sock, family, type, protocol, kern); 

out: 
   net_family_read_unlock(); 
   return i; 
out_module_put: 
   module_put(net_families[family]->owner); 
out_release: 
   sock_release(sock); 
   goto out; 
} 

그리고는 sock_alloc() 함수를 이용하여 BSD 소켓 구조체(struct socket)를 생성한다. 생성된 소켓의 타입에 인자로 주어진 type 변수를 설정하고 try_module_get() 함수를 이용하여 family 인자가 가리키는 프로토콜이 모듈로 구현된 경우 사용 카운터를 증가시킨다. 다음으로 주어진 프로토콜(family)에 맞는 net_families 구조체의 멤버인 create() 함수를 호출하여 커널에서 사용할 소켓 구조체(struct sock)를 생성한다.

앞의 경우 inet_family_ops 구조체의 create() 함수인 inet_create() 함수가 호출된다. 커널에서는 이렇게 생성된 INET 소켓(struct sock)을 사용하여 필요한 작업을 처리하지만 사용자 레벨에서는 BSD 소켓(struct socket) 인터페이스를 사용하여 프로그래밍이 이루어진다. 그리고는 try_module_get() 함수를 이용하여 소켓 관련 연산자 구조체가 존재하는지 검사한 뒤 res 변수에 생성된 소켓의 포인터를 저장한다. 마지막으로 security_socket_post_create() 함수를 호출하여 보안 사항을 점검한 뒤 net_family 구조체에 대한 락을 해제하고 리턴한다.

connect() 시스템 콜

connect() 부분은 소켓을 통해 통신할 상대방 측과의 신뢰성 있는 연결을 확립하는 과정이다. 먼저 conect()를 호출한 측(클라이언트)에서 연결을 요청하기 위해 SYN이라는 형태의 패킷을 상대방(서버)에게 보낸다. SYN 패킷을 받은 서버는 이에 대한 확인을 위해 SYN-ACK 패킷을 보내고, 마지막으로 클라이언트가 이에 대한 응답으로 ACK 패킷을 서버에게 보냄으로써 연결이 성립되는 형태이다. 이렇게 연결 요청시 총 3단계로 패킷을 주고받기 때문에 3-way handshake라고 한다.

<그림 2> 3-way handshake in TCP

connect() 시스템 콜도 마찬가지로 sys_connect() 함수에서 처리된다. 먼저 인자로 주어진 파일 디스크립터를 통해 해당 BSD 소켓의 정보를 얻어온 후 소켓에 연관된 연산자 구조체의 connect() 함수를 호출한다. socket() 시스템 콜을 호출할 때 PF_INET, SOCK_STREAM으로 설정했으므로 이 과정에서 에 정의된 inet_stream_ops 구조체의 inet_stream_connect() 함수가 호출된다. 이 함수는 BSD 소켓 구조체의 state 필드를 검사하여 아직 connect가 호출되지 않은 SS_UNCONNECTED 상태라면 해당 소켓에 연관된 프로토콜의 connect() 함수를 다시 호출하고 타임아웃에 관련된 처리를 한 후 state를 SS_CONNECTED 상태로 변경한다.

TCP 프로토콜에서 처리하는 connect 함수는 tcp_prot 구조체의 tcp_v4_connect()이다. 이 함수는 에 정의되어 있으며, 먼저 connect() 시스템 콜의 인자로 주어진 소켓 주소에 대해 ip_route_connect() 함수를 호출하여 라우팅 테이블을 검색하고 패킷을 전송할 목적지 정보를 얻어온다. 이렇게 얻어온 정보를 이용하여 INET 소켓의 정보를 적절히 설정하고 sk_state 필드를 TCP_SYN_SENT 상태로 변경한다.

그리고는 tcp_v4_hash_connect() 함수를 호출하여 클라이언트 측의 포트를 자동으로 할당한다. 이 값은 sysctl_local_port_range[0], sysctl_local_port_range1 사이의 값으로 할당 가능하며, 이전에 할당된 값이 tcp_port_rover 변수에 저장되어 있으므로(초기 값은 1023) 이 값보다 1만큼 더 큰 값에서부터 검색을 시작한다. 이렇게 포트가 할당되면 ip_route_newports() 함수를 다시 호출하여 새로 할당된 포트에 대해 라우팅 테이블의 변경 사항이 있는지 다시 검색한다. 그리고 마지막으로 tcp_connect() 함수를 호출하여 SYN 패킷을 위한 소켓 버퍼를 생성하고 tcp_transmit_skb() 함수를 통해 전송한다.

그리고 이후에는 서버 측에서 SYN-ACK 패킷이 도착하기를 기다리게 된다. SYN-ACK 패킷을 수신했다면 tcp_rcv_state_process()에 의해 tcp_rcv_synsent_state_process() 함수가 호출된다. 한 번에 전송할 수 있는 패킷의 최대 크기인 MSS(Maximum Segment Size) 값을 동기화하고 sk_state 필드를 TCP_ESTABLISHED 상태로 변경한 후 ACK 패킷을 서버로 전송한다.

패킷의 전송

응용 계층 - Echo client

응용프로그램에서 네트워크로 데이터를 전송하기 위해서는 생성된 소켓에 write, send, sendto, sendmsg 등의 시스템 콜을 사용할 수 있다. 여기서는 가장 일반적인 형태인 write 연산에 대해 살펴보도록 하겠다. write 시스템 콜을 호출하면 커널의 sys_write() 함수가 호출된다. 이 함수는 리눅스의 VFS(Virtual File System) 형식을 따라 주어진 파일에 맞는 연산을 처리할 수 있도록 vfs_write() 함수를 호출하며 결국 file 구조체의 f_op 연산자 구조체에서 write 필드가 가리키는 함수를 호출한다. 다음은 에 정의된 소켓에 대한 f_op 연산자 구조체이다.

static struct file_operations socket_file_ops = { 
  .owner =    THIS_MODULE, 
  .llseek =    no_llseek, 
  .aio_read =    sock_aio_read, 
  .aio_write =    sock_aio_write, 
  .poll =    sock_poll, 
  .ioctl =     sock_ioctl, 
  .mmap =    sock_mmap, 
  .open =    sock_no_open,  /* special open code to disallow open via /proc */ 
  .release =    sock_close, 
  .fasync =    sock_fasync, 
  .readv =    sock_readv, 
  .writev =    sock_writev, 
  .sendpage =    sock_sendpage 
}; 

여 기서 볼 수 있듯이 socket_file_ops 구조체에서는 write 연산을 정의하지 않았다. 이 경우 vfs_write() 함수는 do_sync_write() 함수에 의해 aio_write() 함수를 호출하도록 되어 있으므로 결국 sock_aio_write() 함수가 호출된다. sock_aio_write() 함수는 적절한 인자를 설정한 후 __sock_sendmsg() 함수를 호출하게 된다. 앞에서는 write 시스템 콜에 관해 살펴봤지만 writev, send, sendto, sendmsg 시스템 콜을 호출한 경우에도 결과적으로는 sock_sendmsg() 함수가 호출되고, 이 함수는 다시 __sock_sendmsg() 함수를 호출하기 때문에 이후의 과정은 모두 동일하게 처리된다.

static inline int __sock_sendmsg(struct kiocb *iocb, struct socket *sock, 
        struct msghdr *msg, size_t size) 
{ 
    struct sock_iocb *si = kiocb_to_siocb(iocb); 
    int err; 

    si->sock = sock; 
    si->scm = NULL; 
    si->msg = msg; 
    si->size = size; 

    err = security_socket_sendmsg(sock, msg, size); 
    if (err) 
        return err; 

    return sock->ops->sendmsg(iocb, sock, msg, size); 
} 

__sock_sendmsg() 함수가 호출된 시점에서 msg 인자의 msg_iov 필드가 가리키는 iovec 구조체의 iov_base는 write() 시스템 콜이 호출될 때 주어진 사용자 공간의 데이터인 "hello"를 가리키며 size 인자는 5가 된다. 이 함수는 소켓 I/O 연산에 필요한 sock_iocb 구조체를 적절히 설정한 뒤 security_socket_sendmsg() 함수를 호출하여 보안 사항을 점검한다. 이후에 BSD 소켓 구조체의 ops 연산자 구조체에 있는 sendmsg 필드에 저장된 함수를 호출한다.

이 와 같이 PF_INET으로 소켓을 생성한 경우 inet_stream_ops 구조체의 inet_sendmsg() 함수가 호출된다. inet_sendmsg() 함수는 주어진 소켓에 대한 INET 소켓의 정보를 얻어온 후 sk_prot 구조체의 sendmsg 필드가 가리키는 함수를 주어진 인자와 함께 호출한다(여기서 BSD 소켓이 INET 소켓으로 바뀌어 넘겨진다). 우리는 SOCK_STREAM 인자를 주어 소켓을 생성했기 때문에 이 과정에서 최종적으로 TCP 프로토콜의 sendmsg 처리 함수인 tcp_sendmsg() 함수가 호출된다.

전송 계층 - TCP

tcp_sendmsg() 함수는 <net/ipv4/tcp.c>에 정의되어 있다. 먼저 소켓에 대한 락을 획득하고 msg에 대한 플래그가 있다면 설정한다. TCP_CHECK_TIMER() 매크로는 현재 아무런 작업도 수행하지 않는다. sock_sndtimeo() 함수는 패킷 전송시 대기할 시간을 MSG_DONTWAIT 플래그가 설정된 경우 0으로 그렇지 않다면 sk_sndtimeo 값으로 설정한다.

sk_sndtimeo 값은 setsockopt() 시스템 콜을 통해 특별히 지정하지 않았으므로 MAX_SCHEDULE_TIMEOUT (= LONG_MAX) 값을 가지며 실제적으로 거의 무한정 기다리게 된다. 그리고 현재 소켓의 상태가 연결이 확립된 상태(TCPF_ESTABLISHED)가 아니라면 sk_stream_wait_connect() 함수를 통해 timeo 시간 동안 연결이 확립되기를 기다린다. 그런 다음 tcp_current_mss() 함수를 호출하여 패킷 헤더 부분을 제외한 실제 데이터 영역의 크기를 계산한다.

이제 실제 데이터 전송에 필요한 정보를 설정하는데 "hello"라는 문자열 하나의 데이터만을 가지고 있으므로 iovlen = 1, iov->iov_base = "hello", iov->iov_len = 5로 설정되어 있을 것이다. copied는 실제 전송된 데이터의 양을 나타내는 변수로 처음에는 0으로 설정한다. 그리고 현재까지 실행되는 동안 에러가 발생됐는지 소켓이 닫혔는지를 검사하여 이 경우 적절한 처리를 하고 전송을 종료한다.

  while (--iovlen >= 0) { 
  int seglen = iov->iov_len; 
  unsigned char __user *from = iov->iov_base; 

  iov++; 

  while (seglen > 0) { 
    int copy; 

    skb = sk->sk_write_queue.prev; 

    if (!sk->sk_send_head || 
      (copy = mss_now - skb->len) <= 0) { 

new_segment: 
      /* Allocate new segment. If the interface is SG, 
      * allocate skb fitting to single page. 
      */ 
      if (!sk_stream_memory_free(sk)) 
        goto wait_for_sndbuf; 

      skb = sk_stream_alloc_pskb(sk, select_size(sk, tp), 
        0, sk->sk_allocation); 
      if (!skb) 
        goto wait_for_memory; 

      /* 
      * Check whether we can use HW checksum. 
      */ 
      if (sk->sk_route_caps & 
        (NETIF_F_IP_CSUM | NETIF_F_NO_CSUM | 
        NETIF_F_HW_CSUM)) 
          skb->ip_summed = CHECKSUM_HW; 

      skb_entail(sk, tp, skb); 
      copy = mss_now; 
  } 

전송은 각각의 iov에 대하여 일어나므로 우리의 경우는 한번만 처리될 것이다. 현재 iov에 대하여 데이터(세그먼트)의 길이와 포인터를 각각 seglen, from 변수에 저장한 후에 iov 포인터를 증가시킨다. seglen=5이므로 while문 안으로 들어와서 skb 포인터를 소켓의 전송 큐 내에 있는 마지막 소켓 버퍼를 가리키도록 설정한다(아직은 아무런 소켓 버퍼도 들어있지 않다). 소켓이 최초에 생성되면 sk_send_head 필드가 NULL로 설정되므로 if문 안쪽의 new_segment 부분으로 들어가서 새로운 소켓 버퍼를 생성한다.

sk_stream_memory_free() 함수로 현재 소켓의 전송 버퍼(sndbuf)에 공간이 남아있는지 검사한 후 sk_stream_alloc_pskb() 함수를 이용하여 소켓 버퍼를 할당한다. 그리고 네트워크 장치에서 하드웨어 적으로 체크섬을 지원하는지를 검사하여 이 경우 하드웨어에서 처리할 수 있도록 skb->ip_summed 필드를 CHECKSUM_HW 로 표시한다. 이렇게 생성된 소켓 버퍼는 skb_entail() 함수를 이용하여 전송 일련번호를 설정한 후에 소켓 구조체의 전송 큐에 넣어진다.

 /* Where to copy to? */ 
  if (skb_tailroom(skb) > 0) { 
    if (copy > skb_tailroom(skb)) 
      copy = skb_tailroom(skb); 
    if ((err = skb_add_data(skb, from, copy)) != 0) 
      goto do_fault; 
  } else { 
    int merge = 0; 
    int i = skb_shinfo(skb)->nr_frags; 
    struct page *page = TCP_PAGE(sk); 
    int off = TCP_OFF(sk); 

    if (skb_can_coalesce(skb, i, page, off) && 
      off != PAGE_SIZE) { 
      merge = 1; 
    } else if (i == MAX_SKB_FRAGS || 
      (!i && 
        !(sk->sk_route_caps & NETIF_F_SG))) { 
    tcp_mark_push(tp, skb); 
      goto new_segment; 
    } else if (page) { 
      off = (off + L1_CACHE_BYTES - 1) & 
      ~(L1_CACHE_BYTES - 1); 
    if (off == PAGE_SIZE) { 
      put_page(page); 
    TCP_PAGE(sk) = page = NULL; 
       } 
    } 

    if (!page) { 
      /* Allocate new cache page. */ 
      if (!(page = sk_stream_alloc_page(sk))) 
      goto wait_for_memory; 
      off = 0; 
    } 

    if (copy > PAGE_SIZE - off) 
      copy = PAGE_SIZE - off; 

    err = skb_copy_to_page(sk, from, skb, page, 
        off, copy); 
    if (err) { 
      if (!TCP_PAGE(sk)) { 
      TCP_PAGE(sk) = page; 
      TCP_OFF(sk) = 0; 
      } 
      goto do_error; 
    } 

    /* Update the skb. */ 
    if (merge) { 
      skb_shinfo(skb)->frags[i - 1].size += 
        copy; 
    } else { 
      skb_fill_page_desc(skb, i, page, off, copy); 
      if (TCP_PAGE(sk)) { 
      get_page(page); 
      } else if (off + copy < PAGE_SIZE) { 
      get_page(page); 
      TCP_PAGE(sk) = page; 
      } 
    } 

    TCP_OFF(sk) = off + copy; 
  } 

다음으로 소켓 버퍼의 공간(tailroom)이 남아있다면 이 공간에 skb_add_data() 함수를 이용하여 데이터를 복사한다. 남은 공간이 없다면 skb_can_coalesce() 함수를 호출하여 소켓의 전송 메시지를 위한 페이지 내에 복사할 수 있는지 검사하고, 그렇지 않고 네트워크 장치가 Scatter-Gather I/O를 지원하지 않거나 이미 MAX_SKB_FRAGS 만큼의 단편화(fragmentation)가 이뤄졌다면 new_segment 부분으로 돌아가서 새로운 소켓 버퍼를 생성한다. 만일 이미 페이지가 꽉 차 있다면 페이지를 해제하고 새로운 페이지를 할당받아 skb_copy_to_page() 함수를 이용하여 페이지에 데이터를 복사한다. 그리고 데이터가 복사된 정보를 소켓 버퍼의 데이터에 해당하는 skb_share_info 구조체에 기록한 후 소켓의 페이지와 오프셋 정보도 갱신한다.

   if (!copied) 
TCP_SKB_CB(skb)->flags &= ~TCPCB_FLAG_PSH; 

    tp->write_seq += copy; 
    TCP_SKB_CB(skb)->end_seq += copy; 
    skb_shinfo(skb)->tso_segs = 0; 

    from += copy; 
    copied += copy; 
    if ((seglen -= copy) == 0 && iovlen == 0) 
        goto out; 

    if (skb->len != mss_now || (flags & MSG_OOB)) 
        continue; 

    if (forced_push(tp)) { 
        tcp_mark_push(tp, skb); 
        __tcp_push_pending_frames(sk, tp, mss_now, TCP_NAGLE_PUSH); 
    } else if (skb == sk->sk_send_head) 
        tcp_push_one(sk, mss_now); 
    continue; 
    ... 
out: 
    if (copied) 
        tcp_push(sk, tp, flags, mss_now, tp->nonagle); 
    TCP_CHECK_TIMER(sk); 
    release_sock(sk); 
    return copied; 

copied 변수가 0이라면 TCP 헤더의 PSH 플래그를 지우고 전송 일련번호를 갱신한 뒤 from과 copied 변수도 복사된 만큼 증가시킨다. 첫 번째 if문의 조건을 만족하므로 out 부분으로 이동한 뒤 tcp_push() 함수를 이용하여 패킷을 전송한다.

tcp_push() 함수는 __tcp_push_pending_frames() 함수를 호출하고 이 함수는 다시 tcp_write_xmit() 함수를 호출한다. tcp_write_xmit() 함수는 소켓 내의 전송될 소켓 버퍼(sk_send_head)에 대해 tcp_snd_test()를 호출하여 해당 소켓 버퍼를 전송할지 큐에 넣을지 결정한 후 tcp_transmit_skb() 함수를 호출한다. 이 함수는 소켓의 TCP 연산을 나타내는 tcp_func 구조체의 queue_xmit 필드가 가리키는 함수를 호출하는 데 이 함수는 IP 계층의 ip_queue_xmit()에 해당한다.

네트워크 계층 - IP

ipqueue_xmit() 함수는 <net/ipv4/ip_output.c>에 정의되어 있다. 이 함수는 크게 두 부분으로 나눌 수 있는데 먼저 앞부분은 커널의 라우팅 테이블을 검색하여 패킷이 전송될 목적지의 주소를 알아내는 일이다. 먼저 해당 소켓으로 이미 다른 패킷을 보내서 목적지에 대한 캐시 데이터를 가지고 있다면 이 과정을 생략한다. 그리고 __sk_dst_check() 함수를 호출하여 만약 목적지에 대한 정보를 가지고 있지 않거나 더 이상 사용할 수 없는 데이터인 경우에는 새로 라우팅 테이블을 검색하도록 한다.

검 색에 필요한 정보는 flowi 구조체에 저장하며 전송할 인터페이스 정보, 출발지와 목적지의 네트워크 주소 및 포트 번호, 프로토콜과 TOS(Type of Service) 정보 등이 저장된다. 이렇게 생성한 정보를 가지고 ip_route_output_flow() 함수를 호출하면 검색 결과가 rtable 구조체에 저장되고 이를 소켓과 소켓 버퍼에 저장한다.

여기까지 왔다면 목적지에 대한 라우팅 정보를 가지고 있는 경우이다. 먼저 Strict Source Routing 옵션이 설정되어 있는 경우 목적지가 정해진 경로와 다르다면 에러로 처리한다. 그리고 나서 IP 헤더 정보를 설정한다. IP 옵션이 주어진 경우에는 ip_options_build() 함수를 이용하여 옵션 정보를 생성한다. 그리고 ip_select_ident_more() 함수를 호출하여 fragment ID를 설정한 뒤 ip_send_check() 함수에서 checksum 값을 계산한다.

마지막으로 소켓 버퍼의 우선순위를 설정한 후 NF_IP_LOCAL_OUT이라는 Netfilter Hook으로 넘겨 패킷을 필터링할지를 검사한 다음에 NF_HOOK() 매크로의 마지막 인자로 주어진 dst_output() 함수를 호출한다. dst_output() 함수는 소켓 버퍼의 목적지 정보를 가지는 dst 구조체의 output 필드가 가리키는 함수를 호출한다. 이것은 ip_route_output_flow() 함수를 처리하는 과정에서 ip_output() 함수로 설정된다.

ip_output() 함수는 우선 패킷의 전송이 요청됐음을 나타내는 통계 정보(IPSTATS_MIB_OUTREQUESTS)를 증가시킨다. 소켓 버퍼의 데이터의 길이를 검사하여 현재 목적지로 보낼 수 있는 최대 전송 크기(MTU: Maximum Transfer Unit)보다 큰 경우에는 ip_fragment() 함수를 호출하여 패킷을 나눠서 보내고, 그렇지 않은 경우에는 ip_finish_output() 함수를 호출하여 그대로 패킷을 전송한다. 우리의 경우 데이터의 길이는 5이므로 ip_finish_output() 함수가 호출될 것이다.

packet_routed: 
  if (opt && opt->is_strictroute && rt->rt_dst != rt->rt_gateway) 
      goto no_route; 

  /* OK, we know where to send it, allocate and build IP header. */ 
  iph = (struct iphdr *) skb_push(skb, sizeof(struct iphdr) + (opt ? opt->optlen : 0)); 
  *((__u16 *)iph) = htons((4 << 12) | (5 << 8) | (inet->tos & 0xff)); 
  iph->tot_len = htons(skb->len); 
  if (ip_dont_fragment(sk, &rt->u.dst) && !ipfragok) 
    iph->frag_off = htons(IP_DF); 
  else 
    iph->frag_off = 0; 
  iph->ttl = ip_select_ttl(inet, &rt->u.dst); 
  iph->protocol = sk->sk_protocol; 
  iph->saddr = rt->rt_src; 
  iph->daddr = rt->rt_dst; 
  skb->nh.iph = iph; 
  /* Transport layer set skb->h.foo itself. */ 

  if (opt && opt->optlen) { 
    iph->ihl += opt->optlen >> 2; 
    ip_options_build(skb, opt, inet->daddr, rt, 0); 
  } 

  ip_select_ident_more(iph, &rt->u.dst, sk, skb_shinfo(skb)->tso_segs); 

  /* Add an IP checksum. */ 
  ip_send_check(iph); 

  skb->priority = sk->sk_priority; 

  return NF_HOOK(PF_INET, NF_IP_LOCAL_OUT, skb, NULL, rt->u.dst.dev, 
      dst_output); 

no_route: 
  IP_INC_STATS(IPSTATS_MIB_OUTNOROUTES); 
  kfree_skb(skb); 
  return -EHOSTUNREACH; 
} 

ip_finish_output() 함수는 소켓 버퍼의 장치 정보와 프로토콜 정보를 설정한 뒤 NF_IP_POST_ROUTING Netfilter Hook을 통해 ip_finish_output2() 함수를 호출한다. ip_finish_output2() 함수는 에 정의되어 있다.

이 함수는 먼저 현재 소켓 버퍼 내에 데이터 링크 계층의 헤더 정보(dev->hard_header)가 들어갈 만한 공간이 있는지 검사하여 없는 경우 skb_realloc_headroom() 함수를 호출하여 공간을 확보한다. 그리고 넷필터 디버깅 옵션이 설정되어 있는 경우 nf_debug_ip_finish_output2() 함수를 호출하여 필요한 메시지를 출력한다.

그리고 목적지에 대한 하드웨어 헤더 캐시 정보를 가지고 있다면 캐시에 포함된 헤더 정보를 소켓 버퍼에 저장하고, 캐시의 hh_output 필드가 가리키는 함수를 호출한다. 캐시 정보를 가지고 있지 않다면 직접 다음 번 전송될 목적지(neighbour)에 대한 output 필드가 가리키는 함수를 호출한다. output 필드는 neigh_resolve_output() 함수를 가리킨다. 이 함수는 내부적으로 neigh_opt 구조체의 queue_xmit 필드가 가리키는 함수를 호출하는데 hh_output 필드가 가리키는 것과 동일하게 dev_queue_xmit() 함수를 가리킨다. 이제 dev_queue_xmit() 함수를 따라 데이터 링크 계층으로 내려가 보자.

데이터 링크 계층 - pci-skeleton

dev_queue_xmit() 함수는 IP 계층에서 처리된 소켓 버퍼를 실제 네트워크 장치에게로 넘겨 전송하는 역할을 하며 에 정의되어 있다.

우 선 소켓 버퍼가 소켓의 데이터 전송용 페이지(sk_sndmsg_page) 내에 fragment로 나눠져 있다. 하지만 전송할 네트워크 장치에서 fragment 혹은 SG(Scatter-Gather) I/O를 지원하지 않거나, 하나 이상의 fragment가 장치에서 DMA로 접근할 수 없는 영역에 있다면 __skb_linearize() 함수를 이용하여 가능한 영역 내의 하나의 데이터로 합친다. 그리고 checksum이 아직 계산되지 않았다면 여기서 skb_checksum_help() 함수를 이용하여 계산하고 local_bh_disable() 매크로를 이용하여 현재 CPU에 대해 softirq를 금지시킨다.

  if (q->enqueue) { 
    spin_lock(&dev->queue_lock); 

    rc = q->enqueue(skb, q); 

    qdisc_run(dev); 

    spin_unlock(&dev->queue_lock); 
    rc = rc == NET_XMIT_BYPASS ? NET_XMIT_SUCCESS : rc; 
    goto out; 
  } 

  if (dev->flags & IFF_UP) { 
    int cpu = smp_processor_id(); /* ok because BHs are off */ 

    if (dev->xmit_lock_owner != cpu) { 

      HARD_TX_LOCK(dev, cpu); 

      if (!netif_queue_stopped(dev)) { 
        if (netdev_nit) 
          dev_queue_xmit_nit(skb, dev); 

        rc = 0; 
        if (!dev->hard_start_xmit(skb, dev)) { 
          HARD_TX_UNLOCK(dev); 
          goto out; 
        } 
      } 
      HARD_TX_UNLOCK(dev); 
      if (net_ratelimit()) 
        printk(KERN_CRIT "Virtual device %s asks to " 
          "queue packet!\n", dev->name); 
    } else { 
      if (net_ratelimit()) 
        printk(KERN_CRIT "Dead loop on virtual device " 
          "%s, fix it urgently!\n", dev->name); 
    } 
  } 

  rc = -ENETDOWN; 
  local_bh_enable(); 

out_kfree_skb: 
  kfree_skb(skb); 
  return rc; 
out: 
  local_bh_enable(); 
  return rc; 
} 

이제 실제로 각 장치에 해당하는 전송 함수를 불러 패킷을 전송할 차례이다. 먼저 해당 장치에서 전송될 데이터를 위한 큐를 지원한다면(q->enqueue), 이 큐에 소켓 버퍼를 집어넣고 qdisc_run() 함수를 이용하여 전송한다. qdisc_run() 함수는 다시 qdisc_restart() 함수를 호출하여 현재 네트워크 장치가 패킷을 전송할 수 있는지 검사하한다(netif_queue_stopped(dev)). 여기서 전송할 수 있다면 dev 구조체의 hard_start_xmit 필드가 가리키는 함수를 호출하여 네트워크 장치에게 넘기고, 그렇지 않다면 다시 큐에 넣고 netif_schedule() 함수를 이용하여 NET_TX_SOFTIRQ를 발생시켜 이후에 전송되도록 한다.

해당 장치에서 큐를 지원하지 않는다면 직접 전송하는데 먼저 현재 장치에 대한 장치에 대한 락을 가지고 있는 CPU를 검사하여 만약 이미 락을 가지고 있는 경우라면 무언가 잘못된 경우이므로 에러로 처리한다. 그렇지 않다면 netif_queue_stopped() 함수를 이용하여 장치가 패킷을 전송할 수 있는지 검사한 후 역시 hard_start_xmit 필드가 가리키는 함수를 호출한다. 이 함수는 각 장치의 드라이버 내에 위치하고 있으며 이에 대한 일반적인 형태로 파일 내의 net_send_packet() 함수 혹은 파일 내의 netdrv_start_xmit() 함수를 참조하기 바란다.

패킷의 수신

이제 네트워크 장치를 통해 받은 패킷이 처리되는 과정에 대해 살펴보자.

데이터 링크 계층 - pci-skeleton

네 트워크 장치가 패킷을 수신하면 인터럽트가 발생한다. 이 인터럽트에 대한 처리는 각 장치에 따라 다르므로 여기서는 파일에서 구현한 PCI 버스를 사용하는 일반적인 네트워크 장치에 대한 부분을 살펴볼 것이다. 먼저 이 장치의 open() 함수에서 다음과 같이 인터럽트를 등록한다.

static int netdrv_open (struct net_device *dev) 
{ 
    ... 

    retval = request_irq (dev->irq, netdrv_interrupt, SA_SHIRQ, dev->name, dev); 
    if (retval) { 
        DPRINTK ("EXIT, returning %d\n", retval); 
        return retval; 
    } 

    ... 
} 

장 치의 irq 번호에 해당하는 인터럽트에 대해 netdrv_interrupt() 함수를 등록한다. 이 함수는 장치의 상태 레지스터의 값을 읽어 전송(TX)과 수신(RX)에 해당하는 인터럽트 처리 함수를 호출하는데 여기서는 netdrv_rx_interrupt() 함수가 호출된다. 이 함수에서는 dev_alloc_skb() 함수를 이용하여 소켓 버퍼를 생성하고, eth_copy_and_sum() 함수를 이용하여 데이터를 복사한 뒤, eth_type_trans() 함수를 호출하여 하드웨어 헤더 정보를 설정하고 이더넷 프로토콜 정보를 리턴한다. 그리고 netif_rx() 함수를 호출하여 소켓 버퍼를 현재 CPU의 softnet_data 구조체의 input_pkt_queue에 넣고, dev 구조체의 poll_list 정보를 softnet_data 구조체의 poll_list에 추가한 후 NET_RX_SOFTIRQ를 발생시킨다. 그리고 패킷 수신에 대한 통계 정보를 갱신한 후에 인터럽트를 처리를 마친다. 나머지 부분은 softirq로 처리하며 이에 대한 처리는 에 정의된 net_rx_action() 함수가 맡고 있다.

이 함수는 현재 CPU의 softnet_data 구조체의 poll_list에 대하여 처리를 한다. 그 자료 구조에 접근하는 동안에는 인터럽트로 인해 새로운 패킷이 추가되지 않도록 인터럽트를 금지시켜야 한다. 장치가 처리할 수 있는 양을 넘었거나 너무 많은 시간이 흐른 경우에는 다음 번 softirq 시점에서 처리하도록 softnet_break 부분으로 이동하여 리턴하고, 그렇지 않다면 dev 구조체의 poll 필드가 가리키는 함수를 호출한다. 이 함수는 process_backlog()에 해당하며 softnet_data 구조체의 input_pkt_queue 구조체 내의 소켓 버퍼 정보를 하나씩 꺼내서 netif_receive_skb() 함수를 호출한다. 이 함수는 하드웨어 헤더 정보를 읽어 적절한 처리를 한 후 패킷의 프로토콜에 해당하는 packet_type 구조체의 처리 함수를 호출한다. 이 경우 ip_packet_type 구조체의 ip_rcv() 함수가 호출될 것이다.

네트워크 계층 - IP

이 함수는 먼저 자신에게 보내진 패킷이 아니라면 버린다. 그리고 통계 정보를 갱신한 후 소켓 버퍼의 데이터가 공유되고 있는지 검사하여 그런 경우 소켓 버퍼 구조체 자체를 복사(clone)한다. 그리고 pskb_may_pull() 함수를 호출하여 받은 패킷의 데이터 길이가 IP 헤더 정보를 포함하는 길이인지를 검사한 후 이를 IP 헤더로 인식한다. 그리고 RFC1122의 패킷 거부(discard) 규정에 따라 다음 4가지 사항을 점검한다.

① 패킷의 길이가 IP 헤더 정보의 길이보다 작지는 않은가? ②IP 버전의 4인가? ③checksum이 올바른가? ④패킷의 길이 정보가 올바른가? 여기서 IP 헤더에 포함된 헤더 길이 정보(ihl 필드에 해당)는 4의 배수의 형태로 기록되어 있으므로 실제 길이와 비교하기 위해서는 4를 곱하는 형태가 되어야 한다(ihl * 4 혹은 ihl << 2). 혹은 IP 헤더의 최소 길이는 20바이트이므로 이를 검사하기 위해서는 ihl 필드가 5보다 작은지 검사할 수도 있다. 이 단계를 거친 올바른 패킷이라면 NF_IP_PRE_ROUTING Netfilter Hook을 거쳐 ip_rcv_finish() 함수를 호출한다.

ip_rcv_finish() 함수는 먼저 ip_route_input() 함수를 호출하여 리눅스 상에서 패킷을 처리하기 위한 목적지 정보를 설정한다. 최종 목적지가 자기 자신이라면 소켓 버퍼의 dst 구조체의 input 필드가 ip_local_deliver() 함수를 가리키도록 설정된다. 그렇지 않고 자신을 거쳐 다른 호스트에게 보내지는 패킷의 경우에는 ip_forward() 함수로 설정된다. 그리고 IP 헤더 내에 옵션 정보가 포함되어 있다면(헤더의 길이가 20 바이트보다 커지므로 ihl 필드가 5보다 크다) ip_options_complie() 함수를 호출하여 ip_options 구조체의 형태로 만든다. 그리고 dst_input() 함수를 호출하여 dst 구조체의 input 필드가 가리키는 ip_local_deliver() 함수를 호출한다.

ip_local_deliver() 함수는 패킷이 fragment라면 ip_defrag() 함수를 호출하여 ipq 구조체에 저장하고, 그렇지 않다면 NF_IP_LOCAL_IN이라는 Netfilter Hook을 통해 ip_local_deliver_finish() 함수를 호출한다. 이 함수는 먼저 IP 헤더 길이만큼 데이터를 이동시켜 TCP 헤더 정보로 설정한 뒤 해당 프로토콜에 해당하는 정보를 찾아 상위 프로토콜로 넘겨주는 일을 한다. 이 경우 tcp_protocol 구조체의 tcp_v4_rcv() 함수가 호출된다.

전송 계층 - TCP

tcp_v4_rcv() 함수는 주어진 TCP 헤더 정보에 따라 적절한 처리를 한 뒤 소켓 버퍼의 출발지와 목적지의 네트워크 주소 및 포트 번호를 통해 __tcp_v4_lookup() 함수를 호출하여 그에 해당하는 소켓 정보를 찾아낸다. 그리고 소켓에 필터가 존재하는 경우 sk_filter() 함수를 통해 필터링을 하고 tcp_v4_do_rcv() 함수를 호출한다. 이 함수는 에 정의되어 있다.

이 함수는 현재 소켓의 상태에 따라 각각 다른 처리 함수를 호출한다. 먼저 일반적으로 소켓이 연결된 상태라면 (TCP_ESTABLISHED) tcp_rcv_established() 함수를 호출하고, 그렇지 않고 소켓을 기다리는 중이라면 (TCP_LISTEN) tcp_v4_hnd_req() 함수를 호출하여 연결에 대한 요청을 처리한다. 그 외의 상태에 대해서는 connect() 시스템 콜에 대한 부분에서 간략히 살펴본 대로 tcp_rcv_state_process() 함수가 호출된다. tcp_rcv_established() 함수는 ACK에 대한 처리와 타임스탬프, 수신 일련번호 및 윈도우에 대한 처리를 한 후에 사용자 공간으로 데이터를 복사해 준다.

이 때 softirq를 처리하는 프로세스(current)가 소켓을 기다리는 프로세스라면 현재 프로세스의 상태를 TASK_RUNNING으로 만들고 tcp_copy_to_iovec() 함수를 통해 직접 데이터를 복사한다. 그렇지 않다면 소켓 버퍼를 소켓 구조체의 sk_receive_queue의 맨 마지막에 넣고 소켓 버퍼의 소유자를 해당 소켓으로 설정한 뒤 소켓 구조체의 sk_data_ready 필드가 가리키는 함수를 호출한다. 이 함수는 sock_def_readable() 함수로 설정되어 있으며, sk 구조체의 sk_sleep 필드가 가리키는 wait_queue에서 잠들어 있는 프로세스들을 깨운다.

응용 계층

응 용 프로그램에서 read() 시스템 콜을 호출하면 write()의 경우와 마찬가지로 sys_read()→vfs_read()→do_sync_read()→sock_aio_read() 함수를 거쳐 __sock_recvmsg() 함수가 호출되며, 에 정의되어 있다.

이 함수는 소켓 I/O 연산을 위한 sock_iocb 구조체를 초기화한 뒤, security_socket_recvmsg() 함수를 호출하여 보안 사항을 점검하고 실제 루틴인 BSD 소켓 구조체의 ops 구조체의 recvmsg 필드가 가리키는 함수를 호출한다. inet_stream_ops 구조체의 recvmsg 필드는 sock_common_recvmsg() 함수를 가리키고 있으며, 이 함수는 다시 INET 소켓의 recvmsg 필드가 가리키는 함수를 호출한다. TCP에서 이 함수는 tcp_sendmsg()에 해당한다. 이 함수는 루프를 돌며 sk_receive_queue 내의 소켓 버퍼를 검사하여 원하는 offset 에 해당하는 소켓 버퍼의 데이터를 찾아 복사한다. 이 과정에서 프로세스가 시그널을 받는다면 sock_rcvtimeo() 함수에서 계산된 timeo 값에 따라 -ERESTARTSYS 혹은 -EINTR 에러와 함께 리턴된다.

sk_receive_queue 에서 해당하는 소켓 버퍼를 찾지 못하면 sk_wait_data() 함수를 호출하여 timeo 시간만큼 기다린다. 이 때 프로세스는 INET 소켓 구조체의 sk_sleep 필드가 가리키는 wait_queue에서 잠든다. 이 후에 패킷을 받으면 tcp_rcv_established()에서 이 wait_queue 내의 프로세스들을 깨우게 될 것이다.

계층별 패킷 흐름 정리

지 금껏 네트워크 패킷이 송/수신되는 과정을 패킷의 전달 과정을 따라가며 살펴보았다. 지금부터 이를 간단히 블럭도로 정리하여 각 계층에서 패킷의 송/수신을 처리하는 것을 단계 별로 살펴보기로 한다. <그림 3>에서 왼쪽이 수신 과정, 오른쪽이 송신 과정을 보여준다.

데이터 링크 계층

< 그림 3>은 데이터 링크 계층에서 패킷이 송/수신 되는 과정을 보여준다. 패킷이 수신되면 인터럽트 처리 루틴에 의해 소켓 버퍼를 생성하여 데이터를 복사하고 하드웨어 헤더 정보를 설정한 뒤 현재 CPU의 수신 큐에 넣고 softirq에게로 처리를 넘긴다. 패킷을 송신할 때는 일단 네트워크 장치마다 할당된 큐에 소켓 버퍼를 넣고 현재 송신이 가능한지를 검사하여 디바이스 드라이버의 hard_start_xmit 루틴을 호출하여 직접 송신하거나 softirq를 발생시켜 이후에 송신하도록 한다. 앞에서 디바이스 드라이버 계층의 함수는 내의 처리 함수들에 해당한다.

<그림 3> 데이터 링크 계층

네트워크 계층 - IP

<그림 4>는 네트워크 계층의 패킷 전송도이다. 커널의 컴파일 과정에서 네트워크 필터링(network filtering) 기능이 포함되면 패킷의 전송 과정에서 다음과 같은 5가지의 Netfilter Hook을 거치는 데 각각의 역할은 다음과 같다.

  • NF_IP_PRE_ROUTING : 네트워크 장치로부터 수신된 모든 패킷을 처리한다. 실제로 패킷에 대한 처리가 이루어지기 전에 필터링이 가능하게 되므로 DOS 공격에 대한 처리나 목적지 네트워크 주소 변환(DNAT)의 처리, 통계 정보 기록 등을 하기에 알맞다.
  • NF_IP_LOCAL_IN : 로컬 머신에게 전송된 패킷만을 처리한다.
  • NF_IP_FORWARD : 로컬 머신을 통해 다른 머신에게로 forwarding되는 패킷만을 처리한다.
  • NF_IP_LOCAL_OUT : 로컬 머신에서 송신하는 패킷만을 처리한다.
  • NF_IP_POST_ROUTING : 네트워크 장치를 통해 송신하는 모든 패킷을 처리한다(forwarding 패킷 포함). 출발지 네트워크 주소 변환(SNAT)이나 masquerading의 처리, 통계 정보 기록 등을 하기에 알맞다.

<그림 4> 네트워크 계층

전송 계층 - TCP

< 그림 5>는 전송 계층의 패킷 처리 과정을 보여준다. TCP 계층에서 패킷을 수신하면 현재 소켓의 상태에 따라 각기 다른 함수가 호출된다. 소켓이 연결된 상태의 처리 함수인 tcp_rcv_established()는 소켓 버퍼의 데이터를 사용자 공간의 버퍼에 복사하며 이를 기다리며 잠든 프로세스가 있다면 깨운다. 송신 과정에서는 소켓 버퍼를 생성하여 데이터를 복사하고 모든 계층의 헤더 정보가 들어갈 만한 공간을 확보해 둔다.

<그림 5> 전송 계층

커널 해커가 많이 등장하길

이번 글에서는 리눅스의 네트워크 서브 시스템에 대해 간략하게 살펴보았다. 물론 이 밖에도 더 많은 부분이 있지만 필자의 부족한 실력과 한정된 지면으로 인해 더 소개하지 못한 것이 아쉽기만 할 따름이다.

항 상 시작할 때는 마음만 앞서서 많은 것을 소개하려고 하다가 뒤로 갈수록 필자의 한계를 깨닫고 용두사미의 형태로 진행되는 것 같아 부끄럽고 독자들에게 죄송스러운 마음이 든다. 앞으로 국내에서도 훌륭한 커널 해커가 많이 등장하여 리눅스 진영에서 활약을 해 주길 기대하며 3회에 걸친 리눅스 커널 2.6에 대한 연재를 마치고자 한다.@

* 이 기사는 ZDNet Korea의 제휴매체인 마이크로소프트웨어에 게재된 내용입니다. [Bottomcontent]

반응형