去年笔者将openwrt-22.03
系统移植到了基于全志D1/riscv64的嵌入式设备上。当时发现系统启动后,网络不可用;简单地修改/etc/config/network
设备即可以正常连接有线网络。为了学习riscv ISA
,笔者手动为该设备编译了汇编器链接器(不含gcc编译器)、GNU make
以及Vim,这样就可以在全志D1嵌入式设备上学习riscv的汇编语言开发了。因这些工具是手动编译生成的,其安装路径如下:
root@OpenWrt:/tmp# uname -a
Linux OpenWrt 5.4.61+ #0 PREEMPT Sun Jul 31 15:12:47 2022 riscv64 GNU/Linux
root@OpenWrt:/tmp# ls /opt/binutils/bin/
ar ld objdump tic
as ld.bfd ranlib toe
captoinfo less readelf tput
clear lessecho reset tset
dmesg lesskey rview view
ex make rvim vim
file ncurses6-config strings vimdiff
infocmp nm strip vimtutor
infotocap objcopy tabs xxd
root@OpenWrt:/tmp# echo $PATH
/opt/binutils/bin:/usr/bin:/usr/sbin:/bin:/sbin
本着由简入繁的学习原则,笔者编写了一个不依赖glibc库的简单汇编代码,其编译运行的调试结果如下:
root@OpenWrt:/tmp/assembly# make helloworld
as -mabi=lp64d -fPIC -o helloworld.o helloworld.S
ld --eh-frame-hdr -melf64lriscv -e _pentry -o helloworld helloworld.o
root@OpenWrt:/tmp/assembly# file helloworld
helloworld: ELF 64-bit LSB executable, UCB RISC-V, double-float ABI, version 1 (SYSV), statically linked, not stripped
root@OpenWrt:/tmp/assembly# ./helloworld
Hello World!
root@OpenWrt:/tmp/assembly# echo $?
3
注意到,上面生成的helloworld
是一个静态链接的可执行文件,它不需要动态链接器。helloworld.S
是一个入门级的汇编代码(对于riscv的汇编代码学习,除了riscv官方提供的ISA详解文档外,笔者还推荐github上的一个汇总说明文档),它参考了Linux内核关于syscall系统调用的说明(其中riscv相关的内容),分别使用ecall
汇编指令调用了write
及exit
两个系统调用,分别用于给柡准输出写Hello World!
、退出当前进程:
.file "helloworld.S"
.option pic
.text
.section .text.startup,"ax",@progbits
.align 4
.globl _pentry
.type _pentry, @function
_pentry:
li a7, 64
li a0, 1
lla a1, .LC0
li a2, 13
ecall
nop
li a7, 93
li a0, 3
ecall
nop
.size _pentry, . - _pentry
.section .rodata.str,"aMS",@progbits,1
.align 4
.LC0:
.string "Hello World!\n"
上面的简单helloworld
应用,并不依赖glibc
的C语言动态库。这一约束大大限制了我们在全志D1/riscv嵌入式设备上编写的汇编应用的功能。而该设备上也缺少整套的gcc
工具链,如何解决这一问题?笔者的方案是将链接到glibc
的C语方库的这一过程整合到全志D1设备上运行,就可以直接在汇编代码中引用柡准C语言库提供的变量及函数。举例说明,对于一个依赖C语言库的示例应用example.S
,在全志D1设备上的汇编、链接过程如下:
root@OpenWrt:/tmp/assembly# make example
as -mabi=lp64d -fPIC -o example.o example.S
ld --eh-frame-hdr -melf64lriscv -dynamic-linker \
/lib/ld-linux-riscv64xthead-lp64d.so.1 \
-o example /tmp/assembly/lib64xthead-lp64d/crt1.o \
/tmp/assembly/lib64xthead-lp64d/crti.o \
/tmp/assembly/lib64xthead-lp64d/crtbegin.o \
-L/tmp/assembly/lib64xthead-lp64d -L/lib \
example.o --no-as-needed -lc --no-as-needed \
/tmp/assembly/lib64xthead-lp64d/crtend.o \
/tmp/assembly/lib64xthead-lp64d/crtn.o
root@OpenWrt:/tmp/assembly# file example
example: ELF 64-bit LSB executable, UCB RISC-V, RVC, double-float ABI, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-riscv64xthead-lp64d.so.1, for GNU/Linux 4.15.0, with debug_info, not stripped
root@OpenWrt:/tmp/assembly# ldd ./example
linux-vdso.so.1 (0x0000003ff6200000)
libc.so.6 => /lib64xthead/lp64d/libc.so.6 (0x0000003ff60f2000)
/lib/ld-linux-riscv64xthead-lp64d.so.1 (0x0000003ff6202000)
root@OpenWrt:/tmp/assembly# ./example 5 6
Hello World!
Value A: 5, B: 6, result: 30
需要说明的是,在使用C语言库编写汇编应用时,需要严格地遵守riscv-abi中要求的调用规则,这里不再展开。上面链接过程中需要用到的多个目柡文件crtX.o
,是在PC机上从交叉编译器riscv64-glibc-gcc-thead_20200702.tar.xz
中提取的(其中,libc.so
是一个修改后的文本文件)。这些文件源于glibc
,用于Linux系统环境下应用的C运行时(C Run Time)的初始化操作:
root@OpenWrt:/tmp/assembly# ls lib64xthead-lp64d/
crt1.o crtend.o crtn.o libc_nonshared.a
crtbegin.o crti.o libc.so rv64-ld-log.txt
root@OpenWrt:/tmp/assembly# cat lib64xthead-lp64d/libc.so
/* GNU ld script
Use the shared library, but some functions are only in
the static library, so try that secondarily. */
OUTPUT_FORMAT(elf64-littleriscv)
GROUP ( /lib/libc.so.6 /tmp/assembly/lib64xthead-lp64d/libc_nonshared.a AS_NEEDED ( /lib/ld-linux-riscv64xthead-lp64d.so.1 ) )
以下给出调用C库的一些函数的汇编代码example.S
源文件,它引用了libc.so.6
动态库中的stdout
、fprintf
、strtoll
等符号:
.file "example.c"
.option pic
.text
.section .text.startup,"ax",@progbits
.align 1
.align 4
.globl main
.type main, @function
main:
addi sp,sp,-48
sd s0,8(sp)
sd s1,16(sp)
sd s2,24(sp)
sd ra,40(sp)
sd s3,32(sp)
addi s0,sp,48
li a5,1
li s2,0
li s1,0
bgt a0,a5,.L7
.L2:
mul a4,s1,s2
la s3,stdout
ld a0,0(s3)
mv a3,s1
mv a2,s2
lla a1,.LC0
call fprintf@plt
ld a0,0(s3)
call fflush@plt
ld ra,40(sp)
ld s0,8(sp)
subw a0,s2,s1
ld s3,32(sp)
ld s1,16(sp)
ld s2,24(sp)
addi sp,sp,48
jr ra
.L7:
mv s1,a0
ld a0,8(a1)
mv s3,a1
li a2,0
li a1,0
call strtoll@plt
li a5,2
mv s2,a0
beq s1,a5,.L4
ld a0,16(s3)
li a2,0
li a1,0
call strtoll@plt
mv s1,a0
j .L2
.L4:
li s1,0
j .L2
.size main, .-main
.section .rodata.str1.8,"aMS",@progbits,1
.align 3
.LC0:
.string "Hello World!\nValue A: %lld, B: %lld, result: %lld\n"
值得说明的是,上面的example.S
是由以下C代码编译生成的,并非笔者手动编写:
#include
#include
#include
int main(int argc, char * argv[])
{
long long aval, bval;
aval = bval = 0;
if (argc >= 2)
aval = (long long) strtoll(argv[1], NULL, 0);
if (argc >= 3)
bval = (long long) strtoll(argv[2], NULL, 0);
fprintf(stdout, "Hello World!\nValue A: %lld, B: %lld, result: %lld\n",
aval, bval, aval * bval);
fflush(stdout);
return (int) (aval - bval);
}
以上笔者演示了在嵌入式设备上,仅使用汇编器as
及链接器ld
开发汇编应用的两种方法。第一种是使用riscv
的ecall
汇编指令,直接调用Linux内核提供的系统调用;这种方法生成的可执行文件是静态链接的,不依赖动态链接器也不能调用标准C语言库提供的功能。第二种方法是可以连接到标准C语言库的,而且是动态链接的。当然,第二种方法也可以方便地扩展到其他动态库,而不仅限于标准C语言库。笔者对比了两种方法,发现第二种可以方便地访问到应用运行时的命令行参数,那么如何使用第一种方法访问这些命令行参数呢?
通过查看全志D1的Linux内核代码可知,应用的命令行参数及环境变量是存放在应用的栈上面的:
/* awd1-linux-5.4/fs/exec.c */
/*
* 'copy_strings()' copies argument/environment strings from the old
* processes's memory to the new process's stack. The call to get_user_pages()
* ensures the destination page is created and not swapped out.
*/
static int copy_strings(int argc, struct user_arg_ptr argv,
struct linux_binprm *bprm)
{
struct page *kmapped_page = NULL;
char *kaddr = NULL;
unsigned long kpos = 0;
int ret;
while (argc-- > 0) {
const char __user *str;
int len;
unsigned long pos;
ret = -EFAULT;
str = get_user_arg_ptr(argv, argc);
if (IS_ERR(str))
goto out;
......
static int do_execveat_common(int fd, struct filename *filename, ... {
......
retval = copy_string_kernel(bprm->filename, bprm);
if (retval < 0)
goto out_free;
bprm->exec = bprm->p;
retval = copy_strings(bprm->envc, envp, bprm);
if (retval < 0)
goto out_free;
retval = copy_strings(bprm->argc, argv, bprm);
if (retval < 0)
goto out_free;
}
于是,笔者编写了不依赖C语言库及其运行时的稍复杂一些的汇编代码dumpenv.S
,它会遍历函数栈上的保存的应用命令行参数及环境变量,依次输出到标准输出,下面是编译运行的结果:
root@OpenWrt:/tmp/assembly# make dumpenv
as -mabi=lp64d -fPIC -o dumpenv.o dumpenv.S
ld --eh-frame-hdr -melf64lriscv -e _pentry -o dumpenv dumpenv.o
root@OpenWrt:/tmp/assembly# file dumpenv
dumpenv: ELF 64-bit LSB executable, UCB RISC-V, double-float ABI, version 1 (SYSV), statically linked, not stripped
root@OpenWrt:/tmp/assembly# ./dumpenv hello world "" Welcome To RISC-V
stackpointer: 0x0000003ffffc3c80
stkptr[0x00]: 0x0000000000000007
stkptr[0x08]: 0x0000003ffffc3e7d
stkptr[0x10]: 0x0000003ffffc3e87
stkptr[0x18]: 0x0000003ffffc3e8d
./dumpenv
hello
world
Welcome
To
RISC-V
USER=root
SSH_CLIENT=192.168.1.8 49510 22
SHLVL=1
HOME=/root
OLDPWD=/tmp
SSH_TTY=/dev/pts/0
[email protected]
PS1=\[\e]0;\u@\h: \w\a\]\u@\h:\w\$
ENV=/etc/shinit
LOGNAME=root
TERM=xterm
PATH=/opt/binutils/bin:/usr/bin:/usr/sbin:/bin:/sbin
SHELL=/bin/ash
PWD=/tmp/assembly
SSH_CONNECTION=192.168.1.8 49510 192.168.1.6 22
./dumpenv
有人会问,为什么在环境变量结束后,还会有一个./dumpenv
的信息?这一点可以参考上面帖出的Linux内核源码:因在riscv平台上,C语言函数栈是向下生长的,内核在栈上构造这些信息时,参数的写入恰好与dumpenv
输出的顺序是相反的。在调用copy_strings
存入环境变量之前,会单独将应用的可执行文件名先写入,即以上调试结果的最后一个./dumpenv
。下面是笔者编写的dumpenv.S
全部代码,仅供参考:
.file "dumpenv.S"
.option pic
.text
.section .text.startup, "ax", @progbits
.align 4
.globl dump_int
.type dump_int, @function
dump_int:
addi sp, sp, -48
addi a1, sp, 8
mv a2, a1
li a3, 0x30
sb a3, 0x0(a2)
addi a2, a1, 1
li a3, 0x78
sb a3, 0x0(a2)
li a4, 2
li a7, 0x39
1:
li a5, 17
sub a5, a5, a4
sll a5, a5, 2
srl a5, a0, a5
andi a5, a5, 0xf
addi a3, a5, 0x30
bleu a3, a7, 2f
addi a3, a3, 0x27
2:
add a5, a1, a4
sb a3, 0x0(a5)
addi a4, a4, 1
li a5, 18
bne a4, a5, 1b
li a2, 18
li a0, 1
li a7, 64
ecall
nop
addi sp, sp, 48
ret
.size dump_int, . - dump_int
.align 4
.globl dump_str
.type dump_str, @function
dump_str:
addi sp, sp, -16
sd zero, 0x8(sp)
beqz a0, 1f
li a2, -1
2:
addi a2, a2, 1
add a1, a0, a2
lbu a3, 0x0(a1)
bnez a3, 2b
beqz a2, 1f
sd a2, 0x8(sp)
mv a1, a0
li a0, 1
li a7, 64
ecall
1:
nop
ld a0, 0x8(sp)
addi sp, sp, 16
ret
.size dump_str, . - dump_str
.align 4
.globl dump_char
.type dump_char, @function
dump_char:
addi sp, sp, -16
sb a0, 8(sp)
addi a1, sp, 8
li a2, 1
li a0, 1
li a7, 64
ecall
nop
addi sp, sp, 16
ret
.size dump_char, .-dump_char
.align 4
.global dump_str_array
.type dump_str_array, @function
dump_str_array:
addi sp, sp, -48
bnez a0, 1f
addi sp, sp, 48
ret
1:
sd s0, 0x8(sp)
sd s1, 0x10(sp)
sd s2, 0x18(sp)
sd ra, 0x20(sp)
mv s0, a0
mv s1, a1
2:
bgtz s1, 3f
lbu a0, 0x0(s0)
beqz a0, 4f
3:
mv a0, s0
call dump_str
addi s2, a0, 1
li a0, 10
call dump_char
add s0, s0, s2
addi s1, s1, -1
j 2b
4:
ld s0, 0x8(sp)
ld s1, 0x10(sp)
ld s2, 0x18(sp)
ld ra, 0x20(sp)
addi sp, sp, 48
ret
.size dump_str_array, . - dump_str_array
.align 4
.globl _pentry
.type _pentry, @function
_pentry:
mv s4, sp
addi sp, sp, -16
lla a0, .Lstkptr
call dump_str
mv a0, s4
call dump_int
li a0, 10
call dump_char
lla a0, .Lsp_0
call dump_str
ld a0, 0x0(s4)
call dump_int
li a0, 10
call dump_char
lla a0, .Lsp_8
call dump_str
ld a0, 0x8(s4)
call dump_int
li a0, 10
call dump_char
lla a0, .Lsp_10
call dump_str
ld a0, 0x10(s4)
call dump_int
li a0, 10
call dump_char
lla a0, .Lsp_18
call dump_str
ld a0, 0x18(s4)
call dump_int
li a0, 10
call dump_char
ld a0, 0x8(s4)
ld a1, 0x0(s4)
call dump_str_array
li a7, 93
li a0, 8
ecall
nop
.size _pentry, . - _pentry
.section .rodata.str, "aMS", @progbits, 1
.align 4
.Lstkptr:
.string "stackpointer: \0"
.Lsp_0:
.string "stkptr[0x00]: \0"
.Lsp_8:
.string "stkptr[0x08]: \0"
.Lsp_10:
.string "stkptr[0x10]: \0"
.Lsp_18:
.string "stkptr[0x18]: \0"
至此我们可以说,基于riscv
的应用层汇编的开发,有了一个可行的方法。但若要深入了解riscv ISA
,仅编写应用层汇编是不够的,还需要能够编写、运行Supervisor
模式下的需要优先级的汇编指令;这一点,可以通过修改u-boot
或Linux内核源码来学习;有时间和精力的,也可以从头写一个BareMetal
的程序。