ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Verilog实现稳健RR调度器:从算法原理到工程实践

Verilog实现稳健RR调度器:从算法原理到工程实践 1. 从“相爱相杀”说起为什么芯片前端工程师绕不开RR调度器如果你在芯片前端设计领域摸爬滚打了一段时间尤其是在做总线仲裁、多通道数据分发或者多核任务调度这类模块时大概率已经和“RR调度器”打过交道并且很可能有过一段“相爱相杀”的经历。所谓“相爱”是因为轮询调度算法本身逻辑清晰、公平性好、实现起来似乎也不复杂是解决多请求源竞争共享资源的首选方案之一。而“相杀”则往往发生在你真正动手用Verilog把它实现出来丢进仿真器尤其是上板实测的时候——你会发现那些教科书上寥寥几行伪代码所忽略的细节比如请求的同步与异步处理、空泡的消除、权重的动态调整、以及和上下游模块的握手时序配合每一个都可能成为让你调试到深夜的“坑”。这个标题里的“RR调度”指的就是Round-Robin轮询调度。它的核心思想就像体育比赛里的循环赛制或者银行、食堂的排队叫号系统确保每个请求者都能被依次、公平地服务到不会出现某个请求者长期“饿死”的情况。在芯片内部当多个主设备比如多个CPU核心、DMA控制器需要访问同一个从设备比如一块共享内存、一个外设或者多个数据流需要复用同一个处理单元时就需要一个仲裁器来做决定RR调度器就是其中最常用的一种仲裁策略。我之所以想详细聊聊用Verilog实现一个稳健的RR调度器是因为我发现很多初学者甚至一些有经验的工程师容易把它想得太简单。网上能找到的示例代码很多只实现了最基础的、不考虑实际时序和交互的“理想模型”一旦放到真实的流水线环境中各种时序冲突、优先级反转的问题就暴露出来了。这次我们就抛开那些过于简化的例子从需求定义、接口设计、核心状态机到仿真验证和常见陷阱完整地走一遍目标是做出一个在真实项目中能直接拿来用或者至少能提供清晰参考的RR调度器模块。2. 需求深挖一个“能用”的RR调度器到底需要什么在动手写第一行代码之前我们必须把需求想清楚。一个RR调度器绝不仅仅是一个“轮流给信号”的逻辑。我们需要定义它的工作场景、接口信号、以及必须处理的边界情况。2.1 核心功能与非功能性需求首先明确核心功能在N个请求者中根据轮询规则每次选择一个且仅一个请求者获得授权。这是最基本的要求。但仅仅这样还不够我们需要考虑以下非功能性需求这些才是工程实现的难点公平性与无饥饿保证这是RR算法的立身之本。必须确保在持续有请求的情况下每个请求者最终都能被服务到。这意味着我们的实现不能因为某个请求者暂时没有请求就把它从轮询队列中“踢出去”导致其后续有请求时也无法被及时响应。低延迟与高吞吐仲裁本身不应该成为系统性能的瓶颈。理想情况下每个周期都应该能输出仲裁结果。这就要求仲裁逻辑尽量组合或者状态转移非常高效。处理请求的动态性请求信号可能随时拉高或拉低。调度器必须能实时响应这些变化。例如当前被轮询到的请求者如果没有请求应该立即跳到下一个有请求的请求者而不是傻等一个周期这被称为“消除空泡”。清晰的接口与握手调度器需要与上下游模块通信。上游是N个请求者下游是被仲裁的资源。通常需要定义请求、授权、完成等握手信号。一个常见的接口是请求者发出req信号仲裁器返回gnt信号请求者在获得授权后使用资源并在使用完毕后可以撤销req或通过单独的完成信号。复位与初始状态系统上电或复位后仲裁器应该从一个确定的状态开始比如从索引0开始轮询或者从一个预设的优先级开始。2.2 接口信号定义基于以上需求我们可以定义一个典型的RR仲裁器模块接口。假设我们有4个请求者N4。module rr_arbiter #( parameter REQ_WIDTH 4 )( input wire clk, input wire rst_n, // 异步低电平复位 input wire [REQ_WIDTH-1:0] req, // 请求信号bit[i]为1表示第i个请求者有请求 output reg [REQ_WIDTH-1:0] gnt // 授权信号bit[i]为1表示授权给第i个请求者。注意理想情况下同一时刻只有1bit为1。 );这是一个最简单的版本只有时钟、复位、请求和授权。但它在实际使用中会遇到问题gnt是一个寄存器输出它会在req变化的同一个时钟周期后更新。如果下游模块需要立即使用gnt比如作为多路选择器的选择信号这个延迟可能无法接受。因此更常见的做法是将核心仲裁逻辑设计成组合逻辑直接根据当前的req和上一次的仲裁历史通常是一个指针寄存器产生本次的gnt。同时我们需要一个寄存器来记录“上一次服务到了谁”也就是轮询指针。module rr_arbiter_comb #( parameter REQ_WIDTH 4 )( input wire clk, input wire rst_n, input wire [REQ_WIDTH-1:0] req, output wire [REQ_WIDTH-1:0] gnt // 注意这里改成了wire由组合逻辑产生 ); reg [REQ_WIDTH-1:0] last_gnt; // 或者用一个指针寄存器如 reg [$clog2(REQ_WIDTH)-1:0] pointer;这样gnt就能在req或last_gnt变化的同一周期内经过组合逻辑延迟更新满足了低延迟的要求。last_gnt则在每个时钟周期根据本次有效的gnt进行更新为下一次仲裁提供历史依据。3. 算法实现选型从“标准轮询”到“掩码轮询”明确了需求和接口接下来就要选择具体的实现算法。RR调度在软件中实现很简单一个循环队列即可。但在硬件中我们需要一个每个周期都能并行计算结果的电路。这里介绍两种最主流的硬件实现方法。3.1 标准轮询算法及其Verilog实现标准轮询的思路很直观维护一个指针pointer指向上一次被授权的请求者索引。下一次仲裁时从pointer1开始顺时针或逆时针查找第一个有请求的位。步骤拆解将当前的请求向量req和指针pointer作为输入。从pointer1位置开始对req向量进行扫描。找到第一个req为1的位将其索引作为本次授权结果并生成一个独热码形式的gnt。如果扫描一圈回到pointer都没有找到有效请求即req全为0则gnt输出全0。在时钟上升沿如果本次gnt非零则更新pointer为本次授权位的索引如果req全为0则pointer保持不变。Verilog实现关键点这种扫描逻辑如果直接用for循环描述综合工具会生成一个优先级选择链当REQ_WIDTH较大时路径延迟可能较长。一种更硬件友好的方法是使用“双倍向量”技巧。// 假设 REQ_WIDTH 4, pointer 为 0 到 3 的整数 reg [1:0] pointer; // 指针寄存器 wire [2*REQ_WIDTH-1:0] double_req; wire [2*REQ_WIDTH-1:0] double_gnt; wire [REQ_WIDTH-1:0] gnt_comb; wire no_request; // 构造双倍向量{req, req}这样从pointer1开始找即使超出原始范围也能在第二份副本里找到 assign double_req {req, req}; // 从 double_req[pointer1] 开始找到第一个为1的位。这里需要一个优先级编码器。 // 我们可以用一个简单的查找逻辑。为了清晰这里用一个for循环描述行为综合器会处理成并行逻辑。 always (*) begin double_gnt 0; for (int i 0; i REQ_WIDTH; i i 1) begin if (double_req[pointer 1 i]) begin double_gnt[pointer 1 i] 1b1; break; // 找到第一个就退出 end end end // 将双倍向量中的授权结果映射回原始宽度。因为double_gnt中只有一位为1且其索引范围在[pointer1, pointerREQ_WIDTH] // 所以可以通过取模或截断的方式得到gnt_comb。 // 更简单的方法将double_gnt的高REQ_WIDTH位和低REQ_WIDTH位相或。 assign gnt_comb double_gnt[REQ_WIDTH-1:0] | double_gnt[2*REQ_WIDTH-1:REQ_WIDTH]; assign no_request (req 0); // 时钟更新逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin pointer 0; end else if (!no_request) begin // 找到gnt_comb中为1的那一位更新pointer为其索引 // 需要一个编码器将独热码转成二进制索引 case (gnt_comb) 4b0001: pointer 0; 4b0010: pointer 1; 4b0100: pointer 2; 4b1000: pointer 3; default: pointer pointer; // 理论上不会进入安全起见 endcase end end assign gnt gnt_comb;这个实现将扫描逻辑通过双倍向量和并行查找实现了避免了长的串行链。pointer的更新发生在时钟边沿而gnt的组合逻辑输出则依赖于当前的req和上一周期的pointer。3.2 掩码轮询算法更优雅的硬件实现标准轮询算法容易理解但“双倍向量”和后续的索引映射逻辑略显繁琐。在工业界更流行一种称为“掩码轮询”的算法它更加简洁和优雅。核心思想根据上一次的授权结果last_gnt一个独热码生成一个掩码mask。这个掩码将last_gnt及其之前更低优先级的位都屏蔽掉置为0。这样做的目的是下一次仲裁只考虑在轮询顺序上排在last_gnt之后的请求者。对原始的req向量应用这个掩码得到masked_req req ~mask。如果masked_req不为零说明在last_gnt之后有请求者那么就对masked_req做一个普通的固定优先级仲裁比如从LSB到MSB即索引0优先级最高。这个仲裁结果就是本次的RR授权。如果masked_req为零说明last_gnt之后没有请求者了。那么就直接对原始的req做固定优先级仲裁。这相当于开始了新的一轮轮询。为什么这样是RR因为掩码确保了上一次被服务过的请求者及其前面的请求者在本轮被暂时“忽略”优先级给了后面的请求者。只有当后面的人都“轮空”时才重新从队头开始。这完美符合了RR“轮流服务”的定义。Verilog实现module rr_arbiter_mask #( parameter REQ_WIDTH 4 )( input wire clk, input wire rst_n, input wire [REQ_WIDTH-1:0] req, output wire [REQ_WIDTH-1:0] gnt ); reg [REQ_WIDTH-1:0] last_gnt; // 组合逻辑部分 wire [REQ_WIDTH-1:0] mask; wire [REQ_WIDTH-1:0] masked_req; wire [REQ_WIDTH-1:0] grant_masked; wire [REQ_WIDTH-1:0] grant_unmasked; wire no_masked_req; // 1. 生成掩码将last_gnt及其之前的所有位都置1 // 例如 last_gnt4‘b0010则mask4’b0011 (即 0010 | 0001) // 一种经典的生成方法是mask last_gnt - 1; 但要注意边界。 // 更通用的方法是使用一个前导零/一检测电路或者用循环。 // 这里用一个简单的位操作技巧适用于小位宽或特定综合工具 // 我们可以这样理解我们需要一个向量从LSB到last_gnt的位含都为1。 // 可以计算mask (last_gnt 1) - 1; 但需要处理last_gnt为0的情况。 // 下面是一种行为描述综合工具会优化 always (*) begin mask {REQ_WIDTH{1b0}}; for (int i 0; i REQ_WIDTH; i) begin if (last_gnt[i]) begin for (int j 0; j i; j) begin mask[j] 1b1; end break; end end end // 2. 应用掩码 assign masked_req req ~mask; // 3. 固定优先级仲裁器LSB优先即索引0优先级最高 // 这是一个标准的优先级编码器将最低有效位的1转换为独热码。 // 注意这里要求输入至少有一位为1否则输出未定义。我们需要先判断。 assign no_masked_req (masked_req 0); // 实现一个LSB优先的优先级编码器输出独热码grant_masked always (*) begin grant_masked {REQ_WIDTH{1b0}}; for (int i 0; i REQ_WIDTH; i) begin if (masked_req[i]) begin grant_masked[i] 1b1; break; end end end // 4. 对原始req做固定优先级仲裁用于masked_req全0时 always (*) begin grant_unmasked {REQ_WIDTH{1b0}}; for (int i 0; i REQ_WIDTH; i) begin if (req[i]) begin grant_unmasked[i] 1b1; break; end end end // 5. 最终授权选择 assign gnt no_masked_req ? grant_unmasked : grant_masked; // 6. 更新历史寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) begin last_gnt {REQ_WIDTH{1b0}}; // 或者初始化为某个值比如 1这样第一次仲裁从索引0开始因为mask初始为0会走grant_unmasked路径 // last_gnt 1; // 这样初始mask为0第一次仲裁使用grant_unmasked end else if (|req) begin // 如果有任何请求则更新。也可以直接用gnt判断。 last_gnt gnt; end // 如果req全0last_gnt保持不变 end endmodule掩码算法的优势在于它将RR仲裁巧妙地分解为“掩码生成”和“固定优先级仲裁”两个步骤。固定优先级仲裁器是硬件中非常成熟且高效的模块通常是一个前导零检测器或其变体。整个逻辑清晰且易于扩展到支持权重Weighted RR——只需要修改掩码生成规则或优先级仲裁规则即可。4. 仿真验证与常见陷阱如何证明你的调度器真的“公平”代码写完了但工作只完成了一半。没有经过充分验证的硬件代码等于一堆废铁。对于RR调度器我们的验证重点就是公平性和正确性。4.1 构建测试平台我们需要一个SystemVerilog或Verilog的测试平台来产生各种激励场景并检查仲裁器的输出是否符合预期。关键测试场景基础功能测试顺序请求。例如让req[0]先有效然后是req[1]再是req[2]检查授权是否按0-1-2的顺序轮转。空泡消除测试在轮询过程中突然将当前被轮到的请求撤销req拉低检查仲裁器是否能立刻跳过它服务下一个有效请求。例如指针在1req[2]为0req[3]为1那么授权应该直接给3而不是停在2等待。并发请求测试多个请求同时有效。检查仲裁是否从上次授权位置之后开始选择。例如上次授权给2当前req4‘b1111那么授权应该给3。全零请求测试所有请求无效时授权输出应为全零且指针last_gnt或pointer应保持不变。复位测试复位后指针是否恢复到初始状态如0或1首次仲裁是否符合预期。随机压力测试使用随机数生成器在长时间内随机切换各个req信号通过断言或覆盖率收集来检查是否永远满足“如果一个请求持续有效它最终会被服务”无饥饿。同时检查是否同一时刻最多只有一个gnt有效。一个简单的SV测试平台框架module tb_rr_arbiter(); localparam WIDTH 4; logic clk 0; logic rst_n; logic [WIDTH-1:0] req; logic [WIDTH-1:0] gnt; rr_arbiter_mask #(.REQ_WIDTH(WIDTH)) u_dut (.*); always #5 clk ~clk; // 100MHz时钟 initial begin rst_n 0; req 0; #20 rst_n 1; #10; // 测试1顺序请求 $display(Test 1: Sequential request); for (int i 0; i WIDTH; i) begin req (1 i); (posedge clk); check_gnt(req, gnt, $sformatf(Seq test iter %0d, i)); // 这里check_gnt是一个自定义任务检查gnt是否与预期匹配 end // 测试2空泡消除 $display(Test 2: Bubble elimination); req 4b0011; // req0, req1有效 repeat(2) (posedge clk); // 预期先授权0再授权1 req 4b0100; // 此时指针应在1之后即2但req[2]0, req[3]0 (posedge clk); // 预期因为req[2]和req[3]都为0且req[0]和req[1]在mask范围内所以授权应为0开始新轮询 // 具体预期取决于你的实现需要根据算法计算 // 测试3随机测试 $display(Test 3: Random stress test); for (int i 0; i 1000; i) begin std::randomize(req) with { req inside {[0: (1WIDTH)-1]}; }; (posedge clk); // 可以加入断言$assert( $onehot0(gnt) ); // 检查gnt是否为独热码或全0 end #100 $finish; end // 检查授权的任务 task check_gnt(input logic [WIDTH-1:0] exp_req, input logic [WIDTH-1:0] act_gnt, input string msg); // 这里可以添加复杂的检查逻辑比如根据last_gnt和req计算预期gnt // 简单起见只检查独热码属性 if ( !($onehot0(act_gnt)) ) begin $error(%s: gnt is not one-hot or zero! act_gnt%4b, msg, act_gnt); end // 如果exp_req不全为0则act_gnt不应全为0 if ( (exp_req ! 0) (act_gnt 0) ) begin $error(%s: Request is non-zero but grant is zero! req%4b, msg, exp_req); end // 如果act_gnt非零其对应的req位必须为1 if (act_gnt ! 0) begin if ( (act_gnt exp_req) 0 ) begin $error(%s: Granted bit is not requested! act_gnt%4b, req%4b, msg, act_gnt, exp_req); end end endtask endmodule4.2 调试中常见的“坑”与解决之道在实际仿真和调试中你可能会遇到以下问题锁死或饥饿某个请求者永远得不到授权。原因指针更新逻辑有误。例如在请求全为0时错误地更新了指针导致指针跳到了一个没有请求的位置并且由于掩码机制低索引的请求被永久屏蔽。排查在测试中让某个请求者如req[0]持续有效但其他请求者随机变化运行足够长的周期观察req[0]的gnt是否偶尔会亮起。如果长时间没有说明公平性被破坏。仔细检查指针更新条件确保只在有效授权发生时才更新指针。授权输出非独热码同一时刻出现了多个gnt位为1。原因组合逻辑仲裁器尤其是自己写的优先级编码器在边界条件下如输入全0输出未定义或者多个条件同时成立时break逻辑没写好。解决在组合逻辑中确保所有输入情况都有明确的输出赋值。使用default分支或完整的if-else、case语句覆盖所有可能。对于优先级编码器确保循环中的break能正确工作。时序问题组合逻辑环路这是最危险的问题之一。如果gnt的组合逻辑依赖于last_gnt而last_gnt又由gnt在时钟沿更新这本身是正常的寄存器-组合逻辑-寄存器路径。但如果你不小心在生成gnt的组合逻辑中又引入了对gnt本身的依赖例如assign gnt some_logic gnt就会形成组合逻辑环路导致仿真出现X值实际电路震荡。排查综合后查看网表或使用lint工具检查。在代码中仔细检查所有always (*)块和assign语句确保没有信号给自己赋值。初始状态不一致复位后第一次仲裁的结果不符合预期。原因last_gnt复位值设置不当。在掩码算法中如果last_gnt复位为全0那么初始掩码mask也为全0。第一次仲裁时masked_req req ~0 req然后走固定优先级仲裁LSB优先。这通常意味着索引0的优先级最高但这并不是标准的RR因为下一轮last_gnt变成了0掩码会屏蔽0服务1这看起来是正常的。但关键在于第一次仲裁它没有“历史”所以从0开始是合理的。如果你想严格规定从某个特定索引开始比如从索引0开始第一轮你需要确保复位后last_gnt的值能产生对应的掩码。一个技巧是将last_gnt复位为1 (WIDTH-1)这样第一次仲裁时掩码会屏蔽掉最高位从索引0开始查找实现了从0开始轮询。解决根据系统需求明确初始仲裁策略并相应设置复位值。5. 从基础到进阶权重轮询与可变请求者数量一个基础的、固定的RR调度器可能无法满足所有场景。这里探讨两个常见的扩展方向。5.1 权重轮询调度器的实现思路基础RR是平等的但有时我们希望给某些请求者更高的带宽比例这就需要权重轮询。例如请求者0的权重是2请求者1的权重是1那么调度顺序可能是0, 1, 0, ... 而不是0, 1, 0, 1, ...实现方法之一计数器法为每个请求者设置一个权重计数器weight_counter[i]。初始化所有计数器为各自的权重值。仲裁时选择权重计数器大于0且当前有请求的请求者中按RR规则选择可以基于基础RR仲裁器。被选中的请求者其计数器减1。当所有被选中的请求者的计数器都减到0时将所有请求者的计数器重置为初始权重。这种方法直观但需要维护多个计数器且重置逻辑稍复杂。实现方法之二配额列表法将RR调度看作一个“令牌”分发过程。我们可以预先根据权重计算出一个调度序列列表。例如权重为[2,1]则序列为[0, 1, 0]。然后用一个指针在这个序列中循环。每次仲裁时检查序列中当前指针所指的请求者是否有请求。如果有则授权如果没有则指针前进到序列中下一个有请求的请求者位置。这本质上还是RR只是轮询的“槽位”数量变多了某些请求者占据了多个槽位。在硬件中这个序列可以用一个小的ROM或查找表实现指针就是一个简单的循环计数器。这种方法适合权重固定且数量不大的场景。5.2 支持动态请求者数量有时系统中的请求者数量不是固定的比如可配置的虚拟通道。我们的仲裁器需要能处理req向量中有效的位数动态变化的情况。一种方法是使用一个valid掩码信号。input wire [REQ_WIDTH-1:0] req_valid; // 对应位为1表示该请求者存在/使能在仲裁逻辑中我们只考虑req req_valid有效的位。在生成掩码或进行优先级仲裁时需要忽略那些valid为0的位。这需要在掩码生成和优先级编码器中加入valid的判断稍微增加了逻辑复杂度。核心思想是一个不存在的请求者既不应该被服务也不应该影响轮询指针的路径。指针在跳过无效请求者时应视为它们不存在。6. 与系统集成握手协议与性能考量最后我们的RR仲裁器需要集成到更大的系统中。这涉及到与上下游模块的握手协议。6.1 常见的握手协议简单请求-授权协议如我们之前定义的请求者拉高req仲裁器在下一周期或组合逻辑延迟后拉高对应的gnt。请求者在获得授权后开始传输传输完成后自行拉低req。这种协议简单但仲裁器不知道传输何时结束可能造成资源浪费授权后请求者可能还需要准备数据。请求-授权-完成协议在req/gnt基础上增加一个done或release信号。请求者获得授权后使用资源使用完毕后拉高done信号。仲裁器在收到done后才认为本次服务结束可以更新指针进行下一次仲裁。这种协议更精确但需要额外的信号线。AXI/ACE等总线协议中的仲裁这些标准总线有复杂的握手信号如VALID/READY。仲裁器通常仲裁的是VALID信号。例如多个AXI主设备发出读地址请求ARVALID仲裁器根据ARVALID和RR规则选择其中一个主设备的ARREADY置为有效。此时仲裁器通常是互联矩阵的一部分需要同时处理地址、数据、响应通道的仲裁与复用复杂度更高。6.2 性能优化点提前仲裁如果知道请求会提前若干周期到来可以提前进行仲裁将授权结果流水线化从而隐藏仲裁延迟提高系统频率。多级仲裁当请求者数量非常多时比如64个单级RR仲裁器的组合逻辑路径可能很长。可以采用树形结构先分组进行第一级RR仲裁每组选出一个胜者再进行第二级的总仲裁。这牺牲了一点公平性组间和组内可能不是严格的全局RR但提升了时序。寄存器输出与流水线如果gnt的组合逻辑路径成为关键路径可以考虑将gnt打一拍寄存器输出。但这会引入一个周期的授权延迟需要系统其他部分配合。也可以将仲裁逻辑本身流水化。实现一个稳健可靠的RR调度器是芯片前端工程师的基本功。它看似简单却蕴含着硬件设计中对并发、时序和公平性的深刻理解。从清晰的需求分析到优雅的掩码算法实现再到严谨的仿真验证和系统集成每一步都需要仔细推敲。希望这篇详细的拆解能帮你下一次与RR调度器“相遇”时少一些“相杀”多一些“相爱”让它成为你手中构建复杂芯片系统的得力工具。记住多写测试多看波形尤其是边界情况的波形是驯服任何硬件逻辑的不二法门。
返回列表