Linux 移植向导

译序:

出于学习目的,在网上搜索了一些将Linux移植到特定硬件平台下的一些文章。多数都在讲解基于某一款开发板的详细移植过程,少有从架构和平台视角来讨论Linux的移植。本文虽然是国外作者一篇早年的文章,但是还是从中受益匪浅,共享出来希望对大家也会有所帮助。本人英文水平和专业能力有限,翻译不当之处难免,还请见谅!

原文地址:http://www.embedded.com/print/4023297

译文:

为什么需要别人来帮助移植Linux?本文介绍了非嵌入式系统程序员可能会忽视的内容

Linux在嵌入式系统领域中越来越流行。许多公司专门从事将Linux移植到嵌入式系统的工作。本文将介绍在一个特定的嵌入式硬件平台环境下移植Linux所涉及的一些工作,以及如何完成这些工作、

目前(2001),大部分嵌入式系统都是公司的私有资产。如果一个半导体公司设计研发了一款新的处理器,他们必须依靠操作系统商将操作系统移植到新的处理器上。新的处理器需要其相应的开发工具,比如编译器、调试器、仿真器等等。操作系统商通常情况下会提供这些的工具。此外,新的处理器也需要连接一些外部设备。设备驱动也需要在嵌入式操作系统上开发出来。随着Linux进入嵌入式系统领域,半导体公司自己也可以将操作系统移植到新设计的处理器上,因为Linux的内核源代码是开源的。GNU项目也提供了大量的Linux平台下的开发工具而且也是开原的。Linux下还带有大量的设备驱动,可以直接使用,也可以作为驱动开发者开发自己驱动的一个起始点。

目标硬件平台

在本文的示例中,目标硬件平台(开发板)包括一颗ASIC芯片(可以支持窗口终端和Internet访问),一颗MIPS CPU,和一个CPU接口电路(北桥)。此外,开发板还支持EDO RAM。Flash Rom(只读存储),I2C RTC,I2C EEPROM,和I2C Serial Bus。顶层硬件模块架构图如图1所示。ASIC内部的硬件模块架构图如图2所示。采用Ramdisk(内存盘,后续介绍)作为根文件系统。使用Ramdisk是为节省移植时间。虽然,本文是基于在MIPS CPU上做Linux移植的介绍,但是总的移植方法对于不同的处理器来说大同小异。另外,本文也只是对移植过程中所谈到的Linux内核功能,做最低限度上的介绍。移植要使用到交叉编译开发工具链,这些是在一台安装Red hat Linux 的PC上的。可以到Linux VR站点查找MIPS 平台编译Linux所需要的交叉编译开发工具和资源。如下的编译开发工具需要用到:交叉编译汇编器,连接器等等;C 交叉编译器;交叉开发环境的C库文件。详细安装步骤参看交叉工具链附带的文档。


内核源代码

MIPS平台的内核源代码可以从Linux-VR站点下载到。移植Linux到一个新的硬件平台,最重要的一点是要清楚的理解内核源代码的组织结构。下面提到的目录结构并不是Linux内核的完整目录,只是本文会涉及到的一些目录。Linux源代码的顶级目录包含如下的子目录:

  • arch—该目录包含了所有与硬件架构相关的代码。对于Linux支持的每一个架构平台,都有一个相应的目录与之对应。每一个这样的硬件架构目录下又有4个主要的子目录,分别是:
  1. Kernel—该目录包含与该CPU 架构相关的支持内核功能的代码
  2. mm—该目录包含与该CPU 架构相关的支持内存管理功能的代码
  3. lib—该目录包含与该CPU 架构相关的库代码
  4. MY-PLATFORM—该目录包含某一特定硬件平台的代码(特定硬件平台总是基于某一种CPU架构并包含一些挂接在特定总线上的外部设备)

提示:Linux也可以移植到一些不带MMU(内存管理单元用以支持虚拟内存)的处理器上

  • documentation—该目录包含大量内核开发相关的文档
  • drivers—该目录包含设备设备驱动相关的代码。每一种类型的设备都有对应的子目录,例如字符设备,块设备,网络设备等等。
  • fs—该目录包含所有的文件系统相关的代码。每一种文件系统都有对应的子目录。
  • include—该目录包含内核代码的头文件。common子目录包含所有架构公用的头文件,每一个架构还有对应的子目录,还有许多其它内核代码用到的子目录。
  • init—该目录包含内核初始化部分的代码
  • kernel—该目录包含主要的内核代码
  • lib—该目录包含内核使用到的一些代码
  • mm—该目录包含一些内存管理部分的代码

构建内核映像

在Linux源代码顶层目录下的Makefile文件中,ARCH变量必须定义正确(就本文来说定义成MIPS)。在 /arch/MY_ARCH/boot下的Makefile文件,交叉编译的变量和内核加载地址也要定义正确。如果需要加入新的配置选项,需要修改/arch/MY_ARCH/config.in文件。对于新的硬件平台可能会需要新的配置选项,以包含平台相关的代码。内核也需要重新进行配置("make config"),尽可能裁剪,只保留最小的需要(串口驱动,内存盘,ext2文件系统),然后,执行"make dep“重新建立依赖关系,最后执行"make vmliux"生成内核映象。

Ramdisk(内存盘)

在开始阶段,可以使用内存盘作为根文件系统。MIPS架构下的内存盘映像和对象文件已经可以从Linux-VR站点获取到。内存盘映象是一个Ext2文件系统映象文件,内存盘对象文件是个ELF对象文件,它封装了内存盘映像文件并可以连接到内核中。内存盘映像文件通常以压缩形式存储。在配置内核时要开启CONFIG_BLK_DEV_RAM 和CONFIG_BLK_DEV_INITRD选项。内存盘映像文件也可以根据需要包含进自己的应用程序。可以从Linux-VR站点获取脚本工具来创建内存盘对象文件。ramdisk.o文件需要先拷贝到/arch/MY_ARCH/boot目录下并会被连接到内核中。可以参看Documentation目录下的randisk.txt文档,里面有Linux下内存盘设备的使用的详细描述。

针对特定处理器的内核代码修改

如果目标硬件平台使用的是流行的处理器,大部分情况下,Linux已经支持这样的处理器,如果Linux没有支持目标平台的处理器,就需要从已经支持的类型当中选择与其最接近的类型作为参考,来进行处理器相关的代码移植。比如,MIPS公司将MIPS Core授权给许多芯片生厂商,这些生厂商可能会对其进行修改。TLB条目的数量不同厂商可能选择不同的数目。新的配置选项需要加入以包含针对特定处理器所做的修改。在移植到一个新的处理器架构时,/arch/MY_ARCH/kernel和/arch/MY_ARCH/mm目录下的代码也需要修改。

位于/arch/MY_ARCH/kernel/head.S的汇编文件包含Linux内核的入口点(kernel_entry)和异常处理有关的代码。kernel_entry的伪代码实现如List1所示:

Listing 1: Kernel entry pseudocode

  1. Set desired endian mode
  2. Clear the BEV bit
  3. Set the TLB bit
  4. GOTO cpu_probe and return
  5. Set up stack for kernel
  6. Clear bss
  7. GOTO prom_init and return
  8. GOTO loadmmu and return
  9. Disable coprocessors
  10. GOTO start_kernel
  11. GOTO 10
CPU的配置寄存器要设置正确。首先要保证系统设定了正确的大小端模式。在本文的例子中,系统使用小端模式。其次异常向量位需要清除,保证系统可以响应异常处理。此外,TLB位需要设置为1,这样可以使用TLB机制(加速内存访问)

接下来是检测CPU类型。下面List2 是一个函数简单的实现例子。头文件/include/asm/bootinfo.h定义了宏MYCPU和MY_MACH_GROUP。在函数中,变量mips_cputype必须被赋值。该变量值后续会用来确定CPU必须要用到的使用异常处理和MMU例程,同时也会用于返回/proc文件系统下的CPU信息。

Listing 2: Code to probe cputype

LEAF(cpu_probe)
        la       t3, mips_cputype

        li       t2, MYCPU       /* include/asm-mips/bootinfo.h */
       b       probe_done
       sw       t2, (t3)
END(cpu_probe)

再接下来,为内核初始化代码建立栈,然后释放内核映像的BSS节,然后调用prom_init函数。在调用loadmmu函数时,TLB和高速缓存被刷新,管理高速缓存的函数会建立好。然后关闭除0号协处理器以外的其它协处理器。最后跳转到start_kernel函数。目录/arch/MY_ARCH/mm包含有TLB和高速缓存处理的例程。

针对特定平台的内核代码修改

在目录/arch/MY_ARCH/ 目录下,每一个目标硬件平台都有自己对应的子目录。对于未包含的硬件平台,需要建立一个新目录,可以选择一个与之最接近的平台并拷贝相应的文件到新的目录。在该目录下,要包含有特定硬件平台相关的中断处理、计时器管理、平台初始化等例程。同时在/include/asm目录下,为新平台创建一个目录,存放平台相关的头文件。在文件/arch/MY_ARCH/MY_PLATFORM/prom.c中,有定义prom_init函数,如List3所示。在该函数中,可以修改内核启动参数,设置机器组、可用内存。

Listing 3: PROM initialization

int __init prom_init (int argc, char **argv, char **envp)
{
       unsigned int mem_limit;// set upper limit to maximum physical RAM (32MB)
       mem_limit = 32 * 1024 * 1024;

       // the bootloader usually passes us argc/argv[] .
       //In the present case, these arguments are not
       //passed from the bootloader. The kernel wants
       // one big string. put it in arcs_cmdline, which
        ater gets copied to command_line
       //(see arch/mips/kernel/setup.c)

       strcpy (arcs_cmdline, *root=/dev/ram*);

       mips_machgroup = MY_MACH_GROUP;

       // set the upper bound of usable memory
       mips_memory_upper = KSEG0 + mem_limit;

       printk(*Detected %dMB of memory\n*, mem_limit >> 20);

       return 0;
}

内核启动

下面List4代码片段,是需要特别关注的start_kernel函数中的的开头部分,该函数定义在/init/main.c文件。

asmlinkage void __init start_kernel(void)
{
       char * command_line;

        /*
         * Interrupts are still disabled. Do necessary setups, then
         * enable them
         */
        lock_kernel();
       printk(linux_banner);
       setup_arch(&command_line, &memory_start, &memory_end);
       memory_start = paging_init(memory_start, memory_end);
       trap_init();
       init_IRQ();
       sched_init();
       time_init();
       parse_options(command_line);
       .
       .
       .

List5 给出le setup_arch函数的实现,该函数定义在/arch/MY_ARCH/kernel/setup.c文件。在该函数中,会调用目标平台的setup函数。命令行参数、内存起始和结束地址会返回给调用函数。在该函数中,连接到内核的内存盘映像的起始和结束地址也会被赋值,

Listing 5: Architecture setup function

__initfunc(void setup_arch(char **cmdline_p,
              unsigned long * memory_start_p, unsigned long * memory_end_p))
{#ifdef CONFIG_BLK_DEV_INITRD
#if CONFIG_BLK_DEV_INITRD_OFILE
       extern void *__rd_start, *__rd_end;
#endif
#endif

       myplatform_setup();       strncpy(command_line, arcs_cmdline, CL_SIZE);
       *cmdline_p = command_line;

       *memory_start_p = (unsigned_long) &_end;
       *memory_end_p = mips_memory_upper;

#ifdef CONFIG_BLK_DEV_INITRD
#if CONFIG_BLK_DEV_INITRD_OFILE
       // Use the linked-in ramdisk
       // image located at __rd_start.
       initrd_start = (unsigned long)&__rd_start;
       initrd_end = (unsigned long)&__rd_end;
       initrd_below_start_ok = 1;
       if (initrd_end > memory_end)
       {
          printk(*initrd extends beyond end of memory *
          *(0x%08lx > 0x%08lx)\ndisabling initrd\n*,
          initrd_end, memory_end);
      initrd_start = 0;
    }
#endif
#endif
}

Listing 6: Platform-specific initialization code

__initfunc(void myplatform_setup(void)){

     irq_setup = myplatform_irq_setup;    /*
     * mips_io_port_base is the beginning
     *of the address space to which x86
     * style I/O ports are mapped.
     */
     mips_io_port_base = 0xa0000000;

    /*
     * platform_io_mem_base is the beginning of I/O bus memory space as
     * seen from the physical address bus. This may or may not be ident-
     * ical to mips_io_port_base, e.g. the former could point to the beginning of PCI
     *memory space while the latter might indicate PCI I/O
     * space. The two values are used in different sets of macros. This
     * must be set to a correct value by the platform setup code.
     */
     platform_io_mem_base=0x10000000;

    /*
     * platform_mem_iobus_base is the beginning of main memory as seen
     * from the I/O bus, and must be set by the platform setup code.
     */
platform_mem_iobus_base=0x0;#ifdef CONFIG_REMOTE_DEBUG
    /*
     * Do the minimum necessary to set up debugging
     */
    myplatform_kgdb_hook(0);
    remote_debug = 1;
#endif

#ifdef CONFIG_BLK_DEV_IDE
    ide_ops = &std_ide_ops;
#endif

#ifdef CONFIG_VT
#if defined(CONFIG_DUMMY_CONSOLE)
    conswitchp = &dummy_con;
#endif
#endif

/*
 * just set rtc_ops && pci_ops; forget the rest
 */
rtc_ops = &myplatform_rtc_ops;
pci_ops = &myplatform_pci_ops;
}

特定平台的初始化代码(List6所示)定义在/arch/MY_ARCH/MY_PLATFORM/setup.c文件中。在该函数中,要设置好各种全局地址和平台相关的RTC和PCI操作函数接口。就PCI而言,需要在特定平台上的实现下面的7个PCI相关函数:

  • myplatform_pcibios_fixup()
  • myplatform_pcibios_read_config_byte()
  • myplatform_pcibios_read_config_word()
  • myplatform_pcibios_read_config_dword()
  • myplatform_pcibios_write_config_byte()
  • myplatform_pcibios_write_config_word()
  • myplatform_pcibios_write_config_dword()

中断处理

trap_init函数根据CPU类型,把顶层异常处理代码拷贝到KSEG0中断向量所在位置。中断处理相关代码包含在/arch/MY_ARCH/MY_PLATFORM/irq.c和int_handler.S文件里。多数硬件系统会有一个中断控制器处理系统的中断。中断控制器通常挂接在处理器的一根外部中断连线上。架构相关的代码要做修改,保证中断控制器与内核中断处理相适应。

List7给出了特定平台的中断初始化代码。必须先使用set_except_vector函数设置(0号向量)最高层中断处理函数。其次初始化平台的中断控制器。如果开启了远程调试,需要调用set_debug_traps函数,以便断点和错误状况可以通告给调试程序。另外还需要产生一个断点来开始与运行在远端主机的调试器的通讯。

Listing 7: Platform-specific interrupt initialization

static void __init myplatform_irq_setup (void){    set_except_vector (0, myplatform_handle_int);

    // Initialize InterruptController

    InterruptController_Init(IsrTable);

#ifdef CONFIG_REMOTE_DEBUG
    printk (*Setting debug traps - please connect the remote debugger.\n*);
    set_debug_traps ();
    breakpoint ();
#endif
}

顶层的中断处理代码(List8所示)首先保存所有寄存器,然后关闭中断。通过读取CAUSE寄存器,获取中断源。如果是计时器中断,调用相应的中断服务例程。如果不是计时器中断,要检查中断控制器连接的中断源上是否有中断产生。

Listing 8: Top-level interrupt handler

NESTED(myplatform_handle_int, PT_SIZE, ra)
    .set    .noat
    SAVE_ALL
    CLI
    .set    .at

    mfc0    .s0, CP0_CAUSE    .# get irq mask

    /* First, we check for counter/timer IRQ. */
    andi    .a0, s0, CAUSEF_IP5
    beq    .a0, zero, 1f
    andi    .a0, s0, CAUSEF_IP2    # delay slot, check hw0 interrupt

    /* Wheee, a timer interrupt. */
    move    .a0, sp
    jal    .timer_interrupt
    nop    .    .# delay slot

    j ret_from_irq
    nop    .    .# delay slot

1:
    beq    .a0, zero, 1f
    nop

    /* Wheee, combined hardware
    level zero interrupt. */
    jal    .InterruptController_InterruptHandler
    move    .a0, sp    .# delay slot

    j    .ret_from_irq
    nop    .    .# delay slot

1:
    /* Here by mistake? This is possible,
    *what can happen is that by the time we
    *take the exception the IRQ pin goes low, so
     *just leave if this is the case.
     */
    j    .ret_from_irq
    nop
    END(myplatform_handle_int)

Listing 9: Interrupt handler for the interrupt controller

void
InterruptController_InterruptHandler (
    struct pt_regs *regs
    )

{
    IntVector intvector;
    struct irqaction *action;
    int irq, cpu = smp_processor_id();

    InterruptControllerGetPendingIntVector(&intvector);
    InterruptControllerGetPendingIntSrc((&irq);

  action = (struct irqaction *)intvector;

      if ( action == NULL ) {
      printk(*No handler for hw0 irq: %i\n*, irq);
        return;
      }

    hardirq_enter(cpu);

      action->handler(irq, action->dev_id, regs);
      kstat.irqs[0]irq++;
      hardirq_exit(cpu);

} // InterruptController_InterruptHandler ()

在目标平台上,还需要实现request_irq、free_irq、enable_irq和disable_irq四个中断相关的函数。request_irq函数用来对特定中断源安装中断处理例程。free_irq函数用来释放特定中断分配的内存。enable_irq函数调用中断控制器的接口函数,启用某一中断请求线。disable_irq函数用来关闭中断请求线。

定时器中断定义在文件/arch/MY_ARCH/MY_PLATFORM/time,c,该文件包含平台相关的时钟管理代码。运行在MIPS架构上的Linux内核需要一个100Hz的定时器中断(每10ms中断一次)。在MIPS CPU里,可以对0号协处理器的寄存器通过编程方式产生100Hz的定时器中断。需要用到计数寄存器和比较寄存器配合来产生定时器效果。在启用时,计数寄存器可以设定一个任意值,每一个处理器时钟周期,计数寄存器的值加1。在两个寄存器的值相等时,会产生一个外部硬件中断新号,同时计数寄存器的值被复位。复位后,计数寄存器会重新计数。定时器中断处理例程会调用do_timer函数。对比较寄存器写操作可以清除定时器中断。

串口控制台驱动

控制台运行在串口驱动之上。串口驱动使用轮询方式为printk函数提供服务。这种驱动需要提供如下的最少接口函数:

  • serial_console_init()-for registering the console printing procedure for kernel printk() functionality, before the console driver is properly initialized
  • serial_console_setup()-for initializing the serial port
  • serial_console_write(struct console *console, const char *string, int count)-for writing "count" character

TTY驱动

采用中断方式通讯的串口驱动可以创建终端设备。注册带有TYT的串口驱动就可以创建终端设备。在/frivers/char目录下,已经有许多各种各样的串口驱动可用。只要选择和目标平台串口最接近的驱动进行修改就可以建立目标平台的串口驱动。Linux系统下,中断方式驱动的字符设备编程接口可以在《Linux Device Drivers》一书中有描述。

Bootloader

虽然在目标架构下。可以使用LILO(Linux Loader)加载内核,但是使用平台已有的内核加载器,移植工作会更快。LILO通过一种方式把信息传给内核,这种采用的方式类似于PC BIOS传递信息给内核的情况。然后。LILO会调用位于内核的kernel_entry函数,将控制权转移给内核。 如果使用已有的加载器。需要在command_line字符串中加入启动参数,以传递给内核。在本文的示例中,在command_line字符串中,加入了"root=/dev/ram"参数,该参数通知内核使用内存盘作为根文件系统挂载。如果需要,也可以加入其它启动参数。在加载器把内核映像加载到内存指定地址后,就跳转到kernel_entry函数对应的地址执行。

如果加载器有print 输出函数,调试起来会更容易,因为内核里的printk函数会缓存所有的到控制台输出,要到控制台初始化以后才能看到输出。(console_init在/init/main.c文件中)

如果一切进行的顺利,会看到如下信息的输出:

Detected 32MB of memory.
Loading R4000/MIPS32 MMU routines.
CPU revision is: 000028a0
Primary instruction cache 32 kb, linesize 32 bytes
Primary data cache 32 kb, linesize 32 bytes
Linux version 2.2.12 (rpalani@rplinux)
(gcc version egcs-2.90.29 980515 (egcs-10))
CPU frequency 200.00 MHz
Calibrating delay loop: 199.88 BogoMIPS
Memory: 14612k/16380k available
(472k kernel code, 908k data)
Checking for wait' instruction... available.
POSIX conformance testing by UNIFIX
Linux NET4.0 for Linux 2.2
Based upon Swansea University Computer Society NET3.039
Starting kswapd v1.1.1.1
No keyboard driver installed
RAMDISK driver initialized: 16 RAM disks of 4096K size 1024 blocksize
RAMDISK: Compressed image found at block 0
VFS: Mounted root (ext2 filesystem) readonly.
Freeing unused kernel memory: 32k freed

内核会尝试打开控制台设备,并且从根文件系统的下列位置/sbin/init、/etc/init、/bin/init,依次查找并执行“init”文件。如果都没有找到。内核会启动一个交互式shell程序。如果shell程序也没有找到,内核会发生“panic”,这种情况不希望发生。如果启动成功,控制台会出现shell提示符。接下来可以执行内存盘的各种应用程序了。

加入新驱动

目标平台上的硬件设备驱动也可以加进来。这可以通过选择最相匹配的硬件驱动再进行修改来完成。如果目标平台有平台专用的硬件,没有现有驱动可以使用,就要使用标准驱动接口来实现这样的设备驱动。驱动可以用模块方式实现,以便使用insmod和rmmod动态的加载卸载。

有益的提示:

Sprinkle printk() statements liberally throughout your code to aid debugging. This may be an obvious suggestion, but it is worth mentioning.

Remote GDB5 may also be useful for debugging, though in my experience printk's are more than enough for debugging kernel code. In remote GDB, the host development system runs gdb and talks to the kernel running on the target platform via a serial line. You need to setup CONFIG_REMOTE_DEBUG = Y in the kernel configuration. putDebugChar(char ch) and getDebugChar() are the two functions that need to be implemented over the serial port for remote debugging using gdb.

If you are forced to use a common port for console and debug, the GDB output can be multiplexed with the debug output by setting the high bit in putDebugChar(). GDB forwards output without the high bit set to the user session.

To start with, implement only the basic minimum functions for the tty driver as specified in $(TOPDIR)/include/linux/tty_ldisc.h.


实时需求

The subject of embedded systems is not complete without a mention of real-time requirements. The standard Linux kernel provides soft real-time support. There are currently two major approaches to achieve hard real-time with Linux. These are RTLinux and RTAI. Both approaches have their own real-time kernel running Linux as the lowest priority task.

When dealing with proprietary hardware, as it often happens in embedded systems, the issue of proprietary software crops up as well. In Linux, proprietary modules can be handled with the GNU Lesser General Public License, which permits linking with non-free modules. It is compatible with the GNU General Public License, which is a free software license, and a copyleft license.6

With a good knowledge of the processor architecture and the hardware devices being used, porting Linux to an embedded system can be accomplished in a short time frame, which is of vital importance in the fast paced embedded systems market. In my case, where I have been using UNIX for quite some time, it took me around two months to complete the port of the minimum kernel functionality to our platform. Porting Linux to a different platform should not take that long when doing it for a second time.

作者介绍:

Rajesh Palani works as a senior software engineer at Philips Semiconductors. He has been designing and developing embedded software since 1993. He has worked on the design and development of software (ranging from firmware to applications) for set-top boxes, digital still cameras, TVs (Teletext), and antilock braking systems. Contact him atrajesh.palani@philips.com.

尾注

1. Stands for "GNU's Not Unix," a project launched in 1984 to develop a complete Unix-like operating system which is free software: the GNU system.

2. The topmost directory in the Linux source tree (/usr/src/linux, by default).

3. Translation Lookaside Buffer-hardware used for virtual to physical address mapping in a processor.

4. The subject of developing a bootloader for your processor is outside the scope of this article.

5. GNU Debugger-helps you to start your program, make it stop on specified conditions, examine what has happened (when your program has stopped), and change things in your program.

6. Copyleft says that anyone who redistributes the software, with or without changes, must pass along the freedom to further copy and change it.


参考文献

A Web site containing a wealth of information on Linux in general:

Web sites devoted to Linux on MIPS: Web sites dealing with real-time Linux: Beck, M. et al. Linux Kernel Internals. New York: Addison-Wesley, 1998. This book is a good source of information on the kernel internals.

Rubini, Alesandro. Linux Device Drivers. Sebastopol, CA: O Reilly & Associates, 1998.

This book delves into kernel internals and talks in detail about all types of device drivers under Linux.



评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值