文章目录
- 前言
- 一. 网络通信本质
- 端口号
- TCP与UDP
- 网络字节序
- 二. socket编程接口
- socket()和sockaddr结构体
- 三. 简单echo服务
- 结束语
前言
本系列文章是计算机网络学习
的笔记,欢迎大佬们阅读,纠错,分享相关知识。希望可以与你共同进步。
一. 网络通信本质
上篇博客说到,MAC地址标识网卡的全球唯一性,IP地址标识计算机在公网中的唯一性。要想进行网络通信,就必须知道目的主机的IP地址
但是这还不够,数据只是成功送到了目的主机,并没有被处理。QQ消息要发到QQ,微信消息要发到微信。数据不仅要送达目的主机,还要送达目的程序,也就是进程
所以网络通信的本质是
两个主机的两个进程基于网络的进程间通信
网络通信的过程:
- 先将数据通过OS,将数据发送到目标主机(TCP/IP协议),其中IP标识公网上唯一的一台主机
- 在本主机收到数据后,推送给上层指定的进程
那么如何标识进程呢?——端口号
端口号
首先,回答为什么不使用pid?
- 并不是所有的进程都需要接收发送网络数据
- 网络属于文件系统的一部分,同样使用pid会增加耦合度
接下来介绍端口号
端口号(port)是传输层协议的内容
- 端口号是一个
2字节16位
的整数 - 端口号用来标识一个进程,告诉操作系统,当前的这个数据要交给哪一个进程来处理
- IP地址+端口号能标识网络上唯一一台主机的一个进程
- 一个端口号只能被一个进程占用
- 一个进程可以绑定多个端口号
端口号的作用
操作系统会维护一张端口号和pid对应的hash表,通过端口号可以找到对应进程pid,然后获取进程结构体,其中就有文件fd。
将网络数据写入文件,进程就可以从文件中读取网络数据了,如此就将网络通信转化成文件读写
TCP与UDP
TCP和UDP都是传输层协议
TCP协议
- 有连接
- 可靠传输
- 面向字节流
UDP协议
- 无连接
- 不可靠传输
- 面向数据报
可靠与不可靠传输不是褒义和贬义的关系,可靠意味着需要有更多资源保证可靠,也有很多场景适合不可靠传输
网络字节序
内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大小端之分。网络数据流同样也有大小端之分
小端是将低位数据放到低地址,高位数据放到高地址,大端反之
那么如何定义网络数据流的地址呢?
- 发送方主机通常将发送缓冲区的数据按内存地址从低到高发出
- 接收方主机把从网络上接到的字节一次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存
- 因此,网络数据流的地址应这样规定:先发出的数据时低地址,后发出数据时高地址
- TCP/IP协议规定,
网络数据流应采用大端字节序,即低地址高字节
- 不管这台主机是大端机还是小端机,都会按照这个TCP/IP规定的网络字节序来发送/接收数据
- 如果当前发送方主机是小端,就需要先将数据转成大端,否则就忽略,直接发送即可
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用库函数做网络字节序
和主机字节序
的转换
- 记忆:h表示
host主机
,n表示network网络
,l表示32位长整数,s表示16位短整数 - 例如htonl表示32为长整数从主机字节序转为网络字节序
- 如果主机是小端字节序,这些函数将参数做响应的大小端转换后返回
- 如果主机是大端字节序,这些函数不做转换,直接返回
C语言有定义宏
表示该主机是大端还是小端,所以只需要判断一下宏即可知道本主机是大端还是小端
二. socket编程接口
上述说到,网络通信的本质是两台主机中的两个进程通信。
在Linux学习中,进程通信有两个标准——System V
和POSIX
历史
UNIX两大贡献者——贝尔实验室和BSD,在进程之间通信侧重不同,前者基于内核对进程之间的通信手段进行了改进,形成System V IPC
,而后者则是基于网络形成了套接字
POSIX是IEEE制定的标准,目的是为运行在不同操作系统上的软件提供统一的接口,实现者则是不同的操作系统内核开发人员。
如今POSIX已经支持同主机的进程通信和网络通信,POSIX将会是大势所趋
参考System V 与 POSIX
本系列讲解的都是POSIX标准的接口
socket()和sockaddr结构体
socket()
//创建socket 文件描述符
int socket(int domain,int type,int protocol);
上述说到,OS通过端口号找到对应pid,找到对应进程,就可以找这个进程所有的文件,将网络数据写入文件,就将网络通信转换为文件读写
socket()的作用就是创建一个网络文件,返回值int就是文件描述符
-
int domain:指定通信域
主要使用AF_UNIX
(本主机的进程通信)AF_INET
(网络通信),AF_INET6(IPv6的网络通信) -
int type:指定通信语义
常用的是SOCK_STREAM
(面向字节流——TCP),SOCK_DGRAM
(面向数据报——UDP) -
int protocol:
默认为0
,OS会判断是使用TCP还是UDP
这三个参数都将会标识该文件是网络文件
sockaddr结构体
OS使用sockaddr保存本主机信息。因为POSIX标准同时支持本主机进程通信和网络通信,所以用C语言模拟多态的形式实现着两种通信。
具体操作如下:
在socket常见API中
//绑定端口号
int bind(int socket,const struct sockaddr*address,socklen_t address_len);
//接收请求
int accept(int socket,struct sockaddr*address,socklen_t address_len);
//建立连接
int connect(int sockfd,const struct sockaddr*addr,socklen_t addrlen);
这三个接口的参数中,都有const struct sockaddr*
struct sockaddr是通用结构体,struct sockaddr_in是网络通信结构体,struct sockaddr_un是本主机进程通信结构体
只要在传参时强转成sockaddr即可
- IPv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址用
sockaddr_in
结构体表示,包括16位地址类型,16位端口号和32位IP地址 - IPv4,IPv6地址类型分别定义为常数AF_INET(PF_INET也可以)和AF_INET6。只要取得某种sockaddr结构体的首地址,不需要具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容
- socket API可以用struct sockaddr*类型表示,在使用的时候需要强转成sockaddr_in;这样的好处是程序的通用性,可以接收IPv4,IPv6,以及UNIX Domain Socket各种类型的sockaddr结构体指针作为参数
sockaddr_in定义如下:
sin_zero是填充字段
in_addr用来标识一个IPv4的IP地址,其实就是一个32位的整数
三. 简单echo服务
接下来,简单实现UDP网络echo服务器(接收并送回数据)和客户端
边写边讲解注意点
makefile
all:client server
client:udp_client.cc
g++ -o $@ $^ -std=c++11
server:udp_server.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f client server
先编写客户端
udp_server.hpp
#pragma once
#include<iostream>
#include<string>
#include<cerrno>
#include<cstring>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
namespace ns_server
{
class UdpServer
{
public:
UdpServer(){}
void InitServer(){}//初始化服务器
void Start(){}//启动服务器
~UdpServer(){}
private:
int _sock;//套接字
uint16_t _port;//端口号
std::string _ip;//IP地址
};
}
udp_server.cc
#include "udp_server.hpp"
#include<memory>
using namespace ns_server;
using namespace std;
int main()
{
unique_ptr<UdpServer> usvr(new UdpServer());
usvr->InitServer();//初始化
usvr->Start();//启动
return 0;
}
以上是基本框架
网络服务,服务器肯定需要端口号和IP地址,另外还需要保存套接字
- 创建套接字
void InitServer()
{
_sock=socket(AF_INET,SOCK_DGRAM,0);
if(_sock<0)
std::cerr<<"create sock error,"<<strerror(errno)<<",errno:"<<errno<<std::endl;
}
创建套接字失败会返回-1,并设置错误码
- 定义struct sockaddr_in结构体
其中需要提供端口号和IP地址,我们通过构造函数获取
//构造函数获取端口号和IP地址
UdpServer(uint16_t port,std::string ip):_port(port),_ip(ip)
{}
//初始化服务器
void InitServer()
{
_sock=socket(AF_INET,SOCK_DGRAM,0);
if(_sock<0)
std::cerr<<"create sock error,"<<strerror(errno)<<",errno:"<<errno<<std::endl;
struct sockaddr_in local;
bzero(&local,sizeof(local));//清空结构体
local.sin_family=AF_INET;//地址类型
local.sin_port=htons(_port);//端口号
local.sin_addr.s_addr=inet_addr(_ip.c_str());//IP地址
}
但此时该sockaddr_in结构体仅仅是定义在栈帧上,并没有写入内核,没有和网络文件绑定
所以需要使用bind()函数
- 绑定端口号
//绑定端口号
int bind(int socket,struct sockaddr*address,socklen_t address_len);
int sokcet
:要绑定的套接字(网络文件描述符)const struct sockaddr
:相关网络信息结构体socklen_t address_len
:结构体大小
绑定失败返回值-1,并设置错误码
//初始化服务器
void InitServer()
{
_sock=socket(AF_INET,SOCK_DGRAM,0);
if(_sock<0)
{
std::cerr<<"create sock error,"<<strerror(errno)<<std::endl;
return 1;
}
struct sockaddr_in local;
bzero(&local,sizeof(local));//清空结构体
local.sin_family=AF_INET;//地址类型
local.sin_port=htons(_port);//端口号
local.sin_addr.s_addr=inet_addr(_ip.c_str());//IP地址
//绑定结构体
if(bind(_sock,(struct sockaddr*)&local,sizeof(local))<0)
{
std::cerr<<"bind error,"<<strerror(errno)<<std::endl;
return 2;
}
}
注意,云服务器一般不允许绑定特定IP
另外,如果服务器有多个网卡,则不管哪个网卡/哪个IP地址接收到的数据,只要是该端口号的,都应该接收
所以服务器的IP一般如此设置
local.sin_addr.s_addr=INADDR_ANY;
socket INADDR_ANY就是指定地址为0.0.0.0
的这个地址,这个地址不是确定的地址,而是表示“所有地址”
,“任意地址”
所以只要是发送给指定端口号的数据,无论是发送给本机的哪个IP地址的,都一并接收
初始化服务器到此暂告一段落
接下来是启动服务器
服务器首先是需要一直运行的,即使在凌晨,我们一样可以玩游戏,看QQ
因为是echo服务器,所以需要接收客户端发送的消息,然后再发送回去
recvfrom()
int sockfd
:从哪个套接字读取数据void * buf
:存数据的缓冲区size_t len
:缓冲区大小int flags
:读取数据的方式(阻塞读或非阻塞读)struct sockaddr* src_addr
:输入输出型参数,获取客户端信息socklen_t * addrlen
:输入输出型参数,客户结构体大小。注意:输入src_addr的大小,返回发送方结构体大小
- 返回值:读取数据的个数。错误返回-1并设置错误码
sendto()
int sockfd
:往哪个套接字写数据const void * buf
:写的数据size_t len
:数据大小int flags
:写数据的方式(阻塞或非阻塞)struct sockaddr* dest_addr
:目的主机信息结构体socklen_t * addrlen
:结构体大小- 返回值:发送了多少数据。错误返回-1并设置错误码
Start()代码如下:
//启动服务器
void Start()
{
char buffer[1024];
while(true)
{
struct sockaddr_in client;
socklen_t len=sizeof(client);
//缓冲区需要预留\0的位置
int n=recvfrom(_sock,buffer,sizeof(buffer-1),0,(struct sockaddr*)&client,&len);
if(n>0) buffer[n]='\0';
else continue;
//提取客户端信息
std::string clientIp=inet_ntoa(client.sin_addr);
uint16_t clientPort=ntohs(client.sin_port);
std::cout<<"["<<clientIp<<" : "<<clientPort<<"]# "<<buffer<<std::endl;
//送回数据
//发送回去的数据不需要携带\0
sendto(_sock,buffer,strlen(buffer),0,(struct sockaddr*)&client,sizeof(client));
}
}
接下来是udp_server.cc
我们需要在启动服务器时指明端口号,类似 ./udp_server 8080
#include "udp_server.hpp"
#include"err.hpp"
#include<memory>
using namespace ns_server;
using namespace std;
//使用手册
// ./udp_server port
static void usage(string proc)
{
cout<<"Usage:\n\t"<<proc<<" port\n"<<std::endl;
}
int main(int argc,char*argv[])
{
if(argc!=2)
{
usage(argv[0]);
return -1;
}
//提取参数中的端口号
uint16_t port=atoi(argv[1]);
unique_ptr<UdpServer> usvr(new UdpServer(port));
usvr->InitServer();
usvr->Start();
return 0;
}
如此,最基本的echo服务器完成。
接下来是客户端的编写
客户端简单编写一些,就不封装成类了
客户端大致流程如下:
- 创建套接字
- 提取目标服务器信息
- 发送消息
UDP的客户端并不需要bind,因为客户端的端口号不能指定,应该由操作系统分配
。如果两个客户端自己绑定同一个端口号,那就不能同时运行了,所以为了避免这种情况,选择让操作系统分配闲置的端口号
而操作系统会在客户端首次发送数据
(sendto等)时,给客户端分配IP和端口号,然后bind套接字
目标服务器是由运行程序时指定:如 ./client 127.0.0.1 8888
代码如下:
#include<iostream>
#include<string>
#include<cstring>
#include<cerrno>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include"err.hpp"
using namespace std;
static void usage(string proc)
{
cout<<"Usage\n\t"<<proc<<" serverIp serverPort"<<endl;
}
int main(int argc,char*argv[])
{
if(argc!=3)
{
usage(argv[0]);
exit(USAGE_ERR);
}
//提取服务器信息
string serverIp=argv[1];
uint16_t serverPort=atoi(argv[2]);
//创建套接字
int sock=socket(AF_INET,SOCK_DGRAM,0);
if(sock<0)
{
cerr<<"create sock error,"<<strerror(errno)<<endl;
exit(SOCKET_ERR);
}
std::cout << "create socket success: " << sock << std::endl;
//客户端不需要自己bind
struct sockaddr_in server;
memset(&server,0,sizeof(server));
server.sin_family=AF_INET;
server.sin_addr.s_addr=inet_addr(serverIp.c_str());
server.sin_port=htons(serverPort);
while(true)
{
cout<<"please enter your message# ";
string message;
getline(cin,message);
sendto(sock,message.c_str(),message.size(),0,(struct sockaddr*)&server,sizeof(server));
char buffer[1024];//接收返回的数据
struct sockaddr_in tmp;//发送方
memset(&tmp,0,sizeof(tmp));
socklen_t len=sizeof(tmp);
int n=recvfrom(sock,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&tmp,&len);
if(n>0)
{
buffer[n]='\0';
cout<<"server echo# "<<buffer<<endl;
}
}
return 0;
}
结束语
UDP socket编程的内容到此就结束了,感谢看到此处。
欢迎大家纠错和补充
如果觉得本篇文章对你有所帮助的话,不妨点个赞支持一下博主,拜托啦,这对我真的很重要。