一、STM32的中断向量表
这里先简单说一下中断向量表,程序“中断”的意思其实看字面意思就能懂了,就是会中断程序。比如你的cpu正在兴高采烈的执行着你给的任务,突然某个地方出现问题了,直接导致cpu中断了你的任务,不继续干你的活了。
当然了这里的中断也不是说就是不好的,你也可以当成“打断”的意思,比如你想让cpu正常执行你的程序,又想要cpu可以允许你临时“打断”它,给他分配临时任务。比如按钮的实现通常就是中断实现的,我们通常会在程序里写一个按钮的中断函数,可以在按下按钮时中断当前任务直接进入按钮中断函数执行我们的按钮任务。中断执行完后再让cpu回去继续执行主任务。
中断向量表就是告诉cpu,让他知道触发哪个中断的时候应该跳转到哪个地址去执行对应的函数。所以中断向量表的内容实际上就是每一个中断函数的地址值。
STM32的中断向量表第一项固定都是栈顶地址!
(一)第一行机器代码
上一章我们大概了解了STM32的启动流程,认识到其复位启动后,首先会从地址 0x0000 0000 处取出 MSP 的初始值(栈顶地址),然后再从下一个地址 0x0000 0004 处取出 PC 的初始值,这个值是复位向量,LSB 必须是 1。然后从这个值所对应的地址处取指令执行。
所以我们指令执行的地址实际上就是0x0000 0004的存放的值,也就是我们复位中断函数(启动函数)的地址。
注:
中断向量最后一位必须是1的说法是系统用来区分是ARM模式还是Thumb模式的,但是我测试LSB是0也能正常运行,不过建议还是跟着官方文档来
上面听起来有点绕,但是告诉我们的信息是:
0x0800 0000
:这是启动代码的第一个地方,这个地方需要保存的就是栈指针的初始值(MSP的初始值,就是我们的栈顶地址),这时候就需要了解什么是栈,栈在STM32里的作用了。这里不详细解释,但是栈都是从高位向下增长的,一般我们的栈顶地址就是我们RAM的末位+1。
接下来我们开始计算栈顶地址,RAM在地址空间是以0x2000 0000开始的,而我们STM32F103C8T6的RAM大小是20K=0x5000,从0x2000 0000(包含本身)开始,长度是0x5000,所以我们RAM的末位就是0x2000 4FFF。所以我们的栈顶地址是0x2000 4FFF + 1 = 0x2000 5000。其实直接用起始地址加RAM大小就行了...
所以我们知道了我们要从0x0800 0000开始写的第一行代码的十六进制表示是0x2000 5000
,转为我们的机器代码就是0010 0000 0000 0000 0101 0000 0000 0000
。恭喜,这就是我们写的第一行机器代码!在我们烧录程序的时候会把这32位的机器代码从0x0800 0000起始地址开始写入。
代码小本本:
1 | // 写在FLASH 0x0800 0000处的第一行代码 |
(二)定义中断向量表代码
- PC的初始值(复位中断函数的地址):
上面的第一行代码已经初始化了SP的值,然后cpu开始往下取向量表的第二项,这一项就是程序计数器PC的初始值,就是指向我们程序指令开始执行的地方(指向我们的启动函数了,复位中断函数,就是我们复位、上电后执行的地方)。我们可以先定一个地址作为我们启动函数的开始地址,这个地址一定要在中断向量表以外的地方,比如中断向量表长度是12C,那我们定义的启动函数地址至少要在0x0800 012C之后,比如0x0800 0130。
确定了我们的启动函数的开始地址是0x0800 0130之后,我们就可以把这个地址放到向量表的第二项里了,所以我们的第二行代码就是给向量表第二项写入一个复位中断函数地址值0x0800 0130,但是m3文档里说了这个值应该要LSB是1,所以我们写入向量表的地址就变成了0x0800 0131了(亲测直接写0x0800 0130也没问题)。我们的第二行代码:0000 1000 0000 0000 0000 0001 0011 0001
。 - NMI不可屏蔽中断函数地址:
前面写好了向量表的第二项的值,现在开始写第三项,也就是把NMI不可屏蔽中断函数的地址值写入向量表的第三项。其实我们这里为了为了方便,(只是点个灯而已就暂时不去处理这些中断了),所以我们随便指向一个地址当作默认中断处理函数的地址吧😂,比如选择加在启动函数的后面(加在后面要先计算启动函数的长度,这里先选一个比较长的值0x100)。
启动函数的地址是0x0800 0130,假设启动函数长度有0x100,那我们直接把默认中断函数的地址设置为0x0800 0130 + 0x100 = 0x0800 0230 .同样的我们让LSB+1,所以我们的第三行代码就出来了:0x0800 0231 =0000 1000 0000 0000 0000 0010 0011 0001
。 - 硬件失效中断函数地址:
和 NMI不可屏蔽中断 一样,我们把这个硬件中断也指向我们默认的中断处理函数地址吧。所以我们的第四行代码就是0x0800 0231 =0000 1000 0000 0000 0000 0010 0011 0001
。 - 其他中断:
其他中断的跳转地址我们都可以写成和上面两个中断一样的默认中断函数的地址0x0800 0231,这里为了方便我们干脆就不写其他中断的地址值了。。。
到了这里我们就写好了我们的中断向量表,它是从代码启动区开始到 + 0x012C结束(stm32f103c8t6文档里是这个长度)。
整理一下我们现在写好的代码吧
代码小本本:
1 | // 写在FLASH 0x0800 0000处的第一行代码---指定栈顶 |
二、复位中断函数(启动函数)
定义完了中断向量表后,我们就可以开始编写我们的启动函数了,这个启动函数就相当于C程序的main函数一样,stm32上电启动后开始执行的代码就是我们这里要编写的代码。
而这里也是我们本教程的主要功能逻辑部分,我们将在这个启动函数编写我们的点灯程序!
现在我们先整理一下点灯的思路,比如我的核心板led灯是在PC13引脚上的,而且是共阳极(引脚输出0点亮)。点灯步骤如下:
- 使能APB2外设时钟使能寄存器RCC_APB2ENR
- 配置端口配置高寄存器GPIOC_CRH(把PC13引脚设置为输出模式)
- 配置端口输出数据寄存器GPIOC_ODR(共阳极是置0亮,所以只想让灯亮的话,共阳极的只要完成上面那两个步骤就行)
接下来我们开始一步一步按上面的3个步骤来编写代码。因为本次编写的代码和之前定义向量表的时候不一样了,这次是需要用到具体的指令了,通常就是用到ARM提供的汇编指令,但是我们本次是机器码编程,所以相当于我们把汇编手动转为了机器码(人形汇编器😄)。虽然我们直接用机器码,但是汇编指令符号本身就是机器码的助记符,而且为了文档里查找指令方便,我们还是会将汇编指令符号也了解一下,作为我们在文档里查找对应机器指令的搜索符号。
好了,请打开文档:《ARMv7-M的体系结构参考手册》、《STM32F10xxx参考手册》
(一)使能APB2外设时钟使能寄存器RCC_APB2ENR
我们先打开《STM32F10xxx参考手册》文档,然后先找到存储器映射这一部分,这里可以看到我们每一个外设对应的地址范围。找到我们需要控制的外设,就是RCC时钟控制那一部分。
说到这里,有些同学就要困惑了,说“你直接就要把这个什么什么时钟使能什么的,我怎么知道要给RCC_APB2ENR使能啊!!”。那我们就再看一下文档,翻到存储器和总线构架
那部分, ,这就是stm32的系统架构。我们可以看到:
我们要操作的引脚是GPIO的C端口,而GPIOC是在APB2总线上的(APB2通过AHB/APB桥连接AHB总线再连接总线矩阵再和内核连接)。然后我们再往下看,可以发现有个描述:
复位以后,所有除SRAM和FLITF以外的外设都被关闭。同理我们要使用APB2上的外设需要使用时我们必须先打开APB2的时钟,那就是操作RCC_APB2ENR。
好了,继续看我们的存储器映射部分,找到RCC外设的地址范围,可以发现,RCC的起始地址是:0x4002 1000
,再参见文档的6.3.7节,我们找到 APB2 外设时钟使能寄存器(RCC_APB2ENR) 的寄存器配置描述。
我们发现这个寄存器的偏移地址是0x18
。通过前面我们知道的起始地址和这个偏移地址,我们就能知道RCC_APB2ENR的寄存器地址是0x4002 1000 + 0x18 = 0x4002 1018
。
然后我们再看看具体的配置信息:
从上图我们可以知道,我们需要GPIOC时钟使能,就得把这个寄存器的第4位置1
。(位从0开始)
通过上面我们知道了,要使能GPIOC时钟,其实就是把地址为
0x4002 1018
的值的第4位设置为1。
那逻辑就简单了,我们只需要让cpu从地址0x4002 1018
读取到值并放入一个通用寄存器,然后使用修改位的指令修改这个值的第4位为1,然后再把这个值放回到原来的地址里就行了!!开干!
打开《ARMv7-M的体系结构参考手册》文档,让我们找一下对应的指令。首先我们m3内核使用的是thumb-2指令集,所以我们直接在文档的目录里跳转到thumb指令的地方。(thumb-2指令集支持16位和32位,我们按需选择即可)
我们要实现把从一个地址里加载值到寄存器里,那先看指令目录的描述,我们先看16位的指令。
从上图我们发现,好像有“加载/存储单个数据项”的这么一个选项,我们继续点进去跳转到相应的页面看看。
到这里我们找到了一些用来加载数据的指令,就是圈起来的这些。然后看描述,这些指令都是加载数据到寄存器,但是区别就是0101
开头的这些指令好像是从源寄存器加载数据到目的寄存器的,而我们是要直接从0x4002 1018
这个地址加载数据到目的寄存器。所以就跳过这些,继续往下看,发现下半部分加载数据的指令的描述有个“immediate”,就是“立即”。这应该就是我们需要的了,因为我们如果想直接在指令里使用一些数,而不是放在寄存器里再使用,那这些数就是立即数
。立即数是直接存在指令本身里的。所以我们选择一个可以把立即数地址的值加载进寄存器的指令。
我们选了LDR指令,也就是二进制01101开头的这一条指令,如上图,是不是傻眼了。东西很多,无从下手是不是。我们一步一步看,先看指令的描述,这里描述其实就是说这个指令是从基寄存器和立即偏移地址计算地址,从内存加载字到另一个目的寄存器。
这时更傻眼了,这里还需要一个基寄存器,我们只有0x4002 1018
这么一个立即数,哪来的基寄存器。算了放弃这条指令/(ㄒoㄒ)/~~,继续找别的指令。继续看之前目录圈起来的那些命令,发现基本都需要一个基寄存器作为基址外加偏移量的方式。那想直接把0x4002 1018
里的数据直接取出来的梦是破碎了😔,所以我们只能先把这个地址保存到一个寄存器里当作“基址寄存器”才能继续使用刚刚的那些指令了。
那怎么把0x4002 1018
这个地址移到寄存器里面呢,我们再回过头看看别的指令。
这是我们发现00xxxx开头的这些指令好像有描述“移动move”这样的字样,这是不是就是我们要用的、把一个数直接移动到寄存器里的功能呢。点击目录进去看看:
找到了100xx开头的指令好像就是“移动”。
注:文档里Opcode开头的其实就是操作码,机器指令里一般由操作码,条件码,操作数这些组成,一般用操作码来区分指令,所以我们找指令就是根据操作码的值来确定是什么指令的
看描述我们知道,这次应该是找对指令了,这个指令也许就能把我们的0x4002 1018
放到寄存器里了(注意,我们是把0x4002 1018这个地址放到寄存器,而不是把这个地址的 值 放到寄存器里。)但是我们看这个文档页面,好像这个MOV下面有好多条机器指令呢?有T1、T2、T3、A1、A2这么多!其实T开头就是我们要用的Thumb指令,而A开头的是ARM指令。m3内核我们用的都是Thumb指令的。从文档里我们也看出来,T开头的Thumb指令有16位的和32位的,具体用哪个就得根据场景来选择了。(Thumb-2指令集是包含32位的!)
我们先看T1的指令,就是16位的。解析一下这个16位的指令:从[15:11]的编码是00100, 这个就是这条指令的操作码,也就是它的标识。意味着cpu获取机器指令时如果发现是00100“开头”的,就会当成这条指令来执行了。然后我们继续看第二个指令里的位置为[10:8]的标识【Rd】,还有第三个位置是[7:0]的标识【imm8】。这些标识到底代表着什么意思呢?我们继续在文档里往下翻一下,发现在MOV指令的末尾“Assembler syntax”汇编语法这部分:
从这里我们就可以知道,Rd实际上就是“The destination register”,目标寄存器。然后再找找有没有imm8,发现并没有。但是我们仔细看汇编语法的<const>
这个标识,其实imm8就是属于<const>
的一部分,就是立即数常量的意思。在文档描述的汇编语法里都是以#号开头的。
终于,一条简单的用于将立即数常量移动到寄存器里的16位机器指令算是知道了其结构组成,但是我们再仔细看上图种<const>
标识的描述,终于也是让我们激动的♥凉了一大半。The range of values is 0-255 for encoding T1 and 0-65535 for encoding T3 or A2.
MOV的T1这条机器指令竟然只能把0-255的立即数值放进寄存器,而32位的T3的立即数大小范围也只是0-65535。而我们要保存到寄存器的立即数大小是“0x4002 1018” = 1,073,877,016 !!这么大的一个数,显然一条指令是放不下的。
其实我们从原因上就能推断出来了,立即数本身是存储在指令里的,而32位cpu的指令长度就是32位,如果是小一点的立即数还好,但是我们0x4002 1018
明显就已经是32位了,所以一条指令光是用来存储这个立即数就已经满了,更别提需要的基本的操作码这些了。现在只能另外想办法了,既然我们的立即数很大,那可不可以拆分呢?T3指令支持0-65535,65535其实就是最大的16位了。所以我们只要把32位的立即数拆成两个16位的数就可以了!
现在我们先把低16位的0x1018
放到寄存器里吧。0x1018
就已经是16位了,显然我们第一眼看的简单的16位机器指令T1是满足不了要求了,它最大只支持255。所以我们直接看T3这个指令吧。
我们可以看到这条机器指令的操作码是11110
(或者说前缀)。接着是一个i标识,这个i其实和imm一样,是immediated单词的前缀简写,就是指的立即数。i表示1位的立即数。然后先继续往下看其它标识,发现了【imm4】【imm3】【imm8】分别是4位、3位、8位的立即数。还有一个占4位的符号【Rd】是指的目标寄存器。
好了,我们已经了解这条机器指令的组成了:1 1 1 1 0 i 1 0 0 1 0 0 imm4 0 imm3 Rd imm8
接下来我们只需要知道,机器指令里的i、imm4、imm3、imm8、Rd
这些怎么取值,就能完整的写出我们的机器指令了!那这些怎么取值呢,其实在机器指令下方有描述。如上图,d = UInt(Rd);
,其实表示d等于4位的二进制值Rd转为无符号整数。啥意思呢,我们知道通用寄存器有R0-R15,比如我们要用R1,那d这个值就是1,如果我们用R15,那d就是15。那机器指令里的Rd其实就是d的二进制表示。这里我们先把Rd这个符号解决了,我们要把0x1018放到R0的寄存器,那d=0,那4位的Rd的二进制编码就是0000
,所以我们的指令就变成了:1 1 1 1 0 i 1 0 0 1 0 0 imm4 0 imm3 0000 imm8
现在只剩下了立即数的部分,有i、imm4、imm3、imm8,那这些值怎么取呢,从上面的图可以看到有公式imm32 = ZeroExtend(imm4:i:imm3:imm8, 32);
。这就告诉我们,32位的立即数imm32就是由imm4:i:imm3:imm8通过一个ZeroExtend()函数得出来的。具体这个函数是什么意思呢,可以看文档的第2108页,看J-2表其实就有每个函数的大概描述。
可以看到,这个函数的意思就是把‘0’往左边扩展填充。比如ZeroExtend(imm4, 8),imm4=0110,那就是在imm4左边填充‘0’使其转为8位。所以结果是0000 0110
。然后我们上面imm32 = ZeroExtend(imm4:i:imm3:imm8, 32);
意思就是在imm4:i:imm3:imm8的左边填充‘0’使其变成32位的数。比如我们需要保存的数是0x1018
,二进制是0001 0000 0001 1000
,经过ZeroExtend函数后会变成0000 0000 0000 0000 0001 0000 0001 1000
。这正是我们需要的,我们的目的就是把0x1018放入寄存器r0,而0x1018就是我们0x4002 1018的低16位,存到寄存器r0时也应该存到低16位,所以最终保存到寄存器R0的值就是0000 0000 0000 0000 0001 0000 0001 1000
,所以imm4:i:imm3:imm8 = 0x1018 = 0001 0000 0001 1000,imm4=0001,i=0,imm3=000,imm8=0001 1000。填充到我们的机器指令,最终机器指令变成1 1 1 1 0 0 1 0 0 1 0 0 0001 0 000 0000 0001 1000
!
搞了这么久,我们的第一行机器指令终于出来了(前面写的中断向量表的机器代码其实都是地址)。记录一下先:
代码小本本:
1 | # 写在FLASH 0x0800 0000处的第一行代码---指定栈顶 |
任重而道远,我们的第一步目的是把0x4002 1018
地址放到寄存器R0,现在只完成了一半,只是把0x1018放到了R0,还剩下高16位0x4002还没放
。我们继续,如果我们再使用放0x1018的指令能不能也把0x4002放进去呢,其实通过上面我们就知道,放进寄存器的数是imm32,而imm32是通过ZeroExtend()函数来形成的,这个函数会在左边填充‘0’来把低于32位的数据填充成32位的。(其实填充成多少位的是通过这个函数的第二个参数决定的,这里默认说的是32)
如果我们也把0x4002
放进去,那形成的立即数就是0000 0000 0000 0000 0100 0000 0000 0010
,指令会把这个32位的数直接放到寄存器R0,这时候我们发现,R0里面本来寸的0x1018直接被0x4002给覆盖了,因为它把0x4002也写进寄存器的低16位了,我们的目的应该是让它写入高16位,并且不能动R0里面存在的低16位,这样才是完整的0x4002 1018
。所以这个指令是不能用了,我们得再找找其他的指令,看看有没有指令是写入高16位,然后其他位不动的。
文档往下翻着翻着我们就找到了这么一个指令,助记符是MOVT,看他的描述,发现确实符合我们的要求,它是把立即数写到目标寄存器的上半字(高16位),然后不动目标寄存器的下半字。完全符合我们的需求。我们需要把0x4002
放入R0寄存器的高16位。继续看指令的使用方法,可以看到指令是:1 1 1 1 0 i 1 0 1 1 0 0 imm4 0 imm3 Rd imm8
,使用方法也很简单,和之前的指令是差不多的,我们只需要把i、imm4、imm3、Rd、imm8填充进去即可。我们要放入的寄存器是R0,所以Rd=0000。然后再看看我们该怎么给它放16位立即数,可以看到下面描述:imm16 = imm4:i:imm3:imm8;
,意思很简单就是直接imm4:i:imm3:imm8组成我们的16位立即数就好。我们的16位立即数是0x4002
=0100 0000 0000 0010
=imm4:i:imm3:imm8,得出imm4、i、imm3、imm8后填充到指令,得出机器指令为:1 1 1 1 0 0 1 0 1 1 0 0 0100 0 000 0000 0000 0010
!
我们又写好了一条机器指令代码啦,记一下:
代码小本本:
1 | # 写在FLASH 0x0800 0000处的第一行代码---指定栈顶 |
好了,我们总算完成了把0x4002 1018
放入寄存器R0的目标了,接下来我们可以用我们之前想用的LDR指令,从地址0x4002 1018
取出保存的值,并放入一个寄存器里了。再回顾一下我们的目的:就是把地址为0x4002 1018
的值的第4位设置为1。
那怎么获取地址的值呢?其实我们之前找的LDR指令就是实现这个目的的,我们继续回去看这个指令:
我们直接看T1这个16位的指令吧,之前我们就知道,这个指令是要从一个基寄存器取出地址再加上一个立即数的偏移量作为最终地址,然后从这个地址取值放到目标寄存器。我们先把指令包括符号写一下:0 1 1 0 1 imm5 Rn Rt
,Rn是基寄存器,Rt是目标寄存器, imm5就是偏移量,因为我们已经在R0里保存了我们的地址0x4002 1018
,所以Rn=R0=000,偏移量是0,imm5=00000,我们就把值保存到R1,所以Rt=001。这样我们的指令就写好了:0110 1000 0000 0001
,代码写好了,记一下:
代码小本本:
1 | # 写在FLASH 0x0800 0000处的第一行代码---指定栈顶 |
再进行下一步,下一步是要修改值的第4位为1。一般我们要设置一个二进制的一位为1,我们可以把那一位和1进行或运算,比如0101
,我们需要把第1位(从低0位开始)设置为1,那我们只需要把他和0010
进行或运算即可,或运算后的结果是0111
。可以看到其他位不变,只有第1位变成了‘1’。所以我们现在需要把第4位置为1,就需要让R1保存的值和0000 0000 0000 0000 0000 0000 0001 0000
进行或运算。
注:这篇教程里说的第几位都是从0下标开始的
那我们就在指令文档里找一下可以进行或运算的指令吧,
找到了相关的指令ORR{S}<c> <Rd>,<Rn>,#<const>
,是进行或运算的,看一下机器指令是这样的:1 1 1 1 0 i 0 0 0 1 0 S Rn 0 imm3 Rd imm8
,然后往下翻到‘汇编符号’部分(Assembler syntax),我们大概清楚了这个指令就是把常量#
在机器指令里,imm和Rn和Rd我们都了解了,但是S符号是什么呢。我们看下面其实有描述setflags = (S == ‘1’)
,其实就是需不需要设置标志,这里我们把S设置成0就行。然后还有一个就是我们立即数imm32,我们最终的imm32立即数会和Rn里面保存的值进行或运算,所以我们得了解imm32是怎么得来的,下面也有描述(imm32, carry) = ThumbExpandImm_C(i:imm3:imm8, APSR.C);
。使用过一个ThumbExpandImm_C函数处理得来的。我们知道,我们的imm32是0000 0000 0000 0000 0000 0000 0001 0000
(因为imm32是要和R1的值进行或运行,上面已经说过了)。所以我们再看一下ThumbExpandImm_C函数是什么逻辑,在imm32=0x10时,i:imm3:imm8应该是什么值(我们的目的就是要知道i:imm3:imm8的值,因为知道这几个的值我们才能把指令补充完整)
在文档里我们找到了这个函数的定义如上图,我们传进来的i:imm3:imm8在函数的形参名称是imm12,然后我们通过函数的逻辑发现,只要让imm12[11:8]=0000,那imm32就等于ZeroExtend(imm12<7:0>, 32);那我们其实只需要设置imm12[7:0]=0001 0000,然后ZeroExtend函数会在左边补‘0’使其成为一个32位的数,也就是我们需要的0000 0000 0000 0000 0000 0000 0001 0000
。那我们就知道了imm12=0000 0001 0000 = i:imm3:imm8。
所以i=0 imm3=000 imm8=0001 0000。然后我们选择的目标寄存器也是R1,填充一下命令那就是1 1 1 1 0 0 0 0 0 1 0 0 0001 0 000 0001 0001 0000
代码小本本:
1 | # 写在FLASH 0x0800 0000处的第一行代码---指定栈顶 |
现在我们已经把值给修改了,但我们还需要把修改后的值重新放回到地址0x4002 1018
。这里不说太多废话了,直接找文档看看有哪些指令是支持从寄存器里把值放回到一个地址里的。
找到了STR指令,这里描述的功能是:从Rt取值放到Rn和偏移立即数组成的地址里。我们的值是存放在R1里的,地址是存放在R0里的,偏移量是0。所以直接得出了机器指令为0110 0000 0000 0001
代码小本本:
1 | # 写在FLASH 0x0800 0000处的第一行代码---指定栈顶 |
废了这么大力气终于是把RCC_APB2ENR的GPIOC时钟使能了!但是其实步骤很简单,就是需要去文档里找到对应的指令并且看懂指令的使用方式比较困难。如果之前学过汇编,那就不会这么艰难了。不过熟能生巧,直接写机器码后面再看汇编好像也不错,算是当了一回人形汇编器。也许以后有机会亲自动手优化汇编器汇编出来的机器指令呢😂
优化:
其实有些指令是可以优化的,主要在于对文档里各个指令的熟悉程度,同一个功能一般不同的指令都有可能可以实现。比如学会使用寻址方式很重要。就像我们上面把外设寄存器地址0x4002 1018放入通用寄存器时,都是要分成两个指令去存的,要是后续我们又要操作外设0x4002 1022、0x4002 1026这些呢?不可能每个值都用两条指令保存进寄存器里,浪费时间不说,空间也浪费了。这种情况我们就可以只定义一个基地址0x4002 0000,然后后面的操作都是通过偏移量去算最终地址,这样能省下很多指令。通过偏移地址寻址优化的其实还可以通过PC作为基寄存器的方式,比如我们可以把 变量定义在代码区后面 ,这样后续的寻址就可以直接使用PC+偏移量来直接寻址到变量。上面的指令还可以使用m3内核的一种操作方式,就是 位带操作 ,我们上面的指令中有一个ORR指令是通过或运算来改变使能寄存器的第4位的值,其实可以通过位带别名区来操作。具体内容可以看文档《Cortex-M3权威指南》,里边有位带的映射和操作描述。
(二)配置PC13端口的输出模式
该怎么把GPIOC13口的设置为输出模式呢?我们可以看文档《STM32F10xxx参考手册》,找到GPIO端口C的外设:
可以看到GPIO的C端口外设的寄存器起始地址是0x4001 1000
,我们再到文档的8.2.2节看一下端口配置高寄存器(GPIOx_CRH)
–因为我们是13口,所以在高寄存器里面配置。
我们先算一下这个寄存器所在的地址是多少,GPIOC_CRH = 0x4001 1000 + 0x04 = 0x4001 1004
这就是我们要外设寄存器地址。然后我们再看看寄存器配置说明里,发现是通过配置GPIOC_CRH的[21:20]=01或10或11
来设置PC13端口为输出模式,区别就是输出最大速度不同而已。我们就选10Mhz的吧,所以需要设置GPIOC_CRH[21:20]=01
。
继续看文档发现还需要设置第23和第22位来配置输出的模式类型,这里我们设置为通用推挽输出就好。所以GPIOC_CRH[23:22]=00
。
所以本次的任务是:把地址为
0x4001 1004
的GPIOC_CRH寄存器的值的[23:22:21:20]设置为0001
按照之前我们的做法就是:
- 把外设寄存器的地址放到一个通用寄存器Rd里
- 再通过LDR指令从通用寄存器里取出保存的地址,并从地址里加载值到另一个通用寄存器Rt
- 修改Rt寄存器里的值,把值的[23:22:21:20]设置为
0001
(找一些可以直接设置位的指令) - 把修改后的值从Rt寄存器里放回到Rd保存的地址里
1、0x4001 1004
放入寄存器R0
继续使用MOV的T3指令,先把0x1004
放入R0的低16位里。
指令:1111 0i10 0100 imm4 0imm3 Rd imm8
,imm32=ZeroExtend(imm4:i:imm3:imm8, 32)=0000 0000 0000 0000 0001 0000 0000 0100
,所以imm4:i:imm3:imm8=0001 0000 0000 0100
,所以把指令完善:1111 0010 0100 0001 0000 0000 0000 0100
;
继续使用MOVT的T3指令,把0x4001
放入R0的高16位里。
指令:1111 0i10 1100 imm4 0imm3 Rd imm8
,imm16=imm4:i:imm3:imm8, 32=0100 0000 0000 0001
,所以把指令完善:1111 0010 1100 0100 0000 0000 0000 0001
;
2、R0保存的地址的值加载到R1
这部分的代码其实和上面操作APB2时钟使能的一样,都是把R0保存的地址加载到R1,所以这里可以直接得出指令:0110 1000 0000 0001
。
3、修改R1的值[23:22:21:20]设置为0001
修改值的操作,因为这次并不是都把位置为1,所以并不能用跟上一次一样的ORR指令,我们重新找一下看看有没有支持直接设置位的指令。
找着找着终于找到了,这个指令看描述就是位插入的意思,点进详情页面看看:
指令是:1111 0011 0110 Rn 0 imm3 Rd imm2 0 msb
,从指令的描述我们知道,这是把Rn寄存器里的数从低位开始n个位,放入Rd寄存器的lsbit位开始到msbit结束的地方。我们的Rd是R1,Rn是要从里面取位的寄存器,我们需要把0001
插入R1的[23:22:21:20]位里面,所以我们必须要先把0001
放入一个寄存器作为Rn。我们就用寄存器R2吧。
使用之前看到的MOV的T1指令,将
0001
放入寄存器R2
指令:0010 0 Rd imm8
。从MOV T1指令可以看出,这个是把立即数imm8高位补0变成32位数据后保存到Rd寄存器。我们是要把0001
放入Rd,所以imm8=0000 0001
,Rd=010
,指令完善:0010 0010 0000 0001
;
继续回到BFI指令,现在Rn=R2=0010。所以指令完善:1111 0011 0110 0010 0 imm3 0001 imm2 0 msb
。其中imm3、imm2和msb应该放什么值我们还不清楚,那就继续看文档下面的描述。 msbit = UInt(msb); lsbit = UInt(imm3:imm2);
,从这里可以看出,msbit就是由msb转成的无符号整数,lsbit就是由imm3:imm2转成的无符号整数。我们要把0001
放入R1的[23:22:21:20]位,所以msbit=23=10111=msb
,lsbit=20=10100=imm3:imm2
。进一步完善指令就是:1111 0011 0110 0010 0 101 0001 00 0 10111
。整理一下格式四位对齐:1111 0011 0110 0010 0101 0001 0001 0111
。
4、把R1的值存储到R0保存的地址里
把寄存器里保存的数据存储到外部(FLASH或者SRAM等等外设或寄存器),使用的还是上面使用过的STR指令。继续看指令图:
指令是:0110 0 imm5 Rn Rt
,imm5是地址偏移量立即数,Rn是保存存储的地址的寄存器,Rt就是存储数据要拿出来放到Rn保存的地址里的数据寄存器。简单说就是:把Rt保存的数据拿出来放到Rn+imm5组成的地址里。我们现在是要把R1的值取出来放到R0保存的地址里。所以Rt=R1=001,Rn=R0=000,因为R0里保存的地址就是我们想要操作的地址了,所以没有偏移量,所以imm5=00000。完善指令:0110 0000 0000 0001
。
好了,上面配置PC13端口的输出模式的4个步骤终于都写完对应的机器代码了,现在我们整理出来放入小本本:
代码小本本:
1 | # 写在FLASH 0x0800 0000处的第一行代码---指定栈顶 |
(三)配置端口输出数据寄存器GPIO_ODR
这个寄存器就是用来配置我们IO口的输出是高电平还是低电平的,先上图:
从图中我们可以看到,我们需要设置PC13端口的输出,只需要把这个寄存器的第13位设置为0或1就行了。这个GPIO_ODR寄存器的偏移地址是0x0C,所以最终地址是0x4001 1000 + 0x0C = 0x4001 100C
。
这个寄存器复位时默认每一位都是0,所以如果PC13连接的LED灯是共阳极的,则复位时已经不用配置这个位的输出了,只用配置之前的(一)和(二)两个步骤就能点亮LED灯了。(共阳极低电平点亮,共阴极高电平点亮)
共阳极的实际上已经点亮灯了,不需要进行下面的操作了。如果是共阴极高电平点亮,这时候我们可以按照之前写过的流程:
- 把寄存器地址
0x4001 100C
放入通用寄存器R0 - 使用LDR加载R0中保存的地址的值放入R1
- 使用ORR指令修改R1里保存的值的第13位为1
- 把修改后的值从R1拿出来存储到R0保存的地址里
这就是控制输出数据寄存器GPIO_ODR点亮LED灯的过程,但是其实上面的4个步骤可以进行优化一下。比如第一个步骤,因为R0执行(二)的时候,里面已经存放了端口配置高寄存器(GPIOx_CRH)
的地址,而这个地址和我们要保存的0x4001 100C
只差了8个字节,所以我们根本就不用执行指令再把0x4001 100C
放入R0了,可以取消这一步了。
又因为这个寄存器复位值各个位都是0,我们干脆少写一点代码(扛不住了🙃),不把值取出来了,直接把一个10 0000 0000 0000
放到寄存器R1,然后把R1存储到R0的地址和0x08
偏移量里吧。开干!
使用MOV T3指令:1111 0i10 0100 imm4 0imm3 Rd imm8
,imm32=ZeroExtend(imm4:i:imm3:imm8, 32)=0000 0000 0000 0000 0010 0000 0000 0000
,所以imm4:i:imm3:imm8=0010 0000 0000 0000
,Rd=0001
,所以把指令完善:1111 0010 0100 0010 0000 0001 0000 0000
;这下把10 0000 0000 0000
放到寄存器R1了。
这里直接把R1的值放到R0保存的地址再加上8字节的偏移量的地址内,使用STR指令:0110 0 imm5 Rn Rt
,因为imm32 = ZeroExtend(imm5:’00’, 32); 所以imm5:00
=0x08
=0001000
,imm5=00010
,Rn=R0=000
,Rt=R1=001
,完善指令:0110 0 imm5 Rn Rt
=0110 0 00010 000 001
。
终于是把这部分代码也写完了,我们整理一下小本本吧:
代码小本本:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36# 写在FLASH 0x0800 0000处的第一行代码---指定栈顶
0010 0000 0000 0000 0101 0000 0000 0000
# 写在FLASH 0x0800 0004处的第二行代码---指定复位中断函数的地址
0000 1000 0000 0000 0000 0001 0011 0001
# 写在FLASH 0x0800 0000处的第一行代码---指定不可屏蔽中断函数的地址
0000 1000 0000 0000 0000 0010 0011 0001
# 写在FLASH 0x0800 0000处的第一行代码---指定硬件失效中断函数的地址
0000 1000 0000 0000 0000 0010 0011 0001
# 这里是启动函数的第一条指令,从这里到启动函数的结尾的代码要烧录到向量表中填写的启动函数的地址
# 使能RCC_APB2ENR---把寄存器地址的低16位0x1018放到寄存器R0
1111 0010 0100 0001 0000 0000 0001 1000
# 使能RCC_APB2ENR---把寄存器地址的高16位0x4002放到寄存器R0
1111 0010 1100 0100 0000 0000 0000 0010
# 使能RCC_APB2ENR---把R0里的地址0x4002 1018的值加载到寄存器R1
0110 1000 0000 0001
# 使能RCC_APB2ENR---使用0x10立即数和R1里保存的值进行或运算并保存回R1
1111 0000 0100 0001 0000 0001 0001 0000
# 使能RCC_APB2ENR---从R1里取出修改后的值再放到R0存的地址里面
0110 0000 0000 0001
# 配置PC13端口的输出模式---把寄存器地址的低16位0x1004放到寄存器R0
1111 0010 0100 0001 0000 0000 0000 0100
# 配置PC13端口的输出模式---把寄存器地址的高16位0x4001放到寄存器R0
1111 0010 1100 0100 0000 0000 0000 0001
# 配置PC13端口的输出模式---R0保存的地址的值加载到R1
0110 1000 0000 0001
# 配置PC13端口的输出模式---把0001放到寄存器R2
0010 0010 0000 0001
# 配置PC13端口的输出模式---把R2寄存器的低4位的值插入R1的值[23:22:21:20]
1111 0011 0110 0010 0101 0001 0001 0111
# 配置PC13端口的输出模式---把R1的值存储到R0保存的地址里
0110 0000 0000 0001
# 如果要点亮的LED灯是共阳极的,就不用写下面的代码了!
# 配置PC13端口输出---直接把一个`10 0000 0000 0000`放到寄存器R1
1111 0010 0100 0010 0000 0001 0000 0000
# 配置PC13端口输出---把R1的值放到R0保存的地址再加上8字节的偏移量的地址内
0110 0000 1000 0001
(四)死循环
主函数最末尾一般都要设置一个死循环,防止程序一直从PC取下一条指令执行导致崩溃。正常情况下PC取了指令之后,在cpu要运行指令之前,PC会自动加1字。这样相当于是保存了下一次要执行的指令的地址。所以我们简单一点,让PC的值重新跳回当前指令执行,这样就变成了死循环了。我们先看看文档里的分支指令B
我们看看T2指令,可以看到指令是1110 0 imm11
,然后imm32=SignExtend(imm8:’0’, 32); 通过前面的学习我们知道SignExtend函数就是把传入的立即数的最高位复制使其变成32位立即数。而imm32在这条指令中表示PC寄寄存器的偏移量,也就是跳转的目标地址 = PC + imm32
。比如我们当前指令的地址是0x0800 0010
,那我们从PC中取的值就是0x0800 0010 + 0x04 = 0x0800 0014
,而我们跳转时需要跳转回到0x0800 0010
继续执行当前指令。所以带入公式得到0x0800 0010 = 0x0800 0014 + imm32
,所以imm32=0x0800 0010 - 0x0800 0014 = 0xFFFF FFFC
。其实我们发现,当前指令的地址是啥,因为PC的值读取出来总是当前地址+0x04,所以其实PC地址和当前地址的偏移量一直是-0x04
=0xFFFF FFFC
。所以imm11=1111 1111 110
带入指令得到:1110 0 1111 1111 110
。
代码小本本:
1 | # 写在FLASH 0x0800 0000处的第一行代码---指定栈顶 |
上面就是本教程从上电开始到点亮PC13的LED灯的二进制代码了😇,其实本来想法是启动函数不要单单是只写这么点东西的,正经的启动函数都会编写把一些数据和变量之类的往RAM里搬。但现在真的写不下去了…
三、默认中断处理函数
前面我们在中断向量表已经填写不可屏蔽中断和硬件失效中断的处理函数地址,我们把这两个中断统一指向我们现在要编写的默认的中断处理函数里。(后续别的中断也可以指向当前默认中断函数的指令地址),我们前面已经定义了我们的默认中断处理函数的地址是0x0800 0231
,那我们后续把指令写入硬件时,就得写入指定的地址0x0800 0231
。由于我们现在只是写代码,还不涉及烧录,所以我们就先写好如何处理默认中断的机器指令就行。
这里不想写那么多了,直接在默认中断处理函数这里写个死循环就行了,继续沿用我们之前写死循环的机器代码:
1110 0111 1111 1110
。🆗,完事了。。。
1 | # 默认中断处理函数直接写个死循环吧...烧录地址记得要和向量表里的中断地址一致 |
四、编码结尾
到这里二进制代码的编写就完成了,本篇主要讲如何在中断向量表写入触发中断时跳转的地址,还讲了如何根据文档来编写点亮PC13的LED灯的机器代码。其实有很多中间步骤写得都不太详细,因为很多东西不是一言两语能解释清楚的,最终都要回归到文档,特别是指令的描述和用法规则。而我们本篇最终写成的机器代码其实只是文本的表达形式,还不能直接烧录到STM32芯片里,必须要先转化为真正的二进制文件才可以让芯片执行。后续的教程就是如何把我们写好的机器代码转化为二进制形式。 > 因为我们写的0和1只是文本,比如文本的0的ASCII码是48,对应的二进制是00110000,而我们机器代码是要求在芯片机器读取时是真的0,而不是文本的`0`(00110000)。 最终的代码小本本:1 | # 写在FLASH 0x0800 0000处的第一行代码---指定栈顶 |