一 本章简介
如果大家在学习 C 语言时的第一个工程是打印一句 HELLO WORLD! ,那么在嵌入式单片机的世界中,第一个工程一定是点亮一个 LED 灯。它是我们与芯片建立连接的第一步,也是验证最小系统板是否正常工作的最直观手段。
本章将带你从零开始,基于天空星 STM32F407(低配版和高配版均可)用 寄存器版本 的方法新建并运行第一个点灯工程,贯穿完整的工程文件夹、编译器/IDE 选择、简单的下载与在线调试。
IMPORTANT
- 先搭环境,再谈其他:学习本章前请先根据第三章【学习前的准备工作】完成开发环境安装(包含工具链、调试器驱动、设备固件包、OpenOCD/Keil Pack 等),要确保自己打开我们提供的案例工程可以直接进行编译下载,否则不建议继续往下看。
- 本章为何选择寄存器?:本章采用 寄存器方式来创建工程 ,即不依赖 HAL/LL 库的 GPIO 驱动,甚至最基础的stm32f4xx头文件也不用,主要是为了帮助你理解驱动寄存器的底层原理。强烈建议大家在实际使用时直接使用HAL/LL库版本。
NOTE
本章要用到的硬件设备:
- 天空星核心板(STM32F407版本),搭配天空星筑基学习板。
- DAPLink仿真器(用于给天空星下载及调试程序),其他调试器也可以,前提是你自己会设置。
1.1 学习目标
学习如何从头新建 天空星STM32F407 的寄存器版本的工程(三种工程共存)。
理解 IDE 与编译器的基本概念和区别,了解 GCC、Arm Compiler 5(armcc)、Arm Compiler 6(armclang)的差异与选择建议【本章只简单提示,后续章节会做详细对比】。
在三种主流环境中完成工程的创建、编译与下载:
- Keil MDK(Arm 编译器)
- VSCode + EIDE 插件(可选 GCC编译器)
- CLion + GCC(CMake + OpenOCD)
掌握寄存器级 GPIO 配置方法,理解 RCC 时钟使能、GPIO 模式/速度/上下拉/输出的用法。
学会初步的在线调试,利用调试器直接查看和修改芯片内部的寄存器的值。
学会控制GPIO寄存器,实现让天空星开发板上面的LED灯闪烁的C代码。
1.2 重点提示
- 循序渐进,从一种工具链开始,不要一上来三个环境同时配。建议先用 Keil(MDK) 快速验证硬件连通性,它的集成度最高,能最快地验证你的硬件连接和基本设置是否正确。熟悉了之后再尝试 GCC + VSCode/CLion。
- 启用外设时钟:GPIO 寄存器在使用前必须先在 RCC->AHB1ENR 打开端口时钟,否则对寄存器的读写是无效的。GPIO的各个端口就像依河水而建的村庄,打开时钟在这里就类似于打开水坝,有了水,文明才能发展;同样,有了心跳(时钟),GPIO才能工作。
- 本章不使用 STM32CubeMX 来生成工程,将和大家一起从头开始新建寄存器工程,要注意本章所新建的工程并不是后续案例使用的初始工程,文件结构也是毫无讲究。在后续的正式章节中,我们会直接使用 STM32CubeMX 来辅助新建工程,这里只是为了方便大家理解寄存器和工程的组织方式,后续都不需要大家重新建立工程。
1.3 基础概念与术语
- IDE (Integrated Development Environment):集成开发环境。可以把它想象成一个 超级工具箱,里面包含了代码编辑器、编译器、调试器(这里指的是软件上的,具体实体是在外部的调试器,如DAPLINK,STLINK,JLINK)等所有你需要的工具,比如 Keil MDK。
- 编辑器 (Editor):编写代码的 笔记本 ,如 VSCode、Notepad++。它本身只负责文字编辑,不负责编译和调试。
- 编译器 (Compiler):翻译官。它负责将你用 C语言编写的、人类可读的源代码,翻译成单片机能够理解并执行的机器码(0和1)。例如 GCC、armcc。
- 工具链 (Toolchain):一套完整的工具集合,包括编译器、汇编器、链接器等,用于将源代码转换成最终的可执行文件。GCC 和 ARM Compiler 就是常见的工具链。
- 调试器/仿真器/探针 (Debugger):硬件(比如 DAPLink)和软件(如 Keil 的调试界面、GDB、OpenOCD)的组合,允许你连接到正在运行的芯片,控制其执行、查看内存和寄存器状态,在本章节中我们使用立创开发板推出的DAPLINK仿真器来进行调试下载,各位也可以选择Jlink或者ST-Link。
- 寄存器 (Register):芯片内部的一些特殊存储单元,每个寄存器都有特定的地址和功能。通过读写这些寄存器,我们就可以配置和控制芯片的各种功能。这是最底层的硬件操作方式。可以简单理解为是密密麻麻的开关。
- GPIO (General-purpose input/output):通用输入输出端口。单片机的引脚,可以被程序配置为输入模式(读取外部信号)或输出模式(驱动外部设备,如 LED)。在上一章节【认识GPIO】中有详细介绍。
- RCC (Reset and Clock Control):复位与时钟控制器。STM32 的“心脏起搏器(心跳)” 和 “电源管理中心”,负责管理芯片的启动、复位以及为所有外设提供工作时钟。
- CMSIS (Cortex Microcontroller Software Interface Standard):Cortex 微控制器软件接口标准。它是由 ARM 公司提出的一套标准,它提供了一层硬件抽象层,使得上层软件和中间件在不同厂商的 Cortex-M 芯片间更易于移植。我们后面用到的
stm32f4xx.h等头文件就属于这个体系。
1.4 工程链接
仓库工程地址: 9-BASIC-LED_GPIO_OUT_register_version
工程压缩包:9-BASIC-LED_GPIO_OUT_register_version.zip
二 什么是IDE,编辑器和编译器又是什么?
这三个概念是嵌入式开发的基石,很多初学者容易混淆。让我们用几个比喻来搞懂它们的关系。
想象一下,你要写一本书(单片机的程序),并且希望一个只会说0和1两个数字的机器人(单片机)能够读懂并执行书里的内容。
- 编辑器 (Editor):就是你的 笔和纸。你可以用它来书写你的草稿(源代码)。这支笔可以很普通(比如 Windows 记事本),也可以很高级,带语法高亮、自动补全等功能(比如 VSCode)。但无论如何,笔和纸本身并不能让机器人看懂你的草稿,毕竟他只看得懂0和1。
- 编译器 (Compiler):就是一位 翻译家。他得到你的草稿后,逐字逐句地将其翻译成机器人能懂的0和1(机器码)。这位翻译家有不同的流派,有商用的也有开源的:
- GCC (GNU Compiler Collection):一位开源、免费、用途广泛的翻译家,被广泛应用于各种平台。
- Arm Compiler (armcc/armclang):由 ARM 公司官方出品的专业翻译家,对 ARM 架构的芯片(比如我们的 STM32)有特别深入的理解和优化,用它编译出来的代码通常体积更小,运行效率更高。通常集成在 Keil MDK 等商业工具中。
armcc是其经典版本(V5)【目前最新版本的Keil MDK中已经不自带了,只能自行额外安装】,armclang是基于 LLVM/Clang 的现代版本(V6)。
- IDE (Integrated Development Environment):这是一个设施齐全的 专业团队。这个工作室里不仅为你准备了顶级的笔和纸(高级编辑器),还内置了一位或多位翻译家(编译器/工具链)。更重要的是,它还提供了一整套的校对、装订、出版流程(项目管理、构建系统、甚至集成了GIT管理),以及一个可以直接与机器人对话的特殊窗口(调试器界面),让你能看到机器人正在执行哪一页,脑子里在想什么(查看寄存器和内存)。
总结一下:
| 概念 | 核心功能 | 例子 |
|---|---|---|
| 编辑器 | 编写代码文本 | VSCode, Notepad-- |
| 编译器 | 将源代码翻译成机器码 | GCC, armcc, armclang |
| IDE | 集成了编辑器、编译器、调试器等的 一站式 开发平台 | Keil MDK, CLion, IAR |
阅读到这里,大家应该对三种编译环境有了一定理解了。
- Keil MDK:一个经典的 IDE,自带 Arm Compiler。
- VSCode + EIDE:VSCode 是一个强大的编辑器,好多程序员都戏称它是宇宙第一编辑器,EIDE 插件则巧妙地将 GCC 工具链和调试功能(需搭配其他插件)嫁接”了进来,使其变成了一个轻量级的 IDE。
- CLion:一个专业的 C/C++ IDE,它不自带嵌入式编译器,但通过强大的 CMake 构建系统,可以灵活地与外部的 GCC 工具链和 OpenOCD 调试工具协同工作。
在本教程学习使用中,我们首选推荐是Keil(MDK),其次是使用了EIDE插件的VS Code,最后才建议各位使用Clion,他们各自软件的安装及使用如果还不熟悉的话请返回看第三章的内容。
三 用 KEIL(MDK) 来新建 寄存器 工程
IMPORTANT
如果你是新手,可以只看本节,另外两种方式(第四节 EIDE 和第五节 CLion ),可以暂时不看,避免造成理解混乱。Keil 的集成环境能让你最大限度地规避环境配置问题,专注于代码逻辑本身。
【先用最简单的方法成功,再去探索更广阔的世界】
3.1 前期准备
- 确保已正确安装 Keil MDK 软件。
- 提前给 Keil MDK 安装好 STM32F4xx_DFP 这个设备支持包。更推荐大家通过离线安装包来安装,如果用keil在线安装的话可能会有网络问题。如果你现在还没有安装,请回顾第三章的安装教程。
3.2 获取必要所需文件
要从零开始构建一个寄存器工程,我们不需要 HAL 库,但至少需要一个汇编所写的启动文件。它是连接我们的 C 代码和底层硬件的桥梁。需要注意,就算是同一个芯片在使用不同的编译器编译代码时,它的启动文件也是不一样的。
启动文件startup:是汇编语言编写,是芯片上电后、进入 main 函数前执行的第一段代码。现在不需要理解,你就把它当作一个引路人,它可以把芯片从汇编的世界带入C语言的世界。
startup_stm32f407xx.s: 主要负责设置堆栈指针、初始化.data和.bss段、设置中断向量表,最后调用SystemInit()和main()函数。
这个文件从哪里来?
本教程所使用的天空星核心板所用的主控 STM32F407 是ST公司生产的,自然相关资料需要去ST的网站上下载,点击这个链接:
STM32CubeF4 | Product - STMicroelectronics
登录然后下载这个Package包(可能你看本篇文章时版本号不是截图的中版本了,没关系,可以直接用最新的,如果遇到问题了,可以选择右边的Select Version来下载之前的版本):

如果你可以顺畅访问GitHub的话,也可以直接从这个仓库下载。在目录的
Source/Templates/arm里面就可以找到 startup_stm32f407xx.s 这个文件了。
我们在ST官网下载下来的是一个压缩包,将它解压缩之后进入文件夹,打开这个路径:
STM32Cube_FW_F4_V1.28.0\Drivers\CMSIS\Device\ST\STM32F4xx\Source\Templates
就能看到三个文件夹,我们的Keil(MDK)需要使用arm目录下的 startup_stm32f407xx.s:

进入后就能找到启动文件startup_stm32f407xx.s了:

你现在可以完全不管这个文件里面内容,只要知道它是单片机运行你写的代码前所必须执行的汇编代码就可以,在后面的 系统是如何启动的 章节我们会重新回过头介绍的。
3.3 开始创建工程
在开始之前,先把 DAPLink仿真器 和 天空星核心板 正确连接起来,如下图所示:

具体怎么接线,请看这篇文章的内容:链接
3.3.1 规划工程目录
我们要创建最简工程,所以这里就先不考虑文件夹结构了,本来也就只有两个文件而已。
在电脑上新建一个文件夹,因为我们目前创建的这个是Keil(MDK)的工程,所以我把他暂时取名为Keil(MDK),将在前面小节获取到的 startup_stm32f407xx.s 这个文件复制过来。

3.3.2 新建Keil(MDK)工程
Step1: 打开Keil MDK,新建工程,选择菜单 Project -> New µVision Project...。

Step2: 导航到我们创建的 Keil(MDK) 文件夹,输入工程名 project,点击保存。

Step3: 在弹出的 Select Device for Target 窗口中,搜索并选择 STM32F407VE(天空星高配版是 VGT6,低配版是 VET6,本教程所使用的是天空星低配版,高配版用这个配置也可以正常用的,他们俩的区别就是高配版的FLASH是1024K,低配版的FLASH容量是512K)。点击 OK。
【如果在这里根本搜索不到或者完全没有厂家,需要你重新回到第三章查看如果给KEIL安装STM32F4的PACk包】
【如果看不到芯片的图标,需要你点前面的小+号他才会显示出来】

Step4: 接着会弹出 Manage Run-Time Environment 窗口。这是关键一步:因为我们是手动添加文件,所以不要勾选任何组件,尤其是 Device::Startup。现在先直接点击 Cancel 关闭它。

Step5:在工程中添加启动文件 startup_stm32f407xx.s
单击 Target 1 前面的小 + 号:

双击里面的 Source Group 1会弹出来让你让你选择文件的对话框,选择 startup_stm32f407xx.s 后下面的文件名就会自动填入你选择的文件名,最后单击右边的 Add,此时该文件就被我们添加入工程里面了。

NOTE
细心的朋友可能会发现了,本来我们这个文件夹里面是只有一个启动文件 startup_stm32f407xx.s 的,但是在上面选择文件的时候里面多了很多内容,比如Listings文件夹,Objects文件夹以及project.uvoptx和project.uvprojx,这些都是上面创建Keil工程时产生的。

单击 Source Group1 前面的 + 号,就能看到我们刚加入的启动文件了:

3.3.3 新建并添加 main.c
【为什么要添加main.c函数?】
大家在学C语言的时候想必都知道c语言的入口函数是main函数,那么在单片机上这个main函数是如何被执行的呢?他是被谁调用的,我们现在先来看一下启动文件 startup_stm32f407xx.s 中的168行到179行:
; Reset handler
Reset_Handler PROC
EXPORT Reset_Handler [WEAK]
IMPORT SystemInit
IMPORT __main
LDR R0, =SystemInit
BLX R0
LDR R0, =__main
BX R0
ENDP2
3
4
5
6
7
8
9
10
11
12
这段汇编代码,就是单片机复位后执行的第一段用户代码,我们称之为复位处理程序(Reset Handler)。至于为什么会先执行这段代码,我们后续章节再补充说明。【TODO】后续章节完成要在这里更新。
在这里我们先把这个单片机想象成一个人,假设他现在刚刚睡醒:
- 闹钟响了 (Power-On(上电) / Reset(复位)):你给单片机一上电,或者按一下复位键,就相当于闹钟响了。CPU“醒来”的第一件事,不是去想今天要做什么(
main函数),而是执行一个固定的 晨间例行程序 (引导程序)。 - 穿衣洗漱 调整状态 (
SystemInit):这个例行程序的第一步,就是SystemInit。你看代码LDR R0, =SystemInit和BLX R0,意思就是“找到SystemInit这个函数,然后去执行它”。这个函数是干嘛的呢?它负责一些最基础的系统初始化,比如:- 配置系统时钟:就像给自己的手表对时,让单片机的心脏(晶振及内部时钟)以正确的频率跳动。这是后续所有外设(GPIO、串口等)能正常工作的基础。
- 开启外设时钟:比如FPU(浮点运算单元)的时钟。
- 其他一些关键的底层配置,比如配置Flash的访问速度。做完这些,单片机的 身体机能 才算基本就绪。
- 准备开始一天的工作 (
__main):洗漱完毕,接下来就要准备正式开始工作了。代码LDR R0, =__main和BX R0,就是跳转到__main。注意,这里是__main(有两个下划线),它不是我们在学C语言时写的那个main函数!毕竟名字虽然长得像,但是不一样。 - 从C库到main函数:
__main是C库(Runtime Library)里的一个标准初始化函数。你可以把它理解成一个 照顾它起居的保姆。这个函数的任务是:- 在RAM里建立起C语言运行所需要的环境,比如初始化全局变量、静态变量(把它们从Flash拷贝到RAM,或者清零)。这个过程一般叫做 分散加载。
- 最后,当一切准备就绪,
__main这位“幕后功臣”的最后一个动作,就是调用我们编写的main()函数。至此,程序的控制权终于交到了你的手上!
所以,启动文件和main函数调用链是这样的:
硬件复位(上电或者手动复位) -> Reset_Handler (汇编) -> SystemInit (C函数) -> __main (C库函数) -> main (你的C函数)。
根据这个调用链我们可以知道在C文件中,我们至少需要实现两个函数才能正常编译通过,一个是SystemInit,另一个是main。
接下来我们创建并编写main.c,现在继续回到Keil(MDK)软件,选择菜单 File -> New。

此时keil会打开一个Text开头的文件,这里我们先不编辑这个文件,继续点击菜单 File -> save。

这里会弹出来要保存的路径和文件名,我们还是选择之前的新建Keil(MDK)文件夹的路径,并将这个文件保存为main.c文件。

然后和之前添加 startup_stm32f407xx.s 启动文件一样来添加我们刚刚新建的 main.c 文件。
继续双击Target 1里面的 Source Group 1会弹出来让你让你选择文件的对话框,选择 main.c 后下面的文件名就会自动填入你选择的文件名,最后单击右边的 Add,此时该文件就被我们添加入工程里面了。

到这里,我们就能在左边的工程树中看到新加入的main.c了,此时双击打开这个文件,我们就可以进行代码的编写了。之前根据启动文件的部分代码,我们也已经知道至少需要实现两个函数,一个是SystemInit,另一个是main。
第一个SystemInit函数,:对于初学者,我们暂时不必关心复杂的时钟配置。STM32芯片设计得很友好,即使我们什么都不做,它也会使用内部RC振荡器(HSI)以16MHz的速度运行,虽然速度慢点(毕竟STM32F407最高可以以168MHz的速度运行),但程序能跑起来。因此,我们可以先提供一个空的 SystemInit 函数。
第二个main函数是这里的主角,可是我们现在连GPIO都不知道如何控制,既然还不会操作GPIO点灯,那我们就先让CPU做点能 看得见 的计算。我们定义一个全局变量,在 main 函数的死循环里不断让它自增。在本章节的第七章,我们会借助 DAPLink仿真器 直接看到单片机进行自+1计算的结果。
可以将下面的代码先复制到你的main.c文件中:
/* 定义一个全局变量,用于在调试时观察
volatile 关键字告诉编译器,这个变量的值随时可能在程序逻辑之外被改变
(比如在中断里,或被调试器修改),因此不要对它进行优化,每次都从内存中读取。 */
static volatile unsigned int lckfb_count = 0;
/* 函数声明:告诉编译器后面会定义这些函数 */
void SystemInit(void);
static void delay_simple(unsigned int cycles);
/**
* @brief 程序主函数
* @param 无
* @retval 无
*/
int main(void)
{
/* 嵌入式系统的主循环,程序会永远在while(1)里面运行*/
while (1)
{
lckfb_count++; /* 每次循环,让计数器加1*/
delay_simple(500000); /* 调用一个简单的延时,减慢循环速度*/
}
}
/**
* @brief 系统初始化函数
* @note 此函数在启动代码 startup_stm32f407xx.s 中被调用
* 我们暂时将其留空,系统将使用默认的内部时钟(HSI)运行
* @param 无
* @retval 无
*/
void SystemInit(void)
{
/* 此处留空 */
}
/**
* @brief 一个简单的软件延时函数
* @note 这是一个不精确的阻塞延时,仅用于演示。
* 它通过执行空操作指令来消耗时间。
* @param cycles: 循环次数,值越大延时越长
* @retval 无
*/
static void delay_simple(unsigned int cycles)
{
while (cycles--)
{
/* __asm("nop") 是一条汇编指令,意思是 "No Operation" (无操作)
CPU执行它只消耗一个时钟周期,不做任何事。
volatile 确保编译器不会把这个循环优化掉。*/
__asm volatile ("nop");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
上面代码有详细的注释,就算有不理解也可以让子弹先飞一会,先别管他,学到后面自然会知道的,这里只要理解main函数里面做了啥操作就可以了,lckfb_count++;是给全局变量进行自加1,delay_simple(500000); 是单纯的起到延时作用。
static,volatile都是C语言的基础知识,这里重点强调一下while(1)的重要性:在PC(电脑)上写简单的C程序,main 函数执行完毕就意味着程序结束,资源会由操作系统回收。但在没有操作系统的裸机单片机上,main 函数绝对不能返回!一旦返回,CPU不知道接下来该去哪里,就会 跑飞 ,执行未知内存区域的代码,导致系统崩溃(通常会进入HardFault(硬件错误)中断)。一个永不退出的 while(1) 循环是裸机程序的生命线。
3.3.4 配置工程选项
单击工程配置Option for Target...:

将Floating Point Hardware 从 Single Precision 修改为Not Used。【这个很重要,这里不修改,单片机就会进入硬件错误,造成程序无法运行的假象】
【疑惑-为什么需要取消掉这个Single Precision呢?】
STM32F407属于Cortex-M4内核,它内置了一个硬件FPU。这意味着它可以非常高效地处理单精度浮点数(float),也就是上方对话框中的 Single Precision的功能,我们可以简单理解为这个功能可以加速浮点数的运算速度。现代的C编译器(如Keil MDK, GCC, IAR)在为Cortex-M4编译代码时,为了追求最高性能,会默认启用硬件FPU支持。也就意味着编译器会生成专门的FPU指令(例如 VADD, VMUL)来执行浮点运算。
在实际的正常工程中,SystemInit()这个函数一般是由官方提供的库文件来实现的,其内部会对FPU进行使能,但是我们为了实现最简工程,在上面实现的SystemInit()是个空函数,里面是没有实际处理代码的,所以如果编译器编译出来的代码如果有FPU的指令,但是我们又没有使能(开启)这个指令,单片机就会触发硬件错误。
在实际工程中,我们是需要把这个FPU打开的,否则相当于浪费了单片机的浮点性能。
单击工程配置Option for Target...这个弹出框里面的Debug,将硬件调试器从默认的 ULINK2/ME Cortex Debugger修改为CMSIS-DAP Debugger,然后单击右边的Settings进行外部调试器的设置。

在打开的对话框中,先选择菜单栏的第一个Debug,然后选择当前使用的调试器,也就是LCKFB DAPLink CMSIS-DAP,正常情况下,在SW Device那里的IDCODE会显示一个ID值,对于天空星STM32系列核心板来说,这个值一般是0x2BA01477。
- 假如你左边的
CMSIS-DAP-JTAG/SW Adapter选项框里面没有对应调试器的话,可能是调试器和电脑之间没有连接好,先重新插拔试一下,最好直接接电脑的USB口,不要经过USB扩展坞。 - 假如你右边的
SW Device里面没有IDCODE显示出来,那可能是调试器和天空星开发板之间没有的连接不稳定或者接错了,同时,如果线材质量不够好的话,也需要把MAX Clock的速度要降低一点,可以从10MHz先降到1MHz试试。 - Port接口这里我们选择SW方式,但是我们天空星的板子引出来的2X5P排针中,我们只引出了SWD用到的DIO和CLK,相比JTAG下载方式占用的引脚更少。

然后我们切换到第3页Flash Download,将这里的 Reset and Run 给勾选上。目的是让我们的程序在下载后就立即运行,如果不勾选这个,你需要按一下板子上的复位按键单片机才会开始运行。最后在关闭这个对话框之前,一定要点击OK,否则之前设置的全部都不会保存,如果你没有点OK,而是直接关掉了这个对话框,那么本小节前面的全部设置都需要重新设置。

最后我们在之前的对话框中选择Utilities,确保里面的Use Debug Driver有被勾选上,这个选项的作用是让Keil(MDK)在将固件下载到单片机中时,使用我们在上个选项中所使用的默认调试器。最后,这里最后在关闭这个对话框之前,一定要点击OK,否则之前设置的也不会保存。

3.3.5 进行编译测试
此时我们单击Keil(MDK)左上角的Rebuild全编译按钮进行编译:

如果上面的 Build Output 窗口显示了".\Objects\project.axf" - 0 Error(s), 0 Warning(s).,也就是0错误,0警告,就说明我们这个工程可以正常编译了,可以继续下面的学习了。如果没有,也搞不清楚哪里出错了,请重新新建个文件夹,再从头开始看本篇教程,重新开始吧。
四 用 VS Code + EIDE 来新建 寄存器 工程
IMPORTANT
【如果你是初学者,同时也对使用VS Code+EIDE来编写代码没有兴趣,建议先跳过本小节,直接开始看第六节】
4.1 前期准备
- 已正确安装 Visual Studio Code 软件。
- 已在 VS Code 中成功安装 EIDE 插件,并根据前面章节的说明,配置好了编译和调试环境。
- 已再VS Code 中成功安装 Cortex-Debug 插件。
- 已熟悉 Keil (MDK) 的基本操作,能够独立创建和编译项目(最简单的变量循环自增一),并对 Keil 的工程结构有大致了解。
4.2 获取必要所需文件
要从零开始构建一个寄存器工程,我们不需要 HAL 库,但至少需要一个汇编所写的启动文件。它是连接我们的 C 代码和底层硬件的桥梁。需要注意,就算是同一个芯片在使用不同的编译器编译代码时,它的启动文件也是不一样的,比如Keil(MDK)所使用的编译器是armcc,在VS Code + EIDE中编译,我们默认使用gcc编译器。
启动文件startup:是汇编语言编写,是芯片上电后、进入 main 函数前执行的第一段代码。现在不需要理解,你就把它当作一个引路人,它可以把芯片从汇编的世界带入C语言的世界。
startup_stm32f407xx.s: 主要负责设置堆栈指针、初始化.data和.bss段、设置中断向量表,最后调用SystemInit()和main()函数。
这个文件从哪里来?
本教程所使用的STM32F407是ST公司生产的,自然相关资料需要去ST的网站上下载,点击这个链接:
STM32CubeF4 | Product - STMicroelectronics
登录然后下载这个Package包(可能你看本篇文章时版本号不是截图的中版本了,没关系,可以直接用最新的,如果遇到问题了,可以选择右边的Select Version来下载之前的版本):

如果你可以顺畅访问GitHub的话,也可以直接从这个仓库下载。在目录的
Source/Templates/gcc里面就可以找到 startup_stm32f407xx.s 这个文件了。
我们在ST官网下载下来的是一个压缩包,将它解压缩之后进入文件夹,打开这个路径:
STM32Cube_FW_F4_V1.28.0\Drivers\CMSIS\Device\ST\STM32F4xx\Source\Templates
就能看到三个文件夹,我们的Keil(MDK)需要使用arm目录下的 startup_stm32f407xx.s:

双击上面的gcc文件夹,进入里面找到startup_stm32f407xx.s这个文件。

IMPORTANT
一定要注意,这里用到的启动文件虽然和Keil(MDK)里面用到的文件名是一样的,但是文件内容是不一样的,如果你在后面出现了无法正常编译的情况,请回过头来重新检查是不是启动文件误选了。
你现在可以完全不管这个文件里面的内容,只要知道它是单片机运行你写的代码前所必须执行的汇编代码就可以,在后面的 系统是如何启动的 章节我们会重新回过头介绍的。
4.3 开始创建工程
在开始之前,先把 DAPLink仿真器 和 天空星核心板 正确连接起来,如下图所示:

4.3.1 规划工程目录
我们要创建最简工程,所以这里就先不考虑文件夹结构了,本来也就只有两个文件而已。EIDE本身自己会创建文件夹,所以这里就不像Keil(DMK)一样要进行新建文件夹了。
4.3.2 新建 VS Code+EIDE 工程
首先先打开VS Code,然后点击左侧边栏的EIDE插件:

切换至EIDE插件后,我们单击【新建项目】:

点击上方窗口处的【空项目】:

再单击 Cortex-M 项目,我们的天空星STM32主控芯片所使用的内核就是Cortex-M4的:

我们将上面的项目名称修改为EIDE-Project,你也可以修改为你想命名的工程名,确定好之后按回车进行确认。

接下来会弹出来工程的保存路径,找到我们前面创建的 EIDE 文件夹,然后单击【选择项目的保存位置】:

这里要和之前的Keil(MDK)工程在同级目录,不过你自己新建工程的话就不讲究这个了,可以随意放到你想放的位置。
右下角会弹出来让我们切换工作区的提示,这里点击【继续】:

此时,会自动跳转到你新建工程的工作区:

4.3.3 添加启动文件
上面的工程创建好之后,可以看到原来的目录中就会出现EIDE-Project,这个新文件夹了。将在前面小节【4.2】获取到的 startup_stm32f407xx.s 这个文件复制过来。

我们返回到VS Code里面,可以看到再工作区里面已经能看到启动文件了,但是它还没有被加入到工程编译里面,我们来点击左边侧边栏进入EIDE插件里面把他加入到工程里面:

进入EIDE工程界面后,鼠标右键点击【项目资源】,把启动文件给添加进来:


选择显示所有文件,然后选中startup_stm32f407xx.s,把他添加进来:

鼠标左键点击项目资源前面的小三角,就能看到启动文件被正常添加进来了:

4.3.4 新建并添加 main.c
此处内容和新建Keil工程章节的3.3.2内容是高度一致的,可以跳着看。
【为什么要添加main.c函数?】
大家在学C语言的时候想必都知道c语言的入口函数是main函数,那么在单片机上这个main函数是如何被执行的呢?他是被谁调用的,我们现在先来看一下gcc的启动文件 startup_stm32f407xx.s 中的60行到102行:

这段汇编代码,就是单片机复位后执行的第一段用户代码,我们称之为复位处理程序(Reset Handler)。至于为什么会先执行这段代码,我们后续章节再补充说明。【TODO】后续章节完成要在这里更新。
在这里我们先把这个单片机想象成一个人,假设他现在刚刚睡醒:
- 闹钟响了 (Power-On(上电) / Reset(复位)):你给单片机一上电,或者按一下复位键,就相当于闹钟响了。CPU“醒来”的第一件事,不是去想今天要做什么(
main函数),而是执行一个固定的 晨间例行程序 (引导程序)。- 穿衣洗漱 调整状态 (
SystemInit):这个例行程序的第一步,就是SystemInit。你看代码LDR R0, =SystemInit和BLX R0,意思就是“找到SystemInit这个函数,然后去执行它”。这个函数是干嘛的呢?它负责一些最基础的系统初始化,比如:
- 配置系统时钟:就像给自己的手表对时,让单片机的心脏(晶振及内部时钟)以正确的频率跳动。这是后续所有外设(GPIO、串口等)能正常工作的基础。
- 开启外设时钟:比如FPU(浮点运算单元)的时钟。
- 其他一些关键的底层配置,比如配置Flash的访问速度。做完这些,单片机的 身体机能 才算基本就绪。
- 准备开始一天的工作 (
__main):洗漱完毕,接下来就要准备正式开始工作了。代码LDR R0, =__main和BX R0,就是跳转到__main。注意,这里是__main(有两个下划线),它不是我们在学C语言时写的那个main函数!毕竟名字虽然长得像,但是不一样。- 从C库到main函数:
__main是C库(Runtime Library)里的一个标准初始化函数。你可以把它理解成一个 照顾它起居的保姆。这个函数的任务是:
- 在RAM里建立起C语言运行所需要的环境,比如初始化全局变量、静态变量(把它们从Flash拷贝到RAM,或者清零)。这个过程一般叫做 分散加载。
- 最后,当一切准备就绪,
__main这位“幕后功臣”的最后一个动作,就是调用我们编写的main()函数。至此,程序的控制权终于交到了你的手上!
所以,启动文件和我们要实现的两个函数调用链是这样的:
硬件复位(上电或者手动复位) -> Reset_Handler (汇编) -> SystemInit (C函数) -> main (你的C函数)。
根据这个调用链我们可以知道在C文件中,我们至少需要实现两个函数才能正常编译通过,一个是SystemInit,另一个是main。
我们点击VS Code菜单栏的 文件 -> 新建文件,

会弹出来命名文件,这里先取名为main.c,然后按回车。

此时会弹出来这个main.c文件的保存路径,依旧选择EIDE的工程路径,单击创建文件。

此时main.c文件,就已经保存到上面的目录中了。
我们继续回到EIDE插件的界面,点击项目资源后面的【添加文件】按钮。

此时会弹出来该文件的选择路径,我们选择前面保存的main.c文件:

当你可以在EIDE的项目资源下面看到main.c文件时,就说明此时EIDE工程已经正常添加main.c文件了。
之前根据启动文件的部分代码,我们也已经知道至少需要实现两个函数,一个是SystemInit,另一个是main。
第一个SystemInit函数,:对于初学者,我们暂时不必关心复杂的时钟配置。STM32芯片设计得很友好,即使我们什么都不做,它也会使用内部RC振荡器(HSI)以16MHz的速度运行,虽然速度慢点(毕竟STM32F407最高可以以168MHz的速度运行),但程序能跑起来。因此,我们可以先提供一个空的 SystemInit 函数。
第二个main函数是这里的主角,可是我们现在连GPIO都不知道如何控制,既然还不会操作GPIO点灯,那我们就先让CPU做点能 看得见 的计算。我们定义一个全局变量,在 main 函数的死循环里不断让它自增。在本章节的第七章,我们会借助 DAPLink仿真器 直接看到单片机进行自+1计算的结果。
可以将下面的代码先复制到你的main.c文件中:
/* 定义一个全局变量,用于在调试时观察
volatile 关键字告诉编译器,这个变量的值随时可能在程序逻辑之外被改变
(比如在中断里,或被调试器修改),因此不要对它进行优化,每次都从内存中读取。 */
static volatile unsigned int lckfb_count = 0;
/* 函数声明:告诉编译器后面会定义这些函数 */
void SystemInit(void);
static void delay_simple(unsigned int cycles);
/**
* @brief 程序主函数
* @param 无
* @retval 无
*/
int main(void)
{
/* 嵌入式系统的主循环,程序会永远在while(1)里面运行*/
while (1)
{
lckfb_count++; /* 每次循环,让计数器加1*/
delay_simple(500000); /* 调用一个简单的延时,减慢循环速度*/
}
}
/**
* @brief 系统初始化函数
* @note 此函数在启动代码 startup_stm32f407xx.s 中被调用
* 我们暂时将其留空,系统将使用默认的内部时钟(HSI)运行
* @param 无
* @retval 无
*/
void SystemInit(void)
{
/* 此处留空 */
}
/**
* @brief 一个简单的软件延时函数
* @note 这是一个不精确的阻塞延时,仅用于演示。
* 它通过执行空操作指令来消耗时间。
* @param cycles: 循环次数,值越大延时越长
* @retval 无
*/
static void delay_simple(unsigned int cycles)
{
while (cycles--)
{
/* __asm("nop") 是一条汇编指令,意思是 "No Operation" (无操作)
CPU执行它只消耗一个时钟周期,不做任何事。
volatile 确保编译器不会把这个循环优化掉。*/
__asm volatile ("nop");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
上面代码有详细的注释,就算有不理解也可以让子弹先飞一会,先别管他,学到后面自然会知道的,这里只要理解main函数里面做了啥操作就可以了,lckfb_count++;是给全局变量进行自加1,delay_simple(500000); 是单纯的起到延时作用。
static,volatile都是C语言的基础知识,这里重点强调一下while(1)的重要性:在PC(电脑)上写简单的C程序,main 函数执行完毕就意味着程序结束,资源会由操作系统回收。但在没有操作系统的裸机单片机上,main 函数绝对不能返回!一旦返回,CPU不知道接下来该去哪里,就会 跑飞 ,执行未知内存区域的代码,导致系统崩溃(通常会进入HardFault(硬件错误)中断)。一个永不退出的 while(1) 循环是裸机程序的生命线。
4.3.5 配置工程选项
WARNING
!!!该项可以跳过,没有这个芯片支持包也可以正常编译下载。
在Keil(MDK)创建工程时,我们第一步就是选择具体的芯片型号,截止目前,在EIDE工程中我们还没有设置芯片型号,也就是编译器现在都不知道你当前的目标芯片,现在点击芯片支持包后面的绿色+号,为了方便,先选择在线下载芯片支持包,点第一个From Repo:

在上方搜索栏中,搜索STM32F4,单击进行安装:

等待下载进度条走完,芯片支持包就被我们成功添加进来了,点击芯片支持包前面的小三角,展开,然后点击STM32F4xx_DFP后面的选择芯片。

在弹出框中,搜索STM32F407VE,进行具体芯片型号的选择,单击弹出来的芯片型号。

4.3.6 设置构建配置
我们点击,构建配置前面的小三角,进行展开,首先确保你当前的构建配置后面现在是GCC,如果不是的话需要更改成GCC:

【重点来了!】要选择一下这里面的 链接脚本路径。
【链接脚本有什么作用?】
编译器把我们的 C 代码编译成一个个 零件 (目标文件)。链接脚本的作用,就是告诉链接器这个 包工头,如何把所有这些 零件 组装起来,并精确地放置到单片机(STM32)内部的物理空间(FLASH 和 RAM)里,最终形成一个可以运行的完整程序。
没有这个 蓝图 ,链接器就不知道该把代码放哪里,把变量放哪里,程序就无法正确运行。
一般来说链接脚本是芯片厂家提供的,我们这里使用的GCC链接脚本是由STM32CubeMX自动生成的,大家现在可以先不关心这个东西是哪里来的,怎么写出来的。直接下载下来保存到你的工程目录即可。
NOTE
细心的同学可能已经发现了,在Keil(MDK)工程的配置中,我们并没有添加链接脚本这个步骤,并不是说Keil(MDK)工程不需要这个链接脚本,只不过是他已经给你提前处理好了而已,这也是为什么建议初学者先使用Keil(MDK)进行前期的学习。
【下面这个是GCC链接脚本的下载链接】

再回到EIDE插件目录,点击链接脚本路径后面的打开,修改上面的窗口文件名为STM32F407VEX.ld,然后按回车进行确定。

4.3.7 进行编译测试
如果你前面操作都正常,那么此时你就可以点击Build进行编译测试了,这个工程很小,编译会超级快:

当你下面的终端出现build successfully !后,恭喜你,说明你现在新建的这个VS Code + EIDE工程可以正常编译,可以继续下面的学习了。如果没有,也搞不清楚哪里出错了,请重新新建个文件夹,再从头开始看本章教程,重新开始吧。
4.3.8 搭建下载环境
EIDE本身是不支持调试下载的,其调试下载功能是借助Cortex-Debug和openocd插件或者其他第三放插件实现的。
点开【烧录配置】前面的小三角,首先把烧录配置后面的默认配置【JLink】修改为OpenOCD:

【疑惑:为什么在EIDE里面提供的选项分别是JLink,STLink,OpenOCD这些?怎么没有DAPLink呢?】
JLink和STLink都有自己提供的GDB Server,而DAPLink本身并没有提供这个,OpenOCD是纯软件,它可以作为桥梁来连接DAPLink,甚至JLink和STLink也可以,
这里我们需要把芯片配置改为STM32F4x.cfg,点击芯片配置后面的修改按钮,在弹出来的搜索框中,搜索stm32f4,然后在下面的列表中选择这个配置文件,

接下来再把接口配置的 stlink.cfg 修改为 cmsis-dap.cfg

4.3.9 搭建调试环境
在开始之前,请确保你已经安装了 Cortex-Debug 插件,这是 VS Code 进行嵌入式调试的核心。打开VS CODE 点击 【扩展】 栏目,在上发搜索栏中搜索 Cortex-Debug 进行安装。
可以参考该链接处的说明:调试项目 | Embedded IDE For VSCode

定位到我们在第四章【用 VS Code + EIDE 来新建 寄存器 工程】创建好的工程。
【可选,这个可以脱离EIDE的调试按钮,想自己修改详细配置就用这个,想省心就直接用EIDE插件里面的调试按钮】
在.vscode目录中创建一个名为 launch.json 的文件,将下面这些内容填进去:
{
"version": "0.2.0",
"configurations": [
{
"cwd": "${workspaceRoot}",
"type": "cortex-debug",
"request": "launch",
"name": "Debug: OpenOCD",
"servertype": "openocd",
"executable": "build/Debug/EIDE-Project.elf",
"runToEntryPoint": "main",
"configFiles": [
"interface/cmsis-dap.cfg",
"target/stm32f4x.cfg"
],
"toolchainPrefix": "arm-none-eabi"
"svdFile": "STM32F407.svd"
"liveWatch":{
"enabled": true,
"samplesPerSecond": 4
}
}
]
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
也可以直接从gitee下载文件到你的本地: 链接地址
将该文件放入根目录下的.vscode文件夹:

五 用 CLion 来新建 寄存器 工程
IMPORTANT
【如果你是初学者,同时也对使用CLion来编写代码没有兴趣,建议先跳过本小节,直接开始看第六节。但如果你追求极致的代码补全体验和现代化的IDE开发环境,CLion将是你的不二之选】
我们后续学习HAL库时,CLion工程(cmake)都是由cubemx自动生成,不需要再像下面这样麻烦的。大家着急的话可以先看这个链接里面的内容。
5.1 前期准备
- 已正确安装 CLion 软件:建议使用较新的版本,JetBrains 对嵌入式的支持在不断更新,近期还更新了live-watch特性,全局变量无须暂停程序,会自动刷新了。
- 已安装好 arm-none-eabi-gcc 交叉编译工具链:这是将 C 代码翻译成 STM32 能听懂的机器码的翻译官。请确保已将其路径添加到系统的环境变量中。
- 已安装 MinGW 或 Ninja:CMake 生成构建文件需要构建工具(Make 或 Ninja)的支持。
- 已安装 OpenOCD:CLion 调试全靠它来连接硬件。
- 硬件连接:确保 DAPLink 仿真器与天空星核心板已连接,并插入电脑。
5.2 获取必要所需文件
与 VS Code + EIDE 的逻辑一样,我们不需要庞大的 HAL 库,但必须要有 启动文件 和 链接脚本 。
5.2.1 启动文件 (startup)
CLion 默认使用 GCC 编译器,因此我们需要 GCC 版本的启动文件。
要从零开始构建一个寄存器工程,我们不需要 HAL 库,但至少需要一个汇编所写的启动文件。它是连接我们的 C 代码和底层硬件的桥梁。需要注意,就算是同一个芯片在使用不同的编译器编译代码时,它的启动文件也是不一样的,比如Keil(MDK)所使用的编译器是armcc,在clion中编译,我们默认使用gcc编译器。
启动文件startup:是汇编语言编写,是芯片上电后、进入 main 函数前执行的第一段代码。现在不需要理解,你就把它当作一个引路人,它可以把芯片从汇编的世界带入C语言的世界。
startup_stm32f407xx.s: 主要负责设置堆栈指针、初始化.data和.bss段、设置中断向量表,最后调用SystemInit()和main()函数。
这个文件从哪里来?
本教程所使用的STM32F407是ST公司生产的,自然相关资料需要去ST的网站上下载,点击这个链接:
STM32CubeF4 | Product - STMicroelectronics
登录然后下载这个Package包(可能你看本篇文章时版本号不是截图的中版本了,没关系,可以直接用最新的,如果遇到问题了,可以选择右边的Select Version来下载之前的版本):

如果你可以顺畅访问GitHub的话,也可以直接从这个仓库下载。在目录的
Source/Templates/gcc里面就可以找到 startup_stm32f407xx.s 这个文件了。
我们在ST官网下载下来的是一个压缩包,将它解压缩之后进入文件夹,打开这个路径:
STM32Cube_FW_F4_V1.28.0\Drivers\CMSIS\Device\ST\STM32F4xx\Source\Templates
就能看到三个文件夹,我们的Keil(MDK)需要使用arm目录下的 startup_stm32f407xx.s:

双击上面的gcc文件夹,进入里面找到startup_stm32f407xx.s这个文件。

IMPORTANT
一定要注意,这里用到的启动文件虽然和Keil(MDK)里面用到的文件名是一样的,但是文件内容是不一样的,如果你在后面出现了无法正常编译的情况,请回过头来重新检查是不是启动文件误选了,CLion用的编译器和VSCODE + EIDE是一样的,所以他们的启动文件可以用同一个,启动文件只和编译器有关。
你现在可以完全不管这个文件里面的内容,只要知道它是单片机运行你写的代码前所必须执行的汇编代码就可以,在后面的 系统是如何启动的 章节我们会重新回过头介绍的。
[!NOTE]如果你上面两个方法都没发正常下载的话,也可以来我们的Gitee仓库来获取。
5.2.2 链接脚本 (Linker Script)
【链接脚本有什么作用?】
编译器把我们的 C 代码编译成一个个 零件 (目标文件)。链接脚本的作用,就是告诉链接器这个 包工头,如何把所有这些 零件 组装起来,并精确地放置到单片机(STM32)内部的物理空间(FLASH 和 RAM)里,最终形成一个可以运行的完整程序。
没有这个 蓝图 ,链接器就不知道该把代码放哪里,把变量放哪里,程序就无法正确运行。
一般来说链接脚本是芯片厂家提供的,我们这里使用的GCC链接脚本是由STM32CubeMX自动生成的,大家现在可以先不关心这个东西是哪里来的,怎么写出来的。直接下载下来保存到你的工程目录即可。
NOTE
细心的同学可能已经发现了,在Keil(MDK)工程的配置中,我们并没有添加链接脚本这个步骤,并不是说Keil(MDK)工程不需要这个链接脚本,只不过是他已经给你提前处理好了而已,这也是为什么建议初学者先使用Keil(MDK)进行前期的学习。
【下面这个是GCC链接脚本的下载链接】
STM32F407VEX.ld,大家可以把这个仓库全部下载下来后,把这个文件复制处理啊,也可以一键复制后在本地新建文件后,把内容填进来。
5.3 开始创建工程
CLion 的项目结构基于 CMake,对于习惯了 Keil 的同学来说,这可能是一次思维的重构,但一旦习惯,你会爱上它的灵活性。
先创建一个名为CLion的文件夹,然后把前面在5.2章节获取的两个文件(启动文件+链接脚本)先放到里面。

5.3.1 新建项目
- 打开 CLion,点击 新建项目。
- 在左侧列表中选择 C 可执行文件。
- 我们要写寄存器版本,不依赖 HAL 库,在后面的正式教学章节中我们会选择 STM32CubeMX 来直接生成模板,因为它会自动帮我们生成 CMakeLists.txt 的基础框架和必要的启动文件/链接脚本。为了让初学者理解透彻,我们这里选择最硬核的
C Executable方式,手写 CMakeLists.txt(其实我也是根据STM32CubeMX生成的文件来改的)。
- 我们要写寄存器版本,不依赖 HAL 库,在后面的正式教学章节中我们会选择 STM32CubeMX 来直接生成模板,因为它会自动帮我们生成 CMakeLists.txt 的基础框架和必要的启动文件/链接脚本。为了让初学者理解透彻,我们这里选择最硬核的
- 位置:浏览文件夹,选择我们在上面创建好的CLion文件夹。
- 语言标准:选择
C99或C11。 - 点击 创建,因为我们已经在文件夹中放了一些东西了,所以它会提示你目录不为空,直接点击【从现有的源创建】即可成功创建项目。

工程创建完成后,会多出两个新文件(main.c,CMakeLists.txt),一个新文件夹(cmake-build-debug),此时会报错,不要担心,我们下一步再处理。
5.3.2 新增 gcc-arm-none-eabi.cmake 文件
在工程根目录下新建一个名为gcc-arm-none-eabi.cmake的文件,把下面的内容复制到文件里面,当然,你也可以通过这个链接直接下载下来。
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_C_COMPILER_ID GNU)
set(CMAKE_CXX_COMPILER_ID GNU)
# Some default GCC settings
# arm-none-eabi- must be part of path environment
set(TOOLCHAIN_PREFIX arm-none-eabi-)
set(CMAKE_C_COMPILER ${TOOLCHAIN_PREFIX}gcc)
set(CMAKE_ASM_COMPILER ${CMAKE_C_COMPILER})
set(CMAKE_CXX_COMPILER ${TOOLCHAIN_PREFIX}g++)
set(CMAKE_LINKER ${TOOLCHAIN_PREFIX}g++)
set(CMAKE_OBJCOPY ${TOOLCHAIN_PREFIX}objcopy)
set(CMAKE_SIZE ${TOOLCHAIN_PREFIX}size)
set(CMAKE_EXECUTABLE_SUFFIX_ASM ".elf")
set(CMAKE_EXECUTABLE_SUFFIX_C ".elf")
set(CMAKE_EXECUTABLE_SUFFIX_CXX ".elf")
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
# MCU specific flags
set(TARGET_FLAGS "-mcpu=cortex-m4 -mfpu=fpv4-sp-d16 -mfloat-abi=hard ")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${TARGET_FLAGS}")
set(CMAKE_ASM_FLAGS "${CMAKE_C_FLAGS} -x assembler-with-cpp -MMD -MP")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wall -Wextra -Wpedantic -fdata-sections -ffunction-sections")
set(CMAKE_C_FLAGS_DEBUG "-O0 -g3")
set(CMAKE_C_FLAGS_RELEASE "-Os -g0")
set(CMAKE_CXX_FLAGS_DEBUG "-O0 -g3")
set(CMAKE_CXX_FLAGS_RELEASE "-Os -g0")
set(CMAKE_CXX_FLAGS "${CMAKE_C_FLAGS} -fno-rtti -fno-exceptions -fno-threadsafe-statics")
set(CMAKE_C_LINK_FLAGS "${TARGET_FLAGS}")
set(CMAKE_C_LINK_FLAGS "${CMAKE_C_LINK_FLAGS} -T \"${CMAKE_SOURCE_DIR}/STM32F407VEX.ld\"")
set(CMAKE_C_LINK_FLAGS "${CMAKE_C_LINK_FLAGS} --specs=nano.specs")
set(CMAKE_C_LINK_FLAGS "${CMAKE_C_LINK_FLAGS} -Wl,-Map=${CMAKE_PROJECT_NAME}.map -Wl,--gc-sections")
set(CMAKE_C_LINK_FLAGS "${CMAKE_C_LINK_FLAGS} -Wl,--start-group -lc -lm -Wl,--end-group")
set(CMAKE_C_LINK_FLAGS "${CMAKE_C_LINK_FLAGS} -Wl,--print-memory-usage")
set(CMAKE_CXX_LINK_FLAGS "${CMAKE_C_LINK_FLAGS} -Wl,--start-group -lstdc++ -lsupc++ -Wl,--end-group")2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
这个是CMake 工具链文件(gcc-arm-none-eabi.cmake),用于告诉 CMake 如何用 arm-none-eabi GCC 工具链为 ARM Cortex‑M4(带 FPU)的裸机目标(就是我们天空星开发板所用的STM32F407主芯片)进行交叉编译与链接。假如没有上面这些命令,我们编译出来的大概率就不是给单片机运行的程序,而是给PC运行的程序,那就完全乱套了。

5.3.3 修改 CMakeLists.txt 文件
上面的文件5.3.2 新增 gcc-arm-none-eabi.cmake 文件确定了我们使用什么工具链来进行项目的编译,指定了链接脚本路径。本节的 CMakeLists.txt 会告诉IDE让那些文件参与编译:
复制以下内容到CLion自己创建的CMakeLists.txt 文件中,把之前的内容替换掉:
cmake_minimum_required(VERSION 3.22)
#
# This file is generated only once,
# and is not re-generated if converter is called multiple times.
#
# User is free to modify the file as much as necessary
#
# Setup compiler settings
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_C_EXTENSIONS ON)
# Define the build type
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE "Debug")
endif()
# Set the project name
set(CMAKE_PROJECT_NAME project)
# Include toolchain file
include("gcc-arm-none-eabi.cmake")
# Enable compile command to ease indexing with e.g. clangd
set(CMAKE_EXPORT_COMPILE_COMMANDS TRUE)
# Core project settings
project(${CMAKE_PROJECT_NAME})
message("Build type: " ${CMAKE_BUILD_TYPE})
# Enable CMake support for ASM and C languages
enable_language(C ASM)
# Create an executable object type
add_executable(${CMAKE_PROJECT_NAME})
# Link directories setup
target_link_directories(${CMAKE_PROJECT_NAME} PRIVATE
# Add user defined library search paths
)
# Add sources to executable
target_sources(${CMAKE_PROJECT_NAME} PRIVATE
# Add user sources here
${CMAKE_CURRENT_SOURCE_DIR}/main.c
${CMAKE_CURRENT_SOURCE_DIR}/startup_stm32f407xx.s
)
# Add include paths
target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE
# Add user defined include paths
)
# Add project symbols (macros)
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE
# Add user defined symbols
)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
要添加源文件就在这中间填写:
# Add sources to executable
target_sources(${CMAKE_PROJECT_NAME} PRIVATE
# Add user sources here
${CMAKE_CURRENT_SOURCE_DIR}/main.c
${CMAKE_CURRENT_SOURCE_DIR}/startup_stm32f407xx.s
)2
3
4
5
6
TIP
${CMAKE_CURRENT_SOURCE_DIR} 在这里就是当前项目的根目录。
要添加头文件路径就在这中间填写:
# Add include paths
target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE
# Add user defined include paths
)2
3
4
5
要添加全局宏定义就在这中间填写:
# Add project symbols (macros)
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE
# Add user defined symbols
)2
3
4
目前我们总共就两个文件,所以这里什么都不需要填写。
然后选择主菜单 -> 文件 -> 重新加载CMake项目:

如果你前面的环境配置没有问题,那么 Debug 这里应该会成功输出一个 [已完成] 的提示:

5.3.4 编写 main.c
此处内容和新建Keil工程章节的3.3.2内容是高度一致的,可以跳着看。
【为什么有main.c函数?】
大家在学C语言的时候想必都知道c语言的入口函数是main函数,那么在单片机上这个main函数是如何被执行的呢?他是被谁调用的,我们现在先来看一下gcc的启动文件 startup_stm32f407xx.s 中的60行到102行:

这段汇编代码,就是单片机复位后执行的第一段用户代码,我们称之为复位处理程序(Reset Handler)。至于为什么会先执行这段代码,我们后续章节再补充说明。【TODO】后续章节完成要在这里更新。
在这里我们先把这个单片机想象成一个人,假设他现在刚刚睡醒:
- 闹钟响了 (Power-On(上电) / Reset(复位)):你给单片机一上电,或者按一下复位键,就相当于闹钟响了。CPU“醒来”的第一件事,不是去想今天要做什么(
main函数),而是执行一个固定的 晨间例行程序 (引导程序)。- 穿衣洗漱 调整状态 (
SystemInit):这个例行程序的第一步,就是SystemInit。你看代码LDR R0, =SystemInit和BLX R0,意思就是“找到SystemInit这个函数,然后去执行它”。这个函数是干嘛的呢?它负责一些最基础的系统初始化,比如:
- 配置系统时钟:就像给自己的手表对时,让单片机的心脏(晶振及内部时钟)以正确的频率跳动。这是后续所有外设(GPIO、串口等)能正常工作的基础。
- 开启外设时钟:比如FPU(浮点运算单元)的时钟。
- 其他一些关键的底层配置,比如配置Flash的访问速度。做完这些,单片机的 身体机能 才算基本就绪。
- 准备开始一天的工作 (
__main):洗漱完毕,接下来就要准备正式开始工作了。代码LDR R0, =__main和BX R0,就是跳转到__main。注意,这里是__main(有两个下划线),它不是我们在学C语言时写的那个main函数!毕竟名字虽然长得像,但是不一样。- 从C库到main函数:
__main是C库(Runtime Library)里的一个标准初始化函数。你可以把它理解成一个 照顾它起居的保姆。这个函数的任务是:
- 在RAM里建立起C语言运行所需要的环境,比如初始化全局变量、静态变量(把它们从Flash拷贝到RAM,或者清零)。这个过程一般叫做 分散加载。
- 最后,当一切准备就绪,
__main这位“幕后功臣”的最后一个动作,就是调用我们编写的main()函数。至此,程序的控制权终于交到了你的手上!
所以,启动文件和我们要实现的两个函数调用链是这样的:
硬件复位(上电或者手动复位) -> Reset_Handler (汇编) -> SystemInit (C函数) -> main (你的C函数)。
根据这个调用链我们可以知道在C文件中,我们至少需要实现两个函数才能正常编译通过,一个是SystemInit,另一个是main。
CLion在创建工程时,已经把main.c文件给我们创建好了,我们只需要把下面这写内容复制到CLion给我们创建好的main.c文件里面就可以了。
/* 定义一个全局变量,用于在调试时观察
volatile 关键字告诉编译器,这个变量的值随时可能在程序逻辑之外被改变
(比如在中断里,或被调试器修改),因此不要对它进行优化,每次都从内存中读取。 */
static volatile unsigned int lckfb_count = 0;
/* 函数声明:告诉编译器后面会定义这些函数 */
void SystemInit(void);
static void delay_simple(unsigned int cycles);
/**
* @brief 程序主函数
* @param 无
* @retval 无
*/
int main(void)
{
/* 嵌入式系统的主循环,程序会永远在while(1)里面运行*/
while (1)
{
lckfb_count++; /* 每次循环,让计数器加1*/
delay_simple(500000); /* 调用一个简单的延时,减慢循环速度*/
}
}
/**
* @brief 系统初始化函数
* @note 此函数在启动代码 startup_stm32f407xx.s 中被调用
* 我们暂时将其留空,系统将使用默认的内部时钟(HSI)运行
* @param 无
* @retval 无
*/
void SystemInit(void)
{
/* 此处留空 */
}
/**
* @brief 一个简单的软件延时函数
* @note 这是一个不精确的阻塞延时,仅用于演示。
* 它通过执行空操作指令来消耗时间。
* @param cycles: 循环次数,值越大延时越长
* @retval 无
*/
static void delay_simple(unsigned int cycles)
{
while (cycles--)
{
/* __asm("nop") 是一条汇编指令,意思是 "No Operation" (无操作)
CPU执行它只消耗一个时钟周期,不做任何事。
volatile 确保编译器不会把这个循环优化掉。*/
__asm volatile ("nop");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
5.3.5 进行编译测试
此时我们点击CLion右上角的一个像锤子🔨一样的图标,那个是构建按钮,如果你此时直接点击构建会出现异常,如下图所示:

这个报错是因为我们的生成器没有选对,你重新按照下图的方式再操作一下就可以:

重点就是要把CMake里面的生成器修改为MinGW Makefiles,当我们再次点击构建,下方控制台出现 构建 已完成 时就说明我们这个编译环境已经搭建好了,此时你在根目录的cmake-build-debug文件夹中,我们就能找到编译好的文件project.elf了,常见的编译固件还有hex,bin后缀(这俩都可以从elf转换过来)的,我们以后再详细介绍。
5.4 配置 OpenOCD 下载
代码能编译了,下一步就是要把代码下载进芯片并进行调试。
- 点击 CLion 右上角的 运行/调试配置 下拉框(一般显示为项目名,在我们这里就是project),选择 编辑配置。

- 点击弹出框
运行/调试配置左上角的 + 号,在弹出框中鼠标滚轮向下滑动,选择 OpenOCD 下载并运行。

- 名称:随便取,比如
下载到天空星。

- 可执行二进制文件:点击后面的文件选择你,在弹出框中选择我们刚刚在5.3.5进行编译测试 里生成的
project.elf文件。

面板配置文件(关键):
- 这里需要指定 OpenOCD 的配置文件。
- 点击右侧的文件夹图标,找到你 OpenOCD 安装目录下的
share/openocd/scripts。 - 我们需要两个配置:接口配置(CMSIS-DAP)和 目标芯片配置(STM32F4)。
- 但在 CLion 的界面里,我们通常提供一个整合的
.cfg文件。 - 实际操作:在项目根目录下新建一个名为
lckfb-skystar.cfg的文件,内容如下:
source [find interface/cmsis-dap.cfg] #source [find interface/stlink.cfg] # The target MCU. This should match your board source [find target/stm32f4x.cfg] reset_config srst_only1
2
3
4
5
6
7
8位置如下图所示:

- 回到 CLion 配置界面,选择这个新建的
lckfb-skystar.cfg。

- 上面那些都设置好了以后,点击 确定 保存。

确保硬件连接正常的话,此时我们就可以点击下载来运行了,动图演示一下这个过程,要确保一下你当前的 运行/调试配置 这里选择的是我们上面创建好的配置:

当控制台显示 ** Programming Finished **且右边弹出来【已下载固件】后,就能说明我们的下载环境搭建完成了。
5.5 在线调试测试
看本节之前一定要确保你在5.4小节是可以正常下载程序的。
我们单击CLion右上角的调试按钮(一个像小虫子的按钮),来进入调试状态,:

当出现调试器已连接至 tcp:localhost:3333字样时,就代表我们成功进入到调试状态了。调试相关的内容我们在第七节再详细介绍。
六 LED 灯的基础知识
在我们开始点灯之前,先要了解我们的 操作对象 ——LED 灯。很多初学者认为点灯只是简单的 设置高低电平,但如果不了解其物理特性,可能会面临亮度不均、烧毁IO口甚至烧毁灯珠的风险。
LED(Light Emitting Diode),全称为发光二极管,是一种能将电能直接转化为光能的半导体器件。它不仅保留了二极管 单向导电 的特性(即电流只能往一个方向流),还拥有一个神奇的特性:当电流顺畅流过时,它会发光。
6.1 LED灯的内部结构
本图来自东芝半导体,仅供学习交流使用:

上面的图片展示了 LED 内部最核心的结构——PN结。
6.1.1 物理结构(图片上半部分)
图片上方的红色块(p-type)和青色块(n-type)代表两种不同的半导体材料:
- P型半导体 (p-type):这边的 主力军 是空穴 (Holes),你可以把空穴想象成带有正电荷的 空座位 。
- N型半导体 (n-type):这边的 主力军 是电子 (Electrons),它们带有负电荷,活泼好动。
当我们将它们结合在一起时,就形成了一个 PN结。
6.1.2 什么是 正向偏置
上面图片中间的电池符号非常关键。注意看,电池的 正极(长瘦竖线)连接到了 P型 区域,负极(短粗竖线)连接到了 N型 区域。 这就称为正向偏置。电池的电压像一个水泵,把 N 区的电子推向中间,同时也把 P 区的空穴推向中间。如果没有这个电压(或者电压不够大,即未达到导通电压),电子和空穴就 懒得动 ,灯就不会亮。
TIP
硬件工程师在选型的时候要重点注意LED灯的导通电压(正向偏置/正向电压,Vf):常见的几种颜色的LED灯的导通电压如下所示:
常见颜色的典型导通电压范围大致如下(实际取决于材料、封装和工作电流,大家可以去立创商城搜索LED灯来确定真实LED灯的正向压降):
- 红色(Red):约 1.6 ~ 2.2 V
- 橙/黄(Orange/Yellow):约 1.8 ~ 2.3 V
- 绿色(Green):约 2.0 ~ 3.2 V
- 蓝色(Blue):约 2.6 ~ 3.6 V
- 白色(White):约 2.8 ~ 3.6 V
- 红外(IR):约 1.0 ~ 1.4 V
比如有些初学者在选型的时候不注意这个电压:当 MCU/IO 口工作电压为 1.8 V 时,如果你还选择蓝灯或白灯(Vf ≈ 3V),直接用 IO 口驱动是无法点亮的【不管你的限流电阻有多小,就算没有限流电阻他也不会亮的】 —— IO 电压低于 LED 的 Vf,电流不会流过(或者极小),结果就是 LED 不亮或亮度非常弱,无法正常工作。
6.1.3 微观层面的发光机制(图片下半部分 - 能带图)
这是最硬核的部分,也是发光的本质:
- 能级差 (Eg):请看图右侧的标尺 Eg。在半导体物理中,N区的电子处于较高的能量状态(导带),而P区的空穴处于较低的能量状态(价带)。你可以把这想象成高台(N区)和地面(P区)。
- 复合 (Recombination):当电流推动电子跨过交界处进入 P 区时,高能量的电子遇到了空位(空穴)。电子会自然地 掉进 空穴里。这个过程叫电子与空穴的复合。
- 光子释放 (Light):根据能量守恒定律,电子从 高台 掉到 地面 ,多余的能量必须释放出来。在 LED 材料中,这部分能量主要以 光子 的形式释放。
总结一下:
电子跳楼(复合),释放能量,能量变成了光。 掉落的高度差(能带宽度 Eg)决定了光的颜色。跳得越高(Eg越大),发出的光能量越强(如蓝光、紫光);跳得约低(Eg越小),发出的光能量越弱(如红光、红外光)。
6.2 常见的灯的种类
为了体现 LED 的优势,我们将其与传统的发光方式做一个对比,这里对比的比较简单,想详细了解,请自行问AI:
- 白炽灯:先将电能转为热能,再发光(热致发光),效率低、耗能多。
- 原理:电流通过钨丝产生极高的温度(2000℃以上),把灯丝烧得 白热化 从而发光。
- 缺点:这是一个极其 败家 的过程,90% 以上的电能变成了热能浪费掉了,只有极少部分变成了光。寿命短。
- 荧光灯:低压汞蒸气放电激发磷光体发光,需要镇流器/电子镇流器(启动电路)。效率中等(比白炽灯好),含汞需回收处理。以前上学时教室里用的那种长长的灯管就是直管式日光灯(也可叫荧光灯)。
- OLED(有机发光二极管):有机材料电致发光,呈面光源,薄而柔性。
- LED灯:直接将电能转为光能,能量损失少,发热小,效率高,更节能。
- 原理:冷光源(发热小),几乎直接将电能转换为光能。
- 优点:
- 效率高:同等亮度下,能耗仅为白炽灯的 1/10。
- 寿命长:正常使用可达 5~10 万小时(白炽灯通常只有 1000 小时)。
- 响应快:纳秒级的响应速度,这使得它非常适合做信号指示灯或高速通信(如光纤通信中的发射源)。
6.3 驱动LED灯的原理
这里的 驱动 ,指的就是如何让 LED灯 安全、稳定地亮起来。
6.3.1 为什么不能直接怼电源?
LED 是一个非线性元件。它的电压-电流(V-I)特性曲线非常陡峭。
- 电压微变,电流剧变:一旦超过导通电压,电压稍微升高一点点,电流就会成倍暴增,瞬间烧毁 LED。
- 负温度系数:LED 温度升高,导通电压会下降,导致电流进一步增大,温度更高……陷入恶性循环(热失控)。
因此,驱动 LED 的核心法则:必须限制电流!
6.3.2 两种常见的驱动方式
方式一:限流电阻驱动(最常用、成本最低)
这是我们在开发板上最常见的方式。在 LED 电路中串联一个电阻。
- 计算公式:R=(Vcc−Vled)/I
- Vcc:电源电压(如 3.3V 或 5V)。
- Vled:LED 导通压降(红灯约 1.8V-2.0V)。
- I:你希望流过的电流(通常指示灯给 1mA~20mA 即可),因为LED灯只需要很小的电流就会很亮,所以为了避免刺眼,一般都是直接串1K甚至2K的电阻,此时通过LED灯的电流可能连1mA都不到。
方式二:恒流源驱动(一般在大功率照明中使用)
使用专门的电源芯片,无论输入电压怎么变,它都能保证输出电流恒定。这多用于路灯、家用照明,开发板上为了节省成本极少使用。比如我们庐山派的屏幕接口哪里就用到了恒流源驱动,只不过驱动的不是LED,而是屏幕的背光。
6.3.3 单片机控制 LED 的两种接法
TIP
看本小节之前,先回顾上一章的 3.2.4 输出控制(P-MOS管和N-MOS管) ,理解一下GPIO中推挽模式里上下MOS管的配合,这里会更好理解。
在电路原理图中,我们一般会看到两种接法:
- 灌电流(Sink Current)—— 低电平点亮
- 接法:LED 正极接电源(VCC),负极串联电阻后接单片机引脚(GPIO)。
- 原理:当 GPIO 输出低电平(0)时,电流从 VCC -> LED -> GPIO -> GND,形成回路,灯亮。
- 优点:单片机引脚(GPIO)的 吸入 电流能力通常比 输出 电流能力强。

- 拉电流(Source Current)—— 高电平点亮
- 接法:LED 正极串联电阻接 GPIO,负极接 GND。
- 原理:当 GPIO 输出高电平(1)时,电流从 GPIO -> 电阻 -> LED -> GND,形成回路,灯亮。
- 现状:现代单片机(如 STM32、GD32 等)推挽输出能力很强,这种接法也完全没问题,且逻辑上更符合直觉(1就是亮,0就是灭)。

TIP
在看原理图时,一定要先确认是 低电平亮 还是 高电平亮 ,否则你的代码逻辑可能是反的!对于天空星核心板上的PB2引脚上接的灯,它是拉电流(Source Current)的方式点亮的,也就是高电平点亮,低电平熄灭;而筑基学习板上的大部分LED灯,基本都是采用灌电流(Sink Current)的方式来驱动LED灯的,也就是说基本都是低电平点亮,高电平熄灭LED灯。
6.4 常见的LED灯
LED 的“长相”主要由封装决定。我们在 天空星开发板 和 筑基学习板 上看到的主要是贴片 LED。
6.4.1 直插式 LED (DIP)

- 外观:有一个圆圆的灯头,两根长长的引脚(长正短负)。
- 应用:老式电器、手工 DIY、教学实验箱,或者需要导光处理,对发光位置的高度有明确要求的。
- 特点:体积大,需要手工焊接或波峰焊(生产成本比较高),占用 PCB 的位置是比较大的。
6.4.2 贴片式 LED (SMD)

这是目前主流电子产品的首选。
- 外观:扁平的小方块,没有长引脚,直接焊在电路板表面。
- 型号命名:通常用尺寸命名,如 0805(英制,约 2.0mm x 1.2mm)、0603、1206 等。
- 优点(为什么要用贴片?):
- 体积小:高度集成,适合对尺寸要求寸土寸金的PCB。
- 适合自动化生产:可以使用贴片机高速自动贴装,生产效率极高。
- 抗震性好:紧贴电路板,不像直插灯那样容易被震松。
- 极性辨别:贴片 LED 通常在负极一侧会有绿色的点或者横杠标记,焊接时需注意方向。
LED灯大家可以去立创商城进行选型,各种种类的LED灯都可以通过筛选条件来进行选择:

6.5 天空星核心板和筑基学习板上我们能点亮的灯
6.5.1 天空星核心板上的LED灯:
原理图:

可以看到天空星核心板上我们可以控制的灯的引脚为PB2。
实物图中LED的位置:

就在TYPE-C旁边,还做了绿色LED灯的彩印标识。
6.5.2 天空星筑基学习板上的LED灯:
原理图:

可以看到筑基学习板上我们可以控制的灯的引脚为PB8。
实物图位置:

TIP
并不是说天空星上只有这一个灯我们能控制,还有八个LED白灯也能控制,只不过这八个LED是用I2C通行协议来去控制一个IO扩展板芯片来间接控制这些LED灯的,对初学者来说比较麻烦,也难以理解,直接用GPIO是控制不了的,我们后面再介绍。
七 在线调试状态下直接点亮LED灯
核心目标:不写一行代码,通过 在线调试 直接控制单片机内部寄存器,点亮板子上的LED灯。
对于初学者,本章能帮你建立 寄存器控制硬件 的直观感觉,打破对单片机的神秘感; 对于工程师,本章演示的 寄存器直接操作 是排查底层硬件Bug、验证外设配置的终极手段。
7.1 Keil(MDK)
7.1.1 确认调试环境是否正常
在本节开始之前,你需要确保你已经看完了本章的第三节【用 KEIL(MDK) 来新建 寄存器 工程】,并且已经正常创建了可以正常编译的环境和工程。
继续3.3.5小结,确保可以正常编译后我们单击Keil(MDK)软件中的Download按钮(快捷键是F8),如果之前的操作都没有问题,此时你编译的固件就会下载到天空星核心板里面。
注意:如果此时板子没反应是正常的,因为我们还没写点灯的代码。

软件左下角会有下载进度,我们这个固件很小,所以很快就下完了,下载过程会经过擦除,编程,校验这几个过程,出现了Flash Load finished 字样就说明测试的固件已经成功下载到单片机里面了。如果你前面在调试器(Debug)那里设置正确的话,程序会自动运行,但是我们连LED灯现在都没点亮,在外面单纯看天空星是看不出来的,接下来我们进入硬件调试看看单片机内部的运行状态(直接看看它的内部在做什么)。
接下来我们单击Start/Stop Debug Session进入硬件在线调试,如我们之前所说,有了调试器,我们就可以连接到正在运行的芯片,控制其执行、查看内存和寄存器状态,也就是说我们可以像在电脑上调试C代码一样,直接看到单片机在执行时,各个变量的值,同时也可以直接改变芯片内部寄存器的值。前面我们章节也说过,点灯的过程就是操作寄存器的过程,既然写程序能控制这些寄存器,现在有了调试器(下载器),我们就能通过鼠标点点来点亮板子上的LED灯了。

进入调试后的界面如下图所示:

调试(Debug)是什么?说白了,就是让快速运行的程序 慢下来 ,有时候还要 停下来,这样就能让你像个侦探一样,一步一步地审查代码,看看它到底在干嘛,找出藏在里面的 虫子(Bug)。
我们这个教程为什么要这么早的就介绍硬件调试(本教程使用DAPLINK,你也可以使用ST-Link或者Jlink等其他调试器):
- 一是因为嵌入式开发的核心就是 软硬结合 ,调试器是连接两者的桥梁,我们可以在Keil中直接就能看到寄存器的变化,看到代码里所执行变量的当前值;
- 二是能给大家建立对单片机的信任感,极大降低学习初期的挫败感,对新手而言,最痛苦的莫过于下载完程序后,板子毫无反应,你完全不知道是不是代码写错了。连上硬件调试器进入调试就能能立刻给你答案:只要能连上目标芯片并单步执行,就证明你的核心电路是通的,MCU是活的;
- 三是时代变了,现在基本的调试器最便宜的甚至不到十块钱,拒绝专业工具纯属 自讨苦吃 ,以前那种用串口ISP或者USB DFU来下载学习的方式现在直接抛弃吧,如果你还是只能下载程序,不能调试程序,基本就预示着你还是个门外汉。
上方截图中,我们先重点介绍一下前7个控制按钮,其他的功能我们在后续专题章节中再介绍吧:
- ①:复位 (Reset) ,这个是软件复位,点击后它会重新设置CPU的程序计数器(PC指针)到复位向量指向的地址,并执行启动代码,重新初始化全局/静态变量,基本等同于你按下了板子上的复位按钮。
- ②:全速运行 (Run),点击后,程序会以最快的速度(MCU内部所设置的速度)从当前位置开始执行,直到遇到你设置的断点(Breakpoint),或者你手动按下“停止”按钮,或者程序结束/卡死。
- ③:停止运行 (Stop) ,这个按钮在程序停止状态下是无效的,只有单片机内部程序在正常运行时你才可以单击让程序停止下来,这个的原理是调试器像MCU核心发送了一个Halt(暂停)指令。
- ④:单步执行-进入函数 (Step) ,字面意思,可以让程序进去执行函数内部的语句,如果你这个函数以及是最底层的没有再调用函数的话,等同于下面的⑤单步执行-跳过函数 (Step Over) 。
- ⑤:单步执行-跳过函数 (Step Over) ,单击这个按钮,可以单步执行通过这个函数,不会进入函数内部。
- ⑥:单步执行-跳出函数 (Step Out) ,如果你在调试时进入了函数内部进行单步调试,但是你已经不关心后面函数的执行情况了,就可以点这个让单片机把剩下部分直接全速执行完,并跳出这个函数。
- ⑦:运行到光标处 (Run to Cursor Line),用鼠标选择好光标位置后(直接把鼠标选定到对应函数,蓝色光标就会移动到对应位置了),此时单击这个,程序就会全速运行,直到运行到这段代码。
建议各位多点点,多用用,放心这个不会造成调试器或者开发板的损坏。
默认设置下,单片机在进入调试后会默认执行到main函数然后停下来,等待我们下一步的调试指令。所以现在我们先单击②:全速运行 (Run),让程序运行起来,在main函数中,我们实现了让lckfb_count这个变量持续自增1的功能,我们用鼠标光标选中这个变量,单击鼠标右键,把他添加到 Watch 1观察窗中,就能看到这个变量在持续增加了。

此时你就能看到lckfb_count这个变量的当前值在持续增加了:

默认是以16进制展示的,如果想切换成十进制展示,可以右键对应的Value,然后把菜单第一个的Hexadecimal Display给取消勾选,此时显示出来的值就是十进制的了。

【TIP1】如果你发现变量一直没有刷新怎么办?
检查并确认,菜单->View 里面的最后一个Periodic Window Update是否已经被勾选了,这个选项的作用就是让各窗口的数据定时按周期更新。

【TIP2】变量加入了Watch 1,但是没有找到Watch 1这个窗口怎么办?
检查并确认,菜单->View 里面的 Watch Windows -> Watch 1 是否已经被勾选了。

7.1.2 开始在调试状态下不写代码点亮LED灯
经过以上操作,能在Watch 1正常看到变量变化后,现在可以确认调试环境没有问题了,只有确认硬件调试没有问题后,才能继续后面的操作,否则请重新看前面的内容检查问题。
从本章第六小节【LED灯的基础知识】的后面部分介绍中,我们知道了天空星开发板上面有一个我们可以通过程序控制的绿灯,它连接在单片机的PB2引脚上面,只要这个引脚变成高电平对外输出电流,板子上的绿灯就会亮了。
【STEP1:使能GPIOB口的时钟】
我们在1.2 重点提示小节中和大家强调过,使用外设功能前务必要先开启对应外设的时钟使能,否则对应外设是无法正常工作的。
接下来我们打开STM32F4的参考手册和数据手册,看一下控制外设时钟的寄存器究竟是哪一个。
这里我只是简单说明,更详细的介绍请看后续章节的介绍,这里只简单提及让大家有个概念即可。
在数据手册中搜索,Figure 5. STM32F40xxx block diagram 我们来看一下STM32F4的硬件框图,找一下GPIOB属于哪一个外设时钟:

在图中可以明确看出来,GPIO PORT B 挂载在 AHB1 总线上,那么再看参考手册,搜一下RCC AHB1 外设时钟使能寄存器 (RCC_AHB1ENR):

这个32位寄存器的复位值是0x0010_0000,展开成二进制就是0B000100000000000000000000,对应到上面的定义,只有CCMDATARAMEN(内部CCM内存,速度更快,但DMA无法访问,仅CPU可访问) 的使能在芯片复位后就是开启的。而所有GPIOA,GPIOB,GPIOx等默认都是关闭的,如果我们想让PB2这个引脚能正常工作,那首先要做的就是先使能GPIOB的时钟,也就是要把上面这个寄存器的位1写入数据1。
那么我们在调试状态下如何使能这个位呢?继续之前的调试操作,选择菜单Peripherals -> System Viewer -> RCC。

右边就会弹出来RCC等好多个寄存器了,我们根据前面提到的 (RCC_AHB1ENR),选择AHB1ENR寄存器,点前面的小 + 号进行展开,给GPIOBEN的后面的小框框打个对勾,就是给GPIOB的时钟进行使能了。

同时我们看到AHB1ENR后面跟着的Value从默认值0x0010_0000变成了0x0010_0002,展开成二进制就是0B000100000000000000000010,再翻到上面看RCC AHB1 外设时钟使能寄存器 (RCC_AHB1ENR),此时GPIOB时钟所对应的位1已经被设置位1了,和预期相符。
【STEP2:初始化PB2为输出模式】
接下来我们重新回顾上一章节[8]认识GPIO,我们在这个章节详细介绍了驱动GPIO所涉及到的寄存器,和使用GPIO的一般步骤:
- 使能 GPIO 端口时钟: 在
RCC_AHB1ENR寄存器中,使能目标 GPIO 端口(如 GPIOA)的时钟。- 配置引脚模式: 设置
GPIOx_MODER寄存器,选择输入、输出、复用还是模拟模式。- **配置引脚参数 **:
- 输出/复用模式: 配置
GPIOx_OTYPER(推挽/开漏) 和GPIOx_OSPEEDR(速度)。- 输入/输出模式: 配置
GPIOx_PUPDR(上拉/下拉/浮空)。- 复用模式: 配置
GPIOx_AFRL/AFRH选择具体的外设功能。- 操作引脚:
- 输出: 通过写
GPIOx_ODR或GPIOx_BSRR来改变引脚电平。- 输入: 通过读
GPIOx_IDR来获取引脚电平。- 复用/模拟: 配置完成交由相应外设(如 USART, ADC)自动控制和使用,CPU 通常不再直接干预引脚电平。
我们前面已经完成了步骤1,成功使能了GPIOB的时钟,接下来看如何进行让PB2这个引脚进行初始化,配置为输出模式(毕竟我们要驱动LED灯就是要对外进行输出)。那就是要去操作GPIOB 的 端口模式寄存器 (GPIOB_MODER),也就是说要把GPIOB的MODER2设置成01(通用输出模式)。
那我们继续之前的调试操作,择菜单 Peripherals -> System Viewer -> GPIO -> GPIOB。

单击后就能在右边的窗口中看到GPIOB相关的寄存器了,我们根据上面的内容(要先配置GPIO的模式-端口模式寄存器 (GPIOB_MODER)),先点MODER进行展开,能看到当前默认值是0x00000280,不知道为什么的话返回上一章4.2.1部分的解惑:【解惑】为何 GPIOA/GPIOB 的 MODER 复位值不是全 0?

我们直接把GPIOB->MODER里面的MODER2修改为0x01,此时MODER的值也变成了0x00000290 ,同时也意味着PB2成功被初始化为了输出模式。

【STEP3:设置PB2输出高电平,点亮天空星核心板上的LED灯!】
万事俱备,只欠东风!现在我们可以真正地控制 PB2 输出高电平了。根据上一章的学习,我们知道能控制GPIO输出电平的有两个寄存器,一个是ODR,另一个是BSRR。这里我们先用更直观的ODR寄存器来测试吧,GPIOB_ODR是一个 32 位寄存器,但只用到了低16位(高 16 位保留但是无效),每个 bit 控制一个引脚的输出状态。

意味着我们把GPIOB_ODR的ODR2设置为1,那么理论上此时天空星核心板上的PB2所控制的LED灯此时就会亮了。继续开始调试操作,点ODR前面的小+号进行展开,给ODR2打个对勾:

当你勾选的瞬间,你会看到 ODR 寄存器的值从 0x00000000 变成了 0x00000004 ( 2² = 4)。
见证奇迹的时刻到了!
低头看一下你的天空星核心板,在TYPE-C口旁边的绿色LED灯,此刻应该已经点亮了!
恭喜你!不写代码也点亮了一个灯,你现在可以反复点击上面的ODR2后面的对勾,可以看到板子上的绿灯在随着你的点击进行闪烁,再次恭喜你,不写代码也实现了LED灯闪烁!

如果一切正常的话,箭头指的这个绿灯会开始闪烁
7.2 VS Code + EIDE
WARNING
初学者先不要看这里,这是另一种环境的搭建,避免对你产生干扰。
Keil 虽然经典,但其编辑器体验确实充满了 年代感 。对于习惯了现代 IDE 开发的工程师来说,VS Code 配合 EIDE 插件无疑是目前 STM32 开发中比较优雅的方案之一。VS Code 的强大在于插件生态,而 EIDE 很好地解决了工具链管理的问题。EIDE添加文件和头文件的方式也类似于Keil,不需要你来编写复杂的CMakeLists文件了,也更适合初学者来使用。
VS Code 配合 EIDE 插件提供了更现代的代码编辑体验,但在调试方面,它依赖 Cortex-Debug 插件,其逻辑与 Keil 略有不同,特别是对寄存器的查看方式。
7.2.1 确认调试环境是否正常
找到我们前面在双击EIDE-Project.code-workspace来唤醒 VSCODE。
进入VS CODE 后选择左边栏进入EIDE扩展,点击编译进行测试,终端能正常输出[ DONE ] build successfully !,就说明当前环境可以正常编译。

将编译好的固件下载到天空星里面,有两种方式:
方式一:
下方状态栏右边的Flash按钮。

方式二:
EIDE插件中项目后面也会有下载按钮(要注意鼠标移动到上面后,才会显示出来)

点击下载按钮后,终端里面就会输出调试器相关的状态,当你看到 Verify Started 和 Verified OK 字样后,就说明当前工程的程序已经正确下载至天空星上面了。
单击EIDE插件中当前工程的绿色小三角,来进入调试状态

为了方便观察,下面以动图的方式来帮助大家理解进入调试的过程。单击后需要等待一段时间,等 VS CODE 下面的状态栏从蓝色变为橙色,同时自动跳转到调试界面后,我们在 CORTEX LIVE WATCH 栏目里添加我们在前面程序中的全局变量 lckfb_count ,单击调试控制台里面的 继续 按钮就能让当前程序继续运行了,同时你也会观察到这个变量的值在随着时间自行自增了。

进入调试后的界面如下图所示:

调试(Debug)是什么?说白了,就是让快速运行的程序 慢下来 ,有时候还要 停下来,这样就能让你像个侦探一样,一步一步地审查代码,看看它到底在干嘛,找出藏在里面的 虫子(Bug)。
和KEIL类似,在具体文件行号前面单击就可以添加断点,当程序全速运行时,遇到这个断点就会停下来。

我们这个教程为什么要这么早的就介绍硬件调试(本教程使用DAPLINK,你也可以使用ST-Link或者Jlink等其他调试器):
- 一是因为嵌入式开发的核心就是 软硬结合 ,调试器是连接两者的桥梁,我们可以在Keil中直接就能看到寄存器的变化,看到代码里所执行变量的当前值;
- 二是能给大家建立对单片机的信任感,极大降低学习初期的挫败感,对新手而言,最痛苦的莫过于下载完程序后,板子毫无反应,你完全不知道是不是代码写错了。连上硬件调试器进入调试就能能立刻给你答案:只要能连上目标芯片并单步执行,就证明你的核心电路是通的,MCU是活的;
- 三是时代变了,现在基本的调试器最便宜的甚至不到十块钱,拒绝专业工具纯属 自讨苦吃 ,以前那种用串口ISP或者USB DFU来下载学习的方式现在直接抛弃吧,如果你还是只能下载程序,不能调试程序,基本就预示着你还是个门外汉。
上方截图中,我们先重点介绍一下中间最上面的那七个控制按钮,其他的功能我们在后续专题章节中再介绍吧:
- ①:复位 (Reset device) ,这个是软件复位,点击后它会重新设置CPU的程序计数器(PC指针)到复位向量指向的地址,并执行启动代码,重新初始化全局/静态变量,基本等同于你按下了板子上的复位按钮。
- ②:继续运行/暂停 (Run/Stop),点击后,程序会以最快的速度(MCU内部所设置的速度)从当前位置开始执行,直到遇到你设置的断点(Breakpoint),或者你手动按下 停止 按钮,或者程序结束/卡死。这个按钮在单击后就会从继续运行变为暂停,变成暂停的按钮后,我们就可以让单片机停下来了,这个的原理是调试器像MCU核心发送了一个Halt(暂停)指令。
- ③:单步执行-跳过函数 (Step Over) ,单击这个按钮,可以单步执行通过这个函数,不会进入函数内部。
- ④:单步执行-进入函数 (Step) ,字面意思,可以让程序进去执行函数内部的语句,如果你这个函数以及是最底层的没有再调用函数的话,等同于下面的⑤单步执行-跳过函数 (Step Over) 。
- ⑤:单步执行-跳出函数 (Step Out) ,如果你在调试时进入了函数内部进行单步调试,但是你已经不关心后面函数的执行情况了,就可以点这个让单片机把剩下部分直接全速执行完,并跳出这个函数。
- ⑥:重启,单击这个按钮,调试系统会进行一次重启。
- ⑦:停止调试,单击这个按钮,会退出调试。
建议各位多点点,多用用,放心这个不会造成调试器或者开发板的损坏。
默认设置下,单片机在进入调试后会默认执行到main函数然后停下来,等待我们下一步的调试指令。所以现在我们先单击②:全速运行 (Run),让程序运行起来,在main函数中,我们实现了让lckfb_count这个变量持续自增1的功能,点击右侧 CORTEX LIVE WATCH后面的+号,在上方命令框中输入这个变量的名称,程序正常运行后,我们就可以可以看到变量的实时值了。
7.2.2 开始在调试状态下不写代码点亮LED灯
VS Code + EIDE 搭建出来的环境不像 KEIL 那么智能,KEIL(MDK)在你安装好芯片的pack包之后,如果还是和之前直接进入调试,是没有各个外设的寄存器显示出来的,我们也就无法通过鼠标点点来实现LED灯的点亮。
在 VS Code 中查看外设寄存器,必须告诉它芯片的 地图 ,这个地图就是 SVD 文件 (System View Description),这个文件会包含微控制器的寄存器映射,部分外设的描述信息等,有了这个,IDE就可以拆解各个寄存器展示给用户。我已经提前上传到筑基学习板的仓库里面了,可以来这个地址来获取:

把这个文件下载到本地,放到当前工程项目的根目录:

此时再进入调试界面,我们就能看到左边的调试栏正常出现了 XPERIPHERALS 这个栏目,点击各个外设前面的小三角,就能展开,详细外设的寄存器具体值了。

经过以上操作,进入调试界面后,能在Watch 1正常看到变量变化且右边的XPERIPHERALS里面的外设资源也成功加载出来后,现在可以确认调试环境没有问题了,只有确认硬件调试没有问题后,才能继续后面的操作,否则请重新看前面的内容检查问题。
从本章第六小节【LED灯的基础知识】的后面部分介绍中,我们知道了天空星开发板上面有一个我们可以通过程序控制的绿灯,它连接在单片机的PB2引脚上面,只要这个引脚变成高电平对外输出电流,板子上的绿灯就会亮了。
【STEP1:使能GPIOB口的时钟】
我们在1.2 重点提示小节中和大家强调过,使用外设功能前务必要先开启对应外设的时钟使能,否则对应外设是无法正常工作的。
接下来我们打开STM32F4的参考手册和数据手册,看一下控制外设时钟的寄存器究竟是哪一个。
这里我只是简单说明,更详细的介绍请看后续章节的介绍,这里只简单提及让大家有个概念即可。
在数据手册中搜索,Figure 5. STM32F40xxx block diagram 我们来看一下STM32F4的硬件框图,找一下GPIOB属于哪一个外设时钟:

在图中可以明确看出来,GPIO PORT B 挂载在 AHB1 总线上,那么再看参考手册,搜一下RCC AHB1 外设时钟使能寄存器 (RCC_AHB1ENR):

这个32位寄存器的复位值是0x0010_0000,展开成二进制就是0B000100000000000000000000,对应到上面的定义,只有CCMDATARAMEN(内部CCM内存,速度更快,但DMA无法访问,仅CPU可访问) 的使能在芯片复位后就是开启的。而所有GPIOA,GPIOB,GPIOx等默认都是关闭的,如果我们想让PB2这个引脚能正常工作,那首先要做的就是先使能GPIOB的时钟,也就是要把上面这个寄存器的位1写入数据1。
那么我们在调试状态下如何使能这个位呢?继续之前的调试操作,看向右边栏目中的XPERIPHERALS,鼠标向下滚动,找到RCC@40023800,点击前面的小箭头,再从下面的类目中找到AHB1ENR@0x30,我们就能看到各个GPIO端口使能的位了。

我们根据前面提到的 (RCC_AHB1ENR),选择AHB1ENR寄存器,再把它里面的GPIOBEN[1:1]0b0的值修改为1,就是把GPIOB端口的时钟给打开(使能)了。
TIP
GPIOBEN[1:1]0b0 如何理解呢?
首先,GPIOBEN说的就是这个位的名字,他和数据手册里面是对应的;[1:1]是寄存器的域通常写作 [msb:lsb],表示从 msb 到 lsb 的连续位(含端点),[1:1] 表示只有一个位 —— 第 1 位(bit1),位编号是从0开始的;0b 前缀表示二进制数(binary)。所以 0b0 就是二进制的 0,想要使能这个位,我们只需要把0b后面的值改成1就可以了。

同时我们看到AHB1ENR后面跟着的Value从默认值0x0010_0000变成了0x0010_0002,展开成二进制就是0B000100000000000000000010,再翻到上面看RCC AHB1 外设时钟使能寄存器 (RCC_AHB1ENR),此时GPIOB时钟所对应的位1已经被设置位1了,和预期相符。
【STEP2:初始化PB2为输出模式】
接下来我们重新回顾上一章节[8]认识GPIO,我们在这个章节详细介绍了驱动GPIO所涉及到的寄存器,和使用GPIO的一般步骤:
- 使能 GPIO 端口时钟: 在
RCC_AHB1ENR寄存器中,使能目标 GPIO 端口(如 GPIOA)的时钟。- 配置引脚模式: 设置
GPIOx_MODER寄存器,选择输入、输出、复用还是模拟模式。- **配置引脚参数 **:
- 输出/复用模式: 配置
GPIOx_OTYPER(推挽/开漏) 和GPIOx_OSPEEDR(速度)。- 输入/输出模式: 配置
GPIOx_PUPDR(上拉/下拉/浮空)。- 复用模式: 配置
GPIOx_AFRL/AFRH选择具体的外设功能。- 操作引脚:
- 输出: 通过写
GPIOx_ODR或GPIOx_BSRR来改变引脚电平。- 输入: 通过读
GPIOx_IDR来获取引脚电平。- 复用/模拟: 配置完成交由相应外设(如 USART, ADC)自动控制和使用,CPU 通常不再直接干预引脚电平。
我们前面已经完成了步骤1,成功使能了GPIOB的时钟,接下来看如何进行让PB2这个引脚进行初始化,配置为输出模式(毕竟我们要驱动LED灯就是要对外进行输出)。那就是要去操作GPIOB 的 端口模式寄存器 (GPIOB_MODER),也就是说要把GPIOB的MODER2设置成01(通用输出模式)。
那我们继续之前的调试操作,我们要操作的引脚是PB2,也就是编号为2的,选择左边的外设GPIOB -> MODER -> MODER2。

我们根据上面的内容(要先配置GPIO的模式-端口模式寄存器 (GPIOB_MODER))能看到当前MODER默认值是0x00000280,不知道为什么的话返回上一章4.2.1部分的解惑:【解惑】为何 GPIOA/GPIOB 的 MODER 复位值不是全 0?
我们直接把GPIOB->MODER里面的MODER2修改为0x01,输入数字后按下回车就能正常修改了,此时MODER的值也变成了0x00000290 ,同时也意味着PB2成功被初始化为了输出模式。

【STEP3:设置PB2输出高电平,点亮天空星核心板上的LED灯!】
万事俱备,只欠东风!现在我们可以真正地控制 PB2 输出高电平了。根据上一章的学习,我们知道能控制GPIO输出电平的有两个寄存器,一个是ODR,另一个是BSRR。在KEIL(MDK)里面我们是用ODR寄存器来点亮LED灯的,这里我们就用BSRR寄存器来点亮吧。
根据上一章【第八章-认识GPIO】里面的4.3.2 GPIO 端口置位/复位寄存器 (GPIOx_BSRR) (x = A..I)小节,我们知道,这个BSRR寄存器是无法读取的,只能写入。如果你忘了的话,返回去再看看吧,这里为了方便大家学习,再展示一下具体的寄存器定义吧:

这个寄存器的特性如下:
- 低16位 (BS[15:0]) - 置位:向
BSy位(y=0-15)写入1,会原子地将对应的ODRy位置1,使PINy输出高电平。写入0则无任何效果。 - 高16位 (BR[15:0]) - 复位:向
BRy位(y=0-15)写入1,会原子地将对应的ODRy位清0,使PINy输出低电平。写入0则无任何效果。
意味着我们把GPIOB_BSRR的BS2设置为1,那么理论上此时天空星核心板上的PB2所控制的LED灯此时就会亮了。
想要熄灭这个灯,向BS2写0是无效的,需要向BR1写1,天空星核心板上的PB2所控制的LED灯才会熄灭。
下面以动图来展示一下我操作的过程,各位根据动图来学着操作一下。

见证奇迹的时刻到了!
在我们向GPIOB_BSRR的BS2写入1后,低头看一下你的天空星核心板,在TYPE-C口旁边的绿色LED灯,此刻应该已经点亮了!再向GPIOB_BSRR的BR2写入1后,此时TYPE-C口旁边的绿色LED灯机会熄灭了。
恭喜你!不写代码进行了一次LED闪烁实验,你现在可以反复执行上面的操作,可以看到板子上的绿灯在随着你的操作进行闪烁,再次恭喜你,不写代码也实现了LED灯闪烁!

TIP
我们在第八章的 4.3.2 GPIO 端口置位/复位寄存器 (GPIOx_BSRR) (x = A..I) 就说过:
寄存器(BSRR)的操作对象不是直接反应到GPIO引脚,而是(ODR)寄存器,从前面的介绍框图也能看出来,BSRR实际上类似于ODR的上级,它通过控制ODR来实现对引脚状态的改变。
也就是说:BSRR 并不直接驱动引脚电平,BSRR 是通过修改 ODR 来实现引脚置位/复位的。
我们通过在 EIDE+VS CODE 里面修改BSRR寄存器来看看是不是如我们之前所说,BSRR实际控制的是ODR寄存器:

从这个调试过程中,向BSRR寄存器写入的值都会反应到ODR寄存器上,我们就能知道,BSRR寄存器的修改确实实际就是体现在ODR寄存器上面的。希望大家在后续学习过程中一定要多用调试器,有了调试器就像有了强大的观察与控制能力——你可以在寄存器级别实时验证假设、定位问题,而不是仅靠猜测。
7.3 CLion
WARNING
初学者先不要看这里,这是另一种环境的搭建,避免对你产生干扰。
7.3.1 确认调试环境是否正常
这里为了和前面创建工程解耦,我们直接重新打开我们的CLion工程,直接找到工程目录,右键点击文件夹选择用CLion来打开,然后先编译一下整个工程,确保可以正常编译:

接下来点击右上角的绿色小三角,把程序先下载到天空星里面(出现Programming Finished就说明下载成功了):

确保前面的操作都正常,然后点击右上角的调试(一个像小虫子一样的图标),进入调试状态:

为了方便观察,下面以动图的方式来帮助大家理解进入调试的过程。单击后需要等待一段时间,等下面提示框中出现调试器已连接后,就算成功进入调试状态了,此时天空星会全速运行,我们在lckfb_count++这里打一个断点(点击行号,会出现一个红色原点),我们右键lckfb_count这个变量,点击添加到监视,此时我们就能在下方的线程和变量栏目里面看到我们刚添加的这个变量了,又因为我们前面在lckfb_count++这行代码前面缇娜加了一个断点,此时程序会停在这里,可以看到变量没有变化,我们单击调试栏的恢复程序(一个竖线+小三角的绿色图标),就能观察到变量在增加了。
WARNING
目前CLion对使用DAPLink的调试器适配没有那么好,暂时不好设置live watch,也就是说目前我们没有让天空星在运行状态下还能看到变量自动刷新的效果。如果你用的是ST-LINK或者Jlink的话,可以参考这篇文章来设置:链接

我们这个教程为什么要这么早的就介绍硬件调试(本教程使用DAPLINK,你也可以使用ST-Link或者Jlink等其他调试器):
- 一是因为嵌入式开发的核心就是 软硬结合 ,调试器是连接两者的桥梁,我们可以在Keil中直接就能看到寄存器的变化,看到代码里所执行变量的当前值;
- 二是能给大家建立对单片机的信任感,极大降低学习初期的挫败感,对新手而言,最痛苦的莫过于下载完程序后,板子毫无反应,你完全不知道是不是代码写错了。连上硬件调试器进入调试就能能立刻给你答案:只要能连上目标芯片并单步执行,就证明你的核心电路是通的,MCU是活的;
- 三是时代变了,现在基本的调试器最便宜的甚至不到十块钱,拒绝专业工具纯属 自讨苦吃 ,以前那种用串口ISP或者USB DFU来下载学习的方式现在直接抛弃吧,如果你还是只能下载程序,不能调试程序,基本就预示着你还是个门外汉。
进入调试后的界面如下图所示:

上方截图中,我们先重点介绍一下左下角的那十个控制按钮,其他的功能我们在后续专题章节中再介绍吧:
- ①:重启,单击这个按钮,调试系统会进行一次重启。左下角的重启和右上角的都是一个作用.
- ②:复位 ,这个是软件复位,点击后它会重新设置CPU的程序计数器(PC指针)到复位向量指向的地址,并执行启动代码,重新初始化全局/静态变量,基本等同于你按下了板子上的复位按钮。
- ③:停止调试,单击这个按钮,会退出调试。
- ④:恢复程序,点击后,程序会以最快的速度(MCU内部所设置的速度)从当前位置开始执行,直到遇到你设置的断点(Breakpoint),或者你手动按下 停止 按钮,或者程序结束/卡死
- ⑤:暂停程序 ,单击该按钮后,我们就可以让单片机停下来了,这个的原理是调试器像MCU核心发送了一个Halt(暂停)指令。
- ⑥:步过,单击这个按钮,可以单步执行通过这个函数,不会进入函数内部。。
- ⑦:步入,字面意思,可以让程序进去执行函数内部的语句,如果你这个函数以及是最底层的没有再调用函数的话,等同于下面的⑧单出。
- ⑧:步出,如果你在调试时进入了函数内部进行单步调试,但是你已经不关心后面函数的执行情况了,就可以点这个让单片机把剩下部分直接全速执行完,并跳出这个函数。
- ⑨:查看断点,查看我们当前在该工程中设置的所有断点。
- ⑩:忽略断点,点击后,我们在前面的程序中设置的所有断点都会被临时屏蔽,程序经过断点时不会暂停,同时前面设置的断点会从红色变为白色。
调试(Debug)是什么?说白了,就是让快速运行的程序 慢下来 ,有时候还要 停下来,这样就能让你像个侦探一样,一步一步地审查代码,看看它到底在干嘛,各个变量具体的值,找出藏在里面的 虫子(Bug)。
建议各位多点点,多用用,放心这个不会造成调试器或者开发板的损坏。
7.2.2 开始在调试状态下不写代码点亮LED灯
TIP
开始看本节之前,要确保你已经正常安装好了openocd和arm-none-eabi-gcc 工具链,并且能正常在Windows控制台输入命令来控制。
经过以上操作,能在线程和变量正常看到变量变化后,现在可以确认调试环境没有问题了,只有确认硬件调试没有问题后,才能继续后面的操作,否则请重新看前面的内容检查问题。
从本章第六小节【LED灯的基础知识】的后面部分介绍中,我们知道了天空星开发板上面有一个我们可以通过程序控制的绿灯,它连接在单片机的PB2引脚上面,只要这个引脚变成高电平对外输出电流,板子上的绿灯就会亮了。
在第八章中,我们知道了能控制GPIO输出的寄存器有两个,一个是ODR(端口输出数据寄存器),一个是BSRR(位设置/重置寄存器),在 Keil(MDK) 中我们用鼠标来找到ODR寄存器点亮了LED灯, 在 VS Code + EIDE 我们也是通过寄存器视图来操作 BSRR。当然CLion也能在加载svd文件后做到这个,不过既然我们这里用的是CLion,那就选一种更贴切代码读写寄存器实际情况的方法,计算对应寄存器的地址,和需要写入的值,在内存视图中,用地址来修改对应寄存器的方式来点亮LED灯。本节我们会用一种更接近底层本质的方法——直接向物理外设地址写值。
本节依旧和Keil(MDK)一样用ODR寄存器来点灯,但是画风会完全不一样。
WARNING
切记,【这里不使用CLion的调试功能】进行下一步前,请先退出/停止 CLion 正在运行的 debug 会话(如果有),因为进入调试状态后,调试通道就被CLion给占用了,我们这里直接用控制台来调用OpenOCD来直接进行寄存器的读写。
首先通过控制台的openocd命令来连接我们的DAPLink仿真器,输入以下命令:
openocd -f interface/cmsis-dap.cfg -f target/stm32f4x.cfg -c "adapter_khz 1000"当出现 Info : Listening on port 3333 for gdb connections ,就代表我们正常启动了:

请确保你的上面已经运行了 OpenOCD 命令且没有报错,正处于 Listening on port 3333 的状态。不要关闭它!然后我们再重新开一个端口:
在新的终端输入:
arm-none-eabi-gdb进入 (gdb) 提示符后,输入连接命令:
target remote localhost:3333像下面这个动图展示的一样:

接下来的所有操作,我们都在第二个终端中操作:
TIP
【解惑】为什么不能在启动 OpenOCD 的终端里直接输入命令?
OpenOCD 的终端在启动后就变成了 服务器日志显示窗口 ,它只负责不停地打印 OpenOCD 内部发生了什么(比如连上了芯片、报错了、断开了),它不接受我们的键盘输入指令。
OpenOCD 的设计架构是经典的 C/S(Client/Server,客户端/服务器)架构。
- Server(服务端):就是我们最开始运行
openocd ...命令的那个终端。它的工作是掌控调试器(DAPLink),死死地盯着目标芯片(STM32)。它启动后就进入了 监听模式 ,像一个接线员一样等待只别人的电话,自己是不打电话的。 - Client(客户端):就是我们开启的第二个窗口。它是实际发送指令的。
OpenOCD 默认开启了两个主要的 端口:
- 4444 端口(Telnet):给人用的。也就是我们即将要用的方式,你在里面输入人类能看懂的指令(如
reset、mdw),它转发给 Server。 - 3333 端口(GDB):给软件用的。比如 CLion、Keil 或者 GDB 命令行工具,它们通过这个端口和 OpenOCD 交流。
在前面设置CLion调试配置时也能看到这两个端口号。
【STEP1:确定对应寄存器的基地址】
在 VS Code+EIDE 的SVD视图 里 或者 Keil(MDK) 里,我们点点鼠标就能看到寄存器的具体地址。但在命令行里,我们可以说是 ''盲人'' ,必须通过查阅数据手册(DataSheet)找到各个外设的基地址,从而计算出寄存器的绝对物理地址。
这就要求我们具备一名嵌入式工程师的基本功:地址映射计算,控制下面的三个寄存器之前,我们都会计算对应寄存器的地址。
回顾 7.1.2 节,我们需要操作三个寄存器:
- RCC_AHB1ENR (开启GPIOB时钟)
- GPIOB_MODER (配置PB2为输出)
- GPIOB_ODR (控制PB2输出高低电平)
而这三个寄存器分别属于两个大类,RCC_AHB1ENR属于RCC外设,GPIOB_MODER和GPIOB_ODR属于GPIOB外设,查阅 STM32F4 规格书的Table 10. Register boundary addresses :
规格书的下载链接:STM32F407xx-规格书-EN.pdf

从上图就可以知道,RCC 基地址(起始地址)= 0x4002 3800,GPIOB 基地址(起始地址) = 0x4002 0400
后面到对应寄存器时,我们可以看到那个寄存器的偏移地址,和这个基地址相加我们就能计算出寄存器的绝对地址了。
【STEP2:使能GPIOB口的时钟】
我们在1.2 重点提示小节中和大家强调过,使用外设功能前务必要先开启对应外设的时钟使能,否则对应外设是无法正常工作的。
接下来我们打开STM32F4的参考手册和数据手册,看一下控制外设时钟的寄存器究竟是哪一个。
这里我只是简单说明,更详细的介绍请看后续章节的介绍,这里只简单提及让大家有个概念即可。
在数据手册中搜索,Figure 5. STM32F40xxx block diagram 我们来看一下STM32F4的硬件框图,找一下GPIOB属于哪一个外设时钟:

在图中可以明确看出来,GPIO PORT B 挂载在 AHB1 总线上,那么再看参考手册,搜一下RCC AHB1 外设时钟使能寄存器 (RCC_AHB1ENR):

这个32位寄存器的复位值是0x0010_0000,展开成二进制就是0B000100000000000000000000,对应到上面的定义,只有CCMDATARAMEN(内部CCM内存,速度更快,但DMA无法访问,仅CPU可访问) 的使能在芯片复位后就是开启的。而所有GPIOA,GPIOB,GPIOx等默认都是关闭的,如果我们想让PB2这个引脚能正常工作,那首先要做的就是先使能GPIOB的时钟,也就是要把上面这个寄存器的位1写入数据1。
在【STEP1:确定对应寄存器的基地址】中,我们知道了RCC 基地址(起始地址)= 0x4002 3800,上面的图片里也表明了RCC_AHB1ENR的偏移地址为:0x30。也就是说,物理绝对地址=基地址+偏移地址=0x4002 3800 + 0x30 = 0x4002 3830。
TIP
OpenOCD 提供了两个核心指令:
monitor mdw [地址](Memory Display Word):读取该地址的一个32位数据。monitor mww [地址] [数据](Memory Write Word):向该地址写入一个32位数据。
上面的截图中,手册已经写了RCC_AHB1ENR寄存器的复位值为0x0010_0000,我们也计算了它的物理绝对地址是0x4002 3830,我们来读取一下试试,在第二个终端窗口中输入:
monitor mdw 0x40023830
可以看到读出来的值为0x40023830: 00100000,这就表明,寄存器RCC_AHB1ENR地址(也就是0x40023830地址处),其值为00100000,也就是默认的复位值为0x0010_0000,和预期相符。
我们要把 Bit 1 (GPIOBEN) 置 1。0x00100000 | 0x00000002 = 0x00100002。
写入数据:
monitor mww 0x40023830 0x00100002(此时,芯片内部 GPIOB 的大门已经打开,电流开始涌入 GPIOB 模块)
然后再给它读取一下(monitor mdw 0x40023830)确认是否写入成功:

出现0x40023830: 00100002就说明我们正常把GPIOB的时钟给打开了。
【STEP3:初始化PB2为输出模式】
接下来我们重新回顾上一章节[8]认识GPIO,我们在这个章节详细介绍了驱动GPIO所涉及到的寄存器,和使用GPIO的一般步骤:
- 使能 GPIO 端口时钟: 在
RCC_AHB1ENR寄存器中,使能目标 GPIO 端口(如 GPIOA)的时钟。- 配置引脚模式: 设置
GPIOx_MODER寄存器,选择输入、输出、复用还是模拟模式。- **配置引脚参数 **:
- 输出/复用模式: 配置
GPIOx_OTYPER(推挽/开漏) 和GPIOx_OSPEEDR(速度)。- 输入/输出模式: 配置
GPIOx_PUPDR(上拉/下拉/浮空)。- 复用模式: 配置
GPIOx_AFRL/AFRH选择具体的外设功能。- 操作引脚:
- 输出: 通过写
GPIOx_ODR或GPIOx_BSRR来改变引脚电平。- 输入: 通过读
GPIOx_IDR来获取引脚电平。- 复用/模拟: 配置完成交由相应外设(如 USART, ADC)自动控制和使用,CPU 通常不再直接干预引脚电平。
我们前面已经完成了步骤1,成功使能了GPIOB的时钟,接下来看如何进行让PB2这个引脚进行初始化,配置为输出模式(毕竟我们要驱动LED灯就是要对外进行输出)。那就是要去操作GPIOB 的 端口模式寄存器 (GPIOB_MODER),也就是说要把GPIOB的MODER2设置成01(通用输出模式)。
在【STEP1:确定对应寄存器的基地址】中,我们知道了GPIO基地址(起始地址)= 0x4002 0400,上面的图片里也表明了GPIOx_MODER的偏移地址为:0x00。也就是说,物理绝对地址=基地址+偏移地址=0x4002 0400 + 0x00 = 0x4002 0400。
我们要操作 0x40020400 (GPIOB_MODER)。 先读一下:
monitor mdw 0x40020400默认值通常是 0x00000280。 PB2 对应的是 MODER2(即第4、5位)。我们要把它设置为 01 (通用输出模式)。 0x00000280 的第4、5位本身就是0,所以直接加上 0x10 (二进制 10000,即第4位置1) 即可。 目标值:0x00000290。
写入数据:
monitor mww 0x40020400 0x00000290(此时,PB2 引脚已经整装待发,准备对外输出)

能看到当前默认值是0x00000280,不知道为什么的话返回上一章4.2.1部分的解惑:【解惑】为何 GPIOA/GPIOB 的 MODER 复位值不是全 0?
写入数据后,此时此时MODER的值也变成了0x00000290 ,同时也意味着PB2成功被初始化为了输出模式。
【STEP4:设置PB2输出高电平,点亮天空星核心板上的LED灯!】
万事俱备,只欠东风!现在我们可以真正地控制 PB2 输出高电平了。根据上一章的学习,我们知道能控制GPIO输出电平的有两个寄存器,一个是ODR,另一个是BSRR。这里我们先用更直观的ODR寄存器来测试吧【和KEIL里一样】,GPIOB_ODR是一个 32 位寄存器,但只用到了低16位(高 16 位保留但是无效),每个 bit 控制一个引脚的输出状态。

意味着我们把GPIOB_ODR的ODR2设置为1,那么理论上此时天空星核心板上的PB2所控制的LED灯此时就会亮了。
在【STEP1:确定对应寄存器的基地址】中,我们知道了GPIO基地址(起始地址)= 0x4002 0400,上面的图片里也表明了GPIOx_ODR的偏移地址为:0x14。也就是说,物理绝对地址=基地址+偏移地址=0x4002 0400 + 0x14 = 0x4002 0414。
操作 0x40020414 (GPIOB_ODR)。 我们要把 Bit 2 (ODR2) 置 1。 写入 0x00000004:
monitor mww 0x40020414 0x00000004敲下回车的那一刻,请看向你的天空星核心板。 那个TYPE-C旁边绿色的 LED 灯,是不是亮了?

如果你想关掉它,就写 0:
monitor mww 0x40020414 0x00000000恭喜你!你刚刚完成了一次最底层的 纯手工 嵌入式控制。没有 IDE 的辅助,没有 C 语言的封装,你直接通过调试链路(借助DAPLink仿真器),像操纵木偶一样直接控制了天空星核心板上面的STM32主芯片的GPIO寄存器。
这,就是嵌入式开发的底层浪漫。
7.2.3 Clion 如何查看芯片外设
类似 keil(MDK) 和 vscode+EIDE ,我们也可以在 CLIon 里直接以类似图形化的形式操作寄存器来点灯的,
首先要获取一下我们这个芯片的svd文件,点这个链接也可以直接下载。

重新回到CLion,加载我们在上面获取到的SVD,


会弹出来让我们选择要显示的外设,我们这里就先直接全选就好了:

那我们继续和之前在KEIL或者VSCODE里面的操作一样,这里不再详细介绍这些寄存器怎么改(毕竟前面都已经说过了),只简单演示一下【要记得先暂停程序】:

此时,天空星核心板旁边的绿灯就会亮了。
7.4 用网页连接DAPLink仿真器直接修改寄存器来点灯
TIP
在线使用链接:天空星调试助手
使用教程: 天空星调试助手使用文档
在开始操作前,我们一定要确保天空星核心板里面是没有控制GPIO相关的程序的,筑基学习板套件在出厂前,因为需要进行出厂测试,所以会默认烧录出厂固件,其程序是会对LED灯所用到的引脚进行操作的,如果板子上有程序,可能会在你写寄存器后很快又把寄存器改回去,导致你看不到预期结果。
开始下面的操作前,一定要确定硬件连接是否正常,确保 DAPLink 仿真器与天空星核心板已正确连接,并插入电脑USB口中。
7.4.1 给天空星烧录不控制GPIO的程序
我们在前面的第三到五章所创建好的工程,直接编译下载后,就是一个不会对GPIO进行操作的程序。请各位自行选择自己中意的编译环境,将这个只对变量进行自增一的程序下载到天空星核心板中。
我下面以VS Code + EIDE 来给大家演示一下,烧录前要确保我们的main函数里面只有对变量自增一的操作,没有对GPIO的操作。

烧录后,你的天空星开发板会什么反应都没有,这是正常的。
TIP
如果你无法正常下载,请先看 4.3.8 搭建下载环境 章节,确认下载环境没有问题。
7.4.2 实验意义与价值
这种点灯方式脱离了IDE,对于初学者,可以让你亲眼看到 修改一个寄存器位就能控制硬件 的过程,打破对单片机的神秘感,对于工程师,当驱动代码不工作时,直接操作寄存器验证配置是否正确。
| 对比项 | Keil MDK 调试 | 天空星调试助手 | 优劣分析 |
|---|---|---|---|
| 软件安装 | 需要较大 的 IDE | 无需安装,浏览器即可 | ✅ 我们更轻便 |
| 工程文件 | 需要 .uvprojx 工程 | 只需 SVD 文件 | ✅ 我们更简单 |
| 跨平台支持 | 仅 Windows | Windows/Mac/Linux【理论支持,实际未测】 | ✅ 我们全平台 |
| 寄存器可视化 | 需要手动打开外设窗口 | 位域图形化,点击即可操作 | ✅ 我们更直观 |
| 调试功能完整性 | 完整的单步/断点/变量查看 | 仅支持寄存器读写和内存监控 | ⚠️ IDE 更强大 |
| 使用门槛 | 需要熟悉 IDE 操作 | 会用浏览器就能上手 | ✅ 我们更低 |
WARNING
结论:天空星调试助手不是要替代 IDE,而是作为轻量级补充工具。在需要快速验证、现场调试、教学演示时,它比 IDE 更方便。主要还是为了教学本章来使用,可以说完全就是为了这碟醋,包了盘饺子。
7.4.3 打开调试网页进入调试模式
Step 1:打开天空星调试助手
- 打开 Chrome 或 Edge 浏览器
- 访问在线地址:https://wiki.lckfb.com/storage/html/web-debugger/
- 等待页面完全加载
💡 提示:建议收藏此页面到浏览器书签,方便下次快速访问
打开后的界面如下图所示: 
Step 2:加载 SVD 文件
- 点击页面中间的 [📂 加载SVD文件] 右上角工具栏的 SVD 按钮
- 在弹出的文件选择对话框中,找到并选择
STM32F407.svd文件,如果没有的话你先按照欢迎界面的 ⬇️ 还没有 SVD 文件? 中的提示下载一下。 - 等待 SVD 解析完成(通常 1-2 秒)

加载成功的标志:
- 左侧外设树形列表显示所有外设(ADC、CAN、DAC、DMA、GPIO、I2C、RCC、SPI、TIM、USART 等)
- 页面中央的欢迎提示消失,显示 请从左侧选择外设 的提示

Step 3:连接 DAPLink
- 确保 DAPLink 已通过 USB 连接到电脑,且系统已识别(可在设备管理器中确认)
- 确保 DAPLink 与天空星开发板的 SWD 接口(2x5p排针那里)连接正确
- 确保天空星开发板已通过 Type-C 供电 或者直接由DAPLink来供电
- 点击页面右上角的 [🔌 连接] 按钮
- 在浏览器弹出的 USB 设备选择框中,选择 DAPLink 设备(我们的立创DAPLink会显示为 LCKFB DAPLink CMSIS-DAP )
- 点击"连接"

连接成功的标志:
- ✅ 状态指示灯(连接按钮旁边的圆点)变为白色亮起
- ✅ 连接按钮文字变为红色的"断开"
- ✅ 底部系统日志显示"DAPLink 连接成功"或 "Connected to target"
Step 4:确认调试模式
确认页面右上角的模式开关处于 🐞 调试 模式(绿色高亮状态)。
- 🧮 计算 模式:离线模式,仅进行 SVD 解析和位域值计算,不与硬件通信
- 🐞 调试 模式:在线模式,与硬件实时同步读写(我们需要使用这个模式)

7.4.4 点亮LED灯
在 6.5.1 天空星核心板上的LED灯: 小节中,我们知道了,当 PB2 引脚输出 高电平(3.3V) 时,电流从 PB2 流向 GND,LED 点亮 ,当 PB2 引脚输出 低电平(0V) 时,没有电压差,没有电流,LED 熄灭。
和在上面 7.1.2 开始在调试状态下不写代码点亮LED灯 小节中一样,这里我们也是三步走,第一步:使能外设时钟;第二步:配置引脚模式 ;第三步:控制输出电平 ;
TIP
为什么要这个顺序?
- 时钟使能:STM32 为了省电,默认关闭大部分外设时钟。没有时钟,外设寄存器无法读写.
- 模式配置:GPIO 引脚可用于多种功能(输入/输出/复用/模拟),必须先配置才能正确工作.我们是需要GPIO的输出模式的。
- 电平控制:只有完成前两步,控制电平才会生效.
【STEP1:使能GPIOB口的时钟】
我们在1.2 重点提示小节中和大家强调过,使用外设功能前务必要先开启对应外设的时钟使能,否则对应外设是无法正常工作的。
接下来我们打开STM32F4的参考手册和数据手册,看一下控制外设时钟的寄存器究竟是哪一个。
这里我只是简单说明,更详细的介绍请看后续章节的介绍,这里只简单提及让大家有个概念即可。
在数据手册中搜索,Figure 5. STM32F40xxx block diagram 我们来看一下STM32F4的硬件框图,找一下GPIOB属于哪一个外设时钟:

在图中可以明确看出来,GPIO PORT B 挂载在 AHB1 总线上,那么再看参考手册,搜一下RCC AHB1 外设时钟使能寄存器 (RCC_AHB1ENR):

这个32位寄存器的复位值是0x0010_0000,展开成二进制就是0B000100000000000000000000,对应到上面的定义,只有CCMDATARAMEN(内部CCM内存,速度更快,但DMA无法访问,仅CPU可访问) 的使能在芯片复位后就是开启的。而所有GPIOA,GPIOB,GPIOx等默认都是关闭的,如果我们想让PB2这个引脚能正常工作,那首先要做的就是先使能GPIOB的时钟,也就是要把上面这个寄存器的位1写入数据1。
那么我们在网页连接DAPLink状态下如何使能这个位呢?在成功加载STM32F407的SVD文件后,我们就可以在网页的左边找到对应外设了:
依次选择RCC->ANHB1ENR->GPIOBEN,单击GPIOBEN后面的那个拨动开关,使之变为1,就是给GPIOB的时钟进行使能了。

单击后可以看到如下所示的图: 
我们看到AHB1ENR后面跟着的Value从默认值0x0010_0000变成了0x0010_0002,后面的二进制展开位的第1位也变成1了,和十六进制是对应的。
【STEP2:初始化PB2为输出模式】
接下来我们重新回顾上一章节[8]认识GPIO,我们在这个章节详细介绍了驱动GPIO所涉及到的寄存器,和使用GPIO的一般步骤:
- 使能 GPIO 端口时钟: 在
RCC_AHB1ENR寄存器中,使能目标 GPIO 端口(如 GPIOA)的时钟。- 配置引脚模式: 设置
GPIOx_MODER寄存器,选择输入、输出、复用还是模拟模式。- 配置引脚参数 :
- 输出/复用模式: 配置
GPIOx_OTYPER(推挽/开漏) 和GPIOx_OSPEEDR(速度)。- 输入/输出模式: 配置
GPIOx_PUPDR(上拉/下拉/浮空)。- 复用模式: 配置
GPIOx_AFRL/AFRH选择具体的外设功能。- 操作引脚:
- 输出: 通过写
GPIOx_ODR或GPIOx_BSRR来改变引脚电平。- 输入: 通过读
GPIOx_IDR来获取引脚电平。- 复用/模拟: 配置完成交由相应外设(如 USART, ADC)自动控制和使用,CPU 通常不再直接干预引脚电平。
我们前面已经完成了步骤1,成功使能了GPIOB的时钟,接下来看如何进行让PB2这个引脚进行初始化,配置为输出模式(毕竟我们要驱动LED灯就是要对外进行输出)。那就是要去操作GPIOB 的 端口模式寄存器 (GPIOB_MODER),也就是说要把GPIOB的MODER2设置成01(通用输出模式)。
那我们继续之前的调试操作,找到 GPIO -> GPIOB。

单击后就能在中间的窗口中看到GPIOB相关的寄存器了,我们根据上面的内容(要先配置GPIO的模式-端口模式寄存器 (GPIOB_MODER)),先点MODER进行展开,能看到当前默认值是0x00000280,不知道为什么的话返回上一章4.2.1部分的解惑:【解惑】为何 GPIOA/GPIOB 的 MODER 复位值不是全 0?

我们直接把GPIOB->MODER里面的MODER2从00修改位01,就是点一下01即可,此时MODER的值也变成了0x00000290 ,同时也意味着PB2成功被初始化为了输出模式。

【STEP3:设置PB2输出高电平,点亮天空星核心板上的LED灯!】
万事俱备,只欠东风!现在我们可以真正地控制 PB2 输出高电平了。根据上一章的学习,我们知道能控制GPIO输出电平的有两个寄存器,一个是ODR,另一个是BSRR。这里我们先用更直观的ODR寄存器来测试吧,GPIOB_ODR是一个 32 位寄存器,但只用到了低16位(高 16 位保留但是无效),每个 bit 控制一个引脚的输出状态。

意味着我们把GPIOB_ODR的ODR2设置为1,那么理论上此时天空星核心板上的PB2所控制的LED灯此时就会亮了。继续开始调试操作,点ODR前面的小三角号进行展开,点击ODR2后面的开关位,把它设置位1:

当你勾选的瞬间,你会看到 ODR 寄存器的值从 0x00000000 变成了 0x00000004 ( 2² = 4)。
见证奇迹的时刻到了!
低头看一下你的天空星核心板,在TYPE-C口旁边的绿色LED灯,此刻应该已经点亮了!
恭喜你!不写代码也点亮了一个灯,你现在可以反复点击上面的点击ODR2后面的开关位,可以看到板子上的绿灯在随着你的点击进行闪烁,再次恭喜你,不写代码也实现了LED灯闪烁!

如果一切正常的话,箭头指的这个绿灯会开始闪烁。
7.5 总结
通过本节,你并没有编写一行代码,却实实在在地控制了单片机的GPIO。 这印证了嵌入式开发的一个真相:代码只是工具,寄存器才是控制硬件的本质。 无论是库函数还是 HAL 库,最终都是在把你的意图翻译成对这些寄存器的读写操作。
保持这种 透视 硬件的感觉,下一节我们将正式开始用代码来编写你的第一个点灯程序!
八 编写代码来点亮LED灯
8.1 合并工程文件
TIP
如果你是初学者,可以不用看本小节,直接继续用原来的keil工程就好了,不用合并。
本教程设计之出,定位就是让各位用户拥抱各类AI助手,但是KEIL无法直接使用AI插件,而VS Code和CLion都可以方便的安装各种AI插件,让上面的三个工程合并在同一个文件夹中,只是编译环境不一样,让所有.C文件通用是我们要实现的目标。
创建一个名为project的文件夹:

先把Keil(MDK)里面的所有文件复制到project文件夹里面,然后同把CLion文件夹和EIDE-Project文件夹中的所有也复制到project文件夹中,会提示你是否覆盖同名文件,直接点击【替换这些文件】就可以(本来文件内容也都是一样的,除了启动文件):
WARNING
要注意keil的armcc编译器用到的启动文件和CLion或者VS Code + EIDE中我们用的启动文件名,名字一样,但是内容完全不一样,所以我们需要为keil的启动文件单独找个位置存放:
我这里先把原来keil工程中的,startup_stm32f407xx.s 复制到合并工程的Objects目录下,然后我们打开keil工程,替换一下当前的启动文件:

整体文件组合如下图所示:

复制过来的文件内部如上图所示。
8.2 如何在 C 语言中操作物理地址?
我们在第七章-在线调试状态下直接点亮LED灯中,已经知道了需要操作的三个寄存器的地址:
- RCC_AHB1ENR (GPIO的时钟使能):
0x4002 3830 - GPIOB_MODER (GPIOB的模式配置):
0x4002 0400 - GPIOB_ODR (GPIOB输出控制):
0x4002 0414
在 C 语言中,代表 地址 的数据类型是指针。要向地址 0x4002 3830 写入数据,我们需要把它强制转换为指针。
假如我们要在代码里面复刻在第七章,在调试状态下使能GPIOB的时钟的操作,就得写下面这个命令:
*(volatile unsigned int *)(0x40023830) = 0x00100002;别被这行代码吓到,我们像剥洋葱一样剥开它,分段来看:
(0x40023830): 这只是一个单纯的十六进制数字,在编译器眼里它就是个整数,细心的你也能观察到,这个其实就是RCC_AHB1ENR寄存器的地址。(unsigned int *): 我们告诉编译器:【嗨,兄弟,别把这个数字当整数看了,把它当成一个无符号整型数据的地址(指针)】。volatile: 告诉编译器我要通过这个地址控制硬件,千万不要给我做优化!每次读写都要老老实实地去访问物理内存,不要读缓存。*( ... ): 指针的解引用操作。意思是 去操作这个地址指向的那块内存 。
8.3 用C代码来点亮LED灯
搞了这么久,终于到正式点灯的时候了,接下来我们用C代码来点亮天空星核心板旁边的绿灯,这里有关寄存器定义,介绍啥的都不再赘述了,详细的请参考上面的章节,这里为了照顾初学者,我们用keil(MDK)来给大家演示:
还是老规矩,双击keil工程直接打开:

在这个章节中,我们用ODR寄存器来点亮。
在8.2章节中,已经再次强调了用到的三个寄存器的地址,本章要做的,就是往这些寄存器里面写值。
实际要搞的代码就三行,我们直接在注释里面讲解吧:
/*
* STEP 1: 开启 GPIOB 时钟
* 寄存器: RCC_AHB1ENR (地址: 0x40023830)
* 目标: 使能 GPIOBEN (Bit 1)
*
* 计算过程:
* RCC_AHB1ENR 的复位默认值通常是 0x00100000
* 我们要让 Bit 1 变 1,即二进制 ...0001 0000 0000 0000 0000 0010
* 换算成十六进制就是 0x00100002
*/
*(volatile unsigned int *)(0x40023830) = 0x00100002;
/*
* STEP 2: 配置 PB2 为输出模式
* 寄存器: GPIOB_MODER (地址: 0x40020400)
* 目标: 将 MODER2 (Bit 5, Bit 4) 设置为 "01" (通用输出模式)
*
* 计算过程:
* 假设我们不关心其他引脚,只想配置 PB2。
* 对应 Bit 5 应为 0,Bit 4 应为 1。
* 二进制: ... 0000 0000 0000 0001 0000 (注意 PB2 在第 4 位开始)
* 即: 0x00000010
*/
*(volatile unsigned int *)(0x40020400) = 0x00000010;
/*
* STEP 3: 点亮 LED (输出高电平)
* 寄存器: GPIOB_ODR (地址: 0x40020414)
* 目标: 让 PB2 (Bit 2) 输出 1
*
* 计算过程:
* 二进制: ... 0000 0100 (第2位是1)
* 十六进制: 0x00000004
*/
*(volatile unsigned int *)(0x40020414) = 0x00000004;2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
我们直接在while(1)前面插入上面这些代码:

进行编译烧录后,低头看看你天空星核心板上的绿灯是否已经成功点亮了?再次祝贺你。接下来我们让他闪烁一下。
8.4 用C代码来让LED灯闪烁
让他闪烁就很简单了,就控制一个寄存器就行:
这个代码是这个灯的引脚输出高电平:
/*
* 点亮 LED (输出高电平)
* 寄存器: GPIOB_ODR (地址: 0x40020414)
* 目标: 让 PB2 (Bit 2) 输出 1
*
* 计算过程:
* 二进制: ... 0000 0100 (第2位是1)
* 十六进制: 0x00000004
*/
*(volatile unsigned int *)(0x40020414) = 0x00000004;2
3
4
5
6
7
8
9
10
这个代码是让这个灯的引脚输出低电平:
/*
* 点亮 LED (输出高电平)
* 寄存器: GPIOB_ODR (地址: 0x40020414)
* 目标: 让 PB2 (Bit 2) 输出 1
*
* 计算过程:
* 二进制: ... 0000 0000 (第2位是1)
* 十六进制: 0x00000000
*/
*(volatile unsigned int *)(0x40020414) = 0x00000000;2
3
4
5
6
7
8
9
10
我们用这两个代码再加上两个延时函数就可以让灯闪烁起来了,把原来的让变量自增一的代码给删掉,在while的两个方括号之间加入这个:
*(volatile unsigned int *)(0x40020414) = 0x00000004;
delay_simple(500000);
*(volatile unsigned int *)(0x40020414) = 0x00000000;
delay_simple(500000);2
3
4

再看一眼天空星筑基学习板,是否此时绿灯已经开始闪烁了?接下来的第八章的后续内容初学者可以先不看了,将再给各位多介绍几种更贴近实际的代码。
8.5 拒绝 魔术数字 ——使用宏定义优化代码
TIP
这里的 魔术数字 指的是代码中直接出现的、未经解释的数字常量(如 0x40023830)。在实际工程中,满屏的魔术数字是维护者的噩梦。
虽然在 8.3 和 8.4 节中,我们成功点亮并闪烁了 LED,但各位回头看看那几行代码,是不是觉得心里发毛?
*(volatile unsigned int *)(0x40023830) = 0x00100002;如果你作为一个资深工程师,在代码Review(审查)时看到同事写出这样的代码,你一定会想把键盘拍在他脸上。因为过个两三天,恐怕连他自己都忘了 0x40023830 到底是干嘛的。此外,万一哪天要换成 GPIOC 口点灯,难道要拿计算器重新算一遍所有地址吗?还要不要效率了!
C语言中的 #define 宏定义就是来解决这个问题的。我们将这些难记的地址起一个尽量 见名知意 名字。
我们直接在 main.c 顶部,把这些地址 起名 :
// 基地址定义 (参考芯片手册 Memory Map)
#define PERIPH_BASE ((unsigned int)0x40000000)
#define AHB1PERIPH_BASE (PERIPH_BASE + 0x00020000)
// RCC 时钟控制相关
#define RCC_BASE (AHB1PERIPH_BASE + 0x3800)
#define RCC_AHB1ENR *(volatile unsigned int *)(RCC_BASE + 0x30)
// GPIOB 相关
#define GPIOB_BASE (AHB1PERIPH_BASE + 0x0400)
#define GPIOB_MODER *(volatile unsigned int *)(GPIOB_BASE + 0x00)
#define GPIOB_ODR *(volatile unsigned int *)(GPIOB_BASE + 0x14)2
3
4
5
6
7
8
9
10
11
12
有了这些定义,原本晦涩难懂的代码就可以瞬间变得优雅,然后在main函数中间填入以下内容:
// 1. 开启 GPIOB 时钟
RCC_AHB1ENR = 0x00100002;
// 2. 配置 PB2 为输出模式
GPIOB_MODER = 0x00000010;
// 3. 在 while(1) 中闪烁
while(1) {
GPIOB_ODR = 0x00000004; // 点亮
delay_simple(500000);
GPIOB_ODR = 0x00000000; // 熄灭
delay_simple(500000);
}2
3
4
5
6
7
8
9
10
11
12
13
14

这样写,是不是一眼就能看懂这几行代码在干什么了?大家写代码时一定要注意,可读性 是工程代码的第一生命力。
8.6 嵌入式工程师的必修课——【读-改-写】操作
WARNING
这一小节的内容可能对初学者来说比较难理解,也是新手和老鸟的分水岭。请务必反复阅读,直到理解为止。
在 8.5 节的代码中,有一个非常致命的隐患。
看这行代码: RCC_AHB1ENR = 0x00100002;
这行代码的意思是:将 RCC_AHB1ENR 寄存器的值直接赋值为 0x00100002。 这就好比,你住在一个有32个房间的酒店里(寄存器的32位),你想把第2号房间的灯打开。你不仅打开了2号房的灯,还顺手把其他31个房间的电闸全拉了!因为你把其他位都强制写成了0。
如果在你操作 GPIOB 时钟之前(这个代码里没有,但是实际工程肯定会碰到的),GPIOA 的时钟已经被开启了(Bit 0 为 1),你这一行赋值代码下去,GPIOA 的时钟就被你无情地关闭了,挂在 GPIOA 上的所有设备瞬间瘫痪。
在嵌入式开发中,我们需要遵循一个法则:只修改我们需要修改的位,绝对不能影响其他位。
为了实现这个目标,我们需要使用 C 语言的 位操作(Bitwise Operations),配合 读-改-写 (Read-Modify-Write)的逻辑。
8.6.1 置位操作(把某一位设为1)
我们要把 RCC_AHB1ENR 的第 1 位(GPIOBEN)置为 1,同时保持其他位不变,应该用 按位或(|) 操作符。
RCC_AHB1ENR = RCC_AHB1ENR | (1 << 1);
// 简写为:
RCC_AHB1ENR |= (1 << 1);2
3
1 << 1 的意思是把数字 1 左移 1 位,变成二进制 ...0010。 任何一位和 0 进行 或 运算,值不变;和 1 进行 或 运算,值变为 1。这样就只操作了我们关心的那一位。
8.6.2 清零操作(把某一位设为0)
我们要把 GPIOB_ODR 的第 2 位清零(熄灭 LED),同时保持其他位不变,应该用 按位与(&) 和 按位取反(~) 操作。
/*
* 逻辑:任何数 & 0 = 0; 任何数 & 1 = 保持原样
*/
GPIOB_ODR &= ~(1 << 2);2
3
4
逻辑分析:
(1 << 2)得到二进制...0000 0100。~(1 << 2)取反,得到二进制...1111 1011。- 让寄存器和这个数进行 与 运算。第 2 位对应的是 0,会被强制清零;其他位对应的是 1,就会保持原样。
8.6.3 最终优化的代码
结合宏定义和位操作,我们写出一个真正的、实际工程中有可能会用到的的点灯代码:
int main(void) {
// STEP 1: 开启 GPIOB 时钟 (安全版本)
// 只操作 Bit 1,不影响其他外设时钟
RCC_AHB1ENR |= (1 << 1);
// STEP 2: 配置 PB2 为输出模式 (通用推挽输出 01)
// 先把 PB2 对应的两个位 (Bit 5, Bit 4) 清零
GPIOB_MODER &= ~(3 << (2 * 2));
// 再把 Bit 4 置为 1
GPIOB_MODER |= (1 << (2 * 2));
while (1) {
// STEP 3: 点亮 LED (PB2 置 1)
GPIOB_ODR |= (1 << 2);
delay_simple(500000);
// STEP 4: 熄灭 LED (PB2 置 0)
GPIOB_ODR &= ~(1 << 2);
delay_simple(500000);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

现在,你的代码不仅能跑,而且很健壮。哪怕你以后在这个工程里添加了串口、ADC等其他功能,这段点灯代码也不会破坏别的配置。
8.7 进阶技巧——使用BSRR寄存器进行原子操作
TIP
我们在前面7.2章节中,也已经在调试状态下用BSRR寄存器来点亮LED灯了,本小节我们用代码的方式来控制这个BSRR寄存器。
细心的工程师可能已经发现了,用 ODR 寄存器配合 |= 和 &= 操作虽然安全,但效率略低,毕竟有三个步骤嘛。
因为 GPIOB_ODR |= (1 << 2); 这行代码在汇编层面实际上分了三步:
- 读:CPU 从
ODR寄存器读取当前值到内部通用寄存器。 - 改:CPU 在通用寄存器中进行 或 运算。
- 写:CPU 将运算结果写回
ODR寄存器。
这叫 读-改-写 (Read-Modify-Write) 周期。如果在 读 和 写 之间发生了一个中断,中断里也修改了 ODR 寄存器的其他位,当芯片从中断回来执行 写 操作时,就会把中断里运行的成果给覆盖掉!虽然概率很低,但在嵌入式系统中,这是不可接受的,要记得墨菲定律,可能发生的问题一定会发生。
设计芯片的设计师非常有经验,他们为 GPIO 提供了一个特殊的寄存器:GPIOx_BSRR (Bit Set/Reset Register)。
这个寄存器很神奇:
- 写 1 有效,写 0 无效。这意味着我们不需要读出原值,直接赋值即可,不用担心覆盖其他位。
- 低 16 位 (0-15):对应引脚写 1,则该引脚输出高电平(Set)。
- 高 16 位 (16-31):对应引脚写 1,则该引脚输出低电平(Reset)。
BSRR地址:GPIOB_BASE + 0x18,更详细的说明,请看上一章的这部分内容:链接
我们需要增加一个宏定义:
#define GPIOB_BSRR *(volatile unsigned int *)(GPIOB_BASE + 0x18)再来一个极速点灯代码:
while (1) {
/*
* 使用 BSRR 点亮 LED
* 向 BSRR 的低 16 位中的第 2 位写 1 -> PB2 输出高
* 注意:这里直接赋值,不需要 |=,因为写 0 的位不起作用
*/
GPIOB_BSRR = (1 << 2);
delay_simple(500000);
/*
* 使用 BSRR 熄灭 LED
* 向 BSRR 的高 16 位中的第 2 位 (即 Bit 18) 写 1 -> PB2 输出低
* 16 + 2 = 18
*/
GPIOB_BSRR = (1 << (16 + 2));
delay_simple(500000);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
这种操作是原子性的,一波带走,中间不会被中断或者其他东西打断逻辑,是控制 GPIO 输出最推荐的方式,后面的HAL库,其底层实际也是控制这个寄存器来控制GPIO的。
将这段代码替换到你的工程中,编译下载。

九 课后作业
根据本章内容,相信大家已经能正常让天空星核心板上面的绿色LED灯正常闪烁了,但是我们筑基学习板上也有一个LED灯,他的引脚是PB8,请大家新建一个工程,把这个灯也在调试状态下点亮,并且编写代码让他闪烁。
不要小瞧这个简单的课后作业,完成它能帮你把寄存器、引脚配置、调试器使用这些看似零散的知识真正串起来,变成可复用的工程能力。我可不会提供完整工程哦,没有答案的作业才是好作业【PS:这个真不难!】。
TIP
【【【彩蛋】】】
26年1月30号中午12点前:
把你学习本篇教程的感受,实操记录,在线调试点灯和实际用IDE(任意选一种)实际点亮筑基学习板上PB8引脚的这个灯的过程,录制一个 视频 上传至B站,并将视频链接发到该篇 帖子 下面留言,笔者将随机挑一位用户送 天空星·筑基学习板 配套的2.0寸屏幕扩展板的免单卷。
在帖子中回复内容请按照以下格式来【不按格式来的视为放弃】:
视频链接:
嘉立创客编:
可以联系我交流学习心得或者提建议【要注意,我不是客服,请不要问我过于愚蠢的问题】:

十 总结
每一位试图用纯寄存器开发 STM32 的初学者,在经历了最初点亮 LED 的兴奋后,紧接着面临的就是对繁琐地址查阅的厌倦和对代码可维护性的担忧。
TIP
【可能很多人都有下面的想法】:
现在这样也太麻烦和容易出错了吧,一个地址搞错了找问题都难死。该如何改善呢?
点个灯都这么麻烦,后面驱动其他设备岂不是比西天取经还难了?
在实际的工程开发中,我们绝不会像这样一行行去查手册、算地址、写十六进制数。
无论多么高级的库(HAL库、LL库、标准库),无论多么复杂的操作系统(RT-Thread,FreeRTOS等),它们剥去华丽的外衣后,最底层依然是我们今天写的这些读写寄存器的操作。
如果把嵌入式开发比作盖房子,第九章(本章)我们是在亲手烧砖(操作寄存器),虽然让你懂了砖头是怎么来的,但如果盖摩天大楼还让你一块块烧砖,那还没盖完人就累垮了。
下一章中,我们将通过代码封装,让那些枯燥的十六进制数字基本消失,取而代之的是逻辑清晰、通俗易懂的函数调用。从零开始,手把手教你编写一套属于自己的 GPIO 驱动库。当然会比较简陋的,只是为了方便的大家理解。
