笔者不才,寄存器的结构体映射这个名字其实是笔者自己造出来的,似乎也没有找到什么关于这方面的官方说法。
先解释一下这个名字吧,以免读者产生奇怪的误解:
寄存器的结构体映射描述的是一种通过修改结构体的值实现修改寄存器内容的关系。
而这篇文章中试图解释的问题是:HAL库如何实现从寄存器到结构体的映射。
一些基础知识
C的指针
指针,一个讨论起C语言恒久离不开的话题,我们来先复习一些关于指针的知识。
指针与uint32_t
缓缓地敲出两句代码:
int a = 0;
int *ptr = &a;
脑海中浮现一个问题:指针是什么?
是一个变量
指针是一个变量的话,a也是一个变量。
我们都知道a的值是0,那么指针ptr的值是什么,又是多少呢?
是一个地址,是&a
显然上面是一句废话,不过还是要接着问:既然这样,那&a的值是多少呢?
这其实是个为难人的问题,毕竟变量的地址并不是我们人为分配的,绝大多数情况下,即使是作为编写代码的人我们也仍然不知道一个变量的地址。
不过其实笔者真正想问的是:&a是个什么类型的值?或者说,ptr是个什么类型的值?
请注意这里的表述:什么类型的值,而不是变量。
是无符号32位整型,也就是我们常用的uint32_t或者u32(叫什么取决于开发环境对于无符号32位整型的重命名)
所有的指针值都是uint32_t吗,当然不是,误解的锅咱不背。
当然,也别向另一个方向误会,上面这句话指的是在其他计算机上指针值不一定是uint32_t,但是至少在STM32单片机上,指针值都是uint32_t。
没错,无论是整型指针、浮点型指针、结构体指针还是联合体指针,值都是uint32_t
我们稍微再追究一下:
众所周知,STM32单片机的字长为32,寻址位数是32位(这两者没啥关系)。换种说法来讲的话就是:STM32单片机的数据总线宽度为32位,而地址总线的宽度刚好也是32位。
抛开数据总线不谈,地址总线的宽度是32位决定了什么呢?
决定了C的指针的长度是32位,即8位十六进制数。
所以,当你运行这个程序时:
#include "stdio.h"
#include "stdint.h"
int main(void) {
int a = 156;
printf("0x%x", &a);
return 0;
}
会得到类似这样的输出:
0x261ff1c
当然,这样写代码大部分编译器都会对于&a
发出警告,因为你的格式控制符表示你想输出的值是一个十六进制的整型,而你放在这里的是整形指针。程序当然可以正常运行,编译器的警告是由于其猜测你想使用的数据类型与实际输入的数据类型不符,所以编译器发出警告以确保你知道你自己在干什么。
想让它不发出警告?简单:
#include "stdio.h"
#include "stdint.h"
int main(void) {
int a = 156;
printf("0x%x", (uint32_t) &a);
return 0;
}
这就好比在你老妈唠叨你:“孩子,都这时候了,你应该...”的时候,你打断她道:“我知道,学习!”。
在编译器唠叨你:“你代码写错了吧!我猜你想传个整型过来,你却...”的时候,你打断道:“我知道自己在干嘛,我就是想把这个指针当整型用!”。编译器自然无话可说。
当然,运行结果不会有任何改变,就好比无论你是否打断你老妈的话都得去学习(害,程序人生呗)。
指针与变量
说话这种东西,有些东西必须要说清楚才行,不然就容易让听者感到糊涂。其实上面虽然说了这么多,但是有一些关键的东西一直没有点出来,是关于变量的:
- 变量是存储一些二进制0和1的容器,无论里面的内容是整型还是浮点型
- 变量这种容器会被放在内存的某个位置,这个位置就叫变量的地址
- 变量这种容器有它自己的用途和特点,就像厨房里的盐罐醋瓶一样,这些被我们称为数据类型
- 数据类型的意义在于:
- 告诉编译器容器的大小
- 告诉编译器容器在内存中应该如何对齐
- 告诉编译器对于容器的内容可以进行何种操作
- 告诉编译器应该如何解释容器中的一堆不明意义的0和1
看完上面这些东西我们会意识到,单纯变量里面的内容其实是没有什么意义的,是数据类型给予了他们意义。
说到这,我们转念一想:指针也是变量啊。
刚才我们也提到了,指针里面的值其实就是uint32_t,那么问题来了:
指针表示地址,那为什么我随便定义一个uint32_t类型的变量不能表示地址呢?
既然说到了我们就试一试:
#include "stdio.h"
#include "stdint.h"
int main(void) {
int a = 156;
uint32_t temp = (uint32_t) &a;
printf("%d", *temp);
return 0;
}
很不幸,并没有过编译。
报错的位置在于*temp
,既然指针,或者说地址值是uint32_t,那为什么用uint32_t不能按照地址的方式寻址到a的值呢?
这个问题中的主要矛盾点在于二者的数据类型是不同的,答案其实在刚刚已经说过了:
数据类型的意义之一在于:
告诉编译器对于容器的内容可以进行何种操作
何种操作,用面向对象的说法叫方法,用最朴素的角度来说其实就是运算。
没错,上面的代码之所以会出问题,并不是因为十六进制数不能作为地址值,而是由于*
这种寻址运算并不能对无符号整型这种数据类型使用。
我们改改之前的代码:
#include "stdio.h"
#include "stdint.h"
int main(void) {
int a = 156;
uint32_t temp = (uint32_t) &a;
printf("%d", *(int *) temp);
return 0;
}
只要在寻址之前将无符号整型转化成整型指针,或者换句话说:
只要在寻址之前告诉编译器:“我想将这个整型作为指针来使用,我知道自己在干什么”,编译器自然不会干扰你的操作。
运行程序,输出的内容跃然纸上:
156
指针与常量
既然说到了数据类型,又说到了常量,那就来说说常量的数据类型。
当然,由const修饰的变量其实几乎和普通变量一样,所以并不在探讨范围之内。
抛开字符常量的数据类型不谈,我们来看看数值常量:
100 //> int
100.0 //> double
100.0f //> float
100u //> unsigned int
100l //> long int
100ul //> usigned long int
显然,当对于一个不加修饰的数值常量而言,没有小数点就会被默认为整型变量,而有了小数点就会默认为双精度类型。
至于常用的三个修饰语:u、l、f
- u代表无符号修饰
- l代表long修饰
- f代表浮点型
- 以上三种修饰可以按照任何合法的方式组合
笔者说这些当然不是为了科普常量数据类型的知识点,而是让读者意识到一件事:
常量是拥有数据类型的,并且默认的常量类型绝不会是一个指针。
相信看到这里应该也不难理解为什么下面这段代码会报错了吧:
(0x0261ff1c
是变量a的内存地址,见上文的某程序)
#include "stdio.h"
#include "stdint.h"
int main(void) {
int a = 156;
printf("%d", *0x0261ff1c);
return 0;
}
当然是因为整型不允许进行寻址操作,相信读者应该也能很容易的想到解决方法:
#include "stdio.h"
#include "stdint.h"
int main(void) {
int a = 156;
printf("%d", *(int *) 0x0261ff1c);
return 0;
}
了解了这些,相信我们就可以继续下一部分了。
单片机的内存映射
标题虽然长,不过实际的内容不过就是一张图:
这张图来自于DS9716,即STM32F401xC的数据手册,描述了整个单片机的内存分布情况。
显然,存放我们代码的片上Flash在这个位置:
不过这并不是我们的重点,因为在正常的使用中我们对于FLASH的存在已经习以为常。这里笔者想要强调的是单片机的外设寄存器:
由于HAL库的封装,导致我们对于寄存器的底层信息知之甚少。
所以,熟悉一下这张图我们再继续下去。
众所周知,STM32有两条高速总线(AHB1、2)和两条低速总线(APB1、2),他们的内存分布正如内存映射图上画的一样,要想获得更细节的信息,我们要参考寄存器边界地址表(节选):
知道了这些我们就可以结束复习,继续向下进行了。
一个好奇的产生
每个想法的产生当然都是有原因的,先来说说笔者是如何注意到使用结构体来映射寄存器的现象的吧。
第一次接触到寄存器这个词语是在学习定时器的时候,当时试图理解定时器的工作原理,所以不得不开始考虑寄存器是个什么玩意。
当时想要修改定时器的某个寄存器的值就需要写上这么一段代码:
TIM2->CCR1 = 999;
当时用我浅显的认知看出来这其实是在对结构体的一个成员赋值,所以就觉得结构体好厉害,还可以操控寄存器。
随着时间的流逝,对C的了解越来越多,发现结构体虽然可以进行寄存器的封装,但是并不代表着映射到了寄存器。
这个时候还没有很糊涂。
后来见到了一些芯片厂商写的库(某sence),发现在初始化的时候其实是利用总线通讯将结构体的值送到了寄存器中来实现了两边的值的同步。
知道了这件事之后笔者懵逼了:
单片机的结构体和寄存器是如何同步的呢?
读读HAL源码
想探究这个问题的话我们还是来读一读HAL的源码吧,毕竟没有比代码讲东西讲的更清楚的了。
就用笔者的寄存器启蒙外设来探索这个问题吧:定时器。
进入到一个开了定时器的工程,打开main.c,直接去看看定时器的初始化代码:
MX_TIM2_Init();
进入函数就进入了tim.c文件中,可以看到在文件前面定义了全局的定时器句柄:
TIM_HandleTypeDef htim2;
接着看看初始化函数内都干了什么:
基本就是对寄存器句柄的各种成员赋值云云,然后调用一个Init函数来将这些配置加载到寄存器中,所以我们抓一个初始化函数来康康:
大致看了看发现所有的寄存器赋值都是对于传入的TIMx的结构体成员来赋值。
那么是怎么实现结构体和寄存器的内容同步呢?
内含寄存器成员的结构体其实是htim句柄的一个实例成员:
htim2.Instance = TIM2;
所以我们来看看这个结构体的定义:
缓缓打出一个问号:?
啥??不是结构体??????是个宏???
回去看看定时器句柄的定义其实不难发现,是我们肤浅了:
这个实例成员不是一个结构体,而是个结构体指针,指向的类型就是TIM_TypeDef
。
所以上面的宏定义其实是把TIM2_BASE
强制转换成了一个指向TIM_TypeDef
的结构体指针,这并没有什么问题,我们继续向下探究:
似乎是个十六进制整数?接着看:
终于找到头了。
这个0x40000000是啥呢?不知道你有没有觉得眼熟,反正笔者是很眼熟的,这分明就是内存映射图中的外设内存块基址嘛:
而APB1总线外设的基址和外设基址是一样的:
而定时器2的寄存器边界相对于APB1总线外设基址的偏移刚好是0。
于是我们获得了定时器2的寄存器在内存中的地址:0x40000000
结合我们之前复习的指针的知识,只要将这个整型地址转化为结构体指针,即可直接通过结构体来访问这段内存了。
再来看看寄存器结构体的定义:
前面的__IO
其实是经过粉饰之后的volatile
。
值得一提的是:
笔者曾经认为volatile才是实现寄存器到结构体映射的关键,但是其实并不是。
volatile
的作用其实是告诉编译器:“这个变量有可能被以非软件的方式改变(硬件修改寄存器值),所以不要优化这个变量相关的代码,并且要从内存中密切关注这个变量的变化。”
综上,其实根本就没有什么寄存器和结构体内容的同步过程,因为他们从内存上来讲本就是同一个东西。
说点别的
关于能否映射的迷思
既然都讲到这里了,再来说说笔者之前糊涂的地方吧。
为什么我们用的一些功能芯片(例如各种传感器)不能像这样实现结构体的寄存器映射呢?
答案其实很简单,就是由于这些芯片不在单片机的寻址范围之内,而是通过IIC或者SPI这种总线来与芯片进行连接。
无法被视为芯片的一部分,自然就不能直接使用结构体进行映射了。
关于FLASH的额外内容
之前讲述的内容涉及到了片上FLASH的内存映射,但是片外FLASH由于是使用SPI与单片机进行通信的,自然就不能进行映射。
但是,凡事总有个例外:
其实一些单片机具有QSPI外设,可以开启内存映射模式来连接FLASH,即可将片外FLASH的地址映射到单片机的内存中,进而直接使用指针来访问上面的数据了。
不过,这部分内容并不是我们当前关心的,所以感兴趣的话大家自行查阅吧。