资讯动态

IMX6ULL ADC驱动开发实战:从设备树到IIO子系统全解析

发布时间:2026/10/20 20:25:01 来源:尧图企业网站定制
很多做嵌入式Linux的朋友第一次碰到IMX6ULL的ADC驱动开发第一反应都是这不就是个简单的寄存器操作我在单片机上读个电位器那是家常便饭。可真到把设备树、引脚复用、IIO子系统、时钟管理全部串起来的时候才发现这是个标准的Linux驱动开发题目而不是ADC采集题目。这篇文章把我自己从零开始调通IMX6ULL ADC驱动的完整思路、关键代码和踩坑过程整理出来。如果你手里有一块带ADC引脚的IMX6ULL开发板想接一个电位器或者传感器做电压采集这篇文章可以直接作为参考路线如果你只是想搞清楚Linux下ADC驱动到底是怎么工作的这篇文章也能给你一个完整的脉络。1. 先说清楚IMX6ULL的ADC驱动开发难点到底在哪1.1 12位逐次逼近型ADC的硬件底子IMX6ULL内部集成的是一个12位逐次逼近型SARADC支持8个外部输入通道从AD1_IN0到AD1_IN7。逐次逼近的工作原理说穿了就是二分法猜电压内部有一个比较器和一个可编程的DAC先从最高位开始猜把猜测值转成模拟量跟输入电压比比完再决定下一位是1还是012位就是猜12轮。这个原理本身不难但落实到芯片上有个关键点——芯片内部有一个采样保持电容采样瞬间会把输入引脚上的电荷抽走所以信号源能不能扛得住这种抽取直接决定了精度。这一点后面讲输入阻抗时还要展开。另一个容易被忽略的硬件事实是这颗ADC并不是IMX6ULL独有的IP它的寄存器风格和驱动框架在早期一系列芯片里都能看到影子。所以你搜资料的时候会看到很多互相矛盾的写法有人直接说mainline内核有现成驱动有人说必须自己port BSP驱动其实两种说法都对只是面向的驱动版本不同。1.2 为什么有人说没现成驱动有人说有先说结论mainline内核里有一个叫vf610-adc的IIO驱动它的设备树兼容字符串支持fsl,vf610-adc和fsl,imx6ul-adc这类写法IMX6ULL在不少内核版本上能直接匹配。也就是说如果你的内核比较新设备树节点写得对确实可以做到配好DTS就能在/sys/bus/iio下面读到数据不需要自己写一行驱动代码。但问题在于很多开发板上跑的BSP内核是某个厂商定制过的版本里面的ADC驱动可能是另一套老驱动设备树节点名、兼容字符串、甚至中断号和时钟名都对不上mainline。再加上有些教程驱动是自研的字符设备驱动不走IIO框架直接创建一个/dev/adc节点给你read。这种驱动在某一类板卡上很流行但你把它搬到另一块板子上往往要改寄存器操作、改时钟配置等于重新做一遍移植。所以IMX6ULL的ADC开发真正的难点不是会不会配寄存器而是能不能判断出当前内核环境走哪条路最省力。我的建议是先打开你当前内核源码目录搜一下adc相关的compatible字符串看看到底有没有驱动能匹配你的芯片再决定是改DTS还是改驱动。这一步花十分钟能帮你省掉一整天。1.3 动手之前先确认的四个事实第一个事实板子上ADC引脚到底有没有引出来。很多IMX6ULL核心板把GPIO1_IO01到GPIO1_IO08这些引脚复用给了LCD、JTAG或者网卡底板上根本找不到ADC测试点。我见过有人折腾半天驱动最后发现引脚压根没引出到排针全做了其它功能。第二个事实VREF实际接的是多少伏。ADC的满量程就是VREF如果是3.3V那么1个LSB就是3300/4096≈0.806mV如果是2.5V就是2500/4096≈0.61mV。这个值直接参与电压换算而且不同板卡差异很大必须看原理图确认不能想当然。第三个事实你的内核版本和BSP来源。这决定了你是用mainline驱动、厂商BSP驱动还是要自己写。内核里搜一下比在论坛问一百遍都准。第四个事实目标用途。只是低速读取一个电位器那轮询就够了如果是采集音频或者做连续监测就必须考虑中断、触发缓冲和采样率问题。先想清楚用途再选技术路线否则很容易做过头。2. 硬件侧三件套引脚复用、参考电压和输入阻抗2.1 ADC输入通道与引脚复用IMX6ULL的8个ADC外输通道AD1_IN0到AD1_IN7对应的是GPIO1_IO01到GPIO1_IO08这8个引脚。但注意这些引脚在IOMUXC里并不是只有ADC一个功能它们往往还能做GPIO、UART、JTAG或者其它外设的复用你必须在设备树里明确把它们配置成模拟输入用途而不是靠默认状态。这里有个特别容易踩的坑ADC这类模拟输入在IOMUX里通常没有像UART的ALT模式那么明确的AD功能选项。很多BSP的参考例程是把它配成GPIO输入模式同时关闭上下拉、关闭迟滞也就是用高阻抗输入的状态来让模拟信号进来。我之前第一次配的时候以为要找一个叫AD1_IN0的mux值翻遍头文件没找到后来才明白这个思路。以最常见的BSP写法为例设备树里一般是这样的iomuxc { pinctrl_adc1: adc1grp { fsl,pins MX6UL_PAD_GPIO1_IO01__GPIO1_IO01 0xb0 MX6UL_PAD_GPIO1_IO02__GPIO1_IO02 0xb0 ; }; };后面的0xb0这类pad控制值在大多数BSP里就是关闭上下拉、关闭迟滞、设置输入模式的组合。如果你直接抄一个GPIO输出配置比如0x10b0这种带驱动的值ADC读数会受到内部上拉或驱动器的影响轻则读数偏低重则完全不对。这块务必对着你BSP里的pad头文件确认。2.2 参考电压VREF怎么来VREF是ADC转换的尺子它决定满量程对应的电压。IMX6ULL这颗ADC的参考电压在不同板卡上接法差别很大有些板卡直接把VREF接到3.3V电源轨有些用独立的LDO单独供电还有一些板卡甚至没有把VREF引脚单独引出来而是内部跟某个电源域绑在一起。我拿到的这块板子就是VREF接3.3V,但3.3V这一路同时还给SD卡、LED和部分外设供电结果就是当SD卡读写时ADC读数会出现周期性的抖动。这种问题如果不知道VREF的来源会一直以为是驱动里的滤波做得不够。所以在动手写驱动之前建议先用万用表实测一下VREF引脚的对地电压并在不同负载条件下多测几次。如果发现VREF波动明显就得在硬件上加一级RC滤波或者干脆在软件里把该电源域的干扰时段避开。VREF不稳时你在软件上做再多滤波都是治标不治本。2.3 输入阻抗与信号调理前面提到的采样保持电容问题在IMX6ULL上同样存在。ADC采样瞬间会从输入引脚抽取一个电流脉冲如果信号源阻抗很高比如直接用了一个1MΩ的电位器电荷还没来得及补充采样电容上的电压就已经跌了最终读数会偏低。解决思路无非两个方向一是降低信号源阻抗在信号源和ADC之间加一个运放跟随器二是给ADC足够长的采样时间让采样电容慢慢充到稳定。寄存器里有一个采样周期配置位有的BSP默认值偏短接高阻源时读数会明显偏低且不稳定。我测过一个100kΩ电位器直接接ADC默认采样周期下读数比万用表低了几十毫伏把采样周期调大之后偏差立刻缩小。另外一个简单有效的做法是在ADC引脚对地加一个0.1uF的陶瓷电容形成一个RC低通滤波。这个电容一方面滤高频噪声另一方面相当于一个电荷池能在采样瞬间补充一部分电荷。但注意电容太大会让信号响应变慢如果是动态信号要权衡取舍。电位器、分压电阻这类静态信号0.1uF基本是安全的。3. 寄存器级转换流程从HC0到R0的一次完整采集3.1 必须熟悉的六个寄存器虽然我们最终走IIO框架但驱动核心始终是寄存器操作。IMX6ULL ADC这一组寄存器说多不多说少不少实际开发里最常打交道的就六个。我把它们的偏移和作用整理成一张表偏移寄存器作用0x00CFG时钟分频、采样周期、触发方式等基本配置0x04GC通用控制含校准开关、平均值设置0x08GS通用状态反映校准是否在进行0x0CHC0硬件控制通道0选择通道并启动转换0x14HS状态寄存器转换完成标志COCO0/COCO10x18R0硬件结果通道0保存12位转换结果这里有个特点值得注意ADC模块内有两套硬件通道也就是HC0/R0和HC1/R1。你可以同时配置两个不同的输入通道让转换交错进行也可以把其中一个当成备用通道随时切换。大多数简单驱动只用HC0但理解HC1的存在对后面做多通道快速切换很有帮助。3.2 单次转换的完整时序一次单通道转换的逻辑非常清晰总共四步配置、启动、等待、读取。我第一次在单片机上写ADC驱动时是这套流程到了IMX6ULL上还是这套流程只是寄存器名字变了。第一步配置CFG寄存器把ADC时钟分频到合适频率并设定采样周期。ADC时钟不能超过参考手册给出的上限否则转换结果不可靠。分频值的计算要看你板子上给ADC供时钟的源头频率不同BSP里的IPG时钟频率可能不同必须手算一遍再填。第二步往HC0寄存器写入要转换的通道号和启动位。通道号就是0到7对应AD1_IN0到AD1_IN7。启动位置1后ADC就会开始一次转换。第三步等待HS寄存器里的完成标志。如果开了中断这一步会变成在中断处理函数里等没开中断就轮询。轮询时务必加超时保护不能让驱动在异常情况下死等。第四步从R0寄存器读取12位结果。这里要留意结果字段的位宽和位置不同版本参考手册对结果字段的定义可能有差异开发时以手头手册为准。我习惯只取低12位同时屏蔽掉无效的高位。下面是一段简化版的转换函数能代表这个流程static int imx6ull_adc_convert(struct imx6ull_adc *adc, int ch, int *val) { u32 hc, hs; int timeout 10000; /* 清状态位 */ writel(0, adc-base ADC_HS); /* 写通道号和启动位 */ hc (ch 0x7) | 0x1; writel(hc, adc-base ADC_HC0); /* 轮询完成标志COCO0 */ do { hs readl(adc-base ADC_HS); if (hs (1 0)) break; } while (--timeout 0); if (timeout 0) return -ETIMEDOUT; *val readl(adc-base ADC_R0) 0xfff; return 0; }这段代码没用任何花哨技巧但它是整个驱动的心脏。我在实际调试中遇到的大多数诡异问题最后都回归到这个函数里要么是启动位没写对要么是状态位看错了要么是超时时间不够。3.3 校准偏移和增益是怎么回事SAR型ADC天然存在两种误差失调误差和增益误差。失调误差表现为输入0V时读数不是0而是有个固定偏移增益误差表现为输入满量程时读数不是4095整体斜率有偏差。IMX6ULL的ADC提供了自动校准机制就是通过GC寄存器里跟校准相关的控制位让硬件内部自动测算并修正这两个误差。校准流程通常是把校准控制位置1然后等待GS寄存器里的校准状态位清零。整个过程在probe函数里做一次就够了不需要每次转换都做。校准完成后校准结果会体现在偏移校准寄存器和增益校准寄存器里正常情况下你不需要手动改它们。我遇到过一台设备在批量生产时部分板子的ADC读数整体偏低排查半天发现是产线测试流程里跳过了probe阶段的自校准。所以校准这件事千万不要觉得反正影响不大就省略。尤其当你需要把多个板卡的读数统一对到同一把尺子上时校准是必须的。4. 驱动代码落地设备树配置与IIO驱动骨架4.1 设备树节点和pinctrl填写不管用现成驱动还是自己写驱动设备树这一步都绕不开。IMX6ULL的ADC节点在BSP里一般被命名为adc1寄存器基地址是0x0219c000中断和时钟属性则因BSP版本而异。下面是一个比较典型的节点写法adc1 { pinctrl-names default; pinctrl-0 pinctrl_adc1; clocks clks IMX6UL_CLK_ADC1; clock-names adc; vref-supply reg_vref_3v3; status okay; };这里有两个属性容易出问题。第一个是clocks如果你用的驱动是mainline的vf610-adc它会在probe里获取名为adc的时钟并启用。DTS里漏配时钟或者时钟名对不上probe会直接失败而且报错信息不一定明显。第二个是vref-supply如果板子上有专门的VREF稳压器这个属性用来关联它驱动可以通过regulator框架读取参考电压值来计算scale。没有这个regulator节点时驱动就必须在代码里写死一个电压值。pinctrl部分我在前面已经给了示例再强调一遍ADC引脚配置的关键是高阻抗输入宁可少配一个无关属性也不能配错成带上下拉的输出模式。如果你的设备树里adc1节点没有pinctrl也能启动别惊讶有些BSP把引脚状态默认设成了模拟输入。4.2 probe阶段要做的事自己写IIO驱动时probe函数的任务可以归纳为四步映射寄存器、使能时钟、做自校准、注册IIO设备。这里用platform驱动加devm资源管理的写法最省心出错时资源自动释放不容易泄漏。static int imx6ull_adc_probe(struct platform_device *pdev) { struct device *dev pdev-dev; struct imx6ull_adc *adc; struct iio_dev *indio_dev; struct resource *res; indio_dev devm_iio_device_alloc(dev, sizeof(*adc)); if (!indio_dev) return -ENOMEM; adc iio_priv(indio_dev); adc-dev dev; res platform_get_resource(pdev, IORESOURCE_MEM, 0); adc-base devm_ioremap_resource(dev, res); if (IS_ERR(adc-base)) return PTR_ERR(adc-base); adc-clk devm_clk_get(dev, adc); if (IS_ERR(adc-clk)) return PTR_ERR(adc-clk); clk_prepare_enable(adc-clk); /* 上电后做一次自校准 */ imx6ull_adc_calibrate(adc); indio_dev-name imx6ull-adc; indio_dev-modes INDIO_DIRECT_MODE; indio_dev-info imx6ull_adc_info; indio_dev-channels imx6ull_adc_channels; indio_dev-num_channels ARRAY_SIZE(imx6ull_adc_channels); return devm_iio_device_register(dev, indio_dev); }校准函数也很直接就是把校准位置1然后等状态位清零static void imx6ull_adc_calibrate(struct imx6ull_adc *adc) { u32 gc readl(adc-base ADC_GC); gc | 0x1; /* CAL */ writel(gc, adc-base ADC_GC); while (readl(adc-base ADC_GS) 0x1) /* CALU */ cpu_relax(); }如果你发现校准一直不结束先查时钟是否真的起来了再查ADC模块的时钟是否超上限。时钟没使能的情况下寄存器能读但转换和校准都不会动这是最经典的读寄存器全是0但就是不工作场景。4.3 read_raw的两种实现轮询与中断IIO框架下用户态读数据最终都会走到iio_info结构体里的read_raw回调。回调里核心就是调用前面写的转换函数。区别在于转换函数内部是轮询等待还是中断等待。轮询模式实现简单在read_raw里直接调用转换函数就行了适合慢速采集场景。中断模式则需要额外把中断号通过platform_get_irq拿到在probe里request_irq中断处理函数里标志完成并用complete唤醒等待的read_raw。代码量多一截但处理器不用空转等待。这两种方式怎么选我的经验是如果采集频率只有几十赫兹以下轮询完全够用如果要做音频级别或者需要精确控制采样时刻走中断加IIO触发缓冲更合理。对大多数开发板上的电位器、光敏电阻、电池电压监测来说轮询是最简单可靠的。IIO通道的描述也要定义好否则用户态看到的节点名和属性名都是错的#define IMX6ULL_ADC_CHANNEL(idx) \ { \ .type IIO_VOLTAGE, \ .indexed 1, \ .channel idx, \ .info_mask_separate BIT(IIO_CHAN_INFO_RAW) | \ BIT(IIO_CHAN_INFO_SCALE), \ .datasheet_name AD1_IN #idx, \ } static const struct iio_chan_spec imx6ull_adc_channels[] { IMX6ULL_ADC_CHANNEL(0), IMX6ULL_ADC_CHANNEL(1), IMX6ULL_ADC_CHANNEL(2), IMX6ULL_ADC_CHANNEL(3), IMX6ULL_ADC_CHANNEL(4), IMX6ULL_ADC_CHANNEL(5), IMX6ULL_ADC_CHANNEL(6), IMX6ULL_ADC_CHANNEL(7), };read_raw里要分别处理RAW和SCALE两种请求static int imx6ull_adc_read_raw(struct iio_dev *indio_dev, struct iio_chan_spec const *chan, int *val, int *val2, long mask) { struct imx6ull_adc *adc iio_priv(indio_dev); switch (mask) { case IIO_CHAN_INFO_RAW: return imx6ull_adc_convert(adc, chan-channel, val) ? : IIO_VAL_INT; case IIO_CHAN_INFO_SCALE: *val 0; *val2 adc-vref_mv * 1000000 / 4096; return IIO_VAL_INT_PLUS_MICRO; } return -EINVAL; }5. 用户态数据读取换算公式、滤波和连续采样5.1 raw与scale的关系驱动注册成功后系统里会出现/sys/bus/iio/devices/iio:device0目录下面有一堆in_voltageN_raw和in_voltageN_scale文件。raw就是原始读数scale是每个LSB对应的电压值单位是mV。最终电压就是两者相乘电压(mV) raw × scale比如我在板子上实测rootboard:~# cat /sys/bus/iio/devices/iio:device0/in_voltage0_raw 2015 rootboard:~# cat /sys/bus/iio/devices/iio:device0/in_voltage0_scale 0.805664那么当前电压就是2015×0.805664≈1623mV也就是1.623V左右。如果你看到scale打印的是一个整数比如805那说明驱动返回的是另一种格式计算时要自己除以1000换算前先确认一下格式比盲目套公式靠谱。应用层如果要用C代码循环读取千万别每次打开关闭文件开销太大。正确做法是把文件描述符打开后每次用pread读取既能随意指定偏移又不会因为多线程读取互相干扰。IIO的sysfs节点每次read都会触发一次新的转换所以读的频率就是你实际采样的频率。5.2 数据抖动怎么处理ADC读数抖动是再正常不过的事尤其是最后两位。12位分辨率下如果VREF是3.3V1个LSB只有0.8mV电源纹波、周围电磁干扰、信号线长度都会在最后几位上体现出来。处理抖动分硬件和软件两条线。硬件上我前面提到的RC滤波电容是最廉价有效的方案信号线尽量短远离PWM线和开关电源必要的时候用屏蔽线。软件上最常见的做法是多次采样取平均比如连续采16次去掉最大最小值再平均能把大部分随机噪声压下去。IIO本身支持在硬件上配置平均次数但如果你用的是现成驱动不一定暴露了这个配置项那就直接在应用层做更灵活。还要注意一个特殊现象如果读数不是随机抖动而是呈现规律的周期性波动比如每隔一段固定时间就跳一次那大概率不是热噪声而是系统里某个周期事件在干扰VREF或者引脚。最常见的就是开关电源的PWM周期、LED扫描周期、SD卡读写等。遇到这种情况先查硬件耦合路径不要一味加滤波。5.3 多通道轮流采样的坑如果需要采集多个通道最直接的思路就是应用层轮流读in_voltage1_raw、in_voltage2_raw。但要注意如果驱动内部没有加互斥锁两个线程同时发起转换就可能出现通道串扰A线程启动通道0的转换B线程把HC0改成了通道1结果A读到的数据其实是通道1的。自己在驱动里加一个mutex把转换函数的启动到读取包在锁里是最稳妥的做法。IMX6ULL的HC0/HC1双通道机制本身可以缓解这个问题比如固定HC0跑通道0HC1跑通道1两个结果互不覆盖但实际使用中大部分简单驱动并不会利用这一点而是靠锁保证原子性。另外多通道切换时还要留意每个通道之间是否需要延时。如果前一个通道是高阻信号源采样电容里残余的电荷会影响下一个通道的第一次采样结果可以先丢弃第一次数据或者在每次启动转换前插入一个最小间隔。我调试多通道时发现第一个通道读数总是略偏后来在应用层把每通道第一次采样结果扔掉数据立刻正常了。6. 实测踩坑记录读数异常的完整排查思路6.1 固定读0或者满量程我最开始调这块板子时电位器怎么拧读到的都是0。第一反应是驱动问题查了通道号、寄存器配置都没错。后来用万用表直接量ADC引脚发现引脚电压确实在变但读数始终是0于是怀疑引脚被内部下拉。问题果然出在pinctrl上。我当时抄了一份BSP例程的pad配置那个值带了内部下拉等于在信号源和地之间接了一个几十到上百kΩ的电阻。电位器本身阻值又比较高分压之后ADC引脚上的电压被严重拉低读数自然接近0。换成0xb0这个高阻输入配置之后问题立刻消失。固定读满量程4095也是类似思路要么引脚被上拉要么输入电压真的高于VREF要么VREF本身没接好导致ADC参考电压异常。遇到固定读0或满量程我的排查顺序是先万用表测引脚电压确认硬件信号是正常的再检查pinctrl的上下拉配置最后检查通道选择和VREF。这个顺序能过滤掉大部分低级问题。6.2 采样值跳变的真实原因比想象中复杂一次读数在800到1000之间来回跳而且跳变毫无规律这是我遇到的第二个比较头疼的问题。当时我以为是电源噪声给VREF加电容、给ADC引脚加电容效果都不明显。后来灵机一动拿示波器去看ADC引脚的波形才发现引脚上叠加了一个很高频率的振铃。这个振铃的来源是信号线。我用了大概20厘米长的杜邦线把电位器连到开发板引脚上线本身在天线一样接收周围的电磁干扰再加上分布电感信号在引脚处产生了振铃。把线换成尽量短的屏蔽线并且在引脚对地加0.1uF电容后跳动范围从几百个LSB降到了两三个LSB。这个案例给我的教训是模拟信号过长的杜邦线就是噪声天线采集精度要求高时长线方案本身就该被否决。软件上我也做了配合应用层做了一次中值滤波加均值滤波。先取5次采样排序取中值再连续取几个中值做平均最终的读数非常稳定。这种方式对于传感器缓慢变化的场景很实用代价是响应速度变慢但绝大多数应用都能接受。6.3 一次转换超时的排查过程最折磨人的一次问题是驱动probe完全正常IIO设备也注册了但一读raw节点就卡住最后超时返回错误。这种设备在但不工作的现象比直接报错难排查得多。我当时的排查链路是这样的第一步用devmem工具手动读寄存器确认设备树里的寄存器基地址对不对结果能读到说明映射没问题。第二步检查中断是否触发。我改了设备树加了interrupts属性中断号是从BSP头文件里抄的。手动触发一次转换用cat /proc/interrupts看中断计数发现中断从来没来过于是怀疑中断号配错。第三步改用轮询方式读状态位发现轮询也超时——这就说明问题根本不在中断而是转换压根没完成。第四步回头看时钟。我在DTS里配了clocks属性但驱动里没有对ADC时钟做分频配置默认条件下ADC时钟超过上限转换一直处于异常状态。把分频值按参考手册重新计算填进去之后转换立刻正常了。这个案例给我的最大收获是遇到设备注册成功但不工作按寄存器映射→中断→时钟这个顺序排查基本能覆盖90%的原因。而且一定要用好devmem和/proc/interrupts这两个工具它们能帮你快速判断问题到底出在驱动、设备树还是硬件。7. 最后分享几个自己的实用习惯7.1 开发阶段先确认环境再动代码我现在拿到一块新的IMX6ULL板卡做ADC第一件事不是写驱动而是先翻内核源码确认当前内核有没有匹配的ADC驱动设备树模板里有没有现成节点。如果有直接改DTS把节点配好启动后看sysfs能不能出数据。这个步骤能帮我快速判断硬件通路是否正常。硬件通路没验证之前就写驱动出了问题很难分清是驱动问题还是硬件问题。另外我会在开发阶段专门留一个测试脚本用循环读取的方式连续打印各通道原始值和换算电压配合万用表或者信号发生器做交叉验证。一旦驱动有任何改动跑一遍脚本看基线是否变化比临时拼命令高效得多。7.2 把换算公式和滤波逻辑沉淀到应用层驱动里只返回原始值电压换算和滤波全部放在应用层这是我个人的习惯。原因很简单如果驱动里把线性化曲线、滤波算法、校准偏移都写死那换一块VREF不同的板卡或者传感器类型变了就得改内核代码重新编译调试成本高。放到应用层改完直接跑还能快速对比不同滤波参数的效果。同理如果采集的是温度、压力这类物理量建议把从电压到物理量的标定公式也放在应用层。这样驱动保持纯粹应用层保留灵活性等产品定型后再决定要不要把某些固定逻辑下沉到内核都不迟。IMX6ULL上做ADC驱动开发说到底不是跟寄存器较劲而是跟Linux的框架、设备树和硬件特性打交道。把这套思路理顺了后面再做其它外设驱动你会发现很多东西都是相通的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑