资讯详情

资讯详情

STM32F103 GPIO模拟SPI驱动W25Q16实战

简介这份压缩包面向使用STM32F103进行嵌入式存储扩展的开发者与学习者聚焦在不依赖硬件SPI外设的前提下用GPIO软件模拟SPI时序完成W25Q16闪存的读写。内容围绕SPI四种模式、时钟极性与相位配置、片选与数据收发等关键点展开适合硬件SPI资源紧张、需要节省引脚或做低成本Flash方案的场景。包内共78个文件以34个h头文件和32个c源文件为主辅以8个汇编启动文件及Keil工程、说明文档和清理脚本压缩包约311KB代码按SPI模拟层、Flash操作层、板级支持以及串口、定时器等模块组织便于按层阅读和移植。现有4111人学习读者可从中获得可编译的工程框架、W25Q16命令封装与读写流程参考以及软件模拟SPI的时序实现和调试排错思路。1. 板载 SPI 被占用之后用 GPIO 把 W25Q16 挂上 stm32f103手上那块 stm32f103 最小系统板SPI1 的四个脚早被板载屏和调试口分干净了可参数区、字库、日志缓存又必须落在一颗 W25Q16 上。这时候最省事的做法不是改板子而是拿四个空闲 GPIO 把 SPI 时序手动打出来——stm32f103 模拟 SPI 读写 W25Q16 这件事本质就是把寄存器外设换成软件位翻转代价是速率和 CPU 占用换来的是引脚随便挑、时序随便调、移植到别的芯片上改几个宏就能跑。它适合两类人一类是硬件已经定型、外设不够用的另一类是初学 flash 驱动、想看清 CS、SCK、MOSI、MISO 到底怎么配合的。这篇从时序讲到驱动再讲到跨页写和校验代码是能在实板上跑通的那一版。2. 先对准时序再写代码SPI Mode 0 与 W25Q16 命令集的对应关系模拟 SPI 最容易出的问题不是代码写错是时序理解偏了一格。W25Q16 支持 Mode 0 和 Mode 3 两种实际项目里几乎都用 Mode 0时钟空闲为低数据在上升沿被采样。软件模拟没有配置项这回事你必须把这句话翻译成代码里语句的先后顺序顺序错了读回来的就是一堆 0xFF 或者 0x00。2.1 从时序图反推四个相位的先后顺序Mode 0 下一次完整的位传输是这样走的CS 先拉低主机在 SCK 还是低电平的时候把这一位放到 MOSI 上然后 SCK 拉高从机在上升沿采样 MOSI同时把自己的这一位推到 MISO 上主机在 SCK 高电平期间读 MISO最后 SCK 拉低准备下一位。也就是说改数据在低电平采数据在高电平这两件事之间的延时就决定了你能跑多快。写成代码就是四步循环设 MOSI → 拉高 SCK → 读 MISO → 拉低 SCK。中间插的延时不能省F103 在 72MHz 下 GPIO 翻转本身就接近几十纳秒如果从机还没把 MISO 稳定下来你就去读读到的是上一次的残留值——这种错很难查因为它只在某些板子、某些温度下出现非常玄学。提示CS 不是传输开关这么简单。W25Q16 的擦除和编程命令是在 CS 的上升沿才真正启动内部操作的。CS 拉高早了一步命令字节还没发完拉高晚了一步倒没事但如果你在忙等待期间反复拉 CS内部状态机会被打断。2.2 最小命令子集六条命令撑起读写擦W25Q16 的完整命令表有几十条真正常用的就六条。先把这六条跑通剩下的都是锦上添花。命令指令码方向作用关键约束Write Enable0x06写置位状态寄存器 WEL 位每次擦/写前都必须发一次Read Status-10x05读读 BUSY(bit0)、WEL(bit1)可连续读直到 CS 拉高Read JEDEC ID0x9F读返回厂商容量三字节上电自检用固定 0xEF4015Read Data0x03读从任意地址连续读地址可跨页、跨扇区无限制Page Program0x02写写一页最多 256 字节不可跨页越界会回卷到页首Sector Erase0x20写擦除 4KB 一个扇区写之前必须先擦最小单位就是它这张表里最要命的两条约束一条是写前必擦一条是页编程不能跨页。前者很多人第一次用 flash 会忽略直接往有数据的地址写结果新数据和旧数据按位与在一起读出来是乱的后者更隐蔽地址越过 256 字节边界后不会报错而是绕回本页开头把前面的数据覆盖掉属于写完了还觉得自己对的那种翻车。WEL 位的意义也要说清它是一个一次性授权。发完 0x06 之后芯片只允许你执行紧接着的下一条擦除或编程命令执行完自动清零。所以代码里必须严格保持写使能 → 擦/写 → 忙等待这个顺序中间不能插别的写类操作。2.3 位序与 CS 的持有范围W25Q16 是 MSB first一个字节从最高位开始发。软件模拟时如果忘了tx 1这一句发出去的字节位序就反了读 ID 会得到 0x7FF502 之类的怪值——这个错误很典型因为它不崩溃只是所有数据都错容易让人怀疑硬件。CS 的持有范围按命令区分0x06 写使能拉低 → 发 8 位 → 拉高全程就一个字节。0x05 读状态拉低 → 发 0x05 → 连续读若干字节 → 拉高。可以在一次 CS 低电平期间反复读直到 BUSY 清零。0x9F 读 ID拉低 → 发 0x9F → 读 3 字节 → 拉高。0x03 读数据拉低 → 发 0x03 → 发 3 字节地址 → 读 N 字节 → 拉高。0x02 页编程拉低 → 发 0x02 → 发 3 字节地址 → 发 N 字节数据 →拉高此时才真正开始烧写。0x20 扇区擦除拉低 → 发 0x20 → 发 3 字节地址 → 拉高此时才开始擦。注意地址是 24 位、3 个字节、高字节在前。W25Q16 容量 2MB地址范围 0x000000 到 0x1FFFFF实际只用低 21 位最高字节恒为 0x00但协议要求照样发满三个字节少发一个就全乱。3. 从 GPIO 到命令层把模拟 SPI 拆成四个可测的原语驱动不要一上来就写读整个文件这种大函数先做分层最底下是引脚宏中间是字节传输原语上面是命令封装最上面才是业务读写。这样出错时能一段一段往下压不用整块怀疑。3.1 引脚分配与 GPIO 初始化选脚的原则是避开已有外设、尽量挨着同一个端口方便用 BSRR 一次性置位。下面这组是我常用的组合SCK/MOSI 推挽输出MISO 上拉输入CS 推挽输出且上电默认高电平——这条很重要CS 上电是低的话芯片会误判成命令起始后面怎么调都不对。#include stm32f10x.h /* 引脚映射集中在这一层换板子只改这四行 */ #define W25_CS_L() GPIO_ResetBits(GPIOA, GPIO_Pin_4) #define W25_CS_H() GPIO_SetBits(GPIOA, GPIO_Pin_4) #define W25_SCK_L() GPIO_ResetBits(GPIOA, GPIO_Pin_5) #define W25_SCK_H() GPIO_SetBits(GPIOA, GPIO_Pin_5) #define W25_MOSI_L() GPIO_ResetBits(GPIOA, GPIO_Pin_7) #define W25_MOSI_H() GPIO_SetBits(GPIOA, GPIO_Pin_7) #define W25_MISO_R() GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6) void w25_gpio_init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); /* SCK / MOSI / CS推挽输出50MHz 档位留足翻转余量 */ gpio.GPIO_Pin GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_7; gpio.GPIO_Mode GPIO_Mode_Out_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); /* MISO上拉输入防止从机未驱动时读到浮空电平 */ gpio.GPIO_Pin GPIO_Pin_6; gpio.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, gpio); W25_CS_H(); /* 空闲态 CS 必须为高 */ W25_SCK_L(); /* Mode 0空闲时钟为低 */ }参数上要留意的有两点。GPIO_Speed_50MHz不是必须这么快而是 F103 的输出速度档位只有 2/10/50 三档选 50MHz 是为了让上升沿足够陡逻辑分析仪上看不出明显的斜坡如果选 2MHz长一点的杜邦线上波形会变圆高速下从机采样就可能出错。MISO 配上拉是因为 W25Q16 在 CS 为高时 MISO 是高阻态不上拉的话读到的第一位常是随机值。3.2 spi_transfer 原语一个函数定生死整个模拟 SPI 的核心就这么一个函数它同时完成发一个字节和收一个字节这就是 SPI 全双工的本质——你发的同时它也在发。半双工时收到的字节丢掉即可。/* 约 100~200ns 的空转按逻辑分析仪实测的 SCK 频率回调 NOP 数量 */ static inline void spi_dly(void) { __NOP(); __NOP(); __NOP(); __NOP(); } uint8_t spi_transfer(uint8_t tx) { uint8_t rx 0; uint8_t i; for (i 0; i 8; i) { /* 1. 时钟为低时改变 MOSIMode 0 要求 */ if (tx 0x80) { W25_MOSI_H(); } else { W25_MOSI_L(); } tx 1; spi_dly(); /* 2. 上升沿从机在此刻采样 MOSI */ W25_SCK_H(); spi_dly(); /* 3. 时钟高电平期间读 MISO先移位再填最低位 */ rx 1; if (W25_MISO_R()) { rx | 0x01; } /* 4. 下降沿从机在此刻更新 MISO准备下一位 */ W25_SCK_L(); spi_dly(); } return rx; }rx 1的位置是关键必须在读 MISO之前移位这样第一次读到的位经过 8 次移位后落在 bit0符合 MSB first。如果写成先读再移整个字节的位序会整体偏移一位读 ID 会得到看似合理但完全错误的数值。spi_dly()里的 NOP 数量决定了 SCK 频率。F103 在 72MHz 下一次循环加上 GPIO 操作大约十几个周期4 个 NOP 大致能把 SCK 压在 2~3MHz 这一档具体得拿逻辑分析仪抓一次再定。别凭感觉填这是最容易拍脑袋的地方。3.3 命令层封装写使能、忙等待、擦除、页编程有了字节原语上面就是拼命令。先做两个基础设施写使能和忙等待。忙等待做不好后面所有擦写都会随机失败。/* 读状态寄存器 1 */ static uint8_t w25_read_sr1(void) { uint8_t sr; W25_CS_L(); spi_transfer(0x05); sr spi_transfer(0xFF); /* 发什么都行0xFF 是惯例 */ W25_CS_H(); return sr; } /* 写使能发完自动置 WEL无需检查返回值但要确认 WEL 真的置上了 */ static void w25_write_enable(void) { W25_CS_L(); spi_transfer(0x06); W25_CS_H(); } /* 忙等待擦除一个扇区最长几十毫秒超时要给够 */ static int w25_wait_busy(uint32_t timeout_ms) { while (timeout_ms--) { if ((w25_read_sr1() 0x01) 0) return 0; /* BUSY 位清零 */ delay_ms(1); } return -1; } /* 扇区擦除addr 必须 4KB 对齐 */ int w25_sector_erase(uint32_t addr) { w25_write_enable(); W25_CS_L(); spi_transfer(0x20); spi_transfer((addr 16) 0xFF); spi_transfer((addr 8) 0xFF); spi_transfer( addr 0xFF); W25_CS_H(); /* 上升沿触发内部擦除 */ return w25_wait_busy(500); }w25_wait_busy的 timeout 不能省。曾经有个项目在擦除时死循环查了两天才发现是供电电压偏低导致擦除一直不完成——如果没有超时退出整个系统就卡在那里看门狗都救不回来。扇区擦除典型时间 30~50ms整片擦除可能到几秒超时值按最坏情况给。页编程同理只多一步发完地址后连续发数据最后一个字节发完才拉高 CS。/* 页编程addr 所在页内偏移 len 不能超过 256len 由调用者保证 */ int w25_page_program(uint32_t addr, const uint8_t *buf, uint16_t len) { uint16_t i; w25_write_enable(); /* 严格检查 WEL没置上说明上一条命令被打断 */ if ((w25_read_sr1() 0x02) 0) return -1; W25_CS_L(); spi_transfer(0x02); spi_transfer((addr 16) 0xFF); spi_transfer((addr 8) 0xFF); spi_transfer( addr 0xFF); for (i 0; i len; i) { spi_transfer(buf[i]); } W25_CS_H(); /* 上升沿后才真正开始烧写 */ return w25_wait_busy(20); }这里读 WEL 的那两行是血泪经验换来的。正常流程下 WEL 一定置位但如果上一次操作被中断比如中断里也去操作了 flashWEL 可能是 0此时发页编程命令芯片会直接忽略函数返回成功、数据没写进去属于最难查的一类问题。3.4 上电自检读 JEDEC ID 是唯一可信的握手所有外设驱动的第一行测试代码都应该是读 ID。W25Q16 的 0x9F 命令固定返回 0xEF、0x40、0x15 三字节厂商码 0xEF容量码 0x15 对应 2MB。这三个值对不上后面所有代码都不用看了。int w25_check_id(void) { uint8_t id[3]; W25_CS_L(); spi_transfer(0x9F); id[0] spi_transfer(0xFF); id[1] spi_transfer(0xFF); id[2] spi_transfer(0xFF); W25_CS_H(); if (id[0] 0xEF id[1] 0x40 id[2] 0x15) return 0; return -1; /* 调用方把 id[] 打出来比看返回值有用得多 */ }如果读出来是0x00 0x00 0x00优先怀疑 MISO 没接好或者上拉没配如果是0xFF 0xFF 0xFF优先怀疑 MOSI 没发出去或者 CS 根本没拉低如果三个值看着像但整体错位多半是位序或者rx 1的位置写反了。这三种现象对应三类完全不同的错误把 ID 打出来比单看返回值有价值得多。4. 擦、写、读、校验4KB 扇区与 256 字节页的边界处理单条命令跑通只是万里长征第一步业务代码里真正会出问题的是边界。W25Q16 有两套粒度擦除最小 4KB一个扇区编程最大 256 字节一页这两个数字不能混。写入逻辑必须建立在先擦后写、按页拆分这两条规则之上否则跑一次测试看着对跑第二次就全错。4.1 存储结构扇区、页与地址映射2MB 空间按 4KB 切成 512 个扇区每个扇区 16 页每页 256 字节。地址是线性字节地址所以层级大小地址对齐要求操作页 Page256 Baddr % 256 0页编程起点单次最多 256 B扇区 Sector4 KBaddr % 4096 0擦除最小单位擦除只能按扇区来改一个字节这件事在物理上做不到。实际工程里的做法是把需要频繁改的参数集中放在一个扇区里改的时候整块读出来、改内存里的副本、擦除整个扇区、再整块写回去。或者用双扇区乒乓一个写一个备份掉电也不会丢。4.2 跨页写拆包是最简单的解法页编程越界会回卷到本页开头不报错。所以写任意长度数据时必须按本页剩余空间切分。/* 任意地址、任意长度写入内部完成跨页拆分调用前需保证目标区域已擦除 */ int w25_write(uint32_t addr, const uint8_t *buf, uint32_t len) { uint32_t chunk; while (len 0) { /* 当前地址到本页末尾还能放多少字节 */ chunk 256 - (addr 0xFF); if (chunk len) chunk len; if (w25_page_program(addr, buf, (uint16_t)chunk) ! 0) { return -1; } addr chunk; buf chunk; len - chunk; } return 0; }256 - (addr 0xFF)这一句是整个函数的核心它算的是从当前地址到下一个 256 边界还有多少字节。第一次写从页中间开始时chunk 会小于 256正好写到页尾后面每次都是整页 256。这段代码不长但它决定了你的文件系统能不能正常工作——我见过一个日志模块就是因为省了这一步每 256 字节覆盖一次前面的数据跑了一周才发现日志全是乱的。注意这个函数不负责擦除。擦除是扇区级的、耗时的、会清掉整块 4KB 的操作把它隐藏在写函数里会导致每次写都擦一遍寿命和速度都扛不住。擦除时机由上层业务决定这是设计上必须有意识分开的两件事。4.3 读回校验写完不验等于没写模拟 SPI 的速率比硬件 SPI 慢一个数量级但慢有慢的好处出错概率高早点暴露比上线后暴露强。每次写入后立即读回比对是这套驱动最值钱的一步。/* 逐字节读回比对返回第一个不一致的偏移全部一致返回 -1 */ int32_t w25_verify(uint32_t addr, const uint8_t *buf, uint32_t len) { uint32_t i; uint8_t rd; W25_CS_L(); spi_transfer(0x03); spi_transfer((addr 16) 0xFF); spi_transfer((addr 8) 0xFF); spi_transfer( addr 0xFF); for (i 0; i len; i) { rd spi_transfer(0xFF); if (rd ! buf[i]) { W25_CS_H(); return (int32_t)i; /* 返回偏移便于定位是页边界还是首字节 */ } } W25_CS_H(); return -1; }返回偏移而不是简单的布尔值是因为偏移本身携带信息错在偏移 0多半是写使能没生效错在第 256 字节多半是跨页拆分有问题错得零散且每次位置不同多半是时序延时不够。用返回值定位比重新加打印快得多。读命令 0x03 没有长度限制可以一口气读完整个 2MB所以校验大块数据时不用循环发命令一次 CS 低电平就能读完。4.4 速率标定延时到底给多少模拟 SPI 的实际速率取决于spi_dly()里塞了几个 NOP以及编译器优化等级。别信计算拿逻辑分析仪抓 SCK 波形最准。下面是同一块板子、72MHz 主频、-O2 优化下的大致量级NOP 数量SCK 约单字节 8 位耗时读 1KB 理论耗时06~8 MHz~1.3 us~1.3 ms42~3 MHz~3.5 us~3.6 ms101 MHz 左右~8 us~8 ms速率不是越高越好。杜邦线超过 10cm、没有地线回流、或者板上有开关电源干扰时SCK 一快MISO 上就会出现毛刺读出来的数据偶发一位翻转而且只在某些板子上出现。工程上的稳妥做法是把 SCK 压到自己实测能稳定跑的最高频率的一半剩下的性能用页读取来补——一次 CS 读整页比多次单字节读快得多。注意擦除和编程的耗时跟 SPI 速率无关它们是芯片内部的高压操作。一个 4KB 扇区擦除典型 30~50ms页编程典型 0.7~3ms。写 1MB 数据的瓶颈几乎全在擦除上优化 SPI 时序的意义有限。5. 避坑排查模拟 SPI 读写 W25Q16 最容易翻车的五个点下面这五条都是我或者身边人真实踩过的按现象、原因、解决三段写遇到问题时可以对着现象直接查。5.1 读 ID 恒为 0xFF 或恒为 0x00现象上电调w25_check_id()三个字节要么全是 0xFF要么全是 0x00改代码没用。原因这两种返回值指向完全不同的硬件问题。全 0xFF 说明 MISO 线一直是被拉高的最常见的是 MISO 引脚配成了输出模式或者根本没接、接触不良全 0x00 说明 MISO 一直被拉低可能是引脚配成了下拉或者 CS 压根没拉低、芯片根本没被选中。还有一种隐蔽情况CS 用的引脚和调试口JTAG复用程序跑起来了但引脚功能没重映射CS 实际上不受控。解决先断开 flash用万用表量 MISO 对地电压空闲时应该接近 VCC上拉生效。确认 GPIO 初始化代码里 MISO 是GPIO_Mode_IPUCS 是推挽输出且初始为高。如果 CS 脚复用了 JTAG先关掉 JTAG 再初始化。这三步做完 ID 还不对才去怀疑时序。5.2 写入返回成功读回数据错位或整段为旧值现象w25_write返回 0w25_verify却在某个偏移开始对不上或者读回的是上次写的旧内容。原因九成是跨页问题。页编程越过 256 字节边界后会回卷到本页开头覆盖函数不报错。另一种可能是目标区域没擦除新数据与旧数据按位与看起来写进去了实际是脏数据。解决先确认调用w25_write之前目标地址所在扇区已经擦过。然后在w25_page_program里加一句断言检查(addr 0xFF) len 256越界直接返回错误。再打印 verify 返回的偏移如果偏移正好是 256 的整数倍那基本可以锁定跨页拆分写错了。5.3 擦除后第一次写一定失败第二次就正常现象擦除 → 写 → 读第一次读出来全是 0xFF再执行一遍同样的写就成功了。原因擦除和写使能之间的时序问题。w25_sector_erase里发完 0x06 后立刻发 0x20如果这两个命令之间 CS 没有完整地拉高再拉低芯片可能把两个命令当成一条解析。另一种情况是擦除完成后 BUSY 还没真正清零代码就急着发写使能导致 WEL 被内部状态机清掉。解决在w25_write_enable()之后、发擦除命令之前读一次 SR1 确认 WEL(bit1) 为 1不为 1 就重发一次。擦除后的忙等待超时值给到 500ms 以上并且每次读 SR1 之间至少隔 1ms别用紧密循环把 SPI 总线占满。5.4 小批量测试全对跑久了偶发一位翻转现象单次读写几千字节没问题连续跑几小时后校验偶发失败重新上电又好了。原因时序余量不足。SCK 太快或者spi_dly()在 -O2 优化下被编译器当成无用代码删掉了__NOP()一般不会被删但如果自己写了空循环for(i0;i4;i);就很可能被优化掉。另一个来源是电源擦除瞬间电流能到十几毫安去耦电容不够时 VCC 会掉一下芯片内部状态机异常。解决把spi_dly()改成__NOP()内联汇编保证不被优化然后拿逻辑分析仪在长时间运行后抓一次 SCK 和 MISO 的对齐关系看 MISO 的建立时间还剩多少。电源侧在 flash 的 VCC 脚旁边并一颗 0.1uF 加一颗 10uF走线尽量短。这两条做完偶发错误基本能消掉。5.5 断电重启后数据丢失或内容错乱现象写入并校验通过拔电再上电读出来是 0xFF 或者半截数据。原因写操作还没真正完成就断电了。页编程的 CS 上升沿只是启动真正烧进去要几毫秒w25_wait_busy如果超时值给小了函数提前返回上层以为写完了就去干别的此时断电就丢。另一种是参数区没有做掉电保护擦除到一半断电整个扇区变成不确定状态。解决所有写操作的忙等待超时必须给足页编程至少 10ms扇区擦除至少 500ms并且检查返回值超时就报错而不是当成功。参数区用双扇区乒乓A 区写成功并校验通过后才把标志位写到 B 区上电时比对两个标志位取有效的那一份。代价是多占 4KB换的是断电不丢数据。6. 收敛成可移植接口换硬件 SPI 时不用重写业务代码模拟 SPI 跑通之后早晚会遇到这板子 SPI 外设空出来了能不能提速的需求。如果业务代码里到处都是W25_CS_L()和spi_transfer()换硬件 SPI 就得全文件重写。比较省心的做法是在最下面加一层操作函数指针模拟和硬件两套实现都挂到这个接口上。typedef struct { void (*cs)(int level); uint8_t (*xfer)(uint8_t tx); } w25_bus_t; /* 模拟 SPI 实现 */ static void sim_cs(int level) { level ? W25_CS_H() : W25_CS_L(); } static const w25_bus_t bus_sim { sim_cs, spi_transfer }; /* 硬件 SPI 实现接上去就是改这两行 */ static void hw_cs(int level) { level ? W25_CS_H() : W25_CS_L(); } static uint8_t hw_xfer(uint8_t tx) { while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) RESET); SPI_I2S_SendData(SPI1, tx); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) RESET); return SPI_I2S_ReceiveData(SPI1); } static const w25_bus_t bus_hw { hw_cs, hw_xfer };命令层所有函数改成接收const w25_bus_t *bus参数内部用bus-xfer()替代直接调用。这样业务层完全不用动切换只改一行初始化。硬件 SPI 跑到 18MHz 时读 1KB 从 3.6ms 降到 0.5ms 以内擦除时间不变整体收益主要在频繁读字库的场景。进阶验证可以做两件事。一是把整片 2MB 写满递增序列再全读比对跑通一次基本能排除地址位宽和跨页的所有问题二是人为把 VCC 拉低到 2.7VW25Q16 的下限再跑同样的测试能提前暴露电源余量不足的隐患。最后说个习惯。从那以后我每次调新的 flash 驱动都强制走一遍固定的四步读 ID、单字节写读、跨页写读、整扇区擦写校验一步不过就不往下走。看起来慢但比对着示波器猜一整天快得多。模拟 SPI 这东西不难难的是每个环节都可能悄悄错一点点而错一点点在 flash 上就是数据全废。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →