恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
同步FIFO空满判定与Verilog实现:深度8参数化设计详解
首页
资讯中心
/
同步FIFO空满判定与Verilog实现:深度8参数化设计详解
同步FIFO空满判定与Verilog实现:深度8参数化设计详解
发布时间:2026/10/17 2:24:15
最近整理上一轮流片用的内部模块时又被同事问起“同步FIFO到底怎么判空满”才发现很多在FPGA里写了几年代码的人对这个小模块的理解还是“看着 DDR3 控制器或者某个 AXI FIFO IP 的时序图能跑通但让自己从零写一个就露怯”。所以这篇先把同步 FIFO 这件最简单、但所有异步设计都会踩到的地基讲透用 Verilog 实现一个参数化的同步 8 深度 FIFO带完整代码、空满判定方案对比和仿真测试思路。内容适合刚学 Verilog 的校招选手也适合要用裸 FIFO 替代 IP 核的工程师。FIFO 全称 First In First Out本质是一个先进先出的数据队列。同步 FIFO 意味着读写共用同一个时钟域考虑的问题比异步 FIFO 简单很多但空满判断这个核心逻辑恰恰是后续做异步 FIFO 时要处理的最重要问题之一。把同步版本吃透跨时钟域版本才有基础。同步 8 深度 FIFO 本身功能并不复杂写入端在 wr_en 拉高且未满时把数据压入队列读出端在 rd_en 拉高且未空时把最老的数据取走。真正的难点在于读写操作可以同时进行你怎么知道当前队列里有多少数据、到底是空还是满以及如何在极端边界条件下不丢数据、不读空数据。这篇文章我会从指针设计、空满判定、代码实现、仿真验证四个维度逐一拆开最后再聊几个实际工程里必须注意的坑。1. 同步FIFO在数字电路中的定位读写指针与空满判断的核心矛盾1.1 为什么一个小队列会成为一个独立设计话题很多人第一次看到 FIFO 的 RTL 代码觉得不过就是一个数组加两个计数器但真正上手写 testbench 测边界时就会发现问题远没有这么简单。比如你连续写了 8 个数据第 9 个写请求到来时应该被阻塞你连续读了 8 个数据第 9 个读请求到来时读端口不能把无效数据送出去。这两件事同时发生时空满标志的时序必须既准确又及时否则下游模块可能采到错误的数据或者乒乒乓乓地产生一连串协议违例。我在某模数转换器的数据采集链路里调试过一个典型的丢失问题上游 ADC 每 16 个周期产生 1 个样本下游 DSP 模块每两个样本做一次窗函数运算两边的节奏完全不一致如果不加 FIFO 做缓冲要么上游等待浪费采样周期要么下游因数据未就绪而算错。加一个深度 8 的同步 FIFO 后上游只要 FIFO 未满就可以随时写入下游只要 FIFO 非空就可以随时读出两边各自按自己的节拍工作依赖性被完全解耦。这正是 FIFO 的价值所在——它不是一个存储元件而是一个速率匹配和节奏解耦元件。1.2 读写指针的本质两个相对位置不断变化的游标FIFO 的存储体是一个固定深度的数组读写双方各持一个游标写指针永远指向下一个将要写入的地址读指针永远指向下一个将要读出的地址。当读写指针相等时FIFO 可能处于两种状态要么一个数据都没写过空要么刚刚写满一整轮又读空归零也空还有一种诡异状态是写指针绕了一圈后恰好追上了读指针此时如果处理不当就会把满当成空。所以同步 FIFO 的核心矛盾就变成了如何在指针相同时区分“空”和“满”以及在满了之后指针继续绕圈时如何不能把新数据覆盖到旧数据上。深度 8 带来的一个好消息是地址只有 8 个指针位宽很小逻辑非常简单调试时也能手动穷举所有状态所以我才建议初学者从这个规模入手。1.3 深度8的实际含义数据位宽与深度的关系标题里的“8位深度”容易被误解成数据位宽 8 bit实际上它指的是队列深度为 8即最多可以存放 8 个数据。数据位宽是另一个参数我们用DATA_WIDTH单独定义。深度 8 意味着如果数据位宽也是 8 bit整个存储体只有 64 bit综合时会直接放进 LUTRAM 而非 Block RAM这是后续工程选型时要格外注意的。2. 8深度×参数化接口定义与存储体选型的工程考量2.1 模块接口设计原则写 RTL 的第一步永远是定义接口接口信息一旦定型后面改起来非常痛苦。同步 FIFO 的标准接口包括时钟 clk、异步复位 rst_n、写使能 wr_en、写数据 din、读使能 rd_en、读数据 dout、空标志 empty、满标志 full。有些场景还需要 almost_empty / almost_full 做预警或者 wr_count / rd_count 做占用统计但我们第一步只保留最核心的信号避免一次引入太多概念。module sync_fifo #( parameter DATA_WIDTH 8, parameter DEPTH 8 )( input wire clk, input wire rst_n, input wire wr_en, input wire [DATA_WIDTH-1:0] din, input wire rd_en, output reg [DATA_WIDTH-1:0] dout, output wire empty, output wire full ); // 指针位宽计算 localparam ADDR_WIDTH $clog2(DEPTH); // 高位扩展指针位宽比寻址宽度多 1 位 localparam PTR_WIDTH ADDR_WIDTH 1;这里有一个非常容易被忽略的点DEPTH被定义为 8$clog2(8)结果是 3那么地址位宽是 3 位可以表示 0~7。如果我把指针位宽也定义成 3 位那么写满 8 个数据后指针回绕到 0此时读指针也在 0两者相等FIFO 既可能是空也可能是满无法区分。所以空满判定必须要引入一个额外的维度这就是我为什么会用PTR_WIDTH ADDR_WIDTH 1。高位扩展法的核心思想是让指针多出一位“圈数”标志位。当写指针绕完一圈回到地址 0 时最高位翻转一次这样同样指向地址 3第一次到达地址 3 和绕了一圈后再次到达地址 3指针值是完全不同的。最高位不同就能区分出“写指针在下一圈”这个事实而不只是看到地址位相同就判定为空。2.2 存储体寄存器堆还是Block RAM深度 8 的存储体可以直接用寄存器数组实现reg [DATA_WIDTH-1:0] mem [0:DEPTH-1];这个写法综合后的结果通常是 LUTRAM 或分布式 RAM不占用 Block RAM。反过来如果我用 Xilinx 的 BRAM 原语去实现深度 8 的 FIFOBlock RAM 最小粒度也是 18Kb 或 36Kb深度 8 的 FIFO 连零头都用不到严重浪费存储资源。这也是为什么很多场景下小深度的 FIFO 用寄存器堆实现深度超过 64 或 128 时才考虑真正的 Block RAM。判断标准不是“FIFO 一定要用 RAM”而是深度较小的情况下LUTRAM 的时序更好、布局更灵活、不消耗宝贵的 BRAM 资源。我在某图像处理 Demo 里做过一个 64 深度、24 bit 位宽的跨行缓存用 LUTRAM 实现只占了不到 200 个 LUT但换成 BRAM 原语后直接把整片 BRAM 占掉导致后续其他模块没有 BRAM 可用。所以这里的一个经验是深度小于 64 的 FIFO我通常会直接让综合工具推断 LUTRAM不强行例化 BRAM 原语。2.3 参数化设计的陷阱深度必须是2的幂使用$clog2作为地址位宽计算函数有一个隐含前提深度必须是 2 的整数次幂否则$clog2的结果不是整数部分仿真工具会报错或出现 0 位宽的情况。实际工程中 FIFO 深度基本上都是 2、4、8、16、32、64、128、256、512、1024、2048 这样依次翻上去很少出现深度 5、深度 10 这类怪数值。如果需要支持任意深度则要在 wraparound 判断时加入特殊处理即“小于 DEPTH 时加 1等于 DEPTH-1 时回绕到 0”。这会增加一点逻辑但可以保证任意深度都能用。我在做某跨平台系统时一个语音 buffer 需要深度 240因为算法帧长是 30而帧数是 8240 不是 2 的幂。当时我并没有直接扩展成 256 浪费空间而是在参数化设计里加入了通用回绕逻辑。不过第一版设计我强烈建议保持深度为 2 的幂逻辑最简洁边界情况最少。3. 空满判定计数器法、高位扩展法与组合逻辑对比逐项拆解3.1 计数器法最直观但位宽和比较器压力最大计数器法的思路是维护一个cnt记录当前 FIFO 中的数据个数。写入时 cnt 加 1读出时 cnt 减 1读写同时发生时 cnt 不变。empty 为cnt 0full 为cnt DEPTH。reg [$clog2(DEPTH):0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt d0; end else begin case ({wr_en ~full, rd_en ~empty}) 2b10 : cnt cnt 1b1; 2b01 : cnt cnt - 1b1; default : cnt cnt; endcase end end assign empty (cnt d0); assign full (cnt DEPTH[($clog2(DEPTH)):0]);这个方案在小深度下非常好用可读性也高几乎不会写错。但它有几个隐患。首先是 cnt 的位宽是$clog2(DEPTH)1深度 8 时需要 4 位比较器也是 4 位看不出什么问题如果把深度提升到 1024cnt 宽度变成 11 位每个时钟周期都要做 11 位比较和 11 位加减组合逻辑路径变长时序压力随之上升。更深层的问题是 cnt 同时依赖读写两个方向的更新任何一方的毛刺都会直接污染计数结果而且布局布线时cnt 的扇出比较大不利于后端收时序。同步 FIFO 里虽然不像异步 FIFO 那样需要格雷码同步但这种“每个周期都要更新全局状态”的设计风格在高速时钟下可能成为瓶颈。3.2 高位扩展法FPGA 工程中更常用的空满判定高位扩展法的指针位宽比地址位宽多 1 位用这一位的不同来编码“圈数”。当写指针和读指针完全相等时说明 FIFO 为空当写指针的最高位与读指针最高位相反、低几位相同时说明写指针恰好比读指针多走了一整圈即 FIFO 满。以下代码是完整的高位扩展空满判定reg [PTR_WIDTH-1:0] wr_ptr, rd_ptr; wire [ADDR_WIDTH-1:0] wr_addr wr_ptr[ADDR_WIDTH-1:0]; wire [ADDR_WIDTH-1:0] rd_addr rd_ptr[ADDR_WIDTH-1:0]; assign empty (wr_ptr rd_ptr); assign full (wr_ptr[PTR_WIDTH-1] ! rd_ptr[PTR_WIDTH-1]) (wr_ptr[ADDR_WIDTH-1:0] rd_ptr[ADDR_WIDTH-1:0]);判断空的逻辑很简单两指针完全相等。判断满的条件则有两个最高位不同意味着写指针已经比读指针多绕了一圈低地址位相同意味着写指针和读指针正指向同一个 slot而由于最高位不同这个相同的地址是下一圈的位置所以队列满。这个方案的精妙之处在于不需要额外的计数器空满判断只与两个指针相关不依赖读写并行与否。也就是说即使 wr_en 和 rd_en 同时拉高写指针和读指针各自独立递增空满标志也是由指针的实际位置直接推导的不存在“先加后减”的中间态问题。3.3 组合逻辑输出标志 vs 寄存器输出标志空满标志可以做成组合逻辑直接输出也可以打一拍寄存。组合逻辑输出的优点是实时性好写使能在时钟沿被采样的同时full 信号可以立即反映下一拍还能不能写缺点是当指针变化沿与标志产生沿混在同一个组合回路里时可能出现窄脉冲毛刺。在同步 FIFO 里这两个信号通常要送到外部模块的时序逻辑采样所以组合输出即便出现亚稳态窗口只要在下一个时钟沿采样时已经稳定就问题不大。但更稳妥的做法是将其寄存一拍牺牲一个周期的即时性换取时序上的干净。我在某数据采集项目里吃过大亏直接把组合 full 信号拉出来作为中断标志送给 CPU 侧逻辑结果在满与不满切换的瞬间产生了 1ns 量级的毛刺CPU 那边的寄存器正好在毛刺时采样误判成 FIFO 满导致一帧数据被丢弃。从那之后凡是送给外部模块的空满标志我都打了一拍寄存器输出内部数据通路如果是同步采样则直接用组合逻辑标志因为采样沿会对齐毛刺会被彻底滤掉。reg empty_r, full_r; always (posedge clk or negedge rst_n) begin if (!rst_n) begin empty_r 1b1; full_r 1b0; end else begin empty_r empty; full_r full; end end要注意 empty_r 和 full_r 都滞后组合标志一个周期。使用寄存器版标志时写满后还能再写一个数据这个“额外写入”是否允许取决于协议定义。我在下面的完整代码里默认使用组合标志作为读写使能的钳制条件这样时序最贴近教科书模型。4. 完整代码逐段拆解写指针、读指针与数据输出时刻的博弈4.1 完整 RTL 代码把上面的设计整合成一个完整、可综合的模块module sync_fifo #( parameter DATA_WIDTH 8, parameter DEPTH 8 )( input wire clk, input wire rst_n, input wire wr_en, input wire [DATA_WIDTH-1:0] din, input wire rd_en, output reg [DATA_WIDTH-1:0] dout, output wire empty, output wire full ); localparam ADDR_WIDTH $clog2(DEPTH); localparam PTR_WIDTH ADDR_WIDTH 1; reg [DATA_WIDTH-1:0] mem [0:DEPTH-1]; reg [PTR_WIDTH-1:0] wr_ptr; reg [PTR_WIDTH-1:0] rd_ptr; wire [ADDR_WIDTH-1:0] wr_addr wr_ptr[ADDR_WIDTH-1:0]; wire [ADDR_WIDTH-1:0] rd_addr rd_ptr[ADDR_WIDTH-1:0]; // 分为两段第一段为指针更新 always (posedge clk or negedge rst_n) begin if (!rst_n) begin wr_ptr {PTR_WIDTH{1b0}}; end else if (wr_en !full) begin wr_ptr wr_ptr 1b1; end end always (posedge clk or negedge rst_n) begin if (!rst_n) begin rd_ptr {PTR_WIDTH{1b0}}; end else if (rd_en !empty) begin rd_ptr rd_ptr 1b1; end end // 写入与读出 always (posedge clk) begin if (wr_en !full) mem[wr_addr] din; end always (posedge clk or negedge rst_n) begin if (!rst_n) dout {DATA_WIDTH{1b0}}; else if (rd_en !empty) dout mem[rd_addr]; end // 空满判断 assign empty (wr_ptr rd_ptr); assign full (wr_ptr[PTR_WIDTH-1] ! rd_ptr[PTR_WIDTH-1]) (wr_ptr[ADDR_WIDTH-1:0] rd_ptr[ADDR_WIDTH-1:0]); endmodule这段代码每次读写操作都比真实的数据存取晚一拍。对于写端口数据din在wr_en拉高的时钟沿写入mem[wr_addr]对于读端口dout在rd_en拉高的下一个时钟沿才更新。这样设计带来一个特点读使能拉高时当前dout还是上一次的数据下一个时钟沿才会变成新读出的数据。4.2 为什么选择寄存器输出而不是组合输出读数据有两种常见形式// 方式A组合输出 // assign dout mem[rd_addr]; // 方式B寄存器输出 // always (posedge clk) dout mem[rd_addr];组合输出的最大问题是rd_ptr 变化后 dout 立即变化不再与时钟沿对齐。下游如果是一个时序逻辑直接采dout必须在 rd_en 拉高后的下一个时钟沿之前数据保持稳定但组合逻辑的延时和布线抖动可能导致建立时间不够。寄存器输出则把数据变化严格对齐到时钟沿时序分析更容易通过但代价是读延迟增加一拍。实际工程里我会看下游模块的时序要求。如果下游模块只要在 rd_en 拉高后的下一拍采样数据寄存器输出最省心如果下游模块要求 rd_en 和数据出现在同一拍比如某些 AXI 接口那组合输出更合适但需要手工加约束并做后仿真确认。4.3 写满后继续写会发生什么wr_ptr更新条件是wr_en !full所以 FIFO 满时即使wr_en保持拉高指针不移动数据也不会写入mem。这是标准 FIFO 的行为。如果做的是“覆盖写”FIFO也就是满时新数据覆盖最旧数据需要额外修改读指针或采用环形缓冲加计数器的方案不建议在这个模块上插一脚。读空后继续读的情况类似rd_en保持拉高但empty为 1指针不动dout保持上一次的值。这个“读空保持”特性在使用时要特别注意因为下游可能误把残余数据当作有效数据。最稳妥的做法是读出端再增加一个 valid 信号但为了保持协议简单第一版 FIFO 不做这个扩展留给后续版本。5. testbench三场景实战从空到满、满到空与读写同时进行的边界测试5.1 场景一连续写入直到满再连续读出直到空最基础的测试是从空状态连续写 8 个数据检查每个周期 full 信号的变化再从满状态连续读 8 个数据检查 empty 信号的变化。timescale 1ns/1ps module tb_sync_fifo; parameter DATA_WIDTH 8; parameter DEPTH 8; reg clk, rst_n; reg wr_en, rd_en; reg [DATA_WIDTH-1:0] din; wire [DATA_WIDTH-1:0] dout; wire empty, full; sync_fifo #(.DATA_WIDTH(DATA_WIDTH), .DEPTH(DEPTH)) dut ( .clk(clk), .rst_n(rst_n), .wr_en(wr_en), .din(din), .rd_en(rd_en), .dout(dout), .empty(empty), .full(full) ); initial clk 0; always #5 clk ~clk; integer i; initial begin rst_n 0; wr_en 0; rd_en 0; din 0; (posedge clk); rst_n 1; // 连续写 8 个数据验证 full 在第 8 个周期后拉高 for (i 0; i DEPTH; i i 1) begin (posedge clk); wr_en 1; din i 1; end (posedge clk); wr_en 0; #1; if (full ! 1b1) $display(FAIL: full should be 1); // 连续读 8 个数据验证 empty 在第 8 个周期后拉高 for (i 0; i DEPTH; i i 1) begin (posedge clk); rd_en 1; end (posedge clk); rd_en 0; #1; if (empty ! 1b1) $display(FAIL: empty should be 1); #50; $finish; end endmodule注意这里我用了#1延时后检测信号是为了避开时钟沿附近 combinational 输出的亚稳态窗口。如果你直接用(posedge clk)后的 immediately 值可能在同一个时刻采样到旧值产生假 FAIL。5.2 场景二读写同时进行验证空满不误判读写同时进行的测试最容易暴露判断逻辑的错误。假设 FIFO 从空开始每个周期同时写入和读出那么队列永远只有 0 个或 1 个数据full 不应该拉高empty 在写入数据后的一拍内应该拉低。这种场景用来验证空满标志是否被“本来可以写却误判为满”“本来可以读却误判为空”的边界条件带偏。我的测试策略是在前 4 个周期只写不读让 FIFO 变为 4 个数据然后连续 10 个周期同时写读此时 FIFO 内部数据应该保持 4 个不变full 始终为 0empty 始终为 0。如果某一个周期 full 意外拉高说明高位扩展判断逻辑在 wr_ptr 与 rd_ptr 恰好相差一圈时出了错。5.3 场景三随机读写的终极压力测试前面两道测试都是固定模式压制不了隐蔽问题。我习惯再加一轮随机读写测试每个周期用简单伪随机数决定 wr_en 和 rd_en 的取值也可以用 Verilog 的$random同时用独立软件模型模拟一个理想 FIFO逐周期对比理想 FIFO 的 empty/full/dout 与 DUT 是否一致。reg [DATA_WIDTH-1:0] ref_mem [0:DEPTH-1]; reg [ADDR_WIDTH-1:0] ref_wr, ref_rd; reg ref_empty, ref_full; always (posedge clk or negedge rst_n) begin if (!rst_n) begin ref_wr 0; ref_rd 0; end else begin if (wr_en !ref_full) begin ref_mem[ref_wr] din; ref_wr ref_wr 1b1; end if (rd_en !ref_empty) begin ref_rd ref_rd 1b1; end if ((wr_en !ref_full) !(rd_en !ref_empty)) ref_cnt ref_cnt 1b1; else if (!(wr_en !ref_full) (rd_en !ref_empty)) ref_cnt ref_cnt - 1b1; else ref_cnt ref_cnt; end end assign ref_empty (ref_cnt 0); assign ref_full (ref_cnt DEPTH);用 ref_cnt 模型去对拍高位扩展法的 DUT本质是引入了“第三种实现”做交叉验证。实际运行 10 万个随机周期的对拍哪个周期空满标志或 dout 不匹配就可以定位到具体输入序列再用单独 testbench 复现。这个方法帮我抓到过一个几乎不可见的 bug复位释放后前几个周期内 dout 有 X 态。6. 综合实现与常见坑点满标志打拍、数据有效窗口与复位释放时序6.1 满标志打拍后带来的“再写一个”问题如果你把 full 寄存器输出写满后 full 拉高的时刻实际上晚了一个周期。在这一个周期里外部逻辑可能已经再次拉高 wr_en而 RTL 里的写条件用的是!full此时 full 还是旧值 0于是又写入了一个数据。这个行为在某些协议中是致命的因为 FIFO 深度明明是 8却最多能装 9 个数据。解决办法之一是独立生成一个内部组合 full 作为写钳制寄存器 full 只送给外部做状态上报。在上面的完整代码里我对外只利用组合 full 进行写保护因此不存在这个问题。但如果你参考其他代码风格看到full_r直接进wr_en条件需要格外小心它的“一个周期的宽容窗口”。6.2 读数据有效窗口下游采样时的“老数据还魂”寄存器输出的dout在 rd_en 拉高后的一拍才更新这一拍之内如果下游模块误采样会拿到上一轮的数据。很多初学者在调试时发现“为什么我读出来的第一个数据是复位值 0第二个才是真正数据”就是因为没有把读延迟考虑进时序。如果下游要求 rd_en 与 dout 严格同步可选的方案是改回组合输出但要加上适当的时序约束。还有第三种方案是输出端带回读有效信号assign rd_valid rd_en !empty;下游在rd_valid为高时采样就不会被残余值误导。这是我比较推荐的标准做法。6.3 复位释放与时钟沿的竞争异步复位虽然用起来简单但复位释放不能随意交叉在时钟沿上。推荐采用异步复位同步释放的结构否则释放时如果恰逢时钟上升沿指针可能从复位态跳到一个不确定值导致空满标志出错。reg rst_n_r1; reg rst_n_r2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rst_n_r1 1b0; rst_n_r2 1b0; end else begin rst_n_r1 1b1; rst_n_r2 rst_n_r1; end end wire rst_n_sync rst_n_r2;把内部模块的复位信号从rst_n换成rst_n_sync可以避免复位释放与时钟沿竞争。对 FIFO 这种小模块来说复位释放毛刺会导致指针初始状态异常空满判断全面崩溃所以这个细节务必处理好。6.4 综合时的工作量评估深度 8、位宽 8 的同步 FIFO综合后 LUT 用量通常不到 50FF 用量主要来自指针寄存器、dout 寄存器和存储体。资源占用极小但它在时序上的意义远大于资源意义。做布局布线时序分析时主要观察 wr_ptr 和 rd_ptr 的路径延迟因为它们会驱动 mem 的地址端口和空满比较器。从代码可读性的角度我更推荐用localparam把地址位宽和指针位宽分开定义不要写死“3 位”之类的魔数。某同事在某音视频处理项目中把 FIFO 深度从 8 改成 512 时因为代码里到处是写死的2b拼接和3d7比较改了整整三天才全对。参数化设计从第一版开始就养成习惯后面的大深度扩展才会轻松。7. 从同步8深度到下一步这个模块还能怎么演进写到这里同步 FIFO 的基础已经完整闭环存储体、读写指针、空满判定、testbench 验证、综合注意事项该踩的坑也都提了。整个模块在标准流程下大约 30 行 RTL 代码花一个晚上就能写出来并跑通仿真但它背后涉及的指针绕圈、边界判断、打拍策略是所有 FIFO 设计的通用骨架。系列后续如果要继续深入方向有三个比较值得做。第一是异步 FIFO把读写指针各自用格雷码转换再跨时钟域打拍空满判断变成“读指针采写指针”“写指针采读指针”这是你开始真正理解跨时钟域设计的起点第二是 FWFTFirst Word Fall Through模式把读延迟压缩到 0在高速 AXI 接口中很常见第三是附加功能比如 almost_full 预警水印、可编程满阈值、数据打包拆分格式转换这些都是现代 SoC 设计中 FIFO 常见的复杂形态。如果你正在写自己的第一个 FIFO建议先把同步 8 深度的代码敲三遍第一遍照着敲第二遍不看代码默写第三遍用计数器法重新实现再对拍。等你能解释清楚为什么高位扩展法不需要计数器也能判断空满时异步 FIFO 的设计门就算真正推开了。