文章目录
- 前言
- 一、预备知识
- 理解源IP地址和目的IP地址
- 认识端口号
- 认识UDP协议和TCP协议
- 了解网络字节序
- 二、socket 套接字
- socket 常见API
- sockaddr 和 sockaddr_in
- 三、UDP Socket编程
- 封装UdpSocket
- 实现UDP通用服务器
- 实现英译汉服务器
- 实现UDP通用客户端
- 实现英译汉客户端
- 四、地址转换函数
- 字符串转in_addr的函数
- in_addr转字符串的函数
前言
本编文章是博主学习了网络套接字编程后对相关知识的总结,阅读本文可对网络编程有基本的了解。文章内容包括认识IP地址和端口号的作用、了解网络字节序等网络编程中的基本概念、掌握Socket API的基本用法,从而能够实现简单的UDP客户端/服务器和TCP客户端/服务器,并且理解TCP服务器建立连接、发送数据、断开连接的基本流程。
一、预备知识
理解源IP地址和目的IP地址
IP地址被用来给Internet上的电脑一个编号。大家日常见到的情况是每台联网的PC上都需要有IP地址,才能正常通信。如果一台电脑上的数据要传输给另一台电脑,那么该电脑对应的IP地址就是源IP地址,而对于的目的电脑的IP地址就是目的IP地址。如果一台电脑与目的电脑进行通信,就必须知道目的IP地址,才能将信息发送给正确的对象。
认识端口号
知道了目的IP并不能建立双方之间的通信,两台主机通信的目的不是为了将数据发送给对方,而是为了访问目的主机的某一个访问。而目的主机上通常不仅仅只有一个服务,因此就引入了端口号进行区分。
所谓的端口,就好像是门牌号一样,客户端可以通过IP地址找到对应的服务器端,但是服务器端是有很多端口的,每个应用程序对应一个端口号,通过类似门牌号的端口号,客户端才能真正的访问到该服务器。为了对端口进行区分,将每个端口进行了编号,这就是端口号。
端口号的作用
端口号的主要作用是表示一台计算机中的特定进程所提供的服务。网络中的计算机是通过IP地址来代表其身份的,它只能表示某台特定的计算机,但是一台计算机上可以同时提供很多个服务,如数据库服务、FTP服务、Web服务等,我们就通过端口号来区别相同计算机所提供的这些不同的服务,如常见的端口号21表示的是FTP服务,端口号23表示的是Telnet服务端口号25指的是SMTP服务等。端口号一般习惯为4位整数,在同一台计算机上端口号不能重复,否则,就会产生端口号冲突这样的例外。
端口号的使用规则
TCP与UDP段结构中端口地址都是16比特,可以有在 0 ~ 65535 范围内的端口号。对于这65536个端口号有以下的使用规定:
- 端口号小于256的定义为常用端口,服务器一般都是通过常用端口号来识别的。任何TCP/IP实现所提供的服务都用1 ~ 1023之间的端口号,是由ICANN来管理的;端口号从1024—49151是被注册的端口,也成为“用户端口”,被IANA指定为特殊服务使用;
- 客户端只需保证该端口号在本机上是唯一的就可以了。客户端端口号因存在时间很短暂又称临时端口号;
- 大多数TCP/IP实现给临时端口号分配1024—5000之间的端口号。大于5000的端口号是为其他服务器预留的。
认识UDP协议和TCP协议
在TCP/IP网络体系结构中,TCP(传输控制协议,Transport Control Protocol)、UDP(用户数据报协议,User Data Protocol)是传输层最重要的两种协议,为上层用户提供级别的通信可靠性。
传输控制协议(TCP)
TCP(传输控制协议)定义了两台计算机之间进行可靠的传输而交换的数据和确认信息的格式,以及计算机为了确保数据的正确到达而采取的措施。协议规定了TCP软件怎样识别给定计算机上的多个目的进程如何对分组重复这类差错进行恢复。协议还规定了两台计算机如何初始化一个TCP数据流传输以及如何结束这一传输。TCP最大的特点就是提供的是面向连接、可靠的字节流服务。
用户数据报协议(UDP):
UDP(用户数据报协议)是一个简单的面向数据报的传输层协议。不提供可靠性,也不提供报文到达确认、排序以及流量控制等功能。它只是把应用程序传给IP层的数据报发送出去,但是并不能保证它们能到达目的地。因此报文可能会丢失、重复以及乱序等。但由于UDP在传输数据报前不用在客户和服务器之间建立一个连接,且没有超时重发等机制,故而传输速度很快。UDP最大的特点就是提供的是非面向连接的、不可靠的数据流传输。
了解网络字节序
通过以前对C语言的学习,我们知道了内存中的多字节数据相对于内存地址有大端和小端之分(大端和小端),磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分。当然,网络数据流中也同样有大小端之分。
以下是对网络数据流的地址定义
- 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
- 接收主机从网络中接收到的数据依次保存到缓冲区中,也同样按照内存地址从低到高的顺序保存;
- 由此可以保证网络数据流的地址都是:先发出的数据在低地址处,后发出的数据在高地址处;
- TCP/IP协议规定,网络数据流采用大端字节序,即低地址高字节;
- 不管当前主机是大端机还是小端机,都会按照TCP/IP协议所规定的网络字节序进行接收就发送数据;
- 如果当前发送主机是小端,就需要先将数据转成大端;否则就忽略,直接发送即可。
数据采用大小端写入内存的区别:
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转
换。
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
- 这些函数名很好记,h表示host;n表示network;l表示32位长整数;s表示16位短整数。
- 例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
- 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;
- 如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。
二、socket 套接字
socket 常见API
#include <sys/socket.h>
// 创建 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);
socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6,以及UNIX Domain Socket等。然而,各种网络协议的地址格式并不相同。
sockaddr 和 sockaddr_in
struct sockaddr
和 struct sockaddr_in
这两个结构体用来处理网络通信的地址。
1、sockaddr
sockaddr
在头文件#include <sys/socket.h>
中定义,sockaddr的缺陷是:sa_data
把目标地址和端口信息混在一起了。其结构如下:
/* POSIX.1g specifies this type name for the `sa_family' member. */
typedef unsigned short int sa_family_t;
/* This macro is used to declare the initial common members
of the data types used for socket addresses, `struct sockaddr',
`struct sockaddr_in', `struct sockaddr_un', etc. */
#define __SOCKADDR_COMMON(sa_prefix) \
sa_family_t sa_prefix##family
/* Structure describing a generic socket address. */
struct sockaddr
{
__SOCKADDR_COMMON (sa_); /* Common data: address family and length. */
char sa_data[14]; /* Address data. */
};
struct sockaddr {
sa_family_t sin_family;//地址族
char sa_data[14]; //14字节,包含套接字中的目标地址和端口信息
};
2、sockadd_in
sockaddr_in
在头文件#include<netinet/in.h>
或#include <arpa/inet.h>
中定义,该结构体解决了sockaddr
的缺陷,把port
和addr
分开储存在两个变量中,其结构定义如下:
/* Structure describing an Internet socket address. */
struct sockaddr_in
{
__SOCKADDR_COMMON (sin_);
in_port_t sin_port; /* Port number. */
struct in_addr sin_addr; /* Internet address. */
/* Pad to size of `struct sockaddr'. */
unsigned char sin_zero[sizeof (struct sockaddr)
- __SOCKADDR_COMMON_SIZE
- sizeof (in_port_t)
- sizeof (struct in_addr)];
};
sin_port和sin_addr都必须是网络字节序(NBO),一般可视化的数字都是主机字节序(HBO)。
虽然socket API中的接口是sockaddr
,但是我们真正在基于IPv4编程时,使用的数据结构是sockaddr_in
,这个结构里主要有三部分信息:地址类型、端口号、IP地址。
3、sockaddr 和 sockaddr_in的结构
- IPv4和IPv6的地址格式定义在头文件
netinet/in.h
中,IPv4地址用sockaddr_in
结构体表示,包括16位地址类型,16位端口号和32位IP地址。 - IPv4、IPv6地址类型分别定义为常数
AF_INET
、AF_INET6
, 这样,只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容。 - socket API 可以都用
struct sockaddr *
类型表示,在使用的时候需要强制转化成sockaddr_in
, 这样的好处是程序的通用性,既可以接收IPv4、IPv6、也可以接收UNIX Domain Socket
各种类型的sockaddr
结构体指针做为参数。
4、总结
二者长度一样,都是16个字节,即占用的内存大小是一致的,因此可以互相转化。二者是并列结构,指向sockaddr_in
结构的指针也可以指向sockaddr
。
sockaddr
常用于bind
、connect
、recvfrom
、sendto
等函数的参数,指明地址信息,是一种通用的套接字地址。
sockaddr_in
是internet
环境下套接字的地址形式。所以在网络编程中我们会对sockaddr_in
结构体进行操作,使用sockaddr_in
来建立所需的信息,最后使用类型转化就可以了。一般先把sockaddr_in
变量赋值后,强制类型转换后传入用sockaddr
做参数的函数。即sockaddr_in
用于socket
定义和赋值;而sockaddr
用于函数参数。
用法举例:
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
int main(int argc,char **argv)
{
int sockfd = 0;
struct sockaddr_in addr_in;
struct sockaddr * addr;
sockfd = socket(AF_INET, SOCK_STREAM, 0); //获得fd
bzero(&addr_in,sizeof(addr_in)); // 初始化结构体
/*
8008的主机字节序 小端字节序 0001 1111 0100 1000 = 8008
8008的网络字节序 大端字节序 0100 1000 0001 1111 = 18463
*/
addr_in.sin_port = htons(8008);
addr_in.sin_family = AF_INET; // 设置地址家族
addr_in.sin_addr.s_addr = inet_addr("192.168.3.30"); //设置地址
printf("sockaddr_in.sin_addr.s_addr = %d \n", addr_in.sin_addr.s_addr);
printf("addr = %s \n", inet_ntoa(addr_in.sin_addr));
// addr_in.sin_addr.s_addr = htonl(INADDR_ANY); //设置地址
printf("struct sockaddr size = %ld \n", sizeof (addr));
printf("struct sockaddr_in size = %ld \n", sizeof (addr_in));
addr = (struct sockaddr *)&addr_in;
// bind(sockfd, (struct sockaddr *)&addr_in, sizeof(struct sockaddr)); /* bind的时候进行转化 */
bind(sockfd, addr, sizeof(struct sockaddr));
... ...
return 0;
}
其中inet_addr()
作用是将一个IP字符串转化为一个网络字节序的整数值,用于sockaddr_in.sin_addr.s_addr
的初始化。inet_ntoa()
作用是将一个sin_addr
结构体输出成IP字符串(network to ascii)。
三、UDP Socket编程
这里我们使用UDP套接字编程实现一个简单的英译汉服务器。
封装UdpSocket
udpSocket.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cassert>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
class udpSocket
{
public:
udpSocket() : _fd(-1) {}
~udpSocket(){}
public:
// 创建套接字
bool Socket()
{
_fd = socket(AF_INET, SOCK_DGRAM, 0); // AF_INET表示采用IPv4, SOCK_DGRAM表示采用udp协议
if (_fd < 0)
{
std::cerr << "create socket error!" << std::endl;
return false;
}
return true;
}
// 关闭套接字
bool Close()
{
close(_fd);
return true;
}
// Bind套接字相关信息
bool Bind(const std::string &ip, uint16_t port)
{
sockaddr_in addr;
// 填充协议家族
addr.sin_family = AF_INET;
// 填写端口号,port会通过网络发送给客户端
addr.sin_port = htons(port);
// 服务器必须有IP地址,“xx.yy.zz.aaa”,字符串风格的点分十进制 -》4字节IP -> uint32_t IP
// INADDR_ANY(0): 不关心会bind到哪个IP,bind任意IP,服务器一般的做法
// inet_addr指定填充确定的IP,特殊用途,或者测试时使用,除了做转化,还会自动给我们进行 h—>n
addr.sin_addr.s_addr = ip.empty() ? htonl(INADDR_ANY) : inet_addr(ip.c_str());
// bind 网络信息
// struct sockaddr_in填充信息, struct sockaddr充当函数参数
if (bind(_fd, (const sockaddr *)&addr, sizeof addr) == -1)
{
std::cerr << "bind error!" << std::endl;
return false;
}
return true;
}
// 接收数据
bool RecvFrom(std::string *buf, std::string *ip = nullptr, uint16_t *port = nullptr)
{
char inbuf[1024 * 10];
sockaddr_in peer;
socklen_t len = sizeof(peer);
// 将接收的数据读入到inbuf缓冲区中,并且获取客户端的sockaddr_in信息
ssize_t read_size = recvfrom(_fd, inbuf, sizeof(inbuf) - 1, 0, (sockaddr *)&peer, &len);
if (read_size < 0)
{
std::cerr << "recvfrom error!" << std::endl;
return false;
}
// 将缓冲区中的内容放到输出型参数中
buf->assign(inbuf, read_size);
if (ip != nullptr)
{
*ip = inet_ntoa(peer.sin_addr);
}
if (port != nullptr)
{
*port = ntohs(peer.sin_port);
}
return true;
}
// 发送数据
bool SendTo(const std::string &buf, const std::string &ip, uint16_t port)
{
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = ip.empty() ? htonl(INADDR_ANY) : inet_addr(ip.c_str());
//发送数据给客户端
ssize_t write_size = sendto(_fd, buf.c_str(), buf.size(), 0, (const sockaddr *)&addr, sizeof(addr));
if (write_size < 0)
{
std::cerr << "sendto error!" << std::endl;
return false;
}
return true;
}
private:
int _fd;
};
实现UDP通用服务器
udpServer.hpp
#pragma once
#include "udpSocket.hpp"
// C++11 能够兼容函数指针、仿函数和Lambda表达式
#include <functional>
typedef std::function<void (const std::string &, std::string* resp)> Handler;
class udpServer
{
public:
udpServer()
{
assert(_sock.Socket());
}
~udpServer()
{
assert(_sock.Close());
}
public:
bool Start(const std::string &ip, uint16_t port, Handler handler)
{
// 绑定IP 和 端口号
if (!_sock.Bind(ip, port))
{
return false;
}
while (true)
{
// 尝试读取请求
std::string req;
std::string client_ip;
uint16_t client_port;
if (!_sock.RecvFrom(&req, &client_ip, &client_port))
{
continue;
}
std::string resq;
//根据请求获得响应
handler(req, &resq);
//返回相应给客户端
_sock.SendTo(resq, client_ip, client_port);
//debug
printf("[%s:%d] req: %s, resq: %s\n", client_ip.c_str(), client_port, req.c_str(), resq.c_str());
}
_sock.Close();
return true;
}
private:
udpSocket _sock;
};
实现英译汉服务器
dict_server.cpp
#include <iostream>
#include <string>
#include <unordered_map>
#include "udpServer.hpp"
std::unordered_map<std::string, std::string> g_dict;
void Translate(const std::string &req, std::string *resp)
{
auto it = g_dict.find(req);
if (it == g_dict.end())
{
std::cout << "未查找到!" << std::endl;
return;
}
*resp = it->second;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
printf("Usage: ./dict_server [ip] [port]\n");
return 1;
}
// 数据初始化
g_dict.insert(std::make_pair("hello", "你好"));
g_dict.insert(std::make_pair("world", "世界"));
g_dict.insert(std::make_pair("c++", "最好的编程语言"));
g_dict.insert(std::make_pair("byte", "字节"));
// 启动服务器
udpServer().Start(argv[1], atoi(argv[2]), Translate);
return 0;
}
实现UDP通用客户端
udpClient.hpp
#pragma once
#include "udpSocket.hpp"
class udpClient
{
public:
udpClient(const std::string &ip, uint16_t port)
: _ip(ip), _port(port)
{
assert(_sock.Socket());
}
~udpClient() { _sock.Close(); }
public:
bool RecvFrom(std::string* buf)
{
return _sock.RecvFrom(buf);
}
bool SendTo(const std::string& buf)
{
return _sock.SendTo(buf, _ip, _port);
}
private:
udpSocket _sock;
// 服务器的IP和端口号
std::string _ip;
uint16_t _port;
};
实现英译汉客户端
client.cpp
#include <iostream>
#include <string>
#include "udpClient.hpp"
int main(int argc, char *argv[])
{
if (argc != 3)
{
printf("Usage: ./dict_server [ip] [port]\n");
return 1;
}
udpClient client(argv[1], atoi(argv[2]));
while(true)
{
std::string word;
std::cout << "请输入您要查找的单词:";
if(!(std::cin >> word))
{
std::cout << "end..." << std::endl;
break;
}
client.SendTo(word);
std::string res;
client.RecvFrom(&res);
std::cout << word << "的意思是:" << res << std::endl;
}
return 0;
}
运行结果:
四、地址转换函数
sockaddr_in
中的成员struct in_addr sin_addr
表示32位的IP地址,但是我们通常用点分十进制的字符串表示IP 地址,以下函数可以在字符串表示 和in_addr
表示之间转换。
字符串转in_addr的函数
#include <arpa/inet.h>
int inet_aton(const char* strptr, struct in_addr* addrptr);
in_addr_t inet_addr(const char* strptr);
int inet_pton(int family, const char* strptr, void* addrptr);
in_addr转字符串的函数
char* inet_ntoa(struct in_addr inaddr);
const char* inet_ntop(int family, const void* addrptr, char* strptr, size_t len);
其中inet_pton
和inet_ntop
不仅可以转换IPv4的in_addr
,还可以转换IPv6的in6_addr
,因此函数接口是void* addrptr
。
代码样例:
#include <iostream>
#include <cstdio>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main()
{
sockaddr_in addr;
inet_aton("127.0.0.1", &addr.sin_addr);
uint32_t* ptr = (uint32_t*)(&addr.sin_addr);
printf("addr: %x\n", *ptr);
printf("addr_str: %s\n", inet_ntoa(addr.sin_addr));
return 0;
}
运行结果:
关于inet_ntoa
inet_ntoa
这个函数返回了一个char*类型的指针,很显然是这个函数自己在内部为我们申请了一块内存来保存IP的结果。那么是
否需要调用者手动释放呢?答案是不需要。
在man手册上说,inet_ntoa
函数,是把这个返回结果放到了静态存储区,所以这个时候不需要我们手动进行释放。