04.2 匯流排與記憶體映射 I/O#
學習目標#
- 理解匯流排在 SoC 中的角色:為什麼 CPU、記憶體、周邊要透過匯流排而非直連。
- 學會設計一個簡單的仲裁器(arbiter),處理多個 master 搶同一條匯流排的問題。
- 掌握 memory-mapped I/O 的原理,能規劃一張完整的位址映射表,並理解位址解碼電路的實作。
- 能實作 UART 收發控制器與 GPIO 模組,並將它們掛上自製匯流排。
- 體會「把周邊當成特殊位址的記憶體」這個抽象如何簡化軟硬體介面。
4.2.1 為什麼需要匯流排#
前三章的系統裡,CPU 直接接 SRAM,位址與資料線一對一相連。但真實 SoC 上有十幾個模組:快取後面的主記憶體、UART、GPIO、計時器、之後還有 DMA(04.3)。如果每對模組都直連,連線數會隨模組數量平方成長——這正是匯流排(bus)要解決的問題:共享一組位址/資料/控制線,用仲裁與位址解碼來分時使用。
工業界的標準匯流排是 AMBA 系列(AHB、APB、AXI),特性如下:
| 匯流排 | 定位 | 特點 |
|---|---|---|
| AXI4 / AXI-Lite | 高效能主幹 | 位址/資料通道分離、支援 burst、outstanding、多層互連 |
| AHB | 中速 | 單通道、pipeline 位址與資料 |
| APB | 慢速周邊 | 極簡、兩拍以上、功耗低 |
本書為教學自訂一支極簡匯流排(下稱 sBus):單 master 起步、可擴多 master。協定只有三個訊號組:
flowchart LR
M1[Master 0<br/>CPU] --> ARB{Arbiter}
M2[Master 1<br/>DMA] --> ARB
ARB --> DEC{位址解碼器}
DEC --> SRAM[RAM<br/>0x0000_0000]
DEC --> GPIO[GPIO<br/>0x1000_0000]
DEC --> UART[UART<br/>0x1000_1000]
DEC --> TMR[Timer<br/>0x1000_2000]// sBus 單次傳輸:master 拉高 req,slave 完成後拉一拍 done
// req : 請求有效
// we : 1=寫, 0=讀
// addr : 全域位址
// wdata : 寫資料
// rdata : 讀資料(done 拍有效)
// done : 傳輸完成,一拍脈衝
選擇「req/done 握手 + 可變延遲」的原因:SRAM 一拍完成、UART 移位要 87 拍、DMA 更久,周邊速度天差地別,固定延遲的匯流排(如早期 8086 系統)無法統一它們。
4.2.2 匯流排仲裁#
當 04.3 的 DMA 加入後,匯流排有兩個 master:CPU 與 DMA。仲裁器決定誰能用匯流排。常見策略:
- 固定優先權(Fixed Priority):DMA > CPU。DMA 大量搬資料時可能讓 CPU 餓死。
- 輪替(Round-Robin):輪流使用,公平,實作只要一個「上次誰用過」的位址暫存器。
- 加權輪替(Weighted RR):兼顧即時性與公平。
以下是一個兩 master 的 Round-Robin 仲裁器:
module arbiter_rr (
input logic clk, rst,
// 兩個 master 的請求
input logic req0, // CPU
input logic req1, // DMA
output logic gnt0, // 授權給 CPU
output logic gnt1, // 授權給 DMA
input logic done // 匯流排傳輸完成
);
typedef enum logic {OWN0, OWN1} state_t;
state_t state;
always_ff @(posedge clk) begin
if (rst) state <= OWN0;
else case (state)
OWN0: if (done && req1) state <= OWN1; // 用完就輪替
OWN1: if (done && req0) state <= OWN0;
endcase
end
assign gnt0 = (state == OWN0);
assign gnt1 = (state == OWN1);
endmodule
兩個實作要點:不要打斷進行中的傳輸(只在 done 時切換授權,否則 Slave 端會看到半截事務);仲裁不要增加組合邏輯延遲到關鍵路徑(此例仲裁純粹看暫存器狀態,零額外延遲)。
仲裁器之後,匯流排的多工器把被授權 master 的 addr/we/wdata 路由到共用線上;rdata 則反向多工回對應 master。
4.2.3 Memory-Mapped I/O 與位址映射表#
Memory-mapped I/O 的核心思想:周邊的暫存器就是特殊位址的「記憶體」。CPU 用同樣的 load/store 指令存取它們,不需要額外的 IN/OUT 指令集(那是 x86 port I/O 的做法)。位址解碼器根據高位位址選出 slave,再把低位位址交給 slave 內部選暫存器。
本書系統的位址映射表(RISC-V 風格規劃):
| 位址範圍 | 模組 | 暫存器(offset) | 讀/寫 | 說明 |
|---|---|---|---|---|
0x0000_0000–0x0000_7FFF | SRAM | — | R/W | 32 KB 程式+資料記憶體 |
0x1000_0000–0x1000_00FF | GPIO | 0x00 OUT、0x04 IN、0x08 OE | R/W | 8-bit 平行 I/O |
0x1000_1000–0x1000_10FF | UART | 0x00 TX、0x04 RX、0x08 STAT | R/W | 見 4.2.4 |
0x1000_2000–0x1000_20FF | Timer | 0x00 MTIME、0x08 CMP | R/W | 64-bit 計數 |
0x1000_3000–0x1000_30FF | 效能計數 | 0x00 ACCESS、0x08 MISS | R | 04.1 的 AMAT 實測 |
0x2000_0000–0x2000_00FF | DMA | 0x00 SA、0x04 DA、0x08 LEN、0x0C CTRL | R/W | 04.3 |
0x3000_0000–0x3000_00FF | PLIC | 0x00 PENDING、0x04 ENABLE、0x08 CLAIM | R/W | 04.3 |
解碼器實作(組合邏輯,比較高位位址):
wire sel_ram = (bus_addr < 32'h0000_8000);
wire sel_gpio = (bus_addr[31:28] == 4'h1) && (bus_addr[27:16] == 12'h000);
wire sel_uart = (bus_addr[31:28] == 4'h1) && (bus_addr[27:16] == 12'h001);
wire sel_timer = (bus_addr[31:28] == 4'h1) && (bus_addr[27:16] == 12'h002);
wire sel_dma = (bus_addr[31:28] == 4'h2);
注意兩件事:位址區塊要對齊到 2 的冪次且互不重疊,否則解碼邏輯會出現多個 select 同時拉高;每個 slave 必須回應 done,未映射位址可以由一個 default slave 回應並(可選)觸發 bus error 中斷,幫助軟體抓野指標。
軟體端用指標存取周邊,C 語言要加 volatile:
#define UART_TX (*(volatile uint32_t *)0x10001000)
#define UART_STAT(*(volatile uint32_t *)0x10001008)
void putc(char c) { while (UART_STAT & 1) ; UART_TX = c; } // 等 TX 空
volatile 禁止編譯器把「讀 STAT」最佳化成只讀一次——否則 while 迴圈會變成死迴圈。這是 memory-mapped I/O 軟體端最重要的觀念。
4.2.4 UART 控制器 Verilog 範例#
UART(通用非同步收發器)是最經典的序列周邊:兩條線(TX/RX)、無時脈線,靠雙方約定鮑率對齊。8N1 格式:1 個起始位(0)、8 個資料位(LSB 先)、1 個停止位(1)。以 115200 鮑率、50 MHz 系統時脈,每個 bit 有 50_000_000/115200 ≈ 434 個時脈。
//=============================================================
// uart.v : 8N1 UART,TX 含 16 深度 FIFO,RX 含 overrun 偵測
//=============================================================
module uart #(
parameter CLK_HZ = 50_000_000,
parameter BAUD = 115_200
)(
input logic clk, rst,
// sBus slave 介面
input logic sel, req, we,
input logic [7:0] addr, // 暫存器 offset
input logic [31:0] wdata,
output logic [31:0] rdata,
output logic done,
// 序列腳位
output logic tx, input logic rx,
// 中斷(給 04.3)
output logic irq_rx, irq_tx
);
localparam int BITCNT = CLK_HZ / BAUD; // 434
// ---------- TX ----------
logic [7:0] tx_fifo [0:15];
logic [3:0] f_wr, f_rd;
wire f_empty = (f_wr == f_rd);
wire f_full = ((f_wr + 1) & 4'hF) == f_rd;
logic [15:0] baud_cnt; logic [3:0] bit_idx;
logic [9:0] shifter; logic tx_busy;
always_ff @(posedge clk) begin
if (rst) begin
f_wr <= 0; {baud_cnt, bit_idx} <= 0;
shifter <= ~10'b0; tx_busy <= 0; tx <= 1'b1;
end else begin
// 匯流排寫入 TX 暫存器 -> 推入 FIFO
if (sel && req && we && addr == 8'h00 && !f_full) begin
tx_fifo[f_wr] <= wdata[7:0];
f_wr <= f_wr + 1'b1;
end
// 移位器空了且 FIFO 有資料 -> 取出並加上 start/stop
if (!tx_busy && !f_empty) begin
shifter <= {1'b1, tx_fifo[f_rd], 1'b0}; // {stop, data, start}
f_rd <= f_rd + 1'b1;
tx_busy <= 1'b1; bit_idx <= 0; baud_cnt <= 0;
end
// 鮑率產生與移位
if (tx_busy) begin
if (baud_cnt == BITCNT-1) begin
baud_cnt <= 0;
tx <= shifter[0];
shifter <= {1'b1, shifter[9:1]};
if (bit_idx == 9) tx_busy <= 0;
else bit_idx <= bit_idx + 1'b1;
end else baud_cnt <= baud_cnt + 1'b1;
end
end
end
assign irq_tx = ~tx_busy & ~f_empty; // 簡化:可改為 FIFO 半空
// ---------- RX ----------
logic [15:0] rx_cnt; logic [3:0] rx_bit;
logic [7:0] rx_sh; logic rx_busy, rx_prev;
always_ff @(posedge clk) begin
if (rst) begin
rx_busy <= 0; rx_prev <= 1'b1; rx_cnt <= 0; rx_bit <= 0;
end else begin
rx_prev <= rx;
if (!rx_busy && rx_prev && !rx) begin // 下降沿 = start bit
rx_busy <= 1'b1; rx_cnt <= BITCNT/2; rx_bit <= 0; // 移到位元中央
end else if (rx_busy) begin
if (rx_cnt == 0) begin
rx_cnt <= BITCNT - 1;
if (rx_bit < 8) begin // 取樣在每個位元中央
rx_sh <= {rx, rx_sh[7:1]};
rx_bit <= rx_bit + 1'b1;
end else begin
rx_busy <= 0; // 收完(應驗證 stop bit=1)
if (!f_full) begin
tx_fifo[f_wr] <= rx_sh; // 與 TX 共用陣列僅為精簡
f_wr <= f_wr + 1'b1; // 實作應分開 RX FIFO
end
end
end else rx_cnt <= rx_cnt - 1'b1;
end
end
end
// ---------- 匯流排介面 ----------
always_comb begin
rdata = 32'b0;
case (addr[7:0])
8'h00: rdata = 32'b0; // TX: 寫暫存器
8'h04: rdata = {24'b0, rx_sh}; // RX: 讀資料
8'h08: rdata = {f_empty, f_full, 30'b0}; // STAT[0]=TX空
default: rdata = 32'b0;
endcase
end
assign done = sel && req; // 暫存器存取 1 拍完成
endmodule
設計解讀:
- TX 側用 FIFO + 移位器把「CPU 寫入」與「鮑率節奏的串列輸出」解耦:CPU 一次寫入整串字串,UART 自己慢慢吐,匯流排不被佔住。這是所有慢速周邊設計的通用模式。
- RX 在 start bit 的下降沿啟動,計數半個 bit 時間到達位元中央再取樣——之後每個位元的取樣點自然落在中央,抗誤差能力最好。嚴謹版本還應對 start bit 做兩次取樣確認(多數投票)以抗毛刺。
- 鮑率誤差:434 × 115200 = 49,972,800 Hz,誤差 0.005%,遠低於 UART 允許的 ±2%,但若換鮑率務必重算並檢查誤差。
4.2.5 GPIO 模組#
GPIO 是最簡單的周邊,但正好展示 memory-mapped I/O 的全貌:
module gpio (
input logic clk, rst,
input logic sel, req, we,
input logic [7:0] addr,
input logic [31:0] wdata,
output logic [31:0] rdata,
output logic done,
inout logic [7:0] io_pins
);
logic [7:0] out_reg, oe_reg; // 輸出值、輸出使能
logic [7:0] in_reg;
always_ff @(posedge clk) begin
if (rst) begin out_reg <= 0; oe_reg <= 0; in_reg <= 0; end
else begin
if (sel && req && we) case (addr[7:0])
8'h00: out_reg <= wdata[7:0];
8'h08: oe_reg <= wdata[7:0];
endcase
in_reg <= io_pins; // 輸入同步(避免亞穩態傳播)
end
end
assign io_pins = oe_reg ? out_reg : 8'hZZ; // 三態
always_comb case (addr[7:0])
8'h00: rdata = {24'b0, out_reg};
8'h04: rdata = {24'b0, in_reg};
8'h08: rdata = {24'b0, oe_reg};
default: rdata = 32'b0;
endcase
assign done = sel && req;
endmodule
FPGA 內部其實沒有真正的三態匯流排(只有 I/O ring 有),inout 只在頂層腳位合法;晶片內部要用方向暫存器 + mux 模擬。同步器 in_reg <= io_pins 若要抗亞穩態應做兩級(把 io_pins 先過兩個 FF 再用,習題會要求你改)。
4.2.6 把它們組起來:匯流排互連頂層#
module soc_bus (
input logic clk, rst,
// CPU master 介面
input logic m_req, m_we,
input logic [31:0] m_addr, m_wdata,
output logic [31:0] m_rdata,
output logic m_done,
// 外部腳位
output logic tx, input logic rx, inout logic [7:0] io_pins
);
// slave select 與 rdata 多工
logic [31:0] r_ram, r_gpio, r_uart;
logic d_ram, d_gpio, d_uart;
wire sel_ram = (m_addr < 32'h0000_8000);
wire sel_gpio = (m_addr[31:16] == 16'h1000) && (m_addr[15:8] == 8'h00);
wire sel_uart = (m_addr[31:16] == 16'h1000) && (m_addr[15:8] == 8'h01);
assign m_rdata = sel_ram ? r_ram :
sel_gpio ? r_gpio : r_uart;
assign m_done = sel_ram ? d_ram :
sel_gpio ? d_gpio : d_uart;
sram_controller u_ram (.clk(clk), .rst(rst), .sel(sel_ram),
.req(m_req), .we(m_we), .addr(m_addr), .wdata(m_wdata),
.rdata(r_ram), .done(d_ram));
gpio u_gpio (.clk(clk), .rst(rst), .sel(sel_gpio),
.req(m_req), .we(m_we), .addr(m_addr[7:0]), .wdata(m_wdata),
.rdata(r_gpio), .done(d_gpio), .io_pins(io_pins));
uart u_uart (.clk(clk), .rst(rst), .sel(sel_uart),
.req(m_req), .we(m_we), .addr(m_addr[7:0]), .wdata(m_wdata),
.rdata(r_uart), .done(d_uart), .tx(tx), .rx(rx),
.irq_rx(), .irq_tx());
endmodule
這個頂層目前是單 master;04.3 加入 DMA 時,只要在 CPU 與 slaves 之間插入 4.2.2 的仲裁器即可,所有 slave 介面完全不用改——這就是定義清楚介面所買到的擴充性。
本章小結#
- 匯流排以共享線路取代模組兩兩直連,仲裁器(固定優先權、Round-Robin)解決多 master 競爭,切換授權必須等到傳輸
done,不可打斷進行中的事務。 - Memory-mapped I/O 把周邊暫存器映射到統一位址空間,CPU 用普通 load/store 存取;位址解碼用高位位址產生 select,區塊需對齊、不重疊,並為未映射位址提供預設回應。
- 位址映射表是軟硬體的契約:軟體端存取周邊的指標必須
volatile,防止編譯器最佳化掉狀態輪詢。 - UART 以「FIFO + 移位器」解耦匯流排與鮑率節奏,RX 在位元中央取樣;GPIO 展示了方向暫存器與輸入同步的基本功。
- 定義清晰的 slave 介面(req/done/rdata)讓新增 master(下一章的 DMA)只需插入仲裁器,不必動周邊。
實作題#
- 位址解碼改進:把 4.2.6 頂層改成用 casez 與參數化的基地址進行解碼,並加入 default slave:存取未映射位址時回
done且把錯誤旗標寫入一個可讀的錯誤暫存器,記錄最後一次非法存取的位址。 - RX FIFO 獨立化:範例中 RX 共用了 TX 的儲存陣列(刻意精簡),請為 RX 建立獨立 FIFO 與 overrun 旗標,並在 STAT 暫存器新增 RX 有效/OVERRUN 位元。
- Round-Robin 驗證:寫一個 testbench,讓兩個 master 以隨機間隔發請求,驗證 4.2.2 仲裁器在競爭時嚴格交替、在單方請求時不閒置,並統計兩者的頻寬佔比接近 50%。
- GPIO 雙級同步:修改 GPIO 輸入路徑,加入兩級觸發器同步器與可选的去彈跳計數器(例如 20 ms 穩定才更新 in_reg),用實體按鍵驗證。
- printf 到 UART:用 C 寫
printf等級的輸出(至少支援%d、%x、%s),透過 4.2.4 的 UART 列印 04.1 的效能計數器,完成「快取 miss rate 報表」的閉環。