蓝牙小车舵机PWM的51单片机实现:三种定时器方案解析
2026/9/16 20:57:13 网站建设 项目流程

简介:面向51单片机蓝牙小车开发场景,这份压缩包提供了一套完整的舵机测试C语言程序,适合电子爱好者、嵌入式初学者以及正在搭建无线遥控小车的开发者参考。包内共3个.c源文件,压缩包整体仅3KB,代码精简,便于快速阅读和移植。内容是三种不同的PWM信号生成方式:使用PCA的16位软件定时器产生PWM、使用T0一个定时器产生PWM、使用T0与T1两个定时器产生PWM,覆盖了51单片机常见的定时器/PCA资源用法,读者可对比三种实现方式的异同,按硬件资源灵活选择。通过蓝牙模块接收指令并控制舵机角度,可以帮助理解串口无线通信、PWM脉宽调制与舵机驱动之间的配合。目前已有277人学习下载,作为轻量级参考代码,适合学习舵机控制和蓝牙遥控小车的核心逻辑。

1. 一个zip里装了三种PWM写法,蓝牙小车到底该用哪套?

一个“舵机测试C51程序.zip”解压出三个.c文件,文件名已经把方案写在明面上:单个T0、T0+T1、PCA的16位软件定时器。它们都用来生成舵机PWM,都面向同一个场景——蓝牙遥控小车的前轮转向。我最初做蓝牙小车时以为随便选一个就能跑,结果T0方案在舵机上抖得像筛子,原因是把初值算成了10ms周期而不是20ms。这篇不绕弯子,直接从定时器初值、中断服务程序和串口解析三层拆开这三个文件,最后给出一套能用逻辑分析仪验证的联调方法,适合正在调51小车、想自己改PWM实现的人。

2. 舵机角度与PWM脉宽的换算:定时器初值必须死磕的细节

2.1 为什么舵机只认高电平宽度,不认占空比

舵机内部会自己产生一个周期20ms的基准信号,和输入PWM信号做比较,差值直接驱动电机。真正决定角度的是高电平持续时间,而不是频率或占空比。0.5ms到2.5ms的高电平通常对应0到180度,周期只要落在5ms到20ms之间都能工作,但周期太短会让舵机内部基准电路来不及复位,长时间超频容易发热甚至烧板。常见的SG90和MG996R都以20ms为标准周期,所以下面的定时器配置统一按20ms算。

2.2 12MHz晶振下的定时器重载值计算

如果直接定时20ms,12MHz晶振下机器周期是1us,需要计数20000,16位定时器确实装得下,但这样中断频率只有50Hz,中断函数里只够做一次简单的IO翻转,很难再精确控制脉宽变化。更常见做法是把20ms拆成200份,每份0.1ms,用一个定时器中断每0.1ms触发一次,软件计数到200次后重新开始。这样舵机高电平的最小调整步进就是0.1ms,对应约9度,已经能满足普通转向舵机的定位需求。

#define PWM_TIMER_UNIT 100 // 定时器重载值,12MHz下对应100us #define PWM_PERIOD 200 // 20ms / 0.1ms = 200份 unsigned char pwm_cnt = 0; unsigned char pwm_high = 15; // 15份对应1.5ms中位 void Timer0_Init() { TMOD &= 0xF0; TMOD |= 0x01; // T0工作方式1:16位定时器 TH0 = (65536 - PWM_TIMER_UNIT) >> 8; TL0 = (65536 - PWM_TIMER_UNIT) & 0xFF; ET0 = 1; TR0 = 1; EA = 1; }

65536减去100等于65436,十六进制是0xFF9C,所以TH0=0xFF、TL0=0x9C。这个初值必须和晶振频率严格对应,如果板子上用的是11.0592MHz晶振,机器周期变成1.085us,同样的重载值实际定时约108us,整帧周期会偏到21.6ms,脉宽也跟着放大8%。虽然舵机大概率还能转,但角度误差会很明显,所以调舵机时最好换12MHz晶振,或者用PCA方式单独校准。

2.3 一张表定死三个基准角度

角度高电平宽度折算0.1ms份数对应的pwm_high值
0.5ms55
45°1.0ms1010
90°1.5ms1515
135°2.0ms2020
180°2.5ms2525

这张表是后面解析蓝牙指令时直接要用的。主程序里把角度换算成pwm_high写入,中断服务程序下一周期就会按新值输出。要注意角度超过180或小于0时必须钳位,否则舵机堵转电流会很大,轻则抖动,重则烧掉LDO或者舵机本身。

3. 拆解T0、T1和PCA:三种PWM生成的C51代码实现

3.1 单T0定时器:中断里软件分频,适合一路舵机

单T0方案的思路是用同一个100us中断既做分频又做脉宽比较。每进一次中断,计数器pwm_cnt加1,当pwm_cnt达到200时拉高输出并清零,当pwm_cnt等于pwm_high时拉低输出。这样高电平持续时间就是pwm_high乘以100us。代码量最小,适合只控制一个舵机的简单小车。

#include <reg52.h> sbit SERVO_PIN = P1^0; #define PWM_PERIOD 200 unsigned char pwm_cnt = 0; unsigned char pwm_high = 15; // 1.5ms void Timer0_Init() { TMOD &= 0xF0; TMOD |= 0x01; TH0 = (65536 - 100) >> 8; TL0 = (65536 - 100) & 0xFF; ET0 = 1; TR0 = 1; EA = 1; } void Timer0_ISR() interrupt 1 { TH0 = (65536 - 100) >> 8; TL0 = (65536 - 100) & 0xFF; if (++pwm_cnt >= PWM_PERIOD) { pwm_cnt = 0; SERVO_PIN = 1; // 周期开始,输出高电平 } else if (pwm_cnt == pwm_high) { SERVO_PIN = 0; // 高电平时间到,拉低 } }

这里的关键是pwm_cnt先加1再比较,所以一个周期里pwm_cnt取1到200,而不是0到199。当pwm_cnt递增到200时马上清0并输出高,下一次中断pwm_cnt变为1,高电平会持续到pwm_cnt等于pwm_high才拉低。如果pwm_high设成0,周期内永远不拉低,舵机会一直全速转,所以初始化时最好做一次边界检查。

我实际测试过,这段中断服务里除了重装初值只有一次比较和一次IO写,执行时间在十几个机器周期,对脉宽的影响大概几个微秒,舵机完全感觉不到。但如果你要多路舵机,比如同时控制小车转向和摄像头云台,在这个中断里再加一路逻辑,代码执行时间会翻倍,CPU占用率变得很难看,那时候就要考虑双定时器或者PCA。

3.2 双定时器T0+T1:一个管周期,一个管脉宽

双定时器方案把高低电平的控制彻底分开。T0只负责20ms周期,周期一到就把舵机信号拉高,同时启动T1;T1的初值由角度对应的微秒数决定,计数到0时触发中断拉低信号并关闭自己。这样高电平宽度由T1的硬件计数保证,不再依赖T0中断里频繁的软件比较,精度比单T0高不少。

sbit SERVO_PIN = P1^0; volatile unsigned int servo_duty = 1500; // 单位us,默认1.5ms void Timer0_Init() { TMOD &= 0x0F; TMOD |= 0x01; // T0方式1,16位定时器 TH0 = (65536 - 20000) >> 8; // 20ms周期 TL0 = (65536 - 20000) & 0xFF; ET0 = 1; TR0 = 1; EA = 1; } void Timer1_Init() { TMOD &= 0xF0; TMOD |= 0x10; // T1方式1 ET1 = 1; TR1 = 0; } void Timer0_ISR() interrupt 1 { TH0 = (65536 - 20000) >> 8; TL0 = (65536 - 20000) & 0xFF; SERVO_PIN = 1; TH1 = (65536 - servo_duty) >> 8; TL1 = (65536 - servo_duty) & 0xFF; TR1 = 1; } void Timer1_ISR() interrupt 3 { TR1 = 0; SERVO_PIN = 0; }

servo_duty的单位是us,比如中位1.5ms就填1500。因为T1也是16位定时器,最大能定65ms,完全够用。要注意的是,如果servo_duty超过20000,T1还没到中断,下一次T0又来了,会导致PWM波形错乱,所以外部角度更新时必须限制在500到2500us范围内。

这个方案的代价是占用了T0和T1两个定时器。如果后续要挂HC-05蓝牙,必须用T1做波特率发生器,那就冲突了。解决方式有两种:一是改用单T0或PCA方案,二是用STC增强型51内部的独立波特率发生器。很多人在这个坑里栽过,以为串口和舵机互不干扰,结果T1同时给PWM和UART服务,波特率直接乱掉。

3.3 PCA的16位软件定时器:时基更独立,中断更稳

PCA是增强型51上的可编程计数器阵列,STC12和STC15系列都带。压缩包里的“使用PCA的16位软件定时器产生PWM控制.c”,本质是把PCA的16位计数器当成一个高精度定时器来用,中断频率固定,然后在中断里做软件分频。它不占用T0/T1,对后面挂蓝牙串口特别友好。

// 以STC15系列PCA模块为例,寄存器名称因型号略有差异 #define PWM_PERIOD 200 unsigned char pca_cnt = 0; unsigned char pwm_high = 15; void PCA_Init() { CMOD = 0x00; // 时钟源为系统时钟/12 CCON = 0x40; // CR=1,启动PCA计数器 CCAPM0 = 0x49; // 使能CCF0比较匹配中断 PCA0L = 0x00; // 16位计数器复位 PCA0H = 0x00; CCAP0L = 100 & 0xFF; // 比较目标:100us CCAP0H = 100 >> 8; EA = 1; } void PCA_ISR() interrupt 7 { if (CCF0) { CCF0 = 0; PCA0L = 0x00; // 清零计数器,形成固定100us中断 PCA0H = 0x00; if (++pca_cnt >= PWM_PERIOD) { pca_cnt = 0; SERVO_PIN = 1; } else if (pca_cnt == pwm_high) { SERVO_PIN = 0; } } }

PCA计数器每计数到100就触发一次CCF0中断,服务程序里先清标志,再把计数器清零,重新开始一个100us周期。因为PCA的计数时钟独立,中断频率不会受T0/T1重装语句影响。需要提醒的是,不同STC型号的寄存器名称不完全一样,有的叫CH/CL,有的叫PCA0H/PCA0L,移植时一定要打开对应芯片的头文件确认。

这个方案的软肋在于它需要硬件支持。如果手头是普通STC89C52,根本没有PCA模块,这个文件编译都过不了。这就是为什么压缩包里会把三种方式都拿出来,不是冗余,而是为了让不同平台的人都能用。

3.4 三种实现对比与选型

方式中断频率占用资源典型精度适用场景
单T010kHz1个定时器受中断开销影响,误差3~5us一路舵机,代码量最小
T0+T150Hz+1kHz2个定时器高电平由T1硬件保证,误差<1us对脉宽精度敏感,不挂串口
PCA软件定时器10kHz1个PCA通道时基独立,误差1~2us有PCA模块的增强型51,想保留定时器

对比下来,如果确认单片机是STC15系列,我一般直接选PCA,因为它把定时器资源全部空出来,蓝牙通信和舵机控制可以同时跑。如果手上只有STC89C52,单T0方案最稳妥,双定时器虽然精度高,但和后面的蓝牙串口在T1上会打架。

4. HC-05蓝牙串口与舵机指令解析:从手机摇杆到角度值

4.1 蓝牙模块与单片机的接线和波特率

蓝牙控制小车用HC-05或HC-06都很常见。HC-05支持主从一体,手机连接后就是一路无线串口。接线只有四根:VCC、GND、TXD、RXD。这里最容易错的是交叉:蓝牙TXD接单片机P3.0(RXD),蓝牙RXD接单片机P3.1(TXD),而且GND必须和单片机共地,否则串口电平没有参考点,数据全是乱码。

波特率方面,HC-05默认常见的是9600或115200,这个工程按9600处理。51单片机配11.0592MHz晶振,用T1方式2做波特率发生器可以得到精确的9600。如果用了双定时器PWM方案,T1已经被占,串口就没法用了,这是上一章说的冲突的直接影响。

4.2 定义一帧可扩展的控制协议

裸串口数据很容易被干扰,尤其是手机和蓝牙模块之间偶发丢包,所以最好包一个简单帧。下面这个协议只用了5个字节,但已经能支持后续扩展调速、灯控等命令。

字节序号内容示例值说明
0帧头0xAA固定值,用来找起始位置
1数据长度0x02从CMD到DATA的字节数
2命令字0x100x10表示舵机控制
3角度值0x5A0到180
4校验和0x??字节1到3的累加和

校验和算法是前面所有字节加起来,使整帧和为0。例如发送角度90度,数据长度为2,命令0x10,角度0x5A,那么校验字节就是0x02加0x10加0x5A等于0x6C。这样接收端只要把第1到第3字节和校验字节相加,结果等于0就认为帧有效。

4.3 串口中断接收状态机

串口中断里不能做耗时操作,所以接收用状态机,每一步只取一个字节,完整帧接收后再在while主循环里解析。状态0等待帧头,状态1读长度,状态2收数据,状态3收校验并判断。

unsigned char rxbuf[8]; unsigned char uart_state = 0; unsigned char rx_index = 0; unsigned char frame_len = 0; bit frame_ok = 0; unsigned char CheckSum() { unsigned char cs = 0, i; for (i = 1; i < frame_len + 2; i++) cs += rxbuf[i]; cs += rxbuf[frame_len + 2]; return cs; // 结果为0表示校验通过 } void UART_ISR() interrupt 4 { unsigned char ch; if (RI) { RI = 0; ch = SBUF; switch (uart_state) { case 0: if (ch == 0xAA) { rxbuf[0] = ch; uart_state = 1; } break; case 1: rxbuf[1] = ch; frame_len = ch; rx_index = 2; uart_state = 2; break; case 2: rxbuf[rx_index++] = ch; if (rx_index >= frame_len + 2) uart_state = 3; break; case 3: rxbuf[rx_index] = ch; if (CheckSum() == 0) frame_ok = 1; uart_state = 0; break; } } } void UART_Init() { SCON = 0x50; TMOD &= 0x0F; TMOD |= 0x20; // T1方式2 TH1 = 0xFD; // 11.0592MHz 9600 TL1 = 0xFD; PCON = 0x00; ES = 1; TR1 = 1; EA = 1; }

UART_Init里的TH1=0xFD是11.0592MHz下9600波特率的标准初值。如果换成12MHz晶振,这个初值会有一点误差,但大多数蓝牙模块的容错范围都能接受,只是不建议在长帧大数据量时这么用。

主循环解析帧时可以这样写:

void main() { Timer0_Init(); UART_Init(); while (1) { if (frame_ok) { frame_ok = 0; if (rxbuf[2] == 0x10) { unsigned char angle = rxbuf[3]; if (angle > 180) angle = 180; pwm_high = (unsigned char)((500 + angle * 2000 / 180) / 100); } } } }

角度到脉宽的换算公式是:高电平us数等于500加上角度乘以2000除以180,再把us转成0.1ms的份数,也就是除以100。比如角度90度,计算结果是1500us,对应的pwm_high是15。这个公式比查表通用,而且避免了浮点数运算。

5. 逻辑分析仪实测PWM波形:蓝牙小车联调前的最后一关

5.1 抓波形,用示波器验证1.5ms中位

把逻辑分析仪的两个通道分别夹在P1.0和GND,采样率设到250kHz以上,触发方式选下降沿或上升沿,抓一个20ms周期的波形。观察高电平宽度是不是1.5ms。如果不是,先排除万用表探头衰减、逻辑分析仪采样率不够,再检查定时器初值。比如T0方式下如果测到1.48ms,可以简单地让pwm_high从15改成16,再抓一次,直到接近1.5ms。这个方法比纸上算初值更快,也能顺便验证中断开销对脉宽的影响。

5.2 串口助手发指令,确认舵机跟随

打开串口调试助手,波特率9600,勾选十六进制发送,发送AA 02 10 00 12。帧尾校验是0x02加0x10加0x00等于0x12,发送后舵机应转到0度。再发AA 02 10 5A 6C,0x02加0x10加0x5A等于0x6C,舵机应转到90度。如果角度不对,用逻辑分析仪看PWM高电平宽度,算回角度;如果完全不转,检查舵机电源电压,舵机堵转电流大,必须单独供电,不能直接从单片机VCC取电。

5.3 蓝牙模块连不上时的快速排查

  • 检查蓝牙TXD/RXD与单片机是否交叉接线
  • 检查GND是否共地
  • 确认波特率一致,串口助手和蓝牙AT命令查询都用9600
  • 用USB转串口直接连蓝牙模块,先排除模块本身故障
  • 供电不足会导致蓝牙反复重连,单独用3.3V稳压模块给蓝牙供电

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

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

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

立即咨询