Linux Socket网络编程基础
一.网络编程基础
1.理解源IP与目的IP
IP用来在网络中标识主机的唯一性。上一章我们也讲到过:数据传输到主机不是目的,而是手段,并且进程作为用户的代理,当数据进入主机并由进程拿到进行各种用途,这才是目的。但是在系统中有很多进程,所以我们需要在网络背景下标识主机的唯一性。

上网的行为有两种:
1.从远端服务器获取数据
2.将本地数据传送到远端服务器
由上一张网络基础我们得知,当两台主机跨网络通信时,一定会贯穿所有网络协议栈,因为请求由用户所在的应用层发出,而传输需要依赖传输层和物理层。网卡是传输层的设备,进程与网卡,网卡与网络无时无刻不在进行IO操作,那么我们就可以这样理解:
用户的行为都是IO操作,网络通信的本质就是远端两个不同主机的进程在进行数据交互——进程通信。
2.认识端口号
较为官方的说法:
端⼝号( port )是传输层协议的内容.
• 端⼝号是⼀个 2 字节 16 位的整数;
• 端⼝号⽤来标识⼀个进程, 告诉操作系统, 当前的这个数据要交给哪⼀个进程来处理;
• IP地址 + 端⼝号能够标识⽹络上的某⼀台主机的某⼀个进程;• ⼀个端⼝号只能被⼀个进程占⽤.
简单来说,端口号就是为了解决上面说的网络背景下的主机唯一性和进程唯一性。
在操作系统的网络栈中,传输层(如TCP或UDP)的核心职责之一是实现多路分解,即将接收到的网络数据正确交付给对应的应用程序进程。
为了实现这一机制,操作系统内核维护了一个关键的数据结构,通常是一个哈希表。这个哈希表的作用是建立端口号到进程套接字的映射。
其工作流程可以概括为:
- 进程注册:当一个进程希望通过网络通信时,它会向操作系统申请一个端口号(或绑定到一个特定端口),并创建一个套接字。此时,内核会在这个哈希表中添加一个条目,将端口号映射到该进程的套接字结构上。 
- 数据接收:当一份网络报文到达主机时,协议栈会解析其传输层头部,提取出关键字段——目的端口号。 
- 查找匹配:内核使用这个目的端口号作为键,在维护的哈希表中进行快速查找。 
- 数据交付:查找成功后,内核便能找到与之关联的套接字,最终将报文数据放入该套接字的接收缓冲区,从而唤醒并交付给等待数据的正确进程。 

问题:pid可以唯一标识进程,为什么还需要端口号?
1.不是所有的进程都要进行网络通信。
2.从技术角度,用pid标识进程唯一性技术上是可行的,但是pid是一个系统的概念,如果未来pid变化了(甚至消失),网络也要跟着变,具有强耦合性。
在整个网络通信中,我们采用下面的方式进行:
IP:全网内唯一一个主机(源IP和目的IP)
port:该主机唯一一个进程(源port和目的port)
IP+port=全网唯一一个进程。这样的组合叫做socket。
进程是用户的代理,这样就能标识数据收发双方的唯一性,从而进行通信了。

3.端口号划分
在操作系统中,对端口号进行了划分。其中:
0-1023:知名端口号,HTTP,FTP,SSH等广为人知的应用层协议的端口号都是固定的
1024-65535:操作系统动态分配的端口号,客户端程序的端口号就是从这个范围分配的
一个进程可以绑定多个端口号,但是一个端口号不能被多个进程绑定。
简单来说,端口号划分的工作就类似于:我们熟知的重要电话号码110、119等等是绝不允许普通用户使用的,因为它们都有其重要且独特的用处;我们普通用户使用的号码在办理业务时随机分配。
源端口和目的端口描述的问题,就是数据是谁发的,要发给谁。
4.理解socket
IP用来标识互联网中唯一的主机,port标识主机中唯一的端口。那么:
IP+port就可以标识互联网中唯一的进程
所以,通信的时候,本质是两个互联⽹进程代表⼈来进⾏通信,{srcIp,srcPort,dstIp,dstPort}
这样的4元组就能标识互联⽹中唯⼆的两个进程。
- Socket(套接字) 是操作系统提供的一个抽象概念,是网络通信的端点。你可以把它想象成一个“门”或者“信箱”。 
- IP地址 相当于你所在建筑的地址(例如:北京市海淀区XX路XX号)。 
- 端口号 相当于这个建筑里某个房间的门牌号(例如:808房间)。 
所以,IP + 端口号 共同构成了一个唯一的“通信地址”,确保数据能准确发送到目标计算机的特定进程(程序)。而 Socket 则是你的程序用来“站在这个门口”进行“收发信件”(即数据收发)的那个工具或接口。
一个Socket在通信前必须绑定到一个IP地址和端口号上。
5.传输层的典型代表
传输层属于内核,我们要通过网络协议栈进行通信,必定要调用传输层的系统调用接口。而在造轮子时,往往会实用下三层的协议。

6.传输层的协议:TCP与UDP
TCP协议:
• 传输层协议
• 有连接 • 可靠传输• ⾯向字节流
UDP协议:
• 传输层协议
• ⽆连接 • 不可靠传输• ⾯向数据报
既然一个可靠,另一个不可靠,为什么还要保留UDP呢?
TCP虽然可靠,但是必定要多做更多工作,复杂,占有资源多;
UDP虽然不可靠,但它更简单,效率更高
在这里我们仅对TCP和UDP协议的特点做简单总结,具体的功能后续再介绍。
7.网络字节序
我们知道,计算机本身是有大端存储和小端存储的。
高权值——高地址:小端
高权值——低地址:大端
当通信的两个主机的存储序列不同,就有可能在通信时产生解析错误的问题。如何解决这种问题?
做一种规定:凡是发送到网络中的数据,都是大端形式(是什么端不重要,重要的是要有这个约定)
• 发送主机通常将发送缓冲区中的数据按内存地址从低到⾼的顺序发出;
• 接收主机把从⽹络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到⾼的顺序保存; • 因此,⽹络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是⾼地址. • TCP/IP协议规定,⽹络数据流应采⽤⼤端字节序,即低地址⾼字节. • 不管这台主机是⼤端机还是⼩端机, 都会按照这个TCP/IP规定的⽹络字节序来发送/接收数据;• 如果当前发送主机是⼩端, 就需要先将数据转成⼤端; 否则就忽略, 直接发送即可;
这样的规定,可以调用库函数中有关网络字节序转换的系统调用实现。
NAMEhtonl, htons, ntohl, ntohs - convert values between host and network byte orderSYNOPSIS#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 表⽰ 32 位⻓整数, s 表⽰ 16 位短整数。
• 例如 htonl 表⽰将 32 位的⻓整数从主机字节序转换为⽹络字节序,例如将IP地址转换后准备发送。
• 如果主机是⼩端字节序,这些函数将参数做相应的⼤⼩端转换然后返回;
• 如果主机是⼤端字节序,这些函数不做转换,将参数原封不动地返回。
8.socket编程接口
// 创建 socket ⽂件描述符 (TCP/UDP, 客⼾端 + 服务器)
int socket(int domain, int type, int protocol);// 绑定端⼝号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address, socklen_t address_len);// 开始监听socket (TCP, 服务器)
int listen(int socket, int backlog);// 接收请求 (TCP, 服务器)
int accept(int socket, struct sockaddr* address, socklen_t* address_len);// 建⽴连接 (TCP, 客⼾端)
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);我们可以看到,频繁的出现sockaddr的一个结构体,这样的结构体是什么?
struct sockaddr {sa_family_t sa_family;char        sa_data[14];}
网络间通信实际上是进程间通信,System V是本地进程间通信,而我们之前学过的POSIX标准主要用于网络通信,并且也可以进行本地通信。
套接字有很多种类,来满足不同的应用场景:
网络socket,本地socket,原始socket
socket的设计者只想提供一种通信接口,就是sockaddr。网络通信,就用in,本地通信就用un。
我们可以通过指定这个结构体中的sa_family字段来确定套接字类型,类型如下:

不管是in还是un,首部都有16位地址类型字段。而根据上面的接口我们可以看到,我们定义in或者是un,而传入时实用sockaddr。在接口内部可以自行区分,是网络通信还是本地通信

sockaddr_in结构:

二.基于Socket实现简单的收发信息
1.再探重要接口
1.创建套接字socket
参数:域(本地还是网络通信),套接字类型(在这里设置为UDP),协议:都通过宏传参
SYNOPSIS#include <sys/types.h>          /* See NOTES */#include <sys/socket.h>int socket(int domain, int type, int protocol);
2.绑定IP和端口bind
创建套接字最多也就是打开了类似管道文件,此时是无法进行通信的。所以我们要给网络文件socketaddr设置IP地址和端口号,进行绑定。
参数:socket的返回值_sockfd,sockaddr结构,传进来的sockaddr大小
SYNOPSIS#include <sys/types.h>          /* See NOTES */#include <sys/socket.h>int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
addr的结构:协议族,端口号,IP地址
struct sockaddr_in {sa_family_t		sin_family;	/* Address family		*/unsigned short int	sin_port;	/* Port number			*/struct in_addr	sin_addr;	/* Internet address		*//* Pad to size of `struct sockaddr'. */unsigned char		__pad[__SOCK_SIZE__ - sizeof(short int) -sizeof(unsigned short int) - sizeof(struct in_addr)];
};3.点分十进制IP转四字节IP
网络通信时,我们必须是四字节IP,而我们日常实用的是点分十进制的(字符串类型)IP地址。所以在IP地址,我们需要将IP转换为4字节,再将4字节转换为网络序列。有一个特定的接口inet_addr接口可以完成这两个步骤.
 in_addr_t inet_addr(const char *cp);

相同的,我们也可以把四字节整数转换成点分十进制的IP。使用inet_ntoa即可。
4.收消息recvfrom
参数:创建的套接字返回值sockfd,自定义的缓冲区buf用于收消息,缓冲区长度,是否选择阻塞式IO(类似于scanf)标志位flag,数据来源的信息src_addr,以及这个结构体的长度addrlen。
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);对于网络服务,或者说一个进程,他可能同时收到不同的数据包,他需要知道客户端client的套接字信息。
5.发消息sendto
参数形式与收消息基本一致。毕竟发消息也需要知道目标进程的套接字信息。
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,const struct sockaddr *dest_addr, socklen_t addrlen);
udp socketfd:既可以读,也可以写。也就是说,udp通信是全双工的
2.封装服务端UdpServer.hpp
#pragma once#include <iostream>
#include <string>
#include <functional>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"using namespace LogModule;
// 目的是将客户端传入的数据处理
// func_t是回调函数类型,传入的是一个函数指针,函数指针指向一个函数,函数的输入参数是const std::string&,返回值是std::string
using func_t = std::function<std::string(const std::string &)>;int defaultfd = -1;// 进行网络通信的类class UdpServer
{
public:UdpServer(uint16_t port, func_t func): _sockfd(defaultfd), _port(port), _func(func), _isrunning(false){// 构造函数}void Init(){// 初始化// 1.创建套接字_sockfd = socket(AF_INET, SOCK_DGRAM, 0);if (_sockfd < 0){LOG(LogLevel::FATAL) << "create error!";exit(1);}LOG(LogLevel::INFO) << "create socket success:" << _sockfd;// 2.创建成功,进行ip和端口绑定// 首先填充sockaddr字段struct sockaddr_in local;// 清除缓冲区bzero(&local, sizeof(local));local.sin_family = AF_INET;// IP和端口发往网络,就需要网络序列转换local.sin_port = htons(_port);// 这里我们不指定绑定的ip地址,所以用INADDR_ANYlocal.sin_addr.s_addr = htonl(INADDR_ANY);// 3.绑定// 服务端需要显式绑定,因为IP和端口需要被各大客户端所知// 尽管我们使用的是sockaddr_in,这里进行强转即可int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));if (n < 0){LOG(LogLevel::FATAL) << "bind error";exit(2);}LOG(LogLevel::INFO) << "bind success, sockfd : " << _sockfd;}void Start(){_isrunning = true;//服务器一旦启动,就是一个死循环while(_isrunning){//1.服务器接收消息,并做处理char buffer[1024];struct sockaddr_in peer;socklen_t len = sizeof(peer);//服务器需要知道客户端的地址,所以需要传入地址参数//收客户端数据ssize_t s=recvfrom(_sockfd, buffer, sizeof(buffer), 0, (struct sockaddr *)&peer, &len);if(s>0){//2.服务器成功收到消息,开始处理//收到网络序列,进行转化int peer_port = ntohs(peer.sin_port);//这个接口用于将四字节风格的IP转换为点分十进制的IPstd::string peer_ip = inet_ntoa(peer.sin_addr);//将收到的消息转化为字符串buffer[s]=0;//3.将消息进行处理std::string result=_func(buffer);//4.将处理结果发送给客户端sendto(_sockfd, result.c_str(), result.size(), 0, (struct sockaddr *)&peer, len);}}}~UdpServer(){}private:int _sockfd;uint16_t _port;bool _isrunning;func_t _func;// 不对ip进行传入
};3.服务端Server.cc
#include <iostream>
#include <memory>
#include "UdpServer.hpp"// 默认的处理函数
std::string defaulthandler(const std::string &message)
{std::string hello = "hello, ";hello += message;return hello;
}// ./udpserver port方式传参
int main(int argc, char *argv[])
{if(argc != 2){std::cerr << "Usage: " << argv[0] << " port" << std::endl;return 1;}uint16_t port = std::stoi(argv[1]);Enable_Console_Log_Strategy();// 创建一个UdpServer对象,并启动服务std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, defaulthandler);usvr->Init();usvr->Start();return 0;
}4.客户端UdpClient.cc
#include <iostream>
#include <string>
#include <cstring>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>// ./udpclient server_ip server_port方式传参
int main(int argc, char *argv[])
{if (argc != 3){std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;return 1;}std::string server_ip = argv[1];// 注意:这里需要用std::stoi()转换成整型uint16_t server_port = std::stoi(argv[2]);// 1. 创建socketint sockfd = socket(AF_INET, SOCK_DGRAM, 0);if(sockfd < 0){std::cerr << "socket error" << std::endl;return 2;}// 2. 本地的ip和端口是什么?要不要和上面的“文件”关联呢?// client需要bind,但不需要显式bind。//首次发送消息,OS会自动给client进行bind,OS知道IP,端口号采用随机端口号的方式//   为什么?一个端口号,只能被一个进程bind,为了避免client端口冲突// 显而易见,想要获取服务,就需要服务器的相关信息struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(server_port);server.sin_addr.s_addr = inet_addr(server_ip.c_str());while(true){// 3. 输入消息std::string input;std::cout << "Please Enter# ";std::getline(std::cin, input);// 4. 发送消息int n = sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr*)&server, sizeof(server));(void)n;char buffer[1024];//同样地,我们需要知道recv的数据信息struct sockaddr_in peer;socklen_t len = sizeof(peer);// 5. 接收消息int m = recvfrom(sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);if(m > 0){buffer[m] = 0;std::cout << buffer << std::endl;}}return 0;
}5.一些重要问题
1.client的端口问题
client要访问目标服务器,需要知道服务器的IP和端口。
那如何知道服务器的IP和端口?
再实际应用中,往往客户端和服务端由同一个公司编写,服务端的IP和端口都被内置在客户端中。
客户端的端口是多少不重要,只要求其唯一性。
2.服务端的IP问题
本地环回(127.0.0.1):要求服务器与客户端在同一主机上,不会将client数据发送到网络,仅在本地通信,常用于网络代码的测试。
这里有个特别重点的地方:我们上面的代码中,服务端的IP设置如下:
local.sin_addr.s_addr = htonl(INADDR_ANY);这样设置的目的是:让服务端能够接受来自任何网络接口的连接请求
如果我们将服务端的ip改为192.168.1.1(一个具体的值),会出现什么问题?
server bind公网ip——无法绑定 在云服务器中,公网ip没有配置到自己的ip中,也就无法直接进行bind了。
bind本地环回,内网ip——可以绑定
server绑定内网ip,但是用环回是无法访问的
总结如下:
❌ IP冲突 - 与常见网关地址冲突
❌ 环境依赖 - 只能在特定网络环境下工作
❌ 部署困难 - 不同环境需要修改代码
❌ 访问限制 - 客户端必须使用特定IP连接
❌ 维护复杂 - 网络变更时需要代码修改
3.客户端显式bind问题
我们还发现,在客户端UdpClient中,我们并没有显式bind。这是为什么?
1. 端口资源冲突
// 问题示例:固定端口绑定
bind(client_fd, "192.168.1.100:54321");- 后果:同一台机器上无法运行多个客户端实例 
- 原因:端口是稀缺资源,固定占用导致其他进程无法使用 
- 正常情况:系统自动分配临时端口(32768-60999) 
2.网络环境适应性差
// 硬编码客户端配置
bind(client_fd, "192.168.1.100", 8080);- 问题:客户端在不同网络环境中无法工作 
- 场景: - 家庭网络(192.168.1.x)→ 可能工作 
- 公司网络(10.0.0.x)→ 完全失败 
- 公共WiFi(任意IP)→ 完全失败 
 
如果我们显示进行client的地址bind,client未来访问就必须使用server端bind的地址信息。
显示进程启动的udp协议网络服务
netstat -naup
当我们启动服务时,上述命令结果如下:
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name    
udp        0      0 127.0.0.53:53           0.0.0.0:*                           -                   
udp        0      0 10.0.12.14:68           0.0.0.0:*                           -                   
udp        0      0 10.0.12.14:123          0.0.0.0:*                           -                   
udp        0      0 127.0.0.1:123           0.0.0.0:*                           -                   
udp        0      0 0.0.0.0:54886           0.0.0.0:*                           1658186/./udpclient 
udp        0      0 0.0.0.0:8080            0.0.0.0:*                           -                   
udp6       0      0 fe80::5054:ff:fe8e::123 :::*                                -                   
udp6       0      0 ::1:123                 :::*                                -                  