当前位置: 首页 > news >正文

STM32 BootLoader 实战(五):基于 W5500 网口的 YMODEM 升级 APP 固件

摘要

串口 YMODEM 升级适合调试和近距离维护,现场设备数量多以后,网口升级会更方便。W5500 自带硬件 TCP/IP 协议栈,STM32 只需要通过 SPI 操作 Socket,就可以做一个轻量级 TCP 升级通道。

这篇把前面的 YMODEM 接收逻辑搬到 W5500 TCP 连接上,重点处理下面几个问题:

  • BootLoader 如何初始化 W5500
  • BootLoader 做 TCP Server 还是 TCP Client
  • TCP 是字节流,YMODEM 包解析要怎么适配
  • 什么时候清除 APP 有效标志
  • 网线拔掉、TCP 断开、升级超时以后怎么处理
  • W5500 接收的数据如何写入 APP Flash

阅读前默认工程已经完成:

  • BootLoader 固定运行在0x08000000
  • APP 已经按偏移地址链接
  • APP 已经完成中断向量表重定位
  • BootLoader 已经封装 Flash 擦写接口
  • BootLoader 已经具备 APP 有效标志和参数区
  • 串口 YMODEM 接收逻辑已经能跑通

目录

  • 1. 为什么网口升级还可以继续用 YMODEM
  • 2. W5500 网口升级整体流程
  • 3. 硬件连接和启动条件
  • 4. 网络参数和 Socket 规划
  • 5. W5500 初始化代码
  • 6. 建立 TCP Server
  • 7. 把 TCP 接收适配成 YMODEM 字节接口
  • 8. YMODEM over TCP 的接收流程
  • 9. 上位机发送方式
  • 10. 断线、超时和重复升级处理
  • 11. 调试日志和状态码
  • 12. 常见问题
  • 13. 总结

1. 为什么网口升级还可以继续用 YMODEM

W5500 走 TCP 后,YMODEM 不是必需项。

TCP 已经保证数据按顺序到达,也会处理重传。理论上可以自己定义一个更简单的协议:

固件头 + 固件长度 + 固件 CRC + 固件数据

继续使用 YMODEM 的原因主要有三个。

第一,前面串口升级已经写好了 YMODEM 包解析、文件大小解析、CRC16 校验、EOT 结束处理。网口升级只需要替换底层收发接口。

第二,YMODEM 第 0 包自带文件名和文件大小,BootLoader 可以直接拿文件大小检查 APP 分区。

第三,YMODEM 每包带 CRC16,即使 TCP 已经可靠,也能在应用层多做一次包级检查,调试时更容易定位问题。

所以这篇采用下面的思路:

串口升级: UART 接收字节 -> YMODEM 解析 -> Flash 写 APP 网口升级: W5500 TCP 接收字节 -> YMODEM 解析 -> Flash 写 APP

上层 YMODEM 状态机尽量不改,只替换底层RecvByteSendByte

2. W5500 网口升级整体流程

这里让 BootLoader 作为 TCP Server。

PC 上位机作为 TCP Client,连接到设备固定 IP 和固定端口,然后通过这条 TCP 连接发送 YMODEM 数据。

流程如下:

STM32 上电 | v 运行 BootLoader | v 判断是否进入升级模式 | +-- 否:检查 APP 有效,跳转 APP | +-- 是:初始化 W5500 | v 配置静态 IP | v 打开 TCP Server | v 等待 PC 连接 | v 周期发送 'C' | v 接收 YMODEM 固件 | v 擦除 APP 区域 | v 写入 APP Flash | v 校验 APP | v 写 APP 有效标志 | v 复位

TCP Server 模式更适合 BootLoader:

  • 设备 IP 固定,上位机主动连接
  • BootLoader 不需要知道上位机 IP
  • 多台设备现场维护时,按 IP 逐个升级
  • 逻辑比 TCP Client 更直观

如果产品现场 IP 不固定,也可以在 BootLoader 里跑 DHCP。但 BootLoader 阶段越简单越稳,基础版本先用静态 IP。

3. 硬件连接和启动条件

W5500 和 STM32 通过 SPI 通信。常见连接如下:

W5500 SCS -> STM32 SPI_NSS 或普通 GPIO W5500 SCLK -> STM32 SPI_SCK W5500 MISO -> STM32 SPI_MISO W5500 MOSI -> STM32 SPI_MOSI W5500 RST -> STM32 GPIO W5500 INT -> STM32 GPIO,可选 W5500 3V3 -> 3.3V W5500 GND -> GND

BootLoader 中至少需要控制:

  • SPI 初始化
  • CS 片选
  • RST 复位
  • W5500 读写寄存器
  • Socket 状态轮询

升级模式可以通过下面几种方式进入:

按键进入升级模式 APP 写升级标志后复位 BootLoader 检查 APP 无效后进入 上电后等待固定时间,如果有网络连接则进入升级

工程里更常见的是组合方式:

APP 有效 + 没有升级请求:跳转 APP APP 无效:停留 BootLoader 检测到升级按键:停留 BootLoader 检测到 APP 写入升级标志:停留 BootLoader

网口升级不应该在 APP 正常有效时随便擦除 APP。要先进入 BootLoader 升级模式,再打开 W5500 升级端口。

4. 网络参数和 Socket 规划

基础版本使用静态 IP。

示例网络参数:

#defineBOOT_NET_SOCKET0#defineBOOT_NET_PORT5000Ustaticwiz_NetInfo g_boot_net_info={.mac={0x00,0x08,0xDC,0x11,0x22,0x33},.ip={192,168,1,88},.sn={255,255,255,0},.gw={192,168,1,1},.dns={8,8,8,8},.dhcp=NETINFO_STATIC};

PC 和设备在同一个网段时,上位机连接:

设备 IP:192.168.1.88 端口:5000 协议:TCP

Socket 分配:

Socket 0:BootLoader 升级 TCP Server Socket 1~7:暂不使用

BootLoader 里不要一开始就堆太多网络功能。DHCP、DNS、HTTP、MQTT 都可以放到 APP 里。BootLoader 阶段只保留最小升级通道。

5. W5500 初始化代码

WIZnet 官方 ioLibrary 使用一组回调适配 SPI 和片选。

底层先准备几个函数:

externSPI_HandleTypeDef hspi1;#defineW5500_CS_GPIO_PortGPIOA#defineW5500_CS_PinGPIO_PIN_4#defineW5500_RST_GPIO_PortGPIOA#defineW5500_RST_PinGPIO_PIN_3staticvoidW5500_Select(void){HAL_GPIO_WritePin(W5500_CS_GPIO_Port,W5500_CS_Pin,GPIO_PIN_RESET);}staticvoidW5500_Unselect(void){HAL_GPIO_WritePin(W5500_CS_GPIO_Port,W5500_CS_Pin,GPIO_PIN_SET);}staticuint8_tW5500_ReadByte(void){uint8_ttx=0xFFU;uint8_trx=0U;(void)HAL_SPI_TransmitReceive(&hspi1,&tx,&rx,1U,100U);returnrx;}staticvoidW5500_WriteByte(uint8_tdata){(void)HAL_SPI_Transmit(&hspi1,&data,1U,100U);}staticvoidW5500_Reset(void){HAL_GPIO_WritePin(W5500_RST_GPIO_Port,W5500_RST_Pin,GPIO_PIN_RESET);HAL_Delay(10);HAL_GPIO_WritePin(W5500_RST_GPIO_Port,W5500_RST_Pin,GPIO_PIN_SET);HAL_Delay(100);}

注册回调并初始化 W5500:

#include"wizchip_conf.h"#include"socket.h"staticint32_tBootNet_Init(void){uint8_ttx_size[8]={2,2,2,2,2,2,2,2};uint8_trx_size[8]={2,2,2,2,2,2,2,2};W5500_Reset();reg_wizchip_cs_cbfunc(W5500_Select,W5500_Unselect);reg_wizchip_spi_cbfunc(W5500_ReadByte,W5500_WriteByte);if(wizchip_init(tx_size,rx_size)!=0){return-1;}ctlnetwork(CN_SET_NETINFO,(void*)&g_boot_net_info);return0;}

这里每个 Socket 分配 2KB TX、2KB RX。W5500 内部总共有 32KB Buffer,基础升级只用 Socket 0,也可以给 Socket 0 分配更大缓存。

例如只使用 Socket 0:

uint8_ttx_size[8]={8,0,0,0,0,0,0,0};uint8_trx_size[8]={8,0,0,0,0,0,0,0};

先用 2KB 调通,再按实际吞吐调整。

6. 建立 TCP Server

W5500 的 TCP Server 状态大致如下:

SOCK_CLOSED | v socket() | v SOCK_INIT | v listen() | v SOCK_LISTEN | v PC 连接 | v SOCK_ESTABLISHED

封装一个 TCP Server 维护函数:

staticint32_tBootNet_ServerProcess(void){uint8_tstate;int32_tret;state=getSn_SR(BOOT_NET_SOCKET);switch(state){caseSOCK_CLOSED:ret=socket(BOOT_NET_SOCKET,Sn_MR_TCP,BOOT_NET_PORT,0);if(ret!=BOOT_NET_SOCKET){return-1;}break;caseSOCK_INIT:if(listen(BOOT_NET_SOCKET)!=SOCK_OK){close(BOOT_NET_SOCKET);return-1;}break;caseSOCK_LISTEN:break;caseSOCK_ESTABLISHED:return1;caseSOCK_CLOSE_WAIT:disconnect(BOOT_NET_SOCKET);close(BOOT_NET_SOCKET);break;default:close(BOOT_NET_SOCKET);break;}return0;}

等待上位机连接:

staticint32_tBootNet_WaitClient(uint32_ttimeout_ms){uint32_tstart=HAL_GetTick();while((HAL_GetTick()-start)<timeout_ms){int32_tstate=BootNet_ServerProcess();if(state==1){return0;}HAL_Delay(10);}return-1;}

BootLoader 可以在串口打印状态:

NET: init w5500 NET: ip 192.168.1.88 NET: listen 5000 NET: client connected

7. 把 TCP 接收适配成 YMODEM 字节接口

YMODEM 接收层需要两个底层函数:

int32_tBoot_RecvByte(uint8_t*data,uint32_ttimeout_ms);voidBoot_SendByte(uint8_tdata);

串口版本底层是HAL_UART_Receive()HAL_UART_Transmit()

W5500 版本底层换成recv()send()

staticint32_tBootNet_Send(constuint8_t*data,uint16_tlength){int32_tret;if(getSn_SR(BOOT_NET_SOCKET)!=SOCK_ESTABLISHED){return-1;}ret=send(BOOT_NET_SOCKET,(uint8_t*)data,length);if(ret!=length){return-1;}return0;}staticvoidBootNet_SendByte(uint8_tdata){(void)BootNet_Send(&data,1U);}

接收指定长度:

staticint32_tBootNet_Recv(uint8_t*data,uint16_tlength,uint32_ttimeout_ms){uint32_tstart=HAL_GetTick();uint16_treceived=0U;while(received<length){uint8_tstate=getSn_SR(BOOT_NET_SOCKET);if((state==SOCK_CLOSED)||(state==SOCK_CLOSE_WAIT)){return-1;}if(state!=SOCK_ESTABLISHED){return-1;}int32_tret=recv(BOOT_NET_SOCKET,&data[received],length-received);if(ret>0){received+=(uint16_t)ret;start=HAL_GetTick();continue;}if((HAL_GetTick()-start)>=timeout_ms){return-2;}}return(int32_t)received;}staticint32_tBootNet_RecvByte(uint8_t*data,uint32_ttimeout_ms){if(BootNet_Recv(data,1U,timeout_ms)==1){return0;}return-1;}

这里有一个关键点:TCP 是字节流,不保留发送端的包边界。

上位机一次send()1029 字节,STM32 端可能分多次recv()收到。STM32 端一次recv()到 500 字节、300 字节、229 字节都正常。

所以 YMODEM 层不能假设一次 TCP 接收就是一个完整 YMODEM 包。正确做法是像串口一样按字节读取,或者按指定长度累计读取。

8. YMODEM over TCP 的接收流程

前面串口 YMODEM 的单包接收函数可以继续使用。

把底层函数替换成:

staticint32_tBoot_UartRecvByte(uint8_t*data,uint32_ttimeout_ms){returnBootNet_RecvByte(data,timeout_ms);}staticvoidBoot_UartSendByte(uint8_tdata){BootNet_SendByte(data);}

函数名也可以改成更通用的:

staticint32_tBootPort_RecvByte(uint8_t*data,uint32_ttimeout_ms);staticvoidBootPort_SendByte(uint8_tdata);

这样同一份 YMODEM 代码可以支持串口和网口:

typedefstruct{int32_t(*recv_byte)(uint8_t*data,uint32_ttimeout_ms);void(*send_byte)(uint8_tdata);}BootPortOps_t;

串口端口:

staticconstBootPortOps_t g_uart_port={.recv_byte=BootUart_RecvByte,.send_byte=BootUart_SendByte};

W5500 端口:

staticconstBootPortOps_t g_net_port={.recv_byte=BootNet_RecvByte,.send_byte=BootNet_SendByte};

YMODEM 接收函数改成传入端口:

int32_tBoot_YmodemUpgrade(constBootPortOps_t*port){YmodemPacket_t packet;BootFileInfo_t file_info;uint32_twrite_addr=APP_BASE_ADDR;uint32_treceived_size=0U;port->send_byte(YMODEM_CRC);if(Ymodem_ReceivePacket(port,&packet,1000U)!=YMODEM_PACKET_DATA){return-1;}if(Ymodem_ParseHeaderPacket(packet.data,&file_info)!=0){port->send_byte(YMODEM_CAN);return-1;}if(BootFlash_CheckImageSize(file_info.file_size)!=0){port->send_byte(YMODEM_CAN);return-1;}Boot_BeginUpgrade(APP_BASE_ADDR,file_info.file_size);if(BootFlash_EraseApp(file_info.file_size)!=0){port->send_byte(YMODEM_CAN);return-1;}returnBoot_YmodemReceiveData(port,&file_info,write_addr,&received_size);}

上面只是骨架。核心规则仍然和串口一致:

第 0 包:只解析文件名和文件大小,不写 Flash 第 1 包开始:写入 APP_BASE_ADDR EOT 后:校验 APP,设置 APP 有效标志

网口升级的入口:

int32_tBoot_NetYmodemUpgrade(void){if(BootNet_Init()!=0){return-1;}if(BootNet_WaitClient(30000U)!=0){return-1;}returnBoot_YmodemUpgrade(&g_net_port);}

9. 上位机发送方式

普通串口工具的 YMODEM 功能默认走串口,不一定能直接对 TCP Socket 发送。

网口 YMODEM 需要下面两种上位机之一:

支持 Raw TCP 连接并支持 YMODEM 发送的终端工具 自定义 TCP YMODEM 上位机

调试时可以先做一个简单上位机:

1. 连接 192.168.1.88:5000 2. 等待 BootLoader 发字符 'C' 3. 发送 YMODEM 第 0 包 4. 等待 ACK 和 'C' 5. 发送固件数据包 6. 发送 EOT 7. 发送结束空包

上位机日志可以这样打印:

TCP: connect 192.168.1.88:5000 YMODEM: wait C YMODEM: send header app.bin 58240 YMODEM: send data 1024 / 58240 YMODEM: send data 2048 / 58240 YMODEM: send eot YMODEM: done

BootLoader 端日志:

NET: client connected YMODEM: wait header YMODEM: file app.bin, size 58240 FLASH: erase app YMODEM: receiving YMODEM: received 1024 / 58240 YMODEM: received 2048 / 58240 YMODEM: eot APP: verify ok APP: set valid SYS: reset

如果上位机只是普通 TCP 发送文件,不走 YMODEM 流程,BootLoader 会一直等待第 0 包格式,升级不会成功。

10. 断线、超时和重复升级处理

网口比串口多一个问题:TCP 连接可能随时断开。

常见情况:

网线被拔掉 交换机断电 PC 上位机异常退出 TCP 连接超时 W5500 Socket 进入 CLOSE_WAIT

处理规则分两段。

10.1 还没擦 APP 前断线

如果还没有解析到 YMODEM 第 0 包,或者文件大小还没检查通过,断线后不动 APP。

未收到合法第 0 包 | +-- 不清 APP 有效标志 +-- 不擦 APP +-- 关闭 Socket +-- 重新 listen

10.2 开始擦 APP 后断线

一旦执行了:

Boot_BeginUpgrade(APP_BASE_ADDR,file_info.file_size);BootFlash_EraseApp(file_info.file_size);

APP 就不能再被认为有效。

断线后处理:

保持 APP 无效状态 关闭 Socket 重新打开 TCP Server 等待重新发送完整固件

基础版本不做断点续传。因为断点续传需要上位机、参数区、YMODEM 流程一起配合,复杂度会上去。

现场产品更稳的做法是:

升级失败 -> APP 无效 -> BootLoader 等待重新完整升级

10.3 Socket 异常恢复

封装一个 Socket 复位函数:

staticvoidBootNet_ResetSocket(void){disconnect(BOOT_NET_SOCKET);close(BOOT_NET_SOCKET);}

接收失败时调用:

staticint32_tBootNet_HandleUpgradeError(void){BootNet_ResetSocket();while(1){if(BootNet_WaitClient(0xFFFFFFFFU)==0){returnBoot_YmodemUpgrade(&g_net_port);}}}

不要在升级失败后直接跳 APP。APP 有效标志已经被清除,就停留在 BootLoader。

10.4 超时时间设置

不同阶段超时时间可以分开设置:

#defineBOOT_NET_WAIT_CLIENT_TIMEOUT_MS30000U#defineBOOT_YMODEM_WAIT_HEADER_MS30000U#defineBOOT_YMODEM_PACKET_TIMEOUT_MS10000U#defineBOOT_NET_IDLE_TIMEOUT_MS60000U

第 0 包可以等久一点,因为上位机连接后可能还没点发送。

数据包接收阶段不能无限等。长时间没有数据,就关闭连接,重新进入等待升级。

11. 调试日志和状态码

网口升级调试时,串口日志仍然很有用。

可以定义状态码:

typedefenum{BOOT_NET_STATE_IDLE=0,BOOT_NET_STATE_INIT,BOOT_NET_STATE_LISTEN,BOOT_NET_STATE_CONNECTED,BOOT_NET_STATE_WAIT_YMODEM,BOOT_NET_STATE_ERASE,BOOT_NET_STATE_RECEIVE,BOOT_NET_STATE_VERIFY,BOOT_NET_STATE_DONE,BOOT_NET_STATE_ERROR}BootNetState_t;

日志不要每包都刷太多,低速串口打印会影响接收节奏。

可以按 4KB 或 16KB 打印一次:

staticvoidBoot_LogProgress(uint32_treceived,uint32_ttotal){if((received&0x3FFFU)==0U){printf("YMODEM: %lu / %lu\r\n",received,total);}}

基础日志:

NET: init NET: link ok NET: listen 5000 NET: connected YMODEM: header ok FLASH: erase ok YMODEM: receive 16384 / 58240 YMODEM: receive 32768 / 58240 APP: verify ok APP: valid SYS: reset

失败日志:

NET: disconnected YMODEM: packet timeout FLASH: write failed APP: crc failed BOOT: wait new firmware

12. 常见问题

12.1 PC 能 ping 通设备,但连不上 5000 端口

检查:

  • BootLoader 是否真的进入升级模式
  • Socket 是否进入SOCK_LISTEN
  • 端口号是否一致
  • PC 防火墙是否拦截
  • W5500 网关和子网掩码是否配置正确

如果 BootLoader 很快跳到 APP,TCP Server 还没来得及监听,也会表现为端口连不上。

12.2 TCP 已连接,但上位机一直不发送

检查 BootLoader 是否发送字符C

YMODEM 发送端通常要等接收端发C后才开始发送第 0 包。

如果 BootLoader 没发C,上位机可能一直等待。

12.3 发送普通 bin 文件失败

YMODEM 不是简单裸发.bin文件。

它需要:

第 0 包:文件名 + 文件大小 第 1 包开始:固件数据 EOT:传输结束 结束空包:YMODEM 批量传输结束

只用 TCP 工具直接发送.bin,BootLoader 不能按 YMODEM 解析。

12.4 接收一半失败

重点看:

  • TCP 是否断开
  • W5500 Socket 是否进入SOCK_CLOSE_WAIT
  • recv()是否长期返回 0
  • Flash 写入是否耗时过长
  • YMODEM 包超时时间是否太短
  • 上位机是否严格等待 ACK 后再发下一包

如果上位机连续发送太快,而 BootLoader 又在阻塞擦写 Flash,容易出现接收节奏问题。

可以先降低发送速度,确认流程稳定后再优化。

12.5 APP 写入成功,但复位后不运行

按前几篇的检查顺序来:

  • APP 链接地址是否正确
  • APP 中断向量表是否重定位
  • APP_BASE_ADDR + 0是否为 SRAM 地址
  • APP_BASE_ADDR + 4是否为 APP Reset_Handler
  • YMODEM 第 0 包是否误写入 APP 区
  • APP 有效标志是否写入
  • APP CRC 是否匹配

网口升级只是传输方式不同,跳转失败的根因仍然多半在 APP 地址、向量表、Flash 写入和有效标志。

12.6 多台设备同时升级怎么处理

基础版本按 IP 单台升级。

多台设备可以使用:

每台设备固定不同 IP 上位机按 IP 列表逐台连接升级 升级完成后等待设备重启 再升级下一台

不要让多个上位机同时连同一台设备的升级 Socket。BootLoader 阶段只保留单连接逻辑。

13. 总结

W5500 网口升级的核心不是重新写一套升级协议,而是把已有的 YMODEM 接收逻辑搬到 TCP 字节流上。

这篇的关键点:

  • BootLoader 用 W5500 建立 TCP Server
  • PC 上位机作为 TCP Client 连接设备
  • TCP 是字节流,接收端要累计读取,不能假设一次recv()就是一包
  • YMODEM 第 0 包只解析文件名和文件大小
  • 文件大小合法后再清 APP 有效标志、擦除 APP
  • 第 1 包开始写入 APP Flash
  • 写完后校验 APP,再写有效标志
  • TCP 断开后关闭 Socket,重新等待完整升级
  • APP 无效时不跳转 APP

串口升级和网口升级可以共用同一套 YMODEM 状态机、同一套 Flash 擦写接口、同一套 APP 校验和参数区逻辑。底层只替换收发字节接口,BootLoader 的整体结构会更清楚。

参考标签

STM32 BootLoader W5500 YMODEM IAP TCP 网口升级 嵌入式 单片机
http://www.jsqmd.com/news/975410/

相关文章:

  • MicroPython嵌入式开发:从核心原理到硬件交互实战
  • 如何使用Video2X将低清视频无损放大到4K:AI视频增强完整指南
  • Genesis Plus GX:免费世嘉模拟器终极指南与跨平台安装教程
  • PMSM无感FOC控制实战包:Simulink建模→滑模观测器→IF启动→dsPIC33实测全流程
  • 2026年6月天津滨海新区继承律所测评!规划家族财富传承/信托/股票期权/不动产 - 资讯纵览
  • Steamless:终极SteamStub DRM移除工具完整指南
  • MonkeyCode 无障碍设计:让AI编程工具对每个人都友好
  • 终极网盘直链下载助手:九大平台全速下载的完整解决方案
  • 如何用AI在3分钟内制作专业短视频:Pixelle-Video终极指南
  • 3步打造你的专属桌面萌宠:BongoCat跨平台互动猫咪指南
  • 都市领航教育:会计培训课程之会计初级实操培训班课程内容亮点及学习大纲 - 左岸花开Acorn
  • 车载SoC电源管理实战:基于NXP PMIC的MT2712供电与功能安全设计
  • 百兆以太网硬件地址过滤:CAM与FPGA协同设计实战
  • AI应用开发相关知识
  • MonkeyCode 与国产大模型:通义千问、DeepSeek、GLM的适配之路
  • 2026Ecosentinel项目实训
  • 避坑指南:手把手教你搞定宝兰德BES 9.5.2单实例的分离安装与控制台访问
  • STM32F407 USB高速设备开发全套资源:KEIL工程+Windows驱动+CDC/MSC/HID示例
  • 影刀RPA多店铺跨店营销实战:统一满减活动配置与跨店订单自动分账系统
  • 免费视频去水印在线工具有哪些?实测推荐,免费视频去水印在线工具怎么选? - 工具软件使用方法推荐
  • 终极怪物猎人世界插件HunterPie:三步快速配置,新手也能轻松掌握游戏数据
  • 生成式音频:从TTS到语义驱动的多模态声音生成
  • Winform力臂动态演示控件:带角度调节、平滑动画和四向手形切换
  • 基于MC68HC11E9的步进电机控制系统:从硬件驱动到软件闭环详解
  • LPC55S36 Cortex-M33 CoreMark移植优化实战:性能与能效深度调校
  • Defender Control终极指南:3步永久禁用Windows Defender的完整教程
  • MonkeyCode 开源安全审计:第三方依赖风险管理与供应链安全
  • 2026滁州婚纱摄影TOP5排名|真实口碑实力榜单,备婚新人必看指南 - charlieruizvin
  • 学化妆哪家机构强?2026新手择校终极指南 - 品牌测评鉴赏家
  • 12个开源组件:构建你的智能知识管理系统