Linux网络:TCP & UDP socket
- socket 套接字
- sockaddr
- 网络字节序
- IP地址转换
- bzero
- UDP socket
- socket
- bind
- recvfrom
- sendto
- TCP socket
- socket
- bind
- listen
- connect
- accept
- send
- recv
本博客讲解 Linux 下的 TCP 和 UDP 套接字编程。无论是创建套接字、绑定地址,还是发送和接收数据,都会详细讲解。希望这篇博客能帮你更轻松地理解这些概念,并在实践中得心应手。
socket 套接字
套接字是网络通信的基础,它提供了一种标准化的方式,使得程序能够通过网络发送和接收数据。
套接字的种类非常多样,比如:
unix socket
域套接字:用于本地通信inet socket
网络套接字:用于网络通信raw socket
原始套接字:用于网络管理
sockaddr
sockaddr
是一个在网络编程中用于表示“套接字地址”的通用结构体。它的作用是存储网络地址信息,供套接字函数使用,此时套接字函数就知道要对哪一台主机进行网络操作。sockaddr
包含在头文件<arpa/inet.h>
中。
sockaddr
结构体不能直接存储 IPv4 或 IPv6 的地址信息,在实际使用中,通常会用到它的具体子类型,如 sockaddr_in
(用于 IPv4)和 sockaddr_in6
(用于 IPv6),sockaddr_un
(用于域套接)。
如图:
为了管理多种套接字,所有套接字的头部都是一个16位的地址类型,用于辨别这个结构体表示哪一个套接字。当操作sockaddr
的时候,读取前16位就知道这个sockaddr
具体是哪一种套接字。随后再进行类型转化,变成对应套接字类型的结构体,此时就能对具体的套接字做操作了。
sockaddr
的定义如下:
struct sockaddr {
unsigned short sa_family;
char sa_data[14];
};
sa_family
:表示协议族,例如 IPv4 使用AF_INET
,IPv6 使用AF_INET6
,域套接使用AF_UNIX
其中最常用的就是AF_INET
进行IPv4通信。其对应的具体结构体为struct sockaddr_in
,定义如下:
struct sockaddr_in {
sa_family_t sin_family;
__be16 sin_port;
struct in_addr sin_addr;
unsigned char __pad[__SOCK_SIZE__ - sizeof(short int) -
sizeof(unsigned short int) - sizeof(struct in_addr)];
};
sin_family
:前16位,表示套接字类型sin_port
:表示端口号sin_addr
:表示IPv4地址
此处有一个小细节,sin_addr
的类型是struct in_addr
,按理来说IPv4
的地址占32
位,用一个int
类型即可存储,这里的结构体又是啥?
其实是Linux
对其进行了额外的一层封装:
struct in_addr {
__be32 s_addr;
};
此处的__be32
就是一个32
位的整型,也就是说存储地址的时候,要用sockaddr_in.sin_addr.s_addr
,此处嵌套了两层结构体。
基于IP地址和端口号,此时就可以定位到全世界的一个主机上的一个具体进程,此时就可以进行后续的网络通信了!
网络字节序
在不同主机内存中,字节数据分为大端字节序和小端字节序,假设一个大端主机和一个小端主机进行通信,此时就会发生错误,因为两别解析数据的方式不同,于是网络字节序
出现了。
TCP/IP 规定,大端字节序为网络字节序,在网络中通信必须使用网络字节序
也就是说,如果当前主机是大端主机,那么收发数据时不做处理。如果是小端主机,那么收发数据时要把数据转化为小端字节序。
在此处讲解这个问题,就是因为在填写sockaddr_in
内部的IP和端口号时,内部数据的字节序要使用网络字节序。
假设我们现在要往sockaddr_in
内部填入端口号22:
struct sockaddr_in sock;
sock.sin_port = 22;
以上就是一个错误示例,因为不清楚代码的运行环境是大端还是小端,此时存入的数据22就有可能不是网络字节序,所以要先将22转为网络字节序,Linux
为此提供了专门的接口。
以下接口用于序列转化,需要头文件<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);
上面接口的用法非常好记,比如htonl
拆解为host to net long
,也就是将long
类型从主机的字节序转化为网络字节序。同理htns
就是将short
类型从主机的字节序转化为网络字节序。
反过来,ntohl
就是net to host long
,将long
类型从网络字节序转化为主机的字节序。
如果主机是大端,那么这些函数对数据不做任何处理。如果主机是小端,那么就会进行大小端的转化。
由于sin_port
的类型是int16
,所以在写入端口前要用htons
进行转化:
struct sockaddr_in sock;
sock.sin_port = htons(22);
IP地址转换
如果想要给一个sockaddr_in
结构体填入数据,那么第一个问题就是IP地址的格式问题。
IP地址有两种基本格式,4字节序列,以及点分十进制,如果拿到的IP地址格式与自己所需的类型不符,此时就要考虑两种格式之间转化的问题了。但是不必担心,这个问题也有对应的接口解决。
以下函数需要头文件:<sys/socket.h>
,<netinet/in.h>
,<arpa/inet.h>
。
inet_addr
用于将点分十进制转化为四字节序列:
in_addr_t inet_addr(const char *cp);
cp
:指向点分十进制IP地址字符串的指针
如果转化错误,返回INADDR_NONE
,本质上是数字-1
。
示例:
struct sockaddr_in sock;
sock.sin_addr.s_addr = inet_addr("127.0.0.1");
此处要注意,实际存储IP序列的是.sin_addr.s_addr
,这里有两层结构体嵌套。
那么有人就有疑问了,都说了存入sockaddr_in
中的数据必须是网络字节序,此处将点分十进制转化为四字节序列后,是不是要再转化为网络字节序?
比如这样:
struct sockaddr_in sock;
sock.sin_addr.s_addr = htons(inet_addr("127.0.0.1"));
其实不用,inet_addr
会完成两个任务:
- 将点分十进制转化为四字节序列
- 将四字节序列转化为网络字节序
也就是说在inet_addr
内部已经顺带完成了转化网络字节序的工作!
inet_ntoa
用于将四字节序列转化为点分十进制:
char *inet_ntoa(struct in_addr in);
bzero
目前初始化一个sockaddr_in
的代码如下:
struct sockaddr_in sock;
sock.sin_addr.s_addr = inet_addr("127.0.0.1");
sock.sin_port = htons(22);
一个要点是IP地址格式的转化,另一个要点是网络字节序。
但是这样还不完整,还有一个成员sin_family
没有初始化,对于IPv4通信,此处填入AF_INET
,这个在之前已经说过。
struct sockaddr_in sock;
sock.sin_family = AF_INET;
sock.sin_addr.s_addr = inet_addr("127.0.0.1");
sock.sin_port = htons(22);
目前为止已经初始化了一个比较完整的sockaddr_in
了。但是创建结构体时,分配到的内存原先有可能存储了其他数据,因为有填充部分我们不初始化,为了保证之前的数据不会影响,还要把整个结构体的内存全部置为0
。
bzero
用于初始化一段内存为0
,需要头文件<strings.h>
,定义如下:
void bzero(void* s, size_t n);
s
:要初始化内存的地址n
:要初始化的字节数
在初始化整个sockaddr_in
之前,先用bzero
将内存清零:
struct sockaddr_in sock;
bzero(&sock, sizeof(sock));
现在我们就有一个比较完整的sockaddr_in
初始化过程了:
struct sockaddr_in sock;
bzero(&sock, sizeof(sock));
sock.sin_family = AF_INET;
sock.sin_addr.s_addr = inet_addr("127.0.0.1");
sock.sin_port = htons(22);
上述所有内容,都只是在初始化一个套接字所需的地址,还没有真正的创建套接字,接下来就了解操作真正的套接字的接口。
UDP socket
socket
socket
函数用于创建一个新的套接字,需要头文件<sys/types.h>
,<sys/socket.h>
,函数原型如下:
int socket(int domain, int type, int protocol);
参数:
domain
:指定协议族type
:指定套接字类型protocol
:通常设为 0,表示使用默认协议
返回值:
- 成功:返回新创建的套接字的文件描述符
- 失败:返回 -1
如果要创建UDP
套接字,那么参数应该填为:
int fd = socket(AF_INET, SOCK_DGRAM, 0);
AF_INET
:表示IPv4SOCK_DGRAM
:表示UDP套接字,DGRAM
为datagram
缩写,即数据报
socket
的返回值是一个整型,本质是一个文件描述符,Linux
一切皆文件,后续对网络的操作就是对这个文件的操作。比如向网络中发送消息,其实就是向文件中写入数据。
bind
当创建完套接字后,这个套接字还没有指定和哪一个主机通信,此时就需要IP地址和端口号,之前讲的sockaddr_in
就派上用场了!
bind
函数用于给套接字绑定IP地址和端口号,指定和哪一台主机通信,需要头文件<sys/types.h>
,<sys/socket.h>
,函数原型如下:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
sockfd
:套接字对应的文件描述符addr
:指向sockaddr
的指针addrlen
:sockaddr
的真实类型的长度
返回值:
- 成功:返回0
- 失败:返回-1,并设置错误码
之前讲解的sockaddr_in
是sockaddr
的一种,此处注意传入的是struct sockaddr *
,也就是说sockaddr_in
类型的变量传入的时候要进行类型转化。
由于不知道struct sockaddr *
具体指向哪一种套接字地址,所以第三个参数要传入真实类型结构体大小,防止越界访问。
示例:
// 1.创建UDP套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// 2.初始化套接字要通信的目标主机地址
struct sockaddr_in sock;
bzero(&sock, sizeof(sock));
sock.sin_family = AF_INET;
sock.sin_addr.s_addr = inet_addr("1.2.3.4");
sock.sin_port = htons(8888);
// 3.绑定地址到套接字
bind(sockfd, (struct sockaddr*)&sock, sizeof(sock));
比如以上示例中,绑定的地址为1.2.3.4
,端口为8888
,这表明只有1.2.3.4
可以通过端口8888
和这个套接字与本主机进行通信。
此处不代表1.2.3.4
以外的地址不能与当前主机通信了,只是对于被绑定套接字而言。就好像一个学校有好几个大门,其中某个门叫做“校长专用通道”,那么只有校长可以通过这个门进入校园,其他学生不能通过这个门进入校园,但是其他学生也可以通过其它的门进入。
recvfrom
绑定好主机后,就可以开始进行网络通信了,UDP基于报文通信,所以发送的数据也是报文形式。
recvfrom
函数用于接收数据报,需要头文件<sys.types.h>
和<sys/socket.h>
,函数原型如下:
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
参数:
sockfd
:套接字的文件描述符buf
:指向接收缓冲区len
:最多可以读取的字节数,一般防止接收缓冲区越界flags
:控制发送行为的标志,通常设为 0src_addr
:输出型参数,得到消息发送方的sockaddr
,即IP地址和端口号addrlen
:输出型参数,得到src_addr
真实结构的大小
返回值:
- 成功时返回实际读取的字节数
- 失败时返回 -1,并设置错误码
示例:
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in src_addr;
bzero(&src_addr, sizeof(src_addr));
src_addr.sin_family = AF_INET;
src_addr.sin_addr.s_addr = inet_addr("0.0.0.0");
src_addr.sin_port = htons(8888);
bind(sockfd, (struct sockaddr*)&src_addr, sizeof(src_addr));
// 输出形参数
struct sockaddr_in peer;
socklen_t peer_len;
// 接收消息
char* buf[1024];
recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr*)&peer, &peer_len);
此处的peer
用于接收发送方的sockaddr
,也就是IP地址和端口号。发送方发送的报文被存储在buf
中。
sendto
sendto
函数用于发送数据报,需要头文件<sys.types.h>
和<sys/socket.h>
,函数原型如下:
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
参数:
sockfd
:套接字的文件描述符buf
:指向要发送的数据缓冲区的指针len
:要发送的数据的字节数flags
:控制发送行为的标志,通常设为 0dest_addr
:指向消息接收方目标地址sockaddr
的指针addrlen
:dest_addr
真实结构的大小。
返回值:
- 成功时返回实际发送的字节数
- 失败时返回 -1,并设置错误码
- 返回0表示没有读取到数据
示例:
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in dest_addr;
bzero(&dest_addr, sizeof(dest_addr));
dest_addr.sin_family = AF_INET;
dest_addr.sin_addr.s_addr = inet_addr("192.168.1.100");
dest_addr.sin_port = htons(8888);
const char *message = "Hello, UDP!";
sendto(sockfd, message, strlen(message), 0, (struct sockaddr *)&dest_addr, sizeof(dest_addr));
此处给192.168.1.100
地址,通过端口8888
发送了一个报文,内容是”Hello, UDP!“
。
不知你有没有发现这一份代码有一点蹊跷,这个套接字没有bind
。此处程序作为客户端向主机主动发起报文,那么操作系统会给这个进程分配一个端口。在还没有发送数据前,我们并不知道这个随机分配的端口是多少,无法进行bind
,这该咋办?
在发送数据时,有两种情况:
- 如果套接字没有绑定端口号,Linux会自动为其分配端口号,并完成绑定,随后通过随机分配的端口发送数据,这种行为称为
隐式绑定
- 如果套接字已经绑定了端口号,Linux则直接通过指定端口发送数据
因此以上代码中没有bind
这个过程。
TCP socket
socket
与UDP一样,TCP也要通过socket
函数创建套接字,只是参数略有不同。
int socket(int domain, int type, int protocol);
domain
:对于IPv4,使用AF_INET
type
:填入SOCK_STREAM
,即面向字节流的TCP服务protocol
:填0即可
创建TCP套接字代码如下:
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
后续就可以通过文件描述符sockfd
操作TCP连接了。
bind
同样的,TCP服务端要通过bind
进行绑定,表明自己连接的主机以及端口号。而客户端不用,因为发送数据时操作系统会隐式绑定。
listen
当TCP服务端启动后,此时就要等待别人来连接自己,此时就处于listen
状态。而进入该状态,需要调用函数listen
,包含在头文件<sys/types.h>
,<sys/socket.h>
,函数原型如下:
int listen(int sockfd, int backlog);
参数:
sockfd
:进行监听的套接字backlog
:TCP全连接队列的大小,此处暂时不管,设为一个适中的值,比如10
返回值:
- 成功:返回0
- 失败:返回-1
示例:
struct sockaddr_in sock;
bzero(&sock, sizeof(sock));
sock.sin_family = AF_INET;
sock.sin_addr.s_addr = inet_addr("0.0.0.0");
sock.sin_port = htons(8888);
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
bind(sockfd, (struct sockaddr*)&sock, sizeof(sock));
listen(sockfd, 10);
while (true);
假设该进程为test.exe
,执行该程序后,通过netstat
指令查看:
此时进程就处于LISTEN
状态,即等待连接。
connect
对于客户端来说,需要向服务端发起连接请求,此时需要函数connect
,包含在头文件<sys/types.h>
,<sys/socket.h>
中,函数原型如下:
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
sockfd
:发起连接的套接字addr
:发起连接对象的信息,即IP地址何端口号addrlen
:addr
的真实类型的大小
返回值:
- 成功:返回0
- 失败:返回-1并设置错误码
以下是一个客户端的示例:
// 构建目标主机信息
struct sockaddr_in sock;
bzero(&sock, sizeof(sock));
sock.sin_family = AF_INET;
sock.sin_addr.s_addr = inet_addr("127.0.0.1");
sock.sin_port = htons(8888);
// 发起TCP连接
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
connect(sockfd, (struct sockaddr*)&sock, sizeof(sock));
accept
当服务端在listen
状态下,接收到来自客户端的连接,就可以选择同意这个连接,此时需要函数accept
,包含在头文件<sys/types.h>
,<sys/socket.h>
中,函数原型如下:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数:
sockfd
:之前进行listen
的套接字sockaddr
:输出客户端的信息,即IP地址和端口号addrlen
:sockaddr
的真实大小
返回值:
- 成功:返回一个文件描述符
- 失败:返回-1并设置错误码
奇怪的事情来了,为什么accept
会返回一个文件描述符?
此处的文件描述符,其实就是一个套接字socket
,先前说过套接字是通过文件描述符来操作的。此处返回的套接字,是专门用于和客户端通信的套接字。也就是说后续与客户端通信,使用这个新的套接字完成。
对于TCP服务端来说,有两种套接字,一种是用于listen
的套接字,其负责监听指定端口,查看有没有到来的连接。一旦连接建立成功,此时与客户端的通信过程由新的套接字完成。
示例:
struct sockaddr_in sock;
bzero(&sock, sizeof(sock));
sock.sin_family = AF_INET;
sock.sin_addr.s_addr = inet_addr("0.0.0.0");
sock.sin_port = htons(8888);
int listen_sockfd = socket(AF_INET, SOCK_STREAM, 0);
bind(listen_sockfd, (struct sockaddr*)&sock, sizeof(sock));
listen(listen_sockfd, 10);
// peer用于接收客户端信息
struct sockaddr_in peer;
socklen_t peer_len;
// 接收来自客户端的连接
int sockfd = accept(listen_sockfd, (struct sockaddr*)&peer, &peer_len);
以上是一个完整的TCP服务端启动过程,一般而言accpet
之后,会使用多进程/多线程完成后续的通信,而主进程继续listen
其它的连接,本博客不展示该过程了。
此处的listen_sockfd
就是专门用于连接的套接字,而最后的sockfd
是与客户端通信的套接字。
send
当连接建立成功后,就可以开始收发消息了,TCP是面向字节流的,与UDP不同,TCP可以把sockfd
文件描述符完全当作一个文件,完成消息的读写。
发送消息使用send
函数,包含在头文件<sys/types.h>
,<sys/socket.h>
中,函数原型如下:
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
参数:
sockfd
:用于通信的套接字buf
:要发送的数据缓冲区len
:要发送的数据的长度flags
:设为0即可
不知道你有没有发现,它和文件写入函数write
几乎没有差别:
ssize_t write(int fd, const void *buf, size_t count);
前三个参数没有区别,而send
的第四个参数固定为0。其实send
和write
一样,都是直接向文件中写入字符串的,这符合TCP面向字节流的特性,在发送数据时,也可以用write
代替send
。
recv
接收消息使用recv
函数,包含在头文件<sys/types.h>
,<sys/socket.h>
中,函数原型如下:
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
参数:
sockfd
:用于通信的套接字buf
:数据的接收缓冲区len
:最大接收数据的长度flags
:设为0即可
同样,其实recv
和read
函数差不多:
ssize_t read(int fd, void *buf, size_t count);
在读取TCP连接的数据的时可以使用两者的任何一个。