首页 > 其他分享 >mit6.828笔记 - lab3 Part B:页面故障、断点异常和系统调用

mit6.828笔记 - lab3 Part B:页面故障、断点异常和系统调用

时间:2024-05-15 17:33:09浏览次数:24  
标签:mit6.828 调用 syscall trap lab3 user tf 断点 内核

Part B 页面故障、断点异常和系统调用

虽然说,我们故事的主线是让JOS能够加载、并运行 user/hello.c 编译出来的镜像文件。
虽然说,经过Part A最后几节,我们初步实现了异常处理的基础设施。
但是对于操作系统来说,还远远不够,比如说那个 trap_dispatch 还没完成。

所以在回到故事主线之前,我们需要进一步完善异常处理的基础设施。

处理页面故障

页面故障异常,即中断向量 14 (T_PGFLT),是一个特别重要的异常,我们将在本实验和下一个实验中大量使用。当处理器发生页面故障时,它会将导致故障的线性(即虚拟)地址存储到一个特殊的处理器控制寄存器 CR2 中。在 trap.c 中,我们提供了一个特殊函数 page_fault_handler() 的雏形,用于处理页面故障异常。

根据 lab3 手册的指引,我们先处理好缺页异常的处理

Exercise 5

练习 5. 修改 `trap_dispatch()`,
将页面故障异常分派到 `page_fault_handler()`。
现在,您应该能够让 `make grade` 在 `faultread`、`faultreadkernel`、`faultwrite` 和 `faultwritekernel` 测试中成功。
如果有任何测试不成功,请找出原因并加以修复。
记住,你可以使用 `make run-x` 或 `make run-x-nox` 将 JOS 启动到特定的用户程序中。
例如,`make run-hello-nox` 运行 hello 用户程序。

按照指引,在 trap_dispatch 中,调用一下 page_fault_handler 就行。不过呢,正如手册所言,这只是 page_fault_handler 的雏形,看看 page_fault_handler 就知道,里面其实什么都没做。后面会再进一步完善缺页故障的处理。

trap_dispatch

static void
trap_dispatch(struct Trapframe *tf)
{
	// Handle processor exceptions.
	// LAB 3: Your code here.
	if(tf->tf_trapno == T_PGFLT){
		page_fault_handler(tf);
		return ;
	}
	// Unexpected trap: The user process or the kernel has a bug.
	print_trapframe(tf);
	if (tf->tf_cs == GD_KT)
		panic("unhandled trap in kernel");
	else {
		env_destroy(curenv);
		return;
	}
}

这里就是在其中添加了一个 if 判断,如果trapno 是却也异常就调用 page_fault_handler,
因此可以想象,之后对trap_dispatch的扩展的话,大概会是个switch-case 的多分支结构,
根据不同的 trapno 调用不同的处理函数。

make grade 测试一下:
image.png

断点异常

接下来按照 lab3 手册的指引,完成断点异常的处理:

中断点异常,即中断向量 3(T_BRKPT),通常用于允许调试程序在程序代码中插入断点,方法是用特殊的 1 字节 int3 软件中断指令临时替换相关的程序指令。在 JOS 中,我们将略微滥用这个异常,把它变成一个原始的伪系统调用,任何用户环境都可以用它来调用 JOS 内核监控器。如果我们把 JOS 内核监视器看作一个原始的调试器,那么这种用法实际上是比较恰当的。例如,lib/panic.cpanic() 的用户模式实现就会在显示panic信息后执行一个 int3。

Exercise 6

练习 6. 修改 trap_dispatch(),使断点异常调用内核监视器。现在你应该能让 make grade 在breakpoint测试中成功了。

将 trap_dispatch 改写成这样

switch (tf->tf_trapno)
	{
	case T_PGFLT:
		page_fault_handler(tf);
		return ;
	case T_BRKPT:
		monitor(tf);
		return ;
	default:
		break;
	}

System calls

接下来,lab3手册终于带着我们实现卡着主线故事的 int 48 了。

用户进程通过调用 system calls 来要求内核帮他们干活。
当用户进程调用一个system call, 处理器会进入内核态,处理器和内核会一起协作来保存用户进程状态
然后,内核执行适当代码来处理系统调用
调用完毕后,将控制返还给用户进程
用户进程如何调用内核的具体实现,各个操作系统各不相同。

在 JOS 内核中,我们将使用 int 指令,该指令会导致处理器中断。具体来说,我们将使用 int $0x30 作为系统调用中断。我们为您定义了 T_SYSCALL 常量为 48 (0x30)。您需要设置中断描述符,允许用户进程引发该中断。请注意,中断 0x30 不能由硬件产生, 因此允许用户代码生成中断不会引起歧义。

应用程序将通过寄存器传递系统调用号和系统调用参数。这样,内核就不需要在用户环境的堆栈或指令流中到处乱跑了。
系统调用号将存放在 %eax,参数(最多五个)将分别存放在 %edx、%ecx、%ebx、%edi 和 %esi。内核会将返回值传回 %eax调用系统调用的汇编代码已在 **lib/syscall.c** 中的 **syscall()** 中为您编写

最后一段交代了用户的 syscall 是如何传参的,即,通过5个寄存器传递。那为啥不设计成像平时一样压入栈调用呢?
稍微想一下,如果压入栈,然后再把栈中的参数复制到异常栈,那不就和调用门在跨级转移控制权限的过程一样了吗。
但是,这里是通过中断实现用户进程调用内核的过程的,中断的控制转移和调用门的控制转移在栈切换的区别就在于:
中断:
在压入旧SS和旧ESP之后,压入返回地址之前,压入的是 EFLAGS,
image.png
而调用门则是,压入栈中的参数

Exercise 7

Exercise 7
练习 7. 在内核中为中断向量 `T_SYSCALL` 添加一个处理程序。您需要编辑 `kern/trapentry.S` 和 `kern/trap.c` 的 `trap_init()`。
您还需要修改 `trap_dispatch()`,通过调用` syscall()`(定义在 `kern/syscall.c`)来处理系统调用中断,并在 `%eax` 中安排将返回值传回用户进程。
最后,您需要在 `kern/syscall.c` 中实现 `syscall()`。
如果系统调用号无效,请确保 `syscall()` 返回 `-E_INVAL`。
您应该阅读并理解 `lib/syscall.c`(尤其是内联汇编例程),以确认您对系统调用接口的理解。
处理 `inc/syscall.h` 中列出的**所有系统调用** ,**为每个调用调用相应的内核函数** 。

在内核下运行用户/hello 程序(make run-hello)。它应该会在控制台上打印 "hello, world",然后在用户模式下引起页面错误。如果没有出现这种情况,很可能说明你的系统调用处理程序不太正确。现在你也应该能让 make grade 在 testbss 测试中成功了。

按照手册,我们先注册 syscall中断,在inc/trap.h 中,已经有了 T_SYSCALL 的定义了。现在我们要做的是:

  1. 在 kern/trap_init.c 于IDT中创建入口
  2. 在 kern/trapentry.S 中完成创建syscall 的 handler
  3. 在 kern/trap.c : trap_dispatch() 中调用 syscall
// 第一步 kern/trap.c : trap_init
void trap_init(){
	//...
	void handler_syscall();
	//...
	SETGATE(idt[T_SYSCALL], 0, GD_KT, handler_syscall, 3);
	//...
	
// 第二步,kern/trapentry.S:
TRAPHANDLER_NOEC(handler_SYSCALL, T_SYSCALL)

// 第三步:在 kern/trap.c : trap_dispatch
//...
	case T_SYSCALL:
		tf->tf_regs.reg_eax = syscall(tf->tf_regs.reg_eax, 
			tf->tf_regs.reg_edx,
			tf->tf_regs.reg_ecx,
			tf->tf_regs.reg_ebx,
			tf->tf_regs.reg_edi,
			tf->tf_regs.reg_esi
		);
		return ;
//...

不过 syscall 我们还没实现,注意,我们现在要实现的是 kern/syscall.h 和 kern/syscall.c 中声明定义的syscall。而不是 user/hello 中调用的那个 lib/syscall.c

kern/syscall.c : syscall

// Dispatches to the correct kernel function, passing the arguments.
int32_t
syscall(uint32_t syscallno, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
	// Call the function corresponding to the 'syscallno' parameter.
	// Return any appropriate return value.
	// LAB 3: Your code here.

	// panic("syscall not implemented");
	int32_t ret;
	switch (syscallno) {
		case SYS_cputs:
			sys_cputs((char *)a1, (size_t)a2);
			ret = 0;
			break;
		case SYS_cgetc:
			ret = sys_cgetc();
			break;
		case SYS_getenvid:
			ret = sys_getenvid();
			break;
		case SYS_env_destroy:
			ret = sys_env_destroy((envid_t)a1);
			break;

	default:
		return -E_INVAL;
	}
}

运行 make run-hello

image.png

可以看到,syscall被成功调用了,然后发生了缺页故障,显示用户访问了虚拟地址 0x0000_0048。这是为什么呢?
在 uamin 对cprintf的第二次调用中,访问了 thisenv->envid ,看起来载jos设计下,用户进程有能力访问自身的env结构体,可能是访问这个结构体出错了,那这个结构体变量究竟在哪里声明的呢,有是怎么赋值的呢?
实际上,在 user/helloc 的 umain 之前,还运行了别的代码(umain,并不是hello.c编译链接后形成的elf文件的入口),接着看手册。


用户态入门

一段用户程序在 lib/entry.S 的顶部开始运行。
经过一些设置后,这段代码会调用 lib/libmain.c 中的 libmain()
应修改 libmain() 以初始化全局指针 thisenv,使其指向 envs[] 数组中的环境结构 Env。(请注意,lib/entry.S 已将 envs 定义为指向您在 A 部分中设置的 UENVS 映射)。提示:在 inc/env.h 中查找并使用 sys_getenvid

先来看一看 lib/entry.S

lib/entry.S

#include <inc/mmu.h>
#include <inc/memlayout.h>

.data
// 定义全局符号 “envs”、“pages”、“uvpt ”和 "uvpd
// 这样,在 C 语言中就可以像使用普通全局数组一样使用它们。
	.globl envs
	.set envs, UENVS
	.globl pages
	.set pages, UPAGES
	.globl uvpt
	.set uvpt, UVPT
	.globl uvpd
	.set uvpd, (UVPT+(UVPT>>12)*4)


// 入口点 - 当我们最初加载到一个新环境时,
// 内核(或我们的父环境)会在这里启动我们的运行。
.text
.globl _start
_start:
	// 查看堆栈中的参数是否已启动
	cmpl $USTACKTOP, %esp
	jne args_exist

	// 如果没有,则推送假 argc/argv 参数。.
	// 当我们被内核加载时,就会发生这种情况、
	// 因为内核不知道要传递参数。
	pushl $0
	pushl $0

args_exist:
	call libmain
1:	jmp 1b

然后来做练习8

Exercise 8

练习 8. 在用户库中添加所需的代码,然后启动内核。
你应该会看到user/hello打印"hello world"然后打印"i am environment 0001000".
然后,user/hello 会调用 sys_env_destroy()(请参阅 lib/libmain.clib/exit.c)尝试 "退出"。
由于内核目前只支持一个用户环境,因此它应该报告已经破坏了唯一的环境,然后进入内核监视器。你应该能让 make grade 在 hello 测试中取得成功。

lib/libmain.c

// Called from entry.S to get us going.
// entry.S 已经定义了 envs、pages、uvpd 和 uvpt。

#include <inc/lib.h>

extern void umain(int argc, char **argv);

const volatile struct Env *thisenv;
const char *binaryname = "<unknown>";

void
libmain(int argc, char **argv)
{
	// 设置 thisenv 以指向 envs[] 中的 Env 结构。
	// LAB 3: Your code here.
	envid_t envid = sys_getenvid(); //练习7实现的系统调用
	thisenv = &envs[ENVX(envid)];

	// save the name of the program so that panic() can use it
	if (argc > 0)
		binaryname = argv[0];

	// call user main routine
	umain(argc, argv);

	// exit gracefully
	exit();
}

这个时候试试 make qemu,用户进程应该是可以顺利执行了
image.png

总结:syscall流程

现在可以看清整个异常处理的全貌了
image.png

做到这里,有一个疑惑,上图红色箭头,发生中断的时候,CPU切换到TSS记录的权限为0的栈。这个栈的位置是在 trap_init -> trap_init_percpu 时确定的

	ts.ts_esp0 = KSTACKTOP;
	ts.ts_ss0 = GD_KD;
	ts.ts_iomb = sizeof(struct Taskstate);

也就是说,这会使得esp指向KSTACK的栈底,这样不会覆盖之前的数据吗?
可以通过DEBUG看一下

调试:内核栈被清空了吗

开两个窗口,一个 make run-hello-gdb 另一个 make gdb
然后在gdb窗口里给 env_run 打上断点 b env_run ,然后运行 c
程序在在用户进程开始运行之前断下,我们看一眼 KSTACKTOP 之后32个双字的数据 x/32wx 0xf0000000-0x70
image.png
可以看到,内核栈中确实是有一定数据的,然后我们再打一个断点,打在 handler_SYSCALL,
b handler_SYSCALL
这里是中断发生后,操作系统第一时间获得控制权的地方,之前是CPU的操作(上面流程图的红色字体部分描述的),让程序继续运行
c
先看一眼 esp ,如果没猜错的话,应该在距离 0xf000_0000 很近的较低处
p $esp
image.png

确实如此,接下来再看看 内核栈中的内容x/32wx 0xf0000000-0x70

对比一下env_run时 打印的
image.png
可以看到,真的是直接覆盖了内核栈。。。然后覆盖了5个双字,这五个双字应该就是中断时,CPU自动执行的操作,即
image.png
也就是说,现在的内核栈的情况如下

内核栈地址 内容 对应含义
0xEFFF_FFFC 0x000_0023 old SS
0xEFFF_FFF8 0xEEBF_DFD4 old ESP
0xEFFF_FFF4 0x0000_0046 old eflags
0xEFFF_FFF0 0x0000_001b old CS
0xEFFF_FFEC 0x0080_0AB0 old EIP
0xEFFF_FFE8 xxxxxx 内核栈原数据

说明内核栈中的数据确实被覆盖了,在往下看看呢。
接下来handler_SYSCALL 将更多的参数压入内核栈,形成了一个trapframe,然后作为参数调用 trap。然后我们继续调试 trap
b trap
image.png
如上图,然而这里的整个过程都在覆盖中断前的内核栈
image.png
内核栈已经面目全非了。那么疑惑解开,内核栈相当于在中断时就会被清空,好家伙。

不过都到这了,继续调试看看吧,我还比较好奇内核如何将权限归还给用户进程,ring0 怎么变成 ring3

调试:返回用户进程

好,看看返回给用户进程是怎么做的,直接 c,因为之前在 env_run 下断点了,直接停在env_run,然后步进到env_pop_tf之前
image.png
继续 si步进
image.png
可以看到进入内联汇编后,esp甚至都跑到 envs 数组里了(curenv的trapframe),然后后面一通pop,将各个寄存器还原成中断前的状态。
然后最后一句关键的iret,实现内核态到用户态的跨级执行权转移,来看看iret前后的变化:
image.png
可以看到,这句iret,不是想象中改变 CS和IP那么简单,连着esp和SS都变了,这是跨级执行权转移,iret 从栈中弹出数据,还原cs、eip、ss、esp等,我们再调试看看iret前后栈的变化:
image.png
上图是 iret 之前,可以看到 esp 正好指向 curenv->env_tf.tf_eip,iret将这些寄存器还原,从而实现跨级权限转移,将控制权还给用户进程。

页面故障和内存保护

到目前为止,我们顺利的让 user/hello.c编译出的elf 加载至我们的操作系统,并且运行起来。但为了让user目录下其他的代码也运行起来,还需要对内存进行保护。
接下来按照 手册的指引,完善内存保护措施,并完成练习9.

Exercise 9

任务内容:

1. 修改`kern/trap.c`,当页错误发生在内核态时panic。

~~~ad-note
检查`tf_cs`的低位字节可以判断fault发生在**用户态**还是**内核态**
~~~

2. 读`kern/pmap.c` 中的 `user_mem_assert` 并实现 `user_mem_check`

3. 修改 `kern/syscall.c` 对系统调用的参数进行正确性检查

4. 启动你的kernel,运行 `user/buggyhello`。 environment应当被销毁, kernel 不应当 'panic'。你应该会看到:

~~~txt
[00001000] user_mem_check assertion failure for va 00000001
[00001000] free env 00001000
Destroyed the only environment - nothing more to do!
~~~

5. 最终,修改 `kern/kdebug.c` 中的 `debuginfo_eip`,在 `usd, stabs, stabstr` 上调用 'user_mem_check'。如果你现在运行 `user/breakpoint` ,你应能够从 kernel monitor 运行 `backtrace`,并看见backtrace 在kernel panics 之前,随着一个page fault回溯到 `lib/libmain.c` 。
	是什么导致了page fualt?
	你不需要修复他,但是你要明白它为何发生。


在 kern/trap.c : trap() 中添加

//当页错误发生在内核态时panic
	if ((tf->tf_cs & 3) == 0) 
		panic("page_fault_handler():page fault in kernel\n");

然后第二步,完成 kern/pmap.c: user_mem_check

user_mem_check

先看看 user_mem_assert 是怎么用 user_mem_check 的:
image.png
然后实现 user_mem_check

  1. 检查 va 开始之后大小为 len 的内存空间范围,确认其权限是否为 perm
  2. 除此之外的限制:
    1. 低于 ULIM
    2. 该页具备权限
  3. 如果发生错误,则将 user_mem_check_addr 设置为第一个有问题的页
  4. 如果没有问题,则返回 0, 否则返回 -E_FAULT
// 检查环境是否允许以权限'perm | PTE_P'访问内存范围 [va,va+len]。
// 通常'perm'至少包含 PTE_U,但这不是必需的。
// 'va'和'len'不必进行页面对齐;您必须测试包含该范围内任何内容的每一页。 您可以测试'len/PGSIZE'、
// 'len/PGSIZE + 1' 或 'len/PGSIZE + 2' 页面。
//
// 如果 (1) 地址低于 ULIM,并且 (2) 页表允许,用户程序可以访问虚拟地址。 这些正是你应该在这里实现的测试。
//
// 如果出现错误,将 “user_mem_check_addr ”变量设置为第一个错误的虚拟地址。
//
// 如果用户程序可以访问该地址范围,则返回 0,否则返回 -E_FAULT。
//
int
user_mem_check(struct Env *env, const void *va, size_t len, int perm)
{
	// LAB 3: Your code here.
	cprintf("user_mem_check va: %x, len: %x\n", va, len);
	pde_t * pgdir = env->env_pgdir;
	uint32_t begin = (uint32_t)ROUNDDOWN(va, PGSIZE);//虽然说不必进行页面对齐,但是对齐起来代码写的更方便
	uint32_t end = (uint32_t)ROUNDUP(va+len, PGSIZE);
	for(uint32_t i = begin; i < end; i+= PGSIZE){
		pte_t * pte = pgdir_walk(env->env_pgdir, (void*)i, 0);
		if(	(i>=ULIM)//检查是否
			||!pte
			||!(*pte&PTE_P)||((*pte&perm) != perm)
		){
			user_mem_check_addr = (i < (uint32_t)va ? (uint32_t)va : i); //由于对齐了,第一页的地址值可能在va之前。
			return -E_FAULT;
		}
	}
	cprintf("user_mem_check success va: %x, len: %x\n", va, len);
	return 0;
}

接着,练习的第三步,观察 kern/syscall.c 中的所有系统调用,看看那个接收了来自用户进程的指针。
其实就只有 sys_cputs,补充让 user_mem_assert:

static void
sys_cputs(const char *s, size_t len)
{
	// Check that the user has permission to read memory [s, s+len).
	// Destroy the environment if not.

	// LAB 3: Your code here.
	user_mem_assert(curenv, (void *)s, len, 0);
	// Print the string supplied by the user.
	cprintf("%.*s", len, s);
}

第四步 修改kern/kdebug.c : debuginfo_eip
根据注释,添加代码即可

		const struct UserStabData *usd = (const struct UserStabData *) USTABDATA;

		// Make sure this memory is valid.
		// Return -1 if it is not.  Hint: Call user_mem_check.
		// LAB 3: Your code here.
		if (user_mem_check(curenv, usd, sizeof(struct UserStabData), PTE_U) < 0) {
  			return -1;
}
		stabs = usd->stabs;
		stab_end = usd->stab_end;
		stabstr = usd->stabstr;
		stabstr_end = usd->stabstr_end;

		// Make sure the STABS and string table memory is valid.
		// LAB 3: Your code here.
		size_t stablen = stab_end - stabs + 1;
		size_t strlen = stabstr_end - stabstr + 1;
		if (user_mem_check(curenv, stabs, stablen, PTE_U) < 0) {
			return -1;
		}
		if (user_mem_check(curenv, stabstr, strlen, PTE_U) < 0) {
			return -1;
		}

到目前为止,所有的练习都完成了。最后 make grade

image.png

标签:mit6.828,调用,syscall,trap,lab3,user,tf,断点,内核
From: https://www.cnblogs.com/toso/p/18194347

相关文章

  • 关于学成在线项目如何处理断点续传
    我是基于分块上传的模式实现断点续传的需求,当文件上传一部分断网后前边上传过的不在上传。具体逻辑流程如下前端对文件进行分块处理前端开个多线程一块一块上传,上传前服务端发个消息检验该分块是否上传,如果在文件系统OSS/minio存在,则不在上传。等所有分块上传完毕,服务......
  • Springboot+React实现Minio文件分片上传、断点续传
    前言本文采用前后端结合,后端给前端每个分片的上传临时凭证,前端请求临时url,通过后端间接的去上传分片。其实无关乎vue或者react,思路都是一样的,逻辑也全都是js写的,跟模板语法或者jsx也没关系,仅仅是赋值不一样而已。前端:React+TypeScript+Antd+axios+spark-md5+p-......
  • mit6.828 - lab2笔记
    目标:重点学习内存管理的相关知识,包括内存布局、页表结构、页映射任务:完成内存管理的相关代码lab2中,完全可以跟着实验手册的节奏走,逐步完善内存管理的代码。环境准备:实验2包含以下新的源文件:inc/memlayout.hkern/pmap.ckern/pmap.hkern/kclock.hkern/kclock.cmemlay......
  • mit6.828 - lab1笔记
    安装环境编译qemu1.PC启动打开两个窗口,在第一个窗口中makeqemu-gdb,会启动内核,但在执行第一个指令之前停下;在第二个窗口中makegdb,实时观察第一个窗口中的执行过程。从这里可以观察到:IBMPC在物理地址0x000ffff0开始执行,位于为ROMBIOS保留的64KB区域的最顶部。......
  • Go语言实现多协程文件上传,断点续传--demo
    packagemainimport("fmt""io""os""regexp""strconv""sync""github.com/qianlnk/pgbar")/***需求:1.多协程下载文件2.断点续连**/funcmain(){//获取要下载文件DownloadFileName:=&quo......
  • 【问题处理】蓝鲸监控-数据断点解决
    本文来自腾讯蓝鲸智云社区用户:fadewalk在问答社区看到有小伙伴在落地蓝鲸的过程中出现监控平台的grafana面板数据断点问题,往往出现这种问题,都比较的头疼。如果将CMDB(配置管理数据库)比作运维的基石,那么监控可以比作运维的"眼睛"或"感知器"。监控在运维中起着至关重要的作用,类似......
  • 第八节 函数的连续性与间断点
    第八节函数的连续性与间断点一、函数的连续性连续的定义定义1:设函数\(y=f(x)\)在点\(x₀\)的某一邻域内有定义,如果:\(\qquad\qquad\Large\underset{\trianglex\rightarrow0}{\lim}\triangley=\underset{\trianglex\rightarrow0}{\lim}[f(x_0+\trianglex)-f(x_0......
  • 1. 大文件上传如何断点续传
    大文件上传流程文件分片-将文件分割成多个小块,以便于上传和管理。计算文件以及分片文件的Hash值-生成唯一标识符-通过计算文件及其分片的Hash值来创建一个唯一的标识符。上传分片-根据标识符判断分片文件上传状态-避免重复上传。如果上传......
  • ARM DS-5 断点设置及常用Debug 命令
    1.1DS-5Debug方法梳理通常在调试过程中需要打断点来进行单步调试,这个时候可以按照下面步骤来进行:在使用DS-5Debug之前需要先load所编译的elf文件: 设置好路径:1.2.1DS-5设置断点Debug在上面完成elf文件的load及路径设置后,我们就可以使用DS-5进行设置断......
  • 【SpringBoot整合系列】SpringBoot 实现大文件分片上传、断点续传及秒传
    目录功能介绍文件上传分片上传秒传断点续传相关概念相关方法大文件上传流程前端切片处理逻辑后端处理切片的逻辑流程解析后端代码实现功能目标1.建表SQL2.引入依赖3.实体类4.响应模板5.枚举类6.自定义异常7.工具类8.Controller层9.FileService10.LocalStorageService11......