【Linux】UDP编写之~端口号&网络字节序&套接字种类&sockaddr通用地址结构&UDP聊天窗

端口号Port

  在计算机网络中,实现主机间的通信不仅仅是要定位到主机,还需要精确到主机上特定的应用程序或进程。这意味着,网络通信的核心实际上是进程间的通信。当数据到达目标主机时,系统需要知道将这些数据交给哪个进程处理,无论是QQ、微信还是其他应用程序。
  对此,我们引入了端口号的概念:端口号是一个16位的数字,能够标识主机上的一个特定进程。通过端口号,操作系统可以识别、并确定接收到的数据应该转发给哪个进程。因此,结合IP地址和端口号,我们就能精确地定位到互联网上某个特定主机上的特定进程。

Pid vs 端口号port
  我们知道PID可以标识一个进程的唯一性了,那为什么还要设置端口号呢?
  1、从纯技术角度上看,确实可以只使用Pid来实现进程间的网络通信,但这会导致系统资源管理和网络通信紧密耦合,难以维护和扩展。通过引入端口号Port,我们可以将系统和网络模块解耦,使它们独立运行,互不干扰。
  2、服务器进程通常固定使用一个端口号,如同紧急服务电话一样,这个端口号用于确保客户端能始终可靠地连接到服务器。而进程Pid 在每次启动时都可能不同,端口号则弥补这种变化,来确保网络服务的连续性和可访问性。
  3、并不是所有的进程都需要通信,但所有的进程都必须有自己的Pid


当进程绑定一个端口号之后,我们便称这个进程为网络服务进程。那它们之间是如何进行绑定的呢?
  它们是通过哈希hash来绑定的,端口号作为Key值,Pid作为Value值。一个端口号只能绑定一个进程来确保唯一性,而一个进程可以绑定多个端口号,即我们可以通过不同的端口号找到同一个进程

网络字节序及其API

socket名字由来:
  早期的电话系统使用实际的插座来连接电话线,这种插座有一个物理接口,可以插入一个插头(plug)以建立连接。网络通信中的套接字概念借用了这个思想,即在软件层面上“插入”一个连接,以实现两台主机之间的通信。在物理层面,套接字常常被比作一种“插座”,进程通过这个插座(socket)与网络连接,实现数据的发送和接收。

网络字节序
  网络中存在着各种不同架构的计算机,它们可能采用不同的大小端字节序。当一个小端存储的主机需要发送数据给一台大端存储的主机时,如果大端主机直接按照大端的方式接收数据,就会遇到因字节序不同而导致的数据错误问题。对此我们引出网络字节序,规定网络中的数据必须是大端的。如果当前发送主机是大端,直接发送即可,否则就需要先将数据转成大端再发送。

  大小端转化的接口,不用自己写。这种常用的接口,直接用库帮我们封装好的就行了。助记:h表示主机(host),n表示网络(network),l表示32(long)位长整数,s表示16(short)位短整数。IP是32位,端口号是16位,因此IP转网络字节序是使用带l的,端口号是使用带s的。

#include <arpa/inet.h>

主机转网络(32位):uint32_t htonl(uint32_t hostlong);
主机转网络(16位):uint16_t htons(uint16_t hostshort);
网络转主机(32位):uint32_t ntohl(uint32_t netlong);
网络转主机(16位):uint16_t ntohs(uint16_t netshort);

点分十进制IP和网络字节序转化,注意inet_ntoa这个函数返回了一个char*, 这个函数自己在静态存储区为我们申请了一块内存来保存IP的结果,第二次调用时的结果会覆盖掉上一次的结果,使用这个函数要额外注意
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
字符串转网络:int inet_aton(const char *strptr, struct in_addr *addrptr);
字符串转网络:int inet_pton(int family, const char *strptr, void *addrptr);
字符串转网络:in_addr_t inet_addr(const char *cp);
字符串转网络:in_addr_t inet_network(const char *cp);
网络转字符串:char *inet_ntoa (struct in_addr inaddr);

网络转字符串:const char* inet_ntop(int family,const void *addrptr,char *strptr,size_t len );

套接字种类

套接字编程中,常见的有网络套接字编程,原始套接字编程,unix域间套接字编程。
  原始套接字:提供了绕过传输层直接访问网络层的高级功能,通常用于制作抓包和网络监控工具,但它相对复杂。
  Unix域间套接字:主要用于同一主机上的进程间通信,不同于网络套接字,它不支持跨网络通信。
  网络套接字:允许程序跨越多个网络和主机进行通信。这里我们将专注于网络套接字编程,不涉及原始套接字和Unix域套接字。

sockaddr通用地址结构及socket常用API

  套接字的种类有多种,我们不必为每一种套接字都设计一个独立的接口,对此应用层的socket API为我们提供了一层抽象的网络编程接口。它适用于各种网络层协议,简化了网络编程,并且提高了代码的可移植性和可维护性。

  在使用任意一种套接字通信时,我们都需要将它强转为(sockaddr*)结构。这里我们看到这四个结构体首字段都是一个16为的地址类型,通过sockaddr->type的值我们便能知道要进行哪种套接字通信,如果 sockaddr->type==AF_INET 则进行网络IPv4通信,为AF_UNIX则进行域间套接字通信,为AF_INET6则进行网络IPv6通信,依次类推。这样我们就可以使用统一的接口来进行网络编程。
   使用统一接口处理不同类型的套接字通信类似于C++中的多态性,其中基类指针可以指向派生类对象。之前我们使用用户级线程库时,用的参数是void*,这里为什么没有设计成void*呢?主要是因为网络接口出来的时候,C语言的规范里面还没有void* ,为了保持向前兼容,即使有更好的方案,接口也不能被修改。

socket常用接口

#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了这个头文件,上面两个就可以省略

int socket(int domain, int type, int protocol);
	- 功能:创建一个套接字
	- 参数:
		- domain: 协议族
			AF_INET : ipv4
			AF_INET6 : ipv6
			AF_UNIX, AF_LOCAL : 本地套接字通信(进程间通信)
		- type: 通信过程中使用的协议类型
			SOCK_STREAM : 流式协议
			SOCK_DGRAM : 报式协议
		- protocol : 具体的一个协议。一般写0(默认)
			- SOCK_STREAM : 流式协议默认使用 TCP
			- SOCK_DGRAM : 报式协议默认使用 UDP
		- 返回值:
			- 成功:返回文件描述符,操作的就是内核缓冲区。
			- 失败:-1

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); // socket命名
	- 功能:绑定,将fd 和本地的IP + 端口进行绑定
	- 参数:
		- sockfd : 通过socket函数得到的文件描述符
		- addr : 需要绑定的socket地址,这个地址封装了ip和端口号的信息
		- addrlen : 第二个参数结构体占的内存大小

int listen(int sockfd, int backlog);  /proc/sys/net/core/somaxconn
	- 功能:监听这个socket上的连接
	- 参数:
		- sockfd : 通过socket()函数得到的文件描述符
		- backlog : 未连接的队列 和 已经连接的队列 和的最大值
		- (以使用 cat /proc/sys/net/core/somaxconn 查看:4096),一般不用设置那么大,如:8/128

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
	- 功能:接收客户端连接,默认是一个阻塞的函数,阻塞等待客户端连接
	- 参数:
		- sockfd : 用于监听的文件描述符
		- addr : 传出参数,记录了连接成功后客户端的地址信息(ip,port)
		- addrlen : 指定第二个参数的对应的内存大小
	- 返回值:
		- 成功 :用于通信的文件描述符
		- -1 : 失败

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
	- 功能: 客户端连接服务器
	- 参数:
		- sockfd : 用于通信的文件描述符
		- addr : 客户端要连接的服务器的地址信息
		- addrlen : 第二个参数的内存大小
	- 返回值:成功 0, 失败 -1
	
ssize_t write(int fd, const void *buf, size_t count); // 写数据
ssize_t read(int fd, void *buf, size_t count); // 读数据

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, 
                 struct sockaddr *src_addr, socklen_t *addrlen); 
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);
参数:
   buf      --  输入型参数,即要存发送的数据
   len      --  收到/发送数据的长度
   flag     --  默认为0
   src_addr --  输出型参数,存发送数据的对象
   addrlen  --  输入输出型参数,存src_addr的大小

UDP 聊天窗和Windows客户端

  UDP通信是面向数据报了,它不保证可靠性。这是特性,不是缺点。要保证可靠性我们可以使用TCP通信,因此我们再接下来的UDP编程中,服务器只需要创建和绑定套接字,不需要监听和接收。 在UDP服务器编程中,服务器通常需要绑定一个IP地址和端口号,而端口号必须是固定的、众所周知的,以便客户端可以轻松地找到并与之通信。常见的有HTTPS:443;SSH:22;MySQL:3306等等。
  服务器监听的IP地址可以是具体的一个地址,也可以是 通配符地址IPv4(0.0.0.0),IPv6( [::] 或 0::0::0:0:0 )。服务器如果有多个IP地址,绑定通配符地址可以允许服务器接受发往任何IP的连接,绑定通配符地址对于有多网卡或多IP的服务器特别有用。而绑定特定IP的话,只能接收一个IP发来的连接。同时,服务器绑定特定IP在虚拟机上是可以运行的,但云服务器禁止绑定公网IP,因为云服务器的公网IP和你的服务器网卡,其实是通过net方式去桥接的,所以公网IP并不是你的网卡。
  在UDP客户端编程中,客户端并不需要显示绑定IP和端口号,客户端的IP地址在发送数据报给服务器时,作为数据报的一部分被自动包含在内。因此,客户端不需要显式指定自己的IP地址,服务器也能知道客户端的IP。一台主机下,如果允许进程自己绑定端口号,那么当多个进程都想绑定同一个端口号的情况下,一个进程启动之后,后面的进程就都启动不来了。因此客户端的端口由操作系统随机分配,端口号是多少并不重要,能标识唯一性就行,它不像服务器的端口号一样需要被所有的客户端知晓。
  在设计聊天窗口的时候,服务器需要对用户发来的消息进行广播,对此我们用一种hash表来保证用户信息就行。而聊天的时候,有单独的输入框和接收框,这里我们没有图形化界面。我们可以在客户端使用两个线程,一个负责发送消息给服务器,另外一个负责接收服务器广播的消息,这样客户端收发消息就可以并发访问了。现在我们还需要一个输入框和接收框,对此我们可以建立两个终端,本终端负责发送,然后把收到的消息,文件重定向到另外一个终端,这样输入和接收就分开了。这里我们把标准错误cerr重定向到另外的那个终端 ./client IP Port 2>dev/pts/终端号

核心实现:
UDPServer.hpp

#pragma once
#include <iostream>
#include <sys/types.h>         
#include <sys/socket.h>
#include <netinet/in.h>
#include <cstring>
#include <arpa/inet.h>
#include <functional>
#include <unordered_map>
#include "Log.hpp"
using namespace::std;

enum
{
    SOCK_ERROR=1,
    BIND_ERROR
};

class UDPServer
{
public:
    UDPServer(uint16_t port=8888,string ip="0.0.0.0")
    :_port(port),_ip(ip)
    {}

    void init()
    {
        _sockfd=socket(AF_INET,SOCK_DGRAM,0);//  面向数据报
        Log lg;
        if(_sockfd<0)
        {
            lg(Fatal,"socket crate error %d",_sockfd);
            exit(SOCK_ERROR);
        }
        lg(Info,"sockfd crate success %d",_sockfd);
        sockaddr_in addr_in;
        memset(&addr_in, 0, sizeof(addr_in));
        // 主机转网络
        addr_in.sin_port=htons(_port);
        addr_in.sin_addr.s_addr=inet_addr(_ip.c_str()); // ip地址
        addr_in.sin_family=AF_INET;  // 协议家族
        int n=bind(_sockfd,(sockaddr*)&addr_in,sizeof(addr_in));
        if(n<0)
        {
            lg(Fatal,"Bind error %d: %s",errno,strerror(errno));
            exit(SOCK_ERROR);
        }
        lg(Info,"Bind success");
    }

    void checkuser(sockaddr_in& client) // 查看用户是否存在
    {
        string client_ip=inet_ntoa(client.sin_addr);
        auto it=_clients.find(client_ip);
        if(it == _clients.end()) // 添加到用户列表
        {
            cout << "[" << client_ip << ":" << ntohs(client.sin_port)<< "] add to online users" << endl;
            _clients[client_ip]=client;
        }
        else _clients[client_ip]=client; // 让重连客户更新
    }
    void broadcast(sockaddr_in client,char* info)  // 广播给所有用户
    {
        string client_ip=inet_ntoa(client.sin_addr);
        uint16_t client_port=ntohs(client.sin_port);
        string message=string("[")+client_ip+": "+to_string(client_port)+"]# "+info;
        cout<<message<<endl;
        socklen_t len=sizeof(client); // 这里一定要初始化,否则会出现未定义行为
        for(auto& user:_clients)
            sendto(_sockfd,message.c_str(),message.size(),0,(sockaddr*)&(user.second),len);
    }

    void run()
    {
        sockaddr_in client; // 要记录客户端的信息
        socklen_t len=sizeof(client); // 这里一定要初始化,否则会出现未定义行为
        char buff[1024];
        // 这里可以对方法分层
        while(true)
        {
            ssize_t n=recvfrom(_sockfd,buff,1023,0,(sockaddr*)&client,&len);
            if (n < 0) {
                perror("recvfrom failed");
                continue; // 继续等待新的数据
            }
            buff[n]=0;
            checkuser(client);
            broadcast(client,buff);
        }
    }
    
private:
    int _sockfd; // 网卡文件描述符
    uint16_t _port;
    string _ip;
    unordered_map<string,sockaddr_in> _clients; // 记录用户列表
};

Windows客户端:
client.cpp

#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <WinSock2.h>
#include <string>
#include <cstring>
#pragma comment(lib,"ws2_32.lib")
using namespace::std;
#pragma warning(disable:4996)

int main()
{
	WSAData wsd;
	//启动Winsock
	//进行Winsocket的初始化,windows初始化socket网络库,申请2.2的版本
	if (WSAStartup(MAKEWORD(2, 2), &wsd) != 0)
	{
		cout << "WSAStartup Error =" << WSAGetLastError() << endl;
		return 0;
	}
	cout << "WSAStartup Success" << endl;

	SOCKET sock = socket(AF_INET, SOCK_DGRAM, 0);
	if (sock == SOCKET_ERROR)
	{
		cout << "socket ERROR = " << WSAGetLastError() << endl;
		return 1;
	}
	cout << "socket success" << endl;

	struct sockaddr_in server;
	uint16_t serverport = 8888;
	string serverip = "47.120.6.18";
	memset(&server, 0, sizeof(server));
	server.sin_family = AF_INET;
	server.sin_port = htons(serverport);
	server.sin_addr.s_addr = inet_addr(serverip.c_str());

	string input("comming");
	int n = sendto(sock, input.c_str(), input.size(), 0, (sockaddr*)&server, sizeof(server));
	char buffer[1024];
	while (true)
	{
		cout << "Please Enter# ";
		getline(cin, input);
		int n = sendto(sock, input.c_str(), input.size(), 0, (sockaddr*)&server, sizeof(server));
		if (n < 0)
		{
			cerr << "sendto error" << endl;
			break;
		}
		//接收服务器的数据
		int len = sizeof(server);
		n = recvfrom(sock, buffer, sizeof(buffer) - 1, 0, (sockaddr*)&server, &len);
		if (n >= 0) buffer[n] = 0;
		cout << buffer << endl;
	}

	//最后将使用库的相关资源全部释放掉 关闭套接字的文件描述符
	closesocket(sock);
	WSACleanup();
	return 0;
}

自己遇到的坑

  看下面这种未定义的行为,正当我打算对run方法分层的时候,发现服务器突然连接不上客户端了。不断调整,最后我都没有让第二个代码执行hander方法,只是传参,Print方法并没有执行。两份同样的代码,图二,服务器收到的IP还会自己变,但是客户端始终没有收到服务器的回应。代码明明一样,找了好久发现是socklen_t变量没有初始话而产生的未定义行为。在recvfrom传参的时候,要么初始化len,要么传入nullptr,否则可能产生未定义的行为。


  LInux中写的代码在VS中,并不能直接使用,即使是同一个函数。如下面这个现象,很多函数参数返回值设计Windows都有所改变,总之在Linux中跑的代码,直接复制到Windows中,尽量检查一下函数的参数和返回值类型问题。避免不必要Bug寻找,而且这种Bug还很难找

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

杰瑞的猫^_^

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值