深入理解 Linux 内核—系统调用

释放双眼,带上耳机,听听看~!

Unix 系统通过向内核发出系统调用实现了用户态进程和硬件设备之间的大部分接口。

POSIX API 和系统调用

应用编程接口:只是一个函数定义,说明如何获得一个给定的服务。
系统调用:通过软中断向内核态发出一个明确的请求。

一个 API 没必要对应一个特定的系统调用,比如抽象的数学函数。
一个 API 可能调用几个系统调用。

系统调用属于内核,而用户态的库函数不属于内核。

系统调用处理程序及服务例程

当用户态的进程调用一个系统调用时,CPU 切换到内核态并开始执行一个内核函数。
80×86 中,有两种调用 Linux 系统调用的方式,最终结果都是跳转到所谓系统调用处理程序的汇编语言函数。

进程通过参数系统调用号来识别所需的系统调用,eax 寄存器用作此目的。

系统调用返回一个整数值:

  • = 0 表示系统调用成功结束

  • < 0 表示一个出错条件,存放在 errno 变量中,该值是由封装例程从系统调用返回后设置。

系统调用处理程序与其他异常处理程序结构类似,执行下列操作:

  • 内核态堆栈保存大多数寄存器的内容。
  • 调用名为“系统调用服务例程”的相应的 C 函数处理系统调用。
  • 退出系统调用处理程序:用保存在内核栈中的值加载寄存器,CPU 从内核态切回用户态。

xyz() 系统调用对应的服务例程的名字通常是 sys_xyz()。

图 10-1 中,箭头是执行流,SYSCALL 和 SYSEXIT 是汇编语言指令,分别将 CPU 从用户态切换到内核态和从内核态切换到用户态。
深入理解 Linux 内核---系统调用

总结:

  • 系统调用处理程序是汇编指令,系统调用服务例程是 C 语言函数。
  • 系统调用处理程序调用系统调用服务例程。

内核利用系统调用分派表将系统调用号与相应的服务例程关联。
该表存放在 sys_call_table 数组中,有 NR_syscalls 个表项:
第 n 个表项包含系统调用号为 n 的服务例程的地址。

NR_syscalls 宏只是对可实现的系统调用最大个数的静态限制,并不表示实际已实现的系统调用个数。
分派表中的任意一个表项可包含 sys_ni_syscall() 的地址,该函数是”未实现“系统调用的服务例程,仅仅返回出错码 -ENOSYS。

进入和退出系统调用

本地应用可通过两种不同的方式调用系统调用:

  • 执行 int $0x80 汇编指令。Linux 旧版本中。
  • 执行 sysenter 汇编指令。Linux 新版本引入。

同样,内核可通过两种方式从系统调用退出,从而使 CPU 切换回用户态:

  • 执行 iret 汇编指令。Linux 旧版本中。
  • 执行 sysexit 汇编指令。Linux 新版本引入。

支持进入内核的两种方式需要解决兼容性问题。

通过 int $0x80 发出系统调用

向量 128(0x80)对应于内核入口点。
在内核初始化期间调用 trap_init(),用如下方式建立对应于向量 128 的中断描述符表表项:


1
2
3
1set_system_gate(0x80, &amp;system_call);
2
3

该调用将下列值存入该门描述符的相应字段:

  • Segment Selector,内核代码段 __KERNEL_CS 的段选择符。
  • Offset,指向 system_call() 系统调用处理程序的指针。
  • Type,置为 15,表示这个异常是一个陷阱,相应的处理程序不禁止可屏蔽中断。
  • DPL,描述符特权级,置为 3,允许用户态进程调用该异常处理程序。

因此,当用户态进程发出 int $0x80 指令时,CPU 切换到内核态并从地址 system_call 处开始执行指令。

system_call()

首先将系统调用号和该异常处理程序可以用到的所有 CPU 寄存器保存到相应的栈中,
不包括由控制单元已经自动保存的 eflags、cs、eip、ss 和 esp 寄存器。
在 ds 和 es 中装入内核数据段的段选择符。


1
2
3
4
5
6
7
1system_call:
2pushl %eax
3SAVE_ALL
4movl $0xffffe000, %eax
5andl %esp, %ebx
6
7

随后,在 ebx 中存放当前进程的 thread_info 数据结构的地址,
这是通过获得内核栈指针的值并把它取整到 4KB 或 8KB 的倍数而完成的。

接下来,检查 thread_info 结构 flag 字段的 TIF_SYSCALL_TRACE 和 TIF_SYSCALL_AUDIT 标志之一是否被设置为 1,
即检查是否有某一调用程序正在跟踪执行程序对系统调用的调用。
如果是,两次调用 do_syscall_trace():
一次正好在该系统调用服务例程执行前,一次在其之后。
do_syscall_trace() 停止 current,并因此允许调试进程收集关于 current 的信息。

然后,对用户态进程传递来的系统调用号进行有效性检查。
如果大于或等于系统调用分派表中的表项数,则系统调用程序终止:


1
2
3
4
5
6
7
8
9
10
11
12
1cmpl $NR_syscalls, %eax
2jb nobadsys
3
4; 如果系统调用号无效,将 -ENOSYS 值存放在栈中曾保存 eax 寄存器的单元中
5; 然后跳到 resume_userspace
6; 这样,当进程恢复它在用户态的执行时,会在 eax 中发现一个负的返回码
7movl $(-ENOSYS), 24(%esp)
8jmp resume_userspace
9
10nobadsys:
11
12

最后,调用与 eax 中所包含的系统调用号对应的特定服务例程:


1
2
3
4
5
6
7
8
1; 因为分派表中的每个表项占 4 个字节
2; 因此首先把系统调用号乘以 4
3; 再加上 sys_call_table 分配表的起始地址,
4; 然后从该地址单元获取执行服务例程的指针
5; 内核就找到了要调用的服务例程
6call *sys_call_table(0, %eax, 4)
7
8

从系统调用退出

当系统调用服务例程结束时,system_call() 从 eax 获得它的返回值,
并将该值存放到曾保存用户态 eax 寄存器值的那个栈单元的位置上:


1
2
3
1movl %eax, 24(%esp)
2
3

因此,用户态进程将在 eax 中找到系统调用的返回码。

然后 system_call() 关闭本地中断并检查当前进程的 thread_info 结构的标志:


1
2
3
4
5
6
7
8
9
10
1cli        ; 关闭本地中断
2movl 8(%ebp), %ecx  ; flags 字段在 thread_info 数据结构的偏移量为 8
3testw $0xffff, %cx  ; 通过掩码 0xffff 选择与所有标志(不包括 TIF_POLLING_NRFLAG)对应的位
4
5; 如果所有的标志都没有被设置,函数就跳转到 restore_all 标记处
6; restore_all 标记处的代码恢复保存在内核栈中的寄存器的值
7; 并执行 iret 汇编指令以重新开始执行用户态进程
8je restore_call  
9
10

只要有任意标志被设置,就要在返回用户态之前完成一些工作。
如果 TIF_SYSCALL_TARACE 标志被设置,system_call() 就第二次调用 do_syscall_trace() ,然后跳转到 resume_userspace 标记处。
否则,函数就跳转到 work_pending 标记处。

通过 sysenter 指令发出系统调用

汇编指令 int 由于要执行几个一致性和安全性检查,所以速度较慢。

sysenter 指令提供了一种从用户态到内核态的快速切换方法。

sysenter 指令

汇编指令 sysenter 使用三种特殊的寄存器,必须装入以下值:

  • SYSENTER_CS_MSR,内核代码段的段选择符
  • SYSENTER_EIP_MSR,内核入口点线性地址
  • SYSENTER_ESP_MSR,内核堆栈指针

执行 sysenter 指令时,CPU 控制单元:

  1. 把 SYSENTER_CS_MSR 的内容拷贝到 cs。
  2. 把 SYSENTER_EIP_MSR 的内容拷贝到 eip。
  3. 把 SYSENTER_ESP_MSR 的内容拷贝到 esp。
  4. 把 SYSENTER_CS_MSR 加 8 的值装入 ss。

因此,CPU 切换到内核态并开始执行内核入口点的第一条指令。

内核初始化期间,一旦系统中的每个 CPU 执行 enable_esp_cpu() ,三个特定于模型的寄存器就被初始化了。
enable_esp_cpu() 执行以下步骤:

  1. 把内核代码(__KERNEL_CS)的段选择符写入 SYSENTER_CS_MSR 寄存器。
  2. 把 sysenter_entry() 的线性地址写入 SYSENTER_CS_EIP 寄存器。
  3. 计算本地 TSS 末端的线性地址,并将该值写入 SYENTER_CS_ESP 寄存器。

对 SYSENTER_CS_ESP 寄存器的设置的说明:
系统调用开始的时候,内核栈是空的,因此 esp 寄存器应该执行 4KB 或 8KB 内存区域的末端,该内存区域包括内核堆栈和当前进程的描述符。
因为用户态的封装例程不知道该内存区域的地址,因此不能正确设置该寄存器。
但必须在切换到内核态之前设置该寄存器的值,因此,内核初始化该寄存器,以便为本地 CPU 的任务状态段编址。
每次进程切换时,内核把当前进程的内核栈指针保存到本地 TSS 的 esp0 字段。
这样,系统调用处理程序读 esp 寄存器,计算本地 TSS 的 esp0 字段的地址,然后把正确的内核堆栈指针装入 esp 寄存器。

vsyscall 页

只要 CPU 和 Linux 内核都支持 sysenter 指令,标准库 libc 中的封装函数就可以使用它。

该兼容性问题需要非常复杂的解决方案。
本质上,在初始化阶段,sysenter_setup() 建立一个称为 vsyscall 页的页框,它包括一个小的 EFL 共享对象(一个很小的 EFL 动态链接库)。
当进程发出 execve() 系统调用而开始执行一个 EFL 程序时,vsyscall 页中的代码就会自动链接到进程的地址空间。
vsyscall 页中的代码使用最有用的指令发出系统调用。

sysenter_setup() 为 vsyscall 页分配一个新页框,并将它的物理地址与 FIX_VSYSCALL 固定映射的线性地址相关联。
然后把预先定义好的多个 EFL 共享对象拷贝到该页中:

  • 如果 CPU 不支持 sysenter,sysenter_setup() 建立一个包括下列代码的 vsyscall 页:


1
2
3
4
5
1__kernel_vsyscall:
2   int $0x80
3   ret
4
5
  • 否则,如果 CPU 的确支持 sysenter,sysenter_setup() 建立一个包括下列代码的 vsyscall 页:


1
2
3
4
5
6
7
8
1__kernel_vsyscall:
2   pushl %ecx
3   push %edx
4   push %ebp
5   movl %esp, %ebp
6   sysenter
7
8

当标准库中的封装例程必须调用系统调用时,都调用 __kernel_vsyscall()。

进入系统调用

当用 sysenter 指令发出系统调用时,依次执行下述步骤:

  1. 标准库中的封装例程把系统调用号装入 eax 寄存器,并调用 __kernel_vsyscall()。
  2. __kernel_vsyscall() 把 ebp、edx 和 ecx 的内容保存到用户态堆栈中,把用户栈指针拷贝到 ebp 中,然后执行 sysenter 指令。
  3. CPU 从用户态切换到内核态,内核态开始执行 sysenter_entry()(由 SYSENTER_EIP_MSR 寄存器指向)。
  4. sysenter_entry() 汇编指令执行下述步骤:

a. 建立内核堆栈指针:


1
2
3
1movl -508(%esp), %esp
2
3
  • 开始时,esp 寄存器指向本地 TSS 的第一个位置,本地 TSS 的大小为 512 字节。

因此,sysenter 指令把本地 TSS 中的偏移量为 4 处的字段的内容(即 esp0 字段的内容)装入 esp。
esp0 字段总是存放当前进程的内核堆栈指针。

  • b. 打开本地中断:


1
2
3
1sti
2
3
  • c. 把用户数据段的段选择符、当前用户栈指针、eflags 寄存器、用户代码段的段选择符及从系统调用退出时要指向的指令的地址保存到内核堆栈:


1
2
3
4
5
6
7
1pushl $(__USER_DS)
2pushl %ebp
3pushfl
4pushl $(__USER_CS)
5pushl $SYSENTER_RETURN
6
7
  • d. 把由封装例程传递的寄存器的值恢复到 ebp 中:


1
2
3
1movl (%ebp), %ebp
2
3
  • 该指令完成恢复的工作,因为__kernel_vsyscall() 把 ebp 的原始值存入用户态堆栈中,并随后把用户堆栈指针的当前值装入 ebp 中。

  • e. 通过执行一系列指令调用系统调用处理程序,这些指令与 system_call 标记处开始的指令是一样的。

退出系统调用

系统调用服务例程结束时,sysenter_entry() 本质上执行与 system_call() 相同的操作。
首先,从 eax 获得系统调用服务例程的返回码,并存入内核栈中保存用户态 eax 寄存器值的位置。
然后,函数禁止本地中断,并检查 current 的 thread_info 结构中的标志。

如果有任何标志被设置,则在返回用户态之前还需要完成一些工作。
为避免代码复制,跳转到 resume_userspace 或 work_pending 标记处。
最后,汇编指令 iret 从内核堆栈中取 5 个参数(在 sysenter_entry() 第 4c 步保存到内核堆栈中),这样,CPU 切换到用户态并开始执行 SYSENTER_RETURN 标记处的代码。

否则,如果 sysenter_entry() 确定标志都被清 0,就快速返回用户态:


1
2
3
4
5
6
7
8
1; 将由 sysenter_entry() 在第 4c 步保存的堆栈值加载到 edx 和 ecx 寄存器中
2movl 40(%esp), %edx  ; edx 获得 SYSENTER_RETURN 标记处的地址
3movl 52(%esp), %ecx  ; ecx 获得当前用户数据栈的指针
4xorl %ebp, %ebp
5sti
6sysexit
7
8

sysexit 指令

sysexit 是与 sysenter 配对的汇编指令:它允许从内核态快速切换到用户态。
CPU 控制单元执行下述步骤:

  1. 把 SYSENTER_CS_MSR 寄存器中的值加 16 所得到的结果加载到 cs 寄存器。
  2. 把 edx 寄存器的内容拷贝到 eip 寄存器。
  3. 把 SYSENTER_CS_MSR 寄存器中的值加 24 所得到的结果加载到 ss 寄存器。
  4. 把 ecx 寄存器的内容拷贝到 esp 寄存器。

因为 SYSENTER_CS_MSR 寄存器加载的是内核代码的段选择符。
所以,cs 寄存器加载的是用户代码的段选择符,ss 寄存器加载的是用户数据段的段选择符。

结果,CPU 从内核态切换到用户态,并开始执行其地址存放在 edx 中的指令。

SYSENTER_RETURN 的代码

SYSENTER_RETURN 标记处的代码存放在 vsyscall 页中,通过 sysenter 进入的系统调用被 iret 或 sysexit 指令终止时,该页框中的代码被执行。

该代码恢复保存在用户态堆栈中的 ebp、edx 和 ecx 寄存器的原始内容,并把控制权返回给标准库中的封装例程:


1
2
3
4
5
6
7
1SYSENTER_RETURN:
2popl %ebp
3popl %edx
4popl %ecx
5ret
6
7

参数传递

系统调用的输入/输出参数可能是:

  • 实际的值
  • 用户态进程地址空间的变量
  • 指向用户态函数的指针的数据结构地址

因为 system_call() 和 sysenter_entry() 是 Linux 中所有系统调用的公共入口点,因此每个系统调用至少有一个参数 ,即通过 eax 寄存器传递进来的系统调用号。

普通 C 函数的参数传递时通过把参数值写入活动的程序栈(用户态栈或内核态栈)实现的。
而系统调用是一种横跨用户和内核的特殊函数,所以既不能使用用户态栈也不能使用内核态栈。
在发出系统调用前,系统调用的参数被写入 CPU 寄存器,然后再调用系统调用服务例程前,内核再把存放在 CPU 中的参数拷贝到内核态堆栈中,因为系统调用服务例程是普通的 C 函数。

为什么内核不直接把参数从用户态的栈拷贝到内核态的栈?

  • 同时操作两个栈比较复杂。
  • 寄存器的使用使得系统调用服务处理程序的结构与其他异常处理程序结构类似。

使用寄存器传递参数时,必须满足两个条件:

  • 每个参数的长度不能超过寄存器的长度,即 32 位。
  • 参数的个数不能超过 6 个(除 eax 中传递的系统调用号),因为寄存器数量有限。

当确实存在多于 6 个参数的系统调用时,用一个单独的寄存器指向进程地址空间中这些参数值所在的一个内存区。

用于存放系统调用号和系统调用参数的寄存器是:

  • eax(系统调用号)、ebx、ecx、edx、esi、edi 及 ebp。

system_call() 和 sysenter_entry() 使用 SAVE_ALL 宏将这些寄存器的值保存在内核态堆栈中。
因此,当系统调用服务例程转到内核态堆栈时,就会找到 system_call() 或 sysenter_entry() 的返回地址,紧接着时存放在 ebx 中的参数(系统调用的第一个参数),存放在 ecx 中的参数等。
这种栈结构与普通函数调用的栈结构完全相同,因此,系统调用服务例程很容易通过使用 C 语言结构引用它的参数。

有时候,服务例程需要知道在发出系统调用前 CPU 寄存器的内容。
类型为 pt_regs 的参数允许服务例程访问由 SAVE_ALL 宏保存在内核态堆栈中的值:


1
2
3
1int sys_fork(struct pt_regs regs)
2
3

服务例程的返回值必须写入 eax 寄存器。
这在执行 return n 指令时由 C 编译程序自动完成。

验证参数

有一种检查对所有的系统调用都是通用的。
只要有一个参数指定的是地址,那么内核必须检查它是否在这个进程的地址空间内。
检查方式:仅仅验证该线性地址是否小于 PAGE_OFFSET(即没有落在留给内核的线性地址区间内)。

这是一种非常错略的检查,真正的检查推迟到分页单元将线性地址转换为物理地址时。
后面的“动态地址检查:修正代码”会讨论缺页异常处理程序如何成功地检测到由用户态进程以参数传递的这些地址在内核态是无效的。

该粗略的检查确保了进程地址空间和内核地址空间都不被非法访问。

对系统调用所传递地址的检测是通过 access_ok() 宏实现的,它有两个分别为 addr 和 size 的参数。
该宏检查 addr 到 addr+size-1 之间的地址区间:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1int access_ok(const void *addr, unsigned long size)
2{
3   unsigned long a = (unsigned long)addr;
4
5   // 验证 addr + size 是否大于 2^32-1
6   // 因为 gcc 编译器用 32 位数表示无符号长整数和类型
7   // 所以等价于对溢出条件进行检查
8   // addr_limit.seg:
9   // 普通进程,通常存放 PAGE_OFFSET
10  // 内核线程,为 0xffffffff
11  // 可通过 get_fs 和 set_fs 宏动态修改 addr_limit.seg
12  if(a + size &lt; a || a + size &gt; current_thread_info()-&gt;addr_limit.seg)
13      return 0;
14  return 1;
15}
16
17

访问进程地址空间

get_user() 和 put_user() 宏可方便系统调用服务例程读写进程地址空间的数据。
get_user() 宏从一个地址读取 1、2 或 4 个连续字节。
put_user() 宏把 1、2 或 4 个连续字节的内容写入一个地址。

参数:

  • 要传送的值 x
  • 一个变量 ptr,决定还有多少字节要传送

get_user(x, ptr) 可展开为 __get_user_1()、__get_user_2() 或 __get_user_4() 汇编语言函数。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
1__get_user_2:
2   ; 前 6 个指令所执行的检查与 access_ok() 宏相同
3   ; 即确保要读取的两个字节的地址小于 4GB 并小于 current 进程的 addr_limit.seg 字段
4   addl $1, %eax   ; eax 包含要读取的第一个字节的地址 ptr
5   jc bad_get_user
6   movl $0xffffe000, %edx
7   andl %esp, %edx
8   cmpl 24(%edx), %eax  ; addr_limit.seg 位于 current 的 thread_info 中偏移量为 24 处
9   jae bad_get_user
10
11  ; 如果地址有效,执行 movzwl 指令
12  ; 把要读的数据存到 edx 寄存器的两个低字节
13  ; 两个高字节置 0
142:    movzwl -1(%eax), %edx
15  xorl %eax, %eax   // 在 eax 中设置返回码 0
16  ret  
17
18  ; 如果地址无效
19bad_get_user:
20  xorl %edx, %edx  ; 清 edx
21  movl $-EFAULT, %eax  ; 将 eax 置为 -EFAULT
22  ret
23
24

put_user(x, ptr) 宏类似于 get_user,但把值 x 写入以地址 ptr 为起始地址的进程地址空间。
根据 x 的大小,使用 __put_user_asm() 宏,或 __put_user_u64() 宏。
成功写入则在 eax 寄存器中返回 0,否则返回 -EFAULT。

动态地址检查:修正代码

access_ok() 宏仅对以参数传入的线性地址空间进行粗略检查,保证用户态进程不会试图侵扰内核地址空间。
但线性地址仍然可能不属于进程地址空间,这时,内核使用该地址时,会发生缺页异常。

缺页异常处理程序区分在内核态引起缺页异常的四种情况,并进行相应处理:

  1. 内核试图访问属于进程地址空间的页,但是,相应的页框可能不存在,或内核试图写一个只读页。

此时,处理程序必须分配和初始化一个新的页框(请求调页、写时复制)。

  1. 内核试图访问属于内核地址空间的页,但是,相应的页表项还没有初始化(处理非连续内存区访问)。

此时,内核必须在当前进程页表中适当建立一些表项。

  1. 某一个内核函数包含编程错误,导致函数运行时引起异常;或者,可能由于瞬时的硬件错误引起异常。

此时,处理程序必须执行一个内核漏洞(处理地址空间以外的错误地址)。

  1. 系统调用服务例程试图读写一个内存区,该内存区的地址以系统调用参数传入,但不属于进程的地址空间。

下面解释缺页处理程序如何区分第 3、4 种情况。

异常表

只有少数的函数和宏访问进程的地址空间;
因此,如果异常是由一个无效的参数引起的,那么引起异常的指令一定包含在其中一个函数或展开的宏中。

对用户空间寻址的指令非常少。
因此,可把访问进程地址空间的每条内核指令的地址放到一个叫异常表的结构中。

当内核态发生缺页异常时,do_page_fault() 处理程序检查异常表:
如果表中包含产生异常的指令地址,则该错误就是由非法的系统调用参数引起的,
否则,就是由某一严重的 bug 引起的。

Linux 定义了几个异常表。
主要的异常表在建立内核程序映像时,由 C 编译器自动生成。
它存放在内核代码段的 __ex_table 节,起始地址和终止地址由 C 编译器产生的两个符号 __start__ex_table 和 __stop__ex_table 标识。

此外,每个动态装载的内核模块都包含自己的局部异常表。
该表在建立模块映像时,由 C 编译器自动产生,在把模块插入到运行中的内核时,该表被装入内存。

每个异常表的表项是一个 exception_table_entry 结构,有两个字段:

  • insn,访问进程地址空间的指令的线性地址。
  • fixup,修正代码的地址。

修正代码由几条汇编指令组成,用以解决由缺页异常所引起的问题。
修正通常由插入的一个指令序列组成,该指令序列强制服务例程向用户态进程返回一个出错码。
这些指令通常在访问进程地址空间的同一函数或宏中定义;
由 C 编译器把它们放置在内核代码段的一个叫 .fixup 的独立部分。

search_exception_tables() 在所有异常表中查找一个指定地址:
若该地址在某一个表中,则返回指向相应 exception_table_entry 结构的指针;否则,返回 NULL。
因此,缺页处理程序 do_page_fault() 执行:


1
2
3
4
5
6
7
8
9
10
11
12
1// regs-&gt;eip 字段包含异常发生时保存到内核态栈 eip 寄存器中的值
2// 如果 eip 寄存器中的该值在某个异常表中
3if((fixup = search_exception_tables(regs-&gt;eip))
4{
5   // 把 regs-&gt;eip 保存的值替换为 search_exception_tables() 的返回值
6   regs-&gt;eip = fixup-&gt;fixup;
7
8   // 缺页处理程序终止,被中断的程序恢复运行
9   return 1;
10}
11
12

生成异常表和修正代码

GNU 汇编程序伪指令 .section 允许程序员指定可执行文件的哪部分包含紧接着要执行的代码。
可执行文件包含一个代码段,该代码段可能被划分为节。

在异常表中加入一个表项:


1
2
3
4
5
6
1; &quot;a&quot; 属性指定必须把这一节与内核映像的剩余部分一块加载到内存中
2.section __ex_table, &quot;a&quot;
3   .long faulty_instruction_address, fixup_code_address
4.previous
5
6

.previous 伪指令强制汇编程序把紧接着的代码插入到遇到上一个 .section 伪指令时激活的节。


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
1__get_user_1:
2   [...]
31: movzbl (%eax), %edx  
4   [...]
5  
6__get_user_2:
7   [...]
82: movzwl -1(%eax), %edx
9   [...]
10 
11__get_user_4:
12  [...]
133:    movl -3(%eax), %edx
14  [...]
15 
16; 修正代码对这三个函数是公用的,被标记为 bad_get_user
17; 如果缺页异常是由标号 1、2 或 3 处的指令查时,则修正代码就执行
18; bad_get_user 修正代码给发出系统调用的进程只简单地返回一个出错码 -EFAULT
19bad_get_user:
20  xorl %edx, %edx
21  movl $-EFAULT, %eax
22  ret
23
24; 每个异常表项由两个标号组成
25; 第一个是标号,前缀 &quot;b&quot; 表示&quot;向后的&quot;
26.section __ex_table, &quot;a&quot;
27  .long 1b, bad_get_user
28  .long 2b, bad_get_user
29  .long 3b, bad_get_user
30.previous
31
32

其他作用于用户态地址空间的内核函数也使用修正代码技术。
比如 strlen_user(string) 宏,返回系统调用中 string 参数的长度,string 以 null 结尾;出错时返回 0。

strlen_user(string):


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
1   mvol $0, %eax
2
3   ; ecx 和 ebx 寄存器的初始值设置为 0x7fffffff
4   ; 表示用户态地址空间字符串的最大长度
5   movl $0x7fffffff, %ecx  
6   movl %ecx, %ebx
7  
8   movl string, %edi
9
10  ; repne; scabsb 循环扫描由 edi 指向的字符串
11  ; 在 eax 中查找值为 0 的字符(字符串的结尾标志 \0)
120:    repne; scabsb
13  subl %ecx, %ebx  ; 每一次循环 scasb 都将 ecx 减 1
14  movl %ebx, %eax ; 所以 eax 中最后存放字符串长度
15 
16; 修正代码被插入到节 .fixup
17; &quot;ax&quot; 属性指定该节必须加载到内存且包含可执行代码
18; 如果缺页异常是由标号为 0 的指令引起
19; 就执行修正代码,它只简单地把 eax 置为 0
20; 因此强制该宏返回一个出错码 0 而不是字符串长度
21; 然后跳转到标号 1,即宏之后的相应指令。
221:   
23.section .fixup, &quot;ax&quot;
242:    xorl %eax, %eax  
25  jmp 1b
26 
27.previous
28
29; 在 __ex_table 中增加一个表项
30; 内容包括 repne; scasb 指令的地址和相应的修正代码的地址
31.section __ex_table, &quot;a&quot;
32  .long 0b, 2b
33 
34.previous
35
36

内核封装例程

系统调用也可以被内核线程调用,内核线程不能使用库函数。
为了简化相应的封装例程的声明,Linux 定义了 7 个从 _syscall0 到 _syscall6 的一组宏。

每个宏名字中的数字 0~6 对应着系统调用所用的参数个数(系统调用号除外)。
也可以用这些宏来声明没有包含在 libc 标准库中的封装例程。
然而,不能用这些宏来为超过 6 个参数(系统调用号除外)的系统调用或返回非标准值的系统调用封装例程。

每个宏严格地需要 2+2*n 个参数,n 是系统调用的参数个数。
前两个参数指明返回值类型和名字;
后面的每一对附加参数指明参数的类型和名字。
以 fork() 系统调用为例,其封装例程可以通过如下语句产生:


1
2
3
1_syscall0(int, fork)
2
3

而 write() 系统调用的封装例程可通过如下语句产生:


1
2
3
1_syscall3(int, write, int, fd, const char *, buf, unsigned int, count)
2
3

展开如下:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1int write(int fd, const char *buf, usninged int count)
2{
3   long __res;
4   adm(&quot;int $0x80&quot;,
5       : &quot;0&quot; (__NR_write), &quot;b&quot; ((long)fd),
6         &quot;c&quot; ((long)buf), &quot;d&quot; ((long)count));
7   if((unsigned long)__res &gt;= (unsigned long)-129)
8   {
9       errno = -__res;
10      __res = -1;
11  }
12  return (int)__res;
13}
14
15

__NR_write 宏来自 _syscall3 的第二个参数;
它可展开成 write() 的系统调用号,当编译前面的函数时,产生如下汇编代码:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
1write:
2   pushl ebx           ; 将 ebx 入栈
3   movl 8(%esp), %ebx  ; 第一个参数放入 ebx
4   movl 12(%esp), %ecx ; 第二个参数放入 ecx
5   mvol 16(%esp), %edx ; 第三个参数放入 edx
6   movll $4, %eax      ; __NR_write 放入 eax
7   int $0x80           ; 调用系统调用
8   cmpl $-125, %eax        ; 检测返回码
9   jbe .L1             ; 如果无错则跳转
10  negl %eax           ; 求 eax 的补码
11  movl %eax, errno        ; 结果放入 errno
12  movl -1, %eax       ; eax 置为 -1
13.L1:  popl %ebx       ; 从堆栈弹出 ebx
14  ret                 ; 返回调用程序
15
16

如果 eax 中的返回值在 -1~-129 之间,则被解释为出错码,在 errno 中存放 -eax 的值并返回 -1;
否则,返回 eax 中的值。

给TA打赏
共{{data.count}}人
人已打赏
安全运维

WordPress网站专用docker容器环境带Waf

2020-7-18 20:04:44

安全运维

运维安全-Gitlab管理员权限安全思考

2021-9-19 9:16:14

个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索