Android 11(R)启动流程 初版

news2025/1/21 22:06:17

启动流程

bootloader会去启动android第一个进程Idle,pid为0,会对进程 内存管理等进行初始化。Idle还被称作swapper。Idle会去创建两个进程,一个是init,另外一个是kthread。 kthread会去启动内核,用户是由init进行启动。

init进程的启动

在内核common/init/main.c 的kernel_init函数中

	if (execute_command) {
		ret = run_init_process(execute_command);
		if (!ret)
			return 0;
		panic("Requested init %s failed (error %d).",
		      execute_command, ret);
	}
	if (!try_to_run_init_process("/sbin/init") ||
	    !try_to_run_init_process("/etc/init") ||
	    !try_to_run_init_process("/bin/init") ||
	    !try_to_run_init_process("/bin/sh"))
		return 0;

可以看到这里启动了init进程。并且这个bin是在根目录下的。

static int run_init_process(const char *init_filename)
{
	argv_init[0] = init_filename;
	pr_info("Run %s as init process\n", init_filename);
	return do_execve(getname_kernel(init_filename),
		(const char __user *const __user *)argv_init,
		(const char __user *const __user *)envp_init);
}

do_execve 是一个内核函数,用于执行用户空间的程序,最后会调用到__do_execve_file函数

/* 参数解释
int fd:

文件描述符,用于指定要执行的文件。如果不使用文件描述符,可以传入 AT_FDCWD。
struct filename *filename:

文件名结构体,包含要执行的可执行文件的路径。
struct user_arg_ptr argv:

参数指针结构体,指向传递给新进程的命令行参数。
struct user_arg_ptr envp:

环境变量指针结构体,指向传递给新进程的环境变量。
int flags:

标志位,用于控制执行行为。
struct file *file:

文件结构体指针,指向要执行的文件。如果不通过文件描述符打开文件,可以传入 NULL。*/


static int __do_execve_file(int fd, struct filename *filename,
			    struct user_arg_ptr argv,
			    struct user_arg_ptr envp,
			    int flags, struct file *file)
{
	char *pathbuf = NULL;
	struct linux_binprm *bprm;
	struct files_struct *displaced;
	int retval;

    //检查 filename 是否为错误指针,如果是,则返回相应的错误码。

	if (IS_ERR(filename))
		return PTR_ERR(filename);

	/*
	 * We move the actual failure in case of RLIMIT_NPROC excess from
	 * set*uid() to execve() because too many poorly written programs
	 * don't check setuid() return code.  Here we additionally recheck
	 * whether NPROC limit is still exceeded.
	 */

    //检查当前用户的进程数是否超过了限制,如果超过了限制,则返回 -EAGAIN 错误码。
    //清除进程标志 PF_NPROC_EXCEEDED。

	if ((current->flags & PF_NPROC_EXCEEDED) &&
	    atomic_read(&current_user()->processes) > rlimit(RLIMIT_NPROC)) {
		retval = -EAGAIN;
		goto out_ret;
	}

	/* We're below the limit (still or again), so we don't want to make
	 * further execve() calls fail. */
	current->flags &= ~PF_NPROC_EXCEEDED;

    //调用 unshare_files 函数分离文件结构,以确保进程在执行期间独占文件描述符表
	retval = unshare_files(&displaced);
	if (retval)
		goto out_ret;

	retval = -ENOMEM;
    //分配并初始化 linux_binprm 结构体,该结构体用于存储进程执行所需的各种信息
	bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
	if (!bprm)
		goto out_files;

	retval = prepare_bprm_creds(bprm);
	if (retval)
		goto out_free;

    //调用 check_unsafe_exec 函数进行安全检查
	check_unsafe_exec(bprm);
	current->in_execve = 1;

    //如果没有提供文件指针,则使用 do_open_execat 函数根据文件描述符和文件名打开文件
	if (!file)
		file = do_open_execat(fd, filename, flags);
	retval = PTR_ERR(file);
	if (IS_ERR(file))
		goto out_unmark;

    /*准备执行环境:
    调用 sched_exec 函数设置调度相关信息。
    初始化 bprm 结构体中的文件和文件名信息。
    调用 bprm_mm_init 函数初始化进程的内存管理。
    调用 prepare_arg_pages 函数准备参数和环境变量的内存页。
    调用 prepare_binprm 函数准备可执行文件的二进制参数。*/

	sched_exec();

	bprm->file = file;
	if (!filename) {
		bprm->filename = "none";
	} else if (fd == AT_FDCWD || filename->name[0] == '/') {
		bprm->filename = filename->name;
	} else {
		if (filename->name[0] == '\0')
			pathbuf = kasprintf(GFP_KERNEL, "/dev/fd/%d", fd);
		else
			pathbuf = kasprintf(GFP_KERNEL, "/dev/fd/%d/%s",
					    fd, filename->name);
		if (!pathbuf) {
			retval = -ENOMEM;
			goto out_unmark;
		}
		/*
		 * Record that a name derived from an O_CLOEXEC fd will be
		 * inaccessible after exec. Relies on having exclusive access to
		 * current->files (due to unshare_files above).
		 */
		if (close_on_exec(fd, rcu_dereference_raw(current->files->fdt)))
			bprm->interp_flags |= BINPRM_FLAGS_PATH_INACCESSIBLE;
		bprm->filename = pathbuf;
	}
	bprm->interp = bprm->filename;

	retval = bprm_mm_init(bprm);
	if (retval)
		goto out_unmark;

	retval = prepare_arg_pages(bprm, argv, envp);
	if (retval < 0)
		goto out;

	retval = prepare_binprm(bprm);
	if (retval < 0)
		goto out;


    //复制参数和环境变量:

    //调用 copy_strings_kernel 函数复制文件名到用户栈。
    //调用 copy_strings 函数依次复制环境变量和命令行参数到用户栈

	retval = copy_strings_kernel(1, &bprm->filename, bprm);
	if (retval < 0)
		goto out;

	bprm->exec = bprm->p;
	retval = copy_strings(bprm->envc, envp, bprm);
	if (retval < 0)
		goto out;

	retval = copy_strings(bprm->argc, argv, bprm);
	if (retval < 0)
		goto out;

	/*
	 * When argv is empty, add an empty string ("") as argv[0] to
	 * ensure confused userspace programs that start processing
	 * from argv[1] won't end up walking envp. See also
	 * bprm_stack_limits().
	 */
	if (bprm->argc == 0) {
		const char *argv[] = { "", NULL };
		retval = copy_strings_kernel(1, argv, bprm);
		if (retval < 0)
			goto out;
		bprm->argc = 1;
	}

    //调用 exec_binprm 函数执行可执行文件
	retval = exec_binprm(bprm);
	if (retval < 0)
		goto out;

    //如果执行成功,清理各种临时数据结构,释放资源,并返回成功状态。
    //如果执行失败,进行错误处理,清理资源,并返回相应的错误码。
	/* execve succeeded */
	current->fs->in_exec = 0;
	current->in_execve = 0;
	rseq_execve(current);
	acct_update_integrals(current);
	task_numa_free(current, false);
	free_bprm(bprm);
	kfree(pathbuf);
	if (filename)
		putname(filename);
	if (displaced)
		put_files_struct(displaced);
	return retval;

out:
	if (bprm->mm) {
		acct_arg_size(bprm, 0);
		mmput(bprm->mm);
	}

out_unmark:
	current->fs->in_exec = 0;
	current->in_execve = 0;

out_free:
	free_bprm(bprm);
	kfree(pathbuf);

out_files:
	if (displaced)
		reset_files_struct(displaced);
out_ret:
	if (filename)
		putname(filename);
	return retval;
}

接下来就正式进入init的启动流程,init代码路径:system/core/init/main.cpp  main函数代码较短,直接贴上来。

int main(int argc, char** argv) {
#if __has_feature(address_sanitizer)
    __asan_set_error_report_callback(AsanReportCallback);
#endif

    if (!strcmp(basename(argv[0]), "ueventd")) {
        return ueventd_main(argc, argv);
    }

    if (argc > 1) {
        if (!strcmp(argv[1], "subcontext")) {
            android::base::InitLogging(argv, &android::base::KernelLogger);
            const BuiltinFunctionMap& function_map = GetBuiltinFunctionMap();

            return SubcontextMain(argc, argv, &function_map);
        }

        if (!strcmp(argv[1], "selinux_setup")) {
            return SetupSelinux(argv);
        }

        if (!strcmp(argv[1], "second_stage")) {
            return SecondStageMain(argc, argv);
        }
    }

    return FirstStageMain(argc, argv);
}

FirstStageMain

在FirstStageMain中,主要是挂载文件目录,创建一些文件,做一些初始化等的阶段。

还包括初始化日志系统,将输入输出重定向。

最后,会去启动init 并且带一个selinux_setup

SelinuxInitialize

在SelinuxInitialize中会初始化一些安全策略

selinux_setup之后会去second_stage

SecondStageMain

PropertyInit();  初始化属性域

selinux初始化
// Now set up SELinux for second stage.
SelinuxSetupKernelLogging();
SelabelInitialize();
SelinuxRestoreContext();//恢复安全上下文


//处理子进程的终止信号,判断子进程有没有挂掉 (僵尸进程) 回收资源。
InstallSignalFdHandler(&epoll);
InstallInitNotifier(&epoll);
StartPropertyService(&property_fd);


//匹配命令和函数之间的关系,ls 等命令和函数的关系
const BuiltinFunctionMap& function_map = GetBuiltinFunctionMap();




/********************************/
//解析init.rc
LoadBootScripts(am, sm);
/********************************/
static void LoadBootScripts(ActionManager& action_manager, ServiceList& service_list) {
    Parser parser = CreateParser(action_manager, service_list);

    std::string bootscript = GetProperty("ro.boot.init_rc", "");
    if (bootscript.empty()) {
        parser.ParseConfig("/system/etc/init/hw/init.rc");
        if (!parser.ParseConfig("/system/etc/init")) {
            late_import_paths.emplace_back("/system/etc/init");
        }
        // late_import is available only in Q and earlier release. As we don't
        // have system_ext in those versions, skip late_import for system_ext.
        parser.ParseConfig("/system_ext/etc/init");
        if (!parser.ParseConfig("/product/etc/init")) {
            late_import_paths.emplace_back("/product/etc/init");
        }
        if (!parser.ParseConfig("/odm/etc/init")) {
            late_import_paths.emplace_back("/odm/etc/init");
        }
        if (!parser.ParseConfig("/vendor/etc/init")) {
            late_import_paths.emplace_back("/vendor/etc/init");
        }
    } else {
        parser.ParseConfig(bootscript);
    }
}

在LoadBootScripts中会去创建三种对应解释器

Parser CreateParser(ActionManager& action_manager, ServiceList& service_list) {
    Parser parser;

    parser.AddSectionParser("service", std::make_unique<ServiceParser>(
                                               &service_list, GetSubcontext(), std::nullopt));
    parser.AddSectionParser("on", std::make_unique<ActionParser>(&action_manager, GetSubcontext()));
    parser.AddSectionParser("import", std::make_unique<ImportParser>(&parser));

    return parser;
}

init处理的重要事情

1.挂载文件

2.设置selinux

3.开启属性服务

4.解析init.rc

5.循环处理脚本  -》 启动zygote

6.循环等待

init.rc -》zygote

# Now we can start zygote for devices with file based encryption
trigger zygote-start

这段 init.rc 脚本配置了在不同加密状态下启动 zygote 及其相关服务的逻辑。根据设备的加密状态(未加密、不支持加密或已加密)

init.rc解析时会导入import /system/etc/init/hw/init.${ro.zygote}.rc

这里大括号{}中的ro.zygote表示它会用 ro.zygote 系统属性的值来替换 ${ro.zygote}

所以这里是  import /system/etc/init/hw/init.zygote32.rc

init.zygote32.rc类似的文件有四个,对应32位系统,64位系统,还有主32次64或者主64的。这种会在32执行失败之后再执行64位的。

启动一个服务 zygote   后面是路径, 在后面是参数

zygote的作用之一是要进入java层,为Android启动运行时环境。

AppRuntime runtime(argv[0], computeArgBlockSize(argc, argv)); -》class AppRuntime : public AndroidRuntime

app_main.cpp中 runtime.start("com.android.internal.os.ZygoteInit", args, zygote);去启动android运行时环境。

app_main.cpp   runtime.start -》

AndroidRuntime.cpp    AndroidRuntime::start -》

startVm 启动虚拟机-》

startReg  注册jni-》

env->CallStaticVoidMethod(startClass, startMeth, strArray); 

启动com.android.internal.os.ZygoteInit  -》ZygoteInit.java . main

AndroidRuntime 中包括对虚拟机一系列的初始化,这里包括heapsize的初始化为16M,

进程和虚拟机是什么关系

虚拟机实现了进程中内存管理的功能

注册jni

startReg-》

register_jni_procs-》

register_com_android_internal_os_RuntimeInit (env)

register_com_android_internal_os_ZygoteInit_nativeZygoteInit(env)

register_com_android_internal_os_......

Zygote的java启动

runtime.start("com.android.internal.os.ZygoteInit", args, zygote);  -》

env->CallStaticVoidMethod(startClass, startMeth, strArray);-》

ZygoteInit.java  main-》

preload(bootTimingsTraceLog);//加快进程的启动-》

zygoteServer = new ZygoteServer(isPrimaryZygote);-》

Runnable r = forkSystemServer(abiList, zygoteSocketName, zygoteServer);// 启动systemserver进程-》

caller = zygoteServer.runSelectLoop(abiList);进入loop死循环,接收AMS传过来的消息

preload加载的时间

Zygote总结:

native:

1.初始化运行环境,创建jvm

2.注册jni

3.调用zygoteinit.main

java

1.预加载 --  加快进程启动

2.socket  服务器

3.循环等待

Zygote  fork SystemServer进程

SystemServer的主要工作是管理服务,AMS  WMS都和SystemServer属于同一进程,这些服务都是在SystemServer运行起来的。

ZygoteInit.java 

-》

Runnable r = forkSystemServer(abiList, socketName, zygoteServer);

-》

Zygote.forkSystemServer-》

Zygote.java  nativeForkSystemServer

-》

com_android_internal_os_Zygote.cpp   com_android_internal_os_Zygote_nativeForkSystemServer 

-》

ForkAndSpecializeCommon-》

fork(); -》

pid == 0  handleSystemServerProcess-》

ZygoteInit.zygoteInit(parsedArgs.targetSdkVersion, parsedArgs.remainingArgs, cl);

-》

RuntimeInit.applicationInit(targetSdkVersion, argv, classLoader);

-》

RuntimeInit.commonInit();//初始化运行环境

-》

ZygoteInit.nativeZygoteInit //启动binder,方法在androidRuntime.cpp中注册

-》

RuntimeInit.applicationInit(targetSdkVersion, argv, classLoader);//ActivityThread.main();

-》

findStaticMain //app启动流程也是走这里,因此这里不仅返回AMS通过socket传过来的ActivityThread、还有systemserver

-》cl.getMethod("main", new Class[] { String[].class });//通过反射拿到对应类的main方法的Method对象

-》MethodAndArgsCaller implements Runnable  封装成Runnable对象

-》

mMethod.invoke(null, new Object[] { mArgs });  执行run时通过invoke函数执行类所对应的main函数

-》r.run();

SystemServer基本流程

main-》new SystemServer().run();-》

startBootstrapServices

startCoreServices

startOtherServices -》

createSystemContext //创建系统上下文

SystemServer如何管理服务

systemserver基本都通过mSystemServiceManager.startService 来启动服务,可以看出SystemServer是通过SystemServiceManager来管理service的,服务都必须封装systemservice类,

mActivityManagerService = mSystemServiceManager.startService(ActivityManagerService.Lifecycle.class).getService();

mActivityManagerService = mSystemServiceManager.startService(ActivityManagerService.Lifecycle.class).getService();

public class ActivityManagerService extends IActivityManager.Stub

        implements Watchdog.Monitor, BatteryStatsImpl.BatteryCallback {

..............} 从这里看出ActivityManagerService 是一个binder。但是因为只能有一个父类,为了能够实现继承自systemservice的效果,ActivityManagerService 实现了一个静态内部类Lifecycle拓展自SystemService。

public static final class Lifecycle extends SystemService {.......}

public Lifecycle(Context context) {
    super(context);
    mService = new ActivityManagerService(context);
}

并且mSystemServiceManager.startService传递的是类名,可以通过反射创建实例,通过实例调用service.onStart();可以调用到继承自SystemService 的Lifecycle里面的onStart函数。

public <T extends SystemService> T startService(Class<T> serviceClass) {}

在通过

ServiceManager.addService(Context.ACTIVITY_SERVICE, this, /* allowIsolated= */ true, DUMP_FLAG_PRIORITY_CRITICAL | DUMP_FLAG_PRIORITY_NORMAL | DUMP_FLAG_PROTO);

或者

publishBinderService(Context.USER_SERVICE, mUms); 将服务注册到servicemanager里面去,尽管publishBinderService最后也是调用ServiceManager.addService函数。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1978414.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

算法通关:017_1:二叉树及三种顺序的递归遍历

文章目录 题目思路代码运行结果 题目 二叉树及三种顺序的递归遍历 思路 代码 /*** Author: ggdpzhk* CreateTime: 2024-08-04** 二叉树及三种顺序的递归遍历* LeetCode 144. 二叉树的前序遍历* LeetCode 94. 二叉树的中序遍历* LeetCode 145. 二叉树的后序遍历* LeetCode 10…

sqli-labs靶场——第二关

1、判断注入类型 ?id1和?id2-1的页面一样所以是数字型 2、判断闭合类型 数字型没有闭合符号 3、order by查看有几列 当输入order by 4 时候页面变化&#xff0c;3的时候正常&#xff0c;所以是3列 4、union select联合查询查看回显 /sqli-labs/Less-2/?id-1 union sel…

ESP32使用MQTT协议通讯(EMQX)

一、背景介绍 前面完成了ESP32MicroPython环境的搭建01_ESP32 MicroPython开发环境搭建_eps32开发板-CSDN博客 现在想实现以下功能&#xff1a; 1.通过手机或电脑&#xff0c;远程给ESP32发送相关指令。 2.ESP32接到指令后&#xff0c;做出相应的高低电平输出。 这样就相当…

视频编辑SDK,底层架构合理,前端自定义程度高

如何高效、专业地制作出符合品牌形象、吸引目标受众的视频内容&#xff0c;成为了众多企业面临的共同挑战。美摄科技&#xff0c;作为视频编辑技术的先行者&#xff0c;以其卓越的视频编辑SDK&#xff08;Software Development Kit&#xff09;&#xff0c;为企业用户量身打造了…

进程状态都有哪些?

目录 前言&#xff1a; 进程的各个状态&#xff1a; 1、R状态&#xff08;进程运行状态&#xff09;和S状态&#xff08;休眠状态&#xff09; 2、T状态和t状态&#xff08;暂停进程&#xff09; 3、D状态&#xff08;磁盘休眠状态&#xff09; 4、Z状态&#xff08;僵尸状…

学习笔记 韩顺平 零基础30天学会Java(2024.8.2)

P447 五大运行时异常 P448 异常课堂练习 P449 异常处理机制 try-catch-finally throws(处理机制二选一)&#xff0c;如果没有显式处理异常&#xff0c;默认throws JVM处理异常直接输出异常信息&#xff0c;退出程序 P450 tryCatch 对于第一个细节&#xff0c;发生异常之后时try…

C语言快速入门及精通学习指南——手把手教零基础/新手入门(完整C语言学习笔记整理)

前言 作为一名拥有多年开发经验的码农&#xff0c;我的职业生涯涵盖了多种编程语言&#xff0c;包括 C 语言、C、C# 和 JavaScript。在这一过程中&#xff0c;我深刻地意识到扎实的基础对于编程学习的重要性&#xff0c;尤其是对于 C 语言这样一门核心语言来说。 出于对…

sgg快餐项目-3 项目

一、数仓架构 本项目的数据是事务数据&#xff0c;都存储在mysql数据库&#xff0c;如果是其他的项目&#xff0c;那数据可能会在文本、爬虫等。要使用相关的组件将数据导入到HDFS上。&#xff08;因为要把数据导入到hive做数据管理、存储和分析&#xff0c;而hive就是在hfds上…

2024年文件防泄密系统TOP3|遥遥领先的文件防泄密系统

古语有云&#xff1a;“密者&#xff0c;国之重器&#xff0c;不可不慎。” 在今日之数字化时代&#xff0c;信息的保密与安全&#xff0c;已然成为企业乃至国家生存与发展的基石。 随着数据泄露事件频发&#xff0c;文件防泄密系统的重要性愈发凸显。 2024年&#xff0c;随着…

数据存储与访问

一、文件存储读写 1.Android文件的操作模式 2.文件的相关操作方法 3.文件读写的实现 Android中的文件读写和Java中的文件I/O相同&#xff0c;流程也很简单&#xff0c;下面我们来写个简单的示例&#xff1a; PS:这里用的是模拟器&#xff0c;因为笔者的N5并没有root&#xf…

Go语言加Vue3零基础入门全栈班11 Go语言+gorm用户管理系统实战 2024年08月03日 课程笔记

概述 如果您没有Golang的基础&#xff0c;应该学习如下前置课程。 Golang零基础入门Golang面向对象编程Go Web 基础Go语言开发REST API接口_20240728Go语言操作MySQL开发用户管理系统API教程_20240729Redis零基础快速入门_20231227GoRedis开发用户管理系统API实战_20240730Mo…

工业人工智能真的能落地吗?

文章目录 前言Part1 聊聊技术1 人工智能、机器学习和深度学习的关系2 优化思想的一个案例 part2 聊聊业务3 工业人工智能与消费互联网人工智能的区别3.1 消费互联网中人工智能的应用3.2 为什么如此成熟的消费互联网人工智能扩展到工业场景那么难 4 工业互联网人工智能的发展方向…

wxPython中wx.adv.DatePickerCtrl用法

wx.adv.DatePickerCtrl是一个日期选择组件&#xff0c;支持键盘手工录入日期和弹出日历窗口选择日期两种方式。 一、组件样式 wx.adv.DP_SPIN &#xff1a; 只允许键盘手工录入和组件右侧上下箭头调整日期。 wx.adv.DP_DROPDOWN &#xff1a; 只允许健盘手工录入和组件右侧打开…

CentOS7 编译ffmpeg wasm库

1. 安装 emscripten 1)克隆 emsdk git clone https://github.com/emscripten-core/emsdk.git 2)cd 到emsdk 3)安装,按照官网的步骤(Download and install — Emscripten 3.1.65-git (dev) documentation) 4)验证 注意:如果emcc -v 报错: 提示是python脚本错误,是因为…

Qt Modbus 寄存器读写实例

一.线圈状态寄存器读写 项目效果如下 1. 写单个寄存器 MODBUS_API int modbus_write_bit(modbus_t *ctx, int coil_addr, int status); int addrui->spinBoxwirte_addr->value();int dataui->spinBoxwirte_data->value();int ret modbus_write_bit(mb,addr,d…

【Qt】Qt日志信息处理

Qt日志信息处理 一、介绍二、相关函数的使用介绍1. qInstallMessageHandle2. QtMsgType3. QMessageLogContext 三、一个简单示例 一、介绍 Qt有Info、Debug、Warning、Critical、Fatal五种级别的调试信息。 Info: 提示信息qDebug&#xff1a;调试信息qWarning&#xff1a;警告…

目标检测——X光安检数据集

1. OPIXray数据集&#xff08;2020&#xff09; 2. HIXray数据集&#xff08;2021&#xff09; 3. SIXray数据集&#xff08;2019&#xff09; 4. CLCXray数据集&#xff08;2022&#xff09; 5. PIDray数据集&#xff08;2021&#xff09; 6. GDXray数据集&#xff08;20…

C++-引用,inline,nullptr

一&#xff0c;引用 1.1引用的概念与定义 引用不是新定义⼀个变量&#xff0c;而是给已存在变量取了⼀个别名&#xff0c;编译器不会为引用变量开辟内存空间&#xff0c;它和它引用的变量共用同⼀块内存空间。 引用的使用方式如下&#xff1a; 类型& 引用别名 引用对象…

【带你入门生信】什么是生物信息学

生物信息学 生物信息学&#xff1a;利用应用数学、信息学、统计学和计算机科学&#xff0c;对生物学数据进行搜索&#xff08;收集和筛选&#xff09;、处理&#xff08;编辑、整理、管理和显示&#xff09;及分析&#xff08;计算和模拟&#xff09;&#xff0c;提取生物信息…

一键生成原创文案,轻松成为文案高手的方法

当下时代&#xff0c;文案写作对于各个企业推广产品和服务显得隔外重要。优秀的文案能够吸引用户的注意力&#xff0c;激发购买欲望&#xff0c;从而为企业带来更多的销售机会。然而&#xff0c;对于许多人来说&#xff0c;撰写出一篇吸引人的原创文案可能并不容易。但是&#…