简介:安卓手机用蓝牙示波器是一份面向电子爱好者和嵌入式开发者的开源测量方案,将手机作为显示终端,通过蓝牙接收硬件采集到的信号波形,解决传统示波器笨重、不便携带的问题。压缩包共8个文件,大小约115KB,包含5个Java源码文件、1个可直接安装的APK、硬件电路原理图以及dsPIC固件压缩包,覆盖了从单片机数据采集、蓝牙传输到安卓端解析显示的完整链路。项目由国外爱好者设计,硬件接收器由信号采集电路、ADC和蓝牙模块组成,软件端负责数据解析和波形绘制,适合用于学习蓝牙API、数模转换以及移动端可视化技术,也可按需修改ADC精度或界面功能进行二次开发。已有2383人学习下载,资源虽小,但软硬件资料齐备,对于想要低成本搭建便携示波器或研究手机外围设备的开发者很有参考价值。
1. 手机当示波器用:一个蓝牙探头的完整开源方案
调试一块电机驱动板时,手边没有台式示波器,只能靠万用表量电压,波形细节全靠猜。那种情况下,最想要的不是一台新仪器,而是把手机变成屏幕、把一个十几块钱的蓝牙模块变成采样前端——这就是这个安卓蓝牙示波器项目解决的痛点。它包含完整的单片机采集固件、安卓手机App源码,以及硬件原理图,组装起来就是一台两通道无线示波器。
项目适合两类人:一类是日常调嵌入式板子的开发者,需要随时随地看PWM波形、串口电平、电源纹波;另一类是刚接触单片机+蓝牙开发的学生,想找一份能跑通的软硬结合参考工程。它不追求台式示波器的指标,但胜在便携、免接线、源码全开放,从硬件到App每一层都可以按自己的需求改。
2. 项目全貌:目录结构、系统分层与蓝牙数据帧设计
2.1 系统分层:采集板、蓝牙链路、安卓App三方协作
整个系统从信号流入手机屏幕,一共经过三层。第一层是硬件采集板,信号经过分压衰减、钳位保护后进入单片机自带的ADC,由定时器触发完成等间隔采样;第二层是蓝牙串口透传链路,单片机把采样数据打包成固定帧格式,通过串口发给蓝牙模块,蓝牙模块以无线串口的方式把数据送到手机;第三层是安卓App,负责蓝牙设备的发现与连接、数据帧解析、波形绘制和触控交互。
这三层的耦合点只有一个:串口数据帧协议。硬件层按照协议往串口里塞数据,App层按照协议从蓝牙Socket里解数据,两端的开发可以完全独立推进。我拆这个项目时最直观的感受是,协议设计得越简单,联调时越少吵架——这个项目用的是帧头+长度+通道+数据+校验的经典结构,没有搞复杂的数据压缩,稳定优先。
2.2 目录结构:源码、原理图、文档分别放在哪
拿到资源后,顶层目录结构大致如下:
bluetooth-scope/ ├── android/ # 安卓App工程 │ ├── app/src/main/java/ # Java源码 │ └── app/src/main/res/ # 界面布局与资源 ├── firmware/ # 单片机固件工程 │ ├── src/ # 采集与蓝牙发送源码 │ ├── inc/ # 头文件 │ └── project/ # 工程文件 ├── hardware/ # 硬件原理图与PCB │ ├── schematic.pdf # 原理图PDF │ └── bom.csv # 物料清单 └── docs/ # 协议文档与使用说明android/目录是标准的Android Studio工程结构,直接用IDE打开就能编译;firmware/的工程文件对应某款常见MCU,用对应的IDE打开后需要核对芯片型号和引脚定义;hardware/里的原理图是PDF格式,BOM表是CSV,方便直接下单买料。建议先把docs/里的协议文档读一遍,再去看代码,否则直接看固件里的字节操作容易一头雾水。
2.3 数据帧格式:蓝牙链路上跑的是什么
蓝牙串口透传本身不关心数据内容,它只是一根无线延长线。要让手机端正确还原波形,固件必须按帧打包。这个项目的数据帧设计如下:
// 数据帧格式定义(firmware/inc/protocol.h) #define FRAME_HEADER 0xA5 // 帧头固定字节 #define FRAME_MAX_LEN 132 // 一帧最大长度 typedef struct { uint8_t header; // 帧头,固定0xA5 uint8_t length; // 有效数据长度,不超过128 uint8_t channel; // 通道号,bit0为CH1,bit1为CH2 uint8_t data[128]; // ADC采样数据,1字节表示一个采样点 uint8_t checksum; // 校验和,data逐字节累加的低8位 } frame_t;每帧最多携带128个采样点,校验和只覆盖数据区,不包含帧头。这样的设计有两个好处:一是单帧长度有限,蓝牙缓冲区不容易溢出,丢包后最多丢128个点,恢复代价小;二是校验逻辑简单,单片机端累加即可,App端解析时同样累加比对,不一致就丢弃这一帧,不破坏波形连续性。
3. 硬件原理图与固件采集:模拟前端、ADC采样和蓝牙分包
3.1 模拟前端:分压衰减网络与输入保护
示波器第一级不是ADC,而是信号调理电路。被测信号可能是0到3.3V的数字电平,也可能是正负几伏的模拟信号,直接进ADC会超出量程甚至烧毁引脚。原理图上最前端是两个电阻组成的分压网络,把输入电压按比例衰减到ADC可接受的范围。
分压档位对应关系如下:
| 档位 | 分压比 | 测量范围(参考3.3V基准) | 适用信号 |
|---|---|---|---|
| 1 | 1:1 | 0 ~ 3.3V | 数字电平、低压模拟信号 |
| 2 | 1:10 | 0 ~ 33V | 电源轨、电机驱动输出 |
| 3 | 1:100 | 0 ~ 330V | 高压信号(需注意安全) |
分压电阻后面还有两个并联的二极管钳位到电源轨,防止输入电压突然超过VCC或低于GND时损坏ADC引脚。这一步在原理图上不起眼,但实际使用中很多翻车都是因为省掉了钳位二极管,测量电机驱动板时一个尖峰就把MCU烧了。我一般会在输入端串联一个几百欧的限流电阻,和钳位二极管配合,进一步降低损坏概率。
3.2 ADC采样配置:定时器触发而不是软件延时
固件里AD采集最核心的选择是用定时器触发ADC,而不是在循环里用延时函数控制采样间隔。原因很简单:delay的精度受中断影响,采样点间隔不均匀,波形在时间轴上会扭曲。用定时器触发ADC,每个采样点之间的间隔由硬件保证,时基准确。
// 固件初始化代码(firmware/src/adc.c) void adc_init(uint32_t sample_rate_hz) { // 计算定时器分频与重载值,产生指定的采样触发频率 uint16_t prescaler = 72 - 1; // 根据系统时钟计算 uint16_t period = (SystemCoreClock / (prescaler + 1)) / sample_rate_hz - 1; TIM_TimeBaseInitTypeDef tim_base; tim_base.TIM_Period = period; tim_base.TIM_Prescaler = prescaler; TIM_TimeBaseInit(TIM2, &tim_base); // 配置ADC为外部触发模式,触发源选择定时器2 ADC_ExternalTrigConvCmd(ADC1, ENABLE); ADC_ExternalTrigConvTypeDef trigger = ADC_ExternalTrigConv_T2_TRGO; ADC_ExternalTrigConfig(ADC1, trigger); // 使能ADC并启动第一次转换 ADC_Cmd(ADC1, ENABLE); ADC_SoftwareStartConvCmd(ADC1, ENABLE); }sample_rate_hz参数决定了时基分辨率——采样率越高,能看到的信号频率越高,但蓝牙传输压力也越大。这个项目的典型配置是200kSPS,对应能比较清晰地观察几十kHz以内的信号,对音频、电机PWM、串口波形都够用。如果提高采样率到500kSPS,单帧128个点只覆盖不到0.3ms的窗口,App端的刷新体验会很差,需要同时调整帧长或采样模式。
3.3 蓝牙模块接线:三根线解决无线传输
蓝牙部分用的是市面上最常见的串口透传模块,支持SPP协议,出厂默认波特率115200。接线只有三根:VCC接3.3V或5V,GND共地,TXD接MCU的RXD、RXD接MCU的TXD。原理图上特别注意的地方是蓝牙模块的供电——如果模块峰值电流超过MCU开发板的稳压器输出能力,会出现连接后频繁断连的现象。
固件端串口配置要与蓝牙模块的波特率严格一致,否则收到的全是乱码。需要留意的是,很多蓝牙透传模块出厂默认波特率是9600而不是115200,拿到模块后先用AT指令确认一下当前配置,再改固件里的串口初始化参数,或者直接用AT指令把模块调到固件期望的波特率。这是这个项目里最容易踩的坑之一,不是代码问题,是配置没对齐。
3.4 固件发送逻辑:DMA搬运加帧打包
采集和发送是两件事,不能混在一个循环里。这个项目的固件采用双缓冲策略:ADC在DMA半传输中断里填满缓冲A,主循环把缓冲A的数据打包成帧发出去,同时DMA继续往缓冲B里写数据。这样采集不丢点,发送不阻塞。
// 固件发送代码(firmware/src/uart_tx.c) void process_adc_buffer(uint8_t *buffer, uint16_t sample_count, uint8_t channel_mask) { frame_t frame; // 填装帧头、长度、通道号 frame.header = FRAME_HEADER; frame.length = sample_count; frame.channel = channel_mask; // 逐字节拷贝采样数据,同时计算校验和 frame.checksum = 0; for (uint16_t i = 0; i < sample_count; i++) { frame.data[i] = buffer[i]; frame.checksum += buffer[i]; } // 通过串口DMA发送整帧,发送期间CPU可以继续处理下一批数据 uart_dma_send((uint8_t *)&frame, sizeof(frame.header) + sizeof(frame.length) + sizeof(frame.channel) + sample_count + sizeof(frame.checksum)); }channel_mask用于标识这一帧来自哪个通道,双通道模式下两个通道的采样数据会分别打包。用DMA发送而不是阻塞式HAL_UART_Transmit,是为了避免发送期间错过下一次ADC转换完成事件。常见的错误是直接用阻塞发送,导致采样率一高就丢数据,表现是波形上出现规律的断点。
4. 安卓App链路:蓝牙SPP连接、字节流解析与波形绘制
4.1 蓝牙设备发现与SPP连接
安卓端的蓝牙开发绕不开权限适配和设备发现。App使用经典蓝牙的SPP协议与透传模块通信,不是BLE,所以BluetoothAdapter的startDiscovery扫描到的是标准蓝牙设备,需要匹配模块名称或MAC地址来过滤。连接时使用SPP的标准UUID,这是安卓系统与蓝牙串口模块之间约好的服务ID。
// 安卓App连接代码(android/app/src/main/java/.../BluetoothService.java) private static final UUID SPP_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"); public boolean connect(BluetoothDevice device) { try { BluetoothSocket socket = device.createRfcommSocketToServiceRecord(SPP_UUID); socket.connect(); // 阻塞调用,建议放到子线程 mmSocket = socket; mmInStream = socket.getInputStream(); mmOutStream = socket.getOutputStream(); return true; } catch (IOException e) { Log.e(TAG, "connect failed: " + e.getMessage()); return false; } }createRfcommSocketToServiceRecord是经典蓝牙连接的首选方式,系统会去设备上查找匹配UUID的服务通道。注意socket.connect()是阻塞操作,不能放在主线程,否则ANR弹窗是跑不掉的。如果连接失败,常见处理是先close()再重试两次,有些国产手机的蓝牙栈对SPP兼容性一般,重试几次通常能连上。
4.2 字节流解析:状态机拆帧与校验
蓝牙串口是流式数据,没有消息边界,所以App端必须用状态机从字节流里一帧一帧地切数据。解析逻辑的核心是:先找帧头,再读长度,然后收够长度后校验,校验通过就交给绘制层,否则丢弃整个帧重新寻找帧头。
// 数据解析代码(android/app/src/main/java/.../FrameParser.java) public void pushBytes(byte[] buffer, int count) { for (int i = 0; i < count; i++) { byte b = buffer[i]; if (state == STATE_IDLE) { if ((b & 0xFF) == 0xA5) { state = STATE_LENGTH; // 命中帧头,下一步读长度 } } else if (state == STATE_LENGTH) { frameLength = b & 0xFF; if (frameLength > 0 && frameLength <= 128) { state = STATE_DATA; frameData = new byte[frameLength]; dataIndex = 0; } else { state = STATE_IDLE; // 长度非法,回到找帧头状态 } } else if (state == STATE_DATA) { frameData[dataIndex++] = b; if (dataIndex >= frameLength) { state = STATE_CHECKSUM; } } else if (state == STATE_CHECKSUM) { byte checksum = 0; for (byte d : frameData) { checksum += d; } if (checksum == b) { onFrameReceived(frameData, frameLength); // 校验通过 } state = STATE_IDLE; } } }这个状态机的关键参数是frameLength的上下限约束:上限128是协议里定义的单帧最大点数,下限大于0是为了屏蔽空帧。如果长度字节被蓝牙噪声干扰成了大于128的值,直接放弃,避免分配超大数组拖慢解析速度。状态机的好处是即使中间丢了字节,只要下一帧的帧头出现,就能自动恢复同步。
4.3 波形绘制:自定义View与手势缩放
波形显示用的是自定义View,而不是现成的图表库,原因主要有两个:一是示波器波形绘制要求高性能,每秒钟要刷新大量折线点,自定义View可以直接操作Canvas和Path,避免图表库的额外计算开销;二是示波器的交互逻辑很固定——平移、缩放、暂停,自定义View实现起来更可控。
// 波形绘制核心代码(android/app/src/main/java/.../WaveformView.java) @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); Path wavePath = new Path(); float stepX = (float) getWidth() / visiblePoints; // 每个采样点的像素间距 float centerY = getHeight() / 2f; for (int i = startIndex; i < startIndex + visiblePoints; i++) { float x = (i - startIndex) * stepX; float normalized = (samples[i] & 0xFF) / 255.0f; // 归一化到0~1 float y = centerY - (normalized - 0.5f) * getHeight() * 0.9f; if (i == startIndex) { wavePath.moveTo(x, y); } else { wavePath.lineTo(x, y); } } canvas.drawPath(wavePath, wavePaint); }visiblePoints决定了当前屏幕显示多少个采样点,配合手势缩放可以动态调整,实现“放大看细节、缩小看全貌”的效果。startIndex是可视窗口的起点,双指拖动时修改这个值就能实现波形平移。绘制时把采样值归一化到View的高度范围内,这样无论信号幅值怎么变,波形都在屏幕可视区域内。要注意wavePath每次绘制都要重新构造,不能复用旧Path。
4.4 参数下行控制:手机反向设置采样率
示波器不能只被动收数据,还得能主动设置量程和采样率。App通过同一个蓝牙Socket发送控制指令,指令格式与上行数据帧不同:固定两个字节,第一个是命令字,第二个是参数值。固件收到后解析命令字,配置对应的硬件参数。
// 控制指令发送(android/app/src/main/java/.../ControlPanel.java) public void setSampleRate(int rateIndex) { byte[] cmd = new byte[2]; cmd[0] = (byte) 0x01; // 命令字:设置采样率 cmd[1] = (byte) rateIndex; // 参数:档位索引 0~4 sendCommand(cmd); } public void setVoltageRange(int rangeIndex) { byte[] cmd = new byte[2]; cmd[0] = (byte) 0x02; // 命令字:设置电压量程 cmd[1] = (byte) rangeIndex; // 参数:0=1:1, 1=1:10, 2=1:100 sendCommand(cmd); }下行控制指令和上行数据帧共用同一条蓝牙链路,必然存在双向同时收发的场景。SPP本身是全双工的,问题不大,但要注意App端的发送操作也要走子线程,不要在UI线程直接写Socket。固件端在蓝牙模块的串口中断里解析命令字,解析动作要尽量快,避免影响ADC数据的正常发送。
5. 避坑指南:蓝牙丢包、采样率虚标与电压不准的排查记录
5.1 蓝牙链路的三条坑:连接、吞吐与乱码
第一条坑是手机连接后立即断开或连接不稳定。现象是蓝牙配对成功,但App里连接几秒后就收到Socket closed异常。原因通常是供电不足——蓝牙模块发射瞬间电流可以达到几十毫安,如果模块和MCU共用同一个LDO且LDO输出能力有限,射频发射时电压跌落会导致模块复位。解决办法是用独立的LDO给蓝牙模块供电,或者直接在模块电源引脚并联一个100uF左右的电解电容储能。
第二条坑是采样率上去了但波形依然卡顿,屏幕刷新跟不上。原因是蓝牙SPP的实际吞吐不等于理论波特率——串口波特率115200对应约11.5KB/s的字节速率,但蓝牙传输本身有协议开销和重传机制,实际可用吞吐往往只有理论值的六七成。同时App端的Socket读取如果不做缓冲,每来一个字节就唤醒一次线程,性能开销很大。我常用的做法是在App端用BufferedInputStream,攒够一段数据再通知UI刷新。
第三条坑是解析出来的波形全是毛刺或随机跳变。原因是帧同步丢失后没有快速恢复,状态机卡在错误状态。解决方法是给帧头增加一个“连续N个帧头才算真正同步”的条件,并且在STATE_CHECKSUM校验失败时不要立即回到STATE_IDLE,而是先试探丢弃一个字节重新找帧头,避免被连续的噪声干扰。
5.2 采集链路的两条坑:时基不准与幅值偏差
第四条坑是波形在时间轴上明显不均匀,同样的方波前半段宽后半段窄。原因是使用了延时函数而不是硬件定时器触发采样,中断处理代码执行时间不定,导致采样间隔抖动。这个问题的排查方法是把采样率降到很低(比如1kSPS),如果波形还是不均匀,基本可以确定是软件延时的问题。解决思路就是切换到本章第3节提到的定时器触发模式,让ADC转换由硬件驱动。
第五条坑是测量直流电压时读数总是偏低或偏高,偏差比例固定。原因是分压电阻的精度和ADC基准电压都有误差,原理图上的标称值不等于实际值。解决办法是校准:用一台精度更高的万用表同时测量同一个信号,记录App显示值与真实值之间的比例关系,在固件或App里加一个校准系数。原理图上分压电阻如果用的是1%精度的贴片电阻,校准系数通常能纠偏到1%以内。
6. 二次开发:给波形加软件触发的改造思路
拿到这个项目后,最值得改的第一件事不是换皮肤,而是加软件触发。没有触发的示波器,波形在屏幕上随机游走,看周期性信号全靠运气。硬件触发需要额外的比较器电路,软件触发则完全在固件里实现,成本最低、见效最快。
触发逻辑放在固件的DMA中断里:每采到一个点,判断当前值与前一值的相对关系,检查是否满足触发条件(例如上升沿:前一个点低于阈值,当前点高于阈值)。触发条件满足时,把后续采到的点存入发送缓冲区,并开启一个“触发后采样点数”计数,攒够一帧就发送。
// 软件触发实现示意(firmware/src/trigger.c) void trigger_check(uint8_t current_sample, uint8_t previous_sample, uint16_t threshold) { if (!trigger_armed) { // 上升沿触发:前值低于阈值,当前值不低于阈值 if (previous_sample < threshold && current_sample >= threshold) { trigger_armed = 1; post_trigger_count = 0; } } else { // 触发后采集满一帧再发送 if (post_trigger_count < FRAME_MAX_LEN) { post_trigger_count++; } else { trigger_armed = 0; // 一帧收满,准备下一次触发 } } }改造后验证方法很简单:把信号发生器输出一个1kHz方波,打开App的触发开关并把阈值设在方波的中间电平,波形应该稳定停在屏幕上不再左右漂移。触发阈值和触发边沿(上升沿/下降沿)可以做成两个可配置参数,通过App的下行控制指令下发,复用现有的命令通道即可。从那以后,我每次改完固件都会强制走一遍完整的连接、采样、触发、绘制流程,确认没有引入新的数据链路问题,毕竟这类软硬结合的项目,任何一层的小改动都可能让整条链路翻车——希望帮到你。
本文还有配套的精品资源,点击获取