操作系统lab1实验报告

[练习1]

理解通过 make 生成执行文件的过程。(要求在报告中写出对下述问题的回答)
在此练习中,大家需要通过阅读代码来了解:
1. 操作系统镜像文件 ucore.img 是如何一步一步生成的?(需要比较详细地解释 Makefile 中
每一条相关命令和命令参数的含义,以及说明命令导致的结果)
2. 一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?

[练习1.1]

1、生成ucore.img需要kernelbootblock

生成ucore.img的代码如下:

$(UCOREIMG): $(kernel) $(bootblock)$(V)dd if=/dev/zero of=$@ count=10000$(V)dd if=$(bootblock) of=$@ conv=notrunc$(V)dd if=$(kernel) of=$@ seek=1 conv=notrunc$(call create_target,ucore.img)

首先先创建一个大小为10000字节的块儿,然后再将bootblock拷贝过去。
生成ucore.img需要先生成kernelbootblock

2、生成kernel

而生成kernel的代码如下:

$(kernel): tools/kernel.ld
$(kernel): $(KOBJS)@echo "bbbbbbbbbbbbbbbbbbbbbb$(KOBJS)"@echo + ld $@$(V)$(LD) $(LDFLAGS) -T tools/kernel.ld -o $@ $(KOBJS)@$(OBJDUMP) -S $@ > $(call asmfile,kernel)@$(OBJDUMP) -t $@ | $(SED) '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $(call symfile,kernel)

通过make V=指令得到执行的具体命令如下:

ld -m    elf_i386 -nostdlib -T tools/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

然后根据其中可以看到,要生成kernel,需要用GCC编译器将kern目录下所有的.c文件全部编译生成的.o文件的支持。具体如下:

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

3、生成bootblock

而生成bootblock的代码如下:

$(bootblock): $(call toobj,$(bootfiles)) | $(call totarget,sign) @echo "========================$(call toobj,$(bootfiles))"@echo + ld $@$(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 $^ -o $(call toobj,bootblock)@$(OBJDUMP) -S $(call objfile,bootblock) > $(call asmfile,bootblock)@$(OBJCOPY) -S -O binary $(call objfile,bootblock) $(call outfile,bootblock)@$(call totarget,sign) $(call outfile,bootblock) $(bootblock)

同样根据make V=指令打印的结果,得到要生成bootblock,首先需要生成bootasm.o、bootmain.o、sign
下列代码为生成bootasm.o、bootmain.o的代码,由宏定义批量实现了。

bootfiles = $(call listf_cc,boot)
$(foreach f,$(bootfiles),$(call cc_compile,$(f),$(CC),$(CFLAGS) -Os -nostdinc))

而实际的命令在make V=指令结果里可以看到。
下述是由bootasm.S生成bootasm.o的具体命令:

gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o

下述是由bootmain.c生成bootmain.o的具体命令

gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootmain.c -o obj/boot/bootmain.o

至于上述命令的具体参数,查阅资料罗列如下:
- -ggdb 生成可供gdb使用的调试信息
- -m32 生成适用于32位环境的代码
- -gstabs 生成stabs格式的调试信息
- -nostdinc 不使用标准库
- -fno-stack-protector 不生成用于检测缓冲区溢出的代码
- -Os 为减小代码大小而进行优化
- -I

添加搜索头文件的路径
- -fno-builtin 不进行builtin函数的优化


下列代码为生成sign的代码

$(call add_files_host,tools/sign.c,sign,sign)
$(call create_target_host,sign,sign)

下面是生成sign具体的命令:

gcc -Itools/ -g -Wall -O2 -c tools/sign.c -o obj/sign/tools/sign.o
gcc -g -Wall -O2 obj/sign/tools/sign.o -o bin/sign

有了上述的bootasm.o、bootmain.o、sign
接下来就可以生成bootblock了,实际命令如下:

ld -m    elf_i386 -nostdlib -N -e start -Ttext 0x7C00 obj/boot/bootasm.o obj/boot/bootmain.o -o obj/bootblock.o

参数解释如下:(不重复解释)
- -m 模拟为i386上的连接器
- -N 设置代码段和数据段均可读写
- -e 指定入口
- -Ttext 制定代码段开始位置


[练习1.2]

一个被系统认为是符合规范的硬盘主引导扇区的特征有以下几点:
- 磁盘主引导扇区只有512字节
- 磁盘最后两个字节为0x55AA
- 由不超过466字节的启动代码和不超过64字节的硬盘分区表加上两个字节的结束符组成

[练习2]

  1. 从 CPU加电后执行的第一条指令开始,单步跟踪 BIOS的执行。
  2. 在初始化位置 0x7c00 设置实地址断点,测试断点正常。
  3. 从 0x7c00 开始跟踪代码运行,将单步跟踪反汇编得到的代码与 bootasm.S和 bootblock.asm进行比较。
  4. 自己找一个 bootloader或内核中的代码位置,设置断点并进行测试

首先通过make qemu指令运行出等待调试的qemu虚拟机,然后再打开一个终端,通过下述命令连接到qemu虚拟机:

gdb
target remote 127.0.0.1:1234

进入到调试界面:

输入si命令单步调试,
这是另一个终端会打印下一条命令的地址和内容:


然后输入b*0x7c00在初始化位置地址0x7c00设置上断点,如下:


然后输入continue使之继续运行:

这时成功在0x7c00处停止运行,然后我们查看此处的反汇编代码,如下:

对比此时bootasm.S中的起始代码,发现确实是一样的

这里多次的单步调试就不在截图赘述了。

[练习3]

分析从bootloader进入保护模式的过程。BIOS 将通过读取硬盘主引导扇区到内存,并转跳到对应内存中的位置执行 bootloader。请分析bootloader是如何完成从实模式进入保护模式的

首先我们先分析一下bootloader

1、关闭中断,将各个段寄存器重置

它先将各个寄存器置0

    cli               # Disable interruptscld               # String operations incrementxorw %ax, %ax     # Segment number zeromovw %ax, %ds     # -> Data Segmentmovw %ax, %es     # -> Extra Segmentmovw %ax, %ss     # -> Stack Segment

2、开启A20

然后就是将A20置1,这里简单解释一下A20,当 A20 地址线控制禁止时,则程序就像在 8086 中运行,1MB 以上的地是不可访问的。而在保护模式下 A20 地址线控制是要打开的,所以需要通过将键盘控制器上的A20线置于高电位,使得全部32条地址线可用。

seta20.1:inb $0x64, %al    # 读取状态寄存器,等待8042键盘控制器闲置testb $0x2, %al   # 判断输入缓存是否为空jnz seta20.1movb $0xd1, %al    # 0xd1表示写输出端口命令,参数随后通过0x60端口写入outb %al, $0x64   seta20.2:inb $0x64, %al    testb $0x2, %aljnz seta20.2movb $0xdf, %al   # 通过0x60写入数据11011111 即将A20置1outb %al, $0x60   

3、加载GDT

lgdt gdtdesc

4、将CR0的第0位置1

movl %cr0, %eaxorl $CR0_PE_ON, %eaxmovl %eax, %cr0

5、长跳转到32位代码段,重装CS和EIP

ljmp $PROT_MODE_CSEG, $protcseg

6、重装DS、ES等段寄存器等

    movw $PROT_MODE_DSEG, %ax   # Our data segment selectormovw %ax, %ds     # -> DS: Data Segmentmovw %ax, %es     # -> ES: Extra Segmentmovw %ax, %fs     # -> FSmovw %ax, %gs     # -> GSmovw %ax, %ss     # -> SS: Stack Segment

7、转到保护模式完成,进入boot主方法

    movl $0x0, %ebpmovl $start, %espcall bootmain

[练习4]

分析bootloader加载ELF格式的OS的过程
1. bootloader如何读取硬盘扇区的?
2. bootloader是如何加载 ELF格式的 OS?
这里主要分析是bootmain函数,

bootmain(void) {readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);if (ELFHDR->e_magic != ELF_MAGIC) {goto bad;}struct proghdr *ph, *eph;ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);eph = ph + ELFHDR->e_phnum;for (; ph < eph; ph ++) {readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);}((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();
bad:outw(0x8A00, 0x8A00);outw(0x8A00, 0x8E00);while (1);
}

bootloader读取硬盘扇区

根据上述bootmain函数分析,首先是由readseg函数读取硬盘扇区,而readseg函数则循环调用了真正读取硬盘扇区的函数readsect来每次读出一个扇区 ,如下,详细的解释看代码中的注释:

readsect(void *dst, uint32_t secno) {waitdisk(); // 等待硬盘就绪// 写地址0x1f2~0x1f5,0x1f7,发出读取磁盘的命令outb(0x1F2, 1);outb(0x1F3, secno & 0xFF);outb(0x1F4, (secno >> 8) & 0xFF);outb(0x1F5, (secno >> 16) & 0xFF);outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);outb(0x1F7, 0x20);waitdisk();insl(0x1F0, dst, SECTSIZE / 4);//读取一个扇区
}

bootloader加载 ELF格式的 OS

读取完磁盘之后,开始加载ELF格式的文件。详细的解释看代码中的注释。

bootmain(void) {..........//首先判断是不是ELFif (ELFHDR->e_magic != ELF_MAGIC) {goto bad;                 }struct proghdr *ph, *eph;//ELF头部有描述ELF文件应加载到内存什么位置的描述表,这里读取出来将之存入phph = (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头表中的入口信息,找到内核的入口并开始运行 ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();
bad:..........
}

[练习5]

完成kdebug.c中函数print_stackframe的实现,可以通过函数>print_stackframe来跟踪函数调用堆栈中记录的返回地址。

1、函数堆栈的原理

理解函数堆栈最重要的两点是:栈的结构,以及EBP寄存器的作用。

一个函数调用动作可分解为零到多个 PUSH指令(用于参数入栈)和一个 CALL 指令。CALL 指令内部其实还暗含了一个将返回地址压栈的动作,这是由硬件完成的。几乎所有本地编译器都会在每个函数体之前插入类似如下的汇编指令:

pushl %ebp
movl %esp,%ebp

这两条汇编指令的含义是:首先将ebp 寄存器入栈,然后将栈顶指针 esp 赋值给 ebp
movl %esp %ebp这条指令表面上看是用esp覆盖 ebp原来的值,其实不然。因为给 ebp赋值之前,
ebp 值已经被压栈(位于栈顶),而新的ebp又恰恰指向栈顶。此时ebp寄存器就已经处于一个
非常重要的地位,该寄存器中存储着栈中的一个地址(原 ebp入栈后的栈顶),从该地址为基准,
向上(栈底方向)能获取返回地址、参数值,向下(栈顶方向)能获取函数局部变量值,而该地址
处又存储着上一层函数调用时的ebp值。

大概就如同下图:

现在做一下更完整的解释:

函数调用大概包括以下几个步骤:
- 1、参数入栈:将参数从右向左(或从右向左)依次压入系统栈中。
- 2、返回地址入栈:将当前代码区调用指令的下一条指令地址压入栈中,供函数返回时继续执行。
- 3、代码区跳转:处理器从当前代码区跳转到被调用函数的入口处。
- 4、栈帧调整
- 4.1保存当前栈帧状态值,已备后面恢复本栈帧时使用(EBP入栈)。
- 4.2将当前栈帧切换到新栈帧(将ESP值装入EBP,更新栈帧底部)。
- 4.3给新栈帧分配空间(把ESP减去所需空间的大小,抬高栈顶)。

而函数返回大概包括以下几个步骤:
- 1、保存返回值,通常将函数的返回值保存在寄存器EAX中。
- 2、弹出当前帧,恢复上一个栈帧。
- 2.1在堆栈平衡的基础上,给ESP加上栈帧的大小,降低栈顶,回收当前栈帧的空间
- 2.2将当前栈帧底部保存的前栈帧EBP值弹入EBP寄存器,恢复出上一个栈帧。
- 2.3将函数返回地址弹给EIP寄存器。
- 3、跳转:按照函数返回地址跳回母函数中继续执行。

而由此我们可以直接根据ebp就能读取到各个栈帧的地址和值,一般而言,ss:[ebp+4]处为返回地址,ss:[ebp+8]处为第一个参数值(最后一个入栈的参数值,此处假设其占用 4 字节内存,对应32位系统),ss:[ebp-4]处为第一个局部变量,ss:[ebp]处为上一层 ebp 值。

2、print_stackframe函数的实现

首先我们直接看到print_stackframe函数的注释:

void print_stackframe(void) {/* LAB1 YOUR CODE : STEP 1 *//* (1) call read_ebp() to get the value of ebp. the type is (uint32_t);* (2) call read_eip() to get the value of eip. the type is (uint32_t);* (3) from 0 .. STACKFRAME_DEPTH*    (3.1) printf value of ebp, eip*    (3.2) (uint32_t)calling arguments [0..4] = the contents in address (unit32_t)ebp +2 [0..4]*    (3.3) cprintf("\n");*    (3.4) call print_debuginfo(eip-1) to print the C calling function name and line number, etc.*    (3.5) popup a calling stackframe*           NOTICE: the calling funciton's return addr eip  = ss:[ebp+4]*                   the calling funciton's ebp = ss:[ebp]*/
}

这样我们直接根据注释以及之前的相关知识就能比较简单的编写成程序,如下所示:

void print_stackframe(void) {      uint32_t ebp=read_ebp();//(1) call read_ebp() to get the value of ebp. the type is (uint32_t)uint32_t eip=read_eip();//(2) call read_eip() to get the value of eip. the type is (uint32_t)int i;for(i=0;i<STACKFRAME_DEPTH&&ebp!=0;i++){//(3) from 0 .. STACKFRAME_DEPTHcprintf("ebp:0x%08x   eip:0x%08x ",ebp,eip);//(3.1)printf value of ebp, eipuint32_t *tmp=(uint32_t *)ebp+2;cprintf("arg :0x%08x 0x%08x 0x%08x 0x%08x",*(tmp+0),*(tmp+1),*(tmp+2),*(tmp+3));//(3.2)(uint32_t)calling arguments [0..4] = the contents in address (unit32_t)ebp +2 [0..4]cprintf("\n");//(3.3) cprintf("\n");print_debuginfo(eip-1);//(3.4) call print_debuginfo(eip-1) to print the C calling function name and line number, etc.eip=((uint32_t *)ebp)[1];ebp=((uint32_t *)ebp)[0];//(3.5) popup a calling stackframe}
}

实验结果截图如下:

[练习6]

1.中断向量表中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
2.请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。注意除了系统调用中断(T_SYSCALL)以外,其它中断均使用中断门描述符,权限为内核态权限;而系统调用中断使用异常,权限为陷阱门描述符。每个中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。
3.请编程完善trap.c中的中断处理函数trap在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用 print_ticks子程序,向屏幕上打印一行文字100 ticks

[练习6.1]

中断描述符表一个表项占8字节。其中0~15位和48~63位分别为offset的低16位和高16位。16~31位为段选择子。通过段选择子获得段基址,加上段内偏移量即可得到中断处理代码的入口。大致如下图:

[练习6.2]

这里这里主要就是实现对中断向量表的初始化。
注释如下:

void idt_init(void) {/* LAB1 YOUR CODE : STEP 2 *//* (1) Where are the entry addrs of each Interrupt Service Routine (ISR)?*     All ISR's entry addrs are stored in __vectors. where is uintptr_t __vectors[] ?*     __vectors[] is in kern/trap/vector.S which is produced by tools/vector.c*     (try "make" command in lab1, then you will find vector.S in kern/trap DIR)*     You can use  "extern uintptr_t __vectors[];" to define this extern variable which will be used later.* (2) Now you should setup the entries of ISR in Interrupt Description Table (IDT).*     Can you see idt[256] in this file? Yes, it's IDT! you can use SETGATE macro to setup each item of IDT* (3) After setup the contents of IDT, you will let CPU know where is the IDT by using 'lidt' instruction.*     You don't know the meaning of this instruction? just google it! and check the libs/x86.h to know more.*     Notice: the argument of lidt is idt_pd. try to find it!*/
}

重点就是两步
第一步,声明__vertors[],其中存放着中断服务程序的入口地址。这个数组生成于vertor.S中。
第二步,填充中断描述符表IDT。
第三部,加载中断描述符表。
对应到代码中如下所示:

void idt_init(void) {extern uintptr_t __vectors[];//声明__vertors[]int i;for(i=0;i<256;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(&idt_pd);//使用lidt指令加载中断描述符表
}

这里的SETGATEmmu.h中有定义,

#define SETGATE(gate, istrap, sel, off, dpl) 

简单解释一下参数

gate:为相应的idt[]数组内容,处理函数的入口地址
istrap:系统段设置为1,中断门设置为0
sel:段选择子
off:为__vectors[]数组内容
dpl:设置特权级。这里中断都设置为内核级,即第0级

[练习6.3]

这里根据指导书查看函数trap_dispatch,发现print_ticks()子程序已经被实现了,所以我们直接进行判断输出即可,如下(见注释):

........
........
case IRQ_OFFSET + IRQ_TIMER:ticks ++; //每一次时钟信号会使变量ticks加1if (ticks==TICK_NUM) {//TICK_NUM已经被预定义成了100,每到100便调用print_ticks()函数打印ticks-=TICK_NUM;print_ticks();}break;
.........
.........

实现之后截图如下:


然后我摁下了字母a,如下:

屏幕予以回显,实验成功!

操作系统ucore lab1实验报告相关推荐

  1. ucore lab1 实验报告

    UCORE LAB1 实验报告 练习一 理解通过make生成执行文件的过程 1.操作系统镜像文件ucore.img是如何一步一步生成的? 先打开lab1文件夹下的Makefile,查看里面的代码,在各 ...

  2. 操作系统 ucore lab1

    操作系统 ucore lab1 实验目的 操作系统是一个软件,也需要通过某种机制加载并运行它.在这里我们将通过另外一个更加简单的软件-bootloader来完成这些工作.为此,我们需要完成一个能够切换 ...

  3. 观察Linux进程 线程的异步并发执行,操作系统linux版实验报告.doc

    操作系统linux版实验报告.doc (29页) 本资源提供全文预览,点击全文预览即可全文预览,如果喜欢文档就下载吧,查找使用更方便哦! 19.90 积分 操作系统实验报告(Linux版)网络142 ...

  4. 西工大计算机操作系统实验报告,西工大计算机操作系统课程设计实验报告bh05xh5...

    <西工大计算机操作系统课程设计实验报告bh05xh5>由会员分享,可在线阅读,更多相关<西工大计算机操作系统课程设计实验报告bh05xh5(7页珍藏版)>请在人人文库网上搜索. ...

  5. 四川大学计算机综合实践报告,四川大学计算机操作系统第四实验报告

    1.实验报告(学生打印后提交)实验名称: 作业调度系统 实验时间: 2015 年 6 月 4 日实验人员:_(姓名)_(学号)_(年级)实验目的: 理解操作系统中调度的概念和调度算法. 学习 Linu ...

  6. [HITML] 哈工大2020秋机器学习Lab1实验报告

    Gtihub仓库 不想白嫖的就来这投个币吧 2020年春季学期 计算学部<机器学习>课程 Lab1 实验报告 姓名 学号 班号 电子邮件 手机号码 1 实验目的 掌握最小二乘法求解(无惩罚 ...

  7. 计算机操作系统存储管理实验报告,《操作系统》存储管理实验报告

    <操作系统>存储管理实验报告 ____大学____学院实验报告课程名称: 计算机操作系统 实验名称: 存储管理实验 实验日期: 班级: 姓名: 学号: 仪器编号: XX 实验报告要求:1. ...

  8. 操作系统 ucore lab1 练习2-6

    [练习2] 使用qemu执行并调试lab1中的软件 进行如下的小练习: 从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行. 在初始化位置0x7c00设置实地址断点,测试断点正常. 从0x7c ...

  9. ucore lab3实验报告

    Lab3实验报告 Lab3实验报告 练习0填写以有实验 练习1给未被映射的地址映上物理页 问题回答 练习2补充完成基于FIFO的页面替换算法 问题回答 实验运行截图 扩展练习 Challenge 借助 ...

最新文章

  1. Udacity机器人软件工程师课程笔记(九)-ROS-Catkin包、工作空间和目录结构
  2. 参观Speedy Cloud 有感
  3. redis在PHP中的基本使用
  4. word2vec代码_TensorFlow2.0 代码实战专栏(四):Word2Vec (Word Embedding)
  5. 简明writeStream实现
  6. java获取gbk文件名 linux_Java读取linux系统中文文件名时候乱码,并显示文件不存在...
  7. JAVA 23种开发模式详解(代码举例)
  8. Docker volume使用
  9. 操作系统原理之文件系统(第五章)
  10. python处理json数据 乱码报错_python json.loads json.dumps(ensure_ascii = False) 汉字乱码问题解决...
  11. mongodb数据库的安装 for windows版本 0916
  12. Python——(Anaconda+PyCharm)Youki的Python环境配置笔记(Ubuntu+Windows)~
  13. 年金计算机在线,年金终值复利计算器在线(企业年金计算方法)
  14. python2.0迅雷下载_【Tomato DualWan】迅雷离线下载完美教程
  15. Surely Vue-去除水印
  16. Qt | QTableView的使用方法
  17. permutation importance
  18. 一台电脑同时上内网和外网
  19. Python入门实例验证及结果之实例7 圆周率的计算 ##程序循环结构 ##random库
  20. html+css+js制作一个超炫酷的雪花特效

热门文章

  1. 在TeXstuidio中如何设置XeLaTeX编译
  2. WPS文字表格外计算功能配合书签使用公式轻松实现
  3. 解决:Flutter 不识别 iPhone 测试机
  4. 如何在笔记本上设置wifi热点(菜鸟篇)
  5. 实现一个完整的前后端交互
  6. prophet模型预测时间序列
  7. pic12f508 c 语言教程,pic12f508中文资料
  8. 查看ubuntu系统的版本信息
  9. PHP定时任务 - PHP自动定时循环执行任务实例代码
  10. 2018年,Java程序员转型大数据开发,是不是一个好选择?