简介:面向嵌入式开发与物联网学习者的Zigbee/CC2530网络拓扑综合实验资料,聚焦Zstack协议栈下的网络结构搭建与节点通信实现,完整覆盖实验目的、硬件环境、原理分析到代码编写与实测现象的全流程,适合学习单片机、无线传感网络或准备CC2530课程设计的学生参考。
包内共5个文件,包含两个C语言源码文件(实现协调器与终端节点主要功能)、头文件、txt说明文本及docx实验报告,压缩包仅762KB,轻量易获取。实验报告细致展示了引脚选择逻辑、拓扑分支结构、从零开始的开发环境搭建与代码烧写步骤,并配有带注释的程序源码。
已有614人学习浏览,实验现象来自作者在硬件实验室的实测结果,真实可参考。整体内容紧凑,既有可运行代码,又有完整讲解,适合需要快速上手CC2530实验或完成同类网络拓扑开发任务的读者使用。
1. 串口打印 A(B(E,F(I,J)),C(G,H)):Zstack 树形拓扑的观察窗口
在 CC2530 的 zigbee 硬件实验里,串口助手输出A(B(E,F(I,J)),C(G,H))看起来只是一行括号串,实际它同时验证了四件事:协调器成功建网、多个节点完成入网、父-子关系被正确上报、Zstack 协议栈的转发路径与实验设计一致。
这个字符串不是人工打印的静态文本,而是协调器收到各节点上报后动态拼接出的树形结构。要做出来,需要理解 Zstack 的设备角色、入网回调时机、拓扑表存储和 UART 输出路径。
这篇笔记以实验包的 code 工程(Coordinate.h/c + Enddevice.c)为线索,把这些点逐个拆开,并给出可直接抄的代码与排错方法。适合正在调 zigbee 网络、准备毕设或初学协议栈的人参考。
2. Zstack 网络拓扑基础与实验工程结构
实验包里的文件划分很有代表性:Coordinate.h/c只负责协调器侧拓扑收集,Enddevice.c是节点侧上报逻辑。这样的拆分让多人协作和移植都比较清晰。先理解设备角色和入网回调,再看代码不迟。
2.1 zigbee 设备类型与树形拓扑的物理含义
Zstack 定义了三种逻辑设备:协调器(Coordinator)、路由器(Router)和终端设备(End Device)。协调器负责选择信道和 PAN ID、启动网络;路由器允许其他节点通过它入网并转发数据;终端设备只收发数据,不能再做父节点。回到A(B(E,F(I,J)),C(G,H)),根节点 A 是协调器,B、C 是路由器,F 下面挂了 I、J,说明 F 也必须是路由器;E、G、H、I、J 是终端设备。树深度被协议栈的MAX_DEPTH限制,这个实验里是 3 层,路径 A-F-I 已经到协议栈允许的中等深度。
提示:有很多实验平台用同一份 Enddevice.c 配合编译宏切换角色,不要看到文件名里有 End 就认为所有非协调器节点都是终端。
我在调试时遇到过一种误判:把 B、C 也烧成 End Device,结果串口永远打印不出B(...)。原因很简单,终端设备不允许挂子节点,F 下面自然也不会有 I、J。所以动手前先确认哪些节点编译成 Router,哪些编译成 End Device。
2.2 Zstack 入网流程与父子关系上报点
节点上电后,Zstack 会经历DEV_HOLD、DEV_NWK_JOINING、DEV_NWK_JOINED等状态迁移。入网完成的标志是ZDO_StateChangeCB回调事件,事件里devState变成DEV_END_DEVICE或DEV_ROUTER。在这个回调里可以安全读取短地址,并触发一次「我入网了」的上报,因为此时 MAC 关联已经完成,短地址已经分配。
常见做法是在回调里读取以下两个值:本节点短地址NLME_GetShortAddr(),以及父节点短地址ZDO_GetParentAddr()这类平台封装。Zstack 原版 SDK 并没有一个统一叫这个名字的公开 API,很多实验平台自己在ZDApp.c或者ZDObject.c里封装,行为是查邻居表里关联标志为父节点的记录。所以看到实验代码里调用这样名字的函数,不用惊讶,它是把网络层邻居表封装了一层。
协调器侧的入网确认则不同。协调器建网成功后收到ZDO_NetworkStartConfirmCB,此时devState == DEV_COORDINATOR。要注意:协调器的串口打印不能放在主函数循环里,而应该放在建网成功的回调之后,否则节点列表还没收到任何上报,打出来只是空拓扑。
2.3 Coordinate.h/c:拓扑表与括号串生成的约定
Coordinate.c 的核心是一张静态数组拓扑表。CC2530 的资源很紧张,内置 8KB RAM(实际可用更少),所以不能用动态链表,常见做法是预定义上限:
#define MAX_NODES 32 #define MAX_CHILDREN 8 #define MAX_DEPTH 5 typedef struct { uint16 shortAddr; uint16 parentAddr; uint8 depth; uint8 childCnt; uint16 child[MAX_CHILDREN]; } topo_node_t; static topo_node_t g_topoNodes[MAX_NODES]; static uint8 g_nodeCnt = 0;这个结构体把每个节点的短地址、父短地址、深度和子节点列表放在一起。childCnt用于记录有效子节点数量,后续括号拼接时只遍历到childCnt,避免把数组里的脏数据打印出来。MAX_DEPTH=5同时约束递归拼接深度,防止 Zstack 任务栈溢出。
这里的括号串生成规则很简单:从根节点 A 开始,输出节点名;如果它有子节点,就输出(,然后按顺序拼接所有子节点,子节点间用,分隔,最后输出)。
| 字符 | 含义 | 解析动作 |
|---|---|---|
A | 根节点短地址,通常映射为0x0000 | 建立根节点对象 |
( | 前一个节点有子节点 | 深度加 1 |
, | 兄弟节点分隔 | 回到同一父节点下 |
) | 当前子节点列表结束 | 深度减 1 |
实际工程里短地址是0x0000、0x0001这种十六进制数,不能直接打印成A、B。实验代码里通常会做一层「地址到单字母」的映射,简单办法是把短地址低 8 位加上'A'后输出。节点数不超过 26 个时,这种做法足够直观;超过之后要用A1、B2这类复合编号。
3. 实验环境搭建与烧录:从 zigbee 仿真器驱动到串口助手参数
很多同学卡在代码之外:zigbee 仿真器驱动安装不上,串口助手打开乱码,烧录成功但没有输出。这一章把我实际用过的流程写完整,硬件平台是 CC2530 ZigBee 节点模块系列实验平台,代码用 IAR for 8051 打开编译。stm32 开发中常见的 USB-TTL 在这里依然能用,但要注意 CC2530 串口电平是 3.3V,不能直接接 5V 的 RS232 电平。
3.1 硬件连接与最小系统确认
实验至少需要三块以上节点板才能看到树形拓扑,只烧两块最多看到A(B),撑不起A(B(E,F(I,J)),C(G,H))。我建议准备 9 块节点:1 个协调器、3 个路由器、5 个终端。实在没有这么多,也可以用 5 块节点,调整预期拓扑结构。
| 设备 | 数量 | 角色 | 观察点 |
|---|---|---|---|
| 协调器节点 | 1 | 建网、收集拓扑、串口输出 | 接 USB-TTL 到电脑 |
| 路由器节点 | 2~3 | 转发、允许终端入网 | 上电后 LED 快闪变慢闪 |
| 终端节点 | 4~5 | 入网并上报父地址 | 入网后向父节点发短帧 |
连接注意事项:节点模块上的 JTAG/SWD 仿真器接口与串口引脚不要同时占用同一个 USART。CC2530 的串口常用 P0_2(RX)和 P0_3(TX),对应 USART0 的备用功能。手头是新大陆 zigbee 模块时,原理图上的 TXD/RXD 丝印通常直接连到这两个引脚,接 USB-TTL 时交叉连接:模块 TXD 接 USB-TTL 的 RXD,模块 RXD 接 USB-TTL 的 TXD,并且要共地。
3.2 仿真器驱动安装与 IAR 工程配置
zigbee 仿真器驱动安装属于「完成一次就不想再碰」的环节。驱动装不上的常见原因有两个:一是 Windows 强制签名导致驱动被拦截,二是仿真器板载的固件版本和驱动不匹配。前者可以在高级启动选项里禁用驱动签名,后者需要按住仿真器上的复位键再插入 USB,让系统识别到 DFU 模式后重刷固件。
驱动正常后设备管理器里会出现对应的 COM 口,同时 IAR 工程里要确认如下配置:工程选项 -> General Options -> Target -> Device 选择Texas Instruments -> CC2530;Debugger -> Setup -> Driver 选择仿真器对应的选项,本实验平台常见的是TI SmartRF04EB或Texas Instruments XDS100。烧录时先给节点板上电,再在 IAR 里点 Download and Debug。如果提示连接失败,多半是仿真器线序接反,或者板子没上电。
提示:下载完成后不要直接停在调试状态,CC2530 要退出调试模式才能独立运行。按 F5 运行或者直接复位板子,串口才会有输出。
3.3 串口助手参数与乱码排查
串口助手建议用一个支持十六进制和 ASCII 切换的工具。实验代码里通常把串口初始化放在MT_UartInit或HAL_UART层,波特率常见是 115200。确认参数一致:
| 参数 | 值 | 说明 |
|---|---|---|
| 波特率 | 115200 | 与代码halUARTCfg.baudRate对应 |
| 数据位 | 8 | 标准 8N1 |
| 校验位 | None | 无校验 |
| 停止位 | 1 | 无流控 |
| 流控 | None | 若开启 RTS/CTS,普通 USB-TTL 无连线时会卡住 |
如果串口出现规律性乱码,先查波特率是否一致;波特率对但首字符乱码,查模块 TXD 与 USB-TTL RXD 是否虚接;整段都没有数据,先短接 USB-TTL 的 TX/RX 自测工具,再用示波器或万用表量模块 TXD 引脚在复位瞬间有没有电平变化。这个顺序能快速定位是 PC 侧、连线侧还是 CC2530 侧的问题。
3.4 验证建网成功的现象
烧录完成后,协调器节点的串口会在建网成功后打出类似Network formed的提示,部分实验平台还会打印PAN ID。如果只是在串口助手看到一堆协议栈调试信息而没有业务输出,检查工程里是否定义了ZTOOL_P1或MT_TASK,这些宏会把大量 Zigbee 协议栈帧通过串口发出来,干扰业务数据。
4. 串口输出 A(B(E,F(I,J)),C(G,H)) 的关键代码路径
现在进入核心。实验结果能否稳定打出目标字符串,取决于三条代码路径:终端入网上报、协调器维护拓扑表、递归拼接输出。这里按数据流从下往上拆。
4.1 终端入网后把父子地址发给协调器
终端设备上电后,在ZDO_StateChangeCB中判断入网成功,然后构造一帧数据发到协调器短地址0x0000。数据内容不需要复杂,一个字节表示自身短地址低 8 位,一个字节表示父节点短地址低 8 位。
void ZDO_StateChangeCB(uint8 devState) { afAddrType_t dstAddr; uint8 buf[2]; if (devState == DEV_END_DEVICE || devState == DEV_ROUTER) { buf[0] = NLME_GetShortAddr() & 0xFF; buf[1] = getParentShortAddr() & 0xFF; dstAddr.addrMode = afAddr16Bit; dstAddr.addr.shortAddr = 0x0000; dstAddr.endPoint = SAMPLEAPP_ENDPOINT; AF_DataRequest( &endpointDesc, &dstAddr, 1, // 簇 ID,实验里自行定义 2, // 数据长度 buf, &endpointDesc, AF_DISCV_ROUTE, 0x40 ); } }AF_DataRequest第一个参数是发送端端点描述符,第二个参数是目的地址,addrMode = afAddr16Bit表示用短地址寻址,endPoint由工程里注册的业务端点决定。AF_DISCV_ROUTE允许路由发现,0x40是超时时间。这样协调器收到帧后,就能从afIncomingMSGPacket_t结构体的srcAddr.addr.shortAddr拿到发送者地址,从payload[1]拿到它的父地址。
需要注意getParentShortAddr()在不同实验平台实现不同。Zstack 原版没有统一接口,常见实现是遍历邻居表nwk_neighbor_t,找到relationship == NEIGHBOR_PARENT的条目并把短地址返回。邻居表条目在入网完成后已经建立,所以在ZDO_StateChangeCB里调用是安全的。如果放在SampleApp_Init里调用,那时可能还没入网,读出来是0xFFFF。
4.2 协调器维护拓扑表
协调器在 AF 数据回调里把收到的节点信息写入g_topoNodes。插入时要处理回环:同一个短地址重复上报,只更新父地址;父地址不在表中时,递归创建父节点。否则串口打印出的树会缺中间层。
static uint8 addNode(uint16 addr, uint16 parent) { uint8 i; for (i = 0; i < g_nodeCnt; i++) { if (g_topoNodes[i].shortAddr == addr) { g_topoNodes[i].parentAddr = parent; return i; } } g_topoNodes[g_nodeCnt].shortAddr = addr; g_topoNodes[g_nodeCnt].parentAddr = parent; g_topoNodes[g_nodeCnt].depth = 0; g_topoNodes[g_nodeCnt].childCnt = 0; g_nodeCnt++; return g_nodeCnt - 1; }g_nodeCnt超过MAX_NODES时要返回错误,上位机侧也要做容量检查。这个函数只负责登记节点,不负责建立 child 数组,因为建立 child 数组需要遍历所有节点,把 parentAddr 等于当前地址的节点挂到当前节点的 child 里。两者分开写,逻辑才清晰。
建 child 数组的代码通常在每次上报后调用一次:
void rebuildChildren(void) { uint8 i, j; for (i = 0; i < g_nodeCnt; i++) { g_topoNodes[i].childCnt = 0; for (j = 0; j < g_nodeCnt; j++) { if (g_topoNodes[j].parentAddr == g_topoNodes[i].shortAddr) { if (g_topoNodes[i].childCnt < MAX_CHILDREN) { g_topoNodes[i].child[g_topoNodes[i].childCnt++] = g_topoNodes[j].shortAddr; } } } } }这段代码的时间复杂度是 O(n^2),节点数只有十几时完全够用。MAX_CHILDREN=8是一个谨慎的约束,实际实验里一个路由器挂 5~6 个终端已经比较极限,超过 8 个说明入网策略需要调整。
4.3 递归拼接括号串
打印A(B(E,F(I,J)),C(G,H))的本质是从根节点开始深度优先递归。CC2530 的调用栈非常小,递归层次被MAX_DEPTH约束后可以放心使用。
static void printNode(uint8 idx, char *buf, uint8 *len) { uint8 i, childIdx; buf[(*len)++] = 'A' + (g_topoNodes[idx].shortAddr & 0x0F); if (g_topoNodes[idx].childCnt > 0) { buf[(*len)++] = '('; for (i = 0; i < g_topoNodes[idx].childCnt; i++) { if (i > 0) { buf[(*len)++] = ','; } childIdx = findIndexByAddr(g_topoNodes[idx].child[i]); printNode(childIdx, buf, len); } buf[(*len)++] = ')'; } }buf长度建议分配 128 字节。A(B(E,F(I,J)),C(G,H))本身大约 21 字节,但节点全部挂满时会超过 100 字节,sprintf的缓冲太小会截断。findIndexByAddr是线性查找,返回节点在g_topoNodes中的下标。打印前先memset(buf, 0, 128),并确认(*len)从 0 开始,避免上一次残留数据混进去。
4.4 子节点排序:为什么不能按入网顺序打印
如果直接用入网顺序打印,第一次实验可能是A(B,C),断电重新上电后变成A(C,B)。这不是 bug,但会让评分和验收变得困难。常见做法是在rebuildChildren之后对每个节点的 child 数组做一次插入排序,按shortAddr升序排列。插入排序在小数组上比快速排序更稳定,代码也更短。
static void sortChildren(topo_node_t *node) { uint8 i, j; uint16 key; for (i = 1; i < node->childCnt; i++) { key = node->child[i]; j = i; while (j > 0 && node->child[j - 1] > key) { node->child[j] = node->child[j - 1]; j--; } node->child[j] = key; } }短地址由协议栈分配,入网顺序随机,但排序后拓扑结构只在节点父关系变化时才变化。实际调试中,这也能让你一眼看出某次实验少了一个节点:A(B(E,F(I,J)),C(G,H))里 F 缺了 I,打印会变成A(B(E,F(J)),C(G,H)),而不是乱序。
4.5 串口重定向与定时重打印
在 Zstack 里直接调用printf需要把putchar重定向到 UART。常见做法是在HAL_UART初始化后,重写标准库的putchar:
int putchar(int c) { HalUARTWrite(HAL_UART_PORT_0, (uint8 *)&c, 1); return c; }HalUARTWrite的第三个参数是写入长度,这里每次写一个字节,速率足够打印几十字节的拓扑串。如果程序里大量调用printf,建议改成组装完字符串后一次性HalUARTWrite,减少调用次数。
打印触发点一般有两个:一是处理入网上报后立即打印,二是用osal_start_timerEx启动一个 3 秒定时器,反复重打。第二种方式在验收演示时很实用,因为评委往往在你已经演示完后要求再观察一次,定时重打不用重新上电。定时重打时注意在打印前加一个if (g_nodeCnt > 0)空表保护,避免刚上电串口打出一堆空括号。
5. 把拓扑字符串变成自动化验证工具
5.1 用 Python 验证括号串合法性
手动核对A(B(E,F(I,J)),C(G,H))的括号匹配不是不行,节点一多就容易看漏。写一个递归下降解析器,既能验证括号合法性,又能输出深度和节点数。以下代码来自我平时处理串口日志用的小工具:
def parse(s): pos = 0 def node(): nonlocal pos name = s[pos] pos += 1 children = [] if pos < len(s) and s[pos] == '(': pos += 1 children.append(node()) while s[pos] == ',': pos += 1 children.append(node()) pos += 1 return (name, children) tree = node() assert pos == len(s) return tree def info(n): depth = 1 + max([info(c)[1] for c in n[1]], default=0) cnt = 1 + sum([info(c)[2] for c in n[1]]) return n[0], depth, cnt print(info(parse("A(B(E,F(I,J)),C(G,H))")))node()每次读一个字母作节点名,遇到(递归解析子节点,遇到)结束当前层。info()递归统计最大深度和节点总数,输出是('A', 4, 9),深度从根算起为 4,节点数为 9。如果期望拓扑是 3 层,那要看你怎么数层级,建议在实验报告里统一写清楚。
5.2 串口日志接入检查脚本
实际验收时,我一般让协调器定时重打拓扑串,并把串口助手的日志保存成 txt。注意日志里除了业务字符串,还可能有协议栈调试帧,不能整行直接喂给解析器。用re.findall(r'[A-Z][A-Z0-9]*(?:\([^)]*\))*', line)先把候选串过滤出来,再对每个候选做括号完整性检查。括号不完整的直接丢弃,完整的再交给parse()。
如果后续要给实验报告出图,还可以在解析后的树上递归生成 Graphviz dot 格式,每个节点名后带上短地址。这样做的好处是,串口打印不再是一段给人看的日志,而是结构化协议,重复实验和验收时都能自动核对拓扑是否和预期一致。
本文还有配套的精品资源,点击获取