
第一次用CH32V103调板子的时候我犯过一个特别蠢的错。工程编译零错误零警告程序烧进去板子一点反应都没有调试器连上之后PC指针停在了一个完全看不懂的地方。折腾了一下午最后发现是链接脚本里FLASH的LENGTH写错了——我用的是C8T664KB Flash结果工程里套了一个128KB Flash的链接脚本编译器把一部分代码和只读数据放到了物理上不存在的地址上。从那以后我养成了一个习惯新建任何一个芯片的工程第一件事就是把.ld文件从头到尾读一遍改每一个数字之前都要问一遍自己“这个地址对应的物理资源到底在哪”。这篇博文就围绕CH32V103的链接脚本.ld文件展开面向已经开始用RISC-V内核MCU做嵌入式开发、但还没系统性读过链接脚本的工程师。我会从“链接脚本是给谁看的”讲起一步步拆解CH32V103默认ld文件中每段话的含义然后给几个我自己用过的修改场景Bootloader预留空间、非初始化变量段、固定地址缓冲区、堆区调整最后聊聊改了ld之后最容易翻车的几种报错和排查顺序。内容会比较长但你跟着走完一遍以后再遇到“程序不跑”“一进中断就死机”“下载成功但校验失败”这类问题会多一条非常明确的排查思路。1. 链接脚本是芯片内存的“施工图”不读懂它改一处就翻一次车1.1 编译、汇编都过了链接这步才是MCU工程的“最后一公里”一个C程序从源码到可执行文件完整链路是预处理、编译、汇编、链接四步。前三步做的事情是把C代码翻译成CPU认识的指令但“这段代码要放在哪个地址、这个变量要放在哪里、堆栈区占多大”前三步全都不知道。它们只负责产出目标文件.o里面记录的是“我这里有一个函数叫main”“那里有一个全局变量叫count”至于这些符号最终落到内存的哪个位置是链接器的工作。PC上的程序不太需要关心这个问题因为操作系统装载器会在运行前动态分配虚拟地址你编译时用的是相对地址。但MCU裸机环境下完全不是一回事芯片上电后没有操作系统帮你安排内存CPU直接按固定地址取指令所以代码段放在Flash的哪个偏移、全局变量在RAM的哪个区域、栈顶在哪都必须在编译链接时就写死。这就是链接脚本存在的根本原因。1.2 .ld里到底管了哪三件事入口、存哪儿、怎么搬一个GCC链接脚本核心内容就三件事。第一程序入口在哪。对MCU来说不是main函数而是启动文件里的复位入口通常是_start或者Reset_Handler。这一行如果在ld里写错或漏掉程序可能还在跑但调试器一连接就找不对起点。第二哪些段放在哪块存储区。芯片有Flash有RAMFlash掉电不丢但只能在启动时通过加载器访问RAM运行快但掉电清零。ld文件里划分了.text、.data、.bss这些段并规定它们最终落在MEMORY里定义的哪一块区域。第三数据怎么搬。这是最容易忽略的一点。MCU上电时RAM内容是随机值而你在C源码里写uint32_t count 100;这个100是存在Flash里的。必须有代码在进入main之前把“初始值”从Flash复制到RAM的目标位置同时把没显式初始化、默认应该为0的变量清零。ld文件通过定义_sdata、_etext、_edata、_sbss、_ebss这些“锚点符号”让启动文件知道从哪里搬、搬到哪里、搬多长。整条链路缺一个符号程序就跑飞。1.3 CH32V103默认ld文件藏在哪MounRiver Studio和手动GCC两条路用MounRiver StudioWCH官方IDE基于Eclipse新建CH32V103工程时IDE会自动在项目根目录的Ld文件夹下生成一个类似CH32V103C8T6_FLASH.ld的文件。编译时Makefile会通过-T CH32V103C8T6_FLASH.ld把它传给链接器所以你在IDE里通常感觉不到它的存在直到某天需要修改Flash分区时才恍然大悟原来还有这么个文件。如果你习惯用VSCode配交叉编译工具链或者用CMake管理工程那链接脚本就更绕不开了。Makefile或CMakeLists.txt里的链接参数必须显式写-T你的芯片.ld没有这个参数链接器就只知道“我在为一个叫elf的目标格式工作”但完全不知道目标芯片的Flash有多大、RAM从哪开始。结果是链接器按默认的平坦地址模型来布局生成的elf烧进CH32V103百分百跑不起来。提示在MounRiver Studio里如果改了ld文件名或路径记得同步检查工程的链接配置。IDE的图形界面里通常有一个Linker Script的输入框填错了会直接报找不到ld文件。2. 把CH32V103的默认ld从头拆到尾MEMORY、SECTIONS与启动文件如何对暗号2.1 ENTRY(_start)程序入口不是main别被C语言习惯带偏打开CH32V103的ld文件第一句一般是ENTRY( _start )这一行指定了整个ELF文件的入口地址。芯片复位后CPU从Flash起始地址取出第一条指令然后一条一条执行。但调试器、OpenOCD这类工具要能正确地“认领”程序比如通过GDB执行load之后自动把PC设到正确位置就需要依赖ELF头里的入口字段。CH32V103的启动文件startup_ch32v103.s里通常把入口标号定义为_start有的版本会叫Reset_Handler。如果你动手移植别人的工程看到启动文件里写的入口是Reset_Handler而ld文件里写的是_start要特别小心。两者不一致虽然不一定导致链接失败因为地址可以由向量表定位但GDB一连接就会出现“PC停在非预期位置”的现象。最好的做法是启动文件、ld文件、向量表三处保持一致统一用一个入口宏来定义。2.2 MEMORY段为什么Flash从0x08000000开始、栈顶在0x20005000CH32V103的ld文件里内存定义一般是这样的MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 64K RAM (xrw) : ORIGIN 0x20000000, LENGTH 20K }注意一个反直觉的点CH32V103是RISC-V内核但它的Flash基地址是0x08000000RAM基地址是0x20000000非常像Cortex-M芯片。这并不是错误而是WCH为了兼容STM32F103的引脚与开发体验在内存映射上做了对齐设计。如果你之前玩过GD32VF103或者其他RISC-V单片机千万别想当然地认为“反正都是RISC-V地址都一样”每个厂商的内存映射可以完全不同。LENGTH 64K这个值直接对应你手上芯片的具体型号。CH32V103C8T6是64KB Flash、20KB RAM如果误用了CH32V103R8T6的128KB Flash设置链接器会把代码排布到0x08010000之后的地址而这些地址在物理上根本不存在烧进去程序肯定不跑。这也是我在开头提到的翻车事故的根本原因。RAM的区域起始是0x20000000长度20KB也就是0x5000字节所以最后一个字节的地址是0x20004FFF。很多启动文件里会直接定义栈顶为RAM末尾_estack 0x20005000;由于RISC-V的栈是向下增长的栈顶设在RAM最高地址压栈时往低地址方向走可以最大程度利用RAM空间。这个值必须和ld里RAM的ORIGIN LENGTH一致否则栈底可能落在物理RAM之外。2.3 SECTIONS核心.text、.data、.bss的“加载地址”和“运行地址”SECTIONS是ld文件的主体CH32V103的标准布局大致如下SECTIONS { .text : { . ALIGN(4); KEEP(*(.isr_vector)) *(.text) *(.text.*) *(.rodata) *(.rodata.*) . ALIGN(4); _etext .; } FLASH .data : { . ALIGN(4); _sdata .; *(.data) *(.data.*) . ALIGN(4); _edata .; } RAM AT FLASH _load_addr LOADADDR(.data); .bss : { . ALIGN(4); _sbss .; *(.sbss) *(.sbss.*) *(.bss) *(.bss.*) *(COMMON) . ALIGN(4); _ebss .; } RAM . ALIGN(4); _end .; }这段代码里信息量最大的是.data段的写法 RAM AT FLASH。翻译成大白话就是运行时这个段放在RAM里但它最初的“源数据”存放在Flash里。_sdata数据段在RAM中的起始地址初始化代码往这里开始写入。_load_addr数据段在Flash中的加载地址初始化代码从这里读取初值。_edata数据段在RAM中的结束地址。启动文件里的搬移伪代码逻辑类似for (src _load_addr, dst _sdata; dst _edata; dst, src) *dst *src;.text段里的_etext符号正好标记了只读区域结束的位置在一些版本的启动文件里它也被直接当作数据加载地址来用。不同版本的WCH工程加载地址符号名可能叫_load_addr也可能叫_etext或_sidata本质都是同一个Flash端地址。读懂了自己的启动文件再用哪个符号比背标准模板更重要。.bss段不需要加载地址因为它的初值全是0启动文件只需要知道RAM里起始地址_sbss和结束地址_ebss然后逐字节清零即可。2.4 启动文件里那些符号ld里谁在定义我把CH32V103启动文件和链接脚本之间的关键“接头暗号”整理成了表格排查问题时可以直接对照。ld中定义的符号含义启动文件中的典型用途_start程序入口地址复位后第一条指令所在处_estack栈顶地址RAM末尾初始化栈指针sp_sdata数据段运行起始地址初始化RAM数据的目标地址_load_addr/_etext数据段在Flash中的加载地址取出初值拷贝到RAM_edata数据段运行结束地址判断拷贝是否完成_sbssBSS段起始地址清零循环起点_ebssBSS段结束地址清零循环终点_end堆区起点动态内存分配起始这里有一个非常隐蔽的坑不同的编译器启动文件模板符号名习惯不一样。用riscv-none-embed-gcc的newlib启动文件时你可能会遇到__bss_start__、__bss_end__、__data_start__这一套符号和WCH的标准启动文件里的_sbss、_ebss完全不是一套体系。如果你从网上拷了一份启动文件又套用了WCH的ld链接阶段很可能报undefined reference to _sbss。这种问题排查不复杂但很浪费时间——先对齐符号名再分析程序跑不跑。3. 四种最常见的ld修改实战Bootloader预留、NOINIT段、固定地址缓冲区、堆栈调整3.1 IAP场景把FLASH ORIGIN改到0x08002000还要同步改mtvec做OTA升级或者IAP引导最常见的做法是Bootloader占用Flash前8KB应用App从0x08002000开始放。修改方法是在App工程里改ldMEMORY { FLASH (rx) : ORIGIN 0x08002000, LENGTH 56K RAM (xrw) : ORIGIN 0x20000000, LENGTH 20K }这里必须同时把LENGTH从64K改为56K因为0x08002000偏移了8KB后面的可用空间就只剩56KB。常见错误是只改了ORIGINLENGTH还写64K结果链接器会把后面的代码放到0x08010000之外又是个物理不存在的地址。改完ld还不够中断向量表也要跟着搬家。CH32V103和Cortex-M一样向量表默认放在Flash开头也就是0x08000000。如果App被链接到0x08002000但mtvec寄存器还指向0x08000000那么每次中断发生CPU都会从Bootloader的向量表里找中断处理函数表现就是“程序能跑一进中断就死机或跳回Bootloader”。在WCH的标准外设库里可以通过NVIC_SetVectorTable(NVIC_VectTab_FLASH, 0x2000);来设置偏移底层实际就是写mtvec。如果你的工程是自己手写的启动文件记得在main早期把mtvec设置成0x08002000。这个操作和链接脚本属于“双人配合”漏了哪一个效果都是灾难性的。3.2 添加.noinit段软件复位标志不再被启动代码清零有些变量不希望在MCU复位后被自动清零典型例子是软件复位标志、休眠唤醒计数、死机记录。默认情况下所有未显式初始化的全局变量都进.bss段复位后被启动代码清成0这个特性会毁掉你的上次运行状态。解决方法是在ld文件里额外定义一个.noinit段.noinit (NOLOAD) : { . ALIGN(4); *(.noinit) *(.noinit.*) . ALIGN(4); } RAM注意(NOLOAD)这个属性它告诉链接器这段不需要在Flash中准备加载数据也不要在运行时做初始化搬运或清零。C代码里这样用__attribute__((section(.noinit))) uint32_t boot_count; __attribute__((section(.noinit))) uint8_t warm_reset_flag;这个技巧特别适合做“软复位判断”。程序重启后读一下warm_reset_flag如果是1说明是软件复位而不是上电复位可以跳过某些硬件初始化流程直接恢复现场。但要注意只有复位类型是“不真正断电”的复位比如NVIC_SystemReset触发RAM内容才会保留。如果用户把电源拔了又重新插上boot_count一样是随机值所以使用前还是需要结合复位状态寄存器综合判断。3.3 用KEEP固定一个绝对地址的DMA缓冲区有些外设对缓冲区地址有要求比如DMA目标地址如果要求512字节对齐而你随便定义了一个结构体数组编译器不一定给你排到对齐地址上。虽然可以通过C语言的__attribute__((aligned(512)))在编译层面解决但更激进的做法是在ld文件里直接把某个段钉到RAM的具体位置。在SECTIONS里这样写.my_buffer (NOLOAD) : { . ORIGIN(RAM) 8K; KEEP(*(.my_buffer)) } RAM对应C代码__attribute__((section(.my_buffer))) uint8_t dma_buf[1024];KEEP()的作用是防止链接器做垃圾回收时把这个看起来“没被引用”的段删掉。现在的MCU工程经常开-ffunction-sections -fdata-sections配合--gc-sections来减小体积任何一个没被代码直接引用的自定义段都可能被优化掉。写上KEEP()就是明确告诉链接器“这段我另有用处别动”。这种做法的优点是完全可控缺点是会人为制造碎片。如果你把缓冲区放在RAM中间链接器就无法再把这个区域分配给普通变量RAM本身才20KB每浪费1KB都得想清楚值不值。我个人一般只在绝对必要的时候才这么干能用__attribute__((aligned()))解决的对齐问题优先用C语言层面解决。3.4 堆和栈在20KB RAM里如何平衡ld中手动定义堆区CH32V103的RAM只有20KB在同时使用FreeRTOS、堆内存分配、较大的数据缓冲区时内存很容易紧张。默认情况下堆的大小可能在启动文件里以宏的形式定义或者在ld文件里通过PROVIDE定义。一个手动定义堆区的常见写法.heap : { . ALIGN(4); __heap_start__ .; . . 0x1000; __heap_end__ .; } RAM意思是把堆放在.bss段之后大小为4KB。注意堆是从低地址往高地址增长栈是从0x20005000往低地址增长的两者相向运动。如果堆顶超过了栈底程序就会出现“过一段时间才崩溃”的典型内存踩踏现象。改堆大小前最好先看编译生成的.map文件确认当前.text、.data、.bss到底占了多少RAM再决定堆分配多少。我不止一次见到有同事在只有20KB RAM的芯片上想当然用new和malloc做大量动态分配结果系统运行几小时后随机死机。CH32V103这种入门级MCU能用静态数组解决的事尽量不要用堆。4. 改动ld后常翻的五类车报错信息与排查链路4.1 collect2: error: ld returned 1 exit status真正的错误在上面这是GCC工具链在链接失败时最经典的最终报错。很多新手看到collect2: error: ld returned 1 exit status就懵了以为这是什么高深的错误码其实它只是一句包装话意思是“链接器进程非零退出”。真正有价值的错误信息在这行上面需要往上翻编译日志。常见的子错误包括section .text will not fit in region FLASHFlash空间不足或者ORIGIN/LENGTH设置不合理region RAM overflowed by xxx bytesRAM溢出undefined reference to _estack启动文件引用的符号没有在ld里定义relocation truncated to fit: R_RISCV_HI20 against symbol某条指令的寻址范围不够通常和段布局有关在MounRiver Studio里有时候IDE的错误窗口只显示一行摘要务必切换到Console/Problems视图找到真正的报错。如果你在命令行构建建议加V1或查看完整log别只盯着最后一行。4.2 region FLASH overflowedLENGTH错了还是ALIGN导致的问题这个报错有两个常见原因。第一个是芯片型号和ld不匹配。比如用CH32V103C8T6LENGTH却写的是128KFlash地址空间到了0x08020000编译器按128KB排布代码量超过实际64KB链接器虽然能排下因为地址空间理论上有128KB但烧录时或运行时就不正常。这种错不一定报overflow反而可能报“No space in execution regions”或者下载校验失败。第二个是ALIGN对齐导致的空间超限。假设Flash剩下最后的几个字节. ALIGN(4)会把当前地址推进到下一个4字节边界如果跨过了ORIGIN LENGTH的终点就报overflow。这种现象很微妙因为代码量看起来明明“差不多刚好”但就是放不下。排查方法是看.map文件末尾的.值和0x0801000064KB边界对比。4.3 改了Flash偏移后进中断就跑飞向量表与ld必须同步偏移这个现象特别有迷惑性。程序烧进去main函数里点灯正常主循环正常表面看一切没问题但只要你按下一个按键触发外部中断设备立刻死机或者PC跳到一个看似随机的地址。原因我前面已经提过向量表还在0x08000000。App被链接到0x08002000但mtvec还指向Flash开头中断一来CPU从0x08000000读取向量得到的是Bootloader的中断处理地址或者一个还没配置的复位向量程序自然就飞了。排查顺序我建议这样先确认ld文件FLASH ORIGIN确实是0x08002000别改错地方。再确认代码里有没有调用NVIC_SetVectorTable(NVIC_VectTab_FLASH, 0x2000);或者直接写寄存器。最后用调试器查看mtvec寄存器的实际值确认它等于0x08002000。如果三者不一致程序飞了就一点都不奇怪。4.4 undefined reference to _estack启动文件与ld符号对不上移植工程时最常见的问题。你从A厂商的例程里拷贝了startup_ch32v103.s又用了B工程生成的ld两者对栈顶符号的命名可能不一样。一个叫_estack一个叫__stack_top链接器报undefined reference还算好的至少能定位。更坑的是符号名字一样、含义不同比如_end在不同库里的含义有微妙区别那就要对着.map文件一个个看。我在实际工作中遇到过一次启动文件引用了_sdata、_edata但ld里定义的是__data_start__、__data_end__结果链接通过了通过某种兼容符号可启动文件根本没执行搬运所有已初始化全局变量全是0程序表现就是各种初始化逻辑失效。最后靠对比.map文件和启动文件反汇编才找出问题。所以每次换启动文件或换ld模板第一件事就是检查符号清单。4.5 下载后校验失败下载器配置与ld的ORIGIN冲突链接脚本里把Flash起始改成0x08002000之后如果IDE或调试器里的Flash下载算法还默认从0x08000000开始擦写就会出现烧录成功、但运行不对的情况。更严格一些的下载器配置还会报“verify failed at address 0x08002000”。原因很简单下载算法本身的地址范围可能写死了设备Flash的起始地址和扇区映射。Bootloader和App分区时如果App工程烧录时也从地址0开始擦除顺便把Bootloader干掉了。解决办法是在MounRiver Studio的调试配置/下载配置里把Flash烧录的起始地址改成App的链接地址0x08002000。注意每次修改ld里的Flash分区都要同时检查三处链接脚本ORIGIN、代码里mtvec偏移、下载器烧录起始地址。这三处没有联动好就会出现各种“编译没错但跑不起来”的诡异问题。5. 改ld前的三条工作流建议备份、看map、用nm和objdump验收5.1 用映射文件(.map)验证段地址是否符合预期链接完成后工具链默认会在输出目录生成.map映射文件。这个文件记录了每个段的起始地址、结束地址、占用大小、以及每个符号的绝对地址。改完ld后第一件事就是看.map文件里的关键段地址.text起始地址是否等于ORIGIN(FLASH)_etext的值是否在Flash范围内.data段运行地址是否在RAM中_end符号是否在0x20005000之下这些值全部符合预期再进下一步调试。很多人跳过这一步直接在板子上跑出问题后才回头查效率很低。5.2 用nm、objcopy、objdump三板斧确认产物如果你常用命令行工具链这三条命令是检查链接结果的利器。riscv-none-embed-size build/ch32v103.elf riscv-none-embed-nm -n build/ch32v103.elf | grep -E (_start|_estack|_sdata|_edata|_sbss|_ebss|_end) riscv-none-embed-objdump -d build/ch32v103.elf | tail -50size看text/data/bss各自占用多少字节nm -n按地址排序打印符号重点看栈顶和搬运边界objdump -d反汇编可以确认第一条指令确实在_start处并且能看到启动代码的搬运循环是否正确引用了符号。这三板斧做完链接脚本的修改基本就有信心了。5.3 工程文件的ld版本管理与注释习惯ld文件是一个工程里最容易被“悄悄改动、然后集体失忆”的文件。两个人同时改Flash分区或者某次调试时临时改了栈大小忘了还原都可能把工程带入一个难以排查的状态。我现在要求所有涉及内存布局的改动必须在ld文件里写明注释包括改动目的、日期、影响范围。Git提交信息里也要单独提到“ld: FLASH ORIGIN 0x08002000 for bootloader”。这看起来是个很简单的习惯但真的能帮你避免很多折腾。另外每次改完ld之后保留一份编译产物的elf文件下次如果出现新问题可以用objdump对比两次布局的差异。最后分享一个我实际用着很顺手的检查技巧修改Flash起始地址后烧录前用objcopy从elf生成一份bin文件然后用十六进制编辑器打开看前256字节。正常情况下bin文件开头应该是向量表第一个32位字是栈顶地址第二个32位字是复位入口地址。如果这两个值和你的预期不一致那基本可以断定是向量表或者链接脚本出了问题趁早排查比等到板子上跑飞再查要省太多时间。