ARMv8架构u 您所在的位置:网站首页 bootloader程序执行过程 ARMv8架构u

ARMv8架构u

2023-04-01 01:25| 来源: 网络整理| 查看: 265

注:本文基于armv8架构来对u-boot进行启动流程分析,u-boot版本为2022-01。

1 概述

首先引用wiki上的简介: u-boot 是一个主要用于嵌入式系统的引导加载程序,可以支持多种不同的计算机系统结构。u-boot最先是由德国DENX软件中心团队开发,后续众多有志于开放源码bootloader移植工作的嵌入式开发人员将各个不同系列嵌入式处理器的移植工作不断展开和深入,以支持了更多的嵌入式操作系统的装载与引导。

选择u-boot的理由:

开放源码; 支持多种嵌入式操作系统内核的引导,如Linux、NetBSD, VxWorks, QNX, RTEMS, ARTOS, LynxOS, android; 支持多个处理器系列,如PowerPC、ARM、x86、MIPS; 较高的可靠性和稳定性; 高度灵活的功能设置,适合U-Boot调试、操作系统不同引导要求、产品发布等; 丰富的设备驱动源码,如串口、以太网、SDRAM、FLASH、LCD、NVRAM、EEPROM、RTC、键盘等; 较为丰富的开发调试文档与强大的网络技术支持; 基于以上理由本篇文章对现在主流的armv8架构的u-boot启动流程进行详细分析,以便所有人快速学习和理解u-boot的工作流程。

2 armv8 u-boot的启动

先看arm官网提供的一张图: 上图详细概括了arm官方推荐的armv8的启动层次结构:

官方将启动分为了BL1,BL2,BL31,BL32,BL33阶段,根据顺序,芯片启动后首先执行BL1阶段代码,接着验签启动BL2,BL2根据具体设计启动BL31或者BL33,BL32只有在有BL31时才可能会存在并被验签加载启动。

armv8分为Secure World和Non-Secure World(Normal World),四种异常级别从高到低分别为EL3,EL2,EL1,EL0。

Secure World就是可以执行可信的firmware和app,比如密码支付,指纹识别等一系列依赖安全保证的服务。 Non-Secure World就是我们常见的u-boot,linux,qnx等裸机程序或者操作系统。

EL3具有最高管理权限,负责安全监测和安全模式切换。 EL2主要提供了对虚拟化的支持。 EL1是一个特权模式,能够执行一些特权指令,用于运行各类操作系统,在安全模式则是可信任OS。 EL0是无特权模式,所有APP应用都在EL0。

上图中的BL1,BL2,BL31,BL32,BL33分别对应如下功能:

BL1:是一切信任的根,一般就是固化在ROM中的一段启动加载代码,用于引导bl2,并对bl2进行验签保证可信任执行; BL2:一般是在flash中的一段可信安全启动代码,它的可信建立在bl1对它的验证,主要完成一些平台相关的初始化,比如对ddr的初始化等,并在完成初始化后寻找BL31或者BL33进行执行;如果找到了BL31则不会继续调用BL33,如果没有BL31则BL33必须有; BL31:BL31不像BL1和BL2是一次性运行的,它作为最后一道可信任固件存在,在系统运行时通过smc指令陷入EL3调用系统安全服务或者在Secure World和Non-Secure World之间进行切换;在完成BL31初始化后会去寻找BL32或者BL33进行验签后加载执行; BL32:OPTee OS + 安全app,它是一个可信安全的OS运行在EL1并在EL0启动可信任APP(上述的指纹验证等app),并在Trust OS运行完成后通过smc指令返回BL31,BL31切换到Non-Seucre World继续执行BL33; BL33:非安全固件,也就是我们常见的UEFI firmware或者u-boot也可能是直接启动Linux kernel; 启动BL1,BL2,BL31,BL32则是一个完整的ATF信任链建立流程(ARM Trusted Firmware),像常见的PSCI(Power State Coordination Interface)功能则是在ATF的BL31上实现;

最后一张图完整展示整个调用流程: BL2根据是否存在BL31和BL32可选择性的加载不同firmware;

综上所述可知u-boot是一个运行在非安全世界的bootloader,负责加载各类操作系统,并提供丰富的驱动接口;并根据是否存在安全固件还可以进行不同的boot流程,如下。

u-boot,u-boot-spl,u-boot-tpl的关系: 对于一般嵌入式而言只需要一个u-boot作为bootloader即可,但是在小内存,或者有atf的情况下还可以有spl,tpl;

spl:Secondary Program Loader,二级加载器 tpl:Tertiary Program Loader,三级加载器 出现spl和tpl的原因最开始是因为系统sram太小,rom无法在ddr未初始化的情况下一次性把所有代码从flash,emmc,usb等搬运到sram中执行,也或者是flash太小,无法完整放下整个u-boot来进行片上执行。所以u-boot又定义了spl和tpl,spl和tpl走u-boot完全相同的boot流程,不过在spl和tpl中大多数驱动和功能被去除了,根据需要只保留一部分spl和tpl需要的功能,通过CONFIG_SPL_BUILD和CONFIG_TPL_BUILD控制;一般只用spl就足够了,spl完成ddr初始化,并完成一些外设驱动初始化,比如usb,emmc,以此从其他外围设备加载u-boot,但是如果对于小系统spl还是太大了,则可以继续加入tpl,tpl只做ddr等的特定初始化保证代码体积极小,以此再次从指定位置加载spl,spl再去加载u-boot。

从目前来看,spl可以取代上图中bl2的位置,或者bl1,根据具体厂商实现来决定,有一些芯片厂商会将spl固化在rom中,使其具有从emmc,usb等设备加载u-boot或者其他固件的能力。

当然在有atf的情况下可以由atf加载u-boot,或者由spl加载atf,atf再去加载u-boot。甚至在快速启动的系统中可以直接由spl启动加载linux等操作系统而跳过启动u-boot;在上图中arm官方只是给出了一个建议的启动信任链,具体实现都需要芯片厂商来决定;

后续分析启动流程中会在具有SPL和TPL的地方拓展它们的分叉执行路径尽量把SPL和TPL的功能也一并分析;

3 u-boot源码整体结构和一些编译配置方式 3.1 编译配置方式

u-boot使用了同Linux一样的编译配置方式,即使用kbuild系统来管理整体代码的配置和编译,通过defconfig来定制各种不同厂商的芯片bootloader二进制程序。 编译只需要注意通过环境变量或者命令行参数的方式引入一个交叉编译工具即可:

CROSS_COMPILE:定义交叉编译工具链,可以是aarch64-linux-gnu-,arm-none-eabi-或者ppc-linux-gnu-等等; u-boot有几个配置是需要由对应board配置的。 SYS_ARCH,SYS_CPU,SYS_SOC,SYS_BOARD,SYS_VENDOR,SYS_CONFIG_NAME; 一般在board/vendor/board/Kconfig中可全部定义,部分SYS_CPU,SYS_SOC也可以在arch/xxx/Kconfig中定义,根据这几个配置即可确定使用的cpu架构,厂商,板级信息,soc信息。Makefile会自动根据上述信息进入对应目录组织编译规则,一般如果没有自己对应的这些board信息,需要自己在对应目录建立这些Kconfig和在configs中建立defconfig。

在configs目录中保存了uboot中所有支持的board配置,比如要使用rk3399的evb板的配置信息使用如下方式即可编译出来:

make CROSS_COMPILE=aarch64-linux-gnu- evb-rk3399_defconfig make 1 2

如果没有对应的defconfig可以找一个与自己板级信息类似的defconfig生成一个.config,再通过menuconfig来完成自己board的配置,并最后通过savedefconfig保存为自己board的defconfig:

make CROSS_COMPILE=aarch64-linux-gnu- evb-rk3399_defconfig make menuconfig make savedefconfig cp defconfig configs/my_defconfig 1 2 3 4 5

下面是evb rk3399的定义:

CONFIG_SYS_ARCH="arm" CONFIG_SYS_CPU="armv8" CONFIG_SYS_SOC="rk3399" CONFIG_SYS_VENDOR="rockchip" CONFIG_SYS_BOARD="evb_rk3399" CONFIG_SYS_CONFIG_NAME="evb_rk3399" 1 2 3 4 5 6

根据CONFIG_SYS_BOARD的定义还会为每个源文件自动包含include/configs/xxxx.h头文件,evb rk3399则是include/configs/evb_rk3399.h头文件。 这个头文件可在其中定义board的一些关键配置,系统的ram大小,环境变量的起始地址和大小,GIC基地址,时钟频率,是否开启看门狗等定义,可根据具体需求来定义。

u-boot使用Kconfig和include/configs/xxx.h来灵活的确定u-boot编译流程及最终生成的文件。比如当定义CONFIG_SYS_CPU为"armv8",CONFIG_SYS_ARCH为"arm"时,即确定了目标架构为armv8会自动根据Makefile进入对应目录进行编译链接。

3.2 u-boot源码结构

这里只对一些常用的目录进行说明:

arch:各种架构的启动初始化流程代码,链接脚本等均在此目录对应的架构中存放; board:包含了大部分厂商的board初始化代码,基本平台化相关的代码都在对应的board目录中,早期的一些board代码在arch/xxx/xxx-mach中,现在基本不会放在arch目录下面了; cmd:包含了大量实用的u-boot命令的实现,比如md,cp,cmp,tftp,fastboot,ext4load等命令的实现,我们也可以在此处添加自己实现的命令; common:包含了u-boot的核心初始化代码,包括board_f,board_r,spl等一系列代码; configs:包含了所有board的配置文件,可直接使用; drivers:大量驱动代码的存放处; dts:编译生成dtb,内嵌dtb到u-boot的编译规则定义目录; env:环境变量功能实现代码; fs:文件系统读写功能的实现,里面包含了各类文件系统的实现; include:所有公用头文件的存放路径; lib:大量通用功能实现,提供给各个模块使用; net:网络相关功能的实现; scripts:编译,配置文件的脚本文件存放处; tools:测试和实用工具的实现,比如mkimage的实现代码在此处;

4 u-boot armv8链接脚本

在进行源码分析之前,首先看看u-boot的链接脚本,通过链接脚本可以从整体了解一个u-boot的组成,并且可以在启动分析中知道某些逻辑是在完成什么工作。在armv8中,u-boot使用arch/arm/cpu/armv8/u-boot.lds进行链接。u-boot-spl和u-boot-tpl使用arch/arm/cpu/armv8/u-boot-spl.lds进行链接,因为每个board的情况可能不同,所以u-boot可以通过Kconfig来自定义u-boot-spl.lds和u-boot-tpl.lds。

4.1 u-boot.lds /* SPDX-License-Identifier: GPL-2.0+ */ /* * (C) Copyright 2013 * David Feng * * (C) Copyright 2002 * Gary Jennejohn, DENX Software Engineering, */ #include #include OUTPUT_FORMAT("elf64-littleaarch64", "elf64-littleaarch64", "elf64-littleaarch64") OUTPUT_ARCH(aarch64) ENTRY(_start) -------------------------------------------------------------------- (1) /* *(1)首先定义了二进制程序的输出格式为"elf64-littleaarch64", * 架构是"aarch64",程序入口为"_start"符号; */ SECTIONS { #ifdef CONFIG_ARMV8_SECURE_BASE -------------------------------------------------- (2) /* *(2)ARMV8_SECURE_BASE是u-boot对PSCI的支持,在定义时可以将PSCI的文本段, * 数据段,堆栈段重定向到指定的内存,而不是内嵌到u-boot中。 * 不过一般厂商实现会使用atf方式使其与bootloader分离,这个功能不常用; */ /DISCARD/ : { *(.rela._secure*) } #endif . = 0x00000000; -------------------------------------------------------------- (3) /* *(3)定义了程序链接的基地址,默认是0,通过配置CONFIG_SYS_TEXT_BASE可修改 * 这个默认值。 */ . = ALIGN(8); .text : { *(.__image_copy_start) --------------------------------------------------- (4) /* *(4)__image_copy_start和__image_copy_end用于定义需要重定向的段, * u-boot是一个分为重定向前初始化和重定向后初始化的bootloader, * 所以此处会定义在完成重定向前初始化后需要搬运到ddr中数据的起始地址和结束地址; * * 大多数时候u-boot是运行在受限的sram或者只读的flash上, * u-boot为了启动流程统一会在ddr未初始化和重定位之前不去访问全局变量, * 但是又为了保证u-boot能够正常读写全局变量,内存,调用各类驱动能力, * 所以u-boot将启动初始化分为了两个部分,重定向前初始化board_f和 * 重定向后初始化 board_r,在重定向之前完成一些必要初始化, * 包括可能的ddr初始化,然后通过__image_copy_start和__image_copy_end * 将u-boot搬运到ddr中,并在ddr中进行重定向后初始化,这个时候的u-boot就可以 * 正常访问全局变量等信息了。 * * 如果想要在board_f过程中读写一些全局变量信息该怎么办呢? * u-boot通过定义global_data(gd)来完成此功能, * 后续在分析到时会详细讲解实现方式。 */ CPUDIR/start.o (.text*) -------------------------------------------------- (5) /* *(5)定义了链接程序的头部文本段,armv8就是 * arch/arm/cpu/armv8/start.S, * start.S中所有文本段将会链接到此段中并且段入口符号就是_start; */ } /* This needs to come before *(.text*) */ .efi_runtime : { ------------------------------------------------------------ (6) /* *(6)在定义了efi运行时相关支持时才会出现使用的段,一般不用关心; */ __efi_runtime_start = .; *(.text.efi_runtime*) *(.rodata.efi_runtime*) *(.data.efi_runtime*) __efi_runtime_stop = .; } .text_rest : ---------------------------------------------------------------- (7) /* *(7)除了start.o,其他的所有文本段将会链接到此段中; */ { *(.text*) } #ifdef CONFIG_ARMV8_PSCI -------------------------------------------------------- (8) /* *(8)同(2),是PSCI相关功能的支持,一般不会使用; */ .__secure_start : #ifndef CONFIG_ARMV8_SECURE_BASE ALIGN(CONSTANT(COMMONPAGESIZE)) #endif { KEEP(*(.__secure_start)) } #ifndef CONFIG_ARMV8_SECURE_BASE #define CONFIG_ARMV8_SECURE_BASE #define __ARMV8_PSCI_STACK_IN_RAM #endif .secure_text CONFIG_ARMV8_SECURE_BASE : AT(ADDR(.__secure_start) + SIZEOF(.__secure_start)) { *(._secure.text) . = ALIGN(8); __secure_svc_tbl_start = .; KEEP(*(._secure_svc_tbl_entries)) __secure_svc_tbl_end = .; } .secure_data : AT(LOADADDR(.secure_text) + SIZEOF(.secure_text)) { *(._secure.data) } .secure_stack ALIGN(ADDR(.secure_data) + SIZEOF(.secure_data), CONSTANT(COMMONPAGESIZE)) (NOLOAD) : #ifdef __ARMV8_PSCI_STACK_IN_RAM AT(ADDR(.secure_stack)) #else AT(LOADADDR(.secure_data) + SIZEOF(.secure_data)) #endif { KEEP(*(.__secure_stack_start)) . = . + CONFIG_ARMV8_PSCI_NR_CPUS * ARM_PSCI_STACK_SIZE; . = ALIGN(CONSTANT(COMMONPAGESIZE)); KEEP(*(.__secure_stack_end)) } #ifndef __ARMV8_PSCI_STACK_IN_RAM . = LOADADDR(.secure_stack); #endif .__secure_end : AT(ADDR(.__secure_end)) { KEEP(*(.__secure_end)) LONG(0x1d1071c); /* Must output something to reset LMA */ } #endif . = ALIGN(8); .rodata : { *(SORT_BY_ALIGNMENT(SORT_BY_NAME(.rodata*))) } ------------------- (9) /* *(9)所有仅读数据将会在这个段中对齐排序存放好; */ . = ALIGN(8); .data : { -------------------------------------------------------------------- (10) /* *(10)所有数据段将会链接到此段中; */ *(.data*) } . = ALIGN(8); . = .; . = ALIGN(8); .u_boot_list : { ------------------------------------------------------------- (11) /* *(11)u_boot_list段定义了系统中当前支持的所有命令和设备驱动,此段把散落在各个文件中 * 通过U_BOOT_CMD的一系列拓展宏定义的命令和U_BOOT_DRIVER的拓展宏定义的设备驱动收集到一起, * 并按照名字排序存放,以便后续在命令行快速检索到命令并执行和检测注册的设备和设备树匹配 * probe设备驱动初始化;(设备驱动的probe只在定义了dm模块化驱动时有效) */ KEEP(*(SORT(.u_boot_list*))); } . = ALIGN(8); .efi_runtime_rel : { __efi_runtime_rel_start = .; *(.rel*.efi_runtime) *(.rel*.efi_runtime.*) __efi_runtime_rel_stop = .; } . = ALIGN(8); .image_copy_end : { *(.__image_copy_end) } . = ALIGN(8); .rel_dyn_start : -------------------------------------------------------- (12) /* *(12)一般u-boot运行时是根据定义的基地址开始执行,如果加载地址和链接地址 * 不一致则会出现不能执行u-boot的问题。通过一个 * 配置CONFIG_POSITION_INDEPENDENT即可打开地址无关功能, * 此选项会在链接u-boot时添加-PIE参数。此参数会在u-boot ELF文件中 * 生成rela*段,u-boot通过读取此段中表的相对地址值与实际运行时地址值 * 依次遍历进行修复当前所有需要重定向地址,使其可以实现地址无关运行; * 即无论链接基地址如何定义,u-boot也可以在任意ram地址 * 运行(一般需要满足最低4K或者64K地址对齐); * * 注意此功能只能在sram上实现,因为此功能会在运行时修改文本段数据段中的地址, * 如果此时运行在片上flash,则不能写flash,导致功能失效无法实现地址无关; */ { *(.__rel_dyn_start) } .rela.dyn : { *(.rela*) } .rel_dyn_end : { *(.__rel_dyn_end) } _end = .; . = ALIGN(8); .bss_start : { -------------------------------------------------------- (13) /* *(13)众所周知的bbs段; */ KEEP(*(.__bss_start)); } .bss : { *(.bss*) . = ALIGN(8); } .bss_end : { KEEP(*(.__bss_end)); } /DISCARD/ : { *(.dynsym) } -------------------------------------------- (14) /* *(14)一些在链接时无用需要丢弃的段; */ /DISCARD/ : { *(.dynstr*) } /DISCARD/ : { *(.dynamic*) } /DISCARD/ : { *(.plt*) } /DISCARD/ : { *(.interp*) } /DISCARD/ : { *(.gnu*) } #ifdef CONFIG_LINUX_KERNEL_IMAGE_HEADER ----------------------------------- (15) /* *(15)在efi加载时会很有用,主要在u-boot的二进制头部添加了一些头部信息, * 包括大小端,数据段文本段大小等,以便于efi相关的加载器读取信息, * 此头部信息来自于Linux arm64的Image的头部信息;该头部也不属于u-boot的 * 一部分只是被附加上去的; */ #include "linux-kernel-image-header-vars.h" #endif } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 4.2 u-boot-spl.lds

此链接脚本是标准的spl链接脚本,还包含了u_boot_list段,如果对应自己board不需要命令行或者模块化驱动设备,只作为一个加载器则可以自定义更简略的链接脚本。

/* SPDX-License-Identifier: GPL-2.0+ */ /* * (C) Copyright 2013 * David Feng * * (C) Copyright 2002 * Gary Jennejohn, DENX Software Engineering, * * (C) Copyright 2010 * Texas Instruments, * Aneesh V */ MEMORY { .sram : ORIGIN = IMAGE_TEXT_BASE, ---------------------------------------- (1) /* *(1)\>XXX 的形式可以将指定段放入XXX规定的内存中;一般u-boot-spl只有 * 很小的可运行内存块,所以spl中会舍去大量不需要用的段只保留关键的 * 文本段数据段等,并且通过>.sram的形式将不在ddr初始化前用到的段定义到sdram中, * 后续只需在完成ddr初始化后将这些段搬运到ddr中即可,而不需要额外的 * 地址修复逻辑,如下:有一个sram 0x18000-0x19000, * 一个sdram 0x80000000 - 0x90000000, * 那么通过>.sram方式则map文件可能如下: * 0x18000 stext * ... * 0x18100 sdata * ... * 0x80000000 sbss * ... */ LENGTH = IMAGE_MAX_SIZE } MEMORY { .sdram : ORIGIN = CONFIG_SPL_BSS_START_ADDR, LENGTH = CONFIG_SPL_BSS_MAX_SIZE } OUTPUT_FORMAT("elf64-littleaarch64", "elf64-littleaarch64", "elf64-littleaarch64") OUTPUT_ARCH(aarch64) ENTRY(_start) -------------------------------------------------------------------- (2) /* *(2)同u-boot.lds一致,共用一套逻辑入口_start; */ SECTIONS { .text : { . = ALIGN(8); *(.__image_copy_start) -------------------------------------------------- (3) /* *(3)同样的,如果spl需要重定向则会使用此段定义,大多数情况下spl中会用上重定向; */ CPUDIR/start.o (.text*) *(.text*) } >.sram .rodata : { . = ALIGN(8); *(SORT_BY_ALIGNMENT(SORT_BY_NAME(.rodata*))) } >.sram .data : { . = ALIGN(8); *(.data*) } >.sram #ifdef CONFIG_SPL_RECOVER_DATA_SECTION ---------------------------------------- (4) /* *(4)SPL_RECOVER_DATA_SECTION段用于保存数据段数据, * 一些board在初始化时修改data段数据,并在后续某个阶段 * 从此段中恢复data的原始数据; */ .data_save : { *(.__data_save_start) . = SIZEOF(.data); *(.__data_save_end) } >.sram #endif .u_boot_list : { . = ALIGN(8); KEEP(*(SORT(.u_boot_list*))); } >.sram .image_copy_end : { . = ALIGN(8); *(.__image_copy_end) } >.sram .end : { . = ALIGN(8); *(.__end) } >.sram _image_binary_end = .; .bss_start (NOLOAD) : { . = ALIGN(8); KEEP(*(.__bss_start)); } >.sdram -------------------------------------------------------------- (5) /* *(5)将bss段数据定义到>.sdram中,即可在初始化ddr后直接对此段地址清零 * 即可使用全局未初始化变量,并且不会带来副作用。 */ .bss (NOLOAD) : { *(.bss*) . = ALIGN(8); } >.sdram .bss_end (NOLOAD) : { KEEP(*(.__bss_end)); } >.sdram /DISCARD/ : { *(.rela*) } /DISCARD/ : { *(.dynsym) } /DISCARD/ : { *(.dynstr*) } /DISCARD/ : { *(.dynamic*) } /DISCARD/ : { *(.plt*) } /DISCARD/ : { *(.interp*) } /DISCARD/ : { *(.gnu*) } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118

从上述的链接脚本可以看出,armv8的u-boot的启动是从arch/arm/cpu/armv8/start.S中的_start开始的,并在后续初始化中调用了很多链接脚本中定义的地址符号表。

文章来源: blog.csdn.net,作者:嵌入式与Linux那些事,版权归原作者所有,如需转载,请联系作者。

原文链接:blog.csdn.net/qq_16933601/article/details/125586858



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有