Socket 编程

Socket 编程

一、前行必备

1.1 网络中进程之间如何通信

网络进程间的通信,首要解决的问题是如何唯一标识一个进程,否则通信无从谈起!

在本地可以通过进程 PID 来唯一标识一个进程,但是在网络中这是行不通的。其实 TCP/IP 协议族已经帮我们解决了这个问题,网络层的「IP 地址」可以唯一标识网络中的主机,而传输层的「协议 + 端口」可以唯一标识主机中的应用程序(进程)。

这样利用三元组「IP 地址、协议、端口」就可以唯一标识网络的进程了,网络中的进程通信就可以利用这个标志与其它进程进行交互。

使用 TCP/IP 协议的应用程序通常采用应用编程接口——Socket,来实现网络进程之间的通信。

1.2 文件描述符

在 Linux 中,一切皆文件。一个硬件设备也可以被映射为一个虚拟的文件,称为设备文件。例如,stdin 称为标准输入文件,它对应的硬件设备一般是键盘,stdout 称为标准输出文件,它对应的硬件设备一般是显示器。

「一切皆文件」的思想极大地简化了程序员的理解和操作,使得对硬件设备的处理就像普通文件一样。所有在 Linux 中创建的文件都有一个 int 类型的编号,称为文件描述符(File Descriptor,简称 FD)。使用文件时,我们只需要知道文件描述符就可以,例如,stdin 的描述符为 0,stdout 的描述符为 1。

在Linux中,socket 也被认为是文件的一种,和普通文件的操作没有区别,所以在网络数据传输过程中自然可以使用与文件 I/O 相关的函数。可以认为,两台计算机之间的通信,实际上是两个 socket 文件的相互读写。

文件描述符有时也被称为文件句柄(File Handle),但「句柄」主要是 Windows 中术语。

二、Socket 编程

2.1 总览

在开始讲解 Socket 编程前,先通过一张图片浏览一下网络进程间通信的流程:

image-20221007143150337

  • 服务器首先启动,稍后某个时刻客户启动,它试图连接到服务器。
  • 客户通过 send() 函数给服务器发送一段数据,服务器通过 recv() 函数接收客户发送的数据,并处理该请求,之后通过 send() 函数给客户发回一个响应。
  • 这个过程一直持续下去,直到客户关闭 Socket 连接,从而给服务器发送一个 EOF(文件结束)通知。服务器收到后接着也关闭与之相应的 Socket,然后结束运行或者等待新的客户连接。

2.2 socket()

2.2.1 函数介绍

为了执行网络 I/O,一个进程必须做的第一件事就是调用 socket() 函数,指定期望的通信协议类型(使用 IPv4 的 TCP、使用 IPv6 的 UDP 等)。

函数原型:int socket(int domain, int type, int protocol);

头 文 件:#include <sys/socket.h>

返 回 值:

  1. 调用成功后会返回一个小的非负整数(socket 描述符)。
  2. 调用失败返回 -1,并置 errno 为相应的错误码。。

参数描述:

  1. domain:即协议域,又称为协议族(family)。常用的协议族有:

    协议族 说明
    AF_INET IPv4 协议
    AF_INET6 IPv6 协议

    协议族决定了socket 的地址类型,在通信中必须采用对应的地址,如 AF_INET 决定了要用 32 位的 IPv4 地址与 16 位的端口号组合。

  2. type:指定 socket 类型。常用的 socket 类型有:

    socket 类型 说明
    SOCK_STREAM 字节流套接字
    SOCK_DGRAM 数据报套接字
  3. protocol:故名思意,就是指定协议。常用的协议有:

    协议 说明
    IPPROTO_TCP TCP 传输协议
    IPPROTO_UDP UDP 传输协议
    • 若将 protocol 置为 0,socket() 会自动选择 domain 和 type 对应的默认协议:

      \(domain \backslash^{type}\) SOCK_STREAM SOCK_DGRAM
      AF_INET IPPROTO_TCP IPPROTO_UDP
      AF_INET6 IPPROTO_TCP IPPROTO_UDP

2.2.2 函数使用

int main()
{
    // 创建TCP套接字
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == sockfd)
    {
        printf("fail to call socket, errno[%d, %s]\n", errno, strerror(errno));
        exit(0);
    }
}
int main()
{
    // 创建UDP套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (-1 == sockfd)
    {
        printf("fail to call socket, errno[%d, %s]\n", errno, strerror(errno));
        exit(0);
    }
}

当我们调用 socket() 创建一个 socket 描述符时,返回的 socket 描述符存在于协议族(address family,AF_XXX)空间中,但还没有一个具体的地址;如果想要给它赋值一个地址,就必须调用 bind() 函数。

2.3 bind()

2.3.1 函数介绍

socket() 函数用来创建套接字,确定套接字的各种属性,然后服务端要用 bind() 函数将套接字与特定的 IP 地址和端口绑定起来,只有这样,流经该 IP 地址和端口的数据才能交给套接字。

函数原型:int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

头 文 件:#include <sys/socket.h>

返 回 值:调用成功返回 0;出错返回 -1,并置 errno 为相应的错误码。

参数描述:

  1. sockfd:即 socket 描述字,它是通过 socket() 函数创建的、唯一标识一个socket。
  2. addr:socket 地址结构。
  3. addrlen:地址的长度。

大多数套接字函数都需要一个指向套接字地址结构的指针(addr)作为参数。每个协议族都定义它自己的套接字地址结构,这些结构的名字均以 sockaddr_ 开头,并以对应每个协议族的唯一后缀结尾。

2.3.2 函数使用

我们来看一个代码,将创建的套接字与 IP 地址 192.0.0.128、端口 8080 绑定:

#define IPADDR "192.0.0.128"    /* IP 地址 */
#define PORT    8080                /* 端口号 */

int main()
{
    // 将套接字与特定的IP地址和端口绑定起来
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port   = htons(PORT);
    inet_aton(IPADDR, &addr.sin_addr);
    int iBind = bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
    if (-1 == iBind)
    {
        printf("fail to call bind, errno[%d, %s]\n", errno, strerror(errno));
        close(sockfd);
        exit(0);
    }
}

2.3.3 sockaddr_in

在该代码中,我们使用了 IPv4 的地址结构 sockaddr_in,它的定义如下:

struct in_addr
{
    in_addr_t       s_addr;         // network byte ordered
}
struct sockaddr_in
{
    sa_family_t     sin_family;     // AF_INET

    in_port_t       sin_port;       // 16-bit TCP or UDP port number
                                    // network byte ordered

    struct in_addr  sin_addr;       // 32-bit IPv4 address
                                    // network byte ordered

    char            sin_zero[8];    // unused
}
  1. sin_family:和 socket() 的第一个参数的含义相同,取值也要保持一致。
  2. sin_port:16 位端口号,长度为 2Byte。有关端口号的赋值,有两点需要关注:
    • 理论上端口号的取值范围为 0~65535,但 0~1023 的端口一般由系统分配给特定的服务程序,例如 web 服务的端口为 70,FTP 服务的端口为 21,所以我们的程序尽量在 1024~65536 之间分配端口号。
    • 通过 htons 函数将端口号转化为网络字节序。
  3. sin_addr:是 in_addr 结构体类型的变量,表示 32 位 IP 地址,并通过 inet_aton 函数将其转化为网络字节序。
  4. sin_zero:剩余的 8 个字节,没有用,一般使用 memset() 函数填充为0。

2.3.4 sockaddr

但函数 bind() 的第二个参数 sockaddr,代码中却使用了 sockaddr_in,然后再强制转换为 sockaddr,这是为什么呢?在解释之前,我们先来看一下 sockaddr 长什么样:

struct sockaddr
{
    sa_family_t     sa_family;      // address family: AF_XXX
    char            sa_data[14];    // protocol-specific address
}

下图是 sockaddr 与 sockaddr_in 的对比(括号中的数字表示所占用的字节数):

image-20221016175826592

sockaddr 和 sockaddr_in 的长度相同,都是16 个字节,但是 sockaddr 的 sa_data 区域需要同时指定 IP 地址和端口号,例如「192.0.0.128:8080」。遗憾的是没有相关函数将这个字符串转换成需要的形式,也就很难给 sockaddr 类型的变量直接赋值,所以使用 sockaddr_in 来代替。这两个结构体的长度相同,强制转换类型时也不会丢失字节,也没有多于的字节。

可以认为,sockaddr 是一个通用的套接字结构体,可以用来保存多种类型的 IP 地址和端口号,而 sockaddr_in 是专门用来保存 IPv4 地址的结构体。另外还有 sockaddr_in6,用来保存 IPv6 地址。

2.4 connect()

2.4.1 函数介绍

客户端通过 connect() 函数来建立与服务端的连接

函数原型:int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

头 文 件:#include <sys/socket.h>

返 回 值:调用成功返回 0;出错返回 -1,并置 errno 为相应的错误码。

参数描述:

  1. sockfd:即 socket 描述字,它是通过 socket() 函数创建的、唯一标识一个socket。
  2. addr:socket 地址结构。
  3. addrlen:地址的长度。

2.4.2 函数使用

#define IPADDR "192.0.0.128"    /* IP 地址 */
#define PORT    8080                /* 端口号 */

int main()
{
    // 将套接字与特定的IP地址和端口号建立连接
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(PORT);
    inet_aton(IPADDR, &addr.sin_addr);
    int iConn = connect(sockfd, (struct sockaddr *)&addr, sizeof(addr));
    if (-1 == iConn)
    {
        printf("fail to call connect, errno[%d, %s]\n", errno, strerror(errno));
        close(sockfd);
        exit(0);
    }
}

有关地址结构的说明,参考 bind()。

2.5 listen()

2.5.1 函数介绍

函数原型:int listen(int sockfd, int backlog);

头 文 件:#include <sys/socket.h>

返 回 值:调用成功返回 0,出错返回 -1。

参数描述:

  1. sockfd 为需要进入监听状态的套接字。
  2. backlog 为请求队列的最大长度。
    • 当套接字正在处理客户端请求时,如果有新的请求进来,套接字是没法处理的,只能把它先放到缓冲区中,待当前请求处理完毕后,再从缓冲区中读取出来处理。如果不断有新的请求进来,它们就按照先后顺序在缓冲区中排队,直到缓冲区满,而这个缓冲区,就称为请求队列。

listen() 函数仅由服务端调用,使套接字进入被动监听状态。所谓被动监听,是指在没有客户端请求时,套接字处于「睡眠」状态;只有当接收到客户端的请求时,套接字才会被「唤醒」来响应请求。

缓冲区的长度(能存放多少个客户端的请求)可以通过 listen() 函数的backlog参数指定,但究竟为多少并没有什么标准,根据你的需求来定。如果将 backlog 的值设置为 SOMAXCONN,就由系统来决定请求队列长度,这个值一般比较大,可能是几百或者更多。当请求队列满时,就不再接收新的请求;对于 linux,客户端会受到 ECONNREFUSED 错误。

注意:listen()函数只是让套接字处于监听状态,并没有接收请求。接收请求需要使用accept()函数。

2.5.2 函数使用

#define BACKLOG 10
int main()
{
    // 让套接字进入被动监听状态
    int iListen = listen(sockfd, BACKLOG);
    if (-1 == iListen)
    {
        printf("fail to call listen, errno[%d, %s]\n", errno, strerror(errno));
        close(sockfd);
        exit(0);
    }
}

2.6 accept()

2.6.1 函数介绍

函数原型:int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

头 文 件:#include <sys/socket.h>

返 回 值:

  1. 调用成功后会返回一个小的非负整数(描述符)。
  2. 调用失败返回 -1,并置 errno 为相应的错误码。

参数描述:它的形参列表与 bind() 和 connect() 是相同的,区别在于该函数的 addr 返回的是已连接的对端的(客户端)协议地址(IP 地址和端口号),如果我们对「对端的协议地址」不感兴趣,可将 addr 和 addrlen 均置为 NULL。

accept() 函数由服务端调用。如果调用成功,将返回一个新的描述符,代表与所返回客户(addr)的TCP连接。在讨论 accept() 函数时,我们称它的第一个参数为监听套接字描述符(由 socket() 创建,随后作用于 bind() 和 listen() 的第一个参数的描述符),称它的返回值为已连接套接字描述符

区分这两个套接字非常重要:

  • 一个服务器通常仅仅创建一个「监听套接字」,它在该服务器的生命周期内一直存在。
  • 内核为每个由服务器进程接受的客户连接创建一个「已连接套接字」,当服务器完成对某个给定客户的服务时,相应的「已连接套接字」就被关闭了。

2.6.2 函数使用

用法一:不关注带对端的协议地址

int main()
{
    // 当套接字处于监听状态时,可以通过 accept 函数来接收客户端的请求
    int acceptfd = accept(sockfd, NULL, NULL);
    if (-1 == acceptfd)
    {
        printf("fail to call accept, errno[%d, %s]\n", errno, strerror(errno));
        close(sockfd);
        exit(0);
    }
}

用法二:需要获取对端的协议地址

#define STRING_LEN_16     16

/* 将 IP 地址的网络字节序转化为点分十进制字符串 */
char *_inet_ntoa(struct in_addr *addr, char *ipAddr, int len)
{
    if (NULL == addr || NULL == ipAddr || 16 > len)
    {
        printf("invalid param\n");
        return NULL;
    }
    unsigned char *tmp = (unsigned char*)addr;
    snprintf(ipAddr, len, "%d.%d.%d.%d", tmp[0], tmp[1], tmp[2], tmp[3]);
    return ipAddr;   
}
int main()
{
    // 当套接字处于监听状态时,可以通过 accept 函数来接收客户端的请求
    struct sockaddr_in peerAddr;
    socklen_t peerAddrLen = sizeof(struct sockaddr_in);
    int acceptfd = accept(sockfd, (struct sockaddr *)&peerAddr, &peerAddrLen);
    if (-1 == acceptfd)
    {
        printf("fail to call accept, errno[%d, %s]\n", errno, strerror(errno));
        close(sockfd);
        exit(0);
    }
    char peerIPAddr[STRING_LEN_16];
    _inet_ntoa(&peerAddr.sin_addr, peerIPAddr, STRING_LEN_16);
    printf("peer client address [%s:%u]\n", peerIPAddr, ntohs(peerAddr.sin_port));
}

2.7 send()

函数原型:ssize_t send(int sockfd, const void *buf, size_t len, int flags);

头 文 件:#include <sys/socket.h>

返 回 值:成功返回发送的字节个数;失败返回 -1,并置 errno 为响应的错误码。

参数描述:

  1. sockfd:对于服务端而言,传入「已连接套接字描述符」;对于客户端而言,传入套接字描述符。
  2. buf:需要发送的数据。
  3. len:指定要发送的数据大小。
  4. flags:标志位,一般置为 0。

2.8 recv()

函数原型:ssize_t recv(int sockfd, void *buf, size_t len, int flags);

头 文 件:#include <sys/socket.h>

返 回 值:

  1. 成功返回接收到的字符个数。
  2. 失败返回 -1,并置 errno 为响应的错误码。
  3. 对端关闭则返回 0。

参数描述:

  1. sockfd:对于服务端而言,传入「已连接套接字描述符」;对于客户端而言,传入套接字描述符。
  2. buf:指明一个缓冲区,该缓冲区用来存放接收到的数据;
  3. len:指定缓冲区 buf 的大小。
  4. flags:标志位,一般置为 0。

image-20221016205605552

2.9 close()

函数原型:int close(int fd);

头 文 件:#include <unistd.h>

功 能:关闭一个文件描述符。

三、一个完整的 Demo

3.1 Server

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <errno.h>
#include <unistd.h>

#define IPADDR "192.0.0.128"    /* IP 地址 */
#define PORT    8080                /* 端口号 */
#define STRING_LEN_16     16
#define STRING_LEN_64     64

char *_inet_ntoa(struct in_addr *addr, char *ipAddr, int len)
{
    if (NULL == addr || NULL == ipAddr || 16 > len)
    {
        printf("invalid param\n");
        return NULL;
    }
    unsigned char *tmp = (unsigned char*)addr;
    snprintf(ipAddr, len, "%d.%d.%d.%d", tmp[0], tmp[1], tmp[2], tmp[3]);
    return ipAddr;   
}
int main()
{
    // 创建TCP套接字
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == sockfd)
    {
        printf("fail to call socket, errno[%d, %s]\n", errno, strerror(errno));
        exit(0);
    }

    // 将套接字与特定的IP地址和端口绑定起来
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port   = htons(PORT);
    inet_aton(IPADDR, &addr.sin_addr);
    int iBind = bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
    if (-1 == iBind)
    {
        printf("fail to call bind, errno[%d, %s]\n", errno, strerror(errno));
        close(sockfd);
        exit(0);
    }

    // 让套接字进入被动监听状态
    int iListen = listen(sockfd, 10);
    if (-1 == iListen)
    {
        printf("fail to call listen, errno[%d, %s]\n", errno, strerror(errno));
        close(sockfd);
        exit(0);
    }

    // 当套接字处于监听状态时,可以通过 accept 函数来接收客户端的请求
    struct sockaddr_in peerAddr;
    socklen_t peerAddrLen = sizeof(struct sockaddr_in);
    int connfd = accept(sockfd, (struct sockaddr *)&peerAddr, &peerAddrLen);
    if (-1 == connfd)
    {
        printf("fail to call accept, errno[%d, %s]\n", errno, strerror(errno));
        close(sockfd);
        exit(0);
    }
    char peerIPAddr[STRING_LEN_16];
    _inet_ntoa(&peerAddr.sin_addr, peerIPAddr, STRING_LEN_16);
    printf("peer client address [%s:%u]\n", peerIPAddr, ntohs(peerAddr.sin_port));

    while (1)
    {
        // 读取客户端发送的数据
        char buf[STRING_LEN_64];
        int n = recv(connfd, buf, STRING_LEN_64 - 1, 0);
        buf[n] = '\0';

        if (0 == n) // n为0表示对端关闭
        {
            printf("peer close\n");
            break;
        }

        printf("recv msg from client : %s\n", buf);

        sleep(2);
        
        // 向客户端发送数据
        char str[] = "recved~";
        printf("send msg to client : %s\n", str);
        send(connfd, str, strlen(str), 0);
    }

    // 交互结束,关闭套接字
    close(connfd);
    close(sockfd);

    return 0;
}

3.2 Client

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <errno.h>
#include <unistd.h>

#define IPADDR "192.0.0.128" /* 服务端 IP 地址 */
#define PORT   8080              /* 服务端 端口号 */
#define STRING_LEN_64 64

int main()
{
    // 创建TCP套接字
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    printf("sockfd = %d\n", sockfd);
    if (-1 == sockfd)
    {
        printf("fail to call socket, errno[%d, %s]\n", errno, strerror(errno));
        exit(0);
    }

    // 将套接字与特定的IP地址和端口号建立连接
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(PORT);
    inet_aton(IPADDR, &addr.sin_addr);
    int iConn = connect(sockfd, (struct sockaddr *)&addr, sizeof(addr));
    if (-1 == iConn)
    {
        printf("fail to call connect, errno[%d, %s]\n", errno, strerror(errno));
        close(sockfd);
        exit(0);
    }

    // 向服务端发送数据
    char str[] = "hello world";
    printf("send msg to server : %s\n", str);
    send(sockfd, str, strlen(str), 0);

    // 接收服务端相应的数据
    char buf[STRING_LEN_64];
    int n = recv(sockfd, buf, STRING_LEN_64 - 1, 0);
    buf[n] = '\0';
    printf("recv msg from server : %s\n", buf);

    // 交互结束,关闭套接字
    close(sockfd);

    return 0;
}

3.3 客户端 connect 调用报 113 错误

运行环境:

  1. 虚拟机 CentOS 7 运行 Server
  2. 虚拟机 CentOS 6 运行 Client

当 CentOS 6 启动 Client 时,运行到 connect 函数报错:fail to call connect, errno[113, No route to host]。排查了一下,报这个错误的原因是运行服务端的 CentOS 7 未关闭防火墙,相关指令如下:

  • firewall-cmd --state:查看防火墙状态

    • running 表示防火墙处于开启状态

    • not running 表示防火墙处于关闭状态

  • systemctl stop firewalld.service:关闭防火墙

  • systemctl start firewalld.service:打开防火墙

CentOS 6,相关指令如下:

  • service iptables status:查看防火墙状态
    • 如果防火墙处于关闭状态,则提示:iptables:未运行防火墙。
  • service iptables stop:关闭防火墙
  • service iptables start:打开防火墙

四、POSIX 规范要求的数据类型

最后,附上 POSIX 规范要求的数据类型。

头文件:#include <netinet/in.h>

数据类型 说明 大小
int8_t 带符号的 8 位整数 1 Byte
uint8_t 无符号的 8 位整数 1 Byte
int16_t 带符号的 16 位整数 2 Byte
uint16_t 无符号的 16 位整数 2 Byte
int32_t 带符号的 32 位整数 4 Byte
uint32_t 无符号的 32 位整数 4 Byte
sa_family_t 套接字地址结构的地址族 2 Byte
socklen_t 套接字地址结构的长度,一般为 uint32_t 4 Byte
in_addr_t IPv4 地址,一般为 uint32_t 4 Byte
in_port_t TCP 或 UDP 端口号,一般为 uint16_t 2 Byte
ssize_t 有符号整型;在 32位 机器上等同与 int,在 64 位机器上等同与 long int。

参考资料

posted @ 2022-10-16 21:20  MElephant  阅读(2209)  评论(0编辑  收藏  举报