ctrl+Alt打开terminal,uname -a查看linux内核版本。我这里安装的ubuntu的内核版本为5.4.0-29-generic。
socket.h中只有函数声明,要获得c文件得解压linux内核源码。
extern int socket (int __domain, int __type, int __protocol) __THROW;
函数的作用是创建套接字
__domain就是指PF_INET、PF_INET6以及PF_LOCAL等,表示是什么样的套接字
__type可用的值是
- SOCK_STREAM:表示的是字节流,对应TCP
- SOCK_DGRAM:表示的是数据报,对应UDP
- SOCK_RAW:表示的是原始套接字
__protocol:protocol本来是用来指定通信协议的,但现在基本废弃,因为协议已经通过前面两个参数指定完成,protocol目前一般写成0即可。
返回文件描述符,异常返回-1
这篇文章的预处理指令写的很细致。https://www.zfl9.com/c-cpp.html
extern 只在头文件中做声明,否则两个文件都引用同一个头文件时,会出现重复定义的问题。
在C语言中int a;即使没赋值也算定义了。在头文件test.h中声明extern int a;另一个c文件test1.c中定义int a = 100;然后在test2.c中,引入头文件test.h,主函数中打印a,可以获得test1.c中定义的a的值。
注意在clion中测试的时候,test1.c和test2.c要属于同一个target,且只有一个主函数,否则会发生链接错误。
变量名前两个下划线:涉及到命名规则。一个下划线加大写字母,两个下划线,都是给编译器和标准库用的。而我们一般只用一个下划线加小写字母表示私有变量。命名时最好不要使用下划线开头。以免发生冲突。
__THROW指什么?#define __THROW attribute ((nothrow __LEAF))。它是一个宏。知乎上的解释。https://zhuanlan.zhihu.com/p/123879953 C++会调用C函数,它控制当C++代码调用这段C函数的行为。nothrow告诉编译器这个函数不会扔出异常,leaf告诉编译器这个函数传进来的参数不会进行修改。在C语言里,这段代码没有意义,只有C++调用时才有意义。且这个宏只在linux的C库里有。至于__attribute__暂时就不细深究了。
网络编程中客户端与服务端核心逻辑
发送缓冲区概念
TCP三次握手
发送缓冲区和接收缓冲区实验
client.c
//
// Created by tobin on 6/13/20.
//
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define MESSAGE_SIZE 1024000
#define SERVER_PORT 12345
#define SERVER_ADDR "127.0.0.1" // localhost 本机ip
void send_data(int);
int main(int argc, char **argv) {
int sockfd;
struct sockaddr_in servaddr;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(SERVER_PORT); // 主机字节序转为网络字节序,网络字节序大端,主机字节序,intel是小端,arm tcp/ip都是大端
// 网络通信中一般只有端口号和ip地址进行大小端的转换,其他数据是以字符串的格式传输,所以无需转换
inet_pton(AF_INET, SERVER_ADDR, &servaddr.sin_addr);
// 将点分十进制的ip地址转为用于网络传输的数值格式,存于sin_addr中,详细可以参考 https://blog.csdn.net/zyy617532750/article/details/58595700
connect(sockfd, (const struct sockaddr *) &servaddr, sizeof(servaddr)); // 连接服务端
send_data(sockfd);
exit(0); //https://blog.csdn.net/song_esther/article/details/85707459 exit是进程的结束,操作系统提供的系统调用,和rentun 0,在主函数中区别不大
}
void send_data(int sockfd) {
char *query;
query = malloc(MESSAGE_SIZE + 1); // 分配内存,单位是字节
for (int i = 0; i < MESSAGE_SIZE; i++) {
query[i] = 'a';
}
query[MESSAGE_SIZE] = '\0';
const char *cp;
cp = query;
long remaining = (long) strlen(query);
while (remaining) {
long n_written = send(sockfd, cp, remaining, 0); // flags = 0 ,send等价于write, cp是要发送的消息,remaining是要发送的字节数
// 阻塞套接字,n_written就是需要发送的数据大小,即循环只运行一次
// send返回只表示数据发送到发送缓冲区当中,接收方需要循环读取数据,并考虑EOF等异常条件
fprintf(stdout, "send into buffer %ld \n", n_written);
if (n_written < 0) {
perror("send");
return;
}
remaining -= n_written;
cp += n_written;
}
}
server.c
//
// Created by tobin on 6/12/20.
//
#include <stdio.h>
#include <strings.h>
#include <sys/socket.h>
#include <netinet/in.h>
// #include <zconf.h>
#include <errno.h>
#define SERVER_PORT 12345
ssize_t readn(int, void *, size_t); //ssize_t 有符号整型,在32位机上等于int,在64位机上等于long int
void read_data(int);
int main(int argc, char **argv) {
int listenfd, connfd;
socklen_t clilen; // unsigned int
struct sockaddr_in cliaddr, servaddr; // 这里是socket地址,具体内容复制在下面
//struct sockaddr_in
// {
// __SOCKADDR_COMMON (sin_); // 地址族,比如ipv6 ipv4就是常见的因特网地址族
// /* #define __SOCKADDR_COMMON(sa_prefix) \
// sa_family_t sa_prefix##family */ // 使用了宏定义,且其中有##的特殊用法,相当于sa_prefixfamily,连接符号,#str,则是在前后加双引号,转为字符串
// in_port_t sin_port; /* Port number. */ // 无符号整数,16位端口号
// struct in_addr sin_addr; /* Internet address. */ // 32位无符号ip地址
//
// /* Pad to size of `struct sockaddr'. */
// 填充字段,保证sockaddr_in(因特网套接字)的大小和sockaddr(通用套接字相同),通用套接字大小为128位
// unsigned char sin_zero[sizeof (struct sockaddr)
// - __SOCKADDR_COMMON_SIZE
// - sizeof (in_port_t)
// - sizeof (struct in_addr)];
// };
//
listenfd = socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));// 不是标准C函数,gcc支持,功能相当于memset(&servaddr, 0, sizeof(servaddr)),清零
// extern void bzero (void *__s, size_t __n) __THROW __nonnull ((1)); void* 可以修饰不确定类型的指针,但是void不能修饰变量
servaddr.sin_family = AF_INET; // ipV4
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // INADDR_ANY,服务器地址绑定到默认网卡地址(0x00000000)
servaddr.sin_port = htons(SERVER_PORT); // 设置服务器端口地址,服务器地址一般是绑定在互相都知道的端口上,如果传入0,则操作系统分配空闲端口
bind(listenfd, (const struct sockaddr *) &servaddr, sizeof(servaddr));
// 当时还没有void *,设计了通用socket地址,将服务器地址转为通用socket地址,这里const指针,即bind函数内部不能通过传进来的servaddr指针去修改内容
// 套接字和地址相关联,此时的套接字是主动套接字
// int a;
// const int *p1 = &a;//p1是指向常量的指针
// int b;
// p1 = &b;//正确,p1本身的值可以改变
// *p1 = 1;//编译出错,不能通过p1改变所指对象
// int a;
// const int *p1 = &a;//p1是指向常量的指针
// int b;
// p1 = &b;//正确,p1本身的值可以改变
// *p1 = 1;//编译出错,不能通过p1改变所指对象
listen(listenfd, 1024); // 主动套接字变成被动套接字,监听,第二个参数表示已完成建立(ESTABLISHED)但是未Accept的队列大小,决定了并发数目
for (;;) {
clilen = sizeof(cliaddr);
connfd = accept(listenfd, (const struct sockaddr *) &cliaddr, &clilen);
// 返回新的fd,表示客户端与服务端的连接,监听套接字即listenfd一直在工作,这样就可以处理多个用户的请求
read_data(connfd);
close(connfd); // 关闭连接
}
return 0;
}
void read_data(int sockfd) {
ssize_t n;
char buf[1024];
int time = 0;
for (;;) {
fprintf(stdout, "block in read\n");
// 都是把格式好的字符串输出,只是输出的目标不一样:
// 1 printf,是把格式字符串输出到到标准输出(一般是屏幕,可以重定向)。
// 2 sprintf,是把格式字符串输出到指定字符串中,所以参数比printf多一个char*。那就是目标字符串地址。
// 3 fprintf, 是把格式字符串输出到指定文件设备中,所以参数笔printf多一个文件指针FILE*
if ((n = readn(sockfd, (void *) buf, (size_t) 1024)) == 0) // 如果返回0,说明对方发了FIN包
return;
time++;
fprintf(stdout, "1K read for %d \n", time);
sleep(1); // 睡眠1s,模拟服务器时延
}
}
// vptr 是buffer数组,size是每次读多少字节的数据,返回的是真实读了多少数据,有可能读的过程中,发了FIN包
ssize_t readn(int fd, void *vptr, size_t size) {
size_t nleft;
ssize_t nread;
char *ptr;
ptr = vptr;
nleft = size;
while (nleft > 0) {
if ((nread = read(fd, ptr, nleft)) < 0) { //
//read 函数要求操作系统内核从套接字描述字 socketfd读取最多多少个字节(size),并将结果存储到 buffer 中。
// 返回值告诉我们实际读取的字节数目,也有一些特殊情况,如果返回值为 0,表示 EOF(end-of-file),
// 这在网络中表示对端发送了 FIN 包,要处理断连的情况;如果返回值为 -1,表示出错。当然,如果是非阻塞 I/O,情况会略有不同
if (errno == EINTR) // 系统中断,客户端发送了FIN包,则停止读数据
nread = 0;
else
return (-1);
} else if (nread == 0) { // 停止读数据
break;
}
nleft -= nread; // 剩余未读数据
ptr += nread; // buffer数组指针王往后移动
}
return size - nleft; // 返回此次所读数据的字节个数
}
server运行结果
client运行结果
拓展问题:
发送缓冲区越大越好吗?内核缓冲区总是充满数据会发生粘包问题。同时网络的传输大小会限制单次发送的大小,最后由于数据堵塞需要消耗大量的内存资源,资源利用率不高
数据从应用程序发送端到应用程序接收端,复制了几次。
复制几次没有固定答案。 下面来源于极客时间的讲解。
让我们先看发送端,当应用程序将数据发送到发送缓冲区时,调用的是 send 或 write 方法,如果缓存中没有空间,系统调用就会失败或者阻塞。我们说,这个动作事实上是一次“显式拷贝”。而在这之后,数据将会按照 TCP/IP 的分层再次进行拷贝,这层的拷贝对我们来说就不是显式的了。
接下来轮到 TCP 协议栈工作,创建 Packet 报文,并把报文发送到传输队列中(qdisc),传输队列是一个典型的 FIFO 队列,队列的最大值可以通过 ifconfig 命令输出的 txqueuelen 来查看。通常情况下,这个值有几千报文大小。
TX ring 在网络驱动和网卡之间,也是一个传输请求的队列。
网卡作为物理设备工作在物理层,主要工作是把要发送的报文保存到内部的缓存中,并发送出去。
接下来再看接收端,报文首先到达网卡,由网卡保存在自己的接收缓存中,接下来报文被发送至网络驱动和网卡之间的 RX ring,网络驱动从 RX ring 获取报文 ,然后把报文发送到上层。
这里值得注意的是,网络驱动和上层之间没有缓存,因为网络驱动使用 Napi 进行数据传输。因此,可以认为上层直接从 RX ring 中读取报文。
最后,报文的数据保存在套接字接收缓存中,应用程序从套接字接收缓存中读取数据。
这就是数据流从应用程序发送端,一直到应用程序接收端的整个过程,你看懂了吗?
上面的任何一个环节稍有积压,都会对程序性能产生影响。但好消息是,内核和网络设备供应商已经帮我们把一切都打点好了,我们看到和用到的,其实只是冰山上的一角而已。