STM32高级开发(8)-链接器与启动文件

最近休息了一下,中间断断续续在虚拟机上靠着记忆恢复了原来崩溃的虚拟机上80%的工作成果,还算过得去吧,完全丢失的也就是些不大重要的资料。今天新买的机械键盘也到货了,不得不说顺丰的工作人员好评,给过年假期里仍在工作的商家和快递员们点个赞。现在我的感觉炒鸡棒,所以我们继续下面的教程吧~

在上一篇中我们介绍了,样例工程中的makefile的工作原理和功能。我想对大多数童鞋来说理解编译器将.c文件编译为.o文件并不大困难,但是却难以明白最后链接的过程是什么作用和为什么要这样做。还有就是我们在样例工程中启动的文件为什么是自己编写的,它又怎样做到将程序入口引导到main函数上,那么在这篇中我们就来深入的讨论下这两个话题。

链接器

链接的过程

首先,想要明白链接器的工作原理我们还是要来深入的看看整个编译过程中具体的方式和原理。

我想大家都知道高级语言出现之前我们所用的汇编语言是除机器码外最接近硬件的语言。使用汇编的代码甚至可以很容易的手动转换为机器代码。那么接下来的介绍就需要童鞋们多少了解一点汇编程序了(如8051的汇编)。在单片机执行的过程中命令被执行的顺序只有两种:顺序执行和根据指令跳转执行位置。在汇编的代码中,良好的写法是把各个函数分块放在储存的不同位置上,并在前面写上程序的标号 (如:“START:”),最后由编译器将START程序处的地址装入写有 START标号跳转指令的地方。

由此,我们就可以理解C语言被编译为二进制执行文件的过程了,首先每个C文件都被编译为了.o的,带有未解析地址的中间文件,而后工具链的链接器将所有C文件的.o 文件链接将他们有序的排列到储存中,并将他们个个函数处的地址解析使得其他不同地方的函数能够跳转到该函数的入口地址。由此一个有序排列的可被单片机执行的文件便生成了。至于其中各个.c文件产生的功能在单片机储存中的排列顺序和地址位置,在最后我们链接器工作产生的.map文件中是有显示的,如下面从样例工程中.map文件中复制的片段:

.isr_vector     0x08000000      0x134
                0x08000000                . = ALIGN (0x4)
 *(.isr_vector)
 .isr_vector    0x08000000      0x134 ./USER/CoIDE_startup.o
                0x08000000                g_pfnVectors
                0x08000134                . = ALIGN (0x4)

.text           0x08000134     0x1464
                0x08000134                . = ALIGN (0x4)
 *(.text)
 .text          0x08000134       0x5c /home/yangliu/Library/gcc-arm-none-eabi-5_4-2016q3/bin/../lib/gcc/arm-none-eabi/5.4.1/armv7-m/crtbegin.o
 .text          0x08000190       0x80 ./USER/main.o
                0x08000190                main
 .text          0x08000210       0x68 ./USER/CoIDE_startup.o
                0x08000210                Reset_Handler
                0x08000210                Default_Reset_Handler
                0x08000268                EXTI2_IRQHandler
                0x08000268                TIM8_TRG_COM_IRQHandler
                0x08000268                TIM8_CC_IRQHandler
                0x08000268                TIM1_CC_IRQHandler
                0x08000268                TIM6_IRQHandler
                0x08000268                PVD_IRQHandler
                0x08000268                SDIO_IRQHandler
                0x08000268                EXTI3_IRQHandler
                0x08000268                EXTI0_IRQHandler
                0x08000268                I2C2_EV_IRQHandler
                0x08000268                ADC1_2_IRQHandler

所以我们的gcc链接器就是用来做这个工作的,当然不只是gcc的链接器,世上所有c程序的编译工具链应该都是以这种理念设计的。。当然不排除我见识少,没见过特殊的。

工具链中链接器的用法

在实际中,链接器的执行程序实际上是arm-none-eabi-ld这个文件,但是我再实际的编写过程中在遇到.c.cpp文件混合的工程中,ld会在链接过程中报错。而对此官方的说明是推荐使用arm-none-eabi-gcc指令来链接工程,它会自动的调用ld程序且不会出现上面这种情况,所以接下来我们都是以arm-none-eabi-gcc指令来介绍链接器工作的。

$(CC) $(C_OBJ) -T stm32_f103ze_gcc.ld -o $(TARGET).elf   -mthumb -mcpu=cortex-m3 -Wl,--start-group -lc -lm -Wl,--end-group -specs=nano.specs -specs=nosys.specs -static -Wl,-cref,-u,Reset_Handler -Wl,-Map=Project.map -Wl,--gc-sections -Wl,--defsym=malloc_getpagesize_P=0x80 

在上面这段截取自样例工程makefile的代码片中,我们可以看到在最后生成.elf文件时的指令。变量CCarm-none-eabi-gcc,变量OBJ为所有.o文件。* -o xx.elf*为链接.o文件生成.elf文件。

ld文件

在链接的过过程中与编译过程相比其中显著的与编译指令不同的便是 -T xx.ld

在这里 -T xx.ld实际上是调用了一个.ld的文件,那么.ld文件是做什么的呢?这里就比较高深了,在51单片机中我们知道最后在生成代码后51单片机内存中会有如 code、xdata、data的区段,来讲代码中执行部分、变量部分等分区块放置,而.ld就是一种链接器使用的规则性文件,他告诉链接器单片机系统的ROM、RAM的地址和他们的大小等信息,并指示链接器将什么代码保存在什么位置。

对于.ld文件它是有一套自己的语法及设置参数的规则的,大家可以不具体作了解,但求看懂其中一部分的信息。


/* Entry Point */
ENTRY(Reset_Handler)

/* Highest address of the user mode stack */
_estack = 0x20010000;    /* end of 64K RAM */

/* Generate a link error if heap and stack don't fit into RAM */
_Min_Heap_Size = 0;      /* required amount of heap  */
_Min_Stack_Size = 0x200; /* required amount of stack */

/* Specify the memory areas */
MEMORY
{
  FLASH (rx)      : ORIGIN = 0x08000000, LENGTH = 512K
  RAM (xrw)       : ORIGIN = 0x20000000, LENGTH = 64K
  MEMORY_B1 (rx)  : ORIGIN = 0x60000000, LENGTH = 0K
}

SECTIONS
{
  /* The startup code goes first into FLASH */
  .isr_vector :
  {
    . = ALIGN(4);
    KEEP(*(.isr_vector)) /* Startup code */
    . = ALIGN(4);
  } >FLASH

  /* The program code and other data goes into FLASH */
  .text :
  {
    . = ALIGN(4);
    *(.text)           /* .text sections (code) */
    *(.text*)          /* .text* sections (code) */
    *(.glue_7)         /* glue arm to thumb code */
    *(.glue_7t)        /* glue thumb to arm code */
    *(.eh_frame)

    KEEP (*(.init))
    KEEP (*(.fini))

    . = ALIGN(4);
    _etext = .;        /* define a global symbols at end of code */
  } >FLASH

  /* Constant data goes into FLASH */
  .rodata :
  {
    . = ALIGN(4);
    *(.rodata)         /* .rodata sections (constants, strings, etc.) */
    *(.rodata*)        /* .rodata* sections (constants, strings, etc.) */
    . = ALIGN(4);
  } >FLASH

  .ARM.extab   : { *(.ARM.extab* .gnu.linkonce.armextab.*) } >FLASH
  .ARM : {
    __exidx_start = .;
    *(.ARM.exidx*)
    __exidx_end = .;
  } >FLASH

  .preinit_array     :
  {
    PROVIDE_HIDDEN (__preinit_array_start = .);
    KEEP (*(.preinit_array*))
    PROVIDE_HIDDEN (__preinit_array_end = .);
  } >FLASH
  .init_array :
  {
    PROVIDE_HIDDEN (__init_array_start = .);
    KEEP (*(SORT(.init_array.*)))
    KEEP (*(.init_array*))
    PROVIDE_HIDDEN (__init_array_end = .);
  } >FLASH
  .fini_array :
  {
    PROVIDE_HIDDEN (__fini_array_start = .);
    KEEP (*(SORT(.fini_array.*)))
    KEEP (*(.fini_array*))
    PROVIDE_HIDDEN (__fini_array_end = .);
  } >FLASH

  /* used by the startup to initialize data */
  _sidata = LOADADDR(.data);

  /* Initialized data sections goes into RAM, load LMA copy after code */
  .data : 
  {
    . = ALIGN(4);
    _sdata = .;        /* create a global symbol at data start */
    *(.data)           /* .data sections */
    *(.data*)          /* .data* sections */

    . = ALIGN(4);
    _edata = .;        /* define a global symbol at data end */
  } >RAM AT> FLASH

  /* Uninitialized data section */
  . = ALIGN(4);
  .bss :
  {
    /* This is used by the startup in order to initialize the .bss secion */
    _sbss = .;         /* define a global symbol at bss start */
    __bss_start__ = _sbss;
    *(.bss)
    *(.bss*)
    *(COMMON)

    . = ALIGN(4);
    _ebss = .;         /* define a global symbol at bss end */
    __bss_end__ = _ebss;
  } >RAM

  /* User_heap_stack section, used to check that there is enough RAM left */
  ._user_heap_stack :
  {
    . = ALIGN(4);
    PROVIDE ( end = . );
    PROVIDE ( _end = . );
    . = . + _Min_Heap_Size;
    . = . + _Min_Stack_Size;
    . = ALIGN(4);
  } >RAM

  /* MEMORY_bank1 section, code must be located here explicitly            */
  /* Example: extern int foo(void) __attribute__ ((section (".mb1text"))); */
  .memory_b1_text :
  {
    *(.mb1text)        /* .mb1text sections (code) */
    *(.mb1text*)       /* .mb1text* sections (code)  */
    *(.mb1rodata)      /* read-only data (constants) */
    *(.mb1rodata*)
  } >MEMORY_B1

  /* Remove information from the standard libraries */
  /DISCARD/ :
  {
    libc.a ( * )
    libm.a ( * )
    libgcc.a ( * )
  }

  .ARM.attributes 0 : { *(.ARM.attributes) }
}

至于链接时其他的链接参数大部分和编译参数相同,不同的也就是:

--start-group -lc -lm -Wl,--end-group -specs=nano.specs -specs=nosys.specs -static -Wl,-cref,-u,Reset_Handler -Wl,-Map=Project.map -Wl,--gc-sections -Wl,--defsym=malloc_getpagesize_P=0x80 

对于这些指令我只是大致的清楚是什么,但具体的一些参数我也不大了解,如果大家有兴趣可以自己检索一下,或者最好的办法就是到工具链中的说明文档寻找说明。

STM32高级开发(8)-链接器与启动文件_第1张图片

在我们实际的工程建立及编写中,我们使用的都是从别处找来的ld文件,在样例工程中的.ld文件只要在内存大小堆栈等位置上根据stm32具体的型号稍作修改就可以使用了。或者在之后我们介绍libopencm3的驱动库中,其作者就有写好的所有芯片型号的ld文件,我们也可以从那里复制并修改以用于我们自己的工程。其中ld文件中一些变量如堆栈大小等我们会在讲解启动文件的过程中来解析,因为启动文件和ld文件中的东西息息相关。

启动文件

很多刚接触stm32不久的童鞋对stm32的启动文件的印象大多就是教程里的一句话:启动文件就是stm32在执行main函数前将系统初始化并把PC(即程序计数器,也就是当前执行代码位置的指针)设置到main函数的文件。确实在KEIL或IAR之类的集成开发环境中我们不必关心启动文件的存在,但是在我们的gcc的使用中,我们就需要去理解这个文件了。

在样例工程中,我放置的是一个从CooCox开源集成开发环境中拷贝修改的启动文件,在USER目录下的CoIDE_startup.c,这里我就不放文件的内容了,我们只去其中一部分来讲。

其中具体的启动文件的说明和标注大家可以在下面的链接下载,这是一篇我当初学习启动文件时做的学习笔记,里面详细的标注了启动文件各个部分的作用,大家可以下载下来自己学习,由于是在OneNote中做的笔记,而且篇幅超长,在导出为pdf时候会被截断成几个部分影响阅读,所有就做成了网页格式,大家可以用自己的浏览器打开阅读。(为了保证资源的永久有效性设置了下载1点的积分,请见谅。)

http://download.csdn.net/detail/zhengyangliu123/9747151

想要理解启动代码,首先我们需要看看GNU编译器的与其他编译器不同的新特性之一:_attribute((xxx)),在gcc中attribute关键词用于为函数或变量等赋予特性,就像MDK中的weak 说明符类似,只不过attribute的使用更具多样性且灵活,关于attribute的具体介绍大家可以看看这个文章来了解:

http://blog.csdn.net/ruixj/article/details/4274721。

其次我们要知道,在我们使用的Cortex-M3内核中,程序执行的最开始会从ROM首地址的第一位取出MSP的数值(即栈顶地址指针寄存器),然后会在第二位取出复位中断函数的地址,并跳转过去。且在一般来说,单片机系统的所有中断向量表初始时会放在ROM的最前段,所以我们定义了一个函数指针数组在堆栈初始值的后方,构成了这样一个被装入ROM首段地址的数据:

__attribute__ ((used,section(".isr_vector")))

void (* const g_pfnVectors[])(void) =

{       

  /*----------Core Exceptions-------------------------------------------------*/

  (void *)&pulStack[STACK_SIZE],     /*!< The initial stack pointer         */

  Reset_Handler,                /*!< Reset Handler                            */

  NMI_Handler,                  /*!< NMI Handler                              */

  HardFault_Handler,            /*!< Hard Fault Handler                       */

  MemManage_Handler,            /*!< MPU Fault Handler                        */

  BusFault_Handler,             /*!< Bus Fault Handler                        */

  UsageFault_Handler,           /*!< Usage Fault Handler                      */

  0,0,0,0,                      /*!< Reserved                                 */

  SVC_Handler,                  /*!< SVCall Handler                           */

  DebugMon_Handler,             /*!< Debug Monitor Handler                    */

  0,                            /*!< Reserved                                 */

  PendSV_Handler,               /*!< PendSV Handler                           */

  SysTick_Handler,              /*!< SysTick Handler                          */



  /*----------External Exceptions---------------------------------------------*/

  WWDG_IRQHandler,              /*!<  0: Window Watchdog                      */

  PVD_IRQHandler,               /*!<  1: PVD through EXTI Line detect         */

  TAMPER_IRQHandler,            /*!<  2: Tamper                               */

  RTC_IRQHandler,               /*!<  3: RTC                                  */

  FLASH_IRQHandler,             /*!<  4: Flash                                */

  RCC_IRQHandler,               /*!<  5: RCC                                  */

  EXTI0_IRQHandler,             /*!<  6: EXTI Line 0                          */

  EXTI1_IRQHandler,             /*!<  7: EXTI Line 1                          */

  EXTI2_IRQHandler,             /*!<  8: EXTI Line 2                          */

  EXTI3_IRQHandler,             /*!<  9: EXTI Line 3                          */

  EXTI4_IRQHandler,             /*!< 10: EXTI Line 4                          */

  DMA1_Channel1_IRQHandler,     /*!< 11: DMA1 Channel 1                       */

  DMA1_Channel2_IRQHandler,     /*!< 12: DMA1 Channel 2                       */

  DMA1_Channel3_IRQHandler,     /*!< 13: DMA1 Channel 3                       */

  DMA1_Channel4_IRQHandler,     /*!< 14: DMA1 Channel 4                       */

  DMA1_Channel5_IRQHandler,     /*!< 15: DMA1 Channel 5                       */

  DMA1_Channel6_IRQHandler,     /*!< 16: DMA1 Channel 6                       */

  DMA1_Channel7_IRQHandler,     /*!< 17: DMA1 Channel 7                       */

  ADC1_2_IRQHandler,            /*!< 18: ADC1 & ADC2                          */

  USB_HP_CAN1_TX_IRQHandler,    /*!< 19: USB High Priority or CAN1 TX         */

  USB_LP_CAN1_RX0_IRQHandler,   /*!< 20: USB Low  Priority or CAN1 RX0        */

  CAN1_RX1_IRQHandler,          /*!< 21: CAN1 RX1                             */

  CAN1_SCE_IRQHandler,          /*!< 22: CAN1 SCE                             */

  EXTI9_5_IRQHandler,           /*!< 23: EXTI Line 9..5                       */

  TIM1_BRK_IRQHandler,          /*!< 24: TIM1 Break                           */

  TIM1_UP_IRQHandler,           /*!< 25: TIM1 Update                          */

  TIM1_TRG_COM_IRQHandler,      /*!< 26: TIM1 Trigger and Commutation         */

  TIM1_CC_IRQHandler,           /*!< 27: TIM1 Capture Compare                 */

  TIM2_IRQHandler,              /*!< 28: TIM2                                 */

  TIM3_IRQHandler,              /*!< 29: TIM3                                 */

  TIM4_IRQHandler,              /*!< 30: TIM4                                 */

  I2C1_EV_IRQHandler,           /*!< 31: I2C1 Event                           */

  I2C1_ER_IRQHandler,           /*!< 32: I2C1 Error                           */

  I2C2_EV_IRQHandler,           /*!< 33: I2C2 Event                           */

  I2C2_ER_IRQHandler,           /*!< 34: I2C2 Error                           */

  SPI1_IRQHandler,              /*!< 35: SPI1                                 */

  SPI2_IRQHandler,              /*!< 36: SPI2                                 */

  USART1_IRQHandler,            /*!< 37: USART1                               */

  USART2_IRQHandler,            /*!< 38: USART2                               */

  USART3_IRQHandler,            /*!< 39: USART3                               */

  EXTI15_10_IRQHandler,         /*!< 40: EXTI Line 15..10                     */

  RTCAlarm_IRQHandler,          /*!< 41: RTC Alarm through EXTI Line          */

  USBWakeUp_IRQHandler,         /*!< 42: USB Wakeup from suspend              */  

  TIM8_BRK_IRQHandler,          /*!< 43: TIM8 Break                           */        

  TIM8_UP_IRQHandler,           /*!< 44: TIM8 Update                          */ 

  TIM8_TRG_COM_IRQHandler,      /*!< 45: TIM8 Trigger and Commutation         */

  TIM8_CC_IRQHandler,           /*!< 46: TIM8 Capture Compare                 */

  ADC3_IRQHandler,              /*!< 47: ADC3                                 */

  FSMC_IRQHandler,              /*!< 48: FSMC                                 */

  SDIO_IRQHandler,              /*!< 49: SDIO                                 */

  TIM5_IRQHandler,              /*!< 50: TIM5                                 */

  SPI3_IRQHandler,              /*!< 51: SPI3                                 */

  UART4_IRQHandler,             /*!< 52: UART4                                */     

  UART5_IRQHandler,             /*!< 52: UART5                                */           

  TIM6_IRQHandler,              /*!< 53: TIM6                                 */           

  TIM7_IRQHandler,              /*!< 54: TIM7                                 */           

  DMA2_Channel1_IRQHandler,     /*!< 55: DMA2 Channel1                        */  

  DMA2_Channel2_IRQHandler,     /*!< 56: DMA2 Channel2                        */  

  DMA2_Channel3_IRQHandler,     /*!< 57: DMA2 Channel3                        */    

  DMA2_Channel4_5_IRQHandler,   /*!< 58: DMA2 Channel4 & Channel5             */   

  (void *)0xF108F85F            /*!< Boot in RAM mode                         */

};     

注意在数组的attribute的修饰中,它将函数的位置规定在了section(“.isr_vector”)的位置,而.isr_vector则在ld文件中定义在FLASH开始的地方:

/* The startup code goes first into FLASH */
  .isr_vector :
  {
    . = ALIGN(4);
    KEEP(*(.isr_vector)) /* Startup code */
    . = ALIGN(4);
  } >FLASH

所以显而易见的,在启动后第二个周期里内核读取了复位向量表的地址并跳转了过去,所以单片机的启动代码必然存放于rest vector中,我们在启动文件中找到复位函数:

#pragma weak Reset_Handler = Default_Reset_Handler  

void Default_Reset_Handler(void)

{

  /* Initialize data and bss */

  unsigned long *pulSrc, *pulDest;



  /* Copy the data segment initializers from flash to SRAM */

  pulSrc = &_sidata;



  for(pulDest = &_sdata; pulDest < &_edata; )

  {

    *(pulDest++) = *(pulSrc++);

  }



  /* Zero fill the bss segment.  This is done with inline assembly since this

     will clear the value of pulDest if it is not kept in a register. */

  __asm("  ldr     r0, =_sbss\n"

        "  ldr     r1, =_ebss\n"

        "  mov     r2, #0\n"

        "  .thumb_func\n"

        "zero_loop:\n"

        "    cmp     r0, r1\n"

        "    it      lt\n"

        "    strlt   r2, [r0], #4\n"

        "    blt     zero_loop");



  /* Setup the microcontroller system. */

  SystemInit();



  /* Call the application's entry point.*/

  main();

}

在启动函数中我们可以清晰地看到,在最后一步中,单片机的程序被转入到了main函数的入口,那么在执行main函数之前,C语言,和内联汇编程序干了什么呢?首先头位置的C语言将终端向量表从ROM头位置,复制到了RAM头位置(即:0x20000000),这里在RAM中的终端向量表时间上没有没我们用到,当然这是因为在M3的内核中,它允许用户在NIVC的寄存器中重新定义终端向量表的位置,我们可以使用

NVIC_SetVectorTable(NVIC_VectTab_FLASH,0);

这个函数来将终端向量表设置到到0x20000000位置。该功能实际上是用于方便装有系统的环境中使用,可以加快终端响应的速度,同时可以快速的动态的更改终端处理的程序。当然在我们的应用中并未使用到这一特性,所以此处的复制中断向量表的操作是可以删除的,它在此的作用只是为了防止用户在程序中使用了重定向向量表语句而使得程序跑飞所添加的。因为终端向量是系统最基础稳定性的保证,如果在硬件错误发生等中断发生的情况下单片机无法正确的跳转,会对代码调试和系统稳定运行带来严重的影响。

之后紧跟的这几条汇编代码实现的是:全局变量与静态变量的初始化并将其从flash中调入内存,即在C语言运行全局变量与静态变量的初始化操作。在此之后, SystemInit();函数被调用,配置好时钟等参数。最后我们的main函数就可以执行啦~。

这便是是我们在这个例程中使用的启动文件,而在keil工程中,这个文件是用汇编代码写成的,但这些文件功能都是一样的,设置终端向量表,初始化全局与静态变量,进入main函数,都是这样的流程。在gcc的环境中我们也可以是用汇编编写这样的文件,我们面前的选择有很多,当然我们没必要自己编写这些链接文件和启动代码,在之后的实际的工程建立中我会告诉大家实际的方法。不过在此之前我们还是要先把基础的内容学好再说。我们继续~

其他的说明

在文件中我们看到了_sidata、_sdata等变量,这些变量在文件的前面部分被定义为外部:

extern unsigned long _sidata;    /*!< Start address for the initialization 

                                      values of the .data section.            */

extern unsigned long _sdata;     /*!< Start address for the .data section     */    

extern unsigned long _edata;     /*!< End address for the .data section       */    

extern unsigned long _sbss;      /*!< Start address for the .bss section      */

extern unsigned long _ebss;      /*!< End address for the .bss section        */ 

而该文件却并未包含任何.h文件,那么他们从哪来的呢?细心的同学可能已经注意到了,我们之前提到过,这些变量的定义实际上都来自于ld文件中,他们在ld文件中被定义,最后链接器会将他们转换为实际的地址给我们的程序所使用的。

最后再说一下 attribute ((weak))属性,该属性表面其后的变量或是函数为弱申明,即在没有其他申明情况下调用改函数,而如果其他地方申明了,则会顶替该函数。所以在启动文件中,他们被用来修饰中断处理函为中断向量表提供一个默认的地址,而当用户定义后,就将地址转为用户定义的位置。

总结

说了这么多,这也是我们在这个系列中比较难以理解的部分,因为涉及到了GNU C的特性和计算机编译链接的最基础的部分,还有Cortex-M3内核工作的方式,但是请大家仔细的去理解学习,如果看了这篇文章还不懂那就多查查相关的资料,当你理解并贯通 这些知识时,你会发现原来在单片机上c语言是这样工作的,原来中断系统是这么的重要,你会发现单片机在你的眼前是如此的透彻。

在最后,我们还要说说,其实很多同学目前掌握的都是一个很简单的单片机应用方式,这都是被keil、IAR之流惯坏的,实际上在单片机背后,其实际的工作复杂而又充满着精致的设计,这点我们会在之后的nuttx系统使用中见到。那时你会发现原来我们使用的M3单片机还有这么多的我们之前没用过的中断,原来m3的内核如此强大。对此我推荐大家还是学一遍51单片机的汇编教程,当你理解和使用过汇编后,你会更容易理解未来的讲解内容,同时也更容易理解此篇的内容。当然如果大家有兴趣可以先自己看看由宋岩前辈翻译的Cortex-M3 权威指南,来提前感受一下Cortex-M3内核的魅力。

你可能感兴趣的:(STM32)