news 2026/8/26 13:19:34

UDS 0x87 LinkControl服务详解:动态控制诊断通讯波特率与实战应用

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
UDS 0x87 LinkControl服务详解:动态控制诊断通讯波特率与实战应用

1. 从一次真实的通讯故障说起:为什么我们需要LinkControl?

前段时间,我在调试一个基于CAN总线的控制器时,遇到了一个让人头疼的问题。设备在实验室环境下一切正常,诊断会话切换、读写数据、刷写流程都跑得飞快。但一旦装到实车上,在发动机启动、大负载电器工作的瞬间,诊断仪就频繁报“通讯超时”。抓取总线报文一看,发现不是ECU没回复,而是ECU回复的报文被淹没在了密集的总线负载中,诊断仪没能在规定时间内收到完整的响应帧。

这个问题,本质上不是协议逻辑错误,而是物理层和数据链路层的通讯参数与当前恶劣的电磁环境、高总线负载不匹配。在UDS(统一诊断服务)协议栈中,负责管理这个“通讯管道”粗细和节奏的服务,就是0x87服务——LinkControl(链接控制)

简单来说,UDS协议就像两个人用对讲机通话。0x10、0x22这些服务定义了“说什么”(比如“请报告车速”、“清除故障码”),而0x87服务则是用来调整“对讲机本身”的。比如,在嘈杂的工地(高电磁干扰),我们可以把语速放慢、每个字说清楚(降低波特率、增加帧间隔);在安静的办公室,则可以快速交流(提高波特率)。0x87服务就是用来动态切换这些底层通讯参数的指令。

对于嵌入式软件工程师、汽车诊断测试工程师以及任何需要深度定制或优化UDS通讯的开发者而言,深入理解0x87服务,意味着你不仅能实现诊断功能,更能确保诊断通讯的鲁棒性和适应性。它是在复杂车载网络环境中,保障诊断这条“生命线”始终可用的关键工具。接下来,我将结合ISO14229-1标准,为你彻底拆解0x87服务的原理、使用方法和那些手册上不会写的实战经验。

2. 深入原理:0x87服务到底在控制什么“链接”?

在展开具体服务之前,我们必须先厘清一个核心概念:这里的“Link”指的是什么?很多人会误以为是TCP/IP中的网络链接,但在UDS的语境下,尤其是在经典的CAN、CAN FD、LIN乃至DoIP(基于IP)的底层,LinkControl控制的是数据链路层(Data Link Layer)的通讯属性

根据ISO 14229-1标准,0x87服务主要用于在诊断会话期间,请求服务器(ECU)切换其与客户端(诊断仪)之间用于诊断通讯的网络层或数据链路层参数。这通常不是指切换整个ECU的通信矩阵(那需要刷写),而是在运行时临时调整诊断通道的“行为模式”。

2.1 三种子功能(Sub-function)详解

0x87服务包含三个核心子功能,分别对应三种不同的链接控制模式:

### 2.1.1 子功能0x01: verifyBaudrateTransitionWithFixedBaudrate(验证固定波特率切换)

这个子功能的名字有点长,但其意图很明确:验证ECU是否支持切换到某个指定的、固定的波特率

  • 请求格式87 01 [Baudrate Identifier]
    • 87: 服务标识符(SID)。
    • 01: 子功能,表示“验证”。
    • Baudrate Identifier: 一个字节的波特率标识符。这是关键!这个标识符的具体数值(如0x01代表125kbps,0x02代表250kbps,0x03代表500kbps)完全由整车厂或ECU供应商自定义,并在网络设计文档(如CDD、ODX)中定义。UDS标准本身不规定这个映射关系。
  • ECU的响应
    • 肯定响应(Positive Response):C7 01 [Baudrate Identifier]。这仅表示:“我(ECU)支持你请求的这个波特率标识符所代表的波特率”。注意,这并不代表ECU已经切换了波特率!它只是一个能力查询。
    • 否定响应(Negative Response): 如果ECU不支持该标识符对应的波特率,会回复7F 87 33(requestOutOfRange)或其他相应NRC。
  • 使用场景:在尝试真正的波特率切换之前,先进行“探路”。比如,诊断仪不确定当前ECU是否支持500kbps的诊断波特率,可以先发87 01 03(假设03代表500kbps)来询问。得到肯定响应后,再使用87 02子功能进行实际切换。这是一种安全、规范的操作流程。

### 2.1.2 子功能0x02: verifyBaudrateTransitionWithSpecificBaudrate(验证特定波特率切换)

这个子功能比0x01更灵活。它不是查询预定义的波特率标识符,而是允许诊断仪直接提议一个具体的波特率数值(以bit/s为单位),让ECU验证是否支持

  • 请求格式87 02 [Baudrate Byte 1] [Baudrate Byte 2] [Baudrate Byte 3]
    • 87 02: 服务和子功能。
    • 后跟3个字节,共同表示一个24位(3字节)的无符号整数,单位是bit/s(比特每秒)。例如,要提议500000 bit/s (500kbps),这个数值的十六进制是0x07A120,那么三个字节就是07 A1 20。请求报文即为:87 02 07 A1 20
  • ECU的响应
    • 肯定响应:C7 02。表示ECU支持切换到此特定波特率。
    • 否定响应: 如果不支持,回复7F 87 33等。
  • 使用场景:当诊断仪需要与一个波特率信息未知的ECU建立通讯,或者需要尝试非标波特率时使用。它提供了更大的灵活性。但同样,这只是验证,不执行切换。

### 2.1.3 子功能0x03: transitionBaudrate(切换波特率)

这是执行实际操作的子功能。它命令ECU立即将其诊断通讯的波特率切换到之前通过87 0187 02验证过的波特率。

  • 请求格式87 03 [Baudrate Identifier]
    • 这里的Baudrate Identifier必须与之前成功验证的请求中的标识符一致。如果是通过87 02验证的,这个标识符通常就是0x00或一个约定的值,具体取决于实现。
  • ECU的响应
    • 关键点:ECU在成功处理该请求后,会先以当前的旧波特率发送肯定响应C7 03
    • 发送完这个响应后,ECU会立即将其诊断CAN控制器的波特率切换到新的设定值。
    • 此后,诊断仪必须将自己的CAN接口波特率也调整为相同的新值,才能继续进行后续的诊断通讯。
  • 使用场景:这是整个流程的最后一步。在验证通过后,执行实际切换以优化通讯(如从125kbps切换到500kbps以加速刷写),或适配特殊环境。

重要提示:波特率切换是一个高风险操作。一旦切换,如果诊断仪没有同步更改接口设置,将立即丢失与ECU的通讯连接,且ECU通常不会自动切回。因此,在执行87 03前,必须有完备的错误处理和超时重连机制。

2.2 不仅仅是波特率:扩展的链接控制

虽然标准主要描述了波特率控制,但“LinkControl”的概念可以扩展。在一些具体的实现或更底层的协议中(如针对CAN FD),它可能还包括对以下参数的控制:

  1. CAN FD 比特率切换:控制仲裁阶段(标准比特率)和数据阶段(更高数据比特率)的速率。
  2. 帧间隔时间:调整连续发送的CAN帧之间的最小间隔(例如,用于满足某些硬件收发器的要求或降低总线负载)。
  3. 唤醒模式:控制诊断通讯对ECU休眠/唤醒行为的影响。

这些扩展功能通常通过自定义子功能(0x80-0xFE)或利用87 02子功能传递更复杂的数据参数来实现。具体需要查阅对应ECU的诊断规范。

3. 实战演练:一个完整的波特率切换流程与CAPL脚本示例

理论说得再多,不如一行代码。下面,我将以最常见的场景——在CANoe/CANalyzer环境中,使用CAPL脚本实现“验证并切换至500kbps”——为例,展示完整的操作流程和注意事项。

我们假设:

  • 当前诊断波特率为125kbps(标识符0x01)。
  • 目标波特率为500kbps(标识符0x03)。
  • 诊断请求ID(物理寻址):0x7E0
  • 诊断响应ID:0x7E8

3.1 步骤分解与CAPL实现

步骤一:连接与初始会话建立首先,你需要确保诊断仪与ECU在默认波特率下已经建立了通讯,并且进入了非默认会话(例如扩展诊断会话0x10 03),因为0x87服务通常需要在非默认会话下才被启用。

// CAPL脚本示例 - 初始化与建立会话 variables { // 定义标识符 const long DEFAULT_BAUD_ID = 0x01; // 125kbps const long TARGET_BAUD_ID = 0x03; // 500kbps byte positiveResponse[4096]; dword responseLength; } on start { // 1. 确保CAN通道波特率已设置为当前波特率(125kbps) canSetBaudrate(1, 125); // 假设通道1,125代表125kbps // 2. 发送10 03进入扩展会话 byte request_10_03[] = {0x10, 0x03}; diagSendRequest(request_10_03); // 等待并检查肯定响应 50 03 // ... (省略响应检查代码) }

步骤二:验证目标波特率(使用子功能0x01)在会话建立成功后,先验证ECU是否支持我们想要切换的波特率标识符。

// 函数:验证波特率 int verifyBaudrate(byte baudId) { byte request[3]; request[0] = 0x87; // SID request[1] = 0x01; // Sub-function: verifyBaudrateTransitionWithFixedBaudrate request[2] = baudId; // Baudrate Identifier // 发送请求 diagSendRequest(request); // 设置超时,等待响应 setTimer(udsTimeout, 2000); // 2秒超时 // 在on diagResponse事件中处理响应 // 如果收到 C7 01 [baudId],返回1(成功) // 如果收到 7F 87 33,返回0(不支持) // 如果超时,返回-1(通讯失败) // ... (具体响应处理逻辑需在 on diagResponse 中实现) return waitForResponse(); // 假设此函数阻塞等待并返回结果 } on key 'v' { // 按键盘'v'触发验证 int result = verifyBaudrate(TARGET_BAUD_ID); if (result == 1) { write("验证成功:ECU支持切换到波特率标识符 0x%02X", TARGET_BAUD_ID); } else if (result == 0) { write("验证失败:ECU不支持该波特率标识符。"); } else { write("验证超时:通讯异常。"); } }

步骤三:执行波特率切换(使用子功能0x03)验证成功后,执行实际的切换操作。这是最关键的步骤,需要处理好切换前后的波特率同步。

// 函数:执行波特率切换 int transitionBaudrate(byte baudId) { byte request[3]; request[0] = 0x87; request[1] = 0x03; // Sub-function: transitionBaudrate request[2] = baudId; diagSendRequest(request); // !!!特别注意 !!! // ECU会以旧波特率回复 C7 03,然后立即切换。 // 因此,我们需要在发送请求后,立即准备切换诊断仪端的CAN接口波特率。 // 通常,在确认收到肯定响应后(或在一个极短的确定延时后)进行切换。 setTimer(switchTimer, 50); // 假设50ms后执行切换,确保已收到响应 return 1; } on timer switchTimer { // 停止当前测量 stopMeasurement(); // 切换CAN通道的波特率设置 // 注意:这里的数值需要根据 TARGET_BAUD_ID 的实际含义来设置 // 假设 0x03 对应 500kbps canSetBaudrate(1, 500); // 将通道1波特率改为500kbps write("诊断仪端波特率已切换至500kbps。"); // 重新启动测量 startMeasurement(); // 可选:发送一个简单的诊断请求(如0x3E 80 保持会话)来测试新波特率下的通讯 byte testerPresent[] = {0x3E, 0x80}; diagSendRequest(testerPresent); write("正在测试新波特率下的通讯..."); } on diagResponse 0x7E8 { // 处理0x87服务的响应 if (this.byte(0) == 0xC7 && this.byte(1) == 0x03) { write("收到波特率切换肯定响应。ECU即将切换波特率。"); // 可以在这里设置一个标志,通知switchTimer可以安全执行 } // ... 其他响应处理 }

步骤四:异常处理与回退机制必须考虑切换失败的情况。例如,发送87 03后没有收到响应(可能ECU切换异常,或诊断仪切换时机不对)。

// 在全局变量中定义一个重试机制和回退波特率 variables { int baudSwitchRetryCount = 0; const int MAX_RETRY = 3; } // 修改 transitionBaudrate 函数或在其调用处增加重试逻辑 on key 't' { // 按键盘't'触发切换 while (baudSwitchRetryCount < MAX_RETRY) { if (executeTransition() == SUCCESS) { // 假设的封装函数 baudSwitchRetryCount = 0; break; } else { baudSwitchRetryCount++; write("切换失败,第%d次重试。", baudSwitchRetryCount); // 重要:在重试前,先将诊断仪波特率切回已知可用的波特率(如初始的125kbps) canSetBaudrate(1, 125); testConnection(); // 测试连接是否恢复 delay(1000); } } if (baudSwitchRetryCount >= MAX_RETRY) { write("波特率切换彻底失败,请检查硬件连接或ECU配置。"); // 尝试最终回退到默认波特率 canSetBaudrate(1, 125); } }

3.2 使用CANdelaStudio (CDD) 或 ODX 文件

在实际工程中,波特率标识符(Baudrate Identifier)的映射关系、0x87服务是否支持、在哪些会话下支持等信息,都定义在ECU的诊断数据库文件(CDD或ODX)中。在CANoe中,你可以通过Diagnostics/ISO TP配置窗口导入这些文件,CAPL脚本可以通过Diag对象以更安全、便捷的方式调用服务,而无需硬编码SID和子功能。

// 使用Diag对象调用(更推荐) on key 'c' { DiagRequest drvReq; // 诊断请求对象 DiagResponse drvResp; // 诊断响应对象 // 通过服务名称获取请求对象(需CDD/ODX支持) drvReq = DiagGetPrimitiveDataByService("LinkControl"); // 设置子功能和参数 drvReq.SetSubFunction(0x01); // verify drvReq.SetParameter("BaudrateIdentifier", TARGET_BAUD_ID); // 发送并等待响应 diagSendRequest(drvReq); }

这种方式避免了记忆具体的SID,并且参数名更直观,依赖于数据库的完整性。

4. 高阶应用、常见陷阱与调试技巧

掌握了基础流程,我们来看看那些容易踩坑的地方和一些高级用法。

4.1 为什么我的0x87服务请求总是被否定(NRC 0x22)?

这是最常见的问题之一。NRC 0x22代表“conditionsNotCorrect”。请按以下顺序排查:

  1. 会话状态:确认ECU是否处于支持0x87服务的诊断会话中(通常是编程会话(0x10 02)扩展诊断会话(0x10 03))。在默认会话(0x10 01)下,绝大多数安全相关和配置服务都是被禁止的。解决方案:先发送10 0210 03进入相应会话。
  2. 安全状态:0x87服务可能被定义为“安全相关服务”。这意味着在执行前,必须通过0x27服务(SecurityAccess)完成安全解锁,获得足够的权限等级。解决方案:检查诊断规范,确认是否需要先进行0x27服务种子密钥交换。
  3. 依赖条件:有些ECU要求在执行波特率切换前,必须满足特定条件,如“车辆速度为零”、“点火开关ON但发动机OFF”等。这些条件不满足也会返回0x22。解决方案:仔细阅读ECU的详细诊断需求规范。

4.2 切换波特率后“失联”了怎么办?

这是最令人紧张的状况。发送87 03后,诊断仪再也收不到任何ECU的报文。

  1. 原因:诊断仪没有在ECU切换波特率后,同步更改自身CAN接口的波特率设置。两者波特率不匹配,自然无法通讯。
  2. 应急恢复
    • 手动重设:在CANoe/CANalyzer的硬件配置界面,手动将对应CAN通道的波特率改回原来的值(或尝试其他可能的值)。
    • 脚本自动回退:如3.1节所述,在脚本中实现超时检测。如果发送87 03后,在预定时间内(如100ms)没有收到任何来自ECU的报文(不限于诊断响应,任何ECU发送的CAN帧),则自动将诊断仪波特率切回上一个已知有效的值,并尝试发送10 01回到默认会话。
    • 硬件复位:最彻底的方法,给ECU重新上电。ECU在冷启动后,通常会加载默认的通信配置(包括默认的诊断波特率)。

4.3 0x87服务在CAN FD和DoIP中的应用

  • 在CAN FD中:CAN FD允许在数据阶段使用更高的比特率。0x87服务可以用来动态控制这个数据阶段比特率。例如,在刷写大量数据时,切换到更高的数据比特率(如2Mbps或5Mbps)以显著提升传输效率。此时,请求参数可能需要包含两个波特率值:仲裁段波特率和数据段波特率。
  • 在DoIP (Diagnostic over IP) 中:虽然DoIP底层是TCP/IP,但“链接控制”的概念依然存在。这里的0x87服务可能被用来控制DoIP实体(车辆网关)与测试设备之间的TCP数据吞吐率激活/停用DoIP路由控制车辆发现过程。其参数和语义与CAN环境完全不同,需要参考ISO 13400(DoIP)和具体的实现规范。

4.4 调试技巧:使用CANoe的Trace和Graphics窗口

  1. Trace窗口:过滤显示0x7E00x7E8的报文。清晰看到87 01请求和C7 01响应,以及87 03请求和C7 03响应。确认时序和内容是否正确。
  2. Graphics窗口:创建一个信号,用来显示“当前诊断波特率”。在CAPL脚本中,每次成功切换波特率后,更新这个信号的值。这样可以在图形上直观地看到波特率切换的发生时刻,便于与总线负载率、错误帧等信号进行关联分析。
  3. 总线负载率观察:在执行切换前后,观察总线负载率的变化。从低波特率切换到高波特率,在发送相同数量报文的情况下,负载率会降低(因为每比特时间变短,单位时间能发送更多比特)。这可以间接验证切换是否真正生效。

5. 与其他服务的协同:构建健壮的诊断序列

0x87服务很少孤立使用,它总是嵌入在一个更复杂的诊断操作序列中,尤其是ECU软件刷写(Programming)流程。理解它在这个序列中的位置至关重要。

一个简化的、包含波特率切换的刷写前置流程可能如下:

  1. 进入扩展会话10 03
  2. 安全访问(解锁)27 01-> 获取种子(Seed) -> 计算密钥(Key) ->27 02 [Key]
    • 这里可能就涉及到uds capl 调用dll uds算seedkey这个热搜词中的场景,即用CAPL调用外部DLL来计算密钥。
  3. 链接控制(验证波特率)87 01 [HighSpeedBaudID]-> 响应C7 01
  4. 链接控制(切换波特率)87 03 [HighSpeedBaudID]-> 响应C7 03
    • 诊断仪在收到C7 03后,立即更改CAN接口波特率设置。
  5. 测试新链接:发送3E 80(TesterPresent)或22 [DID](读取数据),确认在新波特率下通讯正常。
  6. 进入编程会话10 02(注意:有些ECU要求在编程会话下才能进行后续的343637服务)。
  7. 关闭DTC设置85 02(控制DTC设置)
  8. 通信控制28 03 [控制类型](可能禁止非诊断报文,降低总线负载,为高速刷写让出带宽)。
  9. 开始刷写流程34请求下载,36传输数据,37请求退出传输...

在这个序列中,0x87服务(步骤3&4)是提升后续34/36/37服务数据传输效率的关键准备步骤。如果没有切换到更高波特率,刷写一个几兆字节的软件包将耗费难以忍受的时间。

6. 测试考量:如何全面测试0x87服务?

作为测试工程师,针对0x87服务,不能只测“正常流程通过”。以下是一些关键的测试点,呼应了“最全面的uds测试用例”这个需求:

  1. 正常功能测试

    • 在支持的会话(如扩展会话、编程会话)下,验证87 0187 03序列成功执行,且切换后通讯正常。
    • 验证87 02子功能(如果支持)可以指定任意波特率并成功切换。
  2. 无效参数测试

    • 发送87 0187 03时,使用未定义的Baudrate Identifier(如0xFF),应返回NRC0x31(requestOutOfRange)。
    • 发送87 02时,使用ECU不支持的波特率值(如一个极低或极高的值),应返回NRC0x33(securityAccessDenied? 这里更可能是0x31或自定义NRC,具体看规范)。
  3. 条件不满足测试

    • 默认会话下发送0x87服务请求,应返回NRC0x7E(serviceNotSupportedInActiveSession)或0x22
    • 未通过安全访问时发送请求(如果该服务受安全保护),应返回NRC0x33
    • 车辆行驶状态(模拟车速信号>0)下发送请求,应返回NRC0x22
  4. 序列错误测试

    • 不经过87 01验证,直接发送87 03请求切换,ECU应如何处理?(可能返回NRC0x24-requestSequenceError,或直接拒绝)。
    • 连续两次发送87 03请求切换波特率,第二次请求应如何处理?
  5. 异常与恢复测试

    • 中断测试:在ECU回复C7 03后,诊断仪延迟切换自身波特率(如延迟1秒)。验证ECU在此期间是否还能处理以旧波特率发送的请求?通常不能,因为ECU已经切换。
    • 恢复测试:执行87 03切换后,强制让诊断仪与ECU“失联”。然后给ECU重新上电,验证其诊断波特率是否恢复为默认值,并能用默认波特率重新建立连接。
    • 压力测试:在极高总线负载(>80%)的情况下,执行波特率切换操作,观察是否会出现错误帧或切换失败。
  6. 边界与性能测试

    • 测试支持的最小和最大波特率。
    • 测量切换波特率操作本身所耗费的时间(从发送87 03到在新波特率下成功完成一次诊断通信的时间)。这个时间对于刷写流程的整体耗时评估很重要。

通过以上这些测试,你才能称得上对0x87服务进行了“全面”的覆盖,确保该功能在车辆各种复杂环境下都能稳定可靠地工作。理解并掌握LinkControl服务,是你从“会用UDS”到“精通UDS”道路上必不可少的一步。

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

RAG高级检索实战:从基础召回升级到精准问答

做 RAG 项目最难受的是什么&#xff1f;不是模型不够聪明&#xff0c;不是知识库没搭起来&#xff0c;而是 检索出来的东西根本不对 。用户问的问题明明很简单&#xff0c;召回的内容却南辕北辙&#xff0c;最后大模型一本正经地编了个答案&#xff0c;你还要在脑子里替他解释…

作者头像 李华
网站建设 2026/8/26 13:14:49

嵌入式按键消抖全攻略:硬件RC滤波与软件状态机

先说清楚一件事&#xff1a;这篇文章里说的 Switch&#xff0c;既不是游戏主机&#xff0c;也不是编程语言里的 switch 分支语句&#xff0c;而是我们电路板上那个一按就"咔哒"响的物理按键开关。凡是玩过单片机、Arduino、STM32&#xff0c;或者画过控制板的朋友&am…

作者头像 李华
网站建设 2026/8/26 13:09:08

直拍视频本地处理工具链:人声分离、字幕生成与批量转码实践

这次我们来看一个偏“演出素材本地处理”的实操场景&#xff1a;拿到一段现场舞台直拍视频后&#xff0c;怎么用开源工具链把画质、音轨、字幕、批量归档一次跑通。很多朋友收藏了一堆直拍素材&#xff0c;但真正要剪辑、补字幕、做音画同步时&#xff0c;却发现要么软件太笨重…

作者头像 李华
网站建设 2026/8/26 13:03:47

CUSUM与K-Means协同的工业时序异常检测与模式识别

1. 项目概述&#xff1a;这是一道典型的“数据驱动型建模题”&#xff0c;不是纯数学推导&#xff0c;也不是纯编程炫技 2024年华中杯B题&#xff0c;表面看是数学建模竞赛的一道赛题&#xff0c;但实际操作中&#xff0c;它更像一个浓缩版的工业级数据分析实战项目——你面对的…

作者头像 李华
网站建设 2026/8/26 13:03:31

模拟ASIC设计指南:从规格定义、版图布局到仿真验证的完整链路

先纠正一个长期存在的误解&#xff1a;模拟ASIC到底“ASIC”在哪里 很多芯片工程师一听到ASIC三个字母&#xff0c;脑子里蹦出来的画面就是数字后端、综合工具、标准单元库、自动布局布线&#xff0c;似乎ASIC天然就等于数字芯片。这种印象不能说错&#xff0c;但至少过时了十…

作者头像 李华
网站建设 2026/8/26 13:00:32

QWM训练协议:冻结世界模型只训练策略网络的PyTorch实践

QWM 是斯坦福和北大相关研究中出现的一个缩写&#xff0c;核心动作是让世界模型在训练阶段不再参与参数更新&#xff0c;只作为只读组件为决策模块提供状态预测。这个方向之所以值得关注&#xff0c;是因为它把“训练一个世界模型”和“使用一个世界模型”彻底拆开&#xff0c;…

作者头像 李华