简介:机器人协同控制是工业自动化中的重要方向,双臂系统相比单臂在搬运、装配等场景下具有显著效率优势。在ROS框架中,多机械臂协同面临模型配置、运动规划与碰撞避免等核心挑战。通过URDF/Xacro建立统一的双臂模型,借助MoveIt进行多规划组管理,并使用Gazebo完成仿真验证,能够有效解决双臂避碰的任务需求。该类技术可广泛应用于分拣、打螺丝、长物体协同搬运等场景。本文基于UR10实例,详细介绍了从仿真搭建、运动规划到真实机器人接入的完整实现过程,为双臂协同控制的工程落地提供参考。 做双机械臂系统这件事,最开始是因为一个分拣场景的需求:单臂的效率不够,双臂又能协同配合。真正动手才发现,把两台UR10放到同一个ROS控制框架里,难度并不是简单乘以2,而是在仿真、规划、通信、安全上的全面升级。这个项目最终交付了一套完整方案:在Gazebo里可以跑的双UR10仿真,以及能连接两台真实UR10并完成双臂协同控制的代码和文档。如果你也准备做类似的双臂项目,这篇文章可以省下你至少两周的踩坑时间。
我这里的核心做法是:用ROS Noetic作为主控系统,URDF/Xacro建模双臂,Gazebo做仿真验证,MoveIt做运动规划,真实UR10通过ur_robot_driver接入。整体思路是先仿真后真机,同一套任务脚本可以在两种模式下切换。这篇文章会把架构、仿真搭建、双臂避碰、真机接入、代码组织、典型坑位全部拆开讲清楚。
1. 双机械臂系统的整体架构与选型逻辑
1.1 为什么是双机械臂,而不是两台独立单臂
很多人一开始会觉得,双机械臂不就是两台单臂放一起吗?各跑各的不就行了。真做起来就发现不是那么回事。两台独立单臂最大的问题是:彼此不知道对方在干什么。规划时没有把对方当作障碍物,分拣任务中很容易撞到一起。更重要的是,如果任务本身需要双手配合,比如搬运长物件、左右同时拧螺丝,那单靠独立指令根本同步不起来。
所以我从需求层面就先明确了:这套系统需要统一的状态树、统一的TF坐标关系、统一的任务调度入口。在这个基础上,双机械臂的控制可以解释为“两个规划组 + 一个共享规划场景 + 一个协调任务节点”。这样左臂和右臂都有独立的运动规划能力,但它们的碰撞空间会实时同步给对方。这是双臂系统和“两台单臂拼在一起”的本质区别。
1.2 系统组成和版本选型
我的主力环境是Ubuntu 20.04 + ROS Noetic。为什么没有一开始就上ROS2?因为UR10的成熟驱动、MoveIt与Gazebo的联调资料,在Noetic上最全。如果你准备用ROS2,也可以迁移,但UR10生态里Noetic依然是相对稳的选择。如果只是为了仿真学习,Ubuntu 22.04 + ROS2 Humble + MoveIt2也能走通,但下文以Noetic为例。系统组成包括:
- 机器人本体:两台UR10,工作半径约1.3米,有效载荷10公斤。
- 仿真环境:Gazebo 11,加载双UR10的URDF模型。
- 运动规划:MoveIt + OMPL,两个规划组分别管理左臂和右臂。
- 底层控制:仿真里用ros_control + gazebo_ros_control插件,真实机器上用ur_robot_driver提供的手腕驱动接口。
- 视觉/外围:工作台上的模拟目标物、可选的RGB-D相机、夹爪模型。
这套组合在实际测试中非常顺,因为每一步都有现成的ROS包可以拼。UR10相关的ur_description和ur_robot_driver是官方维护,Gazebo的ros_control生态也稳定。我不建议从零手写底层关节控制,浪费时间且容易出安全问题。
1.3 信息流和节点划分
从顶层看,系统的数据流是这样的:双臂协调任务节点负责拆解任务,把左臂目标位姿和右臂目标位姿分别发给两个MoveIt动作客户端;每个MoveIt规划组在收到目标后,从planning scene里读取当前环境,并把对侧臂的关节状态当成动态障碍物;规划完成的轨迹通过FollowJointTrajectory动作接口发给Gazebo控制器或真实UR10控制器。
关键的话题和服务大致如下:
| 名称 | 类型 | 作用 |
|---|---|---|
| /arm_left/joint_states | sensor_msgs/JointState | 左臂关节状态反馈 |
| /arm_right/joint_states | sensor_msgs/JointState | 右臂关节状态反馈 |
| /arm_left/move_group | moveit_msgs/MoveGroupAction | 左臂规划请求入口 |
| /arm_right/move_group | moveit_msgs/MoveGroupAction | 右臂规划请求入口 |
| /arm_left/follow_joint_trajectory | control_msgs/FollowJointTrajectoryAction | 左臂轨迹执行 |
| /arm_right/follow_joint_trajectory | control_msgs/FollowJointTrajectoryAction | 右臂轨迹执行 |
| /planning_scene | moveit_msgs/PlanningScene | 共享规划场景同步 |
这样的设计让双臂之间解耦,又通过planning_scene共享环境,能有效避免碰撞。任务调度节点反而很简单:只负责规划目标和等待执行结果。
2. Gazebo仿真环境的搭建与验证
2.1 双臂URDF模型的组织方式
在Gazebo里让两台UR10同时出现的第一个坑就是:URDF里的link和joint名字不能重复。如果你直接把官方ur10.urdf.xacro复制两份,就会出现两个base_link,导致TF树直接冲突。我采用的做法是把单臂模型写成一个xacro宏,调用两次,再用不同的名前缀包裹。例如,左臂的所有link叫left_upper_arm_link,右臂叫right_upper_arm_link,joint同理。
下面的xacro片段思路很典型:
<xacro:macro name="dual_ur10"> <xacro:include filename="$(find ur_description)/urdf/ur10_macro.xacro" /> <xacro:ur10_macro prefix="left_" joint_limited="true"/> <xacro:ur10_macro prefix="right_" joint_limited="true"/> </xacro:macro>这里面最关键的是prefix参数。ur_description自带的宏本身支持加前缀,相当于把整棵TF子树的命名空间都隔开了。不过需要注意,某些link名称如果不通过参数传入,可能会漏掉前缀。初始化完成之后,我会用rosrun tf2_tools view_frames.py生成TF树检查一遍,确保left_base_link和right_base_link都挂在同一个world下。
我把左右臂安装在同一个水平底座上,两臂底座间距大约1.5米。这样既留出了重合工作区,又不会让机械臂在待机位就互相干涉。底部框架用一个固定的world_link表示,底座高度0.8米,方便放置台架。
2.2 ros_control控制器配置
要让Gazebo能真正执行MoveIt规划的轨迹,URDF里必须有transmission标签,并且加载gazebo_ros_control插件。仿真控制我采用的是JointTrajectoryController,因为MoveIt的FollowJointTrajectory动作直接能对接。每个关节需要设置合适的PID,一开始我直接用默认参数,结果Gazebo里关节抖得厉害,后面把P降到80,D提高到1.5左右才稳定下来。
一个典型的controllers.yaml长这样:
arm_left_joint_trajectory_controller: type: position_controllers/JointTrajectoryController joints: - left_shoulder_pan_joint - left_shoulder_lift_joint - left_elbow_joint - left_wrist_1_joint - left_wrist_2_joint - left_wrist_3_joint gains: left_shoulder_pan_joint: {p: 80.0, i: 0.5, d: 1.5} left_shoulder_lift_joint: {p: 80.0, i: 0.5, d: 1.5} left_elbow_joint: {p: 60.0, i: 0.5, d: 1.2} left_wrist_1_joint: {p: 30.0, i: 0.2, d: 0.8} left_wrist_2_joint: {p: 30.0, i: 0.2, d: 0.8} left_wrist_3_joint: {p: 20.0, i: 0.2, d: 0.5}右臂类似。注意控制器名称里的arm_left前缀要和MoveIt配置里对应的action命名空间一致,否则MoveIt找不到执行器。
2.3 场景建模和初始位姿
仿真场景里我在两台机械臂之间放了一个金属台架,台架高度大概0.9米,台面上放几个模拟工件。这样稍后测试规划避碰时,机械臂不会单纯在自由空间瞎动,而是真的有环境约束。
初始位姿设置也很讲究。左臂和右臂的home点我都设置在靠近底座两侧、略微抬高的位置,六轴角度大约接近“待机折叠”状态。目的是在Gazebo刚启动时就让双臂远离相互碰撞区,同时避免重力作用下关节突然下沉导致仿真崩坏。设置初始位姿可以直接在spawn模型时通过-J参数传,也可以发布一份joint_states让robot_state_publisher刷新。
2.4 跑通第一个双臂仿真测试
仿真启动的顺序是:先启动Gazebo世界,再启动robot_state_publisher和ros_control控制器,最后启动MoveIt。一个常用的launch组合类似这样:
roslaunch dual_ur10_gazebo gazebo_world.launch roslaunch dual_ur10_moveit_config moveit_planning_execution.launch sim:=true等Gazebo稳定后,先在RViz里给左臂定一个工作台上方的目标位姿,规划并执行。如果左臂能平滑到达,再给右臂定一个对称位姿,双臂同时执行。我第一次跑通时左右臂竟然同时把末端伸到同一个点附近,虽然没有物理碰撞,但那一下冷汗就出来了——所以从仿真阶段开始就要把避碰机制加进去,而不是等到真机再考虑。
3. 双机械臂运动规划:避碰与协同
3.1 两个MoveIt规划组在同一套环境中共存
我的做法是启动两个MoveIt节点,一个管左臂,一个管右臂。它们加载的URDF是同一份完整的双机械臂模型,各自的planning_group分别设置为left_arm和right_arm。这样每个MoveIt都拥有全部机器人的状态,但只规划自己负责的那组关节。
这里有个容易踩坑的地方:如果你在MoveIt Setup Assistant里用单臂模型生成配置,拿到双臂环境中直接用,MoveIt会因为找不到部分关节名字而报错。正确做法是开启双臂模型,在生成的config/srdf中保留两个group,并设置默认规划参数。MoveIt允许同一个机器人模型里有多个group,这就是双系统并存的基础。
两个move_group的命名空间可以通过ns参数隔离,例如先启动:
roslaunch dual_ur10_moveit_config moveit_group.launch ns:=arm_left group:=left_arm roslaunch dual_ur10_moveit_config moveit_group.launch ns:=arm_right group:=right_arm这样在topic层面,左、右臂的MoveIt接口就完全分开了,任务节点可以分别调用。
3.2 把对侧机械臂变成动态障碍物
双臂规划最核心的问题:左臂规划时,必须知道右臂此刻在哪里。最简单的办法是在规划前,把对侧臂的每个link当成一个CollisionObject加到当前MoveIt的planning scene里。
我在协调任务节点里维护一个定时器,每200毫秒读取对侧臂的/joint_states,然后根据URDF里的link尺寸,将这些link的位置转化为一组shape_msgs/SolidPrimitive和geometry_msgs/Pose,通过PlanningSceneInterface发布到当前规划场景中。这样规划时,MoveIt就会认为另一条臂是环境中不能碰的障碍物。
这种方法实时性好,代码也容易理解。代价是每次更新都要重新发布collision object,如果频率太高,会挤占规划时间。实测200毫秒刷新足够,因为UR10的正常运动速度不会在两三百毫秒内产生致命位移变化。如果要求更高,可以在一段轨迹执行前先推演对侧臂未来的轨迹片段,再生成动态障碍物,但工程量大很多,我暂时没有追求。
还有一种方法是使用MoveIt的AllowedCollisionMatrix,直接把对侧臂的link加入“不可接触”列表。这个办法更快,但对复杂环境模型不够细致,我建议还是用CollisionObject更直观。
3.3 双臂协调任务的具体实现
我这里举一个非常常见的任务:双臂同时抓取一根长杆。你可以把任务拆成三个动作:左臂运动到长杆左边抓取点;右臂运动到长杆右边抓取点;两个机械臂同时抬起长杆到某个高度。每一步都依赖上一步完成,所以要有一个顶层调度状态机。
Python伪代码如下:
import rospy from moveit_commander import MoveGroupCommander, PlanningSceneInterface rospy.init_node("dual_arm_task_node") left_group = MoveGroupCommander("left_arm") right_group = MoveGroupCommander("right_arm") scene = PlanningSceneInterface() left_group.set_planning_time(5.0) right_group.set_planning_time(5.0) def move_both(left_pose, right_pose): left_group.set_pose_target(left_pose) right_group.set_pose_target(right_pose) left_plan = left_group.plan() right_plan = right_group.plan() if left_plan and right_plan: left_group.execute(left_plan, wait=False) right_group.execute(right_plan, wait=True) else: rospy.logwarn("plan failed, try again")注意execute方法里的wait=True/False的组合。如果两个都wait=True,第一个会阻塞,导致第二只臂迟迟不启动。这里我先让左臂开始执行,再让右臂执行,并用wait=True等待右臂完成。实际项目中还需要等待两个动作都结束后再进入下一阶段,可以用一个Future或者线程去轮询MoveIt的状态。
如果单纯是同步运动,上面的方式足够了。但如果两个目标的完成时间差距太大,可能出现一臂先到位等待另一臂的情况。这时我会把两段轨迹的时间手动对齐:在后处理时,把较长轨迹的时间作为总时间,对短轨迹做时间缩放,保证双臂同时启动、同时到位。代价是一条臂的速度被压低,好处是整体姿态更安全。
3.4 规划参数调整和实时性优化
MoveIt默认的规划时间只有1秒,规划尝试次数也很少。在双臂场景中,由于对侧臂不断变成障碍物,规划失败的可能性比单臂高很多。我把planning_time调到5秒,num_planning_attempts调到10。这对仿真和真机都适用,但如果用于实时产线,5秒规划时间太长,需要换更快的规划器或预生成轨迹库。
另外,可以把max_velocity_scaling_factor和max_acceleration_scaling_factor分开设置。仿真时可以设0.8,速度拉满看效果;真机第一次跑我建议设0.1,尤其双臂同时动的时候,低速度给紧急停止留足反应时间。
还有一个小技巧:给规划场景里的障碍物加上padding。MoveIt支持为collision object设置padding参数,相当于给物体膨胀一圈。两只臂之间的距离本来就近时,稍微膨胀一点,可以把规划结果往安全方向推。我通常会设置0.02米到0.05米的padding。
4. 从仿真切换到真实UR10
4.1 硬件连接和驱动安装
真实UR10和仿真的差距主要体现在底层通信。UR10控制器支持通过以太网口与外部PC通信。我把两台UR10分别接到工控机的两个独立网口,设置静态IP,避免共用一个网口导致带宽争抢。
驱动我用的是ur_robot_driver,相比老旧的ur_modern_driver,它对Polyscope新版系统支持更好,还能通过dashboard接口控制机器人的上电和加载程序。为了配合驱动,必须在UR10的示教器上安装externalcontrol.urcap,并在程序里加入External Control节点,这样机器人才能接受来自ROS侧的速度/位置指令。
准备好之后启动:
roslaunch ur_robot_driver ur10_bringup.launch robot_ip:=192.168.1.50这时可以在另一个终端里检测能否收到关节状态:
rostopic echo /joint_states如果没有任何数据,先检查机器人的External Control节点是否正在运行,以及网络是否能ping通。
4.2 MoveIt从仿真切到真实的配置改动
MoveIt本身不关心底层是仿真还是真实,它只通过FollowJointTrajectory动作发送轨迹。区别在于:执行这条动作的服务端是谁。仿真时是ros_control的joint_trajectory_controller;真实时是ur_robot_driver提供的scaled_pos_joint_traj_controller。
我在整个项目里用一个顶层launch参数做切换:
<arg name="sim" default="true"/> <arg name="robot_ip_left" default="192.168.1.50"/> ... <group unless="$(arg sim)"> <node name="arm_left_driver" pkg="ur_robot_driver" type="ur10_bringup.launch"/> </group> <group if="$(arg sim)"> <node name="arm_left_gazebo" pkg="dual_ur10_gazebo" .../> </group>在MoveIt配置里,需要把controllers.yaml也根据sim参数加载不同的版本。仿真版本对应控制器名字arm_left_joint_trajectory_controller,真实版本对应scaled_pos_joint_traj_controller。否则MoveIt执行轨迹时会一直报“controller not found”。
还有一处必须要改:use_sim_time。仿真中要设为true,让所有ROS节点使用Gazebo的仿真时钟;真机时要设为false,使用系统时钟。这个参数如果忘记切换,最典型的症状是MoveIt规划正常,但执行时卡住不动,或者机器人收到一串零速度指令。
4.3 真机调试的安全流程
真机和仿真最大的不同就是一旦出错,代价很高。我的经验是分四步走:
首先,在示教器上把工具速度限制设到最低,例如10%。然后,把ROS侧的max_velocity_scaling_factor设为0.05到0.1,先做单臂小范围往复运动,确认关节方向和驱动反馈一致。
第二步,用MoveIt在RViz里规划一条非常简单的轨迹,比如从home点到旁边10厘米的位置。发送执行,观察真实机械臂的移动方向是否与RViz中的预演完全一致。UR10的关节方向和URDF模型默认是匹配的,但如果你改过零点或者安装方向,这一步最容易发现偏差。
第三步,再测试双臂低速联动。先让左臂从home点运动到目标位,右臂原地不动,验证对侧臂作为障碍物时不会产生误报。然后让右臂运动,左臂不动。最后才让双臂同时运动。
第四步,准备紧急停止。UR10示教器上的红色急停按钮是最后防线,同时我还在工控机上监听机器人的safety_mode话题,如果进入保护性停止,立刻发送停止轨迹指令。
4.4 网络延迟和实时性注意事项
真实UR10的控制方式并不像仿真那样每个关节直接接受位置指令,而是通过UR控制器内部进行平滑和速度规划。因此ROS侧发过来的轨迹到了机器人端肯定会有一小段缓冲。网络延迟主要体现在几个地方:指令传输延迟、状态反馈延迟、dashboard命令延迟。
如果发现真实机器人执行轨迹时“一顿一顿”,优先排查网络。我用有线直连时,ping延时通常小于1毫秒,非常稳定。如果用无线网络,经常出现几十毫秒甚至几百毫秒的抖动,这时候机器人很容易进入保护停。如果是路由器转发,也建议关闭其他带宽占用较大的服务。
在代码层面,我给FollowJointTrajectory客户端设置了执行超时检查。如果5秒内没有收到执行结果反馈,就自动取消轨迹并报警。这个保护在实际产线上非常重要,因为MoveIt的plan()只能保证规划成功,不能保证底层真的执行完毕。
5. 源码组织和文档应该怎么写
5.1 包结构划分
一个好的双臂项目源码,不应该把所有launch和脚本塞到一个包里。我最终交付的源代码按功能拆成了五个包:
dual_ur10_description:双UR10的URDF/xacro、左右臂模型、底座和夹爪模型。dual_ur10_gazebo:Gazebo世界文件、控制器配置、PID参数、仿真launch。dual_ur10_moveit_config:MoveIt配置、SRDF、OMPL规划配置、controllers.yaml。dual_ur10_bringup:顶层入口launch,支持sim/real切换。dual_ur10_tasks:双臂任务调度节点、脚本、状态机、服务定义。
这样划分的好处是:改仿真设置不会影响真机配置;改运动规划参数不影响任务逻辑。项目维护起来非常清晰,多人协作也不会互相覆盖文件。
5.2 顶层启动流程和参数传递
我写了一个总入口launch,用sim参数区分模式:
roslaunch dual_ur10_bringup main.launch sim:=true roslaunch dual_ur10_bringup main.launch sim:=false内部启动顺序大致是:先启动机械臂驱动(Gazebo spawn或ur_robot_driver),再启动robot_state_publisher,然后启动MoveIt,最后启动双臂任务节点。顺序不能乱,尤其是MoveIt启动时如果读不到robot_description,会直接退出。我给每个节点加respawn="true",这样某个节点崩溃后能快速重启。
在文档里一定要写清楚如何检查每一层是否启动成功。例如:
- 看
/joint_states是否有数据。 - 看
/tf里是否存在左右臂各自的TF树。 - 看MoveIt的RViz插件是否正常加载机器人模型。
- 用
rosnode list确认move_group节点都在运行。
5.3 文档说明的要点
文档并不是简单贴几个命令,而是要让人拿到源码后能顺利跑起来。我的项目文档包含以下几块:
- 环境依赖清单:Ubuntu 20.04、ROS Noetic、Gazebo 11、MoveIt、ur_robot_driver、moveit_commander、catkin。
- 编译步骤:
catkin_make或catkin build,以及哪些包需要额外安装。 - 快速启动:先仿真后真机的两条完整命令。
- 双机械臂任务说明:每个任务节点的功能、依赖的topic和service、可调参数。
- 常见问题表:例如Gazebo启动后机械臂下坠、MoveIt执行时找不到控制器、真机连接失败等,都列出排查思路。
文档中的常见问题表我建议采用如下结构:
| 症状 | 可能原因 | 解决方法 |
|---|---|---|
| Gazebo中机械臂剧烈抖动 | PID参数不合适 | 降低P,提高D |
| MoveIt执行轨迹无反应 | 控制器名字不匹配 | 检查controllers.yaml |
| 真机连接失败 | 机器人程序未启动External Control | 检查URCap和程序节点 |
| 双臂规划时碰撞检测失效 | 对侧臂未加入planning scene | 检查协调节点的joint_states订阅 |
6. 实测中避不开的坑
6.1 TF树和命名空间的坑
这是第一次在RViz里看到两台机械臂时最容易出问题的地方。症状是两台臂重叠在一起,或者关节位置乱跳。原因基本是URDF中左右臂没有统一加前缀。如果左臂的某个link仍叫base_link,而右臂也叫base_link,TF树里会出现两个名字相同的link,RViz只能随机显示一个,看起来就像模型“穿越”了。
解决方法是加前缀后,用check_urdf或直接打开生成的URDF文件,搜索每一个<link name=和<joint name=,确保左臂相关名字都带left_,右臂带right_。一旦有漏网之鱼,立刻修。不要指望只靠robot_state_publisher来区分。
6.2 仿真的抖动和过冲
Gazebo里的机械臂抖动,大多数不是模型问题,而是控制器参数问题。UR10关节的力矩相对较大,如果PID的P值过高,系统容易震荡;如果D值太小,又会出现到位后反复摇摆。我从单臂调起,先把所有关节P设为20,D设为0.2,确定系统稳定后再逐渐增大P,直到轨迹跟踪精度满足需求。最后测试下来,腕部关节因为惯性小,P和D可以比大臂关节更低。
另一个细节是Gazebo的仿真步长。默认步长1000Hz对多关节双臂来说计算压力很大,如果电脑性能不够,可以把max_step_size设为0.002秒,但步长太大反而更易造成抖动。我的建议是先保持0.001秒,调好控制器后再考虑提高性能。
6.3 真机与仿真不一致的问题
有一类问题在仿真里几乎发现不了:UR10的关节零点偏移和方向定义。虽然官方URDF和真实机器人一致,但在长期使用后,机器人零点可能需要重新标定。实际跑真机之前,我建议在示教器上先手动把每个关节转到0度附近,再对比ROS里读到的/joint_states,确认关节序号和方向对应关系。
另外,真实环境中不能完全依赖MoveIt的规划场景。仿真里桌面的尺寸是已知的,但真实环境可能有线缆、物料堆、围栏等物体。我后来在真实工作台上加了一个静态点云发布节点,把深度相机数据转成OccupancyMap,再叠加进PlanningScene,这样MoveIt才能有效地避开地面杂散物体。
6.4 效率优化和后续扩展
仿真和真机都跑通之后,项目才算真正可用。再往后扩展的话,有几个方向比较有价值:
第一,加入视觉引导。用ArUco码或者3D点云获取工件目标位姿,让双臂根据视觉结果动态调整抓取点。这比固定位姿更贴近产线。
第二,加入力控或导纳控制。UR10的末端如果要做装配、打磨这类接触任务,纯位置控制不够。可以在末端装六维力传感器,通过force_torque_sensor_controller把力反馈接入ROS。
第三,迁移到ROS2。如果你要长期维护这套系统,ROS2的节点生命周期和通信机制更适合分布式部署。不过需要把MoveIt2、gazebo_ros2、ur_robot_driver的ROS2版本全部对齐,工作量不小。
我个人在实际使用中的体会是,双机械臂项目的成败,一半在规划算法,另一半在工程细节。如果你只是想在仿真里玩,做到这里已经算完整;但如果要落地到真实设备,建议先把仿真里的成功率跑到95%以上,并把安全链路全部测通,再碰真机。最后再分享一个小技巧:不管代码写得有多顺手,每次改完模型或控制器参数,都先让机械臂在home点附近做一次低速循环测试,确认关节状态、TF树和控制指令都没有异常,再进行正式任务。这个习惯能帮你挡掉很多莫名其妙的“炸机”问题。
本文还有配套的精品资源,点击获取