SV 访问控制:local、protected 与 package 作用域的封装纪律
更新时间:2026-09-28。上一篇 SystemVerilog 类、继承与多态 讲了「类怎么派生、方法怎么覆写」;这一篇只讲配套的另一半:成员该让谁看见。SV 访问控制看起来只是
local/protected两个关键字,但验证平台里一半「改一处崩三处」的事故,根子都在封装边界没划清。
本文要回答的问题
local、protected、默认(public)三种可见性,编译器各自放行到哪一层?- 父类的
local成员子类到底能不能碰?想只给子类用、不给外人用该用哪个? - class 之外还有一层作用域:package、
import、export怎么配合,跨文件的可见性怎么管? - UVM 里 transaction 字段全裸奔是惯例,那封装纪律到底用在哪儿?
一、三种可见性:编译器放行到哪一层
SystemVerilog 类成员默认是 public——不写任何限定词,谁都能读写。在此之上有两档收紧:
| 限定词 | 本类方法 | 子类方法 | 类外代码 |
|---|---|---|---|
| (默认 public) | 可以 | 可以 | 可以 |
protected | 可以 | 可以 | 拒绝 |
local | 可以 | 拒绝 | 拒绝 |
判定时只看「访问发起方站在哪个类的代码里」,不看句柄类型。一个常见误区:在 class A 的方法里访问另一个 A 对象的 local 成员——这是合法的。可见性按类不按对象,local int score 在 A::compare(A other) 里写 other.score 编译器放行,因为发起方仍是 A 的方法。C++ 程序员会觉得理所当然,但从「句柄即指针」的直觉出发的人常在这里卡一下。
class bus_txn;
local int unsigned id; // 只有 bus_txn 的方法能碰
protected int unsigned addr; // bus_txn 及其子类能碰
rand bit [7:0] data; // 默认 public,谁都能 rand 到
function new();
id = next_id++; // 本类内部随意用
endfunction
function void print();
$display("id=%0d addr=%h", id, addr);
endfunction
endclass
class err_txn extends bus_txn;
function void poison();
addr = 'hdead_beef; // 合法:protected 对子类开放
// id = 0; // 编译错:local 不进子类
endfunction
endclass三条经验对应三档:只想自己用的内部状态(自增计数、缓存句柄、中间标志位)给 local;设计上就打算让子类扩展时覆写或读取的骨架成员给 protected;transaction 的 rand 字段、配置项这类「生来就是接口」的留 public。
二、继承链上的访问边界
local 与 protected 的分界只在继承链上才有意义,值得把判定规则说透。

图上那根没有画出来的箭头就是关键:err_txn 到 lid 之间不存在访问路径。很多从 C++ 转来的工程师记得「private 子类不可见」,但 SV 里更常见的翻车方向相反——把本想给子类用的成员写成了 local,子类一扩展就编译错,然后图省事把 local 删掉裸奔。正确动作是改 protected:对类外依然封死,对继承链放行,封装没有被破坏。
还有一条和覆写(override)交叉的规则:子类覆写虚方法时不能收紧可见性。父类方法是 public 虚方法,子类不能把它覆写成 local——多态调用按父类句柄发起,编译器无法保证发起点满足收紧后的可见性,直接报错。反过来放宽(protected 覆写成 public)语法上允许,但等于把父类的设计意图拆了,code review 时该拦。
三、class 之外的一层:package 作用域
可见性管控不止类内三档。文件多了以后,「这个类/函数/参数别的文件看不看得见」由 package 管。
package bus_pkg;
parameter int AW = 32;
class bus_txn; /* ... */ endclass
function bus_txn make_txn(); /* ... */ endfunction
endpackage
// 使用方两条路:
import bus_pkg::*; // 全量引入,图省事
import bus_pkg::bus_txn; // 精确引入,推荐
bus_pkg::bus_txn t = new(); // 不 import,全限定名也行规则只有几条但坑不少:
- 不 import 也能用——
pkg::name全限定名永远有效。import只是把名字搬进当前作用域省打字。 - 通配
import pkg::*是「按需」的:名字只有被真正引用时才引入,所以两个 package 通配 import 后有同名类,只要不引用就不冲突;一旦引用,编译器报歧义。解法是冲突名改全限定。 - 顺序敏感:编译单元里
import语句只影响它之后的代码。同一文件里先用了类再 import,前面那段照旧不认。 - package 里不能再
local:package 项天然对「import 它的编译单元」可见,没有 package 级的隐藏关键字。想藏实现细节,手段是把辅助类放进单独 package 但不写进公开头文件,或者用export控制再导出。
export 的场景比较窄:package A import 了 package B 的东西后,可以 export B::xxx; 把它再转发给 import A 的人。验证 IP 打包时用它造「单一入口 package」——用户只 import vip_pkg::*,底下依赖的 utility 包由 vip_pkg 代为 export。注意通配 import 的名字不能被通配再导出,必须 export B::name; 逐个点名,这是很多 VIP 封装时漏掉、导致用户端编译缺符号的原因。
四、验证平台里的取舍:哪里该封、哪里该裸
UVM 社区的惯例是 transaction 和 config 对象字段全 public——因为 uvm_field_* 自动化宏、随机化、copy/compare 都建立在直接访问之上,封成 local 反而寸步难行。这不等于封装纪律在 TB 里没用,该封的是另一批东西:
| 成员类型 | 建议可见性 | 理由 |
|---|---|---|
| transaction 的 rand 字段 | public | 随机化、field 宏、sequence 直接改约束都要够到 |
driver/monitor 的 vif | 不写限定(事实 public) | config_db get 在类内完成,但 UVM 惯例不封 |
| 组件内部缓存、统计计数、临时句柄 | local | 外人碰了只会耦合出隐性依赖 |
| 基类留给子类扩展的 hook 方法骨架 | protected virtual | 对类外封死、对派生开放,正是 protected 的本职 |
| 自增 id、对象池 freelist、单例指针 | local static | 跨对象共享的类级状态,泄露即全局污染 |
两条判据可以覆盖九成纠结:「这个成员被外人改掉,类的不变量还守得住吗?」守不住就 local;「设计上是否预期子类覆写/读取它?」是就 protected。
封装破坏的典型症状也值得记一笔,因为它们出现时报错点往往离病根很远:子类里莫名编译错「identifier not declared」——多半是父类成员被标了 local;改了基类一个内部字段,三个不相关 test 结果变了——那个字段本不该是 public,早被人跨类直接写;VIP 升级后用户端报缺符号——package 的 export 清单漏了名字。排障时先问「这个访问按可见性表该不该成立」,比逐行看报错上下文快得多。
这一篇在知识体系里的位置
- 上游:SystemVerilog 类、继承与多态 讲派生与虚方法,本篇的可见性判定以那篇的继承模型为前提。
- 配套:SystemVerilog callback 钩子机制 里的 hook 方法骨架正是
protected virtual的典型用法。 - 平台层:分层验证平台与复用架构 讨论「配置与结构分离」,package 作用域纪律是它在文件组织上的落地。
总结
SV 访问控制就三档——默认 public 谁都能碰,protected 只放行继承链,local 连子类也挡在门外;类外再套一层 package/import/export 管跨文件可见性。守的纪律只有一句:transaction 的 rand 字段生来是接口就裸着,内部状态、类级共享量、扩展骨架按「不变量守不守得住、子类要不要够到」两条判据分别落进 local 与 protected——封装划对了,改一处崩三处的事故基本绝迹。