2.5 PE结构:导入表详细解析

导入表(Import Table)是Windows可执行文件中的一部分,它记录了程序所需调用的外部函数(或API)的名称,以及这些函数在哪些动态链接库(DLL)中可以找到。在Win32编程中我们会经常用到导入函数,导入函数就是程序调用其执行代码又不在程序中的函数,这些函数通常是系统提供给我们的API,在调用者程序中只保留一些函数信息,包括函数名机器所在DLL路径。

当程序需要调用某个函数时,它必须知道该函数的名称和所在的DLL文件名,并将DLL文件加载到进程的内存中。导入表就是告诉程序这些信息的重要数据结构。一般来说导入表的数据结构如下:

  • Import Lookup Table:通常被称为ILT,记录了程序需要调用的外部函数的名称,每个名称以0结尾。如果使用了API重命名技术,这里的名称就是修改过的名称。
  • Import Address Table:通常被称为IAT,记录了如何定位到程序需要调用的外部函数,即每个函数在DLL文件中的虚拟地址。在程序加载DLL文件时,IAT中的每一个条目都会被填充为实际函数在DLL中的地址。如果DLL中的函数地址发生变化,程序会重新填充IAT中的条目。
  • Import Directory Table:通常被称为IDT,记录了DLL文件的名称、ILT和IAT在可执行文件中的位置等信息。

导入表是Windows可执行文件中的重要组成部分,它直接决定了程序是否能够正确调用外部函数和执行需要依赖外部DLL文件的功能。在分析恶意软件或者逆向工程中,导入表也是非常重要的分析对象,常常可以通过检查IAT中的条目或IDT中的DLL名称,来发现程序中是否存在恶意行为或隐藏的功能。

2.5.1 导入表原理分析

对于磁盘上的PE文件来说,它无法得知这些导入函数会被放置在那个空间中,只有当PE文件被装入内存时,Windows装载器才会将导入表中声明的动态链接库与函数一并加载到进程的地址空间,并修正指令代码中调用函数地址,最后让系统API函数与用户程序结合起来.

为了验证导入函数的导入规律,这里我们使用汇编语言调用一个简单地弹窗,这里并没有使用C语言是因为C中封装了太多无用代码,这回阻碍我们学习导入表结构,这里我所使用的汇编环境是RadASM,编译器是VC++10.

  .386p.model flat,stdcalloption casemap:noneinclude windows.inc
include user32.inc
includelib user32.lib
include kernel32.inc
includelib kernel32.lib.dataszTitle byte 'MsgBox',0hszMsg byte 'hello lyshark',0h.codemain PROCinvoke MessageBox,NULL,offset szMsg,offset szTitle,MB_OKinvoke ExitProcess,0main ENDP
END main

在汇编中程序一旦被编译,编译器会对invoke指令进行分解,分解后的指令中将包含指向导入函数的地址的操作数,当PE加载后,该操作数就会被操作系统导入替换为函数的VA地址,如下我们使用调试器附加,观察这段弹窗代码,有没有发现特殊的地方?

00D21000 | 6A 00              | push 0x0                            |
00D21002 | 68 0030D200        | push main.D23000                    |  
00D21007 | 68 0730D200        | push main.D23007                    |  
00D2100C | 6A 00              | push 0x0                            |
00D2100E | E8 07000000        | call <JMP.0x00D2101A>               | call MessageBox
00D21013 | 6A 00              | push 0x0                            |
00D21015 | E8 06000000        | call <JMP.0x00D21020>               | call ExitProcess
00801017 | CC                 | int3                                |
00D2101A | FF25 0820D200      | jmp dword ptr ds:[<&0x00D22008>]    | 导入函数地址
00D21020 | FF25 0020D200      | jmp dword ptr ds:[<&0x00D22000>]    | 导入函数地址

反汇编后,可看到对MessageBoxExitProcess函数的调用,变成了对<JMP.0x00D2101A><JMP.0x00D21020>地址的调用,但是这两个地址显然是位于程序自身模块,而不是系统模块中,实际上这是由于编译器在编译时,自动在程序代码的后面添加了jmp dword ptr ds:[<&0xxxxxx>]类型的跳转指令,其中的[xxxxx]地址中才是真正存放导入函数地址的地址.

PE文件在被装入内存后JMP跳转后面的地址才会被操作系统确定并填充到指定的位置上,那么在程序没有被PE装载器加载之前0x00D22000地址处的内容是什么呢,我们使用上面的PE解析器对节表进行解析观察.

----------------------------------------------------------------------------------------------------
编号     节区名称           虚拟偏移        虚拟大小        实际偏移        实际大小        节区属性
----------------------------------------------------------------------------------------------------
1        .text           0x00001000      0x00000026      0x00000400      0x00000200      0x60000020
2        .rdata          0x00002000      0x00000092      0x00000600      0x00000200      0x40000040
3        .data           0x00003000      0x00000015      0x00000800      0x00000200      0xC0000040
4        .rsrc           0x00004000      0x00000010      0x00000A00      0x00000200      0x40000040
5        .reloc          0x00005000      0x00000030      0x00000C00      0x00000200      0x42000040
----------------------------------------------------------------------------------------------------

由于该程序的OEP建议装入地址是0x0d20000所以0x0d22000地址实际上是处于RVA偏移为2000h的地方,我们再观察各个节的相对偏移,可发现2000h开始的地方位于.rdata节内,而这个节的实际偏移项为600h,也就是说0x0d22000地址的内容实际上对应到了PE文件中偏移600h处的数据.

你可以打开WinHEX十六进制查看器,或自己实现一个简单的十六文本进制转换器,对可执行文件进行十六进制转换与输出,使用Python实现代码如下.

import os,sys
import argparsedef BinaryToHex(FileName,Seek,Range):count = 0offset = 0with open(FileName,"rb") as fp:file_size = os.path.getsize(FileName)fp.seek(int(Seek))offset = int(Seek)if int(Seek)+int(Range) < file_size:print("-" * 60)print("0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15 | offset")print("-" * 60)for item in range(int(Range)):char = fp.read(1)count = count + 1if count % 16 == 0:if ord(char) < 16:print("0" + hex(ord(char))[2:] + " | ",end="")else:print(hex(ord(char))[2:] + " | ",end="")print("0x%07d"%offset)offset = offset + 16else:if ord(char) < 16:print("0" + hex(ord(char))[2:] + " ",end="")else:print(hex(ord(char))[2:] + " ",end="")else:print("[-] 输入参数超出文件最大字节数.")if __name__ == "__main__":# 使用方式: main.py -e qq.exe -s 0 -c 100parser = argparse.ArgumentParser()parser.add_argument("-e","--exe",dest="exe",help="指定要打开的二进制文件")parser.add_argument("-s","--seek",dest="seek",help="指定文件偏移位置")parser.add_argument("-c","--count",dest="count",help="指定要读取的字节数")args = parser.parse_args()if args.exe and args.seek and args.count:BinaryToHex(args.exe,args.seek,args.count)else:parser.print_help()

将光标拖到600h处,会发现其对应的地址是00002076h,这个地址显然也不会是ExitProcess函数的调用地址,此时我们将它作为RVA相对偏移来看呢?

C:\Users> python main.py -e c://pe/x86.exe -s 1536 -c 100
------------------------------------------------------------
0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15 | offset
------------------------------------------------------------
76 20 00 00 00 00 00 00 5c 20 00 00 00 00 00 00 | 0x0001536
54 20 00 00 00 00 00 00 00 00 00 00 6a 20 00 00 | 0x0001552
08 20 00 00 4c 20 00 00 00 00 00 00 00 00 00 00 | 0x0001568
84 20 00 00 00 20 00 00 00 00 00 00 00 00 00 00 | 0x0001584
00 00 00 00 00 00 00 00 00 00 00 00 76 20 00 00 | 0x0001600
00 00 00 00 5c 20 00 00 00 00 00 00 b1 01 4d 65 | 0x0001616

查看节表可以发现RVA地址00002076h也处于.rdata节内(虚拟偏移+虚拟大小 > 2076h),我们拿00002076h减去节的起始地址0x2000h得到这个RVA相对于节首的偏移是76h,也就是说它对应文件为0x600+76 = 676h开始的地方,接下来观察可发现,这个位置的字符串正好就是ExitProcess对应的文件偏移中的位置.

C:\Users> python main.py -e c://pe/x86.exe -s 1648 -c 100
------------------------------------------------------------
0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15 | offset
------------------------------------------------------------
2e 64 6c 6c 00 00 9b 00 45 78 69 74 50 72 6f 63 | 0x0001648
65 73 73 00 6b 65 72 6e 65 6c 33 32 2e 64 6c 6c | 0x0001664
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | 0x0001680
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | 0x0001696
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | 0x0001712
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | 0x0001728

最后的总结,当PE文件被装载到内存之前,Windows装载器会根据jmp dword ptr ds:[<xxxxxx>]里面的相对偏移RVA来得到函数名,再根据函数名在内存中找到函数地址,并且用函数的实际地址将[xxxxx]处的内容替换成真正的函数地址,从而完成对函数的调用解析.

2.5.2 IMAGE_IMPORT_DESCRIPTOR

导入表位置和大小可以从PE文件头中IMAGE_OPTIONAL_HEADER32结构的IMAGE_DATA_DIRECTORY数据目录字段中获取,从IMAGE_DATA_DIRECTORY字段得到的是导入表的RVA值,如果在内存中查找导入表,那么将RVA值加上PE文件装入的基址就是实际的地址.

首先我们需要找到数据目录表,找到了数据目录结构,就能找到导入表,导入表由一系列的IMAGE_IMPORT_DESCRIPTOR结构组成,结构的数量取决于程序需要使用的DLL文件数量,每个结构对应一个DLL文件,在所有结构的最后,由一个内容全为0的IMAGE_IMPORT_DESCRIPTOR结构作为结束标志,表结构定义如下:

typedef struct _IMAGE_IMPORT_DESCRIPTOR
{union {DWORD   Characteristics;DWORD   OriginalFirstThunk;     // 包含指向IMAGE_THUNK_DATA(输入名称表)结构的数组} DUMMYUNIONNAME;DWORD   TimeDateStamp;              // 当可执行文件不与被输入的DLL进行绑定时,此字段为0 DWORD   ForwarderChain;             // 第一个被转向的API的索引DWORD   Name;                       // 指向被输入的DLL的ASCII字符串的RVADWORD   FirstThunk;                 // 指向输入地址表(IAT)的RVA
} IMAGE_IMPORT_DESCRIPTOR;

如上表结构定义中的OriginalFirstThunkFirstThunk字段含义是相同的,他们都指向一个包含IMAGE_THUNK_DATA结构的数组,数组中每个IMAGE_THUNK_DATA结构定义了一个导入函数的具体信息,数组的最后以一个内容全为0的IMAGE_THUNK_DATA结构作为结束,该结构的定义如下:

typedef struct _IMAGE_THUNK_DATA32
{union {DWORD ForwarderString;      // 转发字符串的RAVDWORD Function;             // 被导入函数的地址DWORD Ordinal;              // 被导入函数的序号DWORD AddressOfData;        // 指向输入名称表 PIMAGE_IMPORT_BY_NAME} u1;
} IMAGE_THUNK_DATA32;

从上方的结构定义不难看出,是一个双字共用体结构,当结构的最高位为1时,表示函数是以序号的方式导入的,这时双字的低位就是函数的序号,当双字最高位为0时,表示函数以函数名方式导入,这时双字的值是一个RVA,指向一个用来定义导入函数名称的IMAGE_IMPORT_BY_NAME结构,此结构定义如下:

typedef struct _IMAGE_IMPORT_BY_NAME
{WORD    Hint;          // 函数序号CHAR   Name[1];        // 导入函数的名称
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

上面的所有结构就是导入表的全部了,如果但看这些东西,懵逼那是很正常的,其实总结起来就是下图这张表.

现在我们来分析下上图,导入表中IMAGE_IMPORT_DESCRIPTOR结构的NAME字段指向字符串Kernel32.dll表明当前程序要从Kernel32.dll文件中导入函数,OriginalFirstThunkFirstThunk字段指向两个同样的IMAGE_THUNK_DATA数组,由于要导入4个函数,所有数组中包含4个有效项目并以最后一个内容为0的项目作为结束。

第4个函数是以序号导入的,与其对应的IMAGE_THUNK_DATA结构最高位等于1,和函数的序号0010h组合起来的数值就是80000010h,其余的3个函数采用的是以函数名方式导入,所以IMAGE_THUNK_DATA结构的数值是一个RVA,分别指向3个IMAGE_IMPORT_BY_NAME结构,每个结构的第一个字段是函数的序号,后面就是函数的字符串名称了,一切就这么简单!

上图为什么会出现两个一模一样的IMAGE_THUNK_DATA数组结构呢? 这是因为PE装载器会将其中一个结构修改为函数的地址jmp dword ptr[xxxx]其中的xxxx就是由FirstThunk字段指向的那个数组中的一员。

实际上当PE文件被装载入内存后,内存中的映像会被Windows修正为如下图所示的样子:

其中由FristThunk字段指向的那个数组中的每个双字都被替换成了真正的函数入口地址,之所以在PE文件中使用两份IMAGE_THUNK_DATA数组的拷贝并修改其中的一份,是为了最后还可以留下一份备份数据用来反过来查询地址所对应的导入函数名。

2.5.3 枚举导入表流程

通过编程实现读取导入表数据,首先通过(PIMAGE_IMPORT_DESCRIPTOR)(RVAtoFOA(rav) + GlobalFileBase)找到导入表结构体,并以此通过循环的方式输出每一个导入表中导入函数即可,这段代码实现如下所示;

int main(int argc, char * argv[])
{BOOL PE = IsPeFile(OpenPeFile("c://pe/x86.exe"), 0);if (PE == TRUE){// 1. 从数据目录表的下标为1的项找到 rvaDWORD rav = NtHeader->OptionalHeader.DataDirectory[1].VirtualAddress;// 2. 找到导入表结构体auto ImportTable = (PIMAGE_IMPORT_DESCRIPTOR)(RVAtoFOA(rav) + GlobalFileBase);// 3. 遍历导入表数组,数组以全0结尾while (ImportTable->Name){// 4. 输出对应DLL的名字CHAR* DllName = (CHAR*)(RVAtoFOA(ImportTable->Name) + GlobalFileBase);// printf("----> [遍历模块: %s] \n", DllName);printf("Hint值 \t\t API序号 \t 文件RVA \t VA地址 \t 函数名称 \t 模块: [ %s ] \n", DllName);// 5. 找到 IAT 表(文件中的导入表)auto Iat = (PIMAGE_THUNK_DATA)(RVAtoFOA(ImportTable->FirstThunk) + GlobalFileBase);// 这个是INT内存中的导入表auto Int = (PIMAGE_THUNK_DATA)(RVAtoFOA(ImportTable->OriginalFirstThunk) + GlobalFileBase);// 6. 遍历 IAT表 ,直到遇到 全 0 结束遍历while (Iat->u1.Ordinal != 0){// 7. 判断是否有名字if (Iat->u1.AddressOfData & 0x80000000){// 序号导入,直接输出printf("[%5d] \t [None] \n", LOWORD(Iat->u1.AddressOfData));}else{// 找到名字结构体auto Name = (PIMAGE_IMPORT_BY_NAME)(RVAtoFOA(Iat->u1.AddressOfData) + GlobalFileBase);// 通过ImageBase与AddressOfData 相加得到VADWORD ImageBase = NtHeader->OptionalHeader.ImageBase;DWORD VA = Iat->u1.AddressOfData + ImageBase;printf("[%5d] \t %09d \t %08X \t %08X \t %s \n",Name->Hint, Iat->u1.Ordinal, RVAtoFOA(Iat->u1.AddressOfData), VA, Name->Name);}++Iat;}// 指向下一个结构ImportTable++;}}else{printf("非标准程序 \n");}system("pause");return 0;
}

编译并运行上述代码,则可输出当前程序中的所有导入函数信息,输出效果如下图所示;

本文作者: 王瑞
本文链接: https://www.lyshark.com/post/9108413f.html
版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/67922.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【MySQL】MySQL 慢SQL如何避险

我们在日常开发中&#xff0c;一定遇见过某些SQL执行较慢的情况&#xff0c;我们俗称“慢SQL”&#xff0c;如果你对系统的接口性能要求较高的话&#xff0c;一定不会放过这种SQL&#xff0c;肯定会想办法进行解决&#xff0c;那么&#xff0c;导致慢 SQL 出现的原因&#xff0…

ref 操作 React 定时器

秒表 需要将 interval ID 保存在 ref 中&#xff0c;以便在需要时能够清除计时器。 import { useRef, useState } from "react";const SecondWatch () > {const [startTime, setStartTime] useState<any>(null);const [now, setNow] useState<any>…

【python爬虫】9.带着小饼干登录(cookies)

文章目录 前言项目&#xff1a;发表博客评论post请求 cookies及其用法session及其用法存储cookies读取cookies复习 前言 第1-8关我们学习的是爬虫最为基础的知识&#xff0c;从第9关开始&#xff0c;我们正式打开爬虫的进阶之门&#xff0c;学习爬虫更多的精进知识。 在前面几…

SpringBoot整合Jpa实现增删改查功能(提供Gitee源码)

前言&#xff1a;在日常开发中&#xff0c;总是撰写一些简单的SQL会非常耗时间&#xff0c;Jpa可以完美的帮我们提高开发的效率&#xff0c;对于常规的SQL不需要我们自己撰写&#xff0c;相对于MyBatis有着更简单易用的功能&#xff0c;但是MyBatis自由度相对于Jpa会更高一些&a…

树形结构的节点作为查询参数业务

1、业务描述 有一个树结构&#xff0c;存在一个唯一的code和一个父节点的pcode&#xff0c;要求前端传入任意层的code匹配这个code对应的所有子节点对应的数据。 2、解决思路 因为无法判定传入的code到底在那层&#xff0c;so 直接递归调用查询判断&#xff0c;如果有子节点就…

unity 之 如何获取父物体与子物体

文章目录 获取父物体获取子物体 获取父物体 在Unity中&#xff0c;你可以使用Transform组件的属性来获取对象的父物体。以下是在C#脚本中如何获取父物体的示例代码&#xff1a; using UnityEngine;public class GetParentExample : MonoBehaviour {void Start(){// 获取当前物…

【OpenCV实战】4.OpenCV 五种滤波使用实战(均值、盒状、中值、高斯、双边)

OpenCV 五种滤波使用实战(均值、盒状、中值、高斯、双边&#xff09; 〇、Coding实战内容一、滤波、核和卷积1.1 滤波1.2 核 & 滤波器1.3 公式1.4 例子 二、图片边界填充实战2.1 解决问题2.2 相关OpenCV函数2.3 Code 三. 均值滤波实战3.1 理论3.2 Blur3.3 Code 四. 盒状滤波…

南方科技大学博士研究生奖助学金,深圳大学

目录 南方科技大学 中南大学 南京大学 厦门大学 苏州大学 中南财经政法大学 深圳大学 南方科技大学 https://ocean.sustech.edu.cn/ocean/public/upload/download/3/2.pdf 南方科技大学的在读研究生&#xff0c;每人每年都会得到40000元的补助&#xff0c;这40000块钱分…

DP读书:鲲鹏处理器 架构与编程(十二)鲲鹏软件实战案例Docker+KVM的部署

10min速通了解鲲鹏软件实战案例 云服务器源码移植与编译配置云服务器Porting Advisor代码移植搭建交叉编译环境x86云服务器交叉编译 OpenSSL鲲鹏云服务器上编译 OpenSSL Docker的安装与应用安装DockerDocker运行与验证Docker常用命令卸载Docker安装适配鲲鹏架构的Docker镜像 KV…

C语言模拟最简单的计算机

C语言模拟最简单的计算机 以下内容参考南大“计算机系统基础”实验&#xff1a;不停计算的机器 概述 如下面的伪代码所示&#xff0c;计算机运行程序的过程为取指令–>运行指令–>更新PC的值。 while (1) {从PC指示的存储器位置取出指令;执行指令;更新PC; }取指(inst…

Flink中RPC实现原理简介

前提知识 Akka是一套可扩展、弹性和快速的系统&#xff0c;为此Flink基于Akka实现了一套内部的RPC通信框架&#xff1b;为此先对Akka进行了解 Akka Akka是使用Scala语言编写的库&#xff0c;基于Actor模型提供一个用于构建可扩展、弹性、快速响应的系统&#xff1b;并被应用…

3 | Java Spark 配置和 数据筛选

下面将演示如何使用 Apache Spark 的 Java API 来加载数据、筛选出偶数,并计算它们的总和。Apache Spark 是一个强大的分布式计算框架,适用于大规模数据处理任务。 创建 Maven 项目 首先,我们需要创建一个 Maven 项目,以便管理依赖项。在项目的 pom.xml 文件中添加以下 S…

feign调用失败 feign.RetryableException: xxx-service executing GET http://xxx/test

一。 问题引入 升级springcloud的版本后 突然发现 以前正常的feign调用也报错了 升级后的各组件版本如下 spring cloud 2021.0.5 spring cloud alibaba 2021.0.5.0 spring boot 2.6.13 错误日志如下 feign.RetryableException: xxx-service executing GET http://xxx-servic…

Servlet属性、监听者和会话

没有servlet能单独存在。在当前的现代Web应用中&#xff0c;许多组件都是在一起协作共同完成一个目标。怎么让这些组件共享信息&#xff1f;如何隐藏信息&#xff1f;怎样让信息做到线程安全&#xff1f; 1 属性和监听者 1.1 初始化 容器初始化一个servlet时&#xff0c;会为…

LeetCode--HOT100题(47)

目录 题目描述&#xff1a;105. 从前序与中序遍历序列构造二叉树&#xff08;中等&#xff09;题目接口解题思路代码 PS: 题目描述&#xff1a;105. 从前序与中序遍历序列构造二叉树&#xff08;中等&#xff09; 给定两个整数数组 preorder 和 inorder &#xff0c;其中 preo…

基于Vgg-Unet模型自动驾驶场景检测

1.VGG VGG全称是Visual Geometry Group属于牛津大学科学工程系&#xff0c;其发布了一些列以VGG开头的卷积网络模型&#xff0c;可以应用在人脸识别、图像分类等方面,VGG的输入被设置为大小为224x244的RGB图像。为训练集图像上的所有图像计算平均RGB值&#xff0c;然后将该图像…

系统错误码指示确立+日志模块手动配置

1&#xff0c;系统错误码指示确立 对于前后端分离的系统设计中&#xff0c;后端建立错误码指示对于前端非常重要可以指示错误存在地方&#xff1b;以用户注册为例&#xff1b; public interface SystemCode{int SYSTEM_USER_ERROR_ADD_FAIL 10000;int SYSTEM_USER_INFO_ADD …

Miniconda3环境迁移

问题&#xff1a; conda之前安装的默认路径空间满了没法进行安装&#xff0c;为此将其进行迁移&#xff0c;但是迁移之后报错 bash: /data/anaconda3/bin/conda: /home/anaconda3/bin/python: 坏的解释器: 没有那个文件或目录解决方案&#xff1a; 1、修改~/.bashrc中的环境…

B093-springsecurity整合jwt和RSA

目录 前后端分离后springsecurity核心filter的应用场景介绍JWT令牌的组成部分JWT案例导包TestJwt RSARsaUtilsTestRSA分析图 JWTRSA导包JwtUtilsTestRSAJWT 完善spring-security整合后且不连数据库的代码案例流程分析图 前后端分离后springsecurity核心filter的应用场景介绍 账…

15 验证差分时钟输入转单端

供给FPGA的时钟有单端时钟&#xff0c;也有差分时钟&#xff0c;当输入是差分时钟时&#xff0c;需要将差分时钟转换为单端时钟输出来作为FPGA的系统工作时钟。 本次使用锁相环来实现差分到单端时钟的转换。 FPGA代码实现如下&#xff1a; TOP层 timescale 1ns / 1ps // // …