安卓WiFi遥控器:四轴与平衡小车的控制与PID调试
2026/9/15 7:40:19 网站建设 项目流程

简介:压缩包面向安卓平台上智能硬件无线遥控场景,围绕WiFi遥控器、四轴飞行器控制APP与平衡小车控制APP,覆盖从界面交互到底层控制的主要环节,包含重力感应倾斜操控、摇杆手动控制以及PID闭环调试等关键设计,适合无人机/平衡车爱好者、Android应用开发者及嵌入式初学者作为项目参考。整个压缩包共有709个文件,大小约32.23MB,数据构成较完整:既有12个可安装的apk和12个java源码文件,也有大量xml界面布局与json数据文件、dex字节码、gradle构建脚本、properties配置及flat资源文件,还有bin、rawproto等辅助资源;xml可查界面与状态配置,json对应数据交换或项目配置,dex与apk能看到最终打包结果,gradle和properties有助于还原构建环境。已有154人学习,适合想弄清安卓与飞控/小车之间WiFi通信、传感器姿态解算、重力与摇杆控制指令映射,以及PID参数整定思路的开发者直接对照源码和APK拆解。压缩包保留了较完整的工程编译链条,能帮助减少从零摸索的时间,快速迁移到自己的遥控或自平衡项目中。

1. 安卓wifi遥控器能装下什么:四轴、平衡小车和PID调试的最小闭环

在自组四轴飞行器和自平衡小车这类项目里,安卓wifi遥控器解决的从来不是“把手机变成一个无线手柄”这一个点,而是一整套遥控、调参和验证的闭环。压缩包标题里出现的四轴飞行器控制APP、平衡小车控制APP、重力感应、摇杆控制、PID调试,正好对应了从机载MCU控制板到手机端的完整工程切分:手机完成重力感应姿态、摇杆量、控制指令生成与PID参数下发,机载侧完成飞行姿态解算、电机驱动和闭环控制。阅读对象包括已经能跑通电机转动、手里只有一块STM32或ESP32主控板的入门玩家,也包括需要把体感控制和PID无线调参带进课设或比赛作品的嵌入式学生。要动手做的事情可以分成三条线:WiFi链路、安卓端交互、MCU端协议,下文按这个顺序把每一条线都落到可配置、可复现的层度。

2. 先定链路:安卓wifi遥控器的通信架构与报文设计

2.1 遥控场景下为什么选UDP而不是TCP

写重力感应采集代码之前,得先把最底层的网络协议定下来。遥控器场景下,手机作为Station连接WiFi模块的AP,模块与MCU之间是串口透传,这条链路的特点是周期性单向数据:手机每秒发送十到五十个控制帧,每帧十二字节左右;机载侧每秒回传姿态角、油门值、电池电压、PID误差等数据。这样一个流量模型下,UDP比TCP合适得多。道理不复杂:TCP是字节流带重传,一个丢失的ACK可能让后续整批控制帧堵在发送缓冲区里,等堵着的帧最终到达时已经过期;对于遥控系统,过期数据比丢失数据更危险,因为MCU会把旧角度当成新角度执行。UDP丢一帧最多造成十几毫秒的视觉抖动,下一帧立即跟上,系统不会积累滞后。

另一个常见考虑是局域网内能否使用TCP长连接来获得断线通知。实际测试下来,飞行器重启或者换电池时,TCP连接需要重新状态同步,而基于目的地IP和端口发数据报的UDP天然地支持“发给192.168.4.1:8080,不关心对方什么时候回来”。所以控制链路建议直接用UDP,把TCP留给参数上传、固件升级这类对完整性和连接状态有要求的操作。丢包率则通过报文里的序号在MCU端统计,而不是依赖协议层保证。

2.2 控制报文格式的最小可复现设计

安卓端、PC端串口调试工具和MCU端接收代码需要围绕同一个报文格式展开。这个格式的约束条件有三个:定长,方便MCU切帧;带类型,让控制帧、回传帧、PID参数帧互不干扰;带序号,用于测丢包率。我自己常用的字段分配如下表。

字段长度说明
SYNC1字节固定0xAA,接收端状态机搜索帧头
SEQ1字节0~255循环,与上一帧之差即丢包数
TYPE1字节0x01控制帧,0x02PID参数帧,0x03回传帧
CH12字节油门或速度,范围建议-1000~1000
CH22字节偏航
CH32字节俯仰
CH42字节横滚
CHK1字节前11字节求和取低8位

每个通道用2字节,是为了让MCU端在串口中断里可以快速拼出有符号整数,直接与IMU解算得到的角度误差做算术运算,而不必在中断里解析浮点。整个报文12字节,正好是WiFi模块串口缓冲区一次能装入的长度,接收端收到一帧完整数据后可以立刻消费,不会跨帧拆分。

对应的安卓端Kotlin发送代码可以这样写。

data class ControlFrame( val seq: Int, val channel1: Int, val channel2: Int, val channel3: Int, val channel4: Int ) { fun toByteArray(): ByteArray { val buf = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN) buf.put(0xAA.toByte()) buf.put((seq and 0xFF).toByte()) buf.put(0x01) // 控制帧 buf.putShort(channel1.toShort()) buf.putShort(channel2.toShort()) buf.putShort(channel3.toShort()) buf.putShort(channel4.toShort()) // 校验和计算前11字节 val base = ByteArray(11) buf.position(0) buf.get(base, 0, 11) val checksum = (base.fold(0) { acc, b -> (acc + (b.toInt() and 0xFF)) } and 0xFF).toByte() buf.put(checksum) return buf.array() } }

逻辑说明与参数要点:这个类把每个控制周期内的通道值转换成网络字节序;注意ByteOrder.LITTLE_ENDIAN必须和MCU端结构体定义的字节序一致,否则在STM32上通过memcpy解析时高低字节颠倒。校验和把0xAA也纳入计算,MCU端只需要对一帧内所有字节求和并检查低8位是否等于0,这一条可以替代“查找帧头+计算校验”的两步操作。发送时用一个DatagramSocket循环执行send(),不要每条控制帧都新建socket,否则每次都会触发端口绑定的延迟。

2.3 WiFi模块的固定IP、波特率和连接提示

手机连上ESP8266或ESP32的AP后,系统会弹出一条“这个WiFi需要操作没有internet打开浏览器并连接”之类的提示,很多新手会以为配置失败。在遥控APP开发的联调阶段,这个提示可以直接忽略,关键是确认手机确实停留在该WiFi上,没有自动切回移动数据。如果模块连的是手机热点,要关闭“热点自动关闭”功能,把模块设置成STATION模式并固定到热点的IP段。

WiFi模块和MCU之间的串口波特率是另一个常见坑。模块出厂多为115200,而平衡小车或四轴原生代码常用57600或921600。必须先确认“模块端串口波特率等于飞控串口波特率”再进行联调。碰到乱码时,用串口调试助手发0x00和0xFF,观察波形是否正常振荡;再看模块的RXD是否接到了主板的TXD,收不到数据的一半原因在交叉接线上。

提示:把WiFi模块的IP固定下来,不要依赖自动分配的地址,否则App里填写的目标IP会在模块重新上电后失效。

3. 重力感应与摇杆控制:把手机姿态换算成通道值

3.1 用旋转矢量传感器而不是直接读重力加速度

写安卓端体感遥控时,新手容易注册加速度计TYPE_GRAVITY,然后直接按X轴输出做倾斜角。实测会发现手机静止时输出也有几十的位置噪声,飞行器会一直修正。原因很简单,加速度计测量的是比力,机体的振动、启动加速度都会叠加到重力向量上;运动越剧烈,加速度计输出越不能代表“重力感应”。更可靠的输入是TYPE_ROTATION_VECTOR,由Android系统的传感器融合算法输出设备在全局坐标系的姿态,内部已经融合了陀螺仪数据和加速度计数据。它不需要自己写互补滤波或卡尔曼,在很多中端手机上稳定输出50~100Hz,对遥控器来说足够。

推荐代码片段如下。

sensorManager.registerListener( object : SensorEventListener { override fun onSensorChanged(event: SensorEvent) { if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) { SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values) SensorManager.getOrientation(rotationMatrix, euler) yawDeg = Math.toDegrees(euler[0].toDouble()).toFloat() pitchDeg = Math.toDegrees(euler[1].toDouble()).toFloat() rollDeg = Math.toDegrees(euler[2].toDouble()).toFloat() } } override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {} }, sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR), SensorManager.SENSOR_DELAY_GAME )

参数说明:SENSOR_DELAY_GAME约50Hz的采样率,对遥控通道已经足够,不要使用FASTEST,高频采样会让手机发热降频,反而造成控制周期抖动。欧拉角中euler[0]是围绕Z轴的偏航,euler[1]euler[2]分别围绕X和Y轴,但不同手机系统里xyz轴定义可能有差异,遇到角度方向反时先在App里加一个“反向开关”,不要急着改融合代码。

3.2 角度死区、限幅与通道映射

拿到欧拉角以后,下一步是把它映射成前文报文里的CH3和CH4。直接线性映射会导致手机平放时因传感器噪声输出连续小值,电机一直轻微抖动。所以映射前要做两步:死区处理,即角度绝对值小于2度时直接补零;限幅控制,即把超过最大控制角度部分封顶。

fun angleToChannel(degree: Float, maxAngle: Float, reversed: Boolean): Int { val deadZone = 2.0f val value = if (reversed) -degree else degree val safe = if (kotlin.math.abs(value) < deadZone) 0f else value val limited = (safe / maxAngle).coerceIn(-1f, 1f) return (limited * 500).toInt() }

maxAngle表示满通道对应的手机倾角,四轴建议20到35度,平衡小车建议10到15度;reversed用来适配握机习惯;乘以500是因为通道范围约定为-500到500。手机平放时,重力感应输出会在0附近抖动,经过死区后通道值大部分时间保持0;当操作者将手机前倾30度时,输出约500,对应MCU端的全速输出。这个映射关系要在App界面上做实时数字显示,方便上电后静止检查零点。

3.3 摇杆View的绘制与50Hz独立发送线程

控制界面不推荐用系统SeekBar拼装两个轴,而是自定义View画两个圆形摇杆区。核心行为是:按下时记录触点坐标,滑动时取触点与圆心的位移向量,除以圆的半径得到-1到1;手指释放后回中。一个常见失误是用布局的绝对坐标做触摸计算,屏幕旋转或布局变化后轴偏移;更稳定的做法是在onTouchEvent里直接拿event.xevent.y对View自身尺寸归一化。

发送逻辑放进独立线程,与UI线程分开。UI线程只负责修改摇杆状态量,一个ScheduledExecutorService每20毫秒读取状态量,生成ControlFrame并发送。不要直接在主线程里写socket,触摸事件频率和WiFi发送频率不一致,接收端看到的通道时间戳会乱。固定50Hz发送还有一个好处:PID调参时能从回传帧里算出端到端延迟,而不会因为发送周期漂移误判成延迟变大。

4. PID调试:平衡小车与四轴的无线标定方法

4.1 把PID调试放进遥控APP的实际收益

对于平衡小车这类强自稳系统,PID调试过程中的反馈延迟直接决定效率。如果每次改P、I、D都要插上USB转TTL、打开PC端串口调试助手、在命令行里传参数,改一次参数最多要半分钟,而车可能已经倒下好几次。这个场景下,App里的PID调试页面相当于一个无线调参终端:输入框里填三个数字,点发送,WiFi模块透传到MCU串口,MCU中断里解析后直接更新全局变量。

要支持这种做法,MCU侧的工程结构需要预留一个统一参数区。常见做法是把四个通道的PID参数放在结构体数组里,例如pid_param[CH_NUM],串口收到0x02类型帧后只修改这个数组。最开始的固件可以不对这套参数做Flash保存,先保证改完立即生效,等到试出稳定参数后再写回Flash;否则在调参过程中频繁擦写Flash会缩短寿命。

4.2 PID参数帧格式与MCU端接收代码

沿用第2章的帧格式,PID参数帧的数据区定义如下。

字段长度说明
对象1字节0表示平衡小车,1表示四轴飞行器
通道1字节0~3,对应MCU里的pid数组下标
KP2字节放大100倍后发送
KI2字节放大100倍后发送
KD2字节放大100倍后发送

对象字段用来防止调小车的参数时不小心把飞控参数覆盖掉;如果标题里的两个控制APP共用同一台手机,这个字段必须实现在协议里。MCU端接收处理可以参照下面这段精简代码。

void handle_pid_message(uint8_t *buf) { if (buf[0] != 0xAA || buf[1] != 0x02) return; uint8_t obj = buf[2]; uint8_t ch = buf[3]; uint16_t kp_int = (buf[4] << 8) | buf[5]; uint16_t ki_int = (buf[6] << 8) | buf[7]; uint16_t kd_int = (buf[8] << 8) | buf[9]; if (obj == TARGET_TWO_WHEEL_CAR && ch < PID_CH_NUM) { g_pid[ch].kp = kp_int / 100.0f; g_pid[ch].ki = ki_int / 100.0f; g_pid[ch].kd = kd_int / 100.0f; printf("PID ch%d: %f %f %f\r\n", ch, g_pid[ch].kp, g_pid[ch].ki, g_pid[ch].kd); } }

说明几点:帧头过滤和控制帧组共用同一入口,所以必须检查TYPE是0x02;整数除以100变成浮点数,这个除法精度足够区分0.01的P值变化,又避免了串口传浮点时的字节序问题;收到参数后立刻回传一行当前参数,App上的数值框会跟随显示,避免出现界面改了但MCU没改的假象。

4.3 平衡小车和四轴的调参顺序

四轴一般是串级PID,内环是角速度环,外环是角度环;平衡小车通常是一个直立环加一个速度外环。调参顺序都是从内环开始,内环稳住了再增加外环。调整方向可以参考下表,数值本身必须根据实际机架实验。

控制量现象调节方向
内环P整车高频抖动、发出嗡嗡声减小P
内环D启动或者转向时发硬,有低频振荡先降D,若P已经很大则同时降P
外环P回中速度慢,缓慢摆动适当增加P
外环I持续倾斜才能走稳,抗偏航不足先确保直立稳定再加I

调参时要观察App上的实时曲线,目标角度和实际角度两条线的跟随速度、超调量是最直观的信息。如果曲线上实际值波形呈锯齿状,说明回传频率太低或者发送线程被Android省电策略限制,先解决链路频率问题再调参数。

4.4 参数下发不生效的三个典型原因

第一个原因是类型字节错误,App里发出去的是0x01控制帧,MCU直接按通道数据处理,寄存器内存被改写;第二个原因是波特率不一致,PCB端已经是115200而模块还停在57600;第三个原因是WiFi信号差,参数帧丢包但手机界面没有反馈。更隐蔽的问题是D参数在协议里是无符号16位,如果在App里把D调成负值,经过类型转换后会变成一个很大的正整数,MCU端除以100后得到异常的数值,飞控立刻振荡。处理办法是在安卓端输入框过滤负数,并且把PID页面设计成“发送后自动读回”模式,依赖上一节MCU端printf的回显来兜底。

提示:在空旷场地测试四轴时,先用绳索绑住机架,只解锁角度环,再做悬停测试,不要一上来就满油门。

5. 端到端验证与无线延迟分析

5.1 用回传帧测算实际控制延迟

整个链路搭好后,第一件事不是起飞,而是测延迟。在MCU端收到控制帧时,把帧里的SEQ值存下来,在回传帧里带上这个SEQ;手机端记录当前时间,收到回传帧后减去发送时间,就能算出往返延迟。局域网环境下这个值应该小于20ms,若明显偏大,优先检查WiFi模块是否使用了节能模式,可以试着在AT指令里关闭模块的sleep,再测一次。

5.2 调参时同时看输出限幅

PID调试页面上除了画目标角度和实际角度曲线,还应当显示PID计算后的输出值。很多人调参时只盯角度波形,忽略了输出饱和。当输出长时间贴近上界时,说明执行机构已经到达极限,此时继续增加P或者I不再改善响应,只会让积分项越积越深,松开手后产生严重过冲。看到输出削平,先检查机械结构和重心位置,而不是继续加增益。

5.3 重力感应控制启动前的校零方式

重力感应通道在启动前需要做一次校零。将手机平放,App读取当前欧拉角并记录到SharedPreferences;发送通道值时减去这个偏移。校零操作只能在静止状态做一次,不能在控制循环里实时清零,否则飞行中会把真实倾斜角当零点,造成姿态漂移。校零完成后,将手机顺时针旋转60度再放平,观察App中角度显示是否回到初始值附近,这一步验证通过后,整套安卓wifi遥控和PID调试链路才算真正达到可飞状态。

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

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

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

立即咨询