如何实现第一个单片机裸机程序(附汇编指令)
Posted Jocelin47
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如何实现第一个单片机裸机程序(附汇编指令)相关的知识,希望对你有一定的参考价值。
一、(8-2)
一个芯片上面有片内SRAM内存(4K),NOR Flash(2M) , Nand控制器(256M),GPIO控制器
启动过程:(大多数ARM芯片从0地址启动)
1、NOR 启动, NOR Flash基址为0 CPU读取NOR上第一个指令(前4字节)执行,CPU继续读取其他指令执行。
2、NAND 启动, 片内4K RAM基地址为0,它会把NandFlash前面4K的内容拷贝到RAM中来,然后CPU从0地址取出第一条指令执行。
总结:也就是我设为Nor启动的时候,我的NOR FLASH上面0地址才是基地址,而片内RAM的地址为0x4000 0000 = 1GB;而用到NAND FLASH的时候是用的片内RAM,4K的片内RAM的基地址0开始运行,NOR FLASH不可访问。
1KB = 1024B = 2^10B = 0x400
1MB = 1024 * 1024 = 2^20B = 0X10000 = 1 0000 0000 0000 0000 0000(二进制)
1GB = 2^30B
二、仅通过汇编指令实现点灯(8-3)
led_on.s
//假设点亮LED:gpf4
.text //表示代码段
.global _start //第一条指令 , _start它表示一个标号
_start:
/*
配置GPF4为输出引脚
把0x100写到地址0x56000050地址上
*/
ldr r1 ,=0x56000050
mov r0, #0x100 //或者使用ldr r0, =0x100
str r0, [r1] //把r0的值写到r1的地址上去
/*
设置GPF4输出为高电平
把0x10写到地址0x56000054 -- 0x10 = 0001 0000正好是第4个索引位置上(从0开始的)
由于是点亮led是写0
*/
ldr r1 ,=0x56000054
ldr r0, =0 //或者使用ldr r0, #0x100
str r0, [r1] //把r0的值写到r1的地址上去
/*加入一个死循环,因为我们生成.bin程序,如果后面不设置为死循环,后面的二进制的内容我们无法知道,我想的这应该就是单片机程序中的防止跑飞*/
halt:
b halt
.text 部分是处理器开始执行代码的地方,指定了后续编译出来的内容放在代码段【可执行】,是arm-gcc编译器的关键词。
.global关键字用来让一个符号对链接器可见,可以供其他链接对象模块使用;告诉编译器后续跟的是一个全局可见的名字【可能是变量,也可以是函数名】
global _start 让 _start 符号成为可见的标识符,这样链接器就知道跳转到程序中的什么地方并开始执行。
_start是默认起始地址,也是编译、链接后程序的起始地址。由于程序是通过加载器来加载的,必须要找到 _start名字的函数,因此_start必须定义成全局的,以便存在于编译后的全局符号表中,供其它程序【如加载器】寻找到
arm-linux-gcc -c - o led_on.o led_on.S //编译
arm-linux-ld -Ttest 0 led_on.o -o led_on.elf //链接
arm-linux-objcopy - o binaru -S led_on.elf led_on.bin //生成bin文件
可以写在一个makefile中
all:
arm-linux-gcc -c -o led_on.o led_on.S
arm-linux-ld -Ttest 0 led_on.o -o led_on.elf
arm-linux-objcopy -o binaru -S led_on.elf led_on.bin
clean:
rm *.bin *.o *.elf
生成 elf 文件并不是能直接用在嵌入式平台上面裸跑的,因为我们并没有操作系统,我们不需要elf文件头的那些指示信息提供给操作系统(在linux上头部会有魔数,告诉操作系统我是JAVA还是elf文件还是#!/bin/bash类型的文件),指示系统怎么去加载文件,在嵌入式上面的完全没有那个必要,只需要将实际的代码提取出来,直接运行就OK,也就是 objcopy的操作。
三、汇编与机器码(8-4)
前面说到ldr是一个伪指令,我们将elf文件反汇编一下查看一下真正汇编指令
all:
arm-linux-gcc -c -o led_on.o led_on.S
arm-linux-ld -Ttest0 led_on.o -o led_on.elf
arm-linux-objcopy -o binaru -S led_on.elf led_on.bin
arm-linux-objcopy -D led_on.elf > led_on.dis
clean:
rm *.bin *.o *.elf
查看反汇编dis文件的内容
第一列是地址,第二列是机器码,第三列是汇编码
pc = 当前指令地址+8
因为ARM是以流水线的方式运行的
当前执行地址A的指令,已经在对地址A+4 的指令进行译码,已经在读取A+8(PC的当前值)的指令。
0地址的指令可以变成 r1 = [ pc + 20 ] = [ 8 + 20 ] = [0x1c] 去0x1c地址去读取它的内存的值 = 0x5600 0050
8地址就是把0x100写到0x56000050地址中去
c地址上 r1 = [0xc + 8 + 12 ] = [ 32 ] = [ 0x20 ] 去0x20地址去读取它的内存的值 = 0x5600 0054
我们看到旁边的机器码,在bin文件通过16进制可以看到与反汇编中机器码是一致的,编译器把这些伪指令转换成真正的汇编码
如果想要电量GPF5的话需要写0x400到0x5600 0050
查看MOV的机器码。发现影响立即数的就是最后的0-11位
这12位如何表示:分为高4位(rotate移位数),低8位(immed_8)
立即数= immed_8 循环右移(2 * rotate)位 = 1 << (1100 = 12) = 1右移24位 = 31个0 1 右移24位后就是 0x100 = (23个0) 1 (8个0)
因此我们少移动2位的话就是0x400 = 1 循环右移22位
可以总结 C/汇编语言是给人类看的 —> bin文件是给机器看的,CPU只管机器码
四、用c语言实现点灯程序(8-7)
int main()
unsigned int *pGPFCON = (unsigned int *)0x56000050;
unsigned int *pGPFDAT = (unsigned int *)0x56000054;
/* 配置GPF4为输出引脚 */
*pGPFCON = 0x100;
/* 设置GPF4输出0 */
*pGPFDAT = 0;
return 0;
a. 我们写出了main函数, 谁来调用它?
b. main函数中变量保存在内存中, 这个内存地址是多少?
答: 我们还需要写一个汇编代码, 给main函数设置内存, 调用main函数
还需要写一个汇编代码,给main函数设置内存,调用main函数
.text
.global _start
_start: //前4行都是固定的语法
/* 设置内存: sp 栈 */
ldr sp, =4096 /* nand启动 */
// ldr sp, =0x40000000+4096 /* nor启动 */
/* 调用main */
bl main
/* 保存返回地址 */
halt:
b halt
局部变量都应该存放在栈中
设置为NAND启动的时候使用片内的4K内存,也就是4096,我们把栈 设置在最顶部
makefile文件:
all:
arm-linux-gcc -c -o led.o led.c
arm-linux-gcc -c -o start.o start.S
arm-linux-ld -Ttext 0 start.o led.o -o led.elf
arm-linux-objcopy -O binary -S led.elf led.bin
arm-linux-objdump -D led.elf > led.dis
clean:
rm *.bin *.o *.elf *.dis
五、解析C程序的内部机制,汇编和C如何调用起来的
首先我们知道
start.s作用: (1) 设置栈 (2)调用main并把返回地址保存在lr中
led.c : main : (1)定义两个局部变量 (2)设置变量 (3)return 0
那就有几个疑问了:
(1)为何要设置栈
因此C函数要用
(2)怎么使用栈
a.保存局部变量 b.保存lr等寄存器
(3)a.被调用如何把返回值返回给调用者?b.调用者如何把参数传给被调用者?
ATPCS:ARM-THUMB procedure call standard(ARM-Thumb过程调用标准)
调用者 通过r0-r3传给或传回 被调用者,r4-r11可能被使用,所以在函数的入口保存他们,在出口恢复他们。 (下面解析代码的时候main函数中的return 0就是保存在)
bl main 返回地址保存在lr寄存器里面,假设main函数也调用其他函数,调用完其他函数后,他也应该返回地址到lr上,main函数的返回地址就被子函数的返回地址覆盖了
dis反汇编文件(我们来开始一条一条的分析执行过程):
led.elf: file format elf32-littlearm
Disassembly of section .text:
00000000 <_start>:
0: e3a0da01 mov sp, #4096 ; 0x1000
4: eb000000 bl c <main> /* bl c跳转到c地址也就是main段下面的c地址开始执行,同时保存lr = 8 */
00000008 <halt>:
8: eafffffe b 8 <halt> /* 这里是lr的返回地址即main函数的出口地址,对应汇编指令 halt: b halt循环*/
0000000c <main>:
c: e1a0c00d mov ip, sp // ip = so = 4096
10: e92dd800 stmdb sp!, fp, ip, lr, pc //pc = 当前指令地址+8 (这一段前面说过流水线) = 10 + 8 = 0x18 ,lr = 8,ip = 4096,fp = 未定义,sp = 4096 - 4 * 4 = 4080(因为加了!所以需要修改后的sp值写会sp中),此时栈中存放的内存布局见下图,高地址存高位寄存器,即R15先存高地址
14: e24cb004 sub fp, ip, #4 ; 0x4 //fp = ip - 4 = 4092
18: e24dd008 sub sp, sp, #8 ; 0x8 // sp = sp - 8 = 4080 - 8 = 4072
1c: e3a03456 mov r3, #1442840576 ; 0x56000000 // r3 = 0x56000000
20: e2833050 add r3, r3, #80 ; 0x50 //r3 = 0x56000050
24: e50b3010 str r3, [fp, #-16] // r3保存在fp - 16 = 4092 - 16 = 4076 的地方,局部变量保存在栈中
28: e3a03456 mov r3, #1442840576 ; 0x56000000
2c: e2833054 add r3, r3, #84 ; 0x54 //r3 = 0x56000054
30: e50b3014 str r3, [fp, #-20]// r3保存在fp - 20 = 4092 - 20 = 4072 的地方
34: e51b2010 ldr r2, [fp, #-16] // r2 = fp - 16 = 4076的地方取值 = 0x56000050
38: e3a03c01 mov r3, #256 ; 0x100 // r3 = 0x100
3c: e5823000 str r3, [r2] // r3的值存入到r2的地址中去
40: e51b2014 ldr r2, [fp, #-20] // r2 = fp - 20 = 4072的地方取值 = 0x56000054
44: e3a03000 mov r3, #0 ; 0x0 // r3 = 0
48: e5823000 str r3, [r2] //这里同理把0写入0x56000054的地址中
4c: e3a03000 mov r3, #0 ; 0x0 // 这里开始4c 50地址就是return 0的操作了,编译器没那么聪明,先把r3=0
50: e1a00003 mov r0, r3 //再把r0 = r3,多此一举了,直接把r0 = 0就可以了
54: e24bd00c sub sp, fp, #12 ; 0xc //恢复栈 sp = fp - 12 = 4092 - 12 = 4080
58: e89da800 ldmia sp, fp, sp, pc //从栈中恢复寄存器,从sp = 4080开始恢复,配合下图内存中的布局:fp = [4080] = 4080 , sp = [4084] = 4096, pc = [4088] = 8-->调回0x8的地址,main返回,返回到halt
//具体stmdb、ldmia操作见下面汇编指令中ldm、stm (8-8) 中 ia 、db的内容。
Disassembly of section .comment:
00000000 <.comment>:
0: 43434700 cmpmi r3, #0 ; 0x0
4: 4728203a undefined
8: 2029554e eorcs r5, r9, lr, asr #10
c: 2e342e33 mrccs 14, 1, r2, cr4, cr3, 1
10: Address 0x10 is out of bounds.
我们可以看到栈中前面几位是用来保存寄存器的值的,函数返回之前会从这里恢复寄存器,下面的内容就是局部变量,sp进去之前是4096,出来后还是4096
bin文件中最后一个确实是e89da800,elf中的comment段并没有放进去,comment的中文名字叫做注释,bin文件肯定不需要注释
现在的代码只涉及被调用者给调用者返回值,那调用者如何传参给被调用者呢?
直接通过r0传入就可以了,见下面的代码,都是通过r0传入来实现不同的形参传入效果
C代码:
void delay(volatile int d)
while (d--);
int led_on(int which)
unsigned int *pGPFCON = (unsigned int *)0x56000050;
unsigned int *pGPFDAT = (unsigned int *)0x56000054;
if (which == 4)
/* 配置GPF4为输出引脚 */
*pGPFCON = 0x100;
else if (which == 5)
/* 配置GPF5为输出引脚 */
*pGPFCON = 0x400;
/* 设置GPF4/5输出0 */
*pGPFDAT = 0;
return 0;
汇编代码:
.text
.global _start
_start:
/* 设置内存: sp 栈 */
ldr sp, =4096 /* nand flash启动 */
// ldr sp, =0x40000000+4096 /* nor flash启动 */
mov r0, #4 //把4作为形参传入到led_on函数中
bl led_on
ldr r0, =100000 //把100000 作为形参传入到led_on函数中
bl delay
mov r0, #5 //把5作为形参传入到led_on函数中
bl led_on
halt:
b halt
因为我们的程序特别小,设置栈是向下生长的,只要跟我们的程序代码部分不冲突就可以了。
六、关看门口以及判断Nor或者Nand启动
看门口:通过定时器去保持系统稳定,他在倒数,当倒数到0之前你要去设置它,如果到0的话他就会复位我们的系统,避免系统卡死。
norflash可以理解成硬盘一样的东西,可以像内存一样读,但不能想内存那样去写,如果一个硬盘很容易去写的话,那岂不是很容易被破坏,所以要写的时候需要发送一定的格式才可以。所以根据这个特性,nor不可以去写,那我们去写一个值到0地址,如果读取出来不是0那就是nand如果是0那就是nor
.text
.global _start
_start:
/* 关闭看门狗 */
ldr r0, =0x53000000
ldr r1, =0
str r1, [r0]
/* 设置内存: sp 栈 */
/* 分辨是nor/nand启动
* 写0到0地址, 再读出来
* 如果得到0, 表示0地址上的内容被修改了, 它对应ram, 这就是nand启动
* 否则就是nor启动
*/
mov r1, #0
ldr r0, [r1] /* 读出原来的值备份 */
str r1, [r1] /* 0->[0] */
ldr r2, [r1] /* r2=[0] */
cmp r1, r2 /* r1==r2? 如果相等表示是NAND启动 */
ldr sp, =0x40000000+4096 /* 先假设是nor启动 */
moveq sp, #4096 /* nand启动 */
streq r0, [r1] /* 恢复原来的值 */
bl main
halt:
b halt
七、中断的引入
-
查询方式
通过大循环不断去判断事件有没有发生 -
中断方式
在大循环中做自己的事情,如果中断发生了就马上去处理中断事件。 -
中断任务怎么被调用(核心:怎么进入到中断服务程序?):
中断控制器来发送信号给CPU
-
ARM对于异常(中断)的使用过程
1、初始化a.设置中断源(硬件处理可以屏蔽某些中断源,比如某些按键中断。软件的中断无法屏蔽。),让他可以产生中断;
b.设置中断控制器(屏蔽,优先级)
c.设置CPU总开关(使能中断)2、执行其他程序
3、产生中断产生中断,举例:按下按键—>中断控制器—>CPU
4、cpu每执行完一条指令都会检查有无中断/异常产生
5、发现有异常/中断产生,开始中断程序的处理:
对于不同的异常跳去不同的地址(这些地址通常是排在一块的,叫做异常向量 )执行程序,这些地址上只是一条跳转指令,跳转执行某个函数;
发生中断时,CPU跳到对应的地址执行
6、这些中断函数处理过程:
a.保存现场
b.调用对应服务程序,处理异常(中断)
c.恢复现场 -
不同的芯片,不同的架构,在这方面的处理稍有差别:
保存/恢复现场:cortex M3/M4是硬件实现的,cortex A7是软件实现的
CPU中止当前执行,跳转去执行处理异常的代码:也有差异- cortex M3/M4在向量表上放置的是函数地址
- cortex A7在向量表上放置的是跳转指令
八、ARM的CPU模式、状态(state)与寄存器
8.1 7种模式
1、a、usr:正常模式
2、b、sys:兴奋模式
3、异常模式:
c、und 未定义指令模式,当CPU不认识这个指令的时候进入这个模式
d、svc 管理模式
e、abt 中止模式1)指令预取模式
2)数据访问模式f、IRQ 中断模式
g、FIQ 快中断
b-g6种模式为特权模式,可以编程操作CPSR直接进入其他模式;
a为用户模式,不可直接进入其他模式;
灰色表示模式专属的寄存器,发现R13、R14他们都有的专属寄存器,R13:sp栈,R14:LR链接寄存器(保存发生异常时的指令地址)
中断处理流程
a.保存现场
保存被中断模式的寄存器。
比如我在用户模式下,假设我保存r0-r14等寄存器,然后去处理异常,回来后再恢复这些寄存器。
如果我发生的是快中断,我就不需要保存被中断模式下的R8-R12了,因为我有自己专属模式下的R8-R12寄存器,并不会影响到你(这就是在FIQ模式下备份寄存器特别多的原因),linux下并不会使用FIQ模式,作为单片机情况下才会使用
b.调用对应服务程序,处理异常(中断)
c.恢复现场
8.2 状态state
ARM state:ARM指令集,每个指令4字节
Thumb state:Thumb指令集、每个指令2字节(减少程序存储的空间)
意思就是mov R0, R1 在ARM指令集下占4字节的机器码,Thumb占2字节的机器码
8.3 CPSR/SPSR
(1) CPSR状态位分析
1、 M4-M0表示当前CPU处于那种模式,也可以修改模式位进入那种模式(用户模式下没有权限修改)
2、bit5 T 表示当前处于ARM state还是Thumb state
3、bit6/bit7为1时,表示禁止所有的FIQ/IRQ
3、bit8-27保留位
4、bit31-28表示状态位
cmp R0,R1 表示如果两个相等,Zero位为1
beq xxx(表示跳转到xxx,但是得先判断第Zero位是否为1,如果为1就跳转)
cmp R0,R1 这条指令会影响到Z位
beq xxx这条指令会使用Z位,如果Z位为1,则跳转
(2) SPSR分析
SPSR:S表示save,用来保存被中断模式的CPSR,比如发生IRQ打断用户模式,那么这个时候IRQ的SPSR就会保存被中断的用户程序的CPSR。
(3)进入异常时的处理流程(硬件实现的)
1、把被中断模式下的下一条指令的地址保存在LR寄存器中 = PC + 4/8
2、把CPSR保存到即将进入到异常模式下的SPSR
3、修改CPSR的M4-M0模式位,进入异常模式
4、跳到向量表
(4)退出异常时的处理流程
1、让LR寄存器减去某个值,赋值给PC,PC = LR_异常 - offset,规则如下图所示
2、把CPSR = SPSR_异常,也就是进入程序时的CPSR把他恢复回来
3、如果是中断模式下,清中断操作
九、自己实现und_exception
对应的向量表
.text
.global _start
_start:
b reset ./* 中断向量表0地址 0:reset */
b do_und /* 中断向量表4地址 4:und */
do_und:
reset:
/* 关闭看门狗 */
ldr r0, =0x53000000
ldr r1, =0
str r1, [r0]
/* 设置内存: sp 栈 */
/* 分辨是nor/nand启动
* 写0到0地址, 再读出来
* 如果得到0, 表示0地址上的内容被修改了, 它对应ram, 这就是nand启动
* 否则就是nor启动
*/
mov r1, #0
ldr r0, [r1] /* 读出原来的值备份 */
str r1, [r1] /* 0->[0] */
ldr r2, [r1] /* r2=[0] */
cmp r1, r2 /* r1==r2? 如果相等表示是NAND启动 */
ldr sp, =0x40000000+4096 /* 先假设是nor启动 */
moveq sp, #4096 /* nand启动 */
streq r0, [r1] /* 恢复原来的值 */
bl main
halt:
b halt
十、如何实现一个按键中断
1、设置中断,让按键能够发出中断
2、设置中断控制器,让他能发出中断给CPU
3、设置CPU,控制CPSR的I位,它是总开关
附:汇编指令
LDR 读内存
LDR R0,[R1] //假设R1的值是x,读取地址x上的数据(4字节),保存到R0
STR 写内存命令
STR R0, [R1] 假设R1的值是x,把R0的值写到地址x(4字节)
B 跳转
MOV
MOV R0 ,R1 // 把R1的值赋值给R0 MOV R0, #100
// R0 = 0x100
MOV和LDR的区别
R0, = 0x12345678 // R0 = 0x12345678
伪指令,它会被拆分为几条真正的ARM指令,如果用MOV对于32位的指令,他有位表示MOV有几位表示R0,那剩下的不足32位村放不下0x12345678,不可以表示任意值,只能表示简单值比如0x100就是简单值(这个简单值叫做立即数),所以如果用MOV R0,0x12345678这是错误的。
add
add r0, r1 ,#4 // r0 = r1 + 4
add r0, r1, r2 // r0 = r1 + r2
sub
sub r0, r1 ,#4 // r0 = r1 - 4
sub r0, r1, r2 // r0 = r1 - r2
BL: brarch and link
bl xxx -> (1 )跳转到xxx (2) 把 返回地址(下一条指令的地址)保存到 lr 寄存器中
ldm、stm (8-8)
ldmia
stmdb
m表示many,ldr的时候一次只能操作一个寄存器
ldm 读内存,写入多个寄存器
stm 把多个寄存器的值写入内存
ia 、db
stmdb sp!,fp, ip, lr, pc
对上面一行汇编代码进行解析:
假设sp = 4096,db表示预先减少
因为内存空间是0-4095才对,4096已经超出了,所以我们使用db预先减少
因此是先减后存,我们已经减完了到sp’ = sp - 4 = 4092,后面需要存储寄存器的值
规则:高编号的寄存器存在高寄存器,因此里面顺序随便
因此:4092-4095 存放PC = R15
在执行先减后存的操作 sp; = sp - 4 = 4092 - 4 = 4088
4088-4091 存放lr= R14
后面一次执行先减后存的操作得到:
4084-4087 存放ip= R12
4080-4083 存放fp = R11
ldmia sp!,fp, sp, pc
ia表示后增,那就是先读后增 ,高编号寄存器存放高地址内存值 。
(1) 因此刚开始是fp = 4080- 4083的值 = 原来保存的fp (低编号寄存器存放低地址内容的值)
(2) 后增sp’ = sp + 4 = 4084
(3) 先读: sp = 4084 - 4087的值= 原来保存的ip
(4) 后增sp’ = sp + 4 = 4088
(5) 先读: sp = 4088 - 4091的值= 原来保存的p’c
(6) 后增sp’ = sp + 4 = 4092
这里的sp后面没有!:表示sp修改后的地址值不存入sp中,因此在第(3)步中已经把原来传入的ip赋值给sp了,而这个ip在程序的最开始的时候就是传入的最原始的sp = 4096,见下图。
以上是关于如何实现第一个单片机裸机程序(附汇编指令)的主要内容,如果未能解决你的问题,请参考以下文章