简介:这是一个基于 Android 设计 APP 控制 51 单片机多功能智能小车的完整项目包,属于课程设计/单片机类高分资源,面向计算机、自动化、电子信息、物联网等专业在校生,以及需要完成毕设、课设或初期项目演示的开发者。包体共 133 个文件、压缩后约 3.74MB,核心内容包括 Android 客户端 Java/XML 源码、单片机端 C/H 底层驱动、可直接安装验证的 APK、工程配置文件以及说明文档,并附有界面截图与工程目录树,整体结构清晰,便于从 APP 界面到小车控制逻辑逐层对照学习。目前已有 114 人学习下载。该项目已通过 95 分答辩评审,核心代码经过运行测试,可直接在现有功能上修改扩展;对入门者而言,也能借助其中的前后端通信实现、硬件驱动接口和完整配置思路,快速理解 Android 与 51 单片机联合开发的典型流程。
1. 拿到这个高分项目,先看清“手机—蓝牙—单片机”这条链路
周五答辩前夜,手机蓝牙搜不到小车模块,51单片机主板上的电源灯却亮着,这是做“基于Android设计APP控制51单片机多功能智能小车”课程设计时几乎必然遇到的一幕。这个高分项目包里放着可直接安装的APK成品、Android工程缓存(resources.ap_、jarlist.cache这一类),以及单片机端的main.c源码和配套文档。资源拿过来之后,重点不是“能跑”,而是搞清楚Android Studio编译出来的APP、HC-05蓝牙模块、51单片机三者之间的通信链路,因为答辩老师最喜欢沿着这条链路往下问。下面按工程实际顺序拆解串口通信协议、PWM调速、Android蓝牙收发、Keil5烧录四个环节,并给出能直接抄的代码和调试参数,适合课设答辩党、单片机初学者和想把资料改造成毕业设计的读者。
2. 串口通信协议与蓝牙透传:帧同步、命令字与校验设计
2.1 为什么课程设计选“51 + HC-05 + Android SPP”
课程设计的选型逻辑很实际。51单片机(STC89C52或AT89S52)在智能小车项目里有三个不可替代的优势:寄存器少、开发资料多、Keil5 C51工具链稳定,对“多功能智能小车”这种低速移动平台,8位机处理一个蓝牙串口中断和两路PWM完全够用。Android端的作用是提供图形界面,把按钮点按转换成字节流;蓝牙模块只是透明传输通道,不解析内容,手机发给它的字节原样出现在51单片机串口,单片机回传的数据也原样回到手机。相比WiFi方案(ESP8266透传或自建TCP),蓝牙SPP方案不需要组网、不需要配置IP,HC-05模块十几块钱,属于课程设计普遍采用、答辩老师又最容易接受的方案。
文件里会出现两个APK(Kyr1eSmartCarControl.apk和Carcontrol.apk),以及两个同名main.c。经验来看,Kyr1eSmartCarControl.apk命名更完整,可以当作较新的版本先装;重复的main.c多半是固件在不同阶段留下的备份,解压后按文件修改时间选新的那份编译。项目里出现的resources.ap_、jarlist.cache、.classpath这些文件,是Eclipse ADT时代构建Android工程时生成的中间产物,拿到Android Studio里一般不会直接参与构建,所以不要被它们干扰。
2.2 指令帧定义:0xAA帧头、命令字、异或校验
51单片机串口收到的是一个字节一个字节进入SBUF的数据流,如果没有帧格式,一旦第一个字节丢失,后面所有指令都会错位。因此协议设计要解决三个问题:帧同步、命令表达、错误检测。常见做法是采用“帧头+命令字+数据区+校验字”的固定长度帧,本项目的蓝牙控制指令帧可以简化为4字节:
| 字节位置 | 名称 | 取值说明 |
|---|---|---|
| 0 | 帧头FRAME_HEADER | 固定0xAA,接收方用它重新对齐同步 |
| 1 | 命令字CMD | 0x00停止,0x01前进,0x02后退,0x03左转,0x04右转,0x05调速 |
| 2 | 数据DATA | 速度值(0~100)或辅助参数,用不到时填0x00 |
| 3 | 校验CHECK | 第1字节与第2字节异或,即CMD^DATA |
校验字用异或而不是累加和,原因是单片机里异或运算只需要一条指令,累加和还要考虑进位处理,在中断里执行会拉长响应时间。帧头固定为0xAA是因为它的二进制是10101010,与常见的0xFF、0x00相比,更不容易在总线空闲态或乱码流中被误判。数据区虽然只有1字节,但足够表达0~100的速度档位,以及蜂鸣器、灯光这类扩展功能的开关状态。
生成帧的C代码放在51端:
#define FRAME_HEADER 0xAA #define CMD_STOP 0x00 #define CMD_FORWARD 0x01 #define CMD_BACKWARD 0x02 #define CMD_LEFT 0x03 #define CMD_RIGHT 0x04 unsigned char txFrame[4]; void buildFrame(unsigned char cmd, unsigned char data) { txFrame[0] = FRAME_HEADER; // 帧头 txFrame[1] = cmd; // 命令字 txFrame[2] = data; // 速度或辅助参数 txFrame[3] = cmd ^ data; // 异或校验 }这段代码在后面调试时可以直接复制到Keil5里。需要注意,buildFrame只是做发往手机的回显或状态上报,真正控制电机不需要它;接收端校验逻辑更重要,见下一节。参数上,如果命令字和数据区的长度将来要扩展,帧格式从4字节改成6字节,状态机同步逻辑也要同步调整,不要只改一边。单片机端的串口初始化也要一并确认:
void UART_Init_9600() { SCON = 0x50; // 串口方式1,8位UART,REN=1允许接收 TMOD &= 0x0F; // 保留定时器0原有配置 TMOD |= 0x20; // 定时器1工作方式2:8位自动重装 TH1 = 0xFD; // 11.0592MHz晶振,9600波特率 TL1 = 0xFD; TR1 = 1; // 启动波特率发生器 ES = 1; EA = 1; }重装值0xFD的来源是11.0592MHz晶振:这个频率专门为了让9600和115200等波特率的误差为0而设计,换成12MHz晶振后同样的0xFD会产生明显偏差,蓝牙透传后误码率剧增。因此main.c里的串口常量明确按11.0592MHz配置,后面提到的PWM定时器初值则按12MHz计算,两者并不冲突,因为用的是两个不同的定时器。
2.3 串口中断状态机:从一个字节到一个完整控制指令
接收端更需要在意字节边界。不要在中断里等整帧到达,否则缓冲区管理复杂而且容易死锁;常见做法是写一个状态机,每收到一个字节就改变一次状态。下面这段代码可以直接作为main.c的串口中断服务函数:
unsigned char rxBuf[4]; unsigned char rxState = 0; void UART_ISR() interrupt 4 { unsigned char byte; if (!RI) return; // 没有接收到数据 RI = 0; // 清接收中断标志 byte = SBUF; // 读串口数据 switch (rxState) { case 0: // 等待帧头 if (byte == 0xAA) { rxBuf[0] = byte; rxState = 1; } break; case 1: // 已收到帧头,等待命令字 rxBuf[1] = byte; rxState = 2; break; case 2: // 已收到命令字,等待数据区 rxBuf[2] = byte; rxState = 3; break; case 3: // 已收到数据区,等待校验并执行 rxBuf[3] = byte; if ((rxBuf[1] ^ rxBuf[2]) == rxBuf[3]) { executeCommand(rxBuf[1], rxBuf[2]); } rxState = 0; // 无论校验是否通过都回到空闲 break; } }这段代码的核心思路是:只有当第一个字节匹配0xAA时才进入状态1,否则一直停留在状态0。蓝牙模块上电时偶尔会吐出几个乱码字节,状态机会自动丢弃它们,下一个正确帧头到来后立刻恢复同步,不需要在main函数里做复位处理。校验失败时直接丢弃整帧,不做任何容错性执行,目的是避免电机误动作造成安全隐患。状态机的性能足够,因为每收到一个字节最多执行4次比较和赋值,在11.0592MHz晶振下用时远小于下一位的到来间隔。
提示:如果把帧头改成0xFF这类全1字节,在总线空闲时可能被误认为有效数据,所以0xAA是更稳妥的选择。这也是很多串口协议把帧头设计成0xAA或0xA5的原因。
3. 51单片机 main.c 实战:PWM调速、指令执行与循迹避障
3.1 L298N电机驱动与定时器PWM调速参数计算
小车的动力部分通常用L298N驱动模块,既能驱动两个直流电机,又自带使能逻辑,输入引脚直接兼容TTL电平。L298N与51的接口常见接法是:P1口低四位接IN1~IN4,P1^4和P1^5分别接ENA、ENB。IN1和IN2控制左电机方向,IN3和IN4控制右电机方向,ENA和ENB用PWM波控制有效电平的时间比例。电机正转、反转和刹车的逻辑组合如下表:
| 功能 | IN1 | IN2 | IN3 | IN4 | ENA/ENB |
|---|---|---|---|---|---|
| 前进 | 1 | 0 | 1 | 0 | PWM调速 |
| 后退 | 0 | 1 | 0 | 1 | PWM调速 |
| 左转 | 1 | 0 | 0 | 1 | 两侧PWM差速 |
| 右转 | 0 | 1 | 1 | 0 | 两侧PWM差速 |
| 刹车 | 1 | 1 | 1 | 1 | 无PWM |
PWM的频率不是越高越好,也不是越低越好。频率太高,电机线圈感抗会让平均电流上不去,驱动力不足;频率太低,电机会发出明显啸叫,而且小车运行不平稳。10ms周期、100个档位的方案,也就是100Hz的PWM,在低速小车上是个标准起点。定时器初值基于12MHz晶振计算:每档0.1ms,初值0xFF9C;如果换成11.0592MHz晶振,直接套用这个初值会产生定时误差,左右轮转速不一致的直接表现就是小车跑不直。
unsigned char pwmTick = 0; unsigned char pwmDutyLeft = 60; // 左轮占空比 60% unsigned char pwmDutyRight = 60; // 右轮占空比 60% void Timer0_ISR() interrupt 1 { TH0 = 0xFF; // 12MHz晶振,100us定时初值高位 TL0 = 0x9C; // 100us定时初值低位 pwmTick++; if (pwmTick >= 100) pwmTick = 0; // 100 * 100us = 10ms,PWM频率100Hz ENA = (pwmTick < pwmDutyLeft) ? 1 : 0; ENB = (pwmTick < pwmDutyRight) ? 1 : 0; }这段代码把ENA和ENB当作普通IO口直接赋值,是51端最常见的做法。pwmDutyLeft和pwmDutyRight是全局变量,后面executeCommand里可以通过修改它们实现调速。参数调整的核心是左右轮对称性问题:同一个占空比下,左侧电机和右侧电机的机械特性可能不同,实际行驶会向一侧偏,所以要在实测中微调两个初值,而不是一味追求左右一致。调试时可以先在代码里把占空比固定为80,让小车直线跑一段距离,观察偏移方向后再回调。
3.2 executeCommand:把命令字转换成四个IO引脚电平
在2.3小节的校验成功分支里调用了executeCommand,这个函数才是真正驱动电机逻辑的地方。它做的事很简单:根据命令字设置IN1~IN4的电平,再按数据区内容更新PWM占空比。用switch比用一串if else更清晰:
void executeCommand(unsigned char cmd, unsigned char data) { switch (cmd) { case CMD_STOP: IN1 = 1; IN2 = 1; IN3 = 1; IN4 = 1; // 刹车 break; case CMD_FORWARD: IN1 = 1; IN2 = 0; IN3 = 1; IN4 = 0; if (data >= 10 && data <= 100) { // 数据保护 pwmDutyLeft = data; pwmDutyRight = data; } break; case CMD_BACKWARD: IN1 = 0; IN2 = 1; IN3 = 0; IN4 = 1; if (data >= 10 && data <= 100) { pwmDutyLeft = data; pwmDutyRight = data; } break; case CMD_LEFT: IN1 = 1; IN2 = 0; IN3 = 0; IN4 = 1; // 左轮正转,右轮反转 pwmDutyLeft = 40; pwmDutyRight = 60; break; case CMD_RIGHT: IN1 = 0; IN2 = 1; IN3 = 1; IN4 = 0; // 左轮反转,右轮正转 pwmDutyLeft = 60; pwmDutyRight = 40; break; default: break; } }代码里的data范围保护值得单独说明。如果把手机端的调速滑块直连到PWM占空比,用户拖到0时电机完全截止,但拖到200时占空比超过100%,程序里如果没做边界处理,pwmDutyLeft就会大于pwmTick的判断范围,导致电机一直全速。把输入限制在10~100之间,既避免最小占空比下电机转不起来,又防止溢出,是工程实现里常见的“输入合理化”处理。
转向的实现方式有两种,一种是转弯半径较大的两轮同向差速,另一种是原地转向。上面左转代码让左轮正转、右轮反转,电机对顶,小车可以原地掉头,适合狭窄场地演示;但原地转向对电机和电源的瞬时电流冲击更大,电池电压不足时会出现突然复位。答辩演示场合建议把速度调低一些。
3.3 循迹、避障传感器如何并入现有主循环
“多功能”这三个字,通常落在一组传感器上:循迹用红外反射传感器,避障用红外避障模块或超声波测距。它们不需要占用串口,只需要占用几个普通IO口,所以可以在main函数的循环里轮询。以两路循迹传感器为例,接P2.0和P2.1,黑线反射率不同,传感器输出电平也不同,代码可以这样组织:
sbit TRACK_LEFT = P2^0; // 左循迹传感器 sbit TRACK_RIGHT = P2^1; // 右循迹传感器 void trackLine(void) { if (TRACK_LEFT == 1 && TRACK_RIGHT == 0) { // 左侧偏离黑线,向右纠偏 pwmDutyLeft -= 5; pwmDutyRight += 5; } else if (TRACK_LEFT == 0 && TRACK_RIGHT == 1) { // 右侧偏离黑线,向左纠偏 pwmDutyLeft += 5; pwmDutyRight -= 5; } if (pwmDutyLeft < 20) pwmDutyLeft = 20; if (pwmDutyRight > 100) pwmDutyRight = 100; }这段代码只做增量调节,每次轮询只调整一个很小的量,避免小车在赛道上来回大幅摆头。实际项目中,循迹模式和蓝牙遥控模式一般不能同时生效,我会加一个mode全局变量:mode为0时蓝牙指令直接控制电机,mode为1时主循环只跑trackLine,蓝牙接收只用于切换模式。这个做法很朴素,但比在中断里切来切去可靠得多,也容易在答辩现场讲清楚。
4. Android端蓝牙控制:Android Studio导入与指令发送
4.1 解压后先区分APK与Android工程缓存文件
安卓端的文件需要分类看待。Kyr1eSmartCarControl.apk和Carcontrol.apk是编译好的安装包,想快速演示的直接装前者;resources.ap_、jarlist.cache、.classpath、index.db这些是Eclipse ADT或本地索引生成的中间产物,在Android Studio里打开工程时会被忽略一部分,但其中.classpath记录了原工程的源码目录和依赖库,如果原工程没有gradle配置,反而能从中看到src和libs的相对路径。我的常见做法是:在Android Studio里新建project,把原工程中com开头的Java目录、res目录、AndroidManifest.xml按原路径复制进去,再让Android Studio自动生成gradle配置。这个过程比折腾Eclipse工程导入快得多,也避开了旧构建工具的版本问题,对课程设计时间紧张的人更友好。
Android SDK的版本选择有讲究。targetSdkVersion如果定在22,蓝牙权限模型还是旧规则,在Android 12以上手机也能跑,但只能用于演示,无法上架应用市场;如果定在33,需要处理运行时权限。课程设计场景建议targetSdkVersion用33,正好把Android 12新的蓝牙权限模型一起演示出来,显得项目有更新价值。
4.2 Android 6到Android 13的蓝牙权限差异
蓝牙权限是这套代码里最容易让新手在答辩现场翻车的点。Android 6以上,扫蓝牙设备必须申请定位权限;Android 12以上,新的BLUETOOTH_SCAN和BLUETOOTH_CONNECT取代了旧的BLUETOOTH和BLUETOOTH_ADMIN。AndroidManifest.xml里需要同时保留两组权限,因为老版本系统不认识新权限,新版本系统又建议用新权限:
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />权限申请不能只在Manifest里写,Android 6以上还需要运行时弹窗确认。Java里封装一个通用方法:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { requestPermissions(new String[]{ Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN }, 1001); } } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { requestPermissions(new String[]{ Manifest.permission.ACCESS_COARSE_LOCATION }, 1002); } }代码按系统版本分了两段,实际上Android 6~11走ACCESS_COARSE_LOCATION,Android 12以上走两个新蓝牙权限。需要注意,即使在Android 12上,如果应用没有声明neverForLocation标志,扫描蓝牙时系统仍可能把它解释为需要定位服务,所以部分测试机上还要同时打开系统的“位置信息”开关。这一点在演示前一定要确认,否则会出现“权限全给了,还是扫不到HC-05”的情况。
4.3 BluetoothSocket发送4字节控制帧的Java实现
蓝牙连接使用SPP协议,UUID 00001101-0000-1000-8000-00805F9B34FB是串口服务标准UUID。连接成功后,发送控制指令的核心代码与2.2小节的帧格式完全对应:
private BluetoothSocket socket; private OutputStream outputStream; public void sendCommand(byte cmd, byte data) { if (socket == null || !socket.isConnected()) { Log.e("CarControl", "蓝牙未连接,请先完成配对"); return; } byte[] frame = new byte[4]; frame[0] = (byte) 0xAA; // 帧头 frame[1] = cmd; // 命令字 frame[2] = data; // 数据区 frame[3] = (byte) (cmd ^ data); // 异或校验 try { outputStream.write(frame); outputStream.flush(); Log.d("CarControl", "TX: " + String.format("%02X %02X %02X %02X", frame[0], frame[1], frame[2], frame[3])); } catch (IOException e) { Log.e("CarControl", "发送失败: " + e.getMessage()); } }sendCommand里先判断socket是否连接,再构造4字节帧,最后写入发送流。这里必须强调,write方法的参数是byte[],不能直接写字符串“AA015051”,否则手机端发出去的是一串ASCII字符0x41、0x41、0x30、0x31,51端按照0xAA帧头匹配时一个都匹配不上。车不动的时候,先通过logcat里打印的TX日志确认发出去的字节是不是AA开头,这个排查几乎能挡住一半的问题。
按钮与指令的映射关系建议在界面设计文档里画清楚,答辩时直接展示这张表:
| 按钮/操作 | 命令字 | 数据区 | 完整帧(十六进制) |
|---|---|---|---|
| 前进 | 0x01 | 0x50 | AA 01 50 51 |
| 后退 | 0x02 | 0x50 | AA 02 50 52 |
| 左转 | 0x03 | 0x00 | AA 03 00 03 |
| 右转 | 0x04 | 0x00 | AA 04 00 04 |
| 停止 | 0x00 | 0x00 | AA 00 00 00 |
| 加速档 | 0x05 | 0x14 | AA 05 14 11 |
从表里能看出,完整帧的计算并不需要电脑帮忙,异或结果当场手算也行。答辩时如果老师问“为什么前进是AA 01 50 51”,可以直接把0x01和0x50的二进制异或过程写出来,这比讲界面的按钮布局更有说服力。
5. Keil5 烧录、串口助手联调与参数对齐
5.1 Keil5 C51编译与STC-ISP烧录要点
Keil5默认安装时不带C51编译器,需要额外勾选C51支持包。新建工程后选择Atmel AT89C52或STC对应型号,选项里要打开“Create HEX File”,否则烧录软件拿不到可执行文件。烧录用STC-ISP,先选择芯片和COM口,点击下载后给目标板重新上电。整个过程最关键的是下载时序:STC单片机要在冷启动瞬间进入下载模式,如果板子上P3.0、P3.1接了蓝牙模块,下载时最好把蓝牙的RXD、TXD拔掉,避免模块拉低串口电平导致下载失败。
5.2 去掉蓝牙模块,先用PC串口助手验证固件
蓝牙模块容易被误判为故障点,但最常见的问题其实是它两侧的波特率不一致。有效的排错方法是把HC-05从电路上断开,用USB转TTL模块直接接51单片机串口。接线是USB转TTL的TXD接单片机P3.0,RXD接P3.1,GND共地。打开PC串口助手,设为9600波特率、8个数据位、1个停止位、无校验,发送十六进制帧AA 01 50 51。
如果小车前进,说明单片机端固件链路完整,问题只出在蓝牙模块或手机端;如果小车没反应,就要查串口中断是否配置正确、PWM占空比变量是否被意外清零。这里有个先后逻辑:先用PC串口助手绕开蓝牙,把固件链路隔离出来验证,再装回蓝牙模块,就不会在“手机问题还是固件问题”之间反复横跳,这也是整个项目调试里信息量最大的一个动作。
5.3 HC-05配对参数与联调排错顺序
HC-05有两种工作模式,AT模式和透传模式。按住模块上的按键再上电,指示灯慢闪进入AT模式,此时串口助手发送AT+UART=9600,0,0可以设置通信波特率,发送AT+NAME=Car51可以改蓝牙名称。如果不在AT模式下,手机串口助手里发出的字符会直接透传到51单片机,这相当于一个检测手段:向手机串口助手发一个0xAA,单片机端能通过状态机收到才算打通。
联调到这一步,把常见问题和处理顺序列成一张表,答辩现场照顺序排查思路很清晰:
| 现象 | 排查点 | 处理思路 |
|---|---|---|
| 手机搜不到HC-05 | 模块是否进入配对模式 | 重新上电,确认指示灯慢闪 |
| 能配对但车不动 | 发送的帧格式对不对 | 用蓝牙串口助手发AA 01 50 51验证 |
| APP安装后闪退 | 缺少运行时权限 | 检查BLUETOOTH_CONNECT和定位权限 |
| 行驶轨迹明显跑偏 | 左右轮PWM初值不一致 | 在main.c中微调pwmDutyLeft/Right |
| 负载时单片机复位 | 电源电压不足 | 换大容量电池,电机驱动与逻辑共地 |
最后补一个有价值的演示技巧:在executeCommand入口处增加一个帧计数变量frameCount,每收到一帧有效指令就自增,然后通过串口把计数回传给手机。
unsigned int frameCount = 0; void executeCommand(unsigned char cmd, unsigned char data) { frameCount++; // 帧计数,用于联调定位 // 原有电机控制逻辑保持不变 switch (cmd) { // ... } }手机端logcat里看到回传计数持续增长,就能立刻区分“手机没发出去”“单片机没收到”“单片机收到但执行失败”三种情况。这个回传链路还可以延伸成状态上报:把电量、避障传感器状态一并编码进数据区,结尾就落在“收到一帧,回显一帧,链路状态一眼可知”这个调试闭环上。
本文还有配套的精品资源,点击获取