TCPIP网络编程笔记

本文详细介绍了TCP/IP网络编程,包括socket(套接字)的基本概念、网络字节序、TCP与UDP的区别、迭代服务器端和客户端的工作流程,以及TCP套接字中的I/O缓冲、TCP内部工作原理和UDP的工作原理。同时讲解了如何断开套接字,以及bind、listen、connect、accept等关键函数的使用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

socket(套接字)

源于UNIX,被BSD发扬光大

  1. 实际上是进程与进程之间的通信方式,socket是进程间通信的接入点
  2. 基于BSD的POSIX标准
  3. 万物皆文件的泛型,在Linux中可以像操作文件一样操作socket
  4. 套接字描述符的本质是文件描述符
  5. 可以对套接字进行close,dup2,read,write,select等操作
  6. 字节序的问题,大端和小端

socket()函数

int socket(int protofamily,int type,int protocol);
  • 协议族(protocol family),一般使用PF_INET
  • 套接字类型(type)
    • SOCK_STREAM,面向连接的套接字,可以比喻成两个工人在传送带的两头传递东西,是一种可靠的,按序传递的,基于字节的面向连接的数据传输方式的套接字
      • 传输过程中数据不会消失
      • 按序传输数据
      • 传输的数据不存在数据边界
      • 套接字连接必须一一对应
    • SOCK_DGRAM,面向消息的套接字,可以比喻成高速传输的快递,不可靠的,不按序传输的,以数据的高速传输为目的的套接字
      • 强调快速传输而非传输顺序
      • 传输的数据可能丢失也可能损毁
      • 数据的传输有数据边界
      • 限制每次传输的数据大小
  • 计算机间的通信协议(protocol)
    socket()会返回一个socket描述符,类似于文件描述符,用于对socket进行操作
  • protofamily:即协议域,又称为协议族,它决定了socket的地址类型,在通信中必须采用对应的地址类型,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址,常用的协议族有AF_INET(IPV4)、AF_INET6(IPV6)、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等
  • type:socket类型,常用的socket类型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等
  • protocol:采用的协议,用的协议有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议
  • type和protocol不可以随意组合的,如SOCK_STREAM不可以跟IPPROTO_UDP组合。当protocol为0时,会自动选择type类型对应的默认协议
  • 当我们调用socket创建一个socket时,返回的socket描述字它存在于协议族(address family,AF_XXX)空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用bind()函数,否则就当调用connect()、listen()时系统会自动随机分配一个端口
  • inet_pton函数需要包含头文件arpa/inet.h
  • 当使用ctrl+c结束服务器端时,再次运行服务器bind()函数会出现地址已经被使用的错误,它是由TCP套接字TIME_WAIT状态引起,该状态会在套接字关闭后保留几分钟,在该状态退出后,该地址才能被重新使用,不要使用ctrl+z退出,要使用ctrl+c退出
    bind()函数
int bind(int socket,const struct sockaddr* addr,socklen_t addrlen)

bind()函数用于将地址族中的特定地址赋给socket
sockfd:即socket描述符,是socket的唯一标识
addr:指向绑定给sockfd的协议地址,这个地址结构根据地址创建socket时的地址协议族的不同而不同
addrlen:对应地址的长度
通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。这就是为什么通常服务器端在listen之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个

struct sockaddr_in {
    sa_family_t    sin_family; /* address family: AF_INET */
    in_port_t      sin_port;   /* port in network byte order */
    struct in_addr sin_addr;   /* internet address */
};

/* Internet address. */
struct in_addr {
    uint32_t       s_addr;     /* address in network byte order */
};
//ipv6对应的是: 
struct sockaddr_in6 { 
    sa_family_t     sin6_family;   /* AF_INET6 */ 
    in_port_t       sin6_port;     /* port number */ 
    uint32_t        sin6_flowinfo; /* IPv6 flow information */ 
    struct in6_addr sin6_addr;     /* IPv6 address */ 
    uint32_t        sin6_scope_id; /* Scope ID (new in 2.4) */ 
};

struct in6_addr { 
    unsigned char   s6_addr[16];   /* IPv6 address */ 
};
//Unix域对应的是: 
#define UNIX_PATH_MAX    108

struct sockaddr_un { 
    sa_family_t sun_family;               /* AF_UNIX */ 
    char        sun_path[UNIX_PATH_MAX];  /* pathname */ 
};

INADDR_ANY代表任何地址,任意地址
htonl()函数可以将一个int转换为IP地址需要的格式,将一个32位数从主机字节顺序转换成网络字节顺序
htons()函数将一个int转换为port需要的格式
listen()函数和connect()函数
作为一个服务器,在调用socket()和bind()之后,需要调用listen()来监听这个socket,如果此时客户端调用connect()发出连接请求,服务端就会接受到这个请求

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

listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。
connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。客户端通过调用connect函数来建立与TCP服务器的连接。
accept()函数

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

sockfd:参数sockfd就是上面解释中的监听套接字,这个套接字用来监听一个端口,当有一个客户与服务器连接时,它使用这个一个端口号,而此时这个端口号正与这个套接字关联。当然客户不知道套接字这些细节,它只知道一个地址和一个端口号。

addr:这是一个结果参数,它用来接受一个返回值,这返回值指定客户端的地址,当然这个地址是通过某个地址结构来描述的,用户应该知道这一个什么样的地址结构。如果对客户的地址不感兴趣,那么可以把这个值设置为NULL。

len:如同大家所认为的,它也是结果的参数,用来接受上述addr的结构的大小的,它指明addr结构所占有的字节个数。同样的,它也可以被设置为NULL。

如果accept成功返回,则服务器与客户已经正确建立连接了,此时服务器通过accept返回的套接字来完成与客户的通信

accept()函数默认会阻塞进程,直到有一个客户连接建立之后返回,它返回一个新可用的socket,这个socket是连接socket

监听socket:监听套接字正如accept的参数sockfd,它是监听套接字,在调用listen函数之后,是服务器开始调用socket()函数生成的,称为监听socket描述字

连接socket:一个套接字会从主动连接的套接字变身为一个监听套接字;而accept函数返回的是已连接socket描述字(一个连接套接字),它代表着一个网络已经存在的点点连接

一个服务器通常只创建一个监听socket,每个与服务器进程建立连接的客户都有一个连接socket,完成对该客户的服务后这个socket才会被关闭

因为套接字在传输数据时需要根据IP地址和端口号来确定目的地址,所以sockaddr_in结构体中包含了所使用的地址族,IP地址以及端口号这三个信息
地址族(Adress family)

  • IPV4,4字节
  • IPV6,16字节
    IP可以分为网络号与主机号,但是仅仅有主机号,只保证了信息可以准确地在计算机之间进行传递,但是不同的应用程序又会对应着不同的socket,这时就需要端口号了,简单地说,端口号就是为了区分不同套接字来设置的,因此一个端口号不能分配给不同的socket。
  • 端口号由16位构成,虽然端口号不能重复,但TCP套接字和UDP套接字不会共用端口号,所以允许重复

网络字节序与地址变换

CPU解析数据的方式分为大端序和小端序

  • 大端序:高位字节放到低位地址
  • 小端序(我们熟悉的顺序):高位字节放到高位地址
    为了防止网络传输过程中出现差错,网络字节序统一为大端序 因此当计算机收到数据时,应该以大端序解析数据;当计算机发送数据时,应该将数据转化为大端序
  • 字节序转换的函数
unsigned short htons(unsigned short)
unsigned long htonl(unsigned long)
unsigned short ntohs(unsigned short)
unsigned long ntohl(unsigned long)```
上述函数名中的h指的是host,n指的是netwoek,s指的是short2字节),l指的是long4字节)

```c
in_addr_t inet_addr(const char* string)```
可以将一个点分十进制表示的IP地址转换为转换成32位大端序的整数型数据,返回转换之后的数据

```c
int inet_aton(const char* string,struct in_addr *addr)

成功转换返回1,转换失败返回0;这个函数实际上是与inet_addr的作用是一样的,不过它会自动把转换之后的ip地址填入传入的in_addr之中

char* inet_ntoa(struct in_addr adr);

这个函数实现的功能与上一个函数正好相反,但要注意保存返回的字符串,成功时返回转换的字符串地址值,失败时返回-1
服务器端在进行bind的时候,可以将IP地址设置为INADDR_ANY,这样可以自动获取计算机的IP地址,若计算机被分配了多个IP地址,则只要端口号一致,就可以从不同的IP地址接收数据,服务器优先考虑INADDR_ANY的绑定方式,而客户端中除非带有一部分服务端的功能,否则不会采用

理解TCP和UDP

TCP套接字是面向连接的,因此又被称为基于流的套接字
主要的四层协议栈,数据链路层->IP层->TCP层(UDP层)->应用层
TCP的作用是保证数据交换过程中可以确认对方已经收到数据,并且重传丢失的数据,那么即便IP层不保证数据传输,这类通信也是可靠的。IP层只关注将数据发送出去,但不关心数据是否丢失或者顺序错误
客户端调用了connect函数后,发生以下情况之一才回返回

  • 服务器端接收连接请求,这里的服务器端接收连接不意味着服务器端调用accept函数,其实是服务器端把连接请求信息记录到等待队列
  • 发生断网等异常清场而中断连接
    服务器在创建套接字之后,调用connect函数时会给客户端分配地址,是在操作系统中分配,更准确地说是在内核中,IP用计算机的IP,端口随机

迭代服务器端和客户端

  • 服务器端在同一时刻只与一个客户端相连,并提供echo服务
  • 服务端依次向5个客户端提供服务并退出
  • 客户端接收用户输入的字符串并发送到服务器端
  • 服务器端将接受的字符串数据传回客户端,即echo
  • 服务器端和客户端之间的字符串回升一直执行到客户端输入Q为止
    详情参考echo_server.c和echo_client.c,但是两个程序会有问题,因为基于TCP的情况下,数据传输是不存在数据边界的,因此客户端中多次调用write函数传递的字符串有可能一次性传递到服务器端,然后客户端可能会从服务器端收到多个字符串;另外服务器端还有可能将过长的数据分成几个数据包进行发送,客户端在读取到全部数据之前就调用了read函数。这些问题都是源自TCP的传输特性

TCP套接字中的I/O缓冲

实际上write函数调用后并非立即传输数据,read函数调用后也并非马上接受数据。write函数调用瞬间先将数据放到输出缓冲中,read函数调用之后,从输入缓冲读取数据

  • I/O缓冲在每个TCP套接字中单独存在
  • I/O缓冲在创建套接字时自动生成
  • 关闭套接字也会继续传递输出缓冲中遗留的数据,但是会丢失输入缓冲中的数据
  • 滑动窗口机制可以确保发送的数据不会超过输入缓冲的大小

TCP内部工作原理

  • 与对方建立连接(三次握手)
    套接字是以全双工方式工作的,可以双向传输数据
    A:B你好,我有数据要传给你,建立连接吧
    B:好,我已经准备好接收数据
    A:谢谢你受理我的请求
    A先向B发送SYN消息,然后B会给A发送SYN+ACK消息,最后A会给B发送ACK消息
  • 与对方交换数据
    超时重传,若发送数据的主机,在一定时间内没收到正确的ACK应答,那么便会试着重传,TCP套接字在发送数据后会启动计时器以等待ACK应答,若相应计时器发生了超市则重传
  • 与对方断开连接(四次挥手)
    A:我希望断开连接
    B:哦。是吗,请稍后
    B:我也准备就绪,可以断开连接了
    A:好的,谢谢合作

UDP

UDP在结构上比TCP更加简洁,它不会像TCP那样发送类似SYN和ACK的应答消息,也不会像SEQ那样给数据分配序号,因此UDP的性能比TCP的高很多。在更重视性能而非可靠性的情况下,UDP是一种很好的选择

UDP工作原理

TCP会在不保证可靠交付的IP层进行流控制,UDP不会进行流控制。
在保证实时传输的情况下,应该使用UDP
TCP比UDP慢的原因是收发数据前后进行的连接设置以及清除过程;收发数据过程中为保证可靠性添加的流控制
UDP中的服务器端和客户端没有连接,它不需要再连接状态下交换数据,因此不必调用listen()和accept()函数
在TCP中,套接字是一对一的关系,也就是除了用于守门的socket之外,每一个客户端和服务器通信都需要独立的socket;但是UDP不管是客户端还是服务器端都只需要一个socket;通俗地说UDP像是邮筒通信,TCP更像是打电话,打电话之前需要双方先建立连接,邮筒的通信则不需要,只要有目的地址

UDP的数据I/O

在TCP中,两个套接字建立好连接之后,传输数据时不需要提供地址;但UDP不会保持连接状态,每次的数据传输都需要添加目的地址信息

#include <sys/socket.h>
ssize_t sendto(int sock,void* buff,size_t nbytes,int flags,struct sockaddr *to,socklen_t addrlen)

成功时返回传输的字节数,失败时返回-1

  • sock是用于传输数据的UDP套接字文件描述符
  • buff是保存待传输数据的缓冲
  • 可选项参数,若没有则为0
  • 存有目标地址的sockaddr结构体
  • 上述sockaddr的长度
#include <sys/socket.h>
ssize_t recvfrom(int sock,void *buff,size_t nbytes,int flasgs,struct sockaddr *from,socklen_t *addrlen)
  • sock是用于接收数据的UDP套接字文件描述符
  • buff是用于保存接收数据的缓冲
  • nbytes是可接收的最大长度,因此最大也无法超过buffer长度
  • flags是可选项参数,若没有则传入0
  • sockaddr from是用于保存发送端地址的结构体,因为UDP中数据的发送端并不固定
  • from结构体的长度

UDP客户端套接字的地址分配

在TCP的客户端中,调用connect函数时会自动完成对客户端的IP地址和端口分配,在UDP中,如果调用sentdo函数时返现尚未分配地址信息,则在首次调用sendto函数时给相应套接字自动分配IP和端口,而且此时分配的地址一直保留到程序结束为止,此时分配的IP使用主机IP,端口号选用未使用的端口号

UDP的数据传输特性和调用connect函数

TCP数据传输中不存在边界,表示在数据传输的过程中调用I/O函数的次数不具有任何意义;相反UDP数据传输的过程中存在数据边界,因此输入函数的调用次数应该与输出函数一致

已连接的UDP套接字和未连接UDP套接字

UDP传输数据可以大致分为3个过程

  1. 向UDP套接字注册目标IP和端口号
  2. 传输数据
  3. 删除UDP套接字中注册的目标地址信息
    但有时候,UDP套接字会对同一个目标地址进行多次sendto,这时第一步和第三步操作就显得没必要,UDP套接字默认是没有连接的,上面这个情况就要用到连接的UDP套接字
    创建已连接的UDP套接字需要用到connect函数,使用connect函数使一个UDP套接字成为一个连接的UDP套接字以后,不仅可以使用sendto和recvfrom来收发数据,也可以使用read和write来收发数据

如何断开套接字

之前使用的方法都是调用close()函数单方面断开连接,这种方法不够优雅,close()函数意味着完全断开连接,完全断开意味着无法传输数据,也不能接受数据
为了应对套接字关闭过程中的问题,衍生了半关闭的概念,半关闭指的是断开一部分连接,处于可以传输数据但是无法接受或者可以接收数据但无法传输的状态
shutdown函数,用于半关闭

int shutdown(int sock,int howto)

sock指的是需要断开的套接字文件描述符,howto指的是断开方式信息,一般有以下几项

  • SHUT_RD:断开输入流
  • SHUT_WR:断开输出流
  • SHUT_RDWR:同时断开I/O流
    断开输入流,套接字无法接收数据,即使输入缓冲收到数据也会被抹去;断开输出流,套接字无法传输数据,但如果输入缓冲中留有未传输的数据则传递到目标主机;同时断开IO流则输入流和输出流都会被断开
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值