Linux | UDP Socket 编程(C++ 基础demo)
文章目录
- UDP Socket 编程(C++ 基础demo)
- 一、引言
- 二、UDP Socket 编程的基本概念
- 2.1 套接字(Socket)
- 2.2 地址结构
- 2.3 大端 & 小端字节序
- 2.4 字节序转换
- 2.5 大致流程
- 三、UDP 服务端编程步骤
- 3.1 创建套接字
- 3.2 绑定地址
- 3.3 接收数据
- 3.4 发送数据
- 3.5 关闭套接字
- 四、UDP 客户端编程步骤
- 4.1 创建套接字
- 4.2 发送数据
- 4.3 接收数据
- 4.4 关闭套接字
- 五、基础UDP Socket示例
- 5.1 服务端代码
- 5.2 客户端代码
- 5.4 Log.hpp
- 5.5 Makefile
UDP Socket 编程(C++ 基础demo)
一、引言
在网络编程中,UDP(User Datagram Protocol,用户数据报协议)是一种简单的传输层协议,它提供了无连接、不可靠的数据传输服务。与 TCP 相比,UDP 不保证数据的可靠传输,也不保证数据包的顺序,但它的开销小,传输速度快,适用于一些对实时性要求较高、对数据准确性要求相对较低的场景,如视频流、音频流、实时游戏等。
本文将详细介绍 UDP Socket 编程的基本概念和步骤,并结合你提供的 UDP 客户端和服务端的示例代码进行讲解。
代码仓库指路👈
二、UDP Socket 编程的基本概念
2.1 套接字(Socket)
套接字是网络编程中的一个抽象概念,它提供了应用程序与网络之间的通信接口。在 UDP 编程中,我们使用的是 UDP 套接字(SOCK_DGRAM)。
2.2 地址结构
在 UDP 编程中,需要使用地址结构来表示网络地址和端口号。常用的地址结构是 sockaddr_in
,它在 <netinet/in.h>
头文件中定义,其结构如下:
struct sockaddr_in {
sa_family_t sin_family; /* 地址族,通常为 AF_INET */
in_port_t sin_port; /* 端口号,需要使用网络字节序 */
struct in_addr sin_addr; /* IP 地址 */
};
struct in_addr {
in_addr_t s_addr; /* 32 位的 IP 地址,需要使用网络字节序 */
};
2.3 大端 & 小端字节序
在网络编程中,大端和小端是两种不同的字节存储顺序
- 大端字节序
大端字节序也称为网络字节序,在这种存储方式下,数据的高位字节存放在内存的低地址处,低位字节存放在内存的高地址处。可以将其类比为我们书写数字时从高位到低位的顺序,高位数字总是先出现。例如,对于十六进制数
0x12345678
,在大端字节序的内存中存储顺序为:
内存地址 | 存储内容 |
---|---|
低地址 | 0x12 |
…… | 0x34 |
…… | 0x56 |
高地址 | 0x78 |
- 小端字节序
小端字节序中,数据的低位字节存放在内存的低地址处,高位字节存放在内存的高地址处。这与大端字节序正好相反。同样以十六进制数
0x12345678
为例,在小端字节序的内存中存储顺序为:
内存地址 | 存储内容 |
---|---|
低地址 | 0x78 |
…… | 0x56 |
…… | 0x34 |
高地址 | 0x12 |
2.4 字节序转换
字节序是指多字节数据在计算机内存中存储的顺序,分为大端字节序(Big Endian)和小端字节序(Little Endian)。在网络编程中,需要使用网络字节序(大端字节序)来表示端口号和 IP 地址。常用的字节序转换函数有:
htons()
:将 16 位的主机字节序转换为网络字节序(用于端口号)。htonl()
:将 32 位的主机字节序转换为网络字节序(用于 IP 地址)。ntohs()
:将 16 位的网络字节序转换为主机字节序(用于端口号)。ntohl()
:将 32 位的网络字节序转换为主机字节序(用于 IP 地址)。
2.5 大致流程
在 UDP socket 通信中,客户端首先创建一个 UDP 套接字,接着依据服务端的 IP 地址和端口号,使用
sendto
函数将数据直接发送出去,无需与服务端建立连接。服务端同样先创建 UDP 套接字,然后把该套接字绑定到指定的 IP 地址和端口上,通过recvfrom
函数等待接收客户端的数据,此函数还能获取客户端的地址信息。服务端接收到数据后,可对数据进行相应处理,再使用sendto
函数依据获取到的客户端地址信息将处理后的响应数据发送回客户端。
三、UDP 服务端编程步骤
3.1 创建套接字
使用 socket()
函数创建一个 UDP 套接字,其原型如下:
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
domain
:地址族,通常为AF_INET
(表示 IPv4)。type
:套接字类型,对于 UDP 套接字,使用SOCK_DGRAM
。protocol
:协议类型,通常为0
,表示使用默认协议。
示例代码中创建套接字的部分:
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd < 0){
mylog(Fatal, "创建套接字失败,sockfd: %d",SOCKET_ERO);
exit(SOCKET_ERO); // 套接字创建失败,退出程序
}
mylog(Info, "创建套接字成功,sockfd: %d",_sockfd);
//mylog是自己实现的简单的输出日志,这里是输出信息,用别的也行
3.2 绑定地址
使用 bind()
函数将套接字绑定到本地地址和端口号,其原型如下:
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd
:套接字描述符。addr
:指向地址结构的指针,通常为sockaddr_in
类型,需要强制转换为struct sockaddr *
类型。addrlen
:地址结构的长度。
示例代码中绑定地址的部分:
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());
int n = bind(_sockfd,(struct sockaddr*)&local,sizeof(local));
if(n < 0){
mylog(Fatal, "绑定套接字失败,sockfd: %d, error string: %s",BIND_ERO,strerror(errno));
exit(BIND_ERO); // 绑定失败,退出程序,BIND_ERO值自定义为1
}
mylog(Info, "绑定套接字成功,sockfd: %d, port: %d, ip: %s",_sockfd,_port,_ip.c_str());
3.3 接收数据
使用 recvfrom()
函数从套接字接收数据,其原型如下:
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
sockfd
:套接字描述符。buf
:接收数据的缓冲区。len
:缓冲区的长度。flags
:接收标志,通常为0
。src_addr
:指向发送方地址结构的指针,用于获取发送方的地址信息。addrlen
:指向发送方地址结构长度的指针,输入输出参数。
示例代码中接收数据的部分:
struct sockaddr_in client;
socklen_t len = sizeof(client);
ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&client, &len);
if(n < 0){
mylog(Warning, "接收数据失败,sockfd: %d, error string: %s",_sockfd,strerror(errno));
continue;
}
inbuffer[n] = 0;
3.4 发送数据
使用 sendto()
函数向指定地址发送数据,其原型如下:
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
sockfd
:套接字描述符。buf
:发送数据的缓冲区。len
:发送数据的长度。flags
:发送标志,通常为0
。dest_addr
:指向目标地址结构的指针,用于指定接收方的地址信息。addrlen
:目标地址结构的长度。
示例代码中发送数据的部分:
std::string info = inbuffer;
std::string echo_string = func(info);
sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr*)&client, len);
3.5 关闭套接字
使用 close()
函数关闭套接字,释放资源。
~UdpServer(){
if(_sockfd > 0){
close(_sockfd);
}
}
四、UDP 客户端编程步骤
4.1 创建套接字
同样使用 socket()
函数创建一个 UDP 套接字。
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
std::cout << "create socket failed" << std::endl;
return 1;
}
4.2 发送数据
使用 sendto()
函数向服务器发送数据。
struct sockaddr_in server;
bzero(&server, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
server.sin_addr.s_addr = inet_addr(serverip.c_str());
std::cout << "send message \" " << message << "\" to " << serverip << ":" << serverport << std::endl;
sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&server, (socklen_t)sizeof(server));
4.3 接收数据
使用 recvfrom()
函数从服务器接收数据。
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
ssize_t s = recvfrom(sockfd, buffer, 1023, 0, (struct sockaddr*)&temp, &len);
if(s > 0){
buffer[s] = 0;
std::cout << buffer << std::endl;
}
4.4 关闭套接字
使用 close()
函数关闭套接字。
close(sockfd);
五、基础UDP Socket示例
5.1 服务端代码
- UdpServer.hpp
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include "Log.hpp"
#include <cstring>
#include <strings.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <functional>
using func_t = std::function<std::string(const std::string&)>;
// func_t 是一个函数类型,参数为 const std::string&,返回值为 std::string。
extern Log mylog;
enum{
SOCKET_ERO = 1,
BIND_ERO = 2
};
const int size = 1024;
// uint16_t default_port = 8080;
std::string default_ip = "0.0.0.0";
class UdpServer
{
public:
UdpServer(const uint16_t &port , const std::string &ip = default_ip)
:_sockfd(0),_port(port),_ip(ip),_is_running(false)
{}
void Init(){
// 1. 创建 udp 套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(_sockfd < 0){
mylog(Fatal, "创建套接字失败,sockfd: %d",SOCKET_ERO);
exit(SOCKET_ERO); // 套接字创建失败,退出程序
}
mylog(Info, "创建套接字成功,sockfd: %d",_sockfd);
// 2. 设置套接字地址结构,绑定本地地址 bind
struct sockaddr_in local;
bzero(&local,sizeof(local));
local.sin_family = AF_INET;
// sin_family 是 struct sockaddr 的成员变量,用来指定协议族,这里是 IPv4。
// AF_INET 是 IPv4 的地址族,INADDR_ANY 是特殊的 IP 地址,表示接收所有 IP 地址。
local.sin_port = htons(_port);
// sin_port 是 struct sockaddr_in 的成员变量,用来指定端口号。
// htons 函数将主机字节序的端口号转换为网络字节序。
// 必须保证端口号是网络字节序列,因为端口号是要在网络上传输,发送给对方的。
// 如果机器本来就是大端字节序,则不需要转换,如果是小端字节序,则需要转换。
// local.sin_addr.s_addr = _ip;
// 但是 _ip 是字符串类型,需要转换为网络字节序的 IP 地址,string->uint32_t,uint32_t 要是网络序列的
local.sin_addr.s_addr = inet_addr(_ip.c_str());
// local.sin_addr.s_addr = INADDR_ANY;
// 这里可以指定 IP 地址,如果不指定,则绑定任意地址,云服务器一般绑定任意地址。
// inet_addr 函数将字符串形式的 IP 地址转换为网络字节序的 IP 地址。
// 要转换成 c_str() 形式的字符串,因为 inet_addr 函数只接受 const char* 类型。
// 目前做的工作是将一些数据设置到套接字地址结构中,但是还没有绑定到本地地址,现在本质知识定义了一个参数并给了一些数据
// 接下来要调用 bind 函数将套接字绑定到本地地址,这样才能接收到数据。
int n = bind(_sockfd,(struct sockaddr*)&local,sizeof(local));
// 因为 local 类型是 struct sockaddr_in,而 bind 函数的传参是 struct sockaddr*,所以需要强制类型转换。
// sizeof(local) 是地址结构的大小,这里是 sizeof(struct sockaddr_in)。
// bind 函数返回 0 表示绑定成功,返回 -1 表示绑定失败。
if(n < 0){
mylog(Fatal, "绑定套接字失败,sockfd: %d, error string: %s",BIND_ERO,strerror(errno));
exit(BIND_ERO); // 绑定失败,退出程序
}
mylog(Info, "绑定套接字成功,sockfd: %d, port: %d, ip: %s",_sockfd,_port,_ip.c_str());
}
void Run(func_t func){
char inbuffer[size];
_is_running = true;
while(_is_running){
struct sockaddr_in client;
socklen_t len = sizeof(client);
ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&client, &len);
if(n < 0){
mylog(Warning, "接收数据失败,sockfd: %d, error string: %s",_sockfd,strerror(errno));
continue;
}
inbuffer[n] = 0;
// inbuffer[n] = '\0'; // 字符串结尾加上 '\0',以便输出时不用再加上 endl
std::string info = inbuffer;
std::string echo_string = func(info);
// // 简单充当一次处理,也可以另外写一个函数用于处理,这里只做简单打印
// std::string info = inbuffer;
// std::string echo_string = "server echo: " + info + "\n";
mylog(Info, "接收到数据,sockfd: %d, data: %s",_sockfd,info.c_str());
sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr*)&client, len);
}
}
~UdpServer(){
if(_sockfd > 0){
close(_sockfd);
}
}
private:
int _sockfd; // 网络文件描述符
uint16_t _port; // 端口号
std::string _ip; // ip 地址,任意地址绑定
bool _is_running; // 服务器运行状态
};
- Main.cc
#include "UdpServer.hpp"
#include <memory>
// ./udpserver port
void Usage(std::string proc){
std::cout <<"\n\rUsage: "<<proc<<" port[1024+]\n\r"<<std::endl;
}
//一个端口只能绑定一个进程,一个进程可以绑定多个端口。
std::string Handle(const std::string& str){
std::string reserve = "Server get a message: ";
reserve += str;
return reserve;
}
int main(int argc, char* argv[])
//使用这种方式可以动态调整端口
{
if(argc != 2)
//因为可执行程序名和 port 都在 argv[0] 和 argv[1] 中,所以 argc 必须为 2
{
Usage(argv[0]);
exit(0);
}
uint16_t port = std::stoi(argv[1]);
//argv[1] 就是用户输入的端口号,需要转换为数字类型
std::unique_ptr<UdpServer> server (new UdpServer(port));
//云服务器禁止直接绑定公网IP,如果是虚拟机,上面的代码就能跑起来
//如果一个机器绑定了多个ip,例如有.164 和 .165,如果这里只绑定了.164的,那么发往.165的请求就不会被监听到
//所以,一般再云服务器上,一般绑定的ip地址是0,那么发给这台主机的数据,不管是.164还是.165,都能收到
//ip绑定为0,就是任意地址绑定
//对于绑定的端口,系统内定的端口号,一般都要有固定的应用层协议使用,一般不建议使用
//建议一般绑定1024以上的端口,因为0-1023端口是系统保留端口,一般应用层协议都不会使用这些端口
//如果要使用这些端口,需要root权限,或者使用sudo命令
server->Init();
server->Run(Handle);
return 0;
}
5.2 客户端代码
- UdpCLient.cc
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <string>
#include <strings.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <cstring>
#include <cstdlib>
void Usage(std::string proc){
std::cout <<"\n\rUsage: "<<proc<<" port[1024+]\n\r"<<std::endl;
}
// ./udpclient serverip serverport
int main(int argc, char* argv[]) {
if(argc != 3)
{
Usage(argv[0]);
exit(0);
}
std::string serverip = argv[1];
uint16_t serverport = atoi(argv[2]);
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
std::cout << "create socket failed" << std::endl;
return 1;
}
// 客户端也要绑定 ip,只不过不需要用户显式的绑定,一般由操作系统自动分配
// 因为客户端端口号有限制,假如固定绑定端口,可能会出现多个进程都要绑定同一个端口的情况,导致无法通信
// 并且 client 的 port 是多少并不重要,只要能保证主机上的唯一性即可
char buffer[1024];
std::string message;
while(true){
std::cout << "input message: ";
getline(std::cin,message);
struct sockaddr_in server;
bzero(&server, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
server.sin_addr.s_addr = inet_addr(serverip.c_str());
std::cout << "send message \" " << message << "\" to " << serverip << ":" << serverport << std::endl;
sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&server, (socklen_t)sizeof(server));
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
ssize_t s = recvfrom(sockfd, buffer, 1023, 0, (struct sockaddr*)&temp, &len);
if(s > 0){
buffer[s] = 0;
std::cout << buffer << std::endl;
}
}
close(sockfd);
return 0;
}
5.4 Log.hpp
- 使用到的自己写的日志小模块,也可以在udp的客户端服务端中直接使用打印逻辑代替其中的mylog
#pragma once
#include <iostream>
#include <time.h>
#include <stdarg.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#define SIZE 1024
#define Info 0
#define Debug 1
#define Warning 2
#define Error 3
#define Fatal 4
#define Screen 1
#define Onefile 2
#define Classfile 3
#define LogFile "log.txt"
class Log
{
public:
Log()
{
printMethod = Screen;
path = "./log/";
}
void Enable(int method)
{
printMethod = method;
}
std::string levelToString(int level)
{
switch (level)
{
case Info:
return "Info";
case Debug:
return "Debug";
case Warning:
return "Warning";
case Error:
return "Error";
case Fatal:
return "Fatal";
default:
return "None";
}
}
void printLog(int level, const std::string &logtxt)
{
switch (printMethod)
{
case Screen:
std::cout << logtxt << std::endl;
break;
case Onefile:
printOneFile(LogFile, logtxt);
break;
case Classfile:
printClassFile(level, logtxt);
break;
default:
break;
}
}
void printOneFile(const std::string &logname, const std::string &logtxt)
{
std::string _logname = path + logname;
int fd = open(_logname.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0666); // "log.txt"
if (fd < 0)
return;
write(fd, logtxt.c_str(), logtxt.size());
close(fd);
}
void printClassFile(int level, const std::string &logtxt)
{
std::string filename = LogFile;
filename += ".";
filename += levelToString(level); // "log.txt.Debug/Warning/Fatal"
printOneFile(filename, logtxt);
}
~Log()
{
}
void operator()(int level, const char *format, ...)
{
time_t t = time(nullptr);
struct tm *ctime = localtime(&t);
char leftbuffer[SIZE];
snprintf(leftbuffer, sizeof(leftbuffer), "[%s][%d-%d-%d %d:%d:%d]", levelToString(level).c_str(),
ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday,
ctime->tm_hour, ctime->tm_min, ctime->tm_sec);
va_list s;
va_start(s, format);
char rightbuffer[SIZE];
vsnprintf(rightbuffer, sizeof(rightbuffer), format, s);
va_end(s);
// 格式:默认部分+自定义部分
char logtxt[SIZE * 2];
snprintf(logtxt, sizeof(logtxt), "%s %s", leftbuffer, rightbuffer);
// printf("%s", logtxt); // 暂时打印
printLog(level, logtxt);
}
private:
int printMethod;
std::string path;
};
Log mylog; // 确保全局对象名称与声明一致(结尾不要加分号)
5.5 Makefile
.PHONY: all
all: udpserver udpclient
udpserver:Main.cc
g++ -o $@ $^ -std=c++11
udpclient:UdpClient.cc
g++ -o $@ $^ -std=c++11
.PHONY: clean
clean:
rm -f udpserver udpclient
vsnprintf(rightbuffer, sizeof(rightbuffer), format, s);
va_end(s);
// 格式:默认部分+自定义部分
char logtxt[SIZE * 2];
snprintf(logtxt, sizeof(logtxt), "%s %s", leftbuffer, rightbuffer);
// printf("%s", logtxt); // 暂时打印
printLog(level, logtxt);
}
private:
int printMethod;
std::string path;
};
Log mylog; // 确保全局对象名称与声明一致(结尾不要加分号)
### 5.5 Makefile
```makefile
.PHONY: all
all: udpserver udpclient
udpserver:Main.cc
g++ -o $@ $^ -std=c++11
udpclient:UdpClient.cc
g++ -o $@ $^ -std=c++11
.PHONY: clean
clean:
rm -f udpserver udpclient