TCP socket 网络编程基础
微信搜索“编程笔记本”,获取更多信息
------------- codingbook2020 -------------
前面我们已经学习了进程、线程以及 TCP/IP 协议等相关内容,今天我们就开始学习 TCP 网络编程。
TCP 协议是 TCP/IP 协议族中一个很重要的协议,由于它传输的稳定性,在很多程序中都在使用,例如 HTTP、FTP 等协议都是在 TCP 协议的基础上构建的。接下来我们将介绍 TCP 套接字编程的基础知识,主要有如下几部分:
- 套接字编程的基础知识部分,介绍套接字编程中经常使用的套接字地质结构,对内核和应用层之间的内存数据传递方式进行简单的介绍
- TCP 网络编程的流程部分,简单介绍 TCP 套接字服务器、客户端的编程框架,对
socket()
、bind()
、listen()
、accept()
、connect()
、close()
函数进行介绍,并提及如何使用read()
、write()
函数进行数据的读取和发送 - 通过一个简单的服务器/客户端的例子介绍 TCP 网络编程的基本流程和代码
- 介绍如何对信号进行截取,特别是 SIGPIPE 信号和 SIGINT 信号
目录
文章目录
1. 套接字编程基础知识
在进行套接字编程之前需要对基本的数据结构有所了解。下面我们将介绍套接字地址结构定义的形式以及如何使用套接字的地址结构。
1.1 套接字地址结构
套接字编程需要指定套接字的地址作为参数,不同的协议族有不同的地址结构定义方式。这些地质结构通常以 sockaddr_
开头,每一个协议族有一个唯一的后缀,例如对于以太网,其结构名称为 sockaddr_in
。
1. 通用套接字数据结构
通用套接字地址类型的定义如下,它可以在不同协议族之间进行强制转换。
// 套接字地址结构
struct sockaddr {
sa_family_t sa_family; // 协议族
char sa_data[14]; // 协议族数据
}
上述结构中协议族成员变量 sa_family 的类型是 sa_family_t
,这个类型其实是 unsigned short
类型, typedef unsigned short sa_family_t
,因此成员变量 sa_family 的长度为 16(2+14)个字节。
2. 实际使用的套接字数据结构
在网络程序设计中所使用的函数中几乎所有的套接字函数都用这个结构作为参数,但是使用 struct sockaddr
不方便进行设置。在以太网中,一般采用结构 struct sockaddr_in
进行设置,其定义如下:
// 以太网套接字地址结构
struct sockaddr_in {
u8 sin_len; // 本结构的长度,16
u8 sin_family; // 协议族,通常为AF_INET
u16 sin_port; // 16位端口号,网络字节序
struct in_addr sin_addr; // 32位IP地址
char sin_zero[8]; // 保留
}
其中,struct in_addr
表示 IP 地址结构,定义如下:
// IP地址结构
struct in_addr {
u32 s_addr; // 32位IP地址,网络字节序
}
3. struct sockaddr_in
与 struct sockaddr
的关系
struct sockaddr_in
与 struct sockaddr
的大小是一样的,都是 16 个字节,在进行地址结构设置的时候,通常的方法是利用结构 struct sockaddr_in
进行设置,然后强制转换为结构 struct sockaddr
类型。
2. TCP 网络编程流程
TCP 网络编程是目前比较通用的方式,例如 HTTP 协议、FTP 协议等很多广泛应用的协议均基于 TCP 协议。TCP 协议主要为 C/S 模式,即客户端(C)、服务器(S)模式,这两种模式之间的程序设计流程存在很大差别。
2.1 TCP 网络编程架构
TCP 网络编程有两种模式,一种是服务器模式,另一种是客户端模式。服务器模式创建一个服务程序,等待客户端用户的连接,接收到用户的连接请求后,根据用户的请求进行处理;客户端模式则根据目的服务器的地址和端口进行连接,向服务器发送请求,并对服务器的响应进行处理。
1. 服务端的程序设计模式
如下图所示为 TCP 连接的服务器模式的程序设计流程。
- 套接字初始化过程中,根据用户对套接字的需求来确定套接字的选项。它按照用户定义的网络类型、协议类型和具体的协议标号等参数来定义。系统根据用户的需求生成一个套接字文件描述符供用户使用
- 套接字与端口的绑定过程中,将套接字与一个地址结构进行绑定。绑定之后,在进行网络程序设计的时候,套接字所代表的 IP 地址和端口地址以及协议类型等参数按照绑定值进行操作
- 由于一个服务器需要满足多个客户端的连接请求,而服务器在某个时刻仅能处理有限个客户端的连接请求,所以服务器需要设置服务端排队队列的长度。服务器在侦听连接时会设置这个参数,限制客户端中等待服务器处理的连接请求的队列长度
- 在客户端发送连接请求之后,可以从套接字文件描述符中读取数据或者向描述符发送数据。
- 当服务器处理完数据,要结束与客户端的通信过程的时候,需要关闭套接字连接
图片
2. 客户端的程序设计模式
上图所示为客户端模式。客户端程序设计模式流程与服务端的处理模式流程类似,二者的不同之处是客户端在套接字初始化后可以不进行地址绑定,而是直接连接服务器端。客户端连接服务器的处理过程中,客户端根据用户设置的服务器地址、端口等参数与特定的服务器程序进行通信。
3. 客户端与服务器的交互过程
客户端与服务器的连接、读写数据、关闭过程中有交互过程。
- 客户端的连接过程,对服务器端来说是接收过程,在这个过程中客户端与服务器进行三次握手,建立 TCP 连接。建立 TCP 连接后,客户端与服务器之间可以进行数据的交互
- 客户端与服务器之间的数据交互是相对的过程,客户端的读数据过程对应了服务端的写数据过程,客户端的写数据过程对应了服务端的读数据过程
- 在服务器和客户端之间的数据交互完毕之后,关闭套接字连接
2.2 创建网络插口函数 socket()
网络程序设计中的套接字系统调用 socket()
函数来获得文件描述符。
1. socket()
函数介绍
socket()
函数的原型如下:
int socket(int domain, int type, int protocal);
这个函数创建一个协议族为 domain 、协议类型为 type 、协议编号为 protocol 的套接字文件描述符。如果函数调用成功,会返回一个表示这个套接字的文件描述符,失败时返回 -1 。
**参数 domain 用于设置网络通信的域,函数 socket() 根据这个参数选择通信协议的族。**在以太网中应该选择使用 PF_INET
这个域。
参数 type 用于设置套接字通信的类型。主要有流式套接字SOCK_STREAM
、数据包套接字SOCK_DGRAM
等。
- 类型为
SOCK_STREAM
的套接字属于 TCP 连接,提供序列化的、可靠的、双向连接的字节流。流式的套接字在进行数据收发之前必须是已连接的,使用read()
和write()
函数进行数据的传输。流式通信方式保证数据不会丢失或者重复接受,当数据在一段时间内仍然没有接收完毕,可以认为这个连接已经死掉 - 类型为
SOCK_DGRAM
的套接字支持 UDP 连接,使用sendto()
和recvfrom()
函数收发数据
参数 protocol 用于指定某个协议的特定类型,即 type 类型中的某个类型。通常某个协议中只有一种特定类型,这样 protocol 参数仅能设置为 0 。
2.3 绑定一个地址端口对 bind() 函数
在成功建立套接字文件描述符后,需要对套接字进行地址和端口的绑定,才能进行数据的收发操作。
1. bind()
函数介绍
bind()
函数的原型如下:
int bind(int sockfd, const struct sockaddr *my_addr, socklen_t addrlen);
这个函数将长度为 addrlen 的 struct sockaddr* 类型的参数 my_addr 与 sockfd 绑定在一起,将 sockfd 绑定到某个端口上。函数调用成功时返回 0 ,失败时返回 -1 。
参数 sockfd 是由函数 socket()
创建的套接字文件描述符。
参数 my_addr 是指向一个结构为 struct sockaddr
的指针,struct sockaddr 包含了地址、端口和 IP 地址的信息。
参数 addrlrn 是 my_addr 结构的长度,可以设置成 sizeof(struct sockaddr)
。
2.4 监听本地端口 listen()
函数 listen()
用来初始化服务器可连接队列,服务器处理客户端连接请求的时候是顺序处理的。同一时间只能处理一个客户端连接。当多个客户端的连接请求同时到来的时候,服务器并不是同时处理的,而是将不同处理的客户端连接请求放到等待队列中,这个队列的长度由 listen()
函数来定义。
1. listen()
函数介绍
listen()
函数的原型如下:
int listen(int sockfd, int backlog);
在接受一个连接之前,需要用 listen()
来侦听端口。
**参数 backlog 表示等待队列的长度。**函数成功调用时返回 0 ,失败时返回 -1 。
2.5 接受一个网络请求 accept() 函数
函数 accept()
成功执行后,会返回一个新的套接字文件描述符来表示客户端的连接,客户端连接的信息可以通过这个新描述符来获得。因此,当服务器成功处理客户端的请求连接后,会有两个文件描述符,老的文件描述符表示正在监听的 socket ,新产生的文件描述符表示客户端的连接,函数 send()
和 recv()
通过新的文件描述符进行数据收发。
1. accept()
函数介绍
accept()
函数的原型如下:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
通过 accept()
函数可以得到成功连接的客户端的 IP 地址、端口和协议族等信息,这个信息是通过参数 addr 获得的。函数的返回值是新连接的客户端套接字文件描述符,与客户端的通信是通过 accept()
返回的新套接字文件描述符来进行的,调用失败则会返回 - 1 。
参数 addrlen 表示 addr 的长度,可以使用 sizeof(struct sockaddr_in)
来获得。需要注意的是,在这个函数中的 addrlen 参数是一个指针而不是整型或结构。
2.6 连接目标网络服务器 connect() 函数
客户端在建立套接字之后,不需要地址绑定就可以直接来连接服务器。连接服务器的函数为 connect()
。
**1. connect()
函数介绍
connect()
函数的原型如下:
int connect(int sockfd, struct sockaddr *serv_addr, int addrlen);
这个函数连接指定参数的服务器,例如 IP 地址、端口等。调用成功时返回 0 ,失败时返回 -1 。
参数 serv_addr 是一个指向数据结构 sockaddr 的指针,其中包括客户端需要连接的服务器的目的端口和 IP 地址,以及协议类型等信息。
参数 addrlen 表示 serv_addr 的长度,是一个整型变量。
2.7 写入数据函数 write()
当服务器在接收到一个客户端的连接后,可以通过套接字描述符进行数据的写入操作,这可以通过函数 write()
来实现。
下面是一个向套接字文件描述符中写入数据的例子,将缓冲区 data 的数据全部写入套接字文件描述符 s 中去,返回值为成功写入的数据长度。
int size;
char data[1024];
size = write(s, data, 1024);
2.8 读取数据函数 read()
与写入数据类似,使用 read()
函数可以从套接字文件描述符中读取数据。
下面是一个从套接字文件描述符中读取数据的例子,从套接字文件描述符 s 中读取 1024 个字节,放入缓冲区 data 中,size 变量的值为成功读取的数据大小。
int size;
char data[1024];
size = read(s, data, 1024);
2.9 关闭套接字 close() 函数
关闭 socket 连接可以使用 close()
函数实现,函数的作用是关闭已经打开的 socket 连接,内核会释放相关的资源,关闭套接字之后就不能再使用这个套接字文件描述符进行数据的读写操作了。
函数的原型如下:
int clsoe(int sockfd);
3. 服务器/客户端的简单例子
例子分为服务器端和客户端,客户端连接服务器后从标准输入读取输入的字符串,发送给服务器;服务器接收到字符串后,发送接收到的总字符串个数给客户端;客户端将接收到的服务器的信息打印到标准输出。
tcp_client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <arpa/inet.h>
#define PORT 8888 // 侦听端口地址
int main(int argc, char* argv[])
{
int sockfd; // 套接字文件描述符
sockfd = socket(AF_INET, SOCK_STREAM, 0); // 建立一个流式套接字
if(sockfd < 0){ // 套接字文件描述符建立失败
printf("socket error\n");
return -1;
}
struct sockaddr_in server_addr; // 服务器地址结构
// 设置服务器地址
bzero(&server_addr, sizeof(server_addr)); // 清零
server_addr.sin_family = AF_INET; // 协议族
server_addr.sin_addr.s_addr = htonl("192.168.245.140"); // 服务器地址
server_addr.sin_port = htons(PORT); // 服务器端口
// 将用户输入的字符串类型的IP地址转为整型
inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
// 连接服务器
connect(sockfd, (struct sockaddr*)&server_addr, sizeof(struct sockaddr));
process_conn_client(sockfd); // 客户端处理过程
close(sockfd); // 关闭连接
return 0;
}
tcp_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#define PORT 8888 // 侦听端口地址
#define BACKLOG 2 // 侦听队列长度
int main(int argc, char *argv[])
{
int ss,sc; // ss为服务器的socket描述符,sc为客户端的socket描述符
int err; // 返回值
pid_t pid; // 分叉进行处理客户端的事务
// 建立一个流式套接字
ss = socket(AF_INET, SOCK_STREAM, 0);
if(ss < 0){ // 出错
printf("socket error\n");
return -1;
}
struct sockaddr_in server_addr; // 服务器地址结构
// 设置服务器地址
bzero(&server_addr, sizeof(server_addr)); // 清零
server_addr.sin_family = AF_INET; // 协议族
server_addr.sin_addr.s_addr = htonl("INADDR_ANY"); // 本地地址
server_addr.sin_port = htons(PORT); // 服务器端口
// 绑定地址结构到套接字描述符
err = bind(ss, (struct sockaddr*)&server_addr, sizeof(server_addr));
if(err < 0){ // 出错
printf("bind error\n");
return -1;
}
// 设置侦听
err = listen(ss, BACKLOG);
if(err < 0){ // 出错
printf("listen error\n");
return -1;
}
struct sockaddr_in client_addr; // 客户端地址结构
// 主循环过程
for(;;) {
socklen_t addrlen = sizeof(struct sockaddr);
// 接收客户端连接
sc = accept(ss, (struct sockaddr*)&client_addr, &addrlen);
if(sc < 0){ // 出错
continue; // 结束本次循环
}
// 建立一个新的进程处理到来的连接
pid = fork(); // 分叉进程
if ( pid == 0 ) { // 子进程中
process_conn_server(sc); // 处理连接
close(ss); // 在子进程中关闭服务器的侦听
} else {
close(sc); // 在父进程中关闭客户端的连接
}
}
}
tcp_process.c
#include <stdio.h>
#include <string.h>
// 客户端的处理过程
void process_conn_client(int s)
{
ssize_t size = 0;
char buffer[1024]; // 数据的缓冲区
for(;;){ // 循环处理过程
// 从标准输入中读取数据放到缓冲区buffer中
size = read(0, buffer, 1024); // 0 表示标准输入
if(size > 0){ // 读到数据
write(s, buffer, size); // 发送给服务器
size = read(s, buffer, 1024); // 从服务器读取数据
write(1, buffer, size); // 1 表示标准输出
}
}
}
// 服务器对客户端的处理
void process_conn_server(int s)
{
ssize_t size = 0;
char buffer[1024]; // 数据的缓冲区
for(;;){ // 循环处理过程
size = read(s, buffer, 1024); // 从套接字中读取数据放到 缓冲区buffer中*/
if(size == 0){ // 没有数据
return;
}
// 构建响应字符,为接收到客户端字节的数量
sprintf(buffer, "%d bytes altogether\n", size);
write(s, buffer, strlen(buffer)+1); // 发给客户端
}
}
运行程序
在一个窗口运行服务器端:
aaa@qq.com:~/Desktop$ ./server
在另一个窗口运行客户端:
aaa@qq.com:~/Desktop$ ./client 127.0.0.1
hello
6 bytes altogether
I love coding
14 bytes altogether
4. 截取信号的例子
在 Linux 操作系统中,当某些情况发生变化时,系统会向相关的进程发送信号。信号的处理方式是系统会调用进程中注册的处理函数,然后调用系统默认的响应方式,包括种植进程。因此在系统结束进程前,注册信号处理函数进行一些处理是一个完善程序的必备条件。
信号是发生某件事情时的一个通知,又是也称其为软中断。信号讲事件发送给相关的进程,相关的进程可以对信号进行捕捉和处理。信号的捕捉由系统自动完成,信号处理函数的注册通过 signal()
函数完成,其函数原型为:
typedef void (*sighandler_t) (int)
sighandler_t signal(int signum, sighandler_t handler);
这个函数向信号 signum 注册一个 void (*sighandler_t) (int)
类型的函数,函数的句柄为 handler
。当进程中捕捉到注册的信号时,会调用响应函数的句柄 ``handler```。信号处理函数在处理系统默认的函数之前被调用。
具体的应用示例见往期笔记。
点击下方图片关注我,或微信搜索**“编程笔记本”**,获取更多信息。