Android系统启动流程 - 2

第一部分:Bootloader启动

一、             Bootloader的定义和种类

简单地说,BootLoader是在操作系统运行之前运行的一段程序,它可以将系统的软硬件

环境带到一个合适状态,为运行操作系统做好准备。这样描述是比较抽象的,但是它的任务确实不多,终极目标就是把OS拉起来运行。

 

在嵌入式系统世界里存在各种各样的Bootloader,种类划分也有多种方式。除了按照处

理器体系结构不同划分以外,还有功能复杂程度的不同。

先区分一下Bootloader和Monitor[l1] : 严格来说,Bootloader只是引导OS运行起来的代

码;而Monitor另外还提供了很多的命令行接口,可以进行调试、读写内存、烧写Flash、配置环境变量等。在开发过程中Monitor提供了很好地调试功能,不过在开发结束之后,可以完全将其设置成一个Bootloader。所以习惯上将其叫做Bootloader。

 

Bootloader

Monitor?

描述

X86

ARM

PowerPC

U-boot

通用引导程序

RedBoot[l2] 

基于eCos的引导程序

BLOB[l3] 

(StrongARM构架)LART(主板)等硬件平台的引导程序

LILO

Linux磁盘引导程序

GRUB

GNU的LILO替代程序

Loadlin

从DOS引导Linux

Vivi

韩国mizi 公司开发的bootloader

       更多bootloader还有:ROLO、Etherboot、ARMboot 、LinuxBIOS等。

      

       对于每种体系结构,都有一系列开放源码Bootloader可以选用:

       X86:X86的工作站和服务器上一般使用LILO和GRUB。

       ARM:最早有为ARM720处理器开发板所做的固件,又有了armboot,StrongARM平

台的blob,还有S3C2410处理器开发板上的vivi等。现在armboot已经并入了U-Boot,所以U-Boot也支持ARM/XSCALE平台。U-Boot已经成为ARM平台事实上的标准Bootloader。

       PowerPC:最早使用于ppcboot,不过现在大多数直接使用U-boot。

       MIPS:最早都是MIPS开发商自己写的bootloader,不过现在U-boot也支持MIPS架构。

       M68K:Redboot能够支持m68k系列的系统。

      

二、             Arm特定平台的bootloader

到目前为止,我们公司已经做过多个Arm平台的android方案,包括:marvell(pxa935)、

informax(im9815)、mediatek(mt6516/6517)、broadcom(bcm2157)。由于不同处理器芯片厂商对arm core的封装差异比较大,所以不同的arm处理器,对于上电引导都是由特定处理器芯片厂商自己开发的程序,这个上电引导程序通常比较简单,会初始化硬件,提供下载模式等,然后才会加载通常的bootloader。

下面是几个arm平台的bootloader方案:

marvell(pxa935) :                bootROM + OBM [l4] + BLOB

informax(im9815) :             bootROM + barbox + U-boot

mediatek(mt6516/6517) :     bootROM + pre-loader[l5]  + U-boot

broadcom(bcm2157) :          bootROM + boot1/boot2 + U-boot

 

为了明确U-boot之前的两个loader的作用,下面以broadcom平台为例,看下在上电之

后到U-boot的流程,如图1.2.1:

                                          图1.2.1 broadcom平台上电流程

 

 

三、             U-boot启动流程分析

最常用的bootloader还是U-boot,可以引导多种操作系统,支持多种架构的CPU。它支持的操作系统有:Linux、NetBSD、VxWorks、QNX、RTEMS、ARTOS、LynxOS等,支持的CPU架构有:ARM、PowerPC、MISP、X86、NIOS、Xscale等。

 

手机系统不像其他的嵌入式系统,它还需要在启动的过程中关心CP的启动,这个时候就涉及到CP的image和唤醒时刻,而一般的嵌入式系统的uboot只负责引导OS内核。所以这里我们也暂不关心CP的启动,而主要关心AP侧。

从上面第二小节中可以看出,bootloader通常都包含有处理器厂商开发的上电引导程序,不过也不是所有的处理都是这样,比如三星的S3C24X0系列,它的bootROM直接跳到U-boot中执行,首先由bootROM将U-boot的前4KB拷贝到处理器ISRAM,接着在U-boot的前4KB中必须保证要完成的两项主要工作:初始化DDR,nand和nand控制器,接着将U-boot剩余的code拷贝到SDRAM中,然后跳到SDRAM的对应地址上去继续跑U-boot。

所以U-boot[l6] 的启动过程,大致上可以分成两个阶段:第一阶段,汇编代码;第二阶段,c代码。

      

3.1 第一阶段

       U-boot的第一条指令从cpu/arm920t/start.S文件开始,第一阶段主要做了如下事情:

      

       1. 设置CPU进入SVC模式(系统管理模式),cpsr[4:0]=0xd3。

       2. 关中断,INTMSK=0xFFFFFFFF, INTSUBMSK=0x3FF。

       3. 关看门狗,WTCON=0x0。

4. 调用s3c2410_cache_flush_all函数,使TLBS,I、D Cache,WB中数据失效。

5. 时钟设置CLKDIVN=0x3 , FCLK:HCLK:PCLK = 1:2:4。

6. 读取mp15的c1寄存器,将最高两位改成11,表示选择了异步时钟模型。

7. 检查系统的复位状态,以确定是不是从睡眠唤醒。

8.  ldr r0,_TEXT_BASE

    adr r1,_start

    cmp r0,r1

    blne cpu_init_crit

    根据这几条语句来判断系统是从nand启动的还是直接将程序下载到SDRAM中运行

的,这里涉及到运行时域[l7] 和位置无关代码的概念,ldr r0,_TEXT_BASE的作用是将board/nextdvr2410/config.mk文件中定义的TEXT_BASE值(0x33f80000)装载到r0中,adr r1,_start该指令是条伪指令,在编译的时候会被转换成ADD或SUB指令根据当前pc值计算出_start标号的地址,这样的话就可以知道当前程序在什么地址运行(位置无关代码:做成程序的所有指令都是相对寻址的指令,包括跳转指令等,这样代码就可以不在链接所指定的地址上运行)。在上电之后,系统从nand启动,这里得到r0和r1值是不一样的,r0=0x33f80000,而r1=0x00000000。所以接下来会执行cpu_init_crit函数。

 

9. cpu_init_crit函数,主要完成了两个工作:首先使ICache and Dcache,TLBs中早期内容失效,再设置p15 control register c1,关闭MMU,Dcache,但是打开了Icache和Fault checking,(要求mmu和Dcache是必须要关闭的,而Icache可以打开可以关闭);其次调用/board/nextdvr2410/memsetup.S文件中的memsetup函数来建立对SDRAM的访问时序。

 

10. Relocate函数,加载nand flash中的uboot到SDRAM中,代码会加载到0x33f80000开始的地址,空间大小是512。

1). ndf2ram函数

a.  设置NFCONF,使能2410的nand 控制器,初始化ECC,disable chip等

b.  enable chip,复位chip,读nand状态,判断是否busy,空闲的话再次disable chip;

c.  为调用c函数准备堆栈空间,这里的堆栈是放在uboot代码在SDRAM空间的最后位置armboot_end开始的128KB地址处(包含3 words for abort-stack,实际的SP位置是128*1024-12B处)。

d.  调用c函数copy_uboot_to_ram():nandll_reset() 设置NFCONF(新增设置了时间参数,其余设置和前面一样),复位nand flash;nandll_read_blocks(),传递了3个参数给它,0x33f80000,0x0, 9*NAND_BLOCK_SIZE.这里在读的过程中检查每个块的坏块标志,如果是坏块,则跳过不读。详情不叙,请看uboot的注释。该部分的c代码在cpu/arm920t/Nand_cp.c文件中

e.  ok_nand_read函数:读取SDRAM的前4k内容和SRAM的4K内容进行比较,只要出现不一样的地方就会进入死循环状态,目的就是为了确保转移代码的正确性。

f.  跳回到调用ndf2ram函数处继续执行

2). ldr pc, _start_armboot

    _start_armboot: .word start_armboot

    这里将会进入第二阶段的c代码部分:start_armboot()函数,/lib_arm/board.c。

 

3.2 第二阶段

    第二阶段从文件/lib_arm/board.c的start_armboot()函数开始。

   

1.     定义一个struct global_data结构体指针gd,struct global_data结构体对象

gd_data,定义一个struct bd_info结构体对象bd_data,定义一个指向函数的二级指针init_fnc_ptr,定义的全局结构体对象都是放在堆栈中的,gd是放在寄存器中的。

2.     gd=&gd_data,gd->bd = &bd_data,并且全部空间清0。

3.     init_fnc_ptr = init_sequence(一个初始化函数指针数组)。将会在接下来的for循环中提取出每一个函数来依次执行完。

init_fnc_t *init_sequence[] = {

    cpu_init,       /* 基本的处理器相关配置 -- cpu/arm920t/cpu.c */

    board_init,        

/* 基本的板级相关配置 -- board/nextdvr2410/nextdvr2410.c */

    interrupt_init,/* 初始化中断处理 -- cpu/arm920t/interrupt.c */

    env_init,       /* 初始化环境变量 -- common/env_flash.c */

    init_baudrate,  /* 初始化波特率设置 -- lib_arm/board.c */

    serial_init,    /* 串口通讯设置 -- cpu/arm920t/serial.c */

    console_init_f,/* 控制台初始化阶段1 -- common/console.c */

    display_banner,/* 打印u-boot信息 -- lib_arm/board.c */

    dram_init,  /* 配置可用的RAM -- board/nextdvr2410/nextdvr2410.c */

    display_dram_config,/* 显示RAM的配置大小 -- lib_arm/board.c */

#if defined(CONFIG_VCMA9)

         checkboard,     /* display board info */

#endif

    NULL,

};

cpu_init:根据需要设定IRQ,FIR堆栈。如果使用中断的话,中断堆栈就接在后面。

 

board_init:设置LOCKTIME,配置MPLL,UPLL,配置IO ports,设置gd->bd->bi_arch_number(553),gd->bd->bi_boot_params = 0x30000100设置boot参数地址,使能Icache和Dcache。

 

interrupt_init:使用timer 4来作为系统clock, 即时钟滴答, 10ms一次,到点就产生一个中断,但由于此时中断还没打开所以这个中断不会响应。

 

env_init:该函数主要做关于环境变量的工作,这个环境变量可以不用存放在nor或者nand flash上,直接在内存中生成(default_environment)。不过对于那些掉电需要保存的参数来说,保存在flash上无疑是最可靠的方式。有的uboot还支持冗余存储,也就是存两份做备份。

        在env初始化的时候,是通过env_init—>nandll_read_blocks将位于nand第9

块上的环境变量(16K)全部读入到0x33ef0000这个起始地址中来,在接下来将堆空间分配好之后,在函数env_relocate中,通过在堆中获得一块区域来存放环境变量,env_ptr指向这块区域,接下来所谓的重新获得环境变量无非就是将原来0x33ef0000开始的16K数据拷贝到env_ptr所指的区域中去。这里分第一次uboot启动(泛指只要在第一次运行saveenv指令之前所启动的uboot过程)和保存过环境变量的情况,但实质是一样的,所不同的是,第一次uboot启动,nand第9块区域中的数据肯定不是什么环境变量,所以这是的crc校验肯定出错,所以这时系统使用了默认的环境变量,但是只要这个默认的环境变量没有写到nand中(运行saveenv)的话,uboot的每次启动都被认为是第一次启动。而保存过环境变量之后的话,在执行env_init的时候,就是从nand中读出了实际存在的环境变量参数,至于修不修改环境变量,保不保存,都没有上面的那种情况出现了。

 

    init_baudrate:第一次启动uboot的时候,采用nextdvr2410nand.h中定义的115200默认波特率,后面的启动如果说在参数里设置了新的波特率的话就会用新的波特率来初始化。

   

    display_banner:打印uboot的一些信息,版本信息:NC-Boot 1.5 日期-时间 ,coed范围,bss开始地址,IRQ、FIR堆栈地址。

 

    dram_init: gd->bd->bi_dram[0].start = PHYS_SDRAM_1;

gd->bd->bi_dram[0].size  = PHYS_SDRAM_1_SIZE;设置板级数据中

的SDRAM开始地址和大小

    display_dram_config:打印SDRAM的配置信息,如下:

                    …

RAM Configuration:

Bank#0: 30000000 64 MB

                    Checkboard: NULL

 

4.     配置可用的flash空间,并且打印出相关信息,flash_init()和display_flash_config()。

 

5.     mem_malloc_init()函数,分配堆空间

    CFG_MALLOC_LEN = 16K(CFG_ENV_SIZE)+128K

    mem_malloc_start = _armboot_start(0x33f80000)- CFG_MALLOC_LEN

    mem_malloc_end = _armboot_start(0x33f80000)

 

6.     env_relocate该函数的作用是将0x33ef0000开始16K的环境参数拷贝到堆空间中去。

 

7.    gd->bd->bi_ip_addr = getenv_IPaddr ("ipaddr")通过这中方式获得环境变量列表中的ipaddr参数(开发板ip),获得环境变量中的MAC地址,设置到gd->bd->bi_enetaddr[reg]中。

8.     devices_init函数,创建了devlist,但是只有一个串口设备注册在内。

9.     console_init_r函数:控制台完全初始化,此后可以使用函数serial_getc和serial_putc或者putc和getc来输出log。

 

10.  使能中断,如果有网卡设备,设置网卡MAC和IP地址。

 

11.  main_loop ();定义于common/main.c。到此所有的初始化工作已经完成,main_loop在标准输入设备中接受命令,然后分析,查找和执行。

去掉所有无关紧要的宏和代码,main_loop()函数如下:

void main_loop()

{

    static char lastcommand[CFG_CBSIZE] = { 0, };

    int len;

    int rc = 1;

    int flag;

    char *s;

int bootdelay;

 

    s = getenv ("bootdelay");   //自动启动内核等待延时

bootdelay =

s ? (int)simple_strtol(s, NULL, 10) : CONFIG_BOOTDELAY;

    

    s = getenv ("bootcmd");  //取得环境中设置的启动命令行

   

    if (bootdelay >= 0 && s && !abortboot (bootdelay)){

     run_command (s, 0);

//执行启动命令行,smdk2410.h中没有定义CONFIG_BOOTCOMMAND,所以没有命令执行。

    }

   

    for (;;) {

    len = readline(CFG_PROMPT);

//读取键入的命令行到console_buffer

        

         flag = 0;       /* assume no special flags for now */

         if (len > 0)

             strcpy (lastcommand, console_buffer);

//拷贝命令行到lastcommand.

         else if (len == 0)

             flag |= CMD_FLAG_REPEAT;

             if (len == -1)

             puts ("\n");

         else

             rc = run_command (lastcommand, flag); //执行这个命令行。

  

     if (rc <= 0) {

      /* invalid command or not repeatable, forget it */

      lastcommand[0] = 0;

    }

}

12.  在上面的main_loop函数中,通常在开发完成的阶段都会设置一个bootcmd的环境

变量,然后将延时bootdelay设置成0,这样当u-boot跑到这里的时候就不会因为用户按下了任意键就进入了命令行模式,可以直接运行bootcmd的命令来直接加载kernel的Image然后移交控制权。如果进入了命令行模式,我们也可以手动输入命令来启动系统,输入的命令也是基本和bootcmd一样。

 

不过值得一提的是,从这里开始到引导内核的函数do_bootimg_linux()之前,不同

厂商之间做的都和原始的U-boot代码差别挺大,不过万变不离其宗,都是加载各种各样的Image到SDRAM中,不过关于CP部分的Image有的厂商是在这里加载,有的是kernel起来后来有kernel来加载,不过都需要加载的Image就是linux kernel的Image。为了方便,只讨论加载kernel Image的情况。

   

    在继续往下之前,有必要提一下几种不同格式linux kernel编译之后所产生的镜像文件,包括其各种头和ramdisk的混合,容易让人迷糊。

    ramdisk是linux内核启动过程中需要使用的一种临时文件系统,它要么单独编译成ramdisk.img(也有叫initrd或者initramfs),要么编译进内核。

    Linux编译之后最终会产生zImage文件,不过呢,为了迎合U-boot的要求,所以也有专门为U-boot的引导做一个uImage,这个只是加了一个U-boot中定义的一个head而已,用于U-boot中检查,当然前面的ramdisk.img也是需要加这个头的,头里面有这个Image的魔数,大小,类型等信息。现在的android中的u-boot也有要求加头的,他对U-boot进行了改进和优化,做成了自己的一套检查机制,所以现在android编译出来linux部分的Image的名字叫boot.img。

    这个boot.img是zImage和ramdisk.img合成之后的,而且还加了专门的头,这个head和U-boot原始的不一样,具体的源码路径可以参考:system/core/mkbootimg/。

/*

** +-----------------+

** | boot header     | 1 page

** +-----------------+

** | kernel          | n pages 

** +-----------------+

** | ramdisk         | m pages 

** +-----------------+

** | second stage    | o pages

** +-----------------+

**

** n = (kernel_size + page_size - 1) / page_size

** m = (ramdisk_size + page_size - 1) / page_size

** o = (second_size + page_size - 1) / page_size

*/

Android就没有在ramdisk和zImage上单独重复加头了,不过近期做的mtk的平台,他们有点怪,除了上面的额外信息之外,还在这二者上单独加了标志字符串,ROOTFS和KERNEL。

   

    了解了上面这些内容之后,对于从nand上加载uImage或者boot.img,都需要经过分离head进行检查,ok之后才会真正地将数据导入SDRAM。另外别忘了的是,如果ramdisk.img是单独的,那么在加载linux kernel的镜像的时候也需要将其加载进SDRAM,如果是编译到内核了,那就不用了。

   

    通常我们的uboot起来之后,我们会运行下面的命令之一来启动内核

tftp 0x30800000 uImage;bootm (地址可选)

或者

nand read 0x30800000 0x40000 0x200000 ; bootm

   

    例如informax的平台u-boot的bootcmd是:

    #define BOOTCMD

"mcu_clk 260;a7vector_SDRAM;dsp_clk 130;nand read 0x46000000 0x200000 0x400000;boot_from_flash boot"

很明显,原始U-boot中没有boot_from_flash命令,是经过他们改造过的。不过功能基本一样。所以还是以bootm来引导uImage为例来讨论。

 

        bootm命令位于cmd_bootm.c文件中:

        U_BOOT_CMD(

            bootm,  CFG_MAXARGS,    1,  do_bootm,

            "bootm   - boot application image from memory\n",

            "[addr [arg ...]]\n    - boot application image stored in memory\n"

            "        passing arguments 'arg ...'; when booting a Linux kernel,\n"

            "        'arg' can be the address of an initrd image\n"

);

在将nand上0x40000开始的2MB数据拷贝到SDRAM的0x30800000之后,就开始执行bootm命令,其所做的工作大致如下:

12.1如果bootm命令没有带地址参数,将会采用默认地址0x30800000,带地址则保存下这个参数地址。

12.2 从SDRAM的0x30800000开始拷贝64字节到一个dead结构体中进行crc32校验,校验ok之后将会调用调用函数print_image_hdr()打印出如下信息:

Image Name:   Linux-2.6.8-rc2-nc-v1

Created:      2010-05-04   4:14:19 UTC

Image Type:   ARM Linux Kernel Image (uncompressed)

Data Size:    1054712 Bytes =  1 MB

Load Address: 30008000

Entry Point:  30008000

 

12.3 跳过64字节的head,开始校验kernel的Image数据,校验码ok之后会打印:Verifying Checksum ... OK

12.4核对cpu类型

   

12.5 检查Image的类型

   

12.6 禁止中断,检查内核的压缩类型,这里不是指的image和zImage的区别,而是有没有在这基础上进行ZIP或ZIP2的压缩。通常这里是没有这样的压缩的。所以接下来将0x30800000+64B开始的zImage数据搬运到ih_load(0x30008000)处,这个数据就是kernel的Image数据。

 

12.7 根据head中OS的类型,如果是linux,head中类型值就是IH_OS_LINUX,所以接下来会执行u-boot到kernel的过渡程序。

do_bootm_linux (cmdtp, flag, argc, argv, addr, len_ptr, verify);

 

12.8定义thekernel函数指针,获取bootargs参数给commandline指针。

12.9 theKernel = (void (*)(int, int, uint))ntohl(hdr->ih_ep),将内核的入口地址赋给thekernel函数指针。

12.10将传递给内核的参数放在0x30000100处,以tag的方式存放,主要放置了memery和cmdline的参数。

12.11关中断,关闭IDCache,同时使ID Cache数据失效。

12.12再次获取bi_arch_number参数为553。

12.13 theKernel (0, bd->bi_arch_number, bd->bi_boot_params)进入内核,第一个参数必须为0,第二个参数为机器类型553,第三个参数为传递给内核参数的其实地址0x30000100。

 

总结下,U-Boot调用内核之前,下面的条件必须满足:

a.  R0=0,R1为机器类型ID,参考linux/arch/arm/tools/mach-types,R2为启动参数tag列表在RAM中的基地址。

b.  CPU的工作模式必须为SVC模式,必须禁止中断(IRQS和FIRS)。

c.  数据cache和MMU必须关闭,指令cache可以打开也可以关闭。

 

这里移交控制权之后,u-boot的使命就算是完成了。说起来U-boot命运挺悲惨的,因为它重要而却最不受内核待见。接下来内核的启动更加复杂。

 

 

 [l1]这个词经常在老外的相关博客或者bootloader代码中出现。

 [l2]Redboot支持的处理器构架有ARM,MIPS,MN10300,PowerPC,v850,x86等。

 [l3]Blob提供两种工作模式:启动加载模式和下载模式,会在进入加载模式之前等待数秒,如果用户有任意键按下,那么blob将进入下载模式,否则继续启动引导linux。

 [l4]OEM Boot Module(OBM),bootROM执行完cpu特定初始化后,拷贝部分OBM到ISRAM内将控制权转给OBM,OBM的前面部分主要完成DDR和EMPI的初始化,之后将自己整个拷贝进DDR,然后转到DDR来执行,接着拷贝AP和CP的Image到DDR,此后OBM复位CP,CP和AP开始同时启动,AP侧OBM执行完后将控制权交给BLOB。

 [l5]在ISRAM内执行,barbox也是。

[l6]注:下面的分析基于s3c2410,arm920t的arm core为例。

 [l7]Image的运行空间


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Android系统启动流程可以分为以下几个步骤: 1. 启动电源以及系统启动:当电源按下时,引导芯片代码开始执行,加载引导程序Bootloader到RAM,并执行该程序。 2. 引导程序Bootloader:引导程序是一个小程序,其主要作用是将系统OS启动起来并运行。 3. Linux内核启动:内核启动时,进行系统设置,包括设置缓存、被保护存储器、计划列表,并加载驱动。内核会在系统文件中寻找"init"文件,并启动init进程或系统的第一个进程。 4. init进程启动:init进程是Android系统的第一个用户空间进程,它负责启动和管理其他系统进程。init进程会读取init.rc文件,根据其中的配置启动系统服务和应用进程。 5. Zygote进程和SystemServer进程启动:Zygote进程是一个特殊的进程,它作为应用进程的模板,用于快速创建新的应用进程。SystemServer进程是Android系统的核心服务进程,负责启动和管理各种系统服务。 6. 应用层进程启动:在Android系统中,应用层进程包括Launcher进程,即主屏幕的进程,以及其他应用程序的进程。这些进程会根据用户的操作和应用的需求来启动和管理。 总结起来,Android系统启动流程包括引导程序Bootloader的加载和执行、Linux内核的启动、init进程的启动、Zygote进程和SystemServer进程的启动,以及应用层进程的启动。这些步骤共同完成了Android系统的初始化和启动。\[1\]\[2\]\[3\] #### 引用[.reference_title] - *1* [Android系统启动流程(一)解析init进程启动过程](https://blog.csdn.net/itachi85/article/details/54783506)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control_2,239^v3^insert_chatgpt"}} ] [.reference_item] - *2* [Android系统启动流程](https://blog.csdn.net/xhmj12/article/details/128149490)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control_2,239^v3^insert_chatgpt"}} ] [.reference_item] - *3* [Android 面试必备 - 系统、App、Activity 启动过程“一锅端”](https://blog.csdn.net/zzz777qqq/article/details/115698795)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control_2,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值