文章目录
- 1. 前导知识
- 1.1 源MAC地址和目的MAC地址
- 1.2 源IP地址和目的IP地址
- 1.3 MAC地址和IP地址的配合
- 1.4 源端口号和目的端口号
- 1.5 Socket
- 1.6 UCP协议和TCP协议
- 1.7 网络字节序
- 高低位
- 高低地址
- 大端和小端
- 网络字节序
- 常用转换函数
- 2. socket 网络编程
- 2.1 socket 常见接口
- 创建套接字
- 绑定
- 监听套接字
- 接收请求
- 建立连接
- 2.2 常见套接字
- 域间套接字
- 原始套接字
- 网络套接字
- 2.3 sockaddr 结构体
- 3. 实践
- 实现简易UDP网络程序
- 实现简易TCP网络程序
1. 前导知识
友情链接:网络基础入门
1.1 源MAC地址和目的MAC地址
MAC地址(Media Access Control Address, 局域网地址)在OSI模型的第二层数据链路层发挥作用,标识本地网络上的设备物理地址。
对于处于同一局域网的多台主机,它们直接向局域网发送的数据是被所有主机共享的(包括发送的主机自己),也就相当于广播,但是只有特定的主机才会处理它(虽然所有主机都收到了信息)。这是因为主机发送的数据中包含了指定主机的MAC地址,除此之外,为了校验数据的完整性,还包含了发生数据的主机本身的MAC地址,以供主机在发送信息后再接收校验。
其中,发送信息的主机的MAC地址叫做源MAC地址,接收信息的主机的MAC地址叫做目的MAC地址。
1.2 源IP地址和目的IP地址
IP地址(Internet Protocol, 互联网协议)在OSI模型的第三层网络层发挥作用,它是一个逻辑地址,用于唯一标识互联网连接设备。
MAC地址标识着设备的全球唯一性,但是仅靠MAC地址无法完成不同网络中数据的传输。我们知道,数据传输是通过网络协议栈传输的,数据自上而下传输时会被每一层协议封装一个报头信息,当数据自下而上传输时,每一层协议会解封装,直到应用层取到数据本身。但是不同的网络可能在某些层的协议有所区别,因此报头的封装和解封装的过程就不像局域网那样对称,因此需要配合IP地址在不同的网络中跳转。
1.3 MAC地址和IP地址的配合
在不同网络中,路由器起着“指路人”的作用,实际上数据在传输过程中可能会经过多个不同网络,那么报头信息中的两个MAC地址一直在随着路由器(路由器也是硬件)的变化而变化,但是源IP地址和目的IP地址不会改变。这就像唐僧每到一个地方都会说“自东土大唐而来,去西天取经”,出发点和目的地是不应该被改变的(在某些特殊情况源IP可能会被改变,但是目的IP绝对不会被改变),但是遇到的好心人听到这句话以后都会告诉唐僧下一个地方应该怎么走,这就是MAC地址和IP地址在不同网络中配合数据传输的过程。
1.4 源端口号和目的端口号
端口号(PORT)的主要作用是表示一台计算机中的特定(特指网络服务)进程所提供的服务,它在传输层发挥作用,标识主机上进程的唯一性。言外之意是一个端口号只能被一个进程使用,而一个进程可以使用多个端口号。
端口号是一个16位的无符号整数,范围从0到65535。在Internet上,端口号用于识别不同的网络服务。例如,Web服务器通常使用端口号80,SMTP服务器使用端口号25等 。
结合进程相关知识,数据本身是被运行起来的进程处理的,因此数据通过网络传输到不同主机中只是一个搬运的过程。因此可以认为数据是在不同主机中的不同进程之间传输,也就是网络层面上的进程间通信。端口号的名字很形象,现实中的港口(port)也是类似的。主机中各种不同的进程就好像一个个蓄势待发的货船,它们在不同编号的位置等待货物,一旦货物就绪,一个个进程就会对其处理。
IP地址标识了公网中主机的唯一性,端口号标识主机上进程的唯一性,那么IP地址+端口号就标识了网络上某台主机中的进程的唯一性。和IP地址类似,端口号会在传输层被封装进报头信息中。
既然PID和端口号都能表示主机上进程的唯一性,为什么不用PID进行网络传输?
端口号标识的进程是PID标识的进程的子集,它们标识的范围不同。PID就像每个人的身份证,虽然它能表示我们在这片土地上的唯一性,但是我们很多时候不使用它,而是使用范围合适、便于管理的标识,例如在教室用座位号、在学校用学号、在高考中用准考证和在银行里用身份证等等。使用PID当然可以,但是这样会增加筛选所有进程中的网络服务进程的负担,也会增加其他非网络服务进程的安全风险。这也是一种解耦的做法,单独用一种标识表示特定种类的元素,能省去筛选的成本。
1.5 Socket
Socket(套接字)是计算机网络中的一个软件结构,它用于在计算机网络中的节点之间发送和接收数据。套接字的结构和属性由网络架构的应用程序编程接口(API)定义。它允许应用程序将I/O插入到网络中,并与网络中的其他应用程序进行通信。简单来说,Socket是计算机之间进行通信的一种约定或一种方式。
Socket这个词在计算机网络中的翻译为“套接字”,原意指的是插座或者插槽。在计算机网络中,它被用来描述两个程序之间建立连接的端点。就像电器插头需要插入插座才能通电一样,两个程序之间也需要一个“插座”来建立连接。因此,这个词被引申为“套接字”。
Socket函数是应用程序与TCP/IP协议族通信的中间软件抽象层,它是一组接口。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部。它将底层复杂的协议体系、执行流程进行了封装,封装完的结果就是一个SOCKET了,也就是说,SOCKET是我们调用协议进行通信的操作接口。
Socket起源于Unix,而Unix/Linux基本哲学之一就是“一切皆文件”,都可以用“打开open –> 读写write/read –> 关闭close”模式来操作。Socket就是该模式的一个实现,socket即是一种特殊的文件,一些socket函数就是对其进行的操作(读/写IO、打开、关闭)。
在实践过程中,其实不必要关心它的各种定义,可以简单地理解为它就是一个数据包,是包含各种通信相关属性的结构体。内置的库中有许多函数,它们会在函数内部对这个数据包中的属性处理。值得注意的是,socket本质是一个按照某种规则(协议)构造出来的一个文件,只要通信两端都按照约定好的规则使用它其中的数据,就能实现通信过程。
友情链接:
- socket是什么?套接字是什么?
- SOCK、SOCKET和TCP_SOCK之间的关系
1.6 UCP协议和TCP协议
下面简单介绍UCP协议和TCP协议。
TCP(Transmission Control Protocol,传输控制协议)提供的是面向连接,可靠的字节流服务。即客户和服务器交换数据前,必须现在双方之间建立一个TCP连接,之后才能传输数据。并且提供超时重发,丢弃重复数据,检验数据,流量控制等功能,保证数据能从一端传到另一端。
UDP(User Datagram Protocol,用户数据报协议)是一个简单的面向数据报的运输层协议。它不提供可靠性,只是把应用程序传给IP层的数据报发送出去,但是不能保证它们能到达目的地。由于UDP在传输数据报前不用再客户和服务器之间建立一个连接,且没有超时重发等机制,所以传输速度很快。
简单地说,TCP就像打电话,首先要通信信道才能进行通信。
为什么UDP不提供可靠性,还要使用它?
尽管UDP不提供可靠性,但它的优点在于传输速度快。由于UDP在传输数据报前不用再客户和服务器之间建立一个连接,且没有超时重发等机制,所以传输速度很快 。这对于一些对实时性要求较高的应用程序来说非常重要,例如在线游戏、实时音视频传输等。在这些情况下,使用UDP协议能够提供更快的响应速度。一般情况下,为了数据安全都使用TCP,在特殊场景下(例如直播和视频)可能会使用UDP。在优秀的通信算法中,常常会同时使用TCP和UDP,根据实际情况调度策略。
实际上,这里的“可靠”是相对的,是中性词。也就是说,TCP为了达到“可靠”,付出了很多代价,例如协议更复杂、维护难度高,因此它的传输速度没有UDP快。其“可靠”与否是协议本身的特点。如果它们会说话的话,那么UDP可能会对TCP说“何必这么累呢?跟我一样直接把数据甩给对面不就好了?”
1.7 网络字节序
高低位
对于任意一个十进制的数值,它可以用多项式 1 0 n 10^n 10n的和表示,例如 123 = 1 × 1 0 2 + 2 × 1 0 1 + 3 × 1 0 0 123 = {1×10^2} + {2×10^1} + {3×10^0} 123=1×102+2×101+3×100,字节的高低对应着权值的大小。例如,对于整数0x12345678,0x12是最高位字节,它的权值是16的三次方;0x78是最低位字节,它的权值是16的零次方。
高低地址
内存地址的高低是指内存地址的数值大小。比如,0x1000是一个比0x0100更高的地址。
简单地说,就是左边低,右边高。
大端和小端
- 小端:数据的高权值位对应高地址处。
- 大端:反之。
假设我们有一个16位的整数0x1234,它占用两个字节。在大端字节序的计算机中,这个整数将按照0x12 0x34的顺序存储在内存中。也就是说,最高位字节0x12存储在内存的低地址处,最低位字节0x34存储在内存的高地址处。
而在小端字节序的计算机中,这个整数将按照0x34 0x12的顺序存储在内存中。也就是说,最低位字节0x34存储在内存的低地址处,最高位字节0x12存储在内存的高地址处。
只要记住大端更符合我们现代人从左到右的读写习惯即可。
网络字节序
接收数据的主机知道对方主机是大端还是小端吗?
不知道。因为主机的大小端是不确定的,因此如果接收数据的主机必须要知道对方主机是大端还是小端。否则就会出现数据读取错误。
发送数据的主机将它的大小端属性特征字段放进报头信息中不就好了?
找到属性字段的前提是接收数据的主机已经知道了发送数据的主机是大端还是小端,这样就矛盾了。
所以网络字节序直接规定了使用大端。因此主机在发送数据和接收数据时,都要对数据进行字节序转换。
转换什么?
- 数据在发送前,需要从主机字节序转换为网络字节序;
- 数据在接收后,需要从网络字节序转换为主机字节序。
常用转换函数
这个转换的工作已经由C标准库完成,实际上,Windows也使用的是相同的一套函数。
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
命名解读:
- h:host,表示主机字节序;
- n:net,表示网络字节序;
- l:long,表示32位长整数;
- s:short,表示16位短整数。
通常情况下,不论测试机是大端还是小端,为了可移植性都要调用这些函数进行转换,如果机器本身是大端,那么这些函数将直接返回。
编码习惯:虽然有时候某些步骤在理论上是不必要的,但实际应用中可能会出现各种各样的问题,所以为了保险起见都会多执行一步。
2. socket 网络编程
2.1 socket 常见接口
TCP是面向连接的,通过socket实现通信的步骤是:
- 创建套接字(服务端和客户端)
- 绑定端口号(服务端)
- 监听套接字(服务端)
- 建立连接(客户端)
UDP是面向字节流的,它的步骤比较简单:
- 创建套接字(服务端和客户端)
- 绑定端口号(服务端)
其中,TCP和UDP的服务端都要创建套接字并绑定端口号,这些步骤将在实践中介绍,仅通过接口的数量就能看到TCP比UDP多做了不少工作。
在此,由于知识的局限,某些参数无法作详细的解释,将在TCP/UDP专题中介绍。
通过man + [函数名]
能很方便地查询函数相关信息。
它们的头文件都是:
#include <sys/types.h>
#include <sys/socket.h>
创建套接字
socket()
函数用于创建套接字。
int socket(int domain, int type, int protocol);
参数:
- domain(域):指定套接字家族,简单地说就是指定通信的方式是本地还是网络:
AF_UNIX, AF_LOCAL
:本地通信。AF_INET
:网络通信。- …
- type:指定套接字的类型,即传输方式:
SOCK_STREAM
:面向连接的套接字/流格式套接字。SOCK_DGRAM
:无连接的套接字/数据报套接字。
- protocol(协议):指定传输协议,默认为
0
,常用的有:IPPROTO_TCP
:表示TCP传输协议。IPPTOTO_UDP
:表示UDP传输协议。
绑定
bind()
函数用于将套接字与指定的IP地址和端口号绑定。通常在TCP协议或UDP协议的服务端设置。
int bind(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);
参数:
- sockfd:要绑定的套接字文件描述符,它的本质是一个数组下标。
- addr:是一个指向
struct sockaddr
类型结构体的指针,该结构体中包含了要绑定的IP地址和端口号。 - addrlen 是
addr
所指向的地址结构体的大小。
监听套接字
listen()
函数用于将套接字转换为被动监听状态。通常在TCP协议的服务端设置。
int listen(int sockfd, int backlog);
参数:
- sockfd:要监听的套接字文件描述符。
- backlog:未完成连接队列的最大长度,即允许等待连接的客户端数量 。
接收请求
accept()
函数用于从监听套接字的未完成连接队列中提取第一个连接请求,创建一个新的已连接套接字,并返回一个指向该套接字的文件描述符。通常在TCP协议的服务端设置。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数:
- sockfd:监听套接字的文件描述符。
- addr:是一个指向 struct sockaddr 类型结构体的指针,用于存储客户端的地址信息。
- addrlen:是一个指向 socklen_t 类型变量的指针,用于存储客户端地址结构体的大小。
建立连接
connect()
函数用于建立与指定套接字的连接。通常在TCP协议的服务端设置。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
- sockfd 是要连接的套接字文件描述符。
- addr 是一个指向
struct sockaddr
类型结构体的指针,该结构体中包含了要连接的服务器的地址信息。 - addrlen 是
addr
所指向的地址结构体的大小。
2.2 常见套接字
套接字是一种通信机制,用于在不同主机或同一主机上的进程间通信。套接字有多种类型,包括流式套接字(SOCK_STREAM)、数据报套接字(SOCK_DGRAM)和原始套接字(SOCK_RAW)等。在这里,我们讨论的是网络套接字。
域间套接字
域间套接字(Domain Socket)是一种特殊类型的套接字(socket)。套接字是一种通信机制,用于在不同主机或同一主机上的进程间通信。套接字有多种类型,包括流式套接字(SOCK_STREAM)、数据报套接字(SOCK_DGRAM)和原始套接字(SOCK_RAW)等。域间套接字是其中的一种类型,用于在同一台主机上的进程间通信。
简单来说,域间套接字是套接字的一种类型,它与其他类型的套接字共享相似的API和通信机制,但是它专门用于在同一台主机上的进程间通信。
原始套接字
原始套接字(Raw Socket)是一种特殊类型的套接字,它允许直接发送和接收IP协议数据包,而不需要任何传输层协议格式。这意味着使用原始套接字时,应用程序需要自己处理传输层协议的相关细节。
原始套接字通常用于安全相关的应用程序,如nmap,或用于在用户空间实现新的传输层协议。它也常用于网络设备上的路由协议,例如IGMPv4、开放式最短路径优先协议 (OSPF)、互联网控制消息协议 (ICMP)。
网络套接字
网络套接字(Network Socket)是一种用于在不同主机上的进程间通信的套接字。它使用了网络协议栈,如TCP/IP协议栈,来实现跨网络的通信。网络套接字使用IP地址和端口号来标识通信端点。
网络套接字有两种类型:流式套接字(SOCK_STREAM)和数据报套接字(SOCK_DGRAM)。流式套接字使用TCP协议进行数据传输,提供可靠的、面向连接的通信服务。数据报套接字使用UDP协议进行数据传输,提供无连接的、不可靠的通信服务。
2.3 sockaddr 结构体
在介绍socket网络套接字的接口时,曾多次提到sockaddr
结构体,它是一个通用的套接字地址结构,用于在套接字编程中传递不同协议族的地址信息。它的定义如下:
struct sockaddr {
sa_family_t sa_family; /* 地址族 */
char sa_data[14]; /* 地址数据 */
};
-
sa_family字段表示地址族(address family),用于指定地址的类型。常见的地址族有AF_INET(IPv4地址)、AF_INET6(IPv6地址)和AF_UNIX(Unix域地址)等。
-
sa_data字段表示协议地址,其长度和内容取决于地址族。例如,对于IPv4地址,它包含了IP地址和端口号;对于Unix域地址,它包含了文件系统中的路径名。
由于sockaddr结构并不能很好地表示各种类型的地址,因此通常会使用特定于地址族的结构来表示套接字地址,例如sockaddr_in(用于IPv4地址)和sockaddr_un(用于Unix域地址)。这些结构与sockaddr结构具有相同的大小和对齐方式,可以相互转换。
因此,这个结构体的唯一目的是为了将不同协议族的地址结构体指针转换为一个“通用”类型,以避免编译器警告。例如,对于IPv4协议族的地址结构体sockaddr_in,它的定义如下:
struct sockaddr_in {
sa_family_t sin_family; /* AF_INET */
in_port_t sin_port; /* 端口号 */
struct in_addr sin_addr; /* IPv4地址 */
};
这个结构体比sockaddr结构体更具体,它包含了IPv4协议族所需的地址信息。当我们调用套接字函数时,例如bind(2),我们需要将sockaddr_in结构体指针强制转换为sockaddr结构体指针,如下所示:
struct sockaddr_in addr;
/* 初始化addr */
bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
这样做是为了让套接字函数能够根据sa_family字段来判断实际的地址类型,并进行相应的处理。同样的道理,对于其他协议族,例如IPv6或UNIX域套接字,也有各自的地址结构体,例如sockaddr_in6和sockaddr_un,它们都可以转换为sockaddr结构体指针。
因此,我们可以认为sockaddr结构体是一个抽象的接口,它隐藏了不同协议族地址结构体之间的差异,让我们可以使用统一的方式来操作套接字。
为了统一使用接口,Linux内核用结构体的前2个字节标定套接字的类型。即即套接字的类型。sa_family字段是一个sa_family_t类型(无符号整型)的变量,通常占用两个字节。
地址族用于指定地址的类型,它决定了套接字如何解释地址信息。常见的地址族有AF_INET(IPv4地址)、AF_INET6(IPv6地址)和AF_UNIX(Unix域地址)等。不同类型的套接字使用不同的协议来传输数据,因此需要使用不同的地址结构来表示它们的地址信息。
通过在sockaddr结构体中使用一个通用的字段来表示地址族,Linux内核可以统一处理不同类型的套接字地址,简化了套接字API的使用。在使用上的体现就是,不管是何种通信方式,网络还是本地通信,虽然在初始化套接字中的属性时使用的是struct sockaddr_in
或struct sockaddr_un
,但是传参都统一类型转换为sockaddr*
。这样就不用单独为不同的通信方式实现不同的接口了,从而减少了使用成本。
在多线程编程中,我们经常利用
void*
(它可以传递任意类型的数据)来给线程函数传递信息,为什么socket不使用void*
来保存通信相关属性呢?
套接字API的设计可以追溯到20世纪70年代末,当时由贝尔实验室的研究人员开发了BSD Unix操作系统。在当时,C语言和Unix操作系统都处于起步阶段,许多现代编程语言和操作系统的特性还没有出现。
在设计套接字API时,研究人员希望能够提供一种通用的接口,用于支持不同类型的网络协议。为了实现这一目标,他们定义了一组通用的套接字地址结构,用于表示不同类型的网络地址。这些结构体包含了特定的字段,用于存储地址族、协议地址等信息。
虽然使用void*
指针也可以实现类似的功能,但是这样做会使得代码变得更加复杂和难以维护。程序员需要手动管理内存,并且需要使用类型转换来访问指针指向的数据。相比之下,使用特定的结构体类型来表示套接字地址更加简单、直观和安全。
因此,套接字API最终采用了特定的结构体类型来表示套接字地址,而不是使用void*
指针。这一设计决策为套接字API提供了清晰、简洁和易用的接口,并且在后来被广泛采纳。
3. 实践
实际上,有了这些接口,我们便能按照“套路”实现网络程序,到目前为止,这是我觉得除了进程间通信之外最有趣的实验。
由于文章还没写完,所以给出两个权威的规范样例。
实现简易UDP网络程序
-
C socket UDP client
-
C socket UDP server
实现简易TCP网络程序
- C socket TCP client
- C socket TCP server