check_public_headers.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. #!/usr/bin/env python
  2. #
  3. # Checks all public headers in IDF in the ci
  4. #
  5. # Copyright 2020 Espressif Systems (Shanghai) PTE LTD
  6. #
  7. # Licensed under the Apache License, Version 2.0 (the "License");
  8. # you may not use this file except in compliance with the License.
  9. # You may obtain a copy of the License at
  10. #
  11. # http://www.apache.org/licenses/LICENSE-2.0
  12. #
  13. # Unless required by applicable law or agreed to in writing, software
  14. # distributed under the License is distributed on an "AS IS" BASIS,
  15. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  16. # See the License for the specific language governing permissions and
  17. # limitations under the License.
  18. #
  19. from __future__ import print_function
  20. from __future__ import unicode_literals
  21. import re
  22. import os
  23. import subprocess
  24. import json
  25. import fnmatch
  26. import argparse
  27. import queue
  28. from threading import Thread, Event
  29. import tempfile
  30. from io import open
  31. class HeaderFailed(Exception):
  32. """Base header failure exeption"""
  33. pass
  34. class HeaderFailedSdkconfig(HeaderFailed):
  35. def __str__(self):
  36. return "Sdkconfig Error"
  37. class HeaderFailedBuildError(HeaderFailed):
  38. def __str__(self):
  39. return "Header Build Error"
  40. class HeaderFailedCppGuardMissing(HeaderFailed):
  41. def __str__(self):
  42. return "Header Missing C++ Guard"
  43. class HeaderFailedContainsCode(HeaderFailed):
  44. def __str__(self):
  45. return "Header Produced non-zero object"
  46. # Creates a temp file and returns both output as a string and a file name
  47. #
  48. def exec_cmd_to_temp_file(what, suffix=""):
  49. out_file = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
  50. rc, out, err = exec_cmd(what, out_file)
  51. with open(out_file.name, "r", encoding='utf-8') as f:
  52. out = f.read()
  53. return rc, out, err, out_file.name
  54. def exec_cmd(what, out_file=subprocess.PIPE):
  55. p = subprocess.Popen(what, stdin=subprocess.PIPE, stdout=out_file, stderr=subprocess.PIPE)
  56. output, err = p.communicate()
  57. rc = p.returncode
  58. output = output.decode('utf-8') if output is not None else None
  59. err = err.decode('utf-8') if err is not None else None
  60. return rc, output, err
  61. class PublicHeaderChecker:
  62. # Intermediate results
  63. COMPILE_ERR_REF_CONFIG_HDR_FAILED = 1 # -> Cannot compile and failed with injected SDKCONFIG #error (header FAILs)
  64. COMPILE_ERR_ERROR_MACRO_HDR_OK = 2 # -> Cannot compile, but failed with "#error" directive (header seems OK)
  65. COMPILE_ERR_HDR_FAILED = 3 # -> Cannot compile with another issue, logged if verbose (header FAILs)
  66. PREPROC_OUT_ZERO_HDR_OK = 4 # -> Both preprocessors produce zero out (header file is OK)
  67. PREPROC_OUT_SAME_HRD_FAILED = 5 # -> Both preprocessors produce the same, non-zero output (header file FAILs)
  68. PREPROC_OUT_DIFFERENT_WITH_EXT_C_HDR_OK = 6 # -> Both preprocessors produce different, non-zero output with extern "C" (header seems OK)
  69. PREPROC_OUT_DIFFERENT_NO_EXT_C_HDR_FAILED = 7 # -> Both preprocessors produce different, non-zero output without extern "C" (header fails)
  70. def log(self, message, debug=False):
  71. if self.verbose or debug:
  72. print(message)
  73. def __init__(self, verbose=False, jobs=1, prefix=None):
  74. self.gcc = "{}gcc".format(prefix)
  75. self.gpp = "{}g++".format(prefix)
  76. self.verbose = verbose
  77. self.jobs = jobs
  78. self.prefix = prefix
  79. self.extern_c = re.compile(r'extern "C"')
  80. self.error_macro = re.compile(r'#error')
  81. self.error_orphan_kconfig = re.compile(r"#error CONFIG_VARS_USED_WHILE_SDKCONFIG_NOT_INCLUDED")
  82. self.kconfig_macro = re.compile(r'\bCONFIG_[A-Z0-9_]+')
  83. self.assembly_nocode = r'^\s*(\.file|\.text|\.ident).*$'
  84. self.check_threads = []
  85. self.job_queue = queue.Queue()
  86. self.failed_queue = queue.Queue()
  87. self.terminate = Event()
  88. def __enter__(self):
  89. for i in range(self.jobs):
  90. t = Thread(target=self.check_headers, args=(i, ))
  91. self.check_threads.append(t)
  92. t.start()
  93. return self
  94. def __exit__(self, exc_type, exc_value, traceback):
  95. self.terminate.set()
  96. for t in self.check_threads:
  97. t.join()
  98. # thread function process incoming header file from a queue
  99. def check_headers(self, num):
  100. while not self.terminate.is_set():
  101. if not self.job_queue.empty():
  102. task = self.job_queue.get()
  103. if task is None:
  104. self.terminate.set()
  105. else:
  106. try:
  107. self.check_one_header(task, num)
  108. except HeaderFailed as e:
  109. self.failed_queue.put("{}: Failed! {}".format(task, e))
  110. except Exception as e:
  111. # Makes sure any unexpected exceptions causes the program to terminate
  112. self.failed_queue.put("{}: Failed! {}".format(task, e))
  113. self.terminate.set()
  114. raise
  115. def get_failed(self):
  116. return list(self.failed_queue.queue)
  117. def join(self):
  118. for t in self.check_threads:
  119. while t.isAlive and not self.terminate.is_set():
  120. t.join(1) # joins with timeout to respond to keyboard interrupt
  121. # Checks one header calling:
  122. # - preprocess_one_header() to test and compare preprocessor outputs
  123. # - check_no_code() to test if header contains some executable code
  124. # Procedure
  125. # 1) Preprocess the include file with C preprocessor and with CPP preprocessor
  126. # - Pass the test if the preprocessor outputs are the same and whitespaces only (#define only header)
  127. # - Fail the test if the preprocessor outputs are the same (but with some code)
  128. # - If outputs different, continue with 2)
  129. # 2) Strip out all include directives to generate "temp.h"
  130. # 3) Preprocess the temp.h the same way in (1)
  131. # - Pass the test if the preprocessor outputs are the same and whitespaces only (#include only header)
  132. # - Fail the test if the preprocessor outputs are the same (but with some code)
  133. # - If outputs different, pass the test
  134. # 4) If header passed the steps 1) and 3) test that it produced zero assembly code
  135. def check_one_header(self, header, num):
  136. res = self.preprocess_one_header(header, num)
  137. if res == self.COMPILE_ERR_REF_CONFIG_HDR_FAILED:
  138. raise HeaderFailedSdkconfig()
  139. elif res == self.COMPILE_ERR_ERROR_MACRO_HDR_OK:
  140. return self.compile_one_header(header)
  141. elif res == self.COMPILE_ERR_HDR_FAILED:
  142. raise HeaderFailedBuildError()
  143. elif res == self.PREPROC_OUT_ZERO_HDR_OK:
  144. return self.compile_one_header(header)
  145. elif res == self.PREPROC_OUT_SAME_HRD_FAILED:
  146. raise HeaderFailedCppGuardMissing()
  147. else:
  148. self.compile_one_header(header)
  149. temp_header = None
  150. try:
  151. _, _, _, temp_header = exec_cmd_to_temp_file(["sed", "/#include/d; /#error/d", header], suffix=".h")
  152. res = self.preprocess_one_header(temp_header, num, ignore_sdkconfig_issue=True)
  153. if res == self.PREPROC_OUT_SAME_HRD_FAILED:
  154. raise HeaderFailedCppGuardMissing()
  155. elif res == self.PREPROC_OUT_DIFFERENT_NO_EXT_C_HDR_FAILED:
  156. raise HeaderFailedCppGuardMissing()
  157. finally:
  158. if temp_header:
  159. os.unlink(temp_header)
  160. def compile_one_header(self, header):
  161. rc, out, err = exec_cmd([self.gcc, "-S", "-o-", "-include", header, self.main_c] + self.include_dir_flags)
  162. if rc == 0:
  163. if not re.sub(self.assembly_nocode, '', out, flags=re.M).isspace():
  164. raise HeaderFailedContainsCode()
  165. return # Header OK: produced zero code
  166. self.log("{}: FAILED: compilation issue".format(header), True)
  167. self.log(err, True)
  168. raise HeaderFailedBuildError()
  169. def preprocess_one_header(self, header, num, ignore_sdkconfig_issue=False):
  170. all_compilation_flags = ["-w", "-P", "-E", "-DESP_PLATFORM", "-include", header, self.main_c] + self.include_dir_flags
  171. if not ignore_sdkconfig_issue:
  172. # just strip commnets to check for CONFIG_... macros
  173. rc, out, err = exec_cmd([self.gcc, "-fpreprocessed", "-dD", "-P", "-E", header] + self.include_dir_flags)
  174. if re.search(self.kconfig_macro, out):
  175. # enable defined #error if sdkconfig.h not included
  176. all_compilation_flags.append("-DIDF_CHECK_SDKCONFIG_INCLUDED")
  177. try:
  178. # compile with C++, check for errors, outputs for a temp file
  179. rc, cpp_out, err, cpp_out_file = exec_cmd_to_temp_file([self.gpp, "--std=c++17"] + all_compilation_flags)
  180. if rc != 0:
  181. if re.search(self.error_macro, err):
  182. if re.search(self.error_orphan_kconfig, err):
  183. self.log("{}: CONFIG_VARS_USED_WHILE_SDKCONFIG_NOT_INCLUDED".format(header), True)
  184. return self.COMPILE_ERR_REF_CONFIG_HDR_FAILED
  185. self.log("{}: Error directive failure: OK".format(header))
  186. return self.COMPILE_ERR_ERROR_MACRO_HDR_OK
  187. self.log("{}: FAILED: compilation issue".format(header), True)
  188. self.log(err)
  189. return self.COMPILE_ERR_HDR_FAILED
  190. # compile with C compiler, outputs to another temp file
  191. rc, c99_out, err, c99_out_file = exec_cmd_to_temp_file([self.gcc, "--std=c99"] + all_compilation_flags)
  192. if rc != 0:
  193. self.log("{} FAILED should never happen".format(header))
  194. return self.COMPILE_ERR_HDR_FAILED
  195. # diff the two outputs
  196. rc, diff, err = exec_cmd(["diff", c99_out_file, cpp_out_file])
  197. if not diff or diff.isspace():
  198. if not cpp_out or cpp_out.isspace():
  199. self.log("{} The same, but empty out - OK".format(header))
  200. return self.PREPROC_OUT_ZERO_HDR_OK
  201. self.log("{} FAILED C and C++ preprocessor output is the same!".format(header), True)
  202. return self.PREPROC_OUT_SAME_HRD_FAILED
  203. if re.search(self.extern_c, diff):
  204. self.log("{} extern C present - OK".format(header))
  205. return self.PREPROC_OUT_DIFFERENT_WITH_EXT_C_HDR_OK
  206. self.log("{} Different but no extern C - FAILED".format(header), True)
  207. return self.PREPROC_OUT_DIFFERENT_NO_EXT_C_HDR_FAILED
  208. finally:
  209. os.unlink(cpp_out_file)
  210. try:
  211. os.unlink(c99_out_file)
  212. except Exception:
  213. pass
  214. # Get compilation data from an example to list all public header files
  215. def list_public_headers(self, ignore_dirs, ignore_files, only_dir=None):
  216. idf_path = os.getenv('IDF_PATH')
  217. project_dir = os.path.join(idf_path, "examples", "get-started", "blink")
  218. subprocess.check_call(["idf.py", "reconfigure"], cwd=project_dir)
  219. build_commands_json = os.path.join(project_dir, "build", "compile_commands.json")
  220. with open(build_commands_json, "r", encoding='utf-8') as f:
  221. build_command = json.load(f)[0]["command"].split()
  222. include_dir_flags = []
  223. include_dirs = []
  224. # process compilation flags (includes and defines)
  225. for item in build_command:
  226. if item.startswith("-I"):
  227. include_dir_flags.append(item)
  228. if "components" in item:
  229. include_dirs.append(item[2:]) # Removing the leading "-I"
  230. if item.startswith("-D"):
  231. include_dir_flags.append(item.replace('\\','')) # removes escaped quotes, eg: -DMBEDTLS_CONFIG_FILE=\\\"mbedtls/esp_config.h\\\"
  232. include_dir_flags.append("-I" + os.path.join(project_dir, "build", "config"))
  233. sdkconfig_h = os.path.join(project_dir, "build", "config", "sdkconfig.h")
  234. # prepares a main_c file for easier sdkconfig checks and avoid compilers warning when compiling headers directly
  235. with open(sdkconfig_h, "a") as f:
  236. f.write("#define IDF_SDKCONFIG_INCLUDED")
  237. main_c = os.path.join(project_dir, "build", "compile.c")
  238. with open(main_c, "w") as f:
  239. f.write("#if defined(IDF_CHECK_SDKCONFIG_INCLUDED) && ! defined(IDF_SDKCONFIG_INCLUDED)\n"
  240. "#error CONFIG_VARS_USED_WHILE_SDKCONFIG_NOT_INCLUDED\n"
  241. "#endif")
  242. # processes public include dirs, removing ignored files
  243. all_include_files = []
  244. files_to_check = []
  245. for d in include_dirs:
  246. if only_dir is not None and not os.path.relpath(d, idf_path).startswith(only_dir):
  247. self.log('{} - directory ignored (not in "{}")'.format(d, only_dir))
  248. continue
  249. if os.path.relpath(d, idf_path).startswith(tuple(ignore_dirs)):
  250. self.log("{} - directory ignored".format(d))
  251. continue
  252. for root, dirnames, filenames in os.walk(d):
  253. for filename in fnmatch.filter(filenames, '*.h'):
  254. all_include_files.append(os.path.join(root, filename))
  255. self.main_c = main_c
  256. self.include_dir_flags = include_dir_flags
  257. ignore_files = set(ignore_files)
  258. # processes public include files, removing ignored files
  259. for f in all_include_files:
  260. rel_path_file = os.path.relpath(f, idf_path)
  261. if any([os.path.commonprefix([d, rel_path_file]) == d for d in ignore_dirs]):
  262. self.log("{} - file ignored (inside ignore dir)".format(f))
  263. continue
  264. if rel_path_file in ignore_files:
  265. self.log("{} - file ignored".format(f))
  266. continue
  267. files_to_check.append(f)
  268. # removes duplicates and places headers to a work queue
  269. for f in set(files_to_check):
  270. self.job_queue.put(f)
  271. self.job_queue.put(None) # to indicate the last job
  272. def check_all_headers():
  273. parser = argparse.ArgumentParser("Public header checker file")
  274. parser.add_argument("--verbose", "-v", help="enables verbose mode", action="store_true")
  275. parser.add_argument("--jobs", "-j", help="number of jobs to run checker", default=1, type=int)
  276. parser.add_argument("--prefix", "-p", help="compiler prefix", default="xtensa-esp32-elf-", type=str)
  277. parser.add_argument("--exclude-file", "-e", help="exception file", default="check_public_headers_exceptions.txt", type=str)
  278. parser.add_argument("--only-dir", "-d", help="reduce the analysis to this directory only", default=None, type=str)
  279. args = parser.parse_args()
  280. # process excluded files and dirs
  281. exclude_file = os.path.join(os.path.dirname(__file__), args.exclude_file)
  282. with open(exclude_file, "r", encoding='utf-8') as f:
  283. lines = [line.rstrip() for line in f]
  284. ignore_files = []
  285. ignore_dirs = []
  286. for line in lines:
  287. if not line or line.isspace() or line.startswith("#"):
  288. continue
  289. if os.path.isdir(line):
  290. ignore_dirs.append(line)
  291. else:
  292. ignore_files.append(line)
  293. # start header check
  294. with PublicHeaderChecker(args.verbose, args.jobs, args.prefix) as header_check:
  295. header_check.list_public_headers(ignore_dirs, ignore_files, only_dir=args.only_dir)
  296. try:
  297. header_check.join()
  298. failures = header_check.get_failed()
  299. if len(failures) > 0:
  300. for failed in failures:
  301. print(failed)
  302. exit(1)
  303. print("No errors found")
  304. except KeyboardInterrupt:
  305. print("Keyboard interrupt")
  306. if __name__ == '__main__':
  307. check_all_headers()