☰
GD32F10x寄存器与FMC/RCU避坑实战:从存储器映射到外设时钟
2026/11/3 15:08:27 网站建设 项目流程

简介:《GD32F10X中文用户手册》(2.6 版本,2022 年 6 月)是 GigaDevice 官方为 GD32F10X 系列 Arm Cortex-M3 32 位 MCU 编写的技术参考文档,适合嵌入式驱动开发、固件移植和硬件调试人员查阅。全书从 Cortex-M3 内核、系统架构与存储器映射讲起,逐章展开位带操作、片上 SRAM 与 FLASH、引导配置、设备电子签名(含 96 位唯一 ID)及系统配置寄存器,并深入 FMC 闪存控制器、GPIO、定时器、ADC、DMA、CRC、UART、SPI、I2C 等外设的寄存器定义与工作流程。压缩包内仅 1 个 PDF 文件,约 26.43MB,单册涵盖全部章节,配有详细目录、图索引与表索引,便于离线检索、随查随用。已有 607 人学习下载。它更像案头工具书:查寄存器位定义、确认时钟与复位流程、排查 Flash 擦写与中断配置问题,都能按目录快速跳转定位,减少反复试错的成本。

1. 一份中文手册的真实定位:寄存器才是芯片的行为约定

很多人把芯片手册当字典用——写代码时遇到某个位不知道填什么,翻到对应页查一下,查完合上。这种用法只动用了手册三分之一的价值。GD32F10x 这类 Cortex-M3 内核的 MCU,外设没有操作系统帮你兜底,你写的每一行配置最终都变成对某个地址的一次写操作,而手册真正记录的是“这个地址写下去之后,芯片内部会发生什么”:写错顺序会不会卡死、标志位什么时候置起、时钟没开时读回来是什么。这些编译器不会告诉你,调试器也看不出来。搞懂这套控制面的组织方式,比记住某个寄存器的位定义有用得多——因为下一个型号换掉的是位定义,不换的是“先解锁、再配置、等标志、后上锁”这个节奏。

2. 存储器映射与位带:4GB 地址空间是怎么被切成功能块的

2.1 存储器映射不是一张表,而是一套约定

Cortex-M3 把 4GB 地址空间做了固定分区,这一点在手册 1.3 节的映射图里画得很清楚:低端 512MB 给代码区,0x20000000 往上是 SRAM,0x40000000 往上是外设,再往上留给外部存储器与外部设备,最后 0xE0000000 开始是内核私有外设,NVIC、SysTick、系统控制块都在那一段。这套划分是架构层面定死的,不是厂商随手排的,所以你在任何一个 Cortex-M3 器件上看到的布局都大体一致,换芯片时真正要重新查的是“这个外设挂在哪个偏移”。

理解这一点之后,很多事情就顺了。所谓“配置一个外设”,本质是对某个固定地址写特定的位模式;所谓“外设寄存器”,本质是一段被硬件监听的内存。手册里那些寄存器地址表不是给编译器看的,是给你确认“我操作的到底是不是我以为的那个东西”用的。我见过不少人在调试时直接memcpy一段数据到外设地址区间,结果当然是翻车——外设寄存器不支持任意宽度的批量写,很多位是只读的,有些位写 1 清零、有些写 0 清零,跟普通内存的语义完全不是一回事。

另一个容易被忽略的点是启动映射。代码区起始地址在复位后不一定指向片上 Flash,它取决于引导配置引脚在上电那一刻被采样到的电平组合,手册 1.4 节专门讲了这件事。也就是说,同一份固件烧进去,如果板子上的引导引脚状态不对,芯片根本不会从你的代码开始跑。这不是软件问题,是硬件状态问题,很多“烧录成功但不运行”的现场最后都追到这里。

2.2 位带:把一个 bit 膨胀成 32 位地址的代价与收益

位带是 Cortex-M3 里一个很有意思的设计:它把 SRAM 和外设区各自最低的 1MB 拿出来,每个 bit 单独映射到别名区的一个 32 位字上。你往别名地址写 1,硬件帮你完成“读原字节、置位、写回”这一整套,而且整个过程是单次总线写,不会被中断打断。

为什么值得为这个功能专门划出 32 倍膨胀的地址空间?因为并发的读改写是嵌入式里最隐蔽的 bug 来源之一。两个中断同时操作同一个寄存器的不同位,用传统的|=写法就可能丢更新。位带把“置某一位”变成一条存储指令,原子性由硬件保证,代码里不用再加临界区。

常见的实现思路是这样算地址的:

#include <stdint.h> /* 别名区基址:SRAM 段与外设段各有一块 */ #define BITBAND_SRAM_BASE 0x22000000UL #define BITBAND_PERI_BASE 0x42000000UL /* * addr : 0x20000000 段或 0x40000000 段内的字节地址 * bit : 0..7,要操作的位序号 * 原理:区内字节偏移 << 5 等价于乘 32(每字节 8 个 bit,每 bit 占 4 字节) * bit 序号 << 2 等价于乘 4 */ #define BITBAND_ADDR(addr, bit) \ ((((addr) & 0xF0000000UL) == 0x40000000UL) \ ? (BITBAND_PERI_BASE + (((addr) & 0x000FFFFFUL) << 5) + ((bit) << 2)) \ : (BITBAND_SRAM_BASE + (((addr) & 0x000FFFFFUL) << 5) + ((bit) << 2))) #define BITBAND_WORD(addr, bit) (*(volatile uint32_t *)BITBAND_ADDR((addr), (bit))) volatile uint32_t g_flags = 0; void set_flag_bit3(void) { /* 编译出来是一条 STR,不需要 __disable_irq() 保护 */ BITBAND_WORD((uint32_t)&g_flags, 3) = 1; }

这段代码里三个常量值得解释:0x000FFFFF是区内偏移掩码,因为只有最低 1MB 参与位带;左移 5 位是因为每个原字节的 8 个 bit 各占别名区 4 字节,8×4=32;左移 2 位是 bit 序号到字节偏移的换算。把这两步合起来,就是别名地址 = 别名基址 + 字节偏移×32 + bit 序号×4。

注意:位带只覆盖最低 1MB,超出范围的地址算出来的别名指向别处,写下去不会报错,只会悄悄改错内存。这类问题查起来非常费时间。

2.3 电子签名与引导配置:芯片怎么回答“我是谁、从哪开始跑”

手册 1.5 节讲了两类电子签名:存储器容量信息和 96 位设备唯一 ID。容量信息是出厂时写死的,程序启动时可以读出来跟自己的链接脚本对一下,防止把为大容量型号编译的固件烧进小容量型号——这种事故在产线上不算罕见,表现是程序跑到一半跳飞,或者擦除越界。

96 位唯一 ID 的用途更广:设备序列号、密钥派生、授权绑定、产线追溯。它出厂就固定,不能改,也不会因为擦除 Flash 而丢失。常见的做法是拿它做 HMAC 的输入,派生一把跟芯片绑定的密钥,固件被复制到别的片子上就跑不起来。

/* 具体地址以手册 1.5.2 的表格为准,这里用符号占位,换型号时只改这一处 */ #define DEVICE_UID_BASE (0x1FFFF7E8UL) void uid_to_string(char *out, uint32_t len) { const volatile uint32_t *uid = (const volatile uint32_t *)DEVICE_UID_BASE; /* 96 位 = 3 个 32 位字,直接按字读,别按字节拼 */ for (int i = 0; i < 3 && (i * 8 + 8) <= len; i++) { uint32_t w = uid[i]; for (int j = 0; j < 8; j++) { out[i * 8 + j] = "0123456789ABCDEF"[(w >> (28 - j * 4)) & 0xF]; } } }

这段代码里的关键是按 32 位字读,而不是按字节拼。系统存储区的访问宽度有要求,按字节读在一些实现上会得到错位数据。另外这类地址的读取不受读保护影响,所以即便 Flash 被保护,UID 依然可读,这也是它能做设备标识的原因。

3. FMC 闪存控制器:为什么擦一次 Flash 要走完三段式流程

3.1 闪存结构与等待周期:读为什么要“等”

片上 Flash 的物理结构和 SRAM 完全不同。SRAM 是六个晶体管锁存一个 bit,读写速度和内核同频;Flash 靠浮栅存电荷,读的时候要比较阈值电压,写的时候要加高压把电子赶进浮栅或者拉出来。这种物理特性决定了它有两个硬约束:读需要时间,写需要高压和较长的时间。

手册 2.3.1 节的闪存结构表里,你能看到主存储块按页划分,页大小跟器件密度有关,擦除的最小单位是页而不是字节。这一点必须先想清楚:你不能“改一个字节”,只能“擦掉一整页再整页写回”。所有 Flash 上的参数存储方案,本质上都是在跟这个约束做斗争——磨损均衡、日志式追加写、双页轮换,都是从这个约束长出来的。

读操作的等待周期由 FMC_WS 寄存器控制。主频越高,一次 Flash 读取需要的等待周期越多,否则内核取指时数据还没准备好。这个寄存器的配置顺序非常关键:升频之前先把等待周期加上去,降频之后再减回来。顺序反了,中间那一小段时间内核在超出 Flash 能力的频率下取指,行为不可预测。

/* 位域名以手册 2.4.1 的表格为准,这里用宏封装,便于换型号时统一改 */ static void fmc_ws_set(uint32_t wscnt) { uint32_t ws = FMC_WS; ws &= ~FMC_WS_WSCNT_MASK; ws |= (wscnt & FMC_WS_WSCNT_MASK); FMC_WS = ws; /* 读回确认,写没写进去在这里能立刻发现 */ if ((FMC_WS & FMC_WS_WSCNT_MASK) != (wscnt & FMC_WS_WSCNT_MASK)) { /* 配置失败,通常意味着这组参数超出了手册允许的范围 */ } }

这个函数看着简单,却是很多“提高主频后随机死机”的根因。死机的表现通常是 HardFault 或者直接跑飞,日志里什么都留不下,像个黑匣子。我一般的排查顺序是:先确认等待周期跟主频是否匹配,再确认 PLL 是不是已经稳定,最后才怀疑代码逻辑。

3.2 双重 KEY 解锁:0x45670123 之后为什么还有一步

FMC 的控制寄存器上电后是锁定的,LK 位置位,任何写操作都被忽略。要解锁得往 FMC_KEY0 连续写两个固定值,先0x45670123,再0xCDEF89AB。很多人第一次看这段会问:既然密钥是公开写在手册里的,那搞两步有什么意义?

意义不在保密,在于防误操作。单片机跑飞的时候,指针可能指向任意地址,一次错误的写落在这两个 KEY 地址上的概率极低,连续两次按正确顺序命中的概率更低。这是用极小的硬件成本换来的“后悔药”:绝大多数野指针不会真的把 Flash 擦掉。同样的思路在选项字节那块还有一套独立的 FMC_OBKEY,两把锁分开,改普通存储区和改保护配置互不影响。

#define FMC_KEY_STEP1 0x45670123UL #define FMC_KEY_STEP2 0xCDEF89ABUL int fmc_unlock(void) { FMC_KEY0 = FMC_KEY_STEP1; FMC_KEY0 = FMC_KEY_STEP2; /* LK 位没清掉说明序列没被接受,后面所有操作都会静默失败 */ if (FMC_CTL0 & FMC_CTL0_LK) { return -1; } return 0; } void fmc_lock(void) { FMC_CTL0 |= FMC_CTL0_LK; /* 软件置位,只有复位才能硬件清除 */ }

解锁之后也别急着写。手册 2.3.3 节强调的时序是“先清标志、再配控制、最后启动”。标志位不清,你没法区分这次操作成功还是上次遗留的错误。

3.3 页擦除与编程的完整时序:ADDR0、START、BUSY 三件套

擦除一页的流程在手册 2.3.4 节写得很细,落到代码上就是四步:置 PER 位、把页首地址写进 FMC_ADDR0、置 START 位、轮询 BUSY。这里有两个细节最容易出问题。第一个是 ADDR0 必须落在页边界上,写页中间地址不会报错,硬件会自己向下对齐,结果就是你“以为擦了 A 页,实际擦了 B 页”。第二个是 BUSY 的轮询必须真的等到清零,中途去读状态寄存器里的其他位,读到的可能是上一次的残留值。

编程的流程更反直觉:没有数据寄存器。你要写的数据直接写到目标地址上,硬件拦截这次写操作,把它转成 Flash 编程。而且宽度有要求,通常必须是半字对齐。

int flash_page_rewrite(uint32_t page_addr, const uint16_t *buf, uint32_t halfwords) { uint32_t i; if (fmc_unlock() != 0) { return -1; } FMC_STAT0 = FMC_STAT0_END | FMC_STAT0_PGERR | FMC_STAT0_WRPERR; /* 写 1 清 */ /* 第一步:整页擦除 */ FMC_CTL0 |= FMC_CTL0_PER; FMC_ADDR0 = page_addr; /* 必须是页首,否则会向下对齐擦错页 */ FMC_CTL0 |= FMC_CTL0_START; while (FMC_STAT0 & FMC_STAT0_BUSY) { /* 空转等待,不要在这里做别的事 */ } if (FMC_STAT0 & (FMC_STAT0_WRPERR | FMC_STAT0_PGERR)) { FMC_CTL0 &= ~FMC_CTL0_PER; fmc_lock(); return -2; /* 大概率是这页被写保护了 */ } FMC_STAT0 = FMC_STAT0_END; FMC_CTL0 &= ~FMC_CTL0_PER; /* 第二步:逐半字编程,注意是直接写目标地址,不是写数据寄存器 */ FMC_CTL0 |= FMC_CTL0_PG; for (i = 0; i < halfwords; i++) { *(volatile uint16_t *)(page_addr + i * 2) = buf[i]; while (FMC_STAT0 & FMC_STAT0_BUSY) { } if (FMC_STAT0 & FMC_STAT0_PGERR) { FMC_CTL0 &= ~FMC_CTL0_PG; FMC_STAT0 = FMC_STAT0_PGERR; fmc_lock(); return -3; /* 目标地址没擦干净,或者不是半字对齐 */ } FMC_STAT0 = FMC_STAT0_END; } FMC_CTL0 &= ~FMC_CTL0_PG; fmc_lock(); return 0; }

这段代码里PGERR的含义值得单独说一句:它表示“你试图把 0 写成 1”。Flash 编程只能把 bit 从 1 变 0,反过来必须靠擦除。所以如果你忘了先擦除,或者这一页只擦了一半,编程就会报这个错。看到 PGERR 先别怀疑时序,先确认目标页是不是干净的。

3.4 选项字节、写保护与读保护:一次性操作的风险

选项字节是 Flash 里一块特殊区域,存的是“芯片怎么保护自己”的配置:哪些页禁止擦写、读保护等级、看门狗行为等等。操作它的流程跟主存储区类似但用的是另一套钥匙:先通过 FMC_OBKEY 解锁,置 OBER 擦除,置 OBWEN 使能编程,再写入。手册 2.3.7 到 2.3.9 节讲的就是这套。

写保护由 FMC_WP 寄存器体现,每个 bit 覆盖一组页,具体映射关系在手册 2.4.8 节的表里。开启之后对应页的擦除和编程都会置 WRPERR。这个功能在需要保护 bootloader 区不被应用代码误擦时特别有用,代价是你自己升级时也得先解保护。

读保护是另一个级别的东西。一旦开启,调试器连不上,普通读取也读不出内容。解除读保护通常会触发整片擦除——这是设计上的取舍:如果不能整片擦除就解除保护,那保护就形同虚设。我见过有人在产线上批量开了读保护,后来想改固件,发现每次都得先整片擦除,生产节拍直接翻倍。这类配置上线前一定要在样机上完整走一遍全流程,包括“如何回退”。

注意:选项字节的修改一般要复位后才生效,写完立刻去读主存储区的保护状态,读到的还是旧值。

4. RCU 时钟与复位:外设从“没电”到“能动”的完整链路

4.1 RCU_CTL 与 RCU_CFG0:从内部 RC 到 PLL 的切换路径

复位之后芯片跑在内部 RC 振荡器上,精度一般,但胜在起振快、不需要外部器件。要用外部晶振和 PLL,就得按顺序动 RCU_CTL 和 RCU_CFG0 这两个寄存器。手册 5.3 节的寄存器表里,CTL 管“开不开”,CFG0 管“选哪个”。

切换路径可以拆成五步:开外部晶振、等稳定标志、配 PLL 的源和倍频、开 PLL、等稳定、切系统时钟源、确认切换完成。中间任何一步跳过稳定标志的等待,都可能切到一个还没起振的时钟上,后果是内核直接停摆,连 HardFault 都来不及进。

int clock_switch_to_pll(uint32_t pll_mul, uint32_t ws_for_target) { uint32_t timeout; /* 1. 开外部晶振(位域名以手册 5.3.1 为准) */ RCU_CTL |= RCU_CTL_HXTALEN; timeout = 0x5FFFF; while (!(RCU_CTL & RCU_CTL_HXTALSTB)) { if (--timeout == 0) { return -1; /* 晶振没起振,先查硬件 */ } } /* 2. 配置 PLL:源选外部晶振,设置倍频 */ RCU_CFG0 &= ~(RCU_CFG0_PLLSEL | RCU_CFG0_PLLMF); RCU_CFG0 |= (RCU_CFG0_PLLSEL | ((pll_mul - 2) << 18)); /* 3. 开 PLL 并等稳定 */ RCU_CTL |= RCU_CTL_PLLEN; timeout = 0x5FFFF; while (!(RCU_CTL & RCU_CTL_PLLSTB)) { if (--timeout == 0) { return -2; } } /* 4. 关键顺序:先加等待周期,再切时钟源 */ fmc_ws_set(ws_for_target); /* 5. 切系统时钟源,并等状态位确认 */ RCU_CFG0 &= ~RCU_CFG0_SCS; RCU_CFG0 |= RCU_CFG0_SCS_PLL; timeout = 0x5FFFF; while ((RCU_CFG0 & RCU_CFG0_SCSS) != RCU_CFG0_SCSS_PLL) { if (--timeout == 0) { return -3; } } return 0; }

PLL 倍频字段的编码方式各个型号不完全一样,有的用“倍频值减 2”,有的直接编码,写的时候一定要对着手册 5.3.2 的表格逐位核。至于等待周期的具体数值,手册 2.4.1 节给出了频率与 WS 的对应关系,别凭感觉填。

4.2 使能寄存器分组的理由:APB1、APB2、AHB 为什么不能合并

RCU 里外设时钟使能被拆成 AHBEN、APB2EN、APB1EN 三组,不是设计者偷懒没合并,而是这三条总线本身的能力不同。AHB 速度最高,挂 DMA、SRAM 接口这类;APB2 次之,挂 GPIO、AFIO、ADC、USART0 这类;APB1 最低,挂定时器、I2C、SPI 这些。分组的本质是让时钟域和总线域对齐,低速外设不用跟着高频总线一起翻转,动态功耗就是这么省下来的。

写代码时的直接后果是:每个外设的使能位在不同寄存器里,写错一个就是“外设毫无反应”。更隐蔽的是复用功能,比如用某个引脚做串口,不只要开 GPIO 时钟和串口时钟,还得开 AFIO 时钟,否则重映射配置写不进去。

/* 开三组时钟:GPIOA、AFIO 在 APB2,定时器在 APB1,DMA 在 AHB */ RCU_APB2EN |= RCU_APB2EN_PAEN | RCU_APB2EN_AFEN; RCU_APB1EN |= RCU_APB1EN_TIMER2EN; RCU_AHBEN |= RCU_AHBEN_DMA0EN; /* 如果这个外设之前被复位过,还要显式解除复位 */ RCU_APB2RST &= ~RCU_APB2RST_PARST;

最后那行解复位操作,是另一个高频翻车点。复位寄存器置位后外设停在复位态,你后面所有配置都写不进去,表现为“时钟开了、寄存器写了、就是不工作”。看到这种现象,先把对应的 RST 位读出来确认一下。

4.3 RSTSCK:复位源识别与时钟源状态的双重身份

RCU_RSTSCK 这个寄存器名字有点怪,因为它同时承担了两个职责:记录上次复位是谁引起的,以及反映备份域的时钟状态。前者对故障诊断极有价值——设备在野外重启了,你总得知道是掉电、看门狗还是软件主动复位。

const char *last_reset_cause(void) { uint32_t rst = RCU_RSTSCK; const char *cause = "unknown"; if (rst & RCU_RSTSCK_PORRSTF) cause = "power-on"; else if (rst & RCU_RSTSCK_SWRSTF) cause = "software"; else if (rst & RCU_RSTSCK_FWDTRSTF) cause = "iwdg"; else if (rst & RCU_RSTSCK_WWDTRSTF) cause = "wwdg"; else if (rst & RCU_RSTSCK_LPRSTF) cause = "low-power"; else if (rst & RCU_RSTSCK_EPRSTF) cause = "external-pin"; /* 写 1 清全部标志,为下次启动做准备 */ RCU_RSTSCK |= RCU_RSTSCK_RSTFC; return cause; }

这段代码要放在main的最开头执行,因为你一旦清了标志,信息就没了。配合备份寄存器把复位原因累计存起来,就能看到“这台设备一周内被看门狗复位了 37 次”这种信息,排查方向立刻就清楚了。

4.4 EXTI 的中断与事件双路径:为什么事件不经过 NVIC

外部中断控制器在手册第 6 章,它的框图里有一条容易被忽略的分叉:同一个触发源,可以走中断路径进 NVIC,也可以走事件路径直接去触发别的外设。事件路径不经过 CPU,不产生中断服务程序,纯粹是硬件之间的连线。

这个设计的意义在于把“响应”和“计算”解耦。比如某个引脚的状态变化需要启动一次 ADC 采样,用事件路径可以让 EXTI 直接触发 ADC,CPU 完全不用醒;如果用中断路径,CPU 得先进中断、再配 ADC、再退出,多花几十个周期。在对时序和功耗都敏感的场景里,这个差别不小。

void exti_key_init(void) { RCU_APB2EN |= RCU_APB2EN_PAEN | RCU_APB2EN_AFEN; gpio_mode_set(GPIOA, GPIO_MODE_INPUT, GPIO_PUPD_PULLUP, GPIO_PIN_0); /* AFIO 里选 EXTI0 的输入端口,不写这步用的可能是默认端口 */ AFIO_EXTISS0 = (AFIO_EXTISS0 & ~AFIO_EXTISS0_EXTI0_SS) | AFIO_EXTISS0_EXTI0_SS_PA; EXTI_INTEN |= EXTI_INTEN_EXTI0; /* 中断路径:会进 NVIC */ EXTI_RTEN |= EXTI_RTEN_EXTI0; /* 上升沿触发 */ nvic_irq_enable(EXTI0_IRQn, 2, 0); }

注意 EXTI 线的编号和 GPIO 引脚号是绑定的:EXTI0 只能接某个端口的 0 号引脚,不能接 1 号。你要用多个引脚,就得占用多条 EXTI 线,而 EXTI5 到 EXTI9 共享一个中断入口,EXTI10 到 EXTI15 也是,进中断后还得自己读标志位判断是哪条线触发的。

5. 避坑与排查:手册上一句话、实际会翻车的五个现场

5.1 闪存操作类:擦除“成功”了,数据却没变

现象:调用擦除函数返回 0,读回来数据还在,或者只有一部分变成 0xFF。

原因:三种可能按概率排序——目标页被 FMC_WP 写了保护,擦除操作被硬件拒绝但代码没检查 WRPERR;地址没落在页首,硬件向下对齐擦掉的是前一页;轮询 BUSY 时读的是缓存的变量而不是寄存器,循环被编译器优化掉了。

解决:把 STAT0 里的三个错误标志全部检查一遍,不要只看返回值;地址传进来先做页对齐断言;轮询处的寄存器访问加volatile,或者直接用库函数里的等待实现。这类问题最坑的地方在于它不报错,只是静默失效,所以检查错误标志这一步不能省。

5.2 闪存操作类:编程后读回数据错位或全 0xFF

现象:写进去的数组读回来整体偏移一个字节,或者干脆全是 0xFF。

原因:数据宽度不对。这类 Flash 的编程单位是半字,你按字节指针写会触发对齐错误或者只写进去一半;另一个原因是忘了先擦除,直接往有数据的页上写,PGERR 被忽略后读到的还是旧内容。

解决:统一用uint16_t *访问目标区,长度按半字算;写之前先确认这一页是 0xFF,不是就先擦。我一般会在编程函数开头加一段校验:扫描目标区域,发现非 0xFF 直接返回错误,而不是硬写。

5.3 时钟与复位类:时钟开了,外设还是不动

现象:RCU_APBxEN里对应的位置了,外设寄存器写进去读出来还是 0,或者配置完毫无反应。

原因:最常见的是外设还停在复位态。复位寄存器置位后外设被按住,你写什么都被忽略。其次是选错了总线分组——比如以为串口在 APB1 其实在 APB2,开了另一个同名外设的时钟。第三种是复用功能没开 AFIO 时钟,重映射配置写不进去,引脚还连着默认功能。

**

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

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

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

立即咨询