作者:高玉涵
时间:2021.11.16 09:30
博客:blog.csdn.net/cg_i
环境:Linux 7e142849497c 5.10.47-linuxkit #1 SMP Sat Jul 3 21:51:47 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux
如前一章所述,许多应用程序处理持久性数据,即通过将数据存储在文件中,使数据的寿命比程序长。你可以关闭程序,然后再打开,然后回到之前打开的地方。现在,存在两种持久性数据:结构化的和非结构化的。非结构化数据就如同我们在 toupper 程序中所处理的数据,仅处理某人输入的文本文件。文件内容对于程序来说不可用,因为程序无法解读用户试图通过无序文本表达的内容。
结构化数据则正好相反,是计算机擅长处理的数据。结构化数据是拆分为字段和记录的数据,且绝大部分为固定长度的字段和记录。由于数据划分为固定长度的记录和固定格式字段,计算机就能解读数据。结构化数据可包含变长字段,但在此情况下,你最好使用数据库。
本章涉及读写固定长度的简单记录。比如,我们想要存储一些认识的人的基本信息,可以设想关于他们的以下固定长度的示例记录:
本例中,除了年龄是使用 4 字节的数字字段(我们可以只用单个字节,但使用一个字更便于处理),其他所有字段都是字符数据。
在编程过程中,某些定义是你经常会在一个或多个程序中反复使用的。你最好将这些定义分别独立存放入文件,这些文件仅仅在需要时包含在汇编语言文件中。例如,在下面几个程序中,我们将访问上述记录的不同部分。这意味着为了用基址寻址方式访问各字段,我们需要各字段相对于记录起始处的偏移量。以下常量描述了上述结构的各字段偏移量。将这些常量放入名为 record-def.s 的文件:
.equ RECORD_FIRSTNAME, 0
.equ RECORD_LASTNAME, 40
.equ RECORD_ADDRESS, 80
.equ RECORD_AGE, 320
.equ RECORD_SIZE, 324
此外,有几个常量我们在程序中一再定义,因此将其放入某个文件是很有用的,这样就不必总是重复输入它们。这里将其放入名为 linux.s 的文件 参见 1.2。
# Linux 常量定义
# 系统调用号
.equ SYS_EXIT, 1
.equ SYS_READ, 3
.equ SYS_WRITE, 4
.equ SYS_OPEN, 5
.equ SYS_CLOSE, 6
.equ SYS_BRK, 45
# 系统调用中断号
.equ LINUX_SYSCALL, 0x80
# 标准文件描述符
.equ STDIN, 0
.equ STDOUT, 1
.equ STDERR, 2
# 能用状态码
.equ END_OF_FILE, 0
我们将采用 record-def.s 中定义的结构编写本章的 3 个程序。第一个程序将生成包含几个如上定义记录的文件。第二个程序将显示文件中的记录。第三个程序将每个记录中的年龄增加一岁。
除了将在所有这些程序中使用的标准常量,我们还要在其中几个程序中使用两个函数:一个用用于读记录,一个用于写记录。
这两个函数需要哪些参数才能工作呢?我们大致需要:
先来看读取函数:
.include "record-def.s"
.include "linux.s"
# 目的:此函数从文件描述符读取一条记录
#
# 输入:文件描述符及缓冲区
#
# 输出:本函数将数据读取缓冲区并返回状态码
#
# 栈局部变量
.equ ST_READ_BUFFER, 8
.equ ST_FILEDES, 12
.section .text
.globl read_record
.type read_record, @function
read_record:
pushl %ebp
movl %esp, %ebp
pushl %ebx
movl ST_FILEDES(%ebp), %ebx # 文件描述符放入 %ebx
movl ST_READ_BUFFER(%ebp), %ecx # 读取数据存储位置放入 %ecx
movl $RECORD_SIZE, %edx # 缓冲区大小
movl $SYS_READ, %eax
int $LINUX_SYSCALL # 读取缓冲区大小返回到 %eax 中
# 注意:%eax 中含返回值,我们将该值传回调用程序
popl %ebx
movl %ebp, %esp
popl %ebp
ret
这是个相当简单的函数,只是从给定的文件描述符读取特定结构大小的数据,放入相应大小的缓冲区。而写函数与之类似:
.include "linux.s"
.include "record-def.s"
# 目的:本函数将一条记录写入给定文件描述符
#
# 输入:文件描述符和缓冲区
#
# 输出:本函数将数据写入缓冲区并返回状态码
#
# 栈局部变量
.equ ST_WRITE_BUFFER, 8
.equ ST_FILEDES, 12
.section .text
.globl write_record
.type write_record, @function
write_record:
pushl %ebp
movl %esp, %ebp
pushl %ebx
movl $SYS_WRITE, %eax
movl ST_FILEDES(%ebp), %ebx # 写入文件描述符放入 %ebx
movl ST_WRITE_BUFFER(%ebp), %ecx # 缓冲区位置
movl $RECORD_SIZE, %edx # 写入的大小
int $LINUX_SYSCALL
# 注意:%eax含返回值,我们将之传回调用程序
popl %ebx
movl %ebp, %esp
popl %ebp
ret
现在我们已经有了基本定义,可以开始写程序了。
这个程序简单地将一些硬编码记录写入磁盘。具体来讲,程序将:
输入以下代码到文件 write-records.s :
.include "linux.s"
.include "record-def.s"
.section .data
# 我们想写入的常量数据
# 每个数据项以空字节(0)填充到适当的长度
#
# .rept 用于填充每一项。
# .rept 告诉汇编程序将 .rept 和 .endr 之间的段重复指定次数
# 在这个程序中,此指令用于将多余的空白字符增加到每个字段未尾以将之填满
#
record1:
.ascii "Fredrick\0"
.rept 31 # 填充到 40 字节
.byte 0
.endr
.ascii "Bartlett\0"
.rept 31 # 填充到 40 字节
.byte 0
.endr
.ascii "4242 S Praireit\nTulsa, OK 55555\0"
.rept 209 # 填充到 240 字节
.byte 0
.endr
.long 45
record2:
.ascii "Marilyn\0"
.rept 32 # 填充到 40 字节
.byte 0
.endr
.ascii "Taylor\0"
.rept 33 # 填充到 40 字节
.byte 0
.endr
.ascii "2224 S Johannan St\nChicago, IL 12345\0"
.rept 203 # 填充到 240 字节
.byte 0
.endr
.long 29
record3:
.ascii "Derrick\0"
.rept 32 # 填充到 40 字节
.byte 0
.endr
.ascii "McIntire\0"
.rept 31 # 填充到 40 字节
.byte 0
.endr
.ascii "500 W Oakland\nSan Diego, CA 54321\0"
.rept 206 # 填充到 240 字节
.byte 0
.endr
.long 36
# 这是我们要写入文件的文件名
file_name:
.ascii "test.dat\0"
.equ ST_FILE_DESCRIPTOR, -4
.globl _start
_start:
# 复制栈指针到 %ebp
movl %esp, %ebp
# 为文件描述符分配空间
subl $4, %esp
# 打开文件
movl $SYS_OPEN, %eax
movl $file_name, %ebx
movl $0101, %ecx # 本指令表明如文件不存在则创建,并打开文件用于写入
movl $0666, %edx
int $LINUX_SYSCALL
# 存储文件描述符
movl %eax, ST_FILE_DESCRIPTOR(%ebp)
# 写第一条记录
pushl ST_FILE_DESCRIPTOR(%ebp)
pushl $record1
call write_record
addl $8, %esp # 重新指向文件描述符
# 写第二条记录
pushl ST_FILE_DESCRIPTOR(%ebp)
pushl $record2
call write_record
addl $8, %esp
# 写第三条记录
pushl ST_FILE_DESCRIPTOR(%ebp)
pushl $record3
call write_record
addl $8, %esp
# 关闭文件描述符
movl $SYS_CLOSE, %eax
movl ST_FILE_DESCRIPTOR(%ebp), %ebx
int $LINUX_SYSCALL
# 退出程序
movl $SYS_EXIT, %eax
movl $0, %ebx
int $LINUX_SYSCALL
这是一个相当简单的程序,仅定义要写入 .data 段的数据,以及适当的系统调用和函数调用来完成工具。要复习所有用到过的系统调用,请参见《Linux 下用汇编语言处理文件》。
为了生成应用程序,我们运行以下命令:
as --gstabs --32 write-records.s -o write-records.o
as --gstabs --32 write-record.s -o write-record.o
ld -m elf_i386 write-record.o write-records.o -o write-records
目前我们分别汇编两个文件,然后用链接器将之合并。要运行程序,请输入命令:
./write-records
这条命令会创建一个包含记录的 test.dat 文件。但是,由于记录包含非打印字符(即空字符),可能无法通过文本编辑器查看。因此,我们需要下一个程序来为我们读取记录。下面是文件部分内容:
[root@7e142849497c:~/html/record# xxd -b test.dat
00000000: 01000110 01110010 01100101 01100100 01110010 01101001 Fredri
00000006: 01100011 01101011 00000000 00000000 00000000 00000000 ck....
0000000c: 00000000 00000000 00000000 00000000 00000000 00000000 ......
00000012: 00000000 00000000 00000000 00000000 00000000 00000000 ......
00000018: 00000000 00000000 00000000 00000000 00000000 00000000 ......
0000001e: 00000000 00000000 00000000 00000000 00000000 00000000 ......
00000024: 00000000 00000000 00000000 00000000 01000010 01100001 ....Ba
0000002a: 01110010 01110100 01101100 01100101 01110100 01110100 rtlett
00000030: 00000000 00000000 00000000 00000000 00000000 00000000 ......
00000036: 00000000 00000000 00000000 00000000 00000000 00000000 ......
0000003c: 00000000 00000000 00000000 00000000 00000000 00000000 ......
00000042: 00000000 00000000 00000000 00000000 00000000 00000000 ......
00000048: 00000000 00000000 00000000 00000000 00000000 00000000 ......
现在,我们将考虑读取记录的过程。这个程序将读取每个记录,并显示每条记录中的名。
由于每个人的姓名长度不同,我们需要一个函数来计算要写入的字符数。由于我们用空字符填充每个字段,因此只需对空字符之前的字符计数。注意,这意味着每条记录都必须包含至少一个空字符。
# 目的:对字符进行计数,直到遇到空字符
#
# 输入:字符串地址
#
# 输出:将计数值返回到 %eax
#
# 过程:
# 用到的寄存器:
# %ecx - 字符计数
# %al - 当前字符
# %edx - 当前字符地址
#
.type count_chars, @function
.globl count_chars
# 这是我们的一个参数在栈上的位置
.equ ST_STRING_START_ADDRESS, 8
count_chars:
pushl %ebp
movl %esp, %ebp
# 计数是从 0 开始
movl $0, %ecx
# 数据的起始地址
movl ST_STRING_START_ADDRESS(%ebp), %edx
count_loop_begin:
# 获取当前字符
movb (%edx), %al
# 是否为空字符?
cmpb $0, %al
# 若为空字符则结束
je count_loop_end
# 否则,递增计数器和指针
incl %ecx
incl %edx
# 返回循环起始处
jmp count_loop_begin
count_loop_end:
# 结束循环,将计数值移入 %eax 并返回
movl %ecx, %eax
popl %ebp
ret
正如你所看到的,这是一个相当简单的函数,只是遍历所有字节并计数,直到遇到空字符。然后,它返回计数值。
我们的记录读取程序也是相当简单的。程序将完成如下步骤:
打开文件;
尝试读取一条记录;
若到达文件结束处则退出,否则计算名的字符数;
将名写到 STDOUT;
写一个换行符到 STDOUT;
返回并读取另一条记录。
为了编写此程序,我们需要另一个简单函数——写一个换行符到 STDOUT 的函数。将下面代码放置到 write-newlines.s 文件中。
.include 'linux.s'
.globl write_newline
.type write_newline, @function
.section .data
newline:
.ascii '\n'
.section .text
.equ ST_FILEDS, 8
write_newline:
pushl %ebp
movl %esp, %ebp
movl $SYS_WRITE, %eax
movl ST_FILEDS(%ebp), %ebx
movl %newline, %ecx
movl $1, %edx
int $linux_syscall
movl %ebp, %esp
popl %ebp
ret
现在,我们准备编写主程序,read-records.s 的代码如下:
.include 'linux.s'
.include 'record-def.s'
.section .data
file_name:
.ascii 'test.dat\0'
.section .bss
.lcomm record_buffer, RECORD_SIZE
.section .text
# 主程序
.globl _start
_start
# 这些是我们将存储输入输出描述符的栈位置(仅供参考:也可以用一个 .data 段中的内在地址代替)
.equ ST_INPUT_DESCRIPTOR, -4
.equ ST_OUTPUT_DESCRIPTOR, -8
# 复制栈指针到 %ebp
movl %esp, %ebp
# 为保存文件描述符分配空间
subl $8, %esp
# 打开文件
movl $SYS_OPEN, %eax
movl $file_name, %ebx
movl $0, %ecx # 表示只读文件
movl $0666, %edx # 表示所有者、组和所有用户对文件都有读/写权限。
int $LINUX_SYSCALL
# 保存文件描述符
movl %eax, ST_INPUT_DESCRIPTOR(%ebp)
# 即使输出文件描述符是常数,我们也将其保存在本地变量,这样
# 如果稍后决定不将其输出到 STDOUT,很容易加以更改
movl $STDOUT, ST_OUTPUT_DESCRIPTOR(%ebp)
record_read_loop:
pushl ST_INPUT_DESCRIPTOR(%ebp) # 把参数压入栈
pushl $record_buffer
call read_record
addl $8, %esp
# 返回读取的字节数
# 如果字节数与我们请求的字节数不同,说明已到达文件结束处或出现错误,
# 我们就要退出
cmpl $RECORD_SIZE, %eax
jne finished_reading
# 否则,打印出名,但我们首先必须知道名的大小
pushl $RECORD_FIRSTNAME + record_buffer
call count_chars
addl $4, %esp
movl %eax, %edx
movl ST_OUTPUT_DESCRIPTOR(%ebp), %ebx
movl $SYS_WRITE, %eax
movl $RECORD_FIRSTNAME + record_buffer, %ecx
int $LINUX_SYSCALL
pushl ST_OUTPUT_DESCRIPTOR(%ebp)
call write_newline
addl $4, %esp
jmp record_read_loop
finished_reading:
movl $SYS_EXIT, %eax
movl $0, %ebx
int $LINUX_SYSCALL
要生成这个程序,我们要汇编其所有组成文件并链接它们:
as --gstabs --32 read_record.s -o read-record.o
as --gstabs --32 count-chars.s -o count-chars.o
as --gstabs --32 write-newline.s -o write-newline.o
as --gstabs --32 read-records.s -o read-records.o
ld -m elf_i386 read-record.o count-chars.o write-newline.o read-records.o -o read-records
你可以通过命令 ./read-records
读取记录。
root@7e142849497c:~/html/record# ./read-records
Fredrick
Marilyn
Derrick
root@7e142849497c:~/html/record#
如上所述,这个程序打开该文件,然后运行用于读取的循环,检查文件是否结束,并写入名。也许对于你来说,下面一行代码中存在新结构:
pushl $RECORD_FIRSTNAME + record_buffer
它看起来就像我们把 add 指令和 pushl 结合起来一样,但实际上并非如此。你看, RECORD_FIRSTNAME 和 record_buffer 都是常数,前者是直接常数,通过使用 .equ 指令创建,后者则是由汇编程序自动定义为标签(其值是紧随其后的数据的起始地址)。由于两者都是汇编程序知道的常数,因此汇编程序在实际汇编程序时能将两者相加,这样整个指令就是立即寻址方式的单个常量入栈。
RECORD_FIRSTNAME 常量是一条记录从起始地址到名字段之间的字节数。record_buffer 是用于保存记录缓冲区的名字。将以上两者相加,我们就可获得存储在 record_buffer 中记录的名字段地址。
本节,我们将编写完成如下步骤的程序:
如同我们最近遇到的多数程序一样,这个程序相当直观。
.include "linux.s"
.include "record-def.s"
.section .data
input_file_name:
.ascii "test.dat\0"
output_file_name:
.ascii "testout.dat\0"
.section .bss
.lcomm record_buffer, RECORD_SIZE
# 局部亦是的栈偏移量
.equ ST_INPUT_DESCRIPTOR, -4
.equ ST_OUTPUT_DESCRIPTOR, -8
.section .text
.globl _start
_start:
# 复制栈指针并为局部亦是分配空间
movl %esp, %ebp
subl $8, %esp
# 打开用于读取的文件
movl $SYS_OPEN, %eax
movl $input_file_name, %ebx
movl $0, %ecx
movl $0666, %edx
int $LINUX_SYSCALL
movl %eax, ST_INPUT_DESCRIPTOR(%ebp)
# 打开用于写入的文件
movl $SYS_OPEN, %eax
movl $output_file_name, %ebx
movl $0101, %ecx
movl $0666, %edx
int $LINUX_SYSCALL
movl %eax, ST_OUTPUT_DESCRIPTOR(%ebp)
loop_begin:
pushl ST_INPUT_DESCRIPTOR(%ebp)
pushl $record_buffer
call read_record
addl $8, %esp
# 返回读取的字节数
# 如果字节数与我们请求的字节数不同,
# 说明已到达文件结束处或出现错误,
# 我们就要退出
cmpl $RECORD_SIZE, %eax
jne loop_end
# 递增年龄
incl record_buffer + RECORD_AGE
# 写入记录
pushl ST_OUTPUT_DESCRIPTOR(%ebp)
pushl $record_buffer
call write_record
add $8, %esp
jmp loop_begin
loop_end:
movl $SYS_EXIT, %eax
movl $0, %ebx
int $LINUX_SYSCALL
我们可以将以上代码输入名为 add-year.s 的文件。为生成程序,请输入以下命令:
as --gstabs --32 add-year.s -o add-year.o
ld -m elf_i386 add-year.o read-record.o write-record.o -o add-year
要运行此程序,请输入以下命令:
./add-year
本程序将 test.dat 中每一条记录的年龄字段增加一年,并将新记录写到文件 testout.dat 。
正如你所看到的,写固定长度的记录百常简单。你只需要读取缓冲区块的数据,进行处理,然后将它们写回文件。遗憾的是,这个程序并未将新的年龄显示在屏幕上,你无法验证程序是否有效。关于显示数字将来会单独出一篇文章。
汇编语言大部分都是对栈进行操作。在先前几篇文章中,我虽给出 GDB 调试程序的栈表,但如果您不亲自用 GDB 边调试边观查表,相信即使是老鸟也不能一时理清头绪(过一段时间后我看也很懵)。鉴于此,这一次我尝试将开始部分的代码,将它们操作栈的步骤以图的形式展现出来,以期能给大家一个直观的感受。涉及代码如下:
_start:
# 这些是我们将存储输入输出描述符的栈位置(仅供参考:也可以用一个 .data 段中的内在地址代替)
.equ ST_INPUT_DESCRIPTOR, -4
.equ ST_OUTPUT_DESCRIPTOR, -8
# 复制栈指针到 %ebp
movl %esp, %ebp
# 为保存文件描述符分配空间
subl $8, %esp
# 打开文件
movl $SYS_OPEN, %eax
movl $file_name, %ebx
movl $0, %ecx # 表示只读文件
movl $0666, %edx # 表示所有者、组和所有用户对文件都有读/写权限。
int $LINUX_SYSCALL
# 保存文件描述符
movl %eax, ST_INPUT_DESCRIPTOR(%ebp)
# 即使输出文件描述符是常数,我们也将其保存在本地变量,这样
# 如果稍后决定不将其输出到 STDOUT,很容易加以更改
movl $STDOUT, ST_OUTPUT_DESCRIPTOR(%ebp)
record_read_loop:
pushl ST_INPUT_DESCRIPTOR(%ebp) # 把参数压入栈
pushl $record_buffer
call read_record
addl $8, %esp
其中ESP实线箭头表示栈顶,虚线箭头表示先前指向的栈位置。其它位置的实线箭头,多用于标记指令、栈、数据来源和去向。如,图2 指向 图9 的箭头,表示此时它们栈内容一样。图8 指向 read_record,表示接下来执行的是 read_record 函数的内容。图15 指向图9 是函数执行完返回到调用它的位置。
栈中保存的数据也不是程序实际在计算机中运行的样子,这里更多的是用于标识或区分的作用。如,图2 中的 (-4)、(-8) 用于表示栈相对位置(实际栈的位置是一串 16 进制地址值)图 8 中栈顶实际应保存的是 addl $8… 指令的地址,因为我们不知道地址是什么所以用指令代表。
使用 C 调用约定的汇编语言函数
Linux 如何从命令行执行程序
Linux 下用汇编语言处理文件