一、实验部分:分析Linux内核创建一个新进程的过程。

【第一部分】 根据要求完成第一部分,步骤如下:

1. 首先进入虚拟机,打开终端,这命令行依次敲入以下命令:

cd LinuxKernel   
rm menu -rf           //强制删除
git clone https://github.com/mengning/menu.git   //将menu更新
cd menu    
mv test_fork.c test.c     //更新test.c 
make rootfs   //运行脚本,自动编译和自动生成根文件系统,同时启动,输入fork命令,子进程和父进程都输出

 一切正常的话,这时候我们简易的内核系统就启动起来了,输入help,就可以看到新添加的命令fork,输入fork:

2. gdb上述的fork命令

 关闭QEMU窗口,中命令行中输入:

qemu -kernel linux-3.18.6/arch/x86/boot/bzImage -initrd rootfs.img -s -S

 再次启动MenuOS,并暂停等待gdb调试。然后水平分割命令行窗口,这新窗口中依次输入以下命令,启动调试:

gdb
file linux-3.18.6/vmlinux
target remote:1234

 然后再设置断点:

b sys_clone
b do_fork
b dup_task_struct
b copy_process
b copy_thread
b ret_from_fork

【第二部分】分析代码

(1)fork()函数

 fork()在父、子进程各返回一次。在父进程中返回子进程的 pid,在子进程中返回0。fork一个子进程的代码如下:

int main(int argc, char * argv[])
{
int pid; /* fork another process */
pid = fork(); if (pid < 0)
{
/* error occurred */
fprintf(stderr,"Fork Failed!");
exit(-1);
} else if (pid == 0)
{
/* child process */
printf("This is Child Process!\n");
} else
{
/* parent process */
printf("This is Parent Process!\n");
/* parent will wait for the child to complete*/
wait(NULL);
printf("Child Complete!\n");
}
}

(2)进程创建流程:

 fork 通过0x80中断(系统调用)来陷入内核,由系统提供的相应系统调用来完成进程的创建。PCB包含了一个进程的重要运行信息,所以我们将围绕在创建一个新进程时,如何来建立一个新的PCB的这一个过程来进行分析,在Linux系统中,PCB主要是存储在一个叫做task_struct这一个结构体中,创建新进程仅能通过fork,vfork,clone等系统调用的形式来进行。

//fork
#ifdef __ARCH_WANT_SYS_FORK
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
return do_fork(SIGCHLD, 0, 0, NULL, NULL);
#else
/* can not support in nommu mode */
return -EINVAL;
#endif
}
#endif //vfork
#ifdef __ARCH_WANT_SYS_VFORK
SYSCALL_DEFINE0(vfork)
{
return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0,
0, NULL, NULL);
}
#endif //clone
#ifdef __ARCH_WANT_SYS_CLONE
#ifdef CONFIG_CLONE_BACKWARDS
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
int __user *, parent_tidptr,
int, tls_val,
int __user *, child_tidptr)
#elif defined(CONFIG_CLONE_BACKWARDS2)
SYSCALL_DEFINE5(clone, unsigned long, newsp, unsigned long, clone_flags,
int __user *, parent_tidptr,
int __user *, child_tidptr,
int, tls_val)
#elif defined(CONFIG_CLONE_BACKWARDS3)
SYSCALL_DEFINE6(clone, unsigned long, clone_flags, unsigned long, newsp,
int, stack_size,
int __user *, parent_tidptr,
int __user *, child_tidptr,
int, tls_val)
#else
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
int __user *, parent_tidptr,
int __user *, child_tidptr,
int, tls_val)
#endif
{
return do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr);
}
#endif

 通过看上边的代码,我们可以清楚的看到,不论是使用 fork 还是 vfork 来创建进程,最终都是通过 do_ fork() 方法来实现的。接下来我们可以追踪到 do_ fork()的代码:

long do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr)
{
struct task_struct *p; //进程描述符结构体指针
int trace = 0;
long nr; //总的pid数量 /*
* Determine whether and which event to report to ptracer. When
* called from kernel_thread or CLONE_UNTRACED is explicitly
* requested, no event is reported; otherwise, report if the event
* for the type of forking is enabled.
*/
if (!(clone_flags & CLONE_UNTRACED)) {
if (clone_flags & CLONE_VFORK)
trace = PTRACE_EVENT_VFORK;
else if ((clone_flags & CSIGNAL) != SIGCHLD)
trace = PTRACE_EVENT_CLONE;
else
trace = PTRACE_EVENT_FORK; if (likely(!ptrace_event_enabled(current, trace)))
trace = 0;
} // 复制进程描述符,返回创建的task_struct的指针
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace);
/*
* Do this prior waking up the new thread - the thread pointer
* might get invalid after that point, if the thread exits quickly.
*/
if (!IS_ERR(p)) {
struct completion vfork;
struct pid *pid; trace_sched_process_fork(current, p); // 取出task结构体内的pid
pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid); if (clone_flags & CLONE_PARENT_SETTID)
put_user(nr, parent_tidptr); // 如果使用的是vfork,那么必须采用某种完成机制,确保父进程后运行
if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
get_task_struct(p);
} // 将子进程添加到调度器的队列,使得子进程有机会获得CPU
wake_up_new_task(p); /* forking complete and child started to run, tell ptracer */
if (unlikely(trace))
ptrace_event_pid(trace, pid); // 如果设置了 CLONE_VFORK 则将父进程插入等待队列,并挂起父进程直到子进程释放自己的内存空间
// 保证子进程优先于父进程运行
if (clone_flags & CLONE_VFORK) {
if (!wait_for_vfork_done(p, &vfork))
ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
} put_pid(pid);
} else {
nr = PTR_ERR(p);
}
return nr;
}

 可以看出过程是通过copy_ process来复制进程描述符,返回新创建的子进程的task_ struct的指针(即PCB指针),将新创建的子进程放入调度器的队列中,让其有机会获得CPU,并且要确保子进程要先于父进程运行。copy_process函数如下:

/*
创建进程描述符以及子进程所需要的其他所有数据结构
为子进程准备运行环境
*/
static struct task_struct *copy_process(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *child_tidptr,
struct pid *pid,
int trace)
{
...
int retval;
struct task_struct *p; ...
// 分配一个新的task_struct,此时的p与当前进程的task,仅仅是stack地址不同
p = dup_task_struct(current);
if (!p)
goto fork_out; ··· retval = -EAGAIN;
// 检查该用户的进程数是否超过限制
if (atomic_read(&p->real_cred->user->processes) >=
task_rlimit(p, RLIMIT_NPROC)) {
// 检查该用户是否具有相关权限,不一定是root
if (p->real_cred->user != INIT_USER &&
!capable(CAP_SYS_RESOURCE) && !capable(CAP_SYS_ADMIN))
goto bad_fork_free;
}
current->flags &= ~PF_NPROC_EXCEEDED; retval = copy_creds(p, clone_flags);
if (retval < 0)
goto bad_fork_free; /*
* If multiple threads are within copy_process(), then this check
* triggers too late. This doesn't hurt, the check is only there
* to stop root fork bombs.
*/
retval = -EAGAIN;
// 检查进程数量是否超过 max_threads,后者取决于内存的大小
if (nr_threads >= max_threads)
goto bad_fork_cleanup_count; if (!try_module_get(task_thread_info(p)->exec_domain->module))
goto bad_fork_cleanup_count; delayacct_tsk_init(p); /* Must remain after dup_task_struct() */
p->flags &= ~(PF_SUPERPRIV | PF_WQ_WORKER);
// 表明子进程还没有调用exec系统调用
p->flags |= PF_FORKNOEXEC;
INIT_LIST_HEAD(&p->children);
INIT_LIST_HEAD(&p->sibling);
rcu_copy_process(p);
p->vfork_done = NULL; // 初始化自旋锁
spin_lock_init(&p->alloc_lock); // 初始化挂起信号
init_sigpending(&p->pending); // 初始化定时器
p->utime = p->stime = p->gtime = 0;
p->utimescaled = p->stimescaled = 0;
#ifndef CONFIG_VIRT_CPU_ACCOUNTING_NATIVE
p->prev_cputime.utime = p->prev_cputime.stime = 0;
#endif
#ifdef CONFIG_VIRT_CPU_ACCOUNTING_GEN
seqlock_init(&p->vtime_seqlock);
p->vtime_snap = 0;
p->vtime_snap_whence = VTIME_SLEEPING;
#endif ... #ifdef CONFIG_DEBUG_MUTEXES
p->blocked_on = NULL; /* not blocked yet */
#endif
#ifdef CONFIG_BCACHE
p->sequential_io = 0;
p->sequential_io_avg = 0;
#endif /* Perform scheduler related setup. Assign this task to a CPU. */ // 完成对新进程调度程序数据结构的初始化,并把新进程的状态设置为TASK_RUNNING
// 同时将thread_info中得preempt_count置为1,禁止内核抢占
retval = sched_fork(clone_flags, p);
if (retval)
goto bad_fork_cleanup_policy; retval = perf_event_init_task(p);
if (retval)
goto bad_fork_cleanup_policy;
retval = audit_alloc(p);
if (retval)
goto bad_fork_cleanup_perf;
/* copy all the process information */ // 复制所有的进程信息
shm_init_task(p);
retval = copy_semundo(clone_flags, p);
if (retval)
goto bad_fork_cleanup_audit;
retval = copy_files(clone_flags, p);
if (retval)
goto bad_fork_cleanup_semundo; ... // 初始化子进程的内核栈
retval = copy_thread(clone_flags, stack_start, stack_size, p);
if (retval)
goto bad_fork_cleanup_io; if (pid != &init_struct_pid) {
retval = -ENOMEM;
// 这里为子进程分配了新的pid号
pid = alloc_pid(p->nsproxy->pid_ns_for_children);
if (!pid)
goto bad_fork_cleanup_io;
} ... // 清除子进程thread_info结构的 TIF_SYSCALL_TRACE,防止 ret_from_fork将系统调用消息通知给调试进程
clear_tsk_thread_flag(p, TIF_SYSCALL_TRACE);
#ifdef TIF_SYSCALL_EMU
clear_tsk_thread_flag(p, TIF_SYSCALL_EMU);
#endif
clear_all_latency_tracing(p); /* ok, now we should be set up.. */ // 设置子进程的pid
p->pid = pid_nr(pid); // 如果是创建线程
if (clone_flags & CLONE_THREAD) {
p->exit_signal = -1; // 线程组的leader设置为当前线程的leader
p->group_leader = current->group_leader; // tgid是当前线程组的id,也就是main进程的pid
p->tgid = current->tgid;
} else {
if (clone_flags & CLONE_PARENT)
p->exit_signal = current->group_leader->exit_signal;
else
p->exit_signal = (clone_flags & CSIGNAL); // 创建的是进程,自己是一个单独的线程组
p->group_leader = p; // tgid和pid相同
p->tgid = p->pid;
} ... if (likely(p->pid)) {
ptrace_init_task(p, (clone_flags & CLONE_PTRACE) || trace); init_task_pid(p, PIDTYPE_PID, pid);
if (thread_group_leader(p)) { ... // 将pid加入散列表
attach_pid(p, PIDTYPE_PGID);
attach_pid(p, PIDTYPE_SID);
__this_cpu_inc(process_counts);
} else { ... }
// 将pid加入PIDTYPE_PID这个散列表
attach_pid(p, PIDTYPE_PID);
// 递增 nr_threads的值
nr_threads++;
} total_forks++;
spin_unlock(&current->sighand->siglock);
syscall_tracepoint_update(p);
write_unlock_irq(&tasklist_lock); ... // 返回被创建的task结构体指针
return p; ...

 可以看出该函数主要完成以下工作:用 dup_ task_ struct 复制当前的 task_ struct,并且检查进程数是否超过限制,初始化自旋锁、挂起信号、CPU 定时器等,调用 sched_ fork 初始化进程数据结构,并把进程状态设置为 TASK_ RUNNING,复制所有进程信息:包括文件系统、信号处理函数、信号、内存管理等,最后调用 copy_ thread 初始化子进程内核栈,为新进程分配并设置新的pid。下面是dup_ task_ struct()中完成的工作:

static struct task_struct *dup_task_struct(struct task_struct *orig)
{
struct task_struct *tsk;
struct thread_info *ti;
int node = tsk_fork_get_node(orig);
int err; //分配一个 task_struct 节点
tsk = alloc_task_struct_node(node);
if (!tsk)
return NULL; //分配一个 thread_info 节点,包含进程的内核栈,ti 为栈底
ti = alloc_thread_info_node(tsk, node);
if (!ti)
goto free_tsk; //将栈底的值赋给新节点的栈
tsk->stack = ti;
//……
return tsk; }

 可以看出在该函数中,调用了alloc_ task_ struct_ node分配一个 task_ struct 节点,调用alloc_ thread_ info_ node分配一个 thread_ info 节点,其实是分配了一个thread_ union联合体,将栈底返回给 ti。最后将栈底的值 ti 赋值给新节点的栈,最终执行完dup_ task_ struct之后,子进程除了tsk->stack指针不同之外,全部都一样!

总结

### 可以通过fork、vfork和clone来创建一个新进程,而他们又都是通过调用do_ fork方法来实现的。do_ fork函数主要是调用copy_ process函数来为子进程复制父进程信息的。copy_ process函数调用 dup_task_struct为子进程分配新的堆栈;调用sched_ fork 初始化进程数据结构,并把进程状态设置为TASK_ RUNNING。copy_ process函数尤为重要,我们可以看到为什么fork()函数返回值为0,并且fork出的子进程是从哪里开始执行的:将子进程的ip设置为ret_ from_ fork的首地址,子进程从ret_ from_ fork开始执行。

二、读书笔记

【第一部分】 定时器和时间管理

一 内核中的时间观念

 内核在硬件的帮助下计算和管理时间。硬件为内核提供一个系统定时器用以计算流逝的时间。系统定时器以某种频率自行触发,产生时钟中断,进入内核时钟中断处理程序中进行处理。墙上时间和系统运行时间根据时钟间隔来计算。利用时间中断周期执行的工作:

 1. 更新系统运行时间;

 2. 更新实际时间;

 3. 在smp系统上,均衡调度程序中各处理器上运行队列;

 4. 检查当前进程是否用尽了时间片,重新进行调度;

 5. 运行超时的动态定时器;

 6. 更新资源消耗和处理器时间的统计值;

二 节拍率

 (1)系统定时器的频率;通过静态预处理定义的——HZ;系统启动按照HZ值对硬件进行设置。体系结构不同,HZ值也不同;HZ可变的。

 ` #define HZ 1000 //内核时间频率

 (2)提高节拍率中断产生更加频繁带来的好处:

  1. 提高时间驱动事件的解析度;

  2. 提高时间驱动事件的准确度;

  3. 内核定时器以更高的频度和准确度;

  4. 依赖顶上执行的系统调用poll()和select()能更高的精度运行;

  5. 系统时间测量更精细;

  6. 提高进程抢占的准确度;

 (3)提高节拍率带来的副作用:

  1.中断频率增高系统负担增加;

  2.中断处理程序占用处理器时间增多;

  3.频繁打断处理器高速缓存;

三、jiffies

 jiffies:全局变量,用来记录自系统启动以来产生的节拍总数。启动时内核将该变量初始化为0;此后每次时钟中断处理程序增加该变量的值。每一秒钟中断次数HZ,jiffies一秒内增加HZ。系统运行时间 = jiffie/HZ.jiffies用途:计算流逝时间和时间管理

四、硬时钟和定时器

 两种设备进行计时:系统定时器和实时时钟。

 (1)实时时钟(RTC):用来持久存放系统时间的设备,即便系统关闭后,靠主板上的微型电池提供电力保持系统的计时。系统启动内核通过读取RTC来初始化墙上时间,改时间存放在xtime变量中。

 (2)系统定时器:内核定时机制,注册中断处理程序,周期性触发中断,响应中断处理程序,进行处理执行以下工作:

  1.获得xtime_lock锁,访问jiffies和更新墙上时间xtime;

  2.更新实时时钟;

  3.更新资源统计值:当前进程耗时,系统时间等;

  4.执行已到期的动态定时器;

  5.执行scheduler_tick()

五、定时器

 定时器:管理内核时间的基础,推后或执行时间执行某些代码。

六、延迟执行

 使用定时器和下半部机制推迟执行任务。还有其他延迟执行的机制:

  (1)忙等待:利用节拍,精确率不高

   unsigned long delay = jiffies + 2*HZ ; //2秒 节拍整数倍才行;
while(time_before(jiffies,delay)) ;

  (2)短延迟:延迟时间精确到毫秒,微妙;短暂等待某个动作完成时,比时钟节拍更短;依靠数次循环达到延迟效果。

   void udelay(unsigned long usecs)
void mdelay(unsigned long msecs)

【第二部分】内存管理:虚拟内存机制

 在早期的计算机中,是没有虚拟内存的概念的。我们要运行一个程序,会把程序全部装入内存,然后运行。当运行多个程序时,经常会出现以下问题:

 1)进程地址空间不隔离,没有权限保护。由于程序都是直接访问物理内存,所以一个进程可以修改其他进程的内存数据,甚至修改内核地址空间中的数据。

 2)内存使用效率低。当内存空间不足时,要将其他程序暂时拷贝到硬盘,然后将新的程序装入内存运行。由于大量的数据装入装出,内存使用效率会十分低下。

 3)程序运行的地址不确定。因为内存地址是随机分配的,所以程序运行的地址也是不确定的。

 可看出内存管理无非就是想办法解决三个问题:如何使进程的地址空间隔离,如何提高内存的使用效率,如何解决程序运行时的重定位问题?现在的内存管理方法就是在程序和物理内存之间引入了虚拟内存这个概念,来解决上述问题。

 Linux内核把虚拟地址空间分为两部分:用户进程空间,内核进程空间。如下图所示:

            

 记得老师上课举过一个形象的例子:实际物理内存就像是我们教室的70个座位,我们进教室可以选择其中的任何一个座位坐下,那么就相当于我们每个人都拥有70个座位这么大的内存,这么看来我们就有n(人数)*70的内存,但事实上每个人拥有的内存空间只是虚拟内存空间。

 每次访问内存空间的某个地址,都需要把地址翻译为实际物理内存地址,所有进程共享同一物理内存,每个进程只把自己目前需要的虚拟内存空间映射并存储到物理内存上,进程要知道哪些内存地址上的数据在物理内存上,哪些不在,还有在物理内存上的哪里,需要用页表来记录。页表的每一个表项分两部分,第一部分记录此页是否在物理内存上,第二部分记录物理内存页的地址(如果在的话)当进程访问某个虚拟地址,去看页表,如果发现对应的数据不在物理内存中,则缺页异常。缺页异常的处理过程,就是把进程需要的数据从磁盘上拷贝到物理内存中,如果内存已经满了,没有空地方了,那就找一个页覆盖,当然如果被覆盖的页曾经被修改过,需要将此页写回磁盘。

具体机制可以参考http://www.linuxidc.com/Linux/2015-02/113981.htm

2017-2018-1 20179215《Linux内核原理与分析》第七周作业的更多相关文章

  1. 2017-2018-1 20179215《Linux内核原理与分析》第二周作业

    20179215<Linux内核原理与分析>第二周作业 这一周主要了解了计算机是如何工作的,包括现在存储程序计算机的工作模型.X86汇编指令包括几种内存地址的寻址方式和push.pop.c ...

  2. 20169212《Linux内核原理与分析》第二周作业

    <Linux内核原理与分析>第二周作业 这一周学习了MOOCLinux内核分析的第一讲,计算机是如何工作的?由于本科对相关知识的不熟悉,所以感觉有的知识理解起来了有一定的难度,不过多查查资 ...

  3. 20169210《Linux内核原理与分析》第二周作业

    <Linux内核原理与分析>第二周作业 本周作业分为两部分:第一部分为观看学习视频并完成实验楼实验一:第二部分为看<Linux内核设计与实现>1.2.18章并安装配置内核. 第 ...

  4. 2018-2019-1 20189221 《Linux内核原理与分析》第九周作业

    2018-2019-1 20189221 <Linux内核原理与分析>第九周作业 实验八 理理解进程调度时机跟踪分析进程调度与进程切换的过程 进程调度 进度调度时机: 1.中断处理过程(包 ...

  5. 2019-2020-1 20199329《Linux内核原理与分析》第九周作业

    <Linux内核原理与分析>第九周作业 一.本周内容概述: 阐释linux操作系统的整体构架 理解linux系统的一般执行过程和进程调度的时机 理解linux系统的中断和进程上下文切换 二 ...

  6. 2019-2020-1 20199329《Linux内核原理与分析》第二周作业

    <Linux内核原理与分析>第二周作业 一.上周问题总结: 未能及时整理笔记 Linux还需要多用 markdown格式不熟练 发布博客时间超过规定期限 二.本周学习内容: <庖丁解 ...

  7. 2019-2020-1 20209313《Linux内核原理与分析》第二周作业

    2019-2020-1 20209313<Linux内核原理与分析>第二周作业 零.总结 阐明自己对"计算机是如何工作的"理解. 一.myod 步骤 复习c文件处理内容 ...

  8. 2018-2019-1 20189221《Linux内核原理与分析》第一周作业

    Linux内核原理与分析 - 第一周作业 实验1 Linux系统简介 Linux历史 1991 年 10 月,Linus Torvalds想在自己的电脑上运行UNIX,可是 UNIX 的商业版本非常昂 ...

  9. 《Linux内核原理与分析》第一周作业 20189210

    实验一 Linux系统简介 这一节主要学习了Linux的历史,Linux有关的重要人物以及学习Linux的方法,Linux和Windows的区别.其中学到了LInux中的应用程序大都为开源自由的软件, ...

  10. 2018-2019-1 20189221《Linux内核原理与分析》第二周作业

    读书报告 <庖丁解牛Linux内核分析> 第 1 章 计算工作原理 1.1 存储程序计算机工作模型 1.2 x86-32汇编基础 1.3汇编一个简单的C语言程序并分析其汇编指令执行过程 因 ...

随机推荐

  1. RecyclerView的使用(3)之加入Header和Footer

    原创文章.转载请注明 http://blog.csdn.net/leejizhou/article/details/50742544 李济洲的博客 RecyclerView尽管作为ListView的替 ...

  2. c/c++的一些小知识点3

    ---恢复内容开始--- ---恢复内容结束---

  3. <转载> 为什么在Python里推荐使用多进程而不是多线程?

    经常我们会听到老手说:“Python下多线程是鸡肋,推荐使用多进程!”,但是为什么这么说呢?                要知其然,更要知其所以然.所以有了下面的深入研究: 首先强调背景:     ...

  4. 【BZOJ3926】[Zjoi2015]诸神眷顾的幻想乡 广义后缀自动机

    [BZOJ3926][Zjoi2015]诸神眷顾的幻想乡 Description 幽香是全幻想乡里最受人欢迎的萌妹子,这天,是幽香的2600岁生日,无数幽香的粉丝到了幽香家门前的太阳花田上来为幽香庆祝 ...

  5. lua调试小技巧

    lua中,如果碰到某个属性值改变了,但是修改的地方又特别多,调试就特别麻烦了,有个小技巧,直接贴代码 local m = {    __index = function( t, k )         ...

  6. Linux修改网络配置

    修改:/etc/sysconfig/network-scripts/ifcfg-eth0 重启网卡/etc/rc.d/init.d/network restart

  7. Opennms -安装

    参考官方网站:https://docs.opennms.org/opennms/releases/latest/guide-install/guide-install.html#gi-install- ...

  8. MySQL删除相同前缀的表,修改某个库的存储引擎

    MySQL5.0 之后,提供了一个新的数据库information_schema,用来记录MySQL总的元数据信息.元数据指的是 数据的数据. 比如表名.列名.列类型.索引名等表的各种属性名称.这个库 ...

  9. Android Studio 1.1 使用介绍及导入 jar 包和第三方依赖库

    导入 jar 包 导入 jar 包的方式非常简单,就是在项目中的 libs 中放入你需要导入的 jar 包,然后右键你的 jar 文件,选择“add as a library”即可在你的项目中使用这个 ...

  10. Bootstrap学习5--bootstrap中的模态框(modal,弹出层)

    bootstrap中的模态框(modal),不同于Tooltips,模态框以弹出对话框的形式出现,具有最小和最实用的功能集. 务必将模态框的 HTML 代码放在文档的最高层级内(也就是说,尽量作为 b ...