AbydOS开发日记 (1) - 基础运行环境构建

3/9/2024 OSC++newliblibstdc++

# 基础环境是什么?

上回说到,通过一点手段,我们成功启用了 C++ 的支持。但是,这样的支持并不完善,我们的内核到目前为止,还没有一个 C 运行库(libc),更别提 C++ 标准库(libc++)了。这就意味着,所有需要用的函数和类都要手写一份,非常折磨人。所以,引入 C 和 C++ 基础库是接下来要做的事。

嵌入式里边,最常用的 libc 是 newlib, 而 C++ 的实现则可以使用 libsupc++。

# 动工

之前的编译里边,为了最小化库的引入,我加入了一个编译参数:-nostdlib。这个参数指示 gcc 不要链接标准库。所以要引入标准库,第一步就是把这个参数去掉,但是又不能引入默认的 crt 初始文件,所以还得加上 -nostartfiles,然后测试编译器的支持。很不幸地,我只写了一句:

#include <stdio.h>
1

然后编译就报错了。一开始,我怀疑是 Include Path 没有设置正确,但是 gcc -v 提示:

Using built-in specs.
COLLECT_GCC=riscv64-unknown-elf-gcc
COLLECT_LTO_WRAPPER=/usr/lib/gcc/riscv64-unknown-elf/9.3.0/lto-wrapper
Target: riscv64-unknown-elf
Configured with: ../configure --build=x86_64-linux-gnu --prefix=/usr 
--includedir='/usr/include' 
--mandir='/usr/share/man' --infodir='/usr/share/info' --sysconfdir=/etc --localstatedir=/var --disable-silent-rules --libdir='/usr/lib/x86_64-linux-gnu' --libexecdir='/usr/lib/x86_64-linux-gnu' --disable-maintainer-mode --disable-dependency-tracking --target=riscv64-unknown-elf --prefix=/usr --infodir=/usr/share/doc/gcc-riscv64-unknown-elf/info --mandir=/usr/share/man --htmldir=/usr/share/doc/gcc-riscv64-unknown-elf/html --pdfdir=/usr/share/doc/gcc-riscv64-unknown-elf/pdf --bindir=/usr/bin --libexecdir=/usr/lib --libdir=/usr/lib --with-pkgversion= --disable-shared --disable-threads --enable-languages=c,c++ --enable-tls --with-newlib --with-native-system-header-dir=/include --disable-libmudflap --disable-libssp --disable-libquadmath --disable-libgomp --disable-nls --with-system-zlib --enable-checking=yes --enable-multilib --with-abi=lp64d --disable-libstdcxx-pch --disable-libstdcxx --disable-fixinc --with-arch=rv64imafdc --with-gnu-as --with-gnu-ld --with-as=/usr/lib/riscv64-unknown-elf/bin/as --with-ld=/usr/lib/riscv64-unknown-elf/bin/ld AR_FOR_TARGET=/usr/lib/riscv64-unknown-elf/bin/ar AS_FOR_TARGET=/usr/lib/riscv64-unknown-elf/bin/as NM_FOR_TARGET=/usr/lib/riscv64-unknown-elf/bin/nm LD_FOR_TARGET=/usr/lib/riscv64-unknown-elf/bin/ld OBJDUMP_FOR_TARGET=/usr/lib/riscv64-unknown-elf/bin/objdump RANLIB_FOR_TARGET=/usr/lib/riscv64-unknown-elf/bin/ranlib READELF_FOR_TARGET=/usr/lib/riscv64-unknown-elf/bin/readelf STRIP_FOR_TARGET=/usr/lib/riscv64-unknown-elf/bin/strip CFLAGS='-g -O2 -fdebug-prefix-map=/build/gcc-riscv64-unknown-elf-3seJsn/gcc-riscv64-unknown-elf-9.3.0=. -fstack-protector-strong' CPPFLAGS='-Wdate-time -D_FORTIFY_SOURCE=2' CXXFLAGS='-g -O2 -fdebug-prefix-map=/build/gcc-riscv64-unknown-elf-3seJsn/gcc-riscv64-unknown-elf-9.3.0=. -fstack-protector-strong' FCFLAGS='-g -O2 -fdebug-prefix-map=/build/gcc-riscv64-unknown-elf-3seJsn/gcc-riscv64-unknown-elf-9.3.0=. -fstack-protector-strong' FFLAGS='-g -O2 -fdebug-prefix-map=/build/gcc-riscv64-unknown-elf-3seJsn/gcc-riscv64-unknown-elf-9.3.0=. -fstack-protector-strong' GCJFLAGS='-g -O2 -fdebug-prefix-map=/build/gcc-riscv64-unknown-elf-3seJsn/gcc-riscv64-unknown-elf-9.3.0=. -fstack-protector-strong' LDFLAGS='-Wl,-Bsymbolic-functions -Wl,-z,relro -Wl,-z,now' OBJCFLAGS='-g -O2 -fdebug-prefix-map=/build/gcc-riscv64-unknown-elf-3seJsn/gcc-riscv64-unknown-elf-9.3.0=. -fstack-protector-strong' OBJCXXFLAGS='-g -O2 -fdebug-prefix-map=/build/gcc-riscv64-unknown-elf-3seJsn/gcc-riscv64-unknown-elf-9.3.0=. -fstack-protector-strong' 'CFLAGS_FOR_TARGET=-Os -mcmodel=medany' 'CXXFLAGS_FOR_TARGET=-Os 
-mcmodel=medany'
Thread model: single
gcc version 9.3.0 () 
1
2
3
4
5
6
7
8
9
10

这表明默认的 include 目录是 /usr/include ,内存模型是 medany 。到 /usr/include 一看,啥也没有,证明这个包没有附带头文件,更别说预编译的库了。(不愧是裸机工具链,真是全 naked 啊)。

# 更换工具链

手动编译运行时太麻烦,最容易的解决方案就是换一个相同架构的、带有预编译库的工具链。很快,我找到了 riscv-collab/riscv-gnu-toolchain (opens new window) 这个官方的工具链仓库,它的 Release 有预编译的。兴致冲冲地下载下来,解压、配置,编译,爆!

...
findfp.c:(.text.global_stdio_init.part.0+0x2): relocation truncated to fit: R_RISCV_HI20 against `stdio_exit_handler'
/opt/riscv/lib/gcc/riscv64-unknown-elf/13.2.0/../../../../riscv64-unknown-elf/lib/libc.a(libc_a-findfp.o): in function `__sfp':
findfp.c:(.text.__sfp+0x0): relocation truncated to fit: R_RISCV_HI20 against symbol `__stdio_exit_handler' defined in .sbss.__stdio_exit_handler section in /opt/riscv/lib/gcc/riscv64-unknown-elf/13.2.0/../../../../riscv64-unknown-elf/lib/libc.a(libc_a-findfp.o)
/opt/riscv/lib/gcc/riscv64-unknown-elf/13.2.0/../../../../riscv64-unknown-elf/lib/libc.a(libc_a-findfp.o): in function `__sinit':
findfp.c:(.text.__sinit+0x6): additional relocation overflows omitted from the output
...
1
2
3
4
5
6
7

这里注意到关键信息是 R_RISCV_HI20 。到这个仓库搜 issue,果然有很多问题都类似,问题指向了工具链附带的库使用的内存模型不对,看配置信息确认是使用了 medlow。关于这两种内存模型的区别,实际上要牵扯到 RISCV 的寻址方式,这里不展开,可以查看这篇博文 (opens new window),这里只要知道 medlow 在 RISCV64 上不能支持我们所设定的内核启动地址 0x80200000。怎么办呢?同样是两种办法,

  1. 更改内核启动地址
  2. 重新编译一份 medany 的工具链

第一种方案并不具有可行性,因为在目前的大多数 RISCV SoC 上,DRAM 的起始地址是 0x80000000,低于这个地址能存储内核的空间,只能是不一定存在的 Flash 设备,或者预先被映射过的内存。出于兼容性考虑,选择第二种方案。由于官方仓库采用了 Github Actions 进行构建,我正好可以利用这一点,fork 一份仓库 (opens new window)然后改一下构建配置 (opens new window)就可以了。

经过两个多小时的睡觉,这套工具链终于编译好了。下载下来测试,编译顺利通过。

# 添加 C printf 的支持

在之前的代码里,我们的输出函数是直接通过 ecall 调用 SBI 的控制台扩展 (DBCN Extension), 没有任何格式化的支持。要使用 printf 输出到控制台,我们要做的就是重写 printf 调用链最底端的输出函数。这时候 newlib 的方便就体现出来了,它的底层总共也只有二十多个 POSIX 的桩函数,只要实现了它们,移植就算完成了。关系到 printf 的桩函数看起来只有一个,那就是:

int _write(int fd, char *buf, int size)
1

这里的 fd 是文件描述符。简单起见,这里我们可以这样实现:

// Hook with libc
int _write(int fd, char *buf, int size)
{
    sbi_ecall(SBI_EXT_DBCN, SBI_EXT_DBCN_CONSOLE_WRITE, size, (unsigned long)buf, 0, 0, 0, 0);
    return size;
}
1
2
3
4
5
6

直接不管 fd。这样之后,添加一个 printf 的调用,编译,不出意料地,报错了:

sbrk.c:(.text._sbrk+0x14): undefined reference to `end'
1

这个 sbrk.c 实际上是堆内存分配相关的。这里爆了符号未定义的错误,原因就是我的链接器种没有定义 .heap 段,自然没有提供堆的结束地址。修改链接脚本,在内核末尾添加如下段:

.heap : {
        __heap_start__ = .;
        end = __heap_start__;
        _end = end;
        __end = end;
        KEEP(*(.heap))
        __heap_end__ = .;
        __HeapLimit = __heap_end__;
    }
1
2
3
4
5
6
7
8
9

注意堆区是向上增长的,所以 end = heap_start。这样再编译就过了,测试也正常输出了。

# C++: 基础支持

有了 printf,很容易想到 C++ 的 std::cout。按照依赖关系,不难想到 cout 的最终调用仍然是 _write(),所以输出的底层就不用管了。尝试添加如下代码:

std::cout << "Hello!\n";
1

编译,又爆了 qwq

[build] system_error.cc:(.text.startup._GLOBAL__sub_I__ZSt20__throw_system_errori+0x2): undefined reference to `__dso_handle'
[build] /opt/riscv/lib/gcc/riscv64-unknown-elf/13.2.0/../../../../riscv64-unknown-elf/bin/ld: system_error.cc:(.text.startup._GLOBAL__sub_I__ZSt20__throw_system_errori+0x26): undefined reference to `__dso_handle'
[build] /opt/riscv/lib/gcc/riscv64-unknown-elf/13.2.0/../../../../riscv64-unknown-elf/bin/ld: AbydOS_KNL: hidden symbol `__dso_handle' isn't defined
[build] /opt/riscv/lib/gcc/riscv64-unknown-elf/13.2.0/../../../../riscv64-unknown-elf/bin/ld: final link failed: bad value
1
2
3
4

这里提示 __dso_handle 符号找不到,搜索之后知道 DSO 就是 Dynamic Shared Object, 和虚函数那套东西有关系。而 std::cout 又恰好是虚基类派生的,出问题很正常。这里找到了 OSDev Wiki (opens new window) 的解释和解决办法,照着文档补上几个函数:

void *__dso_handle(); 
int __cxa_atexit(void (*f)(void *), void *objptr, void *dso);
void __cxa_finalize(void *f);
void __cxa_pure_virtual();
1
2
3
4
  • 实际还要去除 -ffreestanding 参数,让标准库起作用

然后再编译,过了。测试,哎,你怎么卡死了?GDB 一调,好家伙,直接干到 _start_hang 里了,看来是触发了异常然后重置了,由于原子量标记不为0,汇编代码就跳到挂起循环了。想到 std::cout 需要动态分配内存,但是全局new 和 delete 的 operator 没有显式重载,于是加入:

void *operator new(size_t size)
{
    return malloc(size);
}

void *operator new[](size_t size)
{
    return malloc(size);
}

void operator delete(void *p)
{
    free(p);
}

void operator delete[](void *p)
{
    free(p);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

但是并不起作用,还是卡死。

# C++:异常与栈帧

既然排除了内存分配问题,该考虑的就是异常处理了。如果它抛出了异常,但是没有被正常 catch,那就会直接调用 _exit(),在 gdb 调试中也能看到:

(gdb) backtrace
#0  0x000000008021caba in _exit ()
#1  0x000000008021190e in abort ()
Backtrace stopped: frame did not save the PC
1
2
3
4

然后就找到了 OSDev 关于 libsupc++ 的异常捕获的说明 (opens new window)

To make use of exception handling, you also have to tell libsupc++ where the .eh_frame section begins. Before you throw any exception: __register_frame(address_of_eh_frames); . Terminate the .eh_frame section with 4 bytes of zeros (somehow). If you forget this, libsupc++ will never find the end of .eh_frame and generate stupid page faults.

打开 ELF 文件,确实有 .eh_frame 段产生,还有一堆 .gcc_except_table.* 以及 .srodata.*。这样就先修改链接脚本,把段的起始地址导出来(顺手把没归类的节都归入 .text 和 .srodata 段):

    .text :
	{
		PROVIDE(_text_start = .);
		*(.entry)
		*(.text)
		*(.text.*)
		*(.gcc.*)
		*(.gcc_except_table.*)
		. = ALIGN(8);
		PROVIDE(_text_end = .);
	}

    .eh_frame :
	{
		PROVIDE(__eh_frame_start = .);
		*(.eh_frame);
	}

    .srodata :
	{
		PROVIDE(_srodata_start = .);
    	*(.srodata.cst16)
    	*(.srodata.cst8)
    	*(.srodata.cst4)
    	*(.srodata.cst2)
    	*(.srodata .srodata.*)
		. = ALIGN(8);
		PROVIDE(_srodata_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
26
27
28
29

然后猜一下 __register_frame() 的原型,在调用 k_main() 之前先调用:

// Prepare C++ environment
void k_prep_cxx()
{
    // init C++ exceptions
    extern void *__eh_frame_start;
    extern void __register_frame(void *); // not knowing the prototype of __register_frame, guess and OK!
    __register_frame(&__eh_frame_start);
}
1
2
3
4
5
6
7
8

这样再编译测试,成功了!

# 善后:全局构造函数与析构函数

看文档的时候注意到 Calling Global Constructors (opens new window),想起来我确实没有给全局构造函数啥的划分段,更别提调用了。再次修改链接脚本,增加:

    .init_array :
	{
	  PROVIDE_HIDDEN (_init_array_start = .);
	  KEEP (*(SORT_BY_INIT_PRIORITY(.init_array.*) SORT_BY_INIT_PRIORITY(.ctors.*)))
	  KEEP (*(.init_array EXCLUDE_FILE (*crtbegin.o *crtbegin?.o *crtend.o *crtend?.o ) .ctors))
	  . = ALIGN(8);
	  PROVIDE_HIDDEN (_init_array_end = .);
	} 
	
	. = ALIGN(0x1000);

	.fini_array :
	{
	  PROVIDE_HIDDEN (_fini_array_start = .);
	  KEEP (*(SORT_BY_INIT_PRIORITY(.fini_array.*) SORT_BY_INIT_PRIORITY(.dtors.*)))
	  KEEP (*(.fini_array EXCLUDE_FILE (*crtbegin.o *crtbegin?.o *crtend.o *crtend?.o ) .dtors))
	  . = ALIGN(8);
	  PROVIDE_HIDDEN (_fini_array_end = .);
	} 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

之后增加两段代码,分别在 k_main() 前后调用这些构造函数和析构函数即可。

typedef void (*__init_func_ptr)();
extern __init_func_ptr _init_array_start[0], _init_array_end[0];
extern __init_func_ptr _fini_array_start[0], _fini_array_end[0];

// kernel init
void k_before_main(unsigned long *pa0, unsigned long *pa1)
{
    printf("\n===== Entered Test Kernel =====\n");
    printf("a0: 0x%lx \t a1: 0x%lx\n", *pa0, *pa1);

    printf("Calling init_array...\n");
    for (__init_func_ptr *func = _init_array_start; func != _init_array_end; func++)
        (*func)();

    // No args, use default
    if (*pa0 == 0)
        *pa0 = (unsigned long)default_args;
}

// kernel exit
void k_after_main(int main_ret)
{
    printf("\nReached target k_after_main, clearing up...\n");

    for (__init_func_ptr *func = _fini_array_start; func != _fini_array_end; func++)
        (*func)();

    printf("===== Test Kernel exited with %i =====\n", main_ret);
}
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

至此,基础环境就基本搭建完成,异常、内存分配测试基本通过。总耗时:一天!