结论速览
- 链接脚本只干三件事:排布(谁进 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)、ORIGIN、LENGTH |
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,_start 由 Startup/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.map 与 objdump --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、_eitcm、end、_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_reset 里 mtvec = _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_array 用 SORT_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 SystemInit → mret 到 main,**从来没有调用 __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
|
这是整份脚本唯一需要动脑的地方,要点三条:
- **两个零长段的唯一作用是”取地址”**。
.dalign 声明 >RAM,所以它里面的 . 是 RAM 地址 → _data_vma = 0x20000000;.dlalign 声明 >FLASH,同一时刻它的 . 是 Flash 地址 → _data_lma = 0x0000b458。两者尺寸都是 0,不占空间,纯粹为了绕开”一个段只能有一个 VMA”的限制。
- 启动代码怎么用它们(
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 清零。
__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 = . );
|
.bss 是 NOBITS:只占地址、不占文件空间。虽然写了 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 干掉 |
五、改脚本时的坑
| 坑 |
说明 |
.data 漏 AT>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_reset;mtvec 指向 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 的链接地址必须与之一致,否则所有绝对地址全错 |