c++网络编程——解析主机字节序、网络字节序以及深入剖析tcp编程中万恶的结构体(爆肝)

目录

一、前言

二、主机字节序和网路字节序

补充知识:cpu,内存,硬盘之间的关系

一、一个比喻:厨房、桌子和厨师

二、CPU里面存东西吗?

三、为什么不能把所有东西都放CPU里,或者全用最快的技术?

总结

1.大端序和小端序

2.网络字节序

3.ip地址和通讯端口

4.如何处理大小端序

三、sockaddr结构体

四、sockaddr_in结构体

孩子们,你收否有很多问号:

五、下面来看:IP地址相关的结构体

问题:IP地址是字符串,怎么把它转变为32位的大端序整数?

方案一:gethostbyname函数

方案二:字符串IP与大端序IP的转换(在嵌入式用的极多)


注:本文章内容均来自本人的学习笔记为个人学习总结,禁止转载。

参考自B站课程:码农论坛《c++网络编程》。由于当时方便记笔记,笔记中少部分图片(仅涉及部分代码以及相关运行结果展示,不涉及重要笔记、资料等)来源于原课程视频截图,版权归原作者"码农论坛"及相关权利人所有。本笔记无任何商业用途(除开csdn官方操作),仅供个人学习交流。感谢原up主的课程分享!


一、前言

在我之前的一篇博客tcp网络编程以及socket函数详解中,讲到了socket网络编程的基本概念和代码示例。以下是代码,方便查看。

客户端client:

cpp 复制代码
/*
 * 程序名:demo_client.cpp,此程序用于演示socket的客户端
 * kaizy
*/
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
using namespace std;

int main(int argc, char* argv[])
{
	if (argc != 3)
	{
		cout << "Using:./demo_client 服务端的IP 服务端的端口\nExample:./demo_client 192.168.101.139 5005\n\n";
		return -1;
	}

	//第1步:创建客户端的socket(套接字)
	// 类比------准备电话
	// 创建客户端的socket,返回套接字描述符(类似文件句柄)
	int sockfd = socket(AF_INET, SOCK_STREAM, 0);
	if (sockfd == -1)// 创建失败返回-1
	{
		perror("socket");// 打印具体错误原因(如权限、资源不足)
		return -1;
	}
	/*关键参数解释:
	socket 就是让两台电脑上的程序能互相传数据的 "网络管道"
	AF_INET:使用 IPv4 协议(主流网络协议);
	SOCK_STREAM:使用 TCP 协议(面向连接、可靠传输);
	0:默认协议(TCP 协议下可省略);
	sockfd:Socket 描述符,后续所有操作(连接、发送)都基于这个 "句柄"。
	作用:相当于给客户端创建了一个 "网络通讯的通道",没有这个通道就无法和服务端通信。*/

	//第二步:向服务器发起连接请求。(解析服务端ip并发起连接)   
	// 类比------打电话拨号
	struct hostent* h;   //用于存放服务端ip的结构体
	// 把字符串格式的IP(如"192.168.101.139")转换成网络可识别的结构体
	if ((h = gethostbyname(argv[1])) == NULL) //把字符串格式的ip转换成结构体
	{
		cout << "gethostbyname failed.\n" << endl;
		close(sockfd);// 失败时关闭Socket,释放资源
		return -1;
	}

	//注:gethostbyname:不仅能解析 IP,还能解析域名(如 "www.baidu.com"),返回的h包含 IP 的二进制形式。
	//////填充服务端地址结构体
	struct sockaddr_in servaddr; //用于存放服务端ip和端口结构体
	memset(&servaddr, 0, sizeof(servaddr));// 初始化结构体,避免脏数据
	servaddr.sin_family = AF_INET;// 协议族:IPv4
	// 把解析后的IP复制到结构体中(二进制形式)
	memcpy(&servaddr.sin_addr, h->h_addr, h->h_length);   //指定服务端的ip地址
	// 把端口转换成网络字节序(大端序),赋值给结构体
	servaddr.sin_port = htons(atoi(argv[2]));			  //指定服务端的通信端口
	/* atoi(argv[2]):把字符串端口(如 "5005")转换成整数;
	htons:把主机字节序(小端序,x86 架构)转换成网络字节序(大端序),网络通讯必须统一字节序。*/

	///////向服务端发起连接
	if (connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) != 0)//向服务端发起连接请求
	{
		perror("connect");// 打印连接失败原因(如服务端未启动、IP/端口错误)
		close(sockfd);
		return -1;
	}
	/* connect:TCP 的 "三次握手" 核心函数,成功返回 0,失败返回 - 1;
	失败场景:服务端未启动、IP 错误、端口被占用、网络不通等。*/

	//第三步:与服务端通讯,客户发送一个请求报文后等待服务端的回复,收到回复后,再发下一个请求报文
	// 打电话->通话
	char buffer[1024];// 数据缓冲区,存储要发送的内容
	for (int i = 0; i < 3; i++)//循环3次,将与服务端进行3次通讯
	{
		int iret;
		memset(buffer, 0, sizeof(buffer));//清空缓冲区
		sprintf(buffer, "这是第%d个超级女生,编号%03d。", i + 1, i + 1);  // 生成请求报文内容。
		// 向服务端发送请求报文:参数=Socket句柄 + 数据缓冲区 + 数据长度 + 标志(0=默认)
		if ((iret = send(sockfd, buffer, strlen(buffer), 0)) <= 0)
		{
			perror("send");
			break;
		}
		cout << "发送:" << buffer << endl;
		/*sprintf:把格式化内容写入缓冲区(类似 cout,但写入字符数组);
		strlen(buffer):获取有效数据长度(不含字符串结束符 '\0');
		send:发送数据,返回值iret是实际发送的字节数;
		sleep(1):每秒发送 1 条,避免发送过快。*/

		memset(buffer, 0, sizeof(buffer));//把 buffer 数组的所有字节置为 0,清空上一次发送的数据(比如 "这是第 1 个超级女生..."),
		//避免残留数据干扰接收结果。
		//接收服务端的回应报文,如果服务端没有发送回应报文,recv()函数将阻塞等待.。
		if ((iret = recv(sockfd, buffer, sizeof(buffer), 0)) <= 0)
		{
			cout << "iret=" << iret << endl;
			break;
		}
		// recv() 是从 Socket 连接里 "读取" 对方发过来的数据的函数,像打电话时 "听对方说话",没数据就等(阻塞),
		// 拿到数据存到缓冲区,拿不到就返回错误 / 断连信号。
		// 返回值:>0 = 收到的字节数,=0 = 对方正常断连,<0 = 接收失败;
		cout << "接收:" << buffer << endl;

		sleep(1);
	}

	//第四步,关闭socket,释放资源
	close(sockfd);
	//作用:断开与服务端的 TCP 连接(四次挥手),释放系统分配的 Socket 资源,必须执行否则会造成资源泄漏。
}

服务端server:

cpp 复制代码
/*
 * 程序名:demo_server.cpp,此程序用于演示socket通信的服务端
 * kaizy
*/
#include <iostream>       // C++标准输入输出(cout)
#include <cstdio>         // C标准输入输出(perror)
#include <cstring>        // 内存操作(memset、strcpy、strlen)
#include <cstdlib>        // 字符串转数字(atoi)
#include <unistd.h>       // 系统调用(close)
#include <netdb.h>        // 网络相关结构体(sockaddr_in)
#include <sys/types.h>    // 系统类型定义(socket相关)
#include <sys/socket.h>   // Socket核心函数(socket/bind/listen/accept/recv/send)
#include <arpa/inet.h>    // 网络字节序转换(htons/htonl)
using namespace std;      // 简化C++标准库调用

//和客户端头文件基本一致,都是 Linux 下 Socket 编程的标配,覆盖 IO、内存、网络核心能力。

int main(int argc, char* argv[])
{
    if (argc != 2)
    {
        cout << "Using:./demo_server 通讯端口\nExample:./demo_server 5005\n\n";   // 端口大于1024,不与其它的重复。
        cout << "注意:运行服务端程序的Linux系统的防火墙必须要开通5005端口。\n";
        cout << "      如果是云服务器,还要开通云平台的访问策略。\n\n";
        return -1;
    }
    /*服务端只需要指定 "监听的端口"(如 5005),不需要指定 IP(后续会绑定到所有网卡);
    关键提醒:端口需用 1024 以上(1024 以下是系统保留端口,普通用户无权使用),且要开放防火墙 / 云平台策略,否则客户端连不上。*/

    //第一步:创建服务端的socket
    //打电话------准备电话机
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd == -1)
    {
        perror("socket");
        return -1;
    }
    /*AF_INET:IPv4 协议;
    SOCK_STREAM:TCP 协议(面向连接、可靠传输);
    0:默认协议;
    lisenfd:监听套接字描述符,专门用于 "监听客户端连接请求",不负责实际收发数据。*/

    //第二步:把服务器用于通信的ip和端口绑定到socket上
    // 打电话------分配电话号码
    //服务端需要把 "监听的 IP + 端口" 和套接字绑定,客户端才能找到它。
    struct sockaddr_in servaddr;          // 用于存放服务端IP和端口的数据结构,IPv4专用地址结构体,存储IP+端口
    memset(&servaddr, 0, sizeof(servaddr));// 清空结构体,避免脏数据
    servaddr.sin_family = AF_INET;        // 指定协议。
    // 绑定到所有网卡(0.0.0.0),即服务器的任意IP都能接收连接
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 服务端任意网卡的IP都可以用于通讯。
    // 把端口转换成网络字节序,赋值给结构体
    servaddr.sin_port = htons(atoi(argv[1]));     // 指定通信端口,普通用户只能用1024以上的端口。
    /*
    关键参数:
    INADDR_ANY:宏定义,值为 0,代表 "绑定到服务器所有网卡的 IP"(比如服务器有内网 IP、外网 IP,都能接收连接);
    htonl:把主机字节序转换成网络字节序(针对 32 位整数,如 IP);
    htons:把主机字节序转换成网络字节序(针对 16 位整数,如端口);
    atoi(argv[1]):把字符串端口(如 "5005")转成整数。
    
    */

    //绑定服务端的ip和端口
    if (bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) != 0)
    {
        perror("bind");
        close(listenfd);
        return -1;
    }

    // 第三步:把socket设置为可连接(监听)的状态。
    /*
    isten 就是让服务端 socket 开始 "监听连接",进入可以被客户端连接的状态。
    极简版:
    服务端调用 listen 后,就开始等着客户端来 connect
    同时会维护一个等待连接的队列,避免并发连接丢失
    一句话总结:把 socket 设为监听模式,开启接客模式。
    */
    if (listen(listenfd, 5) != 0)
    {
        perror("listen");
        close(listenfd);
        return -1;
    }

    //第四步:受理客户端的连接请求,如果没有客户端连上来,accept()函数将阻塞等待。
    int clientfd = accept(listenfd, 0, 0);

    if (clientfd == -1)
    {
        perror("accept");
        close(listenfd);
        return -1;
    }

    cout << "客户端已连接。\n";
    //第五步:与客户端通信,接收客户端发过来的报文后,回复ok
    char buffer[1024];
    while (true)
    {
        int iret;
        memset(buffer, 0, sizeof(buffer));
        //接收客户端的请求报文,如果客户端没有请求报文,recv()函数将阻塞等待
        //如果客户端已经断开连接,recv()函数将返回0
        if ((iret = recv(clientfd, buffer, sizeof(buffer), 0)) <= 0)
        {
            cout << "iret=" << iret << endl;
            break;
        }
        cout << "接收:" << buffer << endl;
        strcpy(buffer, "ok");//生成回应报文内容
        //向客户端发送回应报文
        if ((iret = send(clientfd, buffer, strlen(buffer), 0)) <= 0)
        {
            perror("send");
            break;
        }
        cout << "发送:" << buffer << endl;
    }
    //第六步:关闭socket,释放资源
    close(listenfd);   // 关闭服务端用于监听的socket。
    close(clientfd);   // 关闭客户端连上来的socket。
}

/*
listen函数:
#include <sys/socket.h>
int listen(int sockfd, int backlog);

两个参数:
sockfd
你用 socket() 创建出来的文件描述符
就是服务端自己的 socket
backlog
等待连接的队列长度
简单说:最多能同时排队等待处理的客户端数量
一般写 5、10、128 都行
你写 5 就够了:listen(fd, 5);
*/
/*
accept 与 connect 超直白对比(实习面试必考)
一句话分清
connect:客户端主动去找服务端
accept:服务端等着客户端来找它

1. 谁在用?
connect → 客户端调用
accept → 服务端调用
2. 干什么?
connect
作用:主动连接指定 IP 和端口的服务器
结果:成功后得到一个 socket,可以 send /read
accept
作用:阻塞等待客户端来连接
结果:来一个客户端,就返回一个新的 socket 专门跟它聊天
3. 阻塞表现
connect:
连不上就一直等,直到超时或成功。
accept:
一直死等,没有客户端连接就卡在这里不动。

 面试标准答案(背这个)
connect 是客户端函数,用于主动向服务端发起连接请求。
accept 是服务端函数,用于阻塞等待客户端连接,成功后返回用于通信的客户端 socket。
*/

但是,这里最复杂的结构体部分还没有讲,今天把它给拿下!

啥结构体?就是存放协议端口,和ip地址的结构体。

但是在此之前要先了解几个概念------主机字节序和网络字节序

二、主机字节序和网路字节序

补充知识:cpu,内存,硬盘之间的关系

一、一个比喻:厨房、桌子和厨师

把计算机想象成一个厨房:

硬盘 (SSD/HDD) :是冰箱和储物柜 。容量巨大,但速度慢。你的菜谱、食材(程序和数据)在不做菜时都放在这里,断电后也不会消失

内存 (RAM) :是料理台 。容量比冰箱小,但厨师操作很快。你想做的任何菜,都必须先把食材和菜谱从冰箱里拿出来,放在料理台上。料理台上的东西,一断电(或者收拾厨房)就没了。

CPU :是厨师 。他不存任何东西,只负责从料理台上拿起食材,按照菜谱(指令)进行切菜、翻炒等操作,再把做好的菜放回料理台上

所以,你的问题"计算机的所有数据都是存放在内存吗?"的答案是:不,所有正在运行的程序和正在处理的数据,必须存放在内存中。 而长久保存的数据(比如你没打开的文档、没运行的游戏),是存放在硬盘里的。

二、CPU里面存东西吗?

CPU内部有非常小极快 的存储空间,叫做 寄存器

寄存器 :相当于厨师手里的调料盒 或者锅铲。厨师不可能把整块肉(数据)一直拿在手里,但他可以把几粒盐(一个数字)、酱油(一个内存地址)这种最紧要的东西放在手边最方便的位置。寄存器的数量极少(一个CPU只有几十个),但速度是计算机里最快的。

数据搬运的完整流程:

  1. 程序运行时,操作系统会把程序代码和数据从硬盘 复制到内存

  2. CPU 执行一条指令,比如"把内存地址1000的数值+1"。

  3. CPU 会先把内存地址1000的数值读到自己的寄存器里。

  4. CPU 在寄存器里完成 +1 操作。

  5. CPU 再把寄存器里计算好的新数值写回到内存地址1000。

三、为什么不能把所有东西都放CPU里,或者全用最快的技术?

这是一个经典的工程权衡问题,核心在于 速度和成本

  • 为什么数据不能全放CPU里?

    成本:CPU里的寄存器(以及L1/L2/L3缓存)技术,速度极快,但成本也极高、功耗大。1MB的CPU缓存比16GB的内存条贵得多。所以只能做到KB或MB级别。

  • 为什么不用内存替代硬盘?

    断电即失 :你关掉电脑,内存里的所有数据就会消失。我们需要硬盘来持久化 存储。成本:内存(SRAM/DRAM)的成本依然远高于硬盘(闪存/机械),且单位容量的耗电量也更大。

这个速度、成本、容量的金字塔结构是计算机体系的基础:

最快、最贵、最小:CPU寄存器

很快、较贵、较小:CPU缓存 (L1/L2/L3)

快、便宜、较大:内存 (RAM)

慢、很便宜、巨大:硬盘 (SSD/HDD)

总结

  1. 内存是CPU的"工作台" :所有正在运行的程序和数据,必须放在内存里。

  2. CPU自己不存数据 :它只通过寄存器 这种极小的"手边工具",来读取、计算、写回内存中的数据。

  3. 硬盘是"仓库":用来长久保存暂时不用的数据。

1.大端序和小端序

如果数据类型占用的内存空间大于1字节,CPU把数据存放在内存中的方式有两种:

大端序(Big Endian):低位字节存放在高位,高位字节存放在低位。

小端序(Little Endia):低位字节存放在低位,高位字节存放在高位。

假设从内存地址0x00000001处开始存储十六进制数0x12345678,那么:

Bit-endian(按原来顺序存储)

0x00000001 0x12

0x00000002 0x34

0x00000003 0x56

0x00000004 0x78

Little-endian(颠倒顺序储存)

0x00000001 0x78

0x00000002 0x56

0x00000003 0x34

0x00000004 0x12

简单说就是:多字节数据在内存里怎么排队。一个是"顺着排",一个是"倒着排"。

大端序:高字节存低地址(高位在前,像写数字一样从左到右)

小端序:低字节存低地址(低位在前,倒着存)

不同 CPU 设计者选择的哲学不同:

大端序(网络序):符合人类读写习惯(高位在前),主要用于网络协议、ARM 默认也可配

小端序(主机序):x86、x86_64 强制使用,目前 PC、服务器、多数嵌入式设备的主流

Intel系列的CPU以小端序方式保存数据,其它型号的CPU不一定。

操作文件的本质是把内存中的数据写入磁盘,在网络编程中,传输数据的本质也是把数据写入文件(socket也是文件描述符)。

(文件、网络 socket 都是"字节流",把内存里的二进制数据直接写进去 → 另一端读出来时,字节顺序相反 → 数字解析错误)

这样的话,字节序不同的计算机之间传输数据,可能会出现问题:

因此,引入了网络字节序来解决这个问题:

2.网络字节序

为了解决不同字节序的计算机之间传输数据的问题,约定采用网络字节序(大端序)。

主机序(Host Byte Order) :取决于 CPU 架构,可以是大端 (如 PowerPC、旧版 Mac),也可以是小端 (如 x86、x86_64、ARM 默认小端模式)。ARM 架构支持大小端切换,但绝大多数嵌入式 Linux 系统都运行在小端模式

网络序(Network Byte Order) :TCP/IP 协议栈明确规定使用 大端 (Big-Endian),这是网络协议中的硬性标准,所有厂商、所有平台都必须遵守,以保证不同架构的设备能正常通信。

一句话总结:主机序可以是大小端,但是网络序一定是大端

都转成网络序,就避免了传输的数据解析问题!!!

C语言提供了四个库函数,用于在主机字节序和网络字节序之间转换:

以下是函数的声明:

#include <arpa/inet.h>

uint16_t htons(uint16_t hostshort); // 主机序 → 网络序(16位)

uint32_t htonl(uint32_t hostlong); // 主机序 → 网络序(32位)

uint16_t ntohs(uint16_t netshort); // 网络序 → 主机序(16位)

uint32_t ntohl(uint32_t netlong); // 网络序 → 主机序(32位)

注:// uint16_t 2字节的整数 unsigned short

// uint32_t 4字节的整数 unsigned int

1byte=8bit

函数详解:

主机序 ↔ 网络序 转换函数

|---------|----------------------------------------------------------|--------|--------|
| 函数 | 含义 | 参数类型 | 返回值类型 |
| htons() | h ost to n etwork s hort | 16位主机序 | 16位网络序 |
| htonl() | h ost to n etwork l ong | 32位主机序 | 32位网络序 |
| ntohs() | n etwork to h ost s hort | 16位网络序 | 16位主机序 |
| ntohl() | n etwork to h ost l ong | 32位网络序 | 32位主机序 |

命名规则拆解;

|----|---------|-----------------------|
| 缩写 | 全称 | 含义 |
| h | host | 主机字节序(x86 是小端) |
| n | network | 网络字节序(固定为大端 ) |
| to | to | 转换方向 |
| s | short | 16位(2字节) |
| l | long | 32位(4字节) |

3.ip地址和通讯端口

在计算机中,IPv4的地址用4字节的整数存放,通讯端口用2字节的整数(0-65535)存放。

例如:192.168.190.134 3232284294 255.255.255.255

192 168 190 134

大端:11000000 10101000 10111110 10000110

小端:10000110 10111110 10101000 11000000

说明:IP地址在内存里怎么存,和网络传输时怎么排,是两回事。

IP 地址 192.168.190.134 对应十六进制:C0 A8 BE 86(192=C0, 168=A8, 190=BE, 134=86)。

|--------------------|---------|-----------------|
| 视角 | 排列顺序 | 十六进制表示 |
| 人看 | 点分十进制 | 192.168.190.134 |
| 内存中(x86小端) | 低地址存低字节 | 86 BE A8 C0 |
| 网络传输(大端) | 先发高字节 | C0 A8 BE 86 |

所以小端机器在内存里看到的是 86 BE A8 C0,但用 htonl 转成网络序后,就会变成 C0 A8 BE 86。

为什么网络传输要用大端???

网络协议是跨架构的,必须规定一个"通用语言"。

规定 大端序(网络序) 作为传输标准,这样:

小端机器(x86)发前用 htonl 转成大端

大端机器(某些ARM、PowerPC)发前不用转

接收端用 ntohl 转回自己的主机序

所以代码里永远写 htonl / ntohl,不要假设自己机器是什么端。

为什么"255.255.255.255"是特殊的

255.255.255.255 的十六进制是 FF FF FF FF:

大端:FF FF FF FF

小端:FF FF FF FF

这是唯一一个"不分端序"的IP地址,因为所有字节都一样。

广播地址天然避开字节序问题。

P地址(4字节)和端口(2字节)在内存中是主机序(x86小端),但网络传输必须是大端(网络序)。

因此发送前必须用 htonl / htons 转换,接收后用 ntohl / ntohs 转回。

这是跨平台通信的"铁律"。

4.如何处理大小端序

在网络编程中,数据收发的时候有自动转换机制,不需要程序员手动转换,只有向sockaddr_in结体成员变量填充数据时,才需要考虑字节序的问题。

三、sockaddr结构体

存放协议族、端口和地址信息,客户端和connect()函数和服务端的bind()函数需要这个结构体。

cpp 复制代码
struct sockaddr 
{
  unsigned short sa_family;	// 协议族,与socket()函数的第一个参数相同,填AF_INET。
  unsigned char sa_data[14];	// 14字节的端口和地址。
};

四、sockaddr_in结构体

sockaddr结构体是为了统一地址结构的表示方法,统一接口函数,但是,操作不方便,所以定义了等价的sockaddr_in结构体,它的大小与sockaddr相同,可以强制转换成sockaddr。

cpp 复制代码
struct sockaddr_in 
{  
  unsigned short sin_family;	// 协议族,与socket()函数的第一个参数相同,填AF_INET。
  unsigned short sin_port;		// 16位端口号,大端序。用htons(整数的端口)转换。
  struct in_addr sin_addr;		// IP地址的结构体。192.168.101.138
  unsigned char sin_zero[8];	// 未使用,为了保持与struct sockaddr一样的长度而添加。
};

struct in_addr // IP地址的结构体。
{				
  unsigned int s_addr;		// 32位的IP地址,大端序。
};

注意观察,这两个结构体的大小是相同的。第一个结构体,端口和地址14字节,第二个结构体包含了另一个结构体,端口号,sin_zero,ip地址,加起来也是14字节。

在程序中,创建的是下方的结构体,调用时才转为上面的结构体。

client:

server:

孩子们,你收否有很多问号:

产生了疑问:_in结构体用的好好的,为什么还要强转为上面的?

  1. 系统调用的接口设计

connect()、bind()、accept() 这些函数的原型是这样设计的:

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

第二个参数要求是 struct sockaddr*(通用地址结构体指针)

它要兼容 IPv4、IPv6、Unix 域套接字等多种地址格式

所以它不能只写死 struct sockaddr_in*(只兼容 IPv4)

咋看出来它只兼容ipv4?

in = Internet

特指 IPv4 协议

看里面的字段:

struct in_addr sin_addr; // ← 这个就是 IPv4 的 IP!

struct in_addr 是 纯 IPv4 地址结构,只有 4 字节

它只能存 192.168.1.100 这种 IPv4,存不下 IPv6!

IPv6 有专门的结构体:

struct sockaddr_in6 // IPv6 专用

struct in6_addr sin6_addr; // 128位 IPv6 地址

关键:内存布局兼容!struct sockaddr_in 的大小和 struct sockaddr 完全一样,sin_family 对应 sa_family,后面的 sin_port+sin_addr 正好填满 sa_data14

2.为什么要强制转换?

C 语言是强类型语言,struct sockaddr_in* 和 struct sockaddr* 是不同的指针类型,编译器不会自动转换;

强转只是告诉编译器:"我知道我在做什么,把这个 IPv4 地址当成通用地址传给系统调用";

实际内存里的数据一点没变,只是换了个 "类型标签"。

那为什么不直接用struct sockaddr* ?

直接用 struct sockaddr* 太不方便、太容易出错!struct sockaddr_in 是为了方便你手动填写 IPv4 地址和端口而设计的"友好版",而 struct sockaddr 只是给系统调用用的"通用壳子"。

sa_data14 是一个无意义的字节数组,里面混着 IP、端口、填充位,没有任何字段名;

如果你直接用它,就得手动拼字节:

struct sockaddr servaddr;

servaddr.sa_family = AF_INET;

// 手动把端口和IP塞到 sa_data 里,非常容易写错!

servaddr.sa_data0 = 0x13; // 端口高8位

servaddr.sa_data1 = 0x89; // 端口低8位

servaddr.sa_data2 = 192; // IP第一段

servaddr.sa_data3 = 168; // IP第二段

// ... 剩下的字节还要手动填,完全是自找麻烦

这不仅难写,可读性极差,还极易因为字节顺序搞错而 Bug 满天飞。

  1. struct sockaddr_in 是给人用的友好封装

它把 IPv4 地址拆成了有名字的字段,写起来一目了然:

struct sockaddr_in {

sa_family_t sin_family; // 地址族

in_port_t sin_port; // 端口号(直接用 htons() 赋值)

struct in_addr sin_addr; // IP地址(直接用 inet_pton() 或 memcpy)

unsigned char sin_zero8;// 填充到和 struct sockaddr 一样大

};

你可以按名字赋值:

servaddr.sin_family = AF_INET;

servaddr.sin_port = htons(5005); // 端口直接赋值

inet_pton(AF_INET, "192.168.1.1", &servaddr.sin_addr); // IP直接赋值

代码清晰、不易出错,这才是给开发者用的接口。

我前面定义的是 struct sockaddr_in(IPv4),那调用 connect 时怎么可能存得下 IPv6?根本没定义 IPv6 结构体啊

connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));

那为什么接口要写成 struct sockaddr*?

不是因为你现在在用 IPv6,而是这个接口设计成 "通用接口",未来可以支持 IPv6。

就像:

插座设计成万能插座

但你现在只插了两脚插头(IPv4)你根本没有调用 IPv6

你现在写的代码,从头到尾都是 IPv4。

// 1. 定义 IPv4 专用结构体

struct sockaddr_in serv_addr;

// 2. 填 IPv4 内容

serv_addr.sin_family = AF_INET;

serv_addr.sin_port = htons(8080);

inet_pton(AF_INET, "192.168.1.1", &serv_addr.sin_addr);

// 3. 强转成通用指针 → 传给 connect

不代表你现在在用三脚插头(IPv6)

如果你真要用 IPv6,才需要换结构体

IPv6 必须换结构体:

// IPv6 专用结构体

struct sockaddr_in6 serv_addr6;

接口还是同一个:

connect(sockfd, (struct sockaddr *)&serv_addr6, sizeof(serv_addr6));

回归正题:

struct sockaddr_in {

unsigned short sin_family; // 协议族,与socket()函数的第一个参数相同,填AF_INET。

unsigned short sin_port; // 16位端口号,大端序。用htons(整数的端口)转换。

struct in_addr sin_addr; // IP地址的结构体。192.168.101.138

unsigned char sin_zero8; // 未使用,为了保持与struct sockaddr一样的长度而添加。

};

struct in_addr { // IP地址的结构体。

unsigned int s_addr; // 32位的IP地址,大端序。

};

五、下面来看:IP地址相关的结构体

问题:IP地址是字符串,怎么把它转变为32位的大端序整数?

方案一:gethostbyname函数

可以把字符串,域名转为ip地址

根据域名/主机名/字符串IP获取大端序IP,用于网络通讯的客户端程序中。

函数原型:

cpp 复制代码
struct hostent *gethostbyname(const char *name);

输入:www.baidu.com 这种字符串

输出:装满 IP 信息的结构体指针

这个结构体就是:

cpp 复制代码
struct hostent { 
  char *h_name;     	// 主机名。
  char **h_aliases;    	// 主机所有别名构成的字符串数组,同一IP可绑定多个域名。 
  short h_addrtype; 	// 主机IP地址的类型,例如IPV4(AF_INET)还是IPV6。
  short h_length;     	// 主机IP地址长度,IPV4地址为4,IPV6地址则为16。
  char **h_addr_list; 	// 最重要:主机的ip地址,以网络字节序存储。 
};
#define h_addr h_addr_list[0] 	// for backward compatibility.

使用函数gethostbyname之前要声明这个结构体,gethostbyname函数返回的信息就在这个结构体里。

struct hostent * (指针)

→ 意思是:我会给你返回一个指针,你必须用同类型指针接住它!

Gethostbyname的返回值: struct hostent *

是啥?

它就是一个指针,指向一块装满了 "域名解析出来的 IP 信息" 的内存。

注意这个宏定义:

#define h_addr h_addr_list0

在结构体中:

char **h_addr_list;

它是一个IP 地址数组,一个域名可能对应多个 IP:

h_addr_list0 = 192.168.1.10(二进制网络序)

h_addr_list1 = 192.168.1.11

h_addr_list2 = NULL(表示结束)

那 h_addr 干嘛用?

绝大多数场景,我们只需要用第一个 IP 就够了。

所以系统给你整个快捷方式:

h->h_addr

就等于:

h->h_addr_list0

代码里看作用:

memcpy(&servaddr.sin_addr, h->h_addr, h->h_length);

这里:

h->h_addr = 拿到解析出来的第一个 IP

格式是网络字节序的二进制 IP

刚好可以直接 memcpy 塞进 servaddr.sin_addr

终极总结:

h_addr_list:存放一个域名对应的所有 IP

h_addr:取第一个 IP,方便你写代码

作用:给 socket 地址结构提供 IP 地址

char **h_addr_list; 它为啥是一个数组 ???

因为一个域名(比如 www.baidu.com)可以对应好多个 IP 地址!为了把所有 IP 都存下来,所以必须用 数组。

为什么一个域名能对应多个 IP?

这叫 DNS 轮询(DNS Load Balance)大厂为了不让服务器崩掉,会给一个域名绑很多 IP。

你电脑每次解析,可能拿到不同的 IP。系统函数 gethostbyname 会把所有解析出来的 IP 全部带回给你!

所以它必须是数组!

以下是C语言知识:

这就是一个指针数组:char *\[\]

先回忆两个最基础的东西

char * → 一串字符 / 一段二进制数据

char *\[\] / char ** → 一串指针,也就是数组

现在有好几个ip:

IP1 → char*

IP2 → char*

IP3 → char*

Char*原本指的是一串字符串,但是注意在这里:

h->h_addr_list0

它是4 字节原始二进制 IP,不是字符串,不是 "192.168.1.1",是 0xC0 0xA8 0x01 0x01 这种裸二进制,不能直接 printf("%s", ...) 打印

为什么还用 char*?

因为 C 语言里:char* 最适合表示 "一段任意二进制数据"所以系统函数就用它来存 IP。

要把这些ip放在一起,就变成:

char *addr_list\[\] = { IP1, IP2, IP3, NULL };

数组名在传参时会退化成指针:

char **h_addr_list

再看最后一步:

memcpy(&servaddr.sin_addr, h->h_addr, h->h_length);

  1. &servaddr.sin_addr

目标地址:把 IP 复制到 sockaddr_in 的 IP 字段里

  1. h->h_addr

源地址:来自 gethostbyname 解析出来的 4 字节网络 IP

  1. h->h_length

复制长度:IPv4 = 4 字节IPv6 = 16 字节自动适配,不用你写死 4!

它做了啥:把"gethostbyname 解析出来的 裸二进制IP" 直接复制到 "socket 需要的 sin_addr 里面"

等同于:servaddr.sin_addr = 解析出来的IP

为什么不能直接赋值?

因为:

h->h_addr 是 char* 类型

servaddr.sin_addr 是 struct in_addr 类型

类型不一样,不能直接 =所以必须用 memcpy 内存拷贝。

哦!!!!!!!!!!!!!懂了吧!!!!!!

方案二:字符串IP与大端序IP的转换(在嵌入式用的极多)

C语言提供了几个库函数,用于字符串格式的IP和大端序IP的互相转换,用于网络通讯的服务端程序中。

cpp 复制代码
typedef unsigned int in_addr_t;    // 32位大端序的IP地址。

// 把字符串格式的IP转换成大端序的IP,转换后的IP赋给sockaddr_in.in_addr.s_addr。
in_addr_t inet_addr(const char *cp); 

// 把字符串格式的IP转换成大端序的IP,转换后的IP将填充到sockaddr_in.in_addr成员。
int inet_aton(const char *cp, struct in_addr *inp);	

// 把大端序IP转换成字符串格式的IP,用于在服务端程序中解析客户端的IP地址。
char *inet_ntoa(struct in_addr in);

嵌入式设备通常有固定的服务器IP,直接写死在代码或配置文件里。用 inet_addr() 转换一下就能用,简单、可靠、无依赖。而 gethostbyname() 需要DNS,会增加代码复杂度、网络依赖、启动时间,还可能因DNS问题导致连接失败。

在嵌入式Linux里,稳定性比灵活性重要一万倍。能用IP解决的问题,绝不用域名。

注意图片的最后两行:

servaddr.sin_addr.s_addr=htonl(INADDR_ANY); // ③如果操作系统有多个IP,全部的IP都可以用于通讯。

//servaddr.sin_addr.s_addr=inet_addr("192.168.101.138"); // ③指定服务端用于通讯的IP(大端序)。

服务器,如果有多个网卡,就可以有多个ip地址

假设服务器有多个ip地址:

这两个ip地址属于不同的网段,对于左右两端的客户端来说,只能访问相应的网段。

如果服务端的程序采用的是第一种写法:

servaddr.sin_addr.s_addr=htonl(INADDR_ANY); // ③如果操作系统有多个IP,全部的IP都可以用于通讯。

那么左右两端的客户端都可以和服务端通信。

但如果采用的是第二种写法:

//servaddr.sin_addr.s_addr=inet_addr("192.168.101.138"); // ③指定服务端用于通讯的IP(大端序)。

那么,只有左边的客户端能与服务端通信。

在客户端,也可以把gethostbyname函数改为int_addr,这是在嵌入式中更常见的做法:

编译运行服务端客户端程序,依然可以正常运行:

但是,gethostbyname函数还可以用域名和主机名,而int_addr函数只能用ip

演示:代码demo5(见linux,或vs)

Ping一下新浪微博,查看其ip地址:

运行客户端:

网站是http协议,用的是80端口

显示:

接受成功,虽然不合法

把IP地址改为域名:

重新启用gethostbyname函数:

就可以成功接收了。

创作不易,大家点赞收藏+关注呀O(∩_∩)O哈哈~

相关推荐
TE-茶叶蛋1 小时前
Next.js中App Router 全部特殊文件一览
开发语言·javascript·网络
小猫咪011 小时前
Linux 定时任务 crontab 详解:让脚本每天自动执行
linux·运维·服务器
jcbut1 小时前
在Linux 7.9上安装NetBackup IT Analytics (ITA) 11.2
linux·运维·netbackup·it analytics·ita
云川之下2 小时前
【linux】免密登录
linux·免密
kaka❷❷2 小时前
Linux 内核、.ko、.so 与 SDK 镜像打包
linux·运维·服务器
微风◝2 小时前
【Linux故障排查】系统启动进入紧急模式:由磁盘挂载超时引发的服务器无法启动
linux·运维·服务器
闲猫2 小时前
堡垒机Linux黑屏识别命令Set -n探索可能性
linux·运维·服务器
寺中人2 小时前
基于Linux实现SSH密钥免密登录完整实战教程(CentOS/Ubuntu通用)
linux·ssh·免密登录·服务器运维·ssh-keygen
有想法的py工程师2 小时前
手工处理 Oracle Cloud ARM 实例在线 DD Rocky Linux 10报错
linux·arm开发·oracle