第一次用CH32V103调板子的时候,我犯过一个特别蠢的错。工程编译零错误零警告,程序烧进去,板子一点反应都没有,调试器连上之后,PC指针停在了一个完全看不懂的地方。折腾了一下午,最后发现是链接脚本里FLASH的LENGTH写错了——我用的是C8T6,64KB 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有RAM,Flash掉电不丢但只能在启动时通过加载器访问,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 Studio(WCH官方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开始、栈顶在0x20005000
CH32V103的ld文件里,内存定义一般是这样的:
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K }注意一个反直觉的点:CH32V103是RISC-V内核,但它的Flash基地址是0x08000000,RAM基地址是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 | 数据段运行结束地址 | 判断拷贝是否完成 |
_sbss | BSS段起始地址 | 清零循环起点 |
_ebss | BSS段结束地址 | 清零循环终点 |
_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工程里改ld:
MEMORY { FLASH (rx) : ORIGIN = 0x08002000, LENGTH = 56K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K }这里必须同时把LENGTH从64K改为56K,因为0x08002000偏移了8KB,后面的可用空间就只剩56KB。常见错误是只改了ORIGIN,LENGTH还写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 FLASH:Flash空间不足,或者ORIGIN/LENGTH设置不合理region RAM overflowed by xxx bytes:RAM溢出undefined reference to '_estack':启动文件引用的符号没有在ld里定义relocation truncated to fit: R_RISCV_HI20 against symbol:某条指令的寻址范围不够,通常和段布局有关
在MounRiver Studio里,有时候IDE的错误窗口只显示一行摘要,务必切换到Console/Problems视图找到真正的报错。如果你在命令行构建,建议加V=1或查看完整log,别只盯着最后一行。
4.2 region 'FLASH' overflowed:LENGTH错了还是ALIGN导致的问题
这个报错有两个常见原因。
第一个是芯片型号和ld不匹配。比如用CH32V103C8T6,LENGTH却写的是128K,Flash地址空间到了0x08020000,编译器按128KB排布,代码量超过实际64KB,链接器虽然能排下(因为地址空间理论上有128KB),但烧录时或运行时就不正常。这种错不一定报overflow,反而可能报“No space in execution regions”或者下载校验失败。
第二个是ALIGN对齐导致的空间超限。假设Flash剩下最后的几个字节,. = ALIGN(4)会把当前地址推进到下一个4字节边界,如果跨过了ORIGIN + LENGTH的终点,就报overflow。这种现象很微妙,因为代码量看起来明明“差不多刚好”,但就是放不下。排查方法是看.map文件末尾的.值,和0x08010000(64KB边界)对比。
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位字是复位入口地址。如果这两个值和你的预期不一致,那基本可以断定是向量表或者链接脚本出了问题,趁早排查比等到板子上跑飞再查要省太多时间。