☰
STM32F103+W5500硬件协议栈实现TCP通信例程详解
2026/10/19 19:20:45 网站建设 项目流程

简介:面向嵌入式开发者的 STM32F103 + W5500 TCP 通信全套例程,实现单片机通过 SPI 驱动 W5500 完成以太网数据传输,适合物联网设备联网、工业数据采集及网络通信入门学习。压缩包共 183 个文件、3.48MB,以 45 个 h 头文件与 43 个 c 源文件为代码主体,同时包含 Keil 工程配置、编译中间文件以及可直接烧录的 hex、axf 镜像,整体结构完整清晰。已有 371 人学习下载,例程覆盖从 SPI 初始化、W5500 寄存器配置到网络参数设定、TCP 客户端/服务器建链与收发数据完整链路,并附有工程清理脚本与调试信息文件,便于二次开发与排错。对于希望理解硬件 TCP/IP 协议栈应用、快速搭建嵌入式网络通信原型的开发者,这套例程提供了开箱即用的参考实现。

1. 这不只是例程,是给 MCU 省掉一整个协议栈

很多人一听 STM32 要联网,第一反应是去移植 lwIP 或者 uIP,结果 Flash 和 RAM 被协议栈吃掉一大块,调试时还分不清是网络问题还是内存问题。而用 STM32F103 + W5500 这个组合,思路完全反过来:TCP/IP 协议栈被做进了 W5500 这颗芯片里,MCU 只负责通过 SPI 读写寄存器、收发数据。这个例程解决的就是「不想跑协议栈,又要稳定 TCP 通信」的场景——M3 内核芯片有了以太网能力,CPU 占用率还很低。

它适合两类人:一类是做工业采集、设备联网、传感器网关的嵌入式开发,想快速把数据送到上位机或服务器;另一类是刚接触网络编程的单片机开发者,想通过例程理解 Socket 是怎么在硬件上落地的。下面我从硬件连接开始,把整个工程的代码链路拆开讲。

2. 硬件架构与工程结构:一颗芯片把网络分包全包了

2.1 W5500 到底帮 MCU 干了什么活

先明确分工。STM32F103 的 Cortex-M3 内核频率最高 72MHz,如果不跑 RTOS、不跑协议栈,它的算力几乎可以全部交给业务逻辑。W5500 内部集成了硬件 TCP/IP 协议栈,支持 TCP、UDP、ICMP、IPv4、ARP、IGMP 等,链路层和网络层的处理全在芯片内部完成,MCU 不需要关心报文是怎么封装、怎么校验的。

它对外暴露的接口就是一个 32 位地址映射的寄存器空间,通过 SPI 访问。MCU 要做的只有三件事:往寄存器里写入目标 IP 和端口、发起连接命令、然后把业务数据通过 Socket 缓冲区发出去。接收方向也一样,W5500 把收到的数据放进内部 RX 缓冲区,MCU 定期查询或者靠中断通知,再读出来。

和软件协议栈对比,差别很明显:写 lwIP 要考虑内存池、pbuf、超时重传,调试时还要开着抓包工具;用 W5500 时这些全部下沉到硬件,应用层代码几乎就是在操作一个带状态机的网卡。代价是 W5500 比裸的 PHY 芯片贵,而且不支持跑在它上面的自定义协议——你要自己实现 HTTP 的话,还是得在 MCU 里组报文。

表:两种联网方案对比

方案 | MCU 资源占用 | 协议栈维护成本 | 适用场景 lwIP + PHY | 高,需 30-50KB RAM | 高,需调试协议细节 | 复杂应用、多协议 W5500 + SPI | 低,只占用 Socket 缓冲 | 低,硬件自动处理 | 工业传输、数据采集

2.2 STM32F103 与 W5500 的硬件连接

例程用的是 SPI1,STM32F103 的 SPI1 最高可以跑到 18MHz(APB2 时钟 72MHz 分频),W5500 的 SPI 时钟最高支持 33.3MHz,所以瓶颈在 MCU 这边。默认引脚分配如下:

引脚 | STM32F103 | 说明 SCK | PA5 | SPI1 时钟 MISO | PA6 | W5500 数据输出 MOSI | PA7 | W5500 数据输入 CS | PA4 | 软件控制片选,注意是低有效 RST | PA3 | 硬件复位,低电平复位 INT | PA2 | 中断输出,低电平有效,可选

用 GPIO 模拟片选而不是硬件 NSS,好处是片选的时机可以自己控制。W5500 的 SPI 每次访问最少 3 字节:第一个字节是地址偏移的高位,第二个字节是地址低位,第三个字节是数据(读的话这个字节是 dummy)。CS 在整个访问期间必须保持低电平,所以在读写函数里要手动拉低、拉高。

另外注意 W5500 的 RST 引脚在低电平保持至少 500us 才能完成复位,且复位后要等 10ms 左右让内部 PHY 稳定,否则后面初始化寄存器可能读到随机值。这个时序在代码初始化函数里能看到。

2.3 例程目录结构与代码分层

例程的代码分成三层:底层是 SPI 驱动和寄存器读写,中间是 W5500 驱动,上层是 TCP 应用逻辑。工程目录大致这样:

project_root/ ├── Hardware/ │ ├── w5500_spi.c // SPI 初始化与读写字节 │ ├── w5500_reg.c // 寄存器读写、块区寻址 │ └── w5500_socket.c // Socket API 封装 ├── User/ │ ├── main.c // 主循环,连接和收发 │ └── tcp_client.c // TCP 客户端逻辑 ├── MDK-ARM/ // Keil 工程文件 └── Doc/ └── 接线说明.md

这个分层的核心思想:底层驱动的函数只做一件事——给定地址和数据,完成一次 SPI 传输;中间层负责把 W5500 的寄存器访问封装成易读的接口;上层应用只调用 socket、connect、send、recv,不直接碰寄存器。二次开发时,你大概率只需要改 User 目录下的两个文件,底层的驱动基本不需要动。

3. 核心驱动拆解:SPI 读写和 32 位地址寻址

3.1 SPI 底层读写函数

// w5500_spi.c 部分代码 void W5500_SPI_Init(void) { SPI_InitTypeDef SPI_InitStructure; // 使能 SPI1 和 GPIOA 时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // SCK/MOSI 复用推挽 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; // MISO 浮空输入 GPIO_Init(GPIOA, &GPIO_InitStructure); // CS 和 RST 配置为推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_Init(GPIOA, &GPIO_InitStructure); SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex; SPI_InitStructure.SPI_Mode = SPI_Mode_Master; SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b; SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; // 时钟空闲低 SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; // 第一个边沿采样 SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; // 软件管理 NSS SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_4; // 18MHz SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; SPI_Init(SPI1, &SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); }

这段初始化有几个关键点。第一个是 SPI 模式必须设置成 CPOL = Low、CPHA = 1Edge,也就是 SPI Mode 0,W5500 只支持这个模式,配错了读出来全是 0xFF。第二个是分频系数选 4,72MHz / 4 = 18MHz,这是 SPI1 的稳定上限,再快可能有信号完整性问题。第三个是 NSS 一定用软件管理,这样片选完全由你自己控制,避免硬件 NSS 自动拉低导致时序错乱。

初始化完成之后,底层只需要两个函数:一个是发一个字节同时收一个字节。SPI 是全双工的,所以读操作也必须先发一个 dummy 字节来产生时钟。基本的字节交换函数如下:

// 发送一个字节并读取一个字节 uint8_t W5500_SPI_ExchangeByte(uint8_t byte) { while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); SPI_I2S_SendData(SPI1, byte); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET); return SPI_I2S_ReceiveData(SPI1); }

这个函数是所有上层操作的基础。读寄存器时,先用它发送地址帧,再发 dummy 字节取回数据;写寄存器时,发送地址帧后再发数据。注意每发一个字节都要等 TXE 标志,否则数据会丢。如果你在调试时发现读回来全是 0,先查这里是不是没有等待标志位。

3.2 W5500 寄存器访问与块区寻址

W5500 的寄存器地址是 32 位宽,由三部分组成:偏移地址(16 位)、块选择(3 位)、读写位(1 位)。其中块选择用来区分访问的是通用寄存器(Common Register)、Socket 寄存器还是 TX/RX Buffer。它的构成是这样的:位 15 到位 0 是偏移地址,位 18 到 16 是块选择,最高位是读写标志。

驱动里封装了三个函数来屏蔽这些细节:

// w5500_reg.c 部分代码 void W5500_WriteReg(uint16_t addr, uint8_t block, uint8_t data) { uint8_t addr_high = (addr >> 8) & 0xFF; uint8_t addr_low = addr & 0xFF; uint8_t block_sel = (block << 3) | 0x04; // 写标志 bit3 为 1 W5500_CS_LOW(); W5500_SPI_ExchangeByte(addr_high); // 地址高字节 W5500_SPI_ExchangeByte(addr_low); // 地址低字节 W5500_SPI_ExchangeByte(block_sel); // 块选择 + 写标志 W5500_SPI_ExchangeByte(data); // 写入数据 W5500_CS_HIGH(); } uint8_t W5500_ReadReg(uint16_t addr, uint8_t block) { uint8_t addr_high = (addr >> 8) & 0xFF; uint8_t addr_low = addr & 0xFF; uint8_t block_sel = (block << 3) | 0x00; // 读标志 bit3 为 0 W5500_CS_LOW(); W5500_SPI_ExchangeByte(addr_high); W5500_SPI_ExchangeByte(addr_low); W5500_SPI_ExchangeByte(block_sel); uint8_t data = W5500_SPI_ExchangeByte(0x00); // 发送 dummy W5500_CS_HIGH(); return data; }

block 参数传入三个宏:COMMON_REG 是 0,SOCKET_REG 是 1,TX_BUF 是 2,RX_BUF 是 3。比如你想写 Socket 0 的模式寄存器 Sn_MR,它的偏移地址是 0x0000,块选择是 SOCKET_REG。如果直接用 W5500_ReadReg(0x0000, 1),读到的就是 Socket 0 的模式寄存器。

这里的坑在于,Socket 寄存器的偏移地址和 Socket 编号相关。Socket 0 的 Sn_MR 在 0x0000,Socket 1 的 Sn_MR 在 0x0100,每个 Socket 占 0x100 字节的寄存器空间。所以访问时要用宏 SOCKET_REG(0, sn) 来拼接,驱动里一般会提供类似W5500_WriteSnMR(sn, value)的封装。

3.3 硬件复位与 PHY 配置

// w5500_reg.c 复位函数 void W5500_HardwareReset(void) { GPIO_WriteBit(GPIOA, GPIO_Pin_3, Bit_RESET); // RST 拉低 Delay_ms(500); // 至少 500us,这里留余量 GPIO_WriteBit(GPIOA, GPIO_Pin_3, Bit_SET); // RST 拉高 Delay_ms(10); // 等 PHY 稳定 }

复位完成后,必须检查一个关键寄存器——版本寄存器 VERSIONR,地址 0x0039,块选择 COMMON_REG。如果返回 0x04,说明芯片通信正常;如果返回 0xFF,SPI 时序有问题;如果返回其它随机值,基本可以断定复位时序太短或者供电不稳。这个检查建议放在初始化函数的第一行,省去后面排查的麻烦。

4. TCP 通信流程图解:从 Socket 创建到数据收发

4.1 初始化网络参数:IP、掩码、网关

在建立 TCP 连接之前,要让 W5500 知道自己的网络地址。例程用的是静态 IP,四个参数直接写死在配置结构体里。初始化代码调用了 WIZCHIP 的初始化函数,设置了三种寄存器:基础网络参数寄存器、Socket 收发缓冲区大小、以及中断相关寄存器。

// tcp_client.c 网络参数配置 wiz_NetInfo net_info = { .mac = {0x00, 0x08, 0xDC, 0x12, 0x34, 0x56}, // MAC 地址,注意不能和局域网内设备冲突 .ip = {192, 168, 1, 100}, // 本机 IP .sn = {255, 255, 255, 0}, // 子网掩码 .gw = {192, 168, 1, 1}, // 默认网关 .dns = {8, 8, 8, 8} // DNS,静态 IP 下一般用不到 }; W5500_SetNetInfo(&net_info); // 写入 GAR、SUBR、SHAR、SIPR 寄存器 W5500_InitBuffer(); // 配置 TX/RX 缓冲区大小

MAC 地址一定不能和局域网里其它设备重复,否则会 ARP 冲突。IP 地址要确认没有被 DHCP 分配出去,否则会地址冲突,TCP 连接能建立但收发不稳定。这里的W5500_SetNetInfo对每类寄存器做循环写入,底层就是连续调用前文说到的W5500_WriteReg。

缓冲区大小的配置要看你的收发需求。例程默认每个 Socket 分配 2KB TX 和 2KB RX,总共 8 个 Socket 需要用 32KB 缓冲区,而 W5500 内部只有 16KB RX + 16KB TX。如果只开 1 个 Socket,可以把它的 TX/RX 都配成 8KB,这样单次能发送更大的数据包。注意:每个 Socket 的缓冲区大小必须写成一样的值,且总分配量不能超过物理大小,否则芯片行为不可预期。

4.2 TCP 客户端连接流程

连接流程本质是一个状态机:先 Open Socket,再 Connect,然后轮询 Socket 状态寄存器,直到状态变成 ESTABLISHED。直接上代码:

// tcp_client.c 建立 TCP 连接 uint8_t W5500_OpenClient(uint8_t sn, uint8_t *ip, uint16_t port) { // 1. 配置 Socket 为 TCP 模式 W5500_WriteSnMR(sn, Sn_MR_TCP); // Sn_MR = 0x01 W5500_WriteSnCR(sn, Sn_CR_OPEN); // 发送 OPEN 命令 while (W5500_ReadSnSR(sn) == SOCK_CLOSED); // 等待状态从 CLOSED 离开 // 2. 写入目标 IP 和端口 W5500_WriteSnDPORTR(sn, port); // 目的端口 for (int i = 0; i < 4; i++) W5500_WriteSnDIPR(sn, i, ip[i]); // 目的 IP // 3. 发送 CONNECT 命令 W5500_WriteSnCR(sn, Sn_CR_CONNECT); // 4. 轮询状态,直到连接建立或超时 uint32_t timeout = 10000; // 10 秒超时 while (timeout--) { if (W5500_ReadSnSR(sn) == SOCK_ESTABLISHED) return 1; // 连接成功 if (W5500_ReadSnSR(sn) == SOCK_CLOSED) return 0; // 连接失败,比如目标不可达 Delay_ms(1); } return 0; // 超时 }

这段代码有几个细节值得注意。W5500_WriteSnCR是写命令寄存器,每条命令执行后芯片会自动清除,不需要手动清。轮询状态时不要用while空转,加一个超时计数,否则服务器不在线时主程序会永远卡在连接这一步。TCP 连接失败时状态会回到 SOCK_CLOSED,不会停留在其它中间态,所以看到 CLOSED 就可以直接返回失败。

连接建立后,Socket 状态变成 SOCK_ESTABLISHED (0x17),此时才能调用收发函数。如果在其它状态调用发送函数,数据会被直接丢弃。

4.3 数据收发与断开重连

// 发送数据 uint16_t W5500_SendData(uint8_t sn, uint8_t *buf, uint16_t len) { W5500_WriteTxBuffer(sn, buf, len); // 先把数据拷进 W5500 TX 缓冲区 W5500_WriteSnCR(sn, Sn_CR_SEND); // 触发发送 while (W5500_ReadSnIR(sn) & Sn_IR_SEND_OK) == 0; // 等待发送完成中断标志 W5500_WriteSnIR(sn, Sn_IR_SEND_OK); // 手动清零中断标志 return len; } // 接收数据 int16_t W5500_RecvData(uint8_t sn, uint8_t *buf, uint16_t max_len) { uint16_t size = W5500_ReadSnRXRSR(sn); // 读取接收数据大小 if (size == 0) return 0; if (size > max_len) size = max_len; // 防止溢出用户缓冲区 W5500_ReadRxBuffer(sn, buf, size); // 从 RX 缓冲区读出 W5500_WriteSnCR(sn, Sn_CR_RECV); // 通知芯片缓冲区已释放 return size; }

发送流程里,W5500_WriteTxBuffer会把用户数据写到 W5500 的 TX 缓冲区,然后写 SEND 命令。发送完成的中断标志必须手动清零,否则下一次调用会误判。接收要注意一点:Sn_RXRSR返回的是已接收但还没被 MCU 读走的数据字节数,读出之后必须发送 RECV 命令,否则芯片认为缓冲区没释放,会一直累积直到溢出。

主循环里的调用方式一般是先查Sn_IR_RECV中断标志,有数据再调用 RecvData,然后处理业务逻辑,最后把数据回发给服务器。心跳包的做法是在主循环里维护一个计时器,每 5 秒调用一次 SendData 发送一个固定报文,这样路由器不会因为长期无流量而断开 TCP 映射。

5. 避坑指南:W5500 连不上的几个常见原因

5.1 读寄存器全是 0xFF

现象:初始化时检查 VERSIONR 寄存器返回值是 0xFF,所有读写操作都不正常。

原因:SPI 模式不匹配。W5500 要求 SPI Mode 0,也就是 CPOL = Low、CPHA = 1Edge。很多人直接把 STM32 SPI 默认配置拿来用,或者复用了之前 OLED 屏的 SPI 配置,结果时序对不上。

解决:在W5500_SPI_Init里显式设置SPI_CPOL_Low和SPI_CPHA_1Edge,并且用示波器确认 SCK 空闲时为低电平。另外检查 MISO 是不是浮空输入模式,如果配成复用推挽会拉死数据线。

5.2 能 Ping 通但 TCP 连不上

现象:从电脑能 ping 通 W5500 的 IP,但 TCP 连接总是超时。

原因:W5500 的 Ping 应答是硬件自动处理的,能 ping 通只说明链路层和网络层正常。连不上 TCP 通常是 Socket 没有进入监听或连接状态,常见原因是端口未正确写入,或者服务器端防火墙拦了端口。

解决:打印 Socket 状态寄存器 Sn_SR,如果在写 CONNECT 命令后状态停在 SOCK_SYNSENT (0x15) 很久,说明发出的 SYN 包没有得到响应——检查目标的 IP 是不是写反了字节序,Port 是不是用了大写形式。顺便在电脑上用 netstat 看有没有 SYN_RECEIVED,如果存在,说明连接已经到服务器了,是应用层没处理。

5.3 通信一段时间后卡死

现象:设备运行几小时到几天后,TCP 连接断开且无法重连,必须断电复位。

原因:最常见的是 TCP 粘包或半包处理不当导致接收缓冲区溢出,或者发送时没有检查 TX 缓冲区空闲空间就写数据。另一个隐蔽原因是忘了清理Sn_IR里的 RECV 中断标志,导致中断标志一直挂着,MCU 不再处理后续中断。

解决:每次接收后必须调用Sn_CR_RECV命令释放缓冲区,不管你的业务逻辑是否需要处理数据。发送前用W5500_ReadSnTXFSR查空闲空间,不够就稍等。程序里建议加一个看门狗,超时未收到数据就主动断开重连,而不是等系统死掉。

5.4 上电偶尔初始化失败

现象:同一个固件,有时上电能正常连网,有时卡在初始化,抓波形发现 RST 引脚时序不稳定。

原因:3.3V 电源上电瞬间有毛刺,或者 W5500 的 RST 引脚受 MCU 引脚默认状态影响。MCU 上电后 GPIO 默认是浮空输入,如果不先拉低再拉高 RST,W5500 可能处于未复位的随机状态。

解决:初始化函数最开始把 RST 引脚配置为推挽输出并先输出低电平,延时 500us 后再拉高。如果电源质量不好,在 W5500 电源引脚并一个 10uF 电容。

5.5 收发数据总丢最后几个字节

现象:发送一个 100 字节的包,服务器只收到 98 字节;或者接收时读出来的数据少了一截。

原因:发送完成中断标志SEND_OK是在数据被搬入发送 FIFO 时置位的,不代表数据已经发到网线上。如果紧接着就操作同一块 TX 缓冲区,可能覆盖还没发完的数据。

解决:发送后不要立即再次写入该 Socket 的 TX 缓冲区。如果需要连续发送多个包,等待SEND_OK之后加一个短暂延时,或者直接依赖 TCP 的滑动窗口机制——更稳的做法是检查Sn_TX_FSR,确认发送缓冲已经完全清空再发下一个包。

6. 二次开发思路:把 TCP 例程改造成实用通信组件

6.1 改成本地 DHCP 自动获取 IP

现在网络参数是写死的。如果设备要批量部署到不同网段,每台都手动改 IP 不现实。W5500 支持 DHCP,芯片原厂驱动库里有 DHCP 客户端的完整实现,但很多例程不默认包含。改造思路:在初始化时不再调用SetNetInfo写死 IP,而是调用 DHCP 模块去广播 Discover 报文,拿到 Offer 后再更新寄存器。注意 DHCP 过程占用 Socket 0,所以你的业务 Socket 要从 1 开始。

6.2 增加心跳和断线自动重连

实际的工业场景里,服务器不会永远在线。改造主循环逻辑成状态机:连接建立后,每 5 秒发一次心跳,连续 3 次没有收到回应就主动DISCONNECT,然后重新走OPEN -> CONNECT流程。这里有个技巧:调用 DISCONNECT 后要等状态回到 SOCK_CLOSED 再重新 OPEN,否则命令会被忽略。

6.3 扩展成多 Socket 并行通信

W5500 支持 8 个独立的 Socket,例程只用了 1 个。你可以把第二个 Socket 配置成 TCP Server,监听 8080 端口,让调试工具直接连上来;第一个 Socket 保持 Client 模式上报数据。两个 Socket 在中断服务程序里通过中断标志区分,业务逻辑用简单的 switch-case 分发。这样设备既能主动上报,又能被动接收配置指令,一个完整的双向管理通道就通了。

6.4 数据格式与业务解耦的验证方法

改造完成后不要急着接真实服务器,先在电脑上用网络调试助手做回环测试:设备发一个特征帧,调试助手回一个特征帧,两边比对无误后再联调。然后把 W5500 的速率从 18MHz 降到 9MHz 再测一轮,如果低速率下正常、高速率下丢包,问题出在 SPI 信号完整性;如果高低速率下都丢包,才需要查软件逻辑。从那以后我每次改这类网络驱动,都强制走一遍「回环测试-降速对比-长时间稳定性」三步,能拦下一大半翻车问题,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询