☰
STM32F103实战:76个例程从点灯到联网的完整开发指南
2026/10/18 20:14:40 网站建设 项目流程

简介:这份源码包面向嵌入式初学者与硬件工程师,提供基于STM32F103芯片的76个完整工程例程,全部实测可编译下载运行,帮助读者快速掌握底层驱动原理与硬件交互流程。包内共2000个文件,以898个.h头文件与855个.c源文件为核心,辅以113个.htm、57个.html说明页面及77个.doc文档,压缩包约189.89MB,每个例程独立成文件夹并附带标准外设库工程模板,支持Keil MDK开发环境。内容覆盖LCD1602滚动显示、DS1302万年历、DS18B20数字温度计、PWM调速、ADC采集串口打印、EEPROM开关机次数记录、红外遥控解码、PS2键盘输入、TFT汉字显示、RTC实时时钟、外部中断、矩阵按键、数码管动态与静态显示、秒表计时及USART通信等典型功能。代码无加密、不依赖第三方闭源库,引脚定义清晰,初始化逻辑完整,并配套printf重定向、延时函数与LED指示等基础模块,注释详尽,便于理解与二次开发。目前已有60人学习。

1. 76个例程到底解决什么问题:从点灯到联网的完整拼图

很多人第一次拿到 STM32F103 实战项目源码包,第一反应是“76 个例程,我该从哪个开始看”。我当年也是这么想的,结果在文件夹里翻了半小时,点了个 LED 闪烁就关掉了。后来带新人做硬件功能验证,才发现这 76 个例程真正的价值不是数量,而是它把一块芯片从“能跑”到“能用”的路径全部铺开了。STM32F103 这颗 Cortex-M3 内核的经典型号,外设资源刚好够覆盖大多数工业控制和消费电子的入门需求,GPIO、USART、SPI、I2C、ADC、DMA、定时器、CAN、USB 设备这些模块,每一个都有独立的可编译工程。你不需要自己从零搭时钟树、配中断向量表,直接打开对应例程改引脚和参数就能验证硬件。适合谁?适合刚画完第一块 F103 板子、焊完发现串口没输出的硬件工程师,也适合想从标准库迁移到 HAL 库的嵌入式开发者。76 个例程不是让你全跑一遍,而是让你在遇到具体问题时,能快速找到那个“最小可运行参考”。

2. 把第一个工程编译进芯片:环境搭建与最小验证

2.1 工具链选型:Keil、IAR 还是 GCC

STM32F103 的例程包通常提供多种工程文件,常见的是 Keil MDK 的.uvprojx和 IAR 的.eww,部分会带 Makefile 或 CMake 用于 GCC 工具链。我一般建议新手先用 Keil MDK,因为它的器件支持包安装最直接,调试器配置界面也最直观。如果你所在团队有授权限制,或者你习惯在 Linux 下开发,那就选arm-none-eabi-gcc配合 Makefile,例程包里如果有Makefile文件,直接make就能出.elf和.hex。IAR 的编译优化等级和 Keil 有差异,同一个例程在 IAR 下可能因为优化等级不同导致延时函数不准,这个后面避坑章节会细说。选型原则就一条:你手头调试器支持哪个就用哪个,J-Link 和 ST-Link 对三家工具链都兼容,不用纠结。

2.2 用 ST-Link 烧录第一个 LED 例程

假设你拿到的是 Keil 工程,目录结构一般是Projects/下按外设分文件夹,比如GPIO_LED/、USART_Printf/。打开GPIO_LED里的工程文件,先别急着编译,检查三个地方:器件型号是否选对(STM32F103C8 还是 STM32F103ZE,Flash 和 RAM 大小不同)、调试器是否选为 ST-Link、下载算法是否加载了对应 Flash 算法。确认后按 F7 编译,再按 F8 下载。如果下载后 LED 不亮,先量引脚电压,再查例程里 GPIO 初始化的引脚号和你板子上的 LED 是否一致。

// GPIO_LED 例程中 LED 初始化片段(标准库风格) void LED_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); // 使能 GPIOC 时钟 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13; // 假设 LED 接在 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); // 默认熄灭(高电平) }

这段代码的逻辑很直白:先开时钟,再配引脚模式和速度,最后给一个默认电平。参数说明里最关键的是GPIO_Mode,推挽输出适合直接驱动 LED,如果是开漏输出,你得外接上拉电阻才能看到高电平。GPIO_Speed设 50MHz 对 LED 来说过剩,但不会烧,只是增加一点功耗和 EMI,实际产品里我会降到 2MHz。时钟使能那行如果漏了,GPIO 配置写进去也不生效,这是新手最常见的“代码没错但不亮”的原因。

2.3 用串口打印确认系统时钟频率

LED 亮了只说明 GPIO 通了,系统时钟树配没配对还得看串口。打开USART_Printf例程,烧录后接 USB 转 TTL 到 PA9/PA10,波特率 115200。如果串口助手收到乱码,八成是 HSE 晶振没起振,代码里默认用外部 8MHz 晶振倍频到 72MHz,但你的板子可能焊的是 12MHz 或者根本没焊晶振。这时候要么改system_stm32f10x.c里的HSE_VALUE宏,要么在SystemInit里切换成 HSI 内部时钟。我一般会在main开头加一句打印SystemCoreClock变量,确认实际运行频率。

// 在 main 函数初始化串口后添加 printf("SystemCoreClock = %d Hz\r\n", SystemCoreClock);

如果打印出来是 72000000,说明时钟树配置正确;如果是 8000000,说明 HSE 启动失败自动切到了 HSI。这个验证步骤能帮你排除后面定时器、PWM、ADC 采样率不准的隐患,因为那些外设的时钟都挂在这棵树上。

3. 外设例程怎么挑怎么改:从 GPIO 到 CAN 的实操路径

3.1 按硬件板子实际连接筛选例程

76 个例程不可能每个都对应你手头的板子,我的做法是先列一张表,把板子上有的外设和例程目录名对应起来。比如你的板子有 SPI Flash、I2C 温湿度传感器、CAN 收发器,那就重点看SPI_Flash、I2C_EEPROM、CAN_Loopback这几个。没有的外设例程直接跳过,不用编译。筛选的时候注意例程的引脚定义,很多例程默认用的是某个特定开发板的引脚,比如 SPI1 的 NSS 用 PA4,但你的板子可能用软件片选接在 PB0,这时候改SPI_Init里的SPI_NSS参数为SPI_NSS_Soft,然后手动控制 GPIO 就行。

外设类型例程目录示例关键引脚常见改动点
GPIOGPIO_LED / GPIO_KeyPC13 / PA0引脚号、上下拉
USARTUSART_Printf / USART_InterruptPA9/PA10波特率、中断优先级
SPISPI_Flash / SPI_OLEDPA5/PA6/PA7片选模式、时钟极性
I2CI2C_EEPROM / I2C_MPU6050PB6/PB7从机地址、速率
ADCADC_DMA / ADC_TempSensorPA1采样时间、通道
CANCAN_Loopback / CAN_NormalPA11/PA12波特率、滤波器
定时器TIM_PWM / TIM_EncoderPA8 / PA6/PA7预分频、自动重载值

这张表不是让你背,而是让你在打开例程前先确认硬件连接,避免“编译通过但没现象”的无效调试。

3.2 改一个 PWM 呼吸灯:参数计算与验证

拿TIM_PWM例程改呼吸灯,核心是改预分频器Prescaler和自动重载值Period。假设系统时钟 72MHz,你要 1kHz 的 PWM 频率,那么72MHz / (Prescaler+1) / (Period+1) = 1kHz。我一般设Prescaler = 71,得到 1MHz 计数频率,再设Period = 999,得到 1kHz。占空比通过TIM_SetCompare1改,从 0 到 1000 循环就是呼吸效果。

// TIM3 PWM 初始化关键参数 TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); TIM_TimeBaseStructure.TIM_Period = 999; // 自动重载值 TIM_TimeBaseStructure.TIM_Prescaler = 71; // 预分频器 TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 500; // 初始占空比 50% TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC2Init(TIM3, &TIM_OCInitStructure); // 通道 2 对应 PA7 TIM_Cmd(TIM3, ENABLE);

参数说明:TIM_Prescaler和TIM_Period都是 16 位寄存器,实际分频系数是寄存器值加一,这个“加一”坑过很多人。TIM_Pulse决定占空比,值必须小于等于TIM_Period,否则输出恒高。验证方法很简单,接一个 LED 在 PA7 和 GND 之间,串一个 1k 电阻,呼吸效果肉眼可见。如果 LED 常亮不呼吸,检查TIM_Cmd有没有使能,或者通道引脚有没有被其他外设复用。

3.3 用 DMA 解放 CPU:ADC 多通道采集

ADC_DMA例程是 76 个里比较有实战价值的一个,它演示了 ADC 规则组多通道扫描加 DMA 搬运。配置步骤:先开 ADC1 和 DMA1 时钟,配 DMA 通道 1 为从 ADC1 数据寄存器搬到内存数组,循环模式,数据宽度 16 位。然后配 ADC 为扫描模式、连续转换、外部触发关闭,设置通道数和采样时间。最后ADC_SoftwareStartConvCmd(ADC1, ENABLE)启动,DMA 就会自动把每个通道的转换结果填进数组。

// ADC + DMA 关键配置片段 DMA_InitTypeDef DMA_InitStructure; ADC_InitTypeDef ADC_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel1); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)adc_values; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = 4; // 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); // ... 配置通道 1 到 3 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);

逻辑说明:DMA 的BufferSize必须等于 ADC 规则组通道数,否则数组会越界或数据错位。DMA_Mode_Circular让 DMA 搬完一轮自动重头开始,适合连续采集。ADC 校准那两步不能省,不校准的话采样值偏差可能超过 5%。参数上,ADC_SampleTime_55Cycles5适合中低阻抗信号源,如果信号源内阻大,要加到 239.5 周期。验证时用串口打印adc_values数组,手摸通道引脚看数值变化,变化趋势对就说明通了。

4. 避坑与排查:76个例程里最容易翻车的五个地方

4.1 编译通过但下载失败:Flash 算法与复位模式

现象是 Keil 里编译零错误零警告,点下载却弹“No Algorithm found”或者“Flash Download failed”。原因通常是工程选用的器件型号和实际芯片不符,比如工程选的是 STM32F103ZE(512K Flash),你板子上是 STM32F103C8(64K Flash),下载算法按大容量配的,地址范围对不上。解决方法是打开 Options for Target → Device,改成实际型号,再到 Utilities → Settings 里确认 Flash 算法列表里有对应容量的算法。另一个原因是 ST-Link 的复位模式设成了Hardware Reset,但板子没接复位线,改成Software Reset或Connect under Reset就能下进去。

4.2 串口乱码:时钟源与波特率误差

串口打印乱码是高频问题,我遇到过三种原因。第一种是 HSE 晶振没起振,系统跑在 HSI 8MHz 上,但代码按 72MHz 算波特率,实际波特率变成 115200 的九分之一,收到全是乱码。第二种是波特率误差累积,72MHz 下配 115200 的误差约 0.16%,一般没事,但如果你改成 9600 又用了错误的预分频,误差可能超过 3%,就会间歇性乱码。第三种是 USB 转 TTL 模块的电平不匹配,有些模块是 5V 电平,F103 是 3.3V,长期用可能损坏引脚。排查顺序:先量 PA9 引脚在空闲时是不是高电平,再用示波器看波形周期算实际波特率,最后换一个确认好的串口模块对比。

4.3 中断进不去:优先级分组与使能顺序

现象是配置了 EXTI 外部中断,按键按下去没反应。原因可能是 NVIC 优先级分组没设,F103 默认分组是 0,所有优先级都是抢占优先级,如果你把两个中断设成同一抢占优先级,高优先级的中断会阻塞低优先级的。解决方法是NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2)设成 2 位抢占 2 位响应,然后给每个中断分配不同的抢占优先级。另一个原因是EXTI_Init之后忘了调NVIC_Init,或者EXTI_GenerateSWInterrupt没清标志位,导致中断只进一次。我一般会在中断服务函数第一行清标志,再处理业务。

4.4 定时器不准:预分频器写入时机

定时器例程改参数后定时不准,比如设了 1 秒定时但实际 1.2 秒。原因通常是TIM_Prescaler和TIM_Period在定时器运行中修改,F103 的预分频器有影子寄存器,写入后要等下一个更新事件才生效。正确做法是先TIM_Cmd(TIMx, DISABLE),改完参数再TIM_Cmd(TIMx, ENABLE)。另外TIM_TimeBaseInit会自动生成更新事件把影子寄存器加载进去,如果你在初始化后立刻改TIM_Prescaler而不重新初始化,新值不会生效。验证方法是用示波器测定时器输出引脚翻转周期,或者用调试器看TIMx->CNT计数。

4.5 低功耗模式唤醒后外设异常

有些例程演示PWR_EnterSTOPMode进入停止模式,唤醒后串口收不到数据。原因是停止模式会关闭 HSE 和 PLL,唤醒后系统时钟默认切回 HSI,虽然代码里可能重新配了时钟,但外设的时钟使能位在低功耗模式下被复位了。解决方法是唤醒后重新调用SystemInit或者手动重新使能外设时钟,再重新初始化串口。我一般会在唤醒后加一个RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE)和USART_Cmd(USART1, ENABLE),确保串口恢复。这个坑在电池供电项目里特别常见,调试时容易误判为硬件故障。

5. 从例程到产品:把 76 个工程拆成自己的驱动库

5.1 提取公共初始化代码做成 board_init

76 个例程每个都有main里的初始化代码,但产品里不可能每个功能都从main开始写。我的做法是新建一个bsp.c和bsp.h,把 GPIO、时钟、串口、定时器这些基础初始化封装成BSP_Init(),在main里只调一次。比如把LED_Init、Key_Init、USART1_Init合并进去,用宏开关控制哪些外设启用。这样换一块板子只需要改bsp.h里的引脚定义,不用动业务代码。

// bsp.h 中引脚定义集中管理 #define LED1_PIN GPIO_Pin_13 #define LED1_PORT GPIOC #define LED1_CLK RCC_APB2Periph_GPIOC #define USART1_BAUD 115200 #define KEY1_PIN GPIO_Pin_0 #define KEY1_PORT GPIOA

参数说明:把端口、引脚、时钟宏分开定义,是为了在bsp.c里用#ifdef条件编译。比如某个板子没有 LED,就把LED1_PIN注释掉,BSP_Init里对应的初始化代码自动跳过。这个习惯能让你在多个硬件版本之间快速切换,不用每次翻例程找引脚。

5.2 用状态机改造阻塞式例程

很多例程的main里是while(1)加delay_ms的阻塞结构,比如按键消抖用delay_ms(20),这在实际产品里会拖慢整个系统。我的改法是引入一个简单的状态机,把延时改成计数器。比如按键扫描每 10ms 执行一次,用systick或者定时器中断提供时基,主循环里只判断标志位。这样串口接收、ADC 采集、按键处理可以并行不阻塞。

// 非阻塞按键扫描状态机 typedef enum { KEY_IDLE, KEY_DEBOUNCE, KEY_PRESSED } KeyState; KeyState key_state = KEY_IDLE; uint16_t key_counter = 0; void Key_Scan_NonBlocking(void) { switch(key_state) { case KEY_IDLE: if (GPIO_ReadInputDataBit(KEY1_PORT, KEY1_PIN) == 0) { key_state = KEY_DEBOUNCE; key_counter = 0; } break; case KEY_DEBOUNCE: if (++key_counter >= 2) { // 2 * 10ms = 20ms 消抖 if (GPIO_ReadInputDataBit(KEY1_PORT, KEY1_PIN) == 0) { key_state = KEY_PRESSED; // 触发按键事件 } else { key_state = KEY_IDLE; } } break; case KEY_PRESSED: if (GPIO_ReadInputDataBit(KEY1_PORT, KEY1_PIN) == 1) { key_state = KEY_IDLE; } break; } }

逻辑说明:这个状态机每 10ms 被调用一次,KEY_DEBOUNCE状态累计两次确认按下,避免抖动误触发。KEY_PRESSED状态等待释放,防止长按重复触发。参数key_counter >= 2对应 20ms 消抖时间,如果按键质量差可以加到 5(50ms)。这个改造不依赖delay,主循环可以继续跑其他任务。

5.3 用条件编译管理不同硬件版本

产品迭代时硬件改版是常事,比如第一版用 SPI Flash,第二版换成 SD 卡。我的做法是在bsp.h里定义HARDWARE_VERSION宏,然后在驱动初始化里用#if切换。这样同一套代码可以编译出不同硬件版本的固件,不用维护多个分支。

#define HW_VER_1 1 #define HW_VER_2 2 #define HARDWARE_VERSION HW_VER_2 #if (HARDWARE_VERSION == HW_VER_1) #define STORAGE_SPI_FLASH 1 #elif (HARDWARE_VERSION == HW_VER_2) #define STORAGE_SD_CARD 1 #endif

参数说明:HARDWARE_VERSION在编译时确定,不占用运行时资源。STORAGE_SPI_FLASH和STORAGE_SD_CARD作为功能开关,在Storage_Init()里分别调用对应的底层驱动。这个技巧在 76 个例程的基础上做产品化时特别有用,因为例程本身是按外设分的,你只需要把它们按硬件版本重新组合。

5.4 用串口命令行做运行时调试

产品调试阶段不可能每次都接调试器,我习惯在串口上做一个简单的命令行,支持查看变量、修改参数、重启设备。这个功能可以从USART_Interrupt例程改过来,把接收中断里的数据存进环形缓冲区,主循环里解析命令。比如输入adc打印当前 ADC 值,输入reset触发软复位。

// 简易命令行解析片段 void CLI_Process(void) { if (cmd_ready) { if (strcmp(cmd_buffer, "adc") == 0) { printf("ADC0=%d ADC1=%d\r\n", adc_values[0], adc_values[1]); } else if (strcmp(cmd_buffer, "reset") == 0) { NVIC_SystemReset(); } else if (strncmp(cmd_buffer, "pwm ", 4) == 0) { int val = atoi(&cmd_buffer[4]); TIM_SetCompare2(TIM3, val); printf("PWM set to %d\r\n", val); } cmd_ready = 0; cmd_index = 0; } }

逻辑说明:cmd_ready在串口中断收到回车换行时置位,主循环检测到后解析。cmd_buffer是环形缓冲区里拼出来的字符串,atoi把参数字符串转成整数。这个命令行不需要操作系统,纯裸机就能跑,占用的 RAM 不到 200 字节。参数上注意cmd_buffer长度要限制,防止溢出,我一般设 32 字节,超长直接丢弃。

5.5 把例程当测试用例而不是模板

最后说一个习惯:76 个例程我从来不直接复制到产品代码里,而是把它们当成硬件测试用例。每块新板子回来,先跑GPIO_LED确认电源和最小系统,再跑USART_Printf确认时钟和串口,再跑SPI_Flash确认 SPI 通路,最后跑ADC_DMA确认模拟部分。全部通过再开始写业务代码。这个流程帮我省了很多“以为是软件问题其实是硬件虚焊”的调试时间。例程的代码风格不一定好,但它的寄存器配置是经过验证的,你可以放心参考,只是别把delay_ms和阻塞逻辑带进产品。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询