Linux根文件系统的过程


猜测过程:
1、加载根文件系统存储介质的驱动程序(根文件系统存在于一个存储介质中)
2、执行根文件系统的初始化代码(从存储介质加载到内存了)

根文件系统

  对于Linux系统,系统中只有一个根目录,路径是“/”,而其它的分区只是挂载在根目录中的一个文件夹,如“/proc”和“/system”等,这里的“/”就是Linux中的根目录。
  对应根目录也就存在一个根目录文件系统的概念,可以将某一个分区挂载为根目录文件系统。这些在我们初始化系统的导航中设置:
简单的来说,根目录文件系统就是一种目录结构,包括了Linux启动的时候所必须的一些目录结构和重要文件。

根文件系统有两种,一种是虚拟根文件系统,另外一种是真实的根文件系统。一般情况下,会首先在虚拟的根文件系统中做一部分工作,然后切换到真实的根文件系统下面。

笼统的来说,虚拟的根文件系统包括三种类型,即Initramfs、cpio-initrd和image-initrd。

Initrd

initrd 的英文含义是 boot loader initialized RAM disk,就是由 boot loader 初始化的内存盘。在 linux内核启动前, boot loader 会将存储介质中的 initrd 文件加载到内存,内核启动时会在访问真正的根文件系统前先访问该内存中的 initrd 文件系统。在 boot loader 配置了 initrd 的情况下,内核启动被分成了两个阶段,第一阶段先执行 initrd 文件系统中的"某个文件",完成加载驱动模块等任务,第二阶段才会执行真正的根文件系统中的 /sbin/init 进程。第一阶段启动的目的是为第二阶段的启动扫清一切障爱,最主要的是加载根文件系统存储介质的驱动模块。我们知道根文件系统可以存储在包括IDE、SCSI、USB在内的多种介质上,如果将这些设备的驱动都编译进内核,可以想象内核会多么庞大、臃肿。

一些重要概念

文件系统

  • 虚拟文件系统存储在RAM里的,没有实际的设备(ROM)与之对应
  • 实际文件系统有实际的存储设备(ROM)与之对应,又可分为远程文件系统和本地文件系统
  • 根文件系统处于文件系统的最上层,其很重要的作用是用来挂载其他文件系统。根文件系统可以是虚拟文件系统也可以是实际文件系统,只要条件支持.
  • 文件系统格式 linux支持包括ext2,ext3,vfat,jffs,ramfs,nfs等文件系统
      jffs2:主要用于nor型flash,特点是可读写,支持数据压缩的日志型文件系统。
      yaffs/yaffs2:主要用于nand型flash,支持跨平台。
      cramfs:只读的压缩文件系统。可用于两种flash。
      ramdisk:基于ram的文件系统。是将一部分固定大小的内存当做块设备来用。它并非是一个实际的文件系统,而是一种将实际的文件系统装入内存的机制。将一些经常访问而又无需更改的文件通过ramdisk放在内存中,可以明显的提高系统的性能。
      initramfs:基于ram的文件系统。initramfs出现在2.6内核中,它类似于tmpfs,是一种基于内存的文件系统,它的使用不需要创建内存块设备。增加文件到ramfs会自动配置更多的内存,并删除或截去文件以释放内存。(若ramdisk没有满,已被占用的额外的内存也不能用来做其它事情,若ramdisk满了,但其它仍有闲置的内存,也必须重新格式化以后才能扩展使用)
      nfs:是由sun开发的一种在不同机器之间通过网络共享文件的技术。在嵌入式linux系统的开发调试阶段,可以利用该技术在主机上建立基于nfs的根文件系统,挂载到嵌入式设备,可以很方便的修改根文件系统的内容。

鸡生蛋蛋生鸡

1、内核刚启动时,磁盘设备、网络设备都还没有被驱动起来,所以无法访问磁盘,没法给磁盘启用对应的文件系统。那赶紧安装磁盘驱动程序,网络驱动程序呀,怎么不加载呢?因为磁盘种类太多了,没法把所有的驱动都编译到内核里头,那样内核得变得多大呀,所以就只能把这些驱动程序编译成模块的方式,在内核加载的时候现场判断当前用的是什么磁盘再加相应的磁盘驱动模块。那就加载磁盘驱动模块呀,等什么呢?原因是编译成驱动模块后,在这个阶段压根就没法加载!还没文件系统呢,怎么加载驱动模块?
2、结果启用文件系统的前提是
磁盘的驱动程序已经加载,而驱动程序的加载的前提是已经有文件系统存在
,这就成了鸡生蛋,蛋生鸡的问题。
3、怎么破?想到内核加载的时候,RAM其实已经可用了,那就基于RAM建立一个临时文件系统吧,这个临时的文件系统自己挂载到自己身上,然后我们指定这个文件系统为根文件系统,这样就有了起步的文件系统啦,借助这个临时的文件系统把磁盘驱动模块、网络驱动模块加载上,这样就可以挂载实际的文件系统啦,有了实际的文件系统之后再把这个实际的文件系统指定为根文件系统,这就好啦,然后其他的各式各样的文件系统就可以陆陆续续的挂载在这个根文件系统下了。
回到之前的一个问题,怎么建立一个基于RAM的虚拟文件系统?首先,在编译内核得时候就编译一个很精简的虚拟文件系统进去,然后内核在启动的时候先注册一个rootfs这个虚拟文件系统,然后挂载这个虚拟文件系统,那rootfs这个虚拟文件还是个空的,得给里头放点东西呀,放什么呢?就放编译进内核里头的那个很精简的虚拟文件系统里的内容。怎么内容放进rootfs里去?方法简单粗暴,直接把编译进内核里头的那个很精简的虚拟文件系统里的内容解压到rootfs里,这个过程叫填充rootfs。

根文件系统初始化过程

为什么不直接把真实的文件系统配置为根文件系统?

答案很简单,内核中没有根文件系统的设备驱动(如USB等存放根文件系统的设备驱动),而且即便你将根文件系统的设备驱动编译到内核中,此时它们还尚未加载,其实所有的Driver是由在后面的Kernel_Init线程进行加载。所以需要CPIO Initrd、Initrd和RAMDisk Initrd。另外,我们的Root设备都是以设备文件的方式指定的,如果没有根文件系统,设备文件怎么可能存在呢?

VFS的注册

下面的代码全部来自于Linux4.1,已经标注文件和方法。
start_kernel(/linux/init/main.c)

asmlinkage __visible void __init start_kernel(void)
{
    ```(省略)
	[1]vfs_caches_init(totalram_pages);
	signals_init();
	/* rootfs populating might need page-writeback */
	page_writeback_init();
	proc_root_init();
	nsfs_init();
	cpuset_init();
	cgroup_init();
	taskstats_init_early();
	delayacct_init();

	check_bugs();

	acpi_subsystem_init();
	sfi_init_late();

	if (efi_enabled(EFI_RUNTIME_SERVICES)) {
		efi_late_init();
		efi_free_boot_services();
	}

	ftrace_init();

	/* Do the rest non-__init'ed, we're now alive */
	rest_init();
}

vfs_caches_init(/linux/fs/dcache.c)

void __init vfs_caches_init(unsigned long mempages)
{
	unsigned long reserve;

	/* Base hash sizes on available memory, with a reserve equal to
           150% of current kernel size */

	reserve = min((mempages - nr_free_pages()) * 3/2, mempages - 1);
	mempages -= reserve;

	names_cachep = kmem_cache_create("names_cache", PATH_MAX, 0,
			SLAB_HWCACHE_ALIGN|SLAB_PANIC, NULL);

	dcache_init();
	inode_init();
	files_init(mempages);
	[1]mnt_init();
	bdev_cache_init();
	chrdev_init();
}

mnt_init(/linux/fs/namespace.c)

void __init mnt_init(void)
{
	unsigned u;
	int err;

	mnt_cache = kmem_cache_create("mnt_cache", sizeof(struct mount),
			0, SLAB_HWCACHE_ALIGN | SLAB_PANIC, NULL);

	mount_hashtable = alloc_large_system_hash("Mount-cache",
				sizeof(struct hlist_head),
				mhash_entries, 19,
				0,
				&m_hash_shift, &m_hash_mask, 0, 0);
	mountpoint_hashtable = alloc_large_system_hash("Mountpoint-cache",
				sizeof(struct hlist_head),
				mphash_entries, 19,
				0,
				&mp_hash_shift, &mp_hash_mask, 0, 0);

	if (!mount_hashtable || !mountpoint_hashtable)
		panic("Failed to allocate mount hash table\n");

	for (u = 0; u <= m_hash_mask; u++)
		INIT_HLIST_HEAD(&mount_hashtable[u]);
	for (u = 0; u <= mp_hash_mask; u++)
		INIT_HLIST_HEAD(&mountpoint_hashtable[u]);

	kernfs_init();

	err = sysfs_init();
	if (err)
		printk(KERN_WARNING "%s: sysfs_init error: %d\n",
			__func__, err);
	fs_kobj = kobject_create_and_add("fs", NULL);
	if (!fs_kobj)
		printk(KERN_WARNING "%s: kobj create error\n", __func__);
	[1]init_rootfs();
	[2]init_mount_tree();
}

init_mount_tree(/linux/fs/namespace.c)

static void __init init_mount_tree(void)
{
	struct vfsmount *mnt;
	struct mnt_namespace *ns;
	struct path root;
	struct file_system_type *type;

	type = get_fs_type("rootfs");
	if (!type)
		panic("Can't find rootfs type");
	mnt = vfs_kern_mount(type, 0, "rootfs", NULL);
	put_filesystem(type);
	if (IS_ERR(mnt))
		panic("Can't create rootfs");

	ns = create_mnt_ns(mnt);
	if (IS_ERR(ns))
		panic("Can't allocate initial namespace");

	init_task.nsproxy->mnt_ns = ns;
	get_mnt_ns(ns);

	root.mnt = mnt;
	root.dentry = mnt->mnt_root;
	mnt->mnt_flags |= MNT_LOCKED;

	set_fs_pwd(current->fs, &root);
	set_fs_root(current->fs, &root);
}

VFS的挂载

rest_init(/linux/init/main.c)

static noinline void __init_refok rest_init(void)
{
	int pid;

	rcu_scheduler_starting();
	smpboot_thread_init();
	/*
	 * We need to spawn init first so that it obtains pid 1, however
	 * the init task will end up wanting to create kthreads, which, if
	 * we schedule it before we create kthreadd, will OOPS.
	 */
	[1]kernel_thread(kernel_init, NULL, CLONE_FS);
	numa_default_policy();
	pid = kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES);
	rcu_read_lock();
	kthreadd_task = find_task_by_pid_ns(pid, &init_pid_ns);
	rcu_read_unlock();
	complete(&kthreadd_done);

	/*
	 * The boot idle thread must execute schedule()
	 * at least once to get things moving:
	 */
	init_idle_bootup_task(current);
	schedule_preempt_disabled();
	/* Call into cpu_idle with preempt disabled */
	cpu_startup_entry(CPUHP_ONLINE);
}

kernel_init(/linux/init/main.c)

static int __ref kernel_init(void *unused)
{
	int ret;

	[1]kernel_init_freeable();
	/* need to finish all async __init code before freeing the memory */
	async_synchronize_full();
	free_initmem();
	mark_rodata_ro();
	system_state = SYSTEM_RUNNING;
	numa_default_policy();

	flush_delayed_fput();

	if (ramdisk_execute_command) {
		ret = run_init_process(ramdisk_execute_command);
		if (!ret)
			return 0;
		pr_err("Failed to execute %s (error %d)\n",
		       ramdisk_execute_command, ret);
	}

	/*
	 * We try each of these until one succeeds.
	 *
	 * The Bourne shell can be used instead of init if we are
	 * trying to recover a really broken machine.
	 */
	if (execute_command) {
		ret = run_init_process(execute_command);
		if (!ret)
			return 0;
		panic("Requested init %s failed (error %d).",
		      execute_command, ret);
	}
	if (!try_to_run_init_process("/sbin/init") ||
	    !try_to_run_init_process("/etc/init") ||
	    !try_to_run_init_process("/bin/init") ||
	    !try_to_run_init_process("/bin/sh"))
		return 0;

	panic("No working init found.  Try passing init= option to kernel. "
	      "See Linux Documentation/init.txt for guidance.");
}

kernel_init_freeable(/linux/init/main.c)

static noinline void __init kernel_init_freeable(void)
{
	/*
	 * Wait until kthreadd is all set-up.
	 */
	wait_for_completion(&kthreadd_done);

	/* Now the scheduler is fully set up and can do blocking allocations */
	gfp_allowed_mask = __GFP_BITS_MASK;

	/*
	 * init can allocate pages on any node
	 */
	set_mems_allowed(node_states[N_MEMORY]);
	/*
	 * init can run on any cpu.
	 */
	set_cpus_allowed_ptr(current, cpu_all_mask);

	cad_pid = task_pid(current);

	smp_prepare_cpus(setup_max_cpus);

	do_pre_smp_initcalls();
	lockup_detector_init();

	smp_init();
	sched_init_smp();

	[1]do_basic_setup();

	/* Open the /dev/console on the rootfs, this should never fail */
	if (sys_open((const char __user *) "/dev/console", O_RDWR, 0) < 0)
		pr_err("Warning: unable to open an initial console.\n");

	(void) sys_dup(0);
	(void) sys_dup(0);
	/*
	 * check if there is an early userspace init.  If yes, let it do all
	 * the work
	 */

	if (!ramdisk_execute_command)
		ramdisk_execute_command = "/init";

	if (sys_access((const char __user *) ramdisk_execute_command, 0) != 0) {
		ramdisk_execute_command = NULL;
		prepare_namespace();
	}

	/*
	 * Ok, we have completed the initial bootup, and
	 * we're essentially up and running. Get rid of the
	 * initmem segments and start the user-mode stuff..
	 *
	 * rootfs is available now, try loading the public keys
	 * and default modules
	 */

	integrity_load_keys();
	load_default_modules();
}

do_basic_setup(/linux/init/main.c)

static void __init do_initcalls(void)
{
	int level;

	for (level = 0; level < ARRAY_SIZE(initcall_levels) - 1; level++)
		do_initcall_level(level);
}

/*
 * Ok, the machine is now initialized. None of the devices
 * have been touched yet, but the CPU subsystem is up and
 * running, and memory and process management works.
 *
 * Now we can finally start doing some real work..
 */
static void __init do_basic_setup(void)
{
	cpuset_init_smp();
	usermodehelper_init();
	shmem_init();
	driver_init();
	init_irq_proc();
	do_ctors();
	usermodehelper_enable();
	[1]do_initcalls();
	random_int_secret_init();
}

函数do_basic_setup()调用所有模块的初始化函数,包括initramfs的初始化函数populate_rootfs。这部分代码在init/initramfs.c下面,函数populate_rootfs通过如下方式导出:
代码[2]:ramdisk_execute_command值通过“rdinit=”指定,如果未指定,则采用默认的值/init。

代码[3]:检查根文件系统中是否存在文件ramdisk_execute_command,如果存在的话则执行init_post(),否则执行prepare_namespace()挂载根文件系统。
populate_rootfs(/linux/init/initramfs.c)

static int __init populate_rootfs(void)
{
	char *err = unpack_to_rootfs(__initramfs_start, __initramfs_size);
	if (err)
		panic("%s", err); /* Failed to decompress INTERNAL initramfs */
	if (initrd_start) {
#ifdef CONFIG_BLK_DEV_RAM
		int fd;
		printk(KERN_INFO "Trying to unpack rootfs image as initramfs...\n");
		err = unpack_to_rootfs((char *)initrd_start,
			initrd_end - initrd_start);
		if (!err) {
			free_initrd();
			goto done;
		} else {
			clean_rootfs();
			unpack_to_rootfs(__initramfs_start, __initramfs_size);
		}
		printk(KERN_INFO "rootfs image is not initramfs (%s)"
				"; looks like an initrd\n", err);
		fd = sys_open("/initrd.image",
			      O_WRONLY|O_CREAT, 0700);
		if (fd >= 0) {
			ssize_t written = xwrite(fd, (char *)initrd_start,
						initrd_end - initrd_start);

			if (written != initrd_end - initrd_start)
				pr_err("/initrd.image: incomplete write (%zd != %ld)\n",
				       written, initrd_end - initrd_start);

			sys_close(fd);
			free_initrd();
		}
	done:
#else
		printk(KERN_INFO "Unpacking initramfs...\n");
		err = unpack_to_rootfs((char *)initrd_start,
			initrd_end - initrd_start);
		if (err)
			printk(KERN_EMERG "Initramfs unpacking failed: %s\n", err);
		free_initrd();
#endif
		/*
		 * Try loading default modules from initramfs.  This gives
		 * us a chance to load before device_initcalls.
		 */
		load_default_modules();
	}
	return 0;
}

下面接着继续分析函数kernel_init_freeable

static noinline void __init kernel_init_freeable(void)
{
	```(忽略)
	do_basic_setup();

	/* Open the /dev/console on the rootfs, this should never fail */
	if (sys_open((const char __user *) "/dev/console", O_RDWR, 0) < 0)
		pr_err("Warning: unable to open an initial console.\n");

	(void) sys_dup(0);
	(void) sys_dup(0);
	/*
	 * check if there is an early userspace init.  If yes, let it do all
	 * the work
	 */

	if (!ramdisk_execute_command)
		ramdisk_execute_command = "/init";

	if (sys_access((const char __user *) ramdisk_execute_command, 0) != 0) {
		ramdisk_execute_command = NULL;
		[1]prepare_namespace();
	}

	/*
	 * Ok, we have completed the initial bootup, and
	 * we're essentially up and running. Get rid of the
	 * initmem segments and start the user-mode stuff..
	 *
	 * rootfs is available now, try loading the public keys
	 * and default modules
	 */

	integrity_load_keys();
	load_default_modules();
}

代码[1]:前面在对函数populate_rootfs进行分析的时候已经知道,对于initramfs和cpio-initrd的情况,都会将文件系统解压到根文件系统(其实就是VFS)。如果文件系统中存在ramdisk_execute_command指定的文件则直接转向init_post()来执行,否则执行函数prepare_namespace()。

根文件系统的挂载

prepare_namespace(/linux/init/do_mounts.c)

/*
 * Prepare the namespace - decide what/where to mount, load ramdisks, etc.
 */
void __init prepare_namespace(void)
{
	int is_floppy;

	[1]if (root_delay) {
		printk(KERN_INFO "Waiting %d sec before mounting root device...\n",
		       root_delay);
		ssleep(root_delay);
	}

	/*
	 * wait for the known devices to complete their probing
	 *
	 * Note: this is a potential source of long boot delays.
	 * For example, it is not atypical to wait 5 seconds here
	 * for the touchpad of a laptop to initialize.
	 */
	[2]wait_for_device_probe();

	md_run_setup();

	[3]if (saved_root_name[0]) {
		root_device_name = saved_root_name;
		if (!strncmp(root_device_name, "mtd", 3) ||
		    !strncmp(root_device_name, "ubi", 3)) {
			[4]mount_block_root(root_device_name, root_mountflags);
			goto out;
		}
		[5]ROOT_DEV = name_to_dev_t(root_device_name);
		if (strncmp(root_device_name, "/dev/", 5) == 0)
			root_device_name += 5;
	}

	[6]if (initrd_load())
		goto out;

	/* wait for any asynchronous scanning to complete */
	[7]if ((ROOT_DEV == 0) && root_wait) {
		printk(KERN_INFO "Waiting for root device %s...\n",
			saved_root_name);
		while (driver_probe_done() != 0 ||
			(ROOT_DEV = name_to_dev_t(saved_root_name)) == 0)
			msleep(100);
		async_synchronize_full();
	}

	is_floppy = MAJOR(ROOT_DEV) == FLOPPY_MAJOR;

	if (is_floppy && rd_doload && rd_load_disk(0))
		ROOT_DEV = Root_RAM0;

	mount_root();
out:
	devtmpfs_mount("dev");
	[8]sys_mount(".", "/", NULL, MS_MOVE, NULL);
	[9]sys_chroot(".");
}

[1]、如果是将根文件系统存放在usb、或scsi设备上的情况,kernel需要等待这些耗费时间比较久的设备驱动加载完毕,所以这里存在一个Delay。
[2]、这里也是等待设备探测完成
[3]、参数saved_root_name存放的是Kernel参数root=所指定的设备文件
[4]、这里相当于将saved_root_nam指定的设备进行加载。如下面传递给内核的command line:
CONFIG_CMDLINE=“console=ttyS0,115200 mem=108M rdinit=/linuxrc root=/dev/mtdblock2”
实际上就是加载/dev/mtdblock2。
[5]、参数ROOT_DEV存放设备节点号。
[6]、挂载initrd,这里进行的操作相当的复杂,可以参照后续关于该函数的详细解释。
[7]、代码[7]:如果指定mount_initrd为true,即没有指定在函数initrd_load中mount的话,则在这里重新realfs的mount操作。
[8]、将挂载点从当前目录(实际当前的目录在mount_root中或者在mount_block_root中指定)移到根目录。对于上面的command line的话,当前的目录就是/dev/mtdblock2。
[9]、将当前目录当作系统的根目录,至此虚拟系统根目录文件系统切换到了实际的根目录文件系统。

initrd_load(/linux/init/do_mounts_initrd.c)

int __init initrd_load(void)
{
	[1]if (mount_initrd) {
		create_dev("/dev/ram", Root_RAM0);
		/*
		 * Load the initrd data into /dev/ram0. Execute it as initrd
		 * unless /dev/ram0 is supposed to be our actual root device,
		 * in that case the ram disk is just set up here, and gets
		 * mounted in the normal path.
		 */
		[2]if (rd_load_image("/initrd.image") && [3]ROOT_DEV != Root_RAM0) {
			sys_unlink("/initrd.image");
			[4]handle_initrd();
			return 1;
		}
	}
	sys_unlink("/initrd.image");
	return 0;
}

代码[1]:可以通过Kernel的参数“noinitrd“来配置mount_initrd的值,默认为1,很少看到有项目区配置该值,所以一般情况下,mount_initrd的值应该为1;

代码[2]:创建一个Root_RAM0的设备节点/dev/ram;

代码[3]:如果根文件设备号不是Root_RAM0则程序就会执行代码[4],换句话说,就是给内核指定的参数不是/dev/ram,例如上面指定的/dev/mtdblock2设备节点肯定就不是Root_RAM0。

另外这行代码还将文件initrd.image释放到节点/dev/ram0,也就是对应image-initrd的操作。

代码[4]:函数handle_initrd主要功能是执行Initrd中的linuxrc文件,并且将realfs的根目录设置为当前目录。其实前面也已经提到了,这些操作只对image-cpio的情况下才会去执行。

真实根文件系统挂载后的操作

static int __ref kernel_init(void *unused)
{
	int ret;

	kernel_init_freeable();
	/* need to finish all async __init code before freeing the memory */
	async_synchronize_full();
	free_initmem();
	mark_rodata_ro();
	system_state = SYSTEM_RUNNING;
	numa_default_policy();

	flush_delayed_fput();

	if (ramdisk_execute_command) {
		ret = run_init_process(ramdisk_execute_command);
		if (!ret)
			return 0;
		pr_err("Failed to execute %s (error %d)\n",
		       ramdisk_execute_command, ret);
	}

	/*
	 * We try each of these until one succeeds.
	 *
	 * The Bourne shell can be used instead of init if we are
	 * trying to recover a really broken machine.
	 */
	if (execute_command) {
		ret = run_init_process(execute_command);
		if (!ret)
			return 0;
		panic("Requested init %s failed (error %d).",
		      execute_command, ret);
	}
	if (!try_to_run_init_process("/sbin/init") ||
	    !try_to_run_init_process("/etc/init") ||
	    !try_to_run_init_process("/bin/init") ||
	    !try_to_run_init_process("/bin/sh"))
		return 0;

	panic("No working init found.  Try passing init= option to kernel. "
	      "See Linux Documentation/init.txt for guidance.");
}

可以看到,在该函数的最后,以此会去搜索文件并执行ramdisk_execute_command、execute_command、/sbin/init、/etc/init、/bin/init和/bin/sh,如果发现这些文件均不存在的话,则通过panic输出错误命令,并将当前的系统Halt在那里。

参考

https://www.ibm.com/developerworks/cn/linux/l-k26initrd/index.html
http://layty.coding.me/2018/09/19/Linux/boot&Kernel/20-%E7%90%86%E8%A7%A3%E6%A0%B9%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/
initrd机制
磁盘分区及其意义
uefi和boot区别
计算机启动流程
grup源码分析

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值