1.了解在编译过程中链接的作用
链接是将各种代码和数据收集并组合成为一个文件的过程,最终得到的文件可以被加载到内存执行。在早期的计算机系统中,链接是手动完成的,在现代计算机系统中,链接是由链接器自动完成的。
在大型的应用程序开发过程中,不可能将所有功能实现全部都写在main.c
中,而是把它拆分成为很多个更容易进行管理的模块f1.c
、f2.c
等。当我们修改其中一个模块时,只需要重新编译该模块,别的模块不用进行重新编译。对于初学者,代码规模一般都比较小,链接一般都是由链接器默默去进行处理的,也不会觉得链接很重要,一般不需要我们自己去进行链接。那么链接有什么作用呢?
- 1.在构建大型程序时,经常会遇到缺少库文件或者是库文件的版本不兼容而导致的链接错误,解决这类问题就需要我们去理解链接器是如何使用库文件来进行解析引用的,否则解决这类问题会无从下手。
- 2.理解链接能够帮助我们去避免一些危险的编程错误。
- 3.理解链接可以帮助我们理解编程语言中的作用域规则是如何实现的,比如全局变量和局部变量之间的区别?当我们看到一个static类型变量代表什么?
- 4.理解链接还可以帮助我们理解一些重要的系统概念,比如加载和运行、虚拟内存、内存映射等。
- 5.理解链接可以帮助我们去更好地利用共享库。
2.执行编译的步骤
比如有下面的文件sum.c
和main.c
下面是sum.c
int sum(int *a, int n)
{
int i = 0, s = 0;
for (; i < n; i++)
{
s += a[i];
}
return s;
}
下面是文件main.c
int sum(int *a, int n);
int array[2] = {1, 2};
int main()
{
int val = sum(array, 2);
return val;
}
在Linux系统中可以使用gcc -Og -o prog main.c sum.c
去编译得到可执行程序prog
。其中-Og
表示代码优化级别为debug级别,用来告诉编译器生成的机器代码要符合原始C代码的结构目的是方便调试。在真正的程序中一般使用-O1
或者是-O2
优化等级,可以提高程序的性能。
编译系统一般包括如下步骤:
- 1.预处理(pre-processor),可以使用命令
gcc -E -o main.i main.c
,其中-E
选项用来限制gcc
只执行预处理,不做编译、汇编以及链接操作,生成一个main.i
文件,它是一个ASCII码的中间文件。 - 2.编译(compiler),可以使用
gcc -S -o main.s main.i
去执行这一步,其中-S
选项表示只对文件进行编译,不做汇编和链接处理器,最终得到一个main.s
文件。 - 3.汇编(assembler),可以使用命令
as -o main.o main.s
去进行执行,最终得到一个main.o
文件。main.o
文件称为可重定位目标文件。 - 4.链接(linker),可以使用链接器(ld)完成该步骤。链接就是将可重定位目标文件和必要的系统文件组合起来生成一个可执行目标文件的操作。
3.可重定位目标文件
编写如下的测试代码main.c
#include <stdio.h>
int count = 10;
int value;
void func(int sum)
{
printf("sum is:%d\n", sum);
}
int main()
{
static int a = 1;
static int b = 0;
int x = 1;
func(x + a + b);
return 0;
}
- 1.我们使用
gcc -c main.c
命令去生成main.o
这个可重定位目标文件。 - 2.接着我们使用
wc -c main.o
去查看main.o
占用的磁盘空间字节数量,得到如下的结果1896 main.o
可重定位目标文件(.o
)包括三个部分组成:ELF Header、Sections、Section Headers。
3.1 Elf Header
查看Elf Header
使用的命令是readelf -h main.o
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 1064 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 64 (bytes)
Number of section headers: 13
Section header string table index: 12
我们首先来看Elf Header
的第一行Magic
:7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
:
- 1.前4个字节
7f 45 4c 46
代表文件的魔数(这四个字节对应ASCII码中的'DEL'、'E'、'L'、'F'),用来说明当前文件的类型(熟悉java的都知道.class
文件的魔数是ca fe ba be
)。 - 2.第5个字节代表的是ELF文件的类型,01代表32位,02代表64位。
- 3.第6个字节表示字节序,01表示小端方式,02代表大端方式。
- 4.第7个字节代表的是ELF文件的版本号,一般为01。
- 5.8-16个字节在ELF标准中没有定义,用0填充。
接着来看剩下的内容:
- 1.
Type
代表这是一个可重定位文件(REL
),还有另外两种类型的文件,分别是可执行文件和共享文件。 - 2.
Start of program headers
指明这是程序的开始地址为0。 - 3.
Start of section headers
指明section headers
的开始地址为1064(十进制)。 - 4.
Size of this header
指明Elf Header
的大小,为64B
。 - 5.
Size of section headers
指明每个Section Header
的大小为64B
,Number of section headers
指明Section Header
的数量为13个。也就是说Section Header Table
这部分占用的空间大小为13*64=832B
。
通过Elf Header
,我们可以得到如下该文件的结构如下图所示
3.2 Section Header Table
There are 13 section headers, starting at offset 0x428:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000054 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000318
0000000000000078 0000000000000018 I 10 1 8
[ 3] .data PROGBITS 0000000000000000 00000094
0000000000000008 0000000000000000 WA 0 0 4
[ 4] .bss NOBITS 0000000000000000 0000009c
0000000000000004 0000000000000000 WA 0 0 4
[ 5] .rodata PROGBITS 0000000000000000 0000009c
000000000000000b 0000000000000000 A 0 0 1
[ 6] .comment PROGBITS 0000000000000000 000000a7
000000000000002a 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 000000d1
0000000000000000 0000000000000000 0 0 1
[ 8] .eh_frame PROGBITS 0000000000000000 000000d8
0000000000000058 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 00000390
0000000000000030 0000000000000018 I 10 8 8
[10] .symtab SYMTAB 0000000000000000 00000130
0000000000000198 0000000000000018 11 11 8
[11] .strtab STRTAB 0000000000000000 000002c8
0000000000000049 0000000000000000 0 0 1
[12] .shstrtab STRTAB 0000000000000000 000003c0
0000000000000061 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
从这个文件中我们可以发现:
- 1.
.text
的起始位置是0x40
,大小为64(0x54
)个字节。起始地址是0x40
,而Elf Header
的大小就是0x40
,也就是说,.text
是紧跟在Elf Header
之后的,结束地址是0x94
。 - 2.
.data
的起始位置是0x94
,大小为8(0x08
)个字节。而.text
的结束地址为0x94
,因此.data
是紧跟在.text
之后的,结束地址为0x9c
。 - 3.
.bss
和.rodata
的起始位置都是0x9c
,.bss
的大小为4(0x04
),.rodata
的大小为11(0x0b
)。 - 4.
.comment
存放的是编译器的版本信息,.symtab
存放的是符号表,.rel .text
存放的是重定位表,.debug
存放的是调试信息,.line
存放的是原始的C代码的行号和.text
中的机器指令之间的映射(类似java字节码文件中的行号表),strtab
存放的是字符串表(String Table
)。
对各个section
进行说明:
- 1.
.text
主要存放的是已经编译好的机器代码。可以使用反汇编工具objdump
去对机器代码转换成汇编代码,具体命令是objdump -s -d main.o
。 - 2.
.data
主要存放的是 已经初始化 的全局变量和静态变量的值。比如我们定义的全局变量count=10
和静态变量a=1
就存放在这里,恰好是8个字节。 - 3.对于未初始化(或者初始化为0)的全局变量和静态变量会被放到
.bss
中,用bss表示未初始化的数据最早源于IBM 704汇编语言中,后续一直沿用。有一个区分bss和data的简单方法,就是把bss翻译成为更好节省空间(better save space)。 - 4.有个很奇怪的点是
.bss
和.rodata
的起始地址相等,并且我们的未出初始化的变量数量占用并不是4个字节/11个字节,实际上.bss
只是一个占位符,用来区分初始化和未初始化的变量,并没有占用具体空间,会在运行时在内存中动态分配空间。而.rodata
中存放的是 只读数据,比如switch
维护的跳转表,printf
中使用到的格式化串(比如sum is:%d
)。
最终就是如下结构:
image.png3.3 Section
使用到的命令为objdump -s -d main.o
main.o: file format elf64-x86-64
Contents of section .text:
0000 554889e5 4883ec10 897dfc8b 45fc89c6 UH..H....}..E...
0010 488d3d00 000000b8 00000000 e8000000 H.=.............
0020 0090c9c3 554889e5 4883ec10 c745fc01 ....UH..H....E..
0030 0000008b 15000000 008b45fc 01c28b05 ..........E.....
0040 00000000 01d089c7 e8000000 00b80000 ................
0050 0000c9c3 ....
Contents of section .data:
0000 0a000000 01000000 ........
Contents of section .rodata:
0000 73756d20 69733a25 640a00 sum is:%d..
Contents of section .comment:
0000 00474343 3a202855 62756e74 7520372e .GCC: (Ubuntu 7.
0010 352e302d 33756275 6e747531 7e31382e 5.0-3ubuntu1~18.
0020 30342920 372e352e 3000 04) 7.5.0.
Contents of section .eh_frame:
0000 14000000 00000000 017a5200 01781001 .........zR..x..
0010 1b0c0708 90010000 1c000000 1c000000 ................
0020 00000000 24000000 00410e10 8602430d ....$....A....C.
0030 065f0c07 08000000 1c000000 3c000000 ._..........<...
0040 00000000 30000000 00410e10 8602430d ....0....A....C.
0050 066b0c07 08000000 .k......
Disassembly of section .text:
0000000000000000 <func>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 83 ec 10 sub $0x10,%rsp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 8b 45 fc mov -0x4(%rbp),%eax
e: 89 c6 mov %eax,%esi
10: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 17 <func+0x17>
17: b8 00 00 00 00 mov $0x0,%eax
1c: e8 00 00 00 00 callq 21 <func+0x21>
21: 90 nop
22: c9 leaveq
23: c3 retq
0000000000000024 <main>:
24: 55 push %rbp
25: 48 89 e5 mov %rsp,%rbp
28: 48 83 ec 10 sub $0x10,%rsp
2c: c7 45 fc 01 00 00 00 movl $0x1,-0x4(%rbp)
33: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 39 <main+0x15>
39: 8b 45 fc mov -0x4(%rbp),%eax
3c: 01 c2 add %eax,%edx
3e: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 44 <main+0x20>
44: 01 d0 add %edx,%eax
46: 89 c7 mov %eax,%edi
48: e8 00 00 00 00 callq 4d <main+0x29>
4d: b8 00 00 00 00 mov $0x0,%eax
52: c9 leaveq
53: c3 retq
我们对重要的部分进行一下分析
- 1.首先来看
.text
部分,很明显就是存放的汇编代码对应的机器代码。(并且在最底下这部分还展示了反汇编之后的代码) - 2.然后就是
.data
部分,内容是0000 0a000000 01000000
,因为采用的是小端字节序,因此应该对应的就是0000000a
和00000001
,也就是对应的全局变量count=10
和静态变量a=1
。 - 3.然后是
.rodata
,内容是73756d20 69733a25 640a00
,使用ASCII码转过来就是sum is:%d
,存放的是只读数据。
3.4 符号和符号表
3.4.1 符号表
符号表是众多section
中的一个(.symtab
),Linux查看符号表使用到的命令是readelf -s main.o
,最终得到的结果如下:
Symbol table '.symtab' contains 17 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS main.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5
6: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 a.2254
7: 0000000000000000 4 OBJECT LOCAL DEFAULT 4 b.2255
8: 0000000000000000 0 SECTION LOCAL DEFAULT 7
9: 0000000000000000 0 SECTION LOCAL DEFAULT 8
10: 0000000000000000 0 SECTION LOCAL DEFAULT 6
11: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 count
12: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM value
13: 0000000000000000 36 FUNC GLOBAL DEFAULT 1 func
14: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
15: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
16: 0000000000000024 48 FUNC GLOBAL DEFAULT 1 main
各个字段的说明:
- 1.最后一列
Name
代表的是符号的名称。 - 2.
Type
字段定义的是该符号的类型,FUNC
代表函数,OBJECT
代表对象(变量/数组),SECTION
代表section
,FILE
代表文件。 - 3.
Bind
字段定义的是该符号是否是全局可见的,GLOBAL
代表全局可见,LOCAL
代表局部可见。 - 4.
Ndx
代表的是与Section Header Table
对应的section
的索引值。 - 5.
Value
字段代表的是符号所在位置相对于section
的偏移量,Size
代表该符号占用空间的大小(字节数)。 - 6.
Vis
字段在C语言中并未使用,因此忽略这个字段。
我们可以看到:
- 1.我们定义的函数
main
和func
它们都是全局可见的,因此Type=FUNC
并且Bind=GLOBAL
,其Ndx=1
代表其位于.text
,从Size
字段我们可以看出来,func
占用的大小为36B
,main
占用的大小是48B
。 - 2.对于
printf
函数,它并不定义在main.c
中,因此Ndx=UND
代表Undefined
。 - 3.对于
count
和value
,二者都是全局变量,因此Bind=GLOBAL
并且Type=OBJECT
,但是它们的Ndx
并不相同也就说它们处于不同的section
中,count
的Ndx=3
代表它位于.data
,value
却位于COM
(COMMON
)中。 - 4.对于
a
和b
,二者都是局部变量,因此Type=OBJECT
并且Bind=LOCAL
,但是a
的Ndx=3
和全局变量count
位于同一个section
中,虽然对b
进行初始化了,但是初始化为0没什么用,因此还是会被放入.bss
段中,也就是Ndx=4
。(对于a
和b
被改为了a.2254
和b.2255
的处理,称为名称修饰,用来方式静态变量发生重名) - 5.对于
Name
为空的部分,代表它就是section
的名称,比如对于Num=2
位置的表项,对应的Name
就是.text
。 - 6.对于局部变量
x
,它并不会被保存在这里,因为局部变量在运行时在栈中动态分配,链接器不必关心这部分符号。
COMMON
和.bss
区别:
- 1.
COMMON
用来存放未初始化的全局变量,而.bss
用来存放未初始化的静态变量,和初始化为0的全局或者静态变量。
3.4.2 符号
在链接器的上下文中存在三种不同的符号,它们分别是
- 1.由该模块定义,也能被别的模块所访问的全局符号(
Global Symbols
)。比如上面定义的全局变量count
和value
以及函数main
和func
- 2.被其它模块定义,被当前模块所引用的符号,称为外部符号(
Externals Symbols
)。 - 3.由该模块定义,但是不能被别的模块所访问的局部符号(
Local Symbols
)。带有static修饰的符号,是不能被其它模块引用的,类似java中的private
。对于C语言来说,static
的作用就是隐藏模块内部的变量和函数声明。
4.符号解析和静态库的链接
4.1 符号解析错误-无法找到符号引用定义
#include <stdio.h>
int add(int x, int y);
int main()
{
int x = 1, y = 2;
int result = add(x, y);
printf("the result is: %d", result);
return 0;
}
当我们使用如上的代码使用gcc -c test_link_error.c
进行编译和汇编时,不会有任何的错误。我们使用readelf -s test_link_error.o
去查看它的符号表
Symbol table '.symtab' contains 13 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS test_link_error.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5
6: 0000000000000000 0 SECTION LOCAL DEFAULT 7
7: 0000000000000000 0 SECTION LOCAL DEFAULT 8
8: 0000000000000000 0 SECTION LOCAL DEFAULT 6
9: 0000000000000000 69 FUNC GLOBAL DEFAULT 1 main
10: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND add
12: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
我们发现,就算只是声明了函数add
,没对它进行实现,但是它仍旧被加入了符号表中。
当我们直接使用gcc test_link_error.c -o test_link_error
命令对其进行编译和链接并生成可执行文件时,却发生了引用未定义的错误。我们可以知道,这个错误发生在链接阶段,而且下面的报错也说了发生在ld
(链接器)。
/tmp/ccpeaREp.o: In function `main':
test_link_error.c:(.text+0x21): undefined reference to `add'
collect2: error: ld returned 1 exit status
4.2 符号解析错误-符号被重复定义
首先我们得了解强符号和弱符号的概念:
强符号包括已经完成初始化的全局变量和函数
弱符号包括未完成初始化的全局变量
4.2.1 强符号被重复定义
#include <stdio.h>
int main()
{
printf("hello\n");
}
#include <stdio.h>
int main()
{
printf("world\n");
}
比如不同的文件中包含了上面的两段代码,main
函数就被重复定义了多次,但是函数是强符号,因此不能正确完成链接,直接抛出错误。类似的,在不同模块中初始化同名的全局变量,也是会直接抛出错误。
4.2.2 强符号和弱符号同时出现/弱符号被重复定义
(1) example1
int x = 1234;
int main()
{
function();
printf("x=%d", x);
}
int x;
void function(void)
{
x = 4567;
}
上述两段代码在不同的模块当中,其中一个定义了强符号x
,另一个定义了弱符号x
,则以强符号为准。但是function
中对强符号进行了修改,导致main
函数中打印出来的数字并不是预期的1234
,而是4567
,假如两个模块是不同的开发者进行开发的,就会???啥情况。
(2) example2
对于弱符号被重复定义,也会出现这种情况
int x;
int main()
{
x = 1234;
function();
printf("x=%d", x);
}
int x;
void function(void)
{
x = 4567;
}
上面就是两个弱符号的情况,也会出现这种情况。
(3) example3
这还不是最糟糕的,还有另外一种情况
int y = 3333;
int x = 1234;
int main()
{
function();
printf("x=%d", x);
}
double x;
void function(void)
{
x = 0.0;
}
这种情况,double在64位系统下占用64个字节,而int占用的是4个字节,在第一个模块中y和x在内存上会存放在一起。当第二个模块中对x进行赋值0.0
,不仅把x位置的内存改了,还把y位置的也给改了,造成了严重的错误。
(4) 如何避免上述的错误
为了避免这类错误,可以在编译时添加-fno-common
的编译选项告诉编译器,遇到多重定义的全局符号时,触发一个错误。或者添加-Werror
编译选项告诉编译器把所有的警告当做异常进行处理,编译不让通过。
4.3 静态链接库的链接
4.3.1 C中的静态链接库存放位置
了解过C语言的都知道printf
是C语言提供的一个库函数,那么链接器是如何使用这一类的静态库的?
printf
、scanf
、strcpy
等函数都是定义在libc.a
的文件库中。在Linux中静态库文件以archive
的特殊文件格式存放在磁盘上。archive
是一组可重定位目标文件(.o
)的集合。
- 1.使用
objdump -t /usr/lib/x86_64-linux-gnu/libc.a > lib.txt
命令将库文件中的内容输出到文件。 - 2.使用
cat lib.txt | grep -n printf.o
如命令可以查找到printf.o
文件的所在的行数,然后就可以根据行数去文件中找对应的内容。
当然,也可以使用工具ar
将libc.a
文件解压成为一个个的.o
文件到当前目录,具体命令为ar -x /usr/lib/x86_64-linux-gnu/libc.a
。
4.3.2 如何去构建一个静态链接库
下面是vector_add.c
源文件中的内容,用来实现向量的加法
void vector_add(int *x, int *y, int *z, int n)
{
for (int i = 0; i < n; i++)
{
z[i] = x[i] + y[i];
}
}
下面是vector_mul.c
源文件的内容,用来实现向量的乘法
void vector_mul(int *x, int *y, int *z, int n)
{
for (int i = 0; i < n; i++)
{
z[i] = x[i] * y[i];
}
}
构建流程如下:
- 1.使用
gcc -c vector_add.c vector_mul.c
对源文件进行编译和汇编工作。 - 2.使用
ar rcs libvector.a vector_add.o vector_mul.o
去对可执行目标文件进行压缩,得到静态库libvector.a
文件。
4.3.3 对静态链接库进行引用
创建一个vector.h
文件,在里面定义vector
相关的方法的声明。
void vector_mul(int *x, int *y, int *z, int n);
void vector_add(int *x, int *y, int *z, int n);
在程序main.c
中去进行使用静态链接库
#include <stdio.h>
#include "vector.h"
int main()
{
int x[2] = {1, 3};
int y[2] = {2, 4};
int z[2];
vector_mul(x, y, z, 2);
printf("z[0]=%d, z[1]=%d", z[0], z[1]);
}
使用的方式:
- 1.在C语言中通过
#include
导入头文件。 - 2.接着使用
gcc -c main.c
得到可重定位目标文件main.o
。 - 3.使用
gcc -static -o main main.o ./libvector.a
去进行静态链接,得到可执行文件main
。
4.3.4 链接器的链接流程
- 1.在链接器
ld
运行时,会检查到main.o
中存在有符号vector_mul
。 - 2.链接器从
libvector.a
中复制vector_mul.o
到可执行文件main
中(vector_add.o
不会被复制,因为没用到相关符号)。 - 3.链接器还会从
libc.a
中复制printf.o
模块以及C的运行时所需要的库,包括crt1.o
、crti.o
、crtbeginT.o
、crtend.o
、crtn.o
等一并拷贝到可执行文件main
中。 - 4.将上面这些模块打包在一起,生成可执行文件
main
。
5. 可重定位目标文件和java字节码文件对比
- 1.可重定位目标文件中的
.symtab
其实就是对应java字节码文件的常量池部分的SymbolTable
。 - 2.
.text
用来存放汇编代码,对应于java字节码文件中的Code
部分。 - 3.
.line
在C语言中用来存放行号和汇编代码之间的映射关系,而在java字节码文件中也有这样一个行号表。
网友评论