简介:这份资源是面向嵌入式初学者与单片机开发者的STM32F103C8T6参考例程代码集,基于ARM Cortex-M3内核,围绕芯片常用外设给出可直接编译运行的工程示例,帮助读者跨越从寄存器手册到实际编程的门槛。内容覆盖ADC采样配置与通道读取、GPIO输入输出与中断、多路UART串口收发、按键检测与消抖、SPI主从通信、SysTick及通用定时器的延时计数与PWM输出,并包含UCOS实时操作系统的任务创建、信号量与互斥锁等移植示例,便于按模块逐个研读。压缩包共1616个文件,以c源文件与h头文件为主体,辅以Keil工程文件、hex烧录文件、map与lst编译清单及少量说明文档,整体约16.54MB,目录按功能分工程组织,检索方便。目前已有9168人学习下载,适合作为边调试边理解硬件资源的实践素材,为后续项目开发积累底层驱动经验。
1. 从一块最小系统板说起:这套 STM32F103C8T6 例程到底能帮你省多少事
手头有一块 STM32F103C8T6 最小系统板,LED 接在 PC13,串口想接 PA9/PA10,但打开官方标准外设库一看,光是建工程、配时钟、写启动文件就够折腾半天。这套开发参考例程代码解决的正是这个场景:它把 C 语言嵌入式开发里最常打交道的片上外设——GPIO、USART、定时器、ADC、I2C、SPI、EXTI、看门狗——按模块拆成一个个能独立编译、独立烧录的最小工程,每个工程只做一件事,代码短、注释密、依赖少。适合刚接触单片机代码、想从“点灯”过渡到“能改能调”的开发者,也适合熟手拿来当外设配置的速查模板。下面按“能干什么 → 怎么跑起来 → 怎么改”的顺序,把这份例程拆开讲透。
2. 工程结构与技术选型:为什么是标准外设库而不是 HAL
2.1 目录怎么分层,每个文件夹负责什么
这套例程的组织方式很朴素,但分层清晰,基本遵循“一个外设一个工程”的原则。拿到压缩包解压后,常见的顶层结构是这样:
STM32F103C8T6_Examples/ ├── Libraries/ │ ├── CMSIS/ # 内核头文件、system_stm32f10x.c │ └── STM32F10x_StdPeriph_Driver/ │ ├── inc/ # 外设驱动头文件 │ └── src/ # 外设驱动源文件 ├── Start/ │ ├── startup_stm32f10x_md.s # 中容量启动文件 │ └── stm32f10x_conf.h # 外设使能裁剪配置 ├── User/ │ ├── main.c │ ├── stm32f10x_it.c # 中断服务函数集中放这里 │ └── stm32f10x_it.h ├── Hardware/ # 板级外设封装,如 led.c、usart.c └── Project/ └── MDK-ARM/ # Keil 工程文件 .uvprojxLibraries放的是芯片原厂提供的标准外设库和 CMSIS 内核支持,属于“不要动”的部分;Start里最关键的是启动文件,C8T6 属于中容量(Medium Density)器件,必须选startup_stm32f10x_md.s,选错成hd或ld会在链接阶段报一堆符号找不到;User是业务入口;Hardware是我个人最推荐保留的一层,把 LED、按键、串口这些板级东西封装成xxx_init()函数,主函数里只调用初始化,换板子时只改这一层。Project下按 IDE 分目录,Keil 工程里已经把上述源文件加进分组,打开就能编译。
提示:不同例程可能把
Hardware叫BSP或直接平铺在User下,判断标准只有一个——看main.c里调用的初始化函数来自哪个文件,那个文件所在目录就是板级层。
2.2 标准外设库 vs HAL:这套代码的取舍
STM32F103C8T6 是 Cortex-M3 内核、64KB Flash、20KB RAM 的经典型号,这套例程用的是 ST 标准外设库(StdPeriph Library),不是现在 CubeMX 默认生成的 HAL。原因很实际:标准库的寄存器操作更贴近硬件,一个GPIO_Init()背后就是 CRL/CRH 寄存器的位操作,读代码时能直接对上参考手册;代码体积也小,一个点灯工程编译出来通常几 KB,20KB RAM 的芯片跑起来毫无压力。HAL 抽象层厚、回调多,对初学者反而容易“只知调用不知原理”。
代价是标准库已经停止更新,新芯片不支持。但对 F103 这个存量巨大的型号来说,标准库生态成熟、例程多、资料全,拿来练手和做小产品都够用。技术栈上就是纯 C 语言 + 标准库 + Keil MDK(或 IAR),不依赖 RTOS,全部裸机跑主循环加中断,这也是嵌入式入门最该先吃透的模型。
2.3 时钟树与启动流程:上电后代码到底跑了什么
很多人改例程时卡在“为什么我的延时不准”,根子就在时钟配置。F103C8T6 外部一般挂 8MHz 晶振,经 PLL 倍频到 72MHz 作为系统时钟。标准库工程里这段逻辑在system_stm32f10x.c的SystemInit()中,启动文件复位后先调它,再跳main()。典型配置代码长这样:
// system_stm32f10x.c 中的关键片段(简化) #define SYSCLK_FREQ_72MHz 72000000 static void SetSysClockTo72(void) { RCC->CR |= ((uint32_t)RCC_CR_HSEON); // 打开外部高速晶振 while (!(RCC->CR & RCC_CR_HSERDY)); // 等待 HSE 稳定 RCC->CFGR |= RCC_CFGR_PLLMULL9; // 8MHz * 9 = 72MHz RCC->CFGR |= RCC_CFGR_PLLSRC; // PLL 源选 HSE RCC->CR |= RCC_CR_PLLON; // 使能 PLL while (!(RCC->CR & RCC_CR_PLLRDY)); // 等待 PLL 锁定 RCC->CFGR |= RCC_CFGR_SW_PLL; // 切换系统时钟到 PLL while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL); }这段代码的逻辑是:先让外部晶振起振并等待稳定标志,再配置 PLL 的倍频系数和时钟源,等 PLL 锁定后把系统时钟切过去。参数上PLLMULL9对应 9 倍频,如果板子焊的是 12MHz 晶振,这里就得改成PLLMULL6,否则超频到 108MHz 会跑飞。改完时钟,SystemCoreClock变量会同步更新,SysTick延时函数靠它算节拍,所以延时不准先查这里,别急着怀疑延时函数本身。
3. 核心外设例程拆解:从点灯到串口收发的完整链路
3.1 GPIO 输出:一个 LED 例程里的四个必改点
点灯是所有例程的第一课,但真正要改对,得看清四件事:时钟、引脚、模式、电平。以 PC13 接 LED(低电平点亮)为例:
#include "stm32f10x.h" void LED_Init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); // 1. 开 GPIOC 时钟 gpio.GPIO_Pin = GPIO_Pin_13; // 2. 选中 13 号引脚 gpio.GPIO_Mode = GPIO_Mode_Out_PP; // 3. 推挽输出 gpio.GPIO_Speed = GPIO_Speed_50MHz; // 输出速度 GPIO_Init(GPIOC, &gpio); } int main(void) { LED_Init(); while (1) { GPIO_ResetBits(GPIOC, GPIO_Pin_13); // 低电平点亮 for (volatile int i = 0; i < 500000; i++); GPIO_SetBits(GPIOC, GPIO_Pin_13); // 高电平熄灭 for (volatile int i = 0; i < 500000; i++); } }逻辑说明:第一步开时钟是新手最容易漏的,APB2 总线上 GPIOC 不使能,后面写寄存器全是无效操作,现象就是“代码没错但灯不亮”。第二步GPIO_Pin_13是位掩码,多个引脚用按位或组合。第三步GPIO_Mode_Out_PP是推挽输出,驱动 LED 够用;如果要接需要上拉的器件,改成GPIO_Mode_Out_OD开漏。参数GPIO_Speed_50MHz影响边沿陡峭程度,点灯用 2MHz 也行,但养成按实际需求选的习惯。主循环里的volatile不能省,否则编译器优化后空循环可能被整个删掉,延时直接消失——这是血泪经验。
3.2 USART 串口:轮询发送与中断接收怎么配
串口是调试和通信的主力,例程一般给两个版本:轮询发送和中断接收。先看初始化,以 USART1、PA9 发送、PA10 接收、115200 波特率为例:
void USART1_Init(uint32_t baud) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); gpio.GPIO_Pin = GPIO_Pin_9; // TX gpio.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽 gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); gpio.GPIO_Pin = GPIO_Pin_10; // RX gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING; // 浮空输入 GPIO_Init(GPIOA, &gpio); usart.USART_BaudRate = baud; usart.USART_WordLength = USART_WordLength_8b; usart.USART_StopBits = USART_StopBits_1; usart.USART_Parity = USART_Parity_No; usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; usart.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &usart); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); // 开接收中断 NVIC_EnableIRQ(USART1_IRQn); // 使能 NVIC 通道 USART_Cmd(USART1, ENABLE); }参数说明:TX 引脚必须配成GPIO_Mode_AF_PP复用推挽,配成普通输出发不出波形;RX 配浮空输入或上拉输入都行。USART_WordLength_8b加无校验是 8N1 的常见组合,和上位机串口助手默认设置一致。中断接收的关键是USART_IT_RXNE(接收寄存器非空)和 NVIC 通道使能,两者缺一不可。中断服务函数里必须读一次USART_ReceiveData()把 RXNE 标志清掉,否则会反复进中断:
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t ch = USART_ReceiveData(USART1); // 读数据即清标志 USART_SendData(USART1, ch); // 原样回显 while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); } }这段回显逻辑适合验证链路通不通。实际项目里接收数据一般塞进环形缓冲区,主循环再取出来解析,避免在中断里做耗时处理——中断里只做搬运,这是裸机编程的基本纪律。
3.3 定时器与 SysTick:延时和定时的两种做法
例程里通常有两种定时手段:SysTick做毫秒级延时,通用定时器做精确周期中断。SysTick配置简单:
void Delay_Init(void) { SysTick_Config(SystemCoreClock / 1000); // 1ms 中断一次 } void Delay_ms(uint32_t ms) { while (ms--) { while (!(SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk)); } }SystemCoreClock / 1000表示每 1ms 计满一次,COUNTFLAG置位表示计数到零。这种忙等延时简单但占 CPU,适合初始化阶段。需要周期性任务时用 TIM2 更合适:配置预分频PSC和自动重装ARR,中断频率 = 72MHz / ((PSC+1)*(ARR+1))。比如要 1kHz 中断,PSC 取 71、ARR 取 999,算下来正好 1ms 一次。参数改错最典型的症状是“中断进得太快导致主循环卡死”,这时先拿计算器把分频和重装值重新算一遍。
3.4 ADC 与 I2C/SPI:模拟采集和总线通信的配置要点
ADC 例程一般演示单通道规则转换,配置顺序是开 ADC 时钟、配 GPIO 为模拟输入、设置规则组通道和采样时间、使能 ADC、校准、启动转换。采样时间这个参数容易被忽略:采样时间太短,高阻抗信号源采不准,读数跳动大,把ADC_SampleTime_55Cycles5往上调一档往往就稳了。I2C 和 SPI 例程则侧重主机模式读写外部器件,I2C 要注意上拉电阻和时钟频率(100kHz 标准模式、400kHz 快速模式),SPI 要注意 CPOL/CPHA 四种模式必须和从机手册对上,对不上就是“发出去的数据从机不认”的经典翻车现场。
4. 编译、烧录与调试:把第一个工程跑起来的完整步骤
4.1 Keil 工程配置:器件型号、宏定义和下载器
打开Project/MDK-ARM下的工程文件后,先确认三处配置。第一处是 Device 页,必须选STM32F103C8,选成C6或CB虽然 Flash 大小不同但通常也能跑,不过保险起见按实际型号选。第二处是 C/C++ 页的宏定义,标准库工程一般要预定义USE_STDPERIPH_DRIVER, STM32F10X_MD,前者告诉stm32f10x.h去包含标准库头文件,后者告诉库当前是中容量器件,缺了会编译报错或行为异常。第三处是 Debug 页,下载器选 ST-Link 或 J-Link,勾选Reset and Run,烧完自动复位运行。
# 如果用命令行 + OpenOCD 烧录(无 Keil 环境时的替代方案) openocd -f interface/stlink.cfg -f target/stm32f1x.cfg \ -c "program build/demo.hex verify reset exit"这条命令的含义是:加载 ST-Link 接口配置和 F1 系列目标配置,把 hex 文件烧进去并校验,最后复位退出。verify不能省,能提前发现烧录不完整的问题。
4.2 上电自检:灯不亮、串口无输出的排查顺序
烧录成功后如果现象不对,按固定顺序排查效率最高。先看电源:3.3V 是否正常、BOOT0 是否接地(接地才从 Flash 启动)。再看复位:NRST 引脚是否被拉低。然后看时钟:晶振是否起振,用示波器或万用表测 OSC_OUT 有无波形。最后看代码:初始化函数是否被调用、时钟是否使能。串口无输出时,先短接 TX/RX 做回环测试确认硬件通路,再核对波特率和引脚复用配置。这个顺序能覆盖八成以上的“第一次跑不通”。
4.3 用串口打印做黑匣子:没有仿真器时的调试手段
不是每个人都有仿真器,串口打印就是最便宜的黑匣子。在关键分支打一行状态输出,比盯着代码猜快得多。封装一个简单的打印函数:
void Log(const char *tag, int val) { while (*tag) { USART_SendData(USART1, *tag++); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); } // 简单输出整数值,够定位问题即可 char buf[12]; int i = 0; if (val == 0) buf[i++] = '0'; while (val > 0) { buf[i++] = '0' + val % 10; val /= 10; } while (i--) { USART_SendData(USART1, buf[i]); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); } }在main里Log("init ok ", 1);这样打点,能快速判断程序卡在哪个初始化之前。注意打印本身有耗时,别放在高频中断里。
5. 避坑与常见问题:那些让例程跑不起来的细节
5.1 启动文件选错导致链接报错
现象:编译通过,链接时报Undefined symbol SystemInit或大量中断向量相关符号缺失。原因:启动文件容量等级和芯片不匹配,C8T6 必须用startup_stm32f10x_md.s,误用了ld或hd版本。解决:在工程分组里移除错误启动文件,重新添加 md 版本,并确认STM32F10X_MD宏已定义。
5.2 时钟没使能,外设“假装工作”
现象:GPIO 配置代码完全正确,但引脚无输出;或串口初始化后发不出数据。原因:忘记调用RCC_APB2PeriphClockCmd()使能对应外设时钟,寄存器写入被硬件忽略。解决:每个外设初始化函数第一行就开时钟,形成肌肉记忆;排查时用调试器看 RCC_APB2ENR 对应位是否为 1。
5.3 中断里没清标志导致反复进入
现象:程序看起来卡死,主循环不执行,或串口疯狂输出乱码。原因:中断服务函数没有清除中断标志,退出后立刻再次触发。解决:USART 读数据清 RXNE,定时器调TIM_ClearITPendingBit(),EXTI 调EXTI_ClearITPendingBit(),每个中断入口都确认清标志动作存在。
5.4 延时函数被编译器优化掉
现象:点灯闪烁频率快得看不清,或空循环延时完全失效。原因:空循环变量没加volatile,编译器认为无副作用直接删除。解决:循环计数变量声明为volatile,或改用SysTick做延时,从根上避免依赖编译器行为。
5.5 串口乱码先查波特率和时钟源
现象:串口助手收到乱码。原因:波特率不匹配,或系统时钟不是预期的 72MHz 导致实际波特率偏移。解决:先确认上位机波特率和代码一致,再核对晶振频率与 PLL 倍频配置,最后检查USART_Init前 USART 时钟是否使能。
6. 二次开发改造:把例程变成自己项目的骨架
拿到这套例程,最忌讳的是在每个工程里东改西改,最后版本散落一地。我一般做三件事把它变成可复用的骨架。第一,抽一个公共模板工程:保留Libraries、Start、User三层,把Hardware层做成空壳,新增外设时只往里加xxx.c/xxx.h,主函数只负责调用初始化。第二,统一板级引脚定义到一个头文件,比如board_config.h里用宏写死LED_PIN、USART_TX_PIN,换板子只改这一个文件,避免在多个源文件里全局搜索替换。第三,给每个外设封装“初始化 + 读 + 写”三个接口,中断服务函数集中放stm32f10x_it.c,业务逻辑一律不进中断。
验证改造是否成功,有个简单办法:新建一个空工程,只包含模板骨架,编译应无警告通过;然后依次加入 LED、串口、定时器模块,每加一个烧录验证一次,任何一步现象不对就回退。这种小步验证比一次性堆完再调试省时间得多。
| 改造点 | 改造前 | 改造后 | 收益 |
|---|---|---|---|
| 引脚定义 | 散落各文件 | 集中到 board_config.h | 换板只改一处 |
| 外设接口 | 直接操作寄存器 | 封装 init/read/write | 业务代码不碰硬件 |
| 中断处理 | 混在业务里 | 集中 it.c,只做搬运 | 降低耦合,好排查 |
| 工程组织 | 一例程一工程 | 公共模板 + 模块追加 | 版本可控 |
还有一个具体技巧:把SysTick延时和串口打印做成“调试开关”,用宏DEBUG_LOG包起来,发布时关掉宏,打印代码整体不参与编译,既不占 Flash 也不用删代码。我吃过一次亏——项目后期为了省空间手动删打印语句,结果误删了一行状态判断,排查了半天。从那以后我每次做裁剪都强制走一遍“改宏 → 全量编译 → 烧录验证现象”的流程,不再手删代码。希望这套例程的拆解能帮到你,先从点灯和串口两个工程跑通,剩下的外设照着同样的套路往下推就行。
本文还有配套的精品资源,点击获取