CSAPP|task03|程序的机器级表示

程序的机器级表示

3.1程序的机器级表示|

重要的前言
本书使用的汇编语法是linux下AT&T的汇编语法格式
Windows下采用的是Intel汇编语法格式
二者的主要区别

  1. 指令操作数的赋值方向是不同的
    Intel:第一个是目的操作数,第二个是源操作数
    AT&T:第一个是源操作数,第二个是目的操作数

  2. 指令前缀
    AT&T:寄存器前边要加上%。立即数前边要加上$
    Intel:没有这方面的要求

编译系统

Intel :16->32->64

3.1.1演示程序

main.c

#include<stdio.c>
void mulstore(long,long *);
int main(){
long d;
multstore(2,3,&d);
printf("2* 3-->%1d \n",d);
return 0;
}

mstore.c

long mult2(long a,long b){
png s=a*b;
return s;
}
3.1.2编译order
linux>gcc -Og -o prog main.c mstore.c

编译选项-Og是用来告诉编译器生成符合原始C代码整体结构的机器代码,在实际项目中,为了获得更高的性能,会使用-O1或者-O2,甚至更高的编译优化选项,但是使用高级别的优化产生的代码会严重变形,导致产生的机器代码与源代码之间的关系难以理解,这里为了理解方便选择-Og这个优化选项

-o 后面跟的参数prog表示生成可执行文件的文件名

3.1.3生成汇编文件

方法一:生成汇编文件mstore.s并用vim打开

linux>gcc -Og -S mstore.c

方法二:

gcc -Og -c mstore.c
//生成目标代码mstore.o
objdump -d-mstore.o
//对二进制文件进行反汇编,将其转换为对应的汇编语言
//objdump可以将机器代码进行反汇编为汇编语言
objdump -d prog
//对这个可执行文件进行反汇编

得到
在这里插入图片描述
.开头的行都是指导汇编器和链接器工作的伪指令
剩余汇编代码与源文件中代码是相关的

删除之后
在这里插入图片描述

pushq :将寄存器rbx的值压入程序栈进行保存
为什么程序一开始要保存rbx的内容,看下面的调用者保护和被调用者保护

在Intel x86-64的处理器中包含了16个通用目的寄存器,这些寄存器用来存放整数数据和指针

在这里插入图片描述
它们的名字都是以%r开头的

3.1.4 两个保存器概念
3.1.4.1 调用者和被调用者

在这里插入图片描述
调用了函数B,寄存器rbx在函数B中被修改,逻辑上寄存器rbx的内容在调用B的前后应该保持一致

3.1.4.2 调用者保存
  • A调用函数B之前,提前保存寄存器 rbx的内容,执行完函数B之后,再恢复寄存器rbx原来存储的内容,就是调用者保存

在这里插入图片描述

3.1.4.3 被调用者保存
  • 函数B在使用寄存器rbx之前先保存寄存器rbx的,在函数B返回之前,先恢复寄存器rbx原来存储的内容,就是被调用者保存

在这里插入图片描述

3.1.5 汇编拆解

具体使用哪一种策略,不同的寄存器被定义成不同的策略
Callee被调用
Caller调用
在这里插入图片描述

3.1.5.1GAS汇编指令知识补充

word 16位类型
32bit 双字
64bit 4字
:GAS 汇编指令通常以字母“b”、“s”、“w”、“l”、“q”或“t”来确定操作数的大小。

b = 字节(8 位)
s = 短(16 位整数)或单(32 位浮点)
w = 字(16 位)
l = long(32 位整数或 64 位浮点数)
q = 四(64 位)
t = 十字节(80 位浮点数)

b代表1个字节;w代表1个字,2个字节;l代表2个字,4个字节;q代表4个字,8个字节

push: push 将一个值写入堆栈
call:函数调用
ret就是函数返回
(%rax)。只要是有括号的了,那就是内存引用,意思是取寄存器%rax中的存的地址中的存的值
mov命令中,两个操作数只允许有一个内存引用,即只能有一个带括号的
四字长寄存器名字开头都是r开头的
在这里插入图片描述
在这里插入图片描述

这里图片和部分资料引用

https://blog.csdn.net/anlian523/article/details/83997464


GCC数据传送指令四个变种:
movb,movw,movl以及movq
其中movb是move byte的缩写.表示传送字节
在这里插入图片描述


根据寄存器用法的定义
函数mulstore的三个参数分别保存在rdi,rsi和rdx中
寄存器rbx与rdx中的内容一致,都是dest指针所指向的内存地址。
movq指令的后缀“q”表示数据的大小
q表示四字
rsp保存程序栈的结束位置

  • call指令对应C代码中的函数调用,该函数的返回值会保存到寄存器rax中,因此寄存器rax中保存了x和y的乘积结果

  • 下一条指令将寄存器rax的值送到内存中,内存的地址就存放在寄存器rbx中。

  • ret就是函数返回

  • 寄存器rbx:被调用者保存寄存器
    pushq:保存寄存器rbx的内容
    pop:函数返回之前,恢复rbx的内容
    movq %rdx,%rbx 将寄存器rbx的内容复制到寄存器rdx

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

3.1.6 生成机器代码文件

我们需要将编译选项-S替换成-c ,就可将上述文件翻译成机器代码

linux> gcc -Og -c mstore.c

执行这条命令,即可生产mstore.c对应的机器代码文件 mstore.o
由于该文件是二进制格式的,无法直接查看。
借助反汇编工具objdump 汇编器将汇编代码翻译成二进制的机器代码,反汇编器就是机器代码翻译成汇编代码

linux> objdump -d mstore.o

在这里插入图片描述

通过对比反汇编得到的汇编代码与编译器直接产生的汇编代码,可以发现二者存在细微的差异
左边是反汇编生成的,右边是编译器直接产生的汇编代码

在这里插入图片描述
反汇编代码
省略了很多指令的后缀的“q”
在call和ret添加后缀"q"
由于q只是表示大小指示符,大多情况下时刻是可以省略的


3.1.7 寄存器的发展史

最早的8086处理器中包含8个16位通用寄存器

  • 每个寄存器都有特殊的功能,它们的名字反映了不同的用途,当处理器从16位扩展到32位的时候,寄存器的位数也随之扩展到了32位
  • 在这里插入图片描述

直到今天,原来8个16位寄存器已经扩展成了64位,此外还增加了8个新的寄存器

在这里插入图片描述

3.1.8 寄存器现状

在一般的程序中,不同的寄存器扮演着不同的角色,相应的程序规范规定了如何使用这些寄存器。
例如:
寄存器rax来保存函数的返回值
寄存器rsp用来保存程序栈的结束位置
还有6个寄存器可以用来传递函数参数

在这里插入图片描述

在这里插入图片描述


3.1.9 指令相关
  1. 指令包含两部分:操作码和操作数
    movq,addq,subq 操作码,操作码决定了CPU执行操作的类型,操作码之后的这部分是操作数

  2. 大多数指令具有一个或者多个操作数
    ret返回指令没有操作数

操作符有哪些?

3.1.10 操作数指示符
  1. 立即数:立即数以$符号开头,后面跟一个C定义的整数
  2. 寄存器:操作数是寄存器的情况,64可以用64位和小于64位的
  3. 内存引用: I m m ( r b , r i , s ) Imm(r_b,r_i,s) Imm(rb,ri,s)寄存器带小括号表示内存引用。我们通常将内存抽象成一个字节数组
    需要从内存中存取数据的时候,需要获得目的数据的起始地址 addr,以及数据长度 b
    为了简便,通常会省略下标b

在这里插入图片描述


有效地址:立即数和基址寄存器 r b r_b rb的值相加,再加上变址寄存器 r i r_i ri与比例因子(s)的乘积
I m m ( r b , r i , s ) → I m m + R [ r b ] + R [ r i ] ⋅ s Imm(r_b, r_i, s) → Imm + R[r_b] + R[r_i] · s Imm(rb,ri,s)Imm+R[rb]+R[ri]s

比例因子s的取值必须是1、2、4或者8
实际上比例因子的取值是与源代码中定义的数组类型的是相关的
编译器会根据数组的类型来确定比例因子的值
char:1
int:4
double:8


内存引用

不带$符号的立即数和带了括号的寄存器
在这里插入图片描述

3.1.11mov类指令

mov S D refersto: D ← S D \leftarrow S DS

mov 源操作数目的操作数

  1. 源操作数
    立即数,寄存器,内存引用
  2. 目的操作数
    寄存器,内存引用

mov指令的后缀与寄存器的大小一定得是匹配的

mov指令还有几种特殊情况
movq $Imm%rax
movq指令的源操作数是立即数时,该立即数只能是32位的补码表示,对该数符号位扩展后,将得到的64位数传送到目的位置

在这里插入图片描述

mov指令
这里引入一个新的指令movabsq
mov与movabsq之间的区别

  1. 如果movq的 源参数 如果为立即数,只能是32位的补码,需要对改32位进行符号位扩展到64位。
  2. 源操作数可以是任意的64位立即数
    目的操作数只能是寄存器

movabsq $0x0011223344556677,%rax

此时寄存器rax内保存的数值如图所示
在这里插入图片描述


MOV指令
接下来,使用movb指令将立即数-1复制到寄存器al,寄存器al的长度为8,与movb指令所操作的数据大小一致

movb $-1,%ax
此时寄存器rax的低8位发生了改变

AX{AH(8bit),AL(8bit)}(16bit)


movw $-1,%ax

此时寄存器rax的低16位发生了改变
在这里插入图片描述
movl(move 4Byte)
movl将立即数-1复制到寄存器eax(32)时候,此时寄存器rax(64)不仅仅是低32位发生了变化,高32位也发生了变化
mov $-1,%eax
在这里插入图片描述
当movl目的操作数是寄存器的时候,它会把寄存器的高4字节设置为0


movabsq:64位数字写入(8Byte)
movb(move8bit):改变8bit(-1->FF)(1Byte)
movw(move2Byte)16bit(2Byte)
movl(4Byte):最低4字节长度,只有当目的操作数为寄存器时将前面4字节全部变为0,x86-64规定任何位寄存器生成32位值的指令都会把该寄存器的高位部分置为0
movq(8Byte):改变了最后4位部分为F,剩下的是符号拓展 (-1.前4位符号拓展二进制全为1,16进制下都是F)


3.1.12零扩展数据传送指令movz

源操作数的数位小于目的操作数

  1. 需要对目的操作数剩余的字节进行零扩展或者符号位扩展
  2. 零扩展数据传送指令有5条,其中字符z是zero的缩写,指令最后两个字符都是大小指示符
    第第一个字母表示目的操作数的大小,第二个字符表示源操作数的大小

在这里插入图片描述


3.1.13符号位扩展传送指令movs

符号位扩展传送指令有6条,其中字符s是sign的缩写,同样指令最后的两个字符也是大小指示符

在这里插入图片描述

对比零扩展和符号扩展,符号扩展比零扩展多一条4字节到8字节的扩展指令

符号位扩展还有一条特殊指令cltq
该指令的源操作数总是寄存器eax,目的操作数总是寄存器rax

cltq指令效果与图中这条指令的效果一致,只不过编码更加紧凑一些
cltq movslq %eax,%rax


3.1.14 数据传送指令

寄存器是CPU内的一种数据存储部件
在程序执行过程中CPU和内存之间需要进行频繁的数据存取
c=a+b
CPU首先执行数据传送指令将a和b的值从内存中读取到寄存器内
在这里插入图片描述

寄存器rax的大小是64个比特位(8个字节),如果变量a是long类型,需要占用8个字节(rax),因此寄存器rax全部的数据位都用来保存变量a
int 4字节 低32位(eax)
short 2字节 低16位(ax)
char 1字节(al)

在这里插入图片描述


处理器在完成加法运算之后,再通过一条数据传送指令将计算结果保存到内存

3.1.14.1 数据传送example
int main(){
long a = 4;
long b = exchange(&a,3);
printf("a = %1d, b = %1d\n",a,b);
return 0;
}
long exchange(long *xp,long y){
long x = *xp;
*xp = y;
return x;
}
3.1.13.2 exchange实现

变量a被替换成3
变量b保存变量a原来的值4
重点看函数exchange所对应的汇编指令
exchange由3条指令实现:两条传送指令和一条返回指令
寄存器rdi:保存函数传递的第一个参数
寄存器rsi:保存函数传递的第二个参数
rdi保存了xp的值
rsi保存了y的值

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

第一条mov指令从内存读取数值到寄存器rdi中
对应于代码的long x =*xp
由于最后函数exchange需要返回变量x的值
所以这里将变量x放到寄存器rax
第二条mov指令将变量y的值写到内存里,变量y存储在寄存器rsi中,内存地址保存在寄存器rdi中,也就是xp指向的内存位置,这条指令对应函数exchange中的*xp=y


读端口用IN指令,写端口用OUT指令
 IN AL,21H;表示从21H端口读取一字节数据到AL
  IN AX,21H;表示从端口地址21H读取1字节数据到AL,从端口地址22H读取1字节到AH


还有两个数据传送指令需要借助程序栈,程序栈本质上是内存中的一个区域,栈的增长方向是从高地址低地址
栈顶的元素是所有栈中元素地址中最低的
栈顶在图的底部,栈底在顶部

我们需要保存寄存器rax内存储的数据0x123,可以使用pushq指令把数据压入栈内,
pushq指令执行的过程可以分解成为两步

  1. 指向栈顶的寄存器rsp进行一个减法操作,在压栈之前,栈顶指针rsp指向栈顶的位置,此处的内存地址是 0x108;
    压栈第一步就是寄存器rsp的值-8(8bit,栈向低地址增长)
    此时指向的内存地址是0x100

在这里插入图片描述

  1. 将需要保存的数据复制到新的栈顶地址(0x100)
    此时内存0x100将保存寄存器rax内存储的数据0x123
    在这里插入图片描述

pushq的指令可以拆分为两条指令
等效于图中的subq和movq两条指令
区别:
pushq:一个字节
subq,movq:8个字节

push(+8,写入)指令的本质还是将数据写入内存
pop(-8,读取)指令就是从内存中读取数据,并且修改栈顶指针
popq %rbx
popq指令就是将栈顶保存的数据复制到寄存器rbx中,修改栈顶指针

pop指令可以拆分为两条指令

  1. 从栈顶的位置读取数据,复制到寄存器rbx 中。此时,栈顶指针rsp只想的内存地址是0x100
    在这里插入图片描述
  2. 然后将栈顶指针加8,pop(读取)后栈顶指针rsp指向的内存地址是0x108
    在这里插入图片描述
    pop操作也可以等效为movq和addq这两条指令
    实际上pop指令是通过修改栈顶指针所指向的内存地址来实现数据删除的。此时内存地址0x100内所保存的数据0x123仍然存在,直到下一次push操作,此处保存的数值才会被覆盖

3.2程序的机器级表示||

算术和逻辑操作

  1. 加载有效地址
  2. 一元操作
  3. 二元操作和移位
3.2.1 加载有效地址
  1. 首先我们看一下指令leaq,实现的功能是加载有效地址
    在x84-64位处理器上,地址长度都是64位,因此不存在leab、leaw这类有关大小的变种

leaq S,D->load Efftive Address

  1. 例如下面的这条指令,表示的含义是把有效地址复制到寄存器rax中
    leaq 7(%rdx,%rdx,4),%rax

在这里插入图片描述

  1. 这个源操作数看上去和内存引用的格式类似,有效地址的计算方式与之前讲到的内存地址的计算方式一致

在这里插入图片描述


假设寄存器rdx内保存的数值为x,那么有效地址的值为
7+%rdx+%rdx*4=7+5x
对于leaq指令所执行的操作不是去内存地址5x+7处读取数据,而是将有效地址(5x+7)这个值直接写入目的寄存器 rax
除了加载有效地址的功能,leaq指令还可以用来表示加法和有限的乘法运算

long scale(long x,long y,long z){
long t = x+4*y+12*z;
return t;
}

编译之后
在这里插入图片描述

x:rdi
y:rsi
z:rdx
分析之后
在这里插入图片描述

为什么z*12要分为两步

  1. 首先计算3*z,执行完第二条后rdx=3z
  2. 3z作为一个整体乘以4

为什么不能使用
在这里插入图片描述
比例因子的取值只能是1,2,4,8四个数中的一个,因此要把12进行分解


3.2.2 一元和二元操作

一元操作
一元操作指令只有一个操作数,该操作数是源操作数也是目的操作数

例如下面四个指令

指令影响描述
INC DD<-D+1加1
DEC DD<-D-1减1
NEG DD<–D取负
NOT DD<-~D取补

二元操作
第一个是源操作数,这个操作数可以是立即数,寄存器或者内存地址;第二个操作数既是源操作数,也是目的操作数,这个操作数可以是寄存器或者内存地址,不能是立即数

指令影响描述
ADD S, DD<-D+S
SUB S,DD<-D-S
IMUL S,DD<–D*S
XOR S,DD<-D^S异或
OR S,DD<-DS
AND S,DD<-D&S

3.2.3 内存与寄存器中

一开始,内存以及寄存器中所保存的数据如图所示
在这里插入图片描述

    1. 加法指令addq是将内存地址0x100内的数据与寄存器rcx相加,二者之和再存储到内存地址0x100处,该指令执行完毕之后,内存地址0x100处所存储的数据由0xFF变成0x100
      在这里插入图片描述
    1. 减法指令subq是将内存地址0x108内的数据减去寄存器rdx内的数据,二者之差在存储到内存地址0x108处,该指令执行完毕之后,内存地址0x108处所存储的数据由0xAB变为0xAB

在这里插入图片描述

3.加一incq 将内存地址0x100内存储的数据加一
在这里插入图片描述
4. 最后一条加法指令是将寄存器rax内的值减去寄存器rdx内的值,最终寄存器rax的值由0x100变成0xFD

在这里插入图片描述


3.2.4 移位操作

左移指令有两个
分别是

SAL:算数左移
SHL:逻辑左移
SAL(shift arithmetic left) 和SHL二者的效果是一样的.都是在右边填零,右移指令不同,分为算数右移和逻辑右移
SAR(arithmic):算数右移
SHR(logical):逻辑右移
在这里插入图片描述
移位量


对于移位量k,可以是一个立即数,或者是存放在寄存器cl中的数,对于移位指令只允许以特定的寄存器cl作为操作数,其他寄存器不行

在这里插入图片描述
len(cl)=8
原则上移位量的编码范围可达2^8-1(255)
实际上对于w位的操作数进行移位操作,移位量是由寄存器cl的低m位来决定 2 m = w 2^m=w 2m=w,w为当前操作的位数

也就是说对于指令salb,当目的操作数是8位,移位量由寄存器cl的低3位来决定

在这里插入图片描述
对于指令salw,移位量则是由寄存器cl的低4位来决定
在这里插入图片描述
双字对应的是低5位,四字对应的是低6位

long arith(long x,long y,long z){
long t1=x^y;
long t2=z*48;
long t3=t1&0xF0F0F0F;
long t4=t2-t3;
return t4;
}
xorq %rsi, %rdi
leaq (%rdx, %rdx, 2), %rax
salq $4, %rax
andl $252645135, %edi
subq %rdi, %rax
ret

z48对应的汇编指令
long t2=z
48;
leaq (%rdx,%rdx,2),%raxx
salq $4,%rax

  1. 计算3*z,计算结果保存到寄存器rax
    在这里插入图片描述
  2. 将寄存器rax进行左移4位,左移4位的操作等效于乘以2的四次方,也就是乘以16

在这里插入图片描述
通过一条leaq指令和一条左移指令(salq)来实现乘法操作
乘法指令(IMUL)的执行需要更长的时间,优先考虑更高效的方式

移位的用途

  1. 右移操作要求区分有符号和无符号数,所以补码运算成为实现有符号数运算的一种好选择
  2. 乘法指令在编译器中有更长的执行时间,可以用移位代替乘法
3.2.4.1 特殊的算数指令

在这里插入图片描述

3.2.5 条件码

ALU除了执行算数和逻辑运算指令外,还会根据该运算结果设置条件码寄存器
在这里插入图片描述

3.2.5.1 条件码寄存器
  1. 由cpu来维护
  2. 长度是单个比特位
  3. 条件码寄存器在执行下一条语句时,上一个行语句的条件状态会被覆盖
  4. 常见条件码
  5. xor and inc neg dec等算数逻辑操作设置条件码
  6. CMP test设置条件码

在这里插入图片描述

加入ALU执行两条连续的算术指令
t1:addq %rax,%rbx
t2:subq %rcx,%rdx

t1,t2表示时刻
t1时刻条件码寄存器中保存的是指令1的执行结果的属性
t2时刻条件码寄存器的内容被下一条指令所覆盖

在这里插入图片描述


常见条件码

进位标志CF,当 CPU 最近执行的一条指令最高位产生了进位时,进位标志(CF)会被置为 1,它可以用来检查无符号数操作的溢出。
零标志ZF,当最近操作的结果等于零时,零标志(ZF)会被置 1。
符号标志SF,当最近的操作结果小于零时,符号标志(SF)会被置1
溢出标志OF,针对有符号数,最近的操作导致正溢出或者负溢出时溢出标志(OF)会被置 1。

条件码寄存器的值是由ALU在执行算数和运算指令时写入的
下面这些算数和逻辑运算指令都会改变条件码寄存器的内容
在这里插入图片描述

3.2.5.2 访问条件码

在这里插入图片描述
还有两类指令可以设置条件码寄存器cmp指令和test指令
cmp指令 和sub指令类似是根据两个操作数的来设置条件码,二者不同的是cmp指令只是设置条件码寄存器,并不会更新目的寄存器的值
test指令和and指令类似,同样test指令只是设置条件码寄存器,而不改变目的寄存器的值


访问条件码

int cmpq(long a,long b){
return (a==b);
}

在这里插入图片描述
a:rsi
b:rdi
在这里插入图片描述

指令 同义名 效果 设置条件
SETE D SETZ D<–ZF 相等/零
SETNE D SETNZ D<–~ZF 不等/非零
SETS D D<–SF 负数
SETNS D D<–~SF 非负数
SETG SETNLE 大于(有符号>)
SETGE SETNL 大于等于(有符号)
SETL SETNGE 小于(有符号)
SETLE SETNG 小于等于(有符号)
SETA SETNBE 大于(无符号)
SETAE SETNB 大于等于(无符号)
SETB SETNAE 小于(无符号)
SETBE SETNA 小于等于(无符号)

————————————————

sete 指令
通常情况不会直接读条件码寄存器
其中一种方式是根据条件码的组合
通过set类指令,将一个字节设置为0或1

在这里sete根据零标志的值对寄存器al进行赋值
后缀e是equal的缩写,如果零标志等于1,指令sete将寄存器al置为1
如果零标志等于0,指令sete将寄存器al设置为0

在这里插入图片描述
然后mov指令对寄存器al进行零扩展,最后返回判断结果

int cmpq(char a,char b){
return (a<b);
}

转换成汇编指令如下
在这里插入图片描述
对比前面相等的情况,可以发现指令有些不同,sete变成了指令setl

判断大小就将这两个数相减
setl:如果a<b,将寄存器al设置为1,其中后缀l是less的缩写,表示“在小于时设置”,不是表示大小Long word
判断小于的情况要复杂一些

  1. 符号标志SF
  2. 溢出标志OF
    SF和OF进行异或的结果进行判断

两个有符号数相减,当没有发生溢出的时候,
如果a<b,结果为负数符号标志被置为1
如果a>b,结果为正数,SF=0

在这里插入图片描述

根据符号标志SF是否就能判断a<b
溢出后SF不会置为1.但是溢出标志OF会置为1
因此,仅仅通过符号标志无法判断a<b
a=1,b=-128的时候,原本结果为t=129,发生了正溢出t=-127
虽然a>b,但是由于溢出导致了结果t小于0,此时符号标志和溢出标志都会被置为1


综合上述所有的情况,根据符号标志和溢出标志的异或结果,可以对a小于b是否为真做出判断

有符号数判断
在这里插入图片描述
无符号数判断

cmp会设置进位标志
对于无符号数的比较
采用的是进位标志和零标志的组合

在这里插入图片描述

> seta
>= setae 
< setb
setbe<=
3.2.6 跳转指令
  1. 产生代码文件时,汇编器会确定所有带标号指令的地址,并将跳转目标编码为跳转指令的一部分
  2. 跳转指令的编码
  3. 常见指令
  4. jmp无条件跳转指令
    • direct:jmp label
    • indirect:jmp *操作数
      • 跳转到内存器 jmp *%rax
      • 跳到内存 jmp *(%rax)
long absdiff_se(long x, long y){
long result;
if(x<y){result = y-x;}
else {result = x-y;}
return result;
}

得到汇编代码
在这里插入图片描述

条件语句x<y由指令cmp来实现,指令cmp(x-y)的结果来设置符号和溢出标志
图中的跳转指令jl,根据符号标志SF和溢出OF的异或来判断
判断是顺序执行还是跳转到L4处执行
当x>y时指令顺序执行,然后返回执行结果,L4处的指令不会被执行
当x<y时,程序跳转到L4处执行,然后返回执行结果,

跳转指令会根据条件寄存器的某种组合来决定是否进行跳转
在这里插入图片描述

3.2.6.1 跳转指令的编码

if-else当满足条件的时候,程序沿着一条执行路径执行
但是失败的时候走另外一条
在现代处理器上,执行效率比较低

针对这种情况,替代方案是使用数据的条件转移来代替控制的条件转移

long comvdiff_se(long x,long y)
{
long rval=y-x;long eval=x-y;
long ntest=x>=y;
if(ntest){
rval=eval;
}
return rval;
}

我们既要计算y-x的值,也要计算x-y的值,分别用两个变量来记录结果,然后再判断x和y的大小
根据测试情况来判断是否更新返回值
这两种写法看上去差别不大,但是第二种效率更高
在这里插入图片描述
cmovge是根据条件码的某种组合来进行有条件的传送数据

当满足规定的条件的时候,将寄存器rdx内的数据复制到寄存器内
在这里插入图片描述

条件传送指令
在这里插入图片描述
基于条件传送的代码会比基于跳转指令的代码效率更高
现代处理器通过流水线来获得高性能
当遇到条件跳转的时候,处理器根据分支预测器猜测每条跳转指令是否执行

发生错误预测的时候会浪费大量的时间

3.2.6.2 循环

三种循环结构do-while,while以及for语句
汇编语言没有定义专门的指令来实现循环结构
在这里插入图片描述

imulq有符号四字乘法

通过指令cmp和跳转指令的组合实现了循环操作
在这里插入图片描述

n>1的时候跳转L2处执行循环


3.2.6.2 while

while和dowhile循环两种循环的差别在于
N>1这个循环的测试位置不同
do-while 循环是先执行循环体的内容,然后再进行循环测试,while 循环则是先进行循环测试,根据测试结果是否执行循环体内容

在这里插入图片描述

3.2.6.2 for

在这里插入图片描述
生成的汇编代码对比
在这里插入图片描述
不同之处在于

jle .L3
jl .L3

这两个汇编代码都是采用-Og选项产生的

三种形式的循环的语句都是通过条件测试和跳转指令来实现的

3.2.6.3 switch语句
 void switch_eg(long x, long n, long *dest){
 long val = x;
 switch(n){
 case 0: val *= 13; break;
 case 2: val += 10; break; 
 case 3: val += 11; break; 
 case 4:
 case 6: val += 11; break; 
 default: val = 0;
 }
 *dest = val;
 }

在针对一个测试有多种可能的结果的时候,switch语句通过跳转表数据结构,使得实现更加高效

在这里插入图片描述
switch语句
指令cmp判断参数n与立即数6的大小
如果n>6程序跳转到default对应的L8程序段
case 0~case 6的情况,可以通过跳转表来访问不同分支,代码将跳转表声明为长度为7的数组,每个元素都是一个指向代码位置的指针
在这里插入图片描述

数组的长度为7,是因为需要覆盖Case0 Case6的情况,对重复的情况case 4 和case 6使用相同的标号
在这里插入图片描述

缺失情况的处理
对于缺失的case1 和 case5的情况使用默认情况的标号

switch语句
在这个例子中,程序使用跳转表来处理多重分支,甚至桑switch有上百种情况时,
虽然跳转表的长度会增加,但是程序的执行只需要一次跳转也能处理复杂分支的情况,与使用一组很长的 if-else 相比,使用跳转表的优点是执行 switch 语句的时间与case 的数量是无关的。因此在处理多重分支的时,与一组很长的 if-else 相比,switch 的执行效率要高。


3.3 程序的机器级表示|||

3.3.5 过程
  1. 传递控制 程序计数器必须被设置为Q的代码的起始地址,然后在返回时,把程序计数器设置为P中调用Q后面的那一行代码的地址
  2. 参数传递 P必须能向Q提供多个参数,Q必须能向P返回一个值
  3. 分配和释放过程

以c的函数调用介绍一下过程的机制
函数P调用函数Q,函数 Q执行完返回函数P

在这里插入图片描述
运行时栈
程序的运行时内存分布中,栈为函数调用提供了后进先出的内存管理机制

在函数P调用函数Q的例子中,当函数Q正在执行时,函数P以及相关调用链上的函数都会被暂时挂起

3.3.5.1 转移控制

函数P调用函数Q的时候,会把地址压入栈中,该地址指明了当函数Q执行结束返回的时候要从函数P的哪个位置继续执行。这个返回地址的压栈操作并不是由指令push来执行的,而是由函数调用 call来实现的
以main函数调用mulstore函数为例来解释一下指令call和指令ret的执行情况

#include<stdio.h>
void mulstore(long ,long ,long *);
int main(){
long d;
mulstore(2,3,&d);
printf("2 * 3 -->%d\n",d);
return 0;
}
long mult2(long a,long b){
long s = a*b;
return s;
}

long mul2(long ,long );
void mulstore(long x,long y,long *dest){
long t = mult2(x,y);
*dest = t;
}

查看这两个函数的反汇编代码

linux>gcc -Og -o prog main.c msotre.c
linux>objdump -d proc

在这里插入图片描述
这一条call指令对应mulstore函数的调用
在这里插入图片描述
指令call不仅要将函数mulstore的第一条指令的地址写入到程序指令寄存器rip中,以此实现函数调用

在这里插入图片描述
同时还要将返回地址压入栈中
在这里插入图片描述
这个返回地址就是函数mulstore调用执行完毕之后,下一条指令的地址
在这里插入图片描述
函数mulstore执行完毕,指令ret从栈中将返回地址弹出,写入到程序指令寄存器rip中

在这里插入图片描述


3.3.5.1 参数传递
  1. 函数参数一般用过寄存器(6),超过用栈
  2. 通过栈传递参数时,所有数据大小都向8的倍数对齐
  3. 指针变量一个字长,64bit系统的指针变量为64bits
  4. proc函数有参数,所以在开辟返回地址之前会先开辟参数空间
    如果一个函数数量大于6,超出的部分要通过栈来传递,假设函数P有n个整型参数,当n的值大于6的时候,参数7-参数n需要用到栈来传递
    在这里插入图片描述

在这里插入图片描述
5. rdi
6. rsi
7. rdx
8. rcx
9. r8
10. r9

代码中函数有8个参数,宝库字节数不同的整数以及不同类型的指针,参数1到参数6是通过寄存器传递,参数7,8是通过栈来传递
在这里插入图片描述

两个需要注意的点

  1. 通过栈来传递参数的时候,所有数据的大小都是向8的倍数对齐,虽然变量a4只占一个字节,但是仍然为其分配了8个字节的存储空间,由于返回地址占用了栈顶的位置,所以这两个参数距离栈顶指针的距离分别为8和16
    在这里插入图片描述
  2. 通过寄存器进行参数传递的时候,寄存器的使用是有特殊顺序规定的,此外,寄存器名字的使用取决于传递参数的大小,如果参数大小是4字节,需要用寄存器edi来保存

在这里插入图片描述

3.3.5.2 栈上的局部存储
  1. 先进入P的指令,然后进入Q。Q在执行时,P其他已执行部分暂时被挂起,进入Q的执行。Q运行时,栈为其局部变量分配空间,完成执行之后对局部变量进行释放。Q完成执行,Q的资源会被完全释放。
  2. 栈指针**%rsp**指向栈顶元素,栈在x86-64中低地址增长。如果要开辟栈空间,减少指针的量;释放空间,增加指针的量,当前执行的过程总在栈顶
  3. 栈帧
    当函数执行所需要的存储空间超出寄存器能够存放的大小的时候,就会借助栈上的存储空间,我们把这部分存储空间称为函数的栈帧。对于函数P调用函数Q的例子,包括较早的帧,调用函数P的帧,还有正在执行函数Q的帧
    • 栈帧一般定长,有些过程使用变长帧
    • 如果通过寄存器,过程P最多可以传递6个参数,超过部分用栈传递
    • 很多函数不需要栈帧。当所有局部变量均可以保存在寄存器中,并且该函数并不会调用其他函数,函数不会开辟栈帧
  4. 函数P调用函数Q时,会把返回地址压入栈中,该地址指明了当函数Q执行结束返回时要从函数P的哪个位置继续执行。
    这个返回地址是P的栈帧的一部分,因为它存放的是P的状态
    Q的代码会拓展当前栈的边界,分配它所需要的栈空间。

在这里插入图片描述

函数caller定义了两个局部变量arg1和arg2
函数swap的功能是交换这两个变量的值,最后返回二者之和
long swap

long swap(long *xp,long *yp){
long x = *xp;
long y = *yp;
*xp = y;
*yp = x;
return x + y;
}

caller的汇编代码的地址运算符的处理方式
在这里插入图片描述
第一条减法指令将栈顶指针减去16,它表示的含义是在栈上分配16字节的空间
在这里插入图片描述

接着的两条mov指令可以推断出变量arg1和arg2存储在函数caller的栈帧上,接下来,分别计算变量arg1和arg2存储的地址,参数准备完毕,执行call指令调用swap函数,最后函数caller返回之前,通过栈顶指针加上16的操作来释放栈帧

在这里插入图片描述

long call_proc(){
long x1 = 1;
int x2 = 2;
short x3 = 3;
char x4 = 4;
proc(x1,&x1,x2,&x2.x3,&x3,x4,&x4);
return (x1+x2)*(x3+x4);
}

根据上面c的代码,画一下 这个函数的栈帧
根据变量的类型可知x1占8个字节,x2占4个字节,x4占一个字节,因此,这四个变量在栈帧中的空间分配如图所示
在这里插入图片描述
由于函数proc需要8个参数,参数7和8需要通过栈帧来传递,传递的参数需要8字节对齐
局部变量不需要对齐


无法使用寄存器存储局部区域的情况

  1. 寄存器容量不够
  2. 某个局部变量使用地址运算符‘&’,因此必须为它产生一个地址
  3. 局部变量为数组或者结构体
3.3.5.3存储器中的局部存储空间

对于16个通用寄存器,除了寄存器rsp之外,其他15个寄存器分别被定义为调用者保存和被调用者保存
在这里插入图片描述
当函数运行需要局部存储空间的时候,栈提供了内存分配和回收的机制,当程序在执行的过程中,寄存器是被所有函数共享的一种资源,为了避免寄存器的使用过程中出现数据覆盖的问题,处理器规定了寄存器的使用的惯例,所有的函数调用都必须遵守这个惯例

  • 栈保存寄存器数值的例子

在这里插入图片描述
函数Q需要使用寄存器rdi来传递参数,因此,函数P需要保存寄存器rdi中的参数x,保存参数x使用了寄存器rbp.根据寄存器使用规则,寄存器rbp被定义为被调用者保存寄存器,所以便有了开头的这条指令pushq %rbq
至于pushq %rbx也是类似的道理

在函数P返回之前,使用pop指令回复寄存器rbp和rbx的值
栈,弹出的顺序和压入的顺序相反

  1. subq:先为这个函数开辟一个32字节的栈空间,用于存储局部数据,因为call_proc这个函数没有参数,所以不会像之前的proc那样开辟参数变量的空间,而是将返回地址压栈
  2. x1~x4这些局部变量分别为8、4、2、1字节,所以rsp在对应位置存储,并且由于要对齐8的倍数,所以x2那行最后会补足1个字节。凑成8个字节
  3. proc有8个参数,所以第7个参数x4和第8个参数存在栈中,前者是地址,耗费8字节,后者是char 1字节,但是因为要对齐,所以还是会开辟8字节空间
  4. proc为数据传递提供的函数,call proc后,栈会压入一个8字节的返回地址,这个地址是proc的返回地址。而proc由于参数都是call_proc传递的,所以不会再先开辟参数空间,此时因为proc返回地址压栈,参数7的地址也由原来的%rsp变成%rsp+8
  5. 执行完成proc,开始执行return里面的计算,最后将%rsp加上32.释放整个栈帧
3.3.5.4 递归
  1. 栈使得每个过程之间互不干扰,这允许我们进行递归
  2. 递归过程中,因为我们需要不断用pushq %rbx去装这些局部参数,所以如果递归次数过大,会导致爆栈

n的阶乘的递归实现
在这里插入图片描述
n=3的时候,看一些汇编代码的执行情况,由于使用寄存器rbx来保存n的值,根据寄存器的使用惯例,首先保存寄存器rbx的值。
在这里插入图片描述

pushq %rbx->Save %rbx

n=3 jle不会跳转到L35执行
指令leaq是用来计算n-1然后再次调用该函数

注意此时寄存器rbx内保存的是3,指令pushq执行完毕之后,栈的状态

在这里插入图片描述
继续执行,直到n=1的时候程序跳转到L35处,执行pop操作

在这里插入图片描述

3.3.6 基本形式

指针类型不同+1得到的结果不同`
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

对指针进行运算的时候,计算结果会根据指针引用的数据类型进行相应的伸缩

在这里插入图片描述

两种数组引用的方式E[1]和E+2
在这里插入图片描述

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

3.3.6.1 二维数组

对于数组D任意一个元素都可以通过图中的计算公式
在这里插入图片描述
在这里插入图片描述
对于5x3的数组A,任意元素的地址可以
在这里插入图片描述

Xa:rdi
i:rsi
j:rdx

A[i][j]的值复制到寄存器eax中

在这里插入图片描述
编译器对定长多维数组的优化
fix_matrix声明为16*16的整型数组

#define N 16
typedef int fix_matrix[N][N];

define声明将N和常数16关联在一起,之后的代码中可以使用N来代替常数16

需要修改的时候修改define

在这里插入图片描述
如何使用汇编代码访问数组元素?

编译器对相关的操作进行了优化,

在这里插入图片描述
在这里插入图片描述
读取Aptr指向元素的数据,然后将指针Aptr指向的元素与指针Bptr指向的元素相乘(inull),最后将乘积结果进行累加,结果保存到寄存器eax中

计算完成后,分别移动指针Aptr 和Bptr指向下一个元素
由于int类型4个字节
rdi+4 对应于Aptr指向数组A的下一个元素
由于数组B一行元素的数量为16
每个元素占4个字节
相邻列元素的地址相差64个字节
对寄存器rcx+64 对应与Bptr指向数组B的下一个元素

判断循环结束的条件是Bptr和Bend是否指向同一个内存地址
如果二者不相等,继续跳转到L7处执行
如果二者相等,循环结束

在这里插入图片描述
C89的标准中malloc:变长数组
动态分配存储空间

ISO C99

A[expr1][expr2];
long var_ele(long n,int a[n][n],long i,long k){
return A[i][j];
}

可以作为一个局部变量,也可以作为函数的参数,当变长数组作为函数参数的时候,参数n必须在数组A之前

在这里插入图片描述

int var_mat(long n, int A[n][n], int B[n][n], long i, long k)
{  long j;
 int result = 0;
 for (j=0; j <N; j++){
 result += A[i][j] * B[j][k];
 } 
  return result;
 }

对比定长数组和变长数组的汇编指令
在这里插入图片描述

  1. 定长数组一般会define定义长度
  2. 变长数组和定长数组的差异来源于汇编
    如果是定长数组,那么乘法执行用的都是leaq这种移位指令
    如果是变长数组,那么乘法会使用乘法指令imulq进行计算。但是乘法指令会导致更大的性能浪费,因此要谨慎使用变长数组
  3. 循环优化
    C对于变长数组的循环优化引入了n去检查边界,优化方法也略有不同
  4. 变长栈帧
    • 存在变长数组的程序都会引入栈指针进行管理
    • 栈指针又叫基指针是我们在操作栈帧的基准地址,函数内部的固定长度局部变量以%rbp为基准地址进行增减
    • leave等价于
movq %rbp,%rsp
popq %rbq

先恢复rsp指针位置到基准0位置处,然后再把rbp弹出,完成函数的栈帧清除

.

3.4 程序的机器级表示IV

3.4.1.1结构体的声明
struct rec{
int i;
int j;
int a[2];
int *p;
}

两个int类型的变量
1个int类型的数组
1个int类型的指针
在这里插入图片描述


声明一个结构体指针变量r
指向结构体的起始地址

在这里插入图片描述

可以看到数组a的元素是嵌入到结构体中的
声明一个结构体类型指针变量r
在这里插入图片描述

变长数组
假设r存放在寄存器rdi中,可以使用下图的汇编指令将字段i复制到字段j

在这里插入图片描述

  • 首先读取字段i的值,字段j相对于结构体起始地址的偏移量为0,所以字段i的地址就是r的地址
    字段j的偏移量为4,需要将r加上偏移量4
  • 其中结构体指针r存放在寄存器rdi中,数组元素的索引值i存放在寄存器rsi中,最后地址的计算结果,存放在rax中
    无论是单个变量还是数组元素,都是通过起始地址加偏移量的方式来访问
3.4.1.2 数据对齐

在这里插入图片描述

在这里插入图片描述
例如变量j是int类型,占4个字节,起始地址必须是4的倍数
编译器会在变量c和变量j之间插入一个3字节的间隙。变量j相对与起始地址的偏移量就为8
整个结构体大小就变成了12字节

任何k字节基本对象的地址必须是k的倍数
在这里插入图片描述
编译器可能要在字段的地址空间分配时插入间隙
以此保证每个结构体的元素对齐的要求

在这里插入图片描述
为了满足所有数组元素的对齐限制
在这里插入图片描述

在这里插入图片描述
a:8字节
b:short 占两个字节,起始的字节偏移量8,满足对齐规则的2的倍数
c:double 8字节
d:一字节
e:4字节
f:char
g:8字节要在f之后插入7个字节的间隙
最后一个变量h占4个字节,此时结构体的大小为52字节,为了保证每个元素都满足对其要求,要在结构体的尾端填充4个字节的间隙

在这里插入图片描述


与结构体不同,联合体所有字段共享同一存储区域,联合体的大小取决于它最大字段的大小

在这里插入图片描述
变量v和数组i的大小都是8个字节
这个联合体占8个字节的存储空间

3.4.1.3联合体的应用

我们实现知道哦两个不同字段的使用是互斥的,那么我们可以将这两个字段声明为一个联合体

我们定义一个二叉树的数据结构,这个二叉树分为内部节点和叶子节点,其中每个内部节点不含数据,都有两个指向孩子节点的指针,每个叶子节点都有两个double类型的数据值

可以用结构体定义而二叉树的节点


struct node_s{
struct node_s *left;
struct node_s *right;
double data[2];
};

每个节点需要32个字节,由于该二叉树的特殊性,我们事先知道该二叉树的任意一个节点不是内部节点就是叶子节点
我们可以用联合体来定义该节点

union node_u{
struct{
union node_s *left;
union node_s *right;
}internal;
double data[2];
}

每个节点只需要16字节的存储空间
这种编码方式无法确定一个节点到另一个节点是叶子节点还是内部节点,通常的解决方法就是引入一个枚举类型,然后创建一个结构体,它包含一个标签和一个联合体

typedef enum{N_LEAT,N_INTERNAL}nodetype_t;
struct node_t{//结构体
nodetype_t type;//枚举类型
union{//联合体
struct{
union node_s *left;
union node_s *right;
}internal;
double data[2];
}info;
};

type占4个字节联合体占16字节
type和联合体之间需要加入4个间隙,整个结构体的大小为24个字节
虽然使用联合体可以节省空间,但是给代码编写带来了麻烦
对于有较多字段的情况,使用联合体带来的空间节省才更方便
联合体可以访问不同数据类型的位模式,当我们使用简单的强制类型转换的时候,将double类型的数据转换成unsigned long类型的时候,除了d=0的情况,二者的二进制位表示差别很大,这时我们可以将这两种类型的变量声明为一个联合体。这样就是可以一种类型来存储,另一种类型来访问

example

unsigned long u=(unsigned long )d;
unsigned long double2bits(double d){
union {
double d;
unsigned long u;
}tmp;
temp.d = d;
return temp.u;
};
3.4.1.4缓冲区溢出
void echo(){
char buf[8];
gets(buf);
puts(buf);
}

echo函数声明了一个长度为8的字符数组,gets函数是c语言标准库定义的函数,它的功能是从一个标准输入读入一行字符串,在遇到回车或者某个错误的情况时停止,gets函数将这个字符串复制到参数buf指明的位置,并在字符串结束的位置加上null字符,注意gets函数会有一个问题,就是它无法确定是否有足够大的空间来保存整个字符串,长一些的字符串可能会导致栈上的其他信息被覆盖

汇编代码如下

gcc -S -o test.s test.c	//注意大写S
vim test.s

查看汇编代码的另一个指令
在这里插入图片描述
这里栈上实际上分配了24个字节的存储空间

栈的数据分布情况

在这里插入图片描述
字符数组位于栈顶的位置,栈从高地址向低地址,栈底位置在高地址,栈顶在低地址

当输入字符串长度不超过23时,不会发生严重的后果,超过以后,返回地址以及更过的状态信息会被破坏
返回指令会导致程序跳转到一个完全意想不到的地方

历史上许多计算机病毒是利用缓冲区溢出的方式对计算机系统进行攻击
现在编译器和操作系统实现了很多机制,来限制入侵者通过这种攻击方式来获得系统控制权

比如栈随机化,栈破坏检测,限制可执行代码区域等

3.4.1.5限制系统控制权
3.4.1.5栈随机化
int main(){
long local;
printf("local at %p\n",&local);
return 0;
}

在过去,程序的栈地址非常容易预测,如果一个攻击者可以确定一个web服务器所使用的栈空间,那就可以设计一个病毒程序来攻击多台机器,栈随机化的思想是栈的位置在程序每次运行的时候都有变化,上面这段代码只是简单打印main函数中局部变量的local的地址
每次运行结果都可能不同

64linux系统地址范围:0x7fff0001b698 0x7ffffffaa4a8
采用了栈随机化的机制,即使许多机器都运行相同的代码,它们的栈地址也是不同的

在linux系统中,栈随机化已经成为标准行为,它属于地址空间布局随机化的一种简称ASLR
采用ASLR,每次运行程序的不同部分都会被加载到内存的不同区域,这类技术的应用增加了系统的安全性,降低了病毒的传播速度

  1. 实现方法:使用分配函数alloca在栈上分配指定字节数量的空间。程序不会是用这段空间,但是他会导致程序每次执行时后续栈位置发生变化。分配的空间n需要足够大,以提供足够多的地址变化
  2. ASLR(地址空间布局随机化):这项技术可以让程序每次执行时的代码、栈等要素加载到内存的不同区域
  3. 暴力破解:攻击者可以使用nop sled,即不断进行对地址的尝试,只要把所有地址都试过,就可以找出正确的地址
3.4.1.5栈破坏检测

编译器会在产生的汇编代码中加入一种保护着机制来检测缓冲区越界,就是在缓冲区与栈保存的状态值之间存储一个特殊值,这个特殊值被称作金丝雀值(canary)

在这里插入图片描述

canary金丝雀值是运行时随机产生的
攻击者想要知道这个金丝雀值具体是什么不容易,函数返回之前,检测金丝雀值是否被修改来判断是否遭受攻击

上汇编
在这里插入图片描述

图中这两行代码是从内存中读取一个数值,然后将该数值放到栈上,其中这个数值就是刚才提到的金丝雀值,存放的位置与程序中定义的缓冲区是相邻的其中指令源操作数%fs:40可以简单理解为一个内存地址,这个内存地址属于特殊的段,被操作系统标记为只读
攻击者无法修改

函数返回之前检查金丝雀值是否被更改(xor)
被更改就会调用一个错误处理例程

在这里插入图片描述

3.4.1.5限制可执行代码区域

最后一种机制是消除攻击者向系统中插入可执行代码的能力
其中一个方法就是限制哪些内存区域能够存放可执行代码

以前x86的处理器将可读和可执行的访问控制合并成一位标志,所以可读的内存页也都是可执行的,由于栈上的数据需要被读写,栈上的数据也是可执行的

虽然实现了一些机制能够限制一些页可读且不可执行,但是这些机制通常会带来严重的性能损失,后来处理器的内存保护引入了不可执行位,将读和可执行访问模式分离开了,有了这个特性可以标记栈被可读可写,但是否可执行由硬件来完成,效率上没有损失

可读的内存页也都是可执行的。由于栈上的数据需要被读写,因此栈上的数据也是可执行的(现在AMD引入了NX等特性,使得栈可以被标记为可读写,但不可执行)

限制可执行代码区域

在这里插入图片描述
以上三种机制,通过编译器和操作系统来实现

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值