目录
- 本节重点
- 一、预备知识
- 1.1 理解源IP地址和目的IP地址
- 1.2 认识端口号
- 1.3 理解 "端口号" 和 "进程ID"
- 1.4 理解源端口号和目的端口号
- 1.5 认识TCP协议
- 1.6 认识UDP协议
- 1.7 网络字节序
- 二、socket编程接口
- 2.1 socket常见的API
- 2.2 sockaddr结构
- 2.3 in_addr结构
- 2.4 地址转换函数
- 2.5 关于inet_ntoa
- 三、Tcp协议通讯流程
- 四、TCP和UDP的对比
- 五、关于前台进程和后台进程的命令
- 5.1 把进程放到后台去运行:&
- 5.2 查看后台进程的命令:jobs
- 5.3 把进程变成前台进程:fg + 编号
- 5.4 把进程变成后台进程:bg + 编号
- 六、网络编程套接字内容一览表
本节重点
1、认识IP地址, 端口号, 网络字节序等网络编程中的基本概念;
2、学习socket api的基本用法;
3、能够实现一个简单的udp客户端/服务器;
4、能够实现一个简单的tcp客户端/服务器(单连接版本, 多进程版本, 多线程版本);
理解tcp服务器建立连接, 发送数据, 断开连接的流程;
一、预备知识
1.1 理解源IP地址和目的IP地址
在IP数据包头部中, 有两个IP地址, 分别叫做源IP地址, 和目的IP地址。
思考: 我们光有IP地址就可以完成通信了嘛? 想象一下发qq消息的例子, 有了IP地址能够把消息发送到对方的机器上,
但是还需要有一个其他的标识来区分出, 这个数据要给哪个程序进行解析。
1.2 认识端口号
端口号(port)是传输层协议的内容。
1、端口号是一个2字节16位的整数;
2、端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理;
3、IP地址 + 端口号能够标识网络上的某一台主机的某一个进程;
4、一个端口号只能被一个进程占用。
1.3 理解 “端口号” 和 “进程ID”
在之前学习系统编程的时候, 学习了 pid 表示唯一一个进程; 此处我们的端口号也是唯一表示一个进程. 那么这两者之间是怎样的关系?
其实这两者是没有什么直接的关系的,只不过都是它们都是用来表示进程的唯一性的而已。那既然有pid能表示进程的唯一性了,那么为什么还要有端口号表示进程的唯一性呢?原因有两点,第一:pid是系统的层面的专有名词,而端口号是网络层面的专有名词,所以把它分开有利于系统和网络名词之间相互解耦,第二:还是解耦,因为 假如有一天不再用pid来唯一标识一个进程的时候,并不会影响到用端口号唯一标识一个进程。打个比方吧,假如对于一个大学生来说,他既是公民也是一个学生,那么每个公民都有自己的身份证来唯一标识一个人,而每个大学生在学校依然有一个学号来唯一标识一个大学生,所以这两个的原理是一样的。
另外, 一个进程可以绑定多个端口号; 但是一个端口号不能被多个进程绑定。
1.4 理解源端口号和目的端口号
传输层协议(TCP和UDP)的数据段中有两个端口号, 分别叫做源端口号和目的端口号. 就是在描述 “数据是谁发的, 要发给谁”。
例如送快递也是一样的,从商家的位置送到客户的位置。那么商家的位置就是源端口号,客户的位置就是目的端口号。
1.5 认识TCP协议
此处我们先对TCP(Transmission Control Protocol 传输控制协议)有一个直观的认识; 后面我们再详细讨论TCP的一些细节问题。
1、传输层协议
2、有连接
3、可靠传输
4、面向字节流
1.6 认识UDP协议
此处我们也是对UDP(User Datagram Protocol 用户数据报协议)有一个直观的认识; 后面再详细学习。
1、传输层协议
2、无连接
3、不可靠传输
4、面向数据报
1.7 网络字节序
我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分, 网络数据流同样有大端小端之分. 那么如何定义网络数据流的地址呢?
1、发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
2、接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
3、因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址.
4、TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节.
5、不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据;
6、如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可;
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。
这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。
例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回 ;
如果主机是大端字节序,这些 函数不做转换,将参数原封不动地返回。
二、socket编程接口
2.1 socket常见的API
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
// 绑定端口号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address,
socklen_t address_len);
// 开始监听socket (TCP, 服务器)
int listen(int socket, int backlog);
// 接收请求 (TCP, 服务器)
int accept(int socket, struct sockaddr* address,
socklen_t* address_len);
// 建立连接 (TCP, 客户端)
int connect(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);
2.2 sockaddr结构
socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6,以及后面会学习的UNIX Domain Socket。然而, 各种网络协议的地址格式并不相同。
1、IPv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示,包括16位地址类型, 16位端口号和32位IP地址.
2、IPv4、IPv6地址类型分别定义为常数AF_INET、AF_INET6. 这样,只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容.
3、socket API可以都用struct sockaddr *类型表示, 在使用的时候需要强制转化成sockaddr_in; 这样的好处是程序的通用性, 可以接收IPv4, IPv6, 以及UNIX Domain Socket各种类型的sockaddr结构体指针做为参数;
struct sockaddr {unsigned short sa_family; /* 地址族, AF_xxx */char sa_data[14]; /* 14字节的协议地址*/
};
struct sockaddr_in {short int sin_family; /* 地址族 */unsigned short int sin_port; /* 端口号 */struct in_addr sin_addr; /* Internet地址 */unsigned char sin_zero[8]; /* 与struct sockaddr一样的长度 */
};
虽然socket api的接口是sockaddr, 但是我们真正在基于IPv4编程时, 使用的数据结构是sockaddr_in; 这个结构里主要有三部分信息: 地址类型, 端口号, IP地址.
2.3 in_addr结构
in_addr用来表示一个IPv4的IP地址. 其实就是一个32位的整数;
2.4 地址转换函数
这里介绍的是基于IPv4的socket网络编程,sockaddr_in中的成员struct in_addr sin_addr表示32位 的IP 地址。
但是我们通常用点分十进制的字符串表示IP 地址,以下函数可以在字符串表示 和in_addr表示之间转换;
字符串转in_addr的函数:
in_addr转字符串的函数:
其中inet_pton和inet_ntop不仅可以转换IPv4的in_addr,还可以转换IPv6的in6_addr,因此函数接口是void* addrptr。
2.5 关于inet_ntoa
inet_ntoa这个函数返回了一个char*, 很显然是这个函数自己在内部为我们申请了一块内存来保存ip的结果. 那么是否需要调用者手动释放呢?
man手册上说, inet_ntoa函数, 是把这个返回结果放到了静态存储区. 这个时候不需要我们手动进行释放。
那么问题来了, 如果我们调用多次这个函数, 会有什么样的效果呢? 验证如下:
运行结果:
因为inet_ntoa把结果放到自己内部的一个静态存储区, 这样第二次调用时的结果会覆盖掉上一次的结果。
思考::如果有多个线程调用 inet_ntoa, 是否会出现异常情况呢?
1、在APUE中, 明确提出inet_ntoa不是线程安全的函数;
2、但是在centos7上测试, 并没有出现问题, 可能内部的实现加了互斥锁;
3、在多线程环境下, 推荐使用inet_ntop, 这个函数由调用者提供一个缓冲区保存结果, 可以规避线程安全问题;
测试多线程调用inet_ntoa的代码:
#include <iostream>
using namespace std;
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <strings.h>
#include <functional>
#include <cstring>
#include <unordered_map>
#include <pthread.h>void *func1(void *argv)
{struct sockaddr_in t1;t1.sin_addr.s_addr = 0;while (true){char *p1 = inet_ntoa(t1.sin_addr);cout << "thread1:" << p1 << endl;sleep(1);}return nullptr;
}void *func2(void *argv)
{struct sockaddr_in t1;t1.sin_addr.s_addr = 0xffffffff;while (true){char *p1 = inet_ntoa(t1.sin_addr);cout << "thread2:" << p1 << endl;sleep(1);}return nullptr;
}int main()
{// struct sockaddr_in t1;// struct sockaddr_in t2;// t1.sin_addr.s_addr=0;// t2.sin_addr.s_addr=0xffffffff;// char* p1=inet_ntoa(t1.sin_addr);// char* p2=inet_ntoa(t2.sin_addr);// cout<<p1<<endl;// cout<<p2<<endl;pthread_t tid1;pthread_t tid2;pthread_create(&tid1, nullptr, func1, nullptr);sleep(5);pthread_create(&tid2, nullptr, func2, nullptr);pthread_join(tid1, nullptr);pthread_join(tid2, nullptr);return 0;
}
在centos7上测试并没有出现问题,可能是内部实现加了互斥锁。
三、Tcp协议通讯流程
下图是基于TCP协议的客户端/服务器程序的一般流程:
一、服务器初始化:
1、调用socket, 创建文件描述符;
2、调用bind, 将当前的文件描述符和ip/port绑定在一起; 如果这个端口已经被其他进程占用了, 就会bind失败;
3、调用listen, 声明当前这个文件描述符作为一个服务器的文件描述符, 为后面的accept做好准备;
4、调用accecpt, 并阻塞, 等待客户端连接过来;
二、建立连接的过程:
1、调用socket, 创建文件描述符;
2、调用connect, 向服务器发起连接请求;
3、connect会发出SYN段并阻塞等待服务器应答; (第一次)
服务器收到客户端的SYN, 会应答一个SYN-ACK段表示"同意建立连接"; (第二次)
客户端收到SYN-ACK后会从connect()返回, 同时应答一个ACK段; (第三次)
这个建立连接的过程, 通常称为 三次握手;
三、数据传输的过程:
建立连接后,TCP协议提供全双工的通信服务; 所谓全双工的意思是, 在同一条连接中, 同一时刻, 通信双方可以同时写数据; 相对的概念叫做半双工, 同一条连接在同一时刻, 只能由一方来写数据;
服务器从accept()返回后立刻调用read(), 读socket就像读管道一样, 如果没有数据到达就阻塞等待;
这时客户端调用write()发送请求给服务器, 服务器收到后从read()返回,对客户端的请求进行处理, 在此期间客户端调用read()阻塞等待服务器的应答;
服务器调用write()将处理结果发回给客户端, 再次调用read()阻塞等待下一条请求;
客户端收到后从read()返回, 发送下一条请求,如此循环下去;
四、断开连接的过程:
如果客户端没有更多的请求了, 就调用close()关闭连接, 客户端会向服务器发送FIN段(第一次);
此时服务器收到FIN后, 会回应一个ACK, 同时read会返回0 (第二次);
read返回之后, 服务器就知道客户端关闭了连接, 也调用close关闭连接, 这个时候服务器会向客户端发送一个FIN; (第三次)
客户端收到FIN, 再返回一个ACK给服务器; (第四次)
这个断开连接的过程, 通常称为 四次挥手。
在学习socket API时要注意应用程序和TCP协议层是如何交互的:
应用程序调用某个socket函数时TCP协议层完成什么动作,比如调用connect()会发出SYN段。
应用程序如何知道TCP协议层的状态变化,比如从某个阻塞的socket函数返回就表明TCP协议收到了某些段,再比如read()返回0就表明收到了FIN段。
四、TCP和UDP的对比
可靠传输 vs 不可靠传输
有连接 vs 无连接
字节流 vs 数据报
五、关于前台进程和后台进程的命令
5.1 把进程放到后台去运行:&
5.2 查看后台进程的命令:jobs
5.3 把进程变成前台进程:fg + 编号
5.4 把进程变成后台进程:bg + 编号
六、网络编程套接字内容一览表
以上就是今天想要跟大家分享的所有内容啦,你学会了吗?如果感觉到有所收获的话,那就点点赞点点关注呗,后期还会持续更新网络编程的相关知识哦,我们下期见!!!