Linux 网络编程(一):socket相关函数与C/S模型实现

1 计算机网络基础

1.1 网络分层模型和代表协议

常见网络分层模型主要有两种:OSI 七层模型和TCP/IP四层模型。

OSI七层模型是ISO组织提出的,如下

  • 应用层:HTTP,FTP
  • 表示层:ASCII,PICT,TIFF,JPEG
  • 会话层:RPC,SQL,NFS
  • 传输层:TCP,UDP,SPX
  • 网络层:IP,ICMP
  • 数据链路层:802.2等等
  • 物理层

实际中常常以TCP/IP四层模型代替,其代表协议如下

  • 应用层:httpftpnfssshtelnet
  • 传输层:TCPUDP
  • 网络层:IPICMPIGMP
  • 链路层:以太网帧协议,ARP

详细介绍可见这里

1.2 C/S模型和B/S模型

C/S模型即client/server模型,分为客户端和服务端。

  • 优点:客户端可以缓存大量数据,在实现上协议选择灵活,完全可以自己设计协议,速度快
  • 缺点:安全性不佳,跨平台性差,开发工作量大

B/S模型即browser/server模型,只需在浏览器中即可访问服务

  • 优点:能够跨平台使用,开发工作量小
  • 缺点:不能缓存大量数据,需要严格遵守HTTP相关协议

1.3 数据传输过程

应用数据在网络进行传输前需要进行逐层封装,不封装则无法在网络中进行传输。

如TCP/IP协议传输的话,应用数据进入传输层时需要打上TCP首部称为数据段Segment,进入网络层时再打上IP首部称为数据包Packet,最后在链路层中还要打上以太网首部和以太网尾部称为帧Freame。其中TCP首部20Bytes,IP首部20Bytes,以太网首部20Bytes,以太网尾部4Bytes。802.3以太网帧最大为1530Bytes(帧间距另外还需要12Bytes)。

同样接收方在接受到数据的过程中也需要逐层解封装。

传输的详细过程可以看这个博客

1.4 以太网帧格式和ARP协议

ARP(Addree Resolution Protocal)即地址解析协议,用于实现从IP地址到MAC地址的映射,即询问目标IP对应的MAC地址。

在传输数据的封装过程中,不仅需要封装源主机和目的主机的IP地址,还需要封装各自的MAC地址。在上层程序中一般更关心IP地址,所以在传输时,需要将其给出的IP地址通过ARP协议来获取目的主机的MAC地址,完成数据封装。

在以太网链路上传输的数据包称为以太帧。

以太帧起始部分由前导码和帧开始符组成。后面紧跟着一个以太网报头,以MAC地址说明目的地址和源地址。帧的中部是该帧负载的包含其他协议报头的数据包(例如IP协议)。以太帧由一个32位冗余校验码结尾。它用于检验数据传输是否出现损坏。

1.5 IP协议

IP协议是TCP/IP协议族的核心协议,也是socket网络编程的基础。

IP协议的主要的主要特点如下

  • 无状态:IP通信双方不同步传输数据的状态信息,所有IP数据报的发送、传输、接受都是相互独立、没有上下文关系的。这种服务优点在于简单、高效。最大的缺点是无法处理乱序和重复的IP数据报,确保IP数据报完整的工作只能交给上层协议来完成。
  • 无连接: IP通信双方都不长久地维持对方的任何信息。上层协议每次发送数据的时候,都需要明确指出对方的IP地址。
  • 不可靠: IP协议不能保证IP数据报准确到达接收端,它指承诺尽最大努力交付。IP模块一旦检测到数据报发送失败,就通知上层协议,而不会试图重传。

IPv4的头部见下图

IPv6的头部见下图

对于头部中各部分的说明见这里

注意IP地址为4字节32位。

1.6 TCP和UDP

TCP(Transmission Control Protocol,传输控制协议)是面向连接的协议,也就是说,在收发数据前,必须和对方建立可靠的连接。 建立连接需要所谓的“三次握手”,断开连接需要“四次挥手”。其对系统资源要求较多,流模式,TCP保证传输数据的有序和正确性。

其包头结构如下图

UDP(User Data Protocol,用户数据报协议)是一个非连接的协议,传输数据之前源端和终端不建立连接, 当它想传送时就简单地去抓取来自应用程序的数据,并尽可能快地把它扔到网络上。 在发送端,UDP传送数据的速度仅仅是受应用程序生成数据的速度、 计算机的能力和传输带宽的限制; 在接收端,UDP把每个消息段放在队列中,应用程序每次从队列中读一个消息段。

注意端口号为2字节8位,所以端口号最大为2^16 - 1 = 65536,最小为0,端口号小于256的定义为常用端口,服务器一般都是通过常用端口号来识别的。任何TCP/IP实现所提供的服务都用1—1023之间的端口号,是由ICANN来管理的;端口号从1024—49151是被注册的端口,也成为“用户端口”,被IANA指定为特殊服务使用。

大多数TCP/IP实现给临时端口号分配1024—5000之间的端口号。大于5000的端口号是为其他服务器预留的。

关于“三次握手”和“四次挥手”,可参见这里

2 socket编程相关函数

2.1 字节序转换

字节序又分为大端字节序和小端字节序。

  • 大端字节序:将高位数据存放到低位内存地址
  • 小端字节序:将低位数据存放到高位内存地址

网络字节序都是大端字节序

字节序转换的相关函数:

  • uint32_t htonl(uint32_t hostlong):将long类型的主机字节序转为网络字节序
  • uint16_t htons(uint16_t hostshort):将short类型的主机字节序转为网络字节序
  • uint32_t ntohl(uint32_t netlong):同上相反
  • uint16_t ntohs(uint16_t netshort):同上相反

2.2 地址转换函数

1
2
3
4
5
6
7
8
9
10
#include <netinet/in.h>
#include <arpa/inet.h>

int inet_aton(const char *cp, struct in_addr *inp); // 将点分十进制地址转换为32位整数
in_addr_t inet_addr(const char* cp); // 将点分十进制地址转换成32位整数
char *inet_ntoa(struct in_addr in); // 将32位整数转换为点分十进制地址
// 将本地字节序转换为网络字节序
int inet_pton(int af, const char *src, void *dst); // af为IP协议,src为点分十进制IP地址,dst为转换后IP地址
// 将网络字节序转换为本地字节序
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

2.3 地址结构

套接口地址结构

1
2
3
4
5
6
7
8
9
struct socketaddr_in {
sa_family_t sin_family; // 地址家族,一般设为 AF_INET
in_port_t sin_port; // 端口
struct in_addr sin_addr; // IPv4的地址
char sin_zero[8]; // 暂不使用 一般设为0
};
struct in_addr {
uint32_t s_addr; // 只包含一个32位无符号数表示地址
};

由于历史原因,有些函数需要使用通用地址结构作为参数,所以有时需要将两种结构的指针进行转换

1
2
3
4
5
struct sockaddr {
uint8_t sin_len; // 整个sockaddr结构体的长度
sa_family_t sin_family; // 指定该地址家族
char sa_data[14]; // 根据上一成员决定形式
};

2.4 相关函数

2.4.1 socket 函数

创建一个套接字用于通信

1
2
<sys/socket.h>
int socket(int domain, int type, int protocol); //三个三参数分别指定通信协议族、socket类型、协议类型

若创建成功返回非负整数,与文件描述符类似,俗称套接口描述字。

失败返回-1。

2.4.2 bind 函数

绑定一个本地地址到套接字

1
2
<sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

三个参数分别为套接字、要绑定的地址、地址长度。

失败返回-1,成功返回0。

2.4.3 listen 函数

将套接字用于监听进入的连接

1
2
#include <sys/socket.h>
int listen(int sockfd, int backlog);

sockfd为要监听的套接字,backlog规定内核为此套接字排队的最大连接个数。

成功返回0,失败返回-1。

监听以后,套接字变为被动套接字,用于在accept函数之前调用,否则默认为主动套接字。

对于给定的监听套接口,内核要维护两个队列:

  • 已有客户发出并到达服务器,服务器正在等待完成相应的TCP三路握手过程
  • 已完成连接的队列

2.4.4 accept 函数

从已完成连接队列返回第一个连接,如果已完成连接队列为空则阻塞

1
2
include <sys/socket.h>
int accept(int sockfd, struct sockaddr* addr, socklen_t *addrlen)

sockfd为服务器套接字,addr是将返回对等方的套接字地址,addrlen返回对等方的套接字地址长度。

成功返回非负整数,失败返回-1

2.4.5 connect 函数

功能是建立一个连接至addr所指定的套接字

1
2
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen);

sockfd是未连接的套接字,addr是要连接的套接字地址,addrlen是第二个参数addr的长度。

成功返回0,失败返回-1。

3 C/S模型实现

3.1 基本实现思路

这里实现一个简单的C/S模型,分别实现服务端和客户端,完成从客户端发送字符,服务端收到后将字符转换为大写,然后回传到客户端,客户端收到后将其打印。

服务端的实现流程:

  • 使用socket()创建套接字
  • 使用bind()绑定服务端地址和端口号
  • 使用listen()设定套接字监听上限
  • 使用accpet()阻塞监听客户端的连接
  • 使用read()从读取字符,用toupper()将其转为大写,然后用write()写会客户端
  • 当客户端关闭后,使用close()关闭套接字

客户端实现则比较简单

  • 使用socket()创建套接字
  • 使用connect()与服务器进行连接
  • 使用通过fgets()从键盘接收输入,然后使用write()写向服务端,最后用read()读取服务端数据,将其打印
  • 当接收到中断程序信息,则使用close()关闭套接字

3.2 C/S模型1.0版

在这个版本里只需根据3.1节的实现流程按部就班调用函数实现即可

3.2.1 服务端实现

直接看服务端代码

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
35
36
37
38
39
40
41
42
43
44
45
#include <ctype.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

#include "helper.h"

int main(int argc, char* argv[]) {
int sockfd = 0;
if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
sys_err("socker error");

struct sockaddr_in serv_addr, clit_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERV_PORT);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
sys_err("bind error");

if (listen(sockfd, 128) == -1)
sys_err("listen error");

int nsockfd = 0;
socklen_t cli_addr_len = sizeof(clit_addr), cli_ip;
char cli_IP[64];
if ((nsockfd = accept(sockfd, (struct sockaddr*)&clit_addr, &cli_addr_len)) == -1)
sys_err("accept error");
printf("client ip: %s port: %d\n",
inet_ntop(AF_INET, &clit_addr.sin_addr.s_addr, cli_IP, sizeof(cli_IP)),
ntohs(clit_addr.sin_port));

char buf[BUFFSIZE];
int ret;
while(ret = read(nsockfd, buf, BUFFSIZE)) {
write(STDERR_FILENO, buf, ret);
for(int i = 0; i < ret; ++i)
buf[i] = toupper(buf[i]);
write(nsockfd, buf, ret);
}
close(sockfd);
close(nsockfd);
return 0;
}

3.2.2 客户端实现

再看客户端代码

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
#include <sys/socket.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

#include "../helper.h"

int main() {
int sockfd;
if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
sys_err("socket error");

struct sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr.s_addr);

if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
sys_err("connect error");

char buf[BUFFSIZE] = {0};
int ret;
while(fgets(buf, BUFFSIZE, stdin)) {
write(sockfd, buf, strlen(buf));
memset(buf, 0, BUFFSIZE);
ret = read(sockfd, buf, BUFFSIZE);
write(STDERR_FILENO, buf, ret);
}
close(sockfd);
return 0;
}

其中头文件helper.h有一些宏和错误辅助函数的实现,后续一些辅助的函数都会放入这个文件中

helper.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#ifndef UNP_HELPER_H_
#define UNP_HELPER_H_

#include <stdlib.h>
#include <errno.h>
#include <sys/socket.h>

#define BUFFSIZE 4096
#define LINESIZE 1024
#define SERV_PORT 9527

void sys_err(const char* str) {
perror(str);
exit(-1);
}

#endif // UNP_HELPER_H_

将服务端和客户端程序分别编译完成后,先运行服务端,再运行客户端。然后在客户端中输入任意小写英文字符,即可看到屏幕将对应大写字符打印在shell中。

3.3 C/S模型2.0版-函数的异常封装

由于我们使用到的大多数函数都需要保证其异常情况,如判断其调用后返回值是否等于-1,如果在主程序中挨个对其判断异常,则会大大降低主程序的可读性,程序逻辑不够突出。

3.3.1 常用函数封装

因此可以将这些常用的需要保证异常安全的函数进行简单封装,将出错处理与逻辑分离,提高主程序的代码整洁性,如

helper.h
1
2
3
4
5
6
7
8
9
10
11
void sys_err(const char* str) {
perror(str);
exit(-1);
}

int Socket(int domain, int type, int protocal) {
int fd = socket(domain, type, protocal);
if (fd == -1)
sys_err("socket error");
return fd;
}

封装后的函数名即是将原函数的首字母大写,并使其形参与原函数完全一致,这样既不影响对原函数的接口调用,又可以提高程序整洁性,同时也方便直接使用man命名查看相关函数原型。

其他函数的封装过程如下

helper.h
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
int Bind(int fd, struct sockaddr* addr, int len) {
if (bind(fd, addr, len) == -1)
sys_err("bind error");
return 0;
}

int Listen(int fd, int n) {
if (listen(fd, n) == -1)
sys_err("listen error");
return 0;
}

int Accept(int fd, struct sockaddr* addr, socklen_t* len) {
int lfd;
again:
lfd = accept(fd, addr, len);
if (lfd == -1)
if ((errno == ECONNABORTED) || (errno == EINTR))
goto again;
else
sys_err("accept error");
return lfd;
}

int Connect(int fd, struct sockaddr* addr, int len) {
if(connect(fd, addr, len) == -1)
sys_err("connect error");
return 0;
}

ssize_t Read(int fd, void *buf, size_t nbytes) {
ssize_t n;
again:
if ((n = read(fd, buf, nbytes)) == -1)
if (errno == EINTR)
goto again;
return n;
}

ssize_t Write(int fd, const void* buf, size_t nbytes) {
ssize_t n;
again:
if ((n = write(fd, buf, nbytes)) == -1)
if (errno == EINTR)
goto again;
return n;
}

int Close(int fd) {
if (close(fd) == -1)
sys_err("close error");
return 0;
}

3.3.2 服务端2.0版本

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
35
36
37
38
39
40
#include <ctype.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

#include "helper.h"

int main(int argc, char* argv[]) {
int sockfd = 0;
int nsockfd = 0;
struct sockaddr_in serv_addr, clit_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERV_PORT);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

socklen_t cli_addr_len = sizeof(clit_addr), cli_ip;
char cli_IP[64];

sockfd = Socket(AF_INET, SOCK_STREAM, 0);
Bind(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
Listen(sockfd, 128);
nsockfd = Accept(sockfd, (struct sockaddr*)&clit_addr, &cli_addr_len);
printf("client ip: %s port: %d\n",
inet_ntop(AF_INET, &clit_addr.sin_addr.s_addr, cli_IP, sizeof(cli_IP)),
ntohs(clit_addr.sin_port));

char buf[BUFFSIZE];
int ret;
while(ret = read(nsockfd, buf, BUFFSIZE)) {
write(STDERR_FILENO, buf, ret);
for(int i = 0; i < ret; ++i)
buf[i] = toupper(buf[i]);
write(nsockfd, buf, ret);
}
Close(sockfd);
Close(nsockfd);
return 0;
}

3.3.3 客户端2.0版本

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
#include <arpa/inet.h>
#include <fcntl.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

#include "../helper.h"

int main() {
struct sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr.s_addr);
char buf[BUFFSIZE] = {0};
int ret;

int sockfd = Socket(AF_INET, SOCK_STREAM, 0);
Connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
while (fgets(buf, BUFFSIZE, stdin)) {
write(sockfd, buf, strlen(buf));
memset(buf, 0, BUFFSIZE);
ret = read(sockfd, buf, BUFFSIZE);
write(STDERR_FILENO, buf, ret);
}
Close(sockfd);
return 0;
}

使用效果同1.0版本一致

3.4 C/S模型3.0-多进程并发

前两个版本实现,服务器同时都只能接受一个客户端的连接,这显然并不合理,因此可以使用多进程或者多线程来实现简单的并发功能,使得服务器可以同时接受多个客户端的连接,并正常提供服务。

在此实现一下多进程版本,主要使用fork()函数来生成子进程。

3.4.1 使用fork()开启多进程

fork()函数如下。

1
2
#include <unistd.h>
pid_t fork(); // pid_t 是int的别名

fork()可以讲运行着的程序分成2个几乎完全一样的进程,每个进程都启动一个从代码同一位置开始执行的现成,这两个进程中的线程继续执行。在父进程中,fork()的返回值是子进程的进程ID;在子进程中fork()返回值为0;出错时返回 -1。

另外,还需要借助signal机制来回收子进程,否则进程不安全。

3.4.2 服务端3.0版本

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#include <arpa/inet.h>
#include <ctype.h>
#include <signal.h>
#include <stdlib.h>
#include <strings.h>
#include <sys/socket.h>
#include <unistd.h>
#include <wait.h>

#include "../helper.h"

void catch_child(int signum) {
while (waitpid(0, NULL, WNOHANG) > 0)
;
}

int main() {
int lfd, cfd, ret;
char buf[BUFFSIZE];
struct sockaddr_in ser_addr, clt_addr;
ser_addr.sin_family = AF_INET;
ser_addr.sin_port = htons(SERV_PORT);
ser_addr.sin_addr.s_addr = htonl(INADDR_ANY);

socklen_t addr_len = sizeof(clt_addr);
pid_t pid;

bzero(&clt_addr, addr_len);

lfd = Socket(AF_INET, SOCK_STREAM, 0);
Bind(lfd, (struct sockaddr*)&ser_addr, addr_len);
Listen(lfd, 128);
while (1) {
cfd = Accept(lfd, (struct sockaddr*)&clt_addr, &addr_len);
char cli_IP[32];
printf("client ip: %s port: %d\n",
inet_ntop(AF_INET, &clt_addr.sin_addr.s_addr, cli_IP, sizeof(cli_IP)),
ntohs(clt_addr.sin_port));

pid = fork();
if (pid == 0) {
close(lfd);
break;
} else if (pid > 0) {
struct sigaction act;
act.sa_handler = catch_child;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
ret = sigaction(SIGCHLD, &act, NULL);
if (ret = 0)
sys_err("sigaction error");
close(cfd);
} else
sys_err("fork error");
}
if (pid == 0) {
while (ret = read(cfd, buf, BUFFSIZE)) {
write(STDERR_FILENO, buf, ret);
for (int i = 0; i < ret; ++i)
buf[i] = toupper(buf[i]);
write(cfd, buf, ret);
}
close(cfd);
exit(1);
}
return 0;
}

客户端则无需变化。

4 结束语

本文先在第一部分记录了一些 Linux 网络编程中的一些基础知识,然后在第二部分介绍了一个简单 C/S模型中会使用的相关函数,最后在第三部分循序渐进地实现了多个版本简单 C/S 模型,作为练习。

总体来说是网络编程的一个简单入门demo,接下来会对学习到的更深的知识进行记录和总结。

-------- 本文结束 感谢阅读 --------
给我加块红烧肉吧
  • 本文标题: Linux 网络编程(一):socket相关函数与C/S模型实现
  • 本文作者: Chou Bin
  • 创建时间: 2020年02月13日 - 21时02分
  • 修改时间: 2020年02月13日 - 22时02分
  • 本文链接: http://yoursite.com/2020/02/13/unp-01/
  • 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!