2019年终总结

      2019年算是正式开始系统学习操作系统的开端年,其实这项工作从2018年6月份就开始了。当时想要系统学习操作系统是基于三个原因考虑的:一个是当时去面试今日头条,头条问了很多很基础底层的问题,比如UNIX五种IO模型,操作系统底层之类的,我是真的不会。我是Java开发程序员,但是深刻感觉到Java仅仅是一层皮,核心功能都是依赖于JVM、操作系统、编译原理构建的。第二个是当时美国对中兴华为芯片禁运,当时好像给人的感觉是中兴一下子就被打趴下了,而华为海思部门数十年如一日的积累让自己有底气对抗美国的科技打击。同时也让我懂得了掌握核心技术的重要性,虽然之前也懂得掌握核心技术的重要性,但是没有这么深刻的体会。唇亡齿寒这个词的含义真正当时有更深刻的体会。第三个是当时在公司的一些经历。我是做应用型开发出身的,说白了主要是做业务。感觉自己的命运很多时候自己控制不了,如果能分配到好的业务这一年就会好很多。而业务又是时时刻刻变化的,不知道什么时候这个业务就会被砍掉,或者各种政策原因导致业务萎缩等等。而技术相对来说比较纯粹和稳定,而我本身又是一个比较喜欢刨根问底的人(当然做业务也需要刨根问底,但是刨的底不一样)。基于上述三个原因就打算好好学习计算机技术,所以打算从Linux操作系统开始学习。

 

一,数字电路学习

       Linux只是一个软件,说白了是硬件的思想,软件驱动硬件的运行。要懂软件就要先懂硬件,所以就从最最基础的硬件开始学起吧,相当于重读一遍计算机专业基础核心课。就从数字电路学起吧。数字电路之前有过基础,书名叫做<<计算机结构与逻辑设计>>,东南大学黄正谨老师版。这本书之前作为专业课学过,学起来比较轻松。大概花了一个月的时间吧,把基本概念理清了。核心是知道了在计算机世界中怎么表示0和1,基于0和1怎么实现算术运算和逻辑运算。比较重要的是知道了寄存器的内部实现原理,寄存器在数字电路中本质上是一个触发器,或者叫做锁存器,就是通过技术手段让寄存器中的数据维持在0或者1,同时寄存器中的数据可以传递。也学到了什么是内存,在物理层面讲内存是由静态电容做成的一个字节数组,每个数组元素的索引就是地址。还有一个比较重要的概念是译码器,译码的过程就是寻址的过程,也就是怎么根据地址总线中的地址找到字节数组索引的过程。同时深刻知道了为什么CPU的速度比内存快那么多的原因。内存是基于静态电容做的,CPU/寄存器是基于硅做的,两个速度相差百倍是可以理解的。这也就引出了后面三级高速缓存的故事。有了上面的基础,接下来又讲了用硬件怎么实现加法器、减法器、移位寄存器、乘法器、触发器、时钟定时器等等。这些器件都是构成CPU的核心部件。通过数字电路的学习,可以接下来学习计算机组成原理了。

 

二,计算机组成原理学习

       计算机组成原理书籍用的是哈尔滨工业大学唐朔飞老师的<<计算机组成原理>>,该本书也是计算机考研计算机学科专业基础综合的指定参考书。

       计算机组成原理说白了是在讲计算机硬件是怎么工作的。先概括一下基本概念吧:

       计算机 = 主机 + 外部设备

 

       主机 = CPU + 总线 + 内存

       外部设备 = 输入设备 + 输出设备

 

       CPU = 寄存器 + 控制器 + 运算器 + 中断处理器 + 三级高速缓存

       总线 = 数据总线 + 地址总线 + 控制总线

       内存 = 静态电容组成的字节数组

 

        输入设备 = 键盘 + 磁盘 + 网卡 + .....

        输出设备 = 终端显示器 + 磁盘 + 网卡 + ....

 

        CPU的基本工作流程是:取指令 ---> 执行指令 ----> 响应中断;在不考虑高速缓存的情况下,取指令/执行指令都需要通过总线去访问内存,在SMP体系(对称多处理,多个CPU共享一个内存,一套系统总线)中,可以通过锁总线的方式实现互斥访问。原因是多个CPU共享同一份系统总线,访问同一个内存。因此在一个CPU占用总线访问一个内存地址空间的时候,只需要锁住总线(拉低总线电平),就可以禁止其他CPU用总线访问同一块内存地址空间。待当前CPU访问完内存释放总线后,其他CPU可以访问内存,可以看到实时的修改结果。在单核CPU上实现互斥更简单,只要在指令序列执行期间关闭中断就可以了,因为只有在发生中断的时候才可以做任务切换,所以只要在执行指令序列期间关闭中断就可以禁止任务切换,保证互斥。

        CPU中还有硬件高速缓存,硬件高速缓存和内存数据一致性保证方法有通写和回写两种。在SMP体系中,各个CPU都有自己的高速缓存,读写都是读写高速缓存,怎么保证多个CPU之间的数据可见性和一致性。CPU提供了内存屏障和总线监听机制来保证。内存屏障是指对于某些写入类指令,CPU可以直接清空高速缓存,将数据直接落地到内存中去。总线监听是指其他CPU通过电子技术时刻监听总线上的变更,一旦发现有0和1的变更,就从内存中重新读取数据到高速缓存中。通过内存屏障和总线嗅探监听就保证了多核处理器多任务之间数据可见性问题,这样理解Java中volatile关键字的实现原理就很简单了。再加上上文提到的锁总线技术,就可以保证原子性。现代高级语言中的原子指令(testAndSet , cmpXchg)都是通过这三种硬件技术(内存屏障清缓存、总线嗅探监听刷新缓存,锁总线)实现的。而compareAndSet又是现代高级语言中实现自旋锁、阻塞锁的关键技术,这部分内容到Linux内核部分再阐述。

         下面说说外部设备和中断。这部分内容很重要,现代操作系统本质上是由中断触发的,中断是计算机工作的源动力,外部设备又是通过中断和CPU交互的。外部设备又叫做IO设备,IO设备速度相对于内存/CPU来说又满了百万量级,不同IO设备的速度差异也不一样。IO设备和CPU之间通过IO接口交互。在IO接口中提供了多个寄存器端口供CPU使用,概括来讲包括数据端口、地址端口和控制端口。IO设备和CPU之间的交互方式有三种方式:程序查询方式、程序中断方式、DMA方式。程序查询方式最最原始也最最简单,CPU一直轮询控制端口,判断控制端口中的状态位是否被置位,IO设备数据准备好的时候置位控制端口,此时CPU就可以读取或者写入设备数据继续工作了。但是这种方式工作效率很低下,一直让CPU在空轮询。程序中断方式是指CPU给IO设备发一个指令,开启IO设备的工作,然后CPU转而去处理其他任务。等到IO设备数据准备好了,IO设备会给CPU发中断。根据上文所说,CPU在每条指令执行的结尾会响应中断,如果有中断就转而去执行中断处理程序,在中断处理程序中会读取/或者写入IO设备提供的数据。处理完毕了通知IO设备可以继续接下来的处理了。这种方式的缺点是数据还是放在IO设备的缓存中,数据准备好了还需要CPU来做复制操作复制到内存中,或者从内存中复制到IO设备高速缓存中。DMA方式更直接,对于读操作来说DMA控制器直接将数据写入到内存指定位置之后再发中断给CPU。对于写操作来说DMA控制器直接将数据从内存指定位置复制到IO设备缓冲区中去。节约了CPU宝贵的时间。

         接下来再讲讲CPU的指令系统,指令大体上分为数据传送指令、算术逻辑运算指令、输入指令、输出指令。这里不打算详细罗列每个指令的具体作用,而是说说指令的变长编址方法,因为这部分内容和软件中的字符编码方法有很多类似之处。指令由操作码和操作数组成,每条指令的操作数都不尽相同,从0到3不等。这会导致每条指令的实际长度也不尽相同。当然我们可以采取一种最简单的方式把所有的指令都编码成固定字节长度的,CPU每次取指令只要取固定字节长度的内存空间就好了,然后再根据具体的编码规则去识别出操作码和多个操作数。但是这样做很不经济,会浪费大量的内存空间,因为某些指令并不需要那么多的存储空间。所以有人提出了变长指令的编码方式。概括来讲比如对于两字节的指令,第一个字节前缀统一是0000,后面的1一个半字节用来存放操作码和操作数。对于三字节指令,第一个字节前缀统一是0001,后面的字节用来存放操作码和操作数。四字节指令第一个字节的前缀统一是0010,这样就可以区分了。CPU在取指令的时候先取第一个字节的前缀,通过前缀来判断该指令是几字节指令,然后再一把把整个全部字节都取出来,然后根据具体的规则来译码区分出来操作码和操作数就可以了。高级语言中的ASCII编码,unicode编码,UTF-8编码,GBK编码也是按照这种格式的。所以学习计算机很多东西都是触类旁通的,往后还会经常介绍。有了这些基础,接下来可以学习汇编语言了。

 

三,实模式下汇编语言的学习

       实模式下汇编语言学习用的教材是王爽老师的<<汇编语言>>,在学习该语言的时候还专门安装了一个DOS开发环境。这里不打算详细介绍学习了多少中汇编指令,更着重说明计算机是怎么运行的。

       首先介绍段以及地址扩充的概念。我们写的代码编译成汇编语言之后都是被封装成段的,这些段被转载到内存中。CPU按照段基地址+段内偏移的方式来访问内存。地址扩充是指内存地址空间比寄存器位数大的时候怎么访问所有内存。假如内存地址空间是20位的,寄存器位数只有16位。地址扩充的方法是用两个寄存器来寻址一个20位的内存地址,一个寄存器左移四位 + 另一个寄存器形成内存地址。通过这种方式来访问内存。

       在介绍一些常用的寄存器:

        CS:IP,存放下一条将要执行的指令的地址

        SS:SP, 存放栈中下一条将要被读取的数据的地址

        DS:[AX,BX,CX,DX] , 存放将要访问的数据

 

四,保护模式下汇编语言的学习

       该部分内容将要详细介绍一下,因为它是学习操作系统内存管理的基础。书籍用的是李忠老师的<<汇编语言,从实模式到保护模式>>。

       保护模式下面的内容比较多,其中主要是内存管理方面的变化,以及地址空间的扩充。在保护模式下地址空间扩展到32位,打到2^32 = 4GB地址空间。寄存器位数扩展到32位,可以完全访问4GB地址空间,无需实模式下面的地址扩充。同时引入流水线指令并行执行机制,第一个条指令的执行阶段可以和第二条指令的取指令阶段并行执行。在遇到跳转指令的时候要清空流水线重新执行。

       保护模式下内存寻址方式也发生了很大的变化,在原来的段式管理基础上增加了页式管理,主要为了解决段式管理中的内存浪费问题。页式管理式可以选择开启与否的,段式管理是一定存在的,同时开始支持多任务。这里的任务可以理解为操作系统中的进程的概念,其实在Linux中本身就没有进程/线程的概念,只有任务的概念。一个任务在内存中就对应一个段(这个说法有点不太准确,X86体系中数据段,代码段,堆栈段都可以单独对应一个段,只是Linux用平坦模型来表示,代码段数据段共用一个段),一个4GB的虚拟地址空间。每个任务在内存中有一个局部描述符表LDT,在LDT中存放了该任务段的起始虚拟地址和段界限。在平台模型下每个任务的起始虚拟地址都是0,段界限都是4GB。此时LDT仅仅起到一个地址占位符的概念,所有的任务都可以共用同一个LDT。而Linux内核恰恰也是这样做的,所有任务共用一个LDT,这个LDT=GDT。这样保证了每个任务都具有4GB的虚拟地址空间。内核中起到核心作用的寻址方式是页式寻址。这里介绍一下线性地址、虚拟地址、逻辑地址、物理地址四者之间的关系。逻辑地址是段基地址和段内偏移的组合。比如CS:EIP就是一个逻辑地址,通过CS可以找到代码段的起始基地址,在平坦模型下代码段的起始基地址就是0.EIP就是段内偏移,通过段基地址+段内偏移就形成了指令的线性地址,线性地址也等于虚拟地址。该线性地址还需要通过页目录表和页表的转换才能形成真实的物理地址。具体页目录表和页表的转换过程不详述,就是一个二级映射的过程。需要指出的是每个任务都有自己的唯一页表,这样虽然两个任务发出的线性地址是一样的,但是页表中存放的物理地址是不一样的,所以两个任务访问到的实际物理地址是不一样的。

        线性地址经过页表到物理地址的转换这个概念非常重要,这里要着重讲内核空间/用户空间,内存映射两个概念,这些都是操作系统的核心概念。每个任务都有4GB的地址空间,0-3GB是用户空间,3GB-4GB是内核空间。每个任务的0-3GB用户空间是独占的,3GB-4GB内核空间是共享的。具体到物理层面是怎么做到的呢,就是基于这个转换做成的。每个任务的页表中,3GB-4GB虚拟地址对应的页表中存放的物理地址都是完全一样的,都存放物理内核空间的实际物理地址。这样每个任务只要做系统调用陷入内核,访问的都是同一块内核空间。每个任务页表中0-3GB用户空间,页表中存放的物理地址可以不一样,这样就保证了每个任务的用户空间互相透明,谁也看不到谁。这样每个任务在访问用户空间的时候,每个任务访问的物理地址仅仅是自己的物理地址,互不干扰。这里又引申出了进程间通信的问题,进程间通信本质上都是要共享内存的,根据上面所述,只有3-4GB的内核空间每个进程可以共享,所以进程间通信必须要到内核空间才能通信。两个进程共同写同一块内核物理空间,这样它们之间就可以交换数据了。下面再来说说内存映射,最经典的内存映射是文件映射。一般文件读取的过程是这样的,先将文件数据从磁盘上读取到内核空间,假定读取到的虚拟地址空间为[3GB , 3GB+512B],对应的真实物理地址空间为[512B , 1024B]。再将数据从内核空间拷贝到用户空间,假定拷贝到的虚拟地址空间为[1GB , 1GB+512GB],对应的真实物理地址空间为[2048B , 1536B]。这样就要经过两次拷贝。如果我们在页表中对应[3GB , 3GB+512B]虚拟地址空间中写入真实物理地址空间为[512B , 1024B],对应[1GB , 1GB+512GB]虚拟地址空间中也写入真实物理地址空间为[512B , 1024B]。这样读取文件只需要把数据从磁盘读取到[512B , 1024B]的物理地址空间就可以了,这就是内存映射,本质上还是通过页表来实现的。

         总结一下,每个任务有一个自己的[0 ,4GB]虚拟地址空间,该地址空间的范围由段描述符表占位。每个任务有自己的页目录表和页表,可以把自己的虚拟地址定位到不同的物理地址上面去。每个任务的内核空间[3GB , 4GB]是共享的,每个任务中该部分页表中存放的物理地址一样,都指向同一块物理地址空间。每个任务的用户空间[0 , 3GB]是独占的,每个任务中该部分页表中存放的物理地址可以不一样。可以基于页表实现内存映射。

        下面说下任务切换。每个任务在自己的虚拟地址空间中有自己当前的运行状态信息,也就是运行上下文。该上下文信息主要是各种寄存器的信息,包括指令指针寄存器CS:EIP ;堆栈段寄存器SS:ESP;数据段寄存器DS,ES,EAX,EBX,ECX,EDX;状态寄存器EFLAGES等。在Linux早期内核源码实现任务切换直接基于X86提供的ljmp指令。ljmp的操作数是另一个任务的任务段描述符TSS。该操作会将当前任务的寄存器状态保存到当前任务的TSS中,同时将新任务的TSS中的各种寄存器信息覆盖到CPU的寄存器上,这样就实现了任务切换。在现代Linux内核中,已经不直接采用ljmp来实现任务切换了。而是通过内核栈来实现任务切换。任务切换要做两步,第一步是页表的切换,第二步是寄存器的切换。假定现在任务在用户空间运行,突然一个时钟中断到了,此时内核将任务运行的所有寄存器信息压入当前任务的内核栈中。假定时间片也到期了,此时内核调用schedule函数找到下一个已经准备就绪的任务。先将新任务的页目录表起始物理地址放到CR3寄存器中,此时实现了页表的切换,往后所有的页面映射都是基于新任务的映射了。新任务的所有寄存器信息存放在新任务的内核栈中,在switch_ to函数中可以访问到新任务的内核栈(内核空间共享),将新任务内核中的寄存器信息覆盖到CPU的寄存器中,在iret指令返回的时候把CS:EIP , SS:ESP也覆盖到CPU的寄存器中,此时几实现了寄存器的切换。

          这里再引申出线程的概念。在Linux中每个任务通过一个task_struct来表示,每个task_struct中会维护当前任务的页表,文件描述符表等。每个任务的task_struct中的页表都不一样,各个任务互相独立。而线程呢,在Linux中也是通过task_struct来表示的,只是在该线程的task_struct中指向通一块页表,用户空间和内核空间完全一样,文件描述符表也指向同一个,这样多个线程就共享同一块虚拟地址空间了。

          学完保护模式下的汇编语言,2018年也过完了,进入2019年主要时间在学Linux操作系统内核。中间详细阅读了<<Linux内核完全剖许0.12>>、<<数据库系统实现>>,<<计算机网络>>,<<Linux内核网络栈源代码情景分析,上册>>,<<netty权威指南>>。中间穿插着看了部分Java concurrent包源码,netty源码。我将来想把网络作为自己主要的研究方向,所以接下来会花大力气在netty上,netty学完了之后再逐渐扩充到上层的RPC , 消息中间件领域去。在这期间还简单阅读了<<UNIX环境高级编程>>,<<UNIX网络编程>>、<<LINUX内核源代码情景分析>>等书。接下来分时间段讲讲各部分的学习情况以及一些感悟。

 

五,Linux内核完全剖析--基于0.12内核;(2019年3月 - 2019年6月)

         这本书可以说是我2019年花时间最多的书籍了,也是对我影响最大的一本书,因为它让我知道了内核是怎么用代码实现的;进程在内核中是怎么表示的、进程的阻塞和唤醒到底是什么;内核到底是怎么管理内存的;文件到底是什么、文件和外部设备有什么关系、select系统调用到底是怎么实现的,等等。总之它带领我敲开了操作系统的大门。 

         通过逐字逐句阅读本书,最大的感触是内核是一个庞大的浑然一体的网状结构组成的系统,虽然经典的操作系统教材总是从进程为切入点来讲解内核,但是我感觉不管先从哪个方面来讲内核,都无法自圆其说。比如先讲进程,那么进程到底是怎么形成的,它的代码段和数据段来自哪里,它总不可能一下子就进入到内存中了,所以肯定要讲execve系统调用,但是该系统调用又涉及到文件系统了。而进程是怎么访问数据的,这些又涉及到内存管理。。。所以我只能把疑问暂时搁置一下,等到读到后面的时候再回过头来才能想明白,哦,原来是这样的。这里的总结就按照书上讲解的顺序来说好了。

         5.1,操作系统启动

                  操作系统启动的过程主要是把内核代码加载到内存中的过程,同时设置好访问内核空间的页目录表和页表。此处设置的页表会将CPU发出的虚拟地址转换为在数值上完全相等的物理地址,方便内核的初始化。同时通过BIOS读取一些硬件基本参数放入到内核空间指定位置上。最后形成的内存映像如下所示:

内核映像
......
fs模块代码
mm模块代码
kernel模块代码
main.c程序代码
全局描述符表GDT(2KB)
中断描述符表IDT(2KB)
head.s部分代码
软盘缓冲区(1KB)
内存页表pg3(4KB)
内存页表pg2(4KB)
内存页表pg1(4kB)
内存页表pg0(4KB)
内存页目录表(4KB)

 

         5.2,进程管理

                 进程管理从操作系统启动0号进程开始说起,0号进程是常驻内核的,或者说0号进程的task_struct是写死在内核源码中的,0号进程的页表直接复用内核的页表。0号进程的任务是一个无限的for循环,在内部一直调用schedule函数做调用。这就保证了操作系统肯定不会死机,即使一个任务都没有,它也会一直执行一个死循环。

                 接下来0号进程会创建1号进程,1号进程也叫做init进程,它会启动shell进程,然后无限while循环调用wait系统调用,用来杀死那些已变成僵尸进程的即将死亡的进程。

                 上述地方少说了一个内容,就是此时还会做各种初始化,包括建立用户空间内存映射mem_map;中断描述符表初始化;外部块设备初始化;字符设备初始化;调度程序初始化(此处会加载0号进程);文件系统初始化等等。然后开中断,此时操作系统可以开始正式工作了。

                 接下来说说创建进程的fork()系统调用。fork()系统调用用来创建一个新进程,在内核中的表示就是给新进程分配一个新的task_struct , 同时分配一块内存空间用来存放任务的内核栈。然后完全复制父进程的页目录表和页表的内容到子进程的页目录表和页表中,此时子进程具有和父进程完全一样的虚拟地址空间。但是父子进程页表项的内容都是写保护的,也即对于父子进程都是读操作的话,两者的执行轨迹一摸一样,并发向前执行。为了好区分父子进程,内核针对fork系统调用返回,在父进程中返回新创建的子进程号,在子进程中返回0,这样外部就可以区分父子进程了。 一旦父子进程有一方对实际物理地址空间有写操作,产生写保护异常,内核立马为写进程分配一块新的物理地址空间,同时将原来物理地址空间中的内容复制到新物理地址空间中,并将该新物理地址空间的基地址放到页目录项中,同时去除父子进程的写保护标志。此时两个进程再在该同样的虚拟地址空间中写,写入的就是不同的物理地址空间了,互不干扰。这就是写时复制机制。

                 接下来再说说任务的睡眠和唤醒,这部分内容比较重要,IO操作,并发编程的核心都在这里。对于任务的睡眠,Linux内核提供了可中断睡眠和不可中断睡眠两种,在任务状态上分别用TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE来表示。两者的唯一区别是可中断睡眠的任务可以被信号唤醒,而不可中断睡眠的任务只能被wake_up函数显示唤醒,当然可中断睡眠任务也可以被wake_up函数唤醒。

                任务的睡眠包括四个基本步骤:将当前任务状态置为TASK_INTERRUPTIBLE,从就绪队列中找到一个可运行的就绪任务,保存当前任务的工作现场,将新任务的工作现场恢复到CPU的寄存器中。

                任务的唤醒相对来说比较简单,就是直接将任务的状态置为TASK_RUNNABLE状态。等到下次调度的时候就有可能把该任务调度起来。

                上面说的是最最基本的,一般任务的睡眠和唤醒都是任务在等待某个动作完成,或者抢占共享内存空间的时候用到的。所以这里需要引入额外的一个状态和阻塞队列。状态通过CAS来判断,判断通过的任务可以独占访问资源,判断不通过的任务放入阻塞队列睡眠。睡眠的过程可以参考上述对任务睡眠的描述。等任务独占访问资源完了之后要释放锁,并唤醒阻塞在队列中的任务。只需要根据一定的策略挑选一个任务,将其从阻塞队列中删除并置为TASK_RUNNABLE状态就可以了。任务的切换在保护模式汇编里面已经说过了,这里不再赘述。

                 接下来再说一下在UNIX里面很重要的信号机制。信号也是一种任务间通信的手段,一个任务可以给另一个任务发信号,内核也可以给任务发信号,只要能够拿到任务的ID就可以。Linux默认分配了64种信号,每种信号都有自己的信号处理函数,可以选择自定义实现,也可以沿用操作系统提供的默认信号处理方式,一般都是杀掉任务。内核/A任务给B任务发送一个信号,在B任务的task_struct的中有一个信号位图signal,在信号位图中将给信号的标志置为1。等到B任务从内核态切换回用户态(系统调用返回、时钟中断返回)的时候,会去挨个检查B任务的信号位图,对于收到的信号执行信号处理函数。执行完信号处理函数之后继续回到原来的用户空间接着执行,就像什么都没有发生过一样。

                 内核中的定时器也很重要,像任务的超时阻塞就是基于定时器做的。在内核空间中有一个按照执行时间先后顺序排列的列表,该列表中存放每个任务的执行信息(一般是一个开始执行时间,一个函数指针)。每次时钟中断过来,在中断处理程序中会判断当前队列中是否有到时间可以执行的任务,如果有的话就执行该任务并将任务从队列中移除。就拿超时阻塞来说,内核中是通过schedule_timeout()函数实现的,该函数将任务阻塞,同时将一个唤醒任务的任务放到定时器队列中。等到定时器到期的时候,队列中存放的任务会将阻塞的任务唤醒,这样就实现了超时阻塞。

               

         5.3,块设备管理

 

 

         5.4,文件系统

 

 

         5.5,内存管理       

        

          

 

       

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值