简介:这份资源面向嵌入式初学者与STM32开发者,围绕STM32F103C8T6微控制器,通过蓝牙模块实现舵机远程角度控制,涵盖UART串口通信、PWM信号生成与舵机驱动等核心知识点,适合作为课程设计、毕业设计或自学练手项目。压缩包共131个文件,约3.11MB,以C源码与头文件为主体(33个.c、34个.h),并包含Keil工程文件(uvprojx、uvoptx)、编译中间产物(o、d、crf、axf、hex)及少量说明文档,工程结构完整,可直接打开编译与烧录。资源已有3900余人学习下载,具备一定参考热度。读者可从中获取蓝牙指令解析、定时器PWM输出配置、舵机独立供电与排错思路等完整实现方案,便于快速复现并在此基础上扩展多舵机或无线遥控功能。
1. 蓝牙控舵机:从一块最小系统板到可复现的闭环控制
很多人第一次拿到 STM32F103C8T6 最小系统板,想做的第一个“能动起来”的项目,不是点灯,而是让舵机转起来。原因很直接:点灯只能看,舵机是能摸到的机械动作,成就感完全不一样。而加上蓝牙之后,手机一发指令,舵机就跟着动,这个链路一旦跑通,后面做机械臂、云台、小车转向都能直接复用。但实际动手时你会发现,卡住新手的往往不是代码本身,而是三件事:舵机的供电到底怎么接、串口蓝牙模块的数据怎么解析、PWM 定时器参数怎么算。这三个问题任何一个没处理好,现象都是“舵机不动”或者“舵机乱抖”,而这三者的排查方向完全不同。这篇笔记就按我实际调试的顺序,把 STM32F103C8T6 用蓝牙控制舵机这条链路拆开讲清楚,从硬件接线、定时器配置、串口中断接收,到角度映射和避坑排查,每一步都给出可复现的代码和参数依据。适合刚上手 STM32 的开发者,也适合做过串口通信但没碰过舵机控制的熟手对照参数边界。
2. 硬件链路先理清:舵机、蓝牙、STM32 三者怎么接才不翻车
2.1 舵机为什么不能直接从 STM32 的 5V 引脚取电
这是最容易被忽略、也最容易烧板子的一条。STM32F103C8T6 最小系统板上的 5V 引脚,通常来自 USB 供电或者板载稳压芯片的输出,它的电流能力很有限。而一个常见的 SG90 舵机,空载电流大概 100mA 到 200mA,堵转瞬间可以冲到 700mA 甚至更高。你让这个 5V 引脚同时给蓝牙模块和舵机供电,轻则舵机抖动、蓝牙断连,重则稳压芯片过热保护,整个系统反复重启。
我一般的做法是:舵机用独立的 5V 电源供电,比如一块单独的降压模块或者四节电池盒,STM32 和蓝牙模块走 USB 或者另一路 5V。关键点是——舵机的电源地、STM32 的地、蓝牙模块的地,三者必须共地。不共地的话,PWM 信号没有参考电平,舵机收到的信号是浮空的,表现就是完全不动或者随机乱转。
接线关系可以按下面这个表来对照:
| 模块 | 引脚 | 接到哪里 | 说明 |
|---|---|---|---|
| 舵机 | 信号线(橙/黄) | STM32 PA1 | 用 TIM2_CH2 输出 PWM |
| 舵机 | 电源正(红) | 独立 5V 正极 | 不要接 STM32 的 5V |
| 舵机 | 电源负(棕/黑) | 公共地 | 与 STM32 地相连 |
| 蓝牙模块 | TXD | STM32 PA10 | 接 USART1_RX |
| 蓝牙模块 | RXD | STM32 PA9 | 接 USART1_TX |
| 蓝牙模块 | VCC | STM32 3.3V | 多数模块支持 3.3V |
| 蓝牙模块 | GND | 公共地 | 与 STM32 地相连 |
注意:蓝牙模块的 TXD 接 STM32 的 RX,RXD 接 STM32 的 TX,这是交叉接法,接反了就是收不到数据,但模块本身不会坏,排查时先查这两根线。
2.2 蓝牙模块选型与串口参数确认
市面上常见的蓝牙串口模块,本质上就是一个透传桥:手机蓝牙发什么,它就往串口上吐什么。选型时关注三个参数就够了——默认波特率、电平兼容、配对方式。多数模块出厂默认波特率是 9600,也有 115200 的,这个必须和 STM32 端 USART 的波特率一致,否则收到的全是乱码。
我一般会先用 USB 转串口工具,把蓝牙模块单独接到电脑上,用串口助手确认两件事:一是模块的默认波特率到底是多少,二是手机连上之后发一个字符,串口助手能不能收到。这一步花五分钟,能省掉后面半小时的“为什么 STM32 收不到数据”的排查。
确认波特率之后,STM32 端的 USART1 配置就按这个值来。下面这段是 USART1 的初始化代码,波特率设为 9600,8 位数据位,1 位停止位,无校验:
// USART1 初始化,用于接收蓝牙模块数据 // PA9 -> TX, PA10 -> RX void USART1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; // 使能 GPIOA 和 USART1 时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); // PA9 复用推挽输出,作为 TX GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // PA10 浮空输入,作为 RX GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); // USART1 参数:9600-8-N-1 USART_InitStructure.USART_BaudRate = 9600; 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_ITConfig(USART1, USART_IT_RXNE, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); USART_Cmd(USART1, ENABLE); }这段代码里几个参数值得说明。USART_BaudRate必须和蓝牙模块一致,不一致就是乱码。USART_Mode同时开了 Rx 和 Tx,虽然这个项目里 Tx 用得少,但调试阶段可以回发确认信息,方便定位问题。USART_IT_RXNE是接收寄存器非空中断,每收到一个字节就触发一次,适合做单字节命令解析。中断优先级设成 1 和 1,是因为后面 TIM2 的 PWM 不需要中断,整个系统里只有串口中断,优先级不冲突。
3. 定时器输出 PWM:20ms 周期和 0.5ms 到 2.5ms 脉宽怎么算
3.1 舵机控制信号的本质:周期固定,脉宽决定角度
舵机的控制协议其实很简单:每隔 20ms 给它一个高电平脉冲,脉冲宽度在 0.5ms 到 2.5ms 之间变化,对应舵机输出轴从 0 度到 180 度。0.5ms 对应 0 度,1.5ms 对应 90 度,2.5ms 对应 180 度。这个关系是线性的,所以角度和脉宽之间就是一个一次函数。
STM32F103C8T6 的 TIM2 挂在 APB1 总线上,APB1 的时钟频率默认是 36MHz,但定时器时钟会倍频到 72MHz。这一点很关键,因为很多人算 PSC 和 ARR 的时候,用的时钟源搞错了,结果 PWM 周期不是 20ms,舵机要么不动,要么抖。
我一般用下面这个思路来配:先定 PWM 周期为 20ms,再定计数分辨率。假设预分频系数 PSC 设为 71,那么定时器时钟就是 72MHz / (71+1) = 1MHz,也就是每 1 微秒计一个数。20ms 就是 20000 微秒,所以自动重装载寄存器 ARR 设为 19999。这样计数值从 0 到 19999 刚好是 20ms。
脉宽方面,0.5ms 对应计数值 500,1.5ms 对应 1500,2.5ms 对应 2500。所以 CCR(捕获比较寄存器)的值直接就是脉宽微秒数。角度到 CCR 的映射公式就是:CCR = 500 + (角度 / 180) * 2000。
3.2 TIM2_CH2 的 PWM 配置代码与参数说明
下面这段是 TIM2 通道 2 输出 PWM 的初始化代码,对应 PA1 引脚:
// TIM2_CH2 PWM 初始化,输出到 PA1,用于舵机控制 // 定时器时钟 72MHz,PSC=71 得到 1MHz 计数频率 // ARR=19999 得到 20ms 周期 void TIM2_PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; // 使能 GPIOA 和 TIM2 时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // PA1 复用推挽输出,作为 TIM2_CH2 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 定时器基础配置:1MHz 计数,20ms 周期 TIM_TimeBaseStructure.TIM_Period = 19999; TIM_TimeBaseStructure.TIM_Prescaler = 71; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); // PWM 模式 1,通道 2 TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 1500; // 初始 1.5ms,对应 90 度 TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC2Init(TIM2, &TIM_OCInitStructure); // 使能预装载,防止修改 CCR 时出现毛刺 TIM_OC2PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM2, ENABLE); TIM_Cmd(TIM2, ENABLE); }参数说明:TIM_Prescaler = 71是因为定时器时钟 72MHz,分频后得到 1MHz,每个计数代表 1 微秒。TIM_Period = 19999是因为 20000 个计数就是 20ms。TIM_Pulse = 1500是初始占空比,对应 1.5ms 脉宽,舵机在 90 度位置。TIM_OCMode_PWM1的意思是计数值小于 CCR 时输出高电平,大于 CCR 时输出低电平,这样 CCR 的值就直接等于高电平时间。TIM_OC2PreloadConfig使能之后,修改 CCR 的值会在下一个周期生效,不会在当前周期中途改变,避免舵机抖动。
3.3 角度到 CCR 的映射函数
有了上面的配置,角度控制就是一个简单的映射。下面这个函数把 0 到 180 度的角度值转换成 CCR 值:
// 角度转 CCR 值,角度范围 0-180 // 0 度 -> 500 (0.5ms),180 度 -> 2500 (2.5ms) void Servo_SetAngle(uint8_t angle) { uint16_t ccr; // 限幅,防止越界导致舵机堵转 if (angle > 180) { angle = 180; } // 线性映射:CCR = 500 + (angle / 180) * 2000 ccr = 500 + (uint16_t)((uint32_t)angle * 2000 / 180); TIM_SetCompare2(TIM2, ccr); }这个函数里做了限幅,是因为如果角度超过 180,CCR 会超过 2500,脉宽超过 2.5ms,舵机会试图转到机械限位之外,导致堵转发热。(uint32_t)angle * 2000先转成 32 位再乘,是为了防止 8 位乘法溢出。实际调试时,如果你发现舵机在 0 度和 180 度附近有嗡嗡声,可以稍微收一点范围,比如 5 度到 175 度,给机械限位留余量。
4. 蓝牙数据解析:单字节命令和字符串命令怎么选
4.1 中断接收单字节:最简单但最容易丢包
最直接的方式是在 USART1 中断里收一个字节,判断这个字节是不是在 0 到 180 之间,如果是就直接设角度。代码大概长这样:
// USART1 中断服务函数,接收单字节角度值 void USART1_IRQHandler(void) { uint8_t data; if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { data = USART_ReceiveData(USART1); // 如果收到的是 0-180 之间的值,直接设为角度 if (data <= 180) { Servo_SetAngle(data); } USART_ClearITPendingBit(USART1, USART_IT_RXNE); } }这种方式的好处是代码极简,手机端发一个字节就能控制。但问题也很明显:手机蓝牙串口助手发数据时,往往不是精确的一个字节,可能带换行符、可能一次发多个字节,而且如果发送端和接收端波特率有微小偏差,或者中断被其他高优先级任务打断,就可能丢字节。丢一个字节的后果就是舵机突然跳到错误角度,在机械臂场景里可能撞到限位。
我一般会在调试阶段用这种方式快速验证链路通不通,但真正做项目时会换成带帧头帧尾的字符串协议。
4.2 字符串命令解析:带帧头帧尾更可靠
更稳妥的做法是定义一个简单的帧格式,比如#090\n表示 90 度,#180\n表示 180 度。帧头是#,帧尾是\n,中间是三位角度值。接收端用一个缓冲区攒字节,收到帧尾再解析。
// 接收缓冲区,最大 16 字节 #define RX_BUF_SIZE 16 static uint8_t rx_buf[RX_BUF_SIZE]; static uint8_t rx_index = 0; // USART1 中断服务函数,字符串协议解析 void USART1_IRQHandler(void) { uint8_t data; if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { data = USART_ReceiveData(USART1); if (data == '#') { // 收到帧头,重置缓冲区 rx_index = 0; rx_buf[rx_index++] = data; } else if (data == '\n') { // 收到帧尾,解析角度 rx_buf[rx_index] = '\0'; // rx_buf 格式为 "#090",跳过帧头解析数字 if (rx_index >= 2) { uint16_t angle = 0; uint8_t i; for (i = 1; i < rx_index; i++) { if (rx_buf[i] >= '0' && rx_buf[i] <= '9') { angle = angle * 10 + (rx_buf[i] - '0'); } } if (angle <= 180) { Servo_SetAngle((uint8_t)angle); } } rx_index = 0; } else { // 普通数据字节,存入缓冲区 if (rx_index < RX_BUF_SIZE - 1) { rx_buf[rx_index++] = data; } else { // 缓冲区溢出,重置 rx_index = 0; } } USART_ClearITPendingBit(USART1, USART_IT_RXNE); } }这个解析逻辑的核心是状态机:遇到#就重置缓冲区开始攒数据,遇到\n就结束并解析,中间的数字字符逐个转成数值。缓冲区溢出时重置索引,防止越界写。解析出来的角度再做一次限幅,超过 180 的直接丢弃。
手机端用蓝牙串口助手,发送内容填#090加换行,或者#180加换行,就能控制舵机转到对应角度。相比单字节方式,这种协议能容忍一定的传输抖动,因为帧头帧尾提供了明确的边界。
4.3 两种方式的对比与选择建议
| 对比项 | 单字节方式 | 字符串协议方式 |
|---|---|---|
| 代码复杂度 | 极简,几行搞定 | 需要缓冲区状态机 |
| 抗干扰能力 | 差,丢字节就出错 | 好,帧头帧尾可重同步 |
| 手机端操作 | 需要发十六进制字节 | 直接发文本命令 |
| 适用场景 | 快速验证链路 | 实际项目、多角度控制 |
| 扩展性 | 只能传角度 | 可扩展命令类型 |
我的建议是:如果你只是想让舵机动起来,验证硬件和蓝牙链路,单字节方式五分钟就能跑通。但如果你要做的是机械臂、云台这类需要精确角度的项目,直接上字符串协议,后面加命令类型(比如控制多个舵机、设置速度)也方便扩展。
5. 避坑与排查:舵机不动、抖动、角度不准的常见原因
5.1 舵机完全不动,但蓝牙模块指示灯正常
现象:手机能连上蓝牙,发数据时模块指示灯闪,但舵机一点反应没有。
原因:这种情况大概率是 PWM 信号没到舵机,或者舵机供电缺失。先查 PA1 有没有波形,用示波器或者逻辑分析仪看,如果没有波形,说明 TIM2 配置有问题;如果有波形但舵机不动,查舵机电源是不是独立供电、地有没有共地。
解决:先确认 TIM2 初始化和TIM_Cmd(TIM2, ENABLE)都调用了,再确认Servo_SetAngle被调用了。如果这些都对,用万用表量舵机电源正负之间有没有 5V,量舵机信号线和地之间有没有电压变化。
5.2 舵机抖动或嗡嗡响,角度不稳定
现象:舵机停在某个位置时持续抖动,或者收到指令后转过去但停不稳。
原因:最常见的是 PWM 周期不对。如果 PSC 和 ARR 算错,周期不是 20ms,舵机内部的比较电路会持续调整,表现就是抖动。另一个原因是舵机供电电流不足,电压被拉低,舵机内部电路复位又重启,反复循环。
解决:先用示波器量 PA1 的 PWM 周期,确认是不是 20ms。如果是 20ms 还抖,换独立电源给舵机供电,或者在舵机电源正负之间并一个 470uF 到 1000uF 的电解电容,吸收瞬间电流波动。
5.3 角度不准,发 90 度但舵机停在别的位置
现象:发#090,舵机停的位置明显不是中位。
原因:舵机本身的机械中位有偏差,不同批次的 SG90 中位脉宽可能不是精确的 1.5ms。另外,如果用了TIM_OC2PreloadConfig但没使能 ARR 预装载,修改 CCR 时可能产生毛刺,导致舵机误判。
解决:先确认TIM_ARRPreloadConfig(TIM2, ENABLE)和TIM_OC2PreloadConfig(TIM2, TIM_OCPreload_Enable)都调用了。如果还有偏差,在Servo_SetAngle里加一个校准偏移量,比如ccr = 500 + offset + (angle * 2000 / 180),实际量出 90 度位置对应的 CCR 值,反推 offset。
5.4 蓝牙连接正常但 STM32 收不到数据
现象:手机显示已连接,发数据时蓝牙模块的 TX 指示灯闪,但 STM32 没有任何反应。
原因:TX 和 RX 接反了,或者波特率不一致,或者 USART 中断没使能。
解决:先交换 TX 和 RX 试一下,这是最常见的。然后确认蓝牙模块的波特率和 USART1 的USART_BaudRate一致。最后检查USART_ITConfig(USART1, USART_IT_RXNE, ENABLE)和 NVIC 配置有没有漏。
5.5 舵机转到极限位置时堵转发热
现象:发 0 度或 180 度时,舵机发出持续的嗡嗡声,外壳发热。
原因:舵机的机械限位和电气限位不完全重合,0.5ms 和 2.5ms 可能让舵机试图转过机械限位,导致堵转。
解决:在Servo_SetAngle里把角度限幅到 5 到 175 度,给机械限位留余量。如果项目确实需要更大范围,换用 270 度舵机,对应脉宽范围也不同,需要重新标定。
6. 进阶技巧:用定时器中断做多舵机调度与角度平滑
单舵机跑通之后,下一步往往是控制多个舵机。STM32F103C8T6 的 TIM2 有四个通道,可以同时输出四路 PWM,但如果你要控制六个以上的舵机,定时器通道就不够了。这时候有两种常见做法:一是用多个定时器,TIM2 加 TIM3 加 TIM4,最多能出十二路;二是用定时器中断做软件 PWM,在一个定时器中断里轮流拉高拉低不同引脚。
我一般会先用硬件 PWM 把能用的通道都用上,不够再用软件方式补。软件 PWM 的思路是:设一个 1MHz 的定时器,每 10 微秒进一次中断,在中断里维护每个舵机的计数器,到点就翻转对应 GPIO。这样理论上可以控制任意多路,但中断频率高,CPU 占用也高,舵机数量多了之后会影响串口接收的实时性。
另一个值得做的优化是角度平滑。直接让舵机从 0 度跳到 180 度,机械冲击很大,齿轮容易磨损。可以在Servo_SetAngle外面包一层渐变逻辑:每次改变目标角度时,不直接设 CCR,而是启动一个定时器,每隔 20ms 把当前角度向目标角度靠近 1 度,直到到达。这样舵机转动平滑,噪音也小。
下面是一个简单的渐变实现思路,用 SysTick 或者一个通用定时器做 20ms 调度:
// 角度渐变控制,每 20ms 调用一次 static uint8_t current_angle = 90; static uint8_t target_angle = 90; void Servo_SmoothUpdate(void) { if (current_angle < target_angle) { current_angle++; Servo_SetAngle(current_angle); } else if (current_angle > target_angle) { current_angle--; Servo_SetAngle(current_angle); } } // 设置目标角度,实际转动由 Servo_SmoothUpdate 逐步完成 void Servo_SetTarget(uint8_t angle) { if (angle > 180) { angle = 180; } target_angle = angle; }这个逻辑里,Servo_SetTarget只更新目标值,实际的角度变化由Servo_SmoothUpdate在定时中断里逐步推进。每 20ms 走 1 度,180 度全程需要 3.6 秒,对于大多数云台和机械臂场景足够平滑。如果你觉得太慢,可以把步长改成 2 度或 3 度,但步长越大,冲击越明显,需要根据舵机型号和负载来试。
验证平滑效果的方法很简单:手机端连续发#000和#180,观察舵机是不是匀速转动,而不是瞬间跳过去。如果中间有卡顿,检查Servo_SmoothUpdate的调用周期是不是稳定的 20ms,可以用示波器量一个 GPIO 翻转来确认。
我自己在这个项目上踩过最深的坑,是早期用单字节方式控制,手机端串口助手默认发的是 ASCII 字符而不是十六进制,结果发90过去,STM32 收到的是9和0两个字节,舵机先转到 9 度再转到 0 度,看起来就是乱动。后来换成字符串协议,帧头帧尾一加,这个问题就再也没出现过。所以如果你现在正卡在“为什么舵机乱转”上,先确认手机端发出去的到底是什么格式,这个习惯帮我省了很多后悔药。希望帮到你。
本文还有配套的精品资源,点击获取