9 在线调试

9.1 在线调试简介

  • 什么是在线调试
    • 在线调试也称为板级调试(必须有开发板),它是将程序文件下载到FPGA芯片后分析代码运行的情况。
  • 为什么要在线调试?
    • 直接观察代码很难找到BUG
    • 软件仿真不全面,难以做到100%的代码覆盖率
    • 在板级交互中,存在异步事件,很难做到仿真,或者仿真起来时间很长,无法运行
    • 外围电路可靠性问题,如电源问题、信号干扰等导致程序运行出错
    • ……
  • FPGA调试的重要性
    • 在整个FPGA设计中,硬件调试与验证会花费超过40%的时间
    • 低效的策略可能导致产品发布延迟

9.2 在线调试工具

  • 在线调试的方法
    • 利用外部测试设备:如示波器(测量模拟量)和逻辑分析仪(测量数字量)等
  • 利用PGA开发工具自带的在线调试工具(在线逻辑分析仪)
FPGA厂商 FPGA开发工具 在线调试工具
Xilinx Vivado ILA(Integrated Logic Analyzer)
Xilinx ISE ChipScope
Altera Quartus II SignalTap II
紫光同创 PDS Debugger

片内逻辑分析仪是片内的资源,所以说它是完全可定制的,你可以去设置要观察多少信号,比方说你要去观察五个端口或者是十个端口或者更多端口都可以去设置。然后要存储个1024深度或者是10240深度,也都是可以定制的。只不过观察的端口数量越多、存储深度越深,那么它占用的资源就越多。

9.3 使用在线调试

9.3.1 Xilinx在线调试方案

  • Xilinx在线调试解决方案
    • Vivado工具集成了逻辑分析仪(ILA),用于替换外部的逻辑分析仪
    • 添加ILA核和VIO核实现在线调试
    • 通过JTAG接口和PC连接
  • ILA VS VIO
    • ILA (Integrated Logic Analyzer):监控逻辑内部信号和端口信号
    • VIO (Virtual Input/Output,虚拟IO):实时监控和驱动逻辑内部信号和端口信号

9.3.2 添加ILA

  • 方法1:添加ILA IP核,在代码中对ILA核进行例化
    • Vivado软件当中集成了很多的IP核(有一个IP库目录,它里面包含图像处理、数字信号处理等等很多的IP核给我们使用,其中有一个就是ILA这个IP核);
    • ILA IP核可以对它进行一个配置,比方说你要观察多少个信号、每个信号的位宽是多少、我们要观察到存储深度是多少等等。
    • 然后在代码当中对它进行一个例化,其实就是例化IP核,那么它会有很多的端口,这个端口跟要观察的这个信号进行连接,后面生成比特流下载到板子当中,那么它就会弹出一个观察的界面去实时地显示待观察信号的一个变化。
    • 这种方法是层次化最高的一种方法,比较直接,但是它有个缺点,就是需要去更改HDL代码。
  • 方法2:在原理图或者网表文件中添加“mark debug”属性
  • 方法3:手动地在XDC约束文件中编写对应的TCL XDC调试命令

9.3.3 插入XADC使用

在这个FPGA芯片内部有一个XADC硬核,可以通过这个硬核实时采集芯片内部的温度以及一些电压(内核电压或者是BRAM的电压等等)。下面就来演示一下。

拿出开发板,连接好电源线、下载器,注意拨码开关一定要拨到JTAG模式才行,因为本来就是使用JTAG接口来获取XADC的数据,所以说这个拨码开关一定要拨到JTAG模式。

然后打开Vivado的软件,直接打开硬件管理员:

然后连接下载器:

下图篮框中会有一个XC7Z020,表示它是一个7020的芯片:

可以在蓝框下面发现有一个XADC,后面的有一个系统监控(System Monitor),双击一下,然后点击OK,可以发现显示了一个温度值,如下图所示:

这个显示的就是芯片内部的温度,由于这个板子没跑什么程序,所以它这个温度值比较低,只有32度左右。除此之外还可以观察一些内核电压,比方说可以点击温度值上面的加号,前面这里是它的一个16个通道的电压,这里可以找到这个VCCAUX,点击OK,可以看到这个是它的一个辅助电压,是1.8伏。继续观察其他电压,比方说VCC0_DDR是1.354伏,其他的电压也都可以观察。

9.3.4 在线调试工具的使用

那本节就话不多说,直接手把手的来教大家如何去使用Vivado软件自带的调试工具。这里先来做两点说明:

  1. 既然是在线调试或者板级调试,肯定是要基于一款开发板去演示ILA的使用,需要说明的是,我们这里主要是演示ILA在线调试工具的使用,其实跟我们的卡板没多大关系,无论使用哪款卡板,整个操作都是一样的;
  2. 既然是在线调试,所以说是需要基于一个工程,这里就基于呼吸灯的实验来进行一个在线调试演示。

好下面就来打开呼吸灯的Vivado工程。首先可以看到它这里的资源的占用情况:

可以看到,LUT占用1%,FF也占用1%,后面进行在线调试之后再去看一下资源占用情况(因为前面说过,在线调试它会占用FPGA内部的资源,比方说逻辑资源或者存储资源等等)。

(一)法1:添加ILA-IP核的方式

方法1是通过添加ILA-IP核的方式进行在线调试,这个Vivado软件提供了ILA-IP核,下面就基于这个IP核来操作。那怎么打开这个IP核呢?就是找到软件左侧栏有一个IP目录:

IP Catalog是赛灵思官方提供的IP核,比方说有什么图像处理的或者是一些数学运算的等等,可以来去根据自己的需求选择合适的IP核使用。

双击打开第一个ILA (Integrated Logic Analyzer),打开之后它会弹出这个IP核的配置界面:

接着,对每个探针的位宽进行设置:

完成配置后,会弹出下面的弹窗:

上图中的两个选项,第一个是全局设置,第二个是OOC设置,它默认选择的是OOC。首先选择OOC模式的话,那么它会将我们的这个IP核作为一个独立的模块去编译,并且生成它的一些网表文件,所以说这里选择OOC模式之后,后面再生成比特流对整个工程做全局编译的时候,它就会省略掉对这个ILA IP核编译过程,相当于是节省时间。如果选择第一个全局的话,那么后面在对整个工程做编译的时候,那么他每次都会对这个ILA IP核进行一个全编译,耗时会比较久。所以说推荐选择第二个选项——OOC模式。、

然后点击Generate等待编译完成即可。

编译完成之后,下面在呼吸灯这个模块里面对这个IP核做一个例化,从下面的软件截图可以看到,ILA已经在这个设计资源里面了,但是并没有被包含在呼吸灯模块里面:

所以说需要在呼吸灯模块中对这个IP核做一个例化,要例化就需要知道这个IP核有哪几个端口,其实可以在下图选项里面找到:

拷贝到呼吸灯模块后,做一个信号的映射就可以了:

加进来之后,在软件界面当中就能够看到呼吸灯模块例化了ILA模块:

那么接下来,就可以生成比特流。

生成比特流之后,打开硬件管理:

然后选择自动连接:

接着,下载程序到板卡:

下载完成之后,界面如下,可以发现相比原来,多出了一个在线调试界面:

点击“单次采集”,界面显示如下:

设定触发设置:

在当前界面,该怎么去切换到代码的位置:

这时候就可以看到代码了:

此外,调试完成后想把调试界面关掉,可按下图操作:

现在,可以来看一下资源的变化:

当调试完成之后,最好是把这个调试文件删除,否则会占用资源。那么怎么删除呢,很简单把它恢复成原来的这个代码就可以了,可以把它注释掉或者是直接把这段代码删除就行了:

然后重新去生成比特流就可以了。

大家可以发现,烧录带调试信息(ILA)的呼吸灯的比特流文件,其实对呼吸灯功能的运行没有什么影响。也就是说在呼吸灯的代码上面添加ILA IP核之后不影响到它的功能。

当然了,不排除某种情况下会影响到运行,比方说你的这个ILA占用资源比较多等。

(二)法2:网表文件中添加调试信号

注意,在网表或原理图当中添加调试的信号,需要是打开综合后的一个设计,所以说如果是新建的工程没有进行综合,那就得先进行综合。因为呼吸灯这个工程之前已经综合过了,所以这里直接打开综合后的一个设计:

根据上图2个步骤之后,可以看到呼吸灯工程对应的器件原理图:

下面,我们直接在这个图上对想要调试的信号添加调试。

首先,对复位信号sys_rst_n和输出信号led添加调试:

那么下面再来调试内部信号,内部信号就是计数器加上标志位:

但是从原理图当中去找这些内部信号是很困难的,它们的形式都变成触发器或者查找表了。这个时候可以在网表文件(或者说是网络的一个列表)中查找:

按照上面的方法,把要调试的信号都加进来,总共就是六个信号:

需要注意的是,有的时候软件会对一些信号做优化,导致网表文件中看不到这个信号,那这时候该怎么办呢?方法是找到原始的Verilog代码,在代码中给要观察的信号添加一个debug的属性。那么怎么添加呢?如下图所示:

(*mark_debug="true"*)有什么含义呢?一方面是给这个信号添加一个debug的属性;另一方面就是可以防止Vivado软件对这个信号做一个优化。

改完代码后,要对它重新进行综合。综合完成后接着要打开综合后的设计,但是由于之前已经打开了综合后的设计,所以说可以点击下图中的reload重新加载:

可能会有一些警告,可以先不用管。打开综合后的设计之后,大家可以发现软件已经自动地将前面添加的debug属性的信号加入进来了。

前面只是给指定的信号添加了debug属性,但是还缺少一些ILA设置:

上图中点击下一步后,就可以对要观察的信号做一个设置:

继续点击下一步后,在下一个页面可以设置采样深度,其他就保持默认,一直下一步,最后完成ILA参数设置。完成后ctrl+s保存一下,这时就可以关闭综合后的设计了,然后重新生成比特流文件。

这个时候可以打开XDC文件来看一下:

浏览这个xdc文件可以发现,除了有拐角约束信息之外,文件后面有很多的关于调试信号的信息,其实刚才在网表文件里面做的一系列设置,保存之后软件都会把这些信息保存到xdc文件中。

好现在生成了比特流文件(注意,板卡要全部连接好):

然后下载程序,如下图所示,可以看到软件同样会识别到调试的文件:

程序下载完成后,软件界面如下:

可以看到,这个界面和前面的法1的调试界面是一样的,那么接下来的调试操作跟法1的操作就完全一样了。这里就不再重复演示了。

在调试完成之后,怎么去取消它的一个调试信息呢?找到xdc文件,将里面的调试信息相关的语句删除,然后CTRL+S保存一下:

然后之前在Verilog代码中添加的(*mark_debug="true"*),可以删除也可以不删除,没有什么影响。删除后重新生成比特流文件,就没有调试信息了。

(三)小结

关于在线调试两种方法就都介绍完了。第一种方法是通过添加ILA IP核的方式,这种方法就是通过直接调用官方的IP核,只不过这种方法是需要提前规划好要调试哪些信息,包括它的位宽要一一对应。那么这种方法是稍微麻烦一点的,当有多个模块的时候,每个模块要单独添加ILA IP核。

而第二种方法相对来说更方便一点,不用提前规划好要观察调试哪些信号(包括这些信号的位宽),直接对着网表文件可视化设置就可以了。

10 IP核

10.1 IP核概述

10.1.1 IP核是什么

IP(Intellectual Property),即知识产权,是ASIC或FPGA中的预先设计好的电路功能模块。注意,可以是官方提供的IP核,也可以是用户自定义的IP核。

如下图所示,是在Vivado软件中的一个FIFO IP核,有了这个IP核,我们可能就不用深究Verilog代码怎么去实现这个FIFO功能,只需要了解这个IP核它有哪些端口、每个端口分别表示什么含义、可配置的参数有哪些,然后就可以调用这个IP核来实现FIFO功能,所以说在使用上就非常方便。

一般FPGA厂商提供的IP核还是非常丰富的,比方说有一些PLL锁相环IP核(PLL可以用于实现对时钟的管理,比方说对时钟进行倍频、分屏、调整时钟相位等等),还有RAM IP核、FIFO IP核(这两种IP核可以做一些存储缓存数据等)。

除此之外,还有一些复杂的IP核,比如FFT、FIR滤波IP核。这些是非常复杂的,所以使用官方IP核的好处就是不用再去详细的去了解它的一个底层原理以及如何使用Verilog代码去实现对应的功能。

简单说,使用IP核可以加速设计,提高开发效率。

10.1.2 如何使用IP核

如下图所示:

比如说,双击打开一个IP核看一下:

10.2 IP核之PLL实验

PLL其实就是一个锁相环,可以实现对时钟的管理,它能实现的具体功能包括时钟倍频、分频、调整相位、调整占空比,包括提高时钟的质量(比方说消除时钟的一些抖动等等)。总之,锁相环IP核是非常重要的。

10.2.1 Xilinx FPGA的时钟管理

(一)CMT时钟管理单元

CMT(Clock Management Tiles):时钟管理单元,可以对时钟进行管理,具有时钟倍频、分频、相位偏移、可编程占空比和优化抖动等功能。CMT包含了PLL,PLL能实现的功能CMT都可以实现。

MMCM是一个混合模式的时钟管理,其实它里面是在PLL的基础上添加了一个用于精准调控相位的电路DCM(数字时钟管理单元)的一部分。PLL是模拟电路,而精准调控相位的是一个数字电路,所以说MMCM是混合模式时钟管理。MMCM相对PLL的优势就是相位可动态调整,但PLL占用的面积更小。

对上图中CMT的输入源做一个简单介绍:

  • BUFRBURH:区域时钟
  • BUFG:全局时钟
  • IBUFG:时钟专用引脚(根据之前的学习知道,引脚进入FPGA必须要经过一个ibuf,而专门接时钟的引脚进入FPGA经过的ibuf就是IBUFG)
  • GT:高速串行收发所输入的时钟
  • Local Routing:本地的一些连线(不推荐)

这些不同的时钟输入源,经过这个PLL或者MMCM之后就可以输出各种不同频率、各种不同相位或者是不同占空比等等的时钟。

(二)PLL介绍

PLL是一种时钟反馈控制电路,其特点是利用外部输入的参考信号控制环路内部震荡信号的频率和相位。下面是PLL简化的一个结构图:

首先,输入时钟ref_clk经过一个鉴频鉴相器(顾名思义鉴频鉴相器就是对ref_clkpll_out的频率和相位进行比较,比较的目的就是让这两个时钟的频率保持一致,相当于是一个反馈,若比较后不一致那它就会将比较结果发往下一级,直到保持一致)。

下一级是一个环路滤波器(顾名思义就是一个滤除一些时钟抖动或者是滤除一些噪声),把滤波后的信号发送到下一级——压控振荡器(顾名思义就是电源值控制的震荡频率,环路滤波器输出的电压值越高,输出时钟频率越高)。

下面是一个形象的类比图(只是为了帮助理解):

10.2.2 实验任务

本节的实验任务是使用开发板输出4路不同频率或相位的时钟,时钟分别为100Mhz,100Mhz(相位偏移180度)、50Mhz和25Mhz。将四路时钟分别输出至扩展口的端口,并通过示波器来测量输出结果。

10.2.3 创建时钟IP核

首先,创建整个项目的文件结构,工程名称就叫ip_clk_wiz

下面打开Visio,绘制一下实验IP核的端口和模块框图,从下图可以看到,顶层模块是ip_clk_wiz,在顶层模块中例化官方提供的时钟IP核:

根据上图所示,输入有两个:系统时钟sys_clk和系统复位sys_rst_n,输出则是实验任务要求的4路时钟信号。

接着,继续使用Visio绘制一下波形图:

下面创建Vivado工程,工程名称就叫ip_clk_wiz。然后打开官方提供的时钟IP核:

双击打开这个时钟向导IP核,如下图所示(注意,下图中还有个Component Name可以对IP核重命名,图中忘记标出来了):

接下来配置时钟IP核的输出:

下图介绍了一下其他页面的功能:

点击OK,完成时钟IP核的配置:

下面需要编写顶层模块,来对时钟IP核进行例化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 顶层模块
module ip_clk_wiz(
input sys_clk,
input sys_rst_n, // 系统复位,低电平有效
output clk_100m,
output clk_100m_180deg,
output clk_50m,
output clk_25m
);

wire locked;
// 定义一个内部复位信号
wire rst_n;

assign rst_n = sys_rst_n & locked;
/*简单说一下这个内部信号:当sys_rst_n和locked都为高电平的时候,
*就说明输出时钟很稳定了,再让rst_n作为其他模块的一个复位信号
*/

// 官方提供的例化模板
clk_wiz_0 u_clk_wiz_0
(
// Clock out ports
.clk_outl (clk_100m),
.clk_out2 (clk_100m_180deg),
.clk_out3 (clk_50m),
.clk_out4 (clk_25m),
// Status and control signals
.reset (~sys_rst_n),
.locked (locked), // locked是时钟IP核输出的一个信号,用于表示输出时钟是否稳定,
// 只有时钟稳定了,才可以用于驱动其他模块
// Clock in ports
.clk_in1 (sys_clk)
)

) ;// input clk_in1

endmodule

加了一个lock信号,可更新一下波形图:

至此,时钟IP核创建和配置就完成了,下面先通过仿真,看一下IP核创建的对不对。

10.2.4 时钟IP核的仿真

(一)Vivado自带仿真器

既然要做仿真,那肯定是要编写tb模块,先找到前面创建的工程文件夹,进入sim文件夹,在该文件夹中编写激励文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 文件名:tb_ip_clk_wiz.v
`timescale 1ns/1ns // 单位1ns,精度1ns

modele tb_ip_clk_wiz();

parameter CLK_PERIOD = 20;

reg sys_clk;
reg sys_rst_n;

wire clk_100m;
wire clk_100m_180deg;
wire clk_50m;
wire clk_25;

initial begin
sys_clk <= 1'b0;
sys_rst_n <= 1'b0;
#200
sys_rst_n <= 1'b1;
end

// 例化顶层模块
ip_clk_wiz u_ip_clk_wiz(
.sys_clk (sys_clk),
.sys_rst_n (sys_rst_n),

.clk_100m (clk_100m),
.clk_100m_180deg (clk_100m_180deg),
.clk_50m (clk_50m),
.clk_25m (clk_25m)
);

endmodel

编写完仿真激励文件,就可以打开ModelSim软件进行仿真了。下面可以思考一个问题:可以像原来仿真流水灯一样仿真现在的时钟IP核吗?因为这个时钟IP核是跟FPGA厂商有关的,我们是通过Vivado软件创建的一个时钟IP核,它会生成一系列的文件,包括它的代码。那么现在在ModelSim软件对这个时钟IP核进行仿真的话,ModelSim会认识这个IP核吗?

其实通过ModelSim软件对这个时钟IP核进行仿真的话会有点问题,这个IP核是赛灵斯厂商的,ModelSim其实只认识标准的Verilog代码,赛灵斯厂家针对他们的器件所产生的一些IP核包括代码,ModelSim软件有些是不认识的,所以如果按照常规的方式进行仿真的话会报错。

请参考正点原子的教程视频,视频中展示并说明了按照常规仿真操作会产生什么错误:https://www.bilibili.com/video/BV1Ec411K791?t=262.4&p=62

既然常规方法不行了,那应该采用什么方法进行仿真呢?

  1. 可以直接使用Vivado自带的仿真器;
  2. 联合仿真,先产生Vivado仿真库,然后和Modelsim进行联合仿真;

本小节先介绍第一种方法。

首先,打开之前创建的Vivado工程ip_clk_wiz.xpr,把之前编写的顶层模块核tb仿真激励文件添加进来:

接下来添加tb仿真激励文件:

之后,打开仿真界面:

可以对仿真的时间长度进行设置:

然后,可以根据仿真的波形,验证设计的时钟IP核功能是否正确。

此外,从软件截图可以发现仿真的信号缺少了lockedrst_n信号,这是因为默认只加载了tb仿真文件中的端口,并没有把顶层模块的信号加入进来,所以需要手动添加进来:

添加进来之后,在重新运行一次仿真。

仿真结束,关闭即可:

(二) Vivado和Modelsim联合仿真

前面,通过Vivado软件自带的仿真软件进行了仿真,那这种方法比较方便,只不过对于大型工程可能仿真的效率比较低。此外,拓展性也比较低,只适用于赛灵思厂商,其他厂商的FPGA仿真不能使用。

所以说比较推荐使用Modelsim软件进行仿真,但是由于本次实验的时钟IP核里面有一些赛灵思厂商自带的一些代码,那么只能是通过对它的代码进行编译来生成一个仿真库,然后通过跟Modelsim软件进行联合仿真,

首先,打开Vivado软件,来生成适配Modelsim的一个仿真编译库,操作如下图所示:

注意,若有报错,一种可能的原因是Modelsim软件版本和Vivado软件版本不匹配。

编译完成后,在Vivado软件中打开时钟IP核工程ip_clk_wiz,然后做一些设置:

完成配置之后,就可以进行联合仿真了:

根据上图操作后,会发现这时候Vivado软件不是打开自己的仿真界面了,而是直接打开了ModelSim软件:

修改仿真时长:

如果后面修改了源代码或者对IP核配置做了修改,那么有2种方法进行重新重新仿真,这里只简单说一下第一种方法:把Modelsim软件关闭掉,然后在Vivado软件中,点击run Simulation(也就是重新跑一次仿真)。

10.2.5 时钟IP核下载验证

在Vivado软件中,打开时钟IP核的工程ip_clk_wiz,参考10.2.2小节的PCB原理图,给它添加约束文件:

完成比特流文件生成后,就可以下载到板卡中了,但是这里我们先进入底层看一下:

可以放大上图中黄色框部分,看一下内部细节:

单击经过iBufg后时钟输出的这根信号线,沿着这根线,可以发现这根线先经过了一个iLogic(iLogic的作用自己查一下,视频讲的比较模糊,当时第一次听的时候没听明白),然后进入到了MMCM:

关于底层的实际电路,请直接看原视频吧,截图笔记做起来好麻烦,我不想做了:
原视频链接: 第18.5讲 IP核之PLL实验-下载验证 - 正点原子 - Bilibili

此外,视频中简单讲了一下iLogic和oLogic的作用:https://www.bilibili.com/video/BV1Ec411K791?t=661.4&p=64

下面,拿出开发板,连接好下载器和电源线,打开电源开关,在Vivado软件中连接板卡,下载程序:

为了观察时钟输出是否正确,需要实验示波器观察:

10.2.6 PLL的IP核小结

10.3 IP核之单端口RAM实验

本小节继续通过实验学习IP核的使用——单端口RAM实验。

RAM是一个非常宽泛的概念,比方说像手机电脑的内存、开发板板载的一些存储器(DDR3、DDR4)其实都属于RAM的一种。RAM功能简单说就是临时存储数据。

10.3.1 单端口RAM实验相关简介

(一)RAM的概念

RAM(Random Access Memory):即随机访问存储器,它可以随时把数据写入任一指定地址的存储单元,也可以随时从任一指定地址中读出数据,其读写速度是由时钟频率决定的。RAM主要用来存放程序以及程序执行过程中产生的中间数据、运算结果等。

关于上图存储器类型的介绍,正点原子讲的还不错,理清了很多模糊概念,建议看一下原视频:
第19.1讲 IP核之单端口RAM-简介 - 正点原子 - Bilibili

本次实验要完成的是单端口RAM IP 核,需要说明一点——要实现单端口RAM的功能,肯定是需要底层硬件支持的,像这个FPGA芯片内部是有BRAM(Bock RAM),芯片里面是存在这样一个电路的,该电路能用于存储。所以说完成这个单端口RAM的实验,底层所使用的结构就是这个BRAM。

而除此之外,其实也可以是使用另外一种资源——CLB——也就是使用FPGA的逻辑资源来实现一个单端口RAM(这种方式实现的其实是叫分布式的RAM)。但FPGA的逻辑资源是非常宝贵的,如果说都是使用分布式RAM缓存数据,那就比较浪费FPGA的逻辑资源了,所以说要缓存大量的数据,一般就是使用芯片内部的BRAM。

另外大家需要注意的是,像这个ROM,赛灵斯也提供了一个ROM的IP核。但是芯片内部是没有ROM硬件电路结构的,它其实还是使用BRAM实现一个ROM的功能。

那可能会疑问如何使用RAM来实现ROM的呢?首先,是只使用RAM读的功能,禁止RAM读的功能。其次就是关于如何用RAM实现ROM的掉电保存功能,其实我们在对ROM做配置的时候,是需要一个配置文件的,那么它的一个初始化的数据就存在这个配置文件里面的,所以说它是能够实现掉电保存这样一个功能的。

但话又说回来,这样的话RAM也支持初始化文件的配置啊,RAM也能掉电不丢失啊,这里是因为RAM配置文件完成之后,我们后面还可以对它进行改写,改变里面的数据,而而这个ROM就不支持改写了。

下图是对不同端口的RAM做了一个解释:

(二)单端口RAM IP核

下面,看一下单端口RAM IP核端口示意图:

上图列出了单端口RAM IP核的端口,有了这个端口之后,就不用去了解它的内部机制是什么样子的,只需要知道每个端口是什么含义以及它的接口时序是什么样子,就可以实现对RAM的操作。

下面对端口含义进行介绍(注意,每个端口名称尾部都有一个A,这个A表示接口A,如果是有两个端口的话,那么还会有一个B)

  • DINA:数据写入端口
  • ADDRA:数据写入的地址
  • WEA:读写控制信号,比方说它为高电平时表示写入操作,而当它为低电平时表示读出操作
  • ENA:是一个使能端口,只有它为高电平,才能够对这个单端口RAM进行读写
  • RSTA:是一个复位端口
  • REGCEA:是一个寄存器使能端口,当它为低电平时,那么DOUTA端口输出的一个固定数据,当它为高电平时,那么DOUTA就可以输出指定地址的数据
  • CLKA:是一个时钟端口,对RAM操作肯定是需要一个时钟的
  • DOUTA:数据读出端口

10.3.2 实验任务

本节的实验任务是使用Vivado软件生成一个单端口的RAM IP核,并对其进行读写操作,然后通过仿真观察波形是否正确,最后通过在线调试工具对实验结果进行验证。如:将RAM设置的深度和宽度分别为32和8进行读写测试。

10.3.3 创建单端口RAM IP核

首先,创建整个项目的文件结构,工程名称就叫ip_1port_ram

下面打开Visio,绘制一下实验IP核的端口和模块框图,从下图可以看到,顶层模块是ip_1port_ram,在顶层模块中例化官方提供的RAM IP核,此外还可以对RAM IP核的读写操作单独封装一个模块;此外本次实验是通过观察仿真的波形图以及在线调试的波形判断对RAM的读写操作是否正确,因此它的输入端口只有时钟和复位,没有输出端口:

下面其实还需要进一步的去划分每个模块的端口都有哪些,但是在这个划分之前,有必要先介绍一下这个RAM IP核的读写接口时序以及在创建完了这个RAM IP核之后,才能确定IP核它包含哪些端口,有了这些端口之后,再通过编写一个ram_rw模块进行读写测试。

下面就简单画一下针对单端口RAM读写操作的接口时序图:

特别注意上面的时序图中,写入数据是地址和数据同时给出,读出数据,数据比地址晚一个时钟。

在学习了RAM的读写接口时序之后,接下来创建一个单端口RAM IP核:

双击上图中Block Memory Generator进行配置:

上面绘制了模块框图,也就是模块是怎么划分的。目前确定了RAM IP核有哪些端口,下面再来确定一下ram_rw有哪些端口(RAM IP核是Vivado软件官方自带的,ram_rw不是,所以需要编写Verilog代码自己实现这个模块)

下面的图展示了完整版的模块划分和端口的连接:

根据上图,绘制一下ram_rw部分的时序波形图:

根据上面两幅图,编写ram_rw模块的Verilog程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
module ram_rw(
input clk,
input rst_n,
input [7:0] ram_rd_data, // RAM读出的数据

output reg ram_en, // RAM使能信号,高电平有效
output ram_we, // RAM写使能1:写0:读
output reg [4:0] ram_addr, // RAM读写地址
output reg [7:0] ram_wr_data,// RAM写入的数据
);

reg [5:0] rw_cnt;

// 对ram_we进行赋值
assign ram_we = (rw_cnt <= 6'd31 && ram_en == 1'b1) ? 1'b1 : 1'b0;

// 对RAM使能信号赋值,结束复位后拉高
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
ram_en <= 1'b0;
else
ram_en <= 1'b1;
end

// 对rw_cnt进行赋值,变化范围:0~63
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
rw_cnt <= 6'b0;
else if(ram_en && rw_cnt == 6'd63)
rw_cnt <= 6'b0;
else if(ram_en)
rw_cnt <= rw_cnt + 6'b1;
else
rw_cnt <= 6'b0;
end

// 对rw_addr进行赋值,变化范围:0~31
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
ram_addr <= 5'b0;
else if(ram_en && rw_cnt == 5'd31)
ram_addr <= 5'b0;
else if(ram_en)
ram_addr <= ram_addr + 5'b1;
else
ram_addr <= 5'b0;
end

// 对rw_addr进行赋值,变化范围:0~31
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
ram_addr <= 5'b0;
else if(ram_en && rw_cnt == 5'd31)
ram_addr <= 5'b0;
else if(ram_en)
ram_addr <= ram_addr + 5'b1;
else
ram_addr <= 5'b0;
end

//对ram_wr_data写数据进行赋值,在WE拉高期间,变化范围是0~31
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
ram_wr_data <= 8'b0;
else if(ram_we && ram_wr_data < 8'd31)
ram_wr_data <= ram_wr_data + 8'b1;
else
ram_wr_data <= 8'b0;
end

endmodule

下面编写顶层模块(例化了ram_rw和ram ip核):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
module ip_1port_ram(
input sys_clk,
input sys_rst_n
);

// 对ram_rw模块进行例化
wire [7:0] ram_rd_data;
wire ram_en;
wire ram_we;
wire [4:0] ram_addr;
wire [7:0] ram_wr_data;

ram_rw u_ram_rw(
.clk (sys_clk),
.rst_n (sys_rst_n),
.ram_rd_data (ram_rd_data),
.ram_en (ram_en),
.ram_we (ram_we),
.ram_addr (ram_addr),
.ram_wr_data (ram_wr_data)
);

// 对ram ip核进行例化
blk_mem_gen_0 u_blk_mem_gen_0(
.clka (sys_clk),
.ena (ram_en),
.wea (ram_we),
.addra (ram_addr),
.dina (ram_wr_data),
.douta (ram_rd_data)
);

endmodule

10.3.4 IP核之单端口RAM-仿真验证

编写tb模块,先找到前面创建的工程文件夹,进入sim文件夹,在该文件夹中编写激励文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
`timescale 1ns/1ns
module tb_ip_1port_ram();

parameter CLK_PERIOD = 20;

reg sys_clk;
reg sys_rst_n;

initial begin
sys_clk <= 1'b0;
sys_rst_n <= 1'b0;
#200;
sys_rst_n <= 1'b1;
end

always #(CLK_PERIOD/2) sys_clk = ~sys_clk;

ip_1port_ram u_ip_1port_ram(
.sys_clk (sys_clk),
.sys_rst_n (sys_rst_n)
);
endmodule

接下来是进行仿真,和上面时钟IP核的仿真类似,请直接参考视频操作吧,这里就不再麻烦的截图展示操作了,太麻烦了,我实在不想做笔记了:IP核之单端口RAM-仿真验证 - 正点原子 - Bilibili

10.3.5 单端口RAM IP核下载验证

在Vivado软件中,打开单端口RAMIP核的工程ip_1port_ram,给它添加约束文件,操作步骤参考10.2.5,约束文件内容如下:

剩余的内容和10.2.5小节差不多,剩下的单端口RAM IP核下载验证的操作请直接参考视频吧,我这里也不重复做笔记了:IP核之单端口RAM在线调试与总结 - 正点原子 - Bilibili

10.3.6 单端口RAM的IP核小结

10.4 IP核之双端口RAM实验

10.5 IP核之FIFO实验

11 UART串口通信

11.1 数据通信的基础概念

本节教学视频讲解了一些通信的基础知识点,比如:串行/并行通信,单工/半双工/双工通信,同步/异步通信,常见的串行通信(UART、IIC、SPI等)等基础的概念。因为我自己觉得是非常基础的一个知识点介绍,所以就不做笔记了。如果有需要,请直接看视频吧。

11.2 UART简介

11.2.1 UART是什么

UART (Universal Asynchronous Receiver-Transmitter) :即通用异步收发器,是一种通用串行数据总线,用于异步通信。一般UART接口常指串口。

UART在发送数据时将并行数据转换成串行数据来传输,在接收数据时,将接收到的串行数据转换成并行数据。

  • 物理层:接口类型、电平标准等
    • 使用UART协议的接口比较多:RS232、RS485、RS422
  • 协议层:通信协议(包括数据格式、传输速率等)

UART的通信协议时序图:

11.2.2 USB UART原理图

11.3 UART实验

11.3.1 实验任务

本节的实验任务是上位机通过串口调试助手发送数据给开发板,开发板通过USB UART串口接收数据并将接收到的数据发送给上位机,完成串口数据环回。UART通信波特率:115200,停止位:1,数据位:8位,无校验位。

11.3.2 绘制实验系统框图

模块框图如下:

上位机发送数据到板卡,FPGA将串行数据解析出来,解析成并行数据,然后通过串口发送模块uart_tx,将并行数据转成串行数据发送给上位机。

接下来细化一下接收模块和发送模块的端口:

首先需要一个时钟信号clk,这个时钟信号就是系统时钟,然后呢还需要一个复位信号rst_n,对整个模块的功能复位,这个复位信号其实就是系统复位信号。uart_rx的功能既然是实现串行数据到并行数据的转换,所以说它的输入还有一个串口的接收端,也就是uart_rxd,对应的输出为uart_rx_data[7:0],表示所接收到的数据转成并行之后的数据,一般位宽就设置为8位。现在还缺少一个端口,比方说这里是输出八位的数据,但是这个数据它什么时候有效呢?所以说这里还有一个类似于标志位的端口——uart_rx_done——标志接收完成,它是一个脉冲信号,每当它拉高一次,表示接收到了一次完整的8位数据。

上图是发送模块的端口划分。本实验中发送模块是一个把并行数据转成串行数据的一个过程,因此需要一个八位的数据输入端口uart_tx_data[7:0],除此之外,还需要一个标志位信号,因为需要知道这八位数据什么时候有效,也就是说这个标志位就是发送使能信号uart_tx_en,每当这个拉高的时候,表示这个数据有效,可以来开启一次传输,由并行数据转成串行数据,也就是uart_txd,通过这个发送端口将并行数据一位一位地发送出去。那么到这里问题又来了,外部如何知道什么时候发送完成,要等上一个字节发送完成之后才能够开启下一次传输对吧,所以还需要一个标志位信号——uart_tx_busy——表示正在发送(正在忙),当变为低电平的时候,表示发送完成,当前不忙。

上面就是整个模块划分,包含接收模块和发送模块两个模块。下面是完整的系统框图:

上图是完整的系统框图,首先最顶层的端口有3个输入(系统时钟sys_clk、系统复位sys_rst_n、串口输入uart_rxd)和1个输出(串口输出uart_txd)。然后顶层例化了两个模块——串口接收模块和串口发送模块。然后接收完成信号uart_rx_done直接连接到发送模块的开始发送信号uart_tx_en注意,上图中信号连反了,请以这里的文字描述为准)。

注意,可以发现虽然说这个模块里面加了uart_tx_busy端口,但是对于本次实验来说并没有用到,即使没有用到,也要去添加这个端口,因为要考虑模块的通用性。

下面通过思维导图,梳理一下两个模块具体要如何实现:

关于异步信号的同步处理(为了消除亚稳态),这里做一下说明:

  • 什么是亚稳态?

亚稳态是由于违背了触发器的建立和保持时间而产生的。寄存器采样需要满足一定的建立时间(setup)和保持时间(holdup),而异步电路没有办法保证建立时间(setup)和保持时间(holdup),所以会出现亚稳态。

通过“打拍”消除亚稳态:

说实话,这里没听懂,笔记也不知道咋做,建议多看几遍原视频:第22.4讲 UART串口通信-绘制波形图 - 正点原子 - Bilibili

资料1:14_亚稳态原理危害及应对方法详解—小梅哥 - Bilibili
资料2:亚稳态 - dracaryssss - Bilibili
资料3:建立、保持时间和亚稳态背后原理 - 仰望星河-致敬三体 - Bilibili

11.3.3 绘制波形图

  • 接收模块波形图:

  • 发送模块波形图:

11.3.4 程序编写

在前面的已经绘制好了系统框图以及串口接收模块和发送模块的波形图,现在就来开始编写代码。先来编写串口接收模块的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
module uart_rx(
input clk,
input rst_n,
input uart_rxd,
output reg uart_rx_done, // uart接收完成信号
output reg [7:0] uart_rx_data // uart接收到的数据
);

parameter CLK_FREQ = 50000000; // 时钟频率50Mhz
parameter UART_BPS = 115200; // 波特率115200
localparam BAUD_CNT_MAX = CLK_FREQ/UART_BPS; // 波特率计数值434(50MHz、115200条件下的值)

// 异步信号同步处理:打三拍
reg uart_rxd_d0;
reg uart_rxd_d1;
reg uart_rxd_d2;

always @(posedge clk or negedge rst_n) begin
if(!rst_n) begin
uart_rxd_d0 <= 1'b0;
uart_rxd_d1 <= 1'b0;
uart_rxd_d2 <= 1'b0;
end
else begin
uart_rxd_d0 <= uart_rxd;
uart_rxd_d1 <= uart_rxd_d0;
uart_rxd_d2 <= uart_rxd_d1;
end
end

reg rx_flag;
wire start_en;
// 计算起始位下降沿
assign start_en = uart_rxd_d2 & (~uart_rxd_d1) & (~rx_flag);


reg [3:0] rx_cnt;
reg [15:0] baud_cnt; // 波特率计数器

// 给接收标志赋值
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
rx_flag <= 1'b0;
else if(start_en)
rx_flag <= 1'b0;
// 在停止位一半的时候拉低
else if(rx_cnt == 4'd9 && baud_cnt == BAUD_CNT_MAX/2 - 1'b1)
rx_flag <= 1'b0;
else
rx_flag <= rx_flag;
end

// 波特率的计数器赋值
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
baud_cnt <= 16'd0;
else if(rx_flag) begin
if(baud_cnt < BAUD_CNT_MAX-1'b1)
baud_cnt <= baud_cnt + 16'b1;
else
baud_cnt <= 16'd0;
end
else
baud_cnt <= 16'd0;
end

// rx_cnt的赋值
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
rst_n <= 4'd0;
else if(rx_flag) begin
if(baud_cnt == BAUD_CNT_MAX-1'b1)
rst_n <= rst_n + 1'b1;
else
rst_n <= rst_n;
end
else
rst_n <= 4'd0;
end

// 根据rx_cnt寄存rxd端口的数据
reg [7:0] rx_data_t;
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
rx_data_t <= 8'b0;
else if(rx_flag) begin
if(baud_cnt == BAUD_CNT_MAX/2-1'b1) begin
case(rx_cnt)
4'b1 : rx_data_t[0] <= uart_rxd_d2;
4'b2 : rx_data_t[1] <= uart_rxd_d2;
4'b3 : rx_data_t[2] <= uart_rxd_d2;
4'b4 : rx_data_t[3] <= uart_rxd_d2;
4'b5 : rx_data_t[4] <= uart_rxd_d2;
4'b6 : rx_data_t[5] <= uart_rxd_d2;
4'b7 : rx_data_t[6] <= uart_rxd_d2;
4'b8 : rx_data_t[7] <= uart_rxd_d2;
default: ;
endcase
end
else
rx_data_t <= rx_data_t;
end
else
rx_data_t <= 8'b0;
end

// 给接收完成信号和接收数据赋值
always @(posedge clk or negedge rst_n) begin
if(!rst_n) begin
uart_rx_done <= 1'b0;
uart_rx_data <= 8'b0;
end
else if(rx_cnt == 4'd9 && baud_cnt == BAUD_CNT_MAX/2 - 1'b1) begin
uart_rx_done <= 1'b1;
uart_rx_data <= rx_data_t;
end
else begin
uart_rx_done <= 1'b0;
uart_rx_data <= uart_rx_data;
end
end

endmodule

再来编写串口发送模块的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
module uart_tx(
input clk,
input rst_n,
input uart_tx_en, // uart发送使能信号
input [7:0] uart_tx_data, // uart要发送的数据
output reg uart_txd,
output reg uart_tx_busy
);

parameter CLK_FREQ = 50000000; // 时钟频率50Mhz
parameter UART_BPS = 115200; // 波特率115200
localparam BAUD_CNT_MAX = CLK_FREQ/UART_BPS; // 波特率计数值434(50MHz、115200条件下的值)

reg [7:0] tx_data_t;
reg [3:0] tx_cnt;
reg [15:0] baud_cnt; // 波特率计数器

always @(posedge clk or negedge rst_n) begin
if(!rst_n) begin
tx_data_t <= 8'b0;
uart_tx_busy <= 1'b0;
end
else if(uart_tx_en) begin
tx_data_t <= uart_tx_data;
uart_tx_busy <= 1'b1;
end
else if(tx_cnt == 4'd9 && baud_cnt == BAUD_CNT_MAX - BAUD_CNT_MAX/16) begin
tx_data_t <= 8'b0;
uart_tx_busy <= 1'b0;
end
else begin
tx_data_t <= tx_data_t;
uart_tx_busy <= uart_tx_busy;
end
end

// 波特率的计数器赋值
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
baud_cnt <= 16'd0;
else if(uart_tx_busy) begin
if(baud_cnt < BAUD_CNT_MAX-1'b1)
baud_cnt <= baud_cnt + 16'b1;
else
baud_cnt <= 16'd0;
end
else
baud_cnt <= 16'd0;
end

// rx_cnt的赋值
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
tx_cnt <= 4'd0;
else if(uart_tx_busy) begin
if(baud_cnt == BAUD_CNT_MAX-1'b1)
tx_cnt <= tx_cnt + 1'b1;
else
tx_cnt <= tx_cnt;
end
else
tx_cnt <= 4'd0;
end

// 根据tx_cnt控制发送端口txd信号
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
uart_txd <= 1'b1;
else if(uart_tx_busy) begin
case(tx_cnt)
4'b0 : uart_txd <= 1'b0; // 起始位
4'b1 : uart_txd <= tx_data_t[0];
4'b2 : uart_txd <= tx_data_t[1];
4'b3 : uart_txd <= tx_data_t[2];
4'b4 : uart_txd <= tx_data_t[3];
4'b5 : uart_txd <= tx_data_t[4];
4'b6 : uart_txd <= tx_data_t[5];
4'b7 : uart_txd <= tx_data_t[6];
4'b8 : uart_txd <= tx_data_t[7];
4'b9 : uart_txd <= 1'b1; // 停止位
default : uart_txd <= 1'b1;
endcase
end
else
uart_txd <= 1'b1;
end

endmodule

最后,编写顶层模块,对接收和发送模块进行例化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
module uart_loopback(
input sys_clk,
input sys_rst_n,
// uart端口
input uart_rxd,
output uart_txd
);

// 对接收模块进行例化
parameter CLK_FREQ = 50000000;
parameter UART_BPS = 115200;

wire uart_rx_done;
wire [7:0] uart_rx_data;
uart_rx #(
.CLK_FREQ (CLK_FREQ),
.UART_BPS (UART_BPS)
)
u_uart_rx(
.clk (sys_clk),
.rst_n (sys_rst_n),
.uart_rxd (uart_rxd),
.uart_rx_done (uart_rx_done),
.uart_rx_data (uart_rx_data)
);
);

// 对发送模块进行例化
uart_tx #(
.CLK_FREQ (CLK_FREQ),
.UART_BPS (UART_BPS)
)
u_uart_tx(
.clk (sys_clk),
.rst_n (sys_rst_n),
.uart_tx_en (uart_rx_done),
.uart_tx_data (uart_rx_data),
.uart_txd (uart_txd),
.uart_tx_busy ()
);
);

endmodule

11.3.5 仿真与下载验证

前面,代码都编写好了,下面需要对代码进行验证。先通过仿真进行验证,针对仿真验证可以单独对两个子模块进行仿真和验证,只不过这样就需要单独编写这两个子模块的tb文件,所以说下面可以直接对顶层模块进行仿真和验证,相当于只需要编写顶层的tb模块就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
`timescale 1ns/1ns

module tb_uart_loopback();

parameter CLK_PERIOD = 20;

reg sys_clk;
reg sys_rst_n;
reg uart_rxd;

wire uart_txd;

// 发送一个字节数据8'b0101_0101
initial begin
sys_clk <= 1'b0;
sys_rst_n <= 1'b0;
uart_rxd <= 1'b1; // 空闲状态
#200
sys_rst_n <= 1'b1;
#1000
uart_rxd <= 1'b0; // 起始位
#8680
uart_rxd <= 1'b1; // D0
#8680
uart_rxd <= 1'b0; // D1
#8680
uart_rxd <= 1'b1; // D2
#8680
uart_rxd <= 1'b0; // D3
#8680
uart_rxd <= 1'b1; // D4
#8680
uart_rxd <= 1'b0; // D5
#8680
uart_rxd <= 1'b1; // D6
#8680
uart_rxd <= 1'b0; // D7
#8680
uart_rxd <= 1'b1; // 停止位
#8680
uart_rxd <= 1'b1; // 空闲状态
end

always #(CLK_PERIOD/2) sys_clk = ~sys_clk;


uart_loopback u_uart_loopback(
.sys_clk (sys_clk),
.sys_rst_n (sys_rst_n),
.uart_rxd (uart_rxd),
.uart_txd (uart_txd),
);

好,下面打开ModelSim软件进行仿真。具体操作请参考视频:第22.7讲 UART串口通信-仿真和下载 - 正点原子 - Bilibili

经过仿真,可以发现整个波形的变化完全符合预期,说明整个代码的编写是正确的,到这里仿真就结束了。下面可以将工程实际下载到板卡中验证了。

打开Vivado创建一个工程,下面参考视频经过一系列步骤完成工程创建(下面两图是两个比较重要的步骤):

然后生成比特流文件。完成之后,连接板卡下载程序(program device就是下载程序)。然后打开串口调试助手,验证串口功能。

11.3.6 Debug解决丢数据问题

第22.8讲 UART串口通信-Debug解决 - 正点原子 - Bilibil

太懒了,这一节没记笔记,先参考上面的视频吧,

11.4 实验总结