这是系列博客中的第二篇,导航如下

在Linux环境中,套接字(Socket)是一种用于进程间通信(IPC)的机制,但这里的进程,包括了同一网络下其它主机的进程,所以它被广泛应用于网络编程。它允许不同计算机上的进程或同一计算机上的不同进程之间进行数据交换。

由于此时我们的网络编程基础较少,所以本文的内容更偏向于实践,而不是原理。希望能在动手实践中加深对网络编程的熟悉程度,减少陌生感

在本文中,我们将:

  • 知识铺垫:认识IP地址, 端口号, 网络字节序等网络编程中的基本概念(简略)
  • socket API学习
  • 实现一个简单的UPD客户端/服务器
  • 实现一个简单的TCP客户端/服务器(服务器包括单连接版本,多进程版本,多线程版本)

知识铺垫

理解源IP地址和目的IP地址

在数据包的头部中,包含两个IP地址,分别叫做源IP地址目的IP地址

为什么要有两个IP地址?因为一般通过网络建立的通信都是双向的,而且一方接收请求后,一般还要把响应发送回去,所以含有两个IP地址才能方便地建立双向链接和通信。而且数据包每经过一个中间主机,都会被询问一次源和目的,就像唐僧常说的口头禅:“贫僧自东土大唐(源IP)而来,要到西天(目的IP)取经去”。

总结:通过IP协议,我们能找到唯一的主机建立网络通信。

认识端口号

套接字(Socket)是一种用于进程间通信(IPC)的机制,然而IP协议只能指定唯一主机,想要进程间通信明显还不够。那在一台主机上,怎么标识唯一的进程呢?端口号(port)应运而生。

  • 端口号是一个2字节16位的整数
  • 正如上文所说,端口号用来标识本机的唯一进程
  • 互斥性:为保证唯一性,一个端口号只能被一个进程占用

综上,通过IP地址+端口号便能够唯一地表示网络上的某一台主机的某一个进程。

端口号 与 进程ID 辨析

既然端口号port进程ID都可以唯一地标识一个进程,那么这两者有何相似与差异呢?

  • 进程ID:一个线程对应一个pid,一个pid对应一个线程,且操作系统就是用pid来调度线程的
  • 端口(port):端口号更像是一种被所有线程共享的互斥资源,每个都只有一份,但是同一个线程可以申请占用多个端口号,而一个端口号因为只有一份,只能被一个线程占用

源端口号和目的端口号

和源IP地址和目的IP地址配套使用,用于建立两台主机上特定的两个线程间的通信。

认识TCP协议和UDP协议

这里仅仅是有一个大概的了解,对协议原理更详细的较少将在后面的博客中提出

TPC协议

TCP(Transmission Control Protocol)是一种可靠的传输协议,特性如下

  • 传输层协议
  • 有连接
  • 可靠传输
  • 面向字节流

UDP协议

UDP(User Datagram Protocol)是一种不可靠的传输协议,特性如下

  • 传输层协议
  • 无连接
  • 不可靠传输
  • 面向数据报

网络字节序

我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分,
网络数据流同样有大端小端之分,这给网络中不同主机的通信带来了困难, 那么如何定义网络数据流的地址,以保证不同机器能通过网络通信呢?

  • 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出
  • 收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存
  • 因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址
  • TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节
  • 不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据
  • 如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可

关于网络字节序的规则如上,显然这些都由程序员来做,效率还是太低了。

为了提高编程效率,同时也为了提高代码移植性,使同样的C代码在大端计算机和小端计算机上都能正常运行,可以调用以下头文件的库函数做网络字节序和主机字节序的转换

1
2
3
4
5
6
#include <arpa/inet.h>

uint32_t htonl(uint32_t hostlong); //32位主机host字节序转网络net字节序
uint16_t htons(uint16_t hostshort);//16位主机host字节序转网络net字节序
uint32_t ntohl(uint32_t netlong); //32位网络net字节序转主机host字节序
uint16_t ntohs(uint16_t netshort); //16位网络net字节序转主机host字节序
  • 这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数
  • 例如htons表示将16位的短整数从主机字节序转换为网络字节序,例如将port端口号转换后准备发送
  • 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回
  • 如果主机是大端字节序,这些 函数不做转换,将参数原封不动地返回。

Socket编程

<sys/socket.h>提供了一系列套接字相关的接口,用于进行网络通信服务

基于Linux中一切皆文件的祖训,在套接字编程所创建的socket文件,也是作为文件管理的,socket文件也会申请自己的文件描述符,和其它打开的文件一起被管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <sys/types.h>      
#include <sys/socket.h>

// 创建 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);

常用接口如上,具体使用方法稍后在代码中展示

struct sockaddr*介绍

为什么不直接介绍struct sockaddr

struct sockaddr是个很简单的结构体,具有类似如下的结构

1
2
3
4
5
struct sockaddr
{
sa_family_t sa_family;
char sa_data[14];
}

然而实际上在函数里用的也不是struct sockaddr,而是其它成员变量更丰富的结构体

唯一的用处就是在函数传参时防止类型不匹配导致的报错,函数内部如何处理指针指向的内存,取决于sa_family的值。struct sockaddr*指针的作用有点类似于面向对象中父类指针在函数传参中的作用

各种sockaddr_家族

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//使用ipv4地址
struct sockaddr_in {
sa_family_t sin_family; // 地址族
in_port_t sin_port; // 端口号
struct in_addr sin_addr; // IPv4地址
};

//使用ipv6地址
struct sockaddr_in6 {
sa_family_t sin6_family; // 地址族
in_port_t sin6_port; // 端口号
uint32_t sin6_flowinfo; // 流信息
struct in6_addr sin6_addr; // IPv6地址
uint32_t sin6_scope_id; // 作用域ID
};

//用于Unix域套接字(IPC通信)
//包含本地文件系统路径
struct sockaddr_un {
sa_family_t sun_family; // 地址族
char sun_path[108]; // Unix域套接字路径
};

//用于链路层(如以太网)
//包含物理设备信息和地址。
struct sockaddr_ll {
uint16_t sll_family; // 地址族
uint16_t sll_protocol; // 协议
int sll_ifindex; // 接口索引
uint16_t sll_hatype; // 硬件类型
uint8_t sll_pkttype; // 数据包类型
uint8_t sll_halen; // 硬件地址长度
uint8_t sll_addr[8]; // 源MAC地址
};

用途

接下来的介绍以struct sockaddr_in为例

1
2
3
4
5
struct sockaddr_in {
sa_family_t sin_family; // 地址族
in_port_t sin_port; // 端口号
struct in_addr sin_addr; // IPv4地址
};

struct sockaddr*指向的结构体的用处对于服务器/客户端有所不同

  • 对服务器提供服务
    • 规定提供网络服务的ip格式(ipv4/ipv6)
    • 规定提供网络服务的ip(因为一个电脑可以有多个ip,后文解释)
    • 规定提供网络服务的端口号值
  • 对服务器连接客户端
    • 储存客户端的ip地址
    • 储存客户端的port端口
  • 对客户端
    • 存储网络消息源的信息

in_addr的底层结构

1
2
3
4
5
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr;
};

in_addr用来表示一个IPv4的IP地址. 其实就是一个32位的无符号整数;

UDP通信编程

使用接口

  • int socket(int domain, int type, int protocol);
    • 用于创建套接字文件,获取套接字文件描述符fd
    • domain规定域名通信协议,比如IPV4IPV6本地通信协议
    • type通信类型,SOCK_DGRAM表示面向数据报,SOCK_STREAM表示面向字节流,以及更多的类型
    • protocol协议代码,可选内容取决于domain参数,可为0
    • 返回值为套接字文件的文件描述符
  • int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
    • 用于给指定sockfd绑定网络通信协议
    • sockfd文件描述符
    • addr就是上面介绍的struct sockaddr的子类
    • addrlenaddr指向结构体的大小
    • 返回值成功则返回0,失败则返回1
  • ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);
    • sockfd文件描述符
    • buf字节串缓冲区,用于存放接收到的字节流
    • len缓冲区大小,防止越界访问
    • flags为位图,可以传多个参数。本次介绍不传参,所以传入一个0
    • src_addr指向储存消息源信息的结构体地址
    • addrlen传入结构体的大小
  • 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为位图,可以传多个参数。本次介绍不传参,所以传入一个0
    • src_addr指向储存目标信息的结构体地址
    • addrlen传入结构体的大小