[翻译]Introduction to the Volatile Keyword(认识关键字volatile)

出处:http://www.amobbs.com/thread-719841-1-1.html

Introduction to the Volatile Keyword

认识关键字Volatile



The use of volatile is poorly understood by many programmers. This is not surprising, as most C texts dismiss it in a sentence or two. 

很多程序员对于volatile的用法都不是很熟悉。这并不奇怪,很多介绍C语言的书籍对于他的用法都闪烁其辞。



Have you experienced any of the following in your C/C++ embedded code? 

•        Code that works fine-until you turn optimization on 

•        Code that works fine-as long as interrupts are disabled 

•        Flaky hardware drivers 

•        Tasks that work fine in isolation-yet crash when another task is enabled 

在你们使用C/C++语言开发嵌入式系统的时候,遇到过以下的情况么?

•        一打开编译器的编译优化选项,代码就不再正常工作了;

•        中断似乎总是程序异常的元凶;

•        硬件驱动工作不稳定;

•        多任务系统中,单个任务工作正常,加入任何其他任务以后,系统就崩溃了。



If you answered yes to any of the above, it's likely that you didn't use the C keyword volatile. You aren't alone. The use of volatile is poorly understood by many programmers. This is not surprising, as most C texts dismiss it in a sentence or two. 

如果你曾经向别人请教过和以上类似的问题,至少说明,你还没有接触过C语言关键字volatile的用法。这种情况,你不是第一个遇到。很多程序员对于volatile都几乎一无所知。大部分介绍C语言的文献对于它都闪烁其辞。



volatile is a qualifier that is applied to a variable when it is declared. It tells the compiler that the value of the variable may change at any time-without any action being taken by the code the compiler finds nearby. The implications of this are quite serious. However, before we examine them, let's take a look at the syntax. 

Volatile是一个变量声明限定词。它告诉编译器,它所修饰的变量的值可能会在任何时刻被意外的更新,即便与该变量相关的上下文没有任何对其进行修改的语句。造成这种“意外更新”的原因相当复杂。在我们分析这些原因之前,我们先回顾一下与其相关的语法。





Syntax 

语法



To declare a variable volatile, include the keyword volatile before or after the data type in the variable definition. For instance both of these declarations will declare foo to be a volatile integer: 

要想给一个变量加上volatile限定,只需要在变量类型声明附之前/后加入一个volatile关键字就可以了。下面的两个实例是等效的,它们都是将foo声明为一个“需要被实时更新”的int型变量。

volatile int foo;

int volatile foo; 



Now, it turns out that pointers to volatile variables are very common. Both of these declarations declare foo to be a pointer to a volatile integer: 

同样,声明一个指向volatile型变量的指针也是非常类似的。下面的两个声明都是将foo定义为一个指向volatile integer型变量的指针。

volatile int * foo; 

int volatile * foo; 



Volatile pointers to non-volatile variables are very rare (I think I've used them once), but I'd better go ahead and give you the syntax: 

一个Volatile型的指针指向一个非volatile型变量的情况非常少见(我想,我可能使用过一次),尽管如此,我还是要给出他的语法:

int * volatile foo; 



And just for completeness, if you really must have a volatile pointer to a volatile variable, then: 

最后一种形式,针对你真的需要一个volatile型的指针指向一个volatile型的情形:

int volatile * volatile foo; 



Incidentally, for a great explanation of why you have a choice of where to place volatile and why you should place it after the data type (for example, int volatile * foo), consult Dan Sak's column, "Top-Level cv-Qualifiers in Function Parameters" (February 2000, p. 63). 

顺便说一下,如果你想知道关于“我们需要在什么时候在什么地方使用volatile”和“为什么我们需要volatile放在变量类型后面(例如,int volatile * foo)”这类问题的详细内容,请参考Dan Sak`s的专题,“Top-Level cv-Qualifiers in Function Parameters”。



Finally, if you apply volatile to a struct or union, the entire contents of the struct/union are volatile. If you don't want this behavior, you can apply the volatile qualifier to the individual members of the struct/union. 

最后,如果你将volatile应用在结构体或者是公用体上,那么该结构体/公用体内的所有内容就都带有volatile属性了。如果你并不想这样(牵一发而动全身),你可以仅仅在结构体/公用体中的某一个成员上单独使用该限定。





Use 

使用



A variable should be declared volatile whenever its value could change unexpectedly. In practice, only three types of variables could change: 

•        Memory-mapped peripheral registers 

•        Global variables modified by an interrupt service routine 

•        Global variables within a multi-threaded application 

当一个变量的内容可能会被意想不到的更新时,一定要使用volatile来声明该变量。通常,只有三种类型的变量会发生这种“意外”:

•        在内存中进行地址映射的设备寄存器;

•        在中断处理程序中可能被修改的全局变量;

•        多线程应用程序中的全局变量;





Peripheral registers 

设备寄存器



Embedded systems contain real hardware, usually with sophisticated peripherals. These peripherals contain registers whose values may change asynchronously to the program flow. As a very simple example, consider an 8-bit status register at address 0x1234. It is required that you poll the status register until it becomes non-zero. The nave and incorrect implementation is as follows: 

嵌入式系统的硬件实体中,通常包含一些复杂的外围设备。这些设备中包含的寄存器,其值往往随着程序的流程同步的进行改变。在一个非常简单的例子中,假设我们有一个8位的状态寄存器映射在地址0x1234上。系统需要我们一直监测状态寄存器的值,直到它的值不为0为止。通常错误的实现方法是:

UINT1 * ptr = (UINT1 *) 0x1234; 

// Wait for register to become non-zero.等待寄存器为非0值

while (*ptr == 0);

// Do something else.作其他事情





This will almost certainly fail as soon as you turn the optimizer on, since the compiler will generate assembly language that looks something like this: 

一旦你打开了优化选项,这种写法肯定会失败,编译器就会生成类似如下的汇编代码:

    mov    ptr, #0x1234     mov    a, @ptr loop     bz    loop 





The rationale of the optimizer is quite simple: having already read the variable's value into the accumulator (on the second line), there is no need to reread it, since the value will always be the same. Thus, in the third line, we end up with an infinite loop. To force the compiler to do what we want, we modify the declaration to: 

优化的工作原理非常简单:一旦我们我们将一个变量读入寄存器中(参照代码的第二行),如果(从变量相关的上下文看)变量的值总是不变的,那么就没有必要(从内存中)从新读取他。在代码的第三行中,我们使用一个无限循环来结束。为了强迫编译器按照我们的意愿进行编译,我们修改指针的声明为:

UINT1 volatile * ptr = 

    (UINT1 volatile *) 0x1234; 

The assembly language now looks like this: 

对应的汇编代码为:

    mov     ptr, #0x1234

loop    mov    a, @ptr        

    bz    loop 

The desired behavior is achieved. 

我们需要的功能实现了!





Subtler problems tend to arise with registers that have special properties. For instance, a lot of peripherals contain registers that are cleared simply by reading them. Extra (or fewer) reads than you are intending can cause quite unexpected results in these cases. 

对于一些较为特殊的寄存器,(我们上面提到的方法)会导致一些难以想象的错误。事实上,很多设备寄存器在读取一次以后就会被清除。这种情况下,多余的读取操作会导致意想不到的错误。





Interrupt service routines 

中断处理程序



Interrupt service routines often set variables that are tested in main line code. For example, a serial port interrupt may test each received character to see if it is an ETX character (presumably signifying the end of a message). If the character is an ETX, the ISR might set a global flag. An incorrect implementation of this might be: 

中断处理程序经常负责更新一些在主程序中被查询的变量的值。例如,一个串行通讯中断会检测接收到的每一个字节是否为ETX信号(以便来确认一个消息帧的结束标志)。如果其中的一个字节为ETX,中断处理程序就是修改一个全局标志。一个错误的实现方法可能为:

int etx_rcvd = FALSE; 

void main()

{

    ...

    while (!ext_rcvd)

    {

        // Wait

    }

    ...



interrupt void rx_isr(void)

{

    ...

    if (ETX == rx_char)

    {

        etx_rcvd = TRUE;

    }

    ...







With optimization turned off, this code might work. However, any half decent optimizer will "break" the code. The problem is that the compiler has no idea that etx_rcvd can be changed within an ISR. As far as the compiler is concerned, the expression !ext_rcvd is always true, and, therefore, you can never exit the while loop. Consequently, all the code after the while loop may simply be removed by the optimizer. If you are lucky, your compiler will warn you about this. If you are unlucky (or you haven't yet learned to take compiler warnings seriously), your code will fail miserably. Naturally, the blame will be placed on a "lousy optimizer." 

在编译优化选项关闭的时候,代码可能会工作的很好。但是,即便是任何半吊子的优化,也会“破坏”这个代码的意图。问题就在于,编译器并不知道etx_rcvd会在中断处理程序中被更新。在编译器可以检测的上下文内,表达式!ext_rcvd总是为真,所以,你就永远无法从循环中跳出。因此,该循环后面的代码会被当作“不可达到 ”的内容而被编译器的优化选项简单的删除掉。如果你比较幸运,你的编译器也许会给你一个相关的警告;如果你没有那么幸运(或者你没有注意到这些警告),你的代码就会导致严重的错误。通常,就会有人抱怨“该死的优化选项”。





The solution is to declare the variable etx_rcvd to be volatile. Then all of your problems (well, some of them anyway) will disappear. 

解决这个问题的方法很简单:将变量etx_rcvd声明为volatile。然后,所有的(至少是一部分症状)那些错误症状就会消失。





Multi-threaded applications 

多线程应用程序





Despite the presence of queues, pipes, and other scheduler-aware communications mechanisms in real-time operating systems, it is still fairly common for two tasks to exchange information via a shared memory location (that is, a global). When you add a pre-emptive scheduler to your code, your compiler still has no idea what a context switch is or when one might occur. Thus, another task modifying a shared global is conceptually identical to the problem of interrupt service routines discussed previously. So all shared global variables should be declared volatile. For example: 

在实时操作系统中,除去队列、管道以及其他调度相关的通讯结构,在两个任务之间采用共享的内存空间(就是全局共享)实现数据的交换仍然是相当常见的方法。当你将一个优先权调度器应用于你的代码时,编译器仍然不知道某一程序段分支选择的实际工作方式以及什么时候某一分支情况会发生。这是因为,另外一个任务修改一个共享的全局变量在概念上通常和前面中断处理程序中提到的情形是一样的。所以,(这种情况下)所有共享的全局变量都要被声明为volatile。例如:

int cntr; 

void task1(void)

{

    cntr = 0;

    while (cntr == 0)

    {

        sleep(1);

    }

    ...



void task2(void)

{

    ...

    cntr++;

    sleep(10);

    ...







This code will likely fail once the compiler's optimizer is enabled. Declaring cntr to be volatile is the proper way to solve the problem. 

一旦编译器的优化选项被打开,这段代码的执行通常会失败。将cntr声明为volatile是解决问题的好办法。





Final thoughts 

反思



Some compilers allow you to implicitly declare all variables as volatile. Resist this temptation, since it is essentially a substitute for thought. It also leads to potentially less efficient code. 

一些编译器允许我们隐含的声明所有的变量为volatile。最好抵制这种便利的诱惑,因为它很容易让我们“不动脑子”,而且,这也常常会产生一个效率相对较低的代码。





Also, resist the temptation to blame the optimizer or turn it off. Modern optimizers are so good that I cannot remember the last time I came across an optimization bug. In contrast, I come across failures to use volatile with depressing frequency. 

所以,我们又诅咒编译优化或者简单的关掉这一选项来抵制这些诱惑。现在的编译优化已经相当聪明,我不记得在编译优化中找到过什么错误。与之相比,为了解决一些错误,我却常常使用疯狂数量的volatile。





If you are given a piece of flaky code to "fix," perform a grep for volatile. If grep comes up empty, the examples given here are probably good places to start looking for problems. 

如果你恰巧有一段代码需要去修正,先搜索一下有没有volatile关键字。如果找不到volatile,那么这个代码很可能会是一个很好的实例来检测前面提到过的各种错误。





Nigel Jones is a consultant living in Maryland. When not underwater, he can be found slaving away on a diverse range of embedded projects. He can be reached at NAJones@compuserve.com. 

Nigel Jones 在马里兰从事顾问工作。除了为各类嵌入式项目开发充当顾问,他平时的一大爱好就是潜水。你可以通过发送邮件到NAJones@compuserve.com与其取得联系。


出处:http://blog.163.com/zhaojie_ding/blog/static/1729728952007925115019663

Volatile的陷阱  

最近写的关于在嵌入式开发中常遇到的关于volatile关键字使用的短文,都是些通用的技术,贴上来share。
 
对于volatile关键字,大部分的C语言教材都是一笔带过,并没有做太过深入的分析,所以这里简单整理了一些关于volatile的使用注意事项。实际上从语法上来看volatile和const是一样的,但是如果const用错,几乎不会有什么问题;而volatile用错,后果可能很严重。所以在volatile的使用上,建议大家还是尽量求稳,少用一些没有切实把握的技巧。

注意volatile修饰的是谁

首先来看下面两个定义的区别:

uchar * volatile reg;

这行代码里volatile修饰的是reg这个变量。所以这里实际上是定义了一个uchar类型的指针,并且这个指针变量本身是volatile 的。但是指针所指的内容并不是volatile的!在实际使用的时候,编译器对代码中指针变量reg本身的操作不会进行优化,但是对reg所指的内容 *reg却会作为non-volatile内容处理,对*reg的操作还是会被优化。通常这种写法一般用在对共享指针的声明上,即这个指针变量有可能会被中断等函数修改。将其定义为volatile以后,编译器每次取指针变量的值的时候都会从内存中载入,这样即使这个变量已经被别的程序修改了当前函数用的时候也能得到修改后的值(否则通常只在函数开始取一次放在寄存器里,以后就一直使用寄存器内的副本)。

volatile uchar *reg;

这行代码里volatile修饰的是指针所指的内容。所以这里定义了一个uchar类型的指针,并且这个指针指向的是一个volatile的对象。但是指针变量本身并不是volatile的。如果对指针变量reg本身进行计算或者赋值等操作,是可能会被编译器优化的。但是对reg所指向的内容 *reg的引用却禁止编译器优化。因为这个指针所指的是一个volatile的对象,所以编译器必须保证对*reg的操作都不被优化。通常在驱动程序的开发中,对硬件寄存器指针的定义,都应该采用这种形式。

volatile uchar * volatile reg;

这样定义出来的指针就本身是个volatile的变量,又指向了volatile的数据内容。

volatile与const的合用

从字面上看,volatile和const似乎是一个对象的两个对立属性,是互斥的。但是实际上,两者是有可能一起修饰同一个对象的。看看下面这行声明:

extern const volatile unsigned int rt_clock;

这是在RTOS系统内核中常见的一种声明:rt_clock通常是指系统时钟,它经常被时钟中断进行更新。所以它是volatile,易变的。因此在用的时候,要让编译器每次从内存里面取值。而rt_clock通常只有一个写者(时钟中断),其他地方对其的使用通常都是只读的。所以将其声明为 const,表示这里不应该修改这个变量。所以volatile和const是两个不矛盾的东西,并且一个对象同时具备这两种属性也是有实际意义的。


注意

在上面这个例子里面,要注意声明和定义时对const的使用:

在需要读写rt_clock变量的中断处理程序里面,应该如下定义(define)此变量:

volatile unsigned int rt_clock;

而在提供给外部用户使用的头文件里面,可以将此变量声明(declare)为:

extern const volatile unsigned int rt_clock;

这样是没有问题的。但是切记一定不能反过来,即定义一个const的变量:

const unsigned int a;

但是却声明为非const变量:

extern unsigned int a;

这样万一在用户函数里面对a进行了写操作,结果是Undefined。

再看另一个例子:

volatile struct devregs * const dvp = DEVADDR;

这里的volatile和const实际上是分别修饰了两个不同的对象:volatile修饰的是指针dvp所指的类型为struct devregs的数据结构,这个结构对应者设备的硬件寄存器,所以是易变的,不能被优化的;而后面的const修饰的是指针变量dvp。因为硬件寄存器的地址是一个常量,所以将这个指针变量定义成const的,不能被修改。

危险的volatile用法

下面将列举几种对volatile的不当使用和可能导致的非预期的结果。

例:定义为volatile的结构体成员

考察下面对一个设备硬件寄存器结构类型的定义:

struct devregs{ 
    unsigned short volatile csr; 
    unsigned short const volatile data; 
};

我们的原意是希望声明一个设备的硬件寄存器组。其中有一个16bit的CSR控制/状态寄存器,这个寄存器可以由程序向设备写入控制字,也可以由硬件设备设置反映其工作状态。另外还有一个16bit的DATA数据寄存器,这个寄存器只会由硬件来设置,由程序进行读入。

看起来,这个结构的定义没有什么问题,也相当符合实际情况。但是如果执行下面这样的代码时,会发生什么情况呢?

struct devregs * const dvp = DEVADDR; 
 
while ((dvp->csr & (READY | ERROR)) == 0) 
    ; /* NULL - wait till done */

通过一个non-volatile的结构体指针,去访问被定义为volatile的结构体成员,编译器将如何处理?答案是:Undefined!C99 标准没有对编译器在这种情况下的行为做规定。所以编译器有可能正确地将dvp->csr作为volatile的变量来处理,使程序运行正常;也有可能就将dvp->csr作为普通的non-volatile变量来处理,在while当中优化为只有开始的时候取值一次,以后每次循环始终使用第一次取来的值而不再从硬件寄存器里读取,这样上面的代码就有可能陷入死循环!!

如果你使用一个volatile的指针来指向一个非volatile的对象。比如将一个non-volatile的结构体地址赋给一个 volatile的指针,这样对volatile指针所指结构体的使用都会被编译器认为是volatile的,即使原本那个对象没有被声明为 volatile。然而反过来,如果将一个volatile对象的地址赋给一个non-volatile的普通指针,通过这个指针访问volatile对象的结果是undefined,是危险的。

所以对于本例中的代码,我们应该修改成这样:

struct devregs { 
    unsigned short csr; 
    unsigned short data; 
}; 
 
volatile struct devregs * const dvp = DEVADDR;

这样我们才能保证通过dvp指针去访问结构体成员的时候,都是作为volatile来处理的。

例:定义为volatile的结构体类型

考察如下代码:

volatile struct devregs { 
    /* stuff */ 
} dev1; 
......; 
struct devregs dev2;

作者的目的也许是希望定义一个volatile的结构体类型,然后顺便定义一个这样的volatile结构体变量dev1。后来又需要一个这种类型的变量,因此又定义了一个dev2。然而,第二次所定义的dev2变量实际上是non-volatile的!!因为实际上在定义结构体类型时的那个 volatile关键字,修饰的是dev1这个变量而不是struct devregs类型的结构体!!

所以这个代码应该改写成这样:

typedef volatile struct devregs { 
    /* stuff */ 
} devregs_t; 
 
devregs_t dev1; 
......; 
devregs_t dev2;

这样我们才能得到两个volatile的结构体变量。

例:多次的间接指针引用

考察如下代码:

/* DMA buffer descriptor */ 
struct bd { 
    unsigned int state; 
    unsigned char *data_buff; 
}; 
 
struct devregs { 
    unsigned int csr; 
    struct bd *tx_bd; 
    struct bd *rx_bd; 
}; 
 
volatile struct devregs * const dvp = DEVADDR; 
 
/* send buffer */ 
dvp->tx_bd->state = READY; 
while ((dvp->tx_bd->state & (EMPTY | ERROR)) == 0) 
    ; /* NULL - wait till done */

这样的代码常用在对一些DMA设备的发送Buffer处理上。通常这些Buffer Descriptor(BD)当中的状态会由硬件进行设置以告诉软件Buffer是否完成发送或接收。但是请注意,上面的代码中对dvp->tx_bd->state的操作实际上是non-volatile的!这样的操作有可能因为编译器对其读取的优化而导致后面陷入死循环。

因为虽然dvp已经被定义为volatile的指针了,但是也只有其指向的devregs结构才属于volatile object的范围。也就是说,将dvp声明为指向volatile数据的指针可以保障其所指的volatile object之内的tx_bd这个结构体成员自身是volatile变量,但是并不能保障这个指针变量所指的数据也是volatile的(因为这个指针并没有被声明为指向volatile数据的指针)。

要让上面的代码正常工作,可以将数据结构的定义修改成这样:

struct devregs { 
    unsigned int csr; 
    volatile struct bd *tx_bd; 
    volatile struct bd *rx_bd; 
};

这样可以保证对state成员的处理也是volatile的。不过最为稳妥和清晰的办法还是这样:

volatile struct devregs * const dvp = DEVADDR; 
volatile struct bd *tx_bd = dvp->tx_bd; 
 
tx_bd->state = READY; 
while ((tx_bd->state & (EMPTY | ERROR)) == 0) 
    ; /* NULL - wait till done */

这样在代码里面能绝对保证数据结构的易变性,即使数据结构里面没有定义好也不会有关系。而且对于日后的维护也有好处:因为这样从代码里一眼就能看出哪些数据结构的访问是必须保证volatile的。

例:到底哪个volatile可能无效

就在你看过前面几个例子,感觉自己可能已经都弄明白了的时候,请看最后这个例子:

struct hw_bd { 
    ......; 
    volatile unsigned char * volatile buffer; 
}; 

struct hw_bd *bdp; 

......; 
bdp->buffer = ...; (1) 
bdp->buffer[i] = ...; (2)

请问上面标记了①和②的两行代码,哪个是确实在访问volatile对象,而哪个又是undefined的结果?

答案是:②是volatile的,①是undefined。来看本例的数据结构示意图:

        (non-volatile)
 bdp -->+-------------+
        |             |
        |   ... ...   |
        |             |
        +-------------+    (volatile)   
        |    buffer   |-->+------------+
        +-------------+   |            |
                          |            |
                          |            |
                          +------------+
                          |  buffer[i] |
                          +------------+
                          |            |
                          |            |
                          +------------+
buffer成员本身是通过一个non-volatile的指针bdp访问的,按照C99标准的定义,这就属于undefined的情况,因此对bdp->buffer的访问编译器不一定能保证是volatile的;

虽然buffer成员本身可能不是volatile的变量,但是buffer成员是一个指向volatile对象的指针。因此对buffer成员所指对象的访问编译器可以保证是volatile的,所以bdp->buffer[i]是volatile的。

所以,看似简单的volatile关键字,用起来还是有非常多的讲究在里面的,大家一定要引起重视。



深度学习是机器学习的一个子领域,它基于人工神经网络的研究,特别是利用多层次的神经网络来进行学习和模式识别。深度学习模型能够学习数据的高层次特征,这些特征对于图像和语音识别、自然语言处理、医学图像分析等应用至关重要。以下是深度学习的一些关键概念和组成部分: 1. **神经网络(Neural Networks)**:深度学习的基础是人工神经网络,它是由多个层组成的网络结构,包括输入层、隐藏层和输出层。每个层由多个神经元组成,神经元之间通过权重连接。 2. **前馈神经网络(Feedforward Neural Networks)**:这是最常见的神经网络类型,信息从输入层流向隐藏层,最终到达输出层。 3. **卷积神经网络(Convolutional Neural Networks, CNNs)**:这种网络特别适合处理具有网格结构的数据,如图像。它们使用卷积层来提取图像的特征。 4. **循环神经网络(Recurrent Neural Networks, RNNs)**:这种网络能够处理序列数据,如时间序列或自然语言,因为它们具有记忆功能,能够捕捉数据中的时间依赖性。 5. **长短期记忆网络(Long Short-Term Memory, LSTM)**:LSTM 是一种特殊的 RNN,它能够学习长期依赖关系,非常适合复杂的序列预测任务。 6. **生成对抗网络(Generative Adversarial Networks, GANs)**:由两个网络组成,一个生成器和一个判别器,它们相互竞争,生成器生成数据,判别器评估数据的真实性。 7. **深度学习框架**:如 TensorFlow、Keras、PyTorch 等,这些框架提供了构建、训练和部署深度学习模型的工具和库。 8. **激活函数(Activation Functions)**:如 ReLU、Sigmoid、Tanh 等,它们在神经网络中用于添加非线性,使得网络能够学习复杂的函数。 9. **损失函数(Loss Functions)**:用于评估模型的预测与真实值之间的差异,常见的损失函数包括均方误差(MSE)、交叉熵(Cross-Entropy)等。 10. **优化算法(Optimization Algorithms)**:如梯度下降(Gradient Descent)、随机梯度下降(SGD)、Adam 等,用于更新网络权重,以最小化损失函数。 11. **正则化(Regularization)**:技术如 Dropout、L1/L2 正则化等,用于防止模型过拟合。 12. **迁移学习(Transfer Learning)**:利用在一个任务上训练好的模型来提高另一个相关任务的性能。 深度学习在许多领域都取得了显著的成就,但它也面临着一些挑战,如对大量数据的依赖、模型的解释性差、计算资源消耗大等。研究人员正在不断探索新的方法来解决这些问题。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值