简介:这是一份基于开源组件的百元水火箭设计工程,面向水火箭爱好者、嵌入式开发者及科技教育场景,旨在解决水火箭从结构设计、数据采集到主动控制的完整实现问题。压缩包共216个文件,体积约11.5MB,包含C语言源码(76个头文件、71个源文件)、SolidWorks三维模型(25个零件图、3个装配图)、STL打印模型、Python脚本、HTML页面及Keil工程配置等,代码、结构与调试配置齐全,可直接用于二次开发或3D打印验证。项目按三阶段推进:第一阶段完成旋转自稳和加速度、温湿度、气压采集,并实现试车台推力采集,确定外壳设计、开伞机构和发射架方案;第二阶段引入可控鸭翼、主动控制与数传功能,实现箭载离线录像;第三阶段重点实现图传、降低成本并做整体优化,完整呈现了水火箭的迭代演进思路。目前已有336人学习下载,适合想从零跟做水火箭或借鉴其低成本软硬件协同方案的开发者。资源明确声明严禁商用,请仅用于个人学习研究。
1. 百元水火箭的原理:用开源组件把测量做到火箭上
花一百块钱做一枚能飞的水火箭,真正难的不是让它飞起来,而是让你知道它飞了多高、姿态如何、喷射过程有多久。发射瞬间只有 0.3 到 0.6 秒,整个过程从离架到落地不到 5 秒,用秒表和肉眼根本抓不住有效数据。我见过很多做水火箭的朋友,PVC 管和可乐瓶做得再漂亮,最后都卡在同一个问题上:没有任何一条可回放的飞行数据。
这个项目的价值在于,它并不是一个单纯的航模制作,而是一个典型的低预算嵌入式数据采集原型。板子用 Arduino Nano 或者 ESP32,传感器用 MPU6050 和 BMP280 这类普及程度极高的开源组件,代码量不大,但覆盖了传感器驱动、二进制帧协议、串口回读、离线解析的完整链路。预算全部加起来确实能压到一百元以内,前提是你愿意自己焊接排针、自己处理供电和密封,并且接受飞行数据可能只有几秒钟这个现实。
适合动手做的人,不只是玩模型的爱好者,还有平时写业务代码、想碰一碰硬件数据链路的开发者。做一次这种项目,你会把 I2C 时序、量程上限、数据丢帧、时间同步这些概念全部踩一遍,比单纯看开发板例程要直观得多。
2. 器件选型与量程匹配:决定飞行数据质量的三个参数
2.1 传感器怎么选:先看量程,再看精度
水火箭的传感器选型和普通气象气球完全不同。气象气球要的是长时间、低噪声、高精度,而水火箭要的是高速变化、短时冲击、抗饱和。这是两个方向,选错了传感器,代码写得再好也没有用。
加速度计是第一个要决策的器件。MPU6050 的加速度量程最高支持 ±16g,这里有个常见误区:很多人以为水火箭的加速度不会超过几个 g,实际上发射车架上瞬间冲击很大。当发动机舱压力达到 5 bar、水质量 300 毫升以上时,离架瞬间加速度峰值可以超过 12g,而如果喷嘴被异物堵住或者安装角度不正,峰值冲到 20g 以上也不罕见。±16g 量程是底线,如果预算允许,直接用 ADXL375,它支持 ±200g,后面做撞击检测和开伞触发计算都有余量。
气压计的选择则要考虑采样率和绝对精度之间的平衡。BMP280 的典型噪声水平在 1 Pa 左右,换算成高度大约是 8 厘米,这对水火箭来说完全够用。它的问题在于采样率,普通模式下最高能跑到 157 Hz,但实际使用中受到 I2C 总线读取耗时的影响,能稳定做到 50 Hz 就已经不错。飞行全程只有 4 秒,50 Hz 意味着只有 200 个采样点,所以代码里不能有任何阻塞等待。
陀螺仪在这里的主要作用不是测姿态——水火箭飞行时间太短,姿态解算做不了什么复杂控制——而是辅助判断翻滚。喷射结束后,火箭进入弹道飞行,如果重心和压心设计得不好,会在空中翻跟头,陀螺仪的角速度积分能把这个过程忠实记录下来。MPU6050 内置 DMP 可以直接输出四元数,省去自己写互补滤波的麻烦,但要注意 DMP 初始化在低功耗后偶尔不成功,需要在上电后加延时。
2.2 主控和存储:Nano 还是 ESP32
主控的定义是“够用就好”。我做过两版:第一版用 Arduino Nano,第二版用 ESP32。Nano 的优点是便宜、稳定、烧录简单,但它的 2KB SRAM 是硬约束。飞行数据如果按照一帧 20 字节、100 Hz 采样率来计算,每秒产生 2KB 数据,存储必须外挂,串口回传在飞行中也不可能实时处理。ESP32 有 320KB 以上的可用 SRAM,可以先把数据攒在内存环形缓冲里,落地后再写 SD 卡或者通过蓝牙回传。
从成本角度考虑,其实还有第三条路:用带有 SPI Flash 的 ATtiny 或者 STM32 最小系统板。不过这意味着引入更底层的寄存器操作,调试时间会拉长。我的建议是,第一次做就选 ESP32,原因不是性能,而是它的 I2C 硬件支持更好,内置的 RTC 可以打时间戳,后续如果你想加无线数据回传,直接用内置蓝牙,不用再接一个模块。
存储方面,SD 卡模块必须选 3.3V 供电版本,5V 的能在实验室跑,上了发射架之后稳压纹波一大就容易掉卡。在代码初始化里,检测 SD 卡失败不应该直接 halt,而是继续飞,把数据留在内存里,落地后再尝试落盘。
2.3 供电与结构:低压差才是稳定供电的关键
电池选择 18650 锂电池配合低压差线性稳压器(LDO),输出 3.3V。千万别用 9V 叠层电池加 AMS1117 的方案:9V 电池在低温下内阻飙升,水火箭发射前通常要灌冰水增加气压,电池温度一降,整个系统直接掉电重启。AMS1117 的压差在 1V 左右,输入一旦跌破 4.5V,输出就不稳了。
结构上有一件事必须提前做:传感器舱的密封。BMP280 有一个透气孔,水雾渗进去之后湿度传感器读数会漂移,直接影响高度换算。我一般用热熔胶把传感器板子背面和排针根部全部封死,舱体底部打一个直径 1mm 的排水孔,防止水到火箭喷出之后在舱内积存。
| 部件 | 参考型号 | 参考单价 | 说明 |
|---|---|---|---|
| 主控 | ESP32 DevKit | 约 25 元 | 双核、内置蓝牙和 4MB Flash |
| 加速度+陀螺仪 | MPU6050 | 约 10 元 | 六轴,量程±16g,支持 DMP |
| 气压计 | BMP280 | 约 12 元 | 精度足够,功耗低,采样率 50Hz |
| 存储 | MicroSD 模块 + 卡 | 约 10 元 | 3.3V 版本,落地后写入 |
| 电池 | 18650 单节 | 约 10 元 | 充放电板负载到 2A 即可 |
| 发射底座 | 铝型材 + 电磁阀 | 约 30 元 | 远程泄压,保障安全距离 |
表里的总价大约在百元以内,前提是你已经有电烙铁和基础工具,并且愿意用 USB 线供电进行大部分地面调试,电池作为外场备选电源。
3. 源码结构:采集、协议与地面站实现的完整链路
3.1 固件状态机:从待发射到回收只有四个状态
水火箭飞行时间太短,固件里不适合用复杂的调度器或者 RTOS 来组织代码,但也不能像 Arduino 例程那样在 loop 里堆 if。我一般把整个飞行流程建模为一个四状态状态机:待发射、倒计时、飞行中、落地回收。状态之间的切换条件必须依赖传感器信号而不是毫秒延时,这是保证数据时间戳可靠性的关键。
enum FlightState { ST_IDLE = 0, // 待发射:等待开始命令 ST_COUNTDOWN, // 倒计时:连续记录加速度 ST_FLIGHT, // 飞行中:检测离架 ST_RECOVERY // 落地:数据落盘 }; FlightState state = ST_IDLE; // 在 loop 中轮询状态机 void update_state() { static float last_accel = 0; float accel_mag = read_accel_magnitude(); switch (state) { case ST_IDLE: // 串口收到 'F' 后进入倒计时 if (Serial.available() && Serial.read() == 'F') { state = ST_COUNTDOWN; log_time_mark("countdown_start"); } break; case ST_COUNTDOWN: // 相对加速度超过 1.5g 时判定离架 if (accel_mag - 1.0f > 0.5f) { state = ST_FLIGHT; log_time_mark("liftoff"); } break; case ST_FLIGHT: // 连续 1 秒加速度接近 1g 且海拔不再上升,判定落地 if (accel_mag > 0.9f && accel_mag < 1.1f) { static uint32_t stable_ms = 0; if (millis() - stable_ms > 1000) { state = ST_RECOVERY; log_time_mark("landed"); } } else { stable_ms = millis(); } break; case ST_RECOVERY: // 执行存储写入、关闭传感器 flush_log_to_sd(); state = ST_IDLE; break; } }核心逻辑说明:状态迁移的条件全部写成“量变触发“而不是定时触发。”离架“判断使用加速度幅值与重力的差值,这样做的好处是无论火箭立着发射还是稍微倾斜都能识别,倾斜时 Z 轴的分量变化不会造成误判。落地判断加了一个 1 秒的稳定窗口,避免伞还没开或者落地弹跳导致状态机反复切换。时间标记函数log_time_mark会在对应的数据帧里写入毫秒级的时间戳,这个时间戳是后续离线解析阶段对齐数据的锚点。
参数注意:1.5g 这个离架阈值不是死的。如果你的火箭发射架有很强的导轨摩擦,实际离架加速度会低于这个值,需要在地面实测后微调。做法是连续采集启动瞬间的 2 秒数据,取峰值的 70% 作为阈值。落地窗口的 1 秒同理,如果火箭最终落到草地上,弹跳幅度会很大,可以适当增加到 1.5 秒。
3.2 传感器读取:用寄存器操作替代库函数
很多现成的 MPU6050 库函数在这个场景里都不适用,它们的读取间隔里加了大量的校验和延时,导致采样率上不去。我在项目里只保留了两段寄存器读写逻辑,一是初始化配置,二是高速读取。
#define MPU6050_ADDR 0x68 void mpu6050_init() { Wire.begin(); Wire.beginTransmission(MPU6050_ADDR); Wire.write(0x6B); // PWR_MGMT_1,电源管理寄存器 Wire.write(0x00); // 退出休眠,使用内部时钟 Wire.endTransmission(); Wire.beginTransmission(MPU6050_ADDR); Wire.write(0x1C); // ACCEL_CONFIG,加速度量程配置 Wire.write(0x18); // ±16g 量程,低通滤波 Wire.endTransmission(); Wire.beginTransmission(MPU6050_ADDR); Wire.write(0x1B); // GYRO_CONFIG,陀螺仪量程配置 Wire.write(0x18); // ±2000dps,适合高速翻滚 Wire.endTransmission(); } void mpu6050_read_accel(int16_t *ax, int16_t *ay, int16_t *az) { Wire.beginTransmission(MPU6050_ADDR); Wire.write(0x3B); // 加速度数据起始寄存器 Wire.endTransmission(false); Wire.requestFrom(MPU6050_ADDR, 6); // 高位在前,大端排列,按寄存器顺序读取 *ax = ((int16_t)Wire.read() << 8) | Wire.read(); *ay = ((int16_t)Wire.read() << 8) | Wire.read(); *az = ((int16_t)Wire.read() << 8) | Wire.read(); }这段代码的逻辑说明:MPU6050 的电源管理寄存器是 0x6B,写入 0x00 退出休眠,这里没有额外配置时钟源,用内部振荡器在短时间飞行中够用。加速度量程配置写在 0x1C,0x18 的二进制是 0b11000,对应±16g 且开启了数字低通滤波,能够滤掉部分机械振动噪声。读取时为什么从 0x3B 连读 6 个字节而不是单寄存器轮询——因为 I2C 每次事务都有起始位和停止位,连续读只付一次开销,在 400kHz 时钟下可以显著减少总线占用。数据返回是大端格式,所以必须做移位拼接,直接用 memcpy 在 Arduino 平台上可能因为字节序问题得到错误结果。
BMP280 的读取稍微复杂一些,它需要先用 0x88 地址读出一组校准系数,然后才能把原始气压 ADC 值换算成真实的 Pa 数值。换算公式在网上能查到,但一个容易踩的坑是校准系数数组用的是有符号还是无符号整型——温度校准的 5 个系数全部是有符号,气压校准的 9 个系数中最后两个也是,写错了高度曲线会漂移几百米。
3.3 下行数据协议:二进制帧比 JSON 更适合存储
外场环境下串口波特率通常设定在 115200 或更高,这个速度下传 JSON 完全可行,但飞行时数据是从内存环形缓冲写入 SD 卡,不是走串口,所以帧格式要考虑的是编码效率和排错便利,而不是“人类可读”。我在这个项目里定义了一个 20 字节的固定长度帧:
// 帧格式定义,共 20 字节 typedef struct { uint8_t sync; // 0xAA 帧同步字 uint8_t state; // 状态机当前状态 uint16_t seq; // 帧序号,用于检测丢帧 uint32_t timestamp; // 毫秒时间戳 int16_t ax, ay, az; // 加速度原始值 int16_t gx, gy, gz; // 角速度原始值 int32_t pressure_q; // 气压原始 ADC 值 uint16_t crc; // CRC16 校验 } flight_frame_t;固定长度的好处是地面站解析时不需要处理粘包和断包,每次读取 20 字节后先检查同步字和 CRC,不合格直接丢弃该帧并递增错误计数。CRC 实现用查表法,代价是 256 字节的 Flash,对 ESP32 来说无所谓。
CRC 校验在这里的作用不只是查错。水火箭落地时冲击很大,SD 卡在写入过程中如果恰好发生断电或者卡松动,最后一个扇区多半是坏的,有 CRC 之后地面站可以直接跳过损坏帧,不影响前后数据的连贯性。
地面站部分用 Python 写,逻辑很简单,读取串口或二进制文件,按帧格式解析后转换成 CSV:
import struct import csv def parse_frame(raw: bytes): # 固定 20 字节一帧,前 5 字节分别对应 sync/state/seq if raw[0] != 0xAA: return None sync, state, seq, ts = struct.unpack("<BBHI", raw[0:8]) accel = struct.unpack("<3h", raw[8:14]) gyro = struct.unpack("<3h", raw[14:20]) return { "timestamp_ms": ts, "ax": accel[0], "ay": accel[1], "az": accel[2], "gx": gyro[0], "gy": gyro[1], "gz": gyro[2], } def file_to_csv(src: str, dst: str): with open(src, "rb") as f, open(dst, "w") as out: writer = csv.writer(out) writer.writerow(["timestamp_ms", "ax", "ay", "az", "gx", "gy", "gz"]) while True: chunk = f.read(20) if len(chunk) != 20: break frame = parse_frame(chunk) if frame: writer.writerow([frame["timestamp_ms"], frame["ax"], frame["ay"], frame["az"], frame["gx"], frame["gy"], frame["gz"]])这段脚本的含义是这样:解析首字节检查同步字,避免把文件中间位置当成帧起始点;struct.unpack使用<小端模式,和底层 C 结构体保持一致;加速度和角速度目前保留原始量纲,需要除以各自的量程系数才能得到重力加速度和度每秒。这一步没有做死,是刻意留到分析阶段的,因为原始值可以在之后重新换算,而一旦缩放成物理单位,丢掉了整数部分的原始精度就再也回不来了。
4. 实现过程中的坑:从电磁阀干扰到数据削顶的排查
4.1 发射架电磁阀干扰烧掉两次数据后,我才加了光耦隔离
这个项目的发射底座用电磁阀作为远程泄压开关,12V 供电,通过串口命令控制开阀。第一次外场测试时,电磁阀通电的瞬间,ESP32 的 I2C 总线直接锁死,传感器读回来的数据全是 0xFF,整帧数据完全不可用。问题的根源不是电磁阀本身,而是它的线圈在断电瞬间会产生很高的反向感应电动势,这个尖峰脉冲通过共用的电源地线耦合进信号线。在实验室里用示波器能看到地线上的毛刺超过 3V,I2C 的高电平阈值在 3.3V 系统中只有 2.5V 左右,每一次电磁阀动作都是一次总线复位信号。
修复方案是两层:第一层是在电磁阀两端并联一个反向续流二极管,吸收断电尖峰;第二层是让发射端的控制器和传感器舱之间的供电完全隔离,用光耦传递开关信号。前者是硬件改造,后者是更干净的工程方案。如果你的发射架和传感器舱通过长电缆连接,强烈建议直接走光耦,成本不到两块钱,换回来的是一条不会被干扰的总线。
现场判断电磁阀干扰的方法很简单。给系统上电后,不打开传感器电源,只让电磁阀连续开关十次,然后把传感器接上,读一段空白数据检查 CRC 错误率。如果错误率不是零,说明干扰路径没有彻底切断,排查方向不是换传感器,而是查电源和地线走线。
4.2 加速度削顶:量程设成±16g 还是会在某次发射中失效
我前面提到量程要设置成±16g,但实测中仍然遇到过削顶。那一次发射的喷嘴用了口径小一档的塑料瓶盖,水喷射速度更快,反冲力更猛,点火瞬间读到的加速度原始值打到满幅 32767 并保持了几毫秒。满幅不是正常的峰值,而是传感器量程上限截断点——你看到的是一个平顶矩形波,而不是真实的冲击曲线。
削顶问题在代码层面有两个补救思路。第一是启动时做一次自检,通过串口读取全量程范围内的噪声基底,如果噪声明显偏高,说明传感器安装处的机械共振太强,需要加缓冲垫而不是改代码。第二是建立削顶检测规则,在帧解析阶段直接把 ax、ay、az 任一值等于 ±32767 的帧标记为CLIPPED,后续分析峰值加速度时自动排除这段数据,避免把截断值当真值去计算。
更根治的办法是换用 ±100g 或者 ±200g 的加速度计,但这会增加预算和体积。如果你的水火箭设计成可复用多次,并且你对不同喷嘴下的推力曲线感兴趣,那么换 ADXL377 是值得的;如果只是一次性验证飞多高,±16g 加削顶标记足够。
4.3 数据搬到 SD 卡时的时序陷阱:中断与写卡抢占
ESP32 的 Arduino 内核是跑 FreeRTOS 的,SD 卡写入如果使用默认的 SPI 模式,会在写操作期间长时间占用总线。而传感器读取走的是 I2C,如果两者共用同一个硬件 SPI 总线,写卡的一个扇区操作会造成传感器数据采集的长时间暂停。这个坑在日志里表现为时间戳出现几百毫秒的断层,而帧序号是连续的,排除丢帧后很难定位。
我把 SD 卡写入挪到了ST_RECOVERY阶段一次性完成,飞行中只往内存环形缓冲写数据。环形缓冲区的大小用 ESP32 的 320KB 可用内存分配 256KB,按照每帧 20 字节、100 Hz 采样率计算,可以存下 128 秒的数据,远超实际飞行时间。这样做彻底避免了飞行中写卡带来的时序抖动,代价是如果火箭坠毁后模块损坏,内存里的数据也就丢了。作为折中,也可以每 5 秒写一次缓存,但考虑到水火箭飞行只有几秒,一次性写是更简单的方案。
4.4 传感器标定与地面验证:不等于做一次就算成功
传感器装在火箭舱里和放在桌面上,读出的加速度零漂完全不同,因为焊接应力会让 MEMS 传感器的零点发生偏移。所以在装舱后必须做一次六位置标定,至少做三轴正反六个朝向,记录每个面的加速度读数均值,计算出偏移量写进配置项。
地面验证最基础的是落体实验。把组装好的传感器舱从 1 米高度平抛出去,读取加速度曲线,理论上自由下落阶段三个轴的合加速度应该接近 0g,触地瞬间会有一个明显的冲击峰。如果你的曲线在下落过程中显示 1g,说明传感器还受安装方向的约束,需要检查是否绑得太紧、轴向被外力拉偏了。这个实验成本极低,但能排查掉传感器焊接、方向定义、姿态解算初始化三个层面的问题。
5. 飞行数据离线解析:用时间戳对齐还原火箭的完整姿态
数据落盘之后,最后一步是把几十秒的原始数据变成有意义的曲线。这里有一个经常被忽略的细节:传感器数据和事件标记的时间基准必须统一。固件里所有时间都来自millis(),而地面站的解析脚本里如果把帧序号乘以 10 毫秒当时间戳用,遇到偶尔丢帧就会让整条曲线后半段逐渐错位。正确做法是直接用帧内的timestamp字段做 X 轴,丢帧的位置在曲线上自然形成缺口,而不是错误地对齐到错误时刻。
具体分析时,我一般会分三段看曲线。第一段是倒计时到离架,重点看气压曲线是否平稳、加速度是否有异常预震;第二段是喷射段,从离架标记到高压气体排空,气压快速下降,加速度出现第一个大峰;第三段是弹道段和回收段,气压变化趋于平缓,加速度回到 1g 附近。把三段画在同一张图上,配合事件标记竖线,整个飞行的物理过程就一目了然了。
关于喷射结束时刻的确定,可以用气压计原始值的差分来计算。BMP280 在飞行中记录的压力下降率在喷射阶段显著高于弹道阶段,通过计算一阶差分并找到斜率突变点,就能比较可靠地确定切水结束的时刻:
def find_jet_end(pressure_list, time_ms): # 计算气压差分,取绝对值 delta = [abs(pressure_list[i] - pressure_list[i-1]) for i in range(1, len(pressure_list))] # 找出差分值下降到首段峰值 20% 以下的时间点 threshold = max(delta) * 0.2 for i, d in enumerate(delta): if d < threshold: return time_ms[i] return None这个算法的逻辑是:喷射段因高压气体快速排出,气压每秒下降可达上百帕,进入弹道段后气压只随高度变化,变化率骤降。取首段峰值的 20% 作为切换阈值比较稳,但不同喷嘴直径和发射压强下的曲线形态不一样,建议你把自己实测的那次数据跑一遍,把阈值调到能看到明显台阶的位置。喷射结束时刻配合加速度峰值,能算出喷水效率,这是我们自己评估火箭设计是否合理的重要指标。
姿态维度上,DMP 输出的四元数可以直接转成欧拉角,但转之前记得对 Z 轴做方向校正。水火箭发射时有一定倾斜角,如果传感器安装坐标系和火箭坐标系没有对齐,那么横滚角的绝对数值没有参考意义,只有相对变化量可以用于判断火箭是否发生了翻转。判断是否翻转,更直接的办法是看角速度的 Z 分量有没有突变的符号翻转,这比欧拉角更灵敏。
数据解析脚本建议直接写到 Jupyter Notebook 里保存为模板,每次外场回来只需要替换文件路径就能生成完整的图表。把加速度幅值、气压高度、角速度 Z 轴三条曲线打印在同一张图里,事件标记用竖线画出,每次发射后花五分钟看一遍图,就能替代过去用肉眼看轨迹和秒表掐时间的全部工作。这套脚本本身就是项目开源源码的一部分,比传感器驱动代码的使用频率高得多。
本文还有配套的精品资源,点击获取