简介:本资源是一套面向嵌入式初学者与STM32开发者的完整STM32F103RCT6实战例程合集,覆盖从外设驱动到系统级应用的全链路学习路径。资源包含3444个文件,主体为824个C源码与866个头文件(.c/.h),辅以大量编译中间文件(.o/.d/.axf/.hex)及Keil工程配置(.uvproj/.uvprojx/.uvoptx),总大小123.09MB,结构清晰、即开即用,适配Keil uVision与STM32CubeIDE环境。已有1100人下载学习,广泛用于课程实验、毕业设计及项目原型开发。读者可直接复现PWM调光、ADC采样、SPI驱动LCD、串口通信、按键中断响应、定时器计时与UCOSII实时系统移植等典型场景,预览中可见LCD.axf、UCOSII-1.axf、ICAPTURE.axf等多目标可执行文件,印证其涵盖裸机与RTOS双模式开发能力,是系统掌握Cortex-M3平台外设配置、HAL库调用与工程构建流程的高实用性参考包。
1. STM32F103RCT6例程:为什么同一份代码在不同开发板上烧录后行为截然不同?
你手头有一份标着“STM32F103RCT6例程”的压缩包,解压后是标准的 Keil MDK 工程结构:USER/,HARDWARE/,CORE/,SYSTEM/,还有startup_stm32f10x_md.s和stm32f10x.h。你把它原封不动烧进一块淘宝常见的“STM32F103C8T6最小系统板”,LED 不闪、串口无输出——但换到另一块标注“兼容F103RCT6”的核心板,却能正常跑通 PWM 和 ADC 采样。这不是玄学,也不是芯片假货(虽然假片确实存在),而是这份例程里埋着三处硬件抽象层与物理引脚绑定的隐式契约:RCC时钟配置依赖具体晶振频率、GPIO初始化硬编码了特定封装的引脚复用功能、SysTick中断优先级被写死为抢占式最高级——而这些,在 C8T6(48引脚)和 RCT6(64引脚)之间,物理引脚映射、可用外设通道数、甚至Flash起始地址都不同。搞不清这点,你就永远在“换个板子就翻车”的循环里打转。这份例程不是教学模板,它是一份带硬件指纹的可执行契约:只有当你理解它如何把 ARM Cortex-M3 的通用指令流,翻译成对特定硅片上寄存器组的精确敲击时,它才真正属于你。
2. RCC时钟树:为什么改一个宏定义,整个系统就从“稳定运行”变成“随机死机”?
STM32F103系列的时钟系统不是简单的“开个开关”,而是一棵需要手动修剪、嫁接、校准的精密树。F103RCT6 的 RCT6 封装决定了它支持外部 8MHz 晶振(HSE),但例程中system_stm32f10x.c里的SystemInit()函数默认启用 HSE 后,直接调用RCC_PLLConfig(RCC_PLLSource_HSE_Div2, RCC_PLLMul_9)—— 这行代码背后藏着三个必须对齐的物理事实:
- 硬件前提:你的开发板上必须真实焊接了 8MHz 晶振,且负载电容匹配(通常 12–22pF);
- 电气约束:PLL 输入频率(HSE/2 = 4MHz)必须落在 1–2MHz 范围内(F103手册 Section 6.2.2),而 PLL 输出(4×9 = 36MHz)又必须 ≤72MHz;
- 启动时序:HSE 启动后需等待
RCC_CR寄存器的HSERDY位被硬件置 1,否则后续 PLL 配置会失败,但例程常省略超时检测,直接while(!RCC_GetFlagStatus(RCC_FLAG_HSERDY))死等——若晶振不起振,MCU 就卡在这里,连调试器都连不上。
2.1 时钟源切换的原子性陷阱
RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK)这行看似平平无奇,实则触发 Cortex-M3 的“系统时钟切换门控”。当 PLL 尚未锁相(RCC_FLAG_PLLRDY未置位)时调用它,CPU 会因时钟丢失而立即挂起。更隐蔽的是:切换过程中若发生 SysTick 中断,且该中断服务函数里调用了基于SysTick_Config()的延时(如delay_ms(1)),就会因 SysTick 重载值计算错误导致无限循环。这是因为SysTick_Config()内部用SysTick_CLKSource_HCLK作为基准,而 HCLK 正在切换中,其频率处于不确定态。
// system_stm32f10x.c 中典型的危险写法(已注释关键风险点) void SystemInit (void) { // ... 省略部分初始化 RCC_HSEConfig(RCC_HSE_ON); // 启动HSE,但无超时保护! while(RCC_GetFlagStatus(RCC_FLAG_HSERDY) == RESET); // 若晶振失效,此处死锁 RCC_PLLConfig(RCC_PLLSource_HSE_Div2, RCC_PLLMul_9); // 输入4MHz,输出36MHz RCC_PLLCmd(ENABLE); while(RCC_GetFlagStatus(RCC_FLAG_PLLRDY) == RESET); // 同样无超时! RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); // 切换瞬间,HCLK频率跳变! // 此刻若SysTick中断触发,且delay_ms()正在执行,灾难开始... }提示:真正的工业级初始化会在
while循环中加入计数器超时(如 10000 次),超时则强制切回 HSI(内部8MHz RC振荡器)并进入安全模式,而非死等。这是例程与量产代码的第一道分水岭。
2.2 HSE vs HSI:谁才是你的“后悔药”?
很多开发者以为 HSE(外部晶振)精度高就一定优于 HSI(内部RC),但 F103 的 HSI 出厂校准误差仅 ±1%,且启动时间 <10μs;而 HSE 启动需 1–10ms,且易受PCB布局、温度漂移影响。例程中若强行禁用 HSI(RCC_HSICmd(DISABLE)),一旦 HSE 失效,MCU 将彻底失联。一份健壮的例程,必须保留 HSI 作为 fallback 时钟源,并在RCC_GetSYSCLKSource()返回非预期值时触发降级逻辑。这正是为什么某些“精简版”例程在高温环境下频繁复位——它删掉了那几行看似冗余的 HSI 备份代码。
2.3 AHB/APB 总线分频比的隐性耦合
RCC_HCLKConfig(RCC_SYSCLK_Div1)设置 AHB 为全速(72MHz),但RCC_PCLK1Config(RCC_HCLK_Div2)将 APB1(含 TIM2–TIM4、USART2/3、I2C1)降为 36MHz。问题在于:TIMx 的计数器时钟 = PCLKx × (TIMxCLK / PCLKx),而TIM_TimeBaseInitTypeDef结构体中的TIM_Period值,是按PCLK1 * 2(即 72MHz)反推出来的。如果误将RCC_PCLK1Config(RCC_HCLK_Div4),APB1 变成 18MHz,但TIM_Period未重算,PWM 频率就会翻两倍——灯闪得像迪厅,但没人知道为什么。这种耦合不会报错,只会让波形偏离预期,是嵌入式调试中最耗时的“幽灵 Bug”。
3. GPIO 引脚复用:为什么明明配置了 PA9 为 USART1_TX,串口却发不出数据?
STM32F103 的 GPIO 不是“插上就亮”的傻瓜接口。PA9 在 RCT6 封装中确实是 USART1_TX 功能,但在 C8T6 上,PA9 被复用为USB_DM(USB Device Mode)。这意味着:同一份GPIO_PinRemapConfig(GPIO_Remap_USART1, ENABLE)代码,在 RCT6 上生效,在 C8T6 上可能根本无意义——因为 C8T6 的 USB 模块与 USART1 共享 PA9/PA10 引脚,且硬件上无法同时启用。例程若未做封装适配,直接硬编码GPIOA的Pin9为复用推挽,就会在 C8T6 上导致 USB 和 USART1 全部失效。
3.1 复用功能寄存器(AFIO)的双重门控
要让 PA9 输出 USART1 数据,需满足两个条件:
- GPIO 模式寄存器(GPIOA_CRL):将 PA9 配置为
Alternate Function Push-Pull(复用推挽); - AFIO 重映射寄存器(AFIO_MAPR):设置
USART1_REMAP = 0(默认映射到 PA9/PA10),或=1(重映射到 PB6/PB7)。
但例程常只操作 GPIO 寄存器,忽略 AFIO。更致命的是:AFIO_MAPR 是 32 位寄存器,写入时必须用|=或&=操作,若直接AFIO->MAPR = 0x00000001,会清空其他位(如 SWJ-JTAG 使能位),导致调试器断连。这就是为什么你改完串口引脚后,Keil 下载失败——不是代码错了,是 JTAG 接口被你一并关掉了。
// 错误示范:直接覆写 AFIO_MAPR,抹掉 SWJ 配置 AFIO->MAPR = 0x00000001; // 危险!SWJ 会被禁用 // 正确做法:仅设置 USART1_REMAP 位,保留其他功能 AFIO->MAPR |= (uint32_t)0x00000001; // 安全地置位 // 或更清晰的写法: AFIO->MAPR &= ~(uint32_t)0xFFFFFFFE; // 先清零目标位 AFIO->MAPR |= (uint32_t)0x00000001; // 再置位3.2 引脚电流驱动能力与上拉/下拉的物理真相
例程中GPIO_InitTypeDef.GPIO_Speed = GPIO_Speed_50MHz常被误解为“输出速度越快越好”。实际上,F103 的 GPIO 输出级由 MOSFET 驱动,50MHz 模式意味着更强的驱动电流(约 25mA),但会带来:
- 更大的开关噪声(EMI);
- 更高的功耗(尤其在高频翻转时);
- 若外接容性负载(如长排线 >10cm),可能引发振铃,导致接收端误判。
而GPIO_PuPd_UP(上拉)看似简单,但 F103 的上拉电阻典型值为 40kΩ,若外接按键到 GND,按下时电流仅 0.12mA(3.3V/40kΩ),远低于常见 MCU 的 1–5mA 驱动能力——这没问题;但若你用此引脚驱动 LED(需 5–10mA),上拉电阻就成了瓶颈,LED 亮度极低。例程中GPIO_PuPd_UP的真实用途,是为浮空输入(如按键)提供确定电平,而非驱动负载。混淆这两者,就是用“逻辑电平”思维去干“功率驱动”的活,注定翻车。
3.3 封装差异导致的引脚不可用性
RCT6 是 64 引脚 LQFP,C8T6 是 48 引脚 LQFP。这意味着:
- RCT6 的 PB12–PB15 可用作 SPI2_NSS/SCK/MISO/MOSI;
- C8T6 的 PB12–PB15 根本不存在,SPI2 只能通过重映射到 PA15/PB3–PB5 使用。
但例程若直接写RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_GPIOB, ENABLE);+GPIO_PinRemapConfig(GPIO_Remap_SPI2, ENABLE);,在 C8T6 上编译能过,运行却无效——因为GPIO_Remap_SPI2依赖的物理引脚不存在。ARM 编译器不会检查外设是否物理存在,它只认寄存器地址。向不存在的寄存器地址写入,结果是“静默失败”:寄存器值不变,外设不工作,且无任何异常触发。这是裸机开发最反直觉的痛点:没有操作系统帮你拦截非法访问。
4. 中断向量表与 NVIC:为什么加了一行NVIC_EnableIRQ(USART1_IRQn),程序就再也进不了 main?
STM32F103 的中断响应不是“注册回调”那么简单。它依赖一张固化在 Flash 起始地址(0x08000000)的向量表,其中第 11 项(索引 10)存放USART1_IRQHandler的入口地址。但例程中若使用 Keil MDK,默认向量表放在 Flash,而你若在main()里动态修改SCB->VTOR = (uint32_t)&my_vector_table,试图把向量表搬到 RAM,就必须确保:
- RAM 中的
my_vector_table前 16 项(复位向量、NMI、HardFault 等)必须与原始 Flash 向量表一致; - 第 11 项必须指向你重写的
USART1_IRQHandler地址; SCB->VTOR的低 7 位必须为 0(对齐要求),且地址必须在合法 RAM 区域(F103RCT6 的 SRAM 是 0x20000000–0x20004FFF)。
若my_vector_table未正确初始化(如第 0 项不是栈顶地址),MCU 复位后会从错误地址取栈指针,直接跳进野指针区域,表现为“烧录后不运行,调试器连不上”。
4.1 NVIC 优先级分组的“数字幻觉”
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2)这行代码,表面是设置优先级分组,实则重写了AIRCR寄存器的PRIGROUP[10:8]位。F103 支持 4 种分组:
| 分组 | 抢占优先级位数 | 子优先级位数 | 可用优先级数 |
|---|---|---|---|
| 0 | 0 | 4 | 16 |
| 1 | 1 | 3 | 8 |
| 2 | 2 | 2 | 4 |
| 3 | 3 | 1 | 2 |
但例程常写NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;却忽略分组类型。若分组是NVIC_PriorityGroup_2(2位抢占),则PreemptionPriority=1表示抢占优先级为 1(二进制01),但若你误设为NVIC_PriorityGroup_0(0位抢占),则PreemptionPriority=1会被解释为子优先级,抢占能力完全丧失——此时即使 USART1 中断到来,也会被任何其他中断打断,导致数据丢失。这种错误无法编译报错,只能靠逻辑分析仪抓波形才能发现。
4.2 SysTick 中断的“时间锚点”本质
SysTick_Config(SystemCoreClock / 1000)生成 1ms 基础滴答,但SystemCoreClock的值来自system_stm32f10x.c中的SystemCoreClockUpdate()函数。该函数通过读取RCC_CFGR寄存器的SW[1:0]位判断当前系统时钟源,再查表返回对应频率。若你在SystemInit()后手动切换了时钟(如从 PLL 切到 HSI),但忘记调用SystemCoreClockUpdate(),SysTick_Config()仍用旧频率计算重载值,1ms 就变成了 2ms 或 500μs。所有基于delay_ms()的时序控制全部偏移,而delay_ms()本身又依赖 SysTick 中断,形成闭环错误。
4.3 中断服务函数(ISR)的“临界区”雷区
USART1_IRQHandler中若调用printf()(依赖fputc重定向到 USART),而fputc内部又调用while(USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET);等待发送完成,就会出大事:
USART_FLAG_TC(Transmission Complete)在最后一个字节移出移位寄存器时置位;- 但若此时有更高优先级中断(如 SysTick)抢占,
while循环被挂起; - SysTick 中断里若也调用
printf(),就会再次进入while循环,而TC标志已被前一次清除,陷入死等。
解决方案不是禁用中断(影响实时性),而是:
- 在 ISR 中只做最小动作:读取 DR 寄存器清 RXNE,写入 DR 寄存器触发 TXE;
- 将格式化、缓冲、发送等耗时操作移到主循环或低优先级中断(如使用 DMA+IDLE 中断);
- 所有共享资源(如 UART 发送缓冲区)用
__disable_irq()/__enable_irq()临时关总中断保护,而非依赖优先级。
5. 常见问题排查:那些让你熬夜到凌晨三点的“经典翻车现场”
现象、原因、解决,一条一条扎进骨髓里:
5.1 现象:Keil 下载成功,但程序不运行,调试器连接后显示 PC 指针停在0x00000000
原因:向量表首地址(0x08000000)处的栈顶地址(MSP)为 0,说明 Flash 中的向量表未正确写入,或startup_stm32f10x_md.s中的Stack_Size定义过小(如EQU 0x200),导致栈溢出覆盖了向量表。
解决:检查startup_stm32f10x_md.s中Stack_Size是否 ≥ 0x400(1KB),并在 Keil 的Options for Target → Target中确认IRAM1起始地址(0x20000000)和大小(0x5000)匹配 F103RCT6 的 20KB SRAM。
5.2 现象:ADC 采样值始终为 0 或满量程(0xFFF)
原因:ADC_RegularChannelConfig()中ADC_SampleTime_XXX参数选择错误。F103 的 ADC 输入阻抗高(约 50kΩ),若信号源内阻 >10kΩ,必须用ADC_SampleTime_239Cycles5(最长采样时间),否则电荷来不及充到采样电容,读数偏低。
解决:测量信号源内阻,若 >5kΩ,强制将采样时间设为ADC_SampleTime_239Cycles5;同时确认ADC_Cmd(ENABLE)在ADC_ResetCalibration()和ADC_StartCalibration()之后调用,校准必须在使能后进行。
5.3 现象:PWM 输出占空比正确,但频率偏差达 ±20%
原因:TIM_TimeBaseInitTypeDef.TIM_Period计算错误。公式应为Period = (SystemCoreClock / (Prescaler + 1)) / PWM_Frequency - 1,但例程常漏减 1,或Prescaler设为 0(不分频)却忘了SystemCoreClock实际是 72MHz,导致Period被低估。
解决:用逻辑分析仪实测 PWM 波形,反推实际频率,代入公式重新计算Period和Prescaler;优先用Prescaler=71(分频 72 倍),使计数器基准为 1MHz,便于心算。
5.4 现象:I2C 通信偶尔失败,I2C_CheckEvent()返回ERROR
原因:I2C 引脚(如 PB6/PB7)未配置为开漏输出(GPIO_Mode_Out_OD),且未外接上拉电阻(典型 4.7kΩ)。推挽模式下,SCL/SDA 线无法被从设备拉低,导致仲裁失败。
解决:检查GPIO_InitTypeDef中GPIO_Mode必须为GPIO_Mode_Out_OD;用万用表测 SDA 线对地电压,空闲时应为 3.3V(上拉有效),若为 0V 说明上拉缺失或短路。
5.5 现象:使用malloc()动态分配内存后,程序随机崩溃
原因:F103RCT6 的 SRAM 仅 20KB,而 Keil 默认堆(Heap)大小为 0x200(512 字节)。malloc(1024)成功返回地址,但后续malloc(512)时堆空间不足,返回 NULL;若代码未检查返回值,解引用 NULL 指针触发 HardFault。
解决:在 KeilOptions for Target → Target中将IRAM1的Size设为 0x5000(20KB),再在Options for Target → Linker中勾选Use Memory Layout from Target Dialog,最后在startup_stm32f10x_md.s中将Heap_Size改为0x1000(4KB);所有malloc()调用前必须if (ptr != NULL)检查。
6. 从“能跑”到“可靠”:一份例程的终极拷问与我的血泪习惯
当你终于让 LED 按预期闪烁、串口吐出“Hello World”,恭喜,你跨过了第一道门槛。但真正的分水岭在于:你能否回答——如果我把这块板子放进 85℃ 的烤箱,连续运行 72 小时,它还会不会准时发 PWM?这不是杞人忧天,而是嵌入式系统的生存法则。F103RCT6 的数据手册明确写着:在 105℃ 结温下,Flash 编程/擦除寿命为 10k 次;而在 85℃ 环境中,晶体振荡器的频率偏移可达 ±500ppm(即 72MHz 变成 71.964MHz)。例程里那个SysTick_Config(72000),在高温下实际是SysTick_Config(71964),1ms 延时悄悄变成了 1.0005ms。单次无关紧要,但 1000 次累积,就是半秒偏差——对电机控制、电池管理这类系统,足以触发保护机制。
我见过太多项目倒在“最后一公里”:硬件团队说“我们测过,没问题”,软件团队说“代码逻辑完美”,而问题出在RCC_GetClocksFreq()返回的SYSCLK_Frequency是基于常温校准值,从未在高温下验证。后来我们做了个简单但残酷的验证:
- 在
main()开头插入while(1) { if (RCC_GetSYSCLKSource() != 0x08) { /* 进入安全模式 */ } },强制校验时钟源; - 用
ADC1采集内部温度传感器(ADC_Channel_16),每 10 秒读一次,若温度 >70℃,自动将SysTick重载值乘以(1 + (temp-25)*0.0003)补偿晶振温漂; - 所有
delay_ms()替换为基于DWT->CYCCNT的周期计数(DWT是 Cortex-M3 内置的 32 位周期计数器,不受时钟源切换影响),DWT->CYCCNT每个周期等于 1 个 CPU 时钟,只要SystemCoreClock更新及时,它就是绝对可靠的时基。
注意:
DWT->CYCCNT需先使能:CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;,且DWT->CYCCNT是 32 位,满 0xFFFFFFFF 后归零,使用时必须处理溢出。
这份 STM32F103RCT6 例程的价值,从来不在“教你点亮 LED”,而在于它是一面镜子,照出你对 ARM Cortex-M3 架构、ST 标准外设库、以及物理世界约束(温度、噪声、电源纹波)的理解深度。它逼你亲手拧开每一个寄存器,看清每个位背后的硅片物理特性。从那以后,我每次拿到新例程,第一件事不是编译,而是打开system_stm32f10x.c,逐行核对SystemInit()中的 RCC 配置是否与原理图晶振匹配;第二件事是打开startup_stm32f10x_md.s,确认Stack_Size和Heap_Size是否留足余量;第三件事,是把main()里所有while(1)循环,替换成__WFI()(Wait For Interrupt),强迫自己用中断驱动替代轮询——因为真正的嵌入式系统,不该靠 CPU 空转来“等待”,而应让硬件事件唤醒它。
希望帮到你。
本文还有配套的精品资源,点击获取