目录
IP地址及端口号在此处并不做详解。
IP地址
IP地址是给连接到互联网上的每一台主机或者路由器的每一个接口,分配一个在全世界范围内唯一的32位的标识符。(ipv4)
上述的在全球范围内唯一的IP,也即我们所谓的公网IP,但是实际上我们很多人用的电脑使用的可能都是内网IP——尤其是那些连接WiFi上网的机器,我们通过百度查询到的IP往往是我们所连接的路由器的公网IP。(所以我当年ping自己公网IPping不通......)
端口号
端口号的本质是一个2字节16位的整数。
端口号可以用来标识一个进程,告诉操作系统,当前数据需要交给哪一个进程来处理。一个端口号只能被一个进程所占用,一个进程可以占用多个端口号。
初识TCP协议
TCP
Transmission Control Protoco传输控制协议TCP是传输层协议,它具有有连接,可靠传输 以及 面向字节流 的特点。
有连接:
TCP双方在发送数据之前会先“握手”以建立连接。以此确保对方可以正常进行通信,并且沟通双方发送后数据的细节,如序号等。
可靠传输:
TCP保证传输的数据是可靠的,数据有序的到达对面。
面向字节流:
虽然应用层与TCP的交互是一次一个数据块,但是TCP只是将应用层传输过来的数据看成一连串的无结构字节流。
TCP会对这一连串的字节流进行二次操作——TCP会根据当前的网络情况以及对方反馈的的一些相关信息来决定每一次发送的数据报有多少个字节。
所以TCP并不会保证接收端应用层收到的数据块与发送端传输下来的数据块有对应的大小关系。但是TCP会保证接收方接受的字节流整体与发送方应用层传输的一样。
需要注意的是,面向字节流的特点虽然会提高传输效率,但是却会导致TCP粘包问题,这些会在后续的博客中进行讲解。
初识UDP协议
UDP
User Datagram Portocal
用户数据报协议
UDP是传输层协议,它具有无连接,不可靠传输 以及 面向数据报 的特征。
无连接:
使用UDP协议发送数据时,传输双方是不需要在发送数据之前进行任何沟通的。只需要知道对方主机的IP以及使用的端口就可以发送。这往往会导致一个尴尬的情况——对方还没有准备好。
不可靠传输:
UDP协议所发送的数据不保证数据的可靠性,以及数据到达后的有序性。
面向数据报:
应用层的数据传输到传输层时,UDP协议并不会对其大小进行分割等二次包装,而是直接将应用层传输过来的数据完整打包为一个数据报,传输给网络层。
即UDP在与应用层/网络层递交数据的时候,都是整条数据进行交付的。
网络字节序
在C与C++中关于内存的学习中,我们了解到了,如int,float一类多字节数据在内存中的存储是有大端小端之分的。
在文件系统的学习中,我们稍微深入地了解一下也可以得知——磁盘文件中的多字节数据相对于文件中地偏移地址也是有大小端之分的。
以此类推,网络数据流同样也会有大端小端之分。但是我们都知道的是,网络数据流是在不同机器,甚至于不同操作系统间传输的,这样的前提条件下,不免会有传输双方使用的字节序不同的情况,那么这样的问题又是如何解决的呢?
字节序
大端字节序:低位放在高地址
小端字节序:低位放在低地址
主机字节序:主机字节序指的是机器本身的存储数据采用的字节序。大多数机器的主机字节序都是小端,因为目前市场占有率最高的处理器依旧是Intel公司,而Intel公司所采用的架构使用的便是小端字节序。
网络字节序:规定在使用网络传输数据时采用大端字节序。
所以在进行网络传输时,一定要将IP与端口等一系列用于传输过程中识别解析的数据使用大端字节序传输。理论上,如果收发双方使用的都是同一种字节序,那么我们所传输的应用层数据是不用进行大小端转换的。
主机字节序与网络字节序的转换
主机字节序转换成网络字节序
#include <arpa/inet.h>
//ip转换
//ip : uint32_t
uint32_t htonl(uint32_t hostlong);
//htonl : h (host) to n (network) l (long 32位长整型)
//hostlong : 主机字节序形式的ip
//返回值 : 网络字节序形式的ip
//port转换
//port : uint16_t
uint32_t htons(uint16_t hostshort);
//htons : h (host) to n (network) s (short 16位长整型)
//hostlong : 主机字节序形式的port
//返回值 : 网络字节序形式的port
网络字节序转换成主机字节序
#include <arpa/inet.h>
//ip转换
//ip : uint32_t
uint32_t ntohl(uint32_t netlong);
//htonl : n (network) to h (host) l (long 32位长整型)
//netlong : 网络字节序形式的ip
//返回值 : 主机字节序形式的ip
//port转换
//port : uint16_t
uint32_t ntohs(uint16_t hostshort);
//ntohs : n (network) to h (host) s (short 16位长整型)
//netshort : 网络字节序形式的port
//返回值 : 主机字节序形式的port
socket编程接口
套接字
创建套接字:
我们的计算机通过网络发送或接收的数据都会通过一个硬件——网卡,创建套接字的含义就是将进程与网卡进行绑定,进程可以从网卡中接收数据,也可以通过网卡发送数据。
绑定地址信息:
为了能够在网络中标识出一个主机中的一个进程,需要通过绑定ip与绑定端口。
对于接收方而言,绑定信息后发送数据的主机就可以在网络中找到接收方在哪台机器是哪个进程。
对于发送方而言,能够标识网络数据是从哪一个进程发送出去的。
scoket编程
UDP编程流程
UDP准备工作:
服务端:创建套接字,绑定地址信息。
客户端:创建套接字。
PS.此时的客户端可以绑定套接字,也可以不绑定,但并不推荐在这个阶段进行绑定。准确地说是不推荐用户手动绑定。
比如,在手动绑定的情况下,我们会在程序中写明我们要绑定的ip以及端口,但是,同一个端口不能被多个进程所绑定。所以,如果我们已经制定了端口,当我们在一台主机上同时打开两个客户端程序的时候,就会导致报错。
UDP清理工作:
服务端:关闭套接字
客户端:关闭套接字
TCP编程流程
TCP服务端:
创建套接字,绑定地址信息,监听,获取新连接,收发数据,关闭连接
TCP客户端:
创建套接字,绑定地址信息(不推荐),发起连接,收发数据,关闭连接
TCP的一大特点是面向连接,即客户端与服务端在使用TCP协议通信之前必须先建立好TCP连接。而客户端与服务端可以建立起新链接的前提条件就是必须是服务端正在监听客户端的请求的到来——或者说,服务端必须阻塞在那里,等待着新链接的到来。这即是所谓的监听。
在服务端进入监听状态后,如果客户端发起一个新的TCP连接,监听到这个信息的服务端会对这个连接进行处理,如果这个连接是非法的,那么服务端会将这个连接断开;否则,服务端就会与客户端进行连接。
在服务端与客户端建立起一个新的连接的时候,服务端会产生一个新的套接字(文件)描述符——区别于我们使用socket创建的,用于监听的套接字(文件描述符),这个套接字专用于与其连接的客户端通信。
因此,每一个TCP连接都会产生一个套接字描述符。
监听套接字与连接套接字也有着极大的不同,这些于此处不表。
编程接口
创建套接字
#include <sys/socket.h>
int socket(int af, int type, int protocol);
//domain : Address Family,地址族,也就是地址类型
// 使用的是什么版本的网际协议 IP (Internet Protocol)
// AF_UNIX : 本地域套接字(在同一台机器使用文件进行通信)
// AF_INET : ipv4版本网际协议
// AF_INET6 : ipv6版本网际协议
//type : 指定套接字类型
// SOCK_DGRAM : 用户数据报套接字,对应UDP
// SOCK_STREAM : 流式套接字,对应TCP
//protocol : 指定传输协议
// IPPROTO_UDP : 代表UDP协议
// IPPROTO_TCP : 代表TCP协议
// 0 : 按照套接字类型选择默认协议
//返回值 : 成功返回操作句柄,实质为文件描述符
// 失败时返回-1
PS.上述接口中用到的IPPROTO_UDP ,IPPROTO_TCP 在头文件 <netinet/in.h> 中。
绑定地址信息
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr* addr, socklen_t addrlen);
//sockfd : 套接字操作句柄,即socket返回的文件描述符
//addr : 指向地址信息结构的指针,结构中存储绑定的地址信息 IP + PORT
//addrlen : 绑定的地址信息的长度
//返回值 : 成功,返回0
// 失败,返回-1
需注意,bind函数所绑定的是本地的地址信息。
发送接口
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void* buf, size_t len, int flags,
const struct sockaddr* dest_addr, socklen_t addrlen);
//sockfd : 套接字描述符
//buf : 要发送的数据
//len : 发送数据的长度
//flags : 调用方式标志位,一般为0,表示阻塞调用
//dest_addr : 指向一个地址信息结构的指针,包含了目的IP与目的端口
//addrlen : 地址信息结构长度
//返回值 : 成功,返回发送的字节数
// 失败,返回-1
接收接口
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void* buf, size_t len, int flags,
struct sockaddr* src_addr, socklen_t* addrlen);
//sockfd : 套接字描述符
//buf : 准备接收数据的缓冲区
//len : 最大可以接收数据的大小
//flags :
//src_addr : 地址信息结构,接收到的数据来自于哪一台机器 源IP + 源PORT
//addrlen : 输出型参数,返回地址信息结构长度
//返回值 : 成功,返回接收到的字符数
// 失败,返回-1
关闭套接字
既然我们对套接字进行操作的句柄是一个文件描述符,那么毫无疑问,我们可以直接使用close()函数来关闭套接字。
int close(int fd);
TCP监听
#include <sys/socket.h>
int listen(int sockfd, int backlog);
//sockfd : 套接字描述符
//backlog : TCP并发连接数(已完成连接数)
未完成连接队列:还处于连接建立阶段的连接被存放于该队列中,所谓未完成连接,可以理解为正在进行三次握手的连接。
已完成连接队列:指的是在内核中已经建立了连接还没有被应用程序接收的套接字。
TCP新连接套接字获取
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr* addr, socklen_t* addrlen);
//sockfd : 套接字描述符
//addr : 地址信息结构体,描述客户端的地址信息的结构体
//addrlen : 地址信息长度
//返回值 : 成功返回新连接的套接字
// 失败返回-1
需要注意的是,这个函数是一个阻塞调用函数,如果已完成连接队列中没有已完成的连接,那么它会一直阻塞,知道有新连接完成,而后获取并返回。
accept函数返回的新连接的套接字,是用来与某个指定的客户端进行通信的,这个套接字并没有监听功能,同时他已经绑定了客户端的地址信息。
因为accept函数是创建了新的套接字返回,所以当有多个客户端发起链接时,服务端会创建多个新连接套接字——当然,在你的程序中需要保证代码会循环回到listen函数的地方。
还有一点,accept函数中的参数 addr 以及 addrlen 是输出型参数,它们是用来存储客户端的地址信息以及其地址信息结构体的,获取这些地址信息并不是为了用来绑定——套接字与地址信息的绑定早在内核中完成三次握手时就已经完成了。获取客户端的地址信息,主要是为了一些实践中可能会碰到的特殊情况——比如我们在某些通信中设置的黑名单,我们可以使用地址信息的对比来禁止与某些客户端进行通信。
TCP客户端绑定服务端地址信息
#include <sys/socket.h>
int connect(int sockfd, struct sockaddr* addr, socklen_t* addrlen);
//sockfd : 套接字描述符
//addr : 地址信息结构体,描述服务端的地址信息的结构体
//addrlen : 地址信息长度
//返回值 : 成功返回新连接的套接字
// 失败返回-1
connect常用于TCP通信中,UDP通信也可以使用,但会导致很多麻烦。
TCP客户端通过connect函数绑定了服务端的地址信息,从而形成一对一的单播通信。
如果客户端没有绑定自身的地址信息,connect函数同时也会绑定客户端的地址信息。
#include <sys/socket.h>
int send(int sockfd, const void* msg, size_t len, int flag);
//sockfd : 套接字描述符,注意,是使用accept获取的套接字描述符
//msg : 指向所要发送的内容
//len : 要发送的数据长度
//flag : 阻塞标志,为0,则阻塞发送
//返回值 : 成功则返回发送的字节数量
// 失败返回-1
与sendto函数不同的是,send函数中不需要输入目的地的地址信息,因为我们在建立TCP连接时已经给双方的连接套接字绑定了对方的地址信息
关于flag,有更详细的描述如下:
#include <sys/socket.h>
ssize_t recv(int sockfd, void* msg, size_t len, int flags);
//sockfd : 套接字描述符
//msg : 将接收到的数据存放在msg指定空间
//len : 期望接收字节数
//flags : 0 阻塞接收
//返回值 : 成功返回接收字节数
// 若对端关闭了连接则返回 0
// 接收错误则返回 -1
在上述的接口函数中,我们使用了一个 struct sockaddr 结构体。通过查询Linux操作系统提供的man手册,我们可以得到该结构体的具体定义如下:
struct sockaddr {
sa_family_t sa_family; //地址域,大小为2个字节
char sa_data[14];
}
struct sockaddr 是一种通用的数据结构,一般编程中并不直接针对此数据结构操作,而是通过其它与sockaddr等价的数据结构。其中,最常使用的是:
#include <netinet/in.h>
#include <arpa/inet.h>
struct sockaddr_in {
sa_family_t sin_family; //地址族
uint16_t sin_port; //端口号
struct in_addr sin_addr; //32位IP地址
char sin_zero[8]; //预留未使用
}
struct in_addr {
in_addr_t s_addr; //32位IPv4地址
}
那么,为什么要设置这么一个通用的数据结构呢?这样做不是平添麻烦吗?其实不然,从最开始的socket函数我们就可以看出,套接字的Address Family (地址族)并不仅有 ipv4 一种,对于不同的地址族,其地址信息的长度等属性也不尽相同。
所以,一种单一的数据结构是无法满足我们对于不同地址族的地址信息的存储的,但是为此定义多个函数明显也是不靠谱的,因此,使用一种通用的数据结构作为传入的参数,而后在函数内部根据其地址族的不同做出相应的处理。
这也是传参的时候为什么需要传入地址信息结构长度的原因。
对于我们在 socket 函数中提到的三个宏,其对应使用的地址信息结构为:
AF_UNIX : 本地域套接字 sockaddr_un
AF_INET : ipv4版本网际协议 sockaddr_in
AF_INET6 : ipv6版本网际协议 sockaddr_in6
bind()与connect()的区别
bind()函数是用来绑定本机的 ip 端口的。
connect()函数连接的时对端(远端)的 ip 以及端口。