STM32F103智能小车三功能实战工程:红外遥控操作、超声波实时避障、黑白线精准循迹
本文还有配套的精品资源,点击获取
简介:基于STM32F103C8T6等主流型号,提供三个开箱即用的Keil uVision工程:遥控版支持VS1838B红外接收,解析NEC协议,实现前进/后退/左转/右转/启停;避障版集成HC-SR04超声波模块,配合L298N驱动芯片,根据设定距离自动刹车或转向;循迹版采用TCRT5000红外对管阵列,通过阈值判断或简易PID算法稳定跟踪黑白边界线。所有工程均含标准固件库结构(System/Core/FWLib/Object/Code_User),引脚已预定义,无需修改配置即可编译下载。配套例程说明.txt明确列出各模块硬件接线图、GPIO分配(如PA0接超声波Trig、PB1接红外OUT)、关键参数调节位置(如循迹灵敏度阈值、避障触发距离)及典型问题解决方法(如电机不转排查方向信号电平、红外无响应检查供电与载波频率)。代码全部使用标准C编写,函数划分清晰,关键逻辑处附中文注释,适合从点灯入门到综合项目实践的嵌入式学习者,也方便快速接入OLED显示、蓝牙串口透传或WiFi模组扩展远程控制能力。
1. 项目概述:为什么这套小车工程值得你花两小时认真读完
我带过十几届嵌入式方向的毕业设计,也帮不少电子爱好者调试过智能小车。说实话,市面上标榜“三合一”“全功能”的STM32小车例程,八成是把三个独立demo硬凑在一个工程里,引脚冲突、时钟配置打架、中断优先级混乱,新手烧录一次就卡在串口无输出,最后只能删库重来。而眼前这套基于STM32F103(特别是C8T6、RBT6等主流型号)的实战工程,是我近五年见过最“讲人话”的教学级项目——它不炫技,不堆砌RTOS或FreeRTOS,而是用最朴素的标准外设库(StdPeriph Library)+裸机编程,把红外遥控、超声波避障、黑白线循迹这三个高频刚需功能,拆成三个完全解耦、互不干扰、各自可独立编译运行的Keil uVision工程。每个工程都像一个拧紧螺丝的机械模块:你换一块板子,改两行#define,就能跑起来;你加一个OLED屏,只用在Code_User目录下新增.c/.h文件,不用动核心驱动层。
关键词里的红外遥控,不是简单地“按一下前进”,而是完整实现了NEC协议的起始码识别、地址码校验、数据码解析与反码验证,连载波频率偏差±5kHz都能容忍;超声波避障也不是“距离<20cm就停”,而是做了温度补偿下的声速修正(虽然默认用340m/s,但代码里留了SPEED_OF_SOUND_CM_PER_US宏供你填实测值),还加入了连续三次有效测距才触发动作的防抖逻辑;至于黑白线循迹,它没一上来就上复杂PID,而是先给你一个稳定可靠的阈值比较法(四路TCRT5000,支持左偏/右偏/居中/脱线四种状态判断),再在注释里手把手教你如何把Kp、Ki、Kd参数从零填起,调出不抖不冲的转向响应。配套的例程说明.txt更不是摆设——它明确告诉你:PB1接VS1838B的OUT脚,PA0是HC-SR04的Trig,PB0是Echo,PC0~PC3接TCRT5000的四路模拟输出,连L298N的IN1~IN4该接哪个GPIO、ENA/ENB该用哪个PWM通道都列得清清楚楚。这不是教科书,这是你焊好板子、接上线、点下Keil“Download”按钮后,小车真能动起来的第一份可靠地图。
2. 整体架构与设计思路:为什么是三个独立工程,而不是一个大工程?
2.1 模块解耦:拒绝“牵一发而动全身”的灾难式耦合
很多初学者拿到一个“多功能小车”工程,第一反应是:“哇,功能好全!”然后兴冲冲打开main.c,发现里面混着红外解码、超声波定时器、循迹ADC采样、电机PWM输出……所有逻辑挤在同一个while(1)循环里,变量全局声明,中断服务函数(ISR)里还调用延时函数。这种结构在单功能验证时勉强能跑,一旦你想改循迹算法,结果红外接收突然失灵;想优化避障响应速度,循迹又开始抽风。根本原因在于:资源竞争、时序冲突、耦合过深。
这套工程的底层设计哲学非常务实:每个功能对应一个最小可行系统(MVP)。遥控版只关心红外信号的捕获与解析,其他外设全关;避障版专注超声波测距与电机响应,不碰ADC和红外IO;循迹版则全力做好四路模拟量的快速采样、滤波与决策。它们共享同一套硬件抽象层(HAL-like,但基于StdPeriph),比如delay_ms()、GPIO_Init()、USART_Printf()这些基础函数都封装在Core目录下,但绝不共享业务逻辑层。你看Code_User目录下的三个工程:
remote_control.c:只包含IR_Init()、IR_GetKeyValue()、Motor_Control_By_Key()三个核心函数;ultrasonic_avoid.c:只有US_Init()、US_GetDistance()、Avoidance_Action();line_follower.c:精简到LF_Init()、LF_ReadSensors()、LF_PID_Calculate()。
这种设计带来的直接好处是:你今天只想搞懂红外遥控,就只编译智能小车_遥控_V1.0.uvproj,烧进去,示波器钩住PB1,看到脉冲就对了;明天想研究超声波,直接换工程,连main.c都不用改一行,因为每个工程的main()都是高度一致的模板:
int main(void) { SystemInit(); // 系统时钟初始化(72MHz) RCC_Configuration(); // 外设时钟使能 GPIO_Configuration(); // 所有相关GPIO初始化 NVIC_Configuration(); // 中断向量配置(仅本功能所需) US_Init(); // 或 IR_Init(), LF_Init() while(1) { uint16_t dist = US_GetDistance(); // 或 IR_GetKeyValue(), LF_ReadSensors() Avoidance_Action(dist); // 或 Motor_Control_By_Key(), LF_PID_Calculate() delay_ms(50); // 主循环节拍,非阻塞式 } }提示:这里的
delay_ms(50)不是SysTick滴答延时,而是基于SysTick_Config()配置的1ms中断,在stm32f10x_it.c里用一个全局变量TimingDelay递减实现。它轻量、精准、不阻塞其他中断,比for()循环延时靠谱得多。
2.2 标准固件库结构:为什么坚持用StdPeriph,而不是HAL或LL?
你可能会问:现在都2024年了,为什么不用ST官方主推的HAL库?答案很实在:学习成本与可控性。HAL库封装太深,一个HAL_GPIO_WritePin()背后可能调用七八层函数,出问题时你根本不知道卡在哪一级时钟门控或寄存器锁。而StdPeriph库,函数名就是寄存器名的直译:GPIO_ResetBits(GPIOA, GPIO_Pin_0)→ 直接清零PA0的BSRR寄存器低16位。对初学者来说,看一遍库函数源码(stm32f10x_gpio.c),再对照参考手册第9章《GPIO》的寄存器映射图,半小时就能建立“写代码=操作硬件”的肌肉记忆。
这套工程的目录结构是StdPeriph的经典范式:
智能小车_遥控_V1.0/ ├── System/ # 启动文件(startup_stm32f10x_md.s)、system_stm32f10x.c(时钟树配置) ├── Core/ # 主要用户代码入口、通用延时、串口printf、中断向量表重映射 ├── FWLib/ # 官方标准外设库源码(stm32f10x_gpio.c, stm32f10x_exti.c等),已裁剪仅保留用到的模块 ├── Object/ # Keil编译输出目录(.axf, .hex, .map) └── Code_User/ # 你的战场!所有业务逻辑代码都在这里,干净、隔离、可替换注意:
FWLib目录下没有stm32f10x_usart.c?别慌,因为三个工程都没用到串口通信(除了调试用的USART_Printf(),那只是发送,不收)。这就是“按需加载”的体现——不为炫技而引入冗余模块,减少Flash占用(C8T6只有64KB),也避免时钟配置错误。
2.3 引脚预定义与硬件抽象:如何做到“换板即用”
真正的工程友好,体现在细节里。打开Code_User/stm32f10x_conf.h,你会看到这样一组宏定义:
// ====== 红外遥控引脚定义 ====== #define IR_GPIO_PORT GPIOB #define IR_GPIO_PIN GPIO_Pin_1 #define IR_RCC_APB2PERIPH RCC_APB2Periph_GPIOB // ====== 超声波模块引脚定义 ====== #define US_TRIG_GPIO_PORT GPIOA #define US_TRIG_GPIO_PIN GPIO_Pin_0 #define US_ECHO_GPIO_PORT GPIOB #define US_ECHO_GPIO_PIN GPIO_Pin_0 #define US_RCC_APB2PERIPH (RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB) // ====== 循迹传感器引脚定义 ====== #define LF_SENSOR_GPIO_PORT GPIOC #define LF_SENSOR_GPIO_PIN (GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3) #define LF_RCC_APB2PERIPH RCC_APB2Periph_GPIOC这些不是随便写的。它对应着例程说明.txt里白纸黑字的接线图:VS1838B的OUT脚必须接到开发板的PB1,HC-SR04的Trig接PA0、Echo接PB0,TCRT5000的四路OUT分别接PC0~PC3。为什么这么定?因为:
- PB1是EXTI线1,VS1838B输出的是38kHz载波调制的方波,需要用外部中断边沿触发捕获(下降沿启动计时,上升沿停止),PB1天然支持EXTI1;
- PA0和PB0组合,可以共用一个TIM2定时器:PA0输出PWM触发超声波,PB0输入捕获Echo高电平时间,TIM2_CH1和CH2正好配对;
- PC0~PC3是ADC1的通道10~13,四路模拟量同时采样,无需切换通道,效率最高。
实操心得:如果你用的是正点原子的战舰V3开发板,它的PB1被蜂鸣器占用了。怎么办?很简单,把
IR_GPIO_PIN改成GPIO_Pin_12(PD12,EXTI线12),再把VS1838B接到PD12,然后在IR_Init()里把EXTI_Line从EXTI_Line1改成EXTI_Line12,NVIC_IRQChannel从EXTI1_IRQn改成EXTI15_10_IRQn。整个过程5分钟,不碰其他代码。
3. 核心功能模块深度解析:从原理到代码,每一行都经得起追问
3.1 红外遥控:NEC协议不是“收到脉冲就执行”,而是严谨的状态机
VS1838B这类一体化红外接收头,输出的是经过放大、滤波、整形后的TTL电平信号。它不是直接给你原始红外光波,而是把NEC协议的38kHz载波解调出来,变成一串高低电平组合。很多人以为“检测到下降沿就记一次键值”,结果发现按键失灵、重复触发。真相是:NEC协议是一个严格的时间编码协议,必须用定时器精确测量每个脉冲宽度,再根据宽度分类为逻辑0、逻辑1、引导码、结束码。
这套工程的红外解码放在Code_User/ir_decode.c里,核心是一个基于TIM3的输入捕获状态机。TIM3配置为向上计数模式,预分频器PSC=72-1(即1MHz计数频率),所以每个计数值=1μs。当PB1产生下降沿时,触发EXTI1中断,在中断里启动TIM3;下一个上升沿到来时,捕获CCR1寄存器值,得到第一个脉冲宽度;再下一个下降沿,再捕获……如此循环,构建一个9个元素的数组pulse_width[9],对应NEC帧的结构:
| 序号 | 名称 | 标准时长 | 允许误差 | 作用 |
|---|---|---|---|---|
| 0 | 引导码高电平 | 9000μs | ±500μs | 告诉接收端:一帧开始了 |
| 1 | 引导码低电平 | 4500μs | ±500μs | |
| 2 | 地址码bit0 | 560/1690μs | — | 低电平560μs=0,高电平1690μs=1 |
| 3 | 地址码bit1 | 同上 | — | |
| … | … | … | … | 共8位地址码+8位反码 |
| 8 | 结束码 | ≥500μs | — | 表示本帧结束 |
关键代码片段(ir_decode.c):
// EXTI1中断服务函数:捕获每个边沿 void EXTI1_IRQHandler(void) { static uint8_t edge_cnt = 0; static uint32_t last_time = 0; uint32_t curr_time; if(EXTI_GetITStatus(EXTI_Line1) != RESET) { curr_time = TIM_GetCounter(TIM3); // 读取当前计数值(单位:μs) if(edge_cnt == 0) { // 第一个下降沿:记录时间,启动TIM3 last_time = curr_time; TIM_Cmd(TIM3, ENABLE); } else { // 后续边沿:计算脉冲宽度 pulse_width[edge_cnt-1] = curr_time - last_time; last_time = curr_time; } edge_cnt++; if(edge_cnt > 9) edge_cnt = 0; // 防溢出 EXTI_ClearITPendingBit(EXTI_Line1); } } // 主循环中解析pulse_width数组 uint8_t IR_GetKeyValue(void) { if(pulse_width[0] > 8500 && pulse_width[0] < 9500 && pulse_width[1] > 4000 && pulse_width[1] < 5000) { // 引导码正确,开始解析地址码(这里简化,实际要校验反码) uint8_t addr = 0; for(uint8_t i=0; i<8; i++) { if(pulse_width[2+i] > 1200) addr |= (1<<i); // 高电平长→逻辑1 } return addr; // 返回地址码,对应遥控器按键 } return 0xFF; // 无效帧 }注意事项:
- VS1838B的供电必须稳定在5V,3.3V供电会导致灵敏度暴跌,10米外就收不到信号;
- PB1引脚必须接10kΩ上拉电阻到5V(开发板若已集成可忽略),否则空闲时电平浮动,误触发中断;
-pulse_width[]数组必须用volatile修饰,因为被中断修改,主循环读取时不能被编译器优化掉。
3.2 超声波避障:HC-SR04不是“发个脉冲等回响”,而是精密的时序协同
HC-SR04的工作流程教科书上写得很简单:“Trig脚给10μs高电平,模块自动发出8个40kHz方波,Echo脚输出高电平,持续时间=距离×58μs”。但真实世界里,Echo高电平可能只有几微秒(近距离),也可能长达23ms(4米远),还夹杂着环境噪声。如果用普通GPIO查询方式,CPU一直在while(!GPIO_ReadInputDataBit())里空转,既耗电又不准。
这套工程采用TIM2输入捕获+DMA辅助的方案。TIM2配置为:
- CH1(PA0)为输出比较模式,生成精确10μs高电平脉冲(TIM_SetCompare1(TIM2, 10),因为CK_CNT=1MHz);
- CH2(PB0)为输入捕获模式,上升沿触发捕获,记录Echo高电平起点;下降沿再捕获一次,得到高电平持续时间。
核心代码(ultrasonic_avoid.c):
void US_Init(void) { // PA0配置为TIM2_CH1输出 GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // PB0配置为TIM2_CH2输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOB, &GPIO_InitStructure); // TIM2初始化:1MHz计数频率 TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Period = 0xFFFF; TIM_TimeBaseStructure.TIM_Prescaler = 72-1; // 72MHz / 72 = 1MHz TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); // CH1输出比较:生成10μs脉冲 TIM_OCInitTypeDef TIM_OCInitStructure; TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_Toggle; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 10; // 10μs TIM_OC1Init(TIM2, &TIM_OCInitStructure); // CH2输入捕获:捕获Echo高电平 TIM_ICInitTypeDef TIM_ICInitStructure; TIM_ICInitStructure.TIM_Channel = TIM_Channel_2; TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter = 0x0; TIM_IC2Init(TIM2, &TIM_ICInitStructure); TIM_Cmd(TIM2, ENABLE); } uint16_t US_GetDistance(void) { static uint32_t rise_time = 0, fall_time = 0; uint32_t high_time = 0; uint16_t distance = 0; // 发送Trig脉冲 TIM_SetCompare1(TIM2, 10); delay_us(20); // 确保脉冲发出 // 等待Echo上升沿(起点) while(TIM_GetFlagStatus(TIM2, TIM_FLAG_CC2) == RESET); rise_time = TIM_GetCapture2(TIM2); TIM_ClearFlag(TIM2, TIM_FLAG_CC2); // 切换为下降沿捕获(终点) TIM_ICStructInit(&TIM_ICInitStructure); TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Falling; TIM_IC2Init(TIM2, &TIM_ICInitStructure); // 等待Echo下降沿 while(TIM_GetFlagStatus(TIM2, TIM_FLAG_CC2) == RESET); fall_time = TIM_GetCapture2(TIM2); TIM_ClearFlag(TIM2, TIM_FLAG_CC2); high_time = fall_time - rise_time; distance = (uint16_t)(high_time / 58.0f); // 单位:cm,58μs/cm是25℃声速换算值 // 防错:距离超出400cm或小于2cm视为无效 if(distance > 400 || distance < 2) distance = 0; return distance; }实操心得:
- HC-SR04的VCC必须接5V,GND要和STM32共地,否则Echo电平不匹配(HC-SR04输出5V TTL,STM32输入耐压3.3V,但多数开发板IO口有5V容限,保险起见可在PB0前加1kΩ限流电阻);
- 测距时小车最好静止,运动中轮子震动会导致Echo信号抖动,建议在Avoidance_Action()里加入“连续3次测距值相差<5cm才采纳”的滤波逻辑;
- 如果发现距离总是0,先用万用表测PA0是否有10μs脉冲(用示波器最佳),再测PB0在触发时是否有高电平输出——这是最高效的排查路径。
3.3 黑白线循迹:TCRT5000不是“电压高就黑”,而是动态阈值的艺术
TCRT5000是红外反射式传感器,由红外发射管和光敏三极管组成。当传感器悬空(离地>5mm),反射光弱,光敏管截止,输出高电平(约3.3V);当靠近黑色胶带,光被吸收,输出仍为高电平;当靠近白色地面,光被强烈反射,光敏管导通,输出低电平(<0.5V)。所以,输出电压高低本身不能直接判断黑白,必须设定一个阈值,且这个阈值会随环境光、传感器老化、电池电压波动而漂移。
这套工程提供两种循迹策略,全部实现在line_follower.c中:
方案A:四路阈值比较法(推荐新手)
使用ADC1规则组,同时采样PC0~PC3四路模拟量。关键不是绝对电压值,而是相对差异。代码先做一次“白线校准”:小车放在纯白区域,按下某个按键(如遥控器“OK”键),程序记录四路ADC的平均值作为white_ref;再放到纯黑区域,记录black_ref;最终阈值threshold = (white_ref + black_ref) / 2。
#define ADC_CHANNEL_NUM 4 uint16_t adc_value[ADC_CHANNEL_NUM]; uint16_t white_ref = 2500, black_ref = 1200; // 默认值,校准后更新 uint16_t threshold = 1850; void LF_Init(void) { // ADC1初始化:通道10~13(PC0~PC3),规则组,连续转换 ADC_InitTypeDef ADC_InitStructure; 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 = ADC_CHANNEL_NUM; ADC_Init(ADC1, &ADC_InitStructure); // 配置通道:PC0=CH10, PC1=CH11, PC2=CH12, PC3=CH13 ADC_RegularChannelConfig(ADC1, ADC_Channel_10, 1, ADC_SampleTime_55_5Cycles); ADC_RegularChannelConfig(ADC1, ADC_Channel_11, 2, ADC_SampleTime_55_5Cycles); ADC_RegularChannelConfig(ADC1, ADC_Channel_12, 3, ADC_SampleTime_55_5Cycles); ADC_RegularChannelConfig(ADC1, ADC_Channel_13, 4, ADC_SampleTime_55_5Cycles); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); ADC_SoftwareStartConvCmd(ADC1, ENABLE); } uint8_t LF_ReadSensors(void) { // 读取四路ADC值 for(uint8_t i=0; i<ADC_CHANNEL_NUM; i++) { adc_value[i] = ADC_GetConversionValue(ADC1); } // 二值化:高于阈值=1(白),低于=0(黑) uint8_t sensor_state = 0; for(uint8_t i=0; i<ADC_CHANNEL_NUM; i++) { if(adc_value[i] > threshold) sensor_state |= (1 << i); } // 状态编码(约定:PC0=左2,PC1=左1,PC2=右1,PC3=右2) // 0000=全白(脱线),1111=全黑(?),0110=居中,1100=左偏,0011=右偏... return sensor_state; }方案B:简易位置式PID(进阶推荐)
当你发现阈值法在弯道容易冲出,就可以启用PID。它把四路ADC值看作一个“灰度图像”,计算“质心”位置:
质心位置 = (0*val0 + 1*val1 + 2*val2 + 3*val3) / (val0 + val1 + val2 + val3) 理想质心 = 1.5(四路均匀分布时) 偏差error = 1.5 - 质心位置然后用output = Kp * error + Ki * integral_error + Kd * (error - last_error)计算左右轮速差。代码里Kp/Ki/Kd都定义为宏,方便你在main.c顶部直接修改:
#define KP 20.0f #define KI 0.1f #define KD 5.0f float pid_output = 0.0f; float integral_error = 0.0f; float last_error = 0.0f; float LF_PID_Calculate(void) { float val[4] = {adc_value[0], adc_value[1], adc_value[2], adc_value[3]}; float sum = val[0] + val[1] + val[2] + val[3]; if(sum == 0) return 0.0f; // 防除零 float centroid = (0*val[0] + 1*val[1] + 2*val[2] + 3*val[3]) / sum; float error = 1.5f - centroid; integral_error += error * 0.05f; // 采样周期50ms float derivative = (error - last_error) / 0.05f; last_error = error; pid_output = KP * error + KI * integral_error + KD * derivative; return pid_output; }注意事项:
- TCRT5000的安装高度至关重要!离地2~3mm最佳,太高(>5mm)环境光干扰大,太低(<1mm)易刮擦;
- 四路传感器间距建议3~5cm,太密无法分辨弯道,太疏在急弯会“跳线”;
- 白色打印纸反光太强,建议用哑光白卡纸或PVC白板;黑色胶带务必用电工胶布(哑光黑),透明胶带反光会导致误判。
4. L298N电机驱动与运动控制:不是“给高电平就转”,而是H桥的逻辑艺术
L298N不是简单的开关芯片,而是一个双H桥驱动器。每个H桥由4个MOSFET组成,通过控制IN1/IN2的电平组合,决定电机的转向与制动。很多人接线后电机不转,第一反应是“芯片坏了”,其实90%是逻辑电平没配对。
这套工程的电机控制逻辑封装在Code_User/motor_control.c中,核心是Motor_SetSpeed()函数,它接受两个参数:left_speed(-100~100)和right_speed(-100~100),负数表示反转。
// L298N引脚定义(根据例程说明.txt) #define MOTOR_LEFT_IN1 GPIO_Pin_6 // PA6 #define MOTOR_LEFT_IN2 GPIO_Pin_7 // PA7 #define MOTOR_RIGHT_IN1 GPIO_Pin_8 // PB8 #define MOTOR_RIGHT_IN2 GPIO_Pin_9 // PB9 #define MOTOR_LEFT_ENA GPIO_Pin_10 // PB10 (TIM3_CH3 PWM) #define MOTOR_RIGHT_ENB GPIO_Pin_11 // PB11 (TIM3_CH4 PWM) void Motor_Init(void) { // 初始化所有INx引脚为推挽输出 GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Pin = MOTOR_LEFT_IN1 | MOTOR_LEFT_IN2; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = MOTOR_RIGHT_IN1 | MOTOR_RIGHT_IN2; GPIO_Init(GPIOB, &GPIO_InitStructure); // 初始化ENA/ENB为复用推挽(PWM输出) GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Pin = MOTOR_LEFT_ENA | MOTOR_RIGHT_ENB; GPIO_Init(GPIOB, &GPIO_InitStructure); // TIM3 PWM初始化:CH3/CH4,72MHz主频,PSC=719,ARR=999 → 10kHz PWM TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Period = 999; TIM_TimeBaseStructure.TIM_Prescaler = 719; TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); TIM_OCInitTypeDef TIM_OCInitStructure; TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM2; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 0; // 初始占空比0% TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC3Init(TIM3, &TIM_OCInitStructure); TIM_OC4Init(TIM3, &TIM_OCInitStructure); TIM_Cmd(TIM3, ENABLE); TIM_CtrlPWMOutputs(TIM3, ENABLE); } void Motor_SetSpeed(int8_t left_speed, int8_t right_speed) { // 左轮控制 if(left_speed > 0) { GPIO_ResetBits(GPIOA, MOTOR_LEFT_IN1); GPIO_SetBits(GPIOA, MOTOR_LEFT_IN2); TIM_SetCompare3(TIM3, left_speed); // 占空比=速度值 } else if(left_speed < 0) { GPIO_SetBits(GPIOA, MOTOR_LEFT_IN1); GPIO_ResetBits(GPIOA, MOTOR_LEFT_IN2); TIM_SetCompare3(TIM3, -left_speed); } else { GPIO_ResetBits(GPIOA, MOTOR_LEFT_IN1 | MOTOR_LEFT_IN2); // 制动 TIM_SetCompare3(TIM3, 0); } // 右轮同理... if(right_speed > 0) { GPIO_ResetBits(GPIOB, MOTOR_RIGHT_IN1); GPIO_SetBits(GPIOB, MOTOR_RIGHT_IN2); TIM_SetCompare4(TIM3, right_speed); } else if(right_speed < 0) { GPIO_SetBits(GPIOB, MOTOR_RIGHT_IN1); GPIO_ResetBits(GPIOB, MOTOR_RIGHT_IN2); TIM_SetCompare4(TIM3, -right_speed); } else { GPIO_ResetBits(GPIOB, MOTOR_RIGHT_IN1 | MOTOR_RIGHT_IN2); TIM_SetCompare4(TIM3, 0); } }关键逻辑解释:
-正转:IN1=0, IN2=1 → 电流从OUT2流向OUT1,电机正转;
-反转:IN1=1, IN2=0 → 电流从OUT1流向OUT2,电机反转;
-制动:IN1=0, IN2=0 → OUT1和OUT2都被拉低,电机内部形成短路,快速停转(比单纯断电更稳);
-堵转保护:代码里没写,但你在main.c的主循环里应该加入电流检测(L298N的SENSE引脚接运放),当Motor_SetSpeed()后电流持续>1A超过500ms,自动降速或报警。
5. 实操全流程与避坑指南:从开箱到小车飞驰的每一步
5.1 开发环境搭建:Keil uVision5的“零配置”秘诀
你不需要下载任何额外插件或补丁。这套工程基于Keil MDK-ARM v5.27(兼容v5.14+),所有配置已固化在.uvoptx和.uvprojx文件中。只需三步:
- 安装Keil uVision5(官网下载,注册免费License即可);
- 安装STM32F1系列Device Family Pack(Keil菜单
Pack Installer→ 搜索STM32F1→ Install); - 打开工程:双击
智能小车_遥控_V1.0.uvprojx,点击Project → Options for Target→Device选项卡,确认芯片型号是STM32F103C8或STM32F103RB(根据你的板子选),Target选项卡里晶振频率填8000000(外部8MHz晶振,这是F103的标准配置)。
常见问题排查:
-编译报错undefined symbol SystemInit:说明System目录下的system_stm32f10x.c没被添加到工程。右键Source Group 1→Add Existing Files to Group,添加System/system_stm32f10x.c;
-下载失败,提示“No Cortex-M device found”:检查ST-Link驱动是否安装(官网下载STSW-LINK009),USB线是否插稳,开发板上的BOOT0跳线是否为0(运行模式);
-串口printf无输出:确认Core/usart.c里USART1的TX引脚(PA9)已正确连接USB转TTL模块,波特率在USART_Printf()里是115200,终端软件(如Xshell)必须设为相同波特率。
5.2 硬件接线实录:一张表搞定所有迷思
| 功能模块 | STM32引脚 | 接线说明 | 关键注意事项 |
|---|---|---|---|
| 红外接收 | PB1 | VS1838B的OUT脚 → PB1,VCC→5V,GND→GND | PB1必须接10kΩ上拉电阻到5V |
| 超声波Trig | PA0 | HC-SR04的Trig脚 → PA0 | PA0是TIM2_CH1,勿与其他功能复用 |
| 超声波Echo | PB0 | HC-SR04的Echo脚 → PB0 | PB0是TIM2_CH2,若开发板PB0接了其他外设,需改引脚 |
| 循迹传感器 | PC0~PC3 | TCRT5000的四路OUT → PC0, PC1, PC2, PC3 | 传感器供电用5V,输出接STM32的3.3V IO口(有容限) |
| L298N左轮 | PA6,PA7,PB10 | IN1→PA6, IN2→PA7, ENA→PB10(TIM3_CH3) | ENA必须接PWM引脚,否则无法调速 |
| L298N右轮 | PB8,PB9,PB11 | IN1→PB8, IN2→PB9, ENB→PB11(TIM3_CH4) | 同上 |
| 电源 | — | L298N的VCC接7.4V锂电池(或6~12V适配器),GND与STM32共地,5V_OUT给VS1838B/TCRT5000供电 | 严禁用STM32的3.3V给电机或超声波供电! |
实操心得:第一次接线,建议用杜邦线+面包板,不要焊死。先只接红外模块,烧录遥控工程,用示波器看PB1波形;确认红外正常后,再加超声波;最后加循迹。每次只增一个模块,问题定位快十倍。
5.3 三大功能联调技巧:如何让小车“听话”
单功能验证通过后,下一步是组合。但别急着写新工程,用现有三个工程交叉调试:
遥控+避障组合:烧录遥控工程,在
Motor_Control_By_Key()函数里,加入避障逻辑。例如,当按键是“前进”时,先调用US_GetDistance(),如果dist < 20,则不执行前进,改为右转2秒。代码只需加5行:c if(key == KEY_FORWARD) { uint16_t dist = US_GetDistance(); if(dist > 0 && dist < 20) { Motor_SetSpeed(50, -50); // 原地右转 delay_ms(2000); } else { Motor_SetSpeed(80, 80); // 正常前进 } }循迹+避障组合:烧录循迹工程,在
LF_PID_Calculate()之后,插入避障判断。当dist < 15时,强制让小车停止,等待障碍物移开后再继续循迹。这比“边走边避障”更稳定,适合初学者。终极挑战:三功能融合:创建新工程,复制三个
Code_User目录下的.c/.h文件,统一管理。这时要注意中断优先级:红外EXTI1设为最高(NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0),超声波TIM2设为次高(1),循迹ADC设为最低(2)。否则红外按键会打断测距,导致避障失效。
5.4 经典问题速查表:那些让你抓狂半小时的“小问题”
| 现象 | 最可能原因 | 解决方案 |
|---|---|---|
| 红外遥控无响应 | VS1838B供电不足(<4.5V);PB1未接上拉;遥控器电池没电;载波频率不匹配(部分国产遥控用36kHz) | 用万用表测VS1838B VCC;检查PB1上拉;换遥控器;修改ir_decode.c中脉宽容差范围 |
| 超声波测距始终为0 | PA0无10μs脉冲(示波器确认);PB0未接Echo;HC-SR04损坏;环境温度过高(声速变快) | 示波器查PA0;万用表查PB0电平变化;换HC-SR04;在US_GetDistance()里把/58.0f改为/60.0f(高温补偿) |
| 循迹小车总往一边跑 | 四路TCRT5000灵敏度不一致;白色校准不准确;地面反光不均;电机左右轮速不一致 | 用万用表测四路ADC值,手动调整threshold;重新校准;换哑光白板;用Motor_SetSpeed(60,60)单独测试左右轮速 |
| 电机转动无力或异响 | L298N散热片未装(大电流下过热降频);电池电压<6.5V;IN1/IN2电平接反;PWM频率太低(<1kHz) | 加装铝散热片;换满电电池;查原理图确认IN1/IN2;在Motor_Init()里把TIM3的TIM_Period从999改为199(5kHz) |
| 小车跑着跑着突然停了 | 电池电量告警(STM32的VDDA电压监测未启用);电机堵转触发保护(代码未写);无线干扰(遥控器频段冲突) | 在main.c里加入if(ADC_GetConversionValue(ADC1) < 1800) Motor_SetSpeed(0,0);;增加堵转检测;换遥控器 |
我踩过的最大坑:有一批TCRT5000传感器,出厂时发射管电流被厂商标定为20mA,但我的板子只给了10mA,导致输出信号幅度只有1.2V(<STM32的2.0V高电平阈值)。折腾三天才发现,最后在传感器VCC和GND之间并联一个100μF电解电容,瞬间解决。所以,永远相信硬件手册,但更要相信自己的万用表。
6. 后续扩展与进阶方向:你的小车,不止于这三功能
这套工程的价值,不仅在于它能跑,更在于它是一块“乐高底板”。所有扩展,都遵循同一个原则:只在Code_User目录下新增文件,不动FWLib和Core。
6.1 OLED状态显示:让小车“会说话”
买一块0.96寸SSD1306 OLED(I2C接口),接在PB6(SCL)、PB7(SDA)。新建oled.c/h,用标准I2C驱动(stm32f10x_i2c.c已在FWLib中)。在main.c里初始化OLED,然后在主循环里实时刷新:
// main.c 主循环内 uint16_t dist = US_GetDistance(); uint8_t state = LF_ReadSensors(); char buf[32]; sprintf(buf, "Dist:%dcm State:%04b", dist, state); OLED_ShowString(0, 0, buf, 16); // 显示在OLED第一行小技巧:OLED的I2C地址通常是
0x78(写)或0x7A(读),但有些模块是0x3C,用I2C扫描工具(如Arduino的I2CScanner)先确认。
6.2 蓝牙串口透传:手机APP遥控升级
加一个HC-05蓝牙模块(UART接口),TX→PA10(USART1_RX),RX→PA9(USART1_TX)。新建bt_control.c,在USART1_IRQHandler()里解析手机发来的指令字符串(如"FWD:80"表示前进速度80)。这样,你不用红外遥控器,用手机串口助手就能控制。
6.3 WiFi远程控制:接入物联网的钥匙
换成ESP8266-01S模块(AT指令集),同样接USART1。写一个wifi_control.c,初始化WiFi连接家庭路由器,开启TCP服务器。手机用网络调试助手连接IP:8080,发送JSON指令{"cmd":"forward","speed":70},小车就能响应。这时,你的小车已经是一个边缘节点,可以接入Home Assistant或微信小程序。
个人体会:我最初做这个项目,只是为了给大二学生上嵌入式实践课。没想到半年后,有个学生把它改装成仓库巡检小车,加了温湿度传感器和蜂鸣器,每天自动巡逻三次,发现异常温度就发短信报警。技术本身没有边界,边界只在你的想象力里。而这套工程,就是帮你把想象力,稳稳落地的第一块基石。
本文还有配套的精品资源,点击获取
简介:基于STM32F103C8T6等主流型号,提供三个开箱即用的Keil uVision工程:遥控版支持VS1838B红外接收,解析NEC协议,实现前进/后退/左转/右转/启停;避障版集成HC-SR04超声波模块,配合L298N驱动芯片,根据设定距离自动刹车或转向;循迹版采用TCRT5000红外对管阵列,通过阈值判断或简易PID算法稳定跟踪黑白边界线。所有工程均含标准固件库结构(System/Core/FWLib/Object/Code_User),引脚已预定义,无需修改配置即可编译下载。配套例程说明.txt明确列出各模块硬件接线图、GPIO分配(如PA0接超声波Trig、PB1接红外OUT)、关键参数调节位置(如循迹灵敏度阈值、避障触发距离)及典型问题解决方法(如电机不转排查方向信号电平、红外无响应检查供电与载波频率)。代码全部使用标准C编写,函数划分清晰,关键逻辑处附中文注释,适合从点灯入门到综合项目实践的嵌入式学习者,也方便快速接入OLED显示、蓝牙串口透传或WiFi模组扩展远程控制能力。
本文还有配套的精品资源,点击获取
