简介安卓蓝牙示波器资源包面向电子爱好者、嵌入式开发者和移动端工程师提供一套将手机当作便携测量终端的完整参考方案适合低成本信号检测与分析场景。硬件部分包含信号采集电路、ADC与蓝牙模块压缩包内附电路原理图及dsPIC固件代码便于自制和调整采样精度软件部分提供安卓应用APK及5个Java源码文件可完成蓝牙数据解析、手机屏幕波形绘制并支持参数设置、波形分析、存储回放等扩展功能。由于蓝牙带宽有限实时性和精度有一定约束但低功耗与广泛兼容性使其仍是手机连接的理想选择开发者也在探索Wi-Fi传输以提高速度。整个zip包共8个文件大小仅115KB包含硬件原理图、固件压缩包、APK与5个Java源文件结构紧凑开源特性便于二次开发。已有2383人学习下载适合学习蓝牙通信、Android数据可视化及移动测量技术的人群。1. 安卓蓝牙示波器一块屏、一个数据流、一次完整的软硬件协同实战把一台普通安卓手机改造成一台两通道蓝牙示波器可能吗答案是不仅可以而且整个链路比想象中短单片机以固定周期采集模拟电压通过蓝牙把数据帧推给手机AppApp负责解码、存储和绘制波形。我拆过一套这样的资源和配套原理图从焊板、烧固件到编译App单次跑通大约需要两天时间。这套资源适合三类人一是电子工程相关课程需要低成本实验平台的学生二是调试电源纹波、传感器输出、马达驱动PWM等低频信号的嵌入式开发三是对蓝牙无线数传感兴趣、想自己攥一套完整软硬件Demo的个人开发者。下面把其中最关键的数据链路、代码结构和踩坑点展开讲让你拿到资源后不迷路。2. 系统架构与选型蓝牙SPP与BLE的取舍、采样率与ADC精度怎么定2.1 整体数据链路传感器到屏幕的每一毫秒去了哪里先看一条信号从被测点到手机屏幕的完整路径。被测电压经过探头或杜邦线先进入硬件板上的前端调理电路这里的任务是分压、限幅、滤波把未知的电压变换成单片机ADC能承受的0~Vref区间。ADC每个采样周期把模拟电压转换成数字码值可能存在DMA缓冲区里也可能直接被中断服务程序取走。固件里的组帧函数把最近一次的ADC结果填进自定义帧结构加上帧头、通道标识、计数值和校验字节然后交给串口发送。蓝牙模块经典SPP或BLE透传通过UART接口拿到这些帧数据再按无线协议打包发到手机。手机端收到蓝牙串口数据后要先做粘包和半包处理按帧头找到完整帧再做校验最后取出ADC值换算成电压并交给绘图View实时刷新。这条链路里最容易被低估的瓶颈是无线的速率和延迟。经典蓝牙SPP的实际吞吐量一般在10~30KB/sBLE在透传模式下可达几KB/s到几十KB/s。而单片机内部ADC能做到1Msps甚至更高所以真正的限制落在蓝牙吞吐量上。比如单通道、每个采样点用2字节表示、再加上6字节的帧头校验数据率就是8字节/采样点。如果想传输20k采样点/秒就是160KB/s这个速率无论SPP还是BLE都扛不住。所以实际设计时要先算清楚可接受的有效带宽再反推采样率上限。2.2 选型经典蓝牙SPP还是BLE以及采样率的量级估算选择蓝牙方案主要看两条连接方式和数据吞吐量。经典蓝牙SPP就像一条虚拟串口连接稳定、丢包少、协议栈简单安卓侧只需要BluetoothSocket就能收发开发最直接。BLE的优势是功耗低广播和连接间隔可配置但它面向的是小包突发场景连续高速传输时容易触发丢包或流控问题。所以我的建议是如果被测信号频率低于10kHz且不苛求电池续航优先用SPP如果做的是穿戴式、需要长时间记录低频信号比如心电图或温度曲线的低功耗应用选BLE但要把采样率进一步压低到1k~5k采样点/秒。采样率的量级可以用一个简单式子估算总需要的字节率 采样率 × 每样本字节数 × 通道数 × 协议系数。协议系数通常取1.2~1.5是因为帧头、校验和可能引入的填补字节。假设用SPP实际能稳定做到20KB/s每样本占2字节单通道协议系数1.25那么最大采样率就是20,000 ÷ (2 × 1 × 1.25) 8k采样点/秒。这对应能测约4kHz带宽以内的信号。如果你的测量对象是几十赫兹的传感器输出这个带宽绰绰有余如果你想看高频振铃就得降低分辨率或压缩数据。2.3 硬件原理图的核心模块前端调理、ADC、蓝牙接口硬件原理图通常包含三个可复用的模块。第一个是输入保护与分压网络。被测电压可能远高于ADC参考电压原理图上会有电阻分压器和两个二极管钳位把输入电压限制在0~VDD之间。分压比例选择很关键比如要测0~30V直流而ADC参考电压是3.3V那分压比至少要10:1。这里一个常见错误是只考虑电阻分压没考虑ADC输入阻抗结果分压电阻太大导致采样值偏低。正确的做法是加一级电压跟随器运放构成让信号源阻抗与ADC隔离。第二个模块是ADC采样电路。单片机的ADC引脚需要一只0.1uF的小电容滤除高频噪声同时保证采样保持电路有足够的充电时间。有些原理图会在ADC引脚前串联一个几十到几百欧的电阻跟电容构成低通滤波器截止频率要算得比目标信号高否则会把你要看的细节滤掉。还要注意ADC参考电压的来源如果直接接单片机LDO输出精度只有1%左右要想波形幅值准确就得用独立基准电压源。第三个模块是蓝牙接口。经典蓝牙模块多数用UARTRX/TX交叉连接还要处理电平匹配。很多蓝牙模块是5V逻辑单片机是3.3V需要加电平转换或者分压。原理图里XC6152类降压芯片给整个板子供电蓝牙模块的供电和地要单独走线避免功率电路干扰到模拟前端。这部分的布局走线会直接影响波形噪声我第一版的板子就是因为蓝牙模块紧挨着模拟输入走线导致屏幕上出现了规律的毛刺。3. 固件端实现ADC连续采样、组帧发送与流量控制3.1 定时器触发ADC采样用DMA还是中断固件端首先要解决的是ADC采样时机。轮询模式只在主循环里循环读ADC采样间隔不稳定波形画出来会带有时间轴抖动。正确的做法是用定时器触发ADC转换并用DMA把结果搬进内存这样采样间隔精确到微秒级CPU还能同时处理组帧和蓝牙发送。下面是一个常见单片机以标准外设库为例的配置思路代码内核参考如下void adc_timer_init(void) { // 定时器分频后得到 1MHz 计数频率 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseInitTypeDef tim; tim.TIM_Prescaler 72 - 1; // 假设系统主频72MHz分频到1MHz tim.TIM_Period 10 - 1; // 1MHz除以10得到100kHz触发频率 tim.TIM_ClockDivision TIM_CKD_DIV1; tim.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, tim); TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update); }这段代码把定时器更新事件作为ADC外部触发源。分频器和周期值决定了触发频率调整这两个参数就能改采样率。比如想要20k采样就把周期改成501MHz÷5020kHz。注意定时器不能产生太快的中断否则主程序会被打满所以更推荐的成熟方案是配合DMA。void adc_dma_start(void) { DMA_InitTypeDef dma; dma.DMA_PeripheralBaseAddr (uint32_t)ADC1-DR; dma.DMA_MemoryBaseAddr (uint32_t)adc_buf; dma.DMA_DIR DMA_DIR_PeripheralSRC; dma.DMA_BufferSize ADC_BUF_LEN; // 比如128次转换 dma.DMA_PeripheralInc DMA_PeripheralInc_Disable; dma.DMA_MemoryInc DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; dma.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; dma.DMA_Mode DMA_Mode_Circular; // 循环采集 dma.DMA_Priority DMA_Priority_High; DMA_Init(DMA1_Channel1, dma); DMA_Cmd(DMA1_Channel1, ENABLE); }这里的DMA缓冲区用了循环模式每采满一整个缓冲区后触发一次中断固件在中断里把缓冲区数据打包发送。之所以不用半传输中断是为了保证每帧数据都是连续的片段不会出现新旧数据混在一起的错位。另一个好处是CPU只需在缓冲区满时工作一次采样间隙完全空闲留给蓝牙推帧用。3.2 数据帧协议设计帧头、通道、计数值与校验蓝牙无线传数不像有线串口那么可靠偶尔会出现字节丢失或乱序。因此每一帧数据必须有明确的边界和校验。我常用的帧格式是6字节帧头 每通道2字节数据。以单通道、批量16个点为例帧结构可以设计成typedef struct __attribute__((packed)) { uint8_t head[2]; // 固定0xAA 0x55 uint8_t channel; // 0:CH1, 1:CH2 uint8_t seq; // 包序号用来检测丢包 uint16_t length; // 本帧数据字节数 uint16_t samples[]; // ADC原始值每个点2字节 uint8_t checksum; // 前所有字节和取低8位 } DataFrame;发送侧这么填充和校验void build_frame(uint8_t ch, uint16_t *buf, uint16_t len) { frame.head[0] 0xAA; frame.head[1] 0x55; frame.channel ch; frame.seq seq; frame.length len * 2; memcpy(frame.samples, buf, len * 2); uint8_t sum 0; uint8_t *p (uint8_t *)frame; for (uint16_t i 0; i sizeof(frame) - 1; i) { sum p[i]; } frame.checksum sum; }seq包序号非常重要。手机端只要发现序号不连续就能判断无线链路丢了包然后决定是补发还是丢弃这帧。length字段用来做半包和粘包拆分的依据checksum用来过滤传输误码。协议里没有用复杂CRC是因为低波特率下误码率并不高一个累加校验足够挡住绝大多数随机错误但要注意累加校验会把字节顺序错误也掩盖掉所以帧头用了AA 55这种非对称模式能在很大程度上避免误对齐。3.3 发送节奏与串口波特率别让蓝牙成为瓶颈蓝牙模块和单片机之间是UART默认波特率一般有9600、38400、115200几档。如果单片机一次发一大包数据蓝牙模块内部缓冲区可能撑爆所以帧长和发送间隔必须算好。比如蓝牙模块串口波特率是115200实际有效数据率约11.5KB/s去掉起始位停止位而ADC采样率20k、每点2字节那么单通道就需要40KB/s明显超了。解决思路有三个降低采样率、降低分辨率8位、或者压缩数据。我一般把发送放在一个独立的环形缓冲队列里主循环从队列里取帧通过串口发送函数推给蓝牙而不是在DMA中断里直接阻塞发送。原因是DMA中断频率高且不能长时间占用否则会饿死定时器中断。代码核心逻辑如下#define TX_RING_SIZE 4096 uint8_t tx_ring[TX_RING_SIZE]; uint16_t tx_head 0, tx_tail 0; void send_frame(DataFrame *p, uint16_t len) { // 将frame字节写入环形缓冲注意处理回绕 for (uint16_t i 0; i len; i) { tx_ring[tx_head] p-bytes[i]; tx_head (tx_head 1) % TX_RING_SIZE; } } void uart_dma_send_task(void) { // 查询DMA是否空闲空闲时从环形缓冲取数据并启动一次DMA发送 }这个设计防止了串口发送阻塞采样任务。如果环形缓冲已满说明手机端处理不过来此时宁可丢弃最新的帧也不能把旧数据写入覆盖掉因为波形看到的“卡顿”总比“错乱”容易接受。实际调试时我会用蓝牙模块的指示灯频率粗略判断是否过载——指示灯闪烁太快说明数据量接近上限闪烁规律性变差则说明发送节奏不稳定。4. 安卓App实现蓝牙连接、数据解析与波形绘制4.1 权限配置与设备发现经典蓝牙的连接流程安卓侧开发要先在AndroidManifest里申请权限。Android 6以上需要动态申请定位权限才能扫描蓝牙设备Android 12以上还要申请BLUETOOTH_SCAN和BLUETOOTH_CONNECT。代码中用运行时权限请求处理扫描接口这一步漏掉的话设备列表永远是空的。连接代码走标准BluetoothAdapter流程BluetoothAdapter adapter BluetoothAdapter.getDefaultAdapter(); adapter.startDiscovery(); BluetoothDevice device adapter.getRemoteDevice(macAddress); BluetoothSocket socket device.createRfcommSocketToServiceRecord(UUID.fromString(00001101-0000-1000-8000-00805F9B34FB)); socket.connect();这个UUID是串口服务SPP的标准UUID。很多新手卡在连接失败上原因往往是扫描和连接不允许并发连接前必须调用cancelDiscovery()。另外为了防止蓝牙服务适配器被系统回收很多资料建议获取一个守护的BluetoothAdapter实例但更稳定的做法是让连接操作放在后台线程并在Activity的onPause里断开连接避免界面切换导致socket泄漏。连接建立后数据读取用独立线程InputStream input socket.getInputStream(); byte[] buffer new byte[1024]; int bytes; while ((bytes input.read(buffer)) ! -1) { if (bytes 0) { dataQueue.offer(Arrays.copyOf(buffer, bytes)); } }这里用的是阻塞式InputStream因为蓝牙socket的读取本身是阻塞的放到普通线程里最直观。千万不能直接在主线程里读数据否则界面一卡一顿波形直接糊掉。4.2 接收线程与环形缓冲避免UI卡顿的设计原始字节流到达后App要做拆帧、校验、转电压、再丢给UI绘制。如果把这些工作全部挤在读取线程里一旦有半包数据线程就被占住后续数据来不及收导致进一步丢包。更合理的结构是用生产者消费者模式读取线程只负责把原始字节写入一个线程安全的环形队列同时另外起一个解析线程从队列里取数据拆出完整帧。private RingBufferbyte[] rawRing new RingBuffer(256); private void parseLoop() { while (!stopFlag) { byte[] chunk rawRing.poll(50, TimeUnit.MILLISECONDS); if (chunk null) continue; parseBytes(chunk); } }parseBytes内部维护一段残留缓冲区每次把新数据追加进去然后循环查找帧头、解析长度、校验、取出样本。解析结果放入一个波形数据数组使用ArrayDeque存放最近几千个点绘制线程通过invalidate()触发重绘。这种分层设计让UI线程永远只做绘制不做IO和解析就算蓝牙数据稍微抖动波形也不会掉帧。4.3 自定义View画波形缩放、暂停、触发基线波形显示是App里最直接的脸面。用系统View的onDraw里画Path即可核心思路是维护一个采样点数组每次解析到新数据就滚动数组再在onDraw里把数组映射为屏幕x/y坐标。Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); Path path new Path(); float stepX getWidth() / (float) waveData.length; for (int i 0; i waveData.length; i) { float y getHeight() - (waveData[i] / 4096f) * getHeight(); if (i 0) { path.moveTo(0, y); } else { path.lineTo(i * stepX, y); } } canvas.drawPath(path, wavePaint); }波形显示还要支持比例缩放。最简单的实现是定义样本数量变量pointsPerScreen当用户双指缩放时改变这个值刷新绘制。暂停功能则是停止更新waveData数组但保留接收线程继续回放数据到后台缓冲这样恢复绘制时能直接补画最近一段。触发基线也就是触发电平可以画一条横向虚线比较跨过基线的相邻点来标记起始位置。这个功能在观察周期信号时特别有用否则波形在屏幕上快速滚动人眼很难捕捉到稳定的相位。5. 联调避坑波形乱飞、数据丢包、电压超量程的五个典型现场5.1 现象刚连上就疯狂掉线联调第一天手机App可以搜索到模块连接后大约一两秒就断开有时候根本连不上。反复配对后偶尔成功但一传输数据瞬间掉线。原因有两个。第一是连接过程中没有调用cancelDiscovery()蓝牙还处在扫描状态占用了RFCOMM通道。第二是单片机上电后蓝牙模块进入AT配置模式或者频繁广播干扰了连接建立。解决方法是连接前强制取消扫描并检查模块状态指示灯确认模块已经进入数据传输模式。控制代码顺序如下adapter.cancelDiscovery(); if (socket ! null) { try { socket.close(); } catch (Exception e) {} } socket device.createRfcommSocketToServiceRecord(uuid); socket.connect();之后就稳定了。5.2 现象波形是锯齿或者随机跳变屏上正常输入一个直流电压显示的却是锯齿状波形或者数值随机跳。原因排查发现模拟前端的地线和数字地没有分开铺铜蓝牙模块发射瞬间的电流波动通过共地耦合到了ADC输入。另一个原因是Vref直接用3.3V LDO供电负载变化导致参考电压波动ADC读数自然跟着抖。解决方法是地平面做单点连接模拟地和数字地通过磁珠或0欧电阻连接Vref换成专门的基准源或者在ADC采样前加一颗100uF电解电容和0.1uF瓷片电容并联。如果还抖则检查探头屏蔽层我后来给两根测试线加了双绞屏蔽线锯齿立刻消失。5.3 现象采样率标称20k实际只有1k设置定时器触发频率是20kHzApp端显示实际更新速率只有1kHz波形明显迟钝。开始怀疑蓝牙吞吐后来发现瓶颈在固件端发送逻辑上。工程里用了串口轮询发送每次发送一帧数据要等待发送寄存器清空如果波特率只有9600那么一帧96字节需要100毫秒实际每秒只能发10帧每帧16个点只有160个点每秒完全达不到20k。解决方法是把波特率提高到115200以上并且配置成使用DMA发送或者把帧内样本数放大一帧发256个点这样帧发送开销被摊薄有效数据率才上去。我最终把一帧样本数改成256波特率提到450000实际能跑到16k采样点/秒足够看10kHz之内的波形。5.4 现象正常测量时单片机发热明显给板子供电后环境温度正常但单片机芯片摸起来烫手。原因是蓝牙模块的VCC被误接到了单片机的IO口上而这个IO口被配置成了推挽输出高电平。蓝牙模块工作电流可以达到几十毫安IO口根本带不动于是内部压降变大热量集中。解决方法是严格区分电源引脚和信号引脚蓝牙模块供电从3.3V电源轨走中间加一个10uF去耦电容。这个现象说明原理图和PCB检查要过一遍“电源流向图”每一路电流从哪来回哪去。5.5 现象电池供电下波形噪声很大用USB供电时波形很干净改用电池供电后噪声幅度增加了至少一个量级。原因不是蓝牙模块而是电池电压不稳定。在电池供电状态下蓝牙发射时电流瞬间增大电源轨跌落再反弹AD转换正好发生在这个波动窗口就会采到毛刺。解决方法是硬件端在电源输入端并入一个大电容例如470uF和一个小电容0.1uF形成高低频去耦阵列固件端则把ADC触发点错开蓝牙发射时刻。更绝的做法是采用恒流源或电源管理芯片做稳压但从实测角度看一颗470uF电容已经能解决95%的噪声问题。6. 进阶调试技巧用回调式波形标记和离线数据回放校准测量误差波形显示出来只是第一步真正好用还要加上测量辅助功能。推荐两个我经常用到的进阶技巧回调式峰值标记和离线数据回放校准。回调式峰值标记不是简单地画一条最高点线而是实时监视新到的样本当检测到连续三个点的变化趋势从升转降时就把这个点的位置用一条竖直光标标出来同时计算相邻两个峰之间的时间差得到频率。代码里用一个简易状态机if (prevSample currentSample nextSample currentSample) { peakIndex index; peakValue currentSample; // 触发UI更新标记 }之所以叫“回调式”是因为峰值检测事件通过回调接口抛给ActivityActivity只负责更新界面标注不参与波形数据存储。这样即使每秒有几千个样本UI也能轻松响应。离线数据回放校准是每次测量时同时把原始ADC值和对应时间戳写入文件格式用CSVtimestamp,adc_value,voltage 1630000000,2048,1.652 1630000001,2050,1.653在电脑上加载这些CSV先用已知直流电压表数据做线性回归求出实际增益和偏置再把修正后的参数写回App的配置文件。这个习惯帮我发现了一块板子上的微小电阻焊错问题ADC看起来正常电压却整体偏低0.08V。校准流程也很简单输入0V、1V、2.5V三档标准电压记录每档的ADC均值然后用两点拟合出斜率和截距。从那以后我每次拿到新板子都会强制走一遍“跑波形、存CSV、回放校准”的三步流程。校准完成后再回来看实时屏波形幅度误差能控制在2%以内比只看读数可靠得多。这套蓝牙示波器资源里带的正是一个能在真实项目里反复用的软硬件底座把通信协议和波形绘制都跑通之后你还可以继续往里面加协议解码、频谱分析甚至双通道时延测量希望帮到你。本文还有配套的精品资源点击获取