一生一芯笔记

一生一芯概述

“一生一芯”概述 _哔哩哔哩_bilibili

程序的执行和模拟器

freestanding 运行时环境

程序如何结束运行

在正常的环境中,写了一段代码return之后,实际上调用了一个系统调用exit。但是在 freestanding 环境中,没有操作系统支持,根据 C99 手册规定,在 freestanding 环境中结束运行是由用户实现决定的。

5.1.2.1 Freestanding environment

2 The effect of program termination in a freestanding environment is
implementation-defined.

在 qemu-system-riscv64 中的 virt 机器模型中,往一个特殊的地址写入一个特殊的“暗号”即可结束 QEMU

#include <stdint.h>
void _start() {
  volatile uint8_t *p = (uint8_t *)(uintptr_t)0x10000000;
  *p = 'A';
  volatile uint32_t *exit = (uint32_t *)(uintptr_t)0x100000;
  *exit = 0x5555;   // magic number
  _start();         // 递归调用,如果正常退出将不会再次打印A
}

在自制 freestanding 运行时环境上运行 Hello 程序

QEMU 虽然是个开源项目,但还挺复杂,不利于我们理解细节。让我们来设计一个面向 RISC-V 程序的简单 freestanding 运行时环境,我做以下约定。

  • 程序从地址 0 开始执行
  • 只支持两条指令
    • addi 指令
    • ebreak 指令
      • 寄存器 a0=0 时,输出寄存器 a1 低 8 位的字符
      • 寄存器 a0=1 时,结束运行
        • ABI Mnemonic(RISC-V 官方为每个寄存器起个名字)
static void ebreak(long arg0, long arg1) {
  asm volatile("addi a0, x0, %0;"
               "addi a1, x0, %1;"
               "ebreak" : : "i"(arg0), "i"(arg1));
}
static void putch(char ch) { ebreak(0, ch); }
static void halt(int code) { ebreak(1, code); while (1); }

void _start() {
  putch('A');
  halt(0);
}

/** 
 * 这段代码定义了三个函数:ebreak、putch 和 halt。
 * ebreak 函数是一个内联汇编函数,它执行 ebreak 指令。
 * 该指令是 RISC-V 架构中的一条调试指令,可以在调试器的控制下执行。
 * 该函数接受两个参数 arg0 和 arg1,它们将被存储在寄存器 a0 和 a1 中。
 * putch 函数调用了 ebreak 函数,并将第一个参数设为 0,
 * 第二个参数设为函数参数 ch。这样做的目的可能是为了在调试器的控制下输出一个字符。
 * halt 函数调用了 ebreak 函数,并将第一个参数设为 1,
 * 第二个参数设为函数参数 code。这样做的目的可能是为了通知调试器程序已经结束,
 * 并使用 code 作为结束状态。然后,halt 函数进入一个死循环,等待调试器的操作。
 * 最后,_start 函数调用了 putch 函数输出字符 'A',然后调用 halt 函数结束程序 
 */ 
riscv64-linux-gnu-gcc -march=rv64g -ffreestanding -nostdlib -static -Wl,-Ttext=0 \
  -O2 -o prog a.c
  • riscv64-linux-gnu-gcc: 这是 GCC 的可执行文件的名称,表示使用的是 GCC 编译器。riscv64-linux-gnu 是编译器的目标平台,表示生成的代码是针对 RISC-V 架构,运行在 Linux 系统上的二进制文件。

  • -march=rv64g: 这个参数指定了编译器使用的指令集。rv64g 表示使用 RISC-V 架构的 64 位指令集。

  • -ffreestanding: 这个参数指示编译器生成的代码将在 freestanding 运行环境中运行。在 freestanding 运行环境中,程序不会自动链接标准 C 库,也不会自动调用 main 函数。

  • -nostdlib: 这个参数表示编译器不需要链接标准 C 库。

  • -static: 这个参数表示生成的代码是静态链接的。

  • -Wl,-Ttext=0: 这个参数是传递给链接器的,表示设置代码段的起始地址为 0。

  • -O2: 这个参数指示编译器使用优化级别为 2 的优化选项。

  • -o prog: 这个参数指定生成的可执行文件的名称为 prog。

  • a.c: 这是要编译的 C 源文件的名称。

llvm-objdump -d prog

反汇编结果如下:

prog:   file format elf64-littleriscv

Disassembly of section .text:

0000000000000000 <_start>:
       0: 13 05 00 00   li      a0, 0
       4: 93 05 10 04   li      a1, 65
       8: 73 00 10 00   ebreak
       c: 13 05 10 00   li      a0, 1
      10: 93 05 00 00   li      a1, 0
      14: 73 00 10 00   ebreak
      18: 6f 00 00 00   j       0x18 <_start+0x18>

我们约定中没有li指令,但是汇编中却出现了,这是因为li是一条伪指令,它的实际实现依然是addi。如果不使用伪指令可以使用以下命令反汇编:

llvm-objdump -M no-aliases -d prog

结果如下,没有伪指令,只有我们约定的几条指令。

prog:   file format elf64-littleriscv

Disassembly of section .text:

0000000000000000 <_start>:
       0: 13 05 00 00   addi    a0, zero, 0
       4: 93 05 10 04   addi    a1, zero, 65
       8: 73 00 10 00   ebreak
       c: 13 05 10 00   addi    a0, zero, 1
      10: 93 05 00 00   addi    a1, zero, 0
      14: 73 00 10 00   ebreak
      18: 6f 00 00 00   jal     zero, 0x18 <_start+0x18>

YEMU 指令如何执行

ISA 手册定义了一个状态机。

  • 状态集合 S = {<R, M>}

    • R = {PC, x0, x1, x2, …}
      • RISC-V 手册 -> 2.1 Programmers’Model for Base Integer ISA
      • PC = 程序计数器 = 当前执行的指令位置
    • M = 内存
      • RISC-V 手册 -> 1.4 Memory

激励事件:执行 PC 指向的指令
状态转移规则:指令的语义 (semantics)
初始状态 S0 = <R0, M0>

我们只要把这个状态机实现出来,就可以用它来执行指令了!

用变量实现内存

#include <stdint.h>
uint64_t R[32], PC; // according to the RISC-V manual
uint8_t M[64];      // 64-Byte memory

Q: 为什么不使用 int64_tint8_t?

A: C语言标准规定, 有符号数溢出是undefined behavior, 但无符号数不会溢出

6.5 Expressions
5 If an exceptional condition occurs during the evaluation of an expression (that is,
if the result is not mathematically defined or not in the range of representable
values for its type), the behavior is undefined.
6.2.5 Types
9 A computation involving unsigned operands can never overflow, because a result that
cannot be represented by the resulting unsigned integer type is reduced modulo the
number that is one greater than the largest value that can be represented by the
resulting type.

用语句实现指令的语义

指令周期 (instruction cycle): 执行一条指令的步骤

  • 取指 (fetch): 从 PC 所指示的内存位置读取一条指令
  • 译码 (decode): 按照手册解析指令的操作码 (opcode) 和操作数 (operand)
  • 执行 (execute): 按解析出的操作码,对操作数进行处理
  • 更新 PC: 让 PC 指向下一条指令

状态机不断执行指令,直到结束运行:

#include <stdbool.h>
bool halt = false;

while (!halt) {
  inst_cycle();
}
 31           20 19 15 14 12 11  7 6       0
+---------------+-----+-----+-----+---------+
|   imm[11:0]   | rs1 | 000 | rd  | 0010011 |    ADDI
+---------------+-----+-----+-----+---------+
+---------------+-----+-----+-----+---------+
| 000000000001  |00000| 000 |00000| 1110011 |   EBREAK
+---------------+-----+-----+-----+---------+

一个简单的实现:

void inst_cycle() {
  uint32_t inst = *(uint32_t *)&M[PC];
  if (((inst & 0x7f) == 0x13) && ((inst >> 12) & 0x7) == 0) { // addi
    if (((inst >> 7) & 0x1f) != 0) {
      R[(inst >> 7) & 0x1f] = R[(inst >> 15) & 0x1f] +
        (((inst >> 20) & 0x7ff) - ((inst & 0x80000000) ? 4096 : 0));
    }
  } else if (inst == 0x00100073) { // ebreak
    if (R[10] == 0) { putchar(R[11] & 0xff); }
    else if (R[10] == 1) { halt = true; }
    else { printf("Unsupported ebreak command\n"); }
  } else { printf("Unsupported instuction\n"); }
  PC += 4;
}

NEMU 代码导读

make 项目构

# 显示make踪迹
strace make
# 显示构建过程
make -d
# 显示更详细的构建构过程
make --debug=v
Reading makefiles...
Reading makefile `Makefile'...
Updating goal targets....
 File `all' does not exist.
   File `all' does not exist.
   Looking for an implicit rule for `all'.
   Trying pattern rule with stem `all'.
   Trying implicit prerequisite `all.c'.
   Trying pattern rule with stem `all'.
   Trying implicit prerequisite `all.cc'.
   Trying pattern rule with stem `all'.
   Trying implicit prerequisite `all.C'.
   Trying pattern rule with stem `all'.
   Trying implicit prerequisite `all.cpp'.
   Trying pattern rule with stem `all'.
   Trying implicit prerequisite `all.CPP'.
   Trying pattern rule with stem `all'.
   Trying implicit prerequisite `all.cxx'.
   Trying pattern rule with stem `all'.
   Trying implicit prerequisite `all.CXX'.
   Trying pattern rule with stem `all'.
   Trying implicit prerequisite `all.c++'.
   Trying pattern rule with stem `all'.
   Trying implicit prerequisite `all.C++'.
   No implicit rule found for `all'.
   Finished prerequisites of target file `all'.
 Must remake target `all'.
gcc -o all all.o
Finished prerequisites of target file `all'.
Must remake target `all'.
gcc -o all all.o
Successfully remade target file `all'.
# 只打印命令不执行
make -n
# 输出目标被构建的原因和执行的命令
make --trace

例如,如果您有一个 makefile,其目标 all 依赖于目标 foobar,并且您运行 make --trace all,您可能会看到如下输出:

make[1]: Entering directory '/path/to/project'
gcc -o foo foo.c
make[1]: Leaving directory '/path/to/project'
make[1]: Entering directory '/path/to/project'
gcc -o bar bar.c
make[1]: Leaving directory '/path/to/project'
make[1]: Entering directory '/path/to/project'
gcc -o all foo.o bar.o
make[1]: Leaving directory '/path/to/project'
make -nB  # -B 可以强制 make 构建所有目标,即使它们已经是最新的
make -nB | vim -

在 vim 编辑器中进行二次处理,过滤不需要的信息。

# 只保留 gcc 或 g++开头的行
:%!grep "^\(gcc\|g++\)"

# 将环境变量$NEMU_HOME 所指示字符串替换为$NEMU_HOME
:%!sed -e "s+$NEMU_HOME+\$NEMU_HOME+g"

# 将$NEMU_HOME/build/obj-riscv64-nemu-interpreter 替换为$OBJ_DIR
:%s+\$NEMU_HOME/build/obj-riscv64-nemu-interpreter+$OBJ_DIR+g

# 将-c 之前的内容替换为$CFLAGS
:%s/-O2.*=riscv64/$CFLAGS/g

# 将最后一行的空格替换成换行并缩进两格
:$s/  */\r  /g

调试技巧选将

断言

在 C 程序中使用断言(assert)不会增加额外的内存空间,也不会增加数据段空间。断言是一种在运行时检查程序假设是否为真的方法,当断言失败时,程序会终止执行并显示错误信息。

在 C 语言中,断言通常使用宏来实现。它在编译时被解释为一个简单的条件语句,因此它不会增加程序的内存空间或数据段空间。断言宏的定义通常类似于以下代码:

#include <assert.h>

#define assert(expression) ((void)0)

这里的 expression 是要检查的条件。如果 expression 为假,则 assert() 函数会发出错误消息并终止程序的执行。如果 expression 为真,则 assert() 函数不会产生任何操作,并且被解释为 ((void)0)。这个语句不会增加任何内存或数据段空间。

需要注意的是,当一个程序使用大量的断言时,它可能会对程序的性能产生一些影响,因为每个断言都需要在运行时进行检查。因此,在生产环境中,应该尽可能减少使用断言,并在测试和调试阶段使用它们来确保代码的正
确性。

// nemu/src/isa/riscv64/local-include/reg.h
static inline int check_reg_idx(int idx) {
  IFDEF(CONFIG_RT_CHECK, assert(idx >= 0 && idx < 32));
  return idx;
}

编译器工具 sanitizer

让编译器自动插入 assert, 拦截常见的非预期行为

  • AddressSanitizer - 检查指针越界,use-after-free
  • ThreadSanitizer - 检查多线程数据竞争
  • LeakSanitizer - 检查内存泄漏
  • UndefinedBehaviorSanitizer - 检查 UB
  • 还能检查指针的比较和相减

打开后程序运行效率有所下降

  • 但调试的时候非常值得,躺着就能让工具帮你找 bug
  • man gcc 查看具体用法

使用方法

GCC 提供了多种 Sanitizer 工具,可以帮助开发者在编译时检测和修复常见的编程错误,例如内存泄漏、缓冲区溢出、使用未初始化的变量等。以下是几个 Sanitizer 工具的示例用法:

  1. Address Sanitizer(ASAN):检测内存错误,例如使用已经释放的内存、堆栈和全局缓冲区的溢出和下溢等。

    gcc -fsanitize=address -g <source files> -o <output file>
  2. Undefined Behavior Sanitizer(UBSAN):检测未定义行为,例如除以零、使用未初始化的变量、指针溢出等。

    gcc -fsanitize=undefined -g <source files> -o <output file>
  3. Thread Sanitizer(TSAN):检测并发问题,例如竞争条件、死锁等。

    gcc -fsanitize=thread -g <source files> -o <output file>
  4. Memory Sanitizer(MSAN):检测使用未初始化的内存,例如读取未初始化的内存、使用已释放的内存等。

    gcc -fsanitize=memory -g <source files> -o <output file>

需要注意的是,Sanitizer 工具可能会增加程序的执行时间和内存消耗,并且可能会产生误报,因此在生产环境中应该禁用 Sanitizer 工具。通常情况下,开发者可以在开发和测试阶段启用 Sanitizer 工具,以帮助他们发现和修复代码中的问题。

自顶向下理解程序行为

ftrace - 函数调用层次,理解程序的大体行为
itrace - 指令执行层次,理解指令级别的行为
mtrace - 访存的踪迹
dtrace - 设备访问的踪迹
sdb - 灵活细致地检查客户程序的状态
si - 细粒度的状态转移
info r/x - 检查R/M
监视点 - 捕捉某状态发生变化的时刻

sdb 与 gdb 结合使用

先用 sdb 定位到出错点附近
再用 gdb 观察 NEMU 的细节行为

程序的运行时间都花在了哪里

Linux 的性能分析工具 perf 是一款功能强大的性能分析工具,它可以通过硬件计数器(Hardware counter)或者性能事件(Performance event)来对 Linux 系统的性能进行分析。以下是 perf 工具的安装和使用方法。

安装 perf 工具

在大部分 Linux 发行版中,perf 工具已经预先安装,如果没有预先安装,可以通过以下命令进行安装。

  • Debian/Ubuntu 系统:sudo apt-get install linux-tools-common linux-tools-generic
  • Fedora 系统:sudo dnf install perf
  • CentOS/RHEL 系统:sudo yum install perf

安装完毕之后,可以通过 perf version 命令来检查 perf 版本信息。

编写一个简单的 C 代码

这里我们编写一个简单的 C 代码,用于测试 perf 工具的使用。代码如下:

#include <stdio.h>

int main()
{
    int i, sum = 0;

    for (i = 1; i <= 1000000; i++)
        sum += i;

    printf("sum = %d\n", sum);

    return 0;
}

代码的作用是计算 1 到 1000000 的和。

使用 perf 工具

下面我们使用 perf 工具来对上述代码进行性能分析。假设代码保存在文件 test.c 中。

统计 CPU 周期数
以下命令用于统计程序的 CPU 周期数:

perf stat ./test

输出结果类似于:

Performance counter stats for './test':

          19,23 msec task-clock:u              #    0.988 CPUs utilized          
                0      context-switches:u        #    0.000 K/sec                  
                0      cpu-migrations:u          #    0.000 K/sec                  
              575      page-faults:u             #    0.030 M/sec                  
   64,013,620,231      cycles:u                  #    3.324 GHz                      (49.80%)
   40,010,335,480      instructions:u            #    0.62  insn per cycle           (62.34%)
    9,998,469,566      branches:u                #  518.693 M/sec                    (62.27%)
          763,176      branch-misses:u           #    0.01% of all branches          (62.32%)

     0.019438122 seconds time elapsed

     0.019411000 seconds user
     0.000007000 seconds sys

输出结果中的 cycles 表示 CPU 周期数,instructions 表示指令数,branches 表示分支指令数。其中,cycles 和 instructions 的比例代表了 CPU 的效率,即 IPC(Instructions Per Cycle)。

统计函数调用次数

以下命令用于统计程序中函数的调用次数:

perf record -e cycles -g ./test

这个命令将启动 perf 工具,并使用 -g 选项记录调用关系图。我们还需要使用 sudo 权限运行该命令,以便 perf 工具可以访问系统的硬件计数器。

成为专业码农

  • 要熟悉项目了 -> STFW/RTFM/RTFSC, 尝试理解一切细节
  • 要写代码了
    • 仔细 RTFM, 正确理解需求
    • 编写可读,可维护,易验证的代码 (不言自明,不言自证)
    • 用 lint 工具检查代码
    • 进行充分的测试
    • 添加充分的断言
  • 要调试了
    • 默念“机器永远是对的/未测试代码永远是错的”
    • sanitizer, trace, printf, gdb, …
  • 平时 -> 用正确的工具/方法做事情
  • 感到不爽了 -> 找正确的工具/搭基础设施

总线选讲

定义

广义上讲总线就是一个通信系统,以下这些都属于广义的总线概念:TCP/IP, 以太网,网线,RTL 信号,系统调用。

主动发起通信的叫 master,响应通信的叫 slave。