----------------------------------------
linux0.11 阅读笔记
writer: hjjdebug
date: 2019年 07月 30日 星期二 10:20:44 CST
----------------------------------------
代码从以下网址获取:
https://github.com/voidccc/linux0.11
或者
https://github.com/hjjdebug/linux0.11
目前的cpu 已经进展到64bit, 老内核是32位程序,所以在64位机器上编译出32位程序,要做如下调整
请对应修改Makefile
as --32
gcc -m32
ld -m elf_i386
我的cpu 64bits, 系统ubuntu14.04, gcc version: 4.8.4
甲: 理解x32 汇编
1. 内嵌汇编语言检查
读懂strstr() 和 strtok(),
只所以如此要求,是因为不调试只靠读代码甚至是带注释的代码也看不懂了.
因为注释毕竟是有限的,前因后果没法描述清楚,也是对注释的一个挑战!
而且调试会让你知道的更多. 在你读懂的时候,必须掌握一些技巧.
例如想近距离观察一下内嵌汇编到底怎样被编译成了汇编,
用下面命令生成反汇编文件,比直接在gdb中看舒服!
objdump -S -d string.o >mystring.s
注意,反汇编obj文件有地址重定位问题.注意鉴别!
AT&T语法,base(offset, index, i),就是 *(base+offset+index*i)
0x4(%eax,%edx,4) => *(4+%eax+(%edx * 4)), 里边是计算地址的公式,取该地址处内容.
例如:
mov 0x4(%eax,%edx,4),%eax
将某地址处内容送eax.
2. 内嵌汇编也有出错的时候,例如
getbase 函数,在-g3 不优化时,dh,dl 不能获得期望的输出(被edx覆盖),跟踪调试后改为ch,cl 正确
3. 80386汇编也确实有点刁,还要设置gdt及各个段描述符等才能让cpu工作, 进入用户模式前还要设置ldt,tr等段及描述符。
启用了mm还要设置内存映射表,好在用户程序不需要管这些破烂事了. 这无疑增加了内核代码的阅读难度!
要认真阅读linux0.00代码, 加深对386编程的理解,它是linux0.11的一个阶梯.
乙: 理解进程概念
进程是一个运行中程序. 进程起码包含所有的寄存器,还包括程序区,数据区,bss区,x86用一个tss段一个ldt段对应一个进程
1. 进程的创建
进程的创建是通过fork来创建的,
先找到一个空的任务槽,find_empty_process, 返回是是一个任务号nr(1,2,3...)
然后再copy_process
copy_process 栈中已经保存了所有寄存器内容. 足以构建一个进程结构变量
进程结构变量存储在一个新申请的页中. 其地址(物理地址)放入task[nr];
copy_mem(nr,p), nr 确定了新的data_base(及code_base)=nr*0x400,0000, 这是线性地址.
要设置p->ldt[1](cs),p->ldt[2](ds)为新new_code_base,new_data_base. 所以任务1的线性地址都是0x400,0000以上
设置ldt表使用的是线性地址.
需要构建新的页目录项,需要申请新的页表,并拷贝旧页表项,其物理映射还是指向同一个代码区和数据区,但设置数据区不可写.
当对数据区有写操作时, 会引起页写保护错误, 错误处理程序从出错的线性地址找到他的页表入口地址,将这个内存页置为可写,
若有必要,也可能新申请一页内存, 让这个table_entry的内容指向这个新的页. 从而可以使用这个新申请的页内存.
在这个表中所填的地址是物理地址,就是出现在memory 地址线上的地址。
(开启了mm的后的内存访问方式)
子进程只是copy了父进程页目录和页表项,并没有真正copy代码区和数据区,这需要写时复制和需求加载,将它们滞后处理了。
2. fork 函数是唯一一个
无输入参数有两个返回值的函数,返回值用以区分是父进程还是子进程
fork 是怎样做到输入无参数,输出两个返回值的?
if(pid=fork())
{ // 父进程
}
else
{//pid=0 子进程
}
简单的说是copy_process 函数修改了子进程的TSS(task stauts segment)内容,使它的eax=0. 将来子进程恢复时,eax=0.
fork() 调用int80 系统中断,返回后如果没有发生调度,将继续执行父进程,把last_pid 付给eax返回.
若此时发生了进程调度,或者以后发生了进程调度,调度到了子进程,子进程从它的TSS 中恢复上下文
执行的位置被定义在int 80 之后(已经存储在了TSS1段中)(eax 向pid赋值之处), 但eax 在上下文中被定义为0,故返回0.
这样就说明了若未发生调度,将返回last_pid, 若调度到了子进程,则返回0的道理.
那会不会调度到父进程呢? 不调度就是父进程,调度走了父进程就要歇一会了,临走会把自己的环境保存到自己的TSS段中.
3. 从进程0 fork 进程1, 第一次缺页中断是怎么来的
进程1 fork 进程0,与其共享代码段和数据段,但设置数据段为只读(*table_entry 项中设定),当调度到进程1,向数据段(0x17)写
数据时会引起写保护异常,异常服务程序会申请新一页内存,将*table_entry 指向本页,并设内存可读写,将进程1的数据内存与
进程0的数据内存进行了分离.
虽然进程0,进程1的代码区物理内存是一样的,但线性地址却是不一样的,后者比前者大64M, 但被映射到了同一个物理地址
在这里,需要理解逻辑地址,线性地址和物理地址的概念.
简言之,逻辑地址是选择子加地址偏移,编译器看到的地址. 由于选择子往往是不变的,所以更侧重于地址偏移.
线性地址是cpu地址,加上选择子所指基地址后形成的地址.
物理地址是线性地址经mmu转化形成的地址,是memory上看到的地址
丙: 驱动;
无驱不动,驱动就是基本的数据输入输出
1. 串行口驱动
需要写端口初始化程序,中断服务程序等。
在rs_init 中
设置了串行口(0x2f8,0x3f8端口基址)波特率为2400.
设置中断门0x24 -> rs1_interrupt, 因为串口1中断矢量是0x24
设置中断门0x23 -> rs2_interrupt, 因为串口2中断矢量是0x23
中断服务程序:
首先识别中断源,对于线路状态改变和modem状态改变只需要读状态寄存器即可,对于读字符中断(芯片收到了字符)
读入放到第一缓冲区,再处理字符到第二缓冲区.
对于写字符,如果写缓冲空了,就关发送空中断请求寄存器
中间层接口程序(tty_read, tty_write): tty_write->rs_write 会打开发送空中断请求寄存器
tty_write->con_write, 可以看到并不是简单的把字符向显示缓冲区copy,还要处理控制字符,还要4中工作状态,可以控制终端显示.
tty_read 从queue 的secondary 中读取数据
2. 键盘驱动
在con_init 中:
set_trap_gate(0x21,&keyboard_interrupt);
中断服务程序:
虽然大部分健的处理方式是do_self, 把一个或几个字符放入队列(有ctrl,alt,shift 映射),但也有功能健处理,有e0,e1扩展健处理
tty_read 可以调用到它
3. 显示器驱动
虽然不是中断程序,但需要处理好几个变量.
a. 光标(x,y) 用 pos=origin + y*video_size_row + (x<<1); 变量跟踪光标
屏幕大小(video_num_columns,video_num_lines)
起始内存地址origin
b. 设置origin, 设置光标
static inline void set_origin(void)
{
cli();
outb_p(12, video_port_reg); //控制寄存器
outb_p(0xff&((origin-video_mem_start)>>9), video_port_val); //值寄存器
outb_p(13, video_port_reg);
outb_p(0xff&((origin-video_mem_start)>>1), video_port_val);
sti();
}
static inline void set_cursor(void)
{
cli();
outb_p(14, video_port_reg);
outb_p(0xff&((pos-video_mem_start)>>9), video_port_val);
outb_p(15, video_port_reg);
outb_p(0xff&((pos-video_mem_start)>>1), video_port_val);
sti();
}
c. 上滚,下滚是通过移动内存来实现的.
4. 硬盘驱动
在hd_init()中:
set_intr_gate(0x2E,&hd_interrupt);
中断服务程序hd_interrupt: 调用do_hd, 这个do_hd 到底是个什么东西?在代码中找没有看到声明.
nm blk_drv.a |grep do_hd
看到 00000000 B do_hd , 在BSS 区
objdump -t blk_drv.a |grep do_hd
00000000 g O .bss 00000004 do_hd
看到是一个object, 在bss 区,大小为4, 可判断其是一个函数指针变量,可以赋值.
其实其技巧是(其实不叫技巧,只是增加阅读难度罢了).
#define DEVICE_INTR do_hd
void (*DEVICE_INTR)(void) = NULL;
它用这种方式定义了一个函数指针,与前边看到导出符号在bss相一致.
继续执行do_hd
do_hd 可以是read_intr,write_intr
写操作:
port_write(HD_DATA,CURRENT->buffer,256);
读操作:
port_read(HD_DATA,CURRENT->buffer,256);
调用硬盘驱动:
请求项的最大个数是32个。
硬盘请求项的执行函数是do_hd_request,它负责根据子设备号,将current_request转化为柱面,磁头,扇区
然后调用hd_out 函数对硬盘发送命令.
启动写:
hd_out(dev,nsect,sec,head,cyl,WIN_WRITE,&write_intr);
启动读:
hd_out(dev,nsect,sec,head,cyl,WIN_READ,&read_intr);
5. ROOT_DEV = 0x301 是什么意思?
它包含主设备0x3, 从设备0x1的含义.
主设备号将代表块设备的顺序号.
从设备号代表了数据位置,一个磁盘可以有4个分区.
struct blk_dev_struct blk_dev[NR_BLK_DEV] = {
{ NULL, NULL }, /* no_dev */
{ NULL, NULL }, /* dev mem */
{ NULL, NULL }, /* dev fd */
{ NULL, NULL }, /* dev hd */
{ NULL, NULL }, /* dev ttyx */
{ NULL, NULL }, /* dev tty */
{ NULL, NULL } /* dev lp */
};
struct blk_dev_struct {
void (*request_fn)(void);
struct request * current_request;
};
前一项是request_fn, 硬盘就是do_hd_request,
后一项是当前请求项指针.
可见主设备号可以找到do_hd_request,
当然,如果主设备号是2,就会找到do_fd_request 了.
可见主设备号代表了驱动.
从设备号,对硬盘而言, 代表了分区号,分区号代表了一块硬盘位置.
分区位置是从硬盘分区表获取的. 从设备号在do_hd_request 函数中有使用.
0x300 第一块硬盘 0x305 第二块硬盘
0x301 第一个分区 0x306 第一个分区
0x302 第二个分区 0x307 第二个分区
0x303 第三个分区 0x308 第三个分区
0x304 第四个分区 0x309 第四个分区
读到此我知道为什么有人听不懂技术讲座了,因为有的东西是需要有基础的,只有理解的人才能讲清楚.
听不懂首先是讲的人没有讲清楚,然后是听的人基础不够或没有听清楚.
初始化,在hd_init() 中
#define DEVICE_REQUEST do_hd_request
blk_dev[MAJOR_NR].request_fn = DEVICE_REQUEST;
高速缓冲区:
extern int end;
struct buffer_head * start_buffer = (struct buffer_head *) &end;
这个end 是什么,是 BSS(Block Started by Symbol segment)段的结尾地址,直接拿来使用的.
00023fb0 B end
buffer_init 就是让buffer_head * 去指向1K 大小的缓冲块。
将来查找空缓冲块,就靠freelist 了, 当然,你需要维护好这个自由节点列表.
硬盘包活软盘的数据都会存到这个缓冲块中.
bh=find_buffer(dev,block);
以dev 和 block 做键值(hash key)來查找.
其中,bread 函数,会返回一个buffer_head *.
struct buffer_head * bread(int dev,int block)
关于 NR_BUFFERS, 它就是nr_buffers, 是混用的, linus 当时还不知道应该用全大写代表常量.
其它:
1. printk 与 printf 有何不同,为什么?
读完代码是很简单的, printf 从用户区获得数据(段选择子0x17), printf 调用write 函数走文件系统.
printk 是内核代码,需要从内核区获取数据而不是用户区,所以让fs也指向内核区就可以了.(段选择子0x10),
printk 直接调用tty_write 不走文件系统.
丁: 文件系统:
1. mount_root 功能:
首先,从磁盘读超级块.
if (!(p=read_super(ROOT_DEV)))
ROOT_DEV 是一个整形变量,由ORIG_ROOT_DEV 来赋值
ROOT_DEV = ORIG_ROOT_DEV;
#define ORIG_ROOT_DEV (*(unsigned short *)0x901FC)
0x901FC 是bootsect 被0x7c00 处移过来的, 由bootsect 1FC位置2个字节来确定.
read_super 会把超级块及inode位图,izone位图都读走.
这两个位图都不会超过8个盘块,分别由超级块结构的8个buffer_head 来指向.
i=p->s_nzones; // 例如i=62000 个逻辑块, 其对应的比特图块是62000 >> 13 个快,因为1块是1Kbytes,8Kbits
while (-- i >= 0)
if (!set_bit(i&8191,p->s_zmap[i>>13]->b_data)) //在这8Kbits数据中判别其对应位是否置位. i&8191 是偏移值.
free++;
然后从磁盘读根INODE
if (!(mi=iget(ROOT_DEV,ROOT_INO)))
定义了一个表inode_table,表的大小是NR_INODE 32 个, 表是一个环形表,每次需要从表中取到一个empty_inode.
get_empty_inode();
2. 打开文件 open 的过程,例如:
open("/dev/tty0",O_RDWR,0)
open 会通过系统调用调用 sys_open, 返回一个fd.
每个进程,只允许打开NR_OPEN = 20 个文件, 这样fd都是小于NR_OPEN 的 (fd<NR_OPEN)
file_table 只包含了 NR_FILE = 64 个文件,说明所有的进程加起来打开文件也不能超过64个,小系统,还可以.
打开的文件会指向file_table中的一项, current->filp[fd]=f;
int open_namei(const char * pathname, int flag, int mode, struct m_inode ** res_inode)
给定路径,返回给定路径所对应的inode
它调用:
static struct m_inode * dir_namei(const char * pathname, int * namelen, const char ** name)
给定路径,返回目录节点和文件名称, 把一件事情分两步来做.
它调用
static struct m_inode * get_dir(const char * pathname)
给定路径,返回最末一级目录节点. 当路径中不再包含'/'时,会退出。这有循环调用的过程
最初的目录节点从根或者当前目录开始搜索.
它调用
static struct buffer_head * find_entry(struct m_inode ** dir, const char * name, int namelen, struct dir_entry ** res_dir)
给一个目录节点,读出该目录数据,从这些数据中查找输入的名字,返回buffer_head* 及 res_dir
这是具体的操作,从一个目录中,查找名字. 返回目录项和buffer_head* bh, bh说明了磁盘数据在内存中位置.
3. write(1,...) 为什么是向控制台写信息?
int write(int fd, const char * buf, size_t count);
因为fd=0是open("/dev/tty0",...)返回的,fd=1,fd=2由fd=0复制
其inode->i_mode是char 设备, 所以调用rw_char,且其dev 是inode->i_zone[0]
依据主设备号将调用,rw_ttyx, 它再调用 tty_write(minor,buf,count)); 由于minor 为0,所以就是显示器了.
其tty->write 就i时 con_write
戊: 阅读的一些注意事项
a. 函数及变量不符合匈牙利命名法.
缺点: 名称失掉了某些信息和意义
b. 全部大写,并不代表它是一个常量. 不过可以当成一个常量来看,例如NR_BUFFERS
c. 输入参数不做保存而直接操作,
缺点: 不利于调试,因为当你想看输入参数时,已被破坏
d. 有的地方还可以再优化.
总的来说,代码干练精巧!
阅读代码总线索: main.c
阅读手段:bochs+gdb远程调试(vim做前端)