简介:这份参考例程面向STM32F103C8T6初学者与嵌入式开发入门者,围绕ARM Cortex-M3内核的常用外设与软件框架,提供可直接编译调试的示例代码,帮助读者快速理解芯片资源并上手实际项目。内容覆盖ADC采样配置、GPIO输入输出与中断、UART串口收发、按键检测与消抖、SPI主从通信、SysTick及通用定时器延时与PWM,以及UCOS实时操作系统的任务创建、信号量与互斥锁等多任务调度示例,基本串起从裸机到操作系统的学习路径。压缩包共1616个文件,以c与h源码、工程配置及编译中间文件为主,另含hex烧录文件、map与lst映射清单、uvproj/uvprojx工程文件及少量说明文档,整体约16.54MB,目录按模块划分便于逐项查阅。已有9168人学习下载,适合对照例程理解外设初始化流程与中断处理思路,为后续项目开发积累可复用的代码基础。
1. 拿到一块 C8T6 最小系统板,从例程里先跑通什么
手头这块 STM32F103C8T6 最小系统板,蓝色 PCB、48 脚、板载 8MHz 晶振和 32.768kHz 低速晶振,很多人第一次上电就卡在“程序烧进去了但灯不亮”。这份开发参考例程代码的价值就在这儿:它把 GPIO、串口、定时器、ADC、I2C、SPI、PWM、中断、SysTick 延时这些外设的初始化流程按模块拆开,每个模块一个独立工程,C 语言写成,寄存器版和标准外设库版都有,适合刚接触嵌入式开发、想拿一块板子把外设逐个点亮的开发者,也适合做课设、做小项目时直接抠代码当骨架用。下面按“先看清结构、再跑通第一个工程、然后逐个模块拆、最后讲怎么改成自己的东西”这条线走。
2. 工程结构拆解:一个例程目录里到底放了什么
2.1 典型目录布局与文件职责
拿到压缩包解压后,常见做法是按外设分文件夹,每个文件夹是一个可以单独用 Keil 打开的工程。以 GPIO 点灯为例,目录大致长这样:
GPIO_LED/ ├── CORE/ │ ├── core_cm3.c │ ├── core_cm3.h │ └── startup_stm32f10x_md.s ├── HARDWARE/ │ └── LED/ │ ├── led.c │ └── led.h ├── STM32F10X_FWLib/ │ ├── inc/ │ └── src/ ├── SYSTEM/ │ ├── delay/ │ ├── sys/ │ └── usart/ ├── USER/ │ ├── main.c │ ├── stm32f10x_it.c │ └── stm32f10x_conf.h └── GPIO_LED.uvprojxCORE放 Cortex-M3 内核文件和启动文件,启动文件选startup_stm32f10x_md.s,因为 C8T6 属于中容量(Medium Density),Flash 64KB、RAM 20KB,选错启动文件是新手第一个翻车点。STM32F10X_FWLib是标准外设库,inc放头文件、src放源文件,工程里不需要全加,用到哪个外设加哪个,全加会拖慢编译。SYSTEM是作者自己封的延时、系统时钟、串口三件套,delay里通常是 SysTick 实现的微秒/毫秒延时,sys里做时钟配置,usart里封装了 printf 重定向。HARDWARE放具体外设驱动,USER放main.c和中断服务函数。
提示:如果解压后看到
.uvprojx打不开或者提示器件包缺失,先装 Keil 的 STM32F1 Device Family Pack,再检查工程选项里 Device 是否选成STM32F103C8。
2.2 时钟树与启动流程:程序从复位到 main 之间发生了什么
C8T6 默认走内部 8MHz RC 振荡器,例程里一般在sys.c的SystemInit或自定义时钟函数里把系统时钟切到外部 8MHz 晶振经 PLL 倍频到 72MHz。这段代码不长,但决定了后面所有外设的时序基准:
// system_stm32f10x.c 中常见的 72MHz 配置片段(标准库版) void SetSysClockTo72(void) { RCC->CR |= (uint32_t)0x00010000; // 使能外部高速晶振 HSE RCC->CFGR &= (uint32_t)0xF8FF0000; // 复位 SW、HPRE、PPRE1、PPRE2、ADCPRE、MCO RCC->CR &= (uint32_t)0xFEF6FFFF; // 复位 HSEON、CSSON、PLLON RCC->CR &= (uint32_t)0xFFFBFFFF; // 复位 HSEBYP RCC->CFGR &= (uint32_t)0xFF80FFFF; // 复位 PLLSRC、PLLXTPRE、PLLMUL、USBPRE RCC->CIR = 0x00000000; // 关闭所有中断 RCC->CR |= (uint32_t)0x00010000; // 再次使能 HSE while ((RCC->CR & (uint32_t)0x00020000) == 0) {} // 等待 HSE 就绪 FLASH->ACR |= FLASH_ACR_PRFTBE; // 使能预取缓冲 FLASH->ACR &= (uint32_t)((uint32_t)~FLASH_ACR_LATENCY); FLASH->ACR |= (uint32_t)FLASH_ACR_LATENCY_2; // 72MHz 需要 2 个等待周期 RCC->CFGR |= (uint32_t)RCC_CFGR_HPRE_DIV1; // AHB 不分频 RCC->CFGR |= (uint32_t)RCC_CFGR_PPRE2_DIV1; // APB2 不分频 RCC->CFGR |= (uint32_t)RCC_CFGR_PPRE1_DIV2; // APB1 二分频,最高 36MHz RCC->CFGR &= (uint32_t)((uint32_t)~(RCC_CFGR_PLLSRC | RCC_CFGR_PLLXTPRE | RCC_CFGR_PLLMULL)); RCC->CFGR |= (uint32_t)(RCC_CFGR_PLLSRC_HSE | RCC_CFGR_PLLMULL9); // 8MHz * 9 = 72MHz RCC->CR |= RCC_CR_PLLON; // 使能 PLL while ((RCC->CR & RCC_CR_PLLRDY) == 0) {} // 等待 PLL 锁定 RCC->CFGR &= (uint32_t)((uint32_t)~(RCC_CFGR_SW)); RCC->CFGR |= (uint32_t)RCC_CFGR_SW_PLL; // 切换系统时钟到 PLL while ((RCC->CFGR & (uint32_t)RCC_CFGR_SWS) != (uint32_t)0x08) {} // 等待切换完成 }这段代码的关键参数:PLLMUL9对应 8MHz 乘 9 得 72MHz,PPRE1_DIV2让 APB1 跑 36MHz,因为 APB1 上的定时器、串口等外设最高只能到 36MHz。FLASH_ACR_LATENCY_2是 72MHz 下必须的 Flash 等待周期,漏掉这一句程序能跑但会偶发取指错误,属于玄学问题。启动文件里Reset_Handler先调SystemInit,再跳__main做 C 库初始化,最后才进main,所以main里第一句就能直接用 72MHz 的延时。
2.3 编译产物与下载方式
工程编译后生成.hex和.axf,下载常见两种:ST-Link 走 SWD 四线(3.3V、GND、SWDIO、SWCLK),或者串口 ISP 走 PA9/PA10 加 BOOT0 拉高。Keil 里在 Options → Debug 选 ST-Link Debugger,Settings 里能识别到芯片 ID 就说明接线没问题。烧录前记得勾选 “Reset and Run”,否则烧完要手动按复位。串口 ISP 的话,用官方 Flash Loader 工具,BOOT0 接 3.3V、BOOT1 接 GND,下载完把 BOOT0 拨回 GND 再复位。
3. 核心模块逐个拆:GPIO、串口、定时器、ADC 的初始化套路
3.1 GPIO 点灯与按键:从寄存器位到库函数
点灯是所有例程的第一个工程,标准库版led.c里初始化流程固定:开时钟、配模式、设初值。
// led.c:PA8 接 LED,低电平点亮 #include "led.h" void LED_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 使能 GPIOA 时钟 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8; // 选中 PA8 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 推挽输出 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; // 输出速度 50MHz GPIO_Init(GPIOA, &GPIO_InitStructure); // 写入配置寄存器 GPIO_SetBits(GPIOA, GPIO_Pin_8); // 默认输出高,LED 灭 }GPIO_Mode_Out_PP是推挽输出,能同时拉高拉低,驱动 LED 够用;如果接的是需要开漏的场合(比如 I2C 引脚)就换GPIO_Mode_Out_OD。GPIO_Speed_50MHz影响的是引脚翻转速率和电磁干扰,点灯用 2MHz 也行,但例程统一写 50MHz 省得改。按键部分一般是上拉输入GPIO_Mode_IPU,按下读低电平,main里轮询加 10ms 延时消抖,这是最土但最稳的写法。
3.2 串口 USART1 与 printf 重定向
串口是调试的黑匣子,例程里usart.c通常做三件事:配 PA9/PA10、设波特率 115200、重定向printf。
// usart.c:USART1 初始化,PA9 TX / PA10 RX void USART1_Init(u32 bound) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; // TX GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; // RX GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; // 浮空输入 GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = bound; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE); } // printf 重定向到 USART1 int fputc(int ch, FILE *f) { while ((USART1->SR & 0X40) == 0); // 等待发送寄存器空 USART1->DR = (u8)ch; return ch; }TX 引脚必须配成GPIO_Mode_AF_PP复用推挽,配成普通输出发不出数据,这是血泪经验。fputc里等的是USART_FLAG_TC(发送完成),不是TXE,等TXE在高速连续发送时可能丢最后一个字节。重定向后要在 Keil 里勾选 “Use MicroLIB”,否则printf会卡在半主机模式。
3.3 定时器 TIM3 中断与 PWM 输出
定时器例程一般分两块:TIM3 做 1ms 中断喂给延时或计数,TIM2/TIM4 做 PWM 调 LED 亮度或驱动舵机。
// tim.c:TIM3 定时 1ms 中断 void TIM3_Int_Init(u16 arr, u16 psc) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); TIM_TimeBaseStructure.TIM_Period = arr; // 自动重装载值 TIM_TimeBaseStructure.TIM_Prescaler = psc; // 预分频 TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); TIM_ITConfig(TIM3, TIM_IT_Update, ENABLE); // 使能更新中断 NVIC_InitStructure.NVIC_IRQChannel = TIM3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 3; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); TIM_Cmd(TIM3, ENABLE); }72MHz 下要 1ms 中断,psc取 7199 得到 10kHz 计数频率,arr取 9 得 1ms。PWM 部分用TIM_OCInitTypeDef配通道,TIM_Pulse决定占空比,改这个值就能调亮度。注意 TIM3 在 APB1 上,时钟是 72MHz 还是 36MHz 取决于RCC_CFGR_PPRE1的分频设置,算错分频会导致定时周期差一倍。
3.4 ADC 多通道采集与 DMA
ADC 例程一般做规则通道单次转换或连续转换加 DMA,采集 PA0~PA3 的电压值通过串口打印。
// adc.c:ADC1 通道 0~3 连续扫描 + DMA void ADC_DMA_Init(void) { ADC_InitTypeDef ADC_InitStructure; DMA_InitTypeDef DMA_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_InitStructure.DMA_PeripheralBaseAddr = (u32)&ADC1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (u32)adc_value; // 外部数组 DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = 4; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_Init(DMA1_Channel1, &DMA_InitStructure); DMA_Cmd(DMA1_Channel1, ENABLE); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = ENABLE; ADC_InitStructure.ADC_ContinuousConvMode = ENABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 4; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_2, 3, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 4, ADC_SampleTime_55Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); ADC_SoftwareStartConvCmd(ADC1, ENABLE); }ADC_SampleTime_55Cycles5是采样时间,信号源内阻大就加长,否则读数跳。DMA 用循环模式,数组adc_value[4]会被自动刷新,main里直接读数组换算电压:adc_value[i] * 3.3f / 4096。校准那两句不能省,省了精度差几个 LSB。
4. 避坑与排查:烧录、时钟、中断里最容易翻车的几处
4.1 程序下载成功但不运行
现象:Keil 提示 Flash Download 成功,复位后 LED 不亮、串口无输出。原因多半是启动文件选错或 BOOT 引脚状态不对。C8T6 是中容量,启动文件必须是startup_stm32f10x_md.s,选了hd或ld链接能过但向量表偏移错。另外 BOOT0 如果被拉高,芯片从系统存储器启动,用户程序根本不执行。解决:检查工程里启动文件、把 BOOT0 接 GND 再复位、确认 Options → Target 里 IROM1 起始地址是0x8000000、大小0x10000。
4.2 串口打印乱码
现象:波特率设 115200,串口助手收到一堆乱码。原因通常是系统时钟没切到 72MHz,USART 的波特率寄存器按 8MHz 算出来,实际时钟不对。也可能是串口助手波特率、停止位、校验位和代码不一致。解决:先用示波器或逻辑分析仪量 PA9 波形,或者临时把系统时钟改回默认 8MHz 看是否正常;确认SystemInit被调用、外部晶振起振(量 OSC_IN 有无 8MHz 正弦波),晶振不起振就换内部 RC 先跑通。
4.3 中断进不去或进去就死
现象:定时器配好了但中断服务函数不执行,或者一进中断就 HardFault。原因一是 NVIC 优先级分组没设,NVIC_PriorityGroupConfig没调导致抢占/子优先级混乱;二是中断服务函数名和启动文件里的向量名不一致,比如写成TIM3_IRQHandler少个下划线;三是中断里调了printf这种耗时函数,或者访问了未初始化指针。解决:核对stm32f10x_it.c里的函数名、在main开头调NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2)、中断里只置标志位,处理放主循环。
4.4 ADC 读数漂移或固定不变
现象:采集电位器电压,数值在 0 和 4095 之间乱跳,或者一直不变。原因:模拟输入引脚配成了输出模式、采样时间太短、参考电压不稳、DMA 数组没加volatile被编译器优化。解决:确认引脚是GPIO_Mode_AIN模拟输入、采样时间加到 239.5 周期、VDDA 就近加 0.1uF 加 10uF 滤波、DMA 目标数组声明为volatile u16 adc_value[4]。
5. 二次开发改造:把例程拼成自己的项目骨架
5.1 从单外设工程合并成多模块工程
例程每个文件夹是独立工程,实际做项目要合并。我的习惯是新建一个工程,把CORE、STM32F10X_FWLib、SYSTEM原样拷过来,HARDWARE下按自己的外设建文件夹,USER/main.c里按“时钟 → 延时 → 串口 → 外设”的顺序初始化。合并时最容易出问题的是stm32f10x_conf.h,多个例程的配置不一样,以功能最全的那份为准,把用到的外设头文件都放开。另外SYSTEM/delay如果用的是 SysTick,注意别和HAL_Delay或别的定时器冲突,一个工程里只保留一套延时实现。
5.2 把轮询改成状态机或中断驱动
例程里按键消抖、串口收数据多是轮询,实际项目里主循环一忙就丢事件。改造思路:串口接收用USART_IT_RXNE中断加环形缓冲区,按键用定时器 10ms 扫描加状态机(空闲 → 按下 → 确认 → 释放),ADC 用 DMA 加半满/全满中断做双缓冲。下面是一个串口环形缓冲的骨架:
// 串口接收环形缓冲,中断里写,主循环里读 #define RX_BUF_SIZE 128 static volatile u8 rx_buf[RX_BUF_SIZE]; static volatile u16 rx_head = 0, rx_tail = 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { u8 data = USART_ReceiveData(USART1); u16 next = (rx_head + 1) % RX_BUF_SIZE; if (next != rx_tail) { // 缓冲区未满才写 rx_buf[rx_head] = data; rx_head = next; } USART_ClearITPendingBit(USART1, USART_IT_RXNE); } } u8 RingBuf_Read(u8 *out) { if (rx_head == rx_tail) return 0; // 空 *out = rx_buf[rx_tail]; rx_tail = (rx_tail + 1) % RX_BUF_SIZE; return 1; }rx_head和rx_tail都加volatile,防止编译器把判断优化掉;缓冲区大小取 2 的幂可以用位与代替取模,这里为了可读性用取模。主循环里while (RingBuf_Read(&ch))逐字节处理,不会阻塞。
5.3 验证改造是否成功的三个检查点
改完别急着上整机,先做三个检查:一是用串口打印各外设初始化返回值或关键寄存器值,确认时钟、波特率、ADC 校准状态符合预期;二是用逻辑分析仪抓 PWM 和串口波形,看频率、占空比、波特率是否和代码一致;三是故意制造边界条件,比如串口连续发 200 字节看环形缓冲会不会溢出、ADC 输入接 0V 和 3.3V 看读数是否贴边。这三步走完,再往业务逻辑上叠。
5.4 一个具体技巧:用宏开关裁剪外设
例程合并后编译慢、Flash 占用大,我一般会在main.h里加一组宏开关:
#define USE_LED 1 #define USE_USART1 1 #define USE_TIM3 0 #define USE_ADC_DMA 0然后在main.c里用#if USE_LED包住初始化调用,stm32f10x_conf.h里对应外设头文件也按宏条件包含。这样同一套代码能出多个配置的固件,调试版全开、量产版只留必要外设,Flash 能从 20KB 压到 8KB 左右。改宏的时候注意stm32f10x_conf.h里被关掉的外设,对应的stm32f10x_xxx.c也要从工程里移除,否则链接会报重复定义。
从那以后我每次合并例程,都强制先把stm32f10x_conf.h和启动文件对一遍,再跑一个最小点灯工程确认时钟链路通,最后才逐个加外设——这个顺序能省掉大半“明明代码一样却不跑”的排查时间。希望帮到你。
本文还有配套的精品资源,点击获取