STM32F103C8T6 LED Demo详解:GPIO、SysTick与PWM实现
简介作为基于STM32F103微控制器的演示工程这份RAR压缩包专注于通过HUB08接口实现32×64双色点阵屏的静态显示。点阵屏由红绿LED组成可呈现红、绿、黄等颜色组合32列乘64行的分辨率足以展示复杂文字与简洁图形适合信息发布或状态指示场景。项目源码包含系统时钟和GPIO初始化、定时器PWM亮度控制、显示缓冲区管理、HUB08协议数据收发以及图像刷新函数静态驱动虽占用较多端口但刷新开销低、显示稳定逻辑分层清晰适合正在学习STM32外设编程和LED显示驱动的开发者逐步阅读。包内共74个文件以31个h头文件和30个c源文件为主另有8个汇编启动文件以及Keil工程配置、JLink设置、清理脚本和ReadMe说明文档压缩包整体大小仅273KB目录结构按功能模块划分方便在MDK中直接打开调试。目前已有2011人学习下载该案例完整演示了静态驱动点阵屏的代码框架读者可基于此快速移植到其他分辨率或颜色的点阵模块对嵌入式入门和项目实战都有参考价值。1. 一个点灯Demo背后的三件套时钟、GPIO与调试链路刚接触STM32的人总以为点灯是最简单的事真上手才发现编译不过、下载失败、灯死活不亮这三个坎每一个都能劝退一批人。这份STM32F103C8T6的LED Demo工程包是一个能跑的最小系统样本基于标准外设库包含GPIO输出、SysTick延时、TIM定时器产生PWM呼吸灯这三块基础内容MDK工程打开就能编译用SWD烧到最常见的C8T6最小系统板上就能看到板载LED先闪烁、再呼吸。适合两类人第一次碰STM32、想找个干净模板把手上的板子跑起来的新手以及手头有自绘或淘来的板子、想快速验证引脚和芯片本身有没有问题的开发者。2. 工程结构拆解从解压目录到MDK工程树拿到压缩包先别急着双击工程文件。把目录结构过一遍能看出这份工程的血统、依赖了哪些库、以及哪些文件是你二次开发时要动的。2.1 目录树里藏着谁的影子从Libraries看工程血统解压后的目录大致是这样STM32F103C8_Demo_2020-10-10-LEDA/ ├── Doc/ │ └── readme.txt ├── Libraries/ │ ├── CMSIS/ │ │ ├── core_cm3.c │ │ ├── core_cm3.h │ │ └── Device/ │ │ └── ST/STM32F10x/ │ │ ├── stm32f10x.h │ │ └── system_stm32f10x.c │ └── STM32F10x_StdPeriph_Driver/ │ ├── inc/ │ │ ├── stm32f10x_gpio.h │ │ ├── stm32f10x_rcc.h │ │ ├── stm32f10x_tim.h │ │ └── stm32f10x_usart.h │ └── src/ └── Project/ └── MDK-ARM/ ├── STM32F103C8_Demo_2020-10-10-LEDA.uvprojx └── startup_stm32f10x_md.sUser目录和main.c我没列全但你应该看到关键信息了。Libraries/CMSIS是Cortex-M3内核抽象层core_cm3.h里定义了寄存器结构体、NVIC操作、SysTick相关函数这部分是ARM内核官方的基本上所有F1工程都一样不需要动。下面的Device目录里是ST对STM32F10x系列的设备头文件stm32f10x.h是这个芯片的寄存器定义全集system_stm32f10x.c里是SystemInit时钟初始化函数。再往下的STM32F10x_StdPeriph_Driver就是常说的标准外设库。inc和src分别放头文件与实现工程里用到了GPIO、RCC、TIM、USART这几个外设就会把对应的stm32f10x_gpio.c、stm32f10x_rcc.c加进编译列表。Project/MDK-ARM下的uvprojx是MDK工程文件startup_stm32f10x_md.s是启动文件md表示medium density对应64KB Flash以内的F103C8/CB这颗料选它是对的。2.2 硬件资源与引脚分配先搞清楚默认配置F103C8T6是48脚封装的Cortex-M3主频最高72MHz64KB Flash20KB RAM。这份Demo默认假设的硬件环境是8MHz外部晶振作为HSE板载LED接在PC13引脚调试口用SWD占用PA13/PA14。那颗常见的蓝色最小系统板LED通常就在PC13高电平点亮如果你用的是其他板子极性可能是反的后面第4章会专门讲这件事。引脚分配大致是这样一张表功能引脚说明板载LEDPC13默认高电平点亮低电平熄灭SWD调试PA13/PA14SWDIO与SWCLK不可复用作普通IO系统时钟OSC_IN/OSC_OUT8MHz HSE晶振内部PLL倍频到72MHz如果你准备移植到自己的板子上这张表就是第一个要改的东西。LED不在PC13就改引脚宏晶振不是8MHz就改后面要说的HSE_VALUESWD引脚被占用就要考虑换调试方式。2.3 72MHz时钟链HSE、PLL与总线分频点灯之前必须先把时钟搞对。F103默认上电后用的是内部8MHz RC振荡器HSI经过二分频后的4MHz性能上不去外设也容易出奇怪问题所以工程里会在SystemInit里切换到外部晶振加PLL得到72MHz主频。核心代码长这样void RCC_Config(void) { SystemInit(); /* 复位时钟树并切到HSE */ RCC_DeInit(); /* 恢复默认时钟配置 */ RCC_HSEConfig(RCC_HSE_ON); /* 打开外部晶振 8MHz */ if (RCC_WaitForHSEStartUp() SUCCESS) { /* 72MHz HCLKAPB1最高36MHzAPB2保持72MHz */ RCC_HCLKConfig(RCC_SYSCLK_Div1); /* AHB不分频 */ RCC_PCLK1Config(RCC_HCLK_Div2); /* APB1 36MHz */ RCC_PCLK2Config(RCC_HCLK_Div1); /* APB2 72MHz */ RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9); RCC_PLLCmd(ENABLE); /* 8MHz x 9 72MHz */ while (RCC_GetFlagStatus(RCC_FLAG_PLLRDY) RESET); /* 等待PLL稳定 */ RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); while (RCC_GetSYSCLKSource() ! 0x08); /* 确认时钟源切到PLL */ } }这段代码里有几个点在实际项目中很容易踩。PCLK1是APB1总线时钟挂在上面的有TIM2到TIM5、USART2/3、I2C等外设这路总线最高只能到36MHz所以必须二分频PCLK2上的GPIO、USART1、SPI1可以跑到72MHz。PLL倍频系数选择取决于外部晶振频率8MHz配9倍频正好72MHz如果板子上是12MHz晶振倍频系数就得改成6否则超频到108MHz芯片直接不稳定。2.4 为什么这份工程选标准外设库而不是HALF1系列上现在常见两套驱动库标准外设库和HAL库。这份Demo用的是标准库我拆下来的第一感觉是作者是个老手或者至少参考的是老教程。标准库是对寄存器的直接封装GPIO改个状态就是调用一个函数操作BSRR寄存器代码量小、执行快、丢进RAM里的小芯片也能跑得很舒服HAL库层次多、抽象重配合CubeMX用起来省事但在C8T6这种128KB Flash的芯片上显得臃肿。标准库还有一个对新手友好的特点每一个外设的初始化函数参数都是结构体开时钟、填结构体、调用Init函数这个套路学会之后GPIO、TIM、USART都是一样的流程不会被宏定义的层层包裹绕晕。缺点是没有CubeMX那种图形化配置时钟树和引脚分配得自己在脑子里画清楚其实这对理解F1的架构有好处。3. 核心模块拆解从复位到LED呼吸的完整链路这一章把这份工程里最值得抄的三段代码逐行讲透GPIO点亮、SysTick延时、TIM2产生PWM呼吸灯。看懂它们你就在裸机上拥有了一套完整的定时控制框架。3.1 GPIO点亮最小单元开时钟、填结构体、写电平LED初始化的代码开头一定是一句RCC时钟使能这是新手最容易漏的。F103的外设时钟默认全关GPIO挂在APB2总线上所以点亮PC13前必须先打开GPIOC的时钟void LED_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); /* 开GPIOC时钟 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_13; /* 选择PC13 */ GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; /* 推挽输出 */ GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; /* 翻转速度50MHz */ GPIO_Init(GPIOC, GPIO_InitStructure); /* 应用配置 */ GPIO_SetBits(GPIOC, GPIO_Pin_13); /* 默认高电平点亮 */ }这段代码的逻辑是先使能GPIOC外设时钟再定义GPIO_InitTypeDef结构体并填三个字段最终调用GPIO_Init把配置写进寄存器。GPIO_Pin选择具体引脚GPIO_Mode_Out_PP是推挽输出意味着引脚能主动输出高和低适合驱动LED这种负载如果改成开漏输出高电平还得靠外部上拉电阻逻辑就会反过来。GPIO_Speed_50MHz表示IO翻转频率上限跑LED这种低频信号用2MHz都够但保持50MHz可以兼容后续复用功能。要说玄学我第一次点灯时遇到过配置了推挽输出、调用了SetBits但LED就是微亮的问题。后来量电压发现引脚只有1.8V其实就是GPIO_Speed配的是2MHz加上板子上LED限流电阻较大驱动能力不足。如果是这个现象先把Speed提到50MHz试。3.2 SysTick延时为什么要用定时器而不是空循环工程里点灯是带着节奏的闪烁效果需要毫秒级延时。最简单的做法是for循环空转几万次但这份工程用了SysTick定时器这是Cortex-M3内核自带的24位递减计数器不占额外外设资源static __IO uint32_t TimingDelay 0; void Delay_Init(void) { SystemCoreClock 72000000; /* 告诉内核当前主频 */ SysTick_Config(SystemCoreClock / 1000); /* 每1ms中断一次 */ } void Delay_Ms(uint16_t ms) { TimingDelay ms; while (TimingDelay ! 0); /* 等待递减到0 */ } void SysTick_Handler(void) { if (TimingDelay ! 0) { TimingDelay--; } }SysTick_Config传入的值是计数器的重装载值72,000,000除以1000等于72,000意味着计数器从72,000递减到0正好1毫秒每次归零触发一次中断在中断里把全局变量TimingDelay减一。Delay_Ms做的事很简单把要延时的毫秒数写进TimingDelay然后死等它变成0。这里有个血泪经验实际上Delay_Ms里那个while空转也会被中断打断但SysTick的中断只会执行几条指令所以误差在微秒级。而for循环空转延时完全依赖编译器的优化结果如果开了-O2编译器可能把整个空循环优化掉延时直接变成0灯闪得根本看不清。所以正规一点的工程都会走SysTick或定时器这也是这套代码值得直接抄走的原因。3.3 TIM2产生PWM呼吸灯效果拆解这份工程叫LEDA我猜A是Application的缩写它不止闪灯附带了一个呼吸灯效果。呼吸灯的本质是PWM占空比从0缓慢加到最大再减回0重复循环。PWM由TIM2产生输出通道映射到PA0void LED_Breath_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); /* TIM2在APB1上 */ /* PA0复用为TIM2_CH1 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; /* 复用推挽 */ GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); /* 时基72MHz/(711)/(4991)2000Hz */ TIM_TimeBaseStructure.TIM_Period 499; /* ARR自动重装载值 */ TIM_TimeBaseStructure.TIM_Prescaler 71; /* PSC预分频系数 */ TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); /* PWM模式1占空比初始0 */ TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 0; TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM2, TIM_OCInitStructure); TIM_Cmd(TIM2, ENABLE); }PWM输出频率的计算公式是f 72MHz / ((PSC1) x (ARR1))这里PSC71、ARR499算出来正好是2000Hz人眼完全感知不到闪烁只看到亮度变化。TIM_Pulse就是比较值CCR它决定占空比从0到499。主循环里不断修改CCR就能实现呼吸uint16_t duty 0; int8_t dir 1; while (1) { TIM_SetCompare1(TIM2, duty); duty dir; if (duty 499) dir -1; /* 到达上限开始变暗 */ if (duty 0) dir 1; /* 到0开始变亮 */ Delay_Ms(5); /* 每次变化间隔5ms */ }这套PWM配置在移植时要注意TIM2_CH1的默认引脚是PA0但F103允许重映射到PA15如果工程里开启过GPIO_PinRemapConfig(GPIO_Remap_TIM2, ENABLE)PA0就不输出了。这一行如果被复制到另一个工程里很容易让人怀疑是芯片坏了其实是被重映射劫持了。3.4 main.c的调度顺序裸机工程的骨架把上面这些模块串起来的是main.c里的初始化顺序int main(void) { RCC_Config(); /* 时钟树优先配好 */ Delay_Init(); /* SysTick时基 */ LED_GPIO_Init(); /* 配置PC13为输出 */ LED_Breath_Init(); /* 配置TIM2_PWM */ while (1) { /* 主循环里做占空比变化或翻转LED */ } }这个初始化顺序是有讲究的先配时钟再配时基最后配外设功能。如果你先初始化TIM2再配时钟PWM根本不会跑起来因为时钟没开。裸机工程里中断服务函数只做标志位或计数器的更新真正的逻辑放在主循环中判断这套模式可以延伸到按键消抖、多任务轮询为以后上RTOS留好习惯。4. 移植与避坑手册把Demo挪到自己板子上的翻车实录这一章写给所有想把这套Demo搬到自制板或不同型号板子上的人。我的建议是先看清楚默认配置再动手否则会经历一场玄学级排查。4.1 移植前要做的四件事选型号、换启动文件、改引脚表、核对时钟先把这份工程的默认硬件假设列出来逐项对照你的板子配置项Demo默认你需要确认芯片型号STM32F103C8T6是否同为F103系列启动文件startup_stm32f10x_md.sFlash超过128KB要换hd板载LEDPC13高电平点亮改成你的引脚与电平极性外部晶振8MHz12MHz或没有晶振要改代码调试口SWDPA13/PA14确认没被其他功能占用改型号的动作在MDK里是点魔术棒的Device选项卡选中对应芯片后启动文件要手动保障。F103C8T6是中等容量用md如果是F103VET6这种512KB的大容量芯片md启动文件里的堆栈和向量表虽然能跑但Flash大小和RAM大小对不上会出现烧录后跑飞、莫名进入HardFault的情况这时必须换hd。这是很多开发者第一次移植F1工程翻车的高频原因。引脚改动比较简单把led.c里所有GPIOC、GPIO_Pin_13替换成你板子上的引脚同时RCC_APB2PeriphClockCmd里的时钟宏也要换。建议在led.h里做成宏只改一处#define LED_PORT GPIOC #define LED_PIN GPIO_Pin_13 #define LED_RCC_CLK RCC_APB2Periph_GPIOC4.2 五条翻车记录现象、原因与解法以下五条都是实际调试中会遇到的问题我把现象、原因和解决方法写清楚。现象一编译报错找不到core_cm3.h或者stm32f10x.h原因工程里的Include路径不完整MDK找不到Libraries目录下的头文件。标准库工程对相对路径敏感移动过文件夹就会失效。解决打开魔术棒的C/C选项卡在Include Paths里手动添加三个路径CMSIS核心层目录、Device目录、StdPeriph_Driver的inc目录。我一般会把这些路径改成相对路径这样工程拷到任何目录都不会丢头文件。现象二下载时提示No target connected点完下载按钮没反应原因最常见的是BOOT0跳线帽被拨到了1芯片进入系统存储器模式JTAG/SWD引脚不工作另一种是之前的程序把PA13/PA14复用成了普通IOSWD功能被关掉。解决把BOOT0接3.3V用串口的ISP模式连接芯片执行一次全片擦除然后把BOOT0跳回GND重新上电。没有串口工具的话可以在点击下载的同时按住板子复位键让芯片在复位瞬间强制进入调试模式成功率看运气但值得一试。从那以后我每次画板都强制在原理图上标一句SWD引脚默认不接其他功能。现象三程序下载成功LED却亮不起来用万用表量引脚电压也正常原因LED的极性或者限流电阻位置和Demo默认相反。有的板子LED阳极接3.3V阴极接MCU引脚这种叫低电平点亮你的GPIO_SetBits输出高电平时LED两端压差为0自然不亮。解决看原理图确认负极性逻辑把led.c里的GPIO_SetBits换成GPIO_ResetBits或者更稳妥的做法是在led.h里定义#define LED_ON GPIO_ResetBits(LED_PORT, LED_PIN) #define LED_OFF GPIO_SetBits(LED_PORT, LED_PIN)这样以后切换极性只改宏定义主循环里的业务代码不用动。现象四用12MHz晶振的板子跑这份工程点灯正常但PWM频率明显不对呼吸节奏诡异原因工程默认8MHz外部晶振12MHz晶振经过PLL9倍频后得到108MHz主频超出F103规格SysTick的重装载值也按72MHz计算所有时间参数全部漂移。解决改stm32f10x.h里的HSE_VALUE宏#define HSE_VALUE 12000000U /* 原来默认是8000000U */然后把RCC_PLLConfig的倍频系数从9改成6让12MHz x 6 72MHz。这个宏不只是在时钟配置里用到库函数里大量计算都引用它漏改一处就会出现个例问题。现象五加入呼吸灯代码后原来的闪烁功能变成亮一下就再也不动原因这类问题多半出在TIM_SetCompare1传参越界或者TIM2中断服务函数与SysTick_Handler冲突。比较值超过ARR上限499时输出会一直高电平灯看起来就像常亮。解决把占空比变量限制在0到TIM_Period范围内并在修改CCR前关一下全局中断本质上就是给共享变量加保护__disable_irq(); TIM_SetCompare1(TIM2, duty); __enable_irq();5. 二次开发把点灯脚本改造成可配置LED状态机拿到这份Demo只用来闪灯太可惜了这套工程底子稍加改造就能变成产品里的LED指示模块。我的习惯是把LED行为抽象成结构体而不是到处散落GPIO_SetBits调用。先定义LED实例结构体把端口、引脚、电平极性、工作模式都装进去typedef struct { GPIO_TypeDef *port; /* LED所在GPIO组如GPIOC */ uint16_t pin; /* 引脚号如GPIO_Pin_13 */ uint16_t active_level; /* 点亮电平0为低电平点亮 */ uint8_t mode; /* 0:常灭 1:常亮 2:闪烁 3:呼吸 */ uint16_t period_ms; /* 闪烁周期或呼吸步进间隔 */ uint16_t duty; /* 当前比较值 */ uint8_t dir; /* 呼吸方向1变亮-1变暗 */ } led_instance_t;然后写一个统一的LED_Update函数在main循环里以Tick为节拍调用它根据mode字段分派逻辑void LED_Update(led_instance_t *led, uint32_t tick) { switch (led-mode) { case 2: /* 闪烁 */ if ((tick / led-period_ms) % 2) LED_SetLevel(led, led-active_level); else LED_SetLevel(led, !led-active_level); break; case 3: /* 呼吸 */ led-duty led-dir; if (led-duty 499) led-dir -1; if (led-duty 0) led-dir 1; TIM_SetCompare1(TIM2, led-duty); break; default: break; } }这样改造之后你要加第二个LED灯只需要再实例化一个led_instance_t传入不同的端口和引脚不需要改任何驱动代码。验证方法也很直接把闪烁周期改成300ms肉眼观察节奏是否符合预期把引脚宏切到PA0确认重映射后的PWM输出位置正确。关键是把状态迁移和硬件操作分层中断里只更新Tick主循环里调Update。从那以后我每次拿到新板子第一件事不是写点灯而是强制自己先画一张引脚-外设分配表把电平极性和复用功能写清楚再动手。这份Demo包里的坑我基本全踩过一遍写成上面这段希望帮到你。本文还有配套的精品资源点击获取