1 用户态和内核态
1.1 用户态
用户态是普通应用程序运行的模式,操作系统为了保护自身和硬件资源,把用户程序“限制”在一个受控的环境中运行:
- 不能直接访问硬件(如 I/O 端口、磁盘、内存管理单元等)。
- 不能直接访问内核数据结构。
- 只能通过系统调用请求内核提供服务(比如 read()、write()、open())。
- 安全性高:即使用户程序崩了,不会直接影响内核或其他程序。
1.2 内核态
内核态是操作系统核心代码(kernel)运行的模式,它拥有对所有硬件资源的完全控制权:
- 可以直接操作硬件、访问内存、控制进程。
- 执行系统调用的处理逻辑。
- 是整个系统最"特权"的模式。
这里举个简单的例子:
用户态----------------------->顾客在银行窗口排队办业务
内核态----------------------->银行柜员在内部操作系统为你服务
1.3 常见的陷入内核的方式
1.4 用户态陷入内核态分析
1.4.1 相关工具介绍
1.4.1.1 strace — 系统调用的侦察兵
strace 是一个在用户态运行的命令行工具,用于跟踪和记录一个程序执行过程中发生的所有系统调用。它会拦截程序调用的每一个 open、read、write、ioctl 等系统调用,并显示调用参数、返回值、错误码等详细信息。它不需要修改程序源码,是排查问题、了解程序行为的首选利器。
使用场景:
- 查看程序有没有成功访问设备文件:比如你写了 /dev/mychardev 驱动,用户程序运行后你不确定有没有执行 open(),用 strace 一看就知道。
- 捕捉 read/write 调用细节:看是否真的调用了 read(),传了多少字节,返回了多少数据,有没有失败。
- 排查 errno 和系统调用失败原因:比如 open() 失败但你不知道为什么,strace 会显示 ENOENT(找不到文件)或 EPERM(权限错误)等信息。
1.4.1.2 perf — 性能分析的瑞士军刀
perf 是 Linux 内核自带的性能分析工具,它可以收集 CPU、内存、系统调用、上下文切换等运行时事件的数据。除了性能统计,它还能追踪系统调用、分析热点函数、绘制调用图,是深入分析应用或内核性能瓶颈的利器。它可以用于用户态程序,也可以追踪内核态行为。
使用场景:
- 统计程序运行时间和资源开销: 使用 perf stat ./your_program 可以看到 task clock、上下文切换、page fault 等指标。
- 系统调用追踪(类似 strace 但更底层): perf trace ./your_program 显示每个系统调用的详细时间戳、线程号、返回值。
- 分析程序瓶颈或热点函数: perf top 显示哪些函数最消耗 CPU,适合分析长时间运行的服务。
1.4.1.3 trace-cmd(ftrace)— 内核函数调用的显微镜
trace-cmd 是对 Linux 内核内建的函数追踪工具 ftrace 的封装器,用户可以用它来记录和查看内核函数的执行路径,包括调用关系和时间顺序。它能告诉你“某个系统调用进入内核后,具体走了哪些函数”,特别适合用于分析驱动代码是否被调用、调用顺序是否正确。
使用场景:
- 追踪 vfs_read() 是否调用了你的 driver_read():你在驱动写了 .read 函数,配合 trace-cmd record -e vfs_read 就能看到是否真的走到了你实现的函数。
- 调试驱动函数没有执行的原因:看看是没挂上 file_operations 还是压根没触发读操作。
- 结合 trace_printk() 打印关键函数时序:比如你怀疑 my_read() 比预期晚执行,就可以配合时间戳分析调用延迟。
1.4.1.4 linux-tools-$(uname -r) — perf 的家属套餐
这是一个包含 perf 工具的内核版本匹配包,确保你用的 perf 与当前运行的内核是一致的(否则可能无法采集事件数据)。它还可能包含一些 cpupower、调频调压等系统级工具。
使用场景:
- 无法安装 perf 时的兜底方案:如果 apt install perf 报错,那就装这个版本号一致的包。
- 保证 perf 功能完整性:某些功能依赖于 linux-tools-common、linux-tools-generic 和版本匹配的工具一起使用。
1.4.2 Linux系统层次结构
(1)用户空间(User Space)
用户空间是 Linux 系统中最上层的一部分,运行着各种用户级的应用程序和服务。我们日常使用的命令行程序(如 bash)、图形界面(如 GNOME、KDE)、浏览器、文本编辑器,以及你自己写的用户程序(如 user_read)都属于这一层。用户空间中的程序不能直接操作硬件或访问内核资源,它们的所有底层操作需求,例如读写文件、网络通信、访问设备等,都必须通过系统调用接口向内核发起请求。这种隔离机制保证了系统的安全性与稳定性。
(2)系统调用接口(System Call Interface)
系统调用接口位于用户空间与内核空间之间,是两者交互的“桥梁”。当用户程序调用如 read()、write()、open() 等函数时,本质上会通过系统调用(syscall 指令或软中断 int 0x80)陷入内核。这一过程由内核中的系统调用入口函数(如 sys_read())接手,进入特权模式(Ring 0)。系统调用接口统一管理用户请求并将其分发给内核中的相关子系统(如文件系统、内存管理、驱动程序等),它确保用户对内核资源的访问是受控的、合法的。
(3)内核核心层(Kernel Core)
内核核心层是 Linux 系统的“中枢神经系统”,负责调度管理整个系统的资源。它包括进程调度器、虚拟内存管理器、系统调用处理模块、文件系统(如 VFS 虚拟文件系统)、网络协议栈、安全机制等。当系统调用进入内核后,比如一个文件读取请求,内核会通过 vfs_read() 接口分发到具体的设备或文件类型处理器。这一层不直接与硬件打交道,而是通过统一抽象,管理所有的资源使用,并将请求下发给驱动层执行。
(4)驱动程序层(Device Drivers)
驱动程序层是内核中的一个专门子模块,负责与具体硬件设备进行通信。比如你的字符设备驱动 mychardev.ko,在内核中注册后,会提供标准接口(如 .read、.write 函数)供内核调用。当内核的 vfs_read() 分发到该设备时,它会调用你在 file_operations 中注册的 my_read() 函数,由你负责具体的数据传输逻辑。驱动层对上暴露统一的接口给内核,对下直接操作硬件寄存器、DMA、I/O 等,是内核访问硬件的“执行者”。
(5)硬件层(Hardware)
硬件层是整个系统最底层,包含了所有物理设备:CPU、内存、网卡、磁盘、USB 设备、显卡、I/O 控制器等。Linux 内核本身并不能直接操作硬件,它依赖驱动程序来控制、初始化和与设备交互。比如你调用 read() 读取串口数据,最终通过驱动写入串口控制寄存器、读取数据缓冲区。这一层是数据真正“发生物理移动”的地方,是所有抽象逻辑最终落地的接口。
2 从用户导内核的调用关系案例分析
2.1 实验平台
Linux ubuntu 4.15.0-142-generic #146~16.04.1-Ubuntu SMP Tue Apr 13 09:27:15 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux
2.2 实验程序
在ubuntu下新建文件夹,并创建驱动文件、测试文件、分析脚本文件。
2.2.1 驱动文件
// mychardev.c
#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "mychardev"
#define MAJOR_NUM 240
static char data[] = "Hello from kernel!\n";
static ssize_t my_read(struct file *file, char __user *buf, size_t len, loff_t *offset) {
printk(KERN_INFO "[mychardev] my_read() called, len=%zu\n", len);
return copy_to_user(buf, data, sizeof(data));
}
static struct file_operations fops = {
.owner = THIS_MODULE,
.read = my_read,
};
static int __init my_init(void) {
register_chrdev(MAJOR_NUM, DEVICE_NAME, &fops);
printk(KERN_INFO "[mychardev] driver loaded\n");
return 0;
}
static void __exit my_exit(void) {
unregister_chrdev(MAJOR_NUM, DEVICE_NAME);
printk(KERN_INFO "[mychardev] driver unloaded\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
2.2.2 测试文件
// user_read.c
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
char buf[128] = {0};
int fd = open("/dev/mychardev", O_RDONLY);
if (fd < 0) {
perror("open");
return 1;
}
ssize_t n = read(fd, buf, sizeof(buf));
if (n < 0) {
perror("read");
return 1;
}
printf("User got: %s", buf);
close(fd);
return 0;
}
2.2.3 分析脚本文件
# trance_all.sh
#!/bin/bash
set -e
# ========== 配置 ==========
TARGET=./user_read
DEVICE=/dev/mychardev
TRACE_DIR=/sys/kernel/debug/tracing
LOG_DIR=.
STRACE_LOG=$LOG_DIR/strace.log
PERF_LOG=$LOG_DIR/perf_trace.log
FTRACE_TMP=/tmp/ftrace_trace.log
FTRACE_LOG=$LOG_DIR/ftrace_trace.log
# ========== 加载驱动 ==========
echo "[*] 插入驱动模块..."
sudo insmod mychardev.ko || true
sudo mknod $DEVICE c 240 0 || true
sudo chmod 666 $DEVICE
# ========== strace ==========
echo "[*] 使用 strace 追踪系统调用"
strace $TARGET 2>&1 | tee $STRACE_LOG
# ========== perf trace ==========
echo "[*] 使用 perf trace 追踪系统调用"
sudo perf trace -o $PERF_LOG $TARGET
# ========== ftrace 准备 ==========
echo "[*] 挂载 debugfs(ftrace 用)"
sudo mount -t debugfs none /sys/kernel/debug || true
echo "[*] 配置 ftrace 追踪器"
sudo bash -c "
cd $TRACE_DIR
echo nop > current_tracer
echo > trace
echo 0 > tracing_on
# 使用 function_graph,显示函数调用路径 + 耗时
echo function_graph > current_tracer
# 设置关注的函数(可根据驱动实际函数名调整)
echo vfs_read > set_graph_function
echo my_read >> set_graph_function 2>/dev/null || true
echo chardevbase_read >> set_graph_function 2>/dev/null || true
echo 1 > tracing_on
"
# ========== 执行用户程序 ==========
echo "[*] 执行用户程序并触发驱动调用..."
$TARGET
# ========== 停止 ftrace & 导出调用链 ==========
echo "[*] 停止 ftrace 并导出调用链日志"
sudo bash -c "
cd $TRACE_DIR
echo 0 > tracing_on
cat trace > $FTRACE_TMP
"
# 拷贝给当前用户使用
cp $FTRACE_TMP $FTRACE_LOG
chmod 644 $FTRACE_LOG
# ========== 展示结果 ==========
echo
echo "[*] ftrace 内核函数调用链(前 50 行)👇"
head -n 50 $FTRACE_LOG
echo
echo "[*] 查看驱动 printk 日志(dmesg)👇"
dmesg | tail -n 20
echo
echo "[✔] 所有分析完成,日志文件:"
echo " - $STRACE_LOG (用户态系统调用)"
echo " - $PERF_LOG (perf 系统调用追踪)"
echo " - $FTRACE_LOG (内核函数调用链)"
2.2.4 Makefile
obj-m := mychardev.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
make -C $(KDIR) M=$(PWD) modules
gcc -o user_read user_read.c
clean:
make -C $(KDIR) M=$(PWD) clean
rm -f user_read
2.3 分析脚本解析
#!/bin/bash
set -e
- #!/bin/bash:声明脚本使用 Bash 解释器执行。
- set -e:一旦脚本中某条命令出错(返回非 0),立即退出脚本,避免连锁错误。
TARGET=./user_read
DEVICE=/dev/mychardev
TRACE_DIR=/sys/kernel/debug/tracing
LOG_DIR=.
STRACE_LOG=$LOG_DIR/strace.log
PERF_LOG=$LOG_DIR/perf_trace.log
FTRACE_TMP=/tmp/ftrace_trace.log
FTRACE_LOG=$LOG_DIR/ftrace_trace.log
- TARGET:你编译出的用户态程序(将调用驱动)
- DEVICE:字符设备路径(你创建的 /dev/mychardev)
- TRACE_DIR:ftrace 的控制路径,ftrace 所有操作都在这个目录里进行
- LOG_DIR:日志文件保存目录,. 代表当前路径
- STRACE_LOG:保存 strace 输出的日志路径
- PERF_LOG:保存 perf trace 的结果
- FTRACE_TMP:临时保存 ftrace 的输出文件(root 权限)
- FTRACE_LOG:最终保存为普通用户可读的 ftrace 调用链日志
echo "[*] 插入驱动模块..."
sudo insmod mychardev.ko || true
sudo mknod $DEVICE c 240 0 || true
sudo chmod 666 $DEVICE
- insmod mychardev.ko:插入你编译的字符设备驱动
- || true:即使已经插入,防止报错中断脚本
- mknod:创建字符设备节点,c 240 0 表示主设备号 240,次设备号 0
- chmod 666:让所有用户都可以读写设备(默认 root 权限不够)
# 使用 strace 追踪用户态系统调用
echo "[*] 使用 strace 追踪系统调用"
strace $TARGET 2>&1 | tee $STRACE_LOG
- strace:捕获并打印程序使用的所有系统调用
- $TARGET:你的用户程序(比如调用了 open、read)
- 2>&1:把 stderr(strace 默认输出)重定向到 stdout
- tee:同时输出到终端和保存到 $STRACE_LOG
# 使用 perf trace 分析系统调用路径
echo "[*] 使用 perf trace 追踪系统调用"
sudo perf trace -o $PERF_LOG $TARGET
- perf trace:类似 strace,但更贴近内核层,可显示更多信息(如函数耗时、CPU切换)
- -o $PERF_LOG:把输出保存到指定文件
- sudo:perf 需要高权限使用硬件性能计数器
# 准备 ftrace 环境
echo "[*] 挂载 debugfs(ftrace 用)"
sudo mount -t debugfs none /sys/kernel/debug || true
- ftrace 工作在 debugfs 文件系统中,它提供内核调试入口
- 如果你未挂载 debugfs,ftrace 无法工作
- || true 是为了防止已经挂载时报错
# 配置 ftrace 跟踪器
echo "[*] 配置 ftrace 追踪器"
sudo bash -c "
cd $TRACE_DIR
echo nop > current_tracer
echo > trace
echo 0 > tracing_on
- current_tracer:当前追踪模式,nop 先清空
- trace:清空上一次追踪的日志内容
- tracing_on=0:先关闭追踪,防止写入未准备好的数据
# 使用 function_graph,显示函数调用路径 + 耗时
echo function_graph > current_tracer
- 切换到 function_graph 模式,这是 ftrace 最强大的一种模式
- 能显示完整函数调用栈 + 每个函数执行耗时
- 比 function 模式多了视觉缩进和耗时信息
# 设置关注的函数(可根据驱动实际函数名调整)
echo vfs_read > set_graph_function
echo my_read >> set_graph_function 2>/dev/null || true
echo chardevbase_read >> set_graph_function 2>/dev/null || true
把 my_read 函数添加到 ftrace 的函数跟踪列表中,如果失败也不报错,不中断脚本执行。
>> set_graph_function
这是写入(追加)到 ftrace 的 函数过滤列表文件:set_graph_function 文件位于 /sys/kernel/debug/tracing/ 中。它控制 ftrace 在 function_graph 模式下,只追踪这些函数及它们的子函数调用链。>> 和 > 不同,>> 是追加模式,不会清除之前设置的函数。
2>/dev/null
这是重定向错误输出,意思是:如果这条命令执行时出错(比如 my_read 函数不存在于内核符号表),把报错信息丢掉,不打印出来。
|| true
这是容错写法,意思是:如果上面那条命令失败了(返回非 0),就执行 true 让它“看起来成功”,不会影响整个脚本。
这是配合 set -e 的技巧:你脚本里写了 set -e,意味着只要一条命令失败,脚本就会中断。但我们又不希望某个函数追踪失败就整个退出。所以加 || true,就算追踪失败也不会终止脚本。
2.4 分析脚本完善
当你使用 ftrace、perf trace 等工具时,默认会捕捉整个系统中所有进程的系统调用或内核函数调用。但我们开发驱动、调试程序时,只关心:我们自己写的程序。因此使用 set_ftrace_pid 限定追踪的 PID。
修改之后的脚本为:
#!/bin/bash
set -e
# ========== 配置 ==========
TARGET=./user_read
DEVICE=/dev/mychardev
TRACE_DIR=/sys/kernel/debug/tracing
LOG_DIR=.
STRACE_LOG=$LOG_DIR/strace.log
PERF_LOG=$LOG_DIR/perf_trace.log
FTRACE_TMP=/tmp/ftrace_trace.log
FTRACE_LOG=$LOG_DIR/ftrace_trace.log
# ========== 加载驱动 ==========
echo "[*] 插入驱动模块..."
sudo insmod mychardev.ko || true
sudo mknod $DEVICE c 240 0 || true
sudo chmod 666 $DEVICE
# ========== strace ==========
echo "[*] 使用 strace 追踪系统调用"
strace $TARGET 2>&1 | tee $STRACE_LOG
# ========== perf trace ==========
echo "[*] 使用 perf trace 追踪系统调用"
sudo perf trace -o $PERF_LOG $TARGET
# ========== ftrace 准备 ==========
echo "[*] 挂载 debugfs(ftrace 用)"
sudo mount -t debugfs none /sys/kernel/debug || true
echo "[*] 配置 ftrace 追踪器"
sudo bash -c "
cd $TRACE_DIR
echo nop > current_tracer
echo > trace
echo 0 > tracing_on
echo function_graph > current_tracer
# 设置关注的函数(可根据驱动实际函数名调整)
echo vfs_read > set_graph_function
echo my_read >> set_graph_function 2>/dev/null || true
echo chardevbase_read >> set_graph_function 2>/dev/null || true
"
# ========== 执行用户程序(仅追踪其 PID) ==========
echo "[*] 启动用户程序,并设置 PID 过滤..."
$TARGET &
TARGET_PID=$!
echo "[*] 捕获用户程序 PID: $TARGET_PID"
# 设置 ftrace 只追踪该 PID
sudo bash -c "echo $TARGET_PID > $TRACE_DIR/set_ftrace_pid"
# 启用 ftrace 追踪
sudo bash -c "echo 1 > $TRACE_DIR/tracing_on"
# 等待程序结束
wait $TARGET_PID
# ========== 停止 ftrace & 导出调用链 ==========
echo "[*] 停止 ftrace 并导出调用链日志"
sudo bash -c "
cd $TRACE_DIR
echo 0 > tracing_on
cat trace > $FTRACE_TMP
"
# 拷贝给当前用户查看
cp $FTRACE_TMP $FTRACE_LOG
chmod 644 $FTRACE_LOG
# ========== 展示结果 ==========
echo
echo "[*] ftrace 内核函数调用链(前 50 行)👇"
head -n 50 $FTRACE_LOG
echo
echo "[*] 查看驱动 printk 日志(dmesg)👇"
dmesg | tail -n 20
echo
echo "[✔] 所有分析完成,日志文件:"
echo " - $STRACE_LOG (用户态系统调用)"
echo " - $PERF_LOG (perf 系统调用追踪)"
echo " - $FTRACE_LOG (内核函数调用链,仅你的程序)"
2.5 编译
编译文件,装载驱动,运行脚本即可看到相关追踪的log文件。文件中具体的含义以及trace的使用,后续会有专门的文章说明,因为涉及的内容太多。
3 Linux设备驱动程序调用关系
3.1 调用关系图
3.2 关键调用路径详解
3.2.1 用户态调用 read()
用户空间的 C 程序调用标准库函数 read(int fd, void *buf, size_t count),这是 POSIX 标准提供的文件读取接口。底层实际是通过 syscall 进入内核态。
3.2.2 系统调用入口
read() 是一个系统调用,在底层通过软件中断(如 x86 的 int 0x80 或 ARM 的 svc #0)进入内核。内核会根据系统调用号 __NR_read 定位到对应的系统调用处理函数。
3.2.3 系统调用处理函数:sys_read()
位于内核源代码的 fs/read_write.c 中,sys_read() 是 Linux 中 read 系统调用的实际处理函数,主要负责:参数校验(检查指针合法性、长度)、封装为内核内部结构、调用 vfs_read() 进入文件系统层。
3.2.4 虚拟文件系统:vfs_read()
VFS(Virtual File System)是 Linux 中抽象出来的统一文件访问接口,不关心具体文件系统类型。vfs_read() 会根据传入的 file 结构体中的 file_operations 成员,找到具体实现并调用:
ret = file->f_op->read(file, buf, count, &pos);
3.2.5 驱动注册的 .read 函数
驱动程序在初始化时,注册了 file_operations 结构体,并设置了 .read 成员为自定义的读取函数,如:
struct file_operations fops = {
.read = my_read,
...
};
当 vfs_read() 调用 .read 时,实际上就是执行你驱动中的 my_read() 函数。
3.2.6 设备驱动的 my_read() 实现
这部分就是你自己实现的驱动函数。
4 驱动代码的编译
4.1 以模块形式编译
4.1.1 在本机(如 Ubuntu x86_64)上编译、加载驱动模块
makefile
obj-m := my_drv.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean
4.1.2 在开发机(PC)交叉编译 .ko 驱动,供 ARM 目标板(如 i.MX6ULL)使用
makefile
obj-m := my_drv.o
KERNELDIR := /home/you/path/to/imx6ull/linux-source
PWD := $(shell pwd)
all:
$(MAKE) -C $(KERNELDIR) M=$(PWD) ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- modules
clean:
$(MAKE) -C $(KERNELDIR) M=$(PWD) ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- clean
4.2 直接和内核源码一起编译
- 把驱动放入内核源码树合适目录,如 drivers/mydrv/
- 修改对应目录下的 Makefile 和 Kconfig
- 在内核配置中添加菜单项
- 使用 make menuconfig 选择编译
- 编译整个内核
// step 1
make clean
//step 2 配置内核
make XXX_defconfig
//step 3 编译内核
make