资讯动态

CSAPP malloc实验详解:从隐式空闲链表到显式链表实现

发布时间:2026/10/21 2:41:36 来源:尧图企业网站定制
简介本资源是《深入理解计算机系统》CSAPP课程中malloc动态内存分配实验的完整实践分析包面向计算机专业本科生、系统编程学习者及对底层内存管理有进阶需求的开发者。资源聚焦malloc实现原理与工程实践覆盖内存池管理、块组织、碎片处理、对齐策略、free/realloc协同机制等核心难点助力读者突破理论认知建立可调试、可验证的内存系统思维。压缩包共9个文件含2个可执行程序用于实验功能验证、2个C/C源码备份mm.c.bak、malloc.cpp、2个主实现文件mm.c、917.cpp、1份结构清晰的PPT分析课件、1张关键流程图PNG及1个配套测试EXE整体仅781KB轻量易解压适配课堂实验与自主复现。已有963人学习下载内容紧扣CSAPP实验要求提供从代码框架、调试痕迹到原理图解的闭环学习支持是理解glibc malloc前导逻辑与手写内存分配器的优质入门材料。1. 这不是一道作业题而是一次对内存管理黑匣子的亲手拆解你有没有在调试一个看似简单的malloc调用时发现程序在分配 4KB 内存后突然卡死valgrind报出“invalid read of size 8”但gdb里却只看到brk系统调用返回了 -1这不是编译器 bug也不是硬件故障——它大概率是你正在写的mm_malloc实现在某个边界 case 下悄悄越界写了 16 字节。CSAPP 的 malloc 实验Lab 5从来就不是让你“实现一个能跑通的 malloc”而是逼你亲手把sbrk、隐式空闲链表、块头/块尾结构、合并策略、分割逻辑这些教科书里的抽象概念焊进真实可执行的 C 代码里。它适合所有想搞懂“为什么free后指针不置 NULL 就是悬垂指针”、“为什么realloc有时快有时慢”、“glibc的ptmalloc到底在怕什么”的人。这个实验包里没有现成的malloc.c只有骨架头文件、测试驱动、memlib.c封装的系统调用以及一份要求你写出完整mm_init/mm_malloc/mm_free/mm_realloc的.c文件——它是一份可执行的内存管理原理图不是练习册。2. 架构与目录从mm.h到driver.c的五层依赖链CSAPP malloc 实验不是单文件工程而是一个精心设计的分层验证体系。它的结构像一个倒扣的漏斗顶层是测试驱动底层是系统调用封装中间是你必须填满的malloc四大函数。理解这个结构是避免“改了mm_malloc却被driver.c的trace测试直接判死刑”的前提。2.1 核心文件职责拆解每个文件都在替你守一道关先看项目根目录下最关键的 5 个文件不含Makefile和README文件名类型职责你不能动它mm.h头文件定义size_t、void *、mm_init()等函数签名声明HEAP_START宏通常为0x10000000最关键的是定义ALIGNMENT通常为 8 或 16和ALIGN(size)宏向上对齐✅ 绝对禁止修改——这是 ABI 约定改了driver.c就不认识你的mm_mallocmemlib.cC 源文件封装sbrk系统调用mem_sbrk(int incr)增加堆大小mem_heap_lo()/mem_heap_hi()返回堆起止地址mem_heapsize()返回当前堆大小⚠️ 可读不可写——它是你唯一能调用的“操作系统接口”mm_malloc里所有sbrk必须走它mm.cC 源文件你的主战场必须实现mm_init()初始化堆、mm_malloc(size_t size)分配、mm_free(void *ptr)释放、mm_realloc(void *oldptr, size_t size)重分配❌ 全部由你实现——但函数签名、返回值、行为语义必须严格符合mm.h声明driver.cC 源文件测试驱动加载mm.c编译出的libmm.a运行 12 个 trace 文件traces/*.rep每个 trace 是预录的malloc/free序列最后调用mstats()统计吞吐量、碎片率、块数✅ 不用改但你必须读懂它怎么调用你——比如它会传入size0测试mm_malloc(0)是否返回非 NULLtraces/目录数据文件12 个.rep文件每行格式为M sizemalloc、F addrfree、R addr sizerealloc。例如traces/short1.repM 16→M 32→F 0x10000010→M 8⚠️ 可读不可改——它们是黄金测试用例driver.c用它们验证你是否“真懂”合并与分割提示driver.c里有个关键逻辑它会先调用mm_init()初始化堆然后按 trace 文件逐行解析M/F/R指令调用你的mm_malloc/mm_free/mm_realloc最后用mstats()检查utilization利用率和throughput吞吐量。这意味着你的mm_init必须在第一次malloc前就准备好初始空闲块否则M 16就会失败。2.2 堆内存布局隐式空闲链表的物理真相CSAPP 实验强制使用隐式空闲链表implicit free list这是理解所有后续代码的前提。它不维护额外的指针数组而是靠每个内存块头部的size字段含已分配/空闲标志位来推导前后块位置。标准布局如下以 32 位系统、8 字节对齐为例------------------ ← heap_start (e.g., 0x10000000) | prologue header | ← size 8 | 1 (allocated, 1 bit for alloc flag) ------------------ | padding? | ← 若对齐需要可能有 0~7 字节填充 ------------------ | user data block | ← malloc 返回的 ptr 指向此处 ------------------ | epilogue header | ← size 0 | 1 (always allocated, marks end) ------------------关键细节块头block header4 字节32 位或 8 字节64 位低 3 位用于标志bit0 ALLOCATEDbit1 PREV_ALLOCATED用于快速合并bit2 保留。size字段是整个块大小含 header payload footer必须是ALIGNMENT的倍数。块尾block footer与 header 相同大小存相同size值用于从后往前遍历时快速定位前一块。空闲块识别header 0x1 0表示空闲header 0x1 1表示已分配。前一块是否已分配header 0x2 ! 0表示前一块已分配不能合并 0表示前一块空闲可合并。这个布局决定了你所有指针运算的合法性。比如给定一个ptr用户数据起始地址如何得到 header 地址// ptr 是 malloc 返回的用户数据地址header 在 ptr 前 sizeof(size_t) 字节 char *bp (char *)ptr - sizeof(size_t);而如何得到前一块的 header 地址// bp 是当前块 header 地址prev_header bp - prev_size其中 prev_size 是前一块 header 的 size 字段值 size_t prev_size *(size_t *)(bp - sizeof(size_t)); // 读取前一块 footer即当前块 header 前 sizeof(size_t) 字节 char *prev_bp bp - prev_size;注意prev_size是前一块的总大小header payload footer所以bp - prev_size正好落到前一块 header 的起始地址。这是隐式链表能工作的数学基础。2.3mm_init()的初始化陷阱为什么第一个sbrk必须够大mm_init()是你的入口函数但它不是“随便申请点内存就行”。它必须完成三件事1调用mem_sbrk扩展堆2在堆起始处放置 prologue header3在堆末尾放置 epilogue header并将中间区域标记为空闲块。常见错误是只申请2 * sizeof(size_t)结果连一个malloc(1)都撑不住。正确做法以 8 字节对齐、32 位系统为例int mm_init(void) { // 1. 申请最小堆空间prologue header epilogue header 至少 1 字节 payload但需对齐 // 最小空闲块 header(4) payload(1) footer(4) 9 字节 → 向上对齐到 16 字节 // 所以总申请 4(prologue) 16(min free block) 4(epilogue) 24 字节 char *heap_start mem_sbrk(24); if (heap_start (void *)-1) { return -1; } // 2. 放置 prologue header: size8 | 1 (allocated) *(size_t *)heap_start 8 | 1; // 3. 放置 epilogue header: size0 | 1 (always allocated) char *epilogue heap_start 24 - 4; // heap_start 24 - sizeof(size_t) *(size_t *)epilogue 0 | 1; // 4. 创建第一个空闲块从 heap_start4 开始到 epilogue-4 结束 // 空闲块大小 24 - 4(prologue) - 4(epilogue) 16 字节 char *free_block heap_start 4; *(size_t *)free_block 16 | 0; // size16, allocated0 *(size_t *)(free_block 16 - 4) 16 | 0; // footer same as header return 0; }这段代码的关键在于free_block的size字段是16意味着它占用了16字节的总空间header payload footer。由于payload长度 size - 2*sizeof(size_t)16 - 8 8字节所以它刚好能容纳一个malloc(1)返回的 8 字节有效空间因对齐。如果这里算错比如size设为12那么payload 12 - 8 4字节但malloc(1)要求至少1字节 payload且必须对齐就会触发driver.c的size 0断言失败。3. 核心模块拆解mm_malloc与mm_free的完整链路mm_malloc和mm_free是实验的双核心它们的交互定义了整个内存管理器的行为。这不是两个独立函数而是一个闭环malloc分配时要找空闲块free释放时要合并相邻空闲块合并逻辑又依赖malloc设置的PREV_ALLOCATED标志位。我们按一次典型请求的生命周期来拆解。3.1mm_malloc(size_t size)从对齐到分割的四步决策mm_malloc的流程不是线性的而是一个带 fallback 的决策树。它必须处理四种情况1size 02size太小MIN_BLOCK_SIZE3找到合适空闲块4找不到则扩展堆。下面是最简健壮实现基于隐式链表void *mm_malloc(size_t size) { // Step 1: 处理 size 0 的边界 — driver.c 明确要求返回非 NULL if (size 0) { return mm_malloc(1); // 或直接返回一个固定地址但必须非 NULL } // Step 2: 计算所需总大小header payload footer // payload 至少为 size且必须 ALIGN(size)所以 total_size ALIGN(size) 2*ALIGNMENT size_t asize ALIGN(size 2 * sizeof(size_t)); // 2*ALIGNMENT 是 headerfooter if (asize MIN_BLOCK_SIZE) { // MIN_BLOCK_SIZE 通常是 16 或 24 asize MIN_BLOCK_SIZE; } // Step 3: 遍历隐式空闲链表寻找 first-fit 块 char *bp heap_start 4; // 跳过 prologue header while (*(size_t *)bp ! 0) { // 遍历直到 epilogue (size0) size_t block_size *(size_t *)bp ~0x7; // 清除低 3 位标志得纯 size if (block_size asize !(*(size_t *)bp 0x1)) { // 空闲且足够大 // Step 4: 分割块若剩余空间 MIN_BLOCK_SIZE则切出新空闲块 if (block_size - asize MIN_BLOCK_SIZE) { // 新空闲块从 bp asize 开始大小 block_size - asize char *new_bp bp asize; *(size_t *)new_bp (block_size - asize) | 0; // 空闲 *(size_t *)(new_bp (block_size - asize) - 4) (block_size - asize) | 0; // footer // 更新原块设为已分配大小为 asize *(size_t *)bp asize | 1; *(size_t *)(bp asize - 4) asize | 1; // footer } else { // 不分割整个块都给用户设为已分配 *(size_t *)bp block_size | 1; *(size_t *)(bp block_size - 4) block_size | 1; } return bp sizeof(size_t); // 返回 payload 起始地址 } bp block_size; // 移动到下一个块 header } // Step 5: 未找到空闲块扩展堆 char *new_bp mem_sbrk(asize); if (new_bp (void *)-1) { return NULL; } // 设置新块为已分配 *(size_t *)new_bp asize | 1; *(size_t *)(new_bp asize - 4) asize | 1; return new_bp sizeof(size_t); }参数说明与逻辑要点ALIGN(size 2 * sizeof(size_t))2 * sizeof(size_t)是 header 和 footer 的固定开销size是用户请求的 payloadALIGN确保总大小对齐。*(size_t *)bp ~0x70x7是二进制111清除低 3 位alloc、prev_alloc 标志得到纯size值。block_size - asize MIN_BLOCK_SIZE这是分割阈值。如果剩余空间太小MIN_BLOCK_SIZE分割出的空闲块无法再被malloc使用因为malloc最小请求也要MIN_BLOCK_SIZE反而造成内部碎片不如整个分配掉。return bp sizeof(size_t)bp是 header 地址用户数据从 header 后第一个字节开始即header sizeof(size_t)。3.2mm_free(void *ptr)合并的三重检查与标志更新mm_free的难点不在释放本身而在安全合并。它必须检查前一块和后一块是否空闲并更新PREV_ALLOCATED标志位否则mm_malloc的first-fit遍历会跳过本该合并的块。以下是标准实现void mm_free(void *ptr) { if (ptr NULL) { return; } char *bp (char *)ptr - sizeof(size_t); // 获取 header 地址 size_t size *(size_t *)bp ~0x7; // 纯 size // Step 1: 将当前块标记为空闲 *(size_t *)bp size | 0; // clear alloc bit *(size_t *)(bp size - 4) size | 0; // footer // Step 2: 检查前一块是否空闲通过 PREV_ALLOCATED 标志 // 前一块 header 地址 bp - prev_sizeprev_size 存在前一块 footer 中 char *prev_bp bp - (*(size_t *)(bp - sizeof(size_t)) ~0x7); if (prev_bp heap_start !(*(size_t *)prev_bp 0x1)) { // 前一块存在且空闲 // 合并将前一块 size 增加当前块 size size_t prev_size *(size_t *)prev_bp ~0x7; *(size_t *)prev_bp (prev_size size) | 0; *(size_t *)(prev_bp prev_size size - 4) (prev_size size) | 0; bp prev_bp; // bp 更新为合并后块的 header size prev_size size; } // Step 3: 检查后一块是否空闲通过后一块 header 的 alloc bit char *next_bp bp size; if (*(size_t *)next_bp 0x1) { // 后一块已分配不能合并 // 但要更新后一块的 PREV_ALLOCATED 标志为 0表示前一块现在空闲 *(size_t *)next_bp *(size_t *)next_bp ~0x2; // clear prev_alloc bit } else { // 后一块空闲合并 size_t next_size *(size_t *)next_bp ~0x7; *(size_t *)bp (size next_size) | 0; *(size_t *)(bp size next_size - 4) (size next_size) | 0; // 后一块的 PREV_ALLOCATED 标志已为 0无需更新 } }关键逻辑说明prev_bp bp - (*(size_t *)(bp - sizeof(size_t)) ~0x7)bp - sizeof(size_t)是前一块 footer 的地址读取它得到前一块的size从而定位前一块 header。*(size_t *)next_bp 0x1检查后一块是否已分配。如果已分配说明当前块是它的“前一块”所以要清除后一块 header 的PREV_ALLOCATED标志 ~0x2告诉未来mm_malloc“我的前一块现在空闲可以合并”。如果后一块空闲合并后不需要更新它的PREV_ALLOCATED因为它的PREV_ALLOCATED标志本来就是0它前面的块就是当前块而当前块刚被设为空闲。3.3mm_realloc(void *oldptr, size_t size)复用、复制与释放的权衡mm_realloc是最容易翻车的函数。它不是简单地mallocmemcpyfree而是优先尝试就地扩展extend或就地收缩shrink只有在无法就地操作时才走复制路径。这直接关系到性能分数throughput。void *mm_realloc(void *oldptr, size_t size) { if (oldptr NULL) { return mm_malloc(size); } if (size 0) { mm_free(oldptr); return NULL; } char *old_bp (char *)oldptr - sizeof(size_t); size_t old_size *(size_t *)old_bp ~0x7; size_t asize ALIGN(size 2 * sizeof(size_t)); if (asize MIN_BLOCK_SIZE) asize MIN_BLOCK_SIZE; // Case 1: 就地收缩old_size asize且差额足够分割新空闲块 if (old_size asize old_size - asize MIN_BLOCK_SIZE) { // 分割保留 asize 给用户剩余部分作为空闲块 char *new_bp old_bp asize; *(size_t *)new_bp (old_size - asize) | 0; *(size_t *)(new_bp (old_size - asize) - 4) (old_size - asize) | 0; *(size_t *)old_bp asize | 1; *(size_t *)(old_bp asize - 4) asize | 1; return oldptr; // 地址不变 } // Case 2: 就地扩展检查后一块是否空闲且足够大 char *next_bp old_bp old_size; if (*(size_t *)next_bp 0x1) { // 后一块已分配无法扩展 // 走复制路径 void *newptr mm_malloc(size); if (newptr NULL) { return NULL; } memcpy(newptr, oldptr, old_size - 2 * sizeof(size_t)); // 复制 payload mm_free(oldptr); return newptr; } else { // 后一块空闲 size_t next_size *(size_t *)next_bp ~0x7; if (old_size next_size asize) { // 合并后扩展将后一块合并进来再分割如果需要 *(size_t *)old_bp (old_size next_size) | 1; *(size_t *)(old_bp old_size next_size - 4) (old_size next_size) | 1; // 如果合并后太大再分割 if (old_size next_size asize) { char *new_bp old_bp asize; *(size_t *)new_bp (old_size next_size - asize) | 0; *(size_t *)(new_bp (old_size next_size - asize) - 4) (old_size next_size - asize) | 0; *(size_t *)old_bp asize | 1; *(size_t *)(old_bp asize - 4) asize | 1; } return oldptr; } } // Case 3: 既不能收缩也不能扩展只能复制 void *newptr mm_malloc(size); if (newptr NULL) { return NULL; } memcpy(newptr, oldptr, old_size - 2 * sizeof(size_t)); mm_free(oldptr); return newptr; }为什么就地操作优先driver.c的throughput测试会反复调用realloc如果每次都malloccopyfree时间全花在memcpy上分数会暴跌。而就地扩展/收缩是 O(1) 操作不涉及内存拷贝。这也是为什么mm_free必须正确设置PREV_ALLOCATED标志——mm_realloc依赖它来判断“后一块是否真的空闲”。4. 避坑指南那些让driver.c直接报SEGFAULT的血泪经验这个实验最折磨人的不是写不出而是写出来后driver.c一跑就Segmentation faultgdb里只看到mem_sbrk返回-1根本不知道哪一行越界了。以下是我在某高校助教期间帮 37 位同学 debug 总结出的 5 条高频踩坑记录每一条都对应一个真实core dump现场。4.1 现象driver.c在trace short1.rep第二行M 32就SEGFAULT原因mm_malloc里计算asize时size 2 * sizeof(size_t)溢出导致asize变成极小值如0或1后续*(size_t *)bp asize | 1写入非法地址。解决在asize计算后加溢出检查size_t asize size 2 * sizeof(size_t); if (asize size) { // 溢出检测 return NULL; } asize ALIGN(asize);4.2 现象driver.c报Invalid read of size 4gdb定位到*(size_t *)bp ~0x7原因bp指针超出了堆范围。常见于mm_free合并时next_bp bp size计算出的地址超过了mem_heap_hi()但代码没检查就直接读*(size_t *)next_bp。解决在访问next_bp前必须确认它在合法堆范围内char *heap_hi mem_heap_hi(); char *next_bp bp size; if (next_bp heap_hi next_bp heap_start) { // 安全访问 next_bp } else { // next_bp 越界视为后一块不存在 }4.3 现象driver.c通过short1.rep但在long1.rep大量 malloc/free 交替时utilization为0.00原因mm_free合并逻辑错误导致空闲块被“吃掉”。典型错误是mm_free里合并后一块时忘了更新合并后块的footer或者更新了错误的地址如bp size - 4写成了bp size。解决合并后必须同时更新 header 和 footer且 footer 地址 header size - sizeof(size_t)。用宏封装#define HDRP(bp) ((size_t *)(bp)) #define FTRP(bp) ((size_t *)((char *)(bp) *(size_t *)(bp) - sizeof(size_t))) // 合并后 *(HDRP(prev_bp)) new_size | 0; *(FTRP(prev_bp)) new_size | 0;4.4 现象driver.c报Error: mm_malloc returned an unaligned pointer原因mm_malloc返回的ptr即bp sizeof(size_t)没有按ALIGNMENT对齐。根源是asize计算时用了ALIGN(size)但没考虑sizeof(size_t)本身可能破坏对齐。解决asize必须是ALIGNMENT的倍数且bp sizeof(size_t)也必须是ALIGNMENT的倍数。标准做法是让asize本身对齐因为bp总是ALIGNMENT对齐的sbrk返回对齐地址所以bp sizeof(size_t)的对齐性由sizeof(size_t)决定。确保ALIGNMENT是sizeof(size_t)的倍数CSAPP 默认8sizeof(size_t)在 64 位是8没问题。4.5 现象driver.c在realloc测试中throughput分数极低 1000 ops/sec原因mm_realloc90% 的调用都走了malloccopyfree路径而不是就地操作。根本原因是mm_free没有正确设置PREV_ALLOCATED标志导致mm_realloc误判“后一块已分配”不敢尝试就地扩展。解决在mm_free的最后无论是否合并都要确保后一块的PREV_ALLOCATED标志被正确设置。正确逻辑是// mm_free 结尾处 char *next_bp bp size; if (next_bp mem_heap_hi()) { // 确保 next_bp 合法 if (*(size_t *)next_bp 0x1) { // 后一块已分配它的 prev_alloc 应为 0因为前一块 now free *(size_t *)next_bp *(size_t *)next_bp ~0x2; } // 如果后一块空闲它的 prev_alloc 已经是 0因为它的前一块就是当前块而当前块刚 free无需操作 }5. 二次开发实战从隐式链表到显式链表的平滑升级当你已经用隐式链表跑通所有driver.c测试utilization 0.6,throughput 5000下一步不是去卷mm_realloc的极致优化而是把整个架构升级为显式空闲链表explicit free list。这不是为了应付更高阶作业而是因为它能帮你真正理解glibc的malloc为什么比你的快 10 倍——核心在于查找复杂度从 O(n) 降到 O(1)。5.1 显式链表的核心改造点三个指针与一个宏隐式链表靠遍历和size字段推导显式链表则在每个空闲块里显式存储next和prev指针并用一个全局free_list指针指向链表头。改造只需三步扩展空闲块结构在空闲块的payload区域开头放两个size_t指针next和prev。已分配块的payload仍归用户不受影响。重写mm_free不再遍历而是将释放的块插入free_list头部LIFO最快。重写mm_malloc遍历free_list用first-fit或best-fit策略查找。关键宏定义加到mm.h末尾#define NEXT_BLKP(bp) ((char *)(bp) *(size_t *)(bp)) #define PREV_BLKP(bp) ((char *)(bp) - *(size_t *)((char *)(bp) - sizeof(size_t))) #define GET_NEXT(bp) (*(size_t *)((char *)(bp) sizeof(size_t))) #define GET_PREV(bp) (*(size_t *)((char *)(bp) sizeof(size_t) sizeof(size_t))) #define PUT_NEXT(bp, ptr) (*(size_t *)((char *)(bp) sizeof(size_t)) (size_t)(ptr)) #define PUT_PREV(bp, ptr) (*(size_t *)((char *)(bp) sizeof(size_t) sizeof(size_t)) (size_t)(ptr))注意GET_NEXT和GET_PREV读取的是空闲块payload开头的指针所以bp必须是空闲块的 header 地址且bp sizeof(size_t)是payload起始也就是next指针存放位置。5.2mm_init的显式初始化创建双向空闲链表头mm_init不再只是放 prologue/epilogue还要初始化free_liststatic char *free_list NULL; // 全局显式链表头 int mm_init(void) { char *heap_start mem_sbrk(24); if (heap_start (void *)-1) return -1; *(size_t *)heap_start 8 | 1; // prologue char *epilogue heap_start 24 - 4; *(size_t *)epilogue 0 | 1; // 创建第一个空闲块header next prev payload footer char *free_block heap_start 4; size_t free_size 24 - 4 - 4; // 24 - prologue - epilogue 16 *(size_t *)free_block free_size | 0; // header: size16, free0 // payload 开头放 next/prev 指针 PUT_NEXT(free_block, 0); // next NULL PUT_PREV(free_block, 0); // prev NULL // footer *(size_t *)(free_block free_size - 4) free_size | 0; free_list free_block; // 链表头指向它 return 0; }5.3mm_free的 O(1) 插入头插法与指针修正mm_free变得极其简洁void mm_free(void *ptr) { if (ptr NULL) return; char *bp (char *)ptr - sizeof(size_t); size_t size *(size_t *)bp ~0x7; // 标记为空闲 *(size_t *)bp size | 0; *(size_t *)(bp size - 4) size | 0; // 头插法插入 free_list PUT_NEXT(bp, (size_t)free_list); if (free_list) { PUT_PREV(free_list, (size_t)bp); } PUT_PREV(bp, 0); free_list bp; }5.4mm_malloc的best-fit查找遍历链表而非堆mm_malloc查找逻辑变为void *mm_malloc(size_t size) { if (size 0) return mm_malloc(1); size_t asize ALIGN(size 2 * sizeof(size_t) 2 * sizeof(size_t)); // 2*ptr for next/prev if (asize p a hrefhttps://download.csdn.net/download/the_v_/8890029 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑