用D写裸机

原文
下一篇

用D编写裸机RISC-V应用

这篇文章展示,如何用D编写,目标为RISC-VQEMU模拟器的程序裸机"你好".项目

为什么是D?

我最近一直在用C编写裸机代码,我有点对C缺乏特征感到沮丧.D引入了叫betterC的模式(基本上禁止了D运行时的所有语言功能).使得D裸机编程大致与C一样.没有得到D的所有功能,但足够涵盖我想要的(事实上,对系统编程,我更喜欢更好C子集的D超过完整的D).

以下是D中我最看重的优点:

1,体面的导入系统(不再有头文件和#include).
2,自动检查边界,及绑定串和数组.
3,结构中的方法.
4,编译时代码求值:编译时运行D代码!
5,强大的元编程.
6,迭代器.
7,默认支持线本存储.
8,保护和RAII.
9,@safe的一些内存安全保护.
10,相当全面和可读在线规范.
11,活跃的不和谐(discord)频道,在几分钟内回答问题.
12,基于LLVM的编译器(LDC)和正式GCC项目的一部分的GNU编译器(GDC).
13,ClangGCC编译器分别都导出了大致相同的标志和内部函数.
这些功能,再加上无运行时类C感觉语言(容易移植以前代码),使我选择D作为C的替代.

安装工具链

试用它来编写面向RISC-V的裸机应用.第一步是下载工具链(以下工具应该在LinuxMacOS上工作).需要三个不同的组件:

1,LDC1.30(基于LLVM的D编译器).可从GitHub这里下载.确保用1.30版.
2,GNUriscv64-unknown-elf工具链.可从这里下载.

3,QEMURISC-V模拟器:qemu-system-riscv64.可从这里下载,或按系统QEMU包的一部分提供.

LDC,因为它支持riscv64目标.我也用过GDC裸机开发,但它需要构建riscv64-unknown-elf-gdc源码的工具链,但没人提供预构建的二进制文件.
GNU工具汇编,链接,其他工具用objcopy/objdump,及QEMU模拟硬件.

安装这些后,应该可运行:

$ ldc2 --version
LDC - the LLVM D compiler (1.30.0):
...

$ riscv64-unknown-elf-ld
riscv64-unknown-elf-ld: no input files

$ qemu-system-riscv64 -h
...

CPU入口

编写裸机代码,所以没有操作系统,没有控制台,没有文件,什么都没有.CPU只是在初始安装后,在预先指定的地址开始执行指令,安装链接脚本时就知道该地址.现在可定义_start符号作为入口,并假设链接器,会在CPU入口处的此标签处放代码.

D函数需要有效的栈指针,因此执行D代码前需要用有效地址加载sp栈指针寄存器.

创建内容如下的叫start.s的文件:

.section ".text.boot"

.globl _start
_start:
    la sp, _stack_start
    call dstart
_hlt:
    j _hlt

现在假设_stack_start是带有效栈地址的符号,在链接脚本中,正确安装它.加载sp后,调用在下一节定义的叫dstartD函数.

D入口

现在可在dstart.d中定义dstart函数.现在只是个无限循环.

module dstart;

extern (C) void dstart() {
    while (1) {}
}

链接脚本

编译该程序之前,需要一些链接脚本来告诉链接器,代码应该如何布局.需要指定地址文本节开始地址(入口地址),并为.rodata,.data,.bss数据节和保留空间.

入口地址

目标为QEMU的虚RISC-V机器,所以要找到它的入口.

可通过告诉它转储设备树,向QEMU查询机器中所有设备列表,请求如下:

$ qemu-system-riscv64 -machine virt,dumpdtb=virt.dtb
$ dtc virt.dtb > virt.dts//..

virt.dts中找到以下项:

memory@80000000 {
    device_type = "memory";
    reg = <0x00 0x80000000 0x00 0x8000000>;
};

表明RAM0x80000000地址开始(以下则是特殊内存或无法访问).虚机的CPU入口是在0x80000000内存中存储的第一个指令.

链接脚本中,要告诉链接器,它应该在0x80000000_start函数.为此,在0x80000000.text节,首先放.text.boot节,来告诉它.然后,包括其余的.text节,然后是只读,可写数据和BSS.

link.ld中:

ENTRY(_start)

SECTIONS
{
    .text 0x80000000 : {
        KEEP(*(.text.boot))//第1节
        *(.text*)
    }
    .rodata : {
        . = ALIGN(8);
        *(.rodata*)
        *(.srodata*)
        . = ALIGN(8);
    }
    .data : {
        . = ALIGN(8);
        *(.sdata*)
        *(.data*)
        . = ALIGN(8);
    }
    .bss : {
        . = ALIGN(8);
        _bss_start = .;
        *(.sbss*)
        *(.bss*)
        *(COMMON)
        . = ALIGN(8);
        _bss_end = .;
    }

    .kstack : {
        . = ALIGN(16);
        . += 4K;
        _stack_start = .;
    }

    /DISCARD/ : { *(.comment .note .eh_frame) }
}

什么是BSS?

BSS是编译器假定全部初化为零的内存区域.一般直接复制程序的静态数据ELF可执行文件.
如果程序中有个你好串,则确切的字节在二进制文件中的某个(在只读数据节中)位置.
但是,许多静态数据初化为零,因此不是直接把0零字节写入ELF文件,链接器为节省空间,创建要求必须在运行时初化为零,但ELF文件自身不包含该数据的特殊(BSS)节.

所以即使有巨大的1MB零数组,你的ELF二进制文件会很小,因为仅当应用启动时,该节才会扩展到RAM.

一般操作系统在启动程序前安装BSS,但因为裸机,因此必须在dstart函数中手动这样.为了初化成功,在链接脚本中定义了地址分别是BSS节的开头和结尾_bss_start和_bss_end符号.

为栈保留空间

还为.kstack节保留一页,并在节尾(栈向下增长)标记了_stack_start符号.栈必须为16字节对齐.

编译!现在,有了编译基本裸机的一切.

$ ldc2 -Oz -betterC -mtriple=riscv64-unknown-elf -mattr=+m,+a,+c --code-model=medium -c dstart.d
$ riscv64-unknown-elf-as -mno-relax -march=rv64imac start.S -c -o start.o
$ riscv64-unknown-elf-ld -Tlink.ld start.o dstart.o -o prog.elf

看看其中的一些标志:

1,Oz:针对大小积极优化.
2,betterC:启用更好C模式(禁止内置的D运行时).
3,mtriple=riscv64-unknown-elf:针对riscv64裸机elf构建.
4,mattr=+m,+a,+c:启用以下RISC-V扩展:m(乘/除),a(原子)和c(压缩指令).
5,code-model=medium:RISC-V中的代码模型,控制如何构建远处位置指针.

medium代码模型(也叫medany)允许定位当前地址的2GiB以内的符号,建议用64位程序.更多信息,见SiFive帖子
6,mno-relax:禁止汇编程序中的链接器放松(LDC中默认禁止).链接器放松RISC-V相关的允许链接器利用(gp全局指针)寄存器的优化.
重复输入命令会乏味,所以创建Makefile(或Knitfile):

SRC=$(wildcard *.d)
OBJ=$(SRC:.d=.o)

all: prog.bin

%.o: %.d
    ldc2 -Oz -betterC -mtriple=riscv64-unknown-elf -mattr=+m,+a,+c,+relax --code-model=medium --makedeps=$*.dep $< -c -of $@
%.o: %.s
    riscv64-unknown-elf-as -march=rv64imac $< -c -o $@
prog.elf: start.o $(OBJ)
    riscv64-unknown-elf-ld -Tlink.ld $^ -o $@
%.bin: %.elf
    riscv64-unknown-elf-objcopy $< -O binary $@
%.list: %.elf
    riscv64-unknown-elf-objdump -D $< > $@
run: prog.bin
    qemu-system-riscv64 -nographic -bios none -machine virt -kernel prog.bin
clean:
    rm -f *.bin *.list *.o *.elf *.dep

-include *.dep

并用:

$ make prog.bin

编译,此文件是程序的原始转储.此时,高达22个字节.
查看反汇编程序,请运行:

$ make prog.list
...
$ cat prog.list
prog.elf:     file format elf64-littleriscv

Disassembly of section .text:

0000000080000000 <_start>:
    80000000:    00001117              auipc    sp,0x1
    80000004:    02010113              addi    sp,sp,32 # 80001020 <_stack_start>
    80000008:    00000097              auipc    ra,0x0
    8000000c:    00c080e7              jalr    12(ra) # 80000014 <dstart>

0000000080000010 <_hlt>:
    80000010:    a001                    j    80000010 <_hlt>
    ...

0000000080000014 <dstart>:
    80000014:    a001                    j    80000014 <dstart>

0x80000000正确链接了_start函数,并是期望的汇编!

如果试运行:

$ make run
qemu-system-riscv64 -nographic -bios none -machine virt -kernel prog.bin

它只会进入无限循环(按下Ctrl-A,Ctrl-X退出QEMU).得到输出之前,还有更多工作.

更多安装:初化BSS

现在修改dstart来初化BSS.需要声明一些extern变量,以便D代码可用_bss_start_bss_end链接器符号.然后可从_bss_start循环到_bss_end并用零赋值该区间内的所有字节.完成后,初化了BSS,可(用可能初化为零的全局变量)运行任意D代码.

extern (C) {
    extern __gshared uint _bss_start, _bss_end;

    void dstart() {
        uint* bss = &_bss_start;
        uint* bss_end = &_bss_end;
        while (bss < bss_end) {
            *bss++ = 0;
        }

        import main;
        kmain();
    }
}

main.d中有裸机入口:

module main;

void kmain() {}

创建最小D运行时

因为缺乏运行时,一些D语言功能不可用.如,未定义size_t等类型,不能用断定.创建最小运行时的第一步是创建object.d文件.D编译器搜索此特殊文件并自动导入.
因此,可在此处定义size_t等类型.这是我喜欢用的最小定义,它也定义了ptrdiff_t,noreturnuintptr.

module object;

alias string = immutable(char)[];
alias size_t = typeof(int.sizeof);
alias ptrdiff_t = typeof(cast(void*) 0 - cast(void*) 0);

alias noreturn = typeof(*null);

static if ((void*).sizeof == 8) {
    alias uintptr = ulong;
} else static if ((void*).sizeof == 4) {
    alias uintptr = uint;
} else {
    static assert(0, "指针必须为4到8位");
}

写入UART设备

大多数系统都有UART设备.一般,工作原理,写个字节到内存中的特殊位置,用板上某些引脚上的UART协议传输该字节.
为了用主机读字节,需要主机上插入UARTUSB的适配器,然后可从主机上相应设备文件(一般/dev/ttyUSB0)中读取.

今天模拟在QEMU中的裸机代码,所以不需要特殊的适配器.QEMU模拟UART设备并打印出写至其传输寄存器字节.

启用易失性加载/存储

写入设备内存时,确保编译器不会删除加载/存储非常重要.如,如果设备放在0x10000000,可转换整数为指针来直接写入该地址.对编译器,只是写入可能是未定义行为或导致死码随机地址(如,如果从未读回该值,编译器可能会确定可消除写入).需要通知编译器,必须保留读/写设备内存,且不能优化.D为此用了volatileStorevolatileLoad内部函数.
可在object.d中定义这些:

pragma(LDC_intrinsic, "ldc.bitop.vld") ubyte volatileLoad(ubyte* ptr);
pragma(LDC_intrinsic, "ldc.bitop.vld") ushort volatileLoad(ushort* ptr);
pragma(LDC_intrinsic, "ldc.bitop.vld") uint volatileLoad(uint* ptr);
pragma(LDC_intrinsic, "ldc.bitop.vld") ulong volatileLoad(ulong* ptr);
pragma(LDC_intrinsic, "ldc.bitop.vst") void volatileStore(ubyte* ptr, ubyte value);
pragma(LDC_intrinsic, "ldc.bitop.vst") void volatileStore(ushort* ptr, ushort value);
pragma(LDC_intrinsic, "ldc.bitop.vst") void volatileStore(uint* ptr, uint value);
pragma(LDC_intrinsic, "ldc.bitop.vst") void volatileStore(ulong* ptr, ulong value);

控制UART

安装后,要弄清楚QEMUUART设备在内存何处,以便可写入它.
QEMU虚机器定义了许多虚设备,就有UART设备.在virt.dts中再次浏览QEMU设备树,如下:

uart@10000000 {
    interrupts = <0x0a>;
    interrupt-parent = <0x03>;
    clock-frequency = <0x384000>;
    reg = <0x00 0x10000000 0x00 0x100>;
    compatible = "ns16550a";
};

表示0x10000000地址上有ns16550aUART设备
实际硬件上,UART需要编写一些内存映射配置寄存器(来安装波特率和其他选项)来正确初化.但是,不需要初化QEMU设备.

它模仿NS16550A设备,写入其传输寄存器,就可写入UART的字节(用QEMU模拟时出现在控制台上).ns16550a传输寄存器是第一个映射寄存器,所以它在0x10000000位置.

uart.d中:

module uart;

struct Ns16650a(ubyte* base) {
    static void tx(ubyte b) {
        volatileStore(base, b);
    }
}

alias Uart = Ns16650a!(cast(ubyte*) 0x10000000);

现在在kmain中,可测试UART.

module main;

import uart;

void kmain() {
    Uart.tx('h');
    Uart.tx('i');
    Uart.tx('\n');
}
$ make prog.bin
$ qemu-system-riscv64 -nographic -bios none -machine virt -kernel prog.bin

hi
Ctrl-A,Ctrl-x退出QEMU(程序从kmain返回后进入无限循环).

制作简单的打印函数

现在可用println函数包装Uart.tx函数,并很快有个裸机你好世界!.
object.d中:

import uart;

void printElem(char c) {
    Uart.tx(c);
}

void printElem(string s) {
    foreach (c; s) {
        printElem(c);
    }
}

void print(Args...)(Args args) {
    foreach (arg; args) {
        printElem(arg);
    }
}

void println(Args...)(Args args) {
    print(args, '\n');
}

而在main.d中:

void kmain() {
    println("你好,世界");
}
$ make prog.bin
$ qemu-system-riscv64 -nographic -bios none -machine virt -kernel prog.bin
Hello world!

你有它,(模拟)裸机你好世界!

一些初化不必要(最终没有用BSS中的变量),但对编写更复杂的裸机程序,应该正确安装好了.

添加支持断定和检查边界

如果试用D断定式,注意到链接失败:

riscv64-unknown-elf-ld: dstart.o: in function `_D6dstart5kmainFZv':
dstart.d:(.text+0x3c): undefined reference to `__assert'

它正在找__assert函数,所以在文件中创建一个:

size_t strlen(const(char)* s) {
    size_t n;
    for (n = 0; *s != '\0'; ++s) {
        ++n;
    }
    return n;
}

extern (C) noreturn __assert(const(char)* msg, const(char)* file, int line) {
//用[0..长度]语法转换符指针到边界串
    string smsg = cast(string) msg[0 .. strlen(msg)];
    string sfile = cast(string) file[0 .. strlen(file)];
    println("fatal error: ", sfile, ": ", smsg);
    while (1) {}
}

现在可用assert语句!

D还支持检查边界,编译器也会在检查边界失败时调用__assert.表明现在也有工作的检查边界.

试试main.d:

void kmain() {
    char[10] array;
    int x = 12;
    println(array[x]);
}

运行它给出:

fatal error: main.d: array index out of bounds

检查边界数组!

此代码不打印行号,因为要求转换,练习留给读者.

启用链接器放松

链接放松RISC-V工具链中允许通过(存储在gp寄存器中)全局指针访问全局变量优化.此值数据节指针,允许通过直接从gp偏移,而不是(需要多个RISC-V指令)从头开始构造全局地址的加载全局变量指令.

要启用链接器放松,必须做三件事:

1,修改链接脚本,使其为全局指针定义一个符号.
2,在_start函数中用此值加载gp寄存器.
3,在编译器中启用链接器放松.
要修改链接脚本,只需在.rodata节定义的开头加以下内容:

__global_pointer$ = . + 0x800;

这设置了__global_pointer$符号(链接器假定在gp中存储的特殊符号),来指向数据段的0x800字节(RISC-V指令,可在一条指令中加载/存储,gp寄存器沿任一方向偏移最多0x800字节的值).这允许gp偏移覆盖大部分/所有静态数据.
接着加到_start:

.option push
.option norelax
la gp, __global_pointer$
.option pop

要暂时启用norelax选项,否则汇编程序会优化为mv gp,gp.
最后,可从riscv64-unknown-elf-as调用中删除-mno-relax标志,并添加-mattr=+m,+a,+c,+relaxldc2调用中,以在编译器中启用链接器松弛.

删除未用的函数

查看程序的(makeprog.list)反汇编,注意到有些函数永远不会调用.这是因为已内联这些函数,但未删除定义.即使标记为私有,D总是导出函数/全局变量目标文件中.幸好,现代链接器非常聪明,且很容易让链接器删除这些未用函数.传递--function-sections--data-sectionsLDC,让它在自己的节中(仍在.text,.data等中)放每个函数/全局.

现在,如果传递--gc-sections标志给链接器,它删除未引用节(从而删除未用的函数/全局变量).有了这些标志,最终的"你好"二进制文件,降到了160字节.

这是链接器优化的基本形式.还有更高级的(LTO)链接时间优化.如果传递-flto=thin-flto=fullLDC,则它生成的目标文件将是LLVM位码.然后,要用LLVMgold链接器插件(或用LLD)调用链接器,以便可读取这些文件.用此方法,链接器应用可跨目标文件完整的编译器优化.

线本存储和全局变量

默认,全局变量在D中是线本的.表明如果按int x;声明全局.然后,每当访问x时,编译器会通过系统的线程指针访问(在RISC-V上,在tp寄存器中存储它).
表明如果用线本变量,最好确保tp指向x所在的内存块,如果有多线程,每个线程的tp应该指向不同线本块(每个线程都有自己的x私有副本).
总之,需要初化dstart中,每个线程的.tdata.tbss节,并用当前线程的本地.tdata指针加载tp.

要在所有线程间共享全局,需要按不变或共享标记.标记为共享变量会有些限制,基本上强制标记与它相关的都为共享.仍可不检查读取/写入它,但至少应知道是否正在访问共享变量(并手动验证是否同步).
在D的未来版本中,除非通过原子内部函数,否则可能禁止直接访问共享变量.如果有来保护变量,那么需要手动丢弃共享限定符,这并不完美,但强制承认访问共享全局可能不安全.

总是可将__gshared属性用作逃生通道,这改变共享全局,但不更改类型(无限制).__gshared全局等价于C全局.

posted @   zjh6  阅读(63)  评论(0编辑  收藏  举报  
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示