简介:基于CC2530(ZigBee)的观景台控制系统,面向物联网与嵌入式开发者,可完成多节点组网与远程监控,适用于景观照明、环境数据采集等场景。压缩包共237个文件,包含CC2530节点源码(以C/H源文件及IAR工程eww/ewp为主)、Windows上位机源码(涉及QT及dll、qm等)、Android APP源码(含gradle工程与apk),另有hex固件、png图标等辅助文件,整体约49.47MB。已有1628人学习下载。压缩包内三端源码齐全:CC2530侧实现温湿度、光照强度采集与ZigBee组网,ESP8266负责数据上传;Android与Windows端可实时查看节点状态、接收掉线提示,并通过手机APP下发控制指令。无论是学习ZigBee协议栈、ESP8266透传,还是练习QT/Android上位机开发,都能从这套完整工程中获得可直接参考的代码与方案。
1. 为什么观景台项目选择CC2530 ZigBee,而不是Wi-Fi或蓝牙
在室外观景台做一套灯光和环境监测控制系统,最难的不是程序逻辑,而是给几十个分散节点供电和组网。拉线不现实,Wi-Fi节点一多就掉线,蓝牙又穿不透几堵石墙。CC2530是TI的8051内核单芯片方案,把2.4GHz射频收发器、MCU和Flash集于一体,配合ZigBee协议栈能组成树状或网状网络,这是这类场景最常见的工程做法。压缩包里是两部分:CC2530端完整工程(协调器、路由器、终端角色齐全)和配套Android手机APP源码,从组网、采集、指令下发到手机界面是一条完整链路。适合想拿真实项目学Z-Stack与Android串口开发的工程师,也适合智慧园区、路灯这类分散控制项目复用同一套协议框架。
2. 基于CC2530的ZigBee网络:从频段、拓扑到观景台数据链路
2.1 2.4GHz物理层与CC2530的硬件外设
ZigBee在2.4GHz ISM频段工作,物理层采用IEEE 802.15.4标准,DSSS直接序列扩频,原始速率250kbps。这个速率看起来保守,但应付开关灯、温湿度上报这类小数据量绰绰有余,换来的是接收灵敏度通常在-97dBm左右,穿墙能力比经典蓝牙好一些。CC2530内部集成了射频收发器、8051 CPU、256KB Flash(F256型号)和8KB RAM。8KB RAM对Z-Stack来说很紧张,大数组要规划着用,不能在App层随手开uint8 tmp[2048]。
这颗芯片的外设在观景台项目里的用途比预想的清晰:
| 外设 | 项目用途 | 关键点 |
|---|---|---|
| USART0 | 与手机APP/串口网关通信 | 波特率高时要校准系统时钟 |
| USART1 | 调试日志输出 | 可映射到P0_6/P0_7 |
| ADC(12位) | 光照、温湿度模拟量采集 | 需配置参考电压与抽取率 |
| Timer1/Timer3 | 定时上报、看门狗喂狗 | 用OSAL周期事件替代裸定时器 |
| DMA | 串口收发 | 变长帧建议先用中断,DMA后置再优化 |
| P1/P2通用IO | 继电器灯控、状态LED | 上电默认电平要确认,避免灯乱闪 |
2.2 协调器、路由器和终端在观景台里的角色划分
ZigBee里有三种设备角色:协调器(Coordinator)、路由器(Router)、终端(End Device)。协调器每个网络唯一,负责建网、分配短地址;路由器负责转发数据,也能挂载自己的传感器;终端只能通过父节点入网,可以休眠省电。观景台控制系统的合理划分是:协调器放在值班室,用USB串口线连平板或手机;沿步道间隔50到80米放路由器,它们同时承担一部分灯控;花坛、角落放置终端传感器,用锂电池供电,定时唤醒上报。
选星型还是网状拓扑要看场地。观景台如果是单广场、单平台,星型网络最稳定,所有节点直接与协调器通信;如果是沿山体、长廊展开的大范围区域,必须启用网状拓扑,让路由器之间多跳。Z-Stack默认的ZigBee PRO支持Mesh,但路由发现、路径修复都有消耗,代码里要区分对待。
| 角色 | 供电方式 | 是否可休眠 | 典型职责 |
|---|---|---|---|
| 协调器 | 常电(USB供电) | 否 | 建网、串口透传、指令汇聚 |
| 路由器 | 常电(220V转DC) | 否 | 多跳转发、灯组控制 |
| 终端 | 电池或常电 | 是 | 环境监测、简单继电器输出 |
2.3 上行遥测与下行控制的帧结构
手机APP发指令到协调器,协调器解析出目标短地址后通过AF_DataRequest发给ZigBee设备;设备执行完再把状态通过同一个网络回传。为了让串口层不混乱,应用层协议要固定成二进制帧,不要用字符串裸传。
常用的帧结构是:0xAA 0x55 + 1字节长度 + 1字节命令 + 2字节短地址(小端) + 载荷 + 1字节校验。校验用累加和取反加一,和发送端对齐。
// 串口收到一帧后的解析骨架,len是已接收的字节数 int parse_app_cmd(uint8_t *buf, int len) { uint8_t sum = 0; int i; if (len < 7) return -1; // 长度不够一帧最小包 if (buf[0] != 0xAA || buf[1] != 0x55) return -1; // 帧头不符 if (buf[2] != len - 4) return -1; // 帧内长度自检 for (i = 0; i < len - 1; i++) sum += buf[i]; sum = (uint8_t)(~sum) + 1; // 与发送端一样的补码校验 if (sum != buf[len - 1]) return -2; // 校验失败,整帧丢弃 // buf[3] 是命令号,buf[4] 和 buf[5] 是小端短地址 switch (buf[3]) { case 0x01: turn_light(buf[4] | (buf[5] << 8), buf[6]); break; case 0x02: set_dimmer(buf[4] | (buf[5] << 8), buf[6]); break; case 0x03: request_sensor(buf[4] | (buf[5] << 8)); break; default: break; } return 0; }这段代码里帧头不选单字节是有原因的:串口噪声很容易伪造单字节帧头,双字节0xAA 0x55能显著降低误触发概率。长度自检是防止粘包后错位解析。ZigBee短地址在协议栈内部是16位,协议里明确用小端排列,否则CC2530端和Android端会因为字节序不一致把所有目标设备都认错。
命令号可以这样规划:0x01单灯开关、0x02PWM调光、0x03请求传感器数据、0x04场景控制(例如一键全夜景模式)、0x81设备主动上报遥测帧。
3. 在IAR下搭建Z-Stack工程:CC2530源码的结构与裁剪
3.1 从TI的GenericApp模板改,而不是从零写协议栈
ZigBee协议栈不薄,没人会在观景台项目里从物理层开始写。常见做法是从TI提供的Z-Stack(CC2530对应的经典版本是Z-Stack 2.5.1a)里的GenericApp或SampleApp工程改起。用IAR Embedded Workbench for 8051打开工程后,工作区里通常有CoordinatorEB-Pro、RouterEB-Pro、EndDeviceEB-Pro三个编译配置,分别对应三种角色。
源码目录里需要关注的是Components/hal(板级驱动)、Components/stack/af(应用框架层API)、Components/osal(任务调度核心)。工程从模板工程改成观景台控制系统时,Application层基本重写:把原来每秒发一次Hello World的代码删掉,换成串口协议解析和GPIO控制。
3.2 OSAL事件循环:任务初始化与消息分发
OSAL(Operating System Abstraction Layer)是Z-Stack自带的协作式调度器。应用层任务通过任务表注册:
// OSAL任务表,把应用层任务挂进去 const pTaskEventHandlerFn tasksArr[] = { macEventLoop, nwk_event_loop, Hal_ProcessEvent, APS_event_loop, ZDApp_event_loop, SampleApp_ProcessEvent // 自己的应用层 }; // 初始化函数里创建周期事件,OSAL会在时间到后回调 void SampleApp_Init(uint8 task_id) { SampleApp_TaskID = task_id; // 500ms周期触发一次“定时状态上报”事件 osal_start_timerEx(SampleApp_TaskID, SAMPLEAPP_SEND_PERIODIC_EVT, SAMPLEAPP_SEND_PERIODIC_TIMEOUT); } uint16 SampleApp_ProcessEvent(uint8 task_id, uint16 events) { if (events & SAMPLEAPP_SEND_PERIODIC_EVT) { send_realtime_report(); // 定时上报光照/温湿度 osal_start_timerEx(SampleApp_TaskID, SAMPLEAPP_SEND_PERIODIC_EVT, SAMPLEAPP_SEND_PERIODIC_TIMEOUT); return (events ^ SAMPLEAPP_SEND_PERIODIC_EVT); } return 0; }整个调度是协作式,事件处理函数里不能做长时间阻塞,否则MAC层、网络层的事件会堆积,网络很快就掉链子。观景台项目里凡是涉及串口等待、传感器延时读值的逻辑,都拆成“启动事件→收到完成中断→再处理”的两段式写法。
3.3 GPIO控制、ADC采集与继电器驱动
灯控是观景台的主力功能。CC2530的P1口推动继电器需要看驱动能力,通常外接ULN2003或光耦隔离模块,CC2530引脚只做逻辑控制。读光照传感器用ADC:
// 继电器输出,P1_1控制一组景观灯电源 P1SEL &= ~BV(1); // P1_1 配置为普通IO P1DIR |= BV(1); // 方向为输出 P1_1 = 1; // 拉高打开继电器 // 读取光照传感器(ADC通道5,参考电压AVDD5) uint16 read_light(void) { ADCIF = 0; // 抽取率64,通道5,单次转换 ADCCON3 = (0x20 | 0x01 | 0x05); while (!ADCIF); // 等待转换完成,量产代码建议带超时 // 12位结果,高8位在ADCH,低2位在ADCL的高两位 return (ADCL >> 2) | (ADCH << 6); }ADC抽取率决定转换时间和噪声抑制。64抽取率适合快速读取,256抽取率精度更好但耗时更长。观景台的光照变化本来就是渐变,用128抽取率已经够了。参考电压选AVDD5时,读到的原始值要自己换算成实际电压,公式是电压 = ADC值 * 参考电压 / 4095,再根据传感器的线性关系换算成照度。
3.4 串口驱动:CC2530与Android APP之间的桥
CC2530串口和手机APP的通信是最容易出问题的环节。Z-Stack自带hal层串口驱动,直接使用HalUARTWrite即可,不需要自己操作U0DBUF。初始化要点是映射引脚和波特率:
void uart0_init(uint32 baud) { PERCFG &= ~0x01; // USART0 使用备用位置1:P0_2(TX) P0_3(RX) P0SEL |= 0x0C; // P0_2/P0_3 设为外设功能 U0CSR |= 0x80; // UART模式 // 波特率由 U0GCR 和 U0BAUD 配合系统时钟算出, // 工程里按32MHz晶振查表赋值,避免运行时计算 U0GCR = UART_BAUD_GCR; U0UCR = 0x00; // 8N1,无流控 URX0IE = 1; // 允许接收中断 }波特率不是越高越好。115200在32MHz系统时钟下误差可接受,但长线缆、劣质USB转串口芯片会把上升沿整得很难看。| 波特率 | 应用场景 | 注意事项 | |---|---|---| | 9600 | 长距离调试、抗干扰优先 | 帧长时传输慢 | | 38400 | 常规现场默认 | 稳定性和速率均衡 | | 115200 | 快速交互的Android APP | 线缆要短,地线要共 |
做观景台控制时我优先选115200,因为一帧指令通常不到30字节,传完不到3ms,手机端感知不到延迟。如果现场出现首字节丢帧,先换38400验证问题是否出在信号完整性。
4. 配套Android手机APP源码:串口打开、协议解析与状态刷新
4.1 Android Studio工程里的核心模块
这套APP源码的本质是“USB串口调试助手 + ZigBee控制界面”。在Android Studio里打开工程后,核心模块其实就四个部分:MainActivity负责界面、SerialService负责串口生命周期、FrameParser负责协议解析、DeviceAdapter负责设备状态列表。
| 类/模块 | 职责 | 关键点 |
|---|---|---|
| MainActivity | 显示温湿度、灯控按钮、亮度进度条 | UI线程不能做串口IO |
| SerialService | 打开/关闭USB串口、读写线程调度 | 处理设备插拔广播 |
| FrameParser | 帧同步、校验、分包重组 | 状态机写法,避免阻塞 |
| DeviceAdapter | 设备在线状态、离线标记 | 数据刷新用Handler |
Android SDK版本不用追新,minSdkVersion设到21左右足够,这套源码依赖的usb-serial-for-android库在旧版SDK上也能跑。重点是把USB Host权限声明在Manifest里,否则手机根本枚举不到CC2530所在的串口设备。
4.2 通过USB OTG打开CC2530串口
手机通过USB OTG线连接CC2530协调器上的USB转串口芯片(CH340或CP2102最常见)后,APP先要拿到USB设备访问权限,再打开串口配置参数:
UsbManager usbManager = (UsbManager) getSystemService(Context.USB_SERVICE); UsbDevice device = findDevice(usbManager.getDeviceList()); // 按VID/PID过滤 if (!usbManager.hasPermission(device)) { PendingIntent pi = PendingIntent.getBroadcast(this, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE); usbManager.requestPermission(device, pi); return; } // 探测并打开串口,来自 usb-serial-for-android 库 UsbSerialPort port = UsbSerialProber.getDefaultProber() .probeDevice(usbManager, device) .getPorts().get(0); port.open(usbManager.openDevice(device)); port.setParameters(115200, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE);findDevice里要判断的关键是USB设备的Vendor ID和Product ID。CH340的VID通常是0x1A86,CP2102的VID通常是0x10C4,代码里做一张设备表避免插上不识别。setParameters的参数必须和设备端的U0GCR、U0UCR配置完全一致,波特率、数据位、停止位、校验位任何一个不匹配都会乱码。
早期版本用蓝牙串口模块(比如HC-05)连接协调器也是一种常见做法,APP端代码从USB切换成BluetoothAdapter,但蓝牙传输在数据量稍大的场景不如USB直连稳定。
4.3 帧解析:处理粘包、半包和校验失败
Android端接收串口数据和设备端接收ZigBee数据一样,都要面对“串口流是一堆连续字节”的问题。不能简单地read一次就认为是一整帧。状态机是标准解法:
public class FrameParser { private final byte[] buf = new byte[300]; private int len = 0; private int state = 0; // 0:等0xAA 1:等0x55 2:等长度 3:收数据 4:等校验 public void onRead(byte[] data, int size) { for (int i = 0; i < size; i++) { byte b = data[i]; switch (state) { case 0: if (b == (byte) 0xAA) state = 1; break; case 1: if (b == (byte) 0x55) { state = 2; len = 0; } else state = 0; // 重新等帧头 break; case 2: if ((b & 0xFF) > 250) { state = 0; break; } // 长度非法 buf[len++] = b; state = 3; break; case 3: buf[len++] = b; if (len >= (buf[0] & 0xFF) + 1) state = 4; break; case 4: if (checksum(buf, len) == b) onFrame(buf, len); state = 0; break; } } } }状态机里每个字节都做同步判断,这使得任何一帧校验失败后,解析器能在下一个0xAA处重新对齐,不会因为一个坏包导致后面所有数据错位。Android端发送时用ByteBuffer组包即可,注意短地址同样要小端写入:
byte[] buildFrame(byte cmd, int shortAddr, byte[] payload) { int len = 1 + 1 + 2 + payload.length + 1; // 长度字段不算帧头和帧头长度本身 ByteBuffer bb = ByteBuffer.allocate(len); bb.put((byte) 0xAA).put((byte) 0x55); bb.put((byte) (len - 4)); bb.put(cmd); bb.putShort((short) shortAddr); // 小端 bb.put(payload); byte sum = 0; for (int i = 0; i < bb.position(); i++) sum += bb.get(i); bb.put((byte) (~sum + 1)); return bb.array(); }ByteBuffer默认使用大端序,这里要显式调order(ByteOrder.LITTLE_ENDIAN),或者在putShort前手动高低字节交换。这个细节如果漏了,设备端解析出来的短地址会差256倍,所有灯都控制到错误的节点上。
4.4 界面状态刷新:串口线程与主线程通信
串口读取必须放在工作线程,Android不允许在主线程做阻塞式IO。读取线程拿到FrameParser解析结果后,再通过Handler把数据切回主线程更新UI。这种模型虽然老,但比在回调里直接操作View安全得多。
private final Handler mUiHandler = new Handler(Looper.getMainLooper()); // 后台线程收到0x81上报帧后,回传已解析的数据对象 void onDeviceData(final DeviceData d) { mUiHandler.post(() -> { tvLight.setText(String.valueOf(d.light)); tvTemp.setText(String.valueOf(d.temp)); pbarStatus.setProgress(d.duty); // 亮度进度条 if (d.offline) { tvStatus.setText("离线"); btnLight.setEnabled(false); } }); }关键是串口线程和UI线程的生命周期解耦。Activity退出时先关闭串口、置中断标志,再结束读写线程,否则再次进入页面时会抛Device not open。观景台APP里常见的卡顿、ANR,十有八九是这里没有处理好。
5. 源码联调:烧录、组网参数与三点排查
5.1 编译烧录与三个必调参数
CC2530源码用IAR编译,烧录用TI官方工具SmartRF Flash Programmer。拿到源码后不要急着全部烧录,先把三个参数统一:PAN ID(个域网标识符)、信道、串口波特率。
Z-Stack的编译参数集中在f8wConfig.cfg文件里,IAR编译命令行通过-f参数把它带进来:
// f8wConfig.cfg 节选 -DZDAPP_CONFIG_PAN_ID=0x12AB // 固定PAN,避免不同网络互相串扰 -DDEFAULT_CHANLIST=0x00008000 // 只监听信道15 // 波特率在 HAL 层配置,App层通过 HalUARTInit 保持一致| 参数 | 建议值 | 说明 |
|---|---|---|
| PAN ID | 固定值如0x12AB | 不用0xFFFF,防止协调器重启后随机新建网络 |
| DEFAULT_CHANLIST | 0x00008000(信道15) | 现场有Wi-Fi时按频谱调整 |
| 串口波特率 | 115200 | 协调器和APP端必须完全一致 |
协调器、路由器、终端三端工程的PAN ID和信道必须一致,否则物理上近在咫尺也组不了网。三端都烧录完毕后,协调器先上电,串口调试助手能看到它打印建网日志,接着路由器上电,终端最后上电,观察终端的状态LED从闪烁变为常亮,说明入网成功。
5.2 组网失败如何定位:看现象、看日志、看抓包
组网不成功时,先别怀疑硬件,九成是配置类问题。经验上沿着“LED状态→串口日志→空口抓包”三层排查:
| 现象 | 第一排查点 | 常见原因 |
|---|---|---|
| 路由器入不了网 | 信道/PAN配置 | 协调器和路由器编译配置不一致 |
| 终端一直Associate | 父节点路由表满 | 关闭安全选项-DSECURE=0再试 |
| 灯控延迟高 | 拓扑与路由深度 | 路由器位置不当,数据在绕远路 |
| 串口收发乱码 | 波特率/接线 | 波特率不一致或USB转串口没共地 |
串口日志是Z-Stack排障的功臣。源码里通过NPI_Printf或LCD相关宏保留调试输出,连上USB转串口线就能看到入网关联事件。如果现场没有串口条件,那就用TI的SmartRF Packet Sniffer配合CC2531 USB Dongle在空中抓包,看Beacon请求、关联请求有没有到达协调器。这能区分“节点根本没发”和“协调器没收”两种完全不同的故障方向。
5.3 2.4GHz干扰与覆盖范围调整
观景台通常在户外,极容易和附近的Wi-Fi、无线摄像头、蓝牙设备抢2.4GHz频段。ZigBee信道11到26从2405MHz到2480MHz,Wi-Fi的三个不重叠信道1、6、11正好穿插其间。常见建议是避开Wi-Fi主瓣选择ZigBee信道15、20、25,但现场干扰不是教科书,不能只看信道编号。我会先用手边的手机或频谱仪扫一圈当前2.4GHz占用情况,再决定DEFAULT_CHANLIST到底写哪个值。
覆盖不够时,优先加路由器而不是加发射功率。CC2530的发射功率范围大约是-22dBm到+4.5dBm,把功率调到最高确实能多传几十米,但电流也随之上涨,长时间高温环境反而更不稳定。把路由器放在节点密集的中心位置,比单纯把协调器功率拉满有效得多。
// 部分Z-Stack版本在应用层直接暴露发送功率接口 // 不同版本API有差异,以工程实际头文件为准 macRadioSetTxPower(0xF5); // 典型高功率档位,具体值查数据手册观景台项目要在图纸上先标好路由节点位置,节点高度尽量超过人的身高,不要贴在金属栏杆或太阳能板背面。实测中,天线离地1.5米和离地0.5米的传输成功率差距可能超过30%。
6. 从能用到稳定:三个值得动手改的源码细节
6.1 应用层重发与离线检测
ZigBee底层有APS ACK和MAC重传,但它只管“这一跳投出去了”,不保证“整条链路从协调器到终端都执行了”。观景台灯控指令如果丢一帧,夜里某个区域就是黑的,体验极差。我一般在协调器端加一个应用层待确认表,下发的每一条控制指令进入表里,超时没收到回执就重发:
typedef struct { uint16 dstAddr; uint8 cmd; uint8 retry; uint8 timeout; // 超时计数 } pending_t; void process_pending(void) { for (int i = 0; i < PENDING_MAX; i++) { if (pending[i].timeout && --pending[i].timeout == 0) { if (pending[i].retry < 3) { send_cmd(pending[i].dstAddr, pending[i].cmd); pending[i].timeout = 5; // 0.5s后再次检查 pending[i].retry++; } else { mark_offline(pending[i].dstAddr); pending[i].timeout = 0; // 放弃 } } } }重试3次、间隔0.5秒是工程经验值。间隔太短会放大网络拥塞,太长则人按了按钮没反应。特别注意:应用层重发要求设备端的控制逻辑做去重,否则同一帧“开灯”指令重复执行两次,继电器可能会抖动。
6.2 继电器控制的重复帧去重
应用层重发机制上线后,终端必须能识别重复帧。最简单的方法是在帧结构里加1字节序列号,终端记住上次处理的序列号,相同序列号直接丢弃:
static uint8 last_seq = 0xFF; void handle_light_cmd(uint8 seq, uint8 on_off) { if (seq == last_seq) return; // 重复帧直接丢弃 last_seq = seq; relay_set(on_off); // 真正执行 }这个去重必须放在设备端而不是协调器端,因为重发的节点可能不是同一个路由器,协调器看到的序列是连续的,但终端收到时已经是乱序。序列号空间只有256,溢出后回到0,但配合“相同才算重复”的规则已经足够防抖。
6.3 把短地址从随机改成静态分配
Z-Stack默认在入网时动态分配16位短地址,协调器重启、终端重新入网后,短地址很可能变化。这带来一个实际问题:手机APP保存的设备编号会失效,重发日志里看到的地址和现场位置对不上。稳定的观景台项目,我会在设备端把短地址写死,协调器端关闭动态分配逻辑,让每个灯控节点拥有永不变化的地址。
// 设备端在入网后自设短地址(部分协议栈版本开放此接口) // 注释:具体接口名以工程内ZDO_RegisterForZDOMsg为准 NLME_SetShortAddr(0x0001); // 路由器/终端的固定编号这样做的代价是网络配置不灵活,换一台设备要重新烧程序;但换来的是APP端的设备表不用每次扫描,离线检测、分组场景、重发日志都能直接按地址索引。观景台这种设备和位置绑死的场景,静态地址比动态地址更值得。
最后建议在布点施工前,把所有节点的物理位置、PAN ID、短地址做一张映射表写进源码注释里。这套地址表会成为现场调试时的“根坐标系”,重发日志、离线列表、甚至工人报修时说的都是同一个编号,这个动作比任何调参都能决定ZigBee观景台项目能否长期跑得稳。
本文还有配套的精品资源,点击获取