C语言—内存的管理和释放


一、Linux下内存分配管理

1.编译好的C程序文件分区

分析C语言下程序的内存分配,我们从一段简单C程序源码test.c开始。如下所示:

#include <stdio.h>
#include <stdlib.h>

int a = 1;  

int main(void) 
{
    int b = 2;
    printf("hello, world!\n");

    exit(0);                                                                                                                                       
}

Linux5.4环境下gcc编译生成a.out可执行文件,执行命令size a.out,结果如下:
在这里插入图片描述
我们发现:
C源代码进过预处理、编译、汇编和链接4步生成一个可执行程序。
程序在没有运行之前,也就是说程序没有被加载到内存前,可执行程序内部已经分好3段信息,分别是代码区(text)、数据区(data)和未初始化数据区(bss)三个部分。(部分人直接把data和bss合起来叫做全局区或静态区)。

2.C程序运行时内存分区

运行可执行程序,系统把程序加载到内存,除了根据可执行程序的信息分出代码区、初始化数据区和未初始化数据区之外,还额外增加了栈区和堆区。
下图所示为可执行代码存储时结构和运行时结构的对照图。一个正在运行着的C编译程序占用的内存分为代码区、初始化数据区、未初始化数据区、堆区和栈区5个部分。
在这里插入图片描述
典型的存储器安排,Linux下的内存分配:
text段、data段、bss段、heap和stack
在这里插入图片描述
(1) 代码区(text 段
代码区存放CPU执行的机器指令。通常代码区是可共享的(即另外的执行程序可以调用它),因为对于频繁被执行的程序,只需要在内存中有一份代码即可。代码区通常是只读的,为了防止程序意外地修改了它的指令。(在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等)

(2) 全局初始化数据区/静态数据区(data 段)
该区包含了在程序中明确被初始化的全局变量、已经初始化的静态变量(包括全局静态变量和局部静态变量)和常量数据(如字符串常量),只初始化一次,数据段属于静态内存分配。
(这里需要注意的是const修饰的变量并不会存放在该区,而是取决于它定义的地方,局部定义的就存在栈区,全局定义的就存放在静态区。)

注: 字符串常量归类在代码区还是数据区有争议,也有把它单独拿出来归类文字常量区(只读数据区)。我们只要自己清楚它是在数据区和代码区的之间就行了。该区一般用于存储只读数据,字面常量都存在这个区里面。

(3) 未初始化数据区(bss段)
存入的是全局未初始化变量和未初始化静态变量。未初始化数据区的数据在程序开始执行之前被内核初始化为0或者空(NULL)。

(4) 堆区(heap
堆是一个大容器,它的容量远大于栈,用于动态内存分配,堆在内存中位于BSS区和栈区之间。一般由程序员分配和释放,若不主动释放,程序结束时,由操作系统回收。堆区通常加载音频文件、视频文件、图像文件、文本文件以及大小超过栈大小由程序员主动申请分配的内存的大数组等。

(5) 栈区(stack
栈区, 是用户存放程序临时创建的局部变量,也就是说我们函数括弧"{}"中定义的变量(但不包括static 声明的变量,static 意味着在数据段中存放变量)。除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放回栈中。由于栈的先进先出特点,所以栈特别方便用来保存/ 恢复调用现场。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。

注意:
①所有未初始化的静态变量和全局变量,编译器会默认赋初值0
②程序在加载到内存前,代码区和全局区(data和bss)的大小就是固定的,程序运行期间不能改变
③data段和bss区中的数据的生存周期为整个程序运行过程
④data段、text区和bss区是由编译器在编译时分配的,堆和栈是由系统在运行时分配的。

下面通过一段代码来查看C程序执行时的内存分配情况:

#include <stdio.h>                                                                                                                                 
#include <stdlib.h>
 
int a;                  //全局区(静态区),bss段
int b = 5;              //全局区(静态区),data段
static int c;           //全局区(静态区),bss段
static int d = 6;       //全局区(静态区),data段

void FunTest()
{
    int *p1 = (int*)malloc(sizeof(int));  //在堆上开辟                                                                                             
//  int *p2 = new int;                    //在堆上开辟(c++)
    free(p1);
}
 
int main()
{
    int e;               //栈区
    int f = 2;           //栈区
    static int g;        //全局区(静态区),bss段
    static int h = 4;    //全局区(静态区),data段

    const int i = 3;     //栈区
    char* p = "hello word";   //p在栈上,“hello word”在常量区
    char str[] = "abcde";     //str为数组变量,存储在栈区
 
    FunTest();
    
    printf("全局未初始化变量a:    %p\n",&a);
    printf("全局初始化变量b:      %p\n",&b);
    printf("全局未初始化静态变量c:%p\n",&c);
    printf("全局初始化静态变量d:  %p\n",&d);
    printf("\n");
    printf("局部未初始化变量e:    %p\n",&e);
    printf("局部初始化变量f:      %p\n",&f);
    printf("局部未初始化静态变量g:%p\n",&g);
    printf("局部初始化静态变量h:  %p\n",&h);
    printf("\n");
    printf("局部常量i:            %p\n",&i);
    printf("字符串常量p:          %p\n",p);  
    printf("局部数组str:          %p\n",str);
    
	return 0;
}

运行结果如下:
运行结果
第四节中,关于程序内存分配情况有一个实例进行讲解。

3.为什么要进行内存分配

(1) 一个进程在运行过程中,代码是根据流程依次执行的,只需要访问一次,当然跳转和递归有可能使代码执行多次,而数据一般都需要访问多次,因此单独开辟空间以方便访问和节约空间。
(2) 临时数据及需要再次使用的代码在运行时放入栈区中,生命周期短。
(3) 全局数据和静态数据有可能在整个程序执行过程中都需要访问,因此单独存储管理。
(4) 堆区由用户自由分配,方便管理

4.内存分配方式

关于内存的分配方式也有不同的分类方法。
有的人分为三类(大部分采用这种):
(1) 静态存储区分配
内存分配在程序运行之前完成,且在程序的整个运行期间都存在,例如全局变量、静态变量等。
(2) 栈上分配
在函数执行时,函数内的局部变量的存储单元在栈上创建,函数执行结束时这些存储单元自动释放。
(3) 堆上分配
堆分配(又称动态内存分配)。程序在运行时用malloc或者new申请内存,程序员自己用free或者delete释放,动态内存的生存期由我们自己决定,若程序员不释放,程序结束后由OS回收。

有的人分为两类(个人在此存有疑问):
(1) 静态分配
分配固定大小的内存分配方法称之为静态内存分配。
例如:

int a = 100;

此行代码指示编译器分配足够的存储区以存放一个整型值,该存储区与名字a相关联,并用数值100初始化该存储区。

(2) 动态分配
动态内存分配就是指在程序执行的过程中动态地分配(如:malloc/free函数)或者回收存储空间的分配内存的方法。
例如:

p1 = (char *)malloc(10*sizeof(int));

此行代码分配了10个int类型的对象,然后返回对象在内存中的地址,接着这个地址被用来初始化指针对象p1,对于动态分配的内存唯一的访问方式是通过指针间接地访问。

说白了,内存的静态分配和动态分配的区别主要是两个:
一是时间不同:静态分配发生在程序编译的时候,动态分配则发生在程序调入和执行的时候。
二是空间不同:堆都是动态分配的,没有静态分配的堆。栈有两种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由动态分配函数进行分配的。不过栈的动态分配和堆不同,他的动态分配是由编译器进行释放,无需我们手工实现。
两点疑问: 个人感觉以上的概念模糊不清且有点矛盾
① 很多人文章里说,静态分配是编译器在处理程序源代码时分配,又说函数调用时局部变量分配在栈上也是静态分配。
② 大多数文章里说,局部变量在栈上分配是由编译器完成,个人对此持保留意见,个人认为是由OS完成的,当然如有读者可解答,非常欢迎评论区留言。

还有的文章根据上面的内存分区分为四类、五类。关于这点,我们不在赘述,我们只要清楚的知道C程序运行时的内存分区即可。

参考文章:
C语言 内存管理
c 程序内存分配管理
C语言中内存分配


二、详解堆和栈

以下内容为转载,读者可自行前往原创文章进行阅读(原创文章在本结末尾)。

堆(Heap)与栈(Stack)是开发人员必须面对的两个概念,在理解这两个概念时,需要放到具体的场景下,因为不同场景下,堆与栈代表不同的含义。一般情况下,有两层含义:
(1) 程序内存布局场景下,堆与栈表示两种内存管理方式
(2) 数据结构场景下,堆与栈表示两种常用的数据结构

1.堆和栈的简介

(1) 栈简介
栈由操作系统自动分配释放 ,用于存放函数的参数值、局部变量等,其操作方式类似于数据结构中的栈。参考如下代码:

int main() {
	int b;				//栈
	char s[] = "abc"; 	//栈
	char *p2;			//栈
}

其中函数中定义的局部变量按照先后定义的顺序依次压入栈中,也就是说相邻变量的地址之间不会存在其它变量。栈的内存地址生长方向与堆相反,由高到底,所以后定义的变量地址低于先定义的变量,比如上面代码中变量 s 的地址小于变量 b 的地址,p2 地址小于 s 的地址。栈中存储的数据的生命周期随着函数的执行完成而结束。
(2) 堆简介
堆由开发人员分配和释放, 若开发人员不释放,程序结束时由 OS 回收,分配方式类似于链表。参考如下代码:

int main() {
	// C 中用 malloc() 函数申请
	char* p1 = (char *)malloc(10);
	cout<<(int*)p1<<endl;		//输出:00000000003BA0C0
	
	// 用 free() 函数释放
	free(p1);
   
	// C++ 中用 new 运算符申请
	char* p2 = new char[10];
	cout << (int*)p2 << endl;		//输出:00000000003BA0C0
	
	// 用 delete 运算符释放
	delete[] p2;
}

其中 p1 所指的 10 字节的内存空间与 p2 所指的 10 字节内存空间都是存在于堆。堆的内存地址生长方向与栈相反,由低到高,但需要注意的是,后申请的内存空间并不一定在先申请的内存空间的后面,即 p2 指向的地址并不一定大于 p1 所指向的内存地址,原因是先申请的内存空间一旦被释放,后申请的内存空间则会利用先前被释放的内存,从而导致先后分配的内存空间在地址上不存在先后关系。堆中存储的数据若未释放,则其生命周期等同于程序的生命周期。
关于堆上内存空间的分配过程,首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆节点,然后将该节点从空闲节点链表中删除,并将该节点的空间分配给程序。另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的delete语句才能正确地释放本内存空间。由于找到的堆节点的大小不一定正好等于申请的大小,系统会自动地将多余的那部分重新放入空闲链表。

2.堆和栈的区别

堆与栈实际上是操作系统对进程占用的内存空间的两种管理方式,主要有如下几种区别

(1) 管理方式不同。栈由操作系统自动分配释放,无需我们手动控制;堆的申请和释放工作由程序员控制,容易产生内存泄漏;
注意: 很多文章里说的是栈由编译器自动管理(具体的我目前也不是很清楚)

(2) 空间大小不同。每个进程拥有的栈的大小要远远小于堆的大小。理论上,程序员可申请的堆大小为虚拟内存的大小,进程栈的大小 64bits 的 Windows 默认 1MB,64bits 的 Linux 默认 10MB;

(3) 生长方向不同。堆的生长方向向上,内存地址由低到高;栈的生长方向向下,内存地址由高到低。

(4) 分配方式不同。堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是由操作系统完成的,比如局部变量的分配。动态分配由动态分配函数进行分配的,但是栈的动态分配和堆是不同的,他的动态分配是由操作系统进行释放,无需我们手工实现。

(5) 分配效率不同。栈由操作系统自动分配,会在硬件层级对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是由C/C++提供的库函数或运算符来完成申请与管理,实现机制较为复杂,频繁的内存申请容易产生内存碎片。显然,堆的效率比栈要低得多。

(6) 存放内容不同。栈存放的内容,函数返回地址、相关参数、局部变量和寄存器内容等。当主函数调用另外一个函数的时候,要对当前函数执行断点进行保存,需要使用栈来实现,首先入栈的是主函数下一条语句的地址,即扩展指针寄存器的内容(EIP),然后是当前栈帧的底部地址,即扩展基址指针寄存器内容(EBP),再然后是被调函数的实参等,一般情况下是按照从右向左的顺序入栈,之后是被调函数的局部变量,注意静态变量是存放在数据段或者BSS段,是不入栈的。出栈的顺序正好相反,最终栈顶指向主函数下一条语句的地址,主程序又从该地址开始执行。堆,一般情况堆顶使用一个字节的空间来存放堆的大小,而堆中具体存放内容是由程序员来填充的。

从以上可以看到,堆和栈相比,由于大量malloc()/free()或new/delete的使用,容易造成大量的内存碎片,并且可能引发用户态和内核态的切换,效率较低。栈相比于堆,在程序中应用较为广泛,最常见的是函数的调用过程由栈来实现,函数返回地址、EBP、实参和局部变量都采用栈的方式存放。虽然栈有众多的好处,但是由于和堆相比不是那么灵活,有时候分配大量的内存空间,主要还是用堆。

无论是堆还是栈,在内存使用时都要防止非法越界,越界导致的非法内存访问可能会摧毁程序的堆、栈数据,轻则导致程序运行处于不确定状态,获取不到预期结果,重则导致程序异常崩溃,这些都是我们编程时与内存打交道时应该注意的问题。

(关于数据结构中的堆和栈这里就不进行讲解了,可阅读下面的文章进行学习。)

转载文章:
堆与栈的区别


三、内存管理函数

在C中动态开辟空间需要用到三个函数 :malloc(), calloc(), realloc()
这三个函数都是向堆中申请内存空间。

阅读文章:C语言动态内存分配函数

首先非常感谢这篇文章的作者,这篇文章关于内存管理函数讲解的非常好。但个人认为文章里存在一个小问题:
在这里插入图片描述
这里先说开辟的内存是在栈上的,又说数组的内存是在编译时分配好的,这是不对的。如果该数组是全局数组变量,那么编译时编译器会分配到全局(静态)区,如果是局部数组变量,编译时是不会分配内存的,进程中会在栈上开辟内存(因为其大小固定,也称为静态分配内存)。


四、其它知识

1.(嵌入式开发中)程序编译完成后的大小统计

在嵌入式开发中,一段程序编译完成后是怎样分区的呢?

相关概念:Code,RO_data,RW_data,ZI_data,RO,RW,常出现在嵌入式程序编译完成后的统计,例如MDK,IAR,ARM GCC。

有些技术文章中会直接使用RO,请注意区分RO和RO-data的区别。

Code: 即代码域,它指的是编译器生成的机器指令。
RO_data: ReadOnly data,即只读数据域,它指程序中用到的只读数据,全局变量,例如C语言中const关键字定义的全局变量就是典型的RO-data。
RW_data: ReadWrite data,即可读写数据域,它指初始化为“非0值”的可读写数据,程序刚运行时,这些数据具有非0的初始值,且运行的时候它们会常驻在RAM区,因而应用程序可以修改其内容。例如全局变量或者静态变量,且定义时赋予“非0值”给该变量进行初始化。
ZI_data: ZeroInitialie data,即0初始化数据,它指初始化为“0值”的可读写数据域,它与RW_data的区别是程序刚运行时这些数据初始值全都为0,而后续运行过程与RW-data的性质一样,它们也常驻在RAM区,因而应用程序可以更改其内容。包括未初始化的全局变量,和初始化为0的全局变量。
RO: 只读区域,包括RO_data和code。

当程序存储在ROM中时,所占用的大小为Code + RO_data + RW_data 。
当程序执行时, RW_data和 ZI_data在RAM中,RO_data和code视cpu架构(51、arm、x86)不同处于ROM或者RAM中。其中ZI_data对应了BSS段,RW_data对应数据段,code对应代码段, RO_data对应数据段。

以上内容来自文章:静态存储区(BSS、数据段、代码段),堆,栈-----------------(划归在C语言)

2.数据存储区域实例

下面这段代码中:extern etext, edata, end;,涉及到了三个我们不熟悉的变量,且没有看见它们的定义,它们到底是什么呢?

在链接过程中,链接器ld和ld86会使用变量记录下执行程序中每个段的逻辑地址。因此在程序中可以通过访问这几个外部变量来获得程序中段的位置。链接器预定义的外部变量通常至少有etext、_etext、edata、_edata、end和_end。
变量名_etext和etext的地址是程序正文段结束后的第1个地址;_edata和edata的地址是初始化数据区后面的第1个地址;end和end的地址是未初始化数据区(bss)后的第1个地址位置。 带下划线’'前缀的名称等同于不带下划线的对应名称,它们之间的唯一区别在于ANSI、POSIX等标准中没有定义符号etext、edata和end。
在这里插入图片描述
main.c文件,代码如下:

/* main.c */
#include <stdio.h>
#include <stdlib.h>

extern void afunc(void);
extern etext, edata, end;

int bss_var;                    /* 未初始化全局数据存储在BSS区 */
int data_var = 42;              /* 初始化全局数据区域存储在数据区 */

#define SHW_ADDR(ID, I) printf("the %8s\t is at addr:%p\n", ID, &I); /* 打印地址 */

int main(int argc, char *argv[]) 
{
	char *p, *b, *nb;
	printf("Addr etext: %p\t Addr edata: %p\t Addr end: %p\t\n", &etext, &edata, &end);
	
	printf("\n");
	printf("text Location:\n");
	SHW_ADDR("main", main);       /* 查看代码段main函数位置 */
	SHW_ADDR("afunc", afunc);     /* 查看代码段afunc函数位置 */
	
	printf("\n");
	printf("bss Location:\n");
	SHW_ADDR("bss_var", bss_var); /* 查看BSS段变量的位置 */
	
	printf("\n");
	printf("data Location:\n");
	SHW_ADDR("data_var", data_var); /* 查看数据段变量的位置 */
	
	printf("\n");
	printf("Stack Locations:\n");
	afunc();	/* 调用函数 */
	
	b  = (char *)malloc(32*sizeof(char)); /* 从堆中分配空间 */
	nb = (char *)malloc(16*sizeof(char)); /* 从堆中分配空间 */
	
	printf("\n");
	printf("Heap Locations:\n");
	printf("the Heap start: %p\n", b); /* 堆的起始位置 */
	printf("the Heap   end: %p\n", (nb+16*sizeof(char))); /* 堆的结束位置 */
	printf("\n");
	
	printf("b and nb in Stack\n");
	SHW_ADDR("b", b);             /* 显示指针b的位置 */
	SHW_ADDR("nb", nb);           /* 显示指针nb的位置 */

	free(b);                      /* 释放申请的空间 */
	free(nb);                     /* 释放申请的空间 */
}

afunc.c文件,代码如下:

/* afunc.c */
#include <stdio.h>

#define SHW_ADDR(ID, I) printf("the %s is at addr: %p\n", ID, &I); /* 打印地址 */

void afunc(void)
{
	static int long level = 0;    /* 静态数据存储在数据段中 */
	int stack_var;                /* 局部变量存储在栈区 */

	if(++level == 5)
	return;

	printf("stack_var %d in stack section is at addr: %p\n", level, &stack_var);
	SHW_ADDR(" level  in  data section", level);
 	printf("\n");
 	
	afunc();	/* 函数递归调用 */
}

运行结果如下:
在这里插入图片描述
根据地址的大小进行可视化排序:
在这里插入图片描述
有一个小问题,理论上 b 的地址应该是比 nb 的地址高的,但这里程序运行的结果显示却是相反。
另外关于:为什么64位系统地址只有12位16进制数?
参考文章:64位的系统,但是在调试时显示的地址为48位

3.函数调用中:字符串常量与字符数组

先来分析几段代码。

代码:test0.c

#include <stdio.h>
#include <stdlib.h>

char* toStr() 
{
	char *s = "hello word!";
	return s;
}

int main(void) 
{
	printf("%s\n", toStr());
}

运行结果:
在这里插入图片描述
运行没有任何问题,因为 “hello world” 是一个字符串常量,存放在文字常量区,把该字符串常量存放的地址赋值给了指针 s,toStr 函数退出时,该该字符串常量所在内存不会被回收,故能够通过指针顺利无误的访问
代码:test1.c

#include <stdio.h>
#include <stdlib.h>

char* toStr()
{
	char s[] = "hello word!";
	return s;
}

int main(void)
{
	printf("%s\n", toStr());
}

编译运行结果:
在这里插入图片描述
调用 toStr 函数,定义了一个局部变量( char [] 型数组),该局部变量存放在栈中,当 toStr 函数退出时,栈要清空,局部变量的内存也被清空了,所以这时的函数返回的是一个已被释放的内存地址,出现段错误。

如果函数的返回值非要是一个局部变量的地址,那么该局部变量一定要申明为static类型
代码:test2.c

#include <stdio.h>

char *returnStr()
{
    static char p[]="hello world!";
    return p;
}

int main()
{
    char *str=NULL;
    str=returnStr();
    printf("%s\n", str);
                                                                                                                                                   
    return 0;
}

运行结果:
在这里插入图片描述
综合性代码:

#include <stdio.h>

//返回的是局部变量的地址,该地址位于动态数据区,栈里                                                                                                                                                 
char *s1()
{
    char p[] = "Hello world!";

    printf("in s1 p=%p\n", p);
    printf("in s1: string's address: %p\n", &("Hello world!"));

    return p;
}

//返回的是字符串常量的地址,该地址位于静态数据区
char *s2()
{
    char *q = "Hello world!";

    printf("in s2 q=%p\n", q);
    printf("in s2: string's address: %p\n", &("Hello world!"));

    return q;
}

//返回的是静态局部变量的地址,该地址位于静态数据区
char *s3()
{
    static char r[] = "Hello world!";

    printf("in s3 r=%p\n", r);
    printf("in s3: string's address: %p\n", &("Hello world!"));
    return r;
}

int main()
{
    char *t1, *t2, *t3;

    t1 = s1();
    t2 = s2();
    t3 = s3();

    printf("\nin main:\n");
    printf("p = %p\nq = %p\nr = %p\n", t1, t2, t3);

//  printf("%s\n", t1);		/* 运行会出现段错误,先屏蔽 */
    printf("%s\n", t2);
    printf("%s\n", t3);
                                                                                                                                                   
    return 0;
}

运行结果:
在这里插入图片描述
根据运行结果我们就很清楚了,这里不再赘述。

参考文章:
Linux虚拟地址空间概述
链接器预定义变量_etext,_edata,_end
c 程序内存分配管理
函数返回指针和返回数组名的区别 (very good)

  • 24
    点赞
  • 123
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
C语言中,指针是一种非常重要的数据类型,用于存储内存地址。通过指针,程序可以访问和操作内存中的数据。指针在C语言中有着广泛的应用,包括动态内存分配、数组和函数调用等方面。 指针的使用使得程序员能够更直接地操作内存,但也带来了内存管理的责任。C语言中的内存管理是程序员需要关注的一个重要方面[1]。在C语言中,内存的分配和释放需要手动进行。如果不正确地管理内存,就容易出现内存泄漏、野指针等问题,导致程序崩溃或出现难以调试的错误。 动态内存分配是指在程序运行时根据需要分配内存空间。C语言提供了一些函数来实现动态内存分配,例如malloc、calloc和realloc函数。这些函数允许程序在运行时动态地请求所需的内存空间。 使用动态内存分配时,程序员需要负责在不再需要使用内存时手动释放已分配的内存空间,以免造成内存泄漏。释放内存的函数是free函数,通过调用free函数可以将先前分配的内存空间释放回系统。 除了动态内存分配外,C语言中还有一些其他的内存管理技术。例如,对于大型数据结构或数组,可以使用指针来减少内存的占用和提高程序的效率。此外,C语言中还有一些规则和约定来确保内存的正确使用,如避免野指针、空指针和越界访问等。 综上所述,C语言中的指针和内存管理密切相关。指针使程序能够直接操作内存,但也需要程序员正确地管理内存的分配和释放。通过动态内存分配和其他内存管理技术,可以有效地利用和管理内存,提高程序的性能和稳定性。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值