Zephyr驱动程序框架简介

这篇具有很好参考价值的文章主要介绍了Zephyr驱动程序框架简介。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

引言

Zephyr为驱动提供一套具体的驱动框架模型,开发者可根据这一套驱动框架模型来实现自己的驱动,这一套模型非常类似Linux内核的驱动实现,如果你对Linux内核驱动模型或有Linux内核驱动开发相关经验那么学习起来会非常轻松与简单。

驱动模型框架是使用了结构化的方式描述驱动,每个驱动都有等级,等级在Zephyr内部已经规定好了,每个等级对应不同的阶段,Zephyr在启动过程中会根据等级来依次初始化这些驱动,同时在不同的阶段下某些内核服务的可用性也是不同的。

一些通用类型的驱动 (常见驱动) Zephyr给出了具体实现,例如:I2CGPIOUSARTSPI..., 当然也存在一些非通用设备例如SPDIF、CAMERA、LCD-DSI、I2S..., 这些非通用设备设备并没有给出具体的实现,但给出了整体框架,开发者应遵守框架模型来完成自己的实现,类型Linux里的VFS子系统调用。

非通用设备也许在单片机/嵌入式领域比较少见,但在PC端是常见设备,针对不同的领域上这些设备的体现也不同,因为在PC端VideoCAMERAUSB这些是必备设备驱动

下图为Zephyr驱动框架模型图

zephyr 驱动,# Zephyr,驱动开发,zephyr,驱动框架,Powered by 金山文档

开始之前的准备

本文需要对驱动有一定概念,在开始之前简单介绍一下什么是驱动,以及驱动(Drive)设备(Device)之间的关系。

驱动是为设备准备的,你可以把它理解为驾驶员,而设备就是车,驱动是用于控制设备的,例如单片机设备上有一个LED灯,如果想让它亮起来就需要有一段代码来对它进行控制,这段代码必须是以接口形式或服务的形式提供而非直接在main函数里跑的裸机代码,这段代码就称为驱动。

当然代码没有规定,刚刚提到的驱动代码形式规定是基于System的,你完全可以按照你的想法与风格来实现驱动,简而言之控制设备的代码称为驱动,驱动就是软件部分而设备就是硬件部分。

通常设备中也具有一些固件代码,通常驱动是修改设备提供的特殊功能寄存器里,而设备里的固件检测到这些电平的变化之后会去控制设备工作,例如USARTCamera... 当然也有一些设备是纯电路实现,没有固件例如小马达电机。

zephyr 驱动,# Zephyr,驱动开发,zephyr,驱动框架,Powered by 金山文档

标准驱动

Zephyr在对芯片做适配时会完成标准驱动的实现,迄今为止Zephyr已经完成了许多芯片架构的适配,这些芯片上所支持的驱动可能有所不同但它们都具有标准驱动的功能,可以理解为Zephyr最小功能、基础功能:

Interrupt controller (中断控制器驱动):

为内核里中断管理子系统提供中断控制服务


Timer (计时器驱动):

为内核里的系统时钟、硬件时钟子系统提供服务


Serial communication (串行通讯驱动):

为内核TTY终端子系统提供服务、例如printk、log这些都属于TTY终端子系统提供的应用层API其子系统底层使用的是这个驱动


Entropy (熵驱动):

为内核随机数子系统提供服务, 用于生成随机数。

熵驱动应用层不能直接调用, 需使用应用层API, 这个与底层实现不同, 在硬件方面随机数有软件算法实现与硬件实现 (例如RNG)。

Zephyr建议应用层开发者不应直接调用熵驱动API, 应使用提供的应用层API, 应用层API对熵驱动做了封装内部会根据硬件环境来做不同的工作, 这样能确保随机数生成的准确性。

本段落引用Zephyr 3.3的开发者文档,未来可能会支持更多的标准驱动, 更多可以参考: Zephyr standard-drivers

初始化流程

在Zephyr启动过程中会跳转到C启动函数z_cstart, 在这个函数里里会对内核服务驱动初始化,在初始化过程中根据驱动的等级来决定驱动在什么阶段下初始化,下面是Zephyr定义的几种驱动等级:

INIT_LEVEL_EARLY:

最优先初始化的驱动等级,在这个过程中内核服务还没有初始化,这个过程中初始化的驱动不能使用Zephyr内核提供的服务

INIT_LEVEL_PRE_KERNEL_1、INIT_LEVEL_PRE_KERNEL_2:

在这个阶段针对开发板的硬件服务完成了初始化还有一些内核服务完成了初始化,这个过程中初始化的驱动可以使用Zephyr内核提供的硬件服务与内核服务API (例如Printk)

INIT_LEVEL_POST_KERNEL:

这个阶段下内核服务可以说是完全初始化了,内核的子系统也全部初始化完成,可以使用Zephyr内存管理子系统,例如Malloc

INIT_LEVEL_APPLICATION:

这个是最后的阶段,在这个阶段下所有的服务以及堆栈都已经完成初始化,在进入应用层之前做最后一次初始化


以上这些阶段可以在Zephyr源代码目录里kernel/init.c:z_cstart里找到它们的初始化代码

z_cstart函数会完成对INIT_LEVEL_EARLY、INIT_LEVEL_PRE_KERNEL_1、INIT_LEVEL_PRE_KERNEL_2的初始化

FUNC_NO_STACK_PROTECTOR
FUNC_NORETURN void z_cstart(void)
{
        /* gcov hook needed to get the coverage report.*/
        gcov_static_init();

        /* initialize early init calls */
        z_sys_init_run_level(INIT_LEVEL_EARLY);

        /* perform any architecture-specific initialization */
        arch_kernel_init();

        LOG_CORE_INIT();

#if defined(CONFIG_MULTITHREADING)
        /* Note: The z_ready_thread() call in prepare_multithreading() requires
        ¦* a dummy thread even if CONFIG_ARCH_HAS_CUSTOM_SWAP_TO_MAIN=y
        ¦*/
        struct k_thread dummy_thread;

        z_dummy_thread_init(&dummy_thread);
#endif
        /* do any necessary initialization of static devices */
        z_device_state_init();

          /* perform basic hardware initialization */
        z_sys_init_run_level(INIT_LEVEL_PRE_KERNEL_1);
        z_sys_init_run_level(INIT_LEVEL_PRE_KERNEL_2);

#ifdef CONFIG_MULTITHREADING
        switch_to_main_thread(prepare_multithreading());
#else
...

prepare_multithreading函数里会去启动bg_thread_main线程,这个线程里会完成INIT_LEVEL_POST_KERNEL、INIT_LEVEL_APPLICATION的初始化

__boot_func
static void bg_thread_main(void *unused1, void *unused2, void *unused3)
{
        ARG_UNUSED(unused1);
        ARG_UNUSED(unused2);
        ARG_UNUSED(unused3);

#ifdef CONFIG_MMU
        /* Invoked here such that backing store or eviction algorithms may
        ¦* initialize kernel objects, and that all POST_KERNEL and later tasks
        ¦* may perform memory management tasks (except for z_phys_map() which
        ¦* is allowed at any time)
        ¦*/
        z_mem_manage_init();
#endif /* CONFIG_MMU */
        z_sys_post_kernel = true;

        z_sys_init_run_level(INIT_LEVEL_POST_KERNEL);
#if CONFIG_STACK_POINTER_RANDOM
        z_stack_adjust_initialized = 1;
#endif
        boot_banner();

#if defined(CONFIG_CPP)
        void z_cpp_init_static(void);
        z_cpp_init_static();
 /* Final init level before app starts */
        z_sys_init_run_level(INIT_LEVEL_APPLICATION);

        z_init_static_threads();

#ifdef CONFIG_KERNEL_COHERENCE
        __ASSERT_NO_MSG(arch_mem_coherent(&_kernel));
#endif

#ifdef CONFIG_SMP
        if (!IS_ENABLED(CONFIG_SMP_BOOT_DELAY)) {
                z_smp_init();
        }
        z_sys_init_run_level(INIT_LEVEL_SMP);
#endif
...
}

在每个不同的阶段下,还有不同优先级的初始化,Zephyr在实现这一点时设计方式很巧妙。

Zephyr里面定义了许多的,这些段用于存放不同的对象,驱动根据等级数量定义了同等数量的段,可以看到如下的Link脚本:

这段脚本是由Zephyr Build脚本整合到Build目录下的link.cmd文件里的

initlevel :
 {
  __init_start = .;
  __init_EARLY_start = .; KEEP(*(SORT(.z_init_EARLY[0-9]_*))); KEEP(*(SORT(.z_init_EARLY[1-9][0-9]_*)));
  __init_PRE_KERNEL_1_start = .; KEEP(*(SORT(.z_init_PRE_KERNEL_1[0-9]_*))); KEEP(*(SORT(.z_init_PRE_KERNEL_1[1-9][0-9]_*)));
  __init_PRE_KERNEL_2_start = .; KEEP(*(SORT(.z_init_PRE_KERNEL_2[0-9]_*))); KEEP(*(SORT(.z_init_PRE_KERNEL_2[1-9][0-9]_*)));
  __init_POST_KERNEL_start = .; KEEP(*(SORT(.z_init_POST_KERNEL[0-9]_*))); KEEP(*(SORT(.z_init_POST_KERNEL[1-9][0-9]_*)));
  __init_APPLICATION_start = .; KEEP(*(SORT(.z_init_APPLICATION[0-9]_*))); KEEP(*(SORT(.z_init_APPLICATION[1-9][0-9]_*)));
  __init_SMP_start = .; KEEP(*(SORT(.z_init_SMP[0-9]_*))); KEEP(*(SORT(.z_init_SMP[1-9][0-9]_*)));
  __init_end = .;
 } > RAM

在Zephyr里它会将驱动根据等级放入名为z_init_Levelprio的段里,例如你的驱动等级是PRE_KERNEL_1,优先级是5,那么就会放入到z_init_PRE_KERNEL_15段里,这些是通过GCC__attribute__((__section__))特性实现的,同时可以看到Link脚本里使用了LD函数,SORT,它会对段进行排序,排序的方式与SORT函数有关,SORT比较方法与STRCMP类似,它会将段名字的ASCII码相减,将结果从小到大进行排序。

它在排序时可以看到它用了正则参数:

KEEP(*(SORT(.z_init_PRE_KERNEL_1[0-9]_*))); KEEP(*(SORT(.z_init_PRE_KERNEL_1[1-9][0-9]_*)))

排序了两次,首先排序的是z_init_PRE_KERNEL_1[0-9]_*,这段代码代表排序9以内编号的段,然后在排序10->99之间编号,这就代表Zephyr将驱动分为了两组,一组是编号0-9以内的,另一组是10-99以内的,这也意味着我们驱动优先级最大是99

最后用__init_EARLY_start...指向这些段的首地址,然后在c语言里引用它们,引用函数在kernel/init.c:z_sys_init_run_level

static void z_sys_init_run_level(enum init_level level)
{
        static const struct init_entry *levels[] = { 
                __init_EARLY_start,  
                __init_PRE_KERNEL_1_start,
                __init_PRE_KERNEL_2_start,
                __init_POST_KERNEL_start,
                __init_APPLICATION_start,
#ifdef CONFIG_SMP
                __init_SMP_start,
#endif
                /* End marker */    
                __init_end,
        };
        const struct init_entry *entry;

        for (entry = levels[level]; entry < levels[level+1]; entry++) {
                const struct device *dev = entry->dev;
                int rc = entry->init(dev);

                if (dev != NULL) {
                        /* Mark device initialized.  If initialization
                         * failed, record the error condition.
                         */
                        if (rc != 0) {
                                if (rc < 0) {
                                        rc = -rc;
                                }
                                if (rc > UINT8_MAX) {
                                        rc = UINT8_MAX;
                                }
                                dev->state->init_res = rc;
                        }
                        dev->state->initialized = true;
                }
        }
}

在引用时,段已经从小到大排序好了,所以只需要通过指针指向段首地址,像链表一样去调用,然后依次调用init函数就完成了驱动的初始化,需要值得注意的是Zephyr在调用init函数指针时并没有判断是否为空指针,也就意味着如果你注册的驱动如果没有给init会导致call 0的操作,从而引发地址异常中断导致内核崩溃。

驱动API

以下API仅供驱动使用, 驱动API存放于device.h文件里

DEVICE_DEFINE

作用

创建设备对象,同时将设备设置为启动初始化,需要开发者指定初始化函数,由这个宏创建的设备对象会在z_cstart函数里进行初始化,此宏函数不能用于基于设备树节点来创建的设备对象

函数原型

DEVICE_DEFINE (dev_id, name, init_fn, pm, data, config, level, prio, api)

参数

  • dev_id - 全局设备结构体名称,用作变量名


  • name - 设备的字符串名称,将存储在device结构体里的name变量中。此名称可用于使用device_get_binding函数查找绑定设备。这必须少于Z_DEVICE_MAX_NAME_LEN (截止目前Zephyr 3.3 这个宏定义值为48)个字符(包括终止NULL),才能从用户模式进行查找。


  • init_fn - 指向设备初始化函数,在初始化阶段会被调用,此函数指针不能为NULL, ini函数原型要求:int (*init_func)(const struct device *port)


  • pm - 指向电源管理资源,如果没有设置为空,如果有将会保存到device结构体里的pm变量中,可以为NULL


  • data - 指向设备的私有可变数据的指针,该数据将存储在device结构体里data变量中,可以为NULL


  • config - 指向设备的私有常量数据的指针,该数据将存储在device结构体里的config变量中,这个变量是常量一旦指定就不可变更,可以为NULL


  • level - 设备的初始化等级


  • prio - 设备在不同等级下的初始化优先级


  • api - 驱动接口指针,提供给应用层的API,可以为NULL

DEVICE_NAME_GET

作用

用于展开DEVICE_DEFINEdev_id定义的结构体全名,DEVICE_DEFINE内部在使用dev_id定义全局设备对象结构体变量名称时会做拼接,这个宏函数可以用来获取拼接后的名称,仅用于预编译阶段使用。

函数原型

DEVICE_NAME_GET (dev_id)

参数

  • dev_id - 设备标识符

返回值

返回展开后的全局设备对象变量名

DEVICE_GET

作用

根据dev_id获取指向DEVICE_DEFINE创建的全局设备对象结构体指针

函数原型

DEVIE_GET (dev_id)

参数

  • dev_id - 设备标识符

返回值

返回设备对象结构体指针

DEVICE_NAME_GET

作用

用于展开DEVICE_DEFINEdev_id定义的结构体全名,DEVICE_DEFINE内部在使用dev_id定义全局设备对象结构体变量名称时会做拼接,这个宏函数可以用来获取拼接后的名称,仅用于预编译阶段使用。

函数原型

DEVICE_NAME_GET (dev_id)

参数

  • dev_id - 设备标识符

返回值

返回展开后的全局设备对象变量名

DEVICE_DECLARE

作用

声明静态设备结构体对象,需要在代码顶层使用它,它的作用是定义一个与全局设备对象无关的静态设备对象,它只能在当前代码文件中使用,它的目的是为了解决循环依赖的问题,例如当你注册IRQ时,这个时候在DEVICE_DEFINE函数还没有定义全局设备对象时你是无法使用全局设备对象的,所以可以定义一个相当于私有的静态设备对象,供你使用,可以将中断与设备对象区分开,同时定义的静态设备结构体对象是不会参与初始化的,其实就相当于定义了一个静态的struct device结构体。

函数原型

DEVICE_DECLARE (dev_id)

参数

  • dev_id - 设备标识符

设备对象结构体

结构体定义

struct device {
      const char *name;
      const void *config;
      const void *api;
      void * const data;
};

成员变量作用

const char *name:

设备对象名称, 可用于device_get_binding函数查找绑定设备

const void *config:

设备配置常量数据结构体

const void *api:

设备API指针

void * const data:

设备数据结构体,该指针常量,但要求data为常量

驱动项目组成部分

在Zephyr驱动框架中,你的驱动应由如下几个部分组成:

  1. src (源代码目录) (必须)

  1. Kconfig (内核配置文件) (必须)

  1. CMakeLists.txt (编译结构描述) (必须)

驱动项目文件夹应存放在$ZEPHYR_HOME/drivers目录下

在Zephyr的drivers目录下有CMakeLists.txt文件,这个文件里定义了哪些子模块需要编译到Zephyr内核里去,它与driver目录下的Kconfig配合使用

第一个驱动: Hello Word

编写代码

首先在drivers目录下创建我们的driver

mkdir drivers/my_driver

创建第一个Hello word并进入到这个目录里构建我们的项目

mkdir drivers/my_driver/hello_word & cd driver/my_driver/hello_word

创建src目录

mkdir src

创建cmake文件和Kconfig

touch CMakeLists.txt Kconfig

创建include文件用来定义驱动结构体

touch my_driver.h

定义API结构体

typedef void (*Func) (void);
struct my_driver_api {
    Func open;
    Func write;
    Func close;
};

创建main文件并开始编写驱动

touch src/main.c & vim src/main.c

首先包含基础头文件

#include <zephyr/device.h>
#include <zephyr/kernel.h>
#include <my_dirver.h>

然后根据DEVICE_DEFINE函数的要求,创建API接口

在开发过程驱动过程中,驱动API的调用不应是轮询或异步实现的,应是同步的,同时如果驱动可以支持中断的情况下应尽量支持中断,除非硬件环境不支持

void my_driver_open (void) {

    printk("hello word open\n");
}

void my_driver_write (void) {

    printk("hello word write\n")
}

void my_driver_close(void) {

    printk("hello word close\n")
} 

static int my_driver_init(const struct device *port) {
    
    return 0;
}

static const struct my_driver_api api = {
    .open =  my_driver_open,
    .write =  my_driver_write,
    .close = my_driver_close,
};

最后使用DEVICE_DEFINE创建设备对象

DEVICE_DEFINE(my_driver_0, "my_driver", my_driver_init, NULL, NULL, NULL, POST_KERNEL, 0, &api);

编写构建脚本

修改CMakeLists.txt文件,输入如下内容

zephyr_library()
zephyr_include_directories_ifdef(CONFIG_MY_DRIVER include)
zephyr_library_sources_ifdef(CONFIG_MY_DRIVER src/main.c)

zephyr_library: 使用zephyr cmake库

zephyr_include_directories_ifdef: 添加头文件到构建环境

zephyr_library_sources_ifdef: 使用条件编译,如果定义了CONFIG_MY_DRIVER 则将main.c包含进来编译

然后在打开Kconfig文件,对内核进行配置

首先定义一个menuconfig告诉内核,这个CONFIG属性用于配置我们的driver,如果想详细学习Kconfig可以到Linux官网学习Kconfig语法

menuconfig MY_DRIVER
     bool "My driver"
     help
         This is my test driver

因为我们里面使用了LOG功能所以需要将LOG模块编译进来,如果应用层没有开启LOG模块会导致编译出现未定义的情况

config LOG
     default y

config LOG_PRINTK
     default y

加入到内核模块里

打开driver目录下的CMakeLists.txt文件在最后一行加入我们的驱动文件条件编译

add_subdirectory_ifdef(CONFIG_MY_DRIVER my_driver/hello_word)

最后将Kconfig加入到driver目录下的Kconfig文件里

source "drivers/my_driver/hello_word/Kconfig"

编写应用层

在应用层的prj.conf文件里加入我们的驱动条件宏

CONFIG_MY_DRIVER=y

编写调用代码

#include <zephyr/device.h>
#include <zephyr/kernel.h>
#include <my_driver.h>

void main(void) {

     const struct device *dev = device_get_binding("my_driver");
     if(dev == NULL) {
             printk("can't open my_driver\n");
             return;
    }
    
     struct my_driver_api *api = dev->api;
    api->open();
    api->write();
    api->close();
}

构建生成

west build -b qemu_x86 samples/my_test/test_driver

运行:

west build -t run

输出结果:

-- west build: running target run
[0/1] To exit from QEMU enter: 'CTRL+a, x'[QEMU] CPU: qemu32,+nx,+pae
SeaBIOS (version zephyr-v1.0.0-0-g31d4e0e-dirty-20200714_234759-fv-az50-zephyr)
Booting from ROM..
*** Booting Zephyr OS build zephyr-v3.3.0-475-g38f554ef4f99 ***
hello open
hello write
hello close

因为配置了Kconfig,你可以在build时使用menuconfig来查看你的驱动

在Device Driver菜单里文章来源地址https://www.toymoban.com/news/detail-735940.html

zephyr 驱动,# Zephyr,驱动开发,zephyr,驱动框架,Powered by 金山文档

到了这里,关于Zephyr驱动程序框架简介的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • Zephyr 学习笔记(一)

    Zephyr OS 是一个占用空间小的内核,用于资源受限的嵌入式系统:从简单的嵌入式环境传感器、LED 可穿戴设备到复杂的嵌入式控制器、智能手表和物联网无线应用。 Zephyr 内核支持多种架构,包括: ARCv2 (EM and HS) and ARCv3(HS6X) ARMv6-M、ARMv7-M、ARMv8-M ARMv7-A and ARMv8-A (Cortex-A, 32-

    2024年01月21日
    浏览(24)
  • Zephyr mailbox

    mailbox 是Zephyr 中的一个内核对象,它提供了增强的消息队列功能,超越了消息队列对象的能力。邮箱允许线程同步或异步地发送和接收任何大小的消息。 信箱允许线程,但不允许 ISR,交换消息。发送消息的线程被称为发送线程,而接收消息的线程则被称为接收线程。每个消

    2023年04月20日
    浏览(29)
  • Zephyr入门教程 2 线程

    当你开始增加你的嵌入式应用的功能时,在单一的主循环和一些中断例程中做所有的事情变得越来越难。通常情况下,下一级的复杂性是某种状态机,你的电子设备的输出会根据这个(内部)状态而改变。如果你需要能够同时操作多个复杂的输入和输出呢?一个很好的例子是

    2024年02月13日
    浏览(33)
  • Zephyr 设备树中的特殊节点

    在zephyr中包含一部分特殊节点,他们的功能各不相同,节点如下: aliases chosen zephyr,user aliases 是对设备树中其他节点起的别名,别名用于为节点提供较短的名称,该名称可用于设备树的其他部分以引用节点。 在Zephyr中,chosen节点是一个特殊的设备树节点,用于指定一些系统级

    2024年02月02日
    浏览(24)
  • Linux驱动开发—最详细应用程序调用驱动程序解析

    Linux下进行驱动开发,完全将驱动程序与应用程序隔开,中间通过 C标准库函数 以及 系统调用 完成驱动层和应用层的数据交换。 驱动加载成功以后会在“/dev”目录下生成一个相应的文件,应用程序通过 对“/dev/xxx” (xxx 是具体的驱动文件名字) 的文件进行相应的操作 即可实

    2024年02月16日
    浏览(34)
  • windows驱动开发7:应用程序和驱动程序的通信

    一、基础介绍 1.1 设备与驱动的关系 设备由驱动去创建,访问一个设备,是首先得访问驱动。如果驱动在卸载的时候没有删除符号,r3下也是不能去访问设备的。 驱动程序和系统其他组件之间的交互是通过给设备发送或者接受发给设备的请求来交互的。换句话说,一个没有任

    2023年04月08日
    浏览(31)
  • Zephyr-7B-β :类GPT的高速推理LLM

    Zephyr 是一系列语言模型,经过训练可以充当有用的助手。 Zephyr-7B-β 是该系列中的第二个模型,是 Mistralai/Mistral-7B-v0.1 的微调版本,使用直接偏好优化 (DPO) 在公开可用的合成数据集上进行训练 。 我们发现,删除这些数据集的内置对齐可以提高 MT Bench 的性能,并使模型更加有

    2024年02月05日
    浏览(27)
  • 物联网操作系统Zephyr入门教程4调度(scheduling)

    调度器决定哪个线程被允许在任何时间点上执行;这个线程被称为当前线程。 在不同的时间点有机会改变当前线程的身份。这些点被称为重新安排点。一些潜在的重排点是: 从运行状态过渡到暂停或等待状态,例如通过k_sem_take()或k_sleep()。 过渡到准备状态,例如通过k_sem_

    2024年02月13日
    浏览(35)
  • Linux 驱动开发基础知识——Hello驱动程序(一)

     个人名片: 🦁作者简介:一名喜欢分享和记录学习的在校大学生 🐯个人主页:妄北y 🐧个人QQ:2061314755 🐻个人邮箱:2061314755@qq.com 🦉个人WeChat:Vir2021GKBS 🐼 本文由妄北y原创,首发CSDN 🎊🎊🎊 🐨座右铭:大多数人想要改造这个世界,但却罕有人想改造自己。 专栏导

    2024年01月19日
    浏览(32)
  • Linux 驱动开发基础知识——认识LED驱动程序 (二)

     个人名片: 🦁作者简介:一名喜欢分享和记录学习的在校大学生 🐯个人主页:妄北y 🐧个人QQ:2061314755 🐻个人邮箱:2061314755@qq.com 🦉个人WeChat:Vir2021GKBS 🐼 本文由妄北y原创,首发CSDN 🎊🎊🎊 🐨座右铭:大多数人想要改造这个世界,但却罕有人想改造自己。 专栏导

    2024年01月21日
    浏览(31)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包