硬件工程师|编译器是如何将芯片执行的第一个指令放到芯片起始地址的()

编译器是如何将芯片执行的第一个指令放到芯片起始地址的?
芯片上电后,会自动跳到第一需要执行的指令,那么编译器和链接器是做了哪些工作才让第一条指令被放在了正确的地方,以arm为例,uboot编译后为何能确保reset被放在了起始地址呢?
本人8年嵌入式Linux BSP开发经验,关于U-Boot的开发经验如下:
·平台开发:涉及ARM32/64、MIPS架构,MTK、海思等不下5个厂商U-Boot的BSP开发
·版本升级:将某平台的低版本U-boot升级至最新u-boot-2022.01版本
·架构设计:将业务代码从U-Boot中剥离,灵活适配不同U-boot版本、所有产品不同平台不同架构的不同单板
所以,相信有资格回答这个问题。
本文8000多字,掏心带你深入理解背后的原理,需要你耐心往下看。除了问题本身,你还将收获:
·什么是链接脚本,U-Boot下的链接脚本长什么样?
·U-Boot编译时是怎么链接的?
·U-Boot下这么多start.s,当前设备跑的到底是哪一个?
正文
这个问题往深了说就涉及到了编译原理。
在计算机世界中,其实每一次链接过程都是由链接脚本控制的。那么什么是链接脚本呢?简单说就是由链接器命令语言书写的,给链接器看的,主要的目的是描述输入文件中的段(如text段、data段、bss段等)如何在输出文件中组装,并控制输出文件的存储布局。
那么上面提到的链接器、链接脚本、输入文件、输出文件分别是指什么呢?
读万卷书,不如走一步路。动动手指,编译一下U-Boot就知道了,如果你还不清楚怎么编译或者没有环境的话,建议你参考我的文章搭建一个:
闪光吧Linux:走进嵌入式Linux大门的第二步——构建最新u-boot学习环境9赞同  · 0评论文章
下面是编译结果,截取重点如下:
$ make CROSS_COMPILE=aarch64-linux-gnu- V=1
....
aarch64-linux-gnu-ld.bfd -pie --gc-sections -Bstatic --no-dynamic-linker -z notext --build-id=none -Ttext 0x00000000 -o u-boot -T u-boot.lds arch/arm/cpu/armv8/start.o --whole-archive arch/arm/cpu/built-in.o arch/arm/cpu/armv8/built-in.o arch/arm/lib/built-in.o board/emulation/common/built-in.o board/emulation/qemu-arm/built-in.o boot/built-in.o cmd/built-in.o common/built-in.o disk/built-in.o drivers/built-in.o drivers/usb/cdns3/built-in.o drivers/usb/common/built-in.o drivers/usb/dwc3/built-in.o drivers/usb/emul/built-in.o drivers/usb/eth/built-in.o drivers/usb/host/built-in.o drivers/usb/mtu3/built-in.o drivers/usb/musb-new/built-in.o drivers/usb/musb/built-in.o drivers/usb/phy/built-in.o drivers/usb/ulpi/built-in.o env/built-in.o fs/built-in.o lib/built-in.o net/built-in.o --no-whole-archive -L /usr/lib/gcc-cross/aarch64-linux-gnu/9 -lgcc -Map u-boot.map; true
    aarch64-linux-gnu-objcopy --gap-fill=0xff -j .text -j .secure_text -j .secure_data -j .rodata -j .data -j .u_boot_list -j .rela.dyn -j .got -j .got.plt -j .binman_sym_table -j .text_rest -j .dtb.init.rodata -j .efi_runtime -j .efi_runtime_rel -O srec u-boot u-boot.srec
    aarch64-linux-gnu-objcopy --gap-fill=0xff -j .text -j .secure_text -j .secure_data -j .rodata -j .data -j .u_boot_list -j .rela.dyn -j .got -j .got.plt -j .binman_sym_table -j .text_rest -j .dtb.init.rodata -j .efi_runtime -j .efi_runtime_rel -O binary u-boot u-boot-nodtb.bin && { echo ' start=$(aarch64-linux-gnu-nm | grep __rel_dyn_start | cut -f 1 -d '\'' '\''); end=$(aarch64-linux-gnu-nm | grep __rel_dyn_end | cut -f 1 -d '\'' '\''); tools/relocate-rela $start $end'; start=$(aarch64-linux-gnu-nm u-boot | grep __rel_dyn_start | cut -f 1 -d ' '); end=$(aarch64-linux-gnu-nm u-boot | grep __rel_dyn_end | cut -f 1 -d ' '); tools/relocate-rela u-boot-nodtb.bin  0x00000000 $start $end; } || { rm -f u-boot-nodtb.bin; false; }
    start=$(aarch64-linux-gnu-nm | grep __rel_dyn_start | cut -f 1 -d ' '); end=$(aarch64-linux-gnu-nm | grep __rel_dyn_end | cut -f 1 -d ' '); tools/relocate-rela $start $end
    cp u-boot-nodtb.bin u-boot.bin
    aarch64-linux-gnu-objdump -t u-boot > u-boot.sym
....
这里的链接器就是  aarch64-linux-gnu-ld.bfd。链接脚本就是u-boot.lds,用“-T”命令行选项来指定。输入文件就是各个目录下编译好的build-in.o以及lib库。输出文件就是u-boot,通过“-o”命令行选项来指定。
千呼万唤始出来,我们来看看u-boot.lds这个链接脚本长的帅不帅:
linuxer@linuxer-virtual-machine:~/work/u-boot-2022.01$ vim u-boot.lds
OUTPUT_FORMAT("elf64-littleaarch64", "elf64-littleaarch64", "elf64-littleaarch64")
OUTPUT_ARCH(aarch64)
ENTRY(_start)
SECTIONS
{
  . = 0x00000000;
  . = ALIGN(8);
  .text :
  {
    *(.__image_copy_start)
    arch/arm/cpu/armv8/start.o (.text*)
  }
  .efi_runtime : {
                                __efi_runtime_start = .;
    *(.text.efi_runtime*)
    *(.rodata.efi_runtime*)
    *(.data.efi_runtime*)
                                __efi_runtime_stop = .;
【硬件工程师|编译器是如何将芯片执行的第一个指令放到芯片起始地址的()】  }
  .text_rest :
  {
    *(.text*)
  }
  . = ALIGN(8);
  .rodata  : { *(SORT_BY_ALIGNMENT(SORT_BY_NAME(.rodata*))) }
  . = ALIGN(8);
  .data : {
    *(.data*)
  }
  . = ALIGN(8);
  . = .;
  . = ALIGN(8);
  .u_boot_list : {
    KEEP(*(SORT(.u_boot_list*)));
  }
  . = ALIGN(8);
  .efi_runtime_rel : {
                                __efi_runtime_rel_start = .;
    *(.rel*.efi_runtime)
    *(.rel*.efi_runtime.*)
linuxer@linuxer-virtual-machine:~/work/u-boot-2022.01$ cat u-boot.lds
OUTPUT_FORMAT("elf64-littleaarch64", "elf64-littleaarch64", "elf64-littleaarch64")
OUTPUT_ARCH(aarch64)
ENTRY(_start)
SECTIONS
{
  . = 0x00000000;
  . = ALIGN(8);
  .text :
  {
    *(.__image_copy_start)
    arch/arm/cpu/armv8/start.o (.text*)
  }
  .efi_runtime : {
                                __efi_runtime_start = .;
    *(.text.efi_runtime*)
    *(.rodata.efi_runtime*)
    *(.data.efi_runtime*)
                                __efi_runtime_stop = .;
  }
  .text_rest :
  {
    *(.text*)
  }
  . = ALIGN(8);
  .rodata : { *(SORT_BY_ALIGNMENT(SORT_BY_NAME(.rodata*))) }
  . = ALIGN(8);
  .data : {
    *(.data*)
  }
  . = ALIGN(8);
  . = .;
  . = ALIGN(8);
  .u_boot_list : {
    KEEP(*(SORT(.u_boot_list*)));
  }
  . = ALIGN(8);
  .efi_runtime_rel : {
                                __efi_runtime_rel_start = .;
    *(.rel*.efi_runtime)
    *(.rel*.efi_runtime.*)
                                __efi_runtime_rel_stop = .;
  }
  . = ALIGN(8);
  .image_copy_end :
  {
    *(.__image_copy_end)
  }
  . = ALIGN(8);
  .rel_dyn_start :
  {
    *(.__rel_dyn_start)
  }
  .rela.dyn  : {
    *(.rela*)
  }
  .rel_dyn_end :
  {
    *(.__rel_dyn_end)
  }
  _end = .;
  . = ALIGN(8);
  .bss_start : {
    KEEP(*(.__bss_start));
  }
  .bss : {
    *(.bss*)
      . = ALIGN(8);
  }
  .bss_end : {
    KEEP(*(.__bss_end));
  }
  /DISCARD/ : { *(.dynsym) }
  /DISCARD/ : { *(.dynstr*) }
  /DISCARD/ : { *(.dynamic*) }
  /DISCARD/ : { *(.plt*) }
  /DISCARD/ : { *(.interp*) }
  /DISCARD/ : { *(.gnu*) }
}
咋一看是不是挺帅的,好,那我们来认识一下这位大帅哥。
他首先是一个文本文件,里面有一系列命令。其中SECTIONS该命令用于描述输出文件的内存布局,它后面跟着花括号中的一些列符号分配和输出段的描述。该命令的第一行设定了特殊符号“.”的值,“.”值是位置计数器。如果不用其他方式指定输出段的地址,地址从位置计数器的当前值开始计算。在SECTIONS命令的最开始,位置计数器的值是0。
接下来定义输出段“.text”。冒号是必需的格式。在输出段的名称后有一个花括号,里面可以列举放入该输出段的输入段。“*”是匹配任何文件名的通配符。表达式“*(.__image_copy_start)”表示所有输入文件中的所有“.__image_copy_start”输入段。接下来是表达式“arch/arm/cpu/armv8/start.o(.text*)”表示所有输入文件中的“.text”段的arch/arm/cpu/armv8/start.o。
题主的关切点来了:
U-Boot执行的第一条指令用链接脚本中的术语叫做“入口点”。链接脚本中使用ENTRY命令来设置。参数是一个符号名称:ENTRY(symol),这里的符号就是"_start"。这里的“_start”是什么呢?就是0x0,查看方式如下:
硬件工程师|编译器是如何将芯片执行的第一个指令放到芯片起始地址的()
文章图片

那么0x0后面放什么呢?摘取关键部分如下:
  . = 0x00000000;
  . = ALIGN(8);
  .text :
  {
    *(.__image_copy_start)
    arch/arm/cpu/armv8/start.o (.text*)
  }
0x0后面放text段,text段里面先放什么呢?就是“*(.__image_copy_start)”,是什么呢,还是0x0:

硬件工程师|编译器是如何将芯片执行的第一个指令放到芯片起始地址的()
文章图片

下面就是主角的真面目了,就是arch/arm/cpu/armv8/start.o (.text*),再揭开面纱一睹真容:
linuxer@linuxer-virtual-machine:~/work/u-boot-2022.01$ vim arch/arm/cpu/armv8/start.S
  *************************************************************************/

.globl _start
_start:
                b reset
                .align 3
....
懂了吧,现在知道为啥“b reset”就会放在存储介质的0地址了吧,圆满解答题主的问题。

2022/04/03更新(没想到阅读量已快1千,感谢知友的赞同和追更):
关于链接脚本的入口点的补充:
前面提到,可以使用ENTRY链接脚本命令设置入口点。其实链接器支持多种方式设置入口点,会按照如下优先顺序尝试设定入口点:
.“-e”入口命令行选项。
.链接脚本中的ENTRY(symbol)命令。
.已经定义的目标特定符号的值。通常是start。
.链接脚本段中第一个字节的地址。
.地址0。
光说不练假把式,实验来检验。
实验一:屏蔽链接脚本中的“ENTRY(_start)"和"arch/arm/cpu/armv8/start.o"

硬件工程师|编译器是如何将芯片执行的第一个指令放到芯片起始地址的()
文章图片

重新编译后,查看映射表不再是从“b reset”开始了,直接是“__arm_smccc_smc”函数:
$ less System.map
0000000000000000 T __arm_smccc_smc
0000000000000000 T __efi_runtime_start
0000000000000000 T __image_copy_start
000000000000002c T __arm_smccc_hvc
0000000000000058 T invoke_psci_fn
00000000000000d4 T efi_reset_system
0000000000000130 W efi_get_time
0000000000000138 W efi_set_time
这种情况直接走顺序4,而.text段描述并没有指定具体内容,顺着肯定就是.efi_runtime段的第一个字节了。
实验二:保留ENTRY(_start),并将“arch/arm/cpu/armv8/start.o”替换为“*(.text)*”:

硬件工程师|编译器是如何将芯片执行的第一个指令放到芯片起始地址的()
文章图片

这种情况扔能通过"ENTRY(_start)"找到对应入口:
$ less System.map
0000000000000000 T __image_copy_start
0000000000000000 T _start
0000000000000008 T _TEXT_BASE
0000000000000010 T _end_ofs
0000000000000018 T _bss_start_ofs
0000000000000020 T _bss_end_ofs
0000000000000028 t reset
000000000000002c T save_boot_params_ret
但无法直接看出是哪个.o,实际确实是“arch/arm/cpu/armv8/start.o”,因为只有"arch/arm/cpu/armv8/start.S"定义了".globl _start"。
这种情况直接走顺序3。
实验三:删除“ENTRY(_start)",并将“arch/arm/cpu/armv8/start.o”替换为“*(.text)*”:

硬件工程师|编译器是如何将芯片执行的第一个指令放到芯片起始地址的()
文章图片

扔能找到arch/arm/cpu/armv8/start.S作为入口:
$ less System.map
0000000000000000 T __image_copy_start
0000000000000000 T _start
0000000000000008 T _TEXT_BASE
0000000000000010 T _end_ofs
0000000000000018 T _bss_start_ofs
0000000000000020 T _bss_end_ofs
0000000000000028 t reset
000000000000002c T save_boot_params_ret
这种情况直接走顺序4。
剩下的实验就交给你啦。

    推荐阅读