ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

RISC-V调试系统实战:从JTAG到DM的Verilog实现与避坑指南

RISC-V调试系统实战:从JTAG到DM的Verilog实现与避坑指南 1. 为什么调试系统是RISC-V开发的“隐藏命门”搞数字IC或者嵌入式开发的人迟早会撞上一个绕不开的大山调试系统。很多朋友写Verilog时仿真跑得飞起波形一看全都对可一旦代码下载到FPGA板子上或者流片回来之后芯片不干活了、跑飞了、寄存器值莫名其妙地变这时候只能干瞪眼靠猜、靠蒙、靠JTAG线上用示波器反复量电平。我见过太多项目在调试环节翻车不是算法不对也不是逻辑写错而是整个调试链路根本没打通——芯片根本连不上调试器更别提看内核状态了。RISC-V架构之所以在这几年迅速普及除了开放指令集本身Debug System规范的完善也是功不可没的。它不像某些老架构的调试接口那样私有化严重而是定义了一套从上位机工具比如OpenOCD、GDB一路下沉到处理器核内部的一条完整链路。这条链路的三层核心就是标题里说的JTAG、DMI和DM。这篇文章我从零开始把这三层到底是什么关系、每一层干哪些活、在Verilog里应该怎么实现一次讲透。不光是讲协议概念还会给你能直接参考的代码写法和实操中踩过的坑。不管你是学生、做SoC集成的工程师还是FPGA开发爱好者只要你想把RISC-V的调试系统搞明白这篇文章应该能帮你省下不少走弯路的时间。2. 从物理引脚到调试寄存器三层链路各自管什么很多人一开始就被JTAG、DMI、DM这三个缩写绕晕了。我打个比方你就明白了整个调试系统就像你要进一栋大楼去某个房间找人。JTAG是大楼的门禁系统你从外面的门进去经过一道道门禁TAP状态机最终进入大堂DMI总线。DMI是楼里的走廊和路标告诉你房间号在哪里、怎么走。DM就是你要找的那个房间里面放着真正的业务人员——调试寄存器通过这些寄存器你可以控制CPU核暂停、运行、读写寄存器。2.1 JTAG层物理通道与TAP状态机JTAG层解决的最核心问题是“怎么把二进制位流从外部调试器送进芯片内部”。这一层是纯串行的和协议层面的地址、数据完全无关。它需要实现五根标准信号线TCK测试时钟给TAP状态机提供时钟节拍TMS测试模式选择控制TAP状态机跳转的按键TDI测试数据输入串行数据入口TDO测试数据输出串行数据出口TRST可选测试复位异步复位TAP状态机需要特别注意TCK是独立于系统时钟的它在调试时由调试器比如JTAG调试器、OpenOCD配合的FTDI芯片产生。这就意味着调试系统是一个跨时钟域系统TCK域这边跑JTAG协议系统域那边跑CPU核逻辑两者频率、相位都不同。跨时钟域处理做不好调试系统就是一块废铁。TAP状态机的核心是一个16状态的有限状态机它根据TCK上升沿时TMS的电平来决定如何迁移。状态机有两个“主跑道”一路是跑到Shift-DR状态用来移位数据寄存器另一路是跑到Shift-IR状态用来移位指令寄存器。指令寄存器的作用很关键——你通过IR选择接下来要操作哪条数据寄存器链路比如选择IDCODE寄存器读芯片ID、BYPASS寄存器旁路、或者选择DMI访问专用寄存器。以一个标准的JTAG时序为例你通过TMS序列把一个8位的指令比如0x1表示IDCODE移入IR然后在Shift-DR阶段移入数据。整个过程看起来繁琐但在硬件上实现起来就是一套非常典型的状态机逻辑后面第三部分我会展开写。2.2 DMI层调试模块的“内部总线”当JTAG把数据链路打通之后数据位流到底要传到哪里去这就是DMIDebug Module Interface的职责了。DMI是一条串行接口总线它的宽度定义在RISC-V Debug Specification里固定为三部分构成地址7位可以直接寻址128个调试寄存器槽位数据32位每个寄存器槽位的宽度操作码2位表示本次是读操作0b01、写操作0b10还是无操作0b00这3个字段加起来是41位在JTAG的TAP层会被当成一条“数据寄存器”来处理。当你在Shift-DR阶段移入41个bit时最后一次捕捉Capture-DR会把这41位锁存到DMI的寄存器里然后DMI控制器会在内部总线上发起对应的读写操作。这里有一个关键点DMI并不直接连接到CPU核。它是连接到DM模块的寄存器堆上的。也就是说DMI是一条“窄总线”它一次只能做一件事要么发起一次写请求要么发起一次读请求。而DM模块内部寄存器的内容就是通过这条窄总线被外部工具访问的。用OpenOCD操作时你敲下一条reg命令查看内核寄存器这条命令的底层动作是OpenOCD通过JTAG发送一条DMI写操作到寄存器abstractdata再发送一条读操作到command寄存器然后通过DMI访问DM内部状态最终返回数据。这个过程对用户来说是在几百毫秒内完成的但底层实际上已经走了无数个JTAG时钟周期。2.3 DM层真正“管住”CPU核的寄存器堆DMDebug Module是整个调试系统的大脑。它不在CPU核内部而是作为SoC里的一个外设模块通过一组寄存器与CPU核的调试接口通常是RISC-V的Debug ROM和Program Buffer机制相连。DM内部有一堆寄存器按功能分类dmcontrolDebug Module Control0x10最核心的控制寄存器用来发起halt、resume、reset请求以及选择要调试的hartdmstatusDebug Module Status0x11状态寄存器反馈当前调试状态比如hart是否处于halt状态hartinfoHart Info0x12描述每个hart的调试能力abstrctcs、command、abstractdata0x16、0x17、0x18抽象命令接口用来间接访问CPU核的寄存器和内存sbcs、sbaddress0/1、sbdata0/10x38-0x3D系统总线访问接口可以直接读写内存映射空间progbuf0-150x20-0x2F程序缓冲用来向CPU核注入调试指令看到这一堆寄存器你可能会觉得头大。但实现DM时你完全不需要一次把全部功能都做完。一个最小可用版本的DM只需要实现关键的几个寄存器就能让OpenOCD跑通dmcontrol、dmstatus、command、abstractdata、abstractauto再加上程序缓冲的一小部分。这也是我想强调的真正的工程实践里调试系统第一步的目标不是拿到完整实现而是“用最少的逻辑打通从OpenOCD到CPU核的第一条通路”。通路通了后面加功能是水到渠成的事。3. Verilog实现前的关键设计决策现在到了动手写Verilog的阶段。但在打开编辑器之前有过工程经验的人都知道有几个设计决策必须先想清楚。这几个决策直接决定了你的代码难不难写、挂上OpenOCD以后好不好调。3.1 时钟域方案Single Clock还是Async JTAG最省事的做法是让TCK直接作为调试系统的工作时钟整个JTAG域和DMI域都用TCK驱动。TAP状态机、DMI控制逻辑、甚至DM寄存器堆的读写都挂在TCK上。这个方案的优点是逻辑简单、无跨时钟域问题仿真也好写。缺点是你不能在全速运行CPU核的同时进行调试——因为CPU核是跑在系统时钟上的而调试模块跑在TCK上两者异步。在实际工程中这会带来一个很麻烦的问题当你访问DM寄存器时CPU核正在改变自己内部的状态导致调试器读回的数据可能不一致。更完整的方案是用系统时钟驱动DM模块内部逻辑JTAG域只负责把DMI的41位请求跨时钟域搬运到系统域。这样一来DM和CPU核天然同域同步逻辑好写状态一致性好。但跨时钟域处理是一个大坑。你在JTAG域上得到的41位DMI请求不能直接拿到系统域去用必须做同步处理。常见做法是JTAG域把请求写进一个异步FIFO系统域读出来执行。反过来系统域的返回数据也要经过FIFO送回JTAG域。异步FIFO在RISC-V调试系统里几乎是标配。具体实操时我的建议是如果你是初学者或者受限于FPGA开发板的资源第一版可以用纯TCK同步的方案先把功能跑通。等链路通畅了、OpenOCD能识别CPU核了再回过来做异步FIFO改造升级到完整方案。千万不要一上来就想着一步到位否则出问题的时候你根本分不清是协议理解错了、时序写错了、还是跨时钟域搬数据搬坏了。3.2 DMI访问的响应机制Busy和重试DMI协议里有一个非常重要的状态位dmstatus寄存器里的anybusy位。它表示DM模块当前是否空闲。外部调试器在发起一次DMI访问后如果立即读dmstatus可能会看到anybusy为1说明上一次访问还没有完成。这就是OpenOCD代码里为什么会有大量“轮询dmstatus等待busy清0”的逻辑。很多人在自己写Verilog实现DM的时候忽略了busy机制直接让DMI请求一拍内响应。这在简单的仿真波形里看不出问题但一旦接上真实的OpenOCD你会碰到一个典型现象OpenOCD提示Error: DMI operation didnt complete然后连接就断了。原因很简单DMI操作有快有慢。访问DM寄存器是快的一拍就能完成。但通过DM访问CPU核内部的寄存器抽象命令可能是慢操作——CPU核可能正在执行任务你要等它被打断、进入调试模式之后才能返回结果。如果DMI在CPU核没准备好之前就返回数据结果就是错误的。而正确实现是当CPU核忙时DMI返回operation pending状态调试器稍后再试。所以在你的DMI状态机设计里必须包含一个wait_empty分支收到DMI请求后先把请求寄存下来然后检查DM模块是否空闲空闲则执行不空闲就置busy标志等待下一条DMI指令轮询到状态位之后再来取结果。3.3 最小寄存器集合的取舍我见过不少初学者一上来就想把RISC-V Debug Spec里的所有寄存器全部实现结果代码写了一两万行逻辑混乱根本跑不通。实际上OpenOCD引导流程跑通并不需要全功能——核心只依赖这几个寄存器最基础的dmcontrol选择hart、发起halt/resume、dmstatus查询状态、command发抽象命令、abstractdata读写CPU核寄存器数据、abstrctcs查询抽象命令是否忙。对于通过调试接口访问内存的场景还需要sbcs、sbaddress0、sbdata0System Bus访问。程序缓冲Program Buffer是另一个能够提升调试效率的关键寄存器。它的作用是在CPU核进入调试模式后向Debug ROM注入一小段指令让核去执行csrw dcsr, x0之类的操作从而完成寄存器的读写。最小实现里progbuf0一个缓冲槽基本够用因为OpenOCD在访问通用寄存器时主要依靠Abstract Command程序缓冲只是辅助。在设计你的DM模块时先画一张表列出每个寄存器要实现哪些位字段哪些位可以固定读0哪些位必须接真实状态。比如dmstatus的allhalted位必须是从CPU核的halt信号反馈过来的不能用常量。而impebreak这样的功能位你可以先固定为0表示不支持OpenOCD照样能正常工作。4. 手写VerilogJTAG TAP到DMI桥接的代码骨架好了设计决策定了接下来真正开始写代码。我从实践的角度给你一套可以直接用的Verilog骨架。这套代码不是教科书写法而是我在工程里实际跑通的简化版本注释里会写清楚每一段是干什么的。4.1 TAP状态机的实现要点TAP状态机的Verilog实现核心是一段很典型的case语句。我在工程里的写法大致是这样的// TAP状态机核心16状态TCK上升沿根据TMS跳转 localparam [3:0] TEST_LOGIC_RESET 4d0, RUN_TEST_IDLE 4d1, SELECT_DR 4d2, CAPTURE_DR 4d3, SHIFT_DR 4d4, EXIT1_DR 4d5, PAUSE_DR 4d6, EXIT2_DR 4d7, UPDATE_DR 4d8, SELECT_IR 4d9, CAPTURE_IR 4d10, SHIFT_IR 4d11, EXIT1_IR 4d12, PAUSE_IR 4d13, EXIT2_IR 4d14, UPDATE_IR 4d15; reg [3:0] tap_state; reg [7:0] ir_shift_reg; // 指令寄存器移位寄存器 reg [40:0] dr_shift_reg; // 数据寄存器移位寄存器DMI访问41位 always (posedge tck or negedge trst) begin if (!trst) begin tap_state TEST_LOGIC_RESET; end else begin case (tap_state) TEST_LOGIC_RESET: tap_state tms ? TEST_LOGIC_RESET : RUN_TEST_IDLE; RUN_TEST_IDLE: tap_state tms ? SELECT_DR : RUN_TEST_IDLE; SELECT_DR: tap_state tms ? SELECT_IR : CAPTURE_DR; CAPTURE_DR: tap_state tms ? EXIT1_DR : SHIFT_DR; SHIFT_DR: tap_state tms ? EXIT1_DR : SHIFT_DR; EXIT1_DR: tap_state tms ? UPDATE_DR : PAUSE_DR; PAUSE_DR: tap_state tms ? EXIT2_DR : PAUSE_DR; EXIT2_DR: tap_state tms ? UPDATE_DR : SHIFT_DR; UPDATE_DR: tap_state tms ? SELECT_DR : RUN_TEST_IDLE; SELECT_IR: tap_state tms ? TEST_LOGIC_RESET : CAPTURE_IR; CAPTURE_IR: tap_state tms ? EXIT1_IR : SHIFT_IR; SHIFT_IR: tap_state tms ? EXIT1_IR : SHIFT_IR; EXIT1_IR: tap_state tms ? UPDATE_IR : PAUSE_IR; PAUSE_IR: tap_state tms ? EXIT2_IR : PAUSE_IR; EXIT2_IR: tap_state tms ? UPDATE_IR : SHIFT_IR; UPDATE_IR: tap_state tms ? SELECT_DR : RUN_TEST_IDLE; default: tap_state TEST_LOGIC_RESET; endcase end end这一段是标准的JTAG实现没有什么玄机。但有几个实操细节容易被忽略我专门点一下当TAP处于SHIFT_DR或SHIFT_IR状态时TDI的数据是在TCK的上升沿采样的同时TDO在TCK的下降沿更新。这个看似不起眼的时序细节决定了你的TAP是否能在真实硬件上稳定工作。在仿真里你可能察觉不到但在真实FPGA上采样和更新沿搞反会导致数据完全错位。数据寄存器的移位长度是可变的。RISC-V Debug Spec定义DMI数据寄存器是41位但IDCODE寄存器是32位BYPASS是1位。所以你的dr_shift_reg最好设计成统一的、足够宽的寄存器比如64位然后在SHIFT_DR阶段根据当前IR的值决定移多少位。很多初学者把每一类寄存器分开实现结果逻辑冗余还容易出错。UPDATE_DR这条路径是DMI请求真正生效的时机。当TAP进入UPDATE_DR状态标志着外部调试器已经把完整的41位数据寄存器移位完成此刻你才可以把dr_shift_reg的内容锁存进DMI请求寄存器并触发DMI访问。4.2 DMI控制器状态机DMI控制器负责解析41位请求执行真正的DM寄存器读写。我实现它时用一个简单的三态状态机localparam DMI_IDLE 2d0, DMI_WAIT 2d1, DMI_DONE 2d2; reg [1:0] dmi_state; reg [6:0] dmi_addr; reg [31:0] dmi_wdata; reg [1:0] dmi_op; reg [31:0] dmi_rdata; // 返回数据 reg dmi_busy; always (posedge clk or negedge rst_n) begin if (!rst_n) begin dmi_state DMI_IDLE; dmi_busy 1b0; end else begin case (dmi_state) DMI_IDLE: begin // 检测到来自JTAG域的DMI请求锁存信号 if (dmi_req_valid) begin dmi_addr dmi_req_addr; dmi_wdata dmi_req_wdata; dmi_op dmi_req_op; dmi_busy 1b1; dmi_state DMI_WAIT; end end DMI_WAIT: begin // 等待DM模块响应。根据操作类型分类处理 if (dmi_op 2b00) begin // 无操作立即完成 dmi_state DMI_DONE; end else if (dm_access_ready) begin // DM模块返回access_ready信号表示读写完成 dmi_rdata dm_rdata; dmi_state DMI_DONE; end end DMI_DONE: begin dmi_busy 1b0; dmi_state DMI_IDLE; end endcase end end这里的关键是dm_access_ready信号。这个信号来自DM模块它必须对不同的操作给出不同的响应时机对于访问dmcontrol、dmstatus这类直接寄存器dm_access_ready可以组合逻辑或一拍延时产生对于访问command寄存器发起的抽象命令dm_access_ready必须在CPU核完成调试操作之后才拉高真正的工程实现中许多团队会给DMI访问加上超时计数防止CPU核异常时调试模块永远卡死我之前就踩过一个坑DM模块内部抽象命令都执行完了但CPU核没有正确进入调试模式导致dm_access_ready迟迟不拉高。OpenOCD那边会一直重试最后以超时报错结束。这种情况排查起来非常痛苦因为你很难通过仿真复现——毕竟仿真环境下CPU核的执行行为是确定性的。4.3 DM寄存器堆的地址译码范例DM寄存器堆就是一堆寄存器的读写逻辑。地址译码部分我认为是最好写的但也最容易写错——因为它太“基础”了人们反而不够重视把某个寄存器的偏移地址写错一位就会导致OpenOCD读到错误的版本号进而拒绝连接。下面是一段地址译码的参考写法只覆盖核心寄存器// DM寄存器访问译码地址宽度7位 always (*) begin // 默认值 dm_rdata 32h0; case (dmi_addr[6:0]) 7h11: begin // dmstatus dm_rdata[0] 1b1; // version固定为1 dm_rdata[3] ime_break; // 不支持时置0 dm_rdata[8] 1b0; // 未连接remote dm_rdata[9] 1b1; // 至少1个hart dm_rdata[10] 1b0; // anyrunning dm_rdata[11] all_halted; // 所有harthalt dm_rdata[12] all_resume; // 所有hartresume dm_rdata[13] 1b0; // 无复位请求 dm_rdata[14] 1b0; // 无复位完成 dm_rdata[15] 1b1; // 支持hasel end 7h10: begin // dmcontrol // 写操作时在这里解析 end 7h16: begin // abstrctcs dm_rdata[8:0] 9h1; // datacount 1 dm_rdata[15:9] 7h0; // progbufsize 0 dm_rdata[21:16] 6h0; // 不支持额外总线 dm_rdata[31:22] 10h0; end default: dm_rdata 32h0; endcase end // dmcontrol 写操作的解析逻辑 always (posedge clk) begin if (dmi_op 2b10 dmi_addr[6:0] 7h10) begin // ndmreset: 复位所有hart if (dmi_wdata[1]) ndmreset 1b1; // haltreq: 请求halt if (dmi_wdata[31]) halt_req 1b1; // resumereq: 请求resume if (dmi_wdata[30]) resume_req 1b1; // hartsel: 选择hart hartsel dmi_wdata[25:16]; end end你在观察这段代码时可能会注意到dmstatus寄存器里我把version固定为1这是RISC-V Debug Spec 0.13版本的规定。如果你的OpenOCD版本比较新它可能会兼容0.13和1.0.x协议这里的version位会根据协议版本不同而需要不同的值。一个实用的选择是先按0.13实现因为OpenOCD对0.13的支持非常成熟等你的系统跑通了再升级协议版本。4.4 与CPU核的接口信号halt/resume/executionDM模块最终要控制CPU核这个控制是通过一组标准的控制信号完成的。不同的CPU核实现比如rocket、BOOM、sweRV、scr1提供的调试接口信号名称略有不同但功能是等价的我整理了一份典型的信号表信号方向名称功能DM - CPUdebug_req请求CPU核进入调试模式haltDM - CPUdebug_resume请求CPU核退出调试模式resumeCPU - DMdebug_halted确认CPU核已进入调试模式CPU - DMdebug_running确认CPU核正在运行CPU - DMdebug_pcCPU核当前PC值读取需要在DM内部halt逻辑非常简单dmcontrol[31]位写1debug_req拉高CPU核响应后debug_halted拉高DM将allhalted状态位置1完成一次halt请求。但真正的难点在resume操作。CPU核从调试模式恢复运行时需要保证两条指令之间不会出现竞争。如果你在CPU核还没有完全退出调试模式时就再次发送halt请求行为是未定义的。这里我的建议是在DM内部加一个小状态机专门处理halt和resume之间的转换确保CPU核处于稳定状态后才接受新的请求。这个状态机不复杂但它能避免你在复杂SoC上遇到的各种“幽灵bug”。5. 实操中逃不掉的“坑”从OpenOCD连接失败到复位风暴代码写完了、仿真也过了接下来上真板子。这是我见过最多人卡住的地方。OpenOCD连接失败、报错信息千奇百怪你百度搜来搜去也找不到靠谱答案。我把这几年实际调试中踩过的坑集中整理了一下分为四类最常见的场景。5.1 信号线接错或浮空导致连接失败报错特征OpenOCD提示JTAG-DP STICKY ERROR或Could not stop Cortex-M device!这类错误。虽然报错信息里提到Cortex-M但是同样的底层问题在RISC-V调试器里也很常见。排插思路用示波器或逻辑分析仪量TCK、TMS、TDI、TDO四根线确认调试器确实发出了波形。很多所谓“连接失败”其实是线都没接对确认TRST引脚是否被拉高。TRST在有些调试器上是必须接的在有些调试器上是可选的。如果FPGA开发板上TRST接法不对TAP状态机会一直处于复位状态检查TDO是否有输出。TDO高阻态几乎可以断定TAP还没进入正确状态确认JTAG链上是否串了多颗芯片。如果你在FPGA内部级联了多个TAP比如同时调试多个RISC-V核第一个TAP不工作后面全都不通遇到这类问题最笨但也最有效的方法是先用jtag调试器配合简单的BYPASS测试确认TAP链路本身是否工作。BYPASS指令是0x3F数据寄存器是1位的你移入1个bit再从TDO读出来如果值对得上说明JTAG链路没问题。5.2 DMI访问永远返回Busy报错特征OpenOCD可以连上JTAG但访问DM寄存器时dmstatus的anybusy位永远为1导致操作超时。这个问题的根源绝大多数情况下出在你的DM模块没有正确响应dm_access_ready。我见过一个特别典型的错误写法作者在DM模块里用组合逻辑判断操作是否完成但dm_access_ready信号用的是always块里的reg类型变量在仿真里看起来没问题到真实硬件上因为组合逻辑的传播延迟导致ready信号和dmi_addr信号不在同一个时钟周期匹配结果DMI控制器永远等不到ready。排查方法在DMI控制器和DM模块之间插入一段寄存器把地址、数据、操作类型打一拍确保所有信号在同一个时钟沿对齐。这个简单改动解决了我项目里80%的“busy永远不消失”问题。另一个原因你的DM模块检测到DMI写操作command寄存器时向CPU核发起了抽象命令请求。但CPU核根本没有实现对应的调试扩展或者核当前正处于复位状态。这种情况下抽象命令永远不会完成DMI自然一直busy。处理办法是在DM模块内部增加超时机制超时后返回错误而不是永远卡死。提示调试系统是个“外部可观测但内部不可见”的系统。当DMI一直busy时你的FPGA内部波形可能看不出任何异常因为DM模块确实按协议操作了只是CPU核没有响应。这时候最好的手段是在DM模块里加一个计数器把访问超时和操作次数导出到调试接口通过ILA等工具实时观察。5.3 复位之后调试状态丢失不少人在FPGA上实现RISC-V调试系统遇到一个奇怪问题刚上电时OpenOCD能连接成功也能看到CPU核但一旦执行复位操作调试链路就断了必须重新上电才能恢复。这个问题十有八九出在ndmreset和dmreset的处理上。RISC-V Debug Spec里ndmreset是复位所有hart处理器核的而dmreset是复位DM模块自身的。很多实现把ndmreset信号直接连到了整个SoC的全局复位树上包括DM模块自己的复位信号。这会导致一个严重后果当OpenOCD设置ndmreset位为1时DM模块自己也被复位了它还没来得及处理完复位动作状态机就清零了所有状态全部丢失。正确的做法是ndmreset只能复位CPU核的逻辑绝对不能复位DM模块本身。你需要把CPU核的复位信号与DM模块的复位信号在物理上隔离开。如果你的SoC里只有一个全局复位源那你需要在DM模块内部做一次复位同步处理// 将CPU核复位信号与DM复位隔离 wire rst_n_global; reg [1:0] rst_sync; always (posedge tcak_or_sys_clk or negedge rst_n_global) begin if (!rst_n_global) begin rst_sync 2b00; end else begin rst_sync {rst_sync[0], 1b1}; end end wire rst_n_dm rst_sync[1]; // DM模块使用同步后的复位这段代码的作用是让DM模块在全局复位释放后仍然保持一小段时间的复位状态确保CPU核已经稳定再让DM模块开始工作。实际工程中这种最小复位同步电路能解决大量莫名其妙的调试异常。5.4 多核调试时的Hart选择问题如果你想调试多核系统比如双核RISC-VOpenOCD会依次连接每个hart。这时一个典型的坑是dmcontrol的hartsel字段选择hart之后必须等到dmstatus的allhalted或anyhalted状态位反映正确之后才能执行后续操作。如果你选中hart0然后立刻去读hart1的寄存器得到的数据很可能是hart0的。多核调试的正确流程是通过hartsel选择要调试的hart轮询dmstatus直到allhalted变为1再发起抽象命令读写上文提到的寄存器如果你的SoC里每颗核的调试接口是独立的而DM模块只有一个那么你必须为每颗核单独维护一套halt/run状态标志并且让dmstatus里的状态位正确反映“当前选中的hart”的状态而不是全局所有核的汇总状态。这是多核调试实现里最容易出bug的部分。6. 从最小系统到完整功能后续还能怎么扩展在最小系统跑通之后你可能会发现当前实现的功能非常基础OpenOCD虽然能连上、能看到GDB的info registers但很多高级功能用不了。这是正常的接下来你就可以按需逐步扩展。优先推荐扩展的第一项是抽象命令的完整实现。目前你可能只实现了Access Register类型的抽象命令也就是通过command寄存器指定寄存器地址然后用abstractdata寄存器读写数据。这个功能已经能覆盖最核心的调试需求了。下一步你可以考虑实现Access Memory类型的命令这样GDB的x命令就能直接查看内存了。第二项值得扩展的是Program Buffer功能。它的作用是让CPU核执行一小段你自定义的指令序列这可以让调试器访问到普通抽象命令难以访问的系统资源和CSR寄存器。比如你想读取某个自定义的performance counter就可以在CPU核进入调试模式后注入一条csrr指令把CSR值搬运到通用寄存器然后通过抽象命令读出来。Program Buffer在工程中非常常用它对调试器来说是透明的但对CPU核来说等于在执行一小段代码。最后如果你的SoC里集成了DMA、网络接口等复杂外设你可以实现System Bus Access接口。这个功能允许调试器直接读写系统总线上挂载的所有外设寄存器而不需要CPU核的参与。在芯片启动早期调试阶段这个功能的价值不可估量——你可以在CPU核完全没有执行任何代码的情况下检查每个外设的初始化配置。我在实际做RISC-V调试系统时最后还想分享一个小技巧在DM模块里加入一个可读写的周期计数器。这个计数器只在调试模式下递增专门用来测量OpenOCD发起一次抽象命令到完成的时钟周期数。这看起来很简单但在调优调试系统性能时极其有用——你会发现某些操作比你想象的慢一个数量级瓶颈可能在CPU核的响应延迟、DMI的总线宽度、JTAG的时钟频率等不同环节。有了这个计数器你就能把优化方向锁定在正确的环节而不是靠猜。调试系统是一个典型的“看起来简单、做起来全是细节”的模块。JTAG、DMI、DM三层协议单独拎出来都不复杂但把它们串起来再放进一个多时钟域、多核、带复位管理的真实SoC里复杂性一下子就被放大了。希望这篇文章能让你少走一些弯路。
返回列表