news 2026/10/26 12:25:32

USB-CAN的LabVIEW二次开发:DLL调用、结构体映射与队列架构

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
USB-CAN的LabVIEW二次开发:DLL调用、结构体映射与队列架构

简介:面向LabVIEW环境下USB-CAN上位机开发的实践示例包,适合具备图形化编程基础、希望实现CAN总线数据收发的工程师与学习者。内容围绕驱动集成、接口配置、报文收发与界面设计四条主线展开,并配合ControlCAN驱动库以及板卡信息、状态查询等辅助模块,解决从硬件连接到自定义上位机程序之间的衔接问题。压缩包共58个文件、约1.17MB,以24个dll动态链接库和19个vi程序为主体,另含ini配置、aliases别名、mnu菜单、lvproj工程与lvlib库文件,并按工程、用户库与编译输出分目录组织,便于直接打开运行和二次修改。目前已有2041人学习。示例中的初始化、数据收发、显示界面、错误处理与用户交互VI均可直接运行,读者能据此理解CAN ID过滤、波特率设置与报文解析的实现方式,并在此基础上扩展多通道监控或自定义解析逻辑,缩短从看懂示例到改通程序的调试周期。

1. 一份 USB-CAN 的 LabVIEW 二次开发例子:它到底替你补了哪一段

接上 USB-CAN 盒子,厂商丢给你一个 DLL 和一份 C 头文件,LabVIEW 这边的上位机开发要从零开始——这是二次开发最常见的起点。这份 USB-CAN 的 LabVIEW 二次开发例子补的正是中间那一层:把驱动导出的几个函数包成 subVI,把 24 字节的报文结构体对应成 LabVIEW 的簇,再用队列把接收循环和界面拆开。它不教 CAN 协议本身,适合已经能看懂报文、但卡在“LabVIEW 怎么调 DLL、结构体怎么摆”这一步的人;也适合手上有个能跑的旧程序、想拆开看看别人怎么分层的熟手。下面按“先看接口、再调 DLL、再搭架构、最后排错”的顺序把这套东西讲清楚。

2. 先认清驱动接口:六个导出函数与两个结构体

拿到一个 USB-CAN 的二次开发包,不要急着拖控件,先花十分钟把头文件读一遍。驱动对外暴露的东西其实很少,真正决定你能不能跑通的只有两件事:函数原型里的参数类型,和结构体里的字节布局。这两样对上了,LabVIEW 那边只是接线问题;对不上,调试一整天也找不到原因。

2.1 例子里的 VI 分层:一个主 VI 加四类 subVI

这类例子的骨架基本一致,打开项目浏览器能看到一棵很浅的树。分层的意义不在于好看,而在于把“会变的”和“不会变的”隔开:驱动调用是最不会变的部分,界面和业务解析是最容易变的部分,中间用一层类型定义衔接。

层级典型文件职责对外接口
设备层Open.vi / Close.vi打开、关闭设备句柄设备索引、错误簇
通道层InitCAN.vi / StartCAN.vi波特率、滤波、启动通道初始化结构体
报文层Transmit.vi / Receive.vi单帧发送、批量接收24 字节裸结构体
转换层Frame.ctl / RawToFrame.vi裸字节与自定义簇互转typedef 簇
应用层Main.vi前面板、队列、定时—

我一般会把报文层做成“只认字节、不认业务”的样子:输入是一帧的原始字节,输出也是一帧的原始字节,中间不做任何单位换算。这样后面换协议、加信号解析,都不用回头改底层 VI,改动范围能压在一个转换层里。

2.2 六个核心函数:参数类型和返回值语义

不管哪家的盒子,导出的函数名可能不同,但职责划分高度雷同。下面这组原型是最常见的形态,参数名各家有出入,类型基本一致:

/* 典型 USB-CAN 驱动导出的一组接口,具体宏定义看驱动头文件 */ DWORD VCI_OpenDevice(DWORD DevType, DWORD DevIndex, DWORD Reserved); DWORD VCI_InitCAN(DWORD DevType, DWORD DevIndex, DWORD CANIndex, VCI_INIT_CONFIG *pConfig); DWORD VCI_StartCAN(DWORD DevType, DWORD DevIndex, DWORD CANIndex); ULONG VCI_Transmit(DWORD DevType, DWORD DevIndex, DWORD CANIndex, VCI_CAN_OBJ *pSend, ULONG Len); ULONG VCI_Receive(DWORD DevType, DWORD DevIndex, DWORD CANIndex, VCI_CAN_OBJ *pRecv, ULONG Len, INT WaitTime); DWORD VCI_CloseDevice(DWORD DevType, DWORD DevIndex);

参数说明上有个容易搞混的点:DevType不是“设备序号”,而是设备型号的宏,比如头文件里可能定义成 3、4、21 这类整数,不同型号不一样,照抄网上代码最容易在这里翻车;DevIndex才是同一型号下第几块盒子,从 0 开始;CANIndex是盒子上的第几路 CAN 通道。

返回值语义要分两类看。Open / Init / Start / Close 这一类返回 1 表示成功、0 表示失败,是布尔语义;Transmit / Receive 返回的是实际处理的帧数,返回 0 不代表出错,可能只是没数据。把这两类返回值混为一谈,就会出现“程序一直报错但其实通信正常”的假故障。

还有一个隐藏参数是Reserved,几乎所有函数都要求它填 0。LabVIEW 里如果这个端子悬空,LabVIEW 会自动给默认值 0,看起来没事;但一旦你把它接到一个未初始化的移位寄存器上,就可能传进去垃圾值,表现为 Open 成功、Init 失败。

2.3 两个结构体:16 字节和 24 字节,记住数字比记住字段快

初始化和报文这两个结构体是整个二次开发的核心,字段顺序错一位,后面全废。先把字节数记住:初始化结构体 16 字节,报文结构体 24 字节。在 LabVIEW 里映射完,第一件事就是量一下簇的大小对不对。

typedef struct { DWORD AccCode; /* 验收码 */ DWORD AccMask; /* 屏蔽码,全 1 表示全收 */ DWORD Reserved; /* 必须填 0 */ UCHAR Filter; /* 0=单滤波 1=双滤波 */ UCHAR Timing0; /* BTR0 */ UCHAR Timing1; /* BTR1 */ UCHAR Mode; /* 0=正常 1=只听 */ } VCI_INIT_CONFIG; /* 合计 16 字节 */ typedef struct { UINT ID; /* 4 标准帧 11 位,扩展帧 29 位 */ UINT TimeStamp; /* 4 单位通常是 0.1 ms */ BYTE TimeFlag; /* 1 是否带时间戳 */ BYTE SendType; /* 1 0 正常 1 单次 2 自发自收 3 单次自发自收 */ BYTE RemoteFlag; /* 1 远程帧标志 */ BYTE ExternFlag; /* 1 扩展帧标志 */ BYTE DataLen; /* 1 0~8 */ BYTE Data[8]; /* 8 数据区,不足补 0 */ BYTE Reserved[3]; /* 3 必须填 0 */ } VCI_CAN_OBJ; /* 合计 24 字节 */

AccCode和AccMask是硬件滤波,两者配合决定哪些 ID 能进接收缓冲。想全收就 AccCode=0、AccMask=0xFFFFFFFF;只想收某几个 ID,就要按位算验收码,这块在只做点对点通信时可以先用全收跑通,别一上来就调滤波,很容易把帧全滤掉还以为驱动坏了。

SendType里那个“自发自收”是个宝:填 2 的时候,盒子发出去的帧会同时进自己的接收缓冲,不需要外部节点回应,就能验证收发链路。调试阶段这个值能省掉一半的排查时间。

Timing0和Timing1在很多驱动里就是 SJA1000 的 BTR0/BTR1 原值,位定义是BTR0 = (SJW-1)<<6 | (BRP-1),BTR1 = SAM<<7 | (TSEG2-1)<<4 | (TSEG1-1)。采样点按(1+TSEG1)/(1+TSEG1+TSEG2)算,做到 75%~87.5% 比较稳。血泪经验是:别自己手算。厂商的配置工具里能连上的那组参数,直接把它显示的两个字节抄过来,比对着公式推半小时靠谱得多。

3. 在 LabVIEW 里把 DLL 调通:CLFN 的四个关键设置

LabVIEW 调 DLL 只有一条路——Call Library Function Node。这个节点看着简单,参数配置面板里每一栏选错都能让你从“跑不通”变成“程序崩”。这一章按配置顺序走:先定调用约定,再定参数类型,再解决簇的内存布局,最后才是写框图。

3.1 调用约定与参数类型:stdcall 和 C 不能猜

双击 CLFN 打开配置面板,右上角“调用约定”有 stdcall(WINAPI) 和 C 两个选项。选错的后果非常典型:函数能返回正确值,程序也能跑,但退出时崩溃,或者连续调用几十次后堆栈错乱。因为两种约定清理堆栈的责任方不同,参数个数一多,栈指针就歪了。

判断方法很简单:看头文件里函数声明前面有没有__stdcall或WINAPI,有就选 stdcall;只写了__declspec(dllexport)而没有约定修饰的,通常是 C 约定。Windows 下的驱动 DLL 绝大多数是 stdcall,Linux 下的 .so 基本都是 C 约定,跨平台迁移时这一栏必须改。

参数类型对应关系也要一次定死,别中途改:

C 类型LabVIEW 类型说明
DWORD / ULONG / UINTU32无符号,别用 I32
INT / LONGI32有符号
BYTE / UCHARU8不要用 U16 图省事
结构体指针Adapt to Type + Aggregate by Value接一个簇
BYTE 数组Array Data Pointer必须先预分配长度

提示:U32 和 I32 在内存里都是 4 字节,传进去不会崩,但 LabVIEW 显示时会把大于 0x7FFFFFFF 的值显示成负数。扩展帧 ID 最大 0x1FFFFFFF,还在范围内,所以这个坑往往潜伏到很后面才炸。

3.2 簇的对齐:这两个结构体刚好没有填充,但别当规律

LabVIEW 的簇在传给 DLL 时,默认按 1 字节紧凑排列;C 编译器默认按成员自然对齐,中间可能插填充字节。这两者一旦不一致,DLL 读到的字段就是错位的。

具体到这两个结构体,好消息是:VCI_CAN_OBJ里三个 DWORD 排在前面,后面全是 BYTE,4 字节对齐和 1 字节对齐算出来都是 24 字节;VCI_INIT_CONFIG同理,16 字节两种算法一致。所以簇直传能跑通。

但这是巧合,不是规律。我见过扩展版初始化结构体长这样:第一个字段是UINT Channel,第二个字段是UINT64 Baudrate。按 1 字节排是 12 字节,按 8 字节对齐是 16 字节,中间多出 4 个填充字节,DLL 把 Baudrate 读成垃圾值,表现是“初始化返回成功但波特率完全不对”。

两个解法,按项目复杂度选:

第一种是簇直传,在 LabVIEW 里建一个 typedef 簇,字段顺序和类型严格照头文件抄,然后把这个簇接到 CLFN 的 Adapt to Type 端子,Data Format 选 Aggregate by Value。如果结构体有对齐问题,右键 typedef 簇的“自定义”里能把对齐改成 4 或 8,但要注意 LabVIEW 只支持按 2 的幂对齐,遇上跨平台的复杂结构体经常调不出来。

第二种是退回字节数组,手工按偏移拼装。稳,但代码多。偏移量自己先算清楚:

import struct raw = bytes(24) # 对应一帧 VCI_CAN_OBJ can_id, ts = struct.unpack_from("<II", raw, 0) # ID 偏移 0,时间戳偏移 4 tflag, stype, rflag, eflag, dlc = struct.unpack_from("<5B", raw, 8) # 偏移 8=TimeFlag 9=SendType 10=RemoteFlag 11=ExternFlag 12=DataLen data = raw[13:13 + dlc] # 数据区从偏移 13 开始 reserved = raw[21:24] # 尾部 3 字节保留

这段逻辑的价值在于:它把 24 字节的布局固化成了可以打印、可以断言的数字。在 LabVIEW 里用“字节数组至无符号整数”加“数组子集”按同样偏移取,虽然比簇直传多几个节点,但任何一次错位都能在探针里一眼看出来。我更倾向于:先用字节数组把链路跑通,确认偏移全对,再换回簇直传图省事。反过来做的话,一旦出问题你分不清是偏移错了还是对齐错了。

还有两个绝对不能碰的做法:把 Data 区写成 LabVIEW 字符串(字符串传的是句柄指针,不是字节内容,必崩),以及簇里塞引用或变体(变体内部有类型描述符,和裸内存完全不是一回事)。

3.3 打开、初始化、启动:三步顺序和返回值分支

顺序不能反:Open → Init → Start。反过来虽然有时也能跑,但滤波和波特率不一定生效。

/* 最小可用顺序,DevType 的具体数值以驱动头文件的宏为准,别照抄 */ DWORD dev = 0, ch = 0; VCI_INIT_CONFIG cfg = {0}; cfg.AccCode = 0x00000000; cfg.AccMask = 0xFFFFFFFF; /* 全收 */ cfg.Filter = 0; cfg.Timing0 = 0x00; /* BTR0,从厂商工具里抄 */ cfg.Timing1 = 0x1C; /* BTR1,从厂商工具里抄 */ cfg.Mode = 0; if (VCI_OpenDevice(DEV_TYPE, dev, 0) != 1) return -1; /* 打开失败 */ if (VCI_InitCAN(DEV_TYPE, dev, ch, &cfg) != 1) return -2; /* 初始化失败 */ if (VCI_StartCAN(DEV_TYPE, dev, ch) != 1) return -3; /* 启动失败 */

在 LabVIEW 里,这三步对应三个 CLFN 加三段条件结构,失败时要把对应的错误码写进错误簇,而不是弹个对话框了事。原因是:Open 失败最常见的原因不是驱动没装,而是上一次运行没有 Close,设备被自己的僵尸进程占着。这时候错误码能直接告诉你“是占用还是未插”,比弹窗有用得多。

Reserved在 Open 里填 0,cfg结构体里的Reserved也填 0。LabVIEW 里如果是用“按名称捆绑”构造簇,没指定的字段默认是全 0,问题不大;但如果簇是前面板控件的值带过来的,就可能残留上一轮的值,稳妥做法是每次调用前用“按名称捆绑”显式覆盖一遍。

初始化失败的第二个常见原因是波特率。盒子上的拨码开关、软件里设置的 BTR0/BTR1、总线上其他节点的波特率,三者必须一致。不一致时 Init 照样返回 1,但总线上全是错误帧,收不到任何数据——这是最难查的一类,因为每一步返回值都是成功的。

3.4 发送与接收:CLFN 端子怎么摆

发送相对简单,一帧一个结构体;接收因为要批量取,参数多一个数组长度。

VCI_CAN_OBJ tx = {0}; tx.ID = 0x123; tx.ExternFlag = 0; /* 标准帧 */ tx.RemoteFlag = 0; /* 数据帧 */ tx.DataLen = 8; tx.Data[0] = 0x11; /* ... 其余字节 */ tx.SendType = 0; /* 正常发送;自测时改 2 */ ULONG sent = VCI_Transmit(DEV_TYPE, dev, ch, &tx, 1); /* 返回实际发送帧数 */ VCI_CAN_OBJ rx[100]; /* 一次最多取 100 帧 */ ULONG got = VCI_Receive(DEV_TYPE, dev, ch, rx, 100, 10); /* 阻塞最多 10 ms */

VCI_Receive的最后一个参数WaitTime单位是毫秒。填 0 是非阻塞,立刻返回当前缓冲里的帧数;填正数是阻塞等待,等到有帧或者超时;有些驱动支持填 -1 表示一直等。别在循环里用 -1,设备一旦掉线,那个 CLFN 就永远不返回,整个循环卡死,前面板的停止按钮也救不回来。

对应的 LabVIEW 端子配置:

端子类型数据格式备注
DevType / DevIndex / CANIndexNumericUnsigned 32-bit直接接常量
pSend / pRecvAdapt to TypeAggregate by Value接 typedef 簇或簇数组
LenNumericUnsigned 32-bit数组长度,发送填 1
WaitTimeNumericSigned 32-bit接收专用,建议 0~10
返回值NumericUnsigned 32-bit实际帧数

接收端传数组时,LabVIEW 要求数组已经预分配好长度(这里就是 100),不能传空数组进去等它填——空数组在内存里没有缓冲区,DLL 往里写就是越界。做法是用“初始化数组”先造 100 个元素的全零簇,再连到 CLFN,输出端再用“数组子集”按实际收到的帧数截断。

4. 收发架构:用队列把接收循环和界面拆开

能收到帧只是第一步。真正决定这套上位机好不好用的,是接收循环和界面之间怎么解耦。把 Receive 直接放在事件结构里,界面一卡就丢帧;把接收放在单循环里又不加缓冲,帧率一高就溢出一片。这一章讲的就是怎么把这块搭稳。

4.1 接收节奏:GetReceiveNum 加批量取,比死循环轮询省一半 CPU

很多例子是这么写的:一个 While 循环里调 Receive,WaitTime 填 0,循环里连个等待都不放。这样跑起来 CPU 直接 100%,因为 CLFN 调用本身有几微秒开销,空转一秒能调几十万次。

正确做法是先用VCI_GetReceiveNum查一下缓冲里有多少帧,有数据再调 Receive,一次取一批。

ULONG pending = VCI_GetReceiveNum(DEV_TYPE, dev, ch); /* 缓冲里待取的帧数 */ if (pending > 0) { ULONG want = pending > 100 ? 100 : pending; /* 一次最多 100 */ ULONG got = VCI_Receive(DEV_TYPE, dev, ch, rx, want, 0); /* got 才是真正取到的帧数,可能小于 want */ }

want的上限取多少是个权衡:太大,每次调用要在栈上分配一大块内存,循环延迟变高;太小,CLFN 调用次数多,高帧率下开销明显。100 是我在几百帧每秒的场景下比较顺手的值,如果总线负载超过 50%,可以降到 50,让循环转得更勤。

循环里再加一个 5~10ms 的等待。这个等待不是可有可无的优化,而是必须的:Windows 上Wait(ms)的实际精度受系统时钟影响,默认在 15.6ms 左右,5ms 的等待可能变成 15ms。如果业务要求接收延迟稳定,就得在程序启动时用 CLFN 调一次winmm.dll的timeBeginPeriod(1),把系统定时器精度提到 1ms,退出时再调timeEndPeriod(1)恢复。这个调用很多驱动自带的例子里有现成 VI,直接抄就行。

设备侧的接收缓冲深度也很关键。多数 USB-CAN 盒子是 2000 帧左右,接收端每 10ms 取一次、每次取 100 帧,理论上能扛 10000 帧/秒;但如果界面线程抢占了接收线程,一次卡顿超过 200ms,2000 帧的缓冲就填满了,后面的帧直接丢,而且驱动不会报错——这就是“程序没报错但数据少了”的典型来源。

4.2 队列里传什么:给 typedef 簇设计字段

队列元素的设计有两个极端:一头是把 24 字节裸结构体整个塞进去,另一头是把每个信号都展开成独立字段。前者传输效率高但下游难读,后者可读性好但内存开销大。

我一般用中间方案:解包成固定字段的 typedef 簇,但只保留原始信息,不做单位换算。

字段类型说明
IDU32原始 ID,扩展帧保留 29 位
DataU8 数组长度固定 8,不足补 0
DLCU80~8,真实数据长度
FlagsU8bit0 扩展帧,bit1 远程帧
TimeStampU32驱动时间戳,单位 0.1ms
ChannelU8多通道时区分来源

用 typedef(.ctl 文件)而不是临时用“捆绑”函数现拼,理由很实际:LabVIEW 在跨 VI 传递簇时,如果两边的簇类型不完全一致(哪怕只是元素顺序不同),会插入一个隐式转换,这个转换在高帧率下是实打实的开销,而且不会报错,你在性能分析里只能看到“某个 VI 耗时异常”。用 .ctl 之后,任何字段改动都会让所有引用处报断线,强制你同步修改。

队列深度按峰值帧率乘以缓冲时长估。2000 帧的队列,每帧约 24 字节,总共不到 50KB,内存完全不是问题,所以宁可开大一点。真正要注意的是队列的“元素出队”侧:如果界面结构里用“出队”不带超时,界面一忙就会不断积压,积压到上限后“入队”开始超时,接收线程被堵住,然后就是丢帧。所以入队时要处理超时分支:超时就把这一帧计数丢弃并累加丢帧计数器,宁可丢也不要堵。

4.3 定时发送与总线负载:1ms 周期到底能不能跑

“我要 1ms 发一帧”——这个需求十个项目里有八个会提。能不能做,算一下就知道。

8 字节标准数据帧的位宽,加上帧间间隔,实际在 108~130 bit 之间(含填充位)。保守按 130 bit 估,总线负载的算式是:

负载 = 周期发送帧率 × 每帧位宽 ÷ 波特率
波特率每帧按 130 bit 算的理论最大帧率1ms 周期(1000 帧/秒)的负载
1000 kbps约 7690 帧/秒13%
500 kbps约 3846 帧/秒26%
250 kbps约 1923 帧/秒52%
125 kbps约 961 帧/秒104%(跑不动)

表里只算了这一路发送,实际总线上还有其他节点。工程上习惯把总负载控制在 50% 以内,超过 70% 时优先级低的消息延迟会明显变大。

算完能跑之后,下一个问题是怎么在 LabVIEW 里做到 1ms。普通的Wait Until Next ms Multiple在 Windows 上精度就是 15.6ms,写 1 进去实际是 15 或 16。要用 Timed Loop 并把周期设成 1ms,同时在程序启动时提一次系统定时器精度。即便如此,1ms 也只是“平均 1ms”,

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 12:25:25

UE5黑洞吞噬效果实现与优化指南

1. 黑洞吞噬效果的核心设计思路 在UE5中实现黑洞吞噬效果&#xff0c;本质上是要模拟广义相对论中描述的时空扭曲现象。这个效果可以拆解为三个核心组成部分&#xff1a;引力场模拟、物质扭曲变形和光线弯曲表现。我通常会从引力场范围界定开始&#xff0c;这是整个效果的基础框…

作者头像 李华
网站建设 2026/10/26 12:25:18

PyTorch验证码识别实战:从数据构造到模型部署

数字加英文的图片验证码识别&#xff0c;是很多人踏入 PyTorch 视觉实战的第一块试金石。它看着简单&#xff1a;不就几十个字符的分类嘛。真上手才知道麻烦在于——你得先有一批带标签的图&#xff0c;然后得让模型在旋转、粘连、干扰线、背景噪点这堆东西里把 4 个字符一个不…

作者头像 李华
网站建设 2026/10/26 12:25:14

LocalTime获取原理与跨平台时间处理实践

1. 理解LocalTime与系统时间获取 在编程中处理时间是一个看似简单却暗藏玄机的任务。最近我在调试一个跨时区的订单系统时&#xff0c;深刻体会到正确获取本地时间的重要性。Fine语言中的 LocalTime time.GetLocalTime() 这行代码&#xff0c;实际上封装了操作系统底层的时间…

作者头像 李华
网站建设 2026/10/26 12:24:31

电赛E题运动目标控制与自动追踪:视觉、STM32控制与PCB供电实战

简介&#xff1a;这份2023年电赛E题“运动目标控制与自动追踪系统”配套代码包&#xff0c;面向参加电子设计竞赛、学习STM32控制与Jetson Nano视觉识别的学生和开发者&#xff0c;覆盖从底层外设驱动到OpenCV目标识别的完整实现。压缩包共550个文件、约21.07MB&#xff0c;以h…

作者头像 李华
网站建设 2026/10/26 12:24:30

OpenCV光斑识别与STM32云台PID:电赛E题追踪系统实战

简介&#xff1a;面向电赛参赛者与嵌入式视觉学习者&#xff0c;资料对应2023年电赛E题“运动目标控制与自动追踪系统”的工程实现&#xff0c;包含STM32控制端源码、Jetson Nano上的OpenCV识别源码及电路板PCB原理图设计。STM32部分按摇杆、二维云台舵机、蓝牙通信、蜂鸣器、L…

作者头像 李华
网站建设 2026/10/26 12:23:24

C++23 std::unreachable优化原理与工程实践

1. 理解std::unreachable的核心价值 在C开发中&#xff0c;我们经常会遇到一些理论上不可能执行到的代码路径。比如switch语句的default分支中已经包含了return&#xff0c;但编译器仍然会认为后续代码可能被执行。这种场景下&#xff0c;std::unreachable()就派上了用场——它…

作者头像 李华