zoukankan      html  css  js  c++  java
  • 一个简单多任务内核实例的分析【转】

    转自:http://blog.csdn.net/rosetta/article/details/8933228

    一个简单多任务内核实例的分析

    转载请注明出处:http://blog.csdn.net/rosetta

     

    简介

    Linux-0.00是由Linus Torvalds写的Linux最初版本(未发布),只是打印AAA和BBB而没有更多的功能,比如内存管理、文件系统、字符设备驱动程序等,而Linux-0.11是一个比较完整的内核,也包含上述内容。

    先分析Linux-0.00而不是Linux-0.11是因为前者是后者的基础,它非常精简但又能涵括几乎所有操作系统的基础知识,在真实完全明白Linux-0.00后再分析Linux-0.11就相对容易很多。

    Linux-0.00的原始源码在redhat9.0中无法编译,但在《Linux内核完全剖析》一书中其作者给出了可以在redhat9.0中编译通过的Linux-0.00版本。

    在分析任何代码之前如果可以我一般会先搭建环境,搭建环境的过程就会对整个操作过程有大概映像,如何搭建环境文档如下。http://blog.csdn.net/rosetta/article/details/8933240

    Linux-0.00包含两个特权级3的用户任务和一个系统调用中断过程。其由两个文件组成:as86汇编语言写的boot.s(引导启动程序)和GNU as汇编写的head.s(多任务内核程序)。前者只是引导程序,把head.s代码加载进内存并把控制权转移到head.s中执行;后者实现两个特权级3上的任务在时钟中断控制下相互切换运行,并实现显示字符的系统调用。

    任务A(0)不停的打印“AAA……”,当遇到时钟中断后切换到任务B(1)中运行打印“BBB……”,再遇时钟中断再打印“AAA……”,如此循环。

    boot.s编译出的代码共544Bytes,由Makefile处理后剩余512B存放在软盘映像文件的第一扇区中(就是《Linux-0.00运行环境搭建》中的Image,这里使用的环境能用不用实际的软盘就不用,因为现在的计算机都没软区,以后考虑使用USB启动是挺不错的)。Image是由Makefile通过dd等命令把boot.s和head.s编译出来的二进制文件合起来的,在Linux-0.11中是由build.c完成合并操作的。

    PC加电启动时,ROM BIOS中的程序会把启动盘(Image可被bochs直接启动,相当于启动盘)上第一个扇区加载到物理内存0x7c00处,并把程序执行权移到0x7c00处开始运行boot代码。boot的主要功能是把映像文件中的head内核代码先加载到内存的0x10000处,再把head搬到0x0处,并设置好临时GDT等信息后,把处理器设置成运行在保护模式下,再跳转到head中执行。这里有一个问题是boot为什么要把head先加载到0x10000再移到0x0处,而不直接加载到0x0处,这是因为把映像文件加载到内存中时需要使用BIOS INT13中断,而BIOS在初始化时会把中断向量表放在内存0x0处。

    BOOT.S源码分析

    完整的boot.s共66行,包括注释和空行,带行号。所有新增的分析都写在代码旁边,不带行号。

      1 !   boot.s

      2 

      3 BOOTSEG = 0x07c0

      4 SYSSEG  = 0x1000            ! system loaded at 0x10000 (65536).

      5 SYSLEN  = 17                ! sectors occupied.

      6 

      7 entry start

      8 start:

      9     jmpi    go,#BOOTSEG  !跳转到段BOOTSEG,段内偏移为go的地方执行代码,此时为实模式,所以BOOTSEG为段地址,后面在保护模式下会讲到,jmpi后面跟的是偏移和段选择符。

     10 go: mov ax,cs !跳转到这里后,cs的值就为0x07c0(注意,段地址在实际转换过程是需要左移4位,即多一个零为0x07c00,以下出些段的地方类似)

     11     mov ds,ax

     12     mov ss,ax

     13     mov sp,#0x400       ! arbitrary value >>512

     14  !设置临时栈指针。在这个程序中好像没什么用。

    !开始加载内核模块head到段0x1000处。这里主要利用BIOS 的int 13中断从第一扇区读取代码到内存。

    使用BIOS int 13 02H功能时各寄存器含义,可查询《x86中断手册》。

    功能描述:读扇区

    入口参数:AH=02H

    AL=扇区数

    CH=柱面

    CL=扇区

    DH=磁头

    DL=驱动器,00H~7FH:软盘;80H~0FFH:硬盘

    ES:BX=缓冲区的地址

    出口参数:CF=0——操作成功,AH=00H,AL=传输的扇区数,否则,AH=状态代码,参见功能号01H中的说明

     15 ! ok, we've written the message, now

     16 load_system: 

     17     mov dx,#0x0000 !DH=00H,驱动器号;DL=00H,磁头号。

     18     mov cx,#0x0002  !CH=00H,0柱面;CL=02H,从第2扇区开始读。

     19     mov ax,#SYSSEG  !ES:BX 读入到此缓冲区地址处(0x1000:0x0000)。

     20     mov es,ax

     21     xor bx,bx

     22     mov ax,#0x200+SYSLEN !AH=02H,读扇区;AL=17,需要读到第17个扇区。

     23     int     0x13

     24     jnc ok_load  !CF=0时跳转。CF=0表明读扇区操作成功。

     25 die:    jmp die !如果读取失败进入死循环,此时代码无法解除错误,只能让代码重新运行。

     26 

     27 ! now we want to move to protected mode ...

     28 ok_load:

     29     cli         ! no interrupts allowed !关中断,为什么要关?怎么开?sti开中断

     30     mov ax, #SYSSEG

     31     mov ds, ax

     32     xor ax, ax

     33     mov es, ax

     34     mov cx, #0x1000

     35     sub si,si

     36     sub di,di

     37     rep

     38     movw !ES:DI<-DS:SI(从DS段的SI偏移处移动CX个字到ES段的DI处)

    !这里使用的是movw,每次移动的是双字节(一个字),一共移动cx=0x1000次=4K次,

    !总共4K*2B=8192B=8KB。

    !一个扇区为512B,8KB=16个扇区,所以这里一共从0x1000:0000开始处移动16个扇区大

    !小到0x0000:0000处。

     39     mov ax, #BOOTSEG 

     40     mov ds, ax!因为前面修改了数据段寄存器ds的值,所以需要设置回来为0x07c0。因为lidt和lgdt是从数据段寄存指向的段中取内存中的值,如果不设置,那么此时ds为0x1000,而lidt会读0x1000:0x69处的值到idtr寄存器中,那么读取的idt肯定是错的。

    正常情况是读0x07c0:0x69处的值到idtr寄存器。

     41     lidt    idt_48      ! load idt with 0,0 

    !加载中断向量表idt的基地址和长度加载到IDTR寄存器中。

     42     lgdt    gdt_48      ! load gdt with whatever appropriate

    !加载全局描述符表gdt的基地址和长度加载到IDTR寄存器中。

    !基地址:0x7c00+gdt,长度:0x7ff(2047+1=2048字节,gdt中的每一项为段描述符,为8字节,所以一共有2048/8项=256项)。

     43 

     44 ! absolute address 0x00000, in 32-bit protected mode.

     45     mov ax,#0x0001  ! protected mode (PE) bit

     46     lmsw    ax      ! This is it! 设置CR0保护模式标志PE(位0).

     47     jmpi    0,8     ! jmp offset 0 of segment 8 (cs) 

    !因为此时已经进入到保护模式,所以这里的8是段选择符,8=1000b,TI为0表示GDT表,索引为1,表示GDT表中的第2项(索引为0表示第1项),即下面GDT表中的第二项——代码段。

     48 

     49 gdt:    .word   0,0,0,0     ! dummy 第一项为0,不用。

     50 

     51     .word   0x07FF      ! 8Mb - limit=2047 (2048*4096=8Mb) !第1,2字节

     52     .word   0x0000      ! base address=0x00000 !第3,4字节 

     53     .word   0x9A00      ! code read/exec !第5,6字节

     54     .word   0x00C0      ! granularity=4096, 386 !第7,8字节

    !段描述符结构如图所示。

    !gdt中的第二项以二进制表示,高双字:0000000011000000 1001101000000000 (4 2

    !          低双字:0000000000000000 0000011111111111 (1 0

    !所以高双字的位23为颗粒度,置位表示单位是4KB,低双字中的位0~15为段限长=0x07FF+1

    !=2KB,所以段限长最终为2KB*4KB=8MB。

    !类型:1010,查P93表4-3知,为代码段,可执行/可读

    ! 高双字中的位12 S=1表示非系统段描述符(代码或数据段描述符)。

     55 

     56     .word   0x07FF      ! 8Mb - limit=2047 (2048*4096=8Mb)!第三项为数据段

     57     .word   0x0000      ! base address=0x00000

     58     .word   0x9200      ! data read/write

     59     .word   0x00C0      ! granularity=4096, 386

     60 

     61 idt_48: .word   0       ! idt limit=0

     62     .word   0,0     ! idt base=0L

     63 gdt_48: .word   0x7ff       ! gdt limit=2048, 256 GDT entries

     64     .word   0x7c00+gdt,0    ! gdt base = 07xxx

     65 .org 510

     66     .word   0xAA55       

    到此boot.s已跳转到head.s的代码中执行,控制权交给head.s处理,下面看head.s源码。

    head.s源码分析

    head.s文件一共256行,GNU汇编,此代码已经运行在保护模式下,代码分析总结写在此代码最后。

      1 #  head.s contains the 32-bit startup code.

      2 #  Two L3 task multitasking. The code of tasks are in kernel area,

      3 #  just like the Linux. The kernel code is located at 0x10000.

     

      4 SCRN_SEL    = 0x18!这些都为段选择符,如图所示。

    !0x18=00011000b,表示GDT表中的第4条(因为第一条从0开始计数)

      5 TSS0_SEL    = 0x20

      6 LDT0_SEL    = 0x28

      7 TSS1_SEL    = 0X30

      8 LDT1_SEL    = 0x38

      9 .global startup_32

     10 .text

     11 startup_32:

     12     movl $0x10,%eax #因为进入保护模式,0x10为段选择符,GDT中的第二项,0x10指之前boot中设置的GDT第三项数据段。

     13     mov %ax,%ds #数据段寄存器ds中被赋予了0x10段选择符。

     14     lss init_stack,%esp #设置系统堆栈段,ss:esp<-init_stack

     15

     16 

     17 # setup base fields of descriptors.

     18     call setup_idt  !重新设置IDT表

     19     call setup_gdt !重新设置GDT表

     20     movl $0x10,%eax     # reload all the segment registers#重新加载所有段寄存器。

     21     mov %ax,%ds     # after changing gdt.

     22     mov %ax,%es

     23     mov %ax,%fs

     24     mov %ax,%gs

     25     lss init_stack,%esp #重新设置系统堆栈段,ss:esp<-init_stack

     26 

     27 # setup up timer 8253 chip.#设置8253定时芯片,这部分先不管,其作用是每隔10秒向中断控制器发送一个中断请求。

     28     movb $0x36, %al

     29     movl $0x43, %edx

     30     outb %al, %dx #向8253芯片控制字寄存器写端口。

     31     movl $11930, %eax        # timer frequency 100 HZ #??

     32     movl $0x40, %edx

     33     outb %al, %dx

     34     movb %ah, %al

     35     outb %al, %dx

     36 

     37 # setup timer & system call interrupt descriptors.

     38     movl $0x00080000, %eax #看42、43行,这是中断门描述符,P95页。8表示段选择(1000,即之前设置好的GDT中的第2项,代码段),P104页,调用门描述符格式。

     39     movw $timer_interrupt, %ax  #

     40     movw $0x8E00, %dx # 类型为E=1110,即中断门

     41     movl $0x08, %ecx              # The PC default timer int. 

     42     lea idt(,%ecx,8), %esi #加载idt的基地址到esi中。#用ecx的值批明IDT表中的第几项?后面的8代码是每一项八字节?

     43     movl %eax,(%esi) #给IDT表中的第8项的低四节地址赋值。

     44     movl %edx,4(%esi) #给IDT表中的第8项的高四节地址赋值。

     45     movw $system_interrupt, %ax

     46     movw $0xef00, %dx

     47     movl $0x80, %ecx

     48     lea idt(,%ecx,8), %esi

     49     movl %eax,(%esi) #这里的eax高16位没有赋值啊?还是00008。

     50     movl %edx,4(%esi)

     51 

     52 # unmask the timer interrupt.

     53 #   movl $0x21, %edx

     54 #   inb %dx, %al

    55 #   andb $0xfe, %al

     56 #   outb %al, %dx

     57 

     58 # Move to user mode (task 0)

     59     pushfl

     60     andl $0xffffbfff, (%esp) #?

     61     popfl

     62     movl $TSS0_SEL, %eax #把任务0的段选择符加载到任务寄存器TR中。

     63     ltr %ax

     64     movl $LDT0_SEL, %eax #把任务0的LDT段选择符加载到局部描述符表寄存器LDTR中。

     65     lldt %ax

     66     movl $0, current #当前任务号0保存在current 中。

     67     sti #开中断

     68     pushl $0x17

     69     pushl $init_stack

     70     pushfl

     71     pushl $0x0f #1111

     72     pushl $task0

     73     iret #IP<-SS:[SP],SP<-SP+2;所以就开始执行task0。

     74 

     75 /****************************************/

     76 setup_gdt:

     77     lgdt lgdt_opcode!加载gdt表的基地址和表限长到GDTR寄存中。

     78     ret

     79 

     80 setup_idt:

     81     lea ignore_int,%edx!加载IDT表的基地址和表限长到IDTR寄存中。

     82     movl $0x00080000,%eax

     83     movw %dx,%ax        /* selector = 0x0008 = cs */

     84     movw $0x8E00,%dx    /* interrupt gate - dpl=0, present */

     85     lea idt,%edi

     86     mov $256,%ecx #设置中断描述符表中256项描述符表的默认值。

     87 rp_sidt:

     88     movl %eax,(%edi)

     89     movl %edx,4(%edi)

     90     addl $8,%edi

     91     dec %ecx

     92     jne rp_sidt

     93     lidt lidt_opcode #加载内在中的IDT相关信息到IDTR寄存中。

     94     ret

     95 

     96 # -----------------------------------

     97 write_char: #打印字符调用。这段不太明白。

     98     push %gs

     99     pushl %ebx

    100 #   pushl %eax

    101     mov $SCRN_SEL, %ebx #内存显示段,不知道怎么用??或者哪里可以查到相关资料?

    102     mov %bx, %gs #gs?

    103     movl scr_loc, %ebx

    104     shl $1, %ebx

    105     movb %al, %gs:(%ebx)

    106     shr $1, %ebx

    107     incl %ebx

    108     cmpl $2000, %ebx

    109     jb 1f

    110     movl $0, %ebx

    111 1:  movl %ebx, scr_loc

    112 #   popl %eax

    113     popl %ebx

    114     pop %gs

    115     ret

    116 

    117 /***********************************************/

    118 /* This is the default interrupt "handler" :-) */

    119 .align 2

    120 ignore_int: #默认中断处理程序打印“C”。

    121     push %ds

    122     pushl %eax

    123     movl $0x10, %eax

    124     mov %ax, %ds

    125     movl $67, %eax            /* print 'C' */

    126     call write_char

    127     popl %eax

    128     pop %ds

    129     iret

    130 

    131 /* Timer interrupt handler */

    132 .align 2

    133 timer_interrupt:

    134     push %ds

    135     pushl %eax

    136     movl $0x10, %eax

    137     mov %ax, %ds

    138     movb $0x20, %al

    139     outb %al, $0x20 #?向8259A控制寄存器引脚写命令。

    140     movl $1, %eax

    141     cmpl %eax, current #判断当前任务号是否是任务1,如果是任务1则切换到任务0.

    142     je 1f

    143     movl %eax, current

    144     ljmp $TSS1_SEL, $0

    145     jmp 2f

    146 1:  movl $0, current

    147     ljmp $TSS0_SEL, $0

    148 2:  popl %eax

    149     pop %ds

    150     iret

    151 

    152 /* system call handler */

    153 .align 2

    154 system_interrupt:

    155     push %ds

    156     pushl %edx

    157     pushl %ecx

    158     pushl %ebx

    159     pushl %eax

    160     movl $0x10, %edx

    161     mov %dx, %ds

    162     call write_char

    163     popl %eax

    164     popl %ebx

    165     popl %ecx

    166     popl %edx

    167     pop %ds

    168     iret

    169 

    170 /*********************************************/

    171 current:.long 0

    172 scr_loc:.long 0

    173 

    174 .align 2

    175 lidt_opcode:

    176     .word 256*8-1       # idt contains 256 entries!IDT表长度256项*8字节/项-1。

    177     .long idt       # This will be rewrite by code.!IDT表的基地址。

    178 lgdt_opcode:

    179     .word (end_gdt-gdt)-1   # so does gdt !gdt表的表限长,2字节长。

    180     .long gdt       # This will be rewrite by code. !gdt表基地址,4字节长。

    181 

    182     .align 8

    183 idt:    .fill 256,8,0       # idt is uninitialized !默认未初始化的IDT表为256个8字节0。

    184 

    185 gdt:    .quad 0x0000000000000000    /* NULL descriptor */

    186     .quad 0x00c09a00000007ff    /* 8Mb 0x08, base = 0x00000 */

    187     .quad 0x00c09200000007ff    /* 8Mb 0x10 */

    188     .quad 0x00c0920b80000002    /* screen 0x18 - for display */

    189 

    190     .word 0x0068, tss0, 0xe900, 0x0 # TSS0 descr 0x20

    191     .word 0x0040, ldt0, 0xe200, 0x0 # LDT0 descr 0x28

    192     .word 0x0068, tss1, 0xe900, 0x0 # TSS1 descr 0x30

    193     .word 0x0040, ldt1, 0xe200, 0x0 # LDT1 descr 0x38

    194 end_gdt:

    195     .fill 128,4,0 !这是栈空间,因为栈是向下增加的,前面的代码放在低内存处,所以高内存的栈顶的位置在后面。即init_stack标号表示栈顶位置。

    196 init_stack:                          # Will be used as user stack for task0.

    197     .long init_stack

    198     .word 0x10 !0x10为段选择符。

    199 

    200 /*************************************/

    201 .align 8

    202 ldt0:   .quad 0x0000000000000000

    203     .quad 0x00c0fa00000003ff    # 0x0f, base = 0x00000

    204     .quad 0x00c0f200000003ff    # 0x17

    205 

    206 tss0:   .long 0             /* back link */

    207     .long krn_stk0, 0x10        /* esp0, ss0 */

    208     .long 0, 0, 0, 0, 0     /* esp1, ss1, esp2, ss2, cr3 */

    209     .long 0, 0, 0, 0, 0     /* eip, eflags, eax, ecx, edx */

    210     .long 0, 0, 0, 0, 0     /* ebx esp, ebp, esi, edi */

    211     .long 0, 0, 0, 0, 0, 0      /* es, cs, ss, ds, fs, gs */

    212     .long LDT0_SEL, 0x8000000   /* ldt, trace bitmap */

    213 

    214     .fill 128,4,0

    215 krn_stk0:

    216 #   .long 0

    203     .quad 0x00c0fa00000003ff    # 0x0f, base = 0x00000

    204     .quad 0x00c0f200000003ff    # 0x17

    205 

    206 tss0:   .long 0             /* back link */

    207     .long krn_stk0, 0x10        /* esp0, ss0 */

    208     .long 0, 0, 0, 0, 0     /* esp1, ss1, esp2, ss2, cr3 */

    209     .long 0, 0, 0, 0, 0     /* eip, eflags, eax, ecx, edx */

    210     .long 0, 0, 0, 0, 0     /* ebx esp, ebp, esi, edi */

    211     .long 0, 0, 0, 0, 0, 0      /* es, cs, ss, ds, fs, gs */

    212     .long LDT0_SEL, 0x8000000   /* ldt, trace bitmap */

    213 

    214     .fill 128,4,0

    215 krn_stk0:

    216 #   .long 0

    217 

    218 /************************************/

    219 .align 8

    220 ldt1:   .quad 0x0000000000000000

    221     .quad 0x00c0fa00000003ff    # 0x0f, base = 0x00000

    222     .quad 0x00c0f200000003ff    # 0x17

    223 

    224 tss1:   .long 0             /* back link */

    225     .long krn_stk1, 0x10        /* esp0, ss0 */

    226     .long 0, 0, 0, 0, 0     /* esp1, ss1, esp2, ss2, cr3 */

    227     .long task1, 0x200      /* eip, eflags */

    228     .long 0, 0, 0, 0        /* eax, ecx, edx, ebx */

    229     .long usr_stk1, 0, 0, 0     /* esp, ebp, esi, edi */

    230     .long 0x17,0x0f,0x17,0x17,0x17,0x17 /* es, cs, ss, ds, fs, gs */

    231     .long LDT1_SEL, 0x8000000   /* ldt, trace bitmap */

    232 

    233     .fill 128,4,0

    234 krn_stk1:

    235 

    236 /************************************/

    237 task0:

    238     movl $0x17, %eax

    239     movw %ax, %ds

    240     movb $68, %al              /* print 'A' */

    241     int $0x80

    242     movl $0xfff, %ecx

    243 1:  loop 1b

    244     jmp task0

    245 

    246 task1:

    247     movl $0x17, %eax

    248     movw %ax, %ds

    249     movb $69, %al              /* print 'B' */

    250     int $0x80

    251     movl $0xfff, %ecx

    252 1:  loop 1b

    253     jmp task1

    254 

    255     .fill 128,4,0 

    256 usr_stk1:

    head.s运行在32位保护模式下,主要包括初始设置的代码,时钟中断int 0x08的过程代码,系统调用中断int 0x80的过程代码以及任务A和任务B的代码和数据。初始设置主要包括:1,重新设置GDT表;2,设置系统定时器芯片;3,重新设置IDT表并设置时钟和系统调用中断门;4,移动到任务A中执行。

    在虚拟地址空间中head.s程序的内核代码和任务代码分配如下图所示。在本示例中,所有代码和数据段都对应到物理内存同一区域上,即从物理内存0开始的区域。GDT中全局代码和数据段描述符的内容都设置为:基地址为0x0000;段限长为0x07ff。因为颗粒度为1,所以实际长度为8MB。而全局显示数据段被设置成:基地址0xb8000;段限长为0x0002,所以实际长度为8KB,对应到显示内存区域上。

    两个任务在LDT中代码段和数据段描述符内容都设置为:基地址0x0000;段限长0x03ff,实际长度为4MB。因此在线性地址空间中这个“内核”的代码和数据段与任务代码和数据段都从线性地址0开始并且因为没有采用分页机制,所以它们都直接对应物理地址0开始处。在head程序编译出的目标文件中以及最终得到的软件映像文件Image中,代码和数据的组织形式如下图所示。

    因为处理特权级0的代码不能直接把控制权转移到特权级3的代码中执行,但中断返回操作是可以的,因此当初始化GDT、IDT和定时芯片结束后,就可以利用中断中断返回指令IRET来启动运行第1个任务。具体的方法是在初始堆栈init_stack中人工设置一个返回环境。

    即把任务0的TSS段选择符加载到任务寄存器LTR中、LDT段选择符加载到LDTR中后,把任务0的用户栈指针(0x17:init_stack)和代码指针(0x0f:task0)以及标志寄存器值压入栈中,然后执行中断返回指针IRET。该指令会弹出堆栈上的堆栈指针作为任务0用户栈指针,恢复假设的任务0的标志寄存器内容,并且弹出栈中代码指针放入CS:EIP寄存中,从而开始执行任务0的代码,完成了从特权级0到特权级3代码的控制转移。

    为了每隔10毫秒切换运行的任务,head.s程序把定时器芯片8253的通道0设置成每经过10毫秒就向中断控制芯片8259A发送一个时钟中断请求信号。PC机的ROM 了BIOS开机时已经在8259A中把时钟中断请求信号设置成中断风和向量8,因为我们需要在中断8的处理过程中执行任务切换操作。任务切换的实现方法是查看current变量中当前运行任务号。如果current为0,就利用任务1的TSS选择符作为操作数执行远跳转指令,从而切换到任务1中执行,否则反之。

    每个任务在执行时,会首先把一个字符的ASCII码放入寄存器AL中,然后调用系统中断调用int 0x80,而该系统调用处理过程会调用一个简单的字符写屏子程序,把寄存器AL中的字符显示在屏幕上,同时把字符显示的屏幕的下一个位置记录下来。在显示过一个字符后,任务代码使用循环语句延迟一段时间,然后又跳转到任务代码开始处继续循环执行,直到运行了10毫秒而发生了定时中断,从而代码会切换到另一个任务去运行。

  • 相关阅读:
    dede织梦编辑器中插入视频文件方法
    织梦在PHP7上安装模块时模块包含的文件为空的解决方法
    织梦dedecms整合添加ckplayer播放器支持flv,mp4等播放功能
    实现dedecms全站动态浏览
    【idea快捷键】
    【Android-stdio-appdemo搭建记录】
    【随记-插件-】
    【mysql远程连库】
    【策略模式和工厂模式的比较】
    【极客学院-idea教程】
  • 原文地址:https://www.cnblogs.com/sky-heaven/p/5279853.html
Copyright © 2011-2022 走看看