深入解析网络编程:Socket基础与操作详解

一、Socket理解

网络程序设计全靠套接字(Socket)接受和发送信息。Socket的英文愿意是“孔”,“插座”。我们可以将Socket比作电话插座。在这个比喻中,电话线相当于网络传输介质,电话机相当于网络中的计算机或设备,而Socket就是它们之间的接口,负责连接和通信。

在这里插入图片描述

以下是这个比喻的进一步解释:

  • 电话线:在网络中,电话线可以被看作是物理的传输介质,如以太网电缆、光纤或无线信号,它们负责传输数据包。
  • 电话机:在网络中,电话机对应于网络中的主机或服务器,它们通过Socket发送和接收信息。
  • 插座(Socket):在电话系统中,插座是连接电话机和电话线的接口。在网络编程中,Socket是软件接口,它允许应用程序通过标准的API发送和接收数据。

通过这个比喻,我们可以理解以下几点:

  • 连接建立:就像电话机通过插座连接到电话线一样,网络程序通过创建Socket来建立到网络的其他部分的连接。
  • 数据传输:电话机的声音通过插座和电话线传输到另一台电话机,同样,网络程序通过Socket发送和接收数据包。
  • 通信协议:电话系统有它自己的规则和协议来确保通话的顺利进行,网络中的Socket也遵循特定的通信协议(如TCPUDP)来保证数据的正确传输。

Socket(套接字)作为网络程序设计的核心组件,它负责实现信息的接收和发送。我们继续将Socket比作电话系统中的插座,以便我们理解:

在网络通信中,两个相互通信的进程就好比电话通话的双方。电话系统的区号类似于网络中的地址,标识着不同的网络区域。每个区域内的进程组成了一个网络。区内的单位交换机相当于网络中的主机,而主机的IP地址则是对应于交换机的位置。那么Socket号与电话号码就可以类比为每个用户通过电话号码来标识,而在网络中,每个Socket通过一个唯一的Socket号来标识。

通信过程:

  • 申请Socket号:就像打电话前需要一部电话机,网络通信前,进程需要申请一个Socket号。
  • 拨号与连接请求:知道对方电话号码后,拨号呼叫相当于在网络中发起连接请求。如果对方在不同的网络区域,还需要提供网络地址(区号)。
  • 通话与数据传输:一旦连接建立,双方可以进行通话,这与网络中的数据传输过程相似。
  • 结束通话与关闭Socket:通话结束后挂断电话,相当于在网络通信结束时关闭Socket。

Socket的抽象概念

  • 端点:Socket为进程通信提供了一个端点,就像电话机为通话提供了一个接口。
  • Socket描述:每个Socket由半相关描述(协议、本地地址、本地端口)和全相关描述(协议、本地地址、本地端口、远程地址、远程端口)来定义。
  • 操作系统分配Socket号:操作系统为每个Socket分配一个本地唯一的Socket号。

客户—服务器模型

  • 客户Socket:客户端程序在需要通信时随机申请一个Socket号,并主动发起连接请求,类似于打电话的人。
  • 服务器Socket:服务器拥有一个全局公认的Socket,可以接收来自任何客户的连接请求和信息请求,类似于拥有固定电话号码的被呼叫方。

Socket解决连接问题:在客户—服务器模型中,Socket机制确保了两个随机进程之间能够建立通信连接。没有Socket的固定标识,就像没有电话号码一样,无法建立通话。

在我们所说的电话系统中,一般用户就只能感受到本地电话机和对方电话号码的存在,建立童话二点过程、话音传输的过程以及整个电话系统的技术细节对它都是同名的,这也与Socket类似。Socket利用网间网通信设施实现进程通信,而且不关心通信设施的细节。

也就是说Socket实质上提供了进程通信的端点。进程通信之前,双方首先必须各自创建一个端点,否则是没有办法建立联系并相互通信的。就如果打电话之前,双方都必须有一个电话一样。

在Socket编程中,存在半相关和全相关的概念来描述Socket的不同方面:

半相关描述指的是Socket在本地主机上的属性,它包括以下三个部分:

  • 协议:Socket使用的通信协议,如TCP(传输控制协议)或UDP(用户数据报协议)。
  • 本地地址:Socket绑定的本地网络接口的IP地址。
  • 本地端口:Socket在本地主机上监听的端口号。

半相关描述可以表示为: 协议 , 本地地址 , 本地端口 { 协议 , 本地地址 , 本地端口 } 协议,本地地址,本地端口。这个描述足以在本地主机上唯一标识一个Socket。

全相关描述不仅包括Socket在本地主机上的属性,还包括与它通信的远程主机的属性。它由以下五个部分组成:

  • 协议:与半相关描述相同,Socket使用的通信协议。
  • 本地地址:与半相关描述相同,Socket绑定的本地网络接口的IP地址。
  • 本地端口:与半相关描述相同,Socket在本地主机上监听的端口号。
  • 远程地址:与本地Socket通信的远程主机的IP地址。
  • 远程端口:远程主机上Socket监听的端口号。

全相关描述可以表示为: 协议 , 本地地址 , 本地端口 , 远程地址 , 远程端口 {{ 协议 , 本地地址 , 本地端口 , 远程地址 , 远程端口 }} 协议,本地地址,本地端口,远程地址,远程端口

这个描述在通信过程中用于唯一标识两个Socket之间的连接。在面向连接的网络通信中,如TCP,全相关描述在建立连接时非常重要,因为它指定了通信的两个端点。而对于无连接的网络通信,如UDP,虽然Socket本身是无连接的,但全相关描述仍然用于发送和接收数据包,以确定数据的目的地。

每一个Socket有一个本地的唯一Socket号,由操作系统分配。


二、Socket的三种类型

根据通信方式和数据传输的特性,Socket 通常分为三种主要类型:流式套接字(Stream Socket,SOCK_STREAM),数据报套接字(Datagram Socket,SOCK_DGRAM)和原始套接字(Raw Socket)。

1、流式套接字

这种类型的套接字使用传输控制协议(TCP),提供可靠的、面向连接的通讯流。流式套接字确保数据按顺序、无误地传输,适合需要高可靠性的数据传输场景,如网页浏览、文件传输等。如果我们通过流式套接字发送了有序的数据:“1”,“2“。那么数据到达远端的顺序也是“1”,“2“。

流式套接字使用了TCP协议。TCP保证了数据传输是正确且有序的。

在这里插入图片描述

面向连接服务器处理的请求往往比较复杂,不是一来一去的请求应答所能解决的,而且往往是并发服务器。

套接字工作过程如下:服务器首先启动,通过调用 socket()建立一个套接字,然后调用bind()将该套接字和本地网络地址联系在一起,再调用 listen()使套接字做好侦听的准备,并规定它的请求队列的长度。之后就调用 accept()来接收连接。客户在建立套接字后就可调用 connect()和服务器建立连接。连接一旦建立,客户机和服务器之间就可以通过调用 read()write()来发送和接收数据。最后,待数据传送结束后,双方调用 close()关闭套接字。

2、数据报套接字

这种类型的套接字使用用户数据报协议(UDP),提供不可靠的、无连接的服务。数据通过相互独立的报文进行传输,数据报套接字不保证数据的传输顺序或传输成功,适合对实时性要求较高,但对可靠性要求不高的场景,如视频直播、在线游戏等。

也就是说,数据报套接字存在以下问题:

  • 如果发生了一个数据报,它可能不会到达。
  • 它可能会以不同的顺序到达。
  • 如果它到达了,它包含的数据中可能存在错误。

数据报套接字使用了UDP。它不像流式套接字那样维护一个打开的连接,我们仅仅需要把数据打成一个包,把远程的IP贴上去,然后把这个包发送出去,这个过程是不需要建立连接的。

无连接服务器一般都是面向事务处理的,一个请求一个应答就完成了客户程序与服务程序之间的相互作用。若使用无连接的套接字编程,我们可以这样做:

在这里插入图片描述

3、原始套接字

原始套接字允许直接访问底层协议,如 IP 协议、ICMP 协议等。它们用于低级别的网络操作,如网络协议分析、构建自定义的网络协议或数据包捕获等。这种套接字通常需要较高的权限,且使用时需谨慎,以避免影响网络安全和稳定性。


三、套接字地址

一个套接字可以这样来解释:它是通过标准的 Linux文件描述符和其他的程序通信的一个方法。

1、基本结构

进行套接字编程需要指定套接字的地址作为参数,不同的协议族有不同的地质结构定义方式。这些地址结构通常以sockadddr_开头,每一个协议族有一个唯一的后缀,例如对于以太网,其结构名称为sockaddr_in

struct sockaddr是通用的套接字地址类型,它可以在不同协议族之间进行强制类型转换。

typedef unsigned short	sa_family_t;
struct sockaddr {
	sa_family_t	sa_family;	/* address family, AF_xxx 协议族	*/
	char		sa_data[14];/* 14 bytes of protocol address 协议族数据	*/
};

sa_family 是结构体的第一个成员,它代表了一个无符号短整型(16个位),用于指定地址家族。这决定了 sa_data 成员如何解释地址信息。(例如,AF_INET 表示 IPv4,AF_INET6 表示 IPv6)。

sa_data 是一个字符数组,占用14个字节,用于存储具体的协议地址信息。这个字段的长度是固定的,但是因为不同的地址家族可能有不同长度的地址,所以这个字段的大小并不总是足以存储所有类型的地址。对于IPv4地址来说,通常只需要2个字节(端口号)加上4个字节的IP地址,共6个字节。

由于 sockaddr 结构体的 sa_data 字段长度固定,它并不适合存储所有类型的地址。因此,对于IPv4地址,通常会使用另一个结构体 struct sockaddr_in,它专门为IPv4地址设计,并包含足够的空间来存储IPv4地址和端口号。

/* Structure describing an Internet (IP) socket address. */
#define __SOCK_SIZE__	16		/* sizeof(struct sockaddr)	*/
struct sockaddr_in {			  
  sa_family_t		sin_family;	/* Address family		*/
  unsigned short int	sin_port;	/* Port number			*/
  struct in_addr	sin_addr;	/* Internet address		*/

  /* Pad to size of `struct sockaddr'. */
  unsigned char		__pad[__SOCK_SIZE__ - sizeof(short int) -
			sizeof(unsigned short int) - sizeof(struct in_addr)];
};
#define sin_zero	__pad	

在这里插入图片描述

sin_family指定地址家族,通常是 AF_INET,表示IPv4网络协议。sin_port存储端口号。sin_addr一个结构体,用于存储IPv4地址。

注意 sin_zero[8] 是为了是两个结构在内存中具有相同的尺寸,使用 sockaddr_in 的时候要把 sin_zero 全部设成零值(使用 bzero()memset()函数)。

在这里插入图片描述

in_addr 结构体通常包含一个32位的 s_addr 成员,用于存储IPv4地址的四个八位字节。

typedef unsigned int __u32;
/* Internet address. */
struct in_addr {
	__u32	s_addr;
};

而且,有一点很重要,就是一个指向 struct sockaddr_in 的指针可以声明指向一个 sturct sockaddr 的结构。所以虽然socket() 函数需要一个 struct sockaddr* ,你也可以给他一个 sockaddr_in * 。注意在 struct sockaddr_in 中,sin_family 相当于 在 struct sockaddr 中的 sa_family,需要设成AF_INET

最后一定要保证 sin_portsin_addr 必须是网络字节顺序 !

2、网络字节顺序转换

网络字节顺序(Network Byte Order)是网络协议中的,它确保了不同计算机系统之间传输的数据能够被正确解释 。

字节顺序(Endianness)

  • 大端序(Big-Endian):高位字节存储在低地址,低位字节存储在高地址。
  • 小端序(Little-Endian):低位字节存储在低地址,高位字节存储在高地址。
    不同的计算机架构可能使用不同的字节顺序。例如,大多数网络协议使用大端序来表示多字节数据,而Intel x86架构则使用小端序。

例如对于一个8字节的数据0x12345678,假设内存中存放的位置为0x1000,则在大端字节序系统和小端字节序系统中的存储方式:

内存地址0x10000x10010x10020x1003
小端字节序0x780x560x340x12
大端字节序0x120x340x560x78

网络字节顺序被定义为大端序,这是因为在网络传输中,需要保证数据的一致性。因此,当数据在网络中传输时,必须按照大端序排列。

为了确保数据在网络中正确传输,需要将主机字节顺序(Host Byte Order)转换为网络字节顺序。我们通常使用的有两种数据类型:短型(两个字节)和长型(四个字节)。下面介绍的这些转换函数对于这两类的无符号整型变量都可以进行正确的转换:

在这里插入图片描述

  • htonl()——”Host to Network Long“:将32位主机字节顺序转换为网络字节顺序(用于IP地址)。
  • htons()——”Host to Network Short“:将16位主机字节顺序转换为网络字节顺序(用于端口号)。
  • ntohl()——”Network to Host Long“:将32位网络字节顺序转换为主机字节顺序。
  • ntohs()——”Network to Host Short“:将16位网络字节顺序转换为主机字节顺序。

即使某些机器的内部字节顺序与网络字节顺序相同,它们仍然需要调用这些转换函数。这是因为:

  1. 代码的可移植性:通过始终使用转换函数,代码可以在不同的系统上运行,而无需关心底层系统的字节顺序。
  2. 隐式检查:调用转换函数可以作为一种隐式的检查,确保数据是按照正确的格式传输的。
  3. 系统优化:在某些系统上,这些转换函数可能会被优化,当检测到主机字节顺序与网络字节顺序相同时,转换函数可能实际上不做任何操作,从而避免了不必要的计算。
    因此,即使是在内部字节顺序与网络字节顺序相同的机器上,使用转换函数也是一种良好的编程实践。这样做确保了代码的一致性和可移植性,同时避免了潜在的错误。

Linux 系统提供和很多用于转换 IP 地址的函数。

inet_addr()inet_ntoa(),它们分别用于将字符串形式的IP地址转换为网络字节顺序的32位无符号整数,以及将网络字节顺序的32位无符号整数转换回字符串形式的IP地址。

net_addr() 函数:将点分十进制字符串形式的IP地址转换为网络字节顺序的32位无符号整数。

返回值:成功时返回转换后的32位无符号整数,失败时返回-1。

注意:由于inet_addr()返回的是32位无符号整数,如果输入的IP地址字符串无效(例如,包含无效的点号或数字),它可能会返回-1,这在二进制表示中实际上是255.255.255.255,这是一个广播地址。因此,在使用inet_addr()时,应该进行适当的错误检查。

inet_ntoa()Network to ASCII) 函数:将网络字节顺序的32位无符号整数转换回点分十进制字符串形式的IP地址。

返回值:返回一个指向静态字符串的指针,该字符串定义在inet_ntoa()函数内部。

注意:由于inet_ntoa()返回的是一个静态字符串的指针,每次调用时都会改变这个指针指向的内容。因此,如果需要保存转换后的IP地址,应该在调用inet_ntoa()后将结果复制到另一个字符串中。

struct sockaddr_in ina;
ina.sin_addr.s_addr = inet_addr("166.111.69.52");
if (ina.sin_addr.s_addr == -1) {
    // 处理错误
}

printf("IP address: %s\n", inet_ntoa(ina.sin_addr));

在实际应用中,使用这些函数可以极大地简化IP地址的处理,但同时也需要注意错误检查和内存管理,以避免潜在的问题。

struct sockaddr_in 中的 sin_addrsin_port 他们的字节顺序都是网络字节顺序,而sin_family 却不是网络字节顺序的。为什么??

sin_addrstruct sockaddr_in 中,sin_addr 存储的是IPv4地址,这个地址是从IP层获取的。因为IP层直接参与网络通信,所以IPv4地址必须以网络字节顺序(大端序)存储,以确保在不同主机间传输时能够被正确解析。sin_port 存储的是端口号,这个端口号通常用于UDP或TCP协议。同样,因为端口号是在传输层使用的,并且会直接参与到网络数据包的构造中,所以它也必须以网络字节顺序存储。

然而, sin_family 表示地址家族。只是内核用于确定 struct sockaddr_in 中存储的是哪种类型的地址。并且,sin_family 永远也不会被发送到网络上,所以可以使用主机字节顺序来存储。


四、套接字的系统调用

在面向连接的通信中,服务器和客户机之间交换数据之前必须建立一个连接。这个过程包括服务器绑定到一个套接字上,然后侦听连接请求。服务器通过编程设定的连接类型来决定如何侦听。连接建立后,数据作为信息的一部分进行交换。服务器通常是通信的起点,首先启动并绑定套接字,然后等待客户端的连接请求。

每提到Linux,大家总能想到”一切皆文件“。这句话的意思也就是:在 Linux 系统中,任何对 I/O 的操作,都是通过读或写一个文件描述符来实现的。

在网络编程中,获取表示网络连接的文件描述符通常是通过调用系统函数 socket() 来实现的。这个函数会返回一个套接字描述符,类似于文件描述符的标识符。拿到这个套接字描述符后,就可以使用一系列系统函数如 send()recv() 来进行数据的发送和接收操作。

既然套接字描述符是文件描述符的一种,那么也可以使用 write()read() 进行套接字通讯,但 send()recv() 函数提供了更多的控制选项。这些函数可以让我们更精细地控制数据传输的行为,例如在 send() 函数中设置特定的标志位来指定发送操作的性质,这在网络编程中是非常有用的。

1、socket()函数

这个函数建立一个协议族为domain、协议类型为type、协议编号为protocol的套接字文件描述符。

在这里插入图片描述

首先,domain 需要被设置为 AF_INET,就像上面的 struct sockaddr_in。然后,type 参数告诉内核这个 socket 是什么类型,SOCK_STREAM或是SOCK_DGRAM。最后,只需要把 protocol 设置为 0 。

套接字地址族(domain)domain 参数用于指定套接字的地址族,即地址类型。在TCP/IP网络中,最常见的地址族是 AF_INET,表示IPv4地址。

除了 AF_INET 外,还有其他地址族,如 AF_INET6(IPv6)、AF_UNIX(Unix域套接字)等。

套接字类型(type)type 参数决定了套接字的类型,即它将如何工作。

SOCK_STREAM 表示面向连接的套接字,如TCP,它提供可靠的、面向连接的数据传输服务。

SOCK_DGRAM 表示无连接的套接字,如UDP,它提供不可靠的、无连接的数据传输服务。

除了这两种类型外,还有其他类型,如 SOCK_RAW(原始套接字)等。

协议(protocol)protocol 参数用于指定协议。对于TCP/IP网络,通常将其设置为0,表示使用默认的IP协议。

对于Unix域套接字,protocol 参数可以设置为0或IPPROTO_TCP(对于TCP)或IPPROTO_UDP(对于UDP)。

返回值: 成功时,socket() 函数返回一个非负整数,表示新的套接字描述符。如果发生错误,socket() 函数返回-1,并设置全局变量 errno 包含错误代码。

2、bind()函数

bind() 函数将长度为addlenstruct sockadd类型的参数与socket文件标识符绑定在一起。将sockfd绑定到某个端口上。

在这里插入图片描述

当使用 socket() 函数得到一个套接字描述符,需要将 socket 绑定机器上的端口。

bind() 函数的作用是将指定的地址分配给一个套接字。这个地址通常是一个网络地址,如IP地址和端口号的组合。

  • sockfd:一个文件描述符,指向要绑定的套接字。
  • addr:一个指向 struct sockaddr 类型的指针,该结构包含要绑定的地址。
  • addrlen:一个整数,表示 addr 指针指向的结构的大小,以字节为单位。addrlen 可以设置为 sizeof(struct sockaddr)

当服务器需要提供服务并等待客户端连接时,必须进行 bind() 操作来指定监听的地址。这之后,服务器需要调用 listen() 函数使其进入监听模式。

客户端在发起连接到服务器时,只需要进行 connect() 操作,不需要进行 bind() 操作。

那么客户端是不需要对得到的套接字描述符进行绑定操作吗?

一般情况下,客户端确实不需要显式地对套接字描述符进行绑定操作。这是因为在网络编程中,绑定操作通常用于服务器端,服务器需要将其套接字绑定到特定的 IP 地址和端口上,以便在该地址和端口上监听和接受来自客户端的连接。

对于客户端,当它需要与服务器建立连接时,通常只需指定服务器的 IP 地址和端口号,而无需显式绑定自己的 IP 地址和端口。客户端调用 connect() 函数时,操作系统会自动为该套接字分配一个本地的临时端口和 IP 地址(如果客户端有多个网络接口的话),然后使用这个套接字连接到服务器。
不过,在某些特殊情况下,客户端可能需要指定特定的本地地址和端口号,这时可以使用 bind() 函数显式地进行绑定。例如,当客户端有多个网络接口且需要指定特定接口进行通信时,或需要绑定到特定的端口号时,可以进行这样的操作。

使用案例:

#include <sys/types.h>
#include <sys/socket.h>
#define MYPORT 4000

int main() {
    int sockfd;  
    // 定义一个整数变量sockfd,用于套接字描述符
    struct sockaddr_in my_addr;  
    // 定义一个结构体变量my_addr,用于存储服务器地址信息

    sockfd = socket(AF_INET, SOCK_STREAM, 0); /* 在你自己的程序中 */
    // 应该在这里进行错误检查!
    if (sockfd == -1) {
        perror("socket error");
        exit(1);
    }
    memset(&my_addr, 0, sizeof(my_addr));
    my_addr.sin_family = AF_INET; /* 指定地址族为IPv4 */
    my_addr.sin_port = htons(MYPORT); /* 将端口号转换为网络字节顺序 */
    my_addr.sin_addr.s_addr = inet_addr("166.111.69.52"); /* 将IP地址转换为整数形式 */


    // 应该在这里进行错误检查!
    int n = bind(sockfd, (struct sockaddr *)&my_addr, sizeof(struct sockaddr));
    if (n == -1) {
        perror("bind error");
        exit(1);
    }

    // 这里应该添加监听和接受连接的代码
    // 例如:listen(sockfd, 10); // 监听队列长度为10
    //      while (1) {
    //          struct sockaddr client_addr;
    //          socklen_t client_addr_len = sizeof(client_addr);
    //          int client_sockfd = accept(sockfd, &client_addr, &client_addr_len);
    //          // 处理客户端连接...
    //      }

    // 不要忘记关闭套接字
    close(sockfd);

    return 0;
}

代码中的错误检查非常重要,因为任何系统调用都有可能失败。如果 socket()bind() 失败,程序应该打印错误信息并退出。

此外,代码片段中缺少了监听和接受连接的代码。在实际的服务器程序中,需要添加这些代码来处理客户端的连接请求。

bind()也可以自动获取IP和端口:

my_addr.sin_port = 0;					/*	随机选择一个端口	*/
my_addr.sin_addr.s_addr = INADDR_ANY;	/*	使用自己的地址		 */

将端口号设为 0,表示让系统随机选择一个未使用的端口。使用 INADDR_ANY 表示绑定到所有可用的本地 IP 地址。这通常意味着在多网卡系统中,程序可以接收来自任何网卡的连接。

#define	INADDR_ANY		((unsigned long int) 0x00000000)

我们并没有将 INADDR_ANY 转化为网络字节序,因为它的值是0,无论用什么顺序排列位的顺序,都是不变的。

3、connect()函数

客户端在建立套接字之后,不需要显式的进行地址绑定,就可以直接连接服务器。connect() 函数尝试将一个套接字与指定的远程地址(服务器的 IP 地址和端口号)建立连接。 在这里插入图片描述

sockfd:这是一个套接字文件描述符,是之前通过 socket() 函数创建的。它标识了当前进行操作的套接字。

addr:这是一个指向 sockaddr 结构体的指针,该结构体包含了远程服务器的 IP 地址和端口号。实际使用中,通常会使用 sockaddr_insockaddr_in6 结构体来具体化地址信息,分别用于 IPv4 和 IPv6 地址。

addrlen:这是 serv_addr 结构体的大小(以字节为单位)。对于 sockaddr_in 结构体,通常使用 sizeof(struct sockaddr_in) 作为 addrlen 的值。与bind()不同的是,这个参数是整形的变量而不是指针。

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

int main() {
    int sockfd;
    struct sockaddr_in serv_addr;

    // 1. 创建套接字
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1) {
        perror("socket");
        exit(EXIT_FAILURE);
    }

    // 2. 设置服务器地址
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(23); // Telnet 默认端口
    serv_addr.sin_addr.s_addr = inet_addr("166.111.69.52");
    

    // 3. 连接到服务器
    int ret = connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
    if (ret == -1) {
        perror("connect");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    printf("Connected to 166.111.69.52 on port 23\n");

    // 进行数据传输...
	
    // 关闭套接字
    close(sockfd);

    return 0;
}

一定要检测 connect()的返回值:如果发生了错误(比如无法连接到远程主机,或是远程主机的指定端口无法进行连接等)它将会返回错误值 –1 。 全局变量 errno将会存储错误代码。

我们没有调用 bind()函数。我们并不在乎我们本地用什么端口来通讯,我们在乎的是我们连到哪台主机上的哪个端口上。Linux 内核自动为我们选择了一个没有被使用的本地端口。

在面向连接的协议的程序中,服务器执行以下函数:

  • 调用 socket()函数创建一个套接字。
  • 调用 bind()函数把自己绑定在一个地址上。
  • 调用 listen()函数侦听连接。
  • 调用 accept()函数接受所有引入的请求。
  • 调用 recv()函数获取引入的信息然后调用 send()回答。

4、listen()函数

listen()用来初始化服务器的可连接队列,服务器处理客户端连接请求的时候是顺序处理的,同一时间内仅能处理一个客户端连接。当多个客户端连接请求同时来到时,服务器并不是同时处理,而是将不能处理的客户端连接请求放到请求队列中,这个队列长度由listen()函数来定义。

listen()函数是等待别人连接,进行系统侦听请求的函数。当有人连接的时候,有两步需要做:通过 listen()函数等待连接请求,然后使用 accept()函数来处理。

在这里插入图片描述

  • sockfd 是一个套接字描述符,由 socket()系统调用获得。

  • backlog 是未经过处理的连接请求队列可以容纳的最大数目。

backlog 具体一些是什么意思呢?每一个连入请求都要进入一个连入请求队列,等待listen 的程序调用 accept()函数来接受这个连接。当系统还没有调用 accept()函数的时候,如果有很多连接,那么本地能够等待的最大数目就是 backlog 的数值。

那么我们需要指定本地端口了,因为我们是等待别人的连接。所以,在 listen()函数调用之前,我们需要使用 bind() 函数来指定使用本地的哪一个端口数值。

如果想在一个端口上接受外来的连接请求的话,那么函数的调用顺序为:

socket() ;

bind() ;

listen() ;

/* 在这里调用 accept()函数 */
//...

5、accept()函数

当一个客户端的连接请求到达服务器主机侦听的端口时,此时客户端的连接会在队列中等待,知道服务器处理接收请求。

accept() 函数是用于服务器端套接字编程中的一个关键函数。它在一个监听套接字上等待客户端的连接请求,并在接收到连接请求时,返回一个新的套接字描述符用于与客户端通信。客户端连接的信息也可以通过这个新描述符来获得。因此当服务器成功处理客户端的请求连接后,会有两个文件描述符。老的文件描述符表示正在监听的socket,新产生的文件描述符表示客户端的连接。

函数 accept()有一些难懂。当调用它的时候,大致过程是下面这样的:

  • 有人从很远很远的地方尝试调用 connect()来连接你的机器上的某个端口(当然是已经在 listen()的)。

  • 他的连接将被 listen 加入等待队列等待 accept()函数的调用(加入等待队列的最多数目由调用 listen()函数的第二个参数 backlog 来决定)。

  • 调用 accept()函数,告诉他你准备连接。

  • accept()函数将回返回一个新的套接字描述符,这个描述符就代表了这个连接。这时候有了两个套接字描述符,返回的那个就是和远程计算机的连接,而第一个套接字描述符仍然在你的机器上原来的那个端口上 listen()

这时候所得到的那个新的套接字描述符就可以进行 send()操作和 recv()操作了。

在这里插入图片描述

  • sockfd 是正在 listen() 的一个套接字描述符。

  • addr 一般是一个指向 struct sockaddr_in 结构的指针;里面存储着远程连接过来的计算机的信息(比如远程计算机的 IP 地址和端口)。如果不关心客户端的地址信息,可以将该参数设置为 NULL

  • addrlen 是一个本地的整型数值,在它的地址传给 accept() 前它的值应该是sizeof(struct sockaddr_in)accept()不会在 addr 中存储多余 addrlen bytes 大小的数据。如果accept()函数在 addr 中存储的数据量不足 addrlen,则 accept()函数会改变 addrlen 的值来反应这个情况。如果 addrNULLaddrlen 应设置为 NULL

我们通过这个函数可以得到成功连接服务器的客户端的IP地址、端口和协议族等信息,这个信息是通过参数addr获得的。函数返回的时候,会将客户端的信息存储在参数addr中。

#include <string.h>  // 用于内存操作,如bzero()
#include <sys/types.h>  // 包含不同类型定义
#include <sys/socket.h>  // 包含套接字相关的系统调用

/* 用户连接的端口号 */
#define MYPORT 8888
/* 等待队列中可以存储多少个未经过 accept()处理的连接 */
#define BACKLOG 10

int main() {
    /* 用来监听网络连接的套接字 sock_fd,用户连入的套接字使用 new_fd */
    int sockfd, new_fd ;
    /* 本地的地址信息 */
    struct sockaddr_in my_addr ;
    /* 连接者的地址信息 */
    struct sockaddr_in their_addr ;
    int sin_size;

    /* 记得在自己的程序中这部分要进行错误检查! */
    sockfd = socket(AF_INET, SOCK_STREAM, 0);  // 创建一个TCP套接字
    if (sockfd == -1) {
        perror("socket error");
        exit(1);
    }

    /* 主机字节顺序 */
    my_addr.sin_family = AF_INET;  // 指定地址族为IPv4
    /* 网络字节顺序,短整型 */
    my_addr.sin_port = htons(MYPORT);  // 将端口号转换为网络字节顺序
    /* 自动赋值为自己的 IP */
    my_addr.sin_addr.s_addr = INADDR_ANY;  // 绑定到本机的所有IP地址

    bind(sockfd, (struct sockaddr *)&my_addr, sizeof(struct sockaddr));  // 绑定套接字地址
    listen(sockfd, BACKLOG);  // 进入监听模式,等待客户端连接

    sin_size = sizeof(struct sockaddr_in);  // 定义一个变量来存储地址长度

    new_fd = accept(sockfd, &their_addr, &sin_size);  // 接受连接
    if (new_fd == -1) {
        perror("accept error");
        exit(1);
    }

    /*
    ... 这里应该添加代码来处理新的连接(new_fd)
    */
  
    // 不要忘记关闭原始套接字(sockfd)
    close(sockfd);

    return 0;
}

这段代码定义了一个TCP服务器,它在端口8888上监听连接,并设置了一个等待队列(backlog)来存储未处理的连接请求。当有客户端连接时,服务器接受连接并创建一个新的套接字(new_fd)来与客户端通信。

注意:在服务器程序中,通常不会在接收到一个新的连接后立即关闭原始套接字(sockfd)。原始套接字用于监听更多的连接请求,而新创建的套接字(new_fd)用于与当前连接的客户端进行通信。如果只处理一个连接,那么在处理完这个连接后,可以关闭新创建的套接字(new_fd),但通常不需要关闭原始套接字(sockfd),除非服务器不再需要监听新的连接。

在面向连接的通信中客户端要做如下一些事

  • 调用 socket()函数创建一个套接字。

  • 调用 connect()函数试图连接服务。

  • 如果连接成功调用 write()函数请求数据,调用 read()函数接收引入的应答。

accept() 返回一个新的套接字描述符,用于与客户端进行通信。这个新的套接字是专用的,并与最初的监听套接字(sockfd)分离。 这两个套接字的关系是什么?

监听套接字:

  • 由服务器使用,专门用于监听来自客户端的连接请求。
  • 通过 socket() 函数创建并绑定到特定的 IP 地址和端口后,调用 listen() 函数进入监听状态。
  • 只负责接受新的连接请求,不参与实际的数据传输。
  • 它作为一个长期存在的套接字,它从服务器启动开始,直到服务器关闭或停止服务。它的唯一任务是接收新的连接,不会关闭直到服务器关闭。

连接套接字:

  • accept() 函数创建,用于与客户端进行实际的数据通信。
  • 每个新的连接请求都会生成一个新的连接套接字。
  • 用于发送和接收数据,与客户端直接通信。
  • 每当 accept() 成功处理一个连接请求时,返回一个新的连接套接字描述符。连接套接字的生命周期与特定的客户端连接相关,当通信结束时(例如客户端断开连接或通信完成),该连接套接字会被关闭。

在网络编程中,服务器通常需要处理多个客户端的连接请求。为此,服务器使用一个监听套接字来等待新的连接请求。接下来,当有新的连接到来时,服务器会为每个连接创建一个新的连接套接字,并使用该连接套接字与客户端进行通信。为了处理多个客户端连接,服务器通常使用多线程或多进程的方式。

在单线程服务器中,accept() 是阻塞的,这意味着服务器在等待新的连接请求时无法处理其他任务或连接请求。

单线程服务器的工作流程如下:

  1. 等待连接请求: 服务器调用 accept() 函数,并阻塞在这个调用上,直到有新的客户端连接请求到达。此时,服务器无法进行其他操作,也无法处理其他客户端的连接请求。
  2. 处理客户端请求: 一旦 accept() 成功返回(即接收到一个新的连接请求),服务器会获得一个新的套接字描述符,用于与该客户端通信。在单线程服务器中,服务器会使用这个新的套接字描述符来处理客户端的请求,进行数据接收、处理和响应。
  3. 继续等待新的连接: 当处理完当前客户端的请求后,服务器将关闭这个连接套接字,然后再次调用 accept() 以等待下一个连接请求。

这种方式的问题是,在处理一个客户端的请求时,服务器无法同时处理其他客户端的连接请求。具体表现为:

  • 如果当前客户端的处理时间较长,其他客户端的连接请求将被阻塞在 accept() 函数上,直到服务器处理完当前的客户端请求。
  • 如果客户端发送的数据量大或处理复杂度高,可能会导致服务器的响应时间变长,从而影响其他客户端的连接和处理。
  • 当有大量客户端同时尝试连接服务器时,单线程服务器无法快速响应每个请求,因为它必须按顺序处理每个连接。

下面我们来描述多线程/多进程情况下的TCP服务器:

在多线程和多进程模型中,TCP服务器能够同时处理多个客户端连接,这种并发处理能力使得服务器在高并发场景下能更高效地工作。

创建监听套接字:

  • 服务器首先创建一个监听套接字,用于接受客户端的连接请求。
  • 绑定套接字到服务器的IP地址和端口,并进入监听状态。
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
bind(listen_sock, (struct sockaddr*)&server_addr, sizeof(server_addr));
listen(listen_sock, BACKLOG);

接受连接并创建线程:

  • 服务器调用 accept() 等待客户端连接请求。当有新的连接请求时,accept() 返回一个新的套接字描述符。
  • 服务器创建一个新的线程来处理这个连接,每个线程都使用这个新套接字与客户端通信。
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int conn_sock = accept(listen_sock, (struct sockaddr*)&client_addr, &addr_len);

pthread_t thread_id;
pthread_create(&thread_id, NULL, handle_client, &conn_sock);
pthread_detach(thread_id); // 使线程资源在完成时自动释放

处理客户端请求:

  • 新创建的线程调用 handle_client() 函数,处理客户端的请求。这个函数通常包括接收数据、处理数据、发送响应等步骤。
void *handle_client(void *arg) {
    int conn_sock = *(int *)arg;
    // 数据处理逻辑
    close(conn_sock);
    return NULL;
}

资源管理和释放:

  • 线程处理完客户端请求后,关闭连接套接字并释放资源。

6、send()、recv()、sendto()、recvfrom()函数

在这里插入图片描述

recv, recvfrom, 和 recvmsg 都是系统调用,用于从套接字(socket)接收数据。
在这里插入图片描述

send, sendto, 和 sendmsg 也都是系统调用,用于在套接字(socket)上发送数据。

下面将它们进行分组:

send()recv():这两个函数是最基本的,通过连接的套接字流进行通讯的函数。

sendto()recvfrom():这两个函数是进行无连接的 UDP 通讯时使用的。

a、send()和recv()

send() 函数: 函数用于将数据从应用程序发送到TCP套接字。

  • 参数
    • sockfd:一个文件描述符,指向要发送数据的套接字。
    • buf:一个指向要发送数据的缓冲区的指针。
    • len:要发送的数据长度,以字节为单位。
    • flags:可选的标志位,通常可以设置为0。
  • 返回值:成功时,返回实际发送的字节数;失败时,返回-1,并设置全局变量 errno 包含错误代码。

send() 函数的返回值实际上告诉了你实际发送了多少字节的数据。如果返回值小于你传递给 send()len 参数,那么就意味着并不是所有的数据都被发送出去了。

这是因为在TCP协议中,数据传输是分块进行的,而且TCP连接的发送缓冲区的大小是有限的。如果发送缓冲区满了,那么 send() 函数就会返回,即使没有发送所有的数据。在这种情况下,剩余的数据仍然留在发送缓冲区中,等待下一次调用 send() 函数时继续发送。

因此,当使用 send() 函数发送数据时,需要检查返回值,以确保所有数据都被发送出去了。如果返回值小于 len,需要再次调用 send() 函数,并传递剩余的数据。例如,如果有1000字节的数据需要发送,可能需要多次调用 send() 函数,每次发送100字节,直到所有的数据都被发送出去。

如果数据包足够小(例如小于1K),通常 send() 函数可以一次性发送所有的数据,因为1KB的数据对于大多数网络来说都是一个合理的发送大小。但是,如果数据包非常大,那么可能需要分块发送数据。

recv() 函数:用于从TCP套接字接收数据。

  • 参数
    • sockfd:一个文件描述符,指向要接收数据的套接字。
    • buf:一个指向接收数据的缓冲区的指针。
    • len:缓冲区的大小,以字节为单位。
    • flags:可选的标志位,通常可以设置为0。
  • 返回值:成功时,返回实际接收的字节数;如果到达了数据末尾,返回0;失败时,返回-1,并设置全局变量 errno 包含错误代码。

TCP(传输控制协议)是一种可靠的、面向连接的传输层协议,它确保数据包按照发送的顺序被接收,并且通过重传丢失的数据包来保证数据的完整性。然而,TCP并不保证一次就能接收到全部的数据。
TCP的工作原理是通过分段和重组来传输数据。数据在发送时会被分割成较小的数据包(称为TCP段),这些数据包在网络中独立传输。每个TCP段都有自己的序列号,以便在接收端能够重新组装成原始数据流。
以下是一些原因,为什么TCP不保证一次就能接收到全部的数据:

  1. 网络拥塞:如果网络拥塞,TCP发送方可能会暂时降低发送速率,导致数据分段在网络中传输时间延长。
  2. 传输延迟:数据包在网络中传输可能会有延迟,不同的数据包可能以不同的速度到达接收端。
  3. 数据包丢失:在极端情况下,数据包可能会在网络中丢失,需要发送方重新发送丢失的数据包。
  4. 接收缓冲区限制:如果接收端的接收缓冲区满了,新的数据包可能无法立即被接收,需要等待缓冲区有空闲空间。
  5. 应用层处理速度:如果接收端的应用程序处理数据的速度慢于接收数据的速度,新的数据包可能无法立即被处理。

因此,在设计基于TCP的应用程序时,需要考虑到这些因素,并设计相应的机制来处理数据分段到达的情况。例如,可以设计一个循环,不断地调用 recv() 函数来接收数据,直到没有更多的数据可接收或者发生了错误。

这两个函数都是阻塞的,意味着在数据发送或接收完成之前,调用线程会一直等待。

在使用这两个函数时,需要注意数据的大小和缓冲区的大小,确保缓冲区足够大以容纳数据,或者确保 len 参数正确设置了缓冲区的大小。

由于TCP(传输控制协议)是一种面向流的通信协议,这意味着它提供了一个连续的数据流,而不像UDP那样将数据分割成独立的包。在TCP套接字上,数据是按顺序传输的,并且通过TCP的流量控制和拥塞控制机制,保证了数据的可靠传输。

由于TCP的这种特性,我们可以使用标准文件操作函数read()write()来与TCP套接字进行交互,而不需要专门为网络编程设计的函数(如send()recv())。这些标准文件操作函数提供了一个简单和直观的方式来读取和写入数据。

b、sendto() 和recvfrom()

使用这两个函数,则数据会在没有建立过任何连接的网络上传输。因为数据报套接字无法对远程主机进行连接。 这意味着发送方和接收方在发送和接收数据之前不需要知道对方的地址。但是,为了确保数据能够正确地发送到目标主机,发送方需要知道目标的IP地址和端口号。

sendto() 函数:用于将数据从应用程序发送到UDP套接字。

  • 参数
    • sockfd:一个文件描述符,指向要发送数据的套接字。
    • buf:一个指向要发送数据的缓冲区的指针。
    • len:要发送的数据长度,以字节为单位。
    • flags:可选的标志位,通常可以设置为0。
    • dest_addr:一个指向 struct sockaddr 结构的指针,包含目标主机的IP地址和端口。
    • addrlen:目标地址的长度,以字节为单位。
  • 返回值:成功时,返回实际发送的字节数;失败时,返回-1,并设置全局变量 errno 包含错误代码。

recvfrom() 函数:用于从UDP套接字接收数据。

  • 参数
    • sockfd:一个文件描述符,指向要接收数据的套接字。
    • buf:一个指向接收数据的缓冲区的指针。
    • len:缓冲区的大小,以字节为单位。
    • flags:可选的标志位,通常可以设置为0。
    • from:一个指向 struct sockaddr 结构的指针,用于存储发送方的IP地址和端口。
    • fromlen:发送方地址的长度,以字节为单位。
  • 返回值:成功时,返回实际接收的字节数;如果到达了数据末尾,返回0;失败时,返回-1,并设置全局变量 errno 包含错误代码。

注意:当使用connect()函数连接到一个UDP服务器时,虽然使用的是UDP(一种无连接的协议),但感觉上好像是在使用TCP(一种有连接的协议)。这是因为connect()创建了一个虚拟的连接,使得可以像使用TCP套接字那样使用send()recv()函数来发送和接收数据。

这个“伪TCP”行为背后的原理是,套接字接口在send()recv()函数调用时自动添加了目标地址和端口的信息。这意味着您不需要在每次调用send()recv()时都显式地指定目标地址和端口。套接字库会使用之前通过connect()设置的地址和端口来发送和接收数据。

简单来说,就是在使用UDP套接字时,通过connect()建立了一个虚拟的连接,这样就可以使用send()recv()函数来发送和接收数据,而不用每次都指定目标地址和端口。虽然这个虚拟连接并不像真正的TCP连接那样提供可靠的数据传输,但它让UDP套接字的编程变得更加简单和直观。

尽管有connect()的虚拟连接,UDP本质上仍然是无连接协议,不提供可靠的传输机制(如确认、重传、顺序保证等)。这意味着数据包可能会丢失、重复或乱序到达。使用connect()不会影响UDP的无状态特性,也就是说,服务器端不需要维护任何连接状态。

在UDP通信中,虽然UDP套接字也被映射到文件描述符上,但不使用标准的文件操作函数 read()write() 来进行数据传输。这主要是因为UDP是一种无连接、面向数据报的协议,与TCP的面向流的特性不同。

但是如果使用 connect() 将UDP套接字绑定到特定的目标地址和端口,那么使用 read()write() 进行数据传输也是可以的。这种情况下,UDP套接字会“记住”目标地址,write() 会发送数据到该地址,read() 会接收来自该地址的数据。

然而,这种用法比较特殊,且失去了UDP无连接特性的灵活性,因此一般不建议这样做。

  • 15
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

无敌岩雀

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值