单片机(STM32,GD32,NXP等)中BootLoader的严谨实现详解

Bootloader(引导加载程序)的主要任务是引导加载并运行应用程序,我们的软件升级逻辑也一般在BootLoader中实现。本文将详细介绍BootLoader在单片机中的实现,包括STM32、GD32、NXP Kinetis等等的所有单片机,因为无论是什么样的芯片,它实现的逻辑都是一样的。

注意,本篇文章主要是介绍实现一个严谨的BootLoader需要掌握的基本知识和需要考虑的细节,如果不注意一些细节,应用层的代码很可能会受到影响。

  • 对于Linux的BootLoader来说其实也是一样的,但它还需要初始化MMU、引导内核等等,这里我们不做过多的讨论。

1 基础知识

1.1 NOR Flash和NAND Flash

NOR Flash和NAND Flash是两种常见的非易失性存储器(Flash Memory)类型,它们在内部结构、使用场景和性能方面存在一些显著的区别。以下是它们之间的一些主要区别:

  1. 内部结构:
    • NOR Flash: NOR Flash的内部结构类似于传统的存储器单元,支持随机访问。因此,它适用于需要快速随机访问的应用场景,例如执行代码(XIP,eXecute In Place)。
    • NAND Flash: NAND Flash的内部结构更适合大容量、顺序读写的应用场景。它采用页和块的结构,通常需要使用控制器来管理读写操作。
  2. 执行方式(XIP - Execute In Place):
    • NOR Flash: 由于其支持随机访问,NOR Flash 可以直接在存储器中执行代码(XIP),无需将代码加载到RAM中。
    • NAND Flash: 通常需要将代码从NAND Flash加载到RAM中才能执行,因为它不太适合随机访问。
  3. 位反转(Bit Inversion):
    • NOR Flash: NOR Flash 通常不需要位反转,即代码可以直接在Flash中运行,无需进行位翻转。
    • NAND Flash: 由于NAND Flash内部的存储单元是多级存储,读取时可能需要对数据进行位反转,以确保正确的数据解析。
  4. 擦写次数:
    • NOR Flash: NOR Flash 的擦写次数相对较高,通常可以达到数百万次,使其更适用于作为代码存储器。
    • NAND Flash: NAND Flash 的擦写次数相对较低,通常在几千次到几百万次之间,取决于具体的 NAND Flash 类型。因此,对于频繁擦写的应用,可能需要考虑其他存储器类型。

总体而言,选择使用NOR Flash还是NAND Flash取决于具体的应用场景和需求。NOR Flash适用于需要随机访问和高擦写次数的应用,例如嵌入式系统中的代码存储。NAND Flash适用于大容量存储和顺序访问的应用,例如存储大型文件和媒体内容。


注意事项:
对于STM32等单片机来说,它们都内置了NOR Flash,都是支持XIP的。但对于一些高端的单片机来说,如I.MX RT系列的MCU,在硬件上就需要自己接Flash,用户可以接NOR也可以接NAND,对应了不同的引导方式,具体就需要查看芯片手册了。

当然,对于单片机的绝大多数场景来说,代码放在NOR Flash中跑的概率比较高,所以本篇文章介绍的也是基于NOR Flash的BootLoader的实现。

  • 对于Linux来说,由于单单编译出来的内核本身就很大,而NOR Flash的成本较高,所以更常见的是将程序存储在一些NON-XIP的介质中,如EMMC、SD卡、NAND Flash,然后上电后将程序拷贝到SDRAM中运行。当然上电拷贝的程序也需要实现,一般芯片会自带一个很小的NOR Flash,里面存放一些固定的启动代码,当然不同厂商芯片的实现不同。

1.2 程序数据段

在程序中,通常会涉及到不同的段,这些段在内存中有着不同的属性和用途。以下是一些常见的程序段及其作用:

  1. 代码段(Text): 通常是只读的

    存储程序的执行代码,包括可执行指令和常量数据。在程序运行时,代码段的内容会被加载到内存中,并且在执行期间不可修改。

  2. 数据段(Data): 包括初始化数据(initialized data)和未初始化数据(uninitialized data)

    存储程序中的全局变量和静态变量。初始化数据在程序启动时会被初始化,而未初始化数据在程序启动时不会被初始化,其初始值为零或未定义。

  3. 只读数据段(Read-Only Data,rodata):

    存储常量数据,如字符串常量、只读常量等。在程序运行时,rodata段的内容不能被修改。

  4. 未初始化数据段(BSS):

    存储未初始化的全局变量和静态变量。在程序启动时,BSS段的内容被初始化为零或未定义的值。

  5. 栈(Stack):

    存储函数的局部变量和函数调用的状态信息。栈是一个先进后出(FILO)的数据结构,用于支持函数调用和返回。

  6. 堆(Heap):

    用于存储动态分配的内存,例如通过 malloc()new分配的内存。堆的管理通常由程序员负责,需要手动分配和释放内存。

1.3 程序镜像文件格式

对于不同的IDE来说,编译后生成的程序的镜像格式都不太一样,常见的有以下几种:

  • AXF:用于基于ARM的微控制器。它包含可执行代码、数据和调试信息。AXF文件通常在开发和调试过程中使用
  • HEX:由十六进制数及其对应的内存地址组成,可以将程序解析和编程到目标设备的内存中
  • S19:以特定格式的ASCII字符表示二进制数据。S19文件包含数据和内存地址,常用于编程旧的微控制器和EEPROM
  • ELF:包含可执行代码、数据和其他加载和执行程序所需的信息,可用于调试、分析和部署到目标设备
  • SREC:类似于S19的文件格式。它以ASCII字符表示二进制数据,但遵循不同的格式
  • BIN:BIN文件是直接包含可执行机器代码的二进制文件。它们通常用于以原始二进制格式存储最终编译的代码。

不管什么格式,都是为不同下载器或者调试而服务的,经过解析后下载进MCU内部FLASH的数据还是bin格式

1.4 Flash相关函数需要放入RAM中执行?

嵌入式Flash由多个块(block)组成,每个块包含了在该块内进行读取、擦除和写入时所需的电路。大多数闪存都存在一个限制:不允许在同一块内在执行擦/写操作的同时,执行读取操作(比如CPU从Flash读取指令运行代码)

举个例子,如果有一段代码在block1中执行,那在这个代码的执行期间,不允许对block1中的任何部分进行擦/写,这可能会导致读写冲突,进而引发错误。

以下是两个解决办法
(1)从不同的Flash块执行命令
如果MCU有多个Flash块,可以将擦/写Flash的代码放置在一个块中,而将其它代码或数据存储在另一个块中。
(2)从SRAM执行Flash命令
如果MCU只有一个Flash块,或用户在每个可用块内都要存放代码和写入,在这些场景中,可以将Flash命令移到SRAM中执行。

2 BootLoader实现实例

这里我将以NXP的Kinetis K系列芯片为例进行BootLoader的实现,我使用的芯片为MK64FN1M0xxx12,官方的开发板为FRDM-K64F

  • 不同MCU的BootLoader实现原理都相同,希望大家能学到一些通用的知识,而不是特定于某个单片机的。

接下来我们就来在一个新的平台中,如何一步一步地通过阅读芯片手册来实现BootLoader。

2.1 查看芯片的Flash映射

如下图所示:
在这里插入图片描述
所以在我们使用的芯片中有自带Flash,而且分为了两个block,其中block 0的范围是0x00000~0x7FFFF;block 1的范围是0x80000~0xFFFFF,也就是两个block各有512KB。另外,在上电后程序将从0地址取值运行。

2.2 Flash的擦写

2.2.1 Flash擦写的代码

在前面的程序镜像文件格式中,我们知道更新程序无非就是将原始的bin文件写到Flash中,所以最重要的一步就是看看芯片内置Flash如何通过程序进行擦写。

首先我们要知道,在写Flash之前必须保证所有的内存为0xFF,这是因为写操作只能将电平从1改为0,所以我们在写入Flash之前,必须要先对Flash进行擦除(一般是以块为单位进行)。

不同的芯片有不同的Flash控制器,这个一般在SDK中有提供相应的Flash驱动,这里不就做详细地分析了。在MK64中,初始化完Flash后可以调用下面两个函数来擦除和写入Flash:

status_t mem_erase(uint32_t address, uint32_t length);
status_t mem_write(uint32_t address, uint32_t length, const uint8_t *buffer);

2.2.2 重定位Flash擦写的代码到SRAM中

在MK64内存映射中,我们知道MK64中有两个block,每个block为512KB,就有前面所说的“Flash相关函数需要放入RAM中执行”的问题,那么第一个解决方案(单独将Flash函数放到第二个block上)其实不太实用,而且很麻烦。所以我们更多使用的是将Flash相关函数重定位到SRAM中执行。

对于MK64的Flash来说,由于是内部的Flash,对于Flash的读写操作来说,只需要更改FTFE寄存器即可。比如如果要擦除某个sector,只需要将这个sector的相关信息填充到FTFE对应的寄存器中,然后将FTFE_FSTAT寄存器的第7位CCIF置1,即可根据我们填充的参数来启动Flash操作。
在这里插入图片描述
所以我们实际上只需要填充好相应的Flash操作寄存器,然后将CCIF位置为1,然后硬件会将CCIF清零,然后我们再等待CCIF置1即可。对于填充寄存器部分,由于没有运行代码,所以可以在Flash中运行,而对于操作CCIF标志位的部分,我们需要将其重定位到SRAM中运行,以下是CCIF位操作的代码:

void flash_run_command(FTFx_REG_ACCESS_TYPE ftfx_fstat)
{
    // clear CCIF bit
    *ftfx_fstat = FTFx_FSTAT_CCIF_MASK;

    // Check CCIF bit of the flash status register, wait till it is set.
    // IP team indicates that this loop will always complete.
    while (!((*ftfx_fstat) & FTFx_FSTAT_CCIF_MASK))
    {
    }
}

我们只要保证这个函数在SRAM中运行就行了,所以我们先将这个函数编译出来,然后通过.map内存映射文件,将去bin文件反汇编objdump,然后找到这个函数在汇编上的机器码,我们这里保存为数组:

const static uint16_t s_flashRunCommandFunctionCode[] = {
    0x2180, /* MOVS  R1, #128 ; 0x80 */
    0x7001, /* STRB  R1, [R0] */
    /* @4: */
    0x7802, /* LDRB  R2, [R0] */
    0x420a, /* TST   R2, R1 */
    0xd0fc, /* BEQ.N @4 */
    0x4770  /* BX    LR */
};

然后我们再初始化Flash的时候,将这个机器码拷贝到SRAM中即可,然后使用一个函数指针指向拷贝到的位置,就可以调用这个函数了:

// 声明函数callFlashRunCommand(对应上面的flash_run_command)
static void (*callFlashRunCommand)(FTFx_REG_ACCESS_TYPE ftfx_fstat);
// 声明保存二进制代码的数组
#define kFLASH_ExecuteInRamFunctionMaxSizeInWords 16U
static uint32_t s_flashRunCommand[kFLASH_ExecuteInRamFunctionMaxSizeInWords];
// 拷贝二进制码到数组中
memcpy((void *)&s_flashRunCommand, (void *)s_flashRunCommandFunctionCode, sizeof(s_flashRunCommandFunctionCode));
// 将callFlashRunCommand函数指针指向数组地址
callFlashRunCommand = (void (*)(FTFx_REG_ACCESS_TYPE ftfx_fstat))((uint32_t)s_flashRunCommand + 1);

这样后续调用callFlashRunCommand函数,就和flash_run_command函数是一个效果,但是callFlashRunCommand 就是在RAM中运行的了。前面说了Flash的所有操作,擦除、写入等等函数,最终都是会置CCIF位来启动Flash控制器进行操作,所以最后只要保证擦除、写入等封装好的函数最后调用的是callFlashRunCommand函数启动即可。


细心的人可能发现上面强制转换时s_flashRunCommand还加了1:
在ARM架构中,函数指针的值通常是奇数。这是因为ARM处理器使用Thumb指令集,而Thumb指令集中的指令是16位的,因此函数的地址通常是2的倍数。由于函数指针的最低位是用来指示Thumb指令集的状态的,所以函数指针的值通常是奇数。

然而,实际上函数在内存中的存储地址是偶数。因为Thumb指令集中的指令是16位的,而ARM处理器要求指令在内存中的地址是4的倍数。因此,当你想要获取函数在内存中的真实地址时,你需要将函数指针的值加上1,以得到实际的偶数地址。

简而言之,通过执行 “+1” 操作,你可以将奇数的函数指针值调整为函数实际在内存中的偶数地址,以正确访问函数的二进制代码。这是在处理ARM函数指针时经常需要考虑的一种调整。


当然,如果你的MCU还支持对Flash的数据进行缓存的话,那就还需要将清除缓存的函数重定位到SRAM中:

// 函数原型:这里不做详细分析了,实际就是控制寄存器
void flash_cache_clear_command(FTFx_REG32_ACCESS_TYPE ftfx_reg)
{
    *ftfx_reg = (*ftfx_reg & ~FMC_PFB01CR_CINV_WAY_MASK) | FMC_PFB01CR_CINV_WAY(~0);
    *ftfx_reg |= FMC_PFB0CR_S_INV_MASK;
    __ISB();
    __DSB();
}

// 函数二进制
const static uint16_t s_flashCacheClearCommandFunctionCode[] = {
    0x6801,         /* LDR  R1, [R0] */
    0x22f0,         /* MOVS R2, #240    ; 0xf0 */
    0x0412,         /* LSLS R2, R2, #16 */
    0x430a,         /* ORRS R2, R2, R1 */
    0x6002,         /* STR  R2, [R0] */
    0xf3bf, 0x8f6f, /* ISB */
    0xf3bf, 0x8f4f, /* DSB */
    0x4770          /* BX   LR */
};

// 声明函数指针
static void (*callFlashCacheClearCommand)(FTFx_REG32_ACCESS_TYPE ftfx_reg);
// 声明数组
#define kFLASH_ExecuteInRamFunctionMaxSizeInWords 16
static uint32_t s_flashCacheClearCommand[kFLASH_ExecuteInRamFunctionMaxSizeInWords];
// 拷贝函数
memcpy((void *)s_flashCacheClearCommand, (void *)s_flashCacheClearCommandFunctionCode, sizeof(s_flashCacheClearCommandFunctionCode));
// 设置函数指针
callFlashCacheClearCommand = (void (*)(FTFx_REG32_ACCESS_TYPE ftfx_reg))((uint32_t)flashCacheClearCommand + 1);
// 调用例子
callFlashCacheClearCommand((FTFx_REG32_ACCESS_TYPE)&MCM->PLACR);

在每次擦除、写完Flash之后,都需要调用这个函数flush一下cache。

  • 注意:在我这个例子中使用机器码的方式手动拷贝这些函数到SRAM中,大家可以简单地在链接脚本中定义一个段,同时把这个段链接到SRAM中,然后在函数声明的地方加上__attribute__((section("段名")))

2.3 MPU、低功耗和时钟的操作

1、MPU
对于MPU来说,在我之前的文章中有详细地介绍MPU内存保护单元详解及例子,感兴趣的可以看一下。

MPU是Cortex-M系列芯片都有的一个特性,它涉及到Cache的一些问题,如果使能的话,对于一些直接与硬件接触的操作,如我们希望在BootLoader中实现通过USB获取固件并升级,而USB一般使用了DMA,这样的话数据的一致性会受到影响。当然我们可以使用CMSIS中的SCB_CleanDCache等函数在执行DMA之前清理一下D-Cache,但这些都太麻烦了,这里建议在BootLoader中直接关掉MPU

2、低功耗
MK64芯片支持低功耗模式,为了防止在固件升级的过程中进入低功耗而引发Flash的未知状态,我们需要将低功耗模式关闭。当然有的芯片是自动开启低功耗,有的则是没有开启低功耗,我的建议还是以防万一,在上电时关闭一下低功耗。
MK64中通过SMC(System Mode Controller,系统模式控制器)中的PMCTRL中的RUNM位控制低功耗模式:
在这里插入图片描述
我们在上电之后将这两个位置为0即可,表示进入正常运行模式。

3、时钟
我们在BootLoader中可能会使用到一些外设,我们可以在启动时就将所有GPIO的时钟打开。当然也可以在使用的时候再单独打开,比如要使用串口,在串口初始化函数中初始化时钟也行。

在MK654中通过SIM(System Integration Module,系统集成模块)的SCGC5寄存器可以控制GPIOA~GPIOE时钟的使能。
在这里插入图片描述

2.4 BootLoader内存分配

首先我们要规定一下BootLoader的大小,假设我们给BootLoader留40KB的大小(需要保证编译出来的BootLoader的bin文件小于40KB),那么在0~0xA000部分就存放BootLoader的代码,从0xA000开始就存放应用程序的代码。当然我们的程序大小不能超过block1,因为block1和block2的内存虽然在逻辑上是连续的,但是CPU无法从block1读取一半指令,又从block2读取一半指令执行。如下图所示:
在这里插入图片描述

2.5 链接脚本修改

对于APP来说,它的偏移现在在0xA000处,所以我们要在IDE中修改链接脚本,将程序链接到0xA000处,我这里使用的是IAR,只需要更改它的链接文件.icf中的__ICFEDIT_intvec_start__即可(变量名可能不同,具体参考你目录下的链接脚本):

define symbol __ICFEDIT_intvec_start__ = 0x0000A000;  /*-User Application Base-*/

对于Keil和IAR,我同样写过文章分析其链接脚本的格式,大家可以参考一下:

2.7 上下文保持一致

我们必须保证程序在进BootLoader前是什么状态,在进APP前就应该是什么状态。

我的真实经历是,同事在BootLoader中使用UART升级,打开了UART中断,但退出BootLoader时没有关闭这个中断。于是在APP的初始化函数中,将数据段复制到RAM中的时候,这个中断就会影响拷贝的值。比如你在程序中初始化了一个char *a = "123";,但实际上a的值可能为1a3

详细的步骤如下:
1、清理Flash的缓存:一般Flash有一个flush类似的函数,保证之前的Flash操作都执行完毕
2、清除所有中断标志位:主要是控制NVIC寄存器,参考代码如下:

__STATIC_INLINE void NVIC_ClearEnabledIRQs(void)
{
    NVIC->ICER[0] = 0xFFFFFFFF;
    NVIC->ICER[1] = 0xFFFFFFFF;
    NVIC->ICER[2] = 0xFFFFFFFF;
    NVIC->ICER[3] = 0xFFFFFFFF;
    NVIC->ICER[4] = 0xFFFFFFFF;
    NVIC->ICER[5] = 0xFFFFFFFF;
    NVIC->ICER[6] = 0xFFFFFFFF;
    NVIC->ICER[7] = 0xFFFFFFFF;
}

__STATIC_INLINE void NVIC_ClearAllPendingIRQs(void)
{
    NVIC->ICPR[0] = 0xFFFFFFFF;
    NVIC->ICPR[1] = 0xFFFFFFFF;
    NVIC->ICPR[2] = 0xFFFFFFFF;
    NVIC->ICPR[3] = 0xFFFFFFFF;
    NVIC->ICPR[4] = 0xFFFFFFFF;
    NVIC->ICPR[5] = 0xFFFFFFFF;
    NVIC->ICPR[6] = 0xFFFFFFFF;
    NVIC->ICPR[7] = 0xFFFFFFFF;
}
  • 执行上面两个函数即可,但上面的代码是基于Cortex-M4或Cortex-M7内核的,其它内核自行参考内核手册的NVIC章节编写。

3、设置VTOR为默认值

kDefaultVectorTableAddress = 0
SCB->VTOR = kDefaultVectorTableAddress;

4、恢复时钟
比如程序中用到了USB的话,系统时钟速率在之前应该配置地很高,这里需要恢复最初始的时钟配置。同时如果前面开启了所有GPIO的时钟的话,这里也要全部关闭。比如使用了UART,打开了对应GPIO的时钟的话,需要在此关闭。

对于MK64来说,如果打开USB的话,配置时钟的时候还使能了这些位,都需要关闭。
在这里插入图片描述
5、使能中断
这和我们刚刚清理的中断标志位不一样,在上电后默认总中断的相应是使能的,为了进一步处理中断请求并继续系统的正常运行,需要重新使能系统对于中断的相应。

__enable_irq()

6、内存屏障
最后我们确保指令和数据的一致性以及正确的执行顺序,这里是保证在APP跳转之前我们的这些设置都起作用了。当然这里的__DSB可以省略,因为我们前面更改的都是强有序内存(这些系统内存即使不使能MPU也是强有序的)。这里更多地考虑的是平台之间的兼容,如代码从Cortex-M4移动到Cortex-M7一样可以使用。

__ISB();
__DSB();

2.8 获取SP和PC

在更新完固件后,我们需要跳转到位于0xA000处的APP,现在的问题是,APP的堆栈指针是什么,应该将PC指针设置为多少才能跳转到APP中。
获取SP和PC
如下图所示,实际上固件的0地址存放的就是堆栈指针,在上电后硬件将设置MSP(主堆栈指针)的值为bin文件0偏移处的值。
在这里插入图片描述
我们再来看一下APP的.s启动文件:
在这里插入图片描述
可以看到第一个果然是堆栈指针,这里的CSTACK可以在链接脚本中指定。同时我们发现第二个向量是Reset_Handler函数的地址,我们将PC值设置为Reset_Handler的值不就可以跳转到APP了吗?获取这两个值的函数如下:

#define APP_VECTOR_TABLE ((uint32_t *)0xA000)
static void get_user_application_entry(uint32_t *appEntry, uint32_t *appStack)
{
	*appEntry = APP_VECTOR_TABLE[1];
    *appStack = APP_VECTOR_TABLE[0];
}

2.9 跳转APP

前面获取了SP(appStack)和PC(appEntry),这里就派上用场了。但在跳转APP之前,我们还需要做两件事:
1、设置堆栈指针
因为前面说的是上电的时候硬件会设置SP,所以仅仅设置的是BootLoader中的SP,对于APP的堆栈指针需要我们自己设置:

__set_MSP(appStack);
__set_PSP(appStack);
  • 主堆栈指针和线程堆栈指针都需要设置。

2、设置向量表地址
同样地,上电后硬件设置的是BootLoader的向量表,我们要将其设置为APP的向量表位置:

#define APP_VECTOR_TABLE ((uint32_t *)0xA000)
SCB->VTOR = (uint32_t)APP_VECTOR_TABLE;

最后我们就可以跳转到APP了,声明一个函数指针,然后指向Reset_Handler,然后执行即可更改PC指针为Reset_Handler

static void (*farewellBootloader)(void) = 0;
farewellBootloader = (void (*)(void))appEntry;
farewellBootloader();

2.9 BootLoader完整流程

下面来列举一下BootLoader的实现步骤:
1、退出低功耗:如果芯片支持的话,需要关闭
2、关闭MPU:建议关闭,否则代码中需要兼容Cache
3、开启所有GPIO的时钟:非必要,可在用到具体某个外设时再打开
4、配置系统时钟树:建议使用芯片内部的时钟作为主时钟源
5、初始化Flash:包括Flash参数的配置、Flash时钟的配置、拷贝代码到SRAM
6、更新固件
实际上就是可以通过UART、SDCARD、USB等各种外设(记得初始化这些外设的引脚)获取最新的固件,然后调用mem_erasemem_write函数将固件写入Flash中。
7、清理上下文:上下文保持一致
8、获取SP和PC值,设置MSP/PSP/VTOR
9、跳转APP

3 待优化

另外,对于固件升级来说,还有两点需要考虑。
1、可靠升级:如果在固件升级的过程中,已经把0xA000处之前的APP擦掉了,准备写入新的固件,但此时如果突然设备断电,那么就没有程序了,原来的程序也不能运行,所以我们还需要保证BootLoader的可靠。

2、加密:现在反汇编的技术已经很成熟了。我最近使用的I.MX RT1170直接硬件自带了OTFAD引擎,可以边解密AES-128加密的代码边运行,可见加密的重要性。而对于这些普通的MCU来说,我们也可以自己设计加密算法。对于MK64来说,有AES解密的引擎,但没有这个功能的MCU也没关系,我们也可以自己解密。可以参考我写的两篇关于AES的文章:

我这里给大家提供一个思路,下图是我在MK64平台中实现的BootLoader:
在这里插入图片描述
这里我使用了AES-128加密,从串口/SDCARD直接边读取加密固件,边解密原始固件到0x80000开始处的位置。同时我在APP的头字段中包含一些字段(很多中断向量表都是空的,可以用来存储一些Boot信息),其中包括CRC字段,解密完后可以用于校验固件的合法性。校验完后将APP从0x80000处拷贝到0xA000处,这样就也保证了可靠升级。最后再校验一次0xA000处的CRC,就表示升级成功了。

4 总结

本文介绍了对于实现一个BootLoader需要考虑的方面,其实本文更多的是想传递一种严谨的思想,而不是从网上随便复制一段代码就去用。在你严谨地做事的同时,就会考虑到更多的东西,比如这里你可能还会学到MPU、低功耗、内存屏障等知识,正是对这一个个知识的好奇、深入理解并积累,同时保持严谨的态度,你才会在不知不觉中成为“高手”。

猜你喜欢

转载自blog.csdn.net/tilblackout/article/details/134621320