• 初学Socket网络编程的过程中,发现此部分学习有大量细节需要掌握,因此笔记不可忽略!
  • 主要参考文章:《UNIX网络编程卷一》《TCP/IP网络编程》
  • 本文只记录重点内容和个人理解,系统学习请移步《UNIX网络编程》
#include<stdio.h>
#include<unistd.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<string.h>
int main()
{
    int servSock = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
    sockaddr_in servAddr;
    memset(&servAddr,0,sizeof(servAddr)); //<string.h>
    servAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
    servAddr.sin_port = htons(1234);
    servAddr.sin_family = AF_INET;
    
    bind(servSock,(sockaddr*)&servAddr,sizeof(servAddr));
    
    listen(servSock,20);
    
    sockaddr_in clntAddr;
    socklen_t clntAddrSize = sizeof(clntAddr);
    int clntSock = accept(servSock,(sockaddr*)&clntAddr,&clntAddrSize);
    char msg[] = "Hello World!";
    write(clntSock,msg,sizeof(msg)); //<unistd.h>
    
    close(clntSock);
    close(servSock);
}

过程概述:

  1. 调用 socket 来创建一个监听套接字
  2. 创建 socket 的身份证(servAddr),指明 IP 地址和端口
  3. 将身份证(servAddr)绑定(bind)到实体(servSock),这样这个套接字被指定了地址和端口
  4. 监听该套接字,时刻准备接受客户端发来的 连接请求
  5. 接受(accept)客户端发来的连接请求,并返回一个新的套接字(clntSock)用来和客户端收发消息
  6. 消息互动(write 或 read)
  7. 关闭套接字(close)

监听套接字只用来接收客户端发来的连接请求,并不用来通信! 用来通信的是 accept 返回的套接字(已连接套接字),即 clntSock,其信息被保存在 clntAddr 中监听套接字和已连接套接字有本质区别。

相关函数和结构体详解:

socket

  • 该函数返回主动套接字,经过 listen 函数转换后才会成为监听套接字。
  • protocol 敲定最终协议 。一般通过前两个参数的组合就能自动推断出最后的协议类型, 但如果前两个参数无法组合出有效协议,则由该参数决定使用何种协议; 如果组合有效,则该参数可直接为 0

bind

  • 该函数将 addr 地址结构所包含的信息绑定到 sockfd 套接字上,相当于为套接字办理身份证。

  • bind 可以手动指定 IP 地址或端口,可以两者都指定,也能两者都不指定,如果不手动指定,则按以下方式处理:

    对于 IPv4,通配地址为宏 INADDR_ANY;对于 IPv6,为 in6addr_any

    servSock.sin_addr.s_addr = htonl(INADDR_ANY); //INADDR_ANY一般为0,可以不用htonl
    
  • 如果绑定 INADDR_ANY,此时服务器在自己的所有 IP 上(如果是多宿)监听,等到客户发来的 SYN 报文时,再绑定到该报文指定的对端 IP

  • 注意,对于 TCP 而言,如果不手动指定端口,则在调用 listen (server) 或 connect (client) 时,内核会选择一个临时端口 。对于客户端,我们一般让内核来绑定套接字的端口(除非需要预留端口);对于服务器端,很少让内核自行决定端口,因为服务器是通过它们的总所周知端口而被外界认识的

  • 对于 TCP 客户端,由内核绑定 IP 地址;对于 TCP 服务器端,如果没有手动绑定,则内核就把客户发送的 SYN 报文中的目的IP地址作为绑定的 IP 地址。

  • 不论是服务器还是客户端,如果绑定指定端口失败,则随机分配一个端口 ,参见TIME_WAIT 与 SO_REUSEADDR

  • 如果服务器的某个端口刚断开连接,处于 TIME_WAIT 状态,则默认情况下不能立即再次绑定该端口,否则返回 EADDRINUSE 错误 ,参见TIME_WAIT 与 SO_REUSEADDR

  • 注意 bind 与 accept 中后两个参数类型的差异,前者是值类型,后者是值-结果类型(调用函数后,参数会被改变,即作为返回值)。

listen

  • 调用 socket 后,默认为主动套接字,调用 listen 后,则转变为监听套接字。
  • 该函数只用于服务器端。调用 listen 函数使套接字从 CLOSED 状态转变为 LISTEN 状态 ,参见TCP三次握手
  • bocklog 参数用来指定套接字队列的最大容纳个数。backlog 一直没有正式的定义,不同的操作系统的实现也有所不同;内核为每个监听套接字维护两个队列:未完成连接队列和已完成连接队列 。关于这两点,详见socket等待队列

accept

  • 注意和 bind 原型的差别!该函数有三个返回值,一个是新建立的已连接套接字 ,一个是客户端套接字 clntAddr,另一个则是 clntAddr 的长度 addrLen;注意,addrLen 是输入-输出参数,传参前需要将 clntAddr 的大小赋给 addrLen。如果对客户端不感兴趣,则后面两个参数可以直接传入 NULL

    严格来说,对端套接字可能是 IPv4,也可能是 IPv6,为了能够容纳这两者,clntAddr 最好为通用套接字结构体 sockaddr_storage

  • accept 从已完成队列中取出一个连接;仅仅只是取出一个完成了三次握手的连接,并返回绑定此连接的套接字

  • 上面这句话说明了一个很重要的事实:accept 与三次握手无关!三次握手是底层网络协议栈自动完成的(当然,第一次握手是 connect 发起的)!换句话说,即使没有 accept 也能完成三次握手!

    做个实验便知:

    //======!!!代码中的Bind、Listen等函数是博主自己包装的,读者可以直接改成小写的形式!!!=======
    //server
    int main(){
     int sock_lsn = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
     struct sockaddr_in addr_lsn;
     memset(&addr_lsn, 0, sizeof(addr_lsn));
     addr_lsn.sin_addr.s_addr = htonl(INADDR_ANY);
     addr_lsn.sin_port = htons(12345);
     addr_lsn.sin_family = AF_INET;
     Bind(sock_lsn, (struct sockaddr*)&addr_lsn, sizeof(addr_lsn));
     Listen(sock_lsn, 20);
     //注意,没有accept和close
     while(1);
    }
    //=================================
    //client
    int main(){
     struct sockaddr_in addr_clnt;
     memset(&addr_clnt, 0 , sizeof(addr_clnt));
     addr_clnt.sin_port = htons(12345);
     addr_clnt.sin_addr.s_addr = inet_addr("127.0.0.1");
     addr_clnt.sin_family = AF_INET;
     for(int i = 0; i < 10; i++) //创建10个进程并发送连接
     {
         if(0 == fork())
         {
             int sock_clnt = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
             Connect(sock_clnt, (struct sockaddr*)&addr_clnt, sizeof(addr_clnt));
             sleep(10);//暂停10s,以便我们观察连接状态
             close(sock_clnt);
             exit(0);
         }
     }
    }
    

    编译后运行,结果如下:


    因为是在同一主机上运行 server 和 client,netstat 分别以客户端和用户端为角度输出了结果,所以有 20 个条目,实际上是 10 条连接,我们只需要看一个纵列的红色条目即可。显然,即使服务器端没有调用 accept,两端仍然建立了连接。这再次说明,accept 只是从全连接队列中取出一个四元组,并绑定到一个新的套接字描述符而已
    ——四元组:即本端 IP、本端端口、对端 IP、对端端口,用来唯一确定一个 TCP 连接

connect

  • connect 函数仅在客户端使用,调用此函数时,内核会为客户端绑定套接字,端口随机,这个过程相当于 bind。

  • connect 会激发三次握手,只在连接成功或出错时返回。出错有以下三种情况:

    1. 客户端迟迟未收到对 SYN 报文的 ACK,此时返回 ETIMEDOUT 错误。

      “迟迟”是多久?SYN 重传次数由 tcp_syn_retries 控制。通常 ,第一次超时重传是在 1 秒后,第二次超时重传是在 2 秒,第三次超时重传是在 4 秒后。即,每次超时的时间是上一次的 2 倍。

    2. 对 SYN 报文的回复是 RST,表明服务端在我们指定的端口上没有套接字处于监听状态,返回 ECONNREFUSED 错误。

    3. SYN 报文在某个中间路由器上引发了“目的地不可达”的 ICMP 错误,重复发送几次 SYN 后,如果在规定时间内仍无响应,则返回错误。

  • 如果 connect 失败,则该套接字不能再重新调用 connect,只能生成新的套接字再 connect 。

    为什么不能对其重调用 connect 呢?其实如果联想到上文中的 TIME_WAIT ,那么这个问题就有思路了。考虑这样一种情况:当发送 SYN 后,由于网络拥堵,在 MSL(报文最大生存时间) 的前一刻到达了服务器,然后服务器发送 ACK 报文,然后 ACK 也在 MSL 前到达客户端;但是,将近 2MSL 已经挺久了,客户端没有等到这么久就返回 connect 错误,同时重新在该套接字上发起 connect 并发送新的 SYN;问题来了,此时上一个 ACK 刚好到达客户端!换句话说,第二次发送的 SYN 得到的回复是第一次的 ACK,这可能导致某些错误。
    以上只是本人的猜想,仅作为一种思路。

close

  • 调用 close 后,该套接字(文件描述符)的引用计数减 1如果引用计数仍大于 0,则不会给对端发送 FIN 报文,等于 0 才会引发挥手 。这一点在多进程网络编程中有重要作用。
  • close 函数的默认操作是立刻关闭套接字并返回,但如果发送缓冲区中还有数据残留,则内核会将这些数据继续发送给对端。close 的行为可以由 SO_LINGER 控制。 关于 SO_LINGER ,参见SOCKET常见选项
  • 对于主动关闭端而言,发送 FIN 并收到对方回复的 ACK 报文后进入 FIN_WAIT2 状态,如果主动关闭端是通过 close() 函数关闭连接的,则 FIN_WAIT2 状态只会持续 tcp_fin_timeout 指定的秒数(默认 60s);如果是通过 shutdown() 关闭连接的,则 FIN_WAIT2 可以一直保持。

shutdown

  • close 同时终止读与写两个方向的数据传送,而 shutdown 用来指定关闭一个方向的数据传送。
  • 使用 shutdown 关闭读后,缓冲区的所有数据都被丢弃,而且后续收到的数据会先被确认然后悄然丢弃,不会返回 RST。
  • shutdown 的 SHUT_WR 不管套接字的引用计数是否为 0,直接发起挥手。

地址转换函数

in_addr_t inet_addr (const char *str)
    若字符串有效,则返回二进制值,无效则返回INADDR_NONE(其值为-1)

本函数将字符串 str 转换为 32 位的网络字节序二进制值。32 位说明它只能用于 IPv4 地址转换。该函数不能处理 255.255.255.255 ,详细原因参见《UNP》P67;另外,此函数已经被废弃,最好不再使用。

int inet_aton (const char *str, struct in_addr *addr)
    若字符串有效,则返回1,否则返回0

本函数将字符串 str 转换为 32 位的网络字节序二进制值并保存在 addr 结构体中。

char *inet_ntoa (struct in_addr addr)

将 32 位的网络字节序二进制值转换为点分十进制字符串。注意,inet_ntoa() 是不可重入的,该函数返回的字符串是储存在静态内存中的,第二次调用该函数时将覆盖之前的结果。因此,当我们通过该函数的返回值取得字符串后必须马上转移到其他地方储存。

int inet_pton (int family, const char * str, void * addr)
    成功则返回1,若str不是有效表达式则返回0,失败则-1

该函数同时适用于 IPv4 和 IPv6,因此第三个参数为 void*,因为实参既可以为 in_addr 也可以为 in6_addr 。

char *inet_ntop (int family, const void * addr, char * str, socklen_t len)
    成功则返回字符串指针,否则返回NULL,并置errno为ENOSPC

该函数同时适用于 IPv4 和 IPv6,将网络字节序二进制值转换为点分十进制字符串。其中 str 不能为 NULL,其必须为 str 分配空间并指定大小。这个大小可使用 <netinet/in.h> 中的宏指定:

#define INET_ADDRSTRLEN 16
#define INET6_ADDRSTRLEN 46

第二个参数 addr 的结构体类型为 in_addrin6_addr ,后面有例子。

另外,inet_ptoninet_ntop 总是可重入的,应尽量使用 inet_ntop 来代替 inet_ntoa

getsockname与getpeername

int getsockname (int fd, struct sockaddr *local_addr, socklen_t *len)
int getpeername (int fd, struct sockaddr *peer_addr, socklen_t *len)
    成功返回0,否则-1

参数 len 是值-参数类型(既做参数,也储存返回值),作为返回值时,传递实际地址结构体的大小 。什么是实际地址结构体?是这样的:当你 getpeername 时,你不知道对面是 IPv4 还是 IPv6,如果此时你将 sockaddr_in 类型(IPv4)的结构体作为第二个参数,len 为 sockaddr_in 的长度,那么问题来了——万一对方是 IPv6 ,那这个 sockaddr_in 就无法完全存储 sockaddr_in6 ,一部分会被截断。同时,len 被修改为 sockaddr_in6 的大小。如何解决这个问题呢?很简单,使用通用结构 sockaddr_storage 来储存实际地址结构体,它能够承载任何套接字地址结构,因此不会被截断 。如下:

    struct sockaddr_storage saddr;
    struct sockaddr_in* pinaddr;
    struct sockaddr_in6* pin6addr;
    socklen_t len = sizeof(saddr);
    int ret = getpeername(sock, (struct sockaddr*)&saddr, &len);
    if(ret == -1)
        err_quit("getpeername err\n");
    if(saddr.ss_family == AF_INET)
    {
        pinaddr = (struct sockaddr_in*)&saddr;
        char str[INET_ADDRSTRLEN];
        printf("IPv4:%s\n", inet_ntop(AF_INET,&pinaddr->sin_addr, str, sizeof(str)));
        printf("port:%u\n", ntohs(pinaddr->sin_port));
    }
    if(saddr.ss_family == AF_INET6)
    {
        pin6addr = (struct sockaddr_in6*)&saddr;
        char str[INET6_ADDRSTRLEN];
        printf("IPv6:%s\n", inet_ntop(AF_INET,&pin6addr->sin6_addr, str, sizeof(str)));
        printf("port:%ud\n", ntohs(pinaddr->sin_port));
    }

有以下理由需要这两个函数:

  • 客户端一般不调用 bind,端口和地址都在 connect 时由内核分配,所以需要通过 getsockname 获得本端信息。
  • 服务器端经常绑定通配地址 INADDR_ANY ,所以需要使用 getsockname 来确定本端绑定的地址。注意,上文说过,当绑定通配地址时,监听套接字的最终绑定结果是在收到对端发来 SYN 报文后才确定的,所以此时 getsockname 的作用对象只能是已连接套接字,而不能是监听套接字。
  • 如果一个服务器程序是由执行过 accept 的某个进程调用 exec 而得到,那么这个服务器程序只能通过 getpeername 来获取对端信息 。inetd 派生 Telnet 服务器就是这样的情况:

字节序转换函数

为了避免在不同字节序主机之间传送数据时发生数据解释错乱,网络传输统一使用大端字节序,即高位数据在低字节,低位数据在高字节。因此,传送数据前我们必须使用以下函数对数据转换字节序:

uint32_t ntohl (uint32_t netlong)
uint16_t ntohs (uint16_t netshort)
    net to host,将网络字节序转为主机字节序
uint32_t htonl (uint32_t hostlong)
uint16_t htons (uint16_t hostshort)
    host to net,将主机字节序转为网络字节序

应该有初学者会疑惑,似乎这几个函数只在绑定端口和 IP 时才使用,其他时候几乎没有用,这是为什么?难道传输数据时,网络栈会自动将我们的数据转换为网络序?这个问题也困惑了我好些时候,详细请移步网络字节序及其注意事项

其他问题

bind为什么将 sockaddr_in 转换为 sockaddr**

为了实现“多态”,即,使用 bind 函数处理多种协议类型。

sockaddr_in 是 IPv4 套接字地址结构体,定义如下:

struct sockaddr_in {
    sa_family_t sin_family;  // 地址族,一般为AF_INET
    uint16_t sin_port;       // 端口号,网络字节序
    struct in_addr sin_addr; // IPv4地址,网络字节序
    char sin_zero[8];        // 未使用,填充0
};
//大小为16字节

sockaddr 是通用套接字地址结构体,定义如下:

struct sockaddr {
    sa_family_t sa_family;    // 地址族
    char sa_data[14];         // 具体地址信息
};
//大小为16字节

实现多态的关键在于 sa_family 成员。所有类型的套接字结构体都保证 sa_family 成员在最前面,这样即使转换后也能正确地获取地址族类型。同时,bind 的第三个参数 len 也很重要,系统调用将第二个参数 sockaddr* 指针指向的 len 大小的数据传入内核空间 。这样,内核有了正确的地址族类型和完整的地址信息,就可以针对各种不同协议类型进行套接字绑定。

你可能和我一样,认为 len 参数有些鸡肋,因为内核完全可以根据 sa_family 确定地址族,而地址族一确定,相应的套接字地址结构体大小就能确定,比如 sa_family 若为 AF_INET,那么就能判断传入的结构体一定是 sockaddr_in 。那为什么还有专门传入 len 呢?实际上,我们设想的前提就有错,地址族确定并不代表套接字地址结构体就能确定,比如 UNIX 域结构和数据链路结构就是可变长度的(如下图) 。因此,为了向内核传入正确、完整的结构体信息,就必须手动传入 len 。

另外,我们在前文中也提到了图中的 sockaddr_storage 结构体,它能够承载任意大小的地址结构体,并满足某些地址结构体的对齐需要 ,定义如下:

struct sockaddr_storage {
    sa_family_t ss_family;      // 地址族
    unsigned long __ss_align;   // 对齐要求
    char __ss_padding[128 - sizeof(unsigned long)];  // 填充
};

除了 ss_family 成员,其他两个成员对用户透明。

最后笔者仍有一个问题:为什么 sockaddr_in 有一个 8 字节的填充?历史原因?效率问题?目前没有找到一套“自圆其说”的说法。

文章作者: 极简
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 后端技术分享
Socket网络编程
喜欢就支持一下吧