Socket编程入门:从IP到端口全解析
目录
本节重点
一、预备知识
1.1 理解源IP地址和目的IP地址
1.2 认识端口号
1.3 理解端口号和进程ID
1.4 认识TCP协议&&UDP协议
1.41 TCP 的 “打电话” 模型
1.42 UDP 的 “寄快递” 模型
1.5 网络字节序
1.51 大小端数据互转函数
二、socket编程接口
2.1 sockaddr的结构
2.11 sockaddr_in结构
2.12 sockaddr_un结构
2.2 socket常见的API
2.21 socket接口
2.22 bind接口
三、简单的UDP网络程序编写
3.1 辅助函数:
3.2 必备知识:
3.3 代码编写
3.3.1 服务端编写
3.3.2 客户端编写
本节重点
1.认识IP地址,端口号,网络字节序等网络编程中的基本概念
2.学习socket api的基本用法
3.实现一个简单的udp客户端/服务器
4.实现一个简单的tcp客户端/服务器(单连接版本,多进程版本,多线程版本)
5.理解tcp服务器建立连接,发送数据,断开连接的流程
一、预备知识
1.1 理解源IP地址和目的IP地址
在上一节中我们用西游记中唐僧的例子理解了ip地址,ip地址相当于规定了一个宏观的方向,MAC地址是微观从哪台主机到哪个主机
在此基础上理解:我们直到把数据从A主机到B主机不是目的,是手段,目的是为了解决应用问题,而且真正通信的不是两个机器,是这两个机器上的软件(进程/人),IP地址能够唯一标识一台主机的唯一性,哪软件用什么来标识呢???为此引入端口的概念
1.2 认识端口号
端口号(port)是传输层协议的内容,在应用层可以使用,通过系统调用可以用来让一个进程关联一个端口号,以此来标识一个进程的唯一性
a.端口号是一个2字节16位的整数 (uint16_t ————typedef unsigned short int uint16_t)
b.端口号是用来标识一个进程,告诉os,当前这个数据要交给哪一个进程处理
c.IP地址+端口号能够标识网络上某一台主机的某一个进程
d.一个端口号只能被一个进程占用
e.一个进程可以绑定多个端口号,不允许多个进程绑定同一个端口号
总结:我们使用ip地址标识主机的唯一性,使用端口号标识进程的唯一性,所以客户端和服务端进程都采用IP地址+port端口号标识某一台主机某一个进程的唯一性
所以网络通信的本质就是进程间通信
进程间通信需要让不同的进程先看到同一份资源————这份资源就是网络
通信就是不断地在IO的过程————不断地向网络中发数据和收数据
1.3 理解端口号和进程ID
在系统编程时,我们就已经学习过PID表示唯一一个进程,那为什么还需要一个端口号来表示呢?
a.这是计算机系统中两个不同层面的标识符,操作系统是操作系统,网络是网络,这样能够进行解耦
b.PID每次重启都会变更,这导致不能定位到唯一性,而端口号不一样,一旦绑定了就确定了
c.不是所有的进程都需要端口号,但是每个进程都要有PID,换言之就是只有网络需求的进程才需要绑定端口号
1.4 认识TCP协议&&UDP协议
TCP(Transmission Control Protocol,传输控制协议)
a.它属于传输层协议
b.有连接
c.可靠传输
d.面向字节流
UDP(User Datagram Protocol,用户数据报协议)
a.它属于传输层协议
b.无连接
c.不可靠传输
d.面向数据报
1.41 TCP 的 “打电话” 模型
- 建立连接:拨号码 → 对方接听 → 确认身份(三次握手)。
- 通信过程:一方说话,另一方必须回应(ACK),若没听到则要求重复(重传)。
- 结束通话:双方确认结束(四次挥手)。
1.42 UDP 的 “寄快递” 模型
- 无需建立连接:直接把包裹(数据报)扔给快递员(网络)。
- 不保证送达:包裹可能丢失、迟到,且多个包裹可能乱序到达。
- 优点:快速发送,适合 “允许少量损失但实时性高” 的场景(如直播)。
1.5 网络字节序
字节序是指多字节数据在计算机内存中存储或者网络传输时各字节的存储顺序。
内存中的多字节数据相对于内存地址有大端和小端之分,
磁盘文件中的多字节数据相对于文件中的偏移地址也有大小端之分
大端即低权位的数据放在高地址处
小端即低权位的数据放在低地址处
同样,网络数据流也有大小端之分,那么如何定义网络数据流的地址呢?
a.发送方按发送缓冲区的数据按内存地址从低到高的顺序发出,接收方保存也是低到高
b. TCP/IP协议族规定,网络数据流应采用大端字节序,即低地址高字节
c.不管这台主机是大端机还是小端机,都会按照TCP/IP协议族规定的网络字节序来发送/接受数据
d.如果当前机器是小端,则需要先将数据转成大端,否则就忽略,直接发送即可;
1.51 大小端数据互转函数
h表示host,n表示network,l表示32位长整数,s表示16位短整数
比如htonl:表示将32位的长整数从主机字节序转换为网络字节序,例如将ip地址转换后准备发送
如果主机是大端字节序,这些函数不做转换,将参数原封不动的返回
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回
二、socket编程接口
Socket 编程是一种基于网络套接字(Socket)的编程模型,用于实现不同设备间的进程通信(网络通信)。它是网络编程的基础,允许程序通过网络发送和接收数据,就像操作本地文件一样简单
Socket 是操作系统提供的网络编程接口,是应用层与传输层之间的抽象层。
它将复杂的 TCP/IP 协议封装为简单的 API,让开发者无需关心底层细节,直接实现数据传输。
通过‘源 IP: 源端口’与‘目标 IP: 目标端口’的组合,实现网络中两个进程的精准通信
三种常见的socket编程类型:
1.网络套接字编程: 跨主机通信(通过网络连接不同设备)(也主持本地通信)
2.原始套接字编程:可跨主机或本地,但直接操作底层协议。绕过传输层(TCP/UDP),直接访问网络层(IP)或链路层(如以太网帧)。
3.unix域套接字编程:同一主机内的进程间通信(IPC),不涉及网络。
总结:就是我们不关心协议,只要调用了相应的接口就满足了对应的协议,os已经帮我们进行了封装,让开发者可以专注于数据传输逻辑
2.1 sockaddr的结构
由于socket有很多的协议和通信方式,各种协议的地址格式各不相同,如何做到一套接口,解决所有的网络或其他场景下的通信问题???
2.11 sockaddr_in结构
in表示internet,表示网络套接字编程,尤其针对 IPv4 网络通信。
第一个字段
这个 __SOCKADDR_COMMON会传参,外部传进来的和family拼接在一起形成一个新的
比如传进来abcd,那最后会形成sa_family_t abcdfamily;
sa_family_t
是一个整数类型,通常在<sys/socket.h>
或<bits/sockaddr.h>
中定义,用于存储地址族的标识符。- 告诉系统当前使用的是哪种网络协议族(如 IPv4、IPv6、Unix 域套接字等)。
-
常量名 值 描述 AF_INET
2 IPv4 协议 AF_INET6
10 IPv6 协议 AF_UNIX
1 Unix 域套接字(本地进程通信) AF_LOCAL
1 与 AF_UNIX
同义AF_PACKET
17 原始数据包(用于链路层编程)
以上是常见的地址族常量(定义在 <sys/socket.h>
中)
第二个字段就是用来填充port端口号的
第三个字段就是用来填充ipv4地址的
注意: IPv4 专用结构体 sockaddr_in,
所以第一个字段必须为AF_INET
IPv6 专用结构体 sockaddr_in6,
第一个字段就必须为
AF_INET6
2.12 sockaddr_un结构
是用于 Unix 域套接字(Unix Domain Socket, UDS) 的地址结构体,用于在同一主机内实现进程间通信(IPC)。与基于网络的 sockaddr_in
(IPv4)或 sockaddr_in6
(IPv6)不同,UDS 通过文件系统路径而非网络地址来标识通信端点,提供更高的效率和安全性。
在这里不做重点讲解,有需要可自行查资料
总结:
a.IPv4 / IPv6地址类型分别定义为常数AF_INET、AF_INET6,这样,只要取得某种sockaddr结构体的首地址,不需要知道具体哪种类型的sockaddr结构体,就可以根据地址类型字段确定实际结构体的类型
b.所以当我们在绑定线程端口时就需要进行强转,如果使用ipv4,就要先创建struct sockaddr_in结构体,然后在进行强转传参
c.socket API可以都用struct sockaddr*类型表示,在使用的时候强制转换即可,这样的好处是程序的通用性,可以接受IPv4,IPv6,以及UNIX Domain Socket各种类型的sockaddr结构体指针作为参数
2.2 socket常见的API
2.21 socket接口
作用:创建socket文件描述符
参数说明:
domain:指定使用的网络层协议 ,也就是上面提到的AF_INET、AF_UNIX等
type:指定使用的传输层协议,常见取值:
值 | 含义 | 对应协议 | 特性 |
---|---|---|---|
SOCK_STREAM | 流式套接字 | TCP | 面向连接、可靠、有序、字节流 |
SOCK_DGRAM | 数据报套接字 | UDP | 无连接、不可靠、保留消息边界 |
SOCK_RAW | 原始套接字 | 直接 IP 层 | 可自定义 IP 头,需 root 权限 |
SOCK_SEQPACKET | 有序数据包套接字 | SCTP | 可靠、保留消息边界、有序 |
protocol: 指定传输层的具体协议,通常为 0(表示使用type
对应的默认协议):
值 | 说明 |
---|---|
0 | 默认协议(如 TCP、UDP) |
IPPROTO_TCP | 显式指定 TCP 协议(值为 6) |
IPPROTO_UDP | 显式指定 UDP 协议(值为 17) |
IPPROTO_ICMP | ICMP 协议(用于 ping、traceroute) |
返回值:成功返回非负整数sockfd(套接字描述符,类似文件描述符)。
失败返回-1
,并设置errno
(如EAFNOSUPPORT
、EPROTONOSUPPORT
等)。
总结:这个函数相当于打开一个文件描述符,网络套接字接收的数据默认存放在内核的套接字接收缓冲区中,符合Linux下一切皆文件的思想。
2.22 bind接口
作用:用于将套接字(socket)与特定地址和端口绑定,也就是刚刚使用socket函数打开的文件描述符要告诉os与哪个套接字绑定。
参数说明:
sockfd:通过 socket()
创建的套接字描述符。
addr:前面我们介绍的sockaddr_in通过填写里面的信息(端口号和ip地址)经过强转得来的
addrlen:addr
结构体的字节长度。帮助内核解析 addr
中的数据,避免越界访问。
返回值:成功返回0,失败返回-1,并且设置错误码
三、简单的UDP网络程序编写
简单来说就两步,一步是socket创建套接字,一步是bind绑定端口号和ip
3.1 辅助函数:
bzero函数:是c语言中用于将内存块清空的函数,避免数据干扰
inet_addr函数:完成点分十进制ip地址------>32位整型ip地址和主机转网络
inet_ntoa函数:完成32位整型ip地址和主机转网络------>点分十进制ip地址
recvfrom函数:在网络数据传输中我们不仅要关注数据是什么,还要关注谁发的
这个函数的作用就是从套接字fd中把数据读到buffer,buffer的大小是len
flags默认设成0,表示默认行为
src_addr是输入输出型参数,也就是我们要创建一个结构体传进去,os会帮我们填对方的ip和port
addrlen是这个结构体的大小
成功返回字节数的大小,失败返回-1,并且设置错误码
sendto函数:网络编程中用于无连接协议(如 UDP)发送数据的系统调用,它能指定接收方的地址信息
flags中设0表示默认行为
使用和recvfrom一样,但结构体addr中要填写对方主机的ip和端口号,表示发送给谁
3.2 必备知识:
ifconfig:是 Linux/Unix 系统中用于配置和查看网络接口信息的命令,全称是 network interface configuration(网络接口配置)。
其中lo中的inet 127.0.0.1 叫做本地环回,本地换回的意思是消息没有经过物理层发出去,仅仅是在本地应用层->传输层->网络层 ->传输层->应用层,就在本地绕了一圈,只是在本地测试用
netstat:是 Linux/Unix 系统中用于查看网络状态的命令,全称是 network statistics(网络统计)。它可以显示系统的网络连接、路由表、接口统计、masquerade 连接、多播成员等网络相关信息,是网络调试和监控的常用工具。
选项 -nuap n表示能显示成数字的显示成数字,u表示udp协议,a表示所有的udp,p表示PID
proto即protocol,即协议 所以显示的都是udp协议 udp6是ipv6
Recv-Q:接收队列(Receive Queue),表示在本地套接字接收缓冲区中,还未被应用程序读取的数据字节数
Send-Q:发送队列(Send Queue ),表示在本地套接字发送缓冲区中,还未被对方确认接收的数据字节数 。
Local Address:本地地址,显示当前主机上用于该网络连接的 IP 地址和端口号 。
Foreign Address:外部地址,表示与本地连接对应的远程(外部)主机的 IP 地址和端口号 。
stat:连接状态(State) ,不过 UDP 是无连接协议,
PID:由于不是root,可能会进行隐藏,可以提权显示
最为重要的一点:
1.服务端需要绑定端口号,不需要绑定ip地址,因为未来这台主机可能会被多ip访问,例如你可以通过本地环回进行访问或者公网ip,如果你指定绑定了某个ip地址就会导致你另外一个ip无法访问,例如你绑定了本地换回,别人通过公网ip就无法访问,所以我们可以将ip设为0.0.0.0或者INADDR_ANY,这都是全0的意思,这样无论是什么样的ip都可以访问,最后在根据port进行区分即可
2.客户端不需要明确bind,这一点交给os完成,即不需要程序员显示bind,因为客户端可能有多种,比如不同的手机厂商,如果抖音绑定了8080端口,那其他程序就无法绑定,我们交给os去绑定即可,这样只要有空余的端口,os会自动绑定,因为你客户端不需要固定端口,而服务端只要启动一般都不会关闭,固定端口是为了别人能访问,客户端是去发起请求只需要os来操作,哪个端口空闲就绑定哪个即可
3.3 代码编写
3.3.1 服务端编写
#pragma once
#include <iostream>
#include <netinet/in.h>
#include <string>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <cstring>
#include <sys/types.h>
#include <functional>using namespace std;static const string defaultip = "0.0.0.0"; // 为什么使用0.0.0.0
class server
{
public:typedef function<void (string, string ,uint16_t)> func_t;// 外部传入ip地址和端口号进行初始化server(const uint16_t port = 8080, const string &ip = defaultip): _port(port), _ip(ip), _sock_fd(-1){}void server_init(){// 1.创建socket_sock_fd = socket(AF_INET, SOCK_DGRAM, 0); // 0会根据前面两个参数来进行填写if (_sock_fd == -1){cerr << "socket error:" << errno << strerror(errno) << endl;exit(1);}// 2.初始化结构体后,使用bind函数绑定端口号和ipstruct sockaddr_in addr; // 定义了一个变量,这个是在栈区上的bzero(&addr, sizeof(addr)); // 释放addr的垃圾数据,防止数据污染addr.sin_family = AF_INET;addr.sin_port = htons(_port); //addr.sin_addr.s_addr = inet_addr(_ip.c_str()); // 完成string->uint32_t和htonl的转换// addr.sin_addr.s_addr = htonl(INADDR_ANY);//ip为全0,所以一开始缺省ip为全0,表示未来只要发送到这台服务器上的数据最后以端口号区分进程,防止多ip的机器int n = bind(_sock_fd, (struct sockaddr *)&addr, sizeof(addr)); // 绑定端口号和ipif (n == -1){cerr << "bind error:" << errno << strerror(errno) << endl;exit(2);}}void server_start(){char buffer[1024]; // 定义缓冲区for (;;){struct sockaddr_in addr;socklen_t len = sizeof(addr);bzero(&addr,len);ssize_t s = recvfrom(_sock_fd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&addr, &len);//0是阻塞式等待if (s > 0) // 返回值为len的长度,不会超过buffer的大小{buffer[s] = 0; // 设置buffer缓冲区的末尾string fromip = inet_ntoa(addr.sin_addr);//转回点分十进制和主机序列uint16_t fromport = ntohs(addr.sin_port);cout << fromip << "[" << fromport << "] : " << buffer << endl;//这里有\0没有处理的风险}else{cerr<<"recvfrom error "<<errno<<strerror(errno)<<endl;}}}~server(){}private:uint16_t _port; // 端口号string _ip; // 存储ip地址,一般不建议绑定ip地址int _sock_fd; // 套接字文件描述符func_t f;//回调函数,业务分离
};
#include "server.hpp"
#include <memory>// 未来启动服务端是这样
// ./server port// 使用手册
static void Usage()
{cout << "Usage: ./server port" << endl;
}
int main(int argc, char *argv[])//这里可以使用getopt()函数
{if (argc != 2){// 传参失败Usage();exit(-1);}uint16_t port = atoi(argv[1]);//使用atoi转换类型//string ip = argv[2];unique_ptr<server> svr(new server(port));svr->server_init();svr->server_start();return 0;
}
3.3.2 客户端编写
#pragma once
#include <iostream>
#include <netinet/in.h>
#include <string>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <cstring>
#include <sys/types.h>
#include <cstring>
using namespace std;
class client
{public:client(const string &serverip, const uint16_t &serverport): _sock_fd(-1), _serverip(serverip), _serverport(serverport){}void init(){// 1.创建socket_sock_fd = socket(AF_INET, SOCK_DGRAM, 0); // 0会根据前面两个参数来进行填写if (_sock_fd == -1){cerr << "socket error:" << errno << strerror(errno) << endl;exit(1);}// 2.client需要bind,但不需要明确显示bind,即不需要程序员自己写,os自动形成端口绑定}void run(){string message;struct sockaddr_in addr;socklen_t len = sizeof(addr);bzero(&addr, len);addr.sin_family = AF_INET;addr.sin_addr.s_addr = inet_addr(_serverip.c_str());addr.sin_port = htons(_serverport);while (true){cout<<"##Please Enter Message##: "<<endl;cin>>message;ssize_t s = sendto(_sock_fd, message.c_str(), message.size(), 0, (struct sockaddr *)&addr, len);}}private:int _sock_fd;string _serverip;uint16_t _serverport;
};
#include "client.hpp"
#include <memory>
//使用手册
// ./server ip port
using namespace std;
static void Usage()
{cout << "Usage: ./client ip port" << endl;
}
int main(int argc, char *argv[])//这里可以使用getopt()函数
{if (argc != 3){// 传参失败Usage();exit(-1);}uint16_t serverport = atoi(argv[2]);//使用atoi转换类型string serverip = argv[1];unique_ptr<client> cli(new client(serverip,serverport));cli->init();cli->run();return 0;
}
注意:这里设置了回调函数,作用就是解耦,让业务和网络传输解耦,后续可以通过多进程多线程去处理数据任务。
如果编写的程序不能访问,有可能是自己的云服务器没有开放端口,可以自己去防火墙开放窗口进行测试