【单片机】再深入理解一下单片机

这是一篇面向单片机新手的文章。

本文提及的单片机以STM32F401CCU6(Arm-cortexM4)为例。

但本文对于单片机外设的描述不多,而Arm-cortexM系列内核都比较相似,因此 Arm-cortexM 系列内核的单片机用户都可以参考本文。

本文脉络大致有以下几点:

  • 用寄存器点个灯
  • 其间引出对启动文件以及ARM的C库的讨论(Keil环境下)
  • 如何学习使用寄存器
  • 考虑使用的开发环境(偏主观)
  • 提出一种任务管理框架供参考
  • 拓展一下链接器相关

0 前言

大多数人都了解STM32吧,stm32是32位的单片机,ST就是ST公司,M指的是Microelectronics的缩写,即微电子,32就是指32位单片机。

除了32位单片机,当然也有8位,16位的单片机。有什么区别呢?一般8位单片机能直接处理的数据也是8位的,其寄存器的位数也是8位。而32位单片机有32位寄存器,可以直接处理8位、16位和32位数据,因此位数大的单片机处理数据的能力往往更强。此外,拥有32位的寄存器代表32位的单片机能够最大寻址到(0xFFFF FFFF)地址处,即其地址空间大小为2^32 Byte = 4 GB,这代表其功能远多于较低位的单片机,相应的,也更复杂。

上面提到了寄存器,寄存器是什么呢?其实寄存器可以从字面意思上理解,它们就是暂存数据的,什么样的数据?二进制数据。多大的数据?一般取决于单片机的位数。

寄存器:我们不处理数据,我们只是数据的搬运工。

从编程的角度上说,某个寄存器的某个位都对应某个功能,比如,使能时钟等等。因此,位操作是寄存器编程最常用的编程手段。

一片单片机内部拥有很多寄存器(其物理形式应该是由数字电路实现的,即具有存储功能的 触发器 组合起来构成了寄存器)。

从软件上来讲,我们可以直接对寄存器进行位操作来实现某个功能,如使能通用GPIO、串口等。

现在,ST公司给我们提供了强大的软件CubeMX,利用它,新手可以通过傻瓜化的配置生成基于HAL库函数的各种外设的基本初始化代码,很大程度的将一些麻烦的底层配置与上层应用隔开了。

在没有库函数的情况下,工程师们要通过查阅官方参考手册,通过自己配置寄存器的方式使能各种功能。实际上,对于stm32或是其他的什么单片机,官方的库函数都是对寄存器配置的封装。因此,寄存器编程可以说是单片机编程的底层逻辑,了解它对于理解整个单片机是非常有用的。

下面,让我们从零开始,先手动构建一个简单的例程来理解寄存器编程。

1 使用寄存器来点个灯

一般的,我们使用Keil来讲解。

1.1 新建工程

在电脑的某个位置建一个新的文件夹,作为工作目录

打开Keil,点击新建工程

img

找到你新建的工作目录,然后给你的keil工程起个名字。

img

找到你使用的芯片设备,我这里使用的stm32f401ccu6。

img

点击ok后,会弹出这样的窗口,我们自己通过文件构建程序,就不使用keil提供的组件了,叉掉这个窗口就行。

img

然后,我们来构建keil工程文件,就是manage project items。

img

然后就是构建你的分组(groups),即keil工程中的文件夹,你可以给你的分组取你喜欢的名字,然后要给构建的分组添加文件。

img

1.2 添加启动文件

现在我们成功建立了一个空的keil工程,接下来,我们需要添加单片机的启动文件。

什么是启动文件,顾名思义,就是单片机上电后要开始执行其中的代码(当然是编译后的代码,或者说机器码指令)。要知道,我们对单片机烧录程序后,程序是存在NOR FLASH芯片里的,上电后,cpu就会读取NOR FLASH里的程序执行。

NOR FLASH,即NOR闪存,与之相对应的有个NAND闪存。两种都叫非易失性存储技术,掉电不遗失数据,发明和商用于上世纪80年代。NOR 闪存通常用于程序存储,NAND 闪存通常用于数据存储。

RISC小科普

RISC(reduced instruction set computer,精简指令集),是计算机中央处理器(CPU)的一种设计模式,与之对应的是CISC(Complex Instruction Set Computer,复杂指令集)。嵌入式CPU往往是采用RISC指令集。
STM32使用的arm cortex-M系列的内核,这个内核的版权属于arm公司。实际上,cortex-M内核处理器是ARMv7指令集架构,这种架构本质上也是基于RISC的,实际上基本上所有的单片机的内核都是基于RISC指令集的。

RISC使用的是load-store体系结构,load和store是两个指令,负责存储器(NOR FLASH 或 内存)和寄存器之间的数据交互。CPU要将某个地址的数据放入寄存器中,只能够使用load指令;要将寄存器中的值存放到内存中,只能够使用store指令。值得一提,RISC的CPU不能直接处理存储器中的数据,即想要更改位于存储器某个地址的数据,必须将其读取到寄存器进行操作,再将其放回原地址处。

科普结束,再说启动文件。

启动文件使用汇编语言编写,没接触过的童鞋理解起来可能感到有些复杂。

启动文件主要完成以下工作

  • 初始化堆栈指针
  • 初始化中断向量表
  • 定义Reset_Handler子程序
  • 初始化中断服务程序[weak]
  • 用户堆栈初始化

那么为什么,单片机一上电就会执行启动文件中的代码呢?

其实,说是"一上电就执行启动文件的代码"有点笼统,应该是,一上电,发生复位中断后,会执行启动文件里定义的复位中断程序。从代码执行的角度,这是你单片机工程代码的最初执行部分。(不严谨,目前先这样理解)

如果,你的keil里勾选了reset and run的选项,那么单片机一上电就会自动复位,从而触发Reset_Handler中断程序;如果你没有勾选,那么你就需要手动reset一下,才能正常执行程序。

Reset_Handler中断程序

;startup_stm32f401xc.s
; Reset handler
Reset_Handler    PROC
                 EXPORT  Reset_Handler             [WEAK]
        IMPORT  SystemInit
        IMPORT  __main

                 LDR     R0, =SystemInit
                 BLX     R0
                 LDR     R0, =__main
                 BX      R0
                 ENDP

这是一段汇编子程序,它调用了执行了SystemInit函数(这个需要我们自己定义,或者调用官方写的文件),然后就去调用__main函数。

这玩意和 main 函数不一样,它定义在arm的c库中,具体参考官网的描述

__main()做了以下的事情:

  • 将非根目录(RO和RW)的执行区域从其加载地址复制到其执行地址。
  • 初始化ZI regions
  • 调用__rt_entry()

总的来讲,__main 做了程序正式开始执行前的一些初始化工作。__main 负责执行代码和数据的复制、解压缩,以及 ZI 数据的零初始化。这里涉及了flash与sram之间的数据交换。

__rt_entry()又做了以下的事情:

  • 设置堆栈和堆
  • 初始化引用的库函数
  • 调用main()
  • 用main()返回的值调用exit()

换句话说,__rt_entry设置了运行环境

(源码没找到,官网说啥就是啥(~﹃~)~zZ)

上面简单讲了一下__main和__rt_entry,不过对于新手来讲是看不太懂的。没关系,这里截取官方的Arm® Compiler User Guide里的图做个形象的总结

img

After Reset the CortexM4 processor is in Thread mode,priority is Privileged, and the Stack is set to Main.

再简单的说,__main被调用会引起main函数的调用,main函数大家应该很熟了。

好的,启动文件是什么我们大概有了一定的理解,下面我们就把stm32f401ccu6需要的启动文件添加到我们的工程里来。

那么,这个文件去哪里找呢?

如果你之前使用过这块单片机,那么它早就存在于你的keil目录下了。一般情况下,在使用一块单片机之前,我们需要使用一个后缀名为pack的DFP(Device Family Pack)文件,以便于让keil能够支持你所用的单片机型号,stm32f4xx的DFP包名为Keil.STM32F4xx_DFP.2.xx.0.pack,它在官网是可以下载的,但是通常国内的网络访问它比较慢(或者访问不了),因此一般这个pack包都是学长学姐们给的,感恩????。

(或许你需要一架梯子????,从此如鸟入青天,鱼入大海,再也不受羁绊了...咳咳)

当时你双击这个文件,它会自动识别你keil软件的下载路径,并下载到指定的位置。如图

img

然后,这个名为STM32F4xx_DFP的文件夹下有我们需要的各种文件,启动文件正在其中。其具体位置如图

有了它的路径,我们在keil的工程里添加它就可以了

img

添加完成后,不要忘记点ok来保存哦。(如果你不想费劲翻找启动文件的目录,可以先把它拷贝到你的工作目录下再添加。)

1.3 写主函数

然后,理所当然的,我们要新建一个main.c文件来定义main函数。

img

当然,不要忘记把它添加到keil工程里来,就像前面添加启动文件一样。

然后我们需要自己定义一个函数SystemInit,不然编译会报错的,我们可以先不写内容,然后在main函数里写程序就可以了,内容如下。

#include "stm32f401xc.h"

void SystemInit(void)
{
    
}
// sw——1:led on
// sw——0:led off
static void LED(uint8_t sw)
{
    //IO port C clock enable
    RCC->AHB1ENR |= 0b1<<2U;
    // set GPIOC13 as General purpose output mode,即通用输出模式
    GPIOC->MODER &= 0b00<<26U ;  
    GPIOC->MODER |= 0b01<<26U;
    //set GPIOC13 as Output push-pull mode,即推挽输出模式
    GPIOC->OTYPER &= 0b0<<13U;
    GPIOC->OTYPER |= 0b0<<13U;
    //set GPIOC13 output speed as Low speed mode ,即最大8Mhz
    GPIOC->OSPEEDR &= 0b00<<26U;
    GPIOC->OSPEEDR |= 0b00<<26U;
    //set GPIOC13 as pull-down mode,即下拉
    GPIOC->PUPDR &= 0b00<<26U;
    GPIOC->PUPDR |= 0b10<<26U;
    //set GPIOC13 as high/low state, 即设置高低电平
    GPIOC->ODR &= 0b0<<13U;
    GPIOC->ODR |= (sw == 0) ? (0b1<<13U) : (0b0<<13U);
}
int main(void)
{
    LED(1);
    while(1);
}

这里的程序使用了官方的寄存器定义头文件,该头文件又包含了其他的各种头文件,我找了找,在不报错的情况下,要添加以下几个头文件,感兴趣的同学可以看看它们的注释,了解一下大概的作用,这几个文件也包含在keil安装目录下,因keil版本的不同,有的文件名的数字编号可能不同,但是目录结构都是一样的,找一找就能找到。

文件名:stm32f401xc.h 
目录:D:\keil\keil\packs\Keil\STM32F4xx_DFP\2.16.0\Drivers\CMSIS\Device\ST\STM32F4xx\Include
简介:CMSIS STM32F401xC Device Peripheral Access Layer Header File.主要定义了寄存器结构体和一些宏定义

文件名:core_cm4.h
目录:D:\keil\keil\packs\ARM\CMSIS\5.9.0\CMSIS\Core\Include
简介:CMSIS Cortex-M4 Core Peripheral Access Layer Header File,与cortex-M4内核有关

文件名:cmsis_version.h
目录:D:\keil\keil\packs\ARM\CMSIS\5.9.0\CMSIS\Core\Include
简介:CMSIS Core(M) Version definitions,内核版本相关

文件名:cmsis_compiler.h
目录:D:\keil\keil\packs\ARM\CMSIS\5.9.0\CMSIS\Core\Include
简介:CMSIS compiler generic header file,编译相关

文件名:cmsis_armclang.h
目录:D:\keil\keil\packs\ARM\CMSIS\5.9.0\CMSIS\Core\Include
简介:CMSIS compiler armclang (Arm Compiler 6) header file,编译器相关

文件名:mpu_armv7.h
目录:D:\keil\keil\packs\ARM\CMSIS\5.9.0\CMSIS\Core\Include
简介:CMSIS MPU API for Armv7-M MPU,内存保护单元接口

找到这几个文件,最好复制下来黏贴到你的工作文件夹,接下来方便添加,keil的头文件的添加方式与.c源文件的添加方式不太一样,方法如下,添加头文件所在的文件夹即可。

img

如果你发现,你的keil文件上有个金色的钥匙????标记,这是因为该文件被设置为只读,如果你想要更改该文件,需要更改文件的属性,将只读的对号取消掉即可。

img

1.4 一个有趣的bug

最后,我们勾选keil的MicroLIB(因为不勾选似乎进不了main函数),我也不太清楚这个的原因,欢迎一起讨论,或者请大佬教我????。

img

附上不勾选时的调试图,调试直接卡在这里

img

好吧,这个问题还是我来回答吧。首先感谢这位大佬写的记录

首先,我们要知道为什么使用MicroLIB没问题,不使用MicroLIB就有问题

Arm的运行库一般可以使用两种,如果不勾选MicroLIB,那么就默认使用StandardLIB,官方的描述看这里

MicroLIB和StandardLIB在初始阶段的__main和__rt_entry有些许不同。

在不勾选MicroLIB时,即使用StandardLIB时,如下图

img

一般工程是默认启用FPH(Floating Point Hardware)的,这是CortexM具有的浮点数加速单元,

The Cortex-M4 / M7 / M33 / M35P / M55 cores have an FPU silicon option

这在StandardLIB中具体体现在以下代码

                 __rt_lib_init_fp_1:
0x080001EE F000F8BE  BL.W          0x0800036E _fp_init
;....
0x0800036E EEF10A10  VMRS          r0,FPSCR

VMRS 表示将FPSCR(floating-point status and control register)寄存器的值赋给r0寄存器,这个操作是需要FPU协处理器的权限的,然而此时,我配置的工程没有配置这个权限!因此会发生错误,进入HardFault_Handler硬件错误中断。所以也就走不到main函数那一步了。

那么,如何解决这个问题呢?

  • 第一点,可以勾选MicroLIB库,不使用默认的StandardLIB,上面说到MicroLIB和StandardLIB在__rt_entry的实现方式是有区别的,在MicroLIB里__rt_entry是不需要对FPSCR做操作的,那它对于FPH是如何操作的呢,这个我也不太清楚,可以肯定的是,在__main和__rt_entry中,它没有对FPSCR寄存器的操作。
  • 第二点,我们依然不勾选MicroLIB,直接将FPH(Floating Point Hardware)设置为Not Used,这样__rt_entry就不会对FPSCR做操作了,程序也就不会出错。
  • 第三点,也是最正确的方式,一般情况下,我们难免需要使用浮点数的,因此FPH是需要的,同时我又不太想使用MicroLIB,那么我就需要对FPU相关寄存器做操作,即使能FPU协处理器的权限。这时可以参考官方代码如下:
//system_stm32f4xx.c
/**
  * @brief  Setup the microcontroller system
  *         Initialize the FPU setting, vector table location and External memory 
  *         configuration.
  * @param  None
  * @retval None
  */
void SystemInit(void)
{
  /* FPU settings ------------------------------------------------------------*/
  #if (__FPU_PRESENT == 1) && (__FPU_USED == 1)
    SCB->CPACR |= ((3UL << 10*2)|(3UL << 11*2));  /* set CP10 and CP11 Full Access */
  #endif

#if defined (DATA_IN_ExtSRAM) || defined (DATA_IN_ExtSDRAM)
  SystemInit_ExtMemCtl(); 
#endif /* DATA_IN_ExtSRAM || DATA_IN_ExtSDRAM */

  /* Configure the Vector Table location -------------------------------------*/
#if defined(USER_VECT_TAB_ADDRESS)
  SCB->VTOR = VECT_TAB_BASE_ADDRESS | VECT_TAB_OFFSET; /* Vector Table Relocation in Internal SRAM */
#endif /* USER_VECT_TAB_ADDRESS */
}

之前我们写了一个空的 SystemInit 让 Reset_Handler 调用,其实是有一定的失误的,看官方的SystemInit的写法,就是考虑到了FPH的使用,具体就是使能SCB->CPACR寄存器。因此再调用__main,__rt_entry,对FPSCR就有操作权限了✔。

因此,既然官方都写好了,我们就把system_stm32f4xx.c文件添加到我们的工程目录即可,这样也就不必自己写 SystemInit 了(虽然你把它复制黏贴也可以),仔细看一下,官方函数还可以选择设置中断向量表的位置,虽然我们没有用就是了。

总结一下,所遇的bug是由于 SystemInit 里没有对FPU的设置,导致arm的运行库StandardLIB在运行__rt_entry的某操作时没有权限,发生了硬件错误❌。

有关更多MicroLIB和StandardLIB的信息,可以参见这篇博文,里面的图很形象。

1.5 编译下载

如果操作正确的话,一般编译是没有问题的,然后下载即可

img

1.6 效果

img

2 学会看官方参考手册

目前的大多数主流单片机都有很多教程,但是最好最准确的教程往往是官方的手册。

这些手册就在DFP包的Document文件夹里,如图

img

一般它们的文件名都是一些编号,建议将其改成你能认识的名字。当然,你也可以在ST的官网找到它们。

我们想要了解怎么使用寄存器,就要看官方的参考手册,想要了解该单片机的硬件数据,就看官方的数据手册,想要了解官方改过哪些bug,就看官方的勘误手册。当然,它们都是英文的。除此以外,ST官网也可以找到更多的手册,例如AN开头的应用笔记(有中文版),大家可以去自行探索。

例如,还记得我上面写的点灯程序吗,我们要首先使能GPIOC的时钟,在参考手册中是是这样描述的。

【单片机】再深入理解一下单片机-小白菜博客
img

类似的,也有GPIO的各种寄存器描述,大家可以结合代码,自行去了解一下。

3 实际编程

3.1 使用库函数

作为较为底层的编程方式,寄存器编程是一种很好的学习手段,但是对于不了解的新手,实际项目中使用寄存器配置外设往往bug奇多,既然官方封装好了库,那我们为什么不用呢。因此,现阶段,在实际应用中,我们一般使用封装好的库函数(以stm32为例,官方库函数有HAL库 or LL库 or 标准库)和寄存器编程相结合的方式编程。

ps:标准库正在被 HAL 库和 LL 库取代,作为一个学习技术的人,我当然推荐学习使用前景更好的库。

3.2 开发环境

(主观成分居多,仅供参考)

开发环境包括了编辑器、编译器等开发工程代码的一系列工具。

Keil MDK 是入门arm系单片机最简单的集成开发环境IDE(Integrated Development Environment),也是arm的官方IDE,目前已到5.37版本,内部集成了Arm C/C++ Complier编译器,可以方便地下载调试程序,并且综合了各种芯片,功能非常强大。缺点是其编辑界面十分地复古,换句话说就是有些丑。

因此可以使用自己喜欢的代码编辑器,推荐VScode。这也是我一直在用的。当然,这依然有些不优雅,因为这样就需要同时打开VScode和Keil,并且调试时需要来回切换界面。

如果想要更优雅的开发,选择是有很多的,针对STM32系列的开发方式网上的方法最多。

比较不错的有Clion开发,Clion是一种C/C++开发环境(软件大小比Visual Studio要小),依赖CMake。它也有丰富的插件支持各样的功能,其优秀的代码补全机制非常香。利用Clion开发STM32主要是依赖STM32CubeMX生成SW4STM32环境(该环境是跨平台的),其中有基础的底层配置,其编译需要依赖Arm gcc编译器,下载需要使用OpenOCD。如果你对优雅的开发环境有需求的话,可以使用Clion。个人体验了一下确实不错。

PS:新下了最新版的STM32CubeMX(6.6.1),竟然没有生成SW4STM32的选项!ST啊,路还是走窄了????。什么?你说还有STM32CubeIDE,那是什么????。

另外,也有使用VScode里的EIDE插件做开发的,依赖的也是Arm gcc编译器。感兴趣也可以去使用。

我最近听说了一个插件,叫PlatformIO IDE。Clion和VScode都可以安装。这个插件集成了不少芯片,ST,TI的一些芯片都有,可以说是很强大了,感兴趣的可以了解下。

4 利用定时器管理任务

现在讨论不移植RTOS(实时操作系统)的情况下,如何更高效地在裸机上进行编程。

一般情况下,单片机编程都是顺序编程+中断的方式,因此,我们要在一个while死循环中编写程序,让主程序处于一种循环状态,避免main函数return结束程序。

合理有效的代码框架能够让我们对我们的程序有更好的把控力(大概)。

下面,我来介绍一种可以实现任务类并行的一种编程方式,名为简易任务调度器,使得各任务之间一定程度上实现了松耦合。(听起来高大上,其实和裸机编程没区别,只是提供一个框架)。

这个框架本质上是基于时间判断(or 函数判断)的轮询任务调度。没有优先级,信号量等概念,因此不能称之为操作系统。

既然要对任务进行管理,就必须知晓任务在何时执行,因此需要设置一个定时器中断专门用来定时,以此计算时间。

单片机上的定时器十分滴珍贵,能发挥出很大的功能,如PWM,输入捕获等等。因此我们尽量不使用单片机的通用定时器和高级定时器作为时基。

幸运的是,官方介绍了一个16位定时器,名为SysTick,它是cortex-M内核带有的一个特殊定时器,我们就用它来做时基。实际上,许多RTOS的时基也是基于SysTick定时器的。

在HAL库中,我们最开始的初始化函数就是HAL_Init(),这里面有SysTick定时器的初始化,默认1ms发生一次中断。有关更多的SysTick信息,可以参考官方cortex_m4的文档。

这个任务调度框架的开发环境是Keil,我放在了GitHub,点这里访问。整体并不复杂,希望可以给你提供一些思路。

(访问GitHub是技术人的必要技能-.-

5 链接相关

拓展内容(仅供参考)

5.1 概述

链接负责把编译生成的目标文件(.o)组合成可执行的机器码。

作为一个可编程硬件,其中程序必须有存储位置,并且在运行时能够被CPU找到并执行,而不同的硬件对于存储地址有着不同的定义,因此在烧录程序前,还需要对程序的机器码进行重定向。

重定向操作是链接操作的一部分。

我们要知道,在不调用子程序和执行跳转指令的前提下,代码是一个接着一个执行的(从存储器空间地址上来讲就是一个接着一个执行的),因此,如何定义程序加载时的执行地址就成了一个重要的任务。

重定向保证了烧录程序后,程序在flash和sram中执行和存储的合理性和可执行性。

链接完成后,一般会生成一个.map文件,这个文件描述了程序中各个组份的大小和映射地址,感兴趣的可以去看看。

下面专门讨论链接中的重定向问题

5.1 Scatter

链接一般是由链接器在完成的,在 Keil 的 arm C/C++ Complier 中的链接器叫armlinker,armlinker使用一种叫做分散加载(scatter)的机制来分配数据和代码。

具体体现在Keil中,就是以.sct后缀名的文件。scatter详见官方描述

一般默认情况下,该文件由Keil在编译阶段自己生成在Objects文件夹下,如果想要自行分配你的程序地址,需要在Keil设置中Linker选项下指定你的sct文件。

该文件为链接器提供了进行分散加载的指引。链接完成,烧录完成,代码开始运行时,不同的分散加载文件对arm的C库有不同的影响,具体的影响出现在初始化阶段,即影响 main 函数之前的 __main 函数。

需要分散加载的东西有以下几点:

  • Code 段:程序代码部分
  • RO Data 段:程序定义的所有常量以及const类型数据
  • RW Data 段:已经初始化的所有静态变量
  • ZI Data 段:未初始化的静态变量

程序烧录完成未运行时,程序存储在flash中。程序运行阶段,程序会被搬移到不同的地方,该操作由__main函数完成,(RW Data + ZI Data)会占用sram,(Code + RO Data + RW Data)会占用flash。

下面就是Keil默认生成的stm32F401CCU6的sct文件。

; *************************************************************
; *** Scatter-Loading Description File generated by uVision ***
; *************************************************************

LR_IROM1 0x08000000 0x00040000  {    ; load region size_region
  ER_IROM1 0x08000000 0x00040000  {  ; load address = execution address
   *.o (RESET, +First)
   *(InRoot$$Sections)
   .ANY (+RO)
   .ANY (+XO)
  }
  RW_IRAM1 0x20000000 0x00010000  {  ; RW data
   .ANY (+RW +ZI)
  }
}

先分析一下文件结构

img

来写一下注释吧

首先我们先把STM32F401CCU6的存储器的映射地址贴出来

img

实际上,上电后,程序最最开始是在 0x0000 0000 地址处开始执行的,然后是由不知道是啥的程序跳转到我们自己的程序。那么 0x0000 0000 地址处到底是什么程序呢?这个是用户不可控的,以我目前的理解是与单片机原装的boatloader程序有关,如果你了解,还请你教教我(_)。

注释如下

; xxx.sct
; *************************************************************
; *** Scatter-Loading Description File generated by uVision ***
; *************************************************************
;LR_IROM1是加载地址空间,即下载程序的地址
LR_IROM1 0x08000000 0x00040000; load region size_region
                              ; 0x08000000 表示你的程序在存储器的起始地址,
                              ; 0x00040000 表示你的程序在存储器占用的最大地址空间
                              
{  
  ;ER_IROM1是执行地址空间,即运行程序的地址空间。
  ;注意,程序存储地址和程序运行地址是不同的概念。
  ER_IROM1 0x08000000 0x00040000; load address = execution address
                                ; 0x08000000 表示你的程序的执行起始地址
                                ; 0x00040000 表示你的程序在执行时占用的最大地址空间

  {  
   *.o (RESET, +First);RESET是个区域的名字,你可以在启动文件找到它的定义,它定义了一些数据,包括中断向量表以及向量表的大小,+First表示首先加载,即中断向量表位于加载地址的首位。
   *(InRoot$$Sections);加载ARM相关C库,取决于你使用MicroLIB还是StandardLIB
                      ; All library sections that must be in a
                      ; root region, for example, __main.o,
                      ; __scatter*.o, __dc*.o, and * Region$$Table
   .ANY (+RO);加载其他任意文件的 RO 区域 
   .ANY (+XO);+XO is for execute only access
  }
  ;RW_IRAM1是执行地址空间,即运行程序的地址空间,RAM里一般存储一些需要经常修改的变量
  RW_IRAM1 0x20000000 0x00010000 ; 0x20000000 表示你的程序的执行起始地址 
                                 ; 0x00010000 表示你的程序在执行时占用的最大地址空间
  {  ; RW data
   .ANY (+RW +ZI);加载其他任意文件的RW 区域 和 ZI 区域
  }
}

当然这只是Keil自动生成的一个sct文件,各种程序都是没有特地的分配地址,通常情况下,使用自定义sct文件的场景并不多,至少我是没用过。

但是,当你去做一个产品时,sct文件一般是必要的,原因可能有以下几点:

  • 用户程序的升级需要Boatloader程序,这时需要调整sct以避免Boatloader程序和用户程序之间的地址冲突
  • 加快程序运行速度,对程序执行速度要求高的的算法可以设置到sram中去执行
  • 扩展存储,如果使用外扩存储器运行代码,则需要自定义sct

5.2 ld

以上的分散加载,包括前面的启动文件,我们都是在arm C/C++ 编译环境(Keil环境)下讨论的。这也是我相对熟悉的环境。

但是,我们要知道,arm不仅提供了 arm C/C++ Complier 环境,还提供了 GNU 的编译环境。这个GNU环境的名字叫 GNU Arm Embedded Toolchain。

GNU的编译环境在高级语言方面与arm C/C++ 没有什么区别,但是在相对底层的汇编语言,他们的指令集大不相同,以启动文件这一汇编文件来举例,可以说两种编译环境下,启动文件的功能基本相同,但实现语法大相径庭。

GNU编译环境是跨平台的编译环境,泛用性很高,这也是我在 3.2 节介绍的除Keil外的开发环境使用 arm gcc 编译器的原因。换句话说,就是因为它好移植。

类比于arm C/C++ Complier中的sct文件,GNU编译环境使用ld后缀名的文件为链接器提供指引。GNU链接脚本文件的语法我还没有理解透彻,感兴趣的可以自行浏览官方文档

6 结语

以上,我们以STM32F401CCU6为例,在Keil环境下讨论了单片机的寄存器编程,启动文件中的复位中断,Arm的C/C++运行库,提出了一种基于SysTick的任务调度方式,最后,我们也浅谈了一下链接器相关的内容。

笔者接触单片机两年时间,忙着参加一些比赛,一直没有对单片机底层知识做过多的探究,学习也基本上是囫囵吞枣。现在借着写博客这个契机,对于单片机的某些底层做了一个简单的探究。

其实,在大多数的应用场景下,大家更多的是对于单片机外设的学习和使用,如GPIO,串口,IIC,定时器等等,很少了解一些冷门的底层知识。

因此,有些童鞋可能看不懂我写的一些东西,这也是正常的,想到两年前我也是什么都不懂的小白。这不影响你继续学习单片机。当你以后遇到这方面的问题,再回来看看,也许会获得某些启发。

在学习的过程中,参考他人的博客是最常用的学习方式。但是,说实话,国内的博客环境不太好,一大堆的复制品。大家不要盲信别人的博客(当然也包括我的),因为我们都是一些学习者,非专业人士,出错是难免的,一定要有自己的认知。有可能,尽量去找官方文档,或者官方社区。

嵌入式学习确实是个大坑,对软硬件知识都有一定的要求。除了单片机,大家以后还可能接触到嵌入式系统计算机,主流的有ARM-CortexA系列为内核的树莓派,Jetson Nano等等。这些东西和单片机的最大区别在于,他们可以跑非实时操作系统,一般是Linux系统。这些嵌入式系统的编程方式与单片机有很大的区别。

不管是啥,嵌入式的学习大多都是自学,比较有效的学习方式是参加各类比赛,做一些小项目,复刻别人的开源项目。

说这么多,主要是讲讲我对于嵌入式学习的一些理解,才疏学浅,如果你能够学到一点东西,那我这篇文章就是有意义的????。