做Linux系统编程的,一定会撞上这“三座大山”:进程怎么来的、进程怎么没的、进程怎么“变脸”。标题里这组关键词——进程管理、进程结束、exec函数,说白了就是Linux进程从生到死、从A程序变成B程序的完整故事线。我最初啃这块的时候也绕了不少弯路,总以为fork和exec是孤立的两个API,后来才意识到,它们其实是一条链上的两个环:fork负责开副本,exec负责换内核,合起来才是现代系统里“创建新程序”的标准姿势。
这篇文章按我个人复习和实战习惯来写,先把进程生命周期捋一遍,再把fork、exit、wait、exec这几个函数族从原理到参数到内核行为全部拆开,最后带一个真正能跑起来的迷你shell样例代码,外加一大串我踩过的坑。适合正在刷《Unix环境高级编程》的人、做嵌入式Linux开发的朋友、以及准备系统编程岗位面试的工程师。
1. 进程管理的底层逻辑与生命周期
1.1 进程不是程序,是一个“运行的上下文”
很多教材上来就背定义:进程是程序的一次执行过程。但真正写代码时,你打交道的是一个叫task_struct的内核结构体,也就是进程控制块(PCB)。每个进程在内核里都有这样一个对象,里面装着PID、父进程指针、进程状态、打开的文件描述符表、信号处理函数的指针、内存描述符mm_struct指针、当前工作目录、退出码等等。
这套设计有点像一个公司工牌:门禁卡(PID)、所属部门(父进程)、能进哪些区域(内存映射)、手上有哪些资源(文件描述符)。里面任何字段被改一改,这个进程的行为就变了。
我调试问题时的习惯是,先把task_struct的关键字段在脑子里过一遍。遇到“进程为什么死了”“为什么资源没释放”“为什么卡在某个状态”,基本都能从这几个字段里找到线索。比如进程状态字段,在ps里能看到R、S、D、Z、T,对应TASK_RUNNING、TASK_INTERRUPTIBLE、TASK_UNINTERRUPTIBLE、TASK_ZOMBIE、TASK_STOPPED。其中Z(僵尸)是最容易踩坑的,后面专门讲。
1.2 进程地址空间的内存布局
除了PCB,每个进程还有一个虚拟地址空间,类似“一张虚拟地图”,把所有内存地址映射到物理内存或者磁盘上。这张地图由mm_struct管理,分成几大区段:
- 代码段(text segment):存放编译后的机器指令;
- 数据段(data segment):初始化过的全局变量、静态变量;
- BSS段:未初始化的全局变量和静态变量;
- 堆(heap):动态分配的内存,向上增长;
- 内存映射区(mmap区域):共享库、动态文件映射,一般在这块;
- 栈(stack):局部变量、函数调用栈,向下增长。
我在排查段错误时,最常想到的就是访问了不属于任何区段的地址。用pmap或者查看/proc/<pid>/maps,能很清楚地看到每个进程的空间分布。初学者如果对这块不熟,先把size命令跑一下,看看可执行文件里text、data、bss的大小,再对比进程起来后的虚拟内存布局,印象会深很多。
1.3 fork()的本质:开一个几乎一样的副本
fork的调用很简单:
pid_t pid = fork(); if (pid == 0) { // 子进程代码路径 } else if (pid > 0) { // 父进程代码路径,pid就是子进程的PID } else { // 失败 }但内核在背后干的事情非常多:为子进程创建新的task_struct、复制父进程的地址空间(至少页表先复制一份)、复制文件描述符表、复制信号处理函数设置、复制各种统计信息等。在Linux上,现代实现采用写时拷贝(Copy-On-Write, COW)技术,父进程和子进程先共享同一份物理内存页,只有当其中一个进程真的写入数据时,内核才把对应的页复制一份出来。
所以fork的开销,关键是“建立新页表和复制task_struct”,而不是把内存整体复制一遍。这也是fork能保持低延迟的原因。
我刚学fork时总有一个误区:以为子进程会从fork开始“重头执行”,其实不是。fork返回时,父子进程都停在fork之后的那一行,区别在于返回值不同。子进程拿到的返回值是0,父进程拿到的是子进程PID,这是编码里最关键的“分流信号”。
1.4 父子进程剪不断的那些“遗传”和“独立”
fork出来之后,父子进程共享的是初始打开的文件描述符(比如STDOUT_FILENO),但每个进程有自己的文件描述符表指向同一个内核文件对象,所以文件偏移量是共享的。独立的是地址空间、栈、堆,以及未决的信号集合和各自身份。
我在面试里常看到一个问题:“fork之后,子进程会继承父进程的哪些东西?”答案包括:环境变量、umask值、当前工作目录、控制终端、已打开的文件描述符、信号处理函数(只有可捕获的信号才继承,SIGINT、SIGQUIT等会被重置)等。但不会继承:父进程自己的PID、父进程的锁、未决信号、fork点的栈状态(其实是拷贝出来的)。
记住一个判断原则:凡是记录在task_struct和进程地址空间里的,都会复制;凡是“属于父进程身份”的东西,都不复制。
了解了这些,再去看进程结束和exec,就顺理成章了。
2. 进程结束:退出、退出码与资源回收
2.1 “善终”与“横死”:exit族与信号终止
进程结束有两种方式,一种是主动退出,一种是被信号杀掉。
主动退出最常见的就是调用exit()函数:
#include <stdlib.h> void exit(int status);exit()会先执行atexit注册的清理函数、刷新stdio缓冲区,然后调用_exit()系统调用结束进程。如果不想做这些用户态清理,直接调_exit()或_exit()系统调用(在Linux上最终走exit_group),会更粗暴地直接让内核回收资源。
我在调试Nginx类的常驻程序时发现,很多人把exit和_exit混用,一旦碰到“日志没写完就退出”的情况,多半就是缓冲区没刷新。因为_exit()不执行任何stdio清理,直接飞了。
被信号终止是另一种常见路径。你向进程发送SIGKILL或SIGTERM,进程如果不处理,默认动作就是终止。这里有个重要区别:
SIGTERM可以被捕获,程序可以做清理再退出,是“礼貌地请走”;SIGKILL不能被捕获、不能被忽略,是内核强制终止,连atexit清理都不会执行。
这也是为什么很多守护进程管理脚本先发SIGTERM,等几秒再发SIGKILL的原因。
2.2 退出码:0到255之间的那点门道
进程退出时带一个退出码,父进程通过wait系列函数拿到。主流约定是0表示成功,非0表示各种错误。shell里用echo $?看到的,就是上一条命令的退出码。
但有个细节,退出码范围是0到255。你在代码里写return -1,最终会变成253暴露给父进程,因为负数会被截断。我写过一次return -1的程序,shell里$?显示253,排查半天才想起这茬。方便起见,自定义错误号尽量控制在0到200以内,而且要保证唯一,否则后面对照错误表容易翻车。
还有一个容易忽略的点:如果一个进程是被信号终止的,wait拿到的是“信号编号”而不是退出码。解析时要区分两种情况,例如:
int status; waitpid(pid, &status, 0); if (WIFEXITED(status)) { // 主动退出,退出码是 WEXITSTATUS(status) } else if (WIFSIGNALED(status)) { // 被信号终止,信号编号是 WTERMSIG(status) }2.3 僵尸进程是怎么产生的:没人“收尸”
这是Linux初学者最绕不开的坑。一个子进程退出了,但父进程还没有调用wait或waitpid去读取它的退出状态,此时子进程进入ZOMBIE状态。它的task_struct还留在进程表里,因为内核要保留它的退出信息,等父进程来取。
为什么不直接删除?因为父进程需要知道“孩子是怎么死的”:正常退出吗?退出码是多少?还是被哪个信号干掉的?这些信息就存在僵尸进程的task_struct里。
如果不收尸会怎样?如果父进程一直不wait,僵尸进程会一直占着进程表项。进程表是操作系统的重要资源,占满之后系统就无法创建新进程了,严重时整个系统变卡甚至无法登录。尤其是那种父进程里写了个fork后忘记wait的循环,几万个僵尸进程直接能把系统拖垮。
在命令行里看到ps aux状态列是Z的进程,基本就是僵尸。解决方法也很简单:杀掉它的父进程,让init(或subreaper进程)接管并自动回收;或者找到父进程,让它正确调用wait。
2.4 wait与waitpid:家长该做的“收尸工作”
wait是一个阻塞调用,调用者会一直等任意一个子进程退出:
pid_t wait(int *status);waitpid则是可控性更强的版本:
pid_t waitpid(pid_t pid, int *status, int options);参数设计上:
pid > 0:等待指定PID的子进程;pid == -1:等待任意子进程,等价于wait;pid == 0:等待与调用者同组的所有子进程;pid < -1:等待组ID等于 abs(pid) 的组内子进程。
第三个参数options是选项位,常用的是WNOHANG(非阻塞轮询)和WUNTRACED(获取被停止但还没退出的子进程状态)。我写守护进程的探测脚本时,经常用WNOHANG搭配循环,避免主进程卡死在wait上。
关于状态解析,除了上面提的WIFEXITED、WIFSIGNALED,还有WIFSTOPPED、WIFCONTINUED。这些宏在<sys/wait.h>里,是排查子进程行为异常的第一手工具。我曾经遇到过子进程“没退出却消失”的现象,实际是它被SIGSTOP暂停了,当时没有用WIFSTOPPED判断,走了弯路。
3. exec函数家族:换个马甲继续跑
3.1 exec到底做了什么:不创建新进程,只换程序
经典的误解是“exec会启动一个新的进程”。不完全是。exec系列函数的作用是用一个新的程序镜像替换当前进程的代码段、数据段、堆和栈,但PID、文件描述符表、当前工作目录这些“身份信息”保持不变。
打个比方:你在公司工位上坐得好好的,突然把面前的工作电脑给换了,新电脑上跑着一套完全不同的系统。但你的工牌(PID)、工位(进程表项)、已经连上的打印机(文件描述符)都还在。这就是exec。
因为不创建新进程,所以exec成功后没有返回值——因为原来的程序已经不跑了,新程序接管了执行流,后续代码都没机会执行。只有当exec失败(比如找不到可执行文件、权限不够)时,才会返回-1,你才有机会处理错误。
边界情况:如果你在exec之后还写了代码,而那个代码居然执行了,说明exec失败了。这个判断方式非常实用:
execl("/bin/ls", "ls", "-l", NULL); // 走到这里的概率极低,但是如果到了,说明exec失败了,得检查 errno。 perror("execl failed"); exit(EXIT_FAILURE);3.2 exec的六个“兄弟”:l、v、p、e分别是什么
标准C库提供了六个exec函数:execl、execlp、execle、execv、execvp、execvpe(还有一个真正的系统调用execve)。命名规则其实很有规律:
- 字母l(list):参数以列表形式给出,最后一个参数后面要加
NULL; - 字母v(vector):参数以字符串数组(argv)形式给出,数组最后一个元素也必须是
NULL; - 字母p(path):会自动在
PATH环境变量的目录里搜索可执行文件,不需要写完整路径; - 字母e(environment):允许显式传入新的环境变量数组,而不是继承当前进程的环境变量。
我用一张表总结一下,方便对照查:
| 函数名 | 参数格式 | 是否PATH搜索 | 是否自定义envp |
|---|---|---|---|
| execl | list | 否 | 否 |
| execv | vector | 否 | 否 |
| execlp | list | 是 | 否 |
| execvp | vector | 是 | 否 |
| execle | list | 否 | 是 |
| execvpe | vector | 是 | 是 |
实际项目里我用最多的是execvp:参数好拼,又支持PATH搜索,写shell模拟器时基本都用它。比如调用ls命令,直接写:
char *argv[] = {"ls", "-l", NULL}; execvp("ls", argv);3.3 PATH与环境变量:为什么有时候找不到程序
不带p的exec函数,需要你提供文件完整路径。带p的则会在PATH变量指定的目录列表中逐个查找。
这就有一个坑:如果当前环境里PATH被改了或者清空了,execvp("ls", argv)可能直接返回ENOENT。我在写定时任务脚本时遇到过类似的坑:cron环境下PATH非常精简,含p的exec也找不到命令。
另外,execle和execvpe可以显式传入envp数组。比如你想让新程序看到一组干净的环境变量而不是继承父进程的全部垃圾环境,可以自己拼一个envp。这个功能在写安全的程序拉起了时特别有用。
有一点值得注意:环境变量传递本身不会覆盖新程序里已有的值。新程序接收到的环境变量完全由传入的envp(或继承的environ)决定。所以如果你用execle传了一个空的envp,新程序几乎什么都看不到。
3.4 为什么exec函数成功不返回:任务已被接管
从程序员视角看起来很神奇,“不返回”其实是因为栈、堆、代码段都被换掉了,原来的调用栈帧也被新程序替换了。不是它“不想返回”,而是它已经没有返回的能力了。
但这里有个极其微妙的点:exec后进程的程序入口并不是main函数开头,而是从ELF文件的入口点开始跑,动态链接器会先完成库加载、重定位等动作,再把控制权交给main。所以你能感受到的现象是“原来的程序没了,新程序从头开始跑”。这也意味着,你无法在exec之后“再回来”继续执行原程序的逻辑。
3.5 fork + exec:Linux里创建新进程的“黄金组合”
单独用exec,进程变成了另一个程序;单独用fork,进程多了一个几乎一样的副本。两者一组合,才实现“运行一个全新的程序”:先用fork复制出一个子进程,再在子进程里调用exec加载新程序,父进程则继续执行自己的逻辑。
经典的调用模型:
pid_t pid = fork(); if (pid == 0) { // 子进程:exec替代自己 execvp(argv[0], argv); exit(127); // 如果exec失败,退出码127是shell惯例 } else if (pid > 0) { waitpid(pid, &status, 0); // 父进程等孩子 }这个模型在shell、nginx worker进程创建、cron拉起任务里满地都是。wsl/linux命令行里你看到的每个命令,几乎都是bash把输入解析后,用这个组合拉起来的。
我在写这个组合时,会特别提醒自己两点:第一,fork之后子进程要尽快exec,不要在中间做太多内存操作,避免COW带来的额外开销;第二,exec失败一定要在子进程里exit,否则子进程会变成一只“平行世界的父进程”继续跑原本的命令解析逻辑,出现双重误解之类的问题。
4. 系统调用背后的运行机制
4.1 fork在内核里的真实路径:clone系统调用
glibc里的fork()函数,底层调用的是clone系统调用(在内核里对应do_fork)。clone与fork的关系可以理解成:clone是通用设施,通过flags控制要共享哪些资源,fork只是它的一种简化形态。
do_fork的主要流程:
- 拷贝当前进程的task_struct,分配新的PID;
- 拷贝进程的mm(虚拟内存),或者只拷贝页表并标记为写时拷贝;
- 拷贝文件描述符表、fs_struct(当前目录信息)、signal_struct等;
- 如果设置了
CLONE_THREAD等标志,则复制线程相关的属性; - 把新task_struct挂到运行队列上,唤醒子进程去调度。
写时拷贝为什么是优化关键?因为子进程大概率会立刻exec或者退出,如果先复制全部物理内存,代价极高。所以现代内核只在页表层面做COW,直到真有写入才复制物理页。这个设计在嵌入式Linux的MMU环境下尤其重要,内存紧张时能省下大量物理页。
4.2 execve的加载流程:从路径解析到ELF加载
exec系列函数最终都会聚到execve系统调用上。内核干的大致是这样的活:
- 检查路径是否存在、是否有执行权限,找到目标文件;
- 读取文件头,判断ELF格式;不是ELF的话,尝试交给解释器处理(比如脚本的shebang行);
- 解析ELF的进程头、段头,创建新的mm_struct,把代码段、数据段映射到虚拟地址空间;
- 把新程序的argv和envp放到用户栈上;
- 从ELF入口点开始启动,如果是靠动态链接器跑的程序,先进入动态链接器,完成动态库加载,再跳到真正的程序入口。
这里有一个非常值得注意的坑:exec调用后,原来的内存内容全被替换了,但task_struct里有些字段会保留。比如曾经的打开文件描述符如果不设FD_CLOEXEC的话会继承下来,这就可能导致新程序拿到多余的fd。真正的“干净环境”需要用close-on-exec标记来保证。这也是我为什么不建议在准备exec的子进程里频繁打开临时文件后不清理。
4.3 wait4与TASK_ZOMBIE状态切换
当一个进程调用exit_group主动退出时,内核会把它的基本信息(退出码、被信号杀死的信息等)保存下来,然后把它的状态改成TASK_ZOMBIE,再发送SIGCHLD给父进程。父进程调用wait4等系统调用后,内核才把僵尸进程的task_struct回收。
这整条链路解释了一个常见现象:你用kill -9杀掉一个子进程,它的父进程如果一直没调用wait,你在ps里还是看得到它,状态为Z。杀掉Z进程本身往往是无效的,因为Z进程已经死了,只是信息还在等待提取。唯一的处置路径就是让父进程wait,或者直接把父进程也干掉,让1号进程(或进程树中的subreaper)出来接管。
我在日志服务里见过非常典型的泄露案例:业务代码里有个线程不断fork子进程执行外部工具,但忘了wait。几天后/proc/sys/kernel/threads-max还没到上限,但进程表已经快占满了。排查就是靠ps -eo pid,ppid,state,cmd | grep Z一把抓出来的。
5. 实操:用fork和exec写一个迷你shell
5.1 需求与设计
光看原理容易飘,我建议你亲手做一个小项目把它钉死。这次我来写一个极简的shell:“读一行命令、解析、fork子进程、exec执行、父进程wait”,循环往复。项目文件只有一个myshell.c,不引第三方库,用纯POSIX API。
设计其实非常直白:
readline或fgets读输入;- 用
strtok按空格拆成argv数组; - 判断是不是
cd、exit这类内建命令; - 外部命令:fork + execvp + waitpid。
5.2 完整代码实现
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/wait.h> #include <signal.h> #define CMD_BUF_SIZE 1024 #define MAX_ARGC 64 // 命令拆分:把一行字符串按空白拆成 argv int parse_cmd(char *buf, char *argv[]) { int argc = 0; int in_word = 0; char *p = buf; while (*p) { if (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') { *p = '\0'; in_word = 0; } else { if (!in_word) { argv[argc++] = p; in_word = 1; } } p++; } argv[argc] = NULL; return argc; } int main(int argc, char *argv[]) { char buf[CMD_BUF_SIZE]; while (1) { printf("myshell> "); fflush(stdout); if (!fgets(buf, sizeof(buf), stdin)) { printf("\n"); break; } // 去换行 if (buf[strlen(buf) - 1] == '\n') buf[strlen(buf) - 1] = '\0'; // 空命令直接继续 if (buf[0] == '\0') continue; // 内建命令:exit if (strcmp(buf, "exit") == 0) break; // 内建命令:cd if (strncmp(buf, "cd ", 3) == 0) { chdir(buf + 3); continue; } // 把命令解析为 argv char *cmd_argv[MAX_ARGC]; parse_cmd(buf, cmd_argv); pid_t pid = fork(); if (pid == 0) { // 子进程:执行外部命令 execvp(cmd_argv[0], cmd_argv); perror("execvp failed"); exit(127); } else if (pid > 0) { // 父进程:等子进程结束 int status; waitpid(pid, &status, 0); if (WIFEXITED(status)) printf("[exit:%d]\n", WEXITSTATUS(status)); else if (WIFSIGNALED(status)) printf("[signal:%d]\n", WTERMSIG(status)); } else { perror("fork failed"); } } return 0; }5.3 编译运行与效果演示
编译命令也就一行:
gcc -Wall -O2 -o myshell myshell.c运行后可以看到基本的交互效果:
myshell> ls myshell.c myshell [exit:0] myshell> ps PID TTY TIME CMD 12345 pts/0 00:00:00 bash 12346 pts/0 00:00:00 myshell 12347 pts/0 00:00:00 ps [exit:0] myshell> nonexistent-cmd execvp failed: No such file or directory [exit:127]这基本就是bash“命令执行”部分最核心的骨架。注意看ps输出里那一行:myshell在等ps的时候,ps是它的子进程。整个生命周期一目了然。
5.4 还可以扩展的方向
这个demo不要停在够用就好。想把进程理解得更透,建议再加这几个功能:
- 支持后台执行,命令末尾加
&时,父进程不wait或者用WNOHANG轮询; - 支持管道符
|,把前一个命令的stdout接到后一个命令的stdin,感受文件描述符继承在不同进程间如何流转; - 设置
SIGCHLD的handler,实现在子进程退出时自动收尸;如果能把handler的信号安全写法和阻塞机制搞明白,这块基本就通关了。
我自己扩展了管道版之后,对“文件描述符表是进程间通信最底层的资源”这句话体会深了很多。强烈推荐你也试着写一下。
6. 常见问题与排查技巧实录
6.1 我的子进程怎么变僵尸了?
这是最高频的问题。直接把排查三连背下来:
- 确认状态:
ps aux | grep Z,先看到底有没有僵尸; - 锁定父进程:
ps -o ppid= <zpid>,找到它的爸爸; - 追代码:在父进程的fork分支后是否调用了
wait/waitpid?还是写了个信号handler处理SIGCHLD却没调wait?
如果父进程本身是个常驻服务,最干净的方式是在SIGCHLDhandler里循环waitpid(-1, NULL, WNOHANG),直到返回0或-1,把一批退出子进程一次性收完。别在handler里做太多事,信号安全第一。
6.2 exec提示Text file busy
如果你看到类似Text file busy的错误,多半是有人正在对该可执行文件执行写操作。比如你刚用gcc编译出一个二进制,马上就去exec它,编译器可能还在写这个文件,内核为了安全会拒绝加载。
我自己遇到过这种情况:脚本在每次执行前重新下载某个可执行程序,然后立刻调用exec,结果间歇性出现ETXTBSY。解决方式是先写临时文件,再原子地rename到目标位置,确保文件在exec时是静止的。
6.3 exec老报找不到文件?先看PATH
不带路径的execvp,如果报ENOENT,第一时间检查当前进程的PATH。可以在代码里打印:
printf("PATH=%s\n", getenv("PATH"));我在cron环境里跑带p的exec,最容易踩这个坑。解决办法是在程序初始化时显式setenv("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", 1),或者干脆不用p系列,直接写完整路径。
6.4 权限问题:EACCES不是系统不够快
exec返回EACCES时,很多人第一反应是检查文件权限ll。没错,第一层就是有没有可执行权限。但还有一个容易忽略的原因:文件系统挂载时带了noexec选项,比如某些云主机挂载的/data盘就是不允许执行二进制的,文件自身权限再好也白搭。
写服务的时候,尽量把需要执行的文件放在/usr/local/bin或者/opt这类没限制的地方。如果放在/tmp下跑都经常出怪事。
6.5 fork炸弹:一条命令就能让系统卡死
有一句“经典”命令:
:(){ :|:& };:这串东西本质是无限循环fork自身,每次成功都立刻生成两个新进程,几十秒内就把进程表吃满。在虚拟机上做实验时要特别小心,不要在宿主机的生产环境测试。我建议你先设置ulimit -u限制最大用户进程数,比如:
ulimit -u 2048再看效果。理解原理比真的炸一次重要得多:fork也不是万能的,资源永远是有限的。
6.6 在虚拟机或WSL里遇到的信号与进程状态问题
很多人在WSL或者Vmware里跑Linux学进程管理。有一个小常识:WSL里的进程模型和完整发行版有一点差异,尤其是systemd相关行为。你在练习时看到的某些状态ps列不出来,先不要怀疑代码的问题,先看看是环境差异。比如有些系统上ps -ef不显示Z状态,用ps aux或者top看更方便。
还有一个经验:在虚拟机里跑宽限内核实验时,如果随便改了/proc/sys/kernel/threads-max或者pid_max,容易造成偶发resource temporarily unavailable错误。除非你知道自己在干什么,否则保持默认值。
收尾之前再讲两个习惯
这些函数练到现在,我个人实操中的体会是:写fork+exec组合时,永远不要忘记两个“保险”——第一,子进程exec失败后一定要立刻exit,否则它会以“父进程的化身”继续运行,逻辑必然混乱;第二,父进程无论多忙,都要保证对每个fork出来的子进程都调用wait/waitpid,这是避免僵尸进程的根本纪律。
另外再分享一个小技巧:调试这类代码,不要急着上gdb。用ps -eo pid,ppid,state,cmd加上strace -f -e trace=clone,execve,wait4一起看,能瞬间看清整个进程家族的兴衰。我在touch一个问题进程时,通常是在strace输出里才恍然大悟:“原来exec失败是因为子进程环境里的PATH变了”。
把进程管理、进程结束、exec函数这条线串明白后,再看线程、信号、进程间通信、守护进程、容器实现,地基稳了很多。