文章目录
一,单进程处理服务器
1,基本概念
想要学习socket网络编程的读者一定要首先学好计算机网络的理论知识,包括
1)osi网络七层模型与ip四层模型
2)套接字含义
3)局域网通信过程
4)广域网通信过程
5)tcp,udp通信协议,在这两个协议中的数据封装,传输过程,传输中可能遇到的问题的处理(差错控制,拥塞控制)
6)ip网络层协议,以太网帧协议
7)数据的封装
以上的知识点是个人觉得学习计算机网络必须要理解透彻的一些知识点,接下来要介绍的socket网络编程便是基于以上知识点学习的。
socket网络编程也是系统编程的一种,系统为我们提供了一系列的一些与网络有关的接口,当我们编写一个网络程序时,需要用到网络功能的时候只需要调用这些接口即可。理解透彻了前面所说的七个知识点有助于我们理解这些接口内部具体的实现原理,方便我们在网络编程的时候遇到错误可以更快地调试出正确的运行效果。接下来就介绍这些接口的使用。
2,socket编程
2.1 字节序转换,IP地址转换,套接字赋值
套接字对应程序猿来说就是一套网络通信的接口,使用这套接口就可以完成网络通信。网络通信的主体主要分为两部分:客户端和服务器端。在客户端和服务器通信的时候需要频繁提到三个概念:IP、端口、通信数据,下面先介绍一下需要注意的一些细节问题。
2.1.1 字节序
字节序即我们计算机组成原理曾经学过的一个数据是采用大端存储还是小端存储的问题,下面也可以简单复习一遍:大端模式——数据的高字节保存在内存的低地址中;比如说一个以字节为单位进行寻址的计算机,要存储一个十六进制的数“0x1234”这个数字的话,每一个数据位占据4个存储位,那么没两个数据位就占8个存储位即一个字节,具体存储在硬盘中就是:数据的高字节“12”存储在内存的低地址中,数据的低字节“34”存储在内存的高地址中。也就是说我们要从内存的低地址处开始取数据的话,依次取出来的是一个数据的从高到低的数据位。而小端存储的话:数据的高数据字节存储在高地址位;数据的低数据字节存储在低地址位。
那么问题就来了,对于要通信的多个主机来说,他们内部选择的数据存储方式是不一样的,这也就导致他们读取一串数据的读取方式也是不一样的,甚至在网络中的数据存储方式也是不一样的,(网络字节序一搬是大端)。因此在数据传输的时候必须要对数据进行处理,以保证接收数据的一方使用它自己的主机序接收数据时得到正确的数据。具体的处理方法为下面四个函数。
#include <arpa/inet.h>
// u:unsigned
// 16: 16位, 32:32位
// h: host, 主机字节序
// n: net, 网络字节序
// s: short
// l: int
// 这套api主要用于 网络通信过程中 IP 和 端口 的转换
// 将一个短整形从主机字节序 -> 网络字节序
uint16_t htons(uint16_t hostshort);
// 将一个整形从主机字节序 -> 网络字节序
uint32_t htonl(uint32_t hostlong);
// 将一个短整形从网络字节序 -> 主机字节序
uint16_t ntohs(uint16_t netshort)
// 将一个整形从网络字节序 -> 主机字节序
uint32_t ntohl(uint32_t netlong);
这四个函数一般用来转换ip地址(32位)与端口(16位),例如主机A某一个ip经过 htonl 就可以将A自己的主机端序转化为网络端序,而另一个主机接收到这个数据只需要 ntohl 就可以将网络端序转换为B主机自己的端序,让其正确读取。
注意:一般char型的数据不需要转换,因为本来一个char型变量就会存储一个字节,不论大端小端,他们的数据存储效果都是一样的。
2.1.2 IP地址转换
为了方便我们肉眼读取ip地址,我们一般会把一个32位的ip地址表示成点分十进制的形式,这种形式是方便人类理解了,但是计算机并不理解。因此,如果要在编程中使用点分十进制的形式为一个ip进行初始化的话,一定要记得将这个点分十进制的IP地址转换成一个整型数(网络字节序,下面称呼为大端序),转换的方法如下:
// 主机字节序的IP地址转换为网络字节序
// 主机字节序的IP地址是字符串, 网络字节序IP地址是整形
int inet_pton(int af, const char *src, void *dst);
参数:
af:地址族(IP地址的家族包括ipv4和ipv6)协议
AF_INET: ipv4格式的ip地址
AF_INET6: ipv6格式的ip地址
src:传入参数,对应要转换的点分十进制的ip地址:192.168.1.100
dst:传出参数,函数调用完成,转换得到的大端整形IP被写入到这块内存中
O返回值:成功返回1,失败返回0或者- 1
另外,我们当然也可以将一个整型数转换为一个点分十进制的字符串
#include <arpa/inet.h>
// 将大端的整形数, 转换为小端的点分十进制的IP地址
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
注:还有一组函数也能进程I地址大小端的转换,但是只能处理ipv4的ip地址:
// 点分十进制IP -> 大端整形
in_addr_t inet_addr (const char *cp);
// 大端整形 -> 点分十进制IP
char* inet_ntoa(struct in_addr in);
2.1.3, 万恶的结构体:sockaddr,sockaddr_in
1,sockaddr结构体
系统内核通过读取该结构体当中的内容获取我们要写的套接字的ip地址与端口,这个结构体成员如下:
typedef unsigned short int sa_family_t;
#define __SOCKADDR_COMMON_SIZE (sizeof (unsigned short int))
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
struct sockaddr {
sa_family_t sa_family; // 地址族协议, ipv4
char sa_data[14]; // 端口(2字节) + IP地址(4字节) + 填充(8字节)
}
该结构体方便内核读取,但是,仔细观察可以发现,如果我们要给这个结构体用ipv4协议族里面的某一ip以及某一个端口赋值的话,可能不太方便——因为sa_family好赋值,但是sa_data[14]成员不好将ip以及端口都赋值进去,我们赋值的时候还要注意数组的下标,不能赋值错位了。因此,为了我们方便赋值,就有了sockaddr_in结构体与sockaddr_un结构体,分别用于给一个tcp通信端以及udp通信端赋值,这里只介绍sockaddr_in。
1,sockaddr_in结构体
sockaddr_in结构体如下
typedef uint16_t in_port_t;
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr;
};
struct sockaddr_in
{
sa_family_t sin_family; /* 地址族协议: AF_INET */
in_port_t sin_port; /* 端口, 2字节-> 大端 */
struct in_addr sin_addr; /* IP地址, 4字节 -> 大端 */
/* 填充 8字节 */
unsigned char sin_zero[sizeof (struct sockaddr) - sizeof(sin_family) -
sizeof (in_port_t) - sizeof (struct in_addr)];
};
可以看到,sockaddr_in的结构就很方便我们进行赋值,而且如果要将数据交付给内核使用的话,就只需要将sockaddr_in类型的数据强制转换为sockaddr类型即可。
2.2 网络程序通信的一般流程
观察下面这张图,这张图为一般网络程序运行框架,后文对该图进行解读。
就服务端来说:
对于这个通信过程,我们用银行接待客户办业务进行类比理解。
1,socket()函数创建一个套接字,就好像有了一个小银行。有了一个大堂经理(为的是以后监听),返回一个监听套接字fd
2,bind()函数给这个套接字绑定一个ip与端口,如果不用bind函数的话,socket()函数创建的套接字也默认有一个ip与随机端口。就好像银行有了一个地址,别人(客户端)就可以知道去哪里办业务了。
3,listen()函数,表示fd开始监听,就好像大堂经理从这里开始工作。(当然,如果是单进程的话,只能处理一个客户端,就相当于一个大堂经理会一直陪着客户直到这个客户的业务办完离开,才会接待下一个客户,如果上一个客户的业务没有办完,该客户就会阻塞在大堂经理的待处理连接里面),注意fd维持了一个待处理连接的一个读队列,可以理解为大堂经理将所有监听到的客户都先记录到小本子上,等待分配给业务员处理。
4,accept(),银行里面的业务员从大堂经理这里要一个人处理,返回参数主要是有一个客户端通信描述符cfd,程序就是通过这个cfd和客户端进行交互。默认一个服务端最高可监听1024个客户端的连接。即可以认为该银行最多有1024个业务员,当然,如果是单进程的话,管他是多少个业务员也没有意义。注意,在服务端这边这个函数执行成功以后cfd就会创建成功,在内核里面就会创建与该客户端交互有关的读缓冲区和写缓冲区。
5,read write,与客户交流的函数,同函数recv与send
6,close,关闭某一个客户端的连接,即服务完某一个客户端就可以使用该函数。表示银行的某业务员结束了对某客户的服务。同样,也可以直接结束大堂经理的工作,表示这个服务端接下来结束提供服务了。
就客户端来说:
也类似:
区别是
1,客户端的socket返回的就是客户端的通信描述符lfd,相当于客户;而不像服务端一样返回的是大堂经理,大堂经理检测到以后才会有对应的与其沟通的业务员。
2,connect表示客户端与服务端尝试建立连接,connect里面的输入参数有要连接服务端的地址,connect执行的时候就会被服务端listen检测到,如果accept没有阻塞的话,这时候就会进行三次握手,三次握手完成以后,服务端执行完accept,客户端执行完connect。
另外注意,针对connect()函数,lfd是输入参数,也是输出参数,执行完该函数以后,lfd也会有对应的输入缓冲区与输出缓冲区与服务端进行交流。而accept中,监听描述符fd仅仅是输入参数,最后函数的返回值才是最终与客户端建立通信的通信描述符。
2.3 通信过程中使用到的函数的具体描述
2.3.1 socket函数
// 创建一个套接字
int socket(int domain, int type, int protocol);
参数:
domain:使用的地址族协议
AF_INET:使用IPv4格式的ip地址
AF_INET6:使用IPv4格式的ip地址
type:
soCK_STREAM:使用流式的传输协议o socK_DGRAM:使用报式(报文)的传输协议
protocol:一般写0即可,使用默认的协议,即使用前面domin和type声明的协议。
返回值:
成功:可用于套接字通信的文件描述符
失败: -1
注意:有些同学可能会问了:domain字段和type字段已经可以指定协议名称了,干嘛还要有protocol字段。实际上domain不仅近可以声明为IPV4或者IPV6协议族,还可以声明为其他的协议族,比如域通信协议族,novell协议族,这些协议族里面也有类似tcp和udp的协议,更会有AF_UNSPEC:不指定具体协议族,这时候就需要protocol字段具体指明具体的使用的协议。
2.3.2 bind函数
// 将文件描述符和本地的IP与端口进行绑定
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
sockfd:监听的文件描述符,通过socket()调用得到的返回值
addr:传入参数,要绑定的IP和端口信息需要初始化到这个结构体中,IP和端口要转换为网络字节序
addrlen:参数addr 指向的内存大小, sizeof (struct sockaddr)
返回值:成功返回0,失败返回-1
2.3.3 listen函数
// 给监听的套接字设置监听
int listen(int sockfd, int backlog);
参数:
sockfd:文件描述符,可以通过调用socket()得到,在监听之前必须要绑定bind ()
backlog:同时能处理的最大连接要求,最大值为128
返回值:函数调用成功返回0,调用失败返回-1
2.3.4 accept函数
// 等待并接受客户端的连接请求, 建立新的连接, 会得到一个新的文件描述符(通信的)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数:
osockfd:监听的文件描述符
addr:传出参数,里边存储了建立连接的客户端的地址信息
addrlen:传入传出参数,用于存储addr指向的内存大小
返回值:函数调用成功,得到一个文件描述符,用于和建立连接的这个客户端通信,调用失败返回-1
注意:这个函数是一个阻塞函数,当没有新的客户端连接请求的时候,该函数阻塞;当检测到有新的客户端连接请求时,阻塞解除,新连接就建立了,得到的返回值也是一个文件描述符,基于这个文件描述符就可以和客户端通信了。
2.3.5 read函数或recv函数
// 接收数据
ssize_t read(int sockfd, void *buf, size_t size);
ssize_t recv(int sockfd, void *buf, size_t size, int flags);
参数:
sockfd:用于通信的文件描述符,accept ()函数的返回值
buf:指向一块有效内存,用于存储接收是数据
size:参数 buf指向的内存的容量
flags:特殊的属性,一般不使用,指定为0
返回值:
大于0:实际接收的字节数
等于0:对方断开了连接
-1:接收数据失败了
注意:如果连接没有断开,接收端接收不到数据,接收数据的函数会阻塞等待数据到达,数据到达后函数解除阻塞,开始接收数据,当发送端断开连接,接收端无法接收到任何数据,但是这时候就不会阻塞了,函数直接返回0。
2.3.6 write函数或send函数
// 发送数据的函数
ssize_t write(int fd, const void *buf, size_t len);
ssize_t send(int fd, const void *buf, size_t len, int flags);
参数:
fd:通信的文件描述符,accept ()函数的返回值
buf:传入参数,要发送的字符串
len:要发送的字符串的长度
flags:特殊的属性,一般不使用,指定为0
返回值:
大于0:实际发送的字节数,和参数len是相等的
-1:发送数据失败了
2.3.7 connect函数
// 成功连接服务器之后, 客户端会自动随机绑定一个端口
// 服务器端调用accept()的函数, 第二个参数存储的就是客户端的IP和端口信息
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
sockfd:通信的文件描述符,通过调用socket ()函数就得到了
addr:存储了要连接的服务器端的地址信息: iP和端口,这个IP和端口也需要转换为大端然后再赋值addrlen: addr指针指向的内存的大小sizeof (struct sockaddr)
返回值:连接成功返回0,连接失败返回- 1
2.4 文件描述符详解
2.4.1 服务端两种文件描述符区分
服务端有两个描述符:监听文件描述符以及通信文件描述符。
监听文件描述符只能有一个,通信文件描述符如果在允许多个客户端连接的服务端程序里可能有多个(比如多线程服务器以及io多路复用(select,epoll)服务器)
监听的文件描述符
1,只需要有一个
2,不负责和客户端通信,负责检测客户端的连接请求,检测到之后调用accept就可以建立新的连接
通信的文件描述符
1,负责和建立连接的客户端通信
2,如果有N个客户端和服务器建立了新的连接,通信的文件描述符就有N个,每个客户端和服务器都对应一个通信的文件描述符
而客户端只有一个用于通信的文件描述符。
2.4.2 文件描述符对应的内存结构
1,一个文件文件描述符对应两块内存,一块内存是读缓冲区,一块内存是写缓冲区
2,读数据:通过文件描述符将内存中的数据读出,这块内存称之为读缓冲区
3,写数据:通过文件描述符将数据写入到某块内存中,这块内存称之为写缓冲区
监听文件描述符需注意:
监听的文件描述符:
1,客户端的连接请求会发送到服务器端监听的文件描述符的读缓冲区中
2,读缓冲区中有数据,说明有新的客户端连接
3,调用accept ()函数,这个函数会检测监听文件描述符的读缓冲区,如果检测不到数据,该函数阻塞,如果检测到数据,解除阻塞,新的连接建立
通信文件描述符需注意:
通信的文件描述符:
1,客户端和服务器端都有通信的文件描述符
2,发送数据:调用函数write () /send (),数据进入到内核中
3,数据并没有被发送出去,而是将数据写入到了通信的文件描述符对应的写缓冲区中
4,内核检测到通信的文件描述符写缓冲区中有数据,内核会将数据发送到网络中
5,接收数据:调用的函数(read /recv ),从内核读数据
6,数据如何进入到内核程序猿不需要处理,数据进入到通信的文件描述符的读缓冲区中
7,数据进入到内核,必须使用通信的文件描述符,将数据从读缓冲区中读出即可
2.5,一个tcp通信的服务端,客户端程序演示
1,TCP服务端
// server.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
int main()
{
// 1. 创建监听的套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1)
{
perror("socket");
exit(0);
}
// 2. 将socket()返回值和本地的IP端口绑定到一起
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(10000); // 大端端口
// INADDR_ANY代表本机的所有IP, 假设有三个网卡就有三个IP地址
// 这个宏可以代表任意一个IP地址
// 这个宏一般用于本地的绑定操作
addr.sin_addr.s_addr = INADDR_ANY; // 这个宏的值为0 == 0.0.0.0
// inet_pton(AF_INET, "192.168.237.131", &addr.sin_addr.s_addr);
int ret = bind(lfd, (struct sockaddr*)&addr, sizeof(addr));
if(ret == -1)
{
perror("bind");
exit(0);
}
// 3. 设置监听
ret = listen(lfd, 128);
if(ret == -1)
{
perror("listen");
exit(0);
}
// 4. 阻塞等待并接受客户端连接
struct sockaddr_in cliaddr;
int clilen = sizeof(cliaddr);
int cfd = accept(lfd, (struct sockaddr*)&cliaddr, (socklen_t*)&clilen);
if(cfd == -1)
{
perror("accept");
exit(0);
}
// 打印客户端的地址信息
char ip[24] = {0};
printf("客户端的IP地址: %s, 端口: %d\n",
inet_ntop(AF_INET, &cliaddr.sin_addr.s_addr, ip, sizeof(ip)),
ntohs(cliaddr.sin_port));
// 5. 和客户端通信
while(1)
{
// 接收数据
char buf[1024];
memset(buf, 0, sizeof(buf));
int len = read(cfd, buf, sizeof(buf));
if(len > 0)
{
printf("客户端say: %s\n", buf);
write(cfd, buf, len);
}
else if(len == 0)
{
printf("客户端断开了连接...\n");
break;
}
else
{
perror("read");
break;
}
}
close(cfd);
close(lfd);
return 0;
}
2,TCP客户端
// client.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
int main()
{
// 1. 创建通信的套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd == -1)
{
perror("socket");
exit(0);
}
// 2. 连接服务器
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(10000); // 大端端口
inet_pton(AF_INET, "192.168.237.131", &addr.sin_addr.s_addr);
int ret = connect(fd, (struct sockaddr*)&addr, sizeof(addr));
if(ret == -1)
{
perror("connect");
exit(0);
}
// 3. 和服务器端通信
int number = 0;
while(1)
{
// 发送数据
char buf[1024];
sprintf(buf, "你好, 服务器...%d\n", number++);
write(fd, buf, strlen(buf)+1);
// 接收数据
memset(buf, 0, sizeof(buf));
int len = read(fd, buf, sizeof(buf));
if(len > 0)
{
printf("服务器say: %s\n", buf);
}
else if(len == 0)
{
printf("服务器断开了连接...\n");
break;
}
else
{
perror("read");
break;
}
sleep(1); // 每隔1s发送一条数据
}
close(fd);
return 0;
}
二,多线程处理服务器
见下一篇博客