文章目录
lecture 4 ——网络编程
传输层的位置
传输层提供的服务
最低限度的传输服务
-
将终端-终端的数据交付扩展到进程-进程的数据交付
-
报文检错
增强服务
-
可靠数据传输
-
流量控制
-
拥塞控制
因特网传输层通过UDP协议和TCP协议,向应用层提供两种不同的传输服务
-
UDP协议:仅提供最低限度的传输服务
-
TCP协议:提供基础服务和增强服务
创建一个网络应用
如何构建?
编写一个分布式程序,使其运行在不同的端系统上,并通过网络通信
选择一种应用程序体系结构
-
传统及主流的是客户-服务器体系结构(C/S)
-
有一台总是在线的主机,运行一个服务器程序(server),服务器主机具有永久的、众所周知的地址
-
用户终端上运行一个客户程序(client),需要时主动与服务器程序通信,请求服务
-
客户只与服务器通信,客户之间不通信
进程如何标识自己
每个进程需要一个标识,以便其它进程能够找到它
- 问题:可以用进程所在主机的地址来标识进程吗?
- 回答:不能!因为同一个主机上可能运行着许多进程
端口号(port number)
- 端口号被用来区分同一个主机上的不同进程
进程标识包括两部分
-
主机地址
-
主机上与该进程关联的端口号
不同终端上的进程如何通信-Socket API
设想在应用程序和网络之间存在一扇“门”
-
需要发送报文时:发送进程将报文推到门外
-
门外的运输设施(因特网)将报文送到接收进程的门口
-
需要接收报文时:接收进程打开门,即可收到报文
在TCP/IP网络中,这扇“门”称为套接字(socket),是应用层和传输层的接口,也是应用程序和网络之间的API
-
Socket 套接字由ARPA资助UC Berkeley的一个研究组研发
-
目的是将 TCP/IP 协议相关软件移植到 UNIX 类系统中,设计者开发了一个接口,形成了 Socket 套接字
-
Linux 系统采用了 Socket 套接字,成为事实标准
字节序与其转换
主要的字节存储机制:大端与小端
主机字节序(小端 Little-Endian)
-
数据的低位字节存储到内存的低地址位 , 数据的高位字节存储到内存的高地址位(高存高,低存低)
-
我们使用的 PC 机,数据的存储默认使用的是小端
网络字节序(大端Big-Endian)
-
数据的低位字节存储到内存的高地址位 , 数据的高位字节存储到内存的低地址位(高存低,低存高)
-
套接字通信过程中操作的数据都是大端存储的,包括:接收/发送的数据、IP地址、端口
-
比如用十六进制存储两个整数: 0 x 12345678 \mathrm{0x12345678} 0x12345678 和 0 x 11223344 \mathrm{0x11223344} 0x11223344
字节序转换
BSD Socket 提供了转换接口
-
从主机字节序到网络字节序的转换函数:htons、htonl
-
从网络字节序到主机字节序的转换函数:ntohs、ntohl
IP 地址一般通过字符串描述,因此,通常使用专用转换API
将一个字符串类型的 IP 地址进行大小端转换
inet_pton
与inet_ntop
//主机字节序的IP地址转换为网络字节序
//主机字节序的IP地址是字符串,网络字节序IP地址是整形
int inet_pton(int af,const char *src,void *dst);
参数:
-
af
: 地址族(IP地址的家族包括ipv4和ipv6)协议- AF_INET \verb|AF_INET| AF_INET: ipv4格式的ip地址
- AF_INET6 \verb|AF_INET6| AF_INET6: ipv6格式的ip地址
-
src
:传入参数,对应要转换的点分十进制的ip地址:192.168.1.100 -
dst
:传出参数,函数调用完成,转换得到的大端整形IP被写入到这块内存 -
返回值:成功返回1,失败返回0或者-1
注意对比下面的函数
#include <arpa/inet.h>
//将大端的整形数,转换为小端的点分十进制的IP地址
const char *inet_ntop(int af,const void *src,char *dst,socklen_t size);
参数:
-
af
: 地址族协议- AF_INET \verb|AF_INET| AF_INET: ipv4格式的ip地址
- AF_INET6 \verb|AF_INET6| AF_INET6: ipv6格式的ip地址
-
src
:传入参数,这个指针指向的内存中存储了大端的整形IP地址 -
dst
:传出参数,存储转换得到的小端的点分十进制的IP地址 -
size
:修饰dst参数的,标记dst指向的内存中最多可以存储多少个字节
▶️ 详情参考博客inet_pton()和inet_ntop()函数详解
套接字Socket
通用地址 sockaddr \verb|sockaddr| sockaddr
-
sockaddr \verb|sockaddr| sockaddr是一个套接字API接口的通用地址类型
-
sa_family \verb|sa_family| sa_family表示地址族协议,一般采用“AF_xxx”形式
-
AF_INET:ipv4格式的ip地址
-
AF_INET6:ipv6格式的ip地址
-
AF_UNIX:unix格式的ip地址
-
-
sa_data \verb|sa_data| sa_data 表示地址:端口(2字节) + IP地址(4字节) + 填充(8字节)
//在写数据的时候不好用
struct sockaddr {
sa_family_t sa_family; //地址族协议,ipv4
char sa_data[14];//端口(2字节) + IP地址(4字节) + 填充(8字节)
}
IPV4地址 sockaddr_in \verb|sockaddr_in| sockaddr_in (相比 sockaddr \verb|sockaddr| sockaddr更方便使用)
-
sin_family:AF_INET(IPv4),编程时实际使用地址类型
-
sin_port:存放端口号(按照网络字节序存储)
-
sin_addr:存放32位IP地址(无符号整数)
-
sin_zero:为与sockaddr大小兼容而保留的空字节
struct sockaddr_in{//struct to hold an address
sa_family_t sin_family;//always AF_INET
in_port_t sin_port;//protocol port number:uint16_t
struct in_addr_sin_addr;//IPaddress
char sin_zero[8];//unused(set to zero)
};
struct in_addr{
in_addr_t s_addr;//ipv4 address(uint32_t)
}
服务原语
原语 | 发出的数据包 | 含义 |
---|---|---|
LISTEN | (无) | 阻塞,直到某个进程试图与之连接 |
CONNECT | CONNECTION REQ | 主动尝试建立一个连接 |
SEND | DATA | 发送信息 |
RECEIVE | (无) | 阻塞,直到有一个DATA数据包到达 |
DISCONNECT | DISCONNECTION REQ | 请求释放连接 |
Berkeley套接字
原语 | 含义 |
---|---|
SOCKET | 创建一个新通信端点 |
BIND | 将套接字与一个本地地址关联 |
LISTEN | 声明愿意接受连接;给出队列大小 |
ACCEPT | 被动创建一个进来的连接 |
CONNECT | 主动创建一个连接 |
SEND | 通过连接发送一些数据 |
RECEIVE | 从连接上接收一些数据 |
CLOSE | 释放连接 |
创建套接字:socket()
-
客户或服务器调用
socket()
创建本地套接字,返回套接字描述符 -
domain \verb|domain| domain指明网络层地址类型
-
type \verb|type| type指明传输层协议:
-
SOCK_STREAM \verb|SOCK_STREAM| SOCK_STREAM代表TCP字节流
-
SOCK_DGRAM \verb|SOCK_DGRAM| SOCK_DGRAM代表UDP数据报
-
#include <sys/socket.h>
int socket(
int domain,/*AF_UNIX,AF_INET,AF_INET6.*/
int type,/*SOCK_STREAM,SOCK_DGRAM*/
int protocol); /*usuallyzero*/
/*Returnsfiledescriptoror-1onerror(setserrno)*/
/*函数的返回值是⼀个⽂件描述符,通过它以操作内核的某块内存,⽹络通信基于该⽂件描述符完成*/
/*如果包含头文件<arpa/inet.h>,就不用再包含<sys/socket.h> 了*/
绑定套接字地址:bind()
-
bind()
用来文件描述符 sockfd \verb|sockfd| sockfd与本地地址 addr \verb|addr| addr绑定 -
客户程序不需要调用
bind()
,操作系统将为其在1024~5000之间分配一个端口号
//将文件描述符和本地的IP与端口进行绑定
int bind(int sockfd,const struct sockaddr *addr,socklen_t addrlen);
参数:
-
sockfd \verb|sockfd| sockfd:监听的文件描述符,通过
socket ()
调用得到的返回值 -
addr \verb|addr| addr:传入参数,要绑定的IP和端口信息需要初始化到这个结构体中,IP和端口要转换为网络字节序
-
addrlen \verb|addrlen| addrlen:参数 addr \verb|addr| addr指向的内存大小,
sizeof (struct sockaddr)
返回值:成功返回0,失败返回-1
监听套接字地址:listen()
-
listen()
用来给监听的套接字设置监听 -
backlog \verb|backlog| backlog设定了监听队列长度
-
客户程序不需要调用
listen()
//给监听的套接字设置监听
int listen(int sockfd,int backlog);
参数:
-
sockfd \verb|sockfd| sockfd: 文件描述符,可以通过调用
socket()
得到,在监听之前必须要绑定bind()
-
backlog \verb|backlog| backlog: 同时能处理的最大连接要求,最大值为128
返回值:函数调用成功返回0,调用失败返回-1
接受套接字地址:accept()
(是一个阻塞函数)
-
accept()
用来等待并接受客户端的连接请求, 建立新的连接, 会得到一个新的文件描述符(通信的文件描述符) -
客户程序不需要调用
accept()
//等待并接受客户端的连接请求,建立新的连接,会得到一个新的文件描述符(通信的)
int accept(int sockfd,struct sockaddr *addr,socklen_t *addrlen);
参数:
-
sockfd \verb|sockfd| sockfd:监听的文件描述符
-
addr \verb|addr| addr:传出参数,里边存储了建立连接的客户端的地址信息
-
addrlen \verb|addrlen| addrlen:传入传出参数,用于存储 addr \verb|addr| addr指向的内存大小
返回值:函数调用成功,得到一个文件描述符,用于和建立连接的这个客户端通信,调用失败返回-1
接收数据套接字地址:read
/recv()
(阻塞函数)
ssize_t read(int sockfd,void *buf,size_t size);
ssize_t recv(int sockfd,void *buf,size_t size,int flags);
参数:
-
sockfd \verb|sockfd| sockfd:用于通信的文件描述符,
accept()
函数的返回值 -
buf \verb|buf| buf:指向一块有效内存,用于存储接收是数据
-
size \verb|size| size:参数 buf \verb|buf| buf指向的内存的容量
-
flags \verb|flags| flags:特殊的属性,一般不使用,指定为0
返回值:
-
大于0:实际接收的字节数
-
等于0:对方断开了连接
-
-1:接收数据失败了
发送数据套接字地址:write
/send()
(也是阻塞函数)
ssize_t write(int fd,const void *buf,size_t len);
ssize_t send(int fd,const void *buf,size_t len,int flags);
参数:
-
fd \verb|fd| fd: 通信的文件描述符,
accept()
函数的返回值 -
buf \verb|buf| buf: 传入参数,要发送的字符串
-
len \verb|len| len: 要发送的字符串的长度
-
flags \verb|flags| flags: 特殊的属性,一般不使用,指定为0
返回值:
-
大于0: 实际发送的字节数,和参数 len \verb|len| len是相等的
-
-1: 发送数据失败了
连接套接字地址:connect()
(三次握手导致connect()函数是阻塞函数)
-
connect()
用来与服务端建立连接 -
仅TCP客户程序调用
connect()
//成功连接服务器之后,客户端会自动随机绑定一个端口
//服务器端调用accept()的函数,第二个参数存储的就是客户端的IP和端口信息
int connect(int sockfd,const struct sockaddr *addr,socklen_t addrlen);
参数:
-
sockfd \verb|sockfd| sockfd: 通信的文件描述符|通过调用
socket()
函数就得到了 -
addr \verb|addr| addr: 存储了要连接的服务器端的地址信息:iP和端口,这个IP和端口也需要转换为大端然后再赋值
-
addrlen \verb|addrlen| addrlen: addr \verb|addr| addr指针指向的内存的大小
sizeof (struct sockaddr)
返回值:连接成功返回0,连接失败返回-1
关闭套接字:close()
-
客户和服务器调用
close()
关闭一个套接字 -
当实参 fd \verb|fd| fd为TCP套接字描述符时,调用
close()
会引起本地进程向远程进程发送关闭连接的消息 -
当实参 fd \verb|fd| fd为UDP套接字描述符时,调用
close()
会引起为此描述符分配的资源被内核释放
#include <unistd.h>
int close(int fd);
/*Returns0 if OK or-1onerror*/
▶️详情参考博客:传输层:(1)传输服务原语和Berkeley套接字
整理笔记时,考古到了01年的一个帖子,其中对于套接字的一些函数功能有着非常形象生动的概括:
socket()
–得到文件描述符!
bind()
--我们在哪个端口?
connect()
–Hello!
listen()
--有人给我打电话吗?
accept()
--“Thank you for calling port 3490.”
send()
和recv()
--Talk to me, baby!
sendto()
和recvfrom()
--Talk to me, DGRAM-style
close()
和shutdown()
--滚开!
getpeername()
–你是谁?
gethostname()
--我是谁?
DNS
--你说“白宫”,我说 “198.137.240.100”▶️ 详情参考博客:socket文件描述符
编程实例:回音服务
回音服务流程
-
客户程序将一行字符(数据),发送给服务器
-
服务器接收数据,然后将收到的数据回送给客户
-
客户接收回送的数据,在屏幕上显示出来
使用UDP套接字实现回音服务
客户或服务器调用sendto()
发送数据
-
数据在 buff \verb|buff| buff中,长度为nbytes字节
-
对方通信进程的地址在 to \verb|to| to中
#include<sys/socket.h>
ssize_tsendto(
int socket_fd,
const void *buff,
size_t nbytes,
int flags, /* usually 0 */
const struct sockaddr *to,
socklen_t *addrlen,
);
/*Return number of bytes written
if OK or -1 on error*/
客户或服务器调用recvform()
接受数据
-
收到的数据放在 buff \verb|buff| buff中
-
对方通信进程的地址在 from \verb|from| from中
#include<sys/socket.h>
ssize_t recvfrom(
int socket_fd,
void *buff,
size_t nbytes,
int flags, /* usually 0*/
struct sockaddr *from,
socklen_t *addrlen,
);
/*Return number of bytes read if OK or -1 on error*/
基于UDP的套接字通信流程
UDP回音服务代码
服务端代码: main()
int main(int argc, char **argv){
char mesg[MAXLINE];
int sockfd, n, len;
struct sockaddr_in cliaddr, servaddr;
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
bind(sockfd, (struct sockaddr *) &servaddr, sizeof(servaddr));
for ( ; ; ) {
len = sizeof(cliaddr);
n = recvfrom(sockfd, mesg, MAXLINE, 0, cliaddr, &len);
sendto(sockfd, mesg, n, 0, cliaddr, len);
}
}
客户端代码
int main(int argc, char **argv){
int sockfd, n;
struct sockaddr_in servaddr;
char sendline[MAXLINE], recvline[MAXLINE + 1];
if (argc != 2){
fprintf(stderr, %s\n, "usage: echoCli <IPaddress>");
exit(EXIT_FAILURE);
}
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
servaddr.sin_port = htons(SERV_PORT);
while (fgets(sendline, MAXLINE, stdin) != NULL) {
sendto(sockfd, sendline, strlen(sendline), 0,
(struct sockaddr *)servaddr, sizeof(servaddr));
n = recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL);
recvline[n] = 0;
fputs(recvline, stdout);
}
exit(0);
}
使用TCP套接字的通信流程
在recv
和send
环节中可能发生阻塞问题
服务器使用多个套接字服务客户
监听的文件描述符
-
只需要一个
-
不负责和客户端通信,负责检测客户端的连接请求,检测到之后调用accept就可以建立新的连接
通信的文件描述符
- 负责和建立连接的客户端通信
如果有N个客户端和服务端建立了新的连接,通信的文件描述符就有N个,每个客户端和服务器都对应一个通信的文件描述符
📌 TCP回音过程代码
服务端代码
// server.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
int main()
{
// 1. 创建监听的套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd == -1)
{
perror("socket");
exit(0);
}
// 2. 将socket()返回值和本地的IP端⼝绑定到⼀起
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(10000); // ⼤端端⼝
// INADDR_ANY代表本机的所有IP, 假设有三个⽹卡就有三个IP地址
// 这个宏可以代表任意⼀个IP地址
// 这个宏⼀般⽤于本地的绑定操作
addr.sin_addr.s_addr = INADDR_ANY; // 这个宏的值为0 == 0.0.0.0
int ret = bind(lfd, (struct sockaddr*)&addr, sizeof(addr));
if(ret == -1)
{
perror("bind");
exit(0);
}
// 3. 设置监听
ret = listen(lfd, 128);
if(ret == -1)
{
perror("listen");
exit(0);
}
// 4. 阻塞等待并接受客户端连接
struct sockaddr_in cliaddr;
socklen_t clilen = sizeof(cliaddr);
int cfd = accept(lfd, (struct sockaddr*)&cliaddr, &clilen);
if(cfd == -1)
{
perror("accept");
exit(0);
}
// 打印客户端的地址信息
char ip[24] = {0};
printf("客户端的IP地址: %s, 端⼝: %d\n"
,
inet_ntop(AF_INET, &cliaddr.sin_addr.s_addr, ip, sizeof(ip)),
ntohs(cliaddr.sin_port));
// 5. 和客户端通信
while(1)
{
// 接收数据
char buf[1024];
memset(buf, 0, sizeof(buf));
int len = read(cfd, buf, sizeof(buf));
if(len > 0)
{
printf("客户端say: %s\n", buf);
write(cfd, buf, len);
}
else if(len == 0)
{
printf("客户端断开了连接...\n");
break;
}
else
{
perror("read");
break;
}
}
// 6. 关闭连接
close(cfd);
close(lfd);
return 0;
}
客户端代码
// client.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
int main()
{
// 1. 创建通信的套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if(fd == -1)
{
perror("socket");
exit(0);
}
// 2. 连接服务器
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(10000); // ⼤端端⼝
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr.s_addr);
int ret = connect(fd, (struct sockaddr*)&addr,
sizeof(addr));
if(ret == -1)
{
perror("connect");
exit(0);
}
// 3. 和服务器端通信
int number = 0;
while(1)
{
// 发送数据
char buf[1024];
sprintf(buf, "你好, 服务器...%d\n", number++);
write(fd, buf, strlen(buf)+1);
// 接收数据
memset(buf, 0, sizeof(buf));
int len = read(fd, buf, sizeof(buf));
if(len > 0)
{
printf("服务器say: %s\n", buf);
}
else if(len == 0)
{
printf(“服务器断开了连接...\n”); break;
}
else
{
perror(“read”); break;
}
sleep(1); // 每隔1s发送⼀条数据
}
// 4. 关闭连接
close(fd);
return 0;
}
三次握手与四次挥手
▶️ TCP部分的知识,可以参考大佬的博客:TCP 的那些事儿(上)、TCP 的那些事儿(下),写的相当深入浅出
网络编程是构建分布式应用的基础,涉及进程间通信和数据传输。在传输层,TCP和UDP协议分别提供不同的服务:TCP保证数据的可靠传输,而UDP则提供较低延迟的简单传输服务。进程通过套接字(socket)API进行通信,套接字是应用层和传输层之间的接口,允许进程发送和接收数据。
套接字编程的核心在于几个关键的系统调用:socket()
用于创建套接字,bind()
将套接字与特定的地址和端口关联,listen()
使套接字处于监听状态以接受连接,accept()
接受连接请求,connect()
由客户端用于建立连接,send()
和recv()
用于数据传输,最后close()
关闭套接字。此外,字节序转换是网络编程中的重要概念,因为网络通信中使用的是大端序,而多数现代系统使用小端序存储数据。
通过这些基本的套接字操作,开发者可以构建出功能丰富的网络应用,实现跨网络的数据交换和资源共享。网络编程不仅要求对套接字API有深刻理解,还需掌握字节序、IP地址处理等基础知识,以确保数据正确、高效地在网络中传输。
(Tips: 最近事情比较繁忙,笔者会尽可能抽出时间补上ICS2网络部分的前面三讲的笔记。本系列笔记均为笔者在课堂老师ppt笔记的基础上补充相关知识拼凑而成,鉴于笔者才疏学浅,文中若有知识性的谬误,还望各位读者朋友不吝赐教 !>_<!)