W5500移植HAL库(下)
164.12.6W5500移植HAL库(下)
/*
* 本代码块只收录本节视频实际查看、修改和调试的源码片段,并按课程最终工程原样保留语句。
* 这些片段分别来自 W5500.h、W5500.c、spi.c 和 main.c,不是可以单独编译的完整程序。
* 课程工程在接收缓冲区路径中仍有两处遗留问题,代码不在笔记中擅自修正,已在对应行和正文中标明。
*/
/* W5500.h:课程使用的片选与复位引脚定义。 */
#define W5500_SCS GPIO_PIN_4 /* W5500 的 SCSn/CS 接到 PA4;HAL 库引脚宏为 GPIO_PIN_4。 */
#define W5500_SCS_PORT GPIOA /* SCSn 所在的 GPIO 端口是 GPIOA。 */
#define W5500_RST GPIO_PIN_5 /* W5500 的低有效复位脚 RSTn 接到 PC5。 */
#define W5500_RST_PORT GPIOC /* RSTn 所在的 GPIO 端口是 GPIOC。 */
/* W5500.c:把标准库的单字节 SPI 发送替换为 HAL 全双工收发。 */
void SPI1_Send_Byte(unsigned char dat) /* 发送一个字节;SPI 同时收到的字节在写操作中不使用。 */
{ /* 单字节交换函数开始。 */
uint8_t recv; /* 为 HAL 的接收指针准备一个真实的 8 位变量。 */
HAL_SPI_TransmitReceive(&hspi1, &dat, &recv, 1, 100); /* SPI1 交换 1 个 8 位数据单元,最长阻塞 100 ms。 */
} /* 函数结束;课程源码没有检查 HAL 返回状态。 */
void SPI1_Send_Short(unsigned short dat) /* 按高字节在前的顺序发送一个 16 位数。 */
{ /* 16 位发送函数开始。 */
SPI1_Send_Byte(dat / 256); /* 整除 256 取得高 8 位并先发送。 */
SPI1_Send_Byte(dat); /* 传给 8 位形参时保留低 8 位并发送。 */
} /* 16 位发送函数结束。 */
unsigned char Read_W5500_1Byte(unsigned short reg) /* 从 W5500 通用寄存器读取 1 字节。 */
{ /* 通用寄存器单字节读取开始。 */
unsigned char i, y = 0; /* i 保存返回数据,y 是为读操作产生时钟的哑字节 0x00。 */
HAL_GPIO_WritePin(W5500_SCS_PORT, W5500_SCS, GPIO_PIN_RESET); /* SCSn 拉低,开始一帧 W5500 SPI 事务。 */
SPI1_Send_Short(reg); /* 先发送 16 位寄存器偏移地址。 */
SPI1_Send_Byte(FDM1 | RWB_READ | COMMON_R); /* 再发送控制字:通用寄存器、读、固定 1 字节。 */
HAL_SPI_TransmitReceive(&hspi1, &y, &i, 1, 100); /* 发送哑字节产生 8 个时钟,同时从 MISO 收到寄存器值。 */
HAL_GPIO_WritePin(W5500_SCS_PORT, W5500_SCS, GPIO_PIN_SET); /* SCSn 拉高,结束本帧事务。 */
return i; /* 返回读取到的 8 位寄存器值。 */
} /* 通用寄存器单字节读取结束。 */
unsigned char Read_W5500_SOCK_1Byte(SOCKET s, unsigned short reg) /* 从指定 Socket 的寄存器读取 1 字节。 */
{ /* Socket 寄存器单字节读取开始。 */
unsigned char i, y = 0; /* i 接收有效数据,y 提供读阶段要发送的哑字节。 */
HAL_GPIO_WritePin(W5500_SCS_PORT, W5500_SCS, GPIO_PIN_RESET); /* 选中 W5500。 */
SPI1_Send_Short(reg); /* 发送 16 位寄存器偏移地址。 */
SPI1_Send_Byte(FDM1 | RWB_READ | (s * 0x20 + 0x08)); /* 控制字选择 Socket s 寄存器块,并读取 1 字节。 */
HAL_SPI_TransmitReceive(&hspi1, &y, &i, 1, 100); /* 发送一个 0x00,同时接收一个有效字节。 */
HAL_GPIO_WritePin(W5500_SCS_PORT, W5500_SCS, GPIO_PIN_SET); /* 释放片选,结束事务。 */
return i; /* 返回读取结果。 */
} /* Socket 寄存器单字节读取结束。 */
unsigned short Read_W5500_SOCK_2Byte(SOCKET s, unsigned short reg) /* 从指定 Socket 寄存器读取连续 2 字节。 */
{ /* Socket 寄存器双字节读取开始。 */
unsigned char i, j, y = 0; /* i 接收高字节,j 接收低字节,y 是哑字节。 */
HAL_GPIO_WritePin(W5500_SCS_PORT, W5500_SCS, GPIO_PIN_RESET); /* 选中 W5500。 */
SPI1_Send_Short(reg); /* 发送 16 位寄存器偏移地址。 */
SPI1_Send_Byte(FDM2 | RWB_READ | (s * 0x20 + 0x08)); /* 控制字选择 Socket s 寄存器块,并读取 2 字节。 */
HAL_SPI_TransmitReceive(&hspi1, &y, &i, 1, 100); /* 第一次交换取得高 8 位。 */
HAL_SPI_TransmitReceive(&hspi1, &y, &j, 1, 100); /* 第二次交换取得低 8 位。 */
HAL_GPIO_WritePin(W5500_SCS_PORT, W5500_SCS, GPIO_PIN_SET); /* 两个数据字节完成后再结束同一帧事务。 */
return (unsigned short)(i) * 256 + (unsigned short)(j); /* 高字节乘 256 后与低字节相加,拼成 16 位结果。 */
} /* Socket 寄存器双字节读取结束。 */
unsigned short Read_SOCK_Data_Buffer(SOCKET s, unsigned char *dat_ptr) /* 从 Socket RX 环形缓冲区读出一批数据。 */
{ /* 接收缓冲区读取函数开始。 */
unsigned short rx_size; /* 保存 W5500 报告的本次可读字节数。 */
unsigned short offset, offset1; /* offset 用于物理地址,offset1 保存并更新逻辑读指针。 */
unsigned short i; /* 循环计数器。 */
unsigned char j, y = 0; /* j 接收一个字节,y 是课程后来补入的哑字节变量。 */
rx_size = Read_W5500_SOCK_2Byte(s, Sn_RX_RSR); /* 读取 Socket 已接收数据长度。 */
if (rx_size == 0) return 0; /* 没有数据就立即返回 0。 */
if (rx_size > 1460) rx_size = 1460; /* 单次最多读取 1460 字节。 */
offset = Read_W5500_SOCK_2Byte(s, Sn_RX_RD); /* 读取当前 RX 读指针。 */
offset1 = offset; /* 保存未取模的逻辑指针,稍后写回寄存器。 */
offset &= (S_RX_SIZE - 1); /* 在 2 KiB 环形缓冲区内求实际物理偏移。 */
HAL_GPIO_WritePin(W5500_SCS_PORT, W5500_SCS, GPIO_PIN_RESET); /* 选中 W5500,开始读取 RX 缓冲区。 */
SPI1_Send_Short(offset); /* 发送 RX 缓冲区内的起始偏移地址。 */
SPI1_Send_Byte(VDM | RWB_READ | (s * 0x20 + 0x18)); /* 选择 Socket s 的 RX 缓冲区并进入可变长度读取。 */
HAL_SPI_TransmitReceive(&hspi1, &y, &j, 1, 100); /* 课程源码先交换一次但未保存该字节;这是遗留的首字节丢弃风险。 */
if ((offset + rx_size) < S_RX_SIZE) /* 判断本次数据是否会跨越环形缓冲区末尾。 */
{ /* 不跨界分支开始。 */
for (i = 0; i < rx_size; i++) /* 按字节读取全部数据。 */
{ /* 单字节读取循环开始。 */
SPI1_Send_Byte(0x00); /* 课程原语句保留:该函数本身已经交换一次并丢弃接收值。 */
HAL_SPI_TransmitReceive(&hspi1, &y, &j, 1, 100); /* 随后又交换一次,因此本分支存在每轮多发送一个哑字节的问题。 */
*dat_ptr = j; /* 保存第二次交换收到的字节。 */
dat_ptr++; /* 输出缓冲区指针移到下一个位置。 */
} /* 单字节读取循环结束。 */
} /* 不跨界分支结束。 */
else /* 数据跨越 RX 环形缓冲区末尾。 */
{ /* 跨界分支开始。 */
offset = S_RX_SIZE - offset; /* 计算从当前地址到缓冲区末尾的字节数。 */
for (i = 0; i < offset; i++) /* 先读取末尾前的第一段数据。 */
{ /* 第一段循环开始。 */
SPI1_Send_Byte(0x00); /* 课程原语句保留:再次产生一次 SPI 交换。 */
HAL_SPI_TransmitReceive(&hspi1, 0x00, &j, 1, 100); /* 课程最终源码仍传入 0x00 空指针;HAL 会返回错误,未真正发送哑字节。 */
*dat_ptr = j; /* 把 j 写入接收缓冲区;上一行失败时 j 不代表新收到的数据。 */
dat_ptr++; /* 移到输出缓冲区下一位置。 */
} /* 第一段循环结束。 */
HAL_GPIO_WritePin(W5500_SCS_PORT, W5500_SCS, GPIO_PIN_SET); /* 到达环形末尾,先结束当前 SPI 帧。 */
HAL_GPIO_WritePin(W5500_SCS_PORT, W5500_SCS, GPIO_PIN_RESET); /* 重新拉低 SCSn,开始从地址 0 读取第二段。 */
SPI1_Send_Short(0x00); /* 第二段从 RX 缓冲区偏移 0 开始。 */
SPI1_Send_Byte(VDM | RWB_READ | (s * 0x20 + 0x18)); /* 再次选择 Socket s 的 RX 缓冲区。 */
HAL_SPI_TransmitReceive(&hspi1, &y, &j, 1, 100); /* 课程源码再次先交换并丢弃一个接收字节。 */
for (; i < rx_size; i++) /* 继续读取绕回缓冲区开头后的剩余数据。 */
{ /* 第二段循环开始。 */
SPI1_Send_Byte(0x00); /* 课程原语句保留:本身已完成一次交换。 */
HAL_SPI_TransmitReceive(&hspi1, &y, &j, 1, 100); /* 又交换一次并保存结果,仍有跳字节风险。 */
*dat_ptr = j; /* 保存接收到的数据。 */
dat_ptr++; /* 输出指针后移。 */
} /* 第二段循环结束。 */
} /* 跨界分支结束。 */
HAL_GPIO_WritePin(W5500_SCS_PORT, W5500_SCS, GPIO_PIN_SET); /* 结束最后一帧 RX 缓冲区读取。 */
offset1 += rx_size; /* 按已消费长度推进逻辑读指针。 */
Write_W5500_SOCK_2Byte(s, Sn_RX_RD, offset1); /* 把新的读指针写回 W5500。 */
Write_W5500_SOCK_1Byte(s, Sn_CR, RECV); /* 发出 RECV 命令,通知 W5500 释放已读数据。 */
return rx_size; /* 返回本次声称读取的字节数。 */
} /* 接收缓冲区读取函数结束。 */
void W5500_Hardware_Reset(void) /* 对 W5500 执行硬件复位并等待网线链路。 */
{ /* 硬件复位函数开始。 */
HAL_GPIO_WritePin(W5500_RST_PORT, W5500_RST, GPIO_PIN_RESET); /* RSTn 拉低,进入复位。 */
HAL_Delay(50); /* 保持低电平 50 ms,超过数据手册要求的最短复位脉宽。 */
HAL_GPIO_WritePin(W5500_RST_PORT, W5500_RST, GPIO_PIN_SET); /* RSTn 拉高,释放复位。 */
HAL_Delay(200); /* 等待芯片内部和 PHY 稳定。 */
while ((Read_W5500_1Byte(PHYCFGR) & LINK) == 0); /* 一直等待 LINK=1;未插网线时会永久阻塞。 */
} /* 硬件复位与链路等待结束。 */
/* spi.c:在 HAL_SPI_MspInit() 中补上 PA4/SCSn 初始化。 */
void HAL_SPI_MspInit(SPI_HandleTypeDef *spiHandle) /* HAL_SPI_Init() 会调用的底层时钟和 GPIO 初始化入口。 */
{ /* SPI MSP 初始化开始。 */
GPIO_InitTypeDef GPIO_InitStruct = {0}; /* 建立并清零 GPIO 配置结构体。 */
if (spiHandle->Instance == SPI1) /* 只处理 SPI1 的底层资源。 */
{ /* SPI1 分支开始。 */
__HAL_RCC_SPI1_CLK_ENABLE(); /* 打开 SPI1 外设时钟。 */
__HAL_RCC_GPIOA_CLK_ENABLE(); /* 打开 PA4~PA7 所在 GPIOA 的时钟。 */
GPIO_InitStruct.Pin = GPIO_PIN_5 | GPIO_PIN_7; /* 选择 PA5/SCK 和 PA7/MOSI。 */
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; /* SCK、MOSI 由 SPI1 复用功能推挽驱动。 */
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; /* 配置输出驱动速度等级,不等于直接设置 SPI 波特率。 */
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); /* 应用 SCK 与 MOSI 的配置。 */
GPIO_InitStruct.Pin = GPIO_PIN_6; /* 选择 PA6/MISO。 */
GPIO_InitStruct.Mode = GPIO_MODE_INPUT; /* 主机从 MISO 接收数据,因此配置为输入。 */
GPIO_InitStruct.Pull = GPIO_NOPULL; /* 不启用 STM32 内部上拉或下拉。 */
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); /* 应用 MISO 输入配置。 */
GPIO_InitStruct.Pin = W5500_SCS; /* 选择课程定义的 PA4/SCSn。 */
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; /* SCSn 是普通 GPIO,由软件以推挽方式控制。 */
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; /* 设置 PA4 的输出驱动速度等级。 */
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); /* 应用 SCSn GPIO 配置;宏定义当前确实指向 GPIOA。 */
HAL_GPIO_WritePin(W5500_SCS_PORT, W5500_SCS, GPIO_PIN_SET); /* 初始置高,保持 W5500 未选中。 */
} /* SPI1 分支结束。 */
} /* SPI MSP 初始化结束。 */
/* main.c:课程用 OLED 汉字作为“程序执行到哪里”的分段标记,并每秒发送测试字符串。 */
int main(void) /* HAL 工程主入口。 */
{ /* 主函数开始。 */
HAL_Init(); /* 初始化 HAL,并建立 HAL_Delay() 使用的系统节拍。 */
SystemClock_Config(); /* 配置系统时钟。 */
Init_GPIO(); /* 初始化工程通用 GPIO。 */
Init_UART1(); /* 初始化串口调试接口。 */
//Init_TIM2(); /* 课程最终代码保留注释,不启用原标准库例程的定时器节拍。 */
Init_SPI1(); /* 初始化 SPI1,并间接调用上面的 HAL_SPI_MspInit()。 */
Init_I2C1(); /* 初始化 OLED 使用的 I2C1。 */
OLED_Init(); /* 初始化 OLED 控制器。 */
OLED_Clear(); /* 清空 OLED 旧内容。 */
OLED_ShowCHinese(0, 0, 0); /* 显示第 1 个调试汉字,证明程序运行到此处。 */
OLED_ShowCHinese(18, 0, 1); /* 显示第 2 个调试汉字。 */
Load_Net_Parameters(); /* 把课程设置的 IP、端口和 MAC 等装入变量。 */
OLED_ShowCHinese(36, 0, 2); /* 显示第 3 个调试汉字。 */
OLED_ShowCHinese(54, 0, 3); /* 显示第 4 个调试汉字;前四字组成“好好学习”。 */
W5500_Hardware_Reset(); /* 复位 W5500 并等待 PHY LINK。 */
OLED_ShowCHinese(72, 0, 4); /* 只有复位函数返回后才显示第 5 个字。 */
OLED_ShowCHinese(90, 0, 5); /* 显示第 6 个字。 */
W5500_Initialization(); /* 初始化 W5500 寄存器和 Socket 0。 */
OLED_ShowCHinese(108, 0, 6); /* 显示第 7 个字。 */
OLED_ShowCHinese(64, 2, 7); /* 显示第 8 个字,最终组成“好好学习,天天向上”。 */
while (1) /* 进入不会主动退出的主循环。 */
{ /* 主循环开始。 */
W5500_Socket_Set(); /* 尝试初始化或建立 Socket 0 连接。 */
W5500_Interrupt_Process(); /* 轮询 W5500 的 Socket 中断状态并更新软件标志。 */
memcpy(Tx_Buffer, "\r\nSPI W5500 NET TEST!\r\n", 23); /* 准备 23 字节网络测试报文。 */
Write_SOCK_Data_Buffer(0, Tx_Buffer, 23); /* 经 Socket 0 发送测试报文。 */
HAL_Delay(1000); /* 每轮间隔约 1 秒,避免连续高速发送。 */
printf("High level duration: \n"); /* 课程最终源码保留的串口调试输出。 */
} /* 主循环结束位置;实际运行不会跳出 while。 */
} /* 主函数结束。 */
上面的代码保持课程最终工程语句不变,只增加解释性注释。特别是
Read_SOCK_Data_Buffer()中残留的0x00空指针和重复哑字节交换没有被偷偷修正;它们是本课发送演示没有覆盖到的接收路径问题。
一、查找和替换
- HAL 驱动按外设拆分。SPI 的公开接口声明在
stm32f1xx_hal_spi.h,实现位于stm32f1xx_hal_spi.c。阅读 HAL 源码时,应先看函数原型、参数和返回值,再决定怎样替换标准库调用,不能只靠函数名称猜测。 - 本节选择阻塞式
HAL_SPI_TransmitReceive(),因为 SPI 本来就是全双工:主机每送出一位,MISO 也同时移入一位。写 W5500 时可以忽略收到的字节;读 W5500 时仍必须在 MOSI 上发送哑字节,主机才能产生 SCK,让从机把数据移出。 HAL_SPI_Transmit()、HAL_SPI_Receive()和HAL_SPI_TransmitReceive()都是 HAL 提供的合法接口。“前两个一定不好用、只有同时收发才兼容”不是通用结论。对当前 SPI1 主机双线全双工配置,使用收发接口能最直观地表达“发送哑字节并同时取回数据”。- 全局替换只适合名称明确且语义完全一致的情况。本节把旧
Delay(...)替换为HAL_Delay(...)前,应限制在当前文件并逐个复核,避免误改函数声明、注释、其他同名符号或不同时间单位的延时。
HAL_SPI_TransmitReceive() 在本工程中的参数含义如下:
| 参数 | 本节传入值 | 含义 |
|---|---|---|
hspi | &hspi1 | SPI1 句柄的地址,里面保存实例、模式、数据位宽和运行状态等信息 |
pTxData | &dat 或 &y | 发送缓冲区地址,不能把要发送的数值直接当作地址 |
pRxData | &recv、&i 或 &j | 接收缓冲区地址,HAL 把同步收到的数据写入这里 |
Size | 1 | 交换 1 个数据单元;当前配置为 8 位,所以是 1 字节 |
Timeout | 100 | 阻塞调用最多等待 100 ms,超时返回 HAL_TIMEOUT |
课程源码没有保存 HAL_SPI_TransmitReceive() 的返回值。调试真实项目时,应区分 HAL_OK、HAL_BUSY、HAL_TIMEOUT 和 HAL_ERROR,否则“SPI 失败”会被误当成“W5500 返回了某个数据”。
二、W5500的SPI接口移植HAL库
2.1 SPI发送接收函数
SPI1 配置为主机、双线全双工、8 位、MSB 先行、CPOL=0、CPHA=第一个边沿,即 SPI Mode 0。W5500 官方数据手册说明它支持 Mode 0 和 Mode 3,并要求数据按 MSB 到 LSB 传输,因此本节配置与 W5500 匹配。
SPI1_Send_Byte() 中:
dat是真正要发出的一个字节,所以传&dat。recv只是承接同时移入的字节。写命令时该值没有业务意义,但必须给 HAL 一个有效接收地址。Size=1是一个 8 位数据单元,不是“1 bit”。Timeout=100的单位是毫秒,不是微秒。- 课程没有检查返回值,这不影响理解迁移思路,但不利于定位总线超时或状态错误。
W5500 的一帧 SPI 访问不是“随便发几个字节”,而是严格分为:
SCSn 拉低
│
├─ 16 位 Offset Address
├─ 8 位 Control Byte:BSB[4:0] + RWB + OM[1:0]
├─ 1/2/4/N 字节 Data Phase
│
SCSn 拉高
固定长度模式 FDM 用控制字中的 OM 指定 1、2 或 4 字节;可变长度模式 VDM 由 SCSn 何时拉高决定数据长度。无论哪种模式,同一帧的地址、控制字和数据阶段之间都不能随意释放片选。
2.1.1 例题1:W5500函数移植与调试
本节依次把 W5500.c 中与 MCU 平台相关的接口替换为 HAL:
SPI_I2S_SendData()与标志轮询替换为HAL_SPI_TransmitReceive()。SPI_I2S_ReceiveData()改为通过接收指针取得数据。GPIO_ResetBits()、GPIO_SetBits()改为HAL_GPIO_WritePin()。Delay()改为HAL_Delay()。- 标准库工程中的
u8改为 HAL/CMSIS 已定义的uint8_t。
标准库读取函数直接返回寄存器数据,HAL 收发函数则通过 pRxData 指向的内存写回结果。两者的接口形式不同,所以不能机械地只替换函数名。
读取 16 位寄存器时,W5500 先返回高字节 i,再返回低字节 j:
i = 0x12,j = 0x34
高字节移到高 8 位:0x12 × 256 = 0x1200
加上低字节: 0x1200 + 0x34 = 0x1234
视频中一度说成“左移 256 位”,这是口误;乘以 256 等价于左移 8 位。还要补充一个 C 语言细节:在 STM32F1 的常见编译环境中,unsigned char 参加算术运算前会发生整型提升,因此 i * 256 本身通常不会先在 8 位宽度内变成 0。课程加入 (unsigned short) 强制转换能明确表达目标位宽,但“没有强转就一定丢失”并不是普遍结论。若把移位结果先强制存回 uint8_t,高 8 位才会被截掉。
不同函数中的同名局部变量 i、j 处于不同作用域,彼此不共享内存,也不会互相覆盖。
2.1.2 例题2:W5500的GPIO定义与初始化
- STM32 标准外设库把 PA4 写作
GPIO_Pin_4,HAL 库写作GPIO_PIN_4,字母大小写不同。C 语言标识符区分大小写,直接搬用旧宏会导致未定义符号。 - 本工程的
W5500_RST是 PC5,W5500_SCS是 PA4。复位脚已在W5500_GPIO_Configuration()中初始化,但 Cube 生成的 SPI 代码只配置了 SCK、MISO、MOSI,不会自动知道 PA4 是业务层手动片选,因此必须额外初始化 SCSn。 - 代码中反复执行
HAL_GPIO_WritePin()不等于引脚已经配置为输出。没有先使能 GPIO 时钟并调用HAL_GPIO_Init(),写引脚可能不会得到预期的电气行为。
2.3 查找W5500的SCS引脚初始化位置
2.3.1 例题1: 查找W5500的SCS引脚初始化位置
课程使用 Keil 的全局查找定位 W5500_SCS,再回到旧标准库工程确认 SCSn 原本在 SPI_Configuration() 中初始化。迁移时不能只搜索“哪里拉高、哪里拉低”,还要找到“哪里打开 GPIO 时钟、哪里设置输出模式和初始电平”。
在 HAL 工程里,Init_SPI1() 调用 HAL_SPI_Init(&hspi1),HAL 随后进入 HAL_SPI_MspInit() 配置底层时钟和引脚。因此,本节把 SCSn 的 GPIO 配置加到 HAL_SPI_MspInit() 的 SPI1 分支中。
2.3.2 例题2:W5500的SCS引脚初始化配置
SPI1 与 W5500 的连接关系为:
STM32F103(主机) W5500(从机)
PA5 / SPI1_SCK ──────────────────────► SCLK
PA7 / SPI1_MOSI ──────────────────────► MOSI
PA6 / SPI1_MISO ◄────────────────────── MISO
PA4 / 普通 GPIO ──────────────────────► SCSn(低电平选中)
PC5 / 普通 GPIO ──────────────────────► RSTn(低电平复位)
GND ─────────────────────── GND
- PA5/SCK 和 PA7/MOSI 是 SPI 外设输出,配置为复用推挽
GPIO_MODE_AF_PP。 - PA6/MISO 是主机输入,配置为
GPIO_MODE_INPUT。 - PA4/SCSn 是软件片选,配置为普通推挽输出
GPIO_MODE_OUTPUT_PP。 - 初始化完成后立即把 SCSn 拉高,表示暂不选中 W5500,防止初始化过程中形成半截 SPI 帧。
GPIO_SPEED_FREQ_HIGH设置的是 STM32 GPIO 输出驱动的速度/边沿能力,不是把网络速度或 SPI 波特率直接设为“高速”。SPI 时钟频率由外设时钟和SPI_BAUDRATEPRESCALER_4决定。- 课程代码调用
HAL_GPIO_Init(GPIOA, ...),而片选宏也确实定义在 GPIOA,因此当前能对应上;若以后改引脚,初始化端口和W5500_SCS_PORT必须一起修改。
2.3.3 例题3:W5500的SCS引脚初始化代码错误排查与修改
链接器报告旧 Delay 未定义,说明 W5500.c 仍依赖原标准库工程的延时函数。本节把当前文件中的 9 处 Delay(...) 改为 HAL_Delay(...)。HAL_Init() 必须先完成,系统节拍正常后 HAL_Delay() 才能按毫秒工作。
视频编译输出实际是 0 个错误、6 个警告,不是完全无警告。能够生成并烧写程序只说明没有阻止链接的错误,警告仍应逐条查看,尤其要关注整数与指针转换、未使用变量及函数声明不匹配。
2.4 编译错误处理
2.4.1 编译错误现象与初步排查
本节先遇到的是下载工具/软件占用问题:关闭冲突软件后,程序才能重新烧录。这类问题属于主机端工具链或调试器资源冲突,与 W5500 协议代码不是同一层故障。排查时应把“编译”“下载”“程序运行”“网络通信”四个阶段分开,不要全部笼统称作“代码不好使”。
2.4.2 调试方法与硬件复位问题
课程把八个 OLED 汉字拆开放到关键函数前后,形成简易的执行路径标记:
显示“好好学习”
│
▼
W5500_Hardware_Reset()
│
▼
显示“天天” → W5500_Initialization() → 显示“向上”
第一次运行只看到“好好学习”,说明程序已经完成前面的初始化,但没有从 W5500_Hardware_Reset() 返回。函数中的两个延时都会结束,真正可能永久停住的是:
while ((Read_W5500_1Byte(PHYCFGR) & LINK) == 0);
PHYCFGR 的 bit 0 是只读的链路状态位:1 表示 Link up,0 表示 Link down。若 SPI 读取失败、网线未连接、对端未上电或自协商尚未完成,这个无超时循环都会让程序一直停在此处。因此 OLED 分段法只能把故障定位到函数或区间,不能单凭显示内容断言一定是哪一种根因。
更通用的调试顺序是先读 W5500 的 VERSIONR。官方数据手册规定该寄存器应为 0x04;读对版本号后,再检查 PHYCFGR.LNK 和 Socket 状态,可以把“SPI 底层错误”与“网线链路错误”分开。
2.4.3 发送函数错误与修正
课程最关键的问题是下面这个调用:
HAL_SPI_TransmitReceive(&hspi1, 0x00, &i, 1, 100);
第二个形参类型是发送缓冲区指针 const uint8_t *pTxData。这里的 0x00 不是“发送数值 0”,而是空指针。STM32 HAL 会检查发送或接收指针是否为空;为空时返回 HAL_ERROR,并不会替程序自动发送一个零。正确理解是:先定义一个值为 0 的 8 位变量,再把变量地址传入。课程因此增加 y=0,并改传 &y。
要特别注意,课程最终工程并没有把接收路径全部修净:
Read_SOCK_Data_Buffer()的跨界第一段循环仍有一处HAL_SPI_TransmitReceive(&hspi1, 0x00, &j, 1, 100)。- 同一函数先调用已经完成一次全双工交换的
SPI1_Send_Byte(0x00),随后又调用一次HAL_SPI_TransmitReceive();这会为每个保存的数据产生两次 SPI 字节交换。 - 控制字后还先执行了一次未保存结果的
HAL_SPI_TransmitReceive(),存在丢弃首个有效数据字节的风险。
这些语句已按课程源码原样保留在顶部代码块中。它们不影响本节演示的周期发送路径,因此“电脑收到测试字符串”不能证明 RX 缓冲区读取路径也正确。后续真正使用网络接收功能时,必须按 W5500 数据手册重新验证每一个时钟对应的接收字节。
2.4.4 修正后验证与成功
修正单字节寄存器读取所用的发送指针后,OLED 能完整显示“好好学习,天天向上”,说明程序已经越过硬件复位和 W5500 初始化位置。电脑端随后收到 SPI W5500 NET TEST!,证明本次演示所需的 SPI 寄存器访问、Socket 建立和发送路径可以运行。
这个验证范围需要准确表述:它验证了课程当时实际走到的初始化与发送路径,没有覆盖断网重连、HAL 超时返回、Socket RX 缓冲区跨界读取或长时间双向收发。
2.4.5 SPI通信原理
SPI 不是“TX、RX 两根线再加一根时钟线”的 UART。以主机为参照:
MOSI:Master Out, Slave In,主机发送、从机接收;MISO:Master In, Slave Out,从机发送、主机接收;SCK:由主机产生的同步时钟;SCSn/CS:低电平选中 W5500,并界定一帧访问的起止;- 所有设备必须共地,W5500 还需要独立的低有效复位脚
RSTn。
SPI 的“快”来自同步时钟、硬件移位和很少的帧间协议开销,但实际可靠速率仍受 MCU 外设时钟、分频、GPIO 边沿、PCB 走线、线缆、模块电平和从机时序限制。W5500 数据手册提到理论设计速度可达 80 MHz,同时也警告高速下信号可能失真;本工程 APB2 为 72 MHz、SPI1 四分频时,SCK 为 18 MHz。
读 W5500 时,主机必须继续发送哑字节来制造 SCK。哑字节的具体数值通常不承载业务意义;重要的是传入一个真实、有效的内存地址,并保证一个期望接收字节只对应一次 SPI 交换。
参考资料
- WIZnet W5500 Datasheet v1.0.9
- STMicroelectronics STM32F1 HAL SPI 头文件
- STMicroelectronics STM32F1 HAL SPI 实现
- ST UM1850:STM32F1 HAL 与 LL 驱动说明
- 本地课程最终工程:
源码/3、STM32单片机裸机部分/code/KEIL/10W5500/ - 本地课程视频:
videos/164.12.6W5500移植HAL库(下).mp4 - 本地转录:
transcripts/164.12.6W5500移植HAL库(下).txt