1.全球IP因特网

1.1数据在互联网上的传输过程

1.2 一个网络程序的软硬件组织

1.3 IP地址结构

  • 一个IP地址就是一个无符号32位整数。网络程序将其存放在如下所示结构体中:
struct in_addr{
   
    uint32_t s_addr;	//大端法表示的IP地址
};
  • 因为网络字节序都是大端法表示的,所以Unix提供了一组函数用于在网络和主机间进行字节序的转换:
#include <arpa/inet.h>

uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);		//返回网络字节序

uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);		//返回主机字节序
  • IP地址通常以点分十进制表示法来表示的。例如127.0.0.1就是0x7f000001。Unix也提供了一组函数用于在点分十进制和32位十六进制数进行转换的函数:

    #include <arpa/inet.h>
    
    int inet_pton(AF_INET,const char *src, void *dst);
    					//成功返回1,src非法返回0,出错返回-1
    
    const char *inet_ntop(AF_INET,const void *src, char *dst, socklen_t size);
    					//成功返回指向字符串的指针,出错返回NULL

    参数AF_INET代表IPv4,而若是128位的IPv6地址,则是AF_INET6;

    对于inet_ntop函数,成功时dst返回指向字符串表示的IP地址的前size字节。

    1.4因特网域名

    为了便于人类的记忆,DNS服务器记录了所有IP地址和主机名的映射,我们可以通过Linux系统下的nslookup程序来查看域名对应的地址。

    • 默认的本地主机域名localhost总是映射为回送地址(loopback address )127.0.0.1
    linux>	nslookup localhost
    Address:127.0.0.1    

    命令行下输入:hostname会得到本机的IP地址。

    • 在通常情况下,多个域名可以映射到同一个或同一组IP地址;

    1.5 地址转换的示例

    • 编写的函数hex2dd.c会将它的十六进制参数转换为点分十进制串:
    #include <stdio.h>
    #include <sys/inet.h>
    #include "csapp.h"
    
    int main(int argc, char **argv)
    {
         
        struct in_addr inaddr;
        uint32_t addr;
        char buf[MAXBUF];
    	
        if(argc != 2){
         
            fprintf(stderr, "usage:%s <hex num>\n",argv[0]);
            exit(0);
        }
        sscanf(argv[1],"%x",&addr);
        inaddr.s_addr = htonl(addr);
        
        if(!inet_ntop(AF_INET,&inaddr,buf,MAXBUF))
            unix_error("inet_ntop");
        printf("%s\n",buf);
        exit(0);
    }
  • 编写的函数dd2hex.c会将它的点分十进制串参数转换为十六进制:

    #include <stdio.h>
    #include <sys/inet.h>
    #include "csapp.h"
    
    int main(int argc, char **argv)
    {
         
        struct in_addr inaddr;
        int rc;
        
        if(argc != 2){
         
            fprintf(stderr, "usage:%s <dotted-decimal>\n",argv[0]);
            exit(0);
        }
        rc = inet_pton(AF_INET, argv[1], &inaddr);
        if(rc == 0)
            app_error("inet_pton error:invalid dotted-decimal address.\n");
        elseif(rc < 0)
            unix_error("inet_pton error.\n");
        
        printf("0x%x\n",ntohl(inaddr.s_addr));
        exit(0);
    }

2.套接字接口

所谓套接字接口其实是一组函数,它们和I/O函数结合起来,用以创建网络应用。下图是基于套接口的网络应用概述:

2.1 套接字地址结构

从程序的角度看,套接字就是一个打开文件的描述符。套接字地址存放在类型为sockaddr_in的16字节结构体中。

/*IP套接字地址结构,_in是互联网的缩写*/
struct sockaddr_in{
   
    uint16_t		sin_family;	//协议簇(AF_INET或AF_INET6)
    uint16_t		sin_port;	//端口号
    struct in_addr 	sin_addr;	//IP地址
    unsigned char	sin_zero[8];//为了与struct sockaddr边界对齐而填充的字节
}/*通用套接字地址结构*/
struct sockaddr{
   
    uint16_t		sa_family;	//协议簇(AF_INET或AF_INET6)
    char			sa_data[14];//地址数据
}

为什么会出现两种套接字地址?

网络编程函数connect、bind和accept要求一个指向与协议有关的套接字地址结构的指针。但套接字接口设计者面临的问题是如何定义这些函数,使之能够接受各种类型的套接字地址结构。而当时void *指针还没有发明,所以解决办法是设计的套接字函数都采用通用地址结构作为参数,而所有特定协议的套接字指针在使用时都强制转换成通用结构。

为了简化代码,Steven指导定义:

typedef struct sockaddr SA;

然后,无论何时需要将sockaddr_in结构强制转换成sockaddr结构时,我们都使用(SA)。

2.2 socket

该函数创建一个套接字描述符;

#include <sys/types.h>
#include <sys/socket.h>

int socket(int domain, int type, int protocol);	//成功返回非负描述符,错误返回-1
  • domain: 使用哪种类型的IP地址,如AF_INET。

  • type :套接字工作类型,如SOCK_STREAM代表套接字是连接的一个端点。

  • protocol: 一般取0

最好的方法是利用getaddrinfo函数自动生成这些参数,以后会说。这里函数返回的套接字描述符仅是部分打开的,还不能用于读和写。

2.3 connect

客户端利用该函数建立与服务器的连接。

#include <sys/socket.h>

int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);
													//成功返回0,错误返回-1
  • clientfd: 客户端套接字描述符;
  • addr: 通用套接字地址
  • addrlen: 套接字地址长度,一般取sizeof(sockaddr_in)

该函数阻塞等待与服务器的连接,若成功就表示套接字描述符现在可以读写了,并且得到的连接是由(客户端IP地址:客户端分配的临时端口号)唯一表示。最好的方法也是利用getaddrinfo函数自动生成这些参数。

2.4 bind

服务器用它将套接字地址和套接字描述符绑定起来。

#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
													//成功返回0,错误返回-1
  • sockfd: 服务器套接字描述符;
  • addr: 通用套接字地址
  • addrlen: 套接字地址长度,一般取sizeof(sockaddr_in)

最好的方法也是利用getaddrinfo函数自动生成这些参数。

2.5 listen

服务器调用该函数告诉内核,该描述符是被服务器用来监听来自客服端连接请求的。

#include <sys/socket.h>

int listen(int sockfd, int backlog);
													//成功返回0,错误返回-1
  • sockfd: 需要转化为监听套接字的描述符
  • backlog: 内核在开始拒绝连接请求之前,队列中要排队的未完成的连接请求的数量。一般设为一个较大的值,比如1024。

2.6 accept

服务器调用该函数等待来自客服端的连接请求。

#include <sys/socket.h>

int accept(int listenfd, struct sockaddr *addr, int *addrlen);
													//成功返回非负描述符,错误返回-1

该函数等待来自客户端的连接请求到达监听描述符listenfd,然后在addr中填写客服端的套接字地址,并返回一个已连接描述符,而它可以用来与客户端通信。

监听描述符和已连接描述符的区别:

  • 监听描述符作为客户端连接请求的一个端点,通常被创建一次,存在于服务器的整个生命周期;
  • 已连接描述符是客户端与服务器之间已经建立起来的连接的一个端点,服务器每次接受连接请求时都会创建一次,它只存在与服务器为一个客户端服务的过程中。

3.转换函数

3.1 getaddrinfo

函数将主机名(网址或点分十进制IP地址)和服务名(端口号)的字符串转化成套接字地址结构。它是可重入和协议无关的,是代替gethostbyname和getservbyname函数的替代品。

#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>

struct addrinfo{
   
    int			ai_flags;	/*hints的参数*/
    int			ai_family;	/*套接字函数的第一个参数*/
    int			ai_socktype;/*套接字函数的第二个参数*/
    int			ai_protocol;/*套接字函数的第三个参数*/
    char		*ai_canonname;/*主机的官方名*/
    size_t		ai_addrlen;	/*套接字地址长度*/
    struct sockaddr *ai_addr; /*套接字地址指针*/
    struct addrinfo *ai_next; /*指向下一个addrinfo条目*/
};


int getaddrinfo( const char *host, const char *service,
               	const struct addrinfo *hints,
               	struct addrinfo **result);		//成功返回0,错误返回非零错误代码。

void freeaddrinfo(struct addrinfo *result);		//无返回

const char *gai_strerror(int errcode);			//返回错误消息字符串
  • host:可以是域名,也可以是点分十进制IP地址。

  • service :可以是服务名(http等),也可是十进制端口号。如果不想把主机名或端口号转换成套接字地址,就把相应的参数设置为NULL,但是至少要有一个有效。

  • hints :控制参数,它是一个特定的addrinfo指针,通过对该addrinfo结构体ai_family、ai_socktype、ai_protocol、ai_flags成员的设置,可以控制getaddrinfo函数返回的套接字地址列表的特性。但其余成员必须设置为0。实际中一般先用memset函数将整个结构清零,然后又选择的对某些成员赋值。

    • ai_family: 可以设置为AF_INET或AF_INET6。前者表示只返回IPv4的地址,后者返回IPv6的地址;
    • ai_socktype :对host关联的每个地址,该函数默认最多返回3个addrinfo结构,每个的ai_socktype字段不同:“连接”、“数据报”和“原始套接字”。如果ai_socktype被设置为SOCK_STREAM,则将限制为对每个地址只返回1个连接的addrinfo结构。
    • ai_flags : 它是一个位掩码,可以进一步修改默认行为,其取值可以是以下宏的
      • AI_ADDRCONFIG: 要求只有本地主机被配置为IPv4时,getaddrinfo返回IPv4地址。
      • AI_CANONNAME: 将列表中第一个addrinfo结构的ai_canonname字段指向host的官方名字。
      • AI_NUMERICSERV:强制getaddrinfo的service参数为端口号。
      • AI_PASSIVE: getaddrinfo默认返回用于客户端的主动套接字,如果设置了该标志,那么函数会返回用于服务器的被动套接字,此时host设置为NULL。得到的套接字地址结构中的地址字段sa_data[14]是通配符地址(wildcard address),表示这个服务器会接受所有发送到服务器的IP地址的请求。
  • result : 指向一个包含sockaddr(套接字地址结构)地址的addrinfo结构体链表。一般调用完这个函数之后,会遍历该链表,依次尝试每个套接字地址,直到socket和connect或bind连接成功。

getaddrinfo函数返回的addrinfo结构中的ai_addr指向的套接字地址可以直接用来传递给套接字接口中的函数(socket、connect、bind、listen、accept等),该特点使得我们编写的客户端和服务器能够独立于某个特殊版本的IP协议。下图展示了getaddrinfo返回的数据结构:

  • 为了避免内存泄漏,一般在调用完getaddrinfo函数之后,会调用freeaddrinfo函数释放该链表;
  • 如果getaddrinfo遇到错误,应用程序可以调用gai_strerror函数将错误代码转换成字符串。
  • 当getaddrinfo创建列表中的addrinfo结构时,会填写除了ai_flags的每个字段。

3.2 getnameinfo

函数将一个套接字地址结构转化成相应的主机名(网址或点分十进制IP地址)和服务名(端口号)字符串,并将它们复制到host和service缓冲区。它也是可重入和协议无关的,是代替gethostbyaddr和getservbyport函数的替代品。

#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>

int getnameinfo( const struct sockaddr *sa, socklen_t salen,
                char *host, size_t hostlen,
               	char *service, size_t servlen, int flags);
				//成功返回0,错误返回非零错误代码。
  • sa :指向大小为salen字节的套接字地址结构;

  • host :指向大小为hostlen字节的缓冲区;如果不想要主机名,可以设置为NULL,hostlen设置为0。

  • service :指向大小为servlen字节的缓冲区;如果不想要服务名,也可以设置为NULL,servlen设置为0,但二者不能同时都设为NULL。

  • flags :它是一个位掩码,可以进一步修改默认行为,其取值可以是以下宏的或。

    • NI_NUMERICHOST: getnameinfo函数默认返回域名,若设置此项则返回数字地址。
    • NI_NUMERICSERV: 默认返回服务名(如果可能的话),若设置此项则返回端口号。

3.3 域名翻译程序

利用上两节所学的两个函数,编写程序hostinfo.c,当输入一个域名时,会得到相应的点分十进制IPv4地址。

#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>

int main(int argc, char **argv) 
{
   
    struct addrinfo *p, *listp, hints;
    char buf[MAXLINE];
    int rc, flags;

    if (argc != 2) {
   
	fprintf(stderr, "usage: %s <domain name>\n", argv[0]);
	exit(0);
    }

    /* Get a list of addrinfo records */
    memset(&hints, 0, sizeof(struct addrinfo));                         
    hints.ai_family = AF_INET;       /* IPv4 only */        //line:netp:hostinfo:family
    hints.ai_socktype = SOCK_STREAM; /* Connections only */ //line:netp:hostinfo:socktype
    if ((rc = getaddrinfo(argv[1], NULL, &hints, &listp)) != 0) {
   
        fprintf(stderr, "getaddrinfo error: %s\n", gai_strerror(rc));
        exit(1);
    }

    /* Walk the list and display each IP address */
    flags = NI_NUMERICHOST; /* Display address string instead of domain name */
    for (p = listp; p; p = p->ai_next) {
   
        Getnameinfo(p->ai_addr, p->ai_addrlen, buf, MAXLINE, NULL, 0, flags);
        printf("%s\n", buf);
    } 

    /* Clean up */
    Freeaddrinfo(listp);

    exit(0);
}

首先,初始化hints结构,使getaddrinfo返回我们想要的地址。我们想得到用作“连接”的IPv4地址,且只想得到域名,不要服务名。

然后,遍历addrinfo结构链表,用getnameinfo将每个套接字地址转换成IPv4地址字符串。

最后,用freeaddrinfo函数释放链表。

运行程序,我们会看到twitter.com映射到4个IP地址。

linux> ./hostinfo twitter.com
199.16.156.102
199.16.156.230
199.16.156.6
199.16.156.70

4. 套接字接口的辅助函数

套接字接口函数和转换函数看上去有些可怕,相当复杂凌乱。这一节会介绍一些包装函数,它们将会大大简化客户端和服务器通信程序的编写。

4.1 open_clientfd

客户端可以直接利用该函数建立与服务器的连接。

#include "csapp.h"

int open_clientfd(char * hostname, char *port);
				//成功返回套接字描述符,出错返回-1

下面是它的源代码,它是可重入和协议无关的。

int open_clientfd(char *hostname, char *port) {
   
    int clientfd, rc;
    struct addrinfo hints, *listp, *p;

    /* Get a list of potential server addresses */
    memset(&hints, 0, sizeof(struct addrinfo));
    hints.ai_socktype = SOCK_STREAM;  /* Open a connection */
    hints.ai_flags = AI_NUMERICSERV;  /* ... using a numeric port arg. */
    hints.ai_flags |= AI_ADDRCONFIG;  /* Recommended for connections */
    if ((rc = getaddrinfo(hostname, port, &hints, &listp)) != 0) {
   
        fprintf(stderr, "getaddrinfo failed (%s:%s): %s\n", hostname, port, gai_strerror(rc));
        return -2;
    }
  
    /* Walk the list for one that we can successfully connect to */
    for (p = listp; p; p = p->ai_next) {
   
        /* Create a socket descriptor */
        if ((clientfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0) 
            continue; /* Socket failed, try the next */

        /* Connect to the server */
        if (connect(clientfd, p->ai_addr, p->ai_addrlen) != -1) 
            break; /* Success */
        if (close(clientfd) < 0) {
    /* Connect failed, try another */  //line:netp:openclientfd:closefd
            fprintf(stderr, "open_clientfd: close failed: %s\n", strerror(errno));
            return -1;
        } 
    } 

    /* Clean up */
    freeaddrinfo(listp);
    if (!p) /* All connects failed */
        return -1;
    else    /* The last connect succeeded */
        return clientfd;
}

假设服务器运行在主机hostname上,并在端口port上监听连接请求。

首先,调用getaddrinfo,返回一个addrinfo结构体链表。遍历该列表依次尝试列表中的每个条目中的ai_addr指向的套接字地址,直到调用socket和connect成功。如果一个失败,则在下一次尝试前关闭掉这个套接字描述符。如果成功建立连接,就释放列表内存,并把套接字描述符返回给客户端,客户端就可以利用它与所有Unix I/O函数与服务器通信了。

4.2 open_listenfd

服务器可以直接利用该函数创建一个监听描述符,准备好建立与客户端的连接。

#include "csapp.h"

int open_listenfd(char *port);
				//成功返回套接字描述符,出错返回-1

open_listenfd函数返回一个打开的监听描述符,且已经准备好在端口port上接受客户端的连接请求。下面是它的源代码:

int open_listenfd(char *port) 
{
   
    struct addrinfo hints, *listp, *p;
    int listenfd, rc, optval=1;

    /* Get a list of potential server addresses */
    memset(&hints, 0, sizeof(struct addrinfo));
    hints.ai_socktype = SOCK_STREAM;             /* Accept connections */
    hints.ai_flags = AI_PASSIVE | AI_ADDRCONFIG; /* ... on any IP address */
    hints.ai_flags |= AI_NUMERICSERV;            /* ... using port number */
    if ((rc = getaddrinfo(NULL, port, &hints, &listp)) != 0) {
   
        fprintf(stderr, "getaddrinfo failed (port %s): %s\n", port, gai_strerror(rc));
        return -2;
    }

    /* Walk the list for one that we can bind to */
    for (p = listp; p; p = p->ai_next) {
   
        /* Create a socket descriptor */
        if ((listenfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0) 
            continue;  /* Socket failed, try the next */

        /* Eliminates "Address already in use" error from bind */
        setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR,    //line:netp:csapp:setsockopt
                   (const void *)&optval , sizeof(int));

        /* Bind the descriptor to the address */
        if (bind(listenfd, p->ai_addr, p->ai_addrlen) == 0)
            break; /* Success */
        if (close(listenfd) < 0) {
    /* Bind failed, try the next */
            fprintf(stderr, "open_listenfd close failed: %s\n", strerror(errno));
            return -1;
        }
    }


    /* Clean up */
    freeaddrinfo(listp);
    if (!p) /* No address worked */
        return -1;

    /* Make it a listening socket ready to accept connection requests */
    if (listen(listenfd, LISTENQ) < 0) {
   
        close(listenfd);
	return -1;
    }
    return listenfd;
}

首先,调用getaddrinfo,返回一个addrinfo结构体链表。遍历该列表依次尝试列表中的每个条目中的ai_addr指向的套接字地址,直到调用socket和bind成功。如果失败,则在下一次尝试前关闭掉这个套接字描述符。如果成功绑定,就释放列表内存,并调用listen函数将该套接字描述符转换为监听描述符返回给调用者,服务器就可以利用它与所有Unix I/O函数响应客户端了。

  • 我们使用了setsockopt函数来配置服务器,使得服务器能够被终止、重启和立即接受连接。一个重启的服务器默认将在30秒内拒绝客户端的连接请求。关于setsockopt的使用很复杂,将会专门写篇文章来讲解他的使用方法。
  • 我们使用了AI_PASSIVE标志并将host参数设置为NULL,这样每个套接字地址字段都会被设置为通配符地址,表示服务器接受发送到本机所有IP地址的请求。

4.3 编写客户端echo和服务器程序

4.3.1客户端程序echoclient

客户端首先与服务器建立连接,之后进入循环等待从标准输入读取文本行发送给服务器。再等待从服务器取回回送的行,并输出结果到标准输出。

#include "csapp.h"

int main(int argc, char **argv) 
{
   
    int clientfd;
    char *host, *port, buf[MAXLINE];
    rio_t rio;

    if (argc != 3) {
   
	fprintf(stderr, "usage: %s <host> <port>\n", argv[0]);
	exit(0);
    }
    host = argv[1];
    port = argv[2];

    clientfd = Open_clientfd(host, port);
    Rio_readinitb(&rio, clientfd);

    while (Fgets(buf, MAXLINE, stdin) != NULL) {
   
	Rio_writen(clientfd, buf, strlen(buf));
	Rio_readlineb(&rio, buf, MAXLINE);
	Fputs(buf, stdout);
    }
    Close(clientfd); //line:netp:echoclient:close
    exit(0);
}
  • 当fgets遇到EOF或键盘上键入Ctrl + D,或重定向到标准输入的文件中用尽了所有文本行时,循环就终止。
  • 循环终止后,客户端关闭描述符。此时会导致发送一个EOF到服务器。

4.3.2 服务器程序echoserver

服务器首先打开监听描述符,进入循环等待与客户端建立连接,连接之后首先输出客户端的域名和IP,之后调用echo函数为其服务。echo函数返回后关闭已连接的描述符,连接终止。

#include "csapp.h"

void echo(int connfd);

int main(int argc, char **argv) 
{
   
    int listenfd, connfd;
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;  /* Enough space for any address */  //line:netp:echoserveri:sockaddrstorage
    char client_hostname[MAXLINE], client_port[MAXLINE];

    if (argc != 2) {
   
	fprintf(stderr, "usage: %s <port>\n", argv[0]);
	exit(0);
    }

    listenfd = Open_listenfd(argv[1]);
    while (1) {
   
	clientlen = sizeof(struct sockaddr_storage); 
	connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
    Getnameinfo((SA *) &clientaddr, clientlen, client_hostname, MAXLINE, 
                 client_port, MAXLINE, 0);
    printf("Connected to (%s, %s)\n", client_hostname, client_port);
	echo(connfd);
	Close(connfd);
    }
    exit(0);
}


void echo(int connfd) 
{
   
    size_t n; 
    char buf[MAXLINE]; 
    rio_t rio;

    Rio_readinitb(&rio, connfd);
    while((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0) {
    //line:netp:echo:eof
	printf("server received %d bytes\n", (int)n);
	Rio_writen(connfd, buf, n);
    }
}
  • clientaddr是一个套接字地址结构,被传递给accept函数,在accept返回前会将连接到的客户端套接字地址填入到clientaddr。
  • 将clientaddr声明为struct sockaddr_storage是因为该结构足够大能装下任何类型的套接字地址,以保持代码的协议无关性。
  • 我们这里建立的echo服务器一次只能处理一个客户端连接。需要不停的在多个客户端间迭代服务,也称之为迭代服务器。
  • echo函数反复读写文本行,直到rio_readlineb函数遇到EOF。

获取更多知识,请点击关注:
嵌入式Linux&ARM
CSDN博客
简书博客
知乎专栏