Linux 系统调用实现

作者:Jorge Manjarrez-Sanchez

本文基于我在 Linux 中创建和安装系统调用以及如何安装一个中断向量来控制串口的经验。在某种程度上,这是一篇关于这两个主题的迷你 HOWTO。

什么是系统调用?

系统调用(或系统请求)是对内核的调用,目的是执行控制设备或执行特权指令的特定功能。系统调用如何处理取决于处理器。通常,对内核的调用是由于中断或异常;在调用中,有一个执行特殊操作的请求。例如,串口可以被编程为在某个字符到达时断言中断,而不是轮询它。这样,处理器可以被其他进程使用,并且仅在需要时才服务于串口。

中断请求及其服务之间的内部操作涉及多个 CPU 寄存器和内存段。简而言之,设备通过在外围中断控制器 (PIC) 上断言中断请求线来引发中断,外围中断控制器通过设置中断请求引脚来通知 CPU。在每条指令之后,CPU 检查此引脚。如果启用,它会从数据总线获取 ID,该 ID 指向中断描述符表 (IDT),其中存储了许多任务、中断和门描述符。描述符包含一个选择器到全局描述符表 (GDT),全局描述符表 (GDT) 包含中断服务例程 (ISR) 驻留的内存段的基地址。

请注意,CPU 已经暂停了它正在执行的进程,因此它必须保存一些信息,以便在中断服务完成后能够恢复进程——这是一个上下文切换。此过程涉及多个文件;大多数可以在 linux/arch/i386/kernel/ 目录中找到。其中一个是 entry.S,它是所有系统调用的入口点,它初始化异常的处理。另一个是 irq.c,它包含处理中断的函数。linux/arch/i386/boot/setup.S 文件初始化 GDT,安装虚拟内存等。以 .h 和 .c 结尾的文件之间有很多连接。您可以检查 irq.c 以查看有多少包含项可以获取宏定义,例如 cli(),它在 linux/include/asm-i386/system.h 中清除中断。

要跟踪任何函数的定义路径,请在命令提示符下键入

grep cli 'find / -name '*.[ch]'
-print'

这将搜索根目录中扩展名为 c 和 h 的所有文件,查找单词 cli。此外,您可以发出命令 man 2 intro 来查看有关系统调用的内容。

系统调用的实现

有几种方法可以创建、安装和执行系统调用。最好的方法是不关心低级细节,如上下文切换,并且不使用汇编语言编写任何例程。这可以通过使用 linux/include/asm/unistd.h 目录中的 _syscallN 宏来完成;它在汇编中展开,但操作系统会处理细节。它使用 int 0x80 将执行控制权转移到内核。一个可能的问题是,此宏可以扩展为现有函数,因此必须小心;否则,您将覆盖现有函数。

为了实现您自己的系统调用,您应该拥有 Linux 内核源代码(首先进行备份)以用作工作副本。作为超级用户 (root),在您的主目录中创建 /usr/src/linux 的整个树副本,因为您将没有机会再次这样做。我们将使用的文件位于 somewhere/linux/ 中。

现在您必须为您计划实现的每个函数选择一个名称。您可以检查源代码树中 linux/arch/i386/kernel/entry.S 和 linux/include/asm/unistd.h 中的现有函数。在 entry.S 中,它们位于末尾,而在 unistd.h 中,它们位于开头。检查这些文件也将帮助您了解如何创建系统调用的原型。在检查时,您将看到每个调用都与一个数字关联。此数字在 %eax 处理器寄存器中传递,指示参数的数量,并且系统调用(函数)的每个参数都在 %ebx%ecx%edx%esi%edi 中传递——在 Intel 平台上最多五个参数。对应于每个 _syscallN 的宏定义(取决于 N 的值)可以在 unistd.h 中找到。有关内部工作原理的更多信息可以在 linux/arch/i386/ 下的各种文件中找到,因为我们将“脏活”留给操作系统。

现在让我们看看如何使用 syscallN 宏以最简单的方式实现新的系统调用。让我们创建一个系统调用 sysSum,它接受两个整数参数并返回两者的和。此外,它使用 printk,它类似于 printf,不同之处在于它在内核级别工作,因此我们将看到我们的函数何时被调用。

为此,编辑一个随机选择的文件(例如,文件 linux/ipc/sem.c),并在末尾添加以下行

asmlinkage int sysSum(int a, int b)
{
        printk("calling sysSum\n");
        return a+b;
}

然后编辑 unistd.h 并添加

#define __NR_sysSum     171
171 是数值顺序中的下一个。在 entry.S 接近末尾处,添加
.long SYMBOL_NAME(sysSum)
最后,将最后一行中的数字加一
.space (NR_syscall-172)*4
如果您在两个文件中都没有匹配数字和名称,您将收到“undefined reference to sysSum”错误消息。如果您有一个工作的内核,您只需注意将数字加一并正确写入您的函数名称。至此,您已经添加了您的系统调用;现在您应该获取包含它的新内核。要重新编译内核,请执行以下顺序步骤
#make config
#make dep
#make clean
#make zImage
#cat ~/linux/arch/i386/boot/zImage >/dev/fd0
步骤 1 创建基本内核配置;如果未进行硬件更改,则下次可以跳过它。步骤 2 检查文件之间的任何依赖关系是否正确。步骤 3 清理任何编译中间文件(目标文件等)。最后两个步骤创建一个压缩的内核映像并将其复制到软盘,以便我们可以尝试我们的新内核并保持原始内核不变。

使用这个新创建的内核重新启动以从用户程序调用系统调用:只需将软盘插入驱动器并重新启动。这个简单的程序测试新创建的系统调用

#include <linux/unistd.h>
_syscall2(int, sysSum, int,a,int,b)
main(){
printf("the sum of 4+3 is %d\n",sysSum(4,3));
}

include 行指示 _syscall 定义的位置。下一行说明我们的系统调用具有 int 的返回类型和两个 int 类型的参数。要编译,请使用命令

gcc -I ~/linux/include
指示编译器使用我们的 include 文件。执行后,您将看到消息:首先是来自 sysSum 的消息,然后是来自测试程序的消息。

我们将实现的函数将是使用字符接收中断来控制串口所需的基本函数。普通用户无法访问串口。在 Linux 中,存在函数 inb(port) 和 outb(byte, port) 来接收和发送一个字节;inwoutw 对两个字节的数据执行相同的操作。为了使用它们,您必须通过使用 ioplioperm 函数来获得权限,这些函数必须以超级用户身份调用,并将为普通用户应用程序提供对 I/O 端口的访问权限。

串口

串口,称为 UART 或 RS-232,具有 BIOS(在 PC 系统上)与之关联的两个 I/O 地址和每个端口一个 IRQ(中断请求)。幸运的是,它们与 DOS 中的相同

COM1 0x3F8 IRQ4
COM2 0x2F8 IRQ3

每个 I/O 端口都有一个地址范围来保存各种支持寄存器。COM1 在内存中从 0x3F8 映射到 0x3FF,COM2 从 0x2F8 映射到 0x2FF。有关其中一些的描述,请参见表 1。要在任何寄存器中设置一位,首先读取实际值,然后与所需值进行 OR 运算,从而保留其他位值。

表 1

串口系统调用

我们将要实现的函数如表 2 所示。此时,我们将使用 IRQ4,但使用其他端口或实现选择端口例程并不困难。

表 2

代码清单 1

正如您在代码清单 1 中看到的,我们设置了一些定义和全局变量,保存标志并禁用中断以使我们的事务成为原子的

save_flags;
cli();

如果优先级较高的中断接管处理器,则 UART 将无法正确初始化。我们还需要指示服务 IRQ4 的例程或中断向量。为了服务中断,我们使用 request_irq(在 linux/arch/i386/kernel/irq.c 中),它或多或少等同于 DOS 中的 setvect。它的原型是

int request_irq(unsigned int irq,
void (*handler) (int, void *, struct pt_regs *),
unsigned long irqflags,
        const char *devname,
        void *dev_id)
我们用
i = request_irq (
myirq,sioRead,SA_INTERRUPT,"sioJRMS",NULL);
if (i) return -1;
调用它,其中 myirq 等于 4(COM1 IRQ),sioRead 是指向中断向量的 void 指针,即服务中断的例程;SA_INTERRUPT 是一个标志,表示我们的中断将是“快速”或不可屏蔽类型。sioJRMS 是通常用于标识设备驱动程序的名称,但在此处用于通过查看 /proc/interrupts 文件来监视我们的例程服务的中断。一旦我们的程序正在运行,我们检查此文件以查看是否已设置我们的中断。如果 i 返回的值为 0,则安装中断向量。

接下来,我们必须通过使用 outb 函数来设置一些 UART 初始值。请记住,此时我们是超级用户。在我们创建系统调用,重新编译内核并使用它重新启动后,这些函数将作为每个用户的库中串口的接口提供,而无需特殊权限。我们使用常量 PORT 来标识端口地址,因此您可以稍后更改它。

outb(0,PORT + 1);     /* Disable interrupts - bit
                          0 ->0 */
outb(0x80,PORT + 3);  /* enable DLAB - bit 7 ->1*/
outb(0x0C,PORT + 0);  /* Set Divisor LSB */
outb(0x00,PORT + 1);  /* Set Divisor MSB */
outb(0x03,PORT + 3);  /* 8 Bits, No Parity, 1
                           Stop Bit */
outb(0xC7,PORT + 2);  /* Enable FIFO if UART is
                         16500+ */
outb(0x0B,PORT + 4);  /* Turn on DTR, RTS, and
                         OUT2 */
outb(0x01,PORT + 1);  /* Interrupt when data
                         received */

这些指令设置 9600 的初始波特率。要设置为不同的速率,请将 115,200(晶体频率)除以由寄存器 3F8 (MSB) 和 3F9 (LSB) 在 3FB 的位 7 为 1 时形成的除数。现在我们已经初始化了 UART,我们可以使用以下行恢复标志

restore_flags(flags);
我们不需要 sti(设置中断),因为它由 restore_flags 自动完成。接下来,定义将服务于中断以读取字符并将其放入循环队列的例程
static void sioHandler(int myirq, void *dev_id, struct pt_regs * regs)
{
 int i;
 do { i = inb(PORT + 5);
        if (i & 1) {
                buffer[bufferin] = inb(PORT);
                bufferin++;
                if (bufferin == 1024) bufferin = 0;
                }
        }while (i & 1);
}
下一个函数是作为 syscall 提供给所有用户的函数
asmlinkage int sioRead(void)
{
char ch;
if (bufferin != bufferout){
        ch = buffer[bufferout];
        bufferout++;
        if (bufferout == 1024) bufferout = 0;
          return ch;
        }
}
它将从缓冲区返回一个字符。代码清单 1 中解释了其他 syscall 的目的。现在我们必须处理通知内核已创建新系统调用,使用前面提到的步骤。

在 unistd.h 中,我们为每个新创建的 syscall 放置一行

#define __NR_sioEnable          170
#define __NR_sioRead            171
#define __NR_sioWrite           172
#define __NR_sioEnd             173
#define __NR_sioSetDivisor      174
#define __NR_sioGetDivisor      175

请注意,相应的数字将根据您拥有的系统调用总数而有所不同。在 entry.s 文件中,放置以下行

.long SYMBOL_NAME(sioEnable)
.long SYMBOL_NAME(sioRead)
.long SYMBOL_NAME(sioWrite)
.long SYMBOL_NAME(sioEnd)
.long SYMBOL_NAME(sioSetDivisor)
.long SYMBOL_NAME(sioGetDivisor)
并记住将最后一行中的数字加一。
.space (NR_syscalls-177)*4
添加 Makefile

这一次,我们不会修改任何文件。相反,我们将使用我们的系统调用创建我们的库。首先,在内核源代码树下创建一个名为 /sio 的目录。在其中,您将创建一个名为 sio.c 的文件,其中将包含代码清单 1 的整个源代码,包括我们创建的所有 include、定义和系统调用。现在,为了使用我们的库重建新内核,我们必须创建一个 Makefile,也位于 /sio 目录中

#Makefile for Serial Input/Output system calls
O_OBJS = sio.o
O_TARGET = siocalls.o
include $(TOPDIR)/Rules.make

此文件将调用 Linux 源代码目录下的 Rules.make。此外,顶层 Makefile(源代码目录下的第一个)将为我们工作。编辑此 Makefile,定义源代码目录的定义位置,并添加我们的新目录,使用以下行

SIOCALLS = sio/siocalls.o
这会将我们的目录名称附加到源目录的路径。因为我们正在使用 outb,所以我们必须使用 -O-O2 进行编译,以启用优化以允许使用内联宏。不用担心——顶层 Makefile 会执行此操作。现在按照前面提到的步骤重新编译内核。
测试新的 Syscall

要通过串口使用我们的系统调用,请制作一个零调制解调器配置的串口线。您将需要两个 DB-9 连接器和导线 2-3、3-2、4-6、6-4、5-5、7-8 和 8-7 引脚。然后使用新内核重新启动,并使用代码清单 2 中存档文件中的程序(请参阅资源)作为非超级用户,您将看到您可以使用我们的函数控制串口。请记住使用 COM1 端口中描述的电缆设置连接两台 Linux 机器。

资源

Jorge Manjarrez Sanchez (jmanjarr@acm.org) 拥有墨西哥 IPN 计算研究中心的硕士学位。他目前正在与西班牙 UPM 合作进行博士项目。他参与了多个研究项目,主要在数据库和互联网领域,并开发了 JDBC-Access type-3 驱动程序。他利用业余时间学习 Linux、墨西哥历史、天文学,并在 CIC-IPN 领导 ACM 学生分会。

加载 Disqus 评论