【X86汇编语言 从实模式到保护模式】05 程序加载器-硬盘和显卡的访问与控制

主引导扇区是处理器迈向广阔天空的第一块跳板。离开主引导扇区后就由操作系统接受。操作系统主要负责内存管理,文件管理,进程管理,外围设备的访问和控制等。

程序可以由千千万万个,但是加载过程是固定的。再本章,我们会把主引导扇区改造成一个程序加载器,用来加载用户程序,并执行该程序(将处理器的控制器交给该程序0)。

1. 代码清单

  • 8-1(程序加载器),源程序文件:c08_mbr.asm
         ;代码清单8-1
         ;文件名:c08_mbr.asm
         ;文件说明:硬盘主引导扇区代码(加载程序) 
         ;创建日期:2011-5-5 18:17
         
         app_lba_start equ 100           ;声明常数(用户程序起始逻辑扇区号)
                                         ;常数的声明不会占用汇编地址
                                    
SECTION mbr align=16 vstart=0x7c00                                     

         ;设置堆栈段和栈指针 
         mov ax,0      
         mov ss,ax
         mov sp,ax
      
         mov ax,[cs:phy_base]            ;计算用于加载用户程序的逻辑段地址 
         mov dx,[cs:phy_base+0x02]
         mov bx,16        
         div bx            
         mov ds,ax                       ;令DS和ES指向该段以进行操作
         mov es,ax                        
    
         ;以下读取程序的起始部分 
         xor di,di
         mov si,app_lba_start            ;程序在硬盘上的起始逻辑扇区号 
         xor bx,bx                       ;加载到DS:0x0000处 
         call read_hard_disk_0
      
         ;以下判断整个程序有多大
         mov dx,[2]                      ;曾经把dx写成了ds,花了二十分钟排错 
         mov ax,[0]
         mov bx,512                      ;512字节每扇区
         div bx
         cmp dx,0
         jnz @1                          ;未除尽,因此结果比实际扇区数少1 
         dec ax                          ;已经读了一个扇区,扇区总数减1 
   @1:
         cmp ax,0                        ;考虑实际长度小于等于512个字节的情况 
         jz direct
         
         ;读取剩余的扇区
         push ds                         ;以下要用到并改变DS寄存器 

         mov cx,ax                       ;循环次数(剩余扇区数)
   @2:
         mov ax,ds
         add ax,0x20                     ;得到下一个以512字节为边界的段地址
         mov ds,ax  
                              
         xor bx,bx                       ;每次读时,偏移地址始终为0x0000 
         inc si                          ;下一个逻辑扇区 
         call read_hard_disk_0
         loop @2                         ;循环读,直到读完整个功能程序 

         pop ds                          ;恢复数据段基址到用户程序头部段 
      
         ;计算入口点代码段基址 
   direct:
         mov dx,[0x08]
         mov ax,[0x06]
         call calc_segment_base
         mov [0x06],ax                   ;回填修正后的入口点代码段基址 
      
         ;开始处理段重定位表
         mov cx,[0x0a]                   ;需要重定位的项目数量
         mov bx,0x0c                     ;重定位表首地址
          
 realloc:
         mov dx,[bx+0x02]                ;32位地址的高16位 
         mov ax,[bx]
         call calc_segment_base
         mov [bx],ax                     ;回填段的基址
         add bx,4                        ;下一个重定位项(每项占4个字节) 
         loop realloc 
      
         jmp far [0x04]                  ;转移到用户程序  
 
;-------------------------------------------------------------------------------
read_hard_disk_0:                        ;从硬盘读取一个逻辑扇区
                                         ;输入:DI:SI=起始逻辑扇区号
                                         ;      DS:BX=目标缓冲区地址
         push ax
         push bx
         push cx
         push dx
      
         mov dx,0x1f2
         mov al,1
         out dx,al                       ;读取的扇区数

         inc dx                          ;0x1f3
         mov ax,si
         out dx,al                       ;LBA地址7~0

         inc dx                          ;0x1f4
         mov al,ah
         out dx,al                       ;LBA地址15~8

         inc dx                          ;0x1f5
         mov ax,di
         out dx,al                       ;LBA地址23~16

         inc dx                          ;0x1f6
         mov al,0xe0                     ;LBA28模式,主盘
         or al,ah                        ;LBA地址27~24
         out dx,al

         inc dx                          ;0x1f7
         mov al,0x20                     ;读命令
         out dx,al

  .waits:
         in al,dx
         and al,0x88
         cmp al,0x08
         jnz .waits                      ;不忙,且硬盘已准备好数据传输 

         mov cx,256                      ;总共要读取的字数
         mov dx,0x1f0
  .readw:
         in ax,dx
         mov [bx],ax
         add bx,2
         loop .readw

         pop dx
         pop cx
         pop bx
         pop ax
      
         ret

;-------------------------------------------------------------------------------
calc_segment_base:                       ;计算16位段地址
                                         ;输入:DX:AX=32位物理地址
                                         ;返回:AX=16位段基地址 
         push dx                          
         
         add ax,[cs:phy_base]
         adc dx,[cs:phy_base+0x02]
         shr ax,4
         ror dx,4
         and dx,0xf000
         or ax,dx
         
         pop dx
         
         ret

;-------------------------------------------------------------------------------
         phy_base dd 0x10000             ;用户程序被加载的物理起始地址
         
 times 510-($-$$) db 0
                  db 0x55,0xaa
  • 8-2(用户程序),源程序文件:c08.asm
         ;代码清单8-2
         ;文件名:c08.asm
         ;文件说明:用户程序 
         ;创建日期:2011-5-5 18:17
         
;===============================================================================
SECTION header vstart=0                     ;定义用户程序头部段 
    program_length  dd program_end          ;程序总长度[0x00]
    
    ;用户程序入口点
    code_entry:      dw start                ;偏移地址[0x04]
                    dd section.code_1.start ;段地址[0x06] 
    
    realloc_tbl_len dw (header_end-code_1_segment)/4
                                            ;段重定位表项个数[0x0a]
    
    ;段重定位表           
    code_1_segment  dd section.code_1.start ;[0x0c]
    code_2_segment  dd section.code_2.start ;[0x10]
    data_1_segment  dd section.data_1.start ;[0x14]
    data_2_segment  dd section.data_2.start ;[0x18]
    stack_segment   dd section.stack.start  ;[0x1c]
    
    header_end:                
    
;===============================================================================
SECTION code_1 align=16 vstart=0         ;定义代码段116字节对齐) 
put_string:                              ;显示串(0结尾);输入:DS:BX=串地址
         mov cl,[bx]
         or cl,cl                        ;cl=0 ?
         jz .exit                        ;是的,返回主程序 
         call put_char
         inc bx                          ;下一个字符 
         jmp put_string

   .exit:
         ret

;-------------------------------------------------------------------------------
put_char:                                ;显示一个字符
                                         ;输入:cl=字符ascii
         push ax
         push bx
         push cx
         push dx
         push ds
         push es

         ;以下取当前光标位置
         mov dx,0x3d4
         mov al,0x0e
         out dx,al
         mov dx,0x3d5
         in al,dx                        ;8位 
         mov ah,al

         mov dx,0x3d4
         mov al,0x0f
         out dx,al
         mov dx,0x3d5
         in al,dx                        ;8位 
         mov bx,ax                       ;BX=代表光标位置的16位数

         cmp cl,0x0d                     ;回车符?
         jnz .put_0a                     ;不是。看看是不是换行等字符 
         mov ax,bx                       ;此句略显多余,但去掉后还得改书,麻烦 
         mov bl,80                       
         div bl
         mul bl
         mov bx,ax
         jmp .set_cursor

 .put_0a:
         cmp cl,0x0a                     ;换行符?
         jnz .put_other                  ;不是,那就正常显示字符 
         add bx,80
         jmp .roll_screen

 .put_other:                             ;正常显示字符
         mov ax,0xb800
         mov es,ax
         shl bx,1
         mov [es:bx],cl

         ;以下将光标位置推进一个字符
         shr bx,1
         add bx,1

 .roll_screen:
         cmp bx,2000                     ;光标超出屏幕?滚屏
         jl .set_cursor

         mov ax,0xb800
         mov ds,ax
         mov es,ax
         cld
         mov si,0xa0
         mov di,0x00
         mov cx,1920
         rep movsw
         mov bx,3840                     ;清除屏幕最底一行
         mov cx,80
 .cls:
         mov word[es:bx],0x0720
         add bx,2
         loop .cls

         mov bx,1920

 .set_cursor:
         mov dx,0x3d4
         mov al,0x0e
         out dx,al
         mov dx,0x3d5
         mov al,bh
         out dx,al
         mov dx,0x3d4
         mov al,0x0f
         out dx,al
         mov dx,0x3d5
         mov al,bl
         out dx,al

         pop es
         pop ds
         pop dx
         pop cx
         pop bx
         pop ax

         ret

;-------------------------------------------------------------------------------
  start:
         ;初始执行时,DS和ES指向用户程序头部段
         mov ax,[stack_segment]           ;设置到用户程序自己的堆栈 
         mov ss,ax
         mov sp,stack_end
         
         mov ax,[data_1_segment]          ;设置到用户程序自己的数据段
         mov ds,ax

         mov bx,msg0
         call put_string                  ;显示第一段信息 

         push word [es:code_2_segment]
         mov ax,begin
         push ax                          ;可以直接push begin,80386+
         
         retf                             ;转移到代码段2执行 
         
  continue:
         mov ax,[es:data_2_segment]       ;段寄存器DS切换到数据段2 
         mov ds,ax
         
         mov bx,msg1
         call put_string                  ;显示第二段信息 

         jmp $ 

;===============================================================================
SECTION code_2 align=16 vstart=0          ;定义代码段216字节对齐)

  begin:
         push word [es:code_1_segment]
         mov ax,continue
         push ax                          ;可以直接push continue,80386+
         
         retf                             ;转移到代码段1接着执行 
         
;===============================================================================
SECTION data_1 align=16 vstart=0

    msg0 db '  This is NASM - the famous Netwide Assembler. '
         db 'Back at SourceForge and in intensive development! '
         db 'Get the current versions from http://www.nasm.us/.'
         db 0x0d,0x0a,0x0d,0x0a
         db '  Example code for calculate 1+2+...+1000:',0x0d,0x0a,0x0d,0x0a
         db '     xor dx,dx',0x0d,0x0a
         db '     xor ax,ax',0x0d,0x0a
         db '     xor cx,cx',0x0d,0x0a
         db '  @@:',0x0d,0x0a
         db '     inc cx',0x0d,0x0a
         db '     add ax,cx',0x0d,0x0a
         db '     adc dx,0',0x0d,0x0a
         db '     inc cx',0x0d,0x0a
         db '     cmp cx,1000',0x0d,0x0a
         db '     jle @@',0x0d,0x0a
         db '     ... ...(Some other codes)',0x0d,0x0a,0x0d,0x0a
         db 0

;===============================================================================
SECTION data_2 align=16 vstart=0

    msg1 db '  The above contents is written by LeeChung. '
         db '2011-05-06'
         db 0

;===============================================================================
SECTION stack align=16 vstart=0
           
         resb 256

stack_end:  

;===============================================================================
SECTION trail align=16
program_end:

2. 用户程序结构

NASM 编译器使用汇编指令 SECTION 或 SEGMENT 来定义段。align 用来指定对其方式。vstart 用于指定在在某一个段内的指令的汇编地址是从该段所在的段头开始计算而不是从程序的最开始的头开始计算。

段的汇编地址就是相对整个程序开头(0)的位置。可以使用一下表达式获取段的汇编地址。

section.段名称.start

如下图是用户程序的整体结构。
在这里插入图片描述

2.1 用户程序头部

浏览代码清单8-2,我们会发现我们的用户程序一共定义了7个段,分别是:第7行定义的header段,27行定义的code_1段,163行定义的code_2段,173行定义的data_1段,194行定义的data_2段,201行定义的stack段和208行定义的trail段。

一般情况下,加载器程序和用户程序是由不同的公司不同的人开发的。所以加载器与用户程序实际上彼此并不知道彼此长什么样。他们并不了解彼此的结构与功能。

但是加载器需要了解一些必要的信息一次来了解如何记载用户程序。

实际上,在用户程序的头部需要定义一个段来规定一个加载器和用户程序之间交流的协议。即代码清单8-2的第7-24行。

SECTION header vstart=0                     ;定义用户程序头部段 
    program_length  dd program_end          ;程序总长度[0x00]
    
    ;用户程序入口点
    code_entry:      dw start                ;偏移地址[0x04]
                    dd section.code_1.start ;段地址[0x06] 
    
    realloc_tbl_len dw (header_end-code_1_segment)/4
                                            ;段重定位表项个数[0x0a]
    
    ;段重定位表           
    code_1_segment  dd section.code_1.start ;[0x0c]
    code_2_segment  dd section.code_2.start ;[0x10]
    data_1_segment  dd section.data_1.start ;[0x14]
    data_2_segment  dd section.data_2.start ;[0x18]
    stack_segment   dd section.stack.start  ;[0x1c]
    
    header_end:  

如下图所示。
加载器与用户程序之间协议部分示意图
用户程序为了能够让加载器将自己加载到内存中去,起码要包含以下信息:

  1. 用户程序的尺寸,即以字节为单的大小,加载器根据该数值来决定来读取多少个逻辑扇区。代码清单 8-2中第8行,使用伪指令 dd 声明和初始化一个双字。程序的长度取自标号 program_end。
  2. 应用程序的入口点,包括短地址和偏移地址。代码清单 8-2中第11,12行,依次声明了入口点(Entry Point)的偏移地址和段地址。偏移地址取自代码段 code_1 中的标号 “start”,段地址是表达式 section.code_1.start 取得。
  3. 段重定位表,用户程序可能包含不知一个段。在加载到内存后,每个段的地址需要重新计算。段重定位是加载器的工作,它需要知道每个段在用户程序的位置,即它们分别位于用户程序的多少字节处
    。因此,需要在用户程序头部建立一张重定位表。代码清单8-2第14行,声明并出初始化段重定位表的项数。代码清单8-2第18-22行,一共有 5 个段,一个段为一个表项,每一个表项使用伪指令 dd 声明并初始化为一个双字。

3. 加载程序(器)的工作流程

加载器程序源码参考8-1。

3.1 初始化和决定加载位置

从大的角度来讲,加载器要加载一个用户程序,并使之开始执行,需要决定两件事。

  1. 看内存中那些地方是空闲的,即从哪个地方开始加载用户程序。
  2. 用户程序位置磁盘的什么位置,即它的起始逻辑扇区号是多少。

那么我们的加载器需要将用户程序加载到哪个位置呢?如下图所示。
可用于记载用户程序的空间范围
物理地址 0x0FFFF 以下是加载器以及栈的势力范围;物理地址 0xA0000 以上是 BIOS 以及外围设备的势力范围。那么中间 0x10000 - 0x9FFFF,差不多 500KB 的可用空间。

本程序将用户程序加载到了 0x10000 处。即代码 151 行。

phy_base dd 0x10000             ;用户程序被加载的物理起始地址

而用户程序是位于逻辑扇区 100 。代码清单 8-1 第6行,声明了一个常数。

app_lba_start equ 100           ;声明常数(用户程序起始逻辑扇区号)

3.2 加载用户程序

3.2.1 准备加载

在知道了用户程序的记载位置后,就可以进行加载了。

程序中将主引导扇区定义成一个段。代码清单8-1第9行。

SECTION mbr align=16 vstart=0x7c00       

其中使用了 vstart=0x7c00 字句,表示该段内的所有汇编偏移地址都从 0x7c00 处开始计算。否则,因为主引导扇区的实际加载位置是 0x0000:0x7c00,在使用标号时,还需要加上那个落差 0x7c00。

代码清单 8-1 第12-14行初始化了栈段寄存器 SS 和栈指针寄存器 SP。栈的短地址是 0x0000,长度是 64KB,栈指针在段内 0xFFFF - 0x0000 之间变化。

代码清单 8-1 第 16-21 行用于取得一个真实的物理地址。这个地址是用户程序的加载地址。并将DS和ES指向该地址的段地址,用于后期的操作。

mov ax,[cs:phy_base]            ;计算用于加载用户程序的逻辑段地址 
mov dx,[cs:phy_base+0x02]
mov bx,16        
div bx            
mov ds,ax                       ;令DS和ES指向该段以进行操作
mov es,ax

该地址保存在标号 phy_base 处的一个双字中,因此,将高16为存到 DX 中,低 16 位存放在 AX,再通过无符号除法指令得到 16 位的段地址(商在AX中)。

3.3.2 读取用户程序

加载器的下一个工作是从硬盘读取用户程序,说白了就是访问其他硬件。

这些可访问的硬件设备我们称其为输入输出(I/O)设备。外围设备和处理器之间的通信是通过端口(Port)实现的,端口在本质上就是一些寄存器,这些寄存器位于I/O接口电路中。

端口在不同的计算机系统中有不同的实现方式。端口一般有两种映射方式,一种是直接映射到内存地址空间的,比如显卡的显存。另一种是独立编址,不和内存发生关系。很明显,磁盘就是通过端口来访问的。

在 Intel 系统中,只有 65536 个端口号(0 - 65535)。因为是独立编址,所有需要使用 in 和 out 指令来访问端口。

in 指令是从端口读。

in al, dx

out 指令是写入端口。

out dx, al

下面来看硬盘的数据的读取。硬盘读写的基本单位是扇区,即至少读或写一个扇区数据。因此,主机和硬盘之间的数据交换是成块的。

从硬盘读写数据有两种方式。

  1. CHS 模式:向磁盘控制器发送 磁头号,柱面号和扇区号。
  2. LBA 模式:逻辑扇区模式。早期逻辑扇区编址方法是LBA28,即使用 28 位标识逻辑扇区,一共可以表示 128GB 的硬盘。现在一般是 LBA48,使用48位可表示 131072 TB 的硬盘容量。

本章使用 LBA28 来访问硬盘。在个人计算机中,ICH 芯片内部通常集成了俩个 PATA/SATA 接口,分别是主硬盘接口和副硬盘接口,主硬盘端口号0x1f0-0x1f7,副硬盘端口号0x170-0x177。

使用 LBA28 主硬盘读取数据的步骤:

  1. 设置要读取的扇区数量。写入 0x1f2 端口。该端口是一个 8 位端口,因此只能读写 255 个扇区,如果写入 0,表示要读取 256 个扇区。每读一个扇区,该数值自减1。

  2. 设置起始 LBA 扇区号。28 位扇区号需要分别写入端口 0x1f3,0x1f4,0x1f5,0x1f6 号。0x1f3:0-7位,0x1f4:8-15位,0x1f5:16-23位,0x1f6:24-27位。其中 0x1f6 的低 4 位用于存放逻辑扇区号的24-27位,第 4 位用于指示硬盘号,0 表示主盘,1 表示从盘。高 3 位是 “111”,表示 LBA 模式。入下图所示
    端口 0x1f6 各位的含义

  3. 向端口 0x1f7 写入0x20,请求硬盘读。

  4. 等待读写操作完成。端口 0x1f7 既是命令端口,又是状态端口。这一步的代码如下。and al,0x88 指令保留 第7位和第3位,使用 cmp al,0x08 指令比较是否硬盘已经准备好读写。

 		 mov dx,0x1f7
  .waits:
         in al,dx
         and al,0x88
         cmp al,0x08
         jnz .waits                      ;不忙,且硬盘已准备好数据传输 

端口 0x1f7 部分状态位的含义如下图所示。
端口 0x1f7 部分状态位的含义
5. 连续取出数据。0x1f0 是硬盘接口数据端口。还是一个 16 位端口。一旦硬盘空闲,且准备就绪。就就可以连续从这个端口写入或者读取数据。下面的从硬盘读取一个扇区,读取的数据存放到由段寄存器指定的数据段,偏移地址由 BX 指定。

         mov cx,256                      ;总共要读取的字数
         mov dx,0x1f0
  .readw:
         in ax,dx
         mov [bx],ax
         add bx,2
         loop .readw

主硬盘最后一个端口是 0x1f1,该端口是错误寄存器,包含硬盘驱动最后一次执行命令的状态(错位u原因)。

3.3.3 过程调用

读写硬盘是经常要做的事,因此将其功能封装成一个过程是一种合适的做法。处理器使用过程调用指令(call)转移到例程代码执行,再遇到过程返回指令时(ret,iret)重新返回调用处的下一条指令接着执行。下图是过程和过程调用的示意图。
过程和过程调用示意图
代码清单 8-1 的第 24-27行用于读取用户程序所在的第一个扇区的内容。其实现是调用 read_hard_disk_0 过程。该过程的实现是代码清单8-1的第79-131行。

在过程调用中,可能会需要参数传递,最简单的方式就是使用寄存器。本例程中,起始逻辑扇区号的高16位存放于DI中,低16位存放于SI中。并将读出来的的数据存放于有段寄存器 DS 指向的数据段,偏移地址在寄存器 BX 中。

在例程的开头,需要将在代码中使用到的寄存器先临时保存到中,并在返回到调用点之前出栈恢复。代码清单8-1:

  • 82-85行:将例程中使用到的寄存器入栈保存。
  • 87-89行:向 0x1f2 端口写入要读取的扇区数。
  • 91-101行:向硬盘接口写入起始逻辑扇区号的第24位。
  • 105行:al 的高4位是 0111B,低 4 位是逻辑扇区号的27-24位。
  • 118-124行:反复从硬盘接口读取 512 字节的数据,并传送到段寄存器 DS 指向的数据区中。
  • 126-129行:把调用过程前的各个寄存器内容中栈中恢复。
  • 131行:ret 指令返回调用过程 call 指令的下一条指令。

如下图是调用 read_hard_disk_0 例程前后的栈指针的变化。
过程调用前后的栈变化
读取完用户的最开始的 512 字节,其中包含了最开始的用户程序头部。如下图所示是用户程序头部的结构示意图:
用户程序头部结构示意图
代码清单8-1的 30-53 行根据头部读取用户程序剩余的内容。

  • 30-31行:整个程序的大小存放到 DX:AX 中。
  • 32-33行:除以 512,得到整个程序的扇区数。
  • 34行:判断是否除尽。没有初进,则转移到后面的代码,去读剩余的扇区,如果除尽了,扇区数减一,因为已经读取了一个扇区。
  • 44行:将用户程序剩余的扇区数传送到寄存器 CX,供后面的 loop 指令使用。
  • 46-48行:将当前数据段 DX 内容加上 0x20,以构造出下一个逻辑段,为从硬盘上读取下一个 512 字节数据做准备。
  • 50行:寄存器 BX 清零。作为数据段段内偏移地址。
  • 51行:每次读硬盘之前,将 SI 内容加一,指向下一个逻辑扇区。
  • 52-53行:循环调用例程 read_hard_disk_0 直到 CX 为零。

3.3.4 重定位用户程序

整个用户程序全部加载到内存之后。就需要整个用户程序进行重定位工作。实际上就是计算出每个段的真实物理段地址,并覆盖程序头部的各个段原来的汇编段地址。

由于用户程序的各个段的汇编地址是可以得出来的,所以我们可以计算各个段的长度。知道了各个段的长度,然后又知道用户程序在内存中的起始位置地址 phy_base。那么就可以很容易计算出各个段在内存中的地址。如下图所示:
段的偏移地址和它在内存中的物理地址
源程序58-62行重定位了用户程序的入口点的代码段。65-74行,重定位其他各个段。调用例程 calc_segment_base 实现:

calc_segment_base:                       ;计算16位段地址
                                         ;输入:DX:AX=32位物理地址
                                         ;返回:AX=16位段基地址 
         push dx                          
         
         add ax,[cs:phy_base]
         adc dx,[cs:phy_base+0x02]
         shr ax,4						; 实际物理段地址的低12位
         ror dx,4						; 实际物理段地址的高4位
         and dx,0xf000
         or ax,dx						; 实际的物理段地址
         
         pop dx
         
         ret

3.3.5 将控制权交给用户程序

用户程序在内存中准备就绪后,就可以将处理器的控制权交给它。代码清单 8-1 第 76 行,通过一个16位的间接绝对远转移指令 jmp far [0x04] 到用户程序入口处开始执行。

用户程序代码参考代码清单 8-2。

  • 137-139行:从头部取得用户程序自己栈段的段地址传送到 SS 中,并将标号 stack_end 所代表的数值传送到 SP 中。
  • 141-142行:取得数据段的段地址传送到段寄存器 DS 中。

例程 put_char 的工作流程图如下:
过程put_char的流程图
光标位置的设置通过操作索引寄存器间接访问。索引的寄存器的端口号是 0x3d4,两个 8 位的光标寄存器其索引值分别是 14(0x0e)和15(0x0f),分别用于提供光标位置的高8位和低8位。并通过数据端口 0x3d5 进行读写数据。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值