以 CH32X035 的 Link.ld 为例:链接脚本文件精读

结论速览

  • 链接脚本只干三件事:排布(谁进 Flash、谁进 RAM)、接线(给启动代码和 C 库提供 _data_lma_sbss_eusrstack_heap_end 等符号)、兜底(把 C++ 构造表收进来)。
  • 唯一的技巧是 VMA/LMA 分离.data 运行在 RAM、初值在 Flash,靠两个零长段 .dalign/.dlalign 取到 _data_vma/_data_lma 一对地址。
  • .stack 用绝对表达式钉在 RAM 顶端,它的起点 _heap_end 就是工程 _sbrk() 的堆上限。
  • PROVIDE 的符号没人引用就不进符号表(map 里显示 [!provide]),普通赋值则一定在。

一、读它之前要用的知识

1.1 section 与”段”

编译器把每个函数、每个变量放进一个命名块(section),名字带前缀:.text(代码)、.rodata(只读数据)、.data(有初值的可写数据)、.bss(初值为 0 的可写数据)。开了 -ffunction-sections -fdata-sections 之后,每个函数/变量各占一个段(.text.xxx.data.yyy)。

链接器的工作就是把这些碎段合并、排序、定位。脚本决定”合并成哪些输出段、各自落在哪个地址”;产物 ELF 里的 segment(段)是输出段的集合。

1.2 VMA 与 LMA

缩写 全称 含义 谁在用
VMA Virtual Memory Address,运行地址 程序运行时这条指令/变量在哪个地址 CPU 取指、读写
LMA Load Memory Address,装载地址 这份内容复位后躺在哪个地址 烧录器、启动拷贝代码

Flash 里的段通常 VMA = LMA。.data 是唯一例外:初值存在 Flash(LMA),运行时必须在 RAM(VMA)。这一步搬运不是链接器做的,是启动代码做的——脚本只负责把两头的地址告诉它。

1.3 脚本关键字速认

写法 作用
MEMORY { ... } 声明可用存储区域:名字、属性(rx/xrw)、ORIGINLENGTH
SECTIONS { ... } 输出段的定义与定位规则
. 位置计数器(location counter),表示当前地址;_sym = . 就是”记录当前位置”
ALIGN(n) . 向上对齐到 n 字节
>REGION 指定段的 VMA 所在区域,. 随之跳到该区域
AT>REGION 指定段的 LMA 所在区域;只写 >X AT>X 表示 VMA = LMA
KEEP(...) 即使 --gc-sections 判定”没人引用”也要保留
PROVIDE(sym = ...) 只在该符号没被别处定义且被引用时才定义;PROVIDE_HIDDEN 版本不进动态符号表
sym = ... 普通赋值,无条件定义
*(.text .text.*) 通配符:收集所有输入段
EXCLUDE_FILE(...) 从通配结果里排除指定目标文件的段
SORT_BY_INIT_PRIORITY(...) init_priority 属性排序(决定 C++ 构造顺序)
ENTRY(_start) 指定 ELF 入口符号(等价于链接选项 -e

1.4 工具链一侧的开关

脚本单独看没有意义,本工程的链接命令(obj/makefile)是:

1
2
3
4
riscv-none-embed-gcc -march=rv32imacxw -mabi=ilp32 -msmall-data-limit=8 -msave-restore \
-Os -ffunction-sections -fdata-sections -fno-common -g \
-T ".../Ld/Link.ld" -nostartfiles -Xlinker --gc-sections \
-Wl,-Map,"CH32X035G8U.map" --specs=nano.specs --specs=nosys.specs -o CH32X035G8U.elf ...

(节选,只保留与链接相关的部分。)

选项 对脚本的影响
-ffunction-sections -fdata-sections 段被拆碎,脚本里的 *(.text.*)*(.data .data.*) 才有意义
--gc-sections 没人引用的段会被丢掉,于是 KEEP 成为”保命符”
-msmall-data-limit=8 ≤8 字节的变量进 .sdata/.sdata2/.sbss,脚本必须收它们并定义 __global_pointer$
-nostartfiles 没有 crt0/crti/crtbegin,_startStartup/startup_ch32x035.S 自己提供
--specs=nano.specs --specs=nosys.specs newlib-nano + syscall 空桩;_sbrk 由工程 Debug/debug.c 自己实现
-Wl,-Map 产出 map 文件,脚本写得对不对全靠它核对

二、脚本全貌

1
2
3
4
5
6
7
8
FLASH  0x00000000  62K          RAM  0x20000000  20K(到 0x20005000)
┌───────────────┐ ┌──────────────────────────┐
│ .init 0x00│ ← 入口 │ .data 0x20000000 │ ← LMA 在 Flash 0xb458
│ .vector 0x04│ 复位向量表 │ .bss 0x200000b0 │ ← 运行前清零
│ .text 0x100│ 代码/rodata │ (堆,向上长)→ _heap_end │ 0x20004800
│ ... │ │ .stack 0x20004800 │ ← 2K,向下长
│ .data 初值 │ 0xb458 │ _eusrstack 0x20005000 │ ← sp 初值
└───────────────┘ └──────────────────────────┘

本工程链接结果的真实地址(取自 obj/CH32X035G8U.mapobjdump --all-headers):

输出段 VMA 大小 LMA
.init 0x00000000 0x4 同 VMA
.vector 0x00000004 0xfc 同 VMA
.text 0x00000100 0xb358 同 VMA
.data 0x20000000 0xb0 0x0000b458
.bss 0x200000b0 0x2010 —(NOBITS,不占 Flash)
.stack 0x20004800 0x800 同 VMA

Flash 实际占用 0x0000b458 ≈ 45 KB(62K 里还剩约 17K);RAM 里 .data + .bss 约 8.2 KB。

三、逐段精读

3.1 头三行:入口、栈大小、PROVIDE

1
2
3
ENTRY( _start )
__stack_size = 2048;
PROVIDE( _stack_size = __stack_size );
  • ENTRY(_start) 决定 ELF 入口:objdump 里 start address 0x00000000,而 _start 就是 .init 的第一条 j handle_reset
  • __stack_size = 2048普通赋值,无条件定义,map 里能看到 __stack_size = 0x800
  • PROVIDE(_stack_size = __stack_size) 是”别处没定义我才定义”,作用是给 C 代码一个可覆盖的默认值(C 侧写 extern char _stack_size[] 就能拿到)。本工程没人引用,map 里就是 [!provide] PROVIDE (_stack_size = __stack_size)

📌 记住这条规律:普通赋值一定进符号表,PROVIDE 只在被引用时才生效。后面 _etext_eitcmend_susrstack 都是 [!provide]

3.2 MEMORY:62K 和 20K 从哪来

1
2
3
4
5
MEMORY
{
FLASH (rx) : ORIGIN = 0x00000000, LENGTH = 62K
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K
}
  • CH32X035G8U6 的 ROM 是 62K、SRAM 20K(工程 CH32X035G8U.wvproj 里芯片描述原文就是 ROM(byte): 62K, SRAM(byte): 20K),所以 LENGTH = 62K 不是漏写,也别顺手改成 64K。
  • FLASH 从 0 开始,因为 QingKe 内核从 0x0 取指,Flash 被映射到 0x0。
  • (rx)(xrw) 是权限属性,链接器用它做检查:把可写段放进只读区会直接报错。
  • RAM 顶端 = 0x20000000 + 20K = 0x20005000,这个数字在 3.8 节还会出现(栈顶)。

3.3 .init.vector:必须钉在最前面的两段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.init :
{
_sinit = .;
. = ALIGN(4);
KEEP(*(SORT_NONE(.init)))
. = ALIGN(4);
_einit = .;
} >FLASH AT>FLASH

.vector :
{
*(.vector);
. = ALIGN(64);
} >FLASH AT>FLASH
  • 输出段按脚本书写顺序分配地址,所以 .init 落在 0x0、.vector 紧跟其后。
  • .init 里放的是 startup 的 _start: j handle_reset(实测 0x0 处 4 字节)。_sinit/_einit 用普通赋值框出范围,本工程没用到,但 WCH 其他芯片的 crt0 会用它跑预初始化代码。KEEP 是给 --gc-sections 的保险:.init 属于”靠地址生效”的段。
  • .vector 是本工程真正的启动表:startup 里用 .word _start.word NMI_Handler…… 排了 63 项,共 0xfc = 252 字节,脚本再 ALIGN(64) 把尾部顶到 64 字节边界,于是 .text 正好从 0x100 开始(实测)。
  • 硬件侧的呼应:handle_resetmtvec = _vector_base | 3,低两位的模式位 3 表示按向量表取入口地址(startup 注释原话是 “Configure the interrupt vector table recognition mode and entry address mode”)。
  • .vector 没写 KEEP,但它也不会被 --gc-sections 丢掉:复位代码里有 la t0, _vector_base,这个重定位让 .vector 变成”被引用”。同理,表里的 .word NMI_Handler 之类引用又把所有中断处理函数(哪怕是 weak 空实现)保住。

📌 由此得出一条实战结论:中断服务函数只要被向量表引用,就不会被 --gc-sections 删掉;反过来,想裁掉某个用不到的中断路由,得动向量表,而不是指望链接器。

3.4 .text / .fini:代码与只读数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.text :
{
. = ALIGN(4);
*(.text)
*(.text.*)
*(.rodata)
*(.rodata*)
*(.gnu.linkonce.t.*)
. = ALIGN(4);
} >FLASH AT>FLASH

.fini :
{
KEEP(*(SORT_NONE(.fini)))
. = ALIGN(4);
} >FLASH AT>FLASH

PROVIDE( _etext = . );
PROVIDE( _eitcm = . );
  • .rodata 被并进 .text,所以 const 数组、字符串常量留在 Flash,不占 RAM——这是脚本帮你省 RAM 的关键一条。
  • *(.text.*) 负责收 -ffunction-sections 拆出来的每个函数段;输入段顺序近似链接顺序,--gc-sections 会把没被引用的成员剔掉。
  • .fini.init 对称,同样 KEEP
  • _etext(代码结束)、_eitcm(ITCM 别名,WCH 模板遗留)都是 PROVIDE:本工程没人引用,map 里是 [!provide]。想用就在 C 里 extern char _etext[];,链接器这才把它填上。

3.5 C++ 构造表五连:模板很长,本工程是空的

.preinit_array.init_array.fini_array.ctors.dtors 这五段是 GCC 的标准套路,作用是把散落在各个目标文件里的 C++ 全局对象构造函数收集成表,运行时由 __libc_init_array() 遍历调用:

  • .init_arraySORT_BY_INIT_PRIORITY(.init_array.*) 排序,决定 C++ 全局对象的构造顺序。
  • EXCLUDE_FILE (*crtbegin.o *crtbegin?.o *crtend.o *crtend?.o) 把 CRT 自带的辅助段排除在外,只留用户构造器。
  • .ctors/.dtors 里那段长注释讲的就是”crtbegin.o 必须排最前、crtend.o 必须排最后”,因为 __init_array_start/end 的边界由它们标定。

⚠ 本工程用 -nostartfiles,既不链 crtbegin.o,启动代码 handle_reset 也只做 jal SystemInitmretmain,**从来没有调用 __libc_init_array**。所以这五段在 map 里尺寸全是 0,__init_array_start/end 也都是 [!provide]。结论:这份脚本对 C 工程是”纯摆设”,但写 C++(或依赖 __attribute__((constructor)))时必须补上启动侧的调用。

3.6 .dalign / .dlalign / .data:VMA 与 LMA 分离

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
.dalign :
{
. = ALIGN(4);
PROVIDE(_data_vma = .);
} >RAM AT>FLASH

.dlalign :
{
. = ALIGN(4);
PROVIDE(_data_lma = .);
} >FLASH AT>FLASH

.data :
{
*(.gnu.linkonce.r.*)
*(.data .data.*)
*(.gnu.linkonce.d.*)
. = ALIGN(8);
PROVIDE( __global_pointer$ = . + 0x800 );
*(.sdata .sdata.*)
*(.sdata2.*)
*(.gnu.linkonce.s.*)
. = ALIGN(8);
*(.srodata.cst16) ... *(.srodata .srodata.*)
. = ALIGN(4);
PROVIDE( _edata = .);
} >RAM AT>FLASH

这是整份脚本唯一需要动脑的地方,要点三条:

  1. **两个零长段的唯一作用是”取地址”**。.dalign 声明 >RAM,所以它里面的 . 是 RAM 地址 → _data_vma = 0x20000000.dlalign 声明 >FLASH,同一时刻它的 . 是 Flash 地址 → _data_lma = 0x0000b458。两者尺寸都是 0,不占空间,纯粹为了绕开”一个段只能有一个 VMA”的限制。
  2. 启动代码怎么用它们startup_ch32x035.S):
1
2
3
4
5
6
7
8
9
10
	la a0, _data_lma
la a1, _data_vma
la a2, _edata
bgeu a1, a2, 2f
1: lw t0, (a0)
sw t0, (a1)
addi a0, a0, 4
addi a1, a1, 4
bltu a1, a2, 1b
2:

一次性搬 0xb0 字节(实测 .data 大小);.bss 那段循环则把 _sbss.._ebss 清零。

  1. __global_pointer$ = . + 0x800 是 RISC-V 专属:编译器把 -msmall-data-limit=8 判定的小变量放进 .sdata/.sdata2/.sbss,用 gp 相对寻址(偏移 ±2K)访问,省掉一条 lui。gp 指向 small data 区中间(前移 0x800),窗口才罩得住整片区域。启动代码里 la gp, __global_pointer$ 外面套了 .option norelax——否则这条 la 自己会被放松成 gp 相对的,鸡生蛋问题。实测 gp = 0x20000880,.sdata 起点 0x20000080,窗口 0x20000080 ~ 0x20001080。

.data 的第一行 *(.gnu.linkonce.r.*) 是模板遗留:把历史风格的只读数据也塞进 RAM 段,会白占 RAM。本工程这一段没有成员,可以先留着不管。

3.7 .bss_end

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.bss :
{
. = ALIGN(4);
PROVIDE( _sbss = .);
*(.sbss*)
*(.gnu.linkonce.sb.*)
*(.bss*)
*(.gnu.linkonce.b.*)
*(COMMON*)
. = ALIGN(4);
PROVIDE( _ebss = .);
} >RAM AT>FLASH

PROVIDE( _end = _ebss);
PROVIDE( end = . );
  • .bssNOBITS:只占地址、不占文件空间。虽然写了 AT>FLASH,objdump 的 program header 里 filesz 只覆盖 .data(0xb0),.bss 不会被烧进 Flash,清零完全靠启动代码。实测 _sbss = 0x200000b0_ebss = 0x200020c0(约 8.2 KB)。
  • *(COMMON*)-fno-common 时代残留的暂定定义;-fno-common 已经在编译选项里,所以基本是空的。
  • _end / end 是 newlib 的历史约定符号,_sbrk() 用它当堆起点,本工程 Debug/debug.c 就是这么写的(见 3.8)。

3.8 .stack:栈、_heap_end_sbrk

1
2
3
4
5
6
7
8
.stack ORIGIN(RAM) + LENGTH(RAM) - __stack_size :
{
PROVIDE( _heap_end = . );
. = ALIGN(4);
PROVIDE(_susrstack = . );
. = . + __stack_size;
PROVIDE( _eusrstack = .);
} >RAM
  • 段名后面直接用绝对表达式给地址:0x20000000 + 0x5000 - 0x800 = 0x20004800,与实测一致。这是”栈必须贴住 RAM 顶端”的标准写法,改 RAM 大小或栈大小时自动跟随。
  • 四个符号各司其职:
符号 实测值 谁在用
_heap_end 0x20004800 工程 _sbrk() 的堆上界
_susrstack 0x20004800 无引用([!provide]),留给需要栈底的代码
_eusrstack 0x20005000 startup 里 la sp, _eusrstack,即 sp 初值
__stack_size 0x800 脚本自身
  • 堆的真实区间由 _sbrk() 决定,debug.c
1
2
3
4
5
6
7
8
9
10
11
void *_sbrk(ptrdiff_t incr)
{
extern char _end[];
extern char _heap_end[];
static char *curbrk = _end;

if ((curbrk + incr < _end) || (curbrk + incr > _heap_end))
return NULL - 1;
curbrk += incr;
return curbrk - incr;
}

于是堆 = 0x200020c0_end)→ 0x20004800_heap_end),约 9.8 KB。**_heap_end 就是这个工程 malloc 的上限**;脚本一改 RAM 布局,堆边界自动跟着变,但要记得 _sbrk 是工程自己写的,不是库给的。

⚠ 栈向下长、堆向上长,二者之间没有保护区:栈溢出会直接踩进堆,而 _sbrk 的边界检查帮不上忙(它只管堆自己)。真要防,得靠硬件栈(startup 里 csrw 0x804, 3 打开的 HPE)或 MPU、看门狗。

四、同族脚本的几处差异

同一套 EVT 里其他例程的 Ld/Link.ld 与本工程只差几行,正好当”照着改的模板”:

例程 差异 说明
IAP/USB_UART/CH32X035_APP FLASH ORIGIN = 0x5000, LENGTH = 42K APP 从 IAP 之后开始:loader 里 User_APP_Addr_offset = 0x5000,两者必须一致;42K 由 0x5000 + 42K = 62K 反推
USB/USBFS/HOST_IAP/APP/Ld_APP ORIGIN = 0x6000, LENGTH = 40K 同上,只是偏移不同
RunInRam/RunInRAM_Select .highcodelalign>FLASH,给 _highcode_lma)+ .highcode>RAM AT>FLASH __attribute__((section(".highcode"))) 的函数放进 RAM 跑(比 Flash 快);startup 里多一段用 _highcode_lma/_highcode_vma_start/_highcode_vma_end 的搬运循环。这就是 3.6 节 VMA/LMA 套路的复用
FLASH/BootAsUser 多一个区域 BFLASH (rx) : ORIGIN = 0x1fff0000, LENGTH = 3328,并新增 .bbinit(收 .bxx/.Bcode>BFLASH AT>BFLASH __attribute__((section(".Bcode"))) 的函数放到 BOOT FLASH 区;main.c 注明该区不支持用户擦除、需用 WCH-LinkUtility ≥ V2.40 按 0x1FFF0000 下载,且跨区跳转约有 1µs 延迟
FreeRTOS/FreeRTOS_Core .stack 末尾多一行 __freertos_irq_stack_top = .; port.c 用它当中断栈顶(实测 0x20005000,等于 _eusrstack),即”任务启动前 main 用的那段栈被回收做 ISR 栈”
RT-Thread/rt-thread .text 里追加 FSymTab/VSymTab/.rti_fn*/RTMSymTab 段与 __fsymtab_start/end__rt_init_start/end__rtmsymtab_start/end RT-Thread 的自动初始化与 MSH 命令表全靠这些符号,用 KEEP(*(SORT(.rti_fn*))) 保证不被 --gc-sections 干掉

五、改脚本时的坑

说明
.dataAT>FLASH 初值会被放到 RAM 地址,烧录后 RAM 里是随机值,启动搬运循环也拿不到正确源地址
忘了 KEEP --gc-sections 下”只靠地址生效”的段(构造表、RT-Thread 初始化表)会被静默删掉,表现为”函数明明写了却没执行”
改了 LENGTH 没复核堆栈 栈用 ORIGIN + LENGTH - __stack_size 自动跟随;堆是 _end_heap_end,两边都动,务必重看 map
.rodata 挪进 RAM RAM 只有 20K,.bss 已占 8.2K、栈 2K;const 数据留在 Flash 才是默认且正确的选择
只改脚本不看 map 每处结论都该在 CH32X035G8U.map 里对得上:段地址、[!provide]*fill* 都能暴露对齐浪费
62K 写成 64K 该型号可用 ROM 就是 62K

六、速查表

问题 答案
程序从哪开始跑? ENTRY(_start) → 0x0 的 j handle_resetmtvec 指向 0x4 处的 .vector
.data 的初值在哪? Flash,LMA = 0xb458(_data_lma);运行时搬到 RAM 0x20000000(_data_vma
为什么要有 .dalign/.dlalign 一个段只能有一个 VMA;用两个零长段分别在 RAM/Flash 区域”读一次 .“,才拿到一对配对地址
__global_pointer$ 为什么要 +0x800 gp 相对寻址窗口是 ±2K,锚点放中间才能罩住整片 small data
栈和堆在哪? 栈:0x20004800 ~ 0x20005000(2K,sp 初值 0x20005000);堆:0x200020c0 ~ 0x20004800(约 9.8K,_end_heap_end
PROVIDE 和普通赋值差在哪? PROVIDE 只在被引用且别处没定义时才出现(否则 map 显示 [!provide]);= 无条件定义
为什么 _etext 在 map 里找不到? 没人引用这个 PROVIDE,链接器就没定义它;C 里 extern char _etext[]; 一引用就会出现
IAP 的 APP 为什么要改 ORIGIN loader 跳转地址是 0x5000,APP 的链接地址必须与之一致,否则所有绝对地址全错