简介:本资源是面向嵌入式开发工程师与高校电类专业学生的TMS320F28335 DSP实战项目,聚焦物联网通信场景下轻量级MQTT协议的C语言嵌入式实现。项目提供完整可编译源码,涵盖底层外设驱动(如EPWM、PieCtrl、SysCtrl)、中断向量配置、系统初始化及MQTT客户端核心逻辑,适用于工业控制、电机驱动等实时性要求较高的应用场景。压缩包共1078个文件,含63个C源文件、33个头文件(.h)、42个工程文件(.pjt)、37个链接映射文件(.map/.out)及大量汇编启动与外设配置文件(如DSP2833x_EPwm.asm、DSP2833x_PieVect.asm),总大小14.65MB,结构清晰,模块划分明确,便于分层学习与调试。已有70人下载学习,读者可直接导入CCS开发环境编译运行,深入理解DSP硬件资源调度、C语言在裸机环境下的内存管理与协议栈精简实现,是掌握嵌入式MQTT开发与F28335平台工程实践的优质参考范例。 做嵌入式这些年,手头有一块 DSP28335 开发板的情况很常见。传统上我们用 DSP 做电机控制、电力电子变换,数据采集大多是通过串口或者 CAN 总线上传给上位机。但这两年物联网和工业互联网的需求上来了,设备要上云、要远程监控、要数据可视化,这时候就有人会遇到一个尴尬的问题:DSP28335 这种老牌芯片,到底能不能跑 MQTT?
能,而且不需要外挂 Linux 主机,不需要换主控,用纯 C 语言就能把 MQTT 客户端跑起来。这个 dspdemo_28335 项目的核心,就是在一颗 TMS320F28335 上,通过 SPI 外接 W5500 以太网模块,用纯 C 语言实现了一个轻量级 MQTT 客户端源码。它的价值在于:既保留了 DSP28335 强大的实时控制能力,又让它具备了接入物联网平台的能力。整个过程不依赖任何操作系统,不依赖第三方 MQTT 库,报文构造、协议交互、状态机管理全是自己写的 C 代码,编译出来只有几 KB,对资源紧张的老平台非常友好。
这个 demo 适合三类人看:一是正在做设备智能化改造、想把手里的 DSP 板卡接入物联网平台的工程师;二是学嵌入式物联网方向、想深入理解 MQTT 协议底层的同学;三是做毕业设计或课程项目,需要"DSP + 无线通信 + 云平台"这个组合的在校生。无论你是哪种身份,这篇文章都会从方案选型讲到底层报文实现,再讲到联调排错,照着做就能复现出一个能跑通的上云 demo。
1. 整体设计与方案选型:为什么是 DSP28335 + W5500 + MQTT
1.1 DSP28335 上云,首先要解决"上网"的问题
TMS320F28335 是 TI C2000 系列里非常经典的一颗浮点 DSP,主频 150MHz,180MHz 峰值运算能力,片上集成 256KB Flash、34KB SRAM,外设包含 ADC、PWM、SCI、SPI、I2C、CAN、McBSP 等,在伺服驱动、变频器、UPS 电源、光伏逆变器等领域服役多年。但这颗芯片有一个天然短板:它没有以太网 MAC/PHY 外设,也没有 MMU 和操作系统支持,裸机环境下根本没法直接跑 TCP/IP 协议栈。
要在这种传统 MCU 上实现网络通信,行业里主要有三条路,各有取舍。
第一,外挂串口转 Wi-Fi 模块,比如 ESP8266 或者 ESP32,DSP 通过串口发 AT 指令,模块负责连接 Wi-Fi 和 TCP。这种方案最省事,DSP 端只需要处理字符串指令,缺点是串口波特率通常只有 115200bps,传小数据没问题,传大包或者高频数据就明显不够用,而且 ESP8266 这类模块在工业环境里的稳定性也让人心里没底。
第二,外挂 ENC28J60 以太网控制器,再在 DSP 上移植 uIP 或 lwIP 软件协议栈,把 TCP/IP 处理交给 CPU。这个方案的优点是器件的成本低,缺点是软件栈会吃掉不少 RAM 和 Flash,裸机调试的复杂度也上来了,TCP 重传、分片、校验这些都要自己维护,做产品迭代时很容易在协议栈上浪费大量时间。
第三,外挂 W5500 这种全硬件 TCP/IP 协议栈芯片。这是我最推荐的做法。W5500 内部集成了 TCP、UDP、IPv4、ICMP、ARP 协议,还自带 32KB 收发缓冲区,DSP 只需要通过 SPI 接口操作它的寄存器,就能完成建立连接、收发数据包这些操作。CPU 的负载极低,代码量也小,特别适合 DSP28335 这种以实时控制为主要任务的芯片。
1.2 为什么选 MQTT 而不是 HTTP 或 TCP 裸协议
设备能上网之后,接下来要考虑的就是应用层协议。很多人会惯性地想到 HTTP,但在嵌入式场景里,MQTT 的优势是碾压级别的。
HTTP 是请求-响应模式,设备要主动请求才能拿到数据,服务器想主动下发一条指令给设备,要么轮询,要么维持一条长连接,这两种做法在公网环境下都又慢又费流量。MQTT 是发布/订阅模式,设备和服务端解耦,任何一方都可以主动发消息,服务器能实时把消息推给设备,特别适合远程控制类应用。
再就是协议开销。HTTP 的请求头动辄几百字节,MQTT 控制报文典型只有 2 到 20 字节,对于 DSP 这种片内 Flash 和 RAM 都有限的平台非常友好。同时 MQTT 还支持 QoS 0/1/2 三级消息质量、会话保持、遗嘱消息,配合心跳保活机制,非常适合在不可靠的无线网络环境下做长连接。所以现在工业物联网、车联网、智能家居的上云方案,基本都是 MQTT 打底。
在 dspdemo_28335 这个项目的设计里,MQTT 是跑在 TCP 之上的,TCP 连接由 W5500 硬件维护。该路径上 DSP 要做的是:构造 MQTT 报文、解析收到的 MQTT 报文、维护连接状态机、定时发送心跳包。这四件事都是纯 C 逻辑,不涉及复杂的协议栈细节。
1.3 软件架构分层:把工程拆成四个模块
写嵌入式代码,一个很重要的习惯就是分层。这个 demo 虽然功能不多,但我在源码里也严格按分层的方式来组织,因为后面真要在上面加业务逻辑、加传感器、加其它通信接口时,分层的好坏直接决定了代码能不能继续维护。
我的建议是分成应用层(App)、MQTT 协议层(Mqtt)、硬件驱动层(BSP)、以及主程序四部分。
BSP 层负责屏蔽硬件差异,包括 bsp_spi.c(DSP 的 SPI 外设初始化与字节收发)、bsp_w5500.c(W5500 的寄存器读写、Socket 管理、数据收发封装)。MQTT 层负责协议逻辑,包括 mqtt_client.c 中的报文构造、报文解析、状态机管理、心跳维护。App 层是具体业务,比如周期读取 ADC 采样的数值、把数值格式化成 JSON 字符串、通过 MQTT 发布到云平台、收到控制指令后翻转 GPIO 等。main.c 只做初始化,然后不停调用 App 层的任务入口。
这样的分层有什么好处?我用一个很实际的场景说明:假如项目换了一颗 STM32F407,只要 BSP 层的接口是统一的,MQTT 层和 App 层几乎一行不用改,只需重新实现 SPI 和 W5500 的底层接口即可,迁移成本极低。反过来,如果今天想升级协议,比如从 MQTT 3.1.1 换成 MQTT 5.0,也只需要关注 MQTT 层,App 层不受影响。
项目文件的典型结构如下:
dspdemo_28335 ├── main.c ├── App/ │ ├── app_config.h // 服务器地址、端口、Topic 定义 │ ├── app_task.c // 周期任务、业务逻辑 │ └── app_task.h ├── BSP/ │ ├── bsp_spi.c // DSP SPI 底层驱动 │ ├── bsp_spi.h │ ├── bsp_w5500.c // W5500 底层驱动 │ └── bsp_w5500.h ├── MQTT/ │ ├── mqtt_client.c // MQTT 客户端实现 │ └── mqtt_client.h └── ForCCS/ ├── DSP2833x_Headers_nonBIOS.cmd └── 28335_RAM_lnk.cmdCCS 工程里把上面这些 .c 文件全部加入编译,Include Path 指向对应的头文件目录,就能直接编译通过。在 Code Composer Studio 中创建一个空工程,把文件拖进去,设置 C2000 编译器版本为 TI v22.6 或相近版本,工作模式选浮点运行支持,基本不需要额外配置。
2. MQTT 协议核心细节:C 语言实现前必须搞懂的底层原理
2.1 一图看懂 MQTT 报文的三段结构
很多人一开始接触 MQTT 源码会懵,因为在裸机嵌入式工程里,没有现成的库可以调用,你要直接面对原始的二进制报文。理解报文结构是第一步。
任何一个 MQTT 控制报文都由三部分组成:固定头(Fixed Header)、可变头(Variable Header)、载荷(Payload)。固定头是所有报文都有的,可变头和载荷则根据报文类型决定是否携带。
我习惯用快递包裹来类比:
- 固定头相当于快递单上的快递公司标志和运单号,它告诉接收方"这是一个什么类型的包裹";
- 可变头相当于收件人姓名、地址这些关键信息;
- 载荷就是包裹里实际装的物品。
固定头至少有两个字节。第一个字节的高四位是报文类型,其中 CONNECT 是 0x01、CONNACK 是 0x02、PUBLISH 是 0x03、SUBSCRIBE 是 0x08、SUBACK 是 0x09、PINGREQ 是 0x0C、PINGRESP 是 0x0D、DISCONNECT 是 0x0E。第一字节的低四位是标志位,不同类型报文含义不同。第二个字节是剩余长度(Remaining Length),它表示的是一整包报文去掉固定头之后还剩下多少字节。剩余长度本身是个变长编码,后面专门讲。
以最典型的 CONNECT 报文为例,它在可变头里需要携带协议名、协议级别、连接标志、Keep Alive 时间,在载荷里需要携带 ClientID、遗嘱主题、遗嘱消息、用户名、密码。整个报文的关键字节布局如下:
固定头: 0x10, Remaining Length 可变头: 0x00 0x04 'M' 'Q' 'T' 'T' // 协议名 MQTT 0x04 // 协议级别 4,即 MQTT 3.1.1 0x02 // 连接标志,Clean Session = 1 0x00 0x3C // Keep Alive = 60 秒 载荷: 0x00 0x0A 'D' 'S' 'P' '_' '2' '8' '3' '3' '5' // ClientID2.2 剩余长度字段:最容易写错的变长编码规则
MQTT 的剩余长度是 1 到 4 个字节的变长整数,每个字节有 7 位有效数据,最高位是延续标志。数值小于 128 时用一个字节表示;128 到 16383 时用两个字节;以此类推,最大支持 268435455 字节的报文。也就是说,DSP28335 上的 MQTT 报文,剩余长度几乎永远在 1 字节范围内,但编码逻辑仍然要按通用方式实现,因为如果主题名称长一点,或者发布了上百字节的 JSON 数据,就可能超过 127 字节,这时候两个字节的编码就会触发。
编码的规则用一个 C 函数来写非常直观:
int mqtt_encode_length(uint8_t *buf, uint32_t len) { int count = 0; do { uint8_t byte = len % 128; len /= 128; if (len > 0) { byte |= 0x80; } buf[count++] = byte; } while (len > 0 && count < 4); return count; }举个例子,如果剩余长度是 300,编码过程是:第一次取 300 % 128 = 44,len 变为 2,因为 len 仍大于 0 所以加 0x80 变成 0xAC;第二次取 2 % 128 = 2,len 变为 0,直接输出 2。最终结果是 0xAC 0x02。
解码就是反向操作,把每个字节的低 7 位按权重累加,再根据最高位判断有没有后续字节。这个地方最容易出问题的是忘记处理多字节场景,导致发的报文长度超过 127 字节后服务端解析就乱了。我个人建议在代码里保留完整的编解码函数,别做"只处理单字节"的投机优化,否则以后再扩展时还得回头改。
2.3 MQTT 客户端状态机必须包含的五个状态
MQTT 客户端不会一直在线,TCP 随时可能断开,所以代码里一定要有状态机。dspdemo_28335 里我定义了五个状态:
- MQTT_STATE_DISCONNECTED:TCP 尚未建立连接,需要尝试连接服务器;
- MQTT_STATE_CONNECTING:TCP 已建立,正在等待 CONNACK;
- MQTT_STATE_CONNECTED:连接正常,可以发布/订阅消息;
- MQTT_STATE_PENDING_RECONNECT:检测到断线,进入退避重连;
- MQTT_STATE_DISCONNECTING:主动断开。
主循环里每次进入循环,先检查当前状态,然后根据状态执行对应的操作。比如在 CONNECTED 状态下,需要定时检查是否到达发送 PINGREQ 的周期;在 CONNECTING 状态下,需要读取 W5500 收到的数据,解析是不是 CONNACK 报文。
状态机这东西在这个 demo 里的意义,和你在 RTOS 里写任务调度是一样的,它把"什么时候该干什么事"固定下来,避免代码里到处是 if-else 乱跳。而且状态机写好了,后续想加 MQTT 5.0 的属性、加遗嘱消息流程,都只是往这个框架里加分支。
2.4 字节序陷阱:C2000 与网络字节序的差异
DSP28335 的 CPU 是小端模式,而 MQTT 协议里的所有多字节整数,比如端口号、Keep Alive、主题长度,都是网络字节序,也就是大端模式。这个差异在直接往缓冲区里写数值时特别容易踩坑。
举个例子,如果直接写buf[2] = (uint8_t)(keep_alive >> 8); buf[3] = (uint8_t)(keep_alive & 0xFF);,那结果是对的,因为手动做了高字节在前。但如果有人图省事,写*(uint16_t *)&buf[2] = keep_alive;,在 DSP 上就完全错了,因为 DSP 会按小端方式把低字节放在低地址,和网络字节序正好相反。
所以在这个项目里,我统一用位移和按位或来构造报文,绝不直接做指针类型强转。比如构造 CONNECT 报文源码片段:
uint16_t keep_alive = 60; mqtt_pkt[8] = 0x00; // Keep Alive 高字节 mqtt_pkt[9] = (uint8_t)keep_alive; // Keep Alive 低字节这里的思路是一字节一字节拼,避免依赖任何平台的字节序特性。这个方法虽然看着啰嗦,但在跨平台移植时是绝对安全的,值得养成习惯。
3. 实操过程:从零搭建 dspdemo_28335 并跑通数据上报
3.1 硬件环境与接线
动手之前先把硬件备齐:
- DSP28335 开发板一块,常见型号比如 TL28335-EVM 或研旭、普中、天祥的板子都可以,核心是芯片是 TMS320F28335CET6 或更早的 F28335ZJZ;
- W5500 以太网模块一个,某宝搜"W5500 模块"就行,模块上自带网络变压器和 RJ45 座;
- USB 转串口模块一个,用来接 DSP 的 SCI 接口,打印调试日志;
- 网线一根,和电脑/路由器相连;
- XDS100V2 或 XDS100V3 仿真器一个,用于 CCS 烧写调试(也可以用串口烧写,但 CCS 调试更方便)。
W5500 与 DSP28335 的接线要特别注意,二者引脚不要乱接。DSP28335 的 SPI-A 引脚定义为:GPIO16 是 SPISIMOA(主机发送),GPIO17 是 SPISOMIA(主机接收),GPIO18 是 SPICLKA(时钟),GPIO19 是 SPISTEA(片选,但我们一般不用硬件片选,而用普通 GPIO 控制 W5500 的 SCSn 引脚)。W5500 模块的引脚分别是 SCSn、SCLK、MOSI、MISO、RST、INT、GND、VCC,对应关系如下:
| W5500 引脚 | DSP28335 引脚 | 说明 |
|---|---|---|
| SCLK | GPIO18(SPICLKA) | SPI 时钟 |
| MOSI | GPIO16(SPISIMOA) | 主机发送从机接收 |
| MISO | GPIO17(SPISOMIA) | 主机接收从机发送 |
| SCSn | GPIO19(或者任意普通 GPIO) | 片选,低有效 |
| RST | GPIO20(任意 GPIO) | 复位控制 |
| INT | GPIO21(任意 GPIO,可选) | 中断引脚,用于快速感知数据到达,demo 里可以用轮询替代 |
| GND | GND | 共地不能少 |
| VCC | 3.3V | W5500 核心是 3.3V 供电 |
如果是比较老的开发板,注意 DSP 的 SPI 引脚电压和 W5500 的 I/O 电压都是 3.3V,是直接兼容的,不需要电平转换。如果用的不是标准 3.3V 逻辑的模块,就需要确认一下模块规格。
3.2 SPI 初始化:波特率计算与模式选择
DSP28335 的 SPI 模块配置并不复杂,重点是三样东西:字长、时钟速率、时钟极性。
W5500 的 SPI 从机工作在 SPI Mode 0 或 Mode 3,也就是 CPOL=0、CPHA=0 或 CPOL=1、CPHA=1,两种模式都可以。dspdemo 里我选择 Mode 0。SPI 的字符长度要配成 8 位,因为 W5500 是按字节读写的,DSP28335 的 SPI 默认支持 16 位字长,在寄存器 SPICCR 的 SPICHAR 字段设为 7 就是 8 位模式。
波特率计算方面,DSP28335 的 LSPCLK 默认是系统时钟的 1/4,系统时钟 150MHz,那 LSPCLK = 37.5MHz。SPI 波特率公式是:SPI Baud Rate = LSPCLK / (SPIBRR + 1)。如果 SPIBRR 取 74,波特率就是 500Kbps,比较保守,非常稳妥。实践下来,W5500 的 SPI 完全支持几兆赫兹,所以可以把 SPIBRR 调小,比如 9,波特率 3.75MHz,数据吞吐量提升明显。不过要注意每家的模块 PCB 布线质量不同,保守起见建议先从 1MHz 左右开始调试,跑通了再提速。
SPI 初始化代码核心片段:
void bsp_spi_init(void) { InitSpiaGpio(); // 复用 GPIO 16-19 为 SPI-A 功能 SpiaRegs.SPICCR.bit.SPISWRESET = 0; // 先复位 SpiaRegs.SPICCR.bit.SPICHAR = 0x07; // 8 位字符 SpiaRegs.SPICCR.bit.CLKPOLARITY = 0; // 时钟极性低电平 SpiaRegs.SPICTL.bit.MASTER_SLAVE = 1; // 主机模式 SpiaRegs.SPICTL.bit.CLK_PHASE = 0; // 时钟相位 0 SpiaRegs.SPIBRR = 9; // 波特率 = 37.5MHz / 10 = 3.75MHz SpiaRegs.SPICTL.bit.SPIINTENA = 1; SpiaRegs.SPICCR.bit.SPISWRESET = 1; // 退出复位 }初始化完成后,读写一个字节的底层函数可以用一个函数搞定,因为 SPI 是全双工,每发一个字节同时收一个字节,W5500 要求所有读操作先发送目标地址和读标志,再继续发送字节来获取数据。实现如下:
uint8_t bsp_spi_xfer(uint8_t byte) { SpiaRegs.SPITXBUF = byte; while (SpiaRegs.SPISTS.bit.INT_FLAG == 0) { } return SpiaRegs.SPIRXBUF; }3.3 W5500 初始化与 Socket 管理
W5500 的寄存器空间分为通用寄存器和 Socket 寄存器两大部分,通过 SPI 帧的偏移地址寻址。初始化流程照这个顺序做:
先拉低 RST 引脚至少 500us 再拉高,等待 150ms 让芯片完成上电复位。然后通过通用寄存器设置基础网络参数:网关地址、子网掩码、MAC 地址、本机 IP 地址。核心函数如下:
void w5500_net_config(uint8_t *mac, uint8_t *ip, uint8_t *gw, uint8_t *mask) { w5500_write_reg(0x0009, mac, 6); // SHAR 源 MAC 地址 w5500_write_reg(0x0001, gw, 4); // GAR 网关地址 w5500_write_reg(0x0005, mask, 4); // SUBR 子网掩码 w5500_write_reg(0x000F, ip, 4); // SIPR 本机 IP }然后初始化 Socket 0。以 TCP 客户端模式为例,先关闭 socket,写远程 IP 和远程端口,设置协议模式为 TCP,最后发送命令打开 Socket。W5500 的相应寄存器偏移地址可以参考数据手册,这里不展开每个地址,重点说清楚流程顺序(关闭 -> 设地址/端口 -> 设模式 -> 打开)。初始化完成后,轮询 Sn_SR 寄存器判断是否建立了 TCP 连接,如果 Sn_SR 变成 0x17(SOCK_ESTABLISHED),就说明 TCP 连接建立成功。
W5500 的数据收发要特别注意一个坑:发送数据前要把数据写到 Socket 的发送缓冲区,然后写 Sn_TX_WRSR 指定要发送的字节数,再触发 SEND 命令,硬件才会帮用户发包。接收则要先查询 Sn_RX_RSR 寄存器拿到接收字节数,然后读出数据,读完以后必须向 Sn_CR 写入 RECV 命令释放缓冲区,否则下次收不到数据。
这里有一个容易让人困惑的点:W5500 的发送缓冲区和接收缓冲区各 16KB,分配在 socket 0/1 各一个。对于 demo 只需要 socket 0,所以在初始化里把 socket 0 的 RX Buffer 配 8KB、TX Buffer 配 8KB 就够了。如果后面要多路 TCP 连接,就得按比例调整。
3.4 MQTT 客户端代码实现:核心函数的写法要点
MQTT 层的代码是整个 demo 的灵魂。我把核心功能拆成几个函数:构造 CONNECT 报文并发送、构造 PUBLISH 报文并发送、解析 CONNACK、处理订阅保持、发送心跳 PINGREQ、处理收到的 PUBLISH/SUBACK。
先看构造 CONNECT 报文。因为 Clean Session 设置为 1,没有遗嘱消息、没有用户名密码,所以报文比较简单。核心代码如下:
uint16_t mqtt_build_connect(uint8_t *buf, const char *client_id) { uint16_t len = 0; uint16_t id_len = strlen(client_id); // 固定头 buf[len++] = 0x10; // 剩余长度暂时占位,后面计算 uint8_t *rl_ptr = &buf[len]; len += 1; // 可变头 buf[len++] = 0x00; buf[len++] = 0x04; buf[len++] = 'M'; buf[len++] = 'Q'; buf[len++] = 'T'; buf[len++] = 'T'; buf[len++] = 0x04; // 协议级别 buf[len++] = 0x02; // Clean Session = 1 buf[len++] = 0x00; buf[len++] = 0x3C; // Keep Alive = 60s // 载荷:ClientID buf[len++] = (uint8_t)(id_len >> 8); buf[len++] = (uint8_t)(id_len & 0xFF); memcpy(&buf[len], client_id, id_len); len += id_len; // 回填剩余长度 int rl = len - (rl_ptr - buf) - 1; *rl_ptr = (uint8_t)rl; return len; }这段代码有几个容易写错的地方。一是剩余长度必须在报文完全构造完以后回填,所以先留出占位,最后再补,这个顺序错了整包数据长度就算错了。二是 ClientID 长度字段必须是大端两字节,好多初学者用一个字节表示 ClientID 长度,短的时候没问题,一长就出错。三是在 MQTT 3.1.1 协议里,连接标志的低位(bit0)必须是 0,Clean Session 是 bit1,如果配置了用户名/密码还有对应的 bit 位,千万别把标志位写错。
构造 PUBLISH 报文(QoS 0)相对简单,因为 QoS 0 没有报文标识符,固定头是 0x30,后面接主题长度、主题名、消息体:
uint16_t mqtt_build_publish(uint8_t *buf, const char *topic, const char *payload) { uint16_t len = 0; uint16_t topic_len = strlen(topic); uint16_t payload_len = strlen(payload); buf[len++] = 0x30; // PUBLISH, QoS 0 uint8_t *rl_ptr = &buf[len]; len += 1; buf[len++] = (uint8_t)(topic_len >> 8); buf[len++] = (uint8_t)(topic_len & 0xFF); memcpy(&buf[len], topic, topic_len); len += topic_len; memcpy(&buf[len], payload, payload_len); len += payload_len; *rl_ptr = (uint8_t)(len - (rl_ptr - buf) - 1); return len; }如果要用 QoS 1,固定头是多少呢?0x32,然后要在主题后面加上两字节报文标识符,同时需要处理 PUBACK 回执。
3.5 主循环逻辑:连接、发布、心跳、订阅全流程
主循环是裸机工程的调度核心。dspdemo_28335 的 main.c 里通常这么组织:
void main(void) { InitSysCtrl(); InitGpio(); bsp_spi_init(); bsp_uart_init(115200); w5500_hard_reset(); w5500_init(); mqtt_client_init(); while (1) { mqtt_task(); // 状态机、心跳、报文处理 app_task(); // 业务任务:周期发布 ADC 采样、处理订阅消息 delay_ms(10); } }mqtt_task 内部会根据状态机的状态执行不同的动作。初始状态是 DISCONNECTED,每 2 秒尝试建立 TCP 连接;TCP 建立成功后,发送 CONNECT 报文,进入 CONNECTING 状态;收到 CONNACK 后,进入 CONNECTED 状态,同时主动订阅控制主题;在 CONNECTED 状态下,每 10 秒发送一条 PUBLISH 消息,每 30 秒发送一次 PINGREQ 心跳。
app_task 里则调用 ADC 模块读取一个模拟量通道的值,比如用芯片内部温度传感器或者外部电位器分压,把读到的 12 位 ADC 值格式化成 JSON 字符串:
sprintf(payload, "{\"adc\": %d}", adc_value); mqtt_publish("device/28335/adc", payload);3.6 和 Broker 联调:用本地 mosquitto 完整跑通
联调这一步非常关键,因为 DSP 板子上的日志不直观,先在电脑上把 MQTT Broker 跑起来,两台设备同时看数据,能快速定位问题。
我常用的方式是本地装一个 mosquitto。Ubuntu 下安装很简单:apt install mosquitto mosquitto-clients。启动后默认监听 1883 端口。也可以用 Windows 版的 mosquitto 或者用 Docker 跑 Eclipse Mosquitto 镜像,命令是docker run -d -p 1883:1883 eclipse-mosquitto。
启动 Broker 后,在另一个终端窗口订阅 DSP 的数据主题:
mosquitto_sub -t device/28335/adc -v然后把 DSP 开发板上电,连接目标网络,等待几秒,可以看到 mosquitto_sub 里不断打印出device/28335/adc {"adc": 2048}这类输出。同时 DSP 的串口调试助手会打印如下日志:
[APP] MQTT broker connecting... [APP] TCP connected. [APP] MQTT CONNECT sent. [APP] CONNACK received, rc=0 [APP] MQTT connected. [APP] Subscribe OK. [APP] Published: {"adc": 2048} [APP] PINGREQ sent.看到 CONNACK received rc=0 就说明 MQTT 连接已经建立,rc=0 是接受连接的意思。之后发布的 JSON 消息如果能在 mosquitto_sub 里看到,就说明整条链路完全打通了。
再测试下行控制,也就是订阅方向。在电脑上执行:
mosquitto_pub -t device/28335/led -m "ON"如果 DSP 这边订阅了device/28335/led主题,主程序里解析到 "ON" 字符串后翻转 GPIO 对应的 LED 灯,逻辑就完整了。
4. 常见问题与排查技巧实录
4.1 SPI 通信不稳定,W5500 读回的寄存器全是 0 或 0xFF
这是最常见的硬件问题。先从这几点排查:检查 SPI 时钟极性和相位是否匹配 W5500 的模式 0;检查 SPI 波特率是否过高,先调到 1Mbps 以下;确认 CS 引脚拉低时序正确;确认 RST 引脚已经正确复位。
我踩过的最大坑是 DSP28335 的 SPI 字符长度配置。它的 SPI 默认是 16 位字长,若没有配成 8 位,DSP 发一个字节出去,W5500 会强行按 8 位解析,结果全部乱掉。另外,读操作时 W5500 有专门的帧格式:第一个字节是地址高 8 位,第二个字节是地址低 8 位和读控制位,第三个字节开始才是返回的数据。如果 SPI 底层函数没处理好这三个阶段的对齐,就会丢第一个字节。
4.2 TCP 能建立连接,但一直收不到 CONNACK
TCP 连接建立成功说明 W5500 的上网链路没问题,问题大概率在 MQTT 报文构造。首先检查 CONNECT 报文长度是不是算错了。可以用电脑上的 MQTT 工具(比如 MQTTX 或者 Wireshark)抓包对比,看标准 MQTT 客户端的 CONNECT 报文和 DSP 发的报文差异在哪里。
最容易错的是剩余长度没回填正确。有些代码在构造时用临时变量算了长度,但两个长度变量混用,导致报文实际长度和报文头声明的不一致。其次,协议级别填 0x04,如果填成 0x03 那就变成 MQTT 3.1 了,很多 Broker 默认不支持或者返回 0x01。
有个快速验证法:把同一个 CONNECT 报文数据用电脑上的 Python 脚本通过 socket 发到 broker,跟 DSP 发出去的报文做对比。如果 Python 能连接成功而 DSP 不行,基本就锁定为 DSP 代码构造的问题了。
4.3 连接成功后立即断开,客户端 ID 冲突
MQTT Broker 默认要求同一时刻同一个 ClientID 只能有一个连接。如果你的 broker 里已经有一个相同 ClientID 的客户端在线,比如之前调试时残留的会话,那么后来者会把前一个踢下线,或者后一个连接被拒绝。这类问题表现为主程序日志显示连接了几秒钟,然后 broker 端日志出现 "Client XXX already connected" 之类的字符串。
解决方法是每次上电时生成一个不同后缀的 ClientID,或者强制把 Clean Session 设为 1,并保证调试时串口工具上只有一个客户端连接。真正开发时,一个设备一个 ClientID,并且把 ClientID 固化到 Flash 里,重启不掉。
4.4 发布消息正常,但订阅主题收不到任何消息
订阅消息收不到有三个常见原因。第一,订阅报文没有构造正确。SUBSCRIBE 固定头的第一字节是 0x82,其中低两位的 0x02 表示 QoS 1,如果写成 0x80 就成了保留标志位错误,broker 直接断开。第二,订阅的主题过滤器和发布端用的主题不一致,MQTT 对主题是大小写敏感的,Device/28335/ADC和device/28335/adc完全不同。第三,如果 broker 配置了权限管理,比如用户名密码、ACL 规则,那还要检查是否有权订阅这个主题。公共 broker 上一般没这么严,但企业里部署的 broker 经常有主题前缀权限限制。
排查方法还是先看 DSP 串口日志里有没有收到 SUBACK,如果收到 SUBACK 且返回码是 0x00,说明 broker 已经接受了订阅申请;如果根本没有 SUBACK,那问题就出在订阅报文本身或 broker 权限配置。
4.5 连接后过一段时间自动掉线,心跳保活没做好
MQTT 的 Keep Alive 机制要求客户端在规定的间隔内发送 PINGREQ,broker 如果在 1.5 倍时间内没有收到任何包,就会判定客户端失联,主动断开连接。许多初次移植的人把心跳周期写成了和 Keep Alive 一样,偶尔网络抖动一下就会超时。稳妥的做法是让心跳周期保持在 Keep Alive 的三分之一到三分之二之间,比如 Keep Alive 设 60 秒,PINGREQ 每 20 秒发一次。
另外 W5500 的 TCP 连接是硬件状态机维护的,但硬件不会主动处理应用层超时,它在长时间没有数据收发时依然认为 TCP 连接是活着的。所以应用层的 MQTT 心跳是必须的,它既是给 broker 看的,也是给自己确认链路用的。如果发现程序主循环卡在某个死循环,PINGREQ 发不出去,也会出现掉线,这时候要检查代码里有没有阻塞时间过长的操作。
4.6 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| SPI 读回全 0 | SPI 波特率过高 / 字符长度没配成 8 位 / 接线错误 | 降低波特率,检查 GPIO 复用配置 |
| SPI 读回全 0xFF | 片选信号没拉低 | 确认 SCSn 控制 GPIO 逻辑 |
| 无法建立 TCP 连接 | IP 地址冲突 / 网关错误 / 网线不通 | ping 模块 IP,检查网卡链路 |
| 收到 CONNACK 返回码 0x01 | MQTT 协议级别不对 | 确认协议级别字段 = 4 |
| 收到 CONNACK 返回码 0x02 | ClientID 非法 | 检查 ClientID 长度与字符 |
| 收到 CONNACK 返回码 0x04/0x05 | 用户名密码不合法 | 检查连接标志与用户名密码字段 |
| 订阅后收不到消息 | 主题不匹配 / 权限限制 | 抓包对比 topic 字符串 |
| 过一段时间自动掉线 | 心跳周期太长 / 软件阻塞 | 缩短心跳周期,优化主循环 |
5. 扩展思考:这个 demo 还能往上加什么
dspdemo_28335 目前做到的是 DSP 作为 MQTT 客户端,主动上报数据、被动接收控制指令。如果要产品化,还有几个方向值得继续扩展。
第一是数据安全。当前 demo 的 MQTT 是明文传输,MQTT 3.1.1 本身没有加密,需要靠 TLS 来保护。但 W5500 没有硬件 TLS 引擎,DSP28335 跑软件 TLS 性能会比较吃紧。实用上可以考虑走 MQTT over DTLS,或者用代理网关做协议转换。如果是局域网应用,也可以考虑对敏感数据做应用层加密,比如在 payload 里加 HMAC 签名。
第二是协议栈的多路复用。W5500 支持 8 个 Socket,你可以让 Socket 0 跑 MQTT,Socket 1 跑 Modbus TCP,Socket 2 跑 WebSocket 之类,做一个协议转换网关。DSP28335 的 150MHz 主频处理这些数据转发绰绰有余。
第三是本地逻辑。目前报文里传的是原始 ADC 值,实际项目里应该把数据先做滤波、阈值判断、单位换算,再格式化上云。控制指令也不应该直接翻转 GPIO,而应该经过安全校验、状态判断和故障保护逻辑。做嵌入式产品,"数据上云"只是第一步,云端的展示、报警、统计模型才是增值部分,而这部分需要和业务方反复对齐。
第四是固件升级。设备联上网之后,OTA 升级是一个几乎绕不开的需求。你可以用 MQTT 收到升级指令,通过消息分片接收新固件,把固件写入外部 SPI Flash,校验后跳转到引导程序启动。这个方向技术要求高,但商业价值很大。从 dspdemo 的架构上往这个方向演进是自然的,只要保证 W5500 的驱动和 MQTT 层与 Flash 驱动解耦即可。
6. 踩坑后的个人体会
最后从代码和调试两个维度倒倒苦水,也分享一些基础经验。
第一,用纯 C 实现 MQTT 并不难,难的是把协议细节做对。最初版本里我的 PINGREQ 报文少写了一个固定头字节,结果 Broker 端完全没反应,DSP 这边的日志也看不到报错。后来逐步在电脑上抓包,才发现是剩余长度没写。所以如果你也打算从零写协议,手里备一个抓包工具非常管用。Wireshark 能直接解析 MQTT 协议,把报文一条条展开对比,定位问题比盲猜快十倍。
第二,裸机工程的调试信息必须做好。这个 demo 里我给每个 MQTT 事件都加了串口日志,包括收发的报文类型、关键字段的值。日志确实是嵌入式开发最重要的辅助工具,没有日志时出了问题只能靠示波器慢慢量引脚,有了日志后很多协议层问题一眼就能看出来。
第三,裸机主循环和 W5500 中断的组合需要平衡。在实际产品里,如果 MQTT 数据量很大,用轮询方式读 W5500 的接收缓冲区会占不少 CPU 时间。我后来在这个 demo 基础上加了 INT 引脚中断,让 W5500 在收到数据时拉低 GPIO21 触发 DSP 的外部中断,DSP 在中断里读取数据,主循环只做业务逻辑。这样 CPU 占用率能降很多,也更接近工业级产品的做法。不过中断处理函数里尽量不要做耗时操作,把数据拷贝到缓冲区后就立刻退出中断,协议解析放到主循环。
如果你也想把手里的 DSP28335 甚至其它老 MCU 接上物联网,不用被"老芯片没有网络能力"这句话框住,只要有一颗能跑 SPI 的 MCU、一个 W5500 模块、加上一份有耐心的 C 代码,上云这扇门就打开了。从零写一遍 MQTT 报文之后,你再去用各种封装好的 SDK,理解深度完全不一样。这个基本功,值得花时间练。
本文还有配套的精品资源,点击获取