627|1

40

帖子

2

TA的资源

一粒金砂(高级)

《RISC-V 体系结构编程与实践(第2版)》——函数调用、GNU汇编器、链接器与内嵌汇编 [复制链接]

本帖最后由 FuShenxiao 于 2025-2-2 23:17 编辑

函数调用规范与栈

本章介绍了函数调用,其中函数调用的实现依赖于栈。在RISC-V中,栈布局和栈回溯的流程依赖于是否使用栈帧指针FP

不使用FP的函数栈布局的关键点:

1. 所有的函数调用栈都是从高地址向低地址扩展

2. SP永远指向栈顶(栈的最低地址)

3. 如果调用了子函数,函数的返回地址需要保存到栈里,即s_ra位置

4. 栈的大小为16字节的倍数

5. 函数返回时需要先把返回地址从栈中恢复到ra寄存器,然后执行RET指令

使用FP的函数栈布局的关键点:

1. 所有的函数调用栈都会组成一个单链表

2. 每个栈使用两个地址来构成这个链表,这两个地址都是64位宽的,并且他们都位于栈底。s_fp的值指向上一个栈帧的栈底;s_ra保存当前函数的返回地址

3. 函数返回时,RISC-V处理器先把返回地址从栈的s_ra位置处载入当前ra寄存器,后执行RET指令

4. 最末端函数不用保存ra寄存器,因为最末端函数的ra寄存器不会被破坏

IMG_20250202_222334.jpg   IMG_20250202_222318.jpg  

实验4-1:观察栈布局

在BenOS里实现函数调用关系kernel_main()→func1()→func2(),然后用GDB 工具观察栈的变化情况,并画出栈布局

以书本中例4-3解释,该例首先在kernel_main()函数中调用子函数func1(),然后在func1()函数中调用add_c()函数

在boot.S汇编文件中分配栈空间,使SP指向0x8020 3000,然后跳转到C语言的kernel_main()函数

boot.S汇编文件如下

.section ".text.boot"

.globl _start
_start:
	/* 关闭中断 */
	csrw sie, zero

	/* 设置栈, 栈的大小为4KB */
	la sp, stacks_start
	li t0, 4096
	add sp, sp, t0

	/* 跳转到C语言 */
	tail kernel_main

.section .data
.align  12
.global stacks_start
stacks_start:
	.skip 4096

kernel.c文件如下

int add_c(int a, int b)
{
	return a + b;
}

int func1(void)
{
	int a = 1;
	int b = 2;

	return add_c(a, b);
}

void kernel_main(void)
{
	func1();
}

在GDB调试器中,我们首先观察代码运行到kernel_main()函数的反汇编代码

(gdb) disassemble
Dump of assembler code for function kernel_main:
   0x0000000080200160 <+0>:     addi    sp,sp,-16
   0x0000000080200162 <+2>:     sd      ra,8(sp)
=> 0x0000000080200164 <+4>:     jal     ra,0x8020013e <func1>
   0x0000000080200168 <+8>:     nop
   0x000000008020016a <+10>:    ld      ra,8(sp)
   0x000000008020016c <+12>:    addi    sp,sp,16
   0x000000008020016e <+14>:    ret

可以看到首先使用ADDI指令扩展栈空间,SP向低地址扩展16个字节,此时SP指向0x8020 2FF0

接着将kernel_main()函数的返回地址(ra寄存器)存储到SP+8的位置(s_ra)

创建栈之后,使用JAL指令跳转到func1()函数

进入func1()函数,再次进行反汇编得到代码

(gdb) disassemble
Dump of assembler code for function func1:
   0x000000008020013e <+0>:     addi    sp,sp,-32
   0x0000000080200140 <+2>:     sd      ra,24(sp)
=> 0x0000000080200142 <+4>:     li      a5,1
   0x0000000080200144 <+6>:     sw      a5,12(sp)
   0x0000000080200146 <+8>:     li      a5,2
   0x0000000080200148 <+10>:    sw      a5,8(sp)
   0x000000008020014a <+12>:    lw      a4,8(sp)
   0x000000008020014c <+14>:    lw      a5,12(sp)
   0x000000008020014e <+16>:    mv      a1,a4
   0x0000000080200150 <+18>:    mv      a0,a5
   0x0000000080200152 <+20>:    jal     ra,0x80200124 <add_c>
   0x0000000080200156 <+24>:    mv      a5,a0
   0x0000000080200158 <+26>:    mv      a0,a5
   0x000000008020015a <+28>:    ld      ra,24(sp)
   0x000000008020015c <+30>:    addi    sp,sp,32
   0x000000008020015e <+32>:    ret

可以看到当跳转到func1()函数时,SP首先向低地址扩展32个字节,为func1()创建一个栈帧,此时SP指向0x8020 2FD0

接着将func1()函数的返回地址存储到SP+24的位置(s_ra)

由于func1()有两个临时变量a和b,观察反汇编代码箭头下方的代码,可以看到将二者分别存储到SP+12和SP+8的位置

最后跳转到add_c()函数,得到反汇编代码

(gdb) disassemble
Dump of assembler code for function add_c:
   0x0000000080200124 <+0>:     addi    sp,sp,-16
   0x0000000080200126 <+2>:     mv      a5,a0
   0x0000000080200128 <+4>:     mv      a4,a1
   0x000000008020012a <+6>:     sw      a5,12(sp)
   0x000000008020012c <+8>:     mv      a5,a4
   0x000000008020012e <+10>:    sw      a5,8(sp)
=> 0x0000000080200130 <+12>:    lw      a4,12(sp)
   0x0000000080200132 <+14>:    lw      a5,8(sp)
   0x0000000080200134 <+16>:    addw    a5,a5,a4
   0x0000000080200136 <+18>:    sext.w  a5,a5
   0x0000000080200138 <+20>:    mv      a0,a5
   0x000000008020013a <+22>:    addi    sp,sp,16
   0x000000008020013c <+24>:    ret

首先SP向低地址扩展16字节,为add_c()函数创建一个栈帧,此时SP指向0x8020 2FC0

在完成add_c()函数后,调用RET指令返回,首先编译器根据ra寄存器的返回地址,跳转到上一级函数

接着编译器释放空间栈,即SP指向func1()函数的栈顶

此时再观察func1()的反汇编代码,可以看到箭头已经跳转到func1()函数的栈顶

=> 0x0000000080200158 <+26>:    mv      a0,a5
   0x000000008020015a <+28>:    ld      ra,24(sp)
   0x000000008020015c <+30>:    addi    sp,sp,32
   0x000000008020015e <+32>:    ret

或者也可以使用FP的栈布局

只需将Makefile中的编译选项从-fomit-frame-pointer改成-fno-omit-frame-pointer

对kernel_main()反汇编

(gdb) disassemble
Dump of assembler code for function kernel_main:
   0x00000000802001a4 <+0>:     addi    sp,sp,-16
   0x00000000802001a6 <+2>:     sd      ra,8(sp)
   0x00000000802001a8 <+4>:     sd      s0,0(sp)
   0x00000000802001aa <+6>:     addi    s0,sp,16
=> 0x00000000802001ac <+8>:     jal     ra,0x80200174 <func1>
   0x00000000802001b0 <+12>:    nop
   0x00000000802001b2 <+14>:    ld      ra,8(sp)
   0x00000000802001b4 <+16>:    ld      s0,0(sp)
   0x00000000802001b6 <+18>:    addi    sp,sp,16
   0x00000000802001b8 <+20>:    ret

相较于不使用FP的栈布局,这里将fp寄存器的值存储到SP的位置(s_fp),并更新fp寄存器的值,其值对应kernel_main()函数的栈底,即0x8020 3000

对func1()反汇编

(gdb) disassemble
Dump of assembler code for function func1:
   0x0000000080200174 <+0>:     addi    sp,sp,-32
   0x0000000080200176 <+2>:     sd      ra,24(sp)
   0x0000000080200178 <+4>:     sd      s0,16(sp)
   0x000000008020017a <+6>:     addi    s0,sp,32
=> 0x000000008020017c <+8>:     li      a5,1
   0x000000008020017e <+10>:    sw      a5,-20(s0)
   0x0000000080200182 <+14>:    li      a5,2
   0x0000000080200184 <+16>:    sw      a5,-24(s0)
   0x0000000080200188 <+20>:    lw      a4,-24(s0)
   0x000000008020018c <+24>:    lw      a5,-20(s0)
   0x0000000080200190 <+28>:    mv      a1,a4
   0x0000000080200192 <+30>:    mv      a0,a5
   0x0000000080200194 <+32>:    jal     ra,0x8020014c <add_c>
   0x0000000080200198 <+36>:    mv      a5,a0
   0x000000008020019a <+38>:    mv      a0,a5
   0x000000008020019c <+40>:    ld      ra,24(sp)
   0x000000008020019e <+42>:    ld      s0,16(sp)
   0x00000000802001a0 <+44>:    addi    sp,sp,32
   0x00000000802001a2 <+46>:    ret

可以看到这里将FP指向的值存储到SP+16位置(s_fp),此时s_fp上存储的值为上一个栈帧的底部

接着更新FP 指向的值为func1()函数的栈底

对于add_c()函数

(gdb) disassemble
Dump of assembler code for function add_c:
   0x000000008020014c <+0>:     addi    sp,sp,-32
   0x000000008020014e <+2>:     sd      s0,24(sp)
   0x0000000080200150 <+4>:     addi    s0,sp,32
   0x0000000080200152 <+6>:     mv      a5,a0
   0x0000000080200154 <+8>:     mv      a4,a1
   0x0000000080200156 <+10>:    sw      a5,-20(s0)
   0x000000008020015a <+14>:    mv      a5,a4
   0x000000008020015c <+16>:    sw      a5,-24(s0)
=> 0x0000000080200160 <+20>:    lw      a4,-20(s0)
   0x0000000080200164 <+24>:    lw      a5,-24(s0)
   0x0000000080200168 <+28>:    addw    a5,a5,a4
   0x000000008020016a <+30>:    sext.w  a5,a5
   0x000000008020016c <+32>:    mv      a0,a5
   0x000000008020016e <+34>:    ld      s0,24(sp)
   0x0000000080200170 <+36>:    addi    sp,sp,32
   0x0000000080200172 <+38>:    ret

这里的操作方式就和上文类似了

实验4-2:观察栈回溯

在BenOS里实现函数调用关系kernel_main()到func1()到func2(),并实现栈回溯功能,输出栈的地址范围和大小,并通过GDB工具观察栈是如何回溯的

在stacktrace.c中实现栈回溯

#include "printk.h"
#include "type.h"

// 定义一个结构体描述栈结构中的s_fp和s_ra
struct stackframe {
	unsigned long s_fp;
	unsigned long s_ra;
};

// 检查地址是否在代码段中
extern char _text[], _etext[];
static int kernel_text(unsigned long addr)
{
	if (addr >= (unsigned long)_text &&
	    addr < (unsigned long)_etext)
		return 1;

	return 0;
}

static void walk_stackframe(void )
{
	unsigned long sp, fp, pc;
	struct stackframe *frame;
	unsigned long low;

	const register unsigned long current_sp __asm__ ("sp");//通过内嵌汇编方式直接获取SP的值
	sp = current_sp;
	pc = (unsigned long)walk_stackframe;
	fp = (unsigned long)__builtin_frame_address(0);

	while (1) {
		if (!kernel_text(pc))
			break;

		/* 检查fp是否有效 */
		low = sp + sizeof(struct stackframe);
		if ((fp < low || fp & 0xf))
			break;

		/*
		 * fp 指向上一级函数的栈底
		 * 减去16个字节,正好是struct stackframe
		 */
		frame = (struct stackframe *)(fp - 16);
		sp = fp;
		fp = frame->s_fp;

		pc = frame->s_ra - 4;

		if (kernel_text(pc))
			printk("[0x%016lx - 0x%016lx]  pc 0x%016lx\n", sp, fp, pc);
	}
}

void dump_stack(void)
{
	printk("Call Frame:\n");
	walk_stackframe();
}

得到每个栈的范围以及调用该函数时的PC值

Call Frame:
[0x0000000080202fa0 - 0x0000000080202fb0]  pc 0x0000000080200f32
[0x0000000080202fb0 - 0x0000000080202fd0]  pc 0x000000008020114a
[0x0000000080202fd0 - 0x0000000080202ff0]  pc 0x0000000080201184
[0x0000000080202ff0 - 0x0000000080203000]  pc 0x00000000802011a4

思考题

4-1. 请阐述RISC-V的函数调用规范

1. 函数的前8个参数使用a0~a7寄存器来传递。

2. 如果函数参数大于8个,前8个参数使用寄存器来传递,后面的参数使用栈来传递。

3. 如果传递的参数小于寄存器宽度(64位),那么先按符号扩展到32位,再按符号扩展到64位。如果传递的参数为寄存器宽度的2倍(128位),那么将使用一对寄存器来传递该参数。

4. 函数的返回参数保存在a0和a1寄存器中。

5. 函数的返回地址保存在ra寄存器中。

6. 如果子函数里使用s0~s11寄存器,那么子函数在使用前需要把这些寄存器的内容保存到栈中,使用完之后再从栈中将内容恢复到这些寄存器里。

7. 栈向下增长(向较低的地址),sp寄存器在进入函数时要对齐到16字节的边界上。传递给栈的第一个参数位于sp寄存器的偏移量0处,后续的参数存储则在相应的较高地址处。

8. 如果GCC使用-fno-omit-frame-pointer编译选项,那么编译器将使用s0寄存器作为栈帧指针。

4-2. 在函数调用过程中,如果函数传递的参数大于8个,该如何传递参数?

前8个参数使用寄存器来传递,后面的参数使用栈来传递。

4-5. 请阐述在RISC-V体系结构下如何通过FP回溯整个栈

1. 获取当前帧指针:从当前函数的FP寄存器获取当前栈帧的指针。

2. 获取返回地址:从当前栈帧中获取返回地址(通常存储在ra寄存器的栈位置)。

3. 更新帧指针:通过当前栈帧中的帧指针值(s0的值),找到上一个栈帧的指针。

4. 重复上述过程:不断更新帧指针,直到到达栈底(通常可以通过比较帧指针与栈顶或栈底地址来判断)

GNU汇编器

汇编器用于将汇编代码翻译为机器目标代码,在编译过程中的位置如下图所示

IMG_20250202_230127.jpg  

本章介绍了相关汇编语法、常用的伪指令以及RISC-V特有的命令行选项和伪指令,熟悉了汇编的使用以及汇编和C语言的交互。

实验5-4:使用汇编伪指令实现一张表

使用汇编的数据定义伪指令,实现表的定义

在Linux内核中,.QUAD用于在代码段或数据段中分配并初始化一个 64 位的存储空间。它通常用于定义 64 位的常量或变量。.ASCIZ用于定义以空字符结尾的字符串。

.align 3
.global func_addr
func_addr:
	.quad  0x800800
	.quad  0x800860
	.quad  0x800880

.align 3
.global func_string
func_string:
	.asciz "func_a"
	.asciz "func_b"
	.asciz "func_c"

在主函数中打印相应地址的函数名

print_func_name(0x800880);
print_func_name(0x800860);
print_func_name(0x800800);

输出函数名

func_c
func_b
func_a

实验5-5:汇编宏的使用

在汇编文件中通过一个宏实现如下两个汇编函数

long add_1(a, b)
long add_2(a, b)

该宏的定义如下

.macro op_func, label, a, b
	// 这里调用add_1()或者add_2()函数,label等于1或者2
.endm

 编写对应的宏

.align 3
.macro op_func op, a, b
        mv a0, \a
	mv a1, \b
	call op_\()\op
.endm

.align 3
.global op_1
op_1:
	add a0, a0, a1
	ret

.global op_2
op_2:
	sub a0, a0, a1
	ret

.global macro_test_1
macro_test_1:
	addi sp, sp, -16
	sd ra, 8(sp)
	
	mv t0, a0
	mv t1, a1

	op_func 1, t0, t1

	ld ra, 8(sp)
	addi sp, sp, 16
	ret

.global macro_test_2
macro_test_2:
	addi sp, sp, -16
	sd ra, 8(sp)
	
	mv t0, a0
	mv t1, a1

	op_func 2, t0, t1

	ld ra, 8(sp)
	addi sp, sp, 16
	ret

在主函数中调用这两个宏

val1 = macro_test_1(5, 5);
if (val1 == 10)
	uart_send_string("lab5-5: macro_test_1 ok\n");

val2 = macro_test_2(5, 5);
if (val2 == 0)
	uart_send_string("lab5-5: macro_test_2 ok\n");

得到输出结果

lab5-5: macro_test_1 ok
lab5-5: macro_test_2 ok

思考题:

5-1. 什么是汇编器?

汇编器是将汇编代码翻译为及其目标代码的程序。

5-2. 如何给汇编代码添加注释?

以“//”或“#”开始,其后同行的内容为注释。

“/* */”用于添加跨行注释。

5-3. 什么是符号?

用于标记程序或数据的位置。

5-4. 什么是伪指令?

伪指令是对汇编器发出的命令,它在源程序汇编期间由汇编器处理。伪指令是由汇编器预处理的指令,可以分解为几条指令的集合。

5-5. 在RISC-V汇编语言中,".align 3"表示什么意思?

按照8字节对齐。

5-6. 下面这条使用了伪指令的语句表示什么意思?

.section ".my.text","awx"

接下来的汇编会链接到".my.text"段

这个段具有“可分配属性”“可写属性”“可执行属性”

5-7. 在汇编宏里,如何使用参数?

使用.MACRO和.ENDM伪指令可以用来组成一个宏

.MACRO伪指令后依次是宏名称与宏的参数

5-8. 下面是my_entry宏的定义

.macro my_entry, rv, label
		j	rv\()\rv\()_\label
.endm

下面的语句调用my_entry宏,请解释该宏是如何展开的

my_entry	1, irq

第一个"rv"表示rv字符,第一个"\()"在汇编宏视线中可以用来表示宏参数的结束字符,第二个"\rv"表示宏的参数rv,第二个"\()"用来表示结束字符,最后的"\label"表示宏的参数label

宏展开后,上述的J指令变成j rv1_irq

5-9. 请阐述.SECTION和.PREVIOUS伪指令的作用

两条伪指令通常配对使用,用来把一段汇编代码链接到特定的段。.SECTION伪指令表示开始一个新的段,.PREVIOUS伪指令表示恢复到.SECTION定义之前的段,以那个段作为当前段。

链接器与链接脚本

链接器和链接脚本常用于代码编译过程,这一章中,链接脚本的设计和重定位让程序的加载地址、运行地址、链接地址能根据实际进行配置。链接器的松弛优化可以减少指令数量来提高代码效率。

实验6-1:分析链接脚本

分析以下链接脚本中每条语句的含义

SECTIONS
{
	/*
        * 设置benos的加载入口地址为0x80200000
        *
        * 这里“.”表示location counter,当前位置
        */
	. = 0x80200000,

	/*
        * 这里是第一个段text.boot,起始地址就是0x80200000
	* 这个段存放了benos的第一条指令
        */

	.text.boot : { *(.text.boot) }

	/*
        * text代码段
        */
	.text : { *(.text) }

	/*
        * 只读数据段
        */
	.rodata : { *(.rodata) }

	/*
        * 数据段
        */
	.data : { *(.data) }

	/*
        * bss段
        *
        * ALIGN(8)表示8个字节对齐
        * bss_begin的起始地址以8字节对齐
        */
	. = ALIGN(0x8);
	bss_begin = .;
	.bss : { *(.bss*) } 
	bss_end = .;
}

实验6-2:输出内存布局

(1) 在C语言中输出BenOS镜像文件的内存布局

使用如下代码显示BenOS镜像文件的内存布局

static void print_mem(void)
{
	printk("BenOS image layout:\n");
	printk("  .text.boot: 0x%08lx - 0x%08lx (%6ld B)\n",
			(unsigned long)_text_boot, (unsigned long)_etext_boot,
			(unsigned long)(_etext_boot - _text_boot));
	printk("       .text: 0x%08lx - 0x%08lx (%6ld B)\n",
			(unsigned long)_text, (unsigned long)_etext,
			(unsigned long)(_etext - _text));
	printk("     .rodata: 0x%08lx - 0x%08lx (%6ld B)\n",
			(unsigned long)_rodata, (unsigned long)_erodata,
			(unsigned long)(_erodata - _rodata));
	printk("       .data: 0x%08lx - 0x%08lx (%6ld B)\n",
			(unsigned long)_data, (unsigned long)_edata,
			(unsigned long)(_edata - _data));
	printk("        .bss: 0x%08lx - 0x%08lx (%6ld B)\n",
			(unsigned long)_bss, (unsigned long)_ebss,
			(unsigned long)(_ebss - _bss));
}

在终端运行程序后看到如下显示

BenOS image layout:
  .text.boot: 0x80200000 - 0x8020003c (    60 B)
       .text: 0x8020003c - 0x802019a4 (  6504 B)
     .rodata: 0x802019a4 - 0x80201c98 (   756 B)
       .data: 0x80201c98 - 0x80203000 (  4968 B)
        .bss: 0x80203010 - 0x80223420 (132112 B)

在benos.map中观察内存布局,可以看到与终端显示的运行地址和benos.map中的链接地址有些存在一些差异

内存配置

名称           来源             长度             属性
*default*        0x0000000000000000 0xffffffffffffffff

链结器命令稿和内存映射

                0x0000000080200000                . = 0x80200000
                0x0000000080200000                _text_boot = .

.text.boot      0x0000000080200000       0x3c
...
.text           0x0000000080200040     0x1964
...
.rodata         0x00000000802019a8      0x2f0
...
.data           0x0000000080202000     0x1000
...
.bss            0x0000000080203010    0x20410

(2) 修改链接脚本,把.data段的VMA修改成0x8020 9000,然后输出内存布局观察是否有变化

只需在linker.ld文件中修改数据段的起始值即可

/*
    * 数据段
    */
    . = 0x80209000,
_data = .;
.data : { *(.data) }
_edata = .;

再次观察输出结果,可以看到.data的段的VMA变化会导致其之后的.bss段的VMA变化,且.data段的内存大小似乎变小了,这个问题与上面一个小问出现的问题似乎与数据对齐有关

BenOS image layout:
  .text.boot: 0x80200000 - 0x8020003c (    60 B)
       .text: 0x8020003c - 0x802019a4 (  6504 B)
     .rodata: 0x802019a4 - 0x80201c98 (   756 B)
       .data: 0x80209000 - 0x8020a000 (  4096 B)
        .bss: 0x8020a010 - 0x8022a420 (132112 B)

(3) 编写C语言函数将.bss段的内容清零

static void clean_bss(void)
{
	unsigned long start = (unsigned long)_bss;
	unsigned long end = (unsigned long)_ebss;
	unsigned size = end - start;

	memset((void *)start, 0, size);
}

实验6-3:加载地址不等于运行地址

代码段存储在ROM中,运行地址在RAM里面,其他段的加载地址和运行地址都在RAM中。请修改BenOS的链接脚本以及汇编源代码,让BenOS可以正确运行

将代码段从加载地址(LMA)拷贝到运行地址(VMA)

.globl _start
_start:
       /*
          假设代码段存储在ROM中(LMA),而ROM的地址在0x80300000

          我们需要把代码段 从加载地址(LMA)拷贝到 运行地址(VMA)
        */
       la t0, TEXT_ROM
       la t1, _text
       la t2, _etext
.L0:
	ld  a5, (t0)
	sd a5, (t1)
	addi t1, t1, 8
	addi t0, t0, 8
	bltu t1, t2, .L0

实验6-4:设置链接地址

修改BenOS的链接脚本,让其链接地址为0xFFFF 0000 0000 0000;查看benos.map文件,指出运行地址和链接地址的区别

在linker.ld的开头设置BenOS的链接地址为0xFFFF 0000 0000 0000

* 设置benos链接地址0xffff000000000000
*/
. = 0xffff000000000000,

终端显示运行地址

BenOS image layout:
  .text.boot: 0x80200000 - 0x8020003c (    60 B)
       .text: 0x80200040 - 0x802019a8 (  6504 B)
     .rodata: 0x802019a8 - 0x80201c98 (   752 B)
       .data: 0x80201c98 - 0x80203000 (  4968 B)
        .bss: 0x80203010 - 0x80223420 (132112 B)

benos.map中显示链接地址

内存配置

名称           来源             长度             属性
*default*        0x0000000000000000 0xffffffffffffffff

链结器命令稿和内存映射

                0xffff000000000000                . = 0xffff000000000000
                0xffff000000000000                _text_boot = .

.text.boot      0xffff000000000000       0x3c
...
.text           0xffff000000000040     0x1964
...
.rodata         0xffff0000000019a8      0x2f0
...
.data           0xffff000000002000     0x1000
...
.bss            0xffff000000003010    0x20410

可见BenOS的MMU能把内存映射到内存空间

实验6-5:链接器松弛优化1

在BenOS中,构造一个无法使用函数跳转优化的场景

int foo(void)
{
}

int main(void)
{
	foo();
}

实验6-6:链接器松弛优化2

在BenOS中,使能和测试符号地址访问优化

#include "uart.h"
#include "type.h"
#include "memset.h"
#include "printk.h"

long a = 5;
long b = 10;

long data(void) {
  return a | b;
}

void kernel_main(void)
{
	uart_init();
	init_printk_done();

	data();

	while(1)
		;

}

实验6-7:分析Linux 5.15内核的链接脚本

链接脚本位于arch/riscv/kernel/vmlinux.lds.S

/* SPDX-License-Identifier: GPL-2.0-only */
/*
 * Copyright (C) 2012 Regents of the University of California
 * Copyright (C) 2017 SiFive
 */
// 定义只读异常表的对齐值为 16 字节
#define RO_EXCEPTION_TABLE_ALIGN	16

#ifdef CONFIG_XIP_KERNEL
#include "vmlinux-xip.lds.S"
#else

#include <asm/pgtable.h>
#define LOAD_OFFSET KERNEL_LINK_ADDR

#include <asm/vmlinux.lds.h>
#include <asm/page.h>
#include <asm/cache.h>
#include <asm/thread_info.h>
#include <asm/set_memory.h>
#include "image-vars.h"

#include <linux/sizes.h>
OUTPUT_ARCH(riscv) // 指定目标架构为 RISC-V
ENTRY(_start) // 指定入口点为 _start

jiffies = jiffies_64; // 将 jiffies(系统启动以来的时钟滴答数)指向 jiffies_64

// 定义了 PECOFF(Portable Executable and Common Object File Format)格式的对齐参数
PECOFF_SECTION_ALIGNMENT = 0x1000;
PECOFF_FILE_ALIGNMENT = 0x200;

SECTIONS
{
	/* Beginning of code and text segment */
	// 代码段
	. = LOAD_OFFSET;
	_start = .;
	HEAD_TEXT_SECTION
	. = ALIGN(PAGE_SIZE);

	.text : {
		_text = .;
		_stext = .;
		TEXT_TEXT
		SCHED_TEXT
		CPUIDLE_TEXT
		LOCK_TEXT
		KPROBES_TEXT
		ENTRY_TEXT
		IRQENTRY_TEXT
		SOFTIRQENTRY_TEXT
		*(.fixup)
		_etext = .;
	}
	// 初始化代码段
	. = ALIGN(SECTION_ALIGN);
	__init_begin = .;
	__init_text_begin = .;
	.init.text : AT(ADDR(.init.text) - LOAD_OFFSET) ALIGN(SECTION_ALIGN) { \
		_sinittext = .;						\
		INIT_TEXT						\
		_einittext = .;						\
	}
	// 早期初始化表
	. = ALIGN(8);
	__soc_early_init_table : {
		__soc_early_init_table_start = .;
		KEEP(*(__soc_early_init_table))
		__soc_early_init_table_end = .;
	}
	__soc_builtin_dtb_table : {
		__soc_builtin_dtb_table_start = .;
		KEEP(*(__soc_builtin_dtb_table))
		__soc_builtin_dtb_table_end = .;
	}
	/* we have to discard exit text and such at runtime, not link time */
	.exit.text :
	{
		EXIT_TEXT
	}

	__init_text_end = .;
	. = ALIGN(SECTION_ALIGN);
#ifdef CONFIG_EFI
	. = ALIGN(PECOFF_SECTION_ALIGNMENT);
	__pecoff_text_end = .;
#endif
	/* Start of init data section */
	__init_data_begin = .;
	INIT_DATA_SECTION(16)
	// 数据段
	.exit.data :
	{
		EXIT_DATA
	}
	PERCPU_SECTION(L1_CACHE_BYTES)

	.rel.dyn : {
		*(.rel.dyn*)
	}

	__init_data_end = .;

	. = ALIGN(8);
	.alternative : {
		__alt_start = .;
		*(.alternative)
		__alt_end = .;
	}
	__init_end = .;

	/* Start of data section */
	// 只读数据段
	_sdata = .;
	RO_DATA(SECTION_ALIGN)
	.srodata : {
		*(.srodata*)
	}
	// 可读写数据段
	. = ALIGN(SECTION_ALIGN);
	_data = .;

	RW_DATA(L1_CACHE_BYTES, PAGE_SIZE, THREAD_ALIGN)
	.sdata : {
		__global_pointer$ = . + 0x800;
		*(.sdata*)
	}

#ifdef CONFIG_EFI
	.pecoff_edata_padding : { BYTE(0); . = ALIGN(PECOFF_FILE_ALIGNMENT); }
	__pecoff_data_raw_size = ABSOLUTE(. - __pecoff_text_end);
#endif

	/* End of data section */
	_edata = .;
	// BSS段
	BSS_SECTION(PAGE_SIZE, PAGE_SIZE, 0)

#ifdef CONFIG_EFI
	. = ALIGN(PECOFF_SECTION_ALIGNMENT);
	__pecoff_data_virt_size = ABSOLUTE(. - __pecoff_text_end);
#endif
	_end = .;
	// 调试信息
	STABS_DEBUG
	DWARF_DEBUG
	ELF_DETAILS

	DISCARDS
}
#endif /* CONFIG_XIP_KERNEL */

思考题:

6-1. 什么是链接器?为什么链接器简称LD?

链接器是用来完成将目标文件(也包括用到的标准库函数目标文件)的代码段、数据段以及符号表等内容收集起来的工具。

早期操作系统的加载器(LD)做了所有的工作,后来LD成了链接器的代名词。

6-2. 链接脚本中的输入段和输出段有什么区别?

在链接脚本中,把输入文件中的段称为输入段,把输出文件中的段称为输出段。

输入段高速链接器如何将输入文件映射到内存布局,输出段高速链接器最终的可执行文件在内存中是如何布局的。

6-3. 什么是加载地址和虚拟地址?

加载地址是加载时段所在的地址

虚拟地址是运行时段所在的地址,也称为运行地址

6-4. 在链接脚本中定义如下符号

foo = 0x100

foo和0x100分别代表什么?

foo为链接器在符号表中创建的一个名为foo的符号

0x100表示内存地址的位置

6-5. 在C语言中,如何引用链接脚本定义的符号?

C语言中可以定义变量并用段为其赋值,或使用extern、memcpy的方法引用链接脚本定义的符号

6-6. 为了构建一个基于ROM的镜像文件,常常将输出段的虚拟地址和加载地址设置得不一致,在一个输入段中,如何表示一个段的虚拟地址和加载地址?

在链接脚本中,可以通过AT关键字来指定一个段的加载地址,同时使用ADDR函数来设置虚拟地址。

6-7. 什么是链接地址?

链接地址是在编译、链接时指定的地址,编程人员设想的程序将来要运行的地址。

6-8. 当一个程序的代码段和链接地址的加载地址不一致时,应该怎么做才能让程序正确运行?

需要将代码段从加载地址复制到链接地址。

6-9. 什么是与位置无关的指令?什么是与位置有关的指令?请举例说明RISC-V指令集中哪些指令是与位置无关的指令,哪些是与位置有关的指令。

与位置无关的指令是指那些在运行时不需要根据其加载地址进行调整的指令。这些指令的执行不依赖于其在内存中的具体位置。

与位置有关的指令是指那些在运行时需要根据其加载地址进行调整的指令。这些指令的执行依赖于其在内存中的具体位置。如果这些指令被加载到不同的内存地址,它们可能无法正确执行。

与位置无关的指令有ADD、SUB、AND、OR、XOR、JALR、LW、SW等指令

与位置有关的指令有LUI、JAL等指令

6-10. 什么是加载重定位和链接重定位?

加载重定位是指在程序被加载到内存时,根据程序的实际加载地址对代码和数据进行调整的过程。

链接重定位是指在程序链接阶段,将多个目标文件中的代码和数据段合并,并根据链接地址对符号引用进行调整的过程。

6-11. OpenSBI和Linux内核是如何实现重定位的?

如下图所示

IMG_20250202_222231.jpg  

 

IMG_20250202_222245.jpg  

6-12. 在Linux内核中,打开MMU之后如何实现重定位?

初始化页表→配置系统控制寄存器→开启MMU→重定位内核→处理重定位表→清除缓存→继续初始化

6-13. 什么是链接器松弛优化?

链接器松弛优化是一种在链接阶段进行的优化技术,目的是通过减少指令数量来提高代码的效率。这种优化通常发生在链接器处理目标文件(.o文件)时,通过将某些指令序列替换为更紧凑的指令来实现。

内嵌汇编代码

内嵌汇编代码在C语言中嵌入汇编代码,从而对特别重要和时间敏感的代码进行优化,同时在C语言中访问某些特殊指令来实现特殊功能。

本章内容以实验和思考题进行分析。

实验7-1:实现简单的memcpy()函数

使用内嵌汇编代码实现简单的memcpy()函数:从0x8020 000地址复制32字节到0x8021 000地址处

编写内嵌汇编代码

static void my_memcpy_asm_test1(unsigned long src, unsigned long dst,
		unsigned long size)
{
	unsigned long tmp = 0;
	unsigned long end = src + size;

	asm volatile (
			"1: ld %1, (%2)\n"
			"sd %1, (%0)\n"
			"addi %0, %0, 8\n"
			"addi %2, %2, 8\n"
			"blt %2, %3, 1b"
			: "+r" (dst), "+r" (tmp), "+r" (src)
			: "r" (end)
			: "memory");
}

void inline_asm_test(void)
{
	my_memcpy_asm_test1(0x80200000, 0x80210000, 32);
}

在GDB调试工具中查看

(gdb) x 0x8020000
0x8020000:      0x00000000
(gdb) x 0x8021000
0x8021000:      0x00000000

实验7-2:使用汇编符号编写内嵌汇编代码

在实验7-1的基础上尝试使用汇编符号名编写内嵌代码的编写

使用汇编符号名的方式来编写内嵌汇编

static void my_memcpy_asm_test2(unsigned long src, unsigned long dst,
		unsigned long size)
{
	unsigned long tmp = 0;
	unsigned long end = src + size;

	asm volatile (
			"1: ld %[tmp], (%[src])\n"
			"sd %[tmp], (%[dst])\n"
			"addi %[dst], %[dst], 8\n"
			"addi %[src], %[src], 8\n"
			"blt %[src], %[end], 1b"
			: [dst] "+r" (dst), [tmp] "+r" (tmp), [src] "+r" (src)
			: [end] "r" (end)
			: "memory");
}

在GDB调试工具中查看相应地址的数据

(gdb) x 0x8020000
0x8020000:      0x00000000
(gdb) x 0x8021000
0x8021000:      0x00000000

实验7-3:实现内嵌汇编代码完善memset()函数

使用内嵌汇编代码完成__memset_16bytes()函数的编写

编写汇编代码

.global __memset_16bytes_asm
__memset_16bytes_asm:
	li t0, 0
.loop:
	sd a1, (a0)
	sd a1, 8(a0)
	addi t0, t0, 16
	blt t0, a2, .loop

	ret

在主函数中编写代码

memset((void *)0x80210005, 0x55, 40);

查看这个地址的数据

(gdb) x 0x80210005
0x80210005 <log_buf+52213>:     0x55555555

实验7-4:实现内嵌汇编代码与宏的结合

实现一个宏MY_OPS(ops, instruction),使其可以对某个内存地址实现or、xor、and、sub等操作

编写代码

static void my_ops_test(void)
{
	unsigned long p;

	p = 0xf;
	my_asm_and(0x2, &p);
	printk("test and: p=0x%x\n", p);

	p = 0x80;
	my_asm_orr(0x3, &p);
	printk("test orr: p=0x%x\n", p);

	p = 0x3;
	my_asm_add(0x2, &p);
	printk("test add: p=0x%x\n", p);
}

得到结果

test and: p=0x2
test orr: p=0x83
test add: p=0x5

实验7-5:实现读和写系统寄存器的宏

实现read_csr(csr)宏以及write_csr(val, csr)宏,读取RISC-V中的系统寄存器

代码实现

static void test_sysregs(void)
{
	unsigned long val;

	val = read_csr(sstatus);

	printk("sstatus =0x%x\n", val);
}

得到RISC-V中的系统寄存器

sstatus =0x0

实验7-6:基于goto模板的内嵌汇编代码

使用goto模板实现一个内嵌汇编函数,判断函数的参数是否为1。如果为1,则跳转到label,并且输出参数的值;否则,直接返回

int test_asm_goto(int a)

代码实现

static int test_asm_goto(int a)
{
	asm goto (
			"addi %0, %0, -1\n"
			"beqz %0, %l[label]\n"
			:
			: "r" (a)
			: "memory"
			: label);

	return 0;

label:
	printk("%s: a = %d\n", __func__, a);
	return 1;
}

同时设置相关输出

int a = 1;
if (test_asm_goto(a) == 1)
	printk("asm_goto: return 1\n");

int b = 0;
if (test_asm_goto(b) == 0)
	printk("asm_goto: b is not 1\n");

得到终端输出

test_asm_goto: a = 1
asm_goto: return 1
asm_goto: b is not 1

思考题:

7-1. 在内嵌汇编代码中,修饰词"volatile""inline""goto"的作用分别是什么?

volatile:用于关闭GCC优化

inline:用于内联,GCC会把汇编代码编译成尽可能短的代码

goto:用于从内嵌汇编代码跳转到C语言的标签处

7-2. 在内嵌汇编代码的输出部分里,"="和"+"分别代表什么意思?

"=":被修饰的操作数只具有可写属性

"+":被修饰的操作数具有可读、可写属性

7-3. 在内嵌汇编代码中,如何表示输出部分和输入部分的参数?

使用内嵌汇编代码修饰符、汇编符号名字等

7-4. 内嵌汇编代码与C语言宏结合时,"#"与"##"分别代表什么意思?

在宏的参数前面添加"#",预处理器会把这个参数转换成一个字符串

"##"用于连接参数和另一个标识符,形成新的标识符

最新回复

函数返回时,RISC-V处理器先把返回地址从栈的s_ra位置处载入当前ra寄存器,后执行RET指令, 是个关键点   详情 回复 发表于 2025-2-3 10:07

回复
举报

6875

帖子

0

TA的资源

五彩晶圆(高级)

函数返回时,RISC-V处理器先把返回地址从栈的s_ra位置处载入当前ra寄存器,后执行RET指令,

是个关键点


回复
您需要登录后才可以回帖 登录 | 注册

随便看看
查找数据手册?

EEWorld Datasheet 技术支持

相关文章 更多>>
关闭
站长推荐上一条 1/10 下一条

 
EEWorld订阅号

 
EEWorld服务号

 
汽车开发圈

About Us 关于我们 客户服务 联系方式 器件索引 网站地图 最新更新 手机版

站点相关: 国产芯 安防电子 汽车电子 手机便携 工业控制 家用电子 医疗电子 测试测量 网络通信 物联网

北京市海淀区中关村大街18号B座15层1530室 电话:(010)82350740 邮编:100190

电子工程世界版权所有 京B2-20211791 京ICP备10001474号-1 电信业务审批[2006]字第258号函 京公网安备 11010802033920号 Copyright © 2005-2025 EEWORLD.com.cn, Inc. All rights reserved
快速回复 返回顶部 返回列表