☰
GD32F10x中文手册2.6版:存储器映射、FMC与时钟低功耗实战
2026/11/3 22:41:43 网站建设 项目流程

简介:《GD32F10X中文用户手册》面向使用GD32F10X系列Arm Cortex-M3 32位MCU的嵌入式开发者、学生与硬件工程师,提供从系统架构到外设编程的中文技术参考,便于芯片选型、底层驱动开发和故障排查。压缩包仅含1个PDF文件,大小约26.43MB,版本为2.6(2022年6月),适合本地检索与离线查阅。手册系统讲解Cortex-M3处理器、时钟与电源管理、存储器映射、位带操作、片上SRAM/FLASH、引导配置和设备电子签名,并延伸至FMC、GPIO、定时器、ADC、DMA、UART、SPI、I2C、CRC及调试接口等内容。目录层级完整,寄存器说明与功能描述较细,可作为开发时的案头资料。已有607人学习,适合需要快速定位模块细节、核对寄存器配置和补充MCU基础的中高级读者。

1. 一份 2.6 版中文手册,抵得上半个星期的英文文档啃读

去年帮某开发者查一块电机驱动板“偶发不启动”的问题,最后落在 FLASH 等待周期上——芯片跑在最高档主频,FMC_WS 还留着复位默认值,取指偶尔踩空。这类问题在 GD32F10x 中文用户手册 2.6 版(2022 年 6 月)里就是一张表格,翻到了十分钟解决,翻不到能耗掉两天。这份手册把系统架构、FMC 闪存控制器、PMU 电源管理、BKP 备份寄存器、RCU 复位时钟、EXTI 中断事件这些模块的寄存器逐位说明做了中文化,图索引和表索引齐全。它适合已经能点灯、但一碰时钟树和 FLASH 擦写就得回去翻英文档的人,也适合带课设时人手一份当工具书。下面按我在项目里真正翻它的顺序拆开讲。

2. 存储器映射与启动配置:把 32 位地址空间拆成能一眼定位的五段

2.1 先读地址表,是因为调试时第一个动作就是看地址

现场排查最频繁的动作,是把 HardFault 压栈的 PC、LR 值捞出来看一眼。手上拿到0x08004A2C或者0x20001F80,能不能三秒内说出它落在哪块区域,直接决定是五分钟定位还是半小时瞎猜。手册 1.3 节那张存储器映射表就是干这个用的,它把整个 4GB 空间按功能切块,并且标出了每块的实际可用范围。

同样是这张表,还能顺手解决另一个常见困惑:为什么有的板子可以靠拉引脚从串口下载程序,有的不行。答案在 1.4 节的引导配置——0x0000_0000起始的那段是别名区,具体映射到主 FLASH、系统存储器还是片上 SRAM,由 BOOT 引脚(部分型号配合选项字节)在上电复位那一刻采样决定。量产出货时用系统存储器里的出厂引导程序做串口升级,就是靠这个机制,不需要额外烧录器。

我的习惯是先把这张表抄到便签上贴在显示器边上,等真正开始写外设驱动,脑子里已经有地址分段的直觉了,看到野指针能立刻反应过来“这个地址根本不该被写”。

2.2 五段地址与它们各自的使用边界

下面这张表是我按手册 1.3 节整理的常用段落,地址区间只写起始,具体上限以手册表格为准,因为不同密度型号的 SRAM 和 FLASH 容量并不一样。

起始地址区域用途使用时的关键约束手册位置
0x0000_0000别名区映射对象由引导配置决定,复位瞬间锁定1.4
0x0800_0000片上主 FLASH擦除以页为单位,编程以半字/字为单位1.3.3、2.3
0x2000_0000片上 SRAM位带别名区起点在0x2200_00001.3.2
0x4000_0000外设寄存器APB1、APB2、AHB 各自偏移不同1.3
0xE000_0000私有外设总线NVIC、SysTick、SCB 等内核外设1.1

值得单独拎出来说的是 FLASH 那一段。它既是代码存放区,又是你想存参数时最容易乱动的地方。很多翻车案例是把参数区放在了程序段后面,结果改一次链接脚本、代码涨了几 KB,参数区就被程序覆盖了。手册 1.3.3 节讲清楚了 FLASH 的组织方式,配合自己工程的.map文件,才能确定“从哪个地址往后是安全的”。

外设段0x4000_0000之后的偏移分配,是后面 RCU、FMC、EXTI 章节反复要查的东西。AHB 上的外设和 APB1、APB2 上的外设挂在不同总线上,复位和时钟使能也分在不同的寄存器里,这一点在写低功耗唤醒代码时会格外要命——唤醒后忘了重新使能某条总线上的外设,现象就是“外设全哑”。

2.3 位带操作:换算公式好写,边界才是坑

位带是 Cortex-M3 内核的特性,把某一位膨胀成一个 32 位字,从而实现对单个比特的原子读写。手册 1.3.1 节给了原理,实际写代码时我一般封装成宏,省得每次手算。

#include <stdint.h> /* 位带别名区基址,Cortex-M3 固定值,手册 1.3.1 节有对应说明 */ #define BITBAND_SRAM_BASE 0x20000000UL #define BITBAND_SRAM_ALIAS 0x22000000UL #define BITBAND_PERI_BASE 0x40000000UL #define BITBAND_PERI_ALIAS 0x42000000UL /* 把 (addr, bit) 换算成别名区地址;addr 必须是 32 位对齐的寄存器或变量地址 */ #define BITBAND_ADDR(addr, bit) \ ((((uint32_t)(addr)) >= BITBAND_PERI_BASE) \ ? (BITBAND_PERI_ALIAS + (((uint32_t)(addr)) - BITBAND_PERI_BASE) * 32UL + (bit) * 4UL) \ : (BITBAND_SRAM_ALIAS + (((uint32_t)(addr)) - BITBAND_SRAM_BASE) * 32UL + (bit) * 4UL)) /* 用法:把 GPIO 输出寄存器的第 3 位置 1,不需要读-改-写 */ #define BITBAND_SET(addr, bit) (*(volatile uint32_t *)BITBAND_ADDR((addr), (bit)) = 1UL) #define BITBAND_CLR(addr, bit) (*(volatile uint32_t *)BITBAND_ADDR((addr), (bit)) = 0UL)

这段代码的关键在两个乘数:* 32是因为原地址每加 1 字节、别名区要前进 32 个字(一个字对应一位),* 4是因为每一位占一个 32 位字即 4 字节。参数addr必须是 32 位对齐的地址,bit取值 0 到 31,超出范围算出来的地址就是垃圾值。

真正容易翻车的是边界。位带别名区只覆盖 SRAM 的低 1MB 和外设区的低 1MB,地址一旦越界,换算出来的别名地址指向的是别的区域,写进去不报错但行为诡异。我见过有人把变量放在0x2001_0000之后还想用位带,结果改一个标志位把隔壁缓冲区改了。所以用位带之前先确认两件事:变量是不是在低 1MB 内,以及链接脚本有没有把它挪走。

2.4 设备唯一 ID 与容量信息:产线自检的两个抓手

手册 1.5.1 和 1.5.2 两节讲的是电子签名,一个是存储器容量信息,一个是 96 位唯一 ID。这两样东西在小批量试产时特别有用:容量信息可以用来判断当前烧进去的固件有没有超出实际 FLASH 大小,唯一 ID 可以拿来做设备序列号或者授权绑定,省掉外挂 EEPROM。

#include <stdint.h> /* 起始地址按手册 1.5.2 节“设备唯一 ID”给出的地址填写,不同型号可能不同 */ #define UID_BASE (UID_START_ADDR_FROM_MANUAL) static inline void uid_read(uint32_t uid[3]) { const volatile uint32_t *p = (const volatile uint32_t *)UID_BASE; uid[0] = p[0]; /* 低 32 位 */ uid[1] = p[1]; /* 中 32 位 */ uid[2] = p[2]; /* 高 32 位,注意高位可能带晶圆信息,不宜全量对外暴露 */ } /* 拼成 96 位字符串用于打印或写入授权文件 */ void uid_to_hex(const uint32_t uid[3], char out[25]) { const char *hex = "0123456789ABCDEF"; for (int i = 0; i < 24; i++) { int shift = (i % 8) * 4; int idx = (i < 8) ? 0 : (i < 16 ? 1 : 2); out[i] = hex[(uid[idx] >> shift) & 0xFU]; } out[24] = '\0'; }

UID_BASE这个宏我没有写死具体数字,因为手册里这张地址表是分型号列的,抄之前先确认自己手上是哪一档密度。三个 32 位字按低到高顺序读出,拼接顺序反过来会导致同一颗芯片在两版固件里算出两个不同的序列号,这种问题在量产阶段才暴露会非常难受。

容量信息那一段的作用更偏防守:程序启动时读一次,和编译期常量对一下,对不上就直接停在错误处理里,比运行到一半莫名其妙复位要好排查得多。有些团队会把它做进自检流程,上电先验容量再跑业务逻辑,多花几十微秒买个安心。

3. FMC 闪存控制器:解锁、页擦除、编程的寄存器时序

3.1 只调库函数会在什么时候失效

平时用现成的库函数擦写 FLASH 没什么问题,但有三类场景绕不开寄存器:一是要在中断里做参数保存,得自己控制临界区;二是掉电保护,要在电压跌落的那几十毫秒里抢一次写;三是排查擦写失败,库函数返回个错误码,具体是解锁没成功还是编程校验没过,还得回来看状态寄存器。手册第 2 章从 2.3 节功能说明一直到 2.4 节十几个寄存器,正好覆盖这三类场景。

先说清楚一个结构上的事实:这批器件的 FLASH 分成主存储块和可选字节块两块,前者放程序,后者放保护配置。两者的操作入口相似但解锁寄存器不同——主存储块用FMC_KEY0,选项字节用FMC_OBKEY。混淆这两个是最常见的失误之一,现象是代码看着没错,但操作位置下去毫无反应。

另一个事实是双 BANK 型号有两个控制寄存器组,FMC_CTL0/FMC_STAT0/FMC_ADDR0和FMC_CTL1/FMC_STAT1/FMC_ADDR1,操作前要先确认目标地址落在哪个 BANK,然后选对应的那组寄存器。单 BANK 型号上写 1 号组是无效操作,不会报错,只是白等。

3.2 解锁与页擦除的最小可运行序列

下面是我在项目里用的骨架,去掉业务之后剩下的就是手册 2.3.3 到 2.3.5 节描述的那套时序。

#include <stdint.h> /* BV(n) 展开为 1<<n;各位的 n 请按手册 2.4.4、2.4.5 节的寄存器表逐位核对后替换 */ #define BV(n) (1UL << (n)) #define FMC_STAT0_BUSY BV(0) /* 忙标志 */ #define FMC_CTL0_PG BV(0) /* 主存储块编程 */ #define FMC_CTL0_PER BV(1) /* 页擦除 */ #define FMC_CTL0_START BV(6) /* 启动一次操作 */ #define FMC_CTL0_LK BV(7) /* 上锁位,1 表示锁定 */ /* 下面几个地址宏按手册 2.4 节的寄存器地址映射表填,这里用占位名 */ #define FMC_KEY0 (*(volatile uint32_t *)FMC_KEY0_ADDR) #define FMC_STAT0 (*(volatile uint32_t *)FMC_STAT0_ADDR) #define FMC_CTL0 (*(volatile uint32_t *)FMC_CTL0_ADDR) #define FMC_ADDR0 (*(volatile uint32_t *)FMC_ADDR0_ADDR) static int fmc_wait_idle(uint32_t timeout) { while ((FMC_STAT0 & FMC_STAT0_BUSY) != 0U) { if (--timeout == 0U) { return -1; /* 超时返回,绝不写死循环 */ } } return 0; } int fmc_page_erase(uint32_t page_addr) { if (fmc_wait_idle(0x000FFFFFU) != 0) return -1; FMC_KEY0 = 0x45670123UL; /* 解锁序列第一步,魔数不能改 */ FMC_KEY0 = 0xCDEF89ABUL; /* 第二步,顺序颠倒就解不开 */ if ((FMC_CTL0 & FMC_CTL0_LK) != 0U) return -2; /* 还锁着说明序列被中断打断 */ FMC_CTL0 |= FMC_CTL0_PER; /* 选页擦除 */ FMC_ADDR0 = page_addr; /* 目标页首地址,必须页对齐 */ FMC_CTL0 |= FMC_CTL0_START; /* 置 START 触发,硬件自动清 */ if (fmc_wait_idle(0x00FFFFFFU) != 0) return -3; FMC_CTL0 &= ~FMC_CTL0_PER; /* 先撤操作位,再上锁 */ FMC_CTL0 |= FMC_CTL0_LK; return 0; }

参数上要盯住三个地方。timeout是个递减计数器,取值要按 CPU 主频算:擦一页的时间在手册 2.3.2 附近的时序表里有典型值,把它折算成循环次数再留三到五倍余量,太小会误判超时,太大等于没保护。page_addr必须页对齐,页大小在手册 2.3.1 的闪存结构表里按密度分档给出,中密度和高密度不一样,照着邻居型号的常量写是经典翻车点。返回值-1/-2/-3分别对应等待超时、解锁失败、擦除超时,实际项目里建议再读一次状态寄存器把错误位记进日志,不然只看到-3没法定位。

还有一个细节:解锁和上锁之间尽量别被中断打断。中断里如果也去读 FLASH(比如查常量表),在擦除进行中读同一 BANK 会拿不到正确数据,严重时直接总线错误。稳妥做法是擦写期间关掉全局中断,或者把这段代码放到 RAM 里执行。

3.3 编程、等待状态寄存器与主频的联动

编程和擦除的区别在于触发方式。手册 2.3.6 节讲的是主存储块编程,这一代器件的做法是“向目标地址直接写入”,不是往数据寄存器里填,宽度按手册规定的半字或字来。写错宽度不会报错,只会得到一堆0x00或者校验失败。

int fmc_word_program(uint32_t addr, uint32_t data) { if (fmc_wait_idle(0x000FFFFFU) != 0) return -1; FMC_KEY0 = 0x45670123UL; FMC_KEY0 = 0xCDEF89ABUL; if ((FMC_CTL0 & FMC_CTL0_LK) != 0U) return -2; FMC_CTL0 |= FMC_CTL0_PG; /* 进入编程模式 */ *(volatile uint32_t *)addr = data; /* 关键:写目标地址本身触发编程 */ if (fmc_wait_idle(0x000FFFFFU) != 0) return -3; FMC_CTL0 &= ~FMC_CTL0_PG; FMC_CTL0 |= FMC_CTL0_LK; return 0; }

addr必须已经擦除过(内容为全0xFF),在没擦的地址上直接编程,硬件不会帮你先擦,结果是新旧数据按位与。这是 NV 存储最常见的 bug,表现为“第一次存能读出来,第二次存读出来是乱码”。

比编程本身更容易被忽略的是等待状态寄存器FMC_WS(手册 2.4.1)。它决定 CPU 访问 FLASH 时插入几个等待周期,取值和 HCLK 频率强相关,手册里给了一张频率与等待周期的对照表。主频往上调了而FMC_WS没跟着改,轻则跑分偏低,重则随机取指错误直接 HardFault。反过来,主频降下来了却把等待周期留着高档位,只是浪费性能,不会崩,所以这个问题在降频时永远发现不了,一升频就现原形。

我的做法是把改主频和改FMC_WS写成同一个函数里的两步,中间不允许别处调用,从代码结构上杜绝漏改。手册 2.4.13 还有个等待状态使能寄存器FMC_WSEN,特定型号上用于让等待周期配置真正生效,改之前扫一眼自己型号有没有这一节。

3.4 选项字节与读保护:最容易把板子变砖的一步

手册 2.3.7 到 2.3.11 这几节讲的是可选字节块,包括擦除、编程、页擦除保护、编程保护和安全保护。这一段是整章里风险最高的地方,因为写错了不是程序跑飞,是芯片连不上调试器。

流程上和主存储块类似但要换解锁寄存器:先往FMC_OBKEY写解锁序列,再操作FMC_CTL0里的选项字节擦除位和编程位,最后等FMC_OBSTAT反映结果。选项字节生效需要一次复位,部分型号还需要断电重上电,只在调试器里点复位可能看不到变化。

风险点集中在安全保护上。开了读保护之后,调试器读不到 FLASH 内容,这是设计意图;但要解除保护,绝大多数型号的做法是整片擦除,程序、参数、标定数据一起没。所以这一步的正确顺序是:功能全部验证完、参数区有备份、确认没有第二块板子可以替换,再开保护。我见过在调试阶段就顺手打开保护、结果第二天要改一个常量却发现只能整片擦的场面,那个下午基本报废。

页擦除保护和编程保护相对温和,它们只是让某些页拒绝擦写,写保护寄存器FMC_WP按页粒度配置。用它来保护引导区是个好主意,但要记得保护范围是包含关系还是独立位,手册 2.4.8 的位定义写得很清楚,配错了会出现“保护了自己想改的那一页”。

4. RCU 时钟与 PMU 低功耗:从 8MHz 到 108MHz 的配置推导

4.1 时钟树的四个分频点和三条必须校验的边界

手册第 5 章把 RCU 拆成了两套写:一套给中密度、高密度、超高密度产品,一套给互联型产品,寄存器数量和字段都有差异。抄配置之前先确认自己在哪一套里,这是最省时间的动作。

时钟树的骨架是:时钟源(内部 RC、外部晶振、PLL)→ 系统时钟 CK_SYS → AHB 预分频 → APB2 和 APB1 预分频。PLL 的输入可以选外部晶振二分频或者内部 RC 二分频,倍频系数可调。整条链路上有三条边界必须自己校验,手册不会替你检查:PLL 输出不能超过器件上限,APB2 不能超过 AHB,APB1 一般是 APB2 的一半上限。

分频系数的组合方式还受编码限制,不是任意整数都能填。手册 5.3.2 的RCU_CFG0表里,每个预分频字段给的是离散取值(比如 1、2、4、8、16),填了表里没有的编码,硬件会当成某个默认值处理,现象是实际频率和你算的对不上,用示波器量 MCO 输出才能发现。这类问题靠读代码是看不出来的,只能对表。

4.2 把系统时钟切到 PLL 的寄存器序列

以 8MHz 外部晶振为例,二分频后 4MHz 进 PLL,倍频到 108MHz,AHB 不分频,APB2 不分频,APB1 二分频。这个组合在带以太网或 USB 的板子上还要额外考虑 48MHz 支路,这里先按下不表。

#include <stdint.h> #define BV(n) (1UL << (n)) /* 位号按手册 5.3.1(RCU_CTL)和 5.3.2(RCU_CFG0)的寄存器表核对后替换 */ #define RCU_CTL_HXTALEN BV(16) #define RCU_CTL_HXTALSTB BV(17) #define RCU_CTL_PLLEN BV(24) #define RCU_CTL_PLLSTB BV(25) #define RCU_CTL_CKSEL_MASK (BV(0) | BV(1)) #define RCU_CTL_CKSEL_PLL BV(1) #define RCU_CTL_SCS_MASK (BV(2) | BV(3)) #define RCU_CTL_SCS_PLL BV(3) #define RCU_CTL (*(volatile uint32_t *)RCU_CTL_ADDR) #define RCU_CFG0 (*(volatile uint32_t *)RCU_CFG0_ADDR) int clock_to_pll_108m(void) { uint32_t timeout = 0x000FFFFFU; /* 1. 开外部晶振并等稳定,超时立刻退出,不要死等 */ RCU_CTL |= RCU_CTL_HXTALEN; while ((RCU_CTL & RCU_CTL_HXTALSTB) == 0U) { if (--timeout == 0U) return -1; } /* 2. 先配预分频和 PLL 参数,此时系统时钟还在内部 RC 上跑 */ RCU_CFG0 = (RCU_CFG0 & ~(APB1_MASK | APB2_MASK | AHB_MASK | PLLMF_MASK | PLLSEL)) | APB1_DIV2 | APB2_DIV1 | AHB_DIV1 | PLLMF_X27 | PLLSEL_HXTAL_DIV2; /* 3. 使能 PLL 并等锁定 */ RCU_CTL |= RCU_CTL_PLLEN; timeout = 0x000FFFFFU; while ((RCU_CTL & RCU_CTL_PLLSTB) == 0U) { if (--timeout == 0U) { RCU_CTL &= ~RCU_CTL_PLLEN; return -2; } } /* 4. 切换系统时钟源,并回读状态位确认切换真的发生 */ RCU_CTL = (RCU_CTL & ~RCU_CTL_CKSEL_MASK) | RCU_CTL_CKSEL_PLL; while ((RCU_CTL & RCU_CTL_SCS_MASK) != RCU_CTL_SCS_PLL) { /* 等状态位 */ } return 0; }

第 1 步的超时退出是整个函数里最重要的一行。晶振不起振的原因很多——负载电容不匹配、焊盘虚焊、把有源晶振按无源的方式接了——如果这里写死循环,现象就是“程序一上电就没反应”,连调试器都难挂上去。加了超时以后程序会继续在内部 RC 上跑,至少能通过串口把错误码吐出来。

第 2 步把预分频和倍频一起配,是因为这些字段都在同一个寄存器里,分两次写容易漏掉中间的中间态。APB1_DIV2、PLLMF_X27这类宏的具体编码值请对着RCU_CFG0的字段表填,我这里只保留语义。切换完成后一定要回读状态位,写入的配置位和实际生效的时钟源是两个不同的字段,只看写入值等于没验证。

还有一个顺序问题:升频之前要先把FMC_WS调到对应档位,降频之后再把它调小。先升频后调等待周期,中间那几微秒就是在超速跑。

4.3 起振失败怎么收场:回滚路径要写在前面

配置时钟的代码最怕写成“只有成功路径”。我的习惯是把整个函数拆成两段:一段尝试切到 PLL,失败就返回错误码;另一段保证无论如何都停留在内部 RC 上并把外设重新初始化一遍。这样即使晶振坏了,板子也能降级运行,至少能上报故障。

复位源也是排查时钟问题的重要线索。手册 5.3.10 的RCU_RSTSCK记录了上一次复位是谁触发的——上电复位、看门狗、软件复位、还是低功耗唤醒。板子反复重启时第一件事就是读这个寄存器,能省掉一大半猜测。它还有个清除标志位,读完要记得清,否则下次读到的还是旧值。

4.4 PMU 三种省电模式与唤醒源的对应关系

手册第 3 章把功耗模式分成睡眠、深度睡眠、待机三档,差别在于关掉了哪些电源域。睡眠只停内核,外设照跑,任何中断都能唤醒;深度睡眠把系统时钟也停了,靠 EXTI 线上的事件唤醒;待机则把 1.2V 域整个断掉,只剩备份域和少数几个唤醒引脚活着,唤醒后相当于一次复位。

/* 进入深度睡眠的典型写法:先置内核 SLEEPDEEP,再执行 WFI * SLEEPDEEP 是 Cortex-M3 内核寄存器位,手册 1.1 节引用了内核文档 */ #define SCB_SCR (*(volatile uint32_t *)0xE000ED10UL) #define SCB_SCR_SLEEPDEEP (1UL << 2) void enter_deep_sleep(void) { SCB_SCR |= SCB_SCR_SLEEPDEEP; __asm volatile ("dsb"); __asm volatile ("wfi"); /* 唤醒源未配置时会一直停在这里 */ SCB_SCR &= ~SCB_SCR_SLEEPDEEP; }

进深度睡眠之前必须把唤醒源配好,wfi本身不检查有没有唤醒源,没配就是永久停机,只能靠复位救回来。这是新手最容易踩的一脚,尤其在调试器里单步执行到wfi时,很多时候调试器直接断开连接,看起来像芯片坏了。

待机模式的坑在唤醒之后。因为相当于复位,时钟回到内部 RC,之前配好的所有外设寄存器都回了默认值。如果代码里

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

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

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

立即咨询