【Linux】深入理解文件缓冲区

文章目录

      • 问题引入
      • 如何理解缓冲区
      • 缓冲区刷新策略
      • 问题解释
      • 模拟一个文件缓冲区

问题引入

首先看一段代码:

#include <stdio.h>
#include <string.h>
int main()
{const char *msg0="hello printf\n";const char *msg1="hello fwrite\n";const char *msg2="hello write\n";printf("%s", msg0);fwrite(msg1, strlen(msg0), 1, stdout);write(1, msg2, strlen(msg2));fork();return 0;
}

运行代码,结果如下:

image-20230828114238102

如果此时将输出结果重定向一下:

image-20230828114139893

会发现printffwrite都打印了两次。

究其原因,就要谈到缓冲区和缓冲区刷新策略的概念了。


如何理解缓冲区

假设你在青岛,你要从网上买一件商品,商家所在地是北京。你不会跑去北京自提,商家也不会跑到青岛亲自送货,因为成本又高效率又低。实际做法是商家通过寄快递的方式把商品寄到你所提供的地址附近的菜鸟驿站。

我们把北京比作外设,把商品比作信息,把青岛比作内存,把你比作进程,所以现在的情景就变成了位于内存的进程要从外设中读取一段信息。那此时菜鸟驿站是个什么角色呢?答案是缓冲区。

我们都有一个共识,那就是访问外设的效率是很低的。

为了解决这个问题,内存中会单独开一块空间作为缓冲区,当你想往外设输出数据的时候,很多时候并不会来一条输出一条,而是等缓冲区中数据达到一定数量之后再刷新到外设。同样地,当从外设中读取数据的时候,很多时候也不会只读取你想要的那一条,而是前前后后多读取一部分数据,你下次要读取的数据很大可能就在这部分数据中。这样一来就减少了io的次数,效率也就提高了。

这里就有几个问题需要理解一下:

  1. 将数据刷新到缓冲区或将数据从缓冲区刷新出来的本质是什么?

    按照正常的理解,刷新应该是要把这部分东西挪到另一个地方去,但挪动数据属实有点复杂,一要删除二要拷贝,拷贝数据不可避免,但是删除可以简化。比如可以记录一下缓冲区的当前大小,需要刷新缓冲区的时候,先把缓冲区的数据拷贝出去,然后把缓冲区的大小 置零,此时就完成了惰性删除。如果要把数据刷新到缓冲区,只需要将数据拷贝到缓冲区,然后原数据都不需要动。所以将数据刷新到缓冲区或者从缓冲区中刷新出来的本质是拷贝。更进一步,再加上等待数据刷新的这部分时间,不就是 一次完整的io了么。所以我们还可以得出io的本质就是等待+拷贝。

  2. 缓冲区的本质是什么?

    缓冲区的本质就是一段内存。

  3. 缓冲区是谁提供的 ?

    像我们使用printffwrite这类C语言接口进行输入输出时,都是对文件进行输入输出,而文件在C语言中是以FILE的形式描述组织的,所以对文件输入输出,实际上是向FILE中提供的缓冲区进行拷贝。

  4. 缓冲区是谁申请的?

    缓冲区是用户申请的。缓冲区本质是一段内存,当我们创建FILE对象时其实就会创建一段缓冲区,本质就是用malloc去申请,只不过我们看不到这个过程。

我们可以看一下FILE中的相关字段:

/usr/include/libio.h有一句typedef struct _IO_FILE FILE

我们可以在/usr/include/stdio.h中看到struct _IO_FILE的相关代码,下面是与缓冲区有关的部分字段:

struct _IO_FILE {int _flags; /* High-order word is _IO_MAGIC; rest is flags. */#define _IO_file_flags _flags//缓冲区相关/* The following pointers correspond to the C++ streambuf protocol. *//* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */char* _IO_read_ptr; /* Current read pointer */char* _IO_read_end; /* End of get area. */char* _IO_read_base; /* Start of putback+get area. */char* _IO_write_base; /* Start of put area. */char* _IO_write_ptr; /* Current put pointer. */char* _IO_write_end; /* End of put area. */char* _IO_buf_base; /* Start of reserve area. */char* _IO_buf_end; /* End of reserve area. *//* The following fields are used to support backing up and undo. */char *_IO_save_base; /* Pointer to start of non-current get area. */char *_IO_backup_base; /* Pointer to first valid character of backup area */char *_IO_save_end; /* Pointer to end of non-current get area. */struct _IO_marker *_markers;struct _IO_FILE *_chain;int _fileno; //封装的文件描述符#if 0int _blksize;#elseint _flags2;#endif_IO_off_t _old_offset; /* This used to be _offset but it's too small. */#define __HAVE_COLUMN /* temporary *//* 1+column number of pbase(); 0 is unknown. */unsigned short _cur_column;signed char _vtable_offset;char _shortbuf[1];/* char* _save_gptr; char* _save_egptr; */_IO_lock_t *_lock;#ifdef _IO_USE_OLD_IO_FILE
};

缓冲区刷新策略

现在有一个缓冲区,是来一条数据就刷新一条数据,还是等数据达到一定数量才刷新呢?这就跟缓冲区的刷新策略有关了。

在效率方面,上面两种刷新策略无疑是后者效率最高,对于相同数量的数据刷新次数最少,访问外设的次数更少,效率自然更高。那是不是就采用这种刷新策略呢?非也,这还要看对应的场景。

对于显示器这种比较特殊的外设,我们需要时刻获取屏幕上的信息,所以数据从缓冲区刷新到显示器上的频率是会更高的。此时采用的刷新策略就是行缓冲,遇到\n就刷新。这也叫做行缓冲。

当然还有更为激进的刷新策略,就是即使刷新,来一点数据就刷一点。这也叫做无缓冲。

还有一种常见的刷新策略就是全缓冲,等到缓冲区满了再刷新。

所以常见的刷新策略就三条:

  1. 行缓冲,遇到换行符就刷新。
  2. 无缓冲,来一条数据刷新一条数据。
  3. 全缓冲,缓冲区满了才刷新。

当然还有特殊情况,比如进程退出时会把缓冲区刷新一遍,还有就是用户强制刷新,比如调用fflush


问题解释

现在应该能回答最初的问题了。

fprintffwrite都是向stdout中写入,在没有重定向的前提下就是向显示器输出,此时采取的刷新策略是行缓冲,所以会正常打印两条信息。

write是系统调用接口,不存在C语言提供的用户级缓冲区,所以就直接向文件写入了,打印出一条信息。

而一旦进行了重定向,此时输出目标是普通文件,普通文件采取的刷新策略是全缓冲。fprintffwritestdout的缓冲区写入的数据不足以填满缓冲区,所以在两条语句都执行结束之后缓冲区并没有及时刷新。而write由于没有这层缓冲区的缘故直接就写入了。此时创建一个子进程,因为是完全继承的父进程,自然而然地继承了父进程的缓冲区和缓冲区中的数据。下一步子进程和父进程都结束,在两个进程退出之前会先刷新缓冲区,所以两个进程的缓冲区中的数据都被刷新出来,也就是两组相同的字符串,每组两条。也就出现了最终的结果。


模拟一个文件缓冲区

知道了上面的原理,我们可以自己模拟实现一个建议的文件缓冲区及文件操作相关接口,封装一个简易的文件操作,加深对文件操作以及缓冲区的理解。

首先我们需要定义一个简易的FILE结构,因为是一个demo级别的,就封装几个核心字段:

#define SIZE 1024#define SYNC_NOW    1 << 0  //无缓冲
#define SYNC_LINE   1 << 1  //行缓冲
#define SYNC_FULL   1 << 2  //全缓冲typedef struct FILE_{int flags;        //刷新策略int fileno;       //文件描述符int cap;          //buffer的总容量int size;         //buffer当前的使用量char buffer[SIZE];//缓冲区,这里大小是写死的,实际缓冲区的大小是可变的
}FILE_;

此外我们实现四个基本接口,分别是fopen_fclose_fwrite_fflush_

仿照C语言库里函数的参数设计:

FILE_ *fopen_(const char *path_name, const char *mode); 
// path_name是要打开文件的路径
// mode是打开文件时的方式,是读、写还是追加void fwrite_(const void *ptr, int num, FILE_ *fp);
// ptr是要写入的数据的地址
// num是要写入的数据的字节数
// fp是要向哪个文件中写入void fclose_(FILE_ * fp);
// fp是要关闭的文件void fflush_(FILE_ *fp);
// 立即刷新fp的缓冲区

对于fopen_函数,我们直接调用系统调用接口open,在调用之前只要根据mode设置好openflag参数即可,然后需要给文件创建相关的FILE结构对象并设置相关信息:

FILE_ *fopen_(const char *path_name, const char *mode)
{int flags = 0;int defaultMode=0666;if (strcmp(mode, "r") == 0){// O_RDONLY -- 只读flags |= O_RDONLY;}else if (strcmp(mode, "w") == 0){// O_WRONLY -- 只写,O_CREAT -- 文件不存在时创建文件,O_TRUNC -- 打开文件时先清空文件flags |= (O_WRONLY | O_CREAT | O_TRUNC);}else if (strcmp(mode, "a") == 0){// O_APPEND -- 追加flags |= (O_WRONLY | O_CREAT | O_APPEND);}else{// ...}int fd = 0;// 如果是以只写的方式打开,则默认文件已经存在,直接打开即可if (flags & O_RDONLY) fd = open(path_name, flags);// 不然则可能需要创建文件,一旦flags中有O_CREAT信息则需要传创建文件时的默认权限else fd = open(path_name, flags, defaultMode);// 打开文件失败,设置错误信息并返回NULL,这也是为什么打开文件时会返回NULL的原因if (fd < 0){const char *err = strerror(errno);write(2, err, strlen(err));return NULL;}// 打开文件成功,创建相关的的FILE结构对象FILE_ *fp = (FILE_*)malloc(sizeof(FILE_));assert(fp);// 设置FILE对象的字段信息fp->flags = SYNC_LINE; 		 // 默认设置成为行刷新fp->fileno = fd;	   		 // 设置文件描述符fp->cap = SIZE;		   		 // 设置缓冲区容量fp->size = 0;		  		 // 初始未进行写入memset(fp->buffer, 0 , SIZE);// 给缓冲区申请空间return fp; 	// 将fp返回
}

对于fclose_,我们也是直接调用系统调用接口close,只不过在关闭文件之前先刷新一下文件的缓冲区。

注意,C语言中的close函数只负责关闭文件,也就是断掉文件和文件描述符直接的连接关系。

FILE结构的生命周期由标准库管理。当程序终止时,所有仍然打开的文件都会被自动关闭,相应的资源和FILE结构会被释放。这也包括在main函数返回之前打开的文件。所以我们不需要显式地释放FILE结构。

void fclose_(FILE_ *fp)
{fflush_(fp);close(fp->fileno);
}

对于fwrite_,很简单,直接把要写入的数据拷贝到文件的缓冲区即可,此时缓冲区中可能已经有部分数据,所以写入的时候要注意细节。然后需要根据当前文件的刷新策略判断一下是否需要刷新。而数据从缓冲区刷新出去的本质就是调用write接口将数据从缓冲区中写入到文件对应的文件描述符中,然后将缓冲区的size置零进行惰性删除缓冲区内的数据:

void fwrite_(const void *ptr, int num, FILE_ *fp)
{// 写入到缓冲区中memcpy(fp->buffer + fp->size, ptr, num); //这里我们不考虑缓冲区溢出的问题fp->size += num;// 无缓冲,数据读进来之后立即刷新if (fp->flags & SYNC_NOW){write(fp->fileno, fp->buffer, fp->size);fp->size = 0; //清空缓冲区}// 全缓冲else if (fp->flags & SYNC_FULL){if(fp->size == fp->cap){write(fp->fileno, fp->buffer, fp->size);fp->size = 0;}}// 行缓冲else if (fp->flags & SYNC_LINE){// 这里只是为了简单地理解原理,不考虑诸如"abcd\nef"这类较为复杂的场景if(fp->buffer[fp->size-1] == '\n') {write(fp->fileno, fp->buffer, fp->size);fp->size = 0;}}
}

最后就是fflush_,直接调用系统调用接口write将缓冲区的数据写到文件中,然后将缓冲区置空就好了。不过此时还有一个问题,我们用write将数据写入到文件,实际中间还有OS的内核缓冲区,可能不会直接刷新到硬盘中的文件,所以可以通过系统调用接口fsync强制要求OS将数据刷新到外设:

void fflush_(FILE_ *fp)
{if(fp->size > 0) write(fp->fileno, fp->buffer, fp->size);fsync(fp->fileno); // 强制要求OS将数据刷新到外设fp->size = 0;      // 将缓冲区置空,无需对缓冲区原有的数据进行操作
}

最后我们整合一下,把所有相关头文件包含和声明放到头文件stdio_.h中:

#pragma once#include <assert.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>#define SIZE 1024
#define SYNC_NOW    1 << 0  //无缓冲
#define SYNC_LINE   1 << 1  //行缓冲
#define SYNC_FULL   1 << 2  //全缓冲typedef struct FILE_{int flags;        //刷新策略int fileno;       //文件描述符int cap;          //buffer的总容量int size;         //buffer当前的使用量char buffer[SIZE];//缓冲区,这里大小是写死的,实际缓冲区的大小是可变的
}FILE_;FILE_ *fopen_(const char *path_name, const char *mode); 
// path_name是要打开文件的路径
// mode是打开文件时的方式,是读、写还是追加void fwrite_(const void *ptr, int num, FILE_ *fp);
// ptr是要写入的数据的地址
// num是要写入的数据的字节数
// fp是要向哪个文件中写入void fclose_(FILE_ * fp);
// fp是要关闭的文件void fflush_(FILE_ *fp);
// 立即刷新fp的缓冲区

相关方法的实现整合在stdio_.c文件中:

#include "myStdio.h"FILE_ *fopen_(const char *path_name, const char *mode)
{int flags = 0;int defaultMode=0666;if (strcmp(mode, "r") == 0){// O_RDONLY -- 只读flags |= O_RDONLY;}else if (strcmp(mode, "w") == 0){// O_WRONLY -- 只写,O_CREAT -- 文件不存在时创建文件,O_TRUNC -- 打开文件时先清空文件flags |= (O_WRONLY | O_CREAT | O_TRUNC);}else if (strcmp(mode, "a") == 0){// O_APPEND -- 追加flags |= (O_WRONLY | O_CREAT | O_APPEND);}else{// ...}int fd = 0;// 如果是以只写的方式打开,则默认文件已经存在,直接打开即可if (flags & O_RDONLY) fd = open(path_name, flags);// 不然则可能需要创建文件,一旦flags中有O_CREAT信息则需要传创建文件时的默认权限else fd = open(path_name, flags, defaultMode);// 打开文件失败,设置错误信息并返回NULL,这也是为什么打开文件时会返回NULL的原因if (fd < 0){const char *err = strerror(errno);write(2, err, strlen(err));return NULL;}// 打开文件成功,创建相关的的FILE结构对象FILE_ *fp = (FILE_*)malloc(sizeof(FILE_));assert(fp);// 设置FILE对象的字段信息fp->flags = SYNC_LINE; 		 // 默认设置成为行刷新fp->fileno = fd;	   		 // 设置文件描述符fp->cap = SIZE;		   		 // 设置缓冲区容量fp->size = 0;		  		 // 初始未进行写入memset(fp->buffer, 0 , SIZE);// 给缓冲区申请空间return fp; 	// 将fp返回
}void fclose_(FILE_ *fp)
{fflush_(fp);close(fp->fileno);
}void fwrite_(const void *ptr, int num, FILE_ *fp)
{// 写入到缓冲区中memcpy(fp->buffer + fp->size, ptr, num); //这里我们不考虑缓冲区溢出的问题fp->size += num;// 无缓冲,数据读进来之后立即刷新if (fp->flags & SYNC_NOW){write(fp->fileno, fp->buffer, fp->size);fp->size = 0; //清空缓冲区}// 全缓冲else if (fp->flags & SYNC_FULL){if(fp->size == fp->cap){write(fp->fileno, fp->buffer, fp->size);fp->size = 0;}}// 行缓冲else if (fp->flags & SYNC_LINE){// 这里只是为了简单地理解原理,不考虑诸如"abcd\nef"这类较为复杂的场景if(fp->buffer[fp->size-1] == '\n') {write(fp->fileno, fp->buffer, fp->size);fp->size = 0;}}
}void fflush_(FILE_ *fp)
{if(fp->size > 0) write(fp->fileno, fp->buffer, fp->size);fsync(fp->fileno); // 强制要求OS将数据刷新到外设fp->size = 0;      // 将缓冲区置空,无需对缓冲区原有的数据进行操作
}

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

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Matlab(变量与文本读取)

目录 1.变量&#xff08;数据&#xff09;类型转换 1.1 字符 1.2 字符串 1.3 逻辑操作与赋值 2.Struct结构体数组 2.1函数的详细介绍&#xff1a; 2.1.1 cell2struct 2.1.1.1 垂直维度转换 2.1.1.2 水平维度转换 2.1.1.3 部分进行转换 2.1.2 rmfield 2.1.3 fieldnames(查…

HTTP 协议

目录 ​编辑一、HTTP 协议是什么 二、抓包工具的使用 三、HTTP 请求 1、认识 URL 2、认识方法 3、认识请求 “报头” HOST &#xff1a; Content-Length 和 Content-Type​编辑 User-Agent Referer Cookie 四、HTTP 响应 1、认识状态码 2、通过 form 表单构造 H…

最小化安装移动云大云操作系统--BCLinux-for-Euler-22.10-everything-x86_64-230316版

CentOS 结束技术支持&#xff0c;转为RHEL的前置stream版本后&#xff0c;国内开源Linux服务器OS生态转向了开源龙蜥和开源欧拉两大开源社区&#xff0c;对应衍生出了一系列商用Linux服务器系统。BCLinux-for-Euler-22.10是中国移动基于开源欧拉操作系统22.03社区版本深度定制的…

读书笔记-《ON JAVA 中文版》-摘要23[第二十章 泛型-2]

文章目录 第二十章 泛型5. 泛型擦除5.1 泛型擦除5.2 迁移兼容性5.3 擦除的问题5.4 边界处的动作 6. 补偿擦除7. 边界8. 通配符8.1 通配符8.2 逆变 9. 问题10. 动态类型安全11. 泛型异常 第二十章 泛型 普通的类和方法只能使用特定的类型&#xff1a;基本数据类型或类类型。如果…

WPF实战项目十二(API篇):配置AutoMapper

1、新建类库WPFProjectShared&#xff0c;在类库下新建文件夹Dtos&#xff0c;新建BaseDto.cs&#xff0c;继承INotifyPropertyChanged&#xff0c;实现通知更新。 public class BaseDto : INotifyPropertyChanged{public int Id { get; set; }public event PropertyChangedEv…

Go的基础运行方式和打包

目录 基础运行方式导入路径 打包技巧相关知识点 基础运行方式 // 文件名可以不是main&#xff0c;但包名和入口函数比如是main // main.go package main // 导入包的时候可以直接导入&#xff0c;也可以导入后指定包名&#xff0c; import ("fmt"godemo "githu…

nlp系列(7)三元组识别(Bert+CRF)pytorch

模型介绍 在实体识别中&#xff1a;使用了Bert模型&#xff0c;CRF模型 在关系识别中&#xff1a;使用了Bert模型的输出与实体掩码&#xff0c;进行一系列变化&#xff0c;得到关系 Bert模型介绍可以查看这篇文章&#xff1a;nlp系列&#xff08;2&#xff09;文本分类&…

linux c编程之“hello world”一

文章目录 hello world开始学习汇编文件 hello.s第1行第2行第3行第4行第5行第6行第7行第8行第9行第10行第11行第12行第13行 X [注]&#xff1a;环境说明&#xff1a; OS&#xff1a;CentOS 7 GCC&#xff1a; 4.8.5 其他环境下的结果可能不尽相同。 声明&#xff1a;本文是我的一…

Unity中的Unistorm3.0天气系统笔记

Unistorm是Unity中的一个天气系统&#xff0c;它功能强大&#xff0c;效果优美。本文所述UniStorm为3.0版本&#xff0c;仅用于学习之用。 一、如何设置【白天】、【黑夜】和【天气类型】&#xff1f; 在Running模式下&#xff0c;按下Esc按键&#xff0c;会【弹出】或者【隐…

Rancher上的应用服务报错:413 Request Entity Too Large

UI->rancher的ingress->UI前端(在nginx里面)->zuul->server 也就是说没经过一次http servlet 都要设置一下大小 1.rancher的ingress 当出现Request Entity Too Large时&#xff0c;是由于传输流超过1M。 1、需要在rancher的ingress中设置参数解决。 配置注释&a…

JavaScript—面向对象、作用域

C#&#xff1a;从类继承 js&#xff1a;从对象继承 什么叫继承&#xff1f; 模板&#xff08;类&#xff09; 原型继承&#xff08;实体&#xff09; 有一个对象存在&#xff0c;构造函数设置原型为这个对象 创建出来的对象就继承与这个对象&#xff08;从对象那里继承&am…

Linux线程概念

目录 一、页表详解 1.地址的属性 2.页框 3.页表录和页表项 二、认识线程 1.线程的概念 2.轻量级进程 三、线程的简单控制 1.线程的创建 2.PID和LWP 3.线程异常 4.线程的资源 &#xff08;1&#xff09;共享的资源 &#xff08;2&#xff09;独有的资源 5.线程的…

mysql 存储引擎系列 (一) 基础知识

当前支持存储引擎 show engines&#xff1b; 显示默认存储引擎 select default_storage_engine; show variables like ‘%storage%’; 修改默认引擎 set default_storage_enginexxx 或 set default_storage_enginexxx; my.ini 或者 my.cnf ,需要重启 服务才能生效 systemctl …

cortex-A7核LED灯实验--STM32MP157

实验目的&#xff1a;实现LED1 / LED2 / LED3三盏灯工作 一&#xff0c;分析电路图 1&#xff0c;思路 分析电路图可知&#xff1a; 网络编号 引脚编号 LED1 PE10 LED2 > PF10 LED3 > PE8 2&#xff0c;工作原理&#xff1a; 写1&#xff1a;LED灯亮&#xf…

防雷检测综合应用方案

防雷检测是指对建筑物的防雷装置进行定期或不定期的检测&#xff0c;以评估其性能和安全状况&#xff0c;发现并消除隐患&#xff0c;保障人身和财产安全的一项重要工作。防雷检测的内容包括对接闪器、避雷带、引下线、接地装置、等电位联结、避雷器等部件的形式、位置、连接、…

RecyclerView面试问答

RecycleView 和 ListView对比: 使用方法上 ListView:继承重写 BaseAdapter,自定义 ViewHolder 与 converView优化。 RecyclerView: 继承重写 RecyclerView.Adapter 与 RecyclerView.ViewHolder。设置 LayoutManager 来展示不同的布局样式 ViewHolder的编写规范化,ListVie…

Spring security报栈溢出几种可能的情况

今天在运行spring security的时候&#xff0c;发现出现了栈溢出的情况&#xff0c;总结可能性如下&#xff1a; 1.UserDetailsService的实现类没有加上Service注入到容器中&#xff0c;导致容器循环寻找UserDetailsService的实现类&#xff0c;最终发生栈溢出的现象。 解决方法…

Redis 7 第三讲 数据类型 进阶篇

⑥ *位图 bitmap 1. 理论 由0和1 状态表现的二进制位的bit 数组。 说明:用String 类型作为底层数据结构实现的一种统计二值状态的数据类型 位图本质是数组,它是基于String 数据类型的按位操作。该数组由多个二进制位组成,每个二进制位都对应一个偏…

Java进阶(6)——抢购问题中的数据不安全(非原子性问题) Java中的synchronize和ReentrantLock锁使用 死锁及其产生的条件

目录 引出场景&#xff1a;大量请求拥挤抢购事务的基本特征ACID线程安全的基本特征 加锁(java)synchronized锁ReentrantLock锁什么是可重入锁&#xff1f;如何保证可重入 滥用锁的代价&#xff1f;&#xff08;死锁&#xff09;死锁的四个必要条件死锁的案例 总结 引出 1.大量请…

基于SpringBoot的员工(人事)管理系统

基于SpringBoot的员工&#xff08;人事&#xff09;管理系统 一、系统介绍二、功能展示三.其他系统实现五.获取源码 一、系统介绍 项目名称&#xff1a;基于SPringBoot的员工管理系统 项目架构&#xff1a;B/S架构 开发语言&#xff1a;Java语言 前端技术&#xff1a;BootS…