在嵌入式開發和底層系統編程領域,裸機開發是一項極具挑戰性但又至關重要的任務。想象一下,在沒有操作系統支持的情況下,讓 C 語言的標準庫函數,如printf
正常工作,這聽起來是不是很有趣又充滿挑戰?今天,我們就來深入探索如何利用 Newlib 在裸機系統上實現這一目標,以 RISC-V 平臺為例,揭開裸機編程的神秘面紗。
軟件抽象與 C 標準庫:從常規系統到裸機的轉變
在日常使用的電腦系統,比如 Mac 或 Linux 筆記本上運行printf
函數,背后有著一套復雜的機制。應用程序調用printf
,這個函數通常是動態鏈接的,經過多層 C 函數調用后,最終會觸發操作系統內核的系統調用。內核會通過不同的子系統來處理輸出,涉及終端和偽終端的相關操作,最后將printf
的輸出呈現在屏幕上。而且,printf
還要依據提供的模板對輸出字符串進行格式化處理,這一整套流程涉及眾多軟件抽象層。
然而,裸機系統與常規系統大不相同。在裸機環境下,大多數這樣的抽象層并不存在,軟件棧要簡單得多。在裸機上進行 C 編程時,C 函數下方沒有任何支持。在常規系統中,進程可以通過系統調用(由軟件中斷實現)將輸出交給內核處理,但在裸機上沒有內核可交付,可我們仍希望printf
之類的函數能夠工作,最好是能輸出到像通用異步收發傳輸器(UART)這樣的簡單 I/O 設備上。這時候,Newlib 就發揮作用了。
Newlib:裸機 C 標準庫的構建利器
你或許熟悉 GNU 的glibc
、musl
等 C 標準庫,但如果想在裸機上啟用 C 標準庫,Newlib 絕對值得關注。從本質上講,Newlib 并不是一個完整的 C 標準庫,而是一個構建定制、精簡 C 標準庫的工具包。
Newlib 將 C 標準庫的實現簡化為幾個具有清晰接口的基本原語,這些原語可以作為獨立函數來實現。像printf
和malloc
這樣更復雜的函數會調用這些原語。例如,我們需要實現_write
原語,它的作用是向輸出流寫入單個字符,Newlib 會在這個基礎上構建printf
函數,從而實現更復雜的輸出功能。
此外,Newlib 還提供了一些預制的實現。在某些配置下,你甚至可以將底層平臺指定為 Linux,這時 Newlib 提供的實現會像glibc
一樣進行系統調用。而在極簡配置中,Newlib 會以最小化的形式提供所有原語,這些原語要么返回零,要么拋出錯誤。開發者可以根據應用程序的實際需求,選擇實現自己關心的構建模塊,其余部分則依賴默認實現。
交叉編譯工具鏈:連接不同平臺的橋梁
在深入了解如何使用 Newlib 之前,我們需要先掌握交叉編譯工具鏈的概念。交叉編譯是指在一個平臺上編譯代碼,生成另一個平臺可執行的指令。例如,從 x86_64/Linux 平臺編譯代碼,使其能在 ARM64/Mac 上運行。
在 Linux 平臺下,情況更為復雜,因為不同的 Linux 發行版可能使用不同的 C 標準庫。從使用一種標準庫的平臺編譯到使用另一種標準庫的同一架構平臺,也屬于交叉編譯。比如,從 x86_64/Linux/glibc 平臺編譯到 x86_64/Linux/musl 平臺。甚至從一個版本的glibc
編譯到另一個版本,同樣屬于交叉編譯,像從 x86_64/Linux/glibc_v1.0 編譯到 x86_64/Linux/glibc_v1.1。
傳統的構建和使用編譯器的方式,比如使用 GCC,在處理交叉編譯時可能會變得很復雜。不過,我們可以采用一種更便捷的方法來滿足需求。我們需要一個滿足特定要求的工具鏈:能從主機平臺生成 RISC-V 指令,并且在調用 C 標準庫功能時使用 Newlib 庫。
在典型的 Linux 發行版中,安裝的 GCC 或 clang 默認會為運行它的同一平臺進行編譯,即宿主平臺和目標平臺相同,這被稱為本地編譯。當包含<stdio.h>
頭文件并調用printf
函數時,編譯器會從標準位置查找相關文件和實現。例如,在 Debian 系統中,stdio.h
位于/usr/include
目錄,標準 C 庫glibc
的動態鏈接版本位于/lib/x86_64-linux-gnu/libc.so
(實際指向/lib/x86_64-linux-gnu/libc.so.6
?)。
為了進行交叉編譯,我們需要獲取能為目標平臺生成指令的編譯器,為目標平臺設置 C 標準庫的路徑,并確保目標平臺的編譯器知道如何使用該庫。這一系列操作通常較為繁瑣,不過我們可以借助一些自動化工具來簡化流程。
自動化 RISC-V 工具鏈構建
為了簡化在 RISC-V 平臺上使用 Newlib 進行開發的過程,我們可以使用 RISC-V 工具鏈項目。該項目雖然仍需在主機上從源代碼構建所有內容,但會通過腳本自動化處理繁瑣的編排工作,包括編譯器的搭建。
首先,從 GitHub 克隆相關倉庫。需要注意的是,克隆時最好使用--recursive
標志,以避免后續問題,盡管官方說明該標志不是必需的,但在某些系統上不使用可能會出現問題。克隆過程可能會花費較長時間,因為要下載大量源代碼。
克隆完成后,進行配置。例如:./configure --prefix=/opt/riscv-newlib --enable-multilib --disable-gdb --with-cmodel=medany
。這里的prefix
指定了新構建的工具鏈、C 標準庫(這里是 Newlib)等的安裝路徑;enable-multilib
用于啟用針對不同 RISC-V 配置的構建,但會使構建過程變慢;disable-gdb
是因為構建 GDB 時可能會出現問題,所以將其排除在工具鏈之外;with-cmodel=medany
這個參數稍后會詳細解釋,它對 64 位 RISC-V 構建的正常運行很關鍵。
配置完成后,通過make
命令啟動構建過程。這里有個小提示,不要嘗試使用-j16
等參數進行并行構建,可能會導致構建失敗。構建過程會持續較長時間,期間可以做些其他事情。構建完成后,會在指定的prefix
路徑下生成可執行文件、庫文件等。
實現內存和 UART 構建模塊
在擁有了可用的 RISC-V + Newlib 交叉工具鏈后,就可以開始構建 Newlib 的基礎模塊了。先從 UART 相關的代碼入手,創建uart.h
文件:
#ifndef UART_H
#define UART_Hvoid uart_putc(char c);
char uart_getc(void);#endif
接著實現這兩個函數,在這個示例中,針對 QEMU 的 16550A UART 進行操作:
#include "uart.h"
// QEMU UART寄存器地址
#define UART_BASE 0x10000000
#define UART_THR (*(volatile char *)(UART_BASE + 0x00))
#define UART_RBR (*(volatile char *)(UART_BASE + 0x00))
#define UART_LSR (*(volatile char *)(UART_BASE + 0x05))
#define UART_LSR_TX_IDLE (1 << 5)
#define UART_LSR_RX_READY (1 << 0)void uart_putc(char c) {// 等待發送器空閑while ((UART_LSR & UART_LSR_TX_IDLE) == 0);UART_THR = c;// 特殊處理換行符,發送CR+LFif (c == '\n') {while ((UART_LSR & UART_LSR_TX_IDLE) == 0);UART_THR = '\r';}
}char uart_getc(void) {// 等待數據while ((UART_LSR & UART_LSR_RX_READY) == 0);return UART_RBR;
}
接下來是syscalls.c
文件,實現printf
等函數依賴的原語,同時處理輸入操作:
// 省略部分代碼...void* _sbrk(int incr) {extern char _end; // 由鏈接器定義,靜態段結束標志extern char _stack_bottom; // 鏈接器腳本中定義,棧底地址static char *heap_end = &_end;char *prev_heap_end = heap_end;// 計算安全的棧限制,棧從_stack_top向下增長到_stack_bottomchar *stack_limit = &_stack_bottom;// 檢查堆是否會增長到太靠近棧的位置if (heap_end + incr > stack_limit) {errno = ENOMEM;return (void*) -1; // 返回錯誤}heap_end += incr;return (void*) prev_heap_end;
}
應用示例:輸入與輸出
現在來構建一個裸機應用示例。編寫main.c
文件:
#include <stdio.h>int main(void) {printf("Hello from RISC-V UART!\n");char buffer[100];printf("Type something: ");scanf("%s", buffer);printf("You typed: %s\n", buffer);while (1) {}return 0;
}
這個應用會通過 UART 輸出問候語,提示用戶輸入內容,讀取用戶輸入并再次輸出。由于沒有運行在常規的 Shell 環境中,輸入時不會回顯按鍵內容。
還需要一個簡單的 C 運行時文件startup.S
:
.section .text.init
.global _start
_start:la sp, _stack_top# 清空BSS段,使用鏈接器腳本中定義的符號la t0, _bss_startla t1, _bss_end
clear_bss:bgeu t0, t1, bss_donesb zero, 0(t0)addi t0, t0, 1j clear_bss
bss_done:# 跳轉到C代碼call main# 如果main函數返回,進入無限循環j .
最后是鏈接器腳本link.ld
:
OUTPUT_FORMAT("elf64-littleriscv")
OUTPUT_ARCH("riscv")
ENTRY(_start)MEMORY {RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 64M
}SECTIONS {/* 代碼段 */.text : {*(.text.init)*(.text)} > RAM/* 只讀數據段 */.rodata : {*(.rodata)} > RAM/* 已初始化數據段 */.data : {*(.data)} > RAM/* 小的已初始化數據段 */.sdata : {*(.sdata)} > RAM/* BSS段,有明確符號 */.bss : {_bss_start =.; /* 定義BSS段開始符號 */*(.bss)*(COMMON).= ALIGN(8);_bss_end =.; /* 定義BSS段結束符號 */} > RAM/* 小BSS段 */.sbss : {_sbss_start =.;*(.sbss)*(.sbss.*).= ALIGN(8);_sbss_end =.;} > RAM/* 堆起始標記 */.= ALIGN(8);_end =.; /* 堆從這里開始向上增長 *//* 棧從RAM末尾向下增長 */_stack_size = 64K;_stack_top = ORIGIN(RAM) + LENGTH(RAM);_stack_bottom = _stack_top - _stack_size;/* 確保堆和棧不重疊 */ASSERT(_end <= _stack_bottom, "Error: Heap collides with stack")
}
鏈接器腳本負責安排代碼和數據在內存中的位置,確保各個段正確放置,并且堆和棧不會沖突。
關鍵要點與應用運行
在構建工具鏈時,--with-cmodel=medany
這個參數至關重要。由于我們構建的是 64 位 RISC-V 機器代碼,應用程序代碼需要使用能夠處理高地址的內存地址模型。如果沒有這個參數,Newlib 庫可能會使用無法有效處理高地址的 RISC-V 指令,導致鏈接錯誤。
在 GitHub 倉庫中提供了Makefile
來簡化構建和運行過程。運行make debug
命令,它會調用交叉編譯器編譯代碼,并使用 QEMU 進行仿真。Makefile
中的CFLAGS
包含-specs=nosys.specs
,這會讓工具鏈使用 Newlib 的nosys
版本,該版本所有構建模塊默認是存根,返回零或錯誤。鏈接器標志-nostartfiles
表示我們將提供自己的最小 C 運行時。
運行make debug
后,QEMU 啟動,輸入內容并回車,就能看到應用的輸出。同時,debug
目標會生成一個qemu_debug.log
文件,它記錄了 VM 的完整運行軌跡,有助于深入了解printf
等函數的工作原理以及 RISC-V 核心的執行過程。
總結
通過這個示例,我們成功地在裸機平臺上實現了printf
等 C 標準庫函數的功能,讓裸機編程有了更接近在完整內核上編程的體驗。利用 Newlib 定義的構建模塊,我們可以進一步擴展功能,實現文件訪問、更完善的內存管理等。而且,這為在裸機代碼中使用強大的庫提供了可能。盡管在極簡環境下,最終軟件鏡像的大小和指令數量需要考慮,但我們構建的ELF
文件大小為220K
,還算比較合理。希望這篇文章能為你的開發工作提供新的思路和方法,祝大家在裸機編程的世界中探索愉快!
科技脈搏,每日跳動。
與敖行客 Allthinker一起,創造屬于開發者的多彩世界。
- 智慧鏈接 思想協作 -