行业资讯

嵌入式开发中__attribute__((section))的深度解析与RT-Thread实战应用

发布时间:2026/8/18 18:05:56
嵌入式开发中__attribute__((section))的深度解析与RT-Thread实战应用 1. 从一次编译警告说起section的引入与价值那天在调试一个RT-Thread项目编译窗口里突然蹦出一条警告warning #10247-d: creating output section codestart without a sections specification。这条警告本身不致命但它像一根刺扎在了我这个有强迫症的嵌入式开发者心里。它背后指向的是一个我们经常用但未必深究的机制链接器脚本Linker Script中的段Section管理。而这条警告恰恰是因为我在代码里使用了__attribute__((section(“codestart”)))把几个关键函数放到了一个自定义的段里但链接器脚本里却没有明确告诉链接器这个段应该放在内存的哪个位置、有什么属性。这让我意识到很多开发者对__attribute__((section))这个GCC/Clang扩展属性的理解可能还停留在“能把变量或函数放到指定段”这个表层功能上。它到底怎么工作在RT-Thread这样的实时操作系统中用它来定制函数布局能带来什么实实在在的好处仅仅是解决一个编译警告吗显然不是。它能让我们精细地控制代码在内存中的物理位置这对于实现快速启动、固化关键函数、隔离安全代码、甚至是实现动态加载模块都有着至关重要的作用。今天我就结合RT-Thread这个具体的舞台把__attribute__((section))里里外外扒个清楚让你不仅会用更懂为什么这么用。2.__attribute__((section))的本质与链接器的对话要理解__attribute__((section))我们必须先跳出C语言的范畴看到编译和链接的整个过程。编译器如arm-none-eabi-gcc将你的.c源文件编译成.o目标文件。在这个目标文件里代码和数据并不是杂乱无章堆在一起的而是被分门别类地放到了不同的“段”Section中。.text段存放程序的可执行代码函数。.data段存放已初始化的全局变量和静态变量。.bss段存放未初始化的全局变量和静态变量程序启动时由启动代码清零。.rodata段存放只读数据比如字符串常量。链接器如arm-none-eabi-ld的职责就是把所有.o文件以及库文件中的这些段按照一个“地图”——也就是链接器脚本.ld文件——的指示合并、排序并最终分配到目标芯片内存的特定地址上生成一个完整的可执行文件如.elf或.bin。那么__attribute__((section(“section_name”)))扮演了什么角色它是在编译阶段给特定的函数或变量打上一个“自定义标签”告诉编译器“别把我放到默认的.text或.data段请把我放到一个名叫 ‘section_name’ 的段里。”这个“section_name”是你自己定义的字符串。例如void my_init_func(void) __attribute__((section(“my_init_section”))); void my_init_func(void) { // 初始化代码 }编译后my_init_func函数的代码就不会出现在.o文件的.text段里而是出现在一个独立的my_init_section段里。为什么链接器会报warning #10247-d因为链接器在处理最终链接时发现输入文件.o文件里有一个它不认识的段“codestart”。链接器脚本里没有关于这个段的任何描述SECTIONS命令里没有它的名字。链接器很“宽容”它不会因此报错停止而是会采取一个默认行为自动创建一个同名的输出段并按照当前的内存区域和默认规则把它塞进去。这个行为可能符合你的预期也可能不符合。警告就是链接器在提醒你“嘿我自作主张处理了这个你自定义的段你最好检查一下链接脚本明确告诉我该怎么安排它。” 忽略这个警告你的函数最终被链接到哪里就取决于链接器的默认策略这在内存布局要求严格的嵌入式系统中是危险的。3. RT-Thread中的实战构建自定义初始化段理论说再多不如动手。我们来看一个在RT-Thread中非常实用的场景系统启动阶段的模块化初始化。RT-Thread本身有优秀的组件自动初始化机制利用INIT_BOARD_EXPORT(fn)等宏将初始化函数指针放到特定的段如.rti_fn.1然后在启动时按优先级顺序遍历执行。其底层原理正是__attribute__((section))。我们可以借鉴这个思想实现自己的初始化段。假设我们有三个模块看门狗Watchdog、系统时钟System Clock、外设硬件Peripheral我们希望它们在main函数执行前严格按照这个顺序完成初始化。3.1 定义段与封装宏首先我们定义一个段名比如“my_init_call”。然后封装一个易用的宏/* my_init.h */ #ifndef _MY_INIT_H_ #define _MY_INIT_H_ #ifdef __cplusplus extern “C” { #endif /* 定义初始化函数类型 */ typedef int (*my_init_fn_t)(void); /* 封装 attribute section 的宏 */ #define MY_INIT_EXPORT(fn, level) \ const my_init_fn_t _my_init_##fn __attribute__((section(“.my_init_call.” #level), used)) fn /* 初始化级别定义 */ #define MY_INIT_LEVEL_WATCHDOG 1 #define MY_INIT_LEVEL_CLOCK 2 #define MY_INIT_LEVEL_PERIPHERAL 3 #ifdef __cplusplus } #endif #endif /* _MY_INIT_H_ */这里有几个关键点typedef int (*my_init_fn_t)(void);定义了初始化函数的统一类型返回int表示成功与否。__attribute__((section(“.my_init_call.” #level), used))section(“.my_init_call.” #level)这是核心。我们不是把所有函数放到一个段而是创建了一系列子段例如.my_init_call.1.my_init_call.2。这样做的好处是链接器在合并输入段时会按照段名的字典序或链接脚本指定规则进行排序。这为我们提供了通过段名控制执行顺序的能力。used这个属性告诉编译器即使这个变量看起来没有被显式引用也不要优化掉它。这对于仅通过段地址来寻址的变量至关重要。MY_INIT_EXPORT(fn, level)宏将函数名fn和级别level拼接生成一个唯一的变量名_my_init_##fn并将函数指针fn赋值给它同时将其放入指定的段。3.2 模块代码中使用在每个模块的源文件中我们定义初始化函数并使用宏“导出”它。/* watchdog.c */ #include “my_init.h” static int watchdog_init(void) { /* 初始化看门狗硬件 */ // ... 具体硬件操作 rt_kprintf(“[My Init] Watchdog initialized.\n”); return 0; } /* 将 watchdog_init 函数导出到级别1的初始化段 */ MY_INIT_EXPORT(watchdog_init, MY_INIT_LEVEL_WATCHDOG);/* system_clock.c */ #include “my_init.h” static int system_clock_init(void) { /* 配置系统时钟PLL等 */ // ... 具体硬件操作 rt_kprintf(“[My Init] System Clock initialized.\n”); return 0; } /* 将 system_clock_init 函数导出到级别2的初始化段 */ MY_INIT_EXPORT(system_clock_init, MY_INIT_LEVEL_CLOCK);/* peripheral.c */ #include “my_init.h” static int peripheral_init(void) { /* 初始化GPIO、UART等基础外设 */ // ... 具体硬件操作 rt_kprintf(“[My Init] Peripheral initialized.\n”); return 0; } /* 将 peripheral_init 函数导出到级别3的初始化段 */ MY_INIT_EXPORT(peripheral_init, MY_INIT_LEVEL_PERIPHERAL);3.3 链接器脚本的配合消除警告并精确定位现在编译项目你会得到几个.o文件里面包含了.my_init_call.1.my_init_call.2.my_init_call.3这些输入段。如果链接器脚本通常是link.lds里没有定义它们就会出现开头的警告。我们需要修改RT-Thread的链接器脚本位于bsp/your_board/link.lds或类似位置在SECTIONS命令中显式地安排这些段。找到.text段定义的区域通常如下.text : { . ALIGN(4); KEEP(*(.vectors)) /* 中断向量表 */ *(.text) /* .text sections (code) */ *(.text*) /* .text* sections (code) */ *(.rodata) /* .rodata sections (constants, strings, etc.) */ *(.rodata*) /* .rodata* sections (constants, strings, etc.) */ *(.glue_7) /* glue arm to thumb code */ *(.glue_7t) /* glue thumb to arm code */ *(.eh_frame) KEEP (*(.init)) KEEP (*(.fini)) . ALIGN(4); _etext .; /* define a global symbol at end of code */ } FLASH我们要把自定义的初始化段也放进去并且确保它们的顺序。通常放在.text主代码区之前是合理的因为它们属于启动代码的一部分。.text : { . ALIGN(4); KEEP(*(.vectors)) /* 中断向量表 */ /* 自定义初始化段按数字顺序排列 */ KEEP(*(SORT(.my_init_call.*))) /* SORT关键字确保按.1, .2, .3排序 */ *(.text) /* .text sections (code) */ *(.text*) /* .text* sections (code) */ /* ... 后续保持不变 ... */ } FLASH关键解释KEEP()链接器指令作用是“保留”这个段。即使链接器认为这个段没有被任何代码引用我们的函数指针变量确实只在链接脚本层面通过地址被引用也不会在垃圾回收--gc-sections阶段被删除。这对于启动代码至关重要。*(SORT(.my_init_call.*))通配符*匹配所有输入文件中的此类段。SORT()是链接器内置函数它会对其参数中的段名按字母顺序进行排序。由于我们的段名是.my_init_call.1.my_init_call.2经过SORT后它们在输出文件中的排列顺序就是1、2、3这直接决定了后续遍历执行的顺序。 FLASH指定这个输出段存放在内存区域FLASH即ROM中。对于初始化函数放在Flash里是标准做法。经过这样的修改编译警告就会消失因为链接器脚本已经明确告知链接器如何处理这些自定义段。3.4 启动代码中遍历执行最后我们需要在系统启动的早期比如在$Sub$$main函数或RT-Thread的启动文件startup_xxx.s跳转到C语言环境后main函数之前添加代码来遍历执行这些初始化函数。/* 在 main.c 或专门的启动文件里 */ extern const my_init_fn_t _start_my_init_call; extern const my_init_fn_t _stop_my_init_call; void my_init_system(void) { const my_init_fn_t *fn_ptr; /* 利用链接器生成的符号进行迭代 */ for (fn_ptr _start_my_init_call; fn_ptr _stop_my_init_call; fn_ptr) { if (*fn_ptr ! RT_NULL) { int result (*fn_ptr)(); if (result ! 0) { /* 初始化失败处理可以打印错误或进入安全模式 */ rt_kprintf(“[My Init] Error: init function at %p failed with code %d\n”, fn_ptr, result); // while(1); // 严重错误可停机 } } } } int main(void) { /* 系统硬件底层初始化 */ // ... /* 执行我们自定义的模块化初始化 */ my_init_system(); /* 接下来初始化RT-Thread内核 */ rtthread_startup(); /* 不会执行到这里 */ while(1); }这里的_start_my_init_call和_stop_my_init_call是两个链接器自动生成的符号。在链接器脚本中我们需要定义它们来标记我们自定义段的起始和结束地址。在链接器脚本的.text段定义中修改/* 自定义初始化段按数字顺序排列 */ PROVIDE(_start_my_init_call .); KEEP(*(SORT(.my_init_call.*))) PROVIDE(_stop_my_init_call .);PROVIDE()用于定义一个符号如果用户代码中没有定义它链接器会创建它并赋值为当前位置计数器.的值。_start_my_init_call被赋值为段开始前的地址_stop_my_init_call被赋值为段结束后的地址。这样在C代码中我们就可以把_start_my_init_call当作一个my_init_fn_t类型的数组起始地址来遍历。注意这种遍历方式假设所有函数指针在内存中是连续存放的。由于我们使用了KEEP和自定义段并且没有其他内容插入这个假设在通常情况下是成立的。这是一种在嵌入式系统中常见的、高效利用链接器特性的技巧。4. 进阶应用与深度思考掌握了基础用法我们可以看看__attribute__((section))在RT-Thread及嵌入式领域更高级的应用。4.1 将函数或数据绝对定位到固定地址有时我们需要将某个函数或变量放到一个绝对确定的地址。例如芯片的Bootloader和Application可能需要共享一段内存数据如跳转标志、升级状态或者某个中断服务程序必须位于地址对齐的特定位置以满足硬件要求。/* 将一个配置结构体放到0x20001000地址假设是SRAM中的一个区域 */ typedef struct { uint32_t boot_flag; uint32_t app_version; uint8_t update_status; } shared_data_t; shared_data_t my_shared_data __attribute__((section(“.shared_section”))) {0}; /* 在链接器脚本中需要精确定义这个段的位置 */ /* 在SECTIONS块内放在.data段之后或单独定义一个内存区域 */ .shared_section 0x20001000 : AT (LOADADDR(.data) SIZEOF(.data)) /* AT指定加载地址在Flash中的位置前面的0x20001000是运行地址VMA */ { KEEP(*(.shared_section)) } SRAM这样在C代码中访问my_shared_data时它的地址就是固定的0x20001000。Bootloader和Application只要约定好这个地址和数据结构就能实现通信。4.2 实现类似RT-Thread的自动初始化机制RT-Thread的INIT_EXPORT宏是此技术的经典应用。我们上面实现的MY_INIT_EXPORT是其简化版。RT-Thread的版本更复杂它通过不同的段名如.rti_fn.1.rti_fn.2 ….rti_fn.6定义了6个初始化级别INIT_BOARD_EXPORT,INIT_PREV_EXPORT, …,INIT_APP_EXPORT并在components.c的rt_components_board_init()和rt_components_init()函数中统一遍历执行。研究其源码rtdef.h和components.c是学习此技术的最佳范例。4.3 优化启动速度将关键函数放到RAM中执行在追求极致启动速度的场景下我们可以将某些对启动时间影响巨大的函数如Flash解密算法、内存自检函数放到RAM中执行因为RAM的访问速度通常远高于Flash尤其是Nor Flash的随机读取。这需要两个步骤使用__attribute__((section(“.fast_code”)))将函数代码标记。在链接器脚本中将.fast_code段分配到RAM区域并且在启动代码中需要增加一段代码将存储在Flash中的.fast_code段内容拷贝到RAM中对应的地址。因为芯片上电后代码都在Flash里直接跳转到RAM地址执行会失败。/* 链接器脚本片段 */ .fast_code : { . ALIGN(4); _sfastcode .; /* 在RAM中的运行起始地址 */ *(.fast_code) *(.fast_code*) . ALIGN(4); _efastcode .; /* 在RAM中的运行结束地址 */ } RAM AT FLASH /* RAM 指定VMA运行地址 AT FLASH 指定LMA加载地址 */ /* 在启动代码中需要拷贝 */ extern uint8_t _sfastcode, _efastcode, _lma_fastcode_start; /* _lma_fastcode_start 需要在链接脚本中定义为 LOADADDR(.fast_code) */ memcpy(_sfastcode, _lma_fastcode_start, _efastcode - _sfastcode);4.4 排查与section相关的常见问题变量未初始化或值错误如果你将变量放到自定义段并在链接脚本中将其地址安排在了.bss段之前而启动代码的清零.bss的操作只针对.bss段那么你的自定义段变量将不会被自动清零。对于需要零初始化的变量要么放在.bss要么在代码中显式初始化。函数调用失败或HardFault如果将函数放到RAM段但忘记在启动时拷贝代码或者链接脚本中运行地址设置错误调用该函数会导致从错误地址取指引发硬件错误。代码体积变大过度使用自定义段尤其是每个函数都单独一个段可能会妨碍链接器的优化如函数合并、垃圾回收导致生成的二进制文件略大。需要权衡利弊。移植性问题__attribute__((section))是GNU编译器扩展在IAR或Keil MDKARMCC/AC6中语法不同。IAR使用 “section_name”或#pragma locationKeil使用__attribute__((section(“section_name”)))或__attribute__((at(address)))。编写跨平台代码时需要用宏进行包装。5. 从原理到实践理解链接器脚本与内存布局要真正玩转__attribute__((section))必须能看懂并修改链接器脚本。它定义了内存的“地图”。一个简单的链接器脚本包含以下关键部分MEMORY定义物理内存区域如FLASH只读存放代码常量、RAM可读可写存放变量堆栈。MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K }SECTIONS定义如何将输入段映射到输出段并放置到MEMORY定义的区域。.text.data.bss是标准的输出段名。*(.text)*(.data)是输入段描述收集所有输入文件中的对应段。. ALIGN(4);是地址对齐指令确保后续内容从4字节对齐的地址开始这对ARM CPU性能很重要。KEEP()PROVIDE()是链接器指令。 FLASH指定输出段存放的内存区域。当你使用自定义段时就是在SECTIONS里增加新的映射规则。理解VMA虚拟内存地址程序运行时的地址和LMA加载内存地址程序存储在非易失性存储器中的地址的区别对于理解代码搬运如将数据从Flash拷贝到RAM至关重要。在大多数情况下.text和.rodata的VMA和LMA相同都在Flash而.data的VMA在RAMLMA在Flash需要启动代码拷贝。通过__attribute__((section))配合链接器脚本我们实现了从软件逻辑到物理内存的精准映射。这不仅仅是解决一个编译警告更是嵌入式开发者从“写代码”到“驾驭系统”迈进的关键一步。它让你能针对芯片特性、项目需求启动速度、安全性、模块化进行深度定制写出更高效、更可靠的嵌入式程序。下次再看到关于section的警告或配置时希望你能会心一笑因为你知道它背后连接着整个软件与硬件交汇的世界。