深入理解计算机系统 --- 异常控制流

在这里插入图片描述
现代系统通过使控制流发生突变 来对这些情况做出反应
一般而言,我们把这些突变称为 异常控制流(Exception Control Flow, ECF)

异常控制流发生在计算机系统的各个层次
在硬件层,硬件检测到的事件会出发控制突然转移到异常处理程序
在操作系统层,内核通过上下文切换从一个用户进程转移到另一个用户进程
在应用层,一个进程可以发送信号到另一个进程,而接收者会将控制突然转移到它的一个信号处理程序
一个程序可以通过回避通常的栈规则,并执行到其他函数中任意位置的非本地跳转来对错误做出反应
在这里插入图片描述
在这里插入图片描述
本章的重要性在于你将开始学习应用是如何与操作系统交互的
这些交互都是围绕着ECF的

8. 异常控制流

8.1 异常

本节目的:对异常和异常处理有一个一般性的了解,并揭示现代计算机系统的一个经常令人感到迷惑的方面

异常时一场控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现
它们有一部分是由硬件实现的,所以具体细节将随系统的不同而有所不同
然而,对于每个系统而言,基本的思想都是相同的

在这里插入图片描述
异常( exception )就是控制流中的突变,用来响应处理器状态中的某些变化

当处理器状态中发生了一个重要的变化时,处理器正在执行某个当前指令 I curr
在处理器中,状态被编码为不同的位和信号
状态变化称为事件( event )
事件可能和当前指令的执行直接相关
比如,发生虚拟内存缺页,算术溢出,或一条指令试图除以零 等等
另一方面,事件也可能和当前指令的执行没有关系
比如,一个系统定时器产生信号或者一个I/O请求完成

在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做 异常表(exception table)
的跳转表,进行一个间接过程调用( 异常 ),到一个专门设计用来处理这类事件的操作系统子程序( 异常处理程序( exception handler ) )
当异常处理程序完成处理后,根据引起异常的事件类型,会发生一下3种情况:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

8.1.1 异常处理

处理异常需要硬件和软件的紧密合作

系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号( exception number )
其中一些号码是由处理器的设计者分配的,其他号码是由操作系统内核( 操作系统常驻内存的部分 )的设计者分配的
前者包括的示例 被零除、缺页、内存访问违例、断点以及算术运算溢出
后者的示例包括 系统调用和来自外部I/O设备的信号
在这里插入图片描述
系统启动时( 当计算机重启或者加电时 ),操作系统分配和初始化一张称为 异常表的跳转表
使得表目K 包含 异常 K 的处理程序的地址

在运行时(当系统在执行某个程序时),处理器检测到发生了一个事件,并且确定了相应的异常号 k
随后,处理器出发异常,方法是执行间接过程调用,通过异常表的条目k,转到相应的处理程序
在这里插入图片描述
上图展示了处理器如何使用异常表来形成适当的异常处理程序的地址
异常号是到异常表中的索引,异常表的其实地址放在一个叫做 异常表基址寄存器( exception table base register ) 的特殊CPU寄存器中

在这里插入图片描述
一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成
在处理程序处理完事件之后,它通过执行一条特殊的从中断返回指令
可选地返回到被中断的程序,该指令将适当的状态弹回到处理器的控制和数据寄存器中
如果异常中断的是一个用户程序,就将状态恢复为用户模式,然后将控制返回给被中断程序

8.1.2 异常的分类

异常可以分为四类: 中断( interrupt )、陷阱( trap )、故障( fault )和终止( abort )
在这里插入图片描述
1.中断
中断是异步发生的,是来自处理器外部的I/O设备的信号的结果
硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的
硬件中断的异常处理程序常常称为 中断处理程序( interrupt handler )
在这里插入图片描述

在这里插入图片描述
在当前指令完成执行之后,处理器注意到中断引脚的电压变高了就从系统总线读取异常号,然后调用适当的中断处理程序
在处理程序返回时,它就将控制返回给下一条指令( 也即如果没有发生中断,在控制流中会在当前指令之后的那条指令 )

剩下的异常类型( 陷阱、故障、终止) 是同步发生的,是执行当前指令的结果
这类指令叫做故障指令( faulting instruction )

2.陷阱和系统调用
陷阱是有意的异常,是执行一条指令的结果
就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令
陷阱最重要的用途是在程序和内核之间提供一个像过程一样的接口,叫做系统调用

用户程序经常需要向 内核请求服务,比如读一个文件(read)、创建一个新的进程(fork)、加载一个新的程序(execve),或者终止当前进程(exit)
为了允许对这些内核服务的受控的访问,处理器提供了一条特殊的 “syscall n” 指令,当用户进程想要请求服务 n 时,可以执行这条指令
执行syscall 指令会导致一个到异常处理程序的陷阱
这个处理程序解析参数,并调用适当的内核程序

在这里插入图片描述
从程序员的角度来看,系统调用和普通的函数调用是一样的
然而,他们的实现非常不同
普通的函数运行在用户模式中,用户模式限制了函数可以执行的指令的类型
而且它们只能访问与调用函数相同的栈

系统调用运行在内核模式中,内核模式允许系统调用执行特权指令,并访问定义在内核中的栈

3.故障
故障由错误情况引起,它可能能够被故障处理程序修正
当故障发生时,处理器将控制转移给故障处理程序,如果处理程序能够修正这个错误,它就将控制返回到引起故障的指令,从而重新执行它
否则,处理器返回到内核中的abort例程,abort例程会终止引起故障的应用程序
在这里插入图片描述
一个经典的故障示例就是缺页异常,当指令引用一个虚拟地址,而该地址相对应的物理页面不在内存中,因此必须从磁盘中取出来,就发生故障
缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令
当指令再次执行时,相应的物理页面已经驻留在内存中了,指令就可以没有故障地运行完成了

4.终止
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误
在这里插入图片描述

8.1.3 Linux/x86-64系统中的异常
有高达256种不同的异常类型
0~31的号码对应的是由Intel架构师定义的异常,因此对任何x86-64系统都是一样的
32~255的号码对应的是操作系统定义的中断和陷阱
在这里插入图片描述
1.Linux/x86-64故障和终止
除法错误,当应用程序试图除以零时,或者当一个除法指令的结果对于目标操作数来说太大了的时候,就会发生除法错误( 异常0 )
Unix不会试图从除法错误中恢复,而是选择终止程序

一般保护故障,许多原因都会导致不为人知的一般保护故障( 异常13 )
通常是因为一个程序引用了一个未定义的虚拟内存区域,或者因为程序试图写一个只读的文本段,Linux不会试图恢复这类故障

缺页( 异常14 )是会重新执行产生故障的指令的一个异常示例
处理程序将适当的磁盘上虚拟内存的一个页面映射到物理内存的一个页面,然后重新执行这条产生故障的指令

机器检查( 异常18 ),机器检查是在导致故障的指令执行中检测到致命的硬件错误时发生的
机器检查处理程序从不返回控制给应用程序

2.Linux/x86-64系统调用
在这里插入图片描述
Linux提供几百种系统调用,当应用程序想要请求内核服务时可以使用
包括读文件、写文件、创建一个新进程

上图展示了一些常见的Linux系统调用,每个系统调用都有一个唯一的整数号,对应于一个到内核中跳转表的偏移量( 这个跳转表和异常表不一样
在这里插入图片描述
在x86-64系统上,系统调用时通过一条称为 syscall陷阱指令来提供的

研究程序能够 如何 使用这条指令来直接调用Linux系统调用 是很有趣的

所有到Linux系统调用的参数是通过通用寄存器而不是栈传递的
按照惯例,寄存器 %rax 包含系统调用号
寄存器 %rdi %rsi %rdx %r10 %r8 %r9 包含最多6个参数
从系统调用返回时,寄存器%rcx %r11 都会被破坏
%rax 包含返回值, -4095 到 -1 之间的负数返回值表明发生了错误,对应于负的error

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

8.2 进程

异常是允许操作系统内核提供进程( process )概念的基本构造块

在现代系统上运行一个程序时,会得到一个假象
好像我们的程序是系统中当前运行的唯一的程序一样
我们的程序好像独占地使用处理器和内存
处理器就好像无间断地一条接一条地执行我们程序中的指令
最后,我们程序中的代码和数据好像是系统内存中唯一的对象
这些假象都是通过进程的概念提供给我们的

进程的经典定义就是一个执行中程序的实例
系统中每个程序都运行在某个进程的 上下文( context ) 中
上下文是由程序正确运行所需的状态组成
这个状态包括放在内存中的程序的代码和数据、栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合

每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件
应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他程序
在这里插入图片描述

8.2.1 逻辑控制流

即使在系统中通常有许多其他程序在运行,进程也可以向每个程序提供一种假象
好像它在独占地使用处理器

如果想调试单步执行程序,我们会看到一系列的程序计数器(PC)的值
这些值唯一地对应于包含在程序的可执行目标文件中的指令
或是包含在运行时动态链接到程序共享对象中的指令
这个PC值的序列叫做逻辑控制流,简称 逻辑流
在这里插入图片描述
上图,考虑一个运行着三个进程的系统

处理器的一个物理控制流被分成了三个逻辑流,每个进程一个
每个竖直的条表示一个进程逻辑流的一部分

在这个例子中,三个逻辑流的执行是交错
进程A执行了一会儿,然后是进程B开始运行到完成,然后进程C执行了一会儿,进程A接着运行到完成,最后进程C可以运行到结束了

上图的关键点在于进程是轮流使用处理器的
每个进程执行它的流的一部分,然后被抢占( preempted )( 暂时挂起 ),然后轮到其他进程
对于一个运行在这些进程之一的上下文中的程序,它看上去就像是在独占地使用处理器

如果我们精确地测量每条指令使用的时间,会发现在程序中一些指令的执行之间
CPU好像会周期性地停顿,然而,每次处理器停顿,它随后会继续执行我们的程序,并不改变程序内存位置或寄存器的内容

8.2.2 并发流

计算机系统中逻辑流有许多不同的形式,异常处理程序、进程、信号处理程序、线程、java进程等 都是逻辑流的例子

一个逻辑流在时间上与另一个流重叠,称为 并发流( concurrent flow )
这两个流被称为 并发地运行,流X和Y互相并发,仅当X在Y开始之后和Y结束之前开始
或者Y在X开始之后和X结束之前开始

多个流并发地执行的一般现象称为 并发( concurrency ),一个进程和其他进程轮流运行的概念称为 多任务( multitasking )
一个进程执行它的控制流的一部分的每一时间段叫做 时间片( time slice )
因此,多任务也叫做时间分片( time slicing ),例如上图进程A由两个时间片组成
在这里插入图片描述

8.2.3 私有地址空间

进程也为每个程序提供一种假象,好像它独占地使用系统地址空间

进程为每个程序提供它自己的私有地址空间,和这个空间中某个地址相关联的那个内存字节不能被其他进程读或者写,这个地址空间是私有的

在这里插入图片描述
尽管和每个私有地址空间相关联的内存的内容一般是不同的,但每个这样的空间都有相同的通用结构

上图展示了一个x86-64Linux进程的地址空间的组织结构

在这里插入图片描述

8.2.4 用户模式和内核模式

为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制
限制一个应用可以执行的指令以及访问的地址空间范围

处理器通常是用**某个控制寄存器中的一个 模式位( mode bit )**来提供这种功能,该寄存器描述了进程当前享有的特权,当设置了模式位时,进程就运行在内核模式中( 超级用户模式 )
一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置

没有设置模式位时,进程就运行在用户模式中
用户模式中的进程不允许执行特权指令( privileged instruction )
比如停止处理器、改变模式位、发起一个I/O操作
也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据
任何这样的尝试,都会导致致命的保护故障
反之,用户程序必须通过系统调用接口间接地访问内核代码和数据

Linux提供了一种机制,叫做 /proc 文件系统,它允许用户模式进程访问内核数据结构的内容
/porc 文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件层次结构
2.6版本的Linux内核引入 /sys 文件系统,它输出关于系统总线和设备的额外底层信息

8.2.5 上下文切换

操作系统内核使用一种称为上下文切换( context switch )的较高层形式的异常控制流来实现多任务
上下文切换机制是建立在 8.1 节讨论过的那些较低层异常机制上的

内核为每个进程维持一个上下文( context )
上下文就是内核重新启动一个被抢占式的进程所需的状态
它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表

在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始先前被抢占了的进程
这种决策就叫做调度( scheduling ),是由内核中称为调度器( scheduler )的代码处理的

当内核选择一个新的进程运行时,我们说内核调度了这个进程
在内核调度了一个新的进程后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程

上下文切换
1.保存当前进程的上下文
2.恢复某个先前被抢占的进程被保存的上下文
3.将控制传递给这个新恢复的进程

在这里插入图片描述
在这里插入图片描述
上图展示了一对进程A和B上下文切换的示例

在这里插入图片描述

8.4 进程控制

Unix 提供了大量从C程序中操作进程的系统调用

8.4.1 获取进程ID

每个进程都有一个唯一的正数( 非零 )进程ID( PID )
Getpid 函数返回调用进程的PID
Getppid 函数返回它的父进程PID ( 创建调用进程的进程 )
在这里插入图片描述
上面两个函数返回一个类型为pid_t 的整数值,Linux系统上它在 types.h 中被定义为int

8.4.2 创建和终止进程

在这里插入图片描述
在这里插入图片描述
Exit函数以status退出状态来终止进程 ( 另一种设置退出状态的方法是从主程序中返回一个整数值 )

父进程通过调用fork函数创建一个新的运行的子进程
在这里插入图片描述
新创建的子进程几乎但不完全与父进程相同
子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本
包括代码和数据、堆、共享库以及用户栈
子进程还获得与父进程任何打开文件描述符相同的副本,意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件
父进程和新创建的子进程之间最大的区别在于他们有不同的PID

在这里插入图片描述
在这里插入图片描述
上图展示了一个使用fork创建子进程的父进程

当fork调用在第6行返回时,在父进程和子进程中x的值都为1
子进程在第8行加一并输出它的x副本
父进程在第13行减一并输出它的x的副本
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
上图展示了前面示例程序的进程图

初始时,父进程将变量x设置为1,父进程调用fork 创建一个子进程,他在自己的私有空间中与父进程并发执行

嵌套:
在这里插入图片描述

8.4.3 回收子进程

当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除
相反,进程在一种已终止的状态中,直到被它的父进程 回收( reaped )
当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,从此刻开始,该进程就不存在了

一个终止了但还未被回收的进程称为 僵死进程( zombie )
在这里插入图片描述
如果一个父进程终止了,内核会安排init进程成为它的孤儿进程的养父
Init进程的PID为1,是在系统启动时由内核创建的,它不会终止,是所有进程的养父
即使僵死子进程没有运行,他们仍然消耗系统的内存资源

一个进程可以通过调用waitpid函数来等待它的子进程终止或者停止
在这里插入图片描述
在这里插入图片描述
1.判定等待集合的成员
等待集合的成员是由参数pid来确定的
在这里插入图片描述
2.修改默认行为
可以通过将options设置为常量 WNOHANG、WUNTRACED、WCONTINUED
各种组合来修改默认行为
在这里插入图片描述
3.检查已回收子进程的退出状态
如果statusp参数是非空的,那么waitpid就会在status中放上关于导致返回的子程序的状态信息,status是statusp指向的值,wait.h 头文件定义了解释status参数的几个宏
在这里插入图片描述
4.错误条件
如果调用进程没有子进程,那么waitpid返回-1,并且设置errno为ECHILD
如果waitpid函数被一个信号中断,那么它返回-1,并设置errno为EINTR
在这里插入图片描述
在这里插入图片描述
5.wait函数
Wit函数是waitoid函数的简单版本
在这里插入图片描述
调用wiat(&status)等价于调用waitpid(-1, &status, 0)

8.4.4 让进程休眠

Sleep函数将一个进程挂起一段时间
在这里插入图片描述
如果请求的时间量已经到了,sleep返回0,否则返回还剩下的要休眠的秒数
后一种情况是可能的,如果因为sleep函数被一个信号中断而过早地返回

另一个很有用的函数是pause函数,该函数让调用函数休眠,直到该进程收到一个信号
在这里插入图片描述

8.4.5 加载并运行程序

Execve函数在当前进程的上下文中加载并运行一个新程序
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
参数列表由上图的数据结构表示的
Argv[0]变量指向一个以null结尾的指针数组,其中每个指针都指向一个参数字符串
按照惯例,argv[0]是可执行目标文件的名字

在这里插入图片描述
环境变量的列表是由上图数据结构表示的,和参数列表类似
其中每个指针指向一个环境变量字符串,每个串都是形如 “name=value” 的 名字-值

在execve加载了filename之后,启动代码 (__libc_start_main)(7.9章节描述)设置栈,并将控制传递给新程序的主函数,该主函数有如下形式的原型:

在这里插入图片描述
在这里插入图片描述
Main函数执行时,用户栈的组织结构 上图展示

从 栈底(高地址) 往 栈顶(低地址) 依次看一看
首先是参数和环境字符串,栈往上紧随其后是以null结尾的指针数组
其中每个指针都指向栈中的一个环境变量字符串
全局变量 environ 指向这些指针中的第一个 envp[0]
紧随环境变量数组之后的是以null结尾的argv[ ] 数组,其中每个元素都指向栈中的一个参数字符串,在栈的顶部是系统启动函数 libc_start_main的栈帧

Main函数有3个参数:

  1. argc,它给出argv[ ]数组中非空指针的数量
    2.argv,指向argv[ ]数组中的第一个条目
    3.envp,指向envp[ ]数组中的第一个条目

Linux提供了几个函数来操作环境数组:
在这里插入图片描述
在这里插入图片描述

8.4.6 利用fork和execve运行程序

Shell是一个交互型的应用级程序,它代表用户运行其他程序

展示一个简单shell的main例程
Shell打印一个命令行提示符,等待用户在stdin上输入命令行,然后对这个命令行求值
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

8.5 信号

到目前为止,已经看到了软件和硬件是如何合作以及提供基本的底层异常机制的
也看到了操作系统如何利用异常来支持上下文切换的异常控制流形式

Linux信号,它允许进程和内核中断其他进程

在这里插入图片描述
上图展示了Linux系统上支持的30种不同类型的信号

一个信号就是一条小消息它通知进程系统中发生了一个某种类型的事件
每种信号类型对应于某种系统事件,底层的硬件异常是由内核异常处理程序处理的
正常情况下,对用户进程而言是不可见的

信号提供了一种机制,通知用户进程发生了这些异常
在这里插入图片描述

8.5.1 信号术语

传送一个信号到目的的进程由两种不同步骤组成的:
在这里插入图片描述
在这里插入图片描述

一个发出而没有被接收的信号叫做 待处理信号( pending signal )
在任何时刻,一种类型至多只会有一个待处理信号
如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待,它们只是被简单的丢弃

一个进程可以有选择性地阻塞接收某种信号,当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这个信号的阻塞

一个待处理信号最多只能被接收一次。内核为每个进程在pending位向量中维护着待处理信号的集合,而在blocked位向量中维护着被阻塞的信号集合
只要传送了一个类型为k的信号,内核就会设置pending中的第k位,而只要接收了一个类型为k的信号,内核就会清除pending中的第k位

##8.5.2 发送信号
Unix系统提供了大量向进程发送信号的机制,所有这些机制都是基于进程组( process group )
这个概念的

1.进程组
每个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的
Getpgrp函数返回当前进程的进程组ID
在这里插入图片描述
默认地,一个子进程和它的父进程同属于一个进程组

一个进程可以通过使用 setpgid函数来改变自己或者其他进程的进程组
在这里插入图片描述
Setpgid函数将进程pid的进程组改为pgid,如果pid是0,那么久使用当前进程的PID,如果pgid是0,那么就用pid指定的进程的pid做为进程组ID

2.用 /bin/kill 程序发送信号
在这里插入图片描述

3.从键盘发送信号

Unix shell 使用作业( job) 这个抽象概念来表示为 对一条命令行求值而创建的进程
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

4.用kill函数发送信号
进程通过调用kill函数发送信号给其他进程( 包括它们自己 )
在这里插入图片描述
如果pid大于零,那么kill函数发送信号号码sig给进程pid
如果pid等于零,那么kill发送信号sig给调用进程所在进程组中的每个进程,包括自己
如果pid小于零,kill发送信号sig给进程组 pid的绝对值 中的每个进程
在这里插入图片描述
上图展示了 父进程用kill函数发送SIGKILL信号给它的子进程

5.用alarm函数发送信号
进程可以通过调用alarm函数向它自己发送SIGALRM信号
在这里插入图片描述
Alarm函数安排内核在secs秒后发送一个SIGALRM信号给调用进程

8.5.3 接收信号

在这里插入图片描述
进程可以通过使用signal函数修改和信号相关联的默认行为,唯一的例外就是SIGSTOP SIGKILL 它们的默认行为是不能修改的
在这里插入图片描述
在这里插入图片描述
当一个进程捕获了一个类型为k的信号时,会调用为信号k设置的处理程序,一个整数参数被设置为k,这个参数允许同一处理函数捕获不同类型的信号

在这里插入图片描述
上图展示了 捕获用户在键盘上输入Ctrl + C时发送的SIGINT信号
默认行为是立即终止该进程,我们将默认行为修改为捕获信号,输出一条信息,然后终止

信号处理程序可以被其他信号处理程序中断
在这里插入图片描述

8.5.4 阻塞和接触阻塞信号

Linux 提供阻塞信号的隐式和显式的机制
在这里插入图片描述
在这里插入图片描述

8.6 非本地跳转

C语言提供了一种用户级异常控制流形式,称为非本地跳转( nonlocal jump )
它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用-返回序列

非本地跳转是通过setjmp和longjmp函数来提供的
在这里插入图片描述
Setjmp函数在env缓冲区中保存当前调用环境,以供后面的longjmp使用,并返回0
调用环境包括程序计数器、栈指针和通用目的寄存器
在这里插入图片描述
Longjmp函数从env缓冲区中恢复调用环境

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

8.7 操作进程的工具

在这里插入图片描述

小结

在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值