简介:这份STM32F103C8T6 HAL库工程模板面向嵌入式初学者与需要快速搭建开发框架的工程师,基于ARM Cortex-M3内核的入门级芯片,解决从零编写底层驱动耗时费力的问题。压缩包共379个文件,约13.07MB,以h头文件、c源文件、o目标文件、d依赖文件及crf交叉引用文件为主,另含uvprojx工程文件、sct分散加载脚本、hex烧录文件与axf可执行文件,覆盖编译、链接到下载的完整流程。工程按CORE、HALIB、OBJ、PRO、SYSTEM、USER等目录组织,分别承载启动文件与中断向量、GPIO/定时器/串口/ADC/SPI等外设驱动、编译产物、工程配置、系统时钟初始化以及用户应用代码,结构清晰便于按模块查阅。目前已有5213人学习下载,读者可直接在此基础上配置时钟与外设、编写业务逻辑,省去基础框架搭建,并借助HAL库良好的移植性在更换芯片型号时仅需少量改动,适合作为STM32入门与项目起步的参考模板。
1. STM32F103C8T6 HAL库工程模板:为什么你每次新建工程都在重复造轮子
手里这块蓝板子(Blue Pill)大概是很多人嵌入式生涯的起点。STM32F103C8T6,72MHz主频,64KB Flash,20KB RAM,价格便宜量又足。但真正让人头疼的从来不是写业务代码,而是每次新建工程时那一堆重复劳动:时钟树配置、外设初始化、中断优先级分组、串口重定向、延时函数……一套流程走下来,半小时没了,还容易漏配某个选项导致玄学问题。
HAL库工程模板要解决的就是这件事。它不是某个具体项目,而是一套可复用的工程骨架——把MCU启动、时钟配置、基础外设初始化、调试串口、SysTick延时这些每个工程都需要的部分固化下来,新建项目时直接复制粘贴,改改外设参数就能开工。适合所有用STM32F103C8T6做产品原型、课程设计、小批量硬件的开发者,尤其是那些不想在CubeMX和Keil之间反复横跳的人。
我见过太多人每次新建工程都从零开始点CubeMX,或者直接拿别人的工程改,改到最后自己都不知道时钟配的是多少。一个稳定的模板,价值不在于技术多高深,而在于让你把精力花在真正的业务逻辑上,而不是重复配置寄存器。
2. 从CubeMX到Keil:一个能直接复用的工程骨架怎么搭
2.1 为什么选HAL库而不是标准库或LL库
STM32F103C8T6的代码库选择,本质上是在开发效率和运行效率之间做权衡。标准库(Standard Peripheral Library)已经停止维护,ST官方主推HAL和LL。LL库更接近寄存器,代码效率高,但可移植性差,换型号基本要重写。HAL库抽象层次高,代码体积大一些,但跨系列移植方便,配合CubeMX可以图形化配置,对新手友好。
对于F103C8T6这种资源有限的芯片,有人担心HAL库太臃肿。实际测下来,一个带串口和GPIO的HAL工程,Flash占用大概12KB左右,RAM占用3KB出头,剩下的空间足够跑大多数应用。如果实在在意效率,可以在CubeMX里把不用的外设全部关掉,HAL库只会编译用到的部分。
我一般会这样选:产品原型和课程设计用HAL,对时序要求极严的场合(比如软件模拟高速SPI)才考虑LL或直接寄存器。模板工程默认用HAL,但保留LL的接入点。
2.2 用CubeMX生成基础工程:时钟树和外设的最小配置
先明确目标:一个能跑、能串口打印、能点灯、能延时的最小工程。打开CubeMX,选STM32F103C8Tx,开始配置。
第一步是RCC设置。在System Core里找到RCC,High Speed Clock选Crystal/Ceramic Resonator。F103C8T6板子外部一般焊的是8MHz晶振,所以HSE选晶振。LSE不用管,除非你要用RTC。
第二步是时钟树。在Clock Configuration标签页,输入源选HSE,PLL Source选HSE,PLL Mul选9倍频,这样8MHz × 9 = 72MHz。AHB Prescaler不分频,APB1 Prescaler选2分频(36MHz),APB2 Prescaler不分频(72MHz)。注意APB1最大只能到36MHz,超了会挂。
第三步是调试接口。在System Core里找到SYS,Debug选Serial Wire。这样SWD接口能用,PA13和PA14不会被占用为普通GPIO。
第四步是GPIO。板子上PC13一般接LED,配置为GPIO_Output,初始电平High(LED灭)。再配一个串口,USART1,异步模式,波特率115200,引脚PA9和PA10。
第五步是工程管理。Project Manager里,Toolchain/IDE选MDK-ARM V5,Code Generator里勾选“Generate peripheral initialization as a pair of .c/.h files per peripheral”,这样每个外设的初始化代码会单独成文件,方便管理。
生成代码后,目录结构大概是这样的:
Core/ Inc/ main.h stm32f1xx_hal_conf.h stm32f1xx_it.h gpio.h usart.h Src/ main.c stm32f1xx_hal_msp.c stm32f1xx_it.c gpio.c usart.c system_stm32f1xx.c Drivers/ STM32F1xx_HAL_Driver/ CMSIS/ MDK-ARM/ project.uvprojx这个结构就是模板的雏形。但CubeMX生成的代码每次重新生成都会覆盖main.c里的用户代码,所以需要做一层隔离。
2.3 把用户代码和CubeMX生成代码隔离开
CubeMX生成的main.c里,用户代码必须写在/* USER CODE BEGIN */和/* USER CODE END */之间,否则重新生成时会被抹掉。但即便如此,main.c里还是会混着大量初始化代码,看着乱。
我的做法是:main.c只保留最核心的流程,把业务逻辑拆到单独的app文件里。具体操作:
在Core/Inc下新建app.h,Core/Src下新建app.c。app.c里写一个App_Init()和一个App_Loop(),main.c里在USER CODE BEGIN 2处调用App_Init(),在while(1)里调用App_Loop()。
/* app.h */ #ifndef __APP_H #define __APP_H #include "main.h" void App_Init(void); void App_Loop(void); #endif/* app.c */ #include "app.h" #include "usart.h" #include "gpio.h" void App_Init(void) { // 这里放用户初始化,比如打印版本信息 printf("App init done.\r\n"); } void App_Loop(void) { // 主循环逻辑 HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); HAL_Delay(500); }main.c里只需要加两行调用:
/* USER CODE BEGIN 2 */ App_Init(); /* USER CODE END 2 */ while (1) { /* USER CODE BEGIN 3 */ App_Loop(); /* USER CODE END 3 */ }这样CubeMX重新生成代码时,app.c和app.h完全不受影响,main.c里的调用也不会被覆盖。逻辑说明:App_Init负责一次性初始化,App_Loop负责循环任务。参数说明:HAL_Delay的单位是毫秒,依赖SysTick中断,默认1ms中断一次。
2.4 串口重定向和printf的三种实现方式
调试串口是模板的刚需。HAL库默认没有printf,需要自己重定向fputc。常见做法有三种:
第一种是重写fputc,用HAL_UART_Transmit阻塞发送。简单,但发送时CPU空转。
/* 在usart.c末尾添加 */ #include <stdio.h> #ifdef __GNUC__ int __io_putchar(int ch) #else int fputc(int ch, FILE *f) #endif { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; }第二种是用DMA发送,不阻塞CPU,但需要处理发送完成中断和缓冲区管理。适合大量数据输出。
第三种是用环形缓冲区加中断,最灵活,但代码量最大。
模板里我一般先用第一种,够用且稳定。等真有性能需求再换。注意Keil里要勾选“Use MicroLIB”,否则printf会卡死。MicroLIB是Keil的精简C库,对嵌入式更友好。
参数说明:HAL_MAX_DELAY表示无限等待,实际项目中建议改成具体超时值,比如100ms,避免串口故障时死等。
3. 模板里必须固化的五个基础模块
3.1 SysTick延时:HAL_Delay的精度和替代方案
HAL库默认用SysTick做1ms时基,HAL_Delay就是基于这个实现的。但HAL_Delay有个坑:它依赖中断,如果在中断里调用HAL_Delay,且中断优先级高于SysTick,就会死锁。另外HAL_Delay是忙等,虽然期间可以响应其他中断,但CPU利用率不高。
如果需要更精确的延时,可以用DWT(Data Watchpoint and Trace)单元做微秒级延时。F103C8T6支持DWT,初始化代码如下:
/* 在app.c里添加微秒延时 */ void Delay_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } void Delay_Us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); }逻辑说明:DWT->CYCCNT是一个自由运行的32位计数器,每CPU周期加1。SystemCoreClock是当前系统时钟,72MHz时每微秒72个周期。参数说明:Delay_Us的入参单位是微秒,最大延时受计数器溢出限制,72MHz下约59秒溢出一次。
注意:DWT在调试器连接时才能用,脱机运行可能不工作。所以模板里保留HAL_Delay作为通用延时,DWT延时作为可选补充。
3.2 串口环形缓冲区:中断接收不丢数据的关键
HAL_UART_Receive_IT一次只能接收指定长度的数据,收完就停。实际项目中串口数据是流式的,长度不定,所以需要环形缓冲区。
实现思路:定义一个缓冲区数组和读写指针,串口中断里把收到的字节写入缓冲区并移动写指针,主循环里从缓冲区读取并移动读指针。关键代码如下:
/* ringbuf.h */ #ifndef __RINGBUF_H #define __RINGBUF_H #include <stdint.h> #define RINGBUF_SIZE 256 typedef struct { uint8_t buffer[RINGBUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } RingBuf_t; void RingBuf_Init(RingBuf_t *rb); int RingBuf_Put(RingBuf_t *rb, uint8_t data); int RingBuf_Get(RingBuf_t *rb, uint8_t *data); uint16_t RingBuf_Available(RingBuf_t *rb); #endif/* ringbuf.c */ #include "ringbuf.h" void RingBuf_Init(RingBuf_t *rb) { rb->head = 0; rb->tail = 0; } int RingBuf_Put(RingBuf_t *rb, uint8_t data) { uint16_t next = (rb->head + 1) % RINGBUF_SIZE; if (next == rb->tail) { return -1; // 缓冲区满 } rb->buffer[rb->head] = data; rb->head = next; return 0; } int RingBuf_Get(RingBuf_t *rb, uint8_t *data) { if (rb->head == rb->tail) { return -1; // 缓冲区空 } *data = rb->buffer[rb->tail]; rb->tail = (rb->tail + 1) % RINGBUF_SIZE; return 0; } uint16_t RingBuf_Available(RingBuf_t *rb) { return (rb->head - rb->tail + RINGBUF_SIZE) % RINGBUF_SIZE; }逻辑说明:head是写指针,tail是读指针,缓冲区满时牺牲一个字节空间来区分空和满。volatile修饰head和tail,因为它们在中断和主循环里都会被访问。参数说明:RINGBUF_SIZE必须是2的幂或至少大于最大突发数据量,256字节对大多数串口命令足够。
在串口中断回调里调用RingBuf_Put:
/* 在usart.c里添加 */ extern RingBuf_t uart1_rb; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { RingBuf_Put(&uart1_rb, rx_byte); HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } }注意:HAL_UART_Receive_IT每次只收一个字节,收完在回调里重新启动。这种方式中断频繁,115200波特率下每字节约87微秒一次中断,CPU负担可接受。如果波特率更高,建议用DMA加空闲中断。
3.3 GPIO初始化模板:输入输出和中断的标准化写法
GPIO配置在CubeMX里点几下就行,但代码里怎么组织有讲究。模板里把GPIO初始化统一放在gpio.c,每个引脚用宏定义别名,方便后期改板。
/* gpio.h */ #define LED_PIN GPIO_PIN_13 #define LED_PORT GPIOC #define KEY_PIN GPIO_PIN_0 #define KEY_PORT GPIOA #define LED_ON() HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_RESET) #define LED_OFF() HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_SET) #define LED_TOGGLE() HAL_GPIO_TogglePin(LED_PORT, LED_PIN) #define KEY_READ() HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN)逻辑说明:宏定义把硬件细节封装起来,换板子时只改宏,不改业务代码。参数说明:LED低电平点亮,所以LED_ON是RESET。按键按下为低电平,KEY_READ返回GPIO_PIN_RESET表示按下。
外部中断的配置在CubeMX里把引脚设为GPIO_EXTI模式,使能NVIC中断。中断回调里做消抖:
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == KEY_PIN) { static uint32_t last_tick = 0; uint32_t now = HAL_GetTick(); if (now - last_tick > 20) { // 20ms消抖 // 按键处理 last_tick = now; } } }注意:HAL_GetTick返回的是SysTick计数,单位毫秒。消抖时间根据按键特性调整,一般10到50ms。
3.4 中断优先级分组:为什么默认配置会埋雷
Cortex-M3的NVIC支持16级优先级,但STM32F1只用了高4位,所以实际优先级是0到15。HAL库默认用NVIC_PRIORITYGROUP_4,即4位全部用于抢占优先级,没有子优先级。
这个配置下,如果两个中断抢占优先级相同,它们不能互相打断。但如果有中断里调用了HAL_Delay,而SysTick优先级更低,就会死锁。所以模板里必须明确优先级分配。
我的分配原则:
- SysTick:优先级15(最低),保证不影响其他中断
- 串口:优先级5
- 外部中断:优先级6
- DMA:优先级4
在CubeMX的NVIC配置里手动改,或者在代码里用HAL_NVIC_SetPriority设置。注意:FreeRTOS里SysTick和PendSV必须设为最低优先级,否则任务调度会出问题。
提示:如果用了FreeRTOS,中断优先级分组要改成NVIC_PRIORITYGROUP_4,且所有调用FreeRTOS API的中断优先级必须高于configMAX_SYSCALL_INTERRUPT_PRIORITY。
3.5 看门狗和低功耗:模板里预留的扩展点
产品级工程需要看门狗。F103C8T6有独立看门狗(IWDG)和窗口看门狗(WWDG)。IWDG用内部40kHz RC振荡器,喂狗时间1到26秒可调。模板里预留IWDG初始化,但默认不启动,需要时在App_Init里调用。
/* 在app.c里添加 */ void IWDG_Init(uint32_t timeout_ms) { // 40kHz时钟,预分频64,则计数频率625Hz // timeout_ms = reload / 625 * 1000 uint32_t reload = timeout_ms * 625 / 1000; hiwdg.Instance = IWDG; hiwdg.Init.Prescaler = IWDG_PRESCALER_64; hiwdg.Init.Reload = reload; HAL_IWDG_Init(&hiwdg); } void IWDG_Feed(void) { HAL_IWDG_Refresh(&hiwdg); }逻辑说明:IWDG一旦启动就不能停止,所以调试时先不启动。参数说明:timeout_ms是喂狗超时时间,实际项目中设为正常循环周期的2到3倍。喂狗放在主循环里,如果某个任务卡死导致主循环不执行,看门狗就会复位。
低功耗方面,F103C8T6支持Sleep、Stop、Standby三种模式。模板里预留进入Stop模式的函数,唤醒源用外部中断或RTC。但低功耗和调试不能同时进行,所以默认不启用。
4. 避坑指南:新建工程时最容易翻车的五个地方
4.1 时钟配置错误导致串口乱码
现象:串口打印出来全是乱码,或者干脆没输出。
原因:CubeMX里时钟树配错了,比如HSE没起振但代码里选了HSE,或者PLL倍频算错导致系统时钟不是72MHz。还有一种情况是串口波特率计算依赖APB时钟,APB1和APB2的分频系数搞混了。
解决:先用示波器或逻辑分析仪测PA8(MCO)输出的时钟,确认系统时钟正确。MCO配置在RCC里,选SYSCLK输出。如果MCO没有输出,说明时钟没配好。另外检查SystemCoreClock变量的值,在调试器里看是不是72000000。串口乱码时,先确认波特率是否匹配,再确认APB时钟是否正确。
4.2 中断优先级冲突导致程序卡死
现象:程序跑着跑着就卡在某个中断里,或者HAL_Delay不返回。
原因:中断优先级配置不当。比如在串口中断里调用了HAL_Delay,而串口中断优先级高于SysTick,SysTick无法打断串口中断,HAL_Delay就死等。或者两个中断互相等待对方释放资源,形成死锁。
解决:遵循“中断里不做延时、不等待”的原则。必须延时就设标志位,在主循环里处理。检查NVIC配置,确保SysTick优先级最低。用调试器看卡死时在哪个中断里,然后调整优先级。
4.3 printf重定向后程序卡在fputc
现象:加了printf重定向代码后,程序一运行就卡死,或者第一次printf就卡住。
原因:Keil里没有勾选“Use MicroLIB”,标准C库的printf依赖半主机模式,需要调试器支持。脱机运行时没有半主机环境,就卡在fputc里。
解决:在Keil的Target选项里勾选“Use MicroLIB”。如果不想用MicroLIB,可以自己实现_sys_exit、_ttywrch等半主机接口,或者直接用HAL_UART_Transmit代替printf。
4.4 堆栈溢出导致变量值被篡改
现象:全局变量或局部变量的值莫名其妙变了,或者程序跑飞进入HardFault。
原因:栈空间不够。F103C8T6的RAM只有20KB,默认栈大小在启动文件里是0x400(1KB)。如果用了printf、浮点运算、递归,1KB栈很容易溢出。堆栈溢出会覆盖相邻的全局变量区域。
解决:在启动文件(startup_stm32f103xb.s)里把Stack_Size改大,比如0x800(2KB)。同时检查Heap_Size,如果用malloc就设大一点,不用就设为0。用调试器看SP寄存器是否接近栈底,或者用填充法检测栈使用量。
4.5 CubeMX重新生成代码覆盖用户修改
现象:在CubeMX里改了个引脚配置,重新生成代码后,之前写的业务逻辑全没了。
原因:用户代码没有写在USER CODE BEGIN和USER CODE END之间,或者写在了CubeMX管理的文件里(比如gpio.c的初始化函数里)。
解决:所有用户代码要么写在USER CODE保护区,要么放在独立的app.c/app.h里。CubeMX管理的文件只放初始化代码,不放业务逻辑。重新生成前先提交git,生成后对比差异,确认没有覆盖。
5. 模板的进阶用法:从单文件到多任务调度
模板搭好后,下一步是让它能支撑更复杂的应用。F103C8T6资源有限,跑RTOS有点勉强,但可以用时间片轮询调度器实现多任务。核心思路是用SysTick计数,每个任务分配一个时间片,到期就执行。
/* scheduler.h */ #ifndef __SCHEDULER_H #define __SCHEDULER_H #include <stdint.h> #define MAX_TASKS 8 typedef struct { void (*task)(void); uint32_t interval; uint32_t last_run; uint8_t enable; } Task_t; void Scheduler_Init(void); int Scheduler_AddTask(void (*task)(void), uint32_t interval_ms); void Scheduler_Run(void); #endif/* scheduler.c */ #include "scheduler.h" #include "main.h" static Task_t tasks[MAX_TASKS]; static uint8_t task_count = 0; void Scheduler_Init(void) { task_count = 0; } int Scheduler_AddTask(void (*task)(void), uint32_t interval_ms) { if (task_count >= MAX_TASKS) { return -1; } tasks[task_count].task = task; tasks[task_count].interval = interval_ms; tasks[task_count].last_run = HAL_GetTick(); tasks[task_count].enable = 1; task_count++; return 0; } void Scheduler_Run(void) { uint32_t now = HAL_GetTick(); for (uint8_t i = 0; i < task_count; i++) { if (tasks[i].enable && (now - tasks[i].last_run >= tasks[i].interval)) { tasks[i].last_run = now; tasks[i].task(); } } }逻辑说明:每个任务记录上次运行时间,主循环里检查当前时间与上次运行时间的差值,超过间隔就执行。参数说明:interval_ms是任务执行周期,最小1ms,但实际受主循环速度限制。MAX_TASKS根据RAM调整,8个任务每个占16字节,共128字节。
在app.c里注册任务:
void Task_Led(void) { LED_TOGGLE(); } void Task_Print(void) { printf("Tick: %lu\r\n", HAL_GetTick()); } void App_Init(void) { Scheduler_Init(); Scheduler_AddTask(Task_Led, 500); Scheduler_AddTask(Task_Print, 1000); } void App_Loop(void) { Scheduler_Run(); }这个调度器是协作式的,任务不能阻塞,否则会影响其他任务。如果某个任务需要长时间运行,要拆成状态机分步执行。相比RTOS,它没有栈切换开销,RAM占用小,适合F103C8T6这种资源紧张的芯片。
验证方法:用串口打印每个任务的执行时间,看是否满足周期要求。如果某个任务执行时间超过其周期,说明需要拆分或提高主循环速度。用GPIO翻转配合示波器测量任务实际执行间隔,比串口打印更准确。
我自己的习惯是,模板搭好后先跑一个“心跳”任务,每秒钟翻转一次LED并打印系统运行时间。这样板子一上电就知道程序在跑,调试时也能快速判断是死机还是逻辑错误。这个习惯帮我省了很多后悔药——有次产品在现场偶发死机,就是靠心跳日志定位到是看门狗喂狗超时。
希望帮到你。
本文还有配套的精品资源,点击获取