首页
登录 | 注册

嵌入式C语言自我修养 06:U-boot镜像自拷贝分析:section属性

6.1 GNU C 的扩展关键字:attribute

GNU C 增加一个 __atttribute__ 关键字用来声明一个函数、变量或类型的特殊属性。声明这个特殊属性有什么用呢?主要用途就是指导编译器在编译程序时进行特定方面的优化或代码检查。比如,我们可以通过使用属性声明指定某个变量的数据边界对齐方式。

__atttribute__ 的使用非常简单,当我们定义一个函数、变量或类型时,直接在它们名字旁边添加下面的属性声明即可:

__atttribute__((ATTRIBUTE))
这里需要注意的是:__atttribute__ 后面是两对小括号,不能图方便只写一对,否则编译可能通不过。括号里面的 ATTRIBUTE 代表的就是要声明的属性。现在 __atttribute__ 支持十几种属性:


  1. section
  2. aligned
  3. packed
  4. format
  5. weak
  6. alias
  7. noinline
  8. always_inline
  9. ……

在这些属性中,aligned 和 packed 用来显式指定一个变量的存储边界对齐方式。一般来讲,我们定义一个变量,编译器会根据变量类型,按照默认的规则来给这个变量分配大小、按照默认的边界对齐方式分配一个地址。而使用 __atttribute__ 这个属性声明,就相当于告诉编译器:按照我们指定的边界地址对齐去给这个变量分配存储空间。


  1. char c2 __attribute__((aligned(8)) = 4;
  2. int global_val __attribute__((section(".data")));

有些属性可能还有自己的参数。比如 aligned(8) 表示这个变量按8字节地址对齐,参数也要使用小括号括起来。如果属性的参数是一个字符串,小括号里的参数还要用双引号引起来。

当然,我们也可以对一个变量同时添加多个属性说明。在定义时,各个属性之间用逗号隔开就可以了。

  1. char c2 __attribute__((packed,aligned(4)));
  2. char c2 __attribute__((packed,aligned(4))) = 4;
  3. __attribute__((packed,aligned(4))) char c2 = 4;

在上面的示例中,我们对一个变量添加2个属性声明,这两个属性都放在 __atttribute__ (()) 的2对小括号里面,属性之间用逗号隔开。这里还有一个细节,就是属性声明要紧挨着变量,上面的三种定义方式都是没有问题的,但下面的定义方式在编译的时候可能就通不过。

  1. char c2 = 4 __attribute__((packed,aligned(4)));

6.2 属性声明:section

在本节教程中,我们先讲一下 section 这个属性。使用__atttribute__ 来声明一个 section 属性,主要用途是在程序编译时,将一个函数或变量放到指定的段,即 section 中。

在讲解这个功能之前,为了照顾一下对计算机编译、链接过程不是很了解的同学,我们先讲一讲程序的编译、链接过程。

程序的编译、链接过程

一个可执行目标文件,它主要由代码段、数据段、BSS 段构成。代码段主要存放编译生成的可执行指令代码,数据段和 BSS 段用来存放全局变量、未初始化的全局变量。代码段、数据段和 BSS 段构成了一个可执行文件的主要部分。

除了这三个段,可执行文件中还包含其它一些段。用编译器的专业术语讲,还会包含其它一些 section,比如只读数据段、符号表等等。我们可以使用下面的 readelf 命令,去查看一个可执行文件中各个 section 的信息。


  1. $ gcc -o a.out hello.c
  2. $ readelf -S a.out

  3.   here are 31 section headers, starting at offset 0x1848:
  4. Section Headers:
  5.   [Nr] Name      Type      Addr     Off      Size
  6.   [ 0] NULL      00000000 000000 000000
  7.   [ 1] .interp   PROGBITS 08048154 000154 000013
  8.   [ 2] .note.ABI-tag NOTE 08048168 000168 000020
  9.   [ 3] .note.gnu.build-i NOTE 08048188 000188 000024
  10.   [ 4] .gnu.hash GNU_HASH 080481ac 0001ac 000020
  11.   [ 5] .dynsym DYNSYM 080481cc 0001cc 000040
  12.   [ 6] .dynstr STRTAB 0804820c 00020c 000045
  13.   [ 7] .gnu.version VERSYM 08048252 000252 000008
  14.   [ 8] .gnu.version_r VERNEED 0804825c 00025c 000020
  15.   [ 9] .rel.dyn REL 0804827c 00027c 000008
  16.   [10] .rel.plt REL 08048284 000284 000008
  17.   [11] .init PROGBITS 0804828c 00028c 000023
  18.   [13] .plt.got PROGBITS 080482d0 0002d0 000008
  19.   [14] .text PROGBITS 080482e0 0002e0 000172
  20.   [15] .fini PROGBITS 08048454 000454 000014
  21.   [16] .rodata PROGBITS 08048468 000468 000008
  22.   [17] .eh_frame_hdr PROGBITS 08048470 000470 00002c
  23.   [18] .eh_frame PROGBITS 0804849c 00049c 0000c0
  24.   [19] .init_array INIT_ARRAY 08049f08 000f08 000004
  25.   [20] .fini_array FINI_ARRAY 08049f0c 000f0c 000004
  26.   [21] .jcr PROGBITS 08049f10 000f10 000004
  27.   [22] .dynamic DYNAMIC 08049f14 000f14 0000e8
  28.   [23] .got PROGBITS 08049ffc 000ffc 000004
  29.   [24] .got.plt PROGBITS 0804a000 001000 000010
  30.   [25] .data PROGBITS 0804a020 001020 00004c
  31.   [26] .bss NOBITS 0804a06c 00106c 000004
  32.   [27] .comment PROGBITS 00000000 00106c 000034
  33.   [28] .shstrtab STRTAB 00000000 00173d 00010a
  34.   [29] .symtab SYMTAB 00000000 0010a0 000470
  35.   [30] .strtab STRTAB 00000000 001510 00022d

在 Linux 环境下,使用 GCC 编译生成一个可执行文件 a.out,使用上面的 readelf 命令,就可以查看这个可执行文件中各个 section 的基本信息,比如大小、起始地址等等。在这些 section 中,其中 .text section 就是我们常说的代码段,.data section 是数据段,.bss section 是 BSS 段。

我们知道一段源程序代码在编译生成可执行文件的过程中,函数和变量是放在不同段中的。一般默认的规则如下。

section组成代码段( .text)函数定义、程序语句数据段( .data)初始化的全局变量、初始化的静态局部变量BSS段( .bss)未初始化的全局变量、未初始化的静态局部变量

比如,在下面的程序中,我们分别定义一个函数、一个全局变量和一个未初始化的全局变量。

  1. //hello.c
  2. int global_val = 8;
  3. int uninit_val;

  4. void print_star(void)
  5. {
  6.     printf("****\n");
  7. }
  8. int main(void)
  9. {
  10.     print_star();
  11.     return 0;
  12. }

接着,我们使用 GCC 编译这个程序,并查看生成的可执行文件 a.out 的符号表和 section header 表信息。


  1. $ gcc -o a.out hello.c
  2. $ readelf -s a.out
  3. $ readelf -S a.out
  4. 符号表信息:
  5. Num: Value Size Type Bind Vis Ndx Name
  6. 37: 00000000 0 FILE LOCAL DEFAULT ABS hello.c
  7. 48: 0804a024 4 OBJECT GLOBAL DEFAULT 26 uninit_val
  8. 51: 0804a014 0 NOTYPE WEAK DEFAULT 25 data_start
  9. 52: 0804a020 0 NOTYPE GLOBAL DEFAULT 25 _edata
  10. 53: 080484b4 0 FUNC GLOBAL DEFAULT 15 _fini
  11. 54: 0804a01c 4 OBJECT GLOBAL DEFAULT 25 global_val
  12. 55: 0804a014 0 NOTYPE GLOBAL DEFAULT 25 __data_start
  13. 61: 08048450 93 FUNC GLOBAL DEFAULT 14 __libc_csu_init
  14. 62: 0804a028 0 NOTYPE GLOBAL DEFAULT 26 _end
  15. 63: 08048310 0 FUNC GLOBAL DEFAULT 14 _start
  16. 64: 080484c8 4 OBJECT GLOBAL DEFAULT 16 _fp_hw
  17. 65: 0804840b 25 FUNC GLOBAL DEFAULT 14 print_star
  18. 66: 0804a020 0 NOTYPE GLOBAL DEFAULT 26 __bss_start
  19. 67: 08048424 36 FUNC GLOBAL DEFAULT 14 main
  20. 71: 080482a8 0 FUNC GLOBAL DEFAULT 11 _init
  21. section header信息:
  22. Section Headers:
  23. [Nr] Name Type Addr Off Size
  24. [14] .text PROGBITS 08048310 000310 0001a2
  25. [25] .data PROGBITS 0804a014 001014 00000c
  26. [26] .bss NOBITS 0804a020 001020 000008
  27. [27] .comment PROGBITS 00000000 001020 000034
  28. [28] .shstrtab STRTAB 00000000 001722 00010a
  29. [29] .symtab SYMTAB 00000000 001054 000480
  30. [30] .strtab STRTAB 00000000 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 版本的系统中,系统会有默认的链接脚本,不需要程序员操心。

  1. $ ld --verbose

我们使用上面命令,就可以查看编译当前程序时,链接器使用的默认链接脚本。在嵌入式系统中,因为是交叉编译,所以软件源码一般会自带一个链接脚本。比如在 U-boot 源码的根目录下面,你会看到一个 u-boot.lds 的文件,这个文件就是编译 U-boot 时,链接器要使用的链接脚本。在 Linux 内核中,同样会有 vmlinux.lds 这样一个链接脚本。

属性 section 编程示例

在 GNU C 中,我们可以通过 __atttribute__ 的 section 属性,显式指定一个函数或变量,在编译时放到指定的 section 里面。通过上面的程序我们知道,未初始化的全局变量是放在 .data section 中的,即放在 BSS 段中。现在我们就可以通过 section 属性,把这个未初始化的全局变量放到数据段 .data 中。

  1. int global_val = 8;
  2. int uninit_val __attribute__((section(".data")));
  3. int main(void)
  4. {
  5.     return 0;
  6. }

通过上面的 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 源码中的一个零长度数组。

  1. char __image_copy_start[0] __attribute__((section(".__image_copy_start")));
  2. char __image_copy_end[0] __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 源码的根目录下面。


  1. OUTPUT_FORMAT("elf32-littlearm",
  2.     "elf32-littlearm",
  3.     "elf32-littlearm")
  4. OUTPUT_ARCH(arm)
  5. ENTRY(_start)
  6. SECTIONS
  7. {
  8.  . = 0x00000000;
  9.  . = ALIGN(4);
  10.  .text :
  11.  {
  12.   *(.__image_copy_start)
  13.   *(.vectors)
  14.   arch/arm/cpu/armv7/start.o (.text*)
  15.   *(.text*)
  16.  }
  17.  . = ALIGN(4);
  18.  .data : {
  19.   *(.data*)
  20.  }
  21.     ...
  22.     ...
  23.  . = ALIGN(4);
  24.  .image_copy_end :
  25.  {
  26.   *(.__image_copy_end)
  27.  }
  28.  .end :
  29.  {
  30.   *(.__end)
  31.  }
  32.  _image_binary_end = .;
  33.  . = ALIGN(4096);
  34.  .mmutable : {
  35.   *(.mmutable)
  36.  }
  37.  .bss_start __rel_dyn_start (OVERLAY) : {
  38.   KEEP(*(.__bss_start));
  39.   __bss_base = .;
  40.  }
  41.  .bss __bss_base (OVERLAY) : {
  42.   *(.bss*)
  43.    . = ALIGN(4);
  44.    __bss_limit = .;
  45.  }
  46.  .bss_end __bss_limit (OVERLAY) : {
  47.   KEEP(*(.__bss_end));
  48.  }
  49. }
通过链接脚本我们可以看到,__image_copy_start 和 __image_copy_end 这两个 section,在链接的时候分别放在了代码段 .text 的前面、数据段 .data 的后面,作为 U-boot 拷贝自身代码的起始地址和结束地址。而在这两个 section 中,我们除了放2个零长度数组外,并没有再放其它变量。根据前面的学习我们知道,零长度数组是不占用存储空间的,所以上面定义的两个零长度数组,其实就分别代表了 U-boot 镜像要拷贝自身镜像的起始地址和结束地址。

  1. char __image_copy_start[0] __attribute__((section(".__image_copy_start")));
  2. char __image_copy_end[0] __attribute__((section(".__image_copy_end")));

无论 U-boot 自身镜像是存储在 Nor Flash,还是 Nand Flash 上,我们只要知道了这两个地址,就可以直接调用相关代码拷贝。

接着在 arch/arm/lib/relocate.S 中,ENTRY(relocate_code) 汇编代码主要完成代码拷贝的功能。

  1. ENTRY(relocate_code)
  2.     ldr r1, =__image_copy_start /* r1 <- SRC &__image_copy_start */
  3.     subs r4, r0, r1 /* r4 <- relocation offset */
  4.     beq relocate_done /* skip relocation */
  5.     ldr r2, =__image_copy_end /* r2 <- SRC &__image_copy_end */

  6. copy_loop:
  7.     ldmia {r10-r11} /* copy from source address [r1] */
  8.     stmia {r10-r11} /* copy to target address [r0] */
  9.     cmp r1, r2 /* until source end address [r2] */
  10.     blo copy_loop
在这段汇编代码中,寄存器 R1、R2 分别表示要拷贝镜像的起始地址和结束地址,R0 表示要拷贝到 RAM 中的地址,R4 存放的是源地址和目的地址之间的偏移,在后面重定位过程中会用到这个偏移值。

  1. ldr r1, =__image_copy_start

见上面指令,在汇编代码中,ARM的 ldr 指令立即寻址,直接对数组名进行引用,获取要拷贝镜像的首地址,并保存在 R1 寄存器中。数组名本身其实就代表一个地址。通过这种方式,U-boot 在嵌入式启动的初始阶段,就完成了自身代码的拷贝工作:从 Flash 上拷贝自身镜像到 RAM 中,然后再进行重定位,最后跳到 RAM 中执行。

电子书籍下载地址:https://pan.baidu.com/s/1a6L0cyIQKKLlmIfRw7U6Dg




















相关文章

  • 前面12节的课程,主要针对 Linux 内核中 GNU C 扩展的一些常用 C 语言语法进行了分析.GNU C 的这些扩展语法,主要用来完善 C 语言标准和编译优化.而通过 C 标准的发展过程我们又发现,对于一些编译器扩展的一些特性,或者其 ...
  • 内建函数,顾名思义,就是编译器内部实现的函数.这些函数跟关键字一样,可以直接使用,无须像标准库函数那样,要 #include 对应的头文件才能使用. 用来处理变长参数列表: 用来处理程序运行异常: 程序的编译优化.性能优化: 查看函数运行中 ...
  • 这一节,接着讲 __atttribute__ 属性声明,__atttribute__ 可以说是 GNU C 最大的特色.我们接下来继续讲一下跟内联函数相关的两个属性:noinline 和 always_inline.这两个属性的用途是告诉编 ...
  • GNU C 通过 __atttribute__ 声明weak属性,可以将一个强符号转换为弱符号. 编译器在编译源程序时,无论你是变量名.函数名,在它眼里,都是一个符号而已,用来表征一个地址.编译器会将这些符号集中,存放到一个叫符号表的 se ...
  • GNU 通过 __atttribute__ 扩展的 format 属性,用来指定变参函数的参数格式检查. 我们经常实现一些自己的打印调试函数.这些打印函数往往是变参函数,那编译器编译程序时,怎么知道我们的参数格式对不对呢?因为我们实现的是变 ...

2020 unjeep.com webmaster#unjeep.com
12 q. 0.014 s.
京ICP备10005923号