【Linux取经路】初探进程地址空间

在这里插入图片描述

文章目录

  • 一、历史问题回顾
  • 二、语言层面的地址空间
    • 2.1 验证
  • 三、虚拟地址的引入
    • 3.1 初步解释这种现象——引入地址空间的概念
    • 3.2 再来粗粒度理解上面的现象
  • 四、细节解释
    • 4.1 地址空间究竟是什么?
    • 4.2为什么要有地址空间
    • 4.3 页表
      • 4.3.1 CR3寄存器
      • 4.3.2 页表是由页表项组成的
      • 4.3.3 缺页中断
  • 五、结语

一、历史问题回顾

之前在介绍 fork 函数的时候说过该函数返回了两次,至于为什么会返回两次,以及 fork 函数是如何做到返回两次的,在【Linux取经路】揭秘进程的父与子一文中已经做了详细的解释,忘了小伙伴可以点回去看看。在解释一个变量怎么会有两个不同值的时候,当时的说法是由于进程具有独立性,所以子进程把把父进程的数据拷贝了一份(写时拷贝),本质上是因为有两块空间,但是为什么同一个变量名可以让父子进程看到不同的内容当时并没有过多解释,因为这涉及到今天要讲解的程序地址空间问题,接下来就让我们来一探究竟吧。

二、语言层面的地址空间

相信大家在学习 C/C++ 语言的时候,一定都见过下面这张图:
在这里插入图片描述

小Tips:上图是以32位机为基础的,32位地址线最多可以表示 2 32 2^{32} 232个地址,因为每个地址线上只有0和1两种可能,最终也就是有4G个地址,一个地址对应一个字节,所以就是 2 32 2^{32} 232个字节,所以总的地址空间大小就是4G。内核空间是给操作系统使用的,一个进程对应的所有的数据结构对象都存储在内核空间。

2.1 验证

#include <stdio.h>    
#include <stdlib.h>    
    
int g_val_1; // 定义一个未初始化全局变量    
int g_val_2 = 100; // 定义一个已初始化全局变量    
int main(int argc, char* argv[], char* env[])    
{    
    printf("code addr:%p\n", main);// 函数名代表的就是地址,通过打印 main 函数的地址来查看代码区的地址    
    const char *str = "Hello word";// 定义一个字符串常量    
    printf("read only string addr:%p\n", str);// 字符常量区的地址    
    printf("init global value addr:%p\n", &g_val_2);// 已初始化全局变量的地址    
    printf("uninit global value addr:%p\n", &g_val_1); // 未初始化全局变量的地址    
    char* men1 = (char*)malloc(100);    
    printf("heap addr-men1:%p\n", men1);// 堆区的地址    
    printf("stack addr-str:%p\n", &str); // 栈区的地址    
    static int a = 10;// 定义一个静态局部变量    
    printf("static a add:%p\n", &a); // 静态局部变量的地址    
    int i = 0;    
    for(; argv[i]; i++)    
        printf("argv[%d],addr:%p\n", i, argv[i]);// 打印命令行参数的地址   
    for(i = 0; env[i]; i++)    
        printf("env[%d],addr:%p\n", i, env[i]);// 打印环境变量的地址                                                                                                                                           
    return 0;    
}

在这里插入图片描述
小Tips:堆栈空间是相对“生长”的。即堆区是先使用低地址再使用高地址,而栈区是先使用高地址再使用低地址。

// 栈区地址由高向低增长
int main()    
{    
    int a;    
    int b;    
    int c;    
    int d;    
    printf("stack addr:%p\n", &a);    
    printf("stack addr:%p\n", &b);    
    printf("stack addr:%p\n", &c);    
    printf("stack addr:%p\n", &d);                                                                                                                                                                               
}    

在这里插入图片描述

// 堆区地址由低向高增长
int main()    
{    
    char* mem1 = (char*)malloc(100);    
    char* mem2 = (char*)malloc(100);    
    char* mem3 = (char*)malloc(100);    
    char* mem4 = (char*)malloc(100);    
    printf("Heap addr:%p\n", mem1);    
    printf("Heap addr:%p\n", mem2);    
    printf("Heap addr:%p\n", mem3);    
    printf("Heap addr:%p\n", mem4);                                                                                                                                                                              
    return 0;    
}

在这里插入图片描述
小Tips:这里还有一个小细节,堆栈地址其实相聚很远,原因是堆栈之间还有一块区域,这块区域在讲解动静态库的时候为大家讲解。

// 静态变量地址的验证
int g_val_1; // 定义一个未初始化全局变量    
int g_val_2 = 100; // 定义一个已初始化全局变量    
int main()    
{    
    printf("code addr:%p\n", main);// 函数名代表的就是地址,通过打印 main 函数的地址来查看代码区的地址    
    const char *str = "Hello word";// 定义一个字符串常量    
    printf("read only string addr:%p\n", str);// 字符常量区的地址    
    printf("init global value addr:%p\n", &g_val_2);// 已初始化全局变量的地址    
    printf("uninit global value addr:%p\n", &g_val_1); // 未初始化全局变量的地址    
    char* men1 = (char*)malloc(100);    
    printf("heap addr-men1:%p\n", men1);    
    printf("stack addr-str:%p\n", &str);    
    static int a = 10;// 定义一个静态局部变量                                                                                                                                                                     
    printf("static a add:%p\n", &a);    
    return 0;    
}

在这里插入图片描述
小Tips:从打印结果中可以看出,静态变量的地址和全局变量的地址十分接近。这是因为 static 修饰的局部变量,编译的时候已经被编译到全局数据区了。注意静态局部变量被编译到全局数据区仅仅是延长了该变量的生命周期,作用域并没有改变,还是只能在 main 函数的作用域中使用该变量。

注意:本小节的所有代码验证都是基于 Linux 操作系统的,相同的代码放在 Windows 操作系统中的 VS 下跑出来的结果可能会有所不同。

三、虚拟地址的引入

下面通过一个例子来引入虚拟地址的概念。

int g_val = 100;                                                                                                                                                                                                  
int main()
{
    pid_t pid = fork();

    if(pid == 0)
    {
        int cnt = 5;
        // 子进程
        while(1)
        {
            printf("I am child, pid:%d, ppid:%d, g_val:%d, &g_val:%p\n", getpid(), getppid(), g_val, &g_val);
            sleep(1);
            if(cnt)
            {
                cnt--;
            }    
            else    
            {    
                g_val = 200;    
            }    
        }    
    }    
    else    
    {    
        // 父进程    
        while(1)    
        {    
            printf("I am parent, pid:%d, ppid:%d, g_val:%d, &g_val:%p\n", getpid(), getppid(), g_val, &g_val);    
            sleep(1);    
        }    
    }    
    return 0;    
}

代码解释:上面这段代码中创建了一个子进程,并且定义了一个全局的变量 g_val,初始化为100。让父子进程同时去访问变量 g_val。在子进程要执行的代码片段中还定义了一个局部变量 cnt,初始化为5,每执行一次循环就让 cnt--,当 cnt 减到0的时候把 g_val 的值修改为200。

在这里插入图片描述
结果分析:从打印结果中可以看出,在子进程对 g_val 进行修改后,父子进程获取到的 g_val 的值并不一样,这符合我们的预期。因为父子进程相互独立,他们拥有各自的代码和数据,子进程在对 g_val 进行修改的时候会发生写时拷贝,这一点在前面的文章中已经讲过。但奇怪的是,为什么同一个地址,从该地址获取到的数据却不相同。那么真想就只有一个,这个打印出来的地址一定不是真实存在的物理地址,因为真实存在的物理地址中只能存放一个数据,不可能同时存储两个不同的数据。因此我们可以得出一个结论:我们代码中打印出来的地址不是物理地址,一般把这个地址叫做线性地址或者虚拟地址

3.1 初步解释这种现象——引入地址空间的概念

之前在介绍进程的时候说过,一个进程就等于 task_struct + 代码数据。但实际上事情并没有这么简单。一个进程一旦被创建出来,操作系统为了让该进程能够更好的运行,除了会为该进程创建对应的 PCB 对象之外,还会为这个进程创建一个地址空间(准确的叫法是进程地址空间)。我们平时在编码过程中使用的地址就是这个地址空间中的地址。进程地址空间本质上是内核为该进程创建的一个结构体对象。进程的 PCB 中是有对应的指针,指向该地址空间。进程地址空间中的虚拟地址和真是的物理地址是通过页表建立联系的,因此每个进程也会有一张页表。
在这里插入图片描述

小Tips:进程 PCB、进程地址空间、页表、物理地址四者之间的关系如上图所示。进程相关的代码和数据一定是存储在物理内存上的。

3.2 再来粗粒度理解上面的现象

根据进程独立性可知,每个进程都要有自己独立的 PCB、进程地址空间、页表。子进程的这些东西,大部分都是以父进程为模板创建出来的。对于全局变量 g_val,在物理内存上它始终只有一份,在父进程中 g_val 有自己的虚拟地址0X601054,创建子进程的时候,子进程根据父进程的地址空间创建出自己的地址空间,此时 g_val 对应的虚拟地址仍然是0X601054。子进程的页表最初也是根据父进程的页表去创建的,因此子进程中 g_val 变量的虚拟地址和父进程中的一样,还是0X601054,并且子进程页表中的虚拟地址和物理地址的映射关系还是继承自父进程。因此,在子进程和父进程中都能够访问到 g_val 这个变量,并且在子进程和父进程中打印出来的 g_val 的地址都是一样的。父子进程共享同一份代码也是根据这个原理来实现的。当子进程要修改 g_val 变量的时候,由于父子进程的数据是相互独立的,该独立性体现在,在子进程去修改 g_val 的值不能影响到父进程,即,在父进程中 g_val 本身的值是100,在子进程将 g_val 的值修改成200的时候,父进程中 g_val 的值仍然得保持100。所以子进程在修改的时候会发生写时拷贝,其本质就是操作系统发现子进程要去修改父子进程所共享的数据,操作系统会说:“子进程你等会儿,先别改”。然后操作系统会为子进程在物理内存中开辟一块空间来存储 g_val 的值,最后修改页表中的虚拟地址0X601054所对应的物理地址。这就是为什么打印出来的是通过一个地址,但是却有两个不同的值。
在这里插入图片描述
小Tips:写时拷贝是操作系统自动完成的,子进程并不知情。这就相当于你有一个朋友要到家里来玩,但是家里有点乱,你让他等会再来,期间你收拾房子的这个过程你朋友并不知情。操作系统就相当于是你,子进程就相当于是你的朋友。重新开辟空间,但是在这个过程中,左侧的虚拟地址是0感知的,它不关心也不会影响它。

四、细节解释

4.1 地址空间究竟是什么?

所谓的进程地址空间,本质是一个描述进程可视范围的大小。并且地址空间内一定存在各种区域划分。因为每个进程将来都会有一个地址空间,所以操作系统一定会想办法把这些地址空间管理起来。管理的本质就是先描述再组织。在语言层面要描述一个事物只能通过结构体,因此地址空间本质是内核的一个数据结构对象,类似 PCB 一样。Linux 中描述地址空间的结构体是 struct mm_struct,该结构体中通过定义 startend 字段来确定地址空间的范围,以及进行区域划分。

struct mm_struct
{
	unsigned long start_code; // 代码段的开始地址
	unsigned long end_code; // 代码段的结束地址
	unsigned long start_data; // 数据的首地址
	unsigned long end_data; // 数据的尾地址
	unsigned long start_brk; // 堆的首地址
	unsigned long brk; // 堆的尾地址
	unsigned long start_stack; // 进程栈的首地址
	//...
};

小Tips:除了这些 startend 对应的地址外,我们更应该注意在 startend 范围内,连续的空间中,每一个最小单位都可以有地址,这个地址可以被直接使用。

4.2为什么要有地址空间

  • 原因一:其中一个重要的原因是让进程以统一的视角看待内存。假如没有地址空间和页表,进程直接访问存储在内存上的数据和代码是非常麻烦的,首先进程需要将对应数据和代码的物理地址保存在进程 PCB 中,其次当一个进程从挂起状态或者阻塞状态被变成运行状态的时候,其数据和代码会被重新加载到内存中,此时物理地址大概率是会发生变化的,如果没有地址空间,就需要去修改进程 PCB 中的内容,这个过程是十分复杂的,并且我们的可执行程序加载到内存中并不一定是连续的,这一点会在后面进行讲解,因为可执行程序并不是一次性全部加载到内存中,这就导致我们的代码在物理内存中是东一块西一块,很乱。而地址空间的存在会帮我们解决这些问题,我们可以对可执行程序的虚拟地址进行连续的编址,这样就可以可以根据虚拟地址的先后顺序去“从头”去执行可执行程序,我们的进程不再需要担心可执行程序实际存储在内存的什么位置。这一点也可以总结成,地址空间的存在将物理内存中的代码和数据从无序变成了有序。

  • 原因二:地址空间存在的第二个重要原因是,进程在访问内存的时候,增加一个转换的过程,在这个转换的过程中,可以对我们的寻址请求进行审查,一旦发生异常访问,比如越界访问,去修改只读数据等,会直接拦截,该请求不会到达物理内存,起到保护物理内存的作用。

  • 原因三:因为有地址空间和页表的存在,将进程管理模块和内存管理模块进行解耦合。

小Tips:地址空间就相当于是操作系统给进程画的一个大饼。操作系统给每个进程划分的地空间范围都是0~4G(以32位操作系统为例)。每个进程都不知道对方的存在,都以为自己会独享这4G的空间。但实际并不是这样的,操作系统并不会一次性把这4G的空间给同一个进程,而是每个进程需要多少了就来申请多少。

4.3 页表

关于页表我们今天只做简单的介绍,真正的页表并不是向上图中画的那样只有两个字段,而是有多个字段并且分为多级页表。今天关于页表只介绍一下几点:

4.3.1 CR3寄存器

CPU 中有一个叫做 CR3 的寄存器(X86架构),该寄存器中保存的就是当前正在运行的进程的页表的地址,本质上属于当前进程的硬件上下文,当前进程被切换走了,是会把和该进程有关的所以硬件上下文数据带走的,当然就包括 CR3 寄存器中保存的该进程页表的首地址。该进程重新被 CPU 调度的时候,会把该这些硬件上下文数据重新加载到 CPU 里对应的寄存器中。CR3 寄存器中存的是物理地址。

4.3.2 页表是由页表项组成的

页表是有多个页表项组成的,一般的页表项有如下几个。Present(存在位):表示该页是否存在于物理内存当中。如果存在数据才可以访问。如果不存在,可能会引发缺页异常。Read/Write(读/写位):表示是否允许读取和写入。如果设置了“只读”,则只允许读取,写入会引发保护异常。代码段和字符常量区的数据所对应的页就是“只读”。User/Supervisor(用户/超级用户位):用于指示是否允许用户态程序访问该页。如果设置了“用户态可访问”,则用户程序可以访问;否则,只有内核态可以访问。这些权限位通过硬件(如CPU)来执行,当程序尝试访问内存时,硬件会检查相应的页表项权限位,如果权限不符合要求,会触发相应的异常,例如页故障异常。这样可以确保对内存的合法访问,提高系统的安全性和稳定性。

小Tips:这里需要注意一下,单纯的物理内存是没有“只读”、“只写”等这些权限管理概念的,对于物理内存上的任何一块空间来说,都应该是可读可写的。所以我们在语言层面所说的代码区和字符常量区是只读的,本质上是因为存储代码和字符常量的物理内存所对应的页中设置了“只读”权限。

#include <stdio.h>    
      
int main()    
{    
    char* str = "Hello World";    
    *str = 'B';                                                                                                                                                                                                 
    return 0;    
}

在这里插入图片描述

小Tips:上面这段代码一看就是错误的写法。因为正常来说是需要在 char* str 的前面加上 const。但是加上后,这段代码会在编译时就报错(语法上说不过去,被 const 修饰后是不能再进行修改的)。而不加则是在运行时报错,这就是这段代码在运行时通过页表去 *str 中的内容,被硬件检查出不符合要求,进而产生了异常。

4.3.3 缺页中断

现代操作系统几乎不做任何浪费空间和时间的事情,操作系统对大文件可以实现分批加载。这个其实很好理解,我们平时玩的大型电脑游戏动辄就几十个G,而我们的内存一般就只有8个G或者16个G,所以我们在玩这种大型游戏的时候,一定没有把和该游戏有关的所有文件一次性加载到内存中,而是采用分批加载的策略。操作系统对可执行程序一般采用的是惰性加载机制,即操作系统承诺给进程分配4G的空间(虚拟内存的大小),但实际在物理内存上是用多少加载多少。通过页表中的Present(存在位)页表项去判断,去标记该页是否存在于物理内存中,如果不存在就会发生缺页中断,将需要的代码和数据加载到物理内存中。可不要小瞧加载过程,这里会涉及到物理内存这么大,该申请那块儿内存,加载是加载可执行程序的那一部分,加载完后物理地址如何填到页表里呢?这一系列和缺页中断相关的问题最终都是由操作系统中的内存管理模块来执行的。整个缺页中断的过程对进程是不可见的。正是因为地址空间和页表的存在,才实现了进程管理和内存管理在软件层面的解耦。进程要访问对应的代码和数据只需要知道虚拟地址即可,内存管理模块根据虚拟地址去判断是否要进行缺页中断。

小Tips:进程在被创建的时候,一定是先创建内核数据结构(进程 PCB、地址空间、页表…),然后再加载对应的可执行程序。挂起状态就是将进程的代码和数据从内存中清出去,然后再将Present(存在位)标志位设置成不存在即可。

五、结语

今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,春人的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是春人前进的动力!

在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/336212.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

智慧校园大数据应用系统介(3)

智能巡课系统 巡课系统是一种新的课堂观察记录工具,它将学校或区域内全体教师作为一个整体,采用数字化手段描述教师和学生的课堂行为。通过移动端实时记录和通用性的统计分析,使教育者更容易发现教学过程与教学成果之间的联系,有助于改变课堂生态、提高教学有效性、提升教…

Codeforces Round 895 (Div. 3)补题

Two Vessels&#xff08;Problem - A - Codeforces&#xff09; 题目大意&#xff1a;有两个无限容器&#xff0c;目前一个容器中有a克水&#xff0c;另一个容器中有b克水&#xff0c;现有一个大小为cg的容器&#xff0c;我们每次可以从一个无限容器中取任意不大于c克的水&…

【Linux】相关背景及环境搭建

前言&#xff1a; 认识 Linux, 了解 Linux 的相关背景&#xff0c;学会如何使用云服务器&#xff0c;掌握使用远程终端工具 xshell 登陆 Linux 服务器 文章目录 一、Linux介绍1.1 关于UNIX1.2 Linux的诞生及发展历程1.3 Linux开源1.4 Linux在各个行业的现状1.5 发行版本 二、Li…

令牌桶算法与Guava的实现RateLimiter源码分析

令牌桶算法与Guava的实现RateLimiter源码分析 令牌桶RateLimiter简介RateLimiter使用示例导入maven依赖编写测试代码 RateLimiter的实现源码解析SmoothRateLimiterSmoothBursty恒速获取令牌acquire(int)tryAcquire(int,long,TimeUnit) 存量桶系数小结 优缺点与漏桶的区别总结 令…

Python爬虫时被封IP,该怎么解决?四大动态IP平台测评

在使用 Python 进行爬虫时&#xff0c;很有可能因为一些异常行为被封 IP&#xff0c;这主要是因为一些爬虫时产生的异常行为导致的。 在曾经的一次数据爬取的时候&#xff0c;我尝试去爬取Google地图上面的商家联系方式和地址信息做营销&#xff0c;可是很不幸&#xff0c;还只…

CloudPanel file-manager/backend/makefile接口存在远程命令执行漏洞CVE-2023-35885

@[toc] 免责声明:请勿利用文章内的相关技术从事非法测试,由于传播、利用此文所提供的信息或者工具而造成的任何直接或者间接的后果及损失,均由使用者本人负责,所产生的一切不良后果与文章作者无关。该文章仅供学习用途使用。 1. CloudPanel 简介 微信公众号搜索:南风漏…

【漏洞复现】Hikvision摄像头产品越权漏洞(CVE-2017-7921)

Nx01 产品简介 Hikvision&#xff08;海康威视&#xff09;是一家在中国颇具影响力的安防公司&#xff0c;其网络摄像头产品在市场上占据了相当大的份额。Hikvision的网络摄像头产品线非常丰富&#xff0c;涵盖了各种型号和功能&#xff0c;以满足不同用户的需求。 Nx02 漏洞描…

Spring DI

目录 什么是依赖注入 属性注入 构造函数注入 Setter 注入 依赖注入的优势 什么是依赖注入 依赖注入是一种设计模式&#xff0c;它通过外部实体&#xff08;通常是容器&#xff09;来注入一个对象的依赖关系&#xff0c;而不是在对象内部创建这些依赖关系。这种方式使得对象…

03-黑马程序员大数据开发:Apache Hive

一、 Apache Hive概述 1. 目的&#xff1a;&#xfeff;了解什么是分布式SQL计算&#xff1b;了解什么是Apache Hive 2. 使用Hive处理数据的好处 &#xfeff;操作接口采用类SQL语法&#xff0c;提供快速开发的能力&#xff08;简单、容易上手)&#xfeff;底层执行MapReduc…

第七回 林教头刺配沧州道 鲁智深大闹野猪林-FreeBSD/Linux图形界面安装配置

高俅定林冲&#xff1a;手持利刃&#xff0c;故入节堂&#xff0c;杀害本官的罪名&#xff0c;将林冲押解去开封府&#xff0c;暗示开封府将林冲处决。 开封府负责办案的叫孙定&#xff0c;他为人刚正不阿&#xff0c;宅心仁厚。在他的据理力争之下&#xff0c;开封府尹最终对…

【linux】ps的基本使用

ps是linux中用于显示进程的工具&#xff0c;确切来说是显示活动进程的工具 ps的基本格式是 ps [选项] sh-3.2# ps --help ps: illegal option -- - usage: ps [-AaCcEefhjlMmrSTvwXx] [-O fmt | -o fmt] [-G gid[,gid...]][-g grp[,grp...]] [-u [uid,uid...]][-p pid[,pid..…

windows下redis使用教程

创建临时服务 redis-server.exe redis.windows.conf启动客户端 验证 # 使用set和get命令&#xff0c;对Redis数据库进行数据存储和获取&#xff0c;如下图所示 config get *创建永久服务 关闭临时服务的cmd窗口&#xff0c;输入以下命令 redis-server.exe --service-insta…

【设计模式-08】Flyweight享元模式

简要说明 简要的理解&#xff1a;享元模式就是新建一个池(Pool)&#xff0c;该池子(Pool)中有新建好的一堆对象&#xff0c;当需要使用时&#xff0c;从池子(Pool)中直接获取&#xff0c;不用重新新建一个对象。通俗的讲就是&#xff1a;共享元数据。 比如Java中的String就是使…

Maven详解(入门到精通)学习maven有这个就够了

目录 1. Maven简介 2. 什么是Maven? 3. Maven的下载和安装 安装maven核心程序 4.Maven 核心概念 5. 第一个maven项目 创建约定的目录结构 6. 为什么创建约定的目录结构&#xff1f; 7. 基本的Maven命令 8. 关于联网下载的问题 9. 仓库 10. pom 11.坐标 12. 依赖初步认…

扎克伯格宣布将购买35万个GPU

Meta公司马克.扎克伯格1月18日在Instagram上发表文章称&#xff0c;该公司正在加强人工智能研究团队的力量&#xff0c;并在充实AI基础设施“弹药库“&#xff0c;计划在今年年底前向芯片设计商英伟达购买35万个H100 GPU芯片&#xff0c;从而使该公司的GPU总量达到约60万个&…

蓝桥杯练习题dfs与bfs

&#x1f4d1;前言 本文主要是【算法】——dfs与bfs的文章&#xff0c;如果有什么需要改进的地方还请大佬指出⛺️ &#x1f3ac;作者简介&#xff1a;大家好&#xff0c;我是听风与他&#x1f947; ☁️博客首页&#xff1a;CSDN主页听风与他 &#x1f304;每日一句&#xff…

璀璨2023,共赴2024——Tempo大数据分析产品年度回顾

随着2024年的到来&#xff0c;2023年已落下了帷幕&#xff0c;这一年里&#xff0c;Tempo大数据分析产品不断追求创新&#xff0c;进行了四次重要的版本升级。为用户带来新功能的同时确保用户在使用产品时获得卓越的体验感&#xff0c;从而更大程度地提升用户的工作效率。 现在…

使用Nginx和Fancyindex组合搭建文件下载站点详细教程

目录 简介 TIPS 1.下载Nginx 2. 安装Fancyindex和Nginx-Fancyindex-Theme模块 2.1 安装编译工具和依赖 2.2 下载Fancyindex和Nginx-Fancyindex-Theme 2.3 编译Nginx并包括Fancyindex 3. 配置Nginx 4.体验 4.1light主题 4.2dark主题 后记 简介 当使用Nginx和Fancyinde…

基于SpringBoot的欢乐校园管理系统

文章目录 项目介绍主要功能截图&#xff1a;部分代码展示设计总结项目获取方式 &#x1f345; 作者主页&#xff1a;超级无敌暴龙战士塔塔开 &#x1f345; 简介&#xff1a;Java领域优质创作者&#x1f3c6;、 简历模板、学习资料、面试题库【关注我&#xff0c;都给你】 &…

使用Python监听并下载微信聊天表情包

实现的功能 只要有人给你发了表情包&#xff0c;不管是群聊还是个人发的&#xff0c;都将它保存到本地。也许某天斗图的时候就能用到&#xff0c;不过即使有了表情包&#xff0c;还需要一个检索功能&#xff0c;不然这一张一张看也太费眼睛了。 检索表情包 检索表情包的功能…