6.1 GNU C 的扩展关键字:attribute
GNU C 增加一个 __atttribute__ 关键字用来声明一个函数、变量或类型的特殊属性。声明这个特殊属性有什么用呢?主要用途就是指导编译器在编译程序时进行特定方面的优化或代码检查。比如,我们可以通过使用属性声明指定某个变量的数据边界对齐方式。
__atttribute__ 的使用非常简单,当我们定义一个函数、变量或类型时,直接在它们名字旁边添加下面的属性声明即可:
__atttribute__((ATTRIBUTE))
这里需要注意的是:__atttribute__ 后面是两对小括号,不能图方便只写一对,否则编译可能通不过。括号里面的 ATTRIBUTE 代表的就是要声明的属性。现在 __atttribute__ 支持十几种属性:
- section
- aligned
- packed
- format
- weak
- alias
- noinline
- always_inline
- ……
在这些属性中,aligned 和 packed 用来显式指定一个变量的存储边界对齐方式。一般来讲,我们定义一个变量,编译器会根据变量类型,按照默认的规则来给这个变量分配大小、按照默认的边界对齐方式分配一个地址。而使用 __atttribute__ 这个属性声明,就相当于告诉编译器:按照我们指定的边界地址对齐去给这个变量分配存储空间。
char c2 __attribute__((aligned()) = ;
int global_val __attribute__((section(".data")));
有些属性可能还有自己的参数。比如 aligned(8) 表示这个变量按8字节地址对齐,参数也要使用小括号括起来。如果属性的参数是一个字符串,小括号里的参数还要用双引号引起来。
当然,我们也可以对一个变量同时添加多个属性说明。在定义时,各个属性之间用逗号隔开就可以了。
char c2 __attribute__((packed,aligned()));
char c2 __attribute__((packed,aligned())) = ;
__attribute__((packed,aligned())) char c2 = ;
在上面的示例中,我们对一个变量添加2个属性声明,这两个属性都放在 __atttribute__ (()) 的2对小括号里面,属性之间用逗号隔开。这里还有一个细节,就是属性声明要紧挨着变量,上面的三种定义方式都是没有问题的,但下面的定义方式在编译的时候可能就通不过。
char c2 = __attribute__((packed,aligned()));
6.2 属性声明:section
在本节教程中,我们先讲一下 section 这个属性。使用__atttribute__ 来声明一个 section 属性,主要用途是在程序编译时,将一个函数或变量放到指定的段,即 section 中。
在讲解这个功能之前,为了照顾一下对计算机编译、链接过程不是很了解的同学,我们先讲一讲程序的编译、链接过程。
程序的编译、链接过程
一个可执行目标文件,它主要由代码段、数据段、BSS 段构成。代码段主要存放编译生成的可执行指令代码,数据段和 BSS 段用来存放全局变量、未初始化的全局变量。代码段、数据段和 BSS 段构成了一个可执行文件的主要部分。
除了这三个段,可执行文件中还包含其它一些段。用编译器的专业术语讲,还会包含其它一些 section,比如只读数据段、符号表等等。我们可以使用下面的 readelf 命令,去查看一个可执行文件中各个 section 的信息。
$ gcc -o a.out hello.c
$ readelf -S a.out
here are section headers, starting at offset 0x1848:
Section Headers:
[Nr] Name Type Addr Off Size
[ ] NULL
[ ] .interp PROGBITS
[ ] .note.ABI-tag NOTE
[ ] .note.gnu.build-i NOTE
[ ] .gnu.hash GNU_HASH 080481ac 0001ac
[ ] .dynsym DYNSYM 080481cc 0001cc
[ ] .dynstr STRTAB 0804820c 00020c
[ ] .gnu.version VERSYM
[ ] .gnu.version_r VERNEED 0804825c 00025c
[ ] .rel.dyn REL 0804827c 00027c
[] .rel.plt REL
[] .init PROGBITS 0804828c 00028c
[] .plt.got PROGBITS 080482d0 0002d0
[] .text PROGBITS 080482e0 0002e0
[] .fini PROGBITS
[] .rodata PROGBITS
[] .eh_frame_hdr PROGBITS 00002c
[] .eh_frame PROGBITS 0804849c 00049c 0000c0
[] .init_array INIT_ARRAY 08049f08 000f08
[] .fini_array FINI_ARRAY 08049f0c 000f0c
[] .jcr PROGBITS 08049f10 000f10
[] .dynamic DYNAMIC 08049f14 000f14 0000e8
[] .got PROGBITS 08049ffc 000ffc
[] .got.plt PROGBITS 0804a000
[] .data PROGBITS 0804a020 00004c
[] .bss NOBITS 0804a06c 00106c
[] .comment PROGBITS 00106c
[] .shstrtab STRTAB 00173d 00010a
[] .symtab SYMTAB 0010a0
[] .strtab STRTAB 00022d
在 Linux 环境下,使用 GCC 编译生成一个可执行文件 a.out,使用上面的 readelf 命令,就可以查看这个可执行文件中各个 section 的基本信息,比如大小、起始地址等等。在这些 section 中,其中 .text section 就是我们常说的代码段,.data section 是数据段,.bss section 是 BSS 段。
我们知道一段源程序代码在编译生成可执行文件的过程中,函数和变量是放在不同段中的。一般默认的规则如下。
section组成代码段( .text)函数定义、程序语句数据段( .data)初始化的全局变量、初始化的静态局部变量BSS段( .bss)未初始化的全局变量、未初始化的静态局部变量
比如,在下面的程序中,我们分别定义一个函数、一个全局变量和一个未初始化的全局变量。
//hello.c
int global_val = ;
int uninit_val;
void print_star(void)
{
printf("****\n");
}
int main(void)
{
print_star();
return ;
}
接着,我们使用 GCC 编译这个程序,并查看生成的可执行文件 a.out 的符号表和 section header 表信息。
$ gcc -o a.out hello.c
$ readelf -s a.out
$ readelf -S a.out
符号表信息:
Num: Value Size Type Bind Vis Ndx Name
: FILE LOCAL DEFAULT ABS hello.c
: 0804a024 OBJECT GLOBAL DEFAULT uninit_val
: 0804a014 NOTYPE WEAK DEFAULT data_start
: 0804a020 NOTYPE GLOBAL DEFAULT _edata
: 080484b4 FUNC GLOBAL DEFAULT _fini
: 0804a01c OBJECT GLOBAL DEFAULT global_val
: 0804a014 NOTYPE GLOBAL DEFAULT __data_start
: FUNC GLOBAL DEFAULT __libc_csu_init
: 0804a028 NOTYPE GLOBAL DEFAULT _end
: FUNC GLOBAL DEFAULT _start
: 080484c8 OBJECT GLOBAL DEFAULT _fp_hw
: 0804840b FUNC GLOBAL DEFAULT print_star
: 0804a020 NOTYPE GLOBAL DEFAULT __bss_start
: FUNC GLOBAL DEFAULT main
: 080482a8 FUNC GLOBAL DEFAULT _init
section header信息:
Section Headers:
[Nr] Name Type Addr Off Size
[] .text PROGBITS 0001a2
[] .data PROGBITS 0804a014 00000c
[] .bss NOBITS 0804a020
[] .comment PROGBITS
[] .shstrtab STRTAB 00010a
[] .symtab SYMTAB
[] .strtab STRTAB 0014d4 00024e
通过符号表和节头表 section header table 信息,我们可以看到,函数 print_star
被放在可执行文件中的 .text section
,即代码段;初始化的全局变量 global_val
被放在了 a.out
的 .data section
,即数据段;而未初始化的全局变量 uninit_val
则被放在了.bss section
,即 BSS 段。
编译器在编译程序时,是以源文件为单位,将一个个源文件编译生成一个个目标文件。在编译过程中,编译器都会按照这个默认规则,将函数、变量分别放在不同的 section 中,最后将各个 section 组成一个目标文件。编译过程结束后,链接器接着会将各个目标文件组装合并、重定位,生成一个可执行文件。
链接器是如何将各个目标文件组装成一个可执行文件的呢?很简单,链接器首先会分别将各个目标文件的代码段整合,组装成一个大的代码段;将各个目标文件中的数据段整合,合并成一个大的数据段;接着将合并后的新代码段、数据段再合并为一个文件;最后经过重定位,就生成了一个可以运行的可执行文件了。
现在又有一个疑问来了,链接器在将各个不同的 section 段组装成一个可执行文件的过程中,各个 section 的顺序如何排放呢?比如代码段、数据段、BSS 段、符号表等,谁放在前面?谁放在后面?
链接器在链接过程中,会将不同的 section,按照链接脚本中指定的各个 section 的排放顺序,组装成一个可执行文件。一般在 Ubuntu 等 PC 版本的系统中,系统会有默认的链接脚本,不需要程序员操心。
$ ld --verbose
我们使用上面命令,就可以查看编译当前程序时,链接器使用的默认链接脚本。在嵌入式系统中,因为是交叉编译,所以软件源码一般会自带一个链接脚本。比如在 U-boot 源码的根目录下面,你会看到一个 u-boot.lds 的文件,这个文件就是编译 U-boot 时,链接器要使用的链接脚本。在 Linux 内核中,同样会有 vmlinux.lds 这样一个链接脚本。
属性 section 编程示例
在 GNU C 中,我们可以通过 __atttribute__ 的 section 属性,显式指定一个函数或变量,在编译时放到指定的 section 里面。通过上面的程序我们知道,未初始化的全局变量是放在 .data section 中的,即放在 BSS 段中。现在我们就可以通过 section 属性,把这个未初始化的全局变量放到数据段 .data 中。
int global_val = ;
int uninit_val __attribute__((section(".data")));
int main(void)
{
return ;
}
通过上面的 readelf 命令查看符号表,我们可以看到,uninit_val 这个未初始化的全局变量,通过__attribute__((section(".data")))
属性声明,就被编译器放在了数据段 .data section 中。
6.3 U-boot 启动过程中的镜像自拷贝分析
有了 section 这个属性,我们接下来就可以试着分析,U-boot 在启动过程中,是如何将自身代码加载的 RAM 中的。
搞嵌入式的都知道 U-boot,U-boot 的用途主要是加载 Linux 内核镜像到内存、给内核传递启动参数、然后引导 Linux 操作系统启动。
U-boot 一般存储在 Nor flash 或 NAND Flash 上。无论从 Nor Flash 还是从 Nand Flash 启动,U-boot 其本身在启动过程中,也会从 Flash 存储介质上加载自身代码到内存,然后进行重定位,跳到内存 RAM 中去执行。这个功能一般叫做“自举”,是不是感觉很牛 X?U-boot 重定位的过程今天就不展开了,有兴趣的同学,可以看看我的嵌入式视频教程《C 语言嵌入式 Linux 高级编程》第3期:程序的编译、链接和运行。今天我们的主要任务是去看看 U-boot 是怎么完成自拷贝的,或者说它是怎样将自身代码从 Flash 拷贝到内存 RAM 中的。
在拷贝自身代码的过程中,一个主要的疑问就是,U-boot 是如何识别自身代码的?是如何知道从哪里拷贝代码的?是如何知道拷贝到哪里停止的?这个时候我们不得不说起 U-boot 源码中的一个零长度数组。
char __image_copy_start[] __attribute__((section(".__image_copy_start")));
char __image_copy_end[] __attribute__((section(".__image_copy_end")));
这两行代码定义在 U-boot-2016.09 中的 arch/arm/lib/section.c 文件中。在其它版本中可能路径不同或者没有定义,为了分析这个功能,建议大家可以下载 U-boot-2016.09 这个版本的U-boot源码。
这两行代码的作用是分别定义一个零长度数组,并告诉编译器要分别放在 .image_copy_start 和 `.image_copy_end` 这两个 section 中。
链接器在链接各个目标文件时,会按照链接脚本里各个 section 的排列顺序,将各个 section 组装成一个可执行文件。U-boot 的链接脚本 u-boot.lds 在 U-boot 源码的根目录下面。
OUTPUT_FORMAT("elf32-littlearm",
"elf32-littlearm",
"elf32-littlearm")
OUTPUT_ARCH(arm)
ENTRY(_start)
SECTIONS
{
. = 0x00000000;
. = ALIGN();
.text :
{
*(.__image_copy_start)
*(.vectors)
arch/arm/cpu/armv7/start.o (.text*)
*(.text*)
}
. = ALIGN();
.data : {
*(.data*)
}
...
...
. = ALIGN();
.image_copy_end :
{
*(.__image_copy_end)
}
.end :
{
*(.__end)
}
_image_binary_end = .;
. = ALIGN();
.mmutable : {
*(.mmutable)
}
.bss_start __rel_dyn_start (OVERLAY) : {
KEEP(*(.__bss_start));
__bss_base = .;
}
.bss __bss_base (OVERLAY) : {
*(.bss*)
. = ALIGN();
__bss_limit = .;
}
.bss_end __bss_limit (OVERLAY) : {
KEEP(*(.__bss_end));
}
}
通过链接脚本我们可以看到,__image_copy_start
和 __image_copy_end
这两个 section,在链接的时候分别放在了代码段 .text 的前面、数据段 .data 的后面,作为 U-boot 拷贝自身代码的起始地址和结束地址。而在这两个 section 中,我们除了放2个零长度数组外,并没有再放其它变量。根据前面的学习我们知道,零长度数组是不占用存储空间的,所以上面定义的两个零长度数组,其实就分别代表了 U-boot 镜像要拷贝自身镜像的起始地址和结束地址。
char __image_copy_start[] __attribute__((section(".__image_copy_start")));
char __image_copy_end[] __attribute__((section(".__image_copy_end")));
无论 U-boot 自身镜像是存储在 Nor Flash,还是 Nand Flash 上,我们只要知道了这两个地址,就可以直接调用相关代码拷贝。
接着在 arch/arm/lib/relocate.S 中,ENTRY(relocate_code) 汇编代码主要完成代码拷贝的功能。
ENTRY(relocate_code)
ldr r1, =__image_copy_start /* r1 <- SRC &__image_copy_start */
subs r4, r0, r1 /* r4 <- relocation offset */
beq relocate_done /* skip relocation */
ldr r2, =__image_copy_end /* r2 <- SRC &__image_copy_end */
copy_loop:
ldmia r1!, {r10-r11} /* copy from source address [r1] */
stmia r0!, {r10-r11} /* copy to target address [r0] */
cmp r1, r2 /* until source end address [r2] */
blo copy_loop
在这段汇编代码中,寄存器 R1、R2 分别表示要拷贝镜像的起始地址和结束地址,R0 表示要拷贝到 RAM 中的地址,R4 存放的是源地址和目的地址之间的偏移,在后面重定位过程中会用到这个偏移值。
ldr r1, =__image_copy_start
见上面指令,在汇编代码中,ARM的 ldr 指令立即寻址,直接对数组名进行引用,获取要拷贝镜像的首地址,并保存在 R1 寄存器中。数组名本身其实就代表一个地址。通过这种方式,U-boot 在嵌入式启动的初始阶段,就完成了自身代码的拷贝工作:从 Flash 上拷贝自身镜像到 RAM 中,然后再进行重定位,最后跳到 RAM 中执行。