前言
- 受众:本教程主要面向STM32单片机初学者,最好对STM32有初步的了解并且使用过C语言进行过最基础的代码编程的用户(至少得用C写过点灯程序吧😀)。
- 目标:主要是希望通过这一系列教程,让大家可以自己从零开始设计二进制代码并烧录到STM32硬件上,完成点亮LED灯的程序。同时也希望能够在编写二进制代码执行的过程中让大家对cpu的执行流程和STM32的启动过程有更深层次的了解,对汇编程序以及汇编过后的二进制机器代码指令有深刻的认识。
- 说明:本系列教程主要通过查阅ARM架构芯片文档来手动写0和1的二进制机器指令代码,过程繁琐且可读性差,一般不用来进行正式项目的开发,本教程主要用于学习芯片底层启动和执行流程。
大白话:“就是给那些以前用C写过STM32简单的程序的初学者看的,然后想知道STM32底层是怎么执行自己写的那个代码的,想看自己写的C经过编译汇编链接后长啥样的,还有就是可以让人跳过配置一堆keil的固件和头文件库函数这些乱七八糟的东西,直接给芯片上0和1的硬菜,直接跳过c到汇编,汇编到机器码,直接一步到位机器码
”
后续计划:“机器语言编程毕竟只是异类,正常不可能用这个来编程,所以后续我会继续出个教程,也是摈弃掉初学者 不知其所以然 就导入的那些固件包、头文件库函数这些东西,也是先撇开keil这类臃肿的IDE。手把手使用记事本编写 C程序 、STM32启动文件 、还有 ld链接脚本 ,再搭配 GNU的交叉编译工具链arm-none-eabi-* 来生成STM32的可执行文件并烧录运行---当然还是点灯...
”
(其实该教程机器指令编程类似于汇编指令编程(●’◡’●),基本算是手动汇编了汇编代码让其变成二进制机器码)
准备工作
在进行代码编程之前,我们需要准备相关的文档和对应软硬件工具,本教程使用的MCU是STMF103C8T6,因为使用的是基于Cortex-M3内核的机器指令进行编程,所以同样也适用于其他使用Cortex-M3内核的MCU(仅限指令!),存储位置和外设等信息不同需要参考对应MCU的文档!
- STMF103C8T6芯片(LQFP-48封装) —自己有开发板直接用开发板就好
- 一个LED灯和电池以及基础元件(电容电阻之类的) —自己有开发板直接用开发板就好
- 编写代码的工具(记事本也行)
- ARMv7-M的体系结构参考手册
- STM32F10xxx参考手册
- STM32F10xxxFLASH编程手册
- STM32自举程序文档
认识Cortex-M3
因为我们后续要编写机器码指令是直接针对硬件的,也就是我们直接对CPU编程。所以至少得了解该CPU的一些基本信息,其实网上本来就有很多介绍Cortex-M3内核的文章了,这里也不细讲了。
现在CPU的架构主要有几种:X86、ARM、RISC-V、MIPS,X86一般就是我们日常使用的电脑的cpu主要架构,而ARM架构一般用于移动设备,比如手机、平板、嵌入式终端等,本次教程使用的STM32F103C8T6就是基于Cortex-M3内核的一款芯片。而Cortex-M3经常用于嵌入式设备。
Cortex-M3的模块框图如下:
(上图模块组件可以不用看😂)只要了解M3内核是通过ICode,DCode从外部主存获取指令和数据,并且在内核里面进行运算,然后把结果通过系统总线存入内存。
所以电脑为什么叫计算机,本质都是cpu核心在不断的进行数据计算,所以我们知道cpu运算简单来说就是:从存储区拿要计算的数据和要使用的计算符号(加减乘除位逻辑运算等等),在cpu核心内部通过诸如加法器运算器进位器等等运算单元进行计算后再输出数据到特定的区域。
举个例子比如1+2=3,内核从外部拿到“操作数” 1、2, 拿到“操作码” + ,然后经过内核运算单元计算后得到3。我们本次所要写的机器指令其实就相当于【1+1】,然后让cpu内核取我们这个指令并运行来完成功能。
大白话:“Cortex-M3就是STM32F10xxx系列使用的CPU内核,M系列在ARM架构里性能功耗啥的都比较低,都是嵌入式设备使用。和手机上的A系列相比性能肯定不行啦,但是实时性应该更好一点?毕竟简单,响应中断应该很快。然后我们指令啥的怎么写呢,就只看ARM-M3的内核说明文档就行啦
”
美美的文档看这 -> ARMv7-M的体系结构参考手册
M3的寄存器
在编写机器代码之前,我们需要了解一下寄存器,因为这是我们后续编码中必不可少的组件。这里说的寄存器是Cortex-m3内部存在的并且可以让我们使用的寄存器,m3有16个常用的寄存器,分别是R0-R15:
- R0-R12:通用寄存器,主要用来保存我们cpu运算过程中需要用到的临时数据等,后面我们写机器代码经常要用到的,比如从内存或者从FLASH等内核外部加载数据都是需要这些通用寄存器先保存的,而且大部分机器指令也都是用来操作这些寄存器。
- R13:R13是栈指针寄存器,该寄存器分成两部分,分别是主栈指针MSP、进程栈指针PSP,这个栈指针也很重要,但是我们本次教程不使用
- R14:链接寄存器LR:当调用一个子例程时,返回地址存储在链接寄存器中。
- R15:程序计数器PC:通常是保存要运行的指令的地址,该寄存器会在获取执行指令后自动加1字(其实就是程序自动按顺序往下执行指令),这个寄存器特别重要,理解它就是理解cpu运行程序的流程,我们可以通过设置该寄存器的值来达到程序自定义跳转的目的。
注:其实我们最终编写的点灯程序只涉及到了R0-R12的其中几个通用寄存器,相当于把寄存器当成变量来使用,所以这部分内容有这个程度了解就可以了(●ˇ∀ˇ●)
大白话:“通用寄存器就是我们写那些指令的时候要用到的,比如我从内存加载一个数据,我就先写一条指令把这个数据保存到一个寄存器里,方便下一条指令能通过这个寄存器访问到我刚刚的那个数据。这里说的寄存器是指的m3内核里面的寄存器,是cpu可以直接访问到的,和我们看stm32文档里那些外设寄存器不是一回事,外设的那些寄存器只是映射在地址空间里,需要我们通过地址空间来操作的。内核的寄存器只有我上面列出来的这个16个和一些我们不常用的特殊寄存器
”
THUMB-2指令
Thumb指令是基于ARM指令的16位指令,其实就是ARM指令抽出来一些指令简化成16位的。而Thumb-2是由Thumb延申而来的,可以同时支持16位和32位指令。
m3使用的指令集就是Thumb-2,而这些指令其实就是汇编指令,汇编指令经过汇编器汇编后就转化为机器可以识别的二进制机器代码。
而我们本教程是直接编写机器代码,所以不需要汇编器和汇编这个环节,但是我们得了解一些基本的汇编指令,这样可以方便我们通过文档找到对应的机器指令(因为机器指令可读性不友好,所以汇编符号其实就相当于机器指令的助记符,帮助理解机器指令的,当然也有很多汇编指令是多个机器指令的集合)。Thumb-2指令是2字节(半字)对齐!!
,这个对于后续的生成可执行文件很重要,所谓的内存对齐或者说字节对齐,其实就是在存储空间的存放位置应该是怎么样的。
比如我们后面生成了一串代码,要烧录到芯片上,如果按2字节对齐去存放,就是0x02 0x04 0x06这样放。
【M3内核体系结构参考手册】指令例子图:
上面直接说到指令,可能很多人就开始不理解了“我们进来是要直接写机器代码点亮灯的,你扯那么多百度上都有的干嘛!!”,其实我也想过教程一开头就扯很多概念描述会不会不友好,会不会劝退很多人。但是后面想一想,这些都是需要了解的常识,况且我们教程是机器代码编程,都深入到机器码这么底层了,不先把一些基本的硬件和指令概念说明一下,后面的编码教程会很难进行下去/(ㄒoㄒ)/~~
大白话:“其实就是M3使用的是Thumb-2指令集,这个指令集支持16位和32位的指令。然后不管是32位还是16位的指令,Thumb-2都是2个字节对齐(就是半字对齐),比如把我们写的指令放到FLASH上的时候也是要按照半字来存放的,我们32位指令先提取16位(半字)按小端序的方式存到FLASH,再继续提取下一个半字...同样的,CPU在取指的时候其实也是半字获取,当获取到前16位指令如果是0b11101、0b11110、0b11111开头,那代表这个指令属于32位指令,CPU会再取后16位数据组成一个32位指令
”
上面大白话好像也不太白,初学估计也有点难懂,也可能是我没法描述清楚…但后续的编写并烧录机器指令的过程应该就会慢慢明白了
机器指令
这里所说的机器指令是可以直接被ARM内核取指译指并执行的二进制机器代码。在编写机器代码时我们可以通过 ARMv7-M的体系结构参考手册 查看各个功能的机器指令。
机器指令一般包括条件码、操作码、操作数等。直接使用机器代码编程,我们可以直接提供给cpu进行执行,省略了c语言编译汇编等步骤😄。我们的目的就是编写Cortex-m3内核能够识别执行的机器指令,并把这些代码直接转化成真正的二进制文件烧录到硬件里。这里不细讲cpu的取指译指执行等操作了。
大白话:“就是我们后需要写的0和1组成的代码(其实使用16进制代表二进制更加方便^_^)
”
cpu内核执行
我们需要清楚cpu是怎么执行程序的,至少应该知道大概的执行流程,不要求知道具体各个cpu内部硬件组件如何进行处理,但得有个模糊概念就是:
m3内核根据程序计数器PC
里保存的指令地址,通过地址从外部存储器(FLASH或SRAM等)加载指令(就是我们自己写的机器指令)和数据,一般是通过ICode、DCode总线。程序计数器PC
在指令加载进来后准备执行之前会由系统自动顺序递增,所以才可以执行完当前指令后按顺序执行一下条指令。同样的,如果我们写的指令包含有操作程序计数器PC
的情况,比如设置一个新的地址到PC寄存器,则下一次会去这个新的地址获取指令执行,这就相当于我们高级语言里的函数跳转或者条件跳转等。
cpu从存储器获取到我们的指令之后,会根据指令内容调用对应的运算单元或者寄存器来处理数据或是通过系统总线访问和操作外设等。
其实到这里大家应该都明白了cpu执行的基本运行流程(当然是针对我们简单的点灯程序来说,实际的执行流程更复杂,例如更具体的流水线取指,解码和执行等,还有一些特殊寄存器用来控制操作模式或者选择栈指针等就不叙述了)
大白话:“我们用0和1写机器代码,然后通过串口等方式写入到芯片的FLASH或者RAM里面,然后CPU根据PC计数器来一直从FLASH或RAM取出我们写的代码,只要我们的代码里有操作某个IO口电平的,就能点亮我们的LED灯啦
”
到了这里又有个问题,就是cpu或者说pc计数器怎么知道我的代码在FLASH的哪个位置呢?或者CPU默认是从哪个位置开始执行呢?
,下面会有解答,就是根据不同的启动模式来从哪个地方读取代码和复位启动。(其实都是从0x0000 0000开始😎)
STM32F10***
本教程使用的单片机是STM32F103C8T6,该型号具体的配置我就不多说了,应该是初学者学习STM32最热门的型号了。介绍到这一步,我们需要了解一下STM32F103C8T6这款MCU的启动流程。
启动模式
打开《STM32F10xxx参考手册》,如上图,我们发现STM32F10xxx有三种启动模式,需要通过配置BOOT0,BOOT1引脚的高低电平来选择使用哪一种启动模式
- 从主闪存启动:主闪存代码地址从0x0800 0000开始,但是也可以从启动地址0x0000 0000开始访问
- 从系统存储器启动:代码地址从0x1FFF F000开始,但是也可以从启动地址0x0000 0000开始访问 (我的是非互联型产品)
- 从内置SRAM启动:启动地址从0x2000 0000开始
因为m3内核实际都是从0x0000 0000开始运行的,但是系统给我们做了映射,它会把代码区的内容映射到0x0000 0000起始的位置。
我们先看第一种启动模式,因为FLASH的地址是从0x0800 0000开始的,我们写入的FLASH的代码也是从这个地址开始,当我们选择这一种启动模式之后,系统会把0x0800 0000开始的数据映射到0x0000 0000开始的地方。
这就相当于系统虽然都是从0x0000 0000开始运行,但是因为做了映射到0x0800 0000,所以程序是从我们写的代码那里开始取数执行的。
同理第二种模式也是把0x1FFF F000开始的地方映射到了0x0000 0000开始的地方,但是我们烧录自己的程序的时候通常是不往这个区域烧录的,因为系统存储器区域默认被厂商固化了一个【自举程序】,这个自举程序可以用来和我们的上位机进行串口通讯,主要是用来烧录用户自己的程序。
第三种是从SRAM启动,启动地址直接从0x2000 0000开始,程序是保存在内存RAM里的,断电就会消失,主要用来调试。
大白话:“我们要烧录写的代码到STM32板子上的时候就要把BOOT0引脚输入1,BOOT1输入0,进到 系统存储器启动 的模式;然后我们烧录完后要运行我们的代码就把BOOT0引脚输入0 进到 FLASH启动模式。
”
启动流程
m3内核都是从0x0000 0000开始执行,但是0x0000 0000都是要写入SP栈顶地址的,从0x0000 0004取地址值给PC计数器,开始执行指令,而且这些地址固定给了中断向量表。
通过文档我们发现, 0x0000 0000作为保留地址(起始就是赋值给SP作为栈顶地址), 0x0000 0004这个地址被固定为Reset,其实就是复位中断函数的地址。而0x0000 0008和0x0000 000C分别是不可屏蔽中断函数和硬件失效中断函数的地址。
也就是说,我们写程序时,0x0800 0000开始可不能乱写,要按中断向量表的要求来写,比如0x0800 0004我们没有写入复位函数的地址,那系统将无法正常复位执行函数(一般复位中断函数就是我们的程序启动函数)。或者如果0x0800 000C我们没有按照中断向量要求写入硬件中断函数地址,而是随便写了一些代码指令,那当系统发生了硬件问题触发硬件失效时,将会从0x0800 000C拿出写的那些代码指令当成地址跳转,这可能会导致系统崩溃。
以STM32F103C8T6为例,文档给出的中断向量表大小是从0x0000 0000到0x0000 012C,如果可以还是尽可能不在这个地址范围写入我们自己的不属于中断函数地址的数据。当然作为简单的点灯学习例子,吭哧吭哧往里写其实也没太大问题(只要能点灯就行🤭)
大白话:“STM32刚启动都是从0x0000 0000处获取栈顶值给SP指针,然后继续从下一个地址0x0000 0004获取复位中断函数地址,然后跳转到对应的复位中断函数地址执行...我们的代码都是写入到例如0x0800 0000这个地址开始的,但是stm32会自己把0x0800 0000映射到0x0000 0000。所以我们的代码就是 把栈顶地址放到0x0800 0000,把启动函数的地址放到0x0800 0004,在启动函数的地址下面存放点灯的机器指令。最后烧录运行,完成🆗
”
本章总结
本章主要是初步了解Cortex-M3内核的一些基本情况,包括m3常用的一些寄存器以及m3支持的thumb-2指令集信息,这些都是后续编写机器指令代码的基础。同时也大概描述了CPU的取指令和程序计数器的作用,帮助理解机器指令是怎么被获取并运行的,又是怎么进行分支跳转的。
后面也说明了STM32的相关启动模式和启动流程,主要目的是了解STM32F103C8T6是怎么运行的,我们的代码应该放在哪个位置才能被机器运行,包括中断向量表的第一第二项都放些啥内容,启动又是从哪个地方开始的。
下一章我们开始进入机器码的世界,通过查阅ARMv7-M的参考手册,开始一行一行的编写机器指令。
注:本章没有介绍存储器映射、内存地址空间这些概念,但是这部分很重要,正常STM32的初学者应该都要了解。32位芯片的地址空间(寻址空间)是0到4GB(0x0000 0000到0xFFFF FFFF),任何外设、FLASH、RAM这些都是通过存储器映射来映射到寻址空间不同的地址里提供给内核操作的,虽然这些外设是通过各个总线和内核连接在一起,但是内核都是通过厂家定义好的内存地址来访问的,寻址空间一般也会被厂家划分好不同的区域的作用了,比如我们前面用到的0x0800 0000就是FLASH的起始地址。后续我们要操作的一些外设寄存器其实也是通过存储器映射来找到对应的内存地址,然后修改该寄存器地址的 值 来达到控制外设的目的