深入理解计算机系统

个人认为要熟练掌握的精华总结部分:

  • [[深入理解计算机系统#附录:数据的底层表示和处理逻辑|数据的底层表示和处理逻辑]]

第一章:计算机系统漫游

资料:gcc编译过程详解

帮助:如何在Linux上运行c程序
命令:gcc -o main main.c
详细阶段:

  • 1预编译阶段:处理源文件中多有的伪指令,包括宏定义、头文件包含等,gcc会将这些内容展开到当前文件中。
    • 命令:gcc -E -o main.i main.c 其中mian.i就是预编译后生成的中间文件
  • 2编译阶段:编译器完成C语言到汇编语言的转换
    • 命令:gcc -S -o main.s main.i(直接gcc main.i -s会生成a.out文件)
  • 3汇编阶段:将汇编语言翻译成二进制目标代码
    • 命令:gcc -c -o main.o main.s(查看二进制文件od main.o)
  • 4链接阶段:在该阶段链接器将多个目标代码文件(以后可能包含库文件)进行链接,最终生成可执行文件。
    • 命令:gcc main.o生成名为a.out的可执行文件
      gcc -o main main.o会生成名为main的可执行文件
      执行,终端输入./filename

我们通过跟踪hello.c的生命周期来开始我们对系的学习

1.1信息就是位+上下文

hello程序的生命是从一个souece program(或者说是source file)开始的,该程序由程序员通过编辑器创建并保存为文本文件。源程序实际上就是有0和1组成的位序列,这些位被组织成8个一组,称为字节。

基本思想:系统中所有的信息–包括磁盘文件、存储器中的程序、存储器中存放的数据以及网络上传送的数据,都是由一串比特表示的。
区分不同数据对象的唯一方法时我们读到这些数据对象的上下文。在不同的上下文中,同样的字节序列可能表示一个整数、浮点数、字符串或机器指令。

1.2 程序被其它程序翻译成不同的格式

hello.c是能够被人读懂的,但为了在系统上运行,每条C语句都必须被其他程序转化为一系列的低级机器语言指令。然后这些指令按照一种称为可执行目标程序(executable object program) 的格式打包好,以二进制磁盘文件的形式存放起来。
Unix系统上,从源文件到目标程序的转化是由compiler driver完成的:
unix> gcc -o hello hello.c
实际的翻译过程分为四个阶段。

|执行程序过程生成文件
预处理阶段预处理器(cpp)根据以#开头的命令修改原始文件,如,直接把stdio.h内容插入到文本中
编译阶段编译器(ccl)把.i文件翻译成.s文件,包含一个汇编语言程序。汇编语言程序中每条语句都以一种标准的文本格式确切地描述了一条低级机器语言指令。
汇编阶段汇编器(as)将.s文件翻译成机器语言指令,并把这些指令打包成一种可重定位目标程序的格式
链接阶段链接器(ld)如:我们调用了printf函数,它存在于一个名为printf.o的单独的预编译目标文件中,ld负责将这个文件并入我们的hello.o中

旁注:GUN项目

GCC是GUN项目开发出来的众多有用的工具之一。GUN项目是1984年被发起的一个宏大项目,就是开发出一个完整的Unix类系统,其源代码能够被不受限制的修改和传播。
GUN环境包括:EMACS编辑器、GCC编译器、GDB调试器、汇编器、链接器、处理二进制文件的工具以及其它一些部件。

1.3 了解编译器如何工作是大有益处的

  • 优化程序性能
    • 需要对汇编语言以及编译器如何将不同的C语句转化成汇编语言有一些基本的了解
  • 理解连接时出现的错误
  • 避免安全漏洞
    • 缓冲区溢出造成了大多数网络和Internet服务器上的安全漏洞。

1.4 处理器读并解释存储在存储器中的指令

执行目标文件./main 命令的第一个单词不是shell的内置命令时,会假设这是一个可执行文件的名字。

1.4.1系统硬件的组成

  • 总线:贯穿整个系统的一个电子管道,携带信息字节并且再各个部件之间传递。通常被设计成传送特定字长的字节块。
  • I/O设备:系统与外界联系的通道。每个I/O设备通过控制器或适配器与I/O总线相连。
  • 主存:临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据。
    • 物理上来说,是由一组DRAM(动态随机访问存储器)芯片组成的。
    • 逻辑上来说,存储器有一个线性字节数组组成。每个字节都有自己的唯一地址(数组索引),这些地址从零开始。
  • 处理器:中央处理单元(CPU),解释(执行)存储在主存中指令的引擎。
    • 核心是程序计数器(PC)。字长大小的存储设备。任何时间点都指向主存中的某条机器指令(内涵其地址)
    • 处理器一直在重复执行相同的基本任务:从PC执行的存储器读取指令,解释指令中的位,执行指令指示的简单操作,然后更新PC指向i西安一条指令。
    • 简单操作在主存、寄存器和ALU之间循环。
      • 加载、存储。在寄存器和主存之间交换数据。
      • 更新。拷贝两个寄存器内容到ALU,ALU将两个数相加,结果存放到一个寄存器并覆盖原来内容。
      • I/O读、I/O写。I/O设备和寄存器之间的数据交换。
      • 跳转。指令本身中抽取一个字,并将这个字拷贝到PC中,覆盖原来的值。

小结

计算机系统是由硬件和软件组成的,它们共同协作以运行应用程序。
计算机内部信息被表示为一组一组的位,它们依据不同的上下文有不同的解释方式。
程序被其他程序翻译成不同的形式,开始时是ASCII文本,然后被编译器和链接器翻译成不同的文件。

处理器读取并解释存放在主存里的二进制指令。
因为花费大量时间在存储器、I/O设备和CPU寄存器之间拷贝数据,系统中的存储设备被按层次排列。
CPU寄存器在顶部。之后是多层的硬件高速缓存寄存器、DRAM、主存储器和磁盘存储器。程序员通过理解和运用这种存储器的层次结构的知识,可以优化他们C程序的性能。

操作系统是硬件和软件之间的媒介。它提供三个基本的抽象概念:文件是对I/O设备的抽象概念;虚拟存储器是对主存和磁盘的抽象概念;进程是处理器、I/O设备和主存的抽象概念。
网络提供了计算机系统之间的通信手段。从系统的某个角度来看,网络就是一种I/O设备。

First part

Chapter 2 信息的表示和处理

单独的来说,单个的位不是非常有用。然而,当我们把位组合在一起时,再加上某种解释(interpretation),即给予不同的可能位模式以含意,我们就能够表示任何有限集合的元素。
程序员需要对计算机运算和更为人熟悉的实数运算之间的关系有牢固的理解。

2.1 信息存储

机器级程序将存储器视为一个非常大的字节数组,称为虚拟存储器。存储器的每个字节有唯一数字标识,称为地址,所有可能的地址的集合称为虚拟地址空间
这个虚拟地址空间只是一个展现给机器的一个概念级映像,现实的实现使用的是随机访问存储器RAM、硬盘存储、特殊硬件和操作系统的结合。
编译器和运行时系统的一个任务就是将这个存储器空间划分为一个更可管理的单元,来存放不同的program object,也就是程序数据、指令和控制信息。这种管理完全是在虚拟地址空间里完成的。

字长决定的最重要的系统参数就是虚拟地址空间的最大大小。

在c语言中,32位机和64位机在long和pointer分配的字节大小是与各自的字长相匹配的。其他的数据类型大小相同。
程序员应力图使他的程序在不同的机器和编译器上可移植。可移植性的一个方面就是使程序对不同类型的数据大小不敏感。例如在32位机中,int类型的程序对象能被用来存储指针,但在64位机上会出现问题。

寻址和字节顺序

对象地址为所使用字节中最小的地址。
存储顺序分为大端法和小端法:0x01234567为例
大端:01 23 45 67 高位信息存在低位地址
小端:67 45 23 01 低位信息存在低位地址
地址:01 02 03 04 读取时从高位信息开始读

字节顺序有时十分重要:
场景1:不同机器之间发送二进制信息。
场景2:检查机器级指令时阅读表示整数数据的字节序列。
场景3:编写规避正常的系统类型的程序时。(强制类型转换:允许以一种不同于它被创造时的类型来引用一个对象)

编写一个程序,打印不同数据类型的字节表示:

#include <stdio.h>

typedef unsigned char *byte_pointer;

void show_bytes(byte_pointer start, int len){   //输出16进制表示的字节。
    int i;
    for(i=0;i<len;i++){
        printf("%.2x\n",start[i]);  //%.2x整数必须至少用两个数字的16进制格式输出
    }                               //start[i]说明我们能够用数组表示法来引用指针

	void show_int(int x){
	    show_bytes((byte_pointer) &x, sizeof(int));  //强制类型转换告诉编译器,程序应该把指针
	}                                                //看成指向一个字节序列,而不是原始数据类
    //												 //型
     
	void show_pointer(void *x){
		show_bytes((byte_pointer) &x, sizeof(void *))
	}
}

运行将结果解读:

  • -3:fd ff ff ff 整数用补码形式存储,负数不足的位补f,该机型采用小端法。
  • 2.5: 00 00 20 40
  • 指针:40 bb 27 b5 fe 7f 00 00
    • 根据存储顺序,实际数值:00 00 7f fe b5 27 bb 40
字符串

每个字符由标准码来表示,最常见的是ACSII

布尔代数和环

熟练掌握位级运算

  • 例子,交换x,y
    • x = x^y ; y = y^x; x = x^y; 实现交换。
  • 一些掩码搭配正确操作实现特定位置的去留和转换
    • 0xFF与x进行&操作,只保留最低位数据,其它置零。
    • 0xFF与x进行^操作,最低位取补,其他位不变。

环:

  • 整数环:<Z, +, -, *, 0, 1>
    • 其中加法为求和运算,乘法为求积运算
    • 负号是加法的逆运算
    • 0,1是单位元(集合里一种特殊的元素,作用于二元运算的一边时结果和另一边一样。例如加法的单位元是0)
  • 布尔代数:<{0,1}, |, &, ~, 0, 1>
    • | 和加法有相似的性质, &和乘法有相似的性质;
      • a+0 = a <—> a|0 = a
      • a*0 = 0 <—> a&0 = 0
      • a*1 = 1 <—> a&1 = a
    • 负号是加法的逆运算,但~不是|的逆运算

知识拓展
整数模n环:<Z_n, +_n, *_n, -_n, 0, 1>
当n等于2 时,形成的环可以等价于
用异或和恒等运算I布尔代数的或和~的环<{1,0}, ^, &, I, 0, 1> (注意这个结构已经不再是布尔带数的体系)
布尔AND和异或分别相当于整数模2环的乘法和加法。
恒等运算对应了-_2运算,对1取负再模2确实还是1
这个环有一个奇怪的属性,每个元素都是自己的加法逆元(a^a = 0)

我们可以用一个环来表示一套运算体系,包括操作数集合,基本操作符和单位元。每一套体系都有其独特的性质。抽象代数包括识别和分析不同领域内数学运算的共同属性。

逻辑运算

逻辑运算和位级运算的区别:

  • 位级运算只有在参数被限制在0,1时才能产生和其对应的逻辑运算同样的效果(逻辑运算考虑位之间的关系,而位级运算位与位之间是孤立的)

  • 逻辑运算如果第一个参数已经能确定结果,则不会对第二个参数求值

    • a&&a/5, p&&*p++都是对逻辑运算性质的利用
  • 位移运算

    • 左移<<算数逻辑都一样
    • 右移分算数和逻辑
      • 算数会将最高位拷贝,补充到因位移空出来的位上。
      • 对于有符号数来说默认为算数右移

2.2整数表示

小技巧:查看数据的物理存储二进制码。

  • 对于正数,补码和原码一样,可以直接打印为%x形式。
    • 对于负数,打印为16进制形式和真实物理存储一样。
  • 真实物理存储:把指针强制转化为unsign char *形式
    • 按数组访问方式依次访问内存地址,长度为sizeof(数据类型)

二进制补码的本质:将最高位解释为负权。

理解:不同数据类型(解释方式)就是对二进制序列做函数映射后的结果
也可以理解为:所有的数据类型本质上都是二进制串,对于同一个地址的二进制串,不同的数据类型导致的结果是对这一串二进制的解读方式不同。

反码和补码的不同理解方式:

理解反码补码
位权最高位位权为(2^(w-1)-1)最高位位权为(2^(w-1))
二进制串最高位表示符号,其他位置取反表示值在反码取反的基础上加1
结果0仍有两种表达负数比正数的表示范围大1

定义类型转化:

  • 位串到无符号:B2U_w(x) //w表示w位的模式串

  • 位串到有符号:B2T_w(x)

  • 由于以上两个函数都是双射函数,有符号和无符号之间也可以相互转化。

    • 推导有符号到无符号 B 2 U w ( T 2 B w ( x ) ) = T 2 U w ( x ) = x w − 1 2 w + x B2U_w( T2B_w (x) ) = T2U_w(x) = x_{w-1}2^{w}+ x B2Uw(T2Bw(x))=T2Uw(x)=xw12w+x
    • 理解角度:有无符号只有最高位解释方式不同,将最高位解释为正和负使得差为2*2^{w-1} = 2^w
    • 相当于正数部分不变,负数部分整体上调表示范围的大小。把原来的最小值接到原来最大值的上面。
    • 总结:有符号和无符号的转化正数相等,负数加减2的w次方。
  • 类似C中的强制类型转化:
    重点理解:强制类型转化只是改变了解释方式,并不改变内存中的模式串

C中的无符号表示

默认为有符号数,无符号后缀加u或U

输出问题 printf

  • printf的第二个参数并不包含类型信息,解释方式完全由前面的占位符决定。

转化问题:

  • 显式转化,又叫强制类型转化,在前面加括号
  • 隐式转化:当一个运算符的两边同时有有无符号两种类型时,都转化为无符号类型
    • 会出现违反直觉的判断:
      • -1 < 0u //结果为0 ,因为-1会被转化为UINT_MAX

细节溢出问题:

我们必须把UINT_MIN写成-2147483647-1, 而不是-2147483648。
因为编译器处理-x的表达式时,先读表达式x,然后再取反。而直接读到…48会溢出。

//实验:测试对一个有符号数取反的操作
int x = ?;
int y = -x;
//当x = 1时,y = -1    , 打印结果相同
//当x = INT_MAX时, y = -INT_MAX , 打印结果相同
//当x = INT_MAX+1时,y = INT_MAX+1  ,打印结果都是-INT_MAX+1  (出现问题)

<font size=“5”, color=“pink”>最后一例印证了以上的细节问题,同时还出现了一个问题就是:我猜测编译器对于 -x 的解释是x原位串按位取反加1得到的位串代替原位串,但问题是"1000"取反后不变,导致-(INT_MAX+1)和INT_MAX+1实际的存储位串是一样的。
也就是说对绝大多数范围内的有符号数求负值(进行取反+1操作)都会得到新的对应负值的位串,但最小值是个例外,它的位串表示在进行了取反+1操作后仍是它自己。
求负值的底层逻辑(取反+1)对于0x80 00 00 00是无效操作,不会改变位模式

深入思考:看似简单的过程:用printf直接输出有符号常量-2147483628,看似没有问题,实际上有一个溢出的过程,后面的数值已经溢出了,实际位模式已经是0x10 00 00 00,但由于求负值的底层逻辑处理方式对这个位串无效,所以歪打正着看似输出了正确的结果。
再度实验验证:printf输出2147483648(INT_MAX+1)结果显示-2147383648说明之前的猜测是正确的。
启发:对于计算机内部所有的数据都是01串有更深入的理解,我们需要掌握实际数据存储的01串的表示和基本操作的底层逻辑。有时很反直觉的现象通过对于实际存储和操作逻辑的分析可以很好地解释问题出现的原因。说白了要掌握真实的处理过程,这个过程是严格遵循逻辑的,但由于解释(编码)的方式存在不可避免地逻辑无法正常对应的部分,所以反直觉的错误是由于解释方式引起的。实际应用中,养成在特殊边界时通过底层表示来思考问题就不会出现这些错误

由此开始记录一些总结内容
建立附录:[[深入理解计算机系统#附录:数据的底层表示和处理逻辑|数据的底层表示和处理逻辑]]

补充:加法逆元对于x,存在x+x’ = 0, 则x’称为x的加法逆元。
新的角度:定义上讲,INT_MIN的加法逆元就是它本身,因为-2(w-1)±2(w-1)正好溢出为0
对于一般数据的求反操作也可以理解为求自然二进制的逆元,(取反+1)取反后两数加和为全是1的模式位,再加1正好溢出为0。(说明补码的编码方式在这个概念上和自然数满足同样的逻辑关系)

拓展和截断一个数字的位表示

拓展:从一个较小的类型转化为一个较大的类型

  • 无符号拓展:直接在高位补零
  • 有符号拓展:在多出来的高位补符号位。(与算数右移有相似之处,有不同)
short sx = -12345
(unsigned) (int) sx  /*4294954951*/
(unsigned) (unsigned short)  /*53191*/
//第一个是有符号拓展,第二个是无符号拓展
//拓展和逻辑右移的区别:拓展只在前面补,逻辑右移在前面补,后面截,最终位数
//没有变。除此区别之位无符号拓展对应逻辑右移,有符号拓展对应算数右移。

//实验二
printf("%d",(((unsigned)x)<<24)>>24)  //无符号x左移24再右移24
//x = 127  -->127
//x = 128  -->-128
//x = 255  -->-1
//x = 256  -->0

辅助理解负数存储过程:

  • 实际负值(-x)–读入x编码,再取反加1.–>计算机存储串
  • 存储串–若最高位为1取反加一,输出时在得到的值前加负号。–>结果
  • 若最高位为0直接输出。若为整数直接存储。

关于有符号和无符号的建议:

有符号和无符号的隐式强制类型转换导致了某些与直觉不相符的行为。并且这些错误常常容易被忽视。

2.3整数运算

无符号加法:

  • 溢出最大值的部分直接截断
  • 可视为模运算(mod2^w)
  • x的加法逆元是2^w-x
  • x+y = s 若s<x 则 一定发生溢出
  • 增长过程中一旦超出最大值,从0开始继续增长。

有符号加法(二进制补码):

  • 两个数的二进制补码之和与无符号之和有完全相同的位级表示
    x + w t y = U 2 T w [ ( x + y ) m o d 2 ] x+_w^ty=U2T_w[(x+y)mod2] x+wty=U2Tw[(x+y)mod2]
    • 会出现三种情况
      • 负溢出(x+y<-2^(w-1)),减少过程中减少到下限后从表示范围的上限开始继续减少
        • 位级表现,负数加负数,符号位进位归零,且低一位没有进位到符号位。
      • 正常(x+y在表示范围内),只有最高位权重反常,只要计算不影响最高位正确定,结果就是正常的。(负加负要保证向最高位进位,结果才正确)
      • 正溢出,(x+y>2^(w-1)),增加过程中超过上限后从下限开始增长。
        • 位级表现,正数加正数,进位溢出到符号位。
      • 溢出后数值差为2^w

一种理解方法:
补码的表示方式其实就是把自然二进制码的上半部分补到了下半部分下面。表示范围大小没变,原本对应的无符号多出的部分整体位移到负数部分。
向上越界后回到下界,向下越界后回到上界。类似一种特殊的模运算,计算前加上一个值,模运算后又减掉。但又不完全一样。
如果把原点定义在数值上的最小值,位串对应"0x80 00 00 00",再去对应模运算就更像了。

2.4浮点

目前所有计算机都支持IEEE(电器和电子工程师协会)浮点标准。

IEEE浮点标准用这个形式表示一个数 V = ( − 1 ) s × M × 2 E V = (-1)^s\times M\times 2^E V=(1)s×M×2E

  • 符号(sign)s决定一个数是正数还是负数
  • 有效数(significand)M是一个二进制小数,小数域frac,n位
  • 指数(exponent)E是2的幂,作用是对浮点数的加权,指数域exp,k位
名称占字节数sexpfrac
float41823
double811152

给定的位表示,根据exp的值,编码被分成3种不同的情况

  • 规格化值 exp的位模式不全为1或全为0。
    • 指数域解释为偏置形式:E = e - bias
      • 其中e表示位模式二进制值,bias = 2^(k-1)-1
      • 单精度范围:-126~127 双精度范围:-1022~1023
    • 小数域定义为M = 1 + f
      • f表示二进制位模式的值,M默认以1开头
  • 非规格化值 指数域全为0(用来表示非常接近0的数)
    • 指数域解释E= 1 - bias,为实现平滑过渡
  • 特殊数值 指数域全为1
    • 小数域全为0时表示无穷
    • 小数域非零时表示NaN,意思是不是一个数
舍入

采用向偶数舍入的方式,实际上提供了四种方案,这种最接近原始值
我们将最低位0认为是偶数,1认为是奇数。

浮点运算

浮点运算缺少重要的群属性(无结合性)

程序的机器级表示

gcc -S code.c    //产生code.s文件,是C编译器产生的汇编代码
//GCC按照自己的格式生成汇编代码,这种格式称为GAS,同Intel文档中格式差异
//很大
gcc -c code.c    //产生code.o文件,二进制,不可读,可反汇编

(gdb)disassemble  //反汇编器

odjdump -d code.o  //可以充当反汇编器
//这种方式产生的汇编文件与gcc直接产生的不太一样
//命名规则与GAS有细微差别,省略了很多指令结尾的l
//个人感觉更好读懂,少了很多难懂的部分,至少显示的只有指令码和操作数

有关优化

优化等级,随数值增长而提升。
提高优化等级使最终程序运行得更快,但编译时间可能变长,对代码得调试也会更困难。
第二等级的优化-O2是性能和使用方便之间的一种很好的妥协。
注意:不同的优化方式最终产生的机器级指令不同,反汇编产生的汇编语言也不同。目前发现优化后指令数量明显变少,且书中展示的应该是没有优化过的汇编指令。

进一步

//code.c
int accum = 0;
int sum (int x, int y) {
	int t = x + y;
}
gcc -o prog code.o main.c   //产生二进制文件prog
objdump -d prog             //输出反汇编结果
//相比于直接反汇编code.o
//输出结果明显变多,

3.3数据格式

由于是从16位体系结构拓展成32位的,Inter用术语“字”表示16位数据。
32叫双字,64位叫四字。大多数指令都是对字节或双字进行操作的。

两个字节是一个字,指针都是4字节的双字。

GAS中,操作后缀表明操作数大小。例如:movb(传送字节)、movw(字)、movl(双字)。注意GAS用后缀l同时表示4字节的正数和8字节的双精度浮点数。这样不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。

3.4访问信息

一个IA32中央处单元CPU包含八个32位值得寄存器。它们都以%e开头。

  • %eax %ebx %ecx %edx中低16位叫%ax-dx, 还可以细分为%ah,%al
  • %esi %edi %esp %ebp 低16位也可以单独使用。
3.4.1 操作数指示符

大多数指令都有一个或多个操作数,指示出执行一个操作数中要引用得源数据值,以及放置结果的目的位置。各种操作数被分为三种类型。

  • 立即数,也就是常数值,跟在$后(如:$-577, $0x1F),Imm表示
  • 寄存器,表示寄存器的内容(%eax, %al) R[E_a(寄存器代号,代表%eax)]
  • 存储器,根据计算出来的地址访问某个存储位置。M[地址]
类型格式操作数值名称
立即数$ImmImm立即数寻址
寄存器E_aR[E_a]寄存器寻址
寄存器ImmM[Imm]绝对寻址
寄存器(E_a)M[E_a]间接寻址
寄存器Imm(E_b)M[Imm+R[E_b]](基址+偏移量)寻址
寄存器(E_b,E_i)M[R[E_b]+R[E_i]]变址
寄存器Imm(E_b,E_i)M[Imm+R[E_b]+R[E_i]]寻址
寄存器(,E_i,s)M[R[E_i]*s]伸缩化变址寻址
寄存器Imm(,E_i,s)M[Imm+R[E_i]*s]伸缩化变址寻址
寄存器(E_b,E_i,s)M[R[E_b]+R[E_i]*s]伸缩化变址寻址
寄存器Imm(E_b,E_i,s)M[Imm+R[E_b]+R[E_i]*s]伸缩化变址寻址

精华提炼:

  • 立即数: $Imm
  • 寄存器:E_a
  • 存储器:
    • 寄存器表示加括号,参数*3: (基址,变址,变址倍数)
    • Imm(单独或括号前)另外加上Imm的值即可

附录:数据的底层表示和处理逻辑

数据表示:

  • 整数:包括char、short、int、long
    • 分类
      • 无符号:自然二进制 (非常自然的表示)
      • 有符号:补码表示 (最高位权重取负)
    • 操作:
      • 取反:- (用自然二进制的补码表示有符号数,求反的过程就是取反+1。注意这一操作对INT_MIN无效)
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值