目录
- 基础概念
- 端口和端口号
- Socket(套接字)
- UDP和TCP的概念
- Socket编程实战
- socket的类型
- struct sockaddr
- struct sockaddr_in
- 网络字节序
- socket API
- TCP网络编程流程分析
- UDP Demo样例
- other
- 概念补充
- setsockopt函数
- 心跳机制
- 面向字节流与面向报文的理解
- 参考
基础概念
端口和端口号
我们常说的端口有两种:物理端口和逻辑端口。物理端口指的是用于连接物理设备之间的接口,如集线器、交换机、路由器上用于连接其他网络设备的接口。逻辑端口指的是指逻辑意义上用于区分服务的端口,比如用于浏览网页服务的80端口,用于FTP服务的21端口等。网络编程场景下所指的端口是逻辑端口,本文所说端口如果没有特指都是指的的逻辑端口。
物理端口比较好理解,那么逻辑端口是怎么一回事呢?端口在操作系统中用于表示一种特定的服务,每个端口都有一个独一无二的端口号,不会出现重复端口号的情况。既然要提供服务,那么每个端口号之下一定是要绑定对应的进程的。但需要注意的是,一个端口下只能绑定一个进程,而一个进程可以同时绑定多个端口号,这就像一个银行的窗口柜台下只能有一个工作人员,而一个工作人员却可以同时在多个窗口下工作。可以参考下图理解端口号的概念:
其实,网络通信的本质就是进程间通信。但与本地通信不同的是,网络通信是两台计算机上的两个进程跨网络之间的通信。进程在本地通信时需要根据进程的pid进行辨别,那么进程在网络之间通信时,同样也需要根据端口号进行辨别。
之所以要有端口号这个东西,这是因为在网络环境中的计算机可以通过IP地址和MAC地址来被找到,但IP+MAC地址并不能表示一台计算机上的特定进程,如果想要访问到特定的进程就一定需要再对其细分,于是端口就成了一个很好的解决方案。
那么为什么不直接使用进程pid,却要额外引入一个端口号,增加通信的复杂程度呢?其实这个问题可以从多个角度来回答,下面先简单列举一些:
- 对于服务器而言,进程可以在任何时候启动、关闭或重新启动,因此进程的pid随时可能会发生变化。而如果用端口号,则每次让进程启动时绑定到端口号就可以做到固定服务绑定固定端口号的效果了。
- 进程pid 是一个操作系统级别的标识符,只在单机环境中有效。当涉及到不同操作系统之间的通信时,进程pid 就不再适用了。而端口号则可以很好的解决这种不同环境下进程表示的差异性问题。
- 端口号与进程pid之间的解耦,可以使得服务与进程之间独立开来,使得服务的配置操作更加灵活。
端口号是一个2字节的整数,范围是1至65535,0不使用。其中 [1, 1023] 是知名端口号,这些端口号一般固定分配给一些服务,是不会改变的,一般也不允许修改(Linux下需要root权限才能操作)。例如80端口表示HTTP服务、21端口表示FTP 文件传输服务、53端口表示DNS 域名解析服务、443端口表示HTTPS 加密的超文本传输服务等。而1024到65535表示动态端口号,是普通用户可以申请的使用的端口号。 如果一个可执行程序程序没有设置端口号,那么操作系统会在动态端口号这个范围内随机挑选一个没有被占用端口号可执行程序使用。
Socket(套接字)
socket 的原意是“插座”,我们把插头插到插座上就能从电网获得电力供应,相应的,应用程序通过socket就可以连接到因特网,进而就可以通过互联网与远程计算机进行数据传输了。也就是说socket 就是用来连接到因特网的工具,通过socket,一台计算机可以接收其他计算机的数据,也可以向其他计算机发送数据。
严格来说socket并不属于任何一个网络协议层,socket是一种介于应用层和传输层的一个抽象层,起到一个承上启下的作用。它为应用层提供了访问传输层网络协议的接口,在Linux中,socket API 封装了操作系统内核中的 TCP/IP 协议栈所提供的服务,使得开发者能够通过这些接口收发数据。
一般来说,IP和端口号是socket的基础属性,大多数场景下的socket操作都需要用到这两个值。
从Linux的角度来看,socket套接字是一种抽象的文件格式,和管道文件一样,它是一种伪文件,存在于内核的缓冲区中,大小不变,一直是0。
其中,套接字是全双工的通信方式,分别有读写缓冲区。全双工的概念如下:
单工通信只支持信号在一个方向上传输(正向或反向),任何时候不能改变信号的传输方向。
半双工通信允许信号在两个方向上传输,但某一时刻只允许信号在一个信道上单向传输。
全双工通信允许数据同时在两个方向上传输,即有两个信道,因此允许同时进行双向传输。
UDP和TCP的概念
TCP是一种传输层协议,是一种面向字节流的有连接可靠传输。UDP也是一种传输层协议,但它是一种面向数据报的无连接不可靠传输。
TCP的特点是:面向字节流、有连接、可靠传输
UDP的特点是:面向数据报、无连接、不可靠传输
之所以TCP是可靠的传输,是因为TCP比UDP多了很多安全检查和差错处理,而UDP只管发送数据报,所以UDP的丢包率理论上要比TCP的高。但并不是说UDP就是一无是处的,TCP在得到安全的同时,也导致了效率的下降。所以相比之下,TCP的可靠性好,丢包率低,但UDP的效率高。所以TCP主要被用于可靠性较高的一些场景,例如HTTP、HTTPS、FTP等;UDP主要被用于对可靠性要求不是那么高的场景,例如各种直播和语音通话等,偶尔丢一两帧数据并不影响整体的体验。
需要注意的是UDP虽然理论上丢包率要略高于TCP,但这并不是就说明UDP一定丢包率很高,一般来说UDP的丢包率只会略高于TCP,但并不会高很多。
Socket编程实战
socket的类型
套接字的主要类型有三种:
-
数据报套接字(SOCK_DGRAM):数据报格式套接字(Datagram Sockets)也叫无连接的套接字,在代码中使用 SOCK_DGRAM 表示。只管传输数据,不作数据校验,如果数据在传输中损坏,或者没有到达另一台计算机,是没有办法补救的。也就是说,数据错了就错了,无法重传。使用的是UDP协议。
-
流式套接字(SOCK_STREAM):流式套接字(Stream Sockets)也叫面向连接的套接字,在代码中使用 SOCK_STREAM 表示。SOCK_STREAM 是一种可靠的、双向的通信数据流,数据可以准确无误地到达另一台计算机,如果损坏或丢失,可以重新发送。
-
原始套接字(SOCK_RAW):一种不同于SOCK_STREAM、SOCK_DGRAM的套接字,它实现于系统核心。原始套接字允许直接发送和接收 IP 数据包,而无需任何特定于协议的传输层格式,而且可以读写内核没有处理过的 IP 数据包。
本文我们只关注数据包套接字和流式套接字。
struct sockaddr
在正式认识socke apit之前,需要先搞清struct sockaddr
和struct sockaddr_in
这两个结构体。
网络编程的时候,有各种各样不同的应用场景,理论上而言,我们应该给每种场景都设计一套编程接口,但由于Linux内核是由C语言编写的缘故,并没有多态这种语法,如果要实现所有场景的接口,就需要定义多种功能相似的函数。
所以为了实现接口的统一性,统一便用struct sockaddr
来描述一个网络字段,其格式为:类型+地址,而其它的类型则固定首16位字节一定是地址类型,这样就能通过类型+地址的方式解析所有的结构,进而统一接口(只需要在传参时进行对应的类型转换),参考下图理解。
struct sockaddr
的定义如下:
struct sockaddr {
u_short sa_family;
char sa_data[14];
};
struct sockaddr_in
struct sockaddr_in
为IPv4的结构体,struct sockaddr_un
结构体主要为同一台机器上的进程间进行高效通信,struct sockaddr_in6
则表示IPv6的结构体。本文的主要以struct sockaddr_in
的使用为主。
sockaddr_in
中有4个成员:sin_family、sin_port、sin_addr、sin_zero,其结构体定义(在netinet/in.h中定义)如下:
struct sockaddr_in {
short int sin_family; /* Address family */
unsigned short int sin_port; /* Port number */
struct in_addr sin_addr; /* Internet address */
unsigned char sin_zero[8]; /* Same size as struct sockaddr */
};
- sin_family
sin_family
表示要使用的网络协议簇,协议簇的在“linux/socket.h”里有详细定义,常见的有如下几个:
- AF_UNIX (本机通信)
- AF_INET (TCP/IP & IPv4)
- AF_INET6 (TCP/IP & IPv6)
在当前IPv4的场景下,sin_family一般设为AF_INET。
-
sin_port
sin_port
就表示的是要使用的端口号。 -
sin_addr
sin_addr
是一个 struct in_addr 类型的成员,其定义如下:struct in_addr { unsigned long s_addr; };
也就是说,其本质是一个unsigned long类型的整型数据。其中,可以用inet_addr函数将char*的ip地址转为4字节序列,并转为网络字节序。
0.0.0.0表示任意IP地址绑定,也就是不限IP的意思,在编程时可以使用INADDR_ANY
宏来表示,其定义为:#define INADDR_ANY ((in_addr_t) 0x00000000)
-
sin_zero
sin_zero
是为了让sockaddr与sockaddr_in两个数据结构保持大小相同而保留的空字节,一般用不到。
网络字节序
由于不同的机器之间有大端和小端之间的差异,所以规定:所有到达网络的数据,必须是大端格式的。也就是说,网络字节序就是大端字节序。不知道大端和小段的可以参考:大端和小端
其中在编程时,对于网络字节序的操作有如下一些封装好的函数,用以简化我们的操作:
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong); // 本地字节序转成网络字节序 - 32位整型
uint16_t htons(uint16_t hostshort); // 本地字节序转成网络字节序 - 16位整型
uint32_t ntohl(uint32_t netlong); // 网络字节序转成本地字节序 - 32位整型
uint16_t ntohs(uint16_t netshort); // 网络字节序转成本地字节序 - 16位整型
其中,h表示host,n表示net,l表示长的32位整型,s表示短的16位整型。注意,避免对同一个数多次调用上述函数,因为这只些只是单纯的转换,并不会做大端小端的检查,如果调用两次可能就又转回去了。
除此之外还有inet_addr
和inet_aton
函数用来将字符串类型的点分十进制IP地址(例如 “192.168.1.1”)转换成可以用于网络传输的32位整型数字,函数声明如下:
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_addr(const char *cp);
socket API
下面来介绍Linux下常用的套接字函数
-
socket
#include <sys/types.h> #include <sys/socket.h> int socket(int domain, int type, int protocol);
用于创建一个套接字文件,并返回其文件描述符。
domain参数:表示所选的协议簇,如下是几种常用的宏定义:
定义 含义 PF_UNIX / PF_LOCAL 本地通讯 AF_INET / PF_INET IPv4 Internet协议 PF_INET6 IPv6 Internet协议 一般IPv4的情况下使用AF_INET 就可以了
type参数:表示套接字通信的类型,常见的几种type参数如下:定义 含义 SOCK_DGRAM 数据报套接字,无连接的套接字,与UDP协议对应 SOCK_STREAM 流式套接字,有连接的套接字,与TCP协议对应 protocol参数:用于指定协议的特定类型。如果为零,则表示自动选择一个。一般来说每种协议都只有一种类型,所以一般设为0即可。如下是一些参数示例:
IPPROTO_TCP - tcp协议
IPPROTO_UDP - udp协议
-
bind
#include <sys/types.h> #include <sys/socket.h> int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
用于将addr绑定到指定套接字,主要起到i端口号绑定的作用,一般用于服务端套接字绑定的工作。如果没有调用这个bind函数,那么在socket通信时,会绑定到一个本机的随机空闲端口号。
一般来说,bind函数只在服务端用,客户端不进行bind,这是因为服务器的服务需要有一个明确的固定端口号,这样才能够很好的被访问到,而客户端一般有各种各样的进程,手动指定端口号很容易就造成端口号冲突的问题,所以就不调用bind函数,让系统自动为我们选择一个空闲的端口号。sockfd参数:需要绑定的套接字文件描述符
addr参数:sockaddr相关信息
addrlen参数:addr参数的长度
-
sendto 和 send
sendto
:sendto函数一般用于UDP协议的数据传输工作。ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
sockfd:表示要通过哪个socket发送
buf:表示要发送的内容
len:表示要发送内容的大小
flags:标志位,一般设为0即可
dest_addr:表示dest_addr对应字段的字节数,要是为了应对多种 struct sockaddr_* 的情况。这个参数不能省略或者直接填0,必须是dest_addr的字节数。send
:send函数主要用于TCP协议的数据传输工作。ssize_t send(int sockfd, const void *buf, size_t len, int flags);
send与sendto的参数基本类似,send函数只是少了dest_addr和addrlen函数,这是因为TCP是面向连接的,一旦建立连接之后,就可以直接收发数据了,所以就不用再指定sockaddr 了。
其中,当flags参数设为0时,send函数就等同于write函数:With a zero flags argument, send() is equivalent to write(2).
注意,send函数和write函数在socket写入时,如果出错则会触发SIGPIPE信号。
-
recvfrom 和 recv
recvfrom
:recvfrom 函数一般用于UDP协议的数据传输工作。ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
其参数含义与上面的sendto一样,只是这里的src_addr表示的是要从哪里接受数据的参数,而不再是目标地的参数了。
recv
:recv函数主要用于TCP协议的数据传输工作。ssize_t recv(int sockfd, void *buf, size_t len, int flags);
recv的参数含义与send的类似,而且当flags为0时,recv函数就等同于read函数了。
- connect、listen、accept
这三个函数都是TCP协议特有的,所以就放在一起说了。
connect
:通常由客户端调用,与服务器建立连接
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
该函数的功能为主动建立连接(通过三次握手),而这个连接的过程是由内核完成的,不是这个函数完成的,这个函数的作用仅仅是通知 Linux 内核,让 Linux 内核自动完成 TCP 三次握手连接。通常情况下,客户端的 connect() 函数默认会一直阻塞,直到三次握手成功或超时失败才返回(正常的情况,这个过程很快完成)。
listen
:设为监听状态,等待客户端的连接请求
int listen(int sockfd, int backlog);
listen函数的主要作用就是将套接字( sockfd )变成被动的连接监听套接字(被动的等待客户端的连接),至于参数 backlog 的作用是设置内核中连接对应的消息队列的长度,TCP 三次握手也不是由这个函数完成,listen()的作用仅仅告诉内核一些信息。
需要注意的是,listen函数不会阻塞,它主要做的事情为,将该套接字和套接字对应的连接队列长度告诉 Linux 内核,然后,listen函数就结束。这样的话,当有一个客户端主动连接(connect),Linux 内核就自动完成TCP 三次握手,将建立好的链接自动存储到队列中,如此重复。所以,只要 TCP 服务器调用了listen函数,客户端就可以通过connect函数和服务器建立连接,而这个连接的过程是由内核完成。
accept
:等待连接并为其创建一个新的套接字
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
accept函数功能是,从处于 established 状态的连接队列头部取出一个已经完成的连接,并为其创建一个新的套接字,这样我们就可以拿着这个套接字进行操作了。如果这个队列没有已经完成的连接,accept函数就会阻塞等待,直到取出队列中已完成的用户连接为止。
TCP网络编程流程分析
本文暂不提供tcp协议的demo样例,感兴趣的话可以参考:Linux下的socket演示程序,如下是tcp协议的 client-server 常规流程:
UDP Demo样例
如下是基于udp协议的 client-server 的demo样例,为了便于查看,略去了相关安全检查的部分。流程图如下所示:
- server端
int main()
{
uint16_t port = 8008;
const int buf_len = 1024;
// 创建套接字
int sock = socket(AF_INET, SOCK_DGRAM, 0);
// 设置套接字属性
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(port);
// bind操作
bind(sock, (struct sockaddr*)&addr, sizeof(addr));
// 服务器收发操作
char buf[buf_len];
while (true)
{
struct sockaddr_in rcv_addr;
socklen_t rcv_len = sizeof(rcv_addr);
// 接收(等待)操作
int rcv_size = recvfrom(sock, buf, buf_len - 1, 0, (struct sockaddr*)&rcv_addr, &rcv_len);
buf[rcv_size] = '\0';
// 发送(回复)操作
const char *reply = "this is reply";
sendto(sock, reply, strlen(reply), 0, (struct sockaddr*)&rcv_addr, rcv_len);
}
}
- client端
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>
// 参数设置
const char* dst_ip = "127.0.0.1";
const int dst_port = 8008;
const int buf_len = 1024;
// 关键函数:socket sendto recvfrom
int main()
{
// 创建套接字
int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
// 设置套接字属性
struct sockaddr_in send_sockaddr;
memset(&send_sockaddr, 0, sizeof(send_sockaddr));
send_sockaddr.sin_family = AF_INET;
send_sockaddr.sin_addr.s_addr = inet_addr(dst_ip);
send_sockaddr.sin_port = htons(dst_port);
// 收发操作
while (true)
{
// 发送数据
const char* msg = "a test msg.";
sendto(sock, msg, strlen(msg), 0, (struct sockaddr*)&send_sockaddr, sizeof(send_sockaddr));
// 接收数据
char buf[buf_len];
struct sockaddr_in recv_sockaddr;
socklen_t len = sizeof(recv_sockaddr);
recvfrom(sock, buf, buf_len - 1, 0, (struct sockaddr*)&recv_sockaddr, &len);
}
return 0;
}
other
概念补充
- 127.0.0.1是Linux的本地环回地址IP,相当于把数据自己转给自己,一般用于测试。
- 测试socket程序时,需要检查防火墙对应的端口号是否是开发状态,否则消息可能会被防火墙拦截。
- 0.0.0.0表示任意IP地址绑定,也就是不限IP的意思。
- socket是全双工的,可以同时读写,不会出现多线程的读写的问题。所以socket的多线程的使用是安全的。
setsockopt函数
setsockopt函数,用于设置套接字的属性,其函数定义如下:
#include <sys/types.h>
#include <sys/socket.h>
int setsockopt(int sockFd, int level, int optname, const void *optval, socklen_t optlen);
sockfd参数:将要被设置或者获取选项的套接字。
level参数:选项定义的层次;支持SOL_SOCKET、IPPROTO_TCP、IPPROTO_IP和IPPROTO_IPV6。一般设成SOL_SOCKET以存取socket层。
> SOL_SOCKET 通用套接字选项.
> IPPROTO_IP IP选项.IPv4套接口
> IPPROTO_TCP TCP选项.
> IPPROTO_IPV6 IPv6套接口
optname参数: 设置的选项,有如下几种选项:
optval参数:对于setsockopt,指针,指向存放选项待设置的新值的缓冲区。获得或者是设置套接字选项.根据选项名称的数据类型进行转换。
optlen参数:optval缓冲区长度。
心跳机制
当服务器上的某个服务配置好了之后,或者建立连接止呕,那么该如何知道这个的服务未来在任何一个时刻,都是健康的呢?我们可以定期(例如30s)向对应的服务发送小请求,类似于ping服务,如果得到了回复,就说明我们的服务是正常的。这个机制,我们就称之为心跳机制,
面向字节流与面向报文的理解
可以将tcp和upd看成不同公司的出租车,tcp这个公司的出租车司机(tcp头)在拉客的时候,一看来了一个乘客,可是自己车上还有三个位置,司机就会继续等,直到自己车上去同一个目的地的乘客坐满了才开车,因为tcp公司认为遵循Nagle算法可以提高效率,节省能源,从socket学校走出来三个团体的学生,每一个团体只有一个人,可能只要消耗一个tcp出租车。如果从socket学校出来了一个团队的学生,但是这个团队有6个学生,一号tcp出租车看看自己车上还有两个个空位置,就让这个团队的两个学生上车了,剩下的学生只能做下一辆车了。这也就造成了一个问题,一号出租车开到了城市中的一个小餐馆,餐馆老板并不知道他们四个学生是不是一个团队的,这也就是粘包粘包的问题。
udp公司的出租车与tcp公司的出租车不一样,只要有一个团队的人走过来,不管是一个人还是7个人,udp出租车都可以一次性给你送走(当然下层的ip层还是可能会分包的,这些我们不用管),不需要等待。到餐馆后,餐馆老板一看是udp公司的出租车,就知道这是一个团队的(也就是不会出现粘包粘包的问题)。
- 摘自:面向字节流与面向报文的通俗解释
参考
- socket编程入门 | C语言中文网
- 【计算机网络】端口详解
- socket 函数参数详解
- 网络编程:UDP网路编程
- TCP网络编程中connect()、listen()和accept()
- socket 网络编程——端口复用技术