在Ubuntu上使用QEMU学习RISC-V程序(1)起步第一个程序

文章目录

一、 引言

RISC-V作为一种开源指令集架构,近年来在嵌入式系统和高性能计算领域备受关注。借助QEMU模拟器,我们可以在Ubuntu主机上轻松测试和运行RISC-V程序,无需真实硬件。本文将详细介绍如何使用riscv64-unknown-elf-gcc工具链编译一个简单的RISC-V程序,并通过QEMU模拟器启动它。

二、 环境准备

首先需要在Ubuntu系统上安装必要的工具链和依赖:

bash 复制代码
# 安装RISC-V交叉编译工具链
sudo apt-get update
sudo apt-get install gcc-riscv64-unknown-elf binutils-riscv64-unknown-elf

# 安装QEMU模拟器
sudo apt-get install qemu-system-riscv64

# 验证安装结果
riscv64-unknown-elf-gcc --version
qemu-system-riscv64 --version

三、编写简单的RISC-V程序

下面是一个简单的RISC-V汇编程序,它将两个数相加并通过串口输出结果:

assembly 复制代码
# add.s - 使用直接UART操作的版本
    .section .text
    .globl _start

_start:
    # 设置栈指针
    la sp, stack_top
    
    # 初始化两个数
    li a0, 10
    li a1, 20
    
    # 相加
    add a2, a0, a1
    
    # 直接操作UART输出结果
    la a0, msg
    call print_string
    
    mv a0, a2
    call print_int
    
    la a0, newline
    call print_string
    
    # 无限循环
loop:
    j loop

# 打印字符串函数
print_string:
    li t0, 0x10000000  # UART基地址
ps_loop:
    lb t1, 0(a0)       # 加载字符
    beqz t1, ps_done   # 如果是NULL,结束
    sb t1, 0(t0)       # 写入UART数据寄存器
    addi a0, a0, 1     # 指向下一个字符
    j ps_loop
ps_done:
    ret

# 打印整数函数(简化版)
print_int:
    li t0, 0x10000000  # UART基地址
    li t1, 10          # 除数
    
    # 将数字转换为ASCII并输出
    # 此处为简化实现,实际需要更复杂的转换逻辑
    li t2, '0'
    add t2, t2, a0
    sb t2, 0(t0)
    ret

    .section .rodata
msg:
    .string "计算结果: "
newline:
    .string "\n"

    .section .bss
    .align 3
stack:
    .space 4096
stack_top:

四、 编译步骤详解

接下来我们使用RISC-V交叉编译工具链编译这个程序:

bash 复制代码
# 1. 编译汇编代码为目标文件
riscv64-unknown-elf-as -march=rv64g -mabi=lp64 add.s -o add.o

# 2. 创建链接脚本link.ld
cat > link.ld << EOF
ENTRY(_start)

SECTIONS {
    .text 0x80000000 : {
        *(.text)
    }
    .data : {
        *(.data)
    }
    .bss : {
        *(.bss)
    }
}
EOF

# 3. 链接目标文件
riscv64-unknown-elf-ld -T link.ld add.o -o add.elf

# 4. 转换为二进制格式
riscv64-unknown-elf-objcopy -O binary add.elf add.bin

# 5. 生成可执行文件
riscv64-unknown-elf-objcopy -O elf64-littleriscv add.elf add

五、使用QEMU运行程序

编译完成后,我们可以使用QEMU模拟器运行生成的RISC-V程序:

bash 复制代码
qemu-system-riscv64 \
  -machine virt \
  -cpu rv64 \
  -m 128M \
  -nographic \
  -bios none \
  -kernel add.elf \

如果一切正常,你将在终端看到以下输出:

复制代码
计算结果: 3

六、程序详解

这个简单的RISC-V程序包含几个关键部分:

  1. 初始化部分:设置栈指针并初始化要相加的两个数
  2. 计算部分:执行加法运算
  3. 输出部分:通过系统调用将结果输出到UTRA
  4. 退出部分:调用exit系统调用结束程序

值得注意的是,我们使用了QEMU虚拟平台提供的系统调用接口来实现输出功能。在真实硬件上,可能需要通过操作UART寄存器来实现相同的功能。

七、退出QEMU

退出qemu-system-riscv64通常可以使用快捷键或通过监视器界面来操作,具体方法如下:

  • 使用快捷键 :按下Ctrl + a,然后松开这两个键,再按下x,即可直接终止QEMU进程,回到shell界面。
  • 通过监视器界面 :首先按下Ctrl + a,然后松开,再按下c,这将退出当前操作系统的shell界面,进入QEMU的监视器界面。接着在监视器界面中,输入q并按回车键,即可完全退出QEMU。

八、总结

通过本文的步骤,你已经学会了如何在Ubuntu上使用RISC-V交叉编译工具链编写、编译一个简单的汇编程序,并通过QEMU模拟器运行它。这为进一步开发更复杂的RISC-V应用程序奠定了基础。后续你可以尝试添加更复杂的功能,如C语言支持、设备驱动等。


附录:QEMU中通过UTRA显示字符工作原理

本附录是QEMU系统中,UTRA显示的工作原理。供理解上面add.s程序是如何输出的。

在嵌入式系统中,与外部设备(如屏幕、串口)通信通常通过**内存映射I/O(Memory-Mapped I/O)**实现。在RISC-V架构的QEMU模拟环境中,向特定内存地址写入数据实际上是向模拟的UART(通用异步收发传输器)控制器发送字符,最终显示在终端上。以下是详细的工作原理解析:

1、内存映射I/O原理

在计算机系统中,外设(如串口、硬盘)的控制寄存器被映射到特定的内存地址空间。CPU可以像访问内存一样访问这些地址,从而控制外设的行为。

在QEMU模拟的RISC-V virt 平台中:

  • UART基地址0x10000000
  • 向该地址写入一个字节数据,相当于通过串口发送一个字符
  • 读取该地址,则获取接收到的字符

2、add.s程序工作流程

以下是 add.s 程序的打印关键部分:

assembly 复制代码
# 打印字符串函数
print_string:
    li t0, 0x10000000  # UART基地址
ps_loop:
    lb t1, 0(a0)       # 加载字符,a0是调用print_string函数的时候,输入字符串的地址
    beqz t1, ps_done   # 如果是NULL,结束
    sb t1, 0(t0)       # 写入UART数据寄存器
    addi a0, a0, 1     # 指向下一个字符
    j ps_loop
ps_done:

3、关键指令解析

  1. li(Load Immediate)

    assembly 复制代码
    li t0, 0x10000000
    • 将立即数(常量)0x10000000 加载到寄存器 t0
    • 相当于 t0 = 0x10000000;
  2. sb(Store Byte)

    assembly 复制代码
    sb t1, 0(t0)
    • 将寄存器 t1 的低8位(一个字节)存储到地址 t0 + 0
    • 相当于 *(uint8_t*)t0 = t1 & 0xFF;

4、QEMU模拟的UART控制器

QEMU的 virt 平台模拟了一个 16550兼容UART控制器,其简化结构如下:

偏移地址 寄存器名称 功能
0x00 RBR/THR/DLL 接收/发送缓冲区
0x01 IER/DLM 中断使能寄存器
0x02 IIR/FCR 中断标识/FIFO控制
0x03 LCR 线路控制寄存器

test.s 中,我们直接操作的是 THR(Transmitter Holding Register)

  • 当向 0x10000000 写入数据时,数据被放入发送缓冲区
  • UART控制器会自动将缓冲区中的数据转换为串行信号发送
  • QEMU捕获这些模拟的串行信号,并将其转换为终端输出

5、为什么不需要初始化UART?

在QEMU的 virt 平台中:

  • UART控制器默认已配置为 8数据位、无校验、1停止位(8N1)
  • 波特率设置为 115200bps
  • 这些默认配置适用于大多数简单应用,因此无需额外初始化

在真实硬件上,通常需要先配置LCR(线路控制寄存器)、IER(中断使能寄存器)等:

assembly 复制代码
# 真实硬件上的UART初始化示例
li t0, 0x10000000     # UART基地址

# 设置波特率为115200
li t1, 0x00           # 除数寄存器值(对于115200bps)
sw t1, 0(t0)          # DLL (除数锁存器低位)
sw t1, 1(t0)          # DLM (除数锁存器高位)

# 配置为8N1模式
li t1, 0x03           # 8数据位, 1停止位, 无校验
sw t1, 3(t0)          # LCR (线路控制寄存器)

6、字符如何显示到终端?

整个数据流向如下:

  1. CPU执行 sb t1, 0(t0) 指令
  2. 数据被写入内存地址 0x10000000
  3. QEMU检测到对该地址的写操作
  4. QEMU模拟UART控制器的行为,将数据转换为字符
  5. QEMU将字符输出到宿主系统的终端

7、扩展知识:处理UART状态

在更复杂的应用中,需要检查UART状态以确保数据成功发送:

assembly 复制代码
# 带状态检查的UART发送函数
uart_putc:
    li t0, 0x10000000      # UART基地址
    li t1, 0x10000005      # LSR (线路状态寄存器)地址

wait_tx_ready:
    lb t2, 0(t1)           # 读取LSR
    andi t2, t2, 0x20      # 检查THRE位(bit 5)
    beqz t2, wait_tx_ready # 如果THRE=0,继续等待

    sb a0, 0(t0)           # 发送字符
    ret

总结

add.s 程序中,通过向 0x10000000 地址写入数据,实际上是利用了QEMU模拟的UART控制器的内存映射I/O特性。这种方式直接、高效,适用于简单的输出需求。在实际开发中,根据硬件平台的不同,可能需要更复杂的初始化和错误处理逻辑。