令人迷惑的linux内核代码中C语法的秘密解析与实践

前言

Linux内核同时使用C语言和汇编语言实现,C语言编写的代码移植性较好、易于维护,而汇编语言编写的代码相当于针对特定的平台做了优化,速度较快,所以需要在它们之间寻找一定的平衡。一般而言,在体系结构的底层或对执行时间要求严格的地方,会使用汇编语言,比如中断和异常处理的底层代码,初始化有关的部分代码等。内核其他部分则是用C语言编写。

一 GCC扩展 

Linux内核必须使用GNU的GCC编译器来编译,而GCC提供了很多的C语言扩展,这些扩展对优化、目标代码布局、更安全的检查等提供了很强的支持。因此,内核代码所使用的C语法并不完全符合ANSI C标准,实际上,只要有可能,内核开发者总是要用到GCC提供的C语言扩展部分。这就为我们学习内核增加了一定的困难,因此,为了能够比较顺利地阅读内核代码,有必要对GCC扩展进行了解。

下面详细介绍Linux内核中常出现的、主要的GCC扩展。 

1.语句表达式(statement-embedded expression)

GCC把包含在括号中的复合语句看作是一个表达式,称为语句表达式,它允许在一个表达式内使用循环、跳转、局部变量,并可以出现在任何允许表达式出现的地方。位于括号中的复合语句的最后一句必需是一个以分号结尾的表达式,它的值将成为这个语句表达式的值。计算最大值和最小值通常被定义为:

#define max(x,y) ((x)>(y)?(x):(y))
#define min(x,y) ((x)<(y)?(x):(y))

测试代码:

#include <stdio.h>
#define max(x,y) ((x)>(y)?(x):(y))
#define min(x,y) ((x)<(y)?(x):(y))

int main()
{
    int x=5,y=6;
    printf("%d,%d\n",max(x,y),min(x,y));
    printf("x=%d,y=%d\n",x,y);
    printf("max=%d\n",max(x++,y++));
    printf("x=%d,y=%d\n",x,y);
    printf("min=%d\n",min(x++,y++));
    printf("x=%d,y=%d\n",x,y);


    return 0;
}

测试结果:

lkmao@ubuntu:~$ ./a.out
6,5
x=5,y=6
max=7
x=6,y=8
min=7
x=8,y=9
lkmao@ubuntu:~$

由测试结果可知,在计算x++和y++比较时,都被有被多次计算的可能。当参数x和y带有副作用时,将会产生错误的结果。使用typedef优化后的宏定义如下

#include <stdio.h>
#define max(x,y) ({ \
    typeof(x) _x = (x);\
    typeof(y) _y = (y);\
    (void)(&_x == &_y);\
    _x > _y?_x:_y;\
    })

#define min(x,y) ({ \
    typeof(x) _x = (x); \
    typeof(y) _y = (y); \
    (void)(&_x == &_y); \
    _x > _y?_x:_y;\
    })

int main()
{
    int x=5,y=6;
    printf("x=%d,y=%d\n",x,y);
    printf("max=%d\n",max(x++,y++));
    printf("x=%d,y=%d\n",x,y);
    printf("min=%d\n",min(x++,y++));
    printf("x=%d,y=%d\n",x,y);
    return 0;
}

测试结果:

lkmao@ubuntu:~$ ./a.out
x=5,y=6
max=6
x=6,y=7
min=7
x=7,y=8
lkmao@ubuntu:~$

其中typeof(x) 表示x的类型,第294行定义了一个与x类型相同的局部变量_x,并将其初始化为x,注意 (void)(&_x == &_y);行的作用是检查参数x和y的类型是否相同。

2.零长度数组(flexible array)

在内核代码里经常出现类似下面的结构。

struct zero_struct{
    char array[0];
};

结构的最后一个元素char array[0]就是GCC所谓的零长度数组,也可以称之为可变长数组,它并不占用结构的空间,但它意味着这个结构的长度充满了变数。创建该结构对象时,可以根据实际的需要指定这个可变长数组的长度,并分配相应的空间。

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

struct zero_struct{
    char array[0];
};
struct one_struct{
    char c;
    char array[0];
};

struct normal_struct{
    int len;
    char array[0];
};
int main()
{
    printf("%ld\n",sizeof(struct zero_struct));
    printf("%ld\n",sizeof(struct one_struct));
    printf("%ld\n",sizeof(struct normal_struct));
    struct normal_struct *ns;
    ns = malloc(sizeof(struct normal_struct) + 100);
    ns->len = 100;
    sprintf(ns->array,"hello world\n");
    printf("ns->array = %s\n",ns->array);
    free(ns);
    return 0;
}

 测试结果:

lkmao@ubuntu:~/learn$ ./a.out
0
1
4
ns->array = hello world

lkmao@ubuntu:~/learn$

3.可变参数宏

1999年的ISO C标准里规定了可变参数宏,语法和函数类似,比如:

#define debug(format, ...) fprintf (stderr, format, __VA_ARGS__)

其中的“…”表示可变参数,实际调用时,它们会替代宏体里的__VA_ARGS__。GCC支持更复杂的形式,可以给可变参数取个名字,如下所示。

#define debug(format, args...) fprintf (stderr, format, args)

有了名字之后,代码显得更具有可读性。内核中的例子为:

++++ include/linux/kernel.h
244 #define pr_info(fmt,arg...) \
245 printk(KERN_INFO fmt,##arg)

其中的pr_info和上面的debug形式除了“##”外,几近相同。“##”主要针对参数为空的情况。既然称为可变参数,那传递空参数也是可以的。如果没有使用“##”,传递空参数时,比如:

debug ("A message");

宏展开后,其中的字符串后面会多个多余的逗号,而“##”则会使预处理器去掉这个多余的逗号。

写一个在虚拟机运行测测试模块,需要写makefile文件

makefile

KERN_DIR = /lib/modules/$(shell uname -r)/build/

FILE_NAME=my_debug
obj-m += $(FILE_NAME).o

all:
    @echo "begin make"
    @echo '$(KERN_DIR) = '$(KERN_DIR)
    make -C $(KERN_DIR) M=$(shell pwd) modules
.PHONY:clean
clean:
    make -C $(KERN_DIR) M=$(shell pwd) clean

测试代码:

#include <linux/init.h>
#include <linux/module.h>
#define mydebug(format,...)     printk(format,__VA_ARGS__)
#define debug(format,arg...)    printk(format,arg)
#define debug3(format,arg...)   printk("%s:%s:%d - "format"\n",__FILE__,__func__,__LINE__,##arg)

static int __init mydebug_init(void)
{
    printk("hello world\n");
    mydebug("%s","hello world");
    debug("%s","hello world");
    debug3("hello world");
    return 0;
}

static void __exit mydebug_exit(void)
{
}
module_init(mydebug_init);
module_exit(mydebug_exit);

MODULE_LICENSE("GPL");

测试结果:

编译:

lkmao@ubuntu:~/learn$ make
begin make
/lib/modules/4.19.260/build/ = /lib/modules/4.19.260/build/
make -C /lib/modules/4.19.260/build/ M=/home/lkmao/learn modules
make[1]: Entering directory '/big/linux-4.19.260/linux-4.19.260'
  Building modules, stage 2.
  MODPOST 1 modules
make[1]: Leaving directory '/big/linux-4.19.260/linux-4.19.260'
lkmao@ubuntu:~/learn$

insmod模块:

lkmao@ubuntu:~/learn$ sudo insmod my_debug.ko
lkmao@ubuntu:~/learn$

搜索模块:

lkmao@ubuntu:~/learn$ lsmod | grep my_debug
my_debug               16384  0
lkmao@ubuntu:~/learn$

dmesg:最后四行就是测试代码输出的调试信息

lkmao@ubuntu:~/learn$ dmesg | tail -n 10
[297330.806238] IPv6: ADDRCONF(NETDEV_UP): ens33: link is not ready
[297331.155395] e1000: ens33 NIC Link is Up 1000 Mbps Full Duplex, Flow Control: None
[297331.157284] IPv6: ADDRCONF(NETDEV_UP): ens33: link is not ready
[297331.162236] IPv6: ADDRCONF(NETDEV_CHANGE): ens33: link becomes ready
[307974.892010] my_debug: loading out-of-tree module taints kernel.
[307974.892676] my_debug: module verification failed: signature and/or required key missing - tainting kernel
[307974.894674] hello world
[307974.894674] hello world
[307974.894674] hello world
[307974.894675] /home/lkmao/learn/my_debug.c:mydebug_init:12 - hello world
lkmao@ubuntu:~/learn$

4.标号元素

在标准C里,数组或结构变量的初始化值必须以固定的顺序出现,而在GCC中,通过指定索引或结构域名,则允许初始化值以任意顺序出现。指定数组索引的方法是在初始化值前写“[INDEX] =”,还可以使用“[FIRST ... LAST] =”的形式指定一个范围,比如:

++++ arch/ia64/kernel/acpi.c
132 int platform_intr_list[ACPI_MAX_PLATFORM_INTERRUPTS] = {
133 [0 ... ACPI_MAX_PLATFORM_INTERRUPTS - 1] = -1134 };

将数组platform_intr_list的任何元素都初始化为-1。

对于结构初始化,比如:

static struct file_operations myfops = {
    .owner = THIS_MODULE,
    .read = my_read,
    .open = my_open,
};

将结构file_operations的元素read初始化为my_read,元素open初始化为my_open,依次类推。使用这种形式,当结构体的定义变化导致元素的偏移位置改变时,仍然可以确保已知元素的正确性。对于未出现在初始化中的元素,其初值为0。

5.特殊属性(__attribute__)

GCC允许声明函数、变量和类型的特殊属性,以便指示编译器进行特定方面的优化和更仔细的代码检查。使用方式为在声明后加上:

__attribute__ (( ATTRIBUTE ))

其中ATTRIBUTE是属性的说明,多个说明之间以逗号分隔。GCC可以支持十几个属性,下面介绍一些比较常用的。

● noreturn

属性noreturn用于函数,表示该函数从不返回。它能够让编译器生成较为优化的代码,消除不必要的警告信息。

void
__attribute__((noreturn))
noreturn_func(void)
{
    printf("noreturn");
    while(1)sleep(1);
}

这个被修饰的函数一定不定返回,否则在编译的时候,会发出警告。

这个属性在内核代码用大部分时间用于修饰复位函数,关机函数。属于壮士一去不复还。

● format(archetype, string-index, first-to-check)

属性 format 用于函数,表示该函数使用printf、scanf、strftime或strfmon风格的参数,并可以让编译器检查函数声明和函数实际调用参数之间的格式化字符串是否匹配。

archetype指定是哪种风格,

string-index指定传入函数的第几个参数是格式化字符串,

first-to-check指定从函数的第几个参数开始按照上述规则进行检查。比如:

++++ include/linux/kernel.h
164 static inline int printk(const char *s, ...)
165 __attribute__ ((format (printf, 1, 2)));

表示printk的第一个参数是格式化字符串,从第二个参数开始根据格式化字符串检查参数。

再多解释,不如看一个真实的代码。

测试代码:

#include <stdio.h>
#include <unistd.h>
#include <stdarg.h>

static inline int go(const char *s, ...)
 __attribute__ ((format (printf, 1, 2)));

static inline int
 __attribute__ ((format (printf, 1, 2)))
go(const char *fmt, ...)
{
    int ret;
    va_list vl;

    va_start(vl, fmt);
    ret = printf(fmt, vl);
    va_end(vl);

    return ret;
    return 0;
}

int
 main()
{
    go("hello world\n");
    return 0;
}

测试结果:

lkmao@ubuntu:~/learn$ ./a.out
hello world
lkmao@ubuntu:~/learn$

● unused

属性unused用于函数和变量,表示该函数或变量可能并不使用,这个属性能够避免编译器产生警告信息。

测试代码1:

int main()
{
    int i;
    return 0;
}

编译结果:编译显示i被定义了,但是没有被使用

lkmao@ubuntu:~/learn$ gcc unused.c -Wall
unused.c: In function ‘main’:
unused.c:3:6: warning: unused variable ‘i’ [-Wunused-variable]
  int i;
      ^
lkmao@ubuntu:~/learn$ 

添加的unused属性的测试代码:

int main()
{
    int i __attribute__((unused));
    return 0;
}

再次编译后,就不会报警了。

lkmao@ubuntu:~/learn$ gcc unused.c -Wall
lkmao@ubuntu:~/learn$ 

● section ("section-name")

属性section用于函数和变量,比如:

++++ include/linux/init.h
43 #define __init __attribute__ ((__section__ (".init.text"))) __cold
44 #define __initdata __attribute__ ((__section__ (".init.data")))
45 #define __exitdata __attribute__ ((__section__(".exit.data")))
46 #define __exit_call __attribute_used__ __attribute__ ((__section__ (".exitcall.exit")))

通常编译器将函数放在.text节,变量放在.data或.bss节,而使用section属性,可以让编译器将函数或变量放在指定的节中。因此上面对__init的定义便表示将__init修饰的代码放在.init.text节。连接器可以把相同节的代码或数据安排在一起,Linux 内核很喜欢使用这种技术,比如__init修饰的所有代码都会被放在.init.text节里,初始化结束后就可以释放这部分内存。

示例代码: 

static int __init mydebug_init(void)
{
    printk("hello world\n");
    return 0;
}

 ● aligned (ALIGNMENT)

属性aligned用于变量、结构或联合,设定一个指定大小的对齐格式,以字节为单位,比如:

示例代码:经过反复测试,总结出结构体里只有一个char类型是,效果是比较好的,可以很好的体现出aligned属性的价值。

#include <stdio.h>
#include <unistd.h>
#include <stdarg.h>

struct one_byte{
    char c;
}__attribute__ ((aligned(1)));;

struct two_byte{
    char c;
}__attribute__ ((aligned(2)));;
struct eight_byte{
    char a;
}__attribute__ ((aligned(8)));;
struct xxx_byte{
    char a;
}__attribute__ ((aligned()));;
struct byte_32{
    char a;
}__attribute__ ((aligned(32)));;
struct byte_64{
    char a;
}__attribute__ ((aligned(64)));;
struct byte_1024{
    char a;
}__attribute__ ((aligned(1024)));;
struct byte_65k{
    char a;
}__attribute__ ((aligned(65536)));;
struct byte_1M{
    char a;
}__attribute__ ((aligned(1024*1024)));;
struct byte_256M{
    char a;
}__attribute__ ((aligned(1024*1024*1024/4)));;
/*
struct byte_512M{
    char a;
}__attribute__ ((aligned(1024*1024*1024/2)));;
*/
int
 main()
{
    printf("%ld \n",sizeof(struct one_byte));
    printf("%ld \n",sizeof(struct two_byte));
    printf("%ld \n",sizeof(struct eight_byte));
    printf("%ld \n",sizeof(struct xxx_byte));
    printf("%ld \n",sizeof(struct byte_32));
    printf("%ld \n",sizeof(struct byte_64));
    printf("%ld \n",sizeof(struct byte_1024));
    printf("%ld \n",sizeof(struct byte_65k));
    printf("%ld \n",sizeof(struct byte_1M));
    printf("%ld \n",sizeof(struct byte_256M));
    //printf("%ld \n",sizeof(struct byte_512M));
    return 0;
}

执行结果:

lkmao@ubuntu:~/learn$ ./a.out
1
2
8
16
32
64
1024
65536
1048576
268435456
lkmao@ubuntu:~/learn$

__attribute__ ((aligned(8)));;
表示结构体eight_byte的成员以8字节对齐。如果aligned后面不紧跟一个指定的数字值,那么编译器将依据目标机器情况使用最大、最有益的对齐方式。需要注意的是,attribute属性的效果与你的连接器也有关,如果你的连接器最大只支持16字节对齐,那么此时定义32字节对齐也是无济于事的???啥意思???

经过测试发现,我的电脑最大可以设置到256MB,如果不是掉上面512M部分的代码,编译报错:

lkmao@ubuntu:~/learn$ gcc attr.c
attr.c:38:1: error: requested alignment is too large
 }__attribute__ ((aligned(1024*1024*1024/2)));;
 ^
lkmao@ubuntu:~/learn$

● packed

属性packed用于变量和类型,用于变量或结构体成员时表示使用最小可能的对齐,用于枚举、结构体或联合类型时表示该类型使用最小的内存。属性packed多用于定义硬件相关的结构时,使元素之间不会因对齐产生问题。参考如下测试代码及测试结果

测试代码:

#include <stdio.h>
#include <unistd.h>
#include <stdarg.h>

struct struct_xxx{
    char c;
    int x;
    char y;
};
struct struct_yyy{
    char c;
    int x;
    char y;
}__attribute__ ((packed));;

int
 main()
{
    printf("%ld \n",sizeof(struct struct_xxx));
    printf("%ld \n",sizeof(struct struct_yyy));
    return 0;
}

测试结果:

lkmao@ubuntu:~/learn$ ./a.out
12
6
lkmao@ubuntu:~/learn$

其中__attribute__ ((packed))告诉编译器,struct_yyy的元素为1字节对齐,不要再添加填充位。因为这个结构的定义和struct_xxx里是完全一致的,包括各个字段的长度,如果不给编译器这个暗示,编译器就会依据平台的类型在结构的每个元素之间添加一定的填充位,添加了填充位后,struct_xxx就变成12字节。

6.内建函数

GCC提供了大量的内建函数,其中很多是标准C库函数的内建版本,比如memcpy,它们与对应的C库函数功能相同,这里就不再进行介绍。还有很多内建函数的名字通常以 __builtin开始,下面只对__builtin_expect进行分析并示例,其他__builtin_xxx函数的分析可参考这个分析过程。

● __builtin_expect (long exp, long c)

遇到这类很陌生的使用方式,GCC参考手册是我们最好的参考资料。下面是GCC参考手册里对__builtin_expect的介绍。

long __builtin_expect (long exp, long c)
You may use __builtin_expect to provide the compiler with branch prediction information.In general, you should prefer to use actual profile feedback for this (‘-fprofile-arcs’),as programmers are notoriously bad at predicting how their programs actually perform. However,there are applications in which this data is hard to collect.The return value is the value of exp, which should be an integral expression. The value of c must be a compile-time constant. The semantics of the built-in are that it is expectedthat exp == c. For example:
if (__builtin_expect (x, 0))
foo ();
would indicate that we do not expect to call foo, since we expect x to be zero. Sinceyou are limited to integral expressions for exp, you should use constructions such as
if (__builtin_expect (ptr != NULL, 1))
error ();
when testing pointer or floating-point values.

大意是,由于大部分代码在分支预测方面做的比较糟糕,所以GCC提供了这个内建的函数来帮助处理分支预测、优化程序。它的第一个参数exp为一个整型的表达式,返回值也是这个exp。它的第二个参数c的值必须是一个编译期的常量。这个内建函数的意思就是exp的预期值为c,编译器可以根据这个信息适当地重排条件语句块的顺序,将符合这个条件的分支放在合适的地方。具体到内核里,比如:

++++ include/linux/compiler.h
60 #define likely(x) __builtin_expect(!!(x), 1)
61 #define unlikely(x) __builtin_expect(!!(x), 0)

unlikely(x)用于告诉编译器,条件x发生的可能性不大,likely则相反。它们一般用在条件语句里,if语句不变,只是当if条件为1,即为真的可能性非常小的时候,可以在条件表达式外面包装一个unlikely(),那么这个条件块里语句的目标码可能就会被放在一个比较远的位置,以保证经常执行的目标码更紧凑。如果可能性非常大,则使用likely()包装。

测试代码:

#include <stdio.h>

#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
int
 main()
{
    int a = 5;
    if(unlikely(a == 6)){
        printf("666\n");
    }else{
        printf("hehehe\n");
    }
    return 0;
}

预处理后的代码:预处理阶段知识进程了宏展开

# 7 "attr.c"
int
 main()
{
 int a = 5;
 if(__builtin_expect(!!(a == 6), 0)){
  printf("666\n");
 }else{
  printf("hehehe\n");
 }
 return 0;
}

测试结果:

lkmao@ubuntu:~/learn$ ./a.out
hehehe
lkmao@ubuntu:~/learn$

加了unlickly的汇编代码:

 1     .file   "attr.c"
  2     .section    .rodata
  3 .LC0:
  4     .string "666"
  5 .LC1:
  6     .string "hehehe"
  7     .text
  8     .globl  main
  9     .type   main, @function
 10 main:
 11 .LFB0:
 12     .cfi_startproc
 13     pushq   %rbp
 14     .cfi_def_cfa_offset 16
 15     .cfi_offset 6, -16
 16     movq    %rsp, %rbp
 17     .cfi_def_cfa_register 6
 18     subq    $16, %rsp
 19     movl    $5, -4(%rbp)
 20     cmpl    $6, -4(%rbp)
 21     sete    %al
 22     movzbl  %al, %eax
 23     testq   %rax, %rax
 24     je  .L2
 25     movl    $.LC0, %edi
 26     call    puts
 27     jmp .L3
 28 .L2:
 29     movl    $.LC1, %edi
 30     call    puts
 31 .L3:
 32     movl    $0, %eax
 33     leave
 34     .cfi_def_cfa 7, 8
 35     ret
 36     .cfi_endproc
 37 .LFE0:
 38     .size   main, .-main
 39     .ident  "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.12) 5.4.0 20160609"
 40     .section    .note.GNU-stack,"",@progbits
~

 去掉unlickly的测试代码:

#include <stdio.h>

#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
int
 main()
{
    int a = 5;
    if(a == 6){
        printf("666\n");
    }else{
        printf("hehehe\n");
    }
    return 0;
}

汇编该代码:

lkmao@ubuntu:~/learn$ gcc -s attr.c -o no_unlickly.s

 1     .file   "attr.c"
  2     .section    .rodata
  3 .LC0:
  4     .string "666"
  5 .LC1:
  6     .string "hehehe"
  7     .text
  8     .globl  main
  9     .type   main, @function
 10 main:
 11 .LFB0:
 12     .cfi_startproc
 13     pushq   %rbp
 14     .cfi_def_cfa_offset 16
 15     .cfi_offset 6, -16
 16     movq    %rsp, %rbp
 17     .cfi_def_cfa_register 6
 18     subq    $16, %rsp
 19     movl    $5, -4(%rbp)
 20     cmpl    $6, -4(%rbp)
 21     jne .L2
 22     movl    $.LC0, %edi
 23     call    puts
 24     jmp .L3
 25 .L2:
 26     movl    $.LC1, %edi
 27     call    puts
 28 .L3:
 29     movl    $0, %eax
 30     leave
 31     .cfi_def_cfa 7, 8
 32     ret
 33     .cfi_endproc
 34 .LFE0:
 35     .size   main, .-main
 36     .ident  "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.12) 5.4.0 20160609"
 37     .section    .note.GNU-stack,"",@progbits

对比两个文件的差异:知道不一样就行了,具体啥意思,(⊙o⊙)…,主要是jne和je。

lkmao@ubuntu:~/learn$ diff no_unlickly.s attr.s
21c21,24
<       jne     .L2
---
>       sete    %al
>       movzbl  %al, %eax
>       testq   %rax, %rax
>       je      .L2
lkmao@ubuntu:~/learn$

2 内嵌汇编

用汇编语言编写内核代码中的部分代码,大体上是出于如下几个方面的考虑。

(1)Linux内核中的底层代码直接和硬件打交道,需要一些专用的指令,而这些指令在C语言中并无对应的语言成分。

(2)内核中实现某些操作的过程、代码段或函数,在运行时会很频繁地被调用,这时用汇编语言编写,其时间效率会有大幅度提高。

(3)在某些特别的场合,一段代码的空间效率也很重要,比如操作系统的引导程序一定要容纳在磁盘的第一个扇区中,多一个字节都不行。这时只能用汇编语言编写。

        在Linux内核代码中,以汇编语言编写的代码,有两种不同的形式。一是完全的汇编代码,这样的代码采用.s作为文档名的后缀。第二种是嵌入在C代码中的汇编语言片段。

        对于新接触Linux内核源码的读者,即使比较熟悉i386汇编语言,在理解内核中的汇编代码时都会感到困难。其原因是,在内核的汇编代码中采用的是不同于常用Intel i386汇编语言的AT&T格式的汇编语言,而在嵌入C代码的汇编语言片段中,更是增加了一些指导汇编工具如何分配使用寄存器、如何与C代码中定义的变量相结合的语言成分。这些成分使得嵌入C代码的汇编语言片断实际上变成了一种介于汇编和C之间的一种中间语言。

暂时缺少示例

以后碰到了,再补上。先标红

小结

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

千册

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

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

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

打赏作者

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

抵扣说明:

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

余额充值