[笔记]Windows核心编程《十八》堆栈

系列文章目录

[笔记]Windows核心编程《一》错误处理、字符编码
[笔记]Windows核心编程《二》内核对象
[笔记]Windows核心编程《三》进程
[笔记]Windows核心编程《四》作业
[笔记]快乐的LInux命令行《五》什么是shell
[笔记]Windows核心编程《五》线程基础
[笔记]Windows核心编程《六》线程调度、优先级和关联性
[笔记]Windows核心编程《七》用户模式下的线程同步
[笔记]Windows核心编程《八》用内核对象进行线程同步
[笔记]Windows核心编程《九》同步设备I/O和异步设备I/O
[笔记]Windows核心编程《十一》Windows线程池
[笔记]Windows核心编程《十二》纤程
[笔记]Windows核心编程《十三》windows内存体系结构
[笔记]Windows核心编程《十四》探索虚拟内存
[笔记]Windows核心编程《十五》在应用程序中使用虚拟内存
[笔记]Windows核心编程《十六》线程栈
[笔记]Windows核心编程《十七》内存映射文件
[笔记]Windows核心编程《十八》堆栈
[笔记]Windows核心编程《十九》DLL基础
[笔记]Windows核心编程《二十》DLL的高级操作技术
[笔记]Windows核心编程《二十一》线程本地存储器TLS
[笔记]Windows核心编程《二十二》注入DLL和拦截API
[笔记]Windows核心编程《二十三》结构化异常处理

前言

windows 对内存进行操作的机制:

  • 虚拟内存
  • 文件映射
  • 堆栈

使用堆栈场景:

  • 堆栈可以用来分配许多较小的数据块。
    例如,若要对链接表和链接树进行管理,最好的方法是使用堆栈。(而不是第15章介绍的虚拟内存操作方法或第1 7章介绍的内存映射文件操作方法)

堆栈优缺点:

  • 堆栈的优点是,
    可以不考虑分配粒度和页面边界之类的问题,集中精力处理手头的任务。
  • 堆栈的缺点是,
    1.分配和释放内存块的速度比其他机制要慢。
    2.并且无法直接控制物理存储器的提交和回收。

堆栈是什么:

堆栈实际是一种栈,Push Pop FIFO特性。
只不过这个操作区域都是在进程的栈区。
请见总结。

特点1

内存分配达到一定页面数量时才会提交物理分配器,堆栈管理器才会提交到堆栈,堆栈释放时也是通过堆栈管理器回收已分配得物理存储器。

从内部来讲,堆栈是地址空间的一个保留区域。
开始时,该保留区域中的大多数页面没有被提交物理存储器。当从堆栈中进行越来越多的内存分配时,堆栈管理器将把更多的物理存储器提交给堆栈。物理存储器总是从系统的页文件中分配的,当释放堆栈中的内存块时,堆栈管理器将收回这些物理存储器。

Microsoft并没有以文档的形式来规定堆栈释放和收回存储器时应该遵循的具体规则, Windows 98 与Windows 2000的规则是不同的。可以这样说,Windows 98 更加注重内存的使用, 因此只要可能,它就收回堆栈。Windows 2000 更加注重速度,因此它往往较长时间占用物理存储器,只有在一段时间后页面不再使用时,才将它返回给页文件。 Microsoft常常进行适应性测 试并运行各种不同的条件,以确定在大部分时间内最适合的规则。随着使用这些规则的应用程序和硬件的变更,这些规则也会有所变化。如果了解这些规则对你的应用程序非常关键,那么 请不要使用堆栈。相反,可以使用虚拟内存函数(即 VirtualAlloc和VirtualFree),这样,就能够控制这些规则。

一、进程的默认堆栈

进程的默认堆栈:当进程初始化时,系统在进程的地址空间中创建一个堆栈。该堆栈称为进程的默认堆栈。按照默认设置,该堆栈的地址空间区域的大小是 1 MB。

但是,系统可以扩大进程的默认堆栈,使它大于其默认值。当创建应用程序时,可以使用 / H E A P链接开关,改变堆栈的1 M B默认区域大小。由于 D L L没有与其相关的堆栈,所以当链接 D L L时,不应该使用 /HEAP链接开关。
/ HEAP链接开关的句法如下:
/HEAP: reserve[ .commit]

许多Windows函数要求进程使用其默认堆栈。例如, Windows 2000的核心函数均使用
Unicode字符和字符串执行它们的全部操作。如果调用 Windows函数的A N S I版本,那么该ANSI版本必须将ANSII字符串转换成Unicode字符串,然后调用同一个函数的 Unicode版本。为了进行字符串的转换,A N S I函数必须分配一个内存块,以便放置 Unicode版本的字符串。该内存块是从你的进程的默认堆栈中分配的。 Windows的其他许多函数需要使用一些临时内存块,这些内存块是从进程的默认堆栈中分配的。另外,老的 1 6位Windows函数LocalAlloc和GlobalAlloc也是从进程的默认堆栈中进行它们的内存分配的。

特点1

对默认堆栈的访问是顺序进行的。

系统必须保证在规定的时间内,每次只有一个线程能够分配和释放默认堆栈中的内存块。如果两个线程试图同时分配默认堆栈中的内存块,那么只有一个线程能够分配内存块,另一个线程必须等待第一个线程的内存块分配之后,才能分配它的内存块。一旦第一个线程的内存块分配完,堆栈函数将允许第二个线程分配内存块。

缺点:

  • 这种顺序访问方法对速度有一定的影响。

特点2

单个进程可以同时拥有若干个堆栈。
这些堆栈可以在进程的寿命期中创建和撤消。但是,默认堆栈是在进程开始执行之前创建的,并且在进程终止运行时自动被撤消。不能撤消进程的默认堆栈。每个堆栈均用它自己的堆栈句柄来标识,用于分配和释放堆栈中的内存块的所有堆栈函数都需要这个堆栈句柄作为其参数。

可以通过调用G e t P r o c e s s H e a p函数获取你的进程默认堆栈的句柄:

HANDLE GetProcessHeap( );

二、为什么要创建辅助堆栈

除了进程的默认堆栈外,可以在进程的地址空间中创建一些辅助堆栈。
由于下列原因,你可能想要在自己的应用程序中创建一些辅助堆栈:

  • 保护组件。
  • 更加有效地进行内存管理。
  • 进行本地访问。
  • 减少线程同步的开销。
  • 迅速释放。

2.1 保护组件

需要保护的组件都放在同一个堆栈的话,某一个保护组件的出错会影响另一个组件。
所以有必要创建两个堆栈分别存放需要保护的组件,进行隔离保护。

2.2 更有效的内存管理

需要保护的组件都放在同一个堆栈的话,某一个保护组件的部分对象被释放时,再放入另一个保护组件的对象,因为两种对象的大小不同,导致会产生内存碎片。
反之,如果每个堆栈只包含大小相同的对象,那么释放一个对象后,另一个对象就可以恰好放入被释放的对象空间中。

2.3 进行本地访问

每当系统必须在RAM与系统的页文件之间进行RAM页面的交换时,系统的运行性能就会受到很大的影响。
如果经常访问局限于一个小范围地址的内存,那么系统就不太可能需要在RAM与磁盘之间进行页面的交换。

所以,在设计应用程序的时候,如果有些数据将被同时访问,那么最好把它们分配在互相
靠近的位置上。
需要保护的组件都放在同一个堆栈的话,遍历某一个组件的所有对象时,可能存在组件的对象存放在不同内存页面,最坏情况下就是一个组件的对象存放在一个内存页面,其余都是其他组件的对象,在这种情况下,遍历目标组件对象,将可能导致每个节点的页面出错(即页文件不存在对象,需要重新映射页文件),从而使进程运行得极慢,耗时。

2.4 减少线程同步的开销

多线程访问同一个堆栈时,由于堆栈访问时顺序的,系统会对访问进行顺序控制,因此数据不会受到破坏。但是存在缺点:

  • 堆栈函数必须要执行额外代码。用以保证堆栈对线程的安全性,如果要进行大量的堆栈分配操作,那么执行这些额外的代码会增加很大的负担,从而降低你的应用程序的运行性能。

反之,

一个线程只访问一个堆栈时,可以告诉系统 堆栈函数不用执行额外代码,但是堆栈对线程的安全性却需要自己管理,系统不负责了。

2.5 迅速释放堆栈

最后要说明的是,将专用堆栈用于某些数据结构后,就可以释放整个堆栈,而不必显式释
放堆栈中的每个内存块。

三、如何创建辅助堆栈

你可以在进程中创建辅助堆栈,方法是让线程调用 HeapCreate函数:

HANDLE HeapCreate(
	DWORD fdwOptions,
	SIZE_T dwInitia1Size.
	SIZE_T dwMaximumSize
); .

fdwOptions:修改如何在堆栈上执行各种操作,你可以设定 0、HEAP_NO_SERIALIZE、HEAP_GENERATE_EXCEPTIONS 或者是这两个标志的组合。

dwInitialSize:用于指明最初提交给堆栈的字节数。如果必要的话,HeapCreate 函数会将这个值转整为CPU页面大小的倍数。

dwMaximumSize:用于指明堆栈能够扩展到的最大值(即系统能够为堆栈保留的地址空间的最大数量)。

如果dwMaximumSize 大于0,那么你创建的堆栈将具有最大值。
如果尝试分配的内存块会导致堆栈超过其最大值,那么这种尝试就会失败。
如果dwMaximumSize的值是0,那么可以创建一个能够扩展的堆栈,它没有内在的限制。从堆栈中分配内存块只需要使堆栈不断扩展,直到物理存储器用完为止。
如果堆栈创建成功,HeapCreate函数返回一个句柄以标识新堆栈。该句柄可以被其他堆栈函数使用。

按照默认设置,堆栈将顺序访问它自己,这样,多个线程就能够分配和释放堆栈中的内存 块而不至于破坏堆栈。

当试图从堆栈分配一个内存块时, HeapAlloc函数(下面将要介绍)必须执行下列操作:

  1. 遍历分配的和释放的内存块的链接表。
  2. 寻找一个空闲内存块的地址。
  3. 通过将空闲内存块标记为“已分配”分配新内存块。
  4. 将新内存块添加给内存块链接表。

3.0 简述标志作用

HEAP_NO_SERIALIZE 标志

应该避免使用 HEAP_NO_SERIALIZE标志:

假定有两个线程试图同时从同一个堆栈中分配内存块。线程 1执行上面的第一步和第二步,获得了空闲内存块的地址。但是,在该线程可以执行第三步之前,它的运行被线程 2抢占,线程 2得到一个机会来执行上面的第一步和第二步。由于线程 1尚未执行第三步,因此线程2发现了同一个空闲内存块的地址。
由于这两个线程都发现了堆栈中它们认为是空闲的内存块,因此线程 1更新了链接表,给新内存块做上了“已分配”的标记。然后线程 2也更新了链接表,给同一个内存块做上了“已分配”标记。到现在为止,两个线程都没有发现问题,但是两个线程得到的是完全相同的内存块的地址。

总结就是,由于线程之间的抢占机制,可能导致不同的线程申请了同一块内存块,引发出一些问题。可能出现的问题是:

  • 内存块的链接表已经被破坏。在试图分配或释放内存块之前,这个问题不会被发现。
  • 两个线程共享同一个内存块。线程1和线程2会将信息写入同一个内存块。当线程1查看该
    内存块的内容时,它将无法识别线程2提供的数据。
  • 一个线程可能继续使用该内存块并且将它释放,导致另一个线程改写未分配的内存。这
    将破坏该堆栈。

解决以上问题的方法是:

  • 单个线程独占对堆栈和它的链接表的访问权,直到该线程执行了 对堆栈的全部必要的操作。
  • 不使用 H E A P _ N O _ S E R I A L I Z E标志,就能够达到这个目的。

安全地使用 HEAP_NO_SERIALIZE标志的条件:

  • 你的进程只使用一个线程。
  • 你的进程使用多个线程,但是只有单个线程访问该堆栈。
  • 你的进程使用多个线程,但是它设法使用其他形式的互斥机制,如关键代码段、互斥对
    象和信标(第8、9章中介绍),以便设法自己访问堆栈。

不使用 HEAP_NO_SERIALIZE 标志的优缺点:(推荐不使用!)

  • 缺点:不使用的话,每当调用堆栈函数时,线程的运行速度会受到一定的影响。
  • 优点:不会破坏你的堆栈及其数据。

HEAP_GENERATE_EXCEPTIONS 标志

会在分配或重新分配堆栈中的内存块的尝试失败时,导致系统引发一个异常条件。
所谓异常条件,只不过是系统使用的另一种方法,以便将已经出现错误的情况通知你的应用程序。有时在设计应用程序时让它查看异常条件比查看返回值要更加容易些。

3.1 从堆栈中分配内存块

若要从堆栈中分配内存块,只需要调用HeapAlloc函数:

PVOID HeapAlloc(
	HANDLE hHeap,
	DWORD fdwF1ags,
	SIZE_T dwBytes 
);

hHeap:用于标识分配的内存块来自的堆栈的句柄。
dwByte:参数用于设定从堆栈中分配的内存块的字节数。
fdwFlags:用于设定影响分配的各个标志。目前支持的标志只有3个,即 HEAP_ZERO_MEMORY、HEAP_GENERATE_EXCEPTIONS和HEAP _NO_SERIALIZE。

  1. HEAP_ZERO_MEMORY标志的作用应该是非常清楚的。该标志使得 HeapAlloc在返回前用 0来填写内存块的内容。

  2. HEAP_GENERATE_EXCEPTIONS标志 用于在堆栈中没有足够的内存来满足需求时使 HeapAlloc函数引发一个软件异常条件。 当用HeapCreate函数创建堆栈时,可以设定HEAP_GENERATE_EXCEPTIONS标志,它告诉堆栈,当不能分配内存块时,就应该引发一个异常条件。
    如果在调用HeapCreate函数时设定了这个标志,那么当调用HeapAlloc函数时,就不需要设定该标志。另外,你可能想要不使用该标志来创建堆栈。在这种情况下,为HeapAlloc函数设定该标志只会影响对HeapAlloc函数的一次调用,并不是每次调用都会受到影响。
    如果HeapAlloc运行失败,引发一个异常条件,那么这个异常条件将是表 18-1中的两个异
    常条件之一。
    在这里插入图片描述
    如果内存块已经成功地分配, HeapAlloc返回内存块的地址。如果内存不能分配并且没有
    设定HEAP_GENERATE_EXCEPTIONS标志,那么HeapAlloc函数返回NULL。

  3. HEAP _NO_SERIALIZE标志 可以用来强制对HeapAlloc函数的调用与访问同一个
    堆栈的其他线程不按照顺序进行。在使用这个标志时应该格外小心,因为如果其他线程在同一
    时间使用该堆栈,那么堆栈就会被破坏。当从你的进程的默认堆栈中分配内存块时,决不要使
    用这个标志,因为数据可能被破坏,你的进程中的其他线程可能在同一时间访问默认堆栈。

Windows 98 如果调用HeapAlloc函数并且要求分配大于256 MB的内存块,Wi n d o w s
98 就将它看成是一个错误,函数的调用将失败。
注意:在这种情况下,该函数总是返回NULL,并且不会引发异常条件,即使你在创建堆栈或者试图分配内存块时使用HEAP_GENERATE_EXCEPTIONS标志,也不会引发异常条件。
注意:当你分配较大的内存块(大约1 MB或者更大)时,最好使用VirtualAlloc函数,应该避免使用堆栈函数。

3.2 改变内存块的大小

场景:

  • 有些应用程序开始时分配的内存块比较大,然后,当所有数据放入内存块后,再缩小内存块的大小。
  • 有些应用程序开始时分配的内存块比较小,后来需要将更多的数据拷贝到内存块中去时,再设法扩大它的大小。

如果要改变内存块的大小,可以调用H e a p R e A l l o c函数:

PVOID HeapReAlloc(
	HANDLE hHeap,
	DWORD fdwF1ags,
	PVOID pvMem,
	SIZE_T dwBytes 
);

hHeap参数:用于指明包含你要改变其大小的内存块的堆栈。
fdwFlags参数:用于设定改变内存块大小时H e a p R e A l l o c函数应该使用的标志。可以使用的标志只有下面4个,即

  • HEAP_GENERATE_EXC EPTIONS
  • HEAP_NO_SERIALIZE
  • HEAP_ZERO_MEMORY
  • HEAP_REALLOC_IN_PLACE_ONLY。

前面两个标志在用于HeapAlloc时,其作用相同。HEAP_ZERO_MEMORY标志只有在你扩大内存块时才使用。在这种情况下,内存块中增加的字节将被置 0。如果内存块已经被缩小,那么该标志不起作用。HEAP_REALLOC_ IN_PLACE_ONLY标志告诉HeapReAlloc函数,它不能移动堆栈中的内存块。如果内存块在增大,HeapReAlloc函数可能试图移动内存块。如果HeapReAlloc能够扩大内存块而不移动它,那么它将会这样做并且返回内存块的原始地址。另外,如果 HeapReAlloc必须移动内存块的内容,则返回新的较大内存块的地址。如果内存块被缩小, HeapReAlloc将返回内存块的原始地址。如果内存块是链接表或二进制树的组成部分,那么可以设定HEAP_REALLOC_IN_PLACE_ONLY标志。在这种情况下,链接表或二进制树中的其他节点可能拥有该节点的指针,改变堆栈中的节点位置会破坏链接表的完整性。

pvMem:用于设定你要改变其大小的内存块的地址
dwBytes:内存块的新的大小(以字节为计量单位)。

3.3 了解内存块的大小

当内存块分配后,可以调用HeapS i z e函数来检索内存块的实际大小:

SIZE_T Heapsize(
	HANDLE hHeap,
	DWORD fdwFlags,
	LPCV01D pvMem
);

hHeap:用于标识堆栈,参数pvMem用于指明内存块的地址。参数 fdwFlags既可以是0,
也可以是HEAP_NO_SERIALIZE。

3.4 释放内存块

当不再需要内存块时,可以调用H e a p F r e e函数将它释放:

B00L HeapFree(
	HANDLE hHeap,
	DWORD fdwF1ags,
	PVOID pvMem
);

HeapFree函数用于释放内存块,如果它运行成功,便返回TRUE。参数fdwF lags既可以是0,也可以是HEAP_NO_SERIALIZE。调用这个函数可使堆栈管理器收回某些物理存储器,但是这没有保证。

3.5 撤消堆栈

如果应用程序不再需要它创建的堆栈,可以通过调用 HeapDestroy 函数将它撤消:

BO0L HeapDestroy (HANDLE hHeap);

调用HeapDestroy函数可以释放堆栈中包含的所有内存块,也可以将堆栈占用的物理存储器和保留的地址空间区域重新返回给系统。如果该函数运行成功,HeapDestroy返回T R U E。如果在进程终止运行之前没有显式撤消堆栈,那么系统将为你将它撤消。但是,只有当进程终止运行时,堆栈才能被撤消。如果线程创建了一个堆栈,当线程终止运行时,该堆栈将不会被撤消。在进程完全终止运行之前,系统不允许进程的默认堆栈被撤消。如果将进程的默认堆栈的句柄传递给HeapDestroy 函数,系统将忽略对该函数的调用。

3.6 用C++程序来使用堆栈

cSomeC1ass* pSomeC1ass = new CSomeC1ass;
delete pSomeC1ass;

重载new和delete操作符,就能够很容易地利用堆栈函数。
CSomeClass类定义为如下的形式:

c1ass CSomeClass {
private:
	static HANDLE s_hHeap;
	static UINT s_uNumAllocsInHeap;
	//other private data and member functions
pub1ic:
	void* operator new(size_t size);
	void  operator de1ete(void* p);
	//other public data and . member functions
}

HANDLE CSomeClass::s_.hHeap = NULL;
UINT CSomeC1ass::s__uNumA11ocs InHeap = 0;
void* CSomeClass::operator new ( size_t size) 
{
	if (s_hHeap == NULL) 
	{
		//Heap does not exist; create it.
		s_hHeap = HeapCreate( HEAP_NO_SERIALIZE,00);
		if( s_hHeap == NULL)
			return (NULL);
	}
	
	//The heap exists for CSomeC1ass objects.
	void* p = HeapA11oc( shHeap. 0,size);
	if (p != NULL)
	{
		//Memory was a1located successfu1ly; incrementll the count of 	CSomeC1ass objects in the heap.
		s_uNumA11ocsInHeap++;
	}
	
	//Return the address of the a1located cSomeC1ass object.
	return(p);
}


void CSomeClass::operator delete (void* p)
{
	if ( HeapFree( s_hHeap. 0. p)) 
	{
		//Object was de1eted successfu11y.
		s_uNumA11ocsInHeap--;
	}
	
	if (s_uNumA11ocsInHeap ==0 )
	{
		//If there are no more objects in the heap.ll destroy the heap.
		if (HeapDestroy ( s_hHeap)) 
		{
			//Set the heap handle to NULL so that the new operatorll wi11 know to create a new heap if a new csomeC1assll object is created.
			s_hHeap = NULL;
		}
	}
}

CSomeClass类的所有实例都在相同的堆栈中分配。
s_hHeap:包含分配CSomeClass对象时所在堆栈的句柄。s_uNumAllocsInHeap:只是一个计数器,用于计算堆栈中已经分配了多少个CSomeClass对象。

四、其他堆栈函数

获取现有堆栈的句柄

由于进程的地址空间中可以存在多个堆栈,因此可以使用 GetProcessHeaps函数来获取现有堆栈的句柄:

DWORD GetProcessHeaps(
	DWORD dwNumHeaps,
	PHANDLE pHeaps 
);

若要调用GetProcessHeaps函数,必须首先分配一个HANDLE数组,然后调用下面的函数:

HANDLE hHeaps[25];
DWORD dwHeaps = GetProcessHeaps(25,hHeaps);
if (dwHeaps > 25)
{
	//More heaps are in this process than we expected.
}e1se {
	//hHeaps[0] through hHeap[dwHeaps - 1]
	//identify the existing heaps.
}

验证堆栈的完整性

HeapValidate函数用于验证堆栈的完整性

B00L HeapValidate(
	HANDLE hHeap,
	DWORD fdwF1ags,
	LPCVOID pvMem
);

合并地址中的空闲内存块

合并地址中的空闲内存块并收回不包含已经分配的地址内存块的存储器页面 函数:

UINT HeapCompact(
	HANDLE hHeap,
	DWORD fdwF1ags 
);

通常情况下,可以为参数f d w F l a g s传递0,但是也可以传递HEAP_NO_SERIALIZE。

用于堆栈线程同步

BOOL HeapLock(HANDLE hHeap);
B00L HeapUn1ock( HANDLE hHeap);

当调用HeapLock函数时,调用线程将成为特定堆栈的所有者。
如果其他任何线程调用堆栈函数(设定相同的堆栈句柄),系统将暂停调用线程的运行,并且在堆栈被HeapUn1ock函数解锁之前不允许它醒来。

遍历堆栈的内容

B0OL Heapwalk(
	HANDLE hHeap,
	PPROCESS_HEAP_ENTRY pHeapEntry 
);

该函数只用于调试目的。它使你能够遍历堆栈的内容。可以多次调用该函数。

每次调用该函数时将传递必须分配和初始化的PROCESS_HEAP_ENTRY结构的地址:

typedef struct _PROCESS_HEAP_ENTRY
{
	PVOID lpData;
	DWORD cbData;
	BYTE cbOverhead;
	BYTE iRegionIndex;
	WORD wF1ags;
	union{
		struct {
			HANDLE hMem;
			DWORD dwReserved[ 3 ];
		}B1ock;
		
		struct {
				DMORD dwCommittedsize;
				DwORD dwUnCommittedSize;
				LPVOID lpFirstB1ock;
				LPVOID 1pLastB1ock;
		}Resion;	
	};
   }
}PROCESS_HEAP_ENTRY,*LPPROCESS_HEAP_ENTRY,*PPROCESS_HEAP_ENTRY;

当开始枚举堆栈中的内存块时,必须将成员 lpData设置为NULL。这将告诉Heapwalk对该结构中的成员进行初始化。当成功地调用 Heapwalk后,可以查看该结构的成员。
若要进入堆栈的下一个内存块,只需要再次调用Heapwalk,传递相同的堆栈句柄和在上次调用该函数时传递的PROCESS_HEAP_ENTRY结构的地址。当Heapwalk返回FALSE时,堆栈中就没有更多的内存块了。关于该结构中的成员的说明,请参见 Platform SDK文档。

在循环调用Heapwalk的时候,必须使用HeapLock和HeapLock函数,这样,当遍历堆栈时,其他线程将无法分配和释放堆栈中的内存块。

总结

1.创建堆栈HeapCreate和分配内存HeapAlloc

HeapCreate:创建堆栈。
HeapAlloc:创建给堆栈分配内存,返回内存地址。

2.堆栈,堆和栈的区别
计算机世界里的“堆栈”你真的懂吗?

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

二进制怪兽

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值