《程序员的自我修养》阅读笔记
从0开始的程序员修养训练
学长推荐这本书,那就开始看吧.
温故而知新
基本前面都了解过了,稍微讲一下不知道的点.
- 北桥是为了连接高频设备而南桥是连接低频设备的,而以前cpu频率甚至和内存差不多
- 多核CPU是在SMP的基础上发展而来,通过节省宝贵的缓存来降低价格.
- 内存的虚拟化(分页)可以让虚拟内存在每个不同的乃至不连续的页中,这样就不用频繁的io操作了.
- 为了避免冲突,线程之间会有同步与锁(大致了解,并没有研究,以后搞多线程软件可以学学)
- 编译器的过度优化会导致代码换顺序,使得错误,这点目前没有好的解决方式,可以用
volatile
关键字来阻止换到volatile
的后面,就像一个大坝一样.
编译和链接
1.预处理(以下部分是AI所写,不保证正确性)
指令:gcc -E hello.c -o hello.i
或者cpp hello.c > hello.i
预编译过程主要处理那些源代码文件中的以“#”开始的预编译指令。"#define ”等,主要处理规则如下:
- 将所有的 "#define ”删除,并且展开所有的宏定义。
- 处理所有条件预编译指令,比如 "#if ”、“#ifdef ”、"#else ”、 "#endif ”.
- 处理 "#include ”预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件。
- 删除所有的注释“//”和“/**/”。添加行号和文件名标识,比如# 2 “ hello.c ” 2 ,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。
- 保留所有的pragma 编译器指令,因为编译器须要使用它们。经过预编译后的 .i 文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件.
一、包含文件和预处理指令相关部分:
-
# 0 "test.c"
:表示开始处理源文件test.c
。 -
# 0 "<built-in>"
:与编译器的内置信息相关,通常包含编译器内部定义的一些信息和头文件。 -
# 0 "<command-line>"
:与命令行选项相关,可能包含编译时通过命令行传递给编译器的一些参数信息。 -
# 1 "test.c"
:表示当前文件是test.c
,数字1
可能表示文件处理的层级或深度等信息。 -
一系列
# 1 "C:/Myself/Cyber/Language/C++/mingw64/x86_64-w64-mingw32/include/xxx.h" 1 3
:这些是对不同头文件的包含信息,如stdio.h
、corecrt_stdio_config.h
、corecrt.h
等。这些行表明了包含的头文件路径,以及可能的处理信息(1 3
等)。这里是编译器在寻找和处理标准库和相关头文件时的信息,它会递归地将这些头文件包含进来,并将其内容展开到当前文件中。 -
分析
# 2 "test.c" 2
:2 表示 当前的行号 为 2。
"test.c"
表示 当前文件名 是test.c
。2是标志(flag)常见的值有:
1
:表示这个文件是一个新的文件。2
:表示返回到原文件的代码(通常是一个包含的头文件结束时)。
#pragma pack(push,_CRT_PACKING)
:将当前的字节对齐设置压入栈中,并设置为_CRT_PACKING
。这通常用于结构体的字节对齐,以确保在不同平台上结构体的布局一致。
#pragma pack(pop)
:恢复之前的字节对齐设置。
我的理解:这些都是gcc编译器指令.
二、数据类型定义部分:
-
typedef __builtin_va_list __gnuc_va_list;
:将__builtin_va_list
重命名为__gnuc_va_list
,这是为了处理变长参数列表,在 C 语言中,使用va_list
来表示变长参数列表,这里的定义可能是为了适应不同编译器或系统的兼容性。 -
各种
typedef
语句:
typedef size_t rsize_t;
:定义rsize_t
为size_t
的别名,size_t
通常用于表示对象大小。typedef long long intptr_t;
和typedef unsigned long long uintptr_t;
:定义了可存储指针的整数类型,用于将指针转换为整数。typedef long long ptrdiff_t;
:用于表示两个指针相减的结果类型。typedef unsigned short wchar_t;
:定义宽字符类型。typedef unsigned short wint_t;
和typedef unsigned short wctype_t;
:与宽字符相关的类型定义。typedef int errno_t;
:表示错误号的数据类型。typedef long __time32_t;
和__extension__ typedef long long __time64_t;
:定义了不同精度的时间类型。typedef __time64_t time_t;
:将time_t
定义为__time64_t
,用于表示时间。
我的理解:这里就是定义,以及描述其在哪个文件中.可以看到许多有趣的东西,比如指针就是整型.
三、结构体定义部分:
struct _iobuf
:
struct _iobuf {
char *_ptr; // 指向当前缓冲区位置的指针
int _cnt; // 缓冲区中剩余字符的数量
char *_base; // 缓冲区的起始位置
int _flag; // 文件状态标志
int _file; // 文件描述符
int _charbuf; // 单个字符缓冲区
int _bufsiz; // 缓冲区大小
char *_tmpfname; // 临时文件名
};
typedef struct _iobuf FILE;
这是对FILE结构体的定义,它用于表示文件流的信息,包含了文件操作所需的各种信息,例如缓冲区的状态和位置等。
threadlocinfo
结构体:
typedef struct threadlocaleinfostruct {
int refcount;
unsigned int lc_codepage;
unsigned int lc_collate_cp;
unsigned long lc_handle[6];
LC_ID lc_id[6];
struct {
char *locale;
wchar_t *wlocale;
int *refcount;
int *wrefcount;
} lc_category[6];
int lc_clike;
int mb_cur_max;
int *lconv_intl_refcount;
int *lconv_num_refcount;
int *lconv_mon_refcount;
struct lconv *lconv;
int *ctype1_refcount;
unsigned short *ctype1;
const unsigned short *pctype;
const unsigned char *pclmap;
const unsigned char *pcumap;
struct __lc_time_data *lc_time_curr;
} threadlocinfo;
这个结构体与线程的本地化信息有关,包含了代码页、区域设置、分类表等信息。
四、函数定义,声明,全局变量
接下来有些函数定义和全局变量就不一一赘述了,讲些重点:
-
输入输出格式化函数
-
__debugbreak()
函数:extern __inline__ __attribute__((__always_inline__,__gnu_inline__)) void __attribute__((__cdecl__)) __debugbreak(void) { __asm__ __volatile__("int {$}3":); }
这个函数使用内联汇编代码
int 3
,来触发调试器断点,用于调试目的 -
__fastfail()
函数:extern __inline__ __attribute__((__always_inline__,__gnu_inline__)) void __attribute__((__cdecl__)) __attribute__ ((__noreturn__)) __fastfail(unsigned int code) { __asm__ __volatile__("int {$}0x29"::"c"(code)); __builtin_unreachable(); }
可能用于快速失败,通过内联汇编代码
int 0x29
来执行快速失败操作,并标记为__noreturn__
表示不会返回。
五、我们的源代码
int main()
{
printf("Hello World\n");
return 0;
}
这些加起来达到了恐怖的一千行.
2 .编译
指令:gcc -S hello.i -o hello.s
或者cc1 hello.c
(这是一个把预处理和编译合并的程序,但是这里没有写它的绝对路径,事实上还要写的,对于不同的语言这个东西是不同的,比如cpp就是cc1plus)或者gcc -S hello.c -o hello.s
代码如下:
.file "test.c"
.text
.def printf; .scl 3; .type 32; .endef
.seh_proc printf
printf:
pushq %rbp
.seh_pushreg %rbp
pushq %rbx
.seh_pushreg %rbx
subq $56, %rsp
.seh_stackalloc 56
leaq 48(%rsp), %rbp
.seh_setframe %rbp, 48
.seh_endprologue
movq %rcx, 32(%rbp)
movq %rdx, 40(%rbp)
movq %r8, 48(%rbp)
movq %r9, 56(%rbp)
leaq 40(%rbp), %rax
movq %rax, -16(%rbp)
movq -16(%rbp), %rbx
movl $1, %ecx
movq __imp___acrt_iob_func(%rip), %rax
call *%rax
movq %rax, %rcx
movq 32(%rbp), %rax
movq %rbx, %r8
movq %rax, %rdx
call __mingw_vfprintf
movl %eax, -4(%rbp)
movl -4(%rbp), %eax
addq $56, %rsp
popq %rbx
popq %rbp
ret
.seh_endproc
.section .rdata,"dr"
.LC0:
.ascii "Hello World\12\0"
.text
.globl main
.def main; .scl 2; .type 32; .endef
.seh_proc main
main:
pushq %rbp
.seh_pushreg %rbp
movq %rsp, %rbp
.seh_setframe %rbp, 0
subq $32, %rsp
.seh_stackalloc 32
.seh_endprologue
call __main
leaq .LC0(%rip), %rax
movq %rax, %rcx
call printf
movl $0, %eax
addq $32, %rsp
popq %rbp
ret
.seh_endproc
.def __main; .scl 2; .type 32; .endef
.ident "GCC: (x86_64-posix-seh-rev0, Built by MinGW-Builds project) 14.2.0"
.def __mingw_vfprintf; .scl 2; .type 32; .endef
一、词法分析
使用扫描器(Scanner)运用类似于有限状态机(Finite State Machine)的算法将代码分割为记号(Token),具体就是把相应不同的符号分出来,像变量名字,运算符等等.有一个叫做lex
的程序可以进行,它只需要你把词法规则输入进去即可.
二、语法分析
使用语法分析器继续,产生语法树,整个过程用的是上下文无关语法以及下推自动机.(根本看不懂)简单来说,就是生成了一个表达式为节点的树.也有个程序如lex
一样,叫yacc
,只需把规则输入即可.
三、语义分析
进行静态语义分析,比如是否除0之类的,然后会赋予类型和隐式转化.
四、中间语言生成和优化
源码级优化器会将语法树转化成中间代码(一种很接近目标代码的形式),常见的有三地址码和P-代码.这样方便优化.
以上都是编译器前端的事,因为后续机器就不一样了.
五、目标语言生成和优化
编译器后端主要包括代码生成器和目标代码优化器.比如选择合适的寻址方式,使用位移来代替乘法运算,删除多余指令.(有个有意思的点,由于高级语言本身复杂的特性,以至于目前没有一个编译器在这里可以完全满足他们的特性,这也导致了优化特别的复杂).但是在这一步,定义在其他文件的变量地址并不知道.
PS:还记得原本预编译的一堆东西吗?在这几步就被剔除了.
3.汇编
转为字节码,只是简单的一一对应,调用汇编器as
即可
指令:as hello.s -o hello.o
或者gcc -c hello.s -o hello.o
或者gcc -c hello.c -o hello.s
4.链接
过去的程序员要把纸带连接一起还要手动算地址,非常繁琐.但是我们现在有ld
.链接本质上就是做这样的事:地址和空间分配,符号决议(决议更多指的是静态,绑定指的是动态),重定位(等于打补丁).这种符号和地址分离的模式也让符号推广了起来(中间层万岁!!!)
总之这一步就把目标文件(.o object)和库(最常见的是运行时库)链接一起.
目标文件有什么?
讲了COPP,ELF,EXE之间的关系.然后是什么文件采取这样的格式.
在linux下使用file命令可以查看其类型,就是pwn的那个file.
做个小实验
先gcc -c test.c
然后obdjump -h test.o
(h是基本信息打印出来的flag)
得
CONTENTS
表示存在的属性.,其他的不多介绍.
可以用size test.o
来打印elf段的长度:
objdump的-d参数可以把指令反汇编,-s可以将段的内容以十六进制的方式打印出来.
各个段
(来自humb1e学长的博客程序员的自我修养(1-3章) - Welcome to my blog):
代码段
呃 好像没啥好说的
机器码 反编译一下就是汇编代码
数据段和只读数据段
.data段保存的是初始化了的全局静态变量和局部静态变量
比如说printf(“%d\n”)这里有一个只读字符串常量"%d\n"这个字符串常量就会被放到.rodata段
程序中的只读常量都会被放到.rodata段,比如说用const修饰的常量和字符串常量
单设一个只读段方便了内存的映射,可以直接把.rodata段属性改为只读,保障了程序的安全性.
另外,有些嵌入式平台的有些存储区是采用只读存储器的,这样就直接把.rodata段写进去就行了
BSS段
static int x1=0;
static int x2=1;
相同的类型但是x1会被放在.bss段,x2被放在.data段
因为x1的值是0,相当于未初始化,这样直接放在.bss段可以节省磁盘空间,这是一个优化的操作
其他段
.init,.fini
.rodata (read only data,相当于.rodata)
.comment(编译器版本信息)
.note(额外的编译器信息,比如程序的公司发行名,发布版本等)
.debug(调试信息)
.line(调试时的行号表,即源代码和编译后指令的对应表)
.plt ,.got
.dynamic(动态连接信息)
.hash(符号哈希表)
.strtab(string table 字符串表,存储elf文件中用到的各种字符串)
.symtab(symbol table 符号表)
.shastrtab(section table段名表)
添加段
以.
作为前缀说明这些表的名字是系统保留的
我们可以在里面插入一些自己定义的段比如music但是不能加.
作为前缀
我们就可以在一个段插入一个mp3然后读取播放.
自定义段
为了满足某种需求,gcc加了一个扩展功能
__attribute__((section("FOO"))) int global =42;
__attribute__((section("FOO"))) void foo(){}
在函数和变量前面加上__attribute__((section("name")))
就可以添加至相应位置.
ELF
文件头
文件头中定义了ELF魔数,文件机器字节长度,数据存储方式,版本,运行平台,ABI版本,ELF重定位结构,硬件平台,硬件平台版本,入口地址,程序入口和长度,段表的位置以及段的数量.
这些数值中有关描述 ELF目标平台的部分,与我们常见的 32 位 Intel 的硬件平台基本上一样。ELF 文件头结构及相关常数被定义在 "/usr/include/elf.h ”里,因为 ELF 文件在各种平台下都通用, ELF 文件有 32 位版本和 64 位版本。
为了保证字节长一样里面相关定义:
typedef uint16_t Elf32_Half;
typedef uint16_t Elf64_Half;
/* Types for signed and unsigned 32-bit quantities. */
typedef uint32_t Elf32_Word;
typedef int32_t Elf32_Sword;
typedef uint32_t Elf64_Word;
typedef int32_t Elf64_Sword;
/* Types for signed and unsigned 64-bit quantities. */
typedef uint64_t Elf32_Xword;
typedef int64_t Elf32_Sxword;
typedef uint64_t Elf64_Xword;
typedef int64_t Elf64_Sxword;
/* Type of addresses. */
typedef uint32_t Elf32_Addr;
typedef uint64_t Elf64_Addr;
/* Type of file offsets. */
typedef uint32_t Elf32_Off;
typedef uint64_t Elf64_Off;
/* Type for section indices, which are 16-bit quantities. */
typedef uint16_t Elf32_Section;
typedef uint16_t Elf64_Section;
/* Type for version symbol information. */
typedef Elf32_Half Elf32_Versym;
typedef Elf64_Half Elf64_Versym;
而文件头相关定义则为:
/* The ELF file header. This appears at the start of every ELF file. */
#define EI_NIDENT (16)
typedef struct
{
unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
Elf32_Half e_type; /* Object file type */
Elf32_Half e_machine; /* Architecture */
Elf32_Word e_version; /* Object file version */
Elf32_Addr e_entry; /* Entry point virtual address */
Elf32_Off e_phoff; /* Program header table file offset */
Elf32_Off e_shoff; /* Section header table file offset */
Elf32_Word e_flags; /* Processor-specific flags */
Elf32_Half e_ehsize; /* ELF header size in bytes */
Elf32_Half e_phentsize; /* Program header table entry size */
Elf32_Half e_phnum; /* Program header table entry count */
Elf32_Half e_shentsize; /* Section header table entry size */
Elf32_Half e_shnum; /* Section header table entry count */
Elf32_Half e_shstrndx; /* Section header string table index */
} Elf32_Ehdr;
typedef struct
{
unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
Elf64_Half e_type; /* Object file type */
Elf64_Half e_machine; /* Architecture */
Elf64_Word e_version; /* Object file version */
Elf64_Addr e_entry; /* Entry point virtual address */
Elf64_Off e_phoff; /* Program header table file offset */
Elf64_Off e_shoff; /* Section header table file offset */
Elf64_Word e_flags; /* Processor-specific flags */
Elf64_Half e_ehsize; /* ELF header size in bytes */
Elf64_Half e_phentsize; /* Program header table entry size */
Elf64_Half e_phnum; /* Program header table entry count */
Elf64_Half e_shentsize; /* Section header table entry size */
Elf64_Half e_shnum; /* Section header table entry count */
Elf64_Half e_shstrndx; /* Section header string table index */
} Elf64_Ehdr;
ELF魔数(magic)
16个字节对应着elf文件的平台属性比如字长,字节序,文件版本
前四个字节是所有elf文件都相同的标识码第一个字节对应ascii字符里的DEL控制符,后面三个就是ELF的ASCII码,是7F454C46.
操作系统在加载文件时会检查魔数,如果魔数不正确就会拒绝加载
接下来第一个字节是用来标志ELF文件类的0x01表示32位0x02是64位
第6个是字节序表示大小端序
第7个事ELF文件的主版本号,但是由于1.2不更新所以为1.
后面9个没有定义,有些厂商会加上自己的自定义的东西.
段表(以32位为例)
可以使用readelf -S hello.o
查看段表,段表位置位于由文件头的"e_shoff"中,段表中每个段的关系都是用一个ELF32_Shdr
结构体来保存,他们合并成一个数组.所以这个结构体又叫做段描述符
typedef struct
{
Elf32_Word sh_name; /* Section name (string tbl index) */
Elf32_Word sh_type; /* Section type */
Elf32_Word sh_flags; /* Section flags */
Elf32_Addr sh_addr; /* Section virtual addr at execution */
Elf32_Off sh_offset; /* Section file offset */
Elf32_Word sh_size; /* Section size in bytes */
Elf32_Word sh_link; /* Link to another section */
Elf32_Word sh_info; /* Additional section information */
Elf32_Word sh_addralign; /* Section alignment */
Elf32_Word sh_entsize; /* Entry size if section holds table */
} Elf32_Shdr;
ps:段名字对于编译器和链接器来说是重要的,但是对于操作系统没啥用,因为人家只看权限.
然后有些类型和标志位,有相应的define,这个就不记下来了,有需要可以用SHT为关键词去elf.h中搜索
重定位表和字符串表
.rel.text是重定位表,用于存储相关信息.
字符串表则为.strtab(字符串表)或者.shstrtab(段表字符串表),顾名思义.
我们回到elf头文件,发现一个e_shstrndx的成员,它表示的就是段表字符串表在段表中的下标.
符号
有全局符号,外部符号,段名,局部符号,行号信息,这些都可以顾名思义,我们主要是关注全局符号和外部符号,链接器也只关注他们.
看符号表用readelf,objdump,nm都可以,nm xxx.o
$nm hello.o
0000000000000000 T func1
0000000000000000 D global_init_var
0000000000000000 B global_uninit_var
0000000000000027 T main
U printf
0000000000000004 d static_var.1
0000000000000004 b static_var2.0
符号表也是文件的一个段,一般叫做.symtab
.他的结构简单,是一个Elf32_Sym结构的数组.
typedef struct
{
Elf32_Word st_name; /* Symbol name (string tbl index) */
Elf32_Addr st_value; /* Symbol value ,用来存储函数或者变量的地址,某些情况是符号在段上的偏移*/
Elf32_Word st_size; /* Symbol size */
unsigned char st_info; /* Symbol type and binding */
unsigned char st_other; /* Symbol visibility */
Elf32_Section st_shndx; /* Section index */
} Elf32_Sym;
可以用readelf -s hello.o
(md,怎么还是s,前面打成大写了)
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.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3 .data
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4 .bss
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5 .rodata
6: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 static_var.1
7: 0000000000000004 4 OBJECT LOCAL DEFAULT 4 static_var2.0
8: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 global_init_var
9: 0000000000000000 4 OBJECT GLOBAL DEFAULT 4 global_uninit_var
10: 0000000000000000 39 FUNC GLOBAL DEFAULT 1 func1
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
12: 0000000000000027 53 FUNC GLOBAL DEFAULT 1 main
符号修饰与函数签名
前置知识我们可以知道.符号名是不可以重复的,这样就会出现一个问题:假如我们定义了一个库函数里面有main函数,这样链接的话我们的C语言程序里就不能有main函数.这样显然是不合理的,所以所有C语言库函数在编译后都会在变量和函数前加上_
,同样的Fortran语言会在前后都加上_
但是这样也无法解决问题,因为库的数量一多仍然会导致重复,所以人们就发明了命名空间.(C++)
(随着时间的推移,很多操作系统和编译器都被重写了汇编库的冲突问题已经不那么明显了,所以Linux下GCC编译器已经默认取消了下划线,但是Windows下的编译器还是保留了下划线)
还有一个问题,对于不同类型,不同类,不同命名空间,都是可以有同样的名字的函数的,那该怎么办.
还记得c++逆向中函数左边的奇奇怪怪的东西,那就是解决办法,那个叫做名称修饰(为什么不直接:😃
extern
修饰符可以让代码变为C语言的代码.
调试信息
GCC编译时加上-g就可以在目标文件里找到调试信息了,而一般开发调试信息甚至会比源程序大个几倍,linux上可以用strip xxx
来去掉.
静态链接
空间与地址分配
为了节省资源,链接会把同种类型的段合并成一个段(对齐需求和BSS不占可执行文件空间)
链接步骤:1.收集所用信息,放在一个全局符号表 2.计算,以重定位为主.
命令ld a.o b.o -e main -0 ab
-e main表示main作为程序入口,ld连接器默认程序入口位_start
我们再用objdump看一下文件.
注意:VMA是虚拟地址的意思,LMA是加载地址,一般两个是相同的,除了部分嵌入式设备.
重定位
在定位之前,无法得知地址的函数会指向当前位置,而变量地址则是以0来填充.
这时候就需要重定位表了,利用命令objdump -r a.o
可查看需要重定位的地方里面会有在代码段或者数据段的偏移和符号.
typedef struct
{
Elf32_Addr r_offset; /* Address */
Elf32_Word r_info; /* Relocation type and symbol index ,定义类型,静态或者动态*/
} Elf32_Rel;
重定位表的结构,每一个元素定义一个入口.
注意,重定位计算时,会考虑取址方式,进行相应计算.
COMMON块
遇到同名不同类型符号(强弱符号),链接器需要处理,目前的编译器和链接器都支持一种叫COMMON块的机制.比如遇到一个int和double,就会以最大的为准(从F语言继承下来的传统)
C++问题(由此可见C++有点过于复杂以至于要专门讲一下)
由于C++会产生大量重复代码,故而会把这些模板的实例代码放在一个段里,每个段包含一个模板实例,链接时候再并入代码段,丢弃掉重复的.
同样的现在的目标文件非常庞大,有许多没有意义的函数,所以VISUAL C++提供了一个编译选项叫做函数级别链接,作用与之前一样,用到哪个函数用哪个函数,没用到就抛弃,GCC也提供类似机制-ffunction-sections
和-fdata-sections
对函数和数据.
为了实现C++的全局构造和折析函数,ELF文件中定义了两个段,.init
.fini
一个是保存初始化代码,用于Glibc的初始化;一个是终止指令,main退出后,Glibc会执行这个段的代码.
静态链接库
静态链接库可以看成一堆目标文件(其中一个文件就是一个函数)的打包,可以用ar -x libc.a
解压出来,但是注意里面有错综复杂的调用关系,所以连接器会自动算好帮我们连接上必要的文件(😭它真的,我哭死)
题外话,如果用-verbose
打印出来链接过程的话,会发现有一个collect2,其实就是ld的包装.
连接过程控制
改变连接器方法有三中,一种是传参,一种是是在目标文件中放指令,一种是自己写脚本.
默认脚本放在/usr/lib/ldscripts/,想让它使用我们的脚本可以用ld -T link.script
具体的脚本规则就不写了,没啥意义.
动态链接(注意区分共享对象和可执行文件z)
因为静态链接库过于大,加上更新时要重新链接,很麻烦.所以动态链接出现了.
linux中.so
,windows上是.dll
我们可以用gcc -shared -o Lib.so Lib.c
把一个代码编译成共享库.
为了确认是动态链接符号还是静态链接符号,必须把共享库当作链接输入的一环,来获取相应的符号表.
我们可以用read -l Lib.so
查看它 的属性,可以发现和普通程序一样,
地址无关代码
介绍
装载时重定位是解决动态模块中有绝对地址引用的办法之一,但是它有一个很大的缺点是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。我们还需要有一种更好的方法解决共享对象指令中对绝对地址的重定位问题。其实我们的目的很简单,希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PIC, Position-independent Code)的技术。可以在gcc用-fPIC的参数.
对于相同模块,可以利用相对寻址(用一个函数来获得当前的PC地址)来获得相应的绝对地址.
对于不同模块,会用到一个叫做全局偏移表(GOT),一个指向这些变量的指针数组.比如我们要访问变量b,程序会找到GOT,根据GOT变量中指向的地址(链接器在装载模块时会填充),这样就可以通过GOT的偏移来寻址了.函数也一样.
可以用objdump -h pic.so
来看到GOT的位置
-fpic和-fPIC
使用GCC产生地址无关代码很简单,我们只需要使用“-fPIC”参数即可。实际上GCC还提供了另外一个类似的参数叫做“-fpic”,即“PIC”3个字母小写,这两个参数从功能上来讲完全一样,都是指示GCC产生地址无关代码。唯一的区别是,“-fPIC”产生的代码要大,而“-fpic”产生的代码相 对较小,而且较快。那么我们为什么不使用“-fpic”而要使用“-fPIC”呢?原因是,由于地址无关代码都是跟硬件平台相关的,不同的平台有着不 同的实现,“-fpic”在某些平台上会有一些限制,比如全局符号的数量或者代码的长度等,而“-fPIC”则没有这样的限制。所以为了方便起见,绝 大部分情况下我们都使用“-fPIC”参数来产生地址无关代码。
如何区分一个DSO是否为PIC
readelf –d foo.so | grep TEXTREL
如果上面的命令有任何输出,那么foo.so就不是PIC的,否则就是PIC的。PIC的DSO是不会包含任何代码段重定位表的,TEXTREL表示代码段 重定位表地址。
PIC与PIE
地址无关代码技术除了可以用在共享对象上面,它也可以用于可执行文件,一个以地址无关方式编译的可执行文件被称作地址无关可执行文 件(PIE, Position-Independent Executable)。与GCC的“-fPIC”和“-fpic”参数类似,产生PIE的参数为“-fPIE”或“-fpie”
共享模块的全局变量问题
有一种很特殊的情况是,当一个模块引用了一个定义在共享对象的全局变量的时候,比如一个共享对象定义了一个全局变量global,而模块 module.c中是这么引用的:
extern int global;
int foo(){
global = 1;
}
当编译器编译module.c时,它无法根据这个上下文判断global是定义在同一个模块的的其他目标文件还是定义在另外一个共享对象之中,即无法判断是否为跨模块间的调用。
假设module.c是程序可执行文件的一部分,那么在这种情况下,由于程序主模块的代码并不是地址无关代码,也就是说代码不会使用这种类似于PIC的机制,它引用这个全局变量的方式跟普通数据访问方式一样,编译器会产生这样的代码:
movl $0x1,XXXXXXXX
XXXXXXXX就是global的地址。由于可执行文件在运行时并不进行代码重定位,所以变量的地址必须在链接过程中确定下来。为了能够使得链接过程正常进行,链接器会在创建可执行文件时,在它的“.bss”段创建一个global变量的副本(合并后创造一个位置给它)。那么问题就很明显了,现在global变量定义在原先的共享对象中,而在可执行文件的“.bss”段还有一个副本。如果同一个变量同时存在于多个位置中,这在程序实际运行过程中肯定是不可行的。
于是解决的办法只有一个,那就是所有的使用这个变量的指令都指向位于可执行文件中的那个副本。ELF共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,也就是说当作前面的类型四,通过GOT来实现变量的访问。当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把GOT中的相应地址指向该副本,这样该变量在运行时实际上最终就只有一 个实例。如果变量在共享模块中被初始化,那么动态链接器还需要将该初始化值复制到程序主模块中的变量副本;如果该全局变量在程序主 模块中没有副本,那么GOT中的相应地址就指向模块内部的该变量副本。
假设module.c是一个共享对象的一部分,那么GCC编译器在-fPIC的情况下,就会把对global的调用按照跨模块模式产生代码。原因也很简单: 编译器无法确定对global的引用是跨模块的还是模块内部的。即使是模块内部的,即模块内部的全局变量的引用,按照上面的结论,还是会产 生跨模块代码,因为global可能被可执行文件引用,从而使得共享模块中对global的引用要执行可执行文件中的global副本。
Q&A
Q: 如果一个共享对象lib.so中定义了一个全局变量G,而进程A和进程B都使用了lib.so,那么当进程A改变这个全局变量G的值时,进程B中的G 会受到影响吗?
A: 不会。因为当lib.so被两个进程加载时,它的数据段部分在每个进程中都有独立的副本,从这个角度看,共享对象中的全局变量实际上和定 义在程序内部的全局变量没什么区别。任何一个进程访问的只是自己的那个副本,而不会影响其他进程。那么,如果我们把这个问题的条件 改成同一个进程中的线程A和线程B,它们是否看得到对方对lib.so中的全局变量G的修改呢?对于同一个进程的两个线程来说,它们访问的是 同一个进程地址空间,也就是同一个lib.so的副本,所以它们对G的修改,对方都是看得到的。
数据段地址无关性
通过上面的方法,我们能够保证共享对象中的代码部分地址无关,但是数据部分是不是也有绝对地址引用的问题呢?
让我们来看看这样一段代码: static int a; static int* p = &a;
如果某个共享对象里面有这样一段代码的话,那么指针p的地址就是一个绝对地址,对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表,这个重定位表里面包含了“R_386_RELATIVE”类型的重定位入口,用于解决上述问题。当动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态链接器就会对该共享对象进行重定位。 实际上,我们甚至可以让代码段也使用这种装载时重定位的方法,而不使用地址无关代码。从前面的例子中我们看到,我们在编译共享对象时使用了“-fPIC”参数,这个参数表示产生地址无关的代码段。如果我们不使用这个参数来产生共享对象又会怎么样呢?
$gcc –shared pic.c –o pic.so
上面这个命令就会产生一个不使用地址无关代码而使用装载时重定位的共享对象。但正如我们前面分析过的一样,如果代码不是地址无关的,它就不能被多个进程之间共享,于是也就失去了节省内存的优点。但是装载时重定位的共享对象的运行速度要比使用地址无关代码的共享对象快,因为它省去了地址无关代码中每次访问全局数据和函数时需要做一次计算当前地址以及间接地址寻址的过程。 对于可执行文件来说,默认情况下,如果可执行文件是动态链接的,那么GCC会使用PIC的方法来产生可执行文件的代码段部分,以便于不同的进程能够共享代码段,节省内存。所以我们可以看到,动态链接的可执行文件中存在“.got”这样的段。
PLT
动态链接时牺牲了一部分性能为代价的.大概百分之1到5左右.
为此出现了一个做法,延迟绑定.就是函数第一次被使用才进行绑定,没用到就不绑定,这样可以极快地增加启动速度.
调用函数会先经过PLT:
bar@plt:
jmp *(bar@GOT)
push n
push moduleID
jump _dl_runtime_resolve
plt先跳转,而第二条命令push n
的地址在连接器初始的时候就写入bar@GOT中,所以执行第二部.n就是bar这个符号在.rel.plt中的下标.接着把模块id压入堆栈,等于传了两个参数,然后调用动态链接器的这个函数把真正的地址写道GOT中.
上面我们描述的是PLT的基本原理,PLT真正的实现要比它的结构稍微复杂一些(见表7-9)。ELF将GOT拆分成了两个表叫 做“.got”和“.got.plt”。其中“.got”用来保存全局变量引用的地址,“.got.plt”用来保存函数引用的地址,也就是说,所有对于外部函数的引用全部被分离出来放到了“.got.plt”中。另外“.got.plt”还有一个特殊的地方是它的前三项是有特殊意义的,分别含义如下: 第一项保存的是“.dynamic”段的地址,这个段描述了本模块动态链接相关的信息,我们在后面还会介绍“.dynamic”段。
第二项保存的是本模块的ID。
第三项保存的是 _dl_runtime_resolve() 的地址。
其中第二项和第三项由动态链接器在装载共享模块的时候负责将它们初始化。“.got.plt”的其余项分别对应每个外部函数的引用。PLT的结构也与我们示例中的PLT稍有不同,为了减少代码的重复,ELF把上面例子中的最后两条指令放到PLT中的第一项。并且规定每一项的长度是16个 字节,刚好用来存放3条指令,实际的PLT基本结构如图7-9所示。
动态链接相关结构
.interp
动态链接器的位置其实是由ELF决定的,用objdump -s a.out
可查看.interp(interpreter解释器)里面就存放着一个字符串,就是可执行文件所需要动态链接器的路径.在linux下这个链接一般是软链接,会指向目前使用的,保证不需要修改ELF文件.
.dynamic
存放动态链接的各个信息(包括许多表)
.dynsym
保存与动态链接相关的符号,.symtal是都保存.
他的字符串表叫做.dynstr,为了加快查找速度,还有符号哈希表复制.hash,可以用readelf -sD Lib.so
来查看相关的表
.rel.dyn和.rel.plt
相当于.rel.text和.rel.data
进程初始化的堆栈信息
动态链接器需要信息,故而初始化时,操作系统会把相关的信息放在堆栈上供它使用
装载相关函数和过程
不写,没这必要
linux共享库
大概原理就是版本号管理+软链接
SO-NAME 是指主版本号,利用这个软链接来保持更新.
然后会有相关符号来确定版本号,比如
VERS_1.2 {
global:
foo;
local:
*;
}
环境变量
LD_LIBRARY_PATH 先寻找的路径
LD_PRELOAD 实现直接装载库
LD_DEBUG 可以设置这个环境变量来获取信息
安装
linux下动态链接库安装是靠ldconfig
软件更新软链接,我们也可以用这个建立SO-NAME
DLL
DLL是通过导入表和导出表(符号名,序号,地址三个对应)来实现函数的传递,以实现模块化和向后兼容性.
__declspec(dllimport)
显式地声明是导入符号,__declspec(dllexport)
是导出符号
可以用.DEF文件重定向一个导出表的函数指向另一个dll的函数.
IAT(导入地址数组),在最开始还没初始化存放符号,初始化后存放地址,对于32位PE来说这个区别是最高位是否有被置1.
微软是代码地址有关的,使用装载时重定位的方式来重定位,也就是说通过基地址的偏移量来改变绝对地址(微软的动态链接器是属于内核,故而能修改代码段的权限)不过由于这种方式,每个进程都会拥有dll代码段的副本,但是也相较于PIC更快(理论)
可以明白,系统dll是装载在固定位置的(0x70000000-0x80000000),那么如此就不需要重定位了,可以直接使用绝对地址,从而提升速度.
同理,如果装载顺序一致,那么位置相对是固定的,因为程序装载是有固定规则,那么我们可以用editbin这个工具直接dll绑定,而且连接器会校对dll时间戳和校验和(在PE的导入表)出错了还是会正常重定位,所以绑定至少没有坏处.
由于过去的dll管理混乱,经常出现dll hell的现象,但是现在使用了一个manifest文件,操作系统会根据这个获取相应dll列表,然后再根据dll的manifest文件寻找对应dll并调用.
内存(较为熟悉,故而简略点写)
有趣的是,GCC编译器有个参数叫做
-fomit-frame-pointer
可以取消帧指针,但是这必然降低速度,且更难理解函数的调用轨迹.只不过多了个ebp,所以搞这个干什么?
用个我不太熟悉的知识点,就是在保存完寄存器后,多出来的栈帧会被0xCCCCCCCCh填充.也就是烫烫.
当然这不一定,毕竟现代编译器优化太强了,如果不会影响链接或者被声明static那么极有可能直接优化为一段代码了.
一个好玩的知识,windows下的函数调用前有可能插入无异议的指令,比如
mov edi,edi
那么这个部分我们大可以拿来做hook
调用惯例
返回值惯例
正常用eax,如果太大再加上edx(高四字节)
超过8字节的话,例子:
typedef struct big_thing
{
char buf[128];
}big_thing;
big_thing return_test()
{
big_thing b;
b.buf[0] = 0;
return big_thing;
}
int main()
{
big_thing n = return_test();
}
-
main函数在栈中开辟一片临时空间,存放返回值,就相当于定义了一个临时变量 big_thing temp.
-
main函数通过寄存器将该临时变量的首地址传给被调用的函数return_test.
-
retturn_test函数执行完之后,将需要返回值拷贝到main函数开辟好的临时内存 temp中, 并将temp的首地址用eax寄存器传出。
-
main函数将临时变量temp复制给接收返回值的变量n。(memcpy)
这里可以看到复制了两次,所以建议不要使用大的数据作为返回值.
堆
linux
linux有两种系统调用,mmap(),brk()
brk()是设置进程数据段的结束地址来扩大作为堆空间.Glibc里有个封装函数叫做sbrk()功能更多
mmap()就是向操作系统申请一段虚拟地址空间.
一般由运行库申请,然后分配,类似于批发商和零售的概念,这样就可以减少系统调用的次数.
windows
windows由于各种各样的内核内存早就支离破碎了
可以用API VirtualAlloc()来申请
- HeapCreate 创造堆
- HeapAlloc 在堆中分配内存
- HeapFree 释放已经分配的内存
- HeapDestroy 摧毁一个堆
堆并非只是向上增长的,至少你看看Windows.
堆分配算法
一个是链表,但是链表太容易破坏.
另一个是位图,分配相同的快,在头中存放一个数组,来记录快是否使用过.
运行库
入口函数
GLIBC
以静态glibc为例
start调用了main,argc,argv(包括环境变量表),init,fini,rtld_fini(动态加载相关的收尾工作),stack_end表明栈底位置
首先让__environ指针指向紧跟在argv数组之后的环境变量数组.
然后是检查操作系统版本.
过滤掉一些无意义的信息后我们可以看到进行了大量的函数调用.
__pthread_initialize_minimal();
__cxa_atexit(rtld_fini,NULL,NULl); //用于将参数指定的函数在main结束后调用
__libc_init_first(argc,argv,__environ);
(*init)(argc,argv,__environ);
在末尾两行代码:
result = main(argc,argv,__environ);
exit(result);
来看看exit():
void exit (int status){
while (__exit_funcs != NULL){
...
__exit_funcs = __exit_funcs->next; //这个是存储有__cxa_atexit和atexit注册的函数的链表
}
...
_exit (status);
}
_exit有汇编实现,可以看见只是调用exit这个系统调用罢了:
_exit:
movl 4(%esp), %ebx
movl $__NR_exit, %eax
int $0x80
hlt ;检测有无成功,如果失败程序就不会终止,那么它就会强制停止它
CRT
太多了,没找到好的扫描版本,直接截图了.
运行库与IO
下面举一个实际的例子,在 Linux 中,值为 0 、1 、2 的 fd 分别代表标准输入、标准输出和标准错误输出。在程序中打开文件得到的 fd 从 3 开始增长。 fd 具体是什么呢?在内核中,每一个进程都有一个私有的“打开文件表”,这个表是一个指针数组,每一个元素都指向一个内核的打开文件对象。而尾,就是这个表的下标。当用户打开一个文件时,内核会在内部生成一个打开文件对象,并在这个表里找到一个空项,让这一项指向生成的打开文件对象,并返回这一项的下标作为 fd.由于这个表处于内核,并且用户无法访问到,因此用户即使拥有陆也无法得到打开文件对象的地址,只能够通过系统提供的函数来操作。
在 C 语言里,操纵文件的渠道则是 FILE 结构,不难想象, c 语言中的 FILE 结构必定和 fd 有一对一的关系,每个 FILE 结构都会记录自己唯一对应的 fd.
图11-4中,内核指针 p 指向该进程的打开文件表,所以只要有 fd ,就可以用 fd+p来得到打开文件表的某一项地址.stdin 、stdout 、stdrr 均是 FILE 结构的指针。
对于 Windows 中的句柄,与 Linux 中的 fd 人同小异,不过 Windows 的句柄并不是打开文件表的下标,而是其下标经过某种线性变换之后的结果。
在大致了解了I/O为何物之后,我们就能知道I/O初始化的职责是什么了。首先I/O初始化函数需要在用户空间中建立 stdin 、stdout 、stderr 及其对应的 FILE 结构,使得程序进入main之后可以直接使用 pnntf 、scanf 等函数。
CRT的初始化
由于学了没啥意义,就略过(其实是懒得看了)
C/C++运行库
C
C的基础运行库就不多讲了,讲些特殊操作.
-
变长参数 变长参数是C语言的特殊参数形式,例如如下函数声明: int printf(const char* format, ...);
如此的声明表明,printf函数除了第一个参数类型为 const char* 之外,其后可以追加任意数量、任意类型的参数。在函数的实现部分,可以使 用stdarg.h里的多个宏来访问各个额外的参数:假设lastarg是变长参数函数的最后一个具名参数(例如printf里的format),那么在函数内部定义 类型为 va_list 的变量:
va_list ap;
该变量以后将会依次指向各个可变参数。ap必须用宏 va_start 初始化一次,其中 lastarg 必须是函数的最后一个具名的参数。
va_start(ap, lastarg);
此后,可以使用 va_arg 宏来获得下一个不定参数(假设已知其类型为type):
type next = va_arg(ap, type);
在函数结束前,还必须用宏 va_end 来清理现场。在这里我们可以讨论这几个宏的实现细节。在研究这几个宏之前,我们要先了解变长参数的 实现原理。变长参数的实现得益于C语言默认的cdecl调用惯例的自右向左压栈传递方式。设想如下的函数:
int sum(unsigned num, ...);
其语义如下:
第一个参数传递一个整数num,紧接着后面会传递num个整数,返回num个整数的和。 当我们调用:
int n = sum(3, 16, 38, 53);
参数在栈上会形成如图11-7所示的布局。
在函数内部,函数可以使用名称num来访问数字3,但无法使用任何名称访问其他的几个不定参数。但此时由于栈上其他的几个参数实际恰好 依序排列在参数num的高地址方向,因此可以很简单地通过num的地址计算出其他参数的地址。sum函数的实现如下:
int sum(unsigned num, ...){ int* p = &num + 1; int ret = 0; while (num--) ret += *p++; return ret; }
在这里我们可以观察到两个事实:
(1)sum函数获取参数的量仅取决于num参数的值,因此,如果num参数的值不等于实际传递的不定参数的数量,那么sum函数可能取到错误 的或不足的参数。
(2)cdecl调用惯例保证了参数的正确清除。我们知道有些调用惯例(如stdcall)是由被调用方负责清除堆栈的参数,然而,被调用方在这里 其实根本不知道有多少参数被传递进来,所以没有办法清除堆栈。而cdecl恰好是调用方负责清除堆栈,因此没有这个问题。
printf的不定参数比sum要复杂得多,因为printf的参数不仅数量不定,而且类型也不定。所以printf需要在格式字符串中注明参数的类型,例如用 %d 表明是一个整数。printf里的格式字符串如果将类型描述错误,因为不同参数的大小不同,不仅可能导致这个参数的输出错误,还有可能 导致其后的一系列参数错误。
【小实验】 printf的狂乱输出
#include<stdio.h> int main(){ printf("%lf\t%d\t%c\n", 1, 666, 'a'); }
在这个程序里,printf的第一个输出参数是一个int(4字节),而我们告诉printf它是一个double(8字节以上),因此printf的输出会错误,由于 printf在读取double的时候实际造成了越界,因此后面几个参数的输出也会失败。该程序的实际输出为(根据实际编译器和环境可能不同):
0.000000 97
下面让我们来看va_list等宏应该如何实现。
va_list 实际是一个指针,用来指向各个不定参数。由于类型不明,因此这个 va_list 以 void* 或 char* 为最佳选择。
va_start 将 va_list 定义的指针指向函数的最后一个参数后面的位置,这个位置就是第一个不定参数。
va_arg 获取当前不定参数的值,并根据当前不定参数的大小将指针移向下一个参数。
va_end 将指针清0。
按照以上思路,va系列宏的一个最简单的实现就可以得到了,如下所示:
#define va_list char* #define va_start(ap,arg) (ap=(va_list)&arg+sizeof(arg)) #define va_arg(ap,t) (*(t*)((ap+=sizeof(t))-sizeof(t))) #define va_end(ap) (ap=(va_list)0)
【小提示】 变长参数宏
在很多时候我们希望在定义宏的时候也能够像print一样可以使用变长参数,即宏的参数可以是任意个,这个功能可以由编译器的变长参数宏实现。在GCC编译器下,变长参数宏可以使用“##”宏字符串连接操作实现,比如:
#define printf(args…) fprintf(stdout, ##args)
而在MSVC下,我们可以使用__VA_ARGS__这个编译器内置宏,比如:
#define printf(…) fprintf(stdout,__VA_ARGS__)
它的效果与前面的GCC下使用##的效果一样。
-
非局部跳转
即使在C语言里也是一个备受争议的机制。使用非局部跳转,可以实现从一个函数体内向另一个事先登记过的函数体内跳转,而不用担心堆栈混乱。下面让我们来看一个示例:
#include<setjump.h> #include<stdio.h> jmp_buf b; void f(){ longjmp(b, 1); } int main(){ if (setjmp(b)) printf("World!"); else{ printf("Hello "); f(); } }
这段代码按常理不论setjmp返回什么,也只会打印出“Hello ”和“World!”之一,然而事实上的输出是:
Hello World!
实际上,当setjmp正常返回的时候,会返回0,因此会打印出“Hello ”的字样。而longjmp的作用,就是让程序的执行流回到当初setjmp返回的时 刻,并且返回由longjmp指定的返回值(longjmp的参数2),也就是1,自然接着会打印出“World!”并退出。换句话说,longjmp可以让程序“时光倒流”回setjmp返回的时刻,并改变其行为,以至于改变了未来。
是的,这绝对不是结构化编程。
多线程
跳过
fread实现
通过这个实现来了解运行库最为庞大的一部分,IO
以MSVC的fread实现为例.
缓冲
为了少量调用系统调用,一次性把需要发送的发送过去,出现了缓冲的概念(Buffer)
fread_s
fread的工作基本交给fread_s,我们看看它.
可以发现它不过是防止越界,还有加锁,把工作交给了_fread_nolock_s
_fread_nolock_s
后续太复杂了,不看,看完的人都是好样的b( ̄▽ ̄)d
系统调用
原理基本熟悉,不做太多了解.
介绍一下linux不以中断实现的新机制
由于int指令在奔腾4代并不佳,才开发的(该死的intel)
如果使用ldd来获取一个可执行文件的共享库的依赖情况,你会发现一些奇怪的现象:
$ ldd /bin/ls
linux-gate.so.1 => (0xffffe000)
librt.so.1 => /lib/tls/i686/cmov/librt.so.1 (0xb7f7a000)
...
我们可以看到linux-gate.so.1没有与任何实际的文件相对应,那么这个库究竟是做什么的呢?答案正是Linux用于支持新型系统调用的“虚拟”共享库。linux-gate.so.1并不存在实际的文件,它只是操作系统生成的一个虚拟动态共享库(Virtual Dynamic Shared Library,VDSO)。这个库总是被加载在地址0xffffe000的位置上。
其余的不多说,传参依旧是同样的顺序通过,通过sysenter(intel在2代开发的指令)进入.
再介绍一下为什么windows要搞api而不是系统调用.
为了兼容性.
运行库实现
主要是具体地写了一个CRT青春版,等我以后学操作系统了再写吧.
附录
大小端序
大端序一般用在MAC,TCP/IP网络和JAVA虚拟机
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了