前言:我们有了IP定位到对应的主机的概念后,信息传递到对应的主机后,而主机中有那么多的进程,该怎么知道把信息交给主机中的哪个进程呢?
一、端口号
IP 到 端口号:如何精准定位
我们在网络层通过 IP 地址找到了目标主机,但这并不是终点。
传输的真正目的:数据并不是要发给“主机”这个铁壳子,而是发给正在运行的人使用的程序(比如你正在用的微信、浏览器)。
进程的代表:在操作系统中,人通过启动进程来完成任务。所以,数据传输的最终目的地是主机内部的某个特定进程。
端口号 (Port) 的作用
既然已经到了目标主机,系统中有那么多进程,怎么知道数据该给谁?这就需要端口号。
- 定义:端口号是一个2字节 (16位)的整数 。
- 功能:用来标识主机上唯一的一个网络进程 。
- 绑定规则:一个端口号只能被一个进程占用,但一个进程可以绑定多个端口号 。
端口号范围划分
- 0 - 1023 (知名端口):HTTP (80), FTP (21), SSH (22) 等级协议专用的,我们自己写程序尽量别碰 。
- 1023 - 65535 (动态端口):操作系统动态分配的,或者我们自己写服务器时绑定的,就在这儿挑 。
思考:为什么不用 PID (进程ID)?
PID 是系统层面的唯一标识,确实也能找到进程。但如果网络协议直接绑定 PID,一旦操作系统调整了 PID 的分配策略,网络协议就得跟着改。使用端口号可以将网络管理与系统进程管理解耦 。
二、什么是 Socket?
搞懂了 IP 和 端口号,Socket 的概念就水到渠成了。
- Socket (套接字):本质就是IP地址 + 端口号。
- 唯一性:
IP标识了全网唯一的主机,Port标识了该主机上唯一的进程。所以IP + Port就能标识互联网中独一无二的一个进程。- 通信本质:网络通信的本质,其实就是两个互联网进程之间的 IPC (进程间通信)。
三、传输层协议初识:TCP vs UDP
在写 Socket 代码前,得先选好用哪种协议。Linux 内核在传输层主要提供了两种选择:
| 特性 | TCP (传输控制协议) | UDP (用户数据报协议) |
| 连接性 | 有连接(打电话,先接通) | 无连接(发短信,直接发) |
| 可靠性 | 可靠传输(保证数据送达) | 不可靠传输(丢了不管) |
| 数据形式 | 面向字节流 | 面向数据报 |
注:详细的 TCP/UDP 机制后续会有专门章节,目前只需知道这些区别即可 。
三、 网络字节序
我们都知道内存里的数据有大端和小端之分。
大端:高位字节存放在低地址。
小端:低位字节存放在低地址(我们常用的 x86 架构大多是小端)。
为什么网络编程要注意这个?
这就好比两个不同方言的人说话。如果发送端主机是小端机,它把一个 32 位的整数(比如 IP 地址)按内存地址从低到高发出去;而接收端可能是大端机,或者网络协议规定了不同的读法,那数据就全乱套了。
为了解决这个问题,TCP/IP 协议强行规定:网络数据流应采用大端字节序(即低地址高字节)。
这意味着:
- 不管你的主机是大端还是小端,发数据前,必须把数据转成大端(网络字节序)。
- 收数据后,必须把数据从大端转回主机字节序。
我们可用举个例子,假设我们要发一个0x1234abcd:
发送端通常将发送缓冲区的数据按内存地址从低到高发出,网络流规定:先发出的数据认为是高位。
如果是小端机,内存里存的是cd ab 34 12(低位在低地址)。如果不转换直接发,网络那边读出来的就是0xcdab3412,这就错了。
转换函数
Linux 提供了<arpa/inet.h>库函数来做转换。为了代码的可移植性,写网络程序必须调用这些函数,不要自己手写位移操作。
这些函数名非常好记:h代表 host(主机),n代表 network(网络),s代表 short(16位),l代表 long(32位)。
- 发送时(Host to Network):
#include <arpa/inet.h> // 用来转换32位 uint32_t htonl(uint32_t hostlong); // 用来转换16位 uint16_t htons(uint16_t hostshort);如果主机本身就是大端,这些函数什么都不做,直接返回原值 ;如果是小端,它会自动帮你翻转
- 接收时(Network to Host):
uint32_t ntohl(uint32_t netlong); uint16_t ntohs(uint16_t netshort);四、Socket 编程接口 (Socket API)
Socket API 是系统调用,属于 OS 内核提供的功能。所有的网络功能,必须通过系统调用来实现
常见 API 一览
写 TCP 服务器/客户端的“五板斧”:
1. socket:创建套接字(相当于买个手机)
// domain: 协议族 (AF_INET 用IPv4) // type: 服务类型 (SOCK_STREAM 用TCP, SOCK_DGRAM 用UDP) int socket(int domain, int type, int protocol);2. bind:绑定端口和IP(相当于给手机插卡,固定号码)
// 这里的 struct sockaddr* 是个通用指针 int bind(int socket, const struct sockaddr *address, socklen_t address_len);3. listen:监听(TCP服务器专用,相当于设置手机响铃模式)
int listen(int socket, int backlog);4. accept:接收连接(TCP服务器专用,相当于有人打进来了,你按接听)
int accept(int socket, struct sockaddr *address, socklen_t* address_len);5. connect:发起连接(TCP客户端专用,相当于拨号)。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);在看bind或connect的函数原型时,你会发现参数类型都是struct sockaddr *。
但实际上我们在写 IPv4 程序时,定义的却是struct sockaddr_in。这是为什么?
sockaddr(通用结构体)
Socket API 是一层抽象接口,它不仅支持 IPv4,还支持 IPv6、UNIX Domain Socket 等各种协议 。 为了能让bind等函数接收各种协议的地址,OS 定义了一个通用的结构体:
struct sockaddr { unsigned short sa_family; // 16位地址类型 (AF_INET, AF_UNIX...) char sa_data[14]; // 14字节的地址数据 };这个结构体很难用,因为它的 IP 和 Port 是混在一起存在sa_data里的。
sockaddr_in(IPv4 专用结构体)
而为了方便操作,OS使用专门针对 IPv4 设计的结构体 :
struct sockaddr_in { short int sin_family; // 地址族 unsigned short int sin_port; // 16位端口号 struct in_addr sin_addr; // 32位IP地 unsigned char sin_zero[8]; // 8字节填充,为了和 struct sockaddr 大小保持一致 };注意:struct in_addr里面其实就是一个uint32_t s_addr,用来存 32 位的 IP 地址 。
内存模型与强制类型转换
虽然上述两种结构体定义不同,但它们的大小是一样的,而且前 16 位(2字节)都是family字段。
只要取得了结构体的首地址,OS 就可以根据前 16 位的family字段判断这是 IPv4 还是 IPv6 。我们在代码中定义sockaddr_in进行赋值,然后在调用 API 时,强制类型转换为struct sockaddr *。
实际代码使用示例:
#include <netinet/in.h> #include <arpa/inet.h> struct sockaddr_in local; // 1. 清空结构体 (有的系统会有垃圾值) bzero(&local, sizeof(local)); // 2. 填充协议族 local.sin_family = AF_INET; // IPv4 // 3. 填充端口号 (注意字节序转换!) local.sin_port = htons(8080); // 4. 填充IP地址 (INADDR_ANY 表示绑定本机所有网卡) // htonl转换IP,虽然 INADDR_ANY 是0,转不转都一样,但好习惯要养成 local.sin_addr.s_addr = htonl(INADDR_ANY); // 5. 强转传参 bind(sockfd, (struct sockaddr*)&local, sizeof(local));