# Running Linux 6.18 on stm32h750
> Github: https://github.com/rota1001/stm32h7-linux
這個專題,我會在僅有 1MB SRAM 的 STM32H750VBT6 平台上,運行 linux 6.18.7。原訂計畫是要加一塊 PSRAM,但出了一點意外,最後還是在只有 1MB SRAM 情況下做了這個專題。
:::warning
這是個還在進行的專案,目前做到成功啟動和排程且可以執行簡單的 user program 了,但還沒有成功運行 busybox。不過 RAM 已經有點不足了,busybox 也要 100 多 KB,要想想辦法。最近頗忙,最後面的部份等這禮拜比完賽再補。
:::
<!-- 故障:D12、B11 --> ``
## 使用 qemu 進行模擬
由於硬體還沒到,還有方便 debug 的緣故,我決定使用 qemu 對 stm32h750 進行有限度的模擬(只模擬 UART 與記憶體讀寫),修改的 qemu 版本是 10.2.0。
### 第一次的編譯
```shell
$ wget https://download.qemu.org/qemu-10.2.0.tar.xz
$ tar xvf qemu-10.2.0.tar.xz
```
如果只想編譯我們選擇的東西的話,可以用 `--without-default-devices` 參數,以下是編譯命令:
```shell
$ ./configure --target-list=arm-softmmu --without-default-devices
$ ninja -C build
```
這樣編出來,如果去看看他有哪些機器的話,會發現是空的:
```shell
$ build/qemu-system-arm -M help
Supported machines are:
none empty machine
```
### 創建開發板與 SOC
在 [How to add a new architecture to QEMU](https://fgoehler.com/blog/adding-a-new-architecture-to-qemu-01/) 這篇文章中說明了怎麼為 QEMU 引入一個新的架構的機器,然而在這裡我只需要創建開發板和 SOC 就好了,CPU 的部份 qemu 裡面已經實現了。
SOC 的部份就是定義微控制器中的東西,包括 USART、RCC、SPI、記憶體映射等,但在這裡我只實現 USART 與記憶體映射,實際做了什麼可以看看這個 [commit](https://github.com/rota1001/stm32h7-linux/commit/2a5bac10fea4a43303ca70e4a065f1e68ffdb0b1)。
QEMU 使用一個叫做 QOM(QEMU Object Model)的模式來進行物件導向程式設計,使用 `TypeInfo` 結構體定義一個類別,包括名稱、親代類別、類別大小、建構子與類別初始化函式等:
```cpp
static const TypeInfo stm32h750_soc_info = {
.name = TYPE_STM32H750_SOC,
.parent = TYPE_SYS_BUS_DEVICE,
.instance_size = sizeof(STM32H750State),
.instance_init = stm32h750_soc_initfn,
.class_init = stm32h750_soc_class_init,
};
```
此外需要使用一個結構體來定義這個類別中的成員,其中開頭會是這個類別繼承自哪個類別,如以下就是建立了一個叫做 `STM32H750State` 的類別,來儲存這個微控制器所要儲存的所有狀態,包括記憶體、CPU、USART 和時鐘,而它繼承自 `SysBusDevice` 類別:
```cpp
struct STM32H750State {
SysBusDevice parent_obj;
ARMv7MState armv7m;
Stm32l4x5UsartBaseState usart1;
MemoryRegion flash;
MemoryRegion dtcmram;
...
MemoryRegion flash_alias;
Clock *sysclk;
};
```
建構子的部份會去呼叫各個成員類別的建構子:
```cpp
static void stm32h750_soc_initfn(Object *obj)
{
STM32H750State *s = STM32H750_SOC(obj);
object_initialize_child(obj, "armv7m", &s->armv7m, TYPE_ARMV7M);
object_initialize_child(obj, "usart1", &s->usart1, TYPE_STM32L4X5_USART);
s->sysclk = qdev_init_clock_in(DEVICE(s), "sysclk", NULL, NULL, 0);
}
```
在類別初始化的部份會設定一個叫 `realize` 的函式,每個類別都會有一個這樣的函式,它做的事情是去初始化每個成員,譬如說設定記憶體地址、USART 地址或是遞迴的呼叫更多 `realize` 之類的。
`stm32h750_soc_realize` 的部份有點多,只挑部份敘述,初始化 flash 使用 `memory_region_init_rom`,而初始化 ram 使用 `memory_region_init_ram`:
```cpp
memory_region_init_rom(&s->flash, OBJECT(dev_soc), "flash", FLASH_SIZE, &error_fatal);
memory_region_add_subregion(system_memory, FLASH_BASE, &s->flash);
memory_region_init_ram(&s->dtcmram, OBJECT(dev_soc), "dtcmram", DTCMRAM_SIZE, &error_fatal);
memory_region_add_subregion(system_memory, DTCMRAM_BASE, &s->dtcmram);
...
```
USART 部份是將 USART1 連接到第 0 個 serial port,再呼叫 USART 對應類別的 realize:
```cpp
DeviceState *dev = DEVICE(&s->usart1);
qdev_prop_set_chr(dev, "chardev", serial_hd(0));
if (!sysbus_realize(SYS_BUS_DEVICE(&s->usart1), errp))
return;
```
這個 USART 我沒有自己寫的原因是因為我發現 STM32L4x5 的 USART 暫存器的排列與 STM32H750 的排列是相同的,而 QEMU 中有 STM32L4x5 的 USART 實作,就不自己寫了。這裡也可以觀察一下 QEMU 中是怎麼實現暫存器的寫入的。在 [`stm32l4x5_usart_base_write`](https://github.com/rota1001/stm32h7-linux/blob/006a13852110f7f6db65b8a16cc73267892ac682/qemu-10.2.0/hw/char/stm32l4x5_usart.c#L462) 中可以看到,QEMU 將暫存器的寫入變成了一個 callback function,在其中判斷要對哪個暫存器寫入什麼值,再進行對應的操作,譬如用 USART 進行 putchar 的時候,會去對 `TDR` 進行寫入,此時它會將 TXE bit 清除,呼叫 `usart_transmit` 進行輸出,當確認 CR1->TE 是開啟的狀態時,就會使用對應的 chardev 進行輸出:
```cpp
switch (addr) {
...
case A_TDR:
s->tdr = value;
s->isr &= ~R_ISR_TXE_MASK;
usart_transmit(NULL, G_IO_OUT, s);
...
```
然後是可以使用 `create_unimplemented_device` 來映射未實現的裝置,讓韌體中對未實現裝置對應的暫存器做寫入時,不會跳 hardfault,以下是 RCC 的部份:
```cpp
create_unimplemented_device("RCC", 0x58024400, 0x400);
```
創建 SOC 完後要創建一塊開發板,實際上這塊開發板上只有這塊微控制器,沒有其他週邊了,就創建一個繼承自 `MachineState` 的類別,其中有一個成員是上述的 `STM32H750State` 類別,在初始化函式中呼叫子類別建構子與 realize,接上時鐘即可。
### 編譯與測試
修改 `hw/arm/Kconfig`:
```diff
+config STM32H750_SOC
+ bool
+ select ARM_V7M
+ select UNIMP
+ select STM32L4X5_USART
+
+config STM32H750_BOARD
+ bool
+ default y
+ depends on TCG && ARM
+ select STM32H750_SOC
+ select GENERIC_LOADER
```
修改 `hw/arm/meson.build`:
```diff
+arm_common_ss.add(when: 'CONFIG_STM32H750_SOC', if_true: files('stm32h750_soc.c'))
+arm_common_ss.add(when: 'CONFIG_STM32H750_BOARD', if_true: files('stm32h750_board.c'))
```
在 `configs/devices/arm-softmmu/default.mak` 中設定 config 來編譯必要原始碼:
```
CONFIG_ARM_V7M=y
CONFIG_STM32H750_SOC=y
CONFIG_STM32H750_BOARD=y
```
然後編譯:
```shell
$ ./configure --target-list=arm-softmmu --without-default-devices
$ ninja -C build
```
測試部份就寫了個簡單的 helloworld 來測試,可以使用以下命令來執行:
```shell
$ qemu-10.2.0/build/qemu-system-arm -machine stm32h750 -s \
-kernel firmware.bin -serial stdio -display none
```
它就成功輸出東西了。
### 完整啟動 Linux 核心
下面[在開發板上驗證](https://hackmd.io/@rota1001/stm32h750-linux#第一支使用者程式)後,我又回過頭來推進軟體模擬進度,讓 Linux 核心可以完整的在我 patch 過的 QEMU 上跑起來。
首先是使用 gdb script 去 hook 一些函式,跳過一些我沒有實現的週邊初始化。
這之後,我發現他的行為是開始執行 `/init` 之後,就卡住了,這是因為 timer interrupt 沒有觸發,於是我在 SOC 中加入了 timer,這個 timer 和 STM32F2 的 timer 是一樣的,可以直接用,但加了之後還是在同樣的地方卡住。此時,我發現如果在 gdb 裡面用寫入 `NVIC_ISPR1` 的方式觸發 IRQ 會進 scheduler,而且如果去看 timer 的數值會發現他的數字是有增加的,代表問題是在 timer 不會觸發 IRQ。
於是我在 QEMU 裡去監控 `stm32f2xx_timer_read` 與 `stm32f2xx_timer_read` 兩個函式,看看 Linux 核心在啟動過程中對 timer 的暫存器做了哪些讀寫。發現他在卡住前對 `CCR1` 與 `DIER` 這兩個暫存器做了寫入。
STM32F2 的 timer 行為是這樣的,`ARR` 和 `CRR1` 是他的兩個暫存器,每次數到 `ARR` 會歸零重新數。`DIER` 的其中兩個 bit 是 `UIE` 和 `CC1IE`,可以分別控制數到 `ARR` 和 `CRR1` 的時候會不會觸發中斷。
在 QEMU 的實作中,只有實現 `ARR` 的中斷,而沒有實現 `CRR1` 的部份,於是我將它實現了,然後它就正常啟動了,且成功的開始執行使用者程式:
```
[ 1.181620] devtmpfs: mounted
[ 1.190161] Freeing unused kernel image (initmem) memory: 12K
[ 1.191654] This architecture does not have kernel memory protection.
[ 1.193469] Run /init as init process
Hello from userspace! (with write)
```
### gdb script
我寫了一個 gdb script 來輔助我做 kernel debug,做了以下幾件事:
- 自動載入所有執行檔的所有區段到特定位置
- 跳過我指定的函式
- 連上 gdb server
實作細節就不講了。
## 硬體到了,但是...
本來預計使用的 PSRAM 是 APS6404L,它最高時脈可以到 133MHz 且支援 QSPI 的讀寫模式。然而,實際開始做時發現,STM32H750 的 QSPI 在 Memory Map 模式的時候是唯獨的,也就代表著它不能當 RAM 來使用。當然如果只是要把東西跑起來的話,實際上可以做到讓它寫入時跳 hardfault,再用 Indirect Mode 去進行寫入,然而這很明顯是不能被接受的事情。所以我的決定是:就試試看只用 1MB 的 RAM 吧!
稍微 google 了一下,找到了這篇 [Spreading the disease: Linux on microcontrollers](https://elinux.org/images/c/ca/Spreading.pdf),他只使用了 256KB 的 RAM 就將 Linux 跑起來了,雖然沒有公佈實作細節,但讓我知道這是可能的了。
<!-- https://elinux.org/images/2/2b/Elce11_hart.pdf -->
## 手做 Bootloader
我看了一下 [afboot-stm32](https://github.com/mcoquelin-stm32/afboot-stm32),發現自己寫個 bootloader 其實不難,而且 u-boot 有點肥,就自己寫了。要做的事情就只是初始化時鐘和 QSPI,然後跳到 kernel image 的入口點就好,上次[跑 DOOM](https://hackmd.io/@rota1001/stm32-doom) 的時候就做過了,不多贅述。
要注意的是,跳到 XIP image 的入口點的時候要代參數,其中第三個參數是 device tree(dtb)所在的地址,dtb 是什麼後面會講:
```cpp
void (*kernel)(unsigned long, unsigned long, unsigned long)
```
## Linux 運行驗證
### 第一次的編譯
我使用的核心版本是 Linux 6.18.7,是目前最新的穩定版本:
```shell
$ wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.18.7.tar.xz
$ tar xvf linux-6.18.7.tar.xz
```
參考 [Running Linux 6.14 on stm32f429](https://hackmd.io/@yawu/HkZ2pm22yx),先用以下的方法去編了一個 Linux 核心:
```shell
$ make ARCH=arm stm32_defconfig
$ make ARCH=arm menuconfig
```
menuconfig 的部份要設定 DRAM 的基址、DRAM 的大小 和 XIP image base,以下是我的設定:
- System Type -> DRAM Base Address: 0x24000000
- System Type -> DRAM SIZE: 0x80000
- Boot options -> XIP Kernel Physical Location: 0x90000000
然後編譯:
```shell
$ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabi- -j$(nproc)
```
它編譯出來的 image 會在 `arch/arm/boot/xipImage`,我們可以看看 vmlinux 各區段的大小:
```shell
$ arm-linux-gnueabi-size vmlinux
text data bss dec hex filename
2818596 305889 100504 3224989 31359d vmlinux
```
可以看到 .data 加上 .bss 差不多 400KB,其實挺多的。
### Device Tree
如果針對不同的硬體週邊的組合,都要在核心中用一份程式碼來實現的話,會造成核心中出現一堆垃圾程式碼,譬如說對於某個特定機器來說,他的 USART 在哪裡,他有哪些記憶體之類的。Device Tree 就是把這些沒必要的細節從核心中抽離,核心只要在啟動時去讀取對應的 Device Tree 就好了。
Device Tree 可以分為原始碼形式的 dts 與 binary 形式的 dtb,在編譯 linux 核心時同時會把原始碼形式的 dts 轉換成 binary 形式的 dtb,而核心在執行期間要讀取的是 binary 形式的 dtb。上述 bootloader 中需要的地址其實就是 dtb 載入到記憶體中之後的地址。
這裡就是去寫一個 stm32h750vbt6 的 dts,這部份學會語法再模仿其他 dts 就寫得出來了,不多贅述。
### 在 qemu 中啟動
qemu 中可以使用 loader 在啟動時將特定 binary 載入到特定的地址,不過要先修改 `hw/arm/Kconfig` 加入特定功能:
```diff
config STM32H750_BOARD
bool
default y
depends on TCG && ARM
select STM32H750_SOC
+ select GENERIC_LOADER
```
接下來可以用以下的命令去啟動 qemu:
```shell
$ qemu-10.2.0/build/qemu-system-arm -machine stm32h750 -s \
-kernel $(BOOTLOADER_BIN) -serial stdio -display none \
-device loader,file=linux-6.18.7/arch/arm/boot/xipImage,addr=0x90000000 \
-device loader,file=linux-6.18.7/arch/arm/boot/dts/st/stm32h750vbt6.dtb,addr=0x90400000
```
因為我將 dtb 載入到 0x90400000 這個地址,所以可以在 bootloader 中這樣呼叫 kernel:
```cpp
kernel(0, ~0UL, 0x90400000);
```
預料之中的,沒有成功啟動,但有成功的跳到核心的進入點了,且 dtb 正確載入了(用記憶體判斷的),是個好預兆。
於是直接上 gdb 看看發生了什麼事,看到它 panic 了,原因是記憶體不足:
```
► 0 0x90000e9e vpanic+334
1 0x90000f2a panic+26
2 0x902a6f18 __memblock_alloc_or_panic+32
3 0x90195642 __unflatten_device_tree+82
4 0x902add00 unflatten_device_tree+78
5 0x902a10ce setup_arch+328
6 0x9029ef7c start_kernel+46
7 0x0 None
```
而且這是在很初期就 panic 了,大概是跑到[這個位置](https://github.com/torvalds/linux/blob/master/init/main.c#L1027)。
### 使用 tinyconfig
我先用 tinyconfig 建立一個很小的核心 config,再把 stm32_defconfig 裡的東西放進去,custom_config 是我把 stm32_defconfig 和一些我在 menuconfig 裡設定的東西融合起來所寫出的東西。
```shell
$ make ARCH=arm tinyconfig
$ ./scripts/kconfig/merge_config.sh -m .config ./custom_config
$ make ARCH=arm olddefconfig
$ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabi- -j$(nproc)
```
再看看大小,加起來降到 100KB 左右:
```shell
$ arm-linux-gnueabi-size vmlinux
text data bss dec hex filename
1140728 83837 17488 1242053 12f3c5 vmlinux
```
另外發現的一件事情是,在設定 config 的時候會設定一個 DRAM_BASE,而 .data 和 .bss 預設會從 DRAM_BASE + 0x8000 開始長,可以透過修改 `arch/arm/Makefile` 來修改設定:
```diff
- textofs-y := 0x00008000
+ textofs-y := 0x0
```
### SPARSEMEM_EXTREME 記憶體模型
做了以上事情之後,會發現記憶體仍然不足,這是因為我在 dts 裡面還只有設定一塊記憶體,也就是 AXISRAM:
```
memory@24000000 {
device_type = "memory";
reg = <0x24000000 0x80000>;
};
```
以下要去啟用其他塊記憶體。
Linux 中的 [Physical Memory Model](https://docs.kernel.org/mm/memory-model.html) 可以大致分為 FLATMEM 與 SPARSEMEM(其實這只是其中的兩種設定,只是概念上我想可以這樣分類),FLATMEM 需要初始記憶體是一整塊連續的,相對應的好處是實體記憶體位置(PFN)與它所對應的 page 結構體可以直接使用偏移量計算出來。然而,我們沒有那麼長的連續記憶體,如果使用 FLATMEM 模式的話,它會將記憶體區塊與記憶體區塊間的空隙都用一些沒用的 page 結構體來描述。
舉個實際的例子,如果在 0x24000000 有大小為 0x80000 的 AXISRAM、在 0x30000000 有大小為 0x48000 的 SRAM1,在 0x24080000 到 0x30000000 之間會用 page 結構體來填滿,每個 page 結構體是 64 bytes 可以表示一個 page(4KB),所以總共會多花 (0x30000000-0x24080000)/(4*1024)*64B,也就是差不多 3MB 的空間,很明顯這不是個可行的方案。所以,我轉而使用 SPARSEMEM 模式。
SPARSEMEM 模式不要求初始記憶體是連續的,而是把初始記憶體分成好幾個 sections,每個 section 內部是連續的,所以很適合有多塊不連續記憶體的裝置。然而 SPARSEMEM 有個缺點是他有多少個 section 是寫死的,我們實際上不會用到那麼多,所以有了另一個 Memory Model,叫做 SPARSEMEM_EXTREME。
在 SPARSEMEM_EXTREME 中,section 是動態分配的,所以不會佔那麼多空間,以下說明如何配置它,首先設定以下 config:
```
CONFIG_SPARSEMEM_MANUAL=y
CONFIG_SPARSEMEM=y
# CONFIG_SPARSEMEM_STATIC is not set
CONFIG_SPARSEMEM_EXTREME=y
CONFIG_ARCH_SPARSEMEM_ENABLE=y
# CONFIG_FLATMEM is not set
```
這樣做之後,會發現做完 `make ARCH=arm olddefconfig` 之後,`CONFIG_SPARSEMEM_EXTREME=y` 的設定不會套用,讀一下 `arch/arm/Kconfig` 之後會知道原因:
```
config ARCH_SPARSEMEM_ENABLE
def_bool !ARCH_FOOTBRIDGE
select SPARSEMEM_STATIC if SPARSEMEM
```
```
config SPARSEMEM_EXTREME
def_bool y
depends on SPARSEMEM && !SPARSEMEM_STATIC
```
看以上這兩條設定會發現在邏輯上 `CONFIG_SPARSEMEM_EXTREME` 是絕對不可能設成 `y` 的,於是我做了以下修改:
```diff
config ARCH_SPARSEMEM_ENABLE
def_bool !ARCH_FOOTBRIDGE
- select SPARSEMEM_STATIC if SPARSEMEM
```
成功啟用 SPARSEMEM_EXTREME 之後修改 dts 並進行測試,發現在 `bootmem_init` 時記憶體不夠:
```
► 0 0x90000b94 vpanic+268
1 0x90000c2c kmalloc_array_noprof.constprop
2 0x90111b12 __populate_section_memmap+58
3 0x90111bfc sparse_init_nid+148
4 0x90111cd2 sparse_init+114
5 0x9010d690 bootmem_init+44
6 0x9010d788 paging_init+36
7 0x9010d276 setup_arch+156
```
這是因為它預設的 section 太大了,可以在 `arch/arm/include/asm/sparsemem.h` 中看到:
```cpp
#define MAX_PHYSMEM_BITS 36
#define SECTION_SIZE_BITS 28
```
第一個數字代表的是最大的實體記憶體有幾個 bit,第二個數字代表一個 section 有幾個 bit。28 的話代表一個 section 是 256 MB,所以 page 結構體需要的空間是 $2^{28}/4096\times 64$ bytes,也就是 4 MB,這很明顯又太大了。
這個案例裡,最高的記憶體地址是 `0x38000000`,只要 30 bits 就足夠了,SECTION_SIZE_BITS 的下限取決於 subsection 的大小 21 bits,為了只修改極小量的程式碼,我就暫時設 21 bits。
```diff
- #define MAX_PHYSMEM_BITS 36
- #define SECTION_SIZE_BITS 28
+ #define MAX_PHYSMEM_BITS 30
+ #define SECTION_SIZE_BITS 21
```
這時候要去調整一下 `ARCH_FORCE_MAX_ORDER` 這個設定,它可以決定核心可以分配的最大連續 page 數是 2 的多少次方個,預設是 10,代表最多一次可以分配 $2^{10}\times 4$ KB 的空間,也就是 4 MB。這個數字不能超過 section 的大小,所以我把它調整成了 4。
做完調整後,下一個卡在的點就不是記憶體問題了:
```
► 0 0x9000310a __invalid_entry+62
1 0x900034dc unwind_frame+96
2 0x900034dc unwind_frame+96
3 0x2400c8e0 stm32_dmamux_driver+20
```
而且更重要的是,如果把斷點下在 `schedule` 的話,會發現它成功的開始排程了:
```
► 0x900bc6a4 <schedule> push {r4, r5, r7, lr}
0x900bc6a6 <schedule+2> ldr r5, [pc, #0x40] R5, [schedule+68]
0x900bc6a8 <schedule+4> add r7, sp, #0 R7 => 0x24053f78 (0x24053f78 + 0x0)
0x900bc6aa <schedule+6> ldr r4, [r5] R4, [__current]
0x900bc6ac <schedule+8> ldr.w r3, [r4, #0x170] R3, [0x24048770]
```
## 在開發板上測試
由於一些週邊要在 QEMU 上實現的成本太高了,我開始直接到開發板上測試,軟體模擬部份配合 gdb script 跳過一些會出問題的部份來輔助 debug。
我發現之前沒有輸出訊息是因為我 config 裡面沒有把 printk 和 early_printk 開啟,於是開啟後就可以看到由 USART1 輸出的訊息了:
```
[ 1.383449] Out of memory and no killable processes...
[ 1.394809] Kernel panic - not syncing: System is deadlocked on memory
[ 1.408505] ---[ end Kernel panic - not syncing: System is deadlocked on memory ]---
```
看起來是記憶體不足,調了一些參數後就解決了,現在看起來成功的到達要找 init 程式去跑的步驟了:
```
[ 1.131774] Run /sbin/init as init process
[ 1.141600] Run /etc/init as init process
[ 1.151503] Run /bin/init as init process
[ 1.161402] Run /bin/sh as init process
[ 1.171173] Kernel panic - not syncing: No working init found. Try passing init= option to kernel. Se.
[ 1.197423] ---[ end Kernel panic - not syncing: No working init found. Try passing init= option to k-
```
下一步就是要想辦法弄個 rootfs 上去,但 RAM 已經有點不夠了,要想辦法繼續壓縮核心或是看看 user program 有沒有辦法 XIP。
<!-- Device Drivers >
Memory Technology Device (MTD) support>
<*> Direct char device access to MTD devices
<*> Caching block device access to MTD devices (產生 /dev/mtdblockX)
Mapping drivers for chip access >
<*> Support for ROM on MTD
CONFIG_BLOCK=y
CONFIG_MISC_FILESYSTEMS=y
CONFIG_ROMFS_FS=y
```diff
+show_mem(0, NULL);
panic("No working init found. Try passing init= option to kernel. "
"See Linux Documentation/admin-guide/init.rst for guidance.");
```
-->
## rootfs
### 再壓一些空間
我一開始選用的是 initramfs,這是需要載入進 RAM 的,所以需要再壓一些空間。看看 boot log 會發現有一塊 128KB 的低位空間被忽略了:
```diff
[ 0.000000] OF: fdt: Ignoring memory block 0x20000000 - 0x20020000
```
這是因為它低於我在 config 裡面設定的 `CONFIG_DRAM_BASE` 而在 [early_init_dt_add_memory_arch](https://github.com/torvalds/linux/blob/5fd0a1df5d05ad066e5618ccdd3d0fa6cb686c27/drivers/of/fdt.c#L1177) 中被丟棄了。於是只要去改一下 `DRAM_BASE`,再修改一下 `textofs-y` 就能同時滿足可以使用那 128KB 的空間和將 .data 與 .bss 區段載入到 0x24000000 起始的記憶體中。
```diff
-CONFIG_DRAM_BASE=0x24000000
-CONFIG_DRAM_SIZE=0x80000
+CONFIG_DRAM_BASE=0x20000000
+CONFIG_DRAM_SIZE=0x20000
```
然而,我發現 128KB 其實很適合放 .bss 和 .data,去看 `System.map` 之後,發現快可以壓進 128KB(0x20000 bytes)了,而將 .bss 和 .data 移到 128KB 的區塊就意味著我們在 memory pool 中一開始有更大的連續區塊(512KB):
```
24020fc8 B _end
```
所以我開始找哪裡可以壓,發現 STM32 預設最大可以有 9 個 USART,所以用了固定長度為 9 的陣列來存結構體,共使用了 4KB 的空間:
```
2001faf0 b stm32_ports
20020af8 b base_crng
```
我只要使用一個,就把它改掉了:
```diff
-#define STM32_MAX_PORTS 9
+#define STM32_MAX_PORTS 1
```
再看看 `System.map`,發現裡面有一堆 STM32F4 的相關結構體,這是因為我的 config 是由 stm32defconfig 微調過來的,有些東西沒關掉,於是我將他們去掉了。
```
# CONFIG_MACH_STM32F429 is not set
# CONFIG_MACH_STM32F469 is not set
# CONFIG_MACH_STM32F746 is not set
# CONFIG_MACH_STM32F769 is not set
# CONFIG_PINCTRL_STM32F429 is not set
# CONFIG_PINCTRL_STM32F469 is not set
# CONFIG_PINCTRL_STM32F746 is not set
# CONFIG_PINCTRL_STM32F769 is not set
```
最後壓進 0x20000 了:
```
2001e170 B _end
```
再看看 boot log,所有記憶體都成功的加進來了:
```
[ 0.000000] Zone ranges:
[ 0.000000] Normal [mem 0x0000000020000000-0x000000003800ffff]
[ 0.000000] Movable zone start for each node
[ 0.000000] Early memory node ranges
[ 0.000000] node 0: [mem 0x0000000020000000-0x000000002001ffff]
[ 0.000000] node 0: [mem 0x0000000024000000-0x000000002407ffff]
[ 0.000000] node 0: [mem 0x0000000030000000-0x0000000030047fff]
[ 0.000000] node 0: [mem 0x0000000038000000-0x000000003800ffff]
```
### initramfs
建好一個 rootfs 的目錄之後,使用以下命令可以生出一個 `initramfs.cpio`:
```shell
$ cd rootfs
$ find . | cpio -o -H newc > ../initramfs.cpio
```
在 config 中帶入參數指定 initramfs 的路徑,然後就可以編譯了:
```
CONFIG_INITRAMFS_SOURCE="/home/rota1001/side-project/stm32h7-linux/initramfs.cpio"
```
### 第一支使用者程式
看 boot log 會發現它無法開啟 initial console:
```
[ 1.029492] Warning: unable to open an initial console.
```
追進去 [init/main.c#L1645](https://github.com/torvalds/linux/blob/master/init/main.c#L1645) 看會發現在核心啟動時會去開啟 `/dev/console`,並且將 0、1、2 這三個 file descriptor 都導到上面,而它現在找不到 `/dev/console`:
```cpp
void __init console_on_rootfs(void)
{
struct file *file = filp_open("/dev/console", O_RDWR, 0);
if (IS_ERR(file)) {
pr_err("Warning: unable to open an initial console.\n");
return;
}
init_dup(file);
init_dup(file);
init_dup(file);
fput(file);
}
```
可以手動在 rootfs 中創建 dev 目錄,並且使用 mknod 去創建節點,這樣可以透過 devtmpfs 去和裝置互動。
mknod 要帶入四個參數:
- 創建的節點路徑
- 裝置類型
- major number
- minor number
這些參數可以在 [devices.txt](https://www.kernel.org/doc/Documentation/admin-guide/devices.txt) 裡面找到,於是分別創建 console 與 null:
```shell
$ mkdir -p rootfs/dev
$ sudo mknod rootfs/dev/console c 5 1
$ sudo mknod rootfs/dev/null c 1 3
```
重新打包會發現,`/dev/console` 成功被開啟來了。
下一步是跑一個簡單的程式,它要使用 bFLT 執行檔格式,這是一種 linux 在 NOMMU 的環境下使用的執行檔格式。
要編譯出這個格式的執行檔要先搞工具鏈,以下使用 pre-build 的 binary,好習慣檢查一下 hash:
```shell
$ wget https://toolchains.bootlin.com/downloads/releases/toolchains/armv7m/tarballs/armv7m--uclibc--bleeding-edge-2025.08-1.tar.xz
$ sha256sum armv7m--uclibc--bleeding-edge-2025.08-1.tar.xz
13ebf698a4bfcdbb41d8e619cf76fbeb15224fe7c5239436a7e9c558ed7852db armv7m--uclibc--bleeding-edge-2025.08-1.tar.xz
$ tar xvf armv7m--uclibc--bleeding-edge-2025.08-1.tar.xz
```
編譯執行檔,將它放進 rootfs 中,我先編譯一個[直接用 syscall 輸出一個字串的程式](https://gist.github.com/rota1001/4a5e061653c034ed75c26b1322659e9c):
```shell
$ armv7m--uclibc--bleeding-edge-2025.08-1/bin/arm-linux-gcc \
-mcpu=cortex-m7 -mthumb -static -nostdlib -fno-pic -fno-pie \
-fomit-frame-pointer -Wl,-elf2flt="-s 128" -o init init.c
$ cp init rootfs
```
然後在 device tree 中的 bootargs 中帶入 `rdinit=/init`,啟動核心。
會發現成功啟動了,但是輸出亂碼,這是因為在 [stm32 的 usart driver](https://github.com/torvalds/linux/blob/5fd0a1df5d05ad066e5618ccdd3d0fa6cb686c27/drivers/tty/serial/stm32-usart.c#L1957) 中,初始化一個 console 時設定的 baud rate 預設是 9600,我直接把它改成 115200:
```diff
static int stm32_usart_console_setup(struct console *co, char *options)
{
struct stm32_port *stm32port;
- int baud = 9600;
+ int baud = 115200;
...
```
再次啟動後,成功執行第一個 user program 了:
```
[ 1.065759] Run /init as init process
Hello from userspace! (with write)
```
### romfs
考慮到使用者程式的可移植性,下一步是我要使用 libc 而非直接的 syscall 實現使用者程式。然而,這樣的情況會使得執行檔的體積顯著的增加,以下這個程式編譯出來的大小將近 100KB:
```cpp
#include <stdio.h>
int main() {
printf("Hello from userspace! (with printf)\n");
}
```
可想而知,根本沒有連續的 100KB 可以用:
```
[ 1.104774] Run /init as init process
[ 1.115493] nommu: Allocation of length 102400 from process 1 (init) failed
...
[ 1.353224] binfmt_flat: Unable to allocate RAM for process text/data, errno -12
```
於是接下來要去實現的就是使用者程式的 XIP 執行。
要實現這件事首先要用 romfs 替代 initramfs,因為在 initramfs 中即使使用者程式是 XIP 了還是會佔 RAM 的空間,而 romfs 則是一直在 flash 中的檔案系統,這樣 XIP 才有較大的效益。
開啟 romfs 首先需要掀開一些 config:
```
# CONFIG_BLOCK is not set
CONFIG_MTD=y
# CONFIG_MTD_BLOCK is not set
# CONFIG_MTD_BLKDEVS is not set
CONFIG_MTD_PHRAM=y
CONFIG_MISC_FILESYSTEMS=y
CONFIG_ROMFS_FS=y
CONFIG_ROMFS_BACKED_BY_MTD=y
CONFIG_ROMFS_ON_MTD=y
```
這裡 `MTD_PHRAM` 可以讓我們透過 bootargs 來直接指定格式化為 romfs 的記憶體區塊的地址在哪裡,不在 device tree 裡面建立 block 是因為那需要額外的 driver。於是可以在 bootargs 加入以下參數:
```
phram.phram=romfs,0x90600000,0x80000 root=mtd:romfs rootfstype=romfs init=/init
```
這會將 0x90600000 開始的記憶體以 romfs 的形式解析,並掛載到根目錄上,然後以 `/init` 作為第一個使用者程式。
此時,`init` 仍不是 XIP 的,但可以成功執行了。
### 使用者程式 XIP 執行
XIP 的意思是只要是唯讀的東西都在 flash 中執行,只有 bss 和 data 在 RAM 中執行,要做到這件事必須要以 PIC (Position Independent Code)的方式來編譯,執行過程中,除了唯獨的區域是以 pc 的相對位置取以外,data 與 bss 都是以在 RAM 中放的 GOT table 的相對位置來取,而 GOT table 所在的位置則由固定的暫存器來定義。gcc 預設是使用 r9,但是如果追進去看看 Linux 核心中 flat binary loader 的實現的話,會發現它使用的是 r10,這件事雖然在這裡以一句話帶過,但我用 gdb 追了一整個晚上([arch/arm/include/asm/processor.h#L69](https://github.com/torvalds/linux/blob/master/arch/arm/include/asm/processor.h#L69)):
```cpp
#define start_thread(regs,pc,sp) \
({ \
...
if (IS_ENABLED(CONFIG_BINFMT_ELF_FDPIC) && \
...
} else if (!IS_ENABLED(CONFIG_MMU)) \
regs->ARM_r10 = current->mm->start_data; \
...
})
```
知道這件事後就可以這樣的去進行編譯:
```
armv7m--uclibc--bleeding-edge-2025.08-1/bin/arm-linux-gcc -mcpu=cortex-m7 -mthumb -static -nostdlib -fpic -mpic-register=r10 -fomit-frame-pointer -Wl,-elf2flt="-s 128" -o init init.c
```
然後就成功了。