基于ARM-Cortex M3/4的GNU汇编的嵌入式程序设计之一——基本的程序设计

一、嵌入式汇编的简介

1、还要不要用汇编?

15年前在大学的微机原理和单片机原理上,我第一次学了汇编。我当时说,这个破东西有啥用,都淘汰啦。现在都是C,C++、Java、Python等等的天下。然而现在,我在用汇编码每一个驱动和算法。甚至连部分核心的程序我都写成汇编。真的是讽刺呢。

其实在当下,嵌入式C/C++在CM3的应用已经被广泛接受了。ST、TI、NXP等厂家也都为了推广自家的产品推出了自己的库,以及自己的IDE。很多工程师甚至都忘记了要去关注外设寄存器——如GPIO的ODR和IDR、I2C的SR等——的行为,更不会去关注内核寄存器,如r0 - r11,很多人也不会知道r7和r8的区别。但是笔者认为,一个有修养的嵌入式工程师会对底层的这些事物保持兴趣。也正因为此,这样的工程师才能真正的解决问题,提出更优的方案。在驱动层直接和硬件打交道,或者你自己用FPGA做了个定制的CPU,你都会用到汇编,或者是汇编能给你收获奇效,或者是作为你的唯一选择。做为对比,我这里说的C泛指指的是C/C++。我们和python、java做的事情不是一个领域的,所以不作为对比。但是对于那些只想搞个大新闻的,比如rust、D语言,呵呵。具体来说,有以下几点。

a,对MCU的外设操作的稳定性和效率进行精确的把握

汇编所写下的代码其实就是二进制一一对应的。因此可以对MCU的行为进行最精确的把控。比如后面笔者会写的STM32F103的硬件i2c的驱动使实现。因为该模块的操作要求用户对SR1、SR2和DR寄存器有时序地访问,所以如果用汇编,就可以精准地把握这些时序,做出一个简短并且稳定的驱动。同时,可以优化掉冗余的内存访问,所以可以提高效率。但是用C,可能你也会做出一个能用的驱动,但是那个时序看起来不见得严格。所以就算是能用,你也会顾虑你的时序和手册上写的仿佛不一样,会不会有问题。

b, 对MCU内核的利用率提高

第一,寄存器使用更灵活。 你进入汇编函数的时候就有r0到r7共8个寄存器随你调用。当然如果你不介意使用高位寄存器,后面还有r8-r11,就是费点事。一般的C编译器不会轻易调用更多的寄存器的。但是如果你亲自操作底层,操作寄存器可以节省内存访问量。所以相比C的程序,你可以提高效率。
第二,很多C下很麻烦的运算和转换操作其实到了汇编反而是比较简单的。 就比如那些麻烦的位域操作。对内存和寄存器的字、半字和字节的转换等。比如那些左右移位操作。在C语言下往往那就是要单独进行的运算,但是在汇编下往往可以在一句指令里顺便完成。因此从
第三,有些操作只能在汇编下实现。 比如利用SVC异常自己定制一个系统调用,要使用循环移位操作,以及使用标志位,进入低功耗模式等等。
第四,会让你脱离对某型号MCU的限制 。用汇编就意味着要适应寄存器,并且摆脱对芯片头文件的依赖。涉及到ARM核的函数——比如那些NVIC_XXXX(…)——你依旧可以用C或汇编来调用。这样你就不存在只会用哪个料,而是真正能做到谁的料好就用谁的料。
第五,汇编的效率是评价代码效率的金标准。 就比如有人在C++上写了个函数式编程应用,让你一看,哇塞,一句话就搞定那么多事情。但是你拉到汇编上,可能全都是废话。说不定你基于当前MCU几句就解决了。那么对不起,MCU最终还是看汇编的。虽然源代码很简洁,但是编译出来的汇编可能根本不如你自己去实现的。至于近来有人说为了针对C语言无法忍受的这个那个缺点,我们提出RUST、D等语言,我只想说,孩子呀,有个C干你们的那些活就足够了。至于你们说的那些优点,见笑了,你们对C和汇编的强大一无所知。有功夫不要总想着搞个大新闻,还是闷声好好学习。

2、汇编其实是一门很潮也很美的语言

第一,汇编是紧紧跟着MCU的发展而发展的,而C永远都是那个C。 因此在C下面能做的事情永远都是根据C的语法来的,但是如果芯片发展了,直接就是体现在汇编上。比如有了硬件乘法器。这样做个浮点乘就是3次操作寄存器而已。共用体和结构体对于汇编来说只是基本的数据存取而已。芯片的架构直接体现在寄存器上,也直接体现在它的指令集里。用汇编,你就知道怎么用程序,这个芯片最强劲。

第二,语法和词法简洁。 所有的符号定义都是类似PHP的弱类型定义,不存在什么语法格式,所有的指令都是看着指令集来的。51、TI、ARM、Thumb、RISC-V、MIPS、X86。。。随便你来。只要你能执行就都听你的。(当然后果要自行承担)

第三,汇编的指令操作灵活。 当你还在琢磨你要为你这个新的变量其个什么名字的时候,直接push到栈里会让你越来越懒得去理会那些破变量名。当然,你得练好栈的操作。对比的话,ARM系的指令集一般都有强大的栈操作指令集,所以如果你喜欢用栈,或者是要跑操作系统,那么ARM会让你很舒服。MIPS或者RISC-V系的指令集就没有这方面的优势,但是数值运算的指令集非常强大。所以如果你要跑算法,你会发现原先C下很晦涩的语法在汇编居然很好理解也很高效。
第四,对内存的操作会让你得心应手。 汇编之下没有严格的类型。如果你认为你需要内存,那么,你就直接划拉一块内存,上面放32位还是放8位,还是放128位,是浮点还是整形,反正你的草原你的马,你想咋耍就咋耍,你说是啥就是啥。你做个函数,要几个参数都行,就算是你不知道有几个参数,也没问题。比如大名鼎鼎的printf(…)就是这种情况。
第五,设计模式的适应。 设计模式的应用的前提是对面向对象的实现。如果有人说,C++面向对象,C面向过程,你个汇编呢?呵呵,凡人,这暴露了你的3个无知,你对面向对象的无知,对C的无知,对汇编的强大的无知。
第六,逆向工程。 你懂得你懂的你懂得你懂的你懂得你懂的你懂得你懂的你懂得你懂的。

3、别人会认为你很强

对于大部分人来说,汇编就是天书。所以你连汇编能都写能调试,大部分人就会认为你是大牛。

那么实际上,你能写汇编,那就意味着你对内存和寄存器的动态情况和整体规划有思路;调试的时候你要关注更多的动态细节和指令细节。所以,从程序的设计思路和调试水平来看,不错,你,确实很强

二、GNU嵌入式汇编简介

1、GNU汇编伪指令

所有的伪指令可以参见 《GNU Assembler Manual》 。常用的汇编伪指令笔者认为有 :

语法含义
.global <symbol>用于定义一个全局符号,即可以被全局引用,相当于C的extern。
.local <symbol>用于定义一个局部符号,即只能用于本文件,相当于C的static。
.align num按照num字节地址对齐,是2的幂。
.set <symbol> <expression>给一个符号赋予一个表达式或值,相当于C的宏
.section设置下面的代码的所处的段
.word,.short,.byte,.string用于在某个地址放置一块数据。
.size <symbol> <num>向调试器声明某个符号对应的内存或闪存的尺寸
.type <symbol> <info>向调试器声明某个符号对应的信息,info可以是%function或者是%object。前者表示这是个函数,后者表示这是个数据块
.weak若定义该符号。跟C的_weak一样。如果别处重新定义,则这个符号作废。

剩下的有用的我们用的时候再说。如果读者用到了其他的伪指令,那你要测试一下再用才行

2、GNU汇编和ARM汇编

这两种汇编其实只是对伪指令有区别。因为笔者用的在STM32CUBEIDE,EMBEDDED STUDIO,RTT Studio,所以用的汇编都是GNU汇编。但是如果你用的是KEIL,那你要用ARM汇编。所有的伪代码都有对应关系,所以看一个就可以了,另一个就触类旁通了。

3、汇编助记符

笔者用的是STM32F103,所以对应的ARM内核是ARM-Cortex M3内核,采用的指令集是thumb-2。如果你对助记符有兴趣,参考《ARM Cortex-M3 Cortex-M4权威指南》和 《ARM Architecture Reference Manual Thumb-2 Supplement》。 笔者常用的指令有如下,

寄存器操作:
mov rd, rm;
r0 - r11互相之间倒腾值。也只有这一条语句可以访问高位寄存器,即r8-r11。

内存操作:

指令含义
ldr rd, [rm, off];从rm+off的地址加载一个32位数到rd
ldrh rd, [rm, off];从rm+off的地址加载一个16位数到rd
ldrb rd, [rm, off];从rm+off的地址加载一个8位数到rd
str rd, [rm, off];将rd的值以32位数保存到rm+off的地址
strh rd, [rm, off];将rd的值以16位数保存到rm+off的地址
strb rd, [rm, off];将rd的值以8位数保存到rm+off的地址
ldr rd,=大立即数 ; 这是一条thumb2的伪指令,不是GNU的伪指令。该指令用于将一个大的立即数装载到一个低位寄存器

栈操作:
push {r}; r必须是r0-r7,不过可以一次写多个,比如push {r0,r1,r2}压r0, r1, r2入栈,也可使是push {r4 - r7}把r4、r5、r6、r7一起都压入栈。
pop {r};推栈,跟push对称。
位操作:
所有位操作都很重要,但是这里强调bic,可以定点清除某一个或某几个位的值。操作配置寄存器定点清位很好用。
数学运算操作其他的目前不赘述。用到的时候说。

4、内联汇编

必须要提一下这个历史悠久的东西。我们一般还是在C语言环境下使用汇编进行嵌入式开发。而且汇编一般还是用的少。在GNU汇编环境下,我们其实一共有两种使用汇编的方法,一种是plain asm,我一般叫平板汇编;另一个叫inline asm,即内联汇编。传说还有一种叫内嵌汇编,即直接把一个C函数声明成汇编,例如__asm void foo(...){ }这样的,大括号里面就直接汇编就行,好像在KEIL下能用。但是在GNU汇编的环境下好像都不行,比如stmcubeide、rt-studio、embedded-studio、mounriver等等的。

平板汇编,即我们创建个汇编文件,按照规范去设计。这种汇编允许你使用所有的指令集和大部分GNU汇编伪指令。

内联汇编,就是在C函数内以__asm()写汇编。这个详细情况参考《ARM GCC Inline Assembler Cookbook》。网上很多写的。可以打开freertos或者是rt-thread的PendSV异常参考一下,就是那么写的。但是有很多限制,比如不能使用IT、TBB和TBH,也不能使用很多的GNU伪指令。优点是很方便,在C函数内就可以直接搞,而且输入输出都能设置,对于算法优化还是很有帮助的。

三、基本的框架

1、运行环境

但是要先交代一下我们的运行与调试环境。
硬件:STM32F103的一个板子。
IDE:STM32CUBEIDE
语言:C和汇编,平板和内联汇编都会用到

2、 开始一个新的函数

创建个项目

我们用cube先建立一个项目,然后创建一个源文件,叫test.s 。
首先把Core\Startup\startup_stm32f103vetx.s的前面几句复制到我们的文件中,如下所示。

  .syntax unified
  .cpu cortex-m3
  .fpu softvfp
  .thumb

由于CSDN编辑器没有汇编的语法格式,所以只好用了C的格式。
这四句的用处是,可以让我们在自己的test.s中使用32位thumb2指令。其他的我们就先不管了。

定义符号

汇编中首先要定义符号(Symbol)。所谓符号就是用个字符串指代一个数字。我们在汇编里可以显示声明。例如:

/*在汇编文件中可以使用这种注释格式*/
/*这个是显示声明*/
.global user_global_val			//user_global_val这个符号将整个程序全局可见

.local  status, sensor_addr		//sensor_addr	这个符号只在本文中可见

隐式声明就非常灵活了。就像MATLAB一样,第一次使用就是声明了。但是这种隐式声明的都是相当于.local的,即本文可见。

/*
*	test.s
*/
.set 	user_val,		0x2121

.section .text.user_string
.type user_string, %object
user_string: .string "Helloworld!"
.size user_string, .-user_string

myfoo:
	push {r4}
	pop {r4}
	bx lr
/*这里面的user_val、user_string、myfoo全部都是隐式声明,都是仅本文可见的。*/

符号赋值

符号赋值分为显示赋值和隐式赋值。前面的user_val就是显示赋值了0x2121,相当于C的#define。

也可以隐式赋值。前面的user_string和myfoo就是隐式赋值。它们都指代的该行的首地址值。

划分内存块,类似于定义变量

如果我们需要在程序中用到内存,即bss段的地址,那么我们可以按如下方法划分:

.section .bss.user_data_bss		/*在bss段上定义一个符号叫user_data_bss	*/
.type user_data_bss, %object	/*这个符号的类型是%object,即数据对象*/
.align	4						/*下一个数据4字节地址对齐*/
user_data_bss: .space 100		/*从这个地址开始往下空出100个字节*/
.size user_data_bss, .-user_data_bss	/* 告知调试器此处有个变量,占了当前地址减去
										 * user_data_bss的首地址这么多字符的空间*/

第一句用于分配这个符号将用于在bss段上分配内存

第二句指定该对象描述的是一个数据对象

第三句,指定user_data_bss这个符号的值是这个地址的值,这个值后面要分配的空间大小由.space那句指定。由于是在bss段,所以不可以初始化任何值,就用.space隔开就好。

第四句用于通知调试器这个变量的情况。其实也可以自己写个数字在上面,指定调试器这个数据块的大小。但是这里有个问题,就是我们指定的只是告诉调试器的,对于汇编器和链接器来说,不一定真的分配了那么多。只有这样写,分配的空间才是对的。
 前文的user_data_bss变量在memory details中的情况
前文的user_data_bss变量在memory details中的情况。

定义函数

定义一个函数要先定义符号,再在分配的可执行段上分配空间给这个符号。如下所示。

  .syntax unified
  .cpu cortex-m3
  .fpu softvfp
  .thumb
  
.global foo						//定义foo这个符号
.section .text.foo				//将text段上分配foo
	.type foo, %function		//foo是个函数
foo:							//函数开始
	push {r4};
	add r1, r0, #0x02;
	pop {r4};
	bx r8;						//中间都是乱写的,这句结束
.size foo, .-foo				//告诉调试器这个函数的大小

先定义这个符号叫foo。当然,如果不想全局也可以定义成local。把它放到text段上,类型是函数,最后告诉调试器函数的大小。这里不需要考虑汇编器和链接器。他们会根据函数体的文本算出大小分配好地址。
foo函数的情况
这就是foo函数在闪存里的情况。为啥是10个字节呢?这些指令不是thumb-2么?其实是因为add r1, r0, #0x02;这句实际上是add.w r1, r0, #0x02;汇编器智能地识别到我们没有写全,如果在反汇编的窗口看,就很清楚了。
在这里插入图片描述
看,反汇编的代码就很清楚了。原本写的是add r1, r0, #0x02;,反汇编看到的却是add.w r1, r0, #0x02;。这句带.w的表示这句是thumb-2的32位指令,所以要占4个字节。那么可以算出,共需要2+4+2+2=10个字节。这就是这个函数的代码的长度。从地址上看,是0x080105ca - 0x080105c0 = 0x0a = 10。

C 函数下的调用

汇编的函数实现了以后,如果日后要在C语言下调用,形式非常简单。例如:

#include "stdint.h"

extern void foo(void);				//直接将符号从外部引入,函数的类型,你说是啥就是啥,只要名字对了就行
// extern void foo(uint32_t);
uint16_t main_test(void){
// extern uint32_t foo(uint32_t, ...);	//引入到哪里都行
	foo();
//	foo(0);
}

但是参数和返回值怎么实现?如果你用的是ARM的C编译器,那么参数表就是r0-r3,如果还有更多就全都压到中栈里,自己去用SP指针去找。返回值就是r0。

3、小结

这样,我们有了函数,也有了数据。那么后面就可以做点事情了。一个基本的汇编框架就搭起来了。

四、启航

根据前面的内容,我们介绍了学习嵌入式汇编的必要性,汇编的优点。我们以GNU汇编为例,从底层驱动入手,搭建了一个汇编程序的框架。虽然现在这个函数还做不了什么,但是作为一个起点,我们开始着手用汇编实现高效稳定的程序。当然,汇编还是有一定的局限性的,尤其是在做完整的项目的时候,往往要涉及到RTOS、FS、GUI等组件。完全靠汇编对与软件的维护也不现实。还是依赖高级语言来做。但是就作为底层的补充,活用汇编,会让你印象深刻的。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值