用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,Clang
和GCC
编译器分别都导出了大致相同的标志和内部函数
.
这些功能,再加上无运行时
和类C
感觉语言(容易移植
以前代码),使我选择D
作为C
的替代.
安装工具链
试用它来编写面向RISC-V
的裸机应用.第一步
是下载工具链(以下工具应该在Linux
或MacOS
上工作).需要三个
不同的组件:
1,LDC1.30
(基于LLVM
的D编译器).可从GitHub
这里下载.确保用1.30
版.
2,GNU
的riscv64-unknown-elf
工具链.可从这里下载.
3,QEMU
的RISC-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
后,调用在下一节定义的叫dstart
的D函数
.
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>;
};
表明RAM
从0x80000000
地址开始(以下则是特殊
内存或无法访问).虚机的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,noreturn
和uintptr
.
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
协议传输
该字节.
为了用主机读字节
,需要主机上插入UART
到USB
的适配器,然后可从主机上相应
的设备文件
(一般/dev/ttyUSB0
)中读取.
今天模拟在QEMU
中的裸机
代码,所以不需要特殊的适配器.QEMU
模拟UART
设备并打印出写至其传输寄存器
的字节
.
启用易失性加载/存储
写入设备内存
时,确保编译器不会删除加载/存储
非常重要.如,如果设备放在0x10000000
,可转换
整数为指针
来直接写入
该地址.对编译器
,只是写入可能是未定义行为或导致死码
的随机地址
(如,如果从未读回
该值,编译器可能会确定
可消除写入).需要通知编译器
,必须保留读/写
设备内存,且不能优化.D
为此用了volatileStore
和volatileLoad
内部函数.
可在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
安装后,要弄清楚QEMU
的UART
设备在内存何处,以便可写入
它.
QEMU
虚机器定义了许多虚设备
,就有UART
设备.在virt.dts
中再次浏览QEMU
设备树,如下:
uart@10000000 {
interrupts = <0x0a>;
interrupt-parent = <0x03>;
clock-frequency = <0x384000>;
reg = <0x00 0x10000000 0x00 0x100>;
compatible = "ns16550a";
};
表示0x10000000
地址上有ns16550a
的UART
设备
实际
硬件上,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,+relax
到ldc2
调用中,以在编译器中启用链接器松弛
.
删除未用的函数
查看程序的(makeprog.list)
反汇编,注意到有些函数永远不会调用
.这是因为已内联这些函数
,但未删除定义.即使标记
为私有,D
总是导出函数/全局变量
到目标文件
中.幸好,现代链接器
非常聪明,且很容易让链接器
删除这些未用
函数.传递--function-sections
和--data-sections
给LDC
,让它在自己的节中(仍在.text,.data
等中)放每个函数/全局
.
现在,如果传递--gc-sections
标志给链接器
,它删除未引用节
(从而删除未用的函数/全局变量
).有了这些标志
,最终的"你好"
二进制文件,降到了160
字节.
这是链接器
优化的基本形式
.还有更高级的(LTO)
链接时间优化.如果传递-flto=thin
或-flto=full
给LDC
,则它生成的目标文件将是LLVM
位码.然后,要用LLVM
的gold
链接器插件(或用LLD
)调用链接器
,以便可读取这些文件.用此方法,链接器
应用可跨目标文件
完整的编译器优化
.
线本存储和全局变量
默认,全局
变量在D中是线本
的.表明如果按int x;
声明全局.然后,每当访问x
时,编译器会通过系统的线程指针
访问(在RISC-V
上,在tp
寄存器中存储
它).
表明如果用线本
变量,最好确保tp
指向x
所在的内存块
,如果有多线程
,每个线程的tp
应该指向不同线本块
(每个线程都有自己的x
私有副本).
总之,需要初化dstart
中,每个线程的.tdata
和.tbss
节,并用当前线程的本地.tdata
指针加载tp
.
要在所有线程
间共享全局
,需要按不变或共享
标记.标记为共享
的变量
会有些限制,基本上强制标记
与它相关的都为共享
.仍可不检查
就读取/写入
它,但至少应知道是否正在访问共享变量
(并手动验证
是否同步
).
在D的未来
版本中,除非通过原子
内部函数,否则可能禁止
直接访问共享变量
.如果有锁
来保护变量,那么需要手动
丢弃共享
限定符,这并不完美,但强制承认访问共享全局
可能不安全.
总是可将__gshared
属性用作逃生通道
,这改变共享
为全局
,但不更改类型(无限制).__gshared
的全局
等价于C全局.
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现