从零开始的单片机

Zheng Yu lucky

本篇文章讲述了如何在STM32F411上开发一个简单的LED灯闪烁的程序并讲解了单片机运行固件的具体流程。理论上来说,学习单片机的难度是严格小于学习操作系统和计算机体系结构这两门课程的难度。因为这两门课程探究的是现代计算机这样一个拥有优秀的性能以及丰富的功能的原理,包含了计算机发展历程中无数人的工作与智慧,我们需要的了解的包括但不限于:中断处理,线程调度,虚拟内存管理,文件系统,指令集,驱动。然而,在单片机中的内容则只是上述内容的一个子集,我们可以先只关注指令,内存以及中断,只要明白这三样事情,就足以理解单片机程序的开发和加载原理。在此之前,我们先来写第一个单片机程序。

开发环境准备

  • 安装arm的交叉编译器,因为stm32f4系列的核心使用的都是cortex-m4的指令集,cortex-m4可以认为是arm的一个变种。编译器可以在ARM官方网站 下载到,可以选择直接下载已经编译好的二进制文件或者下载源码进行本地编译。
  • 安装STM32CubeMX ,这个工具的目的是为了帮我们自动生成一些初始化代码,如果没有这样的工具,开发单片机将会变成一件很麻烦的事情,后面会说明原因。
  • 安装st-link,具体过程可以参见这里 ,这个工具的目的是将我们写好的程序烧录到板子上。

第一个LED灯程序

  • 我们打开STM32CubeMX,点击 File -> New Project,选择我们自己板子的型号,这里我的板子是STM32F411RE。

  • 然后点击Start Project,可以看到一块正方形的板子被一些带有字母数字的标签围着,选择带有PA5的标签,如果找不到可以用右下角的搜索框搜索,之后点击GPIO_Output

  • 最后点击Project Manager,选择项目保存的位置,ToolChain/IDE那一栏我们选择Makefile,因为我不打算使用定制的IDE,而是直接使用vscode这样的编辑器。

  • 找到项目保存位置的Core/Src/main.c,在main函数最后可以看到一个while循环,在里面添加如下代码:

    1
    2
    3
    4
    5
    6
    while (1)
    {
    /* USER CODE END WHILE */
    HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); //Toggle the state of pin PA5
    HAL_Delay(1000); //delay 1000ms
    }
  • 然后直接编译以及烧录:

    1
    st-flash --format=ihex write build/{your_project_name}.hex
  • 最后点击板子上的reset键,我们就可以看到板子的LED灯开始闪烁了。

程序是如何运行的

烧录程序

要弄清楚这个问题,我们可以先研究一下STM32CubeMx为我们生成的Makefile,可以发现这个makefile首先为我们生成了elf,然后使用objcopy,用下面的命令生成可bin文件和hex文件,这样做的目的是去除elf中无用的头部以及符号表等信息。

1
2
arm-none-eabi-objcopy -O ihex target/led.hex
arm-none-eabi-objcopy -O binary target/led.bin

这里需要插一句关于单片的内存的内容,单片机没有虚拟内存这回事,至少STM32F4是没有的。因此我们如果在程序使用指针,都是直接操作物理地址。因此任何时候提到地址,都是说的绝对的物理地址。

回到这两个文件,hex文件指的是Intel Hex ,文件内容可以认为是一个个的形如(地址,数据)的二元组,告诉st-flash根据这些二元组将数据写入到对应单片机的地址上。而bin文件则只有数据,没有地址所以如果我们想烧录bin文件需要像下面这样指定要烧录的地址

1
st-flash write build/led.bin 0x8000000

这条命令是说让bin原模原样的覆盖0x8000000开始的地址的内容。了解了这两个文件后,我们可以认为烧录这一过程改变了单片机的一部分内存数据,我们还需要关注寄存器是如何变化的。

单片机重置

我们在烧录过后,还点击了单片上的Reset按钮,这一步单片及内部发生了什么呢。我们可以参考官方的Cortex-M4文档,在有关寄存器的章节,这里说了会将pc的值覆盖为地址0x00000004处的值,另外还提到了sp的值会被0x0处的值所覆盖。

但是问题是0x00000004处的值是什么呢,我们可以在STM32F411的文档中找到答案

简单来说,在通常情况下,单片机访问0x00000000 ~ 0x0007FFFF(这一段内存相当于访问Main Flash memory,而 Main Flash memory 正是在 0x8000000。因此这也意味着PC的值是0x8000004这个地址的值。

这里我们可以用ghidra逆向一下生成的led.elf文件,看看0x8000004处的内容是什么。

可以很清楚的看到pc会被置成Reset_Handler的地址,我们可以在我们的代码中搜索Reset_Handler可以发现这是STM32CubeMX为我们生成的代码,它的作用是执行一些全局变量的初始化工作以及跳转到main函数,具体来说我们平常在linux上会将程序分成很多段,例如全局变量,代码,堆,栈等等,但是这里我们注意到我们是把程序一股脑的放到了0x8000000的位置,因此我们需要做一个初始化的操作将全局变量和栈恢复到正确状态,因此这个函数做了大量的复制操作去干这件事。

如果仔细看这这个函数,还是会发现它的实现不是特别简单,但是每个单片机程序都需要它,相当于一个模板一样的东西,每次都手动编写或者复制过来很麻烦,因此STMCubeMX就是在这样的地方发挥作用,让我们不需要去写那些重复的代码

单片机的固件烧录以及reset的过程,总结一下就是做了两件事:

  • 用生成的程序覆盖单片机的内存。
  • 单片机自动初始化pc,sp,lr寄存器,然后程序开始执行。

现在我们开始关注外设,首先我们要回答一个问题,什么是外设?

什么是外设?

这里我先放上这样一个图,之后可能会反复用到这张图。我们先回忆一下上次我们写的那个简单的LED程序,我们是通过什么让指令和LED连接起来,让我们通过代码就能控制LED呢,答案是MMIO和GPIO。

我们需要先有一个认识,就是单片机并不是直接控制LED灯的闪烁,单片机能控制的是一个叫GPIO的东西,然后GPIO再控制LED灯,这样一个间接的过程,就和上图最后一行那样。我们可以把GPIO和LED灯都叫做外设,但是通常来说我们更关注GPIO,因为GPIO由单片机直接控制,处于较为核心的位置。因此我们先来看看GPIO是怎样被单片机所控制的。

MMIO(Memory-mapped I/O)

通常来说,控制外设我们就得用外设给我们提供的API,外设的API就是它的寄存器。但单片机所能做的就是执行指令,通过指令来修改或读取内存或是读取它自己的寄存器。但是如果单片机想控制GPIO,就等修改GPIO内部的寄存器,但是单片机不能直接访问到他们,因此MMIO就被提了出来。MMIO通过将GPIO的寄存器映射到单片机的内存上,实现了单片机对外设的直接控制。举个例子,再STM32F411上,GPIO有下面这些寄存器。

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct
{
__IO uint32_t MODER; /*!< GPIO port mode register, Address offset: 0x00 */
__IO uint32_t OTYPER; /*!< GPIO port output type register, Address offset: 0x04 */
__IO uint32_t OSPEEDR; /*!< GPIO port output speed register, Address offset: 0x08 */
__IO uint32_t PUPDR; /*!< GPIO port pull-up/pull-down register, Address offset: 0x0C */
__IO uint32_t IDR; /*!< GPIO port input data register, Address offset: 0x10 */
__IO uint32_t ODR; /*!< GPIO port output data register, Address offset: 0x14 */
__IO uint32_t BSRR; /*!< GPIO port bit set/reset register, Address offset: 0x18 */
__IO uint32_t LCKR; /*!< GPIO port configuration lock register, Address offset: 0x1C */
__IO uint32_t AFR[2]; /*!< GPIO alternate function registers, Address offset: 0x20-0x24 */
} GPIO_TypeDef;

这段代码可以在上次代码中的Drivers\CMSIS\Device\ST\STM32F4xx\Include\stm32f411xe.h中找到,也是STM32CubeMX的代码一部分。如果MMIO将这样一个寄存器集合映射到0x40020000这个内存地址,那么我们通过((uint32_t) 0x40020000)就能访问MODER这个寄存器,当然也能修改。除了GPIO之外,单片机还有很多外设,例如USART, I2C, SPI等等。这些外设都由单片机通过MMIO直接控制,我们把这些外设成为on-chip的外设,而不使用MMIO,单片机无法直接控制的外设,我们称为off-chip的外设。on-chip的外设通常为单片提供一些核心功能,我们之后会介绍这些功能。

GPIO

说完了MMIO,我们再来看看GPIO是怎么控制LED灯的,还记得我们我们再上一节中我们将PA5设置为了GPIO_OUTPUT吗,这里的PA5代表一个针脚,也就是我们看到的单片两边的刺,我们可以用杜邦线将这些针脚与LCD屏幕,GPS等设备连接,实现更加炫酷的功能,而这里的LED等,是在单片机内部已经和PA5相连了,因此我们不需要杜邦线了。关于针脚的数据,我们可以在这里 看到,由图中可以看到,PA5确实和LED相连。

这里我们最后来看一下单片机究竟操作了GPIO的哪些寄存器,具体的控制方式是怎样的,要回答这个问题,我们可以通过阅读代码或者阅读文档的方式解决,但是实际上我们还有第三种方式,那就是使用qiling 框架,qiling框架已经支持对STM32单片机进行仿真模拟,并且可以打印出外设的访问情况,我们来看看。

单片机固件的模拟

这里建议大家不要通过pip安装qiling,直接clone麒麟的仓库,然后切换到dev分支,最新的代码都在dev分支中,然后我们可以直接使用examples下的例子

这个例子就是模拟了一个自带的LED灯程序,我们修改代码成如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import sys
sys.path.append("../..")

from qiling.core import Qiling
from qiling.const import QL_VERBOSE


def test_mcu_gpio_stm32f411():
ql = Qiling(["../../examples/rootfs/mcu/stm32f411/hello_gpioA.hex"],
archtype="cortex_m", profile="stm32f411", verbose=QL_VERBOSE.DEBUG)

ql.hw.create('rcc')
ql.hw.create('gpioa').watch()


ql.hw.gpioa.hook_set(5, lambda: print('LED light up'))
ql.hw.gpioa.hook_reset(5, lambda: print('LED light off'))

ql.run(count=10000)

if __name__ == "__main__":
test_mcu_gpio_stm32f411()

我们先运行一下,看看输出了什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[+]     [GPIOA] [R] ODR  = 0x0
[+] [GPIOA] [W] BSRR = 0x20
[+] [gpioa] Set PA5
LED light up
[+] [GPIOA] [R] ODR = 0x20
[+] [GPIOA] [W] BSRR = 0x200000
[+] [gpioa] Reset PA5
LED light off
[+] [GPIOA] [R] ODR = 0x0
[+] [GPIOA] [W] BSRR = 0x20
[+] [gpioa] Set PA5
LED light up
[+] [GPIOA] [R] ODR = 0x20
[+] [GPIOA] [W] BSRR = 0x200000
[+] [gpioa] Reset PA5
LED light off
[+] [GPIOA] [R] ODR = 0x0
[+] [GPIOA] [W] BSRR = 0x20
[+] [gpioa] Set PA5
LED light up
[+] [GPIOA] [R] ODR = 0x20
[+] [GPIOA] [W] BSRR = 0x200000
[+] [gpioa] Reset PA5
LED light off
[+] [GPIOA] [R] ODR = 0x0

可以看到,单片在反复读取GPIOA的ODR寄存器以及写BSRR的寄存器,然后对应的PA5针脚的状态就会发生变化SET/RESET,然后LED灯的状态也随之改变,具体表现为不停的亮起和熄灭。这与我们实际对LED的观察一致。然后我们再来看看,这个BSRR在GPIO中是作什么用的呢,为什么每次写这个寄存器led灯的状态就会发生变化。我们回答这个问题还是用看文档的方式:

可以看到, 这个寄存器前16位bit代表set,后16位代表reset,通过写对应的bit就可以控制对应的针脚(PIN)。比如要set针脚PA5,就应该写1<<5=0b100000=0x20,这与我们的观察一致。

总结

本文主要介绍了单片机的MMIO和GPIO的基本概念,以及如何使用qiling框架来模拟单片机的固件,希望对大家有所帮助。

  • Post title:从零开始的单片机
  • Post author:Zheng Yu
  • Create time:2021-10-30 15:07:57
  • Post link:https://dataisland.org/2021/10/30/stm32-dev/
  • Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.