文章目录
在前面我们讲了 TCP/IP、TCP 和 UDP 的一些基本知识,到那时协议只有一套,而我们系统多个 TCP 连接或多个应用程序进程必须通过同一个 TCP 协议端口传输数据。为了区别不同的应用程序进程和连接,许多计算机操作系统为应用程序与 TCP/IP 协议交互提供了称为
套接字(Socket)的接口
。
套接口可以说是网络编程中一个非常重要的概念,Linux 以文件的形式实现套接口,与套接口相应的文件属于 sockfs 特殊文件系统,创建一个套接口就是在 sockfs 中创建一个特殊文件,并建立起为实现套接口功能的相关数据结构。换句话说,对每个新创建的 BSD 套接口,Linux 内核都将在 sockfs 特殊文件系统中创建一个新的 inode。描述套接口的数据结构就是 socket。
套接字简介
套接字 Socket
看作是不同主机之间的进程进行双向通信的端点,简单来说,就是通信双方的一种约定,用套接字中的相关函数完成通信过程。
那网络应用程序是怎样通套接字实现数据发送与接收的呢?
- 在应用程序中创建套接字 Socket,通过绑定与网络驱动建立关系
- 应用程序将数据交给套接字Socket,然后套接字交给网络驱动程序向网络中发送出去。
- 计算机从网络中收到与该套接字绑定的 IP 地址和端口号发来的数据后,由网络驱动程序交给Socket
- 应用程序便可以从该 Socket 中提取接收到的数据
操作系统区分不同应用程序进程间的网络通信和连接,主要有 2 个参数:通信的目的 ip 地址和使用的端口号 port
套接字Socket=(IP地址:端口号)
Socket原意是 “插座”。通过将这2个参数结合起来,与一个“插座” Socket 绑定,应用层就可以和传输层通过套接字接口,区分来自不同应用程序进程或网络连接的通信,实现数据传输的并发服务。
套接字有本地套接字和网络套接字两种。
- 本地套接字的名字是 Linux 文件系统中的文件名,一般放在 /tmp 或者 /usr/tmp 目录中
- 网络套接字的名字是与客户连接的特定网络有关的服务标识符(端口号或访问点)。这个标识符允许 Linux 将进入的针对特定端口号的连接转到正确的服务器进程
套接字类型
常用的 TCP/IP 协议的3种套接字如下。
1. 流套接字(SOCK_STREAM)
- 流套接字用于提供面向连接、可靠的数据传输服务。看到这个,我们不禁想起了 TCP 的特点。
- 该服务将保证数据能够实现无差错、无重复发送、按序接收。流套接字之所以能够实现可靠的数据服务,原因在于其使用了 TCP 协议。
2. 数据报套接字(SOCK_DGRAM)
数据报套接字提供了一种无连接的服务。该服务并不能保证数据传输的可靠性,数据有可能在传输过程中出现丢失或者数据重复,且无法保证按序到达。数据报套接字使用 UDP 协议进行数据传输。由于数据报套接字不能保证数据传输的可靠性,对于有可能出现的数据丢失情况,需要在程序中做相应的处理。
3. 原始套接字(SOCK_RAW)
- 原始套接字允许对较低层次的协议直接访问,比如 IP、ICMP 协议,它常用于检验新的协议实现,或者访问现有服务中配置的新设备,因为 RAW_SOCKET 可以自如地控制 Windows 下的多种协议,能够对网络底层的传输机制进行控制,所以可以用原始套接字来操纵网络层和传输层应用。比如,我们可以通过 RAW_SOCKET 来接收发向本机的 ICMP、IGMP包,或者接收 TCP/IP 栈不能够处理的包,也可以用来发送一些自定义包头或者自定义协议的包。网络监听技术很大程度上依赖于 SOCKET_RAW
原始套接字和标准套接字(SOCK_STREAM、SOCK_DGRAM)的区别在于:原始套接字可以读写内核没有处理的 IP 数据包,而流套接字只能去读取 TCP 协议的数据,数据报套接字只能读取 UDP 协议的数据。因此,如果要访问其他协议发送的数据必须使用原始套接字。
重要的数据结构
下面介绍网络编程中比较重要的几个数据结构
1. 表示套接口的数据结构 struct socket
用户使用 socket 系统调用编写应用程序时,通过一个数字来表示一个 socket,所有的数字就会被映射成一个表示 socket 的结构体,该结构体保存了该 socket 的所有属性和数据
套接口是由 socket 数据结构为代表的,形式如下
struct socket {
socket_state state;
unsigned long flags;
const struct proto_ops *ops;
struct fasync_struct *fasync_list;
struct file *file;
struct sock *sk;
wait_queue_head_t wait;
short type;
};
socket_state state
:表示 socket 所处状态,是一个枚举变量,主要有5种状态:socket 未分配,未连接任何 socket,正在连接过程中,已连接一个 socket,正在断开连接的过程中。该成员只对 TCP socket 有用,因为只有 TCP 是面向连接的协议,UDP 跟 原始套接字不需要维护 socket 的状态const struct proto_ops *ops
:与协议相关的一组操作集。协议栈中共定义了三个 struct proto_ops 类型的变量,分别是 myinet_stream_ops、myinet_dgram_ops、myinet_sockraw_ops,对应流协议、数据报和原始套接口协议的操作函数集struct sock *sk
:sk 是网络层对于 socket 的表示。sk_prot和sk_prot_creator,这两个成员指向特定的协议处理函数集,其类型是结构体struct proto,该结构体也是跟struct proto_ops相似的一组协议操作函数集。这两者之间的概念似乎有些混淆,可以这么理解,struct proto_ops的成员操作struct socket层次上的数据,处理完了,再由它们调用成员sk->sk_prot的函数,操作struct sock层次上的数据。即它们之间存在着层次上的差异。struct proto类型的变量在协议栈中总共也有三个,分别是mytcp_prot,myudp_prot,myraw_prot,对应TCP, UDP和RAW协议。struct file *file
:指向 sockfs 文件系统中的相应文件short type
:type 是 socket 的类型,SOCK_DGRAM= 1,SOCK_STREAM = 2,SOCK_RAW = 3
更加详细的关于 socket 的解释,请参阅Struct Socket详细分析
2. 描述套接口通用地址的数据结构 struct sockaddr
由于历史的缘故,在 bind 、connect 等系统调用中,特定于协议的套接口地址结构指针都要强制转换成该通用的套接口地址结构指针。结构形式如下:
struct sockaddr{
sa_family_t sa_family; //address family,: AF_xxx。sa_family 是地址家族,一般都是 "AF_XXX"
char sa_data[14]; //14字节的协议地址
};
此数据结构用做 bind、connect、recvfrom、sendto 等函数的参数,指明地址信息。但一般编程中并不直接针对此数据结构,而是使用另一个与 sockaddr 等价的数据结构。
3. 描述因特网地址结构的数据结构 struct sockaddr_in
每个套接字域都有自己的地址格式
(1) AF_UNIX 域套接字格式
#include <sys/un.h>
#define UNIX_PATH_MAX 108
struct sockaddr_un {
sa_family_t sun_family; /* AF_UNIX */
char sun_path[UNIX_PATH_MAX]; /* pathname */
};
(2) AF_INET 域套接字格式 IPv4
/* Internet address. */
struct in_addr {
uint32_t s_addr; /* address in network byte order */
};
IP 地址是由 4 字节组成的一个 32 位的值
#include <netinet/in.h>
struct sockaddr_in{
short sin_faminy; // 一般来说 AF_INET(地址族) PRF_INET(协议族)
struct in_addr sin_addr; // 存储 IP 地址
unsigned short sin_port; //端口号,必须采用网络数据格式,普通数字可以用 htons() 函数进行转换
};
unsigned char sin_zero[sizeof(struct sockaddr) - __SOCKADDR_COMMON_SIZE - sizeof(in_port_t) - sizeof(struct in_addr)]; /* 没有实际意义,只是为了跟SOCKADDR 结构在内存中对齐*/
(3) AF_INET6 域套接字格式 IPv6
struct sockaddr_in6{
sa_family_t sin6_family; // AF_INET6
in_port_t sin6_port; //端口号
uint32_t sin6_flowinfo;
struct in6_addr sin6_addr;
uint32_t sin6_scope_id;
};
struct in6_addr{
unsigned char s6_addr[16]; // IPv6 address
};
对于应用程序来说,套接字就和文件描述符一样,通过一个唯一的整数值来区分。
基本接口函数
1. 创建套接字的函数 socket( )
socket 函数对应于普通文件的打开操作。普通文件的打开操作返回一个文件描述符,而 socket( ) 用于创建一个 socket 描述符,它标识唯一一个 socket。这个 socket 描述符跟文件描述符一样,后续操作都要用到它,把它作为参数,通过它来进行一些读写操作。
(1)函数原型声明
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
正如可以给 fopen 传入不同的参数值,以打开不同的文件。创建 socket 的时候,也可以指定不同的参数创建不同的 socket 描述符。
(2)参数介绍
domain
:协议域,又称为协议族(family)
常用的协议族有:AF_INET、AF_INET6、AF_LOCAL(或称 AF_UNIX)、AF_ROUTE等等
协议族决定了 socket 的地址类型,在通信中必须采用对应的地址,如 AF_INET 决定了要用 ipv4 地址(32位)与端口号(16位)的组合,AF_UNIX 决定了要用一个绝对路径名作为地址。type
:指定 socket 类型
常用的 socket 类型有:SOCK_STREAM(TCP 协议)、SOCK_DGRAM(UDP 协议)、SOCK_RAW(原始套接字)、SOCK_PACKET、SOCK_SEQPACKETprotocol
:指定协议
常用的协议有:IPPRTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC 等,它们分别对应 TCP 协议、UDP 协议、SCTP 协议、TIPC 协议
【注意】并不是上面的 type 和 protocol 可以随意组合,如 SOCK_STREAM 不可以跟 IPPTOTO_UDP 组合。
当 protocol 为 0 时,会自动选择 type 类型对应的默认协议
当我们调用 socket 创建一个 socket 时,返回的 socket 描述符它存在于协议族空间中,但没有一个具体的地址。如果想给它赋值一个地址,就必须调用 bind( ) 函数,否则调用 connect()、listen() 时系统会自动随机分配一个端口号
2. 绑定地址函数 bind( ) 【服务器使用】
通过 socket 调用创建的套接字必须经过命名(绑定地址)后才能使用
bind 系统调用把 addr 中的地址分配给与描述符 socket 关联的未命名套接字,地址结构的长度由 addr_len 指定。addr 与 addr_len 因地址族(AF_INET、AF_UNIX)的不同而不同,bind 调用时需要将指向特定地址结构的指针转化为指向通用地址的指针,即 (struct sockaddr *)。
(1)函数原型声明
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
(2)参数介绍
sockfd
:socket 描述符
它是通过 socket( ) 创建的唯一标识。bind( ) 函数就是给这个描述字绑定一个名字addr
:待绑定的地址
一个 const struct sockaddr * 指针,指向要绑定给 sockfd 的协议地址。这个地址结构根据地址创建 socket 时的地址协议族的不同而不同。addrlen
:待绑定的地址长度
参数 addr 为通用地址结构,一般只需要提供固定的端口号,即如下设置:
struct sockaddr_in addr;
addr.sin_family = AF_INET; //IPv4
addr.sin_addr.s_addr = htonl(INADDR_ANY); //接受任意 IP 地址的客户连接
addr_sin_port = htons(port); //指定端口号
len = sizeof(addr);
bind(sockfd, (struct sockaddr*)&addr, len);
[注意]:
bind( ) 函数一般只用于服务器,通常服务器在启动的时候会绑定一个众所周知的地址(如 ip 地址 + 端口号),用于提供服务,客户端可以通过它来连接服务器;而客户端不用指定,由系统自动分配一个端口号与自身的 ip 地址相组合。这就是为什么服务器在 listen 之前会先调用 bind,而客户端就不会调用,而是在 connect 时由系统自动分配一个
【补充】:主机字节序和网络字节序
主机字节序
主机字节序就是我们平常说的大端和小段模式:不同的 CPU 有不同的字节序类型,这些字节序是指整数在内存中保存的顺序,这个叫做主机序。
标准的大端模式(Big-Endian)和 小端模式(Littel-Endian)的定义如下
【小端模式(Little-Endian)】:低字节放在低地址端,高字节放在高地址端
【大端模式(Big-Endian)】:高字节放在低地址端,低字节放在高地址端网络字节序
网络字节序是 TCP/IP 中规定好的一种数据表示格式。它与具体的 CPU 类型、操作系统等无关,从而可以保证数据在不同主机之间传输时能够被正确的解释。网络字节序采用 Big-Endian
4个字节的32 bit 值以下面的次序传输:首先是 0-7bit,其次8~15bit,然后16~23bit,最后是24~31bit。即大端字节序。由于 TCP/IP 首部中所有二进制整数在网络中传输时都要求这种次序,因此它有称作网络字节序。字节序,顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序,一个字节的数据不存在顺序的问题
所以,在将一个地址绑定到 socket 时,请先将主节字节序转换成网络字节序,而不要假定主机字节序和网络字节序一样使用的都是 Big-Endian,所以请谨记对主机字节序不要做任何假定,务必将其转化为网络字节序再赋给socket。
3. 监听函数 listen( ) 【服务器使用】
如果作为一个服务器,在调用 socket()、bind() 之后就会调用 listen() 来监听这个 socket。
如果客户端此时调用 connet() 发出请求,服务器端就会收到这个请求。
(1)函数原型声明
#include <sys/socket.h>
int listen(int sockfd, int backlog);
(2)参数介绍
sockfd
:要监听的 socket 描述符backlog
:服务器端等待连接队列的最大长度,一般默认为5
socket() 函数创建的 socket 默认是一个主动类型的,listen 函数将 socket 变为被动类型的,等待客户的连接请求
4. 发送连接请求函数 connect( )【用于客户端】
客户端通过调用 connect( ) 函数 与 TCP 服务器建立连接
(1)函数原型声明
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
(2)参数介绍
sockfd
:客户端的 socket 描述符addr
:服务器的 socket 地址addrlen
:socket 地址的长度
//客户端 connect 设置模板
struct sockaddr_in *server_addr;
server_addr.sin_family = AF_INET;
//服务器地址,不需要 htonl 转换,因为 inet_addr 已定义为 网络字节序
server_addr.sin_addr.s_addr = inet_addr("xxxx.xxxx.xxxx.xxxx");
server_addr.sin_port = htons(port); //与服务器相同的端口号
connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)); //可根据返回值判断连接状态
5. 接受连接请求的函数 accept( ) 【用于服务器】
- TCP 服务器依次调用 socket()、bind()、listen()之后,服务器就开始监听指定的 socket 地址了
- TCP 客户端依次调用 socket()、connect()之后就向服务器发起了一个连接请求
- TCP 在收到这个请求之后,就会调用 accept() 函数接受请求,这样就成功建立了连接。之后就可以进行网络 I/O 操作;即类似于普通文件的读写 I/O 操作。
(1)函数原型声明
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
(2)参数介绍
sockfd
:服务器的 socket 描述符addr
:返回参数,返回客户端的协议地址addrlen
:返回参数,返回客户端协议地址的长度
如果 accept 成功,那么其返回值是由内核自动生成的一个全新的描述符,代表与返回客户的 TCP 连接
【注意】
- accept 的第一个参数为服务器调用 socket() 函数创建的 socket 描述符,称为监听描述符;而 accept() 函数返回的描述符是已连接的 socket 描述符。
- 一个服务器通常只创建一个监听 socket 描述符,他在该服务器的生命周期内一直存在。内核为每个由服务器接受的客户连接创建了一个已连接的 socket 描述符,当服务器完成了对某个客户的服务,相应的已连接的 socket 描述符就被关闭。
6. 用于读写的 I/O 函数
调用网络 I/O 进行读写操作,即实现了网络中不同进程之间的通信!网络 I/O 操作有下面几组:
1. read() / write()
2. recv() / send()
3. readv() / writev()
4. recvmsg() / sendmsg()
5. recvfrom() / sendto()
(1) read 和 write
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
【参数】
fd
:文件描述符buf
:数据缓冲区,用于保存要从 fd 读取或写入的数据count
:输入/写入的数据最大字节数(实际读取或写入的数据大小可能小于count)【返回值】
- 成功:读取或写入的真正的数据大小
- 失败:0
- 返回 -1:函数调用错误,errno值会被设置
(2)readv 和 writev
#include <sys/uio.h>
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
根据iov预先制定的格式读取或写入数据。
相当于写数据块,并且可以制定数据块的大小。
具体参考结构体struct iovec
struct iovec {
void *iov_base; /* Starting address */
size_t iov_len; /* Number of bytes to transfer */
};
(3)send 和 recv
send 和 recv 用于已经建立连接的套接字通信(UDP也有建立连接的)
#include <sys/socket.h>
#include <sys/types.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int fd, void *buf, size_t len, int flags);
前面三个参数类似于 read / write
flags 参数有如下选择:
- MSG_DONTROUTE 勿将数据路由出本地网络
- MSG_DONTWAIT 允许非阻塞操作(等价于使用O_NONBLOCK)
- MSG_EOR 如果协议支持,此为记录结束
- MSG_OOB 如果协议支持,发送带外数据
- MSG_NOSIGNAL 禁止向系统发送异常信息
【返回值】成功则返回实际传送出去的字符数,失败返回-1,错误原因存于errno 中。
(4)recvfrom 和 sendto
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
【参数】
- 前面三个参数与read/write的三个参数类似,分别表示文件描述符,数据缓冲区,最大读取/写入的数据大小
- flags: 与send和recv一样
- src_addr:传出参数,用于接收发送者的地址
- dest_addr:传入参数,要发送的目的地协议地址的套接字地址结构
【返回值】真正发送/接收的数据的大小
【注意点】
- 我们可以看到,sendto 和 recvfrom 函数均含有一个跟对端地址相关的参数(src_addr,dest_addr),因此可以在没有建立连接的网络通信(UDP)中使用。这里 sockfd 只需要通过 socket() 进行创建,而不一定需要 connect() 进行连接。(不需要不代表不能,后面进一步介绍)
- 在一些时候,我们需要使用 connect() 为 UDP 通信建立连接( 因为 UDP 是不可靠的,但我们却想要将异步错误返回)。这里的连接于 TCP 的连接是不一样的。UDP 的 connect 相当于 TCP 的 connect 重载,它没有三次握手的过程,更倾向于绑定的概念。UDP 的 connect() 只是将套接字与 IP 地址进行绑定。
- 使用有连接的 UDP 通信时,我们一般不使用 sendto 和 recvfrom,而使用 send 和 recv 等函数。如非要使用 sendto 和 recvfrom,则 src_addr, dest_addr,参数必须为 NULL,len 必须为0。
(5)recvmsg 和 sendmsg
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
struct msghdr {
void *msg_name; /* optional address */
socklen_t msg_namelen; /* size of address */
struct iovec *msg_iov; /* scatter/gather array */
size_t msg_iovlen; /* # elements in msg_iov */
void *msg_control; /* ancillary data, see below */
size_t msg_controllen; /* ancillary data buffer len */
int msg_flags; /* flags (unused) */
};
[注意]
如果套接口为 SOCK_STREAM 类型,并且远端 “优雅” 地终止了连接(发送端 send 后立即关闭套接字,还没测试),那么 recv() 一个数据也不读取,立即返回。如果立即被强制终止,那么 recv() 将以 WSAECONNRESET 错误失败返回。flags 参数和套接字选项都会影响网络 I/O 函数的调用方式。
7. 关闭连接函数 close( )
close 一个 TCP socket 的缺省行为时把该 socket 标记为已关闭,然后立即返回到调用进程。该描述符不能再被调用进程使用,也就是说不能再作为 read 和 write 的第一个参数。
int close(int fd);
【注意】:close操作只是使相应socket描述字的引用计数-1,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。
总结
套接字通信建立过程
服务器端
- socket() – > 创建服务器监听套接字
服务器应用程序调用 socket() 函数创建一个套接字。它是系统分配给服务器进程类似于文件描述符的资源 - bind() – > 绑定服务器监听信息到套接字
服务器进程用 bind() 函数命名套接字。然后服务器就开始等待客户连接到这个命名套接字 - listen() – > 开始监听,接收客户端的 TCP 连接
- accept() – > 接受客户端发来的连接请求,成功建立连接
从 listen 队列中取出一条已连接的 TCP,返回该连接的 socket 描述符。服务器通过系统调用 accept() 来接受客户端的连接。accept 会创建一个不同于命名套接字的新套接字来与这个特定客户端进行通信,而命名套接字则被保留下来继续处理其他客户的连接请求。 - close() --> 关闭套接字,关闭打开的套接字描述符
【注意】 listen 和 accept 是面向连接的套接字才会有的,正常的无连接通信在 bind 之后,服务器就会阻塞,知道接收到客户端发来的数据
TCP服务器
UDP 服务器
客户端
1. socket() --> 创建客户端连接套接字
2. connect() --> 向指定服务器发起一个连接请求
调用connect与服务器建立连接,将服务器的命名套接字作为一个地址。
然后服务器客户端在连接socket描述字上进行消息通信
3. close() --> 关闭套接字
TCP 客户端
UDP 客户端