DP83640硬件时间戳实现PTP纳秒级对时:驱动与源码实战 简介DP83640以太网PHY芯片的PTP对时实现源码包面向嵌入式网络开发者、工业自动化及电力系统等需要高精度时钟的场景解决多设备间微秒级时间同步的实际问题。包体为完整C源码工程共23个文件其中13个C源文件、9个头文件和1个Makefile整体大小94KB目录按src组织代码覆盖DP83640的寄存器读写、PTP控制报文Sync、Delay_Req、Follow_Up等解析与构造、硬件时间戳捕获、时钟伺服调整、中断事件处理以及启动初始化流程层次分明适合直接阅读、移植或交叉编译调试。资源已有799人学习具备一定参考价值。通过该源码可以深入理解DP83640硬件时间戳引擎与PTP状态机的配合机制掌握从PHY层到协议栈的完整对时链路同时可在实际项目中复用驱动代码与算法框架有效降低基于IEEE 1588的设备开发门槛。1. DP83640 实现 PTP 对时从源码包看硬件时间戳怎么落地一块小嵌入式板卡要把十几个节点的采集时间对齐到亚毫秒板卡网口用的是 DP83640 PHY驱动是厂家默认的普通网卡驱动打点全部走 gettimeofday。首次实测主从节点之间的同步误差在 20ms 上下抖动非常明显。问题的根子不在 CPU 负载而在收发路径上的排队和协议栈处理。后来把 DP83640 的硬件 PTP 功能真正使出来换成这套源码软件里包含的 PHY 驱动和用户态同步进程误差直接从毫秒级降到几十纳秒。这份资源适合手里有 DP83640 PHY、想在内核里做硬件时间戳、或打算把 IEEE1588 对时跑到纳秒级的开发者。源码不大但链路完整值得拆开看一遍。2. 架构设计PHY 时钟、PTP 协议与 Linux 驱动如何分层2.1 硬件时间戳解决的不是“快”是“确定”传统软件对时的误差来源非常分散内核发送路径的队列调度、中断屏蔽、网卡驱动环形缓冲区的管理、协议栈对报文的分片处理都会让打点位置偏离网线上的真实时刻。更麻烦的是这些误差并不恒定测出来的时间戳会随机抖动。DP83640 在物理层收发路径上内置时间戳单元报文经过 MII/RMII 接口的瞬间硬件就把当前 PTP 时钟值锁存进 FIFO。即使后续中断调度被延迟时间戳本身也不会错位。调试这套源码时我特意做过对比同一个板卡一个版本用内核软件时间戳另一个版本换成 DP83640 硬件时间戳。空闲时两者误差接近一旦加上一个定时刷屏的应用软件版本误差从 500us 抖动到 3ms硬件版本依然稳定在 100ns 以内。硬件时间戳的价值不是让“快速”变成“更快”而是把“不确定”变成“确定”。时间戳获取位置典型保存路径单跳抖动范围应用层 gettimeofday应用→系统调用→协议栈10us - 10ms网卡 DMA 描述符网卡引擎→驱动→协议栈1us - 5usDP83640 PHY 内部PHY 引脚电平边界数十至数百ns从这张表可以看出对时精度主要由时间戳离物理层到底有多近决定。DP83640 实现 PTP 对时本质上是把时间戳获取点推到最前端这也是整套源码能工作的前提。2.2 四层链路PHY、驱动、字符设备与用户态校准整条链路分成四层PHY 硬件、内核 PHY 驱动、PTP 字符设备、用户态 ptp_sync 守护进程。第一层 PHY 提供自由运行时钟、收发时间戳寄存器和 GPIO 触发能力。第二层驱动把 PHY 登记为标准 phy_driver在初始化时通过 MDIO 开启 PTP 功能并在 PTP 事件发生时读取时间戳。第三层字符设备映射到 ptp_clock 体系向用户态暴露 read 和 ioctlread 拿当前 PHY 时间ioctl 做相位和频率调整。第四层用户态进程运行 PTP 协议状态机解析报文计算 offset 和 delay最后把校正结果写回 PHY。实际数据流是这样走的主站发送一个 Sync 事件报文从站 DP83640 在中断触发之前就把接收时间 t2 锁存好。网卡驱动从时间戳 FIFO 读出该值写入 skb 的 tstamp 域。用户态程序通过原始套接字收到同一个 skb并从辅助数据中取出 t2。随后收到 Follow_Up 报文得到主站发送时间 t1测量链路延迟后再通过字符设备把调整量写回 PHY。这个过程中每一层只关心自己的一小块职责单独替换任何一层都不影响其他部分工作。驱动初始化时config_init 回调会做四件事复位 PHY、读取芯片版本、使能 PTP 引擎、注册字符设备。其中使能 PTP 引擎包括向寄存器写入接收时间戳使能和发送时间戳使能位。读芯片版本用于核对 phy_id 与驱动声明是否一致不匹配时打印警告但继续加载避免因为版本差异导致 PHY 完全不可用。注册字符设备时会申请独立设备号并把 private_data 指向 PHY 设备结构体用户态调用 ioctl 时内核从 file-private_data 找到 PHY再通过 MDIO 操作寄存器。这部分工作放在 config_init 而不是 probe因为 PHY 框架先是匹配 MDIO 总线设备再进入 config_initprobe 时 PHY 可能还没完成上电寄存器读写会返回异常。2.3 时钟域与频率偏差不只是 offset 一个变量主从同步最容易误解的一点是同步不等于“对表”。直接把本地时间改成主站时间会造成时间跳变测量系统里会产生错乱的时间标签。PTP 的做法是测量偏差并微调相位校正用小步进频率校正则持续跟踪。DP83640 内部 PTP 时钟由参考时钟驱动驱动提供 ADJ_FREQ 命令通过调整分频值来微调频率。源码里维护了三个关键变量offset_ns主从相位差、delay_ns链路单程延迟、freq_ppb频率偏差。delay_ns 通过 Delay_Req/Delay_Resp 报文测量offset_ns 通过 Sync 报文测量freq_ppb 则根据 offset 的历史变化率估算。如果只做相位校正而不做频率校正会出现“当前偏差不大过几分钟又拉开”的假收敛现象。源码将 freq_ppb 的计算放在用户态并限制每次调整幅度避免矫枉过正。所有参与计算的时间戳统一使用 64 位纳秒整数避免浮点误差。日志输出时才转为小数秒。这样的小细节在实际对时中很重要浮点运算可能导致纳秒低位不稳定即使误差绝对值很小也会让 PTP 状态机误判方向。3. 源码结构与配置一眼找到需要改的模块3.1 顶层目录与 Makefile 设计源码包的文件布局清晰一眼能看出哪些是内核部分哪些是用户态部分ptp_dp83640/ ├── kernel/ │ ├── dp83640_ptp.c │ ├── dp83640_ptp.h │ └── Makefile ├── daemon/ │ ├── ptp_sync.c │ ├── ptp_sync.h │ ├── ptp_clock_io.c │ └── Makefile ├── tools/ │ ├── phytool.c │ └── check_pps.sh ├── docs/ │ ├── user_guide.md │ └── test_report.md ├── README.md └── VERSION文件作用kernel/dp83640_ptp.cPHY 驱动处理时间戳读取与 PTP 时钟控制daemon/ptp_sync.c用户态 PTP 协议状态机daemon/ptp_clock_io.c对字符设备的读写封装tools/phytool.cMDIO 寄存器读写调试工具tools/check_pps.sh秒脉冲稳定性检查脚本README 里给的上手顺序是先编译内核模块加载后确认 /dev/ptp0 出现再编译用户态进程在从站节点运行 ptp_sync -i eth0 -m 0。这样一步验证一个组件出问题容易定位。内核模块的 Makefile 采用标准的外模块构建方式obj-m dp83640_ptp.o KDIR ? /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean逻辑说明obj-m 声明要编译的内核对象KDIR 指向目标机内核树。交叉编译时只要重设 KDIR 指向开发板的内核源码目录不需要改模块源码。参数说明如果目标机运行的内核头文件不完整编译会报找不到 generated/autoconf.h这时需要先在开发板上用同版本内核源码做一次 modules_prepare。用户态 Makefile 相对更简单CC ? gcc CFLAGS -O2 -Wall -D_GNU_SOURCE LDLIBS -lrt -lpthread TARGET ptp_sync all: $(TARGET) $(TARGET): ptp_sync.o ptp_clock_io.o $(CC) $(CFLAGS) -o $ $^ $(LDLIBS)逻辑说明-lrt 提供 POSIX 时钟接口-lpthread 用于后台校频线程-D_GNU_SOURCE 打开 GNU 扩展网络接口定义。参数说明交叉编译时指定 CCarm-linux-gnueabihf-gcc 即可不需要改源码。这里值得注意内核模块和用户态程序分开编译避免顶层 Makefile 里出现“先 modprobe 再 run”的耦合也让二次开发时能独立迭代。3.2 内核 PHY 驱动注册入口与中断处理驱动核心是向内核注册一个 phy_driver 结构体下面这段是精简后的入口static struct phy_driver dp83640_driver { .phy_id DP83640_PHY_ID, .phy_id_mask 0xffffffff, .name dp83640-ptp, .config_init dp83640_config_init, .features SUPPORTED_PTP, .ts_info dp83640_ts_info, .hwtstamp dp83640_hwtstamp, .rxtstamp dp83640_rxtstamp, .txtstamp dp83640_txtstamp, }; module_phy_driver(dp83640_driver);逻辑说明phy_driver 告诉内核 PHY 库“我是谁、支持哪些能力、时间戳操作在哪个函数”。rxtstamp 在收包时被调用txtstamp 在发送完成中断里被调用。phy_id_mask 通常全掩码要求精确匹配芯片 ID。参数说明DP83640_PHY_ID 在头文件中定义取自 PHY ID 寄存器寄存器 2 和 3。不同批次芯片的 PHY ID 基本一致但做兼容时建议用掩码 0xfffffff0 避开版本差异不要写死最后 4 位。DP83640 时间戳中断处理是实现中的重点。默认采用中断模式中断触发后先判断是接收事件还是发送事件再读时间戳 FIFO。FIFO 中记录的是 PHY 内部计数器的原始值需要换算成用户指定时基的 UTC 纳秒。换算公式是 raw_time * ns_per_tick这个换算放在中断外完成以免阻塞中断上下文。如果 FIFO 里有多个未读时间戳驱动必须按报文到达顺序依次读取否则后续报文的时间戳会整体错位。3.3 用户态 ptp_sync协议状态机与资源绑定用户态程序的结构类似一个小型服务启动时打开原始套接字、绑定 PTP 事件端口、打开字符设备、创建后台校频线程。主循环用 select recvmsg超时设置为 1 秒。超时后检查本地系统时间与 PHY 时间的偏差防止 PHY 时钟被外部工具误改后无人知晓。源码解析 PTP 报文时只提取必要字段版本、消息类型、sequenceId、correctionField。默认使用 ethertype 0x88f7 过滤二层 PTP 报文也就是 Standard Ethernet 承载方式。代码里保留了一个编译开关可以切换到 IPv4-UDP 承载#ifdef PTP_V2_UDP eth_type 0x0800; /* 需要继续解析 UDP 端口 319 和 320 */ #else eth_type 0x88f7; /* 二层 PTP 事件报文 */ #endif逻辑说明DP83640 硬件可以识别两种承载方式但驱动里的 hwtstamp 过滤规则必须与之一致。如果在二层模式下跑 UDP 报文PHY 不会给报文打时间戳。参数说明0x88f7 是 IEEE 1588 的专用以太网类型UDP 模式需要额外校验 IP 头和 UDP 端口避免把非 PTP 报文交给状态机。这个开关在编译时用 -DPTP_V2_UDP 决定源码内部不做自动探测。4. 核心模块拆解一次 PTP 同步周期完整走读完4.1 时间戳读取从 RX 描述符到用户态从主站收到 Sync 报文的链路分三步PHY 在接收瞬间把时间戳写入 FIFO网卡驱动在 NAPI 收包时调用 rxtstamp 回调用户态通过 recvmsg 辅助数据拿到硬件时间戳。驱动读取时间戳的代码精简如下static void dp83640_rxtstamp(struct net_device *dev, struct sk_buff *skb) { struct ptp_priv *priv netdev_priv(dev); u64 ns; u32 lo, hi; /* 读取两段式时间戳寄存器低32位纳秒高32位秒 */ lo phy_read_mmd(priv-phydev, MDIO_MMD_VEND2, DP83640_RX_TS_LO); hi phy_read_mmd(priv-phydev, MDIO_MMD_VEND2, DP83640_RX_TS_HI); ns ((u64)hi 32) | lo; /* 写入 skb 时间戳用户态可通过 SO_TIMESTAMPING 读取 */ skb-tstamp ns_to_ktime(ns); }逻辑说明MDIO_MMD_VEND2 是厂商专用 MMD 页phy_read_mmd 是一次 clause 45 访问需要在 phydev 锁上执行。时间戳寄存器是边读边清一次 FIFO 只能读一个报文所以读之前必须先确认当前 skb 对应的就是 FIFO 头部的报文。参数说明ns 是相对 PHY 内部时钟纪元的纳秒计数不是系统时间用户态在启动时需要先读取一次 PHY 时间与系统时间建立换算关系或者让驱动把 PHY 时钟初始化为系统启动时间。用户态从套接字读取时间戳struct msghdr msg; struct cmsghdr *cmsg; char ctrl[256]; struct timespec *stamp; recvmsg(fd, msg, 0); for (cmsg CMSG_FIRSTHDR(msg); cmsg ! NULL; cmsg CMSG_NXTHDR(msg, cmsg)) { if (cmsg-cmsg_level SOL_SOCKET cmsg-cmsg_type SCM_TIMESTAMPING) { stamp (struct timespec *)CMSG_DATA(cmsg); recv_ns stamp[0].tv_sec * 1000000000ULL stamp[0].tv_nsec; } }逻辑说明SO_TIMESTAMPING 会返回三个 timespec这里取第一个原始硬件时间戳。如果驱动没有设置成功stamp[0] 会变成软件时间戳后续同步精度就会退化。参数说明cmsg_type 必须是 SCM_TIMESTAMPING不能写成 SO_TIMESTAMP这是最常见的低级错误。发送方向的时间戳读取走的是另一条路径需要通过 MSG_ERRQUEUE 接收错误队列完成。源码里在 txtstamp 回调里把发送时间挂到 skb-tstamp用户态用 recvmsg(fd, msg, MSG_ERRQUEUE) 才能拿到。这部分代码对只做从站同步的开发者可以先不看但在做主站或透明时钟时必须要处理。4.2 计算 offsetSync 与 Follow_Up 的配对逻辑PTP v2 里Sync 是事件报文主站发送 Sync 时记录时间 t1但 t1 不放当前报文里而是由后面的 Follow_Up 报文携带到从站。从站在收到 Sync 时记录本地接收时间 t2收到 Follow_Up 后取出 t1。要得到准确的时钟偏移还需要链路延迟 delay。链路延迟通过 Delay_Req/Delay_Resp 测从站发 Delay_Req 记录 t3主站收到记 t4通过 Delay_Resp 带回从站得到单程平均延迟 (t4 - t3)/2。offset 的计算很简单但前提是配对正确/* 根据主站 t1、从站 t2 与链路延迟计算时钟偏移 */ static int64_t ptp_offset_calc(int64_t t1_master, int64_t t2_slave, int64_t delay_ns) { /* 偏移为正表示从站时钟快于主站 */ int64_t offset t2_slave - t1_master - delay_ns; return offset; }逻辑说明t1 来自 Follow_Up 报文里的原始时间戳t2 来自 Sync 报文的硬件接收时间戳delay_ns 来自最近一次延迟测量。两者使用同一时基否则 offset 会包含一个巨大的常数偏差。参数说明如果启用 single-step 模式Sync 报文里直接携带 t1不再发 Follow_Up此时状态机需要跳过 Follow_Up 分支否则会把下一个普通报文当成 Follow_Up产生非法时间戳。状态机里 pair 用 sequenceId 匹配case PTP_MSG_FOLLOW_UP: /* 保存主站发送时间 t1 */ master-t1_ns msg-header.timestamp_ns; /* 只有 seq 一致才配对丢弃乱序报文 */ if (master-seq_id msg-header.sequence_id) { int64_t off ptp_offset_calc(master-t1_ns, slave-last_t2_ns, slave-delay_ns); ptp_controller_update(off); } break;逻辑说明sequenceId 失败最常见于丢包或乱序。如果两个 Sync/Follow_Up 周期重叠状态机必须维护一个小的环形缓存并按 sequenceId 精确匹配。参数说明slave-last_t2_ns 在收到 Sync 时更新如果同一 sequenceId 的 Sync 没到Follow_Up 直接丢弃不触发 controller_update避免用错数据。4.3 调整本地时钟频率与相位双通道得到 offset 后用户态调用字符设备接口完成校正。相位校正是一次性拨动频率校正是渐进调整。封装函数如下/* 相位修正直接调整本地时间与主站的时间差 */ static int ptp_adjust_phase(int fd, int64_t phase_ns) { struct ptp_ioctl_adjust adj; memset(adj, 0, sizeof(adj)); adj.phase_ns phase_ns; adj.operation PTP_IOC_ADJ_PHASE; return ioctl(fd, PTP_IOCTL_ADJUST, adj); } /* 频率修正freq_ppb 表示十亿分之一频率偏差 */ static int ptp_adjust_freq(int fd, int64_t freq_ppb) { struct ptp_ioctl_adjust adj; memset(adj, 0, sizeof(adj)); adj.freq_ppb freq_ppb; adj.operation PTP_IOC_ADJ_FREQ; return ioctl(fd, PTP_IOCTL_ADJUST, adj); }逻辑说明内核收到 PTP_IOC_ADJ_PHASE 时把纳秒数写进 PHY 相位补偿寄存器收到 PTP_IOC_ADJ_FREQ 时则根据 ppb 换算成倍频校正值写入频率调整寄存器。相位和频率不是互斥的先调相位再微调频率才能让系统长期稳定。参数说明phase_ns 为负表示把时间往前调freq_ppb 可接受范围在 config 中限定默认 ±1000ppm超范围 ioctl 直接返回 EINVAL防止误操作把时钟调飞。频率调整不是每个周期都做。源码用一个 8 次过滤窗口只有 8 个 offset 方向和幅度一致时才更新 freq_ppb相当于滞后滤波。这样网络瞬时拥塞造成的 delay 波动不会被误判为频率漂移。校频线程独立于主循环每 8 秒检查一次和主循环的 1 秒超时互不干扰。5. 避坑记录编译、加载与同步抖动排查5.1 编译驱动的内核配置检查如果用内核自带的 phylib 框架编译前必须确认三个配置CONFIG_PHYLIB、CONFIG_NET_PTP_CLASSIFY、CONFIG_PTP_1588_CLOCK。其中 CONFIG_PTP_1588_CLOCK 如果不打开字符设备注册函数 ptp_clock_register 会找不到符号insmod 时报 unresolved symbol。检查命令grep -E PHYLIB|PTP_1588|NET_PTP /boot/config-$(uname -r)如果目标机上没有 /boot/config就在内核源码目录下执行 scripts/config 手动打开然后重新编译内核。别直接改 Makefile 强行编译否则内核模块加载时会出现版本魔术串不匹配连 depmod 都会警告。这个检查是源码包 README 里明确写的也是我第一次编译翻车的第一落点。5.2 四个典型坑以下四条都是可以从现象到解决逐步复现的问题希望帮你避开。坑一PHY 驱动加载后没有任何输出dmesg 显示 unknown PHY现象加载 .ko 后网络接口仍然由原来的网卡驱动接管/sys 下找不到 dp83640-ptp 设备。原因dp83640_driver 的 phy_id 掩码写错或者系统里其他 PHY 驱动先抢注了同一 phy_id。DP83640 挂在 MDIO 总线上内核按 phy_id 匹配驱动匹配不上就只能继续用 generic phy。解决先用 phytool 或 mdio-tool 读取 PHY 寄存器 2 和 3确认实际 phy_id再把 dp83640_driver 的 phy_id 改一致。如果总线上有多个 PHY还需要同时检查 PHYAD 引脚电平确认 PHY 地址没被复用。坑二用户态读回的时间戳和当前 PHY 时间相差几十秒现象ptp_sync 日志里 t2 时间戳明显不符合 epoch甚至出现负数但当 PHY 时间被读取时数字又和系统时间对不上。原因DP83640 上电后内部 PTP 时钟从零开始计数驱动没有在 config_init 里初始化时基。用户态直接把它当作系统 UTC 时间时基对不上。解决在驱动 config_init 里把 PHY 时钟初始化为系统启动时间或者在用户态启动时读取 PHY 当前计数并建立固定偏移关系。推荐前者因为这样内核和用户态都只有一个 UTC 时基排查更简单。坑三同步后的 offset 出现周期性锯齿波现象offset 不是收敛到零而是在正负几十微秒之间来回摆且波形有固定周期。原因校频周期和 Sync 消息周期不匹配。比如主站 Sync 周期是 1 秒从站每 10 秒才做一次频率调整晶振老化速度跟不上对时需求锯齿自然出现。解决把校频周期设为 Sync 周期的 8 到 16 倍同时减小 PID 的 Ki让频率调整更平滑。源码默认 Ki 是 0.1实测降到 0.05 后锯齿大幅减少。坑四PHY 时间戳 FIFO 里出现多个未读时间戳导致后续时间戳错位现象对时误差随机log 中的 t2 时间戳一会儿正常一会儿落后一个报文周期。原因驱动中断处理不及时或者普通报文和 PTP 事件混在同一个 NAPI 收包流程中FIFO 深度不足后被新时间戳覆盖。解决在 rxtstamp 里每收到一个 PTP 事件报文就立刻读 FIFO如果中断线程优先级不够就把时间和中断线程调度改为 FIFO 调度策略。实测 SCHED_FIFO 优先级 50 以上FIFO 溢出概率显著下降。5.3 验证精度的方法最可靠的验证是外部秒脉冲比对把从站 DP83640 的 GPIO 配置为秒脉冲输出主站和从站板卡分别输出 1PPS接入示波器或时间间隔计数器连续记录 1000 个上升沿的偏差取均值和峰峰值。均值反映相位偏差峰峰值反映抖动。源码自带的 check_pps.sh 也会周期读取 PHY 时间戳来测算秒脉冲间隔但外部示波器是最终仲裁。判断标准很直接直连情况下硬件时间戳的峰峰值应小于 1us多数时候能在几十 ns 到 200ns。如果超过 1us首先看中间链路有没有交换机有交换机且交换机不支持 1588 透明时钟就换直连再测。这个步骤能快速隔离网络不对称的问题不用看一大堆同步日志。6. 二次开发从单点对时扩展到多从站和自定义时钟源如果你要把这套源码从“一个从站对一台主站”扩展成“多个从站同时同步”第一步不是改算法而是改资源绑定。ptp_sync 默认绑定单一网络接口和单一字符设备多从站场景下应该把状态机实例化每个实例独立维护套接字和 ptp_fd。几个实例可以共享同一个组播组只要从站端口的 MAC 地址不同即可内核协议栈对组播报文的重复投递会带来额外开销实测最多跑 8 个从站实例时负载还比较低。更实用的改造是增加“外部 1PPS 驯服”功能。DP83640 支持 GPIO 输入捕获驱动可以在中断里记录外部 1PPS 边沿对应的本地 PHY 时间和预期秒相位比较把误差折算成频率调整量写入 ADJ_FREQ。这样即使主站掉线从站也能靠本地晶振和周期性 1PPS 维持长时间精度。代码上只需要扩展一个 ioctlstruct ptp_pps_lock { int enable; int64_t pps_phase_ns; /* 外部1PPS相对PHY时钟的相位差 */ }; #define PTP_IOC_LOCK_PPS _IOW(P, 8, struct ptp_pps_lock)逻辑说明enable 为 1 时驱动在 GPIO 中断里检查相邻两个秒脉冲之间的 PHY 时钟计数除以标称秒值得出实际频率再根据偏差方向调整频率补偿。参数说明pps_phase_ns 用于处理外部 1PPS 和 PHY 秒边界不对齐的情况常见于外部授时设备输出有固定相位延迟时。改完驱动后用户态可以写成独立线程周期调用该 ioctl替代 ptp_sync 里的网络校频逻辑。验证方法依然用秒脉冲改造后连续跑 8 小时记录 1PPS 偏差的峰峰值如果漂移小于 100ns说明驯服环路闭环正常如果出现缓慢偏移就要减小频率调整步长。从那以后我每次改同步相关代码都强制先在测试环境连续跑 8 小时秒脉冲测量再上目标产品。这种稳定性测试能暴露晶振温漂、驱动中断丢失这些偶发问题希望帮到你。本文还有配套的精品资源点击获取