如何实现第一个单片机裸机程序(附汇编指令)

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
	

因为我们的程序特别小,设置栈是向下生长的,只要跟我们的程序代码部分不冲突就可以了。

六、实现按键程序(看门口)

看门口:通过定时器去保持系统稳定,他在倒数,当倒数到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

附:汇编指令

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,见下图。
在这里插入图片描述

以上是关于如何实现第一个单片机裸机程序(附汇编指令)的主要内容,如果未能解决你的问题,请参考以下文章

如何实现第一个单片机裸机程序(附汇编指令)

ARM汇编指令-STM32单片机启动

(023) 关于51单片机的A5指令

汇编语言如何读取一个地址中存储的变量

求51单片机 数字音乐盒 汇编语言代码

STM32单片机算法指令?