Linux网络编程
1、套接字(Socket)
(1)相关概念
网络编程最主要的工作就是在发送端把信息通过规定好的协议进行组装包,在接收端按照规定好的协议把包进行解析,从而提取出对应的信息,达到通信的目的。其中socket编程是网络常用的编程,我们通过在网络中创建socket套接字来实现网络间的通信。
- 所谓套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。
- Socket最早是应用于UNIX系统的一种通信模式,UNIX系统下的所有操作均是面向文件的。即Socket的通信模式也是基于文件操作的:客户端和服务端均对一个文件完成“打开-读/写-关闭”的操作通过此文件传输信息完成通信。
- 套接字是通信的基石,是支持TCP/IP协议的网络通信的基本操作单元。
套接字Socket=(IP地址:端口号),套接字的表示方法是点分十进制的lP地址后面写上端口号,中间用冒号或逗号隔开。每一个传输层连接唯一地被通信两端的两个端点(即两个套接字)所确定。例如:如果IP地址是192.168.1.1,而端口号是23,那么得到套接字就是(192.168.1.1:23)。
(2)主要类型
Socket处于网络协议的传输层套接字可以分为流套接字、数据报套接字和原始套接字3种不同的类型。
1.流套接字(SOCK_STREAM)
流套接字用于提供面向连接、可靠的数据传输服务。该服务将保证数据能够实现无差错、无重复送,并按顺序接收。流套接字之所以能够实现可靠的数据服务,是因为其使用了传输控制协议,即TCP(The Transmission Control Protocol)协议 。
2.数据报套接字(SOCK_DGRAM)
数据报套接字提供一种无连接、不可靠的服务。该服务并不能保证数据传输的可靠性,数据有可能在传输过程中丢失或出现数据重复,且无法保证顺序地接收到数据。数据报套接字使用UDP( User DatagramProtocol)协议进行数据的传输。由于数据报套接字不能保证数据传输的可靠性,对于有可能出现的数据丢失情况,需要在程序中做相应的处理。
3.原始套接字(SOCK_RAW)
原始套接字与标准套接字(标准套接字指的是前面介绍的流套接字和数据报套接字)的区别在于:原始套接字可以读写内核没有处理的IP数据包,而流套接字只能读取TCP协议的数据,数据报套接字只能读取UDP协议的数据。因此,如果要访问其他协议发送的数据必须使用原始套接。原始套接字主要用于一些协议的开发,可以进行比较底层的操作。
(3)TCP/UDP协议
- TCP Sockets:使用TCP协议实现可靠的网络通信。
- UDP Sockets:使用UDP协议实现效率较高的网络通信。
主要区别:相对来说,TCP是可靠的,UDP是不可靠的
- TCP是面向连接的,UDP是无连接的
- 在TCP协议进行网络通信时,需要先建立连接,也就是说需要先将客户端与服务器的连接连好,然后在进行数据交互。
- TCP是面向字节流的,UDP是面向数据报文的
- 面向字节流是以字节为单位发送数据,并且一个数据包可以以字节大小来拆分成多个数据包,以方便发送。
- TCP可以先将数据存放在发送缓冲区中,可以等待数据到一定大小发送,也可以直接发送,没有固定大小可言。
- UDP需要每次发送都需要发送固定长度的数据包,如果长度过长需要应用层协议主动将其裁剪到适合长度。
- TCP只支持点对点通信,UDP支持一对一,一对多,多对多
- TCP需要双方建立连接,所以需要点对点通信,UDP无需这么复杂。
- TCP有拥塞控制机制,UDP没有
- TCP的拥塞控制可是保证了TCP协议在网络中传输的可靠性,降低重传和丢包的概率,UDP不存在。
TCP or UDP如何选择:
对某些实时性要求比较高或者出具比较大(传输视频)的情况,选择UDP,比如游戏,媒体通信,实时视频流(直播),即使出现传输错误也可以容忍;其它大部分情况下,HTTP都是用TCP,因为要求传输的内容可靠,不出现丢失。
(4)Socket服务器和客户端的开发步骤
要通过互联网进行通信,至少需要一对套接字,其中一个运行于客户端,称之为 Client Socket,另一个运行于服务器端,称之为Server Socket。
- 服务端开发步骤:
- 创建套接字;
- 为套接字添加信息(IP地址和端口号);
- 监听网络连接;
- 监听到有客户端请求连接,接受一个连接;
- 数据交互;
- 关闭套接字断开连接。
- 客户端开发步骤:
- 创建套接字;
- 向服务器发服务请求报文,请求连接;
- 数据交互;
- 关闭套接字断开连接。
(5)TCP 连接的三次握手
在上述的客户端和服务端连接过程中,TCP通过三次握手建立起一个连接,三次握手的主要原因是为了防止旧的重复连接引发混乱。那什么是三次握手呢?
1.握手前的准备工作
三次握手开始的时刻就是,客户端调用connect连接服务端,此时需要有个前提条件就是服务端的套接字要处在监听状态。
-
服务端:
- 调用Socket函数创建通信套接字。其本质就是打开用于通信的socket文件;
- 调用bind函数绑定服务器IP地址和端口号。其实就是将打开的socket文件与存放地址信息的sockaddr_in 结构体绑定,方便对方能够找到这个文件;
- 调用listen函数将套接字设为监听状态。可以理解为设置文件的读写权限,即允许other读写。
-
客户端:
- 站在客户端的角度其实是认为服务端是时刻准备好的,不然也不会主动发起连接。所以客户端只要调用Socket函数创建通信套接字,即打开用于通信的socket文件,随后就可以调用connect函数连接服务端了。
作为主动连接别人的一方,必须要知道你要连谁,即对方的IP地址和端口号;但是你自己无需主动绑定IP地址和监听,因为客户端一般不会被其他主机连接。就好比你是送快递的,你要知道客户的住址;但是客户没必要知道你的住址。
2.第一次握手
客户端主动调用 connect函数的时候,就是发起第一次握手,表明客户端请求和服务端建立连接。发送的报文携带SYN标志位。
客户端状态 : SYN_SENT(连接请求已发送)
3.第二次握手
服务端收到客户端的请求以后,理解了客户端的意图,同意建立连接(Client–>Server方向上的),至此一个方向上的连接就建立好了。服务端也发送SYN请求顺带应答,希望建立连接(Server–>Client方向上的)。
服务端状态:SYN_REVD(接收到连接请求)
4.第三次握手
客户端收到服务端的确认应答,并给服务端发送ACK同意建立连接(Server–>Client方向上的)。给服务端发送ACK以后,客户端就认为连接已经建立成功了。
客户端状态:ESTABLISHED(建立连接)
服务端如果收到客户的应答,就说明客户端也同意建立连接,至此 Server–>Client方向上的连接也就建立好了。服务端也认为连接建立成功了;如果迟迟没有收到,服务端认为客户端不希望与自己建立 Server–>Client方向上的连接,此时会连接失败。
服务端状态:ESTABLISHED(建立连接)/ CLOSED(连接失败)
2、字节序
字节序是指多字节数据在计算机内存中存储或者网络传输时各字节的存储顺序。在跨平台和网络编程中传输数据必须考虑字节序的问题。
(1)大小端字节序
字节序常被分为两类:
- 小端字节序(Big endian) :将低序字节存储在起始地址
- 大端字节序(Little endian) :将高序字节存储在起始地址
主机字节序:采用小端字节序
网络字节序:采用大端字节序
(2)高低地址与高低字节
高低地址:
C程序映射中内存的空间布局大致如下:
最高内存地址 0xFFFFFFFF
栈区(从高内存地址,往 低内存地址发展。即栈底在高地址,栈顶在低地址)
堆区(从低内存地址 ,往 高内存地址发展)
全局区(常量和全局变量)
代码区
最低内存地址 0x00000000
高低字节:
在十进制中靠左边的是高位,靠右边的是低位,在其他进制也是如此。例如 0x12345678,从高位到低位的字节依次是0x12、0x34、0x56和0x78。
对于数据 0x12345678,假设从地址0x4000开始存放,在大端和小端模式下,存放的位置分别为:
内存地址 | 小端字节序 | 大端字节序 |
---|---|---|
0x4003 | 0x12 | 0x78 |
0x4002 | 0x34 | 0x56 |
0x4001 | 0x56 | 0x34 |
0x4000 | 0x78 | 0x12 |
采用Little endian模式的CPU对操作数的存放方式是从低字节到高字节,而Big endian模式对操作数的存放方式是从高字节到低字节。小端存储后:0x78563412 大端存储后:0x12345678。
(3)字节序转换API
网络字节顺序采用Big endian排序方式。网络字节顺序是TCP/IP中规定好的一种数据格式,它与具体的CPU类型、操作系统等无关,从而可以保证数据在不同主机之间传输时能够被正确解释。在网络程序开发时或是跨平台开发时应该注意保证只用一种字节序否则无法通讯。
#include <arpa/inet.h>
//返回网络字节序的值
uint32_t htonl(uint32_t hostlong);//把unsigned long类型从主机序转换到网络序
uint16_t htons(uint16_t hostshort);//把unsigned short类型从主机序转换到网络序
//返回主机字节序的值
uint32_t ntohl(uint32_t netlong);//把unsigned long类型从网络序转换到主机序
uint16_t ntohs(uint16_t netshort);//把unsigned short类型从网络序转换到主机序
h代表host(主机字节序),n代表net(网络字节序),s代表short(2个字节),l代表long(4个字节),通过上面的4个函数可以实现主机字节序和网络字节序之间的转换。有时可以用INADDR_ANY,INADDR_ANY指定地址让操作系统自己获取。
3、套接字相关API
在客户端与服务端的连接过程中用到了许多API接口,下面先介绍一下需要用到的函数:
//套接字相关函数头文件
#include <sys/types.h>
#include <sys/socket.h>
(1)socket()-创建套接字
应用程序在使用套接字前,首先必须拥有一个套接字,系统调用socket()向应用程序提供创建套接字的手段
int socket(int domain, int type, int protocol);
//返回值:成果返回一个文件描述符,失败返回-1。由此可知,套接字的本质还是文件。
参数介绍:
- domain:domain(域)确定通信的特性,指明所使用的协议族,通常为AF_INET,表示互联网协议族(TCP/IP 协议族)
- AF_INET IPV4 因特网域
- AF_INET6 IPV6 因特网域
- AF_UNIX Unix域
- AF_ROUTE 路由套接字
- AF_KEY 密钥套接字
- AF_UNSPEC 未指定
- type:参数指定 socket 的类型,进一步确定通信特征
- SOCK_STREAM
流式套接字提供可靠的、面向连接的通信流;它使用TCP 协议,保证了数据传输的正确性和顺序性。 - SOCK_DGRAM
数据报套接字定义了一种无连接服务,数据通过相互独立的报文进行传输,是无序的,并且不保证是可靠、无差错的。它使用数据报协议UDP。 - SOCK_RAW
允许程序使用低层协议,原始会接字允许对底层协议如IP 或 ICMP 进行直接访问,功能强大但使用较为不便,主要用于一些协议的开发。
- SOCK_STREAM
- protocol:通常是0,表示为给定的域和套接字类型选择默认协议。
- 0 选择 type 类型对应的默认协议
- IPPROTO_TCP TCP传输协议
- IPPROTO_UDP UDP传输协议
- IPPROTO_SCTP SCTP 传输协议
- IPPROTO_TIPC TIPC 传输协议
(2)bind()-指定本地地址
当一个套接字用socket()创建后,存在一个名字空间(地址族),但它没有被命名。bind()将套接字地址(包括本地主机地址和本地端口地址)与所创建的套接字号联系起来,即将名字赋予套接字。
int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
//返回值:成功返回0,失败则返回-1
参数介绍:
- sockfd:服务器端socket描述符
- addr:一个指向包含有本机IP地址及端口号等信息的sockaddr类型的指针,指向要绑定给sockfd的协议地址结构,这个地址结构根据地址创建socket时的地址协议族的不同而不同
- addrlen:服务端的址度长地
其中addr的配置如下:
//ipv4对应的是:
struct sockaddr {
sa_family_t sa_family; //协议族
char sa_data[14];//IP+端口
};
//同等替换:
#include <arpa/inet.h>
struct sockaddr_in {
sa_family_t sin_family; //协议族
in_port_t sin_port; // 端口号
struct in_addr sin_addr; //IP地址结构体
};
struct in_addr {
uint32_t s_addr; /* address in network byte order
};
(3)listen()-监听函数
listen函数只服务与服务端,设置能处理的最大连接数。服务端进程不知道要与谁连接,因此不会主动要求与某个进程连接,只是一直监听是否有其他客户端进程与之连接,然后响应连接请求,并对它作出处理,一个服务端进程可以同时处理多个客户端进程的连接。
int listen(int sockfd, int backlog);
//返回值:成功返回0,失败则返回-1
参数介绍:
- sockfd:服务器端socket描述符
- backlog:指定在请求队列中允许的最大请求数
(4)accept()与connect()-建立连接
accept():用于服务端等待客户端的连接。如果已完成队列为空,那么进程进入睡眠。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
//返回值:返回一个新的套接字描述符,返回值表示已连接客户端的套接字描述符
参数介绍:
- sockfd:服务器端socket描述符
- addr:用来返回已连接的对端**(客户端)协议地址**
- addrlen:客户端的长地址度
connect():和服务端建立连接。
int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
//返回值:成功返回0,失败则返回-1,并且error中包含相应的错误码
参数介绍:
- sockfd:客户端socket描述符
- addr:服务端的IP地址和端口号的地址指针结构
- addrlen:服务端的地址长度
(5)send()和recv()-数据传输
数据传输常用的API有send()和recv()函数,除此之外还有字节流读写函数read()和write()。
既然一个套接字端点表示为一个文件描述符,那么只要建立连接,就可以使用read()和write()函数来通过套接字通信。除此之外,常用的API还有send()和recv()函数这种为数据传递而设计的套接字函数。read()和write()函数在文件编程中已经介绍,这里只介绍send()和recv()函数。
send():在TCP套接字上发送数据的函数,在类似write,使用send时套接字必须已经连接,与write不同的是,send支持第四个参数flags。
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
//返回值:如果send函数copy数据成功,就返回实际copy的字节数,如果send在copy数据时出现错误,那么send就返回SOCKET_ERROR;如果send在等待协议传送数据时网络断开的话,那么send函数也返回SOCKET_ERROR。
参数介绍:
- sockfd:客户端socket描述符
- buf:指向存放send要发送的数据的缓冲区
- len:发送数据的长度
- flags:一般设置为0
recv():在TCP套接字上接收数据的函数,和read相似,但是recv可以指定标志来控制如何接收数据。
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
//返回值:recv函数返回其实际copy的字节数。如果recv在copy时出错,那么它返回SOCKET_ERROR;如果recv函数在等待协议接收数据时网络中断了,那么它返回0。
参数介绍:
- sockfd:客户端socket描述符
- buf:指向存放recv函数接收到的数据的缓冲区
- len:接收数据的长度
- flags:一般设置为0
需要注意的是,send只是将数据copy到缓冲区,recv只是copy缓冲区的数据。
对于发送端:
- **注意并不是send把发送缓冲中的数据传到连接的另一端的,send仅仅是把buf中的数据copy到发送缓冲区的剩余空间里。将缓冲区的数据通过内核发送到对端,这就是tcp的事;
- send函数把buf中的数据成功copy到发送缓冲的剩余空间里后它就返回了,但是此时这些数据并不一定马上被传到连接的另一端。如果协议在后续的传送过程中出现网络错误的话,那么下一个Socket函数就会返回SOCKET_ERROR。
对于接收端:
- 内核将网络中的数据拷贝到缓冲区,等待上层应用读取;
- recv先检查套接字的接收缓冲区,如果接收缓冲区中没有数据或者协议正在接收数据,那么recv就一直等待,直到协议把数据接收完毕。当协议把数据接收完毕,recv函数就把接收缓冲中的数据copy到buf中;如果协议接收到的数据长度大于buf,这种情况下要调用几次recv函数才能把接收缓冲中的数据copy完;
- 对于TCP,如果应用进程一直没有读取,接收缓冲区满了之后,发生的动作是:收端通知发端,接收窗口关闭(win=0)。这个便是滑动窗口的实现。保证TCP套接口接收缓冲区不会溢出,从而保证了TCP是可靠传输。因为对方不允许发出超过所通告窗口大小的数据。 这就是TCP的流量控制,如果对方无视窗口大小而发出了超过窗口大小的数据,则接收方TCP将丢弃它。
(6)IP地址转换
在网络通信的时候,一般IP地址和端口号要发送给对端,而我们使用的IP地址是点分十进制的,比如127.0.0.1,无法直接传递,那么需要用到地址转换API:
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int inet_aton(const char* straddr,struct in_addr *addrp);
//把字符串形式的“127.0.0.1”转为网络能识别的格式
char* inet_ntoa(struct in_addr inaddr);
//把网络格式的ip地址转为字符串形式
4、基于TCP协议的Socket通信
- 服务端创建套接字之后父进程一直等待客户端的连接,同时也接收客户端发送的消息。有客户端连接成功后创建一个子进程对专门回复客户端消息。
- 客户端申请连接,连接成功后也是创建一个子进程专门发送消息,父进程接收服务端回复的消息。
(1)服务端代码
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <netinet/in.h>
int main(int argc, char **argv)
{
int s_fd;//服务端描述符
int c_fd;//accept返回的客户端描述符
char sendBuf[128] = {'\0'};
char recvBuf[128] = {'\0'};
struct sockaddr_in s_addr;
struct sockaddr_in c_addr;
int s_len = sizeof(struct sockaddr_in);
int c_len = sizeof(struct sockaddr_in);
memset(&s_addr,'\0',s_len);
memset(&c_addr,'\0',c_len);
//1.创建socket套接字
if((s_fd = socket(AF_INET,SOCK_STREAM,0)) == -1){
printf("creat socket failed\n");
exit(-1);
}
//2.为套接字添加信息,设置ip地址和端口号
s_addr.sin_family = AF_INET;
//端口号设置需要大于5000,先由数字字符转换成整形字符,然后转换成网络字节序
s_addr.sin_port = htons(atoi(argv[2]));
//把IP地址转换成网络能识别的格式
inet_aton(argv[1],&s_addr.sin_addr);
if(bind(s_fd,(struct sockaddr *)&s_addr,s_len) != 0){
perror("bind");
exit(-1);
}
//3.监听网络连接
if(listen(s_fd,10) != 0){
perror("listen");
exit(-1);
}
while(1){
printf("wait connect -----\n");
//4.建立连接
if((c_fd = accept(s_fd,(struct sockaddr *)&c_addr,&c_len)) == -1){
perror("accept");
exit(-1);
}else{
//连接成功,打印客户端IP地址
printf("get connect %s\n",inet_ntoa(c_addr.sin_addr));
}
//5.创建一个子进程负责回复消息
if(fork() == 0){
while(1){
memset(sendBuf,'\0',sizeof(sendBuf));
printf("return client:\n");
gets(sendBuf);
send(c_fd,sendBuf,strlen(sendBuf),0);
}
}
//6.一直接收消息
while(1){
memset(recvBuf,'\0',sizeof(recvBuf));
recv(c_fd,recvBuf,sizeof(recvBuf),0);
printf("recv from client: %s\n",recvBuf);
}
close(s_fd);
close(c_fd);
}
return 0;
}
(2)客户端代码
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <netinet/in.h>
int main(int argc, char **argv)
{
int c_fd;//客户端描述符
char sendBuf[128] = {'\0'};
char recvBuf[128] = {'\0'};
struct sockaddr_in c_addr;
int c_len = sizeof(struct sockaddr_in);
memset(&c_addr,'\0',c_len);
//1.建立套接字
if((c_fd = socket(AF_INET,SOCK_STREAM,0)) == -1){
printf("creat socket failed\n");
exit(-1);
}
//2.配置IP地址和端口号
c_addr.sin_family = AF_INET;
c_addr.sin_port = htons(atoi(argv[2]));
inet_aton(argv[1],&c_addr.sin_addr);
if(connect(c_fd,(struct sockaddr *)&c_addr,c_len) != 0){
perror("connect");
exit(-1);
}
while(1){
if(fork() == 0){
while(1){
//3.发送数据
memset(sendBuf,'\0',sizeof(sendBuf));
printf("sendMsg:\n");
gets(sendBuf);
send(c_fd,sendBuf,strlen(sendBuf),0);
}
}
//4.接收数据
memset(recvBuf,'\0',sizeof(recvBuf));
recv(c_fd,recvBuf,sizeof(recvBuf),0);
printf("get from server: %s\n",recvBuf);
}
return 0;
}
运行效果:客户端和服务端建立连接后可以进行数据交互。
如果对基于Socket实现客户端服务端文件互传的项目感兴趣,可以看看这篇文章:
基于Socket套接字实现FTP简单功能