Operating Systems for Practical Programmers [1] – The Linker Script

/ 0评 / 0

我确实没想到的是 ARM 的开发者手册也是那么厚。

上一节我们建立了开发环境,现在我们需要面临的第一个问题是如何生成一个能够在开发板上跑的程序。通过交叉编译器,我们已经能够从 C 语言文件和汇编文件中生成对应二进制的目标文件,但是要得到最终成品,我们还需要将目标文件链接。

如果我们直接从 CubeMX 中生成工程的话,这个链接脚本也会附带生成。但本着探究原理的心态,我们可以尝试自己从各个参考手册中获得相关的信息以及如何撰写合适的链接脚本。


STM32F4 是有内置存储器的,我们一般编程的时候也会选择直接使用这些存储器。当然,由于没有内置的内存管理单元,所以在设计时如果希望添加外置存储器的话反而要下一番功夫。不过这些都是后话了。

首先我们需要知道内部存储器究竟是怎样排列的,也就是有哪些存储器以及它们的物理地址是什么。这些信息通常可以在内存映射表中找到。我们从《数据手册》中可以找到图 18 开发板的内存映射。目前我们感兴趣的部分是内置存储器部分,即 SRAM, CCM 和 FLASH 部分。它们的地址如下:

内存名称地址起迄大小
SRAM0x2000_0000 - 0x2001_ffff128KiB
CCM0x1000_0000 - 0x1000_ffff64KiB
FLASH0x0800_0000 - 0x080f_ffff512KiB

现在我们就可以根据上表定义存储区段了:

/* STM32F407VE_FLASH.ld */
MEMORY {
  RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
  CCRAM (rw) : ORIGIN = 0x10000000, LENGTH = 64K
  FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
}

我们还需要为程序指定一个入口函数。在我们进行桌面开发时,我们的链接脚本通常由编译器直接提供,所以这个入口函数一直是 main. 但是现在我们可以自己编写链接脚本,所以完全有自己的能力去自定义入口函数名!

当然,乱取名显然是不合适的。从《编程手册》上我们知道,在启动时,MCU 会跳转到重置中断处理程序开始执行。那么我们不妨遵从标准库的意见,将入口命名为 ResetHandler.

ENTRY(ResetHandler)

我们也可以定义一些常量以供之后使用:

MIN_STACK_SIZE = 0x400; /* 栈大小 */
MIN_HEAP_SIZE = 0x200; /* 堆大小 */

接下来我们开始定义不同的程序段。

首先需要考虑的是中断向量表。在启动时,视 BOOT 引脚的配置,0x0000_0000 将会映射到 FLASH 或者 SRAM 等设备,所以我们希望这部分数据在存储设备的最前方。我们可以单独为中断向量表创建一个区段并指示编译器将数据写入该区段:

SECTIONS {
  .isr_vector :
  {
    . = ALIGN(4); /* 将当前偏移量进行 4 字节对齐,其实按道理这里的位置就是 +0x0 */
    KEEP(*(.isr_vector)) /* 即使这部分没有调用,也一定要保留 */
    . = ALIGN(4);
  } >FLASH /* 写入 FLASH */

  /* 接下来以 in SECTIONS { ... } 开头的部分都放在这里 */
}
/* 其他则放在外面 */

之后是程序段 .text:

/* in SECTIONS { ... } */
.text :
{
  . = ALIGN(4); /* 4 字节对齐 */
  *(.text) /* 代码 */
  *(.text*) /* ^ */
  *(.glue_7) /* 编译器生成的宏代码 */
  *(.glue_7t) /* ^ */
  *(.eh_frame) /* 异常处理,尽管我们用 C, 这玩意在不给参数的情况下会被 crtbegin.o 使用 */
  KEEP(*(.init)) /* 初始化函数 */
  KEEP(*(.fini)) /* 清理函数 */
  . = ALIGN(4);
  _etext = .; /* 提供一个变量表示代码段结束 */
} >FLASH
/* 用于存储初始化函数和清理函数表 */
.preinit_array :
{
  PROVIDE_HIDDEN (__preinit_array_start = .);
  KEEP (*(.preinit_array*))
  PROVIDE_HIDDEN (__preinit_array_end = .);
} >FLASH
.init_array :
{
  PROVIDE_HIDDEN (__init_array_start = .);
  KEEP (*(SORT(.init_array.*)))
  KEEP (*(.init_array*))
  PROVIDE_HIDDEN (__init_array_end = .);
} >FLASH
.fini_array :
{
  PROVIDE_HIDDEN (__fini_array_start = .);
  KEEP (*(SORT(.fini_array.*)))
  KEEP (*(.fini_array*))
  PROVIDE_HIDDEN (__fini_array_end = .);
} >FLASH

在这里我们会发现一些不常见的段,比如 .glue_*, .eh_frame, .init.fini. .glue_ 段用于编译器为对应平台产生的胶水函数,而 .init 段用于存放在主函数执行之前需要被执行的内容,.fini 段用于存放在主函数退出后需要被执行的内容。.init.fini 段中的函数会被之后的 .init_array.fini_array 引用。

.eh_frame 段非常特殊,它用于实现栈展开 (Stack unwinding). 通常会在 C++ 的异常处理中使用。虽然我们并没有计划使用 C++, 但这个段会被 crtbegin.o 使用并生成内容。不过我们也可以向编译器传递 -fno-asynchronous-unwind-tables 参数不生成这个段。

接下来是只读数据 .rodata:

/* in SECTIONS { ... } */
.rodata :
{
  . = ALIGN(4); /* 4 字节对齐 */
  *(.rodata) /* 代码 */
  *(.rodata*) /* ^ */
  . = ALIGN(4);
} >FLASH

接下来是未初始化数据段 .bss. 这些数据是随程序运行动态产生的,所以它们可以直接被分配到内存中。

/* in SECTIONS { ... } */
.bss :
{
  . = ALIGN(4);
  _sbss = .;
  __bss_start__ = _sbss;
  *(.bss) /* 内存数据 */
  *(.bss*) /* ^ */
  *(COMMON) /* ^ */
  . = ALIGN(4);
  _ebss = .;
  __bss_end__ = _ebss;
} >RAM

接下来就是可读写数据段 .data 了。我们的程序显然是保存在 FLASH 里的,但是在运行时 FLASH 是只读的(至少在我们刻意设置之前对于 CPU 而言 FLASH 是只读的),那么这些数据该如何写呢?

答案是在启动时将数据复制到内存中就可以进行读写了。这也是下一节我们讨论启动汇编码的作用时需要涵盖的。现在我们先指示链接器按照 RAM 区计算地址,但是实际生成时要存放在 FLASH 内:

/* in SECTIONS { ... } */
.data :
{
  . = ALIGN(4);
  _sdata = .;
  *(.data) /* 已初始化数据 */
  *(.data*) /* ^ */
  . = ALIGN(4);
  _edata = .;
} >RAM AT> FLASH /* 按照 RAM 计算地址,且输出到 FLASH */

_sidata = LOADADDR(.data); /* .data 段的偏移量, 复制时需要用到 */

接下来我们需要在 RAM 中预留一定空间给程序使用。如果没有足够空间我们应该直接让编译失败而不是烧录之后发现有神秘 Bug 出现:

/* in SECTIONS { ... } */
.user_space :
{
  . = ALIGN(8);
  PROVIDE ( end = . );
  PROVIDE ( _end = . );
  . = . + MIN_HEAP_SIZE;
  PROVIDE (_eheap = .);
  . = . + MIN_STACK_SIZE;
  . = ALIGN(8);
  PROVIDE (_estack = .);
} >RAM

最后我们丢弃不需要的成分以减小内存用量:

/* in SECTIONS { ... } */
/DISCARD/ :
{
  libc.a ( * )
  libm.a ( * )
  libgcc.a ( * )
}

这时我们的链接脚本就基本成型了。当然,你可能会问「为什么定义了 CCM 区但是整个脚本都没有使用这个区域?」因为这篇文章我计划是写一个小时就可以收工的,但是现在我已经写了两个半小时了还没有完成。如果把 CCM 展开讲的话就又需要写很长内容,而这一节已经很长了。所以这个问题我们之后用到了再探究。

在下一节,我们将讨论如何编写初始化汇编程序 src/startup.s.

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

Your comments will be submitted to a human moderator and will only be shown publicly after approval. The moderator reserves the full right to not approve any comment without reason. Please be civil.