简介:面向嵌入式与物联网开发者的STM32+W5500远程固件升级上位机源码包,基于C# WinForms开发,用于通过以太网对远端设备进行固件下发、校验与更新,是网络化设备维护的实用工具。压缩包约76KB,共35个文件,核心包含9个C#源文件、界面设计文件、Visual Studio工程文件、可执行程序及配置文档,其中源文件与工程文件适合二次开发,exe可直接运行试用。工程结构划分清晰,界面逻辑与通信逻辑分离,便于定位和复用。目前已有1581人学习下载。这套上位机工程覆盖TCP连接建立、固件分包传输、CRC/MD5校验、安全写入、重启验证与状态反馈等完整环节;同时展示主窗口及控件布局,便于根据界面元素快速定位对应代码。对于正在实现远程升级功能的开发者,这套源码可帮助理解上位机与STM32+W5500的协同机制,节省从零搭建的时间;也可作为C#网络编程和物联网设备管理教学的案例。 前一阵子给客户做了一台工控设备的远程升级改造,设备装在现场,主控是STM32F103,网口用的W5500,上位机这边要能通过网络把新固件直接推过去,不用再拎着仿真器往现场跑。整个系统从Bootloader设计到上位机联调,踩了不少坑,也总结出一套比较稳的流程。这篇就完整记录下来,给同样在做STM32+W5500远程更新项目的朋友一个参考。
1. 为什么STM32远程升级绕不开上位机
1.1 远程升级的本质:Bootloader + App 双区协作
先把概念理清楚。STM32的远程更新,本质上就是IAP(In-Application Programming),意思是程序在运行过程中自己擦写自己的Flash。但这里有个物理限制:MCU不能同时从Flash执行代码又对Flash做擦写操作,所以必须把程序分成两个独立的部分。
- Bootloader区:放在Flash的低地址,负责和上位机通信、接收固件、写入App区。它一般不更新,或者只有在特殊情况下才更新。
- App区:放在高地址,是实际业务逻辑程序,就是你要远程更新的目标。
开机先跑Bootloader,Bootloader根据升级标志位决定是跳转到App正常启动,还是停留在Bootloader等待接收新固件。App运行过程中如果收到升级指令,就置一个标志位然后软复位,重启后进入Bootloader执行升级流程。
1.2 上位机在这个系统里到底扮演什么角色
很多人以为上位机就是个"发固件的工具",点个按钮把.bin文件扔出去就完事。真正做产品化之后你会发现,上位机要做的事情远不止这些。
它是整个升级流程的控制中枢:负责和Bootloader完成TCP连接、发送握手报文、切分二进制固件并将每帧数据按协议封装成帧、接收并解析Bootloader回传的确认帧、维护发送窗口与超时重传、实时显示每包数据的传输状态和升级进度。
同时,上位机还承担着双通道职责。最稳妥的做法是"TCP下发固件 + 独立通道回读状态"。我这边通常让Bootloader在升级过程中把关键日志通过另一个串口打印出来,或者干脆让上位机在TCP链路之外再用串口监听设备状态,这样即使TCP链路本身出了问题,也能从上位机的串口窗口看出设备卡在哪一步。
所以,上位机是"调度者"而不是"搬运工"。整个远程升级系统的核心逻辑和异常处理,有一大半其实跑在PC侧的软件里,设备的Bootloader反而只需要做成一个尽量简单、稳如老狗的小程序。
2. Flash分区与跳转逻辑:远程升级的地基
2.1 分区规划:Boot区、App区和参数区怎么切
我在STM32F103VC(256KB Flash)上做过的典型分区方案如下。不同芯片Flash大小不同,但分区思路是通用的。
| 分区 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 | 32KB | 升级程序、启动判断 |
| App区 | 0x08008000 | 192KB | 业务固件 |
| 参数区 | 0x08020000 | 8KB | 升级标志、版本号、校验记录 |
| 预留区 | 0x08022000 | 其余 | 日志存储等 |
App区起始地址定为0x08008000,意味着App的Flash可用空间从128KB开始(偏移32KB),所以实际的App起始地址是0x08008000。这里要注意STM32的Flash是按扇区管理的,F103的扇区大小是1KB(小容量)或2KB(中容量),F103VC中容量每扇区1KB。所以32KB的Bootloader正好是32个扇区,App区起始地址自然对齐到扇区边界。
参数区单独划出来,不要和Bootloader或App混在一起。因为升级标志位在升级过程中要被反复擦写,放App区的话,每次App更新都会把这个区覆盖掉。放Bootloader区则会增加Bootloader的复杂度。独立参数区还能顺便存放历史升级记录、当前固件版本号,方便上位机查询。
2.2 跳转代码的实现细节
Bootloader跳转到App的代码,网上有很多版本,但我自己写的这段比较精简可靠:
typedef void (*pFunction)(void); void JumpToApp(uint32_t app_addr) { uint32_t app_stack_addr = *(volatile uint32_t *)app_addr; pFunction app_entry = (pFunction)(*(volatile uint32_t *)(app_addr + 4)); // 检查栈顶地址是否落在RAM地址范围内 if ((app_stack_addr & 0xFFF00000) != 0x20000000) { return; // 栈顶异常,说明App区没有有效程序 } // 关闭全局中断 __disable_irq(); // 重设SysTick,防止进入App后异常 SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; // 设置向量表偏移 SCB->VTOR = app_addr; // 跳转 __set_MSP(app_stack_addr); app_entry(); while (1); }这段代码有几个关键点:
- 入口地址是
app_addr + 4处存放的值,因为Cortex-M3的向量表第一个字是栈顶指针(MSP),第二个字才是复位向量。 - 检查
app_stack_addr的高位是否是0x20000000,这一步能有效防止跳到一片空白Flash导致的硬件异常。我见过不少跳转失败直接进HardFault的案例,多半就是漏了这个判断。 - 跳转前一定要关中断、清零SysTick。因为Bootloader里可能开启了定时器中断,如果不清理,跳到App后中断服务程序地址还是Bootloader的,一触发就死机。
- 重新设置
SCB->VTOR是在跳转之前,不是之后。App启动代码里通常也会设置一次,但Bootloader先设好更保险。
2.3 升级标志位和版本管理:别让设备变成砖
升级标志位我用了一个结构体存在参数区:
typedef struct { uint32_t magic; // 固定值 0x55AA55AA,判断参数区是否有效 uint32_t upgrade_flag; // 1表示等待升级,0表示正常启动 uint32_t app_version; // App版本号 uint32_t upgrade_time; // 升级时间戳 uint32_t crc32; // 参数区CRC校验 } sys_param_t;升级流程是这样:
- App运行时收到上位机传来的"准备升级"指令,先校验固件大小、版本号等基本信息是否合法。
- App设置
upgrade_flag = 1,然后软复位。 - Bootloader启动后检查
upgrade_flag,如果是1就进入升级模式,这时候先不跳转App,而是等上位机连接、发固件。 - 固件接收完成、校验通过后,把
upgrade_flag清0,然后跳转App。 - 如果升级中途断电或者通信失败,Bootloader检测到固件无效(校验失败),不会跳转,而是保持在Bootloader等上位机重新连接,不会变砖。
这里有个容易被忽略的细节:Bootloader接收固件时,不能边收边把App区擦掉就写。正确做法是先擦除App区,然后一扇区一扇区地写入。如果固件接收了一半断掉了,App区就是一个残缺状态。这时upgrade_flag仍然是1,Bootloader不会跳转进入残缺的App,所以设备仍然是安全的,重新连接上位机继续升级即可。
3. W5500通信链路:硬件协议栈带来的稳定性保障
3.1 为什么选W5500而不是软件协议栈
做一个远程升级系统,TCP链路稳定性是生命线。我选W5500而不是直接用STM32自带以太网MAC加软件TCP/IP协议栈(比如lwIP),原因很直接:
- W5500内置硬件TCP/IP协议栈,TCP的三次握手、四次挥手、重传、ACK确认都由芯片自己完成,MCU只要往Socket的发送缓冲区写数据、从接收缓冲区读数据就行。这不仅省掉了lwIP移植的麻烦,还省掉了CPU处理协议栈的开销。
- SPI接口简单,STM32随便哪几个GPIO模拟或者用硬件SPI都能驱动,电路设计复杂度低。
- 稳定性好,协议栈的bug概率远低于自己移植的软件栈。远程升级这种场景下,一个ACK丢包处理不当就可能导致固件传一半卡死,W5500的硬件协议栈在这方面做得很成熟。
- 网上有大量现成的驱动代码,包括W5500官方驱动库,二次开发成本低。
W5500关键资源如下:
| 资源 | 参数 |
|---|---|
| SPI接口速率 | 最高约33MHz |
| Socket数量 | 8个,同时支持8路独立连接 |
| 内部缓冲 | RX/TX各16KB,可灵活分配到各Socket |
| 供电电压 | 3.3V |
| 封装 | LQFP48,还有更小的QFN封装 |
| 型号版本寄存器 | 地址0x0039,复位后应读出0x04 |
3.2 SPI初始化与版本寄存器自检
W5500和STM32之间是标准的SPI连接,我习惯用SPI2(具体用哪个SPI看硬件设计,关键是速率和模式要配好)。初始化时需要特别注意:W5500工作在SPI Mode 0和Mode 3都行,但官方驱动默认是Mode 0,也就是CPOL=0、CPHA=0,时钟空闲为低电平、第一个边沿采样。如果你用的是Mode 3,驱动里没改的话通信直接失败。
初始化的第一步,不是急着设IP、MAC,而是读版本寄存器0x0039验证SPI通路是否正常。这个寄存器在W5500里固定返回0x04。如果读出来不是0x04,说明SPI硬件连接有问题,或者MISO/MOSI接反了、片选信号被拉低了。
uint8_t w5500_read_version(void) { uint16_t addr = 0x0039; uint8_t data = 0; W5500_CS_LOW(); // 控制字节:读取模式(0x00),地址高字节(0x00),地址低字节(0x39) // W5500的控制字节格式:第1位为0表示读,第2位为0表示使用VDM模式 spi_transfer(0x00); // 读模式 + 偏移地址高位 spi_transfer(0x39); // 偏移地址低位 data = spi_transfer(0x00); // 读数据 W5500_CS_HIGH(); return data; }换过几款W5500的板子,我发现板厂打样时最容易出的问题就是SPI引脚布线错误或者虚焊。所以每次拿到新板子,我第一件事就是读版本寄存器,这一步通过了才继续设置网络参数。
3.3 Socket通信逻辑:Bootloader端的TCP服务端实现
远程升级场景下,Bootloader通常作为TCP服务端,上位机作为客户端主动连接。这样设备在现场不需要固定IP,上位机通过设备的MAC和IP去连就行。
Bootloader里的核心逻辑:
初始化W5500,设置MAC、IP、子网掩码、网关。如果设备支持DHCP也可以,但现场升级场景更推荐固定IP,免得设备IP变了上位机找不到。
打开Socket 0,设置为TCP服务端模式,绑定监听端口(比如6000)。
循环检查Socket状态,如果有客户端连接就进入升级服务流程。
void w5500_socket_listen(void) { // 打开Socket 0,TCP服务端模式,端口6000 setSn_MR(0, Sn_MR_TCP); setSn_PORT(0, 6000); setSn_CR(0, Sn_CR_OPEN); while(getSn_SR(0) != SOCK_INIT); // 进入监听状态 setSn_CR(0, Sn_CR_LISTEN); while(getSn_SR(0) != SOCK_LISTEN); }这里要插一句,W5500的Socket操作是命令-状态机驱动的。每次执行一个操作(打开、监听、连接、断开、发送、接收、关闭),都要setSn_CR写命令寄存器,然后轮询getSn_SR确认状态寄存器变成预期的下一个状态。很多新手常犯的错误是写完命令不看状态,直接开始收发数据,结果有时候能通、有时候不规规矩矩,就很抓狂。
实际使用中还有一个坑:W5500的Socket状态机在连接断开后不会自动回到监听状态。所以Bootloader处理完一次升级、或者检测到对端断开后,要显式地关闭Socket,再重新走一遍监听流程。否则设备就一直卡在那,上位机重连也进不来。
4. 升级协议设计:能抗丢包能续传的方案才是好方案
4.1 帧格式设计:固定帧头、可变长度、CRC校验
升级协议是整套系统的中枢,规定好了,上位机和Bootloader都照着这规矩来。
我的帧格式比较简单:
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| 帧头 | 2 | 固定为0xAA 0x55,接收方用它做帧同步 |
| 命令字 | 1 | 0x01握手, 0x02开始传输, 0x03固件数据, 0x04传输结束, 0x05校验结果 |
| 长度 | 2 | 数据字段长度,小端模式 |
| 序号 | 2 | 包序号,用来做重传和乱序处理 |
| 数据 | N | 固件数据或附加信息 |
| CRC16 | 2 | 从命令字到数据结尾的CRC16校验(Modbus RTU多项式) |
选CRC16而不是CRC8,是因为固件数据包通常256或512字节,CRC8检错能力在传输噪声面前不够用。而且CRC16算法的实现代码在STM32上跑一遍,开销很小。
有几个设计时容易踩坑的细节:
- 帧头和CRC放在帧的两端,帧头做同步定位,CRC放在帧尾校验收尾,这是RS485和以太网协议里最常见的模式。万一接收端丢了一个字节,基于帧头重同步、CRC接收失败丢弃整帧,比掐着固定帧长硬解析要灵活得多。
- 序号字段必须有,它是实现断点续传和重传的基础。上位机发送第N帧后,Bootloader回应第N帧的ACK,上位机就知道第N帧成功了,可以发N+1;如果超时没收到ACK,上位机重发第N帧,Bootloader根据序号丢弃重复帧。没有序号的重传机制,很容易在重复发帧时把数据写两遍。
4.2 四阶段升级流程:握手、传输、校验、重启
整个升级过程我分成四个阶段:
阶段一:握手
上位机连接Bootloader的TCP端口后,发0x01握手命令,附带当前固件版本号和文件大小。Bootloader收到后检查版本号是否大于当前版本(防降级),检查文件大小是否在App区域容量范围内(防越界),都OK就回复握手成功,否则回复失败原因。
这里有个重要的安全检查:不要允许降级。如果设备上已经烧了V2.0的固件,上位机却要推一个V1.8的旧固件,这通常意味着操作失误或文件选错,直接拒绝。防止把设备升级到比出厂版本还低的错误状态。
阶段二:传输
上位机把.bin固件文件切成固定长度(比如512字节)的小包,逐一发送。Bootloader每收到一个包,校验CRC和序号,写入App区Flash,然后回ACK。这里要注意,写Flash的速度远低于网络接收速度,所以上位机不能一个劲地猛发,必须等ACK收到后再发下一包。我实测一般是"发一包、等ACK、再发下一包",一个2MB的固件大约需要几分钟到十几分钟,根据网络延迟和擦写速度而定。
阶段三:校验
所有数据包发送完成后,上位机发送0x04传输结束命令,Bootloader对App区整段固件做一次CRC32校验,把计算结果发给上位机。
上位机同时用文件内容也算一个CRC32,两边比对。这里我用CRC32而不是传输过程中的CRC16,是因为CRC16在数MB级别的大固件上碰撞概率已经不可忽略了,最终校验还是得用更强的CRC32。
阶段四:重启
校验通过后,Bootloader清除升级标志位,回复上位机"升级成功",然后延时500ms让上位机收到确认,再软复位跳转到新App。上位机此时可以重新TCP连接App的端口,接收App上报的新版本号确认升级生效。
4.3 断点续传和超时重传:保升级网络抖动不翻车
真实场景下谁也不能保证网络一路平稳。现场如果用的是中转路由器,偶尔丢个包太正常了。所以断点续传不是可选项,是刚需。
我的做法:
- Bootloader侧记录当前已写入Flash的最后一个包序号,把它保存到参数区的另一个变量里。每次收到新的数据包,先判断序号:小于等于已写序号说明是重复帧,直接回ACK但不再写Flash;等于已写序号+1说明是正常顺序帧,写Flash并更新已写序号。
- 上位机侧设置发送超时:发出一个数据包后,3秒内没收到ACK就重发同一帧。连续重发5次仍没回应,判定链路异常,断开TCP连接,提示用户检查设备网络。
- 如果TCP断开,设备端Bootloader进入"等待重连"状态,已写入的固件数据仍然保留在Flash里。上位机重新连接后,发一个查询命令问Bootloader当前已写到的序号,从这个序号继续发,而不是整个固件重新传一遍。
现场实操下来这个方法很省心:哪怕在信号有抖动的工业现场,只要链路能恢复,升级基本都能完成,不用从头再来。
5. 上位机实现:从串口助手到专用工具的进化
5.1 上位机技术选型:C#、Qt还是LabVIEW
上位机的实现方式,热搜词里也出现了C#上位机、MFC上位机、Qt上位机、LabVIEW实现Bootloader上位机等各种方向。我根据自己的使用经验做个对比:
| 技术路线 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| C# (WinForms/WPF) | 开发效率高,串口和TCP库非常成熟,UI方便 | 仅限Windows | 最推荐,工业用户基本都是Windows |
| Qt (C++/Python) | 跨平台,性能好,界面现代 | 开发周期略长,Qt环境配置稍麻烦 | 需要跨平台或对界面要求高的场景 |
| LabVIEW | 图形化编程,调试直观 | 部署时需要运行时引擎,安装包大 | 实验室设备、测试台架集成 |
| Python (PySide/PyQt) | 上手快,适合快速原型 | 打包后体积大,性能不如编译型 | 原型验证、内部工具 |
我个人主力用C#。WPF的现代UI在处理"固件文件选择、版本信息显示、进度条、日志窗口"这类界面时非常顺手,而且System.IO.Ports和System.Net.Sockets两个命名空间直接搞定所有通信需求,不需要装任何第三方库。
5.2 核心代码逻辑:分包发送与ACK处理
上位机发送固件的核心逻辑,可以简化为这样一个流程:
// 读取固件文件 byte[] firmware = File.ReadAllBytes(binFilePath); // 按512字节分包 int packetSize = 512; int totalPackets = (firmware.Length + packetSize - 1) / packetSize; for (int i = currentIndex; i < totalPackets; i++) { byte[] payload = new byte[packetSize]; Array.Copy(firmware, i * packetSize, payload, 0, Math.Min(packetSize, firmware.Length - i * packetSize)); byte[] frame = BuildFrame(0x03, i, payload); // 命令字0x03,带序号 bool ack = false; for (int retry = 0; retry < 5; retry++) { tcpClient.Client.Send(frame); // 等待ACK,设置3秒超时 if (WaitAck(i, 3000)) { ack = true; break; } } if (!ack) { Log("包序号" + i + "发送失败,重试5次未收到ACK"); break; } UpdateProgress(i, totalPackets); }实际代码里还有几个细节要处理:
- 线程模型:不能把发送循环放在UI线程里,否则界面会卡死。我用
BackgroundWorker或者在后台线程里跑循环,进度条通过Dispatcher.Invoke更新到界面。如果你写的上位机在传输大固件时界面无响应,八成就是这个原因。 - 一次性读取整个固件文件进内存:几百KB到几MB的固件都没问题,不用做流式读取,代码更简单。
- 日志输出:每一帧的序号、ACK状态、重试情况全部输出到日志窗口,方便排查现场问题。
5.3 用户体验:别让操作者对着黑框干瞪眼
一个合格的升级上位机,光有功能还不行,人机交互做得不好,现场工程师会骂人。
我做上位机时几个基本要求:
- 升级前展示固件的基本信息:文件名、版本号、大小、适用设备型号,让操作者确认没选错文件。
- 进度条分两级显示:一级显示当前包的发送进度(百分比),一级显示当前包的ACK状态(绿色表示成功、红色表示重试中)。这样即使整体进度条不动,操作者也知道网在跳。
- 明确的失败提示:网络断开、设备无响应、校验失败,都要弹窗或者状态栏高亮提示,并给出下一步建议(重新连接,或联系设备负责人)。
- 日志窗口显示原始帧:开发阶段一定要把原始收发帧打出来,哪怕加个开关,默认关。因为现场出了问题,光看日志文字往往不够,得看到原始字节流才能定位。
- 保存升级日志到文件:每次升级操作结束,自动把日志存成一个文本文件,命名带上时间戳。现场出问题、后面追责或排障都靠它。
这些细节看起来不起眼,但真正在上产线上用起来,体验差异是很明显的。
6. 复盘实际排障:三个最能让人耗掉半天的坑
6.1 W5500的SPI通信异常:版本寄存器救了我
有次调试新板子,程序跑起来W5500就是不工作,检查了所有初始化代码都感觉没问题。后来想起来先读版本寄存器,结果读出0xFF,说明SPI压根没通。
排查下来发现板子上W5500的SCLK引脚被一个0欧电阻接到了STM32的SPI1_SCK,但原理图上MISO和MOSI两路是反的。也就是说STM32的MOSI(PA7)接到了W5500的MISO,而STM32的MISO(PA6)接到了W5500的MOSI,相当于数据在SPI总线上交叉了。W5500虽然支持SPI全双工,但对收发的线序要求很严格,收和发接反就直接读不出来。
从那以后我对每块W5500的板子都是同样的自检流程:直接读版本寄存器,这是比任何外部调试工具都快的方法。另外,W5500复位引脚建议用STM32的一个GPIO控制,软件复位完再读版本寄存器,比直接接RC上电复位更可靠——因为有时候板子供电时序不好,W5500上电复位不完整,软件再补一刀大大降低跑飞概率。
6.2 跳转App失败:栈指针和中断向量惹的祸
一次在实验室联调升级流程,Bootloader接收固件、校验全部成功,只是跳转后App没跑起来,卡在HardFault里。
排查过程很典型:
- 检查跳转代码,栈顶地址判断、关中断、设置VTOR、设置MSP,逻辑都对。
- 检查App编译地址,链接脚本里
FLASH起始地址确实是0x08008000,偏移也对。 - 用ST-Link读Flash内容,发现App区起始位置的第一个字确实写进了正确的栈顶值(0x20005000),第二个字确实是复位向量。
最后定位到问题出在App工程里的中断服务函数没有加__attribute__((used)),被链接器优化掉了。App启动时初始化了外设并开启了中断,但中断向量表里的服务函数地址是空的,一旦中断触发,MCU跳进了一个非法地址,直接HardFault。
这个问题的本质是:跳转本身没有问题,问题出在App程序里没有被正确链接到中断向量表。修复方法是在App的中断服务函数前面加__attribute__((used))。有了这个经验,现在我写App工程时都会先用一个裸机例程验证跳转,确认中断向量表正常后,再往上叠加业务代码。
6.3 升级中途断网的恢复流程
现场遇到过一次升级中途掉电的情况,当时心里咯噔一下——担心设备变砖。结果重启后,设备Bootloader检测到参数区upgrade_flag = 1,App区固件校验失败,于是老老实实停在等待升级状态。上位机重启后重新TCP连接,发一个查询序号命令,从断点处继续传剩下的固件,几分钟后升级就完成了。
事后总结这个能救回设备的核心设计:
upgrade_flag和固件校验状态分开保存,即使固件没传完,标志位也能准确告诉Bootloader"这里有半截固件,不能跳转"。- 参数区用独立Flash存储,并且保存CRC,即便中途掉电,参数区依然有效。
- Bootloader本身不在升级过程中擦写自己,所以Bootloader永远安全,设备永远不会变砖。
基于这些经验,我给这个方案定了个设计原则:Bootloader要尽可能简单、稳定、不依赖外部条件才能运行。它是整套升级系统最后的保险,绝不能因为一个bug导致整个设备变成砖头。所以Bootloader里不要放复杂业务逻辑、不要开太多外设、不要用动态内存分配,连延时都尽量用简单的自旋循环而不用操作系统。
远程升级系统虽然看着只是"发个文件、烧个Flash",但真正把它做可靠,涉及网络协议栈、Flash管理、通信协议设计、上位机工程化、异常恢复等多个环节,每个环节都要考虑到比正常编程更多一两层的极端情况。这套系统做完后,我又在不同型号的STM32上适配过几轮,思路和数据帧结构基本没动,只是改了Flash分区地址和扇区大小参数。如果你正在做类似项目,建议先在一套带串口调试输出的最小系统上把协议跑通,再逐步增加产品功能,不要一上来就写满业务代码——那样一旦出问题,你就不知道是自己业务代码的锅,还是Bootloader的锅了。
最后分享一个小经验:预留一个"强制Bootloader模式"的触发条件,比如上电时检测某个GPIO电平、或者连续收到特定串口数据。这样即使App彻底跑飞了,你还能手动把设备拉回Bootloader重新升级,这比拆机接仿真器省事太多了。
本文还有配套的精品资源,点击获取