模块化设计思想:模块划分原则、模块接口定义、模块内部结构规范
说实话,模块化设计这事儿,我入行头三年都没真正搞明白。那时候总觉得,能跑就行,管它分不分模块。直到有一次接手一个同事留下的代码——一个模块写了三千行,所有功能揉在一起,我看了整整一周才敢动手改。嗯,从那以后,我成了模块化设计的坚定拥护者。
你想想看,芯片设计本质上就是搭积木。每个模块就是一块积木,接口清晰、功能单一,才能搭出稳固的系统。今天我就把这几年的心得掰开揉碎讲给你听。
一、模块划分原则
模块怎么切?我个人的习惯是,先问自己三个问题:
- 这个模块的功能能不能用一句话说清楚? 如果说不清楚,说明切得太粗了。
- 这个模块的输入输出是不是明确的? 如果接口模糊,后面联调会痛不欲生。
- 这个模块能不能独立测试? 不能独立测的模块,debug时就是噩梦。
具体来说,我总结了四条原则:
原则一:高内聚,低耦合
模块内部的功能要紧密相关,模块之间的依赖要尽可能少。我在项目中遇到过,有人把地址译码和FIFO控制写在一个模块里,结果改地址映射时把FIFO也搞坏了。这就是典型的内聚不够。
原则二:单一职责
一个模块只做一件事。比如,一个UART模块就只负责串行数据的收发,别把波特率生成也塞进来——那应该单独做个波特率发生器。
原则三:接口最小化
模块对外暴露的信号越少越好。我曾经见过一个模块,接口信号有80多个,光看端口列表就花了半小时。后来重构时砍到30个,功能一点没少。
原则四:可复用性
写模块时,想想下一个项目能不能直接用。比如,一个AHB-to-APB桥,如果参数化做得好,换个项目改个位宽就能用。
二、模块接口定义
接口定义这事儿,说白了就是定规矩。规矩定好了,团队协作才顺畅。我建议你注意以下几点:
2.1 接口命名规范
我个人习惯用统一的命名风格:
- 输入信号:以
i_开头,比如i_clk、i_rst_n - 输出信号:以
o_开头,比如o_data、o_valid - 双向信号:以
io_开头,比如io_sda
小技巧:接口信号的名字最好能体现它的功能。比如 o_fifo_full 比 o_full 好一万倍。你想想看,debug时看到信号名就知道它干嘛的,多省事。
2.2 接口时序约定
每个接口都要明确时序关系。我一般会在模块头部的注释里写明:
//------------------------------------------------
// 模块:uart_rx
// 功能:UART接收模块
// 接口时序:
// - i_clk: 系统时钟,上升沿采样
// - i_rst_n: 异步复位,低有效
// - i_rx: 串行输入,空闲时为高
// - o_data: 并行数据输出,o_valid为高时有效
// - o_valid: 数据有效标志,持续一个时钟周期
//------------------------------------------------
2.3 接口信号列表
我建议用表格来管理接口信号,一目了然:
| 信号名 | 方向 | 位宽 | 描述 |
|---|---|---|---|
| i_clk | 输入 | 1 | 系统时钟 |
| i_rst_n | 输入 | 1 | 异步复位,低有效 |
| i_rx | 输入 | 1 | 串行数据输入 |
| o_data | 输出 | 8 | 并行数据输出 |
| o_valid | 输出 | 1 | 数据有效标志 |
三、模块内部结构规范
模块内部怎么写?我有个「三段式」的习惯,用了十几年,屡试不爽。
3.1 三段式结构
每个模块内部,我建议分成三个部分:
- 寄存器定义区:所有内部寄存器和wire信号的定义
- 组合逻辑区:用
always @(*)或assign实现的组合逻辑 - 时序逻辑区:用
always @(posedge clk)实现的时序逻辑
举个例子:
module fifo_controller (
input wire i_clk,
input wire i_rst_n,
input wire i_wr_en,
input wire i_rd_en,
output reg o_full,
output reg o_empty
);
//========================================
// 1. 寄存器定义区
//========================================
reg [3:0] wr_ptr;
reg [3:0] rd_ptr;
reg [4:0] fifo_cnt;
//========================================
// 2. 组合逻辑区
//========================================
assign o_full = (fifo_cnt == 5'd16);
assign o_empty = (fifo_cnt == 5'd0);
//========================================
// 3. 时序逻辑区
//========================================
always @(posedge i_clk or negedge i_rst_n) begin
if (!i_rst_n) begin
wr_ptr <= 4'd0;
rd_ptr <= 4'd0;
fifo_cnt <= 5'd0;
end else begin
// 写指针更新
if (i_wr_en && !o_full)
wr_ptr <= wr_ptr + 1'b1;
// 读指针更新
if (i_rd_en && !o_empty)
rd_ptr <= rd_ptr + 1'b1;
// FIFO计数更新
case ({i_wr_en, i_rd_en})
2'b10: fifo_cnt <= fifo_cnt + 1'b1;
2'b01: fifo_cnt <= fifo_cnt - 1'b1;
default: ;
endcase
end
end
endmodule
注意:组合逻辑和时序逻辑一定要分开写。我曾经见过有人把两者混在一起,结果综合出来的电路和仿真对不上,查了三天才发现是组合逻辑里用了非阻塞赋值。这种坑,踩一次就够了。
3.2 代码布局规范
我建议每个模块都保持一致的布局:
- 模块头部:功能描述、作者、修改历史
- 参数定义:用
parameter或localparam - 端口声明:输入在前,输出在后
- 内部信号:wire在前,reg在后
- 逻辑实现:组合逻辑在前,时序逻辑在后
3.3 注释规范
注释不是越多越好,而是要「该有的地方有」。我个人的习惯是:
- 每个模块头部写清楚功能
- 每个always块前面写一句注释,说明它在干嘛
- 复杂的组合逻辑,写清楚真值表或算法
- 不要写「废话注释」,比如
// 时钟信号这种
四、知识体系总览
下面这张图,是我对模块化设计思想的总结。你把它打印出来贴在工位上,写代码时瞄一眼,能少走很多弯路。
我的经验之谈:模块化设计不是一蹴而就的。刚开始你可能觉得「分模块好麻烦」,但等你debug时发现,一个模块出问题,其他模块完全不受影响,那种爽感,值得你花时间去养成这个习惯。
好了,关于模块化设计,我就说这么多。记住一句话:好的模块化设计,让代码像乐高积木一样,拼起来是系统,拆开来是零件。你写代码时多想想这句话,慢慢就能找到感觉。