
简介面向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、T0T1、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晶振下的定时器重载值计算如果直接定时20ms12MHz晶振下机器周期是1us需要计数2000016位定时器确实装得下但这样中断频率只有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工作方式116位定时器 TH0 (65536 - PWM_TIMER_UNIT) 8; TL0 (65536 - PWM_TIMER_UNIT) 0xFF; ET0 1; TR0 1; EA 1; }65536减去100等于65436十六进制是0xFF9C所以TH00xFF、TL00x9C。这个初值必须和晶振频率严格对应如果板子上用的是11.0592MHz晶振机器周期变成1.085us同样的重载值实际定时约108us整帧周期会偏到21.6ms脉宽也跟着放大8%。虽然舵机大概率还能转但角度误差会很明显所以调舵机时最好换12MHz晶振或者用PCA方式单独校准。2.3 一张表定死三个基准角度角度高电平宽度折算0.1ms份数对应的pwm_high值0°0.5ms5545°1.0ms101090°1.5ms1515135°2.0ms2020180°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 双定时器T0T1一个管周期一个管脉宽双定时器方案把高低电平的控制彻底分开。T0只负责20ms周期周期一到就把舵机信号拉高同时启动T1T1的初值由角度对应的微秒数决定计数到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方式116位定时器 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超过20000T1还没到中断下一次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; // CR1启动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一路舵机代码量最小T0T150Hz1kHz2个定时器高电平由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.0RXD蓝牙RXD接单片机P3.1TXD而且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到1804校验和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里的TH10xFD是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 6C0x02加0x10加0x5A等于0x6C舵机应转到90度。如果角度不对用逻辑分析仪看PWM高电平宽度算回角度如果完全不转检查舵机电源电压舵机堵转电流大必须单独供电不能直接从单片机VCC取电。5.3 蓝牙模块连不上时的快速排查检查蓝牙TXD/RXD与单片机是否交叉接线检查GND是否共地确认波特率一致串口助手和蓝牙AT命令查询都用9600用USB转串口直接连蓝牙模块先排除模块本身故障供电不足会导致蓝牙反复重连单独用3.3V稳压模块给蓝牙供电本文还有配套的精品资源点击获取