搞嵌入式开发这么多年我一直觉得链接脚本是很多人的知识盲区。尤其是刚转到RISC-V平台、用CH32V103这类芯片做项目的时候大家宁可去啃晦涩的指令集手册也不愿意花半小时打开那个后缀是.ld的文件看一眼。我最早接触CH32V103时也一样直到被一个bootloader需求逼着改了地址偏移又在一次诡异的栈溢出里折腾了整整两天才彻底老实了——链接脚本这东西不是玄学它就是一张施工图纸看不懂图纸就盖楼早晚要出事。这篇文章准备用CH32V103这个非常典型的RISC-V MCU当例子把链接脚本的每一个关键部分拆开揉碎讲一遍。内容覆盖默认链接脚本的逐行解读、给Bootloader做地址偏移时该怎么改、栈空间不够时怎么科学调整以及最常见的链接报错怎么排查。适合正在用MounRiver StudioWCH官方IDE基于EclipseGCC做CH32V103开发的人也适合所有被collect2: error: ld returned 1 exit status折磨过、却不知道下一步该看哪行报错的朋友。1. 为什么链接脚本是RISC-V工程的施工图纸1.1 链接脚本在全套编译流程中的位置一个C语言工程从源码到能烧录的hex/bin文件要经过预处理、编译、汇编、链接四步。前几步负责把main.c变成CPU认识的机器指令但每条指令放在哪个地址、每个全局变量分配在哪块RAM、栈区从哪儿开始到哪儿结束这些地址相关的事全部由链接器在最后一步决定。链接器凭什么决定就凭链接脚本里的规则。GCC工具链默认有一套内置规则适合跑在操作系统上的普通程序但嵌入式芯片的Flash和RAM是两段地址完全不相连的物理空间代码放Flash里、可读写变量放RAM里这没法用默认规则解决必须给链接器一份专门的脚本。在CH32V103的工程里这份脚本就是Ld/CH32V103x8.ld不同SDK版本文件名可能有细微差异。MounRiver Studio新建工程时已经自动配好了它所以很多人从来没主动看过这个文件也能正常编译烧录。但一旦你需要在启动地址上做文章、给Bootloader和App做分区或者RAM里塞了一个超大的数组导致链接失败你就必须打开它了。1.2 哪些场景迫使你必须动手改ld我总结了几类非常高频的场景基本都是在实际项目中绕不过去的坎第一类Bootloader与App分区。芯片出厂后从0x00000000开始执行这是Flash的起始地址。Bootloader必须在低地址区App要往后面挪。这时候App工程里的链接脚本必须把FLASH的ORIGIN改到Bootloader之后否则App编译出来还是默认从0开始一烧就把Bootloader覆盖了。第二类RAM超额或需要精细分配。CH32V103C8T6的RAM只有20KBFlash是64KB。一个带协议栈、带RTOS、带大缓冲区的工程非常容易把RAM撑爆。链接器报region RAM overflowed的时候你要么裁剪代码要么就得考虑在ld里调整栈和堆的大小甚至把一些只读的大数组挪到Flash里跑。第三类OTA升级、自定义固件信息区、Bootloader跳转固件后需要记录版本号或升级标志。这类场景通常需要在Flash某个固定地址放一小段数据而GCC没有像某些IDE那样提供简单的地址定位操作最干净的办法就是在链接脚本里专门开一个输出段。第四类RTOS或多线程程序需要独立的栈空间。比如要给某个任务留一个固定的栈区域或者把系统栈和用户栈分开规划这些布局层面的需求最终都要落到ld文件上。2. CH32V103默认链接脚本逐行拆解2.1 入口点与MEMORY布局从哪里开始能用多少地方以MounRiver Studio生成的默认CH32V103链接脚本为例开头两段是入口点和内存布局ENTRY(_start) MEMORY { FLASH (rx) : ORIGIN 0x00000000, LENGTH 64K RAM (xrw) : ORIGIN 0x20000000, LENGTH 20K }ENTRY(_start)表示整个程序的入口符号是_startCH32V103的启动汇编文件startup_ch32v103.S里定义了_start全局符号CPU复位后会从这里开始取指。MEMORY块定义了两段可用的存储区域。FLASH的起始地址是0x00000000长度64KB属性rx表示可读可执行RAM的起始地址是0x20000000长度20KB属性xrw表示可读可写可执行。0x00000000开始是Flash、0x20000000开始是RAM这是RISC-V MCU非常经典的地址空间安排ARM Cortex-M的RAM通常也从类似的高地址段开始目的都是给不同存储介质划分独立的地址窗口。这里要注意ORIGIN和LENGTH不是随便写的它们直接决定链接器认为芯片里有多少空间。如果你把LENGTH写成24K链接器会默认为RAM有24KB链接时可能不报错但烧录运行时超出的部分就可能踩到不存在的地址上产生非常难排查的野指针问题。所以改这块之前务必核对芯片数据手册。2.2 输出段从.init到.stack的完整链路MEMORY下面通常有一段__stack_size 2048;之类的变量定义然后进入最核心的SECTIONS块。默认链接脚本会按顺序定义.init、.vector、.text、.fini、.data、.bss、.noinit、.heap、.stack这些输出段。.init段放启动初始化代码通常很短而且用KEEP()强制保留防止链接器做--gc-sections裁剪时给删掉。.vector段就是中断向量表。CH32V103是青稞V3A内核向量表里每个中断入口占4字节KEEP(*(.vector))保证向量表无论是否被引用都会被完整留在Flash起始位置。这是程序能正常响应中断的关键。.text段是所有代码和只读常量的家。注意这里不止收集了.text和.text.*还有.rodata和.rodata*用到的字符串常量、const修饰的全局变量都会放在这里最终占据Flash空间。很多人以为只读数据会进RAM其实不会它们一直在Flash里只是CPU能直接读取而已。.data段比较特殊它的写法是RAM ATFLASH。第一层意思链接后变量最终放在RAM地址空间第二层意思编译产物里这些变量初始值其实存放在Flash区域由启动代码在复位后从Flash拷贝到RAM。这就是VMA虚拟运行地址与LMA加载地址分离的核心思路。那段拷贝代码就在启动汇编里配合_sdata、_edata、_sidata这几个由链接脚本定义好的符号完成工作。.bss段放未初始化或初始化为0的全局变量启动代码会把这段RAM清成0。_sbss和_ebss这两个符号就是清零循环的起止边界。.noinit段则是不需要启动代码做任何初始化操作的内存区适合放一些重启后还想保留的数据比如复位原因、错误标志位。_sdata、_edata、_sbss、_ebss这些符号不是凭空出现的它们是链接器在解析SECTIONS的过程中自动计算并导出的。启动汇编代码里会通过la t0, _sdata这类指令拿到它们的值然后以“取地址、比较地址、跳到下一个地址”的循环方式做搬运和清零。理解了这一点你就能明白为什么ld文件里的符号定义位置很重要——必须定义在对应的输出段内部或紧邻位置否则启动代码拿到的就是错误边界。2.3 若干关键符号的幕后角色除了段边界符号链接脚本里还有几个符号要格外留意。__stack_size定义了栈的大小默认一般是2KB.stack段把栈区放在RAM末尾附近栈顶符号通常是_estack或者_sp。启动汇编里会执行la sp, _sp之类的指令把栈指针指向栈顶。还有一个很重要的全局指针符号__global_pointer$。RISC-V里GP寄存器指向一个“全局指针”区域用来寻址小数据段.sdata、.sbss等。链接脚本里通常会有这样一段PROVIDE(__global_pointer$ ORIGIN(RAM) 0x800);意思是如果代码里没有定义__global_pointer$链接器就自动提供这个符号。启动汇编里也有相应的GP初始化指令。这块你不太需要改但要明白它背后的机制RISC-V通过GP偏移量寻址小全局变量可以省掉一条指令代价是16bit偏移范围有限链接器会尽量把这类变量放在GP附近。.heap段和.stack段在RAM区域里按顺序排列堆向下增长malloc用栈向下增长函数调用用。如果堆和栈在中间相遇就会互相踩踏。这正是很多偶发死机的根源后面我会专门讲怎么改。3. 实战修改让App给Bootloader腾出空间3.1 地址偏移的方案设计假设Bootloader烧录在0x00000000~0x00001FFF这段8KB的Flash里App需要从0x00002000开始放。这时App工程的链接脚本要做两处核心改动MEMORY { FLASH (rx) : ORIGIN 0x00002000, LENGTH 56K RAM (xrw) : ORIGIN 0x20000000, LENGTH 20K }把FLASH的ORIGIN从0x00000000改成0x00002000LENGTH由64K减为56K剩下的56KB0x00002000~0x0000FFFF归App使用。RAM一般不动除非你想给Bootloader和App各自独立分配一段物理上隔开的内存。改这个之前有一个很多人忽略的操作先把原始ld文件备份一份。MounRiver Studio里改错了想还原如果手头没有备份新建工程再导出一份也非常麻烦。我现在的习惯是每个工程根目录下放一个ld_backup/文件夹所有原始配置都扔进去。还要确认一点Bootloader跳转到App时它自身的向量表、中断处理代码都还占据着低地址Flash。App这边的向量表如果在0x00002000那么只要Bootloader跳转前把mtvecRISC-V的中断向量基址寄存器设成0x00002000或者App启动后自己设置中断响应就会走App的向量表。CH32V103的system_ch32v103.c里通常会有一句__VECTOR_TABLE宏定义用于配置向量表位置链接脚本改了之后这个值也要跟着变成0x00002000。3.2 链接脚本与向量表偏移联动修改修改步骤我按顺序列一下第一步改链接脚本里FLASH的ORIGIN和LENGTH。这一步改完后直接编译你会发现.vector段会被链接器自动放进0x00002000因为它在这个区域内从起始位置开始布局。第二步确认向量表对齐。RISC-V向量表一般要求按表项数对齐。CH32V103的青稞V3A核需要留意手册里mtvec基址的最低两位和向量表对齐要求。稳妥的做法是让向量表地址满足64字节或256字节对齐0x00002000本身就是很大的对齐所以没问题。第三步改__VECTOR_TABLE宏。打开system_ch32v103.c或者对应的头文件定位到类似下面这样的代码#define __VECTOR_TABLE 0x00000000改成#define __VECTOR_TABLE 0x00002000这个宏最终会写入mtvec寄存器告诉CPU中断向量表在哪个地址找。第四步如果你的Bootloader有某种跳转协议比如给App传参、复位外设那还需要在App启动代码里考虑外设状态清理。这一步和链接脚本无关但和整体方案有关联。改完之后编译生成hex用WCHISPTool或WCH-Link直接烧录到0x00002000地址再让Bootloader执行一条跳转指令跳到0x00002000。上电后App的main函数、中断响应都能工作说明地址偏移改成功了。3.3 用Map文件和反汇编验证结果链接完别忘了看一眼map文件这是验证修改是否生效最快的方式。在MounRiver Studio的工程目录下编译后能找到.map文件打开搜索.vector你会看到.vector 0x00002000 0x80 ...这表示向量表被安置在Flash的0x00002000处大小0x80128字节可容纳32个向量。再搜索_start确认入口符号也在0x00002000附近的Flash区域App侧的复位处理确实被安排在新地址上。另一种验证方式是反汇编。用工具链里的riscv-none-embed-objdump -d对elf文件做反汇编从头文件里查看前几十条指令确认起点地址是0x00002000而不是默认的0x00000000。我个人踩过一次坑只改了MEMORY的ORIGIN忘改__VECTOR_TABLE结果一上电中断来了CPU还是去0x00000000找向量表后面挂着一堆Bootloader的地址数据跳进去直接HardFault。这种错误非常隐蔽因为代码编译链接都正常就是运行不对。所以每次改完地址布局我第一件事就是搜map文件里的.vector和_start两个地址确认它们都在预期位置。4. 实战修改正确增大栈空间4.1 先给栈溢出定个性很多人遇到函数跑着跑着就跳飞了、RTOS任务莫名挂掉、或者一调用某个大数组就HardFault第一反应是查数组越界第二反应是查指针。其实栈溢出在嵌入式里同样高发而且表现方式和指针错误很像。CH32V103的RAM只有20KB默认栈大小通常只有2KB。如果你的工程开了FPU、用了较多局部变量、递归嵌套层级深或者RTOS里每个任务栈都从这2KB里面切那栈溢出的概率会直线上升。判断栈溢出有个很实用的土办法在启动文件里或者在链接脚本里把栈区填充成一个特殊值比如0xDEADBEEF运行一段时间之后用调试器查看栈区域有多少特殊值被覆盖了。覆盖得越多说明过去的栈用量越接近栈顶离溢出越近。还有一个代码层面的判断方法观察那些不知为何死机的场景是否集中在深函数调用时。比如某个函数里定义了一个512字节的局部数组再调用一个三级函数链每级还要保存几十个字节的寄存器那栈需求轻松超过1KB默认2KB的栈很快就捉襟见肘。4.2 修改__stack_size与堆栈排布确定是栈不够之后改法其实很简单打开链接脚本找到__stack_size 2048;如果工程里没有堆的需求比如不用malloc可以直接改成__stack_size 4096;然后把.heap段的长度调小甚至删掉把RAM空间全部让给栈。这是因为堆和栈是同一块RAM里的两个方向增长的区间堆越大栈可用的相对空间上限就越小。假设RAM是20KB去掉.vector和.data、.bss占用真正空闲的大块区域就那么多堆和栈在这个区域里是按heap - stack顺序排布的堆在低地址、栈在高地址。堆用不到那么多时把它缩小就是给栈腾地方。需要特别提醒修改栈大小时不要盲目放大。如果你加了4KB栈而原有的静态全局变量和堆已经占了18KB那链接器很可能会报RAM溢出。这种情况下要么裁剪全局变量要么换RAM更大的芯片型号比如CH32V103的大RAM版本栈大小只是内存规划的一部分不是孤立变量。另外如果修改之后程序还是死机别急着继续加栈。先查一下是不是存在递归调用没有退出条件或者某个大数组确实越界了。栈大小改大只是把症状往后推根因不除迟早还会爆。我在实际开发中见过一个同事连续把栈从2KB加到8KB最后RAM被吃光仍然死机——后来发现是一个环形缓冲区的写指针越界把栈顶附近的数据全踩了。这个教训让我学会了栈溢出之前先用调试器排查有没有野指针写坏了栈区。4.3 堆栈统一规划的小技巧如果你想让RAM布局更加可控可以在链接脚本里把.heap和.stack段显式地址写得更加明确比如直接把栈放在RAM末尾. ORIGIN(RAM) LENGTH(RAM); __stack_end .;然后让栈向下增长堆向上增长这样堆和栈的冲突只有在RAM彻底耗尽时才会发生。这种方法在做功能安全项目、需要固定地址布局的场景里很实用。不过有一点要留意不同SDK的启动汇编引用栈顶符号名可能不一样有的用_sp、有的用_estack、有的用_eusrstack改动前一定要搜索一下启动文件里用的是哪个符号别改完ld、忘了启动文件结果sp初始化指错了地方。5. 常见链接错误与排查速查5.1 读明白collect2: error: ld returned 1 exit status几乎每个GCC嵌入式工程师都见过这句报错。它的意思是链接阶段的collect2工具有一个子进程调用ldld执行后返回值不是0表示失败。但这句话本身不告诉你任何具体信息真正的错误原因一定在它上面几行或者下面几行。所以看到这行就慌是很冤枉的。正确做法是把编译输出往上翻找到以riscv-none-embed-ld:开头的行或者/usr/bin/ld:开头的行那才是真正的错误内容。最常见的两种是riscv-none-embed-ld: region FLASH overflowed by 1232 bytes riscv-none-embed-ld: region RAM overflowed by 456 bytes前者表示Flash空间溢出放不下编译出来的代码和只读数据后者表示RAM溢出所有全局变量加堆栈加起来超过了20KB。看到这类错误就要回到链接脚本里看是不是LENGTH设置太小或者代码本身超出了当前芯片容量。如果没改过ld文件就溢出那就老老实实裁剪代码或换更大Flash的芯片。5.2 高频错误速查表与避坑技巧我把实际开发里遇到的高频链接错误整理成一张速查表报错片段常见原因排查步骤region FLASH overflowedFlash空间不足或FLASH LENGTH填错看map文件统计各段占用删冗余代码或改芯片region RAM overflowedRAM空间不足或栈/堆配置过大检查全局变量、栈大小、堆大小优先压缩堆undefined reference to _start启动文件缺失或ENTRY符号拼错确认startup_ch32v103.S参与编译链接undefined reference to_eusrstack或_estack链接脚本缺少栈顶符号或符号名不一致搜索启动文件用的栈符号名在ld里补齐multiple definition of xxx同一符号在多个源文件里定义搜索重复定义检查头文件是否被当成源文件编译cannot find -lxxx找不到某个库文件确认库路径、库文件名是否匹配relocation truncated to fit某个符号的地址距离超出指令寻址范围检查是否有超大数组跨地址段放置这里重点说一下符号不匹配问题。不同版本的SDK里栈顶符号命名的确可能不一样。我见过有的工程用_sp有的用_eusrstack有的把栈放在单独段里再导出_sstack/_estack。如果你用的是别人移植的工程原作者的ld文件不一定适配当前启动文件。遇到undefined reference to某符号时别只想到在C代码里定义它优先检查是不是ld文件里漏了对应的符号。还有一个非常实用的技巧用riscv-none-embed-nm快速查看elf里的符号地址。编译出elf文件后执行riscv-none-embed-nm -n app.elf | grep -E _start|_estack|_sdata|_edata你能直接看到这些关键符号被链接到哪个地址。如果_start指向0x00002000以外的地址或者_estack的地址不在RAM末尾附近说明ld文件改得有问题趁早回头检查。最后再分享一个习惯每次改了ld我会同时打开map文件里的Memory Configuration和Linker script and memory map两块先看Memory Configuration里的ORIGIN/LENGTH和预期是否一致再看map文件里每段VMA/LMA是否符合方案设计。这两步做完基本能把90%的链接脚本相关错误消灭在烧录之前。这个内容后续还可以继续扩展的方向挺多比如在链接脚本里给OTA升级做多固件区规划、给RTOS任务栈设置独立内存段、用自定义段做固件版本烧写区等。但最基础的还是先能读懂默认的ld文件、能老老实实地改对地址和大小这一步过了后面那些玩法都是水到渠成的事。
阅读完成 · 觉得有帮助?