Linux网络编程——socket 通信基础
- 1. socket 介绍
- 2. 字节序
- 2.1 简介
- 2.2 字节序举例
- 2.3 字节序转换函数
- 3. socket 地址
- 3.1 通用 socket 地址
- 3.2 专用 socket 地址
- 4. IP地址转换(字符串ip -> 整数,主机、网络字节序的转换 )
- 5. TCP 通信流程
- 6. 套接字函数
1. socket 介绍
所谓 socket
(套接字),就是对网络中不同主机上的应用进程之间进行 双向通信的 端点的抽象。一个套接字就是网络上进程通信的一端,提供了 应用层进程 利用网络协议交换数据的机制。从所处的地位来讲,套接字 上联 应用进程,下联 网络协议栈,是 应用程序 通过 网络协议 进行通信的接口,是 应用程序 与 网络协议根 进行交互的接口。
socket
可以看成是两个网络应用程序进行通信时,各自通信连接中的端点,这是一个 逻辑上的概念。它是网络环境中 进程间通信 的 API
,也是可以被命名和寻址的通信端点,使用中的每一个套接字都有其类型和一个与之相连进程。通信时其中一个网络应用程序将要传输的一段信息写入它所在主机的 socket
中,该 socket
通过与 网络接口卡(NIC
)相连的传输介质将这段信息送到另外一台主机的 socket
中,使对方能够接收到这段信息。socket
是由 IP 地址
和 端口
结合的,提供向应用层进程传送数据包的机制。
socket
本身有“ 插座 ”的意思,在 Linux 环境下,用于表示 进程间网络通信 的 特殊文件类型。本质为 内核 借助 缓冲区 形成的 伪文件。既然是文件,那么理所当然的,我们可以使用 文件描述符 引用套接字。与管道类似的,Linux 系统将其封装成文件的目的是为了 统一接口,使得 读写套接字 和 读写文件 的操作一致。区别是 管道 主要应用于 本地进程间通信,而 套接字 多应用于 网络进程间数据的传递。
使用 文件描述符 fd
引用 socket
:
套接字通信 分两部分:
- 服务器端:被动 接受连接,一般不会主动发起连接
- 客户端:主动 向服务器发起连接
socket
是一套 通信的接口,Linux 和 Windows 都有,但是有一些细微的差别。
2. 字节序
2.1 简介
现代 CPU 的累加器 一次都能 装载(至少)4 字节
(这里考虑 32 位机),即一个整数。那么这 4 字节
在 内存 中排列的顺序 将影响它被累加器装载成的整数的值,这就是字节序问题。在各种计算机体系结构中,对于字节
、字
等的存储机制有所不同,因而引发了计算机通信领域中一个很重要的问题,即通信双方交流的信息单元(比特
、字节
、字
、双字
等等)应该以什么样的顺序进行传送。如果不达成一致的规则,通信双方将无法进行正确的编码/译码从而导致通信失败。
字节序,顾名思义 字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序 (一个字节的数据当然就无需谈顺序的问题了)。
字节序 分为 小端字节序(Big-Endian
) 和 大端字节序(Little-Endian
)。
- 小端字节序则 是指 整数的 低位字节 则存储在内存的 低地址 处,而 高位字节 存储在内存的 高地址 处。
- 大端字节序 是指一个 整数的 最高位字节 (
23 ~ 31 bit
)存储在内存的 低地址 处,低位字节(0 ~ 7 bit
)存储在 内存的 高地址 处;
2.2 字节序举例
小端字节序
-
0x
01
02
03
04
(十六进制,四字节。ff = 255
) -
内存的方向 ----->
-
内存的低位
----->内存的高位
04
03
02
01
0x
11
22
33
44
12
34
56
78
(八个字节)
大端字节序
0x
01
02
03
04
- 内存的方向 ----->
内存的低位
----->内存的高位
01
02
03
04
0x
12
34
56
78
11
22
33
44
- 通过代码检测当前主机的字节序
#include <stdio.h>
int main() {
union {
short value; // 2字节
char bytes[sizeof(short)]; // char[2]
} test;
test.value = 0x0102;
if((test.bytes[0] == 1) && (test.bytes[1] == 2)) {
printf("大端字节序\n");
} else if((test.bytes[0] == 2) && (test.bytes[1] == 1)) {
printf("小端字节序\n");
} else {
printf("未知\n");
}
return 0;
}
2.3 字节序转换函数
当格式化的数据在两台使用不同字节序的主机之间直接传递时,接收端必然错误的解释之。
- 解决问题的方法是:发送端 总是 把要发送的数据 转换成 大端字节序数据 后再发送,而接收端知道对方传送过来的数据总是采用大端字节序,所以接收端可以根据自身采用的字节序决定是否对接收到的数据进行转换(小端机转换,大端机不转换)。
网络字节顺序 是 TCP/IP
中规定好的 一种数据表示格式,它与具体的 CPU 类型、操作系统等无关,从而可以保证数据在不同主机之间传输时能够被正确解释,网络字节顺序 采用 大端排序 方式。
BSD Socket 提供了封装好的 转换接口,方便程序员使用。
- 包括从 主机字节序 到 网络字节序 的 转换函数:
htons
、htonl
; - 从 网络字节序 到 主机字节序 的转换函数:
ntohs
、ntohl
。
h - host 主机,主机字节序
to - 转换成什么
n - network 网络字节序
s - short : unsigned short 无符号短整型,两个字节
l - long : unsigned int 无符号长整型,四个字节
#include <arpa/inet.h>
/*
网络通信时,需要将主机字节序转换成网络字节序(大端),
另外一段获取到数据以后根据情况将网络字节序转换成主机字节序。
*/
// 32位,转IP
uint32_t htonl(uint32_t hostlong); // 主机字节序 -> 网络字节序
uint32_t ntohl(uint32_t netlong); // 网络字节序 -> 主机字节序
// 16位,转换端口
uint16_t htons(uint16_t hostshort); // 主机字节序 -> 网络字节序
uint16_t ntohs(uint16_t netshort); // 网络字节序 -> 主机字节序
#include <stdio.h>
#include <arpa/inet.h>
int main() {
// htonl 转换IP
char buf[4] = {192, 168, 1, 100};
int num = *(int *)buf;
int sum = htonl(num);
unsigned char *p = (char *)∑
printf("%d %d %d %d\n", *p, *(p+1), *(p+2), *(p+3));
printf("=======================\n");
// htons 转换端口
unsigned short a = 0x0102;
printf("a : %x\n", a);
unsigned short b = htons(a);
printf("b : %x\n", b);
printf("=======================\n");
// ntohl 转换IP
unsigned char buf1[4] = {1, 1, 168, 192};
int num1 = *(int *)buf1;
int sum1 = ntohl(num1);
unsigned char *p1 = (unsigned char *)&sum1;
printf("%d %d %d %d\n", *p1, *(p1+1), *(p1+2), *(p1+3));
// ntohs 转换端口
return 0;
}
3. socket 地址
socket 地址 其实是一个 结构体,封装 端口号
和 IP
等信息。 后面的 socket
相关的 api
中需要使用到这个 socket地址。
- 客户端 -> 服务器(
IP
,Port
)
3.1 通用 socket 地址
socket 网络编程接口 中表示 socket 地址 的是 结构体 sockaddr
,其定义如下:
#include <bits/socket.h>
struct sockaddr {
sa_family_t sa_family; // 地址族类型
char sa_data[14]; // 14字节
};
typedef unsigned short int sa_family_t; // 2字节
sa_family
成员是 地址族类型(sa_family_t
)的 变量。地址族类型 通常与 协议族类型 对应。常见的 协议族(protocol family
,也称 domain
)和对应的 地址族(address family
) 如下所示:
宏 PF_*
和 AF_*
都定义在 bits/socket.h
头文件中,且 后者 与 前者 有 完全相同的值,所以二者通常混用。
sa_data
成员用于存放 socket
地址值。但是,不同的协议族的地址值具有不同的含义和长度,如下所示:
由上表可知,14 字节的 sa_data
根本无法容纳多数协议族的地址值。因此,Linux 定义了下面这个 新的通用的 socket 地址结构体,这个结构体不仅提供了足够大的空间用于存放地址值,而且是 内存对齐 的。
#include <bits/socket.h>
struct sockaddr_storage
{
sa_family_t sa_family; // 地址族类型
unsigned long int __ss_align; // 内存对齐
char __ss_padding[ 128 - sizeof(__ss_align) ];
};
typedef unsigned short int sa_family_t;
3.2 专用 socket 地址
很 多网络编程函数 诞生 早于 IPv4 协议,那时候都使用的是 struct sockaddr
结构体,为了向前兼容,现在 sockaddr
退化成了(void *
)的作用,传递一个地址 给函数,至于这个函数是 sockaddr_in
还是 sockaddr_in6
,由地址族确定,然后函数内部再 强制类型转化 为所需的地址类型。
UNIX 本地域协议族 使用如下 专用的 socket
地址结构体:
#include <sys/un.h>
struct sockaddr_un
{
sa_family_t sin_family;
char sun_path[108];
};
TCP/IP 协议族 有 sockaddr_in
和 sockaddr_in6
两个专用的 socket
地址结构体,它们分别用于 IPv4
和 IPv6
:
#include <netinet/in.h>
struct sockaddr_in
{
sa_family_t sin_family; /* __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)];
};
struct in_addr
{
in_addr_t s_addr;
};
struct sockaddr_in6
{
sa_family_t sin6_family;
in_port_t sin6_port; /* Transport layer port # */
uint32_t sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr; /* IPv6 address */
uint32_t sin6_scope_id; /* IPv6 scope-id */
};
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef uint16_t in_port_t;
typedef uint32_t in_addr_t;
#define __SOCKADDR_COMMON_SIZE (sizeof (unsigned short int))
所有 专用 socket 地址
(以及 sockaddr_storage
)类型的变量 在实际使用时都需要转化 为 通用 socket 地址
类型 sockaddr
(强制转化 即可),因为所有 socket
编程接口 使用的地址参数类型都是 sockaddr
。
4. IP地址转换(字符串ip -> 整数,主机、网络字节序的转换 )
通常,人们习惯用 可读性好的字符串 来表示 IP 地址,比如用 点分十进制字符串 表示 IPv4 地址,以及用 十六进制字符串 表示 IPv6 地址。但编程中我们需要先把它们 转化为 整数(二进制数)方能使用。而记录日志时则相反,我们要把整数表示的 IP 地址 转化为 可读的字符串。下面 3 个函数可用于用 点分十进制字符串 表示的 IPv4 地址 和 用 网络字节序整数 表示的 IPv4 地址 之间的转换:
#include <arpa/inet.h>
// 下面的这些函数比较久,使用起来比较麻烦,不推荐使用
in_addr_t inet_addr(const char *cp); // 点分十进制字符串 -> 整数
int inet_aton(const char *cp, struct in_addr *inp); // 点分十进制字符串 -> 整数, 并保存到结构体指针 inp 中
char *inet_ntoa(struct in_addr in); // 整数 -> 点分十进制字符串
下面这对更新的函数也能完成前面 3 个函数同样的功能,并且它们同时适用 IPv4 地址 和 IPv6 地址:⭐️
#include <arpa/inet.h>
// p:点分十进制的IP字符串,n:表示network,网络字节序的整数
int inet_pton(int af, const char *src, void *dst);
af:地址族: AF_INET AF_INET6
src:需要转换的点分十进制的IP字符串
dst:转换后的结果保存在这个里面
// 将网络字节序的整数,转换成点分十进制的IP地址字符串
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
af:地址族: AF_INET AF_INET6
src: 要转换的ip的整数的地址
dst: 转换成IP地址字符串保存的地方
size:第三个参数的大小(数组的大小)
返回值:返回转换后的数据的地址(字符串),和 dst 是一样的
#include <stdio.h>
#include <arpa/inet.h>
int main() {
// 创建一个ip字符串,点分十进制的IP地址字符串
char buf[] = "192.168.1.4"; // 后面默认还有一个字符串结束符
unsigned int num = 0;
// 将点分十进制的IP字符串转换成网络字节序的整数
inet_pton(AF_INET, buf, &num);
unsigned char * p = (unsigned char *)#
printf("%d %d %d %d\n", *p, *(p+1), *(p+2), *(p+3)); // 大端排序
// 将网络字节序的IP整数转换成点分十进制的IP字符串
char ip[16] = "";
const char * str = inet_ntop(AF_INET, &num, ip, 16);
printf("str : %s\n", str);
printf("ip : %s\n", str);
printf("%d\n", ip == str);
return 0;
}
在这里插入图片描述
5. TCP 通信流程
TCP
和 UDP
-> 传输层的协议
UDP
: 用户数据报协议,面向无连接,可以单播,多播,广播, 面向 数据报,不可靠TCP
: 传输控制协议,面向连接的,可靠的,基于 字节流,仅支持单播传输
UDP | TCP | |
---|---|---|
是否创建连接 | 无连接 | 面向连接 |
是否可靠 | 不可靠 | 可靠的 |
连接的对象个数 | 一对一、一对多、多对一、多对多 | 支持一对一 |
传输的方式 | 面向数据报 | 面向字节流 |
首部开销 | 8个字节 | 最少20个字节 |
适用场景 | 实时应用(视频会议,直播) | 可靠性高的应用(文件传输) |
TCP 通信的流程 ⭐️⭐️⭐️
⭐️服务器端 ( 被动接受连接的角色)⭐️
- 创建 一个用于监听的套接字
- 监听:监听有客户端的连接
- 套接字 :这个套接字其实就是一个 文件描述符
- 将这个 监听 文件描述符 和 本地的IP 和 端口 绑定(
IP
和端口
就是 服务器的地址信息)- 客户端 连接 服务器 的时候使用的就是这个
IP
和端口
- 客户端 连接 服务器 的时候使用的就是这个
- 设置 监听,监听的
fd
开始工作 - 阻塞等待,当有客户端发起连接,解除阻塞,接受客户端的连接,会得到一个 和客户端通信的 套接字(
fd
) - 通信
- 接收数据
- 发送数据
- 通信结束,断开连接
⭐️客户端⭐️
- 创建一个用于通信的套接字(
fd
) - 连接服务器,需要指定连接的服务器的
IP
和端口
- 连接成功了,客户端可以直接和服务器 通信
- 接收数据
- 发送数据
- 通信结束,断开连接
6. 套接字函数
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了这个头文件,上面两个就可以省略
int socket(int domain, int type, int protocol);
- 功能:创建一个套接字
- 参数:
- domain: 协议族
AF_INET : ipv4
AF_INET6 : ipv6
AF_UNIX, AF_LOCAL : 本地套接字通信(进程间通信)
- type: 通信过程中使用的协议类型
SOCK_STREAM : 流式协议
SOCK_DGRAM : 报式协议
- protocol : 具体的一个协议。一般写0(默认)
- SOCK_STREAM : 流式协议默认使用 TCP
- SOCK_DGRAM : 报式协议默认使用 UDP
- 返回值:
- 成功:返回文件描述符,操作的就是内核缓冲区。
- 失败:-1
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); // socket命名
- 功能:绑定,将fd 和本地的IP + 端口进行绑定
- 参数:
- sockfd : 通过socket函数得到的文件描述符
- addr : 需要绑定的socket地址,这个地址封装了ip和端口号的信息
- addrlen : 第二个参数结构体占的内存大小
int listen(int sockfd, int backlog); /proc/sys/net/core/somaxconn
- 功能:监听这个socket上的连接
- 参数:
- sockfd : 通过socket()函数得到的文件描述符
- backlog : 未连接的队列 和 已经连接的队列 和的最大值(以使用 cat /proc/sys/net/core/somaxconn 查看:4096),一般不用设置那么大,如:8/128
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- 功能:接收客户端连接,默认是一个阻塞的函数,阻塞等待客户端连接
- 参数:
- sockfd : 用于监听的文件描述符
- addr : 传出参数,记录了连接成功后客户端的地址信息(ip,port)
- addrlen : 指定第二个参数的对应的内存大小
- 返回值:
- 成功 :用于通信的文件描述符
- -1 : 失败
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 功能: 客户端连接服务器
- 参数:
- sockfd : 用于通信的文件描述符
- addr : 客户端要连接的服务器的地址信息
- addrlen : 第二个参数的内存大小
- 返回值:成功 0, 失败 -1
ssize_t write(int fd, const void *buf, size_t count); // 写数据
ssize_t read(int fd, void *buf, size_t count); // 读数据
TCP 通信实现
(1)服务器端
创建 server.c
文件
// TCP 通信的服务器端
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main() {
// 1.创建socket(用于监听的套接字)
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1) {
perror("socket");
exit(-1);
}
// 2.绑定
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
// inet_pton(AF_INET, "192.168.216.129", &saddr.sin_addr.s_addr); // 主机字节序 -> 网络字节序
saddr.sin_addr.s_addr = INADDR_ANY; // 0.0.0.0 (任意地址)
saddr.sin_port = htons(9999); // 主机字节序要转为网络字节序
int ret = bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));
if(ret == -1) {
perror("bind");
exit(-1);
}
// 3.监听
ret = listen(lfd, 8);
if(ret == -1) {
perror("listen");
exit(-1);
}
// 4.接收客户端连接
struct sockaddr_in clientaddr; // 客户端地址信息
int len = sizeof(clientaddr);
int cfd = accept(lfd, (struct sockaddr *)&clientaddr, &len); // 阻塞函数;连接成功,返回用于通信的文件描述符
if(cfd == -1) {
perror("accept");
exit(-1);
}
// 输出客户端的信息
char clientIP[16];
inet_ntop(AF_INET, &clientaddr.sin_addr.s_addr, clientIP, sizeof(clientIP)); //IP:网络字节序 -> 主机字节序
unsigned short clientPort = ntohs(clientaddr.sin_port); //端口:网络字节序 -> 主机字节序
printf("client ip is %s, port is %d\n", clientIP, clientPort);
// 5.通信
char recvBuf[1024] = {0};
while(1) {
// 获取客户端的数据
int num = read(cfd, recvBuf, sizeof(recvBuf)); // 如果客户端没有发送数据,也会阻塞
if(num == -1) {
perror("read");
exit(-1);
} else if(num > 0) {
printf("recv client data : %s\n", recvBuf);
} else if(num == 0) {
// 表示客户端断开连接
printf("clinet closed...");
break;
}
char * data = "hello,i am server";
// 给客户端发送数据
write(cfd, data, strlen(data));
}
// 关闭文件描述符
close(cfd);
close(lfd);
return 0;
}
(2)客户端
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main() {
// 1.创建套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd == -1) {
perror("socket");
exit(-1);
}
// 2.连接服务器端
struct sockaddr_in serveraddr;
serveraddr.sin_family = AF_INET;
inet_pton(AF_INET, "192.168.216.129", &serveraddr.sin_addr.s_addr);
serveraddr.sin_port = htons(9999); // 要和服务器端的一致
int ret = connect(fd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
if(ret == -1) {
perror("connect");
exit(-1);
}
// 3. 通信
char recvBuf[1024] = {0};
while(1) {
char * data = "hello,i am client";
// 给客户端发送数据
write(fd, data , strlen(data));
sleep(1);
int len = read(fd, recvBuf, sizeof(recvBuf));
if(len == -1) {
perror("read");
exit(-1);
} else if(len > 0) {
printf("recv server data : %s\n", recvBuf);
} else if(len == 0) {
// 表示服务器端断开连接
printf("server closed...");
break;
}
}
// 关闭连接
close(fd);
return 0;
}
注: 仅供学习参考,如有不足,欢迎指正!