rt-thread入门之旅(二)—— 从rt_kprintf看RT-Thread的设备驱动框架
1. 从一个简单的打印开始rt_kprintf 的“表面”与“内里”大家好我是老李一个在嵌入式圈子里摸爬滚打了十来年的老码农。今天咱们继续RT-Thread的入门之旅。上一期我们大概搭了个环境点了个灯算是打了个招呼。这一期我想和你聊聊一个你几乎在任何一个RT-Thread例程里都会看到的函数——rt_kprintf。你可能会说这不就是个打印函数嘛跟C语言里的printf有啥区别不就是往串口发个字符串有啥好讲的我刚开始也是这么想的觉得这玩意儿太基础了。但后来当我需要把一个在STM32上跑得好好的程序移植到另一款国产MCU上时问题就来了。我发现我只需要改改底层串口的驱动上层的所有打印代码包括rt_kprintf居然完全不用动那一刻我才意识到这个看似简单的函数背后藏着一套非常精巧的设计思想。rt_kprintf就像一个公司的前台。你作为访客应用程序只需要告诉前台“我要找张三”前台就会帮你联系到张三本人。你不需要知道张三在哪个办公室、分机号是多少、甚至他今天在不在公司。前台rt_kprintf帮你屏蔽了所有这些复杂的内部细节。在RT-Thread里这个“前台”背后连接的就是整个设备驱动框架。我们今天的目标就是顺着rt_kprintf这根藤摸到RT-Thread设备驱动框架这个“大瓜”看看它是如何实现“硬件无关性”让我们写代码可以“一次编写到处运行”的。我们先来看一个最简单的使用场景这也是我们上期点灯程序里用到的rt_kprintf(hello RT-Thread!\n);这行代码一执行“hello RT-Thread!”这串字符就会出现在你的串口调试助手里。表面风平浪静底层却经历了一场精心设计的“接力赛”。这场接力赛的跑道就是RT-Thread的分层架构。我们接下来就一层层剥开看。2. 第一棒内核服务层kservice的封装与缓冲rt_kprintf函数本身住在RT-Thread内核的kservice.c文件里。你可以把它理解为内核提供的一项标准服务。它的首要任务不是直接操作硬件而是做好“格式化”和“缓冲”这两件事。格式化好理解就是处理我们传入的变参...把%d、%s这些占位符替换成真正的数据生成最终的字符串。这和我们平时用的printf是一样的RT-Thread内部用rt_vsnprintf函数来完成这个工作。更有意思的是缓冲。我们来看一段简化后的源码逻辑RT_WEAK int rt_kprintf(const char *fmt, ...) { va_list args; rt_size_t length; static char rt_log_buf[RT_CONSOLEBUF_SIZE]; // 静态缓冲区 va_start(args, fmt); length rt_vsnprintf(rt_log_buf, sizeof(rt_log_buf) - 1, fmt, args); va_end(args); // ... 长度检查等 ... #ifdef RT_USING_DEVICE if (_console_device RT_NULL) { rt_hw_console_output(rt_log_buf); } else { rt_device_write(_console_device, 0, rt_log_buf, length); // 关键调用 } #endif return length; }注意看那个static char rt_log_buf[RT_CONSOLEBUF_SIZE]。这里定义了一个静态缓冲区。为什么需要这个缓冲区直接格式化完就发送不行吗这里有几个考量第一线程安全。使用静态局部变量可以避免在多线程调用时产生内存分配的冲突或碎片。第二性能。对于小段字符串先攒在固定大小的缓冲区里然后一次性交给下层发送比逐个字符调用发送接口效率更高。第三统一入口。无论你打印什么最终都汇聚到这个缓冲区然后通过一个统一的出口rt_device_write发出去。这个RT_CONSOLEBUF_SIZE默认通常是128字节在rtconfig.h里可以改。这里就体现了框架的一个设计理念把可配置的、可能变化的参数通过宏定义隔离出来方便用户根据自己芯片的RAM大小进行调整。最关键的一行是rt_device_write(_console_device, 0, rt_log_buf, length)。看到没rt_kprintf自己并不关心数据最终是怎么发出去的是走串口、USB-CDC、还是网络它统统不管。它只认一个东西_console_device。这是一个设备对象指针。它把缓冲好的数据连同数据长度一起“扔”给了一个名为rt_device_write的通用接口。至此内核服务层的任务就圆满完成了它的职责清晰而单一准备数据并调用标准设备写入接口。3. 第二棒设备驱动框架层device的抽象与转发接力棒现在传到了rt_device_write函数手中。这个函数位于device.c它是RT-Thread设备驱动框架的核心体现。如果说rt_kprintf是前台那设备驱动框架就是公司的整个行政部门它定义了一套所有部门各种硬件设备都必须遵守的沟通流程。我们看看这个“行政部门”是怎么工作的rt_size_t rt_device_write(rt_device_t dev, rt_off_t pos, const void *buffer, rt_size_t size) { /* 参数检查略... */ /* call device_write interface */ if (dev-write ! RT_NULL) { return dev-write(dev, pos, buffer, size); // 函数指针调用 } rt_set_errno(-RT_ENOSYS); return 0; }代码极其简洁它的核心就是一行dev-write(dev, pos, buffer, size)。dev是一个rt_device_t类型的指针它指向一个具体的设备对象。这个对象内部有一个关键的成员write。这个write是一个函数指针。这里就是整个框架解耦的“魔法”所在rt_device_write这个通用接口它并不知道最终执行写入的是串口、LCD还是SD卡。它只需要调用设备对象里注册好的那个write函数指针。具体这个指针指向哪个函数是rt_serial_write还是rt_sdio_write它不关心。这就像行政部下发一个“提交报告”的指令至于技术部是写代码文档市场部是做PPT财务部是填表格那是各个部门自己的事行政部只要求“提交”这个动作。那么这个神奇的write函数指针是在什么时候、被谁赋值的呢这就引出了设备驱动框架的另一个关键操作设备注册。通常在板级初始化函数rt_hw_board_init中会调用类似rt_hw_usart_init()的函数。在这个函数内部会完成具体硬件设备如串口的初始化和向框架的注册。以串口为例注册过程类似这样// 简化后的注册逻辑 rt_err_t rt_hw_serial_register(struct rt_serial_device *serial, const char *name, ...) { struct rt_device *device (serial-parent); // 获取父设备结构体 device-type RT_Device_Class_Char; // 设备类型字符设备 device-init rt_serial_init; device-open rt_serial_open; device-close rt_serial_close; device-read rt_serial_read; device-write rt_serial_write; // 关键将函数指针指向具体的实现 device-control rt_serial_control; // 将设备注册到内核的设备管理器 return rt_device_register(device, name, flag); }看第10行device-write rt_serial_write;。这就是“魔法”生效的时刻通过注册我们将一个通用的设备结构体struct rt_device和一个具体的操作函数集这里是rt_serial_xxx系列绑定在了一起。从此以后当上层调用rt_device_write时通过这个函数指针就能准确无误地跳转到rt_serial_write去执行。这种设计带来的巨大好处就是可移植性。假设你的项目从STM32换到了GD32或者ESP32。你只需要为新的芯片实现一套符合rt_serial_ops标准的底层驱动比如gd32_putc,esp32_uart_write并在初始化时完成同样的注册操作。那么所有上层调用rt_kprintf或者rt_device_write的代码都完全不需要修改框架帮你完成了所有硬件差异的隔离。4. 第三棒组件层serial的策略与分发接力棒现在传到了rt_serial_write。它位于serial.c属于RT-Thread的设备驱动组件层。这一层是针对某一类设备这里是串口的通用操作封装。它比设备框架层更具体但依然不涉及最底层的寄存器操作。它的核心作用是策略分发。串口发送数据有多种模式轮询、中断和DMA。rt_serial_write就像一个调度员根据设备打开时设置的标志open_flag来决定将数据交给哪种模式的发送函数去处理。static rt_size_t rt_serial_write(struct rt_device *dev, rt_off_t pos, const void *buffer, rt_size_t size) { struct rt_serial_device *serial (struct rt_serial_device *)dev; if (dev-open_flag RT_DEVICE_FLAG_INT_TX) { return _serial_int_tx(serial, buffer, size); // 中断模式发送 } #ifdef RT_SERIAL_USING_DMA else if (dev-open_flag RT_DEVICE_FLAG_DMA_TX) { return _serial_dma_tx(serial, buffer, size); // DMA模式发送 } #endif else { return _serial_poll_tx(serial, buffer, size); // 轮询模式发送默认 } }我们最常用的、也是默认的模式是轮询Polling。我们跟进_serial_poll_tx看看rt_inline int _serial_poll_tx(struct rt_serial_device *serial, const rt_uint8_t *data, int length) { while (length) { // 如果是\n且设备是流模式先补一个\r if (*data \n (serial-parent.open_flag RT_DEVICE_FLAG_STREAM)) { serial-ops-putc(serial, \r); } // 调用最终的字符发送接口 serial-ops-putc(serial, *data); data; --length; } return size; }这个函数做了两件有意思的事一是流模式处理在发送换行符\n前自动补一个回车符\r这是为了兼容Windows等系统的串口终端显示。这体现了框架对细节的考量。第二也是更重要的它调用了serial-ops-putc。看又是一个函数指针ops-putcserial-ops是一个指向struct rt_uart_ops结构体的指针这个结构体里定义了一组针对串口的标准操作函数指针struct rt_uart_ops { rt_err_t (*configure)(struct rt_serial_device *serial, struct serial_configure *cfg); rt_err_t (*control)(struct rt_serial_device *serial, int cmd, void *arg); int (*putc)(struct rt_serial_device *serial, char c); // 发送单个字符 int (*getc)(struct rt_serial_device *serial); // 接收单个字符 // ... 可能还有DMA相关函数 };组件层serial.c通过这个ops结构体把“发送一个字符”这个最原子的操作再次抽象和隔离了出去。_serial_poll_tx只负责组织数据处理流控制、循环发送而真正把字符“塞”进硬件寄存器这个动作它委托给了ops-putc。至此组件层的任务也完成了它定义了串口这类设备的通用行为模式并把硬件相关的最后一步操作交给了底层驱动。5. 终点冲刺底层驱动层bsp与硬件直接对话最后一棒终于来到了最底层与芯片外设寄存器直接打交道的板级支持包BSP层。这里就是ops-putc函数指针具体指向的地方它是纯硬件相关的代码。对于STM32这个函数可能就是stm32_putcstatic int stm32_putc(struct rt_serial_device *serial, char c) { struct stm32_uart *uart; uart rt_container_of(serial, struct stm32_uart, serial); // 通过结构体成员找到父结构体 // 向USART的数据寄存器(DR/TDR)写入字符 uart-handle.Instance-DR c; // 等待发送完成标志位 while (__HAL_UART_GET_FLAG((uart-handle), UART_FLAG_TC) RESET); return 1; }这个函数非常“裸机”。它用了一个经典的rt_container_of宏这是从Linux内核学来的技巧根据serial设备结构体的地址反向找到包含它的、更大的stm32_uart结构体从而拿到操作硬件所需的UART_HandleTypeDef句柄。然后就是两句核心操作1. 把字符c写入数据寄存器2. 轮询等待发送完成标志。这就是最底层的硬件操作。那么stm32_putc是怎么和上层的ops-putc挂钩的呢答案在BSP的初始化文件里// 定义具体的硬件操作集 static const struct rt_uart_ops stm32_uart_ops { .configure stm32_configure, .control stm32_control, .putc stm32_putc, // 关联 .getc stm32_getc, .dma_transmit stm32_dma_transmit }; // 在串口初始化函数中将操作集赋值给串口设备对象 int rt_hw_usart_init(void) { for (每个串口) { uart_obj[i].serial.ops stm32_uart_ops; // 关键赋值 // ... 其他初始化 ... rt_hw_serial_register(uart_obj[i].serial, ...); // 注册到框架 } return result; }在rt_hw_usart_init函数中创建具体的串口设备对象uart_obj[i]时将其serial.ops指针指向了我们刚刚定义的stm32_uart_ops。这样一来整个调用链就彻底打通了rt_kprintf-rt_device_write-rt_serial_write-_serial_poll_tx-serial-ops-putc(即stm32_putc)。6. 框架全貌与设计思想总结让我们跳出代码细节从上帝视角回顾一下rt_kprintf引发的这场“接力赛”应用层你调用rt_kprintf(“Hello”)。你只关心“打印”这个意图。内核服务层 (kservice)rt_kprintf函数格式化字符串存入缓冲区然后调用通用的rt_device_write接口。它不知道设备在哪。设备驱动框架层 (device)rt_device_write根据设备句柄调用该设备注册的write函数指针。它不知道设备是什么类型。设备组件层 (components/serial)rt_serial_write根据设备标志选择轮询/中断/DMA模式并调用该串口设备注册的ops-putc函数指针。它不知道芯片型号。板级支持包层 (bsp)stm32_putc操作STM32的USART数据寄存器完成物理发送。它只认识STM32。每一层都只和它的上下两层打交道遵守定义好的接口契约。层与层之间通过结构体和函数指针进行耦合这是一种非常灵活的面向对象思想在C语言中的体现。struct rt_device和struct rt_uart_ops就是两个关键的“契约”。这种分层与解耦的设计其核心思想就是抽象和隔离。抽象定义通用的接口如read,write,control让上层不用关心底层具体实现。隔离将硬件相关的代码牢牢限制在BSP层。当硬件更换时你只需要重写或适配BSP层的驱动并确保它实现了框架要求的“契约”即填充好那些函数指针上层所有业务代码几乎可以无缝迁移。这其实就是硬件无关性的精髓。RT-Thread通过这套设备驱动框架为我们构建了一个“硬件抽象层”HAL。我们开发应用时面对的不再是千差万别的芯片寄存器而是一套统一、稳定的API。这极大地提高了代码的复用率和项目的可维护性。7. 实战思考如何利用这套框架理解了框架我们就能更好地使用它甚至扩展它。这里分享几个我实际项目中的心得首先如何更换控制台设备默认rt_kprintf输出到第一个注册的串口。如果你想输出到其他设备比如USB虚拟串口CDC或者通过以太网输出日志该怎么办很简单因为框架是统一的。你只需要为你新的输出设备如USB CDC实现一套驱动并注册为一个字符设备RT_Device_Class_Char。然后在系统启动后调用rt_console_set_device(“your_device_name”)将控制台切换到这个新设备上。之后所有的rt_kprintf输出就会自动流向新设备无需修改任何打印语句。其次如何更高效地使用串口我们之前分析的默认路径是轮询Polling发送stm32_putc函数里有个while循环等待发送完成这在发送大量数据时会阻塞线程浪费CPU。我们可以利用框架提供的其他模式。在打开串口设备时使用rt_device_open(serial, RT_DEVICE_FLAG_INT_TX | RT_DEVICE_FLAG_DMA_TX)来开启中断或DMA模式。这样rt_serial_write就会调用_serial_int_tx或_serial_dma_tx数据搬运由硬件完成你的线程在发起写操作后就可以立刻返回去做其他事情通过回调函数tx_complete来获知发送完成。框架已经为我们准备好了这些机制我们要做的就是根据需求配置和使用。最后如何为新的硬件平台适配驱动这是最体现框架价值的场景。假设你要为一块新的MCU移植RT-Thread。你不需要去改rt_kprintf也不需要去改rt_device_write。你的主要工作集中在BSP目录下1. 为你的串口实现一个类似stm32_uart_ops的操作集结构体至少实现putc和getc。2. 实现一个类似rt_hw_usart_init的初始化函数在其中创建设备对象、关联操作集、并调用rt_hw_serial_register进行注册。3. 确保这个初始化函数在rt_hw_board_init中被调用。做完这些这个新平台就立刻支持所有基于RT-Thread设备框架的上层应用了包括Finsh命令行、日志系统等。踩过几次坑之后我最大的体会是学习RT-Thread初期不必死磕每一行内核源码。先理解它这种“分层”和“面向接口编程”的思想比记住几个API重要得多。当你写应用代码时下意识地去想“我这段代码属于哪一层它该调用哪一层的接口”你的代码结构自然会清晰起来。这套框架不仅仅是RT-Thread的核心也是你构建复杂、可移植嵌入式应用的强大脚手架。从rt_kprintf这个小口子钻进去看到的却是嵌入式软件工程化的一个经典范式这趟入门之旅算是摸到门道了。

相关新闻

Qwen3-TTS-Tokenizer-12Hz应用案例:智能硬件OTA升级包中语音资源token化压缩

Qwen3-TTS-Tokenizer-12Hz应用案例:智能硬件OTA升级包中语音资源token化压缩

Qwen3-TTS-Tokenizer-12Hz应用案例:智能硬件OTA升级包中语音资源token化压缩 1. 引言:智能硬件的“语音减肥”难题 你有没有遇到过这种情况?家里的智能音箱、儿童故事机或者智能门锁提示要更新系统,你点一下“确认升级”&#x…

2026/5/17 8:08:21 阅读更多 →
GLM-OCR实战:Transformer架构在文档理解中的优势解析

GLM-OCR实战:Transformer架构在文档理解中的优势解析

GLM-OCR实战:Transformer架构在文档理解中的优势解析 最近在整理公司过去几年的技术文档,面对一堆扫描件和PDF,我真是头都大了。传统的OCR工具识别出来的文字,段落错乱、格式丢失是家常便饭,尤其是遇到表格和复杂排版…

2026/7/2 21:29:09 阅读更多 →
GME-Qwen2-VL-2B-Instruct实操案例:招聘JD与岗位配图语义一致性检测

GME-Qwen2-VL-2B-Instruct实操案例:招聘JD与岗位配图语义一致性检测

GME-Qwen2-VL-2B-Instruct实操案例:招聘JD与岗位配图语义一致性检测 1. 项目背景与价值 在当今的招聘市场中,一个常见但容易被忽视的问题是:招聘岗位的配图与职位描述是否真正匹配?很多HR会随意使用一张办公室图片,或…

2026/5/17 8:08:15 阅读更多 →

最新新闻

ThinkPHP 6.0.8反序列化漏洞深度剖析:从POP链原理到实战利用

ThinkPHP 6.0.8反序列化漏洞深度剖析:从POP链原理到实战利用

1. 项目概述:一次对ThinkPHP6.0.8反序列化漏洞的深度剖析最近在复盘一些经典的PHP框架漏洞案例,ThinkPHP6.0.8的反序列化漏洞(CVE-2021-36542)绝对是一个绕不开的经典。这个漏洞的利用链(POP Chain)设计得非…

2026/7/4 21:05:52 阅读更多 →
LiveViewJS生命周期完全解析:从Mount到HandleEvent的完整流程

LiveViewJS生命周期完全解析:从Mount到HandleEvent的完整流程

LiveViewJS生命周期完全解析:从Mount到HandleEvent的完整流程 【免费下载链接】liveviewjs LiveView-based library for reactive app development in NodeJS and Deno 项目地址: https://gitcode.com/gh_mirrors/li/liveviewjs 想要构建实时、响应式的Web应…

2026/7/4 21:05:52 阅读更多 →
天龙八部GM工具:3分钟掌握游戏数据自由编辑的终极方法

天龙八部GM工具:3分钟掌握游戏数据自由编辑的终极方法

天龙八部GM工具:3分钟掌握游戏数据自由编辑的终极方法 【免费下载链接】TlbbGmTool 某网络游戏的单机版本GM工具 项目地址: https://gitcode.com/gh_mirrors/tl/TlbbGmTool 还在为游戏中重复刷怪升级而烦恼?想要快速体验天龙八部单机版的全部内容…

2026/7/4 21:03:51 阅读更多 →
Vault-Operator在生产环境中的最佳实践:来自实际部署的经验分享

Vault-Operator在生产环境中的最佳实践:来自实际部署的经验分享

Vault-Operator在生产环境中的最佳实践:来自实际部署的经验分享 【免费下载链接】vault-operator Run and manage Vault on Kubernetes simply and securely 项目地址: https://gitcode.com/gh_mirrors/va/vault-operator Vault-Operator是一款在Kubernetes环…

2026/7/4 21:03:51 阅读更多 →
智能绕过限制:永久免费使用Cursor AI编程助手的完整方案

智能绕过限制:永久免费使用Cursor AI编程助手的完整方案

智能绕过限制:永久免费使用Cursor AI编程助手的完整方案 【免费下载链接】cursor-free-vip [Support 0.45](Multi Language 多语言)自动注册 Cursor Ai ,自动重置机器ID , 免费升级使用Pro 功能: Youve reached your t…

2026/7/4 21:01:50 阅读更多 →
毕设分享 深度学习yolo藻类细胞检测识别(科研辅助系统)(源码+论文)

毕设分享 深度学习yolo藻类细胞检测识别(科研辅助系统)(源码+论文)

👆👆 完整项目获取方式👆👆完整项目获取方式👆👆完整项目获取方式👆👆完整项目获取方式👆👆 文章目录 👆👆 完整项目获取方式&#x1…

2026/7/4 21:01:50 阅读更多 →

日新闻

Memcached 1.6.43 发布:关键安全修复版本,多项问题得到解决

Memcached 1.6.43 发布:关键安全修复版本,多项问题得到解决

Memcached 1.6.43 正式发布,这是一个关键的安全修复版本,修复了多个方面的问题,还对部分功能进行了优化。 安全修复亮点 此次发布在安全修复上表现突出。binprot 避免了项目引用计数溢出,mcmc 因安全问题提升了上游版本号&#xf…

2026/7/4 0:04:29 阅读更多 →
终极指南:使用HMCL启动器跨平台畅玩Minecraft的完整解决方案

终极指南:使用HMCL启动器跨平台畅玩Minecraft的完整解决方案

终极指南:使用HMCL启动器跨平台畅玩Minecraft的完整解决方案 【免费下载链接】HMCL A Minecraft Launcher which is multi-functional, cross-platform and popular 项目地址: https://gitcode.com/gh_mirrors/hm/HMCL HMCL(Hello Minecraft! Lau…

2026/7/4 0:06:29 阅读更多 →
KMX63与PIC18F66K40在嵌入式HMI中的硬件协同与低功耗设计

KMX63与PIC18F66K40在嵌入式HMI中的硬件协同与低功耗设计

1. KMX63与PIC18F66K40的硬件协同架构解析KMX63作为一款三轴加速度计和磁力计组合传感器,与PIC18F66K40微控制器的搭配堪称嵌入式HMI开发的黄金组合。这套硬件组合的核心优势在于KMX63提供的高精度运动感知能力与PIC18F66K40强大的信号处理能力形成了完美互补。KMX6…

2026/7/4 0:06:29 阅读更多 →

周新闻

月新闻