1 网络编程

1.1 套接字概念

socket在编程中是网络通信的端点。在Linux中,socket被实现为一种特殊类型的“文件”(伪文件),通过文件描述符操作。读写socket和读写文件几乎是一样的接口(如read()write()close())。

socket并不是协议,它只是对TCP/IP协议族的一种封装。通信本质是通过协议进行的,socket只是编程接口(API)。不同语言调用的socket接口可能略有差别,但底层逻辑相同。

一个socket由(IP地址, 端口号)唯一标识。两个进程之间的连接由两个socket组成:(源IP:源端口,目标IP:目标端口) ,称为socket pair。socket pair唯一标识一条TCP连接(或UDP会话)。在网络通信中,套接字一定是成对出现的。 一端的发送缓冲区对应对端的接收缓冲区。

套接字示意图

1.2 地址信息设置

以IPv4为例介绍网络的地址结构。主要涉及的结构体有struct in_addrstructsockaddrstruct sockaddr_in

struct sockaddr是一种通用的地址结构,它可以描述一个IPv4或者IPv6的结构,所有涉及到地址的接口都使用了该类型的参数,但是过于通用的结果是直接用它来描述一个具体的IP地址和端口号十分困难。

struct sockaddr {
	sa_family_t sin_family; // 地址协议,如果为IPv4,则指定为AF_INET
	char sa_data[14]; // 保留字节,并没有指定
};

所以用户一般先使用struct sockaddr_in来构造地址,再将其进行强制类型转换成struct sockaddr以作为网络接口的参数。

struct sockaddr_in {
    sa_family_t sin_family; // 地址协议,如果为IPv4,则指定为AF_INET
    in_port_t sin_port; // 端口号
    struct in_addr sin_addr; // IP地址,点分十进制
};

struct in_addr {
	uint32_t s_addr; // 4字节的整型数据
};

1.3 大小端地址转换

对于一个存储空间大于1个字节的数据,在内存中有两种存储模式:

  • 大端模式(big-endian):数据的高字节在内存的低地址存放,数据的低字节在内存的高地址存放;

    大端模式又称为网络字节序;

    大端模式

  • 小端模式(little-endian):数据的高字节在内存的高地址存放,数据的低字节在内存的低地址存放;

    小端字节序又称为主机字节序。

    小端模式

TCP/IP协议规定,当数据在网络中传输的时候,一律使用网络字节序即大端法。对于应用层协议的载荷部分,如果不需要第三方工具检测内容,可以不进行大小端转换(因为接收方和发送方都是主机字节序即小端法)。但是对于其他层次的头部部分,在发送之前就一定要进行小端到大端的转换了(因为网络中的通信以及tcpdumpnetstat等命令都是以大端法来解析内容的)。

整数大小端转换相关的函数有:

#include <arpa/inet.h>
//h --> host, n --> net, l --> 32bit, s --> 16bit
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);

1.4 IP地址的转换

点分十进制的IP地址转换为4位网络字节序IP地址的函数为:

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int inet_aton(const char *cp, struct in_addr *inp); // inp为传出参数
in_addr_t inet_addr(const char *cp);

4位网络字节序IP地址转换为点分十进制的IP地址的函数为:

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
char *inet_ntoa(struct in_addr in);
const char *inet_ntop(int af,
                     const void *src, // 网络字节序的IP地址
                     char *dst, // 可视化的IP地址,传出参数
                     socklen_t size); // 数组长度

1.5 域名和IP地址的对应关系

DNS协议建立了域名和IP地址的映射关系,去访问一个DNS服务器,服务器当中存储了域名和IP地址的映射关系。与这个操作相关的函数为:

#include <netdb.h>
struct hostent *gethostbyname(const char *name);
struct hostent {
    char *h_name; // 主机的官方域名(规范名)
    char **h_aliases; // 主机的别名列表(以NULL结尾)
    int h_addrtype; // 地址类型(AF_INET表示IPv4,AF_INET6表示IPv6)
    int h_length; // 地址长度(IPv4是4字节,IPv6是16字节)
    char **h_addr_list; // IP 地址列表(以NULL结尾,存储的是网络字节序的二进制地址)
}

2 TCP通信

2.1 TCP通信的流程

使用TCP通信的流程图:

TCP通信的流程

2.2 相关接口

2.2.1 socket

socket函数用于创建一个socket设备。调用该函数时需要指定通信的协议域、套接字类型和协议类型。一般根据选择TCP或者UDP有着固定的写法。

#include <sys/socket.h>
int socket(int domain, int type, int protocol);

//domain AF_INET --> IPv4, AF_INET6 --> IPv6
//type SOCK_STREAM --> TCP, SOCK_DGRAM --> UDP
//protocol IPPROTO_TCP --> TCP, IPPROTO_UDP -->UDP

// 返回值 成功 --> 大于0(一个文件描述符), 失败 --> -1(没有得到套接字)

实际使用时参数3一般直接指定为0。

2.2.2 bind

bind函数用于给套接字赋予一个本地协议地址(即IP地址加端口号)。bind在选择端口号时应当避开知名端口号的范围(<1024)。

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

// sockfd 指定一个套接字
// addr 通用的sockaddr,实际绑定时,会使用struct sockaddr_in 
// addrlen 其值就是sizeof(struct sockaddr_in)

// 返回值 成功 --> 0, 失败 --> -1(设置errno)

该函数只有服务器端需要进行调用,因为服务器需要将自己的网络地址暴露给客户端。

客户端不需要使用bind,操作系统会自动进行分配一个网络地址。

2.2.3 listen

listen函数使TCP服务端开启监听。服务端在开启了listen之后,就可以开始接受客户端连接了。

int listen(int sockfd, int backlog);

// sockfd 监听某一个套接字
// backlog 监听的连接的个数

// 返回值 成功 --> 0, 失败 --> -1(设置errno)

一旦启用了listen之后,操作系统就知道该套接字是服务端的套接字,操作系统内核就不再启用其发送和接收缓冲区,转而在内核区维护两个队列结构:半连接队列和全连接队列。半连接队列用于管理成功第一次握手的连接,全连接队列用于管理已经完成三次握手的队列。backlog在有些操作系统用来指明半连接队列和全连接队列的长度之和,一般填一个正数即可。如果队列已经满了,那么服务端受到任何再发起的连接都会直接丢弃(大部分操作系统中服务端不会回复RST,以方便客户端自动重传)。

两个队列

使用netstat -an命令可以查看主机上某个端口的监听情况:

$ netstat -an|grep 2778
tcp 0 0 127.0.0.1:2778 0.0.0.0:* LISTEN

2.2.4 connect

客户端使用connect来建立和TCP服务端的连接。

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

// sockfd 客户端的套接字
// addr 指定服务器的网络地址
// addrlen 网络地址的长度 

// 返回值 成功 --> 0, 失败 --> -1(设置errno)

客户端在调用connect可以不使用bind来指定本地的端口信息,这时客户端就会随机选择一个临时端口号来作为源端口。调用connect预期是完成TCP建立连接的三次握手。如果服务端未开启对应的端口号或者未监听,则只能收到一个RST回复,并且报错返回的内容是"Connection refused"。

2.2.5 accept

accept函数由服务端调用,用于从全连接队列中取出下一个已经完成的TCP连接。如果全连接队列为空,那么accept会陷入阻塞。一旦全连接队列中到来新的连接,此时accept操作就会就绪,这种就绪是读操作就绪,所以可以使用select函数的读集合进行监听。当accept执行完了之后,内核会创建一个新的套接字文件对象,该文件对象关联的文件描述符是accept的返回值,文件对象当中最重要的结构是一个发送缓冲区和接收缓冲区,可以用于服务端通过TCP连接发送和接收TCP段。

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

// sockfd 指定监听的套接字listenfd
// addr 传入传出参数,存放客户端的网络地址
// addrlen addr长度信息 ,传入传出参数

// 返回值 成功 --> 大于0(一个套接字peerfd), 失败 --> -1(设置errno)

默认情况下,accept是一个阻塞式的函数。

两类套接字:

  • 通常把旧的管理连接队列的套接字称作监听套接字listenfd。监听套接字会一直存在,负责建立各个不同的TCP连接(只要源IP、源端口、目的IP、目的端口四元组任意一个字段有区别,就是一个新的TCP连接),执行三次握手。
  • 新的用于发送和接收TCP段的套接字称作已连接套接字peerfd。某一条单独的TCP连接则是由其对应的已连接套接字进行数据通信的。

2.2.6 send和recv

sendrecv用于将数据在用户态空间和内核态的缓冲区之间进行传输,无论是客户端还是服务端均可使用,但是只能用于TCP连接。将数据拷贝到内核态并不意味着会马上传输,而是会根据时机再由内核协议栈按照协议的规范进行分节,通常缓冲区如果数据过多会分节成MSS的大小,然后根据窗口条件传输到网络层之中。

ssize_t send(int sockfd, const void *buf, size_t len, int flags);

// sockfd server 用peerfd来接收数据 client:用clientfd来接收数据 
// buf 指定的是用户态的发送缓冲区的首地址
// len 指定的是用户态的发送缓冲区的长度
// flags 一般情况下指定为0

// 返回值 成功 --> 大于0(发送的字节数), 失败 --> -1(设置errno)

ssize_t recv(int sockfd, void *buf, size_t len, int flags);

// sockfd server:用peerfd来接收数据 client:用clientfd来接收数据 
// buf 指定的是用户态的接收缓冲区的首地址
// len 指定的是用户态的接收缓冲区的长度
// flags 一般情况下指定为0,还可设置为MSG_PEEK,MSG_WAITALL,MSG_DONTWAIT

// 返回值 大于0 --> 接收的数据长度 
//       等于0 --> 连接断开 
//       小于0 --> 发生了错误,设置errno

使用readwrite可以实现同样的效果,相当于flags参数为0。

需要特别注意的是, sendrecv的次数和网络上传输的TCP段的数量没有关系,多次的sendrecv可能只需要一次TCP段的传输。另外一方面,TCP是一种流式的通信协议,消息是以字节流的方式在信道中传输,这就意味着,消息和消息之间是没有边界的。在不加额外约定的情况下,通信双方并不知道发送和接收到底有没有接收完一个消息,有可能多个消息会在一次传输中被发送和接收(粘包),也有有可能一个消息需要多个传输才能被完整的发送和接收(半包)。

sendrecv都是阻塞式函数:

  • 对于recv函数来说,当内核态接收缓冲区没有数据时,就处于阻塞状态;
  • 对于send函数来说,当内核态的发送缓冲区满了就会阻塞。

2.3 实现服务端-客户端通信

2.3.1 代码实现

server服务端:

#include <func.h>

int main(int argc, char *argv[])
{
    signal(SIGPIPE, SIG_IGN);

    ARGC_CHECK(argc, 3);
    int ret = 0;

    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    ERROR_CHECK(listenfd, -1, "socket");
    printf("listenfd : %d.\n", listenfd);

    int opt = 1;
    ret = setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    ERROR_CHECK(ret, -1, "setsockopt");

    struct sockaddr_in serveraddr;
    memset(&serveraddr, 0, sizeof(serveraddr));
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_port = htons(atoi(argv[2]));
    serveraddr.sin_addr.s_addr = inet_addr(argv[1]);

    ret = bind(listenfd, (const struct sockaddr*)&serveraddr, sizeof(serveraddr));
    ERROR_CHECK(ret, -1, "bind");

    ret = listen(listenfd, 10);
    ERROR_CHECK(ret, -1, "listen");
    printf("server start listening!\n");

    struct sockaddr_in clientaddr;
    memset(&clientaddr, 0, sizeof(clientaddr));
    socklen_t len = sizeof(struct sockaddr);
    int peerfd = accept(listenfd, (struct sockaddr*)&clientaddr, &len);
    printf("%s:%d has connected, peerfd:%d.\n",
           inet_ntoa(clientaddr.sin_addr),
           ntohs(clientaddr.sin_port),
           peerfd);

    char sendbuff[128] = "hello, client!";
    ret = send(peerfd, sendbuff, strlen(sendbuff), 0);
    printf("send %d bytes.\n", ret);

    char recvbuff[128];
    recv(peerfd, recvbuff, sizeof(recvbuff), 0);
    printf("recv msg: %s\n", recvbuff);

    close(peerfd);
    close(listenfd);

    return 0;
}

client服务端:

#include <func.h>

int main(int argc, char *argv[])
{
    ARGC_CHECK(argc, 3);
    int ret = 0;

    int clientfd = socket(AF_INET, SOCK_STREAM, 0);
    ERROR_CHECK(clientfd, -1, "socket");

    struct sockaddr_in serveraddr;
    memset(&serveraddr, 0, sizeof(serveraddr));
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_port = htons(atoi(argv[2]));
    serveraddr.sin_addr.s_addr = inet_addr(argv[1]);

    ret = connect(clientfd, (const struct sockaddr*)&serveraddr, sizeof(serveraddr));
    ERROR_CHECK(ret, -1, "connect");
    printf("connection success!\n");

    char recvbuff[128] = {0};
    recv(clientfd, recvbuff, sizeof(recvbuff), 0);
    printf("recv msg: %s\n", recvbuff);

    char sendbuff[128] = "hello, server!";
    ret = send(clientfd, sendbuff, strlen(sendbuff), 0);
    printf("send %d bytes.\n", ret);


    return 0;
}

2.3.2 SIGPIPE信号处理

当往一个已经断开了的连接上发送数据,第一次send时,由于客户端已经断开,内核会给服务器返回一个RST报文。当第二次send时,服务器进程会收到一个SIGPIPE信号,其默认处理方式是终止进程,直接导致进程奔溃。因此,需要在服务器程序的开始位置,直接将SIGPIPE信号忽略掉。

// 在 main 函数开始处调用
signal(SIGPIPE, SIG_IGN); // 忽略 SIGPIPE 信号

2.3.3 套接字属性设置

当服务器主动断开链接时,会进入到TIME_WAIT状态,此时想要再次启动服务器进程,会报以下的错误bind: Address already in use。为此,必须对套接字的属性进行设置。

int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);

// sockfd server要设置选项的套接字描述符 
// level 选项所属的协议层:
// 			SOL_SOCKET:通用套接字层(最常用,如端口复用)
//          IPPROTO_TCP:TCP协议层(如TCP保活)
//          IPPROTO_IP:IP协议层(如设置TTL)
// optname 具体要设置的选项名(如 SO_REUSEADDR、SO_SNDBUF)
// optval 指向存放选项值的缓冲区(传入要设置的值)
// optlen optval缓冲区的大小(字节数,用 sizeof() 获取)

// 返回值 成功 --> 大于0(一个套接字peerfd), 失败 --> -1(设置errno)

bind前,将listenfd的套接字的属性设置为网络地址可以重用的:

int opt = 1;
ret = setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

2.4 实现http服务器

#include <asm-generic/socket.h>
#include <func.h>

int main(int argc, char *argv[])
{
    ARGC_CHECK(argc, 3);
    int ret;

    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    ERROR_CHECK(listenfd, -1, "socket");

    struct sockaddr_in serveraddr;
    memset(&serveraddr, 0, sizeof(serveraddr));//初始化
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_port = htons(atoi(argv[2]));
    serveraddr.sin_addr.s_addr = inet_addr(argv[1]);

    int on = 1;
    ret = setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
    ERROR_CHECK(ret, -1, "setsockopt");

    ret = bind(listenfd, (const struct sockaddr*)&serveraddr, sizeof(serveraddr));
    ERROR_CHECK(ret, -1, "bind");

    ret = listen(listenfd, 10);
    ERROR_CHECK(ret, -1, "listen");

    while(1)
    {
        struct sockaddr_in clientAddr;
        memset(&clientAddr, 0, sizeof(clientAddr));
        socklen_t len = sizeof(clientAddr);
        int peerfd = accept(listenfd, (struct sockaddr*)&clientAddr, &len);
        printf("%s:%d has connected.\n", 
               inet_ntoa(clientAddr.sin_addr),
               ntohs(clientAddr.sin_port));

        //1.获取http请求报文
        char buff[4096] = {0};
        recv(peerfd, buff, sizeof(buff), 0);

        //2.对请求报文进行响应操作
        const char * startLine = "HTTP/1.1 200 OK\r\n";
        const char * headers = "Server: MyHttpServer1.0\r\n"
            "Content-Type: text/html\r\n"
            "Content-Length: ";
        const char * emptyLine = "\r\n";
        const char * body = 
            "<html><head>This is a test!</head>\n"
            "<body><br>Hello,client!</body></html>";
        //清空缓冲区
        memset(buff, 0, sizeof(buff));
        sprintf(buff, "%s%s%ld\r\n%s%s",
                startLine,headers,strlen(body), emptyLine,body);
        printf("response:\r\n%s\n", buff);
        printf("-------------------------end---------------------------\n\n\n");
        send(peerfd, buff, strlen(buff), 0);
    }

    close(listenfd);

    return 0;
}

3 UDP通信

3.1 UDP通信的流程

使用UDP通信的流程图:

UDP通信的流程

UDP是一个无连接的协议,因此必须要客户端先发送数据,这样服务端才能知道对端的地址信息,从进入后续的通信。

3.2 sendto和recvfrom

sendtorecvfrom这两个函数的行为类似于sendrecv,不过会有一些额外的参数,用来指定或者保存对端的套接字地址结构以及对应的大小。

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
				const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
				struct sockaddr *src_addr, socklen_t *addrlen);

sendto发送数据时,必须要指定网络地址。recvfrom表示从某一个网络地址接收数据。

3.3 实现服务端-客户端通信

server服务端:

#include <func.h>

int main(int argc, char *argv[])
{
    ARGC_CHECK(argc, 3);
    int ret = 0;

    int serverfd = socket(AF_INET, SOCK_DGRAM, 0);
    ERROR_CHECK(serverfd, -1, "socket");

    struct sockaddr_in serveraddr;
    memset(&serveraddr, 0, sizeof(serveraddr));
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_port = htons(atoi(argv[2]));
    serveraddr.sin_addr.s_addr = inet_addr(argv[1]);

    ret = bind(serverfd, (const struct sockaddr*)&serveraddr, sizeof(serveraddr));
    ERROR_CHECK(ret, -1, "bind");

    struct sockaddr_in clientaddr;
    memset(&clientaddr, 0, sizeof(clientaddr));
    socklen_t len = sizeof(struct sockaddr);
    
    char recvbuff[128] = {0};
    ret = recvfrom(serverfd, recvbuff, sizeof(recvbuff), 0, (struct sockaddr*)&clientaddr, &len);
    printf("recvfrom client: %s, bytes: %d.\n", recvbuff, ret);
    printf("client: %s:%d.\n", inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port));

    char sendbuff[] = "hello, client!";
    ret = sendto(serverfd, sendbuff, strlen(sendbuff), 0, (const struct sockaddr*)&clientaddr, sizeof(clientaddr));
    printf("send %d bytes.\n", ret);

    close(serverfd);

    return 0;
}

client服务端:

#include <func.h>

int main(int argc, char *argv[])
{
    ARGC_CHECK(argc, 3);
    int ret = 0;

    int clientfd = socket(AF_INET, SOCK_DGRAM, 0);
    ERROR_CHECK(clientfd, -1, "socket");

    struct sockaddr_in serveraddr;
    memset(&serveraddr, 0, sizeof(serveraddr));
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_port = htons(atoi(argv[2]));
    serveraddr.sin_addr.s_addr = inet_addr(argv[1]);
    socklen_t len = sizeof(serveraddr);
    
    char sendbuff[128] = "hello, server!";
    ret = sendto(clientfd, sendbuff, strlen(sendbuff), 0, (const struct sockaddr*)&serveraddr, sizeof(serveraddr));
    printf("send %d bytes.\n", ret);

    char recvbuff[128] = {0};
    recvfrom(clientfd, recvbuff, sizeof(recvbuff), 0, (struct sockaddr*)&serveraddr, &len);
    printf("recv msg: %s\n", recvbuff);

    // memset(recvbuff, 0, sizeof(recvbuff));
    // recvfrom(clientfd, recvbuff, sizeof(recvbuff), 0, (struct sockaddr*)&serveraddr, &len);
    // printf("recv msg: %s\n", recvbuff);

    close(clientfd);

    return 0;
}

UDP是一个传输层的协议,即在传输层无法解决数据可靠传输的特性。要达到数据可靠传输的效果,只能交给上层(应用层)来处理,因此UDP的通信要比TCP难得多,应用层必须要将TCP的功能实现一次。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐