在学习C语言的时候,我们会写个Hello World
程序来入门,当我们写ARM程序,也该有一个简单的程序引领我们入门,这个程序就是点亮LED。
查看原理图,确定控制LED的引脚:
方式1:使用引脚输出3.3V点亮LED,输出0V熄灭LED。
方式2:使用引脚拉低到0V点亮LED,输出3.3V熄灭LED。
- 有的芯片为了省电等原因,其引脚驱动能力不足,这时可以使用三极管驱动。
方式3:使用引脚输出1.2V点亮LED,输出0V熄灭LED。
方式4:使用引脚输出0V点亮LED,输出1.2V熄灭LED。
但是对于我们写程序来说,不用关心输出的是3.3V还是1.2V,只需要知道引脚输出的是高电平还是低电平,简称输出1或0。
- 逻辑1–>高电平
- 逻辑0–>低电平
芯片操作引脚:
如上图,本喵的STM32F103ZET6开发板上,控制引脚输出0点亮LED灯,这里仅控制Red的LED灯,操作的引脚是PB0。
从芯片手册上查找相关寄存器:
如上图是RCC_APB2ENR
寄存器,用来控制不同组GPIO的使能,将该寄存器的bit3设置为1就使能了GPIOB。
那我们怎么找到这个寄存器呢?
如上图是不同寄存器所在的地址范围。Reset and clock control RCC
是用来使能外设时钟的,它的基地址是0x40021000
。
又因为RCC_APB2ENR
寄存器的偏移地址是0x18
,所以该寄存器的绝对地址就是0x40021000 + 0x18
。
如上图,再从这张表中找到GPIOB的基地址是0x40010C00
。
如上图所示是GPIOx_CRL
寄存器,x是引脚编号,本喵这里使用的是PB0,所以x就是0,需要配置MODE0
和CNF0
。该寄存器的偏移量是0x00
,所以该寄存器的绝对地址就是0x40010C00 + 0x00
。
MODE0
的两个比特位配置为11,表示输出,并且输出速度设置最大,此时电平变化最快。
CNF0
的两个比特位是配置输出模式的,这里仅是点灯,使用默认值即可。
如上图是GPIOx_ODR
寄存器,用来控制引脚的输出电平,根据偏移地址得到它的绝对地址是0x40010C00 + 0x0C
。
由于是PB0,所以控制它的bit0ODR0
即可,该位是1,输出1,该为是0,输出0。
要实现bit0 = 1
或者bit0 = 0
,不能直接GPIOx_ODR = 1
,这样虽然能让bit0为1,但是该寄存器的其他位被置0了。
- 操作寄存器的某一位时,不能影响其他位。
置一:GPIOx_ODR |= (1<<0)
。
置0:GPIOx_ODR &= ~(1<<0)
。
这样的方式就仅在操作bit0,其他位并不影响。
如上图所示GPIOx_BSRR
寄存器,它的绝对地址是0x40010C00 + 0x10
,对于PB0,只需要操作BS0
位和BR0
位。
BS0
写1,输出1,BR0
写1,输出0,这些位写0没有任何影响,此时就可以仅操作这一个寄存器即可,效率较高。
如上图所示启动文件中的汇编代码,蓝色框中的是语法规定,必须有的,暂时不用管它。
板子一上电以后会去向量表__Vectors
处开始执行代码,执行到DCD Reset_Handler
后会开始调用我们自己写的函数mymain
,在调用之前需要设置一下栈顶SP
,然后BL
跳转到我们自己写的函数中去执行。
如上图本喵自己实现的mymain.c
函数中,先创建一个32位的指针变量pReg
,用来访问寄存器。
RCC_APB2ENR
寄存器的地址0x40021000 + 0x18
,将该寄存器的bit3置一,使能GPIOB。GPIOx_CRL
寄存器的地址0x40010C00 + 0x00
,将该寄存器的bit0置一,设置PB0为输出模式。ODR
寄存器的地址0x40010C00 + 0x0C
,将该寄存器的bit0设置为1,让PB0输出1,然后延时,再将bit0设置为0,然后再延时,如此反复。然后编译工程,并将程序烧录到开发板中,可以看到板子上红色的LED灯在闪烁,本喵这里就不贴图了。
ARM架构:
在上面点灯的过程中,本喵在访问寄存器的时候,完全就是在使用C语言的指针来访问内存地址,为什么这样做就可以访问到寄存器呢?
如上图示意图所示,在ARM架构的CPU中,内存RAM
,各种片内外设,如UART
,USB
控制器等都是统一编址的,它们的地址是连续的,从add1
到add6
。
CPU在访问不同的地址时,会将地址先发给内存控制器memeory controller
,由内存控制器去访问地址读取数据。
- 所以在CPU眼里,这些外设以及内存的访问方法都是一样的。
x86架构:
如上图所示是x86架构CPU访问内存和外设的示意图,这里的内存和IO空间中的外设就不是统一编址的,而是分隔开的。
内存的地址范围是0~4GB
,IO空间的范围是0~64k
,这两个空间在0~64K
的地址范围是重复的,CPU通过不同指令来访问不同的空间。
当CPU要访问内存空间的时候,就使用MOV
指令,当CPU要访问IO空间的时候,就使用IN
指令。
精简指令集计算机:
ARM芯片属于精简指令集计算机(RISC:Reduced Instruction Set Computing),它所用的指令比较简单,有如下特点:
① 对内存只有读、写指令
② 对于数据的运算是在CPU内部实现
③ 使用RISC指令的CPU复杂度小一点,易于设计
如上图所示的乘法运算a = a * b,RISC中要使用4条汇编指令:
① 读内存a
② 读内存b
③ 计算a*b
④ 把结果写入内存
复杂指令集计算机:
x86属于复杂指令集计算机(CISC:Complex Instruction Set Computing),
它所用的指令比较复杂,比如某些复杂的指令,它是通过“微程序”来实现的。
比如执行乘法指令时,实际上会去执行一个“微程序”,在“微程序”里,
一样是去执行这4不操作:
① 读内存a
② 读内存b
③ 计算a*b
④ 把结果写入内存
但是对于程序员来说,他看不到“微程序”,他好像用一条指令就搞定了这一切!
这里提到x86架构以及CISC仅仅是为了和ARM架构以及RISC作一个对比,我们使用的是ARM架构以及RISC。
ARM架构中对于数据的运算是在CPU内部实现的,在内部用什么来保存上面乘法运算中的a,b,以及a * b的结果呢?
cortex-M3/M4中寄存器示意图:
CPU内部都有R0、R1、……、R15寄存器,它们可以用来“暂存”数据。
对于R13、R14、R15,还另有用途:
其中R13就是汇编指令里使用的SP
,但是它有两个寄存器,一般情况下使用的是SP_main
寄存器,运行RTOS的时候,任务使用的是SP_process
寄存器。
- 在编程的时候直接使用SP,根据不同情况会自动调用相应的栈寄存器。
如上图所示,在程序执行的过程中,PC寄存器会按照顺序读取指令并去执行。
这16个寄存器之外还有一个xPSR
寄存器,用来保存程序状态,保存上一条指令的执行结果,比如比较结果。还有一些控制作用,比如屏蔽中断、使能中断。
对于cortex-M3/M4来说,xPSR
实际上对应3个寄存器:
① APSR:Application PSR,应用PSR
② IPSR:Interrupt PSR,中断PSR
③ EPSR:Exectution PSR,执行PSR
这3个寄存器的含义如上图所示,其实就是3个寄存器使用32位中的不同比特位,所以就用一个程序状态寄存器xPSR
来表示了3个寄存器。
这3个寄存器,可以单独访问,使用下面汇编指令:
这3个寄存器,也可以一次性访问:
一开始,ARM公司发布两类指令集:
① ARM指令集,这是32位的,每条指令占据32位,高效,但是太占空间。
② Thumb指令集,这是16位的,每条指令占据16位,节省空间。
要节省空间时用Thumb指令,要效率时用ARM指令。
一个CPU既可以运行Thumb指令,也能运行ARM指令。通过程序状态寄存器中有一位,名为“T”,它等于1时表示当前运行的是Thumb指令。
现在有一种情况,函数A是使用Thumb指令写的,函数B是使用ARM指令写的,可以往PC寄存器里写入函数A或B的地址,就可以调用A或B。
但是怎么让CPU在执行A函数是进入Thumb状态,在执行B函数时进入ARM状态?
- 调用函数A时,让PC寄存器的BIT0等于1,即:PC=函数A地址+(1<<0);
- 调用函数B时,让PC寄存器的BIT0等于0:,即:PC=函数B地址
根据函数地址的bit0位来判断这是用Thumb指令写的还是用ARM指令写的。
这样做非常的麻烦,所以后来又引入了Thumb2
指令集,它支持16位指令、32位指令混合编程。
有那么多指令集:ARM、Thumb、Thumb2,难道都要记住它们的指令吗?当然不会,ARM公司推出了UAL
(Unified Assembly Language),统一汇编语言,你不需要去区分这些指令集。
在程序前面用CODE32/CODE16/THUMB表示指令集:ARM/Thumb/Thumb2
我们在使用中不需要记住多少汇编指令,没必要写很复杂的汇编程序,因为在设置栈后就用C语言来写函数了。
常用的汇编指令只有几类:内存访问、数据处理、跳转、其他指令。
以“数据处理”指令为例,UAL汇编格式为:
xPSR
中的值有关,使用条件的指令之前一定得有改变xPSR
寄存器的指令。{S}
表示该指令执行后会去修改程序状态寄存器,也是可写可不写。
下面本喵用一款神器VisUAL
来讲解一下常用汇编指令的用法,这是一款ARM汇编模拟器。
LDR:Load Register
如上图所示是该指令的用法,作用就是从内存中读取数据到寄存器中,其中{type}
表示读取数据的类型,如B就是无符号的一个字节数据,该选项可写可不写。
STR:Store Register
如上图所示就是该指令的用法,作用就是将数据从寄存器中写入到内存中。
如上图所示汇编代码,在执行的过程中在右侧的红色框中可以看到寄存器中值的变化,在下侧的框中可以看到内存中的值。根据回调代码中的注释很容易看懂意思。
MOV
是一个最基本的汇编指令,表示数据的移动,从源操作数移动到目的操作数,如上面中的MOV R0, #0x20000
中,将0x20000
移动到寄存器R0
中。
!
表示R0=R0+8,就是运算完以后要改变R0
寄存器中的值。
LSL
是一个数据左移指令,就相当于C语言中的<<
操作符,如上面的STR R2, [R0, R1, LSL #4]
表示将R1
中的值左移4位然后加到R0
上,最后将R2
寄存器中的值存放到R0
中值所代表的地址处。
LDM:Load Multiple Register
如上图所示是LDM
的用法,作用是从多个地址处将数据读取到多个寄存器中。
addr_mode:
IA - Increment After, 每次传输后才增加Rn的值(默认,可省)
IB - Increment Before, 每次传输前就增加Rn的值(ARM指令才能用)
DA – Decrement After, 每次传输后才减小Rn的值(ARM指令才能用)
DB – Decrement Before, 每次传输前就减小Rn的值
!
: 表示修改后的Rn值会写入Rn寄存器,
如果没有"!", 指令执行完后Rn恢复/保持原值
^
: 会影响CPSR, 在讲异常时再细讲
这里的Rn
表示地址,如LDMIA R0, {R1-R3}
表示将R0,R0+4,R0+8地址处的数据读取到R1,R2,R3寄存器中。
STM:Store Multiple Register
如上图所示是STM
的用法,作用是将多个寄存器中的值写到多个地址处。这里Rn
也表示地址。选项和LDM
的用法一样。
如上图所示是将寄存器中R1,R2,R3中的1,2,3放入到R0中的起始地址0x20000
处时,使用的四种addr_mode
方式。
上面是汇编代码,下面是执行完毕后内存中的值,这个过程中,高地址放编号高的寄存器中值。
根据栈指针指向,可分为满(Full)/空(Empty):
增(Ascending):SP变大。
减(Descending):SP变小。
- 组合后,就有4种栈:满增、满减,空增,空减。
常用的的栈为“满减”:
如上图代码所示,使用STMFD
将数字1,2,3入栈,此时R13也就是SP寄存器的值是0x1FFF4
,因为从0x2000
开始减了12个字节,此时内存中的值也符合。
然后将R1,R2,R3寄存器中的值清0,然后再使用LDMFD
将栈中的1,2,放入到寄存器中。
加法指令:
ADD R1, R2, R3 ; R1 = R2 + R3
ADD R1, R2, #0x12 ; R1 = R2 + 0x12
减法指令:
SUB R1, R2, R3 ; R1 = R2 - R3
SUB R1, R2, #0x12 ; R1 = R2 - 0x12
进行减法运算的时候,发生借位时会改变程序状态寄存器xPSR
中的N位。
位操作:
AND R1, R2, #(1<<4) ; 位与,R1 = R2 & (1<<4)
AND R1, R2, R3 ; 位与,R1 = R2 & R3
BIC R1, R2, #(1<<4) ; 清除某位,R1 = R2 & ~(1<<4)
BIC R1, R2, R3 ; 清除某位,R1 = R2 & ~R3
ORR R1, R2, R3 ;位或,R1 = R2 | R3
VisUAL里不支持(1<<4)这样的写法,写成0x10
。
比较:
CMP R0, R1 ; 比较R0-R1的结果
CMP R0, #0x12 ; 比较R0-0x12的结果
TST R0, R1 ; 测试 R0 & R1的结果
TST R0, #(1<<4) ; 测试 R0 & (1<<4)的结果
比较的本质就是在做减法,用第一个操作数减去第二个操作数,比较的结果会改变程序状态寄存器xPSR
中的N位和Z位。
void A()
{
int a = 10;
B(a);
printf(“ok”);
}
C程序中,函数A调用函数B的实质是:跳转去执行函数B的代码,函数B执行完后,还要回到函数A继续执行后面的代码。
对应的汇编指令就是跳转指令:
由于使用的是Thumb2指令集,所以只使用B
和BL
两条跳转指令。
如上图,使用B跳转指令跳转到延时函数Delay
中,然后让R0减1,再使用BNE
来判断跳转到哪里,当R0不为0时,在BNE
和SUBS
之间执行。
SUBS
在SUB
减法指令的基础上增加了S
,表示执行完后会影响程序状态寄存器xPSR
的值。
在运行过程中,xPSR
中的Z位始终为0,表示R0的值不为0。BNE
是在跳转指令B
的基础上增加了条件NE
,代表的条件就是xPSR
中的Z为0。
此时符合BNE
条件,所以跳转到loop
继续执行,从而实现延时。但是这个延时函数执行完毕后无法获得返回地址,因为B
跳转指令不会保存返回地址到LR
寄存器中。
如上图所示,使用BL
跳转指令,在跳转之前会将返回地址存入到LR
寄存器中,如上图所示,LR
中的值是0x04
,由于当前地址是第一行,返回地址就是下一条指令的地址,也就是第2行,又因为BL
指令是32位指令,所以增加4。
当延时结束以后,将LR
中的返回地址直接赋值给PC
寄存器,程序从第2行开始执行。
这样一条指令:MOV R0, #VAL
意图是把VAL
这个值存入R0寄存器,那么VAL
可以是任意值吗?不可以
- 直接给寄存器赋值的数必须是立即数。
假设VAL
可以是任意数,MOV R0, #VAL
本身是16位或32位,哪来的空间保存任意数值的VAL?所以,VAL必须符合立即数的规定:
如上图是立即数必须符合的规则,但是由我们去判断一个数是否是立即数会比较麻烦,并且我就想把任意数值赋值给R0,这时就可以使用伪指令。
LDR伪指令:
LDR R0, =VAL
“伪指令”,就是假的、不存在的指令。编译器会把“伪指令”替换成真实的指令,比如:
LDR R0, =0x12
中0x12
是立即数,那么替换为:MOV R0, #0x12
。
LDR R0, =0x12345678
中0x12345678
不是立即数,那么替换为:LDR R0, [PC, #offset]
使用LDR
读内存指令读出值,offset是链接程序时确定的。
编译器在程序某个地方保存有这个非立即数的值,需要赋值的时候就来这个地府读取。
- 注意
LDR
作为“伪指令”时,指令中有一个“=”,否则它就是真实的LDR(load regisgter)指令了。
ADR伪指令:
ADR R0, Loop
比如ADR R0, Loop
,要将标号Loop
的地址读取到R0中,它是伪指令,会被转换成真实的指令ADD R0,PC,#VAL
,VAL
在连接的时候确定。
如上图,之前的延时程序可以使用伪指令ADR
直接将返回地址赋值给LR
寄存器,将函数Delay
的地址直接赋值给PC
寄存器,去执行延时函数。
我们的第1个LED程序涉及2个文件:start.s、main.c,它们的处理过程如下:
如上图所示是程序编译的步骤,最后面的红色框是反汇编,就是将生成的可执行二进制文件变成汇编代码。
- 汇编:汇编文件转换为目标文件(里面是机器码)。
- 反汇编:可执行文件(目标文件,里面是机器码),转换为汇编文件。
KEIL中反汇编:
fromelf --bin --output=led.bin Objects\led.axf
fromelf --text -a -c --output=led.dis Objects\led.axf
在KEIL的User选项中,如下图添加这两项:
然后重新编译,即可得到二进制文件led.bin(以后用到)、反汇编文件led.dis。
如上图,只截取led.dis中前面一小段,第一列是地址,第二列是机器码,第三列是汇编代码。
如上图所示是Thumb/Thumb2
指令集中LDR
指令的机器码生成规则,和前面反汇编文件中LDR
对应的机器码做对比,可以发现,完全可以对的上。
汇编代码中调用C函数时使用BL mymain
,那如果我想给mymain
函数传参呢?在前面编译过程中可以看到,.c
源文件也会被编译成汇编文件,然后所有汇编文件再进行汇编生成目标文件,然后再进行连接。
此时start.s
中调用main.s
中的mymain
函数,这两个文件都是汇编文件,汇编调用汇编传参就容易实现了。在ARM中使用寄存器来传参:
r4-r11
用来保存局部变量,函数可能使用它们,所以在函数的入口保存它们,在函数的出口恢复它们。
r12-r15
是特殊用途的寄存器。
int delay(unsigned int d)
{
while (d--);
return 0;
}
int mymain()
{
delay(1000000);
return 0;
}
上面的C代码转换成汇编后调用delay时如下:
LDR R0, =1000000 /* 给delay函数传参数,保存在r0里 */
BL delay
CMP R0, #0 /* 返回值保存在r0中 */
可以看到,在调用delay
之前,直接将1000000赋给寄存器R0
,然后使用BL
调用delay
,此时就通过R0进行了传参。
函数调用结束后,delay
函数的返回值也保存在寄存器R0
中。
如上图是我们生成的反汇编文件,其中机器码是烧写到Flash上的,汇编码只是为了方便我们阅读。
每条指令会对应一个地址,如上图中的0x08000000
,这个地址在Flash中是真实存在的,Flash中的地址也是按照上图中指令的地址这样分布的。
烧到Flash上
地址 | Flash内容 |
---|---|
0x08000000 | 00000000 |
0x08000004 | 08000009 |
0x08000008 | f8dfd004 |
0x0800000c | f000f80c |
0x08000010 | 20010000 |
0x08000014 | bf00b501 |
0x08000018 | 1e419800 |
…… | …… |
如上表所示,烧到Flash上的内容只有机器码,它自动放在与每条指令相对应的地址上。
启动流程:
上电后:
如上图所示汇编代码,上电后,程序会执行Reset_Handler
处,开始执行汇编代码,步骤和C语言的一样。
B
指令跳转回Loop处,循环点亮在调用延时函数delay
时使用的是BL
指令,在延时函数中,使用BNE
判断R0中的值是否为0,延时结束后将LR中的返回地址赋值给PC寄存器。
调用延时函数delay
时,通过寄存器R0传参。
将程序编译并烧录到开发板中,可以看到LED灯在闪烁,本喵这里也不贴图了。
这篇文章中,要对ARM架构有一个框架性的认识,知道CPU是怎么访问内存的,还有要记住这几条常用的汇编指令,其他复杂的指令遇到时自行百度查阅即可。
要明白调用函数是如何传参的,以及板子上电后,程序的执行流程,包括Flash中存放的是什么。