05.2 裸機程式設計與交叉編譯#
學習目標#
讀完本節後,你應該能夠:
- 解釋為什麼嵌入式系統需要「交叉編譯」,以及裸機(bare-metal)與一般應用程式的差別。
- 使用
riscv64-unknown-elf-gcc編譯出不含任何作業系統相依性的可執行檔。 - 撰寫 linker script 控制程式碼與資料在記憶體中的位址配置。
- 撰寫
crt0.S啟動碼,完成從重置向量到 C 語言main()之間的必要初始化。 - 完成「Hello World 透過 UART 輸出」的完整端到端流程,並在 FPGA SoC 上執行。
5.2.1 交叉編譯與裸機的概念#
什麼是交叉編譯?#
你的開發機(x86_64 或 Apple Silicon Mac)與目標機(RISC-V 32 位元 SoC)指令集不同。在開發機上產生目標機可執行的程式碼,就是交叉編譯(cross compilation):
flowchart LR
A[C 語言原始碼\nmain.c] -->|riscv-gcc| B[RISC-V 機器碼\nhello.elf]
S[組合語言\ncrt0.S] -->|riscv-gcc| B
L[Linker Script\nlink.ld] -->|控制位址| B
B -->|objcopy| H[HEX 檔\nmemory.hex]
H -->|readmemh| F[FPGA BRAM]什麼是裸機(bare-metal)?#
一般桌上型程式依賴作業系統:載入器負責把程式放進記憶體、C 標準庫的 printf 透過系統呼叫寫到終端機。裸機程式沒有 OS——CPU 重置後直接執行你的第一條指令,所有底層工作(初始化堆疊、清 BSS、設定 UART)都要自己來。
這也是為什麼裸機程式設計是理解「軟硬體介面」的最佳練習:你被迫面對程式到底放在記憶體哪裡、堆疊長什麼樣、CPU 重置後從哪裡開始跑這些平時被抽象層隱藏的細節——正是計算機結構課程的核心。
安裝工具鏈#
# macOS
brew tap riscv-software-src/riscv
brew install riscv-tools
# 或直接安裝 gcc 工具鏈
brew install riscv64-elf-gcc
# Linux (Ubuntu)
sudo apt install gcc-riscv64-unknown-elf
# 官方預編譯版(推薦,含所有工具)
# https://github.com/riscv-collab/riscv-gnu-toolchain/releases
確認安裝:
riscv64-unknown-elf-gcc --version
riscv64-unknown-elf-objdump --version
5.2.2 Hello World:C 語言部分#
UART 驅動(memory-mapped I/O)#
依 05.1 的位址映射:
// uart.h
#ifndef UART_H
#define UART_H
#include <stdint.h>
#define UART_BASE 0x10000000u
#define UART_DATA (*(volatile uint32_t *)(UART_BASE + 0x0))
#define UART_STATUS (*(volatile uint32_t *)(UART_BASE + 0x4))
#define UART_TX_BUSY (1u << 0)
static inline void uart_putc(char c) {
while (UART_STATUS & UART_TX_BUSY)
; // 輪詢等待 TX idle
UART_DATA = (uint32_t)(unsigned char)c;
}
static inline void uart_puts(const char *s) {
while (*s)
uart_putc(*s++);
}
#endif
關鍵點:
volatile告訴編譯器此位址的讀寫不可被優化掉,因為它對應實際硬體,每次存取都有副作用。- 輪詢式(polling)驅動最簡單,缺點是浪費 CPU 時間;更進階可用中斷驅動(第 06 章主題)。
主程式#
// main.c
#include "uart.h"
int main(void) {
uart_puts("Hello, RISC-V World!\r\n");
uart_puts("This is a bare-metal program.\r\n");
while (1) // 裸機程式不能 return!
;
return 0; // never reached
}
注意最後的 while(1):沒有 OS 可以返回,若 main 返回,CPU 會繼續執行後面未定義的記憶體內容,行為不可預測。
5.2.3 Linker Script:程式的地圖#
為什麼需要自訂 linker script?#
預設的 linker script 假設程式會被 OS 載入到特定虛擬位址。裸機程式必須明確告訴 linker:
- 程式碼(
.text)從哪個位址開始(必須是 CPU 重置後的取指位址,即 05.1 的PROGADDR_RESET)。 - 資料(
.data)、未初始化資料(.bss)、堆疊(stack)、堆(heap)各自放哪。
完整的 linker script#
/* link.ld */
OUTPUT_ARCH(riscv)
ENTRY(_start)
MEMORY
{
RAM (rwx) : ORIGIN = 0x00000000, LENGTH = 64K
}
SECTIONS
{
.text : {
KEEP(*(.text.init)) /* crt0.S 必須放在最前面 */
*(.text .text.*)
} > RAM
.rodata : ALIGN(4) {
*(.rodata .rodata.*)
} > RAM
.data : ALIGN(4) {
__global_pointer$ = . + 0x800; /* GP-relative 定址最佳化 */
*(.sdata .sdata.*)
*(.data .data.*)
} > RAM
.bss : ALIGN(4) {
__bss_start = .;
*(.sbss .sbss.*)
*(.bss .bss.*)
*(COMMON)
__bss_end = .;
} > RAM
. = ALIGN(16);
__heap_start = .; /* 堆從 bss 之後開始 */
. = ORIGIN(RAM) + LENGTH(RAM) - 4096;
__stack_top = .; /* 堆疊頂端:BRAM 最後 4KB 前 */
}
解讀:
ENTRY(_start):ELF 的進入點符號(供除錯器與 objdump 參考;真正影響硬體的是重置位址)。KEEP(*(.text.init)):防止 linker 把 crt0.S 的程式碼當垃圾移除,並確保它位於.text最開頭(即位址 0)。__global_pointer$:RISC-V 的 gp 相對定址最佳化,-msmall-data-limit` 會用到,可大幅縮小程式碼。__bss_start/__bss_end:crt0 要用這兩個符號把 BSS 清零。__stack_top:crt0 要把 sp 指向這裡。
5.2.4 crt0.S:啟動碼#
為什麼需要啟動碼?#
C 語言執行有幾個隱含前提:
- sp 暫存器指向合法堆疊——否則函式呼叫立即崩潰。
- BSS 區已清零——C 語言保證未初始化的全域變數為 0。
- 已初始化的資料在正確位置——
.data中的初始值必須能被lw讀到。
這些都不是 C 語言能自己完成的,所以必須用組合語言寫一段啟動碼(crt0 = C runtime zero)。
完整的 crt0.S#
# crt0.S
.section .text.init
.globl _start
_start:
# 1. 關閉所有中斷(本 SoC 暫無中斷控制器,保險起見)
csrw mie, zero
csrw mstatus, zero
# 2. 設定全域指標 gp(用於 gp-relative 定址)
.option push
.option norelax # 否則 linker 會把這行優化成 mv gp, gp
la gp, __global_pointer$
.option pop
# 3. 設定堆疊指標
la sp, __stack_top
# 4. 清除 BSS 區(C 語言規定未初始化全域變數 = 0)
la t0, __bss_start
la t1, __bss_end
1: bgeu t0, t1, 2f
sw zero, 0(t0)
addi t0, t0, 4
j 1b
2:
# 5. 呼叫 main
call main
# 6. main 不應返回;若返回就進入無窮迴圈
3: wfi # wait for interrupt(或用 j 3b)
j 3b
逐段解釋:
| 段落 | 作用 | 若省略的後果 |
|---|---|---|
csrw mie/mstatus | 清除中斷使能與 MIE 位元 | 若 SoC 有殘留中斷使能會誤觸發 |
la gp + norelax | 設定 gp,.option norelax 防止 linker 優化掉這行 | sdata 存取會算錯位址 |
la sp, __stack_top | 設堆疊 | 第一個 call 就寫到未知記憶體 |
| 清 BSS | 未初始化全域變數歸零 | 未初始化全域變數值為隨機 |
call main | 進入 C 世界 | — |
深入理解
call main:RISC-V 的call展開為auipc + jalr,可跳到 ±2GB 範圍。若程式全在低位 BRAM,其實jal main就夠,但用call更穩健(未來程式搬到 0x8000_0000 的 DRAM 時也不會壞)。
5.2.5 完整編譯流程#
Makefile#
CROSS = riscv64-unknown-elf-
CFLAGS = -march=rv32im -mabi=ilp32 -mcmodel=medany \
-O2 -Wall -ffreestanding -nostdlib \
-msmall-data-limit=8
LDFLAGS = -T link.ld -Wl,--gc-sections
all: hello.hex
hello.elf: crt0.S main.c uart.h
$(CROSS)gcc $(CFLAGS) crt0.S main.c -o hello.elf \
$(LDFLAGS) -Wl,-Map=hello.map
hello.hex: hello.elf
$(CROSS)objcopy -O verilog --verilog-data-width=4 \
hello.elf hello.hex
disasm: hello.elf
$(CROSS)objdump -d hello.elf > hello.lst
clean:
rm -f *.elf *.hex *.map *.lst
重要旗標:
-march=rv32im -mabi=ilp32:目標 32 位元 RISC-V,I+M 指令集(若你的 CPU 沒有 M 擴充,改成rv32i並加-mdivide-traps或軟體除法)。-ffreestanding:告訴編譯器這是獨立環境,不依賴標準庫的完整語意。-nostdlib:不連結 C 標準庫(printf等不可用;需要時可自行提供最小實作或用 newlib-nano +-specs=nano.specs,但必須實作_write等系統呼叫樁)。-ffunction-sections -fdata-sections+--gc-sections:讓 linker 移除未使用的函式,縮小程式。
產生 HEX 給 BRAM#
objcopy -O verilog 產生 @800 開頭格式的 hex 檔。若我們的 BRAM 以 word(32-bit)定址,需配合 --verilog-data-width=4 並確認位址對齊。另一個常見做法是 binary 格式再轉換:
riscv64-unknown-elf-objcopy -O binary hello.elf hello.bin
xxd -e -g4 hello.bin | awk '{print $4}' > hello.hex # 每行一個 32-bit word
檢查產物#
riscv64-unknown-elf-objdump -d hello.elf | head -30
riscv64-unknown-elf-readelf -h hello.elf # 看 Entry point
riscv64-unknown-elf-size hello.elf # 看 text/data/bss 大小
預期的開頭反組譯(位址 0 開始):
00000000 <_start>:
0: 30047073 csrw mstatus, zero
4: 30401073 csrw mie, zero
...
5.2.6 把程式放進 FPGA:三種方法#
方法一:$readmemh(最簡單,本書採用)#
BRAM 模組加入初始化:
module bram #(parameter WORDS = 16384) (
input wire clk,
input wire we,
input wire [13:0] addr,
input wire [31:0] di,
output reg [31:0] do
);
reg [31:0] mem [0:WORDS-1];
initial $readmemh("hello.hex", mem);
always @(posedge clk) begin
if (we) mem[addr] <= di;
do <= mem[addr]; // 同步讀
end
endmodule
缺點:改程式需重新合成(數分鐘)。適合初學與小程式。
方法二:updatemem(Xilinx,免重合成)#
先合成一個含「空 BRAM」的 bitstream,之後只替換 BRAM 內容:
updatemem -meminfo mem.mmi -bit soc.bit -data hello.mem -proc dummy -out soc_prog.bit
需準備 .mmi 描述 BRAM 在 bitstream 中的位置,略複雜但迭代速度快得多。
方法三:韌體載入器(bootloader)#
SoC 先跑一段固定韌體,透過 UART 接收新程式寫入 RAM 後跳過去。這是真實系統的做法(如 U-Boot),留作進階習題。
實測#
make # 編譯出 hello.hex
yosys -p "read_verilog soc_top.v picorv32.v bram.v uart_mmio.v; synth_xilinx -Top soc_top -family xc7; write_json soc.json"
nextpnr-xilinx --chipdb arty-35t --json soc.json --write soc.fasm --freq 100
# (產生 bitstream 的後續步驟見 05.1)
openfpgaloader -b arty soc.bit
screen /dev/ttyUSB1 115200
終端機輸出:
Hello, RISC-V World!
This is a bare-metal program.
恭喜——你從 Verilog 一路到 C 語言,完成了一台「真正」的電腦。
本章小結#
- 交叉編譯是因為開發機與目標機指令集不同;裸機程式沒有 OS,一切初始化自己來。
- Linker script 是「程式的地圖」:決定
.text/.data/.bss/堆疊的位址,ENTRY與.text.init的位置必須對應硬體重置位址。 crt0.S完成 C 語言的隱含前提:設 sp、設 gp、清 BSS,然後call main。- 關鍵編譯旗標:
-march=rv32i(m)、-ffreestanding、-nostdlib、自訂-T link.ld。 - 程式進 BRAM 的三種方式:$readmemh`(簡單)、updatemem(免重合成)、bootloader(真實系統做法)。
實作題#
- 修改訊息:在
main.c中加入你的學號與姓名,透過 UART 輸出;用objdump觀察字串常數被放進哪個 section(提示:.rodata)。 - 觀察啟動碼:故意移除 crt0.S 中「清 BSS」的迴圈,宣告一個未初始化的全域陣列並印出其內容,觀察是否為非零垃圾值;解釋原因。
- 堆疊成長實驗:寫一個深度遞迴函式(如計算費氏數),用
objdump觀察sp的變化範圍,並估算每層遞迴消耗多少堆疊;若超過 4KB 會發生什麼事? - 自製 printf:實作一個極簡
my_printf(const char *fmt, ...),至少支援%d、%x、%s、%c,需要用<stdarg.h>的va_list;用它重寫 Hello World。 - Bootloader:撰寫一段載入器韌體:透過 UART 以 XMODEM 或簡單行協定接收 hex 資料,寫入 BRAM 的 0x1000 起始處,然後用
jalr跳過去執行。驗證你可以「不用重新合成」就更新程式。