1. 重定位的引入

S3C2440的CPU可以直接给片外SDRAM、4K的片内SRAM、Nor Flash、发送读写命令,但是不能直接给Nand Flash发送读写命令。假如把程序烧写到Nand Flash上,即向Nand Flash烧入bin文件,CPU是无法从Nand Flash中取代码执行的。

那为什还可以使用NAND启动?

  1. 上电后,CPU检测到NAND启动,会自动把Nand Flash前4K内容复制到片内SRAM;
  2. CPU从0地址运行SRAM;
  3. 如果程序大于4K,前4K的代码需要把整个程序读出来放到SDRAM(即代码重定位);

如果从Nor Flash启动,会出现什么问题?

  1. 将拨动开关拨到Nor Flash启动时,此时CPU认为的0地址在Nor Flash上面,片内SRAM的基地址就变成了0x40000000(Nand启动时片内SRAM的基地址是0)。
  2. Nor Flash可以像内存一样读,但不能和内存一样写,因此需要将全局变量和静态变量重定位到片外SDRAM中。局部变量不需要重定位,因为其保存在栈中。

例如执行如下几条汇编指令:

1
2
3
MOV R0, #0
LDR R1, [R0] @读有效
STR R1, [R0] @写无效

当程序中含有需要写的全局变量或者静态变量时,假如是NAND Flash可以正常操作,因为此时其实是在片内SRAM中执行的,但如果是在Nor Flash则写无效,因此我们需要将全局变量和静态变量重定位到片外SDRAM中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include "s3c2440_soc.h"
#include "uart.h"
#include "init.h"

char g_Char = 'A';
const char g_Char2 = 'B';
int g_A = 0;
int g_B;

int main(void)
{
uart0_init();
while(1)
{
putchar(g_Char); //串口输出g_Char
g_Char++; //Nor中g_Char++无效
delay(1000000);
}
return 0;
}

链接:arm-linux-ld -Ttext 0 -Tdata 0x800 start.o uart.o init.o main.o -o relocate.elf
烧写在NAND中启动显示ABCDE…。
烧写在Nor中启动显示AAA…,也即说明了Nor不能正常的写。

我们的bin文件大小是2049字节,也就是我们指定的-Tdata 0x800后只存放了char g_Char = ‘A’ 这个变量,
其他的变量呢?查看反汇编文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Disassembly of section .data:

00000800 <__data_start>:
800: Address 0x800 is out of bounds. //数据段开始,-Tdata 0x800指定

Disassembly of section .rodata:

00000474 <g_Char2>: //const放在只读数据段内
474: Address 0x474 is out of bounds.

Disassembly of section .bss: //未初始化的和初值为0的变量都放在bss段,不保存在bin中

00000804 <g_A>:
804: 00000000 andeq r0, r0, r0

00000808 <g_B>:
808: 00000000 andeq r0, r0, r0
Disassembly of section .comment: //注释,调试信息等,不保存在bin中,会保存在elf中

00000000 <.comment>:
......

2. 链接脚本的引入

前面程序发现在Nor Flash启动和从Nand Flash启动的效果不同,这是为什么呢?

  1. 假如现在是从Nor启动:

Nor启动

Nor Flash就被认为是0地址,g_Char被放到0x800后面。CPU上电后开始从0地址运行,它能读取Nor上的代码执行(xip,execute in place),打印出A,但执行g_Char++的时候,写操作无效,所以下次打印出来还是A。

  1. 假如现在是从Nand启动:

Nand启动

上电后Nand Flash前4K的代码被自动的复制到片内SRAM,此时的0地址就是片内SRAM,CPU上电后开始从0地址运行,读取片内SRAM上的代码执行,所以g_Char++能够生效。

为了解决Nor Flash中变量不能修改的问题,可以将变量所在的.data段放在片外SDRAM中试试:
链接:arm-linux-ld -Ttext 0 -Tdata 0x30000000 start.o uart.o init.o main.o -o relocate.elf
编译出来的bin文件很大,因为从0地址到0x30000000之间有一大块空白区域在代码段和数据段之间,称为“黑洞”。

黑洞区

解决黑洞区有两个办法:这两个办法的区别一个只重定位了数据段,另一个重定位了整个程序;

第一个办法:

  1. 把数据段和代码段靠在一起;
  2. 烧写到Nor上,运行时把数据段复制到片外SDRAM,即0x30000000(重定位);

第二个办法:

  1. 让文件直接从0x30000000地址开始,全局变量在0x3…….;
  2. 烧写到Nor上,运行时把整个程序从0地址复制到片外SDRAM上,即0x30000000(重定位);

要实现两种办法需要引入链接脚本,先来看看链接脚本的语法:

1
2
3
4
5
6
7
8
9
10
11
12
SECTIONS{
...
secname start BLOCK(align) (NOLOAD) : AT(ldadr)
{ contents } >region : phdr =fill
...
}
解释:
secname:段名
start:起始地址,链接地址,运行时地址(runtime addr),重定位地址(relocate addr)
AT(ldadr):加载地址(load addr),省略时:load addr = runtime addr
{ contents }:这个段要放的内容
>region : phdr =fill:可以不写,我们用不上

我的理解:

就嵌入式系统而言,程序通常编译和链接为在一个特定的内存地址运行——这通常是SDRAM的起始地址。这个地址就是链接地址,因为它被用于编译时解析程序内的地址。然后,这个程序的镜像(二进制文件)被烧录到了Flash存储器中的一个位置,这个位置被称为Flash的烧录地址,也就是加载地址。在系统上电启动时,引导加载器(Bootloader)会将程序从Flash复制到SDRAM中,这时它被复制到的是预先设置好的链接地址。

换句话说,链接地址是程序预计会运行的内存地址,加载地址是程序实际存储在非易失性存储介质上的地址。当系统启动时,Bootloader负责将程序从Flash(加载地址)复制到SDRAM的链接地址处。

总结:

链接地址:是程序在内存中预期运行的位置,也是链接器在生成可执行程序时使用的地址。

加载地址:在嵌入式环境中通常是指程序存储在非易失性存储(如Flash)中的地址。

启动时,Bootloader负责将程序从其在Flash中的存储位置(加载地址)复制到SDRAM的链接地址处开始执行。

2.1 分体式链接脚本

我们来实现第一种办法:让数据段放在0x800,运行时在0x30000000

1
2
3
4
5
6
7
SECTIONS {
.text 0 : { *(.text) } //所有文件的.text
.rodata : { *(.rodata) } //所有文件的只读数据段
.data 0x30000000 : AT(0x800) { *(.data) } //加载地址为0x800,链接地址为0x30000000
.bss : { *(.bss) *(.COMMON) } //所有文件的bss段、common段
}
//链接:arm-linux-ld -T relocate.lds start.o uart.o init.o main.o -o relocate.elf

重新编译烧录程序发现打印乱码,这是因为我们会从链接地址0x30000000获取g_Char,但在这之前并没有在该地址处准备好数据,因此我们需要重定位数据段,将数据段的加载地址0x800处把数据移动到链接地址。在start.s中加入:

1
2
3
4
5
6
7
/* 重定位data段 */
mov r1, #0x800
ldr r0, [r1]
mov r1, #0x30000000
str r0, [r1]

bl main

这种写法不太通用,只能复制0x800处的一个数据,而且加载地址还需要自己确定,并且因为bin文件不保存bss段,所以我们的程序还需要将bss段清零。改进如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SECTIONS {
.text 0 : { *(.text) } //所有文件的.text
.rodata : { *(.rodata) } //所有文件的只读数据段
.data 0x30000000 : AT(0x800)
{
data_load_addr = LOADADDR(.data); //使用宏提取出加载地址
data_start = . ;
*(.data)
data_end = . ;
}
bss_start = .;
.bss : { *(.bss) *(.COMMON) }
bss_end = .;
}
//链接:arm-linux-ld -T relocate.lds start.o uart.o init.o main.o -o relocate.elf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
	/* 重定位data段 */
ldr r1, =data_load_addr /* data段在bin文件中的地址, 加载地址 */
ldr r2, =data_start /* data段在重定位地址, 运行时的地址 */
ldr r3, =data_end /* data段结束地址 */

cpy:
ldrb r4, [r1]
strb r4, [r2]
add r1, r1, #1
add r2, r2, #1
cmp r2, r3
bne cpy

/* 清除BSS段 */
ldr r1, =bss_start
ldr r2, =bss_end
mov r3, #0

clean:
strb r3, [r1]
add r1, r1, #1
cmp r1, r2
bne clean

bl main

2.2 改进链接脚本

硬件的访问是很耗时的,2440的Nor Flash是16位,SDRAM是32位。假设现在需要重定位16Byte。

采用ldrb,strb命令每次只能读写1Byte数据:

  1. 内存控制器收到ldrb命令后访问Nor,需要访问Nor硬件16次;
  2. 内存控制器收到str命令后访问SDRAM,需要访问SDRAM硬件16次;
  3. 复制16Byte一共访问了32次硬件;

采用ldr,str命令一次能读写4Byte数据:

  1. 内存控制器收到ldr命令后访问Nor,CPU发出4次ldr,需要访问Nor硬件8次;
  2. 内存控制器收到str命令后访问SDRAM,CPU发出4次str,需要访问SDRAM硬件4次;
  3. 复制16Byte一共访问了12次硬件;

继续改进代码:此时要注意地址对齐,否则清除bss段时很可能会意外清除掉data段的数据,使用ALIGN对齐。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SECTIONS {
.text 0 : { *(.text) }
.rodata : { *(.rodata) }
.data 0x30000000 : AT(0x800)
{
data_load_addr = LOADADDR(.data);
. = ALIGN(4);
data_start = . ;
*(.data)
data_end = . ;
}
. = ALIGN(4);
bss_start = .;
.bss : { *(.bss) *(.COMMON) }
bss_end = .;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
	/* 重定位data段 */
ldr r1, =data_load_addr /* data段在bin文件中的地址, 加载地址 */
ldr r2, =data_start /* data段在重定位地址, 运行时的地址 */
ldr r3, =data_end /* data段结束地址 */

cpy:
ldr r4, [r1]
str r4, [r2]
add r1, r1, #4
add r2, r2, #4
cmp r2, r3
ble cpy

/* 清除BSS段 */
ldr r1, =bss_start
ldr r2, =bss_end
mov r3, #0

clean:
str r3, [r1]
add r1, r1, #4
cmp r1, r2
ble clean

bl main

2.3 一体式链接脚本

分体式链接脚本的代码段和数据段的链接地址是分开的,一体式链接脚本的代码段、数据段等都是连续在一起的,我们的嵌入式系统一般都采用一体式,原因如下:

  1. 分体式链接脚本适合单片机,单片机自带有flash可以xip,不需要将代码复制到ram中。
  2. 单片机的ram资源很小,所以将代码复制到ram是很浪费的。
  3. 嵌入式系统ram很大,没必要节省这点空间,而且很多没有nor flash这种xip的flash;
  4. 所以嵌入式需要使用一体式将Nand Flash或SD卡中的程序全部复制到ram中执行。

修改链接脚本和start.s如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SECTIONS
{
. = 0x30000000;
. = ALIGN(4);
.text :
{
*(.text)
}
. = ALIGN(4);
.rodata : { *(.rodata) }
. = ALIGN(4);
.data : { *(.data) }
. = ALIGN(4);
__bss_start = .;
.bss : { *(.bss) *(.COMMON) }
_end = .;
}

AT(ldadr):加载地址(load addr),省略时:加载地址 = 链接地址
按照这个理论,上面这个链接脚本生成的可执行文件加载地址为:0x30000000

烧录工具会读取ELF文件的程序头(Program Headers)部分,找到加载地址,指示烧录工具对应的段需要被烧录到什么地址中。2440设置为nor启动时,CPU的0地址就是nor flash的0地址,而加载地址:0x30000000这是外部sdram。

疑问:烧录器看到加载地址是0x30000000,不就会将程序烧写到外部sdram了吗(也烧录不了,因为没有初始化),而我需要烧写到nor或者nand中!

解答:我的理解是,韦东山做的那个2440的oflash烧录工具应该有他自己的一套烧录机制,即:他的烧录工具不是根据elf中的加载地址来烧录。比如烧录时要通过oflash选择烧录到nor还是nand,烧录到nand的哪个bank,以这样的方式设置了加载地址,烧录器再将代码烧录到对应地址去。

正规的链接脚本写法是rtthread的stm32 bsp:https://github.com/qingOOyuan/RTT_STM32_HAL_ZET6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
	/* 重定位text, rodata, data段整个程序 */

/* nor和nand的加载地址都是0,注意:是flash自身视角的0地址,而不是CPU视角的0地址,
(虽然对于2440而言,nor启动时:CPU视角的0地址 = nor flash视角的0地址)。 */
mov r1, #0

/* 第1条指令运行时的地址 */
ldr r2, =_start

/* bss段的起始地址 */
ldr r3, =__bss_start

/* 此处只写了nor的重定位,nand不能像nor一样支持RAM般直接读取,需要操作nand控制器,
* 从nand flash的0地址处(加载地址),开始读取内容,重定位到链接地址(运行时地址)。 */
cpy:
ldr r4, [r1]
str r4, [r2]
add r1, r1, #4
add r2, r2, #4
cmp r2, r3
ble cpy

/* 清除BSS段 */
ldr r1, =__bss_start
ldr r2, =_end
mov r3, #0
clean:
str r3, [r1]
add r1, r1, #4
cmp r1, r2
ble clean

//bl main /* 使用BL命令相对跳转, 程序仍然在NOR/sram执行 */
ldr pc, =main /* 绝对跳转, 跳到SDRAM */

最终整个代码的流程如下图所示:

一体式链接脚本重定位图示

3. 位置无关码

为什么我们的链接地址再SDRAM中,在Nor Flash中的0地址还能够运行呢?这就要引入位置无关码,当我们访问全局变量或者函数指针等数据的时候访问的是其链接地址,但我们用bl等指令的时候,使用的是PC值+offset的方式访问的。也就与他的链接地址无关,所以我们重定位以及重定位之前的代码都需要用位置无关码来编写,查看反汇编文件:

位置无关码反汇编

尝试更改链接地址,bl处的机器码eb000106都是一样的,机器码一样,执行的内容肯定都是一样的。因此这里并不是跳转到显示的地址(链接地址),而是跳转到: pc + offset,offset由链接器决定。
假设程序从0x30000000执行,当前指令地址:0x3000005c ,那么就是跳到0x30000478;
假设程序从0地址运行,当前指令地址:0x5c,那么就是跳到:0x00000478;
跳转到某个地址并不是由bl指令决定,是由当前pc值决定。反汇编显示该值只是为了方便读代码;

怎么写位置无关码

  1. 使用相对跳转命令b或bl。
  2. 重定位之前不可以使用绝对地址,不可以访问全局变量、静态变量,也不可以访问有初始值的数组(数组的初始值放在.rodata里,使用绝对地址来访问)。
  3. 重定位之后,使用ldr pc, =main,跳转到链接地址去运行。
  4. 前面的一体式重定位程序如果最后还是使用bl相对跳转到main,程序仍在Nor/SRAM执行,要想main函数在SDRAM执行,需要使用绝对地址跳转。

写位置无关码,其实就是不使用绝对地址,判断有没有使用绝对地址,最根本的办法就是查看反汇编。

4. C语言实现重定位

C函数实现和汇编实现最主要的区别就是链接脚本中变量值的获取,假设lds中有一个abc变量:

  1. 汇编中访问链接脚本变量的值,直接使用abc即可;
  2. C函数中首先声明该变量为外部变量,extern abc;
  3. 使用时要取地址,int *p = &abc,此时p的值即为lds中abc的值;

为什么C函数要加&?

  1. C函数定义一个int g_k,程序中必然有4字节来存放这个变量g_k;
  2. 如果lds中有非常多的变量,而我们的C程序只用到其中几个,所以完全没有必要存储lds里的变量,也就是C程序是不保存lds中的变量的;
  3. 在编译程序的时候有一个symbol符号表,保存g_k的地址和lds中变量的值;

符号表

如何使用symbol符号表:

  1. 对于常规的C程序中的变量地址,使用&g_k获得;
  2. 为了保持代码一致,对于lds中的变量,如a2的值,也使用&a2获得;

结论:

  1. C程序不保存lds文件中的变量,所以lds再大也无所谓;
  2. C程序使用symbol中lds变量的值,使用时加上&,并且申明为外部变量,类型无所谓;