0%

最近想通篇了解一下中国历史,无奈市面上讲中国通史的书籍甚少,好评的更是凤毛菱角。故上网搜了一堆,看到“中国社会科学院历史研究所”出品的《中国历史极简本》一书,主观上觉得会是一本很有底蕴的书,而且全是精华。然而事实上本书并不注重章节的连贯性,没有中华上下五千年一气呵成的感觉,而是仅仅按照时间顺序、根据中国不同朝代,挑选军事、经济、政治、文化、人物等不同的点或面进行叙述,当然,书中对中国古代各个方面的点评也相对中肯。不论如何,区区15万字能从炎黄到宣统,把中国古代历史完整咀嚼一遍,此书想必也下了一番功夫。我个人认为,把本书作为一本科普书更适合。

阅读全文 »

临界资源:是指同一个时段内只允许唯一一个访问者操作的资源。比如打印机、IO模块等,但Linux是多任务的,其内核对资源的管理是抢占式的。多个进程同时运行即所谓的并发,而如果多个进程都同时访问同一个资源就会产生竞态。由于驱动模块的特殊性,它不可避免会存在被多个进程同时“打开、读写、关闭”的情况。设想一下,如果某个驱动的逻辑是open的时候分配一块缓存用于read/write,close的时候又释放缓存,就会存在A进程刚打开的设备节点,B进程就关闭,缓存分配了又释放,最终在读写时导致程序崩溃。

所以,本章主要学习Linux驱动模块有哪些手段可以处理并发时的竞态问题。

原子操作

原子操作就是保证对数据修改的完整性,也就是说a = a + 1这么简单的表达式也难以避免被编译为多个指令周期,也许在任务A中刚读完表达式右值,又被任务B更新了a的寄存器,结果一个简单的自加1的操作都可能出现很多诡异的结果。

因此,为了确保i++就是自加1的操作,内核封装了很多API以实现变量的原子操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <asm/atomic.h> # 引入原子操作API

// 定义原子变量,并将其初始化为0
atomic_t v = ATOMIC_INIT(0);

// 变量的原子读写操作
int atomic_read(atomic_t* v);
void atomic_set(atomic_t* v, int i);

// 变量运算的原子操作
void atomic_add(int i, atomic_t* v); // 加
void atomic_sub(int i, atomic_t* v); // 减
int atomic_and(int i, atomic_t* v); // 与
int atomic_or(int i, atomic_t* v); // 或
int atomic_xor(int i, atomic_t* v); // 异或
int atomic_andnot(int i, atomic_t* v); // 与非

// 在运算的基础之上“返回原来的结果”
int atomic_fetch_add(int i, atomic_t* v);
int atomic_fetch_sub(int i, atomic_t* v);
int atomic_fetch_and(int i, atomic_t* v);
int atomic_fetch_or(int i, atomic_t* v);
int atomic_fetch_xor(int i, atomic_t* v);
int atomic_fetch_andnot(int i, atomic_t* v);

void atomic_add_return(int i, atomic_t* v); // 加,并返回新值
void atomic_sub_return(int i, atomic_t* v); // 减,并返回新值

/******************************************
* 强烈注意:
* 以下定义在ARM平台(或Linux5.0+)不存在
* 尽管各大书籍和网络文章里依然这么介绍
******************************************/
void atomic_int(atomic_t* v); // 自增
void atomic_dec(atomic_t* v); // 自减

// 带测试的加减运算,如果操作后原子值为0,则返回true,反之false。
int atomic_inc_and_test(atomic_t* v);
int atomic_dec_and_test(atomic_t* v);
int atomic_sub_and_test(int i, atomic_t* v);
// 注意不是atomic_add_and_test
int atomic_add_negative(int i, atomic_t* v);

自旋锁

自旋锁是一种对临界资源互斥访问的手段,也就是说在访问资源之前上个锁,访问完成后解锁,如果一个进程在访问资源是发现“锁住”了,就会原地打转——而非进入睡眠!直到锁被解开。就好比一辆车遇到红灯后停了下来但没熄火,发动机一直在空转,直到绿灯。但自旋锁有个很大的弊端——“如果红绿灯刚好坏了,发动机会永远空转下去”。

先来看看自旋锁的简单用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <linux/spinlock.h> // 引入自旋锁的头

// 定义并初始化一个锁
spinlock_t lock;
spin_lock_init(&lock);

// 获取锁状态,有两种方式
spin_lock(&lock); // 如果锁住了,原地打转
spin_trylock(&lock); // 如果锁住了,立即返回,不会锁死

// todo...各种临界资源访问和处理

spin_unlock(&lock); // 解锁,为后来的访问者开绿灯

上边是最简单的使用方式,但自旋锁还会受到内核中断、底半部(BH)的影响,所以衍生出了更多的“锁定”和“解锁”API。就好比驾驶员在等红灯时跑去尿尿,恰好此时绿灯亮起,该怎么办?答:禁止驾驶员尿尿😄。

这些函数要视情况具体使用:

1
2
3
4
5
6
7
8
9
void spin_lock_irq(spinlock_t* lock);   // 禁用中断,并上锁
void spin_unlock_irq(spinlock_t* lock); // 启用中断,并解锁

// 同上,但保存/恢复状态字
void spin_lock_irqsave(spinlock_t* lock, unsigned long flags);
void spin_unlock_irqrestore(spinlock_t* lock, unsigned long flags);

void spin_lock_bh(spinlock_t* lock); // 禁用bh,并上锁
void spin_unlock_bh(spinlock_t* lock); // 启用bh,并解锁

开发驱动时应谨慎使用自旋锁,要直到它“空转”的意思是不放弃CPU,所以在其自旋时会对CPU资源造成浪费,如果不小心锁死了,那就悲催了。

综上,自旋锁只是在访问临界资源前后加了一层排他性的锁,至于锁内的资源操作它完全不关心,然而共享资源在并发访问时往往是这样的需求:可以被同时读,但不允许同时写。也是基于此,内核提供了更多的API来满足这些场景。

  1. 读写自旋锁

读写自旋锁会区别读和写的资源,满足并发读取,单一写入的要求,但底层也是“自旋”的机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 读写锁定义
rwlock_t lock;
rwlock_init(&lock);

// 读取上锁/解锁
read_lock(&lock);
// todo...
read_unlock(&lock);

// 写入上锁/解锁
write_lock(&lock);
// todo...
write_unlock(&lock);
  1. 顺序锁

顺序锁是读写锁的优化版,因为读写锁的读和写操作是互斥的,所以使用顺序锁后,当资源正在写入时,依然可以被读取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 顺序锁API的定义
unsigned int read_seqbegin(const seqlock_t* sl);
unsigned int read_seqretry(const seqlock_t *sl, unsigned int start);
void write_seqlock(seqlock_t *sl);
void write_sequnlock(seqlock_t *sl);

/*-----------------以下是具体使用方法-----------------*/
#include <linux/seqlock.h>

// 顺序锁定义
seqlock_t lock;
seqlock_init(&lock);

// 顺序读的过程
unsigned int start = 0;
do {
start = read_seqbegin(&lock);
// todo...read
} while (read_seqretry(&lock, start));

// 写入上锁
write_seqlock(&lock);
// todo...write
write_sequnlock(&lock);
  1. RCU: Read-Copy-Update

读——复制——更新的意思是:把要写的部分先读取被拷贝一个副本,然后把内容写入副本,等到何时的时机一把更新到源。

1
2
3
4
5
6
7
8
#include <linux/rcupdate.h>

void rcu_read_lock(void);
void rcu_read_unlock(void);


void synchronize_rcu(void);
void call_rcu(struct callback_head *head, rcu_callback_t func);

互斥体

互斥的机制在多线程中是很常见的,Linux内核的互斥体metux本质是由自旋锁实现的。但与自旋锁不同的是,互斥体会进入默认睡眠,放弃CPU抢占。

1
2
3
4
5
6
7
8
9
10
11
#include <linux/mutex.h>

// 定义一个互斥体
struct mutex mutex;
mutex_init(&mutex);

// 上锁/解锁方式
void mutex_lock(struct mutex *lock); // 睡眠后不可被中断
int mutex_lock_interruptible(struct mutex *lock); // 睡眠后可被中断
int mutex_trylock(struct mutex *lock); // 如果能解锁就立即返回0,否则立即返回非0
void mutex_unlock(struct mutex *lock); // 解锁

completion

completion就是指一个执行单元等待另一个执行单元的完成信号,有点多线程同步的意思。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <linux/completion.h>

// 定义一个completion
struct completion completion;
void init_completion(struct completion *x);
void reinit_completion(struct completion *x);

// 等待completion标志
void wait_for_completion(struct completion *);

// 唤醒一个执行单元
void complete(struct completion *);
// 唤醒所有执行单元
void complete_all(struct completion *);

How to Use a Bug Tracker

不论你是否把它们称作bug、缺陷、设计副作用,你都无法摆脱它们。知道如何提交一个优秀的bug反馈以及在某方面需要什么,是保持项目顺利推进的关键技能。

一个优秀的bug反馈需要做到三件事:

  • 如何复现bug,尽可能地清晰描述,以及它出现的频率。
  • 理论上应该发生什么,起码在你看来会发生什么。
  • 实际上又发生了什么,或者至少记录下你所掌握的更多信息。

关于bug反馈信息的数量和质量,要做到和反馈者所掌握的一样多。愤怒地反馈个简短的bug(“这个函数太烂了!”)除了告诉开发者你此刻很糟糕以外,没什么卵用。一个含有充足上下文的bug可以更容易被复现而赢得大家的尊重,即使它已停止发布。

变更bug的状态,例如打开-关闭,这是你对bug思考的公开声明。花点时间解释为何你觉得bug应该被关闭,可以挽回在恼怒的经理和消费者面前辩解的时间。变更bug的优先级也类似于一份公开声明,仅仅是因为它对你来说微不足道,但并不意味着它会让其他人停止使用该产品。

不要为了你自己的目的就去重载一个bug字段。将“VITAL:”添加为bug主题的字段可能有利于你对一些报告结果做排序,但它也将被其他人复制并难以避免拼写出错,或者在其他报告中将其删除。使用一个新值或新字段来代替,并用文档记录字段该如何使用,以便他人不必重复。

确保每个人都知道如何找出团队认可的bug以被处理。通常可以采用一个公共的查询名称。确保每个人都采用相同的查询方式,在没有事先通知团队你已经改变了某些内容的情况下,不要更新此查询。

最后,记住bug不是工作的一个标准单元,只是一行代码的精确测量。

古人云:别跟老子说什么断点、跟踪、gdb、lldb…老子调代码从来都是print😂️。调试在程序开发中是非常重要的手段,就像单元测试一样,是保障软件质量的主要手段之一,别不当回事!

言归正传,linux内核提供了多种调试技术,但因为驱动程序不是普通的程序,很多常见的调试工具到内核这一层基本都扑街了,printk反而成了最朴实有效的手段之一,但不论如何,多掌握其他的调试手段和工具,对于今后定位内核模块的错误,总会有帮助的。

打印 - printk

printk就是常规的打印输出,但与应用成的printf稍微不同,往往会看到这样的调用printk(KERN_ALERT"hello world"),其中的KERN_ALERT表示打印级别,内核源码中定义了多种打印级别,且看定义:

1
2
3
4
5
6
7
8
9
10
#define KERN_SOH      "\001"       /* ASCII Start Of Header */

#define KERN_EMERG KERN_SOH "0" /* 紧急事件消息,在系统崩溃前提示用的 */
#define KERN_ALERT KERN_SOH "1" /* 用于需要立即采取动作的情况 */
#define KERN_CRIT KERN_SOH "2" /* 临界状态,涉及严重的硬件或软件操作失败时提示 */
#define KERN_ERR KERN_SOH "3" /* 错误报告 */
#define KERN_WARNING KERN_SOH "4" /* 常规警告 */
#define KERN_NOTICE KERN_SOH "5" /* 普通提示,常见与安全相关的汇报 */
#define KERN_INFO KERN_SOH "6" /* 提示性信息,比如硬件信息 */
#define KERN_DEBUG KERN_SOH "7" /* 调试信息 */

从上定义可以看到,内核共提供了0-7个级别,数值越小,优先级越高

屏蔽其他级别打印

/proc/sys/kernel/printk文件很重要,可以通过它来屏蔽不同级别的打印输出,我们迅速写一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static int __init meme_init(void)
{
printk(KERN_EMERG"emerg 0\n");
printk(KERN_ALERT"alert 1\n");
printk(KERN_CRIT"crit 2\n");
printk(KERN_ERR"err 3\n");
printk(KERN_WARNING"warning 4\n");
printk(KERN_NOTICE"notice 5\n");
printk(KERN_INFO"info 6\n");
printk(KERN_DEBUG"debug 7\n");

return 0;
}
module_init(meme_init);

以上代码只是在模块加载的时候打印了7个级别的内容,现在做一件事:

1
2
3
4
5
6
7
8
9
10
11
12
# 查看该文件发现默认打印级别小于7,即除了KERN_DEBUG级别,其他都能显示到终端
/ # cat /proc/sys/kernel/printk
7 4 1 7

# 现在强制打印级别小于3,即KERN_ERR及其之后级别的内容不再显示
/ # echo 3 > /proc/sys/kernel/printk

# 加载模块
/ # insmod meme.ko
emerg 0
alert 1
crit 2

从上边的指令可以看到,通过修改/proc/sys/kernel/printk文件的值可以直接强制控制台打印的日志级别,这可要比你反复注释+编译的手段高明多了。

通过procfs文件查询调试

printk函数固然简单易用,但除了逼格很low之外还存在个技术上的障碍——大量使用printk会极大地拖累程序的性能,原则上也仅用于常规和错误信息提示,像for、while之类的循环内千万别用它。

然而实际上用户需要时刻掌握各种设备的状态信息,比如cpu当前频率/温度、内存占用率等等,printk显然不能胜任,轮到procfs登场了,我们知道proc是内核的一个虚拟文件系统,能够以文件的形式展现整个系统内部的信息,而通过procfs文件查询调试技术,就是在/proc目录下创建驱动模块自己的目录和文件——俗称入口,供用户随时访问。

我个人觉得,驱动暴露在proc文件系统的入口其实很少会用于“调试”,而是一些关键信息的展示,比如cpuinfomeminfo等文件,这就是典型的应用场景,cpu和内存驱动创建的文件,可以直接查看相关设备的细节信息。

proc文件系统中创建入口文件非常简单,总共有两种形式:

1. 普通proc入口

之前的学习笔记中,我们已经创建了meme驱动模块的设备节点/dev/meme,现在实现一个新功能,用户只需要通过命令cat /proc/meme/state即可查看设备节点/dev/meme的文件访问状态,例如打开、关闭、正在读、正在写等等。

首先,在procfs创建/删除入口文件的API有这么几个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// proc入口文件的结构体定义
struct proc_dir_entry;

// 在procfs下创建一个目录
// @name 要创建的目录名
// @parent 上级目录,NULL表示/proc
struct proc_dir_entry *proc_mkdir(const char *name, struct proc_dir_entry *parent);

// 在procfs下创建一个文件
// @name 要创建的文件名
// @mode 文件的访问权限
// @parent 所在目录,NULL表示/proc
// @proc_fops 文件操作结构,与字符设备一样的机制
struct proc_dir_entry *proc_create(const char *name, umode_t mode, struct proc_dir_entry *parent, const struct file_operations *proc_fops);

// 在procfs下删除一个文件或目录
// @name 要删除的入口名
// @parent 所在上级目录
void remove_proc_entry(const char *name, struct proc_dir_entry *parent);

然后,通过调用proc_mkdir()proc_create()即可创建出/proc/meme/state目录和文件了,几乎没什么难度,注意在创建proc的入口文件时需要指定一个file_operations *fops,其实就是文件访问函数的映射,和字符设备章节中的操作一模一样。而/proc/meme/state是一个只读状态文件,所以我们只需要实现read函数的功能——返回状态字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/**
* meme_proc.c
*/

#include <linux/proc_fs.h>

// 全局变量,meme的设备节点状态
int meme_cdev_state = 0;

static ssize_t meme_proc_read(struct file* filp, char __user* buf, size_t len, loff_t* off)
{
int rc = 0;
const char* state = NULL;

if (*off > 0) {
return 0;
}

switch (meme_cdev_state) {
case MEME_STATE_OPENED: state = "opened"; break;
case MEME_STATE_CLOSED: state = "closed"; break;
case MEME_STATE_READING: state = "reading"; break;
case MEME_STATE_WRITING: state = "writing"; break;
default: state = "unknown"; break;
}

len = strlen(state);
if ((rc = copy_to_user(buf, state, len)) < 0) {
return rc;
}
buf[len++] = '\n';
buf[len++] = '\0';
return len;
}

// 绑定用户层read时的触发函数
const struct file_operations proc_fops = {
.owner = THIS_MODULE,
.read = meme_proc_read,
};

// procfs入口目录
struct proc_dir_entry* meme_proc_entry = NULL;

int __init meme_proc_init(void)
{
// 即/proc/meme/state,用于记录设备描述符/dev/meme的文件访问状态
struct proc_dir_entry* meme_state_file = NULL;

// 创建meme模块的proc目录,即/proc/meme/
meme_proc_entry = proc_mkdir("meme", NULL);
if (meme_proc_entry == NULL) {
return -EINVAL;
}

// 创建/proc/meme/state文件,且该文件为只读
meme_state_file = proc_create("state", 0500, meme_proc_entry, &proc_fops);
if (meme_state_file == NULL) {
return -EINVAL;
}

return 0;
}

void __exit meme_proc_exit(void)
{
// 卸载模块时,自动删除所有/proc/meme所创建的文件及目录
remove_proc_entry("state", meme_proc_entry);
remove_proc_entry("meme", NULL);
}

以上是meme驱动模块在proc入口状态文件的相关实现,既然要监听的是/dev/meme设备节点的访问状态,上述代码是通过meme_cdev_state全局变量来实现的,故这边变量自然是在字符设备的代码实现中被修改的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* main.c
*/

static ssize_t meme_read(struct file* filp, char __user *buf, size_t size, loff_t* off)
{
meme_cdev_state = MEME_STATE_READING;
...
}

static ssize_t meme_write(struct file* filp, const char __user *buf, size_t size, loff_t* off)
{
meme_cdev_state = MEME_STATE_WRITING;
...
}

static int meme_open(struct inode* inode, struct file* filp)
{
meme_cdev_state = MEME_STATE_OPENED;
...
}

static int meme_close(struct inode* inode, struct file* filp)
{
meme_cdev_state = MEME_STATE_CLOSED;
...
}

如此一来,用户访问/dev/meme设备节点,meme_cdev_state值就会被改变,对应的状态自然就暴露到/proc/meme/state的read触发函数中了,完美~

测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
/ # insmod meme.ko 
meme: loading out-of-tree module taints kernel.
meme init: 250:0
/ # mknod /dev/meme c 250 0
/ # while true; do echo 123 >> /dev/meme; done &
/ # cat /proc/meme/state
opened
/ # cat /proc/meme/state
closed
/ # cat /proc/meme/state
opened
/ # cat /proc/meme/state
writing

2. seq_file接口

seq_file主要是用于处理那些比较“大”的proc入口文件。所谓的大不是说文件体积,举个例子,当我们需要开发一个串口驱动,而这个驱动需要记录串口每一次的收发历史记录,用这种方式再何时不过。seq是序列的意思,即通过迭代的方式,把驱动的某些状态信息按顺序打印出来。

seq_file提供了4个迭代函数:

1
2
3
4
5
6
7
8
9
10
struct seq_operations {
// 首次访问时触发
void * (*start) (struct seq_file *m, loff_t *pos);
// 结束访问时触发
void (*stop) (struct seq_file *m, void *v);
// 迭代访问时触发
void * (*next) (struct seq_file *m, void *v, loff_t *pos);
// 打印展示时触发
int (*show) (struct seq_file *m, void *v);
};

现在再创建一个入口文件/proc/meme/info,当用户通过命令cat /proc/meme/info时,打印0-100行计数内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/**
* meme_seq_file.c
*/

#include <linux/seq_file.h>

#define MAXNUM 100

// 访问文件时首先调用的接口
static void* meme_seq_start(struct seq_file* m, loff_t* pos)
{
int* v = NULL;
if (*pos < MAXNUM) {
v = kmalloc(sizeof(int), GFP_KERNEL);
*v = *pos;
seq_printf(m, "start: *(%p) = %d\n", v, *(int*)v);
}

// start函数返回NULL表示pos已到达文件末尾
return v;
}

// 每次迭代时调用,其中v是之前一次迭代(start或next)的返回值
static void* meme_seq_next(struct seq_file* m, void* v, loff_t* pos)
{
int num = *(int*)v;
if (num++ >= MAXNUM) {
// 返回NULL停止迭代
kfree(v);
return NULL;
}

// 每次迭代,v和文件游标都增加1
*(int*)v = *pos = num;
return v;
}

// 结束迭代时调用,如果在start中有内存分配,应该在这里进行内存清理
// 但由于next的最后一次迭代肯定返回NULL,所以这里的v地址一定为NULL
// 不需要作任何处理
static void meme_seq_stop(struct seq_file* m, void* v)
{

}

// 展示时调用,主要是将v的内容格式化并输出到用户空间
static int meme_seq_show(struct seq_file* m, void* v)
{
seq_printf(m, "show: *(%p) = %d\n", v, *(int*)v);
return 0;
}

// 映射seq的start、next、stop、show四个迭代器的函数
const struct seq_operations meme_seq_ops = {
.start = meme_seq_start,
.next = meme_seq_next,
.stop = meme_seq_stop,
.show = meme_seq_show,
};

static int meme_seq_open(struct inode* inode, struct file* filp)
{
// 绑定迭代操作的4个函数到/proc/meme/info文件
return seq_open(filp, &meme_seq_ops);
}

// /proc/meme/info的文件操作映射,除了open需要自己实现外,其他均使用内部定义好的
static const struct file_operations meme_seq_fops = {
.owner = THIS_MODULE,
.open = meme_seq_open,
.read = seq_read,
.llseek = seq_lseek,
.release = seq_release,
};

int __init meme_seq_init(void)
{
// 创建seq入口文件,即/proc/meme/info,并绑定seq相关文件函数
proc_create("info", 0500, meme_proc_entry, &meme_seq_fops);
return 0;
}

void __exit meme_seq_exit(void)
{
// 删除/proc/meme/info文件
remove_proc_entry("info", meme_proc_entry);
}

从上边的代码可以看到,除了实现4个迭代的触发函数之外,还需要实现文件的open触发,剩下的内容就和常规的proc入口文件创建机制没什么区别了。测试一下:

1
2
3
4
5
6
7
8
9
10
/ # insmod meme.ko
/ # cat /proc/meme/info
start: *(ada2e682) = 0
show: *(ada2e682) = 0
show: *(ada2e682) = 1
show: *(ada2e682) = 2
show: *(ada2e682) = 3
...
show: *(ada2e682) = 99
show: *(ada2e682) = 100

代码实现的比较简单,在start函数中为迭代分配一段内容空间用于计数,而在next每次迭代时将计数累加1直到100后跳出迭代,每次迭代后都会自动调用show将记录值打印出来。

通过strace命令监视

strace移植

由于Busybox默认是不带strace的,需要自行移植。

1
2
3
4
5
6
7
8
9
10
11
# 下载strace源码并移植
$ git clone https://github.com/strace/strace.git
$ cd strace && ./bootstrap
$ mkdir build && cd build
$ ../configure prefix=$(pwd)/install --host=arm-linux-gnueabihf CC=arm-linux-gnueabihf-gcc
$ make -j8 && make install

# 将strace命令拷贝至目标文件系统
$ sudo mount -o loop ../../rootfs.ext3 /mnt/
$ sudo cp install/bin/strace /mnt/usr/bin
$ sudo umount /mnt

参照官方文档测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/ # strace -c ls > /dev/null 
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
33.51 0.010491 201 52 48 openat
24.14 0.007557 157 48 47 stat64
14.51 0.004544 378 12 lstat64
5.88 0.001842 921 2 getdents64
5.58 0.001748 194 9 mmap2
4.45 0.001393 154 9 read
3.58 0.001120 140 8 mprotect
2.33 0.000730 121 6 _llseek
1.87 0.000586 117 5 fstat64
1.59 0.000498 124 4 close
1.19 0.000373 93 4 3 ioctl
0.58 0.000181 60 3 brk
0.46 0.000144 144 1 set_tls
0.32 0.000099 99 1 write
0.00 0.000000 0 1 execve
0.00 0.000000 0 2 2 access
0.00 0.000000 0 1 gettimeofday
0.00 0.000000 0 1 uname
0.00 0.000000 0 1 getuid32
------ ----------- ----------- --------- --------- ----------------
100.00 0.031306 170 100 total

又或者测试一下meme驱动模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/ # strace echo 123 > /dev/meme
...
...
#最后几行
close(3) = 0
set_tls(0x76fbe4f0) = 0
mprotect(0x76ef0000, 8192, PROT_READ) = 0
mprotect(0x76f12000, 4096, PROT_READ) = 0
mprotect(0x76f95000, 4096, PROT_READ) = 0
mprotect(0x533000, 12288, PROT_READ) = 0
mprotect(0x76fbf000, 4096, PROT_READ) = 0
getuid32() = 0
brk(NULL) = 0x537000
brk(0x558000) = 0x558000
write(1, "123\n", 4) = 4
exit_group(0) = ?
+++ exited with 0 +++

总之strace是一个非常牛逼的诊断工具,由于本文主要针对的是Linux驱动模块技术,关于这个命令的使用详解就不赘述了,自行Google。

oops消息

oops也就是内核甩了一跤所发出的惨叫,当然导致内核摔跤的绊脚石大概率是我们写出了质量底下的驱动模块导致。举个最简单的例子:

1
2
3
4
5
static void __init meme_init(void)
{
*(int*)NULL = 123;
}
module_init(meme_init)

以上是meme模块加载时的初始化代码,但是我们试图往NULL地址里赋值,很显然这将引发段错误,如果直接insmod模块,不出意外内核将发出一声惨叫——oops!!!

来看看效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/ # insmod meme.ko

meme: loading out-of-tree module taints kernel.
Unable to handle kernel NULL pointer dereference at virtual address 00000000
pgd = 7dfd5e30
[00000000] *pgd=00000000
Internal error: Oops: 805 [#1] SMP ARM
Modules linked in: meme(O+)
CPU: 0 PID: 832 Comm: insmod Tainted: G O 5.0.7 #1
Hardware name: ARM-Versatile Express
PC is at meme_init+0x18/0x94 [meme]
LR is at do_one_initcall+0x54/0x1fc
pc : [<7f005018>] lr : [<80102e70>] psr: 600f0013
sp : 9ddfbdc0 ip : 9dce1540 fp : 80b08c08
r10: 00000000 r9 : 7f002040 r8 : 7f005000
r7 : 00000000 r6 : ffffe000 r5 : 7f002240 r4 : 00000000
r3 : 0000007b r2 : 7ed12b89 r1 : 00000000 r0 : 00000000
Flags: nZCv IRQs on FIQs on Mode SVC_32 ISA ARM Segment none
Control: 10c5387d Table: 7de00059 DAC: 00000051
Process insmod (pid: 832, stack limit = 0x560ea799)
Stack: (0x9ddfbdc0 to 0x9ddfc000)
bdc0: 80b08c08 80b603c0 ffffe000 80102e70 00000000 80136cc8 006000c0 00000000
bde0: 00000000 9ddfbde4 9ddfbde4 7ed12b89 7f002088 a9245000 a9244fff fffff000
be00: 00000000 9dce1700 9dce1700 80b76b90 00000001 9dce1a24 7f002040 7ed12b89
be20: 7f002040 00000001 9dce1a00 9dce14c0 9dce1a24 801a03b8 9dce1a24 80232fc0
be40: 9ddfbf30 9ddfbf30 00000001 9dce1a00 00000001 8019f5c0 7f00204c 00007fff
be60: 7f002040 8019ca04 00000001 7f002088 8019c364 7f002154 7f002170 8094b580
be80: 7f00222c 7f006000 808e1fd8 808e1fe4 808e203c 80b08c08 9dce7600 fffff000
bea0: 80b0b5c4 006002c0 9dce7600 00000043 00000000 00000000 00000000 00000000
bec0: 00000000 00000000 6e72656b 00006c65 00000000 00000000 00000000 00000000
bee0: 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
bf00: 00000000 7ed12b89 00000080 00002a94 76d4da9c a9243a94 ffffe000 80b08c08
bf20: 004cb9a7 00000000 00000051 8019fbf8 a92018d2 a9201a40 a9201000 00042a94
bf40: a9243314 a924313c a9234104 00003000 00003180 00000000 00000000 00000000
bf60: 00001d50 0000002d 0000002e 00000014 00000000 00000010 00000000 7ed12b89
bf80: 9e61a600 76ee7404 00042a94 76f0ddc0 00000080 80101204 9ddfa000 00000080
bfa0: 004b78e3 80101000 76ee7404 00042a94 76d0b008 00042a94 004cb9a7 76f0f968
bfc0: 76ee7404 00042a94 76f0ddc0 00000080 00000001 7ec25eac 004cb9a7 004b78e3
bfe0: 7ec25b68 7ec25b58 0042b291 76de3f12 200f0030 76d0b008 00000000 00000000
[<7f005018>] (meme_init [meme]) from [<80102e70>] (do_one_initcall+0x54/0x1fc)
[<80102e70>] (do_one_initcall) from [<801a03b8>] (do_init_module+0x64/0x1f4)
[<801a03b8>] (do_init_module) from [<8019f5c0>] (load_module+0x1ed4/0x23b4)
[<8019f5c0>] (load_module) from [<8019fbf8>] (sys_init_module+0x158/0x18c)
[<8019fbf8>] (sys_init_module) from [<80101000>] (ret_fast_syscall+0x0/0x54)
Exception stack(0x9ddfbfa8 to 0x9ddfbff0)
bfa0: 76ee7404 00042a94 76d0b008 00042a94 004cb9a7 76f0f968
bfc0: 76ee7404 00042a94 76f0ddc0 00000080 00000001 7ec25eac 004cb9a7 004b78e3
bfe0: 7ec25b68 7ec25b58 0042b291 76de3f12
Code: e3475f00 e3a04000 e3a0307b e1a01004 (e5843000)
---[ end trace c5673f359b9dfbf8 ]---
Segmentation fault

多么熟悉的味道,信息量好大,让人眼花缭乱。别着急,主要留意几个地方:

  1. Modules linked in: meme(O+)说明导致oops的是meme模块驱动。
  2. PC is at meme_init+0x18/0x94 [meme]说明引发oops的函数是meme_init
  3. Exception stack(0x9ddfbfa8 to 0x9ddfbff0)以及之后的N个地址表示异常的栈区,读懂这些地址需要比较丰富的经验,刚开始没必要太纠结这部分。
  4. Segmentation fault表示错误类型是段错误

使用gdb、kgdb等调试器

关于gdb调试神器如何应用到内核模块,就不详述了,如果真想了解可以参考IBM这篇《Linux系统内核的调试》

其实gdb可以提供诸如断点、变量监视、单步执行等非常有用的功能,在追踪bug的效率方面无疑可以碾压上述的几种方式。但是可别忘了,我们写的是Linux内核模块,这些断点、单步执行的功能其实很难应用的内核层,而且驱动本质上是为硬件服务的,你要代码单步执行可以,硬件那边可没有暂停键啊!

所以,在Linux驱动模块的开发过程中,gdb这种工具反而应该尽量避免!

小结一下

  • printk()是最朴实有效的调试方式,Linux一共提供了8个打印级别,可以通过echo N > /proc/sys/kernel/printk来限制级别。
  • 可以通过创建/proc/<entry>入口文件来减少对printk的依赖
  • seq_file是procfs入口文件的特殊实现方式,主要用于状态信息庞大的驱动,按顺序迭代输出。
  • strace是一个非常有用的程序执行跟踪的命令。
  • oops本质上是内核某些地方执行出错产生的提示信息,可以用于定位问题根源。

Hard Work Does not Pay Off

作为一个程序员,努力工作往往得不到回报。你和你的少部分同事始终相信你们在办公室为某个项目付出了大量的时间。但事实上比你工作少的人可能得到的更多——有时是非常的多。如果你尝试每周强制高负荷工作超过30小时,你很可能工作强度太高了。应该考虑减少工作量以提高工作效率。

这种说法看似反直觉且有争议,但这是必然的结果,因为程序和软件开发是一种需要全情投入持续学习的过程。当你在做项目时你要理解更多领域方面的问题,找出更多有效方法来实现目标。为了避免无效工作,你必须流出时间来观察正在做的事情的效果,反思所看到的事物,并改变你相应的行为。

职业编程可不像是拼命跑几公里那样,在铺好的跑道尽头可以看到终点。更多的软件工程更像是一场马拉松越野赛。在黑暗中,只有一份粗糙的地图作为指引。如果你只是从一个方向出发,你可以跑得尽可能快,你可能会令人佩服,但你终究不会成功。你需要保持可持续的步伐,当你了解到更多你的位置和前进方向是,你需要调节你的航向。

此外,你总要学习更多有关软件开发的信息,尤其是编程技术。你很可能要读书,开会,和专家交流,尝试新技术的实践,以及从你的工作中掌握强大的工具。作为一个专业程序员,你必须保持在自己的专业领域自我更新——正如脑外科医生和飞行员应该在他们自己的领域保持最佳状态一样。你应该拿出傍晚、周末、假期来自学,因此你不能让你的傍晚、周末、和假期被你项目上的工作占用。你希望让一个本周工作了60小时的脑外科医生给你做手术吗,或者一周飞了60小时的飞行员?当然不想,准备和教育是他们专业的重要组成部分。

专注于项目,通过寻找聪明的解决办法来尽可能多地作出贡献,提高你的能力,反思自己正在做的,并调整你的行为方式。为避免让自己和我们的职业感到尴尬,像仓鼠一样在笼子里快速转动轮子。作为一个职业程序员你应该知道尝试以每周60小时的高负荷工作可不是一种明智的做事方式。请像专业人士一样:做准备、看效果、观察、反思、并改变!

我也不记得是从哪里听说的这本书,只是大概记得说里面的主角为人处事很厉害,而且发现国家领导人都在推荐此书,最近似乎还打算拍成电视剧,所以决定拜读一番。

小说讲述康熙年间陈廷敬在朝廷为官五十余载,期间各种政治博弈,你来我往,顷刻间便可断送职业生涯,脑袋搬家的“故事”。而陈廷敬年少有才,考取进士,本人凭借等、稳、忍、狠四个字,一路在官场起起伏伏犹如火中取栗,好在为人正直,大公无私,终究没被奸人所害,晚年领的康熙一句“宽大老城,几近完人”后,因身居要职,看透仕途凶险,便主动装聋,请辞病退,告老还乡,方才全身而退。

书中对各种人物的形象刻画,情节把握,我觉得还是比较好的。因为小说主要讲述朝野上下各种博弈,有点像男版《甄嬛传》(虽然我没看过),很多历史情结其实是一笔带过的。我在读本书之前听到的评价比较高,所以对本书的期待很高,但就我个人而言并没有太多吸睛的地方,但也不至于无聊,因为情节环环相扣,读起来还算有意思,最精彩的地方莫过于陈廷敬最后暗中连环参人,很是过瘾。

阅读全文 »

由于Linux中一切皆文件的思想,字符设备(键盘/鼠标/串口)、块设备(存储器)、网络设备(套接字)作为三种主要设备类型,我们在用户层基本可以通过open close read write来完成对设备的绝大部分操作。其中字符设备是涵盖面最广的一种,而且其概念相对容易理解,因此以字符设备为切入口,掌握内核与用户层之间的调用机制是再合适不过了。

本章主要学习

  • 字符设备驱动的基本概念
  • 如何向内核注册/注销一个字符设备
  • 如何通过设备节点、文件API调用驱动的函数

什么是字符设备?

所谓字符设备,泛指那些输入输出以串行方式、按顺序、一个字符一个字符的收发数据的设备。比如键盘,按键数据是一个一个发给系统的,哪怕是组合键;鼠标的坐标;LED灯;串口终端等。而这些数据如果不能及时被系统处理,要么被缓存,要么直接丢弃,但不论如何,当系统去处理这些数据的时候,也是按先来后到的顺序。

你可以设想以下,你的电脑突然卡顿了5秒钟,而这期间你疯狂打了10个字,毫无疑问这10个字的数据被缓存起来了,等系统空闲的时候开始处理它们,现在你是希望这10个字是按照你打的顺序出现在屏幕呢,还是希望它们毫无章法地出现在屏幕。

所以,字符设备,就是数据的IO总是以连续的形式处理的设备类型。

顺带提一句块设备,与字符设备概念做个对比。比如硬盘:你和你的右手出去约会并拍了很多照片回来,然后你把照片导入电脑,而硬盘的存储空间并不是像水杯一样从下往上按顺序填满,而是根据某种算法“随机”存,所以在真实的硬盘中,存放你1号照片的存储区域旁边,并不是2号照片,可能是某份日语学习资料;同理,3号照片可能与去年下载的种子呆在一起。

第一个字符设备 meme

在操作系统中,设备不一定要真实存在,虚拟设备也是设备,可以通过文件函数进行操作。因此为了掌握字符设备的知识,最简单的方法就是自己动手写一个虚拟字符设备。

meme的中文意思是米姆、莫因,表示文化DNA的意思,而作为我学习驱动的实例,它就是“记事本”,用户可以用多种方式读写它,而我给这个虚拟设备取这么个名字没别的意思——just for fun!

先来看看我打算要meme实现哪些内容:

  1. 向内核注册/注销自己
  2. 支持open/close/read/write等文件操作函数

字符设备的注册与注销

要像内核注册一个设备,必须提供它的设备号作为唯一标示,字符设备的设备号由主设备号次设备号共同组成

  • 主设备号(major):表示设备的分类编号,比如1代表u盘、2代表显示器、3代表打印机
  • 次设备号(minor):表示同一分类的实例编号,比如1号u盘、2号u盘、3号u盘

Linux内核可以静态注册动态分配的方式注册一个设备号:

1
2
3
4
5
6
7
8
9
10
// 动态申请,由内核自动扫描空闲设备号给它
// @dev 设备编号
// @firstminor 次设备号的起始编号
// @count 要申请多少个连续的设备号
// @name 设备名称,将出现在/proc/devices中
int alloc_chrdev_region(dev_t *dev, unsigned firstminor, unsigned count, const char *name);

// 静态注册,必须确保该设备号是空闲的
// @first 要注册的起始设备号,主+次
int register_chrdev_region(dev_t first, unsigned count, const char *name);

⚠️注意⚠️:在实际开发过程中,强烈建议仅通过动态分配的方式注册设备,因为说不准你自己规划好的设备编号会在未来被Linux内核给占用了。

字符设备的注册大体分两步:

  1. 初始化一个cdev字符设备的数据结构,向内核说明自己是字符设备
  2. 分配一个设备号与cdev绑定起来

下面就是一个最纯粹的字符设备注册和注销的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <linux/init.h>
#include <linux/module.h>
#include <linux/cdev.h>

// 字符设备结构体,该字符设备的核心数据表示
static struct cdev cdev;

// 驱动的主设备编号,每种类型的设备都有唯一编号
static dev_t devno = 0;

// 模块加载函数:注册字符设备
static int __init meme_init(void)
{
// 1. 向内核申请分配一个主设备号,此设备号从0开始,分配1个,名称为meme
if (alloc_chrdev_region(&devno, 0, 1, "meme") < 0 ) {
printk(KERN_ERR"init fail\n");
return (-1);
}

// 2. 初始化cdev结构体,并与fops文件操作之间建立联系
cdev_init(&cdev, &fops);

// 3. 正式向内核注册一个字符设备
cdev_add(&cdev, devno, 1);

// 注册成功后打印出该设备的主、次设备号
printk(KERN_ALERT"meme init: %d:%d\n", MAJOR(devno), MINOR(devno));
return 0;
}
module_init(meme_init);


// 模块卸载函数:注销字符设备
static void __exit meme_exit(void)
{
// 1. 向内核注销该字符设备
cdev_del(&cdev);

// 2. 向内核申请释放该设备号
unregister_chrdev_region(devno, 1);

printk("meme free\n");
}
module_exit(meme_exit);

// 杂七杂八的东西
MODULE_LICENSE("GPL v2");
MODULE_AUTHOR("Philon");
MODULE_DESCRIPTION("A character device driver");

可以看到,字符设备的注册/注销一般是在模块的加载和卸载函数中实现的,整个过程非常简单:
申请设备号 –> 初始化字符设备 –> 内核添加字符设备 –> 内核删除字符设备 –> 释放设备号

关于cdev_init / cdev_add / cdev_del等函数没有展开说明是因为后续有更好的方法,不要拘泥于此。不过注意cdev_init(&cdev, &fops)这行,fops是接下来要学习的一个非常重要的数据结构——文件操作

字符设备的文件操作

因为Linux的一切皆文件,理论上在用户层任何设备都可以通过文件相关的API函数调用设备,而作为设备驱动本身,很重要的一部分工作就是把用户层的各种文件操作关联到各种驱动函数当中,而这离不开一个结构体struct file_operations,先来看看内核对其定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
int (*iterate) (struct file *, struct dir_context *);
int (*iterate_shared) (struct file *, struct dir_context *);
__poll_t (*poll) (struct file *, struct poll_table_struct *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
unsigned long mmap_supported_flags;
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
int (*check_flags)(int);
int (*flock) (struct file *, int, struct file_lock *);
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
int (*setlease)(struct file *, long, struct file_lock **, void **);
long (*fallocate)(struct file *file, int mode, loff_t offset,
loff_t len);
void (*show_fdinfo)(struct seq_file *m, struct file *f);
#ifndef CONFIG_MMU
unsigned (*mmap_capabilities)(struct file *);
#endif
ssize_t (*copy_file_range)(struct file *, loff_t, struct file *,
loff_t, size_t, unsigned int);
loff_t (*remap_file_range)(struct file *file_in, loff_t pos_in,
struct file *file_out, loff_t pos_out,
loff_t len, unsigned int remap_flags);
int (*fadvise)(struct file *, loff_t, loff_t, int);
} __randomize_layout;

乍一看,很复杂!其实用面向对象的思维可以简单理解为——file_operations就是一个需要继承实现的interface。因此不要去纠结里面的内容有多少,大部分时候我们都是根据需要去实现其中的部分函数即可,等具体实现的时候再来参考对应函数在其中的声明形式。(通过该结构体打通了内核与应用间的任督二脉)

为用户层实现open/close和read/write

  • open和close一般都是处理一些设备复位的工作(或者干脆不做任何处理),但如果有逻辑要放在open/close里执行,务必留意应用程序可能会重复多次打开/关闭同一个文件的情况。
  • read/write在内核中主要围绕copy_to_usercopy_from_user两个函数来处理,它们负责把“交换”用户空间与内核模块的数据,但要特别小心文件偏移,不是所有设备在读写的时候都需要偏移的!比如读取LED灯的状态,每次都应该从头开始读。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// 对应用户层read读取函数,通常用户接收设备内容,如串口收数据
static ssize_t meme_read(struct file* filp, char __user *buf, size_t size, loff_t* off)
{
// 如果当前文件偏移量已经超出内存大小,直接返回错误
if (*off >= meme.size) {
return -ENOMEM;
}

// 如果当前可访问内存长度小于要读取的长度,仅读取可访问长度
if ((*off + size) > meme.size) {
size = meme.size - *off;
}

// 拷贝内核数据到用户空间
if (copy_to_user(buf, filp->private_data + *off, size)) {
return -EFAULT;
}

// 偏移相应的读取长度
*off += size;
return size;
}

// 对应用户层write写入函数,通常用于写入设备内容,如串口发数据
static ssize_t meme_write(struct file* filp, const char __user *buf, size_t size, loff_t* off)
{
// 如果文件偏移超出内存长度,就没必要再写了
if (*off >= meme.size) {
return 0;
}

// 如果要写入的数据长度比剩余可访问的内存长度还要大,仅写入可访问的内存长度
if (*off + size > meme.size) {
size = meme.size - *off;
}

// 拷贝用户数据到内核空间
if (copy_from_user(filp->private_data + *off, buf, size)) {
return -EFAULT;
}

// 偏移相应的写入函数
*off += size;
return size;
}

// 对应用户层open打开函数,就是打开设备描述符
static int meme_open(struct inode* inode, struct file* filp)
{
// 如果是第一次访问设备节点,分配内存
if (meme.data == NULL) {
meme.data = kmalloc(MEME_DEFAULT_DATA_SIZE, GFP_KERNEL);
}

filp->private_data = meme.data;

return 0;
}

// 对应用户层close关闭函数,就是关闭设备文件
static int meme_close(struct inode* inode, struct file* filp)
{
// nothing todo
return 0;
}

// 文件操作结构体,表示内核模块与用户层的函数对应关系
static const struct file_operations fops = {
.owner = THIS_MODULE, // 这其实是个结构体,比如THIS_MODULE->name
.read = meme_read,
.write = meme_write,
.open = meme_open,
.release = meme_close,
};

代码很简单,该说的注释都已经说了,除了file_operations里的这句:.owner = THIS_MODULE。首先要直到THIS_MODULE是一个结构体定义,之所以把它指向owner(模块自身)是为了防止模块正在进行其他操作(如读写)时,被rmmod卸载。每个以模块形式存在的驱动,最好都把这句加上!

设备节点与操作

设备节点是是用户层与内核模块沟通的桥梁!

作为初学者的我曾一度认为/dev目录下的节点就是设备本身,显然这种看法很幼稚,内核真正把设备和驱动暴露在/proc和/sys目录(再说一遍,其实是文件系统!!)而设备节点是在用户层创建的一个特殊文件,它的创建和删除几乎不影响内核对设备本身的管理。

设备节点的创建命令为:mknod <node_name> <type> <major> <minor>

把meme编译好之后实际测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 将驱动模块拷贝到镜像
$ cd ~/varm/os/
$ sudo mount -o loop rootfs.ext3 /mnt
$ sudo cp ../drivers/meme/meme.ko /mnt
$ sudo umount /mnt
$ ./power_on.sh # 启动varm系统

# 以下是qemu虚拟机内操作
/$ insmod meme.ko
meme: loading out-of-tree module taints kernel.
meme init: 250:0 # 👈字符设备注册成功,主设备号250,次设备号0

/$ cat /proc/devices
Character devices:
1 mem
2 pty
...
250 meme # 👈通过/proc/devices也可以看到模块信息
251 rpmb
252 usbmon
253 rtc
...

# 创建设备节点,根据设备号
/$ mknod /dev/meme c 250 0
# 用echo命令写内容到设备节点
/$ echo "I am Philon, I want to be a creator!" >> /dev/meme
# 用cat命令从设备节点读内容
/$ cat /dev/meme
I am Philon, I want to be a creator! # 👈说明读写、开关函数都成功了
4 (?U
?U
?U
444 TT?i????~,?~f??

从上面的操作中可以看到,meme驱动的基本文件操作功能已经具备,之所以最后读出来的内容有乱码,是因为这是一个设备,不是普通文件,该设备强制有0x100的存储空间,每次都会被全部读出来。

小结一下

  • 字符设备是一种通过顺序读写来操作的设备
  • 主设备号表示设备类型,次设备号表示设备实例号
  • file_operations定义了文件操作函数的接口
  • 设备节点是在用户层创建的特殊文件

The Guru Myth

任何在软件领域工作时间长的人都会听过这样的问题:

我碰到一个XYZ异常,你知道这是什么问题吗?

这些提问很少会包含堆栈跟踪、错误日志、或任何关于问题的上下文。他们似乎认为你是在不同的平面上操作,即便不基于证据的分析,也能找到解决方案。他们认为你是大师。

我们期待那些对软件陌生的人提出这样的问题:对他们而言,系统看上去是有魔力的。但让我担心的是在软件论坛里也看到这种话。类似的问题在程序设计版块中出现,例如“我正在构建库存管理。我是否要用乐观锁?”讽刺是的,提问人往往比回答者能更好的解答这个问题。提问者肯定知道上下文、必备条件,并能读到有利和不利的不同策略。但他们已经寄希望于你,在不要上下文的情况下给予他们智慧的答案。他们想要的是魔法。

是时候为了软件行业驱散这种大师神话了。“大师”是人。他们像我们一样是通过逻辑和系统性地分析问题。他们也利用心里的捷径和直觉。回顾一下你见过的最优秀的程序员:可能在某一点上,他知道的比你现在做的还少。如果一个人看起来像大师,那是他多年的学习和完善思维的过程。一个“大师”只是一个有着无穷无尽好奇心的聪明人。

当然,天资也存在巨大的不确定性。很多黑客都比以往任何时候都更聪明、知识渊博、生产力更高。尽管这样,揭穿大师神话也具有积极的影响。例如,当和一个比我聪明的家伙工作的时候,我会干一些跑腿的活,以提供足够的上下文来有效利用他或她的能力。移除大师神话也意味着移除了感知障碍。没有了魔法屏障,我看到了自己可以持续进步。

最后,软件领域的最大障碍之一是那些坚定传播大师神话的聪明人。可能是出于自尊心,或者是在客户或领导那里增加一点价值的策略。讽刺的是,这种态度反而会导致聪明人贬值,直到他们的贡献成长不如他们的同行。我们不需要大师。我们需要愿意培养他所在领域专家的专家。这是我们所有人的空间。

本周到杭州出差,第一次住全季酒店,在附近一家给人仿佛穿越到80年代的如家酒店的衬托下,全季简直闪瞎了我的狗眼。那装修、那格调、那门面,只能用两个字来形容——内敛😂,但是,全季不是我住过最奢华的酒店,但一定是我目前住过最舒服的连锁酒店,也许如它的老板所说,这是面向新中产的中档型酒店,所以对我等打工一族而言,全季的逼格,很是受用。

说到逼格,我还是第一次见有连锁酒店会在客人的床头柜前放一本书的,端起一看《创始人手记,一个企业家的思想、工作和生活》作者季琦,再看封面:“这特么谁啊?”翻看一下:“哦,这家酒店的老总,难怪…”反正闲来无事,就随便翻了翻,别说,写得挺好!这就是本周我读此书的全部原因。

阅读全文 »

话说上回:根文件系统的dev proc sys三个目录并非普通的目录,它们都是不同的文件系统,并不挂载到硬盘分区,而是由内核专门管理的内存区域,便于以文件的形式让用户层与内核层的交互。而由于我们第一节定制的最小Linux操作系统是个“阉割版”,并不存在proc sys两个目录,而且dev目录实质上也是手动创建的,随根目录直接挂载到硬盘,终归不符合“江湖规矩”。

因此为了之后驱动开发做铺垫,本章节主要说明Linux文件系统的启动顺序,解释清楚rootfs加载之后是如何一步步把这些特殊的目录挂载到与之对应的文件系统当中的。所以本回算是一个番外篇!

根文件系统初始化

我们都知道嵌入式操作系统的启动步骤是这样的:u-boot –> kernel –> rootfs
但rootfs被加载之后还有很繁杂的过程,是这样的: rootfs –> /sbin/init –> /etc/inittab –> /etc/rcN.d –> /etc/init.d/rcS

  • init是系统启动后的第一个程序,它的进程ID为1,之后的进程都是它的后代,
  • inittab是初始化表,相当于init的配置文件,根据不同的运行级别执行不同的脚本或命令
  • rcN.d是不同运行级别的初始化“功能清单”,配合inittab使用
  • rcS是初始化脚本

关于Linux运行级别

运行级别在嵌入式Linux和PC-Linux中的区别是很大的,因为PC-Linux一般是跑在服务器上,多用户、升级、维护、恢复等场景,面对众多的后台服务,每种场景都需要定制不同的启动策略,所以才孕育除了“运行级别”这个概念。嵌入式Linux(或者说busybox版文件系统)就没那么复杂了,智能终端往往不会有多用户和维护的场景,如果死机了,就用锅铲拍一拍,所以busybox保留了Linux运行级别的机制,但实际上并没有使用

为了之后讲清楚inittab语法,了解一点运行级别的知识也不坏。

Linux下有无数的服务要跑,称之为守护进程(Daemon),比如httpd sshd ftpd等等,正常情况下我们希望这些服务能开机启动,所以网上有很多教程都让你把要开机启动的命令写到/etc/init.d/rcS文件中。这个固然没错,但忽略了一点,该脚本是“无脑”加载的,假如我们要对系统进行恢复出厂设置,那么前面的三个服务在系统重启后根本没必要运行了;有时候为了排查系统bug,启动一堆多余的服务反而造成障碍等。关于在不同的模式下执行不同的开机启动方案,就是Linux的运行级别

所谓运行级别就是字面意思,在不同的场景下定制不同的后台服务,Linux一共7种运行级别(0-6):

Level Mode Description
0 关机 不要设置为initdefault动作
1 单用户模式 root权限,常用于系统恢复/维护
2 多用户模式(停用网络) 登陆后进入命令行
3 多用户模式(启用网络) 登陆后进入命令行,最常见
4 未使用/预留
5 图形界面模式 登陆后进入GUI界面,PC上最常见
6 重启 不要设置为initdefault动作

可以通过runlevel命令查看自己的系统当前处于哪个运行级别:

1
2
$ runlevel 
N 5 # 表示当前运行在GUI模式

⚠️ 一般而言0、1、6的运行级别都是一样的,但2-5分别表示什么运行级别,不同的Linux发行版不一样。

关于inittab

关于inittab文件需要掌握的只有两点:

  1. 它是init进程的配置文件,PC-Linux的inittab具体说明可参考👉oracle官方手册
  2. 它有自己的语法规则,'<id>:<runlevels>:<action>:<process>'表示'唯一标示:运行级别:执行动作:命令程序'

(由于busybox中的init机制不同于PC-Linux,以下内容仅以嵌入式端busybox版本为主,具体参考👉busybox inittab)

⚠️ id在busybox里并不作为标示,而是用于指定启动程序的tty,通常为缺省值
⚠️ runlevels在busybox中被完全忽略,总是为缺省值

执行动作<action>

action表示以什么样的方式执行程序,有很多种动作可选:

Action Description
sysinit 系统初始化时触发,最先被执行
respawn 如果程序文件不存在,忽略它;如果程序挂了,重启它;如果程序已经在跑了,跳过它
askfirst 相当于respawn,但执行命令前会先显示”Please press Enter to activate this console.”,等到用户回车后才执行
wait 启动程序并等它运行结束,然后再执行之后的程序
once 启动程序,不等待,死了也不管
restart 当收到重启信号时触发
ctrlaltdel 当按下'ctrl+alt+del'组合键后触发
shutdown 当关机时触发

命令程序<process>

就是常规的命令行,如果有特殊要求,也可以通过exec sh来执行。

制作一个简单的inittab

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 系统初始化时,执行rcS脚本
::sysinit:/etc/init.d/rcS

# 如果用串口终端登陆,要求用户先按一下回车才能进入,并配置串口通信参数
::askfirst:/sbin/getty 115200 ttyS0

# 指定其他终端登陆的方式
tty2::askfirst:-/bin/sh
tty3::askfirst:-/bin/sh
tty4::askfirst:-/bin/sh

::restart:/sbin/init

# 关机要卸载有关驱动,停用交换空间
::shutdown:/bin/umount -a -r
::shutdown:/sbin/swapoff -a

从配置中的第2行就能看出,为什么我们需要开机启动的程序都必须卸载/etc/init.d/rcS这个地方了😄。rcS就是一个普通的shell脚本,想怎么写都行,达到目的即可。通常我们会在其中完成网络配置、各种后台服务启动、环境初始化等等。

文件系统自动挂载

初始化过程中,在应用程序启动之前,需要先确保Linux环境就绪,这其中就包括很底层的各种文件系统和驱动的挂载。设想一个网络摄像机,在开机后应用程序开始采集图像编解码并通过网络上传到服务端,结果发现/dev下根本没有设备文件,尝试用ps看一下进程列表,结果啥也没有,发现/proc目录是空的,会不会很崩溃,赶紧拿锅铲拍一拍😂。

因此,开机自动挂载文件系统是非常重要的,首先可以明确rootfs已经被内核自动挂载到根目录了,根据上一小节的学习可知,至少还有4个文件系统需要挂载,对应命令为

1
2
3
4
mount -t proc proc /proc    # 进程文件系统挂载
mount -t sysfs sysfs /sys # 子系统文件系统挂载
mount -t tmpfs tmpfs /dev # 设备描述符不要直接挂载到flash,避免频繁读写
mount -t tmpfs tmpfs /tmp # 就是个块内存,掉电不保持,所以叫临时

我们当然可以把这些命令添加到/etc/init.d/rcS脚本,如果需要卸载时又一条条的umount,不觉得这样很麻烦么,有没有一种方便点的文件系统挂载方式。

/etc/fstab文件解析

fstab翻译过来就是文件系统配置表,配合命令mount -aumount -a使用,命令会根据配置表的内容逐条挂载文件系统到指定的挂载点。

fstab同样有自己的格式要求,一行内容表示一个文件系统的挂载,内容为:

1
2
3
4
5
6
# 设备或目标  挂载点        fs类型  选项       是否备份  是否检查
# device mount-point type options dump pass
proc /proc proc defaults 0 0
sysfs /sys sysfs defaults 0 0
tmpfs /dev tmpfs defaults 0 0
tmpfs /tmp tmpfs defaults 0 0

如果/etc/fstab已存在,只需手动输入mount -a,就会根据配置自动把表里的文件系统挂载上。如果想开机自动挂载怎么办,还记得/etc/inittab么😁?添加两行:

1
2
::sysinit:/bin/mount -a       # 开机自动挂载
::shutdown:/bin/umount -a -r # 关机自动卸载

完善varm的rootfs

明白了rootfs的启动过程以及其它文件系统的自动挂载原理,接下来就该完善之前做好的“阉割版”Linux了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$ cd ~/varm/os/rootfs
$ mkdir -p etc/init.d proc sys dev tmp # 创建相关目录
$ vim etc/inittab # 创建init配置表
# 以下为inittab内容
# ::sysinit:/bin/mount -a
# ::sysinit:/etc/init.d/rcS
# ::askfirst:/bin/sh
# ::ctrlaltdel:/sbin/reboot
# ::shutdown:/sbin/swapoff -a
# ::shutdown:/bin/umount -a -r
# ::restart:/sbin/init

$ vim etc/fstab # 创建自动挂载配置表
# 以下为fstab内容
# proc /proc proc defaults 0 0
# sysfs /sys sysfs defaults 0 0
# tmpfs /dev tmpfs defaults 0 0
# tmpfs /tmp tmpfs defaults 0 0

$ vim etc/init.d/rcS # 创建开机启动脚本
# 以下为rcS内容
# #! /bin/sh
# for i in 1 2 3 4
# do
# /bin/mknod /dev/tty$i c 4 $i
# done
$ chmod +x etc/init.d/rcS # 脚本必须有“可执行”权限

完成rootfs的改造后,重新将其写入镜像文件中:

1
2
3
4
$ cd ~/varm/os
$ sudo mount -o loop rootfs.ext3 /mnt
$ sudo cp -r rootfs/* /mnt
$ sudo umount /mnt

小结一下

  • 嵌入式Linux的文件系统启动顺序为init –> inittab –> fstab –> rcS
  • inittab的基本语法为'<id>:<runlevels>:<action>:<process>'
  • fstab的基本语法为'device mount-point type options dump pass'
  • /etc/init.d/rcS就是一个普通脚本,开机启动的入口