[技术讨论] MCU究竟是怎么玩转全局变量的
搞嵌入式软件开发的我们,在程序代码里面会经常与全局变量(global variable)打交道,在有实时操作系统参与的代码里面,甚至有时候可能还会因为全局变量的使用不当带来Bug等问题。另外,我们平时也主要是手动定义和使用全局变量,一般很少关注其在MCU内部的运行情况,除非是出现与其相关的问题,才会去深入排查。
综上情况,我们其实很有必要深入了解一下全局变量在MCU内部的底层运行逻辑,也就是MCU究竟是怎么操纵全局变量来为我们所用的?别着急,跟着我,一步一步来看!
这里所说的全局变量指的是可以应用于工程内所有文件的全局变量以及只作用于(作用域)某个源文件的静态(static)全局变量。
1、我们先来看下代码里怎么定义全局变量的。
下面我们以两个全局变量为例进行阐述:
第一个是是可以应用于工程内所有文件的全局变量:
在app_do.h文件里进行原型声明,目的是可以让其他源文件使用它:
复制
externu8 module_type;
在app_do.c文件里进行定义和初始化:
复制
u8module_type = 0;
第二个是只作用于app_do.c源文件的静态(static)全局变量,这里我们用数组变量来描述,原理上和基本变量一样:
在app_do.c文件里进行定义和初始化:
复制
staticu8 sgm42406_device[6] = {0};
由于只用在app_do.c文件,因此不需要再在app_do.h进行声明。
这里要讲一下,其实初始化全局变量有两种方法,一种就是在定义的时候直接给其赋值初始值(默认值,缺省值),如上所示;另外一种就是在初始化函数接口里面给其赋值,类似下面这样:
那这两种有什么区别吗?可以根据自身需求进行操作:
直接初始化:适用于初始值在编译时可知的情况,如配置常量、固定阈值等等;
函数内赋值:适用于需要根据运行时条件动态设置初始值的场景。
不过个人建议,可以在对应模块的初始化函数接口里统一初始化全局变量,增强代码可管理性。
2、我们再来看下程序编译后全局变量的一些情况
程序编译通过后,我们可以到map(映像)文件里面看下module_type和sgm42406_device的定义情况和内存映射情况,打开map文件,搜索两个全局变量:
另外,两种不同类型的全局变量,其实被划分在不同位置里面:
其中0x20000034和0x20000047其实是编译器为两个全局变量分配的内存存储地址(总得需要一个地方来存变量),严格意义上说,应该是内存起始地址;Data表示程序中的数据符号,如全局变量或静态变量,其大小字段表示该变量占用的字节数,分别是1个字节和6个字节;.data即表示.data段,为已初始化的全局变量。
那它们的地址为什么类似呢,即偏移地址都是0x20000000呢?这就又涉及到MCU存储器地址映射的知识了。
我们知道,全局变量是存储在RAM里面的,有些MCU也写为SRAM,而RAM存储器的地址映射的起始地址一般就是0x20000000,其结束地址取决于MCU的RAM大小,比如如下是我所用工程对应的MCU系列的用户手册里的信息:
另外,在Keil魔术棒里面也可以查看到RAM的信息,主要包括起始地址和大小:
如上所述,起始地址为0x20000000,大小为0x3800字节,即224KB,与用户手册所描述的一样。
3、我们继续来看下MCU运行阶段的变量操作情况
MCU操作全局变量(读写操作),归根结底是通过汇编语言指令操作内存地址的方式来实现的,我们可以仿真看下,MCU复位情况下,Register菜单的开启和显示如下图所示:
常用的寄存器R0和R1等的值都是为0x00000000,这里说一下,对于Cortex-M3和Cortex-M4处理器的寄存器来说,有16个寄存器,包括13个通用目的寄存器和3个特殊用途寄存器,如下图所示:
在对应的汇编代码Disassembly中,可以看到是如何通过汇编指令操作这些寄存器的。
我们在全局变量module_type赋值语句后面打个断点,然后运行程序看下:
R1寄存器的值已经变成0x20000034了,这不就是全局变量module_type的存储地址嘛。
从C语言代码对应的汇编语言代码也可以看出,一条给全局变量赋值的语句,是如何通过汇编指令LDRB,LDR和STRB来实现的:
这三条汇编代码的主要作用大概如下所述:
复制
/*LDRB = Load Register Byte(加载字节到寄存器) 从栈指针(sp)偏移0x04的地址读取1个字节 读取的字节零扩展到32位后存入r0寄存器 源地址 = sp + 4*/LDRB r0,[sp,#0x04]/*LDR = Load Register(加载字到寄存器) 从程序计数器(pc)偏移32的地址读取4个字节(一个字) 读取的值直接存入r1寄存器 源地址 = pc + 32*/LDR r1,[pc,#32] ; @0x08026D7C/*STRB = Store Register Byte(存储字节到内存) 将r0寄存器的最低字节(8位)存储到内存 目标地址 = r1 + 0(即r1指向的地址) */STRB r0,[r1,#0x00]
这段代码实现的功能主要是:
从栈上读取一个字节数据,加载一个内存地址(0x08026D7C)到r1,将读取的字节存储到这个地址处,其实本质上是在将一个字节数据从栈复制到某个固定内存地址,即将局部变量id_value的值赋给全局变量module_type。
再看下通过环形队列给数组sgm42406_device拷贝数据的操作过程:
虽然我们现在基本很少通过汇编语言来直接开发软件,但汇编语言的指令操作其实是最接近硬件底层的,直接通过操作寄存器的方式会显得更加直观,不过汇编语言的平台差异性比较大,可移植比较差,所以现在搞嵌入式基本上都用C语言了,真正的牛人也许还在用汇编语言,还是很厉害的~~
当我们读取全局变量module_type的值的时候,汇编代码同样会进行一些操作:
即通过LDR,LDRB和SUBS等指令进行操作,其中的SUBS指令即减法指令, 将 r0 减去立即数 0xF0(十进制 240,这个值其实就是module_type的值),结果存回 r0:
所以可以看出来,C语言里面的对变量的读操作其实和汇编语言里面的读操作的实现机制其实是不太一样的。
而读取全局数组sgm42406_device的值的汇编代码可以看到是如下所示:
综上所述,当我们查看反汇编代码的时候,其实可以间接的看到内核寄存器的一些变化,而其变化通常与变量的内存存储地址等相关,这就是底层逻辑。
另外,我们可以看出来,每次访问全局变量的时候,不管是读操作还是写操作,常用的汇编指令一般都是LDR,LDRB,STR和STRB等等。
4、我们最后再来看下Keil里怎么监控全局变量
如果需要通过仿真来实时监控(查看)全局变量的值,可以按照下面这样进行。
进入仿真后,首先要勾选“Periodic Window Update”,这样才能在Watch窗口里看到实时变化的全局变量的值:
对于非static的全局变量,直接将变量名称module_type添加到Watch窗口里,然后运行程序,即可看到其值,如果其值发生改变,其窗口会变成蓝色:
对于static全局变量,直接用上面的操作方式是不行的,会报错:
对于static全局变量,要通过类似监控局部变量的打断点单步调试运行一次后,才能用全速运行的方式查看:
先设断点单步调试一次:
然后全速运行,即可实时监控其状态:
另外,如果你想直接在Watch窗口里查看全局变量的存储地址,也是可以的,使用”&+全局变量”的方式就可以:
通过以上操作,就可以同时看到变量的内存存储地址和变量值了。
对于全局数组,因为数组名本身就代表其起始地址的含义,因此不需要再在前面加&运算符。
或者通过Memory Windows也可以查看变量的内存地址:
即在Memory窗口的地址栏输入 ”&+全局变量”后按回车键,窗口就会跳转到该变量的内存地址起始处,并显示该地址存储的十六进制数据:
以上所有,便是MCU定义和使用全局变量的一些过程和底层逻辑描述,有时候了解这些东西,确实是有助于我们反向排查问题的~~来吧,一起学习吧~~
