尧图精选

深入解析Linux mkdir函数:从系统调用到mkdir_p实现与权限陷阱

🕒 发布时间:2026/10/1 18:27:06 📁 来源:尧图网络
我最早正儿八经研究mkdir函数是在一个日志收集项目里被代码评审怼过一次之后。当时图省事建目录直接写了system(mkdir -p /var/log/xxx/2026/01/07)评审的同事只问了一句如果这个路径被注入个分号你要怎么处理我当场就沉默了。从那以后凡是程序里需要建目录我都老实走mkdir函数这条路。这篇就把mkdir函数从原型、参数、内核行为、实战算法到坑位排查一次讲透。内容不深但足够实用适合刚接触Linux系统编程的C/C开发者也适合嵌入式工程师以及想让脚本和系统调用之间有个清晰认知的运维同学。1. 为什么程序建目录要选mkdir函数1.1 三种建目录方式各有各的坑在Linux里程序想创建一个目录日常比较常见的做法有三种。第一种是直接执行shell里的mkdir命令这个适合人手动敲比如在服务器上临时建个目录一条命令搞定没人会觉得不对。第二种是在代码里偷懒把命令交给system函数去跑system(mkdir -p /var/log/app/2026/01/07);第三种就是直接调用系统提供的mkdir函数int ret mkdir(/var/log/app/2026, 0755);这三种方式看着都能建目录但区别特别大。我画个表格就知道了对比维度shell mkdirsystem(mkdir -p)mkdir函数是否启动子进程否shell内部处理是forkexec一个/bin/sh否直接陷入内核权限控制靠shell环境默认掩码不可控mode参数可精确控制错误处理只能看退出码退出码粗糙拿不到errno返回-1并设置errno可定位原因安全性人脑判断存在命令注入风险参数就是字节串无注入问题递归创建-p参数原生支持支持需要自己实现核心问题就在system那一行的安全风险和进程开销上。system(mkdir -p path)这种写法一旦path里混入了; rm -rf /之类的字符串等于把整台机器的执行权限直接交了出去。就算不考虑安全每次调用system都会fork一个shell进程出来再让shell去fork一个mkdir进程两次进程创建加两次等待性能损耗对高频路径来说完全不可接受。所以正规的工程代码建目录就该走系统调用也就是mkdir函数本身。1.2 一次mkdir调用内核里发生了什么很多教程只会告诉你mkdir函数的两个参数是什么但不太会讲清楚调用之后内核做了什么。其实理解了内核的处理流程很多坑就能提前避开。从用户态调用mkdir(/tmp/abc, 0755)开始glibc会封装成sys_mkdir系统调用进入内核后经过VFS通用层最后落到具体文件系统的目录操作函数上。以ext4为例它最终会调用ext4_dir_inode_operations里的mkdir回调。在这个过程里内核做的事可以简化成三步分配一个新的inode建立对应的目录结构元数据在父目录的数据块里插入一条新目录项更新父目录的mtime、ctime以及相关的链接计数。这里的核心点是创建目录本质上是在父目录里写入一条目录项而不是简简单单“新建一个文件夹”。这也解释了为什么父目录必须同时具备写权限和执行权限。只有写权限你没法“进入”父目录去增删东西只有执行权限你进得去却没权限改里面的目录项。两者缺一不可。1.3 谁最该把mkdir函数用扎实后端C服务要创建运行时目录日志系统要按天/小时建目录安装程序要初始化目录树嵌入式设备要创建数据分区挂载点。就连Python里的os.mkdir和os.makedirs底层封装的一样是这套系统调用。如果你以后打算看内核模块或者做文件系统过滤、安全审计之类的开发更要把目录相关的操作搞明白。特别是“目录创建”对应的钩子函数挂在inode_operations里和read/write的file_operations完全是两个结构体很多人找半天找不到入口就是栽在这个结构划分上。2. mkdir函数核心细节解析2.1 函数原型、头文件与man手册mkdir函数属于POSIX标准在Linux里的定义长这样#include sys/stat.h #include sys/types.h int mkdir(const char *pathname, mode_t mode);还有一个带目录文件描述符的版本int mkdirat(int dirfd, const char *pathname, mode_t mode);返回值很简单成功返回0失败返回-1并把错误码写到errno里。细节都在errno里等下专门讲。头文件方面sys/stat.h是必须的sys/types.h是为了让mode_t在各平台下都有定义。个别老系统或交叉编译环境下不写sys/types.h编译时可能报mode_t未定义这个看着加就行。想查最权威的说明就直接在机器上敲man 2 mkdir手册里把每个errno都列得很全遇到问题先翻man比在网上搜一堆二手答案高效得多。2.2 权限位的真相mode只是“期望值”mode参数的类型是mode_t本质是一串权限位。目录的权限位分三组拥有者、所属组、其他人每组又分读r、写w、执行x三个位。有一点和文件不同目录的执行权限x代表的是“能否进入这个目录”。一个目录就算权限是666可读可写如果没有x权限你照样进不去ls能看到目录里的名字列表但一访问具体文件就提示Permission denied。这个特性经常被新手忽略。常用权限值我整理了一个表mode值rwx表示典型用途0700rwx------私有目录仅本人可进0750rwxr-x---本人组内可进其他人不可进0755rwxr-xr-x最常见所有人可读可执行仅属主可写0770rwxrwx---组内协作目录0775rwxrwxr-x组内可写其他人只读0777rwxrwxrwx所有用户可读写执行危险别乱用这里说一个最容易踩的坑mode参数并不是最终落到磁盘上的权限值。真正生效的权限要跟进程的umask做一次“按位取反再与”的运算也就是final_mode mode ~umask。举例来说你的进程umask是0022调用mkdir(/tmp/aaa, 0777)最终目录权限是0777 0755 0755。你可能觉得传了0777就是0777结果一看目录权限是755一脸懵。反过来如果umask是0027那0777最终变成0750。所以如果你的程序确实需要777这样的权限就三条路在进程初始化阶段调用umask(0)把掩码清掉创建之后主动调用chmod去修正权限接受默认掩码的影响把mode参数按期望值传大多数时候07 55就是个不错的默认值。一般项目里我倾向于第三种尽量不去动进程的umask否则容易影响其他模块的文件创建行为。2.3 mkdirat更安全的目录创建姿势mkdirat和mkdir的区别在于第一个参数不是路径前缀而是一个目录的文件描述符dirfd。配合AT_FDCWD可以表达“相对于当前工作目录”也可以把路径锚定到一个已经打开的目录里。int dfd open(/var/log/app, O_RDONLY | O_DIRECTORY); if (dfd 0) { // 处理错误 } if (mkdirat(dfd, 2026/01, 0755) 0) { // 处理创建错误 }这种写法最大的价值在于规避TOCTOU竞态问题。传统mkdir里传绝对路径系统内部解析路径时路径中间某个组件可能被攻击者替换成符号链接。而mkdirat配合openat这类*at系列函数可以先把目录打开拿到稳定的文件描述符再基于这个fd去操作路径解析范围被限制在已打开的目录内相对安全很多。在写安全工具、沙箱、审计程序时这个函数几乎是标准姿势。不过要注意mkdirat和mkdir一样只创建一级目录不存在“一次创建多级目录”的魔法。多级目录依然要自己逐层处理。3. 从单目录到多目录一个能直接抄走的mkdir_p实现3.1 单级目录创建基础版本与使用边界先看一个最基础的创建单级目录的代码#include stdio.h #include string.h #include errno.h #include sys/stat.h #include sys/types.h int main(void) { if (mkdir(/tmp/hello, 0755) 0) { fprintf(stderr, mkdir failed: %s\n, strerror(errno)); return 1; } return 0; }这个版本只能做一件事创建一级目录。而且前提是它的父目录必须已经存在。比如/tmp存在/tmp/a不存在你执行mkdir(/tmp/a/b, 0755)系统会返回-1errno是ENOENT意思是“No such file or directory”。因为内核里的mkdir语义很简单只负责创建路径中最后一级目录它不会帮你把a先建出来。3.2 递归实现mkdir_p核心思路拆解既然内核不提供“创建多级目录”的原生能力那我们就自己实现一个mkdir_p。核心思路其实一句话就能讲清楚先尝试创建完整路径。如果返回ENOENT说明父目录不存在那就先递归创建父目录再回头创建当前目录。实现时要注意几个字符串处理的细节我直接给一版完整可编译的代码#include stdio.h #include string.h #include errno.h #include sys/stat.h #include sys/types.h static int mkdir_p_recursive(char *path, mode_t mode) { char *slash; int ret; /* 先清理掉路径末尾的斜杠避免空组件干扰 */ size_t len strlen(path); while (len 1 path[len - 1] /) { path[len - 1] \0; len--; } /* 先直接尝试创建当前这一级 */ ret mkdir(path, mode); if (ret 0) { return 0; } /* EEXIST可能目录已经存在也可能是同名文件需要进一步判断 */ if (errno EEXIST) { struct stat st; if (stat(path, st) 0 S_ISDIR(st.st_mode)) { return 0; } errno ENOTDIR; return -1; } /* ENOENT父目录不存在找到最后一个斜杠逐级创建 */ if (errno ENOENT) { slash strrchr(path, /); if (slash NULL) { /* 没有斜杠说明当前目录下直接创建失败且不是不存在的问题 */ return -1; } *slash \0; /* 如果截出来的父目录是空串说明路径是以/开头保留根路径 */ if (slash path) { *slash /; } if (mkdir_p_recursive(path, mode) 0) { return -1; } *slash /; return mkdir(path, mode); } return -1; } int mkdir_p(const char *dir, mode_t mode) { char *tmp strdup(dir); if (tmp NULL) { errno ENOMEM; return -1; } int ret mkdir_p_recursive(tmp, mode); free(tmp); return ret; }有以下几个细节值得单独说一下为什么要strdup因为递归过程中要在原字符串上做截断操作而调用方传入的往往是字符串常量直接改会触发段错误。为什么要处理尾部斜杠如果你传入/tmp/a/系统其实也能处理但我们在截断找最后一级目录时斜杠会被当成空路径组件可能导致逻辑错乱。所以在函数入口先统一把末尾斜杠去掉。为什么截断后要分情况处理slash和path相等的情况因为绝对路径/abc它的父路径截断后是空串得恢复成/否则递归就变成创建空字符串目录了。递归深度通常不会有问题因为正常业务路径层级很少超过几十层。但如果你在做偏底层的工具处理的是不可信输入那几千层深度的恶意路径是有可能让递归爆栈的所以更稳妥的做法是改成迭代。3.3 迭代实现更稳的mkdir_p迭代实现的思路是从路径最前面开始不断用斜杠切出前缀每个前缀都尝试创建一次。这样即使路径再深也只是循环次数多一点不会消耗栈空间。#include stdio.h #include string.h #include errno.h #include sys/stat.h int mkdir_p_iter(const char *dir, mode_t mode) { char tmp[PATH_MAX]; size_t len strlen(dir); size_t i; if (len 0 || len sizeof(tmp)) { errno (len 0) ? EINVAL : ENAMETOOLONG; return -1; } strcpy(tmp, dir); /* 去掉末尾斜杠 */ while (len 1 tmp[len - 1] /) { tmp[len - 1] \0; len--; } /* 逐级处理绝对路径的根 */ for (i 1; i len; i) { if (tmp[i] / || tmp[i] \0) { char ch tmp[i]; tmp[i] \0; if (tmp[0] ! \0) { if (mkdir(tmp, mode) 0) { if (errno ! EEXIST) { return -1; } struct stat st; if (stat(tmp, st) 0 || !S_ISDIR(st.st_mode)) { errno ENOTDIR; return -1; } } } if (ch \0) { break; } tmp[i] /; } } return 0; }这个版本有个好处它对“已是目录”的情况天然做了幂等处理重复调用不会报错可以当ensure_dir来用。3.4 errno速查与EEXIST的类型判断mkdir返回-1之后真正排错依赖errno。我整理了一张速查表排查时可以直接对着看errno英文含义触发场景排查方向EEXISTFile exists目录或同名文件已存在用stat判断已存在的是目录还是文件ENOENTNo such file or directory父目录不存在逐级检查父路径是否存在EACCESPermission denied父目录缺少写或执行权限ls -ld 父目录权限ENOTDIRNot a directory路径中间某个组件是普通文件stat检查每个路径组件EROFSRead-only file system文件系统只读挂载mount查看挂载选项ENOSPCNo space left on device磁盘满或inode耗尽df -h / df -iELOOPToo many symbolic links路径中符号链接嵌套过深检查是否有循环链接ENAMETOOLONGFile name too long路径总长度超过PATH_MAXstrerror后确认路径长度EFAULTBad address指针非法通常是野指针检查入口参数重点说说EEXIST。很多人觉得“返回EEXIST就说明目录已经存在直接当成成功就行”这个想法很危险。如果路径上已经存在一个同名普通文件mkdir同样会返回EEXIST。这时候你要是直接当成“目录已存在”继续往下写文件后面的打开操作就会因为“目标不是目录”而报错错误信息还很诡异。所以EEXIST出现时必须手动用stat确认目标到底是不是目录if (errno EEXIST) { struct stat st; if (stat(path, st) 0 S_ISDIR(st.st_mode)) { return 0; /* 确实是目录幂等成功 */ } /* 是普通文件不能继续 */ errno ENOTDIR; return -1; }带符号链接的情况还要更谨慎。stat会跟随符号链接所以它看到的是链接指向目标的类型。如果你不允许符号链接就要用lstat。到底用哪个取决于你的业务需求。4. 真实项目里踩过的mkdir坑4.1 权限不对八成是umask在悄悄改你我印象很深的一次排障是一个服务程序在启动时创建数据目录代码里明明写的mkdir(path, 0777)运维反馈说创建出来的目录死活是0755。我当时让他在服务启动脚本里打印umask果然启动脚本里执行过umask 022这个掩码被传递给了子进程最终权限就被压成了0755。这个问题的解法前面说过要么进程启动时主动umask(0)清掉掩码要么创建后调用chmod强行修正。但我个人更推荐的做法是不要追求0777按业务最小需求传0755或者0750。目录权限本来就是越收越紧比较好非要0777的场景少之又少。4.2 路径分隔符、空路径与极端情况处理路径时我总结过几个容易忽略的极端情况尾部斜杠mkdir(/tmp/a/, 0755)和mkdir(/tmp/a, 0755)是等价的内核会处理尾部斜杠。但如果我们自己写多级目录创建逻辑最好统一先去掉连续斜杠/tmp//a///b在内核里会被折叠解析但不同文件系统行为可能略有差异稳妥做法是调用前做一次路径归一化空字符串mkdir(, 0755)的返回行为在不同平台不完全一致不要依赖这种写法入口直接拦掉相对路径如果程序中途chdir过相对路径的解析结果可能出乎意料。多线程程序里chdir是全进程共享的影响面更大。要么统一用绝对路径要么用openatmkdirat把目录句柄锚定住。4.3 嵌入式与跨平台差异嵌入式Linux的环境跟桌面服务器差异很大。很多板子上用的是musl而不是glibc头文件兼容性偶尔会冒出来比如某些版本需要额外包含fcntl.h才能拿到AT_FDCWD的定义。还有文件系统类型squashfs这类只读文件系统上直接调用mkdir会返回EROFS。uClinux这种没有内存管理单元的环境fork派生的场景会受限但它依然有mkdir系统调用所以更要直接用mkdir函数而不是system方案。如果代码将来要移植到Windows要留意Windows的C运行时提供的是_mkdir带下划线而且mode参数基本被忽略需要条件编译做一层适配#ifdef _WIN32 #include direct.h #define mkdir(path, mode) _mkdir(path) #endif跨平台代码里这种宏替换比较常见但要注意别在Linux下也误伤到结构体的mkdir成员建议封装函数而不是直接define。4.4 排查mkdir问题的三板斧遇到mkdir相关的问题我的固定排查流程就三步第一步用strace看系统调用现场strace -f -e tracemkdir, mkdirat ./your_programstrace能直接打出程序调用了什么路径、mode传了什么值、系统返回了什么errno很多“代码看着没问题但就是出错”的场景到这里就水落石出了。第二步配合ls和stat看实际文件系统状态ls -ld /path/to/parent stat /path/to/target重点确认父目录权限、目标是否被同名文件占用、目录链接数是否正常。第三步确认文件系统本身可写mount | grep /your_path df -h /your_path df -i /your_path只读挂载、磁盘满、inode耗尽这几个问题不动用df是很难从代码层面判断出来的。5. 从mkdir看系统调用与VFS的设计边界5.1 标准库函数和系统调用的边界很多人会把“标准库函数”和“系统调用”混为一谈其实它们是两层东西。mkdir函数本身是libc提供的封装它负责把参数整理好、触发syscall指令进入内核、再把内核返回的错误码写入errno。应用层不直接去写syscall指令是因为libc做了很多跨架构、跨内核版本的兼容处理普通程序没必要重新造轮子。从性能角度说系统调用有上下文切换成本虽然现代CPU优化得已经很轻量但高频调用依然不可忽视。所以如果你要在循环里创建大量目录尽量优化算法减少调用次数而不要指望一次mkdir能创建很多层目录——它做不到。5.2 目录操作和file_operations别挂错钩子这一点主要针对想做内核层扩展的同学。文件系统里处理“打开文件、读写文件”的钩子函数都在struct file_operations里比如read、write、open、release。但“创建目录”这件事不在这里面它对应的钩子是struct inode_operations里的mkdir回调。我之前看一些内核模块想去拦截某个目录的创建行为翻了一晚上file_operations硬是没找到入口后来才意识到要看inode_operations。这是两个完全不同的结构体。如果你做的是审计或者访问控制类工作想拦截mkdir就好好理解inode_operations如果只是想过滤文件内容的读写再回去啃file_operations。方向错了代码写得再漂亮也没用。5.3 封装一个工程级目录创建接口的几点建议把mkdir_p封装成公共函数放进项目基础库时我一般会遵循几个原则函数命名直接用ensure_dir或者mkdir_p语义直观参数用const char *path加mode_t mode不用全局变量传参返回值统一为0或-1errno保留原始错误码方便上层记录日志内部做幂等重复调用不报错入口处去掉尾部斜杠统一路径格式注释里写明“该函数不是原子操作多进程并发创建同一目录时EEXIST分支必须做目录类型判断”。另外如果业务对目录的原子性有要求比如安装程序要把整个目录树布置好再对外可见那就不要用“先创建目录再逐个写文件”的裸方案而是先创建到一个临时目录最后用rename整体切换。mkdir_p做的是增量搭建不做事务保证这个边界自己心里要有数。我自己的习惯是把mkdir_with_parents当成公共工具函数放在每个项目的base库里统一处理斜杠、EEXIST和umask。调试这种问题最有效的一招是strace——有一次一个同事的目录权限怎么都不对我让他跑了一句strace -f -e tracemkdir ./bin马上看到系统调用层传进来的mode明明是0777但进程umask把结果压成了0755问题一秒定位。所有看起来玄学的文件权限问题扒到系统调用这一层基本都能真相大白。最后再分享一个小技巧如果你写代码时不确定某个路径最终会被挂载在什么文件系统上就在mkdir之后顺手再用access(path, R_OK | W_OK | X_OK)验一下权限不要假设。这个习惯救过我很多次。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →