1. 基于KEIL MDK的STM32F103工程结构解析与SysTick流水灯实现

在嵌入式系统开发中,一个清晰、可复用、符合芯片架构特性的工程组织结构,是项目长期维护和功能扩展的基础。尤其对于初学者而言,盲目依赖IDE自动生成的模板,往往导致对启动流程、内存布局、时钟配置等底层机制缺乏理解,一旦遇到异常中断、时序偏差或外设初始化失败等问题,便陷入“黑盒调试”的困境。本文以STM32F103C8T6(主流入门型号)为对象,从零构建一个最小可行工程,并基于SysTick滴答定时器实现精确周期的LED流水灯控制。所有操作均围绕KEIL MDK-ARM v5.37(推荐使用ARMCC v5编译器)展开,严格遵循CMSIS标准与ST官方固件库V3.5.0的接口规范。

1.1 工程目录结构设计原则

一个健壮的STM32工程不应是IDE生成的一堆杂乱文件,而应体现明确的职责分离与层级抽象。我们采用四层物理目录结构:

目录名 内容说明 工程目的 关键约束
Core CMSIS核心层: core_cm3.h startup_stm32f10x_md.s system_stm32f10x.c 提供Cortex-M3内核寄存器定义、异常向量表、系统时钟初始化骨架 必须与所选芯片Flash容量匹配( md =medium density=64KB); startup_*.s 必须与链接脚本中 STACK_SIZE / HEAP_SIZE 定义一致
FWLIB ST标准外设库: inc/ (头文件)、 src/ (源文件) 封装寄存器操作,提供HAL级API(如 RCC_APB2PeriphClockCmd 仅包含实际使用的外设驱动(本例需 stm32f10x_gpio.c stm32f10x_rcc.c stm32f10x_systick.c ),避免冗余编译
User 用户应用层: main.c led.c/h systick.c/h 实现业务逻辑,隔离硬件细节 所有用户代码不得直接操作寄存器(如 GPIOA->ODR ),必须通过FWLIB API或封装函数
Output 编译输出目录(由KEIL自动生成) 存放 .axf .hex .map 等产物 禁止 手动修改或向此目录添加源文件

该结构直接映射到KEIL工程中的Groups(组)。创建工程时,必须将各目录下的源文件按组归类,而非简单拖入根目录。例如: FWLIB/src/stm32f10x_gpio.c 应归属 FWLIB_SRC 组, User/main.c 归属 USER_APP 组。这种组织方式确保了编译依赖清晰、增量编译高效,并为后续移植到其他IDE(如IAR、STM32CubeIDE)奠定基础。

1.2 固件库与启动文件的精准获取

STM32F103的标准外设库(Standard Peripherals Library, SPL)V3.5.0是经过充分验证的稳定版本,其配套的CMSIS V2.00完全兼容Cortex-M3内核。 切勿使用官网最新版STM32CubeF1替代SPL ——二者编程模型截然不同(CubeMX生成HAL库,SPL为寄存器级封装),混用将导致符号冲突与不可预测行为。

获取路径如下:
1. 访问ST官方归档页面(非当前下载页): https://www.st.com/en/embedded-software/stsw-stm32054.html
2. 下载 STM32F10x_StdPeriph_Lib_V3.5.0.zip
3. 解压后,提取以下关键路径:
- Libraries/CMSIS/CM3/CoreSupport/ → 复制 core_cm3.h Core/ 目录
- Libraries/CMSIS/CM3/DeviceSupport/ST/STM32F10x/ → 复制 stm32f10x.h system_stm32f10x.c Core/ 目录
- Libraries/CMSIS/CM3/DeviceSupport/ST/STM32F10x/startup/arm/ → 复制 startup_stm32f10x_md.s Core/ 目录(注意: md 对应64KB Flash,若用 hd =high density=256KB芯片则选 startup_stm32f10x_hd.s
- Libraries/STM32F10x_StdPeriph_Driver/inc/ → 全部头文件复制至 FWLIB/inc/
- Libraries/STM32F10x_StdPeriph_Driver/src/ 仅复制必需文件 stm32f10x_gpio.c stm32f10x_rcc.c stm32f10x_systick.c (流水灯仅需GPIO、时钟、SysTick)

关键实践点 system_stm32f10x.c 中的 SystemInit() 函数默认将系统时钟配置为8MHz内部RC振荡器(HSI)。但STM32F103通常外接8MHz晶振(HSE),需手动修改该文件中 RCC_DeInit() 后的时钟配置段,启用HSE并倍频至72MHz(PLLCLK = HSE * 9)。这是后续所有外设定时精度的基础,SysTick的1ms基准即源于此。

1.3 KEIL工程创建与编译器配置

KEIL MDK工程创建绝非点击“New Project”即可完成,其本质是建立一套完整的工具链映射关系。以下是精确步骤:

步骤1:新建工程与设备选择
  • 启动KEIL uVision → Project New uVision Project...
  • 路径选择:指向已创建的工程根目录(如 D:\STM32\LED_SysTick
  • 工程名: LED_SysTick.uvprojx
  • 在弹出的 Select Device for Target 对话框中,搜索 STM32F103C8 ,双击确认。 此步至关重要 :KEIL会自动加载该芯片的Flash算法、调试配置及默认启动文件路径,若选错型号(如误选F103RB),后续链接必然失败。
步骤2:添加源文件组(Groups)

在Project Workspace中右键 Target 1 Manage Components... → 切换至 Groups 选项卡:
- 创建新组: Core → 右键 Add Files to Group 'Core' → 添加 Core/startup_stm32f10x_md.s Core/system_stm32f10x.c Core/core_cm3.h (头文件不参与编译,仅用于索引)
- 创建新组: FWLIB_SRC → 添加 FWLIB/src/stm32f10x_gpio.c FWLIB/src/stm32f10x_rcc.c FWLIB/src/stm32f10x_systick.c
- 创建新组: USER_APP → 添加 User/main.c

避坑提示 :KEIL在创建工程时会自动生成 main.c startup_*.s 等文件。 必须立即删除这些自动生成的文件 ,否则将与手动添加的 startup_stm32f10x_md.s 产生重复定义错误(Multiple definition of Reset_Handler )。这是新手最常犯的错误之一。

步骤3:配置编译器与头文件路径
  • Project Options for Target 'Target 1'... Target 选项卡:
  • Xtal (MHz) :填入外部晶振频率, 必须与硬件一致 (典型值为8.000)
  • Use MicroLIB 取消勾选 (MicroLIB是KEIL精简C库,无 printf 浮点支持,且与SPL部分函数存在ABI冲突;标准库更稳定)
  • C/C++ 选项卡:
  • Define :添加预定义宏 USE_STDPERIPH_DRIVER, STM32F10X_MD (告知SPL使用中密度芯片定义)
  • Include Paths :添加以下路径(每行一条,绝对路径或相对路径均可):
    .\Core .\FWLIB\inc .\User
  • Optimization Level 3 (平衡速度与体积, -O3
  • Output 选项卡:
  • Create HEX File :勾选(便于烧录)
  • Debug 选项卡:根据调试器选择(如ST-Link/V2)
步骤4:编译器版本强制指定(核心痛点)

字幕中提及的“v5编译器”问题,源于KEIL MDK-ARM v5.37默认安装了ARM Compiler 6(ARMCC v6),而SPL V3.5.0的汇编启动文件( startup_stm32f10x_md.s )语法与AC6不兼容(如 EXPORT 指令写法差异)。 必须强制使用ARM Compiler 5(ARMCC v5)
- Project Manage Project Items... Folders/Extensions 选项卡
- 在 ARM Compiler 下拉菜单中, 手动选择 ARMCC v5.06 update 6 (build 750) (或任何v5.x版本)
- 点击 OK 保存。此时重新编译,将不再出现 #error "Please select first the target STM32F10x device used in your application." 等AC6专属报错。

原理深挖 :AC5使用ARMASM汇编器,AC6使用ARMASM6。前者支持传统ARM汇编语法(如 IMPORT __main ),后者要求改用 IMPORT __use_no_semihosting 等新指令。SPL的启动文件未适配AC6,故必须降级编译器。这也是为何ST官方推荐CubeMX+HAL库——其生成的启动代码原生支持AC6。

1.4 SysTick滴答定时器原理与初始化

SysTick是Cortex-M3内核集成的24位递减计数器,专为操作系统滴答(OS Tick)和精确延时设计。其优势在于:
- 独立于芯片厂商 :无需配置APB总线时钟,直接挂载在AHB总线上,时钟源为 HCLK/8 HCLK
- 硬件自动重载 :计数至0后自动重载 LOAD 寄存器值,无需软件干预
- 内置中断向量 :固定位于向量表第15号( SysTick_IRQn = 15 ),无需配置NVIC优先级(除非与其他中断竞争)

在STM32F103中,SysTick时钟源选择由 SysTick_CTRL_CLKSOURCE 位控制:
- 0 HCLK/8 (默认,8MHz晶振经PLL倍频至72MHz后, HCLK=72MHz ,则 72MHz/8 = 9MHz
- 1 HCLK (72MHz,计数精度更高,但 LOAD 值需更大)

为实现1ms精确延时,需计算 LOAD 值:

LOAD = (SysTick Clock / Desired Frequency) - 1
     = (72,000,000 Hz / 1000 Hz) - 1 = 71999

此即 SysTick_Config(71999) 的由来。

初始化代码( systick.c )应封装为可重用函数:

#include "stm32f10x.h"

static __IO uint32_t msTicks = 0;

void SysTick_Init(void)
{
    // 配置SysTick: 使用HCLK作为时钟源,使能中断,启动计数器
    if (SysTick_Config(SystemCoreClock / 1000)) {
        // 初始化失败,死循环(实际项目中应触发错误日志)
        while (1);
    }
}

// SysTick中断服务函数(必须命名为SysTick_Handler)
void SysTick_Handler(void)
{
    msTicks++;
}

// 获取毫秒计数值(线程安全,无锁)
uint32_t Get_SysTick_Count(void)
{
    return msTicks;
}

关键细节 SysTick_Config() 是CMSIS提供的标准函数,它自动配置 LOAD CTRL 寄存器并使能中断。 SystemCoreClock 变量由 system_stm32f10x.c 中的 SystemCoreClockUpdate() 更新, 必须确保 SystemInit() 正确执行并调用 SystemCoreClockUpdate() ,否则 SystemCoreClock 仍为默认8MHz,导致1ms延时严重失准(实际为8ms)。

1.5 GPIO端口配置与流水灯实现

STM32F103C8T6的GPIO端口(A-E)具有复用功能,LED通常接在 GPIOA GPIOC 的低速引脚上。本例选用 PA0-PA3 四个引脚控制LED,电路为共阴极接法(LED阳极接VCC,阴极经限流电阻接MCU引脚),故输出 LOW 点亮LED。

端口时钟使能与模式配置

GPIO操作前, 必须使能对应端口的时钟 ,否则寄存器写入无效。 RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_GPIOA, ENABLE) 开启 GPIOA 时钟(APB2总线,高速外设)。

引脚模式配置需明确三要素:
- 模式(MODE) :输出模式( GPIO_Mode_Out_PP = 推挽输出)
- 速率(SPEED) GPIO_Speed_50MHz (满足LED开关速度)
- 引脚(PIN) GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3

完整初始化( led.c ):

#include "stm32f10x.h"

void LED_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;

    // 1. 使能GPIOA时钟
    RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_GPIOA, ENABLE);

    // 2. 配置PA0-PA3为推挽输出,50MHz
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    // 3. 初始状态:全部熄灭(PA0-PA3输出高电平)
    GPIO_SetBits(GPIOA, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3);
}
流水灯主循环逻辑

main.c 中,结合SysTick实现非阻塞式流水灯:

#include "stm32f10x.h"
#include "led.h"
#include "systick.h"

int main(void)
{
    uint32_t lastTick = 0;
    uint8_t ledPos = 0;
    uint32_t currentTick;

    // 1. 系统时钟初始化(由SystemInit()完成,已在startup中调用)
    // 2. 外设初始化
    LED_Init();
    SysTick_Init();

    // 3. 主循环:每500ms移动一次LED位置
    while (1) {
        currentTick = Get_SysTick_Count();
        if ((currentTick - lastTick) >= 500) { // 检查是否过500ms
            lastTick = currentTick;

            // 熄灭所有LED
            GPIO_SetBits(GPIOA, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3);
            // 点亮当前LED
            switch (ledPos) {
                case 0: GPIO_ResetBits(GPIOA, GPIO_Pin_0); break;
                case 1: GPIO_ResetBits(GPIOA, GPIO_Pin_1); break;
                case 2: GPIO_ResetBits(GPIOA, GPIO_Pin_2); break;
                case 3: GPIO_ResetBits(GPIOA, GPIO_Pin_3); break;
            }
            ledPos = (ledPos + 1) % 4; // 循环移位
        }
    }
}

为何不使用 delay_ms()
delay_ms() 通常是阻塞式函数(如 for 循环空转),会占用CPU全部资源,无法响应中断或执行其他任务。而基于SysTick的 Get_SysTick_Count() 方案是事件驱动的,主循环可同时处理多个定时任务(如按键扫描、串口接收),是RTOS的雏形。

1.6 调试与常见故障排查

工程编译通过仅是第一步,实际运行常遇以下问题:

故障1:LED不亮,但程序看似运行
  • 检查点1:硬件连接
    用万用表测量 PA0 引脚电压:正常流水灯下应周期性在 0V (点亮)与 3.3V (熄灭)间切换。若恒为 3.3V ,说明GPIO未成功配置为输出。
  • 检查点2:时钟使能遗漏
    检查 RCC_APB2PeriphClockCmd() 是否被调用,且参数为 RCC_APB2PERIPH_GPIOA 。常见错误是误写为 RCC_APB2PERIPH_GPIOA | RCC_APB2PERIPH_GPIOB (多使能无害)或漏掉 ENABLE
  • 检查点3:引脚复位状态
    STM32复位后GPIO默认为模拟输入模式(高阻态)。若 GPIO_Init() 未执行,引脚呈高阻,万用表测得电压可能为浮动值(非0/3.3V),需示波器确认。
故障2:SysTick中断不触发
  • 检查点1:中断使能标志
    在调试器中查看 SysTick->CTRL 寄存器: BIT0 (ENABLE) BIT1 (TICKINT) 必须为 1 。若为 0 ,说明 SysTick_Config() 返回非零值(初始化失败)。
  • 检查点2:系统时钟配置错误
    检查 SystemCoreClock 值是否为 72000000 。若为 8000000 ,则 SysTick_Config(71999) 实际配置为 8MHz/1000=8000 ,LOAD值过大导致溢出时间远超1ms。
  • 检查点3:中断向量表偏移
    确认 startup_stm32f10x_md.s DCD SysTick_Handler 位于向量表第15项(地址 0x0000003C )。若被其他函数覆盖,中断将无法跳转。
故障3:编译报错 undefined symbol RCC_APB2PeriphClockCmd
  • 根本原因:FWLIB源文件未加入编译
    检查 Project Options for Target C/C++ Define 中是否定义了 USE_STDPERIPH_DRIVER 。若未定义,SPL头文件中的函数声明将被条件编译剔除。
  • 次要原因:头文件路径错误
    #include "stm32f10x_rcc.h" 找不到,说明 FWLIB/inc/ 未加入 Include Paths ,或路径拼写错误(如 inc 写成 INC ,Windows不敏感但Linux敏感)。

1.7 工程优化与进阶方向

一个合格的工程不应止步于功能实现,还需考虑可维护性与扩展性:

优化1:引入状态机管理LED模式

将流水灯、闪烁、全亮等模式抽象为状态,主循环根据状态机迁移规则切换:

typedef enum {
    LED_MODE_OFF,
    LED_MODE_ON,
    LED_MODE_BLINK,
    LED_MODE_FLOW
} LED_Mode_TypeDef;

static LED_Mode_TypeDef currentMode = LED_MODE_FLOW;
static uint8_t flowIndex = 0;

void LED_Update(void)
{
    static uint32_t lastToggle = 0;
    uint32_t now = Get_SysTick_Count();

    switch (currentMode) {
        case LED_MODE_OFF:
            GPIO_SetBits(GPIOA, GPIO_Pin_All);
            break;
        case LED_MODE_ON:
            GPIO_ResetBits(GPIOA, GPIO_Pin_All);
            break;
        case LED_MODE_BLINK:
            if ((now - lastToggle) >= 250) {
                lastToggle = now;
                GPIO_ToggleBits(GPIOA, GPIO_Pin_0);
            }
            break;
        case LED_MODE_FLOW:
            if ((now - lastToggle) >= 500) {
                lastToggle = now;
                GPIO_SetBits(GPIOA, GPIO_Pin_All);
                GPIO_ResetBits(GPIOA, 1 << flowIndex);
                flowIndex = (flowIndex + 1) % 4;
            }
            break;
    }
}
优化2:使用结构体封装LED硬件信息

解耦硬件定义与逻辑代码,便于移植:

typedef struct {
    GPIO_TypeDef* port;
    uint16_t pin;
    FunctionalState activeState; // ENABLE=低电平点亮,DISABLE=高电平点亮
} LED_TypeDef;

#define LED1 {GPIOA, GPIO_Pin_0, ENABLE}
#define LED2 {GPIOA, GPIO_Pin_1, ENABLE}
#define LED3 {GPIOA, GPIO_Pin_2, ENABLE}
#define LED4 {GPIOA, GPIO_Pin_3, ENABLE}

void LED_On(const LED_TypeDef* led)
{
    if (led->activeState == ENABLE) {
        GPIO_ResetBits(led->port, led->pin);
    } else {
        GPIO_SetBits(led->port, led->pin);
    }
}
进阶方向:迁移到HAL库与FreeRTOS

当项目复杂度提升,SPL的局限性显现(如无USB、无高级定时器PWM生成)。此时应:
- 使用STM32CubeMX生成HAL库工程,配置时钟树、GPIO、SysTick
- 引入FreeRTOS,将LED任务、串口任务、传感器采集任务分别创建为独立任务
- SysTick作为RTOS的 xPortSysTickHandler() 中断源, vTaskDelay() 替代手动计时

这一演进路径,正是从裸机开发迈向实时操作系统开发的标准范式。


在实际项目中,我曾因忽略 SystemCoreClock 未更新,在一个电机控制项目中遭遇PID调节周期漂移——理论1ms采样,实测达12ms,导致电机剧烈抖动。排查三天后发现 system_stm32f10x.c HSEStartUpStatus 判断逻辑有误,始终未能进入PLL配置分支。自此,我养成了每次工程创建后,必在 main() 开头添加 while(SystemCoreClock != 72000000); 进行硬性校验的习惯。技术细节的严谨,永远是嵌入式工程师的第一道护城河。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐