=======《 万事开头难》=======

在嵌入式开发的世界里,STM32 是一个绕不开的名字,而正点原子 则是无数中国开发者入门的“领路人”。最近,我从杂物箱底翻出了一块吃灰多年的“战损版”正点原子战舰V3开发板。屏幕碎了,螺丝生锈了,但当我接通电源,那颗红色的电源指示灯依然亮起时,突然有种莫名的感动。

这么多年过去了,它竟然还能用。

这或许就是硬件工程师的浪漫:只要代码不错,硬件不坏,它就永远忠于你的逻辑。今天,我不想让它继续吃灰,决定用它来写下第一个程序——点亮LED指示灯。这不仅是嵌入式世界的“Hello World”,也是对这块老将的一次致敬。




一、 准备工作:唤醒沉睡的“战舰”
虽然我的板子是战损成色,但正点原子的设计质量确实过硬。如果你手头有任何型号的STM32F103开发板(无论是战舰、精英还是Mini),都可以按照今天的步骤操作。

我们需要准备
硬件:
  • STM32F103 开发板(正点原子系列均可,我的是战舰V3)。
  • ST-Link 或 J-Link 仿真器/下载器(用于烧录程序)。
  • Micro USB 线(用于供电或串口通信,供电也可以用配套的电源直接供电)。

软件:
  • 开发环境: Keil MDK(或者STM32CubeIDE,这里我们使用Keil MDK)。点击这里进入官方下载页面。
  • 固件库: STM32F1xx的标准外设库(或者HAL库,今天我们以标准库为例,这也是正点原子教程的经典配置)。
  • 参考资料: 开发板的原理图PDF(这是最重要的,后面找引脚全靠它)。
软件的具体安装步骤这里就直接略过,如果你安装的是版本比较高比如Version 5.43a,可能会出现下面的对话框:

上面的名称类似于这种:D:\Users\Administrator\AppData\Local\Keil_v5\ARM\cmsis-toolbox\bin\cpackget.exe
它的任务是在后台帮你自动下载和安装各种芯片厂商(如ST、NXP)的支持包。如果下载特别慢的话,可以直接关掉,不影响软件的正常安装。不过不是什么特殊的原因尽量不要关闭,后续安装也比较麻烦。
安装完成后打开软件时出现下面的错误提示:


这个错误提示 >>> TOOLS.INI - Section '[ARM]': missing 'PATH' entry ! <<< 说明 Keil 安装目录下的 TOOLS.INI 配置文件出了问题,它里面关于 ARM 编译器的路径信息丢失了或格式不对。

解决方法:手动修复 TOOLS.INI 文件

打开你的 Keil 安装根目录,通常默认路径是:
C:\Users\Administrator\AppData\Local\Keil_v5 或
D:\Users\Administrator\AppData\Local\Keil_v5。

在该文件夹下,找到名为 TOOLS.INI 的文件。
备份文件(重要)
在修改前,建议你复制一份这个文件,粘贴到桌面或文件夹里作为备份。万一改错了可以还原。
用记事本打开并编辑
右键点击 TOOLS.INI,选择用记事本打开。

[UV2]
ORGANIZATION="1"
NAME="1", "1"
EMAIL="1"
ARMSEL=1
USERTE=1
TOOL_VARIANT=mdk_std
RTEPATH="D:\Users\Administrator\AppData\Local\Arm\Packs"
CMSIS_TOOLBOX=D:\Users\Administrator\AppData\Local\Keil_v5\ARM\cmsis-toolbox
[ARM]
LIC0=X96CK-ZI184-BSAYX-HHI0A-N30T8-50MF7

在文件中找到 [ARM] 开头的这一段。如果找不到,也可以直接拉到文件末尾附近看看。
检查并修改:确保 [ARM] 下面有一行 PATH 语句,并且路径指向你的 ARM 编译器文件夹。
正确的格式应该是:PATH="D:\Users\Administrator\AppData\Local\Keil_v5"(请将路径替换为你自己的安装路径)。
举个例子,如果你的 Keil 安装在 D 盘,这一段看起来应该是这样:

[UV2]
ORGANIZATION="1"
NAME="1", "1"
EMAIL="1"
ARMSEL=1
USERTE=1
TOOL_VARIANT=mdk_std
RTEPATH="D:\Users\Administrator\AppData\Local\Arm\Packs"
CMSIS_TOOLBOX=D:\Users\Administrator\AppData\Local\Keil_v5\ARM\cmsis-toolbox
[ARM]
PATH="D:\Users\Administrator\AppData\Local\Keil_v5"
VERSION=5.43a
LIC0=X96CK-ZI184-BSAYX-HHI0A-N30T8-50MF7

删除多余的配置节(如果有)
有些第三方软件(如某些仿真工具)可能会在文件末尾或别处添加一个错误的配置节,比如 [KARM] 或 [undefined toolset],这也会导致冲突。
如果看到这些不属于 [ARM] 或 [C51] 的奇怪段落,可以放心地将其整段(从[到下一空行前)删除。
保存并重启 Keil
点击记事本的 文件 -> 保存,然后关闭记事本。
再次双击打开 Keil uVision,错误提示应该就消失了。
随便打开一个项目测试时,编译提示:
Rebuild started: Project: PodControlGroundEnd
*** Target 'Target 1' uses ARM-Compiler 'Default Compiler Version 5' which is not available.
*** Please review the installed ARM Compiler Versions:
   'Manage Project Items - Folders/Extensions' to manage ARM Compiler Versions.
   'Options for Target - Target' to select an ARM Compiler Version for the target.
*** Rebuild aborted.
Build Time Elapsed:  00:00:00
这部分处理方法由于有太多图片,放到这里就分不清主次了,放到了另一个链接里,点击这里查看。

二、 硬件分析:LED在哪里?
任何程序的第一步,都不是写代码,而是看原理图。
打开正点原子战舰V3的原理图,找到LED相关的部分。你会发现,板上通常会集成两个LED:
  • DS0: 通常连接在 PB5 引脚上。
  • DS1: 通常连接在 PE5 引脚上。

关键点分析:
查看电路连接,我们会发现这些LED的另一端是接在 VCC 3.3V 上的。这意味着什么呢?
  • 如果单片机引脚输出 低电平(0V),电流从VCC流过LED到达引脚,形成回路,灯亮。
  • 如果单片机引脚输出 高电平(3.3V),两端电压相等,灯灭。
  • 这就是常说的“低电平有效”。弄清楚这个逻辑,代码的方向就不会错。

三、 动手写码:三种方式实现“点灯”
在STM32开发中,我们通常有三种方式操作寄存器:寄存器操作标准库操作 HAL库操作。为了让你更全面地理解,我们分别看一下标准库和寄存器的实现方法。

方法一:标准外设库(最推荐新手)
这是正点原子教程中最经典的方式。库函数将底层的寄存器操作封装成了具有良好可读性的函数。
第一步:创建LED驱动文件
在工程目录的 HARDWARE 文件夹下,新建 gpio.c 和 gpio.h。
gpio.h代码:
#ifndef _GPIO_H_
#define _GPIO_H_

#include"stm32f10x_gpio.h"

#define PA1 GPIOA->BSRR
#define PA0 GPIOA->BRR

#define GPIOA_ODR_A (GPIOA_BASE+0x0c)
#define GPIOA_IDR_A	(GPIOA_BASE+0x08)

#define GPIOB_ODR_A (GPIOB_BASE+0x0c)
#define GPIOB_IDR_A	(GPIOB_BASE+0x08)

#define GPIOC_ODR_A (GPIOC_BASE+0x0c)
#define GPIOC_IDR_A	(GPIOC_BASE+0x08)

#define GPIOD_ODR_A (GPIOD_BASE+0x0c)
#define GPIOD_IDR_A	(GPIOD_BASE+0x08)

#define GPIOE_ODR_A (GPIOE_BASE+0x0c)
#define GPIOE_IDR_A	(GPIOE_BASE+0x08)

#define GPIOF_ODR_A (GPIOF_BASE+0x0c)
#define GPIOF_IDR_A	(GPIOF_BASE+0x08)

#define GPIOG_ODR_A (GPIOG_BASE+0x0c)
#define GPIOG_IDR_A	(GPIOG_BASE+0x08)

#define BitBand(Addr,BitNum)    *((volatile unsigned long *)((Addr&0xF0000000)+0x2000000+((Addr&0xfffff)<<5)+(BitNum<<2)))

#define PAout(n) BitBand(GPIOA_ODR_A,n)
#define PAin(n) BitBand(GPIOA_IDR_A,n)

#define PBout(n) BitBand(GPIOB_ODR_A,n)
#define PBin(n) BitBand(GPIOB_IDR_A,n)

#define PCout(n) BitBand(GPIOC_ODR_A,n)
#define PCin(n) BitBand(GPIOC_IDR_A,n)

#define PDout(n) BitBand(GPIOD_ODR_A,n)
#define PDin(n) BitBand(GPIOD_IDR_A,n)

#define PEout(n) BitBand(GPIOE_ODR_A,n)
#define PEin(n) BitBand(GPIOE_IDR_A,n)

#define PFout(n) BitBand(GPIOF_ODR_A,n)
#define PFin(n) BitBand(GPIOF_IDR_A,n)

#define PGout(n) BitBand(GPIOG_ODR_A,n)
#define PGin(n) BitBand(GPIOG_IDR_A,n)

// 引脚别名定义,方便调用
#define LED0 PBout(5)  // DS0 连接 PB5
#define LED1 PEout(5)  // DS1 连接 PE5

void LED_Init(void);

#endif
gpio.c代码:
#include"gpio.h"

void LED_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    
    // 1. 打开时钟:GPIOB和GPIOE是挂载在APB2总线上的
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_GPIOE, ENABLE);
    
    // 2. 配置 PB5
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;    // 推挽输出
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;   // 速度50MHz
    GPIO_Init(GPIOB, &GPIO_InitStructure);
    
    // 3. 配置 PE5
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5;
    GPIO_Init(GPIOE, &GPIO_InitStructure);
    
    // 4. 默认熄灭所有LED(因为低电平亮,所以给高电平就灭了)
    GPIO_SetBits(GPIOB, GPIO_Pin_5);
    GPIO_SetBits(GPIOE, GPIO_Pin_5);
}
新建 delay.c 和 delay.h。
delay.h代码:
#ifndef __DELAY_H
#define __DELAY_H 			   
#include "gpio.h"  
	 
void delay_init(void);
void delay_ms(u16 nms);
void delay_us(u32 nus);

#endif
delay.c代码:
#include "delay.h"

static u8  fac_us=0;							//us延时倍乘数			   
static u16 fac_ms=0;							//ms延时倍乘数,在ucos下,代表每个节拍的ms数
	
//初始化延迟函数
//当使用OS的时候,此函数会初始化OS的时钟节拍
//SYSTICK的时钟固定为HCLK时钟的1/8
//SYSCLK:系统时钟
void delay_init()
{
	SysTick_CLKSourceConfig(SysTick_CLKSource_HCLK_Div8);	//选择外部时钟  HCLK/8
	fac_us=SystemCoreClock/8000000;				//为系统时钟的1/8  
	fac_ms=(u16)fac_us*1000;					//每个ms需要的systick时钟数   
}								    

//延时nus
//nus为要延时的us数.		    								   
void delay_us(u32 nus)
{		
	u32 temp;	    	 
	SysTick->LOAD=nus*fac_us; 					//时间加载	  		 
	SysTick->VAL=0x00;        					//清空计数器
	SysTick->CTRL|=SysTick_CTRL_ENABLE_Msk ;	//开始倒数	  
	do
	{
		temp=SysTick->CTRL;
	}while((temp&0x01)&&!(temp&(1<<16)));		//等待时间到达   
	SysTick->CTRL&=~SysTick_CTRL_ENABLE_Msk;	//关闭计数器
	SysTick->VAL =0X00;      					 //清空计数器	 
}
//延时nms
//注意nms的范围
//SysTick->LOAD为24位寄存器,所以,最大延时为:
//nms<=0xffffff*8*1000/SYSCLK
//SYSCLK单位为Hz,nms单位为ms
//对72M条件下,nms<=1864 
void delay_ms(u16 nms)
{	 		  	  
	u32 temp;		   
	SysTick->LOAD=(u32)nms*fac_ms;				//时间加载(SysTick->LOAD为24bit)
	SysTick->VAL =0x00;							//清空计数器
	SysTick->CTRL|=SysTick_CTRL_ENABLE_Msk ;	//开始倒数  
	do
	{
		temp=SysTick->CTRL;
	}while((temp&0x01)&&!(temp&(1<<16)));		//等待时间到达   
	SysTick->CTRL&=~SysTick_CTRL_ENABLE_Msk;	//关闭计数器
	SysTick->VAL =0X00;       					//清空计数器	  	    
} 
主函数 main.c
代码:——实现交替闪烁:
#include "delay.h"
#include "gpio.h"
int main(void)
{
    delay_init();       // 初始化延时函数
    LED_Init();         // 初始化LED引脚
    
    while(1)
    {
        // 效果:LED0亮,LED1灭
        GPIO_ResetBits(GPIOB, GPIO_Pin_5);  // PB5 置低电平,LED0亮
        GPIO_SetBits(GPIOE, GPIO_Pin_5);    // PE5 置高电平,LED1灭
        delay_ms(300);                      // 延时300ms
        
        // 效果:LED0灭,LED1亮
        GPIO_SetBits(GPIOB, GPIO_Pin_5);    // PB5 置高电平,LED0灭
        GPIO_ResetBits(GPIOE, GPIO_Pin_5);  // PE5 置低电平,LED1亮
        delay_ms(300);
    }
}
或用位绑定(位带)方式实现
#include "delay.h"
#include "gpio.h"
 int main(void)
 {	
	delay_init();	    //延时函数初始化	  
	LED_Init();		  	//初始化与LED连接的硬件接口
	while(1)
	{
		LED0=0;
		LED1=1;
		delay_ms(300);	 //延时300ms
		LED0=1;
		LED1=0;
		delay_ms(300);	//延时300ms
	}
 }

程序编写好后,接下来就是将程序下载进开发板。这里用STLINK下载,用到四根线2、4、7、9。下面这个引脚定义图,纯手工绘制。

这里供电是有USB供电,所以STILINK中的3.3V就没有连接,完全有USB供电。GND取的是离其他线最近的一个,三根线放到一起,这样连接会比较牢固,不易脱落,实际连接的引脚是7、8、9。

编译下载后,你将看到两个LED开始交替闪烁。恭喜!你已经迈出了嵌入式开发的第一步。

方法二:寄存器操作(了解底层)
如果你想知道库函数内部到底发生了什么,可以直接操作寄存器。
初始化代码(寄存器版):
#include"gpio.h"
void LED_Init(void)
{
    // 1. 使能时钟:RCC_APB2ENR 寄存器的第3位是GPIOB,第6位是GPIOE
    RCC->APB2ENR |= (1 << 3);   // 使能PORTB时钟
    RCC->APB2ENR |= (1 << 6);   // 使能PORTE时钟
    
    // 2. 配置GPIO模式:CRL寄存器控制低8位引脚
    // PB5 对应 CRL 的 20-23 位 (5 * 4 = 20)
    GPIOB->CRL &= ~(0xF << 20); // 先清除这4位
    GPIOB->CRL |= (3 << 20);    // 设置为通用推挽输出,50MHz (0011)
    
    // PE5 同理
    GPIOE->CRL &= ~(0xF << 20);
    GPIOE->CRL |= (3 << 20);
    
    // 3. 默认灭灯:通过 BSRR 寄存器或 ODR 寄存器
    GPIOB->BSRR = (1 << 5);     // 置高,灭灯
    GPIOE->BSRR = (1 << 5);
}
虽然寄存器代码看起来晦涩,但执行效率最高,能让你深刻理解《STM32参考手册》中那些寄存器的含义。

四、 进阶思考:从“点灯”到“优雅点灯”
灯亮了吗?亮了。但这只是开始。在实际的项目开发中,我们很少会直接在 main 函数的 while(1) 里写死延时,因为 delay_ms() 会阻塞CPU,在这500ms里,CPU什么也干不了。

如果你想让你的程序看起来更“工业级”,可以尝试以下进阶玩法:

  • 使用定时器: 利用STM32的SysTick定时器或者通用定时器,产生一个10ms或1ms的中断。在中断中设置标志位,主循环查询标志位进行翻转。这样CPU在延时期间可以去处理其他任务。

  • 引入状态机: 不要再用 GPIO_SetBits 和 GPIO_ResetBits 直接控制。定义一个 LED_State 变量,在定时中断里根据状态改变电平。这种思想在复杂的通信协议解析中非常实用。

  • 尝试HAL库: 现在ST主推HAL库。使用STM32CubeMX工具,图形化配置时钟和引脚,直接生成代码。你只需要在 while(1) 里调用 HAL_GPIO_TogglePin 函数即可。代码的移植性会大大增强。

五、 总结
看着这块屏幕碎裂但依旧坚挺的战舰V3,不禁感慨。虽然现在STM32G4、H7系列早已成为主流,开发工具也从Keil逐渐过渡到CubeIDE,但Cortex-M3内核的F103依然是无数工程师心中的“经典”。通过点亮第一个LED,我们不仅学会了GPIO的输出控制,更重温了当初那份对底层硬件的探索热情。

对于嵌入式初学者来说,不要嫌弃开发板老旧,能跑通代码、能理解逻辑的板子就是好板子。如果你的手头也有这么一块吃灰的板子,不妨拿出来,接通电源,让它为你再闪烁一次。