简介这份源码包面向嵌入式初学者与硬件工程师提供基于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 还是 GCCSTM32F103 的例程包通常提供多种工程文件常见的是 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 还是 STM32F103ZEFlash 和 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 / (Prescaler1) / (Period1) 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 解放 CPUADC 多通道采集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”。原因通常是工程选用的器件型号和实际芯片不符比如工程选的是 STM32F103ZE512K Flash你板子上是 STM32F103C864K 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_init76 个例程每个都有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 消抖时间如果按键质量差可以加到 550ms。这个改造不依赖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和阻塞逻辑带进产品。希望帮到你。本文还有配套的精品资源点击获取