深入 Linux Socket 编程:端口号、网络字节序与 struct sockaddr 详解

目录

[一、Socket 编程预备](#一、Socket 编程预备)

[1.1 理解网络通信](#1.1 理解网络通信)

[1.2 端口号](#1.2 端口号)

理解端口号

端口号范围划分

理解端口号和进程PID

理解源端口号和目的端口号

[1.3 理解套接字 socket](#1.3 理解套接字 socket)

[1.4 传输层的典型代表协议(TCP 和 UDP)](#1.4 传输层的典型代表协议(TCP 和 UDP))

[简单认识 TCP 协议](#简单认识 TCP 协议)

[简单认识 UDP 协议](#简单认识 UDP 协议)

[1.5 网络字节序](#1.5 网络字节序)

[二、Socket 编程接口](#二、Socket 编程接口)

[2.1 socket](#2.1 socket)

[2.2 bind](#2.2 bind)

[2.3 sendto](#2.3 sendto)

[2.4 recvfrom](#2.4 recvfrom)

[2.5 listen](#2.5 listen)

[2.6 accept](#2.6 accept)

[2.7 connect](#2.7 connect)

[三、Socket 相关的额外补充](#三、Socket 相关的额外补充)

[3.1 详谈 struct sockaddr 地址结构](#3.1 详谈 struct sockaddr 地址结构)

[3.2 地址转换函数](#3.2 地址转换函数)

字符串地址转网络字节序地址的函数

网络字节序地址转字符串地址


一、Socket 编程预备

1.1 理解网络通信

在上一节网络基础文章中,我们宏观上理解网络通信的过程和原理,但是我们应该思考一个问题:

主机 A 将数据传输到主机 B 是目的吗?

显然不是,因为数据是给人用的。

例如:聊天是人在聊天;下载资料是人在下载;浏览网页是人在浏览

但是人是怎么看到消息的呢?怎么执行下载任务的呢?怎么浏览网页的呢?

这都是通过启动的聊天软件(qq、微信),下载软件(迅雷),浏览器(谷歌)来完成的,由于我们学过操作系统,对于这些启动的程序在操作系统看来,就是一个个正在执行的进程。

所以,进程是人在系统中的代表,只要将数据交给进程,进程处理完数据交给人类,就实现了人类通过计算机来完成数据交互。

总结:网络通信的本质就是进程间通信。

1.2 端口号

理解端口号

网络进程:需要进行网络通信的进程。

在操作系统中同时存在很多进程,那么当数据到达目标主机之后,操作系统怎么能将数据准确地转发给目标进程呢?

在系统层面上,采用进程的 PID 来标识进程的唯一性;在网络层面上,采用端口号来表示网络进程的唯一性。

端口号(port)是传输层协议的内容,它是一个 2 字节 16 位的整数。端口号用来标识一个网络进程,当数据报文达到系统层面,根据端口号交付给目标进程。

注:IP 地址 + 端口号能够标识网络上的某一台主机的某一个进程。(唯一性)

一个端口号只能被一个进程绑定,一个进程可以绑定多个端口号。

端口号范围划分

0 - 1023 :知名端口号,HTTP、FTP、SSH 等这些广为使用的应用层协议,它们的端口号都是固定的。

1024 - 65535:操作系统动态分配的端口号,客户端程序的端口号,就是由操作系统在该范围内分配的。

理解端口号和进程PID

从技术角度层面考虑,能采用进程 PID 实现端口号的功能吗?

为什么不采用进程 PID 来标识网络进程的唯一性呢?

进程 PID 属于操作系统概念,技术上确实可以用来实现进程 PID 来标识唯一的网络进程,但是这样做,会让操作系统和网络强耦合,不便于维护。

另外:一个进程可以绑定多个端口号;但一个端口号只能被一个进程绑定。

理解源端口号和目的端口号

传输层协议的数据段中有两个端口号,分别叫做源端口号和目的端口号。就是在描述"数据是谁发的,要发给谁"。

1.3 理解套接字 socket

在网络通信中,IP 地址用来标识互联网中唯一的一台主机,port 用来标识该主机上唯一的一个网络进程。即:IP 和 port 就能标识互联网中唯一的一个网络进程。

网络通信的本质就是两个互联网中为二的两个网络进程进行进程间通信({srcIP,srcPort,dstIP,dstPort})

我们把 IP + port 叫做套接字 socket

1.4 传输层的典型代表协议(TCP 和 UDP)

在上一节网络基础文章中,我们知道传输层是属于内核的,是操作系统所管理的,那么我们要通过网络协议栈进行网络通信,用户必定调用的是传输层提供的系统调用,来进行网络通信。

简单认识 TCP 协议

此处我们先对 TCP(Transmission Control Protocol,传输控制协议)有一个直观认识,后面再详细理解 TCP 协议

TCP 协议的特点

cpp 复制代码
传输层协议
有连接
可靠传输
面向字节流

简单认识 UDP 协议

此处我们先对 UDP(User Datagram Protocol,用户数据报协议)有一个直观认识,后面再详细理解 UDP 协议

UDP 协议的特点

cpp 复制代码
传输层协议
无连接
不可靠传输
面向数据报

1.5 网络字节序

在学习 C/C++ 语言的时候,大家都知道一个整形变量(int)占用 4 字节的内存空间,可对于不同的内存条,有的内存条将低权值位的字节数据放到低地址处,这种被称为小端,有的内存条将高权值位的字节数据放到高地址处,这种被称为大端。即内存中的多字节数据相对于内存地址有大端和小端之分。其实磁盘文件的多字节数据相对于文件中的偏移地址也有大端和小端之分,而网络是后来者且冯诺依曼体系结构告诉我们:数据流动的本质是拷贝!所以网络数据也有大端和小端之分

而发送主机通常将发送缓冲区的数据按内存地址从低到高的顺序发送;接受主机把网络数据拷贝到接受缓冲区中,也是按内存地址从低到高的顺序拷贝。

因此,网络数据流的地址被规定为:先发送的数据是低地址,后发送的数据是高地址。TCP/IP 协议规定,网络数据流应采用大端字节序,即低地址高字节的方式发送数据。

所以不管当前主机是大端机还是小端机,都应遵守 TCP/IP 协议规定的网络字节序来发送或接受数据。

如果当前发送主机是小端,就需要先将数据转化成大端,如果当前发送主机是大端,可以忽略该情况的考虑,直接发送即可。

为了方便用户操作,C 语言提供了一些库函数,用来进行网络字节序和主机字节序的转化。

cpp 复制代码
#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 表示 long 32位长整数,s 表示 short 16位短整数。

htonl 表示将32位的长整数从主机字节序转化为网络字节序,ntohs 表示将16位的短整数从网络字节序转化为主机字节序。

这些函数内部会帮我们判断当前主机是大端机还是小端机,如果是大端机,这些函数不做转换,将参数原封不动低返回,如果是小端机,这些函数将参数做相应的大小端转换后返回。

核心一句话 + 四个接口:所有发送到网络上的数据都必须以大端字节序来发送

二、Socket 编程接口

2.1 socket

cpp 复制代码
#include <sys/types.h>      
#include <sys/socket.h>

int socket(int domain, int type, int protocol)

功能:创建一个套接字文件描述符,后续通过该文件描述符进行网络通信。

返回值:成功返回一个套接字文件描述符,失败返回 -1。

返回值为什么是一个文件描述符?

socket 返回文件描述符,是因为 Linux 将网络通信抽象为文件对象。调用 socket 创建套接字时,内核会创建 struct socket、协议控制块以及对应的 struct file,并将该 file 对象分配到进程的文件描述符表中,最终返回 fd。用户通过 fd 间接访问内核中的 socket 对象,从而使用统一的文件接口完成网络通信。

参数:

domain:表示通信使用的协议族

常见的协议族:

IPv4

cpp 复制代码
AF_INET

表示:使用 IPv4地址族

例如:192.168.1.100

对应结构:

cpp 复制代码
struct sockaddr_in
{
    sa_family_t sin_family;
    uint16_t sin_port;
    struct in_addr sin_addr;
};

这是 TCP/UDP 网络编程最常用的。

IPv6

cpp 复制代码
AF_INET6

表示:使用 IPv6 地址族

例如:2001:db8:100:1

对应结构:

cpp 复制代码
struct sockaddr_in6

Unix 域套接字

cpp 复制代码
AF_UNIX

用于:同一台机器上的进程间通信

例如:

cpp 复制代码
进程A
 |
socket文件
 |
进程B

它不经过网络协议栈

domain 用途
AF_INET IPv4网络
AF_INET6 IPv6网络
AF_UNIX 本机进程通信

type:决定通信方式

SOCK_STREAM 表示字节流套接字

对应 TCP 协议

特点:

有连接:通信前需要建立连接

可靠:TCP 保证数据不丢,不乱序,不重复

典型:HTTP、SSH、FTP、数据库连接
SOCK_DGRAM 表示数据包套接字

对应 UDP 协议

特点:

无连接:直接发送数据,不需要建立连接

不可靠:数据存在丢失可能

典型:DNS、视频直播、游戏通信

protocol 表示具体协议

一般填 0,表示根据前两个参数自动选择默认协议

创建TCP套接字

cpp 复制代码
socket(AF_INET, SOCK_STREAM, 0);

创建UDP套接字

cpp 复制代码
socket(AF_INET, SOCK_DGRAM, 0);

2.2 bind

cpp 复制代码
#include <sys/types.h>       
#include <sys/socket.h>

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

功能:把一个 socket 绑定到一个具体的网络地址(IP + Port)上。

返回值:成功返回 0, 失败返回 -1,并设置错误码。

参数:

sockfd:socket 文件描述符

addr:要绑定的地址结构

addrlen:地址结构的大小

为什么 socket 创建后还需要 bind?

当我们调用 socket 执行后,内核为我们创建了套接字,但是这个套接字还没有 IP 地址 和端口号,就无法被其他主机找到,进而无法实现网络通信。

bind 就是给套接字分配网络身份。

sockdrr 结构

cpp 复制代码
struct sockaddr
{
    sa_family_t sa_family;
    char sa_data[14];
};

实际开发不会用到它,通常使用:struct sockaddr_in IPv4专用

cpp 复制代码
/* 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)];
  };

/* 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

/* Type to represent a port.  */
typedef uint16_t in_port_t;

/* Internet address.  */
typedef uint32_t in_addr_t;
struct in_addr
  {
    in_addr_t s_addr;
  };

bind 的基本用法

cpp 复制代码
struct sockaddr_in addr;

// 设置协议族,告诉内核这是 IPv4地址
addr.sin_family = AF_INET;
// 设置端口
addr.sin_port = htons(8080);
// 设置IP 需要将字符串转化为无符号长整形的网络字节序
// 对于服务端,可以将其 IP 设置为 INADDR_ANY 
// 保证端口号对的情况下,接受来到该主机任何 IP 地址的数据
// 因为一台主机可以存在多个 IP 地址
addr.sin_addr.s_addr = inet_addr("192.168.1.10");


bind(listenfd, (struct sockaddr*)&addr, sizeof(addr));

对于客户端通常不需要主动 bind,那这是为什么呢?

对于服务端,毋庸置疑肯定是要 bind 端口号的,不然客户端怎么能访问到服务端呢,而对于客户端访问服务端,由于客户端和服务端是同一家公司编写的,那么服务端绑定固定端口,而客户端肯定知道服务端的IP地址和端口,自然可以访问到服务端。而对于客户端是没有必要绑定端口号的,设想一下,如果手机上的不同APP都需要绑定端口号,它们来自不同的公司,那么很有可能存在一个客户端能启动,而另一个客户端不能启动的情况。所以客户端通常不需要主动 bind,但客户端是需要 bind 的,只不过操作系统会给客户端的套接字在发送数据的时候,自动将 IP 地址和随机分配的端口号绑定到套接字上。必须要明确一点:绝大数情况下,都是客户端向服务器发送请求,服务器对其进行处理,然后对其返回处理结果,而当服务器接收到客户端发送过来的数据时,通过TCP/IP 协议就能拿到客户端的 IP 和端口号,然后通过该 IP 和端口号,将处理结果进行返回。

2.3 sendto

cpp 复制代码
#include <sys/types.h>
#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);

功能:向套接字发送消息

返回值:发送成功,返回发送消息的字节数,发送失败,返回 -1,并设置错误码。

参数:

sockfd:套接字的文件描述符

buf:要发送的数据的起始地址

len:要发送的数据的大小

dest_addr:指定将数据发送的目标主机的目标网络进程,它里面需要填充协议族、对方的的端口号和 IP 地址

addrlen:dest_addr 结构体的大小

2.4 recvfrom

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);

功能:接收套接字里面的数据

返回值:接收成功,返回接收信息的字节数,接收失败,返回 -1。

参数:

sockfd:套接字文件描述符

buf:输出型参数,用于接收信息

len:buf 要接收的数据大小。注:在 C/C++ 语言中,要注意给 '\0' 留位置。可以采用 sizeof(buf)-1

flags:用于精细地控制接收数据的行为,一般设置为 0。表示其行为与 read 函数基本一致,当接收缓冲区,没有数据进行阻塞。

src_addr:输出型参数,用于接收对方的 IP 地址和端口号

addrlen:即是输入,也是输出,输入表示:期望读取到的字节大小,输出表示:实际读取到的字节大小。

2.5 listen

cpp 复制代码
#include <sys/types.h>        
#include <sys/socket.h>

int listen(int sockfd, int backlog);

功能:用于 TCP 协议来建立连接的功能,作用:监听套接字

返回值:成功返回 0, 失败返回 -1。

参数:

sockfd:指定需要监听的套接字

backlog:指定套接字"已完成连接队列"的最大长度,它本质上是一个上限值。它的实际生效大小还会受到系统级内核参数 net.core.somaxconn 的制约。

它的最终取值为 min(backlog, net.core.somaxconn)。

要理解 backlog,需要了解Linux内核处理连接的方式。(这个参数在讲 TCP 原理的时候详细说)

2.6 accept

cpp 复制代码
#include <sys/types.h>      
#include <sys/socket.h>

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

功能:用于 TCP 协议来建立连接,当服务端将套接字设置为监听状态,客户端请求连接时,客户端的请求会先存放到监听套接字中,服务端调用 accept 才能和客户端真正建立连接,进行网络通信。

返回值:成功,返回一个套接字文件描述符,服务端通过该套接字文件描述符接收和发送消息。

失败,返回 -1,并设置错误码。

参数:

sockfd:指定需要建立连接的监听套接字文件描述符

addr 和 addrlen 与 recvfrom 参数说明中完全一致。

2.7 connect

cpp 复制代码
#include <sys/types.h>         
#include <sys/socket.h>

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

功能:用于 TCP 协议建立连接,客户端通过 connect 将指定套接字文件描述符请求与服务端建立连接。

参数:

sockfd:指定需要与服务端建立连接的套接字文件描述符。

addr 和 addresslen 与 sendto 参数说明中完全一致。

注:在 TCP 协议中,当客户端与服务端成功建立连接后,客户端和服务端均可通过 read 和 write 向接收缓冲区读取数据和向发送缓冲区写入数据。

三、Socket 相关的额外补充

3.1 详谈 struct sockaddr 地址结构

Linux 内核中 struct sockaddr 地址结构

cpp 复制代码
/* Structure describing a generic socket address.  */
struct sockaddr
  {
    __SOCKADDR_COMMON (sa_);	/* Common data: address family and length.  */
    char sa_data[14];		/* Address 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

将其转换之后

cpp 复制代码
struct sockaddr {
    sa_family_t sa_family; /* 地址族,如 AF_INET, AF_INET6, AF_UNIX */
    char        sa_data[14]; /* 协议相关的具体地址数据 */
};

设计本质:类型安全的多态

所有的套接字 API(bind、connect、sendto、recvfrom、accept 等)的地址参数都声明为

struct sockaddr * 类型。

调用时,你无论采用什么协议,你只需要传入一个协议族专用的地址结构的指针,并将其强制转化为 struct sockaddr* ,内核通过读取 sa_family 字段来识别实际的地址类型,并通过解释 sa_data 中的二进制数据,假如你传的是 struct sockaddr_in * 的强制类型转化,那么系统在底层会根据 sockaddr 中的两个参数将其再强制为 struct sockaddr_in * 来进行访问。

这种方式类似面向对象的基类指针,C 语言通过手动多态实现了统一接口。

IPv4 地址结构 sockaddr_in

Linux 内核中 struct sockaddr_in 地址结构

cpp 复制代码
/* 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)];
  };

/* Type to represent a port.  */
typedef uint16_t in_port_t;

/* Internet address.  */
typedef uint32_t in_addr_t;
struct in_addr
  {
    in_addr_t s_addr;
  };

将其转化之后

cpp 复制代码
struct sockaddr_in {
    sa_family_t    sin_family; /* AF_INET */
    in_port_t      sin_port;   /* 端口号,网络字节序 */
    struct in_addr sin_addr;   /* IPv4 地址,网络字节序 */
    unsigned char  sin_zero[8]; /* 填充,使结构体大小与 sockaddr 一致 */
};

struct in_addr {
    uint32_t s_addr; /* IPv4 地址,网络字节序 */
};

注:在使用 struct sockaddr_in 结构进行填充字段进行传递信息之前,一定要将 struct sockaddr_in必须清零(通常用 bzero 和 memset)

cpp 复制代码
#include <strings.h>

void bzero(void *s, size_t n);

功能:将 [s, s + n) 每一个地址的数据设置为 0

示例:
struct sockaddr_in local;
bzero(&local, sizeof(loacl));
cpp 复制代码
 #include <string.h>

void *memset(void *s, int c, size_t n);

功能:将 [s, s + n) 中每一个地址的数据设置为 c

示例:
struct sockaddr_in local;
memset(&s, 0, sizeof(local));

3.2 地址转换函数

本节只介绍基于 IPv4 的socket网络编程,且只介绍最安全和最常用的两个地址转化函数。

从上述 sockaddr_in 结构中,可以得出 sockaddr_in 的成员 struct in_addr.sin_addr 表示 32 位的 IP 地址。

但是我们通常使用点分十进制的字符串表示 IP 地址,而一下函数可以进行字符串地址和网络字节序地址的转换。

头文件

cpp 复制代码
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

字符串地址转网络字节序地址的函数

cpp 复制代码
int inet_pton(int af, const char *src, void *dst);

功能:将字符串 IP 地址转化为网络字节序地址

参数:
af: 表示网络通信的协议族,这个参数必须为 AF_INET 或 AF_INET6
src: 表示字符串 IP 的起始地址
dst: 输出型参数,表示 struct sockaddr_in 中的成员 struct in_addr 的地址 

返回值:成功返回 1, 失败返回 -1

示例:

std::string ip("192.168.1.100");
struct sockaddr_in local;
bzero(&local, sizeof(local));
inet_pton(AF_INET, ip.c_str(), &local.sin_addr);

网络字节序地址转字符串地址

cpp 复制代码
const char *inet_ntop(int af, const void *src,
                      char *dst, socklen_t size);
功能:将网络字节序地址转化为字符串 IP 地址

参数:
af: 表示网络通信的协议族
src: 表示 struct sockaddr_in 中成员 struct in_addr 地址
dst: 输出型参数,将转化的字符串 IP 地址以该位置为起始地址存储
size: 表示 buffer 的大小

返回值;成功返回 dst 的起始地址,失败返回 NULL

示例:
struct sockaddr_in client;
//...
char buffer[32];
inet_ntop(AF_INET, &client.sin_addr, buffer, sizeof(buffer));
相关推荐
躺柒1 小时前
读半导体简史08大型机
linux·服务器·c语言·数据库·unix·ibm
井川廊咏2 小时前
【总集篇】Linux | 如何创建一个 home 目录在 /data 磁盘的 sudo 用户(补档)
linux·运维·服务器
Kina_C2 小时前
LVS负载均衡的十二种核心调度算法
linux·运维·算法·负载均衡·lvs
shencz20002 小时前
用bc脚本求解大质数
linux
AOwhisky2 小时前
Linux(CentOS)系统管理入门笔记(第十五期)——文件系统与存储管理(上篇):设备识别、挂载与文件查找
linux·运维·笔记·centos·文件系统
寒水馨2 小时前
Linux下载、安装 Bun v1.3.14(附安装包bun-linux-x64.zip)
linux·javascript·typescript·node.js·bun·运行时·包管理器
AI产品库3 小时前
从沙盒到生产库:OpenAI GPT-5.6 Sol 在评测中自主入侵 Hugging Face
网络·gpt
繁星蓝雨3 小时前
C++设计原理——异常处理
java·c++·异常处理·noexcept·throw·try catch
Kurisu_红莉栖3 小时前
关于相关问题的自我回答
c++