[嵌入式] 给非科班的单片机寄存器操作快速上手指南
0. 绪论
对于经过系统培训的开发者. 单片机或SoC的驱动开发, 不管是使用各种库还是直接上寄存器, 都不成问题.
可是很多非专业但是需要干嵌入式或单片机工程的人, 比如机械, 车辆工程, 物理, 或是其他的一些专业. 有时候这些学生需要搞比赛, 做项目, 不可避免的要用到一些单片机 (由于种种原因, 现在几乎都会首选STM32). 但是缺乏系统训练的学生往往无法看懂寄存器版本的例程, 或是别人的开源项目. 自己写的话更是不知道如何开始. 或者编译完成后总是出现莫名的问题. 这给大家带来了极大的困难.
同时互联网上大部分教程都是转载来转载去, 各种差异和版本都有. 很多人即使看过了也是一头雾水. 所以应一个同学要求, 大概写一下寄存器的一些操作.
本教程将会使用”学生用户体量庞大”的STM32F1xx系列的单片机作为例子. 但是不必担心. 所有的寄存器操作都是共通的. 并不会有本质的差异. 希望你能快速阅读完并且理解完, 然后查阅自己需要的资料. 不管是什么东西, 这种方式都是通用的.
嵌入式软件开发具有一个比较庞大的知识体系. 限于作者的时间和水平, 本文不可能讲太多东西. 但是如果你只是因为不会寄存器开发而陷入瓶颈, 那么本文将会有较大帮助 (毕竟这个是写文章的目的).
理解本文需要一些基础, 大概包括: c/c++, 一些数学, 计算机基础知识. 大多数理工类专业都会有相关的课程的. 如果确实存疑可以即时搜索.
让我们先记住开发的正确方式: 摘抄借鉴, 修改糅合,以实现功能为目的, 以别人的代码为基础.
只要不涉及什么知识产权的话, 这样做是完全没有问题的. 毕竟不需要把很多基础性的东西写来写去. 所以请大胆的找开源项目, 尽可能在基础上改, 而不是从零开发. 不废话了, 开始正事.
1. 数学:数制, 进制换算
数制也称为“计数制”,是用一组固定的符号和统一的规则来表示数值的方法。任何一个数制都包含两个基本要素:基数和位权. [1]任何一个数字都能这样表示, 例如
其中绿色部分叫做基数, 红色部分的值叫做进制, 进制上的指数叫做位权.
比如
计算机常用2进制. 就是只有1和0的数字. 其中1一般认为是高电平表示true, 逻辑真值, 0一般是低电平, false, 逻辑假值.
可以计算出来
定理: 在任意进制下, 不一定所有的有理数都可以使用有限位数的小数形式表示.
比如十进制下的0.1就是二进制的无限不循环小数. 上面那个例子也是.
著名的 IEEE754 float浮点数标准导致的bug: 在很多语言中, 0.1+0.2≠0.3
就是因为0.1是二进制无限循环小数的原因. 但是存储器位宽不能是无穷的. 所以产生舍入误差.

所以在很多项目中, 为了实现当两个值相等时触发什么函数, 往往不会直接写相等, 而是两者的差值小于多少时即生效
float a,b;
........
if(a==b)
{
//这种写法不建议
}
........
if(abs(a-b)<=0.00001)
{
//一般这么写
}
二进制与十/十六进制的转换
刚才那个方法就可以直接算. 还有其他算法这里不讲. 先让我贴一个表格:
| BIN | DEC | HEX |
|---|---|---|
| 0000 | 0 | 0 |
| 0001 | 1 | 1 |
| 0010 | 2 | 2 |
| 0011 | 3 | 3 |
| 0100 | 4 | 4 |
| 0101 | 5 | 5 |
| 0110 | 6 | 6 |
| 0111 | 7 | 7 |
| 1000 | 8 | 8 |
| 1001 | 9 | 9 |
| 1010 | 10 | A |
| 1011 | 11 | B |
| 1100 | 12 | C |
| 1101 | 13 | D |
| 1110 | 14 | E |
| 1111 | 15 | F |
BIN是二进制, DEC是十进制, HEX是十六进制. 都是英语简写.
十六进制和二进制可以直接换算. 方法是每四位二进制看作一个十六进制
比如
0110 1101 1011 1001
6 D B 9
转换表背过的话读代码快些. 因为一般情况下, 为了让一行代码看的不至于太长, 人们会用十六进制代表二进制. 尤其是对于拥有32位cortex-M3内核的STM32F1系列单片机, 一次写一个三十二位数属实太冗长.
c/c++中, 默认写的数字都是十进制. 二进制应该是0b开头, 比如0b00101100, 而十六进制是0x开头, 比如0x3C.
//一般这么写
GPIOB->CRL&=0x00440000;
//这么写就不太美观了
GPIOB->CRL&=0b00000000010001000000000000000000;
数学差不多了. 开始正文.
2. c/c++语言基础
a+b; //加法
a-b; //减法
a*b; //乘法
a/b; //除法, 所有计算需要注意整型(整数)和浮点型(小数)的运算区别. 如有疑问自行搜索
a%b; //求模. 就是小学学的余数. 14÷4=3 ... 2 这里 14mod4=2, 14/4=3. float(14)/4=3.5f
a<<b; //左移. 将a看为二进制数, 整个向左移动b位. 比如 00000110<<2 就是 00011000. 当溢出的时候会发生什么呢?
a>>b; //右移. 和上面一样的功能.
a&b; //按位求与(AND), 比如 11010010
// & 01010011
// ------------
// 01010010
a|b; //按位取或(OR)
~a; //按位取非(NOT)
!a; //逻辑非(注意和上面的区别)
c+=a; //相当于 c=c+a; 其他算符同理.
重点看左右移和位操作(与, 或, 非等).
3. 我们配寄存器(register)到底是在配置什么
首先, 你的最终目的都是使用单片机的GPIO(general pin input & output)读取/输出一个高电平还是低电平. 不管是诸如I2C, SPI的通信, 还是按键读取, 亮灯报警, 说到底都是高低电平的控制或探测.
ADC输入的是模拟(Analog)信号, 但是会被转化为数字(Digital)信号, 一样是高低电平. 这里暂且不谈
外设, 时钟, 或者一些功能的使能及配置一样是通过寄存器的. 原理相似. 毕竟芯片集成电路也是电路, 而且是数字电路. 暂时不深入.
所以, 为了让某一个Pin输出电平, 或者使能一个通道, 我们可以用0或者1实现.
但是代码终归是代码, 不是魔法. 为了使需求生效, 单片机将每一个需要控制的量, 赋予一个地址. 在电路层面上实现相关的功能绑定. 用户只需通过给这个地址去写一个值, 就相当于控制了需要控制的东西. 易于计算, 我们的32位处理器最大可以寻址4GB的内存空间.
注意: 并不是每一个地址都是有真实物理地址对应的. 换而言之, 一个地址可能指向的是真实的内存, 也可能并不是真实存在的内存. 不过访问这个地址相当于控制了被控量一样. 此时该地址可以看作控制量的一个句柄(handler).
“使能“的意思是enable. 反义词是”禁用(disable)”. 计算机相关的词汇总是这么的看不懂字面意思. 看英语就明白了.
内存是存储数据的地方. 任何电子信息数据都可以看做01串. 32位机的最小存储单元是DWORD(双字, Double Word), 包含32比特位. 32位机的内存单元可以看作一个个存储32比特位的小仓库. 为了找到所需要的数据, 需要给这些仓库编号. 这个所谓的编号就是地址(Address). 存储的值是地址值的变量叫做指针(pointer). 比如一个仓库放着一个记录某个货物的多个存放仓库的编号, 那么这个内存里的数据就是一个指针. 计算机无法分辨哪写是指针, 哪些是数据. 这需要人去完成.
库函数是寄存器的封装. 不管是ST出的HAL硬件抽象层库, 还是标准库STL, 都是封装而已. 本质上是宏定义替换和一些函数. 宏定义在编译(compile)阶段完成. 不占用宝贵的存储空间. 函数本身会被编译为代码, 所以会占用空间. 尽量避免在库中使用函数. 当然用户代码肯定无所谓.
为什么要用库函数呢? 比如
GPIO_InitTypeDef GPIO_Initure;
GPIO_Initure.Pin=GPIO_PIN_13;
GPIO_Initure.Mode=GPIO_MODE_OUTPUT_PP;
HAL_GPIO_Init(GPIOB,&GPIO_Initure);
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_13, GPIO_PIN_SET);
这一段代码, 不加注释你也知道他的意思是初始化PB13为推挽输出.
但是如果写成了寄存器版本的你可能就看不懂了.
GPIOB->CRH&=0XFF0FFFFF;
GPIOB->CRH|=0X00300000;
GPIOB->ODR|=1<<13;
现在会简要介绍寄存器的配置方法. 你能搜到这个文章肯定是因为你找见的开源代码使用的是寄存器版本. 正常人的话, 能找见库函数绝对是不会用寄存器版本的代码的.
寄存器版本的代码实际上也是封装, 比如上述代码实际上也可以直接写成
(uint32_t *)(((((uint32_t)0x40000000) + 0x10000) + 0x0C00)+0x01)&=0XFF0FFFFF;
(uint32_t *)(((((uint32_t)0x40000000) + 0x10000) + 0x0C00)+0x01)|=0X00300000;
(uint32_t *)(((((uint32_t)0x40000000) + 0x10000) + 0x0C00)+0x04)|=1<<13;
//这一段代码可能不对
上面就是直接通过指针操作寄存器的方式. 寄存器宏定义和结构体相当于, 先找到GPIOB的开头地址, 然后加上结构体的附加地址偏移(去操作某一个功能, 比如CRH寄存器). 最后给这个地址写入一串二进制值去控制相应的东西.
说到底还是去给某些比特上写高低电平.
类似于GPIOB->CRH这种, 实际上是定义的一个结构体.
typedef struct
{
__IO uint32_t CRL;
__IO uint32_t CRH;
__IO uint32_t IDR;
__IO uint32_t ODR;
__IO uint32_t BSRR;
__IO uint32_t BRR;
__IO uint32_t LCKR;
} GPIO_TypeDef;
通过结构体的位关系以及各种宏定义替换, 可以实现地址的编译时自动确定.
寄存器的封装好在对于实际内存位置不同的芯片, 移植只需更改寄存器宏定义映射即可. 而且已经很容易读了.
4. 常见的寄存器配置初始化(使用STM32F1xx系列举例子)
我们写驱动, 最常用的就是GPIO的配置了. 我们就举一些例子.
RCC寄存器
RCC寄存器一般用来配置RCC时钟相关的代码. 比如我要使用PA0的话, 我必须开启PA的时钟通道才行. 时钟相当于处理器的心跳, 没有心跳当然是死的. 但是没理由的开启时钟会带来额外的EMI干扰和整机功耗. 原理暂且不谈, 实际危害就是干扰大了, 耗电多了. 尤其针对于一些对功耗要求严格的场合, 例如手环手表, 应该尽可能在待机时关闭能关闭的所有时钟. 用来省电(比如有的智能手表升级系统后待机变久了, 可能就是这个原因. 我们说的固件(firmware)升级实际上指的就是把新写的代码下载到了老设备上).
举个例子, RCC时钟的配置如下
RCC->APB2ENR|=1<<4; //使能PORTC时钟
RCC->APB2ENR|=1<<3; //使能PORTB时钟
RCC->APB2ENR|=1<<2; //使能PORTA时钟
具体使能哪一个位是控制谁, 需要查表(除非你背过了).
如果是需要快速出项目, 不管其他的, 可以省事, 直接使能所有时钟. 但是不建议这么干.
CRH和CRL寄存器
用处是配置GPIO引脚功能.
比如我查了STM32F103RCT6的数据表(datasheet). 发现PA11/12可以当输入高低电平的. 我现在需要配置, 怎么办?
答: 将PA9/10配置为输入即可.
方法:
- 确定高低段位.
由于F1单片机将4个比特作为控制一个pin的一组. 所以划分Pin15~Pin8为CRH寄存器吗Pin7~Pin0为CRL寄存器. H和L是high/low的简写.
比如我要配置PB13为输出, 因为13是高位, 所以我应该操作CRH寄存器.
2. 确定位
4位为一组(稍后介绍为什么). 从右往左数, 8,9,10,11,12,13, 应该是第6个, 所以先用位与操作, 把要改的数据清零 (方便设置值).
因为处理器的最小操作单位是32比特的一个DWORD, 所以我们只能这么操作特定的位数. 你不能直接给一个DWORD的几个比特写入值.
GPIOB -> CRH = GPIOB->CRH & 0xFF0FFFFF; //从右往左数第六个, 改成0, 就可以将这几位清空了
GPIOB -> CRH &= 0xFF0FFFFF; //这么写也可以
GPIOB -> CRH &= 0b11111111000011111111111111111111; //这么写也可以
3. 写入操作: 配置为输出
查表得知(这个表可以网上搜), 控制一个pin的输出模式, 低两位为00是输入, 其他值为输出(比如11). 推挽输出为00, 所以我们应该设置的二进制是0011. 转换为16进制是0x03. 所以通过或操作写入值.
GPIOB->CRH|=0x00300000; //写入控制位, 推挽输出.
//或者
GPIOB->CRH|=0b00000000001100000000000000000000; //如果你看不懂16进制, 那么这样是不是就懂了?
一般来说, CRH/CRL的配置是, 每一个pin的控制句柄, 高两位是模式, 低两位是输入还是输出.

可以参考一下.
5. 配置好了, 怎么用呢?
特色复用功能就按照特色的方式去弄. (比如USART会有一个自己的中断函数)
通用输入/输出可以使用IDR或者ODR寄存器.
IDR/ODR寄存器
I 是input, O是output. 输入时相应的寄存器的值变化, 读取即可. 输出是往里面写. 一样的.
IDR/ODR寄存器只有低16位有用. 高16位是保留位(就是没用的意思). 从高向低是pin15~pin0. 比如我想给PB13给高电平, 那么就是
GPIOB->ODR|=0x00002000;
GPIOB->ODR|=1<<13; //这么写也行. 还更好认识
GPIOB->ODR|=0b00000000000000000010000000000000; //脑溢血写法, 但是直观的看出来这个控制PB13的原理
如果是输入的话, 读取值的话是怎么读取呢?
temp = (GPIOB->IDR & (0x00002000))>>13;
//或者
temp = (GPIOB->IDR & (1<<13))>>13;
仔细思考通过左右移, 是如何实现读取某一个特定位的值的 (并将其赋值给某一个变量, 这里是temp. 稍后要用到的话就直接调用temp即可)?
6. 我看会了. 我刚开始学寄存器方法是为了读懂别人的代码. 我现在也能写了, 那么我应该写寄存器的还是库函数的?
首先明白一个事实. 就是, 先有的寄存器版本, 然后才有库函数. 库函数是人们为了方便后面发明的东西. 这也造成了几个问题.
- 祖传屎山都是寄存器的, 而且没人翻译成库函数.
- 一些芯片压根就没有库函数. 比如移远的 IOT3S
所以学习寄存器也是有必要的. 因为
- 他教会了你计算机的一些底层知识
- 能读屎山代码了
自己写的话, 那当然是库函数好写了. 尤其是ST的HAL库, 可以直接用自家的CubeIDE生成很多硬件初始化代码. 很方便. 同时维护也很方便.
但是寄存器在某些特定用处上更有用. 比如矩阵键盘. 为了扫描一个拓扑上8×8的矩阵键盘. 可以直接配置Px15~Px8为输入, Px7~Px0为输出. 然后读取的时候直接IDR/ODR成片操作, 几个与或, 很快就得到了输入值. 这种情况比库函数加判断分支的写法不知道高到哪里去了. 又快, 又方便易懂.
所以这几种方式各有利弊.
库函数和寄存器可以混着用吗?
当然可以. 本身使用库函数的项目里可以直接引入寄存器的代码. 因为库函数本身也用到了寄存器. 但是寄存器要使用库函数, 需要先将库函数导入到工程才行(编译链的makefile文件里). 当然都可以混着用.
7. 这些没问题了, 但是那些OD开漏输出, 推挽输出, 浮空输入, 上拉输入, 都是什么意思……
估计能找到本文的人不会提出相关问题. 如果有疑问, 请自行搜索相关的关键字. 也可以看这几篇文章:
Kevin Zhang:开漏输出与推挽输出355 赞同 · 24 评论文章
上拉电阻、下拉电阻的电路原理是什么?105 赞同 · 5 评论回答
sunny:STM32的8种GPIO输入输出模式深入详解125 赞同 · 5 评论文章
