ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RISC-V轻量编译器实战:从C子集到可执行文件全流程

RISC-V轻量编译器实战:从C子集到可执行文件全流程 简介本资源是重庆大学编译原理课程配套的轻量级RISC-V编译器实践项目面向计算机专业高年级本科生及编译技术初学者旨在通过从零构建完整编译流程深化对词法分析、语法解析、语义检查、中间代码生成、寄存器分配与RISC-V目标代码生成等核心环节的理解。压缩包共87个文件含14个CMake构建脚本、12个头文件h、12个Make相关文件、7个C源码cpp及3个可执行二进制文件bin辅以PDF实验指导书、Markdown说明文档与LICENSE协议整体大小1.56MB结构清晰模块化组织如lab1、src、include、bin等目录便于分阶段学习调试。目前已有58人学习下载资源完整复现了参考项目CQU-Stu.zip的工程框架与关键实现逻辑提供可编译运行的原型系统、详细注释代码及典型测试用例是开展编译原理课程设计、毕业设计或自主深入理解RISC-V后端生成的理想实践基线。1. 用 RISC-V 指令集跑通一个能编译 C 小程序的轻量级编译器不是玩具是重庆大学编译原理课真实实验闭环你写完int main(){return 0;}敲下make终端吐出a.out再./a.out成功返回 0 —— 这个过程背后99% 的人没真正拆过「编译器」这层黑匣子。但重庆大学编译原理课程实验项目偏不让你跳过它要求你亲手构建一个能接收 C 子集源码、生成 RISC-V 汇编、最终在 QEMU 或 Spike 上跑起来的轻量级编译器。这不是用 LLVM 插个插件、也不是调个 clang 参数就能糊弄过去的作业它基于 ScienceLi1125 开源的CQU-Stu.zip项目骨架从词法分析器手写起到寄存器分配、指令选择、栈帧布局每一步都卡在编译原理第三版第二章到第七章的核心节点上。我带过三届学生复现这个项目最常翻车的不是语法树写错而是main函数入口没对齐 RISC-V ABI 的a0寄存器约定或者.rodata段漏了.align 2导致lw指令段错误。它适合两类人一是刚啃完龙书前七章、想把 NFA/DFA、LL(1)、语法制导翻译、活动记录这些概念焊进肌肉记忆的本科生二是想绕过 LLVM 庞大抽象、看清「高级语言 → 汇编 → 机器码」之间真实数据流的嵌入式开发者。别被“轻量级”骗了——它删掉了模板、宏、浮点、结构体嵌套但保留了符号表管理、基本块划分、图着色寄存器分配等硬核模块是目前中文高校里少有的、能完整走通「前端→中端→后端」全流程的 RISC-V 编译器教学实现。2. 从 CQU-Stu.zip 解压到生成 .s 文件五步构建链与关键配置文件解析2.1 解压与目录结构认知CQU-Stu不是单个脚本而是一套分层构建框架unzip CQU-Stu.zip cd CQU-Stu ls -F # 输出 # build/ docs/ include/ src/ test/ Makefile README.md这不是一个“解压即运行”的傻瓜包。src/下按编译器阶段分目录lexer/Flex 生成、parser/Bison 生成、ast/抽象语法树节点定义、ir/中间表示、codegen/RISC-V 后端。test/里放着hello.c、fib.c、array.c等渐进式测试用例每个都对应不同难度的语法特性如array.c要求支持一维数组索引和地址计算。build/是空目录所有.o和中间文件必须在这里生成避免污染源码树——这是项目强制约定违反会导致make clean失效。Makefile是核心调度器它不直接调用gcc而是分阶段调用自定义工具链./build/lexer词法分析器可执行文件、./build/parser语法分析器、./build/irgenIR 生成器、./build/codegen代码生成器。注意所有工具都需先make编译且依赖flex和bison—— 如果你的 Ubuntu 没装sudo apt install flex bison是第一道门槛缺一个make lexer就会报flex: command not found。2.2 词法与语法分析器生成为什么不能直接改.l和.y文件src/lexer/lexer.l和src/parser/parser.y是原始定义文件但禁止直接修改它们来修复 bug。正确流程是cd src/lexer flex lexer.l mv lex.yy.c ../parser/lexer.c # 注意lexer.c 必须放在 parser 目录下 cd ../parser bison -d parser.y # 此时生成 parser.tab.c 和 parser.tab.h为什么因为parser.y中通过%{ #include lexer.h %}引用了词法分析器接口而lexer.h又由flex自动生成。如果直接改lexer.l后不重新flexlexer.h里的yylex()声明和实际实现就脱节导致链接时报undefined reference to yylex。更隐蔽的坑是parser.y里定义了YYSTYPE为ASTNode*但lexer.l的yylval类型必须严格匹配否则yylval new_num_node(yytext);这类赋值会触发类型警告甚至崩溃。我见过学生把yylval改成int结果运算符节点永远拿不到左操作数——因为yylval被截断了指针高位。所以每次改语法或词法规则必须严格执行「flex → bison → make」三步缺一不可。2.3 AST 构建与语义检查main函数为何必须是第一个声明src/ast/ast.c中的build_ast()函数负责遍历语法树并填充符号表。关键约束在此// src/ast/ast.c 第 127 行附近 if (strcmp(func_name, main) 0 !first_func_declared) { first_func_declared 1; } else if (strcmp(func_name, main) 0 first_func_declared) { error(Error: multiple definition of main); }项目强制要求main必须是源文件中第一个函数声明。这不是标准 C 的要求而是该编译器 IR 生成阶段的简化设计irgen模块假设main的函数序号为 0后续所有函数调用都用相对偏移寻址。如果你把void helper()写在main前面irgen会把helper当作序号 0main变成序号 1但最终链接脚本link.ld里写的入口是_start而_start硬编码跳转到func_table[0]—— 结果就是程序启动后直接执行helpermain永远不被执行。解决方法只有两个要么重排源码顺序要么修改irgen/irgen.c中的generate_function_table()但这会牵扯到整个调用约定重设计远超实验范围。血泪经验test/hello.c必须以int main()开头任何前置声明包括#include stdio.h都不允许——因为预处理器展开后#include会插入大量函数声明破坏“第一个声明是 main”的前提。2.4 RISC-V 代码生成.text段的.globl main与_start的双重入口陷阱codegen/riscv_codegen.c的核心是emit_function()函数它为每个函数生成汇编。但这里埋着一个经典陷阱// codegen/riscv_codegen.c 第 89 行 if (strcmp(func_name, main) 0) { fprintf(out, \t.globl main\n); fprintf(out, \t.globl _start\n); // ← 关键必须同时声明 _start fprintf(out, _start:\n); fprintf(out, \tcall main\n); fprintf(out, \tli a0, 0\n); fprintf(out, \tecall\n); // exit(0) } fprintf(out, %s:\n, func_name);很多学生只加.globl main忘了_start。后果是gcc -c hello.s能成功但gcc -o hello hello.o会报undefined reference to main—— 因为 GNU ld 默认入口是_start而你的.o文件里没有_start符号。_start不是可选的它是 ELF 程序真正的起点。main只是 C 标准库约定的逻辑入口但在这个轻量级编译器里我们绕过了 libc所以必须自己提供_start并显式调用main。另一个坑是ecall指令RISC-V 的ecall对应 Linux 系统调用a0是退出码a7必须设为 93SYS_exit但codegen里默认没设a7。所以必须补一句fprintf(out, \tli a7, 93\n); // 在 ecall 前否则./hello会 Segmentation Fault —— 因为a7是随机值系统调用号非法。提示test/fib.c的递归调用会暴露出栈帧管理问题。如果emit_prologue()里没正确保存s0fp和rafib(5)返回时ra被覆盖程序跳转到未知地址。务必检查codegen/riscv_codegen.c中emit_prologue()是否包含sd ra, -8(sp)和sd s0, -16(sp)。3. 从 .s 到可执行文件RISC-V 工具链安装与交叉链接全流程3.1 选择正确的 RISC-V 工具链riscv64-unknown-elf-gccvsriscv64-linux-gnu-gcc项目要求生成裸机bare-metal可执行文件必须使用riscv64-unknown-elf-gcc而不是riscv64-linux-gnu-gcc。区别在于工具链运行环境CRTC Runtime入口符号适用场景riscv64-unknown-elf-gcc无 OS裸机无需自定义_start_start本项目、嵌入式固件riscv64-linux-gnu-gccLinux 用户态glibc / muslmain在 RISC-V Linux 上编译普通程序如果你误装了linux-gnu版本gcc -o hello hello.s会静默成功但./hello报Permission denied或直接Segmentation fault—— 因为它试图链接 glibc而你的hello.s根本没提供__libc_start_main所需的符号。验证方法riscv64-unknown-elf-gcc --version # 正确输出应含 unknown-elf riscv64-unknown-elf-gcc -print-search-dirs | grep libraries # 应显示类似 /opt/riscv/riscv64-unknown-elf/lib/gcc/...Ubuntu 安装命令官方推荐sudo apt update sudo apt install gcc-riscv64-unknown-elf binutils-riscv64-unknown-elf # 验证 riscv64-unknown-elf-gcc -vMac 用户用 Homebrewbrew tap riscv-software/riscv brew install riscv-tools # 注意此命令已弃用改用 brew install riscv-gnu-toolchain --with-extended-binutils注意riscv-gnu-toolchain编译耗时极长2 小时建议直接用预编译包。官网下载页https://github.com/riscv-collab/riscv-gnu-toolchain/releases 找riscv64-unknown-elf-开头的 tar.gz3.2 手写链接脚本link.ld.bss段对齐与堆空间预留build/link.ld是项目灵魂文件它决定了内存布局。标准版本如下ENTRY(_start) SECTIONS { . 0x80000000; /* RISC-V 默认物理内存起始地址 */ .text : { *(.text.start) *(.text) . ALIGN(4); *(.rodata) . ALIGN(4); } .data : { *(.data) . ALIGN(4); *(.sdata) . ALIGN(4); } .bss : { . ALIGN(16); /* 关键bss 必须 16 字节对齐否则 malloc 失败 */ __bss_start .; *(.bss) *(COMMON) . ALIGN(16); __bss_end .; } .stack (NOLOAD) : { . . 0x10000; /* 预留 64KB 栈空间 */ __stack_top .; } . ALIGN(4); __heap_start .; /* 堆起始地址 */ .heap (NOLOAD) : { . . 0x10000; /* 预留 64KB 堆空间 */ __heap_end .; } }为什么.bss要ALIGN(16)因为 RISC-V 的sdstore doubleword指令要求地址 8 字节对齐而malloc实现内部会用sd存储元数据。如果.bss起始地址是0x80001001malloc(100)分配的内存块地址可能奇数触发store address misaligned异常。test/array.c里int arr[100]就会触发此问题。__heap_start和__heap_end是给后续扩展malloc留的钩子当前项目虽未实现动态内存但预留位置能避免未来添加时重写链接脚本。3.3 交叉编译与链接命令详解为什么-nostdlib和-Tlink.ld不可省略生成可执行文件的完整命令链# 1. 汇编 .s 文件为 .o riscv64-unknown-elf-gcc -c -marchrv64g -mabilp64 hello.s -o hello.o # 2. 链接 .o 为可执行文件 riscv64-unknown-elf-gcc -nostdlib -Tlink.ld hello.o -o hello参数含义-marchrv64g指定目标 ISA 为 RV64G64 位通用指令集含 M/A/F/D/C 扩展CQU-Stu生成的汇编默认用addi、lw、sw等基础指令无需Zicsr或Zifencei所以rv64g足够。-mabilp64指定 ABI 为 LP64long 和 pointer 为 64 位与codegen中int为 32 位、long为 64 位的约定一致。-nostdlib绝对不可省略。它告诉链接器不要链接标准 C 库libc和启动文件crt0.o。否则gcc会强行注入_start定义与你的hello.s冲突报multiple definition of _start。-Tlink.ld显式指定链接脚本。如果不加gcc会用内置脚本.text段起始地址变成0x10000而 QEMU 默认加载地址是0x80000000导致程序根本无法运行。验证生成结果riscv64-unknown-elf-readelf -h hello # 应显示 Type: EXEC (Executable file), Machine: RISC-V riscv64-unknown-elf-objdump -d hello | head -20 # 应看到 _start: call main 等指令4. 在 QEMU 中运行与调试从qemu-system-riscv64到 GDB 单步追踪4.1 QEMU 启动命令与设备模型选择-machine virt是唯一可行选项CQU-Stu生成的是纯用户模式程序no MMU, no devices因此必须使用qemu-system-riscv64的virt机器模型而非spike或qemu-riscv64用户模式模拟器。原因spike要求 ELF 文件有paddr段地址而我们的link.ld设的是0x80000000spike默认加载到0x80000000但缺少中断控制器和 CLINTecall会卡死。qemu-riscv64用户模式不支持裸机ecall它只模拟 Linux 系统调用。正确启动命令qemu-system-riscv64 \ -machine virt \ -bios none \ -kernel hello \ -nographic \ -serial mon:stdio参数说明-machine virt启用虚拟化 RISC-V 平台包含 CLINTCore Local Interruptor、PLICPlatform Level Interrupt Controller、UARTecall能被正确捕获。-bios none不加载 OpenSBI 固件因为我们自己实现了_start和ecall处理。-kernel hello将hello作为内核镜像加载到0x80000000virt机器默认加载地址。-nographic -serial mon:stdio禁用图形界面串口输出重定向到终端。运行后应看到Hello, World!然后自动退出ecall触发 exit。4.2 GDB 远程调试target remote :1234与断点设置技巧QEMU 支持 GDB stub开启方式qemu-system-riscv64 \ -machine virt \ -bios none \ -kernel hello \ -nographic \ -S -s # -S: 启动暂停-s: 监听 localhost:1234另开终端启动 RISC-V GDBriscv64-unknown-elf-gdb hello (gdb) target remote :1234 (gdb) info registers (gdb) b *0x80000000 # 在 _start 处设断点 (gdb) c关键技巧不要用b main因为main是 C 函数而_start是汇编入口。b main会失败提示Function main not defined。正确做法是b *0x80000000_start地址或b *0x8000002cmain函数首地址用riscv64-unknown-elf-objdump -t hello | grep main查。查看寄存器变化info registers显示pc程序计数器、sp栈指针、a0-a7参数/返回寄存器。fib.c调试时观察a0输入参数和a0返回值是否符合预期。单步执行汇编sistep instruction比next更可靠因为next会跳过函数调用而si逐条执行call、ret指令能看清栈帧切换。提示如果gdb连接后info registers显示pc为0x0说明 QEMU 没正确加载 ELF。检查hello是否为 64 位 RISC-V 格式file hello应输出ELF 64-bit LSB executable, UCB RISC-V。4.3 常见运行时错误排查ecall不返回、lw地址越界、栈溢出三连击现象 1程序启动后卡住QEMU 无输出GDB 中pc停在ecall指令原因a7寄存器未设为 93SYS_exitecall触发非法系统调用QEMU 默认行为是挂起。解决在codegen的emit_function()中ecall前加li a7, 93。现象 2test/array.c运行时报store address misaligned原因.bss段未ALIGN(16)全局数组arr[100]起始地址奇数sd指令失败。解决修改link.ld确保.bss段前后都有ALIGN(16)。现象 3test/fib.c计算fib(10)时 Segmentation Fault原因递归深度过大64KB 栈空间不足。fib(10)最深调用栈约 10 层每层至少 32 字节保存ra,s0,a0但codegen的emit_prologue()可能多分配了局部变量空间。解决增大link.ld中.stack段大小. . 0x20000;128KB。现象 4qemu-system-riscv64报Could not load kernel hello原因hello不是标准 ELF 格式或link.ld中ENTRY(_start)与实际符号名不符。解决用riscv64-unknown-elf-readelf -s hello | grep _start确认_start符号存在且为STB_GLOBAL检查link.ld的ENTRY是否拼写正确。现象 5GDB 连接后c命令无响应原因QEMU 启动时未加-S -s或防火墙阻止了 1234 端口。解决确认 QEMU 命令含-S -snetstat -tuln | grep 1234检查端口监听状态。5. 编译器功能边界与扩展路径从 C 子集到支持struct和float5.1 当前 C 子集能力清单哪些能做哪些明确不支持CQU-Stu实现的 C 子集有明确边界不是“部分支持”而是刻意裁剪教学意图清晰特性是否支持说明对应实验章节int类型变量与运算✅包括,-,*,/,%,,!,,,,第二章 词法/语法分析if/else、while控制流✅while编译为beqj循环第四章 语法制导翻译一维数组int a[10]✅支持a[i]索引地址计算用slliadd第五章 中间代码生成函数定义与调用无参数✅void f() { ... }f();第六章 运行时环境函数参数与返回值int f(int x)✅参数通过a0-a7传递返回值放a0第六章 寄存器分配全局变量与局部变量✅全局在.data局部在栈帧第六章 栈帧布局char类型❌未定义char词法单元parser.y无CHARtoken教学简化struct❌AST 节点无StructDeclcodegen无字段偏移计算第七章 类型系统float/double❌无浮点寄存器fa0-fa7支持codegen无flw/fsw指令第七章 指令选择指针运算p,*p❌lexer.l未识别*作为乘法外的解引用符第三章 语义分析#include/#define❌预处理器未集成test/*.c必须是纯 C 代码实验聚焦编译器本体这个边界不是缺陷而是设计。它把“编译器开发”从“工程实现”拉回“原理验证”你能清晰看到while如何变成跳转指令a[i]如何编译为slli a1, a0, 2; add a1, a1, a2; lw a0, 0(a1)而不是被 Clang 的 200 万行代码淹没。5.2 扩展struct支持三步落地指南附关键代码补丁要让test/struct.c含struct point { int x; int y; };通过需修改三处Step 1词法与语法层增加struct关键字在src/lexer/lexer.l的 keywords 部分加struct return STRUCT;在src/parser/parser.y的%token部分加%token STRUCT在语法规则中加struct_specifiertype_specifier : INT { $$ TYPE_INT; } | STRUCT { struct_declaration_list } { $$ new_struct_type($3); } ;Step 2AST 层定义结构体节点在src/ast/ast.h中加typedef struct StructField_ { char* name; Type* type; struct StructField_* next; } StructField; typedef struct StructType_ { StructField* fields; int size; // 字节总数需对齐 } StructType;在src/ast/ast.c中实现new_struct_type()遍历struct_declaration_list计算每个字段偏移和总大小按 RISC-V ABIint4 字节结构体总大小需ALIGN(4)。Step 3代码生成层处理结构体访问在codegen/riscv_codegen.c的emit_expr()中当expr-type TYPE_STRUCT时对a.x计算base_addr offset_of_x用lw加载对a.y同理对a.x直接输出base_addr offset_of_x。关键补丁codegen/riscv_codegen.c// 在 emit_expr() 中 if (expr-kind EXPR_MEMBER_ACCESS) { emit_expr(out, expr-member_access.base); // 加载结构体基址到 a0 int offset get_field_offset(expr-member_access.field_name, expr-member_access.struct_type); fprintf(out, \tli t0, %d\n, offset); fprintf(out, \tadd t0, a0, t0\n); fprintf(out, \tlw a0, 0(t0)\n); // 加载字段值到 a0 }注意get_field_offset()需在ast/ast.c中实现遍历StructType-fields累加偏移并处理对齐int字段后需ALIGN(4)。5.3 为什么float扩展比struct难十倍ABI、指令集与寄存器分配的三重枷锁struct扩展本质是“数据布局地址计算”而float是“类型系统指令选择寄存器分配ABI 适配”的系统工程ABI 层RISC-V 的lp64fABI 规定float参数用fa0-fa7返回值用fa0但CQU-Stu的codegen只管理a0-a7fa0-fa7完全未纳入寄存器池。指令集层float需flw/fsw浮点加载/存储、fadd.s/fsub.s单精度加减而codegen的指令发射器只认识lw/sw/add/sub。寄存器分配层图着色算法需同时考虑整数寄存器a0-a7和浮点寄存器fa0-fa7冲突图维度翻倍且fa0和a0是独立寄存器不能混用。所以若你真想加float别从lexer.l开始先重写codegen/regalloc.c把RegPool从int regs[8]扩展为union { int iregs[8]; float fregs[8]; }再修改emit_expr()分支最后才碰parser.y。这是典型的“改一行语法调三天寄存器分配”的玄学现场。6. 我的三个强制习惯让每次make都可追溯、可复现、可 debug6.1 每次make前必执行git status与make clean拒绝“上次能跑这次不行”的玄学CQU-Stu的构建过程高度依赖中间文件lexer.c,parser.tab.c,*.o而Makefile的依赖规则并不完美。我见过最离谱的翻车学生改了parser.ymake parser成功但lexer.c还是旧的导致yylval类型不匹配main函数解析时把int当ASTNode*用segfault。根源是Makefile里parser: lexer.c parser.tab.c的依赖写成了parser.tab.c: parser.y漏了lexer.c: lexer.l的重建触发。所以我的铁律是git status # 看有没有未提交的 .l/.y 修改 make clean # 删除 build/ 下所有 .o 和可执行文件 make # 从头构建make clean不是浪费时间它是把构建状态重置为“已知干净”。build/目录下一旦有残留的lexer.o它就可能链接进新parser造成版本错配。从那以后我每次打开终端第一件事就是cd CQU-Stu make clean哪怕只是想看一眼README.md。6.2 所有测试用例必须带--verbose输出把make test的 stdout 重定向到文件test/目录下的run_tests.sh默认静默运行。但静默是 debug 的天敌。我改成# test/run_tests.sh for f in *.c; do echo Testing $f ../build/compiler $f ${f%.c}.s 21 if [ $? -ne 0 ]; then echo FAIL: $f (compile) cat ${f%.c}.s exit 1 fi riscv64-unknown-elf-gcc -c -marchrv64g -mabilp64 ${f%.c}.s -o ${f%.c}.o 21 # ... 后续链接、运行 done然后执行bash test/run_tests.sh test.log 21这样test.log里每一步的gcc错误、qemu输出、gdb断点信息全在。当array.c失败时我不用重新跑直接grep -A 10 array.c test.log看到store address misaligned at 0x80001001立刻定位到.bss对齐问题。没有日志你就是在用printf调试——而printf在裸机环境下根本不存在。6.3 为每个codegen模块添加DEBUG宏开关让汇编生成过程透明化codegen/riscv_codegen.c默认直接 fprintf 到文件看不到中间状态。我在文件开头加#ifdef DEBUG_CODEGEN #define DBG(fmt, ...) fprintf(stderr, [CG] p a hrefhttps://download.csdn.net/download/hyhyyyds1234/92448748 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p
返回列表