Linux Device Drivers

ldd3笔记基础知识 http://zzzppp.cublog.cn/ 去年(2005)9月2号就买了Linux Device Drivers第三版,但一直没仔细拜读.最近决定仔细研读它以学习Linux设备驱动程序. 在这里归纳学习笔记. 不知道自己有没有恒心把它看完,总之better later than never. 就让这一系列的笔记伴随我学习ldd3的漫漫长路吧 ldd3介绍的是2.6.10版的内核 An Introduction to Devices Drivers 驱动扮演的角色 Driver is a software layer that lies between the applications and the actual device. Mechanism & Policy " the role of a device driver is providing mechanism, not policy". mechanism和policy也是隐藏在unix设计背后的经典思想. mechanism: What capabilities are provided. policy : How these capabilities can be used. e.g1: KDE or GNOME: 它们都的基础都是X server. KDE和GNOME属于policy. 它们所关注的是使用什么样的图形界面, 用户面板. 却无须考虑底层硬件. X server属于mechanism, 它与硬件交互, 并为用户程序提供接口. 所以不同的图形界面可以存在于同一台主机上. e.g2: TCP/IP suite OS提供socket接口, 应用程序则可以使用各种协议. socket就是mechanism, 应用程序则关注policy. 经典的OSI七层模型或TCP/IP四层模型也是这个思想. Device Drivers: policy free, only provide mechanism! but, sometimes, u may lay some policies on them. 和驱动一起发布的可能还会有一些库, 应用程序. 库和应用程序就要涉及到policies了. 开发者提供的库或应用程序中的policy即默认的policy. Kernel可划分为下列功能单元 1, 进程管理: 进程调度, 资源分配, 进程间通信. 2, 内存管理: 其实也算是资源分配的一部分. 3, 文件系统: 管理, 组织物理媒介上数据的方法. 4, 设备控制: 设备驱动(ldd3所关注的) 5, 网络: 实质上是进程间通信. 但它不局限于一个特定的进程. 它关注收/发packets, 路由, 地址解析... 可加载模块(lodable modules) module: 可实时加载到内核中的代码, 它可动态连接到内核(insmod, rmmod). 设备驱动就是module的代表, 但module还包括文件系统等等. 当然, 你也可以在开机后关闭模块的功能. 2.2版之后的内核支持在开机后关闭对加载module的支持. 设备, 模块的分类 针对不同的设备类型(实际上也就是文件类型, 可参考APUEv2, p88), 模块分为这些类型: character module, block module, network interface. 每种类型的模块驱动对应类型的设备. 字符设备: 以字节流的形式被访问的设备. e.g: /dev/console : 文本控制台. /dev/ttyS0 : 串口 它通过文件系统节点被访问. e.g: /dev/tty1, /dev/lp0 字符设备与一般文件(regular file)的区别: 可以在一般文件中前后移动(lseek), 但只能顺序访问字符设备. 当然, 也有特例: frame grabbers. 块设备: 能支持文件系统的设备. 传统的UNIX: 只能以block(512B)为单位访问块设备. Linux: 能以访问字符设备的方式访问块设备, 即以字节文单位访问块设备. Linux中字符设备与块设备的区别: 1, 内核内部对数据的组织和管理不同, 但这对驱动开发者来说是透明的. 2, 它们与内核之间的接口不同: 使用两套不同的interface. 网络接口: 能与其他主机通信的设备. 它可以是硬件设备, 也可以是软件设备, 比如lo. (参考TCP/IP详解p26) 网络接口只管收发数据包, 而不管这些数据包被什么协议所使用. 不同于字符设备和块设备, 网络接口没有对应的文件系统节点. 虽然可以通过类似eth0这样的"文件名"来访问网络接口, 但文件系统节点中却没有针对网络接口的节点. 内核与网络接口之间的通信也不同于内核与字符/块设备之间的通信(read, write), 它们之间使用特定的传输数据包的函数调用. 另, 也有一些module不能严格地划分到上面的类型. 比如 USB module: 它工作在内核的USB子系统之上, 而实际的USB设备可以是字符设备, 块设备, 也可以是网络接口. 模块是如何加载到内核的 要支持模块的动态加载,卸载, 在编译内核时要注意: “Lodable Module Support" 中的相应选项要选上. 比如要支持加载模块, 应该选上Enable loadable module support; 要支持卸载模块, 要选上Module unloading ; 要支持强制卸载模块, 要选上Forced module unloading! 1, When the kernel needs a feature that is not resident in the kernel, the kernel module daemon kmod (In earlier versions of linux, this was known as kerneld) execs modprobe to load the module in. modprobe is passed a string in one of two forms: · A module name like softdog or ppp. · A more generic identifier like char-major-10-30 传递给mdoprobe的参数为模块名时, 不需要加.ko的扩展名. 若传递给modprobe的是通用标志符, 那么modprobe通过查看etc/modprobe.conf知道通用标志符对应的模块名. modprobe.conf文件是特定于发行版的, 比如我的Ubuntu中, modprobe查看/etc/modprobe.d/aliases来将通用标志符转化为模块名. 2, 注意模块也存在依赖性问题: 比如你要加载msdos.ko, 需要先加载fat.ko. modprobe查看/lib/module/version/modules.dep得知模块的依赖关系. (version = uname -r). moules.dep由$ depmod -a 命令创建. 依赖其他模块的模块称为: "stacking modules" 3, 知道了依赖关系之后, mprobe先加载prerequisites模块, 再加载模块自身. 实际上, modprobe是通过调用insmod来加载这些模块的! 有两种加载模块的方法, 以刚才的msdos.ko为例子: $ insmod /lib/modules/version/kernel/fs/fat/fat.ko $ insmod /lib/modules/version/kernel/fs/msdos/msdos.ko $ modprobe msdos modprobe和insmod的区别: 1, modprobe知道内核模块默认的存在目录(/lib/modules/version/), 而insmod不知道. 2, 调用modprobe时, 只需给出模块名(不代,ko扩展), 而insmod需要给出完整路径和模块名. 3, modprobe自动解决依赖性. 而insmod需要指定加载内核的先后顺序. 4, 由于modprobe假设要加载的模块在默认目录, 那么若要加载在默认目录之外的模块, 就要调用insmod了. 发行版将modprobe, insmod, depmod打包到一起, 称之为Linux内核模块管理工具, 针对2.4或以前的内核, 该工具名为modutils, 2.6的为module-init-tools. 通过$ lsmod 可以看到加载到内核中的模块信息 也可以查看/proc/modules文件的内容. 实际上,lsmod读命令就是通过查看/proc/modules的内容来显示模块信息的. 卸载模块 使用 $ sudo rmmod mod_name 可以卸载模块. 但内核有时候认为卸载该模块是不安全的, 此时可以使用 $ sudo rmmod -f mod_name来强制卸载模块. 这里都是介绍的加载,卸载模块的命令, 至于模块加载,卸载的原理. 参考" 模块运行环境" 准备工作 模块可以加载到当前运行的内核中, 也可以加载到另一个未运行的内核. 这里暂时只考虑将模块加载到启动的内核中. 学习模块编程, 先要重新编译内核, 为什么要编译内核的? 原因有二: 1, 我们使用的Linux发行版中的内核针对kernel.org的官方内核添加了许多补丁, 提供的内核头文件并不完整, 内核API也可能被修改了, 要学习模块编程, 最好使用官方内核编译. 2, 发行版的内核中, 一般默认的CONFIG_MODVERSIONS被设置为y. 这样你在加载模块时会由于版本问题失败, 所以应该不设置CONFIG_MODVERSIONS. 参考 内核模块编程之_初窥门径 模块程序组成 模块程序设计有点类似于应用程序设计: 起码模块程序中有entry point和exit point. 并且模块程序代码位于独立的文件中. 下面看看模块程序组成. 模块程序中至少要有两个函数: 一个初始化函数, 它在模块被加载到内核中的时候被调用, 一个退出函数(clean_up), 它在模块被卸载的时候被调用. 有两种方法定义上述的两个函数, 推荐用后面的定义方法! int init_module(void) { ... } void cleanup_module(void) { ... } 在2.3.13版之后的内核, 可以用下面的方法来定义它们: static int hello_start(void) { ... } static void hello_end(void) { ... } module_init(hello_start); module_exit(hello_end); 1, 初始化函数和退出函数的定义. 初始化函数和退出函数应该是这样的形式: static int funname_init(void); /* 模块被加载时被调用 */ static void funname_exit(void); /* 模块被卸载时被调用 */ 由于不向外输出这些函数, 一般给它们加上static限定符号. 2, module_init和module_exit宏 funname_init()通过module_init()被注册为模块的entry point. 同样地, funname_exit()通过module_exit()宏被注册为模块的exit point. 注意: 如果文件被编译到静态内核映像中, 退出函数不会被执行. 这些宏扩展必须位于相应函数定义之后! 3, 版权信息 从2.4版的内核起, 可以使用一些宏来声明模块的版权信息: MODULE_LICENSE()宏: 括号中的内容可以是下面的几种形式: "GPL" [GNU Public License v2 or later] "GPL v2" [GNU Public License v2] "GPL and additional rights" [GNU Public License v2 rights and more] "Dual BSD/GPL" [GNU Public License v2 or BSD license choice] "Dual MPL/GPL" [GNU Public License v2 or Mozilla license choice] "Proprietary" [Non free products] 如果是MODULE_LICENSE("Proprietary"), 那么你所编写的模块不是免费的, 内核社区将其视为"污染"了内核, 不会理会相关的bug report, 而且, 不遵循GPL的模块无法调用只针对GPL的符号(参考"输出符号"的内容). 如果声明双重版权, 那么在Linux上, 它与声明为GPL的效果是一样的, 即Linux只在意GPL版权. 另外, MODULE_DESCRIPTION() 描述模块的功能; MODULE_AUTHOR()描述模块的作者; and MODULE_SUPPORTED_DEVICE() 声明模块所支持设备的类型. 在内核源码树的/include/linux/module.h中定义版权宏. 这些宏一般位于文件结尾. 4, 包含头文件. 在模块程序开头需要包含头文件: #include /* 所有的模块文件都要包含 */ #include /* 若使用了优先级标志,需包含 */ #include /* 若使用了宏, 需包含 */ 编译内核模块的方法与编译一般应用程序的方法略有不同. 我们会发现在内核源码树的层层目录中, 都存在有Makefile. 即这些Makefile是分层次组织的. 以往的内核版本中, 编译模块比较麻烦, 需要我们对这些Makefile做出许多更改. 2.6的内核采用了"kbuild"编译系统, 简化了这些问题. 关于kbuild, 可参考内核源码树中的 /Documentation/kbuild/modules.txt. 编译之前, 肯定是需要源文件的. 这些源文件可以放在内核源码树中, 也可以放在内核源码树之外的任何地方. 根据源文件存在的目录, 存在两种编译方法: 在源码树之中和在源码树之外. 在源码树中编译模块 官方内核模块的源代码都是按模块(驱动)类型组织的, 我们到内核源码树的drivers目录可以看到char, usb, block之类的子目录. 那么我们在内核源码树中添加文件时, 最好也遵循这些分类. 分类的规则自己灵活把握. 下面以前面的"hello, world"这个简单的模块为例, 来看看如何在内核源码树中编译模块. 1, 不新建子目录 (1) 先在内核源码树中的drivers目录编辑一个c源程序, 名为hello.c. (2) 修改drivers目录的Makefile文件, 添加: obj-m += hello.o (3) 重新编译内核(回到源码树根目录, 运行 $ sudo make). 这样, 在drivers目录多出了这样几个文件: hello.mod.c, hello.mod.o, hello.o, hello.ko. hello.ko就是编译出来的模块了. 2, 新建子目录 如果源文件比较多, 可以在drivers目录中新建子目录. 还是以hello, world为例: (1) 在内核源码树的drivers目录中新建一个hello子目录, 并将hello.c放在hello目录中. (2) 修改drivers目录的Makefile文件, 添加: obj-m += hello/ (3) 在hello目录中新建一个Makefile文件, 内容为: obj-m += hello.o (4) 重新编译内核(回到源码树根目录, 运行 $ sudo make). 这样, 新生成的模块文件就位于hello目录中. 若在内核源码树中编译模块, 如果不新建子目录, 那么只需修改当前目录的Makefile, 否则应该在当前新建的子目录中新建Makefile指定编译选项, 并修改上层目录的Makefile以让kbuild能够进入新建的子目录. 在源码树之外编译模块 还是以上面的hello, world为例. 在当前目录有个hello.c: (1)首先在模块代码所在的目录新建一个Makefile, 内容为: obj-m := hello.o (2)这样调用make命令: $ sudo make -C /usr/local/src/kernel/linux-2.6.16.20 SUBDIRS=$PWD modules 这里/usr/local/src/kernel/linux-2.6.16.20是内核源码树所在的目录. -C表示要求make先切换到-C指定的目录. SUBDIRS(也可以用M代替SUBDIRS)使make在编译模块之前回到当前目录. 整个编译过程实际上是执行-C指定的内核源码树的Makefile, 并通过SUBDIR指定你要编译的内核源文件的目录. 简化命令行输入 每次调用make的时候输入这些参数比较比较麻烦, 可以这样来改写Makefile以简化: obj-m += hello.o all: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules clean: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean 这样, 只需在当前目录调用 $ sudo make 就可以完成上面的工作. 调用 $ sudo make clean 将删除所有新生成的文件. 上面的Makefile是这样确定内核源码树所在的目录的: 我们先到/lib/modules目录, 会看到一些以内核版本为名的目录, 目录中有一个build文件, 它是一个符号连接, 指向内核源码树. 那么如何确定进入哪个内核版本的目录呢? 这就可以通过 $ uname -r 来确定, 它指出了当前运行内核的版本. 还可以进一步简化这个Makefile: obj-m := hello.o KERNELDIR ?= /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) default: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean 这样不用在Makefile中一次又一次地指定内核代码树的目录. 上面的例子中只讨论了所有的代码在一个文件中的情况. 若代码分布在多个源文件中, 比如file1.c, file2.c, 生成hello.ko. 应该这样写Makefile: obj-m := hello.o hello-objs := file1.o file2.o 注意, 虽然我们的目的是生成.ko文件, 但在Makefile中写为.o! 为预编译的内核编译模块 前面都讨论的是针对当前运行的内核编译模块, 实际上, 也可以针对非当前运行的内核编译模块.比如, 我当前运行的内核版本是2.6.16, 但系统中还有2.6.12版的内核. 在不想重启来运行2.6.12版内核的情况下, 如何针对2.6.12编译模块呢? 内核模块 VS 应用程序 起始与结束 应用程序一般从main()开始, 它执行一些指令, 再结束(返回值). Every module must have an entry function and an exit function. 不同于应用程序, 模块从初始化函数开始, 这个初始化函数名可以是init_module(), 也可以是你通过module_init宏注册的其他函数名. 该初始化函数被称为模块的入口函数. 入口函数的作用: 它告诉内核, 该模块提供了哪些功能, 并设置内核以便在需要这些功能的时候调用模块的函数. 这一过程完毕之后, 入口函数马上返回, 直到内核需要调用模块提供的函数时, 模块才开始被加载, 执行. 有入口, 就自然有出口. 出口函数可以是cleanup_module(), 也可以是你通过module_exit宏注册的其他函数名. 出口函数"undo"入口函数执行的所有工作: 清除入口函数注册的模块功能. 可调用的函数 在应用程序或模块程序中都可调用程序本身为定义的函数. 不同的是: · 应用程序可调用C库所提供的函数(比如libc提供的printf()). · 模块程序只能调用内核所提供的函数(比如内核提供的printk()). 由于内核无法访问C库, 所以模块程序中无法调用C库中的函数. 好在内核本身提供了一些C库中的函数或者替代函数. 查看 /proc/kallsyms , 可直到内核输出了那些函数. 由于调用了函数本身未调用的外部函数, 所以应用程序和模块程序都需要解析这些外部函数, 它们也互不相同: · 应用程序在linking阶段, linker解析外部函数, 将C库所提供的函数添加到对应位置. · 模块程序在insmod时, insmod解析内核所提供的外部函数. 实际上, 上述的应用程序和模块程序的函数调用还是有联系的. 让我们来回忆一下库函数和系统调用的关系. 库函数可能最终调用一个系统调用(比如printf(), 会调用write()), 而系统调用实际上就是内核所输出的函数! 写一个hello, world的应用程序, 编译它, 并运行 $ strace ./hello 看看它调用了那些系统函数! 用户空间 VS 内核空间 用户程序运行于用户空间, 模块运行于内核空间. 内核的功能之一就是实现资源资源分配, 资源包括CPU, 内存, 硬件资源... 在执行一个程序时, 内核为程序分配资源, 为了便于管理资源分配, 内核划分了两个运行级别: 管理员模式和用户模式, 分别对应内核空间和用户空间. 这两个运行级别由CPU来实现, 比如: X86的CPU划分了4个运行级别, 这些级别以ring命名. Linux只使用其中的两个级别: 最高的为管理员模式(ring0), 最低的为用户模式(ring3). 用户模式与管理员模式的区别在于它们具有不同的内存映射: 用户模式只能运行于受保护的用户空间中,它所能访问的资源是受限的. 而管理员模式运行于内核空间, 它能访问所有资源. 以最终调用系统调用的库函数为例, 当库函数调用系统调用时, 运行级别切换到管理员模式, 即程序运行于内核空间. 此时"内核代应用程序执行某些功能". 当系统调用返回时, 切换回用户模式. 此时的进程上下文未变, 只是运行级别发生了改变. 实际上, 在Linux中, 处理器总是处于下列三个状态之一: 1, 在内核空间中, 处于进程上下文, 内核代进程执行. 2, 在内核空间中, 处于中断上下文, 运行中断处理程序, 此时没有对应的进程. 3, 在用户空间中, 执行进程中的用户代码. 命名空间 ( Name Space) 不管是写用户程序还是些模块程序, 都要注意变量的命名. 用户程序: 写比较小的C程序时, 你可以选择你觉得方便的变量命名. 如果程序的规模比较大, 命名就要遵循一定的标准, 如果定义的全局变量中有重名的, 就会造成"命名空间污染 (namespace pollution). 所以在开发用户程序时要注意变量的命名问题: 最好遵循一定的标准, 避免C语言的保留字. 模块程序: 开发模块程序的时候更要注意变量命名: 因为所有的模块都要和整个内核连接. 最好的方法是给你模块代码中所有的变量加上static限定符, 并给你的符号(symbol)添加合适的前缀. (所有的内核前缀应该是小写!) 如果你不想声明static, 你也可以声明一个符号表, 并想内核注册它. 符号表后面再讨论. /proc/kallsysm列出了内核知道的符号, 而且模块于内核共享代码空间, 所以你可以在模块程序中访问它们. 代码空间 ( Code Space) 要理解代码空间就要理解Linux的内存管理. 理解MM("memeory management", 或者"美妹" ) 难度比较大, 这里只涉及皮毛. 应用程序 当进程被创建时, 内核为该进程分配物理内存. 这些内存被进程用以存放执行代码, 变量, 堆栈... 该内存从0x00000000开始, 扩展到某个需要的地方. 每个进程只能访问一个内存空间, 任何两个进程的内存空间不能重叠. (这里不讨论访问另一个进程的地址空间的情况). 注意, 进程可见的地址和内存实际的物理地址是不相同的! 比如, 某个进程访问地址为0xbffff978的内存时, 它所访问的实际物理内存地址并不是该地址. 这都是内核的内存映射造成的. 它所访问的实际内存地址可能是以0xbffff978为名的一个索引, 它指向内存物理地址的某处. 内核 内核具有自己的内存空间. 由于内核能动态地加载到内核中或从内核中卸载, 模块与内核共享代码空间. 所以, 当模块程序段错误时, 即引发整个内核的段错误, 所以编写模块程序的时候要格外小心溢出引发段错误! 设备名 字符设备通过文件系统中的文件名(设备名)来访问. 这些设备名, 文件名有许多不同的, 可以互换的叫法, 比如: 设备节点, 节点文件, 设备文件... 在Linux中, 所有的设备都被视为特殊文件. 它们储存在文件系统中, 但是仅占用文件目录, 而不涉及存储数据. 实际上, 它们只记录了设备所属的设备类别, 主设备号和从设备号等相关信息. 上述的设备名位于/dev目录中, 可以通过ls -l 命令来查看它们: $ ls -l sda[1-3] 显示下列信息: brw-rw---- 1 root disk 8, 1 2006-06-14 20:26 /dev/sda1 brw-rw---- 1 root disk 8, 2 2006-06-14 20:26 /dev/sda2 brw-rw---- 1 root disk 8, 3 2006-06-14 12:30 /dev/sda3 上述的命令列出了系统中SCSI硬盘的信息. 注意这些内容与列出一般文件的区别: (1) 开头是b, 表示是块文件(本章讨论的是char设备, 自然以c开头) (2) 对于设备文件, 在最终修改时间之前列出的是以逗号分隔的两个数字, 而不是一般文件的文件大小. 主, 从设备号 Linux利用设备号来标识设备文件, 设备号分为主设备号和从设备号. 上述的逗号分隔的两个数字就分别是主设备号和从设备号. 主设备号: 标识设备对应的驱动程序. 每个在内核中的设备驱动程序(包括编译进内核的和动态加载的)都有一个唯一的主设备号对应, 内核根据主设备号将设备和相应的设备驱动程序对应起来. 从设备号: 内核使用从设备号来区分具体的物理设备. 实际上, 次设备号只有设备驱动程序使用, 内核的其它部分对它一无所知: 从设备号只是用在驱动程序中来指示某个物理设备. 不同类型的设备文件, 其设备号是独立的. 关于设备号的分配, 可参考内核源码树的Documentation/devices.txt. 设备号的内部表征 2.6以前的内核将主, 从设备号限定在255以内, 2.6的内核没有了这一限制. 在2.6.0及以后版的内核中, 主, 从设备号位于一个dev_t类型的32位数中. (dev_t位于/usr/include/linux/types.h中). 前12位用于主设备号, 后20位用于从设备号. 但这一约定可能会随着版本的更新而改变, 所以在获取主,从设备号时, 不要对dev_t类型的组织作任何假设, 而应使用这两个宏: MAJOR(dev_t dev); MINOR(dev_t dev); 同样, 使用下面的宏来将主, 从设备号转化为一个设备: MKDEV(int major, int minor); 上述宏位于 /usr/include/linux/kdev_t.h中. 分配, 释放设备号 向系统增加一个驱动程序时, 首先要赋予它设备号. 一旦设备注册到内核表中, 无论何时操作与设备驱动程序的主设备号匹配的设备文件, 内核都会调用驱动程序中的函数. 分配设备号: int register_chrdev_region(dev_t first, unsigned int count, char *name); 返回: 0 if 成功分配. 负数 if 失败. first : 你想要分配的第一个设备号, 一般它的从设备号为0. count: 分配设备号(分配的设备号为连续的)个数. name: 用来与设备号对应的设备名, 它位于/proc/devices和sysfs中. register_chrdev_region适用于你预先知道需要分配哪个设备号的情况. 然而一般来说, 你无法提前知道设备将使用那个主设备号. 这种情况下就应该使用下面的函数: int alloc_chrdev_region(dev_t *dev, unsigned int firstminor, unsigned int count, char *name); 返回: 0 if 成功分配. 负数 if 失败. 在该函数中, count 和 name与前面的函数相同. *dev : 输出成功分配的设备号中的第一个设备号. firstminor : 分配的第一个从设备号, 一般为0. 分配设备号有两种选择, 但释放设备号只能调用下面的函数: void unregister_chrdev_region(dev_t first, unsigned int count); count于前两个函数中的count都是相同的, first与dev_t first相同, 但和dev_t *dev是如何对应的呢? 一般在模块的清理函数中调用它. 动态分配主设备号 有两个选择来分配主设备号: 静态地, 即直接使用内核源码数的Documentation/devices.txt中定义的未使用的主设备号. 它对应于register_chrdev_region函数. 动态地: 即动态分配一个主设备号.它对应于alloc_chrdev_region函数. 推荐使用动态分配的方法, 这样你就能在加载甚至编译模块的时候设定主设备号. 动态分配有个缺点: 你无法在分配设备号之前创造设备节点. 但一旦设备号被分配之后, 你可以通过/proc/devices来读取它. 你还可以通过sysfs来获取更多的设备信息. 2.6版内核的sysfs被挂载到/sys目录. awk资料: http://www.linuxjournal.com/article/8913 本文来自ChinaUnix博客,如果查看原文请点:http://blog.chinaunix.net/u2/60434/showart_471552.html
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值