Files
STM32F4-Base/scripts/check_coding_standard.py
wandering1 6505cef9df feat(门禁): coding-standard 加 CODE-RULE-20 禁 C 库堆分配(销 §4.8 空白 ②)
规则:在**掩码后**的文本上匹配 malloc/calloc/realloc/strdup/strndup/
free/aligned_alloc 调用。三条判据都是踩过坑之后定的——
- 大小写敏感:pvPortMalloc / vPortFree(唯一允许的配对)不会误报;
- 词边界 \b:dhara_nand_is_free(、buf_alloc( 这类同形后缀不误报;
- 用掩码:注释与字符串字面量里写 malloc 不报(规范示例就踩过这种)。
覆盖名比 AGENTS 列的五枚多带上 strndup / aligned_alloc——同族同类,
漏一个就等于留后门。

出处不在规范 V1.0,在平台约束:MicroLIB 的 C 堆由 startup_stm32f407xx.s
的 Heap_Size EQU 0x200 只有 512 B,KB 级分配必返 NULL,而模拟器(宿主
CRT 堆)完全正常、**照不出来**——配置引擎 2026-09-18 那次堆分配纠偏与
lftpd 的 malloc 隐患都是**先上线才发现**的那类,靠评审等于没管。

落地前按 门禁说明/coding-standard.md §8 的纪律先全库普查:钩子范围内
C/H 共 151 个文件,命中**只有** simulator/sim/cfg_file_store_sim.c 两行,
即 cfg_store_alloc/cfg_store_free = **平台分配后端的宿主侧实现**(设计依据
写在 docs/子系统设计/配置文件设计说明.md 的"MCU 端 = pvPortMalloc、
模拟器端 = 宿主 CRT")。所以零基线、直接硬拦截;豁免用脚本里的
HEAP_ALLOC_ALLOWLIST(**文件路径 + 函数名**双条件),刻意不做目录级豁免:
下次谁要碰 C 堆就得往这张表加一行,评审看得见。

自证:临时样例文件里 malloc/calloc/free 各报一处,pvPortMalloc/vPortFree/
buf_alloc(、注释、字符串五种陷阱形态全部不误报;
pre-commit run coding-standard --all-files → **Passed**(= 全库零存量)。

顺带修三处口径不一致:
- 规范 V1.0 §6.1 的示例自己写着 malloc/free(通用 C 模板遗留)→ 已在示例
  下加"本仓例外,别照抄"批注,事实源指向 AGENTS.md 平台约束;
- docs/门禁说明/mem-budget.md 里"这条纪律只靠 AGENTS 铁律与评审"已过期 →
  更正为由 CODE-RULE-20 机器拦截;
- AGENTS.md 平台约束条加"已机器拦截 + 唯一豁免"一句;scripts/README.md
  补登 check_test_coverage.py(上一个提交漏登记)。

另:本次一并修好上一处编辑事故——报告 §4.8 曾被我的脚本调用截成半句
(只落第一行、丢掉 malloc 那半句),已重写为"两处空白均已闭合"。

自查:pre-commit run --files(9 个改动文件)适用项全 Passed;
check_doc_links.py --all = 35 处,与主干同数(本分支贡献 0);
check_ids.py --strict 通过(裸编号 P0-1 改平实说法,避免记成新账);
9 个文件 BOM/CR/U+FFFD/行尾与 HEAD 逐项一致。本轮零 .c/.h 改动,未跑
@build、未占 build/bench 锁;分支已 rebase 到 v2-on-ui,覆盖率脚本全量
回归仍 16/16、line=2552/2893 不变。
2026-10-23 23:44:46 +08:00

339 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
C 语言代码规范检查(《嵌入式C语言代码规范_V1.0》可机械化校验的规则)
由 pre-commit 调用:python scripts/check_coding_standard.py <file...>,发现违规退出码 1。
检查项(括号内为规范条款号):
CODE-RULE-01 (2.3.1) .h 文件必须有 XXX_H 格式的头文件保护宏(禁止 __ 双下划线前缀)
CODE-RULE-02 (5.2) 文件开头必须有文件头注释(含 模块名称 / 作者)
CODE-RULE-03 (3.4.1) #define 宏名必须全大写 + 下划线 + 数字
CODE-RULE-04 (3.5.1) typedef 类型名必须以 _t 结尾
CODE-RULE-05 (6.3.2) switch 必须包含 default 分支
CODE-RULE-06 (7.5) 禁用不安全字符串/输入函数 strcpy/strcat/sprintf/gets/scanf/strtok
CODE-RULE-07 (6.3.3) 禁止使用 goto
CODE-RULE-08 (4.1.1) 禁止 Tab 缩进(使用 4 个空格)
CODE-RULE-09 (5.3) 函数定义必须有函数注释(含「函数功能」)
CODE-RULE-10 (3.3.1) 函数名必须全小写(HAL_ 厂商 API 回调名豁免)
CODE-RULE-11 (2.3.5) 禁止相对/绝对路径 include
CODE-RULE-12 (7.6) 禁止可变参数函数(...)
CODE-RULE-13 (7.7) 禁止递归函数
CODE-RULE-15 (6.1.4) 文件作用域指针变量必须初始化为 NULL
CODE-RULE-16 (3.2.2) 文件作用域非 static 全局变量必须 g_ 前缀
CODE-RULE-17 (2.2) 非 static 函数必须在配对 .h 中声明(HAL_ 豁免;无配对头则跳过)
CODE-RULE-18 (6.3.1) 禁止 Yoda 条件(字面量/常量必须在运算符右侧)
CODE-RULE-19 (4.4.2) 函数定义左大括号必须与右括号同行
CODE-RULE-20 (平台约束) 禁止 C 库堆分配 malloc/calloc/realloc/strdup/free
语义类规则(函数长度、单一职责、错误处理等)无法机械可靠判定,不在此脚本范围。
"""
import os
import re
import sys
# 顶层函数定义行:类型 名称 ( 参数 ),可选同行左大括号
FUNC_DEF_RE = re.compile(
r'^[A-Za-z_][A-Za-z0-9_\s\*]*\s+[a-z_][a-z0-9_]*\s*\([^;{]*\)\s*\{?\s*$'
)
CTRL_RE = re.compile(r'^\s*(if|for|while|switch|return|do|else)\b')
C_KEYWORDS = {'if', 'for', 'while', 'switch', 'do', 'else', 'return',
'sizeof', 'struct', 'union', 'enum'}
NAME_PAREN_RE = re.compile(r'([A-Za-z_][A-Za-z0-9_]*)\s*\(')
# CODE-RULE-20 的例外:平台分配后端的**宿主侧实现**(AGENTS.md「只用 pvPortMalloc/vPortFree,
# 或把分配收进平台后端」说的就是这种收口点)。只点名到文件 + 只放行这两个函数名,
# 不做目录级豁免——新增一个想 malloc 的文件必须显式加进这里,评审时看得见。
HEAP_ALLOC_ALLOWLIST = {
'simulator/sim/cfg_file_store_sim.c': frozenset({'malloc', 'free'}),
}
def _mask(text):
"""注释与字符串字面量替换为等长空白(保留换行,行号不变),防止误报。
单次组合正则扫描,避免字符串内的 // 或注释内的引号造成顺序误掩。
"""
def _repl(m):
return ''.join(c if c == '\n' else ' ' for c in m.group(0))
pat = re.compile(r'"(\\.|[^"\\\n])*"|/\*.*?\*/|//[^\n]*', re.S)
return pat.sub(_repl, text)
def _line(text, pos):
return text[:pos].count('\n') + 1
def _fn_defs(masked):
"""线性扫描找函数定义 name(...) {,返回 [(name, name行, body, pre, 大括号行, 右括号行)]。
body 为定义大括号内文本,pre 为名称前 200 字符(用于判定 static)。
"""
out = []
n = len(masked)
for m in NAME_PAREN_RE.finditer(masked):
name = m.group(1)
if name in C_KEYWORDS or name == 'defined':
continue
ls = masked.rfind('\n', 0, m.start()) + 1
if masked[ls:].lstrip()[:1] == '#':
continue
i = m.end() - 1 # '(' 下标
depth, j = 0, i
ok = False
while j < n:
c = masked[j]
if c == '(':
depth += 1
elif c == ')':
depth -= 1
if depth == 0:
ok = True
break
elif c in '{};':
break
j += 1
if not ok:
continue
k = j + 1
while k < n and masked[k] in ' \t\r\n':
k += 1
if k >= n or masked[k] != '{':
continue
depth, b = 0, k
while b < n:
if masked[b] == '{':
depth += 1
elif masked[b] == '}':
depth -= 1
if depth == 0:
break
b += 1
out.append((name, _line(masked, m.start()), masked[k + 1:b],
masked[max(0, m.start() - 200):m.start()],
_line(masked, k), _line(masked, j)))
return out
def check_file(path):
with open(path, 'r', encoding='utf-8', errors='replace') as f:
text = f.read()
lines = text.splitlines()
v = [] # (行号或'-', 规则, 说明)
# CODE-RULE-01 (2.3.1) .h 头文件保护宏
if path.endswith('.h') and \
not re.search(r'#\s*ifndef\s+(?<!_)[A-Z][A-Z0-9_]*_H\b', text):
v.append(('-', 'CODE-RULE-01 2.3.1', '缺少 XXX_H 格式的头文件保护宏(禁止 __ 前缀)'))
# CODE-RULE-02 (5.2) 文件头注释
head = '\n'.join(lines[:60])
if '模块名称' not in head and '作者' not in head:
v.append(('-', 'CODE-RULE-02 5.2', '缺少文件头注释(模块名称 / 作者)'))
# CODE-RULE-03 (3.4.1) 宏名全大写
for m in re.finditer(r'^\s*#\s*define\s+([A-Za-z_][A-Za-z0-9_]*)', text, re.M):
if re.search(r'[a-z]', m.group(1)):
ln = text[:m.start()].count('\n') + 1
v.append((ln, 'CODE-RULE-03 3.4.1', '宏名须全大写+下划线: %s' % m.group(1)))
# CODE-RULE-04 (3.5.1) typedef 类型名 _t 后缀
for m in re.finditer(r'typedef\s+(?:struct|enum|union)\s*\{.*?\}\s'
r'([A-Za-z_][A-Za-z0-9_]*)\s*;', text, re.S):
if not m.group(1).endswith('_t'):
ln = text[:m.start()].count('\n') + 1
v.append((ln, 'CODE-RULE-04 3.5.1', 'typedef 类型名须以 _t 结尾: %s' % m.group(1)))
for m in re.finditer(r'typedef\s+(?:unsigned\s+|signed\s+)?'
r'(?:char|short|int|long|float|double)\s+\*?\s*'
r'([A-Za-z_][A-Za-z0-9_]*)\s*;', text):
if not m.group(1).endswith('_t'):
ln = text[:m.start()].count('\n') + 1
v.append((ln, 'CODE-RULE-04 3.5.1', 'typedef 类型名须以 _t 结尾: %s' % m.group(1)))
# CODE-RULE-05 (6.3.2) switch 必须有 default
for m in re.finditer(r'\bswitch\s*\(', text):
i = text.find('{', m.start())
if i == -1:
continue
j, d = i, 0
while j < len(text):
if text[j] == '{':
d += 1
elif text[j] == '}':
d -= 1
if d == 0:
break
j += 1
if not re.search(r'\bdefault\s*:', text[i:j + 1]):
ln = text[:m.start()].count('\n') + 1
v.append((ln, 'CODE-RULE-05 6.3.2', 'switch 缺少 default 分支'))
# CODE-RULE-06 (7.5) 不安全字符串/输入函数
for m in re.finditer(r'\b(strcpy|strcat|sprintf|gets|scanf|strtok)\s*\(', text):
ln = text[:m.start()].count('\n') + 1
v.append((ln, 'CODE-RULE-06 7.5', '不安全字符串/输入函数: %s (改用安全版本)'
% m.group(1)))
# CODE-RULE-07 (6.3.3) 禁止 goto
m = re.search(r'\bgoto\s+[A-Za-z_]\w*', text)
if m:
ln = text[:m.start()].count('\n') + 1
v.append((ln, 'CODE-RULE-07 6.3.3', '禁止使用 goto'))
# CODE-RULE-08 (4.1.1) 禁止 Tab
if '\t' in text:
for idx, line in enumerate(lines, 1):
if '\t' in line:
v.append((idx, 'CODE-RULE-08 4.1.1', '禁止 Tab 缩进(使用 4 个空格)'))
break
# CODE-RULE-09 (5.3) 函数定义必须有函数注释
for idx, line in enumerate(lines, 1):
if (FUNC_DEF_RE.match(line) and '=' not in line and not CTRL_RE.match(line)):
window = '\n'.join(lines[max(0, idx - 26):idx - 1])
if '函数功能' not in window:
v.append((idx, 'CODE-RULE-09 5.3', '函数定义缺少函数注释(函数功能): %s'
% line.strip()[:50]))
masked = _mask(text)
mlines = masked.splitlines()
defs = _fn_defs(masked)
# CODE-RULE-10 (3.3.1) 函数名必须全小写(HAL_ 厂商 API 回调名豁免)
for name, ln, _body, _pre, _bl, _pl in defs:
if re.search(r'[A-Z]', name) and not name.startswith('HAL_'):
v.append((ln, 'CODE-RULE-10 3.3.1', '函数名须全小写: %s' % name))
# CODE-RULE-11 (2.3.5) 禁止相对/绝对路径 include(基于原始文本,字符串掩码会吃掉引号)
# 规则本意 = 禁"用路径找文件":`#include "../../x.h"`、`#include "App/foo/x.h"`、
# `#include <C:/x.h>`、`#include <..\..\x.h>`。
# ⚠ 2026-10-11 细化:原实现把**任何含 `/` 的 include** 都判违规,于是
# `<sys/stat.h>` 这类**尖括号系统头**也被误报(模拟器侧两处即中招)。
# 现按形态区分:尖括号 = 系统/依赖头,允许带子目录(由 -I 解析);
# 引号 = 项目头,必须裸文件名(带 `/` 或 `\` 即违规)。`..`/绝对路径两种形态都禁。
for m in re.finditer(r'^\s*#\s*include\s*([<"])([^">]+)[">]', text, re.M):
form, inc = m.group(1), m.group(2)
bad = ('..' in inc or '\\' in inc or inc.startswith('/')
or re.match(r'^[A-Za-z]:', inc) is not None)
if form == '"' and '/' in inc:
bad = True
if bad:
v.append((_line(text, m.start()), 'CODE-RULE-11 2.3.5',
'禁止相对/绝对路径 include: %s' % inc))
# CODE-RULE-12 (7.6) 禁止可变参数函数
for m in re.finditer(r'\(\s*[^();{}]*\.\.\.[^();{}]*\)\s*[;{]', masked):
v.append((_line(masked, m.start()), 'CODE-RULE-12 7.6', '禁止可变参数函数(...)'))
# CODE-RULE-13 (7.7) 禁止递归函数
for name, ln, body, _pre, _bl, _pl in defs:
if re.search(r'\b%s\s*\(' % re.escape(name), body):
v.append((ln, 'CODE-RULE-13 7.7', '递归函数: %s' % name))
# CODE-RULE-15 (6.1.4) 文件作用域指针变量必须初始化为 NULL
for idx, line in enumerate(mlines, 1):
if not line or line[0] in ' \t':
continue
if not line.rstrip().endswith(';') or '(' in line or '#' in line:
continue
first = line.split(None, 1)[0]
if first in ('extern', 'typedef', 'struct', 'enum', 'union', 'static',
'return', 'if', 'for', 'while', 'switch', 'do', 'else'):
continue
if '*' not in line or '=' in line:
continue
v.append((idx, 'CODE-RULE-15 6.1.4', '文件作用域指针变量须初始化为 NULL: %s'
% line.strip()[:50]))
# CODE-RULE-16 (3.2.2) 文件作用域非 static 全局变量必须 g_ 前缀
for idx, line in enumerate(mlines, 1):
if not line or line[0] in ' \t' or line[0] in '}]':
continue
if not line.rstrip().endswith(';') or '(' in line or '#' in line:
continue
first = line.split(None, 1)[0]
if first in ('extern', 'typedef', 'struct', 'enum', 'union', 'static',
'const', 'volatile', 'return', 'if', 'for', 'while',
'switch', 'do', 'else'):
continue
m2 = re.search(r'([A-Za-z_][A-Za-z0-9_]*)\s*(\[[^\]]*\])?\s*;\s*$', line)
if not m2:
continue
if not m2.group(1).startswith('g_'):
v.append((idx, 'CODE-RULE-16 3.2.2', '非 static 全局变量须 g_ 前缀: %s'
% line.strip()[:50]))
# CODE-RULE-17 (2.2) 非 static 函数必须在配对头文件声明(HAL_ 豁免;无配对头则跳过)
if path.endswith('.c'):
hpath = path[:-2] + '.h'
if os.path.exists(hpath):
with open(hpath, 'r', encoding='utf-8', errors='replace') as f:
htext = f.read()
for name, ln, _body, pre, _bl, _pl in defs:
if name.startswith('HAL_'):
continue
seg = pre[pre.rfind(';') + 1:]
seg = seg[seg.rfind('}') + 1:]
if re.search(r'\bstatic\b', seg):
continue
if re.search(r'\b%s\s*\(' % re.escape(name), htext):
continue
v.append((ln, 'CODE-RULE-17 2.2', '非 static 函数须在配对头文件声明: %s' % name))
# CODE-RULE-18 (6.3.1) 禁止 Yoda 条件(常量在右)
for m in re.finditer(r'\b(0[xX][0-9a-fA-F]+(?:[uUlL]+)?|\d+(?:[uUlL]+)?'
r'|NULL|true|false)\s*(==|!=|<=|>=|<|>)\s+[A-Za-z_]',
masked):
v.append((_line(masked, m.start()), 'CODE-RULE-18 6.3.1',
'Yoda 条件(常量应在右): %s' % ' '.join(m.group(0).split())))
# CODE-RULE-19 (4.4.2) 函数定义左大括号必须与右括号同行
# CODE-RULE-20 (平台约束,2026-10-23) 禁止 C 库堆分配
# 出处**不在规范 V1.0**(其 §6.1 示例本身就写着 malloc/free,属通用 C 模板遗留,已同日在示例
# 下方加"本仓禁止"批注),而在 AGENTS.md「平台约束」+ docs/内存管理/RAM使用分析与优化方案.md:
# MicroLIB 的 C 堆由 startup_stm32f407xx.s 的 Heap_Size EQU 0x200 限定为 **512 B**,KB 级分配
# 必然返回 NULL,而**模拟器(宿主 CRT 堆)完全正常、照不出来** —— 典型"只有真机才炸",必须机器拦。
# 判据细节(三条都是踩过坑之后定的):
# - 大小写敏感:pvPortMalloc / vPortFree(唯一允许的配对)不会中招;
# - 词边界 \b:dhara_nand_is_free(、buf_alloc( 这类"同形后缀"的自有函数名不会误报;
# - 跑在 masked 上:注释与字符串字面量里提到 malloc 不报(规范示例就是这种命中)。
# 覆盖名比 AGENTS 列的五枚多带上 strndup / aligned_alloc —— 同族同类,漏一个就等于留个后门。
norm = path.replace('\\', '/')
allow = frozenset()
for suffix, names in HEAP_ALLOC_ALLOWLIST.items():
if norm == suffix or norm.endswith('/' + suffix):
allow = names
break
for m in re.finditer(r'\b(malloc|calloc|realloc|strdup|strndup|free|aligned_alloc)\s*\(', masked):
name = m.group(1)
if name in allow:
continue
v.append((_line(masked, m.start()), 'CODE-RULE-20 平台约束',
'禁止 C 库堆分配 %s()(MicroLIB C 堆仅 512 B;用 pvPortMalloc/vPortFree,'
'或把分配收进平台后端)' % name))
return v
def main():
failed = False
for path in sys.argv[1:]:
try:
v = check_file(path)
except OSError as e:
print('cannot read %s: %s' % (path, e))
failed = True
continue
if v:
failed = True
print('%s: %d 处规范违规' % (path, len(v)))
for ln, rule, msg in v:
print(' L%-4s [%s] %s' % (ln, rule, msg))
if failed:
sys.exit(1)
if __name__ == '__main__':
main()