ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入解析CH32V103链接脚本:Flash分区、自定义段与调试技巧

深入解析CH32V103链接脚本:Flash分区、自定义段与调试技巧 如果你在编译CH32V103工程时遇到过“collect2: error: ld returned 1 exit status”这样的报错或者想给板子划分Bootloader区域却不知道Flash地址从哪改那这篇内容就是为你准备的。链接脚本.ld在嵌入式工程里是个容易被忽略、却直接决定程序能不能跑起来的文件。它不长但每一行背后都有讲究改错了不是编译过不去就是上电直接HardFault。这篇文章我会用实际工程经验把RISC-V架构下CH32V103的链接脚本从头到尾拆开讲手把手带你看懂每个段、每个符号在干什么并给出可直接套用的修改案例和报错排查方法。适合正在学RISC-V嵌入式开发的人也适合已经在用MounRiver StudioMRS或GCC工具链但一直没搞懂链接脚本的工程师。看完你不仅能改Flash大小、自定义段还能在链接出错时自己定位问题而不是对着红字干瞪眼。1. 为什么要读懂链接脚本从一次ld报错说起1.1 链接脚本到底在干什么很多人把编译器当成一个黑盒点一下就出Hex文件中间发生了什么一概不知。实际上GCC工具链是分两步走编译器和汇编器把每个.c文件对应的C代码翻译成目标文件.o链接器ld再把一堆.o文件合并成最终的可执行文件.elf。链接脚本就是告诉ld“怎么合并”的图纸哪些代码放到Flash的哪个位置哪些数据放到RAM栈顶在哪里堆有多大。我见过不少新手朋友在工程里加了几个.c文件之后突然报出“region RAM overflowed by xxx bytes”第一反应是删代码。但有时候不是代码太多而是链接脚本里RAM区设置得太小或者某个大数组被放进了不该放的段。看不懂脚本就只能靠瞎试效率很低。对CH32V103这种RISC-V内核的芯片来说链接脚本还牵扯到一个特殊问题启动流程。芯片上电后从哪里取第一条指令向量表放在哪个地址栈指针怎么初始化这些都和链接脚本里的符号直接相关。像_start、_estack、_vector_start这些符号基本都是启动汇编文件里的“约定暗号”链接脚本负责给它们赋上真正的地址。1.2 CH32V103的存储器布局在看脚本之前得先把芯片的内存地图记在脑子里。CH32V103用的是青稞V3A内核片上Flash起始地址为0x00000000容量48KBSRAM起始地址为0x20000000容量10KB。另外还有一块不可直接作为运行区的系统存储区0x1FFFF000用于ISP引导。这三个地址是整个链接脚本的基础。Flash区也叫代码区一般只读RAM区需要可读可写。链接脚本里的MEMORY命令就是把这些硬件的物理内存用更形象的名字圈出来再声明每个区域的起始地址和长度。如果芯片型号变了比如换到CH32V203Flash和RAM的地址、容量都会发生变化第一件事就应该去查数据手册并修改这里的参数。理论上只要ORIGIN和LENGTH这两个值和你芯片实际资源一致其他部分不太会出大问题。但实际工程中经常有人把Flash的LENGTH改成比芯片容量更大的数值导致链接器认为“有这么多空间”编译能过但在线调试或烧录后程序行为异常。所以看懂MEMORY段是读懂整个脚本的第一步。2. 逐段拆解CH32V103默认链接脚本2.1 MEMORY命令Flash与RAM的边界约束MounRiver Studio默认生成的CH32V103链接脚本核心MEMORY段长这样我做了精简实际工程中还包含一些保留段ENTRY(_start) MEMORY { FLASH (rx) : ORIGIN 0x00000000, LENGTH 48K RAM (xrw) : ORIGIN 0x20000000, LENGTH 10K } _estack 0x20002800;第一行的ENTRY(_start)定义了入口点也就是程序第一条指令所在的符号。RISC-V的启动汇编里通常会有_start标签如果找不到这个符号链接器会报cannot find entry symbol。MEMORY命令里两个区域分别带了一组访问属性rx表示这一段是只读且可执行适合放代码和常量xrw表示可读可写可执行适合放变量和栈。属性写错了也有麻烦比如把RAM误写成ro链接器就会拒绝把可写的.data段放进去。注意_estack 0x20002800这一行。0x20000000是RAM起始地址0x2800换算成十进制就是10240正好是10KB。也就是说栈顶被设置成了RAM最高地址。如果把RAM的LENGTH改了这个_estack的值也必须同步调整否则栈指针可能指向不存在的内存区域。2.2 SECTIONS命令从.o到.elf的拼装图纸SECTIONS是链接脚本的主体它告诉链接器哪些输入段来自.o文件应该组合成输出段以及输出段放在哪个内存区域。看一个典型的CH32V103脚本核心部分SECTIONS { .init : { _prog_start .; KEEP(*(.init)) } FLASH .vector : { . ALIGN(4); _vector_start .; KEEP(*(.vectors)) _vector_end .; } FLASH .text : { *(.text*) *(.stext*) } FLASH .rodata : { *(.rodata*) *(.srodata*) } FLASH .data : { _data_start .; *(.data*) _data_end .; } RAM AT FLASH .bss : { _bss_start .; *(COMMON) *(.bss*) _bss_end .; } RAM }这段脚本里出现了四种常见段。.init和.vector放在最前面KEEP()的作用是强制保留这些段里面的内容即使没有被代码引用也不允许被垃圾回收。这对向量表尤其重要因为向量表不是通过CALL指令访问的芯片上电时由硬件根据固定地址跳转编译器无法感知它的“引用关系”不加KEEP就可能被--gc-sections优化掉。.text段放的是真正的机器指令。*(.text*)表示把所有.o文件里的.text子段都吸收进来末尾的通配符*能匹配.text.cmain、.text.foo这类编译器按函数名拆分出来的子段。.rodata存放const修饰的只读常量、字符串字面量等放在Flash里既能省RAM也能防止程序运行中意外改写。.bss段是未初始化或零初始化的全局变量区域。这里有个初学者容易懵的细节*(COMMON)放在*(.bss*)前面。COMMON块对应GCC中“未初始化但未指定section”的全局变量它的加载地址由链接器分配而.bss*是已经明确标记为bss的变量。两者都只占RAM空间不占Flash因为初始值全是0不需要在Flash里保存。2.3 data段为何要ATFLASHLMA与VMA的区别.data段是全局变量的重头戏。注意它的写法先用 RAM指定运行地址VMA再用AT FLASH指定加载地址LMA。意思是说变量在程序运行时位于RAM里但初始值必须保存在Flash里上电时由启动代码把它从Flash拷贝到RAM。可以这么理解VMA是变量“工作时的住址”LMA是它“出厂前的存放位置”。C语言里的int global_val 5;这个5的初始值不可能凭空产生必然是烧录在Flash里的某个地方。程序启动后启动汇编代码会执行一段“搬运工”逻辑把Flash中紧跟在.data加载地址后面的数据一段一段复制到RAM的.data区域。链接脚本里定义的_data_start、_data_end和_data_loadaddr就是给这段“搬运”代码用的坐标。_data_loadaddr LOADADDR(.data);这句的意思就是取.data段的加载地址也就是在Flash里存放初始值的那个位置。如果你删掉了AT FLASH链接器会默认LMA等于VMA认为初始值本来就该放在RAM里搬运逻辑就完全错乱了。这是修改脚本时最容易踩的坑之一。2.4 栈与堆的分配逻辑链接脚本里经常还会出现以下类似的栈堆定义_heap_size 0x400; _stack_size 0x400; .heap : { . ALIGN(4); PROVIDE(_sbss .); . . _heap_size; } RAM .stack : { . ALIGN(4); . . _stack_size; _estack .; } RAM有些工程是把栈底地址固定写在脚本开头比如前面看到的_estack 0x20002800有些则是留出空间后让汇编再设置栈指针。无论哪种写法核心思路都是一样的在RAM顶端给栈划一块专用空间栈向下生长堆向上生长两者之间是动态内存池。_stack_size和_heap_size两个值直接决定了程序能使用的栈深度和malloc内存大小。RISC-V的栈是满递减栈即每次压栈后sp指针递减。如果递归层数深、局部变量又大栈区设置得太小就会悄悄覆盖掉旁边的变量区表现为“程序跑着跑着莫名其妙乱跳”或“某个全局变量值无故被改”。这类问题很难查所以一开始就要养成把栈区留足的习惯。3. 三个拿来即用的修改案例3.1 案例一划分Bootloader与App的Flash区域做OTA或远程升级时最常用的改法是把Flash分成两块Bootloader区域和App区域。假设Bootloader需要16KBApp区域从0x00004000开始MEMORY { FLASH (rx) : ORIGIN 0x00004000, LENGTH 32K RAM (xrw) : ORIGIN 0x20000000, LENGTH 10K }App工程的链接脚本只需要改这两行。与此同时Bootloader跳转到App前需要确认App的向量表地址也指向0x00004000。CH32V103的NVIC_SetVectorTable或SCB-VTOR这类寄存器设置需要根据实际库函数接口来调整。还有一个容易忽略的点_estack仍然要指向RAM顶端不能因为Flash地址变了就跟着改栈和Flash没有直接对应关系。我之前见过有人把_estack也改成0x200000000x4000结果栈顶落在RAM中间一运行就覆盖了全局变量区整个程序崩溃得莫名其妙。App和Bootloader各自拥有独立的链接脚本。Bootloader工程使用完整的Flash区域App工程只使用App区间。两个工程编译出的Hex文件烧录到不同Flash地址通过Bootloader跳转实现无缝升级。如果用下载器直接把App烧到芯片上芯片是无法启动的因为它没有Bootloader做必要的硬件初始化和跳转引导。3.2 案例二把常量表塞进自定义段实际项目中我们经常遇到需要把某个大常量表放到固定位置的情况比如中文字库、固件版本号、加密密钥。默认情况下这些常量会被放进.rodata而.rodata的具体排列顺序是由链接器决定的不方便程序里通过固定地址访问。解决方法是在链接脚本里新增一个段.my_const_table : { . ALIGN(4); KEEP(*(.my_const_table)) . ALIGN(4); } FLASH然后在C代码里这样将变量放入该段const uint8_t version_info[] __attribute__((section(.my_const_table))) { V, 1, ., 0, 0x00 };这样version_info数组就会被强制放在Flash中.my_const_table段对应的位置。如果需要知道这个段的起始地址可以定义两个符号来标记.my_const_table : { _my_const_table_start .; KEEP(*(.my_const_table)) _my_const_table_end .; } FLASH然后在C里用extern声明这两个符号来获取地址。类似const uint8_t* p_table (const uint8_t*)_my_const_table_start;。这种方式在写Bootloader跳转、存储Boot参数、固定版本号时都非常实用。这里有个经验点给自定义段起名字时最好用一个有辨识度的前缀不要用.text或.data这类会跟系统段冲突的名字。另外自定义段如果会被C代码引用一定要加KEEP()否则在编译优化时如果链接器认为没人“主动引用”这段内容可能会被垃圾回收机制丢掉。3.3 案例三手动调整栈大小很多朋友在调试时遇到莫名奇妙的HardFault查来查去最后发现是栈溢出。这时候就需要手动调整栈大小。不同工程模板对栈的定义方式不太一样有的是在汇编启动文件里写死了_stack_size有的是通过链接脚本的_stack_size符号控制。以链接脚本控制栈大小的工程为例如果当前栈大小是0x4001KB想改成4KB直接改定义即可_stack_size 0x1000;改完之后重新编译然后用riscv-none-embed-size -A查看elf文件的段占用情况重点关注.stack和.heap的变化。栈大小改大意味着RAM可用空间减小如果RAM里本来就有大缓冲区可能出现region RAM overflowed。此时就要反过来考虑优化方案缩小栈和堆中没用到的那一个或者把部分大数据移到Flash。判断栈是否溢出还有一个土办法在启动阶段把整个栈区域填充成一个特殊值比如0x5A运行一段时间后再查看RAM中的这个区域如果0x5A被覆盖得很深说明栈深度已经逼近极限。这个方法虽然土但定位问题非常有效。4. 链接过程中常见报错与排查技巧4.1 高频错误速查表链接脚本相关的报错信息往往看起来吓人但大多数属于几类固定问题。我把实际工程中高频出现的错误整理成了一张表供大家对照排查。报错信息根本原因处理思路region FLASH/RAM overflowed by xxxx bytes代码或数据超出了对应存储区长度优先检查MEMORY中的LENGTH是否写错确认是否有大数组、大常量表占用了过多空间section .text will not fit in region FLASH.text段长度超过了Flash剩余空间用size -A查看各段实际占用决定精简代码还是调整Flash划分relocation truncated to fit: R_RISCV_HI20某个符号地址超出指令寻址范围通常是因为段被放置到了过远的地址检查ORIGIN和段顺序undefined reference to _estack链接脚本没有定义启动文件引用的符号在脚本中补充对应符号定义或核对符号名拼写cannot find entry symbol _startENTRY指定的入口符号不存在检查启动汇编文件是否被正确加入工程确认_start标签存在multiple definition同一个符号在多个.c文件或段中重复定义检查全局变量定义改用static或extern声明仔细看可以发现大部分链接错误最终都指向两个方向要么是存储区容量计算错误要么是启动文件与链接脚本的符号约定不一致。先按这个思路排查效率会高很多。4.2 用Map文件和工具链定位问题当链接器报错或程序运行异常时第一个应该打开的就是.map文件。在MounRiver Studio里编译完成后默认会生成一个.map文件里面记录了每个段最终被放置的起始地址、长度以及每个.o文件贡献了多少内容。查看.map文件时我通常会先看以下三块内容Memory Configuration确认MEMORY声明是否符合芯片实际资源Linker script and memory map逐段查看各段的VMA和LMA重点检查.data段的加载地址是否落在Flash里Cross Reference Table如果有查看符号之间的引用关系排查未定义或重复定义的来源。同时可以用工具链自带的命令快速查看elf的段信息。在命令行进入工程目录执行riscv-none-embed-size -A ./Debug/project.elf这条命令会列出所有段的名称、大小和地址一眼就能看出哪个段把空间吃满了。想查看具体符号的地址可以用riscv-none-embed-nm -n ./Debug/project.elf按地址排序打印所有符号用这个可以确认_estack、_vector_start等关键符号是否落在预期的内存区域。每次改完链接脚本我都会跑一遍这两条命令确认没有偏离预期才继续写代码。objdump -h也值得常备riscv-none-embed-objdump -h ./Debug/project.elf它能清晰展示每个段的VMA、LMA、文件偏移和大小特别适合检查AT FLASH是否生效。.data段通常会有两个不同的VMA和LMA地址这恰恰是正确配置的标志。5. 链接脚本调试小技巧与个人经验5.1 几个容易被忽略的坑写过不少RISC-V工程的链接脚本有几个隐藏比较深的坑值得单独拿出来说。第一个是和--gc-sections的配合。如果工程开了函数级裁剪链接器默认会移除没有被引用的段。向量表、中断服务函数这类不会出现在“显式调用”链里的内容必须用KEEP()保护否则编译出的程序一上电就跑飞。建议在链接脚本里对所有包含.isr、.vectors、.init字样的段统一加上KEEP()。第二个是符号命名的大小写问题。GCC工具链是区分大小写的_estack和_eStack完全不是同一个符号。很多链接报错就是启动汇编里写的是_estack链接脚本里写的却是_Estack查了半天都不知道为什么找不到。我现在的习惯是所有跨文件、跨汇编引用的符号都改用同一个固定的命名规范比如统一小写、下划线开头。第三个是段对齐的坑。链接脚本里的. ALIGN(4)不是摆设。RISC-V的某些指令要求数据访问必须按4字节对齐如果某个段的起始地址没对齐程序运行到一半会触发非对齐访问异常。每次新增自定义段时段首段尾都加上ALIGN这个习惯能帮你避免很多诡异问题。第四个是修改脚本后没有clean就重新编译。链接脚本的改动不会总是被重新链接特别是IDE有时只做增量编译而旧链接结果还残留着。我建议每次改完.ld文件后先执行一次Clean Project再重新Build确保用的是新的链接配置。5.2 我的调试工作流根据个人习惯我拿到一块新的RISC-V开发板、准备建立一个新工程时永远不会直接开写业务代码而是先花五分钟做四件事情第一翻开数据手册确认Flash和RAM的准确地址与大小第二打开默认链接脚本核对MEMORY里的参数与芯片是否一致第三用内置模板或上一份可用工程的脚本作为基底而不是从零手写第四编译一个空的main函数跑通链路预留好_estack、_vector_start这些基础约束条件。这四步看起来简单但能挡住后面90%的链接类疑难杂症。很多工程师拿着从别的项目复制来的链接脚本直接改也不看芯片型号结果板子跑不起来还不知道问题出在脚本上。最后再分享一个很实用的小习惯版本管理链接脚本。每次修改.ld文件时不只是改动内容我会把修改前后的配置、芯片型号、工程场景写到提交说明里。嵌入式项目的链接脚本不像业务代码那样频繁变化但一旦出问题往往最难回溯。多记录一句“为什么这么改”能让你在三个月后再看这个文件的时候少掉一大半的头发。
返回列表