【Linux 网络编程】网络基础概念(下):IP、端口、Socket 与网络字节序,写代码前先打好基础

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者。

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。


🔥 本文定位:承接上篇的协议栈与传输流程,根据《1 网络基础概念》PDF 第 19~26 页整理 Socket 编程预备知识,并补充字节序和 IPv4 地址初始化的完整示例。

💡 学习目标 :分清 IP、端口、PID 与 Socket;理解 TCP 字节流与 UDP 数据报;会使用 htons()、htonl() 和 inet_pton();能正确准备 sockaddr_in。

📌 代码约定 :正文代码采用 Linux/POSIX C11 接口,IPv4 示例使用 AF_INET。本篇完整代码只演示地址准备、字节观察与本地绑定,不实现聊天或回显服务;后续 UDP/TCP 博客再完成通信程序。


文章目录


一、网络通信为什么最终要交给应用

上篇已经说明,IP 包通过路由到达目的主机。但对用户而言,"到了这台电脑"还不足以完成任务。

聊天消息应交给聊天程序,远程登录数据应交给 SSH 服务,网页响应应交给浏览器。主机上可以同时存在很多进程,内核必须把网络数据送到正确的通信对象。

1.1 IP解决网络层寻址,端口参与传输层分用

在常见 IPv4 通信中,IP 地址用于网络层寻址;到达主机之后,TCP/UDP 的端口参与定位通信端点。

于是,一个常见 IPv4 端点可以写成:

text 复制代码
192.168.10.8:8080

但不要立刻把这个写法理解为"某个唯一 PID"。端口是传输协议中的编号,最终由内核将数据匹配到 Socket,再由持有这个 Socket 的应用读取。

1.2 网络通信与进程间通信的联系

用户通常通过运行中的应用交换数据,因此从应用角度看,网络通信是进程间通信的一种重要形式。

不过,协议栈中还存在并不由普通应用直接接收的控制报文。例如 ICMP 错误消息可能先被内核处理。我们接下来讨论的是典型 TCP/UDP 应用通信的路径。

沿着这一条链理解更稳妥:

text 复制代码
网络到达与协议判断
    → 传输层端点匹配
    → 对应的 Socket 接收缓冲区
    → 应用读取并解释数据

二、IP端口与PID各自标识什么

这几个概念都能回答某种"是谁",但它们处在不同层次,不能互相代替。

2.1 IPv4与IPv6地址

IPv4 地址是 32 位,IPv6 地址是 128 位。IPv4 常见写法是四组十进制数,每组为 0~255:

text 复制代码
192.168.10.8

一个主机可以有多个网络接口和多个 IP 地址。私有地址也可能在不同隔离网络重复使用。因此在编程和排错时,需要把地址放在具体网络、接口和路由上下文中理解。

2.2 端口号是16位编号

TCP 和 UDP 的源端口、目的端口字段都是 16 位,可表示 0~65535。

常用端口分类为:

范围 IANA 分类 入门理解
0~1023 System Ports 系统/知名端口范围
1024~49151 User Ports 用户/注册端口范围
49152~65535 Dynamic / Private Ports 动态/私有端口范围

以上分类来自 IANA 服务名与端口号登记表。这是登记分类,不等于操作系统必定从第三段分配临时端口。Linux 的临时端口范围是可配置的。

例如 HTTP 常用 TCP 80,HTTPS 常用 TCP 443,SSH 常用 TCP 22。服务也可以在其他端口运行;应用协议与某个端口之间是常见约定,而不是永远不能改变的绑定。

在绑定时填写端口 0,通常表示请求系统分配一个可用端口。分配完成后要用 getsockname() 查询实际结果,不能认为服务会一直在"端口 0"上等待通信。

2.3 PID为何不能直接代替端口

PID 是操作系统管理进程的编号。它用于进程调度、信号、资源管理等本地系统功能。

端口是通信协议和 Socket 寻址的一部分。客户端要知道的是服务端提供哪个通信端点,不必知道服务端进程启动后得到了哪个 PID。

概念 属于哪个语境 典型用途
IP 地址 网络层寻址 决定包到达哪里
端口号 TCP/UDP 等传输层 参与区分服务与通信端点
PID 操作系统进程管理 找到本地进程
文件描述符 fd 进程内的资源引用 读写一个已打开的 Socket

同一个数字可以在不同语境中重复。例如两个不同进程都可能拥有 fd 3,这不意味着它们引用同一个 Socket。

2.4 端口与进程不是绝对的一对一

PDF 用"一个端口对应一个进程"建立直觉,但把它当作严格规则会影响后面的服务器设计。

实际有多种情况:

  • 一个进程可以创建多个 Socket,使用多个端口;
  • TCP 和 UDP 可以分别使用同一个数值端口;
  • 不同本地 IP 上,同一个数值端口的绑定是否冲突,要结合绑定地址判断;
  • fork() 后多个进程可以继承同一个 Socket;
  • Linux 的 SO_REUSEPORT 在满足条件时允许多个 Socket 绑定相同地址。

最后一项可查 socket(7) 的 SO_REUSEPORT 说明。因此,绑定冲突需要结合传输协议、本地地址、网络命名空间和复用规则理解,不能只比较一个端口数字。

初学代码先采用最简单的约定:一个服务器在指定本地地址和端口绑定一个 Socket,先把基本流程写清楚,后续再讨论复用和多进程服务。


三、Socket地址对象与连接四元组

Socket 常译作"套接字"。如果只把它记成 IP + Port,会漏掉代码里非常重要的一层。

3.1 区分Socket地址与Socket对象

Socket 地址 描述端点位置。例如 IPv4 的 sockaddr_in 包含地址族、IP 和端口。

Socket 对象是操作系统管理的通信对象,还包含类型、状态、缓冲区等信息。Linux 程序通过文件描述符引用它。

c 复制代码
int fd = socket(AF_INET, SOCK_DGRAM, 0);

调用成功后返回非负文件描述符,失败时返回 -1 并设置 errno。此时 fd 不是 IP,也不是端口;它是程序操作 Socket 的句柄。

socket() 刚创建对象时,并不表示应用已经明确绑定了一个服务地址。地址准备和 bind() 是后面的动作。

3.2 两个通信端点如何描述

考虑一个 TCP 客户端访问服务器:

text 复制代码
客户端:192.168.10.8:53000
服务器:192.168.20.9:8080

对客户端发出的方向,可以列出四元组:

text 复制代码
{源 IP,源端口,目的 IP,目的端口}

在给定协议和网络上下文中,TCP 连接通常用这四个地址信息区分。若同时讨论 TCP、UDP 等不同协议,则还要加入传输协议,形成常说的五元组。

3.3 服务器为什么能让很多客户端都连接同一个端口

假设服务器监听 192.168.20.9:8080,两个客户端分别从不同端点连接:

text 复制代码
连接一:192.168.10.8:53000 → 192.168.20.9:8080
连接二:192.168.10.8:53001 → 192.168.20.9:8080

它们的服务器端口相同,但四元组不同。服务器通过监听 Socket 接收连接,再使用 accept() 返回的已连接 Socket 与各个客户端通信。

所以,一个 TCP 服务端口不会只能接待一个客户端。监听端口与一条具体连接的识别方式不能混为一谈。

3.4 Socket不只用于IPv4网络

Socket 接口还支持 IPv6、UNIX domain socket 等地址族。UNIX domain socket 可以用本地路径等信息描述地址,并不以 IPv4 地址和端口作为必需内容。

因此,"IP + Port"适合描述常见 IP 通信端点,不能作为 Socket 的完整定义。这也解释了后面 API 为什么接收通用的 sockaddr *。


四、TCP与UDP的第一张对照表

这部分先建立编程所需的基本认识。连接状态、拥塞控制、重传算法和报头细节,会在后面的专门文章里展开。

维度 TCP UDP
所在层次 传输层 传输层
常见 Socket 类型 SOCK_STREAM SOCK_DGRAM
通信模型 有连接 无连接
接口交付特点 有序字节流 数据报
消息边界 不保留应用的发送边界 保留数据报边界
协议自带能力 确认、重传、流量与拥塞控制等 不提供 TCP 式可靠有序传输

4.1 TCP的"可靠"具体意味着什么

TCP 为连接提供可靠、有序的字节流。它通过序号、确认和重传等机制处理网络中常见的丢失和乱序问题。

但不能由此推断"调用 send() 成功,就说明对方业务已经处理完"。成功发送可能只是本地内核接受了这些字节,应用还可能稍后发现断线。

即使对方 TCP 收到了数据,对方应用也可能还没读取,更没有完成数据库写入。若业务要求确认结果,应在应用协议中定义响应或确认消息。

4.2 TCP为什么需要应用层自己划分消息

客户端依次发送两段数据:

text 复制代码
第一次:ABC
第二次:DEF

接收方可能一次读到 ABCDEF,也可能先读到 AB,再读到 CDEF。TCP 保证字节顺序,但不会为应用保留这两次调用的分界线。

因此接收程序要根据应用协议识别消息,例如使用固定长度、长度字段、分隔符等方案。不能把"一次 recv()"直接当成"一条业务消息"。

4.3 UDP的"不可靠"不等于不能用

UDP 本身不保证数据报一定到达,也不保证按发送顺序到达,更不自动为应用补齐丢失的数据报。

但它保留数据报边界,接口简单,应用可以按照需求自行处理超时、序号和重试。选型取决于业务,而不是把"可靠"看作所有场景中的唯一标准。

收到一个数据报时,接收缓冲区仍要足够大。若缓冲区不足,数据报可能被截断,剩余内容不能像 TCP 字节流那样等着下一次继续读取。

4.4 无连接UDP为什么也能调用connect

对 UDP Socket 调用 connect(),通常是设置默认对端,并限制接收来源等,并不建立 TCP 那样的握手连接。

所以,不能仅根据源码出现 connect(),就判断应用一定用了 TCP。应结合地址族、Socket 类型和协议一起看。


五、网络字节序究竟在转换什么

现在考虑一个更直接的问题:同一个整数,在不同计算机内存里可能采用不同的字节排列,怎么保证对方解释一致?

5.1 先把数值与字节排列分开

整数 0x12345678 的数值是确定的,但它在内存中由四个字节表示时,排列可能不同:

内存地址从低到高 第 1 字节 第 2 字节 第 3 字节 第 4 字节
大端序 12 34 56 78
小端序 78 56 34 12

这里所有字节值均为十六进制。大端是高有效字节放在低地址,小端则是低有效字节放在低地址。

5.2 线上的关键事实是字节先后顺序

网络并不传递发送方的内存地址,它传递的是按顺序排列的字节。接收方必须知道如何把这些字节还原成字段。

常见 TCP/IP 协议的多字节整数采用网络字节序,即大端序。若字段表示 0x12345678,按这个约定编码后就是:

text 复制代码
12 34 56 78

本地内存是大端还是小端,不应该成为对端理解协议的前提。双方都先按规范编码,再按规范解码。

5.3 不是所有网络数据都要"转成大端"

PDF 的"所有发送到网络上的数据都必须大端"需要加上范围:字节序主要涉及协议规定的多字节数值字段。

如果发送 ASCII 文本 hello,每个字符已经是一个字节,原有顺序就是文本内容。不能因为网络字节序是大端,就把整个字符串倒过来。

类似地:

  • UTF-8 文本要遵守 UTF-8 编码规则;
  • 图片、音频等要遵守对应文件或业务格式;
  • 自定义二进制协议要明确规定各个数值字段的表示;
  • 某些应用协议可以约定小端整数,但双方必须遵守同一格式。

字节序转换不是把整个缓冲区翻转,而是按字段完成表示转换。

5.4 操作系统会不会自动转换业务正文

TCP/UDP 协议栈会生成它们自己的标准首部,但不会替应用理解并转换每个自定义整数。

例如你把一个本地 uint32_t 的内存直接传给发送函数,Socket 并不知道这四个字节表示"人数""价格"还是普通二进制内容。它把这些字节当作业务载荷处理。

因此,应用层协议中的整数要由应用主动编码。sin_port 等 Socket 地址字段则按 API 要求填写网络字节序。


六、四个转换函数与字节观察实验

Linux 可以使用 <arpa/inet.h> 提供的转换函数:

c 复制代码
#include <arpa/inet.h>

uint16_t htons(uint16_t hostshort);
uint32_t htonl(uint32_t hostlong);
uint16_t ntohs(uint16_t netshort);
uint32_t ntohl(uint32_t netlong);

6.1 函数名怎样记

函数 方向 字段宽度
htons host → network 16 位
htonl host → network 32 位
ntohs network → host 16 位
ntohl network → host 32 位

h 是 host,n 是 network,to 表示方向,s 与 l 对应这些 API 的历史命名。

要特别注意:这里 l 对应的是 32 位,不是"当前平台 sizeof(long) 有多少位就转换多少位"。Linux LP64 环境下,C 的 long 可以是 64 位,这些函数的参数和返回类型仍然如原型所示。参见 byteorder(3)。

6.2 通常不要自己判断机器大小端后再决定是否调用

下面这种写法更合适:

c 复制代码
uint16_t port = 8080;
uint16_t network_port = htons(port);
uint16_t restored_port = ntohs(network_port);

在大端主机上,转换可能保持字节不变;在小端主机上,转换会调整字节排列。调用者不需要为每种机器写分支。

转换函数不会改变原变量,它们返回转换结果。只调用 htons(port); 而不使用返回值,port 本身不会因此变为网络序。

6.3 用unsigned char观察字节

下面的完整程序保存为 code/byte_order_demo.c:

c 复制代码
#include <arpa/inet.h>
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>

static void print_bytes(const char *label, const void *data, size_t len)
{
    const unsigned char *bytes = data;
    printf("%-12s", label);
    for (size_t i = 0; i < len; ++i)
        printf(" %02X", (unsigned int)bytes[i]);
    putchar('\n');
}

int main(void)
{
    const uint32_t host_value = UINT32_C(0x12345678);
    const uint32_t network_value = htonl(host_value);
    unsigned char wire[sizeof network_value];

    memcpy(wire, &network_value, sizeof wire);
    print_bytes("host bytes:", &host_value, sizeof host_value);
    print_bytes("wire bytes:", wire, sizeof wire);

    uint32_t received_value;
    memcpy(&received_value, wire, sizeof received_value);
    printf("decoded: 0x%08" PRIX32 "\n", ntohl(received_value));
    return 0;
}

Linux 编译和运行方式:

bash 复制代码
gcc -std=c11 -Wall -Wextra -Wpedantic byte_order_demo.c -o byte_order_demo
./byte_order_demo

在常见小端 Linux 主机上,预期输出为:

text 复制代码
host bytes:  78 56 34 12
wire bytes:  12 34 56 78
decoded: 0x12345678

在大端主机上,第一行也会是 12 34 56 78。无论主机字节序如何,后两行应符合上述网络格式和还原结果。

6.4 为什么使用memcpy,而不是强转缓冲区

不要为了方便就这样读取:

c 复制代码
/* 不建议这样解析任意字节缓冲区 */
uint32_t value = *(const uint32_t *)buffer;

buffer 的位置可能未按 uint32_t 对齐,这种访问也可能违反类型别名规则。先用 memcpy() 复制到正确类型的局部变量,再做字节序转换,是更稳妥的做法。

应用协议还需要先检查缓冲区是否有足够的字节。安全的表示转换不能替代边界检查。

6.5 8080为什么在线上表示为1F 90

十进制 8080 等于十六进制 0x1F90。使用 htons(8080) 后,观察这两个字节应得到:

text 复制代码
1F 90

在小端机器上,如果把转换后的结果当作本地整数打印,可能看到一个"交换后"的数值。理解转换应观察内存中的字节排列,而不是期待转换前后用 %u 打印的数字始终相同。


七、sockaddr与sockaddr_in

Socket 接口需要支持不同地址族,因此不能要求所有地址都长成"IPv4 + 端口"。

7.1 sockaddr是API使用的通用地址类型

例如 bind() 的接口是:

c 复制代码
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

它可以用于不同地址族。地址族字段让系统知道传入地址如何解释,长度参数说明实际对象大小。

使用 IPv4 时,我们构造 struct sockaddr_in;使用 IPv6 时构造 struct sockaddr_in6。不要把 IPv4 地址的文本字符串直接写进通用结构的 sa_data。

7.2 sockaddr_in主要有哪些字段

关键字段可概括为:

c 复制代码
struct sockaddr_in
{
    sa_family_t    sin_family; /* AF_INET */
    in_port_t      sin_port;   /* 16 位端口,网络字节序 */
    struct in_addr sin_addr;   /* IPv4 地址,网络字节序 */
    /* 实现还可能包含填充等成员,以系统头文件为准 */
};

struct in_addr
{
    in_addr_t s_addr;          /* 32 位 IPv4 地址 */
};

这是用来说明成员含义的结构摘录,不要在自己的程序里重新定义系统结构。真实定义来自 <netinet/in.h>。

Linux 常见布局包括填充成员,但写程序不应依赖"地址类型一定占两字节,整个结构一定是我背下来的大小"。正确方式是用系统头文件和 sizeof。

7.3 这三个字段的填写规则不同

字段 设置方法 字节序要求
sin_family AF_INET 本地 API 的地址族标记,不用 htons()
sin_port htons(port) 网络字节序
sin_addr inet_pton() 或适当的数值转换 网络字节序

sin_port 与 sin_addr 的要求可查 sockaddr_in(3type)。不能看到"网络地址结构",就把其中每个字段都做一次 hton*()。

7.4 为什么调用时要转换成sockaddr指针

c 复制代码
struct sockaddr_in local;
/* 初始化 local */

if (bind(fd, (const struct sockaddr *)&local, sizeof local) == -1)
{
    perror("bind");
}

强转改变的是传给通用接口的指针类型,不会把整个对象改造成另一个结构,也不会自动转换字节序。

sizeof local 仍是实际 IPv4 地址对象的大小。地址族、对象内容和长度必须彼此匹配。


八、正确初始化一个IPv4地址

先把一个地址对象准备正确,再调用通信接口,可以避免不少一运行就报错的问题。

8.1 先初始化对象,再分别设置字段

c 复制代码
struct sockaddr_in peer;
memset(&peer, 0, sizeof peer);

peer.sin_family = AF_INET;
peer.sin_port = htons(8080);

int rc = inet_pton(AF_INET, "192.168.20.9", &peer.sin_addr);
if (rc != 1)
{
    /* rc == 0:地址文本无效;rc == -1:地址族等参数错误 */
}

这里清零是常见的初始化写法,避免对象中留下未明确设置的内容。然后每个字段按其规则填写。

8.2 inet_pton解决文本与二进制地址转换

"192.168.20.9" 是给人阅读的字符串,而 sin_addr 保存二进制 IPv4 地址。

inet_pton() 将地址文本解析到目标对象,返回:

  • 1:成功;
  • 0:文本不符合地址格式;
  • -1:参数中的地址族等不被支持,并设置 errno。

成功时,它写出的地址已经是网络字节序,不要接着再对 sin_addr.s_addr 调用 htonl()。这点在 inet_pton(3)中有明确说明。

rc == 0 时,不应直接用 perror() 当作地址格式错误说明,因为它并不代表此次调用设置了相应 errno。应自己输出"无效地址文本"。

8.3 接收展示地址时,做相反方向的转换

c 复制代码
char text[INET_ADDRSTRLEN];
if (inet_ntop(AF_INET, &peer.sin_addr, text, sizeof text) == NULL)
{
    perror("inet_ntop");
}
else
{
    printf("%s:%u\n", text, (unsigned int)ntohs(peer.sin_port));
}

IP 用 inet_ntop() 从二进制转成可阅读文本;端口用 ntohs() 还原为本地主机数值后再打印。

不要试图用 ntohl() 代替 IP 文本格式化,它只返回整数,并不生成点分十进制字符串。

8.4 两个常见特殊地址

**127.0.0.1**是常用 IPv4 回环地址,可让本机程序通过协议栈进行通信。只绑定回环地址的服务,通常不会通过外部网络接口被访问到。

**0.0.0.0**在本地绑定中常表示 INADDR_ANY,即不限定某个具体本地 IPv4 接口地址。它不是让客户端拿去连接的远程服务器地址。

绑定任意本地地址常写成:

c 复制代码
local.sin_addr.s_addr = htonl(INADDR_ANY);

INADDR_ANY 的值为零,转换后还是零,但统一的数值初始化写法有利于理解。若使用 inet_pton() 解析文本地址,则遵守"已经是网络序,不重复转换"的规则。

绑定地址应属于本机,或者是该 API 支持的通配地址。把远方服务器的 IP 交给本机 bind(),并不会让本机成为那台服务器。


九、Socket接口的职责与调用顺序

PDF 列出了 socket()、bind()、listen()、accept()、connect()。现在把它们放回具体流程。

9.1 先记职责,不急着背完整程序

接口 主要用途 初学者要注意
socket() 创建通信对象 成功返回 fd,失败返回 -1
bind() 关联本地地址 参数是本地地址
listen() 让流式 Socket 等待连接 常用于 TCP 服务端
accept() 取出已建立连接 返回新的已连接 fd
connect() 设置/连接对端 TCP 建立连接,UDP 语义不同
send() / recv() 已指定对端的收发 TCP 可能只处理部分字节
sendto() / recvfrom() 带地址的数据报收发 常用于 UDP
close() 关闭文件描述符 不再需要时释放本地引用

返回值有不同意义。例如 accept() 返回文件描述符,接收函数返回字节数,不能统一认为"非零都是成功"。后续每个接口都要按其定义处理。

9.2 UDP服务端与客户端的典型路径

text 复制代码
UDP 服务端:socket → bind → recvfrom / sendto → close
UDP 客户端:socket → sendto / recvfrom → close

客户端不显式调用 bind() 时,内核通常会在需要时分配本地地址信息和临时端口。若业务要求固定本地端口,也可以主动绑定。

UDP 的基本收发不使用 TCP 的 listen() 与 accept()。它没有"先接受一条 TCP 连接,再拿到连接 fd"的过程。

9.3 TCP服务端与客户端的典型路径

text 复制代码
TCP 服务端:socket → bind → listen → accept → 收发 → close
TCP 客户端:socket → connect → 收发 → close

这里服务器存在两个不同对象:

  • 监听 Socket:等待新连接;
  • 已连接 Socket :由 accept() 返回,用于和特定客户端通信。

关闭一条已连接 Socket 不代表关闭监听 Socket;服务器仍可以继续接受其他客户端。

9.4 创建IPv4 TCP与UDP Socket

c 复制代码
int tcp_fd = socket(AF_INET, SOCK_STREAM, 0);
int udp_fd = socket(AF_INET, SOCK_DGRAM, 0);

在这个常见 IPv4 场景中,第三个参数填 0 表示让系统为地址族和类型选择默认协议。也可以使用相应协议常量明确指定。

如果调用失败,不要继续对 -1 执行 bind() 或 connect()。先检查错误,再决定退出、重试或回收资源。

9.5 先分清本地地址与对端地址

这是初学 Socket 最值得反复检查的地方:

text 复制代码
bind(fd, local, ...)     :这个 Socket 在本机用什么地址
connect(fd, peer, ...)   :这个 Socket 要联系哪个对端

服务器通常绑定自己的服务地址;客户端连接服务器的地址。虽然它们都用 sockaddr_in 表示,但变量承担的角色完全不同。


十、完整地址与本地绑定示例

下面把地址转换、对象创建、绑定、查询实际地址和资源释放放在一个程序里。源码同时保存在 code/ipv4_address_demo.c。

这个例子绑定回环地址和端口 0,由系统选择可用端口;它没有发送数据,也不会持续等待客户端。这样能把观察重点放在地址结构和 API 参数上。

10.1 完整源码

c 复制代码
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

static int make_ipv4_address(struct sockaddr_in *addr,
                             const char *ip, unsigned short port)
{
    memset(addr, 0, sizeof *addr);
    addr->sin_family = AF_INET;
    addr->sin_port = htons(port);

    int rc = inet_pton(AF_INET, ip, &addr->sin_addr);
    if (rc == 1)
        return 0;
    if (rc == 0)
        fprintf(stderr, "Invalid IPv4 address: %s\n", ip);
    else
        perror("inet_pton");
    return -1;
}

static int show_ipv4_address(const char *label,
                             const struct sockaddr_in *addr)
{
    char text[INET_ADDRSTRLEN];
    if (inet_ntop(AF_INET, &addr->sin_addr, text, sizeof text) == NULL)
    {
        perror("inet_ntop");
        return -1;
    }
    printf("%s%s:%u\n", label, text,
           (unsigned int)ntohs(addr->sin_port));
    return 0;
}

int main(int argc, char **argv)
{
    if (argc > 2)
    {
        fprintf(stderr, "Usage: %s [peer-ipv4]\n", argv[0]);
        return EXIT_FAILURE;
    }

    const char *peer_ip = argc == 2 ? argv[1] : "192.168.20.9";
    struct sockaddr_in peer;
    if (make_ipv4_address(&peer, peer_ip, 8080) == -1)
        return EXIT_FAILURE;
    if (show_ipv4_address("Prepared peer: ", &peer) == -1)
        return EXIT_FAILURE;

    struct sockaddr_in local;
    if (make_ipv4_address(&local, "127.0.0.1", 0) == -1)
        return EXIT_FAILURE;

    int fd = socket(AF_INET, SOCK_DGRAM, 0);
    if (fd == -1)
    {
        perror("socket");
        return EXIT_FAILURE;
    }

    int status = EXIT_FAILURE;
    if (bind(fd, (const struct sockaddr *)&local, sizeof local) == -1)
    {
        perror("bind");
        goto cleanup;
    }

    struct sockaddr_in actual;
    memset(&actual, 0, sizeof actual);
    socklen_t actual_len = sizeof actual;
    if (getsockname(fd, (struct sockaddr *)&actual, &actual_len) == -1)
    {
        perror("getsockname");
        goto cleanup;
    }

    if (show_ipv4_address("Bound local: ", &actual) == -1)
        goto cleanup;
    status = EXIT_SUCCESS;

cleanup:
    if (close(fd) == -1)
    {
        perror("close");
        status = EXIT_FAILURE;
    }
    return status;
}

10.2 编译与预期输出

进入 code 目录后,在 Linux 上运行:

bash 复制代码
gcc -std=c11 -Wall -Wextra -Wpedantic ipv4_address_demo.c -o ipv4_address_demo
./ipv4_address_demo

预期输出形式为:

text 复制代码
Prepared peer: 192.168.20.9:8080
Bound local: 127.0.0.1:实际分配的端口

第一行只是准备并展示一个对端地址,没有尝试连接它;第二行是系统实际完成的本地绑定。临时端口由当前系统选择,不应在示例中写死。

可以验证错误文本路径:

bash 复制代码
./ipv4_address_demo 999.1.1.1

预期报告无效 IPv4 地址并以失败状态退出。地址无效时,程序不会继续创建和绑定 Socket。

验证说明:本次制作环境为 Windows,未运行这些 Linux C 程序。这里给出可供 Linux 环境复现的源码与预期现象;字节转换和地址往返规则已用本地等价实验核对,不能把等价实验当作 Linux 编译运行记录。

10.3 getsockname的长度参数为什么要先初始化

actual_len 是输入输出参数:调用前告诉系统缓冲区容量,调用后接收实际地址长度。因此必须先写:

c 复制代码
socklen_t actual_len = sizeof actual;

这个模式也会出现在 accept()、recvfrom() 等获取地址的接口中。不要使用未初始化的长度变量,否则系统不知道你允许它写入多少内容。

10.4 用只读命令观察系统的真实状态

在后续运行持续工作的服务端时,可以使用:

bash 复制代码
# Linux 当前临时端口范围
cat /proc/sys/net/ipv4/ip_local_port_range

# 查看 TCP 监听与 UDP Socket
ss -ltn
ss -lun

# 查看 TCP 连接;进程信息可能需要相应权限
ss -tnp

本篇的绑定示例会立即关闭 Socket,所以程序退出后通常无法再在 ss 中看到它。观察监听和连接应配合后续持续运行的服务器示例。


十一、常见错误与排查清单

11.1 字节序相关错误

错误写法或想法 问题 正确方向
sin_port = 8080 没有满足网络字节序要求 sin_port = htons(8080)
htons(port); 返回值被丢弃 把返回值赋给目标字段
sin_family = htons(AF_INET) 地址族不是网络字段 直接赋值 AF_INET
inet_pton() 后再 htonl() 重复转换已是网络序的地址 直接使用解析结果
对整个字符串反转 字符串不是单个多字节整数 按文本编码传递
htonl() 处理 64 位数据 该接口只转换 32 位 使用协议明确的 64 位编码方案
直接强转任意字节缓冲区 可能有对齐和别名问题 检查长度,再 memcpy()

11.2 地址与接口相关错误

只看到EADDRINUSE就认定"某个进程占了端口"

地址已使用可能与绑定地址、协议、复用选项和连接状态有关。先查看真实 Socket 状态与代码设置,不要只根据端口数字下结论。

把远方服务器IP交给本机bind

bind() 关联本地地址,connect() 才描述对端。检查变量到底代表 local 还是 peer。

认为accept返回的fd就是原监听fd

accept() 成功时返回新的已连接 Socket 文件描述符。监听对象仍然负责接收后续连接。

把网络序端口直接打印给用户

展示之前使用 ntohs()。如果日志里端口与配置不一致,先检查是否漏了还原。

忽略inet_pton的返回值

无效地址文本不能继续当作正确地址使用。区分返回 0 与返回 -1,分别处理格式错误和系统错误。

11.3 用六个问题进行自测

  1. TCP 与 UDP 都使用数值端口 8080,一定冲突吗? 不一定,它们属于不同传输协议。
  2. 创建 Socket 成功后,返回值等于服务端口吗? 不是,返回的是文件描述符。
  3. 服务器监听 8080,能否同时处理多个客户端? 可以,具体 TCP 连接通过不同端点组合区分。
  4. TCP 两次发送一定对应两次接收吗? 不一定,应用要自行识别消息边界。
  5. inet_pton() 成功后还要手动改成网络序吗? 不需要,它已按接口要求输出网络序地址。
  6. 绑定端口 0 以后怎样知道真实端口? 调用 getsockname(),再用 ntohs() 读取端口值。

总结

准备写 Socket 程序时,先把五组关系理顺:

  • IP 地址与端口:网络层寻址与传输层端点识别;
  • 端口与 PID:网络通信编号与本地进程管理编号;
  • Socket 地址与对象:端点描述与内核通信对象;
  • TCP 与 UDP:字节流与数据报接口的不同约定;
  • 本地表示与线上表示:多字节字段必须按协议规定编码。

然后把这些认识落到一段可检查的初始化流程里:

text 复制代码
明确地址角色
    → 初始化 sockaddr_in
    → 设置 AF_INET
    → htons 转换端口
    → inet_pton 解析 IP
    → 检查返回值
    → 把真实对象地址与大小交给 API
    → 使用完关闭 Socket

这条流程看起来基础,却决定了后面 UDP 和 TCP 程序能否正确起步。下一份 PDF 将进入 Socket 编程 UDP,把这些字段和函数真正组合成客户端与服务器。

相关推荐
刚入门的大一新生1 小时前
Linux-Ext系列文件系统
linux·运维·服务器
无足鸟ICT2 小时前
【RHCA+】select循环
linux
SKH.2 小时前
Linux驱动-led、beep
linux·运维·单片机
zbtlink2 小时前
网络卡在哪儿:空口时间、会话表容量与接入速率的可复现定位方法
网络·智能路由器
CCblandy2 小时前
Airllm 大模型本地化部署与高效推理应用指南
大数据·网络·人工智能
碧海银沙音频科技研究院3 小时前
炬芯ATS308X的扩音器:麦克风到扬声器直通功能实现
java·linux·运维
睡一觉就好了。3 小时前
序列化与非序列化
linux·网络
m0_715674433 小时前
金融数据流转监测排行:从看见到拦截的距离
大数据·网络·安全·网络安全·金融