github.com/tinygo-org/tinygo@v0.31.3-0.20240404173401-90b0bf646c27/builder/cc.go (about) 1 package builder 2 3 // This file implements a wrapper around the C compiler (Clang) which uses a 4 // build cache. 5 6 import ( 7 "crypto/sha512" 8 "encoding/hex" 9 "encoding/json" 10 "errors" 11 "fmt" 12 "io" 13 "io/fs" 14 "os" 15 "path/filepath" 16 "sort" 17 "strings" 18 "unicode" 19 20 "github.com/tinygo-org/tinygo/goenv" 21 "tinygo.org/x/go-llvm" 22 ) 23 24 // compileAndCacheCFile compiles a C or assembly file using a build cache. 25 // Compiling the same file again (if nothing changed, including included header 26 // files) the output is loaded from the build cache instead. 27 // 28 // Its operation is a bit complex (more complex than Go package build caching) 29 // because the list of file dependencies is only known after the file is 30 // compiled. However, luckily compilers have a flag to write a list of file 31 // dependencies in Makefile syntax which can be used for caching. 32 // 33 // Because of this complexity, every file has in fact two cached build outputs: 34 // the file itself, and the list of dependencies. Its operation is as follows: 35 // 36 // depfile = hash(path, compiler, cflags, ...) 37 // if depfile exists: 38 // outfile = hash of all files and depfile name 39 // if outfile exists: 40 // # cache hit 41 // return outfile 42 // # cache miss 43 // tmpfile = compile file 44 // read dependencies (side effect of compile) 45 // write depfile 46 // outfile = hash of all files and depfile name 47 // rename tmpfile to outfile 48 // 49 // There are a few edge cases that are not handled: 50 // - If a file is added to an include path, that file may be included instead of 51 // some other file. This would be fixed by also including lookup failures in the 52 // dependencies file, but I'm not aware of a compiler which does that. 53 // - The Makefile syntax that compilers output has issues, see readDepFile for 54 // details. 55 // - A header file may be changed to add/remove an include. This invalidates the 56 // depfile but without invalidating its name. For this reason, the depfile is 57 // written on each new compilation (even when it seems unnecessary). However, it 58 // could in rare cases lead to a stale file fetched from the cache. 59 func compileAndCacheCFile(abspath, tmpdir string, cflags []string, printCommands func(string, ...string)) (string, error) { 60 // Hash input file. 61 fileHash, err := hashFile(abspath) 62 if err != nil { 63 return "", err 64 } 65 66 // Acquire a lock (if supported). 67 unlock := lock(filepath.Join(goenv.Get("GOCACHE"), fileHash+".c.lock")) 68 defer unlock() 69 70 // Create cache key for the dependencies file. 71 buf, err := json.Marshal(struct { 72 Path string 73 Hash string 74 Flags []string 75 LLVMVersion string 76 }{ 77 Path: abspath, 78 Hash: fileHash, 79 Flags: cflags, 80 LLVMVersion: llvm.Version, 81 }) 82 if err != nil { 83 panic(err) // shouldn't happen 84 } 85 depfileNameHashBuf := sha512.Sum512_224(buf) 86 depfileNameHash := hex.EncodeToString(depfileNameHashBuf[:]) 87 88 // Load dependencies file, if possible. 89 depfileName := "dep-" + depfileNameHash + ".json" 90 depfileCachePath := filepath.Join(goenv.Get("GOCACHE"), depfileName) 91 depfileBuf, err := os.ReadFile(depfileCachePath) 92 var dependencies []string // sorted list of dependency paths 93 if err == nil { 94 // There is a dependency file, that's great! 95 // Parse it first. 96 err := json.Unmarshal(depfileBuf, &dependencies) 97 if err != nil { 98 return "", fmt.Errorf("could not parse dependencies JSON: %w", err) 99 } 100 101 // Obtain hashes of all the files listed as a dependency. 102 outpath, err := makeCFileCachePath(dependencies, depfileNameHash) 103 if err == nil { 104 if _, err := os.Stat(outpath); err == nil { 105 return outpath, nil 106 } else if !errors.Is(err, fs.ErrNotExist) { 107 return "", err 108 } 109 } 110 } else if !errors.Is(err, fs.ErrNotExist) { 111 // expected either nil or IsNotExist 112 return "", err 113 } 114 115 objTmpFile, err := os.CreateTemp(goenv.Get("GOCACHE"), "tmp-*.bc") 116 if err != nil { 117 return "", err 118 } 119 objTmpFile.Close() 120 depTmpFile, err := os.CreateTemp(tmpdir, "dep-*.d") 121 if err != nil { 122 return "", err 123 } 124 depTmpFile.Close() 125 flags := append([]string{}, cflags...) // copy cflags 126 flags = append(flags, "-MD", "-MV", "-MTdeps", "-MF", depTmpFile.Name(), "-flto=thin") // autogenerate dependencies 127 flags = append(flags, "-c", "-o", objTmpFile.Name(), abspath) 128 if strings.ToLower(filepath.Ext(abspath)) == ".s" { 129 // If this is an assembly file (.s or .S, lowercase or uppercase), then 130 // we'll need to add -Qunused-arguments because many parameters are 131 // relevant to C, not assembly. And with -Werror, having meaningless 132 // flags (for the assembler) is a compiler error. 133 flags = append(flags, "-Qunused-arguments") 134 } 135 if printCommands != nil { 136 printCommands("clang", flags...) 137 } 138 err = runCCompiler(flags...) 139 if err != nil { 140 return "", &commandError{"failed to build", abspath, err} 141 } 142 143 // Create sorted and uniqued slice of dependencies. 144 dependencyPaths, err := readDepFile(depTmpFile.Name()) 145 if err != nil { 146 return "", err 147 } 148 dependencyPaths = append(dependencyPaths, abspath) // necessary for .s files 149 dependencySet := make(map[string]struct{}, len(dependencyPaths)) 150 var dependencySlice []string 151 for _, path := range dependencyPaths { 152 if _, ok := dependencySet[path]; ok { 153 continue 154 } 155 dependencySet[path] = struct{}{} 156 dependencySlice = append(dependencySlice, path) 157 } 158 sort.Strings(dependencySlice) 159 160 // Write dependencies file. 161 f, err := os.CreateTemp(filepath.Dir(depfileCachePath), depfileName) 162 if err != nil { 163 return "", err 164 } 165 166 buf, err = json.MarshalIndent(dependencySlice, "", "\t") 167 if err != nil { 168 panic(err) // shouldn't happen 169 } 170 _, err = f.Write(buf) 171 if err != nil { 172 return "", err 173 } 174 err = f.Close() 175 if err != nil { 176 return "", err 177 } 178 err = os.Rename(f.Name(), depfileCachePath) 179 if err != nil { 180 return "", err 181 } 182 183 // Move temporary object file to final location. 184 outpath, err := makeCFileCachePath(dependencySlice, depfileNameHash) 185 if err != nil { 186 return "", err 187 } 188 err = os.Rename(objTmpFile.Name(), outpath) 189 if err != nil { 190 return "", err 191 } 192 193 return outpath, nil 194 } 195 196 // Create a cache path (a path in GOCACHE) to store the output of a compiler 197 // job. This path is based on the dep file name (which is a hash of metadata 198 // including compiler flags) and the hash of all input files in the paths slice. 199 func makeCFileCachePath(paths []string, depfileNameHash string) (string, error) { 200 // Hash all input files. 201 fileHashes := make(map[string]string, len(paths)) 202 for _, path := range paths { 203 hash, err := hashFile(path) 204 if err != nil { 205 return "", err 206 } 207 fileHashes[path] = hash 208 } 209 210 // Calculate a cache key based on the above hashes. 211 buf, err := json.Marshal(struct { 212 DepfileHash string 213 FileHashes map[string]string 214 }{ 215 DepfileHash: depfileNameHash, 216 FileHashes: fileHashes, 217 }) 218 if err != nil { 219 panic(err) // shouldn't happen 220 } 221 outFileNameBuf := sha512.Sum512_224(buf) 222 cacheKey := hex.EncodeToString(outFileNameBuf[:]) 223 224 outpath := filepath.Join(goenv.Get("GOCACHE"), "obj-"+cacheKey+".bc") 225 return outpath, nil 226 } 227 228 // hashFile hashes the given file path and returns the hash as a hex string. 229 func hashFile(path string) (string, error) { 230 f, err := os.Open(path) 231 if err != nil { 232 return "", fmt.Errorf("failed to hash file: %w", err) 233 } 234 defer f.Close() 235 fileHasher := sha512.New512_224() 236 _, err = io.Copy(fileHasher, f) 237 if err != nil { 238 return "", fmt.Errorf("failed to hash file: %w", err) 239 } 240 return hex.EncodeToString(fileHasher.Sum(nil)), nil 241 } 242 243 // readDepFile reads a dependency file in NMake (Visual Studio make) format. The 244 // file is assumed to have a single target named deps. 245 // 246 // There are roughly three make syntax variants: 247 // - BSD make, which doesn't support any escaping. This means that many special 248 // characters are not supported in file names. 249 // - GNU make, which supports escaping using a backslash but when it fails to 250 // find a file it tries to fall back with the literal path name (to match BSD 251 // make). 252 // - NMake (Visual Studio) and Jom, which simply quote the string if there are 253 // any weird characters. 254 // 255 // Clang supports two variants: a format that's a compromise between BSD and GNU 256 // make (and is buggy to match GCC which is equally buggy), and NMake/Jom, which 257 // is at least somewhat sane. This last format isn't perfect either: it does not 258 // correctly handle filenames with quote marks in them. Those are generally not 259 // allowed on Windows, but of course can be used on POSIX like systems. Still, 260 // it's the most sane of any of the formats so readDepFile will use that format. 261 func readDepFile(filename string) ([]string, error) { 262 buf, err := os.ReadFile(filename) 263 if err != nil { 264 return nil, err 265 } 266 if len(buf) == 0 { 267 return nil, nil 268 } 269 return parseDepFile(string(buf)) 270 } 271 272 func parseDepFile(s string) ([]string, error) { 273 // This function makes no attempt at parsing anything other than Clang -MD 274 // -MV output. 275 276 // For Windows: replace CRLF with LF to make the logic below simpler. 277 s = strings.ReplaceAll(s, "\r\n", "\n") 278 279 // Collapse all lines ending in a backslash. These backslashes are really 280 // just a way to continue a line without making very long lines. 281 s = strings.ReplaceAll(s, "\\\n", " ") 282 283 // Only use the first line, which is expected to begin with "deps:". 284 line := strings.SplitN(s, "\n", 2)[0] 285 if !strings.HasPrefix(line, "deps:") { 286 return nil, errors.New("readDepFile: expected 'deps:' prefix") 287 } 288 line = strings.TrimSpace(line[len("deps:"):]) 289 290 var deps []string 291 for line != "" { 292 if line[0] == '"' { 293 // File path is quoted. Path ends with double quote. 294 // This does not handle double quotes in path names, which is a 295 // problem on non-Windows systems. 296 line = line[1:] 297 end := strings.IndexByte(line, '"') 298 if end < 0 { 299 return nil, errors.New("readDepFile: path is incorrectly quoted") 300 } 301 dep := line[:end] 302 line = strings.TrimSpace(line[end+1:]) 303 deps = append(deps, dep) 304 } else { 305 // File path is not quoted. Path ends in space or EOL. 306 end := strings.IndexFunc(line, unicode.IsSpace) 307 if end < 0 { 308 // last dependency 309 deps = append(deps, line) 310 break 311 } 312 dep := line[:end] 313 line = strings.TrimSpace(line[end:]) 314 deps = append(deps, dep) 315 } 316 } 317 return deps, nil 318 }