对Mastering_the_FreeRTOS_Real_Time_Kernel-A_Hands-On_Tutorial_Guide 官方文档做阅读笔记
官方网站路径: Free RTOS Book and Reference Manual

有不对的地方欢迎指正

目录

  • 第二章
    • 2.1章节简介及预览
      • 动态内存分配及其与FreeRTOS的相关性
      • 动态内存分配选项
    • 2.2示例内存分配方案
      • Heap_1
      • Heap_2
      • Heap_3
      • Heap_4
      • 为Heap_4使用的数组设置起始地址
      • Heap_5
      • vPortDefineHeapRegions() API
        • vPortDefineHeapRegions()的成员
    • 堆相关实用程序函数
      • xPortGetFreeHeapSize()
      • xPortGetMinimumEverFreeHeapSize()
      • Malloc Failed Hook Functions

第二章

2.1章节简介及预览

 FreeRTOS是由一组C源文件提供的,因此成为一名称职的C程序员是使用FreeRTOS的先决条件,因此本章假定读者熟悉以下概念:

1.C项目是如何构建的,包括不同的编译和链接阶段。
2.什么是栈和堆。
3.标准C库malloc()和free()函数。

动态内存分配及其与FreeRTOS的相关性

 FreeRTOS V9.0.0内核中的对象可以在编译时静态分配,也可以在运行时动态分配:

 本书的以下章节将介绍内核对象,例如任务、队列、信号量和事件组。为了使FreeRTOS尽可能易于使用,这些内核对象不是在编译时静态分配的,而是在运行时动态分配的;FreeRTOS在创建内核对象时分配RAM,并在删除内核对象时释放RAM。此策略减少了设计和规划工作,简化了API,并最大限度地减少RAM占用空间。

 本章讨论动态存储分配。动态内存分配是一个C编程概念,而不是特定于FreeRTOS或多任务的概念。它与FreeRTOS相关,因为内核对象是动态分配的,通用编译器提供的动态存储分配方案并不总是适合实时应用程序。

 可以使用标准的C库malloc()Free()函数分配内存,但由于以下一个或多个原因,它们可能不太合适:

 1. 它们并不总是在小型嵌入式系统上可用。
  2. 它们的实现可能相对来说占用了宝贵的代码空间。
  3. 它们很少是线程安全的。
  4. 它们是不具有确定性的;执行函数所需的时间将因调用而异。
  5. 它们会使链接器配置复杂化。
  6. 如果允许堆空间增长到其他变量使用的内存中,它们可能是调试错误的根源。
  7.它们会因为堆的碎片化导致出现问题。

 如果堆中的空闲RAM被分解成彼此分离的小块,则堆被认为是碎片化的,那么如果堆中没有单个空闲内存块大到足以包含该块,即使堆中所有单独空闲块的总大小比无法分配的块的大小大很多倍,分配内存块的尝试也会失败。

动态内存分配选项

FreeRTOS V9.0.0中的内核对象可以在编译时静态分配,或在运行时动态分配内存:
 早期版本的FreeRTOS使用一个内存池分配方案,从而在编译时预先分配不同大小内存块的池,然后由内存分配函数返回。尽管这是一个在实时系统中使用的常见方案,但因为它不能足够有效地使用RAM使其适用于非常小的嵌入式系统,所以该方案被放弃了。

 FreeRTOS现在将内存分配视为可移植层的一部分(与核心代码库部分相反)。这是为满足不同的嵌入式系统有不同的动态存储分配和时序要求。因此,单一的动态内存分配算法只能适用于应用程序的子集。此外,从核心代码库中删除动态存储分配使应用程序编写者能够在适当的时候使用自己特定的内存分配方案。

 当FreeRTOS需要RAM时,它不调用malloc()函数,而是调用pvPortMalloc()。当RAM被释放时,内核调用vPortFree()而不是调用Free()函数。pvPortMalloc()与标准C库malloc()函数具有相同的原型,vPortFree()与标准C库Free()函数也是具有相同的原型。效果是一样的,但也有不同之处。

pvPortMalloc()vPortFree()是公共函数,因此也可以在应用程序代码中调用。

 FreeRTOS附带了pvPortMalloc()vPortFree()的五个示例,所有这些都在本章中叙述。 在FreeRTOS应用程序中可以使用其中的一个示例,或者使用用户自己配置的方案。

 这五个示例分别在heap_1. cheap_2. cheap_3. cheap_4. cheap_5.c文件中定义,所有这些都位于FreeRTOS/Source/portable/MemMang目录中。

 本章的目的是让读者了解以下内容:
  1. 在FreeRTOS中分配内存。
  2. FreeRTOS提供的五个示例内存分配方案。
  3. 内存分配方案选择。

2.2示例内存分配方案

Heap_1

 对于小型专用嵌入式系统来说,在调度程序启动之前只创建任务和其他内核对象是很常见的。在这种情况下,内存仅在应用程序开始执行任何实时功能之前由内核动态分配,并且内存在应用程序的生命周期内保持分配。这意味着所选择的分配方案不必考虑任何更复杂的内存分配问题, 例如确定性和内存碎片问题,只需要考虑代码大小和复杂度等属性。

Heap_1. c实现了一个非常基础的pvPortMalloc()版本,并且没有实现vPortFree()。如果在应用程序中从不删除任务或其他内核对象则可以使用heap_1。一些商业上或者安全上要求非常严格的系统可能会禁止使用动态内存分配,因此适合使用heap_1。严格的系统通常禁止动态存储分配,因为不确定性、内存碎片和分配失败相关的不确定性——Heap_1总是确定性的,不会有内存碎片的问题。

 heap_1分配方案通过调用了pvPortMalloc()将一个简单的数组细分为更小的块,该数组称为FreeRTOS堆(heap)
 数组的总大小(以字节为单位)由FreeRTOSConfig. h中的configTOTAL_HEAP_SIZE定义设置。以这种方式定义一个大数组会使应用程序消耗大量RAM,即使还没有内存从数组中被分配。

 每次创建的任务都需要一个任务控制块(TCB)和一个要从堆中分配的栈,下图展示了 heap_1 是怎么在创建 task 时细分一个简单的数组。

  1. A表示没有 task 创建之前 array 的样子,整个数组都是空的。
  2. B创建一个 task之后的数组
  3. C创建三个 task之后的数组

Heap_2

 FreeRTOS发行版中保留了Heap_2以实现向后兼容,但不建议将其用于新设计,建议考虑使用heap_4替代heap_2,因为heap_4提供了增强的功能。

Heap_2.c 同样是通过由 configTOTAL_HEAP_SIZE 决定大小的数组工作的。它使用最佳拟合算法来分配内存,与heap_1不同,它允许释放内存。数组是静态声明的,因此会在分配数组中的任何内存之前使应用程序看起来消耗大量RAM。

 最佳拟合算法确保pvPortMalloc()使用大小最接近请求字节数的空闲内存块。思考以下场景:

  1. 堆中包含三个可用内存块,分别为5字节、25字节和100字节。
  2. pvPortMalloc()请求20字节的RAM。

 请求的字节数将适合的最小RAM空闲块是25字节块,因此pvPortMalloc()将25字节块拆分为一个20字节块和一个5字节块,然后返回指向20字节块的指针。新的5字节块仍然可用于以后对pvPortMalloc()的调用。(因此产生了内存碎片的问题)。

 与heap_4不同,heap_2不会将相邻的空闲块组合成一个更大的块,因此更容易出现碎片。然而,如果分配和随后释放的块总是相同的大小,则碎片不是问题。Heap_2适用于重复创建和删除任务的应用程序,前提是分配给创建任务的堆栈大小不变。

下图演示了当任务被创建、删除然后再次创建时,最佳拟合算法是如何工作的。

  1. A显示创建三个任务后的数组。一个大的空闲块保留在数组的顶部。
  2. B显示了其中一个任务被删除后的数组。数组顶部的大空闲块仍然存在。现在还有两个较小的空闲块,是之前分配给被删除任务的TCB和堆栈。
  3. C显示创建另一个任务后的情况。创建任务导致对pvPortMalloc()的两次调用,一次用于分配新的TCB,一次用于分配任务堆栈。任务是使用第3.4节中描述的xTaskCreate()API函数创建的。对pvPortMalloc()的调用发生在xTaskCreate ()函数内部。

 每个TCB大小完全相同,因此最佳拟合算法确保先前分配给已删除任务的TCB的RAM块可以被重用以分配新任务的TCB。
Heap_2不是确定性的,但比大多数标准库的malloc()和Free()更快实现。

Heap_3

Heap_3. c使用标准库malloc()Free()函数,因此堆的大小由链接器配置定义,对configTOTAL_HEAP_SIZE设置没有影响。Heap_3通过暂时挂起FreeRTOS调度程序来使malloc()和Free()线程安全。线程安全和调度程序暂停都是第7章资源管理中涵盖的主题。

Heap_4

 像heap_1heap_2一样,heap_4通过将数组细分为更小的块来工作。同样的数组也是静态定义的,并由configTOTAL_HEAP_SIZE确定大小。

 Heap_4使用首次适应算法来分配内存。与heap_2不同,heap_4将相邻的空闲内存块组合成一个更大的块,从而最大限度地降低内存碎片的风险。
 首次适应算法确保pvPortMalloc()使用第一个足够大的空闲内存块来保存请求的字节数。思考以下场景:

  1. 堆包含三个可用内存块,按照它们在数组中出现的顺序,分别为5字节、200字节和100字节。
  2. pvPortMalloc()请求20字节的RAM。

 第一个适合请求字节数的空闲块是200字节的RAM空闲块,因此, pvPortMalloc() 将200字节的块拆分为一个20字节的块和一个180字节的块,然后返回指向20字节块的指针。新的180字节块仍然可用于以后对 pvPortMalloc() 的调用。

 Heap_4将相邻的空闲块组合成一个更大的块,最大限度地降低了碎片化风险,并使其适用于重复分配和释放不同大小的RAM块的应用程序。

下图演示了heap_4首次适应算法是如何对内存进行分配和释放工作的:

  1. A显示创建三个任务后的数组。一个大的空闲块保留在数组的顶部。
  2. B显示了其中一个任务被删除后的数组。数组顶部的大空闲块仍然存在。还有一个先前分配的已删除任务的TCB和堆栈空闲块。请注意:`与heap_2不同,当TCB被删除时释放的内存,以及当栈被删除时释放的内存,不会保留为两个单独的空闲块,而是组合起来创建一个更大的单个空闲块。
  3. C显示FreeRTOS队列创建后的情况。队列是使用xQueueCreate ()API函数创建的,第4.3节对此进行了描述。xQueueCreate () 调用pvPortMalloc()来分配队列使用的RAM。由于heap_4使用首次适应算法pvPortMalloc() 将从第一个足够大的空闲RAM块分配RAM,以容纳队列,在图中,这块空闲的RAM是之前任务被删除时留下的空闲RAM。然而,队列不会消耗空闲块中的所有RAM,因此该块被分成两部分,未使用的部分仍然可用于以后对pvPortMalloc()的调用。
  4. D显示了直接在应用程序代码调用 pvPortMalloc() 后的情况,而不是通过调用FreeRTOS API函数间接调用。用户分配的块足够小,可以放入第一个空闲块,这是分配给队列内存和分配给TCB内存之间的块。删除任务时释放的内存现在已被拆分为三个单独的块;第一个块保存队列,第二个块保存用户分配的内存,第三个块保持空闲。
  5. E显示队列被删除后的情况,它会自动释放分配给已删除队列的内存。现在用户分配块的上下都有空闲内存。
  6. F表示用户分配的内存也被释放后的情况。用户分配使用的内存块与两侧的空闲内存合并以创建更大的单个空闲块。

 Heap_4不是确定性的,但比大多数标准库的 malloc() 和Free() 更快实现。

为Heap_4使用的数组设置起始地址

 本小节包含进阶信息。如果使用Heap_4则不需要阅读或理解本节。有需要可阅读源文档。

Heap_5

heap_5用于分配和释放内存的算法与heap_4使用的相同。与heap_4不同的是,heap_5不限制从单个静态声明的数组分配内存;heap_5可以从多个分离的内存空间分配内存。当运行FreeRTOS的系统提供的RAM在系统内存映射中不显示为单个连续(无空间)块时,Heap_5很有用。

heap_5是唯一必须在调用pvPortMalloc()之前提供显式初始化
的内存分配方案, 使用vPortDefineHeapRegions() API 函数初始化Heap_5。使用heap_5时,必须在创建任何内核对象(任务、队列、信号量等)之前调用vPortDefineHeapRegions()就可以了。

vPortDefineHeapRegions() API

void vPortDefineHeapRegions( const HeapRegion_t * const pxHeapRegions );

vPortDefineHeapRegions()用于指定每个单独内存区域的起始地址和大小,这些内存区域共同构成heap_5使用的总内存。
 每个单独的存储器区域由结构类型HeapRegion_t定义,所有可用内存区域的定义作为HeapRegion_t结构的数组传递给vPortDefineHeapRegions()

typedef struct HeapRegion
{/* The start address of a block of memory that will be part of the heap.*/
uint8_t *pucStartAddress;
/* The size of the block of memory in bytes. */
size_t xSizeInBytes;
} HeapRegion_t;

vPortDefineHeapRegions()的成员

pxHeapRegions
 指向HeapRegion_t结构体数组开头的指针。数组中的每个结构都描述了使用heap_5时将成为堆一部分的内存区域的起始地址和长度。数组中的HeapRegion_t结构必须按起始地址排序;
 描述具有最低起始地址的内存区域的HeapRegion_t结构必须是数组中的第一个结构体,并且描述具有最高起始地址的内存区域的HeapRegion_t结构必须是数组中的最后一个结构体。
 数组的末尾由一个HeapRegion_t结构标记,该结构的pucStart地址成员设置为NULL

思考下图所示假设的内存映射,其中包含三个单独的RAM块:RAM1、RAM2和RAM3。假设执行代码被放置在未显示的只读存储器中。

以下代码显示了一个HeapRegion_t结构数组,它们一起描述了整个RAM的三个块。

/* Define the start address and size of the three RAM regions. */
#define RAM1_START_ADDRESS      ( ( uint8_t * ) 0x00010000 )
#define RAM1_SIZE               ( 65 * 1024 )#define RAM2_START_ADDRESS         ( ( uint8_t * ) 0x00020000 )
#define RAM2_SIZE               ( 32 * 1024 )#define RAM3_START_ADDRESS         ( ( uint8_t * ) 0x00030000 )
#define RAM3_SIZE               ( 32 * 1024 )
/* Create an array of HeapRegion_t definitions, with an index for each of the three RAM regions, and terminating the array with a NULL address.
The HeapRegion_t structures must appear in start address order, with the structure that contains the lowest start address appearing first. */
const HeapRegion_t xHeapRegions[] =
{{ RAM1_START_ADDRESS, RAM1_SIZE },{ RAM2_START_ADDRESS, RAM2_SIZE },{ RAM3_START_ADDRESS, RAM3_SIZE },{ NULL, 0 } /* Marks the end of the array. */
};int main( void )
{/* Initialize heap_5. */vPortDefineHeapRegions( xHeapRegions );/* Add application code here. */
}

 这代码描述了RAM,它没有演示可用的示例,因为它将所有RAM分配给堆,没有RAM可供其他变量使用。
 构建项目时,构建过程的链接阶段会为每个变量分配一个RAM地址。可供链接器使用的RAM通常由链接器配置文件(例如链接器脚本)描述。

 在图中 B 假设链接器脚本包括RAM1上的信息,但不包括RAM2或RAM3上的信息。
 因此,链接器在RAM1中放置了变量,只留下RAM1地址0x0001nnnn上方的部分可供heap_5使用。0x0001nnnn的实际值将取决于被链接的应用程序中包含的所有变量的组合大小。链接器保留了所有未使用的RAM2和RAM3,保留的RAM2和RAM3可供heap_5使用。

 如果使用所示的代码,分配给heap_5地址0x0001nnnn的RAM将与用于保存变量的RAM区域重叠。为了避免这种情况,该数组中的第一个HeapRegion_t结构可以改变起始地址为0x0001nnnn, 而不是以0x00010000开始的起始地址。但是,这不是推荐的解决方案,因为:

  1. 起始地址可能不容易确定。
  2. 链接器使用的RAM数量在将来的构建中可能会发生变化,因此需要更新HeapRegion_t结构中使用的起始地址。
  3. 如果链接器使用的RAM和heap_5使用的RAM重叠,构建工具将不知道,因此无法警告用户。

 下面演示一个更方便和可维护的示例。它声明了一个名为ucHeap的数组。 ucHeap是一个普通变量,因此它成为链接器分配给RAM1的数据的一部分。第一个HeapRegion_t结构描述了ucHeap的起始地址和大小,因此ucHeap成为heap_5管理的内存的一部分。可以增加ucHeap的大小,直到链接器使用的RAM消耗掉所有RAM1,如上图C所示。

/* Define the start address and size of the two RAM regions not used by the
linker. */
#define RAM2_START_ADDRESS      ( ( uint8_t * ) 0x00020000 )
#define RAM2_SIZE               ( 32 * 1024 )#define RAM3_START_ADDRESS         ( ( uint8_t * ) 0x00030000 )
#define RAM3_SIZE               ( 32 * 1024 )
/* Declare an array that will be part of the heap used by heap_5. The array will be placed in RAM1 by the linker. */
#define RAM1_HEAP_SIZE ( 30 * 1024 )
static uint8_t ucHeap[ RAM1_HEAP_SIZE ];
/* Create an array of HeapRegion_t definitions.
Whereas in Listing 6 the first entry described all of RAM1,
so heap_5 will have used all of RAM1,
this time the first entry only describes the ucHeap array,
so heap_5 will only use the part of RAM1 that contains the ucHeap array.
The HeapRegion_t structures must still appear in start address order,
with the structure that contains the lowest start address appearingfirst. */
const HeapRegion_t xHeapRegions[] =
{{ ucHeap, RAM1_HEAP_SIZE },{ RAM2_START_ADDRESS, RAM2_SIZE },{ RAM3_START_ADDRESS, RAM3_SIZE },{ NULL, 0 } /* Marks the end of the array. */
};

所演示的技术优点包括:

  1. 没有必要使用硬编码的起始地址。
  2. HeapRegion_t结构中使用的地址将由链接器自动设置,因此将一直改变,即使链接器使用的RAM量在未来的构建中发生变化。
  3. 通过链接器分配给heap_5的RAM不可能重叠放入RAM1的数据。
  4. 如果ucHeap太大,应用程序将不会链接。

堆相关实用程序函数

xPortGetFreeHeapSize()

size_t xPortGetFreeHeapSize( void );

xPortGetFreeHeapSize() API函数返回 调用该函数时堆中可用字节的数量。它可用于优化堆大小。例如,如果xPortGetFreeHeapSize() 在创建完所有内核对象后返回2000,那么configTOTAL_HEAP_SIZE的值可以减少2000。

 在heap_3中xPortGetFreeHeap Size()不可用。

xPortGetMinimumEverFreeHeapSize()

size_t xPortGetMinimumEverFreeHeapSize( void );

xPortGetMinimumEverFreeHeapSize() API函数返回自FreeRTOS应用程序开始执行以来堆中存在的最小未分配字节数。

xPortGetMinimumEverFreeHeapSize() API函数返回的值指示应用程序离堆空间耗尽还有有多少字节。例如,如果xPortGetMinimumEverFreeHeapSize() 返回值是200,说明在应用程序开始执行调用此函数后,离堆空间耗尽还有200 Byte。

 此函数在heap_4 或heap_5可用。

Malloc Failed Hook Functions

void vApplicationMallocFailedHook( void );

pvPortMalloc() API函数可直接在应用代码中调用。每次创建内核对象时,它也会在FreeRTOS源文件中调用。内核对象的示例包括任务、队列、信号量和事件组——这些都将在本书的后面章节中描述。

 就像标准库malloc()函数一样,如果pvPortMalloc() 因为请求大小的块不存在而无法返回RAM块,那么它将返回NULL 。如果由于用户正在创建内核对象而执行pvPortMalloc(),并且对pvPortMalloc() 调用后返回NULL,则说明创建内核对象失败。
 如果对pvPortMalloc()的调用返回NULL,则可以将所有示例堆分配方案配置为调用钩子(或回调)函数。

 如果在FreeRTOSConfig.h 中,配置宏configUSE_MALLOC_FAILED_HOOK 为1,应用程序必须提供一个malloc失败的钩子函数。该功能可以适合应用程序的任何方式实现。

Mastering_the_FreeRTOS_Real_Time_Kernel-A_Hands-On_Tutorial_Guide 阅读翻译(二)相关推荐

  1. Feature Selective Anchor-Free Module for Single-Shot Object Detection论文阅读翻译 - 2019CVPR

    Feature Selective Anchor-Free Module for Single-Shot Object Detection论文阅读翻译 文章目录 Feature Selective A ...

  2. 【论文阅读翻译】A STRUCTURED SELF - ATTENTIVE SENTENCE EMBEDDING

    [论文阅读翻译]A STRUCTURED SELF - ATTENTIVE SENTENCE EMBEDDING Abstruct 1. Introducion 2. Approach 2.1 Mod ...

  3. Sparse R-CNN: End-to-End Object Detection with Learnable Proposals - 论文阅读翻译

    Sparse R-CNN: End-to-End Object Detection with Learnable Proposals - 论文阅读翻译 文章目录 Sparse R-CNN: End-t ...

  4. 大型网站技术架构:核心原理与案例分析阅读笔记二

    大型网站技术架构:核心原理与案例分析阅读笔记二 网站架构设计时可能会存在误区,其实不必一味追随大公司的解决方案,也不必为了技术而技术,要根据本公司的实际情况,制定适合本公司发展的网站架构设计,否则会变 ...

  5. 【渝粤题库】陕西师范大学201661英语阅读(二)作业(高起专)

    陕西师范大学 内 部 题 库 教育 (yuyueshool) 编制 <阅读(二)>作业 I.Words DIRECTIONS: Read the sentence given with e ...

  6. Alibaba Druid 源码阅读(二) 数据库连接池实现初步探索

    Alibaba Druid 源码阅读(二) 数据库连接池实现初步探索 简介 在上篇文章中,了解了连接池的应用场景和本地运行了示例,本篇文章中,我们尝试来探索下Alibaba Druid数据库连接池的整 ...

  7. Soul 网关源码阅读(二)代码初步运行

    Soul 源码阅读(二)代码初步运行 简介     基于上篇:Soul 源码阅读(一) 概览,这部分跑一下Soul网关的示例 过程记录     现在我们可以根据地图,稍微探索一下周边,摸一摸      ...

  8. CopyTranslator(复译)-外文辅助阅读翻译解决方案

    CopyTranslator(复译)-外文辅助阅读翻译解决方案 参考文章: (1)CopyTranslator(复译)-外文辅助阅读翻译解决方案 (2)https://www.cnblogs.com/ ...

  9. SURF C++代码 详细阅读(二)—— 极值点检测 确定极值点精确位置

    SURF C++代码详细阅读(二) 2.2.3 极值点检测 2.2.4 确定极值点精确位置 2.3 新建自定义Ipoint类获得特征点 阅读(一)进行到了 2.2.2 获取特征点 buildRespo ...

最新文章

  1. 物联网时代更要注意信息安全
  2. 学生渐进片add如何给_渐进镜片的说明与镜架选择
  3. golang 接口_「Golang系列」 深入理解Golang Empty Interface (空接口)
  4. Solr配置停止词注意
  5. 前端悬浮窗效果_web前端入门到实战:css过渡和动画解析文
  6. PHP编译参数 --prefix=/usr/local/php 的“深远”影响
  7. [Axis2与Eclipse整合开发Web Service系列之三] 服务端返回值
  8. tempdb SQL Server系统数据库的配置,操作和限制
  9. git21天打卡-day8 本地分支push到远程服务器
  10. PyTorch 中如何指定GPU
  11. Python中的open和codecs.open
  12. 【李宏毅机器学习】05:概率生成模型Probabilistic Generative Model
  13. [转]C# 中的常用正则表达式总结
  14. JSP教程第5讲笔记
  15. SOEM主站安装及简单试用记录
  16. GJB438C相比438B在文档种类上的变化
  17. 财富杂志推荐的75本必读书
  18. 盗贼之海显示无法连接服务器,盗贼之海网络连接不上怎么解决
  19. 4G模块加网流程_4G拨号上网相关知识
  20. Android开发学习—指纹识别系统的原理与使用

热门文章

  1. playmaker多事件同时触发解决
  2. 华为交换机S5700升级软件版本
  3. Ubuntu系统安装教程UEFI引导
  4. 树莓派uefi引导linux卡死,树莓派4B 折腾Windows 10 ARM版前传之运行UEFI引导
  5. 安科瑞变电所运维云平台解决方案
  6. CAD制图初学入门:CAD图形导出功能介绍
  7. CSS修改样式基本内容
  8. PTN设备中支持PHP,一般的PTN设备的工作电压为多少()。
  9. 三甲医院医生曝20年工资涨20倍 隐性收入远超工资
  10. 浏览器是怎么工作的(前端必读)