x86内核中的键盘IRQ [英] Keyboard IRQ within an x86 kernel

查看:116
本文介绍了x86内核中的键盘IRQ的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

我正在尝试编写一个非常简单的内核以用于学习目的.阅读了很多有关x86体系结构中的PIC和IRQ的文章之后, 我发现IRQ1是键盘处理程序.我正在使用以下代码来打印被按下的键:

I'm trying to program a very simple kernel for learning purposes. After reading a bunch of articles about the PIC and IRQs in the x86 architecture, I've figured out that IRQ1 is the keyboard handler. I'm using the following code to print the keys being pressed:

#include "port_io.h"

#define IDT_SIZE 256
#define PIC_1_CTRL 0x20
#define PIC_2_CTRL 0xA0
#define PIC_1_DATA 0x21
#define PIC_2_DATA 0xA1

void keyboard_handler();
void load_idt(void*);

struct idt_entry
{
    unsigned short int offset_lowerbits;
    unsigned short int selector;
    unsigned char zero;
    unsigned char flags;
    unsigned short int offset_higherbits;
};

struct idt_pointer
{
    unsigned short limit;
    unsigned int base;
};

struct idt_entry idt_table[IDT_SIZE];
struct idt_pointer idt_ptr;

void load_idt_entry(char isr_number, unsigned long base, short int selector, char flags)
{
    idt_table[isr_number].offset_lowerbits = base & 0xFFFF;
    idt_table[isr_number].offset_higherbits = (base >> 16) & 0xFFFF;
    idt_table[isr_number].selector = selector;
    idt_table[isr_number].flags = flags;
    idt_table[isr_number].zero = 0;
}

static void initialize_idt_pointer()
{
    idt_ptr.limit = (sizeof(struct idt_entry) * IDT_SIZE) - 1;
    idt_ptr.base = (unsigned int)&idt_table;
}

static void initialize_pic()
{
    /* ICW1 - begin initialization */
    write_port(PIC_1_CTRL, 0x11);
    write_port(PIC_2_CTRL, 0x11);

    /* ICW2 - remap offset address of idt_table */
    /*
    * In x86 protected mode, we have to remap the PICs beyond 0x20 because
    * Intel have designated the first 32 interrupts as "reserved" for cpu exceptions
    */
    write_port(PIC_1_DATA, 0x20);
    write_port(PIC_2_DATA, 0x28);

    /* ICW3 - setup cascading */
    write_port(PIC_1_DATA, 0x00);
    write_port(PIC_2_DATA, 0x00);

    /* ICW4 - environment info */
    write_port(PIC_1_DATA, 0x01);
    write_port(PIC_2_DATA, 0x01);
    /* Initialization finished */

    /* mask interrupts */
    write_port(0x21 , 0xff);
    write_port(0xA1 , 0xff);
}

void idt_init()
{
    initialize_pic();
    initialize_idt_pointer();
    load_idt(&idt_ptr);
}

load_idt仅使用lidt x86指令.之后,我要加载键盘处理程序:

load_idt just uses the lidt x86 instruction. Afterwards I'm loading the keyboard handler:

void kmain(void)
{
    //Using grub bootloader..
    idt_init();
    kb_init();
    load_idt_entry(0x21, (unsigned long) keyboard_handler, 0x08, 0x8e);
}

这是实现:

#include "kprintf.h"
#include "port_io.h"
#include "keyboard_map.h"

void kb_init(void)
{
    /* 0xFD is 11111101 - enables only IRQ1 (keyboard)*/
    write_port(0x21 , 0xFD);
}

void keyboard_handler(void)
{
    unsigned char status;
    char keycode;
    char *vidptr = (char*)0xb8000;  //video mem begins here.
    /* Acknownlegment */

    int current_loc = 0;
    status = read_port(0x64);
    /* Lowest bit of status will be set if buffer is not empty */
    if (status & 0x01) {
        keycode = read_port(0x60);
        if(keycode < 0)
            return;
        vidptr[current_loc++] = keyboard_map[keycode];
        vidptr[current_loc++] = 0x07;
    }

    write_port(0x20, 0x20);
}

这是我正在使用的额外代码:

This is the extra code I'm using:

section .text

global load_idt
global keyboard_handler

extern kprintf
extern keyboard_handler_main

load_idt:
    sti
    mov edx, [esp + 4]
    lidt [edx]
    ret

global read_port
global write_port

; arg: int, port number.
read_port:
    mov edx, [esp + 4]
    in al, dx   
    ret

; arg: int, (dx)port number
;      int, (al)value to write
write_port:
    mov   edx, [esp + 4]    
    mov   al, [esp + 4 + 4]  
    out   dx, al  
    ret

这是我的切入点:

bits 32
section .text
;grub bootloader header
        align 4
        dd 0x1BADB002            ;magic
        dd 0x00                  ;flags
        dd - (0x1BADB002 + 0x00) ;checksum. m+f+c should be zero

global start
extern kmain            

start:
;  cli          ;block interrupts
  mov esp, stack_space  ;set stack pointer
  call kmain
  hlt           ;halt the CPU

section .bss
resb 8192       ;8KB for stack
stack_space:

我正在使用QEMU运行内核:

I'm using QEMU to run the kernel:

qemu-system-i386 -kernel kernel

问题是我没有在屏幕上看到任何字符.相反,我仍然得到相同的输出:

The problem is that I'm not getting any character on the screen. Instead, I still get the same output:

SeaBIOS (version Ubuntu-1.8.2-1-ubuntu1)
Booting from ROM...

如何解决此问题?有什么建议吗?

How do I solve this problem? Any suggestions?

推荐答案

您的代码有很多问题.主要问题在下面分别讨论.

You have a number of issues with your code. The main ones are discussed individually below.

HLT 指令将暂停当前CPU等待下一个中断.此时您确实启用了中断.在第一个中断(按键)之后,将执行 HLT 之后的代码.它将开始执行内存中任何随机数据.您可以修改kmain以使用 HLT 指令进行无限循环.这样的事情应该起作用:

The HLT instruction will halt the current CPU waiting for the next interrupt. You do have interrupts enabled by this point. After the first interrupt (keystroke) the code after HLT will be executed. It will start executing whatever random data is in memory. You could modify your kmain to do an infinite loop with a HLT instruction. Something like this should work:

while(1) __asm__("hlt\n\t");


在此代码中:


In this code:

load_idt:
    sti
    mov edx, [esp + 4]
    lidt [edx]
    ret

通常最好在更新中断表之后而不是在中断表之前使用 STI .这样会更好:

It is generally a better idea to use STI after you update the interrupt table, not before it. This would be better:

load_idt:
    mov edx, [esp + 4]
    lidt [edx]
    sti
    ret


您的中断处理程序需要执行iretd才能从中断中正确返回.您的函数keyboard_handler将执行ret返回.要解决此问题,您可以创建一个调用 C keyboard_handler函数的程序集包装,然后执行


Your interrupt handler needs to perform an iretd to properly return from an interrupt. Your function keyboard_handler will do a ret to return. To resolve this you could create an assembly wrapper that calls the C keyboard_handler function and then does an IRETD.

NASM 程序集文件中,您可以定义一个名为keyboard_handler_int的全局函数,如下所示:

In a NASM assembly file you could define a global function called keyboard_handler_int like this:

extern keyboard_handler
global keyboard_handler_int

keyboard_handler_int:
    call keyboard_handler
    iretd

设置 IDT 条目的代码如下:

load_idt_entry(0x21, (unsigned long) keyboard_handler_int, 0x08, 0x8e);


您的kb_init函数最终会(通过掩码)启用键盘中断.不幸的是,您在启用该中断后设置了键盘处理程序.在启用中断之后且将条目放置在 IDT 中之前,可以按下按键.一种快速的解决方法是在调用kb_init之前设置键盘处理程序,例如:


Your kb_init function eventually enables (via a mask) the keyboard interrupt. Unfortunately, you set up the keyboard handler after you enable that interrupt. It is possible for a keystroke to be pressed after the interrupt is enabled and before the entry is placed in the IDT. A quick fix is to set your keyboard handler up before the call to kb_init with something like:

void kmain(void)
{
    //Using grub bootloader..
    idt_init();
    load_idt_entry(0x21, (unsigned long) keyboard_handler_int, 0x08, 0x8e);
    kb_init();
    while(1) __asm__("hlt\n\t");
}


最可能导致内核出现三重故障(并有效地重新引导虚拟机)的最严重问题是定义idt_pointer结构的方式.您曾经使用过:


The most serious problem that is likely causing your kernel to triple fault (and effectively rebooting the virtual machine) is the way you defined the idt_pointer structure. You used:

struct idt_pointer
{
    unsigned short limit;
    unsigned int base;
};

问题是默认的对齐规则将在limit之后和base之前放置2个字节的填充,以使unsigned int在结构中的偏移量为4个字节.若要更改此行为并打包数据而无需填充,可以在结构上使用__attribute__((packed)).定义如下:

The problem is that default alignment rules will place 2 bytes of padding after limit and before base so that the unsigned int will be aligned at a 4 byte offset within the structure. To alter this behavior and pack the data without padding, you can use __attribute__((packed)) on the structure. The definition would look like this:

struct idt_pointer
{
    unsigned short limit;
    unsigned int base;
} __attribute__((packed));

以这种方式进行操作意味着limitbase之间没有多余的字节用于对齐目的.无法有效处理对齐问题会产生base地址,该地址错误地放置在结构中. IDT 指针需要一个16位的值来表示 IDT 的大小,紧随其后的是一个32位的值来表示 IDT 的基地址>.

Doing it this way means that there are no extra bytes placed between limit and base for alignment purposes. Failure to deal with the alignment issue effectively yields a base address that is incorrectly placed in the structure. The IDT pointer needs a 16-bit value representing the size of the IDT followed immediately by a 32-bit value representing the base address of your IDT.

有关结构对齐和填充的更多信息,可以在Eric Raymond的博客中找到. .由于struct idt_entry成员的放置方式,因此没有多余的填充字节.如果要创建永远不希望填充的结构,建议使用__attribute__((packed));.当您使用系统定义的结构映射 C 数据结构时,通常就是这种情况.考虑到这一点,为了清楚起见,我还会打包struct idt_entry.

More information on structure alignment and padding can be found in one of Eric Raymond's blogs. Because of the way that members of struct idt_entry are placed there are no extra padding bytes. If you are creating structs that you never want padded I recommend using __attribute__((packed));. This is generally the case when you are mapping a C data structure with a system defined structure. With that in mind I'd also pack struct idt_entry for clarity.

在中断处理程序中,尽管我建议使用 IRETD ,但还有另一个问题.随着内核的增长和添加更多的中断,您会发现另一个问题.您的内核可能行为异常,寄存器可能会意外更改值.问题在于,充当中断处理程序的 C 函数会破坏某些寄存器的内容,但我们不会保存和恢复它们.其次,方向标记(根据 32位ABI )必须清除( CLD )在调用函数之前.您不能假定在进入中断例程后便清除了方向标志. ABI说:

In the interrupt handler, although I suggested an IRETD, there is another issue. As your kernel grows and you add more interrupts you'll discover another problem. Your kernel may act erratically and registers may change values unexpectedly. The issue is that C functions acting as interrupt handlers will destroy the contents of some registers, but we don't save and restore them. Secondly, the direction flag (per the 32-bit ABI) is required to be cleared (CLD) before a function is called. You can't assume the direction flag is cleared upon entry to the interrupt routine. The ABI says:

EFLAGS标志寄存器包含系统标志,例如方向 标志和进位标志.方向标记必须设置为 进入前和退出时的前进"(即零)方向 从一个功能.其他用户标志在 标准的通话顺序,并且不保留

EFLAGS The flags register contains the system flags, such as the direction flag and the carry flag. The direction flag must be set to the ‘‘forward’’ (that is, zero) direction before entry and upon exit from a function. Other user flags have no specified role in the standard calling sequence and are not preserved

您可以单独推送所有易失性寄存器,但为简便起见,可以使用 PUSHAD POPAD 说明.看起来像这样的中断处理程序会更好:

You could push all the volatile registers individually but for brevity you can use the PUSHAD and POPAD instructions. An interrupt handler would be better if it looked like:

keyboard_handler_int:
    pushad                 ; Push all general purpose registers
    cld                    ; Clear direction flag (forward movement)
    call keyboard_handler
    popad                  ; Restore all general purpose registers
    iretd                  ; IRET will restore required parts of EFLAGS
                           ;   including the direction flag

如果要手动保存和恢复所有易失性寄存器,则必须保存和恢复 EAX ECX EDX 因为不需要在 C 函数调用中保留它们.在中断处理程序中使用x87 FPU指令通常不是一个好主意(主要是为了提高性能),但是如果这样做,则还必须保存和恢复x87 FPU状态.

If you were to save and restore all the volatile registers manually you'd have to save and restore EAX, ECX, and EDX as they don't need to be preserved across C function calls. It generally isn't a good idea to to use x87 FPU instructions in an interrupt handler (mostly for performance), but if you did you'd have to save and restore the x87 FPU state as well.

您没有提供完整的示例,因此我填补了一些空白(包括简单的键盘映射),并对您的键盘处理程序进行了一些改动.修改后的键盘处理程序仅显示按键事件,并跳过没有映射的字符.在所有情况下,代码都会直达处理程序的末尾,以便向 PIC 发送 EOI (中断结束).当前光标位置是一个静态整数,将在中断调用之间保留其值.这样可以使位置在每次按下字符之间前进.

You didn't provide a complete example, so I filled in some of the gaps (including a simple keyboard map) and slight change to your keyboard handler. The revised keyboard handler only displays key down events and skips over characters that had no mapping. In all cases the code drops through to the end of the handler so that the PIC is sent an EOI (End Of Interrupt). The current cursor location is a static integer that will retain its value across interrupt calls. This allows the position to advance between each character press.

我的kprintd.h文件为空,我将所有汇编器原型都放入了port_io.h中.原型应适当地分为多个标题.我只是通过这种方式来减少文件数量.我的文件lowlevel.asm定义了所有低级汇编例程.最终代码如下:

My kprintd.h file is empty, and I put ALL the assembler prototypes into your port_io.h. The prototypes should be divided properly into multiple headers. I only did it this way to reduce the number of files. My file lowlevel.asm defines all the low level assembly routines. The final code is as follows:

kernel.asm:

bits 32
section .text
;grub bootloader header
        align 4
        dd 0x1BADB002            ;magic
        dd 0x00                  ;flags
        dd - (0x1BADB002 + 0x00) ;checksum. m+f+c should be zero

global start
extern kmain

start:
    lgdt [gdtr]                 ; Load our own GDT, the GDTR of Grub may be invalid

    jmp CODE32_SEL:.setcs       ; Set CS to our 32-bit flat code selector
.setcs:
    mov ax, DATA32_SEL          ; Setup the segment registers with our flat data selector
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax
    mov ss, ax
    mov esp, stack_space        ; set stack pointer

    call kmain

; If we get here just enter an infinite loop
endloop:
    hlt                         ; halt the CPU
    jmp endloop

; Macro to build a GDT descriptor entry
%define MAKE_GDT_DESC(base, limit, access, flags) \
    (((base & 0x00FFFFFF) << 16) | \
    ((base & 0xFF000000) << 32) | \
    (limit & 0x0000FFFF) | \
    ((limit & 0x000F0000) << 32) | \
    ((access & 0xFF) << 40) | \
    ((flags & 0x0F) << 52))

section .data
align 4
gdt_start:
    dq MAKE_GDT_DESC(0, 0, 0, 0); null descriptor
gdt32_code:
    dq MAKE_GDT_DESC(0, 0x00ffffff, 10011010b, 1100b)
                                ; 32-bit code, 4kb gran, limit 0xffffffff bytes, base=0
gdt32_data:
    dq MAKE_GDT_DESC(0, 0x00ffffff, 10010010b, 1100b)
                                ; 32-bit data, 4kb gran, limit 0xffffffff bytes, base=0
end_of_gdt:

gdtr:
    dw end_of_gdt - gdt_start - 1
                                ; limit (Size of GDT - 1)
    dd gdt_start                ; base of GDT

CODE32_SEL equ gdt32_code - gdt_start
DATA32_SEL equ gdt32_data - gdt_start

section .bss
resb 8192                       ; 8KB for stack
stack_space:

lowlevel.asm:

section .text

extern keyboard_handler
global read_port
global write_port
global load_idt
global keyboard_handler_int

keyboard_handler_int:
    pushad
    cld
    call keyboard_handler
    popad
    iretd

load_idt:
    mov edx, [esp + 4]
    lidt [edx]
    sti
    ret

; arg: int, port number.
read_port:
    mov edx, [esp + 4]
    in al, dx
    ret

; arg: int, (dx)port number
;      int, (al)value to write
write_port:
    mov   edx, [esp + 4]
    mov   al, [esp + 4 + 4]
    out   dx, al
    ret

port_io.h:

extern unsigned char read_port (int port);
extern void write_port (int port, unsigned char val);
extern void kb_init(void);

kprintf.h:

/* Empty file */

keyboard_map.h:

unsigned char keyboard_map[128] =
{
    0,  27, '1', '2', '3', '4', '5', '6', '7', '8',     /* 9 */
  '9', '0', '-', '=', '\b',     /* Backspace */
  '\t',                 /* Tab */
  'q', 'w', 'e', 'r',   /* 19 */
  't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\n', /* Enter key */
    0,                  /* 29   - Control */
  'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';',     /* 39 */
 '\'', '`',   0,                /* Left shift */
 '\\', 'z', 'x', 'c', 'v', 'b', 'n',                    /* 49 */
  'm', ',', '.', '/',   0,                              /* Right shift */
  '*',
    0,  /* Alt */
  ' ',  /* Space bar */
    0,  /* Caps lock */
    0,  /* 59 - F1 key ... > */
    0,   0,   0,   0,   0,   0,   0,   0,
    0,  /* < ... F10 */
    0,  /* 69 - Num lock*/
    0,  /* Scroll Lock */
    0,  /* Home key */
    0,  /* Up Arrow */
    0,  /* Page Up */
  '-',
    0,  /* Left Arrow */
    0,
    0,  /* Right Arrow */
  '+',
    0,  /* 79 - End key*/
    0,  /* Down Arrow */
    0,  /* Page Down */
    0,  /* Insert Key */
    0,  /* Delete Key */
    0,   0,   0,
    0,  /* F11 Key */
    0,  /* F12 Key */
    0,  /* All other keys are undefined */
};

keyb.c:

#include "kprintf.h"
#include "port_io.h"
#include "keyboard_map.h"

void kb_init(void)
{
    /* This is a very basic keyboard initialization. The assumption is we have a
     * PS/2 keyboard and it is already in a propr state. This may not be the case
     * on real hardware. We simply enable the keyboard interupt */

    /* Get current master PIC interrupt mask */
    unsigned char curmask_master = read_port (0x21);

    /* 0xFD is 11111101 - enables only IRQ1 (keyboard) on master pic
       by clearing bit 1. bit is clear for enabled and bit is set for disabled */
    write_port(0x21, curmask_master & 0xFD);
}

/* Maintain a global location for the current video memory to write to */
static int current_loc = 0;
/* Video memory starts at 0xb8000. Make it a constant pointer to
   characters as this can improve compiler optimization since it
   is a hint that the value of the pointer won't change */
static char *const vidptr = (char*)0xb8000;

void keyboard_handler(void)
{
    signed char keycode;

    keycode = read_port(0x60);
    /* Only print characters on keydown event that have
     * a non-zero mapping */
    if(keycode >= 0 && keyboard_map[keycode]) {
        vidptr[current_loc++] = keyboard_map[keycode];
        /* Attribute 0x07 is white on black characters */
            vidptr[current_loc++] = 0x07;
    }

    /* Send End of Interrupt (EOI) to master PIC */
    write_port(0x20, 0x20);
}

main.c:

#include "port_io.h"

#define IDT_SIZE 256
#define PIC_1_CTRL 0x20
#define PIC_2_CTRL 0xA0
#define PIC_1_DATA 0x21
#define PIC_2_DATA 0xA1

void keyboard_handler_int();
void load_idt(void*);

struct idt_entry
{
    unsigned short int offset_lowerbits;
    unsigned short int selector;
    unsigned char zero;
    unsigned char flags;
    unsigned short int offset_higherbits;
} __attribute__((packed));

struct idt_pointer
{
    unsigned short limit;
    unsigned int base;
} __attribute__((packed));

struct idt_entry idt_table[IDT_SIZE];
struct idt_pointer idt_ptr;

void load_idt_entry(int isr_number, unsigned long base, short int selector, unsigned char flags)
{
    idt_table[isr_number].offset_lowerbits = base & 0xFFFF;
    idt_table[isr_number].offset_higherbits = (base >> 16) & 0xFFFF;
    idt_table[isr_number].selector = selector;
    idt_table[isr_number].flags = flags;
    idt_table[isr_number].zero = 0;
}

static void initialize_idt_pointer()
{
    idt_ptr.limit = (sizeof(struct idt_entry) * IDT_SIZE) - 1;
    idt_ptr.base = (unsigned int)&idt_table;
}

static void initialize_pic()
{
    /* ICW1 - begin initialization */
    write_port(PIC_1_CTRL, 0x11);
    write_port(PIC_2_CTRL, 0x11);

    /* ICW2 - remap offset address of idt_table */
    /*
    * In x86 protected mode, we have to remap the PICs beyond 0x20 because
    * Intel have designated the first 32 interrupts as "reserved" for cpu exceptions
    */
    write_port(PIC_1_DATA, 0x20);
    write_port(PIC_2_DATA, 0x28);

    /* ICW3 - setup cascading */
    write_port(PIC_1_DATA, 0x00);
    write_port(PIC_2_DATA, 0x00);

    /* ICW4 - environment info */
    write_port(PIC_1_DATA, 0x01);
    write_port(PIC_2_DATA, 0x01);
    /* Initialization finished */

    /* mask interrupts */
    write_port(0x21 , 0xff);
    write_port(0xA1 , 0xff);
}

void idt_init()
{
    initialize_pic();
    initialize_idt_pointer();
    load_idt(&idt_ptr);
}

void kmain(void)
{
    //Using grub bootloader..
    idt_init();
    load_idt_entry(0x21, (unsigned long) keyboard_handler_int, 0x08, 0x8e);
    kb_init();
    while(1) __asm__("hlt\n\t");
}

为了链接此内核,我使用具有以下定义的文件link.ld:

In order to link this kernel I use a file link.ld with this definition:

/*
*  link.ld
*/
OUTPUT_FORMAT(elf32-i386)
ENTRY(start)
SECTIONS
 {
   . = 0x100000;
   .text : { *(.text) }
   .rodata : { *(.rodata) }
   .data : { *(.data) }
   .bss  : { *(.bss)  }
 }

我使用 GCC i686 跨编译器对该代码进行编译和链接使用以下命令:

I compile and link this code using a GCC i686 cross compiler with these commands:

nasm -f elf32 -g -F dwarf kernel.asm -o kernel.o
nasm -f elf32 -g -F dwarf lowlevel.asm -o lowlevel.o
i686-elf-gcc -g -m32  -c main.c -o main.o -ffreestanding -O3 -Wall -Wextra -pedantic
i686-elf-gcc -g -m32  -c keyb.c -o keyb.o -ffreestanding -O3 -Wall -Wextra -pedantic
i686-elf-gcc -g -m32  -Wl,--build-id=none -T link.ld -o kernel.elf -ffreestanding -nostdlib lowlevel.o main.o keyb.o kernel.o -lgcc

结果是带有调试信息的名为kernel.elf的内核.我更喜欢将优化级别设置为-O3,而不是将默认值设置为-O0.调试信息使使用 QEMU GDB 进行调试变得更加容易.可以使用以下命令调试内核:

The result is a kernel called kernel.elf with debug information. I prefer an optimization level of -O3 rather than a default of -O0. Debug information makes it easier to debug with QEMU and GDB. The kernel can be debugged with these commands:

qemu-system-i386 -kernel kernel.elf -S -s &

gdb kernel.elf \
        -ex 'target remote localhost:1234' \
        -ex 'layout src' \
        -ex 'layout regs' \
        -ex 'break kmain' \
        -ex 'continue'

如果要在汇编代码级别进行调试,请将layout src替换为layout asm.当使用输入the quick brown fox jumps over the lazy dog 01234567890 QEMU 运行时,显示以下内容:

If you wish to debug at the assembly code level replace layout src with layout asm. When run with the input the quick brown fox jumps over the lazy dog 01234567890 QEMU displayed this:

这篇关于x86内核中的键盘IRQ的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆