2024年3月


Redis24篇集合

1 主从模式介绍

在笔者的另外两篇文章 《
Redis系列:RDB内存快照提供持久化能力
》、《
Redis稳定性之战:AOF日志支撑数据持久化
》中,我们介绍了Redis中的数据持久化技术,包括
RDB快照

AOF日志
。有了这两个利器,我们再也不用担心机器宕机,数据丢失了。

但是持久化技术只是解决了Redis服务故障之后,快速数据恢复的问题。宕机和数据恢复的过程中整个业务系统来说,还是有损失的,并没有根本上提升可用性问题,而且持久化技术对于Redis服务性能来说是有损的。
我们需要的是保障Redis的高可用,减少甚至避免Redis服务发生宕机的可能。

目前实现Redis高可用的模式主要有三种:
主从模式、哨兵模式、集群模式
。今天我们先来聊一下主从模式。
Redis 提供的主从模式,是通过复制的方式,将主服务器上的Redis的数据同步复制一份到从 Redis 服务器,这种做法很常见,MySQL通过binlog进行的主从复制也是这么做的。
主节点的Redis我们称之为master,从节点的Redis我们称之为slave,主从复制为单向复制,只能由主到从,不能由从到主。可以有多个从节点,比如1主3从甚至n从,从节点的多少根据实际的业务需求来判断。

2 主从架构如何保证数据一致性?

为了保证主服务器Redis的数据和从服务器Redis的数据的一致性,也为了分担访问压力,均衡负载,应用层面一般采取读写分离的模式。
读操作:
主、从库都可以执行,一般是在从库上读数据
,对实时性和准确性有100%高真要求的部分业务,在谨慎评估之后也可以读主库,前提是不能给Master带来高压力和风险。
写操作:
只在主库上写数据,写完之后将写操作指令同步到从库

参考下图:
image

2.1 读写分离模式

读写分离模式的使用跟MySQL做读写分离的初衷是一样的。
因为我们已经划分了主从库,而且从库的数据是由主库单向复制的。如果主从库都可以执行写指令,那么在高频并发场景下对不同的副本数据做修改,操作会具有无序性,极易导致各副本产生数据不一致,这是分布式模式的弊病。
如果非要保证数据的强一致性,Redis 需要加锁处理,或者使用队列顺序执行,这样势必降低Redis的性能,降低服务的吞吐能力,这就不是高性能Redis所能接受的。

2.2 主从复制和读写分离的意义

  • 故障隔离和恢复
    :无论主节点或者从节点宕机,其他节点依然可以保证服务的正常运行,并可以手动或自动切换主从。
    • 如果Slave库故障,则读写操作全部走到Master库中
    • 如果Master库故障,则将Slave转成Master库,仅丢失Master库来不及同步到Slave的小部分数据
  • 读写隔离
    :Master 节点提供写服务,Slave 节点提供读服务,分摊流量压力,均衡流量的负载。
  • 提供高可用保障
    :主从模式是高可用的最基础版本,也是 sentinel 哨兵模式和 cluster 集群模式实施的前置条件。

3 搭建Redis主从复制模式

Redis的主从架构中,主节点的数据更新会自动被复制到从节点,确保数据的一致性。主从复制的开启,在从节点配置和发起即可,不需要我们在主节点做任何事情。
image

可以通过 replicaof(Redis 5.0 之前使用 slaveof)命令形成主库和从库的关系。在从节点开启主从复制,如下:

说明:masterip:主机IP,masterport:主机端口号

3.1 主库配置

# 设置Redis监听的IP地址和端口号,默认监听所有IP地址和6379端口
bind 0.0.0.0

# 启用保护模式,允许远程访问
protected-mode no

# 指定Redis监听的端口号
port 6380

# 增加Redis的最大内存限制,以容纳更多数据
#maxmemory 16GB   增加内存限制,根据您的服务器实际内存调整
maxmemory 20480mb

3.2 从库配置

在从服务器的配置文件中加入

replicaof <masterip> <masterport>

假设现在有主实例 (10.21.125.1:6380)、从实例 A(10.21.125.2:6379)和 从实例 B (10.21.125.3:6379),在两个从实例上分别执行以下命令,就成为了Slave,主实例成为 Master。

# 修改为从库监听的端口号
port 6379

# 添加需要同步的主库信息
replicaof 10.21.125.1 6380

4 主从复制原理

主从库模式开启之后,应用层面采用读写分离,所有数据的写操作只会在主库上进行,而读操作基本会在从库上进行(特殊情况下部分读业务允许走主库)。
主从会保持最终一致性:主库有了数据更新之后,会立即同步给从库,来保证主从库的数据的一致的。

4.1 主从库的同步机制

Redis 的主从复制机制均采用异步复制,我们也称为乐观复制,这种复制方式意味着不能完全保证主库和从库数据的实时一致性。
Redis的主从复制机制可以根据不同的业务场景可以采用不同的应对方式。下面是一些主要场景及其对应的实现方案:

1. 首次配置完成主从库之后的全量复制

在从库第一次连接到主库时,将采用psync复制方式进行全量复制。
这意味着从库会从头开始复制主库中的全部数据。
2. 主从正常运行期间,准实时同步

在正常运行状态下,从库通过读取主库的缓冲区来进行增量复制。
这个过程涉及复制主库上发生的新的数据变更。
3. 从库第二次启动(异常或主从网络断开后恢复)

Append增量数据 + 准实时同步将通过读取主库的缓冲区进行部分复制。
这种方式能够快速同步中断期间发生的数据变更,而不会对主库造成重大影响。
image

PSYNC
命令是Redis中用于从节点与主节点之间数据同步的关键命令。它的工作原理包括以下几个步骤:

1. 启动或重连判断:
当从节点(Slave)启动或与主节点(Master)的连接断开后重连时,从节点需要确定是否曾经同步过。
如果从节点没有保存任何主节点的运行ID(runid),它将视为第一次连接到主节点。

2. 首次同步处理:
如果是第一次同步的情况下,
从节点会发送 PSYNC -1 命令给主节点,代表请求全量数据同步。
全量同步是指主节点将其所有数据完整地Copy一份给从节点。

3. 主从重连后的处理:
对于之前已经同步过的从节点,它会发送 PSYNC runid offset 命令
,其中runid是主节点的唯一标识符,offset是从节点上次同步数据的偏移量。这样本质就是增量同步。

4. 主节点响应:
主节点接收到PSYNC命令后,会检查runid是否匹配,以及offset是否在复制积压缓冲区的范围内。
如果匹配且offset有效,主节点将回复 CONTINUE ,并发送自从节点上次断开连接以来的所有写命令。

5. 触发全量同步的条件:
如果runid不匹配,或offset超出了积压缓冲区的范围,主节点将通知从节点执行全量同步,
回复 FULLRESYNC runid offset

6. 积压缓冲区的作用:
主节点会在处理写命令的同时,将这些命令存入复制积压队列(缓冲池),同时记录队列中存放命令的全局offset。
这样做法是保证了效率。
当从节点断线重连,且条件允许时(runid和offset都具备),它可以通过offset从积压队列中进行增量复制,而不是全量复制,这样复制的成本就低很多。

7. 保障数据一致性:
PSYNC机制允许从节点在网络不稳定或其他意外断开连接的情况下,能够以增量方式重新同步数据。这也是它的一大优势,那就是保持主从节点数据的一致性。

8. 什么时候启动重连工作
判断是否进行全量同步,需要考虑两个关键因素:首先,确认这是否是第一次进行数据同步;其次,检查缓存区是否已经达到或超过其容量上限。只有在是第一次同步,或者缓存区已溢出的情况下,才会执行全量同步。

4.2 1主n从的同步说明

如果你有多个从库,则在每次连接的时候需要注意一些细节,如下:

  • 多个从库情况下,每个从库都会记录自己的
    slave_repl_offset
    ,各自复制的进度也不相同。
  • 重连主库进行恢复时,从库会通过 psync 命令将 slave_repl_offset 告知主库,主库判断从库的状态,来决定进行增量复制,还是全量复制。
  • replication buffer 和 repl_backlog 的说明
    • replication buffer 是主从库在进行全量复制时,主库上用于和从库连接的客户端的 buffer
    • repl_backlog_buffer 是为了支持从库增量复制,主库上用于持续保存写操作的一块专用 buffer,所有从库共享的
  • 主库和从库会各自记录自己的复制进度,所以,不同的从库在进行恢复时,需要将自己的复制进度(slave_repl_offset)发给主库,主库才可以按照偏移量取数据跟它同步。

如图所示:
image

5 总结

  • 主从复制的作用一个是为分担读写压力,均衡负载,另一个是为了保证部分实例宕机之后服务的持续可用性,所以Redis演变出主从架构和读写分离。
  • 主从复制的步骤包括:建立连接的阶段、数据同步的阶段、基于长连接的命令传播阶段。
  • 数据同步可以分为全量复制和部分复制,全量复制一般为第一次全量或者长时间主从连接断开。
  • 主从模式是比较低级的可用性优化,要做到故障自动转移,异常预警,高保活,还需要更为复杂的哨兵或者集群模式,这个后面我们继续介绍。

大家好,我是狂师!

前段时间,在后台收到一则留言:"请问一下,你觉得开发技术好,还是测试技术好,如果测试技术好,为什么不直接开发,干嘛做测试?"

这是一则很有意思且大多数技术新人普遍存在的困惑,今天就以此问题,给大家统一解答,分享一下笔者的观点。

1、首先,从技术本身来讲,

开发技术和测试技术都是软件工程中不可或缺的技术能力,它们各自具有独特的价值和重要性。无法简单地说哪个技术更好,因为它们的优劣取决于具体情境和需求。

开发技术专注于实现软件的功能和特性,它涉及到编程语言、算法、数据结构、设计模式等多个方面。优秀的开发技术能够确保软件具有高效、稳定、安全的性能,满足用户需求。

测试技术则专注于确保软件的质量和可靠性,它涉及到测试用例设计、自动化测试、性能测试、安全测试等多个方面。优秀的测试技术能够及时发现软件中的缺陷和错误,为软件开发提供质量保障。

2、从岗位职责来讲,就软件工程来说,

开发工程师则是一个更广泛的概念,包括软件设计人员、软件架构人员、软件工程管理人员、程序员等一系列岗位。他们的主要工作是进行软件的开发和生产,技术要求较为全面,包括但不限于编程语言、数据库技术、平台技术等多个方面。

而测试工程师,随着近些年行业发展趋势,大体又分为两类:业务测试工程师、测试开发工程师。

  • 业务测试工程师,主要负责开发和执行软件测试,以确保软件产品的质量和可靠性。他们需要设计、编写和执行测试计划和测试用例,识别和报告软件缺陷和错误,参与软件开发生命周期,并与开发团队密切合作。测试工程师需要掌握软件测试理论、方法和工具,同时还需要具备分析问题、识别潜在风险和提出解决方案的能力。

  • 而测试开发工程师,是一种特殊的角色,他们既是开发人员,又负责软件测试。他们关注软件产品的可测性、稳健性和性能,并在软件设计初期就参与审阅和推动改进。测试开发工程师需要编写测试工具和自动化测试代码,同时也需要执行一些基础的功能测试或白盒测试。他们需要掌握多种编程语言、测试技术、开发技术,同时还需具备测试工程师的敏锐思维,能够从用户体验、产品业务角度解决问题。

总的来说,测试开发工程师、开发工程师和业务测试工程师在职责和技能要求上各有侧重,但他们共同的目标都是确保软件产品能符合客户、用户需求,同时满足质量和可靠性要求。测试开发工程师更侧重于开发和自动化测试,开发工程师更侧重于软件的开发和生产,而测试工程师则更专注于测试计划和测试用例的设计和执行。三者之间的协作对于软件项目的成功至关重要。

这也符合Google测试之道一直强调的每个工程师都应注重质量,质量是内建的,而非仅仅是测试人员的责任。

3、再者,我的观点

在我之前出版的《自动化测试实战宝典》一书中,提到的“一名优秀的测试开发工程师应该比开发更懂开发,比产品更懂业务,不然你如何发现开发和业务更深层次的问题呢?”

虽然这一观点,现实工作中,能达到的人并不多,但实际上它更多是对测试开发工程师角色的一个高度期望和要求。这种表述并不是字面上的绝对比较,而是强调测试开发工程师需要具备跨越开发和产品领域的综合能力和视角。

  • 首先
    ,测试开发工程师比开发更懂开发,并不是指测试开发工程师在开发技术层面一定比专业的开发工程师更精通,而是指测试开发工程师需要深入了解开发的过程、技术栈、常见的错误模式以及开发的思维方式。这样,他们才能设计出更有效的测试用例,更精准地定位问题,更快速地与开发团队沟通协作。测试开发工程师需要站在开发的角度思考问题,预见并防范可能出现的问题,从而确保软件的质量。

  • 其次
    ,测试开发工程师比产品更懂业务,也不是说测试开发工程师一定比产品经理更了解业务需求,而是强调测试开发工程师需要对业务逻辑有深入的理解,了解业务背后的目标和需求,以便更好地设计测试用例和发现潜在的业务问题。测试开发工程师需要站在用户的角度思考问题,确保软件的功能符合业务需求和用户期望。

这种跨领域的综合能力使得测试开发工程师在发现开发和业务问题方面具有独特的优势。他们可以通过深入了解开发过程和技术栈,发现开发人员可能忽略的潜在问题;同时,通过深入理解业务逻辑和需求,发现产品可能存在的功能缺陷或不符合用户期望的地方。

需要注意的是,这一观点并不是要求测试开发工程师在所有方面都要超过开发或产品人员,而是强调他们需要具备一种综合的视角和能力,以便更好地履行测试和开发工程师的职责,确保软件的质量和可靠性。

在实际操作中,测试开发工程师可以通过与开发团队和产品团队紧密合作,积极参与项目讨论和需求评审,不断提升自己的业务和技术能力,从而更好地发现和解决开发和业务问题。同时,他们也需要保持开放和学习的态度,不断吸收新的知识和技术,以适应不断变化的项目需求和技术环境。

4、最后

质量不是被测试出来的,质量也不等于测试,同时质量并非仅仅依赖于测试阶段,而是需要在整个软件开发过程中进行预防和确保。测试只是其中的一环,而不是质量的唯一保障。

同时测试工程师是一个把质量意识输出到整个团队的人,是一个流程推动者,是一个需求挖掘者,是一个质量把关者,一方面我们确实通过自己的经验和技术手段去挖掘更多的Bug,另外一方面,通过传播质量意识尽可能的去从产品上游去避免Bug。

开发测试比本身没有实际任何意义,只是人员数量上的比率,如果在你的职位头衔上有测试的字样,你的任务就是怎么使得那些头衔上没有测试的人可以更好地去做测试。

希望这些观点,能给你带来新的启发,共勉!

OpenCV是一个基于Apache2.0许可(开源)发行的跨平台计算机视觉和机器学习软件库,可以运行在Linux、Windows、Android和Mac OS操作系统上。 它轻量级而且高效——由一系列 C 函数和少量 C++ 类构成,同时提供了Python、Ruby、MATLAB等语言的接口,实现了图像处理和计算机视觉方面的很多通用算法。OpenCV用C++语言编写,它具有C ++,Python,Java和MATLAB接口,并支持Windows,Linux,Android和Mac OS,OpenCV主要倾向于实时视觉应用。
OpenCvSharp 是一个OpenCV的.Net wrapper,应用最新的OpenCV库开发,使用习惯比EmguCV更接近原始的OpenCV,有详细的使用样例供参考。该库采用LGPL发行,对商业应用友好。使用OpenCvSharp,可用C#,VB.NET等语言实现多种流行的图像处理(image processing)与计算机视觉(computer vision)算法。
但是在实际使用中,由于涉及到不同编程语言之间互相调用,导致C++ 中的OpenCV与C#中的OpenCvSharp 图像数据在不同编程语言之间难以有效传递。在本文中我们将结合OpenCvSharp源码实现原理,探究两种数据之间的通信方式

1. 问题分析

在日常开发中,由于一些库不支持C#接口,因此在使用时我们需要借助动态链接库的方式,在C#中调用C++封装的应用。由于C++与C#底层编译方式不同,因此动态链接库只可以传递基础的数据类型,无法传递像Class这种高级的数据格式。

在日常开发中,我们在C#中使用OpenCvSharp进行图像处理,但是我们调用的算法是通过C++封装的动态链接库,且需要将图片数据传递到C++封装的动态链接库中进行处理,因此实现高效的实现图片数据传递是十分有必要的。常见的方式有两种:

(1)第一种方式是在C#中将图片数据转为基本数据类型byte[]数组,然后将该数据传递到C++动态链接库中,在接收到该数据后,由C++再将该数据重新转为图片数据进行处理。目前该方式经过测试,是可以实现的,但是这样有一个弊端,图片数据需要进行两次的转换,这样会导致严重浪费时间和消耗大量内存。

(2)第二种方式是在C#中将数据保存到本地,然后再C++动态链接库中读取。与上一种方式一样,这样会导致严重浪费时间和消耗大量内存。

2. 解决办法

为了解决这个问题,我们探究了一下OpenCvSharp 实现方式,通过其源码可知,OpenCvSharp 在实现时,是通过对C++中的OpenCV进行了进一步封装,将Mat数据定义成指针类型,然后以指针的方式在C++与C#中进行传递;而在C#中,重新定义了Mat数据类型,将C++传递来的Mat指针作为成员变量进行初始化,而后续基于Mat的所有操作,其低层都是通过传递这个指针进行操作的。

知道了Mat的这个数据类型的实现原理后,我们可以模仿这种方式,以指针的方式实现将OpenCvSharp的数据传递到OpenCV C++中,这样就可以快速实现数据类型传递。实现方式如下图所示。

在C#中使用OpenCvSharp获取一个图片数据,数据类型为Mat,我们可以先进行处理等操作;接下来我们可以获取OpenCvSharp的地址
CvPtr
,然后在C++中使用
*Mat
指针进行获取,然后通过
*Mat
我们便可以获取到OpenCV C++中的Mat数据。接下来,用户就可以根据自己的需求进行处理即可。在处理完成后,在将获得新的用
Mat
数据转为用
*Mat
指针,然后再C#中,使用
IntPtr
数据类型进行接收,然后使用OpenCvSharp的Mat以获取的指针数据为初始值初始化
Mat
数据类型即获得新的
Mat
数据。

通过上述方式,我们便可以很轻松的实现C#中的OpenCvSharp与C++中的OpenCv数据转换。

3. 项目创建

为方便演示,下述所有程序设计与编译皆是在Windows11环境下,使用Visual Studio 2022编辑器实现。

  • OpenCV: 4.8.0
  • OpenCvSharp: 4.9.0

大家可以根据上述版本进行配置,也可以使用其他版本配置,但要保证OpenCV与OpenCvSharp都是同一个基础版本的,且版本差别不要太大。

3.1 创建C++项目

使用Visual Studio 2022创建一个空的C++项目,然后添加两个文件,分别为:
mat_conv.h

mat_conv.cpp

接下来配置项目属性,首先配置项目输出类型,如下图所示,设置图片输出类型为
动态库(.dll)

然后配置OpenCV C++项目依赖,主要是配置C++项目的包含目录、库目录以及附加依赖项三个地方,如下图所示:

以下是我的项目设置信息,大家可以根据自己安装的OpenCV情况进行设置:

包含目录: C:\3rdpartylib\opencv\build\include
库目录: C:\3rdpartylib\opencv\build\x64\vc16\lib
附加依赖项: opencv_world480.lib

3.2 创建C#项目

使用Visual Studio 2022创建一个新的C#控制台项目,然后使用NuGet安装所需的程序集即可,此处只需要安装OpenCvSharp即可,如下图所示:

4. 接口测试

此处主要测试四个接口:

  • 第一个接口测试在OpenCvSharp中读取一张图片,然后将图片数据传入到OpenCV中,测试传入是否成功。
  • 第二个测试接口在OpenCV创建一个图片,绘制一个矩形,然后将创建好的图片传出到OpenCvSharp,测试传出数据是否成功。
  • 第三个测试接口是在OpenCvSharp中读取一张图片,然后将图片数据传入到OpenCV中,并进行一步处理,该处理结果会将数据保存到另一个新的图片数据中,将该新的图片数据传出,然后在OpenCvSharp查看是否处理成功,测试该过程是否成功。
  • 第四个测试接口是在OpenCvSharp中读取一张图片,然后将图片数据传入到OpenCV中,并进行一步处理,该处理结果会直接在原有数据上进行修改,然后在OpenCvSharp查看是否处理成功,测试该过程是否成功。

4.1 接口一测试

在以下文件中分别添加以下代码:

mat_conv.h

#include "opencv2/opencv.hpp"
extern "C"  __declspec(dllexport) void __stdcall mat_conv1(cv::Mat * mat);

mat_conv.cpp

#include "mat_conv.h"

void mat_conv1(cv::Mat *mat)
{
	cv::imshow("image", *mat);
	cv::waitKey(0);
}

Program.cs

using OpenCvSharp;
using OpenCvSharp.Internal;
using System.Runtime.InteropServices;
namespace opencv_csharp
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello, World!");
            string image_path = "image.jpg";
            Mat mat1 = Cv2.ImRead(image_path);
            Methord.mat_conv1(mat1.CvPtr);
        }
    }

    class Methord 
    {
        private const string dll_path = "C:\\Users\\lenovo\\Desktop\\test_opencv\\x64\\Release\\opencv_cpp.dll";
        [DllImport(dll_path, CharSet = CharSet.Unicode, CallingConvention = CallingConvention.Cdecl)]
        public static extern void mat_conv1(IntPtr mat);
    }
}

其中,
mat_conv.h

mat_conv.cpp
为C++项目文件,
Program.cs
文件为C#项目文件。

在C++项目文件中,
extern "C" __declspec(dllexport)
表示使用C语言的编译方式进行编译,并导出到dll中。
mat_conv1(cv::Mat * mat)
方法主要是接受传入的
Mat
指针,并使用
cv::imshow("image", *mat)
将图片数据展示出来。

在C#项目中,使用
[DllImport]
属性将动态链接库中的
mat_conv1
读取出来,同时因为在C#中指针都是被封装为
IntPtr
类型的,因此使用
IntPtr
表示此处传入的参数为指针类型。在使用该接口时,直接调用该方法,并且传入指针参数,该指针参数可以通过
Mat.CvPtr
直接获得。

如下图所示,程序在运行后,成功将传入的图片数据绘制出来,如下图所示,说明该接口测试成功,也证明了该方法是可行的。

4.2 接口二测试

在以下文件中分别添加以下代码:

mat_conv.h

#include "opencv2/opencv.hpp"
extern "C"  __declspec(dllexport) void __stdcall mat_conv2(cv::Mat **returnValue);

mat_conv.cpp

#include "mat_conv.h"

void  mat_conv2(cv::Mat** returnValue)
{
    // 创建一个空白图像
    cv::Mat image = cv::Mat::zeros(400, 400, CV_8UC3);
    // 矩形的左上角和右下角坐标
    cv::Point2f rect_start(50, 50);
    cv::Point2f rect_end(350, 350);
    // 矩形颜色 (B, G, R)
    cv::Scalar color(255, 0, 0); // 红色
    // 矩形线条粗细
    int thickness = 2;
    // 绘制矩形
    cv::rectangle(image, rect_start, rect_end, color, thickness);
    *returnValue = new cv::Mat(image);
}

Program.cs

using OpenCvSharp;
using OpenCvSharp.Internal;
using System.Runtime.InteropServices;
namespace opencv_csharp
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello, World!");
            IntPtr ptr2 = IntPtr.Zero;
            Methord.mat_conv2(out ptr2);
            Mat mat2 = new Mat(ptr2);
            Cv2.ImShow("image2", mat2);
            Cv2.WaitKey(0);
        }
    }

    class Methord 
    {
        private const string dll_path = "C:\\Users\\lenovo\\Desktop\\test_opencv\\x64\\Release\\opencv_cpp.dll";
        [DllImport(dll_path, CharSet = CharSet.Unicode, CallingConvention = CallingConvention.Cdecl)]
        public static extern void mat_conv2(out IntPtr returnValue);
    }
}

其中,
mat_conv.h

mat_conv.cpp
为C++项目文件,
Program.cs
文件为C#项目文件。

在C++项目文件中,
mat_conv2(cv::Mat** returnValue)
主要是创建一个画布,并绘制一个矩形,然后将创建好的图片数据以指针的方式传递到C#中。

在C#项目中,使用
[DllImport]
属性将动态链接库中的
mat_conv2
读取出来,传出数据此处使用的时双重指针,因此使用
out IntPtr
进行接收。再获取到该方法后,我们调用
new Mat(IntPtr ptr)
构造方法初始化为新的
Mat
数据。。

如下图所示,程序在运行后,成功将传出的图片数据绘制出来,如下图所示,说明该接口测试成功,也证明了该方法是可行的。

4.3 接口三测试

在以下文件中分别添加以下代码:

mat_conv.h

#include "opencv2/opencv.hpp"
extern "C"  __declspec(dllexport) void __stdcall mat_conv3(cv::Mat * mat, cv::Mat **returnValue);

mat_conv.cpp

#include "mat_conv.h"

void  mat_conv3(cv::Mat * mat, cv::Mat **returnValue)
{
	cv::Mat m;
	cv::cvtColor(*mat, m, cv::COLOR_BGR2GRAY);
	*returnValue = new cv::Mat(m);
}

Program.cs

using OpenCvSharp;
using OpenCvSharp.Internal;
using System.Runtime.InteropServices;
namespace opencv_csharp
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello, World!");
            string image_path = "image.jpg";
            Mat mat3 = Cv2.ImRead(image_path);
            IntPtr ptr3 = IntPtr.Zero;
            Methord.mat_conv3(mat1.CvPtr, out ptr3);
            Mat mat3 = new Mat(ptr3);
            Cv2.ImShow("image1", mat3);
            Cv2.WaitKey(0);
        }
    }

    class Methord 
    {
        private const string dll_path = "C:\\Users\\lenovo\\Desktop\\test_opencv\\x64\\Release\\opencv_cpp.dll";
        [DllImport(dll_path, CharSet = CharSet.Unicode, CallingConvention = CallingConvention.Cdecl)]
        public static extern void mat_conv3(IntPtr mat, out IntPtr return_value);
    }
}

其中,
mat_conv.h

mat_conv.cpp
为C++项目文件,
Program.cs
文件为C#项目文件。

在C++项目文件中,
mat_conv2(cv::Mat * mat, cv::Mat **returnValue)
方法主要是接受传入的
Mat
指针,并将传入的图片数据转为灰度图,同时将转换好的图片数据以指针的方式传出到C#中。

在C#项目中,使用
[DllImport]
属性将动态链接库中的
mat_conv3
读取出来,其中传入数据为指针数据,所以直接使用
IntPtr
即可;而对于传出数据此处使用的时双重指针,因此使用
out IntPtr
进行接收。再获取到该方法后,我们调用
new Mat(IntPtr ptr)
构造方法初始化为新的
Mat
数据。

如下图所示,程序在运行后,成功将传入的图片数据进行灰度转换,并将转换后的图片数据成功传递出来,说明该接口测试成功,也证明了该方法是可行的。同时我们测试了该过程所需时间,仅使用了3.69毫秒。

4.4 接口四测试

在以下文件中分别添加以下代码:

mat_conv.h

#include "opencv2/opencv.hpp"
extern "C"  __declspec(dllexport) void __stdcall mat_conv4(cv::Mat * mat);

mat_conv.cpp

#include "mat_conv.h"

void  mat_conv4(cv::Mat* mat)
{
    // 矩形的左上角和右下角坐标
    cv::Point2f rect_start(50, 50);
    cv::Point2f rect_end(350, 350);
    // 矩形颜色 (B, G, R)
    cv::Scalar color(255, 0, 0); // 红色
    // 矩形线条粗细
    int thickness = 2;
    // 绘制矩形
    cv::rectangle(*mat, rect_start, rect_end, color, thickness);
}

Program.cs

using OpenCvSharp;
using OpenCvSharp.Internal;
using System.Runtime.InteropServices;
namespace opencv_csharp
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello, World!");
            string image_path = "image.jpg";
            Mat mat4 = Cv2.ImRead(image_path);
            Methord.mat_conv4(mat1.CvPtr);
            Cv2.ImShow("image2", mat4);
            Cv2.WaitKey(0);
        }
    }

    class Methord 
    {
        private const string dll_path = "C:\\Users\\lenovo\\Desktop\\test_opencv\\x64\\Release\\opencv_cpp.dll";
        [DllImport(dll_path, CharSet = CharSet.Unicode, CallingConvention = CallingConvention.Cdecl)]
        public static extern void mat_conv4(IntPtr mat);
    }
}

其中,
mat_conv.h

mat_conv.cpp
为C++项目文件,
Program.cs
文件为C#项目文件。

在C++项目文件中,
mat_conv4(IntPtr mat)
方法主要是接受传入的
Mat
指针,并在传入的图片数据中绘制一个矩形,因为该操作是在原始数据上进行的操作,没有残生新的图像数据,所以不需要传出。

在C#项目中,使用
[DllImport]
属性将动态链接库中的
mat_conv4
读取出来,其中传入数据为指针数据,所以直接使用
IntPtr
即可。然后该方法运行完后,我们直接查看该图像数据信息,查看是否已经被修改。

如下图所示,程序在运行后,结果如下图所示,说明该接口测试成功,也证明了该方法是可行的。

5. 总结

在项目中,我们结合OpenCvSharp源码,使用OpenCvSharp数据指针实现了在C#与C++之间传递图像数据。与传统的数据传递方式相比,该方式通过传递指针,通过指针的方式实现对同一块图像数据进行操作,避免了图像数据的来回转换,极大的节省了程序运行时间以及内存消耗。

最后如果各位开发者在使用中有任何问题,欢迎大家与我联系。

d3d12龙书阅读----绘制几何体(上)

本节主要介绍了构建一个简单的彩色立方体所需流程与重要的api
下面主要结合立方体代码分析本节相关知识

顶点

输入装配器阶段的输入
首先,我们需要定义立方体的八个顶点
顶点结构体:

struct Vertex
{
    XMFLOAT3 Pos;
    XMFLOAT4 Color;
};

当然,对于更复杂的情况,我们不仅要定义顶点的位置与颜色,还要包括法线向量、纹理x坐标、纹理y坐标等等
但在这里情形比较简单
之后,我们还需要定义一个顶点结构体描述子数组,被称为输入布局描述
数组中的每个成员与顶点结构体的成员一一对应,同时也与顶点着色器中的参数对应:

std::vector<D3D12_INPUT_ELEMENT_DESC> mInputLayout;

mInputLayout =
{
    { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
    { "COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 }
};

//顶点着色器
struct VertexIn
{
	float3 PosL  : POSITION;
    float4 Color : COLOR;
};

D3D12_INPUT_ELEMENT_DESC的定义与参数说明可见:
https://learn.microsoft.com/zh-cn/windows/win32/api/d3d12/ns-d3d12-d3d12_input_element_desc
接着,我们还需要为顶点创建顶点缓冲区,与第四章内容创建深度缓冲区的步骤相似,我们首先要填写D3D12_RESOURCE_DESC结构体描述缓冲区资源,然后使用CreateCommittedResource 方法,创建资源与一个堆,并把资源上传到堆中。

CreateCommittedResource 方法的参数说明可见:
https://learn.microsoft.com/zh-cn/windows/win32/api/d3d12/nf-d3d12-id3d12device-createcommittedresource
其中有三个参数在本节中很重要
一个是D3D12_HEAP_PROPERTIES *pHeapProperties
一个是D3D12_RESOURCE_DESC *pDesc
一个是D3D12_RESOURCE_STATES

D3D12_RESOURCE_STATES代表着资源状态
在d3d的初始化中我们提到这样可以防止资源冒险 比如在读的状态在写资源等等
详细的资源种类可见:
https://learn.microsoft.com/zh-cn/windows/win32/api/d3d12/ne-d3d12-d3d12_resource_states
D3D12_HEAP_PROPERTIES是一个结构体:

img
其中D3D12_HEAP_TYPE的类型主要有以下几种:

img

D3D12_RESOURCE_DESC 与 D3D12_HEAP_PROPERTIES的创建 这里分别借用了CD3DX12_HEAP_PROPERTIES 与 CD3DX12_RESOURCE_DESC两种变体方法来简化缓冲区的创建过程:

ThrowIfFailed(device->CreateCommittedResource(
    //默认堆 
    &CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT),
    D3D12_HEAP_FLAG_NONE,
    //bytesize 代表缓冲区所占字节数
    &CD3DX12_RESOURCE_DESC::Buffer(byteSize),
    //common状态
	D3D12_RESOURCE_STATE_COMMON,
    nullptr,
    IID_PPV_ARGS(defaultBuffer.GetAddressOf())));

让我们回到创建顶点缓冲区上来,当我们想要为树木、地形等默认几何体(每一帧都不会发生变化的结合体)来创建顶点缓冲区时,常常选择默认堆来优化性能,当顶点缓冲区初始化完毕后,只有gpu需要从中读取数据来绘制几何体。但是在初始化缓冲区时,需要cpu向默认堆中的顶点缓冲区写入数据,这是我们就需要一个上传堆作为中介,为此本节编写了CreateDefaultBuffer函数:

Microsoft::WRL::ComPtr<ID3D12Resource> d3dUtil::CreateDefaultBuffer(
    ID3D12Device* device,
    ID3D12GraphicsCommandList* cmdList,
    const void* initData,
    UINT64 byteSize,
    Microsoft::WRL::ComPtr<ID3D12Resource>& uploadBuffer)
{
    //创建缓冲区资源
    ComPtr<ID3D12Resource> defaultBuffer;
    ThrowIfFailed(device->CreateCommittedResource(
        &CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT),
        D3D12_HEAP_FLAG_NONE,
        &CD3DX12_RESOURCE_DESC::Buffer(byteSize),
		D3D12_RESOURCE_STATE_COMMON,
        nullptr,
        IID_PPV_ARGS(defaultBuffer.GetAddressOf())));

    //创建上传堆 作为中介
    ThrowIfFailed(device->CreateCommittedResource(
        //上传堆
        &CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD),
		D3D12_HEAP_FLAG_NONE,
        &CD3DX12_RESOURCE_DESC::Buffer(byteSize),
        //上传堆所需要的启动状态
		D3D12_RESOURCE_STATE_GENERIC_READ,
        nullptr,
        IID_PPV_ARGS(uploadBuffer.GetAddressOf())));


    // 描述我们要传入默认堆的数据
    D3D12_SUBRESOURCE_DATA subResourceData = {};
    subResourceData.pData = initData;
    subResourceData.RowPitch = byteSize;
    subResourceData.SlicePitch = subResourceData.RowPitch;

    //转换资源状态  将数据复制给上传堆 上传堆再复制到默认堆
	cmdList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition(defaultBuffer.Get(), 
		D3D12_RESOURCE_STATE_COMMON, 
        //资源处于复制目标状态
        D3D12_RESOURCE_STATE_COPY_DEST));
    UpdateSubresources<1>(cmdList, defaultBuffer.Get(), uploadBuffer.Get(), 0, 0, 1, &subResourceData);
	cmdList->ResourceBarrier(1, &CD3DX12_RESOURCE_BARRIER::Transition(defaultBuffer.Get(),
		D3D12_RESOURCE_STATE_COPY_DEST, D3D12_RESOURCE_STATE_GENERIC_READ));
    return defaultBuffer;
}

整个创建顶点缓冲区的流程如下:
img

然后我们还需要为其创建视图(无需为其创建描述符堆) 以及将其绑定到渲染流水线上的输入槽,这样就可以向输入装配器传入顶点数据:

D3D12_VERTEX_BUFFER_VIEW VertexBufferView()const
{
	D3D12_VERTEX_BUFFER_VIEW vbv;
    //虚拟地址 使用函数即可获得
	vbv.BufferLocation = VertexBufferGPU->GetGPUVirtualAddress();
    //顶点缓冲区所占字节大小
	vbv.StrideInBytes = VertexByteStride;
    //每个顶点数据所占字节大小
	vbv.SizeInBytes = VertexBufferByteSize;

	return vbv;
}
//0 代表绑定第0个输入槽 共有16个
//1 代表顶点缓冲区的数量为1
mCommandList->IASetVertexBuffers(0, 1, &mBoxGeo->VertexBufferView());

最后绘制顶点:

定义图元拓扑类型
 mCommandList->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);

img

索引

索引缓冲区的创建过程和顶点的过程很类似:

定义索引
std::array<std::uint16_t, 36> indices =
{
	// front face
	0, 1, 2,
	0, 2, 3,

	// back face
	4, 6, 5,
	4, 7, 6,

	// left face
	4, 5, 1,
	4, 1, 0,

	// right face
	3, 2, 6,
	3, 6, 7,

	// top face
	1, 5, 6,
	1, 6, 2,

	// bottom face
	4, 0, 3,
	4, 3, 7
};
//索引缓冲区大小
const UINT ibByteSize = (UINT)indices.size() * sizeof(std::uint16_t);
//定义默认堆 与 上传堆
Microsoft::WRL::ComPtr<ID3D12Resource> IndexBufferGPU = nullptr;
Microsoft::WRL::ComPtr<ID3D12Resource> IndexBufferUploader = nullptr;
//初始化索引缓冲区
mBoxGeo->IndexBufferGPU = d3dUtil::CreateDefaultBuffer(md3dDevice.Get(),
	mCommandList.Get(), indices.data(), ibByteSize, mBoxGeo->IndexBufferUploader);
//创建视图 绑定到渲染流水线
D3D12_INDEX_BUFFER_VIEW IndexBufferView()const
{
	D3D12_INDEX_BUFFER_VIEW ibv;
	ibv.BufferLocation = IndexBufferGPU->GetGPUVirtualAddress();
	ibv.Format = IndexFormat;
	ibv.SizeInBytes = IndexBufferByteSize;

	return ibv;
}
mCommandList->IASetIndexBuffer(&mBoxGeo->IndexBufferView());
//绘制顶点
mCommandList->DrawIndexedInstanced(
		mBoxGeo->DrawArgs["box"].IndexCount, 
		1, 0, 0, 0);

注意在上述过程中我们采用索引来绘制顶点 而不是像上一部分那样使用DrawInstanced 参数解释如下:
img

顶点着色器

顶点着色器代码如下

//cbuffer 代表常量缓冲区 b0存储资源的寄存器
cbuffer cbPerObject : register(b0)
{
    //从局部空间转换到齐次裁剪空间
	float4x4 gWorldViewProj; 
};

//顶点着色器输入 
//冒号后面的是参数语义
//要和之前提到的输入布局描述对应 同时也要与顶点着色器的输入参数对应
//冒号签名的是自定义的数据成员的名称 叫做输入签名
struct VertexIn
{
	float3 PosL  : POSITION;
    float4 Color : COLOR;
};
//顶点着色器输出 语义作为下一步几何着色器或者像素着色器的输入参数
struct VertexOut
{
	float4 PosH  : SV_POSITION;
    float4 Color : COLOR;
};

VertexOut VS(VertexIn vin)
{
	VertexOut vout;
	
	//转换到齐次裁剪空间
    //mul 有向量矩阵 或者矩阵矩阵乘法的多个重载版本
    //透视除法步骤是交由硬件处理 人为无需编写代码
	vout.PosH = mul(float4(vin.PosL, 1.0f), gWorldViewProj);
	
	// 直接将输入颜色传递给像素着色器
    vout.Color = vin.Color;
    
    return vout;
}

不同寄存器存储不同类型资源如下:
img
由于使用的着色器语言 HLSL没有 引用或者指针 所以返回多条数据 可以使用结构体的形式 在HLSL中所有函数都是内联的

注意上述代码的语义都是特定的 比如SV_POSITION就代表着存储着齐次裁剪空间的顶点位置信息 其余语义说明可见:
https://learn.microsoft.com/zh-cn/windows/win32/direct3dhlsl/dx-graphics-hlsl-semantics

还有一个地方注意的是 顶点着色器中使用的数据必须要都在之前的顶点结构体中定义(当然还有输入布局描述)但是我们定义的顶点结构体数据可以更多 必须是一个包含关系

像素着色器

对顶点着色器输出的数据 进行插值 在不使用几何着色器的情况下 插值的结果作为像素着色器的输入
这里还强调了一下pixel fragment 与 pixel的区别 像素着色器的输入是像素片段 而像素是已经通过深度测试 模版测试等等 最终绘制到屏幕上去的像素
d3d还提到 由于硬件优化的原因 有些像素片段 进行early-z之后就已经被筛除 但是有可能像素着色器中对像素片段的深度值进行了改变 此时就不能进行early-z 因为像素片段的最终深度值尚未确定

本节的像素着色器的代码很简单,直接输出颜色:
函数参数列表之后的SV_Target语义表示 输出的格式应该与渲染目标的格式相匹配

float4 PS(VertexOut pin) : SV_Target
{
    return pin.Color;
}

着色器编译

ComPtr<ID3DBlob> mvsByteCode = nullptr;
ComPtr<ID3DBlob> mpsByteCode = nullptr;
mvsByteCode = d3dUtil::CompileShader(L"Shaders\\color.hlsl", nullptr, "VS", "vs_5_0");
mpsByteCode = d3dUtil::CompileShader(L"Shaders\\color.hlsl", nullptr, "PS", "ps_5_0");

ComPtr<ID3DBlob> d3dUtil::CompileShader(
	const std::wstring& filename,
	const D3D_SHADER_MACRO* defines,
	const std::string& entrypoint,
	const std::string& target)
{
	UINT compileFlags = 0;
#if defined(DEBUG) || defined(_DEBUG)  
	compileFlags = D3DCOMPILE_DEBUG | D3DCOMPILE_SKIP_OPTIMIZATION;
#endif

	HRESULT hr = S_OK;

	ComPtr<ID3DBlob> byteCode = nullptr;
	ComPtr<ID3DBlob> errors;
	hr = D3DCompileFromFile(filename.c_str(), defines, D3D_COMPILE_STANDARD_FILE_INCLUDE,
		entrypoint.c_str(), target.c_str(), compileFlags, 0, &byteCode, &errors);

	if(errors != nullptr)
		OutputDebugStringA((char*)errors->GetBufferPointer());

	ThrowIfFailed(hr);

	return byteCode;
}

其中比较重要的参数有
文件名 比如:L"Shaders\color.hlsl" 这里的类型是wstring 因此要使用L
着色器的入口点 VS/PS
着色器版本 vs_5_0等等
img
这里简要介绍了一下ID3DBlob这个类型:
img
我在知乎看到一个回答介绍的更为详细:
https://zhuanlan.zhihu.com/p/304352552
下面引用如下

Blob(binary large object),二进制大对象。ID3DBlob则是DX12内建的一种存放较为庞大的二进制对象。在GPU上面,我们对于大部分资源的描述一般都是用地址起点(address starting point)加上对象内存容量(object memory)来描述并且确定某一对象资源
因为其资源内存容量较为庞大的特点,这些资源大多数都不能直接上传到GPU,而是首先在CPU预处理成Blob,然后再上传绑定到GPU上面,才能供GPU使用
上传的对象包括但不限于顶点数据(Vertex data),索引数据(Index data),材质(Texture)等,还包括我们着色器程序(shader)。即我们写的HLSL(high level shader language)程序,需要在CPU端通过预处理和编译才能上传到GPU端供GPU读取并且执行

常量缓冲区

常量缓冲区也是一种GPU资源(ID3D12Resource),但是常量缓冲区是CPU每帧都要更新一次,比如摄像机如果每帧都在移动,那么常量缓冲区每帧都需要更新其中的视图矩阵,所以我们需要将常量缓冲区创建到一个上传堆而非默认堆,这样我们就可以从cpu端更新常量。

下面让我们来看看示例程序中是如何创建常量缓冲区的
首先,定义常量缓冲区结构体:

struct ObjectConstants
{
    XMFLOAT4X4 WorldViewProj = MathHelper::Identity4x4();
};

我们可以看到目前里面只定义了视图矩阵

其次,定义了上传缓冲区的辅助类UploadBuffer.h
注意该辅助类主要用于需要提交到上传堆的gpu资源,而我们之前有一个用于创建默认堆的辅助函数:

template<typename T>
class UploadBuffer
{
public:
    //参数说明 
	//elementCount表示ObjectConstants的数量
	//isConstantBuffer表示是否为要创建常量缓冲区
    UploadBuffer(ID3D12Device* device, UINT elementCount, bool isConstantBuffer) : 
        mIsConstantBuffer(isConstantBuffer)
    {
        mElementByteSize = sizeof(T);
        
		//如果为常量缓冲区,重新计算ObjectConstants结构体的大小
        if(isConstantBuffer)
            mElementByteSize = d3dUtil::CalcConstantBufferByteSize(sizeof(T));
        //创建gpu资源(常量缓冲区) 与 一个上传堆 并把资源提交到堆上
        ThrowIfFailed(device->CreateCommittedResource(
            &CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD),
            D3D12_HEAP_FLAG_NONE,
            &CD3DX12_RESOURCE_DESC::Buffer(mElementByteSize*elementCount),
			D3D12_RESOURCE_STATE_GENERIC_READ,
            nullptr,
            IID_PPV_ARGS(&mUploadBuffer)));
        
		//使用map方法,在cpu端分配一块虚拟地址范围,用来映射gpu的资源
        ThrowIfFailed(mUploadBuffer->Map(0, nullptr, reinterpret_cast<void**>(&mMappedData)));

    }

    UploadBuffer(const UploadBuffer& rhs) = delete;
    UploadBuffer& operator=(const UploadBuffer& rhs) = delete;
    ~UploadBuffer()
    {
		//调用unmap取消对gpu资源的映射
        if(mUploadBuffer != nullptr)
            mUploadBuffer->Unmap(0, nullptr);

        mMappedData = nullptr;
    }
    //获取gpu资源
    ID3D12Resource* Resource()const
    {
        return mUploadBuffer.Get();
    }
    //从cpu端更新常量缓冲区中的内容
    void CopyData(int elementIndex, const T& data)
    {
        memcpy(&mMappedData[elementIndex*mElementByteSize], &data, sizeof(T));
    }

private:
    Microsoft::WRL::ComPtr<ID3D12Resource> mUploadBuffer;
    BYTE* mMappedData = nullptr;
    UINT mElementByteSize = 0;
    bool mIsConstantBuffer = false;
};

创建常量缓冲区 我们可以使用如下代码:

std::unique_ptr<UploadBuffer<ObjectConstants>> mObjectCB = nullptr;
定义常量缓冲区存储的是ObjectConstants类型数据 数量为1
mObjectCB = std::make_unique<UploadBuffer<ObjectConstants>>(md3dDevice.Get(), 1, true);

上述代码中 我们可以看到UploadBuffer这个类是使用了模版 这意味着该方法不仅可以创建常量缓冲区资源 也可以创建其它使用上传堆的gpu资源

同时上述代码中在获取ObjectConstants的大小时,我们可以看到使用了d3dUtil::CalcConstantBufferByteSize的方法,该方法代码如下:

static UINT CalcConstantBufferByteSize(UINT byteSize)
{
    // Example: Suppose byteSize = 300.
    // (300 + 255) & ~255
    // 666666 & ~255
    // 0x022B & ~0x00ff
    // 0x022B & 0xff00
    // 0x0200
    // 512
    return (byteSize + 255) & ~255;
}

这是因为常量缓冲区的大小必须是硬件最小分配空间的整数倍(通常是256b) 这是因为硬件只能按照这样的规格来查看常量数据,所以要对常量缓冲区的数组进行填充字节

然后,我们还需要创建相应的描述符来将资源绑定到渲染流水线上,和之前顶点缓冲区描述符以及索引不同,我们要为常量缓冲区描述符创建描述堆,然后再创建描述符:

//创建cbv描述符堆
void BoxApp::BuildDescriptorHeaps()
{
    D3D12_DESCRIPTOR_HEAP_DESC cbvHeapDesc;
    cbvHeapDesc.NumDescriptors = 1;
    cbvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV;
    cbvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
	cbvHeapDesc.NodeMask = 0;
    ThrowIfFailed(md3dDevice->CreateDescriptorHeap(&cbvHeapDesc,
        IID_PPV_ARGS(&mCbvHeap)));
}

//计算第i个物体ObjectConstants的起始内存位置 与大小
UINT objCBByteSize = d3dUtil::CalcConstantBufferByteSize(sizeof(ObjectConstants));
D3D12_GPU_VIRTUAL_ADDRESS cbAddress = mObjectCB->Resource()->GetGPUVirtualAddress();
int boxCBufIndex = 0;
cbAddress += boxCBufIndex*objCBByteSize;

//填写描述符 创建视图
D3D12_CONSTANT_BUFFER_VIEW_DESC cbvDesc;
cbvDesc.BufferLocation = cbAddress;
cbvDesc.SizeInBytes = d3dUtil::CalcConstantBufferByteSize(sizeof(ObjectConstants));

md3dDevice->CreateConstantBufferView(
	&cbvDesc,
	mCbvHeap->GetCPUDescriptorHandleForHeapStart());

根签名与描述符表

根签名的作用是,定义绑定到渲染流水线上的资源,与对应的着色器的输入寄存器的映射关系,从而可以被着色器程序访问。
不同的绘制调用可能用到一组不同的着色器程序,这就意味着用到不同的根签名。
在d3d中,根签名使用ID3DRootSignature接口来表示,并且由一组描述绘制调用过程中着色器所需资源的根参数定义而成
根参数可以是根常量、根描述符或者描述符表。在本章中,我们只是简要了解根签名,详细的介绍将在下一章中展开,本章只使用了描述符表,即描述符堆中存有描述符的一块连续区域
下面根据代码简要分析:

void BoxApp::BuildRootSignature()
{

	// 根参数
	CD3DX12_ROOT_PARAMETER slotRootParameter[1];

	// 创建一个cbv的描述符表
	CD3DX12_DESCRIPTOR_RANGE cbvTable;
	cbvTable.Init(D3D12_DESCRIPTOR_RANGE_TYPE_CBV, 
    1, //描述符数量
    0 //绑定到b0寄存器);
	slotRootParameter[0].InitAsDescriptorTable(1, &cbvTable);

	// 根签名由一组根参数构成
	CD3DX12_ROOT_SIGNATURE_DESC rootSigDesc(1, slotRootParameter, 0, nullptr, 
		D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT);

	// 创建根签名  必须要先将根签名的描述布局通过ID3DBlob序列化才能传入创建根签名的方法
	ComPtr<ID3DBlob> serializedRootSig = nullptr;
	ComPtr<ID3DBlob> errorBlob = nullptr;
	HRESULT hr = D3D12SerializeRootSignature(&rootSigDesc, D3D_ROOT_SIGNATURE_VERSION_1,
		serializedRootSig.GetAddressOf(), errorBlob.GetAddressOf());

	if(errorBlob != nullptr)
	{
		::OutputDebugStringA((char*)errorBlob->GetBufferPointer());
	}
	ThrowIfFailed(hr);

	ThrowIfFailed(md3dDevice->CreateRootSignature(
		0,
		serializedRootSig->GetBufferPointer(),
		serializedRootSig->GetBufferSize(),
		IID_PPV_ARGS(&mRootSignature)));
}

然后还要通过命令列表设置cbv堆与根签名,再通过设置描述符表绑定资源:

ID3D12DescriptorHeap* descriptorHeaps[] = { mCbvHeap.Get() };
mCommandList->SetDescriptorHeaps(_countof(descriptorHeaps), descriptorHeaps);
mCommandList->SetGraphicsRootSignature(mRootSignature.Get());
mCommandList->SetGraphicsRootDescriptorTable(0, mCbvHeap->GetGPUDescriptorHandleForHeapStart());

一些关于根签名的注意事项:
img

配置光栅器状态与流水线状态对象

大多数控制图形流水线状态对象被统称为流水线状态对象PSO,用接口ID3D12PipelineState表示
创建其的代码如下:

void BoxApp::BuildPSO()
{
    D3D12_GRAPHICS_PIPELINE_STATE_DESC psoDesc;
    ZeroMemory(&psoDesc, sizeof(D3D12_GRAPHICS_PIPELINE_STATE_DESC));
    //绑定输入布局
    psoDesc.InputLayout = { mInputLayout.data(), (UINT)mInputLayout.size() };
    //根签名
    psoDesc.pRootSignature = mRootSignature.Get();
    //顶点着色器
    psoDesc.VS = 
	{ 
		reinterpret_cast<BYTE*>(mvsByteCode->GetBufferPointer()), 
		mvsByteCode->GetBufferSize() 
	};
    //像素着色器
    psoDesc.PS = 
	{ 
		reinterpret_cast<BYTE*>(mpsByteCode->GetBufferPointer()), 
		mpsByteCode->GetBufferSize() 
	};
    //填写光栅器状态 这里使用默认值创建
    psoDesc.RasterizerState = CD3DX12_RASTERIZER_DESC(D3D12_DEFAULT);
    psoDesc.BlendState = CD3DX12_BLEND_DESC(D3D12_DEFAULT);
    psoDesc.DepthStencilState = CD3DX12_DEPTH_STENCIL_DESC(D3D12_DEFAULT);
    psoDesc.SampleMask = UINT_MAX;
    psoDesc.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE;
    psoDesc.NumRenderTargets = 1;
    psoDesc.RTVFormats[0] = mBackBufferFormat;
    psoDesc.SampleDesc.Count = m4xMsaaState ? 4 : 1;
    psoDesc.SampleDesc.Quality = m4xMsaaState ? (m4xMsaaQuality - 1) : 0;
    psoDesc.DSVFormat = mDepthStencilFormat;
    ThrowIfFailed(md3dDevice->CreateGraphicsPipelineState(&psoDesc, IID_PPV_ARGS(&mPSO)));
}

描述符的详细属性可查看微软文档


大家好,我是痞子衡,是正经搞技术的痞子。今天痞子衡给大家介绍的是
i.MXRT1xxx系列GPIO提早供电会影响上电时序导致内部DCDC启动失败

最近有一个 RW612 产品线的同事在设计一个双 MCU 系统 Demo 时发现,当 RW612 板卡和 RT1060 板卡通过 UART 对接时,如果 RW612 板卡提前上电,RT1060 板卡后上电,会导致 RT1060 程序无法正常启动。这其实是一个 i.MX RT 系列上典型的上电时序问题,今天痞子衡就和大家聊聊这个话题:

  • Note: 本文内容以 i.MXRT1060 为例,但基本也适用其他 i.MXRT1xxx 系列。

一、快速复现问题

痞子衡同事在用两块 MCU 板卡(RW612+RT1060)对接时遇到了问题,想复现这个问题,其实没有那么复杂,我们可以用一个USB转串口模块(3.3V)代替 RW612 来和 RT1060 的 ROM ISP UART 口连接:

痞子衡使用得这个USB转串口模块 TXD 和 RXD 驱动能力不同,空载电压测得均是 3.55V,但是和未上电的 RT1060-EVKB 板卡对接时,电压会有所下降,因此痞子衡测试了不同连接组合下的 RT1060 板卡上电启动结果:

USB转串口模块一直通电 RT1060-EVKB J46未连接 RT1060-EVKB J46连接后
模块TXD
芯片GPIO_AD_B0_13
模块RXD
芯片GPIO_AD_B0_12
NVCC_GPIO
NVCC_SD1
NVCC_EMC
NVCC_SD0 DCDC_PSWITCH DCDC_IN VDD_SOC_IN
DCDC_LP
VDD_SOC_IN
DCDC_LP
程序启动结果
未接 未接 0 0 0 0 0 1.275V 启动成功
未接 0.83V 0.33V 0 0.33V 0.33V 0 1.275V 启动成功
2.22V 未接 1.42V 0 1.42V 1.42V 0 0 启动失败
2.24V 1.79V 1.46V 0 1.46V 1.46V 0 0 启动失败

从测试结果可以看到,RT1060 程序启动失败的主要原因是板卡上电后,VDD_SOC_IN 上没有电压(即主芯片内部 DCDC 没有输出)。

二、内部DCDC上电时序要求

在分析启动失败问题前,首先需要简单了解下 i.MXRT1xxx 芯片内部的 DCDC 模块。在一些成本敏感的应用中,首推使用芯片内部 DCDC 供电给内核,因此 DCDC 模块能否正常启动决定了内核能否正常运行。

在恩智浦官网文档
《i.MX RT Hardware Development Guide for the MIMXRT1050/MIMXRT1060 Processor》
里对上电时序做了如下严格规定,其中最常发生问题的地方就是有效 DCDC_PSWITCH 和 DCDC_IN 之间至少 1ms 的延时(以 DCDC_IN 稳定在 3V 为时间起点,以 DCDC_PSWITCH 上升到 1.5V 为时间终点),如果不满足这个 1ms 延时要求,内部 DCDC 模块则可能会启动失败,无法正常输出电压给内核(DCDC_LP 脚)。

• The VDD_SNVS_IN supply must be turned on before any other power supply or connected (shorted) with the VDD_HIGH_IN supply.
• If a coin-cell battery is used to power VDD_SNVS_IN, ensure that it is connected before any other supply is switched on.
• An RC delay circuit is recommended for providing the delay between DCDC_IN stable and DCDC_PSWITCH. The total RC delay should be 5-15 ms.
• DCDC_IN must reach a minimum 3.0 V within 0.3 x RC.
• The delay from DCDC_IN stable at 3.0 V min to DCDC_PSWITCH reaching 0.5 x DCDC_IN (1.5 V) must be at least 1 ms.
• The power-up slew rate specification for other power domains is 360 V/s – 36k V/s.
• POR_B must be held low during the entire power up sequence

三、GPIO提早供电的影响

现在我们查看一下 RT1060-EVKB 原理图设计,外部电源输入 DCDC_3V3 负责给 NVCC_GPIO/SD1/EMC 以及 DCDC_IN 供电,同时 DCDC_IN 也经过了一个 RC 延时电路供电给 DCDC_PSWITCH,这是符合硬件设计要求的。

由于 RT1060 上 GPIO_AD_Bx 和 GPIO_Bx 两组 PAD 供电均来自于 NVCC_GPIO,当其中任何一个 GPIO 有电压时,均可能会漏电到 NVCC_GPIO。因此我们提前将 USB 转串口模块通过 ROM ISP UART 连到板卡,即会漏电到了 NVCC_GPIO->DCDC_3V3->DCDC_IN->DCDC_PSWITCH,即相当于给 DCDC_PSWITCH 的 RC 电路预充了一定电,等板卡正式上电,RC 电路升压到 1.5V 的时间就会缩短。这也就是上个小节表格里为何 DCDC_PSWITCH 预充到 0.33V 时不影响上电时序,而预充到 1.42V 时就不行了,因为延时小于 1ms 了。

根据 RT1060-EVKB 这样的电源电路设计,不仅仅 ROM ISP UART 两个引脚提前上电会影响芯片启动,属于 NVCC_GPIO/SD1/EMC 供电下的任何一个 GPIO 提前上电都会产生相同的效果。但是 NVCC_SD0 供电下的 GPIO 则不会影响启动,因为它在走线上和 DCDC_PSWITCH 之间没有连接。

四、多板卡连接的解决方案

现在我们知道了问题产生的原因,以后遇到此类问题,第一时间先去测量 i.MXRT1xxx 内部 DCDC 输出(DCDC_LP)的电压(如果为 0,则一定是上电时序问题)。那该如何规避问题呢?以与 RT1060-EVKB 连接为例则有如下三个方法:

  • 方法一: 两个板卡之间用 UART 信号连接时串上隔离电阻(比如1K欧姆),这样能降低漏电到 NVCC_GPIO 的电压(以实测为准)。
  • 方法二: 选用不干扰 DCDC_PSWITCH 信号的 GPIO 组(对于 RT1060-EVKB,则是 GPIO_SD_B0_xx 引脚)进行连接。
  • 方法三: 将 RT1060-EVKB 板卡,改为外部 PMIC 供电,弃用主芯片内部 DCDC。

至此,i.MXRT1xxx系列GPIO提早供电会影响上电时序导致内部DCDC启动失败便介绍完毕了,掌声在哪里~~~

欢迎订阅

文章会同时发布到我的
博客园主页

CSDN主页

知乎主页

微信公众号
平台上。

微信搜索"
痞子衡嵌入式
"或者扫描下面二维码,就可以在手机上第一时间看了哦。