起因

有一个string.c 程序,其中有几种不同的全局变量:

  1. 初始化的全局变量str1,其值是一串字符,占6个字节

  2. 初始化的全局变量s,其是一组(指向常量字符串的)指针,占6 * 4 = 24 = 0x18个字节

  3. 未初始化的全局变量str,大小为0

char *s[] = {
	"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
	"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab",
	"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
	", World!\\n",
	"Hello, World!\\n",
	"#####"
};

char str1[] = "Hello";
char str[20];

当不需要将代码段和数据段分开存储的时候,下面的链接脚本是能用的:

ENTRY(_start)
PHDRS { text PT_LOAD; data PT_LOAD; }

SECTIONS {
  . = 0x80000000;
  .text : {
    *(entry)
    *(.text*)
  } : text
  etext = .;
  _etext = .;
  .rodata : {
    *(.rodata*)
  }
  .data : {
    *(.data)
  } : data
  edata = .;
  _data = .;
  .bss : {
	_bss_start = .;
    *(.bss*)
    *(.sbss*)
    *(.scommon)
  }
  _stack_top = ALIGN(0x1000);
  . = _stack_top + 0x8000;
  _stack_pointer = .;
  end = .;
  _end = .;
  _heap_start = ALIGN(0x1000);
}

解释起来也很简单,就是把所有SECTION一股脑全放到以0x80000000开始的地址处

最终会生成如下的ELF:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .text             PROGBITS        80000000 001000 0002f0 00  AX  0   0  4
  [ 2] .rodata           PROGBITS        800002f0 0012f0 0000b8 00   A  0   0  4
  [ 3] .sdata.str1       PROGBITS        800003a8 0013a8 000006 00  WA  0   0  4
  [ 4] .data.s           PROGBITS        800003b0 0013b0 000018 00  WA  0   0  4
  [ 5] .bss              NOBITS          800003c8 0013c8 000014 00  WA  0   0  4
  [ 6] .comment          PROGBITS        00000000 0013c8 00002b 01  MS  0   0  1
  [ 7] .riscv.attributes RISCV_ATTRIBUTE 00000000 0013f3 000025 00      0   0  1
  [ 8] .symtab           SYMTAB          00000000 001418 000300 10      9  23  4
  [ 9] .strtab           STRTAB          00000000 001718 0000de 00      0   0  1
  [10] .shstrtab         STRTAB          00000000 0017f6 00005d 00      0   0  1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  D (mbind), p (processor specific)

There are no section groups in this file.

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  RISCV_ATTRIBUT 0x0013f3 0x00000000 0x00000000 0x00025 0x00000 R   0x1
  LOAD           0x001000 0x80000000 0x80000000 0x003a8 0x003a8 R E 0x1000
  LOAD           0x0013a8 0x800003a8 0x800003a8 0x00020 0x00034 RW  0x1000

 Section to Segment mapping:
  Segment Sections...
   00     .riscv.attributes 
   01     .text .rodata 
   02     .sdata.str1 .data.s .bss 

节选重要的部分:

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  ...
  LOAD           0x0013a8 0x800003a8 0x800003a8 0x00020 0x00034 RW  0x1000
  ...
  
 Section to Segment mapping:
  Segment Sections...
	 ...
   02     .sdata.str1 .data.s .bss 
   ...

可以看到,全局变量str1s 以及节.bss被包括到同一个segment 中,且大小为0x20 。而不考虑内存对齐的情况下,这三个的总大小为0x6 + 0x18 + 0x0 = 0x1e ,而不是上面的0x20 ,这说明在segment内,对str1s 做了内存对齐,这样str10x8s0x18

并且,眼尖的你可能发现了,为何str1s 被输出为section:.sdata.str1.data.s ,但str 就直接是.bss 呢?(这直接为我后面的错误实现埋下了伏笔)

错误的实现

现在,考虑到真实的存储器环境,代码和全局变量初始上电加载时需要存放在非易失存储器中(比如FLASH、MROM),但是非易失存储器一般是不可写的,那么其中的全局变量就只能读而不能写。

为解决这个问题,需要在程序运行之前将非易失存储器中的全局变量搬到可写的易失存储器(比如SRAM)中,同时通知程序从新的存储器中访问这些全局变量。

上述工作就是通过链接脚本完成的,经过修改,有了一个新的链接脚本:

ENTRY(_start)
MEMORY {
  MROM (rx) : ORIGIN = 0x20000000, LENGTH = 4K    /* 只读,存放代码,全局变量初始位置 */
  SRAM (rw) : ORIGIN = 0x0f000000, LENGTH = 8K    /* 可读写,存放数据,全局变量运行时位置 */
}

SECTIONS {
  . = ORIGIN(MROM);
  .text : {
    *(entry)
    *(.text*)
  } > MROM AT> MROM
  etext = .;
  _etext = .;
  .rodata : {
    *(.rodata*)
  } > MROM AT> MROM
  
  .data :{
    *(.data)
  } > SRAM AT> MROM 

  _data_vma = ADDR(.data);
  _data_lma = LOADADDR(.data);
  _data_size = SIZEOF(.data);

  edata = .;
  _data = .;
  .bss : {
    _bss_start = .;
    *(.bss*)
    *(.sbss*)
    *(.scommon)
  } > SRAM
  _stack_top = ALIGN(0x100);
  . = _stack_top + 0x1000;  /* 栈大小改为4KB */
  _stack_pointer = .;
  end = .;
  _end = .;
  _heap_start = ALIGN(0x100);  
}

上述脚本中,通过MEMORY 定义了两种内存区域:MROM 可读可执行但是不能写,用来存放代码和初始全局变量;SRAM 可读可写不能执行,用来搬运后的全局变量以及栈、堆等数据。

然后在定义sections时,通过> region 声明该section在运行时处于内存的哪个区域,通过AT> region 声明该section在加载时处于内存的哪个区域。

所以下面的定义说明,.datasection从MROM加载,但是运行时处于SRAM。同时,我们称呼前者为lma(加载内存地址),称呼后者为vma(虚拟内存地址)

  .data :{
    *(.data)
  } > SRAM AT> MROM 
  
  _data_vma = ADDR(.data);
  _data_lma = LOADADDR(.data);
  _data_size = SIZEOF(.data);

目前位置,我们只是通过链接脚本通知程序全局变量被搬运到了vma ,但实际上这些数据还在lma 中,所以需要一段另外的程序负责搬运这些数据,并把这段程序放到入口程序(比如上文中的_start)中:

extern char _data_lma[];
extern char _data_vma[];
extern char _data_size[];

void _boot_loader() {
  memcpy((void *)_data_vma, (void *)_data_lma, (int)_data_size);
}

没错,这就是bootloaderloader的功能啦

编译后通过ELF 看看实现是否正确:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .text             PROGBITS        20000000 001000 000330 00  AX  0   0  4
  [ 2] .rodata           PROGBITS        20000330 001330 0000b8 00   A  0   0  4
  [ 3] .sdata.str1       PROGBITS        0f000000 002000 000006 00  WA  0   0  4
  [ 4] .data.s           PROGBITS        0f000008 002008 000018 00  WA  0   0  4
  [ 5] .bss              NOBITS          0f000020 002020 000014 00  WA  0   0  4
  [ 6] .comment          PROGBITS        00000000 002020 00002b 01  MS  0   0  1
  [ 7] .riscv.attributes RISCV_ATTRIBUTE 00000000 00204b 000025 00      0   0  1
  [ 8] .symtab           SYMTAB          00000000 002070 000380 10      9  26  4
  [ 9] .strtab           STRTAB          00000000 0023f0 00011e 00      0   0  1
  [10] .shstrtab         STRTAB          00000000 00250e 00005d 00      0   0  1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  D (mbind), p (processor specific)

There are no section groups in this file.

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  RISCV_ATTRIBUT 0x00204b 0x00000000 0x00000000 0x00025 0x00000 R   0x1
  LOAD           0x001000 0x20000000 0x20000000 0x003e8 0x003e8 R E 0x1000
  LOAD           0x002000 0x0f000000 0x200003e8 0x00006 0x00006 RW  0x1000
  LOAD           0x002008 0x0f000008 0x200003ee 0x00018 0x0002c RW  0x1000
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x10

 Section to Segment mapping:
  Segment Sections...
   00     .riscv.attributes 
   01     .text .rodata 
   02     .sdata.str1 
   03     .data.s .bss 
   04     

可以看到,还是生成了三个section,.sdata.str1.data.s.bss ,它们的lma位于MROM里,vma 位于SRAM里,似乎没什么问题。但是现在这三个section并不处于一个segment中了。

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  ...
  LOAD           0x002000 0x0f000000 0x200003e8 0x00006 0x00006 RW  0x1000
  LOAD           0x002008 0x0f000008 0x200003ee 0x00018 0x0002c RW  0x1000
  ...

 Section to Segment mapping:
  Segment Sections...
	 ...
   02     .sdata.str1 
   03     .data.s .bss 
   ...

仔细观察,.sdata.str1 的物理地址PhysAddrlma0x200003e8 ,而.data.sPhysAddr0x200003ee ,两者正好相差0x06 也就是.sdata.str1 的大小。但是两者的虚拟地址VirtAddr 分别为0x0020000x002008 ,相差为0x08 。这意味着在物理地址中,两者没有按照4字节对齐(str1占6个字节,而s紧跟在其后),但是在虚拟地址中两者是按照4字节对齐的(str1实际占6个字节,但空出2个字节,再接着才是s)。

这下问题可大了,如果继续按照上面的实现搬运这些全局变量,那么由于物理地址中str1后没有空出这两个字节, 程序会从0x0f000008 处访问s ,但是搬运时s 被放到了0x0f000006 处,同时不要忘记了s存放的可是指针,这样程序一旦想解引用这些指针就会访问到其他地方。

分析1:错误的通配(copy-paste error)

在经过痛苦的阅读文档和调试后,我才惊奇的发现问题出在.data 的定义上。

还记得上面我们提到,为何str1s 被输出为section:.sdata.str1.data.s,而str 就直接是.bss

让我们来看看string.o的sections:

Section Headers:
[Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
[ 0]                   NULL            00000000 000000 000000 00      0   0  0
[ 1] .text             PROGBITS        00000000 000034 000000 00  AX  0   0  4
[ 2] .data             PROGBITS        00000000 000034 000000 00  WA  0   0  1
[ 3] .bss              NOBITS          00000000 000034 000000 00  WA  0   0  1
[ 4] .text.check       PROGBITS        00000000 000034 00001c 00  AX  0   0  4
[ 5] .rela.text.check  RELA            00000000 000624 000024 0c   I 18   4  4
[ 6] .text.startu[...] PROGBITS        00000000 000050 00013c 00  AX  0   0  4
[ 7] .rela.text.s[...] RELA            00000000 000648 000258 0c   I 18   6  4
[ 8] .bss.str          NOBITS          00000000 00018c 000014 00  WA  0   0  4
[ 9] .sdata.str1       PROGBITS        00000000 00018c 000006 00  WA  0   0  4
[10] .rodata.str1.4    PROGBITS        00000000 000194 000072 01 AMS  0   0  4
[11] .data.s           PROGBITS        00000000 000208 000018 00  WA  0   0  4
[12] .rela.data.s      RELA            00000000 0008a0 000048 0c   I 18  11  4
[13] .comment          PROGBITS        00000000 000220 00002c 01  MS  0   0  1
[14] .note.GNU-stack   PROGBITS        00000000 00024c 000000 00      0   0  1
[15] .eh_frame         PROGBITS        00000000 00024c 000050 00   A  0   0  4
[16] .rela.eh_frame    RELA            00000000 0008e8 000090 0c   I 18  15  4
[17] .riscv.attributes RISCV_ATTRIBUTE 00000000 00029c 000025 00      0   0  1
[18] .symtab           SYMTAB          00000000 0002c4 0002e0 10     19  35  4
[19] .strtab           STRTAB          00000000 0005a4 00007e 00      0   0  1
[20] .shstrtab         STRTAB          00000000 000978 0000c0 00      0   0  1

可以看到,跟data相关的不止有.data 还有.data.s.sdata.str1 ;同理跟bss相关的不止有.bss还有.bss.str

它们分别对应了全局变量sstr1str 。而下面链接脚本中.data的写法只是将所有输入目标文件的.data 输出到同一个.data 中,至于其他的.data.s.sdata.str1 会隐式的创建对应的section;相反.bss 不止通配了*(.bss) ,所以string.o中的.bss.str 被输出到.bss

  .data :{
    *(.data)
  } > SRAM AT> MROM 
  
  .bss : {
	  _bss_start = .;
    *(.bss*)
    *(.sbss*)
    *(.scommon)
  } > SRAM

分析2:为什么VMA对齐了,但是LMA没有

按照分析1的结果,.data 的通配项写错了导致隐式的输出了对应的section,而根据输出的segment可以看到,物理地址是连续的但是虚拟地址是对齐的,同时物理地址和虚拟地址都正确的处于MROMSRAM中。

那么有理由怀疑隐式输出的section.sdata 使用了.data> SRAM AT> MROM ,于是我修改上述通配如下来验证:

  .sdata :{
    *(.sdata*)
  } > SRAM AT> MROM 
  
  .data :{
    *(.data*)
  } > SRAM AT> MROM 

  .bss : {
    _bss_start = .;
    *(.bss*)
    *(.sbss*)
    *(.scommon)
  } > SRAM

输出的ELF果然有.data.sdata.bss.data.bss 同样是被分配到同一个segment中。观察它们的地址,发现在物理地址上.data还是没有对齐,而虚拟地址却对齐了。

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  ...
  LOAD           0x002000 0x0f000000 0x200003e8 0x00006 0x00006 RW  0x1000
  LOAD           0x002008 0x0f000008 0x200003ee 0x00018 0x0002c RW  0x1000
  ...

 Section to Segment mapping:
  Segment Sections...
   ...
   02     .sdata 
   03     .data .bss 
   ...   

这么说,只要在定义时让 .data 的起始地址对齐不就好了,但是随后就会发现无法简单通过操作. 来改变LMA:

  .sdata :{
    *(.sdata*)
  } > SRAM AT> MROM 
  
  . = ALIGN(4);
  .data :{
    *(.data*)
  } > SRAM AT> MROM 

但是可以曲线实现,只是这样的话_data_size就要更复杂了(这里懒得弄直接加2了):

  .sdata : {
    *(.sdata*)
  } > SRAM AT> MROM 

  . = LOADADDR(.sdata) + SIZEOF(.sdata);
  . = ALIGN(0x4);
  _align_addr = .;
  .data : AT(_align_addr) {  // 不能直接使用'.'
    *(.data*)
  } > SRAM

  _data_vma = ADDR(.sdata);
  _data_lma = LOADADDR(.sdata);
  _data_size = SIZEOF(.data) + SIZEOF(.sdata) + 0x2;

或者可以通过在section内部使用ALIGN来对齐:

  .sdata :{
    *(.sdata*)
    . = ALIGN(4);
  } > SRAM AT> MROM 
  
  .data :{
    *(.data*)
  } > SRAM AT> MROM 

当然,最好的解决方法是仿照.bss 的通配,重新定义.data

  .data :{
    *(.data*)
    *(.sdata*)
    *(.scommon)
  } > SRAM AT> MROM 

这样,.data 中物理地址和虚拟地址都没有对齐:

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  ...
  LOAD           0x002000 0x0f000000 0x200003e8 0x0001e 0x0001e RW  0x1000
  LOAD           0x000020 0x0f000020 0x20000406 0x00000 0x00014 RW  0x1000
  ...

 Section to Segment mapping:
  Segment Sections...
   ...
   02     .data 
   03     .bss 
   ...     

而后续的.bss 虽然与.data 在物理地址上没对齐,在虚拟地址上对齐了,但是这部分数据不需要搬运,不会对程序造成影响。不过我们也可以仿照上面在section内ALIGN让它们对齐:

  .data :{
    *(.data*)
    *(.sdata*)
    *(.scommon)
    . = ALIGN(4);
  } > SRAM AT> MROM 

分析3:为什么之前这么写没问题

TODO

芯片技术从业者,本站签约作者