ucore lab1
来源:互联网 发布:沙盘软件多开器 编辑:程序博客网 时间:2024/06/06 00:54
[练习1]
[练习1.1] 操作系统镜像文件 ucore.img 是如何一步一步生成的?(需要比较详细地解释 Makefile 中每一条相关命令和命令参数的含义,以及说明命令导致的结果)
输入make V= (对每条命令进行了精简)
+ cc kern/init/init.c //编译init.c gcc -c kern/init/init.c -o obj/kern/init/init.o+ cc kern/libs/readline.c //编译readline.c gcc -c kern/libs/readline.c -o obj/kern/libs/readline.o+ cc kern/libs/stdio.c //编译stdlio.c gcc -c kern/libs/stdio.c -o obj/kern/libs/stdio.o+ cc kern/debug/kdebug.c //编译kdebug.c gcc -c kern/debug/kdebug.c -o obj/kern/debug/kdebug.o+ cc kern/debug/kmonitor.c //编译komnitor.c gcc -c kern/debug/kmonitor.c -o obj/kern/debug/kmonitor.o+ cc kern/debug/panic.c //编译panic.c gcc -c kern/debug/panic.c -o obj/kern/debug/panic.o+ cc kern/driver/clock.c //编译clock.c gcc -c kern/driver/clock.c -o obj/kern/driver/clock.o+ cc kern/driver/console.c //编译console.c gcc -c kern/driver/console.c -o obj/kern/driver/console.o+ cc kern/driver/intr.c //编译intr.c gcc -c kern/driver/intr.c -o obj/kern/driver/intr.o+ cc kern/driver/picirq.c //编译prcirq.c gcc -c kern/driver/picirq.c -o obj/kern/driver/picirq.o+ cc kern/trap/trap.c //编译trap.c gcc -c kern/trap/trap.c -o obj/kern/trap/trap.o+ cc kern/trap/trapentry.S //编译trapentry.S gcc -c kern/trap/trapentry.S -o obj/kern/trap/trapentry.o+ cc kern/trap/vectors.S //编译vectors.S gcc -c kern/trap/vectors.S -o obj/kern/trap/vectors.o+ cc kern/mm/pmm.c //编译pmm.c gcc -c kern/mm/pmm.c -o obj/kern/mm/pmm.o+ cc libs/printfmt.c //编译printfmt.c gcc -c libs/printfmt.c -o obj/libs/printfmt.o+ cc libs/string.c //编译string.c gcc -c libs/string.c -o obj/libs/string.o+ ld bin/kernel //链接成kernel ld -o bin/kernel obj/kern/init/init.o obj/kern/libs/readline.o obj/kern/libs/stdio.o obj/kern/debug/kdebug.o obj/kern/debug/kmonitor.o obj/kern/debug/panic.o obj/kern/driver/clock.o obj/kern/driver/console.o obj/kern/driver/intr.o obj/kern/driver/picirq.o obj/kern/trap/trap.o obj/kern/trap/trapentry.o obj/kern/trap/vectors.o obj/kern/mm/pmm.o obj/libs/printfmt.o obj/libs/string.o+ cc boot/bootasm.S //编译bootasm.c gcc -c boot/bootasm.S -o obj/boot/bootasm.o+ cc boot/bootmain.c //编译bootmain.c gcc -c boot/bootmain.c -o obj/boot/bootmain.o+ cc tools/sign.c //编译sign.c gcc -c tools/sign.c -o obj/sign/tools/sign.o gcc -O2 obj/sign/tools/sign.o -o bin/sign+ ld bin/bootblock //根据sign规范生成bootblock ld -m elf_i386 -nostdlib -N -e start -Ttext 0x7C00 obj/boot/bootasm.o obj/boot/bootmain.o -o obj/bootblock.o //创建大小为10000个块的ucore.img,初始化为0,每个块为512字节dd if=/dev/zero of=bin/ucore.img count=10000 //把bootblock中的内容写到第一个块dd if=bin/bootblock of=bin/ucore.img conv=notrunc //从第二个块开始写kernel中的内容dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc
从上面并没有看到:根据sign规范生成bootblock的命令
查看makefile文件找到:
@$(call totarget,sign) $(call outfile,bootblock) $(bootblock)
所以从上面可以看出ucore.img的生成过程:
1 编译所有生成bin/kernel所需的文件2 链接生成bin/kernel3 编译bootasm.S bootmain.c sign.c 4 根据sign规范生成obj/bootblock.o5 生成ucore.img
[练习1.2] 一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
截取sign.c文件中的部分源码
char buf[512]; //定义buf数组 memset(buf, 0, sizeof(buf)); // 把buf数组的最后两位置为 0x55, 0xAA buf[510] = 0x55; buf[511] = 0xAA; FILE *ofp = fopen(argv[2], "wb+"); size = fwrite(buf, 1, 512, ofp); if (size != 512) { //大小为512字节 fprintf(stderr, "write '%s' error, size is %d.\n", argv[2], size); return -1; }
主引导扇区的规则如下:
1 大小为512字节2 多余的空间填03 第510个(倒数第二个)字节是0x55,4 第511个(倒数第一个)字节是0xAA。
[练习2]
[练习2.1] 从 CPU 加电后执行的第一条指令开始,单步跟踪 BIOS 的执行。
修改lab1/tools/gdbinit ,内容为:
set architecture i8086target remote :1234
然后在 lab1执行:
make debug
在gdb的调试界面,执行如下命令:
si
来单步跟踪
在gdb的调试界面,执行如下命令,来查看BIOS代码:
x /2i $pc //显示当前eip处的汇编指令
得到下面的截图:
[练习2.2] 在初始化位置0x7c00 设置实地址断点,测试断点正常
修改 gdbinit文件:
set architecture i8086target remote :1234b *0x7c00cx/2i $pc
得到如下结果,断点正常
[练习2.3] 从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和 bootblock.asm进行比较。
改写 makefile文件:
debug: $(UCOREIMG) $(V)$(TERMINAL) -e "$(QEMU) -S -s -d in_asm -D $(BINDIR)/q.log -parallel stdio -hda $< -serial null" $(V)sleep 2 $(V)$(TERMINAL) -e "gdb -q -tui -x tools/gdbinit"
然后再执行
make debug
得到q.log文件:
查看bootasm.S文件:
查看 bootblock.asm文件:
从上面的结果可以看到:
bootasm.S文件中的代码和bootblock.asm是一样的,对于q.log文件,断点之后的代码和bootasm.S,bootblock.asm是一样的。
[练习2.4] 自己找一个bootloader或内核中的代码位置,设置断点并进行测试。
修改gdbinit文件,在0x7c4a处设置断点 (调用bootmain函数处)
set architecture i8086target remote :1234break *0x7c4a
输入 make debug ,得到结果:
断点设置正常
练习3:分析bootloader进入保护模式的过程。
1 关中断和清除数据段寄存器
.globl startstart:.code16 cli //关中断 cld //清除方向标志 xorw %ax, %ax //ax清0 movw %ax, %ds //ds清0 movw %ax, %es //es清0 movw %ax, %ss //ss清0
[练习3.1] 为何开启A20,以及如何开启A20?
初始时A20为0,访问超过1MB的地址时,就会从0循环计数,将A20地址线置为1之后,才可以访问4G内存。A20地址位由8042控制,8042有2个有两个I/O端口:0x60和0x64。
打开流程:
- 等待8042 Input buffer为空;
- 发送Write 8042 Output Port (P2)命令到8042 Input buffer;
- 等待8042 Input buffer为空;
- 将8042 Output Port(P2)得到字节的第2位置1,然后写入8042 Input buffer;
seta20.1: //等待8042键盘控制器不忙 inb $0x64, %al //从0x64端口中读入一个字节到al中 testb $0x2, %al //测试al的第2位 jnz seta20.1 //al的第2位为0,则跳出循环 movb $0xd1, %al //将0xd1写入al中 outb %al, $0x64 //将0xd1写入到0x64端口中 seta20.2: //等待8042键盘控制器不忙 inb $0x64, %al //从0x64端口中读入一个字节到al中 testb $0x2, %al //测试al的第2位 jnz seta20.2 //al的第2位为0,则跳出循环 movb $0xdf, %al //将0xdf入al中 outb %al, $0x60 //将0xdf入到0x64端口中,打开A20
[练习3.2] 如何初始化GDT表?
1 载入GDT表
lgdt gdtdesc //载入GDT表
2 进入保护模式:
通过将cr0寄存器PE位置1便开启了保护模式
cro的第0位为1表示处于保护模式
movl %cr0, %eax //加载cro到eaxorl $CR0_PE_ON, %eax //将eax的第0位置为1movl %eax, %cr0 //将cr0的第0位置为1
3 通过长跳转更新cs的基地址:
上面已经打开了保护模式,所以这里需要用到逻辑地址。$PROT_MODE_CSEG的值为0x80
ljmp $PROT_MODE_CSEG, $protcseg.code32 protcseg:
4 设置段寄存器,并建立堆栈
movw $PROT_MODE_DSEG, %ax // movw %ax, %ds movw %ax, %es movw %ax, %fs movw %ax, %gs movw %ax, %ss movl $0x0, %ebp //设置帧指针 movl $start, %esp //设置栈指针
5 转到保护模式完成,进入boot主方法
call bootmain //调用bootmain函数
[练习3.3] 如何使能和进入保护模式
将cr0寄存器置1
[练习4] 分析bootloader加载ELF格式的OS的过程。
[练习4.1] bootloader如何读取硬盘扇区的?
读取扇区硬盘的代码:
bootloader让CPU进入保护模式后,下一步的工作就是从硬盘上加载并运行OS。考虑到实现的简单性,bootloader的访问硬盘都是LBA模式的PIO(Program IO)方式,即所有的IO操作是通过CPU访问硬盘的IO地址寄存器完成。
在上一个联系中我们的BootLoader已经成功的进入了保护模式,接下来我们要做的就是从硬盘读取并运行我们的OS。对于硬盘来说,我们知道是分成许多扇区的其中每个扇区的大小为512字节。读取扇区的流程我们通过查询指导书可以看到:
1、等待磁盘准备好;
2、发出读取扇区的命令;
3、等待磁盘准备好;
4、把磁盘扇区数据读到指定内存。
接下来我们需要了解下如何具体的从硬盘读取数据,因为我们所要读取的操作系统文件是存在0号硬盘上的,所以,我们来看一下关于0号硬盘的I/O端口:
static voidwaitdisk(void) { //如果0x1F7的最高2位是01,跳出循环 while ((inb(0x1F7) & 0xC0) != 0x40) /* do nothing */;}/* readsect - read a single sector at @secno into @dst */static voidreadsect(void *dst, uint32_t secno) { // wait for disk to be ready waitdisk(); outb(0x1F2, 1); //读取一个扇区 outb(0x1F3, secno & 0xFF); //要读取的扇区编号 outb(0x1F4, (secno >> 8)&0xFF);//用来存放读写柱面的低8位字节 outb(0x1F5, (secno >> 16)&0xFF);//用来存放读写柱面的高2位字节 // 用来存放要读/写的磁盘号及磁头号 outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0); outb(0x1F7, 0x20); // cmd 0x20 - read sectors // wait for disk to be ready waitdisk(); // read a sector insl(0x1F0, dst, SECTSIZE / 4); //获取数据}
一般主板有2个IDE通道,每个通道可以接2个IDE硬盘。访问第一个硬盘的扇区可设置IO地址寄存器0x1f0-0x1f7实现的,具体参数见下表。一般第一个IDE通道通过访问IO地址0x1f0-0x1f7来实现,第二个IDE通道通过访问0x170-0x17f实现。每个通道的主从盘的选择通过第6个IO偏移地址寄存器来设置。从outb()
可以看出这里是用LBA模式的PIO(Program IO)方式来访问硬盘的。从磁盘IO地址和对应功能表
可以看出,该函数一次只读取一个扇区。
readseg简单包装了readsect,可以从设备读取任意长度的内容。
static void readseg(uintptr_t va, uint32_t count, uint32_t offset) { uintptr_t end_va = va + count; va -= offset % SECTSIZE; uint32_t secno = (offset / SECTSIZE) + 1; // 加1因为0扇区被引导占用 // ELF文件从1扇区开始 for (; va < end_va; va += SECTSIZE, secno ++) { readsect((void *)va, secno); } }
[练习4.2] bootloader是如何加载ELF格式的OS?
ELF定义:
/* file header */struct elfhdr { uint32_t e_magic; // must equal ELF_MAGIC uint8_t e_elf[12]; uint16_t e_type; // 1=relocatable, 2=executable, 3=shared object, 4=core image uint16_t e_machine; // 3=x86, 4=68K, etc. uint32_t e_version; // file version, always 1 uint32_t e_entry; // entry point if executable uint32_t e_phoff; // file position of program header or 0 uint32_t e_shoff; // file position of section header or 0 uint32_t e_flags; // architecture-specific flags, usually 0 uint16_t e_ehsize; // size of this elf header uint16_t e_phentsize; // size of an entry in program header uint16_t e_phnum; // number of entries in program header or 0 uint16_t e_shentsize; // size of an entry in section header uint16_t e_shnum; // number of entries in section header or 0 uint16_t e_shstrndx; // section number that contains section name strings};
在这里我们只需要关注其中的几个参数,e_magic,是用来判断读出来的ELF格式的文件是否为正确的格式;e_phoff,是program header表的位置偏移;e_phnum,是program header表中的入口数目;e_entry,是程序入口所对应的虚拟地址。
宏定义:
#define ELFHDR ((struct elfhdr *)0x10000) #define SECTSIZE 512
在bootmain函数中,
void bootmain(void) { // 首先读取ELF的头部 readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0); // 通过储存在头部的幻数判断是否是合法的ELF文件 if (ELFHDR->e_magic != ELF_MAGIC) { goto bad; } struct proghdr *ph, *eph; // ELF头部有描述ELF文件应加载到内存什么位置的描述表, // 先将描述表的头地址存在ph ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff); eph = ph + ELFHDR->e_phnum; // 按照描述表将ELF文件中数据载入内存 for (; ph < eph; ph ++) { readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset); } // ELF文件0x1000位置后面的0xd1ec比特被载入内存0x00100000 // ELF文件0xf000位置后面的0x1d20比特被载入内存0x0010e000 // 根据ELF头部储存的入口信息,找到内核的入口 ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))(); bad: outw(0x8A00, 0x8A00); outw(0x8A00, 0x8E00); while (1); }
总结一下就是:
- 从硬盘读了8个扇区数据到内存
0x10000
处,并把这里强制转换成elfhdr
使用; - 校验
e_magic
字段; - 根据偏移量分别把程序段的数据读取到内存中。
[练习5]:实现函数调用堆栈跟踪函数
首先,可以通过read_ebp()
和read_eip()
函数来获取当前ebp寄存器和eip 寄存器的信息。
然后通过ebp+12,ebp+16,ebp+20,ebp+24来输出4个参数的值,最后更新ebp:ebp=ebp[0],更新eip:eip=ebp[1]。直到ebp 对应地址的值为0(表示当前函数为bootmain)。
read_eip()
函数定义在kdebug.c中:
static __noinline uint32_tread_eip(void) { uint32_t eip; asm volatile("movl 4(%%ebp), %0" : "=r" (eip)); //内联汇编,读取(ebp-4)的值到变量eip return eip; //返回eip的值}
read_ebp()
函数定义在x86.h中:
static inline uint32_tread_ebp(void) { uint32_t ebp; asm volatile ("movl %%ebp, %0" : "=r" (ebp)); //内联汇编,读取edp寄存器的值到变量ebp return ebp; //返回ebp的值}
实现函数如下:
voidprint_stackframe(void) { uint32_t ebp = read_ebp(), eip = read_eip(); //获取ebp和eip的值 int i, j; //#define STACKFRAME_DEPTH 20 for (i = 0; ebp != 0 && i < STACKFRAME_DEPTH; i ++) { cprintf("ebp:0x%08x eip:0x%08x args:", ebp, eip); uint32_t *args = (uint32_t *)ebp + 2; //参数的首地址 for (j = 0; j < 4; j ++) { cprintf("0x%08x ", args[j]); //打印4个参数 } cprintf("\n"); print_debuginfo(eip - 1); //打印函数信息 eip = ((uint32_t *)ebp)[1]; //更新eip ebp = ((uint32_t *)ebp)[0]; //更新ebp }}
执行 make qemu 得到输出:
最后一行的解释:
其对应的是第一个使用堆栈的函数,bootmain.c中的bootmain。(因为此时ebp对应地址的值为0)
bootloader设置的堆栈从0x7c00开始,使用”call bootmain”转入bootmain函数。
call指令压栈,所以bootmain中ebp为0x7bf8。
[练习6]完善中断初始化和处理
[练习6.1]中断向量表中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
一个表项的结构如下:
/*lab1/kern/mm/mmu.h*//* Gate descriptors for interrupts and traps */struct gatedesc { unsigned gd_off_15_0 : 16; // low 16 bits of offset in segment unsigned gd_ss : 16; // segment selector unsigned gd_args : 5; // # args, 0 for interrupt/trap gates unsigned gd_rsv1 : 3; // reserved(should be zero I guess) unsigned gd_type : 4; // type(STS_{TG,IG32,TG32}) unsigned gd_s : 1; // must be 0 (system) unsigned gd_dpl : 2; // descriptor(meaning new) privilege level unsigned gd_p : 1; // Present unsigned gd_off_31_16 : 16; // high bits of offset in segment};
中断处理过程:
可以看到,中断向量表一个表项占用8字节,其中2-3字节是段选择子,0-1字节和6-7字节拼成偏移量,
通过段选择子去GDT中找到对应的基地址,然后基地址加上偏移量就是中断处理程序的地址。
[练习6.2] 请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。
SETGATE函数的实现:
#define SETGATE(gate, istrap, sel, off, dpl) { \ (gate).gd_off_15_0 = (uint32_t)(off) & 0xffff; \ (gate).gd_ss = (sel); \ (gate).gd_args = 0; \ (gate).gd_rsv1 = 0; \ (gate).gd_type = (istrap) ? STS_TG32 : STS_IG32; \ (gate).gd_s = 0; \ (gate).gd_dpl = (dpl); \ (gate).gd_p = 1; \ (gate).gd_off_31_16 = (uint32_t)(off) >> 16; \}
宏定义和数组说明:
#define GD_KTEXT ((SEG_KTEXT) << 3) // kernel text#define DPL_KERNEL (0)#define DPL_USER (3)#define T_SWITCH_TOK 121 // user/kernel switchstatic struct gatedesc idt[256] = {{0}};
idt_init函数的实现:
voididt_init(void) { extern uintptr_t __vectors[]; //保存在vectors.S中的256个中断处理例程的入口地址数组 int i; //使用SETGATE宏,对中断描述符表中的每一个表项进行设置 for (i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i ++) { //IDT表项的个数 //在中断门描述符表中通过建立中断门描述符,其中存储了中断处理例程的代码段GD_KTEXT和偏移量__vectors[i],特权级为DPL_KERNEL。这样通过查询idt[i]就可定位到中断服务例程的起始地址。 SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL); } SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER); //建立好中断门描述符表后,通过指令lidt把中断门描述符表的起始地址装入IDTR寄存器中,从而完成中段描述符表的初始化工作。 lidt(&idt_pd);}
[练习6.3]请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数
首先加入 string.h头文件,为了使用memmove函数
void *memmove(void *dst, const void *src, size_t n);
定义变量:
struct trapframe switchk2u, *switchu2k;
结构体 trapframe
struct trapframe { struct pushregs tf_regs; uint16_t tf_gs; uint16_t tf_padding0; uint16_t tf_fs; uint16_t tf_padding1; uint16_t tf_es; uint16_t tf_padding2; uint16_t tf_ds; uint16_t tf_padding3; uint32_t tf_trapno; /* below here defined by x86 hardware */ uint32_t tf_err; uintptr_t tf_eip; uint16_t tf_cs; uint16_t tf_padding4; uint32_t tf_eflags; /* below here only when crossing rings, such as from user to kernel */ uintptr_t tf_esp; uint16_t tf_ss; uint16_t tf_padding5;} __attribute__((packed));
宏定义:
#define IRQ_OFFSET 32 #define IRQ_TIMER 0#define IRQ_KBD 1#define IRQ_COM1 4#define T_SWITCH_TOU 120#define USER_CS ((GD_UTEXT) | DPL_USER)#define USER_DS ((GD_UDATA) | DPL_USER)#define KERNEL_DS ((GD_KDATA) | DPL_KERNEL)#define TICK_NUM 100
print_ticks函数
static void print_ticks() { cprintf("%d ticks\n",TICK_NUM);#ifdef DEBUG_GRADE cprintf("End of Test.\n"); panic("EOT: kernel seems ok.");#endif}
trap_dispatch函数的实现:
static voidtrap_dispatch(struct trapframe *tf) { char c; switch (tf->tf_trapno) { case IRQ_OFFSET + IRQ_TIMER: ticks ++; if (ticks % TICK_NUM == 0) { print_ticks(); } break; //下面的代码不用我们实现 case IRQ_OFFSET + IRQ_COM1: c = cons_getc(); cprintf("serial [%03d] %c\n", c, c); break; case IRQ_OFFSET + IRQ_KBD: c = cons_getc(); cprintf("kbd [%03d] %c\n", c, c); break; case T_SWITCH_TOU: if (tf->tf_cs != USER_CS) { switchk2u = *tf; switchk2u.tf_cs = USER_CS; switchk2u.tf_ds = switchk2u.tf_es = switchk2u.tf_ss = USER_DS; switchk2u.tf_esp = (uint32_t)tf + sizeof(struct trapframe) - 8; switchk2u.tf_eflags |= FL_IOPL_MASK; *((uint32_t *)tf - 1) = (uint32_t)&switchk2u; } break; case T_SWITCH_TOK: if (tf->tf_cs != KERNEL_CS) { tf->tf_cs = KERNEL_CS; tf->tf_ds = tf->tf_es = KERNEL_DS; tf->tf_eflags &= ~FL_IOPL_MASK; switchu2k = (struct trapframe *)(tf->tf_esp - (sizeof(struct trapframe) - 8)); memmove(switchu2k, tf, sizeof(struct trapframe) - 8); *((uint32_t *)tf - 1) = (uint32_t)switchu2k; } break; case IRQ_OFFSET + IRQ_IDE1: case IRQ_OFFSET + IRQ_IDE2: break; default: if ((tf->tf_cs & 3) == 0) { print_trapframe(tf); panic("unexpected trap in kernel.\n"); } }}
运行结果:
[练习7]
增加syscall功能,即增加一用户态函数(可执行一特定系统调用:获得时钟计数值),
当内核初始完毕后,可从内核态返回到用户态的函数,而用户态的函数又通过系统调用得到内核态的服务
[练习8]
用键盘实现用户模式内核模式切换。具体目标是:“键盘输入3时切换到用户模式,键盘输入0时切换到内核模式”。 基本思路
是借鉴软中断(syscall功能)的代码,并且把trap.c中软中断处理的设置语句拿过来。
- ucore lab1
- ucore lab1
- ucore-Lab1
- ucore lab1
- ucore lab1 任务二
- ucore lab1 任务三
- ucore lab1 任务四
- ucore lab1 任务五
- ucore lab1 任务六
- ucore lab1 任务七
- 操作系统学习 ucore lab1
- ucore-lab1实验报告
- 操作系统实验报告:ucore-lab1
- 操作系统ucore lab1实验报告
- # 操作系统实验报告:ucore-lab1
- 操作系统ucore lab1实验报告
- 操作系统ucore lab1实验报告
- ucore操作系统lab1实验准备知识
- ML->分类
- Perl之单行命令特技
- Python入门——字典
- java学习笔记(六)IO
- 购物车
- ucore lab1
- c++类实现二叉树的基本操作a
- Android O :清除应用数据
- odoo10 report添加自定义CSS
- linux下创建、删除文件夹
- 动态规划
- Python之函数
- @ResponseBody的作用
- 高亮显示