Linux 系统是依靠互联网平台迅速发展起来的,所以它具有强大的网络功能支持,也是Linux 系统的一大特点。互联网对人类社会产生了巨大影响,它几乎改变了人们生活的方方面面,可见互联网对人类社会的重要性!
本章我们便来学习一些网络基础知识,为下一章学习网络编程打下一个基础;本章会向大家介绍网络基础知识,譬如网络通信概述、OSI 七层模型、IP 地址、TCP/IP 协议族、TCP 和UDP 协议等等,其中并不会深入、详细地介绍这些内容,旨在以引导大家入门、了解为主;如果感兴趣的读者可以自行查阅相关书籍进行深入学习。
网络通信本质上是一种进程间通信,是位于网络中不同主机上的进程之间的通信,属于IPC 的一种,通常称为socket IPC,在第十章中给大家简单地提到过,如图10.2.1 中所示。所以网络通信是为了解决在网络环境中,不同主机上的应用程序之间的通信问题。
大概可以分为三个层次,如下所示:
(1)、硬件层:网卡设备,收发网络数据
(2)、驱动层:网卡驱动(Linux 内核网卡驱动代码)
(3)、应用层:上层应用程序(调用socket 接口或更高级别接口实现网络相关应用程序)
在硬件上,两台主机都提供了网卡设备,也就满足了进行网络通信最基本的要求,网卡设备是实现网络数据收发的硬件基础。并且通信的两台主机之间需要建立网络连接,这样两台主机之间才可以进行数据传输,譬如通过网线进行数据传输。网络数据的传输媒介有很多种,大体上分为有线传输(譬如双绞线网线、光纤等)和无线传输(譬如WIFI、蓝牙、ZigBee、4G/5G/GPRS 等),PC 机通常使用有线网络,而手机等移动设备通常使用无线网络。
在内核层,提供了网卡驱动程序,可以驱动底层网卡硬件设备,同时向应用层提供socket 接口。
在应用层,应用程序基于内核提供的socket 接口进行应用编程,实现自己的网络应用程序。需要注意的是,socket 接口是内核向应用层提供的一套网络编程接口,所以我们学习网络编程其实就是学习socket 编程,如何基于socket 接口编写应用程序。
除了socket 接口之外,在应用层通常还会使用一些更为高级的编程接口,譬如http、网络控件等,那么这些接口实际上是对socket 接口的一种更高级别的封装。在正式学习socket 编程之前,我们需要先了解一些网络基础知识,为后面的学习打下一个理论基础。
网络通信知识庞大,其中涉及到一大堆的网络协议(TCP/IP 协议族),笔者不可能把这些内容给大家介绍清楚,本章仅仅只是进行简单介绍,以了解为目的。
七层模型,亦称OSI(Open System Interconnection)。OSI 七层参考模型是国际标准化组织(ISO)制定的一个用于计算机或通信系统间网络互联的标准体系,一般称为OSI 参考模型或七层模型。OSI 七层模型是一个网络互连模型,从上到下依次是:
从上可知,网络通信的模型分了很多层,为什么需要分为这么多层次?原因就在于网络是一种非常复杂的通信,需要进行分层,每一层需要去实现不同的功能。下面我们来详细看下OSI 参考模型中每一层的作用。
应用层
应用层(Application Layer)是OSI 参考模型中的最高层,是最靠近用户的一层,为上层用户提供应用接口,也为用户直接提供各种网络服务。我们常见应用层的网络服务协议有:HTTP、FTP、TFTP、SMTP、
SNMP、DNS、TELNET、HTTPS、POP3、DHCP。
表示层
表示层(Presentation Layer)提供各种用于应用层数据的编码和转换功能,确保一个系统的应用层发送的数据能被另一个系统的应用层识别。如果必要,该层可提供一种标准表示形式,用于将计算机内部的多种数据格式转换成通信中采用的标准表示形式。数据压缩/解压缩和加密/解密(提供网络的安全性)也是表示层可提供的功能之一。
会话层
会话层(Session Layer)对应主机进程,指本地主机与远程主机正在进行的会话。会话层就是负责建立、管理和终止表示层实体之间的通信会话。该层的通信由不同设备中的应用程序之间的服务请求和响应组成。将不同实体之间表示层的连接称为会话。因此会话层的任务就是组织和协调两个会话进程之间的通信,并对数据交换进行管理。
传输层
传输层(Transport Layer)定义传输数据的协议端口号,以及端到端的流控和差错校验。该层建立了主机端到端的连接,传输层的作用是为上层协议提供端到端的可靠和透明的数据传输服务,包括差错校验处理和流控等问题。我们通常说的,TCP、UDP 协议就工作在这一层,端口号既是这里的“端”。
网络层
进行逻辑地址寻址,实现不同网络之间的路径选择。本层通过IP 寻址来建立两个节点之间的连接,为源端发送的数据包选择合适的路由和交换节点,正确无误地按照地址传送给目的端的运输层。网络层(Network Layer)也就是通常说的IP 层。该层包含的协议有:IP(Ipv4、Ipv6)、ICMP、IGMP 等。
数据链路层
数据链路层(Data Link Layer)是OSI 参考模型中的第二层,负责建立和管理节点间逻辑连接、进行硬件地址寻址、差错检测等功能。将比特组合成字节进而组合成帧,用MAC 地址访问介质,错误发现但不能纠正。
数据链路层又分为2 个子层:逻辑链路控制子层(LLC)和媒体访问控制子层(MAC)。MAC 子层的主要任务是解决共享型网络中多用户对信道竞争的问题,完成网络介质的访问控制;LLC 子层的主要任务是建立和维护网络连接,执行差错校验、流量控制和链路控制。
数据链路层的具体工作是接收来自物理层的位流形式的数据,并封装成帧,传送到上一层;同样,也将来自上层的数据帧,拆装为位流形式的数据转发到物理层;并且,还负责处理接收端发回的确认帧的信息,以便提供可靠的数据传输。
物理层
物理层(Physical Layer)是OSI 参考模型的最低层,物理层的主要功能是:利用传输介质为数据链路层提供物理连接,实现比特流的透明传输,物理层的作用是实现相邻计算机节点之间比特流的透明传送,尽可能屏蔽掉具体传输介质和物理设备的差异。使数据链路层不必考虑网络的具体传输介质是什么。“透明传送比特流”表示经实际电路传送后的比特流没有发生变化,对传送的比特流来说,这个电路好像是看不见的。
实际上,网络数据信号的传输是通过物理层实现的,通过物理介质传输比特流。物理层规定了物理设备标准、电平、传输速率等。常用设备有(各种物理设备)集线器、中继器、调制解调器、网线、双绞线、同轴电缆等,这些都是物理层的传输介质。
以上便是对OSI 参考模型中的各个层进行的简单介绍,网上也有很多文章对OSI 参考模型做过详细地介绍。除了OSI 七层模型之外,大家可能还听过TCP/IP 四层模型、TCP/IP 五层模型,那么这些又是什么呢?接下来将向大家介绍。
事实上,TCP/IP 模型是OSI 模型的简化版本,我们来看看OSI 七层模型和TCP/IP 五层模型之间的对应的关系:
所以由上图可知,TCP/IP 五层模型中,将OSI 七层模型的最上三层(应用层、表示层和会话层)合并为一个层,即应用层,所以TCP/IP 五层模型包括:应用层、传输层、网络层、数据链路层以及物理层。除了TCP/IP 五层模型外,还有TCP/IP 四层模型,与五层模型唯一不同的就是将数据链路层和物理层合并为网络接口层,如下图所示:
由上图可知,四层模型包括:应用层、传输层、网络层以及网络接口层。而在实际的应用中还是使用
TCP/IP 四层模型,五层模型是专门为介绍网络原理而设计的。
网络通信中,数据从上层到下层交付时,要进行封装;同理,当目标主机接收到数据时,数据由下层传递给上层时需要进行拆封。这就是数据的封装与拆封。
数据的封装过程如下图所示:
当用户发送数据时,将数据向下交给传输层,但是在交给传输层之前,应用层相关协议会对用户数据进行封装,譬如MQTT、HTTP 等协议,其实就是在用户数据前添加一个应用程序头部,这是处于应用层的操作,最后应用层通过调用传输层接口来将封装好的数据交给传输层。
传输层会在数据前面加上传输层首部(此处以TCP 协议为例,图中的传输层首部为TCP 首部,也可以是UDP 首部),然后向下交给网络层。
同样地,网络层会在数据前面加上网络层首部(IP 首部),然后将数据向下交给链路层,链路层会对数据进行最后一次封装,即在数据前面加上链路层首部(此处使用以太网接口为例,对应以太网首部),然后将数据交给网卡。
最后,由网卡硬件设备将数据转换成物理链路上的电平信号,数据就这样被发送到了网络中。这就是网络数据的发送过程,从图中可以看到,各层协议均会对数据进行相应的封装,可以概括为TCP/IP 模型中的各层协议对数据进行封装的过程。
以上便是网络数据的封装过程,当数据被目标主机接收到之后,会进行相反的拆封过程,将每一层的首部进行拆解最终得到用户数据。所以,数据的接收过程与发送过程正好相反,可以概括为TCP/IP 模型中的各层协议对数据进行解析的过程。
Internet 依靠TCP/IP 协议,在全球范围内实现不同硬件结构、不同操作系统、不同网络系统的主机之间的互联。在Internet 上,每一个节点都依靠唯一的IP 地址相互区分和相互联系,IP 地址用于标识互联网中的每台主机的身份,设计人员为每个接入网络中的主机都分配一个IP 地址(Internet Protocol Address),只有合法的IP 地址才能接入互联网中并且与其他主机进行网络通信,IP 地址是软件地址,不是硬件地址,硬件MAC 地址是存储在网卡中的,应用于局域网中寻找目标主机。
互联网中的每一台主机都需要一个唯一的IP 地址以标识自己的身份,那么IP 地址究竟是什么,如何去定义一个IP 呢?我们需要对IP 地址的编址方式进行了解。
传统的IP 地址是一个32 位二进制数的地址,也叫IPv4 地址,由4 个8 位字段组成。除了IPv4 之外,还有IPv6,IPv6 采用128 位地址长度,8 个16 位字段组成,本小节我们暂时不去理会IPv6 地址。
在网络通信数据包中,IP 地址以32 位二进制的形式表示;而在人机交互中,通常使用点分十进制方式表示,譬如192.168.1.1,这就是点分十进制的表示方式。
IP 地址中的32 位实际上包含2 部分,分别为网络地址和主机地址,可通过子网掩码来确定网络地址和主机地址分别占用多少位。
根据IP 地址中网络地址和主机地址两部分分别占多少位的不同,将IP 地址划分为5 类,分别为A、
B、C、D、E 五类,如下所示:
1、A 类IP 地址
从上图中可以看到,一个A 类IP 地址由1 个字节网络地址和3 个字节主机地址组成,而网络地址的最高位必须为0,因此可知,网络地址取值范围为0~127,一共128 个网络地址。当然,这128 个网络地址中,其中3 个网络地址用作特殊用途,因此可用的网络地址有125 个。
(1)、A 类地址的第一字节为网络地址,其它3 个字节为主机地址;
(2)、A 类地址范围为:1.0.0.1 ~ 127.255.255.254;
(3)、A 类地址中设有私有地址和保留地址:
①、10.X.X.X 是私有地址,所谓私有地址就是在互联网中不能使用,而被用在局域网中使用的地址。
②、127.X.X.X 是保留地址,用作循环测试使用。
2、B 类IP 地址
一个B 类IP 地址由2 个字节的网络地址和2 个字节的主机地址组成,网络地址的最高位必须是“10”,因此,网络地址第一个字节的取值范围为128~191,IP 地址范围从128.0.0.0 到191.255.255.255。对于B 类地址来说,一共拥有16384 个网络地址,其中可用的网络地址有16382 个,每个网络地址能容纳约6 万(2^16-2=65534)多个主机。
(1)、B 类地址中第1 字节和第2 字节为网络地址,其它2 个字节为主机地址。
(2)、B 类地址范围:128.0.0.1 ~ 191.255.255.254。
(3)、B 类地址中设有私有地址和保留地址:
①、172.16.0.0 ~ 172.31.255.255 是私有地址
②、169.254.X.X 是保留地址。如果你的IP 地址是自动获取IP 地址,而你在网络上又没有找到可用的
DHCP 服务器。就会得到其中一个IP。
3、C 类IP 地址
一个C 类IP 地址由3 字节的网络地址和1 字节的主机地址组成,网络地址的最高位必须是“110”,因此C 类IP 地址的第一个字节的取值范围为192~223。范围从192.0.0.0 到223.255.255.255,网络地址可达
209 万余个,每个网络地址能容纳254 个主机。
(1)、C 类地址第1 字节、第2 字节和第3 个字节为网络地址,第4 个个字节为主机地址。另外第1 个字节的高三位固定为110。
(2)、C 类地址范围为:192.0.0.1 ~ 223.255.255.254。
(3)、C 类地址中的私有地址:192.168.X.X 是私有地址。
4、D 类IP 地址
D 类IP 地址第一个字节以“1110”开始,它是一个专门保留的地址,它并不指向特定的网络,目前这一类地址被用在多点广播(多播,Multicast),多点广播地址用来一次寻址一组计算机,它标识共享同一协议的一组计算机。
(1)、D 类地址不分网络地址和主机地址,它的第1 个字节的高四位固定为1110。
(2)、D 类地址范围:224.0.0.1 ~ 239.255.255.254。
5、E 类IP 地址
E 类IP 地址以“llll0”开始,为将来使用保留。全零(“0.0.0.0”)地址对应于当前主机。全“1”的IP 地址(“255.255.255.255”)是当前子网的广播地址。
(1)、E 类地址也不分网络地址和主机地址,它的第1 个字节的前五位固定为11110。
(2)、E 类地址范围:240.0.0.1 ~ 255.255.255.254。
总结
以上就给大家介绍了这5 类IP 地址,其中在A、B、C 三类地址中,各保留了一个区域作为私有地址:
A 类地址:10.0.0.0~10.255.255.255
B 类地址:172.16.0.0~172.31.255.255
C 类地址:192.168.0.0~192.168.255.255
A 类地址的第一组数字为1~126。
B 类地址的第一组数字为128~191。
C 类地址的第一组数字为192~223。
A 类地址的表示范围为:0.0.0.0~126.255.255.255,默认网络掩码为:255.0.0.0;A 类地址分配给规模特别大的网络使用。A 类地址用第一组数字表示网络地址,后面三组数字作为连接于网络上的主机对应的地址。分配给具有大量主机而局域网络个数较少的大型网络,譬如IBM 公司的网络。
B 类地址的表示范围为:128.0.0.0~191.255.255.255,默认网络掩码为:255.255.0.0;B 类地址分配给一般的中型网络。B 类地址用第一、二组数字表示网络地址,后面两组数字代表网络上的主机地址。
C 类地址的表示范围为:192.0.0.0~223.255.255.255,默认网络掩码为:255.255.255.0;C 类地址分配给小型网络,如一般的局域网和校园网,它可连接的主机数量是最少的,采用把所属的用户分为若干的网段进行管理。C 类地址用前三组数字表示网络地址,最后一组数字作为网络上的主机地址。
下面给大家介绍一些特殊的IP 地址,这些IP 地址不能分配给任何一个网络的主机使用。
直接广播地址
直接广播(Direct Broadcast Address):向某个网络上所有的主机发送报文。TCP/IP 规定,主机号各位全部为“1”的IP 地址用于广播,叫作广播地址。譬如一个IP 地址是192.168.0.181,这是一个C 类地址,所以它的主机号只有一个字节,那么对主机号全取1 得到一个广播地址192.168.0.255,向这个地址发送数据就能让同一网络下的所有主机接收到。
A、B、C 三类地址的广播地址结构如下:
⚫ A 类地址的广播地址为:XXX.255.255.255(XXX 为A 类地址中网络地址对应的取值范围,譬如:
120.255.255.255)。
⚫ B 类地址的广播地址为:XXX.XXX.255.255(XXX 为B 类地址中网络地址的取值范围,譬如
139.22.255.255)。
⚫ C 类地址的广播地址为:XXX.XXX.XXX.255(XXX 为C 类地址中网络地址的取值范围,譬如
203.120.16.255)。
受限广播地址
直接广播要求发送方必须广播网络对应的网络号。但有些主机在启动时,往往并不知道本网络的网络号,这时候如果想要向本网络广播,只能采用受限广播地址(Limited Broadcast Address)。
受限广播地址是在本网络内部进行广播的一种广播地址,TCP/IP 规定,32 比特全为“1”的IP 地址用于本网络内的广播,也就是255.255.255.255。
多播地址
多播地址用在一对多的通信中,即一个发送者,多个接收者,不论接受者数量的多少,发送者只发送一次数据包。多播地址属于D 类地址,D 类地址只能用作目的地址,而不能作为主机中的源地址。
环回地址
环回地址(Loopback Address)是用于网络软件测试以及本机进程之间通信的特殊地址。把A 类地址中的127.XXX.XXX.XXX 的所有地址都称为环回地址,主要用来测试网络协议是否工作正常的作用。比如在电脑中使用ping 命令去ping 127.1.1.1 就可以测试本地TCP/IP 协议是否正常。
不能将环回地址作为任何一台主机的IP 地址使用。
0.0.0.0 地址
IP 地址32bit 全为0 的地址(也就是0.0.0.0)表示本网络上的本主机,只能用作源地址。
0.0.0.0 是不能被ping 通的,在服务器中,0.0.0.0 并不是一个真实的的IP 地址,它表示本机中所有的
IPv4 地址。监听0.0.0.0 的端口,就是监听本机中所有IP 的端口。
如何判断两个IP 地址是否处于同一个子网,可通过网络标识来进行判断,网络标识定义如下:
网络标识= IP 地址& 子网掩码
2 个IP 地址的网络标识相同,那么它们就处于同一网络。譬如192.168.1.50 和192.168.1.100,这2 个都是C 类地址,对应的子网掩码为255.255.255.0,很明显,这两个IP 地址与子网掩码进行按位与操作时得到的结果(网络标识)是一样的,所以它们处于同一网络。
首先给大家说明的是,TCP/IP 协议它其实是一个协议族,包含了众多的协议,譬如应用层协议HTTP、
FTP、MQTT…以及传输层协议TCP、UDP 等这些都属于TCP/IP 协议,可参考图29.2.1 所示。
所以,我们一般说TCP/IP 协议,它不是指某一个具体的网络协议,而是一个协议族。网络通信当中涉及到的网络协议实在太多了,对于应用开发来说,可能使用更多的是应用层协议,譬如HTTP、FTP、SMTP
等。
HTTP 协议
HTTP 超文本传输协议(英文:HyperText Transfer Protocol,缩写:HTTP)是一种用于分布式、协作式和超媒体信息系统的应用层协议。HTTP 是万维网数据通信的基础。HTTP 的应用最为广泛,譬如大家经常会打开网页浏览器查询资料,通过浏览器便可开启HTTP 通信。
HTTP 协议工作于客户端(用户)、服务器端(网站)模式下,浏览器作为HTTP 客户端通过URL 向
HTTP 服务端即WEB 服务器发送请求。Web 服务器根据接收到的请求后,向客户端发送响应信息。借助这种浏览器和服务器之间的HTTP 通信,我们能够足不出户地获取网络中的各种信息。
FTP 协议
FTP 协议的英文全称为File Transfer Protocol,简称为FTP,它是一种文件传输协议,从一个主机向一个主机传输文件的协议。FTP 协议同样也是基于客户端-服务器模式,在客户端和服务器之间进行文件传输,譬如我们通常会使用FTP 协议在两台主机之间进行文件传输,譬如一台Ubuntu 系统主机和一台Windows
系统主机,将一台主机作为FTP 服务器、另一台主机作为FTP 客户端,建立FTP 连接之后,客户端可以从服务器下载文件,同样也可以将文件上传至服务器。
FTP 除了基本的文件上传/下载功能外,还有目录操作、权限设置、身份验证等机制,许多网盘的文件传输功能都是基于FTP 实现的。
其它的TCP/IP 协议就不给大家介绍了,有兴趣的读者可以自行百度了解。
下小节我们重点给大家介绍下工作在传输层的TCP、UDP 协议,这两种协议相比各位读者听得比较多。
TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于IP 的传输协议。由图29.2.1 可知,TCP 协议工作在传输层,对上服务socket 接口,对下调用IP 层(网络层)。
关于TCP 协议我们需要理解的重点如下:
①、TCP 协议工作在传输层,对上服务socket 接口,对下调用IP 层;
②、TCP 是一种面向连接的传输协议,通信之前必须通过三次握手与客户端建立连接关系后才可通信;
③、TCP 协议提供可靠传输,不怕丢包、乱序。
TCP 协议如何保证可靠传输?
①、TCP 协议采用发送应答机制,即发送端发送的每个TCP 报文段都必须得到接收方的应答,才能认为这个TCP 报文段传输成功。
②、TCP 协议采用超时重传机制,发送端在发送出一个TCP 报文段之后启动定时器,如果在定时时间内未收到应答,它将重新发送该报文段。
③、由于TCP 报文段最终是以IP 数据报发送的,而IP 数据报到达接收端可能乱序、重复、所以TCP
协议还会将接收到的TCP 报文段重排、整理、再交付给应用层。
TCP 协议的特点如下所示:
⚫ 面向连接的
TCP 是一个面向连接的协议,无论哪一方向另一方发送数据之前,都必须先在双方之间建立一个TCP
连接,否则将无法发送数据,通过三次握手建立连接,后面在介绍。
⚫ 确认与重传
当数据从主机A 发送到主机B 时,主机B 会返回给主机A 一个确认应答;TCP 通过确认应答ACK 实现可靠的数据传输。当发送端将数据发送出去之后会等待对端的确认应答。如果有确认应答,说明数据已经成功到达对端。反之,数据丢失的可能性比较大。
在一定的时间内如果没有收到确认应答,发送端就可以认为数据已经丢失,并进行重发。由此,即使产生了丢失,仍然可以保证数据能够到达对端,实现可靠传输。
⚫ 全双工通信
TCP 连接一旦建立,就可以在连接上进行双向的通信。任何一个主机都可以向另一个主机发送数据,数据是双向流通的,所以TCP 协议是一个全双工的协议。
⚫ 基于字节流而非报文
将数据按字节大小进行编号,接收端通过ACK 来确认收到的数据编号,通过这种机制能够保证TCP 协议的有序性和完整性,因此TCP 能够提供可靠性传输。
⚫ 流量控制(滑动窗口协议)
TCP 流量控制主要是针对接收端的处理速度不如发送端发送速度快的问题,消除发送方使接收方缓存溢出的可能性。TCP 流量控制主要使用滑动窗口协议,滑动窗口是接受数据端使用的窗口大小,用来告诉发送端接收端的缓存大小,以此可以控制发送端发送数据的大小,从而达到流量控制的目的。这个窗口大小就是我们一次传输几个数据。对所有数据帧按顺序赋予编号,发送方在发送过程中始终保持着一个发送窗口,只有落在发送窗口内的帧才允许被发送;同时接收方也维持着一个接收窗口,只有落在接收窗口内的帧才允许接收。这样通过调整发送方窗口和接收方窗口的大小可以实现流量控制。
⚫ 差错控制
TCP 协议除了确认应答与重传机制外,TCP 协议也会采用校验和的方式来检验数据的有效性,主机在接收数据的时候,会将重复的报文丢弃,将乱序的报文重组,发现某段报文丢失了会请求发送方进行重发,因此在TCP 往上层协议递交的数据是顺序的、无差错的完整数据。
⚫ 拥塞控制
如果网络上的负载(发送到网络上的分组数)大于网络上的容量(网络同时能处理的分组数),就可能引起拥塞,判断网络拥塞的两个因素:延时和吞吐量。拥塞控制机制是:开环(预防)和闭环(消除)。
流量控制是通过接收方来控制流量的一种方式;而拥塞控制则是通过发送方来控制流量的一种方式。
TCP 发送方可能因为IP 网络的拥塞而被遏制,TCP 拥塞控制就是为了解决这个问题(注意和TCP 流量控制的区别)。
TCP 拥塞控制的几种方法:慢启动,拥塞避免,快重传和快恢复。
从图29.2.4 可知,当数据由上层发送到传输层时,数据会被封装为TCP 数据段,我们将其称为TCP 报文(或TCP 报文段),TCP 报文由TCP 首部+数据区域组成,一般TCP 首部通常为20 个字节大小,具体格式如下图所示:
下面分别对其中的字段进行介绍:
源端口号和目标端口号
源端口号和目标端口号各占2 个字节,一个4 个字节,关于端口号的概念会在29.5.3 小节进行介绍。每个TCP 报文都包含源主机和目标主机的端口号,用于寻找发送端和接收端应用进程,这两个值加上IP 首部中的源IP 地址和目标IP 地址就能确定唯一一个TCP 连接。有时一个IP 地址和一个端口号也称为socket
(插口)。
序号
占4 个字节,用来标识从TCP 发送端向TCP 接收端发送的数据字节流,它的值表示在这个报文段中的第一个数据字节所处位置码,根据接收到的数据区域长度,就能计算出报文最后一个数据所处的序号,因为
TCP 协议会对发送或者接收的数据进行编号(按字节的形式),那么使用序号对每个字节进行计数,就能很轻易管理这些数据。
在TCP 传送的数据流中,每一个字节都有一个序号。例如,一报文段的序号为300,而且数据共100 字节,则下一个报文段的序号就是400;序号是32bit 的无符号数,序号到达2^32-1 后从0 开始。
确认序号
确认序号占4 字节,是期望收到对方下次发送的数据的第一个字节的序号,也就是期望收到的下一个报文段的首部中的序号;确认序号应该是上次已成功收到数据字节序号+1。只有ACK 标志为1 时,确认序号才有效。TCP 为应用层提供全双工服务,这意味数据能在两个方向上独立地进行传输,因此确认序号通常会与反向数据(即接收端传输给发送端的数据)封装在同一个报文中(即捎带),所以连接的每一端都必须保持每个方向上的传输数据序号准确性。
首部长度
首部长度字段占4 个bit 位,它指出了TCP 报文段首部长度,以字节为单位,最大能记录15*4=60 字节的首部长度,因此,TCP 报文段首部最大长度为60 字节。在字段后接下来有6bit 空间是保留未用的,供以后应用,现在置为0。
6 个标志位:URG/ACK/PSH/RST/SYN/FIN
保留位之后有6 个标志位,分别如下:
①、URG:首部中的紧急指针字段标志,如果是1 表示紧急指针字段有效。
②、ACK:只有当ACK=1 时,确认序号字段才有效。
③、PSH:当PSH=1 时,接收方应该尽快将本报文段立即传送给其应用层。
④、RST:当RST=1 时,表示出现连接错误,必须释放连接,然后再重建传输连接。复位比特还用来拒绝一个不法的报文段或拒绝打开一个连接。
⑤、SYN:SYN=1,ACK=0 时表示请求建立一个连接,携带SYN 标志的TCP 报文段为同步报文段。
⑥、FIN:为1 表示发送方没有数据要传输了,要求释放连接。
窗口大小
占用2 个字节大小,表示从确认号开始,本报文的发送方可以接收的字节数,即接收窗口大小,用于流量控制。
校验和
对整个的TCP 报文段,包括TCP 首部和TCP 数据,以16 位字进行计算所得。这是一个强制性的字段。
紧急指针
本报文段中的紧急数据的最后一个字节的序号。
选项
选项字段的大小是不确定的,最多40 字节。
前面我们提到过,TCP 协议是一个面向连接的协议,双方在进行网络通信之间,都必须先在双方之间建立一条连接,俗称“握手”,可能在学习网络编程之前,大家或多或少都听过“三次握手”、“四次挥手”这些词语,那么“三次握手”、“四次挥手”究竟是什么意思,本小节将详细讨论一个TCP 连接是如何建立的,需要经过哪些过程。
“三次握手”其实是指建立TCP 连接的一个过程,通信双方建立一个TCP 连接需要经过“三次握手”这样一个过程。
首先建立连接的过程是由客户端发起,而服务器会时刻监听、等待着客户端的连接,其示意图如下所示:
TCP 连接一般来说会经历以下过程:
⚫ 第一次握手
客户端将TCP 报文标志位SYN 置为1,随机产生一个序号值seq=J,保存在TCP 首部的序列号(Sequence
Number)字段里,指明客户端打算连接的服务器的端口,并将该数据包发送给服务器端,发送完毕后,客户端进入SYN_SENT 状态,等待服务器端确认。
⚫ 第二次握手
服务器端收到数据包后由标志位SYN=1 知道客户端请求建立连接,服务器端将TCP 报文标志位SYN
和ACK 都置为1,ack=J+1,随机产生一个序号值seq=K,并将该数据包发送给客户端以确认连接请求,服务器端进入SYN_RCVD 状态。
⚫ 第三次握手
客户端收到确认后,检查ack 是否为J+1,ACK 是否为1,如果正确则将标志位ACK 置为1,ack=K+1,并将该数据包发送给服务器端,服务器端检查ack 是否为K+1,ACK 是否为1,如果正确则连接建立成功,客户端和服务器端进入ESTABLISHED 状态,完成三次握手,随后客户端与服务器端之间可以开始传输数据了。
注意:上面写的ack 和ACK,不是同一个概念:
小写的ack 代表的是头部的确认号Acknowledge number,ack。
大写的ACK,则是TCP 首部的标志位,用于标志的TCP 包是否对上一个包进行了确认操作,如果确认了,则把ACK 标志位设置成1。
在完成握手后,客户端与服务器就成功建立了连接,同时双方都得到了彼此的窗口大小,序列号等信息,在传输TCP 报文段的时候,每个TCP 报文段首部的SYN 标志都会被置0,因为它只用于发起连接,同步序号。
为什么需要三次握手?
其实TCP 三次握手过程跟现实生活中的人与人之间的电话交流是很类似的,譬如A 打电话给B:
A:“喂,你能听到我的声音吗?”
B:“我听得到呀,你能听到我的声音吗?”
A:“我能听到你,………”
……
经过三次的互相确认,大家就会认为对方对听的到自己说话,才开始接下来的沟通交流,否则,如果不进行确认,那么你在说话的时候,对方不一定能听到你的声音。所以,TCP 的三次握手是为了保证传输的安全、可靠。
除了“三次握手”,还有“四次挥手”,“四次挥手”(有一些书也会称为四次握手)其实是指关闭TCP
连接的一个过程,当通信双方需要关闭TCP 连接时需要经过“四次挥手”这样一个过程。
四次挥手即终止TCP 连接,就是指断开一个TCP 连接时,需要客户端和服务端总共发送4 个包以确认连接的断开。在socket 编程中,这一过程由客户端或服务端任一方执行close 来触发。
由于TCP 连接是全双工的,因此,每个方向都必须要单独进行关闭,这一原则是当一方完成数据发送任务后,发送一个FIN 来终止这一方向的连接,收到一个FIN 只是意味着这一方向上没有数据流动了,即不会再收到数据了,但是在这个TCP 连接上仍然能够发送数据,直到这一方向也发送了FIN。首先进行关闭的一方将执行主动关闭,而另一方则执行被动关闭。
四次挥手过程的示意图如下:
挥手请求可以是Client 端,也可以是Server 端发起的,我们假设是Client 端发起:
⚫ 第一次挥手
Client 端发起挥手请求,向Server 端发出一个FIN 报文段主动进行关闭连接,此时报文段的FIN 标志位被设置为1。此时,Client 端进入FIN_WAIT_1 状态,这表示Client 端没有数据要发送给Server 端了。
⚫ 第二次挥手
Server 端收到了Client 端发送的FIN 报文段,向Client 端返回一个ACK 报文段,此时报文段的ACK
标志位被设置为1。ack 设为seq 加1,Client 端进入FIN_WAIT_2 状态,Server 端告诉Client 端,我确认并同意你的关闭请求。
⚫ 第三次挥手
Server 端向Client 端发送一个FIN 报文段请求关闭连接,此时报文段的FIN 标志位被设置为1,同时
Client 端进入LAST_ACK 状态。
⚫ 第四次挥手
Client 端收到Server 端发送的FIN 报文段后,向Server 端发送ACK 报文段(此时报文段的ACK 标志位被设置为1),然后Client 端进入TIME_WAIT 状态。Server 端收到Client 端的ACK 报文段以后,就关闭连接。此时,Client 端等待2MSL 的时间后依然没有收到回复,则证明Server 端已正常关闭,那好,Client
端也可以关闭连接了。
这就是关闭TCP 连接的四次挥手过程。所以TCP 协议传输数据的整个过程就如同下图所示:
在正式进行数据传输之前,需要先建立连接,当成功建立TCP 连接之后,双方就可以进行数据传输了。当不再需要传输数据时,关闭连接即可!
TCP 协议在建立连接、断开连接以及数据传输过程中都会呈现出现不同的状态,不同的状态采取的动作也是不同的,需要处理各个状态之间的关系。图29.5.2、图29.5.3 以及图29.5.4 中就出现了一些状态标志,除了这些状态标志之外,还有其它一些TCP 状态,对这些TCP 状态的说明如下所示:
⚫ CLOSED 状态:表示一个初始状态。
⚫ LISTENING 状态:这是一个非常容易理解的状态,表示服务器端的某个SOCKET 处于监听状态,监听客户端的连接请求,可以接受连接了。譬如服务器能够提供某种服务,它会监听客户端TCP
端口的连接请求,处于LISTENING 状态,端口是开放的,等待被客户端连接。
⚫ SYN_SENT 状态(客户端状态):当客户端调用connect()函数连接时,它首先会发送SYN 报文给服务器请求建立连接,因此也随即它会进入到了SYN_SENT 状态,并等待服务器的发送三次握手中的第2 个报文。SYN_SENT 状态表示客户端已发送SYN 报文。
⚫ SYN_REVD 状态(服务端状态):这个状态表示服务器接受到了SYN 报文,在正常情况下,这个状态是服务器端的SOCKET 在建立TCP 连接时的三次握手过程中的一个中间状态,很短暂,基本上用netstat 你是很难看到这种状态的,除非你特意写了一个客户端测试程序,故意将三次TCP 握手过程中最后一个ACK 报文不予发送。因此这种状态时,当收到客户端的ACK 报文后,它会进入到ESTABLISHED 状态。
⚫ ESTABLISHED 状态:这个容易理解了,表示连接已经建立了。
⚫ FIN_WAIT_1 和FIN_WAIT_2 状态:其实FIN_WAIT_1 和FIN_WAIT_2 状态的真正含义都是表示等待对方的FIN 报文。而这两种状态的区别是:FIN_WAIT_1 状态实际上是当SOCKET 在
ESTABLISHED 状态时,它想主动关闭连接,向对方发送了FIN 报文,此时该SOCKET 即进入到
FIN_WAIT_1 状态。而当对方回应ACK 报文后,则进入到FIN_WAIT_2 状态,当然在实际的正常情况下,无论对方何种情况下,都应该马上回应ACK 报文,所以FIN_WAIT_1 状态一般是比较难见到的,而FIN_WAIT_2 状态还有时常常可以用netstat 看到。
⚫ TIME_WAIT 状态:表示收到了对方的FIN 报文,并发送出了ACK 报文,就等2MSL 后即可回到CLOSED 可用状态了。如果FIN_WAIT_1 状态下,收到了对方同时带FIN 标志和ACK 标志的报文时,可以直接进入到TIME_WAIT 状态,而无须经过FIN_WAIT_2 状态。
⚫ CLOSE_WAIT 状态:这种状态的含义其实是表示在等待关闭。怎么理解呢?当对方close 一个
SOCKET 后发送FIN 报文给自己,你系统毫无疑问地会回应一个ACK 报文给对方,此时则进入到
CLOSE_WAIT 状态。接下来呢,实际上你真正需要考虑的事情是察看你是否还有数据发送给对方,如果没有的话,那么你也就可以close 这个SOCKET,发送FIN 报文给对方,也即关闭连接。所以你在CLOSE_WAIT 状态下,需要完成的事情是等待你去关闭连接。
⚫ LAST_ACK 状态:它是被动关闭一方在发送FIN 报文后,最后等待对方的ACK 报文。当收到ACK
报文后,也即可以进入到CLOSED 状态了。
以上便是关于TCP 状态的一些描述说明,状态之间的转换关系就如上图中所示。
除了TCP 协议外,还有UDP 协议,想必大家都听过说,UDP 是User Datagram Protocol 的简称,中文名是用户数据报协议,是一种无连接、不可靠的协议,同样它也是工作在传顺层。它只是简单地实现从一端主机到另一端主机的数据传输功能,这些数据通过IP 层发送,在网络中传输,到达目标主机的顺序是无法预知的,因此需要应用程序对这些数据进行排序处理,这就带来了很大的不方便,此外,UDP 协议更没有
流量控制、拥塞控制等功能,在发送的一端,UDP 只是把上层应用的数据封装到UDP 报文中,在差错检测方面,仅仅是对数据进行了简单的校验,然后将其封装到IP 数据报中发送出去。而在接收端,无论是否收到数据,它都不会产生一个应答发送给源主机,并且如果接收到数据发送校验错误,那么接收端就会丢弃该
UDP 报文,也不会告诉源主机,这样子传输的数据是无法保障其准确性的,如果想要其准确性,那么就需要应用程序来保障了。
UDP 协议的特点:
①、无连接、不可靠;
②、尽可能提供交付数据服务,出现差错直接丢弃,无反馈;
③、面向报文,发送方的UDP 拿到上层数据直接添加个UDP 首部,然后进行校验后就递交给IP 层,而接收的一方在接收到UDP 报文后简单进行校验,然后直接去除数据递交给上层应用;
④、速度快,因为UDP 协议没有TCP 协议的握手、确认、窗口、重传、拥塞控制等机制,UDP 是一个无状态的传输协议,所以它在传递数据时非常快,即使在网络拥塞的时候UDP 也不会降低发送的数据。
UDP 虽然有很多缺点,但也有自己的优点,所以它也有很多的应用场合,因为在如今的网络环境下,
UDP 协议传输出现错误的概率是很小的,并且它的实时性是非常好,常用于实时视频的传输,比如直播、网络电话等,因为即使是出现了数据丢失的情况,导致视频卡帧,这也不是什么大不了的事情,所以,UDP
协议还是会被应用与对传输速度有要求,并且可以容忍出现差错的数据传输中。
前面给大家介绍了IP 地址,互联网中的每一台主机都需要一个唯一的IP 地址以标识自己的身份,网络中传输的数据包通过IP 地址找到对应的目标主机;一台主机通常只有一个IP 地址,但主机上运行的网络进程却通常不止一个,譬如Windows 电脑上运行着QQ、微信、钉钉、网页浏览器等,这些进程都需要进行网络连接,它们都可通过网络发送/接收数据,那么这里就有一个问题?主机接收到网络数据之后,如何确定该数据是哪个进程对应的接收数据呢?其实就是通常端口号来确定的。
端口号本质上就是一个数字编号,用来在一台主机中唯一标识一个能上网(能够进行网络通信)的进程,端口号的取值范围为0~65535。一台主机通常只有一个IP 地址,但是可能有多个端口号,每个端口号表示一个能上网的进程。一台拥有IP 地址的主机可以提供许多服务,比如Web 服务、FTP 服务、SMTP 服务等,这些服务都是能够进行网络通信的进程,IP 地址只能区分网络中不同的主机,并不能区分主机中的这些进程,显然不能只靠IP 地址,因此才有了端口号。通过“IP 地址+端口号”来区分主机不同的进程。
很多常见的服务器它都有特定的端口号,具体详情如下表所示:
Linux 系统是依靠互联网平台迅速发展起来的,所以它具有强大的网络功能支持,也是Linux 系统的一大特点。互联网对人类社会产生了巨大影响,它几乎改变了人们生活的方方面面,可见互联网对人类社会的重要性!
本章我们便来学习Linux 下的网络编程,我们一般称为socket 编程,在上一章中给大家介绍过,socket
是内核向应用层提供的一套网络编程接口,用户基于socket 接口可开发自己的网络相关应用程序。
本章作为网络编程基础内容,其中并不会深入、详细地介绍socket 编程,旨在以引导大家入门为主;其一在于网络编程本就是一门非常难、非常深奥的技能,市面上有很多关于Linux/UNIX 网络编程类书籍,这些书籍专门介绍了网络编程相关知识内容,而且书本非常厚,可将其内容之多、难点之多;其二在于笔者对网络编程了解知之甚少,掌握的知识、技能太少,无法向大家传授更加深入的知识、内容,如果大家以后有机会从事网络编程开发相关工作,可以购买此类书籍深入学习、研究。
套接字(socket)是Linux 下的一种进程间通信机制(socket IPC),在前面的内容中已经给大家提到过,使用socket IPC 可以使得在不同主机上的应用程序之间进行通信(网络通信),当然也可以是同一台主机上的不同应用程序。socket IPC 通常使用客户端<—>服务器这种模式完成通信,多个客户端可以同时连接到服务器中,与服务器之间完成数据交互。
内核向应用层提供了socket 接口,对于应用程序开发人员来说,我们只需要调用socket 接口开发自己的应用程序即可!socket 是应用层与TCP/IP 协议通信的中间软件抽象层,它是一组接口。在设计模式中,
socket 其实就是一个门面模式,它把复杂的TCP/IP 协议隐藏在socket 接口后面,对用户来说,一组简单的接口就是全部,让socket 去组织数据,以符合指定的协议。所以,我们无需深入的去理解tcp/udp 等各种复杂的TCP/IP 协议,socket 已经为我们封装好了,我们只需要遵循socket 的规定去编程,写出的程序自然遵循tcp/udp 标准的。
当前网络中的主流程序设计都是使用socket 进行编程的,因为它简单易用,它还是一个标准(BSD
socket),能在不同平台很方便移植,比如你的一个应用程序是基于socket 接口编写的,那么它可以移植到任何实现BSD socket 标准的平台,譬如LwIP,它兼容BSD Socket;又譬如Windows,它也实现了一套基于
socket 的套接字接口,更甚至在国产操作系统中,如RT-Thread,它也实现了BSD socket 标准的socket 接口。
本小节我们向大家介绍,socket 编程中使用到的一些接口函数。使用socket 接口需要在我们的应用程序代码中包含两个头文件:
#include /* See NOTES */
#include
socket()函数原型如下所示:
#include /* See NOTES */
#include
int socket(int domain, int type, int protocol);
socket()函数类似于open()函数,它用于创建一个网络通信端点(打开一个网络通信),如果成功则返回一个网络文件描述符,通常把这个文件描述符称为socket 描述符(socket descriptor),这个socket 描述符跟文件描述符一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。
该函数包括3 个参数,如下所示:
domain
参数domain 用于指定一个通信域;这将选择将用于通信的协议族。可选的协议族如下表所示:
对于TCP/IP 协议来说,通常选择AF_INET 就可以了,当然如果你的IP 协议的版本支持IPv6,那么可以选择AF_INET6。
type
参数type 指定套接字的类型,当前支持的类型有:
protocol
参数protocol 通常设置为0,表示为给定的通信域和套接字类型选择默认协议。当对同一域和套接字类型支持多个协议时,可以使用protocol 参数选择一个特定协议。在AF_INET 通信域中,套接字类型为
SOCK_STREAM 的默认协议是传输控制协议(Transmission Control Protocol,TCP 协议)。在AF_INET 通信域中,套接字类型为SOCK_DGRAM 的默认协议时UDP。
调用socket()与调用open()函数很类似,调用成功情况下,均会返回用于文件I/O 的文件描述符,只不过对于socket()来说,其返回的文件描述符一般称为socket 描述符。当不再需要该文件描述符时,可调用
close()函数来关闭套接字,释放相应的资源。
如果socket()函数调用失败,则会返回-1,并且会设置errno 变量以指示错误类型。
使用示例
int socket_fd = socket(AF_INET, SOCK_STREAM, 0); // 打开套接字
if (0 > socket_fd)
{
perror("socket error");
exit(-1);
}
............ close(socket_fd); // 关闭套接字
bind()函数原型如下所示:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
bind()函数用于将一个IP 地址或端口号与一个套接字进行绑定(将套接字与地址进行关联)。将一个客户端的套接字关联上一个地址没有多少新意,可以让系统选一个默认的地址。一般来讲,会将一个服务器的套接字绑定到一个众所周知的地址—即一个固定的与服务器进行通信的客户端应用程序提前就知道的地址(注意这里说的地址包括IP 地址和端口号)。因为对于客户端来说,它与服务器进行通信,首先需要知道服务器的IP 地址以及对应的端口号,所以通常服务器的IP 地址以及端口号都是众所周知的。
调用bind()函数将参数sockfd 指定的套接字与一个地址addr 进行绑定,成功返回0,失败情况下返回-
1,并设置errno 以提示错误原因。
参数addr 是一个指针,指向一个struct sockaddr 类型变量,如下所示:
struct sockaddr {
sa_family_t sa_family;
char sa_data[14];
}
第二个成员sa_data 是一个char 类型数组,一共14 个字节,在这14 个字节中就包括了IP 地址、端口号等信息,这个结构对用户并不友好,它把这些信息都封装在了sa_data 数组中,这样使得用户是无法对
sa_data 数组进行赋值。事实上,这是一个通用的socket 地址结构体。
一般我们在使用的时候都会使用struct sockaddr_in 结构体,sockaddr_in 和sockaddr 是并列的结构(占用的空间是一样的),指向sockaddr_in 的结构体的指针也可以指向sockadd 的结构体,并代替它,而且
sockaddr_in 结构对用户将更加友好,在使用的时候进行类型转换就可以了。该结构体内容如下所示:
struct sockaddr_in
{
sa_family_t sin_family; /* 协议族*/
in_port_t sin_port; /* 端口号*/
struct in_addr sin_addr; /* IP 地址*/
unsigned char sin_zero[8];
};
这个结构体的第一个字段是与sockaddr 结构体是一致的,而剩下的字段就是sa_data 数组连续的14 字节信息里面的内容,只不过从新定义了成员变量而已,sin_port 字段是我们需要填写的端口号信息,sin_addr
字段是我们需要填写的IP 地址信息,剩下sin_zero 区域的8 字节保留未用。
最后一个参数addrlen 指定了addr 所指向的结构体对应的字节长度。
使用示例
struct sockaddr_in socket_addr;
memset(&socket_addr, 0x0, sizeof(socket_addr)); // 清零
// 填充变量
socket_addr.sin_family = AF_INET;
socket_addr.sin_addr.s_addr = htonl(INADDR_ANY);
socket_addr.sin_port = htons(5555);
// 将地址与套接字进行关联、绑定
bind(socket_fd, (struct sockaddr *)&socket_addr, sizeof(socket_addr));
注意,代码中的htons 和htonl 并不是函数,只是一个宏定义,主要的作用在于为了避免大小端的问题,需要这些宏需要在我们的应用程序代码中包含头文件
Tips:bind()函数并不是总是需要调用的,只有用户进程想与一个具体的IP 地址或端口号相关联的时候才需要调用这个函数。如果用户进程没有这个必要,那么程序可以依赖内核的自动的选址机制来完成自动地址选择,通常在客户端应用程序中会这样做。
listen()函数只能在服务器进程中使用,让服务器进程进入监听状态,等待客户端的连接请求,listen()函数在一般在bind()函数之后调用,在accept()函数之前调用,它的函数原型是:
int listen(int sockfd, int backlog);
无法在一个已经连接的套接字(即已经成功执行connect()的套接字或由accept()调用返回的套接字)上执行listen()。
参数backlog 用来描述sockfd 的等待连接队列能够达到的最大值。在服务器进程正处理客户端连接请求的时候,可能还存在其它的客户端请求建立连接,因为TCP 连接是一个过程,由于同时尝试连接的用户过多,使得服务器进程无法快速地完成所有的连接请求,那怎么办呢?直接丢掉其他客户端的连接肯定不是一个很好的解决方法。因此内核会在自己的进程空间里维护一个队列,这些连接请求就会被放入一个队列中,服务器进程会按照先来后到的顺序去处理这些连接请求,这样的一个队列内核不可能让其任意大,所以必须有一个大小的上限,这个backlog 参数告诉内核使用这个数值作为队列的上限。而当一个客户端的连接请求到达并且该队列为满时,客户端可能会收到一个表示连接失败的错误,本次请求会被丢弃不作处理。
服务器调用listen()函数之后,就会进入到监听状态,等待客户端的连接请求,使用accept()函数获取客户端的连接请求并建立连接。函数原型如下所示:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
为了能够正常让客户端能正常连接到服务器,服务器必须遵循以下处理流程:
①、调用socket()函数打开套接字;
②、调用bind()函数将套接字与一个端口号以及IP 地址进行绑定;
③、调用listen()函数让服务器进程进入监听状态,监听客户端的连接请求;
④、调用accept()函数处理到来的连接请求。
accept()函数通常只用于服务器应用程序中,如果调用accept()函数时,并没有客户端请求连接(等待连接队列中也没有等待连接的请求),此时accept()会进入阻塞状态,直到有客户端连接请求到达为止。当有客户端连接请求到达时,accept()函数与远程客户端之间建立连接,accept()函数返回一个新的套接字。这个套接字与socket()函数返回的套接字并不同,socket()函数返回的是服务器的套接字(以服务器为例),而
accept()函数返回的套接字连接到调用connect()的客户端,服务器通过该套接字与客户端进行数据交互,譬如向客户端发送数据、或从客户端接收数据。
所以,理解accept()函数的关键点在于它会创建一个新的套接字,其实这个新的套接字就是与执行
connect()(客户端调用connect()向服务器发起连接请求)的客户端之间建立了连接,这个套接字代表了服务器与客户端的一个连接。如果accept()函数执行出错,将会返回-1,并会设置errno 以指示错误原因。
参数addr 是一个传出参数,参数addr 用来返回已连接的客户端的IP 地址与端口号等这些信息。参数
addrlen 应设置为addr 所指向的对象的字节长度,如果我们对客户端的IP 地址与端口号这些信息不感兴趣,可以把arrd 和addrlen 均置为空指针NULL。
connect()函数原型如下所示:
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
该函数用于客户端应用程序中,客户端调用connect()函数将套接字sockfd 与远程服务器进行连接,参数addr 指定了待连接的服务器的IP 地址以及端口号等信息,参数addrlen 指定了addr 指向的struct sockaddr
对象的字节大小。
客户端通过connect()函数请求与服务器建立连接,对于TCP 连接来说,调用该函数将发生TCP 连接的握手过程,并最终建立一个TCP 连接,而对于UDP 协议来说,调用这个函数只是在sockfd 中记录服务器
IP 地址与端口号,而不发送任何数据。
函数调用成功则返回0,失败返回-1,并设置errno 以指示错误原因。
一旦客户端与服务器建立好连接之后,我们就可以通过套接字描述符来收发数据了(对于客户端使用
socket()返回的套接字描述符,而对于服务器来说,需要使用accept()返回的套接字描述符),这与我们读写普通文件是差不多的操作,譬如可以调用read()或recv()函数读取网络数据,调用write()或send()函数发送数据。
read()函数
read()函数大家都很熟悉了,通过read()函数从一个文件描述符中读取指定字节大小的数据并放入到指定的缓冲区中,read()调用成功将返回读取到的字节数,此返回值受文件剩余字节数限制,当返回值小于指定的字节数时并不意味着错误;这可能是因为当前可读取的字节数小于指定的字节数(比如已经接近文件结尾,或者正在从管道或者终端读取数据,或者read()函数被信号中断等),出错返回-1 并设置errno,如果在调read 之前已到达文件末尾,则这次read 返回0。
套接字描述符也是文件描述符,所以使用read()函数读取网络数据时,read()函数的参数fd 就是对应的套接字描述符。
recv()函数
recv()函数原型如下所示:
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
不论是客户端还是服务器都可以通过revc()函数读取网络数据,它与read()函数的功能是相似的。参数
sockfd 指定套接字描述符,参数buf 指向了一个数据接收缓冲区,参数len 指定了读取数据的字节大小,参数flags 可以指定一些标志用于控制如何接收数据。
函数recv()与read()很相似,但是recv()可以通过指定flags 标志来控制如何接收数据,这些标志如下所示:
通常一般我们将flags 参数设置为0,当然,你可以根据自己的需求设置该参数。
当指定MSG_PEEK 标志时,可以查看下一个要读取的数据但不真正取走它,当再次调用read 或recv
函数时,会返回刚才查看的数据。
对于SOCK_STREAM 类型套接字,接收的数据可以比指定的字节大小少。MSG_WAITALL 标志会阻止这种行为,知道所请求的数据全部返回,recv 函数才会返回。对于SOCK_DGRAM 和SOCK_SEQPACKET
套接字,MSG_WAITALL 标志并不会改变什么行为,因为这些基于报文的套接字类型一次读取就返回整个报文。
如果发送者已经调用shutdown 来结束传输,或者网络协议支持按默认的顺序关闭并且发送端已经关闭,那么当所有的数据接收完毕后,recv 会返回0。
recv 在调用成功情况下返回实际读取到的字节数。
write()函数
通过write()函数可以向套接字描述符中写入数据,函数调用成功返回写入的字节数,失败返回-1,并设置errno 变量。
send()函数
函数原型如下所示:
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
send 和write 很相似,但是send 可以通过参数flags 指定一些标志,来改变处理传输数据的方式。这些标志如下所示:
即使send()成功返回,也并不表示连接的另一端的进程就一定接收了数据,我们所能保证的只是当send
成功返回时,数据已经被无错误的发送到网络驱动程序上。
当不再需要套接字描述符时,可调用close()函数来关闭套接字,释放相应的资源。
对于人来说,我们更容易阅读的是点分十进制的IP 地址,譬如192.168.1.110、192.168.1.50,这其实是一种字符串的形式,但是计算机所需要理解的是二进制形式的IP 地址,所以我们就需要在点分十进制字符串和二进制地址之间进行转换。
点分十进制字符串和二进制地址之间的转换函数主要有:inet_aton、inet_addr、inet_ntoa、inet_ntop、
inet_pton 这五个,在我们的应用程序中使用它们需要包含头文件
这些函数可将一个IP 地址在点分十进制表示形式和二进制表示形式之间进行转换,这些函数已经废弃了,基本不用这些函数了,但是在一些旧的代码中可能还会看到这些函数。完成此类转换工作我们应该使用下面介绍的这些函数。
inet_ntop()、inet_pton()与inet_ntoa()、inet_aton()类似,但它们还支持IPv6 地址。它们将二进制Ipv4 或
Ipv6 地址转换成以点分十进制表示的字符串形式,或将点分十进制表示的字符串形式转换成二进制Ipv4 或
Ipv6 地址。使用这两个函数只需包含
inet_pton()函数
inet_pton()函数原型如下所示:
int inet_pton(int af, const char *src, void *dst);
inet_pton()函数将点分十进制表示的字符串形式转换成二进制Ipv4 或Ipv6 地址。
将字符串src 转换为二进制地址,参数af 必须是AF_INET 或AF_INET6,AF_INET 表示待转换的Ipv4
地址,AF_INET6 表示待转换的是Ipv6 地址;并将转换后得到的地址存放在参数dst 所指向的对象中,如果参数af 被指定为AF_INET,则参数dst 所指对象应该是一个struct in_addr 结构体的对象;如果参数af 被指定为AF_INET6,则参数dst 所指对象应该是一个struct in6_addr 结构体的对象。
inet_pton()转换成功返回1(已成功转换)。如果src 不包含表示指定地址族中有效网络地址的字符串,则返回0。如果af 不包含有效的地址族,则返回-1 并将errno 设置为EAFNOSUPPORT。
使用示例
#include
#include
#include
#define IPV4_ADDR "192.168.1.222"
int main(void)
{
struct in_addr addr;
inet_pton(AF_INET, IPV4_ADDR, &addr);
printf("ip addr: 0x%x\n", addr.s_addr);
exit(0);
}
测试结果:
inet_ntop()函数
inet_ntop()函数执行与inet_pton()相反的操作,函数原型如下所示:
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
参数af 与inet_pton()函数的af 参数意义相同。
参数src 应指向一个struct in_addr 结构体对象或struct in6_addr 结构体对象,依据参数af 而定。函数
inet_ntop()会将参数src 指向的二进制IP 地址转换为点分十进制形式的字符串,并将字符串存放在参数dts
所指的缓冲区中,参数size 指定了该缓冲区的大小。
inet_ntop()在成功时会返回dst 指针。如果size 的值太小了,那么将会返回NULL 并将errno 设置为
ENOSPC。
使用示例
#include
#include
#include
int main(void)
{
struct in_addr addr;
char buf[20] = {0};
addr.s_addr = 0xde01a8c0;
inet_ntop(AF_INET, &addr, buf, sizeof(buf));
printf("ip addr: %s\n", buf);
exit(0);
}
经过上面的介绍,本小节我们将进行编程实战,实现一个简单地服务器和一个简单地客户端应用程序。
编写服务器应用程序的流程如下:
①、调用socket()函数打开套接字,得到套接字描述符;
②、调用bind()函数将套接字与IP 地址、端口号进行绑定;
③、调用listen()函数让服务器进程进入监听状态;
④、调用accept()函数获取客户端的连接请求并建立连接;
⑤、调用read/recv、write/send 与客户端进行通信;
⑥、调用close()关闭套接字。
下面,我们就根据上面列举的步骤来编写一个简答地服务器应用程序,代码如下所示:
本例程源码对应的路径为:开发板光盘->11、Linux C 应用编程例程源码->30_socket->socket_server.c。
#include
#include
#include
#include
#include
#include
#include
#include
#define SERVER_PORT 8888 // 端口号不能发生冲突,不常用的端口号通常大于5000
int main(void)
{
struct sockaddr_in server_addr = {0};
struct sockaddr_in client_addr = {0};
char ip_str[20] = {0};
int sockfd, connfd;
int addrlen = sizeof(client_addr);
char recvbuf[512];
int ret;
/* 打开套接字,得到套接字描述符*/
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (0 > sockfd)
{
perror("socket error");
exit(EXIT_FAILURE);
}
/* 将套接字与指定端口号进行绑定*/
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(SERVER_PORT);
ret = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
if (0 > ret)
{
perror("bind error");
close(sockfd);
exit(EXIT_FAILURE);
}
/* 使服务器进入监听状态*/
ret = listen(sockfd, 50);
if (0 > ret)
{
perror("listen error");
close(sockfd);
exit(EXIT_FAILURE);
}
/* 阻塞等待客户端连接*/
connfd = accept(sockfd, (struct sockaddr *)&client_addr, &addrlen);
if (0 > connfd)
{
perror("accept error");
close(sockfd);
exit(EXIT_FAILURE);
}
printf("有客户端接入...\n");
inet_ntop(AF_INET, &client_addr.sin_addr.s_addr, ip_str, sizeof(ip_str));
printf("客户端主机的IP 地址: %s\n", ip_str);
printf("客户端进程的端口号: %d\n", client_addr.sin_port);
/* 接收客户端发送过来的数据*/
for (;;)
{
// 接收缓冲区清零
memset(recvbuf, 0x0, sizeof(recvbuf));
// 读数据
ret = recv(connfd, recvbuf, sizeof(recvbuf), 0);
if (0 >= ret)
{
perror("recv error");
close(connfd);
break;
}
// 将读取到的数据以字符串形式打印出来
printf("from client: %s\n", recvbuf);
// 如果读取到"exit"则关闭套接字退出程序
if (0 == strncmp("exit", recvbuf, 4))
{
printf("server exit...\n");
close(connfd);
break;
}
}
/* 关闭套接字*/
close(sockfd);
exit(EXIT_SUCCESS);
}
以上我们实现了一个非常简单地服务器应用程序,根据上面列举的步骤完成了这个示例代码,最终的功能是,当客户端连接到服务器之后,客户端会向服务器(也就是本程序)发送数据,在我们服务器应用程序中会读取客户端发送的数据并将其打印出来,就是这么简单的一个功能。
SERVER_PORT 宏指定了本服务器绑定的端口号,这里我们将端口号设置为8888,端口不能与其它服务器的端口号发生冲突,不常用的端口号通常大于5000。
代码就不再解释了,都非常简单!
接下来我们再编写一个简单地客户端应用程序,客户端的功能是连接上小节所实现的服务器,连接成功之后向服务器发送数据,发送的数据由用户输入。示例代码如下所示:
本例程源码对应的路径为:开发板光盘->11、Linux C 应用编程例程源码->30_socket->socket_client.c。
#include
#include
#include
#include
#include
#include
#include
#include
#define SERVER_PORT 8888 // 服务器的端口号
#define SERVER_IP "192.168.1.150" // 服务器的IP 地址
int main(void)
{
struct sockaddr_in server_addr = {0};
char buf[512];
int sockfd;
int ret;
/* 打开套接字,得到套接字描述符*/
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (0 > sockfd)
{
perror("socket error");
exit(EXIT_FAILURE);
}
/* 调用connect 连接远端服务器*/
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERVER_PORT); // 端口号
inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr); // IP 地址
ret = connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
if (0 > ret)
{
perror("connect error");
close(sockfd);
exit(EXIT_FAILURE);
}
printf("服务器连接成功...\n\n");
/* 向服务器发送数据*/
for (;;)
{
// 清理缓冲区
memset(buf, 0x0, sizeof(buf));
// 接收用户输入的字符串数据
printf("Please enter a string: ");
fgets(buf, sizeof(buf), stdin);
// 将用户输入的数据发送给服务器
ret = send(sockfd, buf, strlen(buf), 0);
if (0 > ret)
{
perror("send error");
break;
}
// 输入了"exit",退出循环
if (0 == strncmp(buf, "exit", 4))
break;
}
close(sockfd);
exit(EXIT_SUCCESS);
}
代码不再说明!需要注意的是SERVER_IP 和SERVER_PORT 指的是服务器的IP 地址和端口号,服务器的IP 地址根据实际情况进行设置,服务器应用程序示例代码30.4.1 中我们绑定的端口号为8888,所以在客户端应用程序中我们也需要指定SERVER_PORT 为8888。
编译测试
这里笔者将服务器程序运行在开发板上,而将客户端应用程序运行在Ubuntu 系统,当然你也可以将客户端和服务器程序都运行在开发板或Ubuntu 系统,这都是没问题的。
首先编译服务器应用程序和客户端应用程序:
编译得到client 和server 可执行文件,server 可执行文件在开发板上运行,client 可执行文件在PC 端
Ubuntu 系统下运行。将server 可执行文件拷贝到开发板/home/root 目录下,如下所示:
客户端运行之后将会去连接远端服务器,连接成功便会打印出信息“服务器连接成功…”,此时服务器也会监测到客户端连接,会打印相应的信息,如下所示:
接下来我们便可以在客户端处输入字符串,客户端程序会将我们输入的字符串信息发送给服务器,服务器接收到之后将其打印出来,如下所示:
总结
到此,本章的内容就结束了,内容讲得非常浅,目的其实并不是让大家学会网络编程,这个是不可能的,旨在以引导大家入门为主,让大家对socket 网络编程有一个基本的了解和认识。因为网络编程本就是应用编程中一门比较专业的方向,如果大家将来想从事这方面的工作、或者以后从事了这方面工作,再去找资料好好学习,如果没有这个打算,那就不要去深入研究这个,有了基本的了解、认识就行了。