---
# System prepended metadata

title: Can It Run Doom? 從 Bootloader 到 VGA 的 STM32 裸機移植挑戰

---

# Can It Run Doom? 從 Bootloader 到 VGA 的 STM32 裸機移植挑戰
> repo: https://github.com/rota1001/stm32h7-baremetal-doom
> demo: https://www.youtube.com/watch?v=qiCk467Afwo


這是我第一次移植 DOOM，是我大學要解的成就之一，選用 STM32H750 是因為手上剛好有，而且網路上看起來還沒有人在這塊 MCU 上跑 DOOM，是個不錯的挑戰，~~沒想到一個禮拜就做出來了，偏抽象~~

說是 Bare Metal，就要貫徹 Bare Metal 的精神，在這個專案中當然不能使用 HAL Library，所有事情都要通過操作暫存器來做。


## 硬體限制
就「Can it run DOOM?」這點來看的話，最大的瓶頸在於記憶體。

### 與 STM32F429 的比較
以下所有 B 都代表 bytes 而非 bits
||STM32F429-Discovery|STM32H750|
|-|-|-|
|內部 flash|2MB|128KB|
|內部 SRAM|256KB|1MB|
|FMC SDRAM|8MB SDRAM|開發板上針腳沒接出來|



網路上最常被拿來移植 DOOM 的 STM32 是 STM32F429 Discovery，他有 2 MB 的 flash、256 KB 的 SRAM 與透過 FMC 接上的 8 MB SDRAM。因為有外接 SDRAM 的關係，他的 RAM 非常充裕，[甚至還可以跑 linux](https://hackmd.io/0Ua7E28UTDOR8vo0Cqikdg?view)

相較之下，STM32H750 只有 128 KB 的 flash、分散的 1 MB 的 SRAM，雖然規格上可以透過 FMC 接外部 SDRAM，但針腳在開發板上沒有被接出來，除非去畫 PCB，否則無法使用。除了這些記憶體外，它可以通過 QSPI 接上 16 Mbytes 的 flash，所以 flash 的部份沒那麼吃緊，但 flash 寫入很慢，通常不會在執行時去寫它，所以 .bss 與 .data 等區段與 stack、heap 會被放在 RAM 上，所以 RAM 的資源非常吃緊。

這件事情我會透過修改 DOOM 的記憶體管理機制與盡可能壓縮空間來解決。

### DOOM 的大小
我要移植的是 [doomgeneric](https://github.com/ozkl/doomgeneric)，他的各區段的大小約是這樣：
- text + rodata: 500KB
- data: 60KB
- bss: 300KB

另外還有資源檔 doom1.wad 4MB。這個 4MB 的資源檔不會一次被載入到記憶體中，而是以一塊一塊（lump）的單位載入。所以這 4MB 的資源檔可以放在 flash 中。

就這個大小而言，DOOM 是可以塞進這塊開發板的，只是能自由分配的記憶體比較受限而已。

## 如何啟動它
### Vector Table
STM32 有個 Vector Table，當中斷發生時，會去這個表裡面查要跳去哪個地址。這個 Vector Table 的前面幾項長怎樣是由 Cortex-M7 規範的：
![image](https://hackmd.io/_uploads/BkFnc05NWl.png =40%x)
這裡要關注的是 0x00 的位置放的是一開始的 stack pointer，0x04 的地方放的是 Reset，也就是啟動時會跳到的地方。

根據這樣的形式，我們可以寫出一個 Vector Table：
```cpp
#define MSP 0
#define RESET 1
#define HARDFAULT 3
#define SYSTICK 15
#define LTDC 104

__attribute__((section(".isr_vector"))) unsigned long isr_vec[] = {
    [MSP](unsigned long) & _stack_top_on_boot,
    [RESET](unsigned long) reset_isr,
    [HARDFAULT](unsigned long) hardfault_handler,
    [SYSTICK](unsigned long) systick_handler,
    [LTDC](unsigned long)ltdc_irq
};

```

以 STM32H750 來說，這個 Vector Table 被放在 0x8000000，而這個位置正好是內部 flash 的開頭。可以使用 linker script 去規劃每個區段要放在哪裡，這裡就不多贅述。

### 區段初始化
.data 與 .bss 區段會有在執行過程中寫入的需求，所以必須放在 SRAM 中，而 SRAM 是揮發性記憶體，代表斷電後無法保證資料會被保存。於是，對於 .data 這樣有初始值的資料，在程式剛跑起來的時候會把它放在 flash 中，在執行的時候再移到 SRAM 中。實作方式就是用 extern 把 linker script 中的 symbol 化為變數，然後做資料的搬運與初始化：
```cpp
extern unsigned long _stack_top, _frame_buffer, _data_vma_start, _data_vma_end,
    _data_lma, _bss_vma_start, _bss_vma_end, _stack_top_on_boot;

static void sram_init(void)
{
    unsigned long *src = &_data_lma;
    unsigned long *dst = &_data_vma_start;
    while (dst < &_data_vma_end)
        *dst++ = *src++;

    dst = &_bss_vma_start;
    while (dst < &_bss_vma_end)
        *dst++ = 0;
}
```
## 如何設定硬體週邊
### 時鐘
從 CPU 指令週期到各種週邊都離不開時鐘，在 Reference Manual 中會定義各種裝置的時鐘源有哪些選擇，倍頻或分頻要多少倍，由於過於瑣碎，這裡不多贅述。

這裡要注意的一點是，對於任何週邊設備而言，在使用它們時都要去開啟對應的時鐘。

### GPIO
GPIO 就是一些我們可以寫程式控制的針腳，模式大致可以分成三種，輸入、輸出和 Alternative Function。前兩種很直觀，最後一種就是每個針腳有一些特定的功能，當我們希望這個針腳執行這些特定的功能時，可以切換到這個模式。

STM32 的的每個針腳可以有多種 Alternative Function，可以通過 AFRL 與 AFRH 暫存器來設定要使用哪種 Alternative Function，可以從 [datasheet](https://www.st.com/resource/en/datasheet/stm32h750ib.pdf) 中看到每個針腳的每個 AF 編號是什麼功能：
![image](https://hackmd.io/_uploads/ByFHrliEWg.png)


### Memory-Mapped I/O
在 STM32 中，可以拿來設定週邊裝置的暫存器會被映射到特定的記憶體位置，我們可以透過讀寫這些記憶體位置來和週邊裝置互動，譬如說今天假如我們要開啟 GPIOA 的時鐘，可以這樣做：

GPIOA 的時鐘要調整 `RCC_AHB4ENR` 這個暫存器的 `GPIOAEN` 這個 bit，在 Reference Manual 中可以看到他是第 0 個 bit，而可以發現這個暫存器被映射到 (0x58024400UL + 0x0E0UL) 的位置，所以這樣設定：
```cpp
(*(volatile unsigned long *)(0x58024400UL + 0x0E0UL)) |= (1UL << 0);
```

### UART
這是一種串行通訊機制，有 TX 與 RX 兩隻腳，通過 TX 送出、RX 接收，可以拿來輸出 debug 訊息，我的程式中所有基於 putchar 實現的函式都是用這個輸出的。


## VGA
因為最近剛好旁邊有人在接 VGA，偷學了一點，就用 VGA 來顯示了。
### VGA 控制
VGA 是由 VESA 規範的硬體界面，可以在 [VESA DMT](https://glenwing.github.io/docs/VESA-DMT-1.13.pdf) 看到對於時序的規範。

VGA 需要接受的輸入有 HSYNC、VSYNC 和 RGB 共 5 根針腳。
- RGB：接受 0V 到 0.7V 的類比輸入，代表這三種顏色的亮度
- HSYNC：一行資料輸入完了要換行的訊號
- VSYNC：一頁資料輸入完了要翻頁的訊號


VGA 裝置會依據 HSYNC 與 VSYNC 的訊號來判斷現在的解析度是多少，具體參數可以去參考一下 VESA DMT 的表格，但那個有點難讀，以下會畫好讀一點的表格來講。

譬如說以 640x480@60Hz 而言，他的 pixel clock 是 25.175MHz，代表當 HSYNC 與 VSYNC 的訊號對了之後它會以這個頻率來採樣像素。而它每行需要輸入 800 個像素，所以 HSYNC 的頻率是 25.175MHz/800，也就是 31.46875kHz。


那這 800 像素是怎麼出來呢？可以看看下面的表格：
||像素|
|-|-|
|Sync pulse|96|
|Back porch|48|
|Visible area|640|
|Front porch|16|
|總共|800|

<!-- ![image](https://hackmd.io/_uploads/HkRbXgxVWx.png =40%x) -->

Sync pulse 代表 HSYNC 下拉並保持低電位的時間，經過這個時間後會經過一段 Back porch 的時間，代表在 HSYNC 拉起後有多少個 pixel 是無效的輸入，之後是 Visible area，也就是有效輸入的像素，輸入完後是 Front porch，代表這之後要輸入多少個無效的 pixel，接下來進行下一輪的 Sync pulse。

對於垂直的也差不多，只是變成說要等多少個水平 line 而已。
||像素|
|-|-|
|Sync pulse|2|
|Back porch|33|
|Visible area|480|
|Front porch|10|
|總共|525|



<!-- ![image](https://hackmd.io/_uploads/SJRwNle4be.png =40%x) -->
### LTDC
STM32H750 有 LTDC 這個硬體支援，是拿來控制 LCD 螢幕的，可以在不佔用 CPU 的情況下不斷傳送訊號給螢幕。我發現它輸出的訊號與 VGA 非常相似，只有差在它輸出的是數位訊號，而 VGA 需要的輸入是 0 到 0.7V 的類比訊號而已，所以後面接一個 DAC 就好了。

### 輸出 640x400 的畫面
由於 DOOM 的畫面是 320x200，按照比例應該是 640x400，如果要用 640x480 解析度輸出他的話，只要把垂直方向的 Front porch 與 Back porch 增加、Visible area 變小較好，這樣就只須要 640x400 的 framebuffer，等價於除了這個區域外的空間都輸出黑色。

### 將 framebuffer 壓到 320x200
到這裡會發現 640x400 是 256KB 左右，大約是我們擁有的 SRAM 的四分之一，而且一定要是一塊連續記憶體，開銷是非常大的。

由於 SRAM 受限，我要將 framebuffer 壓到理論下限 320x200 bytes，但 VGA 不支援更低的解析度了，怎麼做呢？首先將頻率減半，在水平方向輸出一半的元素，這樣 HSYNC 的頻率就會和原本一樣，而輸出的像素個數變為原本的一半。

這樣在水平方向上問題解決了，但是在垂直方向上同樣要輸出 400 行才能維持同樣的頻率，所以只是把空間壓到 320x400 bytes 而已。

我發現到 LTDC 可以設定在每行的開頭出發一個 interrupt，我可以通過在這個時候修改 framebuffer 的地址來做到在相鄰兩行輸出同一行的內容，於是成功用 200 行的內容輸出 400 行了。

就這樣，我只用了 320x200 的空間就輸出了 640x400 解析度的 VGA 訊號。

### 色彩模式
我採用 L8 色彩模式，framebuffer 中的每個像素以 0 到 255 的數字表示顏色。STM32H750 內有個 256 格的調色盤（CLUT），每格是 24bit 的顏色，代表 0 到 255 的數字分別表示什麼顏色，所以我可以輸出 24bit 的顏色，這 24bit 代表著 24 隻針腳高低電位表示出的數位訊號。


### DAC
可以透過一些電阻，在考慮 VGA 裝置的 RGB 有 75 歐姆內阻的情況下，將數位輸入轉為 0V 到 0.7V 的類比輸出就好，這裡不多贅述。


## Bootloader
### 在內部 flash 的韌體
由於內部 flash 只有 128KB，doomgeneric 的 .text + .rodata 的大小有 500KB，所以不可能全放在裡面。我的作法是在內部 flash 中只放一些初始化的程式碼，只要程式碼放在 16MB 的 QSPI flash 裡面，再跳過去。
```cpp
__attribute__((section(".reset_isr"))) void reset_isr()
{
    ...
    ((void (*)(void)) 0x90000001)();
    printf("[!] PANIC: Unreachable\n");
    while (1)
        ;
}
```
### 在外部 flash 的韌體
這裡要寫一個 linker script 編出另一個韌體，並且將它燒到外部 flash 上面。這時會出現一個問題，我已經編出了一個要放在內部 flash 的韌體 firmwareA 了，我要編一個放在外部 flash 的 firmwareB，而 firmwareB 需要呼叫在 firmwareA 中的函式，那怎麼辦呢？

在這裡我學到了一招，在鏈結的時候有個參數叫 `--just-symbols`，可以在鏈結參數上加上 `-Wl,--just-symbols=firmwareA` 就能使用 firmwareA 中的 symbol 了。

## 移植 DOOM
做了那麼多前置作業，終於可以開始移植 DOOM 了！
### libc
首先是要把會用到的 libc 函式實作出來，就....實作吧！

我自己採取的作法是，先把所有 API 都定出來，每個函式裡面會輸出 `"... not implemented yet"` 的訊息，並且卡住，這樣可以先把東西都編起來再開始一個一個實作。

至於其中有些檔案的操作，因為我懶得寫檔案系統就沒做了，~~畢竟我要趕在這禮拜內做出來~~

### 編譯
doomgeneric 可移植性非常好，實作完 libc 後只要在 Makefile 裡把編譯器與編譯參數改掉就編得動了。在實際 步驟上，我到這步時才知道 DOOM 編起來的大小，所以在這時候決定將 DOOM 放到 QSPI flash 上面。



就這樣，doom 被初步跑起來了，雖然還是輸出錯誤訊息而已。
![image](https://hackmd.io/_uploads/SJgcwJhE-l.png)

### 修改記憶體管理
在現代的系統上移植 doomgeneric 的話，只要實現以下幾個函式就可以成功移植了：
- DG_Init
- DG_DrawFrame
- DG_SleepMs
- DG_GetKey

然而，在這個 Case 中如果只是這樣記憶體一定會不夠。

在 DOOM 中有一種叫 Zone 的記憶體管裡機制，會在一開始 malloc 一塊很大的記憶體（Zone），然後在 DOOM 中都用它提供的 `Z_Malloc` 和 `Z_Free` 來做記憶體分配與釋放。

這樣造成一個問題，是一開始的那塊記憶體一定要是連續的一塊記憶體，而我們的記憶體是分散的好幾塊：
- DTCRAM: 128KB
- SRAM1-3: 228KB
- SRAM4: 64KB
- AXISRAM: 512KB 

於是，我實作了支援多個 Zone 的記憶體管理，在每個 Zone 裡沿用原本的記憶體管理機制進行分配，讓需要修改的程式碼量沒那麼高。

譬如說原本的 malloc 是長這樣的：
```cpp
void *Z_Malloc(int size, int tag, void *user);
```
我實作了一個 `Z_ZoneMalloc` 函式，多帶入了一個 `zone` 參數來說明我要在哪個 Zone 裡面分配記憶體：
```cpp
void *Z_ZoneMalloc(memzone_t *zone, int size, int tag, void *user);
```
然後在 `Z_Malloc` 裡呼叫它：
```cpp
void *Z_Malloc(int size, int tag, void *user)
{
    memzone_t *p = mainzone;
    while (p) {
        void *ptr = Z_ZoneMalloc(p, size, tag, user);
        if (ptr)
            return ptr;
        p = p->next;
    }
    Z_DumpHeap(0, PU_NUM_TAGS);
    I_Error ("Z_Malloc: failed on allocation of %d bytes", size);
    while (1);
}
```

### 螢幕顯示
在 doomgeneric 中有兩個 buffer，一個是 video buffer，一個是 screen buffer。DOOM 在運行中會把畫面寫在 video buffer，在每次狀態更新完後再一次把裡面的內容搬到 screen buffer 裡，這個 screen buffer 就是真正的 frame buffer 了。
>事實上 screen buffer 不一定直接就是 frame buffer，而是交由我們移植者來決定要如何把 screen buffer 上的資訊顯示在畫面上，但在記憶體受限的環境中，我直接讓他是 frame buffer

這時，我第一個想法是，video buffer 可不可以直接當 frame buffer 呢？遊戲資訊可以即時更新在螢幕上豈不美哉，還可以節省記憶體。但是，我發現不行，這樣畫面看起來會是一塊一塊小塊在更新，而不是整塊一起更新，還是沿用原本的設定。

### 指令快取
在剛開啟時，I-Cache 是預設[被關閉的](https://documentation-service.arm.com/static/61efd6602dd99944d051417b#G7.6442727)：
![image](https://hackmd.io/_uploads/SygsIl3VWl.png =70%x)
可以將它開啟：
```cpp
(*(volatile unsigned long *)0xE000ED14) |= (1 << 17);
```
之前在教科書上聽說，在 flash 上執行會造成 fetch latency 很大，當時沒什麼感覺。開啟 I-Cache 後，畫面刷新速度是肉眼可見的提昇，甚至不用做 benchmark 就看得出來。

### 資源檔載入
我要將 4MB 的資源檔 doom1.wad 放進外部 flash 中，並在 DOOM 中存取它。方式就是使用 objcopy 將它轉為一個 object file，在鏈結階段將它加入。

得到這個 object file 後，可以使用 nm 去看一下它裡面的 symbol：
```shell
$ nm doom1_wad.o
004006b4 R _binary_doom1_wad_end
004006b4 A _binary_doom1_wad_size
00000000 R _binary_doom1_wad_start
```
在原始碼中可以這樣去存取這些 symbol：
```cpp
extern const unsigned char _binary_doom1_wad_start[], _binary_doom1_wad_end[]; 
```
將讀取 wad 的功能改成讀取這個陣列就可以載入資源檔了。

### 成功啟動 DOOM
照著一般移植 DOOM 的流程，以下幾個函式是必須要實作的：
- DG_SleepMs
- DG_GetTicksMs
- DG_GetKey

`DG_SleepMs` 與 `DG_GetTicksMs` 我用 systick 這個中斷去計數，`DG_GetKey` 的部份為了簡單我先用按鈕來做。然後修理完一些小問題後，DOOM 成功被啟動了：


![image](https://hackmd.io/_uploads/r1nFyZhEbe.png =50%x)

可以發現這個時候的顏色是很奇怪的，這是因為 DOOM 中有一個自己的調色盤，0 到 255 的數字分別代表一個 24bit 的色彩，於是我把這個調色盤套用到我的 CLUT 設定中，就這樣，它變成了一個正常的 DOOM 了：
![image](https://hackmd.io/_uploads/rySIgWn4Wg.png =50%x)


<!-- ## VGA
stm32h750 有 LTDC 這個硬體支援，它本來是要拿來接 LCD 螢幕的，但我發現需要送的資料非常相似，只有差在 LCD 螢幕是接受數位輸入的，而 VGA 是接受類比輸入的，所以需要經過一個 DAC，這部份可以用電阻做一個。
### VGA 控制
VGA 需要接受的輸入有 HSYNC、VSYNC 和 RGB 共 5 根針腳。RGB 這三根針腳個需要接受一個從 0V 到 0.7V 的類比輸入代表不同亮度的 RGB 顏色。HSYNC 代表一行的資料輸入完了要換行，VSYNC 代表一頁的資料輸入完了要翻頁。VGA 會依據 HSYNC 與 VSYNC 的頻率來判斷現在的解析度是多少，具體參數是多少可以參考[這裡](http://www.tinyvga.com/vga-timing)。

譬如說以 650x480@60Hz 而言，他的 pixel clock 是 25.175MHz，代表當 HSYNC 與 VSYNC 的頻率對了之後它會以這個頻率來採樣像素。而它每行需要輸入 800 個像素，所以 HSYNC 的頻率是 25.175MHz/800，也就是 31.46875kHz。

那這 800 像素是怎麼出來呢？可以看看下圖：
![image](https://hackmd.io/_uploads/HkRbXgxVWx.png)

Sync pulse 代表 HSYNC 下拉並保持低電位的時間，經過這個時間後會經過一段 Back porch 的時間，代表在 HSYNC 拉起後有多少個 pixel 是無效的輸入，之後是 Visible area，這代表有效輸入的像素，輸入完後是 Front porch，代表這之後要輸入多少個無效的 pixel，接下來進行下一輪的 Sync pulse。

對於垂直的也差不多，只是變成說要等多少個水平 line 而已。
![image](https://hackmd.io/_uploads/SJRwNle4be.png)


### 設定 LTDC 的時鐘
![image](https://hackmd.io/_uploads/ryK8-JC7be.png)
LTDC 的時鐘是從 pll3_r_ck 來的，於是先去設定它。
看一下 [VGA 的頻率對照](http://www.tinyvga.com/vga-timing)，我決定用 25.175MHz 的頻率，於是可以用以下的參數弄出這個頻率的時鐘：
- DIVM: 4
- N: 31
- R: 20
- FRAC: 3840

發現到他有一個 LCD_CLK 的輸出腳可以輸出時鐘訊號，剛好有邏輯分析儀，於是看一下。根據 [datasheet](https://www.st.com/resource/en/datasheet/stm32h750ib.pdf)，他是 PE14 的 AF14（Alternative Function）。
![image](https://hackmd.io/_uploads/SyELv1CXZe.png)

設定好後看邏輯分析儀：
![image](https://hackmd.io/_uploads/Bkwe_1AXZx.png)
週期 40ns，剛好 25MHz（理論上會剛剛好是 25.175MHz 但我的邏輯分析儀的分辨率不夠）

## 使用 LTDC 控制 VGA
會發現各種訊號與 VGA 極度相似：
![image](https://hackmd.io/_uploads/ryvmHegNWe.png)

裡面各種參數都是可以用寫程式調整的：
- LTDC_SSCR：控制 HSYNC 與 VSYNC 的寬度，就是 Sync pulse
- LTDC_BPCR：控制 HPB 與 VPB，也就是水平與垂直方向的 Back porch
- LTDC_AWCR：控制 Active width 與 Active height，也就是 Visible area
- LTDC_TWCR：控制 Total height 與 Total width，其實等同於控制 HFP 與 VFP，也就是 Front porch

在實際去做之後，發現 640x480 且 16 bit 顏色的 frame buffer 需要 600KB 左右，而它最大的連續記憶體區域就是 AXISRAM，只有 512KB，因為之後要跑 DOOM，也不想花那麼多時間來做 frame buffer，所以就改而使用 8 bit 的顏色。

8 bit 顏色模式 L8，不同於 RGB565 把每個顏色代表的 bit 數量寫死，而是對於 0 到 255 的數字，我可以指定每個數字代表哪個 24bit 的顏色（每個顏色 8 bit）。這個部份可以使用 `CLUTWR` 這個暫存器去進行設定，而我將它設成了等價於 RGB332 的模式。
### DAC
因為沒有那麼多相同的電阻，我組不起來 R-2R 電阻梯，不過我有 510R、1K、2.2K、4.7K、10K 的電阻，可以階出一個堪用的東西。這裡的電阻取值要考慮到 VGA 裝置內部有個 75 歐姆接地的電阻，。本來預設是要用 RGB565 的，所以接了一個 5bit 的 DAC（6 bit 部份就多接一個 20K），長這樣：
```
-- 510R - V4
|
-- 1K --- V3
|
-- 2.2K - V2
|
-- 4.7K - V1
|
-- 10K  - V0
|
Vout
```
改成 3 bit 或 2 bit 的話就只要把他們接到高位就好。

於是，我成功點亮了 VGA。
 -->


<!-- ## Bootloader
500KB 的程式碼不可能塞進 128KB 的內部 flash 中，所以作法是內部 flash 中的程式碼只負責初始化資料、初始化週邊與設定系統參數，再跳到外部 flash 中主程式的進入點




## Document
- [ARM Cortex-M7 Devices Generic User Guide](https://documentation-service.arm.com/static/61efd6602dd99944d051417b)
- [stm32h750 Reference Manual](https://www.st.com/resource/en/reference_manual/rm0433-stm32h742-stm32h743753-and-stm32h750-value-line-advanced-armbased-32bit-mcus-stmicroelectronics.pdf)

## 系統時鐘設定
我使用 PLL 將系統時鐘倍頻到 320MHz。stm32h750 有 PLL1、PLL2、PLL3 三個彼此獨立的 PLL，每個 PLL 之前可以通過一個分頻（DIVM），然後進行倍頻。倍頻的參數有 N、P、Q、R 和 FRAC，P、Q、R 分別又是三個彼此獨立的時鐘（依據 P、Q、R 不同倍率分出不同頻率的時鐘），時鐘源經過 DIVM 分頻後，頻率會乘上 N 倍再分別以 P、Q、R 倍分頻，也就是這樣的公式：
$$pllx_{ck}=PLLSRC\times\frac{1}{DIVM}\times\frac{N}{x}$$
其中 $x$ 是 P、Q、R 其中一個。

另外，它可以用 FRAC 這個參數對時鐘頻率做細部的調整，調整後的公式長這樣：
$$pllx_{ck}=PLLSRC\times\frac{1}{DIVM}\times\frac{N+\frac{FRAC}{2^{13}}}{x}$$


![image](https://hackmd.io/_uploads/rJmgUkCQ-e.png)
系統時鐘接受的唯一 PLL 輸入是 pll1_p_ck，於是就去設定它。
因為我的開發板上沒有外部時鐘，那個針腳封裝看起來手工焊不上去，於是使用 64MHz 的內部時鐘 HSI 作為 PLLSRC，用以下參數弄出 320MHz 的系統時鐘：
- DIVM: 4
- N: 40
- P: 2





### QSPI
 -->