图 1:第 10 章章首插图

这一篇在干嘛?

操作系统必须在应用程序和硬件之间垫一层接口,而 Unix 里这层接口的主体就是系统调用。本章沿着一次系统调用的完整旅程走一遍:libc 包装例程如何把调用号塞进寄存器、CPU 如何通过 int $0x80sysenter 陷入内核、处理程序如何查分发表调到服务例程、参数怎么传、地址怎么检查、传了坏地址如何靠”异常表+修正代码”体面地报错而不是崩内核。

API 与系统调用 | 处理程序与服务例程 | int $0x80 路径 | sysenter 路径 | 参数传递 | 参数校验与用户空间访问 | 修正代码 | 内核包装例程

API 与系统调用

先把两个最容易混的概念掰开:API(应用编程接口)和系统调用不是一回事。

  • API 是一个函数定义,规定怎么获得某项服务;
  • 系统调用是通过软中断向内核发出的显式请求

Unix 提供若干函数库(如 libc 标准 C 库)给程序员提供 API。库中有一部分 API 对应包装例程(wrapper routine)——唯一使命就是发出系统调用的例程。通常每个系统调用有一个对应的包装例程,定义应用程序该用的 API。

反过来不成立——API 不一定对应某个系统调用:

  1. API 可以直接在用户态完成服务(比如数学函数,没必要进内核);
  2. 一个 API 函数可能发出多个系统调用;
  3. 多个 API 函数可能用同一个系统调用、再包上一层额外功能。典型例子:Linux 的 malloc()calloc()free() 都在 libc 里实现,库代码自己记账,只在需要时用 brk() 系统调用伸缩堆(见第 9 章)。

POSIX 标准针对的是 API 而非系统调用:只要系统向应用程序提供了正确的 API 集合,不管底层怎么实现,都可以认证为符合 POSIX。事实上不少非 Unix 系统就靠用户态函数库模拟全部传统 Unix 服务拿到了 POSIX 认证。

视角不同,关注点不同:程序员眼里 API 和系统调用的区分无关紧要——函数名、参数类型、返回码含义才重要;内核设计者眼里这个区分至关重要——系统调用属于内核,用户态函数库不属于。

大多数包装例程返回整数:-1 通常表示内核无法满足请求(参数非法、资源不足、硬件故障等),具体错误码放在 libc 定义的 errno 变量里。每个错误码是定义为正整数的宏,POSIX 规定了这些宏名;80×86 的 Linux 在 include/asm-i386/errno.h 中定义,为了可移植性它又被标准的 /usr/include/errno.h 包含。

处理程序与服务例程

用户态进程调用系统调用时,CPU 切到内核态、开始执行一个内核函数。80×86 上进入内核有两条路(下一节起详解),但殊途同归——都跳到同一个汇编函数:系统调用处理程序(system call handler)。

因为系统调用很多,进程必须传一个系统调用号来指明要哪个服务——Linux 用 eax 寄存器承担这个角色。

返回值约定内核和包装例程不同,值得专门记:内核里,正数或 0 表示成功,负数表示出错,其值是错误码的相反数;而 errno 变量完全不由内核设置或使用——包装例程在系统调用返回后负责把负值取反塞进 errno。这正是”内核一层、库一层”职责分界的体现。

处理程序的结构与其他异常处理程序类似,干三件事:

  1. 在内核态栈里保存大多数寄存器的内容(所有系统调用共用,汇编写成);
  2. 调用一个叫系统调用服务例程(service routine)的 C 函数处理系统调用;
  3. 退出:从栈里恢复寄存器,CPU 从内核态切回用户态(同样所有系统调用共用,汇编写成)。

xyz() 系统调用的服务例程通常命名为 sys_xyz(),但有少数例外。

图 2:调用一个系统调用(原书图 10-1)

图 2 展示了完整链条:应用程序 → 包装例程 → 系统调用处理程序 → 系统调用服务例程,箭头是执行流;“SYSCALL”和”SYSEXIT”是用户态↔内核态切换指令的占位符。

系统调用号和服务例程怎么对上号?靠系统调用分发表——sys_call_table 数组,共 NR_syscalls 个表项(Linux 2.6.11 内核里是 289)。第 n 项存放系统调用号 n 的服务例程地址。

两个细节:

  • NR_syscalls 只是静态上限,不代表实际实现的系统调用数;
  • 未实现的槽位放 sys_ni_syscall() 的地址——“未实现”系统调用的服务例程,只返回错误码 -ENOSYS

int $0x80 路径

传统方式用 int 汇编指令(见第 4 章)。向量 128(十六进制 0x80)关联内核入口点,内核初始化时 trap_init() 这样设置 IDT 表项:

set_system_gate(0x80, &system_call);

这个调用给门描述符填了这些值(见第 4 章):

  • 段选择符:内核代码段的 __KERNEL_CS
  • 偏移system_call() 系统调用处理程序的指针;
  • Type:15,表示这是一个 Trap,对应处理程序不禁用可屏蔽中断;
  • DPL:3,允许用户态进程调用这个异常处理程序——这是系统调用门的关键,普通异常门可不让用户态随便进。

用户态进程执行 int $0x80,CPU 切内核态、从 system_call 地址开始执行。

system_call() 函数

先把系统调用号和异常处理程序可能用到的所有 CPU 寄存器压栈——eflags、cs、eip、ss、esp 五个已由控制单元自动保存,不用管(见第 4 章)。SAVE_ALL 宏(第 4 章讲过)顺带把内核数据段选择符装进 ds 和 es:

system_call:
    pushl %eax
    SAVE_ALL
    movl $0xfffffe000, %ebx /* 4-KB 栈时为 0xfffff000 */
    andl %esp, %ebx

接着把当前进程 thread_info 结构的地址算出来放进 ebx:拿内核栈指针向上取整到 4KB 或 8KB 边界即可(thread_info 和内核栈共用那块内存,见第 3 章)。

然后检查 thread_info 的 flags 字段里 TIF_SYSCALL_TRACETIF_SYSCALL_AUDIT 是否置位——即程序的系统调用是否正被调试器跟踪。是的话,do_syscall_trace() 会被调用两次:服务例程执行前一次、执行后一次,该函数停住 current,让调试进程收集信息。

再校验系统调用号:

cmpl $NR_syscalls, %eax
jb nobadsys
movl $(-ENOSYS), 24(%esp)
jmp resume_userspace
nobadsys:

号越界就把 -ENOSYS 存进栈上保存 eax 的位置(栈顶偏移 24 处),跳 resume_userspace——进程回到用户态时会在 eax 里发现负的返回码。

号合法,按下标调用服务例程:

call *sys_call_table(0, %eax, 4)

表项 4 字节长,所以”调用号 ×4 + 表首址”定位到槽位,取出函数指针调用。

退出系统调用

服务例程结束后,处理程序把 eax 里的返回码存到栈上保存的用户态 eax 位置:

movl %eax, 24(%esp)

这样用户态进程恢复执行时 eax 正好装着返回值。

随后关本地中断,检查 current 的 thread_info 标志:

cli
movl 8(%ebp), %ecx

flags 字段在 thread_info 偏移 8 处;掩码 0xffff 选中第 4 章表 4-15 中除 TIF_POLLING_NRFLAG 外的所有标志。没有任何标志置位:直接跳 restore_all——恢复栈中寄存器、执行 iret 回用户态(流程见第 4 章图 4-6)。有标志置位:返回用户态前还有活干——TIF_SYSCALL_TRACE 置位就再调一次 do_syscall_trace() 然后跳 resume_userspace;否则跳 work_pending。resume_userspace 和 work_pending 处的代码检查重新调度请求、虚拟 8086 模式、挂起信号和单步跟踪,最后才跳 restore_all 回用户态。

sysenter 路径

int 指令天生慢——它要做一堆一致性和安全检查(见第 4 章)。Intel 从 Pentium II 起提供了”快速系统调用”指令 sysenter,配套返回指令 sysexit。于是进出内核各有两条路:

  • 进入:int $0x80(旧内核唯一方式)或 sysenter
  • 退出:iretsysexit

同时支持两条路并不简单,有 three 个兼容性包袱:

  1. 内核必须既支持只用 int $0x80 的旧库,也支持会用 sysenter 的新库;
  2. sysenter 的标准库必须能对付只支持 int $0x80 的旧内核;
  3. 内核和库都要能在没有 sysenter 的老 CPU 和有 sysenter 的新 CPU 上跑。

sysenter 指令本身

sysenter 用三个专用 MSR 寄存器:

寄存器内容
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 切到内核态、从内核入口点第一条指令开始跑。第 4 步能这么干是因为内核栈段与内核数据段重合、其描述符紧跟内核代码段描述符在 GDT 里(见第 2 章)。

三个 MSR 由 enable_sep_cpu() 函数在内核初始化时对每个 CPU 各执行一次:写入 __KERNEL_CS、写入 sysenter_entry() 的线性地址、写入本地 TSS 末端的线性地址

SYSENTER_ESP_MSR 的设置值得细品:系统调用开始时内核栈是空的,esp 理应指向包含内核栈和 current 描述符的那块 4/8KB 内存区末尾,但用户态包装例程不可能知道这个地址(每个进程不同),寄存器又必须在切内核态前设好。Linux 的解法:让寄存器先存本地 CPU 的 TSS 地址——由于每次进程切换时 switch_to() 都会把当前进程的内核栈指针存进本地 TSS 的 esp0 字段(见第 3 章),进了内核后处理程序再从 esp0 把正确的内核栈指针读出来装进 esp。

vsyscall 页:优雅的兼容方案

libc 包装例程想用 sysenter,前提是 CPU 和内核都支持。这个兼容难题的解法相当精巧:内核初始化时 sysenter_setup() 函数构造一个叫 vsyscall 页的页框,里面装一个微型 ELF 共享对象(小动态库)。进程 execve 装载 ELF 程序时,vsyscall 页的代码被动态链接进进程地址空间(见第 20 章),这段代码会用”当前环境可用的最优指令”发起系统调用。

sysenter_setup() 分配页框、关联到 FIX_VSYSCALL 固定映射线性地址(见第 2 章),然后按 CPU 能力二选一拷入预定义的 ELF 共享对象——不支持 sysenter 时:

__kernel_vsyscall:
    int $0x80
    ret

支持时:

__kernel_vsyscall:
    pushl %ecx
    pushl %edx
    pushl %ebp
    movl %esp, %ebp
    sysenter

包装例程不管里面是什么,调 __kernel_vsyscall() 就行。还有一层兜底:旧内核不支持 sysenter 时根本不建 vsyscall 页,__kernel_vsyscall() 没被链接进进程地址空间,新库检测到这一点就自己退回 int $0x80

进入与退出的完整时序

sysenter 方式发起系统调用的步骤:

  1. 库里的包装例程把系统调用号装进 eax,调 __kernel_vsyscall()

  2. __kernel_vsyscall() 把 ebp、edx、ecx(这三个寄存器马上要被内核征用)压到用户态栈保存,把用户栈指针复制进 ebp,执行 sysenter

  3. CPU 切内核态,开始执行 sysenter_entry()

  4. sysenter_entry() 依次:

    • 设置内核栈指针
    movl -508(%esp), %esp

    此时 esp 指向本地 TSS 之后(TSS 长 512 字节),-508 正好定位到 TSS 偏移 4 处的 esp0 字段——它保存着当前进程的内核栈指针。

    • 开本地中断sti
    • 手工模拟 int 指令的压栈动作,把用户数据段选择符、当前用户栈指针、eflags、用户代码段选择符、系统调用退出地址依次压内核栈:
    pushl $(__USER_DS)
    pushl %ebp
    pushfl
    pushl $(__USER_CS)
    pushl $SYSENTER_RETURN
    • 恢复 ebp 原值movl (%ebp), %ebp——因为 __kernel_vsyscall() 已把 ebp 原值压在用户栈顶、ebp 现在指向那里;
    • 执行一段与 system_call 标号起完全相同的指令序列,正式进入系统调用处理。

服务例程结束后,sysenter_entry() 的退出逻辑与 system_call() 基本相同:取返回码存回栈上 eax 的位置、关中断、查 thread_info 标志。有标志置位时为避免代码重复,直接跳到 system_call() 里的 resume_userspace 或 work_pending 标号走同一条路,最终由 iret 从内核栈弹出第 4 步压入的五个值、切回用户态、从 SYSENTER_RETURN 标号继续执行。

flags 全清时走快速返回:

movl 40(%esp), %edx
movl 52(%esp), %ecx
xorl %ebp, %ebp
sti
sysexit

edx 装入 SYSENTER_RETURN 标号地址,ecx 装回用户栈指针。

sysexit 指令与 sysenter 配对,快速切回用户态:SYSENTER_CS_MSR+16 → cs(用户代码段选择符),edx → eip,SYSENTER_CS_MSR+24 → ss(用户数据段选择符),ecx → esp。CPU 切到用户态、从 edx 存的地址开始执行。

SYSENTER_RETURN 标号的代码就放在 vsyscall 页里,负责恢复 __kernel_vsyscall() 保存的三个寄存器并把控制权还给库里的包装例程:

SYSENTER_RETURN:
    popl %ebp
    popl %edx
    popl %ecx
    ret

参数传递

系统调用和普通函数一样需要输入/输出参数:可以是数值、用户态进程地址空间里变量的地址,甚至是包含指向用户态函数的指针的数据结构(见第 11 章的信号处理相关系统调用)。

系统调用号本身就是第一个参数——因为 system_call() 和 sysenter_entry() 是所有系统调用的公共入口,必须靠它区分。号由包装例程装进 eax:比如应用调 fork() 包装例程,eax 在执行 int/sysenter 前被置为 2(__NR_fork)。程序员通常不用关心调用号,库都替你做好了。

fork() 不需要更多参数,但很多系统调用需要——例如 mmap() 除调用号外最多可带 6 个参数。

参数放在哪?普通 C 函数的参数写在活动栈上。但系统调用是横跨用户/内核两个世界的特殊函数:用户态栈和内核态栈都不能用。方案是:参数在发系统调用前写进 CPU 寄存器;内核在调服务例程前把寄存器里的参数复制到内核态栈上——因为服务例程是个普通 C 函数,得按 C 的规矩在栈上找参数。

为什么不直接从用户态栈复制到内核态栈?其一,同时操作两个栈很复杂;其二,走寄存器让系统调用处理程序的结构与其他异常处理程序保持一致。

用寄存器传参要满足两个条件:

  1. 每个参数长度不超过寄存器长度(32 位)——按 POSIX 标准,超长参数必须传引用(地址),典型如需要读 64 位结构的 settimeofday(),所以这条天然满足;
  2. 参数个数不超过 6 个(eax 里的调用号之外)——80×86 寄存器太少了。

确实有超过 6 个参数的系统调用。这时用一个寄存器指向进程地址空间里的一块内存,参数值打包放在那里。程序员无须关心这套变通:包装例程被调用时参数照常压栈,包装例程自己想办法传给内核。

寄存器分配(按序):eax(调用号)、ebx、ecx、edx、esi、edi、ebp。system_call() 和 sysenter_entry() 用 SAVE_ALL 宏把这些寄存器值压上内核态栈,于是服务例程在栈上看到的布局是:返回地址(回 system_call 或 sysenter_entry),接着是 ebx 存的参数(第一个参数)、ecx 存的参数……——与普通函数调用的栈布局完全一致,服务例程用寻常的 C 语法就能引用参数。

例子:write() 系统调用的服务例程声明为:

int sys_write (unsigned int fd, const char * buf, unsigned int count)

C 编译器生成的代码期望在栈顶返回地址之下、依次保存 ebx、ecx、edx 的位置找到 fd、buf、count。

少数情况下,系统调用本身不带参数,服务例程却需要知道调用前的寄存器内容:比如实现 fork() 的 do_fork() 需要把寄存器复制到子进程的 thread 字段(见第 3 章)。这时给服务例程一个 pt_regs 类型的参数,就能访问 SAVE_ALL 保存在内核栈上的全部寄存器值:

int sys_fork (struct pt_regs regs)

服务例程的返回值必须写进 eax——执行 return n; 时 C 编译器自动完成。

参数校验与用户空间访问

粗检查:access_ok()

内核必须先检查所有系统调用参数,类型因调用而异。比如 write() 的 fd 参数应是文件描述符,sys_write() 得确认 fd 确实是先前打开文件的描述符、进程有权写它,否则返回 -EBADF(见第 1 章)。

但有一类检查所有系统调用通用:参数指定地址时,内核必须确认它在进程地址空间内。有两种做法:

  1. 核实线性地址属于进程地址空间、且所在内存区域有正确的访问权限;
  2. 只核实线性地址小于 PAGE_OFFSET(即不在内核保留区间内)。

早期内核用第一种,但太耗时——每个地址参数都要查一遍,而且通常白查(坏程序并不多见)。从 2.2 版起 Linux 改用第二种:不扫内存区域描述符,高效得多。这当然是非常粗的检查——“小于 PAGE_OFFSET”是地址合法的必要条件而非充分条件。但这么查没有风险,因为更细的错误留到”最后一刻”——分页单元把线性地址翻译成物理地址时——由缺页异常兜底(见修正代码一节)。

那为什么粗检查还要做?想想不做的后果:RAM 从 PAGE_OFFSET 起映射(见第 2 章),内核例程能寻址内存中所有页;若无粗检查,用户进程可以把内核地址空间里的地址当参数传进来,内核就会替它读写内存中的任意页——连缺页异常都不会发生。粗检查是守住进程地址空间和内核地址空间的第一道闸。

检查由 access_ok() 宏执行,参数为 addr 和 size,检查 addr 到 addr+size-1 的区间,等价于:

int access_ok(const void * addr, unsigned long size)
{
    unsigned long a = (unsigned long) addr;
    if (a + size < a ||
        a + size > current_thread_info()->addr_limit.seg)
        return 0;
    return 1;
}

两个检查:addr+size 是否溢出(gcc 把 unsigned long 和指针都当 32 位数,加法回绕即溢出);addr+size 是否超过 current 的 thread_info 里 addr_limit.seg 字段——普通进程该字段为 PAGE_OFFSET,内核线程为 0xffffffff。get_fs/set_fs 宏可以动态改这个字段,让内核临时绕过 access_ok() 的安全检查,直接把内核数据段地址传给系统调用服务例程。verify_area() 与 access_ok() 功能相同,已过时但源码里仍常见。

细访问:get_user() 与 put_user()

服务例程经常要读写进程地址空间里的数据,Linux 提供一组宏,最基本的是 get_user()(读 1、2、4 字节)和 put_user()(写 1、2、4 字节),各带两个参数:要传的值 x 和变量 ptr,ptr 的类型决定传输字节数。get_user(x, ptr) 按 ptr 指向变量的大小展开成 __get_user_1()__get_user_2()__get_user_4() 汇编函数。看 __get_user_2()

__get_user_2:
    addl $1, %eax
    jc bad_get_user
    movl $0xffffe000, %edx /* 4-KB 栈时为 0xfffff000 */
    andl %esp, %edx
    cmpl 24(%edx), %eax
    jae bad_get_user
2:  movzwl -1(%eax), %edx
    xorl %eax, %eax
    ret
bad_get_user:
    xorl %edx, %edx
    movl $-EFAULT, %eax
    ret

eax 是待读首字节地址 ptr。前几条指令干的就是 access_ok() 的活:确认要读的 2 字节地址不超 4GB、也不超过当前进程的 addr_limit.seg(该字段在 thread_info 偏移 24 处,对应 cmpl 的操作数)。合法则 movzwl 把数据读进 edx 低 16 位、高位清零,eax 置 0 返回;非法则 edx 清零、eax 置 -EFAULT 返回。

put_user(x, ptr) 同理,方向相反:按 x 大小调 __put_user_asm() 宏(1/2/4 字节)或 __put_user_u64() 宏(8 字节),成功 eax 返回 0,失败 -EFAULT。

更多访问函数/宏见表 10-1。注意很多都有双下划线前缀的变体:不带下划线的版本先检查地址区间有效性再访问,带下划线的跳过检查。内核需要反复访问同一块用户内存时,开头检查一次、后续用带下划线版本直通,更高效。

函数动作
get_user / __get_user从用户空间读整数(1、2 或 4 字节)
put_user / __put_user向用户空间写整数(1、2 或 4 字节)
copy_from_user / __copy_from_user从用户空间复制任意大小的块
copy_to_user / __copy_to_user向用户空间复制任意大小的块
strncpy_from_user / __strncpy_from_user从用户空间复制以 null 结尾的字符串
strlen_user返回用户空间中以 null 结尾字符串的长度
clear_user / __clear_user用零填充用户空间的内存区

修正代码

access_ok() 只是粗查——它保证用户进程没打内核地址空间的主意,但地址仍可能不属于进程地址空间。这时内核一用这个坏地址,缺页异常就会发生。内核怎么识别”这是系统调用传进来的坏参数”而不是”内核 bug”?先列出内核态发生缺页的全部四种情况,处理程序必须区分对待:

  1. 内核访问属于进程地址空间的页,但页框不存在或试图写只读页——处理程序按请求调页/写时复制分配并初始化新页框(见第 9 章);
  2. 内核访问自己地址空间的页,但页表项尚未初始化(vmalloc 区,见第 9 章)——处理程序补设当前进程的页表项;
  3. 内核函数有编程 bug,或硬件瞬时故障——处理程序执行 kernel oops(见第 9 章);
  4. 本章的新情况:系统调用服务例程试图读写一个作为参数传进来的地址,但该地址不属于进程地址空间。

前两种处理程序已能识别(查内存区域、查主内核页表项)。后两种的区分靠异常表(exception table)。

异常表

关键洞察:内核访问用户地址空间的手段非常有限——就是上一节那一小撮函数和宏。如果缺页由坏参数引起,触发异常的指令必然出自这些函数/宏之中;会访问用户空间的指令数量很少,完全可以把它们的地址登记成表。

于是:内核态发生缺页时,do_page_fault() 查异常表——触异常的指令地址在里面,就是坏系统调用参数(小事,走修正代码);不在里面,就是更严重的 bug(oops)。

异常表有好几张:主异常表在编译内核程序映像时由 C 编译器自动生成,存在内核代码段的 __ex_table 节,首尾地址由编译器生成的 __start___ex_table__stop___ex_table 符号标识;每个动态加载模块(见附录 B)编译时也自带一张本地异常表,插入运行中的内核时载入内存。

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

  • insn:一条访问进程地址空间的指令的线性地址;
  • fixup:当 insn 处的指令触发缺页异常时应调用的汇编修正代码地址。

修正代码通常就几条指令——一般是强迫服务例程向用户态进程返回错误码。它们与访问进程地址空间的宏/函数写在同一处,由编译器放进内核代码段单独的 .fixup 节。

search_exception_tables() 在所有异常表中查找指定地址,命中返回对应表项指针,否则 NULL。缺页处理程序据此拨乱反正:

if ((fixup = search_exception_tables(regs->eip))) {
    regs->eip = fixup->fixup;
    return 1;
}

regs->eip 是异常发生时压栈的指令指针。命中就把保存的 eip 替换成修正代码地址,处理程序返回,被中断的程序”恢复执行”时直接从修正代码开始跑。

异常表与修正代码的生成

GNU 汇编器的 .section 伪指令指定后续代码放进可执行文件的哪个节。往异常表加一项的写法(“a” 属性表示该节随内核映像一起装入内存):

.section __ex_table, "a"
.long faulty_instruction_address, fixup_code_address
.previous

.previous 让汇编器把后续代码放回上一个 .section 指令时的活动节。

回头看 __get_user_1/2/4():真正访问用户地址空间的指令被标号为 1、2、3:

__get_user_1:
    [...]
1:  movzbl (%eax), %edx
    [...]
__get_user_2:
    [...]
2:  movzwl -1(%eax), %edx
    [...]
__get_user_4:
    [...]
3:  movl -3(%eax), %edx
    [...]
bad_get_user:
    xorl %edx, %edx
    movl $-EFAULT, %eax
    ret
.section __ex_table,"a"
.long 1b, bad_get_user
.long 2b, bad_get_user
.long 3b, bad_get_user
.previous

每个异常表项由两个标号构成:数字标号带 b 后缀表示”向后引用”(backward,出现在程序前面几行);三个函数共用名为 bad_get_user 的修正代码。标号 1、2、3 处的指令引发缺页时,修正代码执行——简单地向发起系统调用的进程返回 -EFAULT。

再看 strlen_user(string) 宏——返回系统调用参数里以 null 结尾字符串的长度,出错返回 0。它大体展开成:

movl $0, %eax
movl $0x7ffffff, %ecx
movl %ecx, %ebx
movl string, %edi
0:  repne; scasb
subl %ecx, %ebx
movl %ebx, %eax
1:
.section .fixup,"ax"
2:  xorl %eax, %eax
    jmp 1b
.previous
.section __ex_table,"a"
.long 0b, 2b
.previous

ecx 和 ebx 初始化为 0x7fffffff(用户空间字符串允许的最大长度);repne; scasb 迭代扫描 edi 指向的字符串找结尾的 \0;因为 scasb 每轮递减 ecx,最后 eax 算出扫描过的字节数即串长。

这个宏的修正代码放在 .fixup 节(“ax” 属性:装入内存且含可执行代码)。标号 0 处的指令触发缺页时执行修正:eax 清零(强迫宏返回错误码 0 而不是串长),跳到标号 1(宏之后的指令)继续。第二个 .section 把”标号 0 指令地址 + 修正代码地址”这对组合登记进 __ex_table。

内核包装例程

系统调用主要供用户态进程用,但内核线程也可能调用它们——内核线程不能用库函数。为简化声明对应的包装例程,Linux 定义了 _syscall0_syscall6 七个宏。

宏名里的 0~6 对应系统调用的参数个数(不算调用号)。这些宏用来声明 libc 库里还没有的包装例程(比如内核新加的系统调用库还没支持);局限是:不能用于超过 6 个参数的系统调用,也不能用于返回值非标准的系统调用。

每个宏恰好需要 2 + 2×n 个参数(n 为系统调用参数个数):前两个指定返回类型和系统调用名,后面每对指定一个参数的类型和名字。比如 fork() 的包装例程可以这样生成:

_syscall0(int, fork)

write() 的包装例程可以这样生成:

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

后者展开成:

int write(int fd, const char * buf, unsigned int count)
{
    long __res;
    asm("int $0x80"
        : "=a" (__res)
        : "o" (__NR_write), "b" ((long)fd),
          "c" ((long)buf), "d" ((long)count));
    if ((unsigned long)__res >= (unsigned long)-129) {
        errno = -__res;
        __res = -1;
    }
    return (int) __res;
}

__NR_write 宏由宏的第二个参数推导而来,展开为 write() 的系统调用号。编译后产生的汇编代码:

write:
    pushl %ebx       ; ebx 压栈保存
    movl 8(%esp), %ebx   ; 第一个参数放进 ebx
    movl 12(%esp), %ecx  ; 第二个参数放进 ecx
    movl 16(%esp), %edx  ; 第三个参数放进 edx
    movl $4, %eax        ; __NR_write 装进 eax
    int $0x80            ; 发起系统调用
    cmpl $-125, %eax     ; 检查返回码
    jbe .L1              ; 无错则跳过
    negl %eax            ; eax 取反
    movl %eax, errno     ; 结果存进 errno
    movl $-1, %eax       ; eax 置 -1
.L1: popl %ebx          ; 恢复 ebx
    ret                 ; 返回调用者

注意参数是如何在 int $0x80 之前装进各寄存器的。eax 返回的值若落在 -1 到 -129 之间(内核假定 include/generic/errno.h 里定义的最大错误码是 129)就按错误码解读:包装例程把 -eax 存进 errno、返回 -1;否则原样返回 eax。

常见坑:以为 errno 是内核设置的

内核从不碰 errno。内核只通过 eax 返回负的错误码,“取反、存 errno、自己返回 -1”这三步全是包装例程干的。直接用 raw syscall(比如 _syscall3 宏或汇编)时没人帮你设 errno,错误处理得自己照这套约定写。

常见坑:高估 access_ok() 的能力

access_ok() 只回答”这个地址是不是在内核保留区之外”,不检查地址是否真的属于进程地址空间、是否有权限。传个未映射的用户地址进来它照样放行——真正的拦截发生在缺页异常+异常表那一步。读内核代码时看到 access_ok() 通过就断言地址可用,是典型误读。

通关标准

能独立画出一次 write() 调用从 libc 到内核再返回的全链路(寄存器里各是什么、栈上压了什么、两条路径 int 0x80 与 sysenter 各在哪几步不同),并能解释:为什么内核态访问用户地址还要配异常表和修正代码?——达标。