Linux Platform总线机制详解:从设备树匹配到probe触发的驱动开发全流程

发布时间:2026/10/30 13:00:58

Linux Platform总线机制详解:从设备树匹配到probe触发的驱动开发全流程 1. 为什么i.MX6ULL驱动开发总在跟Platform打交道1.1 从寄存器直接操作到设备模型的演进很多人刚上手i.MX6ULL的Linux驱动时第一反应都是找芯片手册然后把外设寄存器的地址直接写进代码里。比如控制一个LED最简单粗暴的写法大概是这样#define GPIO_LED_BASE 0x020C4000 #define GPIO_DIR_REG (GPIO_LED_BASE 0x04) #define GPIO_DATA_REG (GPIO_LED_BASE 0x00) static void led_on(void) { writel(readl(GPIO_DATA_REG) | (1 24), GPIO_DATA_REG); }这段代码在板子上确实能跑但问题也非常明显寄存器地址、引脚号全部硬编码进了源码。今天点亮LED要改引脚明天换一颗NAND Flash后天整个板子换成另一颗SoC你的驱动就得跟着改一遍。更要命的是改完之后还得重新编译整个驱动甚至可能影响内核镜像。Linux内核的驱动模型就是为了解决这类问题。它把硬件信息从驱动代码里剥离开抽象成了三个角色设备、驱动和总线。设备负责描述“硬件长什么样”驱动负责描述“软件怎么操作”总线则负责把两者拉在一起。内核里绝大多数的真实总线像I2C、SPI、USB各自有完整的协议栈去管理挂接在它上面的设备但系统中还有很多外设并不挂在物理总线上纯粹是SoC内部的功能单元比如GPIO控制器、UART控制器、SDIO控制器、看门狗。这些设备怎么注册怎么匹配驱动Platform总线就是为这个场景准备的。1.2 Platform总线其实是条“兜底虚拟总线”Platform总线不是真实存在的物理连接它是一条虚拟总线专门用来承接那些没有专门总线承载的设备。i.MX6ULL这颗芯片上的外设绝大多数本质上都是SoC内存映射出来的硬件模块所以它们天然属于Platform设备。哪怕你的某些功能是通过I2C接口外挂传感器实现的I2C控制器自身仍然会作为一个Platform设备挂在内核里传感器才挂在I2C控制器的子总线上。在传统的内核代码里平台设备通过platform_device_register()注册驱动通过platform_driver_register()注册。而在i.MX6ULL这类使用设备树的嵌入式平台上设备信息不再由C代码手动构建而是由设备树中的节点描述。内核在启动阶段会扫描设备树把每个带compatible属性的节点转换为一个platform_device注册到Platform总线上之后驱动加载时再自动完成匹配最终触发probe函数。这也是理解整套机制的核心设备树负责“是什么”驱动负责“怎么干”Platform总线负责“配对”。你写驱动的重点也就变成了三件事设备树节点写得准、驱动结构体声明得对、匹配信息对得上。2. platform_match到底怎么匹配四条路径与一条优先规则2.1 从内核源码看匹配优先级Platform设备与驱动的匹配逻辑集中在platform_match()函数中。不同内核版本实现稍有区别但核心思路一直很稳定。以5.x内核的代码为例函数大致逻辑如下static int platform_match(struct device *dev, struct device_driver *drv) { struct platform_device *pdev to_platform_device(dev); struct platform_driver *pdrv to_platform_driver(drv); if (of_driver_match_device(dev, drv)) return 1; if (pdrv-id_table) return platform_match_id(pdev, pdrv-id_table) ! NULL; return (strcmp(pdev-name, drv-name) 0); }第一次看这段代码时我很自然地以为只有一种匹配方式。实际上一共存在四类匹配路径设备树匹配调用of_driver_match_device()拿设备树节点的compatible属性与驱动of_match_table里的compatible字段逐一对比。只要一个匹配即返回成功。传统ID表匹配比较platform_device的name与platform_driver中id_table表项的name字段。设备名与驱动名匹配在id_table为空的情况下直接比较设备名字与驱动名字是否相同。ACPI匹配在以ACPI方式描述硬件的平台主要是x86上还会走acpi_driver_match_device()嵌入式Linux一般不涉及。在i.MX6ULL这种全设备树环境中你平时打交道最多的就是第一条路径。设备树中写compatible myvendor,myled驱动of_match_table中对应的元素也要写compatible myvendor,myled。一个字符的差异匹配都会失败。2.2 为什么of_match_table前面经常带逗号结尾新手经常在别人的驱动里看到这样的写法static const struct of_device_id my_led_of_match[] { { .compatible myvendor,myled, }, { } };这里最容易误解的是最后那个{ }空元素。它不是一个“占位符”而是一个“哨兵”——标志数组结束。内核在遍历of_device_id列表时如果发现某个元素的compatible字段为NULL就认为列表遍历完毕。遗漏这个哨兵轻则遍历越界重则驱动行为不可预测甚至可能在内核启动阶段直接卡死。我自己就因为这个吃过亏当时排查了很久才发现是结构体数组没有正确终止。另外of_match_table中的compatible字段并不要求一定和设备树节点的compatible完全一样。设备树里的属性本身可以写多个值比如compatible myvendor,myled-v2, myvendor,myled;匹配规则是“驱动程序只声明它支持哪个版本”设备树通过包含多个字符串来表达“我兼容哪个版本”。内核会从头开始把设备树compatible的每个值依次与驱动的匹配表对比任意一个对上就算匹配成功。上面的设备树节点如果驱动of_match_table只写了myvendor,myled它也能成功匹配因为设备树明确声明自己兼容老版本。这是在设计设备树兼容性时非常实用的技巧新板子默认写新版本字符串再往后面追加一个旧版本作为向后兼容的兜底。3. 手写一个Platform驱动从设备树到probe触发全流程3.1 设备树节点应该怎么写以最常见的GPIO按键或LED为例先看设备树。在i.MX6ULL的板级设备树文件通常类似imx6ull-myboard.dts中随便找一个方便的位置加一个自己的节点/ { my_led { compatible myvendor,myled; pinctrl-names default; pinctrl-0 pinctrl_myled; led-gpios gpio5 3 GPIO_ACTIVE_LOW; }; };注意几点。第一根节点下面直接定义子节点是完全允许的内核会把这种没有reg属性也没有device_type的普通节点作为平台设备注册。第二compatible的命名规范建议采用厂商前缀,设备名称的格式这也是设备树规范的要求能避免不同厂商之间的命名冲突。第三如果你的设备需要管脚复用务必在节点里引用对应的pinctrl配置否则GPIO的pad配置不对硬件上根本点不亮灯驱动却以为已经初始化成功了。如果是更接近硬件寄存器操作的场景也可以显式给出寄存器资源/ { my_led_mapped { compatible myvendor,myled-mapped; reg 0x020C4000 0x1000; }; };reg属性的前一个数字是起始物理地址后一个数字是长度。驱动侧可以用platform_get_resource()获取该地址或者直接用devm_platform_ioremap_resource()完成地址映射并返回虚拟地址。3.2 驱动代码的标准骨架Platform驱动的代码骨架非常固定建议把下面这个模板保存下来每次开发时直接套用#include linux/module.h #include linux/platform_device.h #include linux/of.h #include linux/of_device.h #include linux/of_gpio.h #include linux/gpio/consumer.h static int my_led_probe(struct platform_device *pdev) { struct device *dev pdev-dev; struct gpio_desc *led_gpio; struct resource *res; void __iomem *base; /* 方式一从设备树读取GPIO描述符 */ led_gpio devm_gpiod_get(dev, led, GPIOD_OUT_LOW); if (IS_ERR(led_gpio)) { dev_err(dev, failed to get led gpio: %ld\n, PTR_ERR(led_gpio)); return PTR_ERR(led_gpio); } /* 方式二从reg属性获取内存资源并映射 */ res platform_get_resource(pdev, IORESOURCE_MEM, 0); base devm_ioremap_resource(dev, res); if (IS_ERR(base)) { dev_err(dev, failed to ioremap resource\n); return PTR_ERR(base); } dev_info(dev, probe success\n); return 0; } static void my_led_remove(struct platform_device *pdev) { dev_info(pdev-dev, remove called\n); } static const struct of_device_id my_led_of_match[] { { .compatible myvendor,myled-mapped, }, { }, }; MODULE_DEVICE_TABLE(of, my_led_of_match); static struct platform_driver my_led_driver { .probe my_led_probe, .remove my_led_remove, .driver { .name my_led_driver, .of_match_table my_led_of_match, }, }; module_platform_driver(my_led_driver); MODULE_LICENSE(GPL);MODULE_DEVICE_TABLE看起来很不起眼但作用很重要。它负责生成模块的别名信息让modprobe这类工具能根据设备树中解析出来的compatible信息判断是否需要加载这个模块。如果你开发的是外部模块想让设备插入时自动触发加载这行宏不能省。3.3 probe函数被调用的完整链路很多人对“设备树节点为什么会变成记忆中的probe调用”没有直观认识。我简单梳理一下这个链路看完你就能把整条线串起来。第一步内核启动时设备树二进制.dtb被解析成device_node节点树其中每个带compatible属性的普通节点会被of_platform_bus_probe()或of_platform_populate()处理生成对应的platform_device。第二步这些platform_device被注册到Platform总线。注册过程会触发总线进行一次bus_add_device()操作设备加入到总线的设备链表。第三步当你的驱动模块被插入时module_platform_driver()宏展开会调用platform_driver_register()驱动加入总线驱动链表。第四步总线框架调用bus_for_each_dev()遍历所有设备对每个设备执行platform_match()也就是第二节分析的匹配过程。第五步匹配成功总线调用驱动的probe函数。如果probe返回0设备与驱动绑定成功。如果返回非0无法绑定但后续重新尝试时仍然可能再次触发。这个链路里的细节是设备先注册驱动后注册是常见时序驱动先注册设备后注册也会触发匹配。这就是为什么你的驱动可以做成模块甚至在系统启动后再手动insmod只要设备树里的节点存在加载驱动的瞬间匹配就会发生。4. probe迟迟不进的排查链路与实测案例4.1 先确认设备到底存不存在写驱动最难受的场景就是模块加载了无报错但probe压根没被调用。这种问题很多新手会一头钻到匹配逻辑里反复检查驱动结构体但我建议按照下面的顺序排查效率会高很多。先看设备树节点的状态ls /sys/bus/platform/devices/在输出里找有没有你设备树中compatible对应的设备目录。比如你写了myvendor,myled设备目录可能叫myled或者内核自动生成的别名一般会包含设备名和地址后缀。如果这里什么都没看到说明设备树节点根本没有成功变成platform_device问题出在设备树侧而不在驱动侧。接下来看驱动是否挂到了Platform总线上ls /sys/bus/platform/drivers/my_led_driver/如果驱动注册成功这个目录应该存在。目录下原本会有一个空的bind接口和unbind接口。驱动注册成功后如果某个设备与驱动成功匹配目录下还会出现一个指向设备的符号链接。这两个目录配合dmesg输出基本能定位80%以上的问题。4.2 检查compatible一致性和module装载情况常见的匹配失败原因可以分成三类。第一类是模块根本没装载到当前内核或者装载的是编译出错的旧版本。用insmod时有任何报错都先解决报错。第二类是对比项不一致最常见的是设备树里写成myvendor,myled驱动里却写成myvendor,myled-mapped或者反过来。第三类是设备树更新了但内核镜像里打包的DTS数据没有重新编译进.dtb。第三类在实际开发中尤其隐蔽。因为i.MX6ULL上经常会用启动环境变量去指定设备树文件如果你修改的是源码里的.dts文件但启动eMMC或SD卡分区里的.dtb仍然还是旧文件那么无论你怎么调驱动代码设备树层面永远不会响应。我踩过一次很深的坑改完设备树也重新编译了内核镜像自以为万事大吉结果启动参数里写的设备树路径对应的分区根本没被我更新的文件覆盖。后来用下面的命令才确认问题dtc -I fs /proc/device-tree -O dts | grep myled如果/proc/device-tree里没有你的节点信息就说明当前运行的内核设备树和你预期不一致。把启动介质中的设备树文件重新拷贝覆盖问题立刻消失。4.3 实测案例一个引脚名引发的排查记录有一回我自己在i.MX6ULL上做一个LED驱动设备树里写的是led-gpios gpio5 3 GPIO_ACTIVE_LOW驱动里用devm_gpiod_get(dev, led, GPIOD_OUT_LOW)去拿GPIO。第一次probe就一直报failed to get led gpio返回-ENOENT。排查时我先怀疑设备树写法把led-gpios改成leds-gpios再改驱动里的名字去对应绕来绕去最后才发现问题根本不在命名的随机性上而是我没有引用GPIO相册相关的头文件导致设备树里的GPIO定义没有被正确解析为可用的gpio chip引用。简单说GPIO描述符的查找依赖设备树中的gpio-xlate映射如果对应的GPIO控制器没有正确注册那不管名字怎么改都找不到。后来我意识到devm_gpiod_get这类API已经在内部封装了设备树解析、中断请求、时钟使能等大量操作但它要求设备树描述必须完整准确。如果你的GPIO控制器在设备树里的状态异常或者pinctrl配置缺失导致gpio chip注册失败那么你在这个节点上怎么调都无济于事。这也提醒我们排查Platform驱动问题时不要只盯着Platform这一层设备树里引用的其他子系统GPIO、时钟、中断可能才是真正的病根。4.4 加点调试输出看匹配路径匹配不成功但又不确定具体原因时可以临时启用内核动态调试或者在你怀疑的路径上打印信息。直接修改驱动加dev_info当然最简单但不是所有时机都能打印。更推荐的方式是使用of_platform_bus_probe之前的早期日志来确认设备树解析状态。如果匹配本身发生了只是probe函数内部又返回了错误那么dmesg里通常会有对应驱动打印的错误信息比如我的led_gpio例子。如果匹配没有发生dmesg里基本只会有设备树解析相关的内容此时把of_match_table里的第一个compatible字符串和设备树节点中的compatible值放在一起人工对比一字不差通常就能解决。遇到实在找不到原因的情况可以临时禁用模块自动加载手动执行下面的步骤echo my_led_driver /sys/bus/platform/drivers/my_led_driver/bind然后立刻看dmesg。有时候内核的匹配日志会通过debugfs或tracepoint暴露你先用bind手动绑定能更快区分“设备不存在”和“匹配条件不满足”两种情形。5. 让Platform充分发挥价值多实例绑定与资源读取思路5.1 一个驱动驱动同型号多个外设设备树解决“硬件信息与驱动代码解耦”之后最直接的收益就体现在多实例场景。假如板子上有4路LED灯每一路使用不同的GPIO引脚传统写法的驱动得为每一路单独封装逻辑或者用模块参数传递引脚号繁琐且易错。使用Platform模型后设备树里直接写4个节点/ { led0: my_led0 { compatible myvendor,myled; led-gpios gpio5 0 GPIO_ACTIVE_LOW; }; led1: my_led1 { compatible myvendor,myled; led-gpios gpio5 1 GPIO_ACTIVE_LOW; }; };驱动侧无需为每个LED写一份重复逻辑。因为每次匹配都会创建一个独立的struct device实例而probe函数会被调用多次每次传入的platform_device *pdev都不同驱动代码使用platform_get_resource、devm_gpiod_get等API时内部自动关联当前设备对应的资源。这样一份驱动可以同时控制多路LED互不干扰代码量也几乎没有增加。这种多实例绑定能力是硬编码寄存器时代梦寐以求的。5.2 把自定义数据写进设备树并在驱动中读取除了reg和GPIOPlatform驱动还可以借助设备树传递任意自定义参数。假设你的LED驱动需要支持“闪烁频率”和“上电默认状态”设备树中可以定义my_led { compatible myvendor,myled; blink-period-ms 500; default-on; };驱动侧通过统一的device_property_*API去读取这样就不需要为每一个参数设计模块加载选项了。struct device *dev pdev-dev; u32 blink_period 0; bool default_on false; device_property_read_u32(dev, blink-period-ms, blink_period); default_on device_property_read_bool(dev, default-on); dev_info(dev, blink: %dms, default_on: %d\n, blink_period, default_on);这套API已经封装了设备树解析细节。无论底层是设备树、ACPI还是平台固件驱动代码都可以保持通用。如果你的驱动不是只在i.MX6ULL上运行将来移植到其他架构平台时这部分代码可以原封不动保留。5.3 资源映射的现代API很多时候你确实需要直接在驱动里操作寄存器。比如需要控制某个自定义外设模块设备树里给出了reg地址但内核没有现成的子系统帮你管理。这个时候devm_platform_ioremap_resource是关键函数struct resource *res platform_get_resource(pdev, IORESOURCE_MEM, 0); void __iomem *base devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(base)) return PTR_ERR(base);res是同时获取资源信息的传统方式devm_platform_ioremap_resource则是更简洁的现代方式它内部会完成资源获取、地址映射、错误处理并在设备释放时自动解除映射。少写了不少样板代码也避免了常见的未释放资源导致的内存问题和映射泄漏。如果同一个外设需要用多个中断设备树里可以定义interrupts属性驱动侧用platform_get_irq(pdev, index)获取对应编号的中断号。如果中断名定义清晰也可以用platform_get_irq_byname(pdev, tx)可读性更好推荐在有多个中断或协作开发时使用。5.4 从Platform设备到更抽象的设备模型理解Platform设备与驱动匹配之后再看Linux其他子系统会发现基本都在使用同一套“总线-设备-驱动”模式。I2C总线下的i2c_driver与i2c_client、SPI总线下的spi_driver与spi_device它们的匹配机制和Platform类似只是匹配规则程序换成了各自总线的match函数。比如I2C驱动也会通过i2c_device_id或设备树中的compatible来匹配SPI总线的spi_match_device同样有相似逻辑。所以一旦你用i.MX6ULL把Platform这套机制彻底吃透后续写哪些挂在真实物理总线上驱动上手速度会明显提高。你会很自然地设计出这样的结构驱动里只保留协议逻辑和业务逻辑所有与硬件相关的寄存器地址、中断号、GPIO编号、管脚复用统统交给设备树去描述。这样不仅代码更干净工程上对硬件改板的适应能力也更强。板卡引脚有变动时多半只要改设备树驱动编译一次就能继续用。实际项目中这套思维带来的价值往往比单纯“能点亮LED”要大得多。我后来接手过多个基于i.MX6ULL的项目有的是音频编解码芯片有的是车载CAN设备有的只是纯粹的GPIO扩展器。无论硬件差异多大只要遵循Platform这种“设备与驱动分家、通过总线匹配”的思路驱动主体代码都能复用。硬件工程师那边改设备树也很顺手根本不需要软件人员频繁介入。如果你刚到嵌入式Linux驱动开发这条路上建议先不要急着堆砌各种外设驱动而是把Platform机制彻底弄清楚找一块i.MX6ULL开发板自己写一个简单节点从设备树到驱动完整走一遍流程再看一看内核源码里platform_match的实现。这条路走通Linux驱动开发的一半地基就有了。
延伸阅读

更多相关文章

2026/10/26 9:15:55

ComfyUI工作流入门:从缺失节点排查到搭建稳定出图链路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/23 17:38:46

技术项目版本更新暂停与回归:应对策略与最佳实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/30 12:58:56

CentOS 7.9部署Zabbix 6.0全流程指南:避开PHP与数据库版本坑

简介:面向CentOS 7.9运维环境,这份Zabbix 6.0 LTS部署文档适合需要搭建自建监控平台的系统管理员、运维工程师和入门学习者。全文以源码方式给出Zabbix Server与Agent 6.0.12的完整安装流程,并串联Nginx 1.20.2、PHP 7.4.30、MySQL 8.0.28等核…

2026/10/30 12:58:56

OpenCV 4.5.1 预编译包正确集成与避坑指南

简介:本资源为 OpenCV 4.5.1 版本预编译完成的开发包,面向计算机视觉初学者、嵌入式/桌面端快速原型开发者及不熟悉 C 构建流程的算法工程师,旨在解决 OpenCV 源码编译耗时长、依赖复杂、CMake 配置易出错等典型痛点。压缩包为 7z 格式&#…

2026/10/30 12:58:56

C# Modbus TCP读写汇川PLC寄存器实战指南

简介:本资源是一套基于C#实现Modbus TCP协议与汇川PLC通信的完整开发示例,面向工业自动化领域的.NET开发者、PLC上位机工程师及工控系统集成学习者,解决现场设备数据采集、远程监控与协议调试等实际问题。压缩包共148个文件,涵盖9…

2026/10/30 12:58:56

three.js三维图表特效源码解析:从渲染链路到性能优化

简介:这是一份面向Web前端开发者与数据可视化学习者的Three.js三维图形图表特效源码包,适合具备一定JavaScript基础、希望将二维图表升级为立体交互效果的进阶练习者。压缩包共9个文件,约500KB,以7个js脚本为核心,搭配…

2026/10/30 12:58:56

pdf2doc.exe在Win10/Win11的安装配置与闪退排查指南

简介:面向需要在 Windows 上把 PDF 批量转成 Word 的办公人群,这是一份基于 Python 自行整合打包的转换工具,解压后直接运行 pdf2doc.exe 即可使用,免安装、无广告,支持单个文件、多文件以及整个文件夹的批量转换。压缩…

2026/10/30 12:53:56

.NET企业门户完整版:RBAC权限驱动的可上线系统骨架

简介:本资源是一套基于.NET技术构建的企业级门户网站完整实现方案,面向.NET初学者、Web开发工程师及企业信息化建设人员,解决企业对外形象展示、内容管理与用户权限管控等核心需求。压缩包共211个文件,涵盖38个C#后端逻辑文件&…

2026/10/29 17:05:52

全国计算机二级C语言真题PDF高效刷题指南

简介:这份PDF是全国计算机二级C语言考试的试题与答案解析,专为备考二级C语言的考生准备。内容涵盖C语言特点与设计理念、程序基本组成单位、main函数执行流程、函数嵌套调用规则、标识符命名规范、整型实型字符型数据类型、八进制十六进制数合法形式、运…

2026/10/29 17:06:02

requests+BeautifulSoup:爬虫抓取h1标题的入门实战指南

简介:一份以实战为导向的Python爬虫入门资源,面向具备基本编程基础、希望快速掌握网络数据抓取的初学者,可解决从零编写简单爬虫的常见困惑。实例以requests库发送HTTP请求、BeautifulSoup解析HTML文档,完整演示从网页中提取 标题…

2026/10/29 17:06:13

递推最小二乘参数辨识MATLAB编程:二阶系统与渐消记忆实现

简介:这是一篇面向控制系统、信号处理与机器学习等领域学习者的最小二乘参数辨识程序说明文档。资源共包含一个docx格式文档,压缩包大小七十八KB,内容围绕递推最小二乘算法和渐消记忆最小二乘算法展开:先构造一个极点位于单位圆内…

2026/10/30 0:00:46

NCP1342高频准谐振反激控制器:从设计约束到验收的完整方案

简介:NCP1342 高频准谐振反激控制器应用介绍译文文档,面向电源设计工程师、硬件研发人员及开关电源学习者,帮助读者跨越英文原版资料的语言门槛,系统理解这款高集成度 QR Flyback 控制器的设计要点。文档围绕芯片核心特性展开&…

2026/10/30 0:00:46

NCP1342准谐振反激电源设计:从选型到验收的全流程解析

简介:这份文档面向电源设计工程师与开关电源学习者,系统梳理NCP1342高频准谐振反激控制器的应用要点,帮助读者快速理解这款高集成度离线电源变换器控制芯片的工作机制与设计方法。内容涵盖芯片核心特性、引脚功能定义、电气特性参数表、最大额…

2026/10/30 0:00:46

Web系统自动登出机制设计与实现全解析

1. 系统自动登出机制的设计初衷在各类Web应用和后台管理系统中,自动登出功能早已成为标配。这个看似简单的功能背后,实际上涉及用户安全、资源管理和体验优化的多重考量。作为从业十多年的老开发,我见过太多因为登出逻辑设计不当导致的安全事…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑