目录
注:本文章内容均来自本人的学习笔记为个人学习总结,禁止转载。
参考自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只有几十个),但速度是计算机里最快的。
数据搬运的完整流程:
-
程序运行时,操作系统会把程序代码和数据从硬盘 复制到内存。
-
CPU 执行一条指令,比如"把内存地址1000的数值+1"。
-
CPU 会先把内存地址1000的数值读到自己的寄存器里。
-
CPU 在寄存器里完成 +1 操作。
-
CPU 再把寄存器里计算好的新数值写回到内存地址1000。
三、为什么不能把所有东西都放CPU里,或者全用最快的技术?
这是一个经典的工程权衡问题,核心在于 速度和成本。
-
为什么数据不能全放CPU里?
成本:CPU里的寄存器(以及L1/L2/L3缓存)技术,速度极快,但成本也极高、功耗大。1MB的CPU缓存比16GB的内存条贵得多。所以只能做到KB或MB级别。
-
为什么不用内存替代硬盘?
断电即失 :你关掉电脑,内存里的所有数据就会消失。我们需要硬盘来持久化 存储。成本:内存(SRAM/DRAM)的成本依然远高于硬盘(闪存/机械),且单位容量的耗电量也更大。
这个速度、成本、容量的金字塔结构是计算机体系的基础:
最快、最贵、最小:CPU寄存器
很快、较贵、较小:CPU缓存 (L1/L2/L3)
快、便宜、较大:内存 (RAM)
慢、很便宜、巨大:硬盘 (SSD/HDD)
总结
-
内存是CPU的"工作台" :所有正在运行的程序和数据,必须放在内存里。
-
CPU自己不存数据 :它只通过寄存器 这种极小的"手边工具",来读取、计算、写回内存中的数据。
-
硬盘是"仓库":用来长久保存暂时不用的数据。
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结构体用的好好的,为什么还要强转为上面的?
- 系统调用的接口设计
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 满天飞。
- 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);
- &servaddr.sin_addr
目标地址:把 IP 复制到 sockaddr_in 的 IP 字段里
- h->h_addr
源地址:来自 gethostbyname 解析出来的 4 字节网络 IP
- 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函数:


就可以成功接收了。