
1. 嵌入式驱动开发到底在做什么很多人第一次听到“嵌入式驱动开发”这个词脑子里浮现的画面是焊台、示波器和一堆看不懂的寄存器手册。这个印象不算错但只对了一半。驱动开发本质上是站在硬件和操作系统之间的一层翻译官它把硬件那些冷冰冰的寄存器操作翻译成内核和上层应用能听懂的接口。你写一个LED闪烁的程序应用层调用的是write()或者ioctl()但真正让LED亮起来的是驱动里面对GPIO寄存器的那一行writel()。我做了十多年嵌入式从裸机跑到RTOS再到嵌入式Linux最大的感受是驱动开发的门槛不在写代码而在“知道为什么这么写”。你可以从网上抄一段字符设备驱动的模板编译加载也能跑但一旦硬件行为不符合预期没有对底层原理的理解你连从哪查起都不知道。这篇文章就是想把我在实际项目中积累的那些“为什么”和“怎么排查”讲清楚适合刚入行一两年、能写点裸机程序但一碰驱动就发怵的兄弟也适合做了几年应用层想往下沉一沉的工程师。嵌入式驱动开发涉及的面很广从最基础的GPIO、UART、I2C、SPI到复杂的USB、PCIe、GPU驱动再到现在的AI加速器驱动每一块都有自己的一套逻辑。但万变不离其宗核心就那么几件事总线模型、设备树、字符设备框架、中断处理、并发控制、电源管理。把这几个吃透了换什么硬件都是查手册的事。2. 驱动开发的核心架构与设计思路2.1 为什么Linux驱动要分那么多层刚接触嵌入式Linux驱动的人最容易懵的就是一个简单的LED驱动为什么要扯到platform总线、设备树、sysfs、字符设备这么多东西直接操作寄存器不行吗行但那是裸机的做法。在Linux下如果你直接在内核模块里硬编码寄存器地址换一块板子就得改代码硬件稍微变一下就得重新编译这显然不可维护。Linux驱动架构的设计哲学是“分离”和“抽象”。分离指的是把硬件相关的部分和硬件无关的部分分开抽象指的是给上层提供统一的接口。具体来说platform总线负责匹配设备和驱动设备树负责描述硬件资源字符设备框架负责提供文件操作接口sysfs负责暴露设备属性。每一层各司其职改硬件只需要改设备树改逻辑只需要改驱动代码互不影响。我拿一个实际项目举例。之前做过一个工业网关主控是i.MX6ULL上面挂了一颗自定义的FPGA通过SPI通信。如果按照裸机思路我需要在驱动里写死SPI控制器的寄存器地址、FPGA的片选引脚、中断号。但用设备树之后这些信息全部放在.dts文件里驱动代码里只用of_property_read_u32()去读。后来客户换了个主控SPI控制器变了我只需要改设备树驱动代码一行没动。这就是分层带来的好处。2.2 设备树不是万能药但不会设备树万万不能设备树Device Tree是嵌入式Linux驱动开发绕不过去的坎。它的本质是一份用特定语法描述的硬件配置清单内核启动时会解析这份清单把硬件资源转换成platform_device然后和注册的platform_driver进行匹配。很多人学设备树的时候喜欢死记语法什么compatible、reg、interrupts、clocks背了一堆但不知道为什么要这么写。我的建议是先理解内核怎么用这些属性再回头记语法。比如compatible属性它的作用就是匹配驱动。内核里有一个of_match_table驱动注册时会把自己的compatible字符串填进去内核遍历设备树节点找到匹配的就调用驱动的probe函数。就这么简单。reg属性描述的是寄存器地址范围格式是起始地址 长度。interrupts描述中断信息通常需要配合中断控制器。clocks和clock-names描述时钟源因为很多外设需要先使能时钟才能访问寄存器。这些属性不是随便定的每一个都对应内核里的一段解析代码。注意设备树里的reg地址是物理地址驱动里用ioremap()映射之后才能访问。不要直接对物理地址解引用会出问题。2.3 字符设备框架的演进从register_chrdev到cdev早期Linux内核用register_chrdev()注册字符设备简单粗暴但有个致命问题它一次性占用主设备号下的所有次设备号。如果你只需要一个设备却占用了256个次设备号其他驱动就没得用了。后来引入了cdev机制可以精确注册次设备号范围配合alloc_chrdev_region()动态分配主设备号灵活了很多。现在的标准做法是用alloc_chrdev_region()申请设备号用cdev_init()和cdev_add()注册字符设备用class_create()和device_create()自动创建设备节点。这样加载驱动之后/dev下面会自动出现设备文件不需要手动mknod。我见过很多教程还在用register_chrdev()不是说不能用但在新项目里最好用新接口。内核社区对旧接口的维护力度在下降而且新接口的扩展性更好。比如你要支持多个次设备号cdev机制天然支持register_chrdev就得自己想办法。3. 核心细节解析与实操要点3.1 GPIO驱动最简单的驱动也有坑GPIO驱动是入门首选但里面有不少细节容易翻车。首先是GPIO编号的计算。在嵌入式Linux里GPIO通常按组管理比如GPIO1_IO05这种命名。内核里用gpio_request()申请GPIO时需要传入一个全局编号。这个编号的计算方式是组号 * 每组引脚数 组内偏移。不同芯片的每组引脚数不一样i.MX6ULL是32STM32MP1也是32但有些芯片是16。这个信息在芯片手册的GPIO章节里有。其次是方向设置。gpio_direction_output()和gpio_direction_input()必须在gpio_request()之后调用否则会失败。输出电平用gpio_set_value()读取用gpio_get_value()。注意gpio_get_value()在输出模式下也能读读的是实际引脚电平不是输出寄存器里的值。这个区别在调试的时候很有用比如你设置了高电平但读回来是低说明引脚被外部电路拉低了。还有一个坑是GPIO的复用功能。很多引脚默认不是GPIO功能而是其他外设功能比如UART、I2C。你需要先通过IOMUX控制器把引脚配置成GPIO模式才能用GPIO接口操作。这个配置通常在设备树里用pinctrl节点描述驱动里不需要手动改寄存器。/* 典型的GPIO操作流程 */ int gpio_num of_get_named_gpio(dev-of_node, led-gpios, 0); if (!gpio_is_valid(gpio_num)) { dev_err(dev, invalid GPIO\n); return -EINVAL; } ret devm_gpio_request(dev, gpio_num, led); if (ret) { dev_err(dev, failed to request GPIO\n); return ret; } gpio_direction_output(gpio_num, 0); gpio_set_value(gpio_num, 1);用devm_前缀的函数有个好处设备卸载时自动释放资源不需要手动gpio_free()。这个机制叫“设备资源管理”能省掉很多清理代码也避免了资源泄漏。3.2 中断处理上半部和下半部怎么分中断是驱动开发里最容易出问题的地方。核心原则就一条中断处理函数要尽可能短。为什么因为中断处理期间CPU不能响应同级和更低优先级的中断如果处理时间太长系统响应会变差甚至丢中断。Linux把中断处理分成上半部top half和下半部bottom half。上半部就是request_irq()注册的那个函数它在中断上下文中执行不能睡眠不能调用可能阻塞的函数。下半部有几种实现方式softirq、tasklet、workqueue。softirq性能最好但用得少tasklet基于softirq但更易用workqueue运行在进程上下文可以睡眠。选择哪种下半部取决于你的任务特性。如果任务很短且不需要睡眠用tasklet。如果任务可能睡眠比如要调用I2C读写必须用workqueue。我见过有人在中断上半部里调用i2c_transfer()结果系统直接卡死因为I2C传输会睡眠而中断上下文不允许睡眠。/* 中断上半部 */ static irqreturn_t my_interrupt_handler(int irq, void *dev_id) { struct my_dev *dev dev_id; /* 清除中断标志 */ writel(0x1, dev-base INT_STATUS_REG); /* 调度下半部 */ schedule_work(dev-work); return IRQ_HANDLED; } /* 下半部 */ static void my_work_handler(struct work_struct *work) { struct my_dev *dev container_of(work, struct my_dev, work); /* 这里可以睡眠可以调用I2C/SPI */ i2c_transfer(dev-client-adapter, msg, 1); }中断共享也是常见需求。多个设备共用一根中断线时request_irq()的flags要加IRQF_SHARED并且每个设备都要传一个唯一的dev_id。中断处理函数里要先判断是不是自己的中断不是就返回IRQ_NONE。这个判断通常靠读中断状态寄存器实现。3.3 并发控制自旋锁和互斥锁怎么选驱动运行在多核系统上并发访问是常态。用户态可能有多个进程同时打开设备文件中断可能随时打断驱动代码内核线程也可能访问共享数据。不加锁数据竞争迟早出问题。Linux提供了几种锁机制自旋锁spinlock、互斥锁mutex、信号量semaphore、读写锁rwlock。选择依据是临界区会不会睡眠。不会睡眠用自旋锁会睡眠用互斥锁。中断上下文只能用自旋锁因为中断上下文不允许睡眠。自旋锁的原理是忙等待获取不到锁就原地循环。所以临界区必须非常短否则浪费CPU。互斥锁获取不到会睡眠让出CPU给其他任务适合临界区较长的场景。/* 自旋锁示例中断上下文和进程上下文共享数据 */ static DEFINE_SPINLOCK(my_lock); static int shared_data; /* 进程上下文 */ static ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { unsigned long flags; spin_lock_irqsave(my_lock, flags); shared_data 1; spin_unlock_irqrestore(my_lock, flags); return count; } /* 中断上下文 */ static irqreturn_t my_isr(int irq, void *dev_id) { spin_lock(my_lock); shared_data 2; spin_unlock(my_lock); return IRQ_HANDLED; }注意spin_lock_irqsave()和spin_lock()的区别。如果中断上下文也会获取同一把锁进程上下文必须用spin_lock_irqsave()在获取锁的同时关中断否则可能死锁进程拿了锁中断来了想拿同一把锁中断自旋等待但进程被中断打断无法释放锁死锁。这个坑我踩过调试了一整天才定位到。3.4 设备树与驱动匹配的完整流程设备树节点和驱动的匹配过程很多人只知道compatible属性但不知道内核具体怎么走的。完整流程是这样的内核启动时解析DTB为每个节点创建device_node然后对于有compatible属性的节点创建platform_device。驱动注册时platform_driver结构体里的of_match_table包含一组compatible字符串。内核遍历所有platform_device用of_match_node()比较compatible匹配成功就调用驱动的probe函数。probe函数里要做几件事获取设备树里的资源寄存器地址、中断号、GPIO、时钟等初始化硬件注册字符设备或输入设备等接口。如果probe返回非零匹配失败驱动不会加载。static const struct of_device_id my_of_match[] { { .compatible vendor,my-device }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_of_match); static struct platform_driver my_driver { .probe my_probe, .remove my_remove, .driver { .name my-device, .of_match_table my_of_match, }, }; module_platform_driver(my_driver);MODULE_DEVICE_TABLE的作用是让内核在模块加载前就知道这个驱动支持哪些设备方便自动加载。没有这个宏模块需要手动insmod。4. 实操过程与核心环节实现4.1 从零写一个完整的platform驱动我拿一个实际的温湿度传感器驱动为例传感器是SHT30通过I2C接口通信。这个例子涵盖了platform驱动、I2C子系统、字符设备、sysfs等核心知识点。第一步是写设备树节点。SHT30挂在I2C1总线上地址是0x44。设备树里这样描述i2c1 { status okay; clock-frequency 100000; sht30: sht3044 { compatible vendor,sht30; reg 0x44; status okay; }; };第二步是写驱动代码。SHT30的驱动可以基于I2C驱动框架写也可以用platform驱动加i2c_client的方式。我选择I2C驱动框架因为传感器本身就是I2C设备用I2C框架更自然。#include linux/module.h #include linux/i2c.h #include linux/fs.h #include linux/cdev.h #include linux/uaccess.h #define SHT30_MEASURE_HIGH 0x2C06 #define SHT30_READ_LEN 6 struct sht30_dev { struct i2c_client *client; struct cdev cdev; dev_t devno; struct class *class; struct device *device; struct mutex lock; }; static int sht30_read_temp_humi(struct sht30_dev *dev, int *temp, int *humi) { u8 cmd[2] { SHT30_MEASURE_HIGH 8, SHT30_MEASURE_HIGH 0xFF }; u8 buf[SHT30_READ_LEN]; struct i2c_msg msgs[2]; int ret; msgs[0].addr dev-client-addr; msgs[0].flags 0; msgs[0].len 2; msgs[0].buf cmd; msgs[1].addr dev-client-addr; msgs[1].flags I2C_M_RD; msgs[1].len SHT30_READ_LEN; msgs[1].buf buf; ret i2c_transfer(dev-client-adapter, msgs, 2); if (ret 0) return ret; *temp (buf[0] 8) | buf[1]; *humi (buf[3] 8) | buf[4]; return 0; }第三步是注册字符设备提供open、read、release接口。read接口里调用sht30_read_temp_humi()把温湿度数据拷贝到用户空间。static ssize_t sht30_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { struct sht30_dev *dev filp-private_data; int temp, humi; char kbuf[64]; int len; if (mutex_lock_interruptible(dev-lock)) return -ERESTARTSYS; if (sht30_read_temp_humi(dev, temp, humi)) { mutex_unlock(dev-lock); return -EIO; } len snprintf(kbuf, sizeof(kbuf), temp%d.%d humi%d.%d\n, temp / 100, temp % 100, humi / 100, humi % 100); if (copy_to_user(buf, kbuf, len)) { mutex_unlock(dev-lock); return -EFAULT; } mutex_unlock(dev-lock); return len; }第四步是probe函数初始化设备、注册字符设备、创建sysfs节点。static int sht30_probe(struct i2c_client *client, const struct i2c_device_id *id) { struct sht30_dev *dev; int ret; dev devm_kzalloc(client-dev, sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; dev-client client; i2c_set_clientdata(client, dev); mutex_init(dev-lock); ret alloc_chrdev_region(dev-devno, 0, 1, sht30); if (ret) return ret; cdev_init(dev-cdev, sht30_fops); dev-cdev.owner THIS_MODULE; ret cdev_add(dev-cdev, dev-devno, 1); if (ret) goto err_cdev; dev-class class_create(THIS_MODULE, sht30); if (IS_ERR(dev-class)) { ret PTR_ERR(dev-class); goto err_class; } dev-device device_create(dev-class, NULL, dev-devno, NULL, sht30); if (IS_ERR(dev-device)) { ret PTR_ERR(dev-device); goto err_device; } dev_info(client-dev, sht30 probed successfully\n); return 0; err_device: class_destroy(dev-class); err_class: cdev_del(dev-cdev); err_cdev: unregister_chrdev_region(dev-devno, 1); return ret; }这个驱动加载之后/dev/sht30会出现cat /dev/sht30就能读到温湿度数据。整个过程涵盖了I2C通信、字符设备、互斥锁、设备资源管理是一个比较完整的驱动开发案例。4.2 中断按键驱动的非阻塞扫描实现按键驱动是另一个经典场景。很多教程用轮询方式读按键但轮询浪费CPU而且响应不及时。正确做法是用中断按键按下时触发中断在中断下半部里处理消抖和键值上报。非阻塞扫描的意思是用户态读取按键时如果没有按键事件read()应该立即返回-EAGAIN而不是阻塞等待。这需要驱动支持O_NONBLOCK标志并且用等待队列实现阻塞和非阻塞两种模式。static ssize_t key_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { struct key_dev *dev filp-private_data; int ret; if (filp-f_flags O_NONBLOCK) { if (kfifo_is_empty(dev-fifo)) return -EAGAIN; } else { if (wait_event_interruptible(dev-wq, !kfifo_is_empty(dev-fifo))) return -ERESTARTSYS; } ret kfifo_out(dev-fifo, dev-key_val, sizeof(dev-key_val)); if (ret ! sizeof(dev-key_val)) return -EIO; if (copy_to_user(buf, dev-key_val, sizeof(dev-key_val))) return -EFAULT; return sizeof(dev-key_val); }中断处理里用kfifo缓存键值然后唤醒等待队列。kfifo是内核提供的无锁环形缓冲区适合单生产者单消费者场景中断里写入用户态读取正好匹配。消抖用定时器实现。按键中断触发后启动一个10ms的定时器定时器到期后再读引脚电平如果还是按下状态才认为是有效按键。这样避免了机械抖动导致的多次触发。static irqreturn_t key_isr(int irq, void *dev_id) { struct key_dev *dev dev_id; mod_timer(dev-debounce_timer, jiffies msecs_to_jiffies(10)); return IRQ_HANDLED; } static void debounce_timer_func(struct timer_list *t) { struct key_dev *dev from_timer(dev, t, debounce_timer); int val gpio_get_value(dev-gpio); if (val 0) { dev-key_val dev-key_code; kfifo_in(dev-fifo, dev-key_val, sizeof(dev-key_val)); wake_up_interruptible(dev-wq); } }这个方案实测很稳按键响应延迟在10ms左右CPU占用几乎为零。比轮询方案优雅得多。4.3 嵌入式Linux根文件系统挂载与NFS调试开发阶段用NFS挂载根文件系统能极大提高效率。每次改驱动不用重新烧写整个系统编译完模块直接insmod就行。配置NFS根文件系统需要几个步骤。首先是主机端配置。安装NFS服务编辑/etc/exports添加导出目录/home/rootfs *(rw,sync,no_root_squash,no_subtree_check)no_root_squash很重要否则客户端root用户会被映射成nobody导致权限问题。然后启动NFS服务sudo exportfs -a sudo systemctl restart nfs-kernel-server目标板端需要配置内核支持NFS根文件系统启动参数里指定setenv bootargs consolettyS0,115200 root/dev/nfs rw nfsroot192.168.1.100:/home/rootfs,v3,tcp ip192.168.1.200v3指定NFS版本3tcp指定传输协议。有些老内核只支持NFS v2需要根据实际情况调整。ip参数是目标板的IP地址必须和主机在同一网段。注意NFS根文件系统依赖网络如果网络不稳定系统会卡死。生产环境不要用NFS只适合开发调试。我踩过的一个坑是主机防火墙没关NFS端口被挡了目标板一直卡在“VFS: Cannot open root device”。排查了半天才发现是防火墙问题。所以调试NFS的时候先把防火墙关了确认能挂载之后再慢慢加规则。5. 常见问题与排查技巧实录5.1 驱动加载失败怎么查驱动加载失败是最常见的问题insmod之后报错信息往往很模糊。我的排查顺序是这样的先看dmesg内核打印的错误信息比insmod的返回值详细得多。常见错误有-ENODEV设备没匹配上、-EBUSY资源被占用、-EINVAL参数无效。-ENODEV通常是设备树compatible不匹配检查设备树节点和驱动的of_match_table是否一致。-EBUSY可能是GPIO或中断被其他驱动占了用cat /proc/gpio和cat /proc/interrupts查看。然后看/sys/bus/platform/drivers/下面有没有你的驱动目录。如果有目录但里面没有设备链接说明驱动注册了但没匹配到设备。如果连目录都没有说明驱动注册本身失败了。还有一个工具是devmem可以直接读写物理地址验证硬件是否正常。比如你怀疑GPIO寄存器没配置对用devmem 0x020A0000读一下IOMUX寄存器的值和手册对比。5.2 中断不触发的排查思路中断不触发的原因很多我整理了一个排查表现象可能原因排查方法中断完全没反应中断号错误查设备树interrupts属性对比手册中断偶尔触发中断标志没清除检查ISR里是否清中断标志中断触发但数据不对寄存器地址错误用devmem读寄存器验证中断触发后系统卡死ISR里睡眠检查ISR是否调用了可能睡眠的函数共享中断不触发没加IRQF_SHARED检查request_irq的flags中断号错误是最常见的。设备树里的interrupts属性格式是中断类型 中断号 触发方式不同中断控制器的编码方式不一样。比如GIC控制器SPI中断的号是中断号 - 32。这个转换关系在手册的中断章节里有。5.3 内存泄漏和竞态条件的调试驱动里的内存泄漏很难发现因为内核模块卸载时不会自动释放kmalloc的内存。用devm_kzalloc()可以避免大部分泄漏但有些场景必须手动管理。/proc/meminfo可以看整体内存变化但精度不够。更好的工具是kmemleak内核配置里打开CONFIG_DEBUG_KMEMLEAK然后echo scan /sys/kernel/debug/kmemleak触发扫描cat /sys/kernel/debug/kmemleak查看泄漏点。竞态条件更隐蔽可能运行几天才出一次问题。KASANKernel Address Sanitizer能检测越界访问和释放后使用CONFIG_KASANy打开。lockdep能检测锁的顺序问题CONFIG_PROVE_LOCKINGy打开。这两个工具会拖慢系统只在调试阶段用。我遇到过一个经典竞态驱动里用全局变量缓存数据中断里写用户态读没加锁。大部分时候没问题但压力测试跑几个小时就会读到半新半旧的数据。后来加了自旋锁就解决了。所以共享数据一定要加锁不要心存侥幸。5.4 嵌入式面试中驱动相关的高频问题面试嵌入式驱动岗位有几个问题几乎必问。第一个是“中断上半部和下半部的区别”这个前面讲过了。第二个是“自旋锁和互斥锁的区别及使用场景”核心就是会不会睡眠。第三个是“设备树的作用和匹配流程”要能说清楚compatible、probe、of_match_table的关系。还有一个高频问题是“驱动里怎么实现阻塞和非阻塞IO”。答案是用等待队列非阻塞模式下检查条件不满足就返回-EAGAIN阻塞模式下用wait_event_interruptible()睡眠等待。poll接口也要实现支持select和epoll。“字符设备和块设备的区别”也是常问的。字符设备按字节流访问不支持随机访问比如串口、按键。块设备按块访问支持随机访问有缓存机制比如硬盘、Flash。块设备的驱动框架比字符设备复杂得多有bio、request queue、电梯算法等。6. 驱动开发的进阶方向与学习路线6.1 从字符设备到子系统框架字符设备驱动是基础但实际项目里很少从头写字符设备。大部分外设都有现成的子系统框架I2C设备用I2C驱动框架SPI设备用SPI框架输入设备用input子系统显示设备用framebuffer或DRM网络设备用netdev。这些框架帮你处理了大部分通用逻辑你只需要实现硬件相关的部分。以input子系统为例按键驱动不需要自己实现字符设备只需要调用input_allocate_device()、input_set_capability()、input_register_device()然后在中段里用input_report_key()和input_sync()上报事件。内核会自动创建/dev/input/eventX设备节点用户态用evdev接口读取。这样代码量少了很多而且兼容标准输入协议。6.2 GPU驱动和AI加速器驱动的门槛GPU驱动和AI加速器驱动是嵌入式驱动里门槛最高的方向。GPU驱动涉及图形管线、着色器、内存管理、命令调度代码量动辄几十万行。AI加速器驱动涉及DMA、张量计算、量化、算子融合需要对深度学习框架有理解。这两个方向的学习路径是先精通字符设备和DMA驱动理解内存映射和缓存一致性然后学习具体硬件的架构手册。GPU驱动可以看Mesa和DRM的源码AI加速器驱动可以看NPU厂商的开源驱动。门槛高意味着竞争少薪资也高但需要投入大量时间。6.3 嵌入式AI测试与驱动的关系嵌入式AI测试是最近的热词它和驱动开发的关系在于AI模型跑在NPU上NPU驱动负责把模型加载到硬件、分配内存、调度计算任务。测试AI性能时驱动层的效率直接影响推理延迟。比如DMA传输没用好数据搬运时间可能比计算时间还长。做嵌入式AI测试需要关注几个驱动层指标内存分配延迟、DMA带宽、中断响应时间、功耗。这些指标用perf、ftrace、powertop等工具测量。如果发现推理延迟高先查驱动层有没有优化空间再查模型本身。6.4 学习路线建议如果你刚入行我的建议是先玩STM32裸机理解寄存器操作和中断。然后上嵌入式Linux学设备树、字符设备、platform驱动。接着学一个子系统框架I2C或SPI都行。最后找一个实际项目练手比如用I2C传感器做数据采集用SPI屏幕做显示用GPIO做控制。书方面《Linux设备驱动开发详解》和《深入理解Linux内核》是必读的。源码方面从drivers/char/下面的简单驱动看起然后看drivers/i2c/和drivers/spi/。社区方面内核邮件列表和lwn.net有很多高质量文章。我个人在实际操作中的体会是驱动开发最难的不是写代码而是调试。硬件不会说话你只能通过寄存器和波形来判断问题。逻辑分析仪和示波器是必备工具很多时候比打印调试管用得多。还有就是要养成看手册的习惯芯片手册里什么都有只是需要耐心找。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。